diff --git a/buildtrigger/basehandler.py b/buildtrigger/basehandler.py index 2555b09ed..f8ed97563 100644 --- a/buildtrigger/basehandler.py +++ b/buildtrigger/basehandler.py @@ -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 diff --git a/buildtrigger/bitbuckethandler.py b/buildtrigger/bitbuckethandler.py index 364801e42..02e6b228f 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, @@ -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'] diff --git a/buildtrigger/customhandler.py b/buildtrigger/customhandler.py index b3b1b01ba..193445ee2 100644 --- a/buildtrigger/customhandler.py +++ b/buildtrigger/customhandler.py @@ -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 diff --git a/buildtrigger/githubhandler.py b/buildtrigger/githubhandler.py index aaaccd4d5..a6df8a979 100644 --- a/buildtrigger/githubhandler.py +++ b/buildtrigger/githubhandler.py @@ -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. diff --git a/buildtrigger/gitlabhandler.py b/buildtrigger/gitlabhandler.py index 448fff4fc..5d221874b 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 @@ -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'] diff --git a/buildtrigger/test/__init__.py b/buildtrigger/test/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/buildtrigger/test/bitbucketmock.py b/buildtrigger/test/bitbucketmock.py new file mode 100644 index 000000000..93d66d143 --- /dev/null +++ b/buildtrigger/test/bitbucketmock.py @@ -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 diff --git a/buildtrigger/test/githubmock.py b/buildtrigger/test/githubmock.py new file mode 100644 index 000000000..77c8a7a1f --- /dev/null +++ b/buildtrigger/test/githubmock.py @@ -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 diff --git a/buildtrigger/test/gitlabmock.py b/buildtrigger/test/gitlabmock.py new file mode 100644 index 000000000..8a2212be9 --- /dev/null +++ b/buildtrigger/test/gitlabmock.py @@ -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 diff --git a/buildtrigger/test/test_bitbuckethandler.py b/buildtrigger/test/test_bitbuckethandler.py new file mode 100644 index 000000000..12c653db5 --- /dev/null +++ b/buildtrigger/test/test_bitbuckethandler.py @@ -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) diff --git a/buildtrigger/test/test_customhandler.py b/buildtrigger/test/test_customhandler.py new file mode 100644 index 000000000..03fcad9be --- /dev/null +++ b/buildtrigger/test/test_customhandler.py @@ -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) diff --git a/buildtrigger/test/test_githosthandler.py b/buildtrigger/test/test_githosthandler.py new file mode 100644 index 000000000..53005d4c0 --- /dev/null +++ b/buildtrigger/test/test_githosthandler.py @@ -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() diff --git a/buildtrigger/test/test_githubhandler.py b/buildtrigger/test/test_githubhandler.py new file mode 100644 index 000000000..5f9a1d786 --- /dev/null +++ b/buildtrigger/test/test_githubhandler.py @@ -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'] diff --git a/buildtrigger/test/test_gitlabhandler.py b/buildtrigger/test/test_gitlabhandler.py new file mode 100644 index 000000000..c88c591d6 --- /dev/null +++ b/buildtrigger/test/test_gitlabhandler.py @@ -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) + + 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/grunt/Gruntfile.js b/grunt/Gruntfile.js index 29ead6454..f0f8c35f2 100644 --- a/grunt/Gruntfile.js +++ b/grunt/Gruntfile.js @@ -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' } }, diff --git a/package.json b/package.json index 7a471e930..dbc5c0019 100644 --- a/package.json +++ b/package.json @@ -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" } 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..100b7ce33 --- /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/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/constants/name-patterns.constant.ts b/static/js/constants/name-patterns.constant.ts index 422887c3e..942028ee1 100644 --- a/static/js/constants/name-patterns.constant.ts +++ b/static/js/constants/name-patterns.constant.ts @@ -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]+)*)$', diff --git a/static/js/constants/pages.constant.ts b/static/js/constants/pages.constant.ts deleted file mode 100644 index 6d0452b8c..000000000 --- a/static/js/constants/pages.constant.ts +++ /dev/null @@ -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; - } -}; 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/dockerfile-path-select.component.html b/static/js/directives/ui/dockerfile-path-select/dockerfile-path-select.component.html new file mode 100644 index 000000000..ef20c83c7 --- /dev/null +++ b/static/js/directives/ui/dockerfile-path-select/dockerfile-path-select.component.html @@ -0,0 +1,37 @@ +
+ + +
+
+ Path entered for folder containing Dockerfile is invalid: Must start with a '/'. +
+
+
diff --git a/static/js/directives/ui/dockerfile-path-select/dockerfile-path-select.component.spec.ts b/static/js/directives/ui/dockerfile-path-select/dockerfile-path-select.component.spec.ts new file mode 100644 index 000000000..431252faa --- /dev/null +++ b/static/js/directives/ui/dockerfile-path-select/dockerfile-path-select.component.spec.ts @@ -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); + }); + }); +}); \ No newline at end of file diff --git a/static/js/directives/ui/dockerfile-path-select/dockerfile-path-select.component.ts b/static/js/directives/ui/dockerfile-path-select/dockerfile-path-select.component.ts new file mode 100644 index 000000000..be0109ef4 --- /dev/null +++ b/static/js/directives/ui/dockerfile-path-select/dockerfile-path-select.component.ts @@ -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; + } +} 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/linear-workflow-section.component.html b/static/js/directives/ui/linear-workflow/linear-workflow-section.component.html new file mode 100644 index 000000000..90c0245ba --- /dev/null +++ b/static/js/directives/ui/linear-workflow/linear-workflow-section.component.html @@ -0,0 +1,7 @@ +
+
+
+ +
diff --git a/static/js/directives/ui/linear-workflow/linear-workflow-section.component.spec.ts b/static/js/directives/ui/linear-workflow/linear-workflow-section.component.spec.ts new file mode 100644 index 000000000..d41ecf194 --- /dev/null +++ b/static/js/directives/ui/linear-workflow/linear-workflow-section.component.spec.ts @@ -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(); + }); + }); +}); diff --git a/static/js/directives/ui/linear-workflow/linear-workflow-section.component.ts b/static/js/directives/ui/linear-workflow/linear-workflow-section.component.ts new file mode 100644 index 000000000..81e2c04ba --- /dev/null +++ b/static/js/directives/ui/linear-workflow/linear-workflow-section.component.ts @@ -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(); + } + } +} diff --git a/static/js/directives/ui/linear-workflow/linear-workflow.component.html b/static/js/directives/ui/linear-workflow/linear-workflow.component.html new file mode 100644 index 000000000..899dc4c2d --- /dev/null +++ b/static/js/directives/ui/linear-workflow/linear-workflow.component.html @@ -0,0 +1,39 @@ +
+ +
+ +
+ + + + + +
+ + + + +
+ Next: +
    +
  • + {{ section.component.sectionTitle }} +
  • +
