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/effects.py
ADDED
|
@@ -0,0 +1,3210 @@
|
|
|
1
|
+
"""Controller-owned external effects: records, completion contexts, decisions.
|
|
2
|
+
|
|
3
|
+
ADR 0004 moves every externally visible write of a phase from the agent to
|
|
4
|
+
the controller. This module is the pure part of that layer (D12.1): the
|
|
5
|
+
persisted shapes, their validation on load, and the reconciliation
|
|
6
|
+
decisions as functions of (record, what was read back). It does no I/O,
|
|
7
|
+
knows no provider and speaks no ``gh``; the reads and writes live in
|
|
8
|
+
:mod:`autoforge.effect_ops`, :mod:`autoforge.github` and
|
|
9
|
+
:mod:`autoforge.git_transport`.
|
|
10
|
+
|
|
11
|
+
The vocabulary
|
|
12
|
+
--------------
|
|
13
|
+
An **effect** is one controller write to one exact remote object (D2.1). Its
|
|
14
|
+
**record** (:class:`EffectRecord`) holds the kind, a deterministic identity,
|
|
15
|
+
the exact target, the precondition, the payload (the exact bytes to send,
|
|
16
|
+
redaction-invariant), the completion criterion, the attempt count, the
|
|
17
|
+
stage, the observed result, the owner binding and the record's position in
|
|
18
|
+
the phase's ordered plan (D2.2). The stages are closed (D2.3)::
|
|
19
|
+
|
|
20
|
+
intended -> attempted -> observed
|
|
21
|
+
\\ \\----> conflict
|
|
22
|
+
\\-----------------> observed | conflict
|
|
23
|
+
|
|
24
|
+
Only ``observed`` lets the consuming phase advance. A record lives for one
|
|
25
|
+
phase entry: the save that commits the phase drops the records, the entry
|
|
26
|
+
observation and the completion context together (D2.4).
|
|
27
|
+
|
|
28
|
+
The **entry observation** (:class:`EntryObservation`, D4.4) is what the
|
|
29
|
+
controller read before the phase's first launch, in a kind-neutral shape:
|
|
30
|
+
remote refs, the base revision, the marker-bearing objects of the
|
|
31
|
+
phase's identities, and the open PR headed at a ref, when the phase may
|
|
32
|
+
adopt one. It is the launch fence: a difference after the agent
|
|
33
|
+
returned is something published during the run (ADR 0004 §2.9).
|
|
34
|
+
|
|
35
|
+
The **completion context** (D4.6) is the validated result data the phase
|
|
36
|
+
consumes once its effects are observed, stored in the form its state field
|
|
37
|
+
takes. There is one closed schema per publishing phase; recovery completes
|
|
38
|
+
from it and never from a rendered payload or the run log.
|
|
39
|
+
|
|
40
|
+
Every value here is validated on load, and a value that fails is corruption
|
|
41
|
+
(:class:`~autoforge.errors.StateError`): never defaulted, clipped or
|
|
42
|
+
repaired from GitHub.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
from __future__ import annotations
|
|
46
|
+
|
|
47
|
+
import hashlib
|
|
48
|
+
import json
|
|
49
|
+
import re
|
|
50
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
51
|
+
from dataclasses import dataclass, field, replace
|
|
52
|
+
from enum import StrEnum
|
|
53
|
+
from typing import Any, NoReturn
|
|
54
|
+
|
|
55
|
+
from .claims import (
|
|
56
|
+
ACCEPTANCE,
|
|
57
|
+
FOLLOW_UP,
|
|
58
|
+
IMPLEMENTATION,
|
|
59
|
+
OPERATOR_COMMENT,
|
|
60
|
+
PROGRESS,
|
|
61
|
+
REVIEW,
|
|
62
|
+
MarkerKind,
|
|
63
|
+
render_acceptance_marker,
|
|
64
|
+
render_follow_up_marker,
|
|
65
|
+
render_implementation_marker,
|
|
66
|
+
render_operator_comment_marker,
|
|
67
|
+
render_progress_marker,
|
|
68
|
+
render_review_marker,
|
|
69
|
+
scan,
|
|
70
|
+
)
|
|
71
|
+
from .errors import ConfigurationError, ControlResultValidationError, StateError
|
|
72
|
+
from .read_context import MAX_LISTED_ISSUES
|
|
73
|
+
from .redaction import MAX_GROWTH_FACTOR, redact
|
|
74
|
+
from .replan_txn import MARKER_RE as REPLAN_MARKER_RE
|
|
75
|
+
from .replan_txn import TRANSACTION_ID_RE, scan_replan_markers
|
|
76
|
+
from .result_parser import (
|
|
77
|
+
FINDING_ID_RE,
|
|
78
|
+
FIX_RESOLUTIONS,
|
|
79
|
+
MAX_ACCEPTANCE_ACTIONS_PER_FIX,
|
|
80
|
+
MAX_FINDING_ID_CHARS,
|
|
81
|
+
MAX_FINDINGS_PER_REVIEW,
|
|
82
|
+
MAX_FIX_RATIONALE_CHARS,
|
|
83
|
+
MAX_OPERATOR_ACTIONS_PER_FIX,
|
|
84
|
+
MAX_RESOLUTIONS_PER_FIX,
|
|
85
|
+
MAX_ROADMAP_SECTION_CHARS,
|
|
86
|
+
MAX_URL_CHARS,
|
|
87
|
+
REVIEW_PROSE_SECTIONS,
|
|
88
|
+
Finding,
|
|
89
|
+
ReviewResult,
|
|
90
|
+
check_published_finding,
|
|
91
|
+
markdown_code_span,
|
|
92
|
+
operator_markup_problem,
|
|
93
|
+
published_markdown_problem,
|
|
94
|
+
published_payload_problem,
|
|
95
|
+
validate_acceptance_body,
|
|
96
|
+
validate_follow_up_body,
|
|
97
|
+
validate_follow_up_title,
|
|
98
|
+
validate_operator_comment_body,
|
|
99
|
+
validate_pr_body,
|
|
100
|
+
validate_pr_title,
|
|
101
|
+
validate_progress_text,
|
|
102
|
+
validate_review_section,
|
|
103
|
+
validate_roadmap_section,
|
|
104
|
+
)
|
|
105
|
+
from .transitions import Phase
|
|
106
|
+
from .validation import parse_comment_url, parse_issue_url, parse_pr_url
|
|
107
|
+
|
|
108
|
+
# -- bounds ------------------------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
# D4.3: at most two issues per effect, the ``MAX_CLOSE_ATTEMPTS`` precedent.
|
|
111
|
+
# A rebase (D5.5) consumes an attempt too.
|
|
112
|
+
MAX_EFFECT_ATTEMPTS = 2
|
|
113
|
+
|
|
114
|
+
# GitHub's limit for an issue, PR or comment body, and for a title, re-checked
|
|
115
|
+
# against the live API by #160 (D2.4). ``github.py`` refuses a write over
|
|
116
|
+
# either before sending it; the record refuses to hold one.
|
|
117
|
+
MAX_BODY_CHARS = 65536
|
|
118
|
+
MAX_TITLE_CHARS = 256
|
|
119
|
+
|
|
120
|
+
# D2.4: the largest plan is FIX: one push, one follow-up effect per deferred
|
|
121
|
+
# finding, and (#243) up to ``MAX_OPERATOR_ACTIONS_PER_FIX`` operator comments
|
|
122
|
+
# for one more finding, so 49 K5/K6, 4 K10 and one K1 (54). A K9 plan (one
|
|
123
|
+
# acceptance comment for one finding) is smaller.
|
|
124
|
+
MAX_EFFECTS_PER_PLAN = (MAX_FINDINGS_PER_REVIEW - 1) + MAX_OPERATOR_ACTIONS_PER_FIX + 1
|
|
125
|
+
|
|
126
|
+
# D2.4's total bound, over every payload string of the plan and the
|
|
127
|
+
# completion context counted at its stored bound. ``json.dumps`` writes the
|
|
128
|
+
# state with ``ensure_ascii``, at most 12 bytes per character (a surrogate
|
|
129
|
+
# pair of ``\\uXXXX`` escapes), so the effect part of the state file stays
|
|
130
|
+
# under 12 MiB in the worst case, far below ``MAX_STATE_FILE_BYTES`` (64 MiB).
|
|
131
|
+
MAX_EFFECT_STATE_CHARS = 1 << 20
|
|
132
|
+
|
|
133
|
+
# The fixed separator of a body append (D5.5): base, separator, block.
|
|
134
|
+
APPEND_SEPARATOR = "\n\n"
|
|
135
|
+
|
|
136
|
+
# A conflict reason names objects and a rule; it is controller prose, redacted
|
|
137
|
+
# and clipped at the writer and bounded on load.
|
|
138
|
+
MAX_CONFLICT_REASON_CHARS = 4096
|
|
139
|
+
|
|
140
|
+
# The entry observation: a phase reads at most a few refs, and one object per
|
|
141
|
+
# identity (an open finding's follow-up, an earlier round's deferral, a PR).
|
|
142
|
+
MAX_OBSERVED_REFS = 4
|
|
143
|
+
MAX_OBSERVED_OBJECTS = 4 * MAX_FINDINGS_PER_REVIEW
|
|
144
|
+
MAX_OBSERVED_KEY_CHARS = 2048
|
|
145
|
+
|
|
146
|
+
_SHA_RE = re.compile(r"^[0-9a-f]{40}\Z")
|
|
147
|
+
_DIGEST_RE = re.compile(r"^[0-9a-f]{64}\Z")
|
|
148
|
+
_REPOSITORY_RE = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+\Z")
|
|
149
|
+
_MAX_REPOSITORY_CHARS = 200
|
|
150
|
+
_MAX_REF_CHARS = 255
|
|
151
|
+
# What ``git check-ref-format`` refuses in a component, plus whitespace: the
|
|
152
|
+
# record holds a ref the controller derived, so anything outside this is
|
|
153
|
+
# corruption, not an exotic but valid branch name.
|
|
154
|
+
_REF_FORBIDDEN_RE = re.compile(r"[\x00-\x20\x7f~^:?*\[\\]|\.\.|@\{|//|/\.|\.lock(?:/|\Z)")
|
|
155
|
+
_TEXT_CONTROL_RE = re.compile(r"[\x00\x7f]")
|
|
156
|
+
_RATIONALE_CONTROL_RE = re.compile(r"(?![\n\t])[\x00-\x1f\x7f-\x9f\u2028\u2029]")
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def sha256_text(text: str) -> str:
|
|
160
|
+
"""The SHA-256 hex digest of ``text`` as UTF-8 (a body base, an outside-markers body)."""
|
|
161
|
+
return hashlib.sha256(text.encode("utf-8")).hexdigest()
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def compose_append(base: str, block: str) -> str:
|
|
165
|
+
"""The payload of a body append: the base, the fixed separator, the block (D5.5)."""
|
|
166
|
+
return f"{base}{APPEND_SEPARATOR}{block}"
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def progress_comment_body(progress: str, marker: str) -> str:
|
|
170
|
+
"""K8's payload: the validated progress text, a blank line, the marker (§2.5)."""
|
|
171
|
+
return f"{progress}{APPEND_SEPARATOR}{marker}"
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
# The prose sections of a review comment, in rendered order, under their
|
|
175
|
+
# fixed headings (#162).
|
|
176
|
+
REVIEW_SECTION_HEADINGS: tuple[tuple[str, str], ...] = (
|
|
177
|
+
("spec", "Spec"),
|
|
178
|
+
("standards", "Standards"),
|
|
179
|
+
("assessment", "Assessment"),
|
|
180
|
+
("observations", "Observations"),
|
|
181
|
+
("verification", "Verification"),
|
|
182
|
+
("summary", "Summary"),
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _review_comment_parts(
|
|
187
|
+
result: ReviewResult, head: str, base: str, merge_base: str
|
|
188
|
+
) -> tuple[str, str]:
|
|
189
|
+
"""The round's review comment up to its marker, and the marker (#162)."""
|
|
190
|
+
lines = [
|
|
191
|
+
f"# AI Code Review — Round {result.round}",
|
|
192
|
+
"",
|
|
193
|
+
f"Reviewed HEAD: {markdown_code_span(head)} against base {markdown_code_span(base)} "
|
|
194
|
+
f"(merge base {markdown_code_span(merge_base)})",
|
|
195
|
+
"",
|
|
196
|
+
"## Findings",
|
|
197
|
+
]
|
|
198
|
+
for finding in result.findings:
|
|
199
|
+
heading = f"### {finding.id} [{finding.classification}]"
|
|
200
|
+
if finding.location:
|
|
201
|
+
heading += f" {markdown_code_span(finding.location)}"
|
|
202
|
+
if finding.title:
|
|
203
|
+
heading += f" — {finding.title}"
|
|
204
|
+
# Every field is a block of its own, at column 0 between blank lines,
|
|
205
|
+
# so GitHub reads it in the context it was judged in alone: a title's
|
|
206
|
+
# unpaired backtick ends with its heading line, and a resolution's
|
|
207
|
+
# fence opens where the resolution starts.
|
|
208
|
+
lines.extend(["", heading, "", "Required resolution:", "", finding.required_resolution])
|
|
209
|
+
if not result.findings:
|
|
210
|
+
lines.extend(["", "None."])
|
|
211
|
+
for key, heading in REVIEW_SECTION_HEADINGS:
|
|
212
|
+
lines.extend(["", f"## {heading}", "", result.sections[key]])
|
|
213
|
+
verdict = "YES" if result.needs_fix_round else "NO"
|
|
214
|
+
lines.extend(["", f"Needs another fix round: {verdict}"])
|
|
215
|
+
marker = render_review_marker(
|
|
216
|
+
result.round,
|
|
217
|
+
head,
|
|
218
|
+
base,
|
|
219
|
+
merge_base,
|
|
220
|
+
result.needs_fix_round,
|
|
221
|
+
[finding.id for finding in result.findings],
|
|
222
|
+
)
|
|
223
|
+
return "\n".join(lines), marker
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def review_comment_body(result: ReviewResult, head: str, base: str, merge_base: str) -> str:
|
|
227
|
+
"""K4's payload (#162): the round's review comment, rendered from the validated result.
|
|
228
|
+
|
|
229
|
+
The heading, the binding line (``head``, ``base`` and ``merge_base`` are
|
|
230
|
+
the controller's binding of the round, never the reviewer's), the
|
|
231
|
+
findings' layout, the needs-fix line and the marker are the controller's
|
|
232
|
+
rendering; each finding's title, location and required resolution, and
|
|
233
|
+
the reviewer's prose sections under their fixed headings, are blocks of
|
|
234
|
+
their own. The marker's ``needs_fix_round`` and ``finding_ids`` and the
|
|
235
|
+
needs-fix line come from the same result, so they cannot disagree with
|
|
236
|
+
it or with each other.
|
|
237
|
+
"""
|
|
238
|
+
text, marker = _review_comment_parts(result, head, base, merge_base)
|
|
239
|
+
return f"{text}{APPEND_SEPARATOR}{marker}"
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def review_comment_problem(
|
|
243
|
+
result: ReviewResult, head: str, base: str, merge_base: str
|
|
244
|
+
) -> str | None:
|
|
245
|
+
"""Why the comment :func:`review_comment_body` renders may not be published, or ``None``.
|
|
246
|
+
|
|
247
|
+
Each field passed the published-content policy alone
|
|
248
|
+
(``check_published_finding``, ``validate_review_section``); the comment
|
|
249
|
+
is judged as a whole too, because fields join (#162). The credential rule
|
|
250
|
+
applies to the whole body (D8.3), and the mention rule to everything
|
|
251
|
+
before the controller's marker (D8.2): one field's unclosed fence or raw
|
|
252
|
+
HTML can make a later field's code text. Judged before the result is
|
|
253
|
+
accepted and again when a saved plan is loaded, so recovery never posts
|
|
254
|
+
a comment the result path would have refused.
|
|
255
|
+
"""
|
|
256
|
+
text, marker = _review_comment_parts(result, head, base, merge_base)
|
|
257
|
+
return published_payload_problem(
|
|
258
|
+
"review comment", f"{text}{APPEND_SEPARATOR}{marker}"
|
|
259
|
+
) or published_markdown_problem("review comment", text)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def implementation_closing_block(issue_url: str) -> str:
|
|
263
|
+
"""What K2's body ends with and K3 appends: ``Closes #n``, a blank line, the marker (#161)."""
|
|
264
|
+
number = parse_issue_url(issue_url).number
|
|
265
|
+
return f"Closes #{number}{APPEND_SEPARATOR}{render_implementation_marker(issue_url)}"
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def replacement_pr_body(text: str, issue_url: str, transaction_marker: str) -> str:
|
|
269
|
+
"""K7's body: the agent's text, ``Closes #n`` and the marker, then the transaction marker.
|
|
270
|
+
|
|
271
|
+
The controller renders everything after the agent's text (#164): the
|
|
272
|
+
closing block of the issue (:func:`implementation_closing_block`) and
|
|
273
|
+
the transaction's marker with the validated counts.
|
|
274
|
+
"""
|
|
275
|
+
return compose_append(
|
|
276
|
+
compose_append(text, implementation_closing_block(issue_url)), transaction_marker
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def follow_up_reference(pr_url: str, finding_id: str) -> str:
|
|
281
|
+
"""The controller's line in a follow-up issue it creates: the PR and finding (#163)."""
|
|
282
|
+
return f"Deferred from finding `{finding_id}` of {parse_pr_url(pr_url).canonical}."
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def follow_up_issue_body(text: str, pr_url: str, finding_id: str) -> str:
|
|
286
|
+
"""K5's body: the agent's text, the controller's reference, then the finding's marker (#163)."""
|
|
287
|
+
return (
|
|
288
|
+
f"{text}{APPEND_SEPARATOR}{follow_up_reference(pr_url, finding_id)}"
|
|
289
|
+
f"{APPEND_SEPARATOR}{render_follow_up_marker(pr_url, finding_id)}"
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def acceptance_comment_header(issue_url: str, pr_url: str, round_: int, finding_id: str) -> str:
|
|
294
|
+
"""The controller's line that opens an acceptance comment (#211).
|
|
295
|
+
|
|
296
|
+
It names the issue, the PR, the review round and the finding, and says
|
|
297
|
+
whose the text below it is: the FIX agent's, published by the
|
|
298
|
+
controller (or posted by the operator) as the evidence the finding asks
|
|
299
|
+
for, never as the controller's own statement.
|
|
300
|
+
"""
|
|
301
|
+
return (
|
|
302
|
+
f"**AutoForge acceptance evidence** for finding `{finding_id}` of review round "
|
|
303
|
+
f"{round_} of {parse_pr_url(pr_url).canonical}, on "
|
|
304
|
+
f"{parse_issue_url(issue_url).canonical}. The text below is the FIX agent's."
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def acceptance_comment_body(
|
|
309
|
+
text: str, issue_url: str, pr_url: str, round_: int, finding_id: str
|
|
310
|
+
) -> str:
|
|
311
|
+
"""K9's payload: the controller's header, the agent's text, then the finding's marker (#211)."""
|
|
312
|
+
return (
|
|
313
|
+
f"{acceptance_comment_header(issue_url, pr_url, round_, finding_id)}"
|
|
314
|
+
f"{APPEND_SEPARATOR}{text}{APPEND_SEPARATOR}"
|
|
315
|
+
f"{render_acceptance_marker(issue_url, pr_url, round_, finding_id)}"
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def acceptance_comment_problem(body: str, finding_id: str) -> str | None:
|
|
320
|
+
"""Why the rendered acceptance comment of ``finding_id`` may not be published, or ``None``.
|
|
321
|
+
|
|
322
|
+
The parser judged the agent's text alone; this judges the comment the
|
|
323
|
+
controller composes from it (ADR 0004 D8.2, D8.3, D8.6): the credential
|
|
324
|
+
rule on the whole payload, the mention rule on everything before the
|
|
325
|
+
marker, and GitHub's comment limit. Names the payload, never the text.
|
|
326
|
+
"""
|
|
327
|
+
subject = f"acceptance comment of {finding_id}"
|
|
328
|
+
if len(body) > MAX_BODY_CHARS:
|
|
329
|
+
return (
|
|
330
|
+
f"the rendered {subject!r} is {len(body)} characters, over GitHub's comment limit "
|
|
331
|
+
f"of {MAX_BODY_CHARS}; shorten the acceptance_action body and re-emit the "
|
|
332
|
+
"CONTROL_RESULT"
|
|
333
|
+
)
|
|
334
|
+
marker_at = body.rfind(APPEND_SEPARATOR)
|
|
335
|
+
return published_payload_problem(subject, body) or published_markdown_problem(
|
|
336
|
+
subject, body[:marker_at] if marker_at >= 0 else body
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def operator_comment_header(
|
|
341
|
+
issue_url: str, pr_url: str, round_: int, finding_id: str, target_url: str
|
|
342
|
+
) -> str:
|
|
343
|
+
"""The controller's line that opens an operator comment (K10, #243).
|
|
344
|
+
|
|
345
|
+
It names the run's issue, the PR, the review round, the finding and the
|
|
346
|
+
issue the comment is for, and says whose the text below it is: the FIX
|
|
347
|
+
agent's, prepared for the operator to post, and verified (never posted)
|
|
348
|
+
by the controller.
|
|
349
|
+
"""
|
|
350
|
+
return (
|
|
351
|
+
f"**AutoForge operator action** for finding `{finding_id}` of review round {round_} "
|
|
352
|
+
f"of {parse_pr_url(pr_url).canonical} ({parse_issue_url(issue_url).canonical}), on "
|
|
353
|
+
f"{parse_issue_url(target_url).canonical}. The text below was prepared by the FIX "
|
|
354
|
+
"agent for the operator to post; the controller only verifies it."
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def operator_comment_body(
|
|
359
|
+
text: str, issue_url: str, pr_url: str, round_: int, finding_id: str, target_url: str
|
|
360
|
+
) -> str:
|
|
361
|
+
"""K10's payload: the controller's header, the agent's text, then the action's marker."""
|
|
362
|
+
return (
|
|
363
|
+
f"{operator_comment_header(issue_url, pr_url, round_, finding_id, target_url)}"
|
|
364
|
+
f"{APPEND_SEPARATOR}{text}{APPEND_SEPARATOR}"
|
|
365
|
+
f"{render_operator_comment_marker(issue_url, pr_url, round_, finding_id, target_url)}"
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def operator_comment_problem(body: str, finding_id: str, target_url: str) -> str | None:
|
|
370
|
+
"""Why the rendered operator comment may not be offered for posting, or ``None`` (#243).
|
|
371
|
+
|
|
372
|
+
The comment as a whole (ADR 0004 D8.2, D8.3, D8.6): the credential rule
|
|
373
|
+
on the whole payload, the mention, image and raw-HTML rules on
|
|
374
|
+
everything before the marker, and GitHub's comment limit. Names the
|
|
375
|
+
payload, never the text.
|
|
376
|
+
"""
|
|
377
|
+
subject = f"operator comment of {finding_id} for {target_url}"
|
|
378
|
+
if len(body) > MAX_BODY_CHARS:
|
|
379
|
+
return (
|
|
380
|
+
f"the rendered {subject!r} is {len(body)} characters, over GitHub's comment limit "
|
|
381
|
+
f"of {MAX_BODY_CHARS}; shorten the operator_acceptance body and re-emit the "
|
|
382
|
+
"CONTROL_RESULT"
|
|
383
|
+
)
|
|
384
|
+
marker_at = body.rfind(APPEND_SEPARATOR)
|
|
385
|
+
markdown = body[:marker_at] if marker_at >= 0 else body
|
|
386
|
+
return (
|
|
387
|
+
published_payload_problem(subject, body)
|
|
388
|
+
or published_markdown_problem(subject, markdown)
|
|
389
|
+
or operator_markup_problem(f"rendered {subject}", markdown)
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def normalized_comment_text(text: str) -> str:
|
|
394
|
+
"""``text`` with CRLF/CR line endings as LF and trailing whitespace dropped per line.
|
|
395
|
+
|
|
396
|
+
Never a comparison of record (K10 compares exact bytes): it only lets a
|
|
397
|
+
conflict reason say that two bodies differ in nothing else (#243).
|
|
398
|
+
"""
|
|
399
|
+
lines = text.replace("\r\n", "\n").replace("\r", "\n").split("\n")
|
|
400
|
+
return "\n".join(line.rstrip() for line in lines).rstrip()
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
# -- closed sets -------------------------------------------------------------------
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
class EffectKind(StrEnum):
|
|
407
|
+
"""The closed effect-kind set (D5.1). Adding one is a protocol bump."""
|
|
408
|
+
|
|
409
|
+
PUSH = "push" # K1
|
|
410
|
+
IMPLEMENTATION_PR = "implementation_pr" # K2
|
|
411
|
+
ADOPT_PR = "adopt_pr" # K3
|
|
412
|
+
REVIEW_COMMENT = "review_comment" # K4
|
|
413
|
+
FOLLOW_UP_ISSUE = "follow_up_issue" # K5
|
|
414
|
+
FOLLOW_UP_APPEND = "follow_up_append" # K6
|
|
415
|
+
REPLACEMENT_PR = "replacement_pr" # K7
|
|
416
|
+
PROGRESS_COMMENT = "progress_comment" # K8
|
|
417
|
+
ACCEPTANCE_COMMENT = "acceptance_comment" # K9 (#211)
|
|
418
|
+
OPERATOR_COMMENT = "operator_comment" # K10 (#243): operator-performed, never issued
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
class Stage(StrEnum):
|
|
422
|
+
"""D2.3: the closed stage set of a record."""
|
|
423
|
+
|
|
424
|
+
INTENDED = "intended" # validated, persisted, never issued
|
|
425
|
+
ATTEMPTED = "attempted" # the count is persisted before each issue
|
|
426
|
+
OBSERVED = "observed" # the read-back matched; terminal
|
|
427
|
+
CONFLICT = "conflict" # BLOCKED, naming the object
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
# The phases whose external writes are effects, and which kinds each may plan.
|
|
431
|
+
PUBLISHING_PHASES = frozenset(
|
|
432
|
+
{
|
|
433
|
+
Phase.ANALYZE_EXECUTE,
|
|
434
|
+
Phase.REVIEW,
|
|
435
|
+
Phase.FIX,
|
|
436
|
+
Phase.REPLAN_REEXECUTE,
|
|
437
|
+
Phase.UPDATE_EPIC,
|
|
438
|
+
}
|
|
439
|
+
)
|
|
440
|
+
KIND_PHASES: Mapping[EffectKind, frozenset[Phase]] = {
|
|
441
|
+
EffectKind.PUSH: frozenset({Phase.ANALYZE_EXECUTE, Phase.FIX, Phase.REPLAN_REEXECUTE}),
|
|
442
|
+
EffectKind.IMPLEMENTATION_PR: frozenset({Phase.ANALYZE_EXECUTE}),
|
|
443
|
+
EffectKind.ADOPT_PR: frozenset({Phase.ANALYZE_EXECUTE}),
|
|
444
|
+
EffectKind.REVIEW_COMMENT: frozenset({Phase.REVIEW}),
|
|
445
|
+
EffectKind.FOLLOW_UP_ISSUE: frozenset({Phase.FIX}),
|
|
446
|
+
EffectKind.FOLLOW_UP_APPEND: frozenset({Phase.FIX}),
|
|
447
|
+
EffectKind.REPLACEMENT_PR: frozenset({Phase.REPLAN_REEXECUTE}),
|
|
448
|
+
EffectKind.PROGRESS_COMMENT: frozenset({Phase.UPDATE_EPIC}),
|
|
449
|
+
EffectKind.ACCEPTANCE_COMMENT: frozenset({Phase.FIX}),
|
|
450
|
+
EffectKind.OPERATOR_COMMENT: frozenset({Phase.FIX}),
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
# D13.3: the contract a launch ran under, recorded in every pre-launch save of
|
|
454
|
+
# a publishing phase. A phase is controller-published once the running version
|
|
455
|
+
# performs its writes itself; #161-#164 added theirs as they landed, so every
|
|
456
|
+
# publishing phase now is.
|
|
457
|
+
LABEL_NONE = ""
|
|
458
|
+
LABEL_AGENT_PUBLISHES = "agent_publishes"
|
|
459
|
+
LABEL_CONTROLLER_PUBLISHES = "controller_publishes"
|
|
460
|
+
LAUNCH_LABELS = frozenset({LABEL_NONE, LABEL_AGENT_PUBLISHES, LABEL_CONTROLLER_PUBLISHES})
|
|
461
|
+
CONTROLLER_PUBLISHED_PHASES = frozenset(
|
|
462
|
+
{Phase.ANALYZE_EXECUTE, Phase.REVIEW, Phase.FIX, Phase.REPLAN_REEXECUTE, Phase.UPDATE_EPIC}
|
|
463
|
+
)
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def launch_label_for(phase: Phase) -> str:
|
|
467
|
+
"""The label the pre-launch save of ``phase`` records (D13.3)."""
|
|
468
|
+
if phase not in PUBLISHING_PHASES:
|
|
469
|
+
return LABEL_NONE
|
|
470
|
+
if phase in CONTROLLER_PUBLISHED_PHASES:
|
|
471
|
+
return LABEL_CONTROLLER_PUBLISHES
|
|
472
|
+
return LABEL_AGENT_PUBLISHES
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def is_legacy_reentry(phase: Phase, attempt: int, label: str) -> bool:
|
|
476
|
+
"""D13.3: a resumed launch ran under the agent-publishing contract of a phase
|
|
477
|
+
this version publishes itself, so its objects are pre-existing, once."""
|
|
478
|
+
return attempt >= 1 and label == LABEL_AGENT_PUBLISHES and phase in CONTROLLER_PUBLISHED_PHASES
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
# -- field checks ------------------------------------------------------------------
|
|
482
|
+
|
|
483
|
+
Checker = Callable[[object, str], None]
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def _fail(what: str, message: str) -> NoReturn:
|
|
487
|
+
raise StateError(f"{what} {message}")
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def _exact(raw: object, keys: Sequence[str], what: str, optional: Sequence[str] = ()) -> dict:
|
|
491
|
+
if not isinstance(raw, dict):
|
|
492
|
+
_fail(what, f"must be an object, got {type(raw).__name__}")
|
|
493
|
+
unknown = sorted(str(k) for k in raw if k not in keys and k not in optional)
|
|
494
|
+
if unknown:
|
|
495
|
+
_fail(what, f"has unknown key(s) {unknown}")
|
|
496
|
+
missing = [k for k in keys if k not in raw]
|
|
497
|
+
if missing:
|
|
498
|
+
_fail(what, f"is missing key(s) {missing}")
|
|
499
|
+
return raw
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def _str(value: object, what: str, max_chars: int) -> str:
|
|
503
|
+
if not isinstance(value, str):
|
|
504
|
+
_fail(what, f"must be a string, got {type(value).__name__}")
|
|
505
|
+
if len(value) > max_chars:
|
|
506
|
+
_fail(what, f"is {len(value)} characters, over its bound of {max_chars}")
|
|
507
|
+
return value
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
def _int(value: object, what: str, minimum: int = 0, maximum: int | None = None) -> int:
|
|
511
|
+
if not isinstance(value, int) or isinstance(value, bool):
|
|
512
|
+
_fail(what, f"must be an integer, got {type(value).__name__}")
|
|
513
|
+
if value < minimum or (maximum is not None and value > maximum):
|
|
514
|
+
bound = f">= {minimum}" if maximum is None else f"in [{minimum}, {maximum}]"
|
|
515
|
+
_fail(what, f"must be {bound}, got {value}")
|
|
516
|
+
return value
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def _bool(value: object, what: str) -> bool:
|
|
520
|
+
if not isinstance(value, bool):
|
|
521
|
+
_fail(what, f"must be a boolean, got {type(value).__name__}")
|
|
522
|
+
return value
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def _sha(value: object, what: str) -> None:
|
|
526
|
+
if not isinstance(value, str) or not _SHA_RE.match(value):
|
|
527
|
+
_fail(what, "must be a full 40-character lowercase commit SHA")
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
def _opt_sha(value: object, what: str) -> None:
|
|
531
|
+
if value is not None:
|
|
532
|
+
_sha(value, what)
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
def _digest(value: object, what: str) -> None:
|
|
536
|
+
if not isinstance(value, str) or not _DIGEST_RE.match(value):
|
|
537
|
+
_fail(what, "must be a 64-character lowercase SHA-256 hex digest")
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def _opt_digest(value: object, what: str) -> None:
|
|
541
|
+
if value is not None:
|
|
542
|
+
_digest(value, what)
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def _repository(value: object, what: str) -> None:
|
|
546
|
+
text = _str(value, what, _MAX_REPOSITORY_CHARS)
|
|
547
|
+
if not _REPOSITORY_RE.match(text):
|
|
548
|
+
_fail(what, "must be an <owner>/<repo> repository name")
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
def _branch(value: object, what: str) -> None:
|
|
552
|
+
text = _str(value, what, _MAX_REF_CHARS)
|
|
553
|
+
if (
|
|
554
|
+
not text
|
|
555
|
+
or text.startswith(("-", "/", "refs/"))
|
|
556
|
+
or text.endswith(("/", "."))
|
|
557
|
+
or _REF_FORBIDDEN_RE.search(text)
|
|
558
|
+
):
|
|
559
|
+
_fail(what, "must be a plain branch name")
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
def _ref(value: object, what: str) -> None:
|
|
563
|
+
text = _str(value, what, _MAX_REF_CHARS)
|
|
564
|
+
if not text.startswith("refs/heads/"):
|
|
565
|
+
_fail(what, "must be a full refs/heads/<branch> ref")
|
|
566
|
+
_branch(text.removeprefix("refs/heads/"), what)
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
def _canonical(value: object, what: str, parse: Callable[[str], object]) -> str:
|
|
570
|
+
text = _str(value, what, MAX_URL_CHARS)
|
|
571
|
+
try:
|
|
572
|
+
ref = parse(text)
|
|
573
|
+
except ConfigurationError:
|
|
574
|
+
_fail(what, "is not a GitHub URL of the expected kind")
|
|
575
|
+
if getattr(ref, "canonical", None) != text:
|
|
576
|
+
_fail(what, "must be stored in its canonical form")
|
|
577
|
+
return text
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
def _issue_url(value: object, what: str) -> None:
|
|
581
|
+
_canonical(value, what, parse_issue_url)
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
def _opt_issue_url(value: object, what: str) -> None:
|
|
585
|
+
if value is not None:
|
|
586
|
+
_issue_url(value, what)
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
def _pr_url(value: object, what: str) -> None:
|
|
590
|
+
_canonical(value, what, parse_pr_url)
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
def _comment_url(value: object, what: str) -> None:
|
|
594
|
+
_canonical(value, what, parse_comment_url)
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
def _object_url(value: object, what: str) -> None:
|
|
598
|
+
"""An issue, PR or comment URL: what an observed marker-bearing object is."""
|
|
599
|
+
text = _str(value, what, MAX_URL_CHARS)
|
|
600
|
+
parse: Callable[[str], object] = parse_comment_url if "#" in text else _parse_issue_or_pr
|
|
601
|
+
_canonical(text, what, parse)
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
def _parse_issue_or_pr(url: str) -> object:
|
|
605
|
+
try:
|
|
606
|
+
return parse_issue_url(url)
|
|
607
|
+
except ConfigurationError:
|
|
608
|
+
return parse_pr_url(url)
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
def _published_text(max_chars: int) -> Checker:
|
|
612
|
+
"""Persisted payload text: bounded and redaction-invariant (D2.4, D8.3)."""
|
|
613
|
+
|
|
614
|
+
def check(value: object, what: str) -> None:
|
|
615
|
+
text = _str(value, what, max_chars)
|
|
616
|
+
if _TEXT_CONTROL_RE.search(text):
|
|
617
|
+
_fail(what, "carries a NUL or DEL character")
|
|
618
|
+
if redact(text) != text:
|
|
619
|
+
# Never quoted: the text is what the check refused to keep.
|
|
620
|
+
_fail(what, "is not redaction-invariant; a payload never holds a credential")
|
|
621
|
+
|
|
622
|
+
return check
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
def _title(value: object, what: str) -> None:
|
|
626
|
+
_published_text(MAX_TITLE_CHARS)(value, what)
|
|
627
|
+
assert isinstance(value, str)
|
|
628
|
+
if not value.strip() or "\n" in value or "\r" in value:
|
|
629
|
+
_fail(what, "must be one non-empty line")
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
def _marker_of(kind: MarkerKind) -> Checker:
|
|
633
|
+
def check(value: object, what: str) -> None:
|
|
634
|
+
text = _str(value, what, MAX_OBSERVED_KEY_CHARS)
|
|
635
|
+
found = scan(kind, text)
|
|
636
|
+
if found.defects or len(found.claims) != 1 or not kind.pattern.fullmatch(text):
|
|
637
|
+
_fail(what, f"must be exactly one well-formed {kind.name} marker")
|
|
638
|
+
|
|
639
|
+
return check
|
|
640
|
+
|
|
641
|
+
|
|
642
|
+
def _replan_marker(value: object, what: str) -> None:
|
|
643
|
+
text = _str(value, what, MAX_OBSERVED_KEY_CHARS)
|
|
644
|
+
found = scan_replan_markers(text)
|
|
645
|
+
if found.malformed or len(found.attestations) != 1 or not REPLAN_MARKER_RE.fullmatch(text):
|
|
646
|
+
_fail(what, "must be exactly one well-formed replan transaction marker")
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
def _marker_list(value: object, what: str) -> None:
|
|
650
|
+
if not isinstance(value, list) or not value:
|
|
651
|
+
_fail(what, "must be a non-empty list of ai-follow-up markers")
|
|
652
|
+
if len(value) > MAX_FINDINGS_PER_REVIEW:
|
|
653
|
+
_fail(what, f"holds {len(value)} markers, over {MAX_FINDINGS_PER_REVIEW}")
|
|
654
|
+
for i, item in enumerate(value):
|
|
655
|
+
_marker_of(FOLLOW_UP)(item, f"{what}[{i}]")
|
|
656
|
+
if len(set(value)) != len(value):
|
|
657
|
+
_fail(what, "repeats a marker")
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
def _absent(value: object, what: str) -> None:
|
|
661
|
+
if value is not True:
|
|
662
|
+
_fail(what, "must be true: a create's precondition is that no object has the identity")
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
# K9's authorization (#211): who allowed the one write. ``operator`` is an
|
|
666
|
+
# ``unblock --approve-action`` of the record's action id (none yet while the
|
|
667
|
+
# id is ""); ``policy`` is ``safety.acceptance_actions: auto``, which
|
|
668
|
+
# records the id when the plan is saved. Persisted with the plan, so a
|
|
669
|
+
# later configuration change never widens it.
|
|
670
|
+
AUTHORIZATION_OPERATOR = "operator"
|
|
671
|
+
AUTHORIZATION_POLICY = "policy"
|
|
672
|
+
AUTHORIZATIONS = (AUTHORIZATION_OPERATOR, AUTHORIZATION_POLICY)
|
|
673
|
+
ACTION_ID_RE = re.compile(r"^A-[0-9a-f]{16}\Z")
|
|
674
|
+
|
|
675
|
+
|
|
676
|
+
def _authorization(value: object, what: str) -> None:
|
|
677
|
+
if value not in AUTHORIZATIONS:
|
|
678
|
+
_fail(what, f"must be one of {AUTHORIZATIONS}")
|
|
679
|
+
|
|
680
|
+
|
|
681
|
+
def _approved_action_id(value: object, what: str) -> None:
|
|
682
|
+
text = _str(value, what, 32)
|
|
683
|
+
if text and not ACTION_ID_RE.match(text):
|
|
684
|
+
_fail(what, "must be empty or an action id of the form A-<16 hex>")
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
# K10 (#243): the action id prefix, distinct from K9's, so no K10 id ever
|
|
688
|
+
# matches ``--approve-action``; why a target was allowed; and the evidence
|
|
689
|
+
# class an observation records.
|
|
690
|
+
OPERATOR_ACTION_ID_RE = re.compile(r"^X-[0-9a-f]{16}\Z")
|
|
691
|
+
ALLOWED_BY_ISSUE_BODY = "issue_body"
|
|
692
|
+
ALLOWED_BY_EPIC = "epic"
|
|
693
|
+
ALLOWED_BY = (ALLOWED_BY_ISSUE_BODY, ALLOWED_BY_EPIC)
|
|
694
|
+
EVIDENCE_EXACT = "exact"
|
|
695
|
+
EVIDENCE_BOUND = "bound"
|
|
696
|
+
EVIDENCE_CLASSES = (EVIDENCE_EXACT, EVIDENCE_BOUND)
|
|
697
|
+
_BINDING_KEYS = ("comment_url", "comment_id", "body_sha256")
|
|
698
|
+
|
|
699
|
+
|
|
700
|
+
def _allowed_by(value: object, what: str) -> None:
|
|
701
|
+
if (
|
|
702
|
+
not isinstance(value, list)
|
|
703
|
+
or not value
|
|
704
|
+
or any(v not in ALLOWED_BY for v in value)
|
|
705
|
+
or value != sorted(set(value), key=ALLOWED_BY.index)
|
|
706
|
+
):
|
|
707
|
+
_fail(what, f"must be a non-empty list of {ALLOWED_BY}, in that order, without repeats")
|
|
708
|
+
|
|
709
|
+
|
|
710
|
+
def _evidence(value: object, what: str) -> None:
|
|
711
|
+
if value not in EVIDENCE_CLASSES:
|
|
712
|
+
_fail(what, f"must be one of {EVIDENCE_CLASSES}")
|
|
713
|
+
|
|
714
|
+
|
|
715
|
+
def _binding(value: object, what: str) -> None:
|
|
716
|
+
"""K10's operator binding: ``null``, or the comment the operator bound (#243)."""
|
|
717
|
+
if value is None:
|
|
718
|
+
return
|
|
719
|
+
data = _exact(value, _BINDING_KEYS, what)
|
|
720
|
+
_comment_url(data["comment_url"], f"{what}.comment_url")
|
|
721
|
+
_number(data["comment_id"], f"{what}.comment_id")
|
|
722
|
+
_digest(data["body_sha256"], f"{what}.body_sha256")
|
|
723
|
+
if parse_comment_url(data["comment_url"]).comment_id != data["comment_id"]:
|
|
724
|
+
_fail(what, "names another comment id than its URL's")
|
|
725
|
+
|
|
726
|
+
|
|
727
|
+
def _watermark(value: object, what: str) -> None:
|
|
728
|
+
_int(value, what)
|
|
729
|
+
|
|
730
|
+
|
|
731
|
+
def _opt_sha_value(value: object, what: str) -> None:
|
|
732
|
+
_opt_sha(value, what)
|
|
733
|
+
|
|
734
|
+
|
|
735
|
+
def _number(value: object, what: str) -> None:
|
|
736
|
+
_int(value, what, minimum=1)
|
|
737
|
+
|
|
738
|
+
|
|
739
|
+
# -- per-kind schemas ----------------------------------------------------------------
|
|
740
|
+
|
|
741
|
+
|
|
742
|
+
@dataclass(frozen=True)
|
|
743
|
+
class _KindSpec:
|
|
744
|
+
identity: Mapping[str, Checker]
|
|
745
|
+
target: Mapping[str, Checker]
|
|
746
|
+
precondition: Mapping[str, Checker]
|
|
747
|
+
payload: Mapping[str, Checker]
|
|
748
|
+
observed: Mapping[str, Checker]
|
|
749
|
+
completion: str
|
|
750
|
+
# K10 (#243): the operator performs the write and the controller only
|
|
751
|
+
# verifies it. Such a record is never attempted: ``attempting`` and
|
|
752
|
+
# ``drive`` refuse it, and the load cross-check refuses one that claims
|
|
753
|
+
# an attempt.
|
|
754
|
+
operator_performed: bool = False
|
|
755
|
+
|
|
756
|
+
|
|
757
|
+
_BODY = _published_text(MAX_BODY_CHARS)
|
|
758
|
+
|
|
759
|
+
KIND_SPECS: Mapping[EffectKind, _KindSpec] = {
|
|
760
|
+
EffectKind.PUSH: _KindSpec(
|
|
761
|
+
identity={"repository": _repository, "ref": _ref, "candidate_sha": _sha},
|
|
762
|
+
target={"repository": _repository, "ref": _ref},
|
|
763
|
+
precondition={"expected_old": _opt_sha_value, "base_sha": _sha},
|
|
764
|
+
payload={"sha": _sha},
|
|
765
|
+
observed={"sha": _sha},
|
|
766
|
+
completion="remote_ref_equals_candidate",
|
|
767
|
+
),
|
|
768
|
+
EffectKind.IMPLEMENTATION_PR: _KindSpec(
|
|
769
|
+
identity={
|
|
770
|
+
"repository": _repository,
|
|
771
|
+
"marker": _marker_of(IMPLEMENTATION),
|
|
772
|
+
"head_branch": _branch,
|
|
773
|
+
},
|
|
774
|
+
target={"repository": _repository, "base": _branch, "head": _branch},
|
|
775
|
+
precondition={"absent": _absent},
|
|
776
|
+
payload={"title": _title, "body": _BODY},
|
|
777
|
+
observed={"url": _pr_url, "number": _number},
|
|
778
|
+
completion="one_pr_on_head_branch_matching_payload",
|
|
779
|
+
),
|
|
780
|
+
EffectKind.ADOPT_PR: _KindSpec(
|
|
781
|
+
identity={"pr_url": _pr_url, "marker": _marker_of(IMPLEMENTATION)},
|
|
782
|
+
target={"pr_url": _pr_url},
|
|
783
|
+
precondition={"base_sha256": _digest},
|
|
784
|
+
payload={"body": _BODY, "block": _BODY},
|
|
785
|
+
observed={"url": _pr_url},
|
|
786
|
+
completion="body_equals_payload_marker_exactly_one",
|
|
787
|
+
),
|
|
788
|
+
EffectKind.REVIEW_COMMENT: _KindSpec(
|
|
789
|
+
identity={"pr_url": _pr_url, "marker": _marker_of(REVIEW)},
|
|
790
|
+
target={"pr_url": _pr_url},
|
|
791
|
+
precondition={"absent": _absent},
|
|
792
|
+
payload={"body": _BODY},
|
|
793
|
+
observed={"url": _comment_url},
|
|
794
|
+
completion="round_comment_exactly_one_body_equals_payload",
|
|
795
|
+
),
|
|
796
|
+
EffectKind.FOLLOW_UP_ISSUE: _KindSpec(
|
|
797
|
+
identity={"repository": _repository, "marker": _marker_of(FOLLOW_UP)},
|
|
798
|
+
target={"repository": _repository},
|
|
799
|
+
precondition={"absent": _absent, "watermark": _watermark},
|
|
800
|
+
payload={"title": _title, "body": _BODY},
|
|
801
|
+
observed={"url": _issue_url, "number": _number},
|
|
802
|
+
completion="one_issue_above_watermark_matching_payload",
|
|
803
|
+
),
|
|
804
|
+
EffectKind.FOLLOW_UP_APPEND: _KindSpec(
|
|
805
|
+
identity={"issue_url": _issue_url, "markers": _marker_list},
|
|
806
|
+
target={"issue_url": _issue_url},
|
|
807
|
+
precondition={"base_sha256": _digest},
|
|
808
|
+
payload={"body": _BODY, "block": _BODY},
|
|
809
|
+
observed={"url": _issue_url},
|
|
810
|
+
completion="body_equals_payload_markers_exactly_one",
|
|
811
|
+
),
|
|
812
|
+
EffectKind.REPLACEMENT_PR: _KindSpec(
|
|
813
|
+
identity={
|
|
814
|
+
"repository": _repository,
|
|
815
|
+
"transaction_marker": _replan_marker,
|
|
816
|
+
"head_branch": _branch,
|
|
817
|
+
},
|
|
818
|
+
target={"repository": _repository, "base": _branch, "head": _branch},
|
|
819
|
+
precondition={"absent": _absent, "watermark": _watermark},
|
|
820
|
+
payload={"title": _title, "body": _BODY},
|
|
821
|
+
observed={"url": _pr_url, "number": _number},
|
|
822
|
+
completion="replacement_bound_at_candidate_matching_payload",
|
|
823
|
+
),
|
|
824
|
+
EffectKind.PROGRESS_COMMENT: _KindSpec(
|
|
825
|
+
identity={"epic_url": _issue_url, "marker": _marker_of(PROGRESS)},
|
|
826
|
+
target={"epic_url": _issue_url},
|
|
827
|
+
precondition={"absent": _absent},
|
|
828
|
+
payload={"body": _BODY},
|
|
829
|
+
observed={"url": _comment_url},
|
|
830
|
+
completion="progress_comment_exactly_one_body_equals_payload",
|
|
831
|
+
),
|
|
832
|
+
EffectKind.ACCEPTANCE_COMMENT: _KindSpec(
|
|
833
|
+
identity={"issue_url": _issue_url, "marker": _marker_of(ACCEPTANCE)},
|
|
834
|
+
target={"issue_url": _issue_url},
|
|
835
|
+
precondition={
|
|
836
|
+
"absent": _absent,
|
|
837
|
+
"authorization": _authorization,
|
|
838
|
+
"approved_action_id": _approved_action_id,
|
|
839
|
+
},
|
|
840
|
+
payload={"body": _BODY},
|
|
841
|
+
observed={"url": _comment_url},
|
|
842
|
+
completion="acceptance_comment_exactly_one_body_equals_payload",
|
|
843
|
+
),
|
|
844
|
+
EffectKind.OPERATOR_COMMENT: _KindSpec(
|
|
845
|
+
identity={"issue_url": _issue_url, "marker": _marker_of(OPERATOR_COMMENT)},
|
|
846
|
+
target={"issue_url": _issue_url},
|
|
847
|
+
precondition={
|
|
848
|
+
"allowed_by": _allowed_by,
|
|
849
|
+
"issue_body_sha256": _digest,
|
|
850
|
+
"binding": _binding,
|
|
851
|
+
},
|
|
852
|
+
payload={"body": _BODY},
|
|
853
|
+
observed={
|
|
854
|
+
"url": _comment_url,
|
|
855
|
+
"comment_id": _number,
|
|
856
|
+
"evidence": _evidence,
|
|
857
|
+
"body_sha256": _digest,
|
|
858
|
+
},
|
|
859
|
+
completion="operator_comment_exact_or_bound_in_complete_listing",
|
|
860
|
+
operator_performed=True,
|
|
861
|
+
),
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
|
|
865
|
+
def _check_fields(raw: object, schema: Mapping[str, Checker], what: str) -> dict:
|
|
866
|
+
data = _exact(raw, tuple(schema), what)
|
|
867
|
+
for key, check in schema.items():
|
|
868
|
+
check(data[key], f"{what}.{key}")
|
|
869
|
+
return dict(data)
|
|
870
|
+
|
|
871
|
+
|
|
872
|
+
# -- the owner binding ---------------------------------------------------------------
|
|
873
|
+
|
|
874
|
+
|
|
875
|
+
def _same_url(a: str, b: str, parse: Callable[[str], Any]) -> bool:
|
|
876
|
+
if not a or not b:
|
|
877
|
+
return a == b
|
|
878
|
+
try:
|
|
879
|
+
return bool(parse(a).identity == parse(b).identity)
|
|
880
|
+
except ConfigurationError:
|
|
881
|
+
return False
|
|
882
|
+
|
|
883
|
+
|
|
884
|
+
@dataclass(frozen=True)
|
|
885
|
+
class Binding:
|
|
886
|
+
"""What persisted effect state must agree with: the state it is loaded with.
|
|
887
|
+
|
|
888
|
+
``phase`` is the state's phase. A record, an observation or a context
|
|
889
|
+
names a publishing phase; it must be the state's, unless the state is
|
|
890
|
+
``BLOCKED`` or ``FAILED`` (a conflict, or a rejection at the bound,
|
|
891
|
+
stopped that phase), and then every piece names the same phase.
|
|
892
|
+
"""
|
|
893
|
+
|
|
894
|
+
run_id: str
|
|
895
|
+
phase: Phase
|
|
896
|
+
issue_url: str
|
|
897
|
+
pr_url: str
|
|
898
|
+
review_round: int
|
|
899
|
+
transaction_id: str
|
|
900
|
+
open_finding_ids: tuple[str, ...] = ()
|
|
901
|
+
|
|
902
|
+
def check_phase(self, phase: Phase, what: str) -> None:
|
|
903
|
+
if phase not in PUBLISHING_PHASES:
|
|
904
|
+
_fail(what, f"names {phase.value}, which publishes nothing")
|
|
905
|
+
if self.phase in (Phase.BLOCKED, Phase.FAILED):
|
|
906
|
+
return
|
|
907
|
+
if phase != self.phase:
|
|
908
|
+
_fail(what, f"is bound to {phase.value} but the state is in {self.phase.value}")
|
|
909
|
+
|
|
910
|
+
def check_issue(self, issue_url: str, what: str) -> None:
|
|
911
|
+
if not _same_url(issue_url, self.issue_url, parse_issue_url):
|
|
912
|
+
_fail(what, "is bound to another issue than the state's current issue")
|
|
913
|
+
|
|
914
|
+
def check_pr(self, pr_url: str, what: str) -> None:
|
|
915
|
+
if not _same_url(pr_url, self.pr_url, parse_pr_url):
|
|
916
|
+
_fail(what, "is bound to another PR than the state's current PR")
|
|
917
|
+
|
|
918
|
+
|
|
919
|
+
@dataclass(frozen=True)
|
|
920
|
+
class EffectOwner:
|
|
921
|
+
"""D2.2: the run, phase, issue, PR and (REPLAN_REEXECUTE) transaction of a record."""
|
|
922
|
+
|
|
923
|
+
run_id: str
|
|
924
|
+
phase: Phase
|
|
925
|
+
issue_url: str
|
|
926
|
+
pr_url: str
|
|
927
|
+
transaction_id: str
|
|
928
|
+
|
|
929
|
+
_KEYS = ("run_id", "phase", "issue_url", "pr_url", "transaction_id")
|
|
930
|
+
|
|
931
|
+
@classmethod
|
|
932
|
+
def from_dict(cls, raw: object, what: str) -> EffectOwner:
|
|
933
|
+
data = _exact(raw, cls._KEYS, what)
|
|
934
|
+
run_id = _str(data["run_id"], f"{what}.run_id", 128)
|
|
935
|
+
phase_text = _str(data["phase"], f"{what}.phase", 64)
|
|
936
|
+
try:
|
|
937
|
+
phase = Phase(phase_text)
|
|
938
|
+
except ValueError:
|
|
939
|
+
_fail(f"{what}.phase", f"is not a phase: {phase_text!r}")
|
|
940
|
+
_issue_url(data["issue_url"], f"{what}.issue_url")
|
|
941
|
+
pr_url = _str(data["pr_url"], f"{what}.pr_url", MAX_URL_CHARS)
|
|
942
|
+
if pr_url:
|
|
943
|
+
_pr_url(pr_url, f"{what}.pr_url")
|
|
944
|
+
txn = _str(data["transaction_id"], f"{what}.transaction_id", 32)
|
|
945
|
+
if phase == Phase.REPLAN_REEXECUTE:
|
|
946
|
+
if not TRANSACTION_ID_RE.match(txn):
|
|
947
|
+
_fail(f"{what}.transaction_id", "must be the 32-hex replan transaction id")
|
|
948
|
+
elif txn:
|
|
949
|
+
_fail(f"{what}.transaction_id", f"must be empty outside {Phase.REPLAN_REEXECUTE.value}")
|
|
950
|
+
return cls(run_id, phase, data["issue_url"], pr_url, txn)
|
|
951
|
+
|
|
952
|
+
def to_dict(self) -> dict:
|
|
953
|
+
return {
|
|
954
|
+
"run_id": self.run_id,
|
|
955
|
+
"phase": self.phase.value,
|
|
956
|
+
"issue_url": self.issue_url,
|
|
957
|
+
"pr_url": self.pr_url,
|
|
958
|
+
"transaction_id": self.transaction_id,
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
def check(self, binding: Binding, what: str) -> None:
|
|
962
|
+
if self.run_id != binding.run_id:
|
|
963
|
+
_fail(what, "is bound to another run than the state's")
|
|
964
|
+
binding.check_phase(self.phase, what)
|
|
965
|
+
binding.check_issue(self.issue_url, what)
|
|
966
|
+
binding.check_pr(self.pr_url, what)
|
|
967
|
+
if self.transaction_id != binding.transaction_id and self.phase == Phase.REPLAN_REEXECUTE:
|
|
968
|
+
_fail(what, "is bound to another replan transaction than the state's")
|
|
969
|
+
|
|
970
|
+
|
|
971
|
+
# -- the record ------------------------------------------------------------------------
|
|
972
|
+
|
|
973
|
+
|
|
974
|
+
@dataclass(frozen=True)
|
|
975
|
+
class EffectRecord:
|
|
976
|
+
"""One effect of one phase entry (D2.1, D2.2). Immutable: a stage change is a new record."""
|
|
977
|
+
|
|
978
|
+
position: int
|
|
979
|
+
kind: EffectKind
|
|
980
|
+
owner: EffectOwner
|
|
981
|
+
identity: dict
|
|
982
|
+
target: dict
|
|
983
|
+
precondition: dict
|
|
984
|
+
payload: dict
|
|
985
|
+
completion: str
|
|
986
|
+
stage: Stage = Stage.INTENDED
|
|
987
|
+
attempts: int = 0
|
|
988
|
+
observed: dict | None = None
|
|
989
|
+
reason: str = ""
|
|
990
|
+
|
|
991
|
+
_KEYS = (
|
|
992
|
+
"position",
|
|
993
|
+
"kind",
|
|
994
|
+
"owner",
|
|
995
|
+
"identity",
|
|
996
|
+
"target",
|
|
997
|
+
"precondition",
|
|
998
|
+
"payload",
|
|
999
|
+
"completion",
|
|
1000
|
+
"stage",
|
|
1001
|
+
"attempts",
|
|
1002
|
+
"observed",
|
|
1003
|
+
"reason",
|
|
1004
|
+
)
|
|
1005
|
+
|
|
1006
|
+
# -- construction -------------------------------------------------------------
|
|
1007
|
+
@classmethod
|
|
1008
|
+
def plan(
|
|
1009
|
+
cls,
|
|
1010
|
+
position: int,
|
|
1011
|
+
kind: EffectKind,
|
|
1012
|
+
owner: EffectOwner,
|
|
1013
|
+
*,
|
|
1014
|
+
identity: dict,
|
|
1015
|
+
target: dict,
|
|
1016
|
+
precondition: dict,
|
|
1017
|
+
payload: dict,
|
|
1018
|
+
) -> EffectRecord:
|
|
1019
|
+
"""A new ``intended`` record, validated exactly as it will be on load.
|
|
1020
|
+
|
|
1021
|
+
A value that fails here is a controller bug or a payload the planner
|
|
1022
|
+
should have refused first (an oversized body, a credential); it is
|
|
1023
|
+
raised as :class:`StateError`, never persisted.
|
|
1024
|
+
"""
|
|
1025
|
+
record = cls(
|
|
1026
|
+
position=position,
|
|
1027
|
+
kind=kind,
|
|
1028
|
+
owner=owner,
|
|
1029
|
+
identity=dict(identity),
|
|
1030
|
+
target=dict(target),
|
|
1031
|
+
precondition=dict(precondition),
|
|
1032
|
+
payload=dict(payload),
|
|
1033
|
+
completion=KIND_SPECS[kind].completion,
|
|
1034
|
+
)
|
|
1035
|
+
return cls.from_dict(record.to_dict())
|
|
1036
|
+
|
|
1037
|
+
@classmethod
|
|
1038
|
+
def from_dict(cls, raw: object, what: str = "effect record") -> EffectRecord:
|
|
1039
|
+
data = _exact(raw, cls._KEYS, what)
|
|
1040
|
+
position = _int(data["position"], f"{what}.position", 0, MAX_EFFECTS_PER_PLAN - 1)
|
|
1041
|
+
what = f"effect record {position}"
|
|
1042
|
+
kind_text = _str(data["kind"], f"{what}.kind", 64)
|
|
1043
|
+
try:
|
|
1044
|
+
kind = EffectKind(kind_text)
|
|
1045
|
+
except ValueError:
|
|
1046
|
+
_fail(f"{what}.kind", f"is not a Wave 1 effect kind: {kind_text!r}")
|
|
1047
|
+
spec = KIND_SPECS[kind]
|
|
1048
|
+
owner = EffectOwner.from_dict(data["owner"], f"{what}.owner")
|
|
1049
|
+
if owner.phase not in KIND_PHASES[kind]:
|
|
1050
|
+
_fail(what, f"is a {kind.value} effect, which {owner.phase.value} never plans")
|
|
1051
|
+
stage_text = _str(data["stage"], f"{what}.stage", 32)
|
|
1052
|
+
try:
|
|
1053
|
+
stage = Stage(stage_text)
|
|
1054
|
+
except ValueError:
|
|
1055
|
+
_fail(f"{what}.stage", f"is not a stage: {stage_text!r}")
|
|
1056
|
+
attempts = _int(data["attempts"], f"{what}.attempts", 0, MAX_EFFECT_ATTEMPTS)
|
|
1057
|
+
if stage == Stage.INTENDED and attempts != 0:
|
|
1058
|
+
_fail(what, "is intended but records an issued attempt")
|
|
1059
|
+
if stage == Stage.ATTEMPTED and attempts < 1:
|
|
1060
|
+
_fail(what, "is attempted but records no attempt")
|
|
1061
|
+
completion = data["completion"]
|
|
1062
|
+
if completion != spec.completion:
|
|
1063
|
+
_fail(f"{what}.completion", f"must be {spec.completion!r} for a {kind.value} effect")
|
|
1064
|
+
observed = data["observed"]
|
|
1065
|
+
if stage == Stage.OBSERVED:
|
|
1066
|
+
observed = _check_fields(observed, spec.observed, f"{what}.observed")
|
|
1067
|
+
elif observed is not None:
|
|
1068
|
+
_fail(f"{what}.observed", f"must be null while the record is {stage.value}")
|
|
1069
|
+
reason = _str(data["reason"], f"{what}.reason", MAX_CONFLICT_REASON_CHARS)
|
|
1070
|
+
# K10 (#243): an intended operator comment may keep the diagnostic of
|
|
1071
|
+
# its last failed verification (:meth:`diagnosed`); it stays pending.
|
|
1072
|
+
diagnosed = kind == EffectKind.OPERATOR_COMMENT and stage == Stage.INTENDED
|
|
1073
|
+
if (stage == Stage.CONFLICT and not reason) or (
|
|
1074
|
+
reason and stage != Stage.CONFLICT and not diagnosed
|
|
1075
|
+
):
|
|
1076
|
+
_fail(
|
|
1077
|
+
f"{what}.reason",
|
|
1078
|
+
"is set exactly when the record is a conflict, or an intended operator comment "
|
|
1079
|
+
"whose last verification failed",
|
|
1080
|
+
)
|
|
1081
|
+
record = cls(
|
|
1082
|
+
position=position,
|
|
1083
|
+
kind=kind,
|
|
1084
|
+
owner=owner,
|
|
1085
|
+
identity=_check_fields(data["identity"], spec.identity, f"{what}.identity"),
|
|
1086
|
+
target=_check_fields(data["target"], spec.target, f"{what}.target"),
|
|
1087
|
+
precondition=_check_fields(
|
|
1088
|
+
data["precondition"], spec.precondition, f"{what}.precondition"
|
|
1089
|
+
),
|
|
1090
|
+
payload=_check_fields(data["payload"], spec.payload, f"{what}.payload"),
|
|
1091
|
+
completion=completion,
|
|
1092
|
+
stage=stage,
|
|
1093
|
+
attempts=attempts,
|
|
1094
|
+
observed=observed,
|
|
1095
|
+
reason=reason,
|
|
1096
|
+
)
|
|
1097
|
+
_CROSS_CHECKS[kind](record, what)
|
|
1098
|
+
return record
|
|
1099
|
+
|
|
1100
|
+
def to_dict(self) -> dict:
|
|
1101
|
+
return {
|
|
1102
|
+
"position": self.position,
|
|
1103
|
+
"kind": self.kind.value,
|
|
1104
|
+
"owner": self.owner.to_dict(),
|
|
1105
|
+
"identity": dict(self.identity),
|
|
1106
|
+
"target": dict(self.target),
|
|
1107
|
+
"precondition": dict(self.precondition),
|
|
1108
|
+
"payload": dict(self.payload),
|
|
1109
|
+
"completion": self.completion,
|
|
1110
|
+
"stage": self.stage.value,
|
|
1111
|
+
"attempts": self.attempts,
|
|
1112
|
+
"observed": None if self.observed is None else dict(self.observed),
|
|
1113
|
+
"reason": self.reason,
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
# -- stage transitions (D2.3, D4.1, D5.5) ---------------------------------------
|
|
1117
|
+
@property
|
|
1118
|
+
def pending(self) -> bool:
|
|
1119
|
+
"""Not yet terminal: reconciled before anything else (D4.2)."""
|
|
1120
|
+
return self.stage in (Stage.INTENDED, Stage.ATTEMPTED)
|
|
1121
|
+
|
|
1122
|
+
@property
|
|
1123
|
+
def bound_left(self) -> bool:
|
|
1124
|
+
return self.attempts < MAX_EFFECT_ATTEMPTS
|
|
1125
|
+
|
|
1126
|
+
def attempting(self) -> EffectRecord:
|
|
1127
|
+
"""The record saved before an issue: ``attempted``, count incremented (D4.1)."""
|
|
1128
|
+
if not self.pending:
|
|
1129
|
+
raise StateError(f"effect record {self.position} is {self.stage.value}; not issued")
|
|
1130
|
+
if not self.bound_left:
|
|
1131
|
+
raise StateError(f"effect record {self.position} has used its {MAX_EFFECT_ATTEMPTS}")
|
|
1132
|
+
if self.operator_performed:
|
|
1133
|
+
# K10 (#243): the operator performs it; the controller never
|
|
1134
|
+
# issues it, under any policy or approval.
|
|
1135
|
+
raise StateError(
|
|
1136
|
+
f"effect record {self.position} ({self.kind.value} {self.action_id}) is "
|
|
1137
|
+
"operator-performed; the controller never issues it"
|
|
1138
|
+
)
|
|
1139
|
+
if self.kind == EffectKind.ACCEPTANCE_COMMENT and not self.authorized:
|
|
1140
|
+
# The one write K9 makes needs its approval first (#211); the
|
|
1141
|
+
# cross-check refuses the attempted record on load as well.
|
|
1142
|
+
raise StateError(f"effect record {self.position} is not authorized; not issued")
|
|
1143
|
+
return replace(self, stage=Stage.ATTEMPTED, attempts=self.attempts + 1)
|
|
1144
|
+
|
|
1145
|
+
def rebasing(self, body: str) -> EffectRecord:
|
|
1146
|
+
"""D5.5: from ``intended`` only, the append over ``body``, saved before its issue."""
|
|
1147
|
+
if self.kind not in APPEND_KINDS or self.stage != Stage.INTENDED:
|
|
1148
|
+
raise StateError(f"effect record {self.position} cannot be rebased")
|
|
1149
|
+
payload = {**self.payload, "body": compose_append(body, self.payload["block"])}
|
|
1150
|
+
rebased = replace(
|
|
1151
|
+
self,
|
|
1152
|
+
payload=payload,
|
|
1153
|
+
precondition={**self.precondition, "base_sha256": sha256_text(body)},
|
|
1154
|
+
).attempting()
|
|
1155
|
+
return EffectRecord.from_dict(rebased.to_dict())
|
|
1156
|
+
|
|
1157
|
+
def observing(self, observed: dict) -> EffectRecord:
|
|
1158
|
+
if not self.pending:
|
|
1159
|
+
raise StateError(f"effect record {self.position} is {self.stage.value}; not observed")
|
|
1160
|
+
record = replace(self, stage=Stage.OBSERVED, observed=dict(observed), reason="")
|
|
1161
|
+
return EffectRecord.from_dict(record.to_dict())
|
|
1162
|
+
|
|
1163
|
+
def conflicting(self, reason: str) -> EffectRecord:
|
|
1164
|
+
return replace(self, stage=Stage.CONFLICT, reason=bounded_reason(reason))
|
|
1165
|
+
|
|
1166
|
+
def diagnosed(self, reason: str) -> EffectRecord:
|
|
1167
|
+
"""K10 (#243): still ``intended``, with why its last verification failed, or "".
|
|
1168
|
+
|
|
1169
|
+
A changed or deleted bound comment, or a target that no longer
|
|
1170
|
+
qualifies or cannot be read, is kept as the record's redacted,
|
|
1171
|
+
bounded ``reason`` until the next verification replaces it or finds
|
|
1172
|
+
nothing wrong ("" clears it), so an offline ``status`` reports
|
|
1173
|
+
``verification_failed`` with the reason (PR #254 R3-F3).
|
|
1174
|
+
"""
|
|
1175
|
+
if self.kind != EffectKind.OPERATOR_COMMENT or self.stage != Stage.INTENDED:
|
|
1176
|
+
raise StateError(f"effect record {self.position} cannot carry a verification failure")
|
|
1177
|
+
return replace(self, reason=bounded_reason(reason) if reason else "")
|
|
1178
|
+
|
|
1179
|
+
def reopened(self) -> EffectRecord:
|
|
1180
|
+
"""A ``conflict`` the operator resolved: reconciled again, as ``attempted``.
|
|
1181
|
+
|
|
1182
|
+
``unblock`` back into the phase keeps the records (D2.4: they belong
|
|
1183
|
+
to the phase entry). A conflict carries no promise that nothing was
|
|
1184
|
+
sent, so it is reconciled as a record whose issue may have landed,
|
|
1185
|
+
with its count kept: the bound of D4.3 still holds across an unblock.
|
|
1186
|
+
"""
|
|
1187
|
+
if self.stage != Stage.CONFLICT:
|
|
1188
|
+
return self
|
|
1189
|
+
if self.attempts == 0:
|
|
1190
|
+
return replace(self, stage=Stage.INTENDED, reason="")
|
|
1191
|
+
return replace(self, stage=Stage.ATTEMPTED, reason="")
|
|
1192
|
+
|
|
1193
|
+
# -- convenience ------------------------------------------------------------------
|
|
1194
|
+
@property
|
|
1195
|
+
def body(self) -> str:
|
|
1196
|
+
return str(self.payload.get("body", ""))
|
|
1197
|
+
|
|
1198
|
+
@property
|
|
1199
|
+
def marker(self) -> str:
|
|
1200
|
+
return str(self.identity.get("marker", ""))
|
|
1201
|
+
|
|
1202
|
+
@property
|
|
1203
|
+
def operator_performed(self) -> bool:
|
|
1204
|
+
"""K10 (#243): the operator performs the write; the controller only verifies it."""
|
|
1205
|
+
return KIND_SPECS[self.kind].operator_performed
|
|
1206
|
+
|
|
1207
|
+
@property
|
|
1208
|
+
def action_id(self) -> str:
|
|
1209
|
+
"""K9's ``A-`` or K10's ``X-`` action id, recomputed; "" for every other kind."""
|
|
1210
|
+
if self.kind == EffectKind.ACCEPTANCE_COMMENT:
|
|
1211
|
+
return acceptance_action_id(self.owner, self.identity, self.target, self.payload)
|
|
1212
|
+
if self.kind == EffectKind.OPERATOR_COMMENT:
|
|
1213
|
+
return operator_action_id(self.owner, self.identity, self.target, self.payload)
|
|
1214
|
+
return ""
|
|
1215
|
+
|
|
1216
|
+
@property
|
|
1217
|
+
def binding(self) -> dict | None:
|
|
1218
|
+
"""K10: the comment the operator bound as evidence, or ``None`` (#243)."""
|
|
1219
|
+
value = self.precondition.get("binding")
|
|
1220
|
+
return dict(value) if isinstance(value, dict) else None
|
|
1221
|
+
|
|
1222
|
+
def binding_to(self, comment_url: str, comment_id: int, body_sha256: str) -> EffectRecord:
|
|
1223
|
+
"""K10 bound by the operator to one existing comment, digest-pinned (#243).
|
|
1224
|
+
|
|
1225
|
+
The new binding supersedes the last verification's failure, which
|
|
1226
|
+
is cleared: the next verification is of this binding.
|
|
1227
|
+
"""
|
|
1228
|
+
if self.kind != EffectKind.OPERATOR_COMMENT or self.stage != Stage.INTENDED:
|
|
1229
|
+
raise StateError(f"effect record {self.position} cannot be bound to evidence")
|
|
1230
|
+
binding = {"comment_url": comment_url, "comment_id": comment_id, "body_sha256": body_sha256}
|
|
1231
|
+
record = replace(self, precondition={**self.precondition, "binding": binding}, reason="")
|
|
1232
|
+
return EffectRecord.from_dict(record.to_dict())
|
|
1233
|
+
|
|
1234
|
+
@property
|
|
1235
|
+
def authorized(self) -> bool:
|
|
1236
|
+
"""K9: an approval of exactly this record's action id is recorded (#211)."""
|
|
1237
|
+
approved = self.precondition.get("approved_action_id", "")
|
|
1238
|
+
return bool(approved) and approved == self.action_id
|
|
1239
|
+
|
|
1240
|
+
def approving(self) -> EffectRecord:
|
|
1241
|
+
"""K9 approved by the operator: its recomputed action id recorded (#211)."""
|
|
1242
|
+
if self.kind != EffectKind.ACCEPTANCE_COMMENT or self.stage != Stage.INTENDED:
|
|
1243
|
+
raise StateError(f"effect record {self.position} cannot be approved")
|
|
1244
|
+
record = replace(
|
|
1245
|
+
self, precondition={**self.precondition, "approved_action_id": self.action_id}
|
|
1246
|
+
)
|
|
1247
|
+
return EffectRecord.from_dict(record.to_dict())
|
|
1248
|
+
|
|
1249
|
+
def describe(self) -> str:
|
|
1250
|
+
"""``<kind> effect <position> on <target>``: names the object, never the payload."""
|
|
1251
|
+
target = (
|
|
1252
|
+
self.target.get("epic_url")
|
|
1253
|
+
or self.target.get("pr_url")
|
|
1254
|
+
or self.target.get("issue_url")
|
|
1255
|
+
or self.target.get("ref")
|
|
1256
|
+
or self.target.get("repository")
|
|
1257
|
+
)
|
|
1258
|
+
return f"{self.kind.value} effect {self.position} on {target}"
|
|
1259
|
+
|
|
1260
|
+
|
|
1261
|
+
APPEND_KINDS = frozenset({EffectKind.ADOPT_PR, EffectKind.FOLLOW_UP_APPEND})
|
|
1262
|
+
|
|
1263
|
+
|
|
1264
|
+
def bounded_reason(reason: str) -> str:
|
|
1265
|
+
"""A conflict reason as it may be persisted: redacted, then clipped at the bound."""
|
|
1266
|
+
text = redact(reason or "conflict").strip() or "conflict"
|
|
1267
|
+
if len(text) > MAX_CONFLICT_REASON_CHARS:
|
|
1268
|
+
text = text[: MAX_CONFLICT_REASON_CHARS - 1] + "\u2026"
|
|
1269
|
+
return text
|
|
1270
|
+
|
|
1271
|
+
|
|
1272
|
+
# -- per-kind cross-field checks --------------------------------------------------------
|
|
1273
|
+
|
|
1274
|
+
|
|
1275
|
+
def _same(record: EffectRecord, what: str, *pairs: tuple[object, object, str]) -> None:
|
|
1276
|
+
for a, b, name in pairs:
|
|
1277
|
+
if a != b:
|
|
1278
|
+
_fail(what, f"has a {name} that differs between its identity and its target")
|
|
1279
|
+
|
|
1280
|
+
|
|
1281
|
+
def _marker_claim(kind: MarkerKind, marker: str): # noqa: ANN202 - the claim type varies
|
|
1282
|
+
return scan(kind, marker).claims[0]
|
|
1283
|
+
|
|
1284
|
+
|
|
1285
|
+
def _check_owner_issue_marker(record: EffectRecord, kind: MarkerKind, what: str) -> None:
|
|
1286
|
+
claim = _marker_claim(kind, record.identity["marker"])
|
|
1287
|
+
issue = getattr(claim, "issue", None)
|
|
1288
|
+
if issue is not None and not _same_url(
|
|
1289
|
+
issue.canonical, record.owner.issue_url, parse_issue_url
|
|
1290
|
+
):
|
|
1291
|
+
_fail(what, "carries a marker for another issue than its owner's")
|
|
1292
|
+
pr = getattr(claim, "pr", None)
|
|
1293
|
+
if pr is not None and not _same_url(pr.canonical, record.owner.pr_url, parse_pr_url):
|
|
1294
|
+
_fail(what, "carries a marker for another PR than its owner's")
|
|
1295
|
+
|
|
1296
|
+
|
|
1297
|
+
def _check_body_ends_with_marker(record: EffectRecord, marker: str, what: str) -> None:
|
|
1298
|
+
if not record.body.endswith(marker):
|
|
1299
|
+
_fail(what, "has a payload body that does not end with its identity's marker")
|
|
1300
|
+
|
|
1301
|
+
|
|
1302
|
+
def _check_append(record: EffectRecord, markers: Sequence[str], what: str) -> None:
|
|
1303
|
+
block = record.payload["block"]
|
|
1304
|
+
tail = APPEND_SEPARATOR + block
|
|
1305
|
+
if not record.body.endswith(tail):
|
|
1306
|
+
_fail(what, "has a payload that is not its base, the separator and its block")
|
|
1307
|
+
base = record.body[: len(record.body) - len(tail)]
|
|
1308
|
+
if sha256_text(base) != record.precondition["base_sha256"]:
|
|
1309
|
+
_fail(what, "has a payload whose base does not match its recorded base digest")
|
|
1310
|
+
for marker in markers:
|
|
1311
|
+
if marker not in block:
|
|
1312
|
+
_fail(what, "has a block that does not carry its identity's marker")
|
|
1313
|
+
|
|
1314
|
+
|
|
1315
|
+
def _cross_push(record: EffectRecord, what: str) -> None:
|
|
1316
|
+
_same(
|
|
1317
|
+
record,
|
|
1318
|
+
what,
|
|
1319
|
+
(record.identity["repository"], record.target["repository"], "repository"),
|
|
1320
|
+
(record.identity["ref"], record.target["ref"], "ref"),
|
|
1321
|
+
)
|
|
1322
|
+
if record.identity["candidate_sha"] != record.payload["sha"]:
|
|
1323
|
+
_fail(what, "pushes another SHA than its identity's candidate")
|
|
1324
|
+
if record.payload["sha"] == record.precondition["base_sha"]:
|
|
1325
|
+
_fail(what, "pushes the base itself; a candidate differs from its base")
|
|
1326
|
+
if record.observed is not None and record.observed["sha"] != record.payload["sha"]:
|
|
1327
|
+
_fail(what, "is observed at another SHA than its candidate")
|
|
1328
|
+
|
|
1329
|
+
|
|
1330
|
+
def _owner_closing_block(record: EffectRecord, repository: str, what: str) -> str:
|
|
1331
|
+
"""The closing block of the owner's issue, for a PR in ``repository``.
|
|
1332
|
+
|
|
1333
|
+
``Closes #n`` names issue ``n`` of the PR's own repository, so it is the
|
|
1334
|
+
owner's issue only when the PR is in that issue's repository.
|
|
1335
|
+
"""
|
|
1336
|
+
if not parse_issue_url(record.owner.issue_url).same_repository(repository):
|
|
1337
|
+
_fail(what, "is in another repository than its owner's issue, which 'Closes #n' names")
|
|
1338
|
+
return implementation_closing_block(record.owner.issue_url)
|
|
1339
|
+
|
|
1340
|
+
|
|
1341
|
+
def _cross_implementation_pr(record: EffectRecord, what: str) -> None:
|
|
1342
|
+
_same(
|
|
1343
|
+
record,
|
|
1344
|
+
what,
|
|
1345
|
+
(record.identity["repository"], record.target["repository"], "repository"),
|
|
1346
|
+
(record.identity["head_branch"], record.target["head"], "head branch"),
|
|
1347
|
+
)
|
|
1348
|
+
_check_owner_issue_marker(record, IMPLEMENTATION, what)
|
|
1349
|
+
_check_body_ends_with_marker(record, record.identity["marker"], what)
|
|
1350
|
+
# The payload is the title and body the parser accepted, the body then
|
|
1351
|
+
# followed by a blank line and the controller's closing block; both texts
|
|
1352
|
+
# get the parser's rules again, because a resumed create publishes them
|
|
1353
|
+
# with no agent result in between. The credential rule over the whole
|
|
1354
|
+
# body is the record's redaction invariance.
|
|
1355
|
+
tail = APPEND_SEPARATOR + _owner_closing_block(record, record.target["repository"], what)
|
|
1356
|
+
if not record.body.endswith(tail):
|
|
1357
|
+
_fail(
|
|
1358
|
+
what,
|
|
1359
|
+
"has a payload body that is not its text, a blank line and the closing block "
|
|
1360
|
+
"of its owner's issue",
|
|
1361
|
+
)
|
|
1362
|
+
text = record.body[: len(record.body) - len(tail)]
|
|
1363
|
+
for key, value, validate in (
|
|
1364
|
+
("title", str(record.payload["title"]), validate_pr_title),
|
|
1365
|
+
("body", text, validate_pr_body),
|
|
1366
|
+
):
|
|
1367
|
+
if value != value.strip():
|
|
1368
|
+
_fail(what, f"has a PR {key} that is not in the parser's stored form")
|
|
1369
|
+
try:
|
|
1370
|
+
validate(value)
|
|
1371
|
+
except ControlResultValidationError as exc:
|
|
1372
|
+
_fail(what, f"has an invalid PR {key}: {exc}")
|
|
1373
|
+
|
|
1374
|
+
|
|
1375
|
+
def _cross_adopt_pr(record: EffectRecord, what: str) -> None:
|
|
1376
|
+
_same(record, what, (record.identity["pr_url"], record.target["pr_url"], "PR"))
|
|
1377
|
+
_check_owner_issue_marker(record, IMPLEMENTATION, what)
|
|
1378
|
+
_check_append(record, (record.identity["marker"],), what)
|
|
1379
|
+
# The base is the adopted PR's own body, a human's text the controller
|
|
1380
|
+
# keeps as it is; only the block is the controller's, and it is exactly
|
|
1381
|
+
# the closing block of the owner's issue.
|
|
1382
|
+
repository = parse_pr_url(record.target["pr_url"]).repository
|
|
1383
|
+
if record.payload["block"] != _owner_closing_block(record, repository, what):
|
|
1384
|
+
_fail(what, "has a block that is not the closing block of its owner's issue")
|
|
1385
|
+
if record.observed is not None and record.observed["url"] != record.target["pr_url"]:
|
|
1386
|
+
_fail(what, "is observed on another PR than its target")
|
|
1387
|
+
|
|
1388
|
+
|
|
1389
|
+
def _cross_review_comment(record: EffectRecord, what: str) -> None:
|
|
1390
|
+
_same(record, what, (record.identity["pr_url"], record.target["pr_url"], "PR"))
|
|
1391
|
+
if not _same_url(record.target["pr_url"], record.owner.pr_url, parse_pr_url):
|
|
1392
|
+
_fail(what, "targets another PR than its owner's")
|
|
1393
|
+
# The review marker names a revision, not a PR: the comment is bound to its
|
|
1394
|
+
# PR by where it is posted, which the two checks above pin to the owner's.
|
|
1395
|
+
claim = _marker_claim(REVIEW, record.identity["marker"])
|
|
1396
|
+
if claim.reviewed_base_ref is None or claim.reviewed_merge_base_sha is None:
|
|
1397
|
+
_fail(what, "carries a review marker without the base it reviewed, which no round binds")
|
|
1398
|
+
_check_body_ends_with_marker(record, record.identity["marker"], what)
|
|
1399
|
+
# The rest of the body is checked where its source is: the REVIEW
|
|
1400
|
+
# completion context saved with it must render exactly this body from
|
|
1401
|
+
# fields that pass the parser's rules (ReviewContext.from_dict).
|
|
1402
|
+
if record.observed is not None and not parse_comment_url(record.observed["url"]).on(
|
|
1403
|
+
parse_pr_url(record.target["pr_url"])
|
|
1404
|
+
):
|
|
1405
|
+
_fail(what, "is observed on another PR than its target")
|
|
1406
|
+
|
|
1407
|
+
|
|
1408
|
+
def _cross_follow_up_issue(record: EffectRecord, what: str) -> None:
|
|
1409
|
+
_same(record, what, (record.identity["repository"], record.target["repository"], "repo"))
|
|
1410
|
+
_check_owner_issue_marker(record, FOLLOW_UP, what)
|
|
1411
|
+
_check_body_ends_with_marker(record, record.identity["marker"], what)
|
|
1412
|
+
if not parse_pr_url(record.owner.pr_url).same_repository(record.target["repository"]):
|
|
1413
|
+
_fail(what, "creates a follow-up issue in another repository than its owner's PR")
|
|
1414
|
+
# The payload is the title and body the parser accepted, the body then
|
|
1415
|
+
# followed by the controller's reference to the PR and finding and the
|
|
1416
|
+
# marker (#163); both texts get the parser's rules again, because a
|
|
1417
|
+
# resumed create publishes them with no agent result in between.
|
|
1418
|
+
claim = _marker_claim(FOLLOW_UP, record.identity["marker"])
|
|
1419
|
+
tail = (
|
|
1420
|
+
f"{APPEND_SEPARATOR}{follow_up_reference(record.owner.pr_url, claim.finding_id)}"
|
|
1421
|
+
f"{APPEND_SEPARATOR}{record.identity['marker']}"
|
|
1422
|
+
)
|
|
1423
|
+
if not record.body.endswith(tail):
|
|
1424
|
+
_fail(
|
|
1425
|
+
what,
|
|
1426
|
+
"has a payload body that is not its text, the controller's reference to its PR "
|
|
1427
|
+
"and finding, and its marker",
|
|
1428
|
+
)
|
|
1429
|
+
text = record.body[: len(record.body) - len(tail)]
|
|
1430
|
+
for key, value, validate in (
|
|
1431
|
+
("title", str(record.payload["title"]), validate_follow_up_title),
|
|
1432
|
+
("body", text, validate_follow_up_body),
|
|
1433
|
+
):
|
|
1434
|
+
if value != value.strip():
|
|
1435
|
+
_fail(what, f"has a follow-up {key} that is not in the parser's stored form")
|
|
1436
|
+
try:
|
|
1437
|
+
validate(value)
|
|
1438
|
+
except ControlResultValidationError as exc:
|
|
1439
|
+
_fail(what, f"has an invalid follow-up {key}: {exc}")
|
|
1440
|
+
|
|
1441
|
+
|
|
1442
|
+
def _cross_follow_up_append(record: EffectRecord, what: str) -> None:
|
|
1443
|
+
_same(record, what, (record.identity["issue_url"], record.target["issue_url"], "issue"))
|
|
1444
|
+
for marker in record.identity["markers"]:
|
|
1445
|
+
claim = _marker_claim(FOLLOW_UP, marker)
|
|
1446
|
+
if not _same_url(claim.pr.canonical, record.owner.pr_url, parse_pr_url):
|
|
1447
|
+
_fail(what, "carries a follow-up marker for another PR than its owner's")
|
|
1448
|
+
if record.payload["block"] != "\n".join(record.identity["markers"]):
|
|
1449
|
+
_fail(what, "has a block that is not its markers, one per line in plan order")
|
|
1450
|
+
_check_append(record, record.identity["markers"], what)
|
|
1451
|
+
if _same_url(record.target["issue_url"], record.owner.issue_url, parse_issue_url):
|
|
1452
|
+
_fail(what, "appends to the current issue, which is never a follow-up")
|
|
1453
|
+
if record.observed is not None and record.observed["url"] != record.target["issue_url"]:
|
|
1454
|
+
_fail(what, "is observed on another issue than its target")
|
|
1455
|
+
|
|
1456
|
+
|
|
1457
|
+
def _cross_replacement_pr(record: EffectRecord, what: str) -> None:
|
|
1458
|
+
_same(
|
|
1459
|
+
record,
|
|
1460
|
+
what,
|
|
1461
|
+
(record.identity["repository"], record.target["repository"], "repository"),
|
|
1462
|
+
(record.identity["head_branch"], record.target["head"], "head branch"),
|
|
1463
|
+
)
|
|
1464
|
+
attestation = scan_replan_markers(record.identity["transaction_marker"]).attestations[0]
|
|
1465
|
+
if attestation.transaction_id != record.owner.transaction_id:
|
|
1466
|
+
_fail(what, "carries the marker of another replan transaction than its owner's")
|
|
1467
|
+
if not attestation.tests_passed:
|
|
1468
|
+
_fail(what, "carries a marker attesting failed tests, which is never published")
|
|
1469
|
+
_check_body_ends_with_marker(record, record.identity["transaction_marker"], what)
|
|
1470
|
+
# The payload is the title and body the parser accepted, the body then
|
|
1471
|
+
# followed by the controller's closing block and transaction marker
|
|
1472
|
+
# (#164); both texts get the parser's rules again, because a resumed
|
|
1473
|
+
# create publishes them with no agent result in between.
|
|
1474
|
+
tail = (
|
|
1475
|
+
APPEND_SEPARATOR
|
|
1476
|
+
+ _owner_closing_block(record, record.target["repository"], what)
|
|
1477
|
+
+ APPEND_SEPARATOR
|
|
1478
|
+
+ record.identity["transaction_marker"]
|
|
1479
|
+
)
|
|
1480
|
+
if not record.body.endswith(tail):
|
|
1481
|
+
_fail(
|
|
1482
|
+
what,
|
|
1483
|
+
"has a payload body that is not its text, the closing block of its owner's issue "
|
|
1484
|
+
"and its transaction marker",
|
|
1485
|
+
)
|
|
1486
|
+
text = record.body[: len(record.body) - len(tail)]
|
|
1487
|
+
for key, value, validate in (
|
|
1488
|
+
("title", str(record.payload["title"]), validate_pr_title),
|
|
1489
|
+
("body", text, validate_pr_body),
|
|
1490
|
+
):
|
|
1491
|
+
if value != value.strip():
|
|
1492
|
+
_fail(what, f"has a PR {key} that is not in the parser's stored form")
|
|
1493
|
+
try:
|
|
1494
|
+
validate(value, phase=Phase.REPLAN_REEXECUTE.value)
|
|
1495
|
+
except ControlResultValidationError as exc:
|
|
1496
|
+
_fail(what, f"has an invalid PR {key}: {exc}")
|
|
1497
|
+
if record.observed is not None and not parse_pr_url(record.observed["url"]).same_repository(
|
|
1498
|
+
record.target["repository"]
|
|
1499
|
+
):
|
|
1500
|
+
_fail(what, "is observed in another repository than its target")
|
|
1501
|
+
|
|
1502
|
+
|
|
1503
|
+
def _cross_progress_comment(record: EffectRecord, what: str) -> None:
|
|
1504
|
+
_same(record, what, (record.identity["epic_url"], record.target["epic_url"], "EPIC"))
|
|
1505
|
+
_check_owner_issue_marker(record, PROGRESS, what)
|
|
1506
|
+
_check_body_ends_with_marker(record, record.identity["marker"], what)
|
|
1507
|
+
# The payload is the progress text the parser accepted and the marker
|
|
1508
|
+
# (``progress_comment_body``); the text gets the parser's rules again,
|
|
1509
|
+
# because a resumed write publishes it with no agent result in between.
|
|
1510
|
+
tail = APPEND_SEPARATOR + record.identity["marker"]
|
|
1511
|
+
if not record.body.endswith(tail):
|
|
1512
|
+
_fail(what, "has a payload body that is not its progress text, a blank line and its marker")
|
|
1513
|
+
try:
|
|
1514
|
+
validate_progress_text(record.body[: len(record.body) - len(tail)])
|
|
1515
|
+
except ControlResultValidationError as exc:
|
|
1516
|
+
_fail(what, f"has an invalid progress text: {exc}")
|
|
1517
|
+
if record.observed is not None and not parse_comment_url(record.observed["url"]).on(
|
|
1518
|
+
parse_issue_url(record.target["epic_url"])
|
|
1519
|
+
):
|
|
1520
|
+
_fail(what, "is observed on another issue than its EPIC")
|
|
1521
|
+
|
|
1522
|
+
|
|
1523
|
+
def acceptance_action_id(owner: EffectOwner, identity: dict, target: dict, payload: dict) -> str:
|
|
1524
|
+
"""The id of one acceptance action (#211): ``A-`` and 16 hex digits.
|
|
1525
|
+
|
|
1526
|
+
The SHA-256 of a canonical encoding of the record's owner, identity,
|
|
1527
|
+
target and payload, so it names exactly one write of exactly these
|
|
1528
|
+
bytes to exactly this issue, for this run's FIX of this PR. It is
|
|
1529
|
+
recomputed whenever it is shown or used, never read as authority: an
|
|
1530
|
+
approval stores the id it approved, and a record whose stored approval
|
|
1531
|
+
is not its recomputed id fails to load.
|
|
1532
|
+
"""
|
|
1533
|
+
return "A-" + _action_digest(owner, identity, target, payload)
|
|
1534
|
+
|
|
1535
|
+
|
|
1536
|
+
def operator_action_id(owner: EffectOwner, identity: dict, target: dict, payload: dict) -> str:
|
|
1537
|
+
"""The id of one operator action (K10, #243): ``X-`` and 16 hex digits.
|
|
1538
|
+
|
|
1539
|
+
Computed as :func:`acceptance_action_id` is, over the same four parts
|
|
1540
|
+
(the binding is a precondition and never changes the id), and never
|
|
1541
|
+
stored as authority. Its prefix never matches ``--approve-action``.
|
|
1542
|
+
"""
|
|
1543
|
+
return "X-" + _action_digest(owner, identity, target, payload)
|
|
1544
|
+
|
|
1545
|
+
|
|
1546
|
+
def _action_digest(owner: EffectOwner, identity: dict, target: dict, payload: dict) -> str:
|
|
1547
|
+
canonical = json.dumps(
|
|
1548
|
+
{
|
|
1549
|
+
"owner": owner.to_dict(),
|
|
1550
|
+
"identity": identity,
|
|
1551
|
+
"target": target,
|
|
1552
|
+
"payload": payload,
|
|
1553
|
+
},
|
|
1554
|
+
sort_keys=True,
|
|
1555
|
+
separators=(",", ":"),
|
|
1556
|
+
ensure_ascii=True,
|
|
1557
|
+
)
|
|
1558
|
+
return hashlib.sha256(canonical.encode("ascii")).hexdigest()[:16]
|
|
1559
|
+
|
|
1560
|
+
|
|
1561
|
+
def _cross_acceptance_comment(record: EffectRecord, what: str) -> None:
|
|
1562
|
+
_same(record, what, (record.identity["issue_url"], record.target["issue_url"], "issue"))
|
|
1563
|
+
if not _same_url(record.target["issue_url"], record.owner.issue_url, parse_issue_url):
|
|
1564
|
+
_fail(what, "comments on another issue than its owner's; K9 targets the run's issue")
|
|
1565
|
+
_check_owner_issue_marker(record, ACCEPTANCE, what)
|
|
1566
|
+
claim = _marker_claim(ACCEPTANCE, record.identity["marker"])
|
|
1567
|
+
# The payload is the controller's header, the text the parser accepted
|
|
1568
|
+
# and the marker (``acceptance_comment_body``); the text gets the
|
|
1569
|
+
# parser's rules again, because a resumed write publishes it with no
|
|
1570
|
+
# agent result in between.
|
|
1571
|
+
head = (
|
|
1572
|
+
acceptance_comment_header(
|
|
1573
|
+
record.owner.issue_url, record.owner.pr_url, claim.round, claim.finding_id
|
|
1574
|
+
)
|
|
1575
|
+
+ APPEND_SEPARATOR
|
|
1576
|
+
)
|
|
1577
|
+
tail = APPEND_SEPARATOR + record.identity["marker"]
|
|
1578
|
+
body = record.body
|
|
1579
|
+
if not body.startswith(head) or not body.endswith(tail) or len(body) < len(head) + len(tail):
|
|
1580
|
+
_fail(
|
|
1581
|
+
what,
|
|
1582
|
+
"has a payload body that is not the controller's header, its text and its marker",
|
|
1583
|
+
)
|
|
1584
|
+
text = body[len(head) : len(body) - len(tail)]
|
|
1585
|
+
if text != text.strip():
|
|
1586
|
+
_fail(what, "has an acceptance text that is not in the parser's stored form")
|
|
1587
|
+
try:
|
|
1588
|
+
validate_acceptance_body(text)
|
|
1589
|
+
except ControlResultValidationError as exc:
|
|
1590
|
+
_fail(what, f"has an invalid acceptance text: {exc}")
|
|
1591
|
+
if body != acceptance_comment_body(
|
|
1592
|
+
text, record.owner.issue_url, record.owner.pr_url, claim.round, claim.finding_id
|
|
1593
|
+
):
|
|
1594
|
+
_fail(what, "has a payload body that is not the controller's rendering of its text")
|
|
1595
|
+
authorization = record.precondition["authorization"]
|
|
1596
|
+
approved = record.precondition["approved_action_id"]
|
|
1597
|
+
if approved and approved != record.action_id:
|
|
1598
|
+
# An approval names the bytes and the target it approved: one that
|
|
1599
|
+
# is not this record's id approved something else.
|
|
1600
|
+
_fail(f"{what}.precondition.approved_action_id", "is not the record's action id")
|
|
1601
|
+
if authorization == AUTHORIZATION_POLICY and not approved:
|
|
1602
|
+
_fail(what, "is authorized by policy but records no approved action id")
|
|
1603
|
+
if record.attempts and not approved:
|
|
1604
|
+
_fail(what, "records an issued attempt without an approval of its action id")
|
|
1605
|
+
if record.observed is not None and not parse_comment_url(record.observed["url"]).on(
|
|
1606
|
+
parse_issue_url(record.target["issue_url"])
|
|
1607
|
+
):
|
|
1608
|
+
_fail(what, "is observed on another issue than its target")
|
|
1609
|
+
|
|
1610
|
+
|
|
1611
|
+
def _cross_operator_comment(record: EffectRecord, what: str) -> None:
|
|
1612
|
+
"""K10 (#243): never attempted, a target of the owner's repository, the rendered payload."""
|
|
1613
|
+
if record.stage == Stage.ATTEMPTED or record.attempts != 0:
|
|
1614
|
+
_fail(
|
|
1615
|
+
what,
|
|
1616
|
+
"is an operator-performed operator_comment that records an attempt; the "
|
|
1617
|
+
"controller never issues it",
|
|
1618
|
+
)
|
|
1619
|
+
_same(record, what, (record.identity["issue_url"], record.target["issue_url"], "issue"))
|
|
1620
|
+
target_url = record.target["issue_url"]
|
|
1621
|
+
owner_issue = parse_issue_url(record.owner.issue_url)
|
|
1622
|
+
if _same_url(target_url, record.owner.issue_url, parse_issue_url):
|
|
1623
|
+
_fail(what, "targets its owner's own issue; an operator comment is for another issue")
|
|
1624
|
+
if not parse_issue_url(target_url).same_repository(owner_issue.repository):
|
|
1625
|
+
_fail(what, "targets an issue outside its owner's repository")
|
|
1626
|
+
if not record.owner.pr_url:
|
|
1627
|
+
_fail(what, "has an owner without a PR; an operator comment belongs to a PR's FIX")
|
|
1628
|
+
if parse_issue_url(target_url).number == parse_pr_url(record.owner.pr_url).number:
|
|
1629
|
+
# Issues and PRs share one number space in a repository.
|
|
1630
|
+
_fail(what, "targets its owner's PR; an operator comment is for another issue")
|
|
1631
|
+
_check_owner_issue_marker(record, OPERATOR_COMMENT, what)
|
|
1632
|
+
claim = _marker_claim(OPERATOR_COMMENT, record.identity["marker"])
|
|
1633
|
+
if claim.target.canonical != target_url:
|
|
1634
|
+
_fail(what, "carries a marker whose target is not its target")
|
|
1635
|
+
# The payload is the controller's header, the text the parser accepted
|
|
1636
|
+
# and the marker (``operator_comment_body``); the text gets the parser's
|
|
1637
|
+
# rules again, link rule included, because it is offered to the operator
|
|
1638
|
+
# with no agent result in between.
|
|
1639
|
+
head = (
|
|
1640
|
+
operator_comment_header(
|
|
1641
|
+
record.owner.issue_url, record.owner.pr_url, claim.round, claim.finding_id, target_url
|
|
1642
|
+
)
|
|
1643
|
+
+ APPEND_SEPARATOR
|
|
1644
|
+
)
|
|
1645
|
+
tail = APPEND_SEPARATOR + record.identity["marker"]
|
|
1646
|
+
body = record.body
|
|
1647
|
+
if not body.startswith(head) or not body.endswith(tail) or len(body) < len(head) + len(tail):
|
|
1648
|
+
_fail(
|
|
1649
|
+
what,
|
|
1650
|
+
"has a payload body that is not the controller's header, its text and its marker",
|
|
1651
|
+
)
|
|
1652
|
+
text = body[len(head) : len(body) - len(tail)]
|
|
1653
|
+
if text != text.strip():
|
|
1654
|
+
_fail(what, "has an operator text that is not in the parser's stored form")
|
|
1655
|
+
try:
|
|
1656
|
+
validate_operator_comment_body(text, repository=owner_issue.repository)
|
|
1657
|
+
except ControlResultValidationError as exc:
|
|
1658
|
+
_fail(what, f"has an invalid operator text: {exc}")
|
|
1659
|
+
if body != operator_comment_body(
|
|
1660
|
+
text, record.owner.issue_url, record.owner.pr_url, claim.round, claim.finding_id, target_url
|
|
1661
|
+
):
|
|
1662
|
+
_fail(what, "has a payload body that is not the controller's rendering of its text")
|
|
1663
|
+
target_ref = parse_issue_url(target_url)
|
|
1664
|
+
binding = record.binding
|
|
1665
|
+
if binding is not None and not parse_comment_url(binding["comment_url"]).on(target_ref):
|
|
1666
|
+
_fail(f"{what}.precondition.binding", "names a comment on another issue than its target")
|
|
1667
|
+
observed = record.observed
|
|
1668
|
+
if observed is not None:
|
|
1669
|
+
ref = parse_comment_url(observed["url"])
|
|
1670
|
+
if not ref.on(target_ref):
|
|
1671
|
+
_fail(what, "is observed on another issue than its target")
|
|
1672
|
+
if ref.comment_id != observed["comment_id"]:
|
|
1673
|
+
_fail(f"{what}.observed", "names another comment id than its URL's")
|
|
1674
|
+
if observed["evidence"] == EVIDENCE_EXACT:
|
|
1675
|
+
if observed["body_sha256"] != sha256_text(record.body):
|
|
1676
|
+
_fail(f"{what}.observed", "is exact evidence whose digest is not the payload's")
|
|
1677
|
+
elif binding is None or (
|
|
1678
|
+
binding["comment_id"] != observed["comment_id"]
|
|
1679
|
+
or binding["body_sha256"] != observed["body_sha256"]
|
|
1680
|
+
):
|
|
1681
|
+
_fail(f"{what}.observed", "is bound evidence that is not the recorded binding")
|
|
1682
|
+
|
|
1683
|
+
|
|
1684
|
+
_CROSS_CHECKS: Mapping[EffectKind, Callable[[EffectRecord, str], None]] = {
|
|
1685
|
+
EffectKind.PUSH: _cross_push,
|
|
1686
|
+
EffectKind.IMPLEMENTATION_PR: _cross_implementation_pr,
|
|
1687
|
+
EffectKind.ADOPT_PR: _cross_adopt_pr,
|
|
1688
|
+
EffectKind.REVIEW_COMMENT: _cross_review_comment,
|
|
1689
|
+
EffectKind.FOLLOW_UP_ISSUE: _cross_follow_up_issue,
|
|
1690
|
+
EffectKind.FOLLOW_UP_APPEND: _cross_follow_up_append,
|
|
1691
|
+
EffectKind.REPLACEMENT_PR: _cross_replacement_pr,
|
|
1692
|
+
EffectKind.PROGRESS_COMMENT: _cross_progress_comment,
|
|
1693
|
+
EffectKind.ACCEPTANCE_COMMENT: _cross_acceptance_comment,
|
|
1694
|
+
EffectKind.OPERATOR_COMMENT: _cross_operator_comment,
|
|
1695
|
+
}
|
|
1696
|
+
|
|
1697
|
+
|
|
1698
|
+
def payload_chars(records: Sequence[EffectRecord]) -> int:
|
|
1699
|
+
"""The payload characters D2.4's total bound counts for ``records``."""
|
|
1700
|
+
return sum(len(v) for r in records for v in r.payload.values() if isinstance(v, str))
|
|
1701
|
+
|
|
1702
|
+
|
|
1703
|
+
def load_records(raw: object, binding: Binding) -> tuple[EffectRecord, ...]:
|
|
1704
|
+
"""The persisted plan, validated: positions in order, one owner, D2.4's bounds."""
|
|
1705
|
+
if not isinstance(raw, list):
|
|
1706
|
+
_fail("effect_records", f"must be a list, got {type(raw).__name__}")
|
|
1707
|
+
if len(raw) > MAX_EFFECTS_PER_PLAN:
|
|
1708
|
+
_fail("effect_records", f"holds {len(raw)} records, over {MAX_EFFECTS_PER_PLAN}")
|
|
1709
|
+
records = tuple(
|
|
1710
|
+
EffectRecord.from_dict(item, f"effect_records[{i}]") for i, item in enumerate(raw)
|
|
1711
|
+
)
|
|
1712
|
+
for i, record in enumerate(records):
|
|
1713
|
+
if record.position != i:
|
|
1714
|
+
_fail(f"effect_records[{i}]", f"is at position {record.position}, not {i}")
|
|
1715
|
+
if record.owner != records[0].owner:
|
|
1716
|
+
_fail(f"effect_records[{i}]", "has another owner than the plan's first record")
|
|
1717
|
+
record.owner.check(binding, f"effect_records[{i}]")
|
|
1718
|
+
if sum(1 for r in records if r.kind == EffectKind.PUSH) > 1:
|
|
1719
|
+
_fail("effect_records", "plans more than one push")
|
|
1720
|
+
acceptance = [r.position for r in records if r.kind == EffectKind.ACCEPTANCE_COMMENT]
|
|
1721
|
+
if len(acceptance) > MAX_ACCEPTANCE_ACTIONS_PER_FIX:
|
|
1722
|
+
_fail("effect_records", "plans more than one acceptance comment")
|
|
1723
|
+
if acceptance and acceptance[0] != 0:
|
|
1724
|
+
# K9 first (#211): nothing else of the plan is sent before the one
|
|
1725
|
+
# write that may wait for an operator.
|
|
1726
|
+
_fail("effect_records", "plans an acceptance comment after another effect")
|
|
1727
|
+
operator = [r.position for r in records if r.kind == EffectKind.OPERATOR_COMMENT]
|
|
1728
|
+
if operator and acceptance:
|
|
1729
|
+
_fail("effect_records", "plans an acceptance comment and operator comments together")
|
|
1730
|
+
if len(operator) > MAX_OPERATOR_ACTIONS_PER_FIX:
|
|
1731
|
+
_fail(
|
|
1732
|
+
"effect_records",
|
|
1733
|
+
f"plans {len(operator)} operator comments, over {MAX_OPERATOR_ACTIONS_PER_FIX}",
|
|
1734
|
+
)
|
|
1735
|
+
if operator != list(range(len(operator))):
|
|
1736
|
+
# K10 is the plan's prefix (#243): nothing the controller sends
|
|
1737
|
+
# precedes an action the operator may still decline.
|
|
1738
|
+
_fail("effect_records", "plans an operator comment after another effect")
|
|
1739
|
+
# By claim key, not marker text: GitHub names owners and repositories
|
|
1740
|
+
# case-insensitively, and the key folds that case.
|
|
1741
|
+
keys = [scan(OPERATOR_COMMENT, records[i].marker).claims[0].key for i in operator]
|
|
1742
|
+
if len(set(keys)) != len(keys):
|
|
1743
|
+
_fail("effect_records", "plans two operator comments with one identity")
|
|
1744
|
+
# One comment verifies one action (PR #254 R2-F1): verification never
|
|
1745
|
+
# observes a comment another record of the plan was observed on.
|
|
1746
|
+
consumed: dict[int, int] = {}
|
|
1747
|
+
for i in operator:
|
|
1748
|
+
if records[i].stage is not Stage.OBSERVED:
|
|
1749
|
+
continue
|
|
1750
|
+
cid = int((records[i].observed or {})["comment_id"])
|
|
1751
|
+
if cid in consumed:
|
|
1752
|
+
_fail(
|
|
1753
|
+
"effect_records",
|
|
1754
|
+
f"verifies the operator comments at positions {consumed[cid]} and {i} with one "
|
|
1755
|
+
"comment",
|
|
1756
|
+
)
|
|
1757
|
+
consumed[cid] = i
|
|
1758
|
+
return records
|
|
1759
|
+
|
|
1760
|
+
|
|
1761
|
+
# -- the entry observation (D4.4) -----------------------------------------------------
|
|
1762
|
+
|
|
1763
|
+
# K10's allowed targets (#243): the issues the run's issue body references
|
|
1764
|
+
# (at most ``MAX_LISTED_ISSUES``, as the EPIC reads them) and the run's EPIC.
|
|
1765
|
+
MAX_OPERATOR_TARGETS = MAX_LISTED_ISSUES + 1
|
|
1766
|
+
_OPERATOR_TARGET_KEYS = ("numbers", "allowed_by", "issue_body_sha256")
|
|
1767
|
+
|
|
1768
|
+
|
|
1769
|
+
@dataclass(frozen=True)
|
|
1770
|
+
class OperatorTargets:
|
|
1771
|
+
"""The issues a FIX entry allows operator comments on, read once at entry (#243).
|
|
1772
|
+
|
|
1773
|
+
``numbers`` are issue numbers of the run's repository in order of first
|
|
1774
|
+
mention in the run's issue body, then the EPIC; ``allowed_by`` names why
|
|
1775
|
+
each is allowed (``issue_body`` and/or ``epic``); ``issue_body_sha256``
|
|
1776
|
+
is the digest of the body they were read from. The prompt's target list
|
|
1777
|
+
is rendered from this, and the result is validated against it, so an
|
|
1778
|
+
issue-body edit during the fixer run cannot make the two disagree.
|
|
1779
|
+
"""
|
|
1780
|
+
|
|
1781
|
+
numbers: tuple[int, ...]
|
|
1782
|
+
allowed_by: Mapping[int, tuple[str, ...]]
|
|
1783
|
+
issue_body_sha256: str
|
|
1784
|
+
|
|
1785
|
+
@classmethod
|
|
1786
|
+
def from_dict(cls, raw: object, what: str) -> OperatorTargets:
|
|
1787
|
+
data = _exact(raw, _OPERATOR_TARGET_KEYS, what)
|
|
1788
|
+
numbers = data["numbers"]
|
|
1789
|
+
if not isinstance(numbers, list) or len(numbers) > MAX_OPERATOR_TARGETS:
|
|
1790
|
+
_fail(f"{what}.numbers", f"must be a list of at most {MAX_OPERATOR_TARGETS} numbers")
|
|
1791
|
+
for i, n in enumerate(numbers):
|
|
1792
|
+
_number(n, f"{what}.numbers[{i}]")
|
|
1793
|
+
if len(set(numbers)) != len(numbers):
|
|
1794
|
+
_fail(f"{what}.numbers", "repeats a number")
|
|
1795
|
+
allowed = data["allowed_by"]
|
|
1796
|
+
if not isinstance(allowed, dict) or sorted(allowed) != sorted(str(n) for n in numbers):
|
|
1797
|
+
_fail(f"{what}.allowed_by", "must name exactly the numbers, each by its decimal text")
|
|
1798
|
+
for key, value in allowed.items():
|
|
1799
|
+
_allowed_by(value, f"{what}.allowed_by[{key}]")
|
|
1800
|
+
if sum(1 for v in allowed.values() if ALLOWED_BY_EPIC in v) > 1:
|
|
1801
|
+
_fail(f"{what}.allowed_by", "names more than one EPIC")
|
|
1802
|
+
_digest(data["issue_body_sha256"], f"{what}.issue_body_sha256")
|
|
1803
|
+
return cls(
|
|
1804
|
+
tuple(numbers),
|
|
1805
|
+
{n: tuple(allowed[str(n)]) for n in numbers},
|
|
1806
|
+
data["issue_body_sha256"],
|
|
1807
|
+
)
|
|
1808
|
+
|
|
1809
|
+
def to_dict(self) -> dict:
|
|
1810
|
+
return {
|
|
1811
|
+
"numbers": list(self.numbers),
|
|
1812
|
+
"allowed_by": {str(n): list(self.allowed_by[n]) for n in self.numbers},
|
|
1813
|
+
"issue_body_sha256": self.issue_body_sha256,
|
|
1814
|
+
}
|
|
1815
|
+
|
|
1816
|
+
|
|
1817
|
+
@dataclass(frozen=True)
|
|
1818
|
+
class EntryObservation:
|
|
1819
|
+
"""What a publishing phase read before its first launch, kind-neutral (D4.4).
|
|
1820
|
+
|
|
1821
|
+
``refs`` maps a ``refs/heads/...`` ref to its head SHA, or ``None`` when
|
|
1822
|
+
the ref was absent. ``objects`` maps a marker (the identity text) to the
|
|
1823
|
+
one object that carried it, or ``None`` when none did. ``prs`` maps a
|
|
1824
|
+
ref of ``refs`` whose PRs were read to the one open PR of the repository
|
|
1825
|
+
headed at it, or ``None`` when there was none: the PR a phase may adopt
|
|
1826
|
+
carries no marker yet, so it is recorded by its branch (#161, ADR 0004
|
|
1827
|
+
K3). A ref missing from ``prs`` had its PRs not read, which is not "no
|
|
1828
|
+
PR": a phase that needs the answer fails where it uses it. ``prs`` is
|
|
1829
|
+
stored only when it is not empty, so an observation that read no PR
|
|
1830
|
+
(every phase but ``ANALYZE_EXECUTE``) is stored as before it existed.
|
|
1831
|
+
``operator_targets`` (FIX only, #243) is the allowed set of K10 targets
|
|
1832
|
+
read at entry, or ``None`` when the run offers no operator action
|
|
1833
|
+
(``safety.acceptance_actions: off``, or an entry observed under protocol
|
|
1834
|
+
10); it is stored only when set.
|
|
1835
|
+
"""
|
|
1836
|
+
|
|
1837
|
+
phase: Phase
|
|
1838
|
+
issue_url: str
|
|
1839
|
+
pr_url: str
|
|
1840
|
+
refs: Mapping[str, str | None]
|
|
1841
|
+
base_sha: str | None
|
|
1842
|
+
objects: Mapping[str, str | None]
|
|
1843
|
+
prs: Mapping[str, str | None] = field(default_factory=dict)
|
|
1844
|
+
operator_targets: OperatorTargets | None = None
|
|
1845
|
+
|
|
1846
|
+
_KEYS = ("phase", "issue_url", "pr_url", "refs", "base_sha", "objects")
|
|
1847
|
+
_OPTIONAL_KEYS = ("prs", "operator_targets")
|
|
1848
|
+
|
|
1849
|
+
@classmethod
|
|
1850
|
+
def from_dict(cls, raw: object, binding: Binding) -> EntryObservation:
|
|
1851
|
+
what = "entry_observation"
|
|
1852
|
+
data = _exact(raw, cls._KEYS, what, cls._OPTIONAL_KEYS)
|
|
1853
|
+
phase_text = _str(data["phase"], f"{what}.phase", 64)
|
|
1854
|
+
try:
|
|
1855
|
+
phase = Phase(phase_text)
|
|
1856
|
+
except ValueError:
|
|
1857
|
+
_fail(f"{what}.phase", f"is not a phase: {phase_text!r}")
|
|
1858
|
+
binding.check_phase(phase, what)
|
|
1859
|
+
_issue_url(data["issue_url"], f"{what}.issue_url")
|
|
1860
|
+
binding.check_issue(data["issue_url"], what)
|
|
1861
|
+
pr_url = _str(data["pr_url"], f"{what}.pr_url", MAX_URL_CHARS)
|
|
1862
|
+
if pr_url:
|
|
1863
|
+
_pr_url(pr_url, f"{what}.pr_url")
|
|
1864
|
+
binding.check_pr(pr_url, what)
|
|
1865
|
+
refs = data["refs"]
|
|
1866
|
+
if not isinstance(refs, dict) or len(refs) > MAX_OBSERVED_REFS:
|
|
1867
|
+
_fail(f"{what}.refs", f"must be an object of at most {MAX_OBSERVED_REFS} refs")
|
|
1868
|
+
for ref, sha in refs.items():
|
|
1869
|
+
_ref(ref, f"{what}.refs key")
|
|
1870
|
+
_opt_sha(sha, f"{what}.refs[{ref}]")
|
|
1871
|
+
_opt_sha(data["base_sha"], f"{what}.base_sha")
|
|
1872
|
+
objects = data["objects"]
|
|
1873
|
+
if not isinstance(objects, dict) or len(objects) > MAX_OBSERVED_OBJECTS:
|
|
1874
|
+
_fail(f"{what}.objects", f"must be an object of at most {MAX_OBSERVED_OBJECTS}")
|
|
1875
|
+
for key, url in objects.items():
|
|
1876
|
+
_observed_identity(key, f"{what}.objects key")
|
|
1877
|
+
if url is not None:
|
|
1878
|
+
_object_url(url, f"{what}.objects value")
|
|
1879
|
+
prs = data.get("prs", {})
|
|
1880
|
+
if not isinstance(prs, dict) or len(prs) > MAX_OBSERVED_REFS or ("prs" in data and not prs):
|
|
1881
|
+
_fail(
|
|
1882
|
+
f"{what}.prs",
|
|
1883
|
+
f"must be a non-empty object of at most {MAX_OBSERVED_REFS} refs when stored",
|
|
1884
|
+
)
|
|
1885
|
+
for ref, url in prs.items():
|
|
1886
|
+
_ref(ref, f"{what}.prs key")
|
|
1887
|
+
if ref not in refs:
|
|
1888
|
+
_fail(f"{what}.prs", f"records a PR on {ref}, a ref the observation did not read")
|
|
1889
|
+
if url is not None:
|
|
1890
|
+
_pr_url(url, f"{what}.prs[{ref}]")
|
|
1891
|
+
targets = None
|
|
1892
|
+
if "operator_targets" in data:
|
|
1893
|
+
if phase != Phase.FIX:
|
|
1894
|
+
_fail(f"{what}.operator_targets", "is recorded by a FIX entry only")
|
|
1895
|
+
targets = OperatorTargets.from_dict(
|
|
1896
|
+
data["operator_targets"], f"{what}.operator_targets"
|
|
1897
|
+
)
|
|
1898
|
+
issue = parse_issue_url(data["issue_url"])
|
|
1899
|
+
if issue.number in targets.numbers or (
|
|
1900
|
+
pr_url and parse_pr_url(pr_url).number in targets.numbers
|
|
1901
|
+
):
|
|
1902
|
+
_fail(f"{what}.operator_targets", "allows the run's own issue or PR")
|
|
1903
|
+
return cls(
|
|
1904
|
+
phase,
|
|
1905
|
+
data["issue_url"],
|
|
1906
|
+
pr_url,
|
|
1907
|
+
dict(refs),
|
|
1908
|
+
data["base_sha"],
|
|
1909
|
+
dict(objects),
|
|
1910
|
+
dict(prs),
|
|
1911
|
+
targets,
|
|
1912
|
+
)
|
|
1913
|
+
|
|
1914
|
+
def to_dict(self) -> dict:
|
|
1915
|
+
data = {
|
|
1916
|
+
"phase": self.phase.value,
|
|
1917
|
+
"issue_url": self.issue_url,
|
|
1918
|
+
"pr_url": self.pr_url,
|
|
1919
|
+
"refs": dict(self.refs),
|
|
1920
|
+
"base_sha": self.base_sha,
|
|
1921
|
+
"objects": dict(self.objects),
|
|
1922
|
+
}
|
|
1923
|
+
if self.prs:
|
|
1924
|
+
data["prs"] = dict(self.prs)
|
|
1925
|
+
if self.operator_targets is not None:
|
|
1926
|
+
data["operator_targets"] = self.operator_targets.to_dict()
|
|
1927
|
+
return data
|
|
1928
|
+
|
|
1929
|
+
|
|
1930
|
+
def _observed_identity(value: object, what: str) -> None:
|
|
1931
|
+
text = _str(value, what, MAX_OBSERVED_KEY_CHARS)
|
|
1932
|
+
for kind in (IMPLEMENTATION, REVIEW, PROGRESS, FOLLOW_UP):
|
|
1933
|
+
if kind.pattern.fullmatch(text):
|
|
1934
|
+
_marker_of(kind)(text, what)
|
|
1935
|
+
return
|
|
1936
|
+
if REPLAN_MARKER_RE.fullmatch(text):
|
|
1937
|
+
_replan_marker(text, what)
|
|
1938
|
+
return
|
|
1939
|
+
_fail(what, "must be one controller marker (an identity)")
|
|
1940
|
+
|
|
1941
|
+
|
|
1942
|
+
# -- completion contexts (D4.6) -------------------------------------------------------
|
|
1943
|
+
|
|
1944
|
+
# Per-key overhead counted for each stored value: the key, quotes and
|
|
1945
|
+
# punctuation of its JSON form, generously.
|
|
1946
|
+
_KEY_OVERHEAD = 64
|
|
1947
|
+
|
|
1948
|
+
|
|
1949
|
+
def _context_header(raw: object, keys: Sequence[str], binding: Binding, phase: Phase) -> dict:
|
|
1950
|
+
what = f"{phase.value} completion context"
|
|
1951
|
+
data = _exact(raw, ("phase", *keys), what)
|
|
1952
|
+
if data["phase"] != phase.value:
|
|
1953
|
+
_fail(f"{what}.phase", f"must be {phase.value}")
|
|
1954
|
+
binding.check_phase(phase, what)
|
|
1955
|
+
return data
|
|
1956
|
+
|
|
1957
|
+
|
|
1958
|
+
@dataclass(frozen=True)
|
|
1959
|
+
class AnalyzeContext:
|
|
1960
|
+
"""``ANALYZE_EXECUTE``: no result data; completion persists K1's and K2's/K3's results.
|
|
1961
|
+
|
|
1962
|
+
The plan saved with it is the push (K1) at position 0, then either the
|
|
1963
|
+
implementation PR (K2) on the pushed branch or the adoption (K3), and the
|
|
1964
|
+
push is the one the entry observation explains: its base is the
|
|
1965
|
+
observed default-branch head and its expected old value the observed
|
|
1966
|
+
head of its ref (#161). So is the PR: the observation read the PRs on
|
|
1967
|
+
the pushed ref, a create follows an observation of none there onto the
|
|
1968
|
+
default branch the observation read (its ref, at the base), and an
|
|
1969
|
+
adoption targets the very PR it recorded (ADR 0004 K2, K3), so a PR
|
|
1970
|
+
that appeared or changed after the entry is never adopted from a plan.
|
|
1971
|
+
"""
|
|
1972
|
+
|
|
1973
|
+
issue_url: str
|
|
1974
|
+
|
|
1975
|
+
PHASE = Phase.ANALYZE_EXECUTE
|
|
1976
|
+
STORED_BOUND = MAX_URL_CHARS + 2 * _KEY_OVERHEAD
|
|
1977
|
+
|
|
1978
|
+
@classmethod
|
|
1979
|
+
def from_dict(
|
|
1980
|
+
cls,
|
|
1981
|
+
raw: object,
|
|
1982
|
+
binding: Binding,
|
|
1983
|
+
*,
|
|
1984
|
+
records: Sequence[EffectRecord] = (),
|
|
1985
|
+
observation: EntryObservation | None = None,
|
|
1986
|
+
**_: object,
|
|
1987
|
+
) -> AnalyzeContext:
|
|
1988
|
+
what = "ANALYZE_EXECUTE completion context"
|
|
1989
|
+
data = _context_header(raw, ("issue_url",), binding, cls.PHASE)
|
|
1990
|
+
_issue_url(data["issue_url"], f"{what}.issue_url")
|
|
1991
|
+
binding.check_issue(data["issue_url"], what)
|
|
1992
|
+
kinds = [r.kind for r in records]
|
|
1993
|
+
if kinds not in (
|
|
1994
|
+
[EffectKind.PUSH, EffectKind.IMPLEMENTATION_PR],
|
|
1995
|
+
[EffectKind.PUSH, EffectKind.ADOPT_PR],
|
|
1996
|
+
):
|
|
1997
|
+
_fail(what, "must be saved with a push then a PR create or adoption")
|
|
1998
|
+
if observation is None:
|
|
1999
|
+
_fail(what, "must be saved with the entry observation it is checked against")
|
|
2000
|
+
push = records[0]
|
|
2001
|
+
ref = push.target["ref"]
|
|
2002
|
+
if ref not in observation.refs:
|
|
2003
|
+
_fail(what, "pushes a ref the entry observation did not read")
|
|
2004
|
+
if push.precondition["expected_old"] != observation.refs[ref]:
|
|
2005
|
+
_fail(what, "pushes over another head than the entry observed on its ref")
|
|
2006
|
+
if push.precondition["base_sha"] != observation.base_sha:
|
|
2007
|
+
_fail(what, "pushes a candidate checked against another base than the entry read")
|
|
2008
|
+
second = records[1]
|
|
2009
|
+
if second.kind is EffectKind.IMPLEMENTATION_PR and (
|
|
2010
|
+
"refs/heads/" + str(second.target["head"]) != ref
|
|
2011
|
+
):
|
|
2012
|
+
_fail(what, "opens its PR from another branch than the one it pushes")
|
|
2013
|
+
if second.kind is EffectKind.IMPLEMENTATION_PR:
|
|
2014
|
+
base_ref = "refs/heads/" + str(second.target["base"])
|
|
2015
|
+
if (
|
|
2016
|
+
base_ref == ref
|
|
2017
|
+
or base_ref not in observation.refs
|
|
2018
|
+
or observation.refs[base_ref] != observation.base_sha
|
|
2019
|
+
):
|
|
2020
|
+
_fail(
|
|
2021
|
+
what, "opens its PR onto another branch than the default branch the entry read"
|
|
2022
|
+
)
|
|
2023
|
+
if ref not in observation.prs:
|
|
2024
|
+
_fail(what, "is saved with an entry observation that did not read the PRs on its ref")
|
|
2025
|
+
observed_pr = observation.prs[ref]
|
|
2026
|
+
if second.kind is EffectKind.IMPLEMENTATION_PR and observed_pr is not None:
|
|
2027
|
+
_fail(what, "opens a PR on a branch where the entry observed an open PR")
|
|
2028
|
+
if second.kind is EffectKind.ADOPT_PR and (
|
|
2029
|
+
observed_pr is None
|
|
2030
|
+
or not _same_url(str(second.target["pr_url"]), observed_pr, parse_pr_url)
|
|
2031
|
+
):
|
|
2032
|
+
_fail(what, "adopts another PR than the one the entry observed on its branch")
|
|
2033
|
+
return cls(data["issue_url"])
|
|
2034
|
+
|
|
2035
|
+
def to_dict(self) -> dict:
|
|
2036
|
+
return {"phase": self.PHASE.value, "issue_url": self.issue_url}
|
|
2037
|
+
|
|
2038
|
+
|
|
2039
|
+
@dataclass(frozen=True)
|
|
2040
|
+
class ReviewContext:
|
|
2041
|
+
"""``REVIEW``: the round, ``needs_fix_round``, the findings and the prose sections.
|
|
2042
|
+
|
|
2043
|
+
It is saved with the round's review comment (K4) alone, and the
|
|
2044
|
+
comment's marker is the context's verdict: the same round, the same
|
|
2045
|
+
``needs_fix_round`` and the findings' ids in the same order (#162).
|
|
2046
|
+
The comment's body is exactly what :func:`review_comment_body` renders
|
|
2047
|
+
from the context at the revision the marker binds, and passes
|
|
2048
|
+
:func:`review_comment_problem` as a whole, so every published field of a
|
|
2049
|
+
body that recovery posts, and their composition, was validated under the
|
|
2050
|
+
parser's rules when the state was loaded, not only when the result was
|
|
2051
|
+
accepted.
|
|
2052
|
+
"""
|
|
2053
|
+
|
|
2054
|
+
issue_url: str
|
|
2055
|
+
pr_url: str
|
|
2056
|
+
round: int
|
|
2057
|
+
needs_fix_round: bool
|
|
2058
|
+
findings: tuple[dict, ...]
|
|
2059
|
+
sections: dict[str, str]
|
|
2060
|
+
|
|
2061
|
+
PHASE = Phase.REVIEW
|
|
2062
|
+
# A finding's stored form is bounded by its parser fields.
|
|
2063
|
+
_FINDING_BOUND = 2000 + 200 + 300 + MAX_FINDING_ID_CHARS + 16 + 5 * _KEY_OVERHEAD
|
|
2064
|
+
_SECTIONS_BOUND = (
|
|
2065
|
+
sum(limit + _KEY_OVERHEAD for _, limit in REVIEW_PROSE_SECTIONS) + _KEY_OVERHEAD
|
|
2066
|
+
)
|
|
2067
|
+
STORED_BOUND = (
|
|
2068
|
+
2 * MAX_URL_CHARS
|
|
2069
|
+
+ MAX_FINDINGS_PER_REVIEW * _FINDING_BOUND
|
|
2070
|
+
+ _SECTIONS_BOUND
|
|
2071
|
+
+ 7 * _KEY_OVERHEAD
|
|
2072
|
+
)
|
|
2073
|
+
|
|
2074
|
+
@classmethod
|
|
2075
|
+
def from_dict(
|
|
2076
|
+
cls,
|
|
2077
|
+
raw: object,
|
|
2078
|
+
binding: Binding,
|
|
2079
|
+
*,
|
|
2080
|
+
records: Sequence[EffectRecord] = (),
|
|
2081
|
+
**_: object,
|
|
2082
|
+
) -> ReviewContext:
|
|
2083
|
+
what = "REVIEW completion context"
|
|
2084
|
+
data = _context_header(
|
|
2085
|
+
raw,
|
|
2086
|
+
("issue_url", "pr_url", "round", "needs_fix_round", "findings", "sections"),
|
|
2087
|
+
binding,
|
|
2088
|
+
cls.PHASE,
|
|
2089
|
+
)
|
|
2090
|
+
_issue_url(data["issue_url"], f"{what}.issue_url")
|
|
2091
|
+
binding.check_issue(data["issue_url"], what)
|
|
2092
|
+
_pr_url(data["pr_url"], f"{what}.pr_url")
|
|
2093
|
+
binding.check_pr(data["pr_url"], what)
|
|
2094
|
+
round_ = _int(data["round"], f"{what}.round", minimum=1)
|
|
2095
|
+
if round_ != binding.review_round + 1:
|
|
2096
|
+
_fail(
|
|
2097
|
+
f"{what}.round",
|
|
2098
|
+
f"is {round_} but the round under review is {binding.review_round + 1}",
|
|
2099
|
+
)
|
|
2100
|
+
needs_fix = _bool(data["needs_fix_round"], f"{what}.needs_fix_round")
|
|
2101
|
+
raw_findings = data["findings"]
|
|
2102
|
+
if not isinstance(raw_findings, list) or len(raw_findings) > MAX_FINDINGS_PER_REVIEW:
|
|
2103
|
+
_fail(f"{what}.findings", f"must be a list of at most {MAX_FINDINGS_PER_REVIEW}")
|
|
2104
|
+
parsed_findings: list[Finding] = []
|
|
2105
|
+
findings: list[dict] = []
|
|
2106
|
+
for i, item in enumerate(raw_findings):
|
|
2107
|
+
try:
|
|
2108
|
+
finding = Finding.from_payload(item, round_, i)
|
|
2109
|
+
# The findings were accepted as publishable in the round's comment.
|
|
2110
|
+
check_published_finding(finding)
|
|
2111
|
+
except ControlResultValidationError as exc:
|
|
2112
|
+
_fail(f"{what}.findings[{i}]", f"is invalid: {exc}")
|
|
2113
|
+
parsed = finding.to_dict()
|
|
2114
|
+
if parsed != item:
|
|
2115
|
+
_fail(f"{what}.findings[{i}]", "is not in the parser's stored form")
|
|
2116
|
+
parsed_findings.append(finding)
|
|
2117
|
+
findings.append(parsed)
|
|
2118
|
+
if len({f["id"] for f in findings}) != len(findings):
|
|
2119
|
+
_fail(f"{what}.findings", "repeats a finding id")
|
|
2120
|
+
if needs_fix != bool(findings):
|
|
2121
|
+
_fail(f"{what}.needs_fix_round", "must be true exactly when findings are present")
|
|
2122
|
+
sections = cls._sections(data["sections"], f"{what}.sections")
|
|
2123
|
+
if [r.kind for r in records] != [EffectKind.REVIEW_COMMENT]:
|
|
2124
|
+
_fail(what, "must be saved with exactly one review comment")
|
|
2125
|
+
record = records[0]
|
|
2126
|
+
claim = _marker_claim(REVIEW, record.identity["marker"])
|
|
2127
|
+
if (
|
|
2128
|
+
claim.round != round_
|
|
2129
|
+
or claim.needs_fix_round != needs_fix
|
|
2130
|
+
or claim.finding_ids != tuple(f["id"] for f in findings)
|
|
2131
|
+
):
|
|
2132
|
+
_fail(what, "disagrees with its review comment's marker on the round's verdict")
|
|
2133
|
+
# A resumed create posts the body with no reviewer result in between
|
|
2134
|
+
# (D4.6), so the body must be the controller's rendering of what was
|
|
2135
|
+
# just validated, at the revision its marker binds: nothing else in it
|
|
2136
|
+
# is the reviewer's, and nothing in it escaped the parser's rules.
|
|
2137
|
+
# The record's own check has already refused a marker without a base.
|
|
2138
|
+
result = ReviewResult(
|
|
2139
|
+
round=round_,
|
|
2140
|
+
reviewed_head_sha=claim.reviewed_head_sha,
|
|
2141
|
+
needs_fix_round=needs_fix,
|
|
2142
|
+
findings=parsed_findings,
|
|
2143
|
+
sections=sections,
|
|
2144
|
+
)
|
|
2145
|
+
binding_args = (
|
|
2146
|
+
claim.reviewed_head_sha,
|
|
2147
|
+
claim.reviewed_base_ref or "",
|
|
2148
|
+
claim.reviewed_merge_base_sha or "",
|
|
2149
|
+
)
|
|
2150
|
+
if record.body != review_comment_body(result, *binding_args):
|
|
2151
|
+
_fail(
|
|
2152
|
+
what,
|
|
2153
|
+
"is saved with a review comment whose body is not the comment the controller "
|
|
2154
|
+
"renders from it",
|
|
2155
|
+
)
|
|
2156
|
+
# Fields valid one by one can still compose a comment the result path
|
|
2157
|
+
# refuses (an unclosed fence closed by a later field's); such a plan
|
|
2158
|
+
# was never accepted, so it is refused, never replayed.
|
|
2159
|
+
problem = review_comment_problem(result, *binding_args)
|
|
2160
|
+
if problem:
|
|
2161
|
+
_fail(what, f"is saved with a review comment the controller may not publish: {problem}")
|
|
2162
|
+
return cls(data["issue_url"], data["pr_url"], round_, needs_fix, tuple(findings), sections)
|
|
2163
|
+
|
|
2164
|
+
@staticmethod
|
|
2165
|
+
def _sections(raw: object, what: str) -> dict[str, str]:
|
|
2166
|
+
"""The prose sections, each in the stored form the parser returns for it."""
|
|
2167
|
+
data = _exact(raw, tuple(key for key, _ in REVIEW_PROSE_SECTIONS), what)
|
|
2168
|
+
sections: dict[str, str] = {}
|
|
2169
|
+
for key, _ in REVIEW_PROSE_SECTIONS:
|
|
2170
|
+
try:
|
|
2171
|
+
parsed = validate_review_section(key, data[key])
|
|
2172
|
+
except ControlResultValidationError as exc:
|
|
2173
|
+
_fail(f"{what}.{key}", f"is invalid: {exc}")
|
|
2174
|
+
if parsed != data[key]:
|
|
2175
|
+
_fail(f"{what}.{key}", "is not in the parser's stored form")
|
|
2176
|
+
sections[key] = parsed
|
|
2177
|
+
return sections
|
|
2178
|
+
|
|
2179
|
+
def to_dict(self) -> dict:
|
|
2180
|
+
return {
|
|
2181
|
+
"phase": self.PHASE.value,
|
|
2182
|
+
"issue_url": self.issue_url,
|
|
2183
|
+
"pr_url": self.pr_url,
|
|
2184
|
+
"round": self.round,
|
|
2185
|
+
"needs_fix_round": self.needs_fix_round,
|
|
2186
|
+
"findings": [dict(f) for f in self.findings],
|
|
2187
|
+
"sections": dict(self.sections),
|
|
2188
|
+
}
|
|
2189
|
+
|
|
2190
|
+
|
|
2191
|
+
FOLLOW_UP_SOURCE_ENTRY = "entry"
|
|
2192
|
+
# D4.6: the redacted form of a FIX rationale, at most this long.
|
|
2193
|
+
MAX_STORED_RATIONALE_CHARS = MAX_FIX_RATIONALE_CHARS * MAX_GROWTH_FACTOR
|
|
2194
|
+
|
|
2195
|
+
|
|
2196
|
+
@dataclass(frozen=True)
|
|
2197
|
+
class FixContext:
|
|
2198
|
+
"""``FIX``: one resolution per open finding, rationales in their redacted form.
|
|
2199
|
+
|
|
2200
|
+
``follow_up_source`` names where a deferred finding's follow-up comes
|
|
2201
|
+
from (D4.6), ``acceptance_source`` the plan position of the K9 record
|
|
2202
|
+
that publishes an ``acceptance_action`` finding's comment (#211), and
|
|
2203
|
+
``operator_sources`` the plan positions of the K10 records of an
|
|
2204
|
+
``operator_acceptance`` finding (#243); each is set exactly on the
|
|
2205
|
+
resolutions of its kind.
|
|
2206
|
+
"""
|
|
2207
|
+
|
|
2208
|
+
issue_url: str
|
|
2209
|
+
pr_url: str
|
|
2210
|
+
round: int
|
|
2211
|
+
resolutions: tuple[dict, ...]
|
|
2212
|
+
|
|
2213
|
+
PHASE = Phase.FIX
|
|
2214
|
+
_RESOLUTION_KEYS = (
|
|
2215
|
+
"finding_id",
|
|
2216
|
+
"resolution",
|
|
2217
|
+
"rationale",
|
|
2218
|
+
"commit_sha",
|
|
2219
|
+
"follow_up_source",
|
|
2220
|
+
"acceptance_source",
|
|
2221
|
+
"operator_sources",
|
|
2222
|
+
)
|
|
2223
|
+
_RESOLUTION_BOUND = (
|
|
2224
|
+
MAX_STORED_RATIONALE_CHARS
|
|
2225
|
+
+ MAX_FINDING_ID_CHARS
|
|
2226
|
+
+ 40
|
|
2227
|
+
+ 32
|
|
2228
|
+
+ 16
|
|
2229
|
+
+ 16
|
|
2230
|
+
+ (MAX_OPERATOR_ACTIONS_PER_FIX * 16)
|
|
2231
|
+
+ (7 * _KEY_OVERHEAD)
|
|
2232
|
+
)
|
|
2233
|
+
STORED_BOUND = (
|
|
2234
|
+
2 * MAX_URL_CHARS + MAX_RESOLUTIONS_PER_FIX * _RESOLUTION_BOUND + (5 * _KEY_OVERHEAD)
|
|
2235
|
+
)
|
|
2236
|
+
|
|
2237
|
+
@classmethod
|
|
2238
|
+
def from_dict(
|
|
2239
|
+
cls,
|
|
2240
|
+
raw: object,
|
|
2241
|
+
binding: Binding,
|
|
2242
|
+
*,
|
|
2243
|
+
records: Sequence[EffectRecord] = (),
|
|
2244
|
+
observation: EntryObservation | None = None,
|
|
2245
|
+
**_: object,
|
|
2246
|
+
) -> FixContext:
|
|
2247
|
+
what = "FIX completion context"
|
|
2248
|
+
data = _context_header(
|
|
2249
|
+
raw, ("issue_url", "pr_url", "round", "resolutions"), binding, cls.PHASE
|
|
2250
|
+
)
|
|
2251
|
+
_issue_url(data["issue_url"], f"{what}.issue_url")
|
|
2252
|
+
binding.check_issue(data["issue_url"], what)
|
|
2253
|
+
_pr_url(data["pr_url"], f"{what}.pr_url")
|
|
2254
|
+
binding.check_pr(data["pr_url"], what)
|
|
2255
|
+
round_ = _int(data["round"], f"{what}.round", minimum=1)
|
|
2256
|
+
if round_ != binding.review_round:
|
|
2257
|
+
_fail(
|
|
2258
|
+
f"{what}.round", f"is {round_} but the round being fixed is {binding.review_round}"
|
|
2259
|
+
)
|
|
2260
|
+
raw_resolutions = data["resolutions"]
|
|
2261
|
+
if not isinstance(raw_resolutions, list) or len(raw_resolutions) > MAX_RESOLUTIONS_PER_FIX:
|
|
2262
|
+
_fail(f"{what}.resolutions", f"must be a list of at most {MAX_RESOLUTIONS_PER_FIX}")
|
|
2263
|
+
resolutions = [
|
|
2264
|
+
cls._resolution(item, f"{what}.resolutions[{i}]")
|
|
2265
|
+
for i, item in enumerate(raw_resolutions)
|
|
2266
|
+
]
|
|
2267
|
+
ids = [r["finding_id"] for r in resolutions]
|
|
2268
|
+
if len(set(ids)) != len(ids):
|
|
2269
|
+
_fail(f"{what}.resolutions", "resolves a finding twice")
|
|
2270
|
+
if sorted(ids) != sorted(binding.open_finding_ids):
|
|
2271
|
+
_fail(f"{what}.resolutions", "must resolve exactly the open findings")
|
|
2272
|
+
cls._check_sources(resolutions, data["pr_url"], records, observation, what)
|
|
2273
|
+
cls._check_acceptance(resolutions, data["issue_url"], data["pr_url"], round_, records, what)
|
|
2274
|
+
cls._check_operator(resolutions, round_, records, observation, what)
|
|
2275
|
+
return cls(data["issue_url"], data["pr_url"], round_, tuple(resolutions))
|
|
2276
|
+
|
|
2277
|
+
@classmethod
|
|
2278
|
+
def _resolution(cls, raw: object, what: str) -> dict:
|
|
2279
|
+
data = _exact(raw, cls._RESOLUTION_KEYS, what)
|
|
2280
|
+
fid = _str(data["finding_id"], f"{what}.finding_id", MAX_FINDING_ID_CHARS)
|
|
2281
|
+
if not FINDING_ID_RE.match(fid):
|
|
2282
|
+
_fail(f"{what}.finding_id", "is not a finding id")
|
|
2283
|
+
resolution = data["resolution"]
|
|
2284
|
+
if resolution not in FIX_RESOLUTIONS:
|
|
2285
|
+
_fail(f"{what}.resolution", f"must be one of {FIX_RESOLUTIONS}")
|
|
2286
|
+
rationale = _str(data["rationale"], f"{what}.rationale", MAX_STORED_RATIONALE_CHARS)
|
|
2287
|
+
if _RATIONALE_CONTROL_RE.search(rationale):
|
|
2288
|
+
_fail(f"{what}.rationale", "carries a control character other than a newline or a tab")
|
|
2289
|
+
commit = _str(data["commit_sha"], f"{what}.commit_sha", 40)
|
|
2290
|
+
if commit:
|
|
2291
|
+
_sha(commit, f"{what}.commit_sha")
|
|
2292
|
+
source = data["follow_up_source"]
|
|
2293
|
+
if source is not None and source != FOLLOW_UP_SOURCE_ENTRY:
|
|
2294
|
+
_int(source, f"{what}.follow_up_source", 0, MAX_EFFECTS_PER_PLAN - 1)
|
|
2295
|
+
if (resolution == "follow_up_created") != (source is not None):
|
|
2296
|
+
_fail(f"{what}.follow_up_source", "is set exactly when the finding is deferred")
|
|
2297
|
+
acceptance = data["acceptance_source"]
|
|
2298
|
+
if acceptance is not None:
|
|
2299
|
+
_int(acceptance, f"{what}.acceptance_source", 0, MAX_EFFECTS_PER_PLAN - 1)
|
|
2300
|
+
if (resolution == "acceptance_action") != (acceptance is not None):
|
|
2301
|
+
_fail(
|
|
2302
|
+
f"{what}.acceptance_source",
|
|
2303
|
+
"is set exactly when the finding is resolved by an acceptance action",
|
|
2304
|
+
)
|
|
2305
|
+
operator = data["operator_sources"]
|
|
2306
|
+
if operator is not None:
|
|
2307
|
+
if (
|
|
2308
|
+
not isinstance(operator, list)
|
|
2309
|
+
or not 1 <= len(operator) <= MAX_OPERATOR_ACTIONS_PER_FIX
|
|
2310
|
+
):
|
|
2311
|
+
_fail(
|
|
2312
|
+
f"{what}.operator_sources",
|
|
2313
|
+
f"must be a list of 1 to {MAX_OPERATOR_ACTIONS_PER_FIX} plan positions",
|
|
2314
|
+
)
|
|
2315
|
+
for i, position in enumerate(operator):
|
|
2316
|
+
_int(position, f"{what}.operator_sources[{i}]", 0, MAX_EFFECTS_PER_PLAN - 1)
|
|
2317
|
+
if len(set(operator)) != len(operator):
|
|
2318
|
+
_fail(f"{what}.operator_sources", "repeats a plan position")
|
|
2319
|
+
if (resolution == "operator_acceptance") != (operator is not None):
|
|
2320
|
+
_fail(
|
|
2321
|
+
f"{what}.operator_sources",
|
|
2322
|
+
"is set exactly when the finding is resolved by operator actions",
|
|
2323
|
+
)
|
|
2324
|
+
return dict(data)
|
|
2325
|
+
|
|
2326
|
+
@staticmethod
|
|
2327
|
+
def _check_operator(
|
|
2328
|
+
resolutions: Sequence[dict],
|
|
2329
|
+
round_: int,
|
|
2330
|
+
records: Sequence[EffectRecord],
|
|
2331
|
+
observation: EntryObservation | None,
|
|
2332
|
+
what: str,
|
|
2333
|
+
) -> None:
|
|
2334
|
+
"""#243: each ``operator_acceptance`` names its K10 records, each K10 one finding.
|
|
2335
|
+
|
|
2336
|
+
Every K10 is of this round and an open finding, and its target is
|
|
2337
|
+
in the allowed set the FIX entry recorded, for the reasons and from
|
|
2338
|
+
the issue body digest recorded there.
|
|
2339
|
+
"""
|
|
2340
|
+
named: set[int] = set()
|
|
2341
|
+
for resolution in resolutions:
|
|
2342
|
+
sources = resolution["operator_sources"]
|
|
2343
|
+
if sources is None:
|
|
2344
|
+
continue
|
|
2345
|
+
fid = resolution["finding_id"]
|
|
2346
|
+
label = f"{what} resolution for {fid}"
|
|
2347
|
+
for source in sources:
|
|
2348
|
+
if source >= len(records):
|
|
2349
|
+
_fail(label, f"names plan position {source}, which the plan does not hold")
|
|
2350
|
+
record = records[source]
|
|
2351
|
+
if record.kind != EffectKind.OPERATOR_COMMENT:
|
|
2352
|
+
_fail(label, f"names effect {source}, which is not an operator comment")
|
|
2353
|
+
claim = _marker_claim(OPERATOR_COMMENT, record.marker)
|
|
2354
|
+
if claim.finding_id != fid or claim.round != round_:
|
|
2355
|
+
_fail(
|
|
2356
|
+
label,
|
|
2357
|
+
f"names operator effect {source}, which is another finding's or round's",
|
|
2358
|
+
)
|
|
2359
|
+
named.add(source)
|
|
2360
|
+
targets = observation.operator_targets if observation is not None else None
|
|
2361
|
+
for record in records:
|
|
2362
|
+
if record.kind != EffectKind.OPERATOR_COMMENT:
|
|
2363
|
+
continue
|
|
2364
|
+
where = f"effect_records[{record.position}]"
|
|
2365
|
+
if record.position not in named:
|
|
2366
|
+
_fail(where, "is an operator comment no resolution of the context names")
|
|
2367
|
+
if targets is None:
|
|
2368
|
+
_fail(
|
|
2369
|
+
where, "is an operator comment, but the FIX entry recorded no allowed targets"
|
|
2370
|
+
)
|
|
2371
|
+
number = parse_issue_url(record.target["issue_url"]).number
|
|
2372
|
+
if number not in targets.numbers:
|
|
2373
|
+
_fail(where, f"targets issue #{number}, which the FIX entry did not allow")
|
|
2374
|
+
if list(targets.allowed_by[number]) != record.precondition["allowed_by"] or (
|
|
2375
|
+
targets.issue_body_sha256 != record.precondition["issue_body_sha256"]
|
|
2376
|
+
):
|
|
2377
|
+
_fail(where, "records another allowed set than the FIX entry's")
|
|
2378
|
+
|
|
2379
|
+
@staticmethod
|
|
2380
|
+
def _check_acceptance(
|
|
2381
|
+
resolutions: Sequence[dict],
|
|
2382
|
+
issue_url: str,
|
|
2383
|
+
pr_url: str,
|
|
2384
|
+
round_: int,
|
|
2385
|
+
records: Sequence[EffectRecord],
|
|
2386
|
+
what: str,
|
|
2387
|
+
) -> None:
|
|
2388
|
+
"""#211: each ``acceptance_action`` names its K9 record, and each K9 one finding."""
|
|
2389
|
+
named: set[int] = set()
|
|
2390
|
+
for resolution in resolutions:
|
|
2391
|
+
source = resolution["acceptance_source"]
|
|
2392
|
+
if source is None:
|
|
2393
|
+
continue
|
|
2394
|
+
fid = resolution["finding_id"]
|
|
2395
|
+
label = f"{what} resolution for {fid}"
|
|
2396
|
+
if source >= len(records):
|
|
2397
|
+
_fail(label, f"names plan position {source}, which the plan does not hold")
|
|
2398
|
+
record = records[source]
|
|
2399
|
+
if record.kind != EffectKind.ACCEPTANCE_COMMENT:
|
|
2400
|
+
_fail(label, f"names effect {source}, which is not an acceptance comment")
|
|
2401
|
+
marker = render_acceptance_marker(issue_url, pr_url, round_, fid)
|
|
2402
|
+
if record.identity["marker"] != marker:
|
|
2403
|
+
_fail(
|
|
2404
|
+
label,
|
|
2405
|
+
f"names acceptance effect {source}, which is another finding's or round's",
|
|
2406
|
+
)
|
|
2407
|
+
named.add(source)
|
|
2408
|
+
for record in records:
|
|
2409
|
+
if record.kind == EffectKind.ACCEPTANCE_COMMENT and record.position not in named:
|
|
2410
|
+
_fail(
|
|
2411
|
+
f"effect_records[{record.position}]",
|
|
2412
|
+
"is an acceptance comment no resolution of the context names",
|
|
2413
|
+
)
|
|
2414
|
+
|
|
2415
|
+
@staticmethod
|
|
2416
|
+
def _check_sources(
|
|
2417
|
+
resolutions: Sequence[dict],
|
|
2418
|
+
pr_url: str,
|
|
2419
|
+
records: Sequence[EffectRecord],
|
|
2420
|
+
observation: EntryObservation | None,
|
|
2421
|
+
what: str,
|
|
2422
|
+
) -> None:
|
|
2423
|
+
"""D4.6: each deferred finding has exactly one source, fixed at the plan."""
|
|
2424
|
+
from .claims import render_follow_up_marker
|
|
2425
|
+
|
|
2426
|
+
observed = dict(observation.objects) if observation is not None else {}
|
|
2427
|
+
own: dict[str, bool] = {}
|
|
2428
|
+
for resolution in resolutions:
|
|
2429
|
+
marker = render_follow_up_marker(pr_url, resolution["finding_id"])
|
|
2430
|
+
own[marker] = observed.get(marker) is not None
|
|
2431
|
+
block_markers = {
|
|
2432
|
+
m
|
|
2433
|
+
for r in records
|
|
2434
|
+
if r.kind == EffectKind.FOLLOW_UP_APPEND
|
|
2435
|
+
for m in r.identity["markers"]
|
|
2436
|
+
}
|
|
2437
|
+
for resolution in resolutions:
|
|
2438
|
+
fid = resolution["finding_id"]
|
|
2439
|
+
marker = render_follow_up_marker(pr_url, fid)
|
|
2440
|
+
source = resolution["follow_up_source"]
|
|
2441
|
+
label = f"{what} resolution for {fid}"
|
|
2442
|
+
if own[marker]:
|
|
2443
|
+
if source != FOLLOW_UP_SOURCE_ENTRY:
|
|
2444
|
+
_fail(label, "must reuse the finding's own follow-up through 'entry'")
|
|
2445
|
+
if marker in block_markers:
|
|
2446
|
+
_fail(label, "is the marker of a reused follow-up, which no block carries")
|
|
2447
|
+
continue
|
|
2448
|
+
if source == FOLLOW_UP_SOURCE_ENTRY:
|
|
2449
|
+
_fail(label, "names 'entry' but the entry observation holds no follow-up for it")
|
|
2450
|
+
if source is None:
|
|
2451
|
+
continue
|
|
2452
|
+
assert isinstance(source, int)
|
|
2453
|
+
if source >= len(records):
|
|
2454
|
+
_fail(label, f"names plan position {source}, which the plan does not hold")
|
|
2455
|
+
record = records[source]
|
|
2456
|
+
if record.kind == EffectKind.FOLLOW_UP_ISSUE:
|
|
2457
|
+
if record.identity["marker"] != marker:
|
|
2458
|
+
_fail(label, f"names follow-up effect {source}, which is another finding's")
|
|
2459
|
+
elif record.kind == EffectKind.FOLLOW_UP_APPEND:
|
|
2460
|
+
if marker not in record.identity["markers"]:
|
|
2461
|
+
_fail(label, f"names append effect {source}, whose block lacks its marker")
|
|
2462
|
+
else:
|
|
2463
|
+
_fail(label, f"names effect {source}, which is not a follow-up effect")
|
|
2464
|
+
|
|
2465
|
+
def to_dict(self) -> dict:
|
|
2466
|
+
return {
|
|
2467
|
+
"phase": self.PHASE.value,
|
|
2468
|
+
"issue_url": self.issue_url,
|
|
2469
|
+
"pr_url": self.pr_url,
|
|
2470
|
+
"round": self.round,
|
|
2471
|
+
"resolutions": [dict(r) for r in self.resolutions],
|
|
2472
|
+
}
|
|
2473
|
+
|
|
2474
|
+
|
|
2475
|
+
@dataclass(frozen=True)
|
|
2476
|
+
class ReplanContext:
|
|
2477
|
+
"""``REPLAN_REEXECUTE``: the counts the K7 marker renders (#164).
|
|
2478
|
+
|
|
2479
|
+
The plan saved with it is the push (K1) of the candidate to the
|
|
2480
|
+
controller-derived replacement branch, compared against "absent", at
|
|
2481
|
+
position 0, then the replacement PR (K7) from that branch, and both are
|
|
2482
|
+
the ones the entry observation explains: the observation recorded the
|
|
2483
|
+
branch absent and the default branch at the base the candidate was
|
|
2484
|
+
checked against, and the PR is opened onto that default branch. The
|
|
2485
|
+
transaction marker the PR carries attests exactly these counts and
|
|
2486
|
+
passed tests, for the state's transaction and execution attempt.
|
|
2487
|
+
"""
|
|
2488
|
+
|
|
2489
|
+
issue_url: str
|
|
2490
|
+
pr_url: str
|
|
2491
|
+
transaction_id: str
|
|
2492
|
+
historical_findings_considered: int
|
|
2493
|
+
unique_failure_constraints: int
|
|
2494
|
+
|
|
2495
|
+
PHASE = Phase.REPLAN_REEXECUTE
|
|
2496
|
+
STORED_BOUND = 2 * MAX_URL_CHARS + 32 + 2 * 20 + 6 * _KEY_OVERHEAD
|
|
2497
|
+
|
|
2498
|
+
@classmethod
|
|
2499
|
+
def from_dict(
|
|
2500
|
+
cls,
|
|
2501
|
+
raw: object,
|
|
2502
|
+
binding: Binding,
|
|
2503
|
+
*,
|
|
2504
|
+
records: Sequence[EffectRecord] = (),
|
|
2505
|
+
observation: EntryObservation | None = None,
|
|
2506
|
+
**_: object,
|
|
2507
|
+
) -> ReplanContext:
|
|
2508
|
+
what = "REPLAN_REEXECUTE completion context"
|
|
2509
|
+
data = _context_header(
|
|
2510
|
+
raw,
|
|
2511
|
+
(
|
|
2512
|
+
"issue_url",
|
|
2513
|
+
"pr_url",
|
|
2514
|
+
"transaction_id",
|
|
2515
|
+
"historical_findings_considered",
|
|
2516
|
+
"unique_failure_constraints",
|
|
2517
|
+
),
|
|
2518
|
+
binding,
|
|
2519
|
+
cls.PHASE,
|
|
2520
|
+
)
|
|
2521
|
+
_issue_url(data["issue_url"], f"{what}.issue_url")
|
|
2522
|
+
binding.check_issue(data["issue_url"], what)
|
|
2523
|
+
_pr_url(data["pr_url"], f"{what}.pr_url")
|
|
2524
|
+
binding.check_pr(data["pr_url"], what)
|
|
2525
|
+
txn = _str(data["transaction_id"], f"{what}.transaction_id", 32)
|
|
2526
|
+
if not TRANSACTION_ID_RE.match(txn) or txn != binding.transaction_id:
|
|
2527
|
+
_fail(f"{what}.transaction_id", "is not the state's replan transaction id")
|
|
2528
|
+
context = cls(
|
|
2529
|
+
data["issue_url"],
|
|
2530
|
+
data["pr_url"],
|
|
2531
|
+
txn,
|
|
2532
|
+
_int(data["historical_findings_considered"], f"{what}.historical_findings_considered"),
|
|
2533
|
+
_int(data["unique_failure_constraints"], f"{what}.unique_failure_constraints"),
|
|
2534
|
+
)
|
|
2535
|
+
if context.unique_failure_constraints > context.historical_findings_considered:
|
|
2536
|
+
_fail(what, "counts more unique constraints than findings considered")
|
|
2537
|
+
if [r.kind for r in records] != [EffectKind.PUSH, EffectKind.REPLACEMENT_PR]:
|
|
2538
|
+
_fail(what, "must be saved with a push then the replacement PR")
|
|
2539
|
+
if observation is None:
|
|
2540
|
+
_fail(what, "must be saved with the entry observation it is checked against")
|
|
2541
|
+
push, create = records
|
|
2542
|
+
ref = push.target["ref"]
|
|
2543
|
+
if ref not in observation.refs or observation.refs[ref] is not None:
|
|
2544
|
+
_fail(what, "pushes a ref the entry observation did not read as absent")
|
|
2545
|
+
if push.precondition["expected_old"] is not None:
|
|
2546
|
+
_fail(what, "pushes over a head; the replacement branch is compared against absent")
|
|
2547
|
+
if push.precondition["base_sha"] != observation.base_sha:
|
|
2548
|
+
_fail(what, "pushes a candidate checked against another base than the entry read")
|
|
2549
|
+
if "refs/heads/" + str(create.target["head"]) != ref:
|
|
2550
|
+
_fail(what, "opens its PR from another branch than the one it pushes")
|
|
2551
|
+
base_ref = "refs/heads/" + str(create.target["base"])
|
|
2552
|
+
if (
|
|
2553
|
+
base_ref == ref
|
|
2554
|
+
or base_ref not in observation.refs
|
|
2555
|
+
or observation.refs[base_ref] != observation.base_sha
|
|
2556
|
+
):
|
|
2557
|
+
_fail(what, "opens its PR onto another branch than the default branch the entry read")
|
|
2558
|
+
attestation = scan_replan_markers(create.identity["transaction_marker"]).attestations[0]
|
|
2559
|
+
if (
|
|
2560
|
+
attestation.findings_considered != context.historical_findings_considered
|
|
2561
|
+
or attestation.unique_constraints != context.unique_failure_constraints
|
|
2562
|
+
):
|
|
2563
|
+
_fail(what, "is saved with a transaction marker that attests other counts")
|
|
2564
|
+
return context
|
|
2565
|
+
|
|
2566
|
+
def to_dict(self) -> dict:
|
|
2567
|
+
return {
|
|
2568
|
+
"phase": self.PHASE.value,
|
|
2569
|
+
"issue_url": self.issue_url,
|
|
2570
|
+
"pr_url": self.pr_url,
|
|
2571
|
+
"transaction_id": self.transaction_id,
|
|
2572
|
+
"historical_findings_considered": self.historical_findings_considered,
|
|
2573
|
+
"unique_failure_constraints": self.unique_failure_constraints,
|
|
2574
|
+
}
|
|
2575
|
+
|
|
2576
|
+
|
|
2577
|
+
@dataclass(frozen=True)
|
|
2578
|
+
class UpdateEpicContext:
|
|
2579
|
+
"""``UPDATE_EPIC``: the roadmap section, the selection, and two digests (D4.6).
|
|
2580
|
+
|
|
2581
|
+
``roadmap_section`` is the section when one is required, else ``None``.
|
|
2582
|
+
``next_issue_url`` is the validated selection, or ``None`` for "the EPIC
|
|
2583
|
+
is complete". The digests are SHA-256 of the EPIC body outside the
|
|
2584
|
+
roadmap markers: as the entry read before the result's launch saw it,
|
|
2585
|
+
and as the splice of this section into that body leaves it. They are
|
|
2586
|
+
present exactly when the section is.
|
|
2587
|
+
|
|
2588
|
+
A re-request (D4.7) starts from a persisted rejection: ``selection_void``
|
|
2589
|
+
or ``section_void`` marks the input the rejection voided, and a void
|
|
2590
|
+
input is ``None``.
|
|
2591
|
+
"""
|
|
2592
|
+
|
|
2593
|
+
issue_url: str
|
|
2594
|
+
pr_url: str
|
|
2595
|
+
roadmap_section: str | None
|
|
2596
|
+
next_issue_url: str | None
|
|
2597
|
+
entry_outside_sha256: str | None
|
|
2598
|
+
spliced_outside_sha256: str | None
|
|
2599
|
+
selection_void: bool = False
|
|
2600
|
+
section_void: bool = False
|
|
2601
|
+
|
|
2602
|
+
PHASE = Phase.UPDATE_EPIC
|
|
2603
|
+
_KEYS = (
|
|
2604
|
+
"issue_url",
|
|
2605
|
+
"pr_url",
|
|
2606
|
+
"roadmap_section",
|
|
2607
|
+
"next_issue_url",
|
|
2608
|
+
"entry_outside_sha256",
|
|
2609
|
+
"spliced_outside_sha256",
|
|
2610
|
+
"selection_void",
|
|
2611
|
+
"section_void",
|
|
2612
|
+
)
|
|
2613
|
+
STORED_BOUND = MAX_ROADMAP_SECTION_CHARS + 3 * MAX_URL_CHARS + 2 * 64 + 9 * _KEY_OVERHEAD
|
|
2614
|
+
|
|
2615
|
+
@classmethod
|
|
2616
|
+
def from_dict(
|
|
2617
|
+
cls,
|
|
2618
|
+
raw: object,
|
|
2619
|
+
binding: Binding,
|
|
2620
|
+
*,
|
|
2621
|
+
records: Sequence[EffectRecord] = (),
|
|
2622
|
+
observation: EntryObservation | None = None,
|
|
2623
|
+
**_: object,
|
|
2624
|
+
) -> UpdateEpicContext:
|
|
2625
|
+
what = "UPDATE_EPIC completion context"
|
|
2626
|
+
data = _context_header(raw, cls._KEYS, binding, cls.PHASE)
|
|
2627
|
+
_issue_url(data["issue_url"], f"{what}.issue_url")
|
|
2628
|
+
binding.check_issue(data["issue_url"], what)
|
|
2629
|
+
_pr_url(data["pr_url"], f"{what}.pr_url")
|
|
2630
|
+
binding.check_pr(data["pr_url"], what)
|
|
2631
|
+
section = data["roadmap_section"]
|
|
2632
|
+
if section is not None:
|
|
2633
|
+
_str(section, f"{what}.roadmap_section", MAX_ROADMAP_SECTION_CHARS)
|
|
2634
|
+
if not section.strip():
|
|
2635
|
+
_fail(f"{what}.roadmap_section", "must be non-empty when present")
|
|
2636
|
+
if redact(section) != section:
|
|
2637
|
+
_fail(f"{what}.roadmap_section", "is not redaction-invariant")
|
|
2638
|
+
# A parsed-form value gets its parser's rules again (D4.6): the
|
|
2639
|
+
# recovery that completes from this context writes the section
|
|
2640
|
+
# into the EPIC body with no agent result in between.
|
|
2641
|
+
try:
|
|
2642
|
+
validate_roadmap_section(section)
|
|
2643
|
+
except ControlResultValidationError as exc:
|
|
2644
|
+
_fail(f"{what}.roadmap_section", f"is invalid: {exc}")
|
|
2645
|
+
_opt_issue_url(data["next_issue_url"], f"{what}.next_issue_url")
|
|
2646
|
+
_opt_digest(data["entry_outside_sha256"], f"{what}.entry_outside_sha256")
|
|
2647
|
+
_opt_digest(data["spliced_outside_sha256"], f"{what}.spliced_outside_sha256")
|
|
2648
|
+
has_digests = data["entry_outside_sha256"] is not None
|
|
2649
|
+
if (data["spliced_outside_sha256"] is not None) != has_digests:
|
|
2650
|
+
_fail(what, "must hold both outside-markers digests or neither")
|
|
2651
|
+
if has_digests != (section is not None):
|
|
2652
|
+
_fail(what, "holds the outside-markers digests exactly when it holds a section")
|
|
2653
|
+
selection_void = _bool(data["selection_void"], f"{what}.selection_void")
|
|
2654
|
+
section_void = _bool(data["section_void"], f"{what}.section_void")
|
|
2655
|
+
if selection_void and data["next_issue_url"] is not None:
|
|
2656
|
+
_fail(what, "voids its selection but still holds one")
|
|
2657
|
+
if section_void and section is not None:
|
|
2658
|
+
_fail(what, "voids its roadmap section but still holds one")
|
|
2659
|
+
cls._check_publication(data["issue_url"], data["pr_url"], records, observation, what)
|
|
2660
|
+
return cls(
|
|
2661
|
+
data["issue_url"],
|
|
2662
|
+
data["pr_url"],
|
|
2663
|
+
section,
|
|
2664
|
+
data["next_issue_url"],
|
|
2665
|
+
data["entry_outside_sha256"],
|
|
2666
|
+
data["spliced_outside_sha256"],
|
|
2667
|
+
selection_void,
|
|
2668
|
+
section_void,
|
|
2669
|
+
)
|
|
2670
|
+
|
|
2671
|
+
@staticmethod
|
|
2672
|
+
def _check_publication(
|
|
2673
|
+
issue_url: str,
|
|
2674
|
+
pr_url: str,
|
|
2675
|
+
records: Sequence[EffectRecord],
|
|
2676
|
+
observation: EntryObservation | None,
|
|
2677
|
+
what: str,
|
|
2678
|
+
) -> None:
|
|
2679
|
+
"""The progress comment is planned or adopted, never neither (D2.2, D4.6, D13.7).
|
|
2680
|
+
|
|
2681
|
+
Completion publishes only what the plan holds, so an empty plan beside
|
|
2682
|
+
this context would finish the phase with no progress comment. The
|
|
2683
|
+
entry observation decides which plan is consistent: no comment
|
|
2684
|
+
carried the marker of (issue, PR) at entry, and the plan is exactly
|
|
2685
|
+
that comment's K8 record; or one did, the entry adopted it (a legacy
|
|
2686
|
+
re-entry, D13.7), and the plan is empty.
|
|
2687
|
+
"""
|
|
2688
|
+
if observation is None:
|
|
2689
|
+
_fail(what, "is persisted without the entry observation read before its launch")
|
|
2690
|
+
marker = render_progress_marker(issue_url, pr_url)
|
|
2691
|
+
if marker not in observation.objects:
|
|
2692
|
+
_fail(what, "has an entry observation that does not record its progress marker")
|
|
2693
|
+
adopted = observation.objects[marker]
|
|
2694
|
+
if adopted is not None:
|
|
2695
|
+
if records:
|
|
2696
|
+
_fail(what, f"plans a progress comment beside the adopted {adopted}")
|
|
2697
|
+
return
|
|
2698
|
+
if (
|
|
2699
|
+
len(records) != 1
|
|
2700
|
+
or records[0].kind != EffectKind.PROGRESS_COMMENT
|
|
2701
|
+
or records[0].identity["marker"] != marker
|
|
2702
|
+
):
|
|
2703
|
+
_fail(
|
|
2704
|
+
what,
|
|
2705
|
+
"must be saved with the one progress-comment record of its marker: the entry "
|
|
2706
|
+
"observation records no comment to adopt",
|
|
2707
|
+
)
|
|
2708
|
+
|
|
2709
|
+
def to_dict(self) -> dict:
|
|
2710
|
+
return {
|
|
2711
|
+
"phase": self.PHASE.value,
|
|
2712
|
+
"issue_url": self.issue_url,
|
|
2713
|
+
"pr_url": self.pr_url,
|
|
2714
|
+
"roadmap_section": self.roadmap_section,
|
|
2715
|
+
"next_issue_url": self.next_issue_url,
|
|
2716
|
+
"entry_outside_sha256": self.entry_outside_sha256,
|
|
2717
|
+
"spliced_outside_sha256": self.spliced_outside_sha256,
|
|
2718
|
+
"selection_void": self.selection_void,
|
|
2719
|
+
"section_void": self.section_void,
|
|
2720
|
+
}
|
|
2721
|
+
|
|
2722
|
+
@property
|
|
2723
|
+
def void(self) -> bool:
|
|
2724
|
+
return self.selection_void or self.section_void
|
|
2725
|
+
|
|
2726
|
+
|
|
2727
|
+
CompletionContext = AnalyzeContext | ReviewContext | FixContext | ReplanContext | UpdateEpicContext
|
|
2728
|
+
|
|
2729
|
+
_CONTEXT_TYPES: Mapping[str, Any] = {
|
|
2730
|
+
Phase.ANALYZE_EXECUTE.value: AnalyzeContext,
|
|
2731
|
+
Phase.REVIEW.value: ReviewContext,
|
|
2732
|
+
Phase.FIX.value: FixContext,
|
|
2733
|
+
Phase.REPLAN_REEXECUTE.value: ReplanContext,
|
|
2734
|
+
Phase.UPDATE_EPIC.value: UpdateEpicContext,
|
|
2735
|
+
}
|
|
2736
|
+
|
|
2737
|
+
|
|
2738
|
+
def _context_type(raw: dict) -> Any:
|
|
2739
|
+
"""The context class of the publishing phase ``raw`` names, or ``None``."""
|
|
2740
|
+
phase = raw.get("phase")
|
|
2741
|
+
return _CONTEXT_TYPES.get(phase) if isinstance(phase, str) else None
|
|
2742
|
+
|
|
2743
|
+
|
|
2744
|
+
def load_context(
|
|
2745
|
+
raw: object,
|
|
2746
|
+
binding: Binding,
|
|
2747
|
+
*,
|
|
2748
|
+
records: Sequence[EffectRecord] = (),
|
|
2749
|
+
observation: EntryObservation | None = None,
|
|
2750
|
+
) -> CompletionContext:
|
|
2751
|
+
"""The persisted completion context of the phase it names, strictly validated."""
|
|
2752
|
+
if not isinstance(raw, dict):
|
|
2753
|
+
_fail("completion_context", f"must be an object, got {type(raw).__name__}")
|
|
2754
|
+
context_type = _context_type(raw)
|
|
2755
|
+
if context_type is None:
|
|
2756
|
+
_fail("completion_context.phase", "must name a publishing phase")
|
|
2757
|
+
context: CompletionContext = context_type.from_dict(
|
|
2758
|
+
raw, binding, records=records, observation=observation
|
|
2759
|
+
)
|
|
2760
|
+
return context
|
|
2761
|
+
|
|
2762
|
+
|
|
2763
|
+
def plan_size_problem(records: Sequence[EffectRecord], context: CompletionContext | None) -> str:
|
|
2764
|
+
"""D2.4's total bound, or "" when the plan fits: payloads plus the context's stored bound."""
|
|
2765
|
+
total = payload_chars(records) + (type(context).STORED_BOUND if context is not None else 0)
|
|
2766
|
+
if total > MAX_EFFECT_STATE_CHARS:
|
|
2767
|
+
return (
|
|
2768
|
+
f"the effect plan holds {total} characters with its completion context, over the "
|
|
2769
|
+
f"bound of {MAX_EFFECT_STATE_CHARS}"
|
|
2770
|
+
)
|
|
2771
|
+
return ""
|
|
2772
|
+
|
|
2773
|
+
|
|
2774
|
+
@dataclass(frozen=True)
|
|
2775
|
+
class PhaseEffects:
|
|
2776
|
+
"""The effect state of one phase entry, as loaded: plan, observation, context."""
|
|
2777
|
+
|
|
2778
|
+
records: tuple[EffectRecord, ...] = ()
|
|
2779
|
+
observation: EntryObservation | None = None
|
|
2780
|
+
context: CompletionContext | None = None
|
|
2781
|
+
|
|
2782
|
+
@property
|
|
2783
|
+
def phase(self) -> Phase | None:
|
|
2784
|
+
for piece in (self.context, self.observation):
|
|
2785
|
+
if piece is not None:
|
|
2786
|
+
return piece.PHASE if hasattr(piece, "PHASE") else piece.phase # type: ignore[union-attr]
|
|
2787
|
+
return self.records[0].owner.phase if self.records else None
|
|
2788
|
+
|
|
2789
|
+
@property
|
|
2790
|
+
def empty(self) -> bool:
|
|
2791
|
+
return not self.records and self.observation is None and self.context is None
|
|
2792
|
+
|
|
2793
|
+
@property
|
|
2794
|
+
def published(self) -> bool:
|
|
2795
|
+
"""D4.7: every record of the plan is observed (an empty plan is published)."""
|
|
2796
|
+
return all(r.stage == Stage.OBSERVED for r in self.records)
|
|
2797
|
+
|
|
2798
|
+
|
|
2799
|
+
def load_phase_effects(
|
|
2800
|
+
records_raw: object, observation_raw: object, context_raw: object, binding: Binding
|
|
2801
|
+
) -> PhaseEffects:
|
|
2802
|
+
"""Validate the three persisted pieces against the state and against each other."""
|
|
2803
|
+
records = load_records(records_raw, binding)
|
|
2804
|
+
observation = None
|
|
2805
|
+
if not isinstance(observation_raw, dict):
|
|
2806
|
+
_fail("entry_observation", f"must be an object, got {type(observation_raw).__name__}")
|
|
2807
|
+
if observation_raw:
|
|
2808
|
+
observation = EntryObservation.from_dict(observation_raw, binding)
|
|
2809
|
+
if not isinstance(context_raw, dict):
|
|
2810
|
+
_fail("completion_context", f"must be an object, got {type(context_raw).__name__}")
|
|
2811
|
+
# One phase first: a context is validated against the plan and the
|
|
2812
|
+
# observation of its own phase, never against another phase's.
|
|
2813
|
+
context_type = _context_type(context_raw)
|
|
2814
|
+
phases = {
|
|
2815
|
+
p
|
|
2816
|
+
for p in (
|
|
2817
|
+
context_type.PHASE if context_type is not None else None,
|
|
2818
|
+
observation.phase if observation is not None else None,
|
|
2819
|
+
records[0].owner.phase if records else None,
|
|
2820
|
+
)
|
|
2821
|
+
if p is not None
|
|
2822
|
+
}
|
|
2823
|
+
if len(phases) > 1:
|
|
2824
|
+
_fail("effect state", "names more than one phase across records, observation and context")
|
|
2825
|
+
context = None
|
|
2826
|
+
if context_raw:
|
|
2827
|
+
context = load_context(context_raw, binding, records=records, observation=observation)
|
|
2828
|
+
effects = PhaseEffects(records, observation, context)
|
|
2829
|
+
if records and context is None:
|
|
2830
|
+
_fail("effect_records", "are persisted without the completion context saved with them")
|
|
2831
|
+
problem = plan_size_problem(records, context)
|
|
2832
|
+
if problem:
|
|
2833
|
+
_fail("effect state", problem)
|
|
2834
|
+
return effects
|
|
2835
|
+
|
|
2836
|
+
|
|
2837
|
+
# -- reconciliation decisions (D4.2, D5.5) -------------------------------------------
|
|
2838
|
+
|
|
2839
|
+
|
|
2840
|
+
class Action(StrEnum):
|
|
2841
|
+
OBSERVE = "observe" # the target is in the intended end state
|
|
2842
|
+
ISSUE = "issue" # save attempted (count + 1), then issue the persisted payload once
|
|
2843
|
+
REBASE = "rebase" # D5.5: save the rebased payload as attempted, then issue it
|
|
2844
|
+
CONFLICT = "conflict" # BLOCKED, naming the object
|
|
2845
|
+
DONE = "done" # already observed: terminal, nothing to do
|
|
2846
|
+
# K9 (#211): the write is due but may not be sent yet (no authorization,
|
|
2847
|
+
# or the run's issue no longer qualifies). Nothing is sent and the
|
|
2848
|
+
# record is unchanged; the phase blocks with the reason.
|
|
2849
|
+
HOLD = "hold"
|
|
2850
|
+
|
|
2851
|
+
|
|
2852
|
+
@dataclass(frozen=True)
|
|
2853
|
+
class Decision:
|
|
2854
|
+
action: Action
|
|
2855
|
+
# OBSERVE: the observed result; HOLD (K9): the exact comment found but not
|
|
2856
|
+
# adopted, because the run's issue no longer qualifies (PR #221 R1-F2).
|
|
2857
|
+
observed: dict | None = None
|
|
2858
|
+
reason: str = "" # CONFLICT, HOLD: names the objects and the rule, never a payload
|
|
2859
|
+
rebase_body: str = "" # REBASE: the body read now, the new base
|
|
2860
|
+
# CONFLICT only because the bound is spent and the write is still absent:
|
|
2861
|
+
# right after an issue that is "not visible yet", not yet a conflict.
|
|
2862
|
+
exhausted: bool = False
|
|
2863
|
+
# HOLD (K10, #243): the verification failed (the bound comment changed,
|
|
2864
|
+
# is gone or is another action's evidence; the target no longer
|
|
2865
|
+
# qualifies), not merely "awaiting the operator". The prefix pass keeps
|
|
2866
|
+
# the reason on the record, so an offline ``status`` reports it.
|
|
2867
|
+
failed: bool = False
|
|
2868
|
+
|
|
2869
|
+
|
|
2870
|
+
@dataclass(frozen=True)
|
|
2871
|
+
class Found:
|
|
2872
|
+
"""One remote object that carries a create effect's identity, as read back.
|
|
2873
|
+
|
|
2874
|
+
``in_end_state`` is the kind's completion predicate, evaluated by the
|
|
2875
|
+
caller's read (open, at the candidate, title and body equal to the
|
|
2876
|
+
payload, ...). ``observed`` is what the record would hold.
|
|
2877
|
+
"""
|
|
2878
|
+
|
|
2879
|
+
url: str
|
|
2880
|
+
in_end_state: bool
|
|
2881
|
+
observed: Mapping[str, object]
|
|
2882
|
+
|
|
2883
|
+
|
|
2884
|
+
def _exhausted(record: EffectRecord) -> Decision:
|
|
2885
|
+
return Decision(
|
|
2886
|
+
Action.CONFLICT,
|
|
2887
|
+
exhausted=True,
|
|
2888
|
+
reason=(
|
|
2889
|
+
f"{record.describe()} has used its {MAX_EFFECT_ATTEMPTS} attempts and its outcome "
|
|
2890
|
+
"is still not on GitHub; perform the write by hand or remove the conflicting "
|
|
2891
|
+
"object, then 'unblock'"
|
|
2892
|
+
),
|
|
2893
|
+
)
|
|
2894
|
+
|
|
2895
|
+
|
|
2896
|
+
def _terminal(record: EffectRecord) -> Decision | None:
|
|
2897
|
+
if record.stage == Stage.OBSERVED:
|
|
2898
|
+
return Decision(Action.DONE, observed=dict(record.observed or {}))
|
|
2899
|
+
if record.stage == Stage.CONFLICT:
|
|
2900
|
+
return Decision(Action.CONFLICT, reason=record.reason)
|
|
2901
|
+
return None
|
|
2902
|
+
|
|
2903
|
+
|
|
2904
|
+
def decide_create(record: EffectRecord, found: Sequence[Found]) -> Decision:
|
|
2905
|
+
"""D4.2 for a create (K2, K4, K5, K7, K8) over a *complete* identity read.
|
|
2906
|
+
|
|
2907
|
+
``found`` is every object that carries the identity, in every state the
|
|
2908
|
+
kind's read covers (D4.5). One in the intended end state is the write;
|
|
2909
|
+
none means the precondition still holds; anything else is a conflict
|
|
2910
|
+
naming every object found.
|
|
2911
|
+
"""
|
|
2912
|
+
terminal = _terminal(record)
|
|
2913
|
+
if terminal is not None:
|
|
2914
|
+
return terminal
|
|
2915
|
+
if len(found) == 1 and found[0].in_end_state:
|
|
2916
|
+
return Decision(Action.OBSERVE, observed=dict(found[0].observed))
|
|
2917
|
+
if not found:
|
|
2918
|
+
if record.bound_left:
|
|
2919
|
+
return Decision(Action.ISSUE)
|
|
2920
|
+
return _exhausted(record)
|
|
2921
|
+
urls = ", ".join(f.url for f in found)
|
|
2922
|
+
if len(found) == 1:
|
|
2923
|
+
return Decision(
|
|
2924
|
+
Action.CONFLICT,
|
|
2925
|
+
reason=(
|
|
2926
|
+
f"{record.describe()}: {urls} carries its identity but is not the object the "
|
|
2927
|
+
"controller intended (another body, title, head or state); it is never "
|
|
2928
|
+
"repaired in place or duplicated"
|
|
2929
|
+
),
|
|
2930
|
+
)
|
|
2931
|
+
return Decision(
|
|
2932
|
+
Action.CONFLICT,
|
|
2933
|
+
reason=f"{record.describe()}: {len(found)} objects carry its identity ({urls})",
|
|
2934
|
+
)
|
|
2935
|
+
|
|
2936
|
+
|
|
2937
|
+
def decide_acceptance(
|
|
2938
|
+
record: EffectRecord, found: Sequence[Found], *, issue_problem: str = ""
|
|
2939
|
+
) -> Decision:
|
|
2940
|
+
"""K9 (#211): :func:`decide_create`, except that a due write may be held.
|
|
2941
|
+
|
|
2942
|
+
The exact payload already on the issue is adopted whoever posted it
|
|
2943
|
+
(an operator may post it instead of approving), and every conflict is
|
|
2944
|
+
decide_create's. ``issue_problem`` (the run's issue is closed, or not in
|
|
2945
|
+
the run's repository) holds an adoption as it holds a write (PR #221
|
|
2946
|
+
R1-F2): the exact payload on such an issue is not adopted, its URL is
|
|
2947
|
+
the hold's ``observed``, and the plan's later writes wait with it. A
|
|
2948
|
+
due write is also held while no approval of this record's action id is
|
|
2949
|
+
recorded. A hold sends nothing and changes nothing; for an ``attempted``
|
|
2950
|
+
record its reason says the earlier write was sent (PR #221 R2-F1).
|
|
2951
|
+
"""
|
|
2952
|
+
decision = decide_create(record, found)
|
|
2953
|
+
if decision.action is Action.OBSERVE and issue_problem:
|
|
2954
|
+
url = (decision.observed or {}).get("url", "")
|
|
2955
|
+
return Decision(
|
|
2956
|
+
Action.HOLD,
|
|
2957
|
+
observed=decision.observed,
|
|
2958
|
+
reason=(
|
|
2959
|
+
f"{record.describe()}: its exact payload is on {url}, but {issue_problem}; it "
|
|
2960
|
+
"is not adopted while the issue does not qualify, and nothing more was sent"
|
|
2961
|
+
),
|
|
2962
|
+
)
|
|
2963
|
+
if decision.action is not Action.ISSUE:
|
|
2964
|
+
return decision
|
|
2965
|
+
if not record.authorized:
|
|
2966
|
+
return Decision(
|
|
2967
|
+
Action.HOLD,
|
|
2968
|
+
reason=(
|
|
2969
|
+
f"{record.describe()}: action {record.action_id} awaits the operator's "
|
|
2970
|
+
"approval; nothing was sent"
|
|
2971
|
+
),
|
|
2972
|
+
)
|
|
2973
|
+
if issue_problem:
|
|
2974
|
+
# An attempted record's write was sent (PR #221 R2-F1): its comment
|
|
2975
|
+
# is not on the issue, or not visible yet, and is not sent again.
|
|
2976
|
+
sent = (
|
|
2977
|
+
f"the comment of attempt {record.attempts} is not on the issue, and nothing more "
|
|
2978
|
+
"is sent while the issue does not qualify"
|
|
2979
|
+
if record.stage == Stage.ATTEMPTED
|
|
2980
|
+
else "nothing was sent"
|
|
2981
|
+
)
|
|
2982
|
+
return Decision(Action.HOLD, reason=f"{record.describe()}: {issue_problem}; {sent}")
|
|
2983
|
+
return decision
|
|
2984
|
+
|
|
2985
|
+
|
|
2986
|
+
@dataclass(frozen=True)
|
|
2987
|
+
class Listed:
|
|
2988
|
+
"""One comment of a K10 target's complete listing, as the decision needs it (#243)."""
|
|
2989
|
+
|
|
2990
|
+
url: str
|
|
2991
|
+
comment_id: int
|
|
2992
|
+
body: str
|
|
2993
|
+
|
|
2994
|
+
|
|
2995
|
+
def decide_operator_comment(
|
|
2996
|
+
record: EffectRecord,
|
|
2997
|
+
*,
|
|
2998
|
+
own: Sequence[Listed],
|
|
2999
|
+
misplaced: Sequence[Listed],
|
|
3000
|
+
listing: Sequence[Listed],
|
|
3001
|
+
defects: Sequence[str] = (),
|
|
3002
|
+
target_problem: str = "",
|
|
3003
|
+
taken: Mapping[int, str] | None = None,
|
|
3004
|
+
) -> Decision:
|
|
3005
|
+
"""K10 (#243): verify an operator-performed comment; never ``ISSUE``.
|
|
3006
|
+
|
|
3007
|
+
``own`` are the target's comments carrying this record's marker key,
|
|
3008
|
+
``misplaced`` those carrying the key of another record of this plan
|
|
3009
|
+
whose target is another issue, ``listing`` the target's complete
|
|
3010
|
+
listing (for a binding) and ``defects`` the unreadable
|
|
3011
|
+
``ai-operator-comment`` markers in it. ``taken`` maps the id of each
|
|
3012
|
+
comment another action already consumes (observed for it, carrying its
|
|
3013
|
+
marker, or verified for it earlier in the round) to that action's id:
|
|
3014
|
+
one comment verifies one action (PR #254 R2-F1). Conflict rows are
|
|
3015
|
+
evaluated first, so an exact or bound match never hides a conflict: a
|
|
3016
|
+
misplaced payload, two comments with this key, one with other bytes, or
|
|
3017
|
+
the exact payload on a comment another action consumes. Then the exact
|
|
3018
|
+
payload (``exact``), then the bound comment with its digest unchanged
|
|
3019
|
+
(``bound``) unless another action consumes it. Everything else is a
|
|
3020
|
+
hold, which sends nothing: where :func:`decide_create` would issue, this
|
|
3021
|
+
waits for the operator. ``target_problem`` (transferred, deleted,
|
|
3022
|
+
unreadable) holds as well. A hold on a target problem or on bound
|
|
3023
|
+
evidence that does not verify is :attr:`Decision.failed`; only "nothing
|
|
3024
|
+
on the target carries it yet" is not.
|
|
3025
|
+
"""
|
|
3026
|
+
taken = taken or {}
|
|
3027
|
+
terminal = _terminal(record)
|
|
3028
|
+
if terminal is not None:
|
|
3029
|
+
return terminal
|
|
3030
|
+
action = f"{record.describe()} ({record.action_id})"
|
|
3031
|
+
if target_problem:
|
|
3032
|
+
return Decision(
|
|
3033
|
+
Action.HOLD, reason=f"{action}: {target_problem}; nothing was verified", failed=True
|
|
3034
|
+
)
|
|
3035
|
+
if defects:
|
|
3036
|
+
return Decision(
|
|
3037
|
+
Action.CONFLICT,
|
|
3038
|
+
reason=(
|
|
3039
|
+
f"{action}: the target's comments cannot be read unambiguously "
|
|
3040
|
+
f"({'; '.join(defects)}); edit or delete the named comment, then 'unblock'"
|
|
3041
|
+
),
|
|
3042
|
+
)
|
|
3043
|
+
if misplaced:
|
|
3044
|
+
urls = ", ".join(c.url for c in misplaced)
|
|
3045
|
+
return Decision(
|
|
3046
|
+
Action.CONFLICT,
|
|
3047
|
+
reason=(
|
|
3048
|
+
f"{action}: {urls} on this issue carries the marker of another operator action "
|
|
3049
|
+
"of this plan, which is meant for another issue (a misplaced payload); delete "
|
|
3050
|
+
"it here and post it on its own issue, then 'unblock'"
|
|
3051
|
+
),
|
|
3052
|
+
)
|
|
3053
|
+
if len(own) > 1:
|
|
3054
|
+
urls = ", ".join(c.url for c in own)
|
|
3055
|
+
return Decision(
|
|
3056
|
+
Action.CONFLICT,
|
|
3057
|
+
reason=(
|
|
3058
|
+
f"{action}: {len(own)} comments carry its marker ({urls}); delete all but one, "
|
|
3059
|
+
"then 'unblock'"
|
|
3060
|
+
),
|
|
3061
|
+
)
|
|
3062
|
+
if own:
|
|
3063
|
+
comment = own[0]
|
|
3064
|
+
if comment.body != record.body:
|
|
3065
|
+
cause = (
|
|
3066
|
+
"its bytes differ from the prepared payload only in line endings or trailing "
|
|
3067
|
+
"whitespace (an editor or the web UI changes them)"
|
|
3068
|
+
if normalized_comment_text(comment.body) == normalized_comment_text(record.body)
|
|
3069
|
+
else "its body is not the prepared payload"
|
|
3070
|
+
)
|
|
3071
|
+
return Decision(
|
|
3072
|
+
Action.CONFLICT,
|
|
3073
|
+
reason=(
|
|
3074
|
+
f"{action}: {comment.url} carries its marker but {cause}; edit it to the "
|
|
3075
|
+
"exact bytes, or delete it and post the payload through the pipe, then "
|
|
3076
|
+
"'unblock'"
|
|
3077
|
+
),
|
|
3078
|
+
)
|
|
3079
|
+
other = taken.get(comment.comment_id)
|
|
3080
|
+
if other:
|
|
3081
|
+
return Decision(
|
|
3082
|
+
Action.CONFLICT,
|
|
3083
|
+
reason=(
|
|
3084
|
+
f"{action}: {comment.url} carries its exact payload but is already the "
|
|
3085
|
+
f"evidence of {other}, and one comment verifies one action; post the "
|
|
3086
|
+
"payload as a comment of its own and remove this action's marker from "
|
|
3087
|
+
f"{comment.url}, then 'unblock'"
|
|
3088
|
+
),
|
|
3089
|
+
)
|
|
3090
|
+
return Decision(
|
|
3091
|
+
Action.OBSERVE,
|
|
3092
|
+
observed={
|
|
3093
|
+
"url": comment.url,
|
|
3094
|
+
"comment_id": comment.comment_id,
|
|
3095
|
+
"evidence": EVIDENCE_EXACT,
|
|
3096
|
+
"body_sha256": sha256_text(comment.body),
|
|
3097
|
+
},
|
|
3098
|
+
)
|
|
3099
|
+
binding = record.binding
|
|
3100
|
+
if binding is not None:
|
|
3101
|
+
other = taken.get(binding["comment_id"])
|
|
3102
|
+
if other:
|
|
3103
|
+
return Decision(
|
|
3104
|
+
Action.HOLD,
|
|
3105
|
+
reason=(
|
|
3106
|
+
f"{action}: the bound comment {binding['comment_url']} is the evidence of "
|
|
3107
|
+
f"{other}, and one comment verifies one action; bind another comment or "
|
|
3108
|
+
"post the payload"
|
|
3109
|
+
),
|
|
3110
|
+
failed=True,
|
|
3111
|
+
)
|
|
3112
|
+
bound = [c for c in listing if c.comment_id == binding["comment_id"]]
|
|
3113
|
+
if bound and sha256_text(bound[0].body) == binding["body_sha256"]:
|
|
3114
|
+
return Decision(
|
|
3115
|
+
Action.OBSERVE,
|
|
3116
|
+
observed={
|
|
3117
|
+
"url": bound[0].url,
|
|
3118
|
+
"comment_id": bound[0].comment_id,
|
|
3119
|
+
"evidence": EVIDENCE_BOUND,
|
|
3120
|
+
"body_sha256": binding["body_sha256"],
|
|
3121
|
+
},
|
|
3122
|
+
)
|
|
3123
|
+
now = sha256_text(bound[0].body) if bound else "none: the comment is gone"
|
|
3124
|
+
return Decision(
|
|
3125
|
+
Action.HOLD,
|
|
3126
|
+
reason=(
|
|
3127
|
+
f"{action}: the bound comment {binding['comment_url']} changed since it was "
|
|
3128
|
+
f"bound (bound sha256 {binding['body_sha256']}, now {now}); bind it again or "
|
|
3129
|
+
"post the payload"
|
|
3130
|
+
),
|
|
3131
|
+
failed=True,
|
|
3132
|
+
)
|
|
3133
|
+
return Decision(
|
|
3134
|
+
Action.HOLD,
|
|
3135
|
+
reason=f"{action}: awaiting the operator; no comment on the target carries it yet",
|
|
3136
|
+
)
|
|
3137
|
+
|
|
3138
|
+
|
|
3139
|
+
def decide_append(
|
|
3140
|
+
record: EffectRecord,
|
|
3141
|
+
body_now: str,
|
|
3142
|
+
*,
|
|
3143
|
+
target_ok: bool,
|
|
3144
|
+
carries_block_marker: bool,
|
|
3145
|
+
rebase_problem: Callable[[str], str],
|
|
3146
|
+
) -> Decision:
|
|
3147
|
+
"""D5.5's table for a body append (K3, K6).
|
|
3148
|
+
|
|
3149
|
+
``target_ok`` is the kind's other conditions (open, in this repository,
|
|
3150
|
+
the K1 candidate as head, ...). ``carries_block_marker`` says whether
|
|
3151
|
+
``body_now`` holds any marker of the block. ``rebase_problem`` judges a
|
|
3152
|
+
rebased payload (size and the credential check) and returns "" when it
|
|
3153
|
+
may be persisted; its reason names the pattern class only.
|
|
3154
|
+
"""
|
|
3155
|
+
terminal = _terminal(record)
|
|
3156
|
+
if terminal is not None:
|
|
3157
|
+
return terminal
|
|
3158
|
+
if body_now == record.body:
|
|
3159
|
+
if target_ok:
|
|
3160
|
+
return Decision(Action.OBSERVE, observed={"url": _append_target(record)})
|
|
3161
|
+
return Decision(
|
|
3162
|
+
Action.CONFLICT,
|
|
3163
|
+
reason=f"{record.describe()}: the body is written but the target no longer qualifies",
|
|
3164
|
+
)
|
|
3165
|
+
if not target_ok:
|
|
3166
|
+
return Decision(
|
|
3167
|
+
Action.CONFLICT,
|
|
3168
|
+
reason=f"{record.describe()}: the target no longer meets the kind's conditions",
|
|
3169
|
+
)
|
|
3170
|
+
if sha256_text(body_now) == record.precondition["base_sha256"]:
|
|
3171
|
+
return Decision(Action.ISSUE) if record.bound_left else _exhausted(record)
|
|
3172
|
+
if record.stage == Stage.INTENDED and not carries_block_marker:
|
|
3173
|
+
problem = rebase_problem(compose_append(body_now, record.payload["block"]))
|
|
3174
|
+
if not problem:
|
|
3175
|
+
return Decision(Action.REBASE, rebase_body=body_now)
|
|
3176
|
+
return Decision(Action.CONFLICT, reason=f"{record.describe()}: no rebase: {problem}")
|
|
3177
|
+
if record.stage == Stage.INTENDED:
|
|
3178
|
+
why = "the body already carries a marker of the block but is not the payload"
|
|
3179
|
+
else:
|
|
3180
|
+
why = (
|
|
3181
|
+
"after an issue the body equals neither the payload nor its base; a write that "
|
|
3182
|
+
"landed and was edited cannot be told from one that never landed"
|
|
3183
|
+
)
|
|
3184
|
+
return Decision(Action.CONFLICT, reason=f"{record.describe()}: {why}")
|
|
3185
|
+
|
|
3186
|
+
|
|
3187
|
+
def _append_target(record: EffectRecord) -> str:
|
|
3188
|
+
return str(record.target.get("pr_url") or record.target.get("issue_url"))
|
|
3189
|
+
|
|
3190
|
+
|
|
3191
|
+
def decide_push(record: EffectRecord, remote_head: str | None) -> Decision:
|
|
3192
|
+
"""K1's windows: the ref at the candidate is observed, at the expected old value is
|
|
3193
|
+
pushed (within the bound), anywhere else is a conflict naming both SHAs."""
|
|
3194
|
+
terminal = _terminal(record)
|
|
3195
|
+
if terminal is not None:
|
|
3196
|
+
return terminal
|
|
3197
|
+
candidate = record.payload["sha"]
|
|
3198
|
+
expected = record.precondition["expected_old"]
|
|
3199
|
+
ref = record.target["ref"]
|
|
3200
|
+
if remote_head == candidate:
|
|
3201
|
+
return Decision(Action.OBSERVE, observed={"sha": candidate})
|
|
3202
|
+
if remote_head == expected:
|
|
3203
|
+
return Decision(Action.ISSUE) if record.bound_left else _exhausted(record)
|
|
3204
|
+
return Decision(
|
|
3205
|
+
Action.CONFLICT,
|
|
3206
|
+
reason=(
|
|
3207
|
+
f"{record.describe()}: {ref} is at {remote_head or 'nothing'}, neither the "
|
|
3208
|
+
f"expected {expected or 'absent ref'} nor the candidate {candidate}"
|
|
3209
|
+
),
|
|
3210
|
+
)
|