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/epic_graph.py
ADDED
|
@@ -0,0 +1,852 @@
|
|
|
1
|
+
"""An EPIC's native dependency graph: snapshot, ready set and admission re-read (ADR 0008; #179).
|
|
2
|
+
|
|
3
|
+
The parallel EPIC scheduler decides what is admissible from GitHub's native
|
|
4
|
+
graph alone, read through four typed reads of
|
|
5
|
+
:class:`~autoforge.github.GitHubClient` (the :class:`GraphReader` protocol):
|
|
6
|
+
the repository id, one issue's node, an issue's direct sub-issues and an
|
|
7
|
+
issue's ``blocked_by`` list. Prose (``Depends on #n``, checklists, the EPIC
|
|
8
|
+
roadmap) is never read, ``blocking`` is never read, and nested EPICs are
|
|
9
|
+
never traversed (D3.1, D3.2).
|
|
10
|
+
|
|
11
|
+
* :func:`build_epic_snapshot` reads the EPIC, its direct sub-issues and the
|
|
12
|
+
blockers of each open child into an immutable :class:`EpicSnapshot`. It
|
|
13
|
+
is complete or it is refused with a typed
|
|
14
|
+
:class:`~autoforge.errors.SnapshotRefused`, never partial (D3.3, D3.4):
|
|
15
|
+
the repository id must match, the EPIC must be open, the child listing
|
|
16
|
+
must hold exactly ``sub_issues_summary.total`` distinct issues and each
|
|
17
|
+
queried blocker listing exactly ``total_blocked_by``, within GitHub's
|
|
18
|
+
documented bounds, and the open children must be acyclic. Entries are
|
|
19
|
+
de-duplicated by issue ``id`` (D3.6). A child whose summary reports no
|
|
20
|
+
blocker is not queried, nor is a closed child, whose blockers decide
|
|
21
|
+
nothing.
|
|
22
|
+
* :func:`evaluate` is pure (D4.1, D4.2, D2.5): a snapshot and the local
|
|
23
|
+
facts give one :class:`ChildVerdict` per child, from the closed
|
|
24
|
+
vocabulary ``ready``, ``closed``, ``blocked``, ``unsupported:<shape>``,
|
|
25
|
+
``owned`` and ``held:<reason>``, in an order that depends on the
|
|
26
|
+
snapshot alone, never on API order, dictionary or set iteration, or time.
|
|
27
|
+
* A blocker is satisfied only when it is closed as ``completed``
|
|
28
|
+
(:func:`blocker_satisfaction`); any other closed reason, an open or
|
|
29
|
+
reopened blocker and one the token may not read block (D3.6).
|
|
30
|
+
* The snapshot digest and the node digests are SHA-256 over canonical JSON
|
|
31
|
+
of exactly the eligibility facts of D4.4; titles, bodies, labels,
|
|
32
|
+
assignees, milestones and timestamps are never part of them.
|
|
33
|
+
* :func:`reread_node` is admission's re-read (D2.2 step 4): one child and
|
|
34
|
+
its complete blocker list, compared with the snapshot's node digest,
|
|
35
|
+
``unchanged`` or ``stale`` with the differing facts. It never mutates the
|
|
36
|
+
snapshot.
|
|
37
|
+
|
|
38
|
+
Nothing here writes to GitHub, takes a lock, writes a file or launches a
|
|
39
|
+
process: the only client methods it calls are :data:`GRAPH_READS`. A
|
|
40
|
+
snapshot lives in memory; #181 records its digests in a claim. Diagnostics
|
|
41
|
+
name issue numbers, states and reasons, never a title or a body, and are
|
|
42
|
+
redacted and bounded.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
from __future__ import annotations
|
|
46
|
+
|
|
47
|
+
import hashlib
|
|
48
|
+
import json
|
|
49
|
+
from collections import deque
|
|
50
|
+
from collections.abc import Callable, Iterable, Mapping
|
|
51
|
+
from dataclasses import dataclass
|
|
52
|
+
from datetime import UTC, datetime
|
|
53
|
+
from enum import Enum
|
|
54
|
+
from functools import partial
|
|
55
|
+
from typing import Protocol, TypeVar
|
|
56
|
+
|
|
57
|
+
from .errors import GitHubError, GitHubUnavailableError, SnapshotRefused
|
|
58
|
+
from .github import MAX_BLOCKED_BY, MAX_SUB_ISSUES, BlockerNode, IssueNode
|
|
59
|
+
from .redaction import redact
|
|
60
|
+
from .validation import GitHubIssueRef, parse_issue_url
|
|
61
|
+
|
|
62
|
+
# The version of the digested document: a change to the fact set is a new one.
|
|
63
|
+
DIGEST_VERSION = "autoforge.epic-graph/1"
|
|
64
|
+
# The bound on a diagnostic or an explanation (a verdict's, a refusal's).
|
|
65
|
+
MAX_DIAGNOSTIC_CHARS = 2000
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class GraphReader(Protocol):
|
|
69
|
+
"""The GitHub reads a snapshot and a re-read use: four reads, no write."""
|
|
70
|
+
|
|
71
|
+
def get_repository_id(self, repo: str) -> int: ...
|
|
72
|
+
|
|
73
|
+
def get_issue_node(self, url: str) -> IssueNode: ...
|
|
74
|
+
|
|
75
|
+
def list_sub_issues(self, url: str) -> tuple[IssueNode, ...]: ...
|
|
76
|
+
|
|
77
|
+
def list_blocked_by(self, url: str) -> tuple[BlockerNode, ...]: ...
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# Every client method this module may call (a structural test holds it to this).
|
|
81
|
+
GRAPH_READS = frozenset(
|
|
82
|
+
{"get_repository_id", "get_issue_node", "list_sub_issues", "list_blocked_by"}
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
# -- vocabulary (ADR 0008 D3.6, D4.1) ------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class LocalFact(Enum):
|
|
90
|
+
"""What the scheduler knows locally about one issue (D4.1): ``owned`` or ``held:<reason>``.
|
|
91
|
+
|
|
92
|
+
#181's reconciliation and #182's supervisor supply them; an issue with no
|
|
93
|
+
local fact (none, ``fresh`` or ``resumable`` at startup) is evaluated
|
|
94
|
+
from the graph alone. ``held:orphaned`` names a namespace bound to this
|
|
95
|
+
EPIC whose issue the snapshot no longer lists, so :func:`evaluate`,
|
|
96
|
+
which gives a verdict per listed child, never meets it.
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
OWNED = "owned"
|
|
100
|
+
HELD_SETTLED_NAMESPACE = "held:settled-namespace"
|
|
101
|
+
HELD_STANDALONE_STATE = "held:standalone-state"
|
|
102
|
+
HELD_OTHER_EPIC = "held:other-epic"
|
|
103
|
+
HELD_UNCERTAIN = "held:uncertain"
|
|
104
|
+
HELD_RESUMABLE = "held:resumable"
|
|
105
|
+
HELD_SPAWN_FAILED = "held:spawn-failed"
|
|
106
|
+
HELD_ORPHANED = "held:orphaned"
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class VerdictKind(Enum):
|
|
110
|
+
"""A child's verdict, in precedence order: the first that applies is the verdict."""
|
|
111
|
+
|
|
112
|
+
CLOSED = "closed"
|
|
113
|
+
UNSUPPORTED = "unsupported"
|
|
114
|
+
BLOCKED = "blocked"
|
|
115
|
+
OWNED = "owned"
|
|
116
|
+
HELD = "held"
|
|
117
|
+
READY = "ready"
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class Shape(Enum):
|
|
121
|
+
"""A child the scheduler never admits, whatever its blockers (D3.6)."""
|
|
122
|
+
|
|
123
|
+
FOREIGN_REPOSITORY = "foreign-repository"
|
|
124
|
+
NESTED_PARENT = "nested-parent"
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class Satisfaction(Enum):
|
|
128
|
+
"""Whether one blocker still blocks its child (D3.6)."""
|
|
129
|
+
|
|
130
|
+
SATISFIED = "satisfied"
|
|
131
|
+
OPEN = "open"
|
|
132
|
+
CLOSED_UNSATISFIED = "closed-unsatisfied"
|
|
133
|
+
UNREADABLE = "unreadable"
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def blocker_satisfaction(blocker: BlockerNode) -> Satisfaction:
|
|
137
|
+
"""A blocker is satisfied only when it is closed with ``state_reason`` ``completed``.
|
|
138
|
+
|
|
139
|
+
Closed as ``not_planned``, as a duplicate, with no reason or with a
|
|
140
|
+
reason GitHub adds later, it is ``closed-unsatisfied``; open (reopened
|
|
141
|
+
included) it is ``open``; one the token may not read is ``unreadable``.
|
|
142
|
+
A blocker in another EPIC or another repository counts by the same live
|
|
143
|
+
state.
|
|
144
|
+
"""
|
|
145
|
+
if not blocker.readable:
|
|
146
|
+
return Satisfaction.UNREADABLE
|
|
147
|
+
if blocker.state != "closed":
|
|
148
|
+
return Satisfaction.OPEN
|
|
149
|
+
if blocker.state_reason == "completed":
|
|
150
|
+
return Satisfaction.SATISFIED
|
|
151
|
+
return Satisfaction.CLOSED_UNSATISFIED
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
# -- the snapshot ---------------------------------------------------------------------------
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
@dataclass(frozen=True)
|
|
158
|
+
class Edge:
|
|
159
|
+
"""``child_id`` is blocked by ``blocker`` (a native ``blocked_by`` dependency)."""
|
|
160
|
+
|
|
161
|
+
child_id: int
|
|
162
|
+
blocker: BlockerNode
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
@dataclass(frozen=True)
|
|
166
|
+
class EpicSnapshot:
|
|
167
|
+
"""One complete, validated reading of an EPIC's native graph (D3).
|
|
168
|
+
|
|
169
|
+
``children`` are the EPIC's distinct direct sub-issues in ascending
|
|
170
|
+
number; ``edges`` are the blockers of every open child that reports
|
|
171
|
+
one, by child number then blocker. ``observed_at`` is when the reading
|
|
172
|
+
started (diagnostics only, never digested). ``digest`` is
|
|
173
|
+
:func:`snapshot_digest` of the facts. Build it with
|
|
174
|
+
:func:`build_epic_snapshot`.
|
|
175
|
+
"""
|
|
176
|
+
|
|
177
|
+
repository: str
|
|
178
|
+
repository_id: int
|
|
179
|
+
epic: IssueNode
|
|
180
|
+
children: tuple[IssueNode, ...]
|
|
181
|
+
edges: tuple[Edge, ...]
|
|
182
|
+
observed_at: str
|
|
183
|
+
digest: str
|
|
184
|
+
|
|
185
|
+
def child(self, number: int) -> IssueNode:
|
|
186
|
+
"""The child of this repository numbered ``number``; ValueError for no such child."""
|
|
187
|
+
for child in self.children:
|
|
188
|
+
if child.number == number and _same_repository(child.repository, self.repository):
|
|
189
|
+
return child
|
|
190
|
+
raise ValueError(f"#{number} is not a child of EPIC #{self.epic.number} in this snapshot")
|
|
191
|
+
|
|
192
|
+
def blockers_of(self, child_id: int) -> tuple[BlockerNode, ...]:
|
|
193
|
+
"""The blockers recorded for the child whose issue id is ``child_id``."""
|
|
194
|
+
return tuple(edge.blocker for edge in self.edges if edge.child_id == child_id)
|
|
195
|
+
|
|
196
|
+
def node_digest(self, number: int) -> str:
|
|
197
|
+
"""The node digest of this repository's child ``number`` (D4.4)."""
|
|
198
|
+
child = self.child(number)
|
|
199
|
+
return node_digest(self.repository_id, child, self.blockers_of(child.id))
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _same_repository(left: str | None, right: str | None) -> bool:
|
|
203
|
+
return (left or "").lower() == (right or "").lower()
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _issue_label(repository: str | None, number: int | None, home: str) -> str:
|
|
207
|
+
"""``#n`` in the snapshot's repository, ``owner/repo#n`` elsewhere."""
|
|
208
|
+
if _same_repository(repository, home):
|
|
209
|
+
return f"#{number}"
|
|
210
|
+
return f"{repository}#{number}"
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _bounded(text: str) -> str:
|
|
214
|
+
text = redact(text)
|
|
215
|
+
if len(text) <= MAX_DIAGNOSTIC_CHARS:
|
|
216
|
+
return text
|
|
217
|
+
marker = " [... truncated]"
|
|
218
|
+
return text[: MAX_DIAGNOSTIC_CHARS - len(marker)] + marker
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _refuse(
|
|
222
|
+
epic: GitHubIssueRef,
|
|
223
|
+
reason: str,
|
|
224
|
+
detail: str,
|
|
225
|
+
*,
|
|
226
|
+
cycle: tuple[int, ...] = (),
|
|
227
|
+
transient: bool = False,
|
|
228
|
+
) -> SnapshotRefused:
|
|
229
|
+
message = _bounded(f"snapshot of EPIC {epic.canonical} refused ({reason}): {detail}")
|
|
230
|
+
return SnapshotRefused(message, reason, cycle=cycle, transient=transient)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
R = TypeVar("R")
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _read(epic: GitHubIssueRef, what: str, read: Callable[[], R]) -> R:
|
|
237
|
+
"""One GitHub read of the snapshot: a failure refuses it, with the failure as its cause."""
|
|
238
|
+
try:
|
|
239
|
+
return read()
|
|
240
|
+
except GitHubError as exc:
|
|
241
|
+
raise _refuse(
|
|
242
|
+
epic,
|
|
243
|
+
"read-failed",
|
|
244
|
+
f"{what} could not be read: {exc}",
|
|
245
|
+
transient=isinstance(exc, GitHubUnavailableError),
|
|
246
|
+
) from exc
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
N = TypeVar("N", IssueNode, BlockerNode)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _distinct(rows: Iterable[N], epic: GitHubIssueRef, what: str) -> tuple[N, ...]:
|
|
253
|
+
"""``rows`` de-duplicated by issue ``id`` (D3.6), first occurrence kept.
|
|
254
|
+
|
|
255
|
+
A repeated ``id`` whose facts differ, or two ids naming one issue, is
|
|
256
|
+
an answer that contradicts itself: the snapshot is refused rather than
|
|
257
|
+
one of the two believed.
|
|
258
|
+
"""
|
|
259
|
+
by_id: dict[int, N] = {}
|
|
260
|
+
by_name: dict[tuple[str, int], int] = {}
|
|
261
|
+
for row in rows:
|
|
262
|
+
seen = by_id.get(row.id)
|
|
263
|
+
if seen is not None:
|
|
264
|
+
if seen != row:
|
|
265
|
+
raise _refuse(
|
|
266
|
+
epic, "inconsistent", f"{what} lists issue id {row.id} twice, differently"
|
|
267
|
+
)
|
|
268
|
+
continue
|
|
269
|
+
if row.number is not None and row.repository is not None:
|
|
270
|
+
name = (row.repository.lower(), row.number)
|
|
271
|
+
other = by_name.setdefault(name, row.id)
|
|
272
|
+
if other != row.id:
|
|
273
|
+
raise _refuse(
|
|
274
|
+
epic,
|
|
275
|
+
"inconsistent",
|
|
276
|
+
f"{what} lists {row.repository}#{row.number} under ids {other} and {row.id}",
|
|
277
|
+
)
|
|
278
|
+
by_id[row.id] = row
|
|
279
|
+
return tuple(by_id.values())
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _utc_timestamp(value: str | None) -> str:
|
|
283
|
+
if value is None:
|
|
284
|
+
return datetime.now(UTC).isoformat()
|
|
285
|
+
try:
|
|
286
|
+
moment = datetime.fromisoformat(value) if isinstance(value, str) else None
|
|
287
|
+
except ValueError:
|
|
288
|
+
moment = None
|
|
289
|
+
offset = moment.utcoffset() if moment is not None else None
|
|
290
|
+
if offset is None or offset.total_seconds() != 0 or "T" not in value:
|
|
291
|
+
raise ValueError(f"observed_at must be an ISO 8601 UTC time, got {value!r:.80}")
|
|
292
|
+
return value
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _child_order(node: IssueNode) -> tuple[int, str]:
|
|
296
|
+
return (node.number, node.repository.lower())
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _blocker_order(blocker: BlockerNode) -> tuple[int, str, int, int]:
|
|
300
|
+
"""Readable blockers by repository and number, then unreadable ones by id."""
|
|
301
|
+
if blocker.readable:
|
|
302
|
+
return (0, (blocker.repository or "").lower(), blocker.number or 0, blocker.id)
|
|
303
|
+
return (1, "", 0, blocker.id)
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def build_epic_snapshot(
|
|
307
|
+
client: GraphReader,
|
|
308
|
+
epic_url: str,
|
|
309
|
+
*,
|
|
310
|
+
repository_id: int,
|
|
311
|
+
observed_at: str | None = None,
|
|
312
|
+
) -> EpicSnapshot:
|
|
313
|
+
"""Read the EPIC's native graph into a complete :class:`EpicSnapshot`, or refuse.
|
|
314
|
+
|
|
315
|
+
``repository_id`` is the verified repository's numeric id (the RunKey's,
|
|
316
|
+
#178); the EPIC URL's repository must have it now. ``observed_at``
|
|
317
|
+
defaults to the current UTC time. Every way the reading can be less
|
|
318
|
+
than complete raises :class:`~autoforge.errors.SnapshotRefused` with
|
|
319
|
+
its reason: ``read-failed`` (the read's error is the cause;
|
|
320
|
+
``transient`` when it may succeed later), ``repository-mismatch``,
|
|
321
|
+
``epic-not-open``, ``children-bound``, ``children-count``,
|
|
322
|
+
``blockers-bound``, ``blockers-count``, ``inconsistent`` and ``cycle``.
|
|
323
|
+
"""
|
|
324
|
+
epic_ref = parse_issue_url(epic_url)
|
|
325
|
+
if isinstance(repository_id, bool) or not isinstance(repository_id, int) or repository_id < 1:
|
|
326
|
+
raise ValueError(f"repository_id must be a positive integer, got {repository_id!r:.40}")
|
|
327
|
+
moment = _utc_timestamp(observed_at)
|
|
328
|
+
|
|
329
|
+
live_id = _read(
|
|
330
|
+
epic_ref, "the repository id", lambda: client.get_repository_id(epic_ref.repository)
|
|
331
|
+
)
|
|
332
|
+
if live_id != repository_id:
|
|
333
|
+
raise _refuse(
|
|
334
|
+
epic_ref,
|
|
335
|
+
"repository-mismatch",
|
|
336
|
+
f"{epic_ref.repository} has repository id {live_id}, not the verified {repository_id}",
|
|
337
|
+
)
|
|
338
|
+
epic = _read(epic_ref, "the EPIC", lambda: client.get_issue_node(epic_ref.canonical))
|
|
339
|
+
if not epic.is_open:
|
|
340
|
+
raise _refuse(
|
|
341
|
+
epic_ref,
|
|
342
|
+
"epic-not-open",
|
|
343
|
+
f"the EPIC is {epic.state} ({epic.state_reason or 'no reason'})",
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
if epic.sub_issues_total > MAX_SUB_ISSUES:
|
|
347
|
+
raise _refuse(
|
|
348
|
+
epic_ref,
|
|
349
|
+
"children-bound",
|
|
350
|
+
f"the EPIC reports {epic.sub_issues_total} sub-issues, over the bound of "
|
|
351
|
+
f"{MAX_SUB_ISSUES}",
|
|
352
|
+
)
|
|
353
|
+
rows = _read(epic_ref, "the sub-issue listing", lambda: client.list_sub_issues(epic.url))
|
|
354
|
+
children = _distinct(rows, epic_ref, "the sub-issue listing")
|
|
355
|
+
if len(children) > MAX_SUB_ISSUES:
|
|
356
|
+
raise _refuse(
|
|
357
|
+
epic_ref,
|
|
358
|
+
"children-bound",
|
|
359
|
+
f"the sub-issue listing holds {len(children)} issues, over the bound of "
|
|
360
|
+
f"{MAX_SUB_ISSUES}",
|
|
361
|
+
)
|
|
362
|
+
if len(children) != epic.sub_issues_total:
|
|
363
|
+
raise _refuse(
|
|
364
|
+
epic_ref,
|
|
365
|
+
"children-count",
|
|
366
|
+
f"the sub-issue listing holds {len(children)} distinct issues but the EPIC reports "
|
|
367
|
+
f"{epic.sub_issues_total}",
|
|
368
|
+
)
|
|
369
|
+
for child in children:
|
|
370
|
+
if child.id == epic.id or child.parent is None or not child.parent.same_target(epic.ref):
|
|
371
|
+
label = _issue_label(child.repository, child.number, epic.repository)
|
|
372
|
+
parent = child.parent.canonical if child.parent is not None else "none"
|
|
373
|
+
raise _refuse(
|
|
374
|
+
epic_ref,
|
|
375
|
+
"inconsistent",
|
|
376
|
+
f"the sub-issue listing holds {label}, whose parent is {parent}",
|
|
377
|
+
)
|
|
378
|
+
children = tuple(sorted(children, key=_child_order))
|
|
379
|
+
|
|
380
|
+
edges: list[Edge] = []
|
|
381
|
+
for child in children:
|
|
382
|
+
if not child.is_open or child.blocked_by_total == 0:
|
|
383
|
+
continue
|
|
384
|
+
label = _issue_label(child.repository, child.number, epic.repository)
|
|
385
|
+
if child.blocked_by_total > MAX_BLOCKED_BY:
|
|
386
|
+
raise _refuse(
|
|
387
|
+
epic_ref,
|
|
388
|
+
"blockers-bound",
|
|
389
|
+
f"{label} reports {child.blocked_by_total} blockers, over the bound of "
|
|
390
|
+
f"{MAX_BLOCKED_BY}",
|
|
391
|
+
)
|
|
392
|
+
listed = _read(
|
|
393
|
+
epic_ref, f"the blockers of {label}", partial(client.list_blocked_by, child.url)
|
|
394
|
+
)
|
|
395
|
+
blockers = _distinct(listed, epic_ref, f"the blockers of {label}")
|
|
396
|
+
if len(blockers) > MAX_BLOCKED_BY:
|
|
397
|
+
raise _refuse(
|
|
398
|
+
epic_ref,
|
|
399
|
+
"blockers-bound",
|
|
400
|
+
f"the blockers of {label} hold {len(blockers)} issues, over the bound of "
|
|
401
|
+
f"{MAX_BLOCKED_BY}",
|
|
402
|
+
)
|
|
403
|
+
if len(blockers) != child.blocked_by_total:
|
|
404
|
+
raise _refuse(
|
|
405
|
+
epic_ref,
|
|
406
|
+
"blockers-count",
|
|
407
|
+
f"the blockers of {label} hold {len(blockers)} distinct issues but {label} "
|
|
408
|
+
f"reports {child.blocked_by_total}",
|
|
409
|
+
)
|
|
410
|
+
edges.extend(Edge(child.id, blocker) for blocker in sorted(blockers, key=_blocker_order))
|
|
411
|
+
|
|
412
|
+
cycle = _find_cycle(children, edges)
|
|
413
|
+
if cycle:
|
|
414
|
+
raise _refuse(
|
|
415
|
+
epic_ref,
|
|
416
|
+
"cycle",
|
|
417
|
+
_describe_cycle(cycle, epic.repository),
|
|
418
|
+
cycle=tuple(node.number for node in cycle),
|
|
419
|
+
)
|
|
420
|
+
|
|
421
|
+
return EpicSnapshot(
|
|
422
|
+
repository=epic.repository,
|
|
423
|
+
repository_id=repository_id,
|
|
424
|
+
epic=epic,
|
|
425
|
+
children=children,
|
|
426
|
+
edges=tuple(edges),
|
|
427
|
+
observed_at=moment,
|
|
428
|
+
digest=snapshot_digest(repository_id, epic, children, edges),
|
|
429
|
+
)
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
# -- cycles ----------------------------------------------------------------------------------
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def _find_cycle(children: tuple[IssueNode, ...], edges: Iterable[Edge]) -> tuple[IssueNode, ...]:
|
|
436
|
+
"""One cycle among the open children, canonically; empty when they are acyclic.
|
|
437
|
+
|
|
438
|
+
An edge goes from a child to an open child that blocks it; a child
|
|
439
|
+
blocked by itself is a cycle of one. The cycle reported starts at the
|
|
440
|
+
lowest-numbered open child that lies on any cycle and is the shortest
|
|
441
|
+
one back to it, neighbours taken in ascending number, so it starts at
|
|
442
|
+
its own lowest number and is the same whatever order GitHub answered
|
|
443
|
+
in. A closed child decides nothing, so a cycle through one is none.
|
|
444
|
+
"""
|
|
445
|
+
open_children = {child.id: child for child in children if child.is_open}
|
|
446
|
+
graph: dict[int, list[IssueNode]] = {}
|
|
447
|
+
for edge in edges:
|
|
448
|
+
target = open_children.get(edge.blocker.id)
|
|
449
|
+
if edge.child_id in open_children and target is not None:
|
|
450
|
+
graph.setdefault(edge.child_id, []).append(target)
|
|
451
|
+
for targets in graph.values():
|
|
452
|
+
targets.sort(key=_child_order)
|
|
453
|
+
for start in sorted(open_children.values(), key=_child_order):
|
|
454
|
+
previous: dict[int, IssueNode | None] = {}
|
|
455
|
+
queue: deque[IssueNode] = deque()
|
|
456
|
+
for target in graph.get(start.id, ()):
|
|
457
|
+
if target.id not in previous:
|
|
458
|
+
previous[target.id] = None
|
|
459
|
+
queue.append(target)
|
|
460
|
+
while queue:
|
|
461
|
+
node = queue.popleft()
|
|
462
|
+
if node.id == start.id:
|
|
463
|
+
path: list[IssueNode] = []
|
|
464
|
+
step: IssueNode | None = previous[start.id]
|
|
465
|
+
while step is not None:
|
|
466
|
+
path.append(step)
|
|
467
|
+
step = previous[step.id]
|
|
468
|
+
return (start, *reversed(path))
|
|
469
|
+
for target in graph.get(node.id, ()):
|
|
470
|
+
if target.id not in previous:
|
|
471
|
+
previous[target.id] = node
|
|
472
|
+
queue.append(target)
|
|
473
|
+
return ()
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def _describe_cycle(cycle: tuple[IssueNode, ...], home: str) -> str:
|
|
477
|
+
labels = [_issue_label(node.repository, node.number, home) for node in cycle]
|
|
478
|
+
if len(labels) == 1:
|
|
479
|
+
return f"a dependency cycle among open children: {labels[0]} is blocked by itself"
|
|
480
|
+
chain = " -> ".join([*labels, labels[0]])
|
|
481
|
+
return f"a dependency cycle among open children: {chain} (each is blocked by the next)"
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
# -- digests (ADR 0008 D4.4) -----------------------------------------------------------------
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def _child_facts(child: IssueNode) -> dict[str, object]:
|
|
488
|
+
return {
|
|
489
|
+
"id": child.id,
|
|
490
|
+
"number": child.number,
|
|
491
|
+
"state": child.state,
|
|
492
|
+
"state_reason": child.state_reason,
|
|
493
|
+
"repository": child.repository.lower(),
|
|
494
|
+
"parent": (
|
|
495
|
+
None
|
|
496
|
+
if child.parent is None
|
|
497
|
+
else f"{child.parent.repository.lower()}#{child.parent.number}"
|
|
498
|
+
),
|
|
499
|
+
"sub_issues": child.sub_issues_total,
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def _edge_facts(child_id: int, blocker: BlockerNode) -> dict[str, object]:
|
|
504
|
+
return {
|
|
505
|
+
"child": child_id,
|
|
506
|
+
"blocker": blocker.id,
|
|
507
|
+
"state": blocker.state if blocker.readable else "unreadable",
|
|
508
|
+
"state_reason": blocker.state_reason,
|
|
509
|
+
"repository": None if blocker.repository is None else blocker.repository.lower(),
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def _sha256(document: dict[str, object]) -> str:
|
|
514
|
+
text = json.dumps(document, sort_keys=True, separators=(",", ":"), ensure_ascii=True)
|
|
515
|
+
return hashlib.sha256(text.encode("ascii")).hexdigest()
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def _facts_list(items: Iterable[dict[str, object]]) -> list[dict[str, object]]:
|
|
519
|
+
"""``items`` sorted by their canonical JSON: a list whose order no input decides."""
|
|
520
|
+
return sorted(items, key=lambda item: json.dumps(item, sort_keys=True))
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def snapshot_digest(
|
|
524
|
+
repository_id: int, epic: IssueNode, children: Iterable[IssueNode], edges: Iterable[Edge]
|
|
525
|
+
) -> str:
|
|
526
|
+
"""SHA-256 over canonical JSON of the snapshot's eligibility facts (D4.4).
|
|
527
|
+
|
|
528
|
+
The repository id; the EPIC's id, number and state; every child's id,
|
|
529
|
+
number, state, ``state_reason``, repository, parent and sub-issue count;
|
|
530
|
+
every edge as (child id, blocker id, blocker state, ``state_reason``,
|
|
531
|
+
repository). Keys and lists are sorted; nothing else is included.
|
|
532
|
+
"""
|
|
533
|
+
return _sha256(
|
|
534
|
+
{
|
|
535
|
+
"digest": DIGEST_VERSION,
|
|
536
|
+
"repository_id": repository_id,
|
|
537
|
+
"epic": {"id": epic.id, "number": epic.number, "state": epic.state},
|
|
538
|
+
"children": _facts_list(_child_facts(child) for child in children),
|
|
539
|
+
"edges": _facts_list(_edge_facts(edge.child_id, edge.blocker) for edge in edges),
|
|
540
|
+
}
|
|
541
|
+
)
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
def node_digest(repository_id: int, child: IssueNode, blockers: Iterable[BlockerNode]) -> str:
|
|
545
|
+
"""The digest of one child and its blockers: :func:`snapshot_digest`'s facts, restricted."""
|
|
546
|
+
return _sha256(
|
|
547
|
+
{
|
|
548
|
+
"digest": DIGEST_VERSION,
|
|
549
|
+
"repository_id": repository_id,
|
|
550
|
+
"child": _child_facts(child),
|
|
551
|
+
"edges": _facts_list(_edge_facts(child.id, blocker) for blocker in blockers),
|
|
552
|
+
}
|
|
553
|
+
)
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
# -- evaluation (ADR 0008 D4.1, D4.2) --------------------------------------------------------
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
@dataclass(frozen=True)
|
|
560
|
+
class BlockerStatus:
|
|
561
|
+
"""One blocker of a child with whether it is satisfied."""
|
|
562
|
+
|
|
563
|
+
blocker: BlockerNode
|
|
564
|
+
satisfaction: Satisfaction
|
|
565
|
+
|
|
566
|
+
def describe(self, home: str) -> str:
|
|
567
|
+
blocker = self.blocker
|
|
568
|
+
if not blocker.readable:
|
|
569
|
+
return f"a blocker the token may not read (issue id {blocker.id})"
|
|
570
|
+
label = _issue_label(blocker.repository, blocker.number, home)
|
|
571
|
+
if self.satisfaction is Satisfaction.OPEN:
|
|
572
|
+
return f"{label} (open{', reopened' if blocker.state_reason == 'reopened' else ''})"
|
|
573
|
+
if self.satisfaction is Satisfaction.CLOSED_UNSATISFIED:
|
|
574
|
+
return f"{label} (closed-unsatisfied: {blocker.state_reason or 'no reason'})"
|
|
575
|
+
return f"{label} (closed: completed)"
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
@dataclass(frozen=True)
|
|
579
|
+
class ChildVerdict:
|
|
580
|
+
"""The verdict on one child (D4.1) and what explains it.
|
|
581
|
+
|
|
582
|
+
``dependents`` is how many open EPIC children transitively depend on the
|
|
583
|
+
child, the first ordering key (D4.2). ``state_reason`` explains
|
|
584
|
+
``closed``, ``shape`` ``unsupported``, ``blockers`` (the unsatisfied
|
|
585
|
+
ones, sorted, with state and reason) ``blocked``, and ``fact`` ``owned``
|
|
586
|
+
and ``held``.
|
|
587
|
+
"""
|
|
588
|
+
|
|
589
|
+
number: int
|
|
590
|
+
issue_id: int
|
|
591
|
+
repository: str
|
|
592
|
+
kind: VerdictKind
|
|
593
|
+
dependents: int
|
|
594
|
+
state_reason: str | None = None
|
|
595
|
+
shape: Shape | None = None
|
|
596
|
+
blockers: tuple[BlockerStatus, ...] = ()
|
|
597
|
+
fact: LocalFact | None = None
|
|
598
|
+
|
|
599
|
+
@property
|
|
600
|
+
def reason(self) -> str:
|
|
601
|
+
"""The verdict in D4.1's vocabulary: ``unsupported:<shape>``, ``held:<reason>``, ..."""
|
|
602
|
+
if self.kind is VerdictKind.UNSUPPORTED and self.shape is not None:
|
|
603
|
+
return f"unsupported:{self.shape.value}"
|
|
604
|
+
if self.kind is VerdictKind.HELD and self.fact is not None:
|
|
605
|
+
return self.fact.value
|
|
606
|
+
return self.kind.value
|
|
607
|
+
|
|
608
|
+
@property
|
|
609
|
+
def ready(self) -> bool:
|
|
610
|
+
return self.kind is VerdictKind.READY
|
|
611
|
+
|
|
612
|
+
def explain(self) -> str:
|
|
613
|
+
"""A bounded, redacted line: the verdict and what it rests on, never a title."""
|
|
614
|
+
label = f"#{self.number}"
|
|
615
|
+
if self.shape is Shape.FOREIGN_REPOSITORY:
|
|
616
|
+
label = f"{self.repository}#{self.number}"
|
|
617
|
+
if self.kind is VerdictKind.CLOSED:
|
|
618
|
+
detail = f"closed ({self.state_reason or 'no reason'})"
|
|
619
|
+
elif self.kind is VerdictKind.UNSUPPORTED and self.shape is Shape.NESTED_PARENT:
|
|
620
|
+
detail = f"{self.reason}: it has sub-issues of its own; run that EPIC separately"
|
|
621
|
+
elif self.kind is VerdictKind.UNSUPPORTED:
|
|
622
|
+
detail = f"{self.reason}: it lives in {self.repository}"
|
|
623
|
+
elif self.kind is VerdictKind.BLOCKED:
|
|
624
|
+
# Only a child of the snapshot's own repository is ever blocked.
|
|
625
|
+
described = (status.describe(self.repository) for status in self.blockers)
|
|
626
|
+
detail = "blocked by " + ", ".join(described)
|
|
627
|
+
elif self.kind is VerdictKind.READY:
|
|
628
|
+
detail = f"ready ({self.dependents} open children depend on it)"
|
|
629
|
+
else:
|
|
630
|
+
detail = self.reason
|
|
631
|
+
return _bounded(f"{label}: {detail}")
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
def _check_local_facts(local_facts: Mapping[int, LocalFact] | None) -> dict[int, LocalFact]:
|
|
635
|
+
facts: dict[int, LocalFact] = {}
|
|
636
|
+
for number, fact in (local_facts or {}).items():
|
|
637
|
+
if isinstance(number, bool) or not isinstance(number, int) or number < 1:
|
|
638
|
+
raise ValueError(f"a local fact names {number!r:.40}, not an issue number")
|
|
639
|
+
if not isinstance(fact, LocalFact):
|
|
640
|
+
raise ValueError(f"the local fact of #{number} is {fact!r:.80}, not a LocalFact")
|
|
641
|
+
facts[number] = fact
|
|
642
|
+
return facts
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
def _dependents(snapshot: EpicSnapshot) -> dict[int, int]:
|
|
646
|
+
"""For each child id, how many open children transitively depend on it (D4.2)."""
|
|
647
|
+
children = {child.id: child for child in snapshot.children}
|
|
648
|
+
dependants: dict[int, set[int]] = {}
|
|
649
|
+
for edge in snapshot.edges:
|
|
650
|
+
if edge.blocker.id in children and edge.child_id in children:
|
|
651
|
+
dependants.setdefault(edge.blocker.id, set()).add(edge.child_id)
|
|
652
|
+
counts: dict[int, int] = {}
|
|
653
|
+
for child_id in children:
|
|
654
|
+
seen: set[int] = set()
|
|
655
|
+
stack = [child_id]
|
|
656
|
+
while stack:
|
|
657
|
+
for dependant in dependants.get(stack.pop(), ()):
|
|
658
|
+
if dependant not in seen and dependant != child_id:
|
|
659
|
+
seen.add(dependant)
|
|
660
|
+
stack.append(dependant)
|
|
661
|
+
counts[child_id] = sum(1 for issue_id in seen if children[issue_id].is_open)
|
|
662
|
+
return counts
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
def _verdict(
|
|
666
|
+
snapshot: EpicSnapshot, child: IssueNode, facts: Mapping[int, LocalFact], dependents: int
|
|
667
|
+
) -> ChildVerdict:
|
|
668
|
+
verdict = partial(
|
|
669
|
+
ChildVerdict,
|
|
670
|
+
number=child.number,
|
|
671
|
+
issue_id=child.id,
|
|
672
|
+
repository=child.repository,
|
|
673
|
+
dependents=dependents,
|
|
674
|
+
)
|
|
675
|
+
if not child.is_open:
|
|
676
|
+
return verdict(kind=VerdictKind.CLOSED, state_reason=child.state_reason)
|
|
677
|
+
if not _same_repository(child.repository, snapshot.repository):
|
|
678
|
+
return verdict(kind=VerdictKind.UNSUPPORTED, shape=Shape.FOREIGN_REPOSITORY)
|
|
679
|
+
if child.sub_issues_total > 0:
|
|
680
|
+
return verdict(kind=VerdictKind.UNSUPPORTED, shape=Shape.NESTED_PARENT)
|
|
681
|
+
statuses = (
|
|
682
|
+
BlockerStatus(blocker, blocker_satisfaction(blocker))
|
|
683
|
+
for blocker in sorted(snapshot.blockers_of(child.id), key=_blocker_order)
|
|
684
|
+
)
|
|
685
|
+
unsatisfied = tuple(s for s in statuses if s.satisfaction is not Satisfaction.SATISFIED)
|
|
686
|
+
if unsatisfied:
|
|
687
|
+
return verdict(kind=VerdictKind.BLOCKED, blockers=unsatisfied)
|
|
688
|
+
fact = facts.get(child.number)
|
|
689
|
+
if fact is LocalFact.OWNED:
|
|
690
|
+
return verdict(kind=VerdictKind.OWNED, fact=fact)
|
|
691
|
+
if fact is not None:
|
|
692
|
+
return verdict(kind=VerdictKind.HELD, fact=fact)
|
|
693
|
+
return verdict(kind=VerdictKind.READY)
|
|
694
|
+
|
|
695
|
+
|
|
696
|
+
def evaluate(
|
|
697
|
+
snapshot: EpicSnapshot, local_facts: Mapping[int, LocalFact] | None = None
|
|
698
|
+
) -> tuple[ChildVerdict, ...]:
|
|
699
|
+
"""One verdict per child of ``snapshot``, in admission order (D4.1, D4.2). Pure.
|
|
700
|
+
|
|
701
|
+
Precedence: ``closed`` > ``unsupported`` > ``blocked`` > ``owned`` >
|
|
702
|
+
``held`` > ``ready``. ``local_facts`` maps an issue number of the
|
|
703
|
+
snapshot's repository to its :class:`LocalFact`; a number the snapshot
|
|
704
|
+
does not list is not a child and gets no verdict. The tuple is ordered
|
|
705
|
+
by descending ``dependents``, then ascending issue number (then
|
|
706
|
+
repository), so its ``ready`` verdicts, in order, are the ready set as
|
|
707
|
+
admission takes it (:func:`ready_set`). The same snapshot and facts give
|
|
708
|
+
the identical tuple whatever order GitHub answered in.
|
|
709
|
+
"""
|
|
710
|
+
facts = _check_local_facts(local_facts)
|
|
711
|
+
dependents = _dependents(snapshot)
|
|
712
|
+
verdicts = (
|
|
713
|
+
_verdict(snapshot, child, facts, dependents[child.id]) for child in snapshot.children
|
|
714
|
+
)
|
|
715
|
+
return tuple(sorted(verdicts, key=lambda v: (-v.dependents, v.number, v.repository.lower())))
|
|
716
|
+
|
|
717
|
+
|
|
718
|
+
def ready_set(verdicts: Iterable[ChildVerdict]) -> tuple[ChildVerdict, ...]:
|
|
719
|
+
"""The ``ready`` verdicts of :func:`evaluate`'s tuple, in its order."""
|
|
720
|
+
return tuple(verdict for verdict in verdicts if verdict.ready)
|
|
721
|
+
|
|
722
|
+
|
|
723
|
+
# -- the admission re-read (ADR 0008 D2.2 step 4) --------------------------------------------
|
|
724
|
+
|
|
725
|
+
|
|
726
|
+
class RereadStatus(Enum):
|
|
727
|
+
UNCHANGED = "unchanged"
|
|
728
|
+
STALE = "stale"
|
|
729
|
+
|
|
730
|
+
|
|
731
|
+
@dataclass(frozen=True)
|
|
732
|
+
class NodeReread:
|
|
733
|
+
"""What admission's re-read of one child found.
|
|
734
|
+
|
|
735
|
+
``stale`` when any eligibility fact differs from the snapshot, or the
|
|
736
|
+
blocker listing could not be read completely; ``differences`` names each
|
|
737
|
+
(bounded, numbers and states only). ``node_digest`` is the digest of
|
|
738
|
+
the facts re-read.
|
|
739
|
+
"""
|
|
740
|
+
|
|
741
|
+
number: int
|
|
742
|
+
status: RereadStatus
|
|
743
|
+
node_digest: str
|
|
744
|
+
differences: tuple[str, ...] = ()
|
|
745
|
+
|
|
746
|
+
@property
|
|
747
|
+
def unchanged(self) -> bool:
|
|
748
|
+
return self.status is RereadStatus.UNCHANGED
|
|
749
|
+
|
|
750
|
+
|
|
751
|
+
def _child_differences(old: IssueNode, new: IssueNode, epic: IssueNode) -> list[str]:
|
|
752
|
+
differences: list[str] = []
|
|
753
|
+
if new.id != old.id:
|
|
754
|
+
differences.append(f"issue id {old.id} -> {new.id}")
|
|
755
|
+
if new.state != old.state or new.state_reason != old.state_reason:
|
|
756
|
+
if new.is_open:
|
|
757
|
+
differences.append(
|
|
758
|
+
f"state {old.state} ({old.state_reason or 'no reason'}) -> open "
|
|
759
|
+
f"({new.state_reason or 'no reason'})"
|
|
760
|
+
)
|
|
761
|
+
else:
|
|
762
|
+
differences.append(f"no longer open: closed ({new.state_reason or 'no reason'})")
|
|
763
|
+
if _child_facts(new)["parent"] != _child_facts(old)["parent"]:
|
|
764
|
+
now = "no parent" if new.parent is None else f"parent {new.parent.canonical}"
|
|
765
|
+
if old.parent is not None and old.parent.same_target(epic.ref):
|
|
766
|
+
differences.append(f"{now}, no longer the EPIC #{epic.number}")
|
|
767
|
+
else:
|
|
768
|
+
differences.append(now)
|
|
769
|
+
if new.sub_issues_total != old.sub_issues_total:
|
|
770
|
+
differences.append(f"sub-issues {old.sub_issues_total} -> {new.sub_issues_total}")
|
|
771
|
+
if new.repository.lower() != old.repository.lower():
|
|
772
|
+
differences.append(f"repository {old.repository} -> {new.repository}")
|
|
773
|
+
return differences
|
|
774
|
+
|
|
775
|
+
|
|
776
|
+
def _blocker_state(blocker: BlockerNode) -> str:
|
|
777
|
+
if not blocker.readable:
|
|
778
|
+
return "unreadable"
|
|
779
|
+
return f"{blocker.state} ({blocker.state_reason or 'no reason'})"
|
|
780
|
+
|
|
781
|
+
|
|
782
|
+
def _blocker_label(blocker: BlockerNode, home: str) -> str:
|
|
783
|
+
if not blocker.readable:
|
|
784
|
+
return f"blocker id {blocker.id}"
|
|
785
|
+
return f"blocker {_issue_label(blocker.repository, blocker.number, home)}"
|
|
786
|
+
|
|
787
|
+
|
|
788
|
+
def _edge_differences(
|
|
789
|
+
old: tuple[BlockerNode, ...], new: tuple[BlockerNode, ...], home: str
|
|
790
|
+
) -> list[str]:
|
|
791
|
+
before = {blocker.id: blocker for blocker in old}
|
|
792
|
+
after = {blocker.id: blocker for blocker in new}
|
|
793
|
+
differences: list[str] = []
|
|
794
|
+
for blocker in sorted(after.values(), key=_blocker_order):
|
|
795
|
+
previous = before.get(blocker.id)
|
|
796
|
+
label = _blocker_label(blocker, home)
|
|
797
|
+
if previous is None:
|
|
798
|
+
differences.append(f"{label} added ({_blocker_state(blocker)})")
|
|
799
|
+
elif _edge_facts(0, previous) != _edge_facts(0, blocker): # one child: its facts alone
|
|
800
|
+
differences.append(f"{label}: {_blocker_state(previous)} -> {_blocker_state(blocker)}")
|
|
801
|
+
for blocker in sorted(before.values(), key=_blocker_order):
|
|
802
|
+
if blocker.id not in after:
|
|
803
|
+
differences.append(f"{_blocker_label(blocker, home)} removed")
|
|
804
|
+
return differences
|
|
805
|
+
|
|
806
|
+
|
|
807
|
+
def reread_node(client: GraphReader, snapshot: EpicSnapshot, issue: int) -> NodeReread:
|
|
808
|
+
"""Re-read child ``issue`` and its complete blocker list; compare with the snapshot.
|
|
809
|
+
|
|
810
|
+
``unchanged`` only when the node digest of the re-read facts equals the
|
|
811
|
+
snapshot's. Otherwise ``stale``, with the differences: not open, a
|
|
812
|
+
different ``id``, a parent that is no longer the EPIC, sub-issues now
|
|
813
|
+
present, a blocker added, removed or in another state, or a blocker
|
|
814
|
+
listing that is not complete (short of, or over, its count or bound).
|
|
815
|
+
The blockers of a child that is no longer open are not read. A read
|
|
816
|
+
that fails raises its GitHubError: it is never ``unchanged``. The
|
|
817
|
+
snapshot is not changed.
|
|
818
|
+
"""
|
|
819
|
+
old = snapshot.child(issue)
|
|
820
|
+
node = client.get_issue_node(old.url)
|
|
821
|
+
blockers: tuple[BlockerNode, ...] = ()
|
|
822
|
+
problems: list[str] = []
|
|
823
|
+
if node.is_open and node.blocked_by_total > 0:
|
|
824
|
+
if node.blocked_by_total > MAX_BLOCKED_BY:
|
|
825
|
+
problems.append(
|
|
826
|
+
f"{node.blocked_by_total} blockers reported, over the bound of {MAX_BLOCKED_BY}"
|
|
827
|
+
)
|
|
828
|
+
else:
|
|
829
|
+
listed = client.list_blocked_by(old.url)
|
|
830
|
+
by_id: dict[int, BlockerNode] = {}
|
|
831
|
+
for blocker in listed:
|
|
832
|
+
if by_id.setdefault(blocker.id, blocker) != blocker:
|
|
833
|
+
problems.append(f"the blocker listing names issue id {blocker.id} twice")
|
|
834
|
+
blockers = tuple(sorted(by_id.values(), key=_blocker_order))
|
|
835
|
+
if len(blockers) != node.blocked_by_total:
|
|
836
|
+
problems.append(
|
|
837
|
+
f"the blocker listing holds {len(blockers)} distinct issues but "
|
|
838
|
+
f"{node.blocked_by_total} are reported"
|
|
839
|
+
)
|
|
840
|
+
digest = node_digest(snapshot.repository_id, node, blockers)
|
|
841
|
+
differences = _child_differences(old, node, snapshot.epic)
|
|
842
|
+
if node.is_open:
|
|
843
|
+
old_blockers = tuple(sorted(snapshot.blockers_of(old.id), key=_blocker_order))
|
|
844
|
+
differences.extend(_edge_differences(old_blockers, blockers, snapshot.repository))
|
|
845
|
+
differences.extend(problems)
|
|
846
|
+
if digest == snapshot.node_digest(issue) and not problems:
|
|
847
|
+
return NodeReread(issue, RereadStatus.UNCHANGED, digest)
|
|
848
|
+
if not differences:
|
|
849
|
+
differences.append("the node digest differs from the snapshot's")
|
|
850
|
+
return NodeReread(
|
|
851
|
+
issue, RereadStatus.STALE, digest, tuple(_bounded(item) for item in differences)
|
|
852
|
+
)
|