Skip to content

Commit

Permalink
Implement GitHub Actions services (closes #24)
Browse files Browse the repository at this point in the history
  • Loading branch information
textbook committed Apr 4, 2021
1 parent fbefe6c commit e4f1c24
Show file tree
Hide file tree
Showing 5 changed files with 313 additions and 4 deletions.
22 changes: 22 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ The following service definitions include the configuration options:
* ``ghe_issues`` - for issues and PRs in project repositories on
`GitHub Enterprise`_ installations

* ``root`` (required - the root URL for the enterprise API)
* ``password`` (required - a GitHub API token)
* ``username`` (required - the username for the token)
* ``account`` (required - the name of the account the project is in, e.g.
Expand All @@ -103,6 +104,26 @@ The following service definitions include the configuration options:
* ``ok_threshold`` (the maximum half life to show as an OK state, in days,
defaults to 7)

* ``gh_actions`` - for Actions in project repositories on `GitHub`_

* ``password`` (required - a GitHub API token)
* ``username`` (required - the username for the token)
* ``account`` (required - the name of the account the project is in, e.g.
``"textbook"``)
* ``repo`` (required - the name of the project repository within that account,
e.g. ``"flash"``)

* ``ghe_actions`` - for Actions in project repositories on `GitHub Enterprise`_
installations

* ``root`` (required - the root URL for the enterprise API)
* ``password`` (required - a GitHub API token)
* ``username`` (required - the username for the token)
* ``account`` (required - the name of the account the project is in, e.g.
``"textbook"``)
* ``repo`` (required - the name of the project repository within that account,
e.g. ``"flash"``)

* ``github`` - for project repositories on `GitHub`_

* ``password`` (required - a GitHub API token)
Expand All @@ -117,6 +138,7 @@ The following service definitions include the configuration options:
* ``github_enterprise`` - for project repositories on `GitHub Enterprise`_
installations

* ``root`` (required - the root URL for the enterprise API)
* ``password`` (required - a GitHub API token)
* ``username`` (required - the username for the token)
* ``account`` (required - the name of the account the project is in, e.g.
Expand Down
7 changes: 5 additions & 2 deletions flash_services/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,15 @@
from .circleci import CircleCI
from .codeship import Codeship
from .coveralls import Coveralls
from .github import (GitHub, GitHubEnterprise, GitHubEnterpriseIssues,
from .github import (GitHub, GitHubActions, GitHubEnterprise,
GitHubEnterpriseActions, GitHubEnterpriseIssues,
GitHubIssues)
from .jenkins import Jenkins
from .tracker import Tracker
from .travis import TravisOS, TravisPro

__author__ = 'Jonathan Sharpe'
__version__ = '0.11.1'
__version__ = '0.12.0'

blueprint = Blueprint(
'services',
Expand All @@ -37,6 +38,8 @@
coveralls=Coveralls,
github=GitHub,
github_enterprise=GitHubEnterprise,
gh_actions=GitHubActions,
ghe_actions=GitHubEnterpriseActions,
gh_issues=GitHubIssues,
ghe_issues=GitHubEnterpriseIssues,
jenkins=Jenkins,
Expand Down
59 changes: 57 additions & 2 deletions flash_services/github.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@
from datetime import timedelta

from .auth import BasicAuthHeaderMixin
from .core import CustomRootMixin, ThresholdMixin, VersionControlService
from .utils import naturaldelta, occurred, safe_parse
from .core import ContinuousIntegrationService, CustomRootMixin, ThresholdMixin, VersionControlService
from .utils import elapsed_time, estimate_time, health_summary, naturaldelta, occurred, Outcome, safe_parse

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -46,6 +46,7 @@ def name(self):
@property
def headers(self):
headers = super().headers
headers['Accept'] = 'application/vnd.github.v3+json'
headers['User-Agent'] = self.repo
return headers

Expand Down Expand Up @@ -177,6 +178,54 @@ def health_summary(self, half_life):
return 'error'


class GitHubActions(GitHub, ContinuousIntegrationService):
"""Show the current build status on GitHub Actions."""

ENDPOINT = '/repos/{repo_name}/actions/runs'
FRIENDLY_NAME = 'GitHub Actions'
OUTCOMES = dict(
action_required=Outcome.CRASHED,
cancelled=Outcome.CANCELLED,
failure=Outcome.FAILED,
in_progress=Outcome.WORKING,
neutral=Outcome.PASSED,
queued=Outcome.WORKING,
skipped=Outcome.CANCELLED,
stale=Outcome.CRASHED,
success=Outcome.PASSED,
timed_out=Outcome.CRASHED,
)

@property
def url_params(self):
params = super().url_params
params.update(dict(per_page=100))
return params

def format_data(self, data):
builds = [self.format_build(build) for build in data['workflows']]
estimate_time(builds)
return dict(name=self.name, builds=builds[:4], health=health_summary(builds))

@classmethod
def format_build(cls, build):
start, finish, elapsed = elapsed_time(
build.get('created_at'),
build.get('updated_at'),
)
duration = None if start is None or finish is None else finish - start
commit = build.get('head_commit', {})
build_completed = build.get('status') == 'completed'
return super().format_build(dict(
author=commit.get('author', {}).get('name'),
duration=duration if build_completed else None,
elapsed=elapsed,
message=commit.get('message'),
outcome=build.get('conclusion') if build_completed else build.get('status'),
started_at=start,
))


class GitHubEnterprise(CustomRootMixin, GitHub):
"""Current status of GHE repositories."""

Expand All @@ -187,3 +236,9 @@ class GitHubEnterpriseIssues(CustomRootMixin, GitHubIssues):
"""Issues and pull requests from GHE repositories."""

FRIENDLY_NAME = 'GitHub Issues'


class GitHubEnterpriseActions(CustomRootMixin, GitHubActions):
"""Actions from GHE repositories."""

FRIENDLY_NAME = 'GitHub Actions'
2 changes: 2 additions & 0 deletions flash_services/static/scripts/services.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ var SERVICES = {
},
gh_issues: gh_issues,
ghe_issues: gh_issues,
gh_actions: builds,
ghe_actions: builds,
github: github,
github_enterprise: github,
jenkins: builds,
Expand Down
227 changes: 227 additions & 0 deletions tests/test_github_actions_service.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
import logging

import pytest
import responses

from flash_services.core import ContinuousIntegrationService
from flash_services.github import GitHubActions, GitHubEnterpriseActions
from flash_services.utils import Outcome


@pytest.fixture
def service():
return GitHubActions(username='user', password='foobar', account='foo', repo='bar')


def test_service_type():
assert issubclass(GitHubActions, ContinuousIntegrationService)


def test_update_success(service, caplog, mocked_responses):
caplog.set_level(logging.DEBUG)
mocked_responses.add(
responses.GET,
'https://api.github.com/repos/foo/bar/actions/runs?per_page=100',
json=dict(total_count=0, workflows=[]),
)

result = service.update()

assert 'fetching GitHub Actions project data' in [
record.getMessage()
for record in caplog.records
if record.levelno == logging.DEBUG
]
assert result == {'builds': [], 'name': 'foo/bar', 'health': 'neutral'}
headers = mocked_responses.calls[0].request.headers
assert headers['Accept'] == 'application/vnd.github.v3+json'
assert headers['Authorization'] == 'Basic dXNlcjpmb29iYXI='
assert headers['User-Agent'] == 'bar'


def test_update_failure(service, caplog, mocked_responses):
mocked_responses.add(
responses.GET,
'https://api.github.com/repos/foo/bar/actions/runs?per_page=100',
status=401,
)

result = service.update()

assert 'failed to update GitHub Actions project data' in [
record.getMessage()
for record in caplog.records
if record.levelno == logging.ERROR
]
assert result == {}


def test_passing_build(service, mocked_responses):
mocked_responses.add(
responses.GET,
'https://api.github.com/repos/foo/bar/actions/runs?per_page=100',
json={
"workflows": [
{
"conclusion": "success",
"created_at": "2016-04-14T20:47:40Z",
"head_commit": {
"message": "I did some work",
"author": {
"name": "Jane Doe"
}
},
"status": "completed",
"updated_at": "2016-04-14T20:57:07Z"
}
]
}
)

result = service.update()

assert result == dict(
builds=[
dict(
author='Jane Doe',
duration=567,
elapsed='took nine minutes',
message='I did some work',
outcome='passed',
started_at=1460666860,
)
],
name='foo/bar',
health='ok'
)


def test_failing_build(service, mocked_responses):
mocked_responses.add(
responses.GET,
'https://api.github.com/repos/foo/bar/actions/runs?per_page=100',
json={
"workflows": [
{
"conclusion": "failure",
"created_at": "2016-04-14T20:47:40Z",
"head_commit": {
"message": "I did some bad work",
"author": {
"name": "Jane Doe"
}
},
"status": "completed",
"updated_at": "2016-04-14T20:57:07Z"
}
]
}
)

result = service.update()

assert result == dict(
builds=[
dict(
author='Jane Doe',
duration=567,
elapsed='took nine minutes',
message='I did some bad work',
outcome='failed',
started_at=1460666860,
)
],
name='foo/bar',
health='error'
)


# See https://docs.github.com/en/rest/reference/checks#create-a-check-run
@pytest.mark.parametrize('status, conclusion, outcome', [
('in_progress', None, Outcome.WORKING),
('queued', None, Outcome.WORKING),
('completed', 'success', Outcome.PASSED),
('completed', 'failure', Outcome.FAILED),
('completed', 'action_required', Outcome.CRASHED),
('completed', 'cancelled', Outcome.CANCELLED),
('completed', 'neutral', Outcome.PASSED),
('completed', 'skipped', Outcome.CANCELLED),
('completed', 'stale', Outcome.CRASHED),
('completed', 'timed_out', Outcome.CRASHED),
])
def test_combines_status_and_conclusion(status, conclusion, outcome, service):
formatted = service.format_build(dict(status=status, conclusion=conclusion))
assert formatted['outcome'] == outcome


def test_working_build(service, mocked_responses):
mocked_responses.add(
responses.GET,
'https://api.github.com/repos/foo/bar/actions/runs?per_page=100',
json={
"workflows": [
{
"created_at": "2016-04-14T20:47:40Z",
"head_commit": {
"message": "I did some more work",
"author": {
"name": "Jane Doe"
}
},
"status": "in_progress",
"updated_at": "2016-04-14T20:47:40Z"
}, {
"conclusion": "success",
"created_at": "2016-04-14T20:47:40Z",
"head_commit": {
"message": "I did some work",
"author": {
"name": "Jane Doe"
}
},
"status": "completed",
"updated_at": "2016-04-14T20:57:07Z"
}
]
},
)

result = service.update()

assert result['builds'][0] == dict(
author='Jane Doe',
duration=None,
elapsed='nearly done',
message='I did some more work',
outcome='working',
started_at=1460666860,
)


def test_actions_enterprise_update(caplog, mocked_responses):
caplog.set_level(logging.DEBUG)
mocked_responses.add(
responses.GET,
'http://dummy.url/repos/foo/bar/actions/runs?per_page=100',
json=dict(total_count=0, workflows=[]),
)
service = GitHubEnterpriseActions(
username='enterprise-user',
password='foobar',
account='foo',
repo='bar',
root='http://dummy.url',
)

result = service.update()

assert 'fetching GitHub Actions project data' in [
record.getMessage()
for record in caplog.records
if record.levelno == logging.DEBUG
]
assert result == dict(builds=[], name='foo/bar', health='neutral')
headers = mocked_responses.calls[0].request.headers
assert headers['Accept'] == 'application/vnd.github.v3+json'
assert headers['Authorization'] == 'Basic ZW50ZXJwcmlzZS11c2VyOmZvb2Jhcg=='
assert headers['User-Agent'] == 'bar'

0 comments on commit e4f1c24

Please sign in to comment.