diff --git a/buildtrigger/basehandler.py b/buildtrigger/basehandler.py index 2555b09ed..b9f035dc0 100644 --- a/buildtrigger/basehandler.py +++ b/buildtrigger/basehandler.py @@ -1,7 +1,10 @@ +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 METADATA_SCHEMA = { 'type': 'object', @@ -18,7 +21,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 +89,7 @@ METADATA_SCHEMA = { } +@add_metaclass(ABCMeta) class BuildTriggerHandler(object): def __init__(self, trigger, override_config=None): self.trigger = trigger @@ -96,72 +100,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): diff --git a/buildtrigger/bitbuckethandler.py b/buildtrigger/bitbuckethandler.py index 364801e42..f96a38cee 100644 --- a/buildtrigger/bitbuckethandler.py +++ b/buildtrigger/bitbuckethandler.py @@ -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, @@ -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'] @@ -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,40 @@ 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'], + 'score': 0, } - namespaces[owner]['repos'].append(owner + '/' + repo['slug']) + return list(namespaces.values()) - 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) + + return [repo_view(repo) for repo in data if repo['owner'] == namespace] def list_build_subdirs(self): config = self.config @@ -431,7 +454,7 @@ class BitbucketBuildTrigger(BuildTriggerHandler): files = set([f['path'] for f in data['files']]) if 'Dockerfile' in files: - return ['/'] + return [''] return [] diff --git a/buildtrigger/customhandler.py b/buildtrigger/customhandler.py index b3b1b01ba..7541995a5 100644 --- a/buildtrigger/customhandler.py +++ b/buildtrigger/customhandler.py @@ -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 diff --git a/buildtrigger/githubhandler.py b/buildtrigger/githubhandler.py index aaaccd4d5..fa31f21d1 100644 --- a/buildtrigger/githubhandler.py +++ b/buildtrigger/githubhandler.py @@ -2,14 +2,15 @@ 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 from buildtrigger.triggerutil import (RepositoryReadException, TriggerActivationException, TriggerDeactivationException, TriggerStartException, EmptyRepositoryException, ValidationRequestException, @@ -273,55 +274,57 @@ class GithubBuildTrigger(BuildTriggerHandler): 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, + '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 list(namespaces.values()) - 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: + return [repo_view(repo) for repo in usr.get_repos() if repo.owner.login == namespace] + + org = gh_client.get_organization(namespace) + if org is None: + return [] + + return [repo_view(repo) for repo in org.get_repos(type='member')] - entries = list(namespaces.values()) - entries.sort(key=lambda e: e['info']['name']) - return entries @_catch_ssl_errors def list_build_subdirs(self): @@ -357,19 +360,17 @@ class GithubBuildTrigger(BuildTriggerHandler): 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) - raise RepositoryReadException(message) + 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): @@ -535,7 +536,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. diff --git a/buildtrigger/gitlabhandler.py b/buildtrigger/gitlabhandler.py index 448fff4fc..19d701ca0 100644 --- a/buildtrigger/gitlabhandler.py +++ b/buildtrigger/gitlabhandler.py @@ -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 @@ -70,6 +74,17 @@ GITLAB_WEBHOOK_PAYLOAD_SCHEMA = { '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 +97,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 +259,57 @@ 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': 0, } - namespaces[owner]['repos'].append(repo['path_with_namespace']) + return list(namespaces.values()) - 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) + return [repo_view(repo) for repo in repositories if repo['namespace']['path'] == namespace] @_catch_timeouts def list_build_subdirs(self): @@ -280,7 +338,7 @@ class GitLabBuildTrigger(BuildTriggerHandler): for node in repo_tree: if node['name'] == 'Dockerfile': - return ['/'] + return [''] return [] diff --git a/data/model/user.py b/data/model/user.py index 28ad75174..6f2f14f64 100644 --- a/data/model/user.py +++ b/data/model/user.py @@ -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. diff --git a/endpoints/api/trigger.py b/endpoints/api/trigger.py index c03e9fbd9..3dec076b6 100644 --- a/endpoints/api/trigger.py +++ b/endpoints/api/trigger.py @@ -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//trigger//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) diff --git a/endpoints/bitbuckettrigger.py b/endpoints/bitbuckettrigger.py index 8c8052235..ba2e5b215 100644 --- a/endpoints/bitbuckettrigger.py +++ b/endpoints/bitbuckettrigger.py @@ -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) diff --git a/endpoints/githubtrigger.py b/endpoints/githubtrigger.py index 1f0d9ca90..43b11e14d 100644 --- a/endpoints/githubtrigger.py +++ b/endpoints/githubtrigger.py @@ -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) diff --git a/endpoints/gitlabtrigger.py b/endpoints/gitlabtrigger.py index 4f51a2bdc..2626a068d 100644 --- a/endpoints/gitlabtrigger.py +++ b/endpoints/gitlabtrigger.py @@ -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) diff --git a/endpoints/web.py b/endpoints/web.py index f3a6f7ce7..a0ad23755 100644 --- a/endpoints/web.py +++ b/endpoints/web.py @@ -183,6 +183,13 @@ def confirm_invite(): def repository(path): return index('') + +@web.route('/repository//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) diff --git a/static/css/core-ui.css b/static/css/core-ui.css index cb7525cc9..89a82ad7d 100644 --- a/static/css/core-ui.css +++ b/static/css/core-ui.css @@ -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; } diff --git a/static/css/directives/ui/linear-workflow.css b/static/css/directives/ui/linear-workflow.css new file mode 100644 index 000000000..b55d10a63 --- /dev/null +++ b/static/css/directives/ui/linear-workflow.css @@ -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; +} diff --git a/static/css/directives/ui/manage-trigger-control.css b/static/css/directives/ui/manage-trigger-control.css new file mode 100644 index 000000000..708852881 --- /dev/null +++ b/static/css/directives/ui/manage-trigger-control.css @@ -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; +} diff --git a/static/css/directives/ui/regex-match-view.css b/static/css/directives/ui/regex-match-view.css new file mode 100644 index 000000000..dd6796949 --- /dev/null +++ b/static/css/directives/ui/regex-match-view.css @@ -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; +} + diff --git a/static/css/directives/ui/setup-trigger-dialog.css b/static/css/directives/ui/setup-trigger-dialog.css deleted file mode 100644 index 482a687bf..000000000 --- a/static/css/directives/ui/setup-trigger-dialog.css +++ /dev/null @@ -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; -} \ No newline at end of file diff --git a/static/css/directives/ui/step-view-step.css b/static/css/directives/ui/step-view-step.css deleted file mode 100644 index 16f31abda..000000000 --- a/static/css/directives/ui/step-view-step.css +++ /dev/null @@ -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; -} \ No newline at end of file diff --git a/static/css/pages/trigger-setup.css b/static/css/pages/trigger-setup.css new file mode 100644 index 000000000..0539d309f --- /dev/null +++ b/static/css/pages/trigger-setup.css @@ -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; +} diff --git a/static/directives/credentials.html b/static/directives/credentials.html index 9580f4ed1..1879f87fe 100644 --- a/static/directives/credentials.html +++ b/static/directives/credentials.html @@ -4,9 +4,7 @@
-

- {{ credential.name }}: -

-

+ {{ credential.name }}: +
diff --git a/static/directives/dockerfile-path-select.html b/static/directives/dockerfile-path-select.html new file mode 100644 index 000000000..699336bb7 --- /dev/null +++ b/static/directives/dockerfile-path-select.html @@ -0,0 +1,32 @@ +
+ + +
+
+ Path entered for folder containing Dockerfile is invalid: Must start with a '/'. +
+
+
\ No newline at end of file diff --git a/static/directives/linear-workflow-section.html b/static/directives/linear-workflow-section.html new file mode 100644 index 000000000..7eeafcf63 --- /dev/null +++ b/static/directives/linear-workflow-section.html @@ -0,0 +1,6 @@ +
+
+
+ +
\ No newline at end of file diff --git a/static/directives/linear-workflow.html b/static/directives/linear-workflow.html new file mode 100644 index 000000000..b1f366f93 --- /dev/null +++ b/static/directives/linear-workflow.html @@ -0,0 +1,31 @@ +
+ +
+ +
+ + + + + +
+ + + + +
+ Next: +
    +
  • + {{ section.title }} +
  • +
+
+
+
+
\ No newline at end of file diff --git a/static/directives/manage-trigger-custom-git.html b/static/directives/manage-trigger-custom-git.html new file mode 100644 index 000000000..96fb0819a --- /dev/null +++ b/static/directives/manage-trigger-custom-git.html @@ -0,0 +1,43 @@ +
+
+ +
+ +
+

Enter repository

+ + Please enter the HTTP or SSH style URL used to clone your git repository: + + +
+ +
+ + +
+ +
+

Select build context directory

+ Please select the build context directory under the git repository: + +
+ + +
+
\ No newline at end of file diff --git a/static/directives/manage-trigger-githost.html b/static/directives/manage-trigger-githost.html new file mode 100644 index 000000000..4912b5c2f --- /dev/null +++ b/static/directives/manage-trigger-githost.html @@ -0,0 +1,330 @@ +
+
+ + +
+
+

Select {{ namespaceTitle }}

+ + Please select the {{ namespaceTitle }} under which the repository lives + + +
+
+ + +
+
+ + + + + + + + + + + + + +
+ {{ namespaceTitle }} +
+ + + + {{ namespace.id }} +
+
+
No matching {{ namespaceTitle }} found.
+
Try expanding your filtering terms.
+
+
+ +
+ Retrieving {{ namespaceTitle }}s +
+ +
+ + +
+ +
+

Select Repository

+ + Select a repository in + + {{ local.selectedNamespace.id }} + + +
+
+ + +
+ +
+
+
+ + + + + + + + + + + + + + + +
+ Repository Name + + Last Updated +
+ + + + + + + {{ repository.name }} + + +
+
+
No matching repositories found.
+
Try expanding your filtering terms.
+
+
+ +
+ Retrieving repositories +
+ + +
+ + +
+
+

Configure Trigger

+ + Configure trigger options for + + {{ local.selectedNamespace.id }}/{{ local.selectedRepository.name }} + + +
+ +
+
+ +
+
+ +
+ Retrieving repository refs +
+ +
+ + +
+
+
+ {{ local.dockerfileLocations.message }} +
+
+ +
+

Select Dockerfile

+ + Please select the location of the Dockerfile to build when this trigger is invoked + + +
+
+ +
+ Retrieving Dockerfile locations +
+ +
+ + +
+ +
+

Verification Error

+ + There was an error when verifying the state of + {{ local.selectedNamespace.id }}/{{ local.selectedRepository.name }} + + + {{ local.triggerAnalysis.message }} +
+ + +
+

Verification Warning

+ {{ local.triggerAnalysis.message }} +
+ + +
+

Ready to go!

+ Click "Create Trigger" to complete setup of this build trigger +
+ + +
+

Robot Account Required

+

The selected Dockerfile in the selected repository depends upon a private base image

+

A robot account with access to the base image is required to setup this trigger, but you are not the administrator of this namespace.

+

Administrative access is required to continue to ensure security of the robot credentials.

+
+ + +
+

Select Robot Account

+ + The selected Dockerfile in the selected repository depends upon a private base image. Select a robot account with access: + + +
+
+ + +
+
+ + + + + + + + + + + + + +
+ Robot Account + + Has Read Access +
+ + + + + Can Read + Read access will be added if selected +
+
+
No matching robot accounts found.
+
Try expanding your filtering terms.
+
+
+ + +
+ +
\ No newline at end of file diff --git a/static/directives/regex-match-view.html b/static/directives/regex-match-view.html new file mode 100644 index 000000000..da9316838 --- /dev/null +++ b/static/directives/regex-match-view.html @@ -0,0 +1,29 @@ +
+
+ Invalid Regular Expression! +
+
+ + + + + + + + + +
Matching: +
    +
  • + {{ item.title }} +
  • +
+
Not Matching: +
    +
  • + {{ item.title }} +
  • +
+
+
+
\ No newline at end of file diff --git a/static/directives/repo-view/repo-panel-builds.html b/static/directives/repo-view/repo-panel-builds.html index a38d783b4..327846002 100644 --- a/static/directives/repo-view/repo-panel-builds.html +++ b/static/directives/repo-view/repo-panel-builds.html @@ -126,10 +126,8 @@ - - Trigger Setup in progress: - Resume | - Cancel + This build trigger has not had its setup completed: + Delete Trigger @@ -185,14 +183,6 @@ build-started="handleBuildStarted(build)">
- -
-
-
diff --git a/static/directives/setup-trigger-dialog.html b/static/directives/setup-trigger-dialog.html deleted file mode 100644 index ad4309aaa..000000000 --- a/static/directives/setup-trigger-dialog.html +++ /dev/null @@ -1,133 +0,0 @@ -
- - -
diff --git a/static/directives/step-view-step.html b/static/directives/step-view-step.html deleted file mode 100644 index b7986e9dd..000000000 --- a/static/directives/step-view-step.html +++ /dev/null @@ -1,9 +0,0 @@ -
-
-
-
-
- - {{ loadMessage }} -
-
diff --git a/static/directives/step-view.html b/static/directives/step-view.html deleted file mode 100644 index 8d3124291..000000000 --- a/static/directives/step-view.html +++ /dev/null @@ -1,3 +0,0 @@ -
-
-
diff --git a/static/directives/trigger-setup-custom.html b/static/directives/trigger-setup-custom.html deleted file mode 100644 index 59227580a..000000000 --- a/static/directives/trigger-setup-custom.html +++ /dev/null @@ -1,40 +0,0 @@ -
-
- - - - - - - - - -
Repository{{ state.build_source }}
Dockerfile Location: -
- {{ state.subdir || '/' }} -
-
-
- - -
- - - -
-
Please enter an HTTP or SSH style URL used to clone your git repository:
- -
- - -
-
Dockerfile Location:
- -
-
-
diff --git a/static/directives/trigger-setup-githost.html b/static/directives/trigger-setup-githost.html deleted file mode 100644 index 1aeb6069e..000000000 --- a/static/directives/trigger-setup-githost.html +++ /dev/null @@ -1,201 +0,0 @@ -
- -
- - - - - - - - - - - - - - - -
- Repository: - -
- - - {{ state.currentRepo.repo }} -
-
- Branches and Tags: - -
- (Build All) - Regular Expression: {{ state.branchTagFilter }} -
-
- Dockerfile Location: - -
- {{ state.currentLocation || '(Repository Root)' }} -
-
-
- - - -
- -
-
Please choose the repository that will trigger the build:
- -
- - -
- -
Please choose the branches and tags to which this trigger will apply:
-
-
- - -
- -
-
-
-
- - -
-
-
- - -
-
- Branches: -
    -
  • - - {{ branchName }} - -
  • -
- ... -
-
- Tags: -
    -
  • - - {{ tagName }} - -
  • -
- ... -
-
- Warning: No branches found -
-
-
-
-
- - -
- -
Dockerfile Location:
- - -
-
- {{ locationError }} -
-
- Warning: No Dockerfiles were found in {{ state.currentRepo.repo }} -
-
- Note: The folder does not currently exist or contain a Dockerfile -
-
- -
-
diff --git a/static/directives/trigger/custom-git/trigger-description.html b/static/directives/trigger/custom-git/trigger-description.html index 2b41418ac..3d405f127 100644 --- a/static/directives/trigger/custom-git/trigger-description.html +++ b/static/directives/trigger/custom-git/trigger-description.html @@ -1,4 +1,4 @@ - Push to {{ trigger.config.build_source }} + Push to repository {{ trigger.config.build_source }} diff --git a/static/js/directives/repo-view/repo-panel-builds.js b/static/js/directives/repo-view/repo-panel-builds.js index 9bc017631..9ff834c97 100644 --- a/static/js/directives/repo-view/repo-panel-builds.js +++ b/static/js/directives/repo-view/repo-panel-builds.js @@ -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; } diff --git a/static/js/directives/ui/dockerfile-path-select.js b/static/js/directives/ui/dockerfile-path-select.js new file mode 100644 index 000000000..b968e20f8 --- /dev/null +++ b/static/js/directives/ui/dockerfile-path-select.js @@ -0,0 +1,54 @@ +/** + * An element which displays a list of selectable paths containing Dockerfiles. + */ +angular.module('quay').directive('dockerfilePathSelect', function () { + var directiveDefinitionObject = { + priority: 0, + templateUrl: '/static/directives/dockerfile-path-select.html', + replace: false, + transclude: true, + restrict: 'C', + scope: { + 'currentPath': '=currentPath', + 'isValidPath': '=?isValidPath', + 'paths': '=paths', + 'supportsFullListing': '=supportsFullListing' + }, + controller: function($scope, $element) { + $scope.isUnknownPath = true; + $scope.selectedPath = null; + + var checkPath = function() { + $scope.isUnknownPath = false; + $scope.isValidPath = false; + + var path = $scope.currentPath || ''; + if (path.length == 0 || path[0] != '/') { + return; + } + + $scope.isValidPath = true; + + if (!$scope.paths) { + return; + } + + $scope.isUnknownPath = $scope.supportsFullListing && $scope.paths.indexOf(path) < 0; + }; + + $scope.setPath = function(path) { + $scope.currentPath = path; + $scope.selectedPath = null; + }; + + $scope.setSelectedPath = function(path) { + $scope.currentPath = path; + $scope.selectedPath = path; + }; + + $scope.$watch('currentPath', checkPath); + $scope.$watch('paths', checkPath); + } + }; + return directiveDefinitionObject; +}); \ No newline at end of file diff --git a/static/js/directives/ui/dropdown-select.js b/static/js/directives/ui/dropdown-select.js index ff84b162b..e6cd0e289 100644 --- a/static/js/directives/ui/dropdown-select.js +++ b/static/js/directives/ui/dropdown-select.js @@ -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) { diff --git a/static/js/directives/ui/linear-workflow.js b/static/js/directives/ui/linear-workflow.js new file mode 100644 index 000000000..1f24adeb5 --- /dev/null +++ b/static/js/directives/ui/linear-workflow.js @@ -0,0 +1,141 @@ +/** + * An element which displays a linear workflow of sections, each completed in order before the next + * step is made visible. + */ +angular.module('quay').directive('linearWorkflow', function () { + var directiveDefinitionObject = { + priority: 0, + templateUrl: '/static/directives/linear-workflow.html', + replace: false, + transclude: true, + restrict: 'C', + scope: { + 'workflowState': '=?workflowState', + 'workflowComplete': '&workflowComplete', + 'doneTitle': '@doneTitle' + }, + controller: function($scope, $element, $timeout) { + $scope.sections = []; + + $scope.nextSection = function() { + if (!$scope.currentSection.valid) { return; } + + var currentIndex = $scope.currentSection.index; + if (currentIndex + 1 >= $scope.sections.length) { + $scope.workflowComplete(); + return; + } + + $scope.workflowState = $scope.sections[currentIndex + 1].id; + }; + + this.registerSection = function(sectionScope, sectionElement) { + // Add the section to the list. + var sectionInfo = { + 'index': $scope.sections.length, + 'id': sectionScope.sectionId, + 'title': sectionScope.sectionTitle, + 'scope': sectionScope, + 'element': sectionElement + }; + + $scope.sections.push(sectionInfo); + + // Add a watch on the `sectionValid` value on the section itself. If/when this value + // changes, we copy it over to the sectionInfo, so that the overall workflow can watch + // the change. + sectionScope.$watch('sectionValid', function(isValid) { + sectionInfo['valid'] = isValid; + if (!isValid) { + // Reset the sections back to this section. + updateState(); + } + }); + + // Bind the `submitSection` callback to move to the next section when the user hits + // enter (which calls this method on the scope via an ng-submit set on a wrapping + //
tag). + sectionScope.submitSection = function() { + $scope.nextSection(); + }; + + // Update the state of the workflow to account for the new section. + updateState(); + }; + + var updateState = function() { + // Find the furthest state we can show. + var foundIndex = 0; + var maxValidIndex = -1; + + $scope.sections.forEach(function(section, index) { + if (section.id == $scope.workflowState) { + foundIndex = index; + } + + if (maxValidIndex == index - 1 && section.valid) { + maxValidIndex = index; + } + }); + + var minSectionIndex = Math.min(maxValidIndex + 1, foundIndex); + $scope.sections.forEach(function(section, index) { + section.scope.sectionVisible = index <= minSectionIndex; + section.scope.isCurrentSection = false; + }); + + $scope.workflowState = null; + if (minSectionIndex >= 0 && minSectionIndex < $scope.sections.length) { + $scope.currentSection = $scope.sections[minSectionIndex]; + $scope.workflowState = $scope.currentSection.id; + $scope.currentSection.scope.isCurrentSection = true; + + // Focus to the first input (if any) in the section. + $timeout(function() { + var inputs = $scope.currentSection.element.find('input'); + if (inputs.length == 1) { + inputs.focus(); + } + }, 10); + } + }; + + $scope.$watch('workflowState', updateState); + } + }; + return directiveDefinitionObject; +}); + +/** + * An element which displays a single section in a linear workflow. + */ +angular.module('quay').directive('linearWorkflowSection', function () { + var directiveDefinitionObject = { + priority: 0, + templateUrl: '/static/directives/linear-workflow-section.html', + replace: false, + transclude: true, + restrict: 'C', + require: '^linearWorkflow', + scope: { + 'sectionId': '@sectionId', + 'sectionTitle': '@sectionTitle', + 'sectionValid': '=?sectionValid', + }, + + link: function($scope, $element, $attrs, $ctrl) { + $ctrl.registerSection($scope, $element); + }, + + controller: function($scope, $element) { + $scope.$watch('sectionVisible', function(visible) { + if (visible) { + $element.show(); + } else { + $element.hide(); + } + }); + } + }; + return directiveDefinitionObject; +}); \ No newline at end of file diff --git a/static/js/directives/ui/manage-trigger-custom-git.js b/static/js/directives/ui/manage-trigger-custom-git.js new file mode 100644 index 000000000..e039e11c0 --- /dev/null +++ b/static/js/directives/ui/manage-trigger-custom-git.js @@ -0,0 +1,27 @@ +/** + * An element which displays the setup and management workflow for a custom git trigger. + */ +angular.module('quay').directive('manageTriggerCustomGit', function () { + var directiveDefinitionObject = { + priority: 0, + templateUrl: '/static/directives/manage-trigger-custom-git.html', + replace: false, + transclude: true, + restrict: 'C', + scope: { + 'trigger': '=trigger', + 'activateTrigger': '&activateTrigger' + }, + controller: function($scope, $element) { + $scope.config = {}; + $scope.currentState = null; + + $scope.$watch('trigger', function(trigger) { + if (trigger) { + $scope.config = trigger['config'] || {}; + } + }); + } + }; + return directiveDefinitionObject; +}); \ No newline at end of file diff --git a/static/js/directives/ui/manage-trigger-githost.js b/static/js/directives/ui/manage-trigger-githost.js new file mode 100644 index 000000000..c63c86199 --- /dev/null +++ b/static/js/directives/ui/manage-trigger-githost.js @@ -0,0 +1,306 @@ +/** + * An element which displays the setup and management workflow for a normal SCM git trigger. + */ +angular.module('quay').directive('manageTriggerGithost', function () { + var directiveDefinitionObject = { + priority: 0, + templateUrl: '/static/directives/manage-trigger-githost.html', + replace: false, + transclude: true, + restrict: 'C', + scope: { + 'repository': '=repository', + 'trigger': '=trigger', + + 'activateTrigger': '&activateTrigger' + }, + controller: function($scope, $element, ApiService, TableService, TriggerService, RolesService) { + $scope.TableService = TableService; + + $scope.config = {}; + $scope.local = {}; + $scope.currentState = null; + + $scope.namespacesPerPage = 10; + $scope.repositoriesPerPage = 10; + $scope.robotsPerPage = 10; + + $scope.local.namespaceOptions = { + 'filter': '', + 'predicate': 'score', + 'reverse': false, + 'page': 0 + }; + + $scope.local.repositoryOptions = { + 'filter': '', + 'predicate': 'last_updated', + 'reverse': false, + 'page': 0, + 'hideStale': true + }; + + $scope.local.robotOptions = { + 'filter': '', + 'predicate': 'can_read', + 'reverse': false, + 'page': 0 + }; + + $scope.getTriggerIcon = function() { + return TriggerService.getIcon($scope.trigger.service); + }; + + $scope.createTrigger = function() { + var config = { + 'build_source': $scope.local.selectedRepository.full_name, + 'subdir': $scope.local.dockerfilePath.substr(1) // Remove starting / + }; + + if ($scope.local.triggerOptions.hasBranchTagFilter && + $scope.local.triggerOptions.branchTagFilter) { + config['branchtag_regex'] = $scope.local.triggerOptions.branchTagFilter; + } + + var activate = function() { + $scope.activateTrigger({'config': config, 'pull_robot': $scope.local.robotAccount}); + }; + + if ($scope.local.robotAccount) { + if ($scope.local.robotAccount.can_read) { + activate(); + } else { + // Add read permission onto the base repository for the robot and then activate the + // trigger. + var robot_name = $scope.local.robotAccount.name; + RolesService.setRepositoryRole($scope.repository, 'read', 'robot', robot_name, activate); + } + } else { + activate(); + } + }; + + var buildOrderedNamespaces = function() { + if (!$scope.local.namespaces) { + return; + } + + var namespaces = $scope.local.namespaces || []; + $scope.local.orderedNamespaces = TableService.buildOrderedItems(namespaces, + $scope.local.namespaceOptions, + ['id'], + ['score']) + + $scope.local.maxScore = 0; + namespaces.forEach(function(namespace) { + $scope.local.maxScore = Math.max(namespace.score, $scope.local.maxScore); + }); + }; + + var loadNamespaces = function() { + $scope.local.namespaces = null; + $scope.local.selectedNamespace = null; + $scope.local.orderedNamespaces = null; + + $scope.local.selectedRepository = null; + $scope.local.orderedRepositories = null; + + var params = { + 'repository': $scope.repository.namespace + '/' + $scope.repository.name, + 'trigger_uuid': $scope.trigger.id + }; + + ApiService.listTriggerBuildSourceNamespaces(null, params).then(function(resp) { + $scope.local.namespaces = resp['namespaces']; + $scope.local.repositories = null; + buildOrderedNamespaces(); + }, ApiService.errorDisplay('Could not retrieve the list of ' + $scope.namespaceTitle)) + }; + + var buildOrderedRepositories = function() { + if (!$scope.local.repositories) { + return; + } + + var repositories = $scope.local.repositories || []; + repositories.forEach(function(repository) { + repository['last_updated_datetime'] = new Date(repository['last_updated'] * 1000); + }); + + if ($scope.local.repositoryOptions.hideStale) { + var existingRepositories = repositories; + + repositories = repositories.filter(function(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; + } + } + + $scope.local.orderedRepositories = TableService.buildOrderedItems(repositories, + $scope.local.repositoryOptions, + ['name', 'description'], + []); + }; + + var loadRepositories = function(namespace) { + $scope.local.repositories = null; + $scope.local.selectedRepository = null; + $scope.local.repositoryRefs = null; + $scope.local.triggerOptions = { + 'hasBranchTagFilter': false + }; + + $scope.local.orderedRepositories = null; + + var params = { + 'repository': $scope.repository.namespace + '/' + $scope.repository.name, + 'trigger_uuid': $scope.trigger.id + }; + + var data = { + 'namespace': namespace.id + }; + + ApiService.listTriggerBuildSources(data, params).then(function(resp) { + if (namespace == $scope.local.selectedNamespace) { + $scope.local.repositories = resp['sources']; + buildOrderedRepositories(); + } + }, ApiService.errorDisplay('Could not retrieve repositories')); + }; + + var loadRepositoryRefs = function(repository) { + $scope.local.repositoryRefs = null; + $scope.local.triggerOptions = { + 'hasBranchTagFilter': false + }; + + var params = { + 'repository': $scope.repository.namespace + '/' + $scope.repository.name, + 'trigger_uuid': $scope.trigger.id, + 'field_name': 'refs' + }; + + var config = { + 'build_source': repository.full_name + }; + + ApiService.listTriggerFieldValues(config, params).then(function(resp) { + if (repository == $scope.local.selectedRepository) { + $scope.local.repositoryRefs = resp['values']; + $scope.local.repositoryFullRefs = resp['values'].map(function(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 + }; + }); + } + }, ApiService.errorDisplay('Could not retrieve repository refs')); + }; + + var loadDockerfileLocations = function(repository) { + $scope.local.dockerfilePath = null; + + var params = { + 'repository': $scope.repository.namespace + '/' + $scope.repository.name, + 'trigger_uuid': $scope.trigger.id + }; + + var config = { + 'build_source': repository.full_name + }; + + ApiService.listBuildTriggerSubdirs(config, params).then(function(resp) { + if (repository == $scope.local.selectedRepository) { + $scope.local.dockerfileLocations = resp; + } + }, ApiService.errorDisplay('Could not retrieve Dockerfile locations')); + }; + + var buildOrderedRobotAccounts = function() { + if (!$scope.local.triggerAnalysis || !$scope.local.triggerAnalysis.robots) { + return; + } + + var robots = $scope.local.triggerAnalysis.robots; + $scope.local.orderedRobotAccounts = TableService.buildOrderedItems(robots, + $scope.local.robotOptions, + ['name'], + []); + }; + + var checkDockerfilePath = function(repository, path) { + $scope.local.triggerAnalysis = null; + $scope.local.robotAccount = null; + + var params = { + 'repository': $scope.repository.namespace + '/' + $scope.repository.name, + 'trigger_uuid': $scope.trigger.id + }; + + var config = { + 'build_source': repository.full_name, + 'subdir': path.substr(1) + }; + + var data = { + 'config': config + }; + + ApiService.analyzeBuildTrigger(data, params).then(function(resp) { + $scope.local.triggerAnalysis = resp; + buildOrderedRobotAccounts(); + }, ApiService.errorDisplay('Could not analyze trigger')); + }; + + $scope.$watch('trigger', function(trigger) { + if (trigger && $scope.repository) { + $scope.config = trigger['config'] || {}; + $scope.namespaceTitle = 'organization'; + $scope.local.selectedNamespace = null; + loadNamespaces(); + } + }); + + $scope.$watch('local.selectedNamespace', function(namespace) { + if (namespace) { + loadRepositories(namespace); + } + }); + + $scope.$watch('local.selectedRepository', function(repository) { + if (repository) { + loadRepositoryRefs(repository); + loadDockerfileLocations(repository); + } + }); + + $scope.$watch('local.dockerfilePath', function(path) { + if (path && $scope.local.selectedRepository) { + checkDockerfilePath($scope.local.selectedRepository, path); + } + }); + + $scope.$watch('local.namespaceOptions.predicate', buildOrderedNamespaces); + $scope.$watch('local.namespaceOptions.reverse', buildOrderedNamespaces); + $scope.$watch('local.namespaceOptions.filter', buildOrderedNamespaces); + + $scope.$watch('local.repositoryOptions.predicate', buildOrderedRepositories); + $scope.$watch('local.repositoryOptions.reverse', buildOrderedRepositories); + $scope.$watch('local.repositoryOptions.filter', buildOrderedRepositories); + $scope.$watch('local.repositoryOptions.hideStale', buildOrderedRepositories); + + $scope.$watch('local.robotOptions.predicate', buildOrderedRobotAccounts); + $scope.$watch('local.robotOptions.reverse', buildOrderedRobotAccounts); + $scope.$watch('local.robotOptions.filter', buildOrderedRobotAccounts); + } + }; + return directiveDefinitionObject; +}); \ No newline at end of file diff --git a/static/js/directives/ui/regex-match-view.js b/static/js/directives/ui/regex-match-view.js new file mode 100644 index 000000000..6bd2380a5 --- /dev/null +++ b/static/js/directives/ui/regex-match-view.js @@ -0,0 +1,36 @@ +/** + * An element which displays the matches and non-matches for a regular expression against a set of + * items. + */ +angular.module('quay').directive('regexMatchView', function () { + var directiveDefinitionObject = { + priority: 0, + templateUrl: '/static/directives/regex-match-view.html', + replace: false, + transclude: true, + restrict: 'C', + scope: { + 'regex': '=regex', + 'items': '=items' + }, + controller: function($scope, $element) { + $scope.filterMatches = function(regexstr, items, shouldMatch) { + regexstr = regexstr || '.+'; + + try { + var regex = new RegExp(regexstr); + } catch (ex) { + return null; + } + + return items.filter(function(item) { + var value = item['value']; + var m = value.match(regex); + var matches = !!(m && m[0].length == value.length); + return matches == shouldMatch; + }); + }; + } + }; + return directiveDefinitionObject; +}); \ No newline at end of file diff --git a/static/js/directives/ui/step-view.js b/static/js/directives/ui/step-view.js deleted file mode 100644 index 70583fa1e..000000000 --- a/static/js/directives/ui/step-view.js +++ /dev/null @@ -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; -}); \ No newline at end of file diff --git a/static/js/directives/ui/trigger-setup-custom.js b/static/js/directives/ui/trigger-setup-custom.js deleted file mode 100644 index b14899efd..000000000 --- a/static/js/directives/ui/trigger-setup-custom.js +++ /dev/null @@ -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; -}); diff --git a/static/js/directives/ui/trigger-setup-githost.js b/static/js/directives/ui/trigger-setup-githost.js deleted file mode 100644 index 03be87852..000000000 --- a/static/js/directives/ui/trigger-setup-githost.js +++ /dev/null @@ -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; -}); \ No newline at end of file diff --git a/static/js/pages/trigger-setup.js b/static/js/pages/trigger-setup.js new file mode 100644 index 000000000..84c2ae1bc --- /dev/null +++ b/static/js/pages/trigger-setup.js @@ -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]; + }; + } +}()); \ No newline at end of file diff --git a/static/js/quay.routes.ts b/static/js/quay.routes.ts index 628c77dbd..1714c51ba 100644 --- a/static/js/quay.routes.ts +++ b/static/js/quay.routes.ts @@ -43,6 +43,9 @@ export function routeConfig( // 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') diff --git a/static/partials/trigger-setup.html b/static/partials/trigger-setup.html new file mode 100644 index 000000000..6ccb87bda --- /dev/null +++ b/static/partials/trigger-setup.html @@ -0,0 +1,65 @@ +
+
+
+ + + + {{ repository.namespace }}/{{ repository.name }} + + + + + Setup Build Trigger: {{ getTriggerId() }} + +
+ +
+
+ Trigger has already been activated. +
+
+ +
+ +
+
+
+

Trigger has been successfully activated

+
+ +
+
+
+ + +
+ +
+ +
+
+
+ + +
+
+
+
+ +
+
Completing setup of the build trigger +
+
+ +
+
+
diff --git a/test/test_api_security.py b/test/test_api_security.py index 62825709e..66d45e718 100644 --- a/test/test_api_security.py +++ b/test/test_api_security.py @@ -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): diff --git a/test/test_api_usage.py b/test/test_api_usage.py index 277410234..eead6487f 100644 --- a/test/test_api_usage.py +++ b/test/test_api_usage.py @@ -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.