Merge pull request #2337 from coreos-inc/new-trigger-ux

Implement new create and manager trigger UI
This commit is contained in:
josephschorr 2017-03-02 18:15:32 -05:00 committed by GitHub
commit aa2f88d321
79 changed files with 4038 additions and 1555 deletions

View file

@ -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

View file

@ -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']

View file

@ -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

View file

@ -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.

View file

@ -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']

View file

View file

@ -0,0 +1,158 @@
from datetime import datetime
from mock import Mock
from buildtrigger.bitbuckethandler import BitbucketBuildTrigger
from util.morecollections import AttrDict
def get_bitbucket_trigger(subdir=''):
trigger_obj = AttrDict(dict(auth_token='foobar', id='sometrigger'))
trigger = BitbucketBuildTrigger(trigger_obj, {
'build_source': 'foo/bar',
'subdir': subdir,
'username': 'knownuser'
})
trigger._get_client = get_mock_bitbucket
return trigger
def get_repo_path_contents(path, revision):
data = {
'files': [{'path': 'Dockerfile'}],
}
return (True, data, None)
def get_raw_path_contents(path, revision):
if path == '/Dockerfile':
return (True, 'hello world', None)
if path == 'somesubdir/Dockerfile':
return (True, 'hi universe', None)
return (False, None, None)
def get_branches_and_tags():
data = {
'branches': [{'name': 'master'}, {'name': 'otherbranch'}],
'tags': [{'name': 'sometag'}, {'name': 'someothertag'}],
}
return (True, data, None)
def get_branches():
return (True, {'master': {}, 'otherbranch': {}}, None)
def get_tags():
return (True, {'sometag': {}, 'someothertag': {}}, None)
def get_branch(branch_name):
if branch_name != 'master':
return (False, None, None)
data = {
'target': {
'hash': 'aaaaaaa',
},
}
return (True, data, None)
def get_tag(tag_name):
if tag_name != 'sometag':
return (False, None, None)
data = {
'target': {
'hash': 'aaaaaaa',
},
}
return (True, data, None)
def get_changeset_mock(commit_sha):
if commit_sha != 'aaaaaaa':
return (False, None, 'Not found')
data = {
'node': 'aaaaaaa',
'message': 'some message',
'timestamp': 'now',
'raw_author': 'foo@bar.com',
}
return (True, data, None)
def get_changesets():
changesets_mock = Mock()
changesets_mock.get = Mock(side_effect=get_changeset_mock)
return changesets_mock
def get_deploykeys():
deploykeys_mock = Mock()
deploykeys_mock.create = Mock(return_value=(True, {'pk': 'someprivatekey'}, None))
deploykeys_mock.delete = Mock(return_value=(True, {}, None))
return deploykeys_mock
def get_webhooks():
webhooks_mock = Mock()
webhooks_mock.create = Mock(return_value=(True, {'uuid': 'someuuid'}, None))
webhooks_mock.delete = Mock(return_value=(True, {}, None))
return webhooks_mock
def get_repo_mock(name):
if name != 'bar':
return None
repo_mock = Mock()
repo_mock.get_main_branch = Mock(return_value=(True, {'name': 'master'}, None))
repo_mock.get_path_contents = Mock(side_effect=get_repo_path_contents)
repo_mock.get_raw_path_contents = Mock(side_effect=get_raw_path_contents)
repo_mock.get_branches_and_tags = Mock(side_effect=get_branches_and_tags)
repo_mock.get_branches = Mock(side_effect=get_branches)
repo_mock.get_tags = Mock(side_effect=get_tags)
repo_mock.get_branch = Mock(side_effect=get_branch)
repo_mock.get_tag = Mock(side_effect=get_tag)
repo_mock.changesets = Mock(side_effect=get_changesets)
repo_mock.deploykeys = Mock(side_effect=get_deploykeys)
repo_mock.webhooks = Mock(side_effect=get_webhooks)
return repo_mock
def get_repositories_mock():
repos_mock = Mock()
repos_mock.get = Mock(side_effect=get_repo_mock)
return repos_mock
def get_namespace_mock(namespace):
namespace_mock = Mock()
namespace_mock.repositories = Mock(side_effect=get_repositories_mock)
return namespace_mock
def get_repo(namespace, name):
return {
'owner': namespace,
'logo': 'avatarurl',
'slug': name,
'description': 'some %s repo' % (name),
'utc_last_updated': str(datetime.utcfromtimestamp(0)),
'read_only': namespace != 'knownuser',
'is_private': name == 'somerepo',
}
def get_visible_repos():
repos = [
get_repo('knownuser', 'somerepo'),
get_repo('someorg', 'somerepo'),
get_repo('someorg', 'anotherrepo'),
]
return (True, repos, None)
def get_authed_mock(token, secret):
authed_mock = Mock()
authed_mock.for_namespace = Mock(side_effect=get_namespace_mock)
authed_mock.get_visible_repositories = Mock(side_effect=get_visible_repos)
return authed_mock
def get_mock_bitbucket():
bitbucket_mock = Mock()
bitbucket_mock.get_authorized_client = Mock(side_effect=get_authed_mock)
return bitbucket_mock

View file

@ -0,0 +1,173 @@
from datetime import datetime
from mock import Mock
from github import GithubException
from buildtrigger.githubhandler import GithubBuildTrigger
from util.morecollections import AttrDict
def get_github_trigger(subdir=''):
trigger_obj = AttrDict(dict(auth_token='foobar', id='sometrigger'))
trigger = GithubBuildTrigger(trigger_obj, {'build_source': 'foo', 'subdir': subdir})
trigger._get_client = get_mock_github
return trigger
def get_mock_github():
def get_commit_mock(commit_sha):
if commit_sha == 'aaaaaaa':
commit_mock = Mock()
commit_mock.sha = commit_sha
commit_mock.html_url = 'http://url/to/commit'
commit_mock.last_modified = 'now'
commit_mock.commit = Mock()
commit_mock.commit.message = 'some cool message'
commit_mock.committer = Mock()
commit_mock.committer.login = 'someuser'
commit_mock.committer.avatar_url = 'avatarurl'
commit_mock.committer.html_url = 'htmlurl'
commit_mock.author = Mock()
commit_mock.author.login = 'someuser'
commit_mock.author.avatar_url = 'avatarurl'
commit_mock.author.html_url = 'htmlurl'
return commit_mock
raise GithubException(None, None)
def get_branch_mock(branch_name):
if branch_name == 'master':
branch_mock = Mock()
branch_mock.commit = Mock()
branch_mock.commit.sha = 'aaaaaaa'
return branch_mock
raise GithubException(None, None)
def get_repo_mock(namespace, name):
repo_mock = Mock()
repo_mock.owner = Mock()
repo_mock.owner.login = namespace
repo_mock.full_name = '%s/%s' % (namespace, name)
repo_mock.name = name
repo_mock.description = 'some %s repo' % (name)
repo_mock.pushed_at = datetime.utcfromtimestamp(0)
repo_mock.html_url = 'https://bitbucket.org/%s/%s' % (namespace, name)
repo_mock.private = name == 'somerepo'
repo_mock.permissions = Mock()
repo_mock.permissions.admin = namespace == 'knownuser'
return repo_mock
def get_user_repos_mock():
return [get_repo_mock('knownuser', 'somerepo')]
def get_org_repos_mock(type='all'):
return [get_repo_mock('someorg', 'somerepo'), get_repo_mock('someorg', 'anotherrepo')]
def get_orgs_mock():
return [get_org_mock('someorg')]
def get_user_mock(username='knownuser'):
if username == 'knownuser':
user_mock = Mock()
user_mock.name = username
user_mock.plan = Mock()
user_mock.plan.private_repos = 1
user_mock.login = username
user_mock.html_url = 'https://bitbucket.org/%s' % (username)
user_mock.avatar_url = 'avatarurl'
user_mock.get_repos = Mock(side_effect=get_user_repos_mock)
user_mock.get_orgs = Mock(side_effect=get_orgs_mock)
return user_mock
raise GithubException(None, None)
def get_org_mock(namespace):
if namespace == 'someorg':
org_mock = Mock()
org_mock.get_repos = Mock(side_effect=get_org_repos_mock)
org_mock.login = namespace
org_mock.html_url = 'https://bitbucket.org/%s' % (namespace)
org_mock.avatar_url = 'avatarurl'
org_mock.name = namespace
org_mock.plan = Mock()
org_mock.plan.private_repos = 2
return org_mock
raise GithubException(None, None)
def get_tags_mock():
sometag = Mock()
sometag.name = 'sometag'
sometag.commit = get_commit_mock('aaaaaaa')
someothertag = Mock()
someothertag.name = 'someothertag'
someothertag.commit = get_commit_mock('aaaaaaa')
return [sometag, someothertag]
def get_branches_mock():
master = Mock()
master.name = 'master'
master.commit = get_commit_mock('aaaaaaa')
otherbranch = Mock()
otherbranch.name = 'otherbranch'
otherbranch.commit = get_commit_mock('aaaaaaa')
return [master, otherbranch]
def get_file_contents_mock(filepath):
if filepath == '/Dockerfile':
m = Mock()
m.content = 'hello world'
return m
if filepath == 'somesubdir/Dockerfile':
m = Mock()
m.content = 'hi universe'
return m
raise GithubException(None, None)
def get_git_tree_mock(commit_sha, recursive=False):
first_file = Mock()
first_file.type = 'blob'
first_file.path = 'Dockerfile'
second_file = Mock()
second_file.type = 'other'
second_file.path = '/some/Dockerfile'
third_file = Mock()
third_file.type = 'blob'
third_file.path = 'somesubdir/Dockerfile'
t = Mock()
if commit_sha == 'aaaaaaa':
t.tree = [
first_file, second_file, third_file,
]
else:
t.tree = []
return t
repo_mock = Mock()
repo_mock.default_branch = 'master'
repo_mock.ssh_url = 'ssh_url'
repo_mock.get_branch = Mock(side_effect=get_branch_mock)
repo_mock.get_tags = Mock(side_effect=get_tags_mock)
repo_mock.get_branches = Mock(side_effect=get_branches_mock)
repo_mock.get_commit = Mock(side_effect=get_commit_mock)
repo_mock.get_file_contents = Mock(side_effect=get_file_contents_mock)
repo_mock.get_git_tree = Mock(side_effect=get_git_tree_mock)
gh_mock = Mock()
gh_mock.get_repo = Mock(return_value=repo_mock)
gh_mock.get_user = Mock(side_effect=get_user_mock)
gh_mock.get_organization = Mock(side_effect=get_org_mock)
return gh_mock

View file

@ -0,0 +1,186 @@
from datetime import datetime
from mock import Mock
from buildtrigger.gitlabhandler import GitLabBuildTrigger
from util.morecollections import AttrDict
def get_gitlab_trigger(subdir=''):
trigger_obj = AttrDict(dict(auth_token='foobar', id='sometrigger'))
trigger = GitLabBuildTrigger(trigger_obj, {
'build_source': 'foo/bar',
'subdir': subdir,
'username': 'knownuser'
})
trigger._get_authorized_client = get_mock_gitlab
return trigger
def adddeploykey_mock(project_id, name, public_key):
return {'id': 'foo'}
def addprojecthook_mock(project_id, webhook_url, push=False):
return {'id': 'foo'}
def get_currentuser_mock():
return {
'username': 'knownuser'
}
def project(namespace, name):
return {
'id': '%s/%s' % (namespace, name),
'default_branch': 'master',
'namespace': {
'id': namespace,
'path': namespace,
'name': namespace,
},
'path': name,
'path_with_namespace': '%s/%s' % (namespace, name),
'description': 'some %s repo' % name,
'last_activity_at': str(datetime.utcfromtimestamp(0)),
'web_url': 'https://bitbucket.org/%s/%s' % (namespace, name),
'ssh_url_to_repo': 'git://%s/%s' % (namespace, name),
'public': name != 'somerepo',
'permissions': {
'project_access': {
'access_level': 50 if namespace == 'knownuser' else 0,
}
},
'owner': {
'avatar_url': 'avatarurl',
}
}
def getprojects_mock(page=1, per_page=100):
return [
project('knownuser', 'somerepo'),
project('someorg', 'somerepo'),
project('someorg', 'anotherrepo'),
]
def getproject_mock(project_name):
if project_name == 'knownuser/somerepo':
return project('knownuser', 'somerepo')
if project_name == 'foo/bar':
return project('foo', 'bar')
return False
def getbranches_mock(project_id):
return [
{
'name': 'master',
'commit': {
'id': 'aaaaaaa',
}
},
{
'name': 'otherbranch',
'commit': {
'id': 'aaaaaaa',
}
},
]
def getrepositorytags_mock(project_id):
return [
{
'name': 'sometag',
'commit': {
'id': 'aaaaaaa',
}
},
{
'name': 'someothertag',
'commit': {
'id': 'aaaaaaa',
}
},
]
def getrepositorytree_mock(project_id, ref_name='master'):
return [
{'name': 'README'},
{'name': 'Dockerfile'},
]
def getrepositorycommit_mock(project_id, commit_sha):
if commit_sha != 'aaaaaaa':
return False
return {
'id': 'aaaaaaa',
'message': 'some message',
'committed_date': 'now',
}
def getusers_mock(search=None):
if search == 'knownuser':
return [
{
'username': 'knownuser',
'avatar_url': 'avatarurl',
}
]
return False
def getbranch_mock(repo_id, branch):
if branch != 'master' and branch != 'otherbranch':
return False
return {
'name': branch,
'commit': {
'id': 'aaaaaaa',
}
}
def gettag_mock(repo_id, tag):
if tag != 'sometag' and tag != 'someothertag':
return False
return {
'name': tag,
'commit': {
'id': 'aaaaaaa',
}
}
def getrawfile_mock(repo_id, branch_name, path):
if path == '/Dockerfile':
return 'hello world'
if path == 'somesubdir/Dockerfile':
return 'hi universe'
return False
def get_mock_gitlab():
mock_gitlab = Mock()
mock_gitlab.host = 'https://bitbucket.org'
mock_gitlab.currentuser = Mock(side_effect=get_currentuser_mock)
mock_gitlab.getusers = Mock(side_effect=getusers_mock)
mock_gitlab.getprojects = Mock(side_effect=getprojects_mock)
mock_gitlab.getproject = Mock(side_effect=getproject_mock)
mock_gitlab.getbranches = Mock(side_effect=getbranches_mock)
mock_gitlab.getbranch = Mock(side_effect=getbranch_mock)
mock_gitlab.gettag = Mock(side_effect=gettag_mock)
mock_gitlab.getrepositorytags = Mock(side_effect=getrepositorytags_mock)
mock_gitlab.getrepositorytree = Mock(side_effect=getrepositorytree_mock)
mock_gitlab.getrepositorycommit = Mock(side_effect=getrepositorycommit_mock)
mock_gitlab.getrawfile = Mock(side_effect=getrawfile_mock)
mock_gitlab.adddeploykey = Mock(side_effect=adddeploykey_mock)
mock_gitlab.addprojecthook = Mock(side_effect=addprojecthook_mock)
mock_gitlab.deletedeploykey = Mock(return_value=True)
mock_gitlab.deleteprojecthook = Mock(return_value=True)
return mock_gitlab

