Skip to content

Commit ba603e3

Browse files
johnramsdenclaude
andcommitted
ci: add weekly CI health report workflow
Adds a scheduled GitHub Actions workflow that queries recent job results and posts a failure rate report to a designated GitHub issue. The report logic lives in .github/scripts/ci_health_report.py; the workflow simply checks out the repo and runs the script. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Signed-off-by: John Ramsden <john.ramsden@canonical.com>
1 parent 706a3a6 commit ba603e3

File tree

2 files changed

+343
-0
lines changed

2 files changed

+343
-0
lines changed
Lines changed: 280 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,280 @@
1+
#!/usr/bin/env python3
2+
"""
3+
CI Health Report
4+
5+
Queries completed GitHub Actions workflow runs over a lookback window,
6+
calculates per-job failure rates, and posts a summary to a GitHub issue.
7+
8+
Required environment variables:
9+
GH_TOKEN - GitHub token with actions:read and issues:write
10+
GH_REPO - Repository in "owner/repo" format
11+
REPORT_ISSUE - Issue number to post the report to
12+
LOOKBACK_DAYS - How many days back to look (default: 30)
13+
"""
14+
15+
import os
16+
import sys
17+
import time
18+
from datetime import datetime, timedelta, timezone
19+
from collections import defaultdict
20+
import urllib.request
21+
import urllib.error
22+
import json
23+
24+
COUNTED_CONCLUSIONS = {"success", "failure"}
25+
26+
27+
def _headers(token):
28+
return {
29+
"Authorization": f"Bearer {token}",
30+
"Accept": "application/vnd.github+json",
31+
"X-GitHub-Api-Version": "2022-11-28",
32+
}
33+
34+
35+
def _urlopen(req):
36+
"""Execute a request with one automatic retry on rate limit (403/429)."""
37+
try:
38+
with urllib.request.urlopen(req) as resp:
39+
return resp.read()
40+
except urllib.error.HTTPError as e:
41+
if e.code not in (403, 429):
42+
raise RuntimeError(f"GitHub API error {e.code} for {req.full_url}") from e
43+
retry_after = e.headers.get("Retry-After")
44+
reset = e.headers.get("X-RateLimit-Reset")
45+
if retry_after:
46+
wait = int(retry_after) + 5
47+
elif reset:
48+
wait = max(0, int(reset) - int(time.time())) + 5
49+
else:
50+
wait = 60
51+
print(f"Rate limited (HTTP {e.code}). Waiting {wait}s before retry...", file=sys.stderr)
52+
time.sleep(wait)
53+
# Retry once outside the except block so a second failure is handled cleanly.
54+
try:
55+
with urllib.request.urlopen(req) as resp:
56+
return resp.read()
57+
except urllib.error.HTTPError as retry_e:
58+
print(
59+
f"Error: rate limit persists after retry (HTTP {retry_e.code}). Giving up.",
60+
file=sys.stderr,
61+
)
62+
sys.exit(1)
63+
64+
65+
def gh_get(token, path):
66+
"""Fetch a single page from the GitHub API and return parsed JSON."""
67+
url = f"https://api.github.com{path}"
68+
req = urllib.request.Request(url, headers=_headers(token))
69+
return json.loads(_urlopen(req))
70+
71+
72+
def get_runs(token, repo, since):
73+
"""Return all completed workflow runs created on or after `since`."""
74+
runs = []
75+
page = 1
76+
while True:
77+
data = gh_get(token, (
78+
f"/repos/{repo}/actions/runs"
79+
f"?status=completed&created=>={since}&per_page=100&page={page}"
80+
))
81+
batch = data.get("workflow_runs", [])
82+
if not batch:
83+
break
84+
runs.extend(batch)
85+
page += 1
86+
return runs
87+
88+
89+
def get_jobs(token, repo, run_id):
90+
"""Return all jobs for a workflow run."""
91+
jobs = []
92+
page = 1
93+
while True:
94+
data = gh_get(token, (
95+
f"/repos/{repo}/actions/runs/{run_id}/jobs"
96+
f"?per_page=100&page={page}"
97+
))
98+
batch = data.get("jobs", [])
99+
if not batch:
100+
break
101+
jobs.extend(batch)
102+
page += 1
103+
return jobs
104+
105+
106+
def post_comment(token, repo, issue_number, body):
107+
"""Post a comment to a GitHub issue."""
108+
url = f"https://api.github.com/repos/{repo}/issues/{issue_number}/comments"
109+
payload = json.dumps({"body": body}).encode()
110+
req = urllib.request.Request(url, data=payload, headers={
111+
**_headers(token),
112+
"Content-Type": "application/json",
113+
})
114+
_urlopen(req)
115+
116+
117+
def build_report(stats, lookback_days, top_n, now):
118+
"""Build the markdown report string from aggregated job stats."""
119+
# Sort by failure rate descending for the main table
120+
rows = sorted(stats.items(), key=lambda x: x[1]["failures"] / x[1]["runs"], reverse=True)
121+
122+
table_lines = []
123+
for (workflow, job), s in rows:
124+
rate = s["failures"] / s["runs"] * 100
125+
table_lines.append(f"| {workflow} | {job} | {s['runs']} | {s['failures']} | {rate:.1f}% |")
126+
127+
# Top N by absolute failure count
128+
top = sorted(stats.items(), key=lambda x: x[1]["failures"], reverse=True)[:top_n]
129+
top_lines = []
130+
for rank, ((workflow, job), s) in enumerate(top, start=1):
131+
rate = s["failures"] / s["runs"] * 100
132+
top_lines.append(f"{rank}. **{workflow} / {job}** — {s['failures']} failures ({rate:.1f}%)")
133+
134+
total_runs = sum(s["runs"] for s in stats.values())
135+
total_failures = sum(s["failures"] for s in stats.values())
136+
overall_rate = (total_failures / total_runs * 100) if total_runs else 0.0
137+
138+
timestamp = now.strftime("%Y-%m-%dT%H:%M:%SZ")
139+
140+
lines = [
141+
"## CI Health Report",
142+
"",
143+
f"_Last {lookback_days} days — generated {timestamp}_",
144+
"",
145+
"### Job Failure Rates",
146+
"",
147+
"| Workflow | Job | Runs | Failures | Rate |",
148+
"|----------|-----|------|----------|------|",
149+
*table_lines,
150+
"",
151+
f"### Top {top_n} Most Failing Jobs",
152+
"",
153+
*top_lines,
154+
"",
155+
"### Summary",
156+
"",
157+
f"- **Total job runs:** {total_runs}",
158+
f"- **Total failures:** {total_failures}",
159+
f"- **Overall failure rate:** {overall_rate:.1f}%",
160+
]
161+
return "\n".join(lines)
162+
163+
164+
def main():
165+
token = os.environ.get("GH_TOKEN", "")
166+
repo = os.environ.get("GH_REPO", "")
167+
issue_number = os.environ.get("REPORT_ISSUE", "")
168+
lookback_days_str = os.environ.get("LOOKBACK_DAYS", "")
169+
top_jobs_str = os.environ.get("TOP_JOBS", "")
170+
171+
if not token or not repo or not issue_number or not lookback_days_str or not top_jobs_str:
172+
print("Error: GH_TOKEN, GH_REPO, REPORT_ISSUE, LOOKBACK_DAYS, and TOP_JOBS must all be set.", file=sys.stderr)
173+
sys.exit(1)
174+
175+
lookback_days = int(lookback_days_str)
176+
top_jobs = int(top_jobs_str)
177+
178+
now = datetime.now(timezone.utc)
179+
since = (now - timedelta(days=lookback_days)).strftime("%Y-%m-%dT%H:%M:%SZ")
180+
181+
print(f"Fetching workflow runs since {since}...")
182+
runs = get_runs(token, repo, since)
183+
print(f"Found {len(runs)} completed runs.")
184+
185+
if not runs:
186+
print("No runs found. Skipping report.")
187+
return
188+
189+
# Aggregate: (workflow_name, job_name) -> {runs, failures}
190+
# Only "success" and "failure" conclusions are counted; skipped/cancelled are excluded.
191+
stats = defaultdict(lambda: {"runs": 0, "failures": 0})
192+
193+
for i, run in enumerate(runs, start=1):
194+
print(f" Fetching jobs for run {i}/{len(runs)} (id={run['id']})...")
195+
jobs = get_jobs(token, repo, run["id"])
196+
for job in jobs:
197+
conclusion = job.get("conclusion")
198+
if conclusion not in COUNTED_CONCLUSIONS:
199+
continue
200+
key = (run["name"], job["name"])
201+
stats[key]["runs"] += 1
202+
if conclusion == "failure":
203+
stats[key]["failures"] += 1
204+
205+
if not stats:
206+
print("No job data collected. Skipping report.")
207+
return
208+
209+
report = build_report(stats, lookback_days, top_jobs, now)
210+
211+
# Write to GitHub step summary if available
212+
summary_path = os.environ.get("GITHUB_STEP_SUMMARY")
213+
if summary_path:
214+
with open(summary_path, "a") as f:
215+
f.write(report + "\n")
216+
217+
print(f"Posting report to issue #{issue_number}...")
218+
post_comment(token, repo, issue_number, report)
219+
print("Report generated successfully.")
220+
221+
## TESTS ##
222+
223+
import unittest
224+
from unittest.mock import MagicMock, patch
225+
226+
227+
class _Tests(unittest.TestCase):
228+
229+
@patch("time.sleep")
230+
@patch("urllib.request.urlopen")
231+
def test_rate_limit_retries_with_wait(self, mock_urlopen, mock_sleep):
232+
"""_urlopen sleeps Retry-After + 5s on 429 then retries successfully."""
233+
import http.client
234+
msg = http.client.HTTPMessage()
235+
msg["Retry-After"] = "10"
236+
resp = MagicMock()
237+
resp.read.return_value = b'{"ok": true}'
238+
resp.__enter__ = lambda s: s
239+
resp.__exit__ = MagicMock(return_value=False)
240+
mock_urlopen.side_effect = [
241+
urllib.error.HTTPError("https://api.github.com/test", 429, "Too Many Requests", msg, None),
242+
resp,
243+
]
244+
result = _urlopen(urllib.request.Request("https://api.github.com/test"))
245+
mock_sleep.assert_called_once_with(15) # Retry-After(10) + 5
246+
self.assertEqual(result, b'{"ok": true}')
247+
248+
def test_build_report_structure_and_totals(self):
249+
"""build_report produces a markdown table and correct summary totals."""
250+
stats = defaultdict(lambda: {"runs": 0, "failures": 0})
251+
stats[("Tests", "build")]["runs"] = 10
252+
stats[("Tests", "build")]["failures"] = 3
253+
now = datetime(2026, 1, 1, 9, 0, 0, tzinfo=timezone.utc)
254+
report = build_report(stats, 30, 5, now)
255+
self.assertIn("| Workflow | Job | Runs | Failures | Rate |", report)
256+
self.assertIn("| Tests | build | 10 | 3 | 30.0% |", report)
257+
self.assertIn("**Total job runs:** 10", report)
258+
self.assertIn("**Total failures:** 3", report)
259+
self.assertIn("**Overall failure rate:** 30.0%", report)
260+
261+
@patch("ci_health_report.post_comment")
262+
@patch("ci_health_report.get_jobs")
263+
@patch("ci_health_report.get_runs")
264+
def test_skipped_and_cancelled_not_counted(self, mock_runs, mock_jobs, mock_comment):
265+
"""skipped and cancelled conclusions are excluded from run and failure counts."""
266+
mock_runs.return_value = [{"id": 1, "name": "Tests"}]
267+
mock_jobs.return_value = [
268+
{"name": "build", "conclusion": "success"},
269+
{"name": "build", "conclusion": "failure"},
270+
{"name": "build", "conclusion": "skipped"},
271+
{"name": "build", "conclusion": "cancelled"},
272+
]
273+
with patch.dict("os.environ", {"GH_TOKEN": "tok", "GH_REPO": "o/r", "REPORT_ISSUE": "1", "LOOKBACK_DAYS": "30", "TOP_JOBS": "5"}):
274+
main()
275+
report = mock_comment.call_args[0][3]
276+
self.assertIn("| Tests | build | 2 | 1 |", report)
277+
278+
279+
if __name__ == "__main__":
280+
main()
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
name: CI Health Report
2+
3+
on:
4+
push:
5+
paths:
6+
- .github/scripts/ci_health_report.py
7+
pull_request:
8+
paths:
9+
- .github/scripts/ci_health_report.py
10+
schedule:
11+
- cron: "0 9 * * 1" # Every Monday at 09:00 UTC
12+
workflow_dispatch:
13+
inputs:
14+
report-issue:
15+
description: "GitHub issue number to post the report to"
16+
required: false
17+
lookback-days:
18+
description: "How many days back to look (default: 30)"
19+
required: false
20+
default: "30"
21+
top-jobs:
22+
description: "Number of top failing jobs to highlight (default: 5)"
23+
required: false
24+
default: "5"
25+
26+
env:
27+
REPORT_ISSUE: ${{ inputs.report-issue || vars.CI_HEALTH_REPORT_ISSUE }}
28+
LOOKBACK_DAYS: ${{ inputs.lookback-days || vars.LOOKBACK_DAYS || '30' }}
29+
TOP_JOBS: ${{ inputs.top-jobs || vars.TOP_JOBS || '5' }}
30+
31+
permissions:
32+
actions: read
33+
issues: write
34+
35+
jobs:
36+
test:
37+
runs-on: ubuntu-latest
38+
steps:
39+
- uses: actions/checkout@v4
40+
- name: Test CI health report script
41+
run: python3 -m unittest ci_health_report -v
42+
working-directory: .github/scripts
43+
44+
report:
45+
needs: test
46+
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
47+
runs-on: ubuntu-latest
48+
steps:
49+
- name: Validate report issue
50+
if: ${{ env.REPORT_ISSUE == '' }}
51+
run: |
52+
echo "::error::REPORT_ISSUE is not set. Configure vars.CI_HEALTH_REPORT_ISSUE or pass report-issue input."
53+
exit 1
54+
55+
- name: Checkout
56+
uses: actions/checkout@v4
57+
58+
- name: Generate CI Health Report
59+
env:
60+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
61+
GH_REPO: ${{ github.repository }}
62+
PYTHONUNBUFFERED: "1"
63+
run: python3 .github/scripts/ci_health_report.py

0 commit comments

Comments
 (0)