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.
Files changed (81) hide show
  1. autoforge/__init__.py +5 -0
  2. autoforge/agent_worktree.py +653 -0
  3. autoforge/certificate.py +495 -0
  4. autoforge/child_env.py +871 -0
  5. autoforge/claims.py +726 -0
  6. autoforge/claude_stream.py +501 -0
  7. autoforge/cli.py +2884 -0
  8. autoforge/config.py +2178 -0
  9. autoforge/config_fingerprint.py +374 -0
  10. autoforge/doctor.py +1551 -0
  11. autoforge/doctor_sandbox.py +691 -0
  12. autoforge/effect_ops.py +1272 -0
  13. autoforge/effects.py +3210 -0
  14. autoforge/engine.py +16637 -0
  15. autoforge/epic_commands.py +1486 -0
  16. autoforge/epic_graph.py +852 -0
  17. autoforge/errors.py +536 -0
  18. autoforge/evidence.py +1722 -0
  19. autoforge/executor.py +1601 -0
  20. autoforge/executor_duplex.py +985 -0
  21. autoforge/executor_supervised.py +144 -0
  22. autoforge/git_transport.py +970 -0
  23. autoforge/github.py +3402 -0
  24. autoforge/interaction.py +1027 -0
  25. autoforge/local_workspace.py +1253 -0
  26. autoforge/locking.py +1050 -0
  27. autoforge/loop_detect.py +660 -0
  28. autoforge/loop_guard.py +404 -0
  29. autoforge/materialize.py +722 -0
  30. autoforge/ownership.py +240 -0
  31. autoforge/pi_rpc.py +713 -0
  32. autoforge/premerge.py +181 -0
  33. autoforge/profiles.py +106 -0
  34. autoforge/progress.py +392 -0
  35. autoforge/prompts/__init__.py +173 -0
  36. autoforge/prompts/acceptance_actions.md +39 -0
  37. autoforge/prompts/analyze_execute.md +128 -0
  38. autoforge/prompts/common.md +132 -0
  39. autoforge/prompts/correction.md +12 -0
  40. autoforge/prompts/fix.md +252 -0
  41. autoforge/prompts/local_analyze_execute.md +69 -0
  42. autoforge/prompts/local_common.md +149 -0
  43. autoforge/prompts/local_fix.md +96 -0
  44. autoforge/prompts/local_review.md +103 -0
  45. autoforge/prompts/operator_acceptance.md +61 -0
  46. autoforge/prompts/replan_reexecute.md +991 -0
  47. autoforge/prompts/review.md +312 -0
  48. autoforge/prompts/update_epic.md +120 -0
  49. autoforge/prompts/update_epic_rerequest.md +85 -0
  50. autoforge/prompts/validation_evidence.md +26 -0
  51. autoforge/prompts/validation_failure.md +22 -0
  52. autoforge/prompts/worktree_state.md +45 -0
  53. autoforge/providers.py +2893 -0
  54. autoforge/read_context.py +389 -0
  55. autoforge/redaction.py +408 -0
  56. autoforge/replan.py +208 -0
  57. autoforge/replan_txn.py +1930 -0
  58. autoforge/result_parser.py +2234 -0
  59. autoforge/roadmap.py +151 -0
  60. autoforge/run_contract.py +408 -0
  61. autoforge/run_namespace.py +839 -0
  62. autoforge/runlog.py +764 -0
  63. autoforge/safefs.py +1717 -0
  64. autoforge/sandbox.py +1313 -0
  65. autoforge/sandbox_bwrap.py +815 -0
  66. autoforge/sandbox_pin.py +170 -0
  67. autoforge/sandbox_policy.py +1107 -0
  68. autoforge/scheduler.py +1143 -0
  69. autoforge/scheduler_state.py +2316 -0
  70. autoforge/scheduler_stop.py +421 -0
  71. autoforge/sdk_policy.py +988 -0
  72. autoforge/state.py +2417 -0
  73. autoforge/transitions.py +388 -0
  74. autoforge/validation.py +324 -0
  75. autoforge/worker.py +568 -0
  76. autoforge_cli-0.1.0a0.dist-info/METADATA +525 -0
  77. autoforge_cli-0.1.0a0.dist-info/RECORD +81 -0
  78. autoforge_cli-0.1.0a0.dist-info/WHEEL +5 -0
  79. autoforge_cli-0.1.0a0.dist-info/entry_points.txt +2 -0
  80. autoforge_cli-0.1.0a0.dist-info/licenses/LICENSE +21 -0
  81. autoforge_cli-0.1.0a0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1272 @@