View file

@ -0,0 +1,91 @@
import json
import pytest
from buildtrigger.test.bitbucketmock import get_bitbucket_trigger
from buildtrigger.triggerutil import (SkipRequestException, ValidationRequestException,
InvalidPayloadException)
from endpoints.building import PreparedBuild
from util.morecollections import AttrDict
@pytest.fixture
def bitbucket_trigger():
return get_bitbucket_trigger()
def test_list_build_subdirs(bitbucket_trigger):
assert bitbucket_trigger.list_build_subdirs() == ['']
@pytest.mark.parametrize('subdir, contents', [
('', 'hello world'),
('somesubdir', 'hi universe'),
('unknownpath', None),
])
def test_load_dockerfile_contents(subdir, contents):
trigger = get_bitbucket_trigger(subdir)
assert trigger.load_dockerfile_contents() == contents
@pytest.mark.parametrize('payload, expected_error, expected_message', [
('{}', InvalidPayloadException, "'push' is a required property"),
# Valid payload:
('''{
"push": {
"changes": [{
"new": {
"name": "somechange",
"target": {
"hash": "aaaaaaa",
"message": "foo",
"date": "now",
"links": {
"html": {
"href": "somelink"
}
}
}
}
}]
},
"repository": {
"full_name": "foo/bar"
}
}''', None, None),
# Skip message:
('''{
"push": {
"changes": [{
"new": {
"name": "somechange",
"target": {
"hash": "aaaaaaa",
"message": "[skip build] foo",
"date": "now",
"links": {
"html": {
"href": "somelink"
}
}
}
}
}]
},
"repository": {
"full_name": "foo/bar"
}
}''', SkipRequestException, ''),
])
def test_handle_trigger_request(bitbucket_trigger, payload, expected_error, expected_message):
def get_payload():
return json.loads(payload)
request = AttrDict(dict(get_json=get_payload))
if expected_error is not None:
with pytest.raises(expected_error) as ipe:
bitbucket_trigger.handle_trigger_request(request)
assert ipe.value.message == expected_message
else:
assert isinstance(bitbucket_trigger.handle_trigger_request(request), PreparedBuild)

View file

@ -0,0 +1,51 @@
import pytest
from buildtrigger.customhandler import CustomBuildTrigger
from buildtrigger.triggerutil import (InvalidPayloadException, SkipRequestException,
TriggerStartException)
from endpoints.building import PreparedBuild
from util.morecollections import AttrDict
@pytest.mark.parametrize('payload, expected_error, expected_message', [
('', InvalidPayloadException, 'Missing expected payload'),
('{}', InvalidPayloadException, "'commit' is a required property"),
('{"commit": "foo", "ref": "refs/heads/something", "default_branch": "baz"}',
InvalidPayloadException, "u'foo' does not match '^([A-Fa-f0-9]{7,})$'"),
('{"commit": "11d6fbc", "ref": "refs/heads/something", "default_branch": "baz"}', None, None),
('''{
"commit": "11d6fbc",
"ref": "refs/heads/something",
"default_branch": "baz",
"commit_info": {
"message": "[skip build]",
"url": "http://foo.bar",
"date": "NOW"
}
}''', SkipRequestException, ''),
])
def test_handle_trigger_request(payload, expected_error, expected_message):
trigger = CustomBuildTrigger(None, {'build_source': 'foo'})
request = AttrDict(dict(data=payload))
if expected_error is not None:
with pytest.raises(expected_error) as ipe:
trigger.handle_trigger_request(request)
assert ipe.value.message == expected_message
else:
assert isinstance(trigger.handle_trigger_request(request), PreparedBuild)
@pytest.mark.parametrize('run_parameters, expected_error, expected_message', [
({}, TriggerStartException, 'missing required parameter'),
({'commit_sha': 'foo'}, TriggerStartException, "'foo' does not match '^([A-Fa-f0-9]{7,})$'"),
({'commit_sha': '11d6fbc'}, None, None),
])
def test_manual_start(run_parameters, expected_error, expected_message):
trigger = CustomBuildTrigger(None, {'build_source': 'foo'})
if expected_error is not None:
with pytest.raises(expected_error) as ipe:
trigger.manual_start(run_parameters)
assert ipe.value.message == expected_message
else:
assert isinstance(trigger.manual_start(run_parameters), PreparedBuild)

View file

@ -0,0 +1,125 @@
import pytest
from buildtrigger.triggerutil import TriggerStartException
from buildtrigger.test.bitbucketmock import get_bitbucket_trigger
from buildtrigger.test.githubmock import get_github_trigger
from buildtrigger.test.gitlabmock import get_gitlab_trigger
from endpoints.building import PreparedBuild
# Note: This test suite executes a common set of tests against all the trigger types specified
# in this fixture. Each trigger's mock is expected to return the same data for all of these calls.
@pytest.fixture(params=[get_github_trigger(), get_bitbucket_trigger(), get_gitlab_trigger()])
def githost_trigger(request):
return request.param
@pytest.mark.parametrize('run_parameters, expected_error, expected_message', [
# No branch or tag specified: use the commit of the default branch.
({}, None, None),
# Invalid branch.
({'refs': {'kind': 'branch', 'name': 'invalid'}}, TriggerStartException,
'Could not find branch in repository'),
# Invalid tag.
({'refs': {'kind': 'tag', 'name': 'invalid'}}, TriggerStartException,
'Could not find tag in repository'),
# Valid branch.
({'refs': {'kind': 'branch', 'name': 'master'}}, None, None),
# Valid tag.
({'refs': {'kind': 'tag', 'name': 'sometag'}}, None, None),
])
def test_manual_start(run_parameters, expected_error, expected_message, githost_trigger):
if expected_error is not None:
with pytest.raises(expected_error) as ipe:
githost_trigger.manual_start(run_parameters)
assert ipe.value.message == expected_message
else:
assert isinstance(githost_trigger.manual_start(run_parameters), PreparedBuild)
@pytest.mark.parametrize('name, expected', [
('refs', [
{'kind': 'branch', 'name': 'master'},
{'kind': 'branch', 'name': 'otherbranch'},
{'kind': 'tag', 'name': 'sometag'},
{'kind': 'tag', 'name': 'someothertag'},
]),
('tag_name', set(['sometag', 'someothertag'])),
('branch_name', set(['master', 'otherbranch'])),
('invalid', None)
])
def test_list_field_values(name, expected, githost_trigger):
if expected is None:
assert githost_trigger.list_field_values(name) is None
elif isinstance(expected, set):
assert set(githost_trigger.list_field_values(name)) == set(expected)
else:
assert githost_trigger.list_field_values(name) == expected
def test_list_build_source_namespaces(githost_trigger):
namespaces_expected = [
{
'personal': True,
'score': 1,
'avatar_url': 'avatarurl',
'id': 'knownuser',
'title': 'knownuser',
'url': 'https://bitbucket.org/knownuser',
},
{
'score': 2,
'title': 'someorg',
'personal': False,
'url': 'https://bitbucket.org/someorg',
'avatar_url': 'avatarurl',
'id': 'someorg'
}
]
found = githost_trigger.list_build_source_namespaces()
found.sort()
namespaces_expected.sort()
assert found == namespaces_expected
@pytest.mark.parametrize('namespace, expected', [
('', []),
('unknown', []),
('knownuser', [
{
'last_updated': 0, 'name': 'somerepo',
'url': 'https://bitbucket.org/knownuser/somerepo', 'private': True,
'full_name': 'knownuser/somerepo', 'has_admin_permissions': True,
'description': 'some somerepo repo'
}]),
('someorg', [
{
'last_updated': 0, 'name': 'somerepo',
'url': 'https://bitbucket.org/someorg/somerepo', 'private': True,
'full_name': 'someorg/somerepo', 'has_admin_permissions': False,
'description': 'some somerepo repo'
},
{
'last_updated': 0, 'name': 'anotherrepo',
'url': 'https://bitbucket.org/someorg/anotherrepo', 'private': False,
'full_name': 'someorg/anotherrepo', 'has_admin_permissions': False,
'description': 'some anotherrepo repo'
}]),
])
def test_list_build_sources_for_namespace(namespace, expected, githost_trigger):
assert githost_trigger.list_build_sources_for_namespace(namespace) == expected
def test_activate(githost_trigger):
_, private_key = githost_trigger.activate('http://some/url')
assert 'private_key' in private_key
def test_deactivate(githost_trigger):
githost_trigger.deactivate()

View file

@ -0,0 +1,89 @@
import json
import pytest
from buildtrigger.test.githubmock import get_github_trigger
from buildtrigger.triggerutil import SkipRequestException, ValidationRequestException
from endpoints.building import PreparedBuild
from util.morecollections import AttrDict
@pytest.fixture
def github_trigger():
return get_github_trigger()
@pytest.mark.parametrize('payload, expected_error, expected_message', [
('{"zen": true}', SkipRequestException, ""),
('{}', ValidationRequestException, "Missing 'repository' on request"),
('{"repository": "foo"}', ValidationRequestException, "Missing 'owner' on repository"),
# Valid payload:
('''{
"repository": {
"owner": {
"name": "someguy"
},
"name": "somerepo",
"ssh_url": "someurl"
},
"ref": "refs/tags/foo",
"head_commit": {
"id": "11d6fbc",
"url": "http://some/url",
"message": "some message",
"timestamp": "NOW"
}
}''', None, None),
# Skip message:
('''{
"repository": {
"owner": {
"name": "someguy"
},
"name": "somerepo",
"ssh_url": "someurl"
},
"ref": "refs/tags/foo",
"head_commit": {
"id": "11d6fbc",
"url": "http://some/url",
"message": "[skip build]",
"timestamp": "NOW"
}
}''', SkipRequestException, ''),
])
def test_handle_trigger_request(github_trigger, payload, expected_error, expected_message):
def get_payload():
return json.loads(payload)
request = AttrDict(dict(get_json=get_payload))
if expected_error is not None:
with pytest.raises(expected_error) as ipe:
github_trigger.handle_trigger_request(request)
assert ipe.value.message == expected_message
else:
assert isinstance(github_trigger.handle_trigger_request(request), PreparedBuild)
@pytest.mark.parametrize('subdir, contents', [
('', 'hello world'),
('somesubdir', 'hi universe'),
('unknownpath', None),
])
def test_load_dockerfile_contents(subdir, contents):
trigger = get_github_trigger(subdir)
assert trigger.load_dockerfile_contents() == contents
@pytest.mark.parametrize('username, expected_response', [
('unknownuser', None),
('knownuser', {'html_url': 'https://bitbucket.org/knownuser', 'avatar_url': 'avatarurl'}),
])
def test_lookup_user(username, expected_response, github_trigger):
assert github_trigger.lookup_user(username) == expected_response
def test_list_build_subdirs(github_trigger):
assert github_trigger.list_build_subdirs() == ['', 'somesubdir']

View file

