mandrel 2.54.0 → 2.56.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 +8 -2
- package/.agents/docs/configuration.md +5 -0
- package/.agents/rules/ci-remediation.md +39 -21
- package/.agents/schemas/agentrc.schema.json +34 -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 +374 -76
- 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/file-ci-gap.js +306 -0
- 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/audit-label-taxonomy.js +25 -1
- package/.agents/scripts/lib/audit-to-stories/dedupe-against-github.js +80 -29
- package/.agents/scripts/lib/audit-to-stories/finding-adapter.js +5 -1
- package/.agents/scripts/lib/audit-to-stories/issue-corpus.js +162 -0
- package/.agents/scripts/lib/audit-to-stories/issue-index.js +83 -0
- package/.agents/scripts/lib/audit-to-stories/issues-file.js +121 -0
- package/.agents/scripts/lib/audit-to-stories/ledger-commit.js +61 -115
- package/.agents/scripts/lib/audit-to-stories/ledger-pr.js +347 -0
- package/.agents/scripts/lib/audit-to-stories/ledger-record.js +126 -0
- package/.agents/scripts/lib/audit-to-stories/parse-audit-md.js +169 -44
- package/.agents/scripts/lib/audit-to-stories/seed-from-findings.js +11 -0
- 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 +52 -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/feedback-loop/graduator-core.js +53 -13
- package/.agents/scripts/lib/feedback-loop/prior-feedback-fetcher.js +71 -25
- package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +18 -25
- package/.agents/scripts/lib/{audit-to-stories/ledger.js → findings/audit-ledger.js} +131 -24
- package/.agents/scripts/lib/findings/route-finding.js +42 -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/github/framework-repo.js +148 -2
- package/.agents/scripts/lib/label-constants.js +6 -1
- package/.agents/scripts/lib/observability/source-classifier.js +2 -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/ci-gap-intake.js +605 -0
- package/.agents/scripts/lib/orchestration/ci-rerun-guard.js +13 -8
- 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 +233 -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/audit-provenance.js +197 -0
- 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 +39 -3
- 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 +63 -0
- 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 +63 -42
- 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/story-follow-ups.js +32 -20
- package/.agents/scripts/lib/orchestration/ticket-validator.js +25 -14
- package/.agents/scripts/lib/orchestration/ticketing/bulk.js +30 -0
- 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/pr-watch-with-update.js +3 -2
- 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/audit-to-stories.md +63 -27
- 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 +35 -14
- package/.agents/workflows/helpers/deliver-story-reference.md +26 -8
- package/.agents/workflows/helpers/deliver-story.md +15 -12
- package/.agents/workflows/helpers/plan-reference.md +30 -0
- package/.agents/workflows/mandrel-plan.md +10 -13
- package/.agents/workflows/memory-consolidate.md +14 -9
- package/docs/CHANGELOG.md +37 -0
- package/lib/cli/registry.js +64 -21
- package/lib/cli/sync.js +27 -2
- package/package.json +7 -4
|
@@ -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
|
*
|
|
@@ -41,18 +46,21 @@
|
|
|
41
46
|
* @see Story #5205
|
|
42
47
|
* @see Story #5210 — fail closed on a degraded child read.
|
|
43
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.
|
|
44
51
|
*/
|
|
45
52
|
|
|
46
53
|
import { Logger } from '../Logger.js';
|
|
47
54
|
import { AGENT_LABELS, TYPE_LABELS } from '../label-constants.js';
|
|
55
|
+
import { concurrentMap } from '../util/concurrent-map.js';
|
|
48
56
|
import { ColumnSync, LABEL_TO_COLUMN } from './column-sync.js';
|
|
49
57
|
import {
|
|
50
58
|
isEpicTicket,
|
|
59
|
+
nativeChildReader,
|
|
51
60
|
readEpicChildIdsFrom,
|
|
52
|
-
resolveEpicNodeId,
|
|
53
61
|
} from './epic-container.js';
|
|
54
62
|
import { resolveOperatorFromCandidates } from './lease-guard-shared.js';
|
|
55
|
-
import { deriveParentState } from './ticketing/bulk.js';
|
|
63
|
+
import { anyChildLanded, deriveParentState } from './ticketing/bulk.js';
|
|
56
64
|
|
|
57
65
|
/**
|
|
58
66
|
* Derived states that mean "children are moving" — the window in which the
|
|
@@ -73,31 +81,20 @@ const IN_FLIGHT_STATES = new Set([
|
|
|
73
81
|
]);
|
|
74
82
|
|
|
75
83
|
/**
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
* @param {object} provider
|
|
90
|
-
* @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.
|
|
91
96
|
*/
|
|
92
|
-
|
|
93
|
-
return async (epic) => {
|
|
94
|
-
const nodeId = resolveEpicNodeId(epic);
|
|
95
|
-
if (nodeId === null) return [];
|
|
96
|
-
return (
|
|
97
|
-
provider?._getNativeSubIssues?.(nodeId, epic?.number ?? epic?.id) ?? []
|
|
98
|
-
);
|
|
99
|
-
};
|
|
100
|
-
}
|
|
97
|
+
const FETCH_CONCURRENCY = 5;
|
|
101
98
|
|
|
102
99
|
/**
|
|
103
100
|
* Resolve the handle the Epic is assigned to while its children run.
|
|
@@ -141,44 +138,97 @@ function isClosed(issue) {
|
|
|
141
138
|
}
|
|
142
139
|
|
|
143
140
|
/**
|
|
144
|
-
*
|
|
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.
|
|
145
147
|
*
|
|
146
|
-
*
|
|
147
|
-
*
|
|
148
|
-
*
|
|
149
|
-
*
|
|
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.
|
|
168
|
+
*
|
|
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.
|
|
150
171
|
*
|
|
151
172
|
* Note the scope: this validates the **readability of the ids it was given**,
|
|
152
173
|
* never the **completeness of the id list**. Completeness is
|
|
153
174
|
* `nativeReadFailed`'s job in {@link rollUpOneEpic} — checking only this one
|
|
154
175
|
* is what let three readable ids stand in for 58 (Story #5210).
|
|
155
176
|
*
|
|
156
|
-
* @param {{ epicId: number, childIds: number[], provider: object }} opts
|
|
157
|
-
* @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>}
|
|
158
179
|
*/
|
|
159
|
-
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.
|
|
160
205
|
const children = [];
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
child = await provider.getTicket(childId);
|
|
165
|
-
} catch (err) {
|
|
206
|
+
const refused = [];
|
|
207
|
+
for (const { childId, child } of read) {
|
|
208
|
+
if (child === null) {
|
|
166
209
|
Logger.warn(
|
|
167
|
-
`[epic-rollup] Epic #${epicId}:
|
|
168
|
-
|
|
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.',
|
|
169
213
|
);
|
|
170
|
-
|
|
214
|
+
continue;
|
|
171
215
|
}
|
|
172
|
-
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' });
|
|
173
222
|
Logger.warn(
|
|
174
|
-
`[epic-rollup] Epic #${epicId}: child #${childId}
|
|
175
|
-
'
|
|
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.",
|
|
176
226
|
);
|
|
177
|
-
|
|
227
|
+
continue;
|
|
178
228
|
}
|
|
179
229
|
children.push(child);
|
|
180
230
|
}
|
|
181
|
-
return children;
|
|
231
|
+
return { children, refused };
|
|
182
232
|
}
|
|
183
233
|
|
|
184
234
|
/**
|
|
@@ -224,19 +274,28 @@ async function applyOwner({ epicId, epic, owner, provider }) {
|
|
|
224
274
|
}
|
|
225
275
|
|
|
226
276
|
/**
|
|
227
|
-
* Close a container whose children
|
|
277
|
+
* Close a container whose children are all finished.
|
|
228
278
|
*
|
|
229
|
-
*
|
|
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
|
|
230
286
|
* @returns {Promise<{ closed: boolean, detail: string|null }>}
|
|
231
287
|
*/
|
|
232
|
-
async function applyClosure({ epicId, provider }) {
|
|
288
|
+
async function applyClosure({ epicId, landed, provider }) {
|
|
233
289
|
try {
|
|
234
290
|
await provider.updateTicket(epicId, {
|
|
235
291
|
state: 'closed',
|
|
236
|
-
state_reason: 'completed',
|
|
292
|
+
state_reason: landed ? 'completed' : 'not_planned',
|
|
237
293
|
});
|
|
238
294
|
Logger.info(
|
|
239
|
-
|
|
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.',
|
|
240
299
|
);
|
|
241
300
|
return { closed: true, detail: null };
|
|
242
301
|
} catch (err) {
|
|
@@ -254,33 +313,44 @@ async function applyClosure({ epicId, provider }) {
|
|
|
254
313
|
* — a reopened child pulls Status back but MUST NOT reopen the issue), so it
|
|
255
314
|
* requires a child list we know to be complete.
|
|
256
315
|
*
|
|
257
|
-
* @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
|
|
258
317
|
* @returns {Promise<object>} Per-Epic outcome record.
|
|
259
318
|
*/
|
|
260
319
|
async function rollUpOneEpic({
|
|
261
320
|
epic,
|
|
262
321
|
childIds,
|
|
322
|
+
bodyOnlyIds = [],
|
|
263
323
|
nativeReadFailed = false,
|
|
264
324
|
provider,
|
|
265
325
|
columnSync,
|
|
266
326
|
owner,
|
|
267
327
|
}) {
|
|
268
|
-
|
|
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);
|
|
269
331
|
const outcome = {
|
|
270
332
|
epicId,
|
|
271
333
|
column: null,
|
|
272
334
|
assigned: false,
|
|
273
335
|
closed: false,
|
|
274
336
|
pending: false,
|
|
337
|
+
refused: [],
|
|
275
338
|
detail: null,
|
|
276
339
|
};
|
|
277
340
|
|
|
278
|
-
const
|
|
279
|
-
|
|
341
|
+
const read = await readChildren({
|
|
342
|
+
epicId,
|
|
343
|
+
childIds,
|
|
344
|
+
bodyOnlyIds,
|
|
345
|
+
provider,
|
|
346
|
+
});
|
|
347
|
+
if (read === null) {
|
|
280
348
|
outcome.pending = true;
|
|
281
349
|
outcome.detail = 'child-read-failed';
|
|
282
350
|
return outcome;
|
|
283
351
|
}
|
|
352
|
+
const { children, refused } = read;
|
|
353
|
+
outcome.refused = refused;
|
|
284
354
|
|
|
285
355
|
const derived = deriveParentState(children);
|
|
286
356
|
const applied = await applyColumn({
|
|
@@ -326,7 +396,11 @@ async function rollUpOneEpic({
|
|
|
326
396
|
return outcome;
|
|
327
397
|
}
|
|
328
398
|
|
|
329
|
-
const closure = await applyClosure({
|
|
399
|
+
const closure = await applyClosure({
|
|
400
|
+
epicId,
|
|
401
|
+
landed: anyChildLanded(children),
|
|
402
|
+
provider,
|
|
403
|
+
});
|
|
330
404
|
outcome.closed = closure.closed;
|
|
331
405
|
outcome.pending = !closure.closed;
|
|
332
406
|
if (closure.detail) outcome.detail = closure.detail;
|
|
@@ -334,47 +408,116 @@ async function rollUpOneEpic({
|
|
|
334
408
|
}
|
|
335
409
|
|
|
336
410
|
/**
|
|
337
|
-
*
|
|
411
|
+
* Resolve this Story's container in **one** request, via the native edge.
|
|
338
412
|
*
|
|
339
|
-
*
|
|
340
|
-
*
|
|
341
|
-
*
|
|
342
|
-
* 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.
|
|
343
417
|
*
|
|
344
|
-
*
|
|
345
|
-
*
|
|
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.
|
|
346
426
|
*/
|
|
347
|
-
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 [];
|
|
348
465
|
let epics;
|
|
349
466
|
try {
|
|
350
|
-
epics = await provider.
|
|
351
|
-
state: '
|
|
467
|
+
epics = await provider.listTicketsByLabel({
|
|
468
|
+
state: 'all',
|
|
352
469
|
labels: TYPE_LABELS.EPIC,
|
|
353
470
|
});
|
|
354
471
|
} catch (err) {
|
|
355
472
|
Logger.warn(
|
|
356
|
-
`[epic-rollup] Could not list
|
|
473
|
+
`[epic-rollup] Could not list Epics (${err?.message ?? err}); ` +
|
|
357
474
|
'skipping the rollup.',
|
|
358
475
|
);
|
|
359
476
|
return [];
|
|
360
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;
|
|
361
501
|
|
|
362
502
|
const matches = [];
|
|
363
|
-
for (const epic of
|
|
364
|
-
|
|
365
|
-
const epicId = Number(epic?.number ?? epic?.id);
|
|
503
|
+
for (const epic of epics) {
|
|
504
|
+
const epicId = Number(epic?.id);
|
|
366
505
|
if (!Number.isInteger(epicId)) continue;
|
|
367
506
|
if (skipEpicIds.has(epicId)) continue;
|
|
368
507
|
// Body checklist UNION native sub-issue edges — the same reader the
|
|
369
|
-
// delivery expansion uses
|
|
370
|
-
// Epic whose children were linked in the
|
|
371
|
-
// permanently unclosable.
|
|
372
|
-
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({
|
|
373
516
|
epic,
|
|
374
517
|
readNativeChildIds: nativeChildReader(provider),
|
|
375
518
|
onWarn: (message) => Logger.warn(message),
|
|
376
519
|
});
|
|
377
|
-
if (!childIds.includes(storyId)) {
|
|
520
|
+
if (!authoritative && !childIds.includes(storyId)) {
|
|
378
521
|
// A degraded read can truncate this Story out of its own container's
|
|
379
522
|
// child list, which drops the Epic from the run entirely rather than
|
|
380
523
|
// rolling it up wrongly. Non-destructive, but silent — say so, since it
|
|
@@ -387,7 +530,7 @@ async function findEpicsForStory({ storyId, provider, skipEpicIds }) {
|
|
|
387
530
|
}
|
|
388
531
|
continue;
|
|
389
532
|
}
|
|
390
|
-
matches.push({ epic, childIds, nativeReadFailed });
|
|
533
|
+
matches.push({ epic, childIds, nativeReadFailed, bodyOnlyIds });
|
|
391
534
|
}
|
|
392
535
|
return matches;
|
|
393
536
|
}
|
|
@@ -424,10 +567,15 @@ export async function rollUpEpicForStory({
|
|
|
424
567
|
if (!Number.isInteger(id) || id <= 0) {
|
|
425
568
|
return { ...empty, reason: 'invalid-story-id' };
|
|
426
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.
|
|
427
574
|
if (
|
|
428
|
-
typeof provider?.listIssuesByLabel !== 'function' ||
|
|
429
575
|
typeof provider?.getTicket !== 'function' ||
|
|
430
|
-
typeof provider?.updateTicket !== 'function'
|
|
576
|
+
typeof provider?.updateTicket !== 'function' ||
|
|
577
|
+
(typeof provider?.getParentIssue !== 'function' &&
|
|
578
|
+
typeof provider?.listTicketsByLabel !== 'function')
|
|
431
579
|
) {
|
|
432
580
|
return { ...empty, reason: 'provider-unsupported' };
|
|
433
581
|
}
|
|
@@ -451,11 +599,12 @@ export async function rollUpEpicForStory({
|
|
|
451
599
|
owner === undefined ? resolveEpicOwner(config) : owner;
|
|
452
600
|
|
|
453
601
|
const epics = [];
|
|
454
|
-
for (const { epic, childIds, nativeReadFailed } of matches) {
|
|
602
|
+
for (const { epic, childIds, nativeReadFailed, bodyOnlyIds } of matches) {
|
|
455
603
|
epics.push(
|
|
456
604
|
await rollUpOneEpic({
|
|
457
605
|
epic,
|
|
458
606
|
childIds,
|
|
607
|
+
bodyOnlyIds,
|
|
459
608
|
nativeReadFailed,
|
|
460
609
|
provider,
|
|
461
610
|
columnSync: sync,
|