Repository navigation
Publish a summary issue after each release sweep #219
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,110 @@ | ||
| """The release-sweep summary: findings, fixes, people's queue and cost. | ||
|
|
||
| Every settled sweep writes ``<state_dir>/reports/sweep-<repo>-<tag>.md``. | ||
| For a repository in auto_merge with a public upstream, the same summary is | ||
| also published as one issue in the knowledge repository through the outbox | ||
| (``open_issue``); the publisher creates it once, keyed by its title. Shadow | ||
| and private repositories publish nothing. | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import json | ||
| from collections import Counter | ||
|
|
||
| MARKER = "<!-- kb-sweep-report:v1 -->" | ||
| PENDING = "pending_sweep_reports" | ||
|
|
||
|
|
||
| def queue_report(ledger, repo: str, report: dict) -> None: | ||
| pending = json.loads(ledger.get_cursor(repo, PENDING) or "[]") | ||
| if not any(item.get("run_id") == report.get("run_id") for item in pending): | ||
| pending.append(report) | ||
| ledger.set_cursor(repo, PENDING, json.dumps(pending, sort_keys=True, default=str)) | ||
|
|
||
|
|
||
| def flush_reports(rt, lifecycle) -> list[str]: | ||
| """Publish every queued report; a failure keeps it (and the rest) queued.""" | ||
| pending = json.loads(rt.ledger.get_cursor(lifecycle.repo, PENDING) or "[]") | ||
| done: list[str] = [] | ||
| while pending: | ||
| publish_summary(rt, lifecycle, pending[0]) | ||
| done.append(str(pending[0].get("run_id"))) | ||
| pending = pending[1:] | ||
| rt.ledger.set_cursor(lifecycle.repo, PENDING, json.dumps(pending, sort_keys=True, default=str)) | ||
| return done | ||
|
|
||
|
|
||
| def report_id(rt, report: dict) -> str: | ||
| """Stable for one sweep (across its resumed attempts), distinct across | ||
| sweeps: a release sweep is its tag; a periodic fallback sweep re-checks the | ||
| same tag, so it also carries its start date.""" | ||
| import datetime as dt | ||
|
|
||
| sweep = report["sweep"] | ||
| tag = sweep.get("tag") or "untagged" | ||
| if sweep.get("reason") == "release": | ||
| return tag | ||
| started = float(report.get("started_at") or rt.clock()) | ||
| return f"{tag}-{sweep.get('reason') or 'sweep'}-" \ | ||
| f"{dt.datetime.fromtimestamp(started, dt.timezone.utc).strftime('%Y%m%d')}" | ||
|
|
||
|
|
||
| def summary(rt, lifecycle, report: dict) -> tuple[str, str]: | ||
| sweep = report["sweep"] | ||
| tag = report_id(rt, report) | ||
| t1 = report.get("t1") or {} | ||
| statuses: Counter = Counter() | ||
| ops: Counter = Counter() | ||
| for changeset_id in report.get("changesets") or []: | ||
| changeset = rt.ledger.changeset(changeset_id) | ||
| statuses[changeset["status"]] += 1 | ||
| for op in changeset["detail"].get("operations") or []: | ||
| ops[op["kind"]] += 1 | ||
| tokens = {"input": 0, "output": 0} | ||
| seconds = 0.0 | ||
| calls = 0 | ||
| if getattr(rt, "traces", None) is not None and report.get("run_id"): | ||
| run_id = report["run_id"] # this sweep instance only (a fallback reuses the tag) | ||
| for record in rt.traces.query(kind="model_call", run_id=run_id, limit=100000): | ||
| calls += 1 | ||
| tokens["input"] += int((record.get("usage") or {}).get("input_tokens") or 0) | ||
| tokens["output"] += int((record.get("usage") or {}).get("output_tokens") or 0) | ||
| seconds += float(record.get("seconds") or 0) | ||
| title = f"Knowledge sweep report: {lifecycle.repo} {tag}" | ||
| lines = [ | ||
| MARKER, | ||
| f"Release sweep of `{lifecycle.repo}` for **{tag}** " | ||
| f"(`{str(sweep.get('from_sha') or '')[:12]}..{str(sweep.get('to_sha') or '')[:12]}`, " | ||
| f"trigger: {sweep.get('reason', '')}).", | ||
| "", | ||
| "| | |", "|---|---|", | ||
| f"| structural issues (T1) | {len(t1.get('issues') or [])} |", | ||
| f"| duplicate rule IDs | {len(t1.get('duplicates') or {})} |", | ||
| f"| pages over capacity | {len(t1.get('over_capacity') or {})} |", | ||
| f"| pages missing from an index (fixed) | {len(t1.get('unlisted') or [])} |", | ||
| f"| pages kept as they are | {len(report.get('kept_pages', report.get('skipped_pages')) or [])} |", | ||
| f"| pages that failed an evaluation | {len(report.get('failed_attempts') or set(report.get('failed_pages') or []))} " | ||
| f"({sum((report.get('failed_attempts') or {}).values())} failed attempts) |", | ||
| f"| change sets | {sum(statuses.values())} " | ||
| f"({', '.join(f'{k}: {v}' for k, v in sorted(statuses.items())) or 'none'}) |", | ||
| f"| operations | {', '.join(f'{k}: {v}' for k, v in sorted(ops.items())) or 'none'} |", | ||
| f"| waiting for people | {len(rt.ledger.human_queue(lifecycle.repo))} |", | ||
| f"| model calls | {calls} ({tokens['input']} in / {tokens['output']} out tokens, {seconds:.0f}s) |", | ||
| ] | ||
| if report.get("breaker"): | ||
| lines += ["", f"**Circuit breaker:** {report['breaker']}; every change set went to people."] | ||
| lines += ["", "Generated by the Copilot knowledge service."] | ||
| return title, "\n".join(lines) + "\n" | ||
|
|
||
|
|
||
| def publish_summary(rt, lifecycle, report: dict) -> str: | ||
| """Write the summary locally; open the issue only where publication is allowed.""" | ||
| title, body = summary(rt, lifecycle, report) | ||
| path = rt.state_dir / "reports" / f"sweep-{lifecycle.repo}-{report_id(rt, report)}.md" | ||
| path.parent.mkdir(parents=True, exist_ok=True) | ||
| path.write_text(f"# {title}\n\n{body}", encoding="utf-8") | ||
|
Comment on lines
+104
to
+106
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
For repositories whose valid release tags contain Useful? React with 👍 / 👎. |
||
| if rt.outbox is None or not lifecycle.auto_merge or not lifecycle.publishes: | ||
| return "recorded" | ||
| rt.outbox.issue(lifecycle.repo, "open_issue", {"title": title, "body": body, "labels": ["kb:sweep-report"]}) | ||
| return "issued" | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -272,7 +272,7 @@ def _progress(rt, lifecycle, sweep: dict) -> dict: | |
| key = [sweep["tag"], sweep["from_sha"], sweep["to_sha"]] | ||
| if not progress or progress.get("key") != key: | ||
| progress = {"key": key, "done": [], "attempts": {}, "t1_done": False, "purge_done": False, | ||
| "held": []} | ||
| "held": [], "kept": [], "started_at": rt.clock()} | ||
| return progress | ||
|
|
||
|
|
||
|
|
@@ -281,9 +281,21 @@ def run_sweep(rt, lifecycle, owner: str, sweep: dict, upstream: UpstreamRepo, | |
| """Traced: every model call and decision of the sweep carries its release.""" | ||
| from ..trace_store import trace_context | ||
|
|
||
| # one identity per sweep instance, persisted before any work so every | ||
| # resumed attempt traces under it (a fallback sweep reuses the tag) | ||
| progress = _progress(rt, lifecycle, sweep) | ||
| if rt.ledger.get_cursor(lifecycle.repo, "sweep_progress") in (None, ""): | ||
| rt.ledger.set_cursor(lifecycle.repo, "sweep_progress", json.dumps(progress, sort_keys=True)) | ||
| run_id = sweep_run_id(lifecycle.repo, sweep, progress) | ||
| with trace_context(playbook="kb-sweep", repo=lifecycle.repo, release=str(sweep.get("tag") or ""), | ||
| run_id=f"sweep-{lifecycle.repo}-{sweep.get('tag') or ''}"): | ||
| return _run_sweep(rt, lifecycle, owner, sweep, upstream, audit_hints) | ||
| run_id=run_id): | ||
| report = _run_sweep(rt, lifecycle, owner, sweep, upstream, audit_hints) | ||
| report["run_id"] = run_id | ||
| return report | ||
|
|
||
|
|
||
| def sweep_run_id(repo: str, sweep: dict, progress: dict) -> str: | ||
| return f"sweep-{repo}-{sweep.get('tag') or ''}-{int(float(progress.get('started_at') or 0))}" | ||
|
|
||
|
|
||
| def draft_key_for_page(repo: str, sweep: dict, page: str) -> str: | ||
|
|
@@ -348,6 +360,7 @@ def _run_sweep(rt, lifecycle, owner: str, sweep: dict, upstream: UpstreamRepo, | |
| continue | ||
| if outcome is None: | ||
| report["skipped_pages"].append(page) | ||
| progress.setdefault("kept", []).append(page) | ||
| progress["done"].append(page) | ||
| continue | ||
| operations, result = outcome | ||
|
|
@@ -382,7 +395,16 @@ def _run_sweep(rt, lifecycle, owner: str, sweep: dict, upstream: UpstreamRepo, | |
| report["changesets"] = list(progress.get("held", [])) | ||
| report["breaker"] = breaker | ||
| report["complete"] = settled | ||
| # the whole sweep, across every resumed attempt (not just this call) | ||
| report["kept_pages"] = list(progress.get("kept", [])) | ||
| report["failed_attempts"] = dict(progress.get("attempts", {})) | ||
| report["started_at"] = progress.get("started_at") | ||
| if settled: | ||
| # the report is queued durably BEFORE the progress that produced it is | ||
| # cleared; the scheduler flushes it (and retries until it succeeds) | ||
| from .report import queue_report | ||
|
|
||
| queue_report(rt.ledger, lifecycle.repo, {**report, "run_id": sweep_run_id(lifecycle.repo, sweep, progress)}) | ||
|
Comment on lines
+403
to
+407
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
A sweep can run through the existing Useful? React with 👍 / 👎. |
||
| rt.ledger.set_cursor(lifecycle.repo, "release", sweep["tag"]) | ||
| rt.ledger.set_cursor(lifecycle.repo, "sweep_baseline", sweep["to_sha"]) | ||
| rt.ledger.set_cursor(lifecycle.repo, "last_sweep_at", str(rt.clock())) | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When an
open_issueitem expires or its generation becomes stale because the repository is paused or rolled back before the publisher handles it,check_item()refuses it without an ack. These lines nevertheless remove the report from the pending queue immediately after merely writing that item, and unlike change-set actions nothing reissues stale or expired report items, so the promised summary issue is permanently lost. Track the item through acknowledgement or retain enough pending state to reissue it.Useful? React with 👍 / 👎.