From 903a05fc0e037ab38072d20cc0788d7bd0631c4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Bidoul?= Date: Fri, 5 Nov 2021 18:28:52 +0100 Subject: [PATCH] Report build statuses to GitHub --- README.md | 3 ++- src/runboat/github.py | 35 +++++++++++++++++++++++++++++++---- src/runboat/models.py | 33 ++++++++++++++++++++++++++++++++- 3 files changed, 65 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 5004390..69b3710 100644 --- a/README.md +++ b/README.md @@ -122,7 +122,6 @@ resources: Prototype (min required to open the project): -- report build status to github - plug it on a bunch of OCA and shopinvader repos to test load - basic tests @@ -142,6 +141,8 @@ More: - shiny UI - websocket stream of build changes, for a dynamic UI +- better target_url in GitHub status: instead of providing the link to the ingress, + provide a link to the build, which redirects to the ingress if the build is started - handle PR close (delete all builds for PR) - handle branch delete (delete all builds for branch) - create builds for all supported repos on startup (goes with sticky branches) diff --git a/src/runboat/github.py b/src/runboat/github.py index 114e8f6..434e402 100644 --- a/src/runboat/github.py +++ b/src/runboat/github.py @@ -1,4 +1,6 @@ +import logging from dataclasses import dataclass +from enum import Enum from typing import Any import httpx @@ -6,8 +8,10 @@ import httpx from .exceptions import NotFoundOnGitHub from .settings import settings +_logger = logging.getLogger(__name__) -async def _github_get(url: str) -> Any: + +async def _github_request(method: str, url: str, json: Any = None) -> Any: async with httpx.AsyncClient() as client: full_url = f"https://api.github.com{url}" headers = { @@ -15,7 +19,8 @@ async def _github_get(url: str) -> Any: } if settings.github_token: headers["Authorization"] = f"token {settings.github_token}" - response = await client.get(full_url, headers=headers) + _logger.debug("%s %s", method, full_url) + response = await client.request(method, full_url, headers=headers, json=json) if response.status_code == 404: raise NotFoundOnGitHub(f"GitHub URL not found: {full_url}.") response.raise_for_status() @@ -30,7 +35,7 @@ class BranchInfo: async def get_branch_info(repo: str, branch: str) -> BranchInfo: - branch_data = await _github_get(f"/repos/{repo}/git/ref/heads/{branch}") + branch_data = await _github_request("GET", f"/repos/{repo}/git/ref/heads/{branch}") return BranchInfo( repo=repo, name=branch, @@ -47,10 +52,32 @@ class PullInfo: async def get_pull_info(repo: str, pr: int) -> PullInfo: - pr_data = await _github_get(f"/repos/{repo}/pulls/{pr}") + pr_data = await _github_request("GET", f"/repos/{repo}/pulls/{pr}") return PullInfo( repo=repo, number=pr, head_sha=pr_data["head"]["sha"], target_branch=pr_data["base"]["ref"], ) + + +class GitHubStatusState(str, Enum): + error = "error" + failure = "failure" + pending = "pending" + success = "success" + + +async def notify_status( + repo: str, sha: str, state: GitHubStatusState, target_url: str | None +) -> None: + # https://docs.github.com/en/rest/reference/repos#create-a-commit-status + await _github_request( + "POST", + f"/repos/{repo}/statuses/{sha}", + json={ + "state": state, + "target_url": target_url, + "context": "ci/runboat", + }, + ) diff --git a/src/runboat/models.py b/src/runboat/models.py index d8997af..02131f4 100644 --- a/src/runboat/models.py +++ b/src/runboat/models.py @@ -7,8 +7,9 @@ from typing import Optional from kubernetes.client.models.v1_deployment import V1Deployment from pydantic import BaseModel -from . import k8s +from . import github, k8s from .build_images import get_build_image +from .github import GitHubStatusState from .settings import settings from .utils import slugify @@ -151,6 +152,12 @@ class Build(BaseModel): image, ) await k8s.deploy(deployment_vars) + await github.notify_status( + repo, + git_commit, + GitHubStatusState.pending, + target_url=None, + ) async def start(self) -> None: """Start build if init succeeded, or reinitialize if failed.""" @@ -167,6 +174,12 @@ class Build(BaseModel): _logger.info(f"Marking failed {self} for reinitialization.") await k8s.delete_job(self.name, job_kind=k8s.DeploymentMode.initialize) await self._patch(init_status=BuildInitStatus.todo, desired_replicas=0) + await github.notify_status( + self.repo, + self.git_commit, + GitHubStatusState.pending, + target_url=None, + ) elif self.status in (BuildStatus.stopped, BuildStatus.stopping): _logger.info(f"Starting {self} that was last scaled on {self.last_scaled}.") await self._patch(desired_replicas=1) @@ -229,6 +242,12 @@ class Build(BaseModel): return _logger.info(f"Initialization job started for {self}.") await self._patch(init_status=BuildInitStatus.started, desired_replicas=0) + await github.notify_status( + self.repo, + self.git_commit, + GitHubStatusState.pending, + target_url=None, + ) async def on_initialize_succeeded(self) -> None: if self.init_status == BuildInitStatus.succeeded: @@ -237,6 +256,12 @@ class Build(BaseModel): return _logger.info(f"Initialization job succeded for {self}, starting.") await self._patch(init_status=BuildInitStatus.succeeded, desired_replicas=1) + await github.notify_status( + self.repo, + self.git_commit, + GitHubStatusState.success, + target_url=self.link, + ) async def on_initialize_failed(self) -> None: if self.init_status == BuildInitStatus.failed: @@ -245,6 +270,12 @@ class Build(BaseModel): return _logger.info(f"Initialization job failed for {self}.") await self._patch(init_status=BuildInitStatus.failed, desired_replicas=0) + await github.notify_status( + self.repo, + self.git_commit, + GitHubStatusState.failure, + target_url=None, + ) async def on_cleanup_started(self) -> None: _logger.info(f"Cleanup job started for {self}.")