diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b268c3..792e8cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,8 +12,8 @@ versions follow [SemVer](https://semver.org/spec/v2.0.0.html). - Refresh coverage overview ordering and visibility on PR merge, closure, or reopening, without waiting for main CI; support manual coverage-only refreshes. - Add bounded Rust coverage CI with immutable per-run/per-attempt LCOV and HTML artifacts. -- Publish retained coverage history to Pages, including late-attempt archives and pull-request - state filtering. +- Publish one coverage result per PR, preferring exact merge-commit coverage, with a separate + pre-/post-merge view and immutable archives for all runs and attempts. - Restrict CI cache writes to main, preserve Cargo build timings, verify draft release assets before immutable publication, and add deployment-only website artwork and shared contributor rules. diff --git a/docs/infrastructure.md b/docs/infrastructure.md index d4ce2fe..a3ce83b 100644 --- a/docs/infrastructure.md +++ b/docs/infrastructure.md @@ -39,12 +39,16 @@ Completed, Commit, Workflow, Lines, Branches, and Functions. Coderef's Python ge rates from summed LCOV counters, links each immutable report and its LCOV/metadata downloads, and displays completion times in UTC. Zero or unavailable metric totals display `n/a`, including branch coverage when Rust instrumentation produces no branch measurements. Existing archives -are read without modification. The overview shows the latest run per target, with main first; -`history.html` retains every run and retry, including closed PRs. Run creation time determines -which run is latest, so retrying an older run cannot replace a newer run. Attempts within a run -are compared numerically. PR rows retain their own PR CI result, sorted by merge time after merging; -the main row contains the latest main CI result. Open PRs follow merged PRs. The coverage figures -describe Rust, not the Python or TypeScript scripts. +are read without modification. The overview shows one result per PR, ordered by merge time, +then open PRs. Pre-merge coverage is replaced only when a main report tests that PR's exact +merge commit; unrelated main runs are omitted. Aggregation PR coverage is never attributed to +its constituent PRs. PRs without pre-merge reports can still show post-merge coverage. +`history.html` shows at most one pre-merge and one post-merge result per PR. Run creation time +selects the newest run within each phase, with numeric attempt ordering for retries. Late +pre-merge reports cannot displace post-merge results. Closed unmerged PRs are omitted from both +views and reappear when reopened. All immutable run URLs remain available, including omitted +runs and retries. A minimal `pull-requests.json` registry records merge SHAs for exact attribution. +The coverage figures describe Rust, not the Python or TypeScript scripts. PR closure (including merge) and reopening trigger a metadata-only refresh using trusted `main` publisher code. This updates overview ordering and visibility without downloading artifacts or replacing measured coverage. Manual refreshes use `gh workflow run pages.yml -f coverage_refresh=true`; diff --git a/tools/coverage_index.py b/tools/coverage_index.py index d9b58b7..7691e16 100644 --- a/tools/coverage_index.py +++ b/tools/coverage_index.py @@ -93,6 +93,9 @@ def _pulls(path: Path) -> dict[int, dict]: def history(root: Path, pulls_path: Path | None = None) -> list[dict]: root.mkdir(parents=True, exist_ok=True) pulls = _pulls(pulls_path) if pulls_path else {} + registry = [{key: pull.get(key) for key in ("number", "state", "merged_at", "merge_commit_sha")} + for pull in pulls.values()] + (root / "pull-requests.json").write_text(json.dumps(registry, indent=2) + "\n", encoding="utf-8") reports = [] for report in _reports(root): target = report.get("target", "main") @@ -118,24 +121,47 @@ def _run_order(report: dict) -> tuple: def regenerate(root: Path) -> int: reports = _read(root / "history.json") if (root / "history.json").exists() else _reports(root) - reports.sort(key=_run_order, reverse=True) + pulls = _pulls(root / "pull-requests.json") + merges = {pull["merge_commit_sha"]: number for number, pull in pulls.items() + if pull.get("merged_at") and pull.get("merge_commit_sha")} latest = {} - for report in reports: - if report.get("visible", True): - latest.setdefault(report.get("target", "main"), report) - visible = sorted(latest.values(), key=lambda report: ( - report.get("target", "main") == "main", + for original in sorted(reports, key=_run_order, reverse=True): + report = dict(original) + target = report.get("target", "main") + phase = "" + if target == "main": + number = merges.get(report.get("head_sha") or report.get("sha")) + if number is None: + continue + target = f"pr/{number}" + phase = "post-merge" + elif target.startswith("pr/"): + phase = "pre-merge" + if target.startswith("pr/"): + pull = pulls.get(int(target[3:])) + if pull is not None: + report["visible"] = pull.get("state") != "closed" or bool(pull.get("merged_at")) + report["reference_time"] = pull.get("merged_at") + if not report.get("visible", True): + continue + report.update(target=target, phase=phase) + latest.setdefault((target, phase), report) + phases = sorted(latest.values(), key=lambda report: ( bool(report.get("reference_time")), _time(report.get("reference_time") or report.get("created_at") or report.get("completed_at")), - _run_order(report)), reverse=True) + report["phase"], _run_order(report)), reverse=True) + visible = [report for report in phases + if report["phase"] != "pre-merge" or (report["target"], "post-merge") not in latest] (root / "index.html").write_text(_render( root, visible, "coderef coverage reports", - "Latest report per target: main first, then merged PRs by merge time, then open PRs. " - "PR rows show PR CI coverage; main shows post-merge CI coverage. Closed unmerged PRs are omitted.", - _link("history.html", "All retained runs and attempts")), encoding="utf-8") + "One result per PR, ordered by merge time, then open PRs. Pre-merge coverage is replaced " + "only by coverage of the exact merge commit. Closed unmerged PRs are omitted; " + "their direct report URLs remain available.", + _link("history.html", "Pre-merge and post-merge results")), encoding="utf-8") (root / "history.html").write_text(_render( - root, reports, "coderef coverage run history", - "All retained CI runs and attempts, newest run first, including closed PRs.", + root, phases, "coderef pre-merge and post-merge coverage", + "One result per PR phase. Post-merge results test the exact merge commit; " + "retries and unrelated main runs are omitted.", _link("index.html", "Current coverage overview")), encoding="utf-8") return len(visible) @@ -146,6 +172,8 @@ def _render(root: Path, reports: list[dict], title: str, description: str, navig target = report.get("target", "main") path = report["path"] label = f"PR {target[3:]}" if target.startswith("pr/") else target + if report.get("phase"): + label += f" ({report['phase']})" if target == "main": source = _link(f"{_REPOSITORY}/tree/main", "main branch") elif target.startswith("pr/"): diff --git a/tools/coverage_index_test.py b/tools/coverage_index_test.py index d18632c..c74f63d 100644 --- a/tools/coverage_index_test.py +++ b/tools/coverage_index_test.py @@ -43,7 +43,7 @@ def test_closed_unmerged_report_hidden(self): self.assertFalse(next(item for item in reports if item["target"] == "pr/12")["visible"]) regenerate(root) self.assertNotIn("PR 12", (root / "index.html").read_text()) - self.assertIn("PR 12", (root / "history.html").read_text()) + self.assertNotIn("PR 12", (root / "history.html").read_text()) def test_coverage_cli_archive_history_regenerate_links_resolve(self): script = Path(__file__).with_name("coverage_index.py") @@ -140,17 +140,17 @@ def test_coverage_overview_empty_and_legacy_metadata_render_safely(self): self.assertEqual(regenerate(root), 0) self.assertIn("No coverage reports", (root / "index.html").read_text()) records = [{"path": "runs/7/1", "target": ''}, - {"path": "runs/8/1", "target": "main", "sha": "123456789"}] + {"path": "runs/8/1", "target": "pr/8", "sha": "123456789"}] (root / "history.json").write_text(json.dumps(records)) self.assertEqual(regenerate(root), 2) html = (root / "index.html").read_text() self.assertNotIn('