@ -0,0 +1,90 @@
import json
import pytest
from buildtrigger.test.gitlabmock import get_gitlab_trigger
from buildtrigger.triggerutil import (SkipRequestException, ValidationRequestException,
InvalidPayloadException)
from endpoints.building import PreparedBuild
from util.morecollections import AttrDict
@pytest.fixture
def gitlab_trigger():
return get_gitlab_trigger()
def test_list_build_subdirs(gitlab_trigger):
assert gitlab_trigger.list_build_subdirs() == ['']
@pytest.mark.parametrize('subdir, contents', [
('', 'hello world'),
('somesubdir', 'hi universe'),
('unknownpath', None),
])
def test_load_dockerfile_contents(subdir, contents):
trigger = get_gitlab_trigger(subdir)
assert trigger.load_dockerfile_contents() == contents
@pytest.mark.parametrize('email, expected_response', [
('unknown@email.com', None),
('knownuser', {'username': 'knownuser', 'html_url': 'https://bitbucket.org/knownuser',
'avatar_url': 'avatarurl'}),
])
def test_lookup_user(email, expected_response, gitlab_trigger):
assert gitlab_trigger.lookup_user(email) == expected_response
@pytest.mark.parametrize('payload, expected_error, expected_message', [
('{}', SkipRequestException, ''),
# Valid payload:
('''{
"object_kind": "push",
"ref": "refs/heads/master",
"checkout_sha": "aaaaaaa",
"repository": {
"git_ssh_url": "foobar"
},
"commits": [
{
"id": "aaaaaaa",
"url": "someurl",
"message": "hello there!",
"timestamp": "now"
}
]
}''', None, None),
# Skip message:
('''{
"object_kind": "push",
"ref": "refs/heads/master",
"checkout_sha": "aaaaaaa",
"repository": {
"git_ssh_url": "foobar"
},
"commits": [
{
"id": "aaaaaaa",
"url": "someurl",
"message": "[skip build] hello there!",
"timestamp": "now"
}
]
}''', SkipRequestException, ''),
])
def test_handle_trigger_request(gitlab_trigger, payload, expected_error, expected_message):
def get_payload():
return json.loads(payload)
request = AttrDict(dict(get_json=get_payload))
if expected_error is not None:
with pytest.raises(expected_error) as ipe:
gitlab_trigger.handle_trigger_request(request)
assert ipe.value.message == expected_message
else:
assert isinstance(gitlab_trigger.handle_trigger_request(request), PreparedBuild)

View file

@ -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.

View file

@ -127,7 +127,7 @@ class BuildTriggerSubdirs(RepositoryParamResource):
try:
subdirs = handler.list_build_subdirs()
return {
'subdir': subdirs,
'subdir': ['/' + subdir for subdir in subdirs],
'status': 'success'
}
except EmptyRepositoryException as exc:
@ -288,8 +288,9 @@ class BuildTriggerAnalyze(RepositoryParamResource):
contents = handler.load_dockerfile_contents()
if not contents:
return {
'status': 'error',
'message': 'Could not read the Dockerfile for the trigger'
'status': 'warning',
'message': 'Specified Dockerfile path for the trigger was not found on the main ' +
'branch. This trigger may fail.',
}
# Parse the contents of the Dockerfile.
@ -341,42 +342,40 @@ class BuildTriggerAnalyze(RepositoryParamResource):
'message': 'Repository "%s" referenced by the Dockerfile was not found' % (base_image)
}
# Check to see if the repository is public. If not, we suggest the
# usage of a robot account to conduct the pull.
read_robots = []
# If the base image is public, mark it as such.
if found_repository.visibility.name == 'public':
return {
'status': 'publicbase'
}
# Otherwise, retrieve the list of robots and mark whether they have read access already.
robots = []
if AdministerOrganizationPermission(base_namespace).can():
perm_query = model.user.get_all_repo_users_transitive(base_namespace, base_repository)
user_ids_with_permission = set([user.id for user in perm_query])
def robot_view(robot):
return {
'name': robot.username,
'kind': 'user',
'is_robot': True
'is_robot': True,
'can_read': robot.id in user_ids_with_permission,
}
def is_valid_robot(user):
# Make sure the user is a robot.
if not user.robot:
return False
# Make sure the current user can see/administer the robot.
(robot_namespace, shortname) = parse_robot_username(user.username)
return AdministerOrganizationPermission(robot_namespace).can()
repo_users = list(model.user.get_all_repo_users_transitive(base_namespace, base_repository))
read_robots = [robot_view(user) for user in repo_users if is_valid_robot(user)]
robots = [robot_view(robot) for robot in model.user.list_namespace_robots(base_namespace)]
return {
'namespace': base_namespace,
'name': base_repository,
'is_public': found_repository.visibility.name == 'public',
'robots': read_robots,
'status': 'analyzed'
'robots': robots,
'status': 'requiresrobot',
'is_admin': AdministerOrganizationPermission(base_namespace).can(),
}
except RepositoryReadException as rre:
return {
'status': 'error',
'message': rre.message
'message': 'Could not analyze the repository: %s' % rre.message,
}
except NotImplementedError:
return {
@ -502,8 +501,54 @@ class BuildTriggerFieldValues(RepositoryParamResource):
@internal_only
class BuildTriggerSources(RepositoryParamResource):
""" Custom verb to fetch the list of build sources for the trigger config. """
schemas = {
'BuildTriggerSourcesRequest': {
'type': 'object',
'description': 'Specifies the namespace under which to fetch sources',
'properties': {
'namespace': {
'type': 'string',
'description': 'The namespace for which to fetch sources'
},
},
}
}
@require_repo_admin
@nickname('listTriggerBuildSources')
@validate_json_request('BuildTriggerSourcesRequest')
def post(self, namespace_name, repo_name, trigger_uuid):
""" List the build sources for the trigger configuration thus far. """
namespace = request.get_json()['namespace']
try:
trigger = model.build.get_build_trigger(trigger_uuid)
except model.InvalidBuildTriggerException:
raise NotFound()
user_permission = UserAdminPermission(trigger.connected_user.username)
if user_permission.can():
handler = BuildTriggerHandler.get_handler(trigger)
try:
return {
'sources': handler.list_build_sources_for_namespace(namespace)
}
except RepositoryReadException as rre:
raise InvalidRequest(rre.message)
else:
raise Unauthorized()
@resource('/v1/repository/<apirepopath:repository>/trigger/<trigger_uuid>/namespaces')
@path_param('repository', 'The full path of the repository. e.g. namespace/name')
@path_param('trigger_uuid', 'The UUID of the build trigger')
@internal_only
class BuildTriggerSourceNamespaces(RepositoryParamResource):
""" Custom verb to fetch the list of namespaces (orgs, projects, etc) for the trigger config. """
@require_repo_admin
@nickname('listTriggerBuildSourceNamespaces')
def get(self, namespace_name, repo_name, trigger_uuid):
""" List the build sources for the trigger configuration thus far. """
try:
@ -517,7 +562,7 @@ class BuildTriggerSources(RepositoryParamResource):
try:
return {
'sources': handler.list_build_sources()
'namespaces': handler.list_build_source_namespaces()
}
except RepositoryReadException as rre:
raise InvalidRequest(rre.message)

View file

@ -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)

View file

@ -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)

View file

@ -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)

View file

@ -183,6 +183,13 @@ def confirm_invite():
def repository(path):
return index('')
@web.route('/repository/<path:path>/trigger/<trigger>', methods=['GET'])
@no_cache
def buildtrigger(path, trigger):
return index('')
@web.route('/starred/')
@no_cache
def starred():
@ -659,9 +666,7 @@ def attach_custom_build_trigger(namespace_name, repo_name):
None, current_user.db_user())
repo_path = '%s/%s' % (namespace_name, repo_name)
full_url = '%s%s%s' % (url_for('web.repository', path=repo_path), '?tab=builds&newtrigger=',
trigger.uuid)
full_url = url_for('web.buildtrigger', path=repo_path, trigger=trigger.uuid)
logger.debug('Redirecting to full url: %s', full_url)
return redirect(full_url)

View file

@ -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'
}
},

View file

@ -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"
}

View file

@ -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;
}

View file

@ -0,0 +1,64 @@
linear-workflow-section {
margin-bottom: 10px;
}
linear-workflow-section.row {
margin-left: 0px;
margin-right: 0px;
}
linear-workflow .upcoming-table {
vertical-align: middle;
margin-left: 20px;
}
linear-workflow .upcoming-table .fa {
margin-right: 8px;
}
linear-workflow .upcoming {
color: #888;
vertical-align: middle;
margin-left: 10px;
}
linear-workflow .upcoming ul {
padding: 0px;
display: inline-block;
margin: 0px;
}
linear-workflow .upcoming li {
display: inline-block;
margin-right: 6px;
margin-left: 2px;
}
linear-workflow .upcoming li:after {
content: "•";
display: inline-block;
margin-left: 6px;
margin-right: 2px;
}
linear-workflow .upcoming li:last-child:after {
content: "";
}
linear-workflow .bottom-controls {
padding: 10px;
}
.linear-workflow-section-element {
padding: 20px;
padding-top: 10px;
}
.linear-workflow-section-element h3, .linear-workflow-section-element strong {
color: #444;
}
.linear-workflow-section-element.current-section h3,
.linear-workflow-section-element.current-section strong {
color: black;
}

View file

@ -0,0 +1,106 @@
.manage-trigger-control .help-col {
padding: 30px;
padding-top: 100px;
}
.manage-trigger-control .main-col {
padding-left: 10px;
padding-right: 10px;
padding-top: 10px;
}
.manage-trigger-control strong {
margin-bottom: 10px;
display: block;
}
.manage-trigger-control .namespace-avatar {
margin-right: 4px;
width: 24px;
vertical-align: middle;
}
.manage-trigger-control .importance-col {
text-align: center;
width: 120px;
}
.manage-trigger-control .co-top-bar {
margin-top: 20px;
height: 28px;
}
.manage-trigger-control .namespace-avatar {
margin-left: 2px;
margin-right: 2px;
display: inline-block;
}
.manage-trigger-control .service-icon {
font-size: 24px;
margin-right: 4px;
vertical-align: middle;
}
.manage-trigger-control .fa-exclamation-triangle {
color: #FCA657;
}
.manage-trigger-control .empty-description {
color: #ccc;
}
.manage-trigger-control .radio {
margin-bottom: 20px;
}
.manage-trigger-control .radio input[type="radio"] {
padding: 4px;
}
.manage-trigger-control .radio label .title {
font-size: 16px;
}
.manage-trigger-control .radio label .weak {
font-weight: 300;
}
.manage-trigger-control .radio label .description {
margin-top: 6px;
color: #aaa;
}
.manage-trigger-control .radio label .extended {
margin-top: 20px;
}
.manage-trigger-control .radio label td:first-child {
vertical-align: top;
padding: 4px;
padding-right: 10px;
}
.manage-trigger-control .regex-match-view {
margin-top: 20px;
}
.manage-trigger-control h3 .fa {
margin-right: 4px;
}
.manage-trigger-control h3.warning {
color: #FCA657;
}
.manage-trigger-control h3.error {
color: #D64456;
}
.manage-trigger-control .success {
color: #2FC98E !important;
}
.manage-trigger-control .nowrap-col {
white-space: nowrap;
}

View file

@ -0,0 +1,36 @@
.regex-match-view-element .match-list {
list-style: none;
overflow: auto;
max-height: 150px;
}
.regex-match-view-element .match-list li {
display: inline-block;
margin-right: 4px;
width: 120px;
padding: 4px;
}
.regex-match-view-element .match-list li .fa {
margin-right: 4px;
vertical-align: middle;
}
.regex-match-view-element .match-list.not-matching li {
color: #aaa;
}
.regex-match-view-element .match-list.matching li {
color: #2fc98e;
}
.regex-match-view-element .match-table td:first-child {
vertical-align: top;
white-space: nowrap;
}
.regex-match-view-element .fa-exclamation-triangle {
margin-right: 4px;
}

View file

@ -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;
}

View file

@ -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;
}

View file

@ -0,0 +1,35 @@
.trigger-setup-element .activated .content {
padding-top: 10px;
padding-bottom: 10px;
}
.trigger-setup-element .activated h3 {
text-align: center;
margin-bottom: 30px;
display: block;
}
.trigger-setup-element .button-bar {
text-align: right;
margin-top: 16px;
}
.trigger-setup-element .activating .cor-loader-inline {
margin-right: 6px;
}
.trigger-setup-element .activating .btn-success {
display: none;
}
.trigger-setup-element .activating-message {
padding: 10px;
padding-left: 30px;
}
.trigger-setup-element .activating-message b {
vertical-align: middle;
font-size: 18px;
font-weight: normal;
}

View file

@ -4,9 +4,7 @@
<!-- Credentials -->
<div ng-repeat="credential in trigger.config.credentials">
<p>
{{ credential.name }}:
<div class="copy-box" value="credential.value"></div>
</p>
{{ credential.name }}:
<div class="copy-box" value="credential.value"></div>
</div>
</div>

View file

