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/effect_ops.py
ADDED
|
@@ -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
|
+
]
|