Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
62 changes: 53 additions & 9 deletions report/ci_report.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 |",
Expand Down Expand Up @@ -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)

Expand All @@ -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:
Expand Down
40 changes: 40 additions & 0 deletions tests/test_ci_report.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading