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

1"""Pure close-ordering reconciliation: issue lifecycle state vs the ledger. 

2 

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. 

10 

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. 

16 

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. 

23 

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.) 

33 

34Two verdicts: 

35 

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. 

41 

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""" 

46 

47from __future__ import annotations 

48 

49from dataclasses import dataclass, field 

50from typing import Any 

51 

52from .ledger import RECORD_TYPE_SHIP_RUN 

53 

54SCHEMA_VERSION = "keel.close-reconcile.v1" 

55 

56VERDICT_OK = "ok" 

57VERDICT_FLAGGED = "flagged" 

58 

59FINDING_PREMATURE_CLOSE = "premature-close" 

60FINDING_PREMATURE_STATUS_DONE = "premature-status-done" 

61 

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" 

66 

67 

68@dataclass(frozen=True) 

69class ObservedIssue: 

70 """The observed lifecycle state of one issue plus its ledger record. 

71 

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 """ 

77 

78 number: int 

79 closed: bool = False 

80 labels: tuple[str, ...] = field(default_factory=tuple) 

81 record: dict[str, Any] | None = None 

82 

83 def has_label(self, label: str) -> bool: 

84 return label in self.labels 

85 

86 

87def record_attests_merge(record: dict[str, Any] | None) -> bool: 

88 """Return whether a ledger ``record`` attests a clear-to-merge decision. 

89 

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" 

110 

111 

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``. 

117 

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 

134 

135 

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. 

142 

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. 

148 

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 }