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)