mandrel 2.53.0 → 2.55.0
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.
- package/.agents/agents/story-worker.md +24 -23
- package/.agents/audit-checklists/accessibility.md +0 -3
- package/.agents/audit-checklists/mobile.md +0 -4
- package/.agents/docs/agentrc-reference.json +4 -2
- package/.agents/docs/configuration.md +2 -0
- package/.agents/schemas/agentrc.schema.json +15 -1
- package/.agents/schemas/lifecycle/merge.unlanded.schema.json +2 -1
- package/.agents/schemas/story-deliver-terminal.schema.json +1 -0
- package/.agents/scripts/audit-to-stories.js +158 -7
- package/.agents/scripts/check-audit-attribution.js +119 -62
- package/.agents/scripts/check-test-portability.js +512 -0
- package/.agents/scripts/coverage-capture.js +17 -10
- package/.agents/scripts/evidence-gate.js +31 -4
- package/.agents/scripts/generate-workflows-doc.js +65 -14
- package/.agents/scripts/git-cleanup.js +4 -0
- package/.agents/scripts/lib/ITicketingProvider.js +78 -0
- package/.agents/scripts/lib/audit-advisories.js +195 -0
- package/.agents/scripts/lib/audit-attribution.js +22 -0
- package/.agents/scripts/lib/audit-to-stories/dedupe-against-github.js +68 -5
- package/.agents/scripts/lib/audit-to-stories/issue-index.js +83 -0
- package/.agents/scripts/lib/audit-to-stories/ledger-commit.js +60 -114
- package/.agents/scripts/lib/audit-to-stories/ledger-pr.js +347 -0
- package/.agents/scripts/lib/audit-to-stories/parse-audit-md.js +169 -44
- package/.agents/scripts/lib/baselines/merge-envelopes.js +298 -32
- package/.agents/scripts/lib/bootstrap/baseline-merge-driver.js +180 -14
- package/.agents/scripts/lib/cli-args.js +26 -0
- package/.agents/scripts/lib/close-validation/gates.js +113 -7
- package/.agents/scripts/lib/close-validation/process.js +7 -3
- package/.agents/scripts/lib/close-validation/runner.js +62 -11
- package/.agents/scripts/lib/config/ci.js +28 -9
- package/.agents/scripts/lib/config-settings-schema-delivery.js +7 -0
- package/.agents/scripts/lib/config-settings-schema.js +19 -1
- package/.agents/scripts/lib/coverage-capture-fullscope.js +23 -11
- package/.agents/scripts/lib/coverage-capture-incremental.js +22 -16
- package/.agents/scripts/lib/coverage-capture-usage.js +5 -1
- package/.agents/scripts/lib/coverage-capture.js +77 -3
- package/.agents/scripts/lib/findings/route-finding.js +4 -2
- package/.agents/scripts/lib/full-suite-lock.js +232 -6
- package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
- package/.agents/scripts/lib/git/sync-from-base.js +130 -13
- package/.agents/scripts/lib/observability/source-classifier.js +1 -0
- package/.agents/scripts/lib/orchestration/check-baselines/phases/compare.js +10 -2
- package/.agents/scripts/lib/orchestration/check-baselines/phases/refresh-ack.js +75 -15
- package/.agents/scripts/lib/orchestration/deliver-recover.js +82 -43
- package/.agents/scripts/lib/orchestration/dependency-candidates.js +8 -4
- package/.agents/scripts/lib/orchestration/epic-candidates.js +9 -4
- package/.agents/scripts/lib/orchestration/epic-container.js +66 -4
- package/.agents/scripts/lib/orchestration/epic-rollup.js +241 -84
- package/.agents/scripts/lib/orchestration/file-assumptions.js +218 -16
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/branches.js +93 -7
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/git-probes.js +22 -6
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/parse-args.js +26 -5
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/phase-drivers.js +13 -2
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/render.js +35 -5
- package/.agents/scripts/lib/orchestration/merge-block-class.js +18 -3
- package/.agents/scripts/lib/orchestration/merge-poll.js +284 -40
- package/.agents/scripts/lib/orchestration/plan-persist/epic-adoption.js +49 -2
- package/.agents/scripts/lib/orchestration/plan-persist/epic-ops.js +43 -7
- package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +24 -1
- package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +5 -0
- package/.agents/scripts/lib/orchestration/plan-persist/summary.js +3 -0
- package/.agents/scripts/lib/orchestration/plan-persist/supersede-ops.js +119 -6
- package/.agents/scripts/lib/orchestration/plan-persist/wave-serialisation.js +110 -0
- package/.agents/scripts/lib/orchestration/planning/memory-pool-advisory.js +130 -40
- package/.agents/scripts/lib/orchestration/resolve-stories.js +44 -1
- package/.agents/scripts/lib/orchestration/review-providers/native.js +31 -11
- package/.agents/scripts/lib/orchestration/review-providers/scoped-lint.js +27 -24
- package/.agents/scripts/lib/orchestration/run-epilogue.js +59 -38
- package/.agents/scripts/lib/orchestration/single-story-close/close-note.js +81 -0
- package/.agents/scripts/lib/orchestration/single-story-close/failed-terminal.js +40 -51
- package/.agents/scripts/lib/orchestration/single-story-close/phases/auto-merge.js +10 -2
- package/.agents/scripts/lib/orchestration/single-story-close/phases/base-sync.js +101 -0
- package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +351 -28
- package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +27 -6
- package/.agents/scripts/lib/orchestration/single-story-close/runner.js +117 -22
- package/.agents/scripts/lib/orchestration/story-close/baseline-upward-writeback.js +94 -12
- package/.agents/scripts/lib/orchestration/story-close/format-autofix.js +6 -1
- package/.agents/scripts/lib/orchestration/ticket-validator.js +25 -14
- package/.agents/scripts/lib/orchestration/ticketing/bulk.js +70 -6
- package/.agents/scripts/lib/orchestration/verify-credit.js +37 -0
- package/.agents/scripts/lib/pinned-override-notes.js +41 -53
- package/.agents/scripts/lib/pinned-override-resolve.js +212 -0
- package/.agents/scripts/lib/qa/resolve-qa-contract.js +18 -0
- package/.agents/scripts/lib/single-story-sweep/sweep-lock.js +173 -9
- package/.agents/scripts/lib/skills/walk-skill-files.js +24 -7
- package/.agents/scripts/lib/test-temp.js +167 -30
- package/.agents/scripts/lib/validation-evidence.js +37 -0
- package/.agents/scripts/lib/wave-runner/footprint.js +167 -14
- package/.agents/scripts/lib/wave-runner/live-probe.js +7 -1
- package/.agents/scripts/lib/wave-runner/ready-set.js +1 -1
- package/.agents/scripts/merge-baseline.js +175 -21
- package/.agents/scripts/providers/github/errors.js +22 -1
- package/.agents/scripts/providers/github/issues.js +106 -1
- package/.agents/scripts/providers/github/sub-issue-add.js +18 -1
- package/.agents/scripts/providers/github.js +6 -0
- package/.agents/scripts/resolve-stories.js +44 -34
- package/.agents/scripts/single-story-close.js +5 -0
- package/.agents/scripts/stories-wave-tick.js +37 -13
- package/.agents/templates/docs/audit-sweep-runbook.md +41 -7
- package/.agents/workflows/audit-accessibility.md +16 -31
- package/.agents/workflows/audit-mobile.md +20 -37
- package/.agents/workflows/git-cleanup.md +17 -3
- package/.agents/workflows/helpers/audit-lens-core.md +45 -0
- package/.agents/workflows/helpers/deliver-digest.md +7 -6
- package/.agents/workflows/helpers/deliver-reference.md +40 -16
- package/.agents/workflows/helpers/deliver-story-reference.md +7 -4
- package/.agents/workflows/helpers/deliver-story.md +15 -12
- package/.agents/workflows/helpers/plan-reference.md +8 -1
- package/.agents/workflows/mandrel-plan.md +4 -7
- package/.agents/workflows/memory-consolidate.md +14 -9
- package/docs/CHANGELOG.md +34 -0
- package/lib/cli/registry.js +64 -21
- package/lib/cli/sync.js +27 -2
- package/package.json +7 -4
|
@@ -129,14 +129,64 @@ export function isEpicTicket(issue) {
|
|
|
129
129
|
* really had. Callers skip the read on a `null` instead, the same clean
|
|
130
130
|
* no-op `providers/github/board-add.js` makes with `reason: 'no-node-id'`.
|
|
131
131
|
*
|
|
132
|
+
* Module-private since the reader that consumes it moved here: `nativeChildReader`
|
|
133
|
+
* below is the only production caller, and exporting a helper nothing outside
|
|
134
|
+
* imports fails the production dead-export gate. Its behaviour is pinned
|
|
135
|
+
* through that reader.
|
|
136
|
+
*
|
|
132
137
|
* @param {{ nodeId?: unknown, node_id?: unknown }} epic
|
|
133
138
|
* @returns {string|null}
|
|
134
139
|
*/
|
|
135
|
-
|
|
140
|
+
function resolveEpicNodeId(epic) {
|
|
136
141
|
const nodeId = epic?.nodeId ?? epic?.node_id;
|
|
137
142
|
return typeof nodeId === 'string' && nodeId !== '' ? nodeId : null;
|
|
138
143
|
}
|
|
139
144
|
|
|
145
|
+
/**
|
|
146
|
+
* Read an Epic's native sub-issue children as issue numbers.
|
|
147
|
+
*
|
|
148
|
+
* The **one** definition, injected into `readEpicChildIdsFrom` by both the
|
|
149
|
+
* delivery expansion (`resolve-stories.js`) and the rollup
|
|
150
|
+
* (`epic-rollup.js`). It lived in each of them as a private copy, and the two
|
|
151
|
+
* copies are exactly the pair that must not drift: if the expansion sees a
|
|
152
|
+
* child the rollup does not, an Epic becomes expandable but permanently
|
|
153
|
+
* unclosable — the Story #5210 failure, arrived at from the other direction.
|
|
154
|
+
* Sharing the reader makes that class of divergence unrepresentable.
|
|
155
|
+
*
|
|
156
|
+
* It lives *here*, in the module that already describes what a container Epic
|
|
157
|
+
* is, rather than in either consumer: `resolve-stories.js` is a CLI entrypoint
|
|
158
|
+
* and importing one from the lib layer would invert the dependency direction.
|
|
159
|
+
* The provider is a parameter, so this module stays provider-agnostic.
|
|
160
|
+
*
|
|
161
|
+
* `resolveEpicNodeId` is what makes the two callers agree on the *id* as well
|
|
162
|
+
* as the reader: an Epic reached through a mapped read carries `nodeId`, one
|
|
163
|
+
* read raw from REST carries `node_id`, and neither caller can tell from the
|
|
164
|
+
* value it holds. A missing id yields `[]` rather than an `undefined` reaching
|
|
165
|
+
* GraphQL as a rejected `ID!`.
|
|
166
|
+
*
|
|
167
|
+
* @param {object} provider
|
|
168
|
+
* @returns {(epic: object) => Promise<number[]>}
|
|
169
|
+
*/
|
|
170
|
+
export function nativeChildReader(provider) {
|
|
171
|
+
return async (epic) => {
|
|
172
|
+
const nodeId = resolveEpicNodeId(epic);
|
|
173
|
+
if (nodeId === null) return [];
|
|
174
|
+
// The declared port first, the legacy private alias second: both forward
|
|
175
|
+
// to the same gateway on the live provider, and the fallback is what keeps
|
|
176
|
+
// test doubles written against the older name working.
|
|
177
|
+
const read =
|
|
178
|
+
provider?.getNativeSubIssues ?? provider?._getNativeSubIssues ?? null;
|
|
179
|
+
if (typeof read !== 'function') return [];
|
|
180
|
+
// Diagnostics-only second argument, and the one place the two shapes are
|
|
181
|
+
// still read together on purpose: this module is the declared bridge
|
|
182
|
+
// between them (see `resolveEpicNodeId` above), and the expression is
|
|
183
|
+
// correct under both — a raw REST issue names the issue number `number`,
|
|
184
|
+
// a mapped ticket names it `id`. Every *consumer* module now receives one
|
|
185
|
+
// declared shape and reads the field directly.
|
|
186
|
+
return (await read.call(provider, nodeId, epic?.number ?? epic?.id)) ?? [];
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
140
190
|
/**
|
|
141
191
|
* Render a container Epic's body.
|
|
142
192
|
*
|
|
@@ -230,12 +280,22 @@ export function readEpicChildIds(body) {
|
|
|
230
280
|
* supplied none never asked for authority and is not degraded relative to what
|
|
231
281
|
* it requested.
|
|
232
282
|
*
|
|
283
|
+
* **`bodyOnlyIds` names the ids the union owes to the checklist alone.** The
|
|
284
|
+
* two sources are not equally trustworthy about a *single* id: a native edge
|
|
285
|
+
* is a link the backend holds, so an id it returns names a real issue, while a
|
|
286
|
+
* checklist row is hand-editable prose and can cite an issue that was deleted,
|
|
287
|
+
* transferred, or simply mistyped. Callers that must decide what an
|
|
288
|
+
* unresolvable id means need to know which source vouched for it — a native id
|
|
289
|
+
* that will not resolve is a failed read, a body-only one is a typo. Empty
|
|
290
|
+
* when the native read failed or never ran: with no authoritative source to
|
|
291
|
+
* contrast against, nothing is "body-only" in the sense that matters.
|
|
292
|
+
*
|
|
233
293
|
* @param {{
|
|
234
294
|
* epic: { number?: number, id?: number, body?: string, nodeId?: string },
|
|
235
295
|
* readNativeChildIds?: (epic: object) => Promise<number[]>,
|
|
236
296
|
* onWarn?: (message: string) => void,
|
|
237
297
|
* }} opts
|
|
238
|
-
* @returns {Promise<{ ids: number[], nativeReadFailed: boolean }>}
|
|
298
|
+
* @returns {Promise<{ ids: number[], nativeReadFailed: boolean, bodyOnlyIds: number[] }>}
|
|
239
299
|
*/
|
|
240
300
|
export async function readEpicChildIdsFrom({
|
|
241
301
|
epic,
|
|
@@ -244,14 +304,16 @@ export async function readEpicChildIdsFrom({
|
|
|
244
304
|
} = {}) {
|
|
245
305
|
const fromBody = readEpicChildIds(epic?.body);
|
|
246
306
|
if (typeof readNativeChildIds !== 'function') {
|
|
247
|
-
return { ids: fromBody, nativeReadFailed: false };
|
|
307
|
+
return { ids: fromBody, nativeReadFailed: false, bodyOnlyIds: [] };
|
|
248
308
|
}
|
|
249
309
|
|
|
250
310
|
try {
|
|
251
311
|
const native = normalizeChildIds(await readNativeChildIds(epic));
|
|
312
|
+
const nativeSet = new Set(native);
|
|
252
313
|
return {
|
|
253
314
|
ids: normalizeChildIds([...native, ...fromBody]),
|
|
254
315
|
nativeReadFailed: false,
|
|
316
|
+
bodyOnlyIds: fromBody.filter((id) => !nativeSet.has(id)),
|
|
255
317
|
};
|
|
256
318
|
} catch (err) {
|
|
257
319
|
onWarn?.(
|
|
@@ -259,6 +321,6 @@ export async function readEpicChildIdsFrom({
|
|
|
259
321
|
`#${epic?.number ?? epic?.id ?? '?'} (${err?.message ?? String(err)}); ` +
|
|
260
322
|
'using the body checklist alone — the child list may be incomplete.',
|
|
261
323
|
);
|
|
262
|
-
return { ids: fromBody, nativeReadFailed: true };
|
|
324
|
+
return { ids: fromBody, nativeReadFailed: true, bodyOnlyIds: [] };
|
|
263
325
|
}
|
|
264
326
|
}
|
|
@@ -10,10 +10,15 @@
|
|
|
10
10
|
*
|
|
11
11
|
* This module is the one place that answers "what state is this Epic in?"
|
|
12
12
|
* — by asking its children — and the one place that writes the answer.
|
|
13
|
-
* {@link rollUpEpicForStory} is invoked from
|
|
14
|
-
*
|
|
15
|
-
* post-land tail
|
|
16
|
-
*
|
|
13
|
+
* {@link rollUpEpicForStory} is invoked from **every edge that changes a
|
|
14
|
+
* child's state**: the `agent::executing` flip in `single-story-init.js`, the
|
|
15
|
+
* post-land tail, and the supersede close in `plan-persist`. Holding at N=1
|
|
16
|
+
* was never the hard part — the gap was that a container only re-derived on
|
|
17
|
+
* the edges someone had remembered to wire, so a cohort superseded by a
|
|
18
|
+
* re-plan left its Epic open with no child that would ever move again.
|
|
19
|
+
* Because every trigger is a child state change, the fallback scan reads
|
|
20
|
+
* `state: 'all'`: "did this reopen work under a container I already closed?"
|
|
21
|
+
* is always a live question.
|
|
17
22
|
*
|
|
18
23
|
* Three invariants shape the writes:
|
|
19
24
|
*
|
|
@@ -40,24 +45,35 @@
|
|
|
40
45
|
* @module lib/orchestration/epic-rollup
|
|
41
46
|
* @see Story #5205
|
|
42
47
|
* @see Story #5210 — fail closed on a degraded child read.
|
|
48
|
+
* @see Story #5255 — a closed child contributes no `agent::*` state.
|
|
49
|
+
* @see Story #5280 — every child edge derives; the parent resolves in one
|
|
50
|
+
* call; the reads carry one declared shape.
|
|
43
51
|
*/
|
|
44
52
|
|
|
45
53
|
import { Logger } from '../Logger.js';
|
|
46
54
|
import { AGENT_LABELS, TYPE_LABELS } from '../label-constants.js';
|
|
55
|
+
import { concurrentMap } from '../util/concurrent-map.js';
|
|
47
56
|
import { ColumnSync, LABEL_TO_COLUMN } from './column-sync.js';
|
|
48
57
|
import {
|
|
49
58
|
isEpicTicket,
|
|
59
|
+
nativeChildReader,
|
|
50
60
|
readEpicChildIdsFrom,
|
|
51
|
-
resolveEpicNodeId,
|
|
52
61
|
} from './epic-container.js';
|
|
53
62
|
import { resolveOperatorFromCandidates } from './lease-guard-shared.js';
|
|
54
|
-
import { deriveParentState } from './ticketing/bulk.js';
|
|
63
|
+
import { anyChildLanded, deriveParentState } from './ticketing/bulk.js';
|
|
55
64
|
|
|
56
65
|
/**
|
|
57
66
|
* Derived states that mean "children are moving" — the window in which the
|
|
58
67
|
* Epic carries an owner. `agent::blocked` counts: a blocked child is still
|
|
59
68
|
* this operator's problem, and dropping the assignee at the moment someone
|
|
60
69
|
* needs to be found would invert the signal.
|
|
70
|
+
*
|
|
71
|
+
* "Blocked" here means an **open** blocked child. `deriveParentState` stopped
|
|
72
|
+
* reading closed children's `agent::*` labels in Story #5255 — a superseded
|
|
73
|
+
* Story closed while still wearing `agent::blocked` is not someone's problem
|
|
74
|
+
* to pick up, and the stale label used to derive `agent::blocked` forever,
|
|
75
|
+
* which both held an owner on the container and pinned it open past the
|
|
76
|
+
* `derived !== DONE` bail below.
|
|
61
77
|
*/
|
|
62
78
|
const IN_FLIGHT_STATES = new Set([
|
|
63
79
|
AGENT_LABELS.EXECUTING,
|
|
@@ -65,31 +81,20 @@ const IN_FLIGHT_STATES = new Set([
|
|
|
65
81
|
]);
|
|
66
82
|
|
|
67
83
|
/**
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
* @param {object} provider
|
|
82
|
-
* @returns {(epic: object) => Promise<number[]>}
|
|
84
|
+
* How many child reads a rollup keeps in flight.
|
|
85
|
+
*
|
|
86
|
+
* Matches the cap `resolve-stories.js` uses for the same shape of work — a
|
|
87
|
+
* fan-out of independent single-issue GETs against one repo — because the
|
|
88
|
+
* constraint being respected is GitHub's, not this module's: enough overlap to
|
|
89
|
+
* collapse a 58-child Epic from 58 sequential round-trips, well under the
|
|
90
|
+
* burst threshold that earns a secondary rate limit. The children of one Epic
|
|
91
|
+
* are order-independent, so the serial loop this replaces was paying
|
|
92
|
+
* `sum(round-trips)` for nothing.
|
|
93
|
+
*
|
|
94
|
+
* Deliberately not exported. Nothing outside this module reads the number, and
|
|
95
|
+
* an export only tests import fails the production dead-export gate.
|
|
83
96
|
*/
|
|
84
|
-
|
|
85
|
-
return async (epic) => {
|
|
86
|
-
const nodeId = resolveEpicNodeId(epic);
|
|
87
|
-
if (nodeId === null) return [];
|
|
88
|
-
return (
|
|
89
|
-
provider?._getNativeSubIssues?.(nodeId, epic?.number ?? epic?.id) ?? []
|
|
90
|
-
);
|
|
91
|
-
};
|
|
92
|
-
}
|
|
97
|
+
const FETCH_CONCURRENCY = 5;
|
|
93
98
|
|
|
94
99
|
/**
|
|
95
100
|
* Resolve the handle the Epic is assigned to while its children run.
|
|
@@ -133,44 +138,97 @@ function isClosed(issue) {
|
|
|
133
138
|
}
|
|
134
139
|
|
|
135
140
|
/**
|
|
136
|
-
*
|
|
141
|
+
* Fetch one child, or say why it does not count as one.
|
|
142
|
+
*
|
|
143
|
+
* Throws on a genuine read failure so the bounded fan-out around it rejects on
|
|
144
|
+
* the first — the conservative answer, not a lazy one: `deriveParentState`
|
|
145
|
+
* reads "all children done" off the list it is handed, so a silently dropped
|
|
146
|
+
* child could close a container with work still open under it.
|
|
147
|
+
*
|
|
148
|
+
* The one id it declines to fail on is a **body-only** one that resolves to
|
|
149
|
+
* nothing. The checklist is hand-editable prose, so a row can cite an issue
|
|
150
|
+
* that was deleted, transferred or mistyped, and no re-run will ever make it
|
|
151
|
+
* resolve — treating that as a failed read pins the container `pending`
|
|
152
|
+
* forever over a typo. A *native* id that will not resolve keeps failing the
|
|
153
|
+
* batch: the backend vouched for that edge, so its absence is a real read
|
|
154
|
+
* problem and next tick may well answer.
|
|
155
|
+
*
|
|
156
|
+
* @param {{ childId: number, droppable: boolean, provider: object }} opts
|
|
157
|
+
* @returns {Promise<{ childId: number, child: object|null }>}
|
|
158
|
+
*/
|
|
159
|
+
async function readOneChild({ childId, droppable, provider }) {
|
|
160
|
+
const child = await provider.getTicket(childId);
|
|
161
|
+
if (child) return { childId, child };
|
|
162
|
+
if (droppable) return { childId, child: null };
|
|
163
|
+
throw new Error(`child #${childId} was not found`);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Read every child of one Epic, freshly, bounded.
|
|
137
168
|
*
|
|
138
|
-
* Returns `null` when any child
|
|
139
|
-
*
|
|
140
|
-
* the list it is handed, so a silently dropped child could close a container
|
|
141
|
-
* with work still open under it.
|
|
169
|
+
* Returns `null` when any child the Epic genuinely claims is unreadable, and
|
|
170
|
+
* otherwise the children that count plus the ones refused by name.
|
|
142
171
|
*
|
|
143
172
|
* Note the scope: this validates the **readability of the ids it was given**,
|
|
144
173
|
* never the **completeness of the id list**. Completeness is
|
|
145
174
|
* `nativeReadFailed`'s job in {@link rollUpOneEpic} — checking only this one
|
|
146
175
|
* is what let three readable ids stand in for 58 (Story #5210).
|
|
147
176
|
*
|
|
148
|
-
* @param {{ epicId: number, childIds: number[], provider: object }} opts
|
|
149
|
-
* @returns {Promise<object[]|null>}
|
|
177
|
+
* @param {{ epicId: number, childIds: number[], bodyOnlyIds?: number[], provider: object }} opts
|
|
178
|
+
* @returns {Promise<{ children: object[], refused: Array<{ childId: number, reason: string }> }|null>}
|
|
150
179
|
*/
|
|
151
|
-
async function readChildren({ epicId, childIds, provider }) {
|
|
180
|
+
async function readChildren({ epicId, childIds, bodyOnlyIds = [], provider }) {
|
|
181
|
+
const droppable = new Set(bodyOnlyIds);
|
|
182
|
+
let read;
|
|
183
|
+
try {
|
|
184
|
+
read = await concurrentMap(
|
|
185
|
+
childIds,
|
|
186
|
+
(childId) =>
|
|
187
|
+
readOneChild({
|
|
188
|
+
childId,
|
|
189
|
+
droppable: droppable.has(childId),
|
|
190
|
+
provider,
|
|
191
|
+
}),
|
|
192
|
+
{ concurrency: FETCH_CONCURRENCY },
|
|
193
|
+
);
|
|
194
|
+
} catch (err) {
|
|
195
|
+
Logger.warn(
|
|
196
|
+
`[epic-rollup] Epic #${epicId}: could not read every child ` +
|
|
197
|
+
`(${err?.message ?? err}) — leaving the Epic untouched.`,
|
|
198
|
+
);
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Classified after the fan-out, in input order, so the warnings an operator
|
|
203
|
+
// reads and the `refused` list a caller reports do not depend on which
|
|
204
|
+
// round-trip happened to finish first.
|
|
152
205
|
const children = [];
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
child = await provider.getTicket(childId);
|
|
157
|
-
} catch (err) {
|
|
206
|
+
const refused = [];
|
|
207
|
+
for (const { childId, child } of read) {
|
|
208
|
+
if (child === null) {
|
|
158
209
|
Logger.warn(
|
|
159
|
-
`[epic-rollup] Epic #${epicId}:
|
|
160
|
-
|
|
210
|
+
`[epic-rollup] Epic #${epicId}: checklist row cites #${childId}, which ` +
|
|
211
|
+
'resolves to no issue and is not a native sub-issue edge — dropping ' +
|
|
212
|
+
'it from the child set. Fix or remove the row.',
|
|
161
213
|
);
|
|
162
|
-
|
|
214
|
+
continue;
|
|
163
215
|
}
|
|
164
|
-
if (
|
|
216
|
+
if (isEpicTicket(child)) {
|
|
217
|
+
// A container under a container. It has no `agent::*` label by
|
|
218
|
+
// construction, so `deriveParentState` would read it as neither done nor
|
|
219
|
+
// in flight and stall the parent on a child that is itself derived.
|
|
220
|
+
// Nesting containers is out of scope entirely; say so and move on.
|
|
221
|
+
refused.push({ childId, reason: 'epic-typed-child' });
|
|
165
222
|
Logger.warn(
|
|
166
|
-
`[epic-rollup] Epic #${epicId}: child #${childId}
|
|
167
|
-
'
|
|
223
|
+
`[epic-rollup] Epic #${epicId}: child #${childId} is itself a container ` +
|
|
224
|
+
'Epic — refused (epic-typed-child). Nested containers derive no ' +
|
|
225
|
+
"state, so it neither blocks nor advances this Epic's.",
|
|
168
226
|
);
|
|
169
|
-
|
|
227
|
+
continue;
|
|
170
228
|
}
|
|
171
229
|
children.push(child);
|
|
172
230
|
}
|
|
173
|
-
return children;
|
|
231
|
+
return { children, refused };
|
|
174
232
|
}
|
|
175
233
|
|
|
176
234
|
/**
|
|
@@ -216,19 +274,28 @@ async function applyOwner({ epicId, epic, owner, provider }) {
|
|
|
216
274
|
}
|
|
217
275
|
|
|
218
276
|
/**
|
|
219
|
-
* Close a container whose children
|
|
277
|
+
* Close a container whose children are all finished.
|
|
220
278
|
*
|
|
221
|
-
*
|
|
279
|
+
* `landed` picks the reason and the sentence, and the two must agree. A cohort
|
|
280
|
+
* re-planned out of existence closes every child as superseded — finished, so
|
|
281
|
+
* the container is finished too, but nothing merged. Reporting that as
|
|
282
|
+
* `completed` over "every child Story landed" is a false claim in the one
|
|
283
|
+
* place an operator goes to find out what a run actually delivered.
|
|
284
|
+
*
|
|
285
|
+
* @param {{ epicId: number, landed: boolean, provider: object }} opts
|
|
222
286
|
* @returns {Promise<{ closed: boolean, detail: string|null }>}
|
|
223
287
|
*/
|
|
224
|
-
async function applyClosure({ epicId, provider }) {
|
|
288
|
+
async function applyClosure({ epicId, landed, provider }) {
|
|
225
289
|
try {
|
|
226
290
|
await provider.updateTicket(epicId, {
|
|
227
291
|
state: 'closed',
|
|
228
|
-
state_reason: 'completed',
|
|
292
|
+
state_reason: landed ? 'completed' : 'not_planned',
|
|
229
293
|
});
|
|
230
294
|
Logger.info(
|
|
231
|
-
|
|
295
|
+
landed
|
|
296
|
+
? `[epic-rollup] Closed container Epic #${epicId} — every child Story landed.`
|
|
297
|
+
: `[epic-rollup] Closed container Epic #${epicId} as not_planned — every ` +
|
|
298
|
+
'child is closed and none landed.',
|
|
232
299
|
);
|
|
233
300
|
return { closed: true, detail: null };
|
|
234
301
|
} catch (err) {
|
|
@@ -246,33 +313,44 @@ async function applyClosure({ epicId, provider }) {
|
|
|
246
313
|
* — a reopened child pulls Status back but MUST NOT reopen the issue), so it
|
|
247
314
|
* requires a child list we know to be complete.
|
|
248
315
|
*
|
|
249
|
-
* @param {{ epic: object, childIds: number[], nativeReadFailed?: boolean, provider: object, columnSync: object, owner: string|null }} opts
|
|
316
|
+
* @param {{ epic: object, childIds: number[], bodyOnlyIds?: number[], nativeReadFailed?: boolean, provider: object, columnSync: object, owner: string|null }} opts
|
|
250
317
|
* @returns {Promise<object>} Per-Epic outcome record.
|
|
251
318
|
*/
|
|
252
319
|
async function rollUpOneEpic({
|
|
253
320
|
epic,
|
|
254
321
|
childIds,
|
|
322
|
+
bodyOnlyIds = [],
|
|
255
323
|
nativeReadFailed = false,
|
|
256
324
|
provider,
|
|
257
325
|
columnSync,
|
|
258
326
|
owner,
|
|
259
327
|
}) {
|
|
260
|
-
|
|
328
|
+
// `epic.id` and nothing else: every read that reaches here now returns the
|
|
329
|
+
// declared ticket shape, in which `id` IS the issue number.
|
|
330
|
+
const epicId = Number(epic?.id);
|
|
261
331
|
const outcome = {
|
|
262
332
|
epicId,
|
|
263
333
|
column: null,
|
|
264
334
|
assigned: false,
|
|
265
335
|
closed: false,
|
|
266
336
|
pending: false,
|
|
337
|
+
refused: [],
|
|
267
338
|
detail: null,
|
|
268
339
|
};
|
|
269
340
|
|
|
270
|
-
const
|
|
271
|
-
|
|
341
|
+
const read = await readChildren({
|
|
342
|
+
epicId,
|
|
343
|
+
childIds,
|
|
344
|
+
bodyOnlyIds,
|
|
345
|
+
provider,
|
|
346
|
+
});
|
|
347
|
+
if (read === null) {
|
|
272
348
|
outcome.pending = true;
|
|
273
349
|
outcome.detail = 'child-read-failed';
|
|
274
350
|
return outcome;
|
|
275
351
|
}
|
|
352
|
+
const { children, refused } = read;
|
|
353
|
+
outcome.refused = refused;
|
|
276
354
|
|
|
277
355
|
const derived = deriveParentState(children);
|
|
278
356
|
const applied = await applyColumn({
|
|
@@ -318,7 +396,11 @@ async function rollUpOneEpic({
|
|
|
318
396
|
return outcome;
|
|
319
397
|
}
|
|
320
398
|
|
|
321
|
-
const closure = await applyClosure({
|
|
399
|
+
const closure = await applyClosure({
|
|
400
|
+
epicId,
|
|
401
|
+
landed: anyChildLanded(children),
|
|
402
|
+
provider,
|
|
403
|
+
});
|
|
322
404
|
outcome.closed = closure.closed;
|
|
323
405
|
outcome.pending = !closure.closed;
|
|
324
406
|
if (closure.detail) outcome.detail = closure.detail;
|
|
@@ -326,47 +408,116 @@ async function rollUpOneEpic({
|
|
|
326
408
|
}
|
|
327
409
|
|
|
328
410
|
/**
|
|
329
|
-
*
|
|
411
|
+
* Resolve this Story's container in **one** request, via the native edge.
|
|
330
412
|
*
|
|
331
|
-
*
|
|
332
|
-
*
|
|
333
|
-
*
|
|
334
|
-
* containers
|
|
413
|
+
* `getParentIssue` reads the sub-issue link backwards, which is the question
|
|
414
|
+
* this module actually asks. The scan below is what it replaces: listing every
|
|
415
|
+
* candidate Epic and reading each one's children until one of them mentions
|
|
416
|
+
* the Story — O(containers) requests, all but one of them discarded.
|
|
335
417
|
*
|
|
336
|
-
*
|
|
337
|
-
*
|
|
418
|
+
* Returns `null` — never throws — when the provider has no such port, when the
|
|
419
|
+
* Story has no parent, or when the parent is not a container Epic. Each of
|
|
420
|
+
* those is "no answer *here*", and the caller's checklist scan is the other
|
|
421
|
+
* half: linkage can legitimately exist as a body row with no native edge
|
|
422
|
+
* behind it.
|
|
423
|
+
*
|
|
424
|
+
* @param {{ storyId: number, provider: object }} opts
|
|
425
|
+
* @returns {Promise<object|null>} Mapped parent Epic, or null.
|
|
338
426
|
*/
|
|
339
|
-
async function
|
|
427
|
+
async function parentEpicFor({ storyId, provider }) {
|
|
428
|
+
if (typeof provider?.getParentIssue !== 'function') return null;
|
|
429
|
+
let parent;
|
|
430
|
+
try {
|
|
431
|
+
parent = await provider.getParentIssue(storyId);
|
|
432
|
+
} catch (err) {
|
|
433
|
+
Logger.warn(
|
|
434
|
+
`[epic-rollup] Parent lookup for Story #${storyId} degraded ` +
|
|
435
|
+
`(${err?.message ?? err}); falling back to the label scan.`,
|
|
436
|
+
);
|
|
437
|
+
return null;
|
|
438
|
+
}
|
|
439
|
+
if (!parent || !isEpicTicket(parent)) return null;
|
|
440
|
+
return parent;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* Every container Epic on the board, as the fallback for a Story whose parent
|
|
445
|
+
* edge the native read could not answer.
|
|
446
|
+
*
|
|
447
|
+
* **`state: 'all'`, deliberately.** The scan used to be `state: 'open'`, which
|
|
448
|
+
* made two of this module's documented behaviours unreachable: a closed
|
|
449
|
+
* container was never listed, so a *reopened* child could never pull its
|
|
450
|
+
* Status back to `In Progress`, and the `isClosed(epic)` branches downstream
|
|
451
|
+
* were dead code asserting a rule nothing could exercise. Every edge that
|
|
452
|
+
* invokes this rollup is a child's own state change — init, post-land, the
|
|
453
|
+
* supersede close, the epilogue — so "did this change reopen work under a
|
|
454
|
+
* container I already closed?" is always a live question, and an open-only
|
|
455
|
+
* listing answers it wrongly rather than partially.
|
|
456
|
+
*
|
|
457
|
+
* Closing stays one-way regardless: {@link rollUpOneEpic} corrects a closed
|
|
458
|
+
* Epic's Status and never writes its issue state.
|
|
459
|
+
*
|
|
460
|
+
* @param {{ provider: object }} opts
|
|
461
|
+
* @returns {Promise<object[]>}
|
|
462
|
+
*/
|
|
463
|
+
async function scanContainerEpics({ provider }) {
|
|
464
|
+
if (typeof provider?.listTicketsByLabel !== 'function') return [];
|
|
340
465
|
let epics;
|
|
341
466
|
try {
|
|
342
|
-
epics = await provider.
|
|
343
|
-
state: '
|
|
467
|
+
epics = await provider.listTicketsByLabel({
|
|
468
|
+
state: 'all',
|
|
344
469
|
labels: TYPE_LABELS.EPIC,
|
|
345
470
|
});
|
|
346
471
|
} catch (err) {
|
|
347
472
|
Logger.warn(
|
|
348
|
-
`[epic-rollup] Could not list
|
|
473
|
+
`[epic-rollup] Could not list Epics (${err?.message ?? err}); ` +
|
|
349
474
|
'skipping the rollup.',
|
|
350
475
|
);
|
|
351
476
|
return [];
|
|
352
477
|
}
|
|
478
|
+
return (Array.isArray(epics) ? epics : []).filter(isEpicTicket);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* Find the container Epics that hold a given Story, with their children.
|
|
483
|
+
*
|
|
484
|
+
* The lookup runs child→parent because linkage is parent→child only — a Story
|
|
485
|
+
* body carries no pointer back, and adding one would reverse ADR
|
|
486
|
+
* `20260726-v2-story-collapse`. It resolves the native edge first and scans
|
|
487
|
+
* only when that answers nothing.
|
|
488
|
+
*
|
|
489
|
+
* The two paths differ in one thing: an Epic the *native* read named is this
|
|
490
|
+
* Story's parent by construction, so its child list is read to derive state,
|
|
491
|
+
* not to confirm the link. A *scanned* Epic still has to prove it lists the
|
|
492
|
+
* Story.
|
|
493
|
+
*
|
|
494
|
+
* @param {{ storyId: number, provider: object, skipEpicIds: Set<number> }} opts
|
|
495
|
+
* @returns {Promise<Array<{ epic: object, childIds: number[], nativeReadFailed: boolean, bodyOnlyIds: number[] }>>}
|
|
496
|
+
*/
|
|
497
|
+
async function findEpicsForStory({ storyId, provider, skipEpicIds }) {
|
|
498
|
+
const parent = await parentEpicFor({ storyId, provider });
|
|
499
|
+
const epics = parent ? [parent] : await scanContainerEpics({ provider });
|
|
500
|
+
const authoritative = parent !== null;
|
|
353
501
|
|
|
354
502
|
const matches = [];
|
|
355
|
-
for (const epic of
|
|
356
|
-
|
|
357
|
-
const epicId = Number(epic?.number ?? epic?.id);
|
|
503
|
+
for (const epic of epics) {
|
|
504
|
+
const epicId = Number(epic?.id);
|
|
358
505
|
if (!Number.isInteger(epicId)) continue;
|
|
359
506
|
if (skipEpicIds.has(epicId)) continue;
|
|
360
507
|
// Body checklist UNION native sub-issue edges — the same reader the
|
|
361
|
-
// delivery expansion uses
|
|
362
|
-
// Epic whose children were linked in the
|
|
363
|
-
// permanently unclosable.
|
|
364
|
-
const {
|
|
508
|
+
// delivery expansion uses, now literally the same function. Reading the
|
|
509
|
+
// body alone here is what made an Epic whose children were linked in the
|
|
510
|
+
// GitHub UI expandable but permanently unclosable.
|
|
511
|
+
const {
|
|
512
|
+
ids: childIds,
|
|
513
|
+
nativeReadFailed,
|
|
514
|
+
bodyOnlyIds,
|
|
515
|
+
} = await readEpicChildIdsFrom({
|
|
365
516
|
epic,
|
|
366
517
|
readNativeChildIds: nativeChildReader(provider),
|
|
367
518
|
onWarn: (message) => Logger.warn(message),
|
|
368
519
|
});
|
|
369
|
-
if (!childIds.includes(storyId)) {
|
|
520
|
+
if (!authoritative && !childIds.includes(storyId)) {
|
|
370
521
|
// A degraded read can truncate this Story out of its own container's
|
|
371
522
|
// child list, which drops the Epic from the run entirely rather than
|
|
372
523
|
// rolling it up wrongly. Non-destructive, but silent — say so, since it
|
|
@@ -379,7 +530,7 @@ async function findEpicsForStory({ storyId, provider, skipEpicIds }) {
|
|
|
379
530
|
}
|
|
380
531
|
continue;
|
|
381
532
|
}
|
|
382
|
-
matches.push({ epic, childIds, nativeReadFailed });
|
|
533
|
+
matches.push({ epic, childIds, nativeReadFailed, bodyOnlyIds });
|
|
383
534
|
}
|
|
384
535
|
return matches;
|
|
385
536
|
}
|
|
@@ -416,10 +567,15 @@ export async function rollUpEpicForStory({
|
|
|
416
567
|
if (!Number.isInteger(id) || id <= 0) {
|
|
417
568
|
return { ...empty, reason: 'invalid-story-id' };
|
|
418
569
|
}
|
|
570
|
+
// One of the two lookup ports is enough: `getParentIssue` answers directly
|
|
571
|
+
// and `listTicketsByLabel` answers by scanning, and `findEpicsForStory`
|
|
572
|
+
// degrades from either to the other. Both absent means no way to reach a
|
|
573
|
+
// container at all.
|
|
419
574
|
if (
|
|
420
|
-
typeof provider?.listIssuesByLabel !== 'function' ||
|
|
421
575
|
typeof provider?.getTicket !== 'function' ||
|
|
422
|
-
typeof provider?.updateTicket !== 'function'
|
|
576
|
+
typeof provider?.updateTicket !== 'function' ||
|
|
577
|
+
(typeof provider?.getParentIssue !== 'function' &&
|
|
578
|
+
typeof provider?.listTicketsByLabel !== 'function')
|
|
423
579
|
) {
|
|
424
580
|
return { ...empty, reason: 'provider-unsupported' };
|
|
425
581
|
}
|
|
@@ -443,11 +599,12 @@ export async function rollUpEpicForStory({
|
|
|
443
599
|
owner === undefined ? resolveEpicOwner(config) : owner;
|
|
444
600
|
|
|
445
601
|
const epics = [];
|
|
446
|
-
for (const { epic, childIds, nativeReadFailed } of matches) {
|
|
602
|
+
for (const { epic, childIds, nativeReadFailed, bodyOnlyIds } of matches) {
|
|
447
603
|
epics.push(
|
|
448
604
|
await rollUpOneEpic({
|
|
449
605
|
epic,
|
|
450
606
|
childIds,
|
|
607
|
+
bodyOnlyIds,
|
|
451
608
|
nativeReadFailed,
|
|
452
609
|
provider,
|
|
453
610
|
columnSync: sync,
|