1
+ """The I/O half of controller-owned effects: one typed operation per kind.
2
+
3
+ :mod:`autoforge.effects` holds the records and the pure decisions; this
4
+ module performs the reads and the one write of each effect kind and drives a
5
+ record through D4.1-D4.3 with them. It knows ``GitHubClient`` and
6
+ ``GitTransport`` methods, never ``gh`` or ``git`` argv; it holds no workflow
7
+ policy beyond the record it is handed, and it persists nothing itself: every
8
+ stage change goes through the ``persist`` callback, which the engine wires to
9
+ an atomic state save (D4.1: the count is durable before the write is issued).
10
+
11
+ One call of :func:`drive` issues a record's write at most once, and only
12
+ after a reconciliation read decided to (D4.2). The read-back that follows
13
+ decides the outcome: the end state is ``observed``; a write GitHub refused
14
+ conclusively is a ``conflict`` (D4.3); anything else -- a lost reply, an
15
+ unavailable GitHub, a write not yet visible -- leaves the record
16
+ ``attempted`` and raises, so the next entry reconciles before anything is
17
+ sent again. A create is therefore never re-sent blind, and the attempt bound
18
+ of :data:`~autoforge.effects.MAX_EFFECT_ATTEMPTS` holds across every crash
19
+ window.
20
+
21
+ Every kind of the closed set (ADR 0004 D5.1) has one operation here:
22
+ :class:`PushOp` (K1), :class:`ImplementationPrOp` (K2), :class:`AdoptPrOp`
23
+ (K3), :class:`ReviewCommentOp` (K4), :class:`FollowUpIssueOp` (K5),
24
+ :class:`FollowUpAppendOp` (K6), :class:`ReplacementPrOp` (K7),
25
+ :class:`ProgressCommentOp` (K8), :class:`AcceptanceCommentOp` (K9) and
26
+ :class:`OperatorCommentOp` (K10). A K9
27
+ write may also be *held* (``Action.HOLD``, #211): nothing is sent until the
28
+ operator approves it, nothing is sent or adopted while the run's issue does
29
+ not qualify, and :func:`drive` returns the record unchanged with the
30
+ reason, also when the issue stopped qualifying between the write and its
31
+ read-back.
32
+
33
+ K10 (#243) is **read-only**: the operator performs the comment and the
34
+ controller only verifies it. :class:`OperatorCommentOp` reads the target's
35
+ complete comment listing and the target itself; its ``issue`` raises
36
+ :class:`OperatorPerformedError` naming the record, :func:`drive` refuses an
37
+ operator-performed record before anything is persisted or sent, and
38
+ :func:`verify` is the one way such a record changes stage (``observed`` or
39
+ ``conflict``, saved as made). Each ``reconcile`` is a complete identity
40
+ read (D4.5): an open listing read to its end, or an all-states read bounded
41
+ by a head-branch filter or a number watermark, never the search API. A
42
+ create's completion is judged byte for byte on GitHub's text: `gh` prints
43
+ control characters and the text of their escapes as caret notation, so the
44
+ one object whose text differs on `gh`'s read, or matches a payload holding
45
+ a ``^``, is judged again on the client's exact read (D4.5a,
46
+ :func:`_exactly`, #327). Each
47
+ ``issue`` is exactly one write. What an operation checks is the record's own
48
+ identity, target and payload; a consumer's further completion predicates
49
+ (K2's and K7's head SHA against K1's observed candidate, K7's
50
+ ``_bind_replacement``) stay with the consumer, which holds the sibling
51
+ records.
52
+ """
53
+
54
+ from __future__ import annotations
55
+
56
+ from collections.abc import Callable, Hashable, Iterable, Mapping, Sequence
57
+ from dataclasses import dataclass, replace
58
+ from typing import Any, Protocol, TypeVar
59
+
60
+ from .claims import (
61
+ ACCEPTANCE,
62
+ FOLLOW_UP,
63
+ IMPLEMENTATION,
64
+ OPERATOR_COMMENT,
65
+ PROGRESS,
66
+ REVIEW,
67
+ Holder,
68
+ MarkerKind,
69
+ collect,
70
+ scan,
71
+ )
72
+ from .effects import (
73
+ MAX_BODY_CHARS,
74
+ Action,
75
+ Decision,
76
+ EffectKind,
77
+ EffectRecord,
78
+ Found,
79
+ Listed,
80
+ Stage,
81
+ decide_acceptance,
82
+ decide_append,
83
+ decide_create,
84
+ decide_operator_comment,
85
+ decide_push,
86
+ )
87
+ from .errors import (
88
+ EffectConflictError,
89
+ GitHubError,
90
+ GitHubNotFoundError,
91
+ GitHubUnavailableError,
92
+ GitHubWrongTargetError,
93
+ GitTransportError,
94
+ StateError,
95
+ )
96
+ from .git_transport import GitTransport, PushOutcome
97
+ from .github import CommentInfo, GitHubClient, IssueInfo, PRInfo
98
+ from .redaction import credential_classes, redact
99
+ from .replan_txn import scan_replan_markers
100
+ from .validation import parse_issue_url, parse_pr_url, same_issue_url
101
+
102
+ Persist = Callable[[EffectRecord], None]
103
+
104
+
105
+ class EffectOperation(Protocol):
106
+ """One kind's reads and write. ``reconcile`` reads only; ``issue`` writes once."""
107
+
108
+ def reconcile(self, record: EffectRecord) -> Decision: ...
109
+
110
+ def issue(self, record: EffectRecord) -> None: ...
111
+
112
+
113
+ class OperatorPerformedError(StateError):
114
+ """A K10 record reached a write path: the controller never issues it (#243)."""
115
+
116
+
117
+ class EffectPending(GitHubUnavailableError):
118
+ """The write was issued and its outcome is not yet readable; the record stays
119
+ ``attempted`` and the next entry reconciles it (transient, never re-sent blind)."""
120
+
121
+
122
+ @dataclass(frozen=True)
123
+ class Driven:
124
+ """A record as last persisted, and what happened to it in this call."""
125
+
126
+ record: EffectRecord
127
+ issued: bool # a write was sent in this call
128
+ # K9: why the due write was not sent, or its comment not adopted (the
129
+ # record is unchanged from its last save)
130
+ held: str = ""
131
+ held_url: str = "" # K9: the exact comment a hold did not adopt, "" when none
132
+
133
+
134
+ def drive(record: EffectRecord, op: EffectOperation, persist: Persist) -> Driven:
135
+ """Reconcile ``record`` and issue its write at most once (D4.1-D4.3).
136
+
137
+ Returns the record ``observed`` or ``conflict``, or (K9) the record
138
+ unchanged with :attr:`Driven.held` set when its write is due but may
139
+ not be sent yet: nothing is persisted or sent then. A K9 whose issue
140
+ stops qualifying between the write and its read-back is held the same
141
+ way, ``attempted`` as persisted before the write and with
142
+ :attr:`Driven.issued` set (PR #221 R2-F1). Raises, with the record
143
+ left as persisted, when the outcome cannot be read yet:
144
+ :class:`GitHubUnavailableError` from a reconciliation read (nothing was
145
+ persisted or charged), or :class:`EffectPending` after an issue whose
146
+ read-back did not show it. A conclusive read failure
147
+ (:class:`GitHubError`) propagates unchanged for the caller to block on.
148
+
149
+ An operator-performed record (K10, #243) is refused before any read,
150
+ persist or write: :func:`verify` is its one path.
151
+ """
152
+ if record.operator_performed:
153
+ raise OperatorPerformedError(
154
+ f"{record.describe()} ({record.action_id}) is operator-performed; the controller "
155
+ "never issues it, so it is verified, never driven"
156
+ )
157
+ decision = op.reconcile(record)
158
+ if decision.action is Action.DONE:
159
+ return Driven(record, issued=False)
160
+ if decision.action is Action.HOLD:
161
+ url = str((decision.observed or {}).get("url", ""))
162
+ return Driven(record, issued=False, held=decision.reason, held_url=url)
163
+ if decision.action is Action.OBSERVE:
164
+ record = record.observing(decision.observed or {})
165
+ persist(record)
166
+ return Driven(record, issued=False)
167
+ if decision.action is Action.CONFLICT:
168
+ if record.stage is not Stage.CONFLICT:
169
+ record = record.conflicting(decision.reason)
170
+ persist(record)
171
+ return Driven(record, issued=False)
172
+ if decision.action is Action.REBASE:
173
+ record = record.rebasing(decision.rebase_body)
174
+ else:
175
+ record = record.attempting()
176
+ # D4.1: the attempt is durable before the write leaves the process.
177
+ persist(record)
178
+ refused: GitHubError | None = None
179
+ try:
180
+ op.issue(record)
181
+ except GitHubUnavailableError:
182
+ # Ambiguous: it may have landed (W4) or not (W3). Only a read decides.
183
+ pass
184
+ except GitHubError as exc:
185
+ refused = exc
186
+ try:
187
+ after = op.reconcile(record)
188
+ except GitHubUnavailableError as exc:
189
+ raise EffectPending(
190
+ f"{record.describe()} was issued (attempt {record.attempts}) but GitHub could not "
191
+ f"be read back ({exc}); 'resume' reconciles it before anything is sent again"
192
+ ) from exc
193
+ if after.action is Action.OBSERVE:
194
+ record = record.observing(after.observed or {})
195
+ persist(record)
196
+ return Driven(record, issued=True)
197
+ held_url = str((after.observed or {}).get("url", ""))
198
+ if after.action is Action.HOLD and (held_url or refused is None):
199
+ # K9 (PR #221 R2-F1): the run's issue stopped qualifying between the
200
+ # write and its read-back. That is conclusive, not a write still
201
+ # propagating: the record stays attempted (it was sent) and the
202
+ # hold, naming the comment if it is there, goes to the caller.
203
+ return Driven(record, issued=True, held=after.reason, held_url=held_url)
204
+ if refused is not None:
205
+ # The read-back's own conflict, or hold, names the object the write
206
+ # collided with or why its target no longer qualifies.
207
+ found = (
208
+ after.reason
209
+ if after.action is Action.HOLD
210
+ or (after.action is Action.CONFLICT and not after.exhausted)
211
+ else "the target does not hold it"
212
+ )
213
+ record = record.conflicting(
214
+ f"{record.describe()}: GitHub refused the write ({redact(str(refused))}); {found}; "
215
+ "a refused write is not retried"
216
+ )
217
+ persist(record)
218
+ return Driven(record, issued=True)
219
+ if after.action is Action.CONFLICT and not after.exhausted:
220
+ # Not merely absent: another object holds the identity, or the target
221
+ # no longer qualifies. That is not something a later read can resolve.
222
+ record = record.conflicting(after.reason)
223
+ persist(record)
224
+ return Driven(record, issued=True)
225
+ raise EffectPending(
226
+ f"{record.describe()} was issued (attempt {record.attempts}) but its read-back does "
227
+ "not show it yet; the record stays attempted and 'resume' reconciles it before "
228
+ "anything is sent again"
229
+ )
230
+
231
+
232
+ def verify(record: EffectRecord, op: EffectOperation, persist: Persist) -> Driven:
233
+ """Reconcile an operator-performed record (K10, #243); never issue anything.
234
+
235
+ ``observed`` and ``conflict`` are persisted as made. A hold returns the
236
+ record, still ``intended``, with :attr:`Driven.held`; a failed
237
+ verification (:attr:`Decision.failed`) is kept as its reason, and a
238
+ plain "awaiting the operator" clears an earlier one, each persisted
239
+ only when it changes the record (PR #254 R3-F3). A decision to issue
240
+ is impossible for the kind and is refused as a controller bug. Raises
241
+ :class:`GitHubUnavailableError` with nothing persisted when the target
242
+ cannot be read now.
243
+ """
244
+ if not record.operator_performed:
245
+ raise OperatorPerformedError(f"{record.describe()} is not operator-performed")
246
+ decision = op.reconcile(record)
247
+ if decision.action is Action.DONE:
248
+ return Driven(record, issued=False)
249
+ if decision.action is Action.HOLD:
250
+ diagnosed = record.diagnosed(decision.reason if decision.failed else "")
251
+ if diagnosed != record:
252
+ persist(diagnosed)
253
+ return Driven(diagnosed, issued=False, held=decision.reason)
254
+ if decision.action is Action.OBSERVE:
255
+ record = record.observing(decision.observed or {})
256
+ persist(record)
257
+ return Driven(record, issued=False)
258
+ if decision.action is Action.CONFLICT:
259
+ if record.stage is not Stage.CONFLICT:
260
+ record = record.conflicting(decision.reason)
261
+ persist(record)
262
+ return Driven(record, issued=False)
263
+ raise OperatorPerformedError(
264
+ f"{record.describe()} ({record.action_id}): a verification decided to "
265
+ f"{decision.action.value}, which an operator-performed record never does"
266
+ )
267
+
268
+
269
+ def raise_conflict(record: EffectRecord) -> None:
270
+ """Raise the typed conflict a ``conflict`` record stands for (names the object only)."""
271
+ if record.stage is Stage.CONFLICT:
272
+ raise EffectConflictError(record.reason)
273
+
274
+
275
+ # -- shared reading helpers ------------------------------------------------------------
276
+
277
+
278
+ def _settled(record: EffectRecord) -> Decision | None:
279
+ """The decision of a terminal record, before any read: ``DONE`` or its conflict."""
280
+ if record.pending:
281
+ return None
282
+ return decide_create(record, ())
283
+
284
+
285
+ def _unreadable(record: EffectRecord, noun: str, defects: Sequence[str]) -> Decision:
286
+ """A conflict: the objects that could carry the identity cannot be read unambiguously.
287
+
288
+ The defect texts come from the claim scanner and name the object and the
289
+ rule; they never quote a body.
290
+ """
291
+ return Decision(
292
+ Action.CONFLICT,
293
+ reason=(
294
+ f"{record.describe()}: the {noun} cannot be read unambiguously ({'; '.join(defects)})"
295
+ ),
296
+ )
297
+
298
+
299
+ def _same_repository(a: str, b: str) -> bool:
300
+ return bool(a) and a.lower() == b.lower()
301
+
302
+
303
+ def _same_issue(a: str, b: str) -> bool:
304
+ return parse_issue_url(a).identity == parse_issue_url(b).identity
305
+
306
+
307
+ def _same_pr(a: str, b: str) -> bool:
308
+ return parse_pr_url(a).identity == parse_pr_url(b).identity
309
+
310
+
311
+ def _same_repo_head(pr: PRInfo) -> bool:
312
+ """The PR's head branch lives in the PR's own repository (not a fork)."""
313
+ return not pr.head_repository or _same_repository(pr.head_repository, pr.repository)
314
+
315
+
316
+ def _unique(objects: Iterable[PRInfo]) -> list[PRInfo]:
317
+ """PRs of one repository, each once (the first read of it is kept)."""
318
+ seen: set[tuple[str, int]] = set()
319
+ out: list[PRInfo] = []
320
+ for obj in objects:
321
+ key = (obj.repository.lower(), obj.number)
322
+ if key not in seen:
323
+ seen.add(key)
324
+ out.append(obj)
325
+ return out
326
+
327
+
328
+ def _unique_issues(objects: Iterable[IssueInfo]) -> list[IssueInfo]:
329
+ seen: set[tuple[str, int]] = set()
330
+ out: list[IssueInfo] = []
331
+ for obj in objects:
332
+ key = (obj.repository.lower(), obj.number)
333
+ if key not in seen:
334
+ seen.add(key)
335
+ out.append(obj)
336
+ return out
337
+
338
+
339
+ _Obj = TypeVar("_Obj")
340
+
341
+
342
+ def _printed_alike(record: EffectRecord) -> bool:
343
+ """Whether another text could print as ``record``'s payload through `gh`.
344
+
345
+ Every rewrite of `gh`'s sanitizer leaves caret notation, a ``^``, where
346
+ a control character or the text of its escape was. A title and body
347
+ without one can only have read back equal from the same bytes; with one,
348
+ an object holding a control character there (``\\x03`` for ``^C``)
349
+ prints the same, so equality on `gh`'s read proves nothing.
350
+ """
351
+ return "^" in record.body or "^" in str(record.payload.get("title", ""))
352
+
353
+
354
+ def _exactly(
355
+ record: EffectRecord,
356
+ found: list[Found],
357
+ objects: Sequence[_Obj],
358
+ placed: Callable[[_Obj], bool],
359
+ completes_exactly: Callable[[_Obj], bool],
360
+ ) -> list[Found]:
361
+ """``found``, its one object judged on GitHub's exact text when `gh`'s read cannot tell.
362
+
363
+ `gh` rewrites a control character, and the literal text of its JSON
364
+ escape (the six characters ``\\u001b`` in a body), into caret notation
365
+ before the controller reads it (see the exact read in
366
+ :mod:`autoforge.github`), so a create that landed byte for byte can read
367
+ back as another text, and (:func:`_printed_alike`) another text can read
368
+ back as the payload. When exactly one object (``objects[0]``) carries
369
+ the identity and its conditions other than its text hold on `gh`'s read
370
+ (``placed``), ``completes_exactly`` reads it again past the sanitizer
371
+ and judges the whole completion predicate on that read (the text, and
372
+ the other conditions once more) if its text differed, or matched a
373
+ payload another text could print as. Equality stays byte for byte; only
374
+ the read changes. Two or more objects are a conflict whatever their
375
+ text, so none is read again; neither is an object whose other conditions
376
+ fail, which no sanitizer explains, nor one that matched a payload with
377
+ no ``^``, which only its own bytes print as. An exact read that fails
378
+ raises as every identity read does (GitHubUnavailableError: nothing
379
+ persisted or sent; GitHubError: the caller blocks), so a text that could
380
+ not be read exactly is never a reason to write.
381
+ """
382
+ if len(found) != 1 or not placed(objects[0]):
383
+ return found
384
+ if found[0].in_end_state and not _printed_alike(record):
385
+ return found
386
+ return [replace(found[0], in_end_state=completes_exactly(objects[0]))]
387
+
388
+
389
+ def _comment_found(
390
+ github: GitHubClient, record: EffectRecord, holders: Iterable[Holder[CommentInfo, Any]]
391
+ ) -> list[Found]:
392
+ """A comment create's holders (K4, K8, K9): in the end state when bodied as the payload.
393
+
394
+ The holders come from the complete listing of the comment's parent, so a
395
+ comment has no condition but its body; one whose body `gh` printed
396
+ otherwise, or alike a payload with a ``^``, is judged on its exact read
397
+ (:func:`_exactly`), which also proves it is that comment on that parent.
398
+ """
399
+ comments: list[CommentInfo] = [h.obj for h in holders]
400
+ found = [
401
+ Found(url=c.url, in_end_state=c.body == record.body, observed={"url": c.url})
402
+ for c in comments
403
+ ]
404
+ return _exactly(
405
+ record,
406
+ found,
407
+ comments,
408
+ lambda _: True,
409
+ lambda c: github.get_comment_exact(c.url).body == record.body,
410
+ )
411
+
412
+
413
+ def append_problem(what: str, kind: MarkerKind, payload: str) -> str:
414
+ """D5.5: why a composed append payload may not be persisted, or "".
415
+
416
+ The same rules the record's payload is validated with on save (size, no
417
+ NUL or DEL, redaction-invariant), checked first so a rebase (or a plan,
418
+ #161) that fails them is a ``conflict`` or a block instead of a save
419
+ error, plus the marker kind's own scan: the composed body must leave
420
+ every marker of ``kind`` readable. The answer names ``what`` and the
421
+ pattern class, never the matched text or the body.
422
+ """
423
+ if len(payload) > MAX_BODY_CHARS:
424
+ return (
425
+ f"the composed body of {what} would be {len(payload)} characters, over the "
426
+ f"{MAX_BODY_CHARS}-character limit"
427
+ )
428
+ if "\x00" in payload or "\x7f" in payload:
429
+ return f"the body of {what} now carries a NUL or DEL character"
430
+ classes = credential_classes(payload)
431
+ if classes:
432
+ return (
433
+ f"the body of {what} now holds a credential-shaped string (pattern class: "
434
+ f"{', '.join(classes)}); published text is refused, never redacted. Remove it "
435
+ "from the body, treat the credential as exposed, then 'unblock'"
436
+ )
437
+ if scan(kind, payload).defects:
438
+ return (
439
+ f"the composed body of {what} would carry a malformed or repeated {kind.name} "
440
+ "marker; the body read now is not one the block can be appended to"
441
+ )
442
+ return ""
443
+
444
+
445
+ # -- K1: push an exact candidate ------------------------------------------------------
446
+
447
+
448
+ class PushRefusedError(GitHubError):
449
+ """K1: the push was refused conclusively, so the ref was not updated (D4.3).
450
+
451
+ Git's lease or the remote refused it, or the controller's own transport
452
+ refused before any ``git push`` ran (a default-branch ref, a
453
+ non-fast-forward, an object it could not read). Not
454
+ :class:`GitHubUnavailableError`, so :func:`drive` records ``conflict``
455
+ unless its read-back finds the ref at the candidate; it is never retried.
456
+ """
457
+
458
+
459
+ def _push_branch(record: EffectRecord) -> str:
460
+ return str(record.target["ref"]).removeprefix("refs/heads/")
461
+
462
+
463
+ class PushOp:
464
+ """K1 (ADR 0004 §2.5, §2.6): an exact candidate SHA to one controller-derived ref.
465
+
466
+ Identity: the repository, the ref and the candidate. Precondition: the
467
+ ref is at ``expected_old`` (absent when ``None``), the candidate descends
468
+ from it (a fast-forward), and the ref is not the default branch.
469
+ Payload: the candidate SHA. Completion: the remote ref, read through
470
+ the GitHub client, equals the candidate (D4.5: one branch read).
471
+
472
+ The push itself goes through :class:`GitTransport` only: an explicit
473
+ URL, a private ``GIT_DIR``, no repository, global or system
474
+ configuration, hooks off, and a compare-and-swap lease (D6, D7). That
475
+ the candidate descends from the recorded base, and the published
476
+ range's messages, are proven by the consumer before it plans the record
477
+ (``prove_range``, D7.5, D8.5).
478
+ """
479
+
480
+ def __init__(self, github: GitHubClient, transport: GitTransport, default_branch: str) -> None:
481
+ if not default_branch:
482
+ raise GitTransportError("the default branch must be named to refuse a push to it")
483
+ self.github = github
484
+ self.transport = transport
485
+ self.default_branch = default_branch
486
+
487
+ def reconcile(self, record: EffectRecord) -> Decision:
488
+ settled = _settled(record)
489
+ if settled is not None:
490
+ return settled
491
+ branch = _push_branch(record)
492
+ if branch.lower() == self.default_branch.lower():
493
+ return Decision(
494
+ Action.CONFLICT,
495
+ reason=(
496
+ f"{record.describe()}: {branch!r} is the default branch, which changes "
497
+ "only through a reviewed PR; a push to it is never issued"
498
+ ),
499
+ )
500
+ try:
501
+ head: str | None = self.github.get_branch_head_sha(record.target["repository"], branch)
502
+ except GitHubNotFoundError:
503
+ head = None
504
+ decision = decide_push(record, head)
505
+ expected = record.precondition["expected_old"]
506
+ if decision.action is not Action.ISSUE or expected is None:
507
+ return decision
508
+ # D6.2: the lease alone would permit a non-fast-forward. Proven here,
509
+ # before an attempt is charged, and again by the transport at issue.
510
+ candidate = record.payload["sha"]
511
+ try:
512
+ fast_forward = self.transport.descends_from(candidate, expected)
513
+ except GitTransportError as exc:
514
+ return Decision(
515
+ Action.CONFLICT,
516
+ reason=(
517
+ f"{record.describe()}: whether {candidate} descends from the branch's "
518
+ f"expected head {expected} cannot be proven ({exc}); a push is never "
519
+ "issued over an unproven ancestry"
520
+ ),
521
+ )
522
+ if not fast_forward:
523
+ return Decision(
524
+ Action.CONFLICT,
525
+ reason=(
526
+ f"{record.describe()}: {candidate} does not descend from the branch's "
527
+ f"expected head {expected}, so the push would not be a fast-forward"
528
+ ),
529
+ )
530
+ return decision
531
+
532
+ def issue(self, record: EffectRecord) -> None:
533
+ ref = record.target["ref"]
534
+ sha = record.payload["sha"]
535
+ expected = record.precondition["expected_old"]
536
+ try:
537
+ result = self.transport.push(
538
+ sha=sha,
539
+ branch=_push_branch(record),
540
+ expected_old=expected,
541
+ default_branch=self.default_branch,
542
+ )
543
+ except GitTransportError as exc:
544
+ # Raised before any `git push` ran: nothing was sent.
545
+ raise PushRefusedError(
546
+ f"the controller's git transport refused to push {sha} to {ref} before "
547
+ f"sending anything ({exc})"
548
+ ) from exc
549
+ if result.outcome in (PushOutcome.PUSHED, PushOutcome.UP_TO_DATE):
550
+ return
551
+ if result.outcome in (PushOutcome.LEASE_REJECTED, PushOutcome.REMOTE_REJECTED):
552
+ raise PushRefusedError(
553
+ f"git reported {result.outcome.value} for {sha} to {ref} over "
554
+ f"{expected or 'an absent ref'} ({result.detail})"
555
+ )
556
+ raise GitHubUnavailableError(
557
+ f"the push of {sha} to {ref} has an unknown outcome ({result.detail})"
558
+ )
559
+
560
+
561
+ # -- K2: the implementation PR --------------------------------------------------------
562
+
563
+
564
+ class ImplementationPrOp:
565
+ """K2 (ADR 0004 §2.5): the implementation PR, created by the controller.
566
+
567
+ Identity: the issue's ``ai-implementation`` marker and the derived head
568
+ branch. Precondition: no PR in any state is headed at the branch, and no
569
+ open PR carries the marker. Payload: title and body (the body ends with
570
+ the marker). Completion: exactly one PR carries the identity, and it is
571
+ open, headed at the branch in this repository, based on the target base
572
+ and titled and bodied as the payload (D4.5: the all-states listing
573
+ filtered by head branch, and the complete open listing; a closed or
574
+ merged PR on the branch is a conflict, never a reason for a second PR).
575
+ """
576
+
577
+ def __init__(self, github: GitHubClient) -> None:
578
+ self.github = github
579
+
580
+ def reconcile(self, record: EffectRecord) -> Decision:
581
+ settled = _settled(record)
582
+ if settled is not None:
583
+ return settled
584
+ repo = record.target["repository"]
585
+ head = record.target["head"]
586
+ claim = scan(IMPLEMENTATION, record.marker).claims[0]
587
+ on_head = self.github.list_prs_for_head(repo, head)
588
+ open_prs = self.github.list_open_prs(repo)
589
+ claimants = collect(IMPLEMENTATION, open_prs, "open PR").claimants(
590
+ claim.key, f"issue {claim.issue.canonical}"
591
+ )
592
+ if claimants.defects:
593
+ return _unreadable(record, f"open PRs of {repo}", claimants.defects)
594
+ on_head_numbers = {pr.number for pr in on_head}
595
+
596
+ def placed(pr: PRInfo) -> bool:
597
+ return (
598
+ pr.number in on_head_numbers
599
+ and pr.is_open
600
+ and pr.head_ref == head
601
+ and _same_repo_head(pr)
602
+ and pr.base_ref == record.target["base"]
603
+ )
604
+
605
+ def completes(pr: PRInfo) -> bool:
606
+ return placed(pr) and pr.title == record.payload["title"] and pr.body == record.body
607
+
608
+ prs = _unique([*on_head, *(h.obj for h in claimants.holders)])
609
+ found = [
610
+ Found(
611
+ url=pr.url,
612
+ in_end_state=completes(pr),
613
+ observed={"url": pr.url, "number": pr.number},
614
+ )
615
+ for pr in prs
616
+ ]
617
+ return decide_create(
618
+ record,
619
+ _exactly(
620
+ record,
621
+ found,
622
+ prs,
623
+ placed,
624
+ lambda pr: completes(self.github.get_pr_exact(pr.url)),
625
+ ),
626
+ )
627
+
628
+ def issue(self, record: EffectRecord) -> None:
629
+ self.github.create_pull_request(
630
+ record.target["repository"],
631
+ base=record.target["base"],
632
+ head=record.target["head"],
633
+ title=record.payload["title"],
634
+ body=record.body,
635
+ )
636
+
637
+
638
+ # -- K3: adopt an existing PR ---------------------------------------------------------
639
+
640
+
641
+ class AdoptPrOp:
642
+ """K3 (ADR 0004 §2.5, D5.5): append the issue's block to an existing PR's body.
643
+
644
+ Target: the PR, open, in the owner issue's repository and not headed in
645
+ a fork. Precondition: its body equals the recorded base and carries no
646
+ implementation marker. Payload: the base, the separator and the block
647
+ (``Closes #n`` and the marker). Completion: the body equals the payload
648
+ byte for byte, and among the open PRs (the complete listing, D4.5) the
649
+ marker resolves to this one only. A rebase (D5.5) is judged by size, the
650
+ credential check and the marker scan of the composed body.
651
+ """
652
+
653
+ def __init__(self, github: GitHubClient) -> None:
654
+ self.github = github
655
+
656
+ def reconcile(self, record: EffectRecord) -> Decision:
657
+ settled = _settled(record)
658
+ if settled is not None:
659
+ return settled
660
+ pr_url = record.target["pr_url"]
661
+ repo = parse_issue_url(record.owner.issue_url).repository
662
+ claim = scan(IMPLEMENTATION, record.marker).claims[0]
663
+ pr = self.github.get_pr(pr_url)
664
+ claimants = collect(IMPLEMENTATION, self.github.list_open_prs(repo), "open PR").claimants(
665
+ claim.key, f"issue {claim.issue.canonical}"
666
+ )
667
+ if claimants.defects:
668
+ return _unreadable(record, f"open PRs of {repo}", claimants.defects)
669
+ others = sorted({h.obj.url for h in claimants.holders if not _same_pr(h.obj.url, pr_url)})
670
+ if others:
671
+ return Decision(
672
+ Action.CONFLICT,
673
+ reason=(
674
+ f"{record.describe()}: {', '.join(others)} already carries the "
675
+ f"implementation marker for issue {claim.issue.canonical}; an issue has "
676
+ "one implementation PR"
677
+ ),
678
+ )
679
+ found = scan(IMPLEMENTATION, pr.body)
680
+ if pr.body != record.body and (
681
+ found.defects or any(c.key != claim.key for c in found.claims)
682
+ ):
683
+ return Decision(
684
+ Action.CONFLICT,
685
+ reason=(
686
+ f"{record.describe()}: the PR's body already carries another or a malformed "
687
+ "implementation marker; an adopted PR implements exactly one issue"
688
+ ),
689
+ )
690
+ return decide_append(
691
+ record,
692
+ pr.body,
693
+ target_ok=(
694
+ pr.is_open
695
+ and _same_repository(pr.repository, repo)
696
+ and _same_pr(pr.url, pr_url)
697
+ and _same_repo_head(pr)
698
+ ),
699
+ carries_block_marker=(
700
+ record.marker in pr.body or any(c.key == claim.key for c in found.claims)
701
+ ),
702
+ rebase_problem=lambda payload: append_problem(pr_url, IMPLEMENTATION, payload),
703
+ )
704
+
705
+ def issue(self, record: EffectRecord) -> None:
706
+ self.github.write_pr_body(record.target["pr_url"], record.body)
707
+
708
+
709
+ # -- K4: the review round comment -----------------------------------------------------
710
+
711
+
712
+ class ReviewCommentOp:
713
+ """K4 (ADR 0004 §2.5): the controller-rendered review round comment on the PR.
714
+
715
+ Identity: the ``ai-review-result`` marker for the round, reviewed HEAD,
716
+ base ref and merge base. Precondition: no comment of the PR carries it.
717
+ Payload: the rendered comment, ending with the marker. Completion:
718
+ exactly one top-level comment carries the marker, and its body equals
719
+ the payload (D4.5: the complete comment listing of the PR).
720
+ """
721
+
722
+ def __init__(self, github: GitHubClient) -> None:
723
+ self.github = github
724
+
725
+ def reconcile(self, record: EffectRecord) -> Decision:
726
+ settled = _settled(record)
727
+ if settled is not None:
728
+ return settled
729
+ pr_url = record.target["pr_url"]
730
+ claim = scan(REVIEW, record.marker).claims[0]
731
+ comments = self.github.get_pr_comments(pr_url)
732
+ claimants = collect(REVIEW, comments, "comment").claimants(
733
+ claim.key, f"round {claim.round} at HEAD {claim.reviewed_head_sha} on {pr_url}"
734
+ )
735
+ if claimants.defects:
736
+ return _unreadable(record, "PR's comments", claimants.defects)
737
+ return decide_create(record, _comment_found(self.github, record, claimants.holders))
738
+
739
+ def issue(self, record: EffectRecord) -> None:
740
+ self.github.create_pr_comment(record.target["pr_url"], record.body)
741
+
742
+
743
+ # -- K5: a follow-up issue ------------------------------------------------------------
744
+
745
+
746
+ class FollowUpIssueOp:
747
+ """K5 (ADR 0004 §2.5): a follow-up issue for one deferred finding.
748
+
749
+ Identity: the ``ai-follow-up`` marker for the PR and finding.
750
+ Precondition: no open issue carries it, and no issue numbered above the
751
+ persisted watermark does. Payload: title and body (the body ends with the
752
+ marker). Completion: exactly one issue carries the marker, and it is
753
+ above the watermark, open, in this repository, not the current issue and
754
+ titled and bodied as the payload (D4.5: the complete open listing, and
755
+ the all-states listing read newest first down to the watermark). A
756
+ follow-up created and then closed by a human is a conflict, never a
757
+ reason to create a second one.
758
+ """
759
+
760
+ def __init__(self, github: GitHubClient) -> None:
761
+ self.github = github
762
+
763
+ def reconcile(self, record: EffectRecord) -> Decision:
764
+ settled = _settled(record)
765
+ if settled is not None:
766
+ return settled
767
+ repo = record.target["repository"]
768
+ watermark = record.precondition["watermark"]
769
+ claim = scan(FOLLOW_UP, record.marker).claims[0]
770
+ open_issues = self.github.list_open_issues(repo, strict=True)
771
+ above = self.github.list_issues_above(repo, watermark)
772
+ issues = _unique_issues([*above, *open_issues])
773
+ claimants = collect(FOLLOW_UP, issues, "issue").claimants(
774
+ claim.key, f"finding {claim.finding_id} of {claim.pr.canonical}"
775
+ )
776
+ if claimants.defects:
777
+ return _unreadable(record, f"issues of {repo}", claimants.defects)
778
+
779
+ def placed(issue: IssueInfo) -> bool:
780
+ return (
781
+ issue.number > watermark
782
+ and issue.is_open
783
+ and _same_repository(issue.repository, repo)
784
+ and not _same_issue(issue.url, record.owner.issue_url)
785
+ )
786
+
787
+ def completes(issue: IssueInfo) -> bool:
788
+ return (
789
+ placed(issue)
790
+ and issue.title == record.payload["title"]
791
+ and issue.body == record.body
792
+ )
793
+
794
+ holders = [h.obj for h in claimants.holders]
795
+ found = [
796
+ Found(
797
+ url=issue.url,
798
+ in_end_state=completes(issue),
799
+ observed={"url": issue.url, "number": issue.number},
800
+ )
801
+ for issue in holders
802
+ ]
803
+ return decide_create(
804
+ record,
805
+ _exactly(
806
+ record,
807
+ found,
808
+ holders,
809
+ placed,
810
+ lambda issue: completes(self.github.get_issue_exact(issue.url)),
811
+ ),
812
+ )
813
+
814
+ def issue(self, record: EffectRecord) -> None:
815
+ self.github.create_issue(
816
+ record.target["repository"], title=record.payload["title"], body=record.body
817
+ )
818
+
819
+
820
+ # -- K6: append follow-up markers to an existing issue --------------------------------
821
+
822
+
823
+ def _marker_keys(kind: MarkerKind, markers: Iterable[str]) -> set[Hashable]:
824
+ return {scan(kind, m).claims[0].key for m in markers}
825
+
826
+
827
+ class FollowUpAppendOp:
828
+ """K6 (ADR 0004 §2.5, D5.5): append a phase's follow-up markers to one handed-over issue.
829
+
830
+ Target: the issue, open, in the owner issue's repository and not the
831
+ current issue. Precondition: its body equals the recorded base and
832
+ carries none of the block's markers. Payload: the base, the separator
833
+ and the block (the markers, one per line). Completion: the body equals
834
+ the payload byte for byte, and no other open issue carries a marker of
835
+ the block (one open follow-up per finding; D4.5: the complete open
836
+ listing). A rebase (D5.5) is judged by size, the credential check and
837
+ the marker scan of the composed body.
838
+ """
839
+
840
+ def __init__(self, github: GitHubClient) -> None:
841
+ self.github = github
842
+
843
+ def reconcile(self, record: EffectRecord) -> Decision:
844
+ settled = _settled(record)
845
+ if settled is not None:
846
+ return settled
847
+ issue_url = record.target["issue_url"]
848
+ repo = parse_issue_url(record.owner.issue_url).repository
849
+ markers: list[str] = list(record.identity["markers"])
850
+ keys = _marker_keys(FOLLOW_UP, markers)
851
+ issue = self.github.get_issue(issue_url)
852
+ listing = collect(FOLLOW_UP, self.github.list_open_issues(repo, strict=True), "open issue")
853
+ if listing.defects:
854
+ return _unreadable(record, f"open issues of {repo}", listing.defects)
855
+ others = sorted(
856
+ {
857
+ h.obj.url
858
+ for h in listing.holders
859
+ if h.claim.key in keys and not _same_issue(h.obj.url, issue_url)
860
+ }
861
+ )
862
+ if others:
863
+ return Decision(
864
+ Action.CONFLICT,
865
+ reason=(
866
+ f"{record.describe()}: {', '.join(others)} already carries a marker of its "
867
+ "block; a finding has one open follow-up issue"
868
+ ),
869
+ )
870
+ found = scan(FOLLOW_UP, issue.body)
871
+ return decide_append(
872
+ record,
873
+ issue.body,
874
+ target_ok=(
875
+ issue.is_open
876
+ and _same_repository(issue.repository, repo)
877
+ and _same_issue(issue.url, issue_url)
878
+ and not _same_issue(issue.url, record.owner.issue_url)
879
+ ),
880
+ carries_block_marker=(
881
+ any(m in issue.body for m in markers) or any(c.key in keys for c in found.claims)
882
+ ),
883
+ rebase_problem=lambda payload: append_problem(issue_url, FOLLOW_UP, payload),
884
+ )
885
+
886
+ def issue(self, record: EffectRecord) -> None:
887
+ self.github.write_issue_body(record.target["issue_url"], record.body)
888
+
889
+
890
+ # -- K7: the replacement PR -----------------------------------------------------------
891
+
892
+
893
+ class ReplacementPrOp:
894
+ """K7 (ADR 0004 §2.5): the replan's replacement PR, created by the controller.
895
+
896
+ Identity: the transaction's ``autoforge-replan-transaction`` marker and
897
+ the derived replacement branch. Precondition: no PR in any state is
898
+ headed at the branch, and no PR numbered above the persisted watermark
899
+ carries the transaction's marker. Payload: title and body. Completion:
900
+ exactly one PR carries the identity, and it is above the watermark, open,
901
+ headed at the branch in this repository, based on the target base and
902
+ titled and bodied as the payload (D4.5: the all-states listing filtered
903
+ by head branch, and the all-states listing bounded by the watermark).
904
+ ``_bind_replacement``'s other predicates (linked issue, sole
905
+ implementation claimant, attestation) remain the consumer's (#164).
906
+ """
907
+
908
+ def __init__(self, github: GitHubClient) -> None:
909
+ self.github = github
910
+
911
+ def reconcile(self, record: EffectRecord) -> Decision:
912
+ settled = _settled(record)
913
+ if settled is not None:
914
+ return settled
915
+ repo = record.target["repository"]
916
+ head = record.target["head"]
917
+ watermark = record.precondition["watermark"]
918
+ txn = record.owner.transaction_id
919
+ on_head = self.github.list_prs_for_head(repo, head)
920
+ above = [p for p in self.github.list_all_prs(repo, strict=True) if p.number > watermark]
921
+ malformed: list[str] = []
922
+ holders: list[PRInfo] = []
923
+ for pr in above:
924
+ markers = scan_replan_markers(pr.body)
925
+ malformed.extend(f"PR {pr.url}: {reason}" for reason in markers.malformed)
926
+ if any(a.transaction_id == txn for a in markers.attestations):
927
+ holders.append(pr)
928
+ if malformed:
929
+ return _unreadable(record, f"PRs of {repo} above #{watermark}", malformed)
930
+ on_head_numbers = {pr.number for pr in on_head}
931
+
932
+ def placed(pr: PRInfo) -> bool:
933
+ return (
934
+ pr.number in on_head_numbers
935
+ and pr.number > watermark
936
+ and pr.is_open
937
+ and pr.head_ref == head
938
+ and _same_repo_head(pr)
939
+ and pr.base_ref == record.target["base"]
940
+ )
941
+
942
+ def completes(pr: PRInfo) -> bool:
943
+ return placed(pr) and pr.title == record.payload["title"] and pr.body == record.body
944
+
945
+ prs = _unique([*on_head, *holders])
946
+ found = [
947
+ Found(
948
+ url=pr.url,
949
+ in_end_state=completes(pr),
950
+ observed={"url": pr.url, "number": pr.number},
951
+ )
952
+ for pr in prs
953
+ ]
954
+ return decide_create(
955
+ record,
956
+ _exactly(
957
+ record,
958
+ found,
959
+ prs,
960
+ placed,
961
+ lambda pr: completes(self.github.get_pr_exact(pr.url)),
962
+ ),
963
+ )
964
+
965
+ def issue(self, record: EffectRecord) -> None:
966
+ self.github.create_pull_request(
967
+ record.target["repository"],
968
+ base=record.target["base"],
969
+ head=record.target["head"],
970
+ title=record.payload["title"],
971
+ body=record.body,
972
+ )
973
+
974
+
975
+ # -- K8: the EPIC progress comment ------------------------------------------------
976
+
977
+
978
+ class ProgressCommentOp:
979
+ """K8 (ADR 0004 §2.5): the controller-posted progress comment on the EPIC.
980
+
981
+ Identity: the ``ai-epic-progress`` marker for (issue, PR) on the EPIC.
982
+ Precondition: no comment of the EPIC carries it. Payload: the progress
983
+ text followed by the marker. Completion: exactly one comment carries the
984
+ marker, and its body equals the payload (D4.5: the complete comment
985
+ listing of the EPIC, never the search API).
986
+ """
987
+
988
+ def __init__(self, github: GitHubClient) -> None:
989
+ self.github = github
990
+
991
+ def reconcile(self, record: EffectRecord) -> Decision:
992
+ if record.stage in (Stage.OBSERVED, Stage.CONFLICT):
993
+ return decide_create(record, ())
994
+ epic = record.target["epic_url"]
995
+ claim = scan(PROGRESS, record.marker).claims[0]
996
+ comments = self.github.get_issue_comments(epic)
997
+ claimants = collect(PROGRESS, comments, "comment").claimants(
998
+ claim.key, f"issue {claim.issue.canonical} (PR {claim.pr.canonical}) on EPIC {epic}"
999
+ )
1000
+ if claimants.defects:
1001
+ return Decision(
1002
+ Action.CONFLICT,
1003
+ reason=(
1004
+ f"{record.describe()}: the EPIC's comments cannot be read unambiguously "
1005
+ f"({'; '.join(claimants.defects)})"
1006
+ ),
1007
+ )
1008
+ return decide_create(record, _comment_found(self.github, record, claimants.holders))
1009
+
1010
+ def issue(self, record: EffectRecord) -> None:
1011
+ self.github.create_issue_comment(record.target["epic_url"], record.body)
1012
+
1013
+
1014
+ class AcceptanceCommentOp:
1015
+ """K9: the acceptance comment of one finding on the run's own issue (#211).
1016
+
1017
+ Identity: the ``ai-acceptance`` marker for (issue, PR, round, finding)
1018
+ on the issue. Precondition: no comment of the issue carries it, the
1019
+ issue is open and in the repository of the run's PR, and the write is
1020
+ authorized (an operator approval of the record's action id, or the
1021
+ policy). Payload: the controller's header, the agent's text and the
1022
+ marker. Completion: exactly one comment carries the marker, and its
1023
+ body equals the payload (D4.5: the complete comment listing of the
1024
+ issue, never the search API). Whoever posted it is irrelevant: an
1025
+ operator who posts the exact payload has done the write. It is adopted
1026
+ only while the issue still qualifies (PR #221 R1-F2): on an issue that
1027
+ was closed or left the run's repository it is held, as the write is.
1028
+ """
1029
+
1030
+ def __init__(self, github: GitHubClient) -> None:
1031
+ self.github = github
1032
+
1033
+ def reconcile(self, record: EffectRecord) -> Decision:
1034
+ if record.stage in (Stage.OBSERVED, Stage.CONFLICT):
1035
+ return decide_create(record, ())
1036
+ issue_url = record.target["issue_url"]
1037
+ claim = scan(ACCEPTANCE, record.marker).claims[0]
1038
+ comments = self.github.get_issue_comments(issue_url)
1039
+ claimants = collect(ACCEPTANCE, comments, "comment").claimants(
1040
+ claim.key,
1041
+ f"finding {claim.finding_id} of round {claim.round} of PR {claim.pr.canonical} "
1042
+ f"on issue {issue_url}",
1043
+ )
1044
+ if claimants.defects:
1045
+ return Decision(
1046
+ Action.CONFLICT,
1047
+ reason=(
1048
+ f"{record.describe()}: the issue's comments cannot be read unambiguously "
1049
+ f"({'; '.join(claimants.defects)})"
1050
+ ),
1051
+ )
1052
+ found = _comment_found(self.github, record, claimants.holders)
1053
+ problem = self._issue_problem(issue_url, record.owner.pr_url)
1054
+ return decide_acceptance(record, found, issue_problem=problem)
1055
+
1056
+ def _issue_problem(self, issue_url: str, pr_url: str) -> str:
1057
+ """Why the run's issue may not receive the comment now, or ""."""
1058
+ issue = self.github.get_issue(issue_url)
1059
+ repository = parse_pr_url(pr_url).repository
1060
+ if not _same_repository(
1061
+ issue.repository or parse_issue_url(issue_url).repository, repository
1062
+ ):
1063
+ return f"issue {issue_url} is not in the run's repository {repository}"
1064
+ if not issue.is_open:
1065
+ return f"issue {issue_url} is {issue.state.lower() or 'not open'}, not open"
1066
+ return ""
1067
+
1068
+ def issue(self, record: EffectRecord) -> None:
1069
+ self.github.create_issue_comment(record.target["issue_url"], record.body)
1070
+
1071
+
1072
+ class OperatorCommentOp:
1073
+ """K10: one operator comment on another issue of the run's repository (#243). Read-only.
1074
+
1075
+ Identity: the ``ai-operator-comment`` marker for (run issue, PR, round,
1076
+ finding, target) on the target. The operator posts it; the controller
1077
+ never does. Completion: exactly one comment of the target's complete
1078
+ listing carries the marker and its body equals the payload (``exact``),
1079
+ or the comment the operator bound is in the listing with its digest
1080
+ unchanged (``bound``). ``plan`` is the FIX plan's records: a marker of
1081
+ another K10 of the plan, meant for another issue, found on this target
1082
+ is a misplaced payload and a conflict. The target is read too: it must
1083
+ still be an issue of the run's repository (open or closed).
1084
+
1085
+ One comment verifies one action (PR #254 R2-F1): a comment another
1086
+ record of the plan is observed on, one carrying the marker of another
1087
+ record of the plan (its exact evidence, observed or not yet), and one
1088
+ ``reserved`` names (``{comment id: action id}``, the comments verified
1089
+ for other actions earlier in the round) is never this record's evidence.
1090
+ """
1091
+
1092
+ def __init__(
1093
+ self,
1094
+ github: GitHubClient,
1095
+ plan: Sequence[EffectRecord] = (),
1096
+ reserved: Mapping[int, str] | None = None,
1097
+ ) -> None:
1098
+ self.github = github
1099
+ self.plan = tuple(plan)
1100
+ self.reserved = dict(reserved or {})
1101
+
1102
+ def reconcile(self, record: EffectRecord) -> Decision:
1103
+ if record.stage in (Stage.OBSERVED, Stage.CONFLICT):
1104
+ return decide_operator_comment(record, own=(), misplaced=(), listing=())
1105
+ target_url = record.target["issue_url"]
1106
+ problem = self.target_problem(target_url, record.owner.issue_url)
1107
+ if problem:
1108
+ return decide_operator_comment(
1109
+ record, own=(), misplaced=(), listing=(), target_problem=problem
1110
+ )
1111
+ return self.reconcile_listing(record)
1112
+
1113
+ def reconcile_listing(self, record: EffectRecord) -> Decision:
1114
+ """:meth:`reconcile` from the target's complete listing alone (#243).
1115
+
1116
+ For a caller that has read the target itself
1117
+ (:meth:`target_disqualification`). Any failure to read the listing
1118
+ propagates.
1119
+ """
1120
+ if record.stage in (Stage.OBSERVED, Stage.CONFLICT):
1121
+ return decide_operator_comment(record, own=(), misplaced=(), listing=())
1122
+ target_url = record.target["issue_url"]
1123
+ comments = self.github.get_issue_comments(target_url)
1124
+ listing = [Listed(url=c.url, comment_id=c.id, body=c.body) for c in comments]
1125
+ collection = collect(OPERATOR_COMMENT, comments, "comment")
1126
+ key = scan(OPERATOR_COMMENT, record.marker).claims[0].key
1127
+ siblings = [
1128
+ r
1129
+ for r in self.plan
1130
+ if r.kind is EffectKind.OPERATOR_COMMENT and r.position != record.position
1131
+ ]
1132
+ others = {
1133
+ scan(OPERATOR_COMMENT, r.marker).claims[0].key
1134
+ for r in siblings
1135
+ if not same_issue_url(r.target["issue_url"], target_url)
1136
+ }
1137
+ claimants = {scan(OPERATOR_COMMENT, r.marker).claims[0].key: r.action_id for r in siblings}
1138
+ own = [
1139
+ Listed(url=h.obj.url, comment_id=h.obj.id, body=h.obj.body)
1140
+ for h in collection.holders
1141
+ if h.claim.key == key
1142
+ ]
1143
+ misplaced = [
1144
+ Listed(url=h.obj.url, comment_id=h.obj.id, body=h.obj.body)
1145
+ for h in collection.holders
1146
+ if h.claim.key in others
1147
+ ]
1148
+ taken = dict(self.reserved)
1149
+ for r in siblings:
1150
+ if r.stage is Stage.OBSERVED:
1151
+ taken[int((r.observed or {})["comment_id"])] = r.action_id
1152
+ for h in collection.holders:
1153
+ if h.claim.key in claimants:
1154
+ taken.setdefault(h.obj.id, claimants[h.claim.key])
1155
+ return decide_operator_comment(
1156
+ record,
1157
+ own=own,
1158
+ misplaced=misplaced,
1159
+ listing=listing,
1160
+ defects=collection.defects,
1161
+ taken={cid: a for cid, a in taken.items() if a != record.action_id},
1162
+ )
1163
+
1164
+ def target_problem(self, target_url: str, issue_url: str) -> str:
1165
+ """Why the target no longer qualifies (transferred, deleted, a PR), or "".
1166
+
1167
+ A transient failure propagates; every conclusive one is a reason.
1168
+ """
1169
+ try:
1170
+ return self.target_disqualification(target_url, issue_url)
1171
+ except GitHubUnavailableError:
1172
+ raise
1173
+ except GitHubError as exc:
1174
+ return f"issue {target_url} cannot be read ({redact(str(exc))})"
1175
+
1176
+ def target_disqualification(self, target_url: str, issue_url: str) -> str:
1177
+ """:meth:`target_problem` from an answer GitHub gave, or "" (#243).
1178
+
1179
+ Not found, another object or another repository is a reason. Any
1180
+ other conclusive failure propagates, like a transient one: an
1181
+ ``unblock`` refuses on a target it cannot read rather than holding
1182
+ it (PR #254 R1-F1).
1183
+ """
1184
+ try:
1185
+ return self.target_mismatch(target_url, issue_url)
1186
+ except GitHubNotFoundError:
1187
+ return f"issue {target_url} is not found (deleted, or not readable with this token)"
1188
+
1189
+ def target_mismatch(self, target_url: str, issue_url: str) -> str:
1190
+ """:meth:`target_disqualification` of a target GitHub finds, or "" (#243).
1191
+
1192
+ Another object or another repository is a reason. Not found
1193
+ propagates like every other failure, for a caller to which a missing
1194
+ target is no reason: a decline, where it holds no marker (PR #254
1195
+ R4-F1).
1196
+ """
1197
+ try:
1198
+ issue = self.github.get_issue(target_url)
1199
+ except GitHubWrongTargetError as exc:
1200
+ what = "a pull request" if exc.is_pull_request else "another object"
1201
+ return f"issue {target_url} answers as {what} ({exc.answered_url})"
1202
+ repository = parse_issue_url(issue_url).repository
1203
+ if not _same_repository(issue.repository or repository, repository):
1204
+ return f"issue {target_url} is no longer in the run's repository {repository}"
1205
+ return ""
1206
+
1207
+ def issue(self, record: EffectRecord) -> None:
1208
+ raise OperatorPerformedError(
1209
+ f"{record.describe()} ({record.action_id}) is operator-performed; the controller "
1210
+ "never posts it"
1211
+ )
1212
+
1213
+
1214
+ _GITHUB_OPS: dict[EffectKind, Callable[[GitHubClient], EffectOperation]] = {
1215
+ EffectKind.IMPLEMENTATION_PR: ImplementationPrOp,
1216
+ EffectKind.ADOPT_PR: AdoptPrOp,
1217
+ EffectKind.REVIEW_COMMENT: ReviewCommentOp,
1218
+ EffectKind.FOLLOW_UP_ISSUE: FollowUpIssueOp,
1219
+ EffectKind.FOLLOW_UP_APPEND: FollowUpAppendOp,
1220
+ EffectKind.REPLACEMENT_PR: ReplacementPrOp,
1221
+ EffectKind.PROGRESS_COMMENT: ProgressCommentOp,
1222
+ EffectKind.ACCEPTANCE_COMMENT: AcceptanceCommentOp,
1223
+ # Read-only: its ``issue`` raises (#243).
1224
+ EffectKind.OPERATOR_COMMENT: OperatorCommentOp,
1225
+ }
1226
+
1227
+
1228
+ def operation_for(
1229
+ record: EffectRecord,
1230
+ github: GitHubClient,
1231
+ *,
1232
+ transport: GitTransport | None = None,
1233
+ default_branch: str = "",
1234
+ ) -> EffectOperation:
1235
+ """The operation of ``record``'s kind.
1236
+
1237
+ A push (K1) also needs the controller's git transport and the default
1238
+ branch it must never push to; without them it has no operation, which
1239
+ is a conflict naming the record rather than a push through anything
1240
+ else. Every other kind needs the GitHub client only.
1241
+ """
1242
+ if record.kind is EffectKind.PUSH:
1243
+ if transport is None or not default_branch:
1244
+ raise EffectConflictError(
1245
+ f"{record.describe()}: a push needs the controller's git transport and the "
1246
+ "default branch, and neither may be left out"
1247
+ )
1248
+ return PushOp(github, transport, default_branch)
1249
+ return _GITHUB_OPS[record.kind](github)
1250
+
1251
+
1252
+ __all__ = [
1253
+ "AcceptanceCommentOp",
1254
+ "AdoptPrOp",
1255
+ "Driven",
1256
+ "EffectOperation",
1257
+ "EffectPending",
1258
+ "FollowUpAppendOp",
1259
+ "FollowUpIssueOp",
1260
+ "ImplementationPrOp",
1261
+ "OperatorCommentOp",
1262
+ "OperatorPerformedError",
1263
+ "ProgressCommentOp",
1264
+ "PushOp",
1265
+ "PushRefusedError",
1266
+ "ReplacementPrOp",
1267
+ "ReviewCommentOp",
1268
+ "drive",
1269
+ "operation_for",
1270
+ "raise_conflict",
1271
+ "verify",
1272
+ ]