@ -126,10 +126,8 @@
<tr ng-repeat="trigger in triggers | filter:{'is_active':false}">
<td colspan="5" style="text-align: center">
<span class="cor-loader-inline"></span>
Trigger Setup in progress:
<a ng-click="setupTrigger(trigger)">Resume</a> |
<a ng-click="deleteTrigger(trigger)">Cancel</a>
This build trigger has not had its setup completed:
<a ng-click="deleteTrigger(trigger)">Delete Trigger</a>
</td>
</tr>
@ -185,14 +183,6 @@
build-started="handleBuildStarted(build)">
</div>
<!-- Setup trigger dialog-->
<div class="setup-trigger-dialog"
repository="repository"
trigger="currentSetupTrigger"
canceled="cancelSetupTrigger(trigger)"
counter="showTriggerSetupCounter"
trigger-runner="askRunTrigger(trigger)"></div>
<!-- Manual trigger dialog -->
<div class="manual-trigger-build-dialog"
repository="repository"
@ -201,5 +191,4 @@
build-started="handleBuildStarted(build)"></div>
<!-- /Dialogs -->
</div>

View file

@ -1,133 +0,0 @@
<div class="setup-trigger-directive-element">
<!-- Modal message dialog -->
<div class="modal fade" id="setupTriggerModal">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-hidden="true">&times;</button>
<h4 class="modal-title">Setup new build trigger</h4>
</div>
<div class="modal-body loading" ng-show="currentView == 'activating'">
<span class="cor-loader-inline"></span> Setting up trigger...
</div>
<div class="modal-body" ng-show="currentView != 'activating'">
<!-- Trigger-specific setup -->
<div class="trigger-description-element trigger-option-section" ng-switch on="trigger.service">
<div ng-switch-when="custom-git">
<div class="trigger-setup-custom" repository="repository" trigger="trigger"
next-step-counter="nextStepCounter" current-step-valid="state.stepValid"
analyze="checkAnalyze(isValid)"></div>
</div>
<div ng-switch-default>
<div class="trigger-setup-githost" repository="repository" trigger="trigger"
kind="{{ trigger.service }}"
next-step-counter="nextStepCounter" current-step-valid="state.stepValid"
analyze="checkAnalyze(isValid)"></div>
</div>
</div>
<!-- Loading pull information -->
<div ng-show="currentView == 'analyzing'" class="loading">
<span class="cor-loader-inline"></span> Checking pull credential requirements...
</div>
<!-- Pull information -->
<div class="trigger-option-section" ng-show="currentView == 'analyzed'">
<!-- Messaging -->
<div ng-switch on="pullInfo.analysis.status">
<div ng-switch-when="error" class="alert alert-danger">{{ pullInfo.analysis.message }}</div>
<div ng-switch-when="warning" class="alert alert-warning">{{ pullInfo.analysis.message }}</div>
<div ng-switch-when="notimplemented" class="alert alert-warning">
<p>For {{ TriggerService.getTitle(trigger.service) }} triggers, we are unable to determine dependencies automatically.</p>
<p>If the git repository being built depends on a private base image, you must manually select a robot account with the proper permissions.</p>
</div>
</div>
<div class="dockerfile-found" ng-if="pullInfo.analysis.is_public === false">
<div class="dockerfile-found-content">
A robot account is <strong>required</strong> for this build trigger because the
Dockerfile found
pulls from the private <span class="registry-name"></span> repository
<a href="/repository/{{ pullInfo.analysis.namespace }}/{{ pullInfo.analysis.name }}" ng-safenewtab>
{{ pullInfo.analysis.namespace }}/{{ pullInfo.analysis.name }}
</a>
</div>
</div>
<div style="margin-bottom: 12px">
Please select the credentials to use when pulling the base image:
</div>
<div ng-if="!isNamespaceAdmin(repository.namespace)" style="color: #aaa;">
<strong>Note:</strong> In order to set pull credentials for a build trigger, you must be an
Administrator of the namespace <strong>{{ repository.namespace }}</strong>
</div>
<!-- Namespace admin -->
<div ng-show="isNamespaceAdmin(repository.namespace)">
<!-- Select credentials -->
<div class="btn-group btn-group-sm">
<button type="button" class="btn btn-default"
ng-class="pullInfo.is_public ? 'active btn-info' : ''"
ng-click="pullInfo.is_public = true">
None
</button>
<button type="button" class="btn btn-default"
ng-class="pullInfo.is_public ? '' : 'active btn-info'"
ng-click="pullInfo.is_public = false">
<i class="fa ci-robot"></i>
Robot account
</button>
</div>
<!-- Robot Select -->
<div ng-show="!pullInfo.is_public" style="margin-top: 10px">
<div class="entity-search" namespace="repository.namespace"
placeholder="'Select robot account for pulling...'"
current-entity="pullInfo.pull_entity"
allowed-entities="['robot']"></div>
<div ng-if="pullInfo.analysis.robots.length" style="margin-top: 20px; margin-bottom: 0px;">
<strong>Note</strong>: We've automatically selected robot account
<span class="entity-reference" entity="pullInfo.analysis.robots[0]"></span>,
since it has access to the private repository.
</div>
<div ng-if="!pullInfo.analysis.robots.length && pullInfo.analysis.name"
style="margin-top: 20px; margin-bottom: 0px;">
<strong>Note</strong>: No robot account currently has access to the private repository. Please create one and/or assign access in the
<a href="/repository/{{ pullInfo.analysis.namespace }}/{{ pullInfo.analysis.name }}/admin" ng-safenewtab>
repository's admin panel.
</a>
</div>
</div>
</div>
</div>
<div class="trigger-option-section" ng-show="currentView == 'postActivation'">
<div ng-if="trigger.config.credentials" class="credentials" trigger="trigger"></div>
<div ng-if="!trigger.config.credentials">
<div class="alert alert-success">The trigger has been successfully created.</div>
</div>
</div>
</div>
<div class="modal-footer" ng-show="currentView != 'activating'">
<button type="button" class="btn btn-primary" ng-disabled="!state.stepValid"
ng-click="nextStepCounter = nextStepCounter + 1"
ng-show="currentView == 'config'">Next</button>
<button type="button" class="btn btn-primary"
ng-disabled="!trigger.$ready || (!pullInfo['is_public'] && !pullInfo['pull_entity'])"
ng-click="activate()"
ng-show="currentView == 'analyzed'">Create Trigger</button>
<button type="button" class="btn btn-success" ng-click="runTriggerNow()"
ng-if="currentView == 'postActivation'">Run Trigger Now</button>
<button type="button" class="btn btn-default" data-dismiss="modal">{{ currentView == 'postActivation' ? 'Done' : 'Cancel' }}</button>
</div>
</div><!-- /.modal-content -->
</div><!-- /.modal-dialog -->
</div><!-- /.modal -->
</div>

View file

@ -1,9 +0,0 @@
<div class="step-view-step-content">
<div ng-show="!loading">
<div ng-transclude></div>
</div>
<div ng-show="loading" class="loading-message">
<span class="cor-loader-inline"></span>
{{ loadMessage }}
</div>
</div>

View file

@ -1,3 +0,0 @@
<div class="step-view-element">
<div class="transcluded" ng-transclude>
</div>

View file

@ -1,40 +0,0 @@
<div class="trigger-setup-custom-element">
<div class="selected-info" ng-show="nextStepCounter > 0">
<table style="width: 100%;">
<tr ng-show="nextStepCounter > 0">
<td width="200px">Repository</td>
<td>{{ state.build_source }}</td>
</tr>
<tr ng-show="nextStepCounter > 1">
<td>Dockerfile Location:</td>
<td>
<div class="dockerfile-location">
<i class="fa fa-folder fa-lg"></i> {{ state.subdir || '/' }}
</div>
</td>
</table>
</div>
<!-- Step view -->
<div class="step-view" next-step-counter="nextStepCounter" current-step-valid="currentStepValid"
steps-completed="stepsCompleted()">
<!-- Git URL Input -->
<!-- TODO(jschorr): make nopLoad(callback) no longer required -->
<div class="step-view-step" complete-condition="trigger['config']['build_source']" load-callback="nopLoad(callback)"
load-message="Loading Git URL Input">
<div style="margin-bottom: 12px;">Please enter an HTTP or SSH style URL used to clone your git repository:</div>
<input class="form-control" type="text" placeholder="git@example.com:namespace/repository.git" style="width: 100%;"
ng-model="state.build_source" ng-pattern="/(((http|https):\/\/)(.+)|\w+@(.+):(.+))/">
</div>
<!-- Dockerfile folder select -->
<div class="step-view-step" complete-condition="trigger.$ready" load-callback="nopLoad(callback)"
load-message="Loading Folder Input">
<div style="margin-bottom: 12px">Dockerfile Location:</div>
<input class="form-control" type="text" placeholder="/" style="width: 100%;"
ng-model="state.subdir" ng-pattern="/^($|\/|\/.+)/">
</div>
</div>
</div>

View file

@ -1,201 +0,0 @@
<div class="trigger-setup-githost-element">
<!-- Current selected info -->
<div class="selected-info" ng-show="nextStepCounter > 0">
<table style="width: 100%;">
<tr ng-show="state.currentRepo && nextStepCounter > 0">
<td width="200px">
Repository:
</td>
<td>
<div class="current-repo">
<i class="dropdown-select-icon org-icon fa" ng-class="scmIcon(kind)"
ng-show="!state.currentRepo.avatar_url"></i>
<img class="dropdown-select-icon org-icon"
ng-src="{{ state.currentRepo.avatar_url ? state.currentRepo.avatar_url : '/static/img/empty.png' }}"
ng-show="state.currentRepo.avatar_url">
{{ state.currentRepo.repo }}
</div>
</td>
</tr>
<tr ng-show="nextStepCounter > 1">
<td>
Branches and Tags:
</td>
<td>
<div class="ref-filter">
<span ng-if="!state.hasBranchTagFilter">(Build All)</span>
<span ng-if="state.hasBranchTagFilter">Regular Expression: <code>{{ state.branchTagFilter }}</code></span>
</div>
</td>
</tr>
<tr ng-show="nextStepCounter > 2">
<td>
Dockerfile Location:
</td>
<td>
<div class="dockerfile-location">
<i class="fa fa-folder fa-lg"></i> {{ state.currentLocation || '(Repository Root)' }}
</div>
</td>
</tr>
</table>
</div>
<!-- Step view -->
<div class="step-view" next-step-counter="nextStepCounter" current-step-valid="currentStepValid"
steps-completed="stepsCompleted()">
<!-- Repository select -->
<div class="step-view-step" complete-condition="state.currentRepo" load-callback="loadRepositories(callback)"
load-message="Loading Repositories">
<div style="margin-bottom: 12px">Please choose the repository that will trigger the build:</div>
<div class="dropdown-select" placeholder="'Enter or select a repository'" selected-item="state.currentRepo"
lookahead-items="repoLookahead" allow-custom-input="true">
<!-- Icons -->
<i class="dropdown-select-icon none-icon fa fa-lg" ng-class="scmIcon(kind)"></i>
<img class="dropdown-select-icon org-icon"
ng-show="state.currentRepo.avatar_url"
ng-src="{{ state.currentRepo.avatar_url ? state.currentRepo.avatar_url : '/static/img/empty.png' }}">
<i class="dropdown-select-icon org-icon fa fa-lg" ng-class="scmIcon(kind)"
ng-show="!state.currentRepo.avatar_url"></i>
<!-- Dropdown menu -->
<ul class="dropdown-select-menu scrollable-menu" role="menu">
<li ng-repeat-start="org in orgs" role="presentation" class="dropdown-header org-header">
<img ng-src="{{ org.info.avatar_url }}" class="org-icon">{{ org.info.name }}
</li>
<li ng-repeat="repo in org.repos" class="trigger-repo-listing">
<a ng-click="selectRepo(repo, org)">
<i class="fa fa-lg" ng-class="scmIcon(kind)"></i> {{ repo }}
</a>
</li>
<li role="presentation" class="divider" ng-repeat-end ng-show="$index < orgs.length - 1"></li>
</ul>
</div>
</div>
<!-- Branch/Tag filter/select -->
<div class="step-view-step" complete-condition="!state.hasBranchTagFilter || state.branchTagFilter"
load-callback="loadBranchesAndTags(callback)"
load-message="Loading Branches and Tags">
<div style="margin-bottom: 12px">Please choose the branches and tags to which this trigger will apply:</div>
<div style="margin-left: 20px;">
<div class="btn-group btn-group-sm" style="margin-bottom: 12px">
<button type="button" class="btn btn-default"
ng-class="state.hasBranchTagFilter ? '' : 'active btn-info'" ng-click="state.hasBranchTagFilter = false">
All Branches and Tags
</button>
<button type="button" class="btn btn-default"
ng-class="state.hasBranchTagFilter ? 'active btn-info' : ''" ng-click="state.hasBranchTagFilter = true">
Matching Regular Expression
</button>
</div>
<div ng-show="state.hasBranchTagFilter" style="margin-top: 10px;">
<form>
<div class="form-group">
<div class="input-group">
<input class="form-control" type="text" ng-model="state.branchTagFilter"
placeholder="(Regular expression. Examples: heads/branchname, tags/tagname)" required>
<div class="dropdown input-group-addon">
<button class="btn btn-default dropdown-toggle" type="button" data-toggle="dropdown">
<span class="caret"></span>
</button>
<ul class="dropdown-menu pull-right">
<li><a ng-click="state.branchTagFilter = 'heads/.+'">
<i class="fa fa-code-fork"></i>All Branches</a>
</li>
<li><a ng-click="state.branchTagFilter = 'tags/.+'">
<i class="fa fa-tag"></i>All Tags</a>
</li>
</ul>
</div>
</div>
</div>
</form>
<div style="margin-top: 10px">
<div class="ref-matches" ng-if="branchNames.length">
<span class="kind">Branches:</span>
<ul class="matching-refs branches">
<li ng-repeat="branchName in branchNames | limitTo:20"
class="ref-reference"
ng-class="isMatching('heads', branchName, state.branchTagFilter) ? 'match' : 'not-match'">
<span ng-click="addRef('heads', branchName)" ng-safenewtab>
{{ branchName }}
</span>
</li>
</ul>
<span ng-if="branchNames.length > 20">...</span>
</div>
<div class="ref-matches" ng-if="tagNames.length">
<span class="kind">Tags:</span>
<ul class="matching-refs tags">
<li ng-repeat="tagName in tagNames | limitTo:20"
class="ref-reference"
ng-class="isMatching('tags', tagName, state.branchTagFilter) ? 'match' : 'not-match'">
<span ng-click="addRef('tags', tagName)" ng-safenewtab>
{{ tagName }}
</span>
</li>
</ul>
<span ng-if="tagNames.length > 20">...</span>
</div>
<div ng-if="state.branchTagFilter && !branchNames.length"
style="margin-top: 10px">
<strong>Warning:</strong> No branches found
</div>
</div>
</div>
</div>
</div>
<!-- Dockerfile folder select -->
<div class="step-view-step" complete-condition="trigger.$ready" load-callback="loadLocations(callback)"
load-message="Loading Folders">
<div style="margin-bottom: 12px">Dockerfile Location:</div>
<div class="dropdown-select" placeholder="'(Repository Root)'" selected-item="state.currentLocation"
lookahead-items="locations" handle-input="handleLocationInput(input)"
handle-item-selected="handleLocationSelected(datum)"
allow-custom-input="true"
hide-dropdown="!supportsFullListing">
<!-- Icons -->
<i class="dropdown-select-icon none-icon fa fa-folder-o fa-lg" ng-show="state.isInvalidLocation"></i>
<i class="dropdown-select-icon none-icon fa fa-folder fa-lg" style="color: black;" ng-show="!state.isInvalidLocation"></i>
<i class="dropdown-select-icon fa fa-folder fa-lg"></i>
<!-- Dropdown menu -->
<ul class="dropdown-select-menu" role="menu">
<li ng-repeat="location in locations">
<a ng-click="setLocation(location)" ng-if="!location">
<i class="fa fa-github fa-lg"></i> Repository Root
</a>
<a ng-click="setLocation(location)" ng-if="location">
<i class="fa fa-folder fa-lg"></i> {{ location }}
</a>
</li>
<li class="dropdown-header" role="presentation" ng-show="!locations.length">
No Dockerfiles found in repository
</li>
</ul>
</div>
<div class="cor-loader" ng-show="!locations && !locationError"></div>
<div class="alert alert-warning" ng-show="locationError">
{{ locationError }}
</div>
<div class="alert alert-warning" ng-show="locations && !locations.length && supportsFullListing">
Warning: No Dockerfiles were found in {{ state.currentRepo.repo }}
</div>
<div class="alert alert-info" ng-show="locations.length && state.isInvalidLocation && supportsFullListing">
Note: The folder does not currently exist or contain a Dockerfile
</div>
</div>
<!-- /step-view -->
</div>
</div>

