Skip to content

Commit

Permalink
Implementation a check run for YAML configs
Browse files Browse the repository at this point in the history
This is an initial attempt at implementing a check run, GitHubConfigsCheck,
that reports Pydantic validation errors.
  • Loading branch information
jonathansick committed Jun 23, 2022
1 parent 239f6d3 commit 43a8af9
Show file tree
Hide file tree
Showing 2 changed files with 249 additions and 13 deletions.
224 changes: 224 additions & 0 deletions src/timessquare/domain/githubcheckrun.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
"""Domain models for a GitHub Check Runs computations."""

from __future__ import annotations

from dataclasses import dataclass
from typing import Any, Dict, List, Optional, Sequence, Union

from gidgethub.httpx import GitHubAPI
from pydantic import ValidationError

from .githubapi import (
GitHubBlobModel,
GitHubCheckRunAnnotationLevel,
GitHubCheckRunConclusion,
GitHubRepositoryModel,
)
from .githubcheckout import (
GitHubRepositoryCheckout,
NotebookSidecarFile,
RepositoryNotebookTreeRef,
)


@dataclass(kw_only=True)
class Annotation:
"""Annotation of an issue in a file."""

path: str

start_line: int

message: str

title: str

annotation_level: GitHubCheckRunAnnotationLevel

end_line: Optional[int] = None

@classmethod
def from_validation_error(
cls, path: str, error: ValidationError
) -> List[Annotation]:
annotations: List[Annotation] = []
for item in error.errors():
title = cls._format_title_for_pydantic_item(item["loc"])
annotations.append(
Annotation(
path=path,
start_line=1,
message=item["msg"],
title=title,
annotation_level=GitHubCheckRunAnnotationLevel.failure,
)
)
return annotations

@staticmethod
def _format_title_for_pydantic_item(
locations: Sequence[Union[str, int]]
) -> str:
title_elements: List[str] = []
for location in locations:
if isinstance(location, int):
title_elements.append(f"[{location}]")
else:
title_elements.append(f".{location}")
return "".join(title_elements)

def export(self) -> Dict[str, Any]:
"""Export a GitHub check run output annotation object."""
output = {
"path": self.path,
"start_line": self.start_line,
"message": self.message,
"title": self.title,
"annotation_level": self.annotation_level,
}
if self.end_line:
output["end_line"] = self.end_line
else:
output["end_line"] = self.start_line
return output


class GitHubConfigsCheck:
"""A domain model for a YAML configuration GitHub Check run."""

def __init__(self) -> None:
self.annotations: List[Annotation] = []
self.sidecar_files_checked: List[str] = []

@classmethod
async def validate_repo(
cls,
github_client: GitHubAPI,
repo: GitHubRepositoryModel,
head_sha: str,
) -> GitHubConfigsCheck:
"""Create a check run result model for a specific SHA of a GitHub
repository containing Times Square notebooks.
"""
check = cls()

try:
checkout = await GitHubRepositoryCheckout.create(
github_client=github_client,
repo=repo,
head_sha=head_sha,
)
except ValidationError as e:
annotations = Annotation.from_validation_error(
path="times-square.yaml", error=e
)
check.annotations.extend(annotations)
return check

# Validate each notebook yaml file
tree = await checkout.get_git_tree(github_client)
for notebook_ref in tree.find_notebooks(checkout.settings):
await check.validate_sidecar(
github_client=github_client,
repo=repo,
notebook_ref=notebook_ref,
)

return check

async def validate_sidecar(
self,
*,
github_client: GitHubAPI,
repo: GitHubRepositoryModel,
notebook_ref: RepositoryNotebookTreeRef,
) -> None:
"""Validate the sidecar file for a notebook, adding its results
to the check.
"""
data = await github_client.getitem(
repo.blobs_url,
url_vars={"sha": notebook_ref.sidecar_git_tree_sha},
)
sidecar_blob = GitHubBlobModel.parse_obj(data)
try:
NotebookSidecarFile.parse_yaml(sidecar_blob.decode())
except ValidationError as e:
annotations = Annotation.from_validation_error(
path=notebook_ref.sidecar_path, error=e
)
self.annotations.extend(annotations)
self.sidecar_files_checked.append(notebook_ref.sidecar_path)

@property
def conclusion(self) -> GitHubCheckRunConclusion:
"""Synthesize a conclusion based on the annotations."""
for annotation in self.annotations:
if (
annotation.annotation_level
== GitHubCheckRunAnnotationLevel.failure
):
return GitHubCheckRunConclusion.failure

return GitHubCheckRunConclusion.success

@property
def title(self) -> str:
return "YAML config validation"

@property
def summary(self) -> str:
sidecar_count = len(self.sidecar_files_checked)

