From 701a0e9b71aa7c5f1342d661dfb87246231856ef Mon Sep 17 00:00:00 2001 From: naindev <193035803+Nain9Dev@users.noreply.github.com> Date: Wed, 14 Oct 2026 16:40:59 +0200 Subject: [PATCH] feat(report): add --until and --limit to reproduce a baseline Let the period end in the past so the numbers measured before a change can be reproduced after it, and let the number of runs read per repository grow beyond 300 for busy repositories. The snapshot of runners, queue and pull requests always describes the present. --- README.md | 2 ++ report/ci_report.py | 62 +++++++++++++++++++++++++++++++++++------ tests/test_ci_report.py | 40 ++++++++++++++++++++++++++ 3 files changed, 95 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 5529e1f..fa2b909 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,8 @@ Status: in progress. The design is in `specs/next-ci-reliability` of median queue time, followed by a snapshot of today: runners online, offline and busy, queued runs and open pull requests. Use it to plan from the current state, not from memory: `py -3 report/ci_report.py --config train/repos.json --repo Nain9Dev/crm-platform --days 7`. + `--until 2026-10-14T11:00` ends the period in the past, so a baseline can be reproduced after later + changes; `--limit` raises the number of runs read per repository (default 300). ## Rules diff --git a/report/ci_report.py b/report/ci_report.py index 6e67312..93b2019 100644 --- a/report/ci_report.py +++ b/report/ci_report.py @@ -156,14 +156,32 @@ def _parse(moment: str) -> dt.datetime: return dt.datetime.fromisoformat(moment) +def parse_moment(text: str) -> dt.datetime: + """A date, or a date and time, read as UTC.""" + moment = dt.datetime.fromisoformat(text) + return moment if moment.tzinfo else moment.replace(tzinfo=dt.UTC) + + def collect( - gh, repo: str, workflow: str, days: int, now: dt.datetime, sample: int = 30 + gh, + repo: str, + workflow: str, + days: int, + now: dt.datetime, + sample: int = 30, + until: dt.datetime | None = None, + limit: int = 300, ) -> RepoStats: - since = now - dt.timedelta(days=days) + """Metrics for runs created in the `days` before `until` (default `now`). + + The snapshot (runners, queue, pull requests) is always about `now`. + """ + end = until or now + since = end - dt.timedelta(days=days) runs = [ r - for r in gh.runs(repo, 300) - if r.get("workflowName") == workflow and _parse(r["createdAt"]) >= since + for r in gh.runs(repo, limit) + if r.get("workflowName") == workflow and since <= _parse(r["createdAt"]) <= end ] conclusive = [r for r in runs if r.get("conclusion") in ("success", "failure")] stats = RepoStats(repo=repo) @@ -236,14 +254,25 @@ def _pct(value: float | None) -> str: return "-" if value is None else f"{round(value * 100)}%" -def render(stats: list[RepoStats], days: int, now: dt.datetime) -> str: - since = (now - dt.timedelta(days=days)).strftime("%Y-%m-%d") +def render( + stats: list[RepoStats], + days: int, + now: dt.datetime, + until: dt.datetime | None = None, +) -> str: + end = until or now + since = (end - dt.timedelta(days=days)).strftime("%Y-%m-%d") lines = [ "# CI health report", "", ( - f"Period: {since} to {now.strftime('%Y-%m-%d')} ({days} days). " + f"Period: {since} to {end.strftime('%Y-%m-%d')} ({days} days). " "Conclusive runs only; cancelled runs are not failures." + + ( + f" The snapshot is about now ({now.strftime('%Y-%m-%d %H:%M')} UTC)." + if until + else "" + ) ), "", "| Repository | Runs | Failed | Fail rate | Infra share | Median duration | Median queue |", @@ -284,6 +313,17 @@ def main(argv: list[str] | None = None) -> int: parser.add_argument("--config", help="train repos.json: its repositories are added") parser.add_argument("--days", type=int, default=7) parser.add_argument("--workflow", default="CI") + parser.add_argument( + "--until", + help="end of the period, UTC (2026-10-14 or 2026-10-14T11:00); default now. " + "Use it to reproduce a baseline after later changes.", + ) + parser.add_argument( + "--limit", + type=int, + default=300, + help="runs read per repository (gh allows up to 1000)", + ) parser.add_argument("--output", help="write the report here instead of printing it") args = parser.parse_args(argv) @@ -297,8 +337,12 @@ def main(argv: list[str] | None = None) -> int: now = dt.datetime.now(dt.UTC) gh = Github() - stats = [collect(gh, repo, args.workflow, args.days, now) for repo in repos] - text = render(stats, args.days, now) + until = parse_moment(args.until) if args.until else None + stats = [ + collect(gh, repo, args.workflow, args.days, now, until=until, limit=args.limit) + for repo in repos + ] + text = render(stats, args.days, now, until=until) if args.output: Path(args.output).write_text(text + "\n", encoding="utf-8") else: diff --git a/tests/test_ci_report.py b/tests/test_ci_report.py index c251e95..84a5a8f 100644 --- a/tests/test_ci_report.py +++ b/tests/test_ci_report.py @@ -178,6 +178,46 @@ def test_a_repository_without_runs_has_no_rates_instead_of_dividing_by_zero(self self.assertIsNone(stats.median_queue_s) +class Until(unittest.TestCase): + """A window that ends in the past makes a baseline reproducible after later changes.""" + + def test_runs_created_after_the_end_of_the_window_are_ignored(self): + gh = sample_github() + gh._runs.append(run(20, "failure", 10, 60)) # 10 minutes ago + gh._jobs[20] = [("Verify", "failure", 5, "Pruebas")] + until = NOW - dt.timedelta(minutes=30) + stats = report.collect(gh, REPO, "CI", days=7, now=NOW, until=until) + self.assertEqual(stats.conclusive, 4) + self.assertEqual(stats.failed, 2) + + def test_without_until_the_window_ends_now(self): + gh = sample_github() + gh._runs.append(run(20, "failure", 10, 60)) + gh._jobs[20] = [("Verify", "failure", 5, "Pruebas")] + stats = report.collect(gh, REPO, "CI", days=7, now=NOW) + self.assertEqual(stats.conclusive, 5) + + def test_the_window_start_moves_with_until(self): + until = NOW - dt.timedelta(days=3) + text = report.render([], days=7, now=NOW, until=until) + self.assertIn("2026-10-04 to 2026-10-11", text) + self.assertIn("snapshot", text.lower()) + + def test_a_moment_may_be_a_date_or_a_date_and_time_in_utc(self): + self.assertEqual( + report.parse_moment("2026-10-14"), + dt.datetime(2026, 10, 14, 0, 0, tzinfo=dt.UTC), + ) + self.assertEqual( + report.parse_moment("2026-10-14T11:00"), + dt.datetime(2026, 10, 14, 11, 0, tzinfo=dt.UTC), + ) + self.assertEqual( + report.parse_moment("2026-10-14T11:00:00Z"), + dt.datetime(2026, 10, 14, 11, 0, tzinfo=dt.UTC), + ) + + class Rendering(unittest.TestCase): def test_the_table_row_is_pinned(self): stats = report.collect(sample_github(), REPO, "CI", days=7, now=NOW)