+
+
+
+
\ No newline at end of file diff --git a/static/js/directives/ui/linear-workflow/linear-workflow.component.spec.ts b/static/js/directives/ui/linear-workflow/linear-workflow.component.spec.ts new file mode 100644 index 000000000..9f479b51b --- /dev/null +++ b/static/js/directives/ui/linear-workflow/linear-workflow.component.spec.ts @@ -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); + }); + }); + }); +}); diff --git a/static/js/directives/ui/linear-workflow/linear-workflow.component.ts b/static/js/directives/ui/linear-workflow/linear-workflow.component.ts new file mode 100644 index 000000000..960289c15 --- /dev/null +++ b/static/js/directives/ui/linear-workflow/linear-workflow.component.ts @@ -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; +} diff --git a/static/js/directives/ui/manage-trigger-custom-git/manage-trigger-custom-git.component.html b/static/js/directives/ui/manage-trigger-custom-git/manage-trigger-custom-git.component.html new file mode 100644 index 000000000..a4bf31b72 --- /dev/null +++ b/static/js/directives/ui/manage-trigger-custom-git/manage-trigger-custom-git.component.html @@ -0,0 +1,44 @@ +
+ + + + +
+

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: + +
+ +
+
+
diff --git a/static/js/directives/ui/manage-trigger-custom-git/manage-trigger-custom-git.component.spec.ts b/static/js/directives/ui/manage-trigger-custom-git/manage-trigger-custom-git.component.spec.ts new file mode 100644 index 000000000..5f87efb36 --- /dev/null +++ b/static/js/directives/ui/manage-trigger-custom-git/manage-trigger-custom-git.component.spec.ts @@ -0,0 +1,14 @@ +import { ManageTriggerCustomGitComponent } from './manage-trigger-custom-git.component'; + + +describe("ManageTriggerCustomGitComponent", () => { + var component: ManageTriggerCustomGitComponent; + + beforeEach(() => { + component = new ManageTriggerCustomGitComponent(); + }); + + describe("$onChanges", () => { + + }); +}); diff --git a/static/js/directives/ui/manage-trigger-custom-git/manage-trigger-custom-git.component.ts b/static/js/directives/ui/manage-trigger-custom-git/manage-trigger-custom-git.component.ts new file mode 100644 index 000000000..c4e88f7cf --- /dev/null +++ b/static/js/directives/ui/manage-trigger-custom-git/manage-trigger-custom-git.component.ts @@ -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); + } + } +} diff --git a/static/js/directives/ui/manage-trigger-githost/manage-trigger-githost.component.html b/static/js/directives/ui/manage-trigger-githost/manage-trigger-githost.component.html new file mode 100644 index 000000000..b3fd10bd7 --- /dev/null +++ b/static/js/directives/ui/manage-trigger-githost/manage-trigger-githost.component.html @@ -0,0 +1,374 @@ +
+ + + + +
+

