@renseiai/agentfactory 0.8.11 → 0.8.13
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/dist/src/config/repository-config.d.ts +24 -0
- package/dist/src/config/repository-config.d.ts.map +1 -1
- package/dist/src/config/repository-config.js +21 -0
- package/dist/src/config/repository-config.test.js +202 -0
- package/dist/src/governor/decision-engine.d.ts +2 -0
- package/dist/src/governor/decision-engine.d.ts.map +1 -1
- package/dist/src/governor/decision-engine.js +7 -0
- package/dist/src/governor/decision-engine.test.js +63 -0
- package/dist/src/governor/governor-types.d.ts +2 -1
- package/dist/src/governor/governor-types.d.ts.map +1 -1
- package/dist/src/index.d.ts +1 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +1 -0
- package/dist/src/merge-queue/conflict-resolver.d.ts +62 -0
- package/dist/src/merge-queue/conflict-resolver.d.ts.map +1 -0
- package/dist/src/merge-queue/conflict-resolver.js +168 -0
- package/dist/src/merge-queue/conflict-resolver.test.d.ts +2 -0
- package/dist/src/merge-queue/conflict-resolver.test.d.ts.map +1 -0
- package/dist/src/merge-queue/conflict-resolver.test.js +405 -0
- package/dist/src/merge-queue/lock-file-regeneration.d.ts +14 -0
- package/dist/src/merge-queue/lock-file-regeneration.d.ts.map +1 -0
- package/dist/src/merge-queue/lock-file-regeneration.js +82 -0
- package/dist/src/merge-queue/lock-file-regeneration.test.d.ts +2 -0
- package/dist/src/merge-queue/lock-file-regeneration.test.d.ts.map +1 -0
- package/dist/src/merge-queue/lock-file-regeneration.test.js +236 -0
- package/dist/src/merge-queue/merge-worker.d.ts +79 -0
- package/dist/src/merge-queue/merge-worker.d.ts.map +1 -0
- package/dist/src/merge-queue/merge-worker.js +221 -0
- package/dist/src/merge-queue/merge-worker.test.d.ts +2 -0
- package/dist/src/merge-queue/merge-worker.test.d.ts.map +1 -0
- package/dist/src/merge-queue/merge-worker.test.js +883 -0
- package/dist/src/merge-queue/strategies/index.d.ts +19 -0
- package/dist/src/merge-queue/strategies/index.d.ts.map +1 -0
- package/dist/src/merge-queue/strategies/index.js +30 -0
- package/dist/src/merge-queue/strategies/merge-commit-strategy.d.ts +14 -0
- package/dist/src/merge-queue/strategies/merge-commit-strategy.d.ts.map +1 -0
- package/dist/src/merge-queue/strategies/merge-commit-strategy.js +58 -0
- package/dist/src/merge-queue/strategies/rebase-strategy.d.ts +14 -0
- package/dist/src/merge-queue/strategies/rebase-strategy.d.ts.map +1 -0
- package/dist/src/merge-queue/strategies/rebase-strategy.js +62 -0
- package/dist/src/merge-queue/strategies/squash-strategy.d.ts +14 -0
- package/dist/src/merge-queue/strategies/squash-strategy.d.ts.map +1 -0
- package/dist/src/merge-queue/strategies/squash-strategy.js +59 -0
- package/dist/src/merge-queue/strategies/strategies.test.d.ts +2 -0
- package/dist/src/merge-queue/strategies/strategies.test.d.ts.map +1 -0
- package/dist/src/merge-queue/strategies/strategies.test.js +354 -0
- package/dist/src/merge-queue/strategies/types.d.ts +62 -0
- package/dist/src/merge-queue/strategies/types.d.ts.map +1 -0
- package/dist/src/merge-queue/strategies/types.js +7 -0
- package/dist/src/orchestrator/parse-work-result.d.ts.map +1 -1
- package/dist/src/orchestrator/parse-work-result.js +22 -0
- package/dist/src/orchestrator/parse-work-result.test.js +49 -0
- package/dist/src/providers/index.d.ts +1 -0
- package/dist/src/providers/index.d.ts.map +1 -1
- package/dist/src/providers/plugin-types.d.ts +177 -0
- package/dist/src/providers/plugin-types.d.ts.map +1 -0
- package/dist/src/providers/plugin-types.js +10 -0
- package/dist/src/providers/plugin-types.test.d.ts +2 -0
- package/dist/src/providers/plugin-types.test.d.ts.map +1 -0
- package/dist/src/providers/plugin-types.test.js +810 -0
- package/dist/src/registry/index.d.ts +4 -0
- package/dist/src/registry/index.d.ts.map +1 -0
- package/dist/src/registry/index.js +2 -0
- package/dist/src/registry/loader.d.ts +25 -0
- package/dist/src/registry/loader.d.ts.map +1 -0
- package/dist/src/registry/loader.js +88 -0
- package/dist/src/registry/node-type-registry.d.ts +52 -0
- package/dist/src/registry/node-type-registry.d.ts.map +1 -0
- package/dist/src/registry/node-type-registry.js +130 -0
- package/dist/src/registry/types.d.ts +65 -0
- package/dist/src/registry/types.d.ts.map +1 -0
- package/dist/src/registry/types.js +10 -0
- package/dist/src/workflow/expression/ast.d.ts +1 -1
- package/dist/src/workflow/expression/ast.d.ts.map +1 -1
- package/dist/src/workflow/expression/context.d.ts +4 -0
- package/dist/src/workflow/expression/context.d.ts.map +1 -1
- package/dist/src/workflow/expression/context.js +5 -1
- package/dist/src/workflow/expression/evaluator.d.ts.map +1 -1
- package/dist/src/workflow/expression/evaluator.js +24 -1
- package/dist/src/workflow/expression/evaluator.test.js +174 -0
- package/dist/src/workflow/expression/expression.test.js +140 -1
- package/dist/src/workflow/expression/helpers.d.ts +4 -0
- package/dist/src/workflow/expression/helpers.d.ts.map +1 -1
- package/dist/src/workflow/expression/helpers.js +51 -0
- package/dist/src/workflow/expression/index.d.ts +14 -0
- package/dist/src/workflow/expression/index.d.ts.map +1 -1
- package/dist/src/workflow/expression/index.js +28 -1
- package/dist/src/workflow/expression/lexer.d.ts.map +1 -1
- package/dist/src/workflow/expression/lexer.js +43 -0
- package/dist/src/workflow/expression/parser.js +1 -1
- package/dist/src/workflow/index.d.ts +3 -3
- package/dist/src/workflow/index.d.ts.map +1 -1
- package/dist/src/workflow/index.js +4 -2
- package/dist/src/workflow/workflow-loader.d.ts +8 -2
- package/dist/src/workflow/workflow-loader.d.ts.map +1 -1
- package/dist/src/workflow/workflow-loader.js +21 -2
- package/dist/src/workflow/workflow-types.d.ts +781 -12
- package/dist/src/workflow/workflow-types.d.ts.map +1 -1
- package/dist/src/workflow/workflow-types.js +248 -3
- package/dist/src/workflow/workflow-types.test.js +621 -1
- package/package.json +3 -2
|
@@ -0,0 +1,883 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
vi.mock('child_process', () => ({
|
|
3
|
+
exec: vi.fn(),
|
|
4
|
+
}));
|
|
5
|
+
vi.mock('./strategies/index.js', () => ({
|
|
6
|
+
createMergeStrategy: vi.fn(),
|
|
7
|
+
}));
|
|
8
|
+
vi.mock('./conflict-resolver.js', () => ({
|
|
9
|
+
ConflictResolver: vi.fn(),
|
|
10
|
+
}));
|
|
11
|
+
vi.mock('./lock-file-regeneration.js', () => ({
|
|
12
|
+
LockFileRegeneration: vi.fn(),
|
|
13
|
+
}));
|
|
14
|
+
import { exec } from 'child_process';
|
|
15
|
+
import { createMergeStrategy } from './strategies/index.js';
|
|
16
|
+
import { ConflictResolver } from './conflict-resolver.js';
|
|
17
|
+
import { LockFileRegeneration } from './lock-file-regeneration.js';
|
|
18
|
+
import { MergeWorker } from './merge-worker.js';
|
|
19
|
+
const mockExec = vi.mocked(exec);
|
|
20
|
+
const mockCreateMergeStrategy = vi.mocked(createMergeStrategy);
|
|
21
|
+
const MockConflictResolver = vi.mocked(ConflictResolver);
|
|
22
|
+
const MockLockFileRegeneration = vi.mocked(LockFileRegeneration);
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// Helpers
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
function makeConfig(overrides = {}) {
|
|
27
|
+
return {
|
|
28
|
+
repoId: 'repo-1',
|
|
29
|
+
repoPath: '/repo',
|
|
30
|
+
strategy: 'rebase',
|
|
31
|
+
testCommand: 'pnpm test',
|
|
32
|
+
testTimeout: 300_000,
|
|
33
|
+
lockFileRegenerate: true,
|
|
34
|
+
mergiraf: true,
|
|
35
|
+
pollInterval: 1000,
|
|
36
|
+
maxRetries: 2,
|
|
37
|
+
escalation: {
|
|
38
|
+
onConflict: 'reassign',
|
|
39
|
+
onTestFailure: 'notify',
|
|
40
|
+
},
|
|
41
|
+
deleteBranchOnMerge: true,
|
|
42
|
+
packageManager: 'pnpm',
|
|
43
|
+
remote: 'origin',
|
|
44
|
+
targetBranch: 'main',
|
|
45
|
+
...overrides,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
function makeDeps(overrides = {}) {
|
|
49
|
+
return {
|
|
50
|
+
storage: {
|
|
51
|
+
dequeue: vi.fn().mockResolvedValue(null),
|
|
52
|
+
markCompleted: vi.fn().mockResolvedValue(undefined),
|
|
53
|
+
markFailed: vi.fn().mockResolvedValue(undefined),
|
|
54
|
+
markBlocked: vi.fn().mockResolvedValue(undefined),
|
|
55
|
+
},
|
|
56
|
+
redis: {
|
|
57
|
+
setNX: vi.fn().mockResolvedValue(true),
|
|
58
|
+
del: vi.fn().mockResolvedValue(undefined),
|
|
59
|
+
get: vi.fn().mockResolvedValue(null),
|
|
60
|
+
set: vi.fn().mockResolvedValue(undefined),
|
|
61
|
+
expire: vi.fn().mockResolvedValue(undefined),
|
|
62
|
+
},
|
|
63
|
+
...overrides,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
function makeEntry(overrides = {}) {
|
|
67
|
+
return {
|
|
68
|
+
prNumber: 42,
|
|
69
|
+
sourceBranch: 'feature/SUP-100',
|
|
70
|
+
targetBranch: 'main',
|
|
71
|
+
issueIdentifier: 'SUP-100',
|
|
72
|
+
prUrl: 'https://github.com/org/repo/pull/42',
|
|
73
|
+
...overrides,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
function makeMockStrategy() {
|
|
77
|
+
return {
|
|
78
|
+
name: 'rebase',
|
|
79
|
+
prepare: vi.fn().mockResolvedValue({ success: true, headSha: 'abc123' }),
|
|
80
|
+
execute: vi.fn().mockResolvedValue({ status: 'success', mergedSha: 'def456' }),
|
|
81
|
+
finalize: vi.fn().mockResolvedValue(undefined),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
function mockExecSuccess() {
|
|
85
|
+
mockExec.mockImplementation((_cmd, _opts, callback) => {
|
|
86
|
+
const cb = typeof _opts === 'function' ? _opts : callback;
|
|
87
|
+
cb?.(null, { stdout: '', stderr: '' });
|
|
88
|
+
return {};
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
function mockExecFailure(errorMsg, stdout = '') {
|
|
92
|
+
mockExec.mockImplementation((_cmd, _opts, callback) => {
|
|
93
|
+
const cb = typeof _opts === 'function' ? _opts : callback;
|
|
94
|
+
const error = Object.assign(new Error(errorMsg), { stdout });
|
|
95
|
+
cb?.(error, { stdout, stderr: '' });
|
|
96
|
+
return {};
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Mock exec that routes by command substring.
|
|
101
|
+
*/
|
|
102
|
+
function mockExecByCommand(routes) {
|
|
103
|
+
mockExec.mockImplementation((cmd, _opts, callback) => {
|
|
104
|
+
const cb = typeof _opts === 'function' ? _opts : callback;
|
|
105
|
+
const cmdStr = typeof cmd === 'string' ? cmd : '';
|
|
106
|
+
const route = Object.entries(routes).find(([pattern]) => cmdStr.includes(pattern));
|
|
107
|
+
if (route) {
|
|
108
|
+
const [, response] = route;
|
|
109
|
+
if (response.error) {
|
|
110
|
+
cb?.(response.error, { stdout: response.stdout ?? '', stderr: response.stderr ?? '' });
|
|
111
|
+
}
|
|
112
|
+
else {
|
|
113
|
+
cb?.(null, { stdout: response.stdout ?? '', stderr: response.stderr ?? '' });
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
else {
|
|
117
|
+
cb?.(null, { stdout: '', stderr: '' });
|
|
118
|
+
}
|
|
119
|
+
return {};
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
// Tests
|
|
124
|
+
// ---------------------------------------------------------------------------
|
|
125
|
+
describe('MergeWorker', () => {
|
|
126
|
+
beforeEach(() => {
|
|
127
|
+
vi.clearAllMocks();
|
|
128
|
+
vi.useFakeTimers();
|
|
129
|
+
});
|
|
130
|
+
afterEach(() => {
|
|
131
|
+
vi.useRealTimers();
|
|
132
|
+
});
|
|
133
|
+
// -------------------------------------------------------------------------
|
|
134
|
+
// processEntry: successful merge flow
|
|
135
|
+
// -------------------------------------------------------------------------
|
|
136
|
+
describe('processEntry', () => {
|
|
137
|
+
it('successful merge flow: prepare -> execute -> test -> finalize', async () => {
|
|
138
|
+
vi.useRealTimers();
|
|
139
|
+
const config = makeConfig();
|
|
140
|
+
const deps = makeDeps();
|
|
141
|
+
const worker = new MergeWorker(config, deps);
|
|
142
|
+
const entry = makeEntry();
|
|
143
|
+
const mockStrategy = makeMockStrategy();
|
|
144
|
+
mockCreateMergeStrategy.mockReturnValue(mockStrategy);
|
|
145
|
+
MockConflictResolver.mockImplementation(() => ({
|
|
146
|
+
resolve: vi.fn(),
|
|
147
|
+
}));
|
|
148
|
+
MockLockFileRegeneration.mockImplementation(() => ({
|
|
149
|
+
shouldRegenerate: vi.fn().mockReturnValue(false),
|
|
150
|
+
regenerate: vi.fn(),
|
|
151
|
+
getLockFileName: vi.fn(),
|
|
152
|
+
ensureGitAttributes: vi.fn(),
|
|
153
|
+
}));
|
|
154
|
+
// Test command succeeds
|
|
155
|
+
mockExecSuccess();
|
|
156
|
+
const result = await worker.processEntry(entry);
|
|
157
|
+
expect(result.status).toBe('merged');
|
|
158
|
+
expect(result.prNumber).toBe(42);
|
|
159
|
+
expect(mockStrategy.prepare).toHaveBeenCalledOnce();
|
|
160
|
+
expect(mockStrategy.execute).toHaveBeenCalledOnce();
|
|
161
|
+
expect(mockStrategy.finalize).toHaveBeenCalledOnce();
|
|
162
|
+
// createMergeStrategy called with the configured strategy
|
|
163
|
+
expect(mockCreateMergeStrategy).toHaveBeenCalledWith('rebase');
|
|
164
|
+
});
|
|
165
|
+
it('prepare failure returns error', async () => {
|
|
166
|
+
vi.useRealTimers();
|
|
167
|
+
const config = makeConfig();
|
|
168
|
+
const deps = makeDeps();
|
|
169
|
+
const worker = new MergeWorker(config, deps);
|
|
170
|
+
const entry = makeEntry();
|
|
171
|
+
const mockStrategy = makeMockStrategy();
|
|
172
|
+
mockStrategy.prepare.mockResolvedValue({ success: false, error: 'Could not fetch remote' });
|
|
173
|
+
mockCreateMergeStrategy.mockReturnValue(mockStrategy);
|
|
174
|
+
MockConflictResolver.mockImplementation(() => ({ resolve: vi.fn() }));
|
|
175
|
+
MockLockFileRegeneration.mockImplementation(() => ({
|
|
176
|
+
shouldRegenerate: vi.fn().mockReturnValue(false),
|
|
177
|
+
regenerate: vi.fn(),
|
|
178
|
+
getLockFileName: vi.fn(),
|
|
179
|
+
ensureGitAttributes: vi.fn(),
|
|
180
|
+
}));
|
|
181
|
+
const result = await worker.processEntry(entry);
|
|
182
|
+
expect(result.status).toBe('error');
|
|
183
|
+
expect(result.message).toContain('Prepare failed');
|
|
184
|
+
expect(result.message).toContain('Could not fetch remote');
|
|
185
|
+
expect(mockStrategy.execute).not.toHaveBeenCalled();
|
|
186
|
+
expect(mockStrategy.finalize).not.toHaveBeenCalled();
|
|
187
|
+
});
|
|
188
|
+
it('merge conflict triggers ConflictResolver', async () => {
|
|
189
|
+
vi.useRealTimers();
|
|
190
|
+
const config = makeConfig();
|
|
191
|
+
const deps = makeDeps();
|
|
192
|
+
const worker = new MergeWorker(config, deps);
|
|
193
|
+
const entry = makeEntry();
|
|
194
|
+
const mockStrategy = makeMockStrategy();
|
|
195
|
+
mockStrategy.execute.mockResolvedValue({
|
|
196
|
+
status: 'conflict',
|
|
197
|
+
conflictFiles: ['src/index.ts'],
|
|
198
|
+
conflictDetails: 'Conflict in src/index.ts',
|
|
199
|
+
});
|
|
200
|
+
mockCreateMergeStrategy.mockReturnValue(mockStrategy);
|
|
201
|
+
const mockResolve = vi.fn().mockResolvedValue({
|
|
202
|
+
status: 'escalated',
|
|
203
|
+
method: 'escalation',
|
|
204
|
+
unresolvedFiles: ['src/index.ts'],
|
|
205
|
+
message: 'Conflict on PR #42',
|
|
206
|
+
});
|
|
207
|
+
MockConflictResolver.mockImplementation(() => ({
|
|
208
|
+
resolve: mockResolve,
|
|
209
|
+
}));
|
|
210
|
+
MockLockFileRegeneration.mockImplementation(() => ({
|
|
211
|
+
shouldRegenerate: vi.fn().mockReturnValue(false),
|
|
212
|
+
regenerate: vi.fn(),
|
|
213
|
+
getLockFileName: vi.fn(),
|
|
214
|
+
ensureGitAttributes: vi.fn(),
|
|
215
|
+
}));
|
|
216
|
+
const result = await worker.processEntry(entry);
|
|
217
|
+
expect(result.status).toBe('conflict');
|
|
218
|
+
expect(result.message).toBe('Conflict on PR #42');
|
|
219
|
+
expect(mockResolve).toHaveBeenCalledOnce();
|
|
220
|
+
// Verify the conflict context passed to the resolver
|
|
221
|
+
expect(mockResolve).toHaveBeenCalledWith(expect.objectContaining({
|
|
222
|
+
repoPath: '/repo',
|
|
223
|
+
sourceBranch: 'feature/SUP-100',
|
|
224
|
+
targetBranch: 'main',
|
|
225
|
+
prNumber: 42,
|
|
226
|
+
issueIdentifier: 'SUP-100',
|
|
227
|
+
conflictFiles: ['src/index.ts'],
|
|
228
|
+
}));
|
|
229
|
+
});
|
|
230
|
+
it('resolved conflict continues to test and finalize', async () => {
|
|
231
|
+
vi.useRealTimers();
|
|
232
|
+
const config = makeConfig();
|
|
233
|
+
const deps = makeDeps();
|
|
234
|
+
const worker = new MergeWorker(config, deps);
|
|
235
|
+
const entry = makeEntry();
|
|
236
|
+
const mockStrategy = makeMockStrategy();
|
|
237
|
+
mockStrategy.execute.mockResolvedValue({
|
|
238
|
+
status: 'conflict',
|
|
239
|
+
conflictFiles: ['src/index.ts'],
|
|
240
|
+
});
|
|
241
|
+
mockCreateMergeStrategy.mockReturnValue(mockStrategy);
|
|
242
|
+
const mockResolve = vi.fn().mockResolvedValue({
|
|
243
|
+
status: 'resolved',
|
|
244
|
+
method: 'mergiraf',
|
|
245
|
+
resolvedFiles: ['src/index.ts'],
|
|
246
|
+
});
|
|
247
|
+
MockConflictResolver.mockImplementation(() => ({
|
|
248
|
+
resolve: mockResolve,
|
|
249
|
+
}));
|
|
250
|
+
MockLockFileRegeneration.mockImplementation(() => ({
|
|
251
|
+
shouldRegenerate: vi.fn().mockReturnValue(false),
|
|
252
|
+
regenerate: vi.fn(),
|
|
253
|
+
getLockFileName: vi.fn(),
|
|
254
|
+
ensureGitAttributes: vi.fn(),
|
|
255
|
+
}));
|
|
256
|
+
// Test command succeeds
|
|
257
|
+
mockExecSuccess();
|
|
258
|
+
const result = await worker.processEntry(entry);
|
|
259
|
+
// Should continue past conflict resolution to test and finalize
|
|
260
|
+
expect(result.status).toBe('merged');
|
|
261
|
+
expect(mockStrategy.finalize).toHaveBeenCalledOnce();
|
|
262
|
+
});
|
|
263
|
+
it('unresolved conflict returns conflict status', async () => {
|
|
264
|
+
vi.useRealTimers();
|
|
265
|
+
const config = makeConfig();
|
|
266
|
+
const deps = makeDeps();
|
|
267
|
+
const worker = new MergeWorker(config, deps);
|
|
268
|
+
const entry = makeEntry();
|
|
269
|
+
const mockStrategy = makeMockStrategy();
|
|
270
|
+
mockStrategy.execute.mockResolvedValue({
|
|
271
|
+
status: 'conflict',
|
|
272
|
+
conflictFiles: ['src/a.ts', 'src/b.ts'],
|
|
273
|
+
});
|
|
274
|
+
mockCreateMergeStrategy.mockReturnValue(mockStrategy);
|
|
275
|
+
const mockResolve = vi.fn().mockResolvedValue({
|
|
276
|
+
status: 'parked',
|
|
277
|
+
method: 'escalation',
|
|
278
|
+
unresolvedFiles: ['src/a.ts', 'src/b.ts'],
|
|
279
|
+
message: 'Parked due to conflicts',
|
|
280
|
+
});
|
|
281
|
+
MockConflictResolver.mockImplementation(() => ({
|
|
282
|
+
resolve: mockResolve,
|
|
283
|
+
}));
|
|
284
|
+
MockLockFileRegeneration.mockImplementation(() => ({
|
|
285
|
+
shouldRegenerate: vi.fn().mockReturnValue(false),
|
|
286
|
+
regenerate: vi.fn(),
|
|
287
|
+
getLockFileName: vi.fn(),
|
|
288
|
+
ensureGitAttributes: vi.fn(),
|
|
289
|
+
}));
|
|
290
|
+
const result = await worker.processEntry(entry);
|
|
291
|
+
expect(result.status).toBe('conflict');
|
|
292
|
+
expect(result.message).toBe('Parked due to conflicts');
|
|
293
|
+
// Should NOT proceed to test or finalize
|
|
294
|
+
expect(mockStrategy.finalize).not.toHaveBeenCalled();
|
|
295
|
+
});
|
|
296
|
+
it('lock file regeneration runs when configured', async () => {
|
|
297
|
+
vi.useRealTimers();
|
|
298
|
+
const config = makeConfig({ lockFileRegenerate: true, packageManager: 'pnpm' });
|
|
299
|
+
const deps = makeDeps();
|
|
300
|
+
const worker = new MergeWorker(config, deps);
|
|
301
|
+
const entry = makeEntry();
|
|
302
|
+
const mockStrategy = makeMockStrategy();
|
|
303
|
+
mockCreateMergeStrategy.mockReturnValue(mockStrategy);
|
|
304
|
+
MockConflictResolver.mockImplementation(() => ({ resolve: vi.fn() }));
|
|
305
|
+
const mockShouldRegenerate = vi.fn().mockReturnValue(true);
|
|
306
|
+
const mockRegenerate = vi.fn().mockResolvedValue({ success: true, lockFile: 'pnpm-lock.yaml', packageManager: 'pnpm' });
|
|
307
|
+
MockLockFileRegeneration.mockImplementation(() => ({
|
|
308
|
+
shouldRegenerate: mockShouldRegenerate,
|
|
309
|
+
regenerate: mockRegenerate,
|
|
310
|
+
getLockFileName: vi.fn(),
|
|
311
|
+
ensureGitAttributes: vi.fn(),
|
|
312
|
+
}));
|
|
313
|
+
mockExecSuccess();
|
|
314
|
+
const result = await worker.processEntry(entry);
|
|
315
|
+
expect(result.status).toBe('merged');
|
|
316
|
+
expect(mockShouldRegenerate).toHaveBeenCalledWith('pnpm', true);
|
|
317
|
+
expect(mockRegenerate).toHaveBeenCalledWith('/repo', 'pnpm');
|
|
318
|
+
});
|
|
319
|
+
it('lock file regeneration failure returns error', async () => {
|
|
320
|
+
vi.useRealTimers();
|
|
321
|
+
const config = makeConfig({ lockFileRegenerate: true, packageManager: 'pnpm' });
|
|
322
|
+
const deps = makeDeps();
|
|
323
|
+
const worker = new MergeWorker(config, deps);
|
|
324
|
+
const entry = makeEntry();
|
|
325
|
+
const mockStrategy = makeMockStrategy();
|
|
326
|
+
mockCreateMergeStrategy.mockReturnValue(mockStrategy);
|
|
327
|
+
MockConflictResolver.mockImplementation(() => ({ resolve: vi.fn() }));
|
|
328
|
+
MockLockFileRegeneration.mockImplementation(() => ({
|
|
329
|
+
shouldRegenerate: vi.fn().mockReturnValue(true),
|
|
330
|
+
regenerate: vi.fn().mockResolvedValue({
|
|
331
|
+
success: false,
|
|
332
|
+
lockFile: 'pnpm-lock.yaml',
|
|
333
|
+
packageManager: 'pnpm',
|
|
334
|
+
error: 'pnpm install failed: ENOENT',
|
|
335
|
+
}),
|
|
336
|
+
getLockFileName: vi.fn(),
|
|
337
|
+
ensureGitAttributes: vi.fn(),
|
|
338
|
+
}));
|
|
339
|
+
const result = await worker.processEntry(entry);
|
|
340
|
+
expect(result.status).toBe('error');
|
|
341
|
+
expect(result.message).toContain('Lock file regeneration failed');
|
|
342
|
+
expect(result.message).toContain('pnpm install failed: ENOENT');
|
|
343
|
+
expect(mockStrategy.finalize).not.toHaveBeenCalled();
|
|
344
|
+
});
|
|
345
|
+
it('test failure returns test-failure status', async () => {
|
|
346
|
+
vi.useRealTimers();
|
|
347
|
+
const config = makeConfig();
|
|
348
|
+
const deps = makeDeps();
|
|
349
|
+
const worker = new MergeWorker(config, deps);
|
|
350
|
+
const entry = makeEntry();
|
|
351
|
+
const mockStrategy = makeMockStrategy();
|
|
352
|
+
mockCreateMergeStrategy.mockReturnValue(mockStrategy);
|
|
353
|
+
MockConflictResolver.mockImplementation(() => ({ resolve: vi.fn() }));
|
|
354
|
+
MockLockFileRegeneration.mockImplementation(() => ({
|
|
355
|
+
shouldRegenerate: vi.fn().mockReturnValue(false),
|
|
356
|
+
regenerate: vi.fn(),
|
|
357
|
+
getLockFileName: vi.fn(),
|
|
358
|
+
ensureGitAttributes: vi.fn(),
|
|
359
|
+
}));
|
|
360
|
+
// Test command fails
|
|
361
|
+
mockExecFailure('Test failed: 3 tests failed', 'FAIL src/index.test.ts');
|
|
362
|
+
const result = await worker.processEntry(entry);
|
|
363
|
+
expect(result.status).toBe('test-failure');
|
|
364
|
+
expect(result.message).toContain('FAIL src/index.test.ts');
|
|
365
|
+
expect(mockStrategy.finalize).not.toHaveBeenCalled();
|
|
366
|
+
});
|
|
367
|
+
it('test timeout handled', async () => {
|
|
368
|
+
vi.useRealTimers();
|
|
369
|
+
const config = makeConfig({ testTimeout: 5000 });
|
|
370
|
+
const deps = makeDeps();
|
|
371
|
+
const worker = new MergeWorker(config, deps);
|
|
372
|
+
const entry = makeEntry();
|
|
373
|
+
const mockStrategy = makeMockStrategy();
|
|
374
|
+
mockCreateMergeStrategy.mockReturnValue(mockStrategy);
|
|
375
|
+
MockConflictResolver.mockImplementation(() => ({ resolve: vi.fn() }));
|
|
376
|
+
MockLockFileRegeneration.mockImplementation(() => ({
|
|
377
|
+
shouldRegenerate: vi.fn().mockReturnValue(false),
|
|
378
|
+
regenerate: vi.fn(),
|
|
379
|
+
getLockFileName: vi.fn(),
|
|
380
|
+
ensureGitAttributes: vi.fn(),
|
|
381
|
+
}));
|
|
382
|
+
// Simulate timeout error — exec rejects with a timeout error
|
|
383
|
+
mockExec.mockImplementation((cmd, _opts, callback) => {
|
|
384
|
+
const cb = typeof _opts === 'function' ? _opts : callback;
|
|
385
|
+
const cmdStr = typeof cmd === 'string' ? cmd : '';
|
|
386
|
+
if (cmdStr.includes('pnpm test')) {
|
|
387
|
+
const error = Object.assign(new Error('Command timed out'), { killed: true, signal: 'SIGTERM' });
|
|
388
|
+
cb?.(error, { stdout: '', stderr: '' });
|
|
389
|
+
}
|
|
390
|
+
else {
|
|
391
|
+
cb?.(null, { stdout: '', stderr: '' });
|
|
392
|
+
}
|
|
393
|
+
return {};
|
|
394
|
+
});
|
|
395
|
+
const result = await worker.processEntry(entry);
|
|
396
|
+
expect(result.status).toBe('test-failure');
|
|
397
|
+
expect(result.message).toContain('Command timed out');
|
|
398
|
+
expect(mockStrategy.finalize).not.toHaveBeenCalled();
|
|
399
|
+
});
|
|
400
|
+
it('finalize pushes and merges', async () => {
|
|
401
|
+
vi.useRealTimers();
|
|
402
|
+
const config = makeConfig({ deleteBranchOnMerge: false });
|
|
403
|
+
const deps = makeDeps();
|
|
404
|
+
const worker = new MergeWorker(config, deps);
|
|
405
|
+
const entry = makeEntry();
|
|
406
|
+
const mockStrategy = makeMockStrategy();
|
|
407
|
+
mockCreateMergeStrategy.mockReturnValue(mockStrategy);
|
|
408
|
+
MockConflictResolver.mockImplementation(() => ({ resolve: vi.fn() }));
|
|
409
|
+
MockLockFileRegeneration.mockImplementation(() => ({
|
|
410
|
+
shouldRegenerate: vi.fn().mockReturnValue(false),
|
|
411
|
+
regenerate: vi.fn(),
|
|
412
|
+
getLockFileName: vi.fn(),
|
|
413
|
+
ensureGitAttributes: vi.fn(),
|
|
414
|
+
}));
|
|
415
|
+
mockExecSuccess();
|
|
416
|
+
const result = await worker.processEntry(entry);
|
|
417
|
+
expect(result.status).toBe('merged');
|
|
418
|
+
expect(mockStrategy.finalize).toHaveBeenCalledWith(expect.objectContaining({
|
|
419
|
+
repoPath: '/repo',
|
|
420
|
+
sourceBranch: 'feature/SUP-100',
|
|
421
|
+
targetBranch: 'main',
|
|
422
|
+
remote: 'origin',
|
|
423
|
+
}));
|
|
424
|
+
});
|
|
425
|
+
it('branch deletion when configured', async () => {
|
|
426
|
+
vi.useRealTimers();
|
|
427
|
+
const config = makeConfig({ deleteBranchOnMerge: true });
|
|
428
|
+
const deps = makeDeps();
|
|
429
|
+
const worker = new MergeWorker(config, deps);
|
|
430
|
+
const entry = makeEntry();
|
|
431
|
+
const mockStrategy = makeMockStrategy();
|
|
432
|
+
mockCreateMergeStrategy.mockReturnValue(mockStrategy);
|
|
433
|
+
MockConflictResolver.mockImplementation(() => ({ resolve: vi.fn() }));
|
|
434
|
+
MockLockFileRegeneration.mockImplementation(() => ({
|
|
435
|
+
shouldRegenerate: vi.fn().mockReturnValue(false),
|
|
436
|
+
regenerate: vi.fn(),
|
|
437
|
+
getLockFileName: vi.fn(),
|
|
438
|
+
ensureGitAttributes: vi.fn(),
|
|
439
|
+
}));
|
|
440
|
+
mockExecByCommand({
|
|
441
|
+
'git push origin --delete feature/SUP-100': { stdout: '' },
|
|
442
|
+
'pnpm test': { stdout: 'All tests passed' },
|
|
443
|
+
});
|
|
444
|
+
const result = await worker.processEntry(entry);
|
|
445
|
+
expect(result.status).toBe('merged');
|
|
446
|
+
// Verify git push --delete was called
|
|
447
|
+
const deleteCalls = mockExec.mock.calls.filter((call) => typeof call[0] === 'string' && call[0].includes('git push') && call[0].includes('--delete'));
|
|
448
|
+
expect(deleteCalls).toHaveLength(1);
|
|
449
|
+
expect(deleteCalls[0][0]).toContain('feature/SUP-100');
|
|
450
|
+
});
|
|
451
|
+
it('branch deletion failure is non-fatal', async () => {
|
|
452
|
+
vi.useRealTimers();
|
|
453
|
+
const config = makeConfig({ deleteBranchOnMerge: true });
|
|
454
|
+
const deps = makeDeps();
|
|
455
|
+
const worker = new MergeWorker(config, deps);
|
|
456
|
+
const entry = makeEntry();
|
|
457
|
+
const mockStrategy = makeMockStrategy();
|
|
458
|
+
mockCreateMergeStrategy.mockReturnValue(mockStrategy);
|
|
459
|
+
MockConflictResolver.mockImplementation(() => ({ resolve: vi.fn() }));
|
|
460
|
+
MockLockFileRegeneration.mockImplementation(() => ({
|
|
461
|
+
shouldRegenerate: vi.fn().mockReturnValue(false),
|
|
462
|
+
regenerate: vi.fn(),
|
|
463
|
+
getLockFileName: vi.fn(),
|
|
464
|
+
ensureGitAttributes: vi.fn(),
|
|
465
|
+
}));
|
|
466
|
+
// Branch deletion fails but test passes
|
|
467
|
+
mockExecByCommand({
|
|
468
|
+
'git push origin --delete': { error: new Error('remote ref does not exist') },
|
|
469
|
+
'pnpm test': { stdout: 'All tests passed' },
|
|
470
|
+
});
|
|
471
|
+
const result = await worker.processEntry(entry);
|
|
472
|
+
// Should still succeed despite branch deletion failure
|
|
473
|
+
expect(result.status).toBe('merged');
|
|
474
|
+
});
|
|
475
|
+
it('merge strategy error returns error status', async () => {
|
|
476
|
+
vi.useRealTimers();
|
|
477
|
+
const config = makeConfig();
|
|
478
|
+
const deps = makeDeps();
|
|
479
|
+
const worker = new MergeWorker(config, deps);
|
|
480
|
+
const entry = makeEntry();
|
|
481
|
+
const mockStrategy = makeMockStrategy();
|
|
482
|
+
mockStrategy.execute.mockResolvedValue({
|
|
483
|
+
status: 'error',
|
|
484
|
+
error: 'Rebase failed: divergent branches',
|
|
485
|
+
});
|
|
486
|
+
mockCreateMergeStrategy.mockReturnValue(mockStrategy);
|
|
487
|
+
MockConflictResolver.mockImplementation(() => ({ resolve: vi.fn() }));
|
|
488
|
+
MockLockFileRegeneration.mockImplementation(() => ({
|
|
489
|
+
shouldRegenerate: vi.fn().mockReturnValue(false),
|
|
490
|
+
regenerate: vi.fn(),
|
|
491
|
+
getLockFileName: vi.fn(),
|
|
492
|
+
ensureGitAttributes: vi.fn(),
|
|
493
|
+
}));
|
|
494
|
+
const result = await worker.processEntry(entry);
|
|
495
|
+
expect(result.status).toBe('error');
|
|
496
|
+
expect(result.message).toBe('Rebase failed: divergent branches');
|
|
497
|
+
expect(mockStrategy.finalize).not.toHaveBeenCalled();
|
|
498
|
+
});
|
|
499
|
+
it('uses entry.issueIdentifier when provided', async () => {
|
|
500
|
+
vi.useRealTimers();
|
|
501
|
+
const config = makeConfig();
|
|
502
|
+
const deps = makeDeps();
|
|
503
|
+
const worker = new MergeWorker(config, deps);
|
|
504
|
+
const entry = makeEntry({ issueIdentifier: 'SUP-200' });
|
|
505
|
+
const mockStrategy = makeMockStrategy();
|
|
506
|
+
mockStrategy.execute.mockResolvedValue({
|
|
507
|
+
status: 'conflict',
|
|
508
|
+
conflictFiles: ['file.ts'],
|
|
509
|
+
});
|
|
510
|
+
mockCreateMergeStrategy.mockReturnValue(mockStrategy);
|
|
511
|
+
const mockResolve = vi.fn().mockResolvedValue({
|
|
512
|
+
status: 'escalated',
|
|
513
|
+
method: 'escalation',
|
|
514
|
+
unresolvedFiles: ['file.ts'],
|
|
515
|
+
message: 'Conflict',
|
|
516
|
+
});
|
|
517
|
+
MockConflictResolver.mockImplementation(() => ({ resolve: mockResolve }));
|
|
518
|
+
MockLockFileRegeneration.mockImplementation(() => ({
|
|
519
|
+
shouldRegenerate: vi.fn().mockReturnValue(false),
|
|
520
|
+
regenerate: vi.fn(),
|
|
521
|
+
getLockFileName: vi.fn(),
|
|
522
|
+
ensureGitAttributes: vi.fn(),
|
|
523
|
+
}));
|
|
524
|
+
await worker.processEntry(entry);
|
|
525
|
+
expect(mockResolve).toHaveBeenCalledWith(expect.objectContaining({ issueIdentifier: 'SUP-200' }));
|
|
526
|
+
});
|
|
527
|
+
it('defaults issueIdentifier to PR-{prNumber} when not provided', async () => {
|
|
528
|
+
vi.useRealTimers();
|
|
529
|
+
const config = makeConfig();
|
|
530
|
+
const deps = makeDeps();
|
|
531
|
+
const worker = new MergeWorker(config, deps);
|
|
532
|
+
const entry = { prNumber: 99, sourceBranch: 'feature/test' };
|
|
533
|
+
const mockStrategy = makeMockStrategy();
|
|
534
|
+
mockStrategy.execute.mockResolvedValue({
|
|
535
|
+
status: 'conflict',
|
|
536
|
+
conflictFiles: ['file.ts'],
|
|
537
|
+
});
|
|
538
|
+
mockCreateMergeStrategy.mockReturnValue(mockStrategy);
|
|
539
|
+
const mockResolve = vi.fn().mockResolvedValue({
|
|
540
|
+
status: 'escalated',
|
|
541
|
+
method: 'escalation',
|
|
542
|
+
unresolvedFiles: ['file.ts'],
|
|
543
|
+
message: 'Conflict',
|
|
544
|
+
});
|
|
545
|
+
MockConflictResolver.mockImplementation(() => ({ resolve: mockResolve }));
|
|
546
|
+
MockLockFileRegeneration.mockImplementation(() => ({
|
|
547
|
+
shouldRegenerate: vi.fn().mockReturnValue(false),
|
|
548
|
+
regenerate: vi.fn(),
|
|
549
|
+
getLockFileName: vi.fn(),
|
|
550
|
+
ensureGitAttributes: vi.fn(),
|
|
551
|
+
}));
|
|
552
|
+
await worker.processEntry(entry);
|
|
553
|
+
expect(mockResolve).toHaveBeenCalledWith(expect.objectContaining({ issueIdentifier: 'PR-99' }));
|
|
554
|
+
});
|
|
555
|
+
it('unexpected exception returns error status', async () => {
|
|
556
|
+
vi.useRealTimers();
|
|
557
|
+
const config = makeConfig();
|
|
558
|
+
const deps = makeDeps();
|
|
559
|
+
const worker = new MergeWorker(config, deps);
|
|
560
|
+
const entry = makeEntry();
|
|
561
|
+
const mockStrategy = makeMockStrategy();
|
|
562
|
+
mockStrategy.prepare.mockRejectedValue(new Error('Unexpected git crash'));
|
|
563
|
+
mockCreateMergeStrategy.mockReturnValue(mockStrategy);
|
|
564
|
+
MockConflictResolver.mockImplementation(() => ({ resolve: vi.fn() }));
|
|
565
|
+
MockLockFileRegeneration.mockImplementation(() => ({
|
|
566
|
+
shouldRegenerate: vi.fn().mockReturnValue(false),
|
|
567
|
+
regenerate: vi.fn(),
|
|
568
|
+
getLockFileName: vi.fn(),
|
|
569
|
+
ensureGitAttributes: vi.fn(),
|
|
570
|
+
}));
|
|
571
|
+
const result = await worker.processEntry(entry);
|
|
572
|
+
expect(result.status).toBe('error');
|
|
573
|
+
expect(result.message).toBe('Unexpected git crash');
|
|
574
|
+
});
|
|
575
|
+
it('skips lock file regeneration when lockFileRegenerate is false', async () => {
|
|
576
|
+
vi.useRealTimers();
|
|
577
|
+
const config = makeConfig({ lockFileRegenerate: false });
|
|
578
|
+
const deps = makeDeps();
|
|
579
|
+
const worker = new MergeWorker(config, deps);
|
|
580
|
+
const entry = makeEntry();
|
|
581
|
+
const mockStrategy = makeMockStrategy();
|
|
582
|
+
mockCreateMergeStrategy.mockReturnValue(mockStrategy);
|
|
583
|
+
MockConflictResolver.mockImplementation(() => ({ resolve: vi.fn() }));
|
|
584
|
+
const mockShouldRegenerate = vi.fn().mockReturnValue(false);
|
|
585
|
+
const mockRegenerate = vi.fn();
|
|
586
|
+
MockLockFileRegeneration.mockImplementation(() => ({
|
|
587
|
+
shouldRegenerate: mockShouldRegenerate,
|
|
588
|
+
regenerate: mockRegenerate,
|
|
589
|
+
getLockFileName: vi.fn(),
|
|
590
|
+
ensureGitAttributes: vi.fn(),
|
|
591
|
+
}));
|
|
592
|
+
mockExecSuccess();
|
|
593
|
+
const result = await worker.processEntry(entry);
|
|
594
|
+
expect(result.status).toBe('merged');
|
|
595
|
+
expect(mockShouldRegenerate).toHaveBeenCalledWith('pnpm', false);
|
|
596
|
+
expect(mockRegenerate).not.toHaveBeenCalled();
|
|
597
|
+
});
|
|
598
|
+
it('uses entry.targetBranch when provided, falls back to config', async () => {
|
|
599
|
+
vi.useRealTimers();
|
|
600
|
+
const config = makeConfig({ targetBranch: 'develop' });
|
|
601
|
+
const deps = makeDeps();
|
|
602
|
+
const worker = new MergeWorker(config, deps);
|
|
603
|
+
const mockStrategy = makeMockStrategy();
|
|
604
|
+
mockCreateMergeStrategy.mockReturnValue(mockStrategy);
|
|
605
|
+
MockConflictResolver.mockImplementation(() => ({ resolve: vi.fn() }));
|
|
606
|
+
MockLockFileRegeneration.mockImplementation(() => ({
|
|
607
|
+
shouldRegenerate: vi.fn().mockReturnValue(false),
|
|
608
|
+
regenerate: vi.fn(),
|
|
609
|
+
getLockFileName: vi.fn(),
|
|
610
|
+
ensureGitAttributes: vi.fn(),
|
|
611
|
+
}));
|
|
612
|
+
mockExecSuccess();
|
|
613
|
+
// With explicit targetBranch in entry
|
|
614
|
+
const entry1 = makeEntry({ targetBranch: 'release' });
|
|
615
|
+
await worker.processEntry(entry1);
|
|
616
|
+
expect(mockStrategy.prepare).toHaveBeenCalledWith(expect.objectContaining({ targetBranch: 'release' }));
|
|
617
|
+
mockStrategy.prepare.mockClear();
|
|
618
|
+
// Without targetBranch in entry — falls back to config
|
|
619
|
+
const entry2 = { prNumber: 50, sourceBranch: 'fix/bug' };
|
|
620
|
+
await worker.processEntry(entry2);
|
|
621
|
+
expect(mockStrategy.prepare).toHaveBeenCalledWith(expect.objectContaining({ targetBranch: 'develop' }));
|
|
622
|
+
});
|
|
623
|
+
});
|
|
624
|
+
// -------------------------------------------------------------------------
|
|
625
|
+
// start/stop
|
|
626
|
+
// -------------------------------------------------------------------------
|
|
627
|
+
describe('start/stop', () => {
|
|
628
|
+
it('acquires Redis lock on start', async () => {
|
|
629
|
+
const config = makeConfig();
|
|
630
|
+
const deps = makeDeps();
|
|
631
|
+
const worker = new MergeWorker(config, deps);
|
|
632
|
+
// Use abort signal to stop immediately after first iteration
|
|
633
|
+
const ac = new AbortController();
|
|
634
|
+
// Make dequeue return null so the loop just polls
|
|
635
|
+
vi.mocked(deps.storage.dequeue).mockResolvedValue(null);
|
|
636
|
+
// Return null for paused check
|
|
637
|
+
vi.mocked(deps.redis.get).mockResolvedValue(null);
|
|
638
|
+
// Start and stop after one iteration
|
|
639
|
+
const startPromise = worker.start(ac.signal);
|
|
640
|
+
// Advance past the first poll interval
|
|
641
|
+
await vi.advanceTimersByTimeAsync(config.pollInterval + 10);
|
|
642
|
+
ac.abort();
|
|
643
|
+
await startPromise;
|
|
644
|
+
expect(deps.redis.setNX).toHaveBeenCalledWith('merge:lock:repo-1', 'worker', 300);
|
|
645
|
+
});
|
|
646
|
+
it('throws if lock already held', async () => {
|
|
647
|
+
const config = makeConfig();
|
|
648
|
+
const deps = makeDeps();
|
|
649
|
+
vi.mocked(deps.redis.setNX).mockResolvedValue(false);
|
|
650
|
+
const worker = new MergeWorker(config, deps);
|
|
651
|
+
await expect(worker.start()).rejects.toThrow('Another merge worker is already running for repo repo-1');
|
|
652
|
+
});
|
|
653
|
+
it('releases lock on stop', async () => {
|
|
654
|
+
const config = makeConfig();
|
|
655
|
+
const deps = makeDeps();
|
|
656
|
+
const worker = new MergeWorker(config, deps);
|
|
657
|
+
vi.mocked(deps.storage.dequeue).mockResolvedValue(null);
|
|
658
|
+
vi.mocked(deps.redis.get).mockResolvedValue(null);
|
|
659
|
+
const ac = new AbortController();
|
|
660
|
+
const startPromise = worker.start(ac.signal);
|
|
661
|
+
await vi.advanceTimersByTimeAsync(config.pollInterval + 10);
|
|
662
|
+
ac.abort();
|
|
663
|
+
await startPromise;
|
|
664
|
+
expect(deps.redis.del).toHaveBeenCalledWith('merge:lock:repo-1');
|
|
665
|
+
});
|
|
666
|
+
it('stop sets running to false', async () => {
|
|
667
|
+
const config = makeConfig();
|
|
668
|
+
const deps = makeDeps();
|
|
669
|
+
const worker = new MergeWorker(config, deps);
|
|
670
|
+
vi.mocked(deps.storage.dequeue).mockResolvedValue(null);
|
|
671
|
+
vi.mocked(deps.redis.get).mockResolvedValue(null);
|
|
672
|
+
const startPromise = worker.start();
|
|
673
|
+
// Let it start running
|
|
674
|
+
await vi.advanceTimersByTimeAsync(50);
|
|
675
|
+
// Stop the worker
|
|
676
|
+
worker.stop();
|
|
677
|
+
// Advance timers to let the loop exit
|
|
678
|
+
await vi.advanceTimersByTimeAsync(config.pollInterval + 100);
|
|
679
|
+
await startPromise;
|
|
680
|
+
// Lock should be released
|
|
681
|
+
expect(deps.redis.del).toHaveBeenCalledWith('merge:lock:repo-1');
|
|
682
|
+
});
|
|
683
|
+
it('heartbeat extends lock TTL', async () => {
|
|
684
|
+
const config = makeConfig();
|
|
685
|
+
const deps = makeDeps();
|
|
686
|
+
const worker = new MergeWorker(config, deps);
|
|
687
|
+
vi.mocked(deps.storage.dequeue).mockResolvedValue(null);
|
|
688
|
+
vi.mocked(deps.redis.get).mockResolvedValue(null);
|
|
689
|
+
const ac = new AbortController();
|
|
690
|
+
const startPromise = worker.start(ac.signal);
|
|
691
|
+
// Advance past the heartbeat interval (60 seconds)
|
|
692
|
+
await vi.advanceTimersByTimeAsync(60_000 + 100);
|
|
693
|
+
// Heartbeat should have called expire to extend the lock
|
|
694
|
+
expect(deps.redis.expire).toHaveBeenCalledWith('merge:lock:repo-1', 300);
|
|
695
|
+
ac.abort();
|
|
696
|
+
await vi.advanceTimersByTimeAsync(config.pollInterval + 10);
|
|
697
|
+
await startPromise;
|
|
698
|
+
});
|
|
699
|
+
it('paused queue causes polling wait', async () => {
|
|
700
|
+
const config = makeConfig({ pollInterval: 100 });
|
|
701
|
+
const deps = makeDeps();
|
|
702
|
+
const worker = new MergeWorker(config, deps);
|
|
703
|
+
// First call: paused, second call: paused, then abort
|
|
704
|
+
let callCount = 0;
|
|
705
|
+
vi.mocked(deps.redis.get).mockImplementation(async (key) => {
|
|
706
|
+
if (key.includes('paused')) {
|
|
707
|
+
callCount++;
|
|
708
|
+
return 'true';
|
|
709
|
+
}
|
|
710
|
+
return null;
|
|
711
|
+
});
|
|
712
|
+
const ac = new AbortController();
|
|
713
|
+
const startPromise = worker.start(ac.signal);
|
|
714
|
+
// Advance to let it check paused state
|
|
715
|
+
await vi.advanceTimersByTimeAsync(config.pollInterval + 50);
|
|
716
|
+
await vi.advanceTimersByTimeAsync(config.pollInterval + 50);
|
|
717
|
+
ac.abort();
|
|
718
|
+
await vi.advanceTimersByTimeAsync(config.pollInterval + 10);
|
|
719
|
+
await startPromise;
|
|
720
|
+
// Should have checked paused state multiple times but never dequeued
|
|
721
|
+
expect(callCount).toBeGreaterThanOrEqual(1);
|
|
722
|
+
expect(deps.storage.dequeue).not.toHaveBeenCalled();
|
|
723
|
+
});
|
|
724
|
+
it('empty queue causes polling wait', async () => {
|
|
725
|
+
const config = makeConfig({ pollInterval: 100 });
|
|
726
|
+
const deps = makeDeps();
|
|
727
|
+
const worker = new MergeWorker(config, deps);
|
|
728
|
+
vi.mocked(deps.storage.dequeue).mockResolvedValue(null);
|
|
729
|
+
vi.mocked(deps.redis.get).mockResolvedValue(null);
|
|
730
|
+
const ac = new AbortController();
|
|
731
|
+
const startPromise = worker.start(ac.signal);
|
|
732
|
+
// Advance past two poll intervals
|
|
733
|
+
await vi.advanceTimersByTimeAsync(config.pollInterval * 2 + 50);
|
|
734
|
+
ac.abort();
|
|
735
|
+
await vi.advanceTimersByTimeAsync(config.pollInterval + 10);
|
|
736
|
+
await startPromise;
|
|
737
|
+
// Should have called dequeue multiple times (polling)
|
|
738
|
+
expect(deps.storage.dequeue).toHaveBeenCalled();
|
|
739
|
+
// No markCompleted/markFailed since queue was empty
|
|
740
|
+
expect(deps.storage.markCompleted).not.toHaveBeenCalled();
|
|
741
|
+
expect(deps.storage.markFailed).not.toHaveBeenCalled();
|
|
742
|
+
});
|
|
743
|
+
it('processes entry and marks completed on success', async () => {
|
|
744
|
+
vi.useRealTimers();
|
|
745
|
+
const config = makeConfig({ pollInterval: 50 });
|
|
746
|
+
const deps = makeDeps();
|
|
747
|
+
const worker = new MergeWorker(config, deps);
|
|
748
|
+
const entry = makeEntry();
|
|
749
|
+
// Return entry on first dequeue, null on second (to stop)
|
|
750
|
+
let dequeueCount = 0;
|
|
751
|
+
vi.mocked(deps.storage.dequeue).mockImplementation(async () => {
|
|
752
|
+
dequeueCount++;
|
|
753
|
+
if (dequeueCount === 1)
|
|
754
|
+
return entry;
|
|
755
|
+
return null;
|
|
756
|
+
});
|
|
757
|
+
vi.mocked(deps.redis.get).mockResolvedValue(null);
|
|
758
|
+
// Set up mocks for processEntry
|
|
759
|
+
const mockStrategy = makeMockStrategy();
|
|
760
|
+
mockCreateMergeStrategy.mockReturnValue(mockStrategy);
|
|
761
|
+
MockConflictResolver.mockImplementation(() => ({ resolve: vi.fn() }));
|
|
762
|
+
MockLockFileRegeneration.mockImplementation(() => ({
|
|
763
|
+
shouldRegenerate: vi.fn().mockReturnValue(false),
|
|
764
|
+
regenerate: vi.fn(),
|
|
765
|
+
getLockFileName: vi.fn(),
|
|
766
|
+
ensureGitAttributes: vi.fn(),
|
|
767
|
+
}));
|
|
768
|
+
mockExecSuccess();
|
|
769
|
+
const ac = new AbortController();
|
|
770
|
+
const startPromise = worker.start(ac.signal);
|
|
771
|
+
// Let the processing happen
|
|
772
|
+
await new Promise(resolve => setTimeout(resolve, 200));
|
|
773
|
+
ac.abort();
|
|
774
|
+
await startPromise;
|
|
775
|
+
expect(deps.storage.markCompleted).toHaveBeenCalledWith('repo-1', 42);
|
|
776
|
+
});
|
|
777
|
+
it('marks blocked on conflict', async () => {
|
|
778
|
+
vi.useRealTimers();
|
|
779
|
+
const config = makeConfig({ pollInterval: 50 });
|
|
780
|
+
const deps = makeDeps();
|
|
781
|
+
const worker = new MergeWorker(config, deps);
|
|
782
|
+
const entry = makeEntry();
|
|
783
|
+
let dequeueCount = 0;
|
|
784
|
+
vi.mocked(deps.storage.dequeue).mockImplementation(async () => {
|
|
785
|
+
dequeueCount++;
|
|
786
|
+
if (dequeueCount === 1)
|
|
787
|
+
return entry;
|
|
788
|
+
return null;
|
|
789
|
+
});
|
|
790
|
+
vi.mocked(deps.redis.get).mockResolvedValue(null);
|
|
791
|
+
const mockStrategy = makeMockStrategy();
|
|
792
|
+
mockStrategy.execute.mockResolvedValue({ status: 'conflict', conflictFiles: ['a.ts'] });
|
|
793
|
+
mockCreateMergeStrategy.mockReturnValue(mockStrategy);
|
|
794
|
+
MockConflictResolver.mockImplementation(() => ({
|
|
795
|
+
resolve: vi.fn().mockResolvedValue({
|
|
796
|
+
status: 'escalated',
|
|
797
|
+
method: 'escalation',
|
|
798
|
+
message: 'Conflict in a.ts',
|
|
799
|
+
}),
|
|
800
|
+
}));
|
|
801
|
+
MockLockFileRegeneration.mockImplementation(() => ({
|
|
802
|
+
shouldRegenerate: vi.fn().mockReturnValue(false),
|
|
803
|
+
regenerate: vi.fn(),
|
|
804
|
+
getLockFileName: vi.fn(),
|
|
805
|
+
ensureGitAttributes: vi.fn(),
|
|
806
|
+
}));
|
|
807
|
+
const ac = new AbortController();
|
|
808
|
+
const startPromise = worker.start(ac.signal);
|
|
809
|
+
await new Promise(resolve => setTimeout(resolve, 200));
|
|
810
|
+
ac.abort();
|
|
811
|
+
await startPromise;
|
|
812
|
+
expect(deps.storage.markBlocked).toHaveBeenCalledWith('repo-1', 42, 'Conflict in a.ts');
|
|
813
|
+
});
|
|
814
|
+
it('marks failed on test failure with notify escalation', async () => {
|
|
815
|
+
vi.useRealTimers();
|
|
816
|
+
const config = makeConfig({
|
|
817
|
+
pollInterval: 50,
|
|
818
|
+
escalation: { onConflict: 'reassign', onTestFailure: 'notify' },
|
|
819
|
+
});
|
|
820
|
+
const deps = makeDeps();
|
|
821
|
+
const worker = new MergeWorker(config, deps);
|
|
822
|
+
const entry = makeEntry();
|
|
823
|
+
let dequeueCount = 0;
|
|
824
|
+
vi.mocked(deps.storage.dequeue).mockImplementation(async () => {
|
|
825
|
+
dequeueCount++;
|
|
826
|
+
if (dequeueCount === 1)
|
|
827
|
+
return entry;
|
|
828
|
+
return null;
|
|
829
|
+
});
|
|
830
|
+
vi.mocked(deps.redis.get).mockResolvedValue(null);
|
|
831
|
+
const mockStrategy = makeMockStrategy();
|
|
832
|
+
mockCreateMergeStrategy.mockReturnValue(mockStrategy);
|
|
833
|
+
MockConflictResolver.mockImplementation(() => ({ resolve: vi.fn() }));
|
|
834
|
+
MockLockFileRegeneration.mockImplementation(() => ({
|
|
835
|
+
shouldRegenerate: vi.fn().mockReturnValue(false),
|
|
836
|
+
regenerate: vi.fn(),
|
|
837
|
+
getLockFileName: vi.fn(),
|
|
838
|
+
ensureGitAttributes: vi.fn(),
|
|
839
|
+
}));
|
|
840
|
+
mockExecFailure('Tests failed', 'FAIL: 2 tests');
|
|
841
|
+
const ac = new AbortController();
|
|
842
|
+
const startPromise = worker.start(ac.signal);
|
|
843
|
+
await new Promise(resolve => setTimeout(resolve, 200));
|
|
844
|
+
ac.abort();
|
|
845
|
+
await startPromise;
|
|
846
|
+
expect(deps.storage.markFailed).toHaveBeenCalledWith('repo-1', 42, expect.any(String));
|
|
847
|
+
});
|
|
848
|
+
it('marks blocked on test failure with park escalation', async () => {
|
|
849
|
+
vi.useRealTimers();
|
|
850
|
+
const config = makeConfig({
|
|
851
|
+
pollInterval: 50,
|
|
852
|
+
escalation: { onConflict: 'reassign', onTestFailure: 'park' },
|
|
853
|
+
});
|
|
854
|
+
const deps = makeDeps();
|
|
855
|
+
const worker = new MergeWorker(config, deps);
|
|
856
|
+
const entry = makeEntry();
|
|
857
|
+
let dequeueCount = 0;
|
|
858
|
+
vi.mocked(deps.storage.dequeue).mockImplementation(async () => {
|
|
859
|
+
dequeueCount++;
|
|
860
|
+
if (dequeueCount === 1)
|
|
861
|
+
return entry;
|
|
862
|
+
return null;
|
|
863
|
+
});
|
|
864
|
+
vi.mocked(deps.redis.get).mockResolvedValue(null);
|
|
865
|
+
const mockStrategy = makeMockStrategy();
|
|
866
|
+
mockCreateMergeStrategy.mockReturnValue(mockStrategy);
|
|
867
|
+
MockConflictResolver.mockImplementation(() => ({ resolve: vi.fn() }));
|
|
868
|
+
MockLockFileRegeneration.mockImplementation(() => ({
|
|
869
|
+
shouldRegenerate: vi.fn().mockReturnValue(false),
|
|
870
|
+
regenerate: vi.fn(),
|
|
871
|
+
getLockFileName: vi.fn(),
|
|
872
|
+
ensureGitAttributes: vi.fn(),
|
|
873
|
+
}));
|
|
874
|
+
mockExecFailure('Tests failed', 'FAIL: 2 tests');
|
|
875
|
+
const ac = new AbortController();
|
|
876
|
+
const startPromise = worker.start(ac.signal);
|
|
877
|
+
await new Promise(resolve => setTimeout(resolve, 200));
|
|
878
|
+
ac.abort();
|
|
879
|
+
await startPromise;
|
|
880
|
+
expect(deps.storage.markBlocked).toHaveBeenCalledWith('repo-1', 42, expect.any(String));
|
|
881
|
+
});
|
|
882
|
+
});
|
|
883
|
+
});
|