Skip to content

Commit 0e13ed4

Browse files
committed
GitHub/CI: Fix displaying failed PR workflow runs that succeeded already
1 parent 697f486 commit 0e13ed4

File tree

3 files changed

+112
-29
lines changed

3 files changed

+112
-29
lines changed

CHANGES.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
# Change log
22

33
## In progress
4+
- GitHub/CI: Fixed displaying failed workflow runs on pull requests
5+
which succeeded afterward
46

57
## v0.1.0, 2025-02-20
68
- Started using `GH_TOKEN` environment variable instead of `GITHUB_TOKEN`,

src/rapporto/github/core.py

Lines changed: 67 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
GitHubActivityQueryBuilder,
1818
GitHubAttentionQueryBuilder,
1919
GitHubSearch,
20+
MarkdownContent,
2021
MultiRepositoryInquiry,
2122
PullRequestMetadata,
2223
)
@@ -127,13 +128,22 @@ def print(self):
127128
print(self.markdown_significant)
128129

129130

130-
class GitHubActionsReport:
131+
class GitHubActionsRequest:
131132
"""
132-
Report about failed outcomes of GitHub Actions workflow runs.
133+
Fetch outcomes of GitHub Actions workflow runs.
134+
135+
Possible event types are: dynamic, pull_request, push, schedule
133136
"""
134137

135138
DELTA_HOURS = 24
136139

140+
event_section_map = OrderedDict(
141+
schedule="Schedule",
142+
pull_request="Pull requests",
143+
# push="Pushes",
144+
dynamic="Dynamic",
145+
)
146+
137147
def __init__(self, inquiry: MultiRepositoryInquiry):
138148
self.inquiry = inquiry
139149
self.session = HttpClient.session
@@ -164,41 +174,75 @@ def fetch(self, filter: ActionsFilter) -> t.List[ActionsOutcome]: # noqa:A002
164174
runs = munchify(response.json()).workflow_runs
165175
for run in runs:
166176
outcome = ActionsOutcome(
177+
id=run.id,
178+
event=run.event,
167179
status=run.status,
168-
repository=repository,
180+
conclusion=run.conclusion,
181+
repository=run.repository,
169182
name=run.display_title,
170183
url=run.html_url,
171184
started=run.run_started_at,
185+
head_branch=run.head_branch,
172186
)
173187
outcomes.append(outcome)
174188
return outcomes
175189

176-
def to_markdown(self, filter: ActionsFilter) -> str: # noqa:A002
177-
return "\n".join([item.markdown for item in self.fetch(filter=filter)])
190+
@property
191+
def runs_failed(self):
192+
return self.fetch(filter=ActionsFilter(status="failure", created=f">{self.yesterday}"))
178193

