Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 70 additions & 2 deletions codespeed/admin.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
from django import forms
from django.contrib import admin
from django.contrib import admin, messages

from codespeed.models import (Project, Revision, Executable, Benchmark, Branch,
Result, Environment, Report)
Result, Environment, Report, GitHubAccount,
PRRun, PRRunner)


class ProjectForm(forms.ModelForm):
Expand Down Expand Up @@ -109,3 +110,70 @@ class ReportAdmin(admin.ModelAdmin):
list_filter = ('environment', 'executable')
ordering = ['-revision']
actions = [recalculate_report]


@admin.register(GitHubAccount)
class GitHubAccountAdmin(admin.ModelAdmin):
"""Read-only: these rows mirror GitHub and are rewritten at each sign-in."""
list_display = ('login', 'name', 'github_id', 'user', 'verified_at')
search_fields = ('login', 'name')
readonly_fields = ('user', 'github_id', 'login', 'name', 'avatar_url',
'verified_at')

def has_add_permission(self, request):
return False


@admin.register(PRRunner)
class PRRunnerAdmin(admin.ModelAdmin):
list_display = ('environment', 'enabled', 'backend', 'has_token',
'last_seen')
list_filter = ('enabled', 'backend')
filter_horizontal = ('executables',)
readonly_fields = ('last_seen',)
actions = ['issue_token']
fieldsets = (
(None, {'fields': ('environment', 'executables', 'enabled', 'notes',
'last_seen')}),
('How the machine is told about work', {
'fields': ('backend', 'workflow_repo', 'workflow_file',
'workflow_ref'),
}),
)

@admin.display(boolean=True, description='Token issued')
def has_token(self, obj):
return bool(obj.token_hash)

def save_model(self, request, obj, form, change):
token = None if obj.token_hash else obj.new_token()
super().save_model(request, obj, form, change)
if token:
self._show_token(request, obj, token)

@admin.action(description='Issue a new token (the old one stops working)')
def issue_token(self, request, queryset):
for runner in queryset:
token = runner.new_token()
runner.save(update_fields=['token_hash'])
self._show_token(request, runner, token)

def _show_token(self, request, runner, token):
# Only the hash is kept, so this message is the one chance to copy it
messages.warning(
request, 'Token for %s: %s (copy it now; it is not stored and '
'cannot be shown again)' % (runner, token))


@admin.register(PRRun)
class PRRunAdmin(admin.ModelAdmin):
"""An audit trail; runs are made from the PR Runner tab, not here."""
list_display = ('id', 'pr_number', 'head_sha', 'runner', 'executable',
'status', 'requested_by_login', 'created_at')
list_filter = ('status', 'runner', 'project')
search_fields = ('pr_number', 'head_sha', 'requested_by_login')
raw_id_fields = ('requested_by',)
date_hierarchy = 'created_at'

def has_add_permission(self, request):
return False
21 changes: 19 additions & 2 deletions codespeed/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,20 @@ def is_authenticated(request):
return False


def may_post_results(user):
"""Whether a signed-in user may post results.

Any local account may. People who signed in with GitHub get an
account for the PR runner.
"""
from .models import GitHubAccount

pk = getattr(user, 'pk', None)
if isinstance(pk, int) and GitHubAccount.objects.filter(user_id=pk).exists():
return user.has_perm('codespeed.add_result')
return True


def basic_auth_required(realm='default'):
def _helper(func):
@wraps(func)
Expand All @@ -33,7 +47,9 @@ def _decorator(request, *args, **kwargs):
if settings.ALLOW_ANONYMOUS_POST:
logger.debug('allowing anonymous post')
allowed = True
elif hasattr(request, 'user') and is_authenticated(request=request):
elif (hasattr(request, 'user') and
is_authenticated(request=request) and
may_post_results(request.user)):
allowed = True
elif 'HTTP_AUTHORIZATION' in request.META:
logger.debug('checking for http authorization header')
Expand All @@ -44,7 +60,8 @@ def _decorator(request, *args, **kwargs):
if authmeth.lower() == 'basic':
username, password = decode_basic_auth(auth)
user = authenticate(username=username, password=password)
if user is not None and user.is_active:
if (user is not None and user.is_active and
may_post_results(user)):
logger.info(
'Authentication succeeded for {}'.format(username))
login(request, user)
Expand Down
8 changes: 5 additions & 3 deletions codespeed/commits/github.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,11 @@ def fetch_json(url):
try:
json_obj = json.load(urlopen(request))
except IOError as e:
logger.exception("Unable to load %s: %s",
url, e, exc_info=True)
raise e
# Rate limits, and such things. CommitLogError lets a
# result upload go through without the commit's metadata rather
# than fail with a 500.
logger.warning("Unable to load %s: %s", url, e)
raise CommitLogError("Unable to load %s: %s" % (url, e)) from e

if "message" in json_obj and \
json_obj["message"] in ("Not Found", "Server Error",):
Expand Down
81 changes: 81 additions & 0 deletions codespeed/github_api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
"""A small GitHub REST client for sign-in and the PR runner.

It is kept apart from ``codespeed.commits.github``, which caches every
response for a month. That suits immutable commits but is wrong for team
memberships and pull requests, which may change.
"""
import json
import logging
import re
from urllib.error import HTTPError
from urllib.parse import urlencode
from urllib.request import Request, urlopen

logger = logging.getLogger(__name__)

API_ROOT = 'https://api.github.com'
API_VERSION = '2022-11-28'
USER_AGENT = 'codespeed'
TIMEOUT = 10

_REPO_RE = re.compile(
r'^https?://github\.com/(?P<owner>[\w.-]+)/(?P<name>[\w.-]+?)(\.git)?/?$'
)


class GitHubError(Exception):
"""A GitHub call failed. status is None when GitHub was unreachable."""

def __init__(self, status, message):
super().__init__('GitHub API error (%s): %s' % (status, message))
self.status = status
self.message = message


def send(request):
"""Send a prepared request and return the decoded JSON body, or None."""
try:
with urlopen(request, timeout=TIMEOUT) as response:
body = response.read()
except HTTPError as e:
try:
message = json.loads(e.read()).get('message', e.reason)
except (ValueError, AttributeError):
message = e.reason
raise GitHubError(e.code, message) from None
except OSError as e: # URLError, timeouts, connection resets
raise GitHubError(None, str(e)) from None
if not body:
return None
try:
return json.loads(body)
except ValueError:
raise GitHubError(None, 'response was not JSON') from None


def api(method, path, token=None, data=None, params=None):
"""Call https://api.github.com<path> and return the decoded JSON."""
if not path.startswith('/'):
raise ValueError('path must start with "/": %r' % path)
url = API_ROOT + path
if params:
url += '?' + urlencode(params)
headers = {
'Accept': 'application/vnd.github+json',
'X-GitHub-Api-Version': API_VERSION,
'User-Agent': USER_AGENT,
}
if token:
headers['Authorization'] = 'Bearer %s' % token
body = None
if data is not None:
body = json.dumps(data).encode()
headers['Content-Type'] = 'application/json'
return send(Request(url, data=body, headers=headers, method=method))


def repo_slug(repo_url):
m = _REPO_RE.match(repo_url or '')
if not m:
return None
return m.group('owner'), m.group('name')
Loading
Loading