mandrel 2.35.0 → 2.36.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/agentrc-reference.json +3 -1
- package/.agents/docs/configuration.md +2 -0
- package/.agents/schemas/agentrc.schema.json +11 -0
- package/.agents/schemas/lifecycle/merge.unlanded.schema.json +2 -1
- package/.agents/schemas/story-deliver-terminal.schema.json +1 -0
- package/.agents/scripts/check-doc-links.js +23 -2
- package/.agents/scripts/git-cleanup.js +2 -0
- package/.agents/scripts/lib/config/ci.js +18 -0
- package/.agents/scripts/lib/config-settings-schema-delivery.js +13 -0
- package/.agents/scripts/lib/observability/source-classifier.js +0 -1
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/branches.js +22 -7
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/git-probes.js +22 -14
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/merged-tip.js +132 -0
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/render.js +56 -11
- package/.agents/scripts/lib/orchestration/merge-block-class.js +10 -1
- package/.agents/scripts/lib/orchestration/merge-poll.js +164 -0
- package/.agents/scripts/lib/orchestration/single-story-close/phases/auto-merge.js +145 -0
- package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +96 -5
- package/.agents/scripts/lib/orchestration/single-story-close/runner.js +9 -1
- package/.agents/scripts/notify.js +4 -10
- package/.agents/workflows/audit-documentation.md +5 -6
- package/docs/CHANGELOG.md +18 -0
- package/package.json +3 -3
- package/.agents/scripts/generate-lifecycle-docs.js +0 -237
|
@@ -320,6 +320,8 @@ Everything `/deliver` and `single-story-close` consume: execution timeouts, work
|
|
|
320
320
|
| `ci.watch.maxResumes` | No | `integer` | `3` | How many times the watcher may resume after a transient stall. 0 disables resuming. |
|
|
321
321
|
| `ci.watch.attachWindowMs` | No | `integer` | `1200000` | Story #4890. How long (ms) the watch keeps re-resolving an EMPTY `gh pr checks --required` set before it stops waiting for a required context to attach. A ruleset attaches its contexts asynchronously and the arrival latency is set by the slowest one, so a required context that is an aggregator job gated on every other tier is the last to appear — measured at 16m52s on this repository. Default 1200000 (20 minutes). Raise it for a repository whose contexts arrive later still; exhausting the window is never reported as a red check (the watch exits 2, not-yet-started). |
|
|
322
322
|
| `ci.autoMerge` | No | `"trust-ci"` \| `"strict"` | `"trust-ci"` | Story #4356 (Epic #4355). Merge posture. 'trust-ci' (default) merges once required checks pass; 'strict' additionally requires a clean review gate. |
|
|
323
|
+
| `ci.blockOnAdvisoryFailure` | No | `boolean` | `true` | Story #5096. When true (default), delivery refuses to arm — and disarms — GitHub native auto-merge while a non-required (advisory) check is genuinely red on the PR head and GitHub reports the PR mergeable anyway (mergeStateStatus=UNSTABLE). `--auto` waits on REQUIRED contexts only, so without this a red advisory quality gate merges unattended. Set false to restore the pre-#5096 behaviour verbatim. |
|
|
324
|
+
| `ci.advisoryAllowlist` | No | `array<string>` | `[]` | Story #5096. Check-run names exempt from blockOnAdvisoryFailure — a red run whose name matches exactly never blocks arming. Matching is exact; an unnamed run can never match and always blocks. |
|
|
323
325
|
| `routing` | No | `object` | — | v2 delivery-spawn routing: role-scoped boot contexts and maker-checker sampling. The v1 singleDelivery epic-route kill-switch was removed in Stage 6. |
|
|
324
326
|
| `routing.roleScopedAgents` | No | `boolean` | `true` | Epic #4478 (M7-B). Kill-switch for the role-scoped boot contexts. When true (default), a converted delivery spawn (`story-worker`, `acceptance-critic`) boots on its own `.claude/agents/<role>.md` system prompt instead of re-paying the full CLAUDE.md @-import closure. When false, every converted spawn falls back to `subagent_type: general-purpose` — the instant, code-rollback-free per-consumer revert, and the universal escape for hosts that ignore `.claude/agents/`. The fallback is the full-closure agent that ran before M7-B, so flipping it off never drops a gate. |
|
|
325
327
|
| `routing.freshCriticSampleRate` | No | `number` | `0.2` | Epic #4478 (M7-B, Part 2). Maker-checker sampling floor. Under the standard profile, a change set touching no sensitive path routes its acceptance clusters down the contract-identical inline critic path, but this fraction of them is still forced through a fresh-context critic so a low derived level never means zero independent checking. Clamped to [0, 1]; 0 disables the floor, 1 forces every cluster fresh. Consumed by resolveCeremonyForRisk (lib/orchestration/ceremony-routing.js). |
|
|
@@ -1913,6 +1913,17 @@
|
|
|
1913
1913
|
"enum": ["trust-ci", "strict"],
|
|
1914
1914
|
"description": "Story #4356 (Epic #4355). Merge posture. 'trust-ci' (default) merges once required checks pass; 'strict' additionally requires a clean review gate.",
|
|
1915
1915
|
"default": "trust-ci"
|
|
1916
|
+
},
|
|
1917
|
+
"blockOnAdvisoryFailure": {
|
|
1918
|
+
"type": "boolean",
|
|
1919
|
+
"description": "Story #5096. When true (default), delivery refuses to arm — and disarms — GitHub native auto-merge while a non-required (advisory) check is genuinely red on the PR head and GitHub reports the PR mergeable anyway (mergeStateStatus=UNSTABLE). `--auto` waits on REQUIRED contexts only, so without this a red advisory quality gate merges unattended. Set false to restore the pre-#5096 behaviour verbatim.",
|
|
1920
|
+
"default": true
|
|
1921
|
+
},
|
|
1922
|
+
"advisoryAllowlist": {
|
|
1923
|
+
"type": "array",
|
|
1924
|
+
"items": { "type": "string" },
|
|
1925
|
+
"description": "Story #5096. Check-run names exempt from blockOnAdvisoryFailure — a red run whose name matches exactly never blocks arming. Matching is exact; an unnamed run can never match and always blocks.",
|
|
1926
|
+
"default": []
|
|
1916
1927
|
}
|
|
1917
1928
|
},
|
|
1918
1929
|
"additionalProperties": false
|
|
@@ -325,6 +325,22 @@ function stripAnchorAndQuery(target) {
|
|
|
325
325
|
return t;
|
|
326
326
|
}
|
|
327
327
|
|
|
328
|
+
// Percent-decode a link target's path portion (Story #5090). A correctly
|
|
329
|
+
// encoded Markdown destination escapes URL-reserved characters — a
|
|
330
|
+
// file-based router's `[token]` segment is written `%5Btoken%5D`, the form
|
|
331
|
+
// CommonMark renderers require — but the filesystem knows only the decoded
|
|
332
|
+
// name. The decode is one-way and total: a malformed escape (`%zz`) degrades
|
|
333
|
+
// to the raw string instead of throwing `URIError`, so an undecodable target
|
|
334
|
+
// is resolved exactly as it was before.
|
|
335
|
+
function decodeLinkPath(pathOnly) {
|
|
336
|
+
if (!pathOnly.includes('%')) return pathOnly;
|
|
337
|
+
try {
|
|
338
|
+
return decodeURIComponent(pathOnly);
|
|
339
|
+
} catch {
|
|
340
|
+
return pathOnly;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
328
344
|
// --- Slash-token extraction ------------------------------------------------
|
|
329
345
|
|
|
330
346
|
// Tokens look like `/<lowercase-alphanum-with-hyphens>`. We exclude tokens
|
|
@@ -394,8 +410,13 @@ export function checkFile(absPath, repoRoot) {
|
|
|
394
410
|
// 2. Relative-link resolution.
|
|
395
411
|
for (const { target, line } of extractLinks(masked)) {
|
|
396
412
|
if (isExternalOrInternalAnchor(target)) continue;
|
|
397
|
-
const
|
|
398
|
-
if (!
|
|
413
|
+
const rawPathOnly = stripAnchorAndQuery(target);
|
|
414
|
+
if (!rawPathOnly) continue; // pure anchor that survived earlier check
|
|
415
|
+
// Decode AFTER anchor/query stripping — so an escaped `%23` cannot
|
|
416
|
+
// collapse into an anchor delimiter and truncate the target — and BEFORE
|
|
417
|
+
// resolution, so the payload-boundary branch below reports the decoded
|
|
418
|
+
// path rather than the escaped one.
|
|
419
|
+
const pathOnly = decodeLinkPath(rawPathOnly);
|
|
399
420
|
let resolved;
|
|
400
421
|
if (pathOnly.startsWith('/')) {
|
|
401
422
|
// Treat root-absolute paths as repo-root relative.
|
|
@@ -64,6 +64,7 @@ import {
|
|
|
64
64
|
probeMergedPr,
|
|
65
65
|
refExists,
|
|
66
66
|
} from './lib/orchestration/git-cleanup/phases/git-probes.js';
|
|
67
|
+
import { probeAncestry } from './lib/orchestration/git-cleanup/phases/merged-tip.js';
|
|
67
68
|
import { parseCleanupArgs } from './lib/orchestration/git-cleanup/phases/parse-args.js';
|
|
68
69
|
import {
|
|
69
70
|
executePrune,
|
|
@@ -110,6 +111,7 @@ export {
|
|
|
110
111
|
planFastForward,
|
|
111
112
|
planStashes,
|
|
112
113
|
probeAllPrs,
|
|
114
|
+
probeAncestry,
|
|
113
115
|
probeContentEquivalent,
|
|
114
116
|
probeLatestPr,
|
|
115
117
|
probeMergedPr,
|
|
@@ -5,6 +5,13 @@
|
|
|
5
5
|
* (default `"trust-ci"`) selects the merge posture — `"trust-ci"` merges once
|
|
6
6
|
* required checks pass, `"strict"` additionally requires a clean review gate.
|
|
7
7
|
*
|
|
8
|
+
* Story #5096 added `blockOnAdvisoryFailure` (default `true`) and
|
|
9
|
+
* `advisoryAllowlist` (default `[]`). GitHub native auto-merge waits on
|
|
10
|
+
* REQUIRED contexts only, so a red ADVISORY gate would otherwise be merged
|
|
11
|
+
* straight past; these knobs own mandrel's side of that decision. Set
|
|
12
|
+
* `blockOnAdvisoryFailure: false` to restore the pre-#5096 behaviour verbatim,
|
|
13
|
+
* or list a job name in `advisoryAllowlist` to exempt just that one.
|
|
14
|
+
*
|
|
8
15
|
* Retired (no production readers on v2 Story-only delivery): `earlyPr`
|
|
9
16
|
* (Epic early-PR warmup) and `requireChecks` (AutomergePredicate escape hatch
|
|
10
17
|
* whose listener was never landed).
|
|
@@ -12,6 +19,8 @@
|
|
|
12
19
|
|
|
13
20
|
export const CI_DELIVERY_DEFAULTS = Object.freeze({
|
|
14
21
|
autoMerge: 'trust-ci',
|
|
22
|
+
blockOnAdvisoryFailure: true,
|
|
23
|
+
advisoryAllowlist: Object.freeze([]),
|
|
15
24
|
});
|
|
16
25
|
|
|
17
26
|
/**
|
|
@@ -31,6 +40,15 @@ export function getCiDelivery(config) {
|
|
|
31
40
|
ci.autoMerge === 'trust-ci' || ci.autoMerge === 'strict'
|
|
32
41
|
? ci.autoMerge
|
|
33
42
|
: CI_DELIVERY_DEFAULTS.autoMerge,
|
|
43
|
+
blockOnAdvisoryFailure:
|
|
44
|
+
typeof ci.blockOnAdvisoryFailure === 'boolean'
|
|
45
|
+
? ci.blockOnAdvisoryFailure
|
|
46
|
+
: CI_DELIVERY_DEFAULTS.blockOnAdvisoryFailure,
|
|
47
|
+
advisoryAllowlist: Array.isArray(ci.advisoryAllowlist)
|
|
48
|
+
? ci.advisoryAllowlist.filter(
|
|
49
|
+
(entry) => typeof entry === 'string' && entry,
|
|
50
|
+
)
|
|
51
|
+
: [...CI_DELIVERY_DEFAULTS.advisoryAllowlist],
|
|
34
52
|
watch:
|
|
35
53
|
ci.watch && typeof ci.watch === 'object' ? { ...ci.watch } : undefined,
|
|
36
54
|
};
|
|
@@ -389,6 +389,19 @@ const CI_DELIVERY_SCHEMA = {
|
|
|
389
389
|
"Story #4356 (Epic #4355). Merge posture. 'trust-ci' (default) merges once required checks pass; 'strict' additionally requires a clean review gate.",
|
|
390
390
|
default: CI_DELIVERY_DEFAULTS.autoMerge,
|
|
391
391
|
},
|
|
392
|
+
blockOnAdvisoryFailure: {
|
|
393
|
+
type: 'boolean',
|
|
394
|
+
description:
|
|
395
|
+
'Story #5096. When true (default), delivery refuses to arm — and disarms — GitHub native auto-merge while a non-required (advisory) check is genuinely red on the PR head and GitHub reports the PR mergeable anyway (mergeStateStatus=UNSTABLE). `--auto` waits on REQUIRED contexts only, so without this a red advisory quality gate merges unattended. Set false to restore the pre-#5096 behaviour verbatim.',
|
|
396
|
+
default: CI_DELIVERY_DEFAULTS.blockOnAdvisoryFailure,
|
|
397
|
+
},
|
|
398
|
+
advisoryAllowlist: {
|
|
399
|
+
type: 'array',
|
|
400
|
+
items: { type: 'string' },
|
|
401
|
+
description:
|
|
402
|
+
'Story #5096. Check-run names exempt from blockOnAdvisoryFailure — a red run whose name matches exactly never blocks arming. Matching is exact; an unnamed run can never match and always blocks.',
|
|
403
|
+
default: [...CI_DELIVERY_DEFAULTS.advisoryAllowlist],
|
|
404
|
+
},
|
|
392
405
|
},
|
|
393
406
|
additionalProperties: false,
|
|
394
407
|
};
|
|
@@ -113,7 +113,6 @@ const FRAMEWORK_SCRIPT_BASENAMES = Object.freeze([
|
|
|
113
113
|
'evidence-gate.js',
|
|
114
114
|
'generate-config-docs.js',
|
|
115
115
|
'generate-lens-checklists.js',
|
|
116
|
-
'generate-lifecycle-docs.js',
|
|
117
116
|
'generate-skills-index.js',
|
|
118
117
|
'generate-workflows-doc.js',
|
|
119
118
|
'git-cleanup.js',
|
|
@@ -37,15 +37,19 @@ import {
|
|
|
37
37
|
removeWorktree,
|
|
38
38
|
worktreesByBranch,
|
|
39
39
|
} from './git-probes.js';
|
|
40
|
+
import { probeAncestry } from './merged-tip.js';
|
|
40
41
|
import { parsePrunedRefs } from './prune.js';
|
|
41
42
|
|
|
42
43
|
const TAG = '[git-cleanup]';
|
|
43
44
|
|
|
45
|
+
/** Fields a planner verdict forwards onto its `skipped[]` entry. */
|
|
46
|
+
const SKIP_DETAIL_FIELDS = ['prNumber', 'tipSha', 'mergedSha', 'detail'];
|
|
47
|
+
|
|
44
48
|
function skipEntryFromVerdict(branch, verdict) {
|
|
45
49
|
const entry = { branch, reason: verdict.reason };
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
50
|
+
for (const field of SKIP_DETAIL_FIELDS) {
|
|
51
|
+
if (verdict[field] != null) entry[field] = verdict[field];
|
|
52
|
+
}
|
|
49
53
|
return entry;
|
|
50
54
|
}
|
|
51
55
|
|
|
@@ -88,6 +92,7 @@ function evaluateLocalBranch({
|
|
|
88
92
|
wtMap,
|
|
89
93
|
remoteName,
|
|
90
94
|
branchTipShaFn,
|
|
95
|
+
ancestryFn,
|
|
91
96
|
contentEquivalentFn,
|
|
92
97
|
branchLastCommitFn,
|
|
93
98
|
}) {
|
|
@@ -102,6 +107,7 @@ function evaluateLocalBranch({
|
|
|
102
107
|
remoteName,
|
|
103
108
|
localExists: true,
|
|
104
109
|
branchTipShaFn,
|
|
110
|
+
ancestryFn,
|
|
105
111
|
});
|
|
106
112
|
if (verdict.kind === 'skip') {
|
|
107
113
|
return { skip: skipEntryFromVerdict(branch, verdict) };
|
|
@@ -134,6 +140,7 @@ function evaluateLocalBranch({
|
|
|
134
140
|
worktreePath: wt?.path ?? null,
|
|
135
141
|
detectedBy,
|
|
136
142
|
localExists: true,
|
|
143
|
+
behindMerge: verdict.reason === 'tip-behind-merge',
|
|
137
144
|
},
|
|
138
145
|
};
|
|
139
146
|
}
|
|
@@ -147,6 +154,7 @@ function collectRemoteOnlyCandidates({
|
|
|
147
154
|
filter,
|
|
148
155
|
prProbe,
|
|
149
156
|
branchTipShaFn,
|
|
157
|
+
ancestryFn,
|
|
150
158
|
skipped,
|
|
151
159
|
}) {
|
|
152
160
|
const out = [];
|
|
@@ -162,6 +170,7 @@ function collectRemoteOnlyCandidates({
|
|
|
162
170
|
remoteName,
|
|
163
171
|
localExists: false,
|
|
164
172
|
branchTipShaFn,
|
|
173
|
+
ancestryFn,
|
|
165
174
|
});
|
|
166
175
|
if (verdict.kind === 'no-pr') continue;
|
|
167
176
|
if (verdict.kind === 'skip') {
|
|
@@ -176,6 +185,7 @@ function collectRemoteOnlyCandidates({
|
|
|
176
185
|
worktreePath: null,
|
|
177
186
|
detectedBy: 'remote-only',
|
|
178
187
|
localExists: false,
|
|
188
|
+
behindMerge: verdict.reason === 'tip-behind-merge',
|
|
179
189
|
});
|
|
180
190
|
}
|
|
181
191
|
return out;
|
|
@@ -187,10 +197,12 @@ function collectRemoteOnlyCandidates({
|
|
|
187
197
|
* The PR probe classifies each candidate by the **latest** PR on the head
|
|
188
198
|
* ref rather than any historical merge. Branches whose latest PR is OPEN
|
|
189
199
|
* or CLOSED-not-merged are skipped with `reason: 'latest-pr-open'` /
|
|
190
|
-
* `reason: 'latest-pr-closed-not-merged'`.
|
|
191
|
-
*
|
|
192
|
-
*
|
|
193
|
-
* `
|
|
200
|
+
* `reason: 'latest-pr-closed-not-merged'`. A MERGED PR whose `headRefOid`
|
|
201
|
+
* differs from the branch tip is resolved by ancestry in
|
|
202
|
+
* `merged-tip.js` — a tip *behind* the merged head becomes a candidate
|
|
203
|
+
* carrying `behindMerge: true`, a tip *ahead* of it keeps the
|
|
204
|
+
* `tip-diverged-from-merge` force-push skip, and an unresolvable rev
|
|
205
|
+
* skips as `unverifiable`.
|
|
194
206
|
*
|
|
195
207
|
* Performance (Story #3333): when the caller does not inject its own
|
|
196
208
|
* `prProbe`, the planner fires **one** bulk `gh pr list --state all`
|
|
@@ -250,6 +262,7 @@ export function planCleanup(ctx) {
|
|
|
250
262
|
prIndexFn = probeAllPrs,
|
|
251
263
|
prFallback = probeLatestPr,
|
|
252
264
|
branchTipShaFn = branchTipSha,
|
|
265
|
+
ancestryFn = probeAncestry,
|
|
253
266
|
contentEquivalentFn = probeContentEquivalent,
|
|
254
267
|
branchLastCommitFn = branchLastCommitAt,
|
|
255
268
|
refExistsFn = refExists,
|
|
@@ -301,6 +314,7 @@ export function planCleanup(ctx) {
|
|
|
301
314
|
wtMap,
|
|
302
315
|
remoteName,
|
|
303
316
|
branchTipShaFn,
|
|
317
|
+
ancestryFn,
|
|
304
318
|
contentEquivalentFn,
|
|
305
319
|
branchLastCommitFn,
|
|
306
320
|
});
|
|
@@ -318,6 +332,7 @@ export function planCleanup(ctx) {
|
|
|
318
332
|
filter,
|
|
319
333
|
prProbe,
|
|
320
334
|
branchTipShaFn,
|
|
335
|
+
ancestryFn,
|
|
321
336
|
skipped,
|
|
322
337
|
}),
|
|
323
338
|
);
|
|
@@ -17,6 +17,7 @@ import { execFileSync } from 'node:child_process';
|
|
|
17
17
|
|
|
18
18
|
import { gitSpawn } from '../../../git-utils.js';
|
|
19
19
|
import { parseWorktreePorcelain } from '../../../worktree-manager.js';
|
|
20
|
+
import { resolveMergedTip } from './merged-tip.js';
|
|
20
21
|
|
|
21
22
|
export {
|
|
22
23
|
canFastForward,
|
|
@@ -452,6 +453,12 @@ export const __testing = { validSha, firstLsRemoteSha, firstStdoutLine };
|
|
|
452
453
|
* into `skipped[]` and continues.
|
|
453
454
|
* - `{ kind: 'no-pr' }` — caller continues without skipping.
|
|
454
455
|
*
|
|
456
|
+
* A MERGED PR whose `headRefOid` differs from the branch tip is handed
|
|
457
|
+
* to {@link resolveMergedTip}, which resolves it by ancestry — see that
|
|
458
|
+
* module for the `tip-behind-merge` / `tip-diverged-from-merge` /
|
|
459
|
+
* `unverifiable` taxonomy and why a bare SHA inequality could not
|
|
460
|
+
* express it.
|
|
461
|
+
*
|
|
455
462
|
* @param {{
|
|
456
463
|
* prInfo: { number?: number, state?: string, mergedAt?: string|null, headRefOid?: string|null } | null,
|
|
457
464
|
* branch: string,
|
|
@@ -459,8 +466,10 @@ export const __testing = { validSha, firstLsRemoteSha, firstStdoutLine };
|
|
|
459
466
|
* remoteName: string,
|
|
460
467
|
* localExists: boolean,
|
|
461
468
|
* branchTipShaFn: (args: { cwd: string, branch: string, remoteName: string, localExists: boolean }) => string | null,
|
|
469
|
+
* ancestryFn?: Function,
|
|
470
|
+
* mergedTipFn?: typeof resolveMergedTip,
|
|
462
471
|
* }} args
|
|
463
|
-
* @returns {{ kind: 'candidate', prInfo: object } | { kind: 'skip', reason: string, prNumber?: number, tipSha?: string|null, mergedSha?: string|null } | { kind: 'no-pr' }}
|
|
472
|
+
* @returns {{ kind: 'candidate', prInfo: object, reason?: string, tipSha?: string, mergedSha?: string } | { kind: 'skip', reason: string, prNumber?: number, tipSha?: string|null, mergedSha?: string|null, detail?: string } | { kind: 'no-pr' }}
|
|
464
473
|
*/
|
|
465
474
|
export function classifyLatestPr({
|
|
466
475
|
prInfo,
|
|
@@ -469,6 +478,8 @@ export function classifyLatestPr({
|
|
|
469
478
|
remoteName,
|
|
470
479
|
localExists,
|
|
471
480
|
branchTipShaFn,
|
|
481
|
+
ancestryFn,
|
|
482
|
+
mergedTipFn = resolveMergedTip,
|
|
472
483
|
}) {
|
|
473
484
|
if (!prInfo) return { kind: 'no-pr' };
|
|
474
485
|
const state =
|
|
@@ -494,17 +505,14 @@ export function classifyLatestPr({
|
|
|
494
505
|
prNumber: prInfo.number ?? null,
|
|
495
506
|
};
|
|
496
507
|
}
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
}
|
|
508
|
-
}
|
|
509
|
-
return { kind: 'candidate', prInfo };
|
|
508
|
+
const tipVerdict = mergedTipFn({
|
|
509
|
+
prInfo,
|
|
510
|
+
branch,
|
|
511
|
+
cwd,
|
|
512
|
+
remoteName,
|
|
513
|
+
localExists,
|
|
514
|
+
branchTipShaFn,
|
|
515
|
+
ancestryFn,
|
|
516
|
+
});
|
|
517
|
+
return tipVerdict ?? { kind: 'candidate', prInfo };
|
|
510
518
|
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* merged-tip.js — resolve a MERGED PR's head against the branch tip
|
|
3
|
+
* (Story #5086).
|
|
4
|
+
*
|
|
5
|
+
* Owns the ancestry probe and the taxonomy the branches-phase classifier
|
|
6
|
+
* applies when a merged PR's `headRefOid` and the branch tip disagree.
|
|
7
|
+
* Split out of `git-probes.js` so the classifier reads as one call and
|
|
8
|
+
* the taxonomy's own documentation sits next to the code it governs.
|
|
9
|
+
*
|
|
10
|
+
* @module lib/orchestration/git-cleanup/phases/merged-tip
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { gitSpawn } from '../../../git-utils.js';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Tri-state ancestry probe: is `ancestorSha` reachable from
|
|
17
|
+
* `descendantSha`?
|
|
18
|
+
*
|
|
19
|
+
* Mirrors the contract `checkHeadAncestor` in
|
|
20
|
+
* `lib/worktree/lifecycle/merge-reachability.js` proved out for the
|
|
21
|
+
* worktree-reap gate — the two cannot share an implementation because
|
|
22
|
+
* that one takes a `ctx.git.gitSpawn` / `ctx.repoRoot` bag while
|
|
23
|
+
* git-cleanup's probes take a bare `cwd`.
|
|
24
|
+
*
|
|
25
|
+
* `git merge-base --is-ancestor` exits **0** (ancestor), **1** (not an
|
|
26
|
+
* ancestor) or **128** (a rev it cannot resolve). Folding 128 into
|
|
27
|
+
* "not an ancestor" is the bug this probe exists to prevent: a merged
|
|
28
|
+
* head absent from the local object DB would silently read as a
|
|
29
|
+
* divergence and re-emit the wrong post-merge-force-push diagnosis. Both
|
|
30
|
+
* revs are therefore resolved with `git rev-parse -q --verify` first, and
|
|
31
|
+
* any failure fails closed to the `error` arm — so `merge-base` never
|
|
32
|
+
* runs against a rev git cannot resolve.
|
|
33
|
+
*
|
|
34
|
+
* @param {{ cwd: string, ancestorSha: string, descendantSha: string, spawn?: typeof gitSpawn }} args
|
|
35
|
+
* @returns {{ outcome: 'ancestor' } | { outcome: 'not-ancestor' } | { outcome: 'error', reason: string }}
|
|
36
|
+
*/
|
|
37
|
+
export function probeAncestry({
|
|
38
|
+
cwd,
|
|
39
|
+
ancestorSha,
|
|
40
|
+
descendantSha,
|
|
41
|
+
spawn = gitSpawn,
|
|
42
|
+
}) {
|
|
43
|
+
for (const rev of [ancestorSha, descendantSha]) {
|
|
44
|
+
const res = spawn(
|
|
45
|
+
cwd,
|
|
46
|
+
'rev-parse',
|
|
47
|
+
'--quiet',
|
|
48
|
+
'--verify',
|
|
49
|
+
`${rev}^{commit}`,
|
|
50
|
+
);
|
|
51
|
+
if (res.status !== 0) {
|
|
52
|
+
return { outcome: 'error', reason: `unresolvable rev ${rev}` };
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
const res = spawn(
|
|
56
|
+
cwd,
|
|
57
|
+
'merge-base',
|
|
58
|
+
'--is-ancestor',
|
|
59
|
+
ancestorSha,
|
|
60
|
+
descendantSha,
|
|
61
|
+
);
|
|
62
|
+
if (res.status === 0) return { outcome: 'ancestor' };
|
|
63
|
+
if (res.status === 1) return { outcome: 'not-ancestor' };
|
|
64
|
+
return {
|
|
65
|
+
outcome: 'error',
|
|
66
|
+
reason: (res.stderr || res.stdout || 'unknown').trim(),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Resolve a MERGED PR's `headRefOid` against the branch's current tip.
|
|
72
|
+
*
|
|
73
|
+
* Returns `null` when there is nothing to resolve — the PR row carries no
|
|
74
|
+
* `headRefOid`, the tip cannot be read, or the tip already matches the
|
|
75
|
+
* merged head — leaving the caller's plain-candidate path untouched.
|
|
76
|
+
*
|
|
77
|
+
* Otherwise the tip is classified by **ancestry**, never by the bare SHA
|
|
78
|
+
* inequality this replaced. That inequality could not tell a branch that
|
|
79
|
+
* is *behind* the merged head from one force-pushed *past* it, and
|
|
80
|
+
* reported both as the latter — advising the operator to push a follow-up
|
|
81
|
+
* commit that, for a stale pre-merge snapshot, does not exist. The three
|
|
82
|
+
* arms:
|
|
83
|
+
*
|
|
84
|
+
* - **ancestor** — 0 commits ahead, every commit landed with the PR:
|
|
85
|
+
* a reap candidate tagged `reason: 'tip-behind-merge'`.
|
|
86
|
+
* - **not-ancestor** — equivalently "≥1 commit ahead", which is why one
|
|
87
|
+
* probe settles the whole taxonomy and no `rev-list` count is needed:
|
|
88
|
+
* the unchanged `tip-diverged-from-merge` force-push skip.
|
|
89
|
+
* - **error** — a rev the local object DB cannot resolve:
|
|
90
|
+
* `reason: 'unverifiable'` carrying the probe's `detail`. Never a
|
|
91
|
+
* silent pass, and never a force-push label.
|
|
92
|
+
*
|
|
93
|
+
* @param {object} args
|
|
94
|
+
* @returns {{ kind: 'candidate', prInfo: object, reason: string, tipSha: string, mergedSha: string } | { kind: 'skip', reason: string, prNumber: number|null, tipSha: string, mergedSha: string, detail?: string } | null}
|
|
95
|
+
*/
|
|
96
|
+
export function resolveMergedTip({
|
|
97
|
+
prInfo,
|
|
98
|
+
branch,
|
|
99
|
+
cwd,
|
|
100
|
+
remoteName,
|
|
101
|
+
localExists,
|
|
102
|
+
branchTipShaFn,
|
|
103
|
+
ancestryFn = probeAncestry,
|
|
104
|
+
}) {
|
|
105
|
+
const mergedSha = prInfo.headRefOid;
|
|
106
|
+
if (!mergedSha) return null;
|
|
107
|
+
const tipSha = branchTipShaFn({ cwd, branch, remoteName, localExists });
|
|
108
|
+
if (!tipSha || tipSha === mergedSha) return null;
|
|
109
|
+
const ancestry = ancestryFn({
|
|
110
|
+
cwd,
|
|
111
|
+
ancestorSha: tipSha,
|
|
112
|
+
descendantSha: mergedSha,
|
|
113
|
+
});
|
|
114
|
+
if (ancestry.outcome === 'ancestor') {
|
|
115
|
+
return {
|
|
116
|
+
kind: 'candidate',
|
|
117
|
+
prInfo,
|
|
118
|
+
reason: 'tip-behind-merge',
|
|
119
|
+
tipSha,
|
|
120
|
+
mergedSha,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
const errored = ancestry.outcome === 'error';
|
|
124
|
+
return {
|
|
125
|
+
kind: 'skip',
|
|
126
|
+
reason: errored ? 'unverifiable' : 'tip-diverged-from-merge',
|
|
127
|
+
prNumber: prInfo.number ?? null,
|
|
128
|
+
tipSha,
|
|
129
|
+
mergedSha,
|
|
130
|
+
...(errored ? { detail: ancestry.reason } : {}),
|
|
131
|
+
};
|
|
132
|
+
}
|
|
@@ -61,6 +61,41 @@ function contentMergedNote(candidate) {
|
|
|
61
61
|
: '';
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
+
/**
|
|
65
|
+
* Pure: render a single behind-the-merged-head candidate annotation.
|
|
66
|
+
*
|
|
67
|
+
* A branch whose tip is a strict ancestor of its merged PR head is a
|
|
68
|
+
* stale pre-merge snapshot — reapable, because every commit on it landed
|
|
69
|
+
* with the PR, but reapable for a different reason than a branch whose
|
|
70
|
+
* tip *matches* the merged head. It used to be skipped outright as a
|
|
71
|
+
* post-merge force-push; the note keeps the two visibly distinct in the
|
|
72
|
+
* dry-run list and the confirmation prompt so the operator can see why a
|
|
73
|
+
* branch that is not at the merged head is nonetheless offered.
|
|
74
|
+
*/
|
|
75
|
+
function behindMergeNote(candidate) {
|
|
76
|
+
return candidate.behindMerge
|
|
77
|
+
? ' (tip behind the merged head — content already landed)'
|
|
78
|
+
: '';
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Pure: every provenance annotation a candidate row carries, in order. */
|
|
82
|
+
function candidateNotes(candidate) {
|
|
83
|
+
return `${contentMergedNote(candidate)}${behindMergeNote(candidate)}`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Pure: one candidate row — its detection provenance, worktree, locality
|
|
88
|
+
* and annotations. Split out of {@link renderDryRun} so that renderer
|
|
89
|
+
* stays a loop over rows rather than growing a fourth inline ternary
|
|
90
|
+
* every time a candidate gains a new dimension.
|
|
91
|
+
*/
|
|
92
|
+
function renderCandidateRow(c) {
|
|
93
|
+
const pr = c.prNumber ? `PR #${c.prNumber}` : c.detectedBy;
|
|
94
|
+
const wt = c.hasWorktree ? ` (worktree: ${c.worktreePath})` : '';
|
|
95
|
+
const remoteOnly = c.localExists === false ? ' (remote-only)' : '';
|
|
96
|
+
return ` • ${c.branch} — ${pr}${wt}${remoteOnly}${candidateNotes(c)}`;
|
|
97
|
+
}
|
|
98
|
+
|
|
64
99
|
/**
|
|
65
100
|
* Pure: render the branch-phase candidate list as the operator-facing text
|
|
66
101
|
* block.
|
|
@@ -87,14 +122,7 @@ export function renderDryRun(plan, opts = {}) {
|
|
|
87
122
|
if (plan.candidates.length === 0) {
|
|
88
123
|
lines.push(' (no merged branches to clean up)');
|
|
89
124
|
} else {
|
|
90
|
-
for (const c of plan.candidates)
|
|
91
|
-
const pr = c.prNumber ? `PR #${c.prNumber}` : c.detectedBy;
|
|
92
|
-
const wt = c.hasWorktree ? ` (worktree: ${c.worktreePath})` : '';
|
|
93
|
-
const remoteOnly = c.localExists === false ? ' (remote-only)' : '';
|
|
94
|
-
lines.push(
|
|
95
|
-
` • ${c.branch} — ${pr}${wt}${remoteOnly}${contentMergedNote(c)}`,
|
|
96
|
-
);
|
|
97
|
-
}
|
|
125
|
+
for (const c of plan.candidates) lines.push(renderCandidateRow(c));
|
|
98
126
|
}
|
|
99
127
|
const skipped = plan.skipped ?? [];
|
|
100
128
|
const currentHeadSkip = skipped.find((s) => s.reason === 'current-head');
|
|
@@ -141,6 +169,17 @@ export function renderCandidateList({ plan, opts = {}, baseBranch = null }) {
|
|
|
141
169
|
return renderDryRun(plan, { baseBranch, execute: !opts.dryRun });
|
|
142
170
|
}
|
|
143
171
|
|
|
172
|
+
/**
|
|
173
|
+
* Pure: the tip / merged short-SHA pair both merged-tip skip lines quote,
|
|
174
|
+
* with a placeholder for either side the planner could not resolve.
|
|
175
|
+
*/
|
|
176
|
+
function shortShaPair(skip) {
|
|
177
|
+
return {
|
|
178
|
+
tip: skip.tipSha ? skip.tipSha.slice(0, 7) : '<unknown>',
|
|
179
|
+
merged: skip.mergedSha ? skip.mergedSha.slice(0, 7) : '<unknown>',
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
144
183
|
/**
|
|
145
184
|
* Pure: render a single latest-PR-state skip line. Returns null when the
|
|
146
185
|
* skip reason is not one of the latest-PR family — `renderDryRun` filters
|
|
@@ -148,7 +187,7 @@ export function renderCandidateList({ plan, opts = {}, baseBranch = null }) {
|
|
|
148
187
|
* `current-head`, `filtered`) stay quiet here. `not-merged` gets its own
|
|
149
188
|
* renderer ({@link renderNotMergedSkipLine}).
|
|
150
189
|
*
|
|
151
|
-
* @param {{ branch: string, reason: string, prNumber?: number, tipSha?: string, mergedSha?: string }} skip
|
|
190
|
+
* @param {{ branch: string, reason: string, prNumber?: number, tipSha?: string, mergedSha?: string, detail?: string }} skip
|
|
152
191
|
* @returns {string | null}
|
|
153
192
|
*/
|
|
154
193
|
export function renderLatestPrSkipLine(skip) {
|
|
@@ -161,13 +200,19 @@ export function renderLatestPrSkipLine(skip) {
|
|
|
161
200
|
return `${TAG} ⏭️ ${skip.branch} skipped — ${prRef} is still open`;
|
|
162
201
|
}
|
|
163
202
|
if (skip.reason === 'tip-diverged-from-merge') {
|
|
164
|
-
const tip
|
|
165
|
-
const merged = skip.mergedSha ? skip.mergedSha.slice(0, 7) : '<unknown>';
|
|
203
|
+
const { tip, merged } = shortShaPair(skip);
|
|
166
204
|
return (
|
|
167
205
|
`${TAG} ⏭️ ${skip.branch} skipped — tip ${tip} diverges from ${prRef}'s merged ${merged} (post-merge force-push); ` +
|
|
168
206
|
`resolve by deleting manually (\`git branch -D ${skip.branch}\`) or pushing the follow-up commit`
|
|
169
207
|
);
|
|
170
208
|
}
|
|
209
|
+
if (skip.reason === 'unverifiable') {
|
|
210
|
+
const { tip, merged } = shortShaPair(skip);
|
|
211
|
+
return (
|
|
212
|
+
`${TAG} ⏭️ ${skip.branch} skipped — cannot verify tip ${tip} against ${prRef}'s merged ${merged}${skip.detail ? `: ${skip.detail}` : ''}; ` +
|
|
213
|
+
`fetch the missing commit or inspect the branch by hand before deleting it`
|
|
214
|
+
);
|
|
215
|
+
}
|
|
171
216
|
if (skip.reason === 'latest-pr-unknown-state') {
|
|
172
217
|
return `${TAG} ⏭️ ${skip.branch} skipped — ${prRef} has an unrecognized state`;
|
|
173
218
|
}
|
|
@@ -96,7 +96,15 @@ export const BLOCK_CLASSES = Object.freeze([
|
|
|
96
96
|
* This is the classifier's four outputs PLUS `predicate-refused` (#4472),
|
|
97
97
|
* emitted DIRECTLY for a headless refusal that never reached the
|
|
98
98
|
* poll-exhaustion classifier — so it is a valid attribution value even though
|
|
99
|
-
* `classifyMergeBlock` never produces it.
|
|
99
|
+
* `classifyMergeBlock` never produces it. Story #5096 added
|
|
100
|
+
* `advisory-gate-red` on the same footing: a genuinely red NON-required check
|
|
101
|
+
* observed while GitHub reports the PR mergeable anyway
|
|
102
|
+
* (`mergeStateStatus: UNSTABLE`), which native auto-merge would land straight
|
|
103
|
+
* past. It is emitted directly by the arm and merge-wait phases — the
|
|
104
|
+
* classifier cannot produce it, because by construction GitHub is NOT blocking
|
|
105
|
+
* the merge, which is the entire problem it names. It is deliberately NOT in
|
|
106
|
+
* `BLOCK_CLASSES`, whose reachability invariant covers only what
|
|
107
|
+
* `classifyMergeBlock` returns. (The Epic-era listeners that used to
|
|
100
108
|
* emit it, AutomergePredicate and AutomergeArmer, are gone; the value stays
|
|
101
109
|
* because archived `merge.unlanded` records carry it and the schema enum
|
|
102
110
|
* must keep validating them.) `isValidBlockClass` (and the `merge.unlanded` schema enum)
|
|
@@ -106,6 +114,7 @@ export const BLOCK_CLASSES = Object.freeze([
|
|
|
106
114
|
export const MERGE_UNLANDED_BLOCK_CLASSES = Object.freeze([
|
|
107
115
|
...BLOCK_CLASSES,
|
|
108
116
|
'predicate-refused',
|
|
117
|
+
'advisory-gate-red',
|
|
109
118
|
]);
|
|
110
119
|
|
|
111
120
|
const BLOCK_CLASS_SET = new Set(MERGE_UNLANDED_BLOCK_CLASSES);
|