switchman-dev 0.1.3 → 0.1.4

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.
@@ -0,0 +1,225 @@
1
+ import { getMergeQueueItem, listMergeQueue, listTasks, listWorktrees, markMergeQueueState, startMergeQueueItem } from './db.js';
2
+ import { gitBranchExists, gitMergeBranchInto, gitRebaseOnto } from './git.js';
3
+ import { runAiMergeGate } from './merge-gate.js';
4
+ import { scanAllWorktrees } from './detector.js';
5
+
6
+ function describeQueueError(err) {
7
+ const message = String(err?.stderr || err?.message || err || '').trim();
8
+ if (/conflict/i.test(message)) {
9
+ return {
10
+ code: 'merge_conflict',
11
+ summary: message || 'Merge conflict blocked queue item.',
12
+ nextAction: 'Resolve the branch conflict manually, then run `switchman queue retry <itemId>`.',
13
+ retryable: true,
14
+ };
15
+ }
16
+
17
+ if (/not a valid object name|pathspec|did not match any file/i.test(message)) {
18
+ return {
19
+ code: 'source_missing',
20
+ summary: message || 'The queued source branch no longer exists.',
21
+ nextAction: 'Recreate the source branch or remove the queue item.',
22
+ retryable: false,
23
+ };
24
+ }
25
+
26
+ return {
27
+ code: 'merge_failed',
28
+ summary: message || 'Merge queue item failed.',
29
+ nextAction: 'Inspect the branch state, then retry or remove the queue item.',
30
+ retryable: true,
31
+ };
32
+ }
33
+
34
+ function scheduleRetryOrBlock(db, item, failure) {
35
+ const retriesUsed = Number(item.retry_count || 0);
36
+ const maxRetries = Number(item.max_retries || 0);
37
+ if (failure.retryable && retriesUsed < maxRetries) {
38
+ return {
39
+ status: 'retrying',
40
+ item: markMergeQueueState(db, item.id, {
41
+ status: 'retrying',
42
+ lastErrorCode: failure.code,
43
+ lastErrorSummary: failure.summary,
44
+ nextAction: `Retry ${retriesUsed + 1} of ${maxRetries} scheduled automatically. Run \`switchman queue run\` again after fixing any underlying branch drift if needed.`,
45
+ incrementRetry: true,
46
+ }),
47
+ };
48
+ }
49
+
50
+ return {
51
+ status: 'blocked',
52
+ item: markMergeQueueState(db, item.id, {
53
+ status: 'blocked',
54
+ lastErrorCode: failure.code,
55
+ lastErrorSummary: failure.summary,
56
+ nextAction: failure.nextAction.replace('<itemId>', item.id),
57
+ }),
58
+ };
59
+ }
60
+
61
+ async function evaluateQueueRepoGate(db, repoRoot) {
62
+ const report = await scanAllWorktrees(db, repoRoot);
63
+ const aiGate = await runAiMergeGate(db, repoRoot);
64
+ const ok = report.conflicts.length === 0
65
+ && report.fileConflicts.length === 0
66
+ && (report.ownershipConflicts?.length || 0) === 0
67
+ && (report.semanticConflicts?.length || 0) === 0
68
+ && report.unclaimedChanges.length === 0
69
+ && report.complianceSummary.non_compliant === 0
70
+ && report.complianceSummary.stale === 0
71
+ && aiGate.status !== 'blocked'
72
+ && (aiGate.dependency_invalidations?.filter((item) => item.severity === 'blocked').length || 0) === 0;
73
+
74
+ return {
75
+ ok,
76
+ summary: ok
77
+ ? `Repo gate passed for ${report.worktrees.length} worktree(s).`
78
+ : 'Repo gate rejected unmanaged changes, stale leases, ownership conflicts, stale dependency invalidations, or boundary validation failures.',
79
+ report,
80
+ aiGate,
81
+ };
82
+ }
83
+
84
+ export function resolveQueueSource(db, repoRoot, item) {
85
+ if (!item) {
86
+ throw new Error('Queue item is required.');
87
+ }
88
+
89
+ if (item.source_type === 'branch') {
90
+ return {
91
+ branch: item.source_ref,
92
+ worktree: item.source_worktree || null,
93
+ pipeline_id: item.source_pipeline_id || null,
94
+ };
95
+ }
96
+
97
+ if (item.source_type === 'worktree') {
98
+ const worktree = listWorktrees(db).find((entry) => entry.name === item.source_worktree || entry.name === item.source_ref);
99
+ if (!worktree) {
100
+ throw new Error(`Queued worktree ${item.source_worktree || item.source_ref} is not registered.`);
101
+ }
102
+ return {
103
+ branch: worktree.branch,
104
+ worktree: worktree.name,
105
+ worktree_path: worktree.path,
106
+ pipeline_id: item.source_pipeline_id || null,
107
+ };
108
+ }
109
+
110
+ if (item.source_type === 'pipeline') {
111
+ const tasks = listTasks(db).filter((task) => task.id.startsWith(`${item.source_pipeline_id || item.source_ref}-`));
112
+ const implementationTask = tasks.find((task) => task.worktree);
113
+ if (!implementationTask?.worktree) {
114
+ throw new Error(`Pipeline ${item.source_pipeline_id || item.source_ref} has no landed worktree branch to queue.`);
115
+ }
116
+ const worktree = listWorktrees(db).find((entry) => entry.name === implementationTask.worktree);
117
+ if (!worktree) {
118
+ throw new Error(`Queued pipeline worktree ${implementationTask.worktree} is not registered.`);
119
+ }
120
+ return {
121
+ branch: worktree.branch,
122
+ worktree: worktree.name,
123
+ worktree_path: worktree.path,
124
+ pipeline_id: item.source_pipeline_id || item.source_ref,
125
+ };
126
+ }
127
+
128
+ throw new Error(`Unsupported queue source type: ${item.source_type}`);
129
+ }
130
+
131
+ export function inferQueueNextAction(item) {
132
+ if (!item) return null;
133
+ if (item.status === 'blocked' && item.next_action) return item.next_action;
134
+ if (item.status === 'merged') return 'No action needed.';
135
+ return null;
136
+ }
137
+
138
+ export function buildQueueStatusSummary(items) {
139
+ const counts = {
140
+ queued: items.filter((item) => item.status === 'queued').length,
141
+ validating: items.filter((item) => item.status === 'validating').length,
142
+ rebasing: items.filter((item) => item.status === 'rebasing').length,
143
+ merging: items.filter((item) => item.status === 'merging').length,
144
+ retrying: items.filter((item) => item.status === 'retrying').length,
145
+ blocked: items.filter((item) => item.status === 'blocked').length,
146
+ merged: items.filter((item) => item.status === 'merged').length,
147
+ };
148
+
149
+ return {
150
+ counts,
151
+ next: items.find((item) => ['queued', 'retrying', 'validating', 'rebasing', 'merging'].includes(item.status)) || null,
152
+ blocked: items.filter((item) => item.status === 'blocked'),
153
+ };
154
+ }
155
+
156
+ export async function runNextQueueItem(db, repoRoot, { targetBranch = 'main' } = {}) {
157
+ const nextItem = listMergeQueue(db).find((item) => ['queued', 'retrying'].includes(item.status));
158
+ if (!nextItem) {
159
+ return { status: 'idle', item: null };
160
+ }
161
+
162
+ const started = startMergeQueueItem(db, nextItem.id);
163
+ if (!started) {
164
+ return { status: 'idle', item: null };
165
+ }
166
+
167
+ try {
168
+ const resolved = resolveQueueSource(db, repoRoot, started);
169
+ const queueTarget = started.target_branch || targetBranch;
170
+
171
+ if (!gitBranchExists(repoRoot, resolved.branch)) {
172
+ return scheduleRetryOrBlock(db, started, {
173
+ code: 'source_missing',
174
+ summary: `Source branch ${resolved.branch} does not exist.`,
175
+ nextAction: `Remove this queue item or recreate ${resolved.branch}, then run \`switchman queue retry ${started.id}\`.`,
176
+ retryable: false,
177
+ });
178
+ }
179
+
180
+ markMergeQueueState(db, started.id, { status: 'rebasing' });
181
+ gitRebaseOnto(resolved.worktree_path || repoRoot, queueTarget, resolved.branch);
182
+
183
+ const gate = await evaluateQueueRepoGate(db, repoRoot);
184
+ if (!gate.ok) {
185
+ return {
186
+ status: 'blocked',
187
+ item: markMergeQueueState(db, started.id, {
188
+ status: 'blocked',
189
+ lastErrorCode: 'gate_failed',
190
+ lastErrorSummary: gate.summary,
191
+ nextAction: `Run \`switchman gate ci\`, resolve the reported issues, then run \`switchman queue retry ${started.id}\`.`,
192
+ }),
193
+ };
194
+ }
195
+
196
+ markMergeQueueState(db, started.id, { status: 'merging' });
197
+ const mergedCommit = gitMergeBranchInto(repoRoot, queueTarget, resolved.branch);
198
+
199
+ return {
200
+ status: 'merged',
201
+ item: markMergeQueueState(db, started.id, {
202
+ status: 'merged',
203
+ mergedCommit,
204
+ }),
205
+ };
206
+ } catch (err) {
207
+ const failure = describeQueueError(err);
208
+ return scheduleRetryOrBlock(db, started, failure);
209
+ }
210
+ }
211
+
212
+ export async function runMergeQueue(db, repoRoot, { maxItems = 1, targetBranch = 'main' } = {}) {
213
+ const processed = [];
214
+ for (let count = 0; count < maxItems; count++) {
215
+ const result = await runNextQueueItem(db, repoRoot, { targetBranch });
216
+ if (!result.item) break;
217
+ processed.push(result);
218
+ if (result.status !== 'merged') break;
219
+ }
220
+
221
+ return {
222
+ processed,
223
+ summary: buildQueueStatusSummary(listMergeQueue(db)),
224
+ };
225
+ }