if self.conclusion == GitHubCheckRunConclusion.success:
text = "Everything looks good ✅"
else:
text = "There are some issues 🧐"

if sidecar_count == 1:
text = (
f"{text} (checked times-square.yaml and 1 notebook sidecar "
"file)"
)
else:
text = (
f"{text} (checked times-square.yaml and "
f"{sidecar_count} notebook sidecar files)"
)

return text

@property
def text(self) -> str:
text = "| File | Status |\n | --- | :-: |\n"

if self._file_ok("times-square.yaml"):
text = f"{text}| times-square.yaml | ✅ |\n"
else:
text = f"{text}| times-square.yaml | ❌ |\n"

sidecar_files = list(set(self.sidecar_files_checked))
sidecar_files.sort()
for sidecar_path in sidecar_files:
if self._is_file_ok(sidecar_path):
text = f"{text}| {sidecar_path} | ✅ |\n"
else:
text = f"{text}| {sidecar_path} | ❌ |\n"

return text

def _is_file_ok(self, path: str) -> bool:
for annotation in self.annotations:
if annotation.path == path:
return False
return True

def export_truncated_annotations(self) -> List[Dict[str, Any]]:
"""Export the first 50 annotations to objects serializable to
GitHub.
Sending more than 50 annotations requires multiple HTTP requests,
which we haven't implemented yet. See
https://docs.github.com/en/rest/checks/runs#update-a-check-run
"""
return [a.export() for a in self.annotations[:50]]
38 changes: 25 additions & 13 deletions src/timessquare/services/github/repo.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@

from __future__ import annotations

import asyncio
from pathlib import PurePosixPath
from typing import List

Expand All @@ -16,7 +15,6 @@
from timessquare.domain.githubapi import (
GitHubBlobModel,
GitHubBranchModel,
GitHubCheckRunConclusion,
GitHubCheckRunModel,
GitHubCheckRunStatus,
GitHubRepositoryModel,
Expand All @@ -26,6 +24,7 @@
RepositoryNotebookModel,
RepositorySettingsFile,
)
from timessquare.domain.githubcheckrun import GitHubConfigsCheck
from timessquare.domain.githubwebhook import (
GitHubCheckRunEventModel,
GitHubCheckSuiteEventModel,
Expand Down Expand Up @@ -304,8 +303,7 @@ async def create_check_run(
https://docs.github.com/en/rest/checks/runs#create-a-check-run
"""
await self._create_yaml_config_check_run(
owner=payload.repository.owner.login,
repo=payload.repository.name,
repo=payload.repository,
head_sha=payload.check_suite.head_sha,
)

Expand All @@ -314,42 +312,56 @@ async def create_rerequested_check_run(
) -> None:
"""Run a GitHub check run that was rerequested."""
await self._create_yaml_config_check_run(
owner=payload.repository.owner.login,
repo=payload.repository.name,
repo=payload.repository,
head_sha=payload.check_run.head_sha,
)

async def _create_yaml_config_check_run(
self, *, owner: str, repo: str, head_sha: str
self, *, repo: GitHubRepositoryModel, head_sha: str
) -> None:
data = await self._github_client.post(
"repos/{owner}/{repo}/check-runs",
url_vars={"owner": owner, "repo": repo},
url_vars={"owner": repo.owner.login, "repo": repo.name},
data={"name": "YAML configurations", "head_sha": head_sha},
)
check_run = GitHubCheckRunModel.parse_obj(data)
await self._compute_check_run(check_run)
await self._compute_check_run(check_run=check_run, repo=repo)

async def compute_check_run(
self, *, payload: GitHubCheckRunEventModel
) -> None:
"""Compute a GitHub check run."""
await self._compute_check_run(payload.check_run)
await self._compute_check_run(
repo=payload.repository, check_run=payload.check_run
)

async def _compute_check_run(self, check_run: GitHubCheckRunModel) -> None:
async def _compute_check_run(
self, *, repo: GitHubRepositoryModel, check_run: GitHubCheckRunModel
) -> None:
"""Compute the YAML validation check run."""
# Set the check run to in-progress
await self._github_client.patch(
check_run.url,
data={"status": GitHubCheckRunStatus.in_progress},
)

await asyncio.sleep(30)
config_check = await GitHubConfigsCheck.validate_repo(
github_client=self._github_client,
repo=repo,
head_sha=check_run.head_sha,
)

# Set the check run to complete
await self._github_client.patch(
check_run.url,
data={
"status": GitHubCheckRunStatus.completed,
"conclusion": GitHubCheckRunConclusion.success,
"conclusion": config_check.conclusion,
"output": {
"title": config_check.title,
"summary": config_check.summary,
"text": config_check.text,
"annotations": config_check.export_truncated_annotations(),
},
},
)

0 comments on commit 43a8af9

Please sign in to comment.