Coverage for src/keel/closeorder.py: 100%
58 statements
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-16 18:07 +0000
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-16 18:07 +0000
1"""Pure close-ordering reconciliation: issue lifecycle state vs the ledger.
3keel's lifecycle contract (see :mod:`keel.ship`) is explicit that an issue is
4only *done* after a real merge: ``status_done_after_merge_only`` and
5``pr_merged_state_authoritative``. But nothing deterministic checks that an issue
6which is **closed** or already carries the **status-done** label actually has a
7ledger record attesting a merge. An issue closed at s4, or a ``status:done``
8label applied before the PR merged, is invisible — exactly the audit gap
9(GAP-16) this module closes.
11This is the lowest-friction core-pure form: a post-hoc reconcile for the
12morning/wrap readers. Given each issue's observed lifecycle state (closed?
13status-done label present?) and the most recent ship_run ledger record for that
14issue, it flags any issue that is closed or status-done while its ledger record
15does **not** attest a merge.
17What counts as "the ledger attests a merge": a ship_run record whose
18``assessment.merge.action`` is ``"merge"`` — the *clear-to-merge* decision the
19ship step records after all gates passed and the window was open (``defer`` and
20``block`` never lead to a merge). A missing record, a malformed record, or a
21``defer``/``block`` decision all mean "no clear-to-merge decision witnessed", so
22a close or status-done at that point is premature.
24This is a proxy, and an honest reader should know its limit: the ship_run record
25captures the *decision* made at ship time, not a confirmed post-merge fact — the
26actual ``gh pr merge`` runs in a separate step and writes nothing back onto the
27record. So a narrow blind spot remains: if the assessment said ``"merge"`` but
28the subsequent merge failed or was never run, a later close/status-done is *not*
29flagged. It is nonetheless the strongest merge signal available to a pure ledger
30reader, and the gate is advisory, so this trade-off is acceptable. (Contrast
31:mod:`keel.consentverify`, which reads the live-observed merged state — close
32reconciliation runs ledger-only by design, for the morning/wrap readers.)
34Two verdicts:
36* **ok** — every observed issue is consistent (open and not status-done, or
37 closed/status-done *with* a merge-attesting record).
38* **flagged** — at least one issue is closed or status-done without a
39 merge-attesting record. The findings are advisory: a wrap/morning reader
40 surfaces them for an operator, they never block a run.
42Pure data in / structured report out: no network, subprocess, clock, or random.
43The CLI does the I/O (live issue state + labels, ledger records) and feeds the
44booleans/records here.
45"""
47from __future__ import annotations
49from dataclasses import dataclass, field
50from typing import Any
52from .ledger import RECORD_TYPE_SHIP_RUN
54SCHEMA_VERSION = "keel.close-reconcile.v1"
56VERDICT_OK = "ok"
57VERDICT_FLAGGED = "flagged"
59FINDING_PREMATURE_CLOSE = "premature-close"
60FINDING_PREMATURE_STATUS_DONE = "premature-status-done"
62# The default label that marks an issue as done. Resolved from
63# ``policy_pack.status_transitions.done`` by the CLI; this constant is the
64# fallback when a project does not configure one.
65DEFAULT_DONE_LABEL = "status:done"
68@dataclass(frozen=True)
69class ObservedIssue:
70 """The observed lifecycle state of one issue plus its ledger record.
72 ``closed`` and ``labels`` come from the live host (``gh issue view``).
73 ``record`` is the most recent ship_run ledger record for the issue's PR (or
74 ``None`` when the issue has no recorded ship run). All offline-supplyable so
75 tests are deterministic.
76 """
78 number: int
79 closed: bool = False
80 labels: tuple[str, ...] = field(default_factory=tuple)
81 record: dict[str, Any] | None = None
83 def has_label(self, label: str) -> bool:
84 return label in self.labels
87def record_attests_merge(record: dict[str, Any] | None) -> bool:
88 """Return whether a ledger ``record`` attests a clear-to-merge decision.
90 ``True`` only for a ship_run record whose ``assessment.merge.action`` is
91 exactly ``"merge"`` — the strongest merge signal a pure ledger reader has
92 (it is the *decision*, not a confirmed post-merge fact; see the module
93 docstring for that blind spot). A non-dict record, a record of another type,
94 a missing or malformed ``assessment``/``merge`` block, or a ``defer``/
95 ``block`` decision all degrade to ``False`` — a corrupt or absent record can
96 never vouch for a merge the ledger did not witness. Pure — reads only its
97 argument.
98 """
99 if not isinstance(record, dict):
100 return False
101 if record.get("record_type") != RECORD_TYPE_SHIP_RUN:
102 return False
103 assessment = record.get("assessment")
104 if not isinstance(assessment, dict):
105 return False
106 merge = assessment.get("merge")
107 if not isinstance(merge, dict):
108 return False
109 return merge.get("action") == "merge"
112def latest_record_for_issue(
113 records: list[dict[str, Any]] | tuple[dict[str, Any], ...],
114 issue_number: int,
115) -> dict[str, Any] | None:
116 """Return the most recent ship_run record for ``issue_number``.
118 Records are appended chronologically, so the last match is the latest ship
119 run for that issue. Non-dict entries and records of another type are skipped;
120 a missing or malformed ``issue`` block never matches. Returns ``None`` when no
121 record matches. Pure — reads only its arguments.
122 """
123 match: dict[str, Any] | None = None
124 for record in records:
125 if not isinstance(record, dict):
126 continue
127 if record.get("record_type") != RECORD_TYPE_SHIP_RUN:
128 continue
129 issue = record.get("issue")
130 number = issue.get("number") if isinstance(issue, dict) else None
131 if number == issue_number:
132 match = record
133 return match
136def reconcile(
137 observed: list[ObservedIssue] | tuple[ObservedIssue, ...],
138 *,
139 done_label: str = DEFAULT_DONE_LABEL,
140) -> dict[str, Any]:
141 """Reconcile each issue's lifecycle state against its ledger record.
143 For every issue in ``observed``: if it is closed without a merge-attesting
144 record, flag ``premature-close``; if it carries ``done_label`` without a
145 merge-attesting record, flag ``premature-status-done``. An issue that is open
146 and not status-done is never flagged regardless of its record; a
147 closed/status-done issue *with* a merge-attesting record is consistent.
149 Returns a structured report: a per-issue assessment, a flat findings list,
150 the verdict, and a summary. Pure — reads only its arguments.
151 """
152 issues: list[dict[str, Any]] = []
153 findings: list[dict[str, Any]] = []
154 for issue in observed:
155 attested = record_attests_merge(issue.record)
156 status_done = issue.has_label(done_label)
157 issue_findings: list[str] = []
158 if issue.closed and not attested:
159 issue_findings.append(FINDING_PREMATURE_CLOSE)
160 findings.append({
161 "issue": issue.number,
162 "finding": FINDING_PREMATURE_CLOSE,
163 "message": (
164 f"issue #{issue.number} is closed but no ship_run ledger "
165 f"record attests a merge"
166 ),
167 })
168 if status_done and not attested:
169 issue_findings.append(FINDING_PREMATURE_STATUS_DONE)
170 findings.append({
171 "issue": issue.number,
172 "finding": FINDING_PREMATURE_STATUS_DONE,
173 "message": (
174 f"issue #{issue.number} carries {done_label!r} but no "
175 f"ship_run ledger record attests a merge"
176 ),
177 })
178 issues.append({
179 "issue": issue.number,
180 "closed": issue.closed,
181 "status_done": status_done,
182 "merge_attested": attested,
183 "findings": issue_findings,
184 "consistent": not issue_findings,
185 })
186 verdict = VERDICT_FLAGGED if findings else VERDICT_OK
187 return {
188 "schema_version": SCHEMA_VERSION,
189 "verdict": verdict,
190 "ok": verdict == VERDICT_OK,
191 "done_label": done_label,
192 "issues": issues,
193 "findings": findings,
194 "summary": {
195 "observed": len(issues),
196 "flagged": sum(1 for item in issues if not item["consistent"]),
197 "findings": len(findings),
198 },
199 }