Skip to content

Commit c4d333a

Browse files
Draft implementation for PR Runner
1 parent d84c6df commit c4d333a

30 files changed

Lines changed: 2148 additions & 10 deletions

‎codespeed/admin.py‎

Lines changed: 70 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
from django import forms
2-
from django.contrib import admin
2+
from django.contrib import admin, messages
33

44
from codespeed.models import (Project, Revision, Executable, Benchmark, Branch,
5-
Result, Environment, Report)
5+
Result, Environment, Report, GitHubAccount,
6+
PRRun, PRRunner)
67

78

89
class ProjectForm(forms.ModelForm):
@@ -109,3 +110,70 @@ class ReportAdmin(admin.ModelAdmin):
109110
list_filter = ('environment', 'executable')
110111
ordering = ['-revision']
111112
actions = [recalculate_report]
113+
114+
115+
@admin.register(GitHubAccount)
116+
class GitHubAccountAdmin(admin.ModelAdmin):
117+
"""Read-only: these rows mirror GitHub and are rewritten at each sign-in."""
118+
list_display = ('login', 'name', 'github_id', 'user', 'verified_at')
119+
search_fields = ('login', 'name')
120+
readonly_fields = ('user', 'github_id', 'login', 'name', 'avatar_url',
121+
'verified_at')
122+
123+
def has_add_permission(self, request):
124+
return False
125+
126+
127+
@admin.register(PRRunner)
128+
class PRRunnerAdmin(admin.ModelAdmin):
129+
list_display = ('environment', 'enabled', 'backend', 'has_token',
130+
'last_seen')
131+
list_filter = ('enabled', 'backend')
132+
filter_horizontal = ('executables',)
133+
readonly_fields = ('last_seen',)
134+
actions = ['issue_token']
135+
fieldsets = (
136+
(None, {'fields': ('environment', 'executables', 'enabled', 'notes',
137+
'last_seen')}),
138+
('How the machine is told about work', {
139+
'fields': ('backend', 'workflow_repo', 'workflow_file',
140+
'workflow_ref'),
141+
}),
142+
)
143+
144+
@admin.display(boolean=True, description='Token issued')
145+
def has_token(self, obj):
146+
return bool(obj.token_hash)
147+
148+
def save_model(self, request, obj, form, change):
149+
token = None if obj.token_hash else obj.new_token()
150+
super().save_model(request, obj, form, change)
151+
if token:
152+
self._show_token(request, obj, token)
153+
154+
@admin.action(description='Issue a new token (the old one stops working)')
155+
def issue_token(self, request, queryset):
156+
for runner in queryset:
157+
token = runner.new_token()
158+
runner.save(update_fields=['token_hash'])
159+
self._show_token(request, runner, token)
160+
161+
def _show_token(self, request, runner, token):
162+
# Only the hash is kept, so this message is the one chance to copy it
163+
messages.warning(
164+
request, 'Token for %s: %s (copy it now; it is not stored and '
165+
'cannot be shown again)' % (runner, token))
166+
167+
168+
@admin.register(PRRun)
169+
class PRRunAdmin(admin.ModelAdmin):
170+
"""An audit trail; runs are made from the PR Runner tab, not here."""
171+
list_display = ('id', 'pr_number', 'head_sha', 'runner', 'executable',
172+
'status', 'requested_by_login', 'created_at')
173+
list_filter = ('status', 'runner', 'project')
174+
search_fields = ('pr_number', 'head_sha', 'requested_by_login')
175+
raw_id_fields = ('requested_by',)
176+
date_hierarchy = 'created_at'
177+
178+
def has_add_permission(self, request):
179+
return False

‎codespeed/auth.py‎

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,20 @@ def is_authenticated(request):
2424
return False
2525

2626

