autoforge-cli 0.1.0a0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. autoforge/__init__.py +5 -0
  2. autoforge/agent_worktree.py +653 -0
  3. autoforge/certificate.py +495 -0
  4. autoforge/child_env.py +871 -0
  5. autoforge/claims.py +726 -0
  6. autoforge/claude_stream.py +501 -0
  7. autoforge/cli.py +2884 -0
  8. autoforge/config.py +2178 -0
  9. autoforge/config_fingerprint.py +374 -0
  10. autoforge/doctor.py +1551 -0
  11. autoforge/doctor_sandbox.py +691 -0
  12. autoforge/effect_ops.py +1272 -0
  13. autoforge/effects.py +3210 -0
  14. autoforge/engine.py +16637 -0
  15. autoforge/epic_commands.py +1486 -0
  16. autoforge/epic_graph.py +852 -0
  17. autoforge/errors.py +536 -0
  18. autoforge/evidence.py +1722 -0
  19. autoforge/executor.py +1601 -0
  20. autoforge/executor_duplex.py +985 -0
  21. autoforge/executor_supervised.py +144 -0
  22. autoforge/git_transport.py +970 -0
  23. autoforge/github.py +3402 -0
  24. autoforge/interaction.py +1027 -0
  25. autoforge/local_workspace.py +1253 -0
  26. autoforge/locking.py +1050 -0
  27. autoforge/loop_detect.py +660 -0
  28. autoforge/loop_guard.py +404 -0
  29. autoforge/materialize.py +722 -0
  30. autoforge/ownership.py +240 -0
  31. autoforge/pi_rpc.py +713 -0
  32. autoforge/premerge.py +181 -0
  33. autoforge/profiles.py +106 -0
  34. autoforge/progress.py +392 -0
  35. autoforge/prompts/__init__.py +173 -0
  36. autoforge/prompts/acceptance_actions.md +39 -0
  37. autoforge/prompts/analyze_execute.md +128 -0
  38. autoforge/prompts/common.md +132 -0
  39. autoforge/prompts/correction.md +12 -0
  40. autoforge/prompts/fix.md +252 -0
  41. autoforge/prompts/local_analyze_execute.md +69 -0
  42. autoforge/prompts/local_common.md +149 -0
  43. autoforge/prompts/local_fix.md +96 -0
  44. autoforge/prompts/local_review.md +103 -0
  45. autoforge/prompts/operator_acceptance.md +61 -0
  46. autoforge/prompts/replan_reexecute.md +991 -0
  47. autoforge/prompts/review.md +312 -0
  48. autoforge/prompts/update_epic.md +120 -0
  49. autoforge/prompts/update_epic_rerequest.md +85 -0
  50. autoforge/prompts/validation_evidence.md +26 -0
  51. autoforge/prompts/validation_failure.md +22 -0
  52. autoforge/prompts/worktree_state.md +45 -0
  53. autoforge/providers.py +2893 -0
  54. autoforge/read_context.py +389 -0
  55. autoforge/redaction.py +408 -0
  56. autoforge/replan.py +208 -0
  57. autoforge/replan_txn.py +1930 -0
  58. autoforge/result_parser.py +2234 -0
  59. autoforge/roadmap.py +151 -0
  60. autoforge/run_contract.py +408 -0
  61. autoforge/run_namespace.py +839 -0
  62. autoforge/runlog.py +764 -0
  63. autoforge/safefs.py +1717 -0
  64. autoforge/sandbox.py +1313 -0
  65. autoforge/sandbox_bwrap.py +815 -0
  66. autoforge/sandbox_pin.py +170 -0
  67. autoforge/sandbox_policy.py +1107 -0
  68. autoforge/scheduler.py +1143 -0
  69. autoforge/scheduler_state.py +2316 -0
  70. autoforge/scheduler_stop.py +421 -0
  71. autoforge/sdk_policy.py +988 -0
  72. autoforge/state.py +2417 -0
  73. autoforge/transitions.py +388 -0
  74. autoforge/validation.py +324 -0
  75. autoforge/worker.py +568 -0
  76. autoforge_cli-0.1.0a0.dist-info/METADATA +525 -0
  77. autoforge_cli-0.1.0a0.dist-info/RECORD +81 -0
  78. autoforge_cli-0.1.0a0.dist-info/WHEEL +5 -0
  79. autoforge_cli-0.1.0a0.dist-info/entry_points.txt +2 -0
  80. autoforge_cli-0.1.0a0.dist-info/licenses/LICENSE +21 -0
  81. autoforge_cli-0.1.0a0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,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
+ )