mandrel 2.46.0 → 2.48.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/docs/configuration.md +1 -0
- package/.agents/docs/quality-gates.md +48 -0
- package/.agents/schemas/story-deliver-terminal.schema.json +6 -1
- package/.agents/scripts/lib/baselines/kernel.js +19 -0
- package/.agents/scripts/lib/baselines/kinds/bundle-size.js +12 -0
- package/.agents/scripts/lib/baselines/kinds/coverage.js +1 -0
- package/.agents/scripts/lib/baselines/kinds/crap.js +21 -5
- package/.agents/scripts/lib/baselines/kinds/duplication.js +1 -0
- package/.agents/scripts/lib/baselines/kinds/kind-factory.js +26 -1
- package/.agents/scripts/lib/baselines/kinds/lighthouse.js +1 -0
- package/.agents/scripts/lib/baselines/kinds/lint.js +12 -0
- package/.agents/scripts/lib/baselines/kinds/maintainability.js +1 -0
- package/.agents/scripts/lib/baselines/kinds/mutation.js +1 -0
- package/.agents/scripts/lib/baselines/merge-envelopes.js +272 -0
- package/.agents/scripts/lib/bootstrap/baseline-merge-driver.js +175 -0
- package/.agents/scripts/lib/bootstrap/quality-bootstrap.js +8 -2
- package/.agents/scripts/lib/observability/source-classifier.js +1 -0
- package/.agents/scripts/lib/orchestration/column-sync.js +26 -2
- package/.agents/scripts/lib/orchestration/epic-container.js +56 -21
- package/.agents/scripts/lib/orchestration/epic-expansion.js +28 -6
- package/.agents/scripts/lib/orchestration/epic-rollup.js +460 -0
- package/.agents/scripts/lib/orchestration/run-epilogue.js +44 -104
- package/.agents/scripts/lib/orchestration/single-story-close/phases/post-land.js +60 -1
- package/.agents/scripts/merge-baseline.js +238 -0
- package/.agents/scripts/providers/github/errors.js +66 -10
- package/.agents/scripts/providers/github/sub-issues.js +8 -1
- package/.agents/scripts/single-story-init.js +35 -0
- package/.agents/workflows/helpers/deliver-reference.md +31 -9
- package/.agents/workflows/mandrel-deliver.md +3 -3
- package/docs/CHANGELOG.md +19 -0
- package/lib/cli/registry.js +63 -0
- package/package.json +1 -1
|
@@ -27,6 +27,32 @@ function isStoryTicket(issue) {
|
|
|
27
27
|
.includes(TYPE_LABELS.STORY);
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
/**
|
|
31
|
+
* The error for an Epic that yielded no children.
|
|
32
|
+
*
|
|
33
|
+
* The two empty cases have different remedies, so they get different messages:
|
|
34
|
+
* an operator told to go link Stories that are already linked will do the wrong
|
|
35
|
+
* thing for what was really a transient API failure (Story #5210).
|
|
36
|
+
*
|
|
37
|
+
* @param {number} id
|
|
38
|
+
* @param {boolean} nativeReadFailed
|
|
39
|
+
* @returns {Error}
|
|
40
|
+
*/
|
|
41
|
+
function noChildrenError(id, nativeReadFailed) {
|
|
42
|
+
if (nativeReadFailed) {
|
|
43
|
+
return new Error(
|
|
44
|
+
`[resolve-stories] Epic #${id} expanded to no child Stories, but the ` +
|
|
45
|
+
`native sub-issue read failed — the list is incomplete, not empty. ` +
|
|
46
|
+
`Re-run once the GitHub API read succeeds.`,
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
return new Error(
|
|
50
|
+
`[resolve-stories] Epic #${id} lists no child Stories. An Epic is a container: ` +
|
|
51
|
+
`link its Stories (a "- [ ] #N" checklist line or a GitHub sub-issue) ` +
|
|
52
|
+
`or deliver the Story ids directly.`,
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
30
56
|
/**
|
|
31
57
|
* Expand any container-Epic id in the requested set to its open child
|
|
32
58
|
* Stories, leaving every other id untouched.
|
|
@@ -90,17 +116,13 @@ export async function expandEpicIds({
|
|
|
90
116
|
continue;
|
|
91
117
|
}
|
|
92
118
|
|
|
93
|
-
const childIds = await readEpicChildIdsFrom({
|
|
119
|
+
const { ids: childIds, nativeReadFailed } = await readEpicChildIdsFrom({
|
|
94
120
|
epic: issue,
|
|
95
121
|
readNativeChildIds,
|
|
96
122
|
onWarn: warn,
|
|
97
123
|
});
|
|
98
124
|
if (childIds.length === 0) {
|
|
99
|
-
throw
|
|
100
|
-
`[resolve-stories] Epic #${id} lists no child Stories. An Epic is a container: ` +
|
|
101
|
-
`link its Stories (a "- [ ] #N" checklist line or a GitHub sub-issue) ` +
|
|
102
|
-
`or deliver the Story ids directly.`,
|
|
103
|
-
);
|
|
125
|
+
throw noChildrenError(id, nativeReadFailed);
|
|
104
126
|
}
|
|
105
127
|
|
|
106
128
|
const open = [];
|
|
@@ -0,0 +1,460 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* epic-rollup.js — derive a container Epic's board state from its children.
|
|
3
|
+
*
|
|
4
|
+
* A container Epic is never delivered, so nothing in the delivery engine
|
|
5
|
+
* ever writes to it. That left it inert on the board for the whole run it
|
|
6
|
+
* was the subject of: `columnForLabels` reads `agent::*` labels and the
|
|
7
|
+
* container carries none by construction, so the Status sync always skipped
|
|
8
|
+
* it; and the only closure lived in the multi-Story run epilogue, which a
|
|
9
|
+
* single-Story delivery never reaches.
|
|
10
|
+
*
|
|
11
|
+
* This module is the one place that answers "what state is this Epic in?"
|
|
12
|
+
* — by asking its children — and the one place that writes the answer.
|
|
13
|
+
* {@link rollUpEpicForStory} is invoked from the two per-Story lifecycle
|
|
14
|
+
* edges (the `agent::executing` flip in `single-story-init.js` and the
|
|
15
|
+
* post-land tail), which is what makes the rollup hold at N=1, and the run
|
|
16
|
+
* epilogue delegates its Epic close here so one closure rule exists.
|
|
17
|
+
*
|
|
18
|
+
* Three invariants shape the writes:
|
|
19
|
+
*
|
|
20
|
+
* 1. **The Epic never gains an `agent::*` label.** That absence keeps the
|
|
21
|
+
* container out of the bare `/mandrel-deliver` ready list and outside
|
|
22
|
+
* `lint-issue-body.js`, so the derived column goes to the board
|
|
23
|
+
* directly via `ColumnSync.setColumn` rather than through a label.
|
|
24
|
+
* 2. **Status recomputes in both directions; closure is one-way.** A
|
|
25
|
+
* reopened child pulls a closed Epic's Status back to `In Progress`
|
|
26
|
+
* and MUST NOT reopen the issue — an operator who closed a container
|
|
27
|
+
* deliberately is not overruled by a reopened child.
|
|
28
|
+
* 3. **Never throws.** Every step degrades with a reason. A stale
|
|
29
|
+
* container costs tidiness; a delivery failed on a board mutation
|
|
30
|
+
* costs a landed Story its terminal envelope.
|
|
31
|
+
* 4. **Closure requires an authoritative child list.** Invariant 3 makes
|
|
32
|
+
* every read degrade rather than fail, which is right for the writes
|
|
33
|
+
* that recompute next tick and wrong for the one that does not. When
|
|
34
|
+
* the native sub-issue read fails, the body checklist still answers
|
|
35
|
+
* "who are the children" — but no longer "are these *all* of them",
|
|
36
|
+
* and closing on that difference shut an Epic over 23 open children
|
|
37
|
+
* (Story #5210). Degraded reads keep the Status and assignee writes
|
|
38
|
+
* and lose only the close.
|
|
39
|
+
*
|
|
40
|
+
* @module lib/orchestration/epic-rollup
|
|
41
|
+
* @see Story #5205
|
|
42
|
+
* @see Story #5210 — fail closed on a degraded child read.
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
import { Logger } from '../Logger.js';
|
|
46
|
+
import { AGENT_LABELS, TYPE_LABELS } from '../label-constants.js';
|
|
47
|
+
import { ColumnSync, LABEL_TO_COLUMN } from './column-sync.js';
|
|
48
|
+
import { isEpicTicket, readEpicChildIdsFrom } from './epic-container.js';
|
|
49
|
+
import { resolveOperatorFromCandidates } from './lease-guard-shared.js';
|
|
50
|
+
import { deriveParentState } from './ticketing/bulk.js';
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Derived states that mean "children are moving" — the window in which the
|
|
54
|
+
* Epic carries an owner. `agent::blocked` counts: a blocked child is still
|
|
55
|
+
* this operator's problem, and dropping the assignee at the moment someone
|
|
56
|
+
* needs to be found would invert the signal.
|
|
57
|
+
*/
|
|
58
|
+
const IN_FLIGHT_STATES = new Set([
|
|
59
|
+
AGENT_LABELS.EXECUTING,
|
|
60
|
+
AGENT_LABELS.BLOCKED,
|
|
61
|
+
]);
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Read an Epic's native sub-issue children as issue numbers.
|
|
65
|
+
*
|
|
66
|
+
* A local adapter rather than an import from `resolve-stories.js`: that
|
|
67
|
+
* module is a CLI entrypoint, and reaching up into one from the lib layer to
|
|
68
|
+
* borrow four lines would invert the dependency direction for no gain. What
|
|
69
|
+
* matters is that the *reader* handed to `readEpicChildIdsFrom` behaves the
|
|
70
|
+
* same on both paths, which is what keeps an Epic from being expandable but
|
|
71
|
+
* unclosable.
|
|
72
|
+
*
|
|
73
|
+
* @param {object} provider
|
|
74
|
+
* @returns {(epic: object) => Promise<number[]>}
|
|
75
|
+
*/
|
|
76
|
+
function nativeChildReader(provider) {
|
|
77
|
+
return async (epic) => {
|
|
78
|
+
if (typeof provider?._getNativeSubIssues !== 'function') return [];
|
|
79
|
+
return provider._getNativeSubIssues(epic?.nodeId, epic?.number ?? epic?.id);
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Resolve the handle the Epic is assigned to while its children run.
|
|
85
|
+
*
|
|
86
|
+
* Deliberately the **non-throwing** resolution (`missingHandleBehavior:
|
|
87
|
+
* 'null'`), unlike the Story lease's: a container with no owner recorded is
|
|
88
|
+
* a cosmetic gap, and refusing the whole rollup over it would cost the
|
|
89
|
+
* Status write and the closure too.
|
|
90
|
+
*
|
|
91
|
+
* @param {object} config Resolved `.agentrc.json` config.
|
|
92
|
+
* @returns {string|null} Bare login, or null when none is configured.
|
|
93
|
+
*/
|
|
94
|
+
function resolveEpicOwner(config) {
|
|
95
|
+
return resolveOperatorFromCandidates({
|
|
96
|
+
candidates: [config?.github?.operatorHandle],
|
|
97
|
+
missingHandleBehavior: 'null',
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Normalize an issue's assignee list to bare logins.
|
|
103
|
+
*
|
|
104
|
+
* @param {unknown} raw
|
|
105
|
+
* @returns {string[]}
|
|
106
|
+
*/
|
|
107
|
+
function normalizeAssignees(raw) {
|
|
108
|
+
if (!Array.isArray(raw)) return [];
|
|
109
|
+
return raw
|
|
110
|
+
.map((a) => (typeof a === 'string' ? a : a?.login))
|
|
111
|
+
.filter((login) => typeof login === 'string' && login.length > 0);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Is this issue already closed?
|
|
116
|
+
*
|
|
117
|
+
* @param {{ state?: string }} issue
|
|
118
|
+
* @returns {boolean}
|
|
119
|
+
*/
|
|
120
|
+
function isClosed(issue) {
|
|
121
|
+
return String(issue?.state ?? '').toLowerCase() === 'closed';
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Read every child of one Epic, freshly.
|
|
126
|
+
*
|
|
127
|
+
* Returns `null` when any child is unreadable. That is the conservative
|
|
128
|
+
* answer, not a lazy one: `deriveParentState` reads "all children done" off
|
|
129
|
+
* the list it is handed, so a silently dropped child could close a container
|
|
130
|
+
* with work still open under it.
|
|
131
|
+
*
|
|
132
|
+
* Note the scope: this validates the **readability of the ids it was given**,
|
|
133
|
+
* never the **completeness of the id list**. Completeness is
|
|
134
|
+
* `nativeReadFailed`'s job in {@link rollUpOneEpic} — checking only this one
|
|
135
|
+
* is what let three readable ids stand in for 58 (Story #5210).
|
|
136
|
+
*
|
|
137
|
+
* @param {{ epicId: number, childIds: number[], provider: object }} opts
|
|
138
|
+
* @returns {Promise<object[]|null>}
|
|
139
|
+
*/
|
|
140
|
+
async function readChildren({ epicId, childIds, provider }) {
|
|
141
|
+
const children = [];
|
|
142
|
+
for (const childId of childIds) {
|
|
143
|
+
let child;
|
|
144
|
+
try {
|
|
145
|
+
child = await provider.getTicket(childId);
|
|
146
|
+
} catch (err) {
|
|
147
|
+
Logger.warn(
|
|
148
|
+
`[epic-rollup] Epic #${epicId}: could not read child #${childId} ` +
|
|
149
|
+
`(${err?.message ?? err}) — leaving the Epic untouched.`,
|
|
150
|
+
);
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
if (!child) {
|
|
154
|
+
Logger.warn(
|
|
155
|
+
`[epic-rollup] Epic #${epicId}: child #${childId} was not found — ` +
|
|
156
|
+
'leaving the Epic untouched.',
|
|
157
|
+
);
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
children.push(child);
|
|
161
|
+
}
|
|
162
|
+
return children;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Push the derived column onto the Epic's board item.
|
|
167
|
+
*
|
|
168
|
+
* @param {{ epicId: number, column: string|null, columnSync: object }} opts
|
|
169
|
+
* @returns {Promise<{ column: string|null, detail: string|null }>}
|
|
170
|
+
*/
|
|
171
|
+
async function applyColumn({ epicId, column, columnSync }) {
|
|
172
|
+
if (!column || !columnSync) return { column: null, detail: null };
|
|
173
|
+
try {
|
|
174
|
+
const result = await columnSync.setColumn(epicId, column);
|
|
175
|
+
if (result?.status === 'synced') return { column, detail: null };
|
|
176
|
+
return { column: null, detail: result?.reason ?? 'column-not-synced' };
|
|
177
|
+
} catch (err) {
|
|
178
|
+
return { column: null, detail: String(err?.message ?? err) };
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Record the operator as the Epic's owner, additively.
|
|
184
|
+
*
|
|
185
|
+
* The additive assignees mutation is the only one that cannot evict a login
|
|
186
|
+
* another run wrote between our read and our write, so it is the only one
|
|
187
|
+
* used here — and the reason the assignee is never removed when the Epic
|
|
188
|
+
* closes. A closed container naming who delivered it is useful; a removal
|
|
189
|
+
* would need the replacing endpoint and would race every concurrent run.
|
|
190
|
+
*
|
|
191
|
+
* @param {{ epicId: number, epic: object, owner: string|null, provider: object }} opts
|
|
192
|
+
* @returns {Promise<{ assigned: boolean, detail: string|null }>}
|
|
193
|
+
*/
|
|
194
|
+
async function applyOwner({ epicId, epic, owner, provider }) {
|
|
195
|
+
if (!owner) return { assigned: false, detail: 'no-operator-handle' };
|
|
196
|
+
if (normalizeAssignees(epic?.assignees).includes(owner)) {
|
|
197
|
+
return { assigned: false, detail: null };
|
|
198
|
+
}
|
|
199
|
+
try {
|
|
200
|
+
await provider.updateTicket(epicId, { addAssignees: [owner] });
|
|
201
|
+
return { assigned: true, detail: null };
|
|
202
|
+
} catch (err) {
|
|
203
|
+
return { assigned: false, detail: String(err?.message ?? err) };
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Close a container whose children have all landed.
|
|
209
|
+
*
|
|
210
|
+
* @param {{ epicId: number, provider: object }} opts
|
|
211
|
+
* @returns {Promise<{ closed: boolean, detail: string|null }>}
|
|
212
|
+
*/
|
|
213
|
+
async function applyClosure({ epicId, provider }) {
|
|
214
|
+
try {
|
|
215
|
+
await provider.updateTicket(epicId, {
|
|
216
|
+
state: 'closed',
|
|
217
|
+
state_reason: 'completed',
|
|
218
|
+
});
|
|
219
|
+
Logger.info(
|
|
220
|
+
`[epic-rollup] Closed container Epic #${epicId} — every child Story landed.`,
|
|
221
|
+
);
|
|
222
|
+
return { closed: true, detail: null };
|
|
223
|
+
} catch (err) {
|
|
224
|
+
return { closed: false, detail: String(err?.message ?? err) };
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Roll one Epic up from the children it lists.
|
|
230
|
+
*
|
|
231
|
+
* `nativeReadFailed` splits the writes by reversibility. Column and assignee
|
|
232
|
+
* are recomputed from scratch on every later tick, so applying them to a
|
|
233
|
+
* possibly-truncated list costs at most a stale board cell that self-corrects.
|
|
234
|
+
* Closure does not: it is the one write no subsequent tick undoes (invariant 2
|
|
235
|
+
* — a reopened child pulls Status back but MUST NOT reopen the issue), so it
|
|
236
|
+
* requires a child list we know to be complete.
|
|
237
|
+
*
|
|
238
|
+
* @param {{ epic: object, childIds: number[], nativeReadFailed?: boolean, provider: object, columnSync: object, owner: string|null }} opts
|
|
239
|
+
* @returns {Promise<object>} Per-Epic outcome record.
|
|
240
|
+
*/
|
|
241
|
+
async function rollUpOneEpic({
|
|
242
|
+
epic,
|
|
243
|
+
childIds,
|
|
244
|
+
nativeReadFailed = false,
|
|
245
|
+
provider,
|
|
246
|
+
columnSync,
|
|
247
|
+
owner,
|
|
248
|
+
}) {
|
|
249
|
+
const epicId = Number(epic?.number ?? epic?.id);
|
|
250
|
+
const outcome = {
|
|
251
|
+
epicId,
|
|
252
|
+
column: null,
|
|
253
|
+
assigned: false,
|
|
254
|
+
closed: false,
|
|
255
|
+
pending: false,
|
|
256
|
+
detail: null,
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
const children = await readChildren({ epicId, childIds, provider });
|
|
260
|
+
if (children === null) {
|
|
261
|
+
outcome.pending = true;
|
|
262
|
+
outcome.detail = 'child-read-failed';
|
|
263
|
+
return outcome;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const derived = deriveParentState(children);
|
|
267
|
+
const applied = await applyColumn({
|
|
268
|
+
epicId,
|
|
269
|
+
column: derived ? (LABEL_TO_COLUMN[derived] ?? null) : null,
|
|
270
|
+
columnSync,
|
|
271
|
+
});
|
|
272
|
+
outcome.column = applied.column;
|
|
273
|
+
if (applied.detail) outcome.detail = applied.detail;
|
|
274
|
+
|
|
275
|
+
if (IN_FLIGHT_STATES.has(derived)) {
|
|
276
|
+
const ownership = await applyOwner({ epicId, epic, owner, provider });
|
|
277
|
+
outcome.assigned = ownership.assigned;
|
|
278
|
+
if (ownership.detail) outcome.detail = ownership.detail;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
if (derived !== AGENT_LABELS.DONE) {
|
|
282
|
+
// Not every child has landed. Reported pending only when the Epic is
|
|
283
|
+
// still open — a closed container with an outstanding child is the
|
|
284
|
+
// reopened-child case, whose Status we just corrected and whose issue
|
|
285
|
+
// state is deliberately left alone.
|
|
286
|
+
outcome.pending = !isClosed(epic);
|
|
287
|
+
return outcome;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
if (isClosed(epic)) return outcome;
|
|
291
|
+
|
|
292
|
+
if (nativeReadFailed) {
|
|
293
|
+
// Every child we could see has landed — but the authoritative read threw,
|
|
294
|
+
// so "every child" is exactly the claim we cannot make. `readChildren`
|
|
295
|
+
// above validates that the ids we were handed are *readable*; nothing
|
|
296
|
+
// there validates that the list is *complete*, which is how an Epic with
|
|
297
|
+
// 23 open children closed off the three its body happened to spell in the
|
|
298
|
+
// bare `- [ ] #N` form (Story #5210). Overwrites any column/owner detail
|
|
299
|
+
// deliberately: this is the reason the Epic is still pending.
|
|
300
|
+
outcome.pending = true;
|
|
301
|
+
outcome.detail = 'child-read-degraded';
|
|
302
|
+
Logger.warn(
|
|
303
|
+
`[epic-rollup] Epic #${epicId}: every child read looks done, but the ` +
|
|
304
|
+
'native sub-issue read degraded — refusing to close on a possibly ' +
|
|
305
|
+
'incomplete child list. Re-run once the API read succeeds.',
|
|
306
|
+
);
|
|
307
|
+
return outcome;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const closure = await applyClosure({ epicId, provider });
|
|
311
|
+
outcome.closed = closure.closed;
|
|
312
|
+
outcome.pending = !closure.closed;
|
|
313
|
+
if (closure.detail) outcome.detail = closure.detail;
|
|
314
|
+
return outcome;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Find the open container Epics that list a given Story, with their children.
|
|
319
|
+
*
|
|
320
|
+
* The lookup runs child→parent by scanning open Epics because linkage is
|
|
321
|
+
* parent→child only — a Story body carries no pointer back, and adding one
|
|
322
|
+
* would reverse ADR `20260726-v2-story-collapse`. It is cheap: open
|
|
323
|
+
* containers are few, and only one listing this Story is ever read further.
|
|
324
|
+
*
|
|
325
|
+
* @param {{ storyId: number, provider: object, skipEpicIds: Set<number> }} opts
|
|
326
|
+
* @returns {Promise<Array<{ epic: object, childIds: number[], nativeReadFailed: boolean }>>}
|
|
327
|
+
*/
|
|
328
|
+
async function findEpicsForStory({ storyId, provider, skipEpicIds }) {
|
|
329
|
+
let epics;
|
|
330
|
+
try {
|
|
331
|
+
epics = await provider.listIssuesByLabel({
|
|
332
|
+
state: 'open',
|
|
333
|
+
labels: TYPE_LABELS.EPIC,
|
|
334
|
+
});
|
|
335
|
+
} catch (err) {
|
|
336
|
+
Logger.warn(
|
|
337
|
+
`[epic-rollup] Could not list open Epics (${err?.message ?? err}); ` +
|
|
338
|
+
'skipping the rollup.',
|
|
339
|
+
);
|
|
340
|
+
return [];
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const matches = [];
|
|
344
|
+
for (const epic of Array.isArray(epics) ? epics : []) {
|
|
345
|
+
if (!isEpicTicket(epic)) continue;
|
|
346
|
+
const epicId = Number(epic?.number ?? epic?.id);
|
|
347
|
+
if (!Number.isInteger(epicId)) continue;
|
|
348
|
+
if (skipEpicIds.has(epicId)) continue;
|
|
349
|
+
// Body checklist UNION native sub-issue edges — the same reader the
|
|
350
|
+
// delivery expansion uses. Reading the body alone here is what made an
|
|
351
|
+
// Epic whose children were linked in the GitHub UI expandable but
|
|
352
|
+
// permanently unclosable.
|
|
353
|
+
const { ids: childIds, nativeReadFailed } = await readEpicChildIdsFrom({
|
|
354
|
+
epic,
|
|
355
|
+
readNativeChildIds: nativeChildReader(provider),
|
|
356
|
+
onWarn: (message) => Logger.warn(message),
|
|
357
|
+
});
|
|
358
|
+
if (!childIds.includes(storyId)) {
|
|
359
|
+
// A degraded read can truncate this Story out of its own container's
|
|
360
|
+
// child list, which drops the Epic from the run entirely rather than
|
|
361
|
+
// rolling it up wrongly. Non-destructive, but silent — say so, since it
|
|
362
|
+
// is the same root cause as the refusal in `rollUpOneEpic`.
|
|
363
|
+
if (nativeReadFailed) {
|
|
364
|
+
Logger.warn(
|
|
365
|
+
`[epic-rollup] Epic #${epicId}: skipped for Story #${storyId} on a ` +
|
|
366
|
+
'degraded child read — the Story may in fact be linked to it.',
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
continue;
|
|
370
|
+
}
|
|
371
|
+
matches.push({ epic, childIds, nativeReadFailed });
|
|
372
|
+
}
|
|
373
|
+
return matches;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Roll every container Epic listing this Story up from its children.
|
|
378
|
+
*
|
|
379
|
+
* `skipEpicIds` exists for a caller that walks several Stories of one run:
|
|
380
|
+
* siblings share a container, so without it the second Story would re-derive
|
|
381
|
+
* — and re-close — an Epic the first already closed. Live listings filter to
|
|
382
|
+
* open Epics and would eventually hide it, but a caller must not have to rely
|
|
383
|
+
* on a remote read racing its own writes.
|
|
384
|
+
*
|
|
385
|
+
* @param {{
|
|
386
|
+
* storyId: number,
|
|
387
|
+
* provider: object,
|
|
388
|
+
* config?: object,
|
|
389
|
+
* columnSync?: object,
|
|
390
|
+
* owner?: string|null,
|
|
391
|
+
* skipEpicIds?: Iterable<number>,
|
|
392
|
+
* }} opts
|
|
393
|
+
* @returns {Promise<{ epics: object[], closed: number[], pending: number[], reason: string|null }>}
|
|
394
|
+
*/
|
|
395
|
+
export async function rollUpEpicForStory({
|
|
396
|
+
storyId,
|
|
397
|
+
provider,
|
|
398
|
+
config,
|
|
399
|
+
columnSync,
|
|
400
|
+
owner,
|
|
401
|
+
skipEpicIds,
|
|
402
|
+
}) {
|
|
403
|
+
const empty = { epics: [], closed: [], pending: [], reason: null };
|
|
404
|
+
const id = Number(storyId);
|
|
405
|
+
if (!Number.isInteger(id) || id <= 0) {
|
|
406
|
+
return { ...empty, reason: 'invalid-story-id' };
|
|
407
|
+
}
|
|
408
|
+
if (
|
|
409
|
+
typeof provider?.listIssuesByLabel !== 'function' ||
|
|
410
|
+
typeof provider?.getTicket !== 'function' ||
|
|
411
|
+
typeof provider?.updateTicket !== 'function'
|
|
412
|
+
) {
|
|
413
|
+
return { ...empty, reason: 'provider-unsupported' };
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
try {
|
|
417
|
+
const matches = await findEpicsForStory({
|
|
418
|
+
storyId: id,
|
|
419
|
+
provider,
|
|
420
|
+
skipEpicIds: new Set(skipEpicIds ?? []),
|
|
421
|
+
});
|
|
422
|
+
if (matches.length === 0) return { ...empty, reason: 'no-container-epic' };
|
|
423
|
+
|
|
424
|
+
// One ColumnSync across every Epic in the run: it caches the board
|
|
425
|
+
// metadata, so sharing it spends the resolve once instead of per Epic.
|
|
426
|
+
const sync =
|
|
427
|
+
columnSync ??
|
|
428
|
+
(typeof provider.graphql === 'function'
|
|
429
|
+
? new ColumnSync({ provider, logger: Logger, config })
|
|
430
|
+
: null);
|
|
431
|
+
const resolvedOwner =
|
|
432
|
+
owner === undefined ? resolveEpicOwner(config) : owner;
|
|
433
|
+
|
|
434
|
+
const epics = [];
|
|
435
|
+
for (const { epic, childIds, nativeReadFailed } of matches) {
|
|
436
|
+
epics.push(
|
|
437
|
+
await rollUpOneEpic({
|
|
438
|
+
epic,
|
|
439
|
+
childIds,
|
|
440
|
+
nativeReadFailed,
|
|
441
|
+
provider,
|
|
442
|
+
columnSync: sync,
|
|
443
|
+
owner: resolvedOwner,
|
|
444
|
+
}),
|
|
445
|
+
);
|
|
446
|
+
}
|
|
447
|
+
return {
|
|
448
|
+
epics,
|
|
449
|
+
closed: epics.filter((e) => e.closed).map((e) => e.epicId),
|
|
450
|
+
pending: epics.filter((e) => e.pending).map((e) => e.epicId),
|
|
451
|
+
reason: null,
|
|
452
|
+
};
|
|
453
|
+
} catch (err) {
|
|
454
|
+
// The module-level never-throws contract. Both call sites are lifecycle
|
|
455
|
+
// edges of a Story that is otherwise fine; neither may fail on this.
|
|
456
|
+
const detail = String(err?.message ?? err);
|
|
457
|
+
Logger.warn(`[epic-rollup] Rollup for Story #${id} failed: ${detail}`);
|
|
458
|
+
return { ...empty, reason: detail };
|
|
459
|
+
}
|
|
460
|
+
}
|