27+
def may_post_results(user):
28+
"""Whether a signed-in user may post results.
29+
30+
Any local account may. People who signed in with GitHub get an
31+
account for the PR runner.
32+
"""
33+
from .models import GitHubAccount
34+
35+
pk = getattr(user, 'pk', None)
36+
if isinstance(pk, int) and GitHubAccount.objects.filter(user_id=pk).exists():
37+
return user.has_perm('codespeed.add_result')
38+
return True
39+
40+
2741
def basic_auth_required(realm='default'):
2842
def _helper(func):
2943
@wraps(func)
@@ -33,7 +47,9 @@ def _decorator(request, *args, **kwargs):
3347
if settings.ALLOW_ANONYMOUS_POST:
3448
logger.debug('allowing anonymous post')
3549
allowed = True
36-
elif hasattr(request, 'user') and is_authenticated(request=request):
50+
elif (hasattr(request, 'user') and
51+
is_authenticated(request=request) and
52+
may_post_results(request.user)):
3753
allowed = True
3854
elif 'HTTP_AUTHORIZATION' in request.META:
3955
logger.debug('checking for http authorization header')
@@ -44,7 +60,8 @@ def _decorator(request, *args, **kwargs):
4460
if authmeth.lower() == 'basic':
4561
username, password = decode_basic_auth(auth)
4662
user = authenticate(username=username, password=password)
47-
if user is not None and user.is_active:
63+
if (user is not None and user.is_active and
64+
may_post_results(user)):
4865
logger.info(
4966
'Authentication succeeded for {}'.format(username))
5067
login(request, user)

‎codespeed/commits/github.py‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,9 +48,11 @@ def fetch_json(url):
4848
try:
4949
json_obj = json.load(urlopen(request))
5050
except IOError as e:
51-
logger.exception("Unable to load %s: %s",
52-
url, e, exc_info=True)
53-
raise e
51+
# Rate limits, and such things. CommitLogError lets a
52+
# result upload go through without the commit's metadata rather
53+
# than fail with a 500.
54+
logger.warning("Unable to load %s: %s", url, e)
55+
raise CommitLogError("Unable to load %s: %s" % (url, e)) from e
5456

5557
if "message" in json_obj and \
5658
json_obj["message"] in ("Not Found", "Server Error",):

‎codespeed/github_api.py‎

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
"""A small GitHub REST client for sign-in and the PR runner.
2+
3+
It is kept apart from ``codespeed.commits.github``, which caches every
4+
response for a month. That suits immutable commits but is wrong for team
5+
memberships and pull requests, which may change.
6+
"""
7+
import json
8+
import logging
9+
import re
10+
from urllib.error import HTTPError
11+
from urllib.parse import urlencode
12+
from urllib.request import Request, urlopen
13+
14+
logger = logging.getLogger(__name__)
15+
16+
API_ROOT = 'https://api.github.com'
17+
API_VERSION = '2022-11-28'
18+
USER_AGENT = 'codespeed'
19+
TIMEOUT = 10
20+
21+
_REPO_RE = re.compile(
22+
r'^https?://github\.com/(?P<owner>[\w.-]+)/(?P<name>[\w.-]+?)(\.git)?/?$'
23+
)
24+
25+
26+
class GitHubError(Exception):
27+
"""A GitHub call failed. status is None when GitHub was unreachable."""
28+
29+
def __init__(self, status, message):
30+
super().__init__('GitHub API error (%s): %s' % (status, message))
31+
self.status = status
32+
self.message = message
33+
34+
35+
def send(request):
36+
"""Send a prepared request and return the decoded JSON body, or None."""
37+
try:
38+
with urlopen(request, timeout=TIMEOUT) as response:
39+
body = response.read()
40+
except HTTPError as e:
41+
try:
42+
message = json.loads(e.read()).get('message', e.reason)
43+
except (ValueError, AttributeError):
44+
message = e.reason
45+
raise GitHubError(e.code, message) from None
46+
except OSError as e: # URLError, timeouts, connection resets
47+
raise GitHubError(None, str(e)) from None
48+
if not body:
49+
return None
50+
try:
51+
return json.loads(body)
52+
except ValueError:
53+
raise GitHubError(None, 'response was not JSON') from None
54+
55+
56+
def api(method, path, token=None, data=None, params=None):
57+
"""Call https://api.github.com<path> and return the decoded JSON."""
58+
if not path.startswith('/'):
59+
raise ValueError('path must start with "/": %r' % path)
60+
url = API_ROOT + path
61+
if params:
62+
url += '?' + urlencode(params)
63+
headers = {
64+
'Accept': 'application/vnd.github+json',
65+
'X-GitHub-Api-Version': API_VERSION,
66+
'User-Agent': USER_AGENT,
67+
}
68+
if token:
69+
headers['Authorization'] = 'Bearer %s' % token
70+
body = None
71+
if data is not None:
72+
body = json.dumps(data).encode()
73+
headers['Content-Type'] = 'application/json'
74+
return send(Request(url, data=body, headers=headers, method=method))
75+
76+
77+
def repo_slug(repo_url):
78+
m = _REPO_RE.match(repo_url or '')
79+
if not m:
80+
return None
81+
return m.group('owner'), m.group('name')

0 commit comments

Comments
 (0)