@renseiai/agentfactory 0.8.12 → 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,236 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
vi.mock('child_process', () => ({
|
|
3
|
+
exec: vi.fn(),
|
|
4
|
+
}));
|
|
5
|
+
vi.mock('fs/promises', () => ({
|
|
6
|
+
readFile: vi.fn(),
|
|
7
|
+
writeFile: vi.fn(),
|
|
8
|
+
access: vi.fn(),
|
|
9
|
+
}));
|
|
10
|
+
import { exec as execCb } from 'child_process';
|
|
11
|
+
import { readFile, writeFile, access } from 'fs/promises';
|
|
12
|
+
import { LockFileRegeneration } from './lock-file-regeneration.js';
|
|
13
|
+
const mockExec = vi.mocked(execCb);
|
|
14
|
+
const mockReadFile = vi.mocked(readFile);
|
|
15
|
+
const mockWriteFile = vi.mocked(writeFile);
|
|
16
|
+
const mockAccess = vi.mocked(access);
|
|
17
|
+
/**
|
|
18
|
+
* Helper: configure the raw exec mock to work with promisify.
|
|
19
|
+
* promisify(exec) turns the callback-style exec into a promise.
|
|
20
|
+
* We mock by calling the callback argument immediately.
|
|
21
|
+
*/
|
|
22
|
+
function mockExecSuccess(stdout = '', stderr = '') {
|
|
23
|
+
mockExec.mockImplementation((_cmd, _opts, callback) => {
|
|
24
|
+
const cb = typeof _opts === 'function' ? _opts : callback;
|
|
25
|
+
cb?.(null, { stdout, stderr });
|
|
26
|
+
return {};
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
function mockExecFailure(message) {
|
|
30
|
+
mockExec.mockImplementation((_cmd, _opts, callback) => {
|
|
31
|
+
const cb = typeof _opts === 'function' ? _opts : callback;
|
|
32
|
+
cb?.(new Error(message), { stdout: '', stderr: '' });
|
|
33
|
+
return {};
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Helper: set up exec to succeed for a sequence of calls.
|
|
38
|
+
* Each call pops the next result from the queue; default is success.
|
|
39
|
+
*/
|
|
40
|
+
function mockExecSequence(results) {
|
|
41
|
+
let callIndex = 0;
|
|
42
|
+
mockExec.mockImplementation((_cmd, _opts, callback) => {
|
|
43
|
+
const cb = typeof _opts === 'function' ? _opts : callback;
|
|
44
|
+
const result = results[callIndex] ?? { stdout: '' };
|
|
45
|
+
callIndex++;
|
|
46
|
+
if (result.error) {
|
|
47
|
+
cb?.(new Error(result.error), { stdout: '', stderr: '' });
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
cb?.(null, { stdout: result.stdout ?? '', stderr: '' });
|
|
51
|
+
}
|
|
52
|
+
return {};
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
describe('LockFileRegeneration', () => {
|
|
56
|
+
let handler;
|
|
57
|
+
beforeEach(() => {
|
|
58
|
+
vi.clearAllMocks();
|
|
59
|
+
handler = new LockFileRegeneration();
|
|
60
|
+
});
|
|
61
|
+
// -------------------------------------------------------------------------
|
|
62
|
+
// shouldRegenerate
|
|
63
|
+
// -------------------------------------------------------------------------
|
|
64
|
+
describe('shouldRegenerate', () => {
|
|
65
|
+
it('returns true when lockFileRegenerate is true and packageManager is not none', () => {
|
|
66
|
+
expect(handler.shouldRegenerate('pnpm', true)).toBe(true);
|
|
67
|
+
expect(handler.shouldRegenerate('npm', true)).toBe(true);
|
|
68
|
+
expect(handler.shouldRegenerate('yarn', true)).toBe(true);
|
|
69
|
+
expect(handler.shouldRegenerate('bun', true)).toBe(true);
|
|
70
|
+
});
|
|
71
|
+
it('returns false when lockFileRegenerate is false', () => {
|
|
72
|
+
expect(handler.shouldRegenerate('pnpm', false)).toBe(false);
|
|
73
|
+
expect(handler.shouldRegenerate('npm', false)).toBe(false);
|
|
74
|
+
expect(handler.shouldRegenerate('yarn', false)).toBe(false);
|
|
75
|
+
expect(handler.shouldRegenerate('bun', false)).toBe(false);
|
|
76
|
+
expect(handler.shouldRegenerate('none', false)).toBe(false);
|
|
77
|
+
});
|
|
78
|
+
it('returns false when packageManager is none', () => {
|
|
79
|
+
expect(handler.shouldRegenerate('none', true)).toBe(false);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
// -------------------------------------------------------------------------
|
|
83
|
+
// getLockFileName
|
|
84
|
+
// -------------------------------------------------------------------------
|
|
85
|
+
describe('getLockFileName', () => {
|
|
86
|
+
it('returns correct file for each package manager', () => {
|
|
87
|
+
expect(handler.getLockFileName('pnpm')).toBe('pnpm-lock.yaml');
|
|
88
|
+
expect(handler.getLockFileName('npm')).toBe('package-lock.json');
|
|
89
|
+
expect(handler.getLockFileName('yarn')).toBe('yarn.lock');
|
|
90
|
+
expect(handler.getLockFileName('bun')).toBe('bun.lockb');
|
|
91
|
+
});
|
|
92
|
+
it('returns null for none', () => {
|
|
93
|
+
expect(handler.getLockFileName('none')).toBeNull();
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
// -------------------------------------------------------------------------
|
|
97
|
+
// regenerate
|
|
98
|
+
// -------------------------------------------------------------------------
|
|
99
|
+
describe('regenerate', () => {
|
|
100
|
+
it('deletes lock file, runs install, and stages result for pnpm', async () => {
|
|
101
|
+
mockAccess.mockResolvedValue(undefined);
|
|
102
|
+
mockExecSuccess();
|
|
103
|
+
const result = await handler.regenerate('/tmp/worktree', 'pnpm');
|
|
104
|
+
expect(result).toEqual({
|
|
105
|
+
success: true,
|
|
106
|
+
lockFile: 'pnpm-lock.yaml',
|
|
107
|
+
packageManager: 'pnpm',
|
|
108
|
+
});
|
|
109
|
+
// Verify exec was called 3 times: rm, install, git add
|
|
110
|
+
expect(mockExec).toHaveBeenCalledTimes(3);
|
|
111
|
+
// 1st call: rm the lock file
|
|
112
|
+
const firstCallCmd = mockExec.mock.calls[0][0];
|
|
113
|
+
expect(firstCallCmd).toContain('rm');
|
|
114
|
+
expect(firstCallCmd).toContain('pnpm-lock.yaml');
|
|
115
|
+
// 2nd call: pnpm install
|
|
116
|
+
const secondCallCmd = mockExec.mock.calls[1][0];
|
|
117
|
+
expect(secondCallCmd).toBe('pnpm install --no-frozen-lockfile');
|
|
118
|
+
// 3rd call: git add
|
|
119
|
+
const thirdCallCmd = mockExec.mock.calls[2][0];
|
|
120
|
+
expect(thirdCallCmd).toContain('git add');
|
|
121
|
+
expect(thirdCallCmd).toContain('pnpm-lock.yaml');
|
|
122
|
+
});
|
|
123
|
+
it('works for npm', async () => {
|
|
124
|
+
mockAccess.mockResolvedValue(undefined);
|
|
125
|
+
mockExecSuccess();
|
|
126
|
+
const result = await handler.regenerate('/tmp/worktree', 'npm');
|
|
127
|
+
expect(result.success).toBe(true);
|
|
128
|
+
expect(result.lockFile).toBe('package-lock.json');
|
|
129
|
+
expect(result.packageManager).toBe('npm');
|
|
130
|
+
const installCmd = mockExec.mock.calls[1][0];
|
|
131
|
+
expect(installCmd).toBe('npm install');
|
|
132
|
+
});
|
|
133
|
+
it('works for yarn', async () => {
|
|
134
|
+
mockAccess.mockResolvedValue(undefined);
|
|
135
|
+
mockExecSuccess();
|
|
136
|
+
const result = await handler.regenerate('/tmp/worktree', 'yarn');
|
|
137
|
+
expect(result.success).toBe(true);
|
|
138
|
+
expect(result.lockFile).toBe('yarn.lock');
|
|
139
|
+
expect(result.packageManager).toBe('yarn');
|
|
140
|
+
const installCmd = mockExec.mock.calls[1][0];
|
|
141
|
+
expect(installCmd).toBe('yarn install');
|
|
142
|
+
});
|
|
143
|
+
it('works for bun', async () => {
|
|
144
|
+
mockAccess.mockResolvedValue(undefined);
|
|
145
|
+
mockExecSuccess();
|
|
146
|
+
const result = await handler.regenerate('/tmp/worktree', 'bun');
|
|
147
|
+
expect(result.success).toBe(true);
|
|
148
|
+
expect(result.lockFile).toBe('bun.lockb');
|
|
149
|
+
expect(result.packageManager).toBe('bun');
|
|
150
|
+
const installCmd = mockExec.mock.calls[1][0];
|
|
151
|
+
expect(installCmd).toBe('bun install');
|
|
152
|
+
});
|
|
153
|
+
it('handles install failure gracefully', async () => {
|
|
154
|
+
// access succeeds (lock file exists), rm succeeds, install fails
|
|
155
|
+
mockAccess.mockResolvedValue(undefined);
|
|
156
|
+
mockExecSequence([
|
|
157
|
+
{ stdout: '' }, // rm succeeds
|
|
158
|
+
{ error: 'ENOENT: pnpm not found' }, // install fails
|
|
159
|
+
]);
|
|
160
|
+
const result = await handler.regenerate('/tmp/worktree', 'pnpm');
|
|
161
|
+
expect(result.success).toBe(false);
|
|
162
|
+
expect(result.lockFile).toBe('pnpm-lock.yaml');
|
|
163
|
+
expect(result.packageManager).toBe('pnpm');
|
|
164
|
+
expect(result.error).toContain('pnpm not found');
|
|
165
|
+
});
|
|
166
|
+
it('handles missing lock file gracefully (no delete error)', async () => {
|
|
167
|
+
// access throws (lock file doesn't exist), so rm is skipped
|
|
168
|
+
mockAccess.mockRejectedValue(new Error('ENOENT'));
|
|
169
|
+
mockExecSuccess();
|
|
170
|
+
const result = await handler.regenerate('/tmp/worktree', 'pnpm');
|
|
171
|
+
expect(result.success).toBe(true);
|
|
172
|
+
expect(result.lockFile).toBe('pnpm-lock.yaml');
|
|
173
|
+
// Should have only 2 exec calls: install and git add (rm was skipped)
|
|
174
|
+
expect(mockExec).toHaveBeenCalledTimes(2);
|
|
175
|
+
const firstCallCmd = mockExec.mock.calls[0][0];
|
|
176
|
+
expect(firstCallCmd).toBe('pnpm install --no-frozen-lockfile');
|
|
177
|
+
const secondCallCmd = mockExec.mock.calls[1][0];
|
|
178
|
+
expect(secondCallCmd).toContain('git add');
|
|
179
|
+
});
|
|
180
|
+
it('returns error for unsupported package manager (none)', async () => {
|
|
181
|
+
const result = await handler.regenerate('/tmp/worktree', 'none');
|
|
182
|
+
expect(result.success).toBe(false);
|
|
183
|
+
expect(result.lockFile).toBe('');
|
|
184
|
+
expect(result.packageManager).toBe('none');
|
|
185
|
+
expect(result.error).toContain('Unsupported package manager');
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
// -------------------------------------------------------------------------
|
|
189
|
+
// ensureGitAttributes
|
|
190
|
+
// -------------------------------------------------------------------------
|
|
191
|
+
describe('ensureGitAttributes', () => {
|
|
192
|
+
it('creates .gitattributes if not exists', async () => {
|
|
193
|
+
mockReadFile.mockRejectedValue(new Error('ENOENT'));
|
|
194
|
+
mockWriteFile.mockResolvedValue(undefined);
|
|
195
|
+
await handler.ensureGitAttributes('/repo', 'pnpm');
|
|
196
|
+
expect(mockWriteFile).toHaveBeenCalledWith('/repo/.gitattributes', 'pnpm-lock.yaml merge=ours\n', 'utf-8');
|
|
197
|
+
});
|
|
198
|
+
it('appends entry if not already present', async () => {
|
|
199
|
+
mockReadFile.mockResolvedValue('*.md linguist-documentation\n');
|
|
200
|
+
mockWriteFile.mockResolvedValue(undefined);
|
|
201
|
+
await handler.ensureGitAttributes('/repo', 'pnpm');
|
|
202
|
+
expect(mockWriteFile).toHaveBeenCalledWith('/repo/.gitattributes', '*.md linguist-documentation\npnpm-lock.yaml merge=ours\n', 'utf-8');
|
|
203
|
+
});
|
|
204
|
+
it('appends with newline separator when file does not end with newline', async () => {
|
|
205
|
+
mockReadFile.mockResolvedValue('*.md linguist-documentation');
|
|
206
|
+
mockWriteFile.mockResolvedValue(undefined);
|
|
207
|
+
await handler.ensureGitAttributes('/repo', 'npm');
|
|
208
|
+
expect(mockWriteFile).toHaveBeenCalledWith('/repo/.gitattributes', '*.md linguist-documentation\npackage-lock.json merge=ours\n', 'utf-8');
|
|
209
|
+
});
|
|
210
|
+
it('skips if entry already present', async () => {
|
|
211
|
+
mockReadFile.mockResolvedValue('pnpm-lock.yaml merge=ours\n');
|
|
212
|
+
await handler.ensureGitAttributes('/repo', 'pnpm');
|
|
213
|
+
expect(mockWriteFile).not.toHaveBeenCalled();
|
|
214
|
+
});
|
|
215
|
+
it('handles each package manager', async () => {
|
|
216
|
+
const expectedEntries = {
|
|
217
|
+
pnpm: 'pnpm-lock.yaml merge=ours',
|
|
218
|
+
npm: 'package-lock.json merge=ours',
|
|
219
|
+
yarn: 'yarn.lock merge=ours',
|
|
220
|
+
bun: 'bun.lockb merge=ours',
|
|
221
|
+
};
|
|
222
|
+
for (const [pm, expected] of Object.entries(expectedEntries)) {
|
|
223
|
+
vi.clearAllMocks();
|
|
224
|
+
mockReadFile.mockRejectedValue(new Error('ENOENT'));
|
|
225
|
+
mockWriteFile.mockResolvedValue(undefined);
|
|
226
|
+
await handler.ensureGitAttributes('/repo', pm);
|
|
227
|
+
expect(mockWriteFile).toHaveBeenCalledWith('/repo/.gitattributes', `${expected}\n`, 'utf-8');
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
it('does nothing for unsupported package manager (none)', async () => {
|
|
231
|
+
await handler.ensureGitAttributes('/repo', 'none');
|
|
232
|
+
expect(mockReadFile).not.toHaveBeenCalled();
|
|
233
|
+
expect(mockWriteFile).not.toHaveBeenCalled();
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
});
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Merge Worker — Single-Instance Processor
|
|
3
|
+
*
|
|
4
|
+
* Implements the core rebase -> resolve conflicts -> regenerate lock files -> test -> merge loop.
|
|
5
|
+
* Acquires a Redis lock to ensure only one worker processes a given repository's queue at a time.
|
|
6
|
+
* Uses heartbeat to extend the lock TTL while processing, and supports graceful shutdown.
|
|
7
|
+
*
|
|
8
|
+
* All external dependencies (storage, redis) are injected via MergeWorkerDeps for testability.
|
|
9
|
+
*/
|
|
10
|
+
import type { PackageManager } from './lock-file-regeneration.js';
|
|
11
|
+
export interface MergeWorkerConfig {
|
|
12
|
+
repoId: string;
|
|
13
|
+
repoPath: string;
|
|
14
|
+
strategy: 'rebase' | 'merge' | 'squash';
|
|
15
|
+
testCommand: string;
|
|
16
|
+
testTimeout: number;
|
|
17
|
+
lockFileRegenerate: boolean;
|
|
18
|
+
mergiraf: boolean;
|
|
19
|
+
pollInterval: number;
|
|
20
|
+
maxRetries: number;
|
|
21
|
+
escalation: {
|
|
22
|
+
onConflict: 'reassign' | 'notify' | 'park';
|
|
23
|
+
onTestFailure: 'notify' | 'park' | 'retry';
|
|
24
|
+
};
|
|
25
|
+
deleteBranchOnMerge: boolean;
|
|
26
|
+
packageManager: PackageManager;
|
|
27
|
+
remote: string;
|
|
28
|
+
targetBranch: string;
|
|
29
|
+
}
|
|
30
|
+
export interface MergeWorkerDeps {
|
|
31
|
+
storage: {
|
|
32
|
+
dequeue(repoId: string): Promise<any | null>;
|
|
33
|
+
markCompleted(repoId: string, prNumber: number): Promise<void>;
|
|
34
|
+
markFailed(repoId: string, prNumber: number, reason: string): Promise<void>;
|
|
35
|
+
markBlocked(repoId: string, prNumber: number, reason: string): Promise<void>;
|
|
36
|
+
};
|
|
37
|
+
redis: {
|
|
38
|
+
setNX(key: string, value: string, ttlSeconds?: number): Promise<boolean>;
|
|
39
|
+
del(key: string): Promise<void>;
|
|
40
|
+
get(key: string): Promise<string | null>;
|
|
41
|
+
set(key: string, value: string): Promise<void>;
|
|
42
|
+
expire(key: string, seconds: number): Promise<void>;
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
export interface MergeProcessResult {
|
|
46
|
+
prNumber: number;
|
|
47
|
+
status: 'merged' | 'conflict' | 'test-failure' | 'error';
|
|
48
|
+
message?: string;
|
|
49
|
+
}
|
|
50
|
+
export declare class MergeWorker {
|
|
51
|
+
private config;
|
|
52
|
+
private deps;
|
|
53
|
+
private running;
|
|
54
|
+
private heartbeatTimer;
|
|
55
|
+
constructor(config: MergeWorkerConfig, deps: MergeWorkerDeps);
|
|
56
|
+
/**
|
|
57
|
+
* Start the merge worker. Acquires a lock, then loops processing the queue.
|
|
58
|
+
*/
|
|
59
|
+
start(signal?: AbortSignal): Promise<void>;
|
|
60
|
+
/**
|
|
61
|
+
* Stop the merge worker gracefully (finishes current merge before stopping).
|
|
62
|
+
*/
|
|
63
|
+
stop(): void;
|
|
64
|
+
/**
|
|
65
|
+
* Process a single queue entry: rebase -> resolve conflicts -> regenerate lock files -> test -> merge
|
|
66
|
+
*/
|
|
67
|
+
processEntry(entry: {
|
|
68
|
+
prNumber: number;
|
|
69
|
+
sourceBranch: string;
|
|
70
|
+
targetBranch?: string;
|
|
71
|
+
issueIdentifier?: string;
|
|
72
|
+
prUrl?: string;
|
|
73
|
+
}): Promise<MergeProcessResult>;
|
|
74
|
+
private runTests;
|
|
75
|
+
private startHeartbeat;
|
|
76
|
+
private stopHeartbeat;
|
|
77
|
+
private sleep;
|
|
78
|
+
}
|
|
79
|
+
//# sourceMappingURL=merge-worker.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"merge-worker.d.ts","sourceRoot":"","sources":["../../../src/merge-queue/merge-worker.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Merge Worker — Single-Instance Processor
|
|
3
|
+
*
|
|
4
|
+
* Implements the core rebase -> resolve conflicts -> regenerate lock files -> test -> merge loop.
|
|
5
|
+
* Acquires a Redis lock to ensure only one worker processes a given repository's queue at a time.
|
|
6
|
+
* Uses heartbeat to extend the lock TTL while processing, and supports graceful shutdown.
|
|
7
|
+
*
|
|
8
|
+
* All external dependencies (storage, redis) are injected via MergeWorkerDeps for testability.
|
|
9
|
+
*/
|
|
10
|
+
import { exec as execCb } from 'child_process';
|
|
11
|
+
import { promisify } from 'util';
|
|
12
|
+
import { createMergeStrategy } from './strategies/index.js';
|
|
13
|
+
import { ConflictResolver } from './conflict-resolver.js';
|
|
14
|
+
import { LockFileRegeneration } from './lock-file-regeneration.js';
|
|
15
|
+
const exec = promisify(execCb);
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
// MergeWorker
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
export class MergeWorker {
|
|
20
|
+
config;
|
|
21
|
+
deps;
|
|
22
|
+
running = false;
|
|
23
|
+
heartbeatTimer = null;
|
|
24
|
+
constructor(config, deps) {
|
|
25
|
+
this.config = config;
|
|
26
|
+
this.deps = deps;
|
|
27
|
+
}
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
// Public API
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
/**
|
|
32
|
+
* Start the merge worker. Acquires a lock, then loops processing the queue.
|
|
33
|
+
*/
|
|
34
|
+
async start(signal) {
|
|
35
|
+
// Acquire single-instance lock
|
|
36
|
+
const lockKey = `merge:lock:${this.config.repoId}`;
|
|
37
|
+
const acquired = await this.deps.redis.setNX(lockKey, 'worker', 300); // 5 min TTL
|
|
38
|
+
if (!acquired) {
|
|
39
|
+
throw new Error(`Another merge worker is already running for repo ${this.config.repoId}`);
|
|
40
|
+
}
|
|
41
|
+
this.running = true;
|
|
42
|
+
this.startHeartbeat(lockKey);
|
|
43
|
+
try {
|
|
44
|
+
while (this.running && !(signal?.aborted)) {
|
|
45
|
+
// Check if paused
|
|
46
|
+
const paused = await this.deps.redis.get(`merge:paused:${this.config.repoId}`);
|
|
47
|
+
if (paused) {
|
|
48
|
+
await this.sleep(this.config.pollInterval, signal);
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
// Try to dequeue next PR
|
|
52
|
+
const entry = await this.deps.storage.dequeue(this.config.repoId);
|
|
53
|
+
if (!entry) {
|
|
54
|
+
// Queue empty — poll interval wait
|
|
55
|
+
await this.sleep(this.config.pollInterval, signal);
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
// Process the PR
|
|
59
|
+
const result = await this.processEntry(entry);
|
|
60
|
+
// Handle result
|
|
61
|
+
switch (result.status) {
|
|
62
|
+
case 'merged':
|
|
63
|
+
await this.deps.storage.markCompleted(this.config.repoId, result.prNumber);
|
|
64
|
+
break;
|
|
65
|
+
case 'conflict':
|
|
66
|
+
await this.deps.storage.markBlocked(this.config.repoId, result.prNumber, result.message ?? 'Merge conflict');
|
|
67
|
+
break;
|
|
68
|
+
case 'test-failure': {
|
|
69
|
+
const action = this.config.escalation.onTestFailure;
|
|
70
|
+
if (action === 'park') {
|
|
71
|
+
await this.deps.storage.markBlocked(this.config.repoId, result.prNumber, result.message ?? 'Tests failed');
|
|
72
|
+
}
|
|
73
|
+
else {
|
|
74
|
+
await this.deps.storage.markFailed(this.config.repoId, result.prNumber, result.message ?? 'Tests failed');
|
|
75
|
+
}
|
|
76
|
+
break;
|
|
77
|
+
}
|
|
78
|
+
case 'error':
|
|
79
|
+
await this.deps.storage.markFailed(this.config.repoId, result.prNumber, result.message ?? 'Unknown error');
|
|
80
|
+
break;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
finally {
|
|
85
|
+
// Graceful shutdown — release lock
|
|
86
|
+
this.stopHeartbeat();
|
|
87
|
+
await this.deps.redis.del(lockKey);
|
|
88
|
+
this.running = false;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Stop the merge worker gracefully (finishes current merge before stopping).
|
|
93
|
+
*/
|
|
94
|
+
stop() {
|
|
95
|
+
this.running = false;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Process a single queue entry: rebase -> resolve conflicts -> regenerate lock files -> test -> merge
|
|
99
|
+
*/
|
|
100
|
+
async processEntry(entry) {
|
|
101
|
+
const strategy = createMergeStrategy(this.config.strategy);
|
|
102
|
+
const conflictResolver = new ConflictResolver({
|
|
103
|
+
mergirafEnabled: this.config.mergiraf,
|
|
104
|
+
escalationStrategy: this.config.escalation.onConflict,
|
|
105
|
+
});
|
|
106
|
+
const lockFileHandler = new LockFileRegeneration();
|
|
107
|
+
const ctx = {
|
|
108
|
+
repoPath: this.config.repoPath,
|
|
109
|
+
worktreePath: this.config.repoPath, // In production, would use a dedicated worktree
|
|
110
|
+
sourceBranch: entry.sourceBranch,
|
|
111
|
+
targetBranch: entry.targetBranch ?? this.config.targetBranch,
|
|
112
|
+
prNumber: entry.prNumber,
|
|
113
|
+
remote: this.config.remote,
|
|
114
|
+
};
|
|
115
|
+
try {
|
|
116
|
+
// 1. Prepare: fetch latest main
|
|
117
|
+
const prepareResult = await strategy.prepare(ctx);
|
|
118
|
+
if (!prepareResult.success) {
|
|
119
|
+
return { prNumber: entry.prNumber, status: 'error', message: `Prepare failed: ${prepareResult.error}` };
|
|
120
|
+
}
|
|
121
|
+
// 2. Execute merge strategy (rebase/merge/squash)
|
|
122
|
+
const mergeResult = await strategy.execute(ctx);
|
|
123
|
+
// 3. Handle conflicts
|
|
124
|
+
if (mergeResult.status === 'conflict') {
|
|
125
|
+
const resolution = await conflictResolver.resolve({
|
|
126
|
+
repoPath: ctx.repoPath,
|
|
127
|
+
worktreePath: ctx.worktreePath,
|
|
128
|
+
sourceBranch: ctx.sourceBranch,
|
|
129
|
+
targetBranch: ctx.targetBranch,
|
|
130
|
+
prNumber: ctx.prNumber,
|
|
131
|
+
issueIdentifier: entry.issueIdentifier ?? `PR-${entry.prNumber}`,
|
|
132
|
+
conflictFiles: mergeResult.conflictFiles ?? [],
|
|
133
|
+
conflictDetails: mergeResult.conflictDetails,
|
|
134
|
+
});
|
|
135
|
+
if (resolution.status !== 'resolved') {
|
|
136
|
+
return {
|
|
137
|
+
prNumber: entry.prNumber,
|
|
138
|
+
status: 'conflict',
|
|
139
|
+
message: resolution.message ?? `Unresolved conflicts in: ${(resolution.unresolvedFiles ?? []).join(', ')}`,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
else if (mergeResult.status === 'error') {
|
|
144
|
+
return { prNumber: entry.prNumber, status: 'error', message: mergeResult.error ?? 'Merge strategy failed' };
|
|
145
|
+
}
|
|
146
|
+
// 4. Regenerate lock files if configured
|
|
147
|
+
if (lockFileHandler.shouldRegenerate(this.config.packageManager, this.config.lockFileRegenerate)) {
|
|
148
|
+
const regenResult = await lockFileHandler.regenerate(ctx.worktreePath, this.config.packageManager);
|
|
149
|
+
if (!regenResult.success) {
|
|
150
|
+
return { prNumber: entry.prNumber, status: 'error', message: `Lock file regeneration failed: ${regenResult.error}` };
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
// 5. Run test suite
|
|
154
|
+
const testResult = await this.runTests(ctx.worktreePath);
|
|
155
|
+
if (!testResult.passed) {
|
|
156
|
+
return { prNumber: entry.prNumber, status: 'test-failure', message: testResult.output };
|
|
157
|
+
}
|
|
158
|
+
// 6. Finalize: push and merge
|
|
159
|
+
await strategy.finalize(ctx);
|
|
160
|
+
// 7. Delete branch if configured
|
|
161
|
+
if (this.config.deleteBranchOnMerge) {
|
|
162
|
+
try {
|
|
163
|
+
await exec(`git push ${this.config.remote} --delete ${entry.sourceBranch}`, {
|
|
164
|
+
cwd: ctx.worktreePath,
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
// Branch deletion failure is non-fatal
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return { prNumber: entry.prNumber, status: 'merged' };
|
|
172
|
+
}
|
|
173
|
+
catch (error) {
|
|
174
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
175
|
+
return { prNumber: entry.prNumber, status: 'error', message };
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
// ---------------------------------------------------------------------------
|
|
179
|
+
// Private helpers
|
|
180
|
+
// ---------------------------------------------------------------------------
|
|
181
|
+
async runTests(worktreePath) {
|
|
182
|
+
try {
|
|
183
|
+
const { stdout, stderr } = await exec(this.config.testCommand, {
|
|
184
|
+
cwd: worktreePath,
|
|
185
|
+
timeout: this.config.testTimeout,
|
|
186
|
+
maxBuffer: 10 * 1024 * 1024, // 10MB
|
|
187
|
+
});
|
|
188
|
+
return { passed: true, output: stdout + stderr };
|
|
189
|
+
}
|
|
190
|
+
catch (error) {
|
|
191
|
+
const message = error instanceof Error ? error.stdout ?? error.message : String(error);
|
|
192
|
+
return { passed: false, output: message };
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
startHeartbeat(lockKey) {
|
|
196
|
+
// Extend lock TTL every 60 seconds
|
|
197
|
+
this.heartbeatTimer = setInterval(async () => {
|
|
198
|
+
try {
|
|
199
|
+
await this.deps.redis.expire(lockKey, 300);
|
|
200
|
+
}
|
|
201
|
+
catch {
|
|
202
|
+
// Heartbeat failure — will be handled by TTL expiry
|
|
203
|
+
}
|
|
204
|
+
}, 60_000);
|
|
205
|
+
}
|
|
206
|
+
stopHeartbeat() {
|
|
207
|
+
if (this.heartbeatTimer) {
|
|
208
|
+
clearInterval(this.heartbeatTimer);
|
|
209
|
+
this.heartbeatTimer = null;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
sleep(ms, signal) {
|
|
213
|
+
return new Promise((resolve) => {
|
|
214
|
+
const timer = setTimeout(resolve, ms);
|
|
215
|
+
signal?.addEventListener('abort', () => {
|
|
216
|
+
clearTimeout(timer);
|
|
217
|
+
resolve();
|
|
218
|
+
}, { once: true });
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"merge-worker.test.d.ts","sourceRoot":"","sources":["../../../src/merge-queue/merge-worker.test.ts"],"names":[],"mappings":""}
|