@magnusekdahl/parallix 1.0.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/CHANGELOG.md +140 -0
- package/LICENSE +661 -0
- package/README.md +196 -0
- package/config/agents.json +25 -0
- package/config/agents.local.json.template +8 -0
- package/config/state-map.json +4 -0
- package/config/state-map.json.template +31 -0
- package/config/workflow.config.schema.json +98 -0
- package/data/.gitkeep +0 -0
- package/docs/adr/0031-ai-agent-instruction-boundary-and-command-floor.md +114 -0
- package/docs/adr/0032-mission-refinement-state-and-usage-budget-signals.md +135 -0
- package/docs/adr/0034-module-and-skill-invocation-model.md +202 -0
- package/docs/adr/0036-mission-sizing-and-dependency-wave-heuristics.md +79 -0
- package/docs/adr/0037-ai-workflow-coordination-architecture.md +162 -0
- package/docs/adr/0041-integration-pipeline-gates.md +165 -0
- package/docs/adr/0042-workflow-cli-color-rendering-approach.md +106 -0
- package/docs/adr/0043-git-target-resolution-strategy.md +185 -0
- package/docs/adr/0044-workflow-distribution-model.md +277 -0
- package/docs/adr/0045-parallax-branch-model.md +182 -0
- package/docs/adr/0046-npm-publish-process-and-security.md +138 -0
- package/docs/adr/index.md +20 -0
- package/docs/agents.md +212 -0
- package/docs/authority-reference.md +298 -0
- package/docs/forgejo-setup.md +31 -0
- package/docs/migration/extraction.md +61 -0
- package/docs/migration/task-classification.md +36 -0
- package/docs/operator-setup.md +76 -0
- package/docs/readme-rewrite-benchmark.md +188 -0
- package/docs/use-cases.md +105 -0
- package/examples/README.md +62 -0
- package/examples/run-enterprise-tarball-workflow-smoke.sh +257 -0
- package/examples/run-verify-env-smoke.sh +40 -0
- package/index.js +250 -0
- package/lib/README.md +13 -0
- package/lib/agents/agents.js +867 -0
- package/lib/agents/claude-telemetry.js +233 -0
- package/lib/agents/claude.js +139 -0
- package/lib/agents/codex-telemetry.js +202 -0
- package/lib/agents/codex.js +219 -0
- package/lib/agents/limit-hit.js +252 -0
- package/lib/agents/mistral-telemetry.js +44 -0
- package/lib/agents/mistral.js +68 -0
- package/lib/agents/opencode-export.js +110 -0
- package/lib/agents/opencode-telemetry.js +356 -0
- package/lib/agents/opencode.js +218 -0
- package/lib/agents/stage-telemetry.js +37 -0
- package/lib/commands/active.js +625 -0
- package/lib/commands/checkpoint.js +76 -0
- package/lib/commands/config.js +39 -0
- package/lib/commands/coverage-gate.js +358 -0
- package/lib/commands/diff.js +119 -0
- package/lib/commands/draft.js +854 -0
- package/lib/commands/handoff.js +501 -0
- package/lib/commands/integrate.js +1528 -0
- package/lib/commands/mission-start.js +246 -0
- package/lib/commands/rebase.js +597 -0
- package/lib/commands/repair-handoff.js +227 -0
- package/lib/commands/resolve-conflict.js +109 -0
- package/lib/commands/review.js +13 -0
- package/lib/commands/setup-review.js +13 -0
- package/lib/commands/setup.js +3 -0
- package/lib/commands/stats-backfill.js +395 -0
- package/lib/commands/stats.js +1601 -0
- package/lib/commands/status.js +183 -0
- package/lib/commands/verify.js +1 -0
- package/lib/core/fmt.js +202 -0
- package/lib/core/git.js +73 -0
- package/lib/core/gitignore.js +110 -0
- package/lib/core/mission-utils.js +1017 -0
- package/lib/core/persistent-data-migration.js +201 -0
- package/lib/core/product-config.js +508 -0
- package/lib/core/runtime-matrix.js +82 -0
- package/lib/core/spawn-tee.js +173 -0
- package/lib/core/state-map.js +89 -0
- package/lib/core/storage.js +165 -0
- package/lib/core/verification.js +149 -0
- package/lib/index.js +77 -0
- package/lib/review/rebase.js +163 -0
- package/lib/review/review-adapter.js +135 -0
- package/lib/review/review-artifacts.js +619 -0
- package/lib/review/review-commands.js +1375 -0
- package/lib/review/review-events.js +1007 -0
- package/lib/review/review-loop.js +1004 -0
- package/lib/review/review-polling.js +141 -0
- package/lib/review/review-prompts.js +212 -0
- package/lib/review/review-state.js +280 -0
- package/lib/review/review.js +96 -0
- package/lib/tools/backlog.js +680 -0
- package/lib/tools/forgejo.js +1585 -0
- package/lib/tools/gatekeeper.js +106 -0
- package/lib/tools/sessions.js +74 -0
- package/lib/tools/setup-review.js +1053 -0
- package/package.json +56 -0
- package/prompts/act-on-review-verbose.md +20 -0
- package/prompts/act-on-review.md +22 -0
- package/prompts/draft.md +20 -0
- package/prompts/execute.md +24 -0
- package/prompts/portfolio.md +30 -0
- package/prompts/review-verbose.md +20 -0
- package/prompts/review.md +17 -0
- package/px.js +236 -0
- package/templates/AGENTS-snippet.md +14 -0
- package/templates/AGENTS.md.template +34 -0
- package/templates/CLAUDE.md.template +27 -0
- package/templates/CODEX.md.template +38 -0
- package/templates/MISTRAL.md.template +24 -0
- package/templates/claude-commands/act-on-review.md +3 -0
- package/templates/claude-commands/area-review.md +3 -0
- package/templates/claude-commands/draft.md +6 -0
- package/templates/claude-commands/execute.md +6 -0
- package/templates/claude-commands/integrate.md +4 -0
- package/templates/claude-commands/portfolio.md +5 -0
- package/templates/claude-commands/review.md +4 -0
- package/templates/codex/config.toml +6 -0
- package/templates/mission-scaffold.md +39 -0
- package/templates/vibe/skills/act-on-review/SKILL.md +16 -0
- package/templates/vibe/skills/area-review/SKILL.md +16 -0
- package/templates/vibe/skills/draft/SKILL.md +16 -0
- package/templates/vibe/skills/execute/SKILL.md +16 -0
- package/templates/vibe/skills/integrate/SKILL.md +16 -0
- package/templates/vibe/skills/portfolio/SKILL.md +21 -0
- package/templates/vibe/skills/review/SKILL.md +16 -0
- package/tools/setup-forgejo-docker.sh +84 -0
|
@@ -0,0 +1,1528 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const os = require('os');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const child_process = require('child_process');
|
|
5
|
+
const { git, getCurrentBranch } = require('../core/git');
|
|
6
|
+
const { resolveTaskFile, getTaskStatus, setTaskStatus, completeTask, getTaskAssignee } = require('../tools/backlog');
|
|
7
|
+
const { toVirtual, toActual } = require('../core/state-map');
|
|
8
|
+
const { getPrStatus, getLatestReviewDecision, syncMerged, readToken, resolveTokenFile, resolveForgejoUser, resolveForgejoHome, isForgejoPath, listOpenPrsForSlug } = require('../tools/forgejo');
|
|
9
|
+
const fmt = require('../core/fmt');
|
|
10
|
+
const { KNOWN_AGENT_NAMES } = require('../agents/agents');
|
|
11
|
+
const { buildAutonomousReviewMatrix, formatMatrixSummary } = require('../core/runtime-matrix');
|
|
12
|
+
const { findMissionDir, resolveWorktree, findMissionArea, missionTitle, parseConflictFilesFromMergeOutput, getConflictFiles, inferSlug, updateGraphifyKnowledgeGraph, resolveMainRepo, getPrimaryWorktree, getPrimaryBranch, conventionalWorktreePath, getMissionYear, softResetTrailingBacklogNoise, findMissionDocInBranches, missionBranchName, missionBaseDir, missionDirForSlug, isMissionArtifact, resolveMissionBaseBranch, resolveBaseWorktree } = require('../core/mission-utils');
|
|
13
|
+
const stats = require('./stats');
|
|
14
|
+
const verification = require('../core/verification');
|
|
15
|
+
const { formatVerificationCommand } = verification;
|
|
16
|
+
const { isForgejoReviewEnabled } = require('../core/product-config');
|
|
17
|
+
const { readReviewState } = require('../review/review-state');
|
|
18
|
+
|
|
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
|
+
|
|
21
|
+
const SYNC_MERGED_DIAGNOSTICS = [
|
|
22
|
+
{ 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
|
+
{ 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.' },
|
|
24
|
+
{ symptom: 'git push rejects mission branch with "stale info" or "fetch first"', cause: 'Local review/<branch> tracking is stale while sync-merged updates the PR branch to the landed squash commit', fix: 'Automated: sync-merged fetches review/<branch>, retries --force-with-lease, then force-pushes only if stale-info persists.' },
|
|
25
|
+
{ symptom: 'curl: (7) Failed to connect to localhost port 3300', cause: 'Forgejo service not reachable from current runtime (sandbox/network)', fix: 'Ensure Forgejo is running (scripts/start-runner.sh) or use FORGEJO_URL override if external.' },
|
|
26
|
+
{ 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
|
+
|
|
29
|
+
class IntegrationAbort extends Error {}
|
|
30
|
+
|
|
31
|
+
function formatRecordedStatsRow(row) {
|
|
32
|
+
return `${row.mission}: implementer=${row.implementer}, pr_fix_rounds=${row.pr_fix_rounds}, classification=${row.classification}, date=${row.date}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function shellQuote(value) {
|
|
36
|
+
return `"${String(value).replace(/(["\\$`])/g, '\\$1')}"`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function maybeUpdateGraphifyOnPrimary(rootDir = getPrimaryWorktree(), { commandRunner = null, log = fmt.log.plain } = {}) {
|
|
40
|
+
return updateGraphifyKnowledgeGraph({
|
|
41
|
+
rootDir,
|
|
42
|
+
commandRunner: commandRunner || ((command, args, options) => require('../core/git').run(command, args, options)),
|
|
43
|
+
log,
|
|
44
|
+
startMessage: `Updating graphify knowledge graph on ${getPrimaryBranch()}...`,
|
|
45
|
+
failureHint: 'Continuing without blocking integration.'
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function prepareNoisePatchForSquash(rootDir, { gitRunner = git } = {}) {
|
|
50
|
+
const diffResult = gitRunner(['-C', rootDir, 'diff', '--cached', '--binary']);
|
|
51
|
+
if (diffResult.status !== 0) {
|
|
52
|
+
return {
|
|
53
|
+
ok: false,
|
|
54
|
+
error: [diffResult.stdout, diffResult.stderr].filter(Boolean).join('\n').trim() || 'Could not capture staged backlog-noise patch.'
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const patch = diffResult.stdout || '';
|
|
59
|
+
if (!patch.trim()) {
|
|
60
|
+
return { ok: true, patchPath: null, cleanup: () => {} };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const patchDir = fs.mkdtempSync(path.join(os.tmpdir(), 'parallix-integrate-noise-'));
|
|
64
|
+
const patchPath = path.join(patchDir, 'backlog-noise.patch');
|
|
65
|
+
fs.writeFileSync(patchPath, patch, 'utf8');
|
|
66
|
+
|
|
67
|
+
const resetResult = gitRunner(['-C', rootDir, 'reset', '--hard', 'HEAD']);
|
|
68
|
+
if (resetResult.status !== 0) {
|
|
69
|
+
return {
|
|
70
|
+
ok: false,
|
|
71
|
+
patchPath,
|
|
72
|
+
error: [resetResult.stdout, resetResult.stderr].filter(Boolean).join('\n').trim() || 'Could not restore a clean checkout after capturing backlog-noise patch.'
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
ok: true,
|
|
78
|
+
patchPath,
|
|
79
|
+
cleanup: () => fs.rmSync(patchDir, { recursive: true, force: true })
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function restoreNoisePatchAfterSquash(rootDir, patchPath, { gitRunner = git } = {}) {
|
|
84
|
+
if (!patchPath) return { ok: true };
|
|
85
|
+
const applyResult = gitRunner(['-C', rootDir, 'apply', '--index', patchPath]);
|
|
86
|
+
if (applyResult.status !== 0) {
|
|
87
|
+
return {
|
|
88
|
+
ok: false,
|
|
89
|
+
error: [applyResult.stdout, applyResult.stderr].filter(Boolean).join('\n').trim() || 'Could not re-apply backlog-noise patch after squash merge.'
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
return { ok: true };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function resolveForgejoUserForIntegration(taskAssignee) {
|
|
96
|
+
// If taskAssignee is set, use it directly as the Forgejo user identity.
|
|
97
|
+
// This ensures that even non-agent assignees (e.g., human users) have their
|
|
98
|
+
// identity properly propagated through all Forgejo operations.
|
|
99
|
+
if (taskAssignee) {
|
|
100
|
+
return {
|
|
101
|
+
forgejoUser: taskAssignee,
|
|
102
|
+
warning: null
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Fall back to environment variable or default only when no assignee is available
|
|
107
|
+
return {
|
|
108
|
+
forgejoUser: resolveForgejoUser(),
|
|
109
|
+
warning: null
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function getUnresolvedIndexConflicts(rootDir = getPrimaryWorktree(), { gitRunner = git } = {}) {
|
|
114
|
+
const result = gitRunner(['-C', rootDir, 'ls-files', '-u']);
|
|
115
|
+
if (result.status !== 0) {
|
|
116
|
+
return {
|
|
117
|
+
ok: false,
|
|
118
|
+
files: [],
|
|
119
|
+
error: [result.stdout, result.stderr].filter(Boolean).join('\n').trim()
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const files = Array.from(new Set(
|
|
124
|
+
result.stdout
|
|
125
|
+
.split('\n')
|
|
126
|
+
.map(line => line.trim())
|
|
127
|
+
.filter(Boolean)
|
|
128
|
+
.map(line => line.split('\t')[1])
|
|
129
|
+
.filter(Boolean)
|
|
130
|
+
));
|
|
131
|
+
|
|
132
|
+
return {
|
|
133
|
+
ok: true,
|
|
134
|
+
files
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function parseStashPopCollisionFiles(output = '') {
|
|
139
|
+
return output
|
|
140
|
+
.split('\n')
|
|
141
|
+
.map(line => line.trim())
|
|
142
|
+
.filter(line => /already exists, no checkout$/i.test(line))
|
|
143
|
+
.map(line => line.replace(/\s+already exists, no checkout$/i, ''));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function reportStashPopFailure(
|
|
147
|
+
slug,
|
|
148
|
+
restoreResult,
|
|
149
|
+
{
|
|
150
|
+
rootDir = getPrimaryWorktree(),
|
|
151
|
+
gitRunner = git,
|
|
152
|
+
getUnresolvedIndexConflictsFn = targetRootDir => getUnresolvedIndexConflicts(targetRootDir, { gitRunner })
|
|
153
|
+
} = {}
|
|
154
|
+
) {
|
|
155
|
+
const output = [restoreResult.stdout, restoreResult.stderr].filter(Boolean).join('\n').trim();
|
|
156
|
+
const headResult = gitRunner(['-C', rootDir, 'log', '-1', '--oneline']);
|
|
157
|
+
const headLine = headResult.status === 0 ? headResult.stdout.trim() : '(unavailable)';
|
|
158
|
+
const integrationLanded = headLine.includes(`${missionBranchName(slug, rootDir)}:`);
|
|
159
|
+
const indexConflicts = getUnresolvedIndexConflictsFn(rootDir);
|
|
160
|
+
const collisionFiles = parseStashPopCollisionFiles(output);
|
|
161
|
+
|
|
162
|
+
fmt.log.fail('Could not restore the temporarily stashed local integration checkout changes.');
|
|
163
|
+
if (integrationLanded) {
|
|
164
|
+
fmt.log.fail(`Integration commit landed: ${headLine}`);
|
|
165
|
+
} else {
|
|
166
|
+
fmt.log.fail(`Integration landing not confirmed by HEAD: ${headLine}`);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (indexConflicts.ok && indexConflicts.files.length > 0) {
|
|
170
|
+
fmt.log.fail('Stash restore failure type: merge-conflict');
|
|
171
|
+
indexConflicts.files.forEach(file => fmt.log.fail(` - ${file}`));
|
|
172
|
+
fmt.log.fail('Recovery steps:');
|
|
173
|
+
fmt.log.fail(` 1. cd ${rootDir}`);
|
|
174
|
+
indexConflicts.files.forEach(file => {
|
|
175
|
+
fmt.log.fail(` 2. Resolve ${file}, then run git add "${file}" or git rm "${file}"`);
|
|
176
|
+
});
|
|
177
|
+
fmt.log.fail(' 3. git status --short');
|
|
178
|
+
fmt.log.fail(' 4. git stash drop');
|
|
179
|
+
} else {
|
|
180
|
+
fmt.log.fail('Stash restore failure type: file-collision');
|
|
181
|
+
if (collisionFiles.length > 0) {
|
|
182
|
+
collisionFiles.forEach(file => fmt.log.fail(` - ${file}`));
|
|
183
|
+
}
|
|
184
|
+
fmt.log.fail('Recovery steps:');
|
|
185
|
+
fmt.log.fail(` 1. git -C ${rootDir} stash show --name-only stash@{0}`);
|
|
186
|
+
if (collisionFiles.length > 0) {
|
|
187
|
+
collisionFiles.forEach(file => {
|
|
188
|
+
fmt.log.fail(` 2. mv ${path.join(rootDir, file)} ${path.join(rootDir, `${file}.pre-stash-pop`)}`);
|
|
189
|
+
});
|
|
190
|
+
fmt.log.fail(` 3. git -C ${rootDir} stash pop`);
|
|
191
|
+
} else {
|
|
192
|
+
fmt.log.fail(` 2. Inspect the latest stash-pop output and move or remove the colliding files in ${rootDir}`);
|
|
193
|
+
fmt.log.fail(` 3. git -C ${rootDir} stash pop`);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (output) {
|
|
198
|
+
fmt.log.fail('stash pop output:');
|
|
199
|
+
fmt.log.fail(output);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function printDiagnosticTable() {
|
|
204
|
+
fmt.log.info('\n--- Node sync-merged Diagnostic Table ---');
|
|
205
|
+
fmt.log.info('| Symptom | Root Cause | Fix Command |');
|
|
206
|
+
fmt.log.info('| :--- | :--- | :--- |');
|
|
207
|
+
SYNC_MERGED_DIAGNOSTICS.forEach(d => {
|
|
208
|
+
fmt.log.info(`| ${d.symptom} | ${d.cause} | ${d.fix} |`);
|
|
209
|
+
});
|
|
210
|
+
fmt.log.info('------------------------------------\n');
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function reportSyncMergedFailure(syncResult) {
|
|
214
|
+
const detail = syncResult.statusCode ? ` (${syncResult.error}: ${syncResult.statusCode})` : ` (${syncResult.error})`;
|
|
215
|
+
fmt.log.fail(`Forgejo sync-merged failed${detail}.`);
|
|
216
|
+
if (syncResult.raw) {
|
|
217
|
+
fmt.log.info('sync-merged raw output:');
|
|
218
|
+
fmt.log.plainError(syncResult.raw);
|
|
219
|
+
}
|
|
220
|
+
printDiagnosticTable();
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Integration gates support
|
|
224
|
+
const INTEGRATION_CONFIG_PATH = path.join(getPrimaryWorktree(), 'config', 'integration-pipelines.json');
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Detect which top-level areas have been modified in the mission branch vs primary branch
|
|
228
|
+
* Returns an array of area names (e.g., ['server', 'auth-server', 'web-client'])
|
|
229
|
+
*/
|
|
230
|
+
function detectChangedAreas(slug, { gitRunner = git, rootDir = getPrimaryWorktree(), baseBranch = null } = {}) {
|
|
231
|
+
const primaryBranch = baseBranch || getPrimaryBranch();
|
|
232
|
+
const branch = `mission/${slug}`;
|
|
233
|
+
|
|
234
|
+
// Get changed files between primary branch and mission branch
|
|
235
|
+
const diffResult = gitRunner(['-C', rootDir, 'diff', '--name-only', primaryBranch, branch, '--']);
|
|
236
|
+
|
|
237
|
+
if (diffResult.status !== 0 && !diffResult.stdout.trim()) {
|
|
238
|
+
// No changes or error - fall back to checking against HEAD in the worktree
|
|
239
|
+
const worktree = resolveWorktree(slug) || conventionalWorktreePath(slug, rootDir);
|
|
240
|
+
if (fs.existsSync(worktree)) {
|
|
241
|
+
const worktreeDiff = gitRunner(['-C', worktree, 'diff', '--name-only', primaryBranch, 'HEAD', '--']);
|
|
242
|
+
if (worktreeDiff.status === 0 && worktreeDiff.stdout.trim()) {
|
|
243
|
+
return parseFilesToAreas(worktreeDiff.stdout);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
return [];
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
return parseFilesToAreas(diffResult.stdout);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Parse a list of files (one per line) and extract the top-level area directories
|
|
254
|
+
*/
|
|
255
|
+
function parseFilesToAreas(filesOutput) {
|
|
256
|
+
const areas = new Set();
|
|
257
|
+
const knownAreas = ['server', 'auth-server', 'web-client', 'docs', 'workflow', 'android', 'kubernetes'];
|
|
258
|
+
|
|
259
|
+
filesOutput.split('\n').forEach(file => {
|
|
260
|
+
file = file.trim();
|
|
261
|
+
if (!file) return;
|
|
262
|
+
const topDir = file.split('/')[0];
|
|
263
|
+
if (knownAreas.includes(topDir)) {
|
|
264
|
+
areas.add(topDir);
|
|
265
|
+
}
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
return Array.from(areas);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Load integration pipelines config from repo-side file
|
|
273
|
+
*/
|
|
274
|
+
function loadIntegrationConfig({ configPath = INTEGRATION_CONFIG_PATH } = {}) {
|
|
275
|
+
if (!fs.existsSync(configPath) || !fs.statSync(configPath).size) {
|
|
276
|
+
return { ok: false, error: 'no config present' };
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
try {
|
|
280
|
+
const content = fs.readFileSync(configPath, 'utf8');
|
|
281
|
+
const config = JSON.parse(content);
|
|
282
|
+
return { ok: true, config };
|
|
283
|
+
} catch (error) {
|
|
284
|
+
return { ok: false, error: `invalid JSON: ${error.message}` };
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Get the integration gate plan for changed areas
|
|
290
|
+
* Returns { gates: [{key, command, run_last}], changedAreas: [...] }
|
|
291
|
+
*/
|
|
292
|
+
function getIntegrationGatePlan(slug, { runIntegrationGates = true, gitRunner = git, dryRun = false, configPath = INTEGRATION_CONFIG_PATH } = {}) {
|
|
293
|
+
// Check config
|
|
294
|
+
const configResult = loadIntegrationConfig({ configPath });
|
|
295
|
+
if (!configResult.ok) {
|
|
296
|
+
if (runIntegrationGates) {
|
|
297
|
+
fmt.log.info(`integration-gates: ${configResult.error}, skipping`);
|
|
298
|
+
}
|
|
299
|
+
return { gates: [], changedAreas: [], configError: configResult.error };
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const config = configResult.config;
|
|
303
|
+
if (!config.gates || Object.keys(config.gates).length === 0) {
|
|
304
|
+
if (runIntegrationGates) {
|
|
305
|
+
fmt.log.info('integration-gates: no gates defined in config, skipping');
|
|
306
|
+
}
|
|
307
|
+
return { gates: [], changedAreas: [], configError: 'no gates defined' };
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// Detect changed areas (always detect for dry-run; for real run, detect only if runIntegrationGates)
|
|
311
|
+
const changedAreas = (runIntegrationGates || dryRun) ? detectChangedAreas(slug, { gitRunner }) : [];
|
|
312
|
+
|
|
313
|
+
if (runIntegrationGates && !dryRun && changedAreas.length === 0) {
|
|
314
|
+
fmt.log.info('integration-gates: no area changes detected, skipping');
|
|
315
|
+
return { gates: [], changedAreas: [], configError: null };
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// Build list of gates to run, preserving order with run_last handling
|
|
319
|
+
const gateEntries = Object.entries(config.gates);
|
|
320
|
+
const gates = gateEntries
|
|
321
|
+
.map(([key, value]) => ({
|
|
322
|
+
key,
|
|
323
|
+
command: value.command,
|
|
324
|
+
order: value.order || 0,
|
|
325
|
+
run_last: value.run_last || false
|
|
326
|
+
}));
|
|
327
|
+
|
|
328
|
+
// Separate into run_last and non-run_last, sort each by order
|
|
329
|
+
const nonRunLast = gates.filter(g => !g.run_last).sort((a, b) => a.order - b.order);
|
|
330
|
+
const runLastGates = gates.filter(g => g.run_last).sort((a, b) => a.order - b.order);
|
|
331
|
+
|
|
332
|
+
// Combine: non-run_last first (by order), then run_last (by order)
|
|
333
|
+
const orderedGates = [...nonRunLast, ...runLastGates];
|
|
334
|
+
|
|
335
|
+
// Filter to only relevant gates (based on changed areas)
|
|
336
|
+
const relevantGates = [];
|
|
337
|
+
for (const gate of orderedGates) {
|
|
338
|
+
// Always include web-e2e if web-client changed
|
|
339
|
+
if (gate.key === 'web-e2e') {
|
|
340
|
+
if (changedAreas.includes('web-client') || changedAreas.length === 0) {
|
|
341
|
+
relevantGates.push(gate);
|
|
342
|
+
}
|
|
343
|
+
} else if (changedAreas.includes(gate.key) || changedAreas.length === 0) {
|
|
344
|
+
relevantGates.push(gate);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
return { gates: relevantGates, changedAreas, configError: null };
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Print the integration gate plan (for --dry-run)
|
|
353
|
+
*/
|
|
354
|
+
function printIntegrationGatePlan(gates) {
|
|
355
|
+
fmt.log.info('Integration gate plan:');
|
|
356
|
+
for (const gate of gates) {
|
|
357
|
+
fmt.log.info(` ${gate.key}: ${gate.command}`);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function buildIntegrationGateEnv(slug, {
|
|
362
|
+
dryRun = false,
|
|
363
|
+
processEnv = process.env,
|
|
364
|
+
gitRunner = git,
|
|
365
|
+
baseBranch = null,
|
|
366
|
+
baseWorktree = null
|
|
367
|
+
} = {}) {
|
|
368
|
+
const env = {
|
|
369
|
+
...processEnv,
|
|
370
|
+
INTEGRATE_DRY_RUN: dryRun ? 'true' : 'false',
|
|
371
|
+
INTEGRATE_CHANGED_AREAS: detectChangedAreas(slug, {
|
|
372
|
+
gitRunner,
|
|
373
|
+
baseBranch,
|
|
374
|
+
...(baseWorktree ? { rootDir: baseWorktree } : {})
|
|
375
|
+
}).join(' ')
|
|
376
|
+
};
|
|
377
|
+
|
|
378
|
+
// Integration must always use the repo-side config, but the changed-area set
|
|
379
|
+
// should be the one resolved for this mission branch, not rediscovered later.
|
|
380
|
+
delete env.INTEGRATION_CONFIG_PATH;
|
|
381
|
+
return env;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function resolveIntegrationVerificationWorktree(slug, {
|
|
385
|
+
baseWorktree = getPrimaryWorktree(),
|
|
386
|
+
resolveWorktreeFn = resolveWorktree,
|
|
387
|
+
conventionalWorktreePathFn = conventionalWorktreePath
|
|
388
|
+
} = {}) {
|
|
389
|
+
return resolveWorktreeFn(slug, { cwd: baseWorktree })
|
|
390
|
+
|| conventionalWorktreePathFn(slug, baseWorktree);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function buildIntegrationVerificationInvocation(slug, {
|
|
394
|
+
baseWorktree = getPrimaryWorktree(),
|
|
395
|
+
resolveWorktreeFn = resolveWorktree,
|
|
396
|
+
conventionalWorktreePathFn = conventionalWorktreePath,
|
|
397
|
+
formatVerificationCommandFn = formatVerificationCommand
|
|
398
|
+
} = {}) {
|
|
399
|
+
const cwd = resolveIntegrationVerificationWorktree(slug, {
|
|
400
|
+
baseWorktree,
|
|
401
|
+
resolveWorktreeFn,
|
|
402
|
+
conventionalWorktreePathFn
|
|
403
|
+
});
|
|
404
|
+
return {
|
|
405
|
+
command: formatVerificationCommandFn('integrate', cwd),
|
|
406
|
+
cwd
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Execute integration gates, aborting on first failure
|
|
412
|
+
* Returns { ok: boolean, failedGate: string|null, error: string|null }
|
|
413
|
+
*/
|
|
414
|
+
async function executeIntegrationGates(gates, { commandRunner = null, rootDir = getPrimaryWorktree() } = {}) {
|
|
415
|
+
const runner = commandRunner || ((cmd, args, options) => require('child_process').spawnSync(cmd, {
|
|
416
|
+
shell: true,
|
|
417
|
+
cwd: rootDir,
|
|
418
|
+
stdio: 'inherit'
|
|
419
|
+
}));
|
|
420
|
+
|
|
421
|
+
for (const gate of gates) {
|
|
422
|
+
fmt.log.info(`Running integration gate: ${gate.key}...`);
|
|
423
|
+
fmt.log.info(` Command: ${gate.command}`);
|
|
424
|
+
|
|
425
|
+
// Execute the command using the injected runner
|
|
426
|
+
const result = runner(gate.command, [], { cwd: rootDir, stdio: 'inherit' });
|
|
427
|
+
|
|
428
|
+
if (result.status !== 0) {
|
|
429
|
+
fmt.log.fail(`Integration gate failed: ${gate.key}`);
|
|
430
|
+
fmt.log.fail(` Command: ${gate.command}`);
|
|
431
|
+
fmt.log.fail(` Exit code: ${result.status}`);
|
|
432
|
+
return { ok: false, failedGate: gate.key, error: `Command exited with code ${result.status}` };
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
fmt.log.pass(`Integration gate passed: ${gate.key}`);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
return { ok: true, failedGate: null, error: null };
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
async function integrate(args) {
|
|
442
|
+
let exitCode = 0;
|
|
443
|
+
let temporaryStash = null;
|
|
444
|
+
let nextActionMessage = null;
|
|
445
|
+
const flags = args.filter(arg => arg.startsWith('--'));
|
|
446
|
+
const params = args.filter(arg => !arg.startsWith('--'));
|
|
447
|
+
const explicitSlug = params[0];
|
|
448
|
+
const slug = inferSlug(explicitSlug);
|
|
449
|
+
const dryRun = flags.includes('--dry-run');
|
|
450
|
+
const noIntegrationGates = flags.includes('--no-integration-gates');
|
|
451
|
+
|
|
452
|
+
if (process.env.FORGEJO_USER === 'gemini' || process.env.WORKFLOW_AGENT === 'gemini') {
|
|
453
|
+
fmt.log.fail('Gemini is not authorized to run integrate. Post a handoff comment on the PR and stop.');
|
|
454
|
+
process.exit(1);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
if (!slug) {
|
|
458
|
+
fmt.log.fail('Usage: px integrate [<slug>] [--dry-run] [--no-integration-gates]');
|
|
459
|
+
process.exit(1);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
if (flags.includes('--no-gate')) {
|
|
463
|
+
fmt.log.warn('integrate ignores --no-gate. The landed squash commit relies on the local git hooks for validation.');
|
|
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();
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
if (!noIntegrationGates) {
|
|
482
|
+
const verification = buildIntegrationVerificationInvocation(slug, { baseWorktree });
|
|
483
|
+
const env = buildIntegrationGateEnv(slug, { dryRun, baseBranch, baseWorktree });
|
|
484
|
+
|
|
485
|
+
if (dryRun) {
|
|
486
|
+
fmt.log.info('Running integration gates (dry-run)...');
|
|
487
|
+
} else {
|
|
488
|
+
fmt.log.info('Running integration gates...');
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
const result = child_process.spawnSync('bash', ['-lc', verification.command], {
|
|
492
|
+
cwd: verification.cwd,
|
|
493
|
+
env,
|
|
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.');
|
|
500
|
+
throw new IntegrationAbort();
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
if (!dryRun) {
|
|
504
|
+
fmt.log.pass('All integration gates passed.');
|
|
505
|
+
}
|
|
506
|
+
} else {
|
|
507
|
+
fmt.log.info('Integration gates skipped via --no-integration-gates flag');
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
if (dryRun) {
|
|
511
|
+
promoteTaskForIntegrationIfNeeded(context, { dryRun: true });
|
|
512
|
+
fmt.log.pass('\nDry run complete. Integration preflight passed.');
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
promoteTaskForIntegrationIfNeeded(context);
|
|
517
|
+
|
|
518
|
+
temporaryStash = stashMainCheckoutIfNeeded({
|
|
519
|
+
slug,
|
|
520
|
+
dirtyEntries: context.mainDirtyEntries,
|
|
521
|
+
rootDir: baseWorktree
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
// End-context check: if we are in the worktree that is about to be deleted,
|
|
525
|
+
// move the Node process to the base worktree to avoid being left in a ghost directory.
|
|
526
|
+
const missionWorktree = conventionalWorktreePath(slug);
|
|
527
|
+
if (process.cwd() === missionWorktree || process.cwd().startsWith(missionWorktree + '/')) {
|
|
528
|
+
fmt.log.info(`Moving process directory to ${baseWorktree} before mission worktree deletion.`);
|
|
529
|
+
process.chdir(baseWorktree);
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// Variant Selection
|
|
533
|
+
const branch = missionBranchName(slug, baseWorktree);
|
|
534
|
+
const mainTitle = missionTitle(slug) || slug;
|
|
535
|
+
const summary = mainTitle.replace(/\s+/g, ' ').trim();
|
|
536
|
+
const mainTaskFile = context.task.taskFile.replace(executionDir, baseWorktree);
|
|
537
|
+
const useVariantA = context.pr.merged;
|
|
538
|
+
fmt.log.info(`Selecting integration variant: ${useVariantA ? 'Variant A (fast-path)' : 'Variant B (full squash-merge)'}`);
|
|
539
|
+
|
|
540
|
+
if (useVariantA) {
|
|
541
|
+
fmt.log.info(`Variant A: already merged on Forgejo; syncing local ${baseBranch} in base worktree ${baseWorktree} for closeout.`);
|
|
542
|
+
const closeoutResult = finalizeVariantACloseout({
|
|
543
|
+
slug,
|
|
544
|
+
summary,
|
|
545
|
+
mainTaskFile,
|
|
546
|
+
rootDir: baseWorktree,
|
|
547
|
+
baseBranch
|
|
548
|
+
});
|
|
549
|
+
if (!closeoutResult.ok) {
|
|
550
|
+
fmt.log.fail(`Variant A closeout failed (${closeoutResult.error}).`);
|
|
551
|
+
if (closeoutResult.detail) {
|
|
552
|
+
fmt.log.fail(closeoutResult.detail);
|
|
553
|
+
}
|
|
554
|
+
if (closeoutResult.error === 'commit-failed') {
|
|
555
|
+
fmt.log.info(`The closeout commit runs the repo git hooks. Fix the reported hook failure in ${baseWorktree} and retry integrate.`);
|
|
556
|
+
fmt.log.info(`For this mission, the relevant verification command is ${formatVerificationCommand(context.area, baseWorktree)}`);
|
|
557
|
+
}
|
|
558
|
+
throw new IntegrationAbort();
|
|
559
|
+
}
|
|
560
|
+
nextActionMessage = `Next: cd ${baseWorktree}`;
|
|
561
|
+
recordPostIntegrationStatsOrAbort(slug, { rootDir: baseWorktree });
|
|
562
|
+
if (!cleanupMissionWorktree(slug)) {
|
|
563
|
+
fmt.log.fail('Mission worktree cleanup failed for Variant A.');
|
|
564
|
+
throw new IntegrationAbort();
|
|
565
|
+
}
|
|
566
|
+
maybeUpdateGraphifyOnPrimary(baseWorktree);
|
|
567
|
+
fmt.log.pass('Variant A integration completed.');
|
|
568
|
+
} else {
|
|
569
|
+
fmt.log.info(`\nStep 1: Using base worktree ${baseWorktree} on ${baseBranch} as the squash-merge target...`);
|
|
570
|
+
|
|
571
|
+
fmt.log.info(`Step 2: Checking merge conflicts against local ${baseBranch} in the base worktree...`);
|
|
572
|
+
const dryMerge = git(['-C', baseWorktree, 'merge', '--no-commit', '--no-ff', branch]);
|
|
573
|
+
const abortResult = git(['-C', baseWorktree, 'merge', '--abort']);
|
|
574
|
+
if (abortResult.status !== 0 && !isNoMergeToAbortResult(abortResult)) {
|
|
575
|
+
fmt.log.fail('Dry-run merge could not be aborted cleanly. Inspect the local integration checkout before retrying integrate.');
|
|
576
|
+
throw new IntegrationAbort();
|
|
577
|
+
}
|
|
578
|
+
if (dryMerge.status !== 0) {
|
|
579
|
+
// Before failing, check if a squash commit for this mission already landed on the primary branch
|
|
580
|
+
// from a previous partial integration run (e.g. sync-merged failed after the commit was created).
|
|
581
|
+
const existingSquash = findExistingSquashCommit(baseWorktree, slug);
|
|
582
|
+
if (existingSquash) {
|
|
583
|
+
fmt.log.warn(`Squash commit already exists on local ${baseBranch} from a previous partial integration (${existingSquash.slice(0, 12)}). Resuming from sync-merged step.`);
|
|
584
|
+
const mergedCommit = existingSquash;
|
|
585
|
+
if (isForgejoReviewEnabled(baseWorktree)) {
|
|
586
|
+
fmt.log.info('Step 6 (resume): Syncing merged state to Forgejo...');
|
|
587
|
+
const syncResult = syncMerged(branch, mergedCommit, {
|
|
588
|
+
rootDir: baseWorktree,
|
|
589
|
+
forgejoUser: context.forgejoUser,
|
|
590
|
+
token: context.forgejoToken
|
|
591
|
+
});
|
|
592
|
+
if (!syncResult.ok) {
|
|
593
|
+
reportSyncMergedFailure(syncResult);
|
|
594
|
+
throw new IntegrationAbort();
|
|
595
|
+
}
|
|
596
|
+
} else {
|
|
597
|
+
fmt.log.info('Step 6 (resume): Skipping Forgejo sync (review provider is not forgejo).');
|
|
598
|
+
}
|
|
599
|
+
nextActionMessage = `Next: cd ${baseWorktree}`;
|
|
600
|
+
recordPostIntegrationStatsOrAbort(slug, { rootDir: baseWorktree });
|
|
601
|
+
fmt.log.info('Step 7 (resume): Cleaning up the local mission worktree...');
|
|
602
|
+
if (!cleanupMissionWorktree(slug)) {
|
|
603
|
+
fmt.log.fail('Mission worktree cleanup failed.');
|
|
604
|
+
throw new IntegrationAbort();
|
|
605
|
+
}
|
|
606
|
+
maybeUpdateGraphifyOnPrimary(baseWorktree);
|
|
607
|
+
fmt.log.pass('Integration completed successfully (resumed from partial state).');
|
|
608
|
+
} else {
|
|
609
|
+
fmt.log.fail('Merge conflicts detected. Rebase the mission branch before integrating.');
|
|
610
|
+
const conflictOutput = [dryMerge.stdout, dryMerge.stderr].filter(Boolean).join('\n');
|
|
611
|
+
const conflictFiles = parseConflictFilesFromMergeOutput(conflictOutput);
|
|
612
|
+
if (conflictFiles.length > 0) {
|
|
613
|
+
fmt.log.info(`Conflicting files (${conflictFiles.length}):`);
|
|
614
|
+
conflictFiles.forEach(f => fmt.log.info(` - ${f}`));
|
|
615
|
+
}
|
|
616
|
+
fmt.log.info('Conflict helper path:');
|
|
617
|
+
for (const line of formatMatrixSummary(buildAutonomousReviewMatrix())) {
|
|
618
|
+
fmt.log.info(line);
|
|
619
|
+
}
|
|
620
|
+
for (const line of buildConflictResolutionPrompt(slug, context.area)) {
|
|
621
|
+
fmt.log.info(line);
|
|
622
|
+
}
|
|
623
|
+
throw new IntegrationAbort();
|
|
624
|
+
}
|
|
625
|
+
} else {
|
|
626
|
+
fmt.log.info('Step 3: Squash-merging the mission branch...');
|
|
627
|
+
let noisePatchState = null;
|
|
628
|
+
if (softResetTrailingBacklogNoise(baseWorktree, git)) {
|
|
629
|
+
noisePatchState = prepareNoisePatchForSquash(baseWorktree, { gitRunner: git });
|
|
630
|
+
if (!noisePatchState.ok) {
|
|
631
|
+
fmt.log.fail('Could not preserve trailing backlog noise before squash merge.');
|
|
632
|
+
if (noisePatchState.error) {
|
|
633
|
+
fmt.log.fail(noisePatchState.error);
|
|
634
|
+
}
|
|
635
|
+
throw new IntegrationAbort();
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
const squashResult = git(['-C', baseWorktree, 'merge', '--squash', branch]);
|
|
639
|
+
if (squashResult.status !== 0) {
|
|
640
|
+
noisePatchState?.cleanup?.();
|
|
641
|
+
fmt.log.fail('Squash merge failed.');
|
|
642
|
+
throw new IntegrationAbort();
|
|
643
|
+
}
|
|
644
|
+
if (noisePatchState?.patchPath) {
|
|
645
|
+
const restoreNoiseResult = restoreNoisePatchAfterSquash(baseWorktree, noisePatchState.patchPath, { gitRunner: git });
|
|
646
|
+
noisePatchState.cleanup();
|
|
647
|
+
if (!restoreNoiseResult.ok) {
|
|
648
|
+
fmt.log.fail('Could not restore trailing backlog noise after squash merge.');
|
|
649
|
+
if (restoreNoiseResult.error) {
|
|
650
|
+
fmt.log.fail(restoreNoiseResult.error);
|
|
651
|
+
}
|
|
652
|
+
throw new IntegrationAbort();
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
fmt.log.info('Step 4: Final closeout checks in the local integration checkout...');
|
|
657
|
+
if (fs.existsSync(mainTaskFile)) {
|
|
658
|
+
completeTask(slug, baseWorktree);
|
|
659
|
+
// Re-resolve because it moved
|
|
660
|
+
const updatedResolution = resolveTaskFile(slug, baseWorktree);
|
|
661
|
+
if (updatedResolution.ok) {
|
|
662
|
+
rewriteWorktreePaths(updatedResolution.taskFile, slug, { rootDir: baseWorktree });
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
git(['-C', baseWorktree, 'add', '-A']);
|
|
667
|
+
fmt.log.info('Step 5: Creating the landed squash commit in the local integration checkout...');
|
|
668
|
+
const commitResult = git([
|
|
669
|
+
'-C',
|
|
670
|
+
baseWorktree,
|
|
671
|
+
'commit',
|
|
672
|
+
'-m',
|
|
673
|
+
`${branch}: ${summary}`
|
|
674
|
+
]);
|
|
675
|
+
if (commitResult.status !== 0) {
|
|
676
|
+
const output = [commitResult.stdout, commitResult.stderr].filter(Boolean).join('\n').trim();
|
|
677
|
+
fmt.log.fail('Could not create the squash commit in the local integration checkout.');
|
|
678
|
+
if (output) {
|
|
679
|
+
fmt.log.fail(output);
|
|
680
|
+
}
|
|
681
|
+
fmt.log.info(`The squash commit runs the repo git hooks. Fix the reported hook failure in ${baseWorktree} and retry integrate.`);
|
|
682
|
+
fmt.log.info(`For this mission, the relevant verification command is ${formatVerificationCommand(context.area, baseWorktree)}`);
|
|
683
|
+
throw new IntegrationAbort();
|
|
684
|
+
}
|
|
685
|
+
const proofResult = verification.captureVerifiedTreeProof(context.area, baseWorktree, {
|
|
686
|
+
gitRunner: git,
|
|
687
|
+
runFn: child_process.spawnSync
|
|
688
|
+
});
|
|
689
|
+
if (!proofResult.ok) {
|
|
690
|
+
fmt.log.fail(`Could not verify the exact tree being published: ${proofResult.error}`);
|
|
691
|
+
throw new IntegrationAbort();
|
|
692
|
+
}
|
|
693
|
+
const proof = proofResult.proof;
|
|
694
|
+
const mergedCommit = git(['-C', baseWorktree, 'rev-parse', 'HEAD']).stdout.trim();
|
|
695
|
+
const proofCheck = verification.assertVerifiedTreeProof(proof, baseWorktree, { gitRunner: git });
|
|
696
|
+
if (!proofCheck.ok) {
|
|
697
|
+
fmt.log.fail(`Verification proof is stale for the publish tree: ${proofCheck.error}`);
|
|
698
|
+
throw new IntegrationAbort();
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
if (isForgejoReviewEnabled(baseWorktree)) {
|
|
702
|
+
fmt.log.info('Step 6: Syncing merged state to Forgejo...');
|
|
703
|
+
const syncResult = syncMerged(branch, mergedCommit, {
|
|
704
|
+
rootDir: baseWorktree,
|
|
705
|
+
forgejoUser: context.forgejoUser,
|
|
706
|
+
token: context.forgejoToken
|
|
707
|
+
});
|
|
708
|
+
if (!syncResult.ok) {
|
|
709
|
+
reportSyncMergedFailure(syncResult);
|
|
710
|
+
throw new IntegrationAbort();
|
|
711
|
+
}
|
|
712
|
+
} else {
|
|
713
|
+
fmt.log.info('Step 6: Skipping Forgejo sync (review provider is not forgejo).');
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
nextActionMessage = `Next: cd ${baseWorktree}`;
|
|
717
|
+
recordPostIntegrationStatsOrAbort(slug, { rootDir: baseWorktree });
|
|
718
|
+
fmt.log.info('Step 7: Cleaning up the local mission worktree...');
|
|
719
|
+
if (!cleanupMissionWorktree(slug)) {
|
|
720
|
+
fmt.log.fail('Mission worktree cleanup failed.');
|
|
721
|
+
throw new IntegrationAbort();
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
maybeUpdateGraphifyOnPrimary(baseWorktree);
|
|
725
|
+
fmt.log.pass('Integration completed successfully.');
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
} catch (error) {
|
|
729
|
+
if (error instanceof IntegrationAbort) {
|
|
730
|
+
exitCode = 1;
|
|
731
|
+
} else {
|
|
732
|
+
throw error;
|
|
733
|
+
}
|
|
734
|
+
} finally {
|
|
735
|
+
if (temporaryStash?.created) {
|
|
736
|
+
const restoreResult = restoreMainCheckoutStash(temporaryStash);
|
|
737
|
+
if (restoreResult.status !== 0) {
|
|
738
|
+
reportStashPopFailure(slug, restoreResult, { rootDir: temporaryStash.rootDir });
|
|
739
|
+
exitCode = 1;
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
if (nextActionMessage) {
|
|
744
|
+
fmt.log.info(`\n${nextActionMessage}`);
|
|
745
|
+
}
|
|
746
|
+
process.exit(exitCode);
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
function buildIntegrationContext(slug, {
|
|
751
|
+
baseBranch = null,
|
|
752
|
+
baseWorktree = null,
|
|
753
|
+
isForgejoReviewEnabledFn = isForgejoReviewEnabled
|
|
754
|
+
} = {}) {
|
|
755
|
+
const branch = `mission/${slug}`;
|
|
756
|
+
const currentBranch = getCurrentBranch();
|
|
757
|
+
const missionDir = findMissionDir(slug);
|
|
758
|
+
const area = missionDir ? findMissionArea(missionDir) : 'docs';
|
|
759
|
+
|
|
760
|
+
// The mission integrates back into its recorded base branch/worktree. When no
|
|
761
|
+
// base was recorded (every legacy mission) these resolve to the primary
|
|
762
|
+
// branch/worktree, so the rest of integration is byte-identical to today.
|
|
763
|
+
let resolvedBaseBranch = baseBranch;
|
|
764
|
+
if (!resolvedBaseBranch) {
|
|
765
|
+
try { resolvedBaseBranch = resolveMissionBaseBranch(slug, process.cwd()); } catch (_) { resolvedBaseBranch = getPrimaryBranch(); }
|
|
766
|
+
}
|
|
767
|
+
let resolvedBaseWorktree = baseWorktree;
|
|
768
|
+
if (!resolvedBaseWorktree) {
|
|
769
|
+
try { resolvedBaseWorktree = resolveBaseWorktree(slug, { rootDir: process.cwd() }); } catch (_) { resolvedBaseWorktree = getPrimaryWorktree(); }
|
|
770
|
+
}
|
|
771
|
+
let task = resolveTaskFile(slug);
|
|
772
|
+
if (!task.ok) {
|
|
773
|
+
const worktree = resolveWorktree(slug);
|
|
774
|
+
if (worktree) task = resolveTaskFile(slug, worktree);
|
|
775
|
+
}
|
|
776
|
+
let taskStatus = task.ok ? getTaskStatus(task.taskFile) : null;
|
|
777
|
+
if (task.ok && taskStatus === 'backlog') {
|
|
778
|
+
const worktree = resolveWorktree(slug);
|
|
779
|
+
if (worktree) {
|
|
780
|
+
const wtTask = resolveTaskFile(slug, worktree);
|
|
781
|
+
if (wtTask.ok) {
|
|
782
|
+
const wtStatus = getTaskStatus(wtTask.taskFile);
|
|
783
|
+
if (wtStatus && wtStatus !== 'backlog') {
|
|
784
|
+
task = wtTask;
|
|
785
|
+
taskStatus = wtStatus;
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
const taskAssignee = task.ok ? getTaskAssignee(task.taskFile) : null;
|
|
791
|
+
const forgejoEnabled = isForgejoReviewEnabledFn(resolvedBaseWorktree);
|
|
792
|
+
|
|
793
|
+
let forgejoIdentity = { forgejoUser: null, warning: null };
|
|
794
|
+
let forgejoToken = null;
|
|
795
|
+
let pr = { exists: false };
|
|
796
|
+
let siblingPrs = [];
|
|
797
|
+
let approval = { ok: false, error: 'forgejo-off', reviewState: null };
|
|
798
|
+
|
|
799
|
+
if (forgejoEnabled) {
|
|
800
|
+
forgejoIdentity = resolveForgejoUserForIntegration(taskAssignee);
|
|
801
|
+
forgejoToken = readToken(forgejoIdentity.forgejoUser);
|
|
802
|
+
pr = getPrStatus(branch, process.cwd(), {
|
|
803
|
+
forgejoUser: forgejoIdentity.forgejoUser,
|
|
804
|
+
token: forgejoToken
|
|
805
|
+
});
|
|
806
|
+
|
|
807
|
+
if (pr.exists && slug) {
|
|
808
|
+
const baseSlugMatch = slug.match(/^(task-\d+)/i);
|
|
809
|
+
const baseSlug = baseSlugMatch ? baseSlugMatch[1].toLowerCase() : slug;
|
|
810
|
+
if (forgejoToken) {
|
|
811
|
+
const allOpen = listOpenPrsForSlug(baseSlug, forgejoToken);
|
|
812
|
+
siblingPrs = allOpen.filter(p => p.head !== branch);
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
approval = pr.exists ? getLatestReviewDecision(branch, {
|
|
817
|
+
forgejoUser: forgejoIdentity.forgejoUser,
|
|
818
|
+
token: forgejoToken
|
|
819
|
+
}) : { ok: false, error: 'pr-missing', reviewState: null };
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
// Local review-state fallback: when forgejo token/API is unavailable but the
|
|
823
|
+
// mission's review-state.json shows approved, populate approval from local state
|
|
824
|
+
// so that integrate can proceed without a live Forgejo connection.
|
|
825
|
+
// Only applies when Forgejo was enabled but approval could not be obtained.
|
|
826
|
+
if (forgejoEnabled && !approval.ok) {
|
|
827
|
+
const localStateFallback = readReviewState(slug, resolvedBaseWorktree);
|
|
828
|
+
if (localStateFallback && localStateFallback.phase === 'approved' && localStateFallback.disposition === 'APPROVED') {
|
|
829
|
+
approval = { ok: true, reviewState: 'APPROVED', source: 'local-review-state' };
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
const mainBranchResult = git(['-C', resolvedBaseWorktree, 'branch', '--show-current']);
|
|
834
|
+
const mainBranch = mainBranchResult.stdout.trim();
|
|
835
|
+
const mainStatus = git(['-C', resolvedBaseWorktree, 'status', '--short']);
|
|
836
|
+
|
|
837
|
+
return {
|
|
838
|
+
slug,
|
|
839
|
+
branch,
|
|
840
|
+
currentBranch,
|
|
841
|
+
missionDir,
|
|
842
|
+
area,
|
|
843
|
+
task,
|
|
844
|
+
taskStatus,
|
|
845
|
+
taskAssignee,
|
|
846
|
+
forgejoUser: forgejoIdentity.forgejoUser,
|
|
847
|
+
forgejoToken,
|
|
848
|
+
taskAssigneeWarning: forgejoIdentity.warning,
|
|
849
|
+
pr,
|
|
850
|
+
siblingPrs,
|
|
851
|
+
approval,
|
|
852
|
+
baseBranch: resolvedBaseBranch,
|
|
853
|
+
baseWorktree: resolvedBaseWorktree,
|
|
854
|
+
mainBranch,
|
|
855
|
+
mainDirtyEntries: mainStatus.stdout.trim().length > 0
|
|
856
|
+
? mainStatus.stdout.trim().split('\n').filter(Boolean)
|
|
857
|
+
: [],
|
|
858
|
+
mainDirty: mainStatus.stdout.trim().length > 0
|
|
859
|
+
};
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
function evaluateTaskStatusForIntegration(context) {
|
|
863
|
+
const stateMapOptions = { rootDir: context.baseWorktree };
|
|
864
|
+
if (toVirtual(context.taskStatus, stateMapOptions) === 'approved') {
|
|
865
|
+
return {
|
|
866
|
+
ok: true,
|
|
867
|
+
level: 'pass',
|
|
868
|
+
message: `Backlog status: approved`
|
|
869
|
+
};
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
const reviewApproved = context.approval?.ok && context.approval.reviewState === 'APPROVED';
|
|
873
|
+
const defaultUserApproved = context.approval?.ok && context.approval.defaultUserApproved === true;
|
|
874
|
+
const localApproved = context.approval?.source === 'local-review-state';
|
|
875
|
+
const reviewCanProceed = context.taskStatus === 'review' && (context.pr?.merged || reviewApproved || localApproved);
|
|
876
|
+
const defaultUserOverride = context.taskStatus === 'review' && context.pr?.merged === false && defaultUserApproved && context.approval.reviewState !== 'APPROVED';
|
|
877
|
+
|
|
878
|
+
if (reviewCanProceed) {
|
|
879
|
+
let reason;
|
|
880
|
+
if (context.pr?.merged) {
|
|
881
|
+
reason = 'Forgejo PR already merged';
|
|
882
|
+
} else if (localApproved) {
|
|
883
|
+
reason = 'local review-state: approved';
|
|
884
|
+
} else {
|
|
885
|
+
reason = `latest formal review state is ${context.approval.reviewState}`;
|
|
886
|
+
}
|
|
887
|
+
return {
|
|
888
|
+
ok: true,
|
|
889
|
+
level: 'warn',
|
|
890
|
+
message: `Backlog status: review accepted for integration because ${reason}`
|
|
891
|
+
};
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
if (defaultUserOverride) {
|
|
895
|
+
return {
|
|
896
|
+
ok: true,
|
|
897
|
+
level: 'warn',
|
|
898
|
+
message: `Backlog status: default user approved for integration despite ${context.approval.reviewState}`
|
|
899
|
+
};
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
return {
|
|
903
|
+
ok: false,
|
|
904
|
+
level: 'fail',
|
|
905
|
+
message: `Backlog status: expected approved, or review with an approved/merged Forgejo PR; found ${toVirtual(context.taskStatus, stateMapOptions)}`
|
|
906
|
+
};
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
function promoteTaskForIntegrationIfNeeded(context, { dryRun = false } = {}) {
|
|
910
|
+
const taskStatusCheck = evaluateTaskStatusForIntegration(context);
|
|
911
|
+
const needsPromotion = context.task?.ok && context.taskStatus === 'review' && taskStatusCheck.ok;
|
|
912
|
+
|
|
913
|
+
if (!needsPromotion) {
|
|
914
|
+
return { changed: false, dryRun: false };
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
if (dryRun) {
|
|
918
|
+
fmt.log.info('Dry run: integration would promote Backlog status from review to approved because review is already fulfilled.');
|
|
919
|
+
return { changed: false, dryRun: true };
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
const stateMapOptions = { rootDir: context.baseWorktree };
|
|
923
|
+
if (!setTaskStatus(context.task.taskFile, toActual('approved', stateMapOptions))) {
|
|
924
|
+
fmt.log.fail('Could not promote the Backlog task to approved before integration.');
|
|
925
|
+
throw new IntegrationAbort();
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
context.taskStatus = toActual('approved', stateMapOptions);
|
|
929
|
+
fmt.log.info('Promoted Backlog status from review to approved because review is already fulfilled.');
|
|
930
|
+
return { changed: true, dryRun: false };
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
function printIntegrationPreflight(
|
|
934
|
+
context,
|
|
935
|
+
{
|
|
936
|
+
readTokenFn = readToken,
|
|
937
|
+
resolveTokenFileFn = resolveTokenFile,
|
|
938
|
+
getUnresolvedIndexConflictsFn = getUnresolvedIndexConflicts,
|
|
939
|
+
findMissionDocInBranchesFn = findMissionDocInBranches,
|
|
940
|
+
isForgejoReviewEnabledFn = isForgejoReviewEnabled,
|
|
941
|
+
log = fmt.log.plain
|
|
942
|
+
} = {}
|
|
943
|
+
) {
|
|
944
|
+
const failures = [];
|
|
945
|
+
const warnings = [];
|
|
946
|
+
|
|
947
|
+
// The integration "checkout" is the mission's base worktree on its base branch.
|
|
948
|
+
// For legacy missions these fall back to the primary worktree/branch, so the
|
|
949
|
+
// preflight output and checks are byte-identical to today.
|
|
950
|
+
const baseWorktree = context.baseWorktree || getPrimaryWorktree();
|
|
951
|
+
const baseBranch = context.baseBranch || getPrimaryBranch();
|
|
952
|
+
|
|
953
|
+
log(fmt.status('INFO', `Integration preflight for ${context.slug}`));
|
|
954
|
+
|
|
955
|
+
const branchPrefix = missionBranchName(context.slug, baseWorktree);
|
|
956
|
+
if (context.currentBranch === context.branch || context.currentBranch.startsWith(`${branchPrefix}-`)) {
|
|
957
|
+
log(fmt.status('PASS', `Mission branch: ${context.currentBranch}`));
|
|
958
|
+
} else {
|
|
959
|
+
failures.push('branch');
|
|
960
|
+
log(fmt.status('FAIL', `Mission branch: current branch is ${context.currentBranch}, expected ${context.branch} (or a branch with a suffix)`));
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
if (context.missionDir) {
|
|
964
|
+
log(fmt.status('PASS', `Mission doc: ${path.join(context.missionDir, 'MISSION.md')}`));
|
|
965
|
+
} else {
|
|
966
|
+
failures.push('mission-doc');
|
|
967
|
+
// Use the base slug (e.g. task-1054) for the canonical path even when the
|
|
968
|
+
// working slug carries a suffix (e.g. task-1054-modern).
|
|
969
|
+
const baseSlugMatch = context.slug.match(/^(task-\d+)/i);
|
|
970
|
+
const canonicalSlug = baseSlugMatch ? baseSlugMatch[1].toLowerCase() : context.slug;
|
|
971
|
+
const canonicalPath = path.relative(
|
|
972
|
+
baseWorktree,
|
|
973
|
+
path.join(missionDirForSlug(baseWorktree, canonicalSlug), 'MISSION.md')
|
|
974
|
+
).split(path.sep).join('/');
|
|
975
|
+
log(fmt.status('FAIL', `Mission doc: ${canonicalPath} not found`));
|
|
976
|
+
|
|
977
|
+
const candidates = findMissionDocInBranchesFn(context.slug, baseWorktree);
|
|
978
|
+
if (candidates && candidates.length > 0) {
|
|
979
|
+
log(fmt.status('INFO', `Found mission doc candidates on other branches. To recover, run:`));
|
|
980
|
+
candidates.forEach(c => {
|
|
981
|
+
log(fmt.status('INFO', ` git show ${c.branch}:${c.path} > ${canonicalPath}`));
|
|
982
|
+
});
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
if (context.task.ok) {
|
|
987
|
+
log(fmt.status('PASS', `Backlog task: ${path.basename(context.task.taskFile)} (${context.taskStatus})`));
|
|
988
|
+
|
|
989
|
+
try {
|
|
990
|
+
const { classification } = stats.resolveMissionClassification(context.slug);
|
|
991
|
+
log(fmt.status('PASS', `Backlog classification: ${classification}`));
|
|
992
|
+
} catch (error) {
|
|
993
|
+
failures.push('classification');
|
|
994
|
+
log(fmt.status('FAIL', `Backlog classification: ${error.message}`));
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
const taskStatusCheck = evaluateTaskStatusForIntegration(context);
|
|
998
|
+
if (!taskStatusCheck.ok) {
|
|
999
|
+
failures.push('task-status');
|
|
1000
|
+
log(fmt.status('FAIL', `${taskStatusCheck.message}`));
|
|
1001
|
+
} else if (taskStatusCheck.level === 'warn') {
|
|
1002
|
+
warnings.push('task-status-review-approved');
|
|
1003
|
+
log(fmt.status('WARN', `${taskStatusCheck.message}`));
|
|
1004
|
+
} else {
|
|
1005
|
+
log(fmt.status('PASS', `${taskStatusCheck.message}`));
|
|
1006
|
+
}
|
|
1007
|
+
} else if (context.task.reason === 'ambiguous') {
|
|
1008
|
+
failures.push('task-ambiguity');
|
|
1009
|
+
log(fmt.status('FAIL', `Backlog task: ambiguous slug ${context.slug}`));
|
|
1010
|
+
context.task.matches.forEach(match => log(` - ${match}`));
|
|
1011
|
+
} else {
|
|
1012
|
+
failures.push('task-missing');
|
|
1013
|
+
log(fmt.status('FAIL', `Backlog task: no task file found for ${context.slug}`));
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
if (isForgejoReviewEnabledFn(baseWorktree)) {
|
|
1017
|
+
const localApproved = context.approval?.source === 'local-review-state';
|
|
1018
|
+
const localApprovalFallback = localApproved;
|
|
1019
|
+
|
|
1020
|
+
if (context.pr.exists && context.pr.state === 'open' && !context.pr.merged) {
|
|
1021
|
+
log(fmt.status('PASS', `Forgejo PR: PR #${context.pr.number} open`));
|
|
1022
|
+
if (localApprovalFallback) {
|
|
1023
|
+
log(fmt.status('INFO', `Forgejo approval: token unavailable, approval sourced from local review-state.json (phase=approved)`));
|
|
1024
|
+
} else if (!context.approval.ok) {
|
|
1025
|
+
failures.push('pr-approval');
|
|
1026
|
+
log(fmt.status('FAIL', `Forgejo approval: could not verify an approved review (${context.approval.error})`));
|
|
1027
|
+
} else if (context.approval.reviewState !== 'APPROVED' && context.approval.defaultUserApproved !== true) {
|
|
1028
|
+
failures.push('pr-approval');
|
|
1029
|
+
log(fmt.status('FAIL', `Forgejo approval: latest formal review state is ${context.approval.reviewState || 'missing'}, expected APPROVED`));
|
|
1030
|
+
} else if (context.approval.reviewState !== 'APPROVED' && context.approval.defaultUserApproved === true) {
|
|
1031
|
+
warnings.push('pr-approval-default-user-override');
|
|
1032
|
+
log(fmt.status('WARN', `Forgejo approval: default user approved despite latest review state being ${context.approval.reviewState}`));
|
|
1033
|
+
} else {
|
|
1034
|
+
log(fmt.status('PASS', `Forgejo approval: latest formal review state is ${context.approval.reviewState}`));
|
|
1035
|
+
}
|
|
1036
|
+
} else if (context.pr.exists && context.pr.merged) {
|
|
1037
|
+
warnings.push('pr-merged');
|
|
1038
|
+
log(fmt.status('WARN', `Forgejo PR: PR #${context.pr.number} is already marked merged`));
|
|
1039
|
+
} else if (context.pr.exists) {
|
|
1040
|
+
failures.push('pr-state');
|
|
1041
|
+
log(fmt.status('FAIL', `Forgejo PR: unexpected state '${context.pr.state}'`));
|
|
1042
|
+
} else {
|
|
1043
|
+
failures.push('pr-missing');
|
|
1044
|
+
log(fmt.status('FAIL', `Forgejo PR: ${context.pr.raw || 'no PR found'}`));
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
if (context.siblingPrs && context.siblingPrs.length > 0) {
|
|
1048
|
+
warnings.push('sibling-prs');
|
|
1049
|
+
const baseSlugMatch = context.slug.match(/^(task-\d+)/i);
|
|
1050
|
+
const baseSlug = baseSlugMatch ? baseSlugMatch[1].toLowerCase() : context.slug;
|
|
1051
|
+
log(fmt.status('WARN', `Multiple open PRs detected for ${baseSlug}. Close stale PRs before integrating:`));
|
|
1052
|
+
context.siblingPrs.forEach(p => {
|
|
1053
|
+
log(fmt.status('INFO', ` - PR #${p.number} (${p.head}): ${p.html_url}`));
|
|
1054
|
+
});
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
const tokenPath = resolveTokenFileFn(context.forgejoUser);
|
|
1058
|
+
const token = readTokenFn(context.forgejoUser);
|
|
1059
|
+
if (token) {
|
|
1060
|
+
log(fmt.status('PASS', `Forgejo token: resolved for ${context.forgejoUser} (${tokenPath || 'env:FORGEJO_TOKEN'})`));
|
|
1061
|
+
} else if (localApprovalFallback) {
|
|
1062
|
+
log(fmt.status('INFO', `Forgejo token: no token file found for ${context.forgejoUser} (approval sourced from local review-state.json)`));
|
|
1063
|
+
} else {
|
|
1064
|
+
failures.push('forgejo-token');
|
|
1065
|
+
log(fmt.status('FAIL', `Forgejo token: no token file found for ${context.forgejoUser}`));
|
|
1066
|
+
}
|
|
1067
|
+
} else {
|
|
1068
|
+
log(fmt.status('INFO', 'Forgejo PR/approval checks skipped (review provider is not forgejo).'));
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
if (context.taskAssigneeWarning) {
|
|
1072
|
+
warnings.push('task-assignee');
|
|
1073
|
+
log(fmt.status('WARN', `${context.taskAssigneeWarning}`));
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
const expectedPrimaryBranch = baseBranch;
|
|
1077
|
+
if (context.mainBranch === expectedPrimaryBranch) {
|
|
1078
|
+
log(fmt.status('PASS', `Integration checkout branch: ${baseWorktree} is on ${expectedPrimaryBranch}`));
|
|
1079
|
+
} else {
|
|
1080
|
+
failures.push('main-branch');
|
|
1081
|
+
const branchLabel = context.mainBranch || '(detached HEAD)';
|
|
1082
|
+
log(fmt.status('FAIL', `Integration checkout branch: expected ${expectedPrimaryBranch}, found ${branchLabel}`));
|
|
1083
|
+
log(fmt.status('INFO', `Retry with: git -C ${baseWorktree} checkout ${expectedPrimaryBranch}`));
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
const indexConflicts = getUnresolvedIndexConflictsFn(baseWorktree);
|
|
1087
|
+
if (!indexConflicts.ok) {
|
|
1088
|
+
failures.push('main-index-conflict-check');
|
|
1089
|
+
log(fmt.status('FAIL', `Integration checkout conflict scan: could not inspect ${baseWorktree} (${indexConflicts.error || 'unknown error'})`));
|
|
1090
|
+
} else if (indexConflicts.files.length > 0) {
|
|
1091
|
+
failures.push('main-index-conflicts');
|
|
1092
|
+
log(fmt.status('FAIL', `Integration checkout conflicts: unresolved merge entries detected in ${baseWorktree}`));
|
|
1093
|
+
indexConflicts.files.forEach(file => log(fmt.status('INFO', ` - ${file}`)));
|
|
1094
|
+
log(fmt.status('INFO', 'Resolve each conflicted path, then drop the stale stash entry before retrying:'));
|
|
1095
|
+
indexConflicts.files.forEach(file => {
|
|
1096
|
+
log(fmt.status('INFO', ` git -C ${baseWorktree} rm "${file}"`));
|
|
1097
|
+
log(fmt.status('INFO', ` git -C ${baseWorktree} add "${file}"`));
|
|
1098
|
+
});
|
|
1099
|
+
log(fmt.status('INFO', ` git -C ${baseWorktree} stash drop`));
|
|
1100
|
+
log(fmt.status('INFO', `Retry with: px integrate ${context.slug} --dry-run`));
|
|
1101
|
+
} else {
|
|
1102
|
+
log(fmt.status('PASS', 'Integration checkout conflicts: no unresolved merge entries in the git index'));
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
if (context.mainDirty) {
|
|
1106
|
+
warnings.push('main-dirty');
|
|
1107
|
+
log(fmt.status('WARN', `Integration checkout dirty: ${baseWorktree} has uncommitted changes that will be stashed temporarily`));
|
|
1108
|
+
context.mainDirtyEntries.forEach(entry => log(fmt.status('INFO', ` - ${entry}`)));
|
|
1109
|
+
} else {
|
|
1110
|
+
log(fmt.status('PASS', 'Integration checkout dirty state: clean'));
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
const gitDir = path.join(process.cwd(), '.git');
|
|
1114
|
+
const isMainRepo = fs.existsSync(gitDir) && !fs.lstatSync(gitDir).isSymbolicLink();
|
|
1115
|
+
const isCorrectPath = process.cwd() === baseWorktree;
|
|
1116
|
+
if (isMainRepo && isCorrectPath) {
|
|
1117
|
+
log(fmt.status('PASS', 'Backlog context: resolves to main repository'));
|
|
1118
|
+
} else {
|
|
1119
|
+
warnings.push('backlog-context');
|
|
1120
|
+
log(fmt.status('WARN', `Backlog context: does not resolve to ${baseWorktree}. (Ignore if running from worktree to test dry-run; post-squash closeout still requires the local integration checkout).`));
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
log(fmt.status('INFO', 'Forgejo configuration: allow_manual_merge assumed enabled'));
|
|
1124
|
+
|
|
1125
|
+
if (warnings.length > 0) {
|
|
1126
|
+
log(fmt.status('WARN', `Integration warnings: ${warnings.join(', ')}`));
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
log(fmt.status('INFO', `${VARIANT_B_AUTOMATION_SUMMARY}`));
|
|
1130
|
+
|
|
1131
|
+
return { failures, warnings };
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
function rewriteWorktreePaths(taskFilePath, slug, options = {}) {
|
|
1135
|
+
const rootDir = options.rootDir || getPrimaryWorktree();
|
|
1136
|
+
const before = fs.readFileSync(taskFilePath, 'utf8');
|
|
1137
|
+
const updated = before.replace(
|
|
1138
|
+
new RegExp(`${conventionalWorktreePath(slug, rootDir)}`, 'g'),
|
|
1139
|
+
rootDir
|
|
1140
|
+
);
|
|
1141
|
+
|
|
1142
|
+
if (before !== updated) {
|
|
1143
|
+
fs.writeFileSync(taskFilePath, updated, 'utf8');
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
function stashMainCheckoutIfNeeded({
|
|
1148
|
+
slug,
|
|
1149
|
+
dirtyEntries = [],
|
|
1150
|
+
rootDir = getPrimaryWorktree(),
|
|
1151
|
+
gitRunner = git
|
|
1152
|
+
}) {
|
|
1153
|
+
if (dirtyEntries.length === 0) {
|
|
1154
|
+
return { created: false };
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
const message = `integrate:${slug}: temporary integration checkout stash`;
|
|
1158
|
+
fmt.log.info(`Stashing unrelated local integration checkout changes before integration: ${message}`);
|
|
1159
|
+
const result = gitRunner([
|
|
1160
|
+
'-C',
|
|
1161
|
+
rootDir,
|
|
1162
|
+
'stash',
|
|
1163
|
+
'push',
|
|
1164
|
+
'--include-untracked',
|
|
1165
|
+
'-m',
|
|
1166
|
+
message
|
|
1167
|
+
]);
|
|
1168
|
+
|
|
1169
|
+
if (result.status !== 0) {
|
|
1170
|
+
fmt.log.fail('Could not stash the unrelated local integration checkout changes.');
|
|
1171
|
+
throw new IntegrationAbort();
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
return {
|
|
1175
|
+
created: true,
|
|
1176
|
+
message,
|
|
1177
|
+
rootDir
|
|
1178
|
+
};
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
function restoreMainCheckoutStash({ message, rootDir = getPrimaryWorktree(), gitRunner = git }) {
|
|
1182
|
+
fmt.log.info(`Restoring temporarily stashed local integration checkout changes: ${message}`);
|
|
1183
|
+
// Pass cwd explicitly so spawnSync does not inherit the process cwd, which may have been
|
|
1184
|
+
// deleted by worktree cleanup earlier in the same integrate run.
|
|
1185
|
+
return gitRunner(['-C', rootDir, 'stash', 'pop'], { cwd: rootDir });
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
function isNoMergeToAbortResult(result) {
|
|
1189
|
+
const output = [result.stdout, result.stderr].filter(Boolean).join('\n');
|
|
1190
|
+
return /MERGE_HEAD missing|There is no merge to abort/i.test(output);
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
function recordPostIntegrationStats(
|
|
1194
|
+
slug,
|
|
1195
|
+
{
|
|
1196
|
+
rootDir = getPrimaryWorktree(),
|
|
1197
|
+
gitRunner = git,
|
|
1198
|
+
recordIntegrationStatsFn = stats.recordIntegrationStats,
|
|
1199
|
+
} = {}
|
|
1200
|
+
) {
|
|
1201
|
+
const dateResult = gitRunner(['-C', rootDir, 'log', '-1', '--format=%cs']);
|
|
1202
|
+
if (dateResult.status !== 0) {
|
|
1203
|
+
throw new Error(`Could not determine integration date for ${slug}.`);
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
const statsCsvPath = stats.resolveStatsFilePath(rootDir);
|
|
1207
|
+
|
|
1208
|
+
const outcome = recordIntegrationStatsFn({
|
|
1209
|
+
slug,
|
|
1210
|
+
rootDir,
|
|
1211
|
+
filePath: statsCsvPath,
|
|
1212
|
+
date: dateResult.stdout.trim(),
|
|
1213
|
+
});
|
|
1214
|
+
|
|
1215
|
+
fmt.log.info(`Workflow stats recorded: ${formatRecordedStatsRow(outcome.row)}`);
|
|
1216
|
+
fmt.log.info('Workflow stats updated:');
|
|
1217
|
+
fmt.log.plain(outcome.report);
|
|
1218
|
+
return outcome;
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
function recordPostIntegrationStatsOrAbort(slug, options = {}) {
|
|
1222
|
+
try {
|
|
1223
|
+
return recordPostIntegrationStats(slug, options);
|
|
1224
|
+
} catch (error) {
|
|
1225
|
+
const detail = error && error.message ? error.message : String(error);
|
|
1226
|
+
fmt.log.fail(`Post-integration workflow stats failed for ${slug}: ${detail}`);
|
|
1227
|
+
throw new IntegrationAbort();
|
|
1228
|
+
}
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
function finalizeVariantACloseout({
|
|
1232
|
+
slug,
|
|
1233
|
+
summary,
|
|
1234
|
+
mainTaskFile,
|
|
1235
|
+
rootDir = getPrimaryWorktree(),
|
|
1236
|
+
gitRunner = git,
|
|
1237
|
+
baseBranch = null,
|
|
1238
|
+
verificationArea = null,
|
|
1239
|
+
captureVerifiedTreeProofFn = verification.captureVerifiedTreeProof,
|
|
1240
|
+
assertVerifiedTreeProofFn = verification.assertVerifiedTreeProof
|
|
1241
|
+
}) {
|
|
1242
|
+
softResetTrailingBacklogNoise(rootDir, gitRunner);
|
|
1243
|
+
|
|
1244
|
+
if (mainTaskFile && fs.existsSync(mainTaskFile)) {
|
|
1245
|
+
completeTask(slug, rootDir);
|
|
1246
|
+
// Re-resolve because it moved
|
|
1247
|
+
const updatedResolution = resolveTaskFile(slug, rootDir);
|
|
1248
|
+
if (updatedResolution.ok) {
|
|
1249
|
+
rewriteWorktreePaths(updatedResolution.taskFile, slug, { rootDir });
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
gitRunner(['-C', rootDir, 'add', '-A']);
|
|
1254
|
+
|
|
1255
|
+
const stagedDiff = gitRunner(['-C', rootDir, 'diff', '--cached', '--quiet']);
|
|
1256
|
+
if (stagedDiff.status === 0) {
|
|
1257
|
+
return { ok: true, changed: false };
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
const commitResult = gitRunner([
|
|
1261
|
+
'-C',
|
|
1262
|
+
rootDir,
|
|
1263
|
+
'commit',
|
|
1264
|
+
'-m',
|
|
1265
|
+
`${missionBranchName(slug, rootDir)}: ${summary} integration closeout`
|
|
1266
|
+
]);
|
|
1267
|
+
if (commitResult.status !== 0) {
|
|
1268
|
+
return {
|
|
1269
|
+
ok: false,
|
|
1270
|
+
error: 'commit-failed',
|
|
1271
|
+
detail: [commitResult.stdout, commitResult.stderr].filter(Boolean).join('\n').trim()
|
|
1272
|
+
};
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
const resolvedVerificationArea = verificationArea || verification.resolveVerificationAdapter(rootDir).defaultArea;
|
|
1276
|
+
const proofResult = captureVerifiedTreeProofFn(resolvedVerificationArea, rootDir, {
|
|
1277
|
+
gitRunner,
|
|
1278
|
+
runFn: child_process.spawnSync
|
|
1279
|
+
});
|
|
1280
|
+
if (!proofResult.ok) {
|
|
1281
|
+
return {
|
|
1282
|
+
ok: false,
|
|
1283
|
+
error: 'verification-failed',
|
|
1284
|
+
detail: proofResult.error || 'failed to verify published tree'
|
|
1285
|
+
};
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
const proofCheck = assertVerifiedTreeProofFn(proofResult.proof, rootDir, { gitRunner });
|
|
1289
|
+
if (!proofCheck.ok) {
|
|
1290
|
+
return {
|
|
1291
|
+
ok: false,
|
|
1292
|
+
error: 'verification-proof-mismatch',
|
|
1293
|
+
detail: proofCheck.error || 'verification proof does not match the tree being published'
|
|
1294
|
+
};
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
const pushTarget = baseBranch || getPrimaryBranch(gitRunner);
|
|
1298
|
+
const pushResult = gitRunner(['-C', rootDir, 'push', 'review', pushTarget]);
|
|
1299
|
+
if (pushResult.status !== 0) {
|
|
1300
|
+
return {
|
|
1301
|
+
ok: false,
|
|
1302
|
+
error: 'push-primary-failed',
|
|
1303
|
+
detail: [pushResult.stdout, pushResult.stderr].filter(Boolean).join('\n').trim()
|
|
1304
|
+
};
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
return { ok: true, changed: true };
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
function cleanupMissionWorktree(
|
|
1311
|
+
slug,
|
|
1312
|
+
{
|
|
1313
|
+
rootDir = getPrimaryWorktree(),
|
|
1314
|
+
gitRunner = git,
|
|
1315
|
+
removeDir = target => {
|
|
1316
|
+
if (isForgejoPath(target, { forgejoHome: resolveForgejoHome() })) {
|
|
1317
|
+
throw new Error(`CRITICAL SAFETY VIOLATION: cleanupMissionWorktree attempted to delete Forgejo home: ${target}`);
|
|
1318
|
+
}
|
|
1319
|
+
return fs.rmSync(target, { recursive: true, force: true });
|
|
1320
|
+
},
|
|
1321
|
+
existsSync = fs.existsSync
|
|
1322
|
+
} = {}
|
|
1323
|
+
) {
|
|
1324
|
+
const branch = missionBranchName(slug, rootDir);
|
|
1325
|
+
const worktreePath = `${conventionalWorktreePath(slug, rootDir)}`;
|
|
1326
|
+
const currentBranch = gitRunner(['-C', rootDir, 'branch', '--show-current']).stdout.trim();
|
|
1327
|
+
if (currentBranch === branch) {
|
|
1328
|
+
return false;
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
const branchExists = gitRunner(['-C', rootDir, 'show-ref', '--verify', '--quiet', `refs/heads/${branch}`]);
|
|
1332
|
+
if (branchExists.status !== 0) {
|
|
1333
|
+
return false;
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
const worktreeList = gitRunner(['-C', rootDir, 'worktree', 'list', '--porcelain']).stdout;
|
|
1337
|
+
const isRegistered = worktreeList.split('\n').some(line => line.trim() === `worktree ${worktreePath}`);
|
|
1338
|
+
|
|
1339
|
+
if (existsSync(worktreePath) && isRegistered) {
|
|
1340
|
+
let removeResult = gitRunner(['-C', rootDir, 'worktree', 'remove', worktreePath]);
|
|
1341
|
+
if (removeResult.status !== 0) {
|
|
1342
|
+
removeResult = gitRunner(['-C', rootDir, 'worktree', 'remove', '--force', worktreePath]);
|
|
1343
|
+
if (removeResult.status !== 0) {
|
|
1344
|
+
return false;
|
|
1345
|
+
}
|
|
1346
|
+
}
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
if (existsSync(worktreePath)) {
|
|
1350
|
+
removeDir(worktreePath);
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
// Prune stale prunable worktrees — e.g. leftover registrations at near-match
|
|
1354
|
+
// paths like ${getPrimaryWorktree()}-<n> for the same mission branch —
|
|
1355
|
+
// before attempting branch deletion. Otherwise `git branch -D` refuses because
|
|
1356
|
+
// git still thinks the branch is checked out somewhere.
|
|
1357
|
+
gitRunner(['-C', rootDir, 'worktree', 'prune']);
|
|
1358
|
+
|
|
1359
|
+
const deleteBranchResult = gitRunner(['-C', rootDir, 'branch', '-D', branch]);
|
|
1360
|
+
if (deleteBranchResult.status !== 0) {
|
|
1361
|
+
return false;
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
return !existsSync(worktreePath);
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
function findExistingSquashCommit(rootDir, slug) {
|
|
1368
|
+
const result = git(['-C', rootDir, 'log', '--format=%H %s', '-50']);
|
|
1369
|
+
if (result.status !== 0) return null;
|
|
1370
|
+
const prefix = `${missionBranchName(slug, rootDir)}:`;
|
|
1371
|
+
for (const line of result.stdout.trim().split('\n')) {
|
|
1372
|
+
const spaceIdx = line.indexOf(' ');
|
|
1373
|
+
if (spaceIdx === -1) continue;
|
|
1374
|
+
const hash = line.slice(0, spaceIdx);
|
|
1375
|
+
const subject = line.slice(spaceIdx + 1);
|
|
1376
|
+
if (subject.startsWith(prefix)) return hash;
|
|
1377
|
+
}
|
|
1378
|
+
return null;
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
/**
|
|
1382
|
+
* Detect merge conflicts in the mission worktree and emit a bounded resolution plan.
|
|
1383
|
+
*
|
|
1384
|
+
* Categories:
|
|
1385
|
+
* - Mission-specific: the adapter-resolved mission directory and backlog/{tasks,completed}/ files matching the slug.
|
|
1386
|
+
* These can be resolved by taking the mission's own version (--theirs during rebase).
|
|
1387
|
+
* - Shared: everything else — warn only, do not auto-skip.
|
|
1388
|
+
*
|
|
1389
|
+
* @param {string} slug - Backlog task key (e.g. "task-108")
|
|
1390
|
+
* @param {string} area - Mission gate area passed to the configured verification command
|
|
1391
|
+
* @param {{ getConflictFilesFn?: Function }} [options] - Overrides for testing
|
|
1392
|
+
* @returns {{ ok: boolean, conflictFiles: string[], sharedFiles: string[], missionSpecificFiles: string[] }}
|
|
1393
|
+
*/
|
|
1394
|
+
function resolveConflictsForMission(slug, area, { getConflictFilesFn, resolveWorktreeFn = resolveWorktree, worktreePathOverride = null, rootDir = getPrimaryWorktree() } = {}) {
|
|
1395
|
+
// Resolve the actual attached worktree dynamically; fall back to the conventional
|
|
1396
|
+
// path only when git worktree list has no entry for this mission branch.
|
|
1397
|
+
const conventionalPath = `${conventionalWorktreePath(slug, rootDir)}`;
|
|
1398
|
+
const resolvedPath = worktreePathOverride || resolveWorktreeFn(slug);
|
|
1399
|
+
const worktreePath = resolvedPath || conventionalPath;
|
|
1400
|
+
if (!resolvedPath) {
|
|
1401
|
+
fmt.log.warn(`No registered worktree found for ${missionBranchName(slug, rootDir)}; falling back to conventional path.`);
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
const detectFn = getConflictFilesFn || ((_rootDir, _branch) => getConflictFiles(worktreePath, getPrimaryBranch()));
|
|
1405
|
+
|
|
1406
|
+
if (!fs.existsSync(worktreePath)) {
|
|
1407
|
+
fmt.log.fail(`Mission worktree not found: ${worktreePath}`);
|
|
1408
|
+
fmt.log.info(`Expected path: ${worktreePath}`);
|
|
1409
|
+
return { ok: false, error: 'worktree-missing', conflictFiles: [], sharedFiles: [], missionSpecificFiles: [], worktreePath };
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1412
|
+
fmt.log.info(`Detecting conflicts in ${worktreePath} against ${getPrimaryBranch()}...`);
|
|
1413
|
+
let conflictFiles;
|
|
1414
|
+
try {
|
|
1415
|
+
conflictFiles = detectFn(worktreePath, getPrimaryBranch());
|
|
1416
|
+
} catch (err) {
|
|
1417
|
+
fmt.log.fail('Merge check failed with a non-conflict error:');
|
|
1418
|
+
fmt.log.fail(err.message);
|
|
1419
|
+
return { ok: false, error: 'merge-failed', conflictFiles: [], sharedFiles: [], missionSpecificFiles: [], worktreePath };
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
if (conflictFiles.length === 0) {
|
|
1423
|
+
fmt.log.pass('No conflicts detected in mission worktree. Integration should proceed cleanly.');
|
|
1424
|
+
fmt.log.info(`Retry: px integrate ${slug} --dry-run`);
|
|
1425
|
+
return { ok: true, conflictFiles: [], sharedFiles: [], missionSpecificFiles: [], worktreePath };
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
fmt.log.info(`Conflicting files (${conflictFiles.length}):`);
|
|
1429
|
+
conflictFiles.forEach(f => fmt.log.info(` - ${f}`));
|
|
1430
|
+
|
|
1431
|
+
// Derive the mission-doc directory prefix from the actual located mission dir
|
|
1432
|
+
// so the classification works for non-standard doc paths (e.g. renamed year dir).
|
|
1433
|
+
const taskPattern = new RegExp(`backlog/(?:tasks|completed)/[^/]*${slug}`);
|
|
1434
|
+
const missionSpecificFiles = conflictFiles.filter(f =>
|
|
1435
|
+
isMissionArtifact(f, slug, worktreePath) || taskPattern.test(f)
|
|
1436
|
+
);
|
|
1437
|
+
const sharedFiles = conflictFiles.filter(f => !missionSpecificFiles.includes(f));
|
|
1438
|
+
const quotedWorktreePath = shellQuote(worktreePath);
|
|
1439
|
+
|
|
1440
|
+
if (sharedFiles.length > 0) {
|
|
1441
|
+
fmt.log.warn(`Conflicts in ${sharedFiles.length} shared file(s) require manual resolution:`);
|
|
1442
|
+
sharedFiles.forEach(f => fmt.log.plain(` - ${f}`));
|
|
1443
|
+
fmt.log.info('Manual resolution path:');
|
|
1444
|
+
fmt.log.info(` cd ${quotedWorktreePath}`);
|
|
1445
|
+
fmt.log.info(` git rebase ${getPrimaryBranch()}`);
|
|
1446
|
+
fmt.log.info(' # resolve each shared file conflict manually');
|
|
1447
|
+
fmt.log.info(' git add <resolved-files>');
|
|
1448
|
+
fmt.log.info(' git rebase --continue');
|
|
1449
|
+
fmt.log.info(` ${formatVerificationCommand(area, worktreePath)}`);
|
|
1450
|
+
fmt.log.info(` px integrate ${slug} --dry-run`);
|
|
1451
|
+
return { ok: false, error: 'shared-file-conflicts', conflictFiles, sharedFiles, missionSpecificFiles, worktreePath };
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
fmt.log.info(`All ${missionSpecificFiles.length} conflict(s) are in mission-specific files.`);
|
|
1455
|
+
fmt.log.info('Skip-all-conflicts path (use --theirs to keep the mission version):');
|
|
1456
|
+
fmt.log.info(` cd ${quotedWorktreePath}`);
|
|
1457
|
+
fmt.log.info(` git rebase ${getPrimaryBranch()}`);
|
|
1458
|
+
fmt.log.info(' # after the rebase pauses on conflicts:');
|
|
1459
|
+
missionSpecificFiles.forEach(f => fmt.log.info(` git checkout --theirs "${f}" && git add "${f}"`));
|
|
1460
|
+
fmt.log.info(' git rebase --continue');
|
|
1461
|
+
fmt.log.info(` ${formatVerificationCommand(area, worktreePath)}`);
|
|
1462
|
+
fmt.log.info(` px integrate ${slug} --dry-run`);
|
|
1463
|
+
|
|
1464
|
+
return { ok: true, conflictFiles, sharedFiles: [], missionSpecificFiles, worktreePath };
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1467
|
+
function buildConflictResolutionPrompt(slug = '<slug>', area = '<area>', options = {}) {
|
|
1468
|
+
const rootDir = options.rootDir || getPrimaryWorktree();
|
|
1469
|
+
const worktreePath = options.worktreePath || resolveWorktree(slug) || conventionalWorktreePath(slug, rootDir);
|
|
1470
|
+
const quotedWorktreePath = shellQuote(worktreePath);
|
|
1471
|
+
return [
|
|
1472
|
+
'Conflict resolution options:',
|
|
1473
|
+
'',
|
|
1474
|
+
'Option A — Agent-assisted (recommended):',
|
|
1475
|
+
' Copy/paste from the mission worktree:',
|
|
1476
|
+
` cd ${quotedWorktreePath} && px resolve-conflict ${slug}`,
|
|
1477
|
+
' (Detects conflict files, categorises mission-specific vs shared, and prints exact resolution commands.)',
|
|
1478
|
+
'',
|
|
1479
|
+
'Option B — Manual rebase path:',
|
|
1480
|
+
` 1. Stay in the mission worktree (${worktreePath}); do not resolve non-trivial conflicts inside ${rootDir}.`,
|
|
1481
|
+
` cd ${quotedWorktreePath}`,
|
|
1482
|
+
' git status --short',
|
|
1483
|
+
` 2. Rebase the mission branch onto the local ${getPrimaryBranch()} branch:`,
|
|
1484
|
+
` git fetch review ${getPrimaryBranch()}`,
|
|
1485
|
+
` git rebase ${getPrimaryBranch()}`,
|
|
1486
|
+
` 3. Re-run the mission gate: ${formatVerificationCommand(area, worktreePath)}`,
|
|
1487
|
+
` 4. Retry the dry-run integration preflight: px integrate ${slug} --dry-run`
|
|
1488
|
+
];
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1491
|
+
module.exports = integrate;
|
|
1492
|
+
module.exports.resolveConflictsForMission = resolveConflictsForMission;
|
|
1493
|
+
module.exports.cleanupMissionWorktree = cleanupMissionWorktree;
|
|
1494
|
+
module.exports.rewriteWorktreePaths = rewriteWorktreePaths;
|
|
1495
|
+
module.exports.finalizeVariantACloseout = finalizeVariantACloseout;
|
|
1496
|
+
module.exports.isNoMergeToAbortResult = isNoMergeToAbortResult;
|
|
1497
|
+
module.exports.buildConflictResolutionPrompt = buildConflictResolutionPrompt;
|
|
1498
|
+
module.exports.VARIANT_B_AUTOMATION_SUMMARY = VARIANT_B_AUTOMATION_SUMMARY;
|
|
1499
|
+
module.exports.stashMainCheckoutIfNeeded = stashMainCheckoutIfNeeded;
|
|
1500
|
+
module.exports.restoreMainCheckoutStash = restoreMainCheckoutStash;
|
|
1501
|
+
module.exports.getPrimaryWorktree = getPrimaryWorktree;
|
|
1502
|
+
module.exports.evaluateTaskStatusForIntegration = evaluateTaskStatusForIntegration;
|
|
1503
|
+
module.exports.promoteTaskForIntegrationIfNeeded = promoteTaskForIntegrationIfNeeded;
|
|
1504
|
+
module.exports.findExistingSquashCommit = findExistingSquashCommit;
|
|
1505
|
+
module.exports.printIntegrationPreflight = printIntegrationPreflight;
|
|
1506
|
+
module.exports.resolveForgejoUserForIntegration = resolveForgejoUserForIntegration;
|
|
1507
|
+
module.exports.getUnresolvedIndexConflicts = getUnresolvedIndexConflicts;
|
|
1508
|
+
module.exports.parseStashPopCollisionFiles = parseStashPopCollisionFiles;
|
|
1509
|
+
module.exports.reportStashPopFailure = reportStashPopFailure;
|
|
1510
|
+
module.exports.maybeUpdateGraphifyOnPrimary = maybeUpdateGraphifyOnPrimary;
|
|
1511
|
+
module.exports.SYNC_MERGED_DIAGNOSTICS = SYNC_MERGED_DIAGNOSTICS;
|
|
1512
|
+
module.exports.printDiagnosticTable = printDiagnosticTable;
|
|
1513
|
+
module.exports.reportSyncMergedFailure = reportSyncMergedFailure;
|
|
1514
|
+
module.exports.recordPostIntegrationStats = recordPostIntegrationStats;
|
|
1515
|
+
module.exports.recordPostIntegrationStatsOrAbort = recordPostIntegrationStatsOrAbort;
|
|
1516
|
+
module.exports.formatRecordedStatsRow = formatRecordedStatsRow;
|
|
1517
|
+
|
|
1518
|
+
// Integration gates exports
|
|
1519
|
+
module.exports.detectChangedAreas = detectChangedAreas;
|
|
1520
|
+
module.exports.parseFilesToAreas = parseFilesToAreas;
|
|
1521
|
+
module.exports.loadIntegrationConfig = loadIntegrationConfig;
|
|
1522
|
+
module.exports.getIntegrationGatePlan = getIntegrationGatePlan;
|
|
1523
|
+
module.exports.printIntegrationGatePlan = printIntegrationGatePlan;
|
|
1524
|
+
module.exports.buildIntegrationGateEnv = buildIntegrationGateEnv;
|
|
1525
|
+
module.exports.resolveIntegrationVerificationWorktree = resolveIntegrationVerificationWorktree;
|
|
1526
|
+
module.exports.buildIntegrationVerificationInvocation = buildIntegrationVerificationInvocation;
|
|
1527
|
+
module.exports.executeIntegrationGates = executeIntegrationGates;
|
|
1528
|
+
module.exports.buildIntegrationContext = buildIntegrationContext;
|