@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.
Files changed (101) hide show
  1. package/dist/src/config/repository-config.d.ts +24 -0
  2. package/dist/src/config/repository-config.d.ts.map +1 -1
  3. package/dist/src/config/repository-config.js +21 -0
  4. package/dist/src/config/repository-config.test.js +202 -0
  5. package/dist/src/governor/decision-engine.d.ts +2 -0
  6. package/dist/src/governor/decision-engine.d.ts.map +1 -1
  7. package/dist/src/governor/decision-engine.js +7 -0
  8. package/dist/src/governor/decision-engine.test.js +63 -0
  9. package/dist/src/governor/governor-types.d.ts +2 -1
  10. package/dist/src/governor/governor-types.d.ts.map +1 -1
  11. package/dist/src/index.d.ts +1 -0
  12. package/dist/src/index.d.ts.map +1 -1
  13. package/dist/src/index.js +1 -0
  14. package/dist/src/merge-queue/conflict-resolver.d.ts +62 -0
  15. package/dist/src/merge-queue/conflict-resolver.d.ts.map +1 -0
  16. package/dist/src/merge-queue/conflict-resolver.js +168 -0
  17. package/dist/src/merge-queue/conflict-resolver.test.d.ts +2 -0
  18. package/dist/src/merge-queue/conflict-resolver.test.d.ts.map +1 -0
  19. package/dist/src/merge-queue/conflict-resolver.test.js +405 -0
  20. package/dist/src/merge-queue/lock-file-regeneration.d.ts +14 -0
  21. package/dist/src/merge-queue/lock-file-regeneration.d.ts.map +1 -0
  22. package/dist/src/merge-queue/lock-file-regeneration.js +82 -0
  23. package/dist/src/merge-queue/lock-file-regeneration.test.d.ts +2 -0
  24. package/dist/src/merge-queue/lock-file-regeneration.test.d.ts.map +1 -0
  25. package/dist/src/merge-queue/lock-file-regeneration.test.js +236 -0
  26. package/dist/src/merge-queue/merge-worker.d.ts +79 -0
  27. package/dist/src/merge-queue/merge-worker.d.ts.map +1 -0
  28. package/dist/src/merge-queue/merge-worker.js +221 -0
  29. package/dist/src/merge-queue/merge-worker.test.d.ts +2 -0
  30. package/dist/src/merge-queue/merge-worker.test.d.ts.map +1 -0
  31. package/dist/src/merge-queue/merge-worker.test.js +883 -0
  32. package/dist/src/merge-queue/strategies/index.d.ts +19 -0
  33. package/dist/src/merge-queue/strategies/index.d.ts.map +1 -0
  34. package/dist/src/merge-queue/strategies/index.js +30 -0
  35. package/dist/src/merge-queue/strategies/merge-commit-strategy.d.ts +14 -0
  36. package/dist/src/merge-queue/strategies/merge-commit-strategy.d.ts.map +1 -0
  37. package/dist/src/merge-queue/strategies/merge-commit-strategy.js +58 -0
  38. package/dist/src/merge-queue/strategies/rebase-strategy.d.ts +14 -0
  39. package/dist/src/merge-queue/strategies/rebase-strategy.d.ts.map +1 -0
  40. package/dist/src/merge-queue/strategies/rebase-strategy.js +62 -0
  41. package/dist/src/merge-queue/strategies/squash-strategy.d.ts +14 -0
  42. package/dist/src/merge-queue/strategies/squash-strategy.d.ts.map +1 -0
  43. package/dist/src/merge-queue/strategies/squash-strategy.js +59 -0
  44. package/dist/src/merge-queue/strategies/strategies.test.d.ts +2 -0
  45. package/dist/src/merge-queue/strategies/strategies.test.d.ts.map +1 -0
  46. package/dist/src/merge-queue/strategies/strategies.test.js +354 -0
  47. package/dist/src/merge-queue/strategies/types.d.ts +62 -0
  48. package/dist/src/merge-queue/strategies/types.d.ts.map +1 -0
  49. package/dist/src/merge-queue/strategies/types.js +7 -0
  50. package/dist/src/orchestrator/parse-work-result.d.ts.map +1 -1
  51. package/dist/src/orchestrator/parse-work-result.js +22 -0
  52. package/dist/src/orchestrator/parse-work-result.test.js +49 -0
  53. package/dist/src/providers/index.d.ts +1 -0
  54. package/dist/src/providers/index.d.ts.map +1 -1
  55. package/dist/src/providers/plugin-types.d.ts +177 -0
  56. package/dist/src/providers/plugin-types.d.ts.map +1 -0
  57. package/dist/src/providers/plugin-types.js +10 -0
  58. package/dist/src/providers/plugin-types.test.d.ts +2 -0
  59. package/dist/src/providers/plugin-types.test.d.ts.map +1 -0
  60. package/dist/src/providers/plugin-types.test.js +810 -0
  61. package/dist/src/registry/index.d.ts +4 -0
  62. package/dist/src/registry/index.d.ts.map +1 -0
  63. package/dist/src/registry/index.js +2 -0
  64. package/dist/src/registry/loader.d.ts +25 -0
  65. package/dist/src/registry/loader.d.ts.map +1 -0
  66. package/dist/src/registry/loader.js +88 -0
  67. package/dist/src/registry/node-type-registry.d.ts +52 -0
  68. package/dist/src/registry/node-type-registry.d.ts.map +1 -0
  69. package/dist/src/registry/node-type-registry.js +130 -0
  70. package/dist/src/registry/types.d.ts +65 -0
  71. package/dist/src/registry/types.d.ts.map +1 -0
  72. package/dist/src/registry/types.js +10 -0
  73. package/dist/src/workflow/expression/ast.d.ts +1 -1
  74. package/dist/src/workflow/expression/ast.d.ts.map +1 -1
  75. package/dist/src/workflow/expression/context.d.ts +4 -0
  76. package/dist/src/workflow/expression/context.d.ts.map +1 -1
  77. package/dist/src/workflow/expression/context.js +5 -1
  78. package/dist/src/workflow/expression/evaluator.d.ts.map +1 -1
  79. package/dist/src/workflow/expression/evaluator.js +24 -1
  80. package/dist/src/workflow/expression/evaluator.test.js +174 -0
  81. package/dist/src/workflow/expression/expression.test.js +140 -1
  82. package/dist/src/workflow/expression/helpers.d.ts +4 -0
  83. package/dist/src/workflow/expression/helpers.d.ts.map +1 -1
  84. package/dist/src/workflow/expression/helpers.js +51 -0
  85. package/dist/src/workflow/expression/index.d.ts +14 -0
  86. package/dist/src/workflow/expression/index.d.ts.map +1 -1
  87. package/dist/src/workflow/expression/index.js +28 -1
  88. package/dist/src/workflow/expression/lexer.d.ts.map +1 -1
  89. package/dist/src/workflow/expression/lexer.js +43 -0
  90. package/dist/src/workflow/expression/parser.js +1 -1
  91. package/dist/src/workflow/index.d.ts +3 -3
  92. package/dist/src/workflow/index.d.ts.map +1 -1
  93. package/dist/src/workflow/index.js +4 -2
  94. package/dist/src/workflow/workflow-loader.d.ts +8 -2
  95. package/dist/src/workflow/workflow-loader.d.ts.map +1 -1
  96. package/dist/src/workflow/workflow-loader.js +21 -2
  97. package/dist/src/workflow/workflow-types.d.ts +781 -12
  98. package/dist/src/workflow/workflow-types.d.ts.map +1 -1
  99. package/dist/src/workflow/workflow-types.js +248 -3
  100. package/dist/src/workflow/workflow-types.test.js +621 -1
  101. 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,2 @@
1
+ export {};
2
+ //# sourceMappingURL=merge-worker.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"merge-worker.test.d.ts","sourceRoot":"","sources":["../../../src/merge-queue/merge-worker.test.ts"],"names":[],"mappings":""}