View file

@ -1,4 +1,4 @@
<span>
<i class="fa fa-git-square fa-lg" style="margin-right: 6px;" data-title="git" bs-tooltip="tooltip.title"></i>
Push to {{ trigger.config.build_source }}
Push to repository {{ trigger.config.build_source }}
</span>

View file

@ -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]+)*)$',

View file

@ -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;
}
};

View file

@ -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; }

View file

@ -0,0 +1,37 @@
<div class="dockerfile-path-select-element">
<div class="dropdown-select" placeholder="'Enter path containing a Dockerfile'"
selected-item="$ctrl.selectedPath"
lookahead-items="$ctrl.paths"
handle-input="$ctrl.setPath(input)"
handle-item-selected="$ctrl.setSelectedPath(datum.value)"
allow-custom-input="true"
hide-dropdown="!$ctrl.supportsFullListing">
<!-- Icons -->
<i class="dropdown-select-icon none-icon fa fa-folder-o fa-lg"
ng-show="$ctrl.isUnknownPath"></i>
<i class="dropdown-select-icon none-icon fa fa-folder fa-lg" style="color: black;"
ng-show="!$ctrl.isUnknownPath"></i>
<i class="dropdown-select-icon fa fa-folder fa-lg"></i>
<!-- Dropdown menu -->
<ul class="dropdown-select-menu pull-right" role="menu">
<li ng-repeat="path in $ctrl.paths">
<a ng-click="$ctrl.setSelectedPath(path)"
ng-if="path">
<i class="fa fa-folder fa-lg"></i> {{ path }}
</a>
</li>
<li class="dropdown-header" role="presentation"
ng-show="!$ctrl.paths.length">
No Dockerfiles found in repository
</li>
</ul>
</div>
<div style="padding: 10px">
<div class="co-alert co-alert-danger"
ng-show="!$ctrl.isValidPath && $ctrl.currentPath">
Path entered for folder containing Dockerfile is invalid: Must start with a '/'.
</div>
</div>
</div>

View file

@ -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);
});
});
});

View file

@ -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;
}
}

View file

@ -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) {

View file

@ -0,0 +1,7 @@
<div class="linear-workflow-section-element"
ng-if="$ctrl.sectionVisible"
ng-class="$ctrl.isCurrentSection ? 'current-section' : ''">
<form ng-submit="$ctrl.onSubmitSection()">
<div ng-transclude />
</form>
</div>

View file

@ -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();
});
});
});

View file

@ -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();
}
}
}

View file

@ -0,0 +1,39 @@
<div class="linear-workflow-element">
<!-- Contents -->
<div ng-transclude />
<div class="bottom-controls">
<table class="upcoming-table">
<tr>
<td>
<!-- Next button -->
<button class="btn btn-primary"
ng-disabled="!$ctrl.currentSection.component.sectionValid"
ng-click="$ctrl.onNextSection()"
ng-class="{
'btn-success': $ctrl.currentSection.index == $ctrl.sections.length - 1,
'btn-lg': $ctrl.currentSection.index == $ctrl.sections.length - 1
}">
<span ng-if="$ctrl.currentSection.index != sections.length - 1">Continue</span>
<span ng-if="$ctrl.currentSection.index == sections.length - 1">
<i class="fa fa-check-circle"></i>{{ ::$ctrl.doneTitle }}
</span>
</button>
</td>
<td>
<!-- Next sections -->
<div class="upcoming"
ng-if="$ctrl.currentSection.index < $ctrl.sections.length - 1">
<b>Next:</b>
<ul>
<li ng-repeat="section in $ctrl.sections"
ng-if="section.index > $ctrl.currentSection.index">
{{ section.component.sectionTitle }}
</li>
</ul>
</div>
</td>
</tr>
</table>
</div>
</div>

View file

@ -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);
});
});
});
});

View file

@ -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;
}

View file

@ -0,0 +1,44 @@
<div class="manage-trigger-custom-git-element manage-trigger-control">
<linear-workflow
done-title="Create Trigger"
on-workflow-complete="$ctrl.activateTrigger({'config': $ctrl.config})">
<!-- Section: Repository -->
<linear-workflow-section class="row"
section-id="repo"
section-title="Git Repository"
section-valid="$ctrl.config.build_source !== undefined">
<div class="col-lg-7 col-md-7 col-sm-12 main-col">
<h3>Enter repository</h3>
<strong>Please enter the HTTP or SSH style URL used to clone your git repository:</strong>
<input class="form-control" type="text" placeholder="git@example.com:namespace/repository.git"
ng-model="$ctrl.config.build_source"
ng-pattern="/(((http|https):\/\/)(.+)|\w+@(.+):(.+))/">
</div>
<div class="col-lg-5 col-md-5 hidden-sm hidden-xs help-col">
<p>Custom git triggers support any externally accessible git repository, via either the normal git protocol or HTTP.</p>
<p><b>It is the responsibility of the git repository to invoke a webhook to tell <span class="registry-name" short="true"></span> that a commit has been added.</b></p>
</div>
</linear-workflow-section><!-- /Section: Repository -->
<!-- Section: Build context -->
<linear-workflow-section class="row"
section-id="dockerfile"
section-title="Build context"
section-valid="$ctrl.config.subdir">
<div class="col-lg-7 col-md-7 col-sm-12 main-col">
<h3>Select build context directory</h3>
<strong>Please select the build context directory under the git repository:</strong>
<input class="form-control" type="text" placeholder="/"
ng-model="$ctrl.config.subdir"
ng-pattern="/^($|\/|\/.+)/">
</div>
<div class="col-lg-5 col-md-5 hidden-sm hidden-xs help-col">
<p>The build context directory is the path of the directory containing the Dockerfile and any other files to be made available when the build is triggered.</p>
<p>If the Dockerfile is located at the root of the git repository, enter <code>/</code> as the build context directory.</p>
</div>
</linear-workflow-section><!-- /Section: Build context -->
</linear-workflow>
</div>

View file

@ -0,0 +1,14 @@
import { ManageTriggerCustomGitComponent } from './manage-trigger-custom-git.component';
describe("ManageTriggerCustomGitComponent", () => {
var component: ManageTriggerCustomGitComponent;
beforeEach(() => {
component = new ManageTriggerCustomGitComponent();
});
describe("$onChanges", () => {
});
});

View file

@ -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);
}
}
}

View file