179194
@property
180-
def markdown(self):
181-
items_scheduled = self.to_markdown(
182-
ActionsFilter(event="schedule", status="failure", created=f">{self.yesterday}")
183-
)
184-
items_pull_requests = self.to_markdown(
185-
ActionsFilter(event="pull_request", status="failure", created=f">{self.yesterday}")
186-
)
187-
items_dynamic = self.to_markdown(
188-
ActionsFilter(event="dynamic", status="failure", created=f">{self.yesterday}")
195+
def runs_pr_success(self):
196+
return self.fetch(
197+
filter=ActionsFilter(
198+
event="pull_request", status="success", created=f">{self.yesterday}"
199+
)
189200
)
190-
return dedent(f"""
191-
# CI failures report {dt.datetime.now().strftime("%Y-%m-%d")}
192-
A report about GitHub Actions workflow runs that failed recently (now-{self.DELTA_HOURS}h).
193201

194-
## Scheduled
195-
{items_scheduled or "n/a"}
196202

197-
## Pull requests
198-
{items_pull_requests or "n/a"}
203+
class GitHubActionsReport:
204+
"""
205+
Report about failed outcomes of GitHub Actions workflow runs.
206+
"""
207+
208+
def __init__(self, inquiry: MultiRepositoryInquiry):
209+
self.inquiry = inquiry
210+
self.request = GitHubActionsRequest(inquiry)
211+
self.runs_failed = self.request.runs_failed
212+
self.runs_pr_success = self.request.runs_pr_success
213+
214+
@property
215+
def runs(self):
216+
"""
217+
All failed runs, modulo subsequent succeeding PR runs.
218+
"""
219+
for run in self.runs_failed:
220+
if run.event == "pull_request" and self.is_pr_successful(run):
221+
continue
222+
yield run
199223

200-
## Dynamic
201-
{items_dynamic or "n/a"}
224+
def is_pr_successful(self, run):
225+
"""
226+
Find out if a given run has others that succeeded afterward.
227+
"""
228+
for pr in self.runs_pr_success:
229+
if (
230+
run.repository.full_name == pr.repository.full_name
231+
and run.head_branch == pr.head_branch
232+
):
233+
if pr.conclusion == "success":
234+
return True
235+
return False
236+
237+
@property
238+
def markdown(self):
239+
mdc = MarkdownContent(labels=self.request.event_section_map)
240+
for run in self.runs:
241+
mdc.add(run.event, run.markdown)
242+
return dedent(f"""
243+
# CI failures report {dt.datetime.now().strftime("%Y-%m-%d")}
244+
A report about GitHub Actions workflow runs that failed recently (now-{self.request.DELTA_HOURS}h).
245+
{mdc.render()}
202246
""").strip() # noqa: E501
203247

204248
def print(self):

src/rapporto/github/model.py

Lines changed: 43 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,13 @@
44
import typing as t
55
import urllib.parse
66
from abc import abstractmethod
7+
from collections import OrderedDict
78
from enum import Enum
89
from pathlib import Path
910

1011
from attrs import define
1112
from dataclasses_json import CatchAll, Undefined, dataclass_json
13+
from munch import Munch
1214

1315
from rapporto.util import sanitize_title
1416

@@ -239,24 +241,59 @@ def make(cls, repository: str, repositories_file: Path = None) -> "MultiReposito
239241

240242
@dataclasses.dataclass
241243
class ActionsFilter:
242-
event: str
243-
status: str
244-
created: str
244+
event: t.Optional[str] = None
245+
status: t.Optional[str] = None
246+
created: t.Optional[str] = None
245247

246248
@property
247249
def query(self) -> str:
248-
return f"event={self.event}&status={self.status}&created={self.created}"
250+
expression = []
251+
if self.event:
252+
expression.append(f"event={self.event}")
253+
if self.status:
254+
expression.append(f"status={self.status}")
255+
if self.created:
256+
expression.append(f"created={self.created}")
257+
return "&".join(expression)
249258

250259

251260
@dataclasses.dataclass
252261
class ActionsOutcome:
262+
id: int
263+
event: str
253264
status: str
254-
repository: str
265+
conclusion: str
266+
repository: Munch
255267
name: str
256268
url: str
257269
started: str
270+
head_branch: str
258271

259272
@property
260273
def markdown(self):
261-
title = sanitize_title(f"{self.repository}: {self.name}")
274+
title = sanitize_title(f"{self.repository.full_name}: {self.name}")
262275
return f"- [{title}]({self.url})"
276+
277+
278+
@dataclasses.dataclass()
279+
class MarkdownContent:
280+
labels: OrderedDict = dataclasses.field(default_factory=OrderedDict)
281+
content: t.Dict[str, t.List[str]] = dataclasses.field(default_factory=dict)
282+
283+
def add(self, section, content):
284+
self.content.setdefault(section, [])
285+
self.content[section].append(content)
286+
287+
def render_section(self, section) -> t.Optional[str]:
288+
if section not in self.content:
289+
return None
290+
label = self.labels.get(section, section)
291+
body = "\n".join(self.content[section])
292+
return f"\n## {label}\n{body}"
293+
294+
def render(self):
295+
sections = []
296+
for section in self.labels.keys():
297+
if markdown := self.render_section(section):
298+
sections.append(markdown)
299+
return "\n".join(sections)

0 commit comments

Comments
 (0)