autoforge-cli 0.1.0a0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- autoforge/__init__.py +5 -0
- autoforge/agent_worktree.py +653 -0
- autoforge/certificate.py +495 -0
- autoforge/child_env.py +871 -0
- autoforge/claims.py +726 -0
- autoforge/claude_stream.py +501 -0
- autoforge/cli.py +2884 -0
- autoforge/config.py +2178 -0
- autoforge/config_fingerprint.py +374 -0
- autoforge/doctor.py +1551 -0
- autoforge/doctor_sandbox.py +691 -0
- autoforge/effect_ops.py +1272 -0
- autoforge/effects.py +3210 -0
- autoforge/engine.py +16637 -0
- autoforge/epic_commands.py +1486 -0
- autoforge/epic_graph.py +852 -0
- autoforge/errors.py +536 -0
- autoforge/evidence.py +1722 -0
- autoforge/executor.py +1601 -0
- autoforge/executor_duplex.py +985 -0
- autoforge/executor_supervised.py +144 -0
- autoforge/git_transport.py +970 -0
- autoforge/github.py +3402 -0
- autoforge/interaction.py +1027 -0
- autoforge/local_workspace.py +1253 -0
- autoforge/locking.py +1050 -0
- autoforge/loop_detect.py +660 -0
- autoforge/loop_guard.py +404 -0
- autoforge/materialize.py +722 -0
- autoforge/ownership.py +240 -0
- autoforge/pi_rpc.py +713 -0
- autoforge/premerge.py +181 -0
- autoforge/profiles.py +106 -0
- autoforge/progress.py +392 -0
- autoforge/prompts/__init__.py +173 -0
- autoforge/prompts/acceptance_actions.md +39 -0
- autoforge/prompts/analyze_execute.md +128 -0
- autoforge/prompts/common.md +132 -0
- autoforge/prompts/correction.md +12 -0
- autoforge/prompts/fix.md +252 -0
- autoforge/prompts/local_analyze_execute.md +69 -0
- autoforge/prompts/local_common.md +149 -0
- autoforge/prompts/local_fix.md +96 -0
- autoforge/prompts/local_review.md +103 -0
- autoforge/prompts/operator_acceptance.md +61 -0
- autoforge/prompts/replan_reexecute.md +991 -0
- autoforge/prompts/review.md +312 -0
- autoforge/prompts/update_epic.md +120 -0
- autoforge/prompts/update_epic_rerequest.md +85 -0
- autoforge/prompts/validation_evidence.md +26 -0
- autoforge/prompts/validation_failure.md +22 -0
- autoforge/prompts/worktree_state.md +45 -0
- autoforge/providers.py +2893 -0
- autoforge/read_context.py +389 -0
- autoforge/redaction.py +408 -0
- autoforge/replan.py +208 -0
- autoforge/replan_txn.py +1930 -0
- autoforge/result_parser.py +2234 -0
- autoforge/roadmap.py +151 -0
- autoforge/run_contract.py +408 -0
- autoforge/run_namespace.py +839 -0
- autoforge/runlog.py +764 -0
- autoforge/safefs.py +1717 -0
- autoforge/sandbox.py +1313 -0
- autoforge/sandbox_bwrap.py +815 -0
- autoforge/sandbox_pin.py +170 -0
- autoforge/sandbox_policy.py +1107 -0
- autoforge/scheduler.py +1143 -0
- autoforge/scheduler_state.py +2316 -0
- autoforge/scheduler_stop.py +421 -0
- autoforge/sdk_policy.py +988 -0
- autoforge/state.py +2417 -0
- autoforge/transitions.py +388 -0
- autoforge/validation.py +324 -0
- autoforge/worker.py +568 -0
- autoforge_cli-0.1.0a0.dist-info/METADATA +525 -0
- autoforge_cli-0.1.0a0.dist-info/RECORD +81 -0
- autoforge_cli-0.1.0a0.dist-info/WHEEL +5 -0
- autoforge_cli-0.1.0a0.dist-info/entry_points.txt +2 -0
- autoforge_cli-0.1.0a0.dist-info/licenses/LICENSE +21 -0
- autoforge_cli-0.1.0a0.dist-info/top_level.txt +1 -0
autoforge/claims.py
ADDED
|
@@ -0,0 +1,726 @@
|
|
|
1
|
+
"""Durable GitHub claims: the one identity contract for controller-adoptable writes.
|
|
2
|
+
|
|
3
|
+
An agent's GitHub write (an implementation PR, a review comment, an EPIC
|
|
4
|
+
progress comment, a follow-up issue, an acceptance comment, an operator
|
|
5
|
+
comment) is something a later controller entry
|
|
6
|
+
must be able to *find again* and something the post-agent read-back must be
|
|
7
|
+
able to *hold the agent to*. Both need the same answer to the same question:
|
|
8
|
+
"which object is this phase's write?". This module owns that answer.
|
|
9
|
+
|
|
10
|
+
The identity of such a write is a **marker**: an HTML comment
|
|
11
|
+
``<!-- <name>: <json object> -->`` that the controller renders into the
|
|
12
|
+
agent's prompt and the agent copies verbatim into the object. Each marker
|
|
13
|
+
kind has exactly one typed payload (:class:`ImplementationClaim`,
|
|
14
|
+
:class:`ReviewClaim`, :class:`ProgressClaim`, :class:`FollowUpClaim`,
|
|
15
|
+
:class:`AcceptanceClaim`, :class:`OperatorCommentClaim`), one
|
|
16
|
+
exact-schema decoder, one renderer, and one ``key``: the value two claims
|
|
17
|
+
are compared by. Nothing outside this module parses a marker.
|
|
18
|
+
|
|
19
|
+
The contract every consumer gets from :func:`collect`:
|
|
20
|
+
|
|
21
|
+
- **Every marker is classified, none is skipped.** A marker of the kind
|
|
22
|
+
whose payload is not exactly the documented shape is a *defect* of the
|
|
23
|
+
object carrying it, not "no marker". A defect anywhere in a collection
|
|
24
|
+
makes every question about that collection inconclusive
|
|
25
|
+
(:class:`ClaimConflictError`), because "no object claims this key" cannot
|
|
26
|
+
be proven while an object carries a claim that could not be read.
|
|
27
|
+
- **One object, one compatible identity.** A PR carries at most one
|
|
28
|
+
implementation marker, a comment at most one review, progress,
|
|
29
|
+
acceptance or operator-comment marker,
|
|
30
|
+
an issue any number of follow-up markers with distinct keys. An object
|
|
31
|
+
that publishes more is defective, whatever the extra marker names: a
|
|
32
|
+
provenance that names two things proves neither.
|
|
33
|
+
- **Cardinality is explicit.** :meth:`Claimants.at_most_one` (an entry:
|
|
34
|
+
nothing yet, or the one write to adopt) and :meth:`Claimants.exactly_one`
|
|
35
|
+
(a read-back: the write the agent claims must exist and be the only one).
|
|
36
|
+
There is no "not more than one".
|
|
37
|
+
- **Identity is GitHub's.** URLs inside payloads are parsed with the typed
|
|
38
|
+
parsers and compared by :attr:`GitHubRef.identity` (owner and repository
|
|
39
|
+
case-insensitive, plus number), never as strings. They are bounded by
|
|
40
|
+
``MAX_URL_CHARS`` before they are parsed, exactly as ``CONTROL_RESULT``
|
|
41
|
+
URLs are: a marker is untrusted text, and the defect it produces is
|
|
42
|
+
persisted as a block reason, so an oversized value is refused by its
|
|
43
|
+
length and never quoted.
|
|
44
|
+
|
|
45
|
+
``replan_txn`` keeps its own transaction marker: that one is bound to a
|
|
46
|
+
controller-generated transaction id and a PR-number watermark, and its
|
|
47
|
+
acceptance rules (source PR, pre-existing PRs) are a different protocol.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
from __future__ import annotations
|
|
51
|
+
|
|
52
|
+
import json
|
|
53
|
+
import re
|
|
54
|
+
from collections.abc import Callable, Hashable, Iterable, Sequence
|
|
55
|
+
from dataclasses import dataclass
|
|
56
|
+
from typing import Generic, Protocol, TypeVar
|
|
57
|
+
|
|
58
|
+
from .errors import ClaimConflictError, ConfigurationError
|
|
59
|
+
from .result_parser import (
|
|
60
|
+
CONTROLLER_MARKER_OPEN_RE,
|
|
61
|
+
FINDING_ID_RE,
|
|
62
|
+
MAX_FINDING_ID_CHARS,
|
|
63
|
+
MAX_URL_CHARS,
|
|
64
|
+
)
|
|
65
|
+
from .validation import GitHubIssueRef, GitHubPullRequestRef, parse_issue_url, parse_pr_url
|
|
66
|
+
|
|
67
|
+
_FULL_SHA_RE = re.compile(r"^[0-9a-fA-F]{40}$")
|
|
68
|
+
# A git refname component is at most 255 bytes and a branch name a few of
|
|
69
|
+
# them; the bound only keeps an oversized marker value out of a block reason.
|
|
70
|
+
MAX_BASE_REF_CHARS = 512
|
|
71
|
+
_BASE_REF_FORBIDDEN_RE = re.compile(r"[\s\x00-\x1f\x7f]")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class Marked(Protocol):
|
|
75
|
+
"""A GitHub object a marker can live in: it has a URL and a body."""
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def url(self) -> str: ...
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def body(self) -> str: ...
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
C = TypeVar("C", bound="Claim")
|
|
85
|
+
O = TypeVar("O", bound=Marked) # noqa: E741 - the object a claim is found in
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class Claim(Protocol):
|
|
89
|
+
@property
|
|
90
|
+
def key(self) -> Hashable: ...
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# -- payloads ------------------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@dataclass(frozen=True)
|
|
97
|
+
class ImplementationClaim:
|
|
98
|
+
"""``ai-implementation``: the PR body names the issue it implements."""
|
|
99
|
+
|
|
100
|
+
issue: GitHubIssueRef
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def key(self) -> Hashable:
|
|
104
|
+
return self.issue.identity
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@dataclass(frozen=True)
|
|
108
|
+
class ReviewClaim:
|
|
109
|
+
"""``ai-review-result``: a review comment names the revision its round decided on.
|
|
110
|
+
|
|
111
|
+
The revision is the PR diff: the reviewed HEAD against the base branch
|
|
112
|
+
the PR targeted when the round was bound, from the merge base the two
|
|
113
|
+
had at that moment. All three are part of the key, so a comment posted
|
|
114
|
+
while the PR targeted another base, or while the base branch was
|
|
115
|
+
another history under the same name (#96), is not this round's,
|
|
116
|
+
exactly as one posted at another HEAD is not, and is never adopted by
|
|
117
|
+
a later entry as a review of the diff the PR shows now.
|
|
118
|
+
|
|
119
|
+
``reviewed_base_ref`` is ``None`` for a marker written before the base
|
|
120
|
+
was part of the schema, and ``reviewed_merge_base_sha`` is ``None`` for
|
|
121
|
+
one written before the merge base was. Such a comment reviewed a diff
|
|
122
|
+
nobody recorded, so it matches no bound key and is never adopted; it
|
|
123
|
+
is not a defect either, because a mid-flight PR carries one from every
|
|
124
|
+
earlier round and a defect anywhere would block every later entry on
|
|
125
|
+
that PR.
|
|
126
|
+
"""
|
|
127
|
+
|
|
128
|
+
round: int
|
|
129
|
+
reviewed_head_sha: str # lowercased
|
|
130
|
+
needs_fix_round: bool
|
|
131
|
+
finding_ids: tuple[str, ...] | None = None
|
|
132
|
+
reviewed_base_ref: str | None = None
|
|
133
|
+
reviewed_merge_base_sha: str | None = None # lowercased
|
|
134
|
+
|
|
135
|
+
@property
|
|
136
|
+
def key(self) -> Hashable:
|
|
137
|
+
return (
|
|
138
|
+
self.round,
|
|
139
|
+
self.reviewed_head_sha,
|
|
140
|
+
self.reviewed_base_ref,
|
|
141
|
+
self.reviewed_merge_base_sha,
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@dataclass(frozen=True)
|
|
146
|
+
class ProgressClaim:
|
|
147
|
+
"""``ai-epic-progress``: an EPIC comment names the finished issue and merged PR."""
|
|
148
|
+
|
|
149
|
+
issue: GitHubIssueRef
|
|
150
|
+
pr: GitHubPullRequestRef
|
|
151
|
+
|
|
152
|
+
@property
|
|
153
|
+
def key(self) -> Hashable:
|
|
154
|
+
return (self.issue.identity, self.pr.identity)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
@dataclass(frozen=True)
|
|
158
|
+
class AcceptanceClaim:
|
|
159
|
+
"""``ai-acceptance``: a comment on the run's issue names the finding it is evidence for.
|
|
160
|
+
|
|
161
|
+
The finding is one round's finding of one PR, so the key is the issue
|
|
162
|
+
the comment is on, the PR, the round and the finding id: a comment
|
|
163
|
+
posted for another round, another PR or another issue is never this
|
|
164
|
+
finding's evidence (#211).
|
|
165
|
+
"""
|
|
166
|
+
|
|
167
|
+
issue: GitHubIssueRef
|
|
168
|
+
pr: GitHubPullRequestRef
|
|
169
|
+
round: int
|
|
170
|
+
finding_id: str
|
|
171
|
+
|
|
172
|
+
@property
|
|
173
|
+
def key(self) -> Hashable:
|
|
174
|
+
return (self.issue.identity, self.pr.identity, self.round, self.finding_id)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
@dataclass(frozen=True)
|
|
178
|
+
class OperatorCommentClaim:
|
|
179
|
+
"""``ai-operator-comment``: a comment on another issue names the finding it is evidence for.
|
|
180
|
+
|
|
181
|
+
The operator, never the controller, posts it (K10, #243). The key is the
|
|
182
|
+
run's issue, the PR, the round, the finding id and the ``target`` issue
|
|
183
|
+
the comment is meant for, so two rounds, two PRs or two targets of one
|
|
184
|
+
finding never share a key. The issue the comment is *on* is not part of
|
|
185
|
+
the key: a payload posted on the wrong issue still carries its key, and
|
|
186
|
+
the controller reads it there as a misplaced payload.
|
|
187
|
+
"""
|
|
188
|
+
|
|
189
|
+
issue: GitHubIssueRef
|
|
190
|
+
pr: GitHubPullRequestRef
|
|
191
|
+
round: int
|
|
192
|
+
finding_id: str
|
|
193
|
+
target: GitHubIssueRef
|
|
194
|
+
|
|
195
|
+
@property
|
|
196
|
+
def key(self) -> Hashable:
|
|
197
|
+
return (
|
|
198
|
+
self.issue.identity,
|
|
199
|
+
self.pr.identity,
|
|
200
|
+
self.round,
|
|
201
|
+
self.finding_id,
|
|
202
|
+
self.target.identity,
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
@dataclass(frozen=True)
|
|
207
|
+
class FollowUpClaim:
|
|
208
|
+
"""``ai-follow-up``: a follow-up issue names the PR and finding it defers."""
|
|
209
|
+
|
|
210
|
+
pr: GitHubPullRequestRef
|
|
211
|
+
finding_id: str
|
|
212
|
+
|
|
213
|
+
@property
|
|
214
|
+
def key(self) -> Hashable:
|
|
215
|
+
return (self.pr.identity, self.finding_id)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
# -- exact-schema decoding ---------------------------------------------------------
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _exact_keys(payload: dict, required: tuple[str, ...], optional: tuple[str, ...] = ()) -> None:
|
|
222
|
+
keys = set(payload)
|
|
223
|
+
missing = sorted(set(required) - keys)
|
|
224
|
+
unknown = sorted(keys - set(required) - set(optional))
|
|
225
|
+
if missing:
|
|
226
|
+
raise ValueError(f"missing key(s) {', '.join(missing)}")
|
|
227
|
+
if unknown:
|
|
228
|
+
raise ValueError(f"unknown key(s) {', '.join(unknown)}")
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _url(payload: dict, key: str, what: str) -> str:
|
|
232
|
+
"""The URL string under ``key``, bounded before any parser can quote it.
|
|
233
|
+
|
|
234
|
+
The ``MAX_URL_CHARS`` rule of ``result_parser``: the typed parsers quote
|
|
235
|
+
the value in their error, and a marker's error reaches a block reason
|
|
236
|
+
and the run log, so an oversized value is refused by its length alone
|
|
237
|
+
and never quoted. A marker is GitHub-authored text like a
|
|
238
|
+
``CONTROL_RESULT`` is agent-authored text; both cross the same boundary.
|
|
239
|
+
"""
|
|
240
|
+
value = payload[key]
|
|
241
|
+
if not isinstance(value, str):
|
|
242
|
+
raise ValueError(f"{key} must be a GitHub {what} URL string")
|
|
243
|
+
if len(value) > MAX_URL_CHARS:
|
|
244
|
+
raise ValueError(
|
|
245
|
+
f"{key} is {len(value)} characters; a GitHub {what} URL is at most {MAX_URL_CHARS}"
|
|
246
|
+
)
|
|
247
|
+
return value
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _issue_ref(payload: dict, key: str) -> GitHubIssueRef:
|
|
251
|
+
value = _url(payload, key, "issue")
|
|
252
|
+
try:
|
|
253
|
+
return parse_issue_url(value)
|
|
254
|
+
except ConfigurationError as exc:
|
|
255
|
+
raise ValueError(f"{key} is not a GitHub issue URL ({exc})") from exc
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _pr_ref(payload: dict, key: str) -> GitHubPullRequestRef:
|
|
259
|
+
value = _url(payload, key, "pull request")
|
|
260
|
+
try:
|
|
261
|
+
return parse_pr_url(value)
|
|
262
|
+
except ConfigurationError as exc:
|
|
263
|
+
raise ValueError(f"{key} is not a GitHub pull request URL ({exc})") from exc
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _base_ref(value: object) -> str:
|
|
267
|
+
"""The base branch a review marker names, as GitHub reports it.
|
|
268
|
+
|
|
269
|
+
Compared for equality with the ``baseRefName`` the controller bound
|
|
270
|
+
right before the round, never interpreted, so the only shape rule is
|
|
271
|
+
the one git imposes on every refname (non-empty, no whitespace, no
|
|
272
|
+
control character) plus a bound before the value can be quoted. A
|
|
273
|
+
comment delimiter inside the name is not refused here: git allows it,
|
|
274
|
+
and :func:`marker_json` keeps it out of the rendered marker text.
|
|
275
|
+
"""
|
|
276
|
+
if not isinstance(value, str):
|
|
277
|
+
raise ValueError("reviewed_base_ref must be a branch name string")
|
|
278
|
+
if not value or len(value) > MAX_BASE_REF_CHARS:
|
|
279
|
+
raise ValueError(
|
|
280
|
+
f"reviewed_base_ref is {len(value)} characters; a branch name is 1 to "
|
|
281
|
+
f"{MAX_BASE_REF_CHARS}"
|
|
282
|
+
)
|
|
283
|
+
if _BASE_REF_FORBIDDEN_RE.search(value):
|
|
284
|
+
raise ValueError(
|
|
285
|
+
f"reviewed_base_ref {value!r} is not a branch name (whitespace or control character)"
|
|
286
|
+
)
|
|
287
|
+
return value
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _finding_id(value: object, what: str, round_: int | None = None) -> str:
|
|
291
|
+
"""The finding id rule of ``result_parser``, applied to marker data.
|
|
292
|
+
|
|
293
|
+
Length is checked before shape so the reason never quotes an unbounded
|
|
294
|
+
string; the id is quoted with ``repr`` so control characters cannot
|
|
295
|
+
reach a block reason or a prompt unescaped.
|
|
296
|
+
"""
|
|
297
|
+
if not isinstance(value, str):
|
|
298
|
+
raise ValueError(f"{what} must be a string")
|
|
299
|
+
if len(value) > MAX_FINDING_ID_CHARS:
|
|
300
|
+
raise ValueError(f"{what} is longer than {MAX_FINDING_ID_CHARS} characters")
|
|
301
|
+
match = FINDING_ID_RE.match(value)
|
|
302
|
+
if match is None:
|
|
303
|
+
raise ValueError(f"{what} {value!r} is not a finding id of the form R<round>-F<n>")
|
|
304
|
+
if round_ is not None and int(match.group("round")) != round_:
|
|
305
|
+
raise ValueError(f"{what} {value!r} does not belong to round {round_}")
|
|
306
|
+
return value
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _decode_implementation(payload: dict) -> ImplementationClaim:
|
|
310
|
+
_exact_keys(payload, ("issue",))
|
|
311
|
+
return ImplementationClaim(issue=_issue_ref(payload, "issue"))
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _decode_review(payload: dict) -> ReviewClaim:
|
|
315
|
+
_exact_keys(
|
|
316
|
+
payload,
|
|
317
|
+
("round", "reviewed_head_sha", "needs_fix_round"),
|
|
318
|
+
("finding_ids", "reviewed_base_ref", "reviewed_merge_base_sha"),
|
|
319
|
+
)
|
|
320
|
+
round_ = payload["round"]
|
|
321
|
+
if not isinstance(round_, int) or isinstance(round_, bool) or round_ < 1:
|
|
322
|
+
raise ValueError(f"round must be a JSON integer >= 1, got {round_!r}")
|
|
323
|
+
sha = payload["reviewed_head_sha"]
|
|
324
|
+
if not isinstance(sha, str) or not _FULL_SHA_RE.match(sha):
|
|
325
|
+
raise ValueError(f"reviewed_head_sha must be a 40-character git SHA, got {sha!r}")
|
|
326
|
+
needs_fix = payload["needs_fix_round"]
|
|
327
|
+
if not isinstance(needs_fix, bool):
|
|
328
|
+
raise ValueError(f"needs_fix_round must be a JSON boolean, got {needs_fix!r}")
|
|
329
|
+
finding_ids: tuple[str, ...] | None = None
|
|
330
|
+
if "finding_ids" in payload:
|
|
331
|
+
raw = payload["finding_ids"]
|
|
332
|
+
if not isinstance(raw, list):
|
|
333
|
+
raise ValueError("finding_ids must be a JSON array")
|
|
334
|
+
ids = [_finding_id(v, "finding_ids entry", round_) for v in raw]
|
|
335
|
+
if len(set(ids)) != len(ids):
|
|
336
|
+
raise ValueError("finding_ids repeats an id")
|
|
337
|
+
finding_ids = tuple(ids)
|
|
338
|
+
base_ref: str | None = None
|
|
339
|
+
if "reviewed_base_ref" in payload:
|
|
340
|
+
base_ref = _base_ref(payload["reviewed_base_ref"])
|
|
341
|
+
merge_base: str | None = None
|
|
342
|
+
if "reviewed_merge_base_sha" in payload:
|
|
343
|
+
raw_base = payload["reviewed_merge_base_sha"]
|
|
344
|
+
if not isinstance(raw_base, str) or not _FULL_SHA_RE.match(raw_base):
|
|
345
|
+
raise ValueError(
|
|
346
|
+
f"reviewed_merge_base_sha must be a 40-character git SHA, got {raw_base!r}"
|
|
347
|
+
)
|
|
348
|
+
merge_base = raw_base.lower()
|
|
349
|
+
return ReviewClaim(
|
|
350
|
+
round=round_,
|
|
351
|
+
reviewed_head_sha=sha.lower(),
|
|
352
|
+
needs_fix_round=needs_fix,
|
|
353
|
+
finding_ids=finding_ids,
|
|
354
|
+
reviewed_base_ref=base_ref,
|
|
355
|
+
reviewed_merge_base_sha=merge_base,
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _decode_progress(payload: dict) -> ProgressClaim:
|
|
360
|
+
_exact_keys(payload, ("issue", "pr"))
|
|
361
|
+
return ProgressClaim(issue=_issue_ref(payload, "issue"), pr=_pr_ref(payload, "pr"))
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _decode_acceptance(payload: dict) -> AcceptanceClaim:
|
|
365
|
+
_exact_keys(payload, ("finding_id", "issue", "pr", "round"))
|
|
366
|
+
round_ = payload["round"]
|
|
367
|
+
if not isinstance(round_, int) or isinstance(round_, bool) or round_ < 1:
|
|
368
|
+
raise ValueError(f"round must be a JSON integer >= 1, got {round_!r}")
|
|
369
|
+
return AcceptanceClaim(
|
|
370
|
+
issue=_issue_ref(payload, "issue"),
|
|
371
|
+
pr=_pr_ref(payload, "pr"),
|
|
372
|
+
round=round_,
|
|
373
|
+
finding_id=_finding_id(payload["finding_id"], "finding_id", round_),
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def _decode_operator_comment(payload: dict) -> OperatorCommentClaim:
|
|
378
|
+
_exact_keys(payload, ("finding_id", "issue", "pr", "round", "target"))
|
|
379
|
+
round_ = payload["round"]
|
|
380
|
+
if not isinstance(round_, int) or isinstance(round_, bool) or round_ < 1:
|
|
381
|
+
raise ValueError(f"round must be a JSON integer >= 1, got {round_!r}")
|
|
382
|
+
return OperatorCommentClaim(
|
|
383
|
+
issue=_issue_ref(payload, "issue"),
|
|
384
|
+
pr=_pr_ref(payload, "pr"),
|
|
385
|
+
round=round_,
|
|
386
|
+
finding_id=_finding_id(payload["finding_id"], "finding_id", round_),
|
|
387
|
+
target=_issue_ref(payload, "target"),
|
|
388
|
+
)
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def _decode_follow_up(payload: dict) -> FollowUpClaim:
|
|
392
|
+
_exact_keys(payload, ("finding_id", "pr"))
|
|
393
|
+
return FollowUpClaim(
|
|
394
|
+
pr=_pr_ref(payload, "pr"), finding_id=_finding_id(payload["finding_id"], "finding_id")
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
# -- kinds ----------------------------------------------------------------------------
|
|
399
|
+
|
|
400
|
+
# The name prefix ``CONTROLLER_MARKER_OPEN_RE`` ends with; a kind's pattern
|
|
401
|
+
# is that opening followed by the rest of its name.
|
|
402
|
+
_NAME_PREFIX = "ai-"
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
@dataclass(frozen=True)
|
|
406
|
+
class MarkerKind(Generic[C]):
|
|
407
|
+
"""One marker kind: its name, its exact-schema decoder, and its object rule."""
|
|
408
|
+
|
|
409
|
+
name: str
|
|
410
|
+
decode: Callable[[dict], C]
|
|
411
|
+
several_per_object: bool # distinct keys may share one object (follow-ups)
|
|
412
|
+
|
|
413
|
+
def __post_init__(self) -> None:
|
|
414
|
+
# Every kind opens as ``CONTROLLER_MARKER_OPEN_RE``: that shared
|
|
415
|
+
# opening is what the result parser refuses in agent text that the
|
|
416
|
+
# controller writes into an issue body verbatim, so a kind outside it
|
|
417
|
+
# would be scanned but not refused.
|
|
418
|
+
if not self.name.startswith(_NAME_PREFIX):
|
|
419
|
+
raise ConfigurationError(f"marker kind {self.name!r} must start with {_NAME_PREFIX!r}")
|
|
420
|
+
|
|
421
|
+
@property
|
|
422
|
+
def pattern(self) -> re.Pattern[str]:
|
|
423
|
+
# Every *complete* comment bearing the name, whatever the payload
|
|
424
|
+
# looks like, so a broken payload is classified rather than passed
|
|
425
|
+
# over. The payload may not contain a comment delimiter: an
|
|
426
|
+
# unterminated marker is not a complete comment and must not swallow
|
|
427
|
+
# the body up to a later ``-->`` and hide a valid marker in the match.
|
|
428
|
+
# The opening is ``CONTROLLER_MARKER_OPEN_RE`` itself (not a copy of
|
|
429
|
+
# it) so the scanner and the result parser's refusal cannot drift.
|
|
430
|
+
#
|
|
431
|
+
# Cost is part of the contract: this pattern runs over every open
|
|
432
|
+
# issue body and every PR body the controller lists, which is text
|
|
433
|
+
# anyone can edit. It is linear in the body: the whitespace runs are
|
|
434
|
+
# possessive (``*+``, no backtracking into them) and no whitespace
|
|
435
|
+
# quantifier touches the lazy payload, so an unterminated marker
|
|
436
|
+
# followed by a long run of blanks is one failed pass, not a
|
|
437
|
+
# quadratic-or-worse search for a split between payload and blanks.
|
|
438
|
+
# :func:`scan` strips the payload instead.
|
|
439
|
+
rest = re.escape(self.name.removeprefix(_NAME_PREFIX))
|
|
440
|
+
return re.compile(
|
|
441
|
+
rf"{CONTROLLER_MARKER_OPEN_RE.pattern}{rest}\s*+:"
|
|
442
|
+
rf"(?P<payload>(?:(?!-->|<!--)[\s\S])*?)-->"
|
|
443
|
+
)
|
|
444
|
+
|
|
445
|
+
def render(self, payload: dict) -> str:
|
|
446
|
+
"""The exact marker text for ``payload``; it round-trips through ``decode``."""
|
|
447
|
+
self.decode(payload) # a renderer that emits an undecodable marker is a bug
|
|
448
|
+
return f"<!-- {self.name}: {marker_json(payload)} -->"
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def marker_json(value: object) -> str:
|
|
452
|
+
"""``value`` as JSON text that can live inside an HTML comment.
|
|
453
|
+
|
|
454
|
+
A marker payload is JSON between ``<!--`` and ``-->``, and :func:`scan`
|
|
455
|
+
ends the payload at the first comment delimiter it meets, as an HTML
|
|
456
|
+
parser does. A payload string that contains one (``x-->y`` and ``a<!--b``
|
|
457
|
+
are valid git branch names, so a ``reviewed_base_ref`` can) would
|
|
458
|
+
truncate the marker it is part of and leave the scanner an unterminated
|
|
459
|
+
JSON string: the object then carries a *defect*, and no review of that
|
|
460
|
+
PR can ever be read back. ``<`` and ``>`` are therefore emitted as the
|
|
461
|
+
JSON escapes ``\\u003c`` and ``\\u003e``, which every JSON decoder
|
|
462
|
+
reads back to the same characters; both delimiters (and the ``--!>``
|
|
463
|
+
an HTML parser also treats as a close) contain one of them, so no
|
|
464
|
+
payload rendered here can end its own comment. The replacement is
|
|
465
|
+
textual and safe: ``json.dumps`` escapes non-ASCII by default, so the
|
|
466
|
+
two characters occur only as themselves, and only inside a string
|
|
467
|
+
literal, since neither is JSON structure.
|
|
468
|
+
|
|
469
|
+
This is the one JSON encoder for marker text: every renderer uses it.
|
|
470
|
+
"""
|
|
471
|
+
return json.dumps(value, sort_keys=True).replace("<", "\\u003c").replace(">", "\\u003e")
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
IMPLEMENTATION: MarkerKind[ImplementationClaim] = MarkerKind(
|
|
475
|
+
"ai-implementation", _decode_implementation, several_per_object=False
|
|
476
|
+
)
|
|
477
|
+
REVIEW: MarkerKind[ReviewClaim] = MarkerKind("ai-review-result", _decode_review, False)
|
|
478
|
+
PROGRESS: MarkerKind[ProgressClaim] = MarkerKind("ai-epic-progress", _decode_progress, False)
|
|
479
|
+
FOLLOW_UP: MarkerKind[FollowUpClaim] = MarkerKind("ai-follow-up", _decode_follow_up, True)
|
|
480
|
+
ACCEPTANCE: MarkerKind[AcceptanceClaim] = MarkerKind("ai-acceptance", _decode_acceptance, False)
|
|
481
|
+
OPERATOR_COMMENT: MarkerKind[OperatorCommentClaim] = MarkerKind(
|
|
482
|
+
"ai-operator-comment", _decode_operator_comment, False
|
|
483
|
+
)
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def render_implementation_marker(issue_url: str) -> str:
|
|
487
|
+
"""The exact ``ai-implementation`` marker the issue's implementation PR carries."""
|
|
488
|
+
return IMPLEMENTATION.render({"issue": parse_issue_url(issue_url).canonical})
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def render_progress_marker(issue_url: str, pr_url: str) -> str:
|
|
492
|
+
"""The exact ``ai-epic-progress`` marker of one UPDATE_EPIC entry (issue, PR)."""
|
|
493
|
+
return PROGRESS.render(
|
|
494
|
+
{"issue": parse_issue_url(issue_url).canonical, "pr": parse_pr_url(pr_url).canonical}
|
|
495
|
+
)
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def render_review_marker(
|
|
499
|
+
round_: int,
|
|
500
|
+
reviewed_head_sha: str,
|
|
501
|
+
reviewed_base_ref: str,
|
|
502
|
+
reviewed_merge_base_sha: str,
|
|
503
|
+
needs_fix_round: bool,
|
|
504
|
+
finding_ids: Sequence[str],
|
|
505
|
+
) -> str:
|
|
506
|
+
"""The exact ``ai-review-result`` marker of one round's review comment."""
|
|
507
|
+
return REVIEW.render(
|
|
508
|
+
{
|
|
509
|
+
"finding_ids": list(finding_ids),
|
|
510
|
+
"needs_fix_round": needs_fix_round,
|
|
511
|
+
"reviewed_base_ref": reviewed_base_ref,
|
|
512
|
+
"reviewed_head_sha": reviewed_head_sha.lower(),
|
|
513
|
+
"reviewed_merge_base_sha": reviewed_merge_base_sha.lower(),
|
|
514
|
+
"round": round_,
|
|
515
|
+
}
|
|
516
|
+
)
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def render_acceptance_marker(issue_url: str, pr_url: str, round_: int, finding_id: str) -> str:
|
|
520
|
+
"""The exact ``ai-acceptance`` marker of one acceptance comment (issue, PR, round, finding)."""
|
|
521
|
+
return ACCEPTANCE.render(
|
|
522
|
+
{
|
|
523
|
+
"finding_id": finding_id,
|
|
524
|
+
"issue": parse_issue_url(issue_url).canonical,
|
|
525
|
+
"pr": parse_pr_url(pr_url).canonical,
|
|
526
|
+
"round": round_,
|
|
527
|
+
}
|
|
528
|
+
)
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def render_operator_comment_marker(
|
|
532
|
+
issue_url: str, pr_url: str, round_: int, finding_id: str, target_url: str
|
|
533
|
+
) -> str:
|
|
534
|
+
"""The exact ``ai-operator-comment`` marker of one operator comment (K10, #243)."""
|
|
535
|
+
return OPERATOR_COMMENT.render(
|
|
536
|
+
{
|
|
537
|
+
"finding_id": finding_id,
|
|
538
|
+
"issue": parse_issue_url(issue_url).canonical,
|
|
539
|
+
"pr": parse_pr_url(pr_url).canonical,
|
|
540
|
+
"round": round_,
|
|
541
|
+
"target": parse_issue_url(target_url).canonical,
|
|
542
|
+
}
|
|
543
|
+
)
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def render_follow_up_marker(pr_url: str, finding_id: str) -> str:
|
|
547
|
+
"""The exact ``ai-follow-up`` marker of one deferral (PR, finding id)."""
|
|
548
|
+
return FOLLOW_UP.render({"finding_id": finding_id, "pr": parse_pr_url(pr_url).canonical})
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
# -- scanning one object ------------------------------------------------------------
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
@dataclass(frozen=True)
|
|
555
|
+
class Scan(Generic[C]):
|
|
556
|
+
"""Every marker of one kind in one body: the usable claims and the defects."""
|
|
557
|
+
|
|
558
|
+
claims: tuple[C, ...]
|
|
559
|
+
defects: tuple[str, ...] # human-readable reasons; non-empty means "defective object"
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
def scan(kind: MarkerKind[C], body: str) -> Scan[C]:
|
|
563
|
+
"""Classify every complete ``kind`` marker in ``body``.
|
|
564
|
+
|
|
565
|
+
A defect is any complete marker whose payload is not exactly the kind's
|
|
566
|
+
schema (unparsable text, a JSON scalar or array, a missing, unknown or
|
|
567
|
+
mistyped key), a second marker on an object that may carry one, or a
|
|
568
|
+
repeated key on an object that may carry several. Defects are reported,
|
|
569
|
+
never skipped: an object that tried to carry a claim and failed is
|
|
570
|
+
evidence of a botched write, not of an unrelated object.
|
|
571
|
+
"""
|
|
572
|
+
claims: list[C] = []
|
|
573
|
+
defects: list[str] = []
|
|
574
|
+
for match in kind.pattern.finditer(body or ""):
|
|
575
|
+
raw = match.group("payload").strip()
|
|
576
|
+
try:
|
|
577
|
+
payload = json.loads(raw)
|
|
578
|
+
except ValueError as exc:
|
|
579
|
+
defects.append(f"{kind.name} marker payload is not valid JSON ({exc})")
|
|
580
|
+
continue
|
|
581
|
+
if not isinstance(payload, dict):
|
|
582
|
+
defects.append(f"{kind.name} marker payload must be a JSON object")
|
|
583
|
+
continue
|
|
584
|
+
try:
|
|
585
|
+
claims.append(kind.decode(payload))
|
|
586
|
+
except ValueError as exc:
|
|
587
|
+
defects.append(f"{kind.name} marker payload is invalid ({exc})")
|
|
588
|
+
if not kind.several_per_object and len(claims) > 1:
|
|
589
|
+
defects.append(
|
|
590
|
+
f"carries {len(claims)} {kind.name} markers; an object publishes exactly one "
|
|
591
|
+
"and a provenance that names two things proves neither"
|
|
592
|
+
)
|
|
593
|
+
elif kind.several_per_object:
|
|
594
|
+
keys = [c.key for c in claims]
|
|
595
|
+
if len(set(keys)) != len(keys):
|
|
596
|
+
defects.append(f"carries the same {kind.name} marker more than once")
|
|
597
|
+
return Scan(tuple(claims), tuple(defects))
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
# -- collections and cardinality -------------------------------------------------------
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
@dataclass(frozen=True)
|
|
604
|
+
class Holder(Generic[O, C]):
|
|
605
|
+
"""One object and the one claim of the kind it makes for the key asked about."""
|
|
606
|
+
|
|
607
|
+
obj: O
|
|
608
|
+
claim: C
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
@dataclass(frozen=True)
|
|
612
|
+
class Claimants(Generic[O, C]):
|
|
613
|
+
"""The objects of a collection claiming one key, with explicit cardinality.
|
|
614
|
+
|
|
615
|
+
``defects`` are the defective objects of the *whole* collection, not only
|
|
616
|
+
of the holders: while any object's marker of this kind could not be
|
|
617
|
+
read, "nothing claims this key" is not provable.
|
|
618
|
+
"""
|
|
619
|
+
|
|
620
|
+
kind: MarkerKind[C]
|
|
621
|
+
what: str # "issue #12", "round 2 at HEAD ab12...", for messages
|
|
622
|
+
holders: tuple[Holder[O, C], ...]
|
|
623
|
+
defects: tuple[str, ...]
|
|
624
|
+
noun: str # "open PR", "comment", "open issue"
|
|
625
|
+
|
|
626
|
+
def _refuse_defects(self) -> None:
|
|
627
|
+
if self.defects:
|
|
628
|
+
raise ClaimConflictError(
|
|
629
|
+
f"cannot establish which {self.noun} carries the {self.kind.name} marker for "
|
|
630
|
+
f"{self.what}: {'; '.join(self.defects)}"
|
|
631
|
+
)
|
|
632
|
+
|
|
633
|
+
def _refuse_many(self) -> None:
|
|
634
|
+
if len(self.holders) > 1:
|
|
635
|
+
urls = ", ".join(sorted(h.obj.url for h in self.holders))
|
|
636
|
+
raise ClaimConflictError(
|
|
637
|
+
f"{len(self.holders)} {self.noun}s carry the {self.kind.name} marker for "
|
|
638
|
+
f"{self.what} ({urls})"
|
|
639
|
+
)
|
|
640
|
+
|
|
641
|
+
def at_most_one(self) -> Holder[O, C] | None:
|
|
642
|
+
"""An entry's question: nothing yet (launch), or the one write to adopt."""
|
|
643
|
+
self._refuse_defects()
|
|
644
|
+
self._refuse_many()
|
|
645
|
+
return self.holders[0] if self.holders else None
|
|
646
|
+
|
|
647
|
+
def exactly_one(self) -> Holder[O, C]:
|
|
648
|
+
"""A read-back's question: the write exists and is the only one."""
|
|
649
|
+
self._refuse_defects()
|
|
650
|
+
self._refuse_many()
|
|
651
|
+
if not self.holders:
|
|
652
|
+
raise ClaimConflictError(
|
|
653
|
+
f"no {self.noun} carries the {self.kind.name} marker for {self.what}"
|
|
654
|
+
)
|
|
655
|
+
return self.holders[0]
|
|
656
|
+
|
|
657
|
+
|
|
658
|
+
@dataclass(frozen=True)
|
|
659
|
+
class Collection(Generic[O, C]):
|
|
660
|
+
"""Every claim of one kind across a collection of objects, scanned once."""
|
|
661
|
+
|
|
662
|
+
kind: MarkerKind[C]
|
|
663
|
+
noun: str
|
|
664
|
+
holders: tuple[Holder[O, C], ...]
|
|
665
|
+
defects: tuple[str, ...]
|
|
666
|
+
|
|
667
|
+
def claimants(self, key: Hashable, what: str) -> Claimants[O, C]:
|
|
668
|
+
mine = tuple(h for h in self.holders if h.claim.key == key)
|
|
669
|
+
return Claimants(self.kind, what, mine, self.defects, self.noun)
|
|
670
|
+
|
|
671
|
+
def grouped(
|
|
672
|
+
self, select: Callable[[C], bool], what: str
|
|
673
|
+
) -> dict[Hashable, tuple[Holder[O, C], ...]]:
|
|
674
|
+
"""Holders whose claim ``select`` accepts, per key; inconclusive on defects."""
|
|
675
|
+
if self.defects:
|
|
676
|
+
raise ClaimConflictError(
|
|
677
|
+
f"cannot establish which {self.noun}s carry {self.kind.name} markers for "
|
|
678
|
+
f"{what}: {'; '.join(self.defects)}"
|
|
679
|
+
)
|
|
680
|
+
out: dict[Hashable, list[Holder[O, C]]] = {}
|
|
681
|
+
for h in self.holders:
|
|
682
|
+
if select(h.claim):
|
|
683
|
+
out.setdefault(h.claim.key, []).append(h)
|
|
684
|
+
return {k: tuple(v) for k, v in out.items()}
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
def collect(kind: MarkerKind[C], objects: Iterable[O], noun: str) -> Collection[O, C]:
|
|
688
|
+
"""Scan every object once; the result answers every claimant question on it."""
|
|
689
|
+
holders: list[Holder[O, C]] = []
|
|
690
|
+
defects: list[str] = []
|
|
691
|
+
for obj in objects:
|
|
692
|
+
result = scan(kind, obj.body)
|
|
693
|
+
for reason in result.defects:
|
|
694
|
+
defects.append(f"{noun} {obj.url}: {reason}")
|
|
695
|
+
holders.extend(Holder(obj, claim) for claim in result.claims)
|
|
696
|
+
return Collection(kind, noun, tuple(holders), tuple(defects))
|
|
697
|
+
|
|
698
|
+
|
|
699
|
+
__all__ = [
|
|
700
|
+
"ACCEPTANCE",
|
|
701
|
+
"FOLLOW_UP",
|
|
702
|
+
"IMPLEMENTATION",
|
|
703
|
+
"MAX_BASE_REF_CHARS",
|
|
704
|
+
"OPERATOR_COMMENT",
|
|
705
|
+
"PROGRESS",
|
|
706
|
+
"REVIEW",
|
|
707
|
+
"AcceptanceClaim",
|
|
708
|
+
"Claimants",
|
|
709
|
+
"Collection",
|
|
710
|
+
"FollowUpClaim",
|
|
711
|
+
"Holder",
|
|
712
|
+
"ImplementationClaim",
|
|
713
|
+
"MarkerKind",
|
|
714
|
+
"OperatorCommentClaim",
|
|
715
|
+
"ProgressClaim",
|
|
716
|
+
"ReviewClaim",
|
|
717
|
+
"Scan",
|
|
718
|
+
"collect",
|
|
719
|
+
"render_acceptance_marker",
|
|
720
|
+
"render_follow_up_marker",
|
|
721
|
+
"render_implementation_marker",
|
|
722
|
+
"render_operator_comment_marker",
|
|
723
|
+
"render_progress_marker",
|
|
724
|
+
"render_review_marker",
|
|
725
|
+
"scan",
|
|
726
|
+
]
|