Implement new create and manager trigger UI

Implements the new trigger setup user interface, which is now a linear workflow found on its own page, rather than a tiny modal dialog

Fixes #1187
This commit is contained in:
Joseph Schorr 2016-09-27 16:52:34 +02:00
parent 21b09a7451
commit 8e863b8cf5
47 changed files with 1835 additions and 1068 deletions

View file

@ -1,7 +1,10 @@
from abc import ABCMeta, abstractmethod
from jsonschema import validate
from six import add_metaclass
from endpoints.building import PreparedBuild from endpoints.building import PreparedBuild
from data import model from data import model
from buildtrigger.triggerutil import get_trigger_config, InvalidServiceException from buildtrigger.triggerutil import get_trigger_config, InvalidServiceException
from jsonschema import validate
METADATA_SCHEMA = { METADATA_SCHEMA = {
'type': 'object', 'type': 'object',
@ -18,7 +21,7 @@ METADATA_SCHEMA = {
'ref': { 'ref': {
'type': 'string', 'type': 'string',
'description': 'git reference for a git commit', 'description': 'git reference for a git commit',
'pattern': '^refs\/(heads|tags|remotes)\/(.+)$', 'pattern': r'^refs\/(heads|tags|remotes)\/(.+)$',
}, },
'default_branch': { 'default_branch': {
'type': 'string', 'type': 'string',
@ -86,6 +89,7 @@ METADATA_SCHEMA = {
} }
@add_metaclass(ABCMeta)
class BuildTriggerHandler(object): class BuildTriggerHandler(object):
def __init__(self, trigger, override_config=None): def __init__(self, trigger, override_config=None):
self.trigger = trigger self.trigger = trigger
@ -96,72 +100,90 @@ class BuildTriggerHandler(object):
""" Returns the auth token for the trigger. """ """ Returns the auth token for the trigger. """
return self.trigger.auth_token return self.trigger.auth_token
@abstractmethod
def load_dockerfile_contents(self): def load_dockerfile_contents(self):
""" """
Loads the Dockerfile found for the trigger's config and returns them or None if none could Loads the Dockerfile found for the trigger's config and returns them or None if none could
be found/loaded. 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 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): def list_build_subdirs(self):
""" """
Take the auth information and the specified config so far and list all of Take the auth information and the specified config so far and list all of
the possible subdirs containing dockerfiles. 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. Transform the incoming request data into a set of actions. Returns a PreparedBuild.
""" """
raise NotImplementedError pass
@abstractmethod
def is_active(self): def is_active(self):
""" """
Returns True if the current build trigger is active. Inactive means further Returns True if the current build trigger is active. Inactive means further
setup is needed. setup is needed.
""" """
raise NotImplementedError pass
@abstractmethod
def activate(self, standard_webhook_url): def activate(self, standard_webhook_url):
""" """
Activates the trigger for the service, with the given new configuration. Activates the trigger for the service, with the given new configuration.
Returns new public and private config that should be stored if successful. Returns new public and private config that should be stored if successful.
""" """
raise NotImplementedError pass
@abstractmethod
def deactivate(self): def deactivate(self):
""" """
Deactivates the trigger for the service, removing any hooks installed in Deactivates the trigger for the service, removing any hooks installed in
the remote service. Returns the new config that should be stored if this the remote service. Returns the new config that should be stored if this
trigger is going to be re-activated. trigger is going to be re-activated.
""" """
raise NotImplementedError pass
@abstractmethod
def manual_start(self, run_parameters=None): def manual_start(self, run_parameters=None):
""" """
Manually creates a repository build for this trigger. Returns a PreparedBuild. Manually creates a repository build for this trigger. Returns a PreparedBuild.
""" """
raise NotImplementedError pass
@abstractmethod
def list_field_values(self, field_name, limit=None): 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 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. field named "branches", and this method would return all branches.
""" """
raise NotImplementedError pass
@abstractmethod
def get_repository_url(self): def get_repository_url(self):
""" Returns the URL of the current trigger's repository. Note that this operation """ 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. """ can be called in a loop, so it should be as fast as possible. """
raise NotImplementedError pass
@classmethod @classmethod
def service_name(cls): def service_name(cls):

View file

@ -1,6 +1,10 @@
import logging import logging
import re import re
from calendar import timegm
import dateutil.parser
from jsonschema import validate from jsonschema import validate
from buildtrigger.triggerutil import (RepositoryReadException, TriggerActivationException, from buildtrigger.triggerutil import (RepositoryReadException, TriggerActivationException,
TriggerDeactivationException, TriggerStartException, TriggerDeactivationException, TriggerStartException,
@ -217,7 +221,8 @@ def get_transformed_webhook_payload(bb_payload, default_branch=None):
try: try:
validate(bb_payload, BITBUCKET_WEBHOOK_PAYLOAD_SCHEMA) validate(bb_payload, BITBUCKET_WEBHOOK_PAYLOAD_SCHEMA)
except Exception as exc: 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) raise InvalidPayloadException(exc.message)
payload = JSONPathDict(bb_payload) payload = JSONPathDict(bb_payload)
@ -225,8 +230,8 @@ def get_transformed_webhook_payload(bb_payload, default_branch=None):
if not change: if not change:
return None return None
ref = ('refs/heads/' + change['name'] if change['type'] == 'branch' is_branch = change['type'] == 'branch'
else 'refs/tags/' + change['name']) ref = 'refs/heads/' + change['name'] if is_branch else 'refs/tags/' + change['name']
repository_name = payload['repository.full_name'] repository_name = payload['repository.full_name']
target = change['target'] target = change['target']
@ -390,7 +395,7 @@ class BitbucketBuildTrigger(BuildTriggerHandler):
return config return config
def list_build_sources(self): def list_build_source_namespaces(self):
bitbucket_client = self._get_authorized_client() bitbucket_client = self._get_authorized_client()
(result, data, err_msg) = bitbucket_client.get_visible_repositories() (result, data, err_msg) = bitbucket_client.get_visible_repositories()
if not result: if not result:
@ -398,22 +403,40 @@ class BitbucketBuildTrigger(BuildTriggerHandler):
namespaces = {} namespaces = {}
for repo in data: for repo in data:
if not repo['scm'] == 'git':
continue
owner = repo['owner'] owner = repo['owner']
if not owner in namespaces: if owner in namespaces:
namespaces[owner]['score'] = namespaces[owner]['score'] + 1
else:
namespaces[owner] = { namespaces[owner] = {
'personal': owner == self.config.get('username'), 'personal': owner == self.config.get('username'),
'repos': [], 'id': owner,
'info': { 'title': owner,
'name': owner 'avatar_url': repo['logo'],
} 'score': 0,
} }
namespaces[owner]['repos'].append(owner + '/' + repo['slug']) return list(namespaces.values())
return namespaces.values() def list_build_sources_for_namespace(self, namespace):
def repo_view(repo):
last_modified = dateutil.parser.parse(repo['utc_last_updated'])
return {
'name': repo['slug'],
'full_name': '%s/%s' % (repo['owner'], repo['slug']),
'description': repo['description'] or '',
'last_updated': timegm(last_modified.utctimetuple()),
'url': 'https://bitbucket.org/%s/%s' % (repo['owner'], repo['slug']),
'has_admin_permissions': repo['read_only'] is False,
'private': repo['is_private'],
}
bitbucket_client = self._get_authorized_client()
(result, data, err_msg) = bitbucket_client.get_visible_repositories()
if not result:
raise RepositoryReadException('Could not read repository list: ' + err_msg)
return [repo_view(repo) for repo in data if repo['owner'] == namespace]
def list_build_subdirs(self): def list_build_subdirs(self):
config = self.config config = self.config
@ -431,7 +454,7 @@ class BitbucketBuildTrigger(BuildTriggerHandler):
files = set([f['path'] for f in data['files']]) files = set([f['path'] for f in data['files']])
if 'Dockerfile' in files: if 'Dockerfile' in files:
return ['/'] return ['']
return [] return []

View file

@ -212,3 +212,18 @@ class CustomBuildTrigger(BuildTriggerHandler):
def get_repository_url(self): def get_repository_url(self):
return None 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,14 +2,15 @@ import logging
import os.path import os.path
import base64 import base64
from calendar import timegm
from functools import wraps from functools import wraps
from ssl import SSLError from ssl import SSLError
from github import (Github, UnknownObjectException, GithubException, from github import (Github, UnknownObjectException, GithubException,
BadCredentialsException as GitHubBadCredentialsException) BadCredentialsException as GitHubBadCredentialsException)
from jsonschema import validate from jsonschema import validate
from app import app, github_trigger
from buildtrigger.triggerutil import (RepositoryReadException, TriggerActivationException, from buildtrigger.triggerutil import (RepositoryReadException, TriggerActivationException,
TriggerDeactivationException, TriggerStartException, TriggerDeactivationException, TriggerStartException,
EmptyRepositoryException, ValidationRequestException, EmptyRepositoryException, ValidationRequestException,
@ -273,55 +274,57 @@ class GithubBuildTrigger(BuildTriggerHandler):
return config return config
@_catch_ssl_errors @_catch_ssl_errors
def list_build_sources(self): def list_build_source_namespaces(self):
gh_client = self._get_client() gh_client = self._get_client()
usr = gh_client.get_user() usr = gh_client.get_user()
try: # Build the full set of namespaces for the user, starting with their own.
repos = usr.get_repos()
except GithubException:
raise RepositoryReadException('Unable to list user repositories')
namespaces = {} namespaces = {}
has_non_personal = False namespaces[usr.login] = {
'personal': True,
'id': usr.login,
'title': usr.name or usr.login,
'avatar_url': usr.avatar_url,
'score': usr.plan.private_repos if usr.plan else 0,
}
for repository in repos: for org in usr.get_orgs():
namespace = repository.owner.login namespaces[org.name] = {
if not namespace in namespaces: 'personal': False,
is_personal_repo = namespace == usr.login 'id': org.login,
namespaces[namespace] = { 'title': org.name or org.login,
'personal': is_personal_repo, 'avatar_url': org.avatar_url,
'repos': [], 'url': org.html_url,
'info': { 'score': org.plan.private_repos if org.plan else 0,
'name': namespace, }
'avatar_url': repository.owner.avatar_url
}
}
if not is_personal_repo: return list(namespaces.values())
has_non_personal = True
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 gh_client = self._get_client()
# return any non-personal repositories. In that case, we need to lookup the usr = gh_client.get_user()
# repositories manually.
# TODO: Remove this once we no longer support GHE versions <= 2.1 if namespace == usr.login:
if not has_non_personal: return [repo_view(repo) for repo in usr.get_repos() if repo.owner.login == namespace]
for org in usr.get_orgs():
repo_list = [repo.full_name for repo in org.get_repos(type='member')] org = gh_client.get_organization(namespace)
namespaces[org.name] = { if org is None:
'personal': False, return []
'repos': repo_list,
'info': { return [repo_view(repo) for repo in org.get_repos(type='member')]
'name': org.name or org.login,
'avatar_url': org.avatar_url
}
}
entries = list(namespaces.values())
entries.sort(key=lambda e: e['info']['name'])
return entries
@_catch_ssl_errors @_catch_ssl_errors
def list_build_subdirs(self): def list_build_subdirs(self):
@ -357,19 +360,17 @@ class GithubBuildTrigger(BuildTriggerHandler):
source = config['build_source'] source = config['build_source']
path = self.get_dockerfile_path() path = self.get_dockerfile_path()
try: try:
repo = gh_client.get_repo(source)
file_info = repo.get_file_contents(path) 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: except GithubException as ghe:
message = ghe.data.get('message', 'Unable to read Dockerfile: %s' % source) return None
raise RepositoryReadException(message)
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 @_catch_ssl_errors
def list_field_values(self, field_name, limit=None): def list_field_values(self, field_name, limit=None):
@ -535,7 +536,7 @@ class GithubBuildTrigger(BuildTriggerHandler):
logger.debug('GitHub trigger payload %s', payload) logger.debug('GitHub trigger payload %s', payload)
metadata = get_transformed_webhook_payload(payload, default_branch=default_branch, metadata = get_transformed_webhook_payload(payload, default_branch=default_branch,
lookup_user=lookup_user) lookup_user=lookup_user)
prepared = self.prepare_build(metadata) prepared = self.prepare_build(metadata)
# Check if we should skip this build. # Check if we should skip this build.

View file

@ -1,6 +1,10 @@
import logging import logging
from calendar import timegm
from functools import wraps from functools import wraps
import dateutil.parser
from app import app, gitlab_trigger from app import app, gitlab_trigger
from jsonschema import validate from jsonschema import validate
@ -70,6 +74,17 @@ GITLAB_WEBHOOK_PAYLOAD_SCHEMA = {
'required': ['ref', 'checkout_sha', 'repository'], '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): def _catch_timeouts(func):
@wraps(func) @wraps(func)
def wrapper(*args, **kwargs): def wrapper(*args, **kwargs):
@ -82,6 +97,27 @@ def _catch_timeouts(func):
return wrapper 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, def get_transformed_webhook_payload(gl_payload, default_branch=None, lookup_user=None,
lookup_commit=None): lookup_commit=None):
""" Returns the Gitlab webhook JSON payload transformed into our own payload """ Returns the Gitlab webhook JSON payload transformed into our own payload
@ -223,35 +259,57 @@ class GitLabBuildTrigger(BuildTriggerHandler):
config.pop('key_id', None) config.pop('key_id', None)
self.config = config self.config = config
return config return config
@_catch_timeouts @_catch_timeouts
def list_build_sources(self): def list_build_source_namespaces(self):
gl_client = self._get_authorized_client() gl_client = self._get_authorized_client()
current_user = gl_client.currentuser() current_user = gl_client.currentuser()
if current_user is False: if current_user is False:
raise RepositoryReadException('Unable to get current user') raise RepositoryReadException('Unable to get current user')
repositories = gl_client.getprojects()
if repositories is False:
raise RepositoryReadException('Unable to list user repositories')
namespaces = {} namespaces = {}
repositories = _paginated_iterator(gl_client.getprojects, RepositoryReadException)
for repo in repositories: for repo in repositories:
owner = repo['namespace']['name'] namespace = repo['namespace']
if not owner in namespaces: namespace_id = namespace['id']
namespaces[owner] = { 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'], 'personal': owner == current_user['username'],
'repos': [], 'id': namespace['path'],
'info': { 'title': namespace['name'],
'name': owner, 'avatar_url': repo['owner']['avatar_url'],
} 'score': 0,
} }
namespaces[owner]['repos'].append(repo['path_with_namespace']) return list(namespaces.values())
return namespaces.values() @_catch_timeouts
def list_build_sources_for_namespace(self, namespace):
def repo_view(repo):
last_modified = dateutil.parser.parse(repo['last_activity_at'])
has_admin_permission = False
if repo.get('permissions'):
access_level = repo['permissions']['project_access']['access_level']
has_admin_permission = _ACCESS_LEVEL_MAP.get(access_level, ("", False))[1]
return {
'name': repo['path'],
'full_name': repo['path_with_namespace'],
'description': repo['description'] or '',
'last_updated': timegm(last_modified.utctimetuple()),
'url': repo['web_url'],
'has_admin_permissions': has_admin_permission,
'private': repo['public'] is False,
}
gl_client = self._get_authorized_client()
repositories = _paginated_iterator(gl_client.getprojects, RepositoryReadException)
return [repo_view(repo) for repo in repositories if repo['namespace']['path'] == namespace]
@_catch_timeouts @_catch_timeouts
def list_build_subdirs(self): def list_build_subdirs(self):
@ -280,7 +338,7 @@ class GitLabBuildTrigger(BuildTriggerHandler):
for node in repo_tree: for node in repo_tree:
if node['name'] == 'Dockerfile': if node['name'] == 'Dockerfile':
return ['/'] return ['']
return [] return []

View file

@ -328,6 +328,11 @@ def delete_robot(robot_username):
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): def _list_entity_robots(entity_name):
""" Return the list of robots for the specified entity. This MUST return a query, not a """ 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. materialized list so that callers can use db_for_update.

View file

@ -127,7 +127,7 @@ class BuildTriggerSubdirs(RepositoryParamResource):
try: try:
subdirs = handler.list_build_subdirs() subdirs = handler.list_build_subdirs()
return { return {
'subdir': subdirs, 'subdir': ['/' + subdir for subdir in subdirs],
'status': 'success' 'status': 'success'
} }
except EmptyRepositoryException as exc: except EmptyRepositoryException as exc:
@ -288,8 +288,9 @@ class BuildTriggerAnalyze(RepositoryParamResource):
contents = handler.load_dockerfile_contents() contents = handler.load_dockerfile_contents()
if not contents: if not contents:
return { return {
'status': 'error', 'status': 'warning',
'message': 'Could not read the Dockerfile for the trigger' 'message': 'Specified Dockerfile path for the trigger was not found on the main ' +
'branch. This trigger may fail.',
} }
# Parse the contents of the Dockerfile. # 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) '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 # If the base image is public, mark it as such.
# usage of a robot account to conduct the pull. if found_repository.visibility.name == 'public':
read_robots = [] return {
'status': 'publicbase'
}
# Otherwise, retrieve the list of robots and mark whether they have read access already.
robots = []
if AdministerOrganizationPermission(base_namespace).can(): 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): def robot_view(robot):
return { return {
'name': robot.username, 'name': robot.username,
'kind': 'user', 'kind': 'user',
'is_robot': True 'is_robot': True,
'can_read': robot.id in user_ids_with_permission,
} }
def is_valid_robot(user): robots = [robot_view(robot) for robot in model.user.list_namespace_robots(base_namespace)]
# 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)]
return { return {
'namespace': base_namespace, 'namespace': base_namespace,
'name': base_repository, 'name': base_repository,
'is_public': found_repository.visibility.name == 'public', 'robots': robots,
'robots': read_robots, 'status': 'requiresrobot',
'status': 'analyzed' 'is_admin': AdministerOrganizationPermission(base_namespace).can(),
} }
except RepositoryReadException as rre: except RepositoryReadException as rre:
return { return {
'status': 'error', 'status': 'error',
'message': rre.message 'message': 'Could not analyze the repository: %s' % rre.message,
} }
except NotImplementedError: except NotImplementedError:
return { return {
@ -502,8 +501,54 @@ class BuildTriggerFieldValues(RepositoryParamResource):
@internal_only @internal_only
class BuildTriggerSources(RepositoryParamResource): class BuildTriggerSources(RepositoryParamResource):
""" Custom verb to fetch the list of build sources for the trigger config. """ """ 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 @require_repo_admin
@nickname('listTriggerBuildSources') @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): def get(self, namespace_name, repo_name, trigger_uuid):
""" List the build sources for the trigger configuration thus far. """ """ List the build sources for the trigger configuration thus far. """
try: try:
@ -517,7 +562,7 @@ class BuildTriggerSources(RepositoryParamResource):
try: try:
return { return {
'sources': handler.list_build_sources() 'namespaces': handler.list_build_source_namespaces()
} }
except RepositoryReadException as rre: except RepositoryReadException as rre:
raise InvalidRequest(rre.message) raise InvalidRequest(rre.message)

View file

@ -40,8 +40,7 @@ def attach_bitbucket_build_trigger(trigger_uuid):
repository = trigger.repository.name repository = trigger.repository.name
repo_path = '%s/%s' % (namespace, repository) repo_path = '%s/%s' % (namespace, repository)
full_url = '%s%s%s' % (url_for('web.repository', path=repo_path), '?tab=builds&newtrigger=', full_url = url_for('web.buildtrigger', path=repo_path, trigger=trigger.uuid)
trigger.uuid)
logger.debug('Redirecting to full url: %s', full_url) logger.debug('Redirecting to full url: %s', full_url)
return redirect(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()) trigger = model.build.create_build_trigger(repo, 'github', token, current_user.db_user())
repo_path = '%s/%s' % (namespace_name, repo_name) repo_path = '%s/%s' % (namespace_name, repo_name)
full_url = '%s%s%s' % (url_for('web.repository', path=repo_path), '?tab=builds&newtrigger=', full_url = url_for('web.buildtrigger', path=repo_path, trigger=trigger.uuid)
trigger.uuid)
logger.debug('Redirecting to full url: %s', full_url) logger.debug('Redirecting to full url: %s', full_url)
return redirect(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()) trigger = model.build.create_build_trigger(repo, 'gitlab', token, current_user.db_user())
repo_path = '%s/%s' % (namespace, repository) repo_path = '%s/%s' % (namespace, repository)
full_url = '%s%s%s' % (url_for('web.repository', path=repo_path), '?tab=builds&newtrigger=', full_url = url_for('web.buildtrigger', path=repo_path, trigger=trigger.uuid)
trigger.uuid)
logger.debug('Redirecting to full url: %s', full_url) logger.debug('Redirecting to full url: %s', full_url)
return redirect(full_url) return redirect(full_url)

View file

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

View file

@ -1397,16 +1397,38 @@ a:focus {
margin-bottom: 6px; 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; width: 300px;
display: inline-block; display: inline-block;
vertical-align: middle; 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 { .co-top-bar .co-filter-box input {
vertical-align: top; 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 { .empty {
border-bottom: none !important; 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 --> <!-- Credentials -->
<div ng-repeat="credential in trigger.config.credentials"> <div ng-repeat="credential in trigger.config.credentials">
<p> {{ credential.name }}:
{{ credential.name }}: <div class="copy-box" value="credential.value"></div>
<div class="copy-box" value="credential.value"></div>
</p>
</div> </div>
</div> </div>

View file

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

View file

@ -0,0 +1,6 @@
<div class="linear-workflow-section-element" ng-show="sectionVisible"
ng-class="isCurrentSection ? 'current-section' : ''">
<form ng-submit="submitSection()">
<div ng-transclude />
</form>
</div>

View file

@ -0,0 +1,31 @@
<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="!currentSection.valid"
ng-click="nextSection()"
ng-class="{'btn-success': currentSection.index == sections.length - 1, 'btn-lg': currentSection.index == sections.length - 1}">
<span ng-if="currentSection.index != sections.length - 1">Continue</span>
<span ng-if="currentSection.index == sections.length - 1"><i class="fa fa-check-circle"></i>{{ doneTitle }}</span>
</button>
</td>
<td>
<!-- Next sections -->
<div class="upcoming" ng-if="currentSection.index != sections.length - 1">
<b>Next:</b>
<ul>
<li ng-repeat="section in sections" ng-if="section.index > currentSection.index">
{{ section.title }}
</li>
</ul>
</div>
</td>
</tr>
</table>
</div>
</div>

View file

@ -0,0 +1,43 @@
<div class="manage-trigger-custom-git-element manage-trigger-control">
<div class="linear-workflow" workflow-state="currentState" done-title="Create Trigger"
workflow-complete="activateTrigger({'config': config})">
<!-- Section: Repository -->
<div class="linear-workflow-section row"
section-id="repo"
section-title="Git Repository"
section-valid="config.build_source">
<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="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>
</div><!-- /Section: Repository -->
<!-- Section: Build context -->
<div class="linear-workflow-section row"
section-id="dockerfile"
section-title="Build context"
section-valid="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="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>
</div><!-- /Section: Build context -->
</div>

View file

@ -0,0 +1,330 @@
<div class="manage-trigger-githost-element manage-trigger-control">
<div class="linear-workflow" workflow-state="currentState" done-title="Create Trigger"
workflow-complete="createTrigger()">
<!-- Section: Namespace -->
<div class="linear-workflow-section row"
section-id="namespace"
section-title="{{ 'Select ' + namespaceTitle }}"
section-valid="local.selectedNamespace">
<div class="col-lg-7 col-md-7 col-sm-12 main-col" ng-show="local.namespaces">
<h3>Select {{ namespaceTitle }}</h3>
<strong>
Please select the {{ namespaceTitle }} under which the repository lives
</strong>
<div class="co-top-bar">
<div class="co-filter-box">
<span class="page-controls" total-count="local.orderedNamespaces.entries.length" current-page="local.namespaceOptions.page" page-size="namespacesPerPage"></span>
<input class="form-control" type="text" ng-model="local.namespaceOptions.filter" placeholder="Filter {{ namespaceTitle }}s...">
</div>
</div>
<table class="co-table" style="margin-top: 20px;">
<thead>
<td class="checkbox-col"></td>
<td ng-class="TableService.tablePredicateClass('id', local.namespaceOptions.predicate, local.namespaceOptions.reverse)">
<a ng-click="TableService.orderBy('id', local.namespaceOptions)">{{ namespaceTitle }}</a>
</td>
<td ng-class="TableService.tablePredicateClass('score', local.namespaceOptions.predicate, local.namespaceOptions.reverse)"
class="importance-col hidden-xs">
<a ng-click="TableService.orderBy('score', local.namespaceOptions)">Importance</a>
</td>
</thead>
<tr class="co-checkable-row"
ng-repeat="namespace in local.orderedNamespaces.visibleEntries | slice:(namespacesPerPage * local.namespaceOptions.page):(namespacesPerPage * (local.namespaceOptions.page + 1))"
ng-class="local.selectedNamespace == namespace ? 'checked' : ''"
bindonce>
<td>
<input type="radio" ng-model="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="::local.maxScore"
log-base="10"></span>
</td>
</tr>
</table>
<div class="empty" ng-if="local.namespaces.length && !local.orderedNamespaces.entries.length"
style="margin-top: 20px;">
<div class="empty-primary-msg">No matching {{ 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-show="!local.namespaces">
<span class="cor-loader-inline"></span> Retrieving {{ namespaceTitle }}s
</div>
<div class="col-lg-4 col-md-4 hidden-sm hidden-xs help-col" ng-show="local.namespaces">
<p>
<span class="registry-name"></span> has been granted access to read and view these {{ namespaceTitle }}s.
</p>
<p>
Don't see an expected {{ namespaceTitle }} here? Please make sure third-party access is enabled for <span class="registry-name"></span> under that {{ namespaceTitle }}.
</p>
</div>
</div><!-- /Section: Namespace -->
<!-- Section: Repository -->
<div class="linear-workflow-section row"
section-id="repo"
section-title="Select Repository"
section-valid="local.selectedRepository">
<div class="col-lg-7 col-md-7 col-sm-12 main-col" ng-show="local.repositories">
<h3>Select Repository</h3>
<strong>
Select a repository in
<img class="namespace-avatar" ng-src="{{ local.selectedNamespace.avatar_url }}">
{{ local.selectedNamespace.id }}
</strong>
<div class="co-top-bar">
<div class="co-filter-box">
<span class="page-controls" total-count="local.orderedRepositories.entries.length" current-page="local.repositoryOptions.page" page-size="repositoriesPerPage"></span>
<input class="form-control" type="text" ng-model="local.repositoryOptions.filter" placeholder="Filter repositories...">
<div class="filter-options">
<label><input type="checkbox" ng-model="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="TableService.tablePredicateClass('name', local.repositoryOptions.predicate, local.repositoryOptions.reverse)" class="nowrap-col">
<a ng-click="TableService.orderBy('name', local.repositoryOptions)">Repository Name</a>
</td>
<td ng-class="TableService.tablePredicateClass('last_updated_datetime', local.repositoryOptions.predicate, local.repositoryOptions.reverse)"
class="last-updated-col nowrap-col">
<a ng-click="TableService.orderBy('last_updated_datetime', local.namespaceOptions)">Last Updated</a>
</td>
<td class="hidden-xs">Description</td>
</thead>
<tr class="co-checkable-row"
ng-repeat="repository in local.orderedRepositories.visibleEntries | slice:(repositoriesPerPage * local.repositoryOptions.page):(repositoriesPerPage * (local.repositoryOptions.page + 1))"
ng-class="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="local.selectedRepository" ng-value="repository"
ng-if="repository.has_admin_permissions">
</td>
<td class="nowrap-col">
<i class="service-icon fa {{ 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="local.repositories.length && !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-show="!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-show="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>
</div><!-- /Section: Repository -->
<!-- Section: Trigger Options -->
<div class="linear-workflow-section row"
section-id="triggeroptions"
section-title="Configure Trigger"
section-valid="local.triggerOptions">
<div class="col-lg-7 col-md-7 col-sm-12 main-col" ng-show="local.repositoryRefs">
<h3>Configure Trigger</h3>
<strong>
Configure trigger options for
<img class="namespace-avatar" ng-src="{{ local.selectedNamespace.avatar_url }}">
{{ local.selectedNamespace.id }}/{{ local.selectedRepository.name }}
</strong>
<div class="radio" style="margin-top: 20px;">
<label>
<input type="radio" name="optionRadio" ng-model="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="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="local.triggerOptions.hasBranchTagFilter">
<table>
<tr>
<td style="white-space: nowrap;">Regular Expression:</td>
<td>
<input type="text" class="form-control" ng-model="local.triggerOptions.branchTagFilter" required>
<div class="description">Examples: heads/master, tags/tagname, heads/.+</div>
<div class="regex-match-view"
items="local.repositoryFullRefs"
regex="local.triggerOptions.branchTagFilter"
ng-if="local.triggerOptions.branchTagFilter"></div>
</td>
</tr>
</table>
</div>
</label>
</div>
</div>
<div class="col-lg-8 col-md-8 col-sm-12 main-col" ng-show="!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>
</div><!-- /Section: Trigger Options -->
<!-- Section: Dockerfile Location -->
<div class="linear-workflow-section row"
section-id="dockerfilelocation"
section-title="Select Dockerfile"
section-valid="local.hasValidDockerfilePath">
<div class="col-lg-7 col-md-7 col-sm-12 main-col" ng-show="local.dockerfileLocations.status == 'error'">
<div class="co-alert co-alert-warning">
{{ local.dockerfileLocations.message }}
</div>
</div>
<div class="col-lg-7 col-md-7 col-sm-12 main-col" ng-show="local.dockerfileLocations.status == 'success'">
<h3>Select Dockerfile</h3>
<strong>
Please select the location of the Dockerfile to build when this trigger is invoked
</strong>
<div class="dockerfile-path-select" current-path="local.dockerfilePath" paths="local.dockerfileLocations.subdir"
supports-full-listing="true" is-valid-path="local.hasValidDockerfilePath"></div>
</div>
<div class="col-lg-8 col-md-8 col-sm-12 main-col" ng-show="!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>
</div><!-- /Section: Dockerfile Location -->
<!-- Section: Verification and Robot Account -->
<div class="linear-workflow-section row"
section-id="verification"
section-title="Confirm"
section-valid="local.triggerAnalysis.status != 'error' && (local.triggerAnalysis.status != 'requiresrobot' || local.robotAccount != null)">
<!-- Error -->
<div class="col-lg-7 col-md-7 col-sm-12 main-col" ng-show="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="{{ local.selectedNamespace.avatar_url }}">
{{ local.selectedNamespace.id }}/{{ local.selectedRepository.name }}
</strong>
{{ local.triggerAnalysis.message }}
</div>
<!-- Warning -->
<div class="col-lg-7 col-md-7 col-sm-12 main-col" ng-show="local.triggerAnalysis.status == 'warning'">
<h3 class="warning"><i class="fa fa-exclamation-triangle"></i> Verification Warning</h3>
{{ local.triggerAnalysis.message }}
</div>
<!-- Public base -->
<div class="col-lg-7 col-md-7 col-sm-12 main-col" ng-show="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-show="local.triggerAnalysis.status == 'requiresrobot' && !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-show="local.triggerAnalysis.status == 'requiresrobot' && 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="local.orderedRobotAccounts.entries.length" current-page="local.robotOptions.page" page-size="robotsPerPage"></span>
<input class="form-control" type="text" ng-model="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="TableService.tablePredicateClass('name', local.robotOptions.predicate, local.robotOptions.reverse)">
<a ng-click="TableService.orderBy('name', local.robotOptions)">Robot Account</a>
</td>
<td ng-class="TableService.tablePredicateClass('can_read', local.robotOptions.predicate, local.robotOptions.reverse)">
<a ng-click="TableService.orderBy('can_read', local.robotOptions)">Has Read Access</a>
</td>
</thead>
<tr class="co-checkable-row"
ng-repeat="robot in local.orderedRobotAccounts.visibleEntries | slice:(robotsPerPage * local.namespaceOptions.page):(robotsPerPage * (local.robotOptions.page + 1))"
ng-class="local.robotAccount == robot ? 'checked' : ''"
bindonce>
<td>
<input type="radio" ng-model="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="local.triggerAnalysis.robots.length && !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="local.triggerAnalysis.status == 'requiresrobot' && 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>
</div><!-- /Section: Robot Account -->
</div>

View file

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

View file

@ -126,10 +126,8 @@
<tr ng-repeat="trigger in triggers | filter:{'is_active':false}"> <tr ng-repeat="trigger in triggers | filter:{'is_active':false}">
<td colspan="5" style="text-align: center"> <td colspan="5" style="text-align: center">
<span class="cor-loader-inline"></span> This build trigger has not had its setup completed:
Trigger Setup in progress: <a ng-click="deleteTrigger(trigger)">Delete Trigger</a>
<a ng-click="setupTrigger(trigger)">Resume</a> |
<a ng-click="deleteTrigger(trigger)">Cancel</a>
</td> </td>
</tr> </tr>
@ -185,14 +183,6 @@
build-started="handleBuildStarted(build)"> build-started="handleBuildStarted(build)">
</div> </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 --> <!-- Manual trigger dialog -->
<div class="manual-trigger-build-dialog" <div class="manual-trigger-build-dialog"
repository="repository" repository="repository"
@ -201,5 +191,4 @@
build-started="handleBuildStarted(build)"></div> build-started="handleBuildStarted(build)"></div>
<!-- /Dialogs --> <!-- /Dialogs -->
</div> </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> <span>
<i class="fa fa-git-square fa-lg" style="margin-right: 6px;" data-title="git" bs-tooltip="tooltip.title"></i> <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> </span>

View file

@ -29,11 +29,9 @@ angular.module('quay').directive('repoPanelBuilds', function () {
$scope.currentFilter = null; $scope.currentFilter = null;
$scope.currentStartTrigger = null; $scope.currentStartTrigger = null;
$scope.currentSetupTrigger = null;
$scope.showBuildDialogCounter = 0; $scope.showBuildDialogCounter = 0;
$scope.showTriggerStartDialogCounter = 0; $scope.showTriggerStartDialogCounter = 0;
$scope.showTriggerSetupCounter = 0;
$scope.triggerCredentialsModalTrigger = null; $scope.triggerCredentialsModalTrigger = null;
$scope.triggerCredentialsModalCounter = 0; $scope.triggerCredentialsModalCounter = 0;
@ -144,16 +142,6 @@ angular.module('quay').directive('repoPanelBuilds', function () {
$scope.triggersResource = ApiService.listBuildTriggersAsResource(params).get(function(resp) { $scope.triggersResource = ApiService.listBuildTriggersAsResource(params).get(function(resp) {
$scope.triggers = resp.triggers; $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.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) { $scope.deleteTrigger = function(trigger, opt_callback) {
if (!trigger) { return; } if (!trigger) { return; }

View file

@ -0,0 +1,54 @@
/**
* An element which displays a list of selectable paths containing Dockerfiles.
*/
angular.module('quay').directive('dockerfilePathSelect', function () {
var directiveDefinitionObject = {
priority: 0,
templateUrl: '/static/directives/dockerfile-path-select.html',
replace: false,
transclude: true,
restrict: 'C',
scope: {
'currentPath': '=currentPath',
'isValidPath': '=?isValidPath',
'paths': '=paths',
'supportsFullListing': '=supportsFullListing'
},
controller: function($scope, $element) {
$scope.isUnknownPath = true;
$scope.selectedPath = null;
var checkPath = function() {
$scope.isUnknownPath = false;
$scope.isValidPath = false;
var path = $scope.currentPath || '';
if (path.length == 0 || path[0] != '/') {
return;
}
$scope.isValidPath = true;
if (!$scope.paths) {
return;
}
$scope.isUnknownPath = $scope.supportsFullListing && $scope.paths.indexOf(path) < 0;
};
$scope.setPath = function(path) {
$scope.currentPath = path;
$scope.selectedPath = null;
};
$scope.setSelectedPath = function(path) {
$scope.currentPath = path;
$scope.selectedPath = path;
};
$scope.$watch('currentPath', checkPath);
$scope.$watch('paths', checkPath);
}
};
return directiveDefinitionObject;
});

View file

@ -28,36 +28,29 @@ angular.module('quay').directive('dropdownSelect', function ($compile) {
} }
$scope.placeholder = $scope.placeholder || ''; $scope.placeholder = $scope.placeholder || '';
$scope.internalItem = null; $scope.lookaheadSetup = false;
// Setup lookahead. // Setup lookahead.
var input = $($element).find('.lookahead-input'); var input = $($element).find('.lookahead-input');
$scope.$watch('clearValue', function(cv) { $scope.$watch('clearValue', function(cv) {
if (cv) { if (cv && $scope.lookaheadSetup) {
$scope.selectedItem = null; $scope.selectedItem = null;
$(input).val(''); $(input).typeahead('val', '');
$(input).typeahead('close');
} }
}); });
$scope.$watch('selectedItem', function(item) { $scope.$watch('selectedItem', function(item) {
if ($scope.selectedItem == $scope.internalItem) { if (item != null && $scope.lookaheadSetup) {
// The item has already been set due to an internal action. $(input).typeahead('val', item.toString());
return; $(input).typeahead('close');
}
if ($scope.selectedItem != null) {
$(input).val(item.toString());
} else {
$(input).val('');
} }
}); });
$scope.$watch('lookaheadItems', function(items) { $scope.$watch('lookaheadItems', function(items) {
$(input).off(); $(input).off();
if (!items) { items = items || [];
return;
}
var formattedItems = []; var formattedItems = [];
for (var i = 0; i < items.length; ++i) { for (var i = 0; i < items.length; ++i) {
@ -80,7 +73,10 @@ angular.module('quay').directive('dropdownSelect', function ($compile) {
}); });
dropdownHound.initialize(); dropdownHound.initialize();
$(input).typeahead({}, { $(input).typeahead({
'hint': false,
'highlight': false
}, {
source: dropdownHound.ttAdapter(), source: dropdownHound.ttAdapter(),
templates: { templates: {
'suggestion': function (datum) { 'suggestion': function (datum) {
@ -92,7 +88,6 @@ angular.module('quay').directive('dropdownSelect', function ($compile) {
$(input).on('input', function(e) { $(input).on('input', function(e) {
$scope.$apply(function() { $scope.$apply(function() {
$scope.internalItem = null;
$scope.selectedItem = null; $scope.selectedItem = null;
if ($scope.handleInput) { if ($scope.handleInput) {
$scope.handleInput({'input': $(input).val()}); $scope.handleInput({'input': $(input).val()});
@ -102,7 +97,6 @@ angular.module('quay').directive('dropdownSelect', function ($compile) {
$(input).on('typeahead:selected', function(e, datum) { $(input).on('typeahead:selected', function(e, datum) {
$scope.$apply(function() { $scope.$apply(function() {
$scope.internalItem = datum['item'] || datum['value'];
$scope.selectedItem = datum['item'] || datum['value']; $scope.selectedItem = datum['item'] || datum['value'];
if ($scope.handleItemSelected) { if ($scope.handleItemSelected) {
$scope.handleItemSelected({'datum': datum}); $scope.handleItemSelected({'datum': datum});
@ -111,6 +105,7 @@ angular.module('quay').directive('dropdownSelect', function ($compile) {
}); });
$rootScope.__dropdownSelectCounter++; $rootScope.__dropdownSelectCounter++;
$scope.lookaheadSetup = true;
}); });
}, },
link: function(scope, element, attrs) { link: function(scope, element, attrs) {

View file

@ -0,0 +1,141 @@
/**
* An element which displays a linear workflow of sections, each completed in order before the next
* step is made visible.
*/
angular.module('quay').directive('linearWorkflow', function () {
var directiveDefinitionObject = {
priority: 0,
templateUrl: '/static/directives/linear-workflow.html',
replace: false,
transclude: true,
restrict: 'C',
scope: {
'workflowState': '=?workflowState',
'workflowComplete': '&workflowComplete',
'doneTitle': '@doneTitle'
},
controller: function($scope, $element, $timeout) {
$scope.sections = [];
$scope.nextSection = function() {
if (!$scope.currentSection.valid) { return; }
var currentIndex = $scope.currentSection.index;
if (currentIndex + 1 >= $scope.sections.length) {
$scope.workflowComplete();
return;
}
$scope.workflowState = $scope.sections[currentIndex + 1].id;
};
this.registerSection = function(sectionScope, sectionElement) {
// Add the section to the list.
var sectionInfo = {
'index': $scope.sections.length,
'id': sectionScope.sectionId,
'title': sectionScope.sectionTitle,
'scope': sectionScope,
'element': sectionElement
};
$scope.sections.push(sectionInfo);
// Add a watch on the `sectionValid` value on the section itself. If/when this value
// changes, we copy it over to the sectionInfo, so that the overall workflow can watch
// the change.
sectionScope.$watch('sectionValid', function(isValid) {
sectionInfo['valid'] = isValid;
if (!isValid) {
// Reset the sections back to this section.
updateState();
}
});
// Bind the `submitSection` callback to move to the next section when the user hits
// enter (which calls this method on the scope via an ng-submit set on a wrapping
// <form> tag).
sectionScope.submitSection = function() {
$scope.nextSection();
};
// Update the state of the workflow to account for the new section.
updateState();
};
var updateState = function() {
// Find the furthest state we can show.
var foundIndex = 0;
var maxValidIndex = -1;
$scope.sections.forEach(function(section, index) {
if (section.id == $scope.workflowState) {
foundIndex = index;
}
if (maxValidIndex == index - 1 && section.valid) {
maxValidIndex = index;
}
});
var minSectionIndex = Math.min(maxValidIndex + 1, foundIndex);
$scope.sections.forEach(function(section, index) {
section.scope.sectionVisible = index <= minSectionIndex;
section.scope.isCurrentSection = false;
});
$scope.workflowState = null;
if (minSectionIndex >= 0 && minSectionIndex < $scope.sections.length) {
$scope.currentSection = $scope.sections[minSectionIndex];
$scope.workflowState = $scope.currentSection.id;
$scope.currentSection.scope.isCurrentSection = true;
// Focus to the first input (if any) in the section.
$timeout(function() {
var inputs = $scope.currentSection.element.find('input');
if (inputs.length == 1) {
inputs.focus();
}
}, 10);
}
};
$scope.$watch('workflowState', updateState);
}
};
return directiveDefinitionObject;
});
/**
* An element which displays a single section in a linear workflow.
*/
angular.module('quay').directive('linearWorkflowSection', function () {
var directiveDefinitionObject = {
priority: 0,
templateUrl: '/static/directives/linear-workflow-section.html',
replace: false,
transclude: true,
restrict: 'C',
require: '^linearWorkflow',
scope: {
'sectionId': '@sectionId',
'sectionTitle': '@sectionTitle',
'sectionValid': '=?sectionValid',
},
link: function($scope, $element, $attrs, $ctrl) {
$ctrl.registerSection($scope, $element);
},
controller: function($scope, $element) {
$scope.$watch('sectionVisible', function(visible) {
if (visible) {
$element.show();
} else {
$element.hide();
}
});
}
};
return directiveDefinitionObject;
});

View file

@ -0,0 +1,27 @@
/**
* An element which displays the setup and management workflow for a custom git trigger.
*/
angular.module('quay').directive('manageTriggerCustomGit', function () {
var directiveDefinitionObject = {
priority: 0,
templateUrl: '/static/directives/manage-trigger-custom-git.html',
replace: false,
transclude: true,
restrict: 'C',
scope: {
'trigger': '=trigger',
'activateTrigger': '&activateTrigger'
},
controller: function($scope, $element) {
$scope.config = {};
$scope.currentState = null;
$scope.$watch('trigger', function(trigger) {
if (trigger) {
$scope.config = trigger['config'] || {};
}
});
}
};
return directiveDefinitionObject;
});

View file

@ -0,0 +1,306 @@
/**
* An element which displays the setup and management workflow for a normal SCM git trigger.
*/
angular.module('quay').directive('manageTriggerGithost', function () {
var directiveDefinitionObject = {
priority: 0,
templateUrl: '/static/directives/manage-trigger-githost.html',
replace: false,
transclude: true,
restrict: 'C',
scope: {
'repository': '=repository',
'trigger': '=trigger',
'activateTrigger': '&activateTrigger'
},
controller: function($scope, $element, ApiService, TableService, TriggerService, RolesService) {
$scope.TableService = TableService;
$scope.config = {};
$scope.local = {};
$scope.currentState = null;
$scope.namespacesPerPage = 10;
$scope.repositoriesPerPage = 10;
$scope.robotsPerPage = 10;
$scope.local.namespaceOptions = {
'filter': '',
'predicate': 'score',
'reverse': false,
'page': 0
};
$scope.local.repositoryOptions = {
'filter': '',
'predicate': 'last_updated',
'reverse': false,
'page': 0,
'hideStale': true
};
$scope.local.robotOptions = {
'filter': '',
'predicate': 'can_read',
'reverse': false,
'page': 0
};
$scope.getTriggerIcon = function() {
return TriggerService.getIcon($scope.trigger.service);
};
$scope.createTrigger = function() {
var config = {
'build_source': $scope.local.selectedRepository.full_name,
'subdir': $scope.local.dockerfilePath.substr(1) // Remove starting /
};
if ($scope.local.triggerOptions.hasBranchTagFilter &&
$scope.local.triggerOptions.branchTagFilter) {
config['branchtag_regex'] = $scope.local.triggerOptions.branchTagFilter;
}
var activate = function() {
$scope.activateTrigger({'config': config, 'pull_robot': $scope.local.robotAccount});
};
if ($scope.local.robotAccount) {
if ($scope.local.robotAccount.can_read) {
activate();
} else {
// Add read permission onto the base repository for the robot and then activate the
// trigger.
var robot_name = $scope.local.robotAccount.name;
RolesService.setRepositoryRole($scope.repository, 'read', 'robot', robot_name, activate);
}
} else {
activate();
}
};
var buildOrderedNamespaces = function() {
if (!$scope.local.namespaces) {
return;
}
var namespaces = $scope.local.namespaces || [];
$scope.local.orderedNamespaces = TableService.buildOrderedItems(namespaces,
$scope.local.namespaceOptions,
['id'],
['score'])
$scope.local.maxScore = 0;
namespaces.forEach(function(namespace) {
$scope.local.maxScore = Math.max(namespace.score, $scope.local.maxScore);
});
};
var loadNamespaces = function() {
$scope.local.namespaces = null;
$scope.local.selectedNamespace = null;
$scope.local.orderedNamespaces = null;
$scope.local.selectedRepository = null;
$scope.local.orderedRepositories = null;
var params = {
'repository': $scope.repository.namespace + '/' + $scope.repository.name,
'trigger_uuid': $scope.trigger.id
};
ApiService.listTriggerBuildSourceNamespaces(null, params).then(function(resp) {
$scope.local.namespaces = resp['namespaces'];
$scope.local.repositories = null;
buildOrderedNamespaces();
}, ApiService.errorDisplay('Could not retrieve the list of ' + $scope.namespaceTitle))
};
var buildOrderedRepositories = function() {
if (!$scope.local.repositories) {
return;
}
var repositories = $scope.local.repositories || [];
repositories.forEach(function(repository) {
repository['last_updated_datetime'] = new Date(repository['last_updated'] * 1000);
});
if ($scope.local.repositoryOptions.hideStale) {
var existingRepositories = repositories;
repositories = repositories.filter(function(repository) {
var older_date = moment(repository['last_updated_datetime']).add(1, 'months');
return !moment().isAfter(older_date);
});
if (existingRepositories.length > 0 && repositories.length == 0) {
repositories = existingRepositories;
}
}
$scope.local.orderedRepositories = TableService.buildOrderedItems(repositories,
$scope.local.repositoryOptions,
['name', 'description'],
[]);
};
var loadRepositories = function(namespace) {
$scope.local.repositories = null;
$scope.local.selectedRepository = null;
$scope.local.repositoryRefs = null;
$scope.local.triggerOptions = {
'hasBranchTagFilter': false
};
$scope.local.orderedRepositories = null;
var params = {
'repository': $scope.repository.namespace + '/' + $scope.repository.name,
'trigger_uuid': $scope.trigger.id
};
var data = {
'namespace': namespace.id
};
ApiService.listTriggerBuildSources(data, params).then(function(resp) {
if (namespace == $scope.local.selectedNamespace) {
$scope.local.repositories = resp['sources'];
buildOrderedRepositories();
}
}, ApiService.errorDisplay('Could not retrieve repositories'));
};
var loadRepositoryRefs = function(repository) {
$scope.local.repositoryRefs = null;
$scope.local.triggerOptions = {
'hasBranchTagFilter': false
};
var params = {
'repository': $scope.repository.namespace + '/' + $scope.repository.name,
'trigger_uuid': $scope.trigger.id,
'field_name': 'refs'
};
var config = {
'build_source': repository.full_name
};
ApiService.listTriggerFieldValues(config, params).then(function(resp) {
if (repository == $scope.local.selectedRepository) {
$scope.local.repositoryRefs = resp['values'];
$scope.local.repositoryFullRefs = resp['values'].map(function(ref) {
var kind = ref.kind == 'branch' ? 'heads' : 'tags';
var icon = ref.kind == 'branch' ? 'fa-code-fork' : 'fa-tag';
return {
'value': kind + '/' + ref.name,
'icon': icon,
'title': ref.name
};
});
}
}, ApiService.errorDisplay('Could not retrieve repository refs'));
};
var loadDockerfileLocations = function(repository) {
$scope.local.dockerfilePath = null;
var params = {
'repository': $scope.repository.namespace + '/' + $scope.repository.name,
'trigger_uuid': $scope.trigger.id
};
var config = {
'build_source': repository.full_name
};
ApiService.listBuildTriggerSubdirs(config, params).then(function(resp) {
if (repository == $scope.local.selectedRepository) {
$scope.local.dockerfileLocations = resp;
}
}, ApiService.errorDisplay('Could not retrieve Dockerfile locations'));
};
var buildOrderedRobotAccounts = function() {
if (!$scope.local.triggerAnalysis || !$scope.local.triggerAnalysis.robots) {
return;
}
var robots = $scope.local.triggerAnalysis.robots;
$scope.local.orderedRobotAccounts = TableService.buildOrderedItems(robots,
$scope.local.robotOptions,
['name'],
[]);
};
var checkDockerfilePath = function(repository, path) {
$scope.local.triggerAnalysis = null;
$scope.local.robotAccount = null;
var params = {
'repository': $scope.repository.namespace + '/' + $scope.repository.name,
'trigger_uuid': $scope.trigger.id
};
var config = {
'build_source': repository.full_name,
'subdir': path.substr(1)
};
var data = {
'config': config
};
ApiService.analyzeBuildTrigger(data, params).then(function(resp) {
$scope.local.triggerAnalysis = resp;
buildOrderedRobotAccounts();
}, ApiService.errorDisplay('Could not analyze trigger'));
};
$scope.$watch('trigger', function(trigger) {
if (trigger && $scope.repository) {
$scope.config = trigger['config'] || {};
$scope.namespaceTitle = 'organization';
$scope.local.selectedNamespace = null;
loadNamespaces();
}
});
$scope.$watch('local.selectedNamespace', function(namespace) {
if (namespace) {
loadRepositories(namespace);
}
});
$scope.$watch('local.selectedRepository', function(repository) {
if (repository) {
loadRepositoryRefs(repository);
loadDockerfileLocations(repository);
}
});
$scope.$watch('local.dockerfilePath', function(path) {
if (path && $scope.local.selectedRepository) {
checkDockerfilePath($scope.local.selectedRepository, path);
}
});
$scope.$watch('local.namespaceOptions.predicate', buildOrderedNamespaces);
$scope.$watch('local.namespaceOptions.reverse', buildOrderedNamespaces);
$scope.$watch('local.namespaceOptions.filter', buildOrderedNamespaces);
$scope.$watch('local.repositoryOptions.predicate', buildOrderedRepositories);
$scope.$watch('local.repositoryOptions.reverse', buildOrderedRepositories);
$scope.$watch('local.repositoryOptions.filter', buildOrderedRepositories);
$scope.$watch('local.repositoryOptions.hideStale', buildOrderedRepositories);
$scope.$watch('local.robotOptions.predicate', buildOrderedRobotAccounts);
$scope.$watch('local.robotOptions.reverse', buildOrderedRobotAccounts);
$scope.$watch('local.robotOptions.filter', buildOrderedRobotAccounts);
}
};
return directiveDefinitionObject;
});

View file

@ -0,0 +1,36 @@
/**
* An element which displays the matches and non-matches for a regular expression against a set of
* items.
*/
angular.module('quay').directive('regexMatchView', function () {
var directiveDefinitionObject = {
priority: 0,
templateUrl: '/static/directives/regex-match-view.html',
replace: false,
transclude: true,
restrict: 'C',
scope: {
'regex': '=regex',
'items': '=items'
},
controller: function($scope, $element) {
$scope.filterMatches = function(regexstr, items, shouldMatch) {
regexstr = regexstr || '.+';
try {
var regex = new RegExp(regexstr);
} catch (ex) {
return null;
}
return items.filter(function(item) {
var value = item['value'];
var m = value.match(regex);
var matches = !!(m && m[0].length == value.length);
return matches == shouldMatch;
});
};
}
};
return directiveDefinitionObject;
});

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

@ -43,6 +43,9 @@ export function routeConfig(
// Repo Build View // Repo Build View
.route('/repository/:namespace/:name/build/:buildid', 'build-view') .route('/repository/:namespace/:name/build/:buildid', 'build-view')
// Repo Trigger View
.route('/repository/:namespace/:name/trigger/:triggerid', 'trigger-setup')
// Create repository notification // Create repository notification
.route('/repository/:namespace/:name/create-notification', 'create-repository-notification') .route('/repository/:namespace/:name/create-notification', 'create-repository-notification')

View file

@ -0,0 +1,65 @@
<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">
<div class="manage-trigger-custom-git" trigger="trigger"
activate-trigger="activateTrigger(config, pull_robot)"></div>
</div> <!-- /custom-git -->
<!-- Hosted Git (GitHub, Gitlab, BitBucket) -->
<div ng-switch-default>
<div class="manage-trigger-githost" trigger="trigger" repository="repository"
activate-trigger="activateTrigger(config, pull_robot)"></div>
</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>

View file

@ -1301,17 +1301,17 @@ class TestBuildTriggerSources831cPublicPublicrepo(ApiTestCase):
ApiTestCase.setUp(self) ApiTestCase.setUp(self)
self._set_url(BuildTriggerSources, trigger_uuid="831C", repository="public/publicrepo") self._set_url(BuildTriggerSources, trigger_uuid="831C", repository="public/publicrepo")
def test_get_anonymous(self): def test_post_anonymous(self):
self._run_test('GET', 401, None, None) self._run_test('POST', 401, None, None)
def test_get_freshuser(self): def test_post_freshuser(self):
self._run_test('GET', 403, 'freshuser', None) self._run_test('POST', 403, 'freshuser', None)
def test_get_reader(self): def test_post_reader(self):
self._run_test('GET', 403, 'reader', None) self._run_test('POST', 403, 'reader', None)
def test_get_devtable(self): def test_post_devtable(self):
self._run_test('GET', 403, 'devtable', None) self._run_test('POST', 403, 'devtable', dict(namespace="foo"))
class TestBuildTriggerSources831cDevtableShared(ApiTestCase): class TestBuildTriggerSources831cDevtableShared(ApiTestCase):
@ -1319,17 +1319,17 @@ class TestBuildTriggerSources831cDevtableShared(ApiTestCase):
ApiTestCase.setUp(self) ApiTestCase.setUp(self)
self._set_url(BuildTriggerSources, trigger_uuid="831C", repository="devtable/shared") self._set_url(BuildTriggerSources, trigger_uuid="831C", repository="devtable/shared")
def test_get_anonymous(self): def test_post_anonymous(self):
self._run_test('GET', 401, None, None) self._run_test('POST', 401, None, None)
def test_get_freshuser(self): def test_post_freshuser(self):
self._run_test('GET', 403, 'freshuser', None) self._run_test('POST', 403, 'freshuser', None)
def test_get_reader(self): def test_post_reader(self):
self._run_test('GET', 403, 'reader', None) self._run_test('POST', 403, 'reader', None)
def test_get_devtable(self): def test_post_devtable(self):
self._run_test('GET', 404, 'devtable', None) self._run_test('POST', 404, 'devtable', dict(namespace="foo"))
class TestBuildTriggerSources831cBuynlargeOrgrepo(ApiTestCase): class TestBuildTriggerSources831cBuynlargeOrgrepo(ApiTestCase):
@ -1337,17 +1337,17 @@ class TestBuildTriggerSources831cBuynlargeOrgrepo(ApiTestCase):
ApiTestCase.setUp(self) ApiTestCase.setUp(self)
self._set_url(BuildTriggerSources, trigger_uuid="831C", repository="buynlarge/orgrepo") self._set_url(BuildTriggerSources, trigger_uuid="831C", repository="buynlarge/orgrepo")
def test_get_anonymous(self): def test_post_anonymous(self):
self._run_test('GET', 401, None, None) self._run_test('POST', 401, None, None)
def test_get_freshuser(self): def test_post_freshuser(self):
self._run_test('GET', 403, 'freshuser', None) self._run_test('POST', 403, 'freshuser', None)
def test_get_reader(self): def test_post_reader(self):
self._run_test('GET', 403, 'reader', None) self._run_test('POST', 403, 'reader', None)
def test_get_devtable(self): def test_post_devtable(self):
self._run_test('GET', 404, 'devtable', None) self._run_test('POST', 404, 'devtable', dict(namespace="foo"))
class TestBuildTriggerSubdirs4i2yPublicPublicrepo(ApiTestCase): class TestBuildTriggerSubdirs4i2yPublicPublicrepo(ApiTestCase):

View file

@ -40,7 +40,8 @@ from endpoints.api.robot import (UserRobotList, OrgRobot, OrgRobotList, UserRobo
RegenerateUserRobot, RegenerateOrgRobot) RegenerateUserRobot, RegenerateOrgRobot)
from endpoints.api.trigger import (BuildTriggerActivate, BuildTriggerSources, BuildTriggerSubdirs, from endpoints.api.trigger import (BuildTriggerActivate, BuildTriggerSources, BuildTriggerSubdirs,
TriggerBuildList, ActivateBuildTrigger, BuildTrigger, TriggerBuildList, ActivateBuildTrigger, BuildTrigger,
BuildTriggerList, BuildTriggerAnalyze, BuildTriggerFieldValues) BuildTriggerList, BuildTriggerAnalyze, BuildTriggerFieldValues,
BuildTriggerSourceNamespaces)
from endpoints.api.repoemail import RepositoryAuthorizedEmail from endpoints.api.repoemail import RepositoryAuthorizedEmail
from endpoints.api.repositorynotification import (RepositoryNotification, from endpoints.api.repositorynotification import (RepositoryNotification,
RepositoryNotificationList, RepositoryNotificationList,
@ -3758,8 +3759,23 @@ class FakeBuildTrigger(BuildTriggerHandler):
def service_name(cls): def service_name(cls):
return 'fakeservice' return 'fakeservice'
def list_build_sources(self): def list_build_source_namespaces(self):
return [{'first': 'source'}, {'second': self.auth_token}] 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): def list_build_subdirs(self):
return [self.auth_token, 'foo', 'bar', self.config['somevalue']] return [self.auth_token, 'foo', 'bar', self.config['somevalue']]
@ -3882,8 +3898,9 @@ class TestBuildTriggers(ApiTestCase):
trigger_uuid=trigger.uuid), trigger_uuid=trigger.uuid),
data={'config': trigger_config}) data={'config': trigger_config})
self.assertEquals('error', analyze_json['status']) self.assertEquals('warning', analyze_json['status'])
self.assertEquals('Could not read the Dockerfile for the trigger', analyze_json['message']) 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. # Analyze the trigger's dockerfile: Second, missing FROM in dockerfile.
trigger_config = {'dockerfile': 'MAINTAINER me'} trigger_config = {'dockerfile': 'MAINTAINER me'}
@ -3943,10 +3960,9 @@ class TestBuildTriggers(ApiTestCase):
trigger_uuid=trigger.uuid), trigger_uuid=trigger.uuid),
data={'config': trigger_config}) data={'config': trigger_config})
self.assertEquals('analyzed', analyze_json['status']) self.assertEquals('requiresrobot', analyze_json['status'])
self.assertEquals('devtable', analyze_json['namespace']) self.assertEquals('devtable', analyze_json['namespace'])
self.assertEquals('complex', analyze_json['name']) self.assertEquals('complex', analyze_json['name'])
self.assertEquals(False, analyze_json['is_public'])
self.assertEquals(ADMIN_ACCESS_USER + '+dtrobot', analyze_json['robots'][0]['name']) 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(trigger.service.name, json['triggers'][0]['service'])
self.assertEquals(False, json['triggers'][0]['is_active']) self.assertEquals(False, json['triggers'][0]['is_active'])
# List the trigger's sources. # List the trigger's source namespaces.
source_json = self.getJsonResponse(BuildTriggerSources, 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', params=dict(repository=ADMIN_ACCESS_USER + '/simple',
trigger_uuid=trigger.uuid)) trigger_uuid=trigger.uuid),
self.assertEquals([{'first': 'source'}, {'second': 'sometoken'}], source_json['sources']) data=dict(namespace='first'))
self.assertEquals([{'name': 'source'}], source_json['sources'])
# List the trigger's subdirs. # List the trigger's subdirs.
subdir_json = self.postJsonResponse(BuildTriggerSubdirs, subdir_json = self.postJsonResponse(BuildTriggerSubdirs,
@ -3980,7 +4003,7 @@ class TestBuildTriggers(ApiTestCase):
trigger_uuid=trigger.uuid), trigger_uuid=trigger.uuid),
data={'somevalue': 'meh'}) data={'somevalue': 'meh'})
self.assertEquals({'status': 'success', 'subdir': ['sometoken', 'foo', 'bar', 'meh']}, self.assertEquals({'status': 'success', 'subdir': ['/sometoken', '/foo', '/bar', '/meh']},
subdir_json) subdir_json)
# Activate the trigger. # Activate the trigger.