Select {{ $ctrl.namespaceTitle }}

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

Select Repository

+ + Select a repository in + + {{ $ctrl.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 + + {{ $ctrl.local.selectedNamespace.id }}/{{ $ctrl.local.selectedRepository.name }} + + +
+ +
+
+ +
+
+ +
+ Retrieving repository refs +
+ +
+ + + +
+
+ {{ $ctrl.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 + {{ $ctrl.local.selectedNamespace.id }}/{{ $ctrl.local.selectedRepository.name }} + + + {{ $ctrl.local.triggerAnalysis.message }} +
+ + +
+

Verification Warning

+ {{ $ctrl.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/js/directives/ui/manage-trigger-githost/manage-trigger-githost.component.spec.ts b/static/js/directives/ui/manage-trigger-githost/manage-trigger-githost.component.spec.ts new file mode 100644 index 000000000..547781352 --- /dev/null +++ b/static/js/directives/ui/manage-trigger-githost/manage-trigger-githost.component.spec.ts @@ -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 + }); +}); \ No newline at end of file diff --git a/static/js/directives/ui/manage-trigger-githost/manage-trigger-githost.component.ts b/static/js/directives/ui/manage-trigger-githost/manage-trigger-githost.component.ts new file mode 100644 index 000000000..a28f1e2f4 --- /dev/null +++ b/static/js/directives/ui/manage-trigger-githost/manage-trigger-githost.component.ts @@ -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; +}; \ No newline at end of file diff --git a/static/js/directives/ui/regex-match-view/regex-match-view.component.html b/static/js/directives/ui/regex-match-view/regex-match-view.component.html new file mode 100644 index 000000000..bdfaa597a --- /dev/null +++ b/static/js/directives/ui/regex-match-view/regex-match-view.component.html @@ -0,0 +1,29 @@ +
+
+ Invalid Regular Expression! +
+
+ + + + + + + + + +
Matching: +
    +
  • + {{ item.title }} +
  • +
+
Not Matching: +
    +
  • + {{ item.title }} +
  • +
+
+
+
diff --git a/static/js/directives/ui/regex-match-view/regex-match-view.component.spec.ts b/static/js/directives/ui/regex-match-view/regex-match-view.component.spec.ts new file mode 100644 index 000000000..6bc05a768 --- /dev/null +++ b/static/js/directives/ui/regex-match-view/regex-match-view.component.spec.ts @@ -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); + }); + }); + }); +}); diff --git a/static/js/directives/ui/regex-match-view/regex-match-view.component.ts b/static/js/directives/ui/regex-match-view/regex-match-view.component.ts new file mode 100644 index 000000000..256a2d99e --- /dev/null +++ b/static/js/directives/ui/regex-match-view/regex-match-view.component.ts @@ -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; + }); + } +} 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-config.module.ts b/static/js/quay-config.module.ts new file mode 100644 index 000000000..21bfbed09 --- /dev/null +++ b/static/js/quay-config.module.ts @@ -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); \ No newline at end of file diff --git a/static/js/quay-pages.module.ts b/static/js/quay-pages.module.ts index 871cb78f0..23ecc26a0 100644 --- a/static/js/quay-pages.module.ts +++ b/static/js/quay-pages.module.ts @@ -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); diff --git a/static/js/quay-routes.module.ts b/static/js/quay-routes.module.ts new file mode 100644 index 000000000..13715a264 --- /dev/null +++ b/static/js/quay-routes.module.ts @@ -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'); + } +} diff --git a/static/js/quay-run.module.ts b/static/js/quay-run.module.ts new file mode 100644 index 000000000..710235c7b --- /dev/null +++ b/static/js/quay-run.module.ts @@ -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': (window).__token || ''}); + + // Handle session expiration. + Restangular.setErrorInterceptor(function(response) { + if (response !== undefined && response.status == 503) { + ($('#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) { + ($('#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((window).location.href) == 0) { + (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 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; + (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); \ No newline at end of file diff --git a/static/js/quay.config.ts b/static/js/quay.config.ts deleted file mode 100644 index af2a36af9..000000000 --- a/static/js/quay.config.ts +++ /dev/null @@ -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}}); - }; - }); - } -} diff --git a/static/js/quay.module.ts b/static/js/quay.module.ts index c149f1ba3..f8d54c5a0 100644 --- a/static/js/quay.module.ts +++ b/static/js/quay.module.ts @@ -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); diff --git a/static/js/quay.routes.ts b/static/js/quay.routes.ts deleted file mode 100644 index 628c77dbd..000000000 --- a/static/js/quay.routes.ts +++ /dev/null @@ -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'); -} diff --git a/static/js/quay.run.ts b/static/js/quay.run.ts deleted file mode 100644 index 9ebcfd262..000000000 --- a/static/js/quay.run.ts +++ /dev/null @@ -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': (window).__token || ''}); - - // Handle session expiration. - Restangular.setErrorInterceptor(function(response) { - if (response !== undefined && response.status == 503) { - ($('#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) { - ($('#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((window).location.href) == 0) { - (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 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; - (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; -} \ No newline at end of file diff --git a/static/js/services/page/page.service.impl.spec.ts b/static/js/services/page/page.service.impl.spec.ts new file mode 100644 index 000000000..61f9e51f1 --- /dev/null +++ b/static/js/services/page/page.service.impl.spec.ts @@ -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", () => { + + }); +}); \ No newline at end of file diff --git a/static/js/services/page/page.service.impl.ts b/static/js/services/page/page.service.impl.ts new file mode 100644 index 000000000..d52818899 --- /dev/null +++ b/static/js/services/page/page.service.impl.ts @@ -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; + } +} diff --git a/static/js/services/page/page.service.ts b/static/js/services/page/page.service.ts new file mode 100644 index 000000000..829959d2a --- /dev/null +++ b/static/js/services/page/page.service.ts @@ -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; +} diff --git a/static/js/services/view-array/view-array.impl.ts b/static/js/services/view-array/view-array.impl.ts index 8cc3f75c4..939d50e5c 100644 --- a/static/js/services/view-array/view-array.impl.ts +++ b/static/js/services/view-array/view-array.impl.ts @@ -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[]; diff --git a/static/partials/trigger-setup.html b/static/partials/trigger-setup.html new file mode 100644 index 000000000..7f1fad28d --- /dev/null +++ b/static/partials/trigger-setup.html @@ -0,0 +1,68 @@ +
+
+
+ + + + {{ 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/data/test.db b/test/data/test.db index 5d0c6c4c7..dc4f5960a 100644 Binary files a/test/data/test.db and b/test/data/test.db differ 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.