diff --git a/envoy.ci.report/VERSION b/envoy.ci.report/VERSION index b470f6b2e..bcab45af1 100644 --- a/envoy.ci.report/VERSION +++ b/envoy.ci.report/VERSION @@ -1 +1 @@ -0.0.3-dev +0.0.3 diff --git a/envoy.ci.report/envoy/ci/report/BUILD b/envoy.ci.report/envoy/ci/report/BUILD index a570002d5..223db89ca 100644 --- a/envoy.ci.report/envoy/ci/report/BUILD +++ b/envoy.ci.report/envoy/ci/report/BUILD @@ -14,6 +14,7 @@ toolshed_library( "exceptions.py", "interface.py", "runner.py", + "typing.py", "abstract/__init__.py", "abstract/filters.py", "abstract/format.py", diff --git a/envoy.ci.report/envoy/ci/report/__init__.py b/envoy.ci.report/envoy/ci/report/__init__.py index a5c06c80a..dccf7780f 100644 --- a/envoy.ci.report/envoy/ci/report/__init__.py +++ b/envoy.ci.report/envoy/ci/report/__init__.py @@ -1,5 +1,5 @@ -from . import abstract, exceptions, interface +from . import abstract, exceptions, interface, typing from .ci import CIRuns from .runner import ( CreationTimeFilter, JSONFormat, MarkdownFormat, @@ -18,4 +18,5 @@ "main", "MarkdownFormat", "ReportRunner", - "StatusFilter") + "StatusFilter", + "typing") diff --git a/envoy.ci.report/envoy/ci/report/abstract/format.py b/envoy.ci.report/envoy/ci/report/abstract/format.py index f2cdafdbe..9259d10f0 100644 --- a/envoy.ci.report/envoy/ci/report/abstract/format.py +++ b/envoy.ci.report/envoy/ci/report/abstract/format.py @@ -1,5 +1,6 @@ import json +import textwrap from datetime import datetime import abstracts @@ -28,36 +29,49 @@ def out(self, data: dict) -> None: class AMarkdownFormat(AFormat): def out(self, data: dict) -> None: - for commit, events in data.items(): - self._handle_commit(commit, events) - - def _handle_commit(self, commit: str, events: list[dict]) -> None: - outcome = ( - "failed" - if any(event["workflow"]["conclusion"] == "failure" - for event - in events) - else "succeeded") - target_branch = events[0]["request"]["target-branch"] + for commit, info in data.items(): + self._handle_commit(commit, info) + + def _handle_commit(self, commit: str, info: dict) -> None: + target_branch = info["head"]["target_branch"] commit_url = f"https://github.com/envoyproxy/envoy/commit/{commit}" - print(f"[{target_branch}@{commit[:7]}]({commit_url}): {outcome}") - for event in events: - self._handle_event(event) + print(f"### [{target_branch}@{commit[:7]}]({commit_url})") + self._handle_commit_message(info['head']['message']) + for request_id, request in info["requests"].items(): + self._handle_event(request_id, request) + + def _handle_commit_message(self, message): + lines = message.splitlines() + if len(lines) == 1: + print(message) + return + summary = lines[0] + details = textwrap.indent( + "\n".join(lines[1:]), + " ", + lambda line: True) + print("
") + print(f" {summary}") + print("
") + print(details) + print("
") + print("
") + print() - def _handle_event(self, event: dict) -> None: - event_type = event["event"] + def _handle_event(self, request_id, request) -> None: + event_type = request["event"] request_started = datetime.utcfromtimestamp( - int(event["request"]["started"])).isoformat() - workflow_name = event["workflow"]["name"] - conclusion = event["workflow"]["conclusion"] - workflow_id = event["workflow_id"] - request_id = event["request_id"] - workflow_url = ( - "https://github.com/envoyproxy/envoy/" - f"actions/runs/{workflow_id}") + int(request["started"])).isoformat() request_url = ( "https://github.com/envoyproxy/envoy/" f"actions/runs/{request_id}") - print( - f" -> [[{event_type}@{request_started}]({request_url})]: " - f"[{workflow_name} ({conclusion})]({workflow_url})") + print(f"#### [{event_type}@{request_started}]({request_url}):") + for workflow_id, workflow in request["workflows"].items(): + workflow_url = ( + "https://github.com/envoyproxy/envoy/" + f"actions/runs/{workflow_id}") + print( + f"- [{workflow['name']} " + f"({workflow['conclusion']})]({workflow_url})") + print() + print("----") diff --git a/envoy.ci.report/envoy/ci/report/abstract/runner.py b/envoy.ci.report/envoy/ci/report/abstract/runner.py index b49c689c8..646e64d3e 100644 --- a/envoy.ci.report/envoy/ci/report/abstract/runner.py +++ b/envoy.ci.report/envoy/ci/report/abstract/runner.py @@ -4,7 +4,7 @@ import os import pathlib from functools import cached_property -from typing import Callable, Type +from typing import Callable import aiohttp @@ -94,7 +94,7 @@ def runs(self) -> interface.ICIRuns: @property @abstracts.interfacemethod - def runs_class(self) -> Type[interface.ICIRuns]: + def runs_class(self) -> type[interface.ICIRuns]: raise NotImplementedError @cached_property diff --git a/envoy.ci.report/envoy/ci/report/abstract/runs.py b/envoy.ci.report/envoy/ci/report/abstract/runs.py index 2e08079b8..33b77d0dd 100644 --- a/envoy.ci.report/envoy/ci/report/abstract/runs.py +++ b/envoy.ci.report/envoy/ci/report/abstract/runs.py @@ -13,7 +13,7 @@ from aio.core.functional import async_property from aio.core.tasks import concurrent -from envoy.ci.report import exceptions +from envoy.ci.report import exceptions, typing URL_GH_REPO_ACTION_ENV_ARTIFACT = "actions/runs/{wfid}/artifacts?name=env" URL_GH_REPO_ACTIONS = "actions/runs?per_page=100" @@ -41,7 +41,7 @@ def __init__( else sort_ascending) @async_property - async def as_dict(self) -> dict: + async def as_dict(self) -> typing.CIRunsDict: return self._sorted(await self._to_dict()) @async_property(cache=True) @@ -57,7 +57,7 @@ async def check_runs(self) -> dict: return result @async_property(cache=True) - async def envs(self) -> dict: + async def envs(self) -> typing.CIRequestEnvsDict: artifacts: dict = {} async for result in concurrent(self._env_fetches): if not result: @@ -214,40 +214,57 @@ async def _resolve_env_artifact_url(self, wfid: int) -> str | None: except IndexError: log.warning(f"Unable to find request artifact: {wfid}") - def _sorted(self, runs: dict) -> dict: + def _sorted(self, runs: typing.CIRunsDict) -> typing.CIRunsDict: max_or_min = ( min if self.sort_ascending else max) return dict( sorted( - ((k, - sorted( - v, - key=lambda event: event["request"]["started"], - reverse=not self.sort_ascending)) - for k, v in runs.items()), + runs.items(), key=lambda item: max_or_min( - x["request"]["started"] - for x in item[1]), + request["started"] + for request + in item[1]["requests"].values()), reverse=not self.sort_ascending)) - async def _to_dict(self) -> dict: - return { - commit: requests - for commit, request in (await self.workflow_requests).items() - if (requests := await self._to_list_request(commit, request))} + async def _to_dict(self) -> typing.CIRunsDict: + wf_requests = await self.workflow_requests + result: dict = {} + for commit, _requests in wf_requests.items(): + requests = await self._to_list_request(commit, _requests) + if not requests: + continue + result[commit] = {} + result[commit]["head"] = dict( + message=requests[0]["request"]["message"], + target_branch=requests[0]["request"]["target-branch"]) + result[commit]["requests"] = {} + for request in requests: + result[commit]["requests"][request["request_id"]] = result[ + commit]["requests"].get( + request["request_id"], + dict(event=request["event"], + started=request["request"]["started"], + workflows={})) + result[commit]["requests"][ + request["request_id"]]["workflows"][ + request["workflow_id"]] = request["workflow"] + return result async def _to_list_request(self, commit: str, request: dict) -> list[dict]: - return [ - {"event": event, - "request": (await self.envs)[commit][event][req]["request"], - "request_id": req, - "check_name": check_run["name"], - "workflow_id": check_run["external_id"], - "workflow": (await self.workflows)[int(check_run["external_id"])]} - for event, requests in request.items() - for check_run in ( - await self.check_runs).get( - commit, {}).get(event, []) - for req in requests] + return sorted( + [{"event": event, + "request": (await self.envs)[commit][event][req]["request"], + "request_id": req, + "check_name": check_run["name"], + "workflow_id": check_run["external_id"], + "workflow": (await self.workflows)[ + int(check_run["external_id"])]} + for event, requests in request.items() + for check_run in ( + await self.check_runs).get( + commit, {}).get(event, []) + for req in requests], + key=lambda item: item["request"]["started"], + reverse=not self.sort_ascending) diff --git a/envoy.ci.report/envoy/ci/report/cmd.py b/envoy.ci.report/envoy/ci/report/cmd.py index 23bfb34a3..707824c3b 100644 --- a/envoy.ci.report/envoy/ci/report/cmd.py +++ b/envoy.ci.report/envoy/ci/report/cmd.py @@ -1,11 +1,10 @@ import sys -from typing import Optional from .runner import ReportRunner -def main(*args: str) -> Optional[int]: +def main(*args: str) -> int | None: return ReportRunner(*args)() diff --git a/envoy.ci.report/envoy/ci/report/runner.py b/envoy.ci.report/envoy/ci/report/runner.py index 9ddec5682..3be2da878 100644 --- a/envoy.ci.report/envoy/ci/report/runner.py +++ b/envoy.ci.report/envoy/ci/report/runner.py @@ -1,6 +1,5 @@ from functools import cached_property -from typing import Type import abstracts @@ -29,7 +28,7 @@ class ReportRunner(abstract.AReportRunner): about Envoy's CI.""" @property - def runs_class(self) -> Type[interface.ICIRuns]: + def runs_class(self) -> type[interface.ICIRuns]: return ci.CIRuns @cached_property diff --git a/envoy.ci.report/envoy/ci/report/typing.py b/envoy.ci.report/envoy/ci/report/typing.py new file mode 100644 index 000000000..3259044ad --- /dev/null +++ b/envoy.ci.report/envoy/ci/report/typing.py @@ -0,0 +1,36 @@ + +from typing import TypedDict + + +class CIWorkflowDict(TypedDict): + name: str + status: str + event: str + conclusion: str + + +class CICommitHeadDict(TypedDict): + message: str + target_branch: str + + +class CIRequestDict(TypedDict): + event: str + started: float + workflows: list[dict[str, CIWorkflowDict]] + + +class CIRunsCommitDict(TypedDict): + head: CICommitHeadDict + requests: dict[int, CIRequestDict] + + +class CIRequestWorkflowDict(TypedDict): + checks: dict + request: dict + + +CIRunsDict = dict[str, CIRunsCommitDict] +CIRequestWorkflowsDict = dict[int, list[CIRequestWorkflowDict]] +CIRequestEventDict = dict[str, CIRequestWorkflowsDict] +CIRequestEnvsDict = dict[str, CIRequestEventDict] diff --git a/envoy.ci.report/tests/test_abstract_runs.py b/envoy.ci.report/tests/test_abstract_runs.py index 14789e8f0..c79f70eaa 100644 --- a/envoy.ci.report/tests/test_abstract_runs.py +++ b/envoy.ci.report/tests/test_abstract_runs.py @@ -749,15 +749,15 @@ async def test_runs__sorted(patches, iters, ascending): _runs.items.return_value = runsdict.items() item_values = iters(cb=lambda i: MagicMock()) item = MagicMock() - item.__getitem__.return_value = item_values + (item.__getitem__.return_value + .__getitem__.return_value + .values.return_value) = item_values with patched as (m_dict, m_max, m_min, m_sorted, m_ascending): m_ascending.return_value = ascending assert ( runs._sorted(_runs) == m_dict.return_value) - sortiter = m_sorted.call_args[0][0] - sortlist = list(sortiter) sortlambda = m_sorted.call_args_list[0][1]["key"] assert ( sortlambda(item) @@ -773,98 +773,135 @@ async def test_runs__sorted(patches, iters, ascending): boundaryiter = m_sorter.call_args[0][0] boundarylist = list(boundaryiter) - assert isinstance(sortiter, types.GeneratorType) assert isinstance(boundaryiter, types.GeneratorType) assert ( m_dict.call_args == [(m_sorted.return_value, ), {}]) assert ( - m_sorted.call_args_list[0] - == [(sortiter, ), + m_sorted.call_args + == [(_runs.items.return_value, ), dict(key=sortlambda, reverse=not ascending)]) - assert ( - sortlist - == [(k, m_sorted.return_value) - for k in runsdict.keys()]) assert ( m_sorter.call_args == [(boundaryiter, ), {}]) assert ( item.__getitem__.call_args == [(1, ), {}]) + assert ( + item.__getitem__.return_value.__getitem__.call_args + == [("requests", ), {}]) assert ( boundarylist - == [item.__getitem__.return_value.__getitem__.return_value + == [item.__getitem__.return_value for item in item_values]) for _item in item_values: assert ( _item.__getitem__.call_args - == [("request", ), {}]) - assert ( - _item.__getitem__.return_value.__getitem__.call_args - == [("started", ), {}]) - subsortlambdas = [ - sorter[1]["key"] - for sorter - in m_sorted.call_args_list[1:]] - assert ( - m_sorted.call_args_list[1:] - == [[(v, ), - dict(key=subsortlambdas[i], - reverse=not ascending)] - for i, v - in enumerate(runsdict.values())]) - for sortfun in subsortlambdas: - event = MagicMock() - assert ( - sortfun(event) - == event.__getitem__.return_value.__getitem__.return_value) - assert ( - event.__getitem__.call_args - == [("request", ), {}]) - assert ( - event.__getitem__.return_value.__getitem__.call_args == [("started", ), {}]) async def test_runs__to_dict(patches, iters): runs = report.abstract.ACIRuns("REPO") patched = patches( + "dict", ("ACIRuns.workflow_requests", dict(new_callable=PropertyMock)), "ACIRuns._to_list_request", prefix="envoy.ci.report.abstract.runs") workflows = iters(dict) - wf_requests = {} - - async def _list_request(commit, request): - if int(commit[1]) % 2: - requests = iters(cb=lambda x: f"{commit}V{x}") - wf_requests[commit] = requests - return requests - return [] + expected = {} - with patched as (m_workflows, m_list): + with patched as (m_dict, m_workflows, m_list): m_workflows.side_effect = AsyncMock(return_value=workflows) + _requests = [] + + async def _list_request(commit, request): + if int(commit[1]) % 2: + requests = iters(cb=lambda x: MagicMock()) + expected[commit] = { + "head": m_dict.return_value, + "requests": { + req.__getitem__.return_value: m_dict.return_value + for req in requests}} + _requests.extend(requests) + return requests + return [] + m_list.side_effect = _list_request assert ( await runs._to_dict() - == wf_requests) + == expected) assert ( m_list.call_args_list == [[(k, v), {}] for k, v in workflows.items()]) + i = 0 + for commit, wf in expected.items(): + first_request = list(wf["requests"].keys())[0] + assert ( + m_dict.call_args_list[i] + == [(), + dict(message=first_request.__getitem__.return_value, + target_branch=first_request.__getitem__.return_value)]) + assert ( + first_request.__getitem__.call_args_list + == [[(k, ), {}] + for k in ["message", "target-branch", "started"]]) + + # assert _requests[i].__getitem__.return_value == first_request + i += 1 + for request, v in wf["requests"].items(): + if request != first_request: + assert ( + request.__getitem__.call_args_list + == [[("started", ), {}]]) + assert ( + m_dict.call_args_list[i] + == [(), + dict(event=request, + started=request.__getitem__.return_value, + workflows={})]) + i += 1 + common_calls = [ + "request_id", "event", "request", "request_id", + "workflow", "request_id", "workflow_id"] + for i, request in enumerate(_requests): + if not i % 5: + assert ( + request.__getitem__.call_args_list + == [[(k, ), {}] + for k + in ["request", "request"] + common_calls]) + else: + assert ( + request.__getitem__.call_args_list + == [[(k, ), {}] + for k + in common_calls]) + assert ( + m_dict.return_value.__getitem__.call_args_list + == [[("workflows", ), {}]] * 10) + assert ( + m_dict.return_value.__getitem__.return_value.__setitem__.call_args_list + == [[(req.__getitem__.return_value, + req.__getitem__.return_value), {}] + for req in _requests]) -async def test_runs__to_list_request(patches, iters): + +@pytest.mark.parametrize("ascending", [True, False]) +async def test_runs__to_list_request(patches, iters, ascending): runs = report.abstract.ACIRuns("REPO") patched = patches( "int", + "sorted", ("ACIRuns.check_runs", dict(new_callable=PropertyMock)), ("ACIRuns.envs", dict(new_callable=PropertyMock)), + ("ACIRuns.sort_ascending", + dict(new_callable=PropertyMock)), ("ACIRuns.workflows", dict(new_callable=PropertyMock)), prefix="envoy.ci.report.abstract.runs") @@ -879,6 +916,7 @@ async def test_runs__to_list_request(patches, iters): check_runs.get.return_value.get.return_value = iters( cb=lambda x: MagicMock()) envs = MagicMock() + item = MagicMock() expected = [] for _event, _requests in request.items.return_value: @@ -896,14 +934,31 @@ async def test_runs__to_list_request(patches, iters): "workflow_id": _check_run.__getitem__.return_value, "workflow": workflows.__getitem__.return_value}) - with patched as (m_int, m_checks, m_envs, m_workflows): + with patched as patchy: + (m_int, m_sorted, m_checks, m_envs, + m_ascending, m_workflows) = patchy + m_ascending.return_value = ascending m_checks.side_effect = AsyncMock(return_value=check_runs) m_workflows.side_effect = AsyncMock(return_value=workflows) m_envs.side_effect = AsyncMock(return_value=envs) assert ( await runs._to_list_request(commit, request) - == expected) + == m_sorted.return_value) + sortlambda = m_sorted.call_args[1]["key"] + assert ( + sortlambda(item) + == item.__getitem__.return_value.__getitem__.return_value) + assert ( + m_sorted.call_args + == [(expected, ), + dict(key=sortlambda, reverse=not ascending)]) + assert ( + item.__getitem__.call_args + == [("request", ), {}]) + assert ( + item.__getitem__.return_value.__getitem__.call_args + == [("started", ), {}]) assert ( envs.__getitem__.call_args_list == ([[(commit, ), {}]] * 125))