Skip to content

Commit c4f06dc

Browse files
authored
Merge pull request #5865 from saadmk11/github-status-api
Send Build Status Report Using GitHub Status API
2 parents 04d4dca + 1117caa commit c4f06dc

File tree

11 files changed

+363
-15
lines changed

11 files changed

+363
-15
lines changed

readthedocs/builds/constants.py

+26
Original file line numberDiff line numberDiff line change
@@ -61,3 +61,29 @@
6161
LATEST,
6262
STABLE,
6363
)
64+
65+
# GitHub Build Statuses
66+
GITHUB_BUILD_STATE_FAILURE = 'failure'
67+
GITHUB_BUILD_STATE_PENDING = 'pending'
68+
GITHUB_BUILD_STATE_SUCCESS = 'success'
69+
70+
# General Build Statuses
71+
BUILD_STATUS_FAILURE = 'failed'
72+
BUILD_STATUS_PENDING = 'pending'
73+
BUILD_STATUS_SUCCESS = 'success'
74+
75+
# Used to select correct Build status and description to be sent to each service API
76+
SELECT_BUILD_STATUS = {
77+
BUILD_STATUS_FAILURE: {
78+
'github': GITHUB_BUILD_STATE_FAILURE,
79+
'description': 'The build failed!',
80+
},
81+
BUILD_STATUS_PENDING: {
82+
'github': GITHUB_BUILD_STATE_PENDING,
83+
'description': 'The build is pending!',
84+
},
85+
BUILD_STATUS_SUCCESS: {
86+
'github': GITHUB_BUILD_STATE_SUCCESS,
87+
'description': 'The build succeeded!',
88+
},
89+
}

readthedocs/builds/models.py

+10
Original file line numberDiff line numberDiff line change
@@ -734,6 +734,16 @@ def __str__(self):
734734
def get_absolute_url(self):
735735
return reverse('builds_detail', args=[self.project.slug, self.pk])
736736

737+
def get_full_url(self):
738+
"""Get full url including domain"""
739+
scheme = 'http' if settings.DEBUG else 'https'
740+
full_url = '{scheme}://{domain}{absolute_url}'.format(
741+
scheme=scheme,
742+
domain=settings.PRODUCTION_DOMAIN,
743+
absolute_url=self.get_absolute_url()
744+
)
745+
return full_url
746+
737747
@property
738748
def finished(self):
739749
"""Return if build has a finished state."""

readthedocs/core/utils/__init__.py

+12-2
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,10 @@
1111
from django.utils.safestring import SafeText, mark_safe
1212
from django.utils.text import slugify as slugify_base
1313

14-
from readthedocs.builds.constants import BUILD_STATE_TRIGGERED
14+
from readthedocs.builds.constants import (
15+
BUILD_STATE_TRIGGERED,
16+
BUILD_STATUS_PENDING,
17+
)
1518
from readthedocs.doc_builder.constants import DOCKER_LIMITS
1619

1720

