mandrel 1.76.0 → 1.77.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 +2 -2
- package/.agents/schemas/agentrc.schema.json +1 -1
- package/.agents/schemas/dispatch-manifest.json +1 -1
- package/.agents/schemas/validation-evidence.schema.json +2 -1
- package/.agents/scripts/audit-to-stories.js +43 -1
- package/.agents/scripts/epic-deliver-prepare.js +31 -0
- package/.agents/scripts/evidence-gate.js +48 -12
- package/.agents/scripts/lib/audit-to-stories/build-story-body.js +141 -34
- package/.agents/scripts/lib/cli-args.js +6 -0
- package/.agents/scripts/lib/close-validation/runner.js +25 -8
- package/.agents/scripts/lib/config/temp-paths.js +1 -1
- package/.agents/scripts/lib/config/worktree-isolation.js +18 -3
- package/.agents/scripts/lib/config-resolver.js +4 -1
- package/.agents/scripts/lib/config-settings-schema-delivery.js +1 -1
- package/.agents/scripts/lib/git-branch-lifecycle.js +90 -0
- package/.agents/scripts/lib/orchestration/auto-merge-cwd.js +128 -0
- package/.agents/scripts/lib/orchestration/column-sync.js +88 -9
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/automerge-armer.js +20 -2
- package/.agents/scripts/lib/orchestration/project-meta-cache.js +238 -0
- package/.agents/scripts/lib/orchestration/reassert-status-column.js +3 -1
- package/.agents/scripts/lib/orchestration/single-story-close/phases/auto-merge.js +25 -2
- package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +80 -14
- package/.agents/scripts/lib/orchestration/single-story-close/runner.js +74 -25
- package/.agents/scripts/lib/orchestration/story-close/phases/locked-pipeline.js +10 -1
- package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +48 -1
- package/.agents/scripts/lib/orchestration/ticket-validator-sizing.js +148 -4
- package/.agents/scripts/lib/orchestration/ticketing/transition.js +8 -1
- package/.agents/scripts/lib/story-body/story-body.js +76 -7
- package/.agents/scripts/lib/story-init/branch-initializer.js +29 -43
- package/.agents/scripts/lib/story-init/hierarchy-tracer.js +25 -4
- package/.agents/scripts/lib/story-init/task-graph-builder.js +22 -12
- package/.agents/scripts/lib/templates/decomposer-prompts.js +23 -0
- package/.agents/scripts/lib/validation-evidence.js +63 -25
- package/.agents/scripts/lib/worktree/node-modules-strategy.js +239 -31
- package/.agents/scripts/resync-status-column.js +5 -0
- package/.agents/scripts/run-coverage.js +85 -45
- package/.agents/scripts/single-story-init.js +22 -29
- package/.agents/scripts/story-init.js +38 -63
- package/.agents/scripts/story-phase.js +46 -4
- package/.agents/skills/core/epic-plan-decompose-author/SKILL.md +5 -3
- package/.agents/workflows/helpers/acceptance-self-eval.md +27 -0
- package/.agents/workflows/helpers/deliver-epic.md +19 -2
- package/.agents/workflows/helpers/epic-deliver-story.md +50 -14
- package/.agents/workflows/helpers/single-story-deliver.md +12 -0
- package/docs/CHANGELOG.md +33 -0
- package/package.json +1 -1
|
@@ -10,10 +10,26 @@
|
|
|
10
10
|
* silently disabling worktrees when the operator omitted the block).
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
+
/**
|
|
14
|
+
* Default `nodeModulesStrategy`, platform-aware (Story #4249).
|
|
15
|
+
*
|
|
16
|
+
* darwin/linux default to `clone` — a copy-on-write (clonefile/reflink) clone
|
|
17
|
+
* of the donor's `node_modules` that is effectively free in time and disk on
|
|
18
|
+
* APFS / reflink-capable filesystems, with a clean fall-back to `per-worktree`
|
|
19
|
+
* on any failure (unsupported fs, cross-volume, etc.). Windows has no reflink
|
|
20
|
+
* equivalent on this path, so it keeps the `per-worktree` install default.
|
|
21
|
+
*
|
|
22
|
+
* @param {NodeJS.Platform} [platform]
|
|
23
|
+
* @returns {'clone' | 'per-worktree'}
|
|
24
|
+
*/
|
|
25
|
+
export function defaultNodeModulesStrategy(platform = process.platform) {
|
|
26
|
+
return platform === 'win32' ? 'per-worktree' : 'clone';
|
|
27
|
+
}
|
|
28
|
+
|
|
13
29
|
export const WORKTREE_ISOLATION_DEFAULTS = Object.freeze({
|
|
14
30
|
enabled: true,
|
|
15
31
|
root: '.worktrees',
|
|
16
|
-
nodeModulesStrategy:
|
|
32
|
+
nodeModulesStrategy: defaultNodeModulesStrategy(),
|
|
17
33
|
primeFromPath: null,
|
|
18
34
|
allowSymlinkOnWindows: false,
|
|
19
35
|
reapOnSuccess: true,
|
|
@@ -55,8 +71,7 @@ export function getWorktreeIsolation(config) {
|
|
|
55
71
|
? wi.enabled
|
|
56
72
|
: WORKTREE_ISOLATION_DEFAULTS.enabled,
|
|
57
73
|
root: wi.root ?? WORKTREE_ISOLATION_DEFAULTS.root,
|
|
58
|
-
nodeModulesStrategy:
|
|
59
|
-
wi.nodeModulesStrategy ?? WORKTREE_ISOLATION_DEFAULTS.nodeModulesStrategy,
|
|
74
|
+
nodeModulesStrategy: wi.nodeModulesStrategy ?? defaultNodeModulesStrategy(),
|
|
60
75
|
primeFromPath:
|
|
61
76
|
wi.primeFromPath === undefined
|
|
62
77
|
? WORKTREE_ISOLATION_DEFAULTS.primeFromPath
|
|
@@ -63,7 +63,10 @@ export {
|
|
|
63
63
|
} from './config/runtime.js';
|
|
64
64
|
export { resolveListValue } from './config/shared.js';
|
|
65
65
|
export { validateOrchestrationConfig } from './config/validate-orchestration.js';
|
|
66
|
-
export {
|
|
66
|
+
export {
|
|
67
|
+
defaultNodeModulesStrategy,
|
|
68
|
+
WORKTREE_ISOLATION_DEFAULTS,
|
|
69
|
+
} from './config/worktree-isolation.js';
|
|
67
70
|
export { PROJECT_ROOT } from './project-root.js';
|
|
68
71
|
|
|
69
72
|
// Cache keyed by absolute root path so callers passing different cwds
|
|
@@ -105,7 +105,7 @@ const WORKTREE_ISOLATION_SCHEMA = {
|
|
|
105
105
|
root: { type: 'string', minLength: 1 },
|
|
106
106
|
nodeModulesStrategy: {
|
|
107
107
|
type: 'string',
|
|
108
|
-
enum: ['per-worktree', 'symlink', 'pnpm-store'],
|
|
108
|
+
enum: ['per-worktree', 'clone', 'symlink', 'pnpm-store'],
|
|
109
109
|
},
|
|
110
110
|
primeFromPath: { type: ['string', 'null'], minLength: 1 },
|
|
111
111
|
allowSymlinkOnWindows: { type: 'boolean' },
|
|
@@ -114,6 +114,96 @@ export function classifyBranchSeed({ localHas, remoteHas }) {
|
|
|
114
114
|
return 'create';
|
|
115
115
|
}
|
|
116
116
|
|
|
117
|
+
/**
|
|
118
|
+
* Single-home for the story-branch seed-action *switch shell* that
|
|
119
|
+
* `single-story-init.js#seedStoryBranch` (standalone path) and
|
|
120
|
+
* `story-init/branch-initializer.js#ensureStoryBranchSeed` (Epic path) had
|
|
121
|
+
* each re-implemented (Story #4255). Both already delegated the (local,
|
|
122
|
+
* remote) decision to `classifyBranchSeed`; only the act-on-the-decision
|
|
123
|
+
* shell (reuse / fetch / create) was duplicated, and that shell was the
|
|
124
|
+
* drift surface for the seed-decision contract.
|
|
125
|
+
*
|
|
126
|
+
* The two callers differ in exactly two behavioural axes, both of which are
|
|
127
|
+
* parameters here — no other conditional branching is introduced:
|
|
128
|
+
* - **`baseRef`** — the ref to branch from on `create` (`main` for the
|
|
129
|
+
* standalone path, the Epic branch for the Epic path).
|
|
130
|
+
* - **`swallowCreateRace`** — when `true`, a `git branch` that exits
|
|
131
|
+
* non-zero with an "already exists" stderr is treated as reuse rather
|
|
132
|
+
* than a fatal error (closes the probe→create race the Epic path runs
|
|
133
|
+
* under concurrent wave dispatch). When `false`, any create failure
|
|
134
|
+
* throws (the standalone path has no concurrent creator to race).
|
|
135
|
+
*
|
|
136
|
+
* The asymmetric surrounding wrappers (merged-sweep, fast-forward,
|
|
137
|
+
* donor-prime, workspace-verify, phase-timer) are deliberately NOT folded
|
|
138
|
+
* in — they stay in their respective callers.
|
|
139
|
+
*
|
|
140
|
+
* Caller-specific log lines and error text are passed in as the `messages`
|
|
141
|
+
* data bag so behaviour stays byte-identical to the pre-extraction switches.
|
|
142
|
+
* The git seams (`spawn`, `existsLocally`, `existsRemotely`) are injected so
|
|
143
|
+
* each caller can bind its own cwd (and tests can mock them).
|
|
144
|
+
*
|
|
145
|
+
* @param {object} opts
|
|
146
|
+
* @param {string} opts.storyBranch
|
|
147
|
+
* @param {string} opts.baseRef Ref to branch from on `create`.
|
|
148
|
+
* @param {boolean} [opts.swallowCreateRace=false]
|
|
149
|
+
* @param {(args: string[]) => { status: number, stdout?: string, stderr?: string }} opts.spawn
|
|
150
|
+
* @param {(branch: string) => boolean} opts.existsLocally
|
|
151
|
+
* @param {(branch: string) => boolean} opts.existsRemotely
|
|
152
|
+
* @param {(level: string, message: string) => void} [opts.progress]
|
|
153
|
+
* @param {object} opts.messages
|
|
154
|
+
* @param {(b: string) => string} opts.messages.reuse
|
|
155
|
+
* @param {(b: string) => string} opts.messages.fetch
|
|
156
|
+
* @param {(b: string, ref: string) => string} opts.messages.create
|
|
157
|
+
* @param {(b: string) => string} [opts.messages.createRace] Used when `swallowCreateRace`.
|
|
158
|
+
* @param {(b: string, ref: string, stderr: string) => string} opts.messages.createError
|
|
159
|
+
* @param {(b: string, stderr: string) => string} [opts.messages.fetchError]
|
|
160
|
+
* When provided, a non-zero `fetch` exit throws with this message; when
|
|
161
|
+
* omitted, the fetch exit status is not inspected.
|
|
162
|
+
*/
|
|
163
|
+
export function seedStoryBranchRef({
|
|
164
|
+
storyBranch,
|
|
165
|
+
baseRef,
|
|
166
|
+
swallowCreateRace = false,
|
|
167
|
+
spawn,
|
|
168
|
+
existsLocally,
|
|
169
|
+
existsRemotely,
|
|
170
|
+
progress = () => {},
|
|
171
|
+
messages,
|
|
172
|
+
}) {
|
|
173
|
+
const action = classifyBranchSeed({
|
|
174
|
+
localHas: existsLocally(storyBranch),
|
|
175
|
+
remoteHas: existsRemotely(storyBranch),
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
if (action === 'local') {
|
|
179
|
+
progress('GIT', messages.reuse(storyBranch));
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (action === 'fetch') {
|
|
184
|
+
progress('GIT', messages.fetch(storyBranch));
|
|
185
|
+
const r = spawn(['fetch', 'origin', `${storyBranch}:${storyBranch}`]);
|
|
186
|
+
if (messages.fetchError && r.status !== 0) {
|
|
187
|
+
throw new Error(
|
|
188
|
+
messages.fetchError(storyBranch, r.stderr || '(no stderr)'),
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// action === 'create'
|
|
195
|
+
progress('GIT', messages.create(storyBranch, baseRef));
|
|
196
|
+
const r = spawn(['branch', storyBranch, baseRef]);
|
|
197
|
+
if (r.status !== 0) {
|
|
198
|
+
const stderr = r.stderr || r.stdout || '';
|
|
199
|
+
if (swallowCreateRace && /already exists/i.test(stderr)) {
|
|
200
|
+
progress('GIT', messages.createRace(storyBranch));
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
throw new Error(messages.createError(storyBranch, baseRef, stderr));
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
117
207
|
/**
|
|
118
208
|
* Ensure an Epic branch exists and is published to `origin`. Handles all
|
|
119
209
|
* four states of the (local, remote) matrix.
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* auto-merge-cwd.js — resolve a worktree-collision-safe cwd for arming
|
|
3
|
+
* GitHub native auto-merge (Story #4282).
|
|
4
|
+
*
|
|
5
|
+
* Root cause this module exists to defeat:
|
|
6
|
+
* Arming auto-merge runs, in effect,
|
|
7
|
+
* `gh pr merge <pr> --auto --squash --delete-branch`. The
|
|
8
|
+
* `--delete-branch` flag makes `gh` shell out to local `git` to leave
|
|
9
|
+
* and delete the PR head branch — including a `git checkout <base>` to
|
|
10
|
+
* switch the working tree off the head branch. When the arm runs from a
|
|
11
|
+
* per-Story worktree cwd (checked out on the head branch `story-<id>`)
|
|
12
|
+
* while the base branch (`main`) is already occupied by the primary
|
|
13
|
+
* worktree, `gh`'s internal `git checkout <base>` collides:
|
|
14
|
+
*
|
|
15
|
+
* fatal: '<base>' is already used by worktree at '<primary>'
|
|
16
|
+
*
|
|
17
|
+
* The arm fails (non-fatally), defeating the unattended auto-merge
|
|
18
|
+
* contract — the operator must re-run the merge manually from a clean cwd.
|
|
19
|
+
*
|
|
20
|
+
* Fix (advisory direction #1 from the Story — "ensure the cwd is already
|
|
21
|
+
* on the base branch so gh's `git checkout <base>` is a no-op"):
|
|
22
|
+
* Re-point the arm at the **primary worktree root** — the working tree
|
|
23
|
+
* that holds the base branch — discovered via `git worktree list
|
|
24
|
+
* --porcelain`. From the primary worktree, `gh`'s `--delete-branch`
|
|
25
|
+
* cleanup never has to `git checkout <base>` (it is already there), so
|
|
26
|
+
* the collision cannot occur. `--delete-branch` is preserved verbatim,
|
|
27
|
+
* so the PR head branch is still removed on merge with no dependency on
|
|
28
|
+
* the consumer's repo-level "auto-delete head branches" toggle.
|
|
29
|
+
*
|
|
30
|
+
* Non-fatal by construction: any failure to resolve the primary worktree
|
|
31
|
+
* (not a git repo, `git` missing, single-worktree layout, parse failure)
|
|
32
|
+
* degrades to returning the original `cwd` unchanged. Worst case is the
|
|
33
|
+
* pre-fix behaviour; this helper never throws and never blocks arming.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import { gitSpawn as defaultGitSpawn } from '../git-utils.js';
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Parse `git worktree list --porcelain` output into structured records.
|
|
40
|
+
*
|
|
41
|
+
* The porcelain format emits one stanza per worktree, blank-line
|
|
42
|
+
* separated, e.g.:
|
|
43
|
+
*
|
|
44
|
+
* worktree /abs/path/to/primary
|
|
45
|
+
* HEAD <sha>
|
|
46
|
+
* branch refs/heads/main
|
|
47
|
+
*
|
|
48
|
+
* worktree /abs/path/to/.worktrees/story-4282
|
|
49
|
+
* HEAD <sha>
|
|
50
|
+
* branch refs/heads/story-4282
|
|
51
|
+
*
|
|
52
|
+
* A linked worktree with a detached HEAD emits `detached` instead of a
|
|
53
|
+
* `branch` line. Pure — exported for tests.
|
|
54
|
+
*
|
|
55
|
+
* @param {string} stdout
|
|
56
|
+
* @returns {Array<{ path: string, branch: string|null }>}
|
|
57
|
+
*/
|
|
58
|
+
export function parseWorktreeList(stdout) {
|
|
59
|
+
const text = String(stdout ?? '');
|
|
60
|
+
const records = [];
|
|
61
|
+
let current = null;
|
|
62
|
+
for (const rawLine of text.split('\n')) {
|
|
63
|
+
const line = rawLine.replace(/\r$/, '');
|
|
64
|
+
if (line.startsWith('worktree ')) {
|
|
65
|
+
if (current) records.push(current);
|
|
66
|
+
current = { path: line.slice('worktree '.length).trim(), branch: null };
|
|
67
|
+
} else if (line.startsWith('branch ') && current) {
|
|
68
|
+
current.branch = line
|
|
69
|
+
.slice('branch '.length)
|
|
70
|
+
.trim()
|
|
71
|
+
.replace(/^refs\/heads\//, '');
|
|
72
|
+
}
|
|
73
|
+
// `HEAD <sha>`, `detached`, `bare`, `locked`, `prunable` lines carry
|
|
74
|
+
// no field we need; ignored.
|
|
75
|
+
}
|
|
76
|
+
if (current) records.push(current);
|
|
77
|
+
return records;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Pick the primary worktree from a parsed worktree list. The primary
|
|
82
|
+
* worktree is the first stanza `git worktree list` emits — it is the
|
|
83
|
+
* original (non-linked) working tree and, during delivery, the one that
|
|
84
|
+
* holds the base branch. Returns its absolute path, or `null` when the
|
|
85
|
+
* list is empty / unparseable.
|
|
86
|
+
*
|
|
87
|
+
* Pure — exported for tests.
|
|
88
|
+
*
|
|
89
|
+
* @param {Array<{ path: string, branch: string|null }>} records
|
|
90
|
+
* @returns {string|null}
|
|
91
|
+
*/
|
|
92
|
+
export function pickPrimaryWorktreePath(records) {
|
|
93
|
+
if (!Array.isArray(records) || records.length === 0) return null;
|
|
94
|
+
const first = records[0];
|
|
95
|
+
return first && typeof first.path === 'string' && first.path.length > 0
|
|
96
|
+
? first.path
|
|
97
|
+
: null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Resolve a worktree-collision-safe cwd for arming auto-merge.
|
|
102
|
+
*
|
|
103
|
+
* Returns the primary worktree root (which holds the base branch) when it
|
|
104
|
+
* can be discovered AND it differs from `cwd`; otherwise returns `cwd`
|
|
105
|
+
* unchanged. Never throws.
|
|
106
|
+
*
|
|
107
|
+
* @param {string} cwd — the cwd the caller would otherwise arm from
|
|
108
|
+
* (often a per-Story worktree on the head branch).
|
|
109
|
+
* @param {{ gitSpawn?: typeof import('../git-utils.js').gitSpawn }} [deps]
|
|
110
|
+
* @returns {string} a cwd safe to run `gh pr merge --delete-branch` from.
|
|
111
|
+
*/
|
|
112
|
+
export function resolveAutoMergeArmCwd(
|
|
113
|
+
cwd,
|
|
114
|
+
{ gitSpawn = defaultGitSpawn } = {},
|
|
115
|
+
) {
|
|
116
|
+
if (typeof cwd !== 'string' || cwd.length === 0) return cwd;
|
|
117
|
+
try {
|
|
118
|
+
const result = gitSpawn(cwd, 'worktree', 'list', '--porcelain');
|
|
119
|
+
if (!result || result.status !== 0) return cwd;
|
|
120
|
+
const primary = pickPrimaryWorktreePath(parseWorktreeList(result.stdout));
|
|
121
|
+
if (!primary) return cwd;
|
|
122
|
+
return primary;
|
|
123
|
+
} catch {
|
|
124
|
+
// Any unexpected failure (git missing, non-repo cwd, etc.) degrades
|
|
125
|
+
// to the original cwd — arming stays best-effort.
|
|
126
|
+
return cwd;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
@@ -35,6 +35,11 @@
|
|
|
35
35
|
*/
|
|
36
36
|
|
|
37
37
|
import { AGENT_LABELS } from '../label-constants.js';
|
|
38
|
+
import {
|
|
39
|
+
invalidateProjectMetaCache,
|
|
40
|
+
readProjectMetaCache,
|
|
41
|
+
writeProjectMetaCache,
|
|
42
|
+
} from './project-meta-cache.js';
|
|
38
43
|
import { resolveProjectMeta } from './project-meta-resolver.js';
|
|
39
44
|
|
|
40
45
|
export const LABEL_TO_COLUMN = Object.freeze({
|
|
@@ -74,6 +79,7 @@ export class ColumnSync {
|
|
|
74
79
|
* projectNumber?: number | null,
|
|
75
80
|
* projectOwner?: string | null,
|
|
76
81
|
* logger?: { info: Function, warn: Function },
|
|
82
|
+
* config?: object,
|
|
77
83
|
* ctx?: { provider?: object, config?: { github?: { projectNumber?: number|null } }, logger?: object },
|
|
78
84
|
* }} opts
|
|
79
85
|
*/
|
|
@@ -89,7 +95,16 @@ export class ColumnSync {
|
|
|
89
95
|
null;
|
|
90
96
|
this.projectOwner = opts.projectOwner ?? provider.projectOwner ?? null;
|
|
91
97
|
this.logger = opts.logger ?? ctx?.logger ?? console;
|
|
98
|
+
// Resolved config bag used to locate the on-disk meta cache's tempRoot.
|
|
99
|
+
// Optional — when omitted, the cache resolves the framework-default
|
|
100
|
+
// `temp` root (Story #4252).
|
|
101
|
+
this.config = opts.config ?? ctx?.config ?? undefined;
|
|
92
102
|
this._meta = null; // lazy-cached { projectId, fieldId, options: Map<name, id> }
|
|
103
|
+
// Records whether the in-process `_meta` was hydrated from the on-disk
|
|
104
|
+
// cache, so a GraphQL error against possibly-stale cached metadata can
|
|
105
|
+
// invalidate the disk entry and force a fresh resolve on the next flip
|
|
106
|
+
// (Story #4252).
|
|
107
|
+
this._metaFromDiskCache = false;
|
|
93
108
|
}
|
|
94
109
|
|
|
95
110
|
/**
|
|
@@ -117,8 +132,9 @@ export class ColumnSync {
|
|
|
117
132
|
const itemId = await this.#getProjectItemId(issueId, meta.projectId);
|
|
118
133
|
if (!itemId) return { status: 'skipped', reason: 'not-on-project' };
|
|
119
134
|
|
|
120
|
-
|
|
121
|
-
|
|
135
|
+
try {
|
|
136
|
+
await this.provider.graphql(
|
|
137
|
+
`
|
|
122
138
|
mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) {
|
|
123
139
|
updateProjectV2ItemFieldValue(
|
|
124
140
|
input: {
|
|
@@ -129,18 +145,71 @@ export class ColumnSync {
|
|
|
129
145
|
}
|
|
130
146
|
) { projectV2Item { id } }
|
|
131
147
|
}`,
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
148
|
+
{
|
|
149
|
+
projectId: meta.projectId,
|
|
150
|
+
itemId,
|
|
151
|
+
fieldId: meta.fieldId,
|
|
152
|
+
optionId,
|
|
153
|
+
},
|
|
154
|
+
);
|
|
155
|
+
} catch (err) {
|
|
156
|
+
// A failed mutation against metadata that came from the disk cache
|
|
157
|
+
// most likely means the board was reconfigured since the entry was
|
|
158
|
+
// written (a stale projectId / fieldId / optionId). Invalidate the
|
|
159
|
+
// disk entry so the next flip re-resolves against the live board and
|
|
160
|
+
// self-heals (Story #4252). Re-throw so the caller's existing error
|
|
161
|
+
// handling (e.g. `syncProjectStatusColumn`'s warn) is preserved.
|
|
162
|
+
this.#invalidateMetaCache();
|
|
163
|
+
throw err;
|
|
164
|
+
}
|
|
139
165
|
return { status: 'synced', column };
|
|
140
166
|
}
|
|
141
167
|
|
|
168
|
+
/**
|
|
169
|
+
* The `(owner, projectNumber)` pair the disk cache is keyed by. Mirrors
|
|
170
|
+
* the owner that `#loadMeta` resolves the board against so a cache hit and
|
|
171
|
+
* a live resolve agree on the same board identity.
|
|
172
|
+
*
|
|
173
|
+
* @returns {{ owner: string|null, projectNumber: number|null }}
|
|
174
|
+
*/
|
|
175
|
+
get #cacheBoard() {
|
|
176
|
+
return {
|
|
177
|
+
owner: this.projectOwner ?? this.provider.owner ?? null,
|
|
178
|
+
projectNumber: this.projectNumber,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Invalidate the on-disk metadata cache entry for this board and drop the
|
|
184
|
+
* in-process copy, so the next `#loadMeta` re-resolves from the live
|
|
185
|
+
* board. Only fires when the current `_meta` came from the disk cache —
|
|
186
|
+
* a freshly-resolved entry that fails the mutation is a transient/live
|
|
187
|
+
* problem, not a stale-cache problem.
|
|
188
|
+
*/
|
|
189
|
+
#invalidateMetaCache() {
|
|
190
|
+
if (!this._metaFromDiskCache) return;
|
|
191
|
+
const { owner, projectNumber } = this.#cacheBoard;
|
|
192
|
+
invalidateProjectMetaCache({ owner, projectNumber, config: this.config });
|
|
193
|
+
this._meta = null;
|
|
194
|
+
this._metaFromDiskCache = false;
|
|
195
|
+
}
|
|
196
|
+
|
|
142
197
|
async #loadMeta() {
|
|
143
198
|
if (this._meta !== null) return this._meta || null;
|
|
199
|
+
// Disk cache hit short-circuits the ~2 metadata GraphQL round-trips
|
|
200
|
+
// (resolveProjectMeta) — repo-invariant board metadata persists across
|
|
201
|
+
// the cold CLI processes of a single-story delivery (Story #4252).
|
|
202
|
+
const cachedBoard = this.#cacheBoard;
|
|
203
|
+
const cached = readProjectMetaCache({
|
|
204
|
+
owner: cachedBoard.owner,
|
|
205
|
+
projectNumber: cachedBoard.projectNumber,
|
|
206
|
+
config: this.config,
|
|
207
|
+
});
|
|
208
|
+
if (cached) {
|
|
209
|
+
this._meta = cached;
|
|
210
|
+
this._metaFromDiskCache = true;
|
|
211
|
+
return this._meta;
|
|
212
|
+
}
|
|
144
213
|
try {
|
|
145
214
|
// Resolve the board by walking the owner-type ladder
|
|
146
215
|
// (organization → user → viewer) via the shared resolver so the
|
|
@@ -176,6 +245,16 @@ export class ColumnSync {
|
|
|
176
245
|
fieldId: field.id,
|
|
177
246
|
options,
|
|
178
247
|
};
|
|
248
|
+
// Persist the freshly-resolved, repo-invariant metadata so the next
|
|
249
|
+
// cold flip reads it from disk instead of re-paying the resolve
|
|
250
|
+
// (Story #4252). Best-effort: a write failure never blocks the sync.
|
|
251
|
+
writeProjectMetaCache({
|
|
252
|
+
owner: cachedBoard.owner,
|
|
253
|
+
projectNumber: cachedBoard.projectNumber,
|
|
254
|
+
meta: this._meta,
|
|
255
|
+
config: this.config,
|
|
256
|
+
});
|
|
257
|
+
this._metaFromDiskCache = false;
|
|
179
258
|
return this._meta;
|
|
180
259
|
} catch (err) {
|
|
181
260
|
this.logger.warn?.(
|
|
@@ -54,6 +54,8 @@
|
|
|
54
54
|
|
|
55
55
|
import { spawnSync } from 'node:child_process';
|
|
56
56
|
|
|
57
|
+
import { resolveAutoMergeArmCwd } from '../../auto-merge-cwd.js';
|
|
58
|
+
|
|
57
59
|
/**
|
|
58
60
|
* Default `gh pr view --json autoMergeRequest` probe. Pure-spawn helper
|
|
59
61
|
* — exported so tests can stub the shell-out without touching the
|
|
@@ -77,12 +79,28 @@ export function ghPrViewAutoMerge({ prUrl, cwd, spawnFn = spawnSync }) {
|
|
|
77
79
|
* helper. Exported so tests can stub. The arg list is captured in a
|
|
78
80
|
* single helper so the merge-lockout lint allow-list narrows to one
|
|
79
81
|
* literal site.
|
|
82
|
+
*
|
|
83
|
+
* Story #4282: `--delete-branch` makes `gh` shell out to local `git`
|
|
84
|
+
* (including a `git checkout <base>`). When this arm runs from a per-Story
|
|
85
|
+
* worktree cwd checked out on the head branch while the base branch is
|
|
86
|
+
* occupied by the primary worktree, that checkout collides
|
|
87
|
+
* (`fatal: '<base>' is already used by worktree`). We re-point the spawn
|
|
88
|
+
* cwd at the primary worktree root (which holds the base branch) via
|
|
89
|
+
* `resolveAutoMergeArmCwd`, so the local checkout is a no-op while
|
|
90
|
+
* `--delete-branch` (head-branch-removed-on-merge) is preserved. The
|
|
91
|
+
* resolver is non-fatal — it degrades to the original cwd.
|
|
80
92
|
*/
|
|
81
|
-
export function ghPrMergeAuto({
|
|
93
|
+
export function ghPrMergeAuto({
|
|
94
|
+
prUrl,
|
|
95
|
+
cwd,
|
|
96
|
+
spawnFn = spawnSync,
|
|
97
|
+
resolveArmCwd = resolveAutoMergeArmCwd,
|
|
98
|
+
}) {
|
|
99
|
+
const armCwd = resolveArmCwd(cwd);
|
|
82
100
|
const result = spawnFn(
|
|
83
101
|
'gh',
|
|
84
102
|
['pr', 'merge', prUrl, '--auto', '--squash', '--delete-branch'],
|
|
85
|
-
{ cwd, encoding: 'utf-8', shell: false },
|
|
103
|
+
{ cwd: armCwd, encoding: 'utf-8', shell: false },
|
|
86
104
|
);
|
|
87
105
|
return {
|
|
88
106
|
status: result.status ?? 1,
|