Split out callbacks into their own blueprint. Add build trigger DB information and connect it with some APIs. Stub out the UI to allow for generation of triggers. Split out the triggers into a plugin-ish architecture for easily adding new triggers.

This commit is contained in:
jakedt 2014-02-18 15:50:15 -05:00
parent fc4983ed8b
commit b5d4919364
13 changed files with 500 additions and 170 deletions

View file

@ -16,11 +16,13 @@ from endpoints.tags import tags
from endpoints.registry import registry
from endpoints.webhooks import webhooks
from endpoints.realtime import realtime
from endpoints.callbacks import callback
logger = logging.getLogger(__name__)
application.register_blueprint(web)
application.register_blueprint(callback, url_prefix='/oauth2')
application.register_blueprint(index, url_prefix='/v1')
application.register_blueprint(tags, url_prefix='/v1')
application.register_blueprint(registry, url_prefix='/v1')

View file

@ -110,6 +110,19 @@ class Repository(BaseModel):
)
class BuildTriggerService(BaseModel):
name = CharField(index=True)
class RepositoryBuildTrigger(BaseModel):
uuid = CharField(default=uuid_generator)
service = ForeignKeyField(BuildTriggerService, index=True)
repository = ForeignKeyField(Repository, index=True)
connected_user = ForeignKeyField(User)
auth_token = CharField()
config = TextField(default='{}')
class Role(BaseModel):
name = CharField(index=True)
@ -248,4 +261,5 @@ all_models = [User, Repository, Image, AccessToken, Role,
RepositoryPermission, Visibility, RepositoryTag,
EmailConfirmation, FederatedLogin, LoginService, QueueItem,
RepositoryBuild, Team, TeamMember, TeamRole, Webhook,
LogEntryKind, LogEntry, PermissionPrototype]
LogEntryKind, LogEntry, PermissionPrototype, BuildTriggerService,
RepositoryBuildTrigger]

View file

@ -55,6 +55,10 @@ class InvalidWebhookException(DataModelException):
pass
class InvalidBuildTriggerException(DataModelException):
pass
def create_user(username, password, email):
if not validate_email(email):
raise InvalidEmailAddressException('Invalid email address: %s' % email)
@ -1352,27 +1356,75 @@ def delete_webhook(namespace_name, repository_name, public_id):
webhook = get_webhook(namespace_name, repository_name, public_id)
webhook.delete_instance()
return webhook
def list_logs(user_or_organization_name, start_time, end_time, performer = None, repository = None):
joined = LogEntry.select().join(User)
if repository:
joined = joined.where(LogEntry.repository == repository)
if performer:
joined = joined.where(LogEntry.performer == performer)
return joined.where(
User.username == user_or_organization_name,
LogEntry.datetime >= start_time,
LogEntry.datetime < end_time).order_by(LogEntry.datetime.desc())
def list_logs(user_or_organization_name, start_time, end_time, performer=None,
repository=None):
joined = LogEntry.select().join(User)
if repository:
joined = joined.where(LogEntry.repository == repository)
def log_action(kind_name, user_or_organization_name, performer=None, repository=None,
access_token=None, ip=None, metadata={}, timestamp=None):
if performer:
joined = joined.where(LogEntry.performer == performer)
return joined.where(
User.username == user_or_organization_name,
LogEntry.datetime >= start_time,
LogEntry.datetime < end_time).order_by(LogEntry.datetime.desc())
def log_action(kind_name, user_or_organization_name, performer=None,
repository=None, access_token=None, ip=None, metadata={},
timestamp=None):
if not timestamp:
timestamp = datetime.today()
kind = LogEntryKind.get(LogEntryKind.name == kind_name)
account = User.get(User.username == user_or_organization_name)
entry = LogEntry.create(kind=kind, account=account, performer=performer,
repository=repository, access_token=access_token, ip=ip,
metadata_json=json.dumps(metadata), datetime=timestamp)
LogEntry.create(kind=kind, account=account, performer=performer,
repository=repository, access_token=access_token, ip=ip,
metadata_json=json.dumps(metadata), datetime=timestamp)
def create_build_trigger(namespace_name, repository_name, service_name,
auth_token, user):
service = BuildTriggerService.get(name=service_name)
repo = Repository.get(namespace=namespace_name, name=repository_name)
trigger = RepositoryBuildTrigger.create(repository=repo, service=service,
auth_token=auth_token,
connected_user=user)
return trigger
def get_build_trigger(namespace_name, repository_name, trigger_uuid):
try:
return (RepositoryBuildTrigger
.select(RepositoryBuildTrigger, BuildTriggerService, Repository)
.join(BuildTriggerService)
.switch(RepositoryBuildTrigger)
.join(Repository)
.switch(RepositoryBuildTrigger)
.join(User)
.where(RepositoryBuildTrigger.uuid == trigger_uuid,
Repository.namespace == namespace_name,
Repository.name == repository_name)
.get())
except RepositoryBuildTrigger.DoesNotExist:
msg = 'No build trigger with uuid: %s' % trigger_uuid
raise InvalidBuildTriggerException(msg)
def list_build_triggers(namespace_name, repository_name):
return (RepositoryBuildTrigger
.select(RepositoryBuildTrigger, BuildTriggerService, Repository)
.join(BuildTriggerService)
.switch(RepositoryBuildTrigger)
.join(Repository)
.where(Repository.namespace == namespace_name,
Repository.name == repository_name))
def delete_build_trigger(namespace_name, repository_name, trigger_uuid):
trigger = get_build_trigger(namespace_name, repository_name, trigger_uuid)
trigger.delete_instance()

