mandrel 2.39.0 → 2.40.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/README.md +6 -3
- package/.agents/agents/auditor.md +5 -0
- package/.agents/docs/SDLC.md +21 -12
- package/.agents/instructions.md +17 -16
- package/.agents/scripts/audit-to-stories.js +510 -66
- package/.agents/scripts/lib/audit-to-stories/epic-grouping-directive.js +39 -0
- package/.agents/scripts/lib/audit-to-stories/ledger-commit.js +290 -0
- package/.agents/scripts/lib/audit-to-stories/parse-audit-md.js +94 -3
- package/.agents/scripts/lib/audit-to-stories/seed-from-findings.js +10 -0
- package/.agents/scripts/lib/label-constants.js +18 -0
- package/.agents/scripts/lib/label-taxonomy.js +18 -5
- package/.agents/scripts/lib/orchestration/epic-container.js +186 -0
- package/.agents/scripts/lib/orchestration/epic-expansion.js +148 -0
- package/.agents/scripts/lib/orchestration/plan-persist/epic-ops.js +320 -0
- package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +18 -0
- package/.agents/scripts/lib/orchestration/run-epilogue.js +130 -1
- package/.agents/scripts/plan-persist.js +39 -1
- package/.agents/scripts/providers/github/sub-issue-add.js +218 -0
- package/.agents/scripts/resolve-stories.js +42 -2
- package/.agents/templates/docs/audit-sweep-runbook.md +169 -0
- package/.agents/workflows/audit-to-stories.md +85 -7
- package/.agents/workflows/helpers/audit-lens-core.md +24 -4
- package/.agents/workflows/helpers/deliver-reference.md +8 -0
- package/.agents/workflows/helpers/plan-reference.md +28 -0
- package/.agents/workflows/mandrel-deliver.md +47 -43
- package/.agents/workflows/mandrel-plan.md +44 -38
- package/docs/CHANGELOG.md +16 -0
- package/package.json +1 -1
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
* 2. Rolls up friction follow-ups across every Story in the run and
|
|
8
8
|
* files/posts them on the primary Story.
|
|
9
9
|
* 3. Checks sibling Spec/acceptance coherence across Story bodies.
|
|
10
|
+
* 4. Closes any container Epic whose children all landed (Story #5139) —
|
|
11
|
+
* the only completion cascade v2 reintroduces.
|
|
10
12
|
*
|
|
11
13
|
* There is no inert planner-only path: `planRunEpilogue` enumerates steps
|
|
12
14
|
* and `runPlanRunEpilogue` executes them. Single-Story runs skip the
|
|
@@ -19,6 +21,8 @@ import { selectAudits } from '../audit-suite/index.js';
|
|
|
19
21
|
import { graduateRetroProposals } from '../feedback-loop/retro-proposals-graduator.js';
|
|
20
22
|
import { gitSpawn } from '../git-utils.js';
|
|
21
23
|
import { Logger } from '../Logger.js';
|
|
24
|
+
import { AGENT_LABELS, TYPE_LABELS } from '../label-constants.js';
|
|
25
|
+
import { isEpicTicket, readEpicChildIds } from './epic-container.js';
|
|
22
26
|
import { composeRoutedProposals } from './retro-proposals.js';
|
|
23
27
|
import {
|
|
24
28
|
assessRollupOutcome,
|
|
@@ -31,14 +35,130 @@ import { upsertStructuredComment } from './ticketing.js';
|
|
|
31
35
|
|
|
32
36
|
/**
|
|
33
37
|
* Canonical epilogue step kinds, in execution order.
|
|
34
|
-
* @type {readonly ['audit-roster', 'follow-up-rollup', 'sibling-coherence']}
|
|
38
|
+
* @type {readonly ['audit-roster', 'follow-up-rollup', 'sibling-coherence', 'epic-close']}
|
|
35
39
|
*/
|
|
36
40
|
export const RUN_EPILOGUE_STEP_KINDS = Object.freeze([
|
|
37
41
|
'audit-roster',
|
|
38
42
|
'follow-up-rollup',
|
|
39
43
|
'sibling-coherence',
|
|
44
|
+
'epic-close',
|
|
40
45
|
]);
|
|
41
46
|
|
|
47
|
+
/**
|
|
48
|
+
* Close a container Epic once every child Story has landed.
|
|
49
|
+
*
|
|
50
|
+
* This is the **only** completion cascade v2 reintroduces (Story #5139), and
|
|
51
|
+
* it is deliberately one-directional: closing the container, never touching a
|
|
52
|
+
* child's state, never reopening.
|
|
53
|
+
*
|
|
54
|
+
* The lookup runs child→parent by scanning open Epics, because linkage is
|
|
55
|
+
* parent→child only — a Story body carries no pointer back. That is the
|
|
56
|
+
* price of leaving Story bodies untouched, and it is cheap: open Epics are
|
|
57
|
+
* few, and the scan is scoped to Epics that actually contain one of this
|
|
58
|
+
* run's delivered Stories, so an unrelated Epic is never swept.
|
|
59
|
+
*
|
|
60
|
+
* Non-fatal throughout: the epilogue is a reporting tail, and a container
|
|
61
|
+
* left open costs tidiness, not correctness.
|
|
62
|
+
*
|
|
63
|
+
* @param {{ stories: string[], provider: object }} opts
|
|
64
|
+
* @returns {Promise<{ kind: string, closed: number[], pending: number[] }>}
|
|
65
|
+
*/
|
|
66
|
+
async function executeEpicClose({ stories, provider }) {
|
|
67
|
+
const result = { kind: 'epic-close', closed: [], pending: [] };
|
|
68
|
+
if (
|
|
69
|
+
typeof provider?.listIssuesByLabel !== 'function' ||
|
|
70
|
+
typeof provider?.updateTicket !== 'function'
|
|
71
|
+
) {
|
|
72
|
+
return result;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const delivered = new Set(stories.map((id) => Number(id)));
|
|
76
|
+
let epics;
|
|
77
|
+
try {
|
|
78
|
+
epics = await provider.listIssuesByLabel({
|
|
79
|
+
state: 'open',
|
|
80
|
+
labels: TYPE_LABELS.EPIC,
|
|
81
|
+
});
|
|
82
|
+
} catch (err) {
|
|
83
|
+
Logger.warn(
|
|
84
|
+
`[run-epilogue] Could not list open Epics (${err?.message ?? err}); skipping the Epic close.`,
|
|
85
|
+
);
|
|
86
|
+
return result;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
for (const epic of Array.isArray(epics) ? epics : []) {
|
|
90
|
+
if (!isEpicTicket(epic)) continue;
|
|
91
|
+
const epicId = Number(epic?.number ?? epic?.id);
|
|
92
|
+
if (!Number.isInteger(epicId)) continue;
|
|
93
|
+
|
|
94
|
+
const childIds = readEpicChildIds(epic?.body);
|
|
95
|
+
if (childIds.length === 0) continue;
|
|
96
|
+
// Only Epics this run actually advanced. Sweeping every open Epic would
|
|
97
|
+
// make a delivery close containers it had nothing to do with.
|
|
98
|
+
if (!childIds.some((c) => delivered.has(c))) continue;
|
|
99
|
+
|
|
100
|
+
let allLanded = true;
|
|
101
|
+
for (const childId of childIds) {
|
|
102
|
+
try {
|
|
103
|
+
const child = await provider.getTicket(childId);
|
|
104
|
+
if (!isSatisfiedChild(child)) {
|
|
105
|
+
allLanded = false;
|
|
106
|
+
break;
|
|
107
|
+
}
|
|
108
|
+
} catch (err) {
|
|
109
|
+
Logger.warn(
|
|
110
|
+
`[run-epilogue] Epic #${epicId}: could not read child #${childId} ` +
|
|
111
|
+
`(${err?.message ?? err}) — leaving the Epic open.`,
|
|
112
|
+
);
|
|
113
|
+
allLanded = false;
|
|
114
|
+
break;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (!allLanded) {
|
|
119
|
+
result.pending.push(epicId);
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
try {
|
|
124
|
+
await provider.updateTicket(epicId, {
|
|
125
|
+
state: 'closed',
|
|
126
|
+
state_reason: 'completed',
|
|
127
|
+
});
|
|
128
|
+
Logger.info(
|
|
129
|
+
`[run-epilogue] Closed container Epic #${epicId} — all ${childIds.length} child Story(ies) landed.`,
|
|
130
|
+
);
|
|
131
|
+
result.closed.push(epicId);
|
|
132
|
+
} catch (err) {
|
|
133
|
+
Logger.warn(
|
|
134
|
+
`[run-epilogue] Could not close Epic #${epicId} (${err?.message ?? err}).`,
|
|
135
|
+
);
|
|
136
|
+
result.pending.push(epicId);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return result;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* A child no longer holds its Epic open once it is closed or `agent::done`.
|
|
145
|
+
*
|
|
146
|
+
* Mirrors `isSatisfiedBlocker` in `lib/orchestration/resolve-stories.js`
|
|
147
|
+
* rather than importing it: that module is the delivery-resolution path and
|
|
148
|
+
* pulling it in here would drag the whole story-body parser into the
|
|
149
|
+
* epilogue for a two-line predicate.
|
|
150
|
+
*
|
|
151
|
+
* @param {{ state?: string, labels?: unknown }} issue
|
|
152
|
+
* @returns {boolean}
|
|
153
|
+
*/
|
|
154
|
+
function isSatisfiedChild(issue) {
|
|
155
|
+
if (String(issue?.state ?? '').toLowerCase() === 'closed') return true;
|
|
156
|
+
const labels = Array.isArray(issue?.labels)
|
|
157
|
+
? issue.labels.map((l) => (typeof l === 'string' ? l : l?.name))
|
|
158
|
+
: [];
|
|
159
|
+
return labels.includes(AGENT_LABELS.DONE);
|
|
160
|
+
}
|
|
161
|
+
|
|
42
162
|
/**
|
|
43
163
|
* @param {string|number|{ id?: string|number, slug?: string }} entry
|
|
44
164
|
* @returns {string|null}
|
|
@@ -123,6 +243,11 @@ export function planRunEpilogue({ planRunId, stories } = {}) {
|
|
|
123
243
|
description: `Sibling-coherence check across the ${ids.length} Story specs of run ${effectiveRunId}`,
|
|
124
244
|
stories: ids,
|
|
125
245
|
},
|
|
246
|
+
{
|
|
247
|
+
kind: 'epic-close',
|
|
248
|
+
description: `Close any container Epic whose children all landed in run ${effectiveRunId}`,
|
|
249
|
+
stories: ids,
|
|
250
|
+
},
|
|
126
251
|
];
|
|
127
252
|
|
|
128
253
|
return {
|
|
@@ -825,6 +950,10 @@ export async function runPlanRunEpilogue({
|
|
|
825
950
|
provider,
|
|
826
951
|
}),
|
|
827
952
|
);
|
|
953
|
+
} else if (step.kind === 'epic-close') {
|
|
954
|
+
results.push(
|
|
955
|
+
await executeEpicClose({ stories: plan.stories, provider }),
|
|
956
|
+
);
|
|
828
957
|
}
|
|
829
958
|
} catch (err) {
|
|
830
959
|
const message = err?.message ?? String(err);
|
|
@@ -124,6 +124,8 @@ const CLI_OPTIONS = {
|
|
|
124
124
|
'force-review': { type: 'boolean', default: false },
|
|
125
125
|
'allow-over-budget': { type: 'boolean', default: false },
|
|
126
126
|
'allow-large-fan-out': { type: 'boolean', default: false },
|
|
127
|
+
'epic-title': { type: 'string' },
|
|
128
|
+
'epic-goal': { type: 'string' },
|
|
127
129
|
};
|
|
128
130
|
|
|
129
131
|
const USAGE =
|
|
@@ -133,7 +135,8 @@ const USAGE =
|
|
|
133
135
|
'[--source-tickets <ids>] [--no-close-superseded] ' +
|
|
134
136
|
'[--route-downgrade-reason <text>] ' +
|
|
135
137
|
'[--dry-run] [--chain-on-clean] [--force-review] ' +
|
|
136
|
-
'[--allow-over-budget] [--allow-large-fan-out]'
|
|
138
|
+
'[--allow-over-budget] [--allow-large-fan-out] ' +
|
|
139
|
+
'[--epic-title <text> --epic-goal <text>]';
|
|
137
140
|
|
|
138
141
|
async function readOptional(filePath, { required }) {
|
|
139
142
|
try {
|
|
@@ -196,6 +199,32 @@ async function loadArtifacts(paths) {
|
|
|
196
199
|
};
|
|
197
200
|
}
|
|
198
201
|
|
|
202
|
+
/**
|
|
203
|
+
* Resolve the optional container-Epic request from the CLI flags.
|
|
204
|
+
*
|
|
205
|
+
* Both halves are required together: an Epic with a title and no goal is a
|
|
206
|
+
* container with nothing explaining the grouping, and a goal with no title
|
|
207
|
+
* cannot be opened at all. Supplying exactly one is a **usage error**, not a
|
|
208
|
+
* silent no-Epic run — the operator asked for a container and would otherwise
|
|
209
|
+
* never learn they did not get one.
|
|
210
|
+
*
|
|
211
|
+
* @param {object} values Parsed `parseArgs` values.
|
|
212
|
+
* @returns {{ title: string, goal: string }|null} `null` when no Epic was requested.
|
|
213
|
+
*/
|
|
214
|
+
export function resolveEpicRequest(values) {
|
|
215
|
+
const title = (values['epic-title'] ?? '').trim();
|
|
216
|
+
const goal = (values['epic-goal'] ?? '').trim();
|
|
217
|
+
if (title === '' && goal === '') return null;
|
|
218
|
+
if (title === '' || goal === '') {
|
|
219
|
+
throw new Error(
|
|
220
|
+
'[plan-persist] --epic-title and --epic-goal must be supplied together ' +
|
|
221
|
+
'(a container Epic needs both a name and a one-paragraph reason it ' +
|
|
222
|
+
'groups these Stories).',
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
return { title, goal };
|
|
226
|
+
}
|
|
227
|
+
|
|
199
228
|
/**
|
|
200
229
|
* Assemble the `runPlanPersist` opts bag from parsed CLI values.
|
|
201
230
|
*
|
|
@@ -224,6 +253,7 @@ export function buildPersistOptions(values, paths, planContextEnvelope) {
|
|
|
224
253
|
sourceTicketIds: source.ids,
|
|
225
254
|
sourceTicketOrigin: source.origin,
|
|
226
255
|
routeDowngradeReason: values['route-downgrade-reason'] ?? null,
|
|
256
|
+
epic: resolveEpicRequest(values),
|
|
227
257
|
// Default-on: `--no-close-superseded` is the explicit escape and always
|
|
228
258
|
// wins over the (default `true`) `--close-superseded`.
|
|
229
259
|
closeSuperseded:
|
|
@@ -468,6 +498,14 @@ runAsCli(import.meta.url, main, {
|
|
|
468
498
|
],
|
|
469
499
|
['--allow-over-budget', 'Permit a Spec over the context budget.'],
|
|
470
500
|
['--allow-large-fan-out', 'Permit a Story count above the fan-out gate.'],
|
|
501
|
+
[
|
|
502
|
+
'--epic-title <text>',
|
|
503
|
+
'Group the persisted Stories under a container Epic with this title (needs --epic-goal).',
|
|
504
|
+
],
|
|
505
|
+
[
|
|
506
|
+
'--epic-goal <text>',
|
|
507
|
+
'The container Epic’s one-paragraph goal (needs --epic-title).',
|
|
508
|
+
],
|
|
471
509
|
],
|
|
472
510
|
},
|
|
473
511
|
});
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GitHub Provider — shared "link child issue to a parent" helper.
|
|
3
|
+
*
|
|
4
|
+
* Story #5139 — a container Epic holds its children as native GitHub
|
|
5
|
+
* sub-issue edges. The read side has existed since v1
|
|
6
|
+
* (`sub-issues.js` → `getNativeSubIssues`, and the three-strategy
|
|
7
|
+
* aggregator in `issues.js` → `getSubTickets`); this is the missing write.
|
|
8
|
+
*
|
|
9
|
+
* API surface used:
|
|
10
|
+
* Read: GET /repos/{owner}/{repo}/issues/{issue_number}/sub_issues
|
|
11
|
+
* Write: POST /repos/{owner}/{repo}/issues/{issue_number}/sub_issues
|
|
12
|
+
* body: { "sub_issue_id": <integer db id of the CHILD issue> }
|
|
13
|
+
*
|
|
14
|
+
* **`sub_issue_id` is the child's database id, not its issue number.** They
|
|
15
|
+
* are different integers and both are plausible, so a mix-up does not throw
|
|
16
|
+
* — it silently links the wrong issue, or a nonexistent one. This mirrors
|
|
17
|
+
* `blocked-by-add.js`, whose `issue_id` carries the same trap.
|
|
18
|
+
*
|
|
19
|
+
* Contract (deliberately identical to `blocked-by-add.js`):
|
|
20
|
+
* - **Idempotent** — reads existing edges first; only POSTs missing ones.
|
|
21
|
+
* - **Non-fatal** — catches all errors per edge, warns, and continues.
|
|
22
|
+
* The function never throws; failures are returned in the summary.
|
|
23
|
+
* The Epic body's checklist is the durable mirror, so a lost edge
|
|
24
|
+
* degrades discoverability rather than losing the child.
|
|
25
|
+
* - **No-op on empty input.**
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { Logger } from '../../lib/Logger.js';
|
|
29
|
+
import { concurrentMap } from '../../lib/util/concurrent-map.js';
|
|
30
|
+
import { paginateRest } from './request-helpers.js';
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Bounded concurrency for the sub-issue round-trips. Matches the
|
|
34
|
+
* dependency-edge writer's cap: modest enough for GitHub's secondary rate
|
|
35
|
+
* limits while collapsing wall-clock from `sum(round-trips)` toward
|
|
36
|
+
* `sum(round-trips) / concurrency`.
|
|
37
|
+
*/
|
|
38
|
+
const EDGE_CONCURRENCY = 5;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Fetch the database ids of a parent's existing sub-issues, **paginated to
|
|
42
|
+
* exhaustion**.
|
|
43
|
+
*
|
|
44
|
+
* This read is the idempotency check: an edge it fails to see is re-POSTed.
|
|
45
|
+
* Reading only the first page would therefore make the writer non-idempotent
|
|
46
|
+
* past the page boundary — the same defect Story #5046 fixed in
|
|
47
|
+
* `blocked-by-add.js`.
|
|
48
|
+
*
|
|
49
|
+
* Returns `[]` on any error so the caller falls back to posting the full
|
|
50
|
+
* set. Worst case is a duplicate POST, which GitHub rejects harmlessly and
|
|
51
|
+
* the per-edge catch absorbs.
|
|
52
|
+
*
|
|
53
|
+
* @param {{ gh: object, owner: string, repo: string, issueNumber: number, paginate?: Function }} opts
|
|
54
|
+
* @returns {Promise<number[]>} Database ids of the parent's current children.
|
|
55
|
+
*/
|
|
56
|
+
async function fetchExistingSubIssueIds({
|
|
57
|
+
gh,
|
|
58
|
+
owner,
|
|
59
|
+
repo,
|
|
60
|
+
issueNumber,
|
|
61
|
+
paginate = paginateRest,
|
|
62
|
+
}) {
|
|
63
|
+
try {
|
|
64
|
+
const data = await paginate(
|
|
65
|
+
gh,
|
|
66
|
+
`/repos/${owner}/${repo}/issues/${issueNumber}/sub_issues`,
|
|
67
|
+
{ label: `[sub-issue-add] sub_issues #${issueNumber}` },
|
|
68
|
+
);
|
|
69
|
+
if (!Array.isArray(data)) return [];
|
|
70
|
+
return data.map((item) => item?.id).filter((id) => typeof id === 'number');
|
|
71
|
+
} catch (err) {
|
|
72
|
+
Logger.warn(
|
|
73
|
+
`[sub-issue-add] Could not fetch existing sub-issues for #${issueNumber}: ${err.message}`,
|
|
74
|
+
);
|
|
75
|
+
return [];
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Link a set of child issues to one parent as native sub-issues.
|
|
81
|
+
*
|
|
82
|
+
* For each entry in `childInternalIds`, checks whether the edge already
|
|
83
|
+
* exists and POSTs only the missing ones. Every individual POST failure is
|
|
84
|
+
* caught, logged and counted — the function never throws.
|
|
85
|
+
*
|
|
86
|
+
* @param {{
|
|
87
|
+
* gh: object,
|
|
88
|
+
* owner: string,
|
|
89
|
+
* repo: string,
|
|
90
|
+
* issueNumber: number,
|
|
91
|
+
* childInternalIds: number[],
|
|
92
|
+
* paginate?: Function,
|
|
93
|
+
* }} opts
|
|
94
|
+
* @returns {Promise<{ added: number, skipped: number, failed: number }>}
|
|
95
|
+
*/
|
|
96
|
+
export async function addSubIssueEdges({
|
|
97
|
+
gh,
|
|
98
|
+
owner,
|
|
99
|
+
repo,
|
|
100
|
+
issueNumber,
|
|
101
|
+
childInternalIds,
|
|
102
|
+
paginate = paginateRest,
|
|
103
|
+
}) {
|
|
104
|
+
const ids = Array.isArray(childInternalIds) ? childInternalIds : [];
|
|
105
|
+
if (ids.length === 0) return { added: 0, skipped: 0, failed: 0 };
|
|
106
|
+
|
|
107
|
+
const existing = await fetchExistingSubIssueIds({
|
|
108
|
+
gh,
|
|
109
|
+
owner,
|
|
110
|
+
repo,
|
|
111
|
+
issueNumber,
|
|
112
|
+
paginate,
|
|
113
|
+
});
|
|
114
|
+
const existingSet = new Set(existing);
|
|
115
|
+
|
|
116
|
+
// Partition up front so the skip count is deterministic regardless of the
|
|
117
|
+
// concurrent POST dispatch order.
|
|
118
|
+
const missing = ids.filter((id) => !existingSet.has(id));
|
|
119
|
+
const skipped = ids.length - missing.length;
|
|
120
|
+
|
|
121
|
+
const perEdge = await concurrentMap(
|
|
122
|
+
missing,
|
|
123
|
+
async (childId) => {
|
|
124
|
+
try {
|
|
125
|
+
await gh.api({
|
|
126
|
+
method: 'POST',
|
|
127
|
+
endpoint: `/repos/${owner}/${repo}/issues/${issueNumber}/sub_issues`,
|
|
128
|
+
body: { sub_issue_id: childId },
|
|
129
|
+
});
|
|
130
|
+
return { added: 1, failed: 0 };
|
|
131
|
+
} catch (err) {
|
|
132
|
+
Logger.warn(
|
|
133
|
+
`[sub-issue-add] Failed to link child(id=${childId}) under #${issueNumber}: ${err.message}`,
|
|
134
|
+
);
|
|
135
|
+
return { added: 0, failed: 1 };
|
|
136
|
+
}
|
|
137
|
+
},
|
|
138
|
+
{ concurrency: EDGE_CONCURRENCY },
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
let added = 0;
|
|
142
|
+
let failed = 0;
|
|
143
|
+
for (const r of perEdge) {
|
|
144
|
+
added += r.added;
|
|
145
|
+
failed += r.failed;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return { added, skipped, failed };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Link child Stories to a container Epic, resolving each child's **database
|
|
153
|
+
* id** from its issue number via the injected `getTicket` hook.
|
|
154
|
+
*
|
|
155
|
+
* Callers hold issue numbers (that is what `plan-persist` creates and what
|
|
156
|
+
* an operator types); the API wants database ids. Doing the translation here
|
|
157
|
+
* keeps that trap in one place instead of at every call site.
|
|
158
|
+
*
|
|
159
|
+
* Never throws: a child whose id cannot be resolved is counted as failed and
|
|
160
|
+
* the remaining edges still go out.
|
|
161
|
+
*
|
|
162
|
+
* @param {{
|
|
163
|
+
* epicNumber: number,
|
|
164
|
+
* childIssueNumbers: number[],
|
|
165
|
+
* getTicket: (issueNumber: number) => Promise<{ internalId: number }>,
|
|
166
|
+
* owner: string,
|
|
167
|
+
* repo: string,
|
|
168
|
+
* gh: object,
|
|
169
|
+
* paginate?: Function,
|
|
170
|
+
* }} opts
|
|
171
|
+
* @returns {Promise<{ added: number, skipped: number, failed: number }>}
|
|
172
|
+
*/
|
|
173
|
+
export async function linkStoriesToEpic({
|
|
174
|
+
epicNumber,
|
|
175
|
+
childIssueNumbers,
|
|
176
|
+
getTicket,
|
|
177
|
+
owner,
|
|
178
|
+
repo,
|
|
179
|
+
gh,
|
|
180
|
+
paginate = paginateRest,
|
|
181
|
+
}) {
|
|
182
|
+
const numbers = Array.isArray(childIssueNumbers) ? childIssueNumbers : [];
|
|
183
|
+
if (numbers.length === 0) return { added: 0, skipped: 0, failed: 0 };
|
|
184
|
+
|
|
185
|
+
let failed = 0;
|
|
186
|
+
const childInternalIds = [];
|
|
187
|
+
|
|
188
|
+
for (const childNumber of numbers) {
|
|
189
|
+
try {
|
|
190
|
+
const ticket = await getTicket(childNumber);
|
|
191
|
+
const internalId = ticket?.internalId;
|
|
192
|
+
if (typeof internalId !== 'number') {
|
|
193
|
+
Logger.warn(
|
|
194
|
+
`[sub-issue-add] Child #${childNumber} has no resolvable database id; skipping edge.`,
|
|
195
|
+
);
|
|
196
|
+
failed++;
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
childInternalIds.push(internalId);
|
|
200
|
+
} catch (err) {
|
|
201
|
+
Logger.warn(
|
|
202
|
+
`[sub-issue-add] Could not resolve child #${childNumber}: ${err.message}`,
|
|
203
|
+
);
|
|
204
|
+
failed++;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const summary = await addSubIssueEdges({
|
|
209
|
+
gh,
|
|
210
|
+
owner,
|
|
211
|
+
repo,
|
|
212
|
+
issueNumber: epicNumber,
|
|
213
|
+
childInternalIds,
|
|
214
|
+
paginate,
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
return { ...summary, failed: summary.failed + failed };
|
|
218
|
+
}
|
|
@@ -39,6 +39,7 @@ import { parseArgs } from 'node:util';
|
|
|
39
39
|
import { runAsCli } from './lib/cli-utils.js';
|
|
40
40
|
import { resolveConfig } from './lib/config-resolver.js';
|
|
41
41
|
import { Logger, routeAllOutputToStderr } from './lib/Logger.js';
|
|
42
|
+
import { expandEpicIds } from './lib/orchestration/epic-expansion.js';
|
|
42
43
|
import {
|
|
43
44
|
buildStoriesEnvelope,
|
|
44
45
|
isSatisfiedBlocker,
|
|
@@ -71,7 +72,9 @@ real issue state.
|
|
|
71
72
|
Options:
|
|
72
73
|
--ids <csv> Comma-separated Story issue numbers. Required. A token may be
|
|
73
74
|
a single id (4922) or an inclusive dash range (4922-4926);
|
|
74
|
-
ranges expand in place and dedupe against the rest.
|
|
75
|
+
ranges expand in place and dedupe against the rest. A
|
|
76
|
+
container Epic id expands to its open child Stories, and may
|
|
77
|
+
be mixed with Story ids.
|
|
75
78
|
--pretty Pretty-print the JSON envelope.
|
|
76
79
|
--no-native Skip the native blocked_by read (body edges only).
|
|
77
80
|
--help Show this help.
|
|
@@ -89,17 +92,54 @@ export function resolveStoriesProvider({
|
|
|
89
92
|
return { provider: createProviderFn(config), config };
|
|
90
93
|
}
|
|
91
94
|
|
|
95
|
+
/**
|
|
96
|
+
* Read an Epic's native sub-issue children as issue numbers.
|
|
97
|
+
*
|
|
98
|
+
* Injected into `expandEpicIds` so the lib layer stays provider-agnostic,
|
|
99
|
+
* exactly as `paginate` is injected into `readNativeBlockedBy`. A provider
|
|
100
|
+
* without the GraphQL surface yields `[]`, and the Epic body's checklist
|
|
101
|
+
* carries the children on its own.
|
|
102
|
+
*
|
|
103
|
+
* @param {object} provider
|
|
104
|
+
* @returns {(epic: object) => Promise<number[]>}
|
|
105
|
+
*/
|
|
106
|
+
export function nativeChildReader(provider) {
|
|
107
|
+
return async (epic) => {
|
|
108
|
+
if (typeof provider?._getNativeSubIssues !== 'function') return [];
|
|
109
|
+
return provider._getNativeSubIssues(epic?.nodeId, epic?.number ?? epic?.id);
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
92
113
|
/**
|
|
93
114
|
* Fetch every requested id and map it to a Story record, failing on the first
|
|
94
115
|
* id that is not a deliverable Story.
|
|
95
116
|
*
|
|
117
|
+
* Container Epics are expanded to their open child Stories **first**, so
|
|
118
|
+
* everything downstream sees a plain Story-id list (Story #5139). The
|
|
119
|
+
* expansion walk is sequential because it is id-by-id conditional; the Story
|
|
120
|
+
* fetch that follows stays under the bounded concurrency.
|
|
121
|
+
*
|
|
96
122
|
* @param {object} provider
|
|
97
123
|
* @param {number[]} ids
|
|
98
124
|
* @returns {Promise<object[]>}
|
|
99
125
|
*/
|
|
100
126
|
export async function fetchStories(provider, ids) {
|
|
101
|
-
|
|
127
|
+
const { ids: resolvedIds, expansions } = await expandEpicIds({
|
|
102
128
|
ids,
|
|
129
|
+
getTicket: (id) => provider.getTicket(id),
|
|
130
|
+
readNativeChildIds: nativeChildReader(provider),
|
|
131
|
+
warn: (m) => Logger.warn(m),
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
for (const { epicId, childIds } of expansions) {
|
|
135
|
+
Logger.info(
|
|
136
|
+
`[resolve-stories] Epic #${epicId} → ${childIds.length} open Story(ies): ` +
|
|
137
|
+
childIds.map((c) => `#${c}`).join(', '),
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return concurrentMap(
|
|
142
|
+
resolvedIds,
|
|
103
143
|
async (id) => {
|
|
104
144
|
const issue = await provider.getTicket(id);
|
|
105
145
|
if (!issue) {
|