@magnusekdahl/parallix 1.1.0 → 1.2.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/config/integration-pipelines.json +9 -0
- package/config/workflow.config.schema.json +12 -0
- package/docs/adr/0032-mission-refinement-state-and-usage-budget-signals.md +10 -8
- package/docs/adr/0036-mission-sizing-and-dependency-wave-heuristics.md +14 -12
- package/docs/adr/0041-integration-pipeline-gates.md +1 -1
- package/docs/adr/0047-per-mission-change-size-budget.md +161 -0
- package/docs/adr/index.md +1 -0
- package/docs/use-cases.md +44 -0
- package/lib/agents/agents.js +149 -100
- package/lib/agents/claude-telemetry.js +14 -10
- package/lib/agents/claude.js +32 -11
- package/lib/agents/codex-telemetry.js +32 -19
- package/lib/agents/codex.js +36 -11
- package/lib/agents/limit-hit.js +42 -25
- package/lib/agents/mistral-telemetry.js +1 -1
- package/lib/agents/mistral.js +13 -3
- package/lib/agents/opencode-export.js +11 -5
- package/lib/agents/opencode-telemetry.js +75 -90
- package/lib/agents/opencode.js +86 -29
- package/lib/agents/stage-telemetry.js +3 -7
- package/lib/commands/active.js +139 -94
- package/lib/commands/checkpoint.js +3 -1
- package/lib/commands/config.js +5 -3
- package/lib/commands/coverage-gate.js +17 -10
- package/lib/commands/diff.js +11 -5
- package/lib/commands/draft.js +95 -55
- package/lib/commands/handoff.js +253 -65
- package/lib/commands/integrate.js +288 -188
- package/lib/commands/mission-start.js +39 -34
- package/lib/commands/rebase.js +32 -21
- package/lib/commands/repair-handoff.js +21 -18
- package/lib/commands/resolve-conflict.js +5 -2
- package/lib/commands/review.js +1 -0
- package/lib/commands/setup-review.js +1 -0
- package/lib/commands/stats-backfill.js +50 -41
- package/lib/commands/stats.js +653 -204
- package/lib/commands/status.js +33 -28
- package/lib/core/fmt.js +58 -15
- package/lib/core/git.js +7 -5
- package/lib/core/gitignore.js +6 -4
- package/lib/core/mission-utils.js +161 -88
- package/lib/core/nels.js +199 -0
- package/lib/core/persistent-data-migration.js +55 -23
- package/lib/core/product-config.js +44 -23
- package/lib/core/runtime-matrix.js +15 -2
- package/lib/core/spawn-tee.js +40 -13
- package/lib/core/state-map.js +32 -13
- package/lib/core/storage.js +19 -5
- package/lib/core/subagent-limit.js +28 -0
- package/lib/core/verification.js +29 -12
- package/lib/review/rebase.js +11 -3
- package/lib/review/review-adapter.js +93 -6
- package/lib/review/review-artifacts.js +89 -23
- package/lib/review/review-commands.js +139 -45
- package/lib/review/review-events.js +103 -52
- package/lib/review/review-loop.js +117 -87
- package/lib/review/review-polling.js +25 -5
- package/lib/review/review-prompts.js +36 -5
- package/lib/review/review-state.js +39 -15
- package/lib/tools/backlog.js +162 -71
- package/lib/tools/forgejo.js +290 -129
- package/lib/tools/gatekeeper.js +15 -0
- package/lib/tools/redgreen.js +220 -0
- package/lib/tools/sessions.js +12 -5
- package/lib/tools/setup-review.js +170 -70
- package/package.json +11 -3
- package/prompts/draft.md +6 -0
- package/templates/mission-scaffold.md +1 -1
|
@@ -7,9 +7,9 @@ const { resolveTaskFile, getTaskStatus, setTaskStatus, completeTask, getTaskAssi
|
|
|
7
7
|
const { toVirtual, toActual } = require('../core/state-map');
|
|
8
8
|
const { getPrStatus, getLatestReviewDecision, syncMerged, readToken, resolveTokenFile, resolveForgejoUser, resolveForgejoHome, isForgejoPath, listOpenPrsForSlug } = require('../tools/forgejo');
|
|
9
9
|
const fmt = require('../core/fmt');
|
|
10
|
-
|
|
10
|
+
|
|
11
11
|
const { buildAutonomousReviewMatrix, formatMatrixSummary } = require('../core/runtime-matrix');
|
|
12
|
-
const { findMissionDir, resolveWorktree, findMissionArea, missionTitle, parseConflictFilesFromMergeOutput, getConflictFiles, inferSlug, updateGraphifyKnowledgeGraph,
|
|
12
|
+
const { findMissionDir, resolveWorktree, findMissionArea, missionTitle, parseConflictFilesFromMergeOutput, getConflictFiles, inferSlug, updateGraphifyKnowledgeGraph, getPrimaryWorktree, getPrimaryBranch, conventionalWorktreePath, softResetTrailingBacklogNoise, findMissionDocInBranches, missionBranchName, missionDirForSlug, isMissionArtifact, resolveMissionBaseBranch, resolveBaseWorktree } = require('../core/mission-utils');
|
|
13
13
|
const stats = require('./stats');
|
|
14
14
|
const verification = require('../core/verification');
|
|
15
15
|
const { formatVerificationCommand } = verification;
|
|
@@ -18,6 +18,7 @@ const { readReviewState } = require('../review/review-state');
|
|
|
18
18
|
|
|
19
19
|
const VARIANT_B_AUTOMATION_SUMMARY = 'Variant B automation: Backlog task closeout, worktree-path rewrite, squash commit with hook-enforced validation, Forgejo sync-merged, and mission worktree cleanup.';
|
|
20
20
|
|
|
21
|
+
/** @type{{symptom: string, cause: string, fix: string}[]} */
|
|
21
22
|
const SYNC_MERGED_DIAGNOSTICS = [
|
|
22
23
|
{ symptom: 'sync-merged reports generic failure; PR still open', cause: 'allow_manual_merge disabled or drifted off in Forgejo settings', fix: 'Enable "Allow manual merge" in Forgejo PR settings and retry.' },
|
|
23
24
|
{ symptom: 'POST /pulls/N/merge returns 405 or 500', cause: 'Forgejo API conflict or stale PR state', fix: 'px review <slug> to check if actually merged.' },
|
|
@@ -26,28 +27,34 @@ const SYNC_MERGED_DIAGNOSTICS = [
|
|
|
26
27
|
{ symptom: 'PR marked merged but remote branch still exists', cause: 'Remote branch deletion failed (permissions or network)', fix: 'px review <slug> --close (to trigger cleanup; identity resolves from review-state).' }
|
|
27
28
|
];
|
|
28
29
|
|
|
30
|
+
/** @extends{Error} */
|
|
29
31
|
class IntegrationAbort extends Error {}
|
|
30
32
|
|
|
33
|
+
/** @param {{mission: string, implementer: string, pr_fix_rounds: string, classification: string, date: string}} row */
|
|
31
34
|
function formatRecordedStatsRow(row) {
|
|
32
35
|
return `${row.mission}: implementer=${row.implementer}, pr_fix_rounds=${row.pr_fix_rounds}, classification=${row.classification}, date=${row.date}`;
|
|
33
36
|
}
|
|
34
37
|
|
|
38
|
+
/** @param {string} value */
|
|
35
39
|
function shellQuote(value) {
|
|
36
40
|
return `"${String(value).replace(/(["\\$`])/g, '\\$1')}"`;
|
|
37
41
|
}
|
|
38
42
|
|
|
39
|
-
|
|
43
|
+
/** @param {string} rootDir @param {{commandRunner?: Function, log?: Function}} opts */
|
|
44
|
+
function maybeUpdateGraphifyOnPrimary(rootDir = getPrimaryWorktree(), opts = {}) {
|
|
40
45
|
return updateGraphifyKnowledgeGraph({
|
|
41
46
|
rootDir,
|
|
42
|
-
commandRunner: commandRunner || ((command, args, options) => require('../core/git').run(command, args, options)),
|
|
43
|
-
log,
|
|
47
|
+
commandRunner: opts.commandRunner || ((/** @type{string} */ command, /** @type{string[]} */ args, /** @type{object} */ options) => require('../core/git').run(command, args, options)),
|
|
48
|
+
log: opts.log,
|
|
44
49
|
startMessage: `Updating graphify knowledge graph on ${getPrimaryBranch()}...`,
|
|
45
50
|
failureHint: 'Continuing without blocking integration.'
|
|
46
51
|
});
|
|
47
52
|
}
|
|
48
53
|
|
|
49
|
-
|
|
50
|
-
|
|
54
|
+
/** @param {string} rootDir @param {{gitRunner?: Function}} opts */
|
|
55
|
+
function prepareNoisePatchForSquash(rootDir, opts = {}) {
|
|
56
|
+
const runner = opts.gitRunner || git;
|
|
57
|
+
const diffResult = runner(['-C', rootDir, 'diff', '--cached', '--binary']);
|
|
51
58
|
if (diffResult.status !== 0) {
|
|
52
59
|
return {
|
|
53
60
|
ok: false,
|
|
@@ -64,7 +71,7 @@ function prepareNoisePatchForSquash(rootDir, { gitRunner = git } = {}) {
|
|
|
64
71
|
const patchPath = path.join(patchDir, 'backlog-noise.patch');
|
|
65
72
|
fs.writeFileSync(patchPath, patch, 'utf8');
|
|
66
73
|
|
|
67
|
-
const resetResult =
|
|
74
|
+
const resetResult = runner(['-C', rootDir, 'reset', '--hard', 'HEAD']);
|
|
68
75
|
if (resetResult.status !== 0) {
|
|
69
76
|
return {
|
|
70
77
|
ok: false,
|
|
@@ -80,9 +87,11 @@ function prepareNoisePatchForSquash(rootDir, { gitRunner = git } = {}) {
|
|
|
80
87
|
};
|
|
81
88
|
}
|
|
82
89
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
90
|
+
/** @param {string} rootDir @param {string|null} patchPath @param {{gitRunner?: Function}} opts */
|
|
91
|
+
function restoreNoisePatchAfterSquash(rootDir, patchPath, opts = {}) {
|
|
92
|
+
if (!patchPath) {return { ok: true };}
|
|
93
|
+
const runner = opts.gitRunner || git;
|
|
94
|
+
const applyResult = runner(['-C', rootDir, 'apply', '--index', patchPath]);
|
|
86
95
|
if (applyResult.status !== 0) {
|
|
87
96
|
return {
|
|
88
97
|
ok: false,
|
|
@@ -92,6 +101,7 @@ function restoreNoisePatchAfterSquash(rootDir, patchPath, { gitRunner = git } =
|
|
|
92
101
|
return { ok: true };
|
|
93
102
|
}
|
|
94
103
|
|
|
104
|
+
/** @param {string|null} taskAssignee */
|
|
95
105
|
function resolveForgejoUserForIntegration(taskAssignee) {
|
|
96
106
|
// If taskAssignee is set, use it directly as the Forgejo user identity.
|
|
97
107
|
// This ensures that even non-agent assignees (e.g., human users) have their
|
|
@@ -105,13 +115,15 @@ function resolveForgejoUserForIntegration(taskAssignee) {
|
|
|
105
115
|
|
|
106
116
|
// Fall back to environment variable or default only when no assignee is available
|
|
107
117
|
return {
|
|
108
|
-
forgejoUser: resolveForgejoUser(),
|
|
118
|
+
forgejoUser: resolveForgejoUser(""),
|
|
109
119
|
warning: null
|
|
110
120
|
};
|
|
111
121
|
}
|
|
112
122
|
|
|
113
|
-
|
|
114
|
-
|
|
123
|
+
/** @param {string} rootDir @param {{gitRunner?: Function}} opts */
|
|
124
|
+
function getUnresolvedIndexConflicts(rootDir = getPrimaryWorktree(), opts = {}) {
|
|
125
|
+
const runner = opts.gitRunner || git;
|
|
126
|
+
const result = runner(['-C', rootDir, 'ls-files', '-u']);
|
|
115
127
|
if (result.status !== 0) {
|
|
116
128
|
return {
|
|
117
129
|
ok: false,
|
|
@@ -123,9 +135,9 @@ function getUnresolvedIndexConflicts(rootDir = getPrimaryWorktree(), { gitRunner
|
|
|
123
135
|
const files = Array.from(new Set(
|
|
124
136
|
result.stdout
|
|
125
137
|
.split('\n')
|
|
126
|
-
.map(line => line.trim())
|
|
138
|
+
.map((/** @type{string} */ line) => line.trim())
|
|
127
139
|
.filter(Boolean)
|
|
128
|
-
.map(line => line.split('\t')[1])
|
|
140
|
+
.map((/** @type{string} */ line) => line.split('\t')[1])
|
|
129
141
|
.filter(Boolean)
|
|
130
142
|
));
|
|
131
143
|
|
|
@@ -143,20 +155,19 @@ function parseStashPopCollisionFiles(output = '') {
|
|
|
143
155
|
.map(line => line.replace(/\s+already exists, no checkout$/i, ''));
|
|
144
156
|
}
|
|
145
157
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
getUnresolvedIndexConflictsFn = targetRootDir => getUnresolvedIndexConflicts(targetRootDir, { gitRunner })
|
|
153
|
-
} = {}
|
|
154
|
-
) {
|
|
158
|
+
/**
|
|
159
|
+
* @param {string} slug
|
|
160
|
+
* @param {{stdout: string, stderr: string, status: number}} restoreResult
|
|
161
|
+
* @param {{rootDir?: string, gitRunner?: Function, getUnresolvedIndexConflictsFn?: Function}} opts
|
|
162
|
+
*/
|
|
163
|
+
function reportStashPopFailure(slug, restoreResult, opts = {}) {
|
|
155
164
|
const output = [restoreResult.stdout, restoreResult.stderr].filter(Boolean).join('\n').trim();
|
|
156
|
-
const
|
|
165
|
+
const runner = opts.gitRunner || git;
|
|
166
|
+
const rootDir = opts.rootDir || getPrimaryWorktree();
|
|
167
|
+
const headResult = runner(['-C', rootDir, 'log', '-1', '--oneline']);
|
|
157
168
|
const headLine = headResult.status === 0 ? headResult.stdout.trim() : '(unavailable)';
|
|
158
169
|
const integrationLanded = headLine.includes(`${missionBranchName(slug, rootDir)}:`);
|
|
159
|
-
const indexConflicts = getUnresolvedIndexConflictsFn(rootDir);
|
|
170
|
+
const indexConflicts = (opts.getUnresolvedIndexConflictsFn || getUnresolvedIndexConflicts)(rootDir);
|
|
160
171
|
const collisionFiles = parseStashPopCollisionFiles(output);
|
|
161
172
|
|
|
162
173
|
fmt.log.fail('Could not restore the temporarily stashed local integration checkout changes.');
|
|
@@ -168,10 +179,10 @@ function reportStashPopFailure(
|
|
|
168
179
|
|
|
169
180
|
if (indexConflicts.ok && indexConflicts.files.length > 0) {
|
|
170
181
|
fmt.log.fail('Stash restore failure type: merge-conflict');
|
|
171
|
-
indexConflicts.files.forEach(file => fmt.log.fail(` - ${file}`));
|
|
182
|
+
indexConflicts.files.forEach((/** @type{string} */ file) => fmt.log.fail(` - ${file}`));
|
|
172
183
|
fmt.log.fail('Recovery steps:');
|
|
173
184
|
fmt.log.fail(` 1. cd ${rootDir}`);
|
|
174
|
-
indexConflicts.files.forEach(file => {
|
|
185
|
+
indexConflicts.files.forEach((/** @type{string} */ file) => {
|
|
175
186
|
fmt.log.fail(` 2. Resolve ${file}, then run git add "${file}" or git rm "${file}"`);
|
|
176
187
|
});
|
|
177
188
|
fmt.log.fail(' 3. git status --short');
|
|
@@ -179,12 +190,12 @@ function reportStashPopFailure(
|
|
|
179
190
|
} else {
|
|
180
191
|
fmt.log.fail('Stash restore failure type: file-collision');
|
|
181
192
|
if (collisionFiles.length > 0) {
|
|
182
|
-
collisionFiles.forEach(file => fmt.log.fail(` - ${file}`));
|
|
193
|
+
collisionFiles.forEach((/** @type{string} */ file) => fmt.log.fail(` - ${file}`));
|
|
183
194
|
}
|
|
184
195
|
fmt.log.fail('Recovery steps:');
|
|
185
196
|
fmt.log.fail(` 1. git -C ${rootDir} stash show --name-only stash@{0}`);
|
|
186
197
|
if (collisionFiles.length > 0) {
|
|
187
|
-
collisionFiles.forEach(file => {
|
|
198
|
+
collisionFiles.forEach((/** @type{string} */ file) => {
|
|
188
199
|
fmt.log.fail(` 2. mv ${path.join(rootDir, file)} ${path.join(rootDir, `${file}.pre-stash-pop`)}`);
|
|
189
200
|
});
|
|
190
201
|
fmt.log.fail(` 3. git -C ${rootDir} stash pop`);
|
|
@@ -210,6 +221,7 @@ function printDiagnosticTable() {
|
|
|
210
221
|
fmt.log.info('------------------------------------\n');
|
|
211
222
|
}
|
|
212
223
|
|
|
224
|
+
/** @param {{statusCode?: number, error: string, raw?: string}} syncResult */
|
|
213
225
|
function reportSyncMergedFailure(syncResult) {
|
|
214
226
|
const detail = syncResult.statusCode ? ` (${syncResult.error}: ${syncResult.statusCode})` : ` (${syncResult.error})`;
|
|
215
227
|
fmt.log.fail(`Forgejo sync-merged failed${detail}.`);
|
|
@@ -227,18 +239,20 @@ const INTEGRATION_CONFIG_PATH = path.join(getPrimaryWorktree(), 'config', 'integ
|
|
|
227
239
|
* Detect which top-level areas have been modified in the mission branch vs primary branch
|
|
228
240
|
* Returns an array of area names (e.g., ['server', 'auth-server', 'web-client'])
|
|
229
241
|
*/
|
|
230
|
-
|
|
231
|
-
|
|
242
|
+
/** @param {string} slug @param {{gitRunner?: Function, rootDir?: string, baseBranch?: string|null}} opts */
|
|
243
|
+
function detectChangedAreas(slug, opts = {}) {
|
|
244
|
+
const primaryBranch = opts.baseBranch || getPrimaryBranch();
|
|
232
245
|
const branch = `mission/${slug}`;
|
|
246
|
+
const runner = opts.gitRunner || git;
|
|
233
247
|
|
|
234
248
|
// Get changed files between primary branch and mission branch
|
|
235
|
-
const diffResult =
|
|
249
|
+
const diffResult = runner(['-C', opts.rootDir || getPrimaryWorktree(), 'diff', '--name-only', primaryBranch, branch, '--']);
|
|
236
250
|
|
|
237
251
|
if (diffResult.status !== 0 && !diffResult.stdout.trim()) {
|
|
238
252
|
// No changes or error - fall back to checking against HEAD in the worktree
|
|
239
|
-
const worktree = resolveWorktree(slug) || conventionalWorktreePath(slug, rootDir);
|
|
253
|
+
const worktree = resolveWorktree(slug) || conventionalWorktreePath(slug, opts.rootDir);
|
|
240
254
|
if (fs.existsSync(worktree)) {
|
|
241
|
-
const worktreeDiff =
|
|
255
|
+
const worktreeDiff = runner(['-C', worktree, 'diff', '--name-only', primaryBranch, 'HEAD', '--']);
|
|
242
256
|
if (worktreeDiff.status === 0 && worktreeDiff.stdout.trim()) {
|
|
243
257
|
return parseFilesToAreas(worktreeDiff.stdout);
|
|
244
258
|
}
|
|
@@ -252,13 +266,14 @@ function detectChangedAreas(slug, { gitRunner = git, rootDir = getPrimaryWorktre
|
|
|
252
266
|
/**
|
|
253
267
|
* Parse a list of files (one per line) and extract the top-level area directories
|
|
254
268
|
*/
|
|
269
|
+
/** @param {string} filesOutput */
|
|
255
270
|
function parseFilesToAreas(filesOutput) {
|
|
256
271
|
const areas = new Set();
|
|
257
|
-
const knownAreas = ['server', 'auth-server', 'web-client', 'docs', 'workflow', 'android', 'kubernetes'];
|
|
272
|
+
const knownAreas = ['lib', 'server', 'auth-server', 'web-client', 'docs', 'workflow', 'android', 'kubernetes'];
|
|
258
273
|
|
|
259
|
-
filesOutput.split('\n').forEach(file => {
|
|
274
|
+
filesOutput.split('\n').forEach((/** @type{string} */ file) => {
|
|
260
275
|
file = file.trim();
|
|
261
|
-
if (!file) return;
|
|
276
|
+
if (!file) {return;}
|
|
262
277
|
const topDir = file.split('/')[0];
|
|
263
278
|
if (knownAreas.includes(topDir)) {
|
|
264
279
|
areas.add(topDir);
|
|
@@ -271,7 +286,9 @@ function parseFilesToAreas(filesOutput) {
|
|
|
271
286
|
/**
|
|
272
287
|
* Load integration pipelines config from repo-side file
|
|
273
288
|
*/
|
|
274
|
-
|
|
289
|
+
/** @param {{configPath?: string}} opts */
|
|
290
|
+
function loadIntegrationConfig(opts = {}) {
|
|
291
|
+
const configPath = opts.configPath || INTEGRATION_CONFIG_PATH;
|
|
275
292
|
if (!fs.existsSync(configPath) || !fs.statSync(configPath).size) {
|
|
276
293
|
return { ok: false, error: 'no config present' };
|
|
277
294
|
}
|
|
@@ -280,7 +297,7 @@ function loadIntegrationConfig({ configPath = INTEGRATION_CONFIG_PATH } = {}) {
|
|
|
280
297
|
const content = fs.readFileSync(configPath, 'utf8');
|
|
281
298
|
const config = JSON.parse(content);
|
|
282
299
|
return { ok: true, config };
|
|
283
|
-
} catch (error) {
|
|
300
|
+
} catch (/** @type{any} */ error) {
|
|
284
301
|
return { ok: false, error: `invalid JSON: ${error.message}` };
|
|
285
302
|
}
|
|
286
303
|
}
|
|
@@ -289,11 +306,12 @@ function loadIntegrationConfig({ configPath = INTEGRATION_CONFIG_PATH } = {}) {
|
|
|
289
306
|
* Get the integration gate plan for changed areas
|
|
290
307
|
* Returns { gates: [{key, command, run_last}], changedAreas: [...] }
|
|
291
308
|
*/
|
|
292
|
-
|
|
309
|
+
/** @param {string} slug @param {{runIntegrationGates?: boolean, gitRunner?: Function, dryRun?: boolean, configPath?: string}} opts */
|
|
310
|
+
function getIntegrationGatePlan(slug, opts = {}) {
|
|
293
311
|
// Check config
|
|
294
|
-
const configResult = loadIntegrationConfig(
|
|
312
|
+
const configResult = loadIntegrationConfig(opts);
|
|
295
313
|
if (!configResult.ok) {
|
|
296
|
-
if (runIntegrationGates) {
|
|
314
|
+
if (opts.runIntegrationGates) {
|
|
297
315
|
fmt.log.info(`integration-gates: ${configResult.error}, skipping`);
|
|
298
316
|
}
|
|
299
317
|
return { gates: [], changedAreas: [], configError: configResult.error };
|
|
@@ -301,16 +319,16 @@ function getIntegrationGatePlan(slug, { runIntegrationGates = true, gitRunner =
|
|
|
301
319
|
|
|
302
320
|
const config = configResult.config;
|
|
303
321
|
if (!config.gates || Object.keys(config.gates).length === 0) {
|
|
304
|
-
if (runIntegrationGates) {
|
|
322
|
+
if (opts.runIntegrationGates) {
|
|
305
323
|
fmt.log.info('integration-gates: no gates defined in config, skipping');
|
|
306
324
|
}
|
|
307
325
|
return { gates: [], changedAreas: [], configError: 'no gates defined' };
|
|
308
326
|
}
|
|
309
327
|
|
|
310
328
|
// Detect changed areas (always detect for dry-run; for real run, detect only if runIntegrationGates)
|
|
311
|
-
const changedAreas = (runIntegrationGates || dryRun) ? detectChangedAreas(slug,
|
|
329
|
+
const changedAreas = (opts.runIntegrationGates || opts.dryRun) ? detectChangedAreas(slug, opts) : [];
|
|
312
330
|
|
|
313
|
-
if (runIntegrationGates && !dryRun && changedAreas.length === 0) {
|
|
331
|
+
if (opts.runIntegrationGates && !opts.dryRun && changedAreas.length === 0) {
|
|
314
332
|
fmt.log.info('integration-gates: no area changes detected, skipping');
|
|
315
333
|
return { gates: [], changedAreas: [], configError: null };
|
|
316
334
|
}
|
|
@@ -351,6 +369,7 @@ function getIntegrationGatePlan(slug, { runIntegrationGates = true, gitRunner =
|
|
|
351
369
|
/**
|
|
352
370
|
* Print the integration gate plan (for --dry-run)
|
|
353
371
|
*/
|
|
372
|
+
/** @param{{key: string, command: string, order: number, run_last: boolean}[]} gates */
|
|
354
373
|
function printIntegrationGatePlan(gates) {
|
|
355
374
|
fmt.log.info('Integration gate plan:');
|
|
356
375
|
for (const gate of gates) {
|
|
@@ -358,22 +377,21 @@ function printIntegrationGatePlan(gates) {
|
|
|
358
377
|
}
|
|
359
378
|
}
|
|
360
379
|
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
INTEGRATE_DRY_RUN: dryRun ? 'true' : 'false',
|
|
380
|
+
/**
|
|
381
|
+
* @param {string} slug
|
|
382
|
+
* @param {{dryRun?: boolean, processEnv?: NodeJS.ProcessEnv, gitRunner?: Function, baseBranch?: string|null, baseWorktree?: string|null}} opts
|
|
383
|
+
*/
|
|
384
|
+
function buildIntegrationGateEnv(slug, opts = {}) {
|
|
385
|
+
/** @type {{[key: string]: string | undefined}} */
|
|
386
|
+
const env = /** @type {{[key: string]: string | undefined}} */ ({
|
|
387
|
+
...opts.processEnv,
|
|
388
|
+
INTEGRATE_DRY_RUN: opts.dryRun ? 'true' : 'false',
|
|
371
389
|
INTEGRATE_CHANGED_AREAS: detectChangedAreas(slug, {
|
|
372
|
-
gitRunner,
|
|
373
|
-
baseBranch,
|
|
374
|
-
...(baseWorktree ? { rootDir: baseWorktree } : {})
|
|
390
|
+
gitRunner: opts.gitRunner,
|
|
391
|
+
baseBranch: opts.baseBranch,
|
|
392
|
+
...(opts.baseWorktree ? { rootDir: opts.baseWorktree } : {})
|
|
375
393
|
}).join(' ')
|
|
376
|
-
};
|
|
394
|
+
});
|
|
377
395
|
|
|
378
396
|
// Integration must always use the repo-side config, but the changed-area set
|
|
379
397
|
// should be the one resolved for this mission branch, not rediscovered later.
|
|
@@ -381,28 +399,26 @@ function buildIntegrationGateEnv(slug, {
|
|
|
381
399
|
return env;
|
|
382
400
|
}
|
|
383
401
|
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
402
|
+
/**
|
|
403
|
+
* @param {string} slug
|
|
404
|
+
* @param {{baseWorktree?: string, resolveWorktreeFn?: Function, conventionalWorktreePathFn?: Function}} opts
|
|
405
|
+
*/
|
|
406
|
+
function resolveIntegrationVerificationWorktree(slug, opts = {}) {
|
|
407
|
+
const resolveFn = opts.resolveWorktreeFn || resolveWorktree;
|
|
408
|
+
const convFn = opts.conventionalWorktreePathFn || conventionalWorktreePath;
|
|
409
|
+
return resolveFn(slug, { cwd: opts.baseWorktree })
|
|
410
|
+
|| convFn(slug, opts.baseWorktree);
|
|
391
411
|
}
|
|
392
412
|
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
const
|
|
400
|
-
baseWorktree,
|
|
401
|
-
resolveWorktreeFn,
|
|
402
|
-
conventionalWorktreePathFn
|
|
403
|
-
});
|
|
413
|
+
/**
|
|
414
|
+
* @param {string} slug
|
|
415
|
+
* @param {{baseWorktree?: string, resolveWorktreeFn?: Function, conventionalWorktreePathFn?: Function, formatVerificationCommandFn?: Function}} opts
|
|
416
|
+
*/
|
|
417
|
+
function buildIntegrationVerificationInvocation(slug, opts = {}) {
|
|
418
|
+
const cwd = resolveIntegrationVerificationWorktree(slug, opts);
|
|
419
|
+
const fmtFn = opts.formatVerificationCommandFn || formatVerificationCommand;
|
|
404
420
|
return {
|
|
405
|
-
command:
|
|
421
|
+
command: fmtFn('integrate', cwd),
|
|
406
422
|
cwd
|
|
407
423
|
};
|
|
408
424
|
}
|
|
@@ -411,10 +427,14 @@ function buildIntegrationVerificationInvocation(slug, {
|
|
|
411
427
|
* Execute integration gates, aborting on first failure
|
|
412
428
|
* Returns { ok: boolean, failedGate: string|null, error: string|null }
|
|
413
429
|
*/
|
|
414
|
-
|
|
415
|
-
|
|
430
|
+
/**
|
|
431
|
+
* @param {{key: string, command: string, order: number, run_last: boolean}[]} gates
|
|
432
|
+
* @param {{commandRunner?: Function, rootDir?: string}} opts
|
|
433
|
+
*/
|
|
434
|
+
async function executeIntegrationGates(gates, opts = {}) {
|
|
435
|
+
const runner = opts.commandRunner || ((/** @type{string} */ cmd) => require('child_process').spawnSync(cmd, {
|
|
416
436
|
shell: true,
|
|
417
|
-
cwd: rootDir,
|
|
437
|
+
cwd: opts.rootDir || getPrimaryWorktree(),
|
|
418
438
|
stdio: 'inherit'
|
|
419
439
|
}));
|
|
420
440
|
|
|
@@ -423,7 +443,7 @@ async function executeIntegrationGates(gates, { commandRunner = null, rootDir =
|
|
|
423
443
|
fmt.log.info(` Command: ${gate.command}`);
|
|
424
444
|
|
|
425
445
|
// Execute the command using the injected runner
|
|
426
|
-
const result = runner(gate.command, [], { cwd: rootDir, stdio: 'inherit' });
|
|
446
|
+
const result = runner(gate.command, [], { cwd: opts.rootDir, stdio: 'inherit' });
|
|
427
447
|
|
|
428
448
|
if (result.status !== 0) {
|
|
429
449
|
fmt.log.fail(`Integration gate failed: ${gate.key}`);
|
|
@@ -438,17 +458,22 @@ async function executeIntegrationGates(gates, { commandRunner = null, rootDir =
|
|
|
438
458
|
return { ok: true, failedGate: null, error: null };
|
|
439
459
|
}
|
|
440
460
|
|
|
461
|
+
/** @param {string[]} args */
|
|
441
462
|
async function integrate(args) {
|
|
442
463
|
let exitCode = 0;
|
|
464
|
+
/** @type{{created?: boolean, message?: string, rootDir?: string}|null} */
|
|
443
465
|
let temporaryStash = null;
|
|
444
466
|
let nextActionMessage = null;
|
|
445
|
-
const flags = args.filter(arg => arg.startsWith('--'));
|
|
446
|
-
const params = args.filter(arg => !arg.startsWith('--'));
|
|
467
|
+
const flags = args.filter((/** @type{string} */ arg) => arg.startsWith('--'));
|
|
468
|
+
const params = args.filter((/** @type{string} */ arg) => !arg.startsWith('--'));
|
|
447
469
|
const explicitSlug = params[0];
|
|
448
470
|
const slug = inferSlug(explicitSlug);
|
|
449
471
|
const dryRun = flags.includes('--dry-run');
|
|
450
472
|
const noIntegrationGates = flags.includes('--no-integration-gates');
|
|
451
473
|
|
|
474
|
+
/** @type {{slug: string, branch: string, currentBranch: string, missionDir?: string, area: string, task: {ok: boolean, taskFile?: string, reason?: string, matches?: string[]}, taskStatus?: string, taskAssignee?: string|null, forgejoUser?: string|null, forgejoToken?: string|null, taskAssigneeWarning?: string|null, pr: {exists?: boolean, state?: string, number?: number, merged?: boolean, raw?: string}, siblingPrs: any[], approval: {ok?: boolean, error?: string, reviewState?: string, defaultUserApproved?: boolean, source?: string}, baseBranch?: string, baseWorktree?: string, mainBranch: string, mainDirtyEntries: string[], mainDirty: boolean}} */
|
|
475
|
+
let context;
|
|
476
|
+
|
|
452
477
|
if (process.env.FORGEJO_USER === 'gemini' || process.env.WORKFLOW_AGENT === 'gemini') {
|
|
453
478
|
fmt.log.fail('Gemini is not authorized to run integrate. Post a handoff comment on the PR and stop.');
|
|
454
479
|
process.exit(1);
|
|
@@ -459,55 +484,70 @@ async function integrate(args) {
|
|
|
459
484
|
process.exit(1);
|
|
460
485
|
}
|
|
461
486
|
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
}
|
|
465
|
-
|
|
466
|
-
try {
|
|
467
|
-
const executionDir = process.cwd();
|
|
468
|
-
const context = buildIntegrationContext(slug);
|
|
469
|
-
// The integration target is the mission's recorded base worktree/branch.
|
|
470
|
-
// For legacy missions these resolve to the primary worktree/branch, keeping
|
|
471
|
-
// the merge/commit/sync path byte-identical to today.
|
|
472
|
-
const baseWorktree = context.baseWorktree;
|
|
473
|
-
const baseBranch = context.baseBranch;
|
|
474
|
-
const { failures } = printIntegrationPreflight(context);
|
|
475
|
-
|
|
476
|
-
if (failures.length > 0) {
|
|
477
|
-
fmt.log.fail('\nIntegration preflight failed. Resolve the blockers above before running integrate.');
|
|
478
|
-
throw new IntegrationAbort();
|
|
487
|
+
if (flags.includes('--no-gate')) {
|
|
488
|
+
fmt.log.warn('integrate ignores --no-gate. The landed squash commit relies on the local git hooks for validation.');
|
|
479
489
|
}
|
|
480
490
|
|
|
481
|
-
|
|
482
|
-
const
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
stdio: 'inherit'
|
|
495
|
-
});
|
|
496
|
-
|
|
497
|
-
if (result.status !== 0) {
|
|
498
|
-
fmt.log.fail(`\nIntegration gates failed with exit code ${result.status}`);
|
|
499
|
-
fmt.log.fail('Aborting before merge.');
|
|
491
|
+
try {
|
|
492
|
+
const executionDir = process.cwd();
|
|
493
|
+
// @ts-expect-error buildIntegrationContext return type mismatch
|
|
494
|
+
context = buildIntegrationContext(slug);
|
|
495
|
+
// The integration target is the mission's recorded base worktree/branch.
|
|
496
|
+
// For legacy missions these resolve to the primary worktree/branch, keeping
|
|
497
|
+
// the merge/commit/sync path byte-identical to today.
|
|
498
|
+
const baseWorktree = context.baseWorktree;
|
|
499
|
+
const baseBranch = context.baseBranch;
|
|
500
|
+
const { failures } = printIntegrationPreflight(context);
|
|
501
|
+
|
|
502
|
+
if (failures.length > 0) {
|
|
503
|
+
fmt.log.fail('\nIntegration preflight failed. Resolve the blockers above before running integrate.');
|
|
500
504
|
throw new IntegrationAbort();
|
|
501
505
|
}
|
|
502
|
-
|
|
503
|
-
if (!
|
|
504
|
-
|
|
506
|
+
|
|
507
|
+
if (!noIntegrationGates) {
|
|
508
|
+
const verification = buildIntegrationVerificationInvocation(slug, { baseWorktree });
|
|
509
|
+
const env = buildIntegrationGateEnv(slug, { dryRun, baseBranch, baseWorktree });
|
|
510
|
+
|
|
511
|
+
if (dryRun) {
|
|
512
|
+
fmt.log.info('Running integration gates (dry-run)...');
|
|
513
|
+
} else {
|
|
514
|
+
fmt.log.info('Running integration gates...');
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
const result = child_process.spawnSync('bash', ['-lc', verification.command], {
|
|
518
|
+
cwd: verification.cwd,
|
|
519
|
+
env,
|
|
520
|
+
stdio: 'inherit'
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
if (result.status !== 0) {
|
|
524
|
+
fmt.log.fail(`\nIntegration gates failed with exit code ${result.status}`);
|
|
525
|
+
fmt.log.fail('Aborting before merge.');
|
|
526
|
+
throw new IntegrationAbort();
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
if (!dryRun) {
|
|
530
|
+
fmt.log.pass('All integration gates passed.');
|
|
531
|
+
}
|
|
532
|
+
} else {
|
|
533
|
+
fmt.log.info('Integration gates skipped via --no-integration-gates flag');
|
|
505
534
|
}
|
|
506
|
-
} else {
|
|
507
|
-
fmt.log.info('Integration gates skipped via --no-integration-gates flag');
|
|
508
|
-
}
|
|
509
535
|
|
|
510
|
-
|
|
536
|
+
if (dryRun) {
|
|
537
|
+
promoteTaskForIntegrationIfNeeded(context, { dryRun: true });
|
|
538
|
+
fmt.log.pass('\nDry run complete. Integration preflight passed.');
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
promoteTaskForIntegrationIfNeeded(context);
|
|
543
|
+
|
|
544
|
+
temporaryStash = stashMainCheckoutIfNeeded({
|
|
545
|
+
slug,
|
|
546
|
+
dirtyEntries: context.mainDirtyEntries,
|
|
547
|
+
rootDir: /** @type{string} */(baseWorktree)
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
if (dryRun) {
|
|
511
551
|
promoteTaskForIntegrationIfNeeded(context, { dryRun: true });
|
|
512
552
|
fmt.log.pass('\nDry run complete. Integration preflight passed.');
|
|
513
553
|
return;
|
|
@@ -526,6 +566,7 @@ async function integrate(args) {
|
|
|
526
566
|
const missionWorktree = conventionalWorktreePath(slug);
|
|
527
567
|
if (process.cwd() === missionWorktree || process.cwd().startsWith(missionWorktree + '/')) {
|
|
528
568
|
fmt.log.info(`Moving process directory to ${baseWorktree} before mission worktree deletion.`);
|
|
569
|
+
// @ts-expect-error baseWorktree may be undefined
|
|
529
570
|
process.chdir(baseWorktree);
|
|
530
571
|
}
|
|
531
572
|
|
|
@@ -533,6 +574,7 @@ async function integrate(args) {
|
|
|
533
574
|
const branch = missionBranchName(slug, baseWorktree);
|
|
534
575
|
const mainTitle = missionTitle(slug) || slug;
|
|
535
576
|
const summary = mainTitle.replace(/\s+/g, ' ').trim();
|
|
577
|
+
// @ts-expect-error context.task.taskFile may be undefined
|
|
536
578
|
const mainTaskFile = context.task.taskFile.replace(executionDir, baseWorktree);
|
|
537
579
|
const useVariantA = context.pr.merged;
|
|
538
580
|
fmt.log.info(`Selecting integration variant: ${useVariantA ? 'Variant A (fast-path)' : 'Variant B (full squash-merge)'}`);
|
|
@@ -569,8 +611,11 @@ async function integrate(args) {
|
|
|
569
611
|
fmt.log.info(`\nStep 1: Using base worktree ${baseWorktree} on ${baseBranch} as the squash-merge target...`);
|
|
570
612
|
|
|
571
613
|
fmt.log.info(`Step 2: Checking merge conflicts against local ${baseBranch} in the base worktree...`);
|
|
614
|
+
// @ts-expect-error baseWorktree may be undefined
|
|
572
615
|
const dryMerge = git(['-C', baseWorktree, 'merge', '--no-commit', '--no-ff', branch]);
|
|
616
|
+
// @ts-expect-error baseWorktree may be undefined
|
|
573
617
|
const abortResult = git(['-C', baseWorktree, 'merge', '--abort']);
|
|
618
|
+
// @ts-expect-error dryMerge/abortResult type mismatch
|
|
574
619
|
if (abortResult.status !== 0 && !isNoMergeToAbortResult(abortResult)) {
|
|
575
620
|
fmt.log.fail('Dry-run merge could not be aborted cleanly. Inspect the local integration checkout before retrying integrate.');
|
|
576
621
|
throw new IntegrationAbort();
|
|
@@ -578,6 +623,7 @@ async function integrate(args) {
|
|
|
578
623
|
if (dryMerge.status !== 0) {
|
|
579
624
|
// Before failing, check if a squash commit for this mission already landed on the primary branch
|
|
580
625
|
// from a previous partial integration run (e.g. sync-merged failed after the commit was created).
|
|
626
|
+
// @ts-expect-error baseWorktree may be undefined
|
|
581
627
|
const existingSquash = findExistingSquashCommit(baseWorktree, slug);
|
|
582
628
|
if (existingSquash) {
|
|
583
629
|
fmt.log.warn(`Squash commit already exists on local ${baseBranch} from a previous partial integration (${existingSquash.slice(0, 12)}). Resuming from sync-merged step.`);
|
|
@@ -591,6 +637,7 @@ async function integrate(args) {
|
|
|
591
637
|
baseBranch: context.baseBranch
|
|
592
638
|
});
|
|
593
639
|
if (!syncResult.ok) {
|
|
640
|
+
// @ts-expect-error reportSyncMergedFailure expects {statusCode, error}
|
|
594
641
|
reportSyncMergedFailure(syncResult);
|
|
595
642
|
throw new IntegrationAbort();
|
|
596
643
|
}
|
|
@@ -608,7 +655,7 @@ async function integrate(args) {
|
|
|
608
655
|
fmt.log.pass('Integration completed successfully (resumed from partial state).');
|
|
609
656
|
} else {
|
|
610
657
|
fmt.log.fail('Merge conflicts detected. Rebase the mission branch before integrating.');
|
|
611
|
-
const conflictOutput = [dryMerge.stdout, dryMerge.stderr].filter(Boolean).join('\n');
|
|
658
|
+
const conflictOutput = [/** @type {any} */ (dryMerge).stdout, /** @type {any} */ (dryMerge).stderr].filter(Boolean).join('\n');
|
|
612
659
|
const conflictFiles = parseConflictFilesFromMergeOutput(conflictOutput);
|
|
613
660
|
if (conflictFiles.length > 0) {
|
|
614
661
|
fmt.log.info(`Conflicting files (${conflictFiles.length}):`);
|
|
@@ -618,7 +665,7 @@ async function integrate(args) {
|
|
|
618
665
|
for (const line of formatMatrixSummary(buildAutonomousReviewMatrix())) {
|
|
619
666
|
fmt.log.info(line);
|
|
620
667
|
}
|
|
621
|
-
for (const line of buildConflictResolutionPrompt(slug, context.area, { baseBranch: context.baseBranch })) {
|
|
668
|
+
for (const line of buildConflictResolutionPrompt(slug, context.area, { baseBranch: context.baseBranch || '' })) {
|
|
622
669
|
fmt.log.info(line);
|
|
623
670
|
}
|
|
624
671
|
throw new IntegrationAbort();
|
|
@@ -626,7 +673,9 @@ async function integrate(args) {
|
|
|
626
673
|
} else {
|
|
627
674
|
fmt.log.info('Step 3: Squash-merging the mission branch...');
|
|
628
675
|
let noisePatchState = null;
|
|
676
|
+
// @ts-expect-error baseWorktree may be undefined
|
|
629
677
|
if (softResetTrailingBacklogNoise(baseWorktree, git)) {
|
|
678
|
+
// @ts-expect-error baseWorktree may be undefined
|
|
630
679
|
noisePatchState = prepareNoisePatchForSquash(baseWorktree, { gitRunner: git });
|
|
631
680
|
if (!noisePatchState.ok) {
|
|
632
681
|
fmt.log.fail('Could not preserve trailing backlog noise before squash merge.');
|
|
@@ -636,6 +685,7 @@ async function integrate(args) {
|
|
|
636
685
|
throw new IntegrationAbort();
|
|
637
686
|
}
|
|
638
687
|
}
|
|
688
|
+
// @ts-expect-error baseWorktree may be undefined
|
|
639
689
|
const squashResult = git(['-C', baseWorktree, 'merge', '--squash', branch]);
|
|
640
690
|
if (squashResult.status !== 0) {
|
|
641
691
|
noisePatchState?.cleanup?.();
|
|
@@ -643,8 +693,8 @@ async function integrate(args) {
|
|
|
643
693
|
throw new IntegrationAbort();
|
|
644
694
|
}
|
|
645
695
|
if (noisePatchState?.patchPath) {
|
|
646
|
-
const restoreNoiseResult = restoreNoisePatchAfterSquash(baseWorktree, noisePatchState.patchPath, { gitRunner: git });
|
|
647
|
-
noisePatchState.cleanup();
|
|
696
|
+
const restoreNoiseResult = restoreNoisePatchAfterSquash(/** @type {string} */ (baseWorktree), noisePatchState.patchPath, { gitRunner: git });
|
|
697
|
+
/** @type {Function} */ (noisePatchState.cleanup)();
|
|
648
698
|
if (!restoreNoiseResult.ok) {
|
|
649
699
|
fmt.log.fail('Could not restore trailing backlog noise after squash merge.');
|
|
650
700
|
if (restoreNoiseResult.error) {
|
|
@@ -660,15 +710,17 @@ async function integrate(args) {
|
|
|
660
710
|
// Re-resolve because it moved
|
|
661
711
|
const updatedResolution = resolveTaskFile(slug, baseWorktree);
|
|
662
712
|
if (updatedResolution.ok) {
|
|
713
|
+
// @ts-expect-error baseWorktree may be undefined
|
|
663
714
|
rewriteWorktreePaths(updatedResolution.taskFile, slug, { rootDir: baseWorktree });
|
|
664
715
|
}
|
|
665
716
|
}
|
|
666
717
|
|
|
718
|
+
// @ts-expect-error baseWorktree may be undefined
|
|
667
719
|
git(['-C', baseWorktree, 'add', '-A']);
|
|
668
720
|
fmt.log.info('Step 5: Creating the landed squash commit in the local integration checkout...');
|
|
669
721
|
const commitResult = git([
|
|
670
722
|
'-C',
|
|
671
|
-
baseWorktree,
|
|
723
|
+
/** @type {string} */ (baseWorktree),
|
|
672
724
|
'commit',
|
|
673
725
|
'-m',
|
|
674
726
|
`${branch}: ${summary}`
|
|
@@ -685,17 +737,18 @@ async function integrate(args) {
|
|
|
685
737
|
}
|
|
686
738
|
const proofResult = verification.captureVerifiedTreeProof(context.area, baseWorktree, {
|
|
687
739
|
gitRunner: git,
|
|
688
|
-
runFn: child_process.spawnSync
|
|
740
|
+
runFn: /** @type {Function} */ (child_process.spawnSync)
|
|
689
741
|
});
|
|
690
|
-
if (
|
|
691
|
-
fmt.log.fail(`Could not verify the exact tree being published: ${proofResult.error}`);
|
|
742
|
+
if (!/** @type {any} */ (proofResult).ok) {
|
|
743
|
+
fmt.log.fail(`Could not verify the exact tree being published: ${/** @type {any} */ (proofResult).error}`);
|
|
692
744
|
throw new IntegrationAbort();
|
|
693
745
|
}
|
|
694
|
-
const proof = proofResult.proof;
|
|
746
|
+
const proof = /** @type {any} */ (proofResult).proof;
|
|
747
|
+
// @ts-expect-error baseWorktree may be undefined
|
|
695
748
|
const mergedCommit = git(['-C', baseWorktree, 'rev-parse', 'HEAD']).stdout.trim();
|
|
696
749
|
const proofCheck = verification.assertVerifiedTreeProof(proof, baseWorktree, { gitRunner: git });
|
|
697
750
|
if (!proofCheck.ok) {
|
|
698
|
-
fmt.log.fail(`Verification proof is stale for the publish tree: ${proofCheck.error}`);
|
|
751
|
+
fmt.log.fail(`Verification proof is stale for the publish tree: ${/** @type {any} */ (proofCheck).error}`);
|
|
699
752
|
throw new IntegrationAbort();
|
|
700
753
|
}
|
|
701
754
|
|
|
@@ -708,6 +761,7 @@ async function integrate(args) {
|
|
|
708
761
|
baseBranch: context.baseBranch
|
|
709
762
|
});
|
|
710
763
|
if (!syncResult.ok) {
|
|
764
|
+
// @ts-expect-error reportSyncMergedFailure expects {statusCode, error}
|
|
711
765
|
reportSyncMergedFailure(syncResult);
|
|
712
766
|
throw new IntegrationAbort();
|
|
713
767
|
}
|
|
@@ -731,13 +785,13 @@ async function integrate(args) {
|
|
|
731
785
|
if (error instanceof IntegrationAbort) {
|
|
732
786
|
exitCode = 1;
|
|
733
787
|
} else {
|
|
734
|
-
throw error;
|
|
788
|
+
throw /** @type {any} */ (error);
|
|
735
789
|
}
|
|
736
790
|
} finally {
|
|
737
791
|
if (temporaryStash?.created) {
|
|
738
|
-
|
|
792
|
+
const restoreResult = restoreMainCheckoutStash(/** @type {any} */ (temporaryStash));
|
|
739
793
|
if (restoreResult.status !== 0) {
|
|
740
|
-
reportStashPopFailure(slug, restoreResult, { rootDir: temporaryStash.rootDir });
|
|
794
|
+
reportStashPopFailure(slug, restoreResult, { rootDir: /** @type {any} */ (temporaryStash).rootDir });
|
|
741
795
|
exitCode = 1;
|
|
742
796
|
}
|
|
743
797
|
}
|
|
@@ -749,6 +803,7 @@ async function integrate(args) {
|
|
|
749
803
|
}
|
|
750
804
|
}
|
|
751
805
|
|
|
806
|
+
/** @param {string} slug @param{{baseBranch?: string|null, baseWorktree?: string|null, isForgejoReviewEnabledFn?: Function}} opts */
|
|
752
807
|
function buildIntegrationContext(slug, {
|
|
753
808
|
baseBranch = null,
|
|
754
809
|
baseWorktree = null,
|
|
@@ -773,15 +828,15 @@ function buildIntegrationContext(slug, {
|
|
|
773
828
|
let task = resolveTaskFile(slug);
|
|
774
829
|
if (!task.ok) {
|
|
775
830
|
const worktree = resolveWorktree(slug);
|
|
776
|
-
if (worktree) task = resolveTaskFile(slug, worktree);
|
|
831
|
+
if (worktree) {task = resolveTaskFile(slug, worktree);}
|
|
777
832
|
}
|
|
778
|
-
let taskStatus = task.ok ? getTaskStatus(task.taskFile) : null;
|
|
833
|
+
let taskStatus = task.ok ? getTaskStatus(/** @type {string} */ (task.taskFile)) : null;
|
|
779
834
|
if (task.ok && taskStatus === 'backlog') {
|
|
780
835
|
const worktree = resolveWorktree(slug);
|
|
781
836
|
if (worktree) {
|
|
782
837
|
const wtTask = resolveTaskFile(slug, worktree);
|
|
783
838
|
if (wtTask.ok) {
|
|
784
|
-
const wtStatus = getTaskStatus(wtTask.taskFile);
|
|
839
|
+
const wtStatus = getTaskStatus(/** @type {string} */ (wtTask.taskFile));
|
|
785
840
|
if (wtStatus && wtStatus !== 'backlog') {
|
|
786
841
|
task = wtTask;
|
|
787
842
|
taskStatus = wtStatus;
|
|
@@ -789,22 +844,24 @@ function buildIntegrationContext(slug, {
|
|
|
789
844
|
}
|
|
790
845
|
}
|
|
791
846
|
}
|
|
792
|
-
const taskAssignee = task.ok ? getTaskAssignee(task.taskFile) : null;
|
|
793
|
-
const forgejoEnabled = isForgejoReviewEnabledFn(resolvedBaseWorktree);
|
|
847
|
+
const taskAssignee = task.ok ? getTaskAssignee(/** @type {string} */ (task.taskFile)) : null;
|
|
848
|
+
const forgejoEnabled = isForgejoReviewEnabledFn(/** @type {string} */ (resolvedBaseWorktree));
|
|
794
849
|
|
|
795
850
|
let forgejoIdentity = { forgejoUser: null, warning: null };
|
|
796
851
|
let forgejoToken = null;
|
|
797
852
|
let pr = { exists: false };
|
|
798
|
-
let siblingPrs = [];
|
|
853
|
+
/** @type{any[]} */ let siblingPrs = [];
|
|
799
854
|
let approval = { ok: false, error: 'forgejo-off', reviewState: null };
|
|
800
855
|
|
|
801
856
|
if (forgejoEnabled) {
|
|
857
|
+
// @ts-expect-error forgejoIdentity.forgejoUser type mismatch
|
|
802
858
|
forgejoIdentity = resolveForgejoUserForIntegration(taskAssignee);
|
|
803
|
-
forgejoToken = readToken(forgejoIdentity.forgejoUser);
|
|
804
|
-
pr = getPrStatus(branch, process.cwd(), {
|
|
805
|
-
forgejoUser: forgejoIdentity.forgejoUser,
|
|
859
|
+
forgejoToken = readToken(/** @type {any} */ (forgejoIdentity.forgejoUser));
|
|
860
|
+
pr = /** @type {any} */ (getPrStatus(branch, process.cwd(), {
|
|
861
|
+
forgejoUser: /** @type {any} */ (forgejoIdentity.forgejoUser),
|
|
862
|
+
// @ts-expect-error forgejoToken may be null
|
|
806
863
|
token: forgejoToken
|
|
807
|
-
});
|
|
864
|
+
}));
|
|
808
865
|
|
|
809
866
|
if (pr.exists && slug) {
|
|
810
867
|
const baseSlugMatch = slug.match(/^(task-\d+)/i);
|
|
@@ -815,10 +872,10 @@ function buildIntegrationContext(slug, {
|
|
|
815
872
|
}
|
|
816
873
|
}
|
|
817
874
|
|
|
818
|
-
approval = pr.exists ? getLatestReviewDecision(branch, {
|
|
819
|
-
forgejoUser: forgejoIdentity.forgejoUser,
|
|
820
|
-
token: forgejoToken
|
|
821
|
-
}) : { ok: false, error: 'pr-missing', reviewState:
|
|
875
|
+
approval = pr.exists ? /** @type {any} */ (getLatestReviewDecision(branch, {
|
|
876
|
+
forgejoUser: /** @type {any} */ (forgejoIdentity.forgejoUser),
|
|
877
|
+
token: /** @type {any} */ (forgejoToken)
|
|
878
|
+
})) : /** @type {any} */ ({ ok: false, error: 'pr-missing', reviewState: undefined });
|
|
822
879
|
}
|
|
823
880
|
|
|
824
881
|
// Local review-state fallback: when forgejo token/API is unavailable but the
|
|
@@ -826,15 +883,15 @@ function buildIntegrationContext(slug, {
|
|
|
826
883
|
// so that integrate can proceed without a live Forgejo connection.
|
|
827
884
|
// Only applies when Forgejo was enabled but approval could not be obtained.
|
|
828
885
|
if (forgejoEnabled && !approval.ok) {
|
|
829
|
-
const localStateFallback = readReviewState(slug, resolvedBaseWorktree);
|
|
886
|
+
const localStateFallback = readReviewState(slug, /** @type {string} */ (resolvedBaseWorktree));
|
|
830
887
|
if (localStateFallback && localStateFallback.phase === 'approved' && localStateFallback.disposition === 'APPROVED') {
|
|
831
|
-
approval = { ok: true, reviewState: 'APPROVED', source: 'local-review-state' };
|
|
888
|
+
approval = /** @type {any} */ ({ ok: true, reviewState: 'APPROVED', source: 'local-review-state' });
|
|
832
889
|
}
|
|
833
890
|
}
|
|
834
891
|
|
|
835
|
-
const mainBranchResult = git(['-C', resolvedBaseWorktree, 'branch', '--show-current']);
|
|
892
|
+
const mainBranchResult = git(['-C', /** @type {string} */ (resolvedBaseWorktree), 'branch', '--show-current']);
|
|
836
893
|
const mainBranch = mainBranchResult.stdout.trim();
|
|
837
|
-
const mainStatus = git(['-C', resolvedBaseWorktree, 'status', '--short']);
|
|
894
|
+
const mainStatus = git(['-C', /** @type {string} */ (resolvedBaseWorktree), 'status', '--short']);
|
|
838
895
|
|
|
839
896
|
return {
|
|
840
897
|
slug,
|
|
@@ -861,9 +918,11 @@ function buildIntegrationContext(slug, {
|
|
|
861
918
|
};
|
|
862
919
|
}
|
|
863
920
|
|
|
921
|
+
/** @param {{slug: string, branch: string, currentBranch: string, missionDir?: string, area: string, task: {ok: boolean, taskFile?: string, reason?: string, matches?: string[]}, taskStatus?: string, taskAssignee?: string|null, forgejoUser?: string|null, forgejoToken?: string|null, taskAssigneeWarning?: string|null, pr: {exists?: boolean, state?: string, number?: number, merged?: boolean, raw?: string}, siblingPrs: any[], approval: {ok?: boolean, error?: string, reviewState?: string, defaultUserApproved?: boolean, source?: string}, baseBranch?: string, baseWorktree?: string, mainBranch: string, mainDirtyEntries: string[], mainDirty: boolean}} context */
|
|
864
922
|
function evaluateTaskStatusForIntegration(context) {
|
|
865
|
-
const stateMapOptions = { rootDir: context.baseWorktree };
|
|
866
|
-
|
|
923
|
+
const stateMapOptions = { rootDir: /** @type {string} */ (context.baseWorktree) };
|
|
924
|
+
// @ts-expect-error context.taskStatus may be undefined
|
|
925
|
+
if (toVirtual(context.taskStatus, /** @type {any} */ (stateMapOptions)) === 'approved') {
|
|
867
926
|
return {
|
|
868
927
|
ok: true,
|
|
869
928
|
level: 'pass',
|
|
@@ -904,10 +963,12 @@ function evaluateTaskStatusForIntegration(context) {
|
|
|
904
963
|
return {
|
|
905
964
|
ok: false,
|
|
906
965
|
level: 'fail',
|
|
966
|
+
// @ts-expect-error stateMapOptions type mismatch
|
|
907
967
|
message: `Backlog status: expected approved, or review with an approved/merged Forgejo PR; found ${toVirtual(context.taskStatus, stateMapOptions)}`
|
|
908
968
|
};
|
|
909
969
|
}
|
|
910
970
|
|
|
971
|
+
/** @param {{slug: string, branch: string, currentBranch: string, missionDir?: string, area: string, task: {ok: boolean, taskFile?: string, reason?: string, matches?: string[]}, taskStatus?: string, taskAssignee?: string|null, forgejoUser?: string|null, forgejoToken?: string|null, taskAssigneeWarning?: string|null, pr: {exists?: boolean, state?: string, number?: number, merged?: boolean, raw?: string}, siblingPrs: any[], approval: {ok?: boolean, error?: string, reviewState?: string, defaultUserApproved?: boolean, source?: string}, baseBranch?: string, baseWorktree?: string, mainBranch: string, mainDirtyEntries: string[], mainDirty: boolean}} context */
|
|
911
972
|
function promoteTaskForIntegrationIfNeeded(context, { dryRun = false } = {}) {
|
|
912
973
|
const taskStatusCheck = evaluateTaskStatusForIntegration(context);
|
|
913
974
|
const needsPromotion = context.task?.ok && context.taskStatus === 'review' && taskStatusCheck.ok;
|
|
@@ -921,8 +982,8 @@ function promoteTaskForIntegrationIfNeeded(context, { dryRun = false } = {}) {
|
|
|
921
982
|
return { changed: false, dryRun: true };
|
|
922
983
|
}
|
|
923
984
|
|
|
924
|
-
const stateMapOptions = { rootDir: context.baseWorktree };
|
|
925
|
-
if (!setTaskStatus(context.task.taskFile, toActual('approved', stateMapOptions))) {
|
|
985
|
+
const stateMapOptions = { rootDir: /** @type {string} */ (context.baseWorktree) };
|
|
986
|
+
if (!setTaskStatus(/** @type {string} */ (context.task.taskFile), toActual('approved', stateMapOptions))) {
|
|
926
987
|
fmt.log.fail('Could not promote the Backlog task to approved before integration.');
|
|
927
988
|
throw new IntegrationAbort();
|
|
928
989
|
}
|
|
@@ -932,6 +993,9 @@ function promoteTaskForIntegrationIfNeeded(context, { dryRun = false } = {}) {
|
|
|
932
993
|
return { changed: true, dryRun: false };
|
|
933
994
|
}
|
|
934
995
|
|
|
996
|
+
/**
|
|
997
|
+
* @param{{slug: string, branch: string, missionDir?: string, area: string, task: {ok: boolean, taskFile?: string, reason?: string, matches?: string[]}, taskStatus?: string, taskAssignee?: string|null, forgejoUser?: string|null, forgejoToken?: string|null, taskAssigneeWarning?: string|null, pr: {exists?: boolean, state?: string, number?: number, merged?: boolean, raw?: string}, siblingPrs: any[], approval: {ok?: boolean, error?: string, reviewState?: string, defaultUserApproved?: boolean, source?: string}, baseBranch?: string, baseWorktree?: string, mainBranch: string, mainDirtyEntries: string[], mainDirty: boolean}} context
|
|
998
|
+
*/
|
|
935
999
|
function printIntegrationPreflight(
|
|
936
1000
|
context,
|
|
937
1001
|
{
|
|
@@ -956,11 +1020,14 @@ function printIntegrationPreflight(
|
|
|
956
1020
|
log(fmt.status('INFO', `Integration preflight for ${context.slug}`));
|
|
957
1021
|
|
|
958
1022
|
const branchPrefix = missionBranchName(context.slug, baseWorktree);
|
|
959
|
-
|
|
960
|
-
|
|
1023
|
+
// @ts-expect-error context.currentBranch doesn't exist on type
|
|
1024
|
+
if ((/** @type {any} */ context).currentBranch === context.branch || (/** @type {any} */ context).currentBranch.startsWith(`${branchPrefix}-`)) {
|
|
1025
|
+
// @ts-expect-error context.currentBranch doesn't exist on type
|
|
1026
|
+
log(fmt.status('PASS', `Mission branch: ${(/** @type {any} */ context).currentBranch}`));
|
|
961
1027
|
} else {
|
|
962
1028
|
failures.push('branch');
|
|
963
|
-
|
|
1029
|
+
// @ts-expect-error context.currentBranch doesn't exist on type
|
|
1030
|
+
log(fmt.status('FAIL', `Mission branch: current branch is ${(/** @type {any} */ context).currentBranch}, expected ${context.branch} (or a branch with a suffix)`));
|
|
964
1031
|
}
|
|
965
1032
|
|
|
966
1033
|
if (context.missionDir) {
|
|
@@ -987,7 +1054,7 @@ function printIntegrationPreflight(
|
|
|
987
1054
|
}
|
|
988
1055
|
|
|
989
1056
|
if (context.task.ok) {
|
|
990
|
-
log(fmt.status('PASS', `Backlog task: ${path.basename(context.task.taskFile)} (${context.taskStatus})`));
|
|
1057
|
+
log(fmt.status('PASS', `Backlog task: ${path.basename(/** @type {string} */ (context.task.taskFile))} (${context.taskStatus})`));
|
|
991
1058
|
|
|
992
1059
|
try {
|
|
993
1060
|
const { classification, error: classificationError } = stats.resolveMissionClassification(context.slug);
|
|
@@ -997,11 +1064,12 @@ function printIntegrationPreflight(
|
|
|
997
1064
|
} else {
|
|
998
1065
|
log(fmt.status('PASS', `Backlog classification: ${classification}`));
|
|
999
1066
|
}
|
|
1000
|
-
} catch (error) {
|
|
1067
|
+
} catch (/** @type{any} */ error) {
|
|
1001
1068
|
failures.push('classification');
|
|
1002
1069
|
log(fmt.status('FAIL', `Backlog classification: ${error.message}`));
|
|
1003
1070
|
}
|
|
1004
1071
|
|
|
1072
|
+
// @ts-expect-error context type mismatch (missing currentBranch)
|
|
1005
1073
|
const taskStatusCheck = evaluateTaskStatusForIntegration(context);
|
|
1006
1074
|
if (!taskStatusCheck.ok) {
|
|
1007
1075
|
failures.push('task-status');
|
|
@@ -1015,7 +1083,8 @@ function printIntegrationPreflight(
|
|
|
1015
1083
|
} else if (context.task.reason === 'ambiguous') {
|
|
1016
1084
|
failures.push('task-ambiguity');
|
|
1017
1085
|
log(fmt.status('FAIL', `Backlog task: ambiguous slug ${context.slug}`));
|
|
1018
|
-
context.task.matches
|
|
1086
|
+
// @ts-expect-error context.task.matches may be undefined
|
|
1087
|
+
context.task.matches.forEach((/** @type{string} */ match) => log(` - ${match}`));
|
|
1019
1088
|
} else {
|
|
1020
1089
|
log(fmt.status('WARN', `Backlog task: no task file found for ${context.slug}; continuing with synthetic/unknown task metadata.`));
|
|
1021
1090
|
log(fmt.status('PASS', 'Backlog classification: unknown'));
|
|
@@ -1057,13 +1126,13 @@ function printIntegrationPreflight(
|
|
|
1057
1126
|
const baseSlugMatch = context.slug.match(/^(task-\d+)/i);
|
|
1058
1127
|
const baseSlug = baseSlugMatch ? baseSlugMatch[1].toLowerCase() : context.slug;
|
|
1059
1128
|
log(fmt.status('WARN', `Multiple open PRs detected for ${baseSlug}. Close stale PRs before integrating:`));
|
|
1060
|
-
context.siblingPrs.forEach(p => {
|
|
1129
|
+
context.siblingPrs.forEach((/** @type{{number: number, head: string, html_url: string}} */ p) => {
|
|
1061
1130
|
log(fmt.status('INFO', ` - PR #${p.number} (${p.head}): ${p.html_url}`));
|
|
1062
1131
|
});
|
|
1063
1132
|
}
|
|
1064
1133
|
|
|
1065
|
-
const tokenPath = resolveTokenFileFn(context.forgejoUser);
|
|
1066
|
-
const token = readTokenFn(context.forgejoUser);
|
|
1134
|
+
const tokenPath = resolveTokenFileFn(/** @type {string} */ (context.forgejoUser));
|
|
1135
|
+
const token = readTokenFn(/** @type {string} */ (context.forgejoUser));
|
|
1067
1136
|
if (token) {
|
|
1068
1137
|
log(fmt.status('PASS', `Forgejo token: resolved for ${context.forgejoUser} (${tokenPath || 'env:FORGEJO_TOKEN'})`));
|
|
1069
1138
|
} else if (localApprovalFallback) {
|
|
@@ -1130,7 +1199,7 @@ function printIntegrationPreflight(
|
|
|
1130
1199
|
if (context.mainDirty) {
|
|
1131
1200
|
warnings.push('main-dirty');
|
|
1132
1201
|
log(fmt.status('WARN', `Integration checkout dirty: ${baseWorktree} has uncommitted changes that will be stashed temporarily`));
|
|
1133
|
-
context.mainDirtyEntries.forEach(entry => log(fmt.status('INFO', ` - ${entry}`)));
|
|
1202
|
+
context.mainDirtyEntries.forEach((/** @type{string} */ entry) => log(fmt.status('INFO', ` - ${entry}`)));
|
|
1134
1203
|
} else {
|
|
1135
1204
|
log(fmt.status('PASS', 'Integration checkout dirty state: clean'));
|
|
1136
1205
|
}
|
|
@@ -1156,6 +1225,7 @@ function printIntegrationPreflight(
|
|
|
1156
1225
|
return { failures, warnings };
|
|
1157
1226
|
}
|
|
1158
1227
|
|
|
1228
|
+
/** @param {string} taskFilePath @param {string} slug @param{{rootDir?: string}} options */
|
|
1159
1229
|
function rewriteWorktreePaths(taskFilePath, slug, options = {}) {
|
|
1160
1230
|
const rootDir = options.rootDir || getPrimaryWorktree();
|
|
1161
1231
|
const before = fs.readFileSync(taskFilePath, 'utf8');
|
|
@@ -1169,6 +1239,7 @@ function rewriteWorktreePaths(taskFilePath, slug, options = {}) {
|
|
|
1169
1239
|
}
|
|
1170
1240
|
}
|
|
1171
1241
|
|
|
1242
|
+
/** @param{{slug: string, dirtyEntries?: string[], rootDir?: string, gitRunner?: Function}} params */
|
|
1172
1243
|
function stashMainCheckoutIfNeeded({
|
|
1173
1244
|
slug,
|
|
1174
1245
|
dirtyEntries = [],
|
|
@@ -1203,6 +1274,7 @@ function stashMainCheckoutIfNeeded({
|
|
|
1203
1274
|
};
|
|
1204
1275
|
}
|
|
1205
1276
|
|
|
1277
|
+
/** @param{{message: string, rootDir?: string, gitRunner?: Function}} params */
|
|
1206
1278
|
function restoreMainCheckoutStash({ message, rootDir = getPrimaryWorktree(), gitRunner = git }) {
|
|
1207
1279
|
fmt.log.info(`Restoring temporarily stashed local integration checkout changes: ${message}`);
|
|
1208
1280
|
// Pass cwd explicitly so spawnSync does not inherit the process cwd, which may have been
|
|
@@ -1210,11 +1282,16 @@ function restoreMainCheckoutStash({ message, rootDir = getPrimaryWorktree(), git
|
|
|
1210
1282
|
return gitRunner(['-C', rootDir, 'stash', 'pop'], { cwd: rootDir });
|
|
1211
1283
|
}
|
|
1212
1284
|
|
|
1285
|
+
/** @param{{stdout: string, stderr: string, status: number}} result */
|
|
1213
1286
|
function isNoMergeToAbortResult(result) {
|
|
1214
1287
|
const output = [result.stdout, result.stderr].filter(Boolean).join('\n');
|
|
1215
1288
|
return /MERGE_HEAD missing|There is no merge to abort/i.test(output);
|
|
1216
1289
|
}
|
|
1217
1290
|
|
|
1291
|
+
/**
|
|
1292
|
+
* @param {string} slug
|
|
1293
|
+
* @param{{rootDir?: string, gitRunner?: Function, recordIntegrationStatsFn?: Function}} options
|
|
1294
|
+
*/
|
|
1218
1295
|
function recordPostIntegrationStats(
|
|
1219
1296
|
slug,
|
|
1220
1297
|
{
|
|
@@ -1240,19 +1317,28 @@ function recordPostIntegrationStats(
|
|
|
1240
1317
|
fmt.log.info(`Workflow stats recorded: ${formatRecordedStatsRow(outcome.row)}`);
|
|
1241
1318
|
fmt.log.info('Workflow stats updated:');
|
|
1242
1319
|
fmt.log.plain(outcome.report);
|
|
1320
|
+
|
|
1321
|
+
const missionRows = outcome.data?.rows || [];
|
|
1322
|
+
const missionReport = stats.renderMissionPhaseReport(missionRows, slug);
|
|
1323
|
+
const firstLine = missionReport.split('\n')[0];
|
|
1324
|
+
fmt.log.info(firstLine);
|
|
1325
|
+
fmt.log.plain(missionReport.split('\n').slice(1).join('\n'));
|
|
1326
|
+
|
|
1243
1327
|
return outcome;
|
|
1244
1328
|
}
|
|
1245
1329
|
|
|
1330
|
+
/** @param {string} slug @param{{rootDir?: string}} options */
|
|
1246
1331
|
function recordPostIntegrationStatsOrAbort(slug, options = {}) {
|
|
1247
1332
|
try {
|
|
1248
1333
|
return recordPostIntegrationStats(slug, options);
|
|
1249
|
-
} catch (error) {
|
|
1334
|
+
} catch (/** @type{any} */ error) {
|
|
1250
1335
|
const detail = error && error.message ? error.message : String(error);
|
|
1251
1336
|
fmt.log.fail(`Post-integration workflow stats failed for ${slug}: ${detail}`);
|
|
1252
1337
|
throw new IntegrationAbort();
|
|
1253
1338
|
}
|
|
1254
1339
|
}
|
|
1255
1340
|
|
|
1341
|
+
/** @param{{slug: string, summary: string, mainTaskFile?: string, rootDir?: string, gitRunner?: Function, baseBranch?: string|null, verificationArea?: string|null, captureVerifiedTreeProofFn?: Function, assertVerifiedTreeProofFn?: Function}} params */
|
|
1256
1342
|
function finalizeVariantACloseout({
|
|
1257
1343
|
slug,
|
|
1258
1344
|
summary,
|
|
@@ -1271,7 +1357,7 @@ function finalizeVariantACloseout({
|
|
|
1271
1357
|
// Re-resolve because it moved
|
|
1272
1358
|
const updatedResolution = resolveTaskFile(slug, rootDir);
|
|
1273
1359
|
if (updatedResolution.ok) {
|
|
1274
|
-
rewriteWorktreePaths(updatedResolution.taskFile, slug, { rootDir });
|
|
1360
|
+
rewriteWorktreePaths(/** @type {string} */ (updatedResolution.taskFile), slug, { rootDir });
|
|
1275
1361
|
}
|
|
1276
1362
|
}
|
|
1277
1363
|
|
|
@@ -1332,12 +1418,16 @@ function finalizeVariantACloseout({
|
|
|
1332
1418
|
return { ok: true, changed: true };
|
|
1333
1419
|
}
|
|
1334
1420
|
|
|
1421
|
+
/**
|
|
1422
|
+
* @param {string} slug
|
|
1423
|
+
* @param{{rootDir?: string, gitRunner?: Function, removeDir?: Function, existsSync?: Function}} options
|
|
1424
|
+
*/
|
|
1335
1425
|
function cleanupMissionWorktree(
|
|
1336
1426
|
slug,
|
|
1337
1427
|
{
|
|
1338
1428
|
rootDir = getPrimaryWorktree(),
|
|
1339
1429
|
gitRunner = git,
|
|
1340
|
-
removeDir = target => {
|
|
1430
|
+
removeDir = (/** @type{string} */ target) => {
|
|
1341
1431
|
if (isForgejoPath(target, { forgejoHome: resolveForgejoHome() })) {
|
|
1342
1432
|
throw new Error(`CRITICAL SAFETY VIOLATION: cleanupMissionWorktree attempted to delete Forgejo home: ${target}`);
|
|
1343
1433
|
}
|
|
@@ -1359,6 +1449,7 @@ function cleanupMissionWorktree(
|
|
|
1359
1449
|
}
|
|
1360
1450
|
|
|
1361
1451
|
const worktreeList = gitRunner(['-C', rootDir, 'worktree', 'list', '--porcelain']).stdout;
|
|
1452
|
+
// @ts-expect-error line implicitly any
|
|
1362
1453
|
const isRegistered = worktreeList.split('\n').some(line => line.trim() === `worktree ${worktreePath}`);
|
|
1363
1454
|
|
|
1364
1455
|
if (existsSync(worktreePath) && isRegistered) {
|
|
@@ -1389,16 +1480,17 @@ function cleanupMissionWorktree(
|
|
|
1389
1480
|
return !existsSync(worktreePath);
|
|
1390
1481
|
}
|
|
1391
1482
|
|
|
1483
|
+
/** @param {string} rootDir @param {string} slug */
|
|
1392
1484
|
function findExistingSquashCommit(rootDir, slug) {
|
|
1393
1485
|
const result = git(['-C', rootDir, 'log', '--format=%H %s', '-50']);
|
|
1394
|
-
if (result.status !== 0) return null;
|
|
1486
|
+
if (result.status !== 0) {return null;}
|
|
1395
1487
|
const prefix = `${missionBranchName(slug, rootDir)}:`;
|
|
1396
1488
|
for (const line of result.stdout.trim().split('\n')) {
|
|
1397
1489
|
const spaceIdx = line.indexOf(' ');
|
|
1398
|
-
if (spaceIdx === -1) continue;
|
|
1490
|
+
if (spaceIdx === -1) {continue;}
|
|
1399
1491
|
const hash = line.slice(0, spaceIdx);
|
|
1400
1492
|
const subject = line.slice(spaceIdx + 1);
|
|
1401
|
-
if (subject.startsWith(prefix)) return hash;
|
|
1493
|
+
if (subject.startsWith(prefix)) {return hash;}
|
|
1402
1494
|
}
|
|
1403
1495
|
return null;
|
|
1404
1496
|
}
|
|
@@ -1414,8 +1506,14 @@ function findExistingSquashCommit(rootDir, slug) {
|
|
|
1414
1506
|
* @param {string} slug - Backlog task key (e.g. "task-108")
|
|
1415
1507
|
* @param {string} area - Mission gate area passed to the configured verification command
|
|
1416
1508
|
* @param {{ getConflictFilesFn?: Function }} [options] - Overrides for testing
|
|
1417
|
-
* @returns {{ ok: boolean, conflictFiles: string[], sharedFiles: string[], missionSpecificFiles: string[] }}
|
|
1509
|
+
* @returns {{ ok: boolean, conflictFiles: string[], sharedFiles: string[], missionSpecificFiles: string[], error?: string, worktreePath?: string }}
|
|
1510
|
+
*/
|
|
1511
|
+
/**
|
|
1512
|
+
* @param {string} slug
|
|
1513
|
+
* @param {string} area
|
|
1514
|
+
* @param{{getConflictFilesFn: Function, resolveWorktreeFn?: Function, worktreePathOverride?: string|null, rootDir?: string, baseBranch?: string|null}} options
|
|
1418
1515
|
*/
|
|
1516
|
+
// @ts-expect-error resolveConflictsForMission options missing getConflictFilesFn
|
|
1419
1517
|
function resolveConflictsForMission(slug, area, { getConflictFilesFn, resolveWorktreeFn = resolveWorktree, worktreePathOverride = null, rootDir = getPrimaryWorktree(), baseBranch = null } = {}) {
|
|
1420
1518
|
// Resolve the actual attached worktree dynamically; fall back to the conventional
|
|
1421
1519
|
// path only when git worktree list has no entry for this mission branch.
|
|
@@ -1434,7 +1532,8 @@ function resolveConflictsForMission(slug, area, { getConflictFilesFn, resolveWor
|
|
|
1434
1532
|
try { targetBranch = resolveMissionBaseBranch(slug, rootDir); } catch (_) { targetBranch = getPrimaryBranch(); }
|
|
1435
1533
|
}
|
|
1436
1534
|
|
|
1437
|
-
|
|
1535
|
+
// @ts-expect-error getConflictFilesFn may be undefined
|
|
1536
|
+
const detectFn = getConflictFilesFn || (() => getConflictFiles(worktreePath, targetBranch));
|
|
1438
1537
|
|
|
1439
1538
|
if (!fs.existsSync(worktreePath)) {
|
|
1440
1539
|
fmt.log.fail(`Mission worktree not found: ${worktreePath}`);
|
|
@@ -1446,7 +1545,7 @@ function resolveConflictsForMission(slug, area, { getConflictFilesFn, resolveWor
|
|
|
1446
1545
|
let conflictFiles;
|
|
1447
1546
|
try {
|
|
1448
1547
|
conflictFiles = detectFn(worktreePath, targetBranch);
|
|
1449
|
-
} catch (err) {
|
|
1548
|
+
} catch (/** @type{any} */ err) {
|
|
1450
1549
|
fmt.log.fail('Merge check failed with a non-conflict error:');
|
|
1451
1550
|
fmt.log.fail(err.message);
|
|
1452
1551
|
return { ok: false, error: 'merge-failed', conflictFiles: [], sharedFiles: [], missionSpecificFiles: [], worktreePath };
|
|
@@ -1459,20 +1558,20 @@ function resolveConflictsForMission(slug, area, { getConflictFilesFn, resolveWor
|
|
|
1459
1558
|
}
|
|
1460
1559
|
|
|
1461
1560
|
fmt.log.info(`Conflicting files (${conflictFiles.length}):`);
|
|
1462
|
-
conflictFiles.forEach(f => fmt.log.info(` - ${f}`));
|
|
1561
|
+
conflictFiles.forEach((/** @type{string} */ f) => fmt.log.info(` - ${f}`));
|
|
1463
1562
|
|
|
1464
1563
|
// Derive the mission-doc directory prefix from the actual located mission dir
|
|
1465
1564
|
// so the classification works for non-standard doc paths (e.g. renamed year dir).
|
|
1466
1565
|
const taskPattern = new RegExp(`backlog/(?:tasks|completed)/[^/]*${slug}`);
|
|
1467
|
-
const missionSpecificFiles = conflictFiles.filter(f =>
|
|
1566
|
+
const missionSpecificFiles = conflictFiles.filter((/** @type{string} */ f) =>
|
|
1468
1567
|
isMissionArtifact(f, slug, worktreePath) || taskPattern.test(f)
|
|
1469
1568
|
);
|
|
1470
|
-
const sharedFiles = conflictFiles.filter(f => !missionSpecificFiles.includes(f));
|
|
1569
|
+
const sharedFiles = conflictFiles.filter((/** @type{string} */ f) => !missionSpecificFiles.includes(f));
|
|
1471
1570
|
const quotedWorktreePath = shellQuote(worktreePath);
|
|
1472
1571
|
|
|
1473
1572
|
if (sharedFiles.length > 0) {
|
|
1474
1573
|
fmt.log.warn(`Conflicts in ${sharedFiles.length} shared file(s) require manual resolution:`);
|
|
1475
|
-
sharedFiles.forEach(f => fmt.log.plain(` - ${f}`));
|
|
1574
|
+
sharedFiles.forEach((/** @type{string} */ f) => fmt.log.plain(` - ${f}`));
|
|
1476
1575
|
fmt.log.info('Manual resolution path:');
|
|
1477
1576
|
fmt.log.info(` cd ${quotedWorktreePath}`);
|
|
1478
1577
|
fmt.log.info(` git rebase ${targetBranch}`);
|
|
@@ -1489,7 +1588,7 @@ function resolveConflictsForMission(slug, area, { getConflictFilesFn, resolveWor
|
|
|
1489
1588
|
fmt.log.info(` cd ${quotedWorktreePath}`);
|
|
1490
1589
|
fmt.log.info(` git rebase ${targetBranch}`);
|
|
1491
1590
|
fmt.log.info(' # after the rebase pauses on conflicts:');
|
|
1492
|
-
missionSpecificFiles.forEach(f => fmt.log.info(` git checkout --theirs "${f}" && git add "${f}"`));
|
|
1591
|
+
missionSpecificFiles.forEach((/** @type{string} */ f) => fmt.log.info(` git checkout --theirs "${f}" && git add "${f}"`));
|
|
1493
1592
|
fmt.log.info(' git rebase --continue');
|
|
1494
1593
|
fmt.log.info(` ${formatVerificationCommand(area, worktreePath)}`);
|
|
1495
1594
|
fmt.log.info(` px integrate ${slug} --dry-run`);
|
|
@@ -1497,6 +1596,7 @@ function resolveConflictsForMission(slug, area, { getConflictFilesFn, resolveWor
|
|
|
1497
1596
|
return { ok: true, conflictFiles, sharedFiles: [], missionSpecificFiles, worktreePath };
|
|
1498
1597
|
}
|
|
1499
1598
|
|
|
1599
|
+
/** @param{string} slug @param{string} area @param{{rootDir?: string, worktreePath?: string, baseBranch?: string|null}} options */
|
|
1500
1600
|
function buildConflictResolutionPrompt(slug = '<slug>', area = '<area>', options = {}) {
|
|
1501
1601
|
const rootDir = options.rootDir || getPrimaryWorktree();
|
|
1502
1602
|
const worktreePath = options.worktreePath || resolveWorktree(slug) || conventionalWorktreePath(slug, rootDir);
|