View file

@ -25,7 +25,7 @@ from auth.permissions import (ReadRepositoryPermission,
AdministerOrganizationPermission,
OrganizationMemberPermission,
ViewTeamPermission)
from endpoints.common import common_login
from endpoints.common import common_login, get_route_data
from util.cache import cache_control
from datetime import datetime, timedelta
@ -34,7 +34,6 @@ user_files = app.config['USERFILES']
build_logs = app.config['BUILDLOGS']
logger = logging.getLogger(__name__)
route_data = None
api = Blueprint('api', __name__)
@ -62,37 +61,6 @@ def request_error(exception=None, **kwargs):
return make_response(jsonify(data), 400)
def get_route_data():
global route_data
if route_data:
return route_data
routes = []
for rule in app.url_map.iter_rules():
if rule.endpoint.startswith('api.'):
endpoint_method = app.view_functions[rule.endpoint]
is_internal = '__internal_call' in dir(endpoint_method)
is_org_api = '__user_call' in dir(endpoint_method)
methods = list(rule.methods.difference(['HEAD', 'OPTIONS']))
route = {
'name': rule.endpoint[4:],
'methods': methods,
'path': rule.rule,
'parameters': list(rule.arguments)
}
if is_org_api:
route['user_method'] = endpoint_method.__user_call
routes.append(route)
route_data = {
'endpoints': routes
}
return route_data
def log_action(kind, user_or_orgname, metadata={}, repo=None):
performer = current_user.db_user()
model.log_action(kind, user_or_orgname, performer=performer,
@ -1335,6 +1303,62 @@ def delete_webhook(namespace, repository, public_id):
abort(403) # Permission denied
def trigger_view(trigger):
return {
'service': trigger.service.name,
'config': json.loads(trigger.config),
'id': trigger.uuid,
'connected_user': trigger.connected_user.username,
}
@api.route('/repository/<path:repository>/trigger/<trigger_uuid>',
methods=['GET'])
@api_login_required
@parse_repository_name
def get_build_trigger(namespace, repository, trigger_uuid):
permission = AdministerRepositoryPermission(namespace, repository)
if permission.can():
try:
trigger = model.get_build_trigger(namespace, repository, trigger_uuid)
except model.InvalidBuildTriggerException:
abort(404)
return jsonify(trigger_view(trigger))
abort(403) # Permission denied
@api.route('/repository/<path:repository>/trigger/', methods=['GET'])
@api_login_required
@parse_repository_name
def list_build_triggers(namespace, repository):
permission = AdministerRepositoryPermission(namespace, repository)
if permission.can():
triggers = model.list_build_triggers(namespace, repository)
return jsonify({
'triggers': [trigger_view(trigger) for trigger in triggers]
})
abort(403) # Permission denied
@api.route('/repository/<path:repository>/trigger/<trigger_uuid>',
methods=['DELETE'])
@api_login_required
@parse_repository_name
def delete_build_trigger(namespace, repository, trigger_uuid):
permission = AdministerRepositoryPermission(namespace, repository)
if permission.can():
model.delete_build_trigger(namespace, repository, trigger_uuid)
log_action('delete_repo_trigger', namespace,
{'repo': repository, 'trigger_id': trigger_uuid},
repo=model.get_repository(namespace, repository))
return make_response('No Content', 204)
abort(403) # Permission denied
@api.route('/filedrop/', methods=['POST'])
@api_login_required
@internal_api_call

119
endpoints/callbacks.py Normal file
View file

@ -0,0 +1,119 @@
import requests
import logging
from flask import request, redirect, url_for, Blueprint
from flask.ext.login import login_required, current_user
from endpoints.common import render_page_template, common_login
from app import app, mixpanel
from data import model
from util.names import parse_repository_name
logger = logging.getLogger(__name__)
callback = Blueprint('callback', __name__)
def exchange_github_code_for_token(code):
code = request.args.get('code')
payload = {
'client_id': app.config['GITHUB_CLIENT_ID'],
'client_secret': app.config['GITHUB_CLIENT_SECRET'],
'code': code,
}
headers = {
'Accept': 'application/json'
}
get_access_token = requests.post(app.config['GITHUB_TOKEN_URL'],
params=payload, headers=headers)
token = get_access_token.json()['access_token']
return token
def get_github_user(token):
token_param = {
'access_token': token,
}
get_user = requests.get(app.config['GITHUB_USER_URL'], params=token_param)
return get_user.json()
@callback.route('/github/callback', methods=['GET'])
def github_oauth_callback():
error = request.args.get('error', None)
if error:
return render_page_template('githuberror.html', error_message=error)
token = exchange_github_code_for_token(request.args.get('code'))
user_data = get_github_user(token)
username = user_data['login']
github_id = user_data['id']
v3_media_type = {
'Accept': 'application/vnd.github.v3'
}
token_param = {
'access_token': token,
}
get_email = requests.get(app.config['GITHUB_USER_EMAILS'],
params=token_param, headers=v3_media_type)
# We will accept any email, but we prefer the primary
found_email = None
for user_email in get_email.json():
found_email = user_email['email']
if user_email['primary']:
break
to_login = model.verify_federated_login('github', github_id)
if not to_login:
# try to create the user
try:
to_login = model.create_federated_user(username, found_email, 'github',
github_id)
# Success, tell mixpanel
mixpanel.track(to_login.username, 'register', {'service': 'github'})
state = request.args.get('state', None)
if state:
logger.debug('Aliasing with state: %s' % state)
mixpanel.alias(to_login.username, state)
except model.DataModelException, ex:
return render_page_template('githuberror.html', error_message=ex.message)
if common_login(to_login):
return redirect(url_for('web.index'))
return render_page_template('githuberror.html')
@callback.route('/github/callback/attach', methods=['GET'])
@login_required
def github_oauth_attach():
token = exchange_github_code_for_token(request.args.get('code'))
user_data = get_github_user(token)
github_id = user_data['id']
user_obj = current_user.db_user()
model.attach_federated_login(user_obj, 'github', github_id)
return redirect(url_for('web.user'))
@callback.route('/github/callback/trigger/<path:repository>', methods=['GET'])
@login_required
@parse_repository_name
def attach_github_build_trigger(namespace, repository):
token = exchange_github_code_for_token(request.args.get('code'))
model.create_build_trigger(namespace, repository, 'github', token,
current_user.db_user())
admin_path = '%s/%s/%s' % (namespace, repository, 'admin')
full_url = url_for('web.repository', path=admin_path) + '?tab=trigger'
logger.debug('Redirecting to full url: %s' % full_url)
return redirect(full_url)

View file

@ -2,7 +2,7 @@ import logging
import os
import base64
from flask import request, abort, session, make_response
from flask import session, make_response, render_template
from flask.ext.login import login_user, UserMixin
from flask.ext.principal import identity_changed
@ -14,6 +14,39 @@ from auth.permissions import QuayDeferredPermissionUser
logger = logging.getLogger(__name__)
route_data = None
def get_route_data():
global route_data
if route_data:
return route_data
routes = []
for rule in app.url_map.iter_rules():
if rule.endpoint.startswith('api.'):
endpoint_method = app.view_functions[rule.endpoint]
is_internal = '__internal_call' in dir(endpoint_method)
is_org_api = '__user_call' in dir(endpoint_method)
methods = list(rule.methods.difference(['HEAD', 'OPTIONS']))
route = {
'name': rule.endpoint[4:],
'methods': methods,
'path': rule.rule,
'parameters': list(rule.arguments)
}
if is_org_api:
route['user_method'] = endpoint_method.__user_call
routes.append(route)
route_data = {
'endpoints': routes
}
return route_data
@login_manager.user_loader
def load_user(username):
logger.debug('Loading user: %s' % username)
@ -68,3 +101,11 @@ def generate_csrf_token():
return session['_csrf_token']
app.jinja_env.globals['csrf_token'] = generate_csrf_token
def render_page_template(name, **kwargs):
resp = make_response(render_template(name, route_data=get_route_data(),
**kwargs))
resp.headers['X-FRAME-OPTIONS'] = 'DENY'
return resp

111
endpoints/trigger.py Normal file
View file

@ -0,0 +1,111 @@
import json
import requests
import logging
from github import Github
from app import app
user_files = app.config['USERFILES']
logger = logging.getLogger(__name__)
ZIPBALL = 'application/zip'
class BuildArchiveException(Exception):
pass
class InvalidServiceException(Exception):
pass
class BuildTrigger(object):
def __init__(self):
pass
def list_repositories(self, auth_token):
"""
Take the auth information for the specific trigger type and load the
list of repositories.
"""
raise NotImplementedError
def incoming_webhook(self, request, auth_token, config):
"""
Transform the incoming request data into a set of actions.
"""
raise NotImplementedError
@classmethod
def service_name(cls):
"""
Particular service implemented by subclasses.
"""
raise NotImplementedError
@classmethod
def get_trigger_for_service(cls, service):
for subc in cls.__subclasses__():
if subc.service_name() == service:
return subc()
raise InvalidServiceException('Unable to find service: %s' % service)
class GithubBuildTrigger(BuildTrigger):
@staticmethod
def _get_client(auth_token):
return Github(auth_token, client_id=app.config['GITHUB_CLIENT_ID'],
client_secret=app.config['GITHUB_CLIENT_SECRET'])
@classmethod
def service_name(cls):
return 'github'
def list_repositories(self, auth_token):
gh_client = self._get_client(auth_token)
usr = gh_client.get_user()
repo_list = [repo.full_name for repo in usr.get_repos()]
for org in usr.get_orgs():
repo_list.extend((repo.full_name for repo in org.get_repos()))
return repo_list
def incoming_webhook(self, request, auth_token, config):
payload = request.get_json()
logger.debug('Payload %s', payload)
ref = payload['ref']
commit_id = payload['head_commit']['id'][0:7]
gh_client = self._get_client(auth_token)
repo_full_name = '%s/%s' % (payload['repository']['owner']['name'],
payload['repository']['name'])
repo = gh_client.get_repo(repo_full_name)
logger.debug('Github repo: %s', repo)
# Prepare the download and upload URLs
branch_name = ref.split('/')[-1]
archive_link = repo.get_archive_link('zipball', branch_name)
download_archive = requests.get(archive_link, stream=True)
upload_url, dockerfile_id = user_files.prepare_for_drop(ZIPBALL)
up_headers = {'Content-Type': ZIPBALL}
upload_archive = requests.put(upload_url, headers=up_headers,
data=download_archive.raw)
if upload_archive.status_code / 100 != 2:
logger.debug('Failed to upload archive to s3')
raise BuildArchiveException('Unable to copy archie to s3 for ref: %s' %
ref)
logger.debug('Successfully prepared job')
return dockerfile_id, branch_name, commit_id

View file

@ -2,19 +2,18 @@ import logging
import requests
import stripe
from flask import (abort, redirect, request, url_for, render_template,
make_response, Response, Blueprint)
from flask.ext.login import login_required, current_user
from flask import (abort, redirect, request, url_for, make_response, Response,
Blueprint)
from flask.ext.login import current_user
from urlparse import urlparse
from data import model
from app import app, mixpanel
from app import app
from auth.permissions import AdministerOrganizationPermission
from util.invoice import renderInvoiceToPdf
from util.seo import render_snapshot
from util.cache import no_cache
from endpoints.api import get_route_data
from endpoints.common import common_login
from endpoints.common import common_login, render_page_template
logger = logging.getLogger(__name__)
@ -22,16 +21,7 @@ logger = logging.getLogger(__name__)
web = Blueprint('web', __name__)
def render_page_template(name, **kwargs):
resp = make_response(render_template(name, route_data=get_route_data(),
**kwargs))
resp.headers['X-FRAME-OPTIONS'] = 'DENY'
return resp
@web.route('/', methods=['GET'], defaults={'path': ''})
@web.route('/repository/<path:path>', methods=['GET'])
@web.route('/organization/<path:path>', methods=['GET'])
@no_cache
def index(path):
@ -106,9 +96,10 @@ def new():
return index('')
@web.route('/repository/')
@web.route('/repository/', defaults={'path': ''})
@web.route('/repository/<path:path>', methods=['GET'])
@no_cache
def repository():
def repository(path):
return index('')
@ -179,97 +170,6 @@ def receipt():
abort(404)
def exchange_github_code_for_token(code):
code = request.args.get('code')
payload = {
'client_id': app.config['GITHUB_CLIENT_ID'],
'client_secret': app.config['GITHUB_CLIENT_SECRET'],
'code': code,
}
headers = {
'Accept': 'application/json'
}
get_access_token = requests.post(app.config['GITHUB_TOKEN_URL'],
params=payload, headers=headers)
token = get_access_token.json()['access_token']
return token
def get_github_user(token):
token_param = {
'access_token': token,
}
get_user = requests.get(app.config['GITHUB_USER_URL'], params=token_param)
return get_user.json()
@web.route('/oauth2/github/callback', methods=['GET'])
def github_oauth_callback():
error = request.args.get('error', None)
if error:
return render_page_template('githuberror.html', error_message=error)
token = exchange_github_code_for_token(request.args.get('code'))
user_data = get_github_user(token)
username = user_data['login']
github_id = user_data['id']
v3_media_type = {
'Accept': 'application/vnd.github.v3'
}
token_param = {
'access_token': token,
}
get_email = requests.get(app.config['GITHUB_USER_EMAILS'],
params=token_param, headers=v3_media_type)
# We will accept any email, but we prefer the primary
found_email = None
for user_email in get_email.json():
found_email = user_email['email']
if user_email['primary']:
break
to_login = model.verify_federated_login('github', github_id)
if not to_login:
# try to create the user
try:
to_login = model.create_federated_user(username, found_email, 'github',
github_id)
# Success, tell mixpanel
mixpanel.track(to_login.username, 'register', {'service': 'github'})
state = request.args.get('state', None)
if state:
logger.debug('Aliasing with state: %s' % state)
mixpanel.alias(to_login.username, state)
except model.DataModelException, ex:
return render_page_template('githuberror.html', error_message=ex.message)
if common_login(to_login):
return redirect(url_for('web.index'))
return render_page_template('githuberror.html')
@web.route('/oauth2/github/callback/attach', methods=['GET'])
@login_required
def github_oauth_attach():
token = exchange_github_code_for_token(request.args.get('code'))
user_data = get_github_user(token)
github_id = user_data['id']
user_obj = current_user.db_user()
model.attach_federated_login(user_obj, 'github', github_id)
return redirect(url_for('web.user'))
@web.route('/confirm', methods=['GET'])
def confirm_email():
code = request.values['code']

View file

@ -1,7 +1,6 @@
import logging
import stripe
import urlparse
import json
from flask import request, make_response, Blueprint
@ -12,11 +11,14 @@ from util.invoice import renderInvoiceToHtml
from util.email import send_invoice_email
from util.names import parse_repository_name
from util.http import abort
from endpoints.trigger import BuildTrigger
logger = logging.getLogger(__name__)
webhooks = Blueprint('webhooks', __name__)
@webhooks.route('/stripe', methods=['POST'])
def stripe_webhook():
request_data = request.get_json()
@ -41,22 +43,37 @@ def stripe_webhook():
return make_response('Okay')
@webhooks.route('/github/push/repository/<path:repository>', methods=['POST'])
@webhooks.route('/push/<path:repository>/trigger/<trigger_uuid>',
methods=['POST'])
@process_auth
@parse_repository_name
def github_push_webhook(namespace, repository):
# data = request.get_json()
def github_push_webhook(namespace, repository, trigger_uuid):
logger.debug('Webhook received for %s/%s with uuid %s', namespace,
repository, trigger_uuid)
permission = ModifyRepositoryPermission(namespace, repository)
if permission.can():
payload = json.loads(request.form['payload'])
ref = payload['ref']
try:
trigger = model.get_build_trigger(namespace, repository, trigger_uuid)
except model.InvalidBuildTriggerException:
abort(404)
repo = model.get_repository(namespace, repository)
token = model.create_access_token(repo, 'write')
handler = BuildTrigger.get_trigger_for_service(trigger.service.name)
logger.debug('Passing webhook request to handler %s', handler)
df_id, tag, name = handler.incoming_webhook(request, trigger.auth_token,
trigger.config)
host = urlparse.urlparse(request.url).netloc
tag = '%s/%s/%s:latest' % (host, repo.namespace, repo.name)
full_tag = '%s/%s/%s:%s' % (host, trigger.repository.namespace,
trigger.repository.name, tag)
model.create_repository_build(repo, token, build_spec, tag)
token = model.create_access_token(trigger.repository, 'write')
logger.debug('Creating build %s with full_tag %s and dockerfile_id %s',
name, full_tag, df_id)
model.create_repository_build(trigger.repository, token, df_id, full_tag,
name)
return make_response('Okay')
abort(403)

View file

@ -164,6 +164,8 @@ def initialize_database():
Visibility.create(name='private')
LoginService.create(name='github')
LoginService.create(name='quayrobot')
BuildTriggerService.create(name='github')
LogEntryKind.create(name='account_change_plan')
LogEntryKind.create(name='account_change_cc')
@ -200,6 +202,8 @@ def initialize_database():
LogEntryKind.create(name='modify_prototype_permission')
LogEntryKind.create(name='delete_prototype_permission')
LogEntryKind.create(name='delete_repo_trigger')
def wipe_database():
logger.debug('Wiping all data from the DB.')

View file

@ -1014,7 +1014,7 @@ function RepoBuildCtrl($scope, Restangular, ApiService, $routeParams, $rootScope
fetchRepository();
}
function RepoAdminCtrl($scope, Restangular, ApiService, $routeParams, $rootScope) {
function RepoAdminCtrl($scope, Restangular, ApiService, KeyService, $routeParams, $rootScope) {
var namespace = $routeParams.namespace;
var name = $routeParams.name;
@ -1024,6 +1024,9 @@ function RepoAdminCtrl($scope, Restangular, ApiService, $routeParams, $rootScope
$scope.permissionCache = {};
$scope.githubRedirectUri = KeyService.githubRedirectUri;
$scope.githubClientId = KeyService.githubClientId;
$scope.buildEntityForPermission = function(name, permission, kind) {
var key = name + ':' + kind;
if ($scope.permissionCache[key]) {
@ -1244,6 +1247,29 @@ function RepoAdminCtrl($scope, Restangular, ApiService, $routeParams, $rootScope
});
};
$scope.loadTriggers = function() {
var params = {
'repository': namespace + '/' + name
};
$scope.newWebhook = {};
$scope.triggersResource = ApiService.listBuildTriggersAsResource(params).get(function(resp) {
$scope.triggers = resp.triggers;
return $scope.triggers;
});
};
$scope.deletetrigger = function(trigger) {
var params = {
'repository': namespace + '/' + name,
'trigger_uuid': trigger.id
};
ApiService.deleteBuildTrigger(null, params).then(function(resp) {
$scope.triggers.splice($scope.triggers.indexOf(trigger), 1);
});
};
var fetchTokens = function() {
var params = {
'repository': namespace + '/' + name

View file

@ -18,6 +18,7 @@
<div class="col-md-2">
<ul class="nav nav-pills nav-stacked">
<li class="active"><a href="javascript:void(0)" data-toggle="tab" data-target="#permissions">Permissions</a></li>
<li><a href="javascript:void(0)" data-toggle="tab" data-target="#trigger" ng-click="loadTriggers()">Build Triggers</a></li>
<li><a href="javascript:void(0)" data-toggle="tab" data-target="#webhook" ng-click="loadWebhooks()">Webhooks</a></li>
<li><a href="javascript:void(0)" data-toggle="tab" data-target="#publicprivate">Public/Private</a></li>
<li><a href="javascript:void(0)" data-toggle="tab" data-target="#delete">Delete</a></li>
@ -206,6 +207,25 @@
</div>
</div>
<!-- Triggers tab -->
<div id="trigger" class="tab-pane">
<div class="panel panel-default">
<div class="panel-heading">Build Triggers
<i class="info-icon fa fa-info-circle" data-placement="left" data-content="SCM push callbacks which can cause the repository to be built and updated."></i>
</div>
<div class="panel-body">
<div class="resource-view" resource="triggersResource" error-message="'Could not load triggers'">
<a class="btn btn-primary" href="https://github.com/login/oauth/authorize?client_id={{ githubClientId }}&scope=repo&redirect_uri={{ githubRedirectUri }}/trigger/{{ repo.namespace }}/{{ repo.name }}">Link to Github</a>
</div>
<div class="right-info">
Quay will do something.
</div>
</div>
</div>
</div>
<!-- Public/private tab -->
<div id="publicprivate" class="tab-pane">
<!-- Public/Private -->

Binary file not shown.