mandrel 2.63.0 → 2.65.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/acceptance-critic.md +3 -2
- package/.agents/agents/auditor.md +3 -2
- package/.agents/agents/plan-critic.md +3 -2
- package/.agents/agents/story-worker.md +2 -2
- package/.agents/audit-checklists/quality.md +3 -0
- package/.agents/docs/agentrc-reference.json +1 -9
- package/.agents/docs/configuration.md +8 -7
- package/.agents/schemas/agentrc.schema.json +6 -13
- package/.agents/schemas/audit-rules.schema.json +1 -1
- package/.agents/schemas/story-deliver-terminal.schema.json +5 -0
- package/.agents/scripts/bootstrap.js +8 -2
- package/.agents/scripts/check-context-budget.js +1 -1
- package/.agents/scripts/lib/ITicketingProvider.js +1 -3
- package/.agents/scripts/lib/audit-suite/findings.js +1 -17
- package/.agents/scripts/lib/audit-suite/frontmatter.js +0 -28
- package/.agents/scripts/lib/audit-suite/index.js +0 -6
- package/.agents/scripts/lib/audit-suite/selector.js +0 -31
- package/.agents/scripts/lib/bootstrap/agents-md-fold.js +156 -0
- package/.agents/scripts/lib/bootstrap/commit-push.js +1 -1
- package/.agents/scripts/lib/bootstrap/manifest.js +2 -2
- package/.agents/scripts/lib/bootstrap/project-bootstrap.js +13 -29
- package/.agents/scripts/lib/config/review-chain-default.js +13 -0
- package/.agents/scripts/lib/config-settings-schema-delivery.js +2 -2
- package/.agents/scripts/lib/config-settings-schema-quality.js +11 -13
- package/.agents/scripts/lib/doc-tiers.js +25 -6
- package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
- package/.agents/scripts/lib/observability/metrics-ledger.js +0 -72
- package/.agents/scripts/lib/orchestration/ci-red-handling.js +73 -0
- package/.agents/scripts/lib/orchestration/code-review.js +11 -6
- package/.agents/scripts/lib/orchestration/deliver-recover.js +56 -11
- package/.agents/scripts/lib/orchestration/epic-rollup.js +29 -12
- package/.agents/scripts/lib/orchestration/merge-block-class.js +20 -4
- package/.agents/scripts/lib/orchestration/merge-poll.js +41 -22
- package/.agents/scripts/lib/orchestration/required-checks.js +147 -0
- package/.agents/scripts/lib/orchestration/review-providers/code-review.js +203 -0
- package/.agents/scripts/lib/orchestration/review-providers/review-provider-factory.js +6 -4
- package/.agents/scripts/lib/orchestration/review-providers/security-review.js +3 -2
- package/.agents/scripts/lib/orchestration/single-story-close/failed-terminal.js +1 -0
- package/.agents/scripts/lib/orchestration/single-story-close/phases/code-review.js +0 -12
- package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +135 -20
- package/.agents/scripts/lib/orchestration/single-story-close/phases/post-land.js +112 -82
- package/.agents/scripts/lib/orchestration/single-story-close/runner.js +72 -5
- package/.agents/scripts/lib/orchestration/story-close/phases/review-core.js +12 -87
- package/.agents/scripts/lib/orchestration/story-deliver-terminal.js +3 -0
- package/.agents/scripts/lib/templates/decomposer-prompts.js +5 -24
- package/.agents/scripts/pr-watch-with-update.js +13 -19
- package/.agents/scripts/providers/github/issues.js +14 -23
- package/.agents/scripts/sync-claude-agents.js +1 -1
- package/.agents/workflows/audit-quality.md +42 -7
- package/.agents/workflows/helpers/acceptance-self-eval.md +1 -1
- package/.agents/workflows/helpers/code-review.md +15 -38
- package/.agents/workflows/helpers/deliver-reference.md +4 -2
- package/.agents/workflows/helpers/deliver-story.md +3 -0
- package/.agents/workflows/helpers/plan-reference.md +9 -8
- package/.agents/workflows/mandrel-deliver.md +2 -1
- package/.agents/workflows/mandrel-plan.md +10 -7
- package/.agents/workflows/mandrel-update.md +5 -3
- package/docs/CHANGELOG.md +38 -0
- package/lib/cli/claude-code-version.js +73 -0
- package/lib/cli/doctor.js +2 -2
- package/lib/cli/registry.js +9 -0
- package/lib/cli/uninstall.js +37 -9
- package/lib/migrations/index.js +2 -0
- package/lib/migrations/steps/2.65.0-fold-claude-md-into-agents-md.js +38 -0
- package/package.json +2 -1
- package/.agents/scripts/lib/audit-suite/lens-diff-floor.js +0 -99
- package/.agents/scripts/lib/audit-suite/runner.js +0 -205
- package/.agents/scripts/lib/audit-suite/substitutions.js +0 -96
- package/.agents/scripts/lib/audit-suite/workflow-loader.js +0 -37
- package/.agents/scripts/lib/orchestration/story-close/phases/local-lens-review.js +0 -234
|
@@ -12,13 +12,11 @@ import {
|
|
|
12
12
|
runArtifactPath,
|
|
13
13
|
tempRootFrom,
|
|
14
14
|
} from '../config/temp-paths.js';
|
|
15
|
-
import { Logger } from '../Logger.js';
|
|
16
15
|
|
|
17
16
|
export const PLAN_METRICS_BASENAME = 'plan-metrics.json';
|
|
18
17
|
export const PLAN_METRICS_SCHEMA_VERSION = 1;
|
|
19
18
|
|
|
20
19
|
/** Evidence for dropping a lens that stays at zero findings. */
|
|
21
|
-
const PLAN_METRICS_KIND_FINDINGS_YIELD = 'findings-yield';
|
|
22
20
|
|
|
23
21
|
/** ~5000 records; rotation only fires on pathological accumulation. */
|
|
24
22
|
export const MAX_LEDGER_BYTES = 1024 * 1024;
|
|
@@ -85,73 +83,3 @@ export async function appendLedgerRecord(record, opts = {}) {
|
|
|
85
83
|
);
|
|
86
84
|
await fs.appendFile(filePath, line, 'utf8');
|
|
87
85
|
}
|
|
88
|
-
|
|
89
|
-
/**
|
|
90
|
-
* Append one findings-yield record. Never throws.
|
|
91
|
-
*
|
|
92
|
-
* @param {{
|
|
93
|
-
* storyId: number,
|
|
94
|
-
* lenses: Array<{ lens: string, findings?: number, skippedByFloor?: boolean }>,
|
|
95
|
-
* cli?: string,
|
|
96
|
-
* epicId?: number|null,
|
|
97
|
-
* diffFloor?: object|null,
|
|
98
|
-
* }} entry
|
|
99
|
-
* @param {object} [config]
|
|
100
|
-
* @param {{ maxBytes?: number }} [opts]
|
|
101
|
-
* @returns {Promise<boolean>} true when the line was written.
|
|
102
|
-
*/
|
|
103
|
-
export async function appendFindingsYield(entry, config, opts = {}) {
|
|
104
|
-
try {
|
|
105
|
-
if (!entry || typeof entry !== 'object') {
|
|
106
|
-
throw new TypeError('appendFindingsYield requires an entry object');
|
|
107
|
-
}
|
|
108
|
-
const storyId = Number(entry.storyId);
|
|
109
|
-
if (!Number.isInteger(storyId) || storyId <= 0) {
|
|
110
|
-
throw new TypeError(
|
|
111
|
-
'appendFindingsYield requires a positive integer entry.storyId',
|
|
112
|
-
);
|
|
113
|
-
}
|
|
114
|
-
if (!Array.isArray(entry.lenses) || entry.lenses.length === 0) {
|
|
115
|
-
throw new TypeError(
|
|
116
|
-
'appendFindingsYield requires a non-empty entry.lenses array',
|
|
117
|
-
);
|
|
118
|
-
}
|
|
119
|
-
const epicId = entry.epicId ?? null;
|
|
120
|
-
const record = {
|
|
121
|
-
v: PLAN_METRICS_SCHEMA_VERSION,
|
|
122
|
-
kind: PLAN_METRICS_KIND_FINDINGS_YIELD,
|
|
123
|
-
cli:
|
|
124
|
-
typeof entry.cli === 'string' && entry.cli.length > 0
|
|
125
|
-
? entry.cli
|
|
126
|
-
: 'story-close-review',
|
|
127
|
-
storyId,
|
|
128
|
-
epicId,
|
|
129
|
-
lenses: entry.lenses
|
|
130
|
-
.filter((l) => l && typeof l.lens === 'string' && l.lens.length > 0)
|
|
131
|
-
.map((l) => ({
|
|
132
|
-
lens: l.lens,
|
|
133
|
-
findings:
|
|
134
|
-
typeof l.findings === 'number' && Number.isFinite(l.findings)
|
|
135
|
-
? l.findings
|
|
136
|
-
: 0,
|
|
137
|
-
skippedByFloor: l.skippedByFloor === true,
|
|
138
|
-
})),
|
|
139
|
-
diffFloor:
|
|
140
|
-
entry.diffFloor && typeof entry.diffFloor === 'object'
|
|
141
|
-
? entry.diffFloor
|
|
142
|
-
: null,
|
|
143
|
-
at: new Date().toISOString(),
|
|
144
|
-
};
|
|
145
|
-
await appendLedgerRecord(record, {
|
|
146
|
-
epicId,
|
|
147
|
-
config,
|
|
148
|
-
maxBytes: opts.maxBytes,
|
|
149
|
-
});
|
|
150
|
-
return true;
|
|
151
|
-
} catch (err) {
|
|
152
|
-
Logger.warn(
|
|
153
|
-
`[plan-metrics] findings-yield append failed (non-fatal): ${err?.message ?? err}`,
|
|
154
|
-
);
|
|
155
|
-
return false;
|
|
156
|
-
}
|
|
157
|
-
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// .agents/scripts/lib/orchestration/ci-red-handling.js
|
|
2
|
+
/**
|
|
3
|
+
* ci-red-handling.js — the first-red half of the no-rerun guard in
|
|
4
|
+
* `rules/ci-remediation.md` § Verifier, shared by every path that observes a
|
|
5
|
+
* red required check: the watcher (`pr-watch-with-update.js`) and the
|
|
6
|
+
* close-and-land merge wait's `checks-failed` fail-fast. The digest itself
|
|
7
|
+
* lives in `ci-rerun-guard.js`.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { Logger } from '../Logger.js';
|
|
11
|
+
import {
|
|
12
|
+
resolveDigestScope,
|
|
13
|
+
resolvePrHeadSha,
|
|
14
|
+
writeCiDigest,
|
|
15
|
+
} from './ci-rerun-guard.js';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The first-red handling every required-check red goes through — the
|
|
19
|
+
* watcher's red path and the close's `checks-failed` fail-fast alike: disarm
|
|
20
|
+
* auto-merge FIRST (the race-free moment), then write the digest keyed to the
|
|
21
|
+
* red head SHA. One implementation, so the guard cannot drift per path.
|
|
22
|
+
* Never throws: a digest-write failure is returned as `digestError`.
|
|
23
|
+
*
|
|
24
|
+
* @param {object} opts
|
|
25
|
+
* @param {number|string|null} [opts.storyId]
|
|
26
|
+
* @param {number} opts.prNumber
|
|
27
|
+
* @param {string} opts.prRef
|
|
28
|
+
* @param {Array<{name:string, outcome:string}>} opts.failures
|
|
29
|
+
* @param {string} opts.tempRoot
|
|
30
|
+
* @param {string} opts.cwd
|
|
31
|
+
* @param {(args: { prRef: string }) => Promise<{ disarmed: boolean, alreadyUnarmed?: boolean, detail: string }>} opts.disarmFn
|
|
32
|
+
* @param {string|null} [opts.headSha] Already-observed head SHA; probed when absent.
|
|
33
|
+
* @param {Function} [opts.headShaFn]
|
|
34
|
+
* @param {Function} [opts.writeDigestFn]
|
|
35
|
+
* @param {object} [opts.logger]
|
|
36
|
+
* @returns {Promise<{ headSha: string|null, disarm: object, digestPaths: { jsonPath: string, mdPath: string }|null, digestError: string|null }>}
|
|
37
|
+
*/
|
|
38
|
+
export async function recordRequiredRed({
|
|
39
|
+
storyId = null,
|
|
40
|
+
prNumber,
|
|
41
|
+
prRef,
|
|
42
|
+
failures,
|
|
43
|
+
tempRoot,
|
|
44
|
+
cwd,
|
|
45
|
+
disarmFn,
|
|
46
|
+
headSha = null,
|
|
47
|
+
headShaFn = resolvePrHeadSha,
|
|
48
|
+
writeDigestFn = writeCiDigest,
|
|
49
|
+
logger = Logger,
|
|
50
|
+
}) {
|
|
51
|
+
const disarm = await disarmFn({ prRef });
|
|
52
|
+
const scope = resolveDigestScope({ storyId });
|
|
53
|
+
const redHeadSha = scope ? (headSha ?? headShaFn({ prRef, cwd })) : null;
|
|
54
|
+
let digestPaths = null;
|
|
55
|
+
let digestError = null;
|
|
56
|
+
try {
|
|
57
|
+
digestPaths = writeDigestFn({
|
|
58
|
+
storyId,
|
|
59
|
+
prNumber,
|
|
60
|
+
headSha: redHeadSha,
|
|
61
|
+
failures,
|
|
62
|
+
tempRoot,
|
|
63
|
+
cwd,
|
|
64
|
+
prRef,
|
|
65
|
+
});
|
|
66
|
+
} catch (err) {
|
|
67
|
+
digestError = String(err?.message ?? err);
|
|
68
|
+
logger?.warn?.(
|
|
69
|
+
`[ci-rerun-guard] failed to write CI digest (non-fatal): ${digestError}`,
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
return { headSha: redHeadSha, disarm, digestPaths, digestError };
|
|
73
|
+
}
|
|
@@ -116,12 +116,17 @@ function resolveScopeEnvelope(opts, config) {
|
|
|
116
116
|
* blockerReason: string|null,
|
|
117
117
|
* }>}
|
|
118
118
|
*/
|
|
119
|
-
/**
|
|
120
|
-
|
|
119
|
+
/**
|
|
120
|
+
* Display name: the single entry's name, `chain[a,b]`, or `'native'`. An
|
|
121
|
+
* unset chain names the entries the factory actually built, so a skipped
|
|
122
|
+
* optional provider is not reported as having run.
|
|
123
|
+
*/
|
|
124
|
+
function resolveProviderName(codeReviewConfig, reviewProvider) {
|
|
125
|
+
const configured = Array.isArray(codeReviewConfig?.providers)
|
|
126
|
+
? codeReviewConfig.providers
|
|
127
|
+
: [];
|
|
121
128
|
const providers =
|
|
122
|
-
|
|
123
|
-
? codeReviewConfig.providers
|
|
124
|
-
: [];
|
|
129
|
+
configured.length > 0 ? configured : (reviewProvider?.chain?.inline ?? []);
|
|
125
130
|
if (providers.length === 1) {
|
|
126
131
|
return providers[0]?.name ?? 'native';
|
|
127
132
|
}
|
|
@@ -236,9 +241,9 @@ async function executeReviewPipeline({ opts, config, envelope }) {
|
|
|
236
241
|
const { scope, ticketId, baseRef, headRef, commentTargetId } = envelope;
|
|
237
242
|
|
|
238
243
|
const codeReviewConfig = config?.delivery?.codeReview ?? null;
|
|
239
|
-
const providerName = resolveProviderName(codeReviewConfig);
|
|
240
244
|
const reviewProvider =
|
|
241
245
|
injectedReviewProvider ?? createReviewProviderFn(codeReviewConfig);
|
|
246
|
+
const providerName = resolveProviderName(codeReviewConfig, reviewProvider);
|
|
242
247
|
|
|
243
248
|
logger?.info?.(
|
|
244
249
|
`[code-review] Running ${providerName} adapter for Story #${ticketId} (${baseRef}...${headRef})...`,
|
|
@@ -13,7 +13,11 @@ import {
|
|
|
13
13
|
} from '../config/temp-paths.js';
|
|
14
14
|
import { gh as defaultGh } from '../gh-exec.js';
|
|
15
15
|
import { gitSpawn as defaultGitSpawn, getStoryBranch } from '../git-utils.js';
|
|
16
|
-
import {
|
|
16
|
+
import {
|
|
17
|
+
CHECKS_FAILED_CLASS,
|
|
18
|
+
deriveChecksStatus,
|
|
19
|
+
isPrMerged,
|
|
20
|
+
} from './merge-poll.js';
|
|
17
21
|
import { NEXT_COMMANDS } from './story-deliver-terminal.js';
|
|
18
22
|
import { STATE_LABELS } from './ticketing.js';
|
|
19
23
|
|
|
@@ -302,6 +306,56 @@ function decideExecuting({ storyId, branch, pr, closeArtifacts, evidence }) {
|
|
|
302
306
|
};
|
|
303
307
|
}
|
|
304
308
|
|
|
309
|
+
/**
|
|
310
|
+
* A close that blocked on a red required check disarmed auto-merge and wrote
|
|
311
|
+
* the CI digest, so the next step is the ci-remediation loop — never this
|
|
312
|
+
* probe again. `null` for every other blocked class (or with no PR to watch).
|
|
313
|
+
*
|
|
314
|
+
* @returns {{ shape: string, nextCommand: string, detail: string, evidence: string[] } | null}
|
|
315
|
+
*/
|
|
316
|
+
function decideBlockedChecksFailed({ storyId, pr, closeArtifacts, evidence }) {
|
|
317
|
+
const envelope = closeArtifacts?.envelope;
|
|
318
|
+
if (envelope?.blocked?.blockClass !== CHECKS_FAILED_CLASS) return null;
|
|
319
|
+
const prNumber = pr?.number ?? envelope?.pr?.number ?? null;
|
|
320
|
+
if (!prNumber) return null;
|
|
321
|
+
return {
|
|
322
|
+
shape: 'blocked-checks-failed',
|
|
323
|
+
nextCommand: NEXT_COMMANDS.watchCi(storyId, prNumber),
|
|
324
|
+
detail:
|
|
325
|
+
`Story is \`agent::blocked\` because a required check on PR #${prNumber} went red. ` +
|
|
326
|
+
`The close disarmed auto-merge and wrote the CI digest ` +
|
|
327
|
+
`(\`story-${storyId}-ci-digest.json\` under the configured tempRoot). Per ` +
|
|
328
|
+
`\`.agents/rules/ci-remediation.md\`, either fix the failure at source and push a new ` +
|
|
329
|
+
`commit on \`story-${storyId}\`, or — when the root cause is outside this delivery — run ` +
|
|
330
|
+
`\`node .agents/scripts/file-ci-gap.js --story ${storyId} --pr ${prNumber} --verdict <verdict> ` +
|
|
331
|
+
`--owner <consumer|framework|platform> --evidence "<proof reading>"\`. Then run the watcher: ` +
|
|
332
|
+
`a green on a new head SHA, or the one rerun a filed capacity / unreproducible-tier verdict ` +
|
|
333
|
+
`admits, re-arms auto-merge.`,
|
|
334
|
+
evidence,
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* A blocked Story: the checks-failed route when it applies, else the
|
|
340
|
+
* class-specific remediation the friction comment already carries.
|
|
341
|
+
*
|
|
342
|
+
* @returns {{ shape: string, nextCommand: string, detail: string, evidence: string[] }}
|
|
343
|
+
*/
|
|
344
|
+
function decideBlocked({ storyId, pr, closeArtifacts, evidence }) {
|
|
345
|
+
return (
|
|
346
|
+
decideBlockedChecksFailed({ storyId, pr, closeArtifacts, evidence }) ?? {
|
|
347
|
+
shape: 'blocked',
|
|
348
|
+
nextCommand: NEXT_COMMANDS.recover(storyId),
|
|
349
|
+
detail:
|
|
350
|
+
`Story is at \`agent::blocked\`. The block was already classified when it was ` +
|
|
351
|
+
`filed — read the \`friction\` comment on #${storyId} for the class-specific ` +
|
|
352
|
+
`remediation, resolve it, then transition back to \`agent::executing\`. ` +
|
|
353
|
+
`Re-run this probe afterwards to confirm the strand cleared.`,
|
|
354
|
+
evidence,
|
|
355
|
+
}
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
|
|
305
359
|
/**
|
|
306
360
|
* The pure decision table: exactly one verdict, never a list.
|
|
307
361
|
*
|
|
@@ -357,16 +411,7 @@ export function decideRecovery({
|
|
|
357
411
|
}
|
|
358
412
|
|
|
359
413
|
if (label === STATE_LABELS.BLOCKED) {
|
|
360
|
-
return {
|
|
361
|
-
shape: 'blocked',
|
|
362
|
-
nextCommand: NEXT_COMMANDS.recover(storyId),
|
|
363
|
-
detail:
|
|
364
|
-
`Story is at \`agent::blocked\`. The block was already classified when it was ` +
|
|
365
|
-
`filed — read the \`friction\` comment on #${storyId} for the class-specific ` +
|
|
366
|
-
`remediation, resolve it, then transition back to \`agent::executing\`. ` +
|
|
367
|
-
`Re-run this probe afterwards to confirm the strand cleared.`,
|
|
368
|
-
evidence,
|
|
369
|
-
};
|
|
414
|
+
return decideBlocked({ storyId, pr, closeArtifacts, evidence });
|
|
370
415
|
}
|
|
371
416
|
|
|
372
417
|
if (label === STATE_LABELS.DONE) {
|
|
@@ -24,6 +24,7 @@ import { ColumnSync, LABEL_TO_COLUMN } from './column-sync.js';
|
|
|
24
24
|
import {
|
|
25
25
|
isEpicTicket,
|
|
26
26
|
nativeChildReader,
|
|
27
|
+
readEpicChildIds,
|
|
27
28
|
readEpicChildIdsFrom,
|
|
28
29
|
} from './epic-container.js';
|
|
29
30
|
import { resolveOperatorFromCandidates } from './lease-guard-shared.js';
|
|
@@ -302,26 +303,29 @@ async function rollUpOneEpic({
|
|
|
302
303
|
|
|
303
304
|
/**
|
|
304
305
|
* Resolve this Story's container in one request via the native parent edge.
|
|
305
|
-
* `
|
|
306
|
-
* a body checklist row, which the caller's scan covers.
|
|
306
|
+
* `authoritative`: the lookup answered, so no parent means no native edge.
|
|
307
307
|
*
|
|
308
308
|
* @param {{ storyId: number, provider: object }} opts
|
|
309
|
-
* @returns {Promise<object|null
|
|
309
|
+
* @returns {Promise<{ parent: object|null, authoritative: boolean }>}
|
|
310
310
|
*/
|
|
311
311
|
async function parentEpicFor({ storyId, provider }) {
|
|
312
|
-
if (typeof provider?.getParentIssue !== 'function')
|
|
312
|
+
if (typeof provider?.getParentIssue !== 'function') {
|
|
313
|
+
return { parent: null, authoritative: false };
|
|
314
|
+
}
|
|
313
315
|
let parent;
|
|
314
316
|
try {
|
|
315
317
|
parent = await provider.getParentIssue(storyId);
|
|
316
318
|
} catch (err) {
|
|
317
319
|
Logger.warn(
|
|
318
320
|
`[epic-rollup] Parent lookup for Story #${storyId} degraded ` +
|
|
319
|
-
`(${err?.message ?? err}); falling back to the label scan.`,
|
|
321
|
+
`(${err?.message ?? err}); falling back to the full label scan.`,
|
|
320
322
|
);
|
|
321
|
-
return null;
|
|
323
|
+
return { parent: null, authoritative: false };
|
|
322
324
|
}
|
|
323
|
-
|
|
324
|
-
|
|
325
|
+
return {
|
|
326
|
+
parent: parent && isEpicTicket(parent) ? parent : null,
|
|
327
|
+
authoritative: true,
|
|
328
|
+
};
|
|
325
329
|
}
|
|
326
330
|
|
|
327
331
|
/**
|
|
@@ -349,6 +353,18 @@ async function scanContainerEpics({ provider }) {
|
|
|
349
353
|
return (Array.isArray(epics) ? epics : []).filter(isEpicTicket);
|
|
350
354
|
}
|
|
351
355
|
|
|
356
|
+
/**
|
|
357
|
+
* `bodyOnly` keeps only Epics whose checklist names the Story (no requests).
|
|
358
|
+
*
|
|
359
|
+
* @param {{ storyId: number, provider: object, bodyOnly: boolean }} opts
|
|
360
|
+
* @returns {Promise<object[]>}
|
|
361
|
+
*/
|
|
362
|
+
async function candidateEpics({ storyId, provider, bodyOnly }) {
|
|
363
|
+
const epics = await scanContainerEpics({ provider });
|
|
364
|
+
if (!bodyOnly) return epics;
|
|
365
|
+
return epics.filter((epic) => readEpicChildIds(epic?.body).includes(storyId));
|
|
366
|
+
}
|
|
367
|
+
|
|
352
368
|
/**
|
|
353
369
|
* Find the container Epics holding a Story (Story bodies carry no parent
|
|
354
370
|
* pointer). Native edge first, scan otherwise; a scanned Epic must prove it
|
|
@@ -358,9 +374,10 @@ async function scanContainerEpics({ provider }) {
|
|
|
358
374
|
* @returns {Promise<Array<{ epic: object, childIds: number[], nativeReadFailed: boolean, bodyOnlyIds: number[] }>>}
|
|
359
375
|
*/
|
|
360
376
|
async function findEpicsForStory({ storyId, provider, skipEpicIds }) {
|
|
361
|
-
const parent = await parentEpicFor({ storyId, provider });
|
|
362
|
-
const epics = parent
|
|
363
|
-
|
|
377
|
+
const { parent, authoritative } = await parentEpicFor({ storyId, provider });
|
|
378
|
+
const epics = parent
|
|
379
|
+
? [parent]
|
|
380
|
+
: await candidateEpics({ storyId, provider, bodyOnly: authoritative });
|
|
364
381
|
|
|
365
382
|
const matches = [];
|
|
366
383
|
for (const epic of epics) {
|
|
@@ -378,7 +395,7 @@ async function findEpicsForStory({ storyId, provider, skipEpicIds }) {
|
|
|
378
395
|
readNativeChildIds: nativeChildReader(provider),
|
|
379
396
|
onWarn: (message) => Logger.warn(message),
|
|
380
397
|
});
|
|
381
|
-
if (!
|
|
398
|
+
if (!parent && !childIds.includes(storyId)) {
|
|
382
399
|
// A degraded read may have truncated this Story out; skip, but say so.
|
|
383
400
|
if (nativeReadFailed) {
|
|
384
401
|
Logger.warn(
|
|
@@ -77,6 +77,25 @@ function describeApiRaceFallback(prProbe, budget) {
|
|
|
77
77
|
return 'no definitive block signal observed; classified as a transient API race or other condition';
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
+
/**
|
|
81
|
+
* Positive evidence a check is still running. Attributed evidence scopes
|
|
82
|
+
* `requiredRunInFlight` to re-runs; `runInFlight` still says "something is
|
|
83
|
+
* running".
|
|
84
|
+
*
|
|
85
|
+
* @param {object} [prProbe]
|
|
86
|
+
* @returns {boolean}
|
|
87
|
+
*/
|
|
88
|
+
function hasChecksPendingEvidence(prProbe) {
|
|
89
|
+
const status = prProbe?.checksStatus;
|
|
90
|
+
const evidence = prProbe?.requiredRunEvidence;
|
|
91
|
+
return (
|
|
92
|
+
status === 'pending' ||
|
|
93
|
+
status === 'still-running' ||
|
|
94
|
+
evidence?.requiredRunInFlight === true ||
|
|
95
|
+
evidence?.runInFlight === true
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
80
99
|
/**
|
|
81
100
|
* Classify why a delivery run finished without a confirmed merge. First match
|
|
82
101
|
* wins: (1) arm failure (a protection rejection at arm time still routes to
|
|
@@ -123,10 +142,7 @@ export function classifyMergeBlock(input) {
|
|
|
123
142
|
// Positive evidence only: `unknown` routes to the fallback; `undefined`
|
|
124
143
|
// (no probe) keeps the step-2 mapping.
|
|
125
144
|
const checksStatus = prProbe?.checksStatus;
|
|
126
|
-
const checksPendingEvidence =
|
|
127
|
-
checksStatus === 'pending' ||
|
|
128
|
-
checksStatus === 'still-running' ||
|
|
129
|
-
prProbe?.requiredRunEvidence?.requiredRunInFlight === true;
|
|
145
|
+
const checksPendingEvidence = hasChecksPendingEvidence(prProbe);
|
|
130
146
|
|
|
131
147
|
// 1b. Definitive, and before step 3 since it also presents as BLOCKED.
|
|
132
148
|
// Head-anchored evidence, not the raw rollup (optional/superseded runs).
|
|
@@ -8,13 +8,29 @@ import { checkVerdict, classifyRollupEntry } from './check-state.js';
|
|
|
8
8
|
|
|
9
9
|
/** Fixed poll interval; default for `delivery.mergeWatch.maxBudgetSeconds`. */
|
|
10
10
|
export const DEFAULT_INTERVAL_SECONDS = 30;
|
|
11
|
+
|
|
12
|
+
/** Checks green, PR unmerged: the merge is imminent, so observe it sooner. */
|
|
13
|
+
const GREEN_INTERVAL_SECONDS = 10;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @param {string|undefined} checksStatus
|
|
17
|
+
* @param {number} intervalSeconds The in-flight cadence.
|
|
18
|
+
* @returns {number} Milliseconds until the next poll.
|
|
19
|
+
*/
|
|
20
|
+
export function pollIntervalMs(checksStatus, intervalSeconds) {
|
|
21
|
+
const seconds =
|
|
22
|
+
checksStatus === 'success'
|
|
23
|
+
? Math.min(GREEN_INTERVAL_SECONDS, intervalSeconds)
|
|
24
|
+
: intervalSeconds;
|
|
25
|
+
return seconds * 1000;
|
|
26
|
+
}
|
|
11
27
|
export const DEFAULT_MAX_BUDGET_SECONDS = 3600;
|
|
12
28
|
|
|
13
29
|
/** Bounds every `gh` spawn so a hang degrades to the probe-error path. */
|
|
14
30
|
export const MERGE_WAIT_GH_TIMEOUT_MS = 60_000;
|
|
15
31
|
|
|
16
32
|
/**
|
|
17
|
-
* Aggregate over EVERY check (the rollup has no `isRequired`): `failure`
|
|
33
|
+
* Aggregate over EVERY check (the view rollup has no `isRequired`): `failure`
|
|
18
34
|
* means "something is red", not "blocked" — see {@link failingChecksBlockMerge}.
|
|
19
35
|
*/
|
|
20
36
|
export function deriveChecksStatus(statusCheckRollup) {
|
|
@@ -47,7 +63,7 @@ export function isPrMerged(pr) {
|
|
|
47
63
|
* @param {{ conclusion?: string, state?: string }} [check]
|
|
48
64
|
* @returns {string|null}
|
|
49
65
|
*/
|
|
50
|
-
function redConclusionOf(check) {
|
|
66
|
+
export function redConclusionOf(check) {
|
|
51
67
|
const conclusion = String(check?.conclusion ?? '').toUpperCase();
|
|
52
68
|
if (conclusion === 'FAILURE' || conclusion === 'ERROR') return conclusion;
|
|
53
69
|
const state = String(check?.state ?? '').toUpperCase();
|
|
@@ -61,17 +77,29 @@ function redConclusionOf(check) {
|
|
|
61
77
|
* @param {{ name?: string, context?: string }} [check]
|
|
62
78
|
* @returns {string|null}
|
|
63
79
|
*/
|
|
64
|
-
function readRunName(check) {
|
|
80
|
+
export function readRunName(check) {
|
|
65
81
|
for (const value of [check?.name, check?.context]) {
|
|
66
82
|
if (typeof value === 'string' && value) return value;
|
|
67
83
|
}
|
|
68
84
|
return null;
|
|
69
85
|
}
|
|
70
86
|
|
|
87
|
+
/**
|
|
88
|
+
* @param {{ status?: string, state?: string }} [check]
|
|
89
|
+
* @returns {boolean}
|
|
90
|
+
*/
|
|
91
|
+
export function isRunInFlight(check) {
|
|
92
|
+
const status = String(check?.status ?? '').toUpperCase();
|
|
93
|
+
// `status` is empty on a StatusContext, so it falls to the `state` branch.
|
|
94
|
+
if (status) return status !== 'COMPLETED';
|
|
95
|
+
const state = String(check?.state ?? '').toUpperCase();
|
|
96
|
+
return state === 'PENDING' || state === 'EXPECTED';
|
|
97
|
+
}
|
|
98
|
+
|
|
71
99
|
/**
|
|
72
100
|
* Head-anchored evidence; `null` on an empty rollup (the caller falls back
|
|
73
|
-
* to consecutive probes). Reads EVERY run
|
|
74
|
-
*
|
|
101
|
+
* to consecutive probes). Reads EVERY run — the unscoped rule used when
|
|
102
|
+
* GitHub's required attribution is unavailable (see `required-checks.js`).
|
|
75
103
|
*
|
|
76
104
|
* @param {Array<{status?: string, conclusion?: string, state?: string}>} statusCheckRollup
|
|
77
105
|
* @returns {{ requiredRunFailed: boolean, requiredRunInFlight: boolean } | null}
|
|
@@ -80,22 +108,12 @@ export function deriveRequiredRunEvidence(statusCheckRollup) {
|
|
|
80
108
|
if (!Array.isArray(statusCheckRollup) || statusCheckRollup.length === 0) {
|
|
81
109
|
return null;
|
|
82
110
|
}
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
if (status && status !== 'COMPLETED') {
|
|
90
|
-
requiredRunInFlight = true;
|
|
91
|
-
} else if (state === 'PENDING' || state === 'EXPECTED') {
|
|
92
|
-
requiredRunInFlight = true;
|
|
93
|
-
}
|
|
94
|
-
if (redConclusionOf(check)) {
|
|
95
|
-
requiredRunFailed = true;
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
return { requiredRunFailed, requiredRunInFlight };
|
|
111
|
+
return {
|
|
112
|
+
requiredRunFailed: statusCheckRollup.some(
|
|
113
|
+
(c) => redConclusionOf(c) !== null,
|
|
114
|
+
),
|
|
115
|
+
requiredRunInFlight: statusCheckRollup.some(isRunInFlight),
|
|
116
|
+
};
|
|
99
117
|
}
|
|
100
118
|
|
|
101
119
|
/** The `mergeStateStatus` meaning GitHub itself gates the merge. */
|
|
@@ -139,7 +157,8 @@ export function formatChecksFailedReason(prProbe, evidencePath) {
|
|
|
139
157
|
|
|
140
158
|
/**
|
|
141
159
|
* A genuinely red REQUIRED check: gated, no review owns `BLOCKED`, a run is
|
|
142
|
-
* red and none in flight
|
|
160
|
+
* red and none in flight — with GitHub attribution, a required run is red and
|
|
161
|
+
* no re-run of it is in flight. No evidence → false (consecutive-probe path).
|
|
143
162
|
*
|
|
144
163
|
* @param {{ checksStatus?: string, mergeStateStatus?: string,
|
|
145
164
|
* reviewDecision?: string,
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GitHub's per-PR required-check attribution (GraphQL `isRequired`), not
|
|
3
|
+
* `.agentrc` requiredChecks, which are local command names.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
deriveRequiredRunEvidence,
|
|
8
|
+
failingChecksBlockMerge,
|
|
9
|
+
isRunInFlight,
|
|
10
|
+
MERGE_WAIT_GH_TIMEOUT_MS,
|
|
11
|
+
readRunName,
|
|
12
|
+
redConclusionOf,
|
|
13
|
+
} from './merge-poll.js';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* A required run is red and no re-run of that same check is in flight.
|
|
17
|
+
*
|
|
18
|
+
* @param {Array<object>} statusCheckRollup non-empty
|
|
19
|
+
* @param {Set<string>} requiredNames
|
|
20
|
+
*/
|
|
21
|
+
function deriveAttributedEvidence(statusCheckRollup, requiredNames) {
|
|
22
|
+
const failedRequired = new Set();
|
|
23
|
+
for (const check of statusCheckRollup) {
|
|
24
|
+
const name = readRunName(check);
|
|
25
|
+
if (name && requiredNames.has(name) && redConclusionOf(check)) {
|
|
26
|
+
failedRequired.add(name);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
let requiredRunInFlight = false;
|
|
30
|
+
let runInFlight = false;
|
|
31
|
+
for (const check of statusCheckRollup) {
|
|
32
|
+
if (!isRunInFlight(check)) continue;
|
|
33
|
+
runInFlight = true;
|
|
34
|
+
if (failedRequired.has(readRunName(check))) requiredRunInFlight = true;
|
|
35
|
+
}
|
|
36
|
+
return {
|
|
37
|
+
requiredRunFailed: failedRequired.size > 0,
|
|
38
|
+
requiredRunInFlight,
|
|
39
|
+
runInFlight,
|
|
40
|
+
attribution: 'github',
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const REQUIRED_CHECKS_QUERY =
|
|
45
|
+
'query($id: ID!, $n: Int!) { node(id: $id) { ... on PullRequest { ' +
|
|
46
|
+
'commits(last: 1) { nodes { commit { statusCheckRollup { ' +
|
|
47
|
+
'contexts(first: 100) { nodes { __typename ' +
|
|
48
|
+
'... on CheckRun { name isRequired(pullRequestNumber: $n) } ' +
|
|
49
|
+
'... on StatusContext { context isRequired(pullRequestNumber: $n) } ' +
|
|
50
|
+
'} } } } } } } } }';
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* @param {object|string} result `gh api graphql` output
|
|
54
|
+
* @returns {Set<string>}
|
|
55
|
+
*/
|
|
56
|
+
function parseRequiredNames(result) {
|
|
57
|
+
const text = typeof result === 'string' ? result : result?.stdout;
|
|
58
|
+
const parsed = JSON.parse(String(text ?? ''));
|
|
59
|
+
if (Array.isArray(parsed?.errors) && parsed.errors.length > 0) {
|
|
60
|
+
throw new Error('graphql errors reading required checks');
|
|
61
|
+
}
|
|
62
|
+
const nodes =
|
|
63
|
+
parsed?.data?.node?.commits?.nodes?.[0]?.commit?.statusCheckRollup?.contexts
|
|
64
|
+
?.nodes;
|
|
65
|
+
if (!Array.isArray(nodes)) throw new Error('required-check contexts absent');
|
|
66
|
+
const names = new Set();
|
|
67
|
+
for (const node of nodes) {
|
|
68
|
+
const name = readRunName(node);
|
|
69
|
+
if (node?.isRequired === true && name) names.add(name);
|
|
70
|
+
}
|
|
71
|
+
return names;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Per-head cache: required attribution is read at most once per PR head. */
|
|
75
|
+
const requiredNamesCache = new Map();
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Required check names, cached per PR head. `null` on any failure.
|
|
79
|
+
*
|
|
80
|
+
* @param {{ prNodeId?: string, prNumber: number|string, headSha?: string,
|
|
81
|
+
* gh: { api: Function }, timeoutMs?: number }} args
|
|
82
|
+
* @returns {Promise<Set<string>|null>}
|
|
83
|
+
*/
|
|
84
|
+
async function readRequiredCheckNames({
|
|
85
|
+
prNodeId,
|
|
86
|
+
prNumber,
|
|
87
|
+
headSha,
|
|
88
|
+
gh,
|
|
89
|
+
timeoutMs = MERGE_WAIT_GH_TIMEOUT_MS,
|
|
90
|
+
}) {
|
|
91
|
+
if (!prNodeId || !headSha) return null;
|
|
92
|
+
const key = `${prNodeId}@${headSha}`;
|
|
93
|
+
if (requiredNamesCache.has(key)) return requiredNamesCache.get(key);
|
|
94
|
+
try {
|
|
95
|
+
const names = parseRequiredNames(
|
|
96
|
+
await gh.api({
|
|
97
|
+
method: 'POST',
|
|
98
|
+
endpoint: 'graphql',
|
|
99
|
+
body: {
|
|
100
|
+
query: REQUIRED_CHECKS_QUERY,
|
|
101
|
+
variables: { id: prNodeId, n: Number(prNumber) },
|
|
102
|
+
},
|
|
103
|
+
execOpts: { timeoutMs },
|
|
104
|
+
}),
|
|
105
|
+
);
|
|
106
|
+
requiredNamesCache.set(key, names);
|
|
107
|
+
return names;
|
|
108
|
+
} catch {
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Scoped evidence when a red gates the merge and attribution reads; else
|
|
115
|
+
* the unscoped rule.
|
|
116
|
+
*
|
|
117
|
+
* @param {{ view?: object, checksStatus?: string, prNumber: number|string,
|
|
118
|
+
* gh: object, ghTimeoutMs?: number, readFn?: Function }} args
|
|
119
|
+
* @returns {Promise<object|null>}
|
|
120
|
+
*/
|
|
121
|
+
export async function readProbeRunEvidence({
|
|
122
|
+
view,
|
|
123
|
+
checksStatus,
|
|
124
|
+
prNumber,
|
|
125
|
+
gh,
|
|
126
|
+
ghTimeoutMs,
|
|
127
|
+
readFn = readRequiredCheckNames,
|
|
128
|
+
}) {
|
|
129
|
+
const rollup = view?.statusCheckRollup;
|
|
130
|
+
const gated = failingChecksBlockMerge({
|
|
131
|
+
checksStatus,
|
|
132
|
+
mergeStateStatus: view?.mergeStateStatus,
|
|
133
|
+
});
|
|
134
|
+
const names = gated
|
|
135
|
+
? await readFn({
|
|
136
|
+
prNodeId: view?.id,
|
|
137
|
+
prNumber,
|
|
138
|
+
headSha: view?.headRefOid,
|
|
139
|
+
gh,
|
|
140
|
+
timeoutMs: ghTimeoutMs,
|
|
141
|
+
})
|
|
142
|
+
: null;
|
|
143
|
+
if (names instanceof Set && Array.isArray(rollup) && rollup.length > 0) {
|
|
144
|
+
return deriveAttributedEvidence(rollup, names);
|
|
145
|
+
}
|
|
146
|
+
return deriveRequiredRunEvidence(rollup);
|
|
147
|
+
}
|