@@ -78,7 +81,10 @@ def prepare_build(
7881
# Avoid circular import
7982
from readthedocs.builds.models import Build
8083
from readthedocs.projects.models import Project
81-
from readthedocs.projects.tasks import update_docs_task
84+
from readthedocs.projects.tasks import (
85+
update_docs_task,
86+
send_external_build_status,
87+
)
8288

8389
build = None
8490

@@ -125,6 +131,10 @@ def prepare_build(
125131
options['soft_time_limit'] = time_limit
126132
options['time_limit'] = int(time_limit * 1.2)
127133

134+
if build:
135+
# Send pending Build Status using Git Status API for External Builds.
136+
send_external_build_status(build.id, BUILD_STATUS_PENDING)
137+
128138
return (
129139
update_docs_task.signature(
130140
args=(version.pk,),

readthedocs/doc_builder/environments.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -658,7 +658,7 @@ def failed(self):
658658
def done(self):
659659
"""Is build in finished state."""
660660
return (
661-
self.build is not None and
661+
self.build and
662662
self.build['state'] == BUILD_STATE_FINISHED
663663
)
664664

readthedocs/oauth/services/base.py

+47
Original file line numberDiff line numberDiff line change
@@ -184,12 +184,28 @@ def paginate(self, url, **kwargs):
184184
return []
185185

186186
def sync(self):
187+
"""Sync repositories and organizations."""
187188
raise NotImplementedError
188189

189190
def create_repository(self, fields, privacy=None, organization=None):
191+
"""
192+
Update or create a repository from API response.
193+
194+
:param fields: dictionary of response data from API
195+
:param privacy: privacy level to support
196+
:param organization: remote organization to associate with
197+
:type organization: RemoteOrganization
198+
:rtype: RemoteRepository
199+
"""
190200
raise NotImplementedError
191201

192202
def create_organization(self, fields):
203+
"""
204+
Update or create remote organization from API response.
205+
206+
:param fields: dictionary response of data from API
207+
:rtype: RemoteOrganization
208+
"""
193209
raise NotImplementedError
194210

195211
def get_next_url_to_paginate(self, response):
@@ -211,9 +227,40 @@ def get_paginated_results(self, response):
211227
raise NotImplementedError
212228

213229
def setup_webhook(self, project):
230+
"""
231+
Setup webhook for project.
232+
233+
:param project: project to set up webhook for
234+
:type project: Project
235+
:returns: boolean based on webhook set up success, and requests Response object
236+
:rtype: (Bool, Response)
237+
"""
214238
raise NotImplementedError
215239

216240
def update_webhook(self, project, integration):
241+
"""
242+
Update webhook integration.
243+
244+
:param project: project to set up webhook for
245+
:type project: Project
246+
:param integration: Webhook integration to update
247+
:type integration: Integration
248+
:returns: boolean based on webhook update success, and requests Response object
249+
:rtype: (Bool, Response)
250+
"""
251+
raise NotImplementedError
252+
253+
def send_build_status(self, build, state):
254+
"""
255+
Create commit status for project.
256+
257+
:param build: Build to set up commit status for
258+
:type build: Build
259+
:param state: build state failure, pending, or success.
260+
:type state: str
261+
:returns: boolean based on commit status creation was successful or not.
262+
:rtype: Bool
263+
"""
217264
raise NotImplementedError
218265

219266
@classmethod

readthedocs/oauth/services/github.py

+75
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212

1313
from readthedocs.api.v2.client import api
1414
from readthedocs.builds import utils as build_utils
15+
from readthedocs.builds.constants import SELECT_BUILD_STATUS
1516
from readthedocs.integrations.models import Integration
1617

1718
from ..models import RemoteOrganization, RemoteRepository
@@ -311,6 +312,80 @@ def update_webhook(self, project, integration):
311312
)
312313
return (False, resp)
313314

315+
def send_build_status(self, build, state):
316+
"""
317+
Create GitHub commit status for project.
318+
319+
:param build: Build to set up commit status for
320+
:type build: Build
321+
:param state: build state failure, pending, or success.
322+
:type state: str
323+
:returns: boolean based on commit status creation was successful or not.
324+
:rtype: Bool
325+
"""
326+
session = self.get_session()
327+
project = build.project
328+
owner, repo = build_utils.get_github_username_repo(url=project.repo)
329+
build_sha = build.version.identifier
330+
331+
# select the correct state and description.
332+
github_build_state = SELECT_BUILD_STATUS[state]['github']
333+
description = SELECT_BUILD_STATUS[state]['description']
334+
335+
data = {
336+
'state': github_build_state,
337+
'target_url': build.get_full_url(),
338+
'description': description,
339+
'context': 'continuous-documentation/read-the-docs'
340+
}
341+
342+
resp = None
343+
344+
try:
345+
resp = session.post(
346+
f'https://api.github.com/repos/{owner}/{repo}/statuses/{build_sha}',
347+
data=json.dumps(data),
348+
headers={'content-type': 'application/json'},
349+
)
350+
if resp.status_code == 201:
351+
log.info(
352+
'GitHub commit status for project: %s',
353+
project,
354+
)
355+
return True
356+
357+
if resp.status_code in [401, 403, 404]:
358+
log.info(
359+
'GitHub project does not exist or user does not have '
360+
'permissions: project=%s',
361+
project,
362+
)
363+
return False
364+
365+
return False
366+
367+
# Catch exceptions with request or deserializing JSON
368+
except (RequestException, ValueError):
369+
log.exception(
370+
'GitHub commit status creation failed for project: %s',
371+
project,
372+
)
373+
# Response data should always be JSON, still try to log if not
374+
# though
375+
if resp is not None:
376+
try:
377+
debug_data = resp.json()
378+
except ValueError:
379+
debug_data = resp.content
380+
else:
381+
debug_data = resp
382+
383+
log.debug(
384+
'GitHub commit status creation failure response: %s',
385+
debug_data,
386+
)
387+
return False
388+
314389
@classmethod
315390
def get_token_for_project(cls, project, force_local=False):
316391
"""Get access token for project by iterating over project users."""

readthedocs/projects/tasks.py

+70-1
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,9 @@
3434
LATEST,
3535
LATEST_VERBOSE_NAME,
3636
STABLE_VERBOSE_NAME,
37+
EXTERNAL,
38+
BUILD_STATUS_SUCCESS,
39+
BUILD_STATUS_FAILURE,
3740
)
3841
from readthedocs.builds.models import APIVersion, Build, Version
3942
from readthedocs.builds.signals import build_complete
@@ -59,6 +62,8 @@
5962
)
6063
from readthedocs.doc_builder.loader import get_builder_class
6164
from readthedocs.doc_builder.python_environments import Conda, Virtualenv
65+
from readthedocs.oauth.models import RemoteRepository
66+
from readthedocs.oauth.services.github import GitHubService
6267
from readthedocs.projects.models import APIProject, Feature
6368
from readthedocs.search.utils import index_new_files, remove_indexed_files
6469
from readthedocs.sphinx_domains.models import SphinxDomain
@@ -573,6 +578,25 @@ def run_build(self, docker, record):
573578

574579
if self.build_env.failed:
575580
self.send_notifications(self.version.pk, self.build['id'])
581+
# send build failure status to git Status API
582+
send_external_build_status(
583+
self.build['id'], BUILD_STATUS_FAILURE
584+
)
585+
elif self.build_env.successful:
586+
# send build successful status to git Status API
587+
send_external_build_status(
588+
self.build['id'], BUILD_STATUS_SUCCESS
589+
)
590+
else:
591+
msg = 'Unhandled Build State'
592+
log.warning(
593+
LOG_TEMPLATE,
594+
{
595+
'project': self.project.slug,
596+
'version': self.version.slug,
597+
'msg': msg,
598+
}
599+
)
576600

577601
build_complete.send(sender=Build, build=self.build_env.build)
578602

@@ -1513,8 +1537,11 @@ def _manage_imported_files(version, path, commit, build):
15131537
@app.task(queue='web')
15141538
def send_notifications(version_pk, build_pk):
15151539
version = Version.objects.get_object_or_log(pk=version_pk)
1516-
if not version:
1540+
1541+
# only send notification for Internal versions
1542+
if not version or version.type == EXTERNAL:
15171543
return
1544+
15181545
build = Build.objects.get(pk=build_pk)
15191546

15201547
for hook in version.project.webhook_notifications.all():
@@ -1773,3 +1800,45 @@ def retry_domain_verification(domain_pk):
17731800
sender=domain.__class__,
17741801
domain=domain,
17751802
)
1803+
1804+
1805+
@app.task(queue='web')
1806+
def send_build_status(build, state):
1807+
"""
1808+
Send Build Status to Git Status API for project external versions.
1809+
1810+
:param build: Build
1811+
:param state: build state failed, pending, or success to be sent.
1812+
"""
1813+
try:
1814+
if build.project.remote_repository.account.provider == 'github':
1815+
service = GitHubService(
1816+
build.project.remote_repository.users.first(),
1817+
build.project.remote_repository.account
1818+
)
1819+
1820+
# send Status report using the API.
1821+
service.send_build_status(build, state)
1822+
1823+
except RemoteRepository.DoesNotExist:
1824+
log.info('Remote repository does not exist for %s', build.project)
1825+
1826+
except Exception:
1827+
log.exception('Send build status task failed for %s', build.project)
1828+
1829+
# TODO: Send build status for other providers.
1830+
1831+
1832+
def send_external_build_status(build_pk, state):
1833+
"""
1834+
Check if build is external and Send Build Status for project external versions.
1835+
1836+
:param build_pk: Build pk
1837+
:param state: build state failed, pending, or success to be sent.
1838+
"""
1839+
build = Build.objects.get(pk=build_pk)
1840+
1841+
# Send status reports for only External (pull/merge request) Versions.
1842+
if build.version.type == EXTERNAL:
1843+
# call the task that actually send the build status.
1844+
send_build_status.delay(build, state)

0 commit comments

Comments
 (0)