Merge pull request #2337 from coreos-inc/new-trigger-ux
Implement new create and manager trigger UI
This commit is contained in:
commit
aa2f88d321
79 changed files with 4038 additions and 1555 deletions
|
@ -1,7 +1,83 @@
|
|||
from abc import ABCMeta, abstractmethod
|
||||
from jsonschema import validate
|
||||
from six import add_metaclass
|
||||
|
||||
from endpoints.building import PreparedBuild
|
||||
from data import model
|
||||
from buildtrigger.triggerutil import get_trigger_config, InvalidServiceException
|
||||
from jsonschema import validate
|
||||
|
||||
NAMESPACES_SCHEMA = {
|
||||
'type': 'array',
|
||||
'items': {
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'personal': {
|
||||
'type': 'boolean',
|
||||
'description': 'True if the namespace is the user\'s personal namespace',
|
||||
},
|
||||
'score': {
|
||||
'type': 'number',
|
||||
'description': 'Score of the relevance of the namespace',
|
||||
},
|
||||
'avatar_url': {
|
||||
'type': 'string',
|
||||
'description': 'URL of the avatar for this namespace',
|
||||
},
|
||||
'url': {
|
||||
'type': 'string',
|
||||
'description': 'URL of the website to view the namespace',
|
||||
},
|
||||
'id': {
|
||||
'type': 'string',
|
||||
'description': 'Trigger-internal ID of the namespace',
|
||||
},
|
||||
'title': {
|
||||
'type': 'string',
|
||||
'description': 'Human-readable title of the namespace',
|
||||
},
|
||||
},
|
||||
'required': ['personal', 'score', 'avatar_url', 'url', 'id', 'title'],
|
||||
},
|
||||
}
|
||||
|
||||
BUILD_SOURCES_SCHEMA = {
|
||||
'type': 'array',
|
||||
'items': {
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'name': {
|
||||
'type': 'string',
|
||||
'description': 'The name of the repository, without its namespace',
|
||||
},
|
||||
'full_name': {
|
||||
'type': 'string',
|
||||
'description': 'The name of the repository, with its namespace',
|
||||
},
|
||||
'description': {
|
||||
'type': 'string',
|
||||
'description': 'The description of the repository. May be an empty string',
|
||||
},
|
||||
'last_updated': {
|
||||
'type': 'number',
|
||||
'description': 'The date/time when the repository was last updated, since epoch in UTC',
|
||||
},
|
||||
'url': {
|
||||
'type': 'string',
|
||||
'description': 'The URL at which to view the repository in the browser',
|
||||
},
|
||||
'has_admin_permissions': {
|
||||
'type': 'boolean',
|
||||
'description': 'True if the current user has admin permissions on the repository',
|
||||
},
|
||||
'private': {
|
||||
'type': 'boolean',
|
||||
'description': 'True if the repository is private',
|
||||
},
|
||||
},
|
||||
'required': ['name', 'full_name', 'description', 'last_updated', 'url',
|
||||
'has_admin_permissions', 'private'],
|
||||
},
|
||||
}
|
||||
|
||||
METADATA_SCHEMA = {
|
||||
'type': 'object',
|
||||
|
@ -18,7 +94,7 @@ METADATA_SCHEMA = {
|
|||
'ref': {
|
||||
'type': 'string',
|
||||
'description': 'git reference for a git commit',
|
||||
'pattern': '^refs\/(heads|tags|remotes)\/(.+)$',
|
||||
'pattern': r'^refs\/(heads|tags|remotes)\/(.+)$',
|
||||
},
|
||||
'default_branch': {
|
||||
'type': 'string',
|
||||
|
@ -86,6 +162,7 @@ METADATA_SCHEMA = {
|
|||
}
|
||||
|
||||
|
||||
@add_metaclass(ABCMeta)
|
||||
class BuildTriggerHandler(object):
|
||||
def __init__(self, trigger, override_config=None):
|
||||
self.trigger = trigger
|
||||
|
@ -96,72 +173,90 @@ class BuildTriggerHandler(object):
|
|||
""" Returns the auth token for the trigger. """
|
||||
return self.trigger.auth_token
|
||||
|
||||
@abstractmethod
|
||||
def load_dockerfile_contents(self):
|
||||
"""
|
||||
Loads the Dockerfile found for the trigger's config and returns them or None if none could
|
||||
be found/loaded.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
pass
|
||||
|
||||
def list_build_sources(self):
|
||||
@abstractmethod
|
||||
def list_build_source_namespaces(self):
|
||||
"""
|
||||
Take the auth information for the specific trigger type and load the
|
||||
list of build sources(repositories).
|
||||
list of namespaces that can contain build sources.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def list_build_sources_for_namespace(self, namespace):
|
||||
"""
|
||||
Take the auth information for the specific trigger type and load the
|
||||
list of repositories under the given namespace.
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def list_build_subdirs(self):
|
||||
"""
|
||||
Take the auth information and the specified config so far and list all of
|
||||
the possible subdirs containing dockerfiles.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
pass
|
||||
|
||||
def handle_trigger_request(self):
|
||||
@abstractmethod
|
||||
def handle_trigger_request(self, request):
|
||||
"""
|
||||
Transform the incoming request data into a set of actions. Returns a PreparedBuild.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def is_active(self):
|
||||
"""
|
||||
Returns True if the current build trigger is active. Inactive means further
|
||||
setup is needed.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def activate(self, standard_webhook_url):
|
||||
"""
|
||||
Activates the trigger for the service, with the given new configuration.
|
||||
Returns new public and private config that should be stored if successful.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def deactivate(self):
|
||||
"""
|
||||
Deactivates the trigger for the service, removing any hooks installed in
|
||||
the remote service. Returns the new config that should be stored if this
|
||||
trigger is going to be re-activated.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def manual_start(self, run_parameters=None):
|
||||
"""
|
||||
Manually creates a repository build for this trigger. Returns a PreparedBuild.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def list_field_values(self, field_name, limit=None):
|
||||
"""
|
||||
Lists all values for the given custom trigger field. For example, a trigger might have a
|
||||
field named "branches", and this method would return all branches.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_repository_url(self):
|
||||
""" Returns the URL of the current trigger's repository. Note that this operation
|
||||
can be called in a loop, so it should be as fast as possible. """
|
||||
raise NotImplementedError
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
def service_name(cls):
|
||||
|
@ -220,3 +315,14 @@ class BuildTriggerHandler(object):
|
|||
prepared.tags = [commit_sha[:7]]
|
||||
|
||||
return prepared
|
||||
|
||||
@classmethod
|
||||
def build_sources_response(cls, sources):
|
||||
validate(sources, BUILD_SOURCES_SCHEMA)
|
||||
return sources
|
||||
|
||||
@classmethod
|
||||
def build_namespaces_response(cls, namespaces_dict):
|
||||
namespaces = list(namespaces_dict.values())
|
||||
validate(namespaces, NAMESPACES_SCHEMA)
|
||||
return namespaces
|
||||
|
|
|
@ -1,6 +1,10 @@
|
|||
import logging
|
||||
import re
|
||||
|
||||
from calendar import timegm
|
||||
|
||||
import dateutil.parser
|
||||
|
||||
from jsonschema import validate
|
||||
from buildtrigger.triggerutil import (RepositoryReadException, TriggerActivationException,
|
||||
TriggerDeactivationException, TriggerStartException,
|
||||
|
@ -31,7 +35,7 @@ BITBUCKET_WEBHOOK_PAYLOAD_SCHEMA = {
|
|||
},
|
||||
},
|
||||
'required': ['full_name'],
|
||||
},
|
||||
}, # /Repository
|
||||
'push': {
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
|
@ -87,10 +91,10 @@ BITBUCKET_WEBHOOK_PAYLOAD_SCHEMA = {
|
|||
},
|
||||
},
|
||||
'required': ['html', 'avatar'],
|
||||
},
|
||||
}, # /User
|
||||
},
|
||||
'required': ['username'],
|
||||
},
|
||||
}, # /Author
|
||||
},
|
||||
},
|
||||
'links': {
|
||||
|
@ -107,19 +111,19 @@ BITBUCKET_WEBHOOK_PAYLOAD_SCHEMA = {
|
|||
},
|
||||
},
|
||||
'required': ['html'],
|
||||
},
|
||||
}, # /Links
|
||||
},
|
||||
'required': ['hash', 'message', 'date'],
|
||||
},
|
||||
}, # /Target
|
||||
},
|
||||
'required': ['target'],
|
||||
},
|
||||
'required': ['name', 'target'],
|
||||
}, # /New
|
||||
},
|
||||
},
|
||||
},
|
||||
}, # /Changes item
|
||||
}, # /Changes
|
||||
},
|
||||
'required': ['changes'],
|
||||
},
|
||||
}, # / Push
|
||||
},
|
||||
'actor': {
|
||||
'type': 'object',
|
||||
|
@ -153,9 +157,9 @@ BITBUCKET_WEBHOOK_PAYLOAD_SCHEMA = {
|
|||
},
|
||||
},
|
||||
'required': ['username'],
|
||||
},
|
||||
}, # /Actor
|
||||
'required': ['push', 'repository'],
|
||||
}
|
||||
} # /Root
|
||||
|
||||
BITBUCKET_COMMIT_INFO_SCHEMA = {
|
||||
'type': 'object',
|
||||
|
@ -217,7 +221,8 @@ def get_transformed_webhook_payload(bb_payload, default_branch=None):
|
|||
try:
|
||||
validate(bb_payload, BITBUCKET_WEBHOOK_PAYLOAD_SCHEMA)
|
||||
except Exception as exc:
|
||||
logger.exception('Exception when validating Bitbucket webhook payload: %s from %s', exc.message, bb_payload)
|
||||
logger.exception('Exception when validating Bitbucket webhook payload: %s from %s', exc.message,
|
||||
bb_payload)
|
||||
raise InvalidPayloadException(exc.message)
|
||||
|
||||
payload = JSONPathDict(bb_payload)
|
||||
|
@ -225,8 +230,8 @@ def get_transformed_webhook_payload(bb_payload, default_branch=None):
|
|||
if not change:
|
||||
return None
|
||||
|
||||
ref = ('refs/heads/' + change['name'] if change['type'] == 'branch'
|
||||
else 'refs/tags/' + change['name'])
|
||||
is_branch = change['type'] == 'branch'
|
||||
ref = 'refs/heads/' + change['name'] if is_branch else 'refs/tags/' + change['name']
|
||||
|
||||
repository_name = payload['repository.full_name']
|
||||
target = change['target']
|
||||
|
@ -237,7 +242,7 @@ def get_transformed_webhook_payload(bb_payload, default_branch=None):
|
|||
config['default_branch'] = default_branch
|
||||
config['git_url'] = 'git@bitbucket.org:%s.git' % repository_name
|
||||
|
||||
config['commit_info.url'] = target['links.html.href']
|
||||
config['commit_info.url'] = target['links.html.href'] or ''
|
||||
config['commit_info.message'] = target['message']
|
||||
config['commit_info.date'] = target['date']
|
||||
|
||||
|
@ -390,7 +395,7 @@ class BitbucketBuildTrigger(BuildTriggerHandler):
|
|||
|
||||
return config
|
||||
|
||||
def list_build_sources(self):
|
||||
def list_build_source_namespaces(self):
|
||||
bitbucket_client = self._get_authorized_client()
|
||||
(result, data, err_msg) = bitbucket_client.get_visible_repositories()
|
||||
if not result:
|
||||
|
@ -398,22 +403,42 @@ class BitbucketBuildTrigger(BuildTriggerHandler):
|
|||
|
||||
namespaces = {}
|
||||
for repo in data:
|
||||
if not repo['scm'] == 'git':
|
||||
continue
|
||||
|
||||
owner = repo['owner']
|
||||
if not owner in namespaces:
|
||||
if owner in namespaces:
|
||||
namespaces[owner]['score'] = namespaces[owner]['score'] + 1
|
||||
else:
|
||||
namespaces[owner] = {
|
||||
'personal': owner == self.config.get('username'),
|
||||
'repos': [],
|
||||
'info': {
|
||||
'name': owner
|
||||
}
|
||||
'id': owner,
|
||||
'title': owner,
|
||||
'avatar_url': repo['logo'],
|
||||
'url': 'https://bitbucket.org/%s' % (owner),
|
||||
'score': 1,
|
||||
}
|
||||
|
||||
namespaces[owner]['repos'].append(owner + '/' + repo['slug'])
|
||||
return BuildTriggerHandler.build_namespaces_response(namespaces)
|
||||
|
||||
return namespaces.values()
|
||||
def list_build_sources_for_namespace(self, namespace):
|
||||
def repo_view(repo):
|
||||
last_modified = dateutil.parser.parse(repo['utc_last_updated'])
|
||||
|
||||
return {
|
||||
'name': repo['slug'],
|
||||
'full_name': '%s/%s' % (repo['owner'], repo['slug']),
|
||||
'description': repo['description'] or '',
|
||||
'last_updated': timegm(last_modified.utctimetuple()),
|
||||
'url': 'https://bitbucket.org/%s/%s' % (repo['owner'], repo['slug']),
|
||||
'has_admin_permissions': repo['read_only'] is False,
|
||||
'private': repo['is_private'],
|
||||
}
|
||||
|
||||
bitbucket_client = self._get_authorized_client()
|
||||
(result, data, err_msg) = bitbucket_client.get_visible_repositories()
|
||||
if not result:
|
||||
raise RepositoryReadException('Could not read repository list: ' + err_msg)
|
||||
|
||||
repos = [repo_view(repo) for repo in data if repo['owner'] == namespace]
|
||||
return BuildTriggerHandler.build_sources_response(repos)
|
||||
|
||||
def list_build_subdirs(self):
|
||||
config = self.config
|
||||
|
@ -431,7 +456,7 @@ class BitbucketBuildTrigger(BuildTriggerHandler):
|
|||
|
||||
files = set([f['path'] for f in data['files']])
|
||||
if 'Dockerfile' in files:
|
||||
return ['/']
|
||||
return ['']
|
||||
|
||||
return []
|
||||
|
||||
|
@ -441,7 +466,7 @@ class BitbucketBuildTrigger(BuildTriggerHandler):
|
|||
|
||||
(result, data, err_msg) = repository.get_raw_path_contents(path, revision='master')
|
||||
if not result:
|
||||
raise RepositoryReadException(err_msg)
|
||||
return None
|
||||
|
||||
return data
|
||||
|
||||
|
@ -518,7 +543,7 @@ class BitbucketBuildTrigger(BuildTriggerHandler):
|
|||
# Lookup the commit SHA for the branch.
|
||||
(result, data, _) = repository.get_branch(branch_name)
|
||||
if not result:
|
||||
raise TriggerStartException('Could not find branch commit SHA')
|
||||
raise TriggerStartException('Could not find branch in repository')
|
||||
|
||||
return data['target']['hash']
|
||||
|
||||
|
@ -526,7 +551,7 @@ class BitbucketBuildTrigger(BuildTriggerHandler):
|
|||
# Lookup the commit SHA for the tag.
|
||||
(result, data, _) = repository.get_tag(tag_name)
|
||||
if not result:
|
||||
raise TriggerStartException('Could not find tag commit SHA')
|
||||
raise TriggerStartException('Could not find tag in repository')
|
||||
|
||||
return data['target']['hash']
|
||||
|
||||
|
|
|
@ -16,9 +16,6 @@ from buildtrigger.bitbuckethandler import (BITBUCKET_WEBHOOK_PAYLOAD_SCHEMA as b
|
|||
from buildtrigger.githubhandler import (GITHUB_WEBHOOK_PAYLOAD_SCHEMA as gh_schema,
|
||||
get_transformed_webhook_payload as gh_payload)
|
||||
|
||||
from buildtrigger.bitbuckethandler import (BITBUCKET_WEBHOOK_PAYLOAD_SCHEMA as bb_schema,
|
||||
get_transformed_webhook_payload as bb_payload)
|
||||
|
||||
from buildtrigger.gitlabhandler import (GITLAB_WEBHOOK_PAYLOAD_SCHEMA as gl_schema,
|
||||
get_transformed_webhook_payload as gl_payload)
|
||||
|
||||
|
@ -162,7 +159,7 @@ class CustomBuildTrigger(BuildTriggerHandler):
|
|||
def handle_trigger_request(self, request):
|
||||
payload = request.data
|
||||
if not payload:
|
||||
raise InvalidPayloadException()
|
||||
raise InvalidPayloadException('Missing expected payload')
|
||||
|
||||
logger.debug('Payload %s', payload)
|
||||
|
||||
|
@ -186,7 +183,10 @@ class CustomBuildTrigger(BuildTriggerHandler):
|
|||
'git_url': config['build_source'],
|
||||
}
|
||||
|
||||
return self.prepare_build(metadata, is_manual=True)
|
||||
try:
|
||||
return self.prepare_build(metadata, is_manual=True)
|
||||
except ValidationError as ve:
|
||||
raise TriggerStartException(ve.message)
|
||||
|
||||
def activate(self, standard_webhook_url):
|
||||
config = self.config
|
||||
|
@ -212,3 +212,18 @@ class CustomBuildTrigger(BuildTriggerHandler):
|
|||
|
||||
def get_repository_url(self):
|
||||
return None
|
||||
|
||||
def list_build_source_namespaces(self):
|
||||
raise NotImplementedError
|
||||
|
||||
def list_build_sources_for_namespace(self, namespace):
|
||||
raise NotImplementedError
|
||||
|
||||
def list_build_subdirs(self):
|
||||
raise NotImplementedError
|
||||
|
||||
def list_field_values(self, field_name, limit=None):
|
||||
raise NotImplementedError
|
||||
|
||||
def load_dockerfile_contents(self):
|
||||
raise NotImplementedError
|
||||
|
|
|
@ -2,11 +2,13 @@ import logging
|
|||
import os.path
|
||||
import base64
|
||||
|
||||
from calendar import timegm
|
||||
from functools import wraps
|
||||
from ssl import SSLError
|
||||
|
||||
from github import (Github, UnknownObjectException, GithubException,
|
||||
BadCredentialsException as GitHubBadCredentialsException)
|
||||
|
||||
from jsonschema import validate
|
||||
|
||||
from app import app, github_trigger
|
||||
|
@ -16,7 +18,6 @@ from buildtrigger.triggerutil import (RepositoryReadException, TriggerActivation
|
|||
SkipRequestException, InvalidPayloadException,
|
||||
determine_build_ref, raise_if_skipped_build,
|
||||
find_matching_branches)
|
||||
|
||||
from buildtrigger.basehandler import BuildTriggerHandler
|
||||
from endpoints.exception import ExternalServiceError
|
||||
from util.security.ssh import generate_ssh_keypair
|
||||
|
@ -260,68 +261,76 @@ class GithubBuildTrigger(BuildTriggerHandler):
|
|||
raise TriggerDeactivationException(msg)
|
||||
|
||||
# Remove the webhook.
|
||||
try:
|
||||
hook = repo.get_hook(config['hook_id'])
|
||||
hook.delete()
|
||||
except GithubException as ghe:
|
||||
default_msg = 'Unable to remove hook: %s' % config['hook_id']
|
||||
msg = GithubBuildTrigger._get_error_message(ghe, default_msg)
|
||||
raise TriggerDeactivationException(msg)
|
||||
if 'hook_id' in config:
|
||||
try:
|
||||
hook = repo.get_hook(config['hook_id'])
|
||||
hook.delete()
|
||||
except GithubException as ghe:
|
||||
default_msg = 'Unable to remove hook: %s' % config['hook_id']
|
||||
msg = GithubBuildTrigger._get_error_message(ghe, default_msg)
|
||||
raise TriggerDeactivationException(msg)
|
||||
|
||||
config.pop('hook_id', None)
|
||||
self.config = config
|
||||
return config
|
||||
|
||||
@_catch_ssl_errors
|
||||
def list_build_sources(self):
|
||||
def list_build_source_namespaces(self):
|
||||
gh_client = self._get_client()
|
||||
usr = gh_client.get_user()
|
||||
|
||||
try:
|
||||
repos = usr.get_repos()
|
||||
except GithubException:
|
||||
raise RepositoryReadException('Unable to list user repositories')
|
||||
|
||||
# Build the full set of namespaces for the user, starting with their own.
|
||||
namespaces = {}
|
||||
has_non_personal = False
|
||||
namespaces[usr.login] = {
|
||||
'personal': True,
|
||||
'id': usr.login,
|
||||
'title': usr.name or usr.login,
|
||||
'avatar_url': usr.avatar_url,
|
||||
'url': usr.html_url,
|
||||
'score': usr.plan.private_repos if usr.plan else 0,
|
||||
}
|
||||
|
||||
for repository in repos:
|
||||
namespace = repository.owner.login
|
||||
if not namespace in namespaces:
|
||||
is_personal_repo = namespace == usr.login
|
||||
namespaces[namespace] = {
|
||||
'personal': is_personal_repo,
|
||||
'repos': [],
|
||||
'info': {
|
||||
'name': namespace,
|
||||
'avatar_url': repository.owner.avatar_url
|
||||
}
|
||||
}
|
||||
for org in usr.get_orgs():
|
||||
namespaces[org.name] = {
|
||||
'personal': False,
|
||||
'id': org.login,
|
||||
'title': org.name or org.login,
|
||||
'avatar_url': org.avatar_url,
|
||||
'url': org.html_url,
|
||||
'score': org.plan.private_repos if org.plan else 0,
|
||||
}
|
||||
|
||||
if not is_personal_repo:
|
||||
has_non_personal = True
|
||||
return BuildTriggerHandler.build_namespaces_response(namespaces)
|
||||
|
||||
namespaces[namespace]['repos'].append(repository.full_name)
|
||||
@_catch_ssl_errors
|
||||
def list_build_sources_for_namespace(self, namespace):
|
||||
def repo_view(repo):
|
||||
return {
|
||||
'name': repo.name,
|
||||
'full_name': repo.full_name,
|
||||
'description': repo.description or '',
|
||||
'last_updated': timegm(repo.pushed_at.utctimetuple()),
|
||||
'url': repo.html_url,
|
||||
'has_admin_permissions': repo.permissions.admin,
|
||||
'private': repo.private,
|
||||
}
|
||||
|
||||
# In older versions of GitHub Enterprise, the get_repos call above does not
|
||||
# return any non-personal repositories. In that case, we need to lookup the
|
||||
# repositories manually.
|
||||
# TODO: Remove this once we no longer support GHE versions <= 2.1
|
||||
if not has_non_personal:
|
||||
for org in usr.get_orgs():
|
||||
repo_list = [repo.full_name for repo in org.get_repos(type='member')]
|
||||
namespaces[org.name] = {
|
||||
'personal': False,
|
||||
'repos': repo_list,
|
||||
'info': {
|
||||
'name': org.name or org.login,
|
||||
'avatar_url': org.avatar_url
|
||||
}
|
||||
}
|
||||
gh_client = self._get_client()
|
||||
usr = gh_client.get_user()
|
||||
if namespace == usr.login:
|
||||
repos = [repo_view(repo) for repo in usr.get_repos() if repo.owner.login == namespace]
|
||||
return BuildTriggerHandler.build_sources_response(repos)
|
||||
|
||||
try:
|
||||
org = gh_client.get_organization(namespace)
|
||||
if org is None:
|
||||
return []
|
||||
except GithubException:
|
||||
return []
|
||||
|
||||
repos = [repo_view(repo) for repo in org.get_repos(type='member')]
|
||||
return BuildTriggerHandler.build_sources_response(repos)
|
||||
|
||||
entries = list(namespaces.values())
|
||||
entries.sort(key=lambda e: e['info']['name'])
|
||||
return entries
|
||||
|
||||
@_catch_ssl_errors
|
||||
def list_build_subdirs(self):
|
||||
|
@ -353,24 +362,28 @@ class GithubBuildTrigger(BuildTriggerHandler):
|
|||
def load_dockerfile_contents(self):
|
||||
config = self.config
|
||||
gh_client = self._get_client()
|
||||
|
||||
source = config['build_source']
|
||||
path = self.get_dockerfile_path()
|
||||
|
||||
try:
|
||||
repo = gh_client.get_repo(source)
|
||||
file_info = repo.get_file_contents(path)
|
||||
if file_info is None:
|
||||
return None
|
||||
|
||||
content = file_info.content
|
||||
if file_info.encoding == 'base64':
|
||||
content = base64.b64decode(content)
|
||||
return content
|
||||
|
||||
except GithubException as ghe:
|
||||
message = ghe.data.get('message', 'Unable to read Dockerfile: %s' % source)
|
||||
message = ghe.data.get('message', 'Unable to list contents of repository: %s' % source)
|
||||
raise RepositoryReadException(message)
|
||||
|
||||
path = self.get_dockerfile_path()
|
||||
try:
|
||||
file_info = repo.get_file_contents(path)
|
||||
except GithubException as ghe:
|
||||
return None
|
||||
|
||||
if file_info is None:
|
||||
return None
|
||||
|
||||
content = file_info.content
|
||||
if file_info.encoding == 'base64':
|
||||
content = base64.b64decode(content)
|
||||
return content
|
||||
|
||||
@_catch_ssl_errors
|
||||
def list_field_values(self, field_name, limit=None):
|
||||
if field_name == 'refs':
|
||||
|
@ -478,8 +491,11 @@ class GithubBuildTrigger(BuildTriggerHandler):
|
|||
raise TriggerStartException(msg)
|
||||
|
||||
def get_branch_sha(branch_name):
|
||||
branch = repo.get_branch(branch_name)
|
||||
return branch.commit.sha
|
||||
try:
|
||||
branch = repo.get_branch(branch_name)
|
||||
return branch.commit.sha
|
||||
except GithubException:
|
||||
raise TriggerStartException('Could not find branch in repository')
|
||||
|
||||
def get_tag_sha(tag_name):
|
||||
tags = {tag.name: tag for tag in repo.get_tags()}
|
||||
|
@ -514,9 +530,21 @@ class GithubBuildTrigger(BuildTriggerHandler):
|
|||
|
||||
# This is for GitHub's probing/testing.
|
||||
if 'zen' in payload:
|
||||
raise ValidationRequestException()
|
||||
raise SkipRequestException()
|
||||
|
||||
# Lookup the default branch for the repository.
|
||||
if 'repository' not in payload:
|
||||
raise ValidationRequestException("Missing 'repository' on request")
|
||||
|
||||
if 'owner' not in payload['repository']:
|
||||
raise ValidationRequestException("Missing 'owner' on repository")
|
||||
|
||||
if 'name' not in payload['repository']['owner']:
|
||||
raise ValidationRequestException("Missing owner 'name' on repository")
|
||||
|
||||
if 'name' not in payload['repository']:
|
||||
raise ValidationRequestException("Missing 'name' on repository")
|
||||
|
||||
default_branch = None
|
||||
lookup_user = None
|
||||
try:
|
||||
|
@ -535,7 +563,7 @@ class GithubBuildTrigger(BuildTriggerHandler):
|
|||
|
||||
logger.debug('GitHub trigger payload %s', payload)
|
||||
metadata = get_transformed_webhook_payload(payload, default_branch=default_branch,
|
||||
lookup_user=lookup_user)
|
||||
lookup_user=lookup_user)
|
||||
prepared = self.prepare_build(metadata)
|
||||
|
||||
# Check if we should skip this build.
|
||||
|
|
|
@ -1,6 +1,10 @@
|
|||
import logging
|
||||
|
||||
from calendar import timegm
|
||||
from functools import wraps
|
||||
|
||||
import dateutil.parser
|
||||
|
||||
from app import app, gitlab_trigger
|
||||
|
||||
from jsonschema import validate
|
||||
|
@ -44,6 +48,9 @@ GITLAB_WEBHOOK_PAYLOAD_SCHEMA = {
|
|||
'items': {
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'id': {
|
||||
'type': 'string',
|
||||
},
|
||||
'url': {
|
||||
'type': 'string',
|
||||
},
|
||||
|
@ -63,13 +70,24 @@ GITLAB_WEBHOOK_PAYLOAD_SCHEMA = {
|
|||
'required': ['email'],
|
||||
},
|
||||
},
|
||||
'required': ['url', 'message', 'timestamp'],
|
||||
'required': ['id', 'url', 'message', 'timestamp'],
|
||||
},
|
||||
},
|
||||
},
|
||||
'required': ['ref', 'checkout_sha', 'repository'],
|
||||
}
|
||||
|
||||
_ACCESS_LEVEL_MAP = {
|
||||
50: ("owner", True),
|
||||
40: ("master", True),
|
||||
30: ("developer", False),
|
||||
20: ("reporter", False),
|
||||
10: ("guest", False),
|
||||
}
|
||||
|
||||
_PER_PAGE_COUNT = 20
|
||||
|
||||
|
||||
def _catch_timeouts(func):
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
|
@ -82,6 +100,27 @@ def _catch_timeouts(func):
|
|||
return wrapper
|
||||
|
||||
|
||||
def _paginated_iterator(func, exc):
|
||||
""" Returns an iterator over invocations of the given function, automatically handling
|
||||
pagination.
|
||||
"""
|
||||
page = 0
|
||||
while True:
|
||||
result = func(page=page, per_page=_PER_PAGE_COUNT)
|
||||
if result is False:
|
||||
raise exc
|
||||
|
||||
counter = 0
|
||||
for item in result:
|
||||
yield item
|
||||
counter = counter + 1
|
||||
|
||||
if counter < _PER_PAGE_COUNT:
|
||||
break
|
||||
|
||||
page = page + 1
|
||||
|
||||
|
||||
def get_transformed_webhook_payload(gl_payload, default_branch=None, lookup_user=None,
|
||||
lookup_commit=None):
|
||||
""" Returns the Gitlab webhook JSON payload transformed into our own payload
|
||||
|
@ -223,35 +262,59 @@ class GitLabBuildTrigger(BuildTriggerHandler):
|
|||
config.pop('key_id', None)
|
||||
|
||||
self.config = config
|
||||
|
||||
return config
|
||||
|
||||
@_catch_timeouts
|
||||
def list_build_sources(self):
|
||||
def list_build_source_namespaces(self):
|
||||
gl_client = self._get_authorized_client()
|
||||
current_user = gl_client.currentuser()
|
||||
if current_user is False:
|
||||
raise RepositoryReadException('Unable to get current user')
|
||||
|
||||
repositories = gl_client.getprojects()
|
||||
if repositories is False:
|
||||
raise RepositoryReadException('Unable to list user repositories')
|
||||
|
||||
namespaces = {}
|
||||
repositories = _paginated_iterator(gl_client.getprojects, RepositoryReadException)
|
||||
for repo in repositories:
|
||||
owner = repo['namespace']['name']
|
||||
if not owner in namespaces:
|
||||
namespaces[owner] = {
|
||||
namespace = repo['namespace']
|
||||
namespace_id = namespace['id']
|
||||
if namespace_id in namespaces:
|
||||
namespaces[namespace_id]['score'] = namespaces[namespace_id]['score'] + 1
|
||||
else:
|
||||
owner = repo['namespace']['name']
|
||||
namespaces[namespace_id] = {
|
||||
'personal': owner == current_user['username'],
|
||||
'repos': [],
|
||||
'info': {
|
||||
'name': owner,
|
||||
}
|
||||
'id': namespace['path'],
|
||||
'title': namespace['name'],
|
||||
'avatar_url': repo['owner']['avatar_url'],
|
||||
'score': 1,
|
||||
'url': gl_client.host + '/' + namespace['path'],
|
||||
}
|
||||
|
||||
namespaces[owner]['repos'].append(repo['path_with_namespace'])
|
||||
return BuildTriggerHandler.build_namespaces_response(namespaces)
|
||||
|
||||
return namespaces.values()
|
||||
@_catch_timeouts
|
||||
def list_build_sources_for_namespace(self, namespace):
|
||||
def repo_view(repo):
|
||||
last_modified = dateutil.parser.parse(repo['last_activity_at'])
|
||||
has_admin_permission = False
|
||||
|
||||
if repo.get('permissions'):
|
||||
access_level = repo['permissions']['project_access']['access_level']
|
||||
has_admin_permission = _ACCESS_LEVEL_MAP.get(access_level, ("", False))[1]
|
||||
|
||||
return {
|
||||
'name': repo['path'],
|
||||
'full_name': repo['path_with_namespace'],
|
||||
'description': repo['description'] or '',
|
||||
'last_updated': timegm(last_modified.utctimetuple()),
|
||||
'url': repo['web_url'],
|
||||
'has_admin_permissions': has_admin_permission,
|
||||
'private': repo['public'] is False,
|
||||
}
|
||||
|
||||
gl_client = self._get_authorized_client()
|
||||
repositories = _paginated_iterator(gl_client.getprojects, RepositoryReadException)
|
||||
repos = [repo_view(repo) for repo in repositories if repo['namespace']['path'] == namespace]
|
||||
return BuildTriggerHandler.build_sources_response(repos)
|
||||
|
||||
@_catch_timeouts
|
||||
def list_build_subdirs(self):
|
||||
|
@ -280,7 +343,7 @@ class GitLabBuildTrigger(BuildTriggerHandler):
|
|||
|
||||
for node in repo_tree:
|
||||
if node['name'] == 'Dockerfile':
|
||||
return ['/']
|
||||
return ['']
|
||||
|
||||
return []
|
||||
|
||||
|
@ -428,18 +491,18 @@ class GitLabBuildTrigger(BuildTriggerHandler):
|
|||
def get_tag_sha(tag_name):
|
||||
tags = gl_client.getrepositorytags(repo['id'])
|
||||
if tags is False:
|
||||
raise TriggerStartException('Could not find tags')
|
||||
raise TriggerStartException('Could not find tag in repository')
|
||||
|
||||
for tag in tags:
|
||||
if tag['name'] == tag_name:
|
||||
return tag['commit']['id']
|
||||
|
||||
raise TriggerStartException('Could not find commit')
|
||||
raise TriggerStartException('Could not find tag in repository')
|
||||
|
||||
def get_branch_sha(branch_name):
|
||||
branch = gl_client.getbranch(repo['id'], branch_name)
|
||||
if branch is False:
|
||||
raise TriggerStartException('Could not find branch')
|
||||
raise TriggerStartException('Could not find branch in repository')
|
||||
|
||||
return branch['commit']['id']
|
||||
|
||||
|
|
0
buildtrigger/test/__init__.py
Normal file
0
buildtrigger/test/__init__.py
Normal file
158
buildtrigger/test/bitbucketmock.py
Normal file
158
buildtrigger/test/bitbucketmock.py
Normal file
|
@ -0,0 +1,158 @@
|
|||
from datetime import datetime
|
||||
from mock import Mock
|
||||
|
||||
from buildtrigger.bitbuckethandler import BitbucketBuildTrigger
|
||||
from util.morecollections import AttrDict
|
||||
|
||||
def get_bitbucket_trigger(subdir=''):
|
||||
trigger_obj = AttrDict(dict(auth_token='foobar', id='sometrigger'))
|
||||
trigger = BitbucketBuildTrigger(trigger_obj, {
|
||||
'build_source': 'foo/bar',
|
||||
'subdir': subdir,
|
||||
'username': 'knownuser'
|
||||
})
|
||||
|
||||
trigger._get_client = get_mock_bitbucket
|
||||
return trigger
|
||||
|
||||
def get_repo_path_contents(path, revision):
|
||||
data = {
|
||||
'files': [{'path': 'Dockerfile'}],
|
||||
}
|
||||
|
||||
return (True, data, None)
|
||||
|
||||
def get_raw_path_contents(path, revision):
|
||||
if path == '/Dockerfile':
|
||||
return (True, 'hello world', None)
|
||||
|
||||
if path == 'somesubdir/Dockerfile':
|
||||
return (True, 'hi universe', None)
|
||||
|
||||
return (False, None, None)
|
||||
|
||||
def get_branches_and_tags():
|
||||
data = {
|
||||
'branches': [{'name': 'master'}, {'name': 'otherbranch'}],
|
||||
'tags': [{'name': 'sometag'}, {'name': 'someothertag'}],
|
||||
}
|
||||
return (True, data, None)
|
||||
|
||||
def get_branches():
|
||||
return (True, {'master': {}, 'otherbranch': {}}, None)
|
||||
|
||||
def get_tags():
|
||||
return (True, {'sometag': {}, 'someothertag': {}}, None)
|
||||
|
||||
def get_branch(branch_name):
|
||||
if branch_name != 'master':
|
||||
return (False, None, None)
|
||||
|
||||
data = {
|
||||
'target': {
|
||||
'hash': 'aaaaaaa',
|
||||
},
|
||||
}
|
||||
|
||||
return (True, data, None)
|
||||
|
||||
def get_tag(tag_name):
|
||||
if tag_name != 'sometag':
|
||||
return (False, None, None)
|
||||
|
||||
data = {
|
||||
'target': {
|
||||
'hash': 'aaaaaaa',
|
||||
},
|
||||
}
|
||||
|
||||
return (True, data, None)
|
||||
|
||||
def get_changeset_mock(commit_sha):
|
||||
if commit_sha != 'aaaaaaa':
|
||||
return (False, None, 'Not found')
|
||||
|
||||
data = {
|
||||
'node': 'aaaaaaa',
|
||||
'message': 'some message',
|
||||
'timestamp': 'now',
|
||||
'raw_author': 'foo@bar.com',
|
||||
}
|
||||
|
||||
return (True, data, None)
|
||||
|
||||
def get_changesets():
|
||||
changesets_mock = Mock()
|
||||
changesets_mock.get = Mock(side_effect=get_changeset_mock)
|
||||
return changesets_mock
|
||||
|
||||
def get_deploykeys():
|
||||
deploykeys_mock = Mock()
|
||||
deploykeys_mock.create = Mock(return_value=(True, {'pk': 'someprivatekey'}, None))
|
||||
deploykeys_mock.delete = Mock(return_value=(True, {}, None))
|
||||
return deploykeys_mock
|
||||
|
||||
def get_webhooks():
|
||||
webhooks_mock = Mock()
|
||||
webhooks_mock.create = Mock(return_value=(True, {'uuid': 'someuuid'}, None))
|
||||
webhooks_mock.delete = Mock(return_value=(True, {}, None))
|
||||
return webhooks_mock
|
||||
|
||||
def get_repo_mock(name):
|
||||
if name != 'bar':
|
||||
return None
|
||||
|
||||
repo_mock = Mock()
|
||||
repo_mock.get_main_branch = Mock(return_value=(True, {'name': 'master'}, None))
|
||||
repo_mock.get_path_contents = Mock(side_effect=get_repo_path_contents)
|
||||
repo_mock.get_raw_path_contents = Mock(side_effect=get_raw_path_contents)
|
||||
repo_mock.get_branches_and_tags = Mock(side_effect=get_branches_and_tags)
|
||||
repo_mock.get_branches = Mock(side_effect=get_branches)
|
||||
repo_mock.get_tags = Mock(side_effect=get_tags)
|
||||
repo_mock.get_branch = Mock(side_effect=get_branch)
|
||||
repo_mock.get_tag = Mock(side_effect=get_tag)
|
||||
|
||||
repo_mock.changesets = Mock(side_effect=get_changesets)
|
||||
repo_mock.deploykeys = Mock(side_effect=get_deploykeys)
|
||||
repo_mock.webhooks = Mock(side_effect=get_webhooks)
|
||||
return repo_mock
|
||||
|
||||
def get_repositories_mock():
|
||||
repos_mock = Mock()
|
||||
repos_mock.get = Mock(side_effect=get_repo_mock)
|
||||
return repos_mock
|
||||
|
||||
def get_namespace_mock(namespace):
|
||||
namespace_mock = Mock()
|
||||
namespace_mock.repositories = Mock(side_effect=get_repositories_mock)
|
||||
return namespace_mock
|
||||
|
||||
def get_repo(namespace, name):
|
||||
return {
|
||||
'owner': namespace,
|
||||
'logo': 'avatarurl',
|
||||
'slug': name,
|
||||
'description': 'some %s repo' % (name),
|
||||
'utc_last_updated': str(datetime.utcfromtimestamp(0)),
|
||||
'read_only': namespace != 'knownuser',
|
||||
'is_private': name == 'somerepo',
|
||||
}
|
||||
|
||||
def get_visible_repos():
|
||||
repos = [
|
||||
get_repo('knownuser', 'somerepo'),
|
||||
get_repo('someorg', 'somerepo'),
|
||||
get_repo('someorg', 'anotherrepo'),
|
||||
]
|
||||
return (True, repos, None)
|
||||
|
||||
def get_authed_mock(token, secret):
|
||||
authed_mock = Mock()
|
||||
authed_mock.for_namespace = Mock(side_effect=get_namespace_mock)
|
||||
authed_mock.get_visible_repositories = Mock(side_effect=get_visible_repos)
|
||||
return authed_mock
|
||||
|
||||
def get_mock_bitbucket():
|
||||
bitbucket_mock = Mock()
|
||||
bitbucket_mock.get_authorized_client = Mock(side_effect=get_authed_mock)
|
||||
return bitbucket_mock
|
173
buildtrigger/test/githubmock.py
Normal file
173
buildtrigger/test/githubmock.py
Normal file
|
@ -0,0 +1,173 @@
|
|||
from datetime import datetime
|
||||
from mock import Mock
|
||||
|
||||
from github import GithubException
|
||||
|
||||
from buildtrigger.githubhandler import GithubBuildTrigger
|
||||
from util.morecollections import AttrDict
|
||||
|
||||
def get_github_trigger(subdir=''):
|
||||
trigger_obj = AttrDict(dict(auth_token='foobar', id='sometrigger'))
|
||||
trigger = GithubBuildTrigger(trigger_obj, {'build_source': 'foo', 'subdir': subdir})
|
||||
trigger._get_client = get_mock_github
|
||||
return trigger
|
||||
|
||||
def get_mock_github():
|
||||
def get_commit_mock(commit_sha):
|
||||
if commit_sha == 'aaaaaaa':
|
||||
commit_mock = Mock()
|
||||
commit_mock.sha = commit_sha
|
||||
commit_mock.html_url = 'http://url/to/commit'
|
||||
commit_mock.last_modified = 'now'
|
||||
|
||||
commit_mock.commit = Mock()
|
||||
commit_mock.commit.message = 'some cool message'
|
||||
|
||||
commit_mock.committer = Mock()
|
||||
commit_mock.committer.login = 'someuser'
|
||||
commit_mock.committer.avatar_url = 'avatarurl'
|
||||
commit_mock.committer.html_url = 'htmlurl'
|
||||
|
||||
commit_mock.author = Mock()
|
||||
commit_mock.author.login = 'someuser'
|
||||
commit_mock.author.avatar_url = 'avatarurl'
|
||||
commit_mock.author.html_url = 'htmlurl'
|
||||
return commit_mock
|
||||
|
||||
raise GithubException(None, None)
|
||||
|
||||
def get_branch_mock(branch_name):
|
||||
if branch_name == 'master':
|
||||
branch_mock = Mock()
|
||||
branch_mock.commit = Mock()
|
||||
branch_mock.commit.sha = 'aaaaaaa'
|
||||
return branch_mock
|
||||
|
||||
raise GithubException(None, None)
|
||||
|
||||
def get_repo_mock(namespace, name):
|
||||
repo_mock = Mock()
|
||||
repo_mock.owner = Mock()
|
||||
repo_mock.owner.login = namespace
|
||||
|
||||
repo_mock.full_name = '%s/%s' % (namespace, name)
|
||||
repo_mock.name = name
|
||||
repo_mock.description = 'some %s repo' % (name)
|
||||
repo_mock.pushed_at = datetime.utcfromtimestamp(0)
|
||||
repo_mock.html_url = 'https://bitbucket.org/%s/%s' % (namespace, name)
|
||||
repo_mock.private = name == 'somerepo'
|
||||
repo_mock.permissions = Mock()
|
||||
repo_mock.permissions.admin = namespace == 'knownuser'
|
||||
return repo_mock
|
||||
|
||||
def get_user_repos_mock():
|
||||
return [get_repo_mock('knownuser', 'somerepo')]
|
||||
|
||||
def get_org_repos_mock(type='all'):
|
||||
return [get_repo_mock('someorg', 'somerepo'), get_repo_mock('someorg', 'anotherrepo')]
|
||||
|
||||
def get_orgs_mock():
|
||||
return [get_org_mock('someorg')]
|
||||
|
||||
def get_user_mock(username='knownuser'):
|
||||
if username == 'knownuser':
|
||||
user_mock = Mock()
|
||||
user_mock.name = username
|
||||
user_mock.plan = Mock()
|
||||
user_mock.plan.private_repos = 1
|
||||
user_mock.login = username
|
||||
user_mock.html_url = 'https://bitbucket.org/%s' % (username)
|
||||
user_mock.avatar_url = 'avatarurl'
|
||||
user_mock.get_repos = Mock(side_effect=get_user_repos_mock)
|
||||
user_mock.get_orgs = Mock(side_effect=get_orgs_mock)
|
||||
return user_mock
|
||||
|
||||
raise GithubException(None, None)
|
||||
|
||||
def get_org_mock(namespace):
|
||||
if namespace == 'someorg':
|
||||
org_mock = Mock()
|
||||
org_mock.get_repos = Mock(side_effect=get_org_repos_mock)
|
||||
org_mock.login = namespace
|
||||
org_mock.html_url = 'https://bitbucket.org/%s' % (namespace)
|
||||
org_mock.avatar_url = 'avatarurl'
|
||||
org_mock.name = namespace
|
||||
org_mock.plan = Mock()
|
||||
org_mock.plan.private_repos = 2
|
||||
return org_mock
|
||||
|
||||
raise GithubException(None, None)
|
||||
|
||||
def get_tags_mock():
|
||||
sometag = Mock()
|
||||
sometag.name = 'sometag'
|
||||
sometag.commit = get_commit_mock('aaaaaaa')
|
||||
|
||||
someothertag = Mock()
|
||||
someothertag.name = 'someothertag'
|
||||
someothertag.commit = get_commit_mock('aaaaaaa')
|
||||
return [sometag, someothertag]
|
||||
|
||||
def get_branches_mock():
|
||||
master = Mock()
|
||||
master.name = 'master'
|
||||
master.commit = get_commit_mock('aaaaaaa')
|
||||
|
||||
otherbranch = Mock()
|
||||
otherbranch.name = 'otherbranch'
|
||||
otherbranch.commit = get_commit_mock('aaaaaaa')
|
||||
return [master, otherbranch]
|
||||
|
||||
def get_file_contents_mock(filepath):
|
||||
if filepath == '/Dockerfile':
|
||||
m = Mock()
|
||||
m.content = 'hello world'
|
||||
return m
|
||||
|
||||
if filepath == 'somesubdir/Dockerfile':
|
||||
m = Mock()
|
||||
m.content = 'hi universe'
|
||||
return m
|
||||
|
||||
raise GithubException(None, None)
|
||||
|
||||
def get_git_tree_mock(commit_sha, recursive=False):
|
||||
first_file = Mock()
|
||||
first_file.type = 'blob'
|
||||
first_file.path = 'Dockerfile'
|
||||
|
||||
second_file = Mock()
|
||||
second_file.type = 'other'
|
||||
second_file.path = '/some/Dockerfile'
|
||||
|
||||
third_file = Mock()
|
||||
third_file.type = 'blob'
|
||||
third_file.path = 'somesubdir/Dockerfile'
|
||||
|
||||
t = Mock()
|
||||
|
||||
if commit_sha == 'aaaaaaa':
|
||||
t.tree = [
|
||||
first_file, second_file, third_file,
|
||||
]
|
||||
else:
|
||||
t.tree = []
|
||||
|
||||
return t
|
||||
|
||||
repo_mock = Mock()
|
||||
repo_mock.default_branch = 'master'
|
||||
repo_mock.ssh_url = 'ssh_url'
|
||||
|
||||
repo_mock.get_branch = Mock(side_effect=get_branch_mock)
|
||||
repo_mock.get_tags = Mock(side_effect=get_tags_mock)
|
||||
repo_mock.get_branches = Mock(side_effect=get_branches_mock)
|
||||
repo_mock.get_commit = Mock(side_effect=get_commit_mock)
|
||||
repo_mock.get_file_contents = Mock(side_effect=get_file_contents_mock)
|
||||
repo_mock.get_git_tree = Mock(side_effect=get_git_tree_mock)
|
||||
|
||||
gh_mock = Mock()
|
||||
gh_mock.get_repo = Mock(return_value=repo_mock)
|
||||
gh_mock.get_user = Mock(side_effect=get_user_mock)
|
||||
gh_mock.get_organization = Mock(side_effect=get_org_mock)
|
||||
return gh_mock
|
186
buildtrigger/test/gitlabmock.py
Normal file
186
buildtrigger/test/gitlabmock.py
Normal file
|
@ -0,0 +1,186 @@
|
|||
from datetime import datetime
|
||||
from mock import Mock
|
||||
|
||||
from buildtrigger.gitlabhandler import GitLabBuildTrigger
|
||||
from util.morecollections import AttrDict
|
||||
|
||||
def get_gitlab_trigger(subdir=''):
|
||||
trigger_obj = AttrDict(dict(auth_token='foobar', id='sometrigger'))
|
||||
trigger = GitLabBuildTrigger(trigger_obj, {
|
||||
'build_source': 'foo/bar',
|
||||
'subdir': subdir,
|
||||
'username': 'knownuser'
|
||||
})
|
||||
|
||||
trigger._get_authorized_client = get_mock_gitlab
|
||||
return trigger
|
||||
|
||||
def adddeploykey_mock(project_id, name, public_key):
|
||||
return {'id': 'foo'}
|
||||
|
||||
def addprojecthook_mock(project_id, webhook_url, push=False):
|
||||
return {'id': 'foo'}
|
||||
|
||||
def get_currentuser_mock():
|
||||
return {
|
||||
'username': 'knownuser'
|
||||
}
|
||||
|
||||
def project(namespace, name):
|
||||
return {
|
||||
'id': '%s/%s' % (namespace, name),
|
||||
'default_branch': 'master',
|
||||
'namespace': {
|
||||
'id': namespace,
|
||||
'path': namespace,
|
||||
'name': namespace,
|
||||
},
|
||||
'path': name,
|
||||
'path_with_namespace': '%s/%s' % (namespace, name),
|
||||
'description': 'some %s repo' % name,
|
||||
'last_activity_at': str(datetime.utcfromtimestamp(0)),
|
||||
'web_url': 'https://bitbucket.org/%s/%s' % (namespace, name),
|
||||
'ssh_url_to_repo': 'git://%s/%s' % (namespace, name),
|
||||
'public': name != 'somerepo',
|
||||
'permissions': {
|
||||
'project_access': {
|
||||
'access_level': 50 if namespace == 'knownuser' else 0,
|
||||
}
|
||||
},
|
||||
'owner': {
|
||||
'avatar_url': 'avatarurl',
|
||||
}
|
||||
}
|
||||
|
||||
def getprojects_mock(page=1, per_page=100):
|
||||
return [
|
||||
project('knownuser', 'somerepo'),
|
||||
project('someorg', 'somerepo'),
|
||||
project('someorg', 'anotherrepo'),
|
||||
]
|
||||
|
||||
def getproject_mock(project_name):
|
||||
if project_name == 'knownuser/somerepo':
|
||||
return project('knownuser', 'somerepo')
|
||||
|
||||
if project_name == 'foo/bar':
|
||||
return project('foo', 'bar')
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def getbranches_mock(project_id):
|
||||
return [
|
||||
{
|
||||
'name': 'master',
|
||||
'commit': {
|
||||
'id': 'aaaaaaa',
|
||||
}
|
||||
},
|
||||
{
|
||||
'name': 'otherbranch',
|
||||
'commit': {
|
||||
'id': 'aaaaaaa',
|
||||
}
|
||||
},
|
||||
]
|
||||
|
||||
def getrepositorytags_mock(project_id):
|
||||
return [
|
||||
{
|
||||
'name': 'sometag',
|
||||
'commit': {
|
||||
'id': 'aaaaaaa',
|
||||
}
|
||||
},
|
||||
{
|
||||
'name': 'someothertag',
|
||||
'commit': {
|
||||
'id': 'aaaaaaa',
|
||||
}
|
||||
},
|
||||
]
|
||||
|
||||
def getrepositorytree_mock(project_id, ref_name='master'):
|
||||
return [
|
||||
{'name': 'README'},
|
||||
{'name': 'Dockerfile'},
|
||||
]
|
||||
|
||||
def getrepositorycommit_mock(project_id, commit_sha):
|
||||
if commit_sha != 'aaaaaaa':
|
||||
return False
|
||||
|
||||
return {
|
||||
'id': 'aaaaaaa',
|
||||
'message': 'some message',
|
||||
'committed_date': 'now',
|
||||
}
|
||||
|
||||
def getusers_mock(search=None):
|
||||
if search == 'knownuser':
|
||||
return [
|
||||
{
|
||||
'username': 'knownuser',
|
||||
'avatar_url': 'avatarurl',
|
||||
}
|
||||
]
|
||||
|
||||
return False
|
||||
|
||||
def getbranch_mock(repo_id, branch):
|
||||
if branch != 'master' and branch != 'otherbranch':
|
||||
return False
|
||||
|
||||
return {
|
||||
'name': branch,
|
||||
'commit': {
|
||||
'id': 'aaaaaaa',
|
||||
}
|
||||
}
|
||||
|
||||
def gettag_mock(repo_id, tag):
|
||||
if tag != 'sometag' and tag != 'someothertag':
|
||||
return False
|
||||
|
||||
return {
|
||||
'name': tag,
|
||||
'commit': {
|
||||
'id': 'aaaaaaa',
|
||||
}
|
||||
}
|
||||
|
||||
def getrawfile_mock(repo_id, branch_name, path):
|
||||
if path == '/Dockerfile':
|
||||
return 'hello world'
|
||||
|
||||
if path == 'somesubdir/Dockerfile':
|
||||
return 'hi universe'
|
||||
|
||||
return False
|
||||
|
||||
def get_mock_gitlab():
|
||||
mock_gitlab = Mock()
|
||||
mock_gitlab.host = 'https://bitbucket.org'
|
||||
|
||||
mock_gitlab.currentuser = Mock(side_effect=get_currentuser_mock)
|
||||
mock_gitlab.getusers = Mock(side_effect=getusers_mock)
|
||||
|
||||
mock_gitlab.getprojects = Mock(side_effect=getprojects_mock)
|
||||
mock_gitlab.getproject = Mock(side_effect=getproject_mock)
|
||||
mock_gitlab.getbranches = Mock(side_effect=getbranches_mock)
|
||||
|
||||
mock_gitlab.getbranch = Mock(side_effect=getbranch_mock)
|
||||
mock_gitlab.gettag = Mock(side_effect=gettag_mock)
|
||||
|
||||
mock_gitlab.getrepositorytags = Mock(side_effect=getrepositorytags_mock)
|
||||
mock_gitlab.getrepositorytree = Mock(side_effect=getrepositorytree_mock)
|
||||
mock_gitlab.getrepositorycommit = Mock(side_effect=getrepositorycommit_mock)
|
||||
|
||||
mock_gitlab.getrawfile = Mock(side_effect=getrawfile_mock)
|
||||
|
||||
mock_gitlab.adddeploykey = Mock(side_effect=adddeploykey_mock)
|
||||
mock_gitlab.addprojecthook = Mock(side_effect=addprojecthook_mock)
|
||||
mock_gitlab.deletedeploykey = Mock(return_value=True)
|
||||
mock_gitlab.deleteprojecthook = Mock(return_value=True)
|
||||
return mock_gitlab
|
91
buildtrigger/test/test_bitbuckethandler.py
Normal file
91
buildtrigger/test/test_bitbuckethandler.py
Normal file
|
@ -0,0 +1,91 @@
|
|||
import json
|
||||
import pytest
|
||||
|
||||
from buildtrigger.test.bitbucketmock import get_bitbucket_trigger
|
||||
from buildtrigger.triggerutil import (SkipRequestException, ValidationRequestException,
|
||||
InvalidPayloadException)
|
||||
from endpoints.building import PreparedBuild
|
||||
from util.morecollections import AttrDict
|
||||
|
||||
@pytest.fixture
|
||||
def bitbucket_trigger():
|
||||
return get_bitbucket_trigger()
|
||||
|
||||
|
||||
def test_list_build_subdirs(bitbucket_trigger):
|
||||
assert bitbucket_trigger.list_build_subdirs() == ['']
|
||||
|
||||
|
||||
@pytest.mark.parametrize('subdir, contents', [
|
||||
('', 'hello world'),
|
||||
('somesubdir', 'hi universe'),
|
||||
('unknownpath', None),
|
||||
])
|
||||
def test_load_dockerfile_contents(subdir, contents):
|
||||
trigger = get_bitbucket_trigger(subdir)
|
||||
assert trigger.load_dockerfile_contents() == contents
|
||||
|
||||
|
||||
@pytest.mark.parametrize('payload, expected_error, expected_message', [
|
||||
('{}', InvalidPayloadException, "'push' is a required property"),
|
||||
|
||||
# Valid payload:
|
||||
('''{
|
||||
"push": {
|
||||
"changes": [{
|
||||
"new": {
|
||||
"name": "somechange",
|
||||
"target": {
|
||||
"hash": "aaaaaaa",
|
||||
"message": "foo",
|
||||
"date": "now",
|
||||
"links": {
|
||||
"html": {
|
||||
"href": "somelink"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}]
|
||||
},
|
||||
"repository": {
|
||||
"full_name": "foo/bar"
|
||||
}
|
||||
}''', None, None),
|
||||
|
||||
# Skip message:
|
||||
('''{
|
||||
"push": {
|
||||
"changes": [{
|
||||
"new": {
|
||||
"name": "somechange",
|
||||
"target": {
|
||||
"hash": "aaaaaaa",
|
||||
"message": "[skip build] foo",
|
||||
"date": "now",
|
||||
"links": {
|
||||
"html": {
|
||||
"href": "somelink"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}]
|
||||
},
|
||||
"repository": {
|
||||
"full_name": "foo/bar"
|
||||
}
|
||||
}''', SkipRequestException, ''),
|
||||
])
|
||||
def test_handle_trigger_request(bitbucket_trigger, payload, expected_error, expected_message):
|
||||
def get_payload():
|
||||
return json.loads(payload)
|
||||
|
||||
request = AttrDict(dict(get_json=get_payload))
|
||||
|
||||
if expected_error is not None:
|
||||
with pytest.raises(expected_error) as ipe:
|
||||
bitbucket_trigger.handle_trigger_request(request)
|
||||
assert ipe.value.message == expected_message
|
||||
else:
|
||||
assert isinstance(bitbucket_trigger.handle_trigger_request(request), PreparedBuild)
|
51
buildtrigger/test/test_customhandler.py
Normal file
51
buildtrigger/test/test_customhandler.py
Normal file
|
@ -0,0 +1,51 @@
|
|||
import pytest
|
||||
|
||||
from buildtrigger.customhandler import CustomBuildTrigger
|
||||
from buildtrigger.triggerutil import (InvalidPayloadException, SkipRequestException,
|
||||
TriggerStartException)
|
||||
from endpoints.building import PreparedBuild
|
||||
from util.morecollections import AttrDict
|
||||
|
||||
@pytest.mark.parametrize('payload, expected_error, expected_message', [
|
||||
('', InvalidPayloadException, 'Missing expected payload'),
|
||||
('{}', InvalidPayloadException, "'commit' is a required property"),
|
||||
|
||||
('{"commit": "foo", "ref": "refs/heads/something", "default_branch": "baz"}',
|
||||
InvalidPayloadException, "u'foo' does not match '^([A-Fa-f0-9]{7,})$'"),
|
||||
|
||||
('{"commit": "11d6fbc", "ref": "refs/heads/something", "default_branch": "baz"}', None, None),
|
||||
('''{
|
||||
"commit": "11d6fbc",
|
||||
"ref": "refs/heads/something",
|
||||
"default_branch": "baz",
|
||||
"commit_info": {
|
||||
"message": "[skip build]",
|
||||
"url": "http://foo.bar",
|
||||
"date": "NOW"
|
||||
}
|
||||
}''', SkipRequestException, ''),
|
||||
])
|
||||
def test_handle_trigger_request(payload, expected_error, expected_message):
|
||||
trigger = CustomBuildTrigger(None, {'build_source': 'foo'})
|
||||
request = AttrDict(dict(data=payload))
|
||||
|
||||
if expected_error is not None:
|
||||
with pytest.raises(expected_error) as ipe:
|
||||
trigger.handle_trigger_request(request)
|
||||
assert ipe.value.message == expected_message
|
||||
else:
|
||||
assert isinstance(trigger.handle_trigger_request(request), PreparedBuild)
|
||||
|
||||
@pytest.mark.parametrize('run_parameters, expected_error, expected_message', [
|
||||
({}, TriggerStartException, 'missing required parameter'),
|
||||
({'commit_sha': 'foo'}, TriggerStartException, "'foo' does not match '^([A-Fa-f0-9]{7,})$'"),
|
||||
({'commit_sha': '11d6fbc'}, None, None),
|
||||
])
|
||||
def test_manual_start(run_parameters, expected_error, expected_message):
|
||||
trigger = CustomBuildTrigger(None, {'build_source': 'foo'})
|
||||
if expected_error is not None:
|
||||
with pytest.raises(expected_error) as ipe:
|
||||
trigger.manual_start(run_parameters)
|
||||
assert ipe.value.message == expected_message
|
||||
else:
|
||||
assert isinstance(trigger.manual_start(run_parameters), PreparedBuild)
|
125
buildtrigger/test/test_githosthandler.py
Normal file
125
buildtrigger/test/test_githosthandler.py
Normal file
|
@ -0,0 +1,125 @@
|
|||
import pytest
|
||||
|
||||
from buildtrigger.triggerutil import TriggerStartException
|
||||
from buildtrigger.test.bitbucketmock import get_bitbucket_trigger
|
||||
from buildtrigger.test.githubmock import get_github_trigger
|
||||
from buildtrigger.test.gitlabmock import get_gitlab_trigger
|
||||
from endpoints.building import PreparedBuild
|
||||
|
||||
# Note: This test suite executes a common set of tests against all the trigger types specified
|
||||
# in this fixture. Each trigger's mock is expected to return the same data for all of these calls.
|
||||
@pytest.fixture(params=[get_github_trigger(), get_bitbucket_trigger(), get_gitlab_trigger()])
|
||||
def githost_trigger(request):
|
||||
return request.param
|
||||
|
||||
@pytest.mark.parametrize('run_parameters, expected_error, expected_message', [
|
||||
# No branch or tag specified: use the commit of the default branch.
|
||||
({}, None, None),
|
||||
|
||||
# Invalid branch.
|
||||
({'refs': {'kind': 'branch', 'name': 'invalid'}}, TriggerStartException,
|
||||
'Could not find branch in repository'),
|
||||
|
||||
# Invalid tag.
|
||||
({'refs': {'kind': 'tag', 'name': 'invalid'}}, TriggerStartException,
|
||||
'Could not find tag in repository'),
|
||||
|
||||
# Valid branch.
|
||||
({'refs': {'kind': 'branch', 'name': 'master'}}, None, None),
|
||||
|
||||
# Valid tag.
|
||||
({'refs': {'kind': 'tag', 'name': 'sometag'}}, None, None),
|
||||
])
|
||||
def test_manual_start(run_parameters, expected_error, expected_message, githost_trigger):
|
||||
if expected_error is not None:
|
||||
with pytest.raises(expected_error) as ipe:
|
||||
githost_trigger.manual_start(run_parameters)
|
||||
assert ipe.value.message == expected_message
|
||||
else:
|
||||
assert isinstance(githost_trigger.manual_start(run_parameters), PreparedBuild)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('name, expected', [
|
||||
('refs', [
|
||||
{'kind': 'branch', 'name': 'master'},
|
||||
{'kind': 'branch', 'name': 'otherbranch'},
|
||||
{'kind': 'tag', 'name': 'sometag'},
|
||||
{'kind': 'tag', 'name': 'someothertag'},
|
||||
]),
|
||||
('tag_name', set(['sometag', 'someothertag'])),
|
||||
('branch_name', set(['master', 'otherbranch'])),
|
||||
('invalid', None)
|
||||
])
|
||||
def test_list_field_values(name, expected, githost_trigger):
|
||||
if expected is None:
|
||||
assert githost_trigger.list_field_values(name) is None
|
||||
elif isinstance(expected, set):
|
||||
assert set(githost_trigger.list_field_values(name)) == set(expected)
|
||||
else:
|
||||
assert githost_trigger.list_field_values(name) == expected
|
||||
|
||||
|
||||
def test_list_build_source_namespaces(githost_trigger):
|
||||
namespaces_expected = [
|
||||
{
|
||||
'personal': True,
|
||||
'score': 1,
|
||||
'avatar_url': 'avatarurl',
|
||||
'id': 'knownuser',
|
||||
'title': 'knownuser',
|
||||
'url': 'https://bitbucket.org/knownuser',
|
||||
},
|
||||
{
|
||||
'score': 2,
|
||||
'title': 'someorg',
|
||||
'personal': False,
|
||||
'url': 'https://bitbucket.org/someorg',
|
||||
'avatar_url': 'avatarurl',
|
||||
'id': 'someorg'
|
||||
}
|
||||
]
|
||||
|
||||
found = githost_trigger.list_build_source_namespaces()
|
||||
found.sort()
|
||||
|
||||
namespaces_expected.sort()
|
||||
assert found == namespaces_expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize('namespace, expected', [
|
||||
('', []),
|
||||
('unknown', []),
|
||||
|
||||
('knownuser', [
|
||||
{
|
||||
'last_updated': 0, 'name': 'somerepo',
|
||||
'url': 'https://bitbucket.org/knownuser/somerepo', 'private': True,
|
||||
'full_name': 'knownuser/somerepo', 'has_admin_permissions': True,
|
||||
'description': 'some somerepo repo'
|
||||
}]),
|
||||
|
||||
('someorg', [
|
||||
{
|
||||
'last_updated': 0, 'name': 'somerepo',
|
||||
'url': 'https://bitbucket.org/someorg/somerepo', 'private': True,
|
||||
'full_name': 'someorg/somerepo', 'has_admin_permissions': False,
|
||||
'description': 'some somerepo repo'
|
||||
},
|
||||
{
|
||||
'last_updated': 0, 'name': 'anotherrepo',
|
||||
'url': 'https://bitbucket.org/someorg/anotherrepo', 'private': False,
|
||||
'full_name': 'someorg/anotherrepo', 'has_admin_permissions': False,
|
||||
'description': 'some anotherrepo repo'
|
||||
}]),
|
||||
])
|
||||
def test_list_build_sources_for_namespace(namespace, expected, githost_trigger):
|
||||
assert githost_trigger.list_build_sources_for_namespace(namespace) == expected
|
||||
|
||||
|
||||
def test_activate(githost_trigger):
|
||||
_, private_key = githost_trigger.activate('http://some/url')
|
||||
assert 'private_key' in private_key
|
||||
|
||||
|
||||
def test_deactivate(githost_trigger):
|
||||
githost_trigger.deactivate()
|
89
buildtrigger/test/test_githubhandler.py
Normal file
89
buildtrigger/test/test_githubhandler.py
Normal file
|
@ -0,0 +1,89 @@
|
|||
import json
|
||||
import pytest
|
||||
|
||||
from buildtrigger.test.githubmock import get_github_trigger
|
||||
from buildtrigger.triggerutil import SkipRequestException, ValidationRequestException
|
||||
from endpoints.building import PreparedBuild
|
||||
from util.morecollections import AttrDict
|
||||
|
||||
@pytest.fixture
|
||||
def github_trigger():
|
||||
return get_github_trigger()
|
||||
|
||||
|
||||
@pytest.mark.parametrize('payload, expected_error, expected_message', [
|
||||
('{"zen": true}', SkipRequestException, ""),
|
||||
|
||||
('{}', ValidationRequestException, "Missing 'repository' on request"),
|
||||
('{"repository": "foo"}', ValidationRequestException, "Missing 'owner' on repository"),
|
||||
|
||||
# Valid payload:
|
||||
('''{
|
||||
"repository": {
|
||||
"owner": {
|
||||
"name": "someguy"
|
||||
},
|
||||
"name": "somerepo",
|
||||
"ssh_url": "someurl"
|
||||
},
|
||||
"ref": "refs/tags/foo",
|
||||
"head_commit": {
|
||||
"id": "11d6fbc",
|
||||
"url": "http://some/url",
|
||||
"message": "some message",
|
||||
"timestamp": "NOW"
|
||||
}
|
||||
}''', None, None),
|
||||
|
||||
# Skip message:
|
||||
('''{
|
||||
"repository": {
|
||||
"owner": {
|
||||
"name": "someguy"
|
||||
},
|
||||
"name": "somerepo",
|
||||
"ssh_url": "someurl"
|
||||
},
|
||||
"ref": "refs/tags/foo",
|
||||
"head_commit": {
|
||||
"id": "11d6fbc",
|
||||
"url": "http://some/url",
|
||||
"message": "[skip build]",
|
||||
"timestamp": "NOW"
|
||||
}
|
||||
}''', SkipRequestException, ''),
|
||||
])
|
||||
def test_handle_trigger_request(github_trigger, payload, expected_error, expected_message):
|
||||
def get_payload():
|
||||
return json.loads(payload)
|
||||
|
||||
request = AttrDict(dict(get_json=get_payload))
|
||||
|
||||
if expected_error is not None:
|
||||
with pytest.raises(expected_error) as ipe:
|
||||
github_trigger.handle_trigger_request(request)
|
||||
assert ipe.value.message == expected_message
|
||||
else:
|
||||
assert isinstance(github_trigger.handle_trigger_request(request), PreparedBuild)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('subdir, contents', [
|
||||
('', 'hello world'),
|
||||
('somesubdir', 'hi universe'),
|
||||
('unknownpath', None),
|
||||
])
|
||||
def test_load_dockerfile_contents(subdir, contents):
|
||||
trigger = get_github_trigger(subdir)
|
||||
assert trigger.load_dockerfile_contents() == contents
|
||||
|
||||
|
||||
@pytest.mark.parametrize('username, expected_response', [
|
||||
('unknownuser', None),
|
||||
('knownuser', {'html_url': 'https://bitbucket.org/knownuser', 'avatar_url': 'avatarurl'}),
|
||||
])
|
||||
def test_lookup_user(username, expected_response, github_trigger):
|
||||
assert github_trigger.lookup_user(username) == expected_response
|
||||
|
||||
|
||||
def test_list_build_subdirs(github_trigger):
|
||||
assert github_trigger.list_build_subdirs() == ['', 'somesubdir']
|
90
buildtrigger/test/test_gitlabhandler.py
Normal file
90
buildtrigger/test/test_gitlabhandler.py
Normal file
|
@ -0,0 +1,90 @@
|
|||
import json
|
||||
import pytest
|
||||
|
||||
from buildtrigger.test.gitlabmock import get_gitlab_trigger
|
||||
from buildtrigger.triggerutil import (SkipRequestException, ValidationRequestException,
|
||||
InvalidPayloadException)
|
||||
from endpoints.building import PreparedBuild
|
||||
from util.morecollections import AttrDict
|
||||
|
||||
@pytest.fixture
|
||||
def gitlab_trigger():
|
||||
return get_gitlab_trigger()
|
||||
|
||||
|
||||
def test_list_build_subdirs(gitlab_trigger):
|
||||
assert gitlab_trigger.list_build_subdirs() == ['']
|
||||
|
||||
|
||||
@pytest.mark.parametrize('subdir, contents', [
|
||||
('', 'hello world'),
|
||||
('somesubdir', 'hi universe'),
|
||||
('unknownpath', None),
|
||||
])
|
||||
def test_load_dockerfile_contents(subdir, contents):
|
||||
trigger = get_gitlab_trigger(subdir)
|
||||
assert trigger.load_dockerfile_contents() == contents
|
||||
|
||||
|
||||
@pytest.mark.parametrize('email, expected_response', [
|
||||
('unknown@email.com', None),
|
||||
('knownuser', {'username': 'knownuser', 'html_url': 'https://bitbucket.org/knownuser',
|
||||
'avatar_url': 'avatarurl'}),
|
||||
])
|
||||
def test_lookup_user(email, expected_response, gitlab_trigger):
|
||||
assert gitlab_trigger.lookup_user(email) == expected_response
|
||||
|
||||
|
||||
@pytest.mark.parametrize('payload, expected_error, expected_message', [
|
||||
('{}', SkipRequestException, ''),
|
||||
|
||||
# Valid payload:
|
||||
('''{
|
||||
"object_kind": "push",
|
||||
"ref": "refs/heads/master",
|
||||
"checkout_sha": "aaaaaaa",
|
||||
"repository": {
|
||||
"git_ssh_url": "foobar"
|
||||
},
|
||||
"commits": [
|
||||
{
|
||||
"id": "aaaaaaa",
|
||||
"url": "someurl",
|
||||
"message": "hello there!",
|
||||
"timestamp": "now"
|
||||
}
|
||||
]
|
||||
}''', None, None),
|
||||
|
||||
# Skip message:
|
||||
('''{
|
||||
"object_kind": "push",
|
||||
"ref": "refs/heads/master",
|
||||
"checkout_sha": "aaaaaaa",
|
||||
"repository": {
|
||||
"git_ssh_url": "foobar"
|
||||
},
|
||||
"commits": [
|
||||
{
|
||||
"id": "aaaaaaa",
|
||||
"url": "someurl",
|
||||
"message": "[skip build] hello there!",
|
||||
"timestamp": "now"
|
||||
}
|
||||
]
|
||||
}''', SkipRequestException, ''),
|
||||
])
|
||||
def test_handle_trigger_request(gitlab_trigger, payload, expected_error, expected_message):
|
||||
def get_payload():
|
||||
return json.loads(payload)
|
||||
|
||||
request = AttrDict(dict(get_json=get_payload))
|
||||
|
||||
if expected_error is not None:
|
||||
with pytest.raises(expected_error) as ipe:
|
||||
gitlab_trigger.handle_trigger_request(request)
|
||||
assert ipe.value.message == expected_message
|
||||
else:
|
||||
assert isinstance(gitlab_trigger.handle_trigger_request(request), PreparedBuild)
|
||||
|
||||
|
|
@ -328,6 +328,11 @@ def delete_robot(robot_username):
|
|||
robot_username)
|
||||
|
||||
|
||||
def list_namespace_robots(namespace):
|
||||
""" Returns all the robots found under the given namespace. """
|
||||
return _list_entity_robots(namespace)
|
||||
|
||||
|
||||
def _list_entity_robots(entity_name):
|
||||
""" Return the list of robots for the specified entity. This MUST return a query, not a
|
||||
materialized list so that callers can use db_for_update.
|
||||
|
|
|
@ -127,7 +127,7 @@ class BuildTriggerSubdirs(RepositoryParamResource):
|
|||
try:
|
||||
subdirs = handler.list_build_subdirs()
|
||||
return {
|
||||
'subdir': subdirs,
|
||||
'subdir': ['/' + subdir for subdir in subdirs],
|
||||
'status': 'success'
|
||||
}
|
||||
except EmptyRepositoryException as exc:
|
||||
|
@ -288,8 +288,9 @@ class BuildTriggerAnalyze(RepositoryParamResource):
|
|||
contents = handler.load_dockerfile_contents()
|
||||
if not contents:
|
||||
return {
|
||||
'status': 'error',
|
||||
'message': 'Could not read the Dockerfile for the trigger'
|
||||
'status': 'warning',
|
||||
'message': 'Specified Dockerfile path for the trigger was not found on the main ' +
|
||||
'branch. This trigger may fail.',
|
||||
}
|
||||
|
||||
# Parse the contents of the Dockerfile.
|
||||
|
@ -341,42 +342,40 @@ class BuildTriggerAnalyze(RepositoryParamResource):
|
|||
'message': 'Repository "%s" referenced by the Dockerfile was not found' % (base_image)
|
||||
}
|
||||
|
||||
# Check to see if the repository is public. If not, we suggest the
|
||||
# usage of a robot account to conduct the pull.
|
||||
read_robots = []
|
||||
# If the base image is public, mark it as such.
|
||||
if found_repository.visibility.name == 'public':
|
||||
return {
|
||||
'status': 'publicbase'
|
||||
}
|
||||
|
||||
# Otherwise, retrieve the list of robots and mark whether they have read access already.
|
||||
robots = []
|
||||
if AdministerOrganizationPermission(base_namespace).can():
|
||||
perm_query = model.user.get_all_repo_users_transitive(base_namespace, base_repository)
|
||||
user_ids_with_permission = set([user.id for user in perm_query])
|
||||
|
||||
def robot_view(robot):
|
||||
return {
|
||||
'name': robot.username,
|
||||
'kind': 'user',
|
||||
'is_robot': True
|
||||
'is_robot': True,
|
||||
'can_read': robot.id in user_ids_with_permission,
|
||||
}
|
||||
|
||||
def is_valid_robot(user):
|
||||
# Make sure the user is a robot.
|
||||
if not user.robot:
|
||||
return False
|
||||
|
||||
# Make sure the current user can see/administer the robot.
|
||||
(robot_namespace, shortname) = parse_robot_username(user.username)
|
||||
return AdministerOrganizationPermission(robot_namespace).can()
|
||||
|
||||
repo_users = list(model.user.get_all_repo_users_transitive(base_namespace, base_repository))
|
||||
read_robots = [robot_view(user) for user in repo_users if is_valid_robot(user)]
|
||||
robots = [robot_view(robot) for robot in model.user.list_namespace_robots(base_namespace)]
|
||||
|
||||
return {
|
||||
'namespace': base_namespace,
|
||||
'name': base_repository,
|
||||
'is_public': found_repository.visibility.name == 'public',
|
||||
'robots': read_robots,
|
||||
'status': 'analyzed'
|
||||
'robots': robots,
|
||||
'status': 'requiresrobot',
|
||||
'is_admin': AdministerOrganizationPermission(base_namespace).can(),
|
||||
}
|
||||
|
||||
except RepositoryReadException as rre:
|
||||
return {
|
||||
'status': 'error',
|
||||
'message': rre.message
|
||||
'message': 'Could not analyze the repository: %s' % rre.message,
|
||||
}
|
||||
except NotImplementedError:
|
||||
return {
|
||||
|
@ -502,8 +501,54 @@ class BuildTriggerFieldValues(RepositoryParamResource):
|
|||
@internal_only
|
||||
class BuildTriggerSources(RepositoryParamResource):
|
||||
""" Custom verb to fetch the list of build sources for the trigger config. """
|
||||
schemas = {
|
||||
'BuildTriggerSourcesRequest': {
|
||||
'type': 'object',
|
||||
'description': 'Specifies the namespace under which to fetch sources',
|
||||
'properties': {
|
||||
'namespace': {
|
||||
'type': 'string',
|
||||
'description': 'The namespace for which to fetch sources'
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@require_repo_admin
|
||||
@nickname('listTriggerBuildSources')
|
||||
@validate_json_request('BuildTriggerSourcesRequest')
|
||||
def post(self, namespace_name, repo_name, trigger_uuid):
|
||||
""" List the build sources for the trigger configuration thus far. """
|
||||
namespace = request.get_json()['namespace']
|
||||
|
||||
try:
|
||||
trigger = model.build.get_build_trigger(trigger_uuid)
|
||||
except model.InvalidBuildTriggerException:
|
||||
raise NotFound()
|
||||
|
||||
user_permission = UserAdminPermission(trigger.connected_user.username)
|
||||
if user_permission.can():
|
||||
handler = BuildTriggerHandler.get_handler(trigger)
|
||||
|
||||
try:
|
||||
return {
|
||||
'sources': handler.list_build_sources_for_namespace(namespace)
|
||||
}
|
||||
except RepositoryReadException as rre:
|
||||
raise InvalidRequest(rre.message)
|
||||
else:
|
||||
raise Unauthorized()
|
||||
|
||||
|
||||
|
||||
@resource('/v1/repository/<apirepopath:repository>/trigger/<trigger_uuid>/namespaces')
|
||||
@path_param('repository', 'The full path of the repository. e.g. namespace/name')
|
||||
@path_param('trigger_uuid', 'The UUID of the build trigger')
|
||||
@internal_only
|
||||
class BuildTriggerSourceNamespaces(RepositoryParamResource):
|
||||
""" Custom verb to fetch the list of namespaces (orgs, projects, etc) for the trigger config. """
|
||||
@require_repo_admin
|
||||
@nickname('listTriggerBuildSourceNamespaces')
|
||||
def get(self, namespace_name, repo_name, trigger_uuid):
|
||||
""" List the build sources for the trigger configuration thus far. """
|
||||
try:
|
||||
|
@ -517,7 +562,7 @@ class BuildTriggerSources(RepositoryParamResource):
|
|||
|
||||
try:
|
||||
return {
|
||||
'sources': handler.list_build_sources()
|
||||
'namespaces': handler.list_build_source_namespaces()
|
||||
}
|
||||
except RepositoryReadException as rre:
|
||||
raise InvalidRequest(rre.message)
|
||||
|
|
|
@ -40,8 +40,7 @@ def attach_bitbucket_build_trigger(trigger_uuid):
|
|||
repository = trigger.repository.name
|
||||
|
||||
repo_path = '%s/%s' % (namespace, repository)
|
||||
full_url = '%s%s%s' % (url_for('web.repository', path=repo_path), '?tab=builds&newtrigger=',
|
||||
trigger.uuid)
|
||||
full_url = url_for('web.buildtrigger', path=repo_path, trigger=trigger.uuid)
|
||||
|
||||
logger.debug('Redirecting to full url: %s', full_url)
|
||||
return redirect(full_url)
|
||||
|
|
|
@ -34,8 +34,7 @@ def attach_github_build_trigger(namespace_name, repo_name):
|
|||
|
||||
trigger = model.build.create_build_trigger(repo, 'github', token, current_user.db_user())
|
||||
repo_path = '%s/%s' % (namespace_name, repo_name)
|
||||
full_url = '%s%s%s' % (url_for('web.repository', path=repo_path), '?tab=builds&newtrigger=',
|
||||
trigger.uuid)
|
||||
full_url = url_for('web.buildtrigger', path=repo_path, trigger=trigger.uuid)
|
||||
|
||||
logger.debug('Redirecting to full url: %s', full_url)
|
||||
return redirect(full_url)
|
||||
|
|
|
@ -47,8 +47,7 @@ def attach_gitlab_build_trigger():
|
|||
|
||||
trigger = model.build.create_build_trigger(repo, 'gitlab', token, current_user.db_user())
|
||||
repo_path = '%s/%s' % (namespace, repository)
|
||||
full_url = '%s%s%s' % (url_for('web.repository', path=repo_path), '?tab=builds&newtrigger=',
|
||||
trigger.uuid)
|
||||
full_url = url_for('web.buildtrigger', path=repo_path, trigger=trigger.uuid)
|
||||
|
||||
logger.debug('Redirecting to full url: %s', full_url)
|
||||
return redirect(full_url)
|
||||
|
|
|
@ -183,6 +183,13 @@ def confirm_invite():
|
|||
def repository(path):
|
||||
return index('')
|
||||
|
||||
|
||||
@web.route('/repository/<path:path>/trigger/<trigger>', methods=['GET'])
|
||||
@no_cache
|
||||
def buildtrigger(path, trigger):
|
||||
return index('')
|
||||
|
||||
|
||||
@web.route('/starred/')
|
||||
@no_cache
|
||||
def starred():
|
||||
|
@ -659,9 +666,7 @@ def attach_custom_build_trigger(namespace_name, repo_name):
|
|||
None, current_user.db_user())
|
||||
|
||||
repo_path = '%s/%s' % (namespace_name, repo_name)
|
||||
full_url = '%s%s%s' % (url_for('web.repository', path=repo_path), '?tab=builds&newtrigger=',
|
||||
trigger.uuid)
|
||||
|
||||
full_url = url_for('web.buildtrigger', path=repo_path, trigger=trigger.uuid)
|
||||
logger.debug('Redirecting to full url: %s', full_url)
|
||||
return redirect(full_url)
|
||||
|
||||
|
|
|
@ -70,8 +70,9 @@ module.exports = function(grunt) {
|
|||
}
|
||||
},
|
||||
quay: {
|
||||
src: ['../static/partials/*.html', '../static/directives/*.html', '../static/directives/*.html'
|
||||
, '../static/directives/config/*.html', '../static/tutorial/*.html'],
|
||||
src: ['../static/partials/*.html', '../static/directives/*.html', '../static/directives/*.html',
|
||||
'../static/directives/config/*.html', '../static/tutorial/*.html',
|
||||
'../static/js/directives/ui/**/*.html'],
|
||||
dest: '../static/dist/template-cache.js'
|
||||
}
|
||||
},
|
||||
|
|
|
@ -37,10 +37,12 @@
|
|||
"@types/angular": "1.5.16",
|
||||
"@types/angular-mocks": "^1.5.8",
|
||||
"@types/angular-route": "^1.3.3",
|
||||
"@types/es6-shim": "^0.31.32",
|
||||
"@types/jasmine": "^2.5.41",
|
||||
"@types/react": "0.14.39",
|
||||
"@types/react-dom": "0.14.17",
|
||||
"angular-mocks": "^1.5.3",
|
||||
"angular-ts-decorators": "0.0.19",
|
||||
"css-loader": "0.25.0",
|
||||
"jasmine-core": "^2.5.2",
|
||||
"jasmine-ts": "0.0.3",
|
||||
|
@ -56,8 +58,8 @@
|
|||
"sass-loader": "4.0.2",
|
||||
"source-map-loader": "0.1.5",
|
||||
"style-loader": "0.13.1",
|
||||
"ts-loader": "0.9.5",
|
||||
"typescript": "2.2.1",
|
||||
"ts-loader": "^0.9.5",
|
||||
"typescript": "^2.2.1",
|
||||
"typings": "1.4.0",
|
||||
"webpack": "^2.2"
|
||||
}
|
||||
|
|
|
@ -1397,16 +1397,38 @@ a:focus {
|
|||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.co-check-bar .co-filter-box input, .co-top-bar .co-filter-box input {
|
||||
.co-check-bar .co-filter-box input, .co-top-bar .co-filter-box input[type="text"] {
|
||||
width: 300px;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.co-check-bar .co-filter-box input, .co-top-bar .co-filter-box label {
|
||||
margin-left: 6px;
|
||||
}
|
||||
|
||||
.co-top-bar .co-filter-box input {
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 767px) {
|
||||
.co-top-bar .page-controls {
|
||||
display: block;
|
||||
margin-bottom: 10px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.co-top-bar .co-filter-box {
|
||||
display: block;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.co-top-bar .filter-options {
|
||||
display: block;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.empty {
|
||||
border-bottom: none !important;
|
||||
}
|
||||
|
|
64
static/css/directives/ui/linear-workflow.css
Normal file
64
static/css/directives/ui/linear-workflow.css
Normal file
|
@ -0,0 +1,64 @@
|
|||
linear-workflow-section {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
linear-workflow-section.row {
|
||||
margin-left: 0px;
|
||||
margin-right: 0px;
|
||||
}
|
||||
|
||||
linear-workflow .upcoming-table {
|
||||
vertical-align: middle;
|
||||
margin-left: 20px;
|
||||
}
|
||||
|
||||
linear-workflow .upcoming-table .fa {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
linear-workflow .upcoming {
|
||||
color: #888;
|
||||
vertical-align: middle;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
linear-workflow .upcoming ul {
|
||||
padding: 0px;
|
||||
display: inline-block;
|
||||
margin: 0px;
|
||||
}
|
||||
|
||||
linear-workflow .upcoming li {
|
||||
display: inline-block;
|
||||
margin-right: 6px;
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
linear-workflow .upcoming li:after {
|
||||
content: "•";
|
||||
display: inline-block;
|
||||
margin-left: 6px;
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
linear-workflow .upcoming li:last-child:after {
|
||||
content: "";
|
||||
}
|
||||
|
||||
linear-workflow .bottom-controls {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.linear-workflow-section-element {
|
||||
padding: 20px;
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
.linear-workflow-section-element h3, .linear-workflow-section-element strong {
|
||||
color: #444;
|
||||
}
|
||||
|
||||
.linear-workflow-section-element.current-section h3,
|
||||
.linear-workflow-section-element.current-section strong {
|
||||
color: black;
|
||||
}
|
106
static/css/directives/ui/manage-trigger-control.css
Normal file
106
static/css/directives/ui/manage-trigger-control.css
Normal file
|
@ -0,0 +1,106 @@
|
|||
.manage-trigger-control .help-col {
|
||||
padding: 30px;
|
||||
padding-top: 100px;
|
||||
}
|
||||
|
||||
.manage-trigger-control .main-col {
|
||||
padding-left: 10px;
|
||||
padding-right: 10px;
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
.manage-trigger-control strong {
|
||||
margin-bottom: 10px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.manage-trigger-control .namespace-avatar {
|
||||
margin-right: 4px;
|
||||
width: 24px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.manage-trigger-control .importance-col {
|
||||
text-align: center;
|
||||
width: 120px;
|
||||
}
|
||||
|
||||
.manage-trigger-control .co-top-bar {
|
||||
margin-top: 20px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.manage-trigger-control .namespace-avatar {
|
||||
margin-left: 2px;
|
||||
margin-right: 2px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.manage-trigger-control .service-icon {
|
||||
font-size: 24px;
|
||||
margin-right: 4px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.manage-trigger-control .fa-exclamation-triangle {
|
||||
color: #FCA657;
|
||||
}
|
||||
|
||||
.manage-trigger-control .empty-description {
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.manage-trigger-control .radio {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.manage-trigger-control .radio input[type="radio"] {
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.manage-trigger-control .radio label .title {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.manage-trigger-control .radio label .weak {
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
.manage-trigger-control .radio label .description {
|
||||
margin-top: 6px;
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
.manage-trigger-control .radio label .extended {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.manage-trigger-control .radio label td:first-child {
|
||||
vertical-align: top;
|
||||
padding: 4px;
|
||||
padding-right: 10px;
|
||||
}
|
||||
|
||||
.manage-trigger-control .regex-match-view {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.manage-trigger-control h3 .fa {
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.manage-trigger-control h3.warning {
|
||||
color: #FCA657;
|
||||
}
|
||||
|
||||
.manage-trigger-control h3.error {
|
||||
color: #D64456;
|
||||
}
|
||||
|
||||
.manage-trigger-control .success {
|
||||
color: #2FC98E !important;
|
||||
}
|
||||
|
||||
.manage-trigger-control .nowrap-col {
|
||||
white-space: nowrap;
|
||||
}
|
36
static/css/directives/ui/regex-match-view.css
Normal file
36
static/css/directives/ui/regex-match-view.css
Normal file
|
@ -0,0 +1,36 @@
|
|||
.regex-match-view-element .match-list {
|
||||
list-style: none;
|
||||
overflow: auto;
|
||||
max-height: 150px;
|
||||
}
|
||||
|
||||
.regex-match-view-element .match-list li {
|
||||
display: inline-block;
|
||||
margin-right: 4px;
|
||||
width: 120px;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.regex-match-view-element .match-list li .fa {
|
||||
margin-right: 4px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.regex-match-view-element .match-list.not-matching li {
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
.regex-match-view-element .match-list.matching li {
|
||||
color: #2fc98e;
|
||||
}
|
||||
|
||||
.regex-match-view-element .match-table td:first-child {
|
||||
vertical-align: top;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
|
||||
.regex-match-view-element .fa-exclamation-triangle {
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
.setup-trigger-directive-element .dockerfile-found-content {
|
||||
margin-left: 32px;
|
||||
}
|
||||
|
||||
.setup-trigger-directive-element .dockerfile-found-content:before {
|
||||
content: "\f071";
|
||||
font-family: FontAwesome;
|
||||
color: rgb(255, 194, 0);
|
||||
position: absolute;
|
||||
top: 0px;
|
||||
left: 0px;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.setup-trigger-directive-element .loading {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.setup-trigger-directive-element .loading .cor-loader-inline {
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.setup-trigger-directive-element .dockerfile-found {
|
||||
position: relative;
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
|
@ -1,9 +0,0 @@
|
|||
.step-view-step-content .loading-message {
|
||||
position: relative;
|
||||
text-align: center;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.step-view-step-content .loading-message .cor-loader-inline {
|
||||
margin-right: 6px;
|
||||
}
|
35
static/css/pages/trigger-setup.css
Normal file
35
static/css/pages/trigger-setup.css
Normal file
|
@ -0,0 +1,35 @@
|
|||
|
||||
.trigger-setup-element .activated .content {
|
||||
padding-top: 10px;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.trigger-setup-element .activated h3 {
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.trigger-setup-element .button-bar {
|
||||
text-align: right;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.trigger-setup-element .activating .cor-loader-inline {
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
.trigger-setup-element .activating .btn-success {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.trigger-setup-element .activating-message {
|
||||
padding: 10px;
|
||||
padding-left: 30px;
|
||||
}
|
||||
|
||||
.trigger-setup-element .activating-message b {
|
||||
vertical-align: middle;
|
||||
font-size: 18px;
|
||||
font-weight: normal;
|
||||
}
|
|
@ -4,9 +4,7 @@
|
|||
|
||||
<!-- Credentials -->
|
||||
<div ng-repeat="credential in trigger.config.credentials">
|
||||
<p>
|
||||
{{ credential.name }}:
|
||||
<div class="copy-box" value="credential.value"></div>
|
||||
</p>
|
||||
{{ credential.name }}:
|
||||
<div class="copy-box" value="credential.value"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -126,10 +126,8 @@
|
|||
|
||||
<tr ng-repeat="trigger in triggers | filter:{'is_active':false}">
|
||||
<td colspan="5" style="text-align: center">
|
||||
<span class="cor-loader-inline"></span>
|
||||
Trigger Setup in progress:
|
||||
<a ng-click="setupTrigger(trigger)">Resume</a> |
|
||||
<a ng-click="deleteTrigger(trigger)">Cancel</a>
|
||||
This build trigger has not had its setup completed:
|
||||
<a ng-click="deleteTrigger(trigger)">Delete Trigger</a>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
|
@ -185,14 +183,6 @@
|
|||
build-started="handleBuildStarted(build)">
|
||||
</div>
|
||||
|
||||
<!-- Setup trigger dialog-->
|
||||
<div class="setup-trigger-dialog"
|
||||
repository="repository"
|
||||
trigger="currentSetupTrigger"
|
||||
canceled="cancelSetupTrigger(trigger)"
|
||||
counter="showTriggerSetupCounter"
|
||||
trigger-runner="askRunTrigger(trigger)"></div>
|
||||
|
||||
<!-- Manual trigger dialog -->
|
||||
<div class="manual-trigger-build-dialog"
|
||||
repository="repository"
|
||||
|
@ -201,5 +191,4 @@
|
|||
build-started="handleBuildStarted(build)"></div>
|
||||
|
||||
<!-- /Dialogs -->
|
||||
|
||||
</div>
|
||||
|
|
|
@ -1,133 +0,0 @@
|
|||
<div class="setup-trigger-directive-element">
|
||||
<!-- Modal message dialog -->
|
||||
<div class="modal fade" id="setupTriggerModal">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<button type="button" class="close" data-dismiss="modal" aria-hidden="true">×</button>
|
||||
<h4 class="modal-title">Setup new build trigger</h4>
|
||||
</div>
|
||||
<div class="modal-body loading" ng-show="currentView == 'activating'">
|
||||
<span class="cor-loader-inline"></span> Setting up trigger...
|
||||
</div>
|
||||
<div class="modal-body" ng-show="currentView != 'activating'">
|
||||
<!-- Trigger-specific setup -->
|
||||
<div class="trigger-description-element trigger-option-section" ng-switch on="trigger.service">
|
||||
<div ng-switch-when="custom-git">
|
||||
<div class="trigger-setup-custom" repository="repository" trigger="trigger"
|
||||
next-step-counter="nextStepCounter" current-step-valid="state.stepValid"
|
||||
analyze="checkAnalyze(isValid)"></div>
|
||||
</div>
|
||||
<div ng-switch-default>
|
||||
<div class="trigger-setup-githost" repository="repository" trigger="trigger"
|
||||
kind="{{ trigger.service }}"
|
||||
next-step-counter="nextStepCounter" current-step-valid="state.stepValid"
|
||||
analyze="checkAnalyze(isValid)"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading pull information -->
|
||||
<div ng-show="currentView == 'analyzing'" class="loading">
|
||||
<span class="cor-loader-inline"></span> Checking pull credential requirements...
|
||||
</div>
|
||||
|
||||
<!-- Pull information -->
|
||||
<div class="trigger-option-section" ng-show="currentView == 'analyzed'">
|
||||
|
||||
<!-- Messaging -->
|
||||
<div ng-switch on="pullInfo.analysis.status">
|
||||
<div ng-switch-when="error" class="alert alert-danger">{{ pullInfo.analysis.message }}</div>
|
||||
<div ng-switch-when="warning" class="alert alert-warning">{{ pullInfo.analysis.message }}</div>
|
||||
<div ng-switch-when="notimplemented" class="alert alert-warning">
|
||||
<p>For {{ TriggerService.getTitle(trigger.service) }} triggers, we are unable to determine dependencies automatically.</p>
|
||||
<p>If the git repository being built depends on a private base image, you must manually select a robot account with the proper permissions.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dockerfile-found" ng-if="pullInfo.analysis.is_public === false">
|
||||
<div class="dockerfile-found-content">
|
||||
A robot account is <strong>required</strong> for this build trigger because the
|
||||
Dockerfile found
|
||||
pulls from the private <span class="registry-name"></span> repository
|
||||
|
||||
<a href="/repository/{{ pullInfo.analysis.namespace }}/{{ pullInfo.analysis.name }}" ng-safenewtab>
|
||||
{{ pullInfo.analysis.namespace }}/{{ pullInfo.analysis.name }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom: 12px">
|
||||
Please select the credentials to use when pulling the base image:
|
||||
</div>
|
||||
<div ng-if="!isNamespaceAdmin(repository.namespace)" style="color: #aaa;">
|
||||
<strong>Note:</strong> In order to set pull credentials for a build trigger, you must be an
|
||||
Administrator of the namespace <strong>{{ repository.namespace }}</strong>
|
||||
</div>
|
||||
|
||||
<!-- Namespace admin -->
|
||||
<div ng-show="isNamespaceAdmin(repository.namespace)">
|
||||
<!-- Select credentials -->
|
||||
<div class="btn-group btn-group-sm">
|
||||
<button type="button" class="btn btn-default"
|
||||
ng-class="pullInfo.is_public ? 'active btn-info' : ''"
|
||||
ng-click="pullInfo.is_public = true">
|
||||
None
|
||||
</button>
|
||||
<button type="button" class="btn btn-default"
|
||||
ng-class="pullInfo.is_public ? '' : 'active btn-info'"
|
||||
ng-click="pullInfo.is_public = false">
|
||||
<i class="fa ci-robot"></i>
|
||||
Robot account
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Robot Select -->
|
||||
<div ng-show="!pullInfo.is_public" style="margin-top: 10px">
|
||||
<div class="entity-search" namespace="repository.namespace"
|
||||
placeholder="'Select robot account for pulling...'"
|
||||
current-entity="pullInfo.pull_entity"
|
||||
allowed-entities="['robot']"></div>
|
||||
|
||||
<div ng-if="pullInfo.analysis.robots.length" style="margin-top: 20px; margin-bottom: 0px;">
|
||||
<strong>Note</strong>: We've automatically selected robot account
|
||||
<span class="entity-reference" entity="pullInfo.analysis.robots[0]"></span>,
|
||||
since it has access to the private repository.
|
||||
</div>
|
||||
<div ng-if="!pullInfo.analysis.robots.length && pullInfo.analysis.name"
|
||||
style="margin-top: 20px; margin-bottom: 0px;">
|
||||
<strong>Note</strong>: No robot account currently has access to the private repository. Please create one and/or assign access in the
|
||||
<a href="/repository/{{ pullInfo.analysis.namespace }}/{{ pullInfo.analysis.name }}/admin" ng-safenewtab>
|
||||
repository's admin panel.
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="trigger-option-section" ng-show="currentView == 'postActivation'">
|
||||
<div ng-if="trigger.config.credentials" class="credentials" trigger="trigger"></div>
|
||||
<div ng-if="!trigger.config.credentials">
|
||||
<div class="alert alert-success">The trigger has been successfully created.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="modal-footer" ng-show="currentView != 'activating'">
|
||||
<button type="button" class="btn btn-primary" ng-disabled="!state.stepValid"
|
||||
ng-click="nextStepCounter = nextStepCounter + 1"
|
||||
ng-show="currentView == 'config'">Next</button>
|
||||
|
||||
<button type="button" class="btn btn-primary"
|
||||
ng-disabled="!trigger.$ready || (!pullInfo['is_public'] && !pullInfo['pull_entity'])"
|
||||
ng-click="activate()"
|
||||
ng-show="currentView == 'analyzed'">Create Trigger</button>
|
||||
|
||||
<button type="button" class="btn btn-success" ng-click="runTriggerNow()"
|
||||
ng-if="currentView == 'postActivation'">Run Trigger Now</button>
|
||||
|
||||
<button type="button" class="btn btn-default" data-dismiss="modal">{{ currentView == 'postActivation' ? 'Done' : 'Cancel' }}</button>
|
||||
</div>
|
||||
</div><!-- /.modal-content -->
|
||||
</div><!-- /.modal-dialog -->
|
||||
</div><!-- /.modal -->
|
||||
</div>
|
|
@ -1,9 +0,0 @@
|
|||
<div class="step-view-step-content">
|
||||
<div ng-show="!loading">
|
||||
<div ng-transclude></div>
|
||||
</div>
|
||||
<div ng-show="loading" class="loading-message">
|
||||
<span class="cor-loader-inline"></span>
|
||||
{{ loadMessage }}
|
||||
</div>
|
||||
</div>
|
|
@ -1,3 +0,0 @@
|
|||
<div class="step-view-element">
|
||||
<div class="transcluded" ng-transclude>
|
||||
</div>
|
|
@ -1,40 +0,0 @@
|
|||
<div class="trigger-setup-custom-element">
|
||||
<div class="selected-info" ng-show="nextStepCounter > 0">
|
||||
<table style="width: 100%;">
|
||||
<tr ng-show="nextStepCounter > 0">
|
||||
<td width="200px">Repository</td>
|
||||
<td>{{ state.build_source }}</td>
|
||||
</tr>
|
||||
|
||||
<tr ng-show="nextStepCounter > 1">
|
||||
<td>Dockerfile Location:</td>
|
||||
<td>
|
||||
<div class="dockerfile-location">
|
||||
<i class="fa fa-folder fa-lg"></i> {{ state.subdir || '/' }}
|
||||
</div>
|
||||
</td>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Step view -->
|
||||
<div class="step-view" next-step-counter="nextStepCounter" current-step-valid="currentStepValid"
|
||||
steps-completed="stepsCompleted()">
|
||||
|
||||
<!-- Git URL Input -->
|
||||
<!-- TODO(jschorr): make nopLoad(callback) no longer required -->
|
||||
<div class="step-view-step" complete-condition="trigger['config']['build_source']" load-callback="nopLoad(callback)"
|
||||
load-message="Loading Git URL Input">
|
||||
<div style="margin-bottom: 12px;">Please enter an HTTP or SSH style URL used to clone your git repository:</div>
|
||||
<input class="form-control" type="text" placeholder="git@example.com:namespace/repository.git" style="width: 100%;"
|
||||
ng-model="state.build_source" ng-pattern="/(((http|https):\/\/)(.+)|\w+@(.+):(.+))/">
|
||||
</div>
|
||||
|
||||
<!-- Dockerfile folder select -->
|
||||
<div class="step-view-step" complete-condition="trigger.$ready" load-callback="nopLoad(callback)"
|
||||
load-message="Loading Folder Input">
|
||||
<div style="margin-bottom: 12px">Dockerfile Location:</div>
|
||||
<input class="form-control" type="text" placeholder="/" style="width: 100%;"
|
||||
ng-model="state.subdir" ng-pattern="/^($|\/|\/.+)/">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
|
@ -1,201 +0,0 @@
|
|||
<div class="trigger-setup-githost-element">
|
||||
<!-- Current selected info -->
|
||||
<div class="selected-info" ng-show="nextStepCounter > 0">
|
||||
<table style="width: 100%;">
|
||||
<tr ng-show="state.currentRepo && nextStepCounter > 0">
|
||||
<td width="200px">
|
||||
Repository:
|
||||
</td>
|
||||
<td>
|
||||
<div class="current-repo">
|
||||
<i class="dropdown-select-icon org-icon fa" ng-class="scmIcon(kind)"
|
||||
ng-show="!state.currentRepo.avatar_url"></i>
|
||||
<img class="dropdown-select-icon org-icon"
|
||||
ng-src="{{ state.currentRepo.avatar_url ? state.currentRepo.avatar_url : '/static/img/empty.png' }}"
|
||||
ng-show="state.currentRepo.avatar_url">
|
||||
{{ state.currentRepo.repo }}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr ng-show="nextStepCounter > 1">
|
||||
<td>
|
||||
Branches and Tags:
|
||||
</td>
|
||||
<td>
|
||||
<div class="ref-filter">
|
||||
<span ng-if="!state.hasBranchTagFilter">(Build All)</span>
|
||||
<span ng-if="state.hasBranchTagFilter">Regular Expression: <code>{{ state.branchTagFilter }}</code></span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr ng-show="nextStepCounter > 2">
|
||||
<td>
|
||||
Dockerfile Location:
|
||||
</td>
|
||||
<td>
|
||||
<div class="dockerfile-location">
|
||||
<i class="fa fa-folder fa-lg"></i> {{ state.currentLocation || '(Repository Root)' }}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Step view -->
|
||||
<div class="step-view" next-step-counter="nextStepCounter" current-step-valid="currentStepValid"
|
||||
steps-completed="stepsCompleted()">
|
||||
<!-- Repository select -->
|
||||
<div class="step-view-step" complete-condition="state.currentRepo" load-callback="loadRepositories(callback)"
|
||||
load-message="Loading Repositories">
|
||||
<div style="margin-bottom: 12px">Please choose the repository that will trigger the build:</div>
|
||||
<div class="dropdown-select" placeholder="'Enter or select a repository'" selected-item="state.currentRepo"
|
||||
lookahead-items="repoLookahead" allow-custom-input="true">
|
||||
<!-- Icons -->
|
||||
<i class="dropdown-select-icon none-icon fa fa-lg" ng-class="scmIcon(kind)"></i>
|
||||
<img class="dropdown-select-icon org-icon"
|
||||
ng-show="state.currentRepo.avatar_url"
|
||||
ng-src="{{ state.currentRepo.avatar_url ? state.currentRepo.avatar_url : '/static/img/empty.png' }}">
|
||||
<i class="dropdown-select-icon org-icon fa fa-lg" ng-class="scmIcon(kind)"
|
||||
ng-show="!state.currentRepo.avatar_url"></i>
|
||||
|
||||
<!-- Dropdown menu -->
|
||||
<ul class="dropdown-select-menu scrollable-menu" role="menu">
|
||||
<li ng-repeat-start="org in orgs" role="presentation" class="dropdown-header org-header">
|
||||
<img ng-src="{{ org.info.avatar_url }}" class="org-icon">{{ org.info.name }}
|
||||
</li>
|
||||
<li ng-repeat="repo in org.repos" class="trigger-repo-listing">
|
||||
<a ng-click="selectRepo(repo, org)">
|
||||
<i class="fa fa-lg" ng-class="scmIcon(kind)"></i> {{ repo }}
|
||||
</a>
|
||||
</li>
|
||||
<li role="presentation" class="divider" ng-repeat-end ng-show="$index < orgs.length - 1"></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Branch/Tag filter/select -->
|
||||
<div class="step-view-step" complete-condition="!state.hasBranchTagFilter || state.branchTagFilter"
|
||||
load-callback="loadBranchesAndTags(callback)"
|
||||
load-message="Loading Branches and Tags">
|
||||
|
||||
<div style="margin-bottom: 12px">Please choose the branches and tags to which this trigger will apply:</div>
|
||||
<div style="margin-left: 20px;">
|
||||
<div class="btn-group btn-group-sm" style="margin-bottom: 12px">
|
||||
<button type="button" class="btn btn-default"
|
||||
ng-class="state.hasBranchTagFilter ? '' : 'active btn-info'" ng-click="state.hasBranchTagFilter = false">
|
||||
All Branches and Tags
|
||||
</button>
|
||||
<button type="button" class="btn btn-default"
|
||||
ng-class="state.hasBranchTagFilter ? 'active btn-info' : ''" ng-click="state.hasBranchTagFilter = true">
|
||||
Matching Regular Expression
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div ng-show="state.hasBranchTagFilter" style="margin-top: 10px;">
|
||||
<form>
|
||||
<div class="form-group">
|
||||
<div class="input-group">
|
||||
<input class="form-control" type="text" ng-model="state.branchTagFilter"
|
||||
placeholder="(Regular expression. Examples: heads/branchname, tags/tagname)" required>
|
||||
<div class="dropdown input-group-addon">
|
||||
<button class="btn btn-default dropdown-toggle" type="button" data-toggle="dropdown">
|
||||
<span class="caret"></span>
|
||||
</button>
|
||||
<ul class="dropdown-menu pull-right">
|
||||
<li><a ng-click="state.branchTagFilter = 'heads/.+'">
|
||||
<i class="fa fa-code-fork"></i>All Branches</a>
|
||||
</li>
|
||||
<li><a ng-click="state.branchTagFilter = 'tags/.+'">
|
||||
<i class="fa fa-tag"></i>All Tags</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
|
||||
<div style="margin-top: 10px">
|
||||
<div class="ref-matches" ng-if="branchNames.length">
|
||||
<span class="kind">Branches:</span>
|
||||
<ul class="matching-refs branches">
|
||||
<li ng-repeat="branchName in branchNames | limitTo:20"
|
||||
class="ref-reference"
|
||||
ng-class="isMatching('heads', branchName, state.branchTagFilter) ? 'match' : 'not-match'">
|
||||
<span ng-click="addRef('heads', branchName)" ng-safenewtab>
|
||||
{{ branchName }}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
<span ng-if="branchNames.length > 20">...</span>
|
||||
</div>
|
||||
<div class="ref-matches" ng-if="tagNames.length">
|
||||
<span class="kind">Tags:</span>
|
||||
<ul class="matching-refs tags">
|
||||
<li ng-repeat="tagName in tagNames | limitTo:20"
|
||||
class="ref-reference"
|
||||
ng-class="isMatching('tags', tagName, state.branchTagFilter) ? 'match' : 'not-match'">
|
||||
<span ng-click="addRef('tags', tagName)" ng-safenewtab>
|
||||
{{ tagName }}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
<span ng-if="tagNames.length > 20">...</span>
|
||||
</div>
|
||||
<div ng-if="state.branchTagFilter && !branchNames.length"
|
||||
style="margin-top: 10px">
|
||||
<strong>Warning:</strong> No branches found
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dockerfile folder select -->
|
||||
<div class="step-view-step" complete-condition="trigger.$ready" load-callback="loadLocations(callback)"
|
||||
load-message="Loading Folders">
|
||||
|
||||
<div style="margin-bottom: 12px">Dockerfile Location:</div>
|
||||
<div class="dropdown-select" placeholder="'(Repository Root)'" selected-item="state.currentLocation"
|
||||
lookahead-items="locations" handle-input="handleLocationInput(input)"
|
||||
handle-item-selected="handleLocationSelected(datum)"
|
||||
allow-custom-input="true"
|
||||
hide-dropdown="!supportsFullListing">
|
||||
<!-- Icons -->
|
||||
<i class="dropdown-select-icon none-icon fa fa-folder-o fa-lg" ng-show="state.isInvalidLocation"></i>
|
||||
<i class="dropdown-select-icon none-icon fa fa-folder fa-lg" style="color: black;" ng-show="!state.isInvalidLocation"></i>
|
||||
<i class="dropdown-select-icon fa fa-folder fa-lg"></i>
|
||||
|
||||
<!-- Dropdown menu -->
|
||||
<ul class="dropdown-select-menu" role="menu">
|
||||
<li ng-repeat="location in locations">
|
||||
<a ng-click="setLocation(location)" ng-if="!location">
|
||||
<i class="fa fa-github fa-lg"></i> Repository Root
|
||||
</a>
|
||||
<a ng-click="setLocation(location)" ng-if="location">
|
||||
<i class="fa fa-folder fa-lg"></i> {{ location }}
|
||||
</a>
|
||||
</li>
|
||||
<li class="dropdown-header" role="presentation" ng-show="!locations.length">
|
||||
No Dockerfiles found in repository
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="cor-loader" ng-show="!locations && !locationError"></div>
|
||||
<div class="alert alert-warning" ng-show="locationError">
|
||||
{{ locationError }}
|
||||
</div>
|
||||
<div class="alert alert-warning" ng-show="locations && !locations.length && supportsFullListing">
|
||||
Warning: No Dockerfiles were found in {{ state.currentRepo.repo }}
|
||||
</div>
|
||||
<div class="alert alert-info" ng-show="locations.length && state.isInvalidLocation && supportsFullListing">
|
||||
Note: The folder does not currently exist or contain a Dockerfile
|
||||
</div>
|
||||
</div>
|
||||
<!-- /step-view -->
|
||||
</div>
|
||||
</div>
|
|
@ -1,4 +1,4 @@
|
|||
<span>
|
||||
<i class="fa fa-git-square fa-lg" style="margin-right: 6px;" data-title="git" bs-tooltip="tooltip.title"></i>
|
||||
Push to {{ trigger.config.build_source }}
|
||||
Push to repository {{ trigger.config.build_source }}
|
||||
</span>
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
/**
|
||||
* Regex patterns to for validating account names.
|
||||
*/
|
||||
export default {
|
||||
export const NAME_PATTERNS: any = {
|
||||
TEAM_PATTERN: '^[a-z][a-z0-9]+$',
|
||||
ROBOT_PATTERN: '^[a-z][a-z0-9_]{1,254}$',
|
||||
USERNAME_PATTERN: '^(?=.{2,255}$)([a-z0-9]+(?:[._-][a-z0-9]+)*)$',
|
||||
|
|
|
@ -1,44 +0,0 @@
|
|||
/**
|
||||
* Manages the creation and retrieval of pages (route + controller)
|
||||
* TODO: Convert to class/Angular service
|
||||
*/
|
||||
export default {
|
||||
_pages: {},
|
||||
|
||||
/**
|
||||
* Create a page.
|
||||
* @param pageName The name of the page.
|
||||
* @param templateName The file name of the template.
|
||||
* @param controller Controller for the page.
|
||||
* @param flags Additional flags passed to route provider.
|
||||
* @param profiles Available profiles.
|
||||
*/
|
||||
create: function(pageName: string, templateName: string, controller?: Object, flags = {}, profiles = ['old-layout', 'layout']) {
|
||||
for (var i = 0; i < profiles.length; ++i) {
|
||||
this._pages[profiles[i] + ':' + pageName] = {
|
||||
'name': pageName,
|
||||
'controller': controller,
|
||||
'templateName': templateName,
|
||||
'flags': flags
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Retrieve a registered page.
|
||||
* @param pageName The name of the page.
|
||||
* @param profiles Available profiles to search.
|
||||
*/
|
||||
get: function(pageName: string, profiles: any[]) {
|
||||
for (var i = 0; i < profiles.length; ++i) {
|
||||
var current = profiles[i];
|
||||
var key = current.id + ':' + pageName;
|
||||
var page = this._pages[key];
|
||||
if (page) {
|
||||
return [current, page];
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
};
|
|
@ -29,11 +29,9 @@ angular.module('quay').directive('repoPanelBuilds', function () {
|
|||
$scope.currentFilter = null;
|
||||
|
||||
$scope.currentStartTrigger = null;
|
||||
$scope.currentSetupTrigger = null;
|
||||
|
||||
$scope.showBuildDialogCounter = 0;
|
||||
$scope.showTriggerStartDialogCounter = 0;
|
||||
$scope.showTriggerSetupCounter = 0;
|
||||
|
||||
$scope.triggerCredentialsModalTrigger = null;
|
||||
$scope.triggerCredentialsModalCounter = 0;
|
||||
|
@ -144,16 +142,6 @@ angular.module('quay').directive('repoPanelBuilds', function () {
|
|||
|
||||
$scope.triggersResource = ApiService.listBuildTriggersAsResource(params).get(function(resp) {
|
||||
$scope.triggers = resp.triggers;
|
||||
|
||||
// Check to see if we need to setup any trigger.
|
||||
var newTriggerId = $routeParams.newtrigger;
|
||||
if (newTriggerId) {
|
||||
$scope.triggers.map(function(trigger) {
|
||||
if (trigger['id'] == newTriggerId && !trigger['is_active']) {
|
||||
$scope.setupTrigger(trigger);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
|
@ -208,18 +196,6 @@ angular.module('quay').directive('repoPanelBuilds', function () {
|
|||
$scope.showTriggerStartDialogCounter++;
|
||||
};
|
||||
|
||||
$scope.cancelSetupTrigger = function(trigger) {
|
||||
if ($scope.currentSetupTrigger != trigger) { return; }
|
||||
|
||||
$scope.currentSetupTrigger = null;
|
||||
$scope.deleteTrigger(trigger);
|
||||
};
|
||||
|
||||
$scope.setupTrigger = function(trigger) {
|
||||
$scope.currentSetupTrigger = trigger;
|
||||
$scope.showTriggerSetupCounter++;
|
||||
};
|
||||
|
||||
$scope.deleteTrigger = function(trigger, opt_callback) {
|
||||
if (!trigger) { return; }
|
||||
|
||||
|
|
|
@ -0,0 +1,37 @@
|
|||
<div class="dockerfile-path-select-element">
|
||||
<div class="dropdown-select" placeholder="'Enter path containing a Dockerfile'"
|
||||
selected-item="$ctrl.selectedPath"
|
||||
lookahead-items="$ctrl.paths"
|
||||
handle-input="$ctrl.setPath(input)"
|
||||
handle-item-selected="$ctrl.setSelectedPath(datum.value)"
|
||||
allow-custom-input="true"
|
||||
hide-dropdown="!$ctrl.supportsFullListing">
|
||||
<!-- Icons -->
|
||||
<i class="dropdown-select-icon none-icon fa fa-folder-o fa-lg"
|
||||
ng-show="$ctrl.isUnknownPath"></i>
|
||||
<i class="dropdown-select-icon none-icon fa fa-folder fa-lg" style="color: black;"
|
||||
ng-show="!$ctrl.isUnknownPath"></i>
|
||||
<i class="dropdown-select-icon fa fa-folder fa-lg"></i>
|
||||
|
||||
<!-- Dropdown menu -->
|
||||
<ul class="dropdown-select-menu pull-right" role="menu">
|
||||
<li ng-repeat="path in $ctrl.paths">
|
||||
<a ng-click="$ctrl.setSelectedPath(path)"
|
||||
ng-if="path">
|
||||
<i class="fa fa-folder fa-lg"></i> {{ path }}
|
||||
</a>
|
||||
</li>
|
||||
<li class="dropdown-header" role="presentation"
|
||||
ng-show="!$ctrl.paths.length">
|
||||
No Dockerfiles found in repository
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div style="padding: 10px">
|
||||
<div class="co-alert co-alert-danger"
|
||||
ng-show="!$ctrl.isValidPath && $ctrl.currentPath">
|
||||
Path entered for folder containing Dockerfile is invalid: Must start with a '/'.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
|
@ -0,0 +1,90 @@
|
|||
import { DockerfilePathSelectComponent } from './dockerfile-path-select.component';
|
||||
|
||||
|
||||
describe("DockerfilePathSelectComponent", () => {
|
||||
var component: DockerfilePathSelectComponent;
|
||||
var currentPath: string;
|
||||
var isValidPath: boolean;
|
||||
var paths: string[];
|
||||
var supportsFullListing: boolean;
|
||||
|
||||
beforeEach(() => {
|
||||
component = new DockerfilePathSelectComponent();
|
||||
currentPath = '/';
|
||||
isValidPath = false;
|
||||
paths = ['/'];
|
||||
supportsFullListing = true;
|
||||
component.currentPath = currentPath;
|
||||
component.isValidPath = isValidPath;
|
||||
component.paths = paths;
|
||||
component.supportsFullListing = supportsFullListing;
|
||||
});
|
||||
|
||||
describe("$onChanges", () => {
|
||||
|
||||
it("sets valid path flag to true if current path is valid", () => {
|
||||
component.$onChanges({});
|
||||
|
||||
expect(component.isValidPath).toBe(true);
|
||||
});
|
||||
|
||||
it("sets valid path flag to false if current path is invalid", () => {
|
||||
component.currentPath = "asdfdsf";
|
||||
component.$onChanges({});
|
||||
|
||||
expect(component.isValidPath).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setPath", () => {
|
||||
var newPath: string;
|
||||
|
||||
beforeEach(() => {
|
||||
newPath = '/conf';
|
||||
});
|
||||
|
||||
it("sets current path to given path", () => {
|
||||
component.setPath(newPath);
|
||||
|
||||
expect(component.currentPath).toEqual(newPath);
|
||||
});
|
||||
|
||||
it("sets valid path flag to true if given path is valid", () => {
|
||||
component.setPath(newPath);
|
||||
|
||||
expect(component.isValidPath).toBe(true);
|
||||
});
|
||||
|
||||
it("sets valid path flag to false if given path is invalid", () => {
|
||||
component.setPath("asdfsadfs");
|
||||
|
||||
expect(component.isValidPath).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setCurrentPath", () => {
|
||||
var newPath: string;
|
||||
|
||||
beforeEach(() => {
|
||||
newPath = '/conf';
|
||||
});
|
||||
|
||||
it("sets current path to given path", () => {
|
||||
component.setSelectedPath(newPath);
|
||||
|
||||
expect(component.currentPath).toEqual(newPath);
|
||||
});
|
||||
|
||||
it("sets valid path flag to true if given path is valid", () => {
|
||||
component.setSelectedPath(newPath);
|
||||
|
||||
expect(component.isValidPath).toBe(true);
|
||||
});
|
||||
|
||||
it("sets valid path flag to false if given path is invalid", () => {
|
||||
component.setSelectedPath("a;lskjdf;ldsa");
|
||||
|
||||
expect(component.isValidPath).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,47 @@
|
|||
import { Input, Component } from 'angular-ts-decorators';
|
||||
|
||||
|
||||
/**
|
||||
* A component that allows the user to select the location of the Dockerfile in their source code repository.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'dockerfilePathSelect',
|
||||
templateUrl: '/static/js/directives/ui/dockerfile-path-select/dockerfile-path-select.component.html'
|
||||
})
|
||||
export class DockerfilePathSelectComponent implements ng.IComponentController {
|
||||
|
||||
// FIXME: Use one-way data binding
|
||||
@Input('=') public currentPath: string;
|
||||
@Input('=') public isValidPath: boolean;
|
||||
@Input('=') public paths: string[];
|
||||
@Input('=') public supportsFullListing: boolean;
|
||||
private isUnknownPath: boolean = true;
|
||||
private selectedPath: string | null = null;
|
||||
|
||||
public $onChanges(changes: ng.IOnChangesObject): void {
|
||||
this.isValidPath = this.checkPath(this.currentPath, this.paths, this.supportsFullListing);
|
||||
}
|
||||
|
||||
public setPath(path: string): void {
|
||||
this.currentPath = path;
|
||||
this.selectedPath = null;
|
||||
this.isValidPath = this.checkPath(path, this.paths, this.supportsFullListing);
|
||||
}
|
||||
|
||||
public setSelectedPath(path: string): void {
|
||||
this.currentPath = path;
|
||||
this.selectedPath = path;
|
||||
this.isValidPath = this.checkPath(path, this.paths, this.supportsFullListing);
|
||||
}
|
||||
|
||||
private checkPath(path: string = '', paths: string[] = [], supportsFullListing: boolean): boolean {
|
||||
this.isUnknownPath = false;
|
||||
var isValidPath: boolean = false;
|
||||
|
||||
if (path.length > 0 && path[0] === '/') {
|
||||
isValidPath = true;
|
||||
this.isUnknownPath = supportsFullListing && paths.indexOf(path) < 0;
|
||||
}
|
||||
return isValidPath;
|
||||
}
|
||||
}
|
|
@ -28,36 +28,29 @@ angular.module('quay').directive('dropdownSelect', function ($compile) {
|
|||
}
|
||||
|
||||
$scope.placeholder = $scope.placeholder || '';
|
||||
$scope.internalItem = null;
|
||||
$scope.lookaheadSetup = false;
|
||||
|
||||
// Setup lookahead.
|
||||
var input = $($element).find('.lookahead-input');
|
||||
|
||||
$scope.$watch('clearValue', function(cv) {
|
||||
if (cv) {
|
||||
if (cv && $scope.lookaheadSetup) {
|
||||
$scope.selectedItem = null;
|
||||
$(input).val('');
|
||||
$(input).typeahead('val', '');
|
||||
$(input).typeahead('close');
|
||||
}
|
||||
});
|
||||
|
||||
$scope.$watch('selectedItem', function(item) {
|
||||
if ($scope.selectedItem == $scope.internalItem) {
|
||||
// The item has already been set due to an internal action.
|
||||
return;
|
||||
}
|
||||
|
||||
if ($scope.selectedItem != null) {
|
||||
$(input).val(item.toString());
|
||||
} else {
|
||||
$(input).val('');
|
||||
if (item != null && $scope.lookaheadSetup) {
|
||||
$(input).typeahead('val', item.toString());
|
||||
$(input).typeahead('close');
|
||||
}
|
||||
});
|
||||
|
||||
$scope.$watch('lookaheadItems', function(items) {
|
||||
$(input).off();
|
||||
if (!items) {
|
||||
return;
|
||||
}
|
||||
items = items || [];
|
||||
|
||||
var formattedItems = [];
|
||||
for (var i = 0; i < items.length; ++i) {
|
||||
|
@ -80,7 +73,10 @@ angular.module('quay').directive('dropdownSelect', function ($compile) {
|
|||
});
|
||||
dropdownHound.initialize();
|
||||
|
||||
$(input).typeahead({}, {
|
||||
$(input).typeahead({
|
||||
'hint': false,
|
||||
'highlight': false
|
||||
}, {
|
||||
source: dropdownHound.ttAdapter(),
|
||||
templates: {
|
||||
'suggestion': function (datum) {
|
||||
|
@ -92,7 +88,6 @@ angular.module('quay').directive('dropdownSelect', function ($compile) {
|
|||
|
||||
$(input).on('input', function(e) {
|
||||
$scope.$apply(function() {
|
||||
$scope.internalItem = null;
|
||||
$scope.selectedItem = null;
|
||||
if ($scope.handleInput) {
|
||||
$scope.handleInput({'input': $(input).val()});
|
||||
|
@ -102,7 +97,6 @@ angular.module('quay').directive('dropdownSelect', function ($compile) {
|
|||
|
||||
$(input).on('typeahead:selected', function(e, datum) {
|
||||
$scope.$apply(function() {
|
||||
$scope.internalItem = datum['item'] || datum['value'];
|
||||
$scope.selectedItem = datum['item'] || datum['value'];
|
||||
if ($scope.handleItemSelected) {
|
||||
$scope.handleItemSelected({'datum': datum});
|
||||
|
@ -111,6 +105,7 @@ angular.module('quay').directive('dropdownSelect', function ($compile) {
|
|||
});
|
||||
|
||||
$rootScope.__dropdownSelectCounter++;
|
||||
$scope.lookaheadSetup = true;
|
||||
});
|
||||
},
|
||||
link: function(scope, element, attrs) {
|
||||
|
|
|
@ -0,0 +1,7 @@
|
|||
<div class="linear-workflow-section-element"
|
||||
ng-if="$ctrl.sectionVisible"
|
||||
ng-class="$ctrl.isCurrentSection ? 'current-section' : ''">
|
||||
<form ng-submit="$ctrl.onSubmitSection()">
|
||||
<div ng-transclude />
|
||||
</form>
|
||||
</div>
|
|
@ -0,0 +1,82 @@
|
|||
import { LinearWorkflowSectionComponent } from './linear-workflow-section.component';
|
||||
import { LinearWorkflowComponent } from './linear-workflow.component';
|
||||
import Spy = jasmine.Spy;
|
||||
|
||||
|
||||
describe("LinearWorkflowSectionComponent", () => {
|
||||
var component: LinearWorkflowSectionComponent;
|
||||
var parentMock: LinearWorkflowComponent;
|
||||
|
||||
beforeEach(() => {
|
||||
component = new LinearWorkflowSectionComponent();
|
||||
parentMock = new LinearWorkflowComponent();
|
||||
component.parent = parentMock;
|
||||
});
|
||||
|
||||
describe("$onInit", () => {
|
||||
|
||||
it("calls parent component to add itself as a section", () => {
|
||||
var addSectionSpy: Spy = spyOn(parentMock, "addSection").and.returnValue(null);
|
||||
component.$onInit();
|
||||
|
||||
expect(addSectionSpy.calls.argsFor(0)[0]).toBe(component);
|
||||
});
|
||||
});
|
||||
|
||||
describe("$onChanges", () => {
|
||||
var onSectionInvalidSpy: Spy;
|
||||
var changesObj: ng.IOnChangesObject;
|
||||
|
||||
beforeEach(() => {
|
||||
onSectionInvalidSpy = spyOn(parentMock, "onSectionInvalid").and.returnValue(null);
|
||||
changesObj = {
|
||||
sectionValid: {
|
||||
currentValue: true,
|
||||
previousValue: false,
|
||||
isFirstChange: () => false,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
it("does nothing if 'sectionValid' input not changed", () => {
|
||||
component.$onChanges({});
|
||||
|
||||
expect(onSectionInvalidSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does nothing if 'sectionValid' input is true", () => {
|
||||
component.$onChanges(changesObj);
|
||||
|
||||
expect(onSectionInvalidSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls parent method to inform that section is invalid if 'sectionValid' input changed to false", () => {
|
||||
changesObj['sectionValid'].currentValue = false;
|
||||
component.$onChanges(changesObj);
|
||||
|
||||
expect(onSectionInvalidSpy.calls.argsFor(0)[0]).toEqual(component.sectionId);
|
||||
});
|
||||
});
|
||||
|
||||
describe("onSubmitSection", () => {
|
||||
var onNextSectionSpy: Spy;
|
||||
|
||||
beforeEach(() => {
|
||||
onNextSectionSpy = spyOn(parentMock, "onNextSection").and.returnValue(null);
|
||||
});
|
||||
|
||||
it("does nothing if section is invalid", () => {
|
||||
component.sectionValid = false;
|
||||
component.onSubmitSection();
|
||||
|
||||
expect(onNextSectionSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls parent method to go to next section if section is valid", () => {
|
||||
component.sectionValid = true;
|
||||
component.onSubmitSection();
|
||||
|
||||
expect(onNextSectionSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,40 @@
|
|||
import { Component, Output, Input } from 'angular-ts-decorators';
|
||||
import { LinearWorkflowComponent } from './linear-workflow.component';
|
||||
|
||||
|
||||
/**
|
||||
* A component which displays a single section in a linear workflow.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'linearWorkflowSection',
|
||||
templateUrl: '/static/js/directives/ui/linear-workflow/linear-workflow-section.component.html',
|
||||
transclude: true,
|
||||
require: {
|
||||
parent: '^^linearWorkflow'
|
||||
}
|
||||
})
|
||||
export class LinearWorkflowSectionComponent implements ng.IComponentController {
|
||||
|
||||
@Input('@') public sectionId: string;
|
||||
@Input('@') public sectionTitle: string;
|
||||
@Input() public sectionValid: boolean = false;
|
||||
public sectionVisible: boolean = false;
|
||||
public isCurrentSection: boolean = false;
|
||||
public parent: LinearWorkflowComponent;
|
||||
|
||||
public $onInit(): void {
|
||||
this.parent.addSection(this);
|
||||
}
|
||||
|
||||
public $onChanges(changes: ng.IOnChangesObject): void {
|
||||
if (changes['sectionValid'] !== undefined && !changes['sectionValid'].currentValue) {
|
||||
this.parent.onSectionInvalid(this.sectionId);
|
||||
}
|
||||
}
|
||||
|
||||
public onSubmitSection(): void {
|
||||
if (this.sectionValid) {
|
||||
this.parent.onNextSection();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,39 @@
|
|||
<div class="linear-workflow-element">
|
||||
<!-- Contents -->
|
||||
<div ng-transclude />
|
||||
|
||||
<div class="bottom-controls">
|
||||
<table class="upcoming-table">
|
||||
<tr>
|
||||
<td>
|
||||
<!-- Next button -->
|
||||
<button class="btn btn-primary"
|
||||
ng-disabled="!$ctrl.currentSection.component.sectionValid"
|
||||
ng-click="$ctrl.onNextSection()"
|
||||
ng-class="{
|
||||
'btn-success': $ctrl.currentSection.index == $ctrl.sections.length - 1,
|
||||
'btn-lg': $ctrl.currentSection.index == $ctrl.sections.length - 1
|
||||
}">
|
||||
<span ng-if="$ctrl.currentSection.index != sections.length - 1">Continue</span>
|
||||
<span ng-if="$ctrl.currentSection.index == sections.length - 1">
|
||||
<i class="fa fa-check-circle"></i>{{ ::$ctrl.doneTitle }}
|
||||
</span>
|
||||
</button>
|
||||
</td>
|
||||
<td>
|
||||
<!-- Next sections -->
|
||||
<div class="upcoming"
|
||||
ng-if="$ctrl.currentSection.index < $ctrl.sections.length - 1">
|
||||
<b>Next:</b>
|
||||
<ul>
|
||||
<li ng-repeat="section in $ctrl.sections"
|
||||
ng-if="section.index > $ctrl.currentSection.index">
|
||||
{{ section.component.sectionTitle }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
|
@ -0,0 +1,127 @@
|
|||
import { LinearWorkflowComponent, SectionInfo } from './linear-workflow.component';
|
||||
import { LinearWorkflowSectionComponent } from './linear-workflow-section.component';
|
||||
import Spy = jasmine.Spy;
|
||||
|
||||
|
||||
describe("LinearWorkflowComponent", () => {
|
||||
var component: LinearWorkflowComponent;
|
||||
|
||||
beforeEach(() => {
|
||||
component = new LinearWorkflowComponent();
|
||||
});
|
||||
|
||||
describe("addSection", () => {
|
||||
var newSection: LinearWorkflowSectionComponent;
|
||||
|
||||
beforeEach(() => {
|
||||
newSection = new LinearWorkflowSectionComponent;
|
||||
});
|
||||
|
||||
it("does not set 'sectionVisible' or 'isCurrentSection' of given section if not the first section added", () => {
|
||||
component.addSection(new LinearWorkflowSectionComponent);
|
||||
component.addSection(newSection);
|
||||
|
||||
expect(newSection.sectionVisible).toBe(false);
|
||||
expect(newSection.isCurrentSection).toBe(false);
|
||||
});
|
||||
|
||||
it("sets 'sectionVisible' of given section to true if it is the first section added", () => {
|
||||
component.addSection(newSection);
|
||||
|
||||
expect(newSection.sectionVisible).toBe(true);
|
||||
});
|
||||
|
||||
it("sets 'isCurrentSection' of given section to true if it is the first section added", () => {
|
||||
component.addSection(newSection);
|
||||
|
||||
expect(newSection.isCurrentSection).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("onNextSection", () => {
|
||||
var currentSection: LinearWorkflowSectionComponent;
|
||||
|
||||
beforeEach(() => {
|
||||
component.onWorkflowComplete = jasmine.createSpy("onWorkflowComplete").and.returnValue(null);
|
||||
currentSection = new LinearWorkflowSectionComponent;
|
||||
currentSection.sectionValid = true;
|
||||
component.addSection(currentSection);
|
||||
});
|
||||
|
||||
it("does not complete workflow or change current section if current section is invalid", () => {
|
||||
currentSection.sectionValid = false;
|
||||
component.onNextSection();
|
||||
|
||||
expect(component.onWorkflowComplete).not.toHaveBeenCalled();
|
||||
expect(currentSection.isCurrentSection).toBe(true);
|
||||
});
|
||||
|
||||
it("calls workflow completed output callback if current section is the last section and is valid", () => {
|
||||
component.onNextSection();
|
||||
|
||||
expect(component.onWorkflowComplete).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sets the current section to the next section if there are remaining sections and current section valid", () => {
|
||||
var nextSection: LinearWorkflowSectionComponent = new LinearWorkflowSectionComponent();
|
||||
component.addSection(nextSection);
|
||||
component.onNextSection();
|
||||
|
||||
expect(currentSection.isCurrentSection).toBe(false);
|
||||
expect(nextSection.isCurrentSection).toBe(true);
|
||||
expect(nextSection.sectionVisible).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("onSectionInvalid", () => {
|
||||
var invalidSection: LinearWorkflowSectionComponent;
|
||||
var sections: LinearWorkflowSectionComponent[];
|
||||
|
||||
beforeEach(() => {
|
||||
invalidSection = new LinearWorkflowSectionComponent();
|
||||
invalidSection.sectionId = "Git Repository";
|
||||
invalidSection.sectionValid = false;
|
||||
component.addSection(invalidSection);
|
||||
|
||||
sections = [
|
||||
new LinearWorkflowSectionComponent(),
|
||||
new LinearWorkflowSectionComponent(),
|
||||
new LinearWorkflowSectionComponent(),
|
||||
];
|
||||
sections.forEach((section) => {
|
||||
section.sectionVisible = false;
|
||||
section.isCurrentSection = false;
|
||||
component.addSection(section);
|
||||
});
|
||||
});
|
||||
|
||||
it("does nothing if invalid section is after the current section", () => {
|
||||
sections[sections.length - 1].sectionValid = false;
|
||||
sections[sections.length - 1].sectionId = "Some Section";
|
||||
component.onSectionInvalid(sections[sections.length - 1].sectionId);
|
||||
|
||||
expect(sections[sections.length - 1].isCurrentSection).toBe(false);
|
||||
expect(sections[sections.length - 1].sectionVisible).toBe(false);
|
||||
});
|
||||
|
||||
it("sets the section with the given id to be the current section", () => {
|
||||
component.onSectionInvalid(invalidSection.sectionId);
|
||||
|
||||
expect(invalidSection.isCurrentSection).toBe(true);
|
||||
});
|
||||
|
||||
it("hides all sections after the section with the given id", () => {
|
||||
sections.forEach((section) => {
|
||||
section.sectionVisible = true;
|
||||
section.isCurrentSection = true;
|
||||
component.addSection(section);
|
||||
});
|
||||
component.onSectionInvalid(invalidSection.sectionId);
|
||||
|
||||
sections.forEach((section) => {
|
||||
expect(section.sectionVisible).toBe(false);
|
||||
expect(section.isCurrentSection).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,68 @@
|
|||
import { Component, Output, Input } from 'angular-ts-decorators';
|
||||
import { LinearWorkflowSectionComponent } from './linear-workflow-section.component';
|
||||
|
||||
|
||||
/**
|
||||
* A component that which displays a linear workflow of sections, each completed in order before the next
|
||||
* step is made visible.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'linearWorkflow',
|
||||
templateUrl: '/static/js/directives/ui/linear-workflow/linear-workflow.component.html',
|
||||
transclude: true
|
||||
})
|
||||
export class LinearWorkflowComponent implements ng.IComponentController {
|
||||
|
||||
@Input('@') public doneTitle: string;
|
||||
@Output() public onWorkflowComplete: (event: any) => void;
|
||||
private sections: SectionInfo[] = [];
|
||||
private currentSection: SectionInfo;
|
||||
|
||||
public addSection(component: LinearWorkflowSectionComponent): void {
|
||||
this.sections.push({
|
||||
index: this.sections.length,
|
||||
component: component,
|
||||
});
|
||||
|
||||
if (this.sections.length == 1) {
|
||||
this.currentSection = this.sections[0];
|
||||
this.currentSection.component.sectionVisible = true;
|
||||
this.currentSection.component.isCurrentSection = true;
|
||||
}
|
||||
}
|
||||
|
||||
public onNextSection(): void {
|
||||
if (this.currentSection.component.sectionValid && this.currentSection.index + 1 >= this.sections.length) {
|
||||
this.onWorkflowComplete({});
|
||||
}
|
||||
else if (this.currentSection.component.sectionValid && this.currentSection.index + 1 < this.sections.length) {
|
||||
this.currentSection.component.isCurrentSection = false;
|
||||
this.currentSection = this.sections[this.currentSection.index + 1];
|
||||
this.currentSection.component.sectionVisible = true;
|
||||
this.currentSection.component.isCurrentSection = true;
|
||||
}
|
||||
}
|
||||
|
||||
public onSectionInvalid(sectionId: string): void {
|
||||
var invalidSection = this.sections.filter(section => section.component.sectionId == sectionId)[0];
|
||||
if (invalidSection.index <= this.currentSection.index) {
|
||||
invalidSection.component.isCurrentSection = true;
|
||||
this.currentSection = invalidSection;
|
||||
this.sections.forEach((section) => {
|
||||
if (section.index > invalidSection.index) {
|
||||
section.component.sectionVisible = false;
|
||||
section.component.isCurrentSection = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* A type representing a section of the linear workflow.
|
||||
*/
|
||||
export type SectionInfo = {
|
||||
index: number;
|
||||
component: LinearWorkflowSectionComponent;
|
||||
}
|
|
@ -0,0 +1,44 @@
|
|||
<div class="manage-trigger-custom-git-element manage-trigger-control">
|
||||
<linear-workflow
|
||||
done-title="Create Trigger"
|
||||
on-workflow-complete="$ctrl.activateTrigger({'config': $ctrl.config})">
|
||||
<!-- Section: Repository -->
|
||||
<linear-workflow-section class="row"
|
||||
section-id="repo"
|
||||
section-title="Git Repository"
|
||||
section-valid="$ctrl.config.build_source !== undefined">
|
||||
|
||||
<div class="col-lg-7 col-md-7 col-sm-12 main-col">
|
||||
<h3>Enter repository</h3>
|
||||
<strong>Please enter the HTTP or SSH style URL used to clone your git repository:</strong>
|
||||
<input class="form-control" type="text" placeholder="git@example.com:namespace/repository.git"
|
||||
ng-model="$ctrl.config.build_source"
|
||||
ng-pattern="/(((http|https):\/\/)(.+)|\w+@(.+):(.+))/">
|
||||
</div>
|
||||
<div class="col-lg-5 col-md-5 hidden-sm hidden-xs help-col">
|
||||
<p>Custom git triggers support any externally accessible git repository, via either the normal git protocol or HTTP.</p>
|
||||
|
||||
<p><b>It is the responsibility of the git repository to invoke a webhook to tell <span class="registry-name" short="true"></span> that a commit has been added.</b></p>
|
||||
</div>
|
||||
</linear-workflow-section><!-- /Section: Repository -->
|
||||
|
||||
<!-- Section: Build context -->
|
||||
<linear-workflow-section class="row"
|
||||
section-id="dockerfile"
|
||||
section-title="Build context"
|
||||
section-valid="$ctrl.config.subdir">
|
||||
|
||||
<div class="col-lg-7 col-md-7 col-sm-12 main-col">
|
||||
<h3>Select build context directory</h3>
|
||||
<strong>Please select the build context directory under the git repository:</strong>
|
||||
<input class="form-control" type="text" placeholder="/"
|
||||
ng-model="$ctrl.config.subdir"
|
||||
ng-pattern="/^($|\/|\/.+)/">
|
||||
</div>
|
||||
<div class="col-lg-5 col-md-5 hidden-sm hidden-xs help-col">
|
||||
<p>The build context directory is the path of the directory containing the Dockerfile and any other files to be made available when the build is triggered.</p>
|
||||
<p>If the Dockerfile is located at the root of the git repository, enter <code>/</code> as the build context directory.</p>
|
||||
</div>
|
||||
</linear-workflow-section><!-- /Section: Build context -->
|
||||
</linear-workflow>
|
||||
</div>
|
|
@ -0,0 +1,14 @@
|
|||
import { ManageTriggerCustomGitComponent } from './manage-trigger-custom-git.component';
|
||||
|
||||
|
||||
describe("ManageTriggerCustomGitComponent", () => {
|
||||
var component: ManageTriggerCustomGitComponent;
|
||||
|
||||
beforeEach(() => {
|
||||
component = new ManageTriggerCustomGitComponent();
|
||||
});
|
||||
|
||||
describe("$onChanges", () => {
|
||||
|
||||
});
|
||||
});
|
|
@ -0,0 +1,24 @@
|
|||
import { Input, Output, Component } from 'angular-ts-decorators';
|
||||
|
||||
|
||||
/**
|
||||
* A component that lets the user set up a build trigger for a custom Git repository.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'manageTriggerCustomGit',
|
||||
templateUrl: '/static/js/directives/ui/manage-trigger-custom-git/manage-trigger-custom-git.component.html'
|
||||
})
|
||||
export class ManageTriggerCustomGitComponent implements ng.IComponentController {
|
||||
|
||||
// FIXME: Use one-way data binding
|
||||
@Input('=') public trigger: {config: any};
|
||||
@Output() public activateTrigger: (trigger: {config: any}) => void;
|
||||
private config: any = {};
|
||||
private currentState: any | null;
|
||||
|
||||
public $onChanges(changes: ng.IOnChangesObject): void {
|
||||
if (changes['trigger'] !== undefined) {
|
||||
this.config = Object.assign({}, changes['trigger'].currentValue.config);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,374 @@
|
|||
<div class="manage-trigger-githost-element manage-trigger-control">
|
||||
<linear-workflow
|
||||
done-title="Create Trigger"
|
||||
on-workflow-complete="$ctrl.createTrigger()">
|
||||
|
||||
<!-- Section: Namespace -->
|
||||
<linear-workflow-section class="row"
|
||||
section-id="namespace"
|
||||
section-title="{{ 'Select ' + $ctrl.namespaceTitle }}"
|
||||
section-valid="$ctrl.local.selectedNamespace">
|
||||
<div class="col-lg-7 col-md-7 col-sm-12 main-col"
|
||||
ng-if="$ctrl.local.namespaces">
|
||||
<h3>Select {{ $ctrl.namespaceTitle }}</h3>
|
||||
<strong>Please select the {{ $ctrl.namespaceTitle }} under which the repository lives</strong>
|
||||
|
||||
<div class="co-top-bar">
|
||||
<div class="co-filter-box">
|
||||
<span class="page-controls"
|
||||
total-count="$ctrl.local.orderedNamespaces.entries.length"
|
||||
current-page="$ctrl.local.namespaceOptions.page"
|
||||
page-size="$ctrl.namespacesPerPage"></span>
|
||||
<input class="form-control" type="text"
|
||||
ng-model="$ctrl.local.namespaceOptions.filter"
|
||||
placeholder="Filter {{ $ctrl.namespaceTitle }}s...">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table class="co-table" style="margin-top: 20px;">
|
||||
<thead>
|
||||
<td class="checkbox-col"></td>
|
||||
<td ng-class="$ctrl.TableService.tablePredicateClass('id', $ctrl.local.namespaceOptions.predicate, $ctrl.local.namespaceOptions.reverse)">
|
||||
<a ng-click="$ctrl.TableService.orderBy('id', $ctrl.local.namespaceOptions)">{{ $ctrl.namespaceTitle }}</a>
|
||||
</td>
|
||||
<td ng-class="$ctrl.TableService.tablePredicateClass('score', $ctrl.local.namespaceOptions.predicate, $ctrl.local.namespaceOptions.reverse)"
|
||||
class="importance-col hidden-xs">
|
||||
<a ng-click="$ctrl.TableService.orderBy('score', $ctrl.local.namespaceOptions)">Importance</a>
|
||||
</td>
|
||||
</thead>
|
||||
|
||||
<tr class="co-checkable-row"
|
||||
ng-repeat="namespace in $ctrl.local.orderedNamespaces.visibleEntries | slice:($ctrl.namespacesPerPage * $ctrl.local.namespaceOptions.page):($ctrl.namespacesPerPage * ($ctrl.local.namespaceOptions.page + 1))"
|
||||
ng-class="$ctrl.local.selectedNamespace == $ctrl.namespace ? 'checked' : ''"
|
||||
bindonce>
|
||||
<td>
|
||||
<input type="radio"
|
||||
ng-model="$ctrl.local.selectedNamespace"
|
||||
ng-value="namespace">
|
||||
</td>
|
||||
<td>
|
||||
<img class="namespace-avatar" ng-src="{{ namespace.avatar_url }}">
|
||||
<span class="anchor"
|
||||
href="{{ namespace.url }}"
|
||||
is-text-only="!namespace.url">{{ namespace.id }}</span>
|
||||
</td>
|
||||
<td class="importance-col hidden-xs">
|
||||
<span class="strength-indicator" value="::namespace.score" maximum="::$ctrl.local.maxScore"
|
||||
log-base="10"></span>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<div class="empty"
|
||||
ng-if="$ctrl.local.namespaces.length && !$ctrl.local.orderedNamespaces.entries.length"
|
||||
style="margin-top: 20px;">
|
||||
<div class="empty-primary-msg">No matching {{ $ctrl.namespaceTitle }} found.</div>
|
||||
<div class="empty-secondary-msg">Try expanding your filtering terms.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-8 col-md-8 col-sm-12 main-col" ng-if="!$ctrl.local.namespaces">
|
||||
<span class="cor-loader-inline"></span> Retrieving {{ $ctrl.namespaceTitle }}s
|
||||
</div>
|
||||
<div class="col-lg-4 col-md-4 hidden-sm hidden-xs help-col" ng-if="$ctrl.local.namespaces">
|
||||
<p>
|
||||
<span class="registry-name"></span> has been granted access to read and view these {{ $ctrl.namespaceTitle }}s.
|
||||
</p>
|
||||
<p>
|
||||
Don't see an expected {{ $ctrl.namespaceTitle }} here? Please make sure third-party access is enabled for <span class="registry-name"></span> under that {{ $ctrl.namespaceTitle }}.
|
||||
</p>
|
||||
</div>
|
||||
</linear-workflow-section><!-- /Section: Namespace -->
|
||||
|
||||
<!-- Section: Repository -->
|
||||
<linear-workflow-section class="row"
|
||||
section-id="repo"
|
||||
section-title="Select Repository"
|
||||
section-valid="$ctrl.local.selectedRepository">
|
||||
|
||||
<div class="col-lg-7 col-md-7 col-sm-12 main-col" ng-if="$ctrl.local.repositories">
|
||||
<h3>Select Repository</h3>
|
||||
<strong>
|
||||
Select a repository in
|
||||
<img class="namespace-avatar" ng-src="{{ $ctrl.local.selectedNamespace.avatar_url }}">
|
||||
{{ $ctrl.local.selectedNamespace.id }}
|
||||
</strong>
|
||||
|
||||
<div class="co-top-bar">
|
||||
<div class="co-filter-box">
|
||||
<span class="page-controls"
|
||||
total-count="$ctrl.local.orderedRepositories.entries.length"
|
||||
current-page="$ctrl.local.repositoryOptions.page"
|
||||
page-size="$ctrl.repositoriesPerPage"></span>
|
||||
<input class="form-control" type="text"
|
||||
ng-model="$ctrl.local.repositoryOptions.filter"
|
||||
placeholder="Filter repositories...">
|
||||
<div class="filter-options">
|
||||
<label>
|
||||
<input type="checkbox"
|
||||
ng-model="$ctrl.local.repositoryOptions.hideStale">
|
||||
Hide stale repositories
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table class="co-table" style="margin-top: 20px;">
|
||||
<thead>
|
||||
<td class="checkbox-col"></td>
|
||||
<td ng-class="$ctrl.TableService.tablePredicateClass('name', $ctrl.local.repositoryOptions.predicate, $ctrl.local.repositoryOptions.reverse)" class="nowrap-col">
|
||||
<a ng-click="$ctrl.TableService.orderBy('name', $ctrl.local.repositoryOptions)">Repository Name</a>
|
||||
</td>
|
||||
<td ng-class="$ctrl.TableService.tablePredicateClass('last_updated_datetime', $ctrl.local.repositoryOptions.predicate, $ctrl.local.repositoryOptions.reverse)"
|
||||
class="last-updated-col nowrap-col">
|
||||
<a ng-click="$ctrl.TableService.orderBy('last_updated_datetime', $ctrl.local.namespaceOptions)">Last Updated</a>
|
||||
</td>
|
||||
<td class="hidden-xs">Description</td>
|
||||
</thead>
|
||||
|
||||
<tr class="co-checkable-row"
|
||||
ng-repeat="repository in $ctrl.local.orderedRepositories.visibleEntries | slice:($ctrl.repositoriesPerPage * $ctrl.local.repositoryOptions.page):($ctrl.repositoriesPerPage * ($ctrl.local.repositoryOptions.page + 1))"
|
||||
ng-class="$ctrl.local.selectedRepository == repository ? 'checked' : ''"
|
||||
bindonce>
|
||||
<td>
|
||||
<span ng-if="!repository.has_admin_permissions">
|
||||
<i class="fa fa-exclamation-triangle"
|
||||
data-title="Admin access is required to add the webhook trigger to this repository" bs-tooltip></i>
|
||||
</span>
|
||||
<input type="radio"
|
||||
ng-model="$ctrl.local.selectedRepository"
|
||||
ng-value="repository"
|
||||
ng-if="repository.has_admin_permissions">
|
||||
</td>
|
||||
<td class="nowrap-col">
|
||||
<i class="service-icon fa {{ $ctrl.getTriggerIcon() }}"></i>
|
||||
<span class="anchor"
|
||||
href="{{ repository.url }}"
|
||||
is-text-only="!repository.url">{{ repository.name }}</span>
|
||||
</td>
|
||||
<td class="last-updated-col nowrap-col">
|
||||
<span am-time-ago="repository.last_updated_datetime"></span>
|
||||
</td>
|
||||
<td class="hidden-xs">
|
||||
<span ng-if="repository.description">{{ repository.description }}</span>
|
||||
<span class="empty-description" ng-if="!repository.description">(None)</span>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<div class="empty"
|
||||
ng-if="$ctrl.local.repositories.length && !$ctrl.local.orderedRepositories.entries.length"
|
||||
style="margin-top: 20px;">
|
||||
<div class="empty-primary-msg">No matching repositories found.</div>
|
||||
<div class="empty-secondary-msg">Try expanding your filtering terms.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-8 col-md-8 col-sm-12 main-col"
|
||||
ng-if="!$ctrl.local.repositories">
|
||||
<span class="cor-loader-inline"></span> Retrieving repositories
|
||||
</div>
|
||||
<div class="col-lg-4 col-md-4 hidden-sm hidden-xs help-col"
|
||||
ng-if="$ctrl.local.repositories">
|
||||
<p>
|
||||
A webhook will be added to the selected repository in order to detect when new commits are made.
|
||||
</p>
|
||||
<p>
|
||||
Don't see an expected repository here? Please make sure you have admin access on that repository.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
</linear-workflow-section><!-- /Section: Repository -->
|
||||
|
||||
<!-- Section: Trigger Options -->
|
||||
<linear-workflow-section class="row"
|
||||
section-id="triggeroptions"
|
||||
section-title="Configure Trigger"
|
||||
section-valid="$ctrl.local.triggerOptions">
|
||||
<div class="col-lg-7 col-md-7 col-sm-12 main-col" ng-if="$ctrl.local.repositoryRefs">
|
||||
<h3>Configure Trigger</h3>
|
||||
<strong>
|
||||
Configure trigger options for
|
||||
<img class="namespace-avatar" ng-src="{{ $ctrl.local.selectedNamespace.avatar_url }}">
|
||||
{{ $ctrl.local.selectedNamespace.id }}/{{ $ctrl.local.selectedRepository.name }}
|
||||
</strong>
|
||||
|
||||
<div class="radio" style="margin-top: 20px;">
|
||||
<label>
|
||||
<input type="radio" name="optionRadio"
|
||||
ng-model="$ctrl.local.triggerOptions.hasBranchTagFilter"
|
||||
ng-value="false">
|
||||
<div class="title">Trigger for all branches and tags <span class="weak">(default)</span></div>
|
||||
<div class="description">Build a container image for each commit across all branches and tags</div>
|
||||
</label>
|
||||
</div>
|
||||
<div class="radio">
|
||||
<label>
|
||||
<input type="radio" name="optionRadio" ng-model="$ctrl.local.triggerOptions.hasBranchTagFilter" ng-value="true">
|
||||
<div class="title">Trigger only on branches and tags matching a regular expression</div>
|
||||
<div class="description">Only build container images for a subset of branches and/or tags.</div>
|
||||
<div class="extended" ng-if="$ctrl.local.triggerOptions.hasBranchTagFilter">
|
||||
<table>
|
||||
<tr>
|
||||
<td style="white-space: nowrap;">Regular Expression:</td>
|
||||
<td>
|
||||
<input type="text" class="form-control"
|
||||
ng-model="$ctrl.local.triggerOptions.branchTagFilter"
|
||||
required>
|
||||
<div class="description">Examples: heads/master, tags/tagname, heads/.+</div>
|
||||
<regex-match-view
|
||||
items="$ctrl.local.repositoryFullRefs"
|
||||
regex="$ctrl.local.triggerOptions.branchTagFilter"
|
||||
ng-if="$ctrl.local.triggerOptions.branchTagFilter"></regex-match-view>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-8 col-md-8 col-sm-12 main-col"
|
||||
ng-if="!$ctrl.local.repositoryRefs">
|
||||
<span class="cor-loader-inline"></span> Retrieving repository refs
|
||||
</div>
|
||||
<div class="col-lg-4 col-md-4 hidden-sm hidden-xs help-col">
|
||||
<p>Do you want to build a new container image for commits across all branches and tags, or limit to a subset?</p>
|
||||
<p>For example, if you use release branches instead of <code>master</code> for building versions of your software, you can configure the trigger to only build images for these branches.</p>
|
||||
<p>All images built will be tagged with the name of the branch or tag whose change invoked the trigger</p>
|
||||
</div>
|
||||
</linear-workflow-section><!-- /Section: Trigger Options -->
|
||||
|
||||
<!-- Section: Dockerfile Location -->
|
||||
<linear-workflow-section class="row"
|
||||
section-id="dockerfilelocation"
|
||||
section-title="Select Dockerfile"
|
||||
section-valid="$ctrl.local.hasValidDockerfilePath">
|
||||
<div class="col-lg-7 col-md-7 col-sm-12 main-col"
|
||||
ng-if="$ctrl.local.dockerfileLocations.status == 'error'">
|
||||
<div class="co-alert co-alert-warning">
|
||||
{{ $ctrl.local.dockerfileLocations.message }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-7 col-md-7 col-sm-12 main-col" ng-if="$ctrl.local.dockerfileLocations.status == 'success'">
|
||||
<h3>Select Dockerfile</h3>
|
||||
<strong>
|
||||
Please select the location of the Dockerfile to build when this trigger is invoked
|
||||
</strong>
|
||||
|
||||
<dockerfile-path-select
|
||||
current-path="$ctrl.local.dockerfilePath"
|
||||
paths="$ctrl.local.dockerfileLocations.subdir"
|
||||
supports-full-listing="true"
|
||||
is-valid-path="$ctrl.local.hasValidDockerfilePath"></dockerfile-path-select>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-8 col-md-8 col-sm-12 main-col"
|
||||
ng-if="!$ctrl.local.dockerfileLocations">
|
||||
<span class="cor-loader-inline"></span> Retrieving Dockerfile locations
|
||||
</div>
|
||||
<div class="col-lg-4 col-md-4 hidden-sm hidden-xs help-col">
|
||||
<p>Please select the location containing the Dockerfile to be built.</p>
|
||||
<p>The build context will start at the location selected.</p>
|
||||
</div>
|
||||
</linear-workflow-section><!-- /Section: Dockerfile Location -->
|
||||
|
||||
<!-- Section: Verification and Robot Account -->
|
||||
<linear-workflow-section class="row"
|
||||
section-id="verification"
|
||||
section-title="Confirm"
|
||||
section-valid="$ctrl.local.triggerAnalysis.status != 'error' && ($ctrl.local.triggerAnalysis.status != 'requiresrobot' || $ctrl.local.robotAccount != null)">
|
||||
<!-- Error -->
|
||||
<div class="col-lg-7 col-md-7 col-sm-12 main-col"
|
||||
ng-if="$ctrl.local.triggerAnalysis.status == 'error'">
|
||||
<h3 class="error"><i class="fa fa-exclamation-circle"></i> Verification Error</h3>
|
||||
<strong>
|
||||
There was an error when verifying the state of <img class="namespace-avatar" ng-src="{{ $ctrl.local.selectedNamespace.avatar_url }}">
|
||||
{{ $ctrl.local.selectedNamespace.id }}/{{ $ctrl.local.selectedRepository.name }}
|
||||
</strong>
|
||||
|
||||
{{ $ctrl.local.triggerAnalysis.message }}
|
||||
</div>
|
||||
|
||||
<!-- Warning -->
|
||||
<div class="col-lg-7 col-md-7 col-sm-12 main-col" ng-if="$ctrl.local.triggerAnalysis.status == 'warning'">
|
||||
<h3 class="warning"><i class="fa fa-exclamation-triangle"></i> Verification Warning</h3>
|
||||
{{ $ctrl.local.triggerAnalysis.message }}
|
||||
</div>
|
||||
|
||||
<!-- Public base -->
|
||||
<div class="col-lg-7 col-md-7 col-sm-12 main-col" ng-if="$ctrl.local.triggerAnalysis.status == 'publicbase'">
|
||||
<h3 class="success"><i class="fa fa-check-circle"></i> Ready to go!</h3>
|
||||
<strong>Click "Create Trigger" to complete setup of this build trigger</strong>
|
||||
</div>
|
||||
|
||||
<!-- Requires robot and is not admin -->
|
||||
<div class="col-lg-7 col-md-7 col-sm-12 main-col" ng-if="$ctrl.local.triggerAnalysis.status == 'requiresrobot' && !$ctrl.local.triggerAnalysis.is_admin">
|
||||
<h3>Robot Account Required</h3>
|
||||
<p>The selected Dockerfile in the selected repository depends upon a private base image</p>
|
||||
<p>A robot account with access to the base image is required to setup this trigger, but you are not the administrator of this namespace.</p>
|
||||
<p>Administrative access is required to continue to ensure security of the robot credentials.</p>
|
||||
</div>
|
||||
|
||||
<!-- Requires robot and is admin -->
|
||||
<div class="col-lg-7 col-md-7 col-sm-12 main-col" ng-if="$ctrl.local.triggerAnalysis.status == 'requiresrobot' && $ctrl.local.triggerAnalysis.is_admin">
|
||||
<h3>Select Robot Account</h3>
|
||||
<strong>
|
||||
The selected Dockerfile in the selected repository depends upon a private base image. Select a robot account with access:
|
||||
</strong>
|
||||
|
||||
<div class="co-top-bar">
|
||||
<div class="co-filter-box">
|
||||
<span class="page-controls"
|
||||
total-count="$ctrl.local.orderedRobotAccounts.entries.length"
|
||||
current-page="$ctrl.local.robotOptions.page"
|
||||
page-size="$ctrl.robotsPerPage"></span>
|
||||
<input class="form-control" type="text" ng-model="$ctrl.local.robotOptions.filter" placeholder="Filter robot accounts...">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table class="co-table" style="margin-top: 20px;">
|
||||
<thead>
|
||||
<td class="checkbox-col"></td>
|
||||
<td ng-class="$ctrl.TableService.tablePredicateClass('name', $ctrl.local.robotOptions.predicate, $ctrl.local.robotOptions.reverse)">
|
||||
<a ng-click="$ctrl.TableService.orderBy('name', $ctrl.local.robotOptions)">Robot Account</a>
|
||||
</td>
|
||||
<td ng-class="$ctrl.TableService.tablePredicateClass('can_read', $ctrl.local.robotOptions.predicate, $ctrl.local.robotOptions.reverse)">
|
||||
<a ng-click="$ctrl.TableService.orderBy('can_read', $ctrl.local.robotOptions)">Has Read Access</a>
|
||||
</td>
|
||||
</thead>
|
||||
|
||||
<tr class="co-checkable-row"
|
||||
ng-repeat="robot in $ctrl.local.orderedRobotAccounts.visibleEntries | slice:($ctrl.robotsPerPage * $ctrl.local.namespaceOptions.page):(robotsPerPage * ($ctrl.local.robotOptions.page + 1))"
|
||||
ng-class="$ctrl.local.robotAccount == robot ? 'checked' : ''"
|
||||
bindonce>
|
||||
<td>
|
||||
<input type="radio"
|
||||
ng-model="$ctrl.local.robotAccount"
|
||||
ng-value="robot">
|
||||
</td>
|
||||
<td>
|
||||
<span class="entity-reference" entity="robot"></span>
|
||||
</td>
|
||||
<td>
|
||||
<span ng-if="robot.can_read" class="success">Can Read</span>
|
||||
<span ng-if="!robot.can_read">Read access will be added if selected</span>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<div class="empty" ng-if="$ctrl.local.triggerAnalysis.robots.length && !$ctrl.local.orderedRobotAccounts.entries.length"
|
||||
style="margin-top: 20px;">
|
||||
<div class="empty-primary-msg">No matching robot accounts found.</div>
|
||||
<div class="empty-secondary-msg">Try expanding your filtering terms.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-4 col-md-4 hidden-sm hidden-xs help-col"
|
||||
ng-if="$ctrl.local.triggerAnalysis.status == 'requiresrobot' && $ctrl.local.triggerAnalysis.is_admin">
|
||||
<p>The Dockerfile you selected utilizes a private base image.</p>
|
||||
<p>In order for the <span class="registry-name"></span> to pull the base image during the build process, a robot account with access must be selected.</p>
|
||||
<p>Robot accounts that already have access to this base image are listed first. If you select a robot account that does not currently have access, read permission will be granted to that robot account on trigger creation.</p>
|
||||
</div>
|
||||
</linear-workflow-section><!-- /Section: Robot Account -->
|
||||
|
||||
</linear-workflow>
|
||||
</div>
|
|
@ -0,0 +1,49 @@
|
|||
import { ManageTriggerGithostComponent, Local, Trigger } from './manage-trigger-githost.component';
|
||||
|
||||
|
||||
describe("ManageTriggerGithostComponent", () => {
|
||||
var component: ManageTriggerGithostComponent;
|
||||
var apiServiceMock: any;
|
||||
var tableServiceMock: any;
|
||||
var triggerServiceMock: any;
|
||||
var rolesServiceMock: any;
|
||||
var repository: any;
|
||||
var trigger: Trigger;
|
||||
var $scope: ng.IScope;
|
||||
|
||||
beforeEach(inject(($injector: ng.auto.IInjectorService) => {
|
||||
apiServiceMock = jasmine.createSpyObj('apiServiceMock', ['listTriggerBuildSourceNamespaces']);
|
||||
tableServiceMock = jasmine.createSpyObj('tableServiceMock', ['buildOrderedItems']);
|
||||
triggerServiceMock = jasmine.createSpyObj('triggerServiceMock', ['getIcon']);
|
||||
rolesServiceMock = jasmine.createSpyObj('rolesServiceMock', ['setRepositoryRole']);
|
||||
$scope = $injector.get('$rootScope');
|
||||
component = new ManageTriggerGithostComponent(apiServiceMock,
|
||||
tableServiceMock,
|
||||
triggerServiceMock,
|
||||
rolesServiceMock,
|
||||
$scope);
|
||||
trigger = {service: "serviceMock", id: 1};
|
||||
component.trigger = trigger;
|
||||
}));
|
||||
|
||||
describe("constructor", () => {
|
||||
// TODO
|
||||
});
|
||||
|
||||
describe("$onInit", () => {
|
||||
// TODO
|
||||
});
|
||||
|
||||
describe("getTriggerIcon", () => {
|
||||
|
||||
it("calls trigger service to get icon", () => {
|
||||
component.getTriggerIcon();
|
||||
|
||||
expect(triggerServiceMock.getIcon.calls.argsFor(0)[0]).toEqual(component.trigger.service);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createTrigger", () => {
|
||||
// TODO
|
||||
});
|
||||
});
|
|
@ -0,0 +1,355 @@
|
|||
import { Input, Output, Component } from 'angular-ts-decorators';
|
||||
import * as moment from 'moment';
|
||||
|
||||
|
||||
/**
|
||||
* A component that lets the user set up a build trigger for a public Git repository host service.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'manageTriggerGithost',
|
||||
templateUrl: '/static/js/directives/ui/manage-trigger-githost/manage-trigger-githost.component.html'
|
||||
})
|
||||
export class ManageTriggerGithostComponent implements ng.IComponentController {
|
||||
|
||||
// FIXME: Use one-way data binding
|
||||
@Input('=') public repository: any;
|
||||
@Input('=') public trigger: Trigger;
|
||||
@Output() public activateTrigger: (trigger: {config: any, pull_robot: any}) => void;
|
||||
private config: any;
|
||||
private local: any = {
|
||||
namespaceOptions: {
|
||||
filter: '',
|
||||
predicate: 'score',
|
||||
reverse: false,
|
||||
page: 0
|
||||
},
|
||||
repositoryOptions: {
|
||||
filter: '',
|
||||
predicate: 'score',
|
||||
reverse: false,
|
||||
page: 0,
|
||||
hideStale: true
|
||||
},
|
||||
robotOptions: {
|
||||
filter: '',
|
||||
predicate: 'score',
|
||||
reverse: false,
|
||||
page: 0
|
||||
}
|
||||
};
|
||||
private currentState: any | null;
|
||||
private namespacesPerPage: number = 10;
|
||||
private repositoriesPerPage: number = 10;
|
||||
private robotsPerPage: number = 10;
|
||||
private namespaceTitle: string;
|
||||
private namespace: any;
|
||||
|
||||
constructor(private ApiService: any,
|
||||
private TableService: any,
|
||||
private TriggerService: any,
|
||||
private RolesService: any,
|
||||
private $scope: ng.IScope) {
|
||||
// FIXME: Here binding methods to class context in order to pass them as arguments to $scope.$watch
|
||||
this.buildOrderedNamespaces = this.buildOrderedNamespaces.bind(this);
|
||||
this.loadNamespaces = this.loadNamespaces.bind(this);
|
||||
this.buildOrderedRepositories = this.buildOrderedRepositories.bind(this);
|
||||
this.loadRepositories = this.loadRepositories.bind(this);
|
||||
this.loadRepositoryRefs = this.loadRepositoryRefs.bind(this);
|
||||
this.buildOrderedRobotAccounts = this.buildOrderedRobotAccounts.bind(this);
|
||||
this.loadDockerfileLocations = this.loadDockerfileLocations.bind(this);
|
||||
this.checkDockerfilePath = this.checkDockerfilePath.bind(this);
|
||||
}
|
||||
|
||||
public $onInit(): void {
|
||||
// TODO: Replace $scope.$watch with @Output methods for child component mutations or $onChanges for parent mutations
|
||||
this.$scope.$watch(() => this.trigger, (trigger) => {
|
||||
if (trigger && this.repository) {
|
||||
this.config = trigger['config'] || {};
|
||||
this.namespaceTitle = 'organization';
|
||||
this.local.selectedNamespace = null;
|
||||
this.loadNamespaces();
|
||||
}
|
||||
});
|
||||
|
||||
this.$scope.$watch(() => this.local.selectedNamespace, (namespace) => {
|
||||
if (namespace) {
|
||||
this.loadRepositories(namespace);
|
||||
}
|
||||
});
|
||||
|
||||
this.$scope.$watch(() => this.local.selectedRepository, (repository) => {
|
||||
if (repository) {
|
||||
this.loadRepositoryRefs(repository);
|
||||
this.loadDockerfileLocations(repository);
|
||||
}
|
||||
});
|
||||
|
||||
this.$scope.$watch(() => this.local.dockerfilePath, (path) => {
|
||||
if (path && this.local.selectedRepository) {
|
||||
this.checkDockerfilePath(this.local.selectedRepository, path);
|
||||
}
|
||||
});
|
||||
|
||||
this.$scope.$watch(() => this.local.namespaceOptions.predicate, this.buildOrderedNamespaces);
|
||||
this.$scope.$watch(() => this.local.namespaceOptions.reverse, this.buildOrderedNamespaces);
|
||||
this.$scope.$watch(() => this.local.namespaceOptions.filter, this.buildOrderedNamespaces);
|
||||
this.$scope.$watch(() => this.local.repositoryOptions.predicate, this.buildOrderedRepositories);
|
||||
this.$scope.$watch(() => this.local.repositoryOptions.reverse, this.buildOrderedRepositories);
|
||||
this.$scope.$watch(() => this.local.repositoryOptions.filter, this.buildOrderedRepositories);
|
||||
this.$scope.$watch(() => this.local.repositoryOptions.hideStale, this.buildOrderedRepositories);
|
||||
this.$scope.$watch(() => this.local.robotOptions.predicate, this.buildOrderedRobotAccounts);
|
||||
this.$scope.$watch(() => this.local.robotOptions.reverse, this.buildOrderedRobotAccounts);
|
||||
this.$scope.$watch(() => this.local.robotOptions.filter, this.buildOrderedRobotAccounts);
|
||||
}
|
||||
|
||||
public getTriggerIcon(): any {
|
||||
return this.TriggerService.getIcon(this.trigger.service);
|
||||
}
|
||||
|
||||
public createTrigger(): void {
|
||||
var config: any = {
|
||||
build_source: this.local.selectedRepository.full_name,
|
||||
subdir: this.local.dockerfilePath.substr(1) // Remove starting /
|
||||
};
|
||||
|
||||
if (this.local.triggerOptions.hasBranchTagFilter &&
|
||||
this.local.triggerOptions.branchTagFilter) {
|
||||
config['branchtag_regex'] = this.local.triggerOptions.branchTagFilter;
|
||||
}
|
||||
|
||||
var activate = () => {
|
||||
this.activateTrigger({'config': config, 'pull_robot': this.local.robotAccount});
|
||||
};
|
||||
|
||||
if (this.local.robotAccount) {
|
||||
if (this.local.robotAccount.can_read) {
|
||||
activate();
|
||||
} else {
|
||||
// Add read permission onto the base repository for the robot and then activate the
|
||||
// trigger.
|
||||
var robot_name = this.local.robotAccount.name;
|
||||
this.RolesService.setRepositoryRole(this.repository, 'read', 'robot', robot_name, activate);
|
||||
}
|
||||
} else {
|
||||
activate();
|
||||
}
|
||||
}
|
||||
|
||||
private buildOrderedNamespaces(): void {
|
||||
if (!this.local.namespaces) {
|
||||
return;
|
||||
}
|
||||
|
||||
var namespaces = this.local.namespaces || [];
|
||||
this.local.orderedNamespaces = this.TableService.buildOrderedItems(namespaces,
|
||||
this.local.namespaceOptions,
|
||||
['id'],
|
||||
['score']);
|
||||
|
||||
this.local.maxScore = 0;
|
||||
namespaces.forEach((namespace) => {
|
||||
this.local.maxScore = Math.max(namespace.score, this.local.maxScore);
|
||||
});
|
||||
}
|
||||
|
||||
private loadNamespaces(): void {
|
||||
this.local.namespaces = null;
|
||||
this.local.selectedNamespace = null;
|
||||
this.local.orderedNamespaces = null;
|
||||
|
||||
this.local.selectedRepository = null;
|
||||
this.local.orderedRepositories = null;
|
||||
|
||||
var params = {
|
||||
'repository': this.repository.namespace + '/' + this.repository.name,
|
||||
'trigger_uuid': this.trigger.id
|
||||
};
|
||||
|
||||
this.ApiService.listTriggerBuildSourceNamespaces(null, params)
|
||||
.then((resp) => {
|
||||
this.local.namespaces = resp['namespaces'];
|
||||
this.local.repositories = null;
|
||||
this.buildOrderedNamespaces();
|
||||
}, this.ApiService.errorDisplay('Could not retrieve the list of ' + this.namespaceTitle));
|
||||
}
|
||||
|
||||
private buildOrderedRepositories(): void {
|
||||
if (!this.local.repositories) {
|
||||
return;
|
||||
}
|
||||
|
||||
var repositories = this.local.repositories || [];
|
||||
repositories.forEach((repository) => {
|
||||
repository['last_updated_datetime'] = new Date(repository['last_updated'] * 1000);
|
||||
});
|
||||
|
||||
if (this.local.repositoryOptions.hideStale) {
|
||||
var existingRepositories = repositories;
|
||||
|
||||
repositories = repositories.filter((repository) => {
|
||||
var older_date = moment(repository['last_updated_datetime']).add(1, 'months');
|
||||
return !moment().isAfter(older_date);
|
||||
});
|
||||
|
||||
if (existingRepositories.length > 0 && repositories.length == 0) {
|
||||
repositories = existingRepositories;
|
||||
}
|
||||
}
|
||||
|
||||
this.local.orderedRepositories = this.TableService.buildOrderedItems(repositories,
|
||||
this.local.repositoryOptions,
|
||||
['name', 'description'],
|
||||
[]);
|
||||
}
|
||||
|
||||
private loadRepositories(namespace: any): void {
|
||||
this.local.repositories = null;
|
||||
this.local.selectedRepository = null;
|
||||
this.local.repositoryRefs = null;
|
||||
this.local.triggerOptions = {
|
||||
'hasBranchTagFilter': false
|
||||
};
|
||||
|
||||
this.local.orderedRepositories = null;
|
||||
|
||||
var params = {
|
||||
'repository': this.repository.namespace + '/' + this.repository.name,
|
||||
'trigger_uuid': this.trigger.id
|
||||
};
|
||||
|
||||
var data = {
|
||||
'namespace': namespace.id
|
||||
};
|
||||
|
||||
this.ApiService.listTriggerBuildSources(data, params).then((resp) => {
|
||||
if (namespace == this.local.selectedNamespace) {
|
||||
this.local.repositories = resp['sources'];
|
||||
this.buildOrderedRepositories();
|
||||
}
|
||||
}, this.ApiService.errorDisplay('Could not retrieve repositories'));
|
||||
}
|
||||
|
||||
private loadRepositoryRefs(repository: any): void {
|
||||
this.local.repositoryRefs = null;
|
||||
this.local.triggerOptions = {
|
||||
'hasBranchTagFilter': false
|
||||
};
|
||||
|
||||
var params = {
|
||||
'repository': this.repository.namespace + '/' + this.repository.name,
|
||||
'trigger_uuid': this.trigger.id,
|
||||
'field_name': 'refs'
|
||||
};
|
||||
|
||||
var config = {
|
||||
'build_source': repository.full_name
|
||||
};
|
||||
|
||||
this.ApiService.listTriggerFieldValues(config, params).then((resp) => {
|
||||
if (repository == this.local.selectedRepository) {
|
||||
this.local.repositoryRefs = resp['values'];
|
||||
this.local.repositoryFullRefs = resp['values'].map((ref) => {
|
||||
var kind = ref.kind == 'branch' ? 'heads' : 'tags';
|
||||
var icon = ref.kind == 'branch' ? 'fa-code-fork' : 'fa-tag';
|
||||
return {
|
||||
'value': kind + '/' + ref.name,
|
||||
'icon': icon,
|
||||
'title': ref.name
|
||||
};
|
||||
});
|
||||
}
|
||||
}, this.ApiService.errorDisplay('Could not retrieve repository refs'));
|
||||
}
|
||||
|
||||
private loadDockerfileLocations(repository: any): void {
|
||||
this.local.dockerfilePath = null;
|
||||
|
||||
var params = {
|
||||
'repository': this.repository.namespace + '/' + this.repository.name,
|
||||
'trigger_uuid': this.trigger.id
|
||||
};
|
||||
|
||||
var config = {
|
||||
'build_source': repository.full_name
|
||||
};
|
||||
|
||||
this.ApiService.listBuildTriggerSubdirs(config, params)
|
||||
.then((resp) => {
|
||||
if (repository == this.local.selectedRepository) {
|
||||
this.local.dockerfileLocations = resp;
|
||||
}
|
||||
}, this.ApiService.errorDisplay('Could not retrieve Dockerfile locations'));
|
||||
}
|
||||
|
||||
private buildOrderedRobotAccounts(): void {
|
||||
if (!this.local.triggerAnalysis || !this.local.triggerAnalysis.robots) {
|
||||
return;
|
||||
}
|
||||
|
||||
var robots = this.local.triggerAnalysis.robots;
|
||||
this.local.orderedRobotAccounts = this.TableService.buildOrderedItems(robots,
|
||||
this.local.robotOptions,
|
||||
['name'],
|
||||
[]);
|
||||
}
|
||||
|
||||
private checkDockerfilePath(repository: any, path: string): void {
|
||||
this.local.triggerAnalysis = null;
|
||||
this.local.robotAccount = null;
|
||||
|
||||
var params = {
|
||||
'repository': this.repository.namespace + '/' + this.repository.name,
|
||||
'trigger_uuid': this.trigger.id
|
||||
};
|
||||
|
||||
var config = {
|
||||
'build_source': repository.full_name,
|
||||
'subdir': path.substr(1)
|
||||
};
|
||||
|
||||
var data = {
|
||||
'config': config
|
||||
};
|
||||
|
||||
this.ApiService.analyzeBuildTrigger(data, params)
|
||||
.then((resp) => {
|
||||
this.local.triggerAnalysis = resp;
|
||||
this.buildOrderedRobotAccounts();
|
||||
}, this.ApiService.errorDisplay('Could not analyze trigger'));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* A type representing local application data.
|
||||
*/
|
||||
export type Local = {
|
||||
namespaceOptions: {
|
||||
filter: string;
|
||||
predicate: string;
|
||||
reverse: boolean;
|
||||
page: number;
|
||||
};
|
||||
repositoryOptions: {
|
||||
filter: string;
|
||||
predicate: string;
|
||||
reverse: boolean;
|
||||
page: number;
|
||||
hideStale: boolean;
|
||||
};
|
||||
robotOptions: {
|
||||
filter: string;
|
||||
predicate: string;
|
||||
reverse: boolean;
|
||||
page: number;
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* A type representing a trigger.
|
||||
*/
|
||||
export type Trigger = {
|
||||
id: number;
|
||||
service: any;
|
||||
};
|
|
@ -0,0 +1,29 @@
|
|||
<div class="regex-match-view-element">
|
||||
<div ng-if="$ctrl.filterMatches($ctrl.regex, $ctrl.items, false) == null">
|
||||
<i class="fa fa-exclamation-triangle"></i>Invalid Regular Expression!
|
||||
</div>
|
||||
<div ng-if="$ctrl.filterMatches($ctrl.regex, $ctrl.items, false) != null">
|
||||
<table class="match-table">
|
||||
<tr>
|
||||
<td>Matching:</td>
|
||||
<td>
|
||||
<ul class="matching match-list">
|
||||
<li ng-repeat="item in $ctrl.filterMatches($ctrl.regex, $ctrl.items, true)">
|
||||
<i class="fa {{ item.icon }}"></i>{{ item.title }}
|
||||
</li>
|
||||
</ul>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Not Matching:</td>
|
||||
<td>
|
||||
<ul class="not-matching match-list">
|
||||
<li ng-repeat="item in $ctrl.filterMatches($ctrl.regex, $ctrl.items, false)">
|
||||
<i class="fa {{ item.icon }}"></i>{{ item.title }}
|
||||
</li>
|
||||
</ul>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
|
@ -0,0 +1,44 @@
|
|||
import { RegexMatchViewComponent } from './regex-match-view.component';
|
||||
|
||||
|
||||
describe("RegexMatchViewComponent", () => {
|
||||
var component: RegexMatchViewComponent;
|
||||
|
||||
beforeEach(() => {
|
||||
component = new RegexMatchViewComponent();
|
||||
});
|
||||
|
||||
describe("filterMatches", () => {
|
||||
var items: ({value: string})[];
|
||||
|
||||
beforeEach(() => {
|
||||
items = [{value: "heads/master"}, {value: "heads/develop"}, {value: "heads/production"}];
|
||||
});
|
||||
|
||||
it("returns null if given invalid regex expression", () => {
|
||||
var regexstr: string = "\\asfd\\";
|
||||
|
||||
expect(component.filterMatches(regexstr, items, true)).toBe(null);
|
||||
});
|
||||
|
||||
it("returns a subset of given items matching the given regex expression if given 'shouldMatch' as true", () => {
|
||||
var regexstr: string = `^${items[0].value}$`;
|
||||
var matches: ({value: string})[] = component.filterMatches(regexstr, items, true);
|
||||
|
||||
expect(matches.length).toBeGreaterThan(0);
|
||||
matches.forEach((match) => {
|
||||
expect(items).toContain(match);
|
||||
});
|
||||
});
|
||||
|
||||
it("returns a subset of given items not matching the given regex expression if given 'shouldMatch' as false", () => {
|
||||
var regexstr: string = `^${items[0].value}$`;
|
||||
var nonMatches: ({value: string})[] = component.filterMatches(regexstr, items, false);
|
||||
|
||||
expect(nonMatches.length).toBeGreaterThan(0);
|
||||
nonMatches.forEach((nonMatch) => {
|
||||
expect(items).toContain(nonMatch);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,38 @@
|
|||
import { Input, Component } from 'angular-ts-decorators';
|
||||
|
||||
|
||||
/**
|
||||
* A component that displays the matches and non-matches for a regular expression against a set of
|
||||
* items.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'regexMatchView',
|
||||
templateUrl: '/static/js/directives/ui/regex-match-view/regex-match-view.component.html'
|
||||
})
|
||||
export class RegexMatchViewComponent implements ng.IComponentController {
|
||||
|
||||
// FIXME: Use one-way data binding
|
||||
@Input('=') private regex: string;
|
||||
@Input('=') private items: any[];
|
||||
|
||||
constructor() {
|
||||
|
||||
}
|
||||
|
||||
public filterMatches(regexstr: string, items: ({value: string})[], shouldMatch: boolean): ({value: string})[] | null {
|
||||
regexstr = regexstr || '.+';
|
||||
|
||||
try {
|
||||
var regex = new RegExp(regexstr);
|
||||
} catch (ex) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return items.filter(function(item) {
|
||||
var value: string = item.value;
|
||||
var m: RegExpMatchArray = value.match(regex);
|
||||
var matches: boolean = !!(m && m[0].length == value.length);
|
||||
return matches == shouldMatch;
|
||||
});
|
||||
}
|
||||
}
|
|
@ -1,126 +0,0 @@
|
|||
/**
|
||||
* An element which displays the steps of the wizard-like dialog, changing them as each step
|
||||
* is completed.
|
||||
*/
|
||||
angular.module('quay').directive('stepView', function ($compile) {
|
||||
var directiveDefinitionObject = {
|
||||
priority: 0,
|
||||
templateUrl: '/static/directives/step-view.html',
|
||||
replace: true,
|
||||
transclude: true,
|
||||
restrict: 'C',
|
||||
scope: {
|
||||
'nextStepCounter': '=nextStepCounter',
|
||||
'currentStepValid': '=currentStepValid',
|
||||
|
||||
'stepsCompleted': '&stepsCompleted'
|
||||
},
|
||||
controller: function($scope, $element, $rootScope) {
|
||||
var currentStepIndex = -1;
|
||||
var steps = [];
|
||||
var watcher = null;
|
||||
|
||||
// Members on 'this' are accessed by the individual steps.
|
||||
this.register = function(scope, element) {
|
||||
element.hide();
|
||||
|
||||
steps.push({
|
||||
'scope': scope,
|
||||
'element': element
|
||||
});
|
||||
|
||||
nextStep();
|
||||
};
|
||||
|
||||
var getCurrentStep = function() {
|
||||
return steps[currentStepIndex];
|
||||
};
|
||||
|
||||
var reset = function() {
|
||||
currentStepIndex = -1;
|
||||
for (var i = 0; i < steps.length; ++i) {
|
||||
steps[i].element.hide();
|
||||
}
|
||||
|
||||
$scope.currentStepValid = false;
|
||||
};
|
||||
|
||||
var next = function() {
|
||||
if (currentStepIndex >= 0) {
|
||||
var currentStep = getCurrentStep();
|
||||
if (!currentStep || !currentStep.scope) { return; }
|
||||
|
||||
if (!currentStep.scope.completeCondition) {
|
||||
return;
|
||||
}
|
||||
|
||||
currentStep.element.hide();
|
||||
|
||||
if (unwatch) {
|
||||
unwatch();
|
||||
unwatch = null;
|
||||
}
|
||||
}
|
||||
|
||||
currentStepIndex++;
|
||||
|
||||
if (currentStepIndex < steps.length) {
|
||||
var currentStep = getCurrentStep();
|
||||
currentStep.element.show();
|
||||
currentStep.scope.load()
|
||||
|
||||
unwatch = currentStep.scope.$watch('completeCondition', function(cc) {
|
||||
$scope.currentStepValid = !!cc;
|
||||
});
|
||||
} else {
|
||||
$scope.stepsCompleted();
|
||||
}
|
||||
};
|
||||
|
||||
var nextStep = function() {
|
||||
if (!steps || !steps.length) { return; }
|
||||
|
||||
if ($scope.nextStepCounter >= 0) {
|
||||
next();
|
||||
} else {
|
||||
reset();
|
||||
}
|
||||
};
|
||||
|
||||
$scope.$watch('nextStepCounter', nextStep);
|
||||
}
|
||||
};
|
||||
return directiveDefinitionObject;
|
||||
});
|
||||
|
||||
|
||||
/**
|
||||
* A step in the step view.
|
||||
*/
|
||||
angular.module('quay').directive('stepViewStep', function () {
|
||||
var directiveDefinitionObject = {
|
||||
priority: 1,
|
||||
require: '^stepView',
|
||||
templateUrl: '/static/directives/step-view-step.html',
|
||||
replace: false,
|
||||
transclude: true,
|
||||
restrict: 'C',
|
||||
scope: {
|
||||
'completeCondition': '=completeCondition',
|
||||
'loadCallback': '&loadCallback',
|
||||
'loadMessage': '@loadMessage'
|
||||
},
|
||||
link: function(scope, element, attrs, controller) {
|
||||
controller.register(scope, element);
|
||||
},
|
||||
controller: function($scope, $element) {
|
||||
$scope.load = function() {
|
||||
$scope.loading = true;
|
||||
$scope.loadCallback({'callback': function() {
|
||||
$scope.loading = false;
|
||||
}});
|
||||
};
|
||||
}
|
||||
};
|
||||
return directiveDefinitionObject;
|
||||
});
|
|
@ -1,49 +0,0 @@
|
|||
/**
|
||||
* An element which displays custom git-specific setup information for its build triggers.
|
||||
*/
|
||||
angular.module('quay').directive('triggerSetupCustom', function() {
|
||||
var directiveDefinitionObject = {
|
||||
priority: 0,
|
||||
templateUrl: '/static/directives/trigger-setup-custom.html',
|
||||
replace: false,
|
||||
transclude: false,
|
||||
restrict: 'C',
|
||||
scope: {
|
||||
'repository': '=repository',
|
||||
'trigger': '=trigger',
|
||||
|
||||
'nextStepCounter': '=nextStepCounter',
|
||||
'currentStepValid': '=currentStepValid',
|
||||
|
||||
'analyze': '&analyze'
|
||||
},
|
||||
controller: function($scope, $element, ApiService) {
|
||||
$scope.analyzeCounter = 0;
|
||||
$scope.setupReady = false;
|
||||
|
||||
$scope.state = {
|
||||
'build_source': null,
|
||||
'subdir': null
|
||||
};
|
||||
|
||||
$scope.stepsCompleted = function() {
|
||||
$scope.analyze({'isValid': $scope.state.build_source != null && $scope.state.subdir != null});
|
||||
};
|
||||
|
||||
$scope.$watch('state.build_source', function(build_source) {
|
||||
$scope.trigger['config']['build_source'] = build_source;
|
||||
});
|
||||
|
||||
$scope.$watch('state.subdir', function(subdir) {
|
||||
$scope.trigger['config']['subdir'] = subdir;
|
||||
$scope.trigger.$ready = subdir != null;
|
||||
});
|
||||
|
||||
$scope.nopLoad = function(callback) {
|
||||
callback();
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
return directiveDefinitionObject;
|
||||
});
|
|
@ -1,242 +0,0 @@
|
|||
/**
|
||||
* An element which displays hosted Git (GitHub, Bitbucket)-specific setup information for its build triggers.
|
||||
*/
|
||||
angular.module('quay').directive('triggerSetupGithost', function () {
|
||||
var directiveDefinitionObject = {
|
||||
priority: 0,
|
||||
templateUrl: '/static/directives/trigger-setup-githost.html',
|
||||
replace: false,
|
||||
transclude: false,
|
||||
restrict: 'C',
|
||||
scope: {
|
||||
'repository': '=repository',
|
||||
'trigger': '=trigger',
|
||||
'kind': '@kind',
|
||||
|
||||
'nextStepCounter': '=nextStepCounter',
|
||||
'currentStepValid': '=currentStepValid',
|
||||
|
||||
'analyze': '&analyze'
|
||||
},
|
||||
controller: function($scope, $element, ApiService, TriggerService) {
|
||||
$scope.analyzeCounter = 0;
|
||||
$scope.setupReady = false;
|
||||
$scope.refs = null;
|
||||
$scope.branchNames = null;
|
||||
$scope.tagNames = null;
|
||||
|
||||
$scope.state = {
|
||||
'currentRepo': null,
|
||||
'branchTagFilter': '',
|
||||
'hasBranchTagFilter': false,
|
||||
'isInvalidLocation': true,
|
||||
'currentLocation': null
|
||||
};
|
||||
|
||||
var checkLocation = function() {
|
||||
var location = $scope.state.currentLocation || '';
|
||||
$scope.state.isInvalidLocation = $scope.supportsFullListing &&
|
||||
$scope.locations.indexOf(location) < 0;
|
||||
};
|
||||
|
||||
$scope.isMatching = function(kind, name, filter) {
|
||||
try {
|
||||
var patt = new RegExp(filter);
|
||||
} catch (ex) {
|
||||
return false;
|
||||
}
|
||||
|
||||
var fullname = (kind + '/' + name);
|
||||
var m = fullname.match(patt);
|
||||
return m && m[0].length == fullname.length;
|
||||
}
|
||||
|
||||
$scope.addRef = function(kind, name) {
|
||||
if ($scope.isMatching(kind, name, $scope.state.branchTagFilter)) {
|
||||
return;
|
||||
}
|
||||
|
||||
var newFilter = kind + '/' + name;
|
||||
var existing = $scope.state.branchTagFilter;
|
||||
if (existing) {
|
||||
$scope.state.branchTagFilter = '(' + existing + ')|(' + newFilter + ')';
|
||||
} else {
|
||||
$scope.state.branchTagFilter = newFilter;
|
||||
}
|
||||
}
|
||||
|
||||
$scope.stepsCompleted = function() {
|
||||
$scope.analyze({'isValid': !$scope.state.isInvalidLocation});
|
||||
};
|
||||
|
||||
$scope.loadRepositories = function(callback) {
|
||||
if (!$scope.trigger || !$scope.repository) { return; }
|
||||
|
||||
var params = {
|
||||
'repository': $scope.repository.namespace + '/' + $scope.repository.name,
|
||||
'trigger_uuid': $scope.trigger.id
|
||||
};
|
||||
|
||||
ApiService.listTriggerBuildSources(null, params).then(function(resp) {
|
||||
$scope.orgs = resp['sources'];
|
||||
setupTypeahead();
|
||||
callback();
|
||||
}, ApiService.errorDisplay('Cannot load repositories'));
|
||||
};
|
||||
|
||||
$scope.loadBranchesAndTags = function(callback) {
|
||||
if (!$scope.trigger || !$scope.repository) { return; }
|
||||
|
||||
var params = {
|
||||
'repository': $scope.repository.namespace + '/' + $scope.repository.name,
|
||||
'trigger_uuid': $scope.trigger['id'],
|
||||
'field_name': 'refs'
|
||||
};
|
||||
|
||||
ApiService.listTriggerFieldValues($scope.trigger['config'], params).then(function(resp) {
|
||||
$scope.refs = resp['values'];
|
||||
$scope.branchNames = [];
|
||||
$scope.tagNames = [];
|
||||
|
||||
for (var i = 0; i < $scope.refs.length; ++i) {
|
||||
var ref = $scope.refs[i];
|
||||
if (ref.kind == 'branch') {
|
||||
$scope.branchNames.push(ref.name);
|
||||
} else {
|
||||
$scope.tagNames.push(ref.name);
|
||||
}
|
||||
}
|
||||
|
||||
callback();
|
||||
}, ApiService.errorDisplay('Cannot load branch and tag names'));
|
||||
};
|
||||
|
||||
$scope.loadLocations = function(callback) {
|
||||
if (!$scope.trigger || !$scope.repository) { return; }
|
||||
|
||||
$scope.locations = null;
|
||||
|
||||
var params = {
|
||||
'repository': $scope.repository.namespace + '/' + $scope.repository.name,
|
||||
'trigger_uuid': $scope.trigger.id
|
||||
};
|
||||
|
||||
ApiService.listBuildTriggerSubdirs($scope.trigger['config'], params).then(function(resp) {
|
||||
if (resp['status'] == 'error') {
|
||||
$scope.locations = [];
|
||||
callback(resp['message'] || 'Could not load Dockerfile locations');
|
||||
return;
|
||||
}
|
||||
|
||||
$scope.locations = resp['subdir'] || [];
|
||||
|
||||
// Select a default location (if any).
|
||||
if ($scope.locations.length > 0) {
|
||||
$scope.setLocation($scope.locations[0]);
|
||||
} else {
|
||||
$scope.state.currentLocation = null;
|
||||
$scope.trigger.$ready = true;
|
||||
checkLocation();
|
||||
}
|
||||
|
||||
callback();
|
||||
}, ApiService.errorDisplay('Cannot load locations'));
|
||||
}
|
||||
|
||||
$scope.handleLocationInput = function(location) {
|
||||
$scope.trigger['config']['subdir'] = location || '';
|
||||
$scope.trigger.$ready = true;
|
||||
checkLocation();
|
||||
};
|
||||
|
||||
$scope.handleLocationSelected = function(datum) {
|
||||
$scope.setLocation(datum['value']);
|
||||
};
|
||||
|
||||
$scope.setLocation = function(location) {
|
||||
$scope.state.currentLocation = location;
|
||||
$scope.trigger['config']['subdir'] = location || '';
|
||||
$scope.trigger.$ready = true;
|
||||
checkLocation();
|
||||
};
|
||||
|
||||
$scope.selectRepo = function(repo, org) {
|
||||
$scope.state.currentRepo = {
|
||||
'repo': repo,
|
||||
'avatar_url': org['info']['avatar_url'],
|
||||
'toString': function() {
|
||||
return this.repo;
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
$scope.selectRepoInternal = function(currentRepo) {
|
||||
$scope.trigger.$ready = false;
|
||||
|
||||
var params = {
|
||||
'repository': $scope.repository.namespace + '/' + $scope.repository.name,
|
||||
'trigger_uuid': $scope.trigger['id']
|
||||
};
|
||||
|
||||
var repo = currentRepo['repo'];
|
||||
$scope.trigger['config'] = {
|
||||
'build_source': repo,
|
||||
'subdir': ''
|
||||
};
|
||||
};
|
||||
|
||||
$scope.scmIcon = function(kind) {
|
||||
return TriggerService.getIcon(kind);
|
||||
};
|
||||
|
||||
var setupTypeahead = function() {
|
||||
var repos = [];
|
||||
for (var i = 0; i < $scope.orgs.length; ++i) {
|
||||
var org = $scope.orgs[i];
|
||||
var orepos = org['repos'];
|
||||
for (var j = 0; j < orepos.length; ++j) {
|
||||
var repoValue = {
|
||||
'repo': orepos[j],
|
||||
'avatar_url': org['info']['avatar_url'],
|
||||
'toString': function() {
|
||||
return this.repo;
|
||||
}
|
||||
};
|
||||
var datum = {
|
||||
'name': orepos[j],
|
||||
'org': org,
|
||||
'value': orepos[j],
|
||||
'title': orepos[j],
|
||||
'item': repoValue
|
||||
};
|
||||
repos.push(datum);
|
||||
}
|
||||
}
|
||||
|
||||
$scope.repoLookahead = repos;
|
||||
};
|
||||
|
||||
$scope.$watch('trigger', function(trigger) {
|
||||
if (!trigger) { return; }
|
||||
$scope.supportsFullListing = TriggerService.supportsFullListing(trigger.service)
|
||||
});
|
||||
|
||||
$scope.$watch('state.currentRepo', function(repo) {
|
||||
if (repo) {
|
||||
$scope.selectRepoInternal(repo);
|
||||
}
|
||||
});
|
||||
|
||||
$scope.$watch('state.branchTagFilter', function(bf) {
|
||||
if (!$scope.trigger) { return; }
|
||||
|
||||
if ($scope.state.hasBranchTagFilter) {
|
||||
$scope.trigger['config']['branchtag_regex'] = bf;
|
||||
} else {
|
||||
delete $scope.trigger['config']['branchtag_regex'];
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
return directiveDefinitionObject;
|
||||
});
|
89
static/js/pages/trigger-setup.js
Normal file
89
static/js/pages/trigger-setup.js
Normal file
|
@ -0,0 +1,89 @@
|
|||
(function() {
|
||||
/**
|
||||
* Trigger setup page.
|
||||
*/
|
||||
angular.module('quayPages').config(['pages', function(pages) {
|
||||
pages.create('trigger-setup', 'trigger-setup.html', TriggerSetupCtrl, {
|
||||
'title': 'Setup build trigger',
|
||||
'description': 'Setup build trigger',
|
||||
'newLayout': true
|
||||
});
|
||||
}]);
|
||||
|
||||
function TriggerSetupCtrl($scope, ApiService, $routeParams, $location, UserService, TriggerService) {
|
||||
var namespace = $routeParams.namespace;
|
||||
var name = $routeParams.name;
|
||||
var trigger_uuid = $routeParams.triggerid;
|
||||
|
||||
var loadRepository = function() {
|
||||
var params = {
|
||||
'repository': namespace + '/' + name
|
||||
};
|
||||
|
||||
$scope.repositoryResource = ApiService.getRepoAsResource(params).get(function(repo) {
|
||||
$scope.repository = repo;
|
||||
});
|
||||
};
|
||||
|
||||
var loadTrigger = function() {
|
||||
var params = {
|
||||
'repository': namespace + '/' + name,
|
||||
'trigger_uuid': trigger_uuid
|
||||
};
|
||||
|
||||
$scope.triggerResource = ApiService.getBuildTriggerAsResource(params).get(function(trigger) {
|
||||
$scope.trigger = trigger;
|
||||
});
|
||||
};
|
||||
|
||||
loadTrigger();
|
||||
loadRepository();
|
||||
|
||||
$scope.state = 'managing';
|
||||
|
||||
$scope.activateTrigger = function(config, pull_robot) {
|
||||
$scope.state = 'activating';
|
||||
var params = {
|
||||
'repository': namespace + '/' + name,
|
||||
'trigger_uuid': trigger_uuid
|
||||
};
|
||||
|
||||
var data = {
|
||||
'config': config
|
||||
};
|
||||
|
||||
if (pull_robot) {
|
||||
data['pull_robot'] = pull_robot['name'];
|
||||
}
|
||||
|
||||
var errorHandler = ApiService.errorDisplay('Cannot activate build trigger', function(resp) {
|
||||
$scope.state = 'managing';
|
||||
return ApiService.getErrorMessage(resp) +
|
||||
'\n\nNote: Errors can occur if you do not have admin access on the repository';
|
||||
});
|
||||
|
||||
ApiService.activateBuildTrigger(data, params).then(function(resp) {
|
||||
$scope.trigger['is_active'] = true;
|
||||
$scope.trigger['config'] = resp['config'];
|
||||
$scope.trigger['pull_robot'] = resp['pull_robot'];
|
||||
$scope.trigger['repository_url'] = resp['repository_url'];
|
||||
$scope.state = 'activated';
|
||||
|
||||
// If there are no credentials to display, redirect to the builds tab.
|
||||
if (!$scope.trigger['config'].credentials) {
|
||||
$location.url('/repository/' + namespace + '/' + name + '?tab=builds');
|
||||
}
|
||||
}, errorHandler);
|
||||
};
|
||||
|
||||
$scope.getTriggerIcon = function() {
|
||||
if (!$scope.trigger) { return ''; }
|
||||
return TriggerService.getIcon($scope.trigger.service);
|
||||
};
|
||||
|
||||
$scope.getTriggerId = function() {
|
||||
if (!trigger_uuid) { return ''; }
|
||||
return trigger_uuid.split('-')[0];
|
||||
};
|
||||
}
|
||||
}());
|
117
static/js/quay-config.module.ts
Normal file
117
static/js/quay-config.module.ts
Normal file
|
@ -0,0 +1,117 @@
|
|||
import { NgModule } from 'angular-ts-decorators';
|
||||
import { INJECTED_CONFIG, INJECTED_FEATURES, INJECTED_ENDPOINTS } from "./constants/injected-values.constant";
|
||||
import { NAME_PATTERNS } from "./constants/name-patterns.constant";
|
||||
import * as Raven from "raven-js";
|
||||
import * as angular from 'angular';
|
||||
|
||||
|
||||
var quayDependencies: any[] = [
|
||||
'chieffancypants.loadingBar',
|
||||
'cfp.hotkeys',
|
||||
'angular-tour',
|
||||
'restangular',
|
||||
'angularMoment',
|
||||
'mgcrea.ngStrap',
|
||||
'ngCookies',
|
||||
'ngSanitize',
|
||||
'angular-md5',
|
||||
'pasvaz.bindonce',
|
||||
'ansiToHtml',
|
||||
'core-ui',
|
||||
'core-config-setup',
|
||||
'infinite-scroll',
|
||||
'react'
|
||||
];
|
||||
|
||||
if (INJECTED_CONFIG && (INJECTED_CONFIG.MIXPANEL_KEY ||
|
||||
INJECTED_CONFIG.MUNCHKIN_KEY ||
|
||||
INJECTED_CONFIG.GOOGLE_ANALYTICS_KEY)) {
|
||||
quayDependencies.push('angulartics');
|
||||
}
|
||||
if (INJECTED_CONFIG && INJECTED_CONFIG.MIXPANEL_KEY) {
|
||||
quayDependencies.push('angulartics.mixpanel');
|
||||
}
|
||||
if (INJECTED_CONFIG && INJECTED_CONFIG.MUNCHKIN_KEY) {
|
||||
quayDependencies.push('angulartics.marketo');
|
||||
}
|
||||
if (INJECTED_CONFIG && INJECTED_CONFIG.GOOGLE_ANALYTICS_KEY) {
|
||||
quayDependencies.push('angulartics.google.analytics');
|
||||
}
|
||||
if (INJECTED_CONFIG && INJECTED_CONFIG.RECAPTCHA_SITE_KEY) {
|
||||
quayDependencies.push('vcRecaptcha');
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Module for application-wide configuration.
|
||||
*/
|
||||
@NgModule({
|
||||
imports: quayDependencies,
|
||||
declarations: [],
|
||||
providers: []
|
||||
})
|
||||
export class QuayConfig {
|
||||
|
||||
public config($provide: ng.auto.IProvideService,
|
||||
$injector: ng.auto.IInjectorService,
|
||||
INJECTED_CONFIG: any,
|
||||
cfpLoadingBarProvider: any,
|
||||
$tooltipProvider: any,
|
||||
$compileProvider: ng.ICompileProvider,
|
||||
RestangularProvider: any): void {
|
||||
cfpLoadingBarProvider.includeSpinner = false;
|
||||
|
||||
// decorate the tooltip getter
|
||||
var tooltipFactory: any = $tooltipProvider.$get[$tooltipProvider.$get.length - 1];
|
||||
$tooltipProvider.$get[$tooltipProvider.$get.length - 1] = function($window: ng.IWindowService) {
|
||||
if ('ontouchstart' in $window) {
|
||||
var existing: any = tooltipFactory.apply(this, arguments);
|
||||
return function(element) {
|
||||
// Note: We only disable bs-tooltip's themselves. $tooltip is used for other things
|
||||
// (such as the datepicker), so we need to be specific when canceling it.
|
||||
if (element !== undefined && element.attr('bs-tooltip') == null) {
|
||||
return existing.apply(this, arguments);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return tooltipFactory.apply(this, arguments);
|
||||
};
|
||||
|
||||
if (!INJECTED_CONFIG['DEBUG']) {
|
||||
$compileProvider.debugInfoEnabled(false);
|
||||
}
|
||||
|
||||
// Configure compile provider to add additional URL prefixes to the sanitization list. We use
|
||||
// these on the Contact page.
|
||||
$compileProvider.aHrefSanitizationWhitelist(/^\s*(https?|ftp|mailto|tel|irc):/);
|
||||
|
||||
// Configure the API provider.
|
||||
RestangularProvider.setBaseUrl('/api/v1/');
|
||||
|
||||
// Configure analytics.
|
||||
if (INJECTED_CONFIG && INJECTED_CONFIG.MIXPANEL_KEY) {
|
||||
let $analyticsProvider: any = $injector.get('$analyticsProvider');
|
||||
$analyticsProvider.virtualPageviews(true);
|
||||
}
|
||||
|
||||
// Configure sentry.
|
||||
if (INJECTED_CONFIG && INJECTED_CONFIG.SENTRY_PUBLIC_DSN) {
|
||||
$provide.decorator("$exceptionHandler", function($delegate) {
|
||||
return function(ex, cause) {
|
||||
$delegate(ex, cause);
|
||||
Raven.captureException(ex, {extra: {cause: cause}});
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// TODO: Make injected values into services and move to NgModule.providers, as constants are not supported in Angular 2
|
||||
angular
|
||||
.module(QuayConfig.name)
|
||||
.constant('NAME_PATTERNS', NAME_PATTERNS)
|
||||
.constant('INJECTED_CONFIG', INJECTED_CONFIG)
|
||||
.constant('INJECTED_FEATURES', INJECTED_FEATURES)
|
||||
.constant('INJECTED_ENDPOINTS', INJECTED_ENDPOINTS);
|
|
@ -1,12 +1,28 @@
|
|||
import * as angular from 'angular';
|
||||
import { rpHeaderDirective, rpBodyDirective, rpSidebarDirective } from './directives/components/pages/repo-page/main';
|
||||
import pages from './constants/pages.constant';
|
||||
import { PageServiceImpl } from './services/page/page.service.impl';
|
||||
import { NgModule } from 'angular-ts-decorators';
|
||||
|
||||
|
||||
export default angular
|
||||
.module('quayPages', [])
|
||||
.constant('pages', pages)
|
||||
/**
|
||||
* Module containing registered application page/view components.
|
||||
*/
|
||||
@NgModule({
|
||||
imports: [],
|
||||
declarations: [],
|
||||
providers: [
|
||||
PageServiceImpl,
|
||||
]
|
||||
})
|
||||
export class quayPages {
|
||||
|
||||
}
|
||||
|
||||
|
||||
// TODO: Move component registration to @NgModule and remove this.
|
||||
angular
|
||||
.module(quayPages.name)
|
||||
.constant('pages', new PageServiceImpl())
|
||||
.directive('rpHeader', rpHeaderDirective)
|
||||
.directive('rpSidebar', rpSidebarDirective)
|
||||
.directive('rpBody', rpBodyDirective)
|
||||
.name;
|
||||
.directive('rpBody', rpBodyDirective);
|
||||
|
|
146
static/js/quay-routes.module.ts
Normal file
146
static/js/quay-routes.module.ts
Normal file
|
@ -0,0 +1,146 @@
|
|||
import { RouteBuilderImpl } from './services/route-builder/route-builder.service.impl';
|
||||
import { RouteBuilder } from './services/route-builder/route-builder.service';
|
||||
import { PageService } from './services/page/page.service';
|
||||
import * as ng from '@types/angular';
|
||||
import { NgModule } from 'angular-ts-decorators';
|
||||
import { INJECTED_FEATURES } from './constants/injected-values.constant';
|
||||
import { quayPages } from './quay-pages.module';
|
||||
|
||||
|
||||
/**
|
||||
* Module containing client-side routing configuration.
|
||||
*/
|
||||
@NgModule({
|
||||
imports: [
|
||||
quayPages,
|
||||
'ngRoute',
|
||||
],
|
||||
declarations: [],
|
||||
providers: [],
|
||||
})
|
||||
export class QuayRoutes {
|
||||
|
||||
public config($routeProvider: ng.route.IRouteProvider,
|
||||
$locationProvider: ng.ILocationProvider,
|
||||
pages: PageService): void {
|
||||
$locationProvider.html5Mode(true);
|
||||
|
||||
// WARNING WARNING WARNING
|
||||
// If you add a route here, you must add a corresponding route in thr endpoints/web.py
|
||||
// index rule to make sure that deep links directly deep into the app continue to work.
|
||||
// WARNING WARNING WARNING
|
||||
|
||||
var routeBuilder: RouteBuilder = new RouteBuilderImpl($routeProvider, pages.$get());
|
||||
|
||||
if (INJECTED_FEATURES.SUPER_USERS) {
|
||||
// QE Management
|
||||
routeBuilder.route('/superuser/', 'superuser')
|
||||
// QE Setup
|
||||
.route('/setup/', 'setup');
|
||||
}
|
||||
|
||||
routeBuilder
|
||||
// Repository View
|
||||
.route('/repository/:namespace/:name', 'repo-view')
|
||||
.route('/repository/:namespace/:name/tag/:tag', 'repo-view')
|
||||
|
||||
// Image View
|
||||
.route('/repository/:namespace/:name/image/:image', 'image-view')
|
||||
|
||||
// Repo Build View
|
||||
.route('/repository/:namespace/:name/build/:buildid', 'build-view')
|
||||
|
||||
// Repo Trigger View
|
||||
.route('/repository/:namespace/:name/trigger/:triggerid', 'trigger-setup')
|
||||
|
||||
// Create repository notification
|
||||
.route('/repository/:namespace/:name/create-notification', 'create-repository-notification')
|
||||
|
||||
// Repo List
|
||||
.route('/repository/', 'repo-list')
|
||||
|
||||
// Organizations
|
||||
.route('/organizations/', 'organizations')
|
||||
|
||||
// New Organization
|
||||
.route('/organizations/new/', 'new-organization')
|
||||
|
||||
// View Organization
|
||||
.route('/organization/:orgname', 'org-view')
|
||||
|
||||
// View Organization Team
|
||||
.route('/organization/:orgname/teams/:teamname', 'team-view')
|
||||
|
||||
// Organization View Application
|
||||
.route('/organization/:orgname/application/:clientid', 'manage-application')
|
||||
|
||||
// View Organization Billing
|
||||
.route('/organization/:orgname/billing', 'billing')
|
||||
|
||||
// View Organization Billing Invoices
|
||||
.route('/organization/:orgname/billing/invoices', 'invoices')
|
||||
|
||||
// View User
|
||||
.route('/user/:username', 'user-view')
|
||||
|
||||
// View User Billing
|
||||
.route('/user/:username/billing', 'billing')
|
||||
|
||||
// View User Billing Invoices
|
||||
.route('/user/:username/billing/invoices', 'invoices')
|
||||
|
||||
// Sign In
|
||||
.route('/signin/', 'signin')
|
||||
|
||||
// New Repository
|
||||
.route('/new/', 'new-repo')
|
||||
|
||||
// Plans
|
||||
.route('/plans/', 'plans')
|
||||
|
||||
// Tutorial
|
||||
.route('/tutorial/', 'tutorial')
|
||||
|
||||
// Contact
|
||||
.route('/contact/', 'contact')
|
||||
|
||||
// About
|
||||
.route('/about/', 'about')
|
||||
|
||||
// Security
|
||||
.route('/security/', 'security')
|
||||
|
||||
// TOS
|
||||
.route('/tos', 'tos')
|
||||
|
||||
// Privacy
|
||||
.route('/privacy', 'privacy')
|
||||
|
||||
// Change username
|
||||
.route('/updateuser', 'update-user')
|
||||
|
||||
// Landing Page
|
||||
.route('/', 'landing')
|
||||
|
||||
// Tour
|
||||
.route('/tour/', 'tour')
|
||||
.route('/tour/features', 'tour')
|
||||
.route('/tour/organizations', 'tour')
|
||||
.route('/tour/enterprise', 'tour')
|
||||
|
||||
// Confirm Invite
|
||||
.route('/confirminvite', 'confirm-invite')
|
||||
|
||||
// Enterprise marketing page
|
||||
.route('/enterprise', 'enterprise')
|
||||
|
||||
// Public Repo Experiments
|
||||
.route('/__exp/publicRepo', 'public-repo-exp')
|
||||
|
||||
// 404/403
|
||||
.route('/:catchall', 'error-view')
|
||||
.route('/:catch/:all', 'error-view')
|
||||
.route('/:catch/:all/:things', 'error-view')
|
||||
.route('/:catch/:all/:things/:here', 'error-view');
|
||||
}
|
||||
}
|
179
static/js/quay-run.module.ts
Normal file
179
static/js/quay-run.module.ts
Normal file
|
@ -0,0 +1,179 @@
|
|||
import { NgModule } from 'angular-ts-decorators';
|
||||
import { INJECTED_CONFIG, INJECTED_FEATURES, INJECTED_ENDPOINTS } from "./constants/injected-values.constant";
|
||||
import { NAME_PATTERNS } from "./constants/name-patterns.constant";
|
||||
import * as angular from 'angular';
|
||||
|
||||
|
||||
var quayDependencies: any[] = [
|
||||
'chieffancypants.loadingBar',
|
||||
'cfp.hotkeys',
|
||||
'angular-tour',
|
||||
'restangular',
|
||||
'angularMoment',
|
||||
'mgcrea.ngStrap',
|
||||
'ngCookies',
|
||||
'ngSanitize',
|
||||
'angular-md5',
|
||||
'pasvaz.bindonce',
|
||||
'ansiToHtml',
|
||||
'core-ui',
|
||||
'core-config-setup',
|
||||
'infinite-scroll',
|
||||
'react'
|
||||
];
|
||||
|
||||
if (INJECTED_CONFIG && (INJECTED_CONFIG.MIXPANEL_KEY ||
|
||||
INJECTED_CONFIG.MUNCHKIN_KEY ||
|
||||
INJECTED_CONFIG.GOOGLE_ANALYTICS_KEY)) {
|
||||
quayDependencies.push('angulartics');
|
||||
}
|
||||
if (INJECTED_CONFIG && INJECTED_CONFIG.MIXPANEL_KEY) {
|
||||
quayDependencies.push('angulartics.mixpanel');
|
||||
}
|
||||
if (INJECTED_CONFIG && INJECTED_CONFIG.MUNCHKIN_KEY) {
|
||||
quayDependencies.push('angulartics.marketo');
|
||||
}
|
||||
if (INJECTED_CONFIG && INJECTED_CONFIG.GOOGLE_ANALYTICS_KEY) {
|
||||
quayDependencies.push('angulartics.google.analytics');
|
||||
}
|
||||
if (INJECTED_CONFIG && INJECTED_CONFIG.RECAPTCHA_SITE_KEY) {
|
||||
quayDependencies.push('vcRecaptcha');
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Module for application-wide configuration.
|
||||
*/
|
||||
@NgModule({
|
||||
imports: quayDependencies,
|
||||
declarations: [],
|
||||
providers: []
|
||||
})
|
||||
export class QuayRun {
|
||||
|
||||
public run($rootScope: QuayRunScope,
|
||||
Restangular: any,
|
||||
PlanService: any,
|
||||
$http: ng.IHttpService,
|
||||
CookieService: any,
|
||||
Features: any,
|
||||
$anchorScroll: ng.IAnchorScrollService,
|
||||
MetaService: any,
|
||||
INJECTED_CONFIG: any): void {
|
||||
var defaultTitle = INJECTED_CONFIG['REGISTRY_TITLE'] || 'Quay Container Registry';
|
||||
|
||||
// Handle session security.
|
||||
Restangular.setDefaultRequestParams(['post', 'put', 'remove', 'delete'],
|
||||
{'_csrf_token': (<any>window).__token || ''});
|
||||
|
||||
// Handle session expiration.
|
||||
Restangular.setErrorInterceptor(function(response) {
|
||||
if (response !== undefined && response.status == 503) {
|
||||
(<any>$('#cannotContactService')).modal({});
|
||||
return false;
|
||||
}
|
||||
|
||||
if (response !== undefined && response.status == 500) {
|
||||
window.location.href = '/500';
|
||||
return false;
|
||||
}
|
||||
|
||||
if (response !== undefined && !response.data) {
|
||||
return true;
|
||||
}
|
||||
|
||||
var invalid_token = response.data['title'] == 'invalid_token' || response.data['error_type'] == 'invalid_token';
|
||||
if (response !== undefined && response.status == 401 &&
|
||||
invalid_token &&
|
||||
response.data['session_required'] !== false) {
|
||||
(<any>$('#sessionexpiredModal')).modal({});
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
// Check if we need to redirect based on a previously chosen plan.
|
||||
var result = PlanService.handleNotedPlan();
|
||||
|
||||
// Check to see if we need to show a redirection page.
|
||||
var redirectUrl = CookieService.get('quay.redirectAfterLoad');
|
||||
CookieService.clear('quay.redirectAfterLoad');
|
||||
|
||||
if (!result && redirectUrl && redirectUrl.indexOf((<any>window).location.href) == 0) {
|
||||
(<any>window).location = redirectUrl;
|
||||
return;
|
||||
}
|
||||
|
||||
$rootScope.$watch('description', function(description: string) {
|
||||
if (!description) {
|
||||
description = `Hosted private docker repositories. Includes full user management and history.
|
||||
Free for public repositories.`;
|
||||
}
|
||||
|
||||
// Note: We set the content of the description tag manually here rather than using Angular binding
|
||||
// because we need the <meta> tag to have a default description that is not of the form "{{ description }}",
|
||||
// we read by tools that do not properly invoke the Angular code.
|
||||
$('#descriptionTag').attr('content', description);
|
||||
});
|
||||
|
||||
// Listen for scope changes and update the title and description accordingly.
|
||||
$rootScope.$watch(function() {
|
||||
var title = MetaService.getTitle($rootScope.currentPage) || defaultTitle;
|
||||
$rootScope.title = title;
|
||||
|
||||
var description = MetaService.getDescription($rootScope.currentPage) || '';
|
||||
if ($rootScope.description != description) {
|
||||
$rootScope.description = description;
|
||||
}
|
||||
});
|
||||
|
||||
$rootScope.$on('$routeChangeSuccess', function (event, current, previous) {
|
||||
$rootScope.current = current.$$route;
|
||||
$rootScope.currentPage = current;
|
||||
$rootScope.pageClass = '';
|
||||
|
||||
if (!current.$$route) { return; }
|
||||
|
||||
var pageClass = current.$$route.pageClass || '';
|
||||
if (typeof pageClass != 'string') {
|
||||
pageClass = pageClass(Features);
|
||||
}
|
||||
|
||||
$rootScope.pageClass = pageClass;
|
||||
$rootScope.newLayout = !!current.$$route.newLayout;
|
||||
$rootScope.fixFooter = !!current.$$route.fixFooter;
|
||||
|
||||
$anchorScroll();
|
||||
});
|
||||
|
||||
var initallyChecked: boolean = false;
|
||||
(<any>window).__isLoading = function() {
|
||||
if (!initallyChecked) {
|
||||
initallyChecked = true;
|
||||
return true;
|
||||
}
|
||||
return $http.pendingRequests.length > 0;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
interface QuayRunScope extends ng.IRootScopeService {
|
||||
currentPage: any;
|
||||
current: any;
|
||||
title: any;
|
||||
description: string,
|
||||
pageClass: any;
|
||||
newLayout: any;
|
||||
fixFooter: any;
|
||||
}
|
||||
|
||||
|
||||
// TODO: Make injected values into services and move to NgModule.providers, as constants are not supported in Angular 2
|
||||
angular
|
||||
.module(QuayRun.name)
|
||||
.constant('NAME_PATTERNS', NAME_PATTERNS)
|
||||
.constant('INJECTED_CONFIG', INJECTED_CONFIG)
|
||||
.constant('INJECTED_FEATURES', INJECTED_FEATURES)
|
||||
.constant('INJECTED_ENDPOINTS', INJECTED_ENDPOINTS);
|
|
@ -1,67 +0,0 @@
|
|||
import * as Raven from 'raven-js';
|
||||
|
||||
|
||||
quayConfig.$inject = [
|
||||
'$provide',
|
||||
'$injector',
|
||||
'INJECTED_CONFIG',
|
||||
'cfpLoadingBarProvider',
|
||||
'$tooltipProvider',
|
||||
'$compileProvider',
|
||||
'RestangularProvider',
|
||||
];
|
||||
|
||||
export function quayConfig(
|
||||
$provide: ng.auto.IProvideService,
|
||||
$injector: ng.auto.IInjectorService,
|
||||
INJECTED_CONFIG: any,
|
||||
cfpLoadingBarProvider: any,
|
||||
$tooltipProvider: any,
|
||||
$compileProvider: ng.ICompileProvider,
|
||||
RestangularProvider: any) {
|
||||
cfpLoadingBarProvider.includeSpinner = false;
|
||||
|
||||
// decorate the tooltip getter
|
||||
var tooltipFactory: any = $tooltipProvider.$get[$tooltipProvider.$get.length - 1];
|
||||
$tooltipProvider.$get[$tooltipProvider.$get.length - 1] = function($window: ng.IWindowService) {
|
||||
if ('ontouchstart' in $window) {
|
||||
var existing: any = tooltipFactory.apply(this, arguments);
|
||||
return function(element) {
|
||||
// Note: We only disable bs-tooltip's themselves. $tooltip is used for other things
|
||||
// (such as the datepicker), so we need to be specific when canceling it.
|
||||
if (element !== undefined && element.attr('bs-tooltip') == null) {
|
||||
return existing.apply(this, arguments);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return tooltipFactory.apply(this, arguments);
|
||||
};
|
||||
|
||||
if (!INJECTED_CONFIG['DEBUG']) {
|
||||
$compileProvider.debugInfoEnabled(false);
|
||||
}
|
||||
|
||||
// Configure compile provider to add additional URL prefixes to the sanitization list. We use
|
||||
// these on the Contact page.
|
||||
$compileProvider.aHrefSanitizationWhitelist(/^\s*(https?|ftp|mailto|tel|irc):/);
|
||||
|
||||
// Configure the API provider.
|
||||
RestangularProvider.setBaseUrl('/api/v1/');
|
||||
|
||||
// Configure analytics.
|
||||
if (INJECTED_CONFIG && INJECTED_CONFIG.MIXPANEL_KEY) {
|
||||
let $analyticsProvider: any = $injector.get('$analyticsProvider');
|
||||
$analyticsProvider.virtualPageviews(true);
|
||||
}
|
||||
|
||||
// Configure sentry.
|
||||
if (INJECTED_CONFIG && INJECTED_CONFIG.SENTRY_PUBLIC_DSN) {
|
||||
$provide.decorator("$exceptionHandler", function($delegate) {
|
||||
return function(ex, cause) {
|
||||
$delegate(ex, cause);
|
||||
Raven.captureException(ex, {extra: {cause: cause}});
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
|
@ -1,59 +1,48 @@
|
|||
import * as angular from 'angular';
|
||||
import { quayConfig } from './quay.config';
|
||||
import quayPages from './quay-pages.module';
|
||||
import quayRun from './quay.run';
|
||||
import { ViewArrayImpl } from './services/view-array/view-array.impl';
|
||||
import NAME_PATTERNS from './constants/name-patterns.constant';
|
||||
import { routeConfig } from './quay.routes';
|
||||
import { INJECTED_CONFIG, INJECTED_FEATURES, INJECTED_ENDPOINTS } from './constants/injected-values.constant';
|
||||
import * as angular from "angular";
|
||||
import { ViewArrayImpl } from "./services/view-array/view-array.impl";
|
||||
import { NAME_PATTERNS } from "./constants/name-patterns.constant";
|
||||
import { INJECTED_CONFIG, INJECTED_FEATURES, INJECTED_ENDPOINTS } from "./constants/injected-values.constant";
|
||||
import { RegexMatchViewComponent } from "./directives/ui/regex-match-view/regex-match-view.component";
|
||||
import { NgModule } from "angular-ts-decorators";
|
||||
import { QuayRoutes } from "./quay-routes.module";
|
||||
import { DockerfilePathSelectComponent } from './directives/ui/dockerfile-path-select/dockerfile-path-select.component';
|
||||
import { ManageTriggerCustomGitComponent } from './directives/ui/manage-trigger-custom-git/manage-trigger-custom-git.component';
|
||||
import { ManageTriggerGithostComponent } from './directives/ui/manage-trigger-githost/manage-trigger-githost.component';
|
||||
import { LinearWorkflowComponent } from './directives/ui/linear-workflow/linear-workflow.component';
|
||||
import { LinearWorkflowSectionComponent } from './directives/ui/linear-workflow/linear-workflow-section.component';
|
||||
import { QuayConfig } from './quay-config.module';
|
||||
import { QuayRun } from './quay-run.module';
|
||||
|
||||
|
||||
var quayDependencies: string[] = [
|
||||
quayPages,
|
||||
'ngRoute',
|
||||
'chieffancypants.loadingBar',
|
||||
'cfp.hotkeys',
|
||||
'angular-tour',
|
||||
'restangular',
|
||||
'angularMoment',
|
||||
'mgcrea.ngStrap',
|
||||
'ngCookies',
|
||||
'ngSanitize',
|
||||
'angular-md5',
|
||||
'pasvaz.bindonce',
|
||||
'ansiToHtml',
|
||||
'core-ui',
|
||||
'core-config-setup',
|
||||
'infinite-scroll',
|
||||
'react'
|
||||
];
|
||||
/**
|
||||
* Main application module.
|
||||
*/
|
||||
@NgModule({
|
||||
imports: [
|
||||
QuayRoutes,
|
||||
QuayConfig,
|
||||
QuayRun,
|
||||
],
|
||||
declarations: [
|
||||
RegexMatchViewComponent,
|
||||
DockerfilePathSelectComponent,
|
||||
ManageTriggerCustomGitComponent,
|
||||
ManageTriggerGithostComponent,
|
||||
LinearWorkflowComponent,
|
||||
LinearWorkflowSectionComponent,
|
||||
],
|
||||
providers: [
|
||||
ViewArrayImpl,
|
||||
],
|
||||
})
|
||||
export class quay {
|
||||
|
||||
if (INJECTED_CONFIG && (INJECTED_CONFIG.MIXPANEL_KEY ||
|
||||
INJECTED_CONFIG.MUNCHKIN_KEY ||
|
||||
INJECTED_CONFIG.GOOGLE_ANALYTICS_KEY)) {
|
||||
quayDependencies.push('angulartics');
|
||||
}
|
||||
if (INJECTED_CONFIG && INJECTED_CONFIG.MIXPANEL_KEY) {
|
||||
quayDependencies.push('angulartics.mixpanel');
|
||||
}
|
||||
if (INJECTED_CONFIG && INJECTED_CONFIG.MUNCHKIN_KEY) {
|
||||
quayDependencies.push('angulartics.marketo');
|
||||
}
|
||||
if (INJECTED_CONFIG && INJECTED_CONFIG.GOOGLE_ANALYTICS_KEY) {
|
||||
quayDependencies.push('angulartics.google.analytics');
|
||||
}
|
||||
if (INJECTED_CONFIG && INJECTED_CONFIG.RECAPTCHA_SITE_KEY) {
|
||||
quayDependencies.push('vcRecaptcha');
|
||||
}
|
||||
|
||||
export default angular
|
||||
.module('quay', quayDependencies)
|
||||
.config(quayConfig)
|
||||
.config(routeConfig)
|
||||
// TODO: Make injected values into services and move to NgModule.providers, as constants are not supported in Angular 2
|
||||
angular
|
||||
.module(quay.name)
|
||||
.constant('NAME_PATTERNS', NAME_PATTERNS)
|
||||
.constant('INJECTED_CONFIG', INJECTED_CONFIG)
|
||||
.constant('INJECTED_FEATURES', INJECTED_FEATURES)
|
||||
.constant('INJECTED_ENDPOINTS', INJECTED_ENDPOINTS)
|
||||
.service('ViewArray', ViewArrayImpl)
|
||||
.run(quayRun)
|
||||
.name;
|
||||
.constant('INJECTED_ENDPOINTS', INJECTED_ENDPOINTS);
|
||||
|
|
|
@ -1,135 +0,0 @@
|
|||
import { RouteBuilderImpl } from './services/route-builder/route-builder.service.impl';
|
||||
import { RouteBuilder } from './services/route-builder/route-builder.service';
|
||||
import pages from './constants/pages.constant';
|
||||
import * as ng from '@types/angular';
|
||||
|
||||
|
||||
routeConfig.$inject = [
|
||||
'pages',
|
||||
'$routeProvider',
|
||||
'$locationProvider',
|
||||
'INJECTED_FEATURES',
|
||||
];
|
||||
|
||||
export function routeConfig(
|
||||
pages: any,
|
||||
$routeProvider: ng.route.IRouteProvider,
|
||||
$locationProvider: ng.ILocationProvider,
|
||||
INJECTED_FEATURES) {
|
||||
$locationProvider.html5Mode(true);
|
||||
|
||||
// WARNING WARNING WARNING
|
||||
// If you add a route here, you must add a corresponding route in thr endpoints/web.py
|
||||
// index rule to make sure that deep links directly deep into the app continue to work.
|
||||
// WARNING WARNING WARNING
|
||||
|
||||
var routeBuilder: RouteBuilder = new RouteBuilderImpl($routeProvider, pages);
|
||||
|
||||
if (INJECTED_FEATURES.SUPER_USERS) {
|
||||
// QE Management
|
||||
routeBuilder.route('/superuser/', 'superuser')
|
||||
// QE Setup
|
||||
.route('/setup/', 'setup');
|
||||
}
|
||||
|
||||
routeBuilder
|
||||
// Repository View
|
||||
.route('/repository/:namespace/:name', 'repo-view')
|
||||
.route('/repository/:namespace/:name/tag/:tag', 'repo-view')
|
||||
|
||||
// Image View
|
||||
.route('/repository/:namespace/:name/image/:image', 'image-view')
|
||||
|
||||
// Repo Build View
|
||||
.route('/repository/:namespace/:name/build/:buildid', 'build-view')
|
||||
|
||||
// Create repository notification
|
||||
.route('/repository/:namespace/:name/create-notification', 'create-repository-notification')
|
||||
|
||||
// Repo List
|
||||
.route('/repository/', 'repo-list')
|
||||
|
||||
// Organizations
|
||||
.route('/organizations/', 'organizations')
|
||||
|
||||
// New Organization
|
||||
.route('/organizations/new/', 'new-organization')
|
||||
|
||||
// View Organization
|
||||
.route('/organization/:orgname', 'org-view')
|
||||
|
||||
// View Organization Team
|
||||
.route('/organization/:orgname/teams/:teamname', 'team-view')
|
||||
|
||||
// Organization View Application
|
||||
.route('/organization/:orgname/application/:clientid', 'manage-application')
|
||||
|
||||
// View Organization Billing
|
||||
.route('/organization/:orgname/billing', 'billing')
|
||||
|
||||
// View Organization Billing Invoices
|
||||
.route('/organization/:orgname/billing/invoices', 'invoices')
|
||||
|
||||
// View User
|
||||
.route('/user/:username', 'user-view')
|
||||
|
||||
// View User Billing
|
||||
.route('/user/:username/billing', 'billing')
|
||||
|
||||
// View User Billing Invoices
|
||||
.route('/user/:username/billing/invoices', 'invoices')
|
||||
|
||||
// Sign In
|
||||
.route('/signin/', 'signin')
|
||||
|
||||
// New Repository
|
||||
.route('/new/', 'new-repo')
|
||||
|
||||
// Plans
|
||||
.route('/plans/', 'plans')
|
||||
|
||||
// Tutorial
|
||||
.route('/tutorial/', 'tutorial')
|
||||
|
||||
// Contact
|
||||
.route('/contact/', 'contact')
|
||||
|
||||
// About
|
||||
.route('/about/', 'about')
|
||||
|
||||
// Security
|
||||
.route('/security/', 'security')
|
||||
|
||||
// TOS
|
||||
.route('/tos', 'tos')
|
||||
|
||||
// Privacy
|
||||
.route('/privacy', 'privacy')
|
||||
|
||||
// Change username
|
||||
.route('/updateuser', 'update-user')
|
||||
|
||||
// Landing Page
|
||||
.route('/', 'landing')
|
||||
|
||||
// Tour
|
||||
.route('/tour/', 'tour')
|
||||
.route('/tour/features', 'tour')
|
||||
.route('/tour/organizations', 'tour')
|
||||
.route('/tour/enterprise', 'tour')
|
||||
|
||||
// Confirm Invite
|
||||
.route('/confirminvite', 'confirm-invite')
|
||||
|
||||
// Enterprise marketing page
|
||||
.route('/enterprise', 'enterprise')
|
||||
|
||||
// Public Repo Experiments
|
||||
.route('/__exp/publicRepo', 'public-repo-exp')
|
||||
|
||||
// 404/403
|
||||
.route('/:catchall', 'error-view')
|
||||
.route('/:catch/:all', 'error-view')
|
||||
.route('/:catch/:all/:things', 'error-view')
|
||||
.route('/:catch/:all/:things/:here', 'error-view');
|
||||
}
|
|
@ -1,143 +0,0 @@
|
|||
import * as $ from 'jquery';
|
||||
import * as ng from '@types/angular';
|
||||
|
||||
|
||||
quayRun.$inject = [
|
||||
'$location',
|
||||
'$rootScope',
|
||||
'Restangular',
|
||||
'UserService',
|
||||
'PlanService',
|
||||
'$http',
|
||||
'$timeout',
|
||||
'CookieService',
|
||||
'Features',
|
||||
'$anchorScroll',
|
||||
'UtilService',
|
||||
'MetaService',
|
||||
'INJECTED_CONFIG',
|
||||
];
|
||||
|
||||
export default function quayRun(
|
||||
$location: ng.ILocationService,
|
||||
$rootScope: QuayRunScope,
|
||||
Restangular: any,
|
||||
UserService: any,
|
||||
PlanService: any,
|
||||
$http: ng.IHttpService,
|
||||
$timeout: ng.ITimeoutService,
|
||||
CookieService: any,
|
||||
Features: any,
|
||||
$anchorScroll: ng.IAnchorScrollService,
|
||||
UtilService: any,
|
||||
MetaService: any,
|
||||
INJECTED_CONFIG: any) {
|
||||
var defaultTitle = INJECTED_CONFIG['REGISTRY_TITLE'] || 'Quay Container Registry';
|
||||
|
||||
// Handle session security.
|
||||
Restangular.setDefaultRequestParams(['post', 'put', 'remove', 'delete'],
|
||||
{'_csrf_token': (<any>window).__token || ''});
|
||||
|
||||
// Handle session expiration.
|
||||
Restangular.setErrorInterceptor(function(response) {
|
||||
if (response !== undefined && response.status == 503) {
|
||||
(<any>$('#cannotContactService')).modal({});
|
||||
return false;
|
||||
}
|
||||
|
||||
if (response !== undefined && response.status == 500) {
|
||||
window.location.href = '/500';
|
||||
return false;
|
||||
}
|
||||
|
||||
if (response !== undefined && !response.data) {
|
||||
return true;
|
||||
}
|
||||
|
||||
var invalid_token = response.data['title'] == 'invalid_token' || response.data['error_type'] == 'invalid_token';
|
||||
if (response !== undefined && response.status == 401 &&
|
||||
invalid_token &&
|
||||
response.data['session_required'] !== false) {
|
||||
(<any>$('#sessionexpiredModal')).modal({});
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
// Check if we need to redirect based on a previously chosen plan.
|
||||
var result = PlanService.handleNotedPlan();
|
||||
|
||||
// Check to see if we need to show a redirection page.
|
||||
var redirectUrl = CookieService.get('quay.redirectAfterLoad');
|
||||
CookieService.clear('quay.redirectAfterLoad');
|
||||
|
||||
if (!result && redirectUrl && redirectUrl.indexOf((<any>window).location.href) == 0) {
|
||||
(<any>window).location = redirectUrl;
|
||||
return;
|
||||
}
|
||||
|
||||
$rootScope.$watch('description', function(description: string) {
|
||||
if (!description) {
|
||||
description = `Hosted private docker repositories. Includes full user management and history.
|
||||
Free for public repositories.`;
|
||||
}
|
||||
|
||||
// Note: We set the content of the description tag manually here rather than using Angular binding
|
||||
// because we need the <meta> tag to have a default description that is not of the form "{{ description }}",
|
||||
// we read by tools that do not properly invoke the Angular code.
|
||||
$('#descriptionTag').attr('content', description);
|
||||
});
|
||||
|
||||
// Listen for scope changes and update the title and description accordingly.
|
||||
$rootScope.$watch(function() {
|
||||
var title = MetaService.getTitle($rootScope.currentPage) || defaultTitle;
|
||||
$rootScope.title = title;
|
||||
|
||||
var description = MetaService.getDescription($rootScope.currentPage) || '';
|
||||
if ($rootScope.description != description) {
|
||||
$rootScope.description = description;
|
||||
}
|
||||
});
|
||||
|
||||
$rootScope.$on('$routeChangeSuccess', function (event, current, previous) {
|
||||
$rootScope.current = current.$$route;
|
||||
$rootScope.currentPage = current;
|
||||
|
||||
$rootScope.pageClass = '';
|
||||
|
||||
if (!current.$$route) { return; }
|
||||
|
||||
var pageClass = current.$$route.pageClass || '';
|
||||
if (typeof pageClass != 'string') {
|
||||
pageClass = pageClass(Features);
|
||||
}
|
||||
|
||||
|
||||
$rootScope.pageClass = pageClass;
|
||||
$rootScope.newLayout = !!current.$$route.newLayout;
|
||||
$rootScope.fixFooter = !!current.$$route.fixFooter;
|
||||
|
||||
$anchorScroll();
|
||||
});
|
||||
|
||||
var initallyChecked = false;
|
||||
(<any>window).__isLoading = function() {
|
||||
if (!initallyChecked) {
|
||||
initallyChecked = true;
|
||||
return true;
|
||||
}
|
||||
return $http.pendingRequests.length > 0;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
interface QuayRunScope extends ng.IRootScopeService {
|
||||
currentPage: any;
|
||||
current: any;
|
||||
title: any;
|
||||
description: string,
|
||||
pageClass: any;
|
||||
newLayout: any;
|
||||
fixFooter: any;
|
||||
}
|
22
static/js/services/page/page.service.impl.spec.ts
Normal file
22
static/js/services/page/page.service.impl.spec.ts
Normal file
|
@ -0,0 +1,22 @@
|
|||
import { PageServiceImpl } from './page.service.impl';
|
||||
|
||||
|
||||
describe("Service: PageServiceImpl", () => {
|
||||
var pageServiceImpl: PageServiceImpl;
|
||||
|
||||
beforeEach(() => {
|
||||
pageServiceImpl = new PageServiceImpl();
|
||||
});
|
||||
|
||||
describe("create", () => {
|
||||
|
||||
});
|
||||
|
||||
describe("get", () => {
|
||||
|
||||
});
|
||||
|
||||
describe("$get", () => {
|
||||
|
||||
});
|
||||
});
|
45
static/js/services/page/page.service.impl.ts
Normal file
45
static/js/services/page/page.service.impl.ts
Normal file
|
@ -0,0 +1,45 @@
|
|||
import { Injectable } from 'angular-ts-decorators';
|
||||
import { PageService } from './page.service';
|
||||
|
||||
|
||||
@Injectable(PageService.name)
|
||||
export class PageServiceImpl implements ng.IServiceProvider {
|
||||
|
||||
private pages: any = {};
|
||||
|
||||
constructor() {
|
||||
|
||||
}
|
||||
|
||||
public create(pageName: string,
|
||||
templateName: string,
|
||||
controller?: any,
|
||||
flags: any = {},
|
||||
profiles: string[] = ['old-layout', 'layout']): void {
|
||||
for (var i = 0; i < profiles.length; ++i) {
|
||||
this.pages[profiles[i] + ':' + pageName] = {
|
||||
'name': pageName,
|
||||
'controller': controller,
|
||||
'templateName': templateName,
|
||||
'flags': flags
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public get(pageName: string, profiles: any[]): any[] | null {
|
||||
for (var i = 0; i < profiles.length; ++i) {
|
||||
var current = profiles[i];
|
||||
var key = current.id + ':' + pageName;
|
||||
var page = this.pages[key];
|
||||
if (page) {
|
||||
return [current, page];
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public $get(): PageService {
|
||||
return this;
|
||||
}
|
||||
}
|
32
static/js/services/page/page.service.ts
Normal file
32
static/js/services/page/page.service.ts
Normal file
|
@ -0,0 +1,32 @@
|
|||
/**
|
||||
* Manages the creation and retrieval of pages (route + controller)
|
||||
*/
|
||||
export abstract class PageService implements ng.IServiceProvider {
|
||||
|
||||
/**
|
||||
* Create a page.
|
||||
* @param pageName The name of the page.
|
||||
* @param templateName The file name of the template.
|
||||
* @param controller Controller for the page.
|
||||
* @param flags Additional flags passed to route provider.
|
||||
* @param profiles Available profiles.
|
||||
*/
|
||||
public abstract create(pageName: string,
|
||||
templateName: string,
|
||||
controller?: any,
|
||||
flags?: any,
|
||||
profiles?: string[]): void;
|
||||
|
||||
/**
|
||||
* Retrieve a registered page.
|
||||
* @param pageName The name of the page.
|
||||
* @param profiles Available profiles to search.
|
||||
*/
|
||||
public abstract get(pageName: string, profiles: any[]): any[] | null;
|
||||
|
||||
/**
|
||||
* Provide the service instance.
|
||||
* @return pageService The singleton service instance.
|
||||
*/
|
||||
public abstract $get(): PageService;
|
||||
}
|
|
@ -1,7 +1,9 @@
|
|||
import { ViewArray } from './view-array';
|
||||
import { Inject } from '../../decorators/inject/inject.decorator';
|
||||
import { Injectable } from 'angular-ts-decorators';
|
||||
|
||||
|
||||
@Injectable(ViewArray.name)
|
||||
export class ViewArrayImpl implements ViewArray {
|
||||
|
||||
public entries: any[];
|
||||
|
|
68
static/partials/trigger-setup.html
Normal file
68
static/partials/trigger-setup.html
Normal file
|
@ -0,0 +1,68 @@
|
|||
<div class="resource-view trigger-setup-element"
|
||||
resources="[repositoryResource, triggerResource]"
|
||||
error-message="'Build trigger not found'">
|
||||
<div class="page-content">
|
||||
<div class="cor-title">
|
||||
<span class="cor-title-link">
|
||||
<a class="back-link" href="/repository/{{ repository.namespace }}/{{ repository.name }}?tab=builds">
|
||||
<i class="fa fa-hdd-o" style="margin-right: 4px"></i>
|
||||
{{ repository.namespace }}/{{ repository.name }}
|
||||
</a>
|
||||
</span>
|
||||
<span class="cor-title-content">
|
||||
<i class="fa" ng-class="getTriggerIcon()"></i>
|
||||
Setup Build Trigger: {{ getTriggerId() }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="co-main-content-panel" ng-show="state != 'activated' && trigger.is_active">
|
||||
<div class="co-alert co-alert-info">
|
||||
Trigger has already been activated.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="co-main-content-panel" ng-show="state == 'activated' || !trigger.is_active">
|
||||
<!-- state = activated -->
|
||||
<div class="activated" ng-if="state == 'activated'">
|
||||
<div class="row">
|
||||
<div class="col-md-offset-3 col-md-6 col-sm-12 col-lg-6 content">
|
||||
<h3>Trigger has been successfully activated</h3>
|
||||
<div class="credentials" trigger="trigger"></div>
|
||||
<div class="button-bar">
|
||||
<a href="/repository/{{ repository.namespace }}/{{ repository.name }}?tab=builds">
|
||||
Return to {{ repository.namespace }}/{{ repository.name }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div> <!-- /state = activated -->
|
||||
|
||||
<!-- state = managing or activating -->
|
||||
<div ng-if="state == 'managing' || state == 'activating'"
|
||||
ng-class="{'activating': state == 'activating'}">
|
||||
<!-- Select the correct flow -->
|
||||
<div ng-switch on="trigger.service">
|
||||
<!-- Custom Git -->
|
||||
<div ng-switch-when="custom-git">
|
||||
<manage-trigger-custom-git
|
||||
trigger="trigger"
|
||||
activate-trigger="activateTrigger(config, pull_robot)"></manage-trigger-custom-git>
|
||||
</div> <!-- /custom-git -->
|
||||
|
||||
<!-- Hosted Git (GitHub, Gitlab, BitBucket) -->
|
||||
<div ng-switch-default>
|
||||
<manage-trigger-githost
|
||||
trigger="trigger"
|
||||
repository="repository"
|
||||
activate-trigger="activateTrigger(config, pull_robot)"></manage-trigger-githost>
|
||||
</div> <!-- /hosted -->
|
||||
</div> <!-- /ngSwitch -->
|
||||
|
||||
<div class="activating-message" ng-show="state == 'activating'">
|
||||
<div class="cor-loader-inline"></div><b>Completing setup of the build trigger</b>
|
||||
</div>
|
||||
</div> <!-- /state = managing -->
|
||||
|
||||
</div> <!-- /co-main-content-panel -->
|
||||
</div>
|
||||
</div>
|
Binary file not shown.
|
@ -1301,17 +1301,17 @@ class TestBuildTriggerSources831cPublicPublicrepo(ApiTestCase):
|
|||
ApiTestCase.setUp(self)
|
||||
self._set_url(BuildTriggerSources, trigger_uuid="831C", repository="public/publicrepo")
|
||||
|
||||
def test_get_anonymous(self):
|
||||
self._run_test('GET', 401, None, None)
|
||||
def test_post_anonymous(self):
|
||||
self._run_test('POST', 401, None, None)
|
||||
|
||||
def test_get_freshuser(self):
|
||||
self._run_test('GET', 403, 'freshuser', None)
|
||||
def test_post_freshuser(self):
|
||||
self._run_test('POST', 403, 'freshuser', None)
|
||||
|
||||
def test_get_reader(self):
|
||||
self._run_test('GET', 403, 'reader', None)
|
||||
def test_post_reader(self):
|
||||
self._run_test('POST', 403, 'reader', None)
|
||||
|
||||
def test_get_devtable(self):
|
||||
self._run_test('GET', 403, 'devtable', None)
|
||||
def test_post_devtable(self):
|
||||
self._run_test('POST', 403, 'devtable', dict(namespace="foo"))
|
||||
|
||||
|
||||
class TestBuildTriggerSources831cDevtableShared(ApiTestCase):
|
||||
|
@ -1319,17 +1319,17 @@ class TestBuildTriggerSources831cDevtableShared(ApiTestCase):
|
|||
ApiTestCase.setUp(self)
|
||||
self._set_url(BuildTriggerSources, trigger_uuid="831C", repository="devtable/shared")
|
||||
|
||||
def test_get_anonymous(self):
|
||||
self._run_test('GET', 401, None, None)
|
||||
def test_post_anonymous(self):
|
||||
self._run_test('POST', 401, None, None)
|
||||
|
||||
def test_get_freshuser(self):
|
||||
self._run_test('GET', 403, 'freshuser', None)
|
||||
def test_post_freshuser(self):
|
||||
self._run_test('POST', 403, 'freshuser', None)
|
||||
|
||||
def test_get_reader(self):
|
||||
self._run_test('GET', 403, 'reader', None)
|
||||
def test_post_reader(self):
|
||||
self._run_test('POST', 403, 'reader', None)
|
||||
|
||||
def test_get_devtable(self):
|
||||
self._run_test('GET', 404, 'devtable', None)
|
||||
def test_post_devtable(self):
|
||||
self._run_test('POST', 404, 'devtable', dict(namespace="foo"))
|
||||
|
||||
|
||||
class TestBuildTriggerSources831cBuynlargeOrgrepo(ApiTestCase):
|
||||
|
@ -1337,17 +1337,17 @@ class TestBuildTriggerSources831cBuynlargeOrgrepo(ApiTestCase):
|
|||
ApiTestCase.setUp(self)
|
||||
self._set_url(BuildTriggerSources, trigger_uuid="831C", repository="buynlarge/orgrepo")
|
||||
|
||||
def test_get_anonymous(self):
|
||||
self._run_test('GET', 401, None, None)
|
||||
def test_post_anonymous(self):
|
||||
self._run_test('POST', 401, None, None)
|
||||
|
||||
def test_get_freshuser(self):
|
||||
self._run_test('GET', 403, 'freshuser', None)
|
||||
def test_post_freshuser(self):
|
||||
self._run_test('POST', 403, 'freshuser', None)
|
||||
|
||||
def test_get_reader(self):
|
||||
self._run_test('GET', 403, 'reader', None)
|
||||
def test_post_reader(self):
|
||||
self._run_test('POST', 403, 'reader', None)
|
||||
|
||||
def test_get_devtable(self):
|
||||
self._run_test('GET', 404, 'devtable', None)
|
||||
def test_post_devtable(self):
|
||||
self._run_test('POST', 404, 'devtable', dict(namespace="foo"))
|
||||
|
||||
|
||||
class TestBuildTriggerSubdirs4i2yPublicPublicrepo(ApiTestCase):
|
||||
|
|
|
@ -40,7 +40,8 @@ from endpoints.api.robot import (UserRobotList, OrgRobot, OrgRobotList, UserRobo
|
|||
RegenerateUserRobot, RegenerateOrgRobot)
|
||||
from endpoints.api.trigger import (BuildTriggerActivate, BuildTriggerSources, BuildTriggerSubdirs,
|
||||
TriggerBuildList, ActivateBuildTrigger, BuildTrigger,
|
||||
BuildTriggerList, BuildTriggerAnalyze, BuildTriggerFieldValues)
|
||||
BuildTriggerList, BuildTriggerAnalyze, BuildTriggerFieldValues,
|
||||
BuildTriggerSourceNamespaces)
|
||||
from endpoints.api.repoemail import RepositoryAuthorizedEmail
|
||||
from endpoints.api.repositorynotification import (RepositoryNotification,
|
||||
RepositoryNotificationList,
|
||||
|
@ -3758,8 +3759,23 @@ class FakeBuildTrigger(BuildTriggerHandler):
|
|||
def service_name(cls):
|
||||
return 'fakeservice'
|
||||
|
||||
def list_build_sources(self):
|
||||
return [{'first': 'source'}, {'second': self.auth_token}]
|
||||
def list_build_source_namespaces(self):
|
||||
return [
|
||||
{'name': 'first', 'id': 'first'},
|
||||
{'name': 'second', 'id': 'second'},
|
||||
]
|
||||
|
||||
def list_build_sources_for_namespace(self, namespace):
|
||||
if namespace == "first":
|
||||
return [{
|
||||
'name': 'source',
|
||||
}]
|
||||
elif namespace == "second":
|
||||
return [{
|
||||
'name': self.auth_token,
|
||||
}]
|
||||
else:
|
||||
return []
|
||||
|
||||
def list_build_subdirs(self):
|
||||
return [self.auth_token, 'foo', 'bar', self.config['somevalue']]
|
||||
|
@ -3882,8 +3898,9 @@ class TestBuildTriggers(ApiTestCase):
|
|||
trigger_uuid=trigger.uuid),
|
||||
data={'config': trigger_config})
|
||||
|
||||
self.assertEquals('error', analyze_json['status'])
|
||||
self.assertEquals('Could not read the Dockerfile for the trigger', analyze_json['message'])
|
||||
self.assertEquals('warning', analyze_json['status'])
|
||||
self.assertEquals('Specified Dockerfile path for the trigger was not ' +
|
||||
'found on the main branch. This trigger may fail.', analyze_json['message'])
|
||||
|
||||
# Analyze the trigger's dockerfile: Second, missing FROM in dockerfile.
|
||||
trigger_config = {'dockerfile': 'MAINTAINER me'}
|
||||
|
@ -3943,10 +3960,9 @@ class TestBuildTriggers(ApiTestCase):
|
|||
trigger_uuid=trigger.uuid),
|
||||
data={'config': trigger_config})
|
||||
|
||||
self.assertEquals('analyzed', analyze_json['status'])
|
||||
self.assertEquals('requiresrobot', analyze_json['status'])
|
||||
self.assertEquals('devtable', analyze_json['namespace'])
|
||||
self.assertEquals('complex', analyze_json['name'])
|
||||
self.assertEquals(False, analyze_json['is_public'])
|
||||
self.assertEquals(ADMIN_ACCESS_USER + '+dtrobot', analyze_json['robots'][0]['name'])
|
||||
|
||||
|
||||
|
@ -3968,11 +3984,18 @@ class TestBuildTriggers(ApiTestCase):
|
|||
self.assertEquals(trigger.service.name, json['triggers'][0]['service'])
|
||||
self.assertEquals(False, json['triggers'][0]['is_active'])
|
||||
|
||||
# List the trigger's sources.
|
||||
source_json = self.getJsonResponse(BuildTriggerSources,
|
||||
# List the trigger's source namespaces.
|
||||
namespace_json = self.getJsonResponse(BuildTriggerSourceNamespaces,
|
||||
params=dict(repository=ADMIN_ACCESS_USER + '/simple',
|
||||
trigger_uuid=trigger.uuid))
|
||||
self.assertEquals([{'id': 'first', 'name': 'first'}, {'id': 'second', 'name': 'second'}], namespace_json['namespaces'])
|
||||
|
||||
|
||||
source_json = self.postJsonResponse(BuildTriggerSources,
|
||||
params=dict(repository=ADMIN_ACCESS_USER + '/simple',
|
||||
trigger_uuid=trigger.uuid))
|
||||
self.assertEquals([{'first': 'source'}, {'second': 'sometoken'}], source_json['sources'])
|
||||
trigger_uuid=trigger.uuid),
|
||||
data=dict(namespace='first'))
|
||||
self.assertEquals([{'name': 'source'}], source_json['sources'])
|
||||
|
||||
# List the trigger's subdirs.
|
||||
subdir_json = self.postJsonResponse(BuildTriggerSubdirs,
|
||||
|
@ -3980,7 +4003,7 @@ class TestBuildTriggers(ApiTestCase):
|
|||
trigger_uuid=trigger.uuid),
|
||||
data={'somevalue': 'meh'})
|
||||
|
||||
self.assertEquals({'status': 'success', 'subdir': ['sometoken', 'foo', 'bar', 'meh']},
|
||||
self.assertEquals({'status': 'success', 'subdir': ['/sometoken', '/foo', '/bar', '/meh']},
|
||||
subdir_json)
|
||||
|
||||
# Activate the trigger.
|
||||
|
|
Reference in a new issue