mandrel 2.47.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/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/epic-container.js +48 -21
- package/.agents/scripts/lib/orchestration/epic-expansion.js +28 -6
- package/.agents/scripts/lib/orchestration/epic-rollup.js +66 -7
- 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/docs/CHANGELOG.md +12 -0
- package/lib/cli/registry.js +63 -0
- package/package.json +1 -1
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* bootstrap/baseline-merge-driver — register the `baselines/*.json` merge
|
|
3
|
+
* driver on a consumer clone (Story #5215).
|
|
4
|
+
*
|
|
5
|
+
* Registration has two halves that live in different places, and conflating
|
|
6
|
+
* them is why this needs a doctor check rather than just an installer:
|
|
7
|
+
*
|
|
8
|
+
* 1. **`.gitattributes`** says which files use the driver. It is a tracked
|
|
9
|
+
* file, so installing the line once ships it to everyone.
|
|
10
|
+
* 2. **`git config merge.mandrel-baseline.driver`** says what the driver
|
|
11
|
+
* actually is. Git deliberately keeps this out of tracked config —
|
|
12
|
+
* otherwise a clone would execute a command chosen by whoever wrote the
|
|
13
|
+
* repo — so it is **per clone**, and a fresh clone silently falls back
|
|
14
|
+
* to git's text merge with no error at all.
|
|
15
|
+
*
|
|
16
|
+
* That silence is the whole reason `mandrel doctor` carries a check: an
|
|
17
|
+
* unregistered clone is not broken in any way it can report on its own, it
|
|
18
|
+
* just quietly goes back to conflicting (or worse, splicing) baselines.
|
|
19
|
+
*
|
|
20
|
+
* This module owns both halves plus the strings they share, so the installer
|
|
21
|
+
* and the doctor check cannot drift apart on the exact command.
|
|
22
|
+
*
|
|
23
|
+
* @module lib/bootstrap/baseline-merge-driver
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import fs from 'node:fs';
|
|
27
|
+
import path from 'node:path';
|
|
28
|
+
import { spawnCapture } from '../child-exec.js';
|
|
29
|
+
|
|
30
|
+
/** Driver name, as it appears on both sides of the registration. */
|
|
31
|
+
const BASELINE_MERGE_DRIVER_NAME = 'mandrel-baseline';
|
|
32
|
+
|
|
33
|
+
/** The `.gitattributes` line that routes baselines through the driver. */
|
|
34
|
+
const BASELINE_MERGE_ATTRIBUTE = `baselines/*.json merge=${BASELINE_MERGE_DRIVER_NAME}`;
|
|
35
|
+
|
|
36
|
+
/** Git config key holding the driver command. */
|
|
37
|
+
export const BASELINE_MERGE_DRIVER_CONFIG_KEY = `merge.${BASELINE_MERGE_DRIVER_NAME}.driver`;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The driver command. Relative to the worktree root, which is where git runs
|
|
41
|
+
* a merge driver from, and where `mandrel sync` materializes `.agents/`.
|
|
42
|
+
*/
|
|
43
|
+
const BASELINE_MERGE_DRIVER_COMMAND =
|
|
44
|
+
'node .agents/scripts/merge-baseline.js %O %A %B %P';
|
|
45
|
+
|
|
46
|
+
/** The exact command an operator runs to complete registration. */
|
|
47
|
+
export const BASELINE_MERGE_DRIVER_REMEDY = `git config ${BASELINE_MERGE_DRIVER_CONFIG_KEY} "${BASELINE_MERGE_DRIVER_COMMAND}"`;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Does this `.gitattributes` content route baselines through the driver?
|
|
51
|
+
* Comment lines do not count — a commented-out registration is not one.
|
|
52
|
+
*
|
|
53
|
+
* @param {string|null|undefined} gitattributes
|
|
54
|
+
* @returns {boolean}
|
|
55
|
+
*/
|
|
56
|
+
export function declaresBaselineMergeDriver(gitattributes) {
|
|
57
|
+
return String(gitattributes ?? '')
|
|
58
|
+
.split('\n')
|
|
59
|
+
.some((line) => {
|
|
60
|
+
const trimmed = line.trim();
|
|
61
|
+
if (trimmed === '' || trimmed.startsWith('#')) return false;
|
|
62
|
+
return trimmed.includes(`merge=${BASELINE_MERGE_DRIVER_NAME}`);
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Add the attribute line, preserving every existing line verbatim.
|
|
68
|
+
*
|
|
69
|
+
* @param {string} projectRoot
|
|
70
|
+
* @param {typeof fs} [fsImpl]
|
|
71
|
+
* @returns {{ action: 'created'|'appended'|'already-present', path: string }}
|
|
72
|
+
*/
|
|
73
|
+
function ensureGitattributesLine(projectRoot, fsImpl = fs) {
|
|
74
|
+
const target = path.join(projectRoot, '.gitattributes');
|
|
75
|
+
if (!fsImpl.existsSync(target)) {
|
|
76
|
+
fsImpl.writeFileSync(target, `${BASELINE_MERGE_ATTRIBUTE}\n`, 'utf8');
|
|
77
|
+
return { action: 'created', path: target };
|
|
78
|
+
}
|
|
79
|
+
const existing = fsImpl.readFileSync(target, 'utf8');
|
|
80
|
+
if (declaresBaselineMergeDriver(existing)) {
|
|
81
|
+
return { action: 'already-present', path: target };
|
|
82
|
+
}
|
|
83
|
+
// A file not ending in a newline would otherwise glue our line onto the
|
|
84
|
+
// last existing one, silently rewriting it.
|
|
85
|
+
const separator = existing === '' || existing.endsWith('\n') ? '' : '\n';
|
|
86
|
+
fsImpl.writeFileSync(
|
|
87
|
+
target,
|
|
88
|
+
`${existing}${separator}${BASELINE_MERGE_ATTRIBUTE}\n`,
|
|
89
|
+
'utf8',
|
|
90
|
+
);
|
|
91
|
+
return { action: 'appended', path: target };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Point `merge.mandrel-baseline.driver` at the driver in THIS clone.
|
|
96
|
+
*
|
|
97
|
+
* @param {string} projectRoot
|
|
98
|
+
* @param {typeof spawnCapture} [spawnImpl]
|
|
99
|
+
* @returns {{ action: 'set'|'already-present'|'not-a-repo'|'failed' }}
|
|
100
|
+
*/
|
|
101
|
+
function ensureDriverGitConfig(projectRoot, spawnImpl = spawnCapture) {
|
|
102
|
+
const opts = {
|
|
103
|
+
cwd: projectRoot,
|
|
104
|
+
encoding: 'utf-8',
|
|
105
|
+
stdio: 'pipe',
|
|
106
|
+
shell: false,
|
|
107
|
+
};
|
|
108
|
+
const inRepo = spawnImpl('git', ['rev-parse', '--git-dir'], opts);
|
|
109
|
+
if ((inRepo?.status ?? 1) !== 0) return { action: 'not-a-repo' };
|
|
110
|
+
|
|
111
|
+
const current = spawnImpl(
|
|
112
|
+
'git',
|
|
113
|
+
['config', '--local', '--get', BASELINE_MERGE_DRIVER_CONFIG_KEY],
|
|
114
|
+
opts,
|
|
115
|
+
);
|
|
116
|
+
if (
|
|
117
|
+
(current?.status ?? 1) === 0 &&
|
|
118
|
+
String(current.stdout ?? '').trim() === BASELINE_MERGE_DRIVER_COMMAND
|
|
119
|
+
) {
|
|
120
|
+
return { action: 'already-present' };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const set = spawnImpl(
|
|
124
|
+
'git',
|
|
125
|
+
[
|
|
126
|
+
'config',
|
|
127
|
+
'--local',
|
|
128
|
+
BASELINE_MERGE_DRIVER_CONFIG_KEY,
|
|
129
|
+
BASELINE_MERGE_DRIVER_COMMAND,
|
|
130
|
+
],
|
|
131
|
+
opts,
|
|
132
|
+
);
|
|
133
|
+
spawnImpl(
|
|
134
|
+
'git',
|
|
135
|
+
[
|
|
136
|
+
'config',
|
|
137
|
+
'--local',
|
|
138
|
+
`merge.${BASELINE_MERGE_DRIVER_NAME}.name`,
|
|
139
|
+
'mandrel baseline merge by row identity',
|
|
140
|
+
],
|
|
141
|
+
opts,
|
|
142
|
+
);
|
|
143
|
+
return { action: (set?.status ?? 1) === 0 ? 'set' : 'failed' };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Install both halves. Idempotent: a second run reports `already-present`
|
|
148
|
+
* and changes no bytes.
|
|
149
|
+
*
|
|
150
|
+
* @param {object} ctx
|
|
151
|
+
* @param {string} ctx.projectRoot
|
|
152
|
+
* @param {typeof spawnCapture} [ctx.spawnImpl]
|
|
153
|
+
* @param {typeof fs} [ctx.fsImpl]
|
|
154
|
+
* @returns {{
|
|
155
|
+
* action: 'already-present'|'updated',
|
|
156
|
+
* attributes: string,
|
|
157
|
+
* config: string,
|
|
158
|
+
* path: string,
|
|
159
|
+
* line: string,
|
|
160
|
+
* }}
|
|
161
|
+
*/
|
|
162
|
+
export function ensureBaselineMergeDriver(ctx) {
|
|
163
|
+
const attributes = ensureGitattributesLine(ctx.projectRoot, ctx.fsImpl ?? fs);
|
|
164
|
+
const config = ensureDriverGitConfig(ctx.projectRoot, ctx.spawnImpl);
|
|
165
|
+
const settled =
|
|
166
|
+
attributes.action === 'already-present' &&
|
|
167
|
+
(config.action === 'already-present' || config.action === 'not-a-repo');
|
|
168
|
+
return {
|
|
169
|
+
action: settled ? 'already-present' : 'updated',
|
|
170
|
+
attributes: attributes.action,
|
|
171
|
+
config: config.action,
|
|
172
|
+
path: attributes.path,
|
|
173
|
+
line: BASELINE_MERGE_ATTRIBUTE,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
@@ -15,7 +15,11 @@
|
|
|
15
15
|
* 4. Seeds `delivery.quality.codingGuardrails` and
|
|
16
16
|
* `delivery.quality.autoRefresh` defaults in `.agentrc.json` when
|
|
17
17
|
* the keys are absent. Existing values are preserved.
|
|
18
|
-
* 5.
|
|
18
|
+
* 5. Registers the `baselines/*.json` merge driver (Story #5215) — the
|
|
19
|
+
* `.gitattributes` line plus this clone's `merge.mandrel-baseline.driver`
|
|
20
|
+
* config, so concurrent baseline refreshes merge by row identity instead
|
|
21
|
+
* of conflicting on the `generatedAt` stamp.
|
|
22
|
+
* 6. Prunes a committed pre-v2 `baselines/epic/` tree (Story #5007). The
|
|
19
23
|
* v2 model is Story-only — nothing writes, reads, or reaps per-Epic
|
|
20
24
|
* ratchet snapshots — so an upgrading consumer is left carrying a
|
|
21
25
|
* committed directory no gate consults. Absent on every repo that never
|
|
@@ -35,6 +39,7 @@ import fs from 'node:fs';
|
|
|
35
39
|
import path from 'node:path';
|
|
36
40
|
import { getAgentrcDefaults, lookupPath } from '../config/defaults.js';
|
|
37
41
|
import { deepEqual } from '../json-utils.js';
|
|
42
|
+
import { ensureBaselineMergeDriver } from './baseline-merge-driver.js';
|
|
38
43
|
|
|
39
44
|
/**
|
|
40
45
|
* The exact pre-commit body the framework ships. Kept as a single string so
|
|
@@ -407,7 +412,7 @@ export function pruneLegacyEpicBaselines(ctx) {
|
|
|
407
412
|
}
|
|
408
413
|
|
|
409
414
|
/**
|
|
410
|
-
* Run all
|
|
415
|
+
* Run all six steps in order. Composable wrapper used by the bootstrap
|
|
411
416
|
* and update workflows. Each step's outcome is returned under its own key
|
|
412
417
|
* so callers can render a per-action summary.
|
|
413
418
|
*
|
|
@@ -423,6 +428,7 @@ export function applyQualityBootstrap(ctx) {
|
|
|
423
428
|
hook: ensurePreCommitHook(ctx),
|
|
424
429
|
scripts: ensureQualityNpmScripts(ctx),
|
|
425
430
|
config: ensureQualityConfigDefaults(ctx),
|
|
431
|
+
mergeDriver: ensureBaselineMergeDriver(ctx),
|
|
426
432
|
legacyBaselines: pruneLegacyEpicBaselines(ctx),
|
|
427
433
|
};
|
|
428
434
|
}
|
|
@@ -33,23 +33,35 @@
|
|
|
33
33
|
import { TYPE_LABELS } from '../label-constants.js';
|
|
34
34
|
|
|
35
35
|
/**
|
|
36
|
-
* The checklist grammar
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
36
|
+
* The checklist grammar: a checklist row, and the **first** issue reference
|
|
37
|
+
* anywhere on it. The checklist is a durable mirror of the native sub-issue
|
|
38
|
+
* edges rather than a second, competing representation — when the sub-issues
|
|
39
|
+
* API is unavailable (an older GHES, a revoked scope, a rate-limit burst) the
|
|
40
|
+
* children are still discoverable from the body alone.
|
|
41
|
+
*
|
|
42
|
+
* Deliberately the **loosest** of the three grammars that read this shape, and
|
|
43
|
+
* looser than it was: it used to require the id to be the whole row
|
|
44
|
+
* (`- [ ] #123`), which matched none of the annotated rows a hand-maintained
|
|
45
|
+
* rollout tracker actually carries — `- [ ] Design sign-off (#1897): pending`,
|
|
46
|
+
* `- [ ] 1.4 #1909 (Part B blocked)`. An Epic with 58 children presented three
|
|
47
|
+
* to the rollup, which closed it with 23 still open (Story #5210).
|
|
48
|
+
*
|
|
49
|
+
* It is NOT in sync with `_getChecklistChildren` (`providers/github/issues.js`)
|
|
50
|
+
* and no longer claims to be: that one is a general parent→child strategy for
|
|
51
|
+
* any issue and still requires `#N` immediately after the checkbox. Reading a
|
|
52
|
+
* superset here is safe in the direction that matters — a spurious id costs a
|
|
53
|
+
* skipped non-Story child, while a missed id costs a container closed over open
|
|
54
|
+
* work. The union with the native edges keeps both honest, and after #5210
|
|
55
|
+
* nothing irreversible is decided on this grammar alone.
|
|
45
56
|
*/
|
|
46
|
-
const CHECKLIST_ITEM_RE = /^-\s*\[[ xX]\]\s
|
|
57
|
+
const CHECKLIST_ITEM_RE = /^-\s*\[[ xX]\]\s+.*?#(\d+)\b/gm;
|
|
47
58
|
|
|
48
59
|
/**
|
|
49
60
|
* The same grammar, unanchored to a global cursor — for callers testing one
|
|
50
|
-
* line at a time. Kept beside its `/g` twin so the two cannot drift
|
|
61
|
+
* line at a time. Kept beside its `/g` twin so the two cannot drift; the pair
|
|
62
|
+
* MUST accept the same line set, which `epic-container.test.js` pins.
|
|
51
63
|
*/
|
|
52
|
-
export const CHECKLIST_ITEM_LINE_RE = /^-\s*\[[ xX]\]\s
|
|
64
|
+
export const CHECKLIST_ITEM_LINE_RE = /^-\s*\[[ xX]\]\s+.*?#\d+\b/;
|
|
53
65
|
|
|
54
66
|
/** Heading the container's one prose section renders under. */
|
|
55
67
|
const GOAL_HEADING = '## Goal';
|
|
@@ -180,12 +192,23 @@ export function readEpicChildIds(body) {
|
|
|
180
192
|
* degrades to the checklist rather than propagating, since a body-derived
|
|
181
193
|
* child list is a strictly better answer than an error.
|
|
182
194
|
*
|
|
195
|
+
* **The degrade is reported, not hidden.** The result carries
|
|
196
|
+
* `nativeReadFailed`, because a truncated list and a genuinely small Epic are
|
|
197
|
+
* otherwise indistinguishable downstream — and one caller
|
|
198
|
+
* (`epic-rollup.js`) decides an irreversible close on the difference. A caller
|
|
199
|
+
* that only needs "who are the children" reads `ids` and ignores the flag;
|
|
200
|
+
* a caller about to do something it cannot undo MUST NOT.
|
|
201
|
+
*
|
|
202
|
+
* `nativeReadFailed` is false when no reader was injected: a caller that
|
|
203
|
+
* supplied none never asked for authority and is not degraded relative to what
|
|
204
|
+
* it requested.
|
|
205
|
+
*
|
|
183
206
|
* @param {{
|
|
184
207
|
* epic: { number?: number, id?: number, body?: string, nodeId?: string },
|
|
185
208
|
* readNativeChildIds?: (epic: object) => Promise<number[]>,
|
|
186
209
|
* onWarn?: (message: string) => void,
|
|
187
210
|
* }} opts
|
|
188
|
-
* @returns {Promise<number[]>}
|
|
211
|
+
* @returns {Promise<{ ids: number[], nativeReadFailed: boolean }>}
|
|
189
212
|
*/
|
|
190
213
|
export async function readEpicChildIdsFrom({
|
|
191
214
|
epic,
|
|
@@ -193,18 +216,22 @@ export async function readEpicChildIdsFrom({
|
|
|
193
216
|
onWarn,
|
|
194
217
|
} = {}) {
|
|
195
218
|
const fromBody = readEpicChildIds(epic?.body);
|
|
196
|
-
if (typeof readNativeChildIds !== 'function')
|
|
219
|
+
if (typeof readNativeChildIds !== 'function') {
|
|
220
|
+
return { ids: fromBody, nativeReadFailed: false };
|
|
221
|
+
}
|
|
197
222
|
|
|
198
|
-
let native = [];
|
|
199
223
|
try {
|
|
200
|
-
native = normalizeChildIds(await readNativeChildIds(epic));
|
|
224
|
+
const native = normalizeChildIds(await readNativeChildIds(epic));
|
|
225
|
+
return {
|
|
226
|
+
ids: normalizeChildIds([...native, ...fromBody]),
|
|
227
|
+
nativeReadFailed: false,
|
|
228
|
+
};
|
|
201
229
|
} catch (err) {
|
|
202
|
-
const id = epic?.number ?? epic?.id ?? '?';
|
|
203
230
|
onWarn?.(
|
|
204
|
-
`[epic-container] native sub-issue read failed for Epic
|
|
205
|
-
|
|
231
|
+
`[epic-container] native sub-issue read failed for Epic ` +
|
|
232
|
+
`#${epic?.number ?? epic?.id ?? '?'} (${err?.message ?? String(err)}); ` +
|
|
233
|
+
'using the body checklist alone — the child list may be incomplete.',
|
|
206
234
|
);
|
|
235
|
+
return { ids: fromBody, nativeReadFailed: true };
|
|
207
236
|
}
|
|
208
|
-
|
|
209
|
-
return normalizeChildIds([...native, ...fromBody]);
|
|
210
237
|
}
|
|
@@ -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 = [];
|
|
@@ -28,9 +28,18 @@
|
|
|
28
28
|
* 3. **Never throws.** Every step degrades with a reason. A stale
|
|
29
29
|
* container costs tidiness; a delivery failed on a board mutation
|
|
30
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.
|
|
31
39
|
*
|
|
32
40
|
* @module lib/orchestration/epic-rollup
|
|
33
41
|
* @see Story #5205
|
|
42
|
+
* @see Story #5210 — fail closed on a degraded child read.
|
|
34
43
|
*/
|
|
35
44
|
|
|
36
45
|
import { Logger } from '../Logger.js';
|
|
@@ -120,6 +129,11 @@ function isClosed(issue) {
|
|
|
120
129
|
* the list it is handed, so a silently dropped child could close a container
|
|
121
130
|
* with work still open under it.
|
|
122
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
|
+
*
|
|
123
137
|
* @param {{ epicId: number, childIds: number[], provider: object }} opts
|
|
124
138
|
* @returns {Promise<object[]|null>}
|
|
125
139
|
*/
|
|
@@ -214,10 +228,24 @@ async function applyClosure({ epicId, provider }) {
|
|
|
214
228
|
/**
|
|
215
229
|
* Roll one Epic up from the children it lists.
|
|
216
230
|
*
|
|
217
|
-
*
|
|
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
|
|
218
239
|
* @returns {Promise<object>} Per-Epic outcome record.
|
|
219
240
|
*/
|
|
220
|
-
async function rollUpOneEpic({
|
|
241
|
+
async function rollUpOneEpic({
|
|
242
|
+
epic,
|
|
243
|
+
childIds,
|
|
244
|
+
nativeReadFailed = false,
|
|
245
|
+
provider,
|
|
246
|
+
columnSync,
|
|
247
|
+
owner,
|
|
248
|
+
}) {
|
|
221
249
|
const epicId = Number(epic?.number ?? epic?.id);
|
|
222
250
|
const outcome = {
|
|
223
251
|
epicId,
|
|
@@ -261,6 +289,24 @@ async function rollUpOneEpic({ epic, childIds, provider, columnSync, owner }) {
|
|
|
261
289
|
|
|
262
290
|
if (isClosed(epic)) return outcome;
|
|
263
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
|
+
|
|
264
310
|
const closure = await applyClosure({ epicId, provider });
|
|
265
311
|
outcome.closed = closure.closed;
|
|
266
312
|
outcome.pending = !closure.closed;
|
|
@@ -277,7 +323,7 @@ async function rollUpOneEpic({ epic, childIds, provider, columnSync, owner }) {
|
|
|
277
323
|
* containers are few, and only one listing this Story is ever read further.
|
|
278
324
|
*
|
|
279
325
|
* @param {{ storyId: number, provider: object, skipEpicIds: Set<number> }} opts
|
|
280
|
-
* @returns {Promise<Array<{ epic: object, childIds: number[] }>>}
|
|
326
|
+
* @returns {Promise<Array<{ epic: object, childIds: number[], nativeReadFailed: boolean }>>}
|
|
281
327
|
*/
|
|
282
328
|
async function findEpicsForStory({ storyId, provider, skipEpicIds }) {
|
|
283
329
|
let epics;
|
|
@@ -304,13 +350,25 @@ async function findEpicsForStory({ storyId, provider, skipEpicIds }) {
|
|
|
304
350
|
// delivery expansion uses. Reading the body alone here is what made an
|
|
305
351
|
// Epic whose children were linked in the GitHub UI expandable but
|
|
306
352
|
// permanently unclosable.
|
|
307
|
-
const childIds = await readEpicChildIdsFrom({
|
|
353
|
+
const { ids: childIds, nativeReadFailed } = await readEpicChildIdsFrom({
|
|
308
354
|
epic,
|
|
309
355
|
readNativeChildIds: nativeChildReader(provider),
|
|
310
356
|
onWarn: (message) => Logger.warn(message),
|
|
311
357
|
});
|
|
312
|
-
if (!childIds.includes(storyId))
|
|
313
|
-
|
|
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 });
|
|
314
372
|
}
|
|
315
373
|
return matches;
|
|
316
374
|
}
|
|
@@ -374,11 +432,12 @@ export async function rollUpEpicForStory({
|
|
|
374
432
|
owner === undefined ? resolveEpicOwner(config) : owner;
|
|
375
433
|
|
|
376
434
|
const epics = [];
|
|
377
|
-
for (const { epic, childIds } of matches) {
|
|
435
|
+
for (const { epic, childIds, nativeReadFailed } of matches) {
|
|
378
436
|
epics.push(
|
|
379
437
|
await rollUpOneEpic({
|
|
380
438
|
epic,
|
|
381
439
|
childIds,
|
|
440
|
+
nativeReadFailed,
|
|
382
441
|
provider,
|
|
383
442
|
columnSync: sync,
|
|
384
443
|
owner: resolvedOwner,
|