@ -0,0 +1,374 @@
<div class="manage-trigger-githost-element manage-trigger-control">
<linear-workflow
done-title="Create Trigger"
on-workflow-complete="$ctrl.createTrigger()">
<!-- Section: Namespace -->
<linear-workflow-section class="row"
section-id="namespace"
section-title="{{ 'Select ' + $ctrl.namespaceTitle }}"
section-valid="$ctrl.local.selectedNamespace">
<div class="col-lg-7 col-md-7 col-sm-12 main-col"
ng-if="$ctrl.local.namespaces">
<h3>Select {{ $ctrl.namespaceTitle }}</h3>
<strong>Please select the {{ $ctrl.namespaceTitle }} under which the repository lives</strong>
<div class="co-top-bar">
<div class="co-filter-box">
<span class="page-controls"
total-count="$ctrl.local.orderedNamespaces.entries.length"
current-page="$ctrl.local.namespaceOptions.page"
page-size="$ctrl.namespacesPerPage"></span>
<input class="form-control" type="text"
ng-model="$ctrl.local.namespaceOptions.filter"
placeholder="Filter {{ $ctrl.namespaceTitle }}s...">
</div>
</div>
<table class="co-table" style="margin-top: 20px;">
<thead>
<td class="checkbox-col"></td>
<td ng-class="$ctrl.TableService.tablePredicateClass('id', $ctrl.local.namespaceOptions.predicate, $ctrl.local.namespaceOptions.reverse)">
<a ng-click="$ctrl.TableService.orderBy('id', $ctrl.local.namespaceOptions)">{{ $ctrl.namespaceTitle }}</a>
</td>
<td ng-class="$ctrl.TableService.tablePredicateClass('score', $ctrl.local.namespaceOptions.predicate, $ctrl.local.namespaceOptions.reverse)"
class="importance-col hidden-xs">
<a ng-click="$ctrl.TableService.orderBy('score', $ctrl.local.namespaceOptions)">Importance</a>
</td>
</thead>
<tr class="co-checkable-row"
ng-repeat="namespace in $ctrl.local.orderedNamespaces.visibleEntries | slice:($ctrl.namespacesPerPage * $ctrl.local.namespaceOptions.page):($ctrl.namespacesPerPage * ($ctrl.local.namespaceOptions.page + 1))"
ng-class="$ctrl.local.selectedNamespace == $ctrl.namespace ? 'checked' : ''"
bindonce>
<td>
<input type="radio"
ng-model="$ctrl.local.selectedNamespace"
ng-value="namespace">
</td>
<td>
<img class="namespace-avatar" ng-src="{{ namespace.avatar_url }}">
<span class="anchor"
href="{{ namespace.url }}"
is-text-only="!namespace.url">{{ namespace.id }}</span>
</td>
<td class="importance-col hidden-xs">
<span class="strength-indicator" value="::namespace.score" maximum="::$ctrl.local.maxScore"
log-base="10"></span>
</td>
</tr>
</table>
<div class="empty"
ng-if="$ctrl.local.namespaces.length && !$ctrl.local.orderedNamespaces.entries.length"
style="margin-top: 20px;">
<div class="empty-primary-msg">No matching {{ $ctrl.namespaceTitle }} found.</div>
<div class="empty-secondary-msg">Try expanding your filtering terms.</div>
</div>
</div>
<div class="col-lg-8 col-md-8 col-sm-12 main-col" ng-if="!$ctrl.local.namespaces">
<span class="cor-loader-inline"></span> Retrieving {{ $ctrl.namespaceTitle }}s
</div>
<div class="col-lg-4 col-md-4 hidden-sm hidden-xs help-col" ng-if="$ctrl.local.namespaces">
<p>
<span class="registry-name"></span> has been granted access to read and view these {{ $ctrl.namespaceTitle }}s.
</p>
<p>
Don't see an expected {{ $ctrl.namespaceTitle }} here? Please make sure third-party access is enabled for <span class="registry-name"></span> under that {{ $ctrl.namespaceTitle }}.
</p>
</div>
</linear-workflow-section><!-- /Section: Namespace -->
<!-- Section: Repository -->
<linear-workflow-section class="row"
section-id="repo"
section-title="Select Repository"
section-valid="$ctrl.local.selectedRepository">
<div class="col-lg-7 col-md-7 col-sm-12 main-col" ng-if="$ctrl.local.repositories">
<h3>Select Repository</h3>
<strong>
Select a repository in
<img class="namespace-avatar" ng-src="{{ $ctrl.local.selectedNamespace.avatar_url }}">
{{ $ctrl.local.selectedNamespace.id }}
</strong>
<div class="co-top-bar">
<div class="co-filter-box">
<span class="page-controls"
total-count="$ctrl.local.orderedRepositories.entries.length"
current-page="$ctrl.local.repositoryOptions.page"
page-size="$ctrl.repositoriesPerPage"></span>
<input class="form-control" type="text"
ng-model="$ctrl.local.repositoryOptions.filter"
placeholder="Filter repositories...">
<div class="filter-options">
<label>
<input type="checkbox"
ng-model="$ctrl.local.repositoryOptions.hideStale">
Hide stale repositories
</label>
</div>
</div>
</div>
<table class="co-table" style="margin-top: 20px;">
<thead>
<td class="checkbox-col"></td>
<td ng-class="$ctrl.TableService.tablePredicateClass('name', $ctrl.local.repositoryOptions.predicate, $ctrl.local.repositoryOptions.reverse)" class="nowrap-col">
<a ng-click="$ctrl.TableService.orderBy('name', $ctrl.local.repositoryOptions)">Repository Name</a>
</td>
<td ng-class="$ctrl.TableService.tablePredicateClass('last_updated_datetime', $ctrl.local.repositoryOptions.predicate, $ctrl.local.repositoryOptions.reverse)"
class="last-updated-col nowrap-col">
<a ng-click="$ctrl.TableService.orderBy('last_updated_datetime', $ctrl.local.namespaceOptions)">Last Updated</a>
</td>
<td class="hidden-xs">Description</td>
</thead>
<tr class="co-checkable-row"
ng-repeat="repository in $ctrl.local.orderedRepositories.visibleEntries | slice:($ctrl.repositoriesPerPage * $ctrl.local.repositoryOptions.page):($ctrl.repositoriesPerPage * ($ctrl.local.repositoryOptions.page + 1))"
ng-class="$ctrl.local.selectedRepository == repository ? 'checked' : ''"
bindonce>
<td>
<span ng-if="!repository.has_admin_permissions">
<i class="fa fa-exclamation-triangle"
data-title="Admin access is required to add the webhook trigger to this repository" bs-tooltip></i>
</span>
<input type="radio"
ng-model="$ctrl.local.selectedRepository"
ng-value="repository"
ng-if="repository.has_admin_permissions">
</td>
<td class="nowrap-col">
<i class="service-icon fa {{ $ctrl.getTriggerIcon() }}"></i>
<span class="anchor"
href="{{ repository.url }}"
is-text-only="!repository.url">{{ repository.name }}</span>
</td>
<td class="last-updated-col nowrap-col">
<span am-time-ago="repository.last_updated_datetime"></span>
</td>
<td class="hidden-xs">
<span ng-if="repository.description">{{ repository.description }}</span>
<span class="empty-description" ng-if="!repository.description">(None)</span>
</td>
</tr>
</table>
<div class="empty"
ng-if="$ctrl.local.repositories.length && !$ctrl.local.orderedRepositories.entries.length"
style="margin-top: 20px;">
<div class="empty-primary-msg">No matching repositories found.</div>
<div class="empty-secondary-msg">Try expanding your filtering terms.</div>
</div>
</div>
<div class="col-lg-8 col-md-8 col-sm-12 main-col"
ng-if="!$ctrl.local.repositories">
<span class="cor-loader-inline"></span> Retrieving repositories
</div>
<div class="col-lg-4 col-md-4 hidden-sm hidden-xs help-col"
ng-if="$ctrl.local.repositories">
<p>
A webhook will be added to the selected repository in order to detect when new commits are made.
</p>
<p>
Don't see an expected repository here? Please make sure you have admin access on that repository.
</p>
</div>
</linear-workflow-section><!-- /Section: Repository -->
<!-- Section: Trigger Options -->
<linear-workflow-section class="row"
section-id="triggeroptions"
section-title="Configure Trigger"
section-valid="$ctrl.local.triggerOptions">
<div class="col-lg-7 col-md-7 col-sm-12 main-col" ng-if="$ctrl.local.repositoryRefs">
<h3>Configure Trigger</h3>
<strong>
Configure trigger options for
<img class="namespace-avatar" ng-src="{{ $ctrl.local.selectedNamespace.avatar_url }}">
{{ $ctrl.local.selectedNamespace.id }}/{{ $ctrl.local.selectedRepository.name }}
</strong>
<div class="radio" style="margin-top: 20px;">
<label>
<input type="radio" name="optionRadio"
ng-model="$ctrl.local.triggerOptions.hasBranchTagFilter"
ng-value="false">
<div class="title">Trigger for all branches and tags <span class="weak">(default)</span></div>
<div class="description">Build a container image for each commit across all branches and tags</div>
</label>
</div>
<div class="radio">
<label>
<input type="radio" name="optionRadio" ng-model="$ctrl.local.triggerOptions.hasBranchTagFilter" ng-value="true">
<div class="title">Trigger only on branches and tags matching a regular expression</div>
<div class="description">Only build container images for a subset of branches and/or tags.</div>
<div class="extended" ng-if="$ctrl.local.triggerOptions.hasBranchTagFilter">
<table>
<tr>
<td style="white-space: nowrap;">Regular Expression:</td>
<td>
<input type="text" class="form-control"
ng-model="$ctrl.local.triggerOptions.branchTagFilter"
required>
<div class="description">Examples: heads/master, tags/tagname, heads/.+</div>
<regex-match-view
items="$ctrl.local.repositoryFullRefs"
regex="$ctrl.local.triggerOptions.branchTagFilter"
ng-if="$ctrl.local.triggerOptions.branchTagFilter"></regex-match-view>
</td>
</tr>
</table>
</div>
</label>
</div>
</div>
<div class="col-lg-8 col-md-8 col-sm-12 main-col"
ng-if="!$ctrl.local.repositoryRefs">
<span class="cor-loader-inline"></span> Retrieving repository refs
</div>
<div class="col-lg-4 col-md-4 hidden-sm hidden-xs help-col">
<p>Do you want to build a new container image for commits across all branches and tags, or limit to a subset?</p>
<p>For example, if you use release branches instead of <code>master</code> for building versions of your software, you can configure the trigger to only build images for these branches.</p>
<p>All images built will be tagged with the name of the branch or tag whose change invoked the trigger</p>
</div>
</linear-workflow-section><!-- /Section: Trigger Options -->
<!-- Section: Dockerfile Location -->
<linear-workflow-section class="row"
section-id="dockerfilelocation"
section-title="Select Dockerfile"
section-valid="$ctrl.local.hasValidDockerfilePath">
<div class="col-lg-7 col-md-7 col-sm-12 main-col"
ng-if="$ctrl.local.dockerfileLocations.status == 'error'">
<div class="co-alert co-alert-warning">
{{ $ctrl.local.dockerfileLocations.message }}
</div>
</div>
<div class="col-lg-7 col-md-7 col-sm-12 main-col" ng-if="$ctrl.local.dockerfileLocations.status == 'success'">
<h3>Select Dockerfile</h3>
<strong>
Please select the location of the Dockerfile to build when this trigger is invoked
</strong>
<dockerfile-path-select
current-path="$ctrl.local.dockerfilePath"
paths="$ctrl.local.dockerfileLocations.subdir"
supports-full-listing="true"
is-valid-path="$ctrl.local.hasValidDockerfilePath"></dockerfile-path-select>
</div>
<div class="col-lg-8 col-md-8 col-sm-12 main-col"
ng-if="!$ctrl.local.dockerfileLocations">
<span class="cor-loader-inline"></span> Retrieving Dockerfile locations
</div>
<div class="col-lg-4 col-md-4 hidden-sm hidden-xs help-col">
<p>Please select the location containing the Dockerfile to be built.</p>
<p>The build context will start at the location selected.</p>
</div>
</linear-workflow-section><!-- /Section: Dockerfile Location -->
<!-- Section: Verification and Robot Account -->
<linear-workflow-section class="row"
section-id="verification"
section-title="Confirm"
section-valid="$ctrl.local.triggerAnalysis.status != 'error' && ($ctrl.local.triggerAnalysis.status != 'requiresrobot' || $ctrl.local.robotAccount != null)">
<!-- Error -->
<div class="col-lg-7 col-md-7 col-sm-12 main-col"
ng-if="$ctrl.local.triggerAnalysis.status == 'error'">
<h3 class="error"><i class="fa fa-exclamation-circle"></i> Verification Error</h3>
<strong>
There was an error when verifying the state of <img class="namespace-avatar" ng-src="{{ $ctrl.local.selectedNamespace.avatar_url }}">
{{ $ctrl.local.selectedNamespace.id }}/{{ $ctrl.local.selectedRepository.name }}
</strong>
{{ $ctrl.local.triggerAnalysis.message }}
</div>
<!-- Warning -->
<div class="col-lg-7 col-md-7 col-sm-12 main-col" ng-if="$ctrl.local.triggerAnalysis.status == 'warning'">
<h3 class="warning"><i class="fa fa-exclamation-triangle"></i> Verification Warning</h3>
{{ $ctrl.local.triggerAnalysis.message }}
</div>
<!-- Public base -->
<div class="col-lg-7 col-md-7 col-sm-12 main-col" ng-if="$ctrl.local.triggerAnalysis.status == 'publicbase'">
<h3 class="success"><i class="fa fa-check-circle"></i> Ready to go!</h3>
<strong>Click "Create Trigger" to complete setup of this build trigger</strong>
</div>
<!-- Requires robot and is not admin -->
<div class="col-lg-7 col-md-7 col-sm-12 main-col" ng-if="$ctrl.local.triggerAnalysis.status == 'requiresrobot' && !$ctrl.local.triggerAnalysis.is_admin">
<h3>Robot Account Required</h3>
<p>The selected Dockerfile in the selected repository depends upon a private base image</p>
<p>A robot account with access to the base image is required to setup this trigger, but you are not the administrator of this namespace.</p>
<p>Administrative access is required to continue to ensure security of the robot credentials.</p>
</div>
<!-- Requires robot and is admin -->
<div class="col-lg-7 col-md-7 col-sm-12 main-col" ng-if="$ctrl.local.triggerAnalysis.status == 'requiresrobot' && $ctrl.local.triggerAnalysis.is_admin">
<h3>Select Robot Account</h3>
<strong>
The selected Dockerfile in the selected repository depends upon a private base image. Select a robot account with access:
</strong>
<div class="co-top-bar">
<div class="co-filter-box">
<span class="page-controls"
total-count="$ctrl.local.orderedRobotAccounts.entries.length"
current-page="$ctrl.local.robotOptions.page"
page-size="$ctrl.robotsPerPage"></span>
<input class="form-control" type="text" ng-model="$ctrl.local.robotOptions.filter" placeholder="Filter robot accounts...">
</div>
</div>
<table class="co-table" style="margin-top: 20px;">
<thead>
<td class="checkbox-col"></td>
<td ng-class="$ctrl.TableService.tablePredicateClass('name', $ctrl.local.robotOptions.predicate, $ctrl.local.robotOptions.reverse)">
<a ng-click="$ctrl.TableService.orderBy('name', $ctrl.local.robotOptions)">Robot Account</a>
</td>
<td ng-class="$ctrl.TableService.tablePredicateClass('can_read', $ctrl.local.robotOptions.predicate, $ctrl.local.robotOptions.reverse)">
<a ng-click="$ctrl.TableService.orderBy('can_read', $ctrl.local.robotOptions)">Has Read Access</a>
</td>
</thead>
<tr class="co-checkable-row"
ng-repeat="robot in $ctrl.local.orderedRobotAccounts.visibleEntries | slice:($ctrl.robotsPerPage * $ctrl.local.namespaceOptions.page):(robotsPerPage * ($ctrl.local.robotOptions.page + 1))"
ng-class="$ctrl.local.robotAccount == robot ? 'checked' : ''"
bindonce>
<td>
<input type="radio"
ng-model="$ctrl.local.robotAccount"
ng-value="robot">
</td>
<td>
<span class="entity-reference" entity="robot"></span>
</td>
<td>
<span ng-if="robot.can_read" class="success">Can Read</span>
<span ng-if="!robot.can_read">Read access will be added if selected</span>
</td>
</tr>
</table>
<div class="empty" ng-if="$ctrl.local.triggerAnalysis.robots.length && !$ctrl.local.orderedRobotAccounts.entries.length"
style="margin-top: 20px;">
<div class="empty-primary-msg">No matching robot accounts found.</div>
<div class="empty-secondary-msg">Try expanding your filtering terms.</div>
</div>
</div>
<div class="col-lg-4 col-md-4 hidden-sm hidden-xs help-col"
ng-if="$ctrl.local.triggerAnalysis.status == 'requiresrobot' && $ctrl.local.triggerAnalysis.is_admin">
<p>The Dockerfile you selected utilizes a private base image.</p>
<p>In order for the <span class="registry-name"></span> to pull the base image during the build process, a robot account with access must be selected.</p>
<p>Robot accounts that already have access to this base image are listed first. If you select a robot account that does not currently have access, read permission will be granted to that robot account on trigger creation.</p>
</div>
</linear-workflow-section><!-- /Section: Robot Account -->
</linear-workflow>
</div>

View file

@ -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
});
});

View file

@ -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;
};

View file

@ -0,0 +1,29 @@
<div class="regex-match-view-element">
<div ng-if="$ctrl.filterMatches($ctrl.regex, $ctrl.items, false) == null">
<i class="fa fa-exclamation-triangle"></i>Invalid Regular Expression!
</div>
<div ng-if="$ctrl.filterMatches($ctrl.regex, $ctrl.items, false) != null">
<table class="match-table">
<tr>
<td>Matching:</td>
<td>
<ul class="matching match-list">
<li ng-repeat="item in $ctrl.filterMatches($ctrl.regex, $ctrl.items, true)">
<i class="fa {{ item.icon }}"></i>{{ item.title }}
</li>
</ul>
</td>
</tr>
<tr>
<td>Not Matching:</td>
<td>
<ul class="not-matching match-list">
<li ng-repeat="item in $ctrl.filterMatches($ctrl.regex, $ctrl.items, false)">
<i class="fa {{ item.icon }}"></i>{{ item.title }}
</li>
</ul>
</td>
</tr>
</table>
</div>
</div>

View file

@ -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);
});
});
});
});

View file

@ -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;
});
}
}

View file

@ -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;
});

View file

@ -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;
});

View file

@ -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;
});

View file

@ -0,0 +1,89 @@
(function() {
/**
* Trigger setup page.
*/
angular.module('quayPages').config(['pages', function(pages) {
pages.create('trigger-setup', 'trigger-setup.html', TriggerSetupCtrl, {
'title': 'Setup build trigger',
'description': 'Setup build trigger',
'newLayout': true
});
}]);
function TriggerSetupCtrl($scope, ApiService, $routeParams, $location, UserService, TriggerService) {
var namespace = $routeParams.namespace;
var name = $routeParams.name;
var trigger_uuid = $routeParams.triggerid;
var loadRepository = function() {
var params = {
'repository': namespace + '/' + name
};
$scope.repositoryResource = ApiService.getRepoAsResource(params).get(function(repo) {
$scope.repository = repo;
});
};
var loadTrigger = function() {
var params = {
'repository': namespace + '/' + name,
'trigger_uuid': trigger_uuid
};
$scope.triggerResource = ApiService.getBuildTriggerAsResource(params).get(function(trigger) {
$scope.trigger = trigger;
});
};
loadTrigger();
loadRepository();
$scope.state = 'managing';
$scope.activateTrigger = function(config, pull_robot) {
$scope.state = 'activating';
var params = {
'repository': namespace + '/' + name,
'trigger_uuid': trigger_uuid
};
var data = {
'config': config
};
if (pull_robot) {
data['pull_robot'] = pull_robot['name'];
}
var errorHandler = ApiService.errorDisplay('Cannot activate build trigger', function(resp) {
$scope.state = 'managing';
return ApiService.getErrorMessage(resp) +
'\n\nNote: Errors can occur if you do not have admin access on the repository';
});
ApiService.activateBuildTrigger(data, params).then(function(resp) {
$scope.trigger['is_active'] = true;
$scope.trigger['config'] = resp['config'];
$scope.trigger['pull_robot'] = resp['pull_robot'];
$scope.trigger['repository_url'] = resp['repository_url'];
$scope.state = 'activated';
// If there are no credentials to display, redirect to the builds tab.
if (!$scope.trigger['config'].credentials) {
$location.url('/repository/' + namespace + '/' + name + '?tab=builds');
}
}, errorHandler);
};
$scope.getTriggerIcon = function() {
if (!$scope.trigger) { return ''; }
return TriggerService.getIcon($scope.trigger.service);
};
$scope.getTriggerId = function() {
if (!trigger_uuid) { return ''; }
return trigger_uuid.split('-')[0];
};
}
}());

View file

@ -0,0 +1,117 @@
import { NgModule } from 'angular-ts-decorators';
import { INJECTED_CONFIG, INJECTED_FEATURES, INJECTED_ENDPOINTS } from "./constants/injected-values.constant";
import { NAME_PATTERNS } from "./constants/name-patterns.constant";
import * as Raven from "raven-js";
import * as angular from 'angular';
var quayDependencies: any[] = [
'chieffancypants.loadingBar',
'cfp.hotkeys',
'angular-tour',
'restangular',
'angularMoment',
'mgcrea.ngStrap',
'ngCookies',
'ngSanitize',
'angular-md5',
'pasvaz.bindonce',
'ansiToHtml',
'core-ui',
'core-config-setup',
'infinite-scroll',
'react'
];
if (INJECTED_CONFIG && (INJECTED_CONFIG.MIXPANEL_KEY ||
INJECTED_CONFIG.MUNCHKIN_KEY ||
INJECTED_CONFIG.GOOGLE_ANALYTICS_KEY)) {
quayDependencies.push('angulartics');
}
if (INJECTED_CONFIG && INJECTED_CONFIG.MIXPANEL_KEY) {
quayDependencies.push('angulartics.mixpanel');
}
if (INJECTED_CONFIG && INJECTED_CONFIG.MUNCHKIN_KEY) {
quayDependencies.push('angulartics.marketo');
}
if (INJECTED_CONFIG && INJECTED_CONFIG.GOOGLE_ANALYTICS_KEY) {
quayDependencies.push('angulartics.google.analytics');
}
if (INJECTED_CONFIG && INJECTED_CONFIG.RECAPTCHA_SITE_KEY) {
quayDependencies.push('vcRecaptcha');
}
/**
* Module for application-wide configuration.
*/
@NgModule({
imports: quayDependencies,
declarations: [],
providers: []
})
export class QuayConfig {
public config($provide: ng.auto.IProvideService,
$injector: ng.auto.IInjectorService,
INJECTED_CONFIG: any,
cfpLoadingBarProvider: any,
$tooltipProvider: any,
$compileProvider: ng.ICompileProvider,
RestangularProvider: any): void {
cfpLoadingBarProvider.includeSpinner = false;
// decorate the tooltip getter
var tooltipFactory: any = $tooltipProvider.$get[$tooltipProvider.$get.length - 1];
$tooltipProvider.$get[$tooltipProvider.$get.length - 1] = function($window: ng.IWindowService) {
if ('ontouchstart' in $window) {
var existing: any = tooltipFactory.apply(this, arguments);
return function(element) {
// Note: We only disable bs-tooltip's themselves. $tooltip is used for other things
// (such as the datepicker), so we need to be specific when canceling it.
if (element !== undefined && element.attr('bs-tooltip') == null) {
return existing.apply(this, arguments);
}
};
}
return tooltipFactory.apply(this, arguments);
};
if (!INJECTED_CONFIG['DEBUG']) {
$compileProvider.debugInfoEnabled(false);
}
// Configure compile provider to add additional URL prefixes to the sanitization list. We use
// these on the Contact page.
$compileProvider.aHrefSanitizationWhitelist(/^\s*(https?|ftp|mailto|tel|irc):/);
// Configure the API provider.
RestangularProvider.setBaseUrl('/api/v1/');
// Configure analytics.
if (INJECTED_CONFIG && INJECTED_CONFIG.MIXPANEL_KEY) {
let $analyticsProvider: any = $injector.get('$analyticsProvider');
$analyticsProvider.virtualPageviews(true);
}
// Configure sentry.
if (INJECTED_CONFIG && INJECTED_CONFIG.SENTRY_PUBLIC_DSN) {
$provide.decorator("$exceptionHandler", function($delegate) {
return function(ex, cause) {
$delegate(ex, cause);
Raven.captureException(ex, {extra: {cause: cause}});
};
});
}
}
}
// TODO: Make injected values into services and move to NgModule.providers, as constants are not supported in Angular 2
angular
.module(QuayConfig.name)
.constant('NAME_PATTERNS', NAME_PATTERNS)
.constant('INJECTED_CONFIG', INJECTED_CONFIG)
.constant('INJECTED_FEATURES', INJECTED_FEATURES)
.constant('INJECTED_ENDPOINTS', INJECTED_ENDPOINTS);

View file

@ -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);

View file

@ -0,0 +1,146 @@
import { RouteBuilderImpl } from './services/route-builder/route-builder.service.impl';
import { RouteBuilder } from './services/route-builder/route-builder.service';
import { PageService } from './services/page/page.service';
import * as ng from '@types/angular';
import { NgModule } from 'angular-ts-decorators';
import { INJECTED_FEATURES } from './constants/injected-values.constant';
import { quayPages } from './quay-pages.module';
/**
* Module containing client-side routing configuration.
*/
@NgModule({
imports: [
quayPages,
'ngRoute',
],
declarations: [],
providers: [],
})
export class QuayRoutes {
public config($routeProvider: ng.route.IRouteProvider,
$locationProvider: ng.ILocationProvider,
pages: PageService): void {
$locationProvider.html5Mode(true);
// WARNING WARNING WARNING
// If you add a route here, you must add a corresponding route in thr endpoints/web.py
// index rule to make sure that deep links directly deep into the app continue to work.
// WARNING WARNING WARNING
var routeBuilder: RouteBuilder = new RouteBuilderImpl($routeProvider, pages.$get());
if (INJECTED_FEATURES.SUPER_USERS) {
// QE Management
routeBuilder.route('/superuser/', 'superuser')
// QE Setup
.route('/setup/', 'setup');
}
routeBuilder
// Repository View
.route('/repository/:namespace/:name', 'repo-view')
.route('/repository/:namespace/:name/tag/:tag', 'repo-view')
// Image View
.route('/repository/:namespace/:name/image/:image', 'image-view')
// Repo Build View
.route('/repository/:namespace/:name/build/:buildid', 'build-view')
// Repo Trigger View
.route('/repository/:namespace/:name/trigger/:triggerid', 'trigger-setup')
// Create repository notification
.route('/repository/:namespace/:name/create-notification', 'create-repository-notification')
// Repo List
.route('/repository/', 'repo-list')
// Organizations
.route('/organizations/', 'organizations')
// New Organization
.route('/organizations/new/', 'new-organization')
// View Organization
.route('/organization/:orgname', 'org-view')
// View Organization Team
.route('/organization/:orgname/teams/:teamname', 'team-view')
// Organization View Application
.route('/organization/:orgname/application/:clientid', 'manage-application')
// View Organization Billing
.route('/organization/:orgname/billing', 'billing')
// View Organization Billing Invoices
.route('/organization/:orgname/billing/invoices', 'invoices')
// View User
.route('/user/:username', 'user-view')
// View User Billing
.route('/user/:username/billing', 'billing')
// View User Billing Invoices
.route('/user/:username/billing/invoices', 'invoices')
// Sign In
.route('/signin/', 'signin')
// New Repository
.route('/new/', 'new-repo')
// Plans
.route('/plans/', 'plans')
// Tutorial
.route('/tutorial/', 'tutorial')
// Contact
.route('/contact/', 'contact')
// About
.route('/about/', 'about')
// Security
.route('/security/', 'security')
// TOS
.route('/tos', 'tos')
// Privacy
.route('/privacy', 'privacy')
// Change username
.route('/updateuser', 'update-user')
// Landing Page
.route('/', 'landing')
// Tour
.route('/tour/', 'tour')
.route('/tour/features', 'tour')
.route('/tour/organizations', 'tour')
.route('/tour/enterprise', 'tour')
// Confirm Invite
.route('/confirminvite', 'confirm-invite')
// Enterprise marketing page
.route('/enterprise', 'enterprise')
// Public Repo Experiments
.route('/__exp/publicRepo', 'public-repo-exp')
// 404/403
.route('/:catchall', 'error-view')
.route('/:catch/:all', 'error-view')
.route('/:catch/:all/:things', 'error-view')
.route('/:catch/:all/:things/:here', 'error-view');
}
}

View file

@ -0,0 +1,179 @@
import { NgModule } from 'angular-ts-decorators';
import { INJECTED_CONFIG, INJECTED_FEATURES, INJECTED_ENDPOINTS } from "./constants/injected-values.constant";
import { NAME_PATTERNS } from "./constants/name-patterns.constant";
import * as angular from 'angular';
var quayDependencies: any[] = [
'chieffancypants.loadingBar',
'cfp.hotkeys',
'angular-tour',
'restangular',
'angularMoment',
'mgcrea.ngStrap',
'ngCookies',
'ngSanitize',
'angular-md5',
'pasvaz.bindonce',
'ansiToHtml',
'core-ui',
'core-config-setup',
'infinite-scroll',
'react'
];
if (INJECTED_CONFIG && (INJECTED_CONFIG.MIXPANEL_KEY ||
INJECTED_CONFIG.MUNCHKIN_KEY ||
INJECTED_CONFIG.GOOGLE_ANALYTICS_KEY)) {
quayDependencies.push('angulartics');
}
if (INJECTED_CONFIG && INJECTED_CONFIG.MIXPANEL_KEY) {
quayDependencies.push('angulartics.mixpanel');
}
if (INJECTED_CONFIG && INJECTED_CONFIG.MUNCHKIN_KEY) {
quayDependencies.push('angulartics.marketo');
}
if (INJECTED_CONFIG && INJECTED_CONFIG.GOOGLE_ANALYTICS_KEY) {
quayDependencies.push('angulartics.google.analytics');
}
if (INJECTED_CONFIG && INJECTED_CONFIG.RECAPTCHA_SITE_KEY) {
quayDependencies.push('vcRecaptcha');
}
/**
* Module for application-wide configuration.
*/
@NgModule({
imports: quayDependencies,
declarations: [],
providers: []
})
export class QuayRun {
public run($rootScope: QuayRunScope,
Restangular: any,
PlanService: any,
$http: ng.IHttpService,
CookieService: any,
Features: any,
$anchorScroll: ng.IAnchorScrollService,
MetaService: any,
INJECTED_CONFIG: any): void {
var defaultTitle = INJECTED_CONFIG['REGISTRY_TITLE'] || 'Quay Container Registry';
// Handle session security.
Restangular.setDefaultRequestParams(['post', 'put', 'remove', 'delete'],
{'_csrf_token': (<any>window).__token || ''});
// Handle session expiration.
Restangular.setErrorInterceptor(function(response) {
if (response !== undefined && response.status == 503) {
(<any>$('#cannotContactService')).modal({});
return false;
}
if (response !== undefined && response.status == 500) {
window.location.href = '/500';
return false;
}
if (response !== undefined && !response.data) {
return true;
}
var invalid_token = response.data['title'] == 'invalid_token' || response.data['error_type'] == 'invalid_token';
if (response !== undefined && response.status == 401 &&
invalid_token &&
response.data['session_required'] !== false) {
(<any>$('#sessionexpiredModal')).modal({});
return false;
}
return true;
});
// Check if we need to redirect based on a previously chosen plan.
var result = PlanService.handleNotedPlan();
// Check to see if we need to show a redirection page.
var redirectUrl = CookieService.get('quay.redirectAfterLoad');
CookieService.clear('quay.redirectAfterLoad');
if (!result && redirectUrl && redirectUrl.indexOf((<any>window).location.href) == 0) {
(<any>window).location = redirectUrl;
return;
}
$rootScope.$watch('description', function(description: string) {
if (!description) {
description = `Hosted private docker repositories. Includes full user management and history.
Free for public repositories.`;
}
// Note: We set the content of the description tag manually here rather than using Angular binding
// because we need the <meta> tag to have a default description that is not of the form "{{ description }}",
// we read by tools that do not properly invoke the Angular code.
$('#descriptionTag').attr('content', description);
});
// Listen for scope changes and update the title and description accordingly.
$rootScope.$watch(function() {
var title = MetaService.getTitle($rootScope.currentPage) || defaultTitle;
$rootScope.title = title;
var description = MetaService.getDescription($rootScope.currentPage) || '';
if ($rootScope.description != description) {
$rootScope.description = description;
}
});
$rootScope.$on('$routeChangeSuccess', function (event, current, previous) {
$rootScope.current = current.$$route;
$rootScope.currentPage = current;
$rootScope.pageClass = '';
if (!current.$$route) { return; }
var pageClass = current.$$route.pageClass || '';
if (typeof pageClass != 'string') {
pageClass = pageClass(Features);
}
$rootScope.pageClass = pageClass;
$rootScope.newLayout = !!current.$$route.newLayout;
$rootScope.fixFooter = !!current.$$route.fixFooter;
$anchorScroll();
});
var initallyChecked: boolean = false;
(<any>window).__isLoading = function() {
if (!initallyChecked) {
initallyChecked = true;
return true;
}
return $http.pendingRequests.length > 0;
};
}
}
interface QuayRunScope extends ng.IRootScopeService {
currentPage: any;
current: any;
title: any;
description: string,
pageClass: any;
newLayout: any;
fixFooter: any;
}
// TODO: Make injected values into services and move to NgModule.providers, as constants are not supported in Angular 2
angular
.module(QuayRun.name)
.constant('NAME_PATTERNS', NAME_PATTERNS)
.constant('INJECTED_CONFIG', INJECTED_CONFIG)
.constant('INJECTED_FEATURES', INJECTED_FEATURES)
.constant('INJECTED_ENDPOINTS', INJECTED_ENDPOINTS);

View file

@ -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}});
};
});
}
}

View file

@ -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);

View file

@ -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');
}

View file

@ -1,143 +0,0 @@
import * as $ from 'jquery';
import * as ng from '@types/angular';
quayRun.$inject = [
'$location',
'$rootScope',
'Restangular',
'UserService',
'PlanService',
'$http',
'$timeout',
'CookieService',
'Features',
'$anchorScroll',
'UtilService',
'MetaService',
'INJECTED_CONFIG',
];
export default function quayRun(
$location: ng.ILocationService,
$rootScope: QuayRunScope,
Restangular: any,
UserService: any,
PlanService: any,
$http: ng.IHttpService,
$timeout: ng.ITimeoutService,
CookieService: any,
Features: any,
$anchorScroll: ng.IAnchorScrollService,
UtilService: any,
MetaService: any,
INJECTED_CONFIG: any) {
var defaultTitle = INJECTED_CONFIG['REGISTRY_TITLE'] || 'Quay Container Registry';
// Handle session security.
Restangular.setDefaultRequestParams(['post', 'put', 'remove', 'delete'],
{'_csrf_token': (<any>window).__token || ''});
// Handle session expiration.
Restangular.setErrorInterceptor(function(response) {
if (response !== undefined && response.status == 503) {
(<any>$('#cannotContactService')).modal({});
return false;
}
if (response !== undefined && response.status == 500) {
window.location.href = '/500';
return false;
}
if (response !== undefined && !response.data) {
return true;
}
var invalid_token = response.data['title'] == 'invalid_token' || response.data['error_type'] == 'invalid_token';
if (response !== undefined && response.status == 401 &&
invalid_token &&
response.data['session_required'] !== false) {
(<any>$('#sessionexpiredModal')).modal({});
return false;
}
return true;
});
// Check if we need to redirect based on a previously chosen plan.
var result = PlanService.handleNotedPlan();
// Check to see if we need to show a redirection page.
var redirectUrl = CookieService.get('quay.redirectAfterLoad');
CookieService.clear('quay.redirectAfterLoad');
if (!result && redirectUrl && redirectUrl.indexOf((<any>window).location.href) == 0) {
(<any>window).location = redirectUrl;
return;
}
$rootScope.$watch('description', function(description: string) {
if (!description) {
description = `Hosted private docker repositories. Includes full user management and history.
Free for public repositories.`;
}
// Note: We set the content of the description tag manually here rather than using Angular binding
// because we need the <meta> tag to have a default description that is not of the form "{{ description }}",
// we read by tools that do not properly invoke the Angular code.
$('#descriptionTag').attr('content', description);
});
// Listen for scope changes and update the title and description accordingly.
$rootScope.$watch(function() {
var title = MetaService.getTitle($rootScope.currentPage) || defaultTitle;
$rootScope.title = title;
var description = MetaService.getDescription($rootScope.currentPage) || '';
if ($rootScope.description != description) {
$rootScope.description = description;
}
});
$rootScope.$on('$routeChangeSuccess', function (event, current, previous) {
$rootScope.current = current.$$route;
$rootScope.currentPage = current;
$rootScope.pageClass = '';
if (!current.$$route) { return; }
var pageClass = current.$$route.pageClass || '';
if (typeof pageClass != 'string') {
pageClass = pageClass(Features);
}
$rootScope.pageClass = pageClass;
$rootScope.newLayout = !!current.$$route.newLayout;
$rootScope.fixFooter = !!current.$$route.fixFooter;
$anchorScroll();
});
var initallyChecked = false;
(<any>window).__isLoading = function() {
if (!initallyChecked) {
initallyChecked = true;
return true;
}
return $http.pendingRequests.length > 0;
};
}
interface QuayRunScope extends ng.IRootScopeService {
currentPage: any;
current: any;
title: any;
description: string,
pageClass: any;
newLayout: any;
fixFooter: any;
}

View file

@ -0,0 +1,22 @@
import { PageServiceImpl } from './page.service.impl';
describe("Service: PageServiceImpl", () => {
var pageServiceImpl: PageServiceImpl;
beforeEach(() => {
pageServiceImpl = new PageServiceImpl();
});
describe("create", () => {
});
describe("get", () => {
});
describe("$get", () => {
});
});

View file

@ -0,0 +1,45 @@
import { Injectable } from 'angular-ts-decorators';
import { PageService } from './page.service';
@Injectable(PageService.name)
export class PageServiceImpl implements ng.IServiceProvider {
private pages: any = {};
constructor() {
}
public create(pageName: string,
templateName: string,
controller?: any,
flags: any = {},
profiles: string[] = ['old-layout', 'layout']): void {
for (var i = 0; i < profiles.length; ++i) {
this.pages[profiles[i] + ':' + pageName] = {
'name': pageName,
'controller': controller,
'templateName': templateName,
'flags': flags
};
}
}
public get(pageName: string, profiles: any[]): any[] | null {
for (var i = 0; i < profiles.length; ++i) {
var current = profiles[i];
var key = current.id + ':' + pageName;
var page = this.pages[key];
if (page) {
return [current, page];
}
}
return null;
}
public $get(): PageService {
return this;
}
}

View file

@ -0,0 +1,32 @@
/**
* Manages the creation and retrieval of pages (route + controller)
*/
export abstract class PageService implements ng.IServiceProvider {
/**
* Create a page.
* @param pageName The name of the page.
* @param templateName The file name of the template.
* @param controller Controller for the page.
* @param flags Additional flags passed to route provider.
* @param profiles Available profiles.
*/
public abstract create(pageName: string,
templateName: string,
controller?: any,
flags?: any,
profiles?: string[]): void;
/**
* Retrieve a registered page.
* @param pageName The name of the page.
* @param profiles Available profiles to search.
*/
public abstract get(pageName: string, profiles: any[]): any[] | null;
/**
* Provide the service instance.
* @return pageService The singleton service instance.
*/
public abstract $get(): PageService;
}

View file

@ -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[];

View file

@ -0,0 +1,68 @@
<div class="resource-view trigger-setup-element"
resources="[repositoryResource, triggerResource]"
error-message="'Build trigger not found'">
<div class="page-content">
<div class="cor-title">
<span class="cor-title-link">
<a class="back-link" href="/repository/{{ repository.namespace }}/{{ repository.name }}?tab=builds">
<i class="fa fa-hdd-o" style="margin-right: 4px"></i>
{{ repository.namespace }}/{{ repository.name }}
</a>
</span>
<span class="cor-title-content">
<i class="fa" ng-class="getTriggerIcon()"></i>
Setup Build Trigger: {{ getTriggerId() }}
</span>
</div>
<div class="co-main-content-panel" ng-show="state != 'activated' && trigger.is_active">
<div class="co-alert co-alert-info">
Trigger has already been activated.
</div>
</div>
<div class="co-main-content-panel" ng-show="state == 'activated' || !trigger.is_active">
<!-- state = activated -->
<div class="activated" ng-if="state == 'activated'">
<div class="row">
<div class="col-md-offset-3 col-md-6 col-sm-12 col-lg-6 content">
<h3>Trigger has been successfully activated</h3>
<div class="credentials" trigger="trigger"></div>
<div class="button-bar">
<a href="/repository/{{ repository.namespace }}/{{ repository.name }}?tab=builds">
Return to {{ repository.namespace }}/{{ repository.name }}
</a>
</div>
</div>
</div>
</div> <!-- /state = activated -->
<!-- state = managing or activating -->
<div ng-if="state == 'managing' || state == 'activating'"
ng-class="{'activating': state == 'activating'}">
<!-- Select the correct flow -->
<div ng-switch on="trigger.service">
<!-- Custom Git -->
<div ng-switch-when="custom-git">
<manage-trigger-custom-git
trigger="trigger"
activate-trigger="activateTrigger(config, pull_robot)"></manage-trigger-custom-git>
</div> <!-- /custom-git -->
<!-- Hosted Git (GitHub, Gitlab, BitBucket) -->
<div ng-switch-default>
<manage-trigger-githost
trigger="trigger"
repository="repository"
activate-trigger="activateTrigger(config, pull_robot)"></manage-trigger-githost>
</div> <!-- /hosted -->
</div> <!-- /ngSwitch -->
<div class="activating-message" ng-show="state == 'activating'">
<div class="cor-loader-inline"></div><b>Completing setup of the build trigger</b>
</div>
</div> <!-- /state = managing -->
</div> <!-- /co-main-content-panel -->
</div>
</div>

Binary file not shown.

View file

@ -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):

View file

@ -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.