@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 @@
|
|
|
1
|
+
{"version":3,"file":"conflict-resolver.test.d.ts","sourceRoot":"","sources":["../../../src/merge-queue/conflict-resolver.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
vi.mock('child_process', () => ({
|
|
3
|
+
exec: vi.fn(),
|
|
4
|
+
}));
|
|
5
|
+
import { exec } from 'child_process';
|
|
6
|
+
import { ConflictResolver } from './conflict-resolver.js';
|
|
7
|
+
const mockExec = vi.mocked(exec);
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
// Helpers
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
function makeContext(overrides = {}) {
|
|
12
|
+
return {
|
|
13
|
+
repoPath: '/repo',
|
|
14
|
+
worktreePath: '/repo/.worktrees/wt-1',
|
|
15
|
+
sourceBranch: 'feature/SUP-100',
|
|
16
|
+
targetBranch: 'main',
|
|
17
|
+
prNumber: 42,
|
|
18
|
+
issueIdentifier: 'SUP-100',
|
|
19
|
+
conflictFiles: ['src/index.ts', 'src/utils.ts'],
|
|
20
|
+
...overrides,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
function makeConfig(overrides = {}) {
|
|
24
|
+
return {
|
|
25
|
+
mergirafEnabled: true,
|
|
26
|
+
escalationStrategy: 'notify',
|
|
27
|
+
...overrides,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Set up mockExec to respond to sequential calls.
|
|
32
|
+
* Each entry maps a pattern (substring match on the command) to a response.
|
|
33
|
+
* Commands not matching any pattern will reject with an error.
|
|
34
|
+
*/
|
|
35
|
+
function mockExecSequence(responses) {
|
|
36
|
+
const queue = [...responses];
|
|
37
|
+
mockExec.mockImplementation((cmd, _opts, callback) => {
|
|
38
|
+
const cb = typeof _opts === 'function' ? _opts : callback;
|
|
39
|
+
const cmdStr = typeof cmd === 'string' ? cmd : '';
|
|
40
|
+
const idx = queue.findIndex((r) => cmdStr.includes(r.pattern));
|
|
41
|
+
if (idx >= 0) {
|
|
42
|
+
const match = queue[idx];
|
|
43
|
+
queue.splice(idx, 1);
|
|
44
|
+
if (match.error) {
|
|
45
|
+
cb?.(match.error, { stdout: match.stdout ?? '', stderr: match.stderr ?? '' });
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
cb?.(null, { stdout: match.stdout ?? '', stderr: match.stderr ?? '' });
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
else {
|
|
52
|
+
cb?.(null, { stdout: '', stderr: '' });
|
|
53
|
+
}
|
|
54
|
+
return {};
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Simple mock where all exec calls succeed with empty stdout.
|
|
59
|
+
*/
|
|
60
|
+
function mockExecSuccess() {
|
|
61
|
+
mockExec.mockImplementation((_cmd, _opts, callback) => {
|
|
62
|
+
const cb = typeof _opts === 'function' ? _opts : callback;
|
|
63
|
+
cb?.(null, { stdout: '', stderr: '' });
|
|
64
|
+
return {};
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Mock that routes grep calls (conflict marker checks) to return specific results,
|
|
69
|
+
* and allows other commands (git add, git rebase, git diff) to succeed.
|
|
70
|
+
*/
|
|
71
|
+
function mockConflictChecks(fileResults, opts) {
|
|
72
|
+
mockExec.mockImplementation((cmd, _opts, callback) => {
|
|
73
|
+
const cb = typeof _opts === 'function' ? _opts : callback;
|
|
74
|
+
const cmdStr = typeof cmd === 'string' ? cmd : '';
|
|
75
|
+
if (cmdStr.includes('grep -c')) {
|
|
76
|
+
// Find which file this grep is checking
|
|
77
|
+
const file = Object.keys(fileResults).find((f) => cmdStr.includes(`"${f}"`));
|
|
78
|
+
if (file !== undefined) {
|
|
79
|
+
if (fileResults[file]) {
|
|
80
|
+
// Has conflict markers — grep returns count > 0
|
|
81
|
+
cb?.(null, { stdout: '3\n', stderr: '' });
|
|
82
|
+
}
|
|
83
|
+
else {
|
|
84
|
+
// No conflict markers — grep exits with code 1
|
|
85
|
+
cb?.(new Error('grep: exit code 1'), { stdout: '0\n', stderr: '' });
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
cb?.(new Error('grep: exit code 1'), { stdout: '0\n', stderr: '' });
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
else if (cmdStr.includes('git rebase --continue')) {
|
|
93
|
+
if (opts?.rebaseError) {
|
|
94
|
+
cb?.(opts.rebaseError, { stdout: '', stderr: opts.rebaseError.message });
|
|
95
|
+
}
|
|
96
|
+
else {
|
|
97
|
+
cb?.(null, { stdout: '', stderr: '' });
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
else if (cmdStr.includes('git diff')) {
|
|
101
|
+
cb?.(null, { stdout: opts?.diffOutput ?? 'diff --git a/file\n+change', stderr: '' });
|
|
102
|
+
}
|
|
103
|
+
else {
|
|
104
|
+
// git add or other commands succeed
|
|
105
|
+
cb?.(null, { stdout: '', stderr: '' });
|
|
106
|
+
}
|
|
107
|
+
return {};
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
// Tests
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
113
|
+
describe('ConflictResolver', () => {
|
|
114
|
+
beforeEach(() => {
|
|
115
|
+
vi.clearAllMocks();
|
|
116
|
+
});
|
|
117
|
+
// -------------------------------------------------------------------------
|
|
118
|
+
// resolve() routing
|
|
119
|
+
// -------------------------------------------------------------------------
|
|
120
|
+
describe('resolve()', () => {
|
|
121
|
+
it('goes straight to escalation when mergiraf is disabled', async () => {
|
|
122
|
+
mockExecSuccess();
|
|
123
|
+
const resolver = new ConflictResolver(makeConfig({ mergirafEnabled: false }));
|
|
124
|
+
const ctx = makeContext();
|
|
125
|
+
const result = await resolver.resolve(ctx);
|
|
126
|
+
expect(result.method).toBe('escalation');
|
|
127
|
+
expect(result.escalationAction).toBe('notify');
|
|
128
|
+
expect(result.unresolvedFiles).toEqual(ctx.conflictFiles);
|
|
129
|
+
// Should never call grep for conflict markers
|
|
130
|
+
const grepCalls = mockExec.mock.calls.filter((call) => typeof call[0] === 'string' && call[0].includes('grep'));
|
|
131
|
+
expect(grepCalls).toHaveLength(0);
|
|
132
|
+
});
|
|
133
|
+
it('attempts mergiraf auto-resolution first when enabled', async () => {
|
|
134
|
+
// All files have conflicts — mergiraf fails to resolve
|
|
135
|
+
mockConflictChecks({
|
|
136
|
+
'src/index.ts': true,
|
|
137
|
+
'src/utils.ts': true,
|
|
138
|
+
});
|
|
139
|
+
const resolver = new ConflictResolver(makeConfig({ mergirafEnabled: true }));
|
|
140
|
+
const ctx = makeContext();
|
|
141
|
+
const result = await resolver.resolve(ctx);
|
|
142
|
+
// Should have called grep for conflict markers
|
|
143
|
+
const grepCalls = mockExec.mock.calls.filter((call) => typeof call[0] === 'string' && call[0].includes('grep'));
|
|
144
|
+
expect(grepCalls).toHaveLength(2);
|
|
145
|
+
// Falls through to escalation
|
|
146
|
+
expect(result.escalationAction).toBe('notify');
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
// -------------------------------------------------------------------------
|
|
150
|
+
// attemptMergiraf
|
|
151
|
+
// -------------------------------------------------------------------------
|
|
152
|
+
describe('attemptMergiraf (via resolve)', () => {
|
|
153
|
+
it('resolves all files and returns resolved status', async () => {
|
|
154
|
+
// No conflict markers in any file — mergiraf resolved them all
|
|
155
|
+
mockConflictChecks({
|
|
156
|
+
'src/index.ts': false,
|
|
157
|
+
'src/utils.ts': false,
|
|
158
|
+
});
|
|
159
|
+
const resolver = new ConflictResolver(makeConfig({ mergirafEnabled: true }));
|
|
160
|
+
const ctx = makeContext();
|
|
161
|
+
const result = await resolver.resolve(ctx);
|
|
162
|
+
expect(result.status).toBe('resolved');
|
|
163
|
+
expect(result.method).toBe('mergiraf');
|
|
164
|
+
expect(result.resolvedFiles).toEqual(['src/index.ts', 'src/utils.ts']);
|
|
165
|
+
expect(result.unresolvedFiles).toBeUndefined();
|
|
166
|
+
// git add called for each resolved file
|
|
167
|
+
const addCalls = mockExec.mock.calls.filter((call) => typeof call[0] === 'string' && call[0].includes('git add'));
|
|
168
|
+
expect(addCalls).toHaveLength(2);
|
|
169
|
+
// git rebase --continue called
|
|
170
|
+
const rebaseCalls = mockExec.mock.calls.filter((call) => typeof call[0] === 'string' && call[0].includes('git rebase --continue'));
|
|
171
|
+
expect(rebaseCalls).toHaveLength(1);
|
|
172
|
+
});
|
|
173
|
+
it('resolves some files and escalates with remaining', async () => {
|
|
174
|
+
// index.ts resolved, utils.ts still has conflicts
|
|
175
|
+
mockConflictChecks({
|
|
176
|
+
'src/index.ts': false,
|
|
177
|
+
'src/utils.ts': true,
|
|
178
|
+
});
|
|
179
|
+
const resolver = new ConflictResolver(makeConfig({ mergirafEnabled: true, escalationStrategy: 'notify' }));
|
|
180
|
+
const ctx = makeContext();
|
|
181
|
+
const result = await resolver.resolve(ctx);
|
|
182
|
+
// Mergiraf partial resolution falls through to escalation
|
|
183
|
+
expect(result.status).toBe('escalated');
|
|
184
|
+
expect(result.method).toBe('escalation');
|
|
185
|
+
expect(result.escalationAction).toBe('notify');
|
|
186
|
+
// The escalation context should only contain the unresolved file
|
|
187
|
+
expect(result.unresolvedFiles).toEqual(['src/utils.ts']);
|
|
188
|
+
});
|
|
189
|
+
it('resolves no files and escalates with all files', async () => {
|
|
190
|
+
mockConflictChecks({
|
|
191
|
+
'src/index.ts': true,
|
|
192
|
+
'src/utils.ts': true,
|
|
193
|
+
});
|
|
194
|
+
const resolver = new ConflictResolver(makeConfig({ mergirafEnabled: true, escalationStrategy: 'park' }));
|
|
195
|
+
const ctx = makeContext();
|
|
196
|
+
const result = await resolver.resolve(ctx);
|
|
197
|
+
expect(result.status).toBe('parked');
|
|
198
|
+
expect(result.method).toBe('escalation');
|
|
199
|
+
expect(result.escalationAction).toBe('park');
|
|
200
|
+
expect(result.unresolvedFiles).toEqual(['src/index.ts', 'src/utils.ts']);
|
|
201
|
+
});
|
|
202
|
+
it('handles rebase --continue failure gracefully', async () => {
|
|
203
|
+
// All files resolved but rebase continue fails
|
|
204
|
+
mockConflictChecks({
|
|
205
|
+
'src/index.ts': false,
|
|
206
|
+
'src/utils.ts': false,
|
|
207
|
+
}, { rebaseError: new Error('rebase failed: could not apply commit') });
|
|
208
|
+
const resolver = new ConflictResolver(makeConfig({ mergirafEnabled: true }));
|
|
209
|
+
const ctx = makeContext();
|
|
210
|
+
const result = await resolver.resolve(ctx);
|
|
211
|
+
// Mergiraf reports escalated due to rebase failure, then resolve()
|
|
212
|
+
// falls through to the configured escalation strategy (notify)
|
|
213
|
+
expect(result.status).toBe('escalated');
|
|
214
|
+
expect(result.method).toBe('escalation');
|
|
215
|
+
expect(result.escalationAction).toBe('notify');
|
|
216
|
+
// The conflict files from mergiraf's failure are forwarded to escalation
|
|
217
|
+
expect(result.unresolvedFiles).toEqual(['src/index.ts', 'src/utils.ts']);
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
// -------------------------------------------------------------------------
|
|
221
|
+
// fileHasConflictMarkers
|
|
222
|
+
// -------------------------------------------------------------------------
|
|
223
|
+
describe('fileHasConflictMarkers (via resolve)', () => {
|
|
224
|
+
it('returns true when conflict markers are present', async () => {
|
|
225
|
+
// Set up: one file with markers, one without
|
|
226
|
+
mockConflictChecks({
|
|
227
|
+
'src/index.ts': true,
|
|
228
|
+
'src/utils.ts': false,
|
|
229
|
+
});
|
|
230
|
+
const resolver = new ConflictResolver(makeConfig({ mergirafEnabled: true }));
|
|
231
|
+
const ctx = makeContext();
|
|
232
|
+
const result = await resolver.resolve(ctx);
|
|
233
|
+
// index.ts was NOT resolved (has markers), utils.ts was resolved
|
|
234
|
+
// Partial resolution — goes to escalation for index.ts
|
|
235
|
+
expect(result.unresolvedFiles).toEqual(['src/index.ts']);
|
|
236
|
+
});
|
|
237
|
+
it('returns false when grep exits with code 1 (no markers)', async () => {
|
|
238
|
+
// All files return grep exit 1 (no markers)
|
|
239
|
+
mockConflictChecks({
|
|
240
|
+
'src/index.ts': false,
|
|
241
|
+
'src/utils.ts': false,
|
|
242
|
+
});
|
|
243
|
+
const resolver = new ConflictResolver(makeConfig({ mergirafEnabled: true }));
|
|
244
|
+
const ctx = makeContext({ conflictFiles: ['src/index.ts'] });
|
|
245
|
+
const result = await resolver.resolve(ctx);
|
|
246
|
+
expect(result.status).toBe('resolved');
|
|
247
|
+
expect(result.method).toBe('mergiraf');
|
|
248
|
+
});
|
|
249
|
+
});
|
|
250
|
+
// -------------------------------------------------------------------------
|
|
251
|
+
// Escalation strategies
|
|
252
|
+
// -------------------------------------------------------------------------
|
|
253
|
+
describe('escalation strategies', () => {
|
|
254
|
+
it('reassign returns escalated with diff context', async () => {
|
|
255
|
+
const diffOutput = 'diff --git a/src/index.ts\n+++ b/src/index.ts\n@@ conflict @@';
|
|
256
|
+
mockConflictChecks({}, { diffOutput });
|
|
257
|
+
const resolver = new ConflictResolver(makeConfig({ mergirafEnabled: false, escalationStrategy: 'reassign' }));
|
|
258
|
+
const ctx = makeContext();
|
|
259
|
+
const result = await resolver.resolve(ctx);
|
|
260
|
+
expect(result.status).toBe('escalated');
|
|
261
|
+
expect(result.method).toBe('escalation');
|
|
262
|
+
expect(result.escalationAction).toBe('reassign');
|
|
263
|
+
expect(result.unresolvedFiles).toEqual(ctx.conflictFiles);
|
|
264
|
+
expect(result.message).toContain('SUP-100');
|
|
265
|
+
expect(result.message).toContain('PR #42');
|
|
266
|
+
expect(result.message).toContain('Agent should resolve and re-submit');
|
|
267
|
+
expect(result.message).toContain('Diff:');
|
|
268
|
+
expect(result.message).toContain(diffOutput);
|
|
269
|
+
});
|
|
270
|
+
it('notify returns escalated with file list', async () => {
|
|
271
|
+
mockExecSuccess();
|
|
272
|
+
const resolver = new ConflictResolver(makeConfig({ mergirafEnabled: false, escalationStrategy: 'notify' }));
|
|
273
|
+
const ctx = makeContext();
|
|
274
|
+
const result = await resolver.resolve(ctx);
|
|
275
|
+
expect(result.status).toBe('escalated');
|
|
276
|
+
expect(result.method).toBe('escalation');
|
|
277
|
+
expect(result.escalationAction).toBe('notify');
|
|
278
|
+
expect(result.unresolvedFiles).toEqual(ctx.conflictFiles);
|
|
279
|
+
expect(result.message).toContain('Merge conflict on SUP-100 PR #42');
|
|
280
|
+
expect(result.message).toContain('src/index.ts');
|
|
281
|
+
expect(result.message).toContain('src/utils.ts');
|
|
282
|
+
});
|
|
283
|
+
it('park returns parked status', async () => {
|
|
284
|
+
mockExecSuccess();
|
|
285
|
+
const resolver = new ConflictResolver(makeConfig({ mergirafEnabled: false, escalationStrategy: 'park' }));
|
|
286
|
+
const ctx = makeContext();
|
|
287
|
+
const result = await resolver.resolve(ctx);
|
|
288
|
+
expect(result.status).toBe('parked');
|
|
289
|
+
expect(result.method).toBe('escalation');
|
|
290
|
+
expect(result.escalationAction).toBe('park');
|
|
291
|
+
expect(result.unresolvedFiles).toEqual(ctx.conflictFiles);
|
|
292
|
+
expect(result.message).toContain('PR #42 parked');
|
|
293
|
+
expect(result.message).toContain('auto-retry');
|
|
294
|
+
});
|
|
295
|
+
it('reassign includes truncated diff when git diff output is available', async () => {
|
|
296
|
+
const longDiff = 'x'.repeat(6000);
|
|
297
|
+
mockConflictChecks({}, { diffOutput: longDiff });
|
|
298
|
+
const resolver = new ConflictResolver(makeConfig({ mergirafEnabled: false, escalationStrategy: 'reassign' }));
|
|
299
|
+
const ctx = makeContext();
|
|
300
|
+
const result = await resolver.resolve(ctx);
|
|
301
|
+
// The diff should be truncated to 5000 chars
|
|
302
|
+
expect(result.message).toBeDefined();
|
|
303
|
+
expect(result.message.includes('Diff:')).toBe(true);
|
|
304
|
+
// The diff portion within the message should be at most 5000 chars
|
|
305
|
+
const diffStart = result.message.indexOf('Diff:\n') + 'Diff:\n'.length;
|
|
306
|
+
const diffPortion = result.message.slice(diffStart);
|
|
307
|
+
expect(diffPortion.length).toBeLessThanOrEqual(5000);
|
|
308
|
+
});
|
|
309
|
+
it('reassign handles git diff failure gracefully', async () => {
|
|
310
|
+
// Make git diff fail
|
|
311
|
+
mockExec.mockImplementation((cmd, _opts, callback) => {
|
|
312
|
+
const cb = typeof _opts === 'function' ? _opts : callback;
|
|
313
|
+
const cmdStr = typeof cmd === 'string' ? cmd : '';
|
|
314
|
+
if (cmdStr.includes('git diff')) {
|
|
315
|
+
cb?.(new Error('diff failed'), { stdout: '', stderr: '' });
|
|
316
|
+
}
|
|
317
|
+
else {
|
|
318
|
+
cb?.(null, { stdout: '', stderr: '' });
|
|
319
|
+
}
|
|
320
|
+
return {};
|
|
321
|
+
});
|
|
322
|
+
const resolver = new ConflictResolver(makeConfig({ mergirafEnabled: false, escalationStrategy: 'reassign' }));
|
|
323
|
+
const ctx = makeContext();
|
|
324
|
+
const result = await resolver.resolve(ctx);
|
|
325
|
+
expect(result.status).toBe('escalated');
|
|
326
|
+
expect(result.escalationAction).toBe('reassign');
|
|
327
|
+
expect(result.message).toContain('(unable to generate diff)');
|
|
328
|
+
});
|
|
329
|
+
});
|
|
330
|
+
// -------------------------------------------------------------------------
|
|
331
|
+
// rebase --continue
|
|
332
|
+
// -------------------------------------------------------------------------
|
|
333
|
+
describe('rebase --continue', () => {
|
|
334
|
+
it('is called after all conflicts are resolved by mergiraf', async () => {
|
|
335
|
+
mockConflictChecks({
|
|
336
|
+
'src/index.ts': false,
|
|
337
|
+
'src/utils.ts': false,
|
|
338
|
+
});
|
|
339
|
+
const resolver = new ConflictResolver(makeConfig({ mergirafEnabled: true }));
|
|
340
|
+
const ctx = makeContext();
|
|
341
|
+
await resolver.resolve(ctx);
|
|
342
|
+
const rebaseCalls = mockExec.mock.calls.filter((call) => typeof call[0] === 'string' && call[0].includes('git rebase --continue'));
|
|
343
|
+
expect(rebaseCalls).toHaveLength(1);
|
|
344
|
+
// Verify GIT_EDITOR=true is set to avoid editor prompt
|
|
345
|
+
const rebaseOpts = rebaseCalls[0][1];
|
|
346
|
+
expect(rebaseOpts.env?.GIT_EDITOR).toBe('true');
|
|
347
|
+
});
|
|
348
|
+
it('is NOT called when some files remain unresolved', async () => {
|
|
349
|
+
mockConflictChecks({
|
|
350
|
+
'src/index.ts': false,
|
|
351
|
+
'src/utils.ts': true,
|
|
352
|
+
});
|
|
353
|
+
const resolver = new ConflictResolver(makeConfig({ mergirafEnabled: true }));
|
|
354
|
+
const ctx = makeContext();
|
|
355
|
+
await resolver.resolve(ctx);
|
|
356
|
+
const rebaseCalls = mockExec.mock.calls.filter((call) => typeof call[0] === 'string' && call[0].includes('git rebase --continue'));
|
|
357
|
+
expect(rebaseCalls).toHaveLength(0);
|
|
358
|
+
});
|
|
359
|
+
it('failure results in escalation with descriptive message', async () => {
|
|
360
|
+
mockConflictChecks({ 'src/index.ts': false }, { rebaseError: new Error('Could not apply abc123') });
|
|
361
|
+
const resolver = new ConflictResolver(makeConfig({ mergirafEnabled: true, escalationStrategy: 'notify' }));
|
|
362
|
+
const ctx = makeContext({ conflictFiles: ['src/index.ts'] });
|
|
363
|
+
const result = await resolver.resolve(ctx);
|
|
364
|
+
// When rebase --continue fails, attemptMergiraf returns 'escalated',
|
|
365
|
+
// which causes resolve() to fall through to the escalation strategy.
|
|
366
|
+
// The final result comes from the notify escalation.
|
|
367
|
+
expect(result.status).toBe('escalated');
|
|
368
|
+
expect(result.method).toBe('escalation');
|
|
369
|
+
expect(result.escalationAction).toBe('notify');
|
|
370
|
+
expect(result.message).toContain('Merge conflict on SUP-100 PR #42');
|
|
371
|
+
});
|
|
372
|
+
});
|
|
373
|
+
// -------------------------------------------------------------------------
|
|
374
|
+
// Edge cases
|
|
375
|
+
// -------------------------------------------------------------------------
|
|
376
|
+
describe('edge cases', () => {
|
|
377
|
+
it('handles empty conflictFiles list', async () => {
|
|
378
|
+
mockExecSuccess();
|
|
379
|
+
const resolver = new ConflictResolver(makeConfig({ mergirafEnabled: false }));
|
|
380
|
+
const ctx = makeContext({ conflictFiles: [] });
|
|
381
|
+
const result = await resolver.resolve(ctx);
|
|
382
|
+
expect(result.status).toBe('escalated');
|
|
383
|
+
expect(result.unresolvedFiles).toEqual([]);
|
|
384
|
+
});
|
|
385
|
+
it('handles single conflict file', async () => {
|
|
386
|
+
mockConflictChecks({ 'src/index.ts': false });
|
|
387
|
+
const resolver = new ConflictResolver(makeConfig({ mergirafEnabled: true }));
|
|
388
|
+
const ctx = makeContext({ conflictFiles: ['src/index.ts'] });
|
|
389
|
+
const result = await resolver.resolve(ctx);
|
|
390
|
+
expect(result.status).toBe('resolved');
|
|
391
|
+
expect(result.resolvedFiles).toEqual(['src/index.ts']);
|
|
392
|
+
});
|
|
393
|
+
it('defaults to notify escalation for unknown strategy', async () => {
|
|
394
|
+
mockExecSuccess();
|
|
395
|
+
// Force an unknown strategy to test the default branch
|
|
396
|
+
const resolver = new ConflictResolver(makeConfig({
|
|
397
|
+
mergirafEnabled: false,
|
|
398
|
+
escalationStrategy: 'unknown',
|
|
399
|
+
}));
|
|
400
|
+
const ctx = makeContext();
|
|
401
|
+
const result = await resolver.resolve(ctx);
|
|
402
|
+
expect(result.escalationAction).toBe('notify');
|
|
403
|
+
});
|
|
404
|
+
});
|
|
405
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export type PackageManager = 'pnpm' | 'npm' | 'yarn' | 'bun' | 'none';
|
|
2
|
+
export interface RegenerationResult {
|
|
3
|
+
success: boolean;
|
|
4
|
+
lockFile: string;
|
|
5
|
+
packageManager: PackageManager;
|
|
6
|
+
error?: string;
|
|
7
|
+
}
|
|
8
|
+
export declare class LockFileRegeneration {
|
|
9
|
+
shouldRegenerate(packageManager: PackageManager, lockFileRegenerate: boolean): boolean;
|
|
10
|
+
getLockFileName(packageManager: PackageManager): string | null;
|
|
11
|
+
regenerate(worktreePath: string, packageManager: PackageManager): Promise<RegenerationResult>;
|
|
12
|
+
ensureGitAttributes(repoPath: string, packageManager: PackageManager): Promise<void>;
|
|
13
|
+
}
|
|
14
|
+
//# sourceMappingURL=lock-file-regeneration.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"lock-file-regeneration.d.ts","sourceRoot":"","sources":["../../../src/merge-queue/lock-file-regeneration.ts"],"names":[],"mappings":"AAOA,MAAM,MAAM,cAAc,GAAG,MAAM,GAAG,KAAK,GAAG,MAAM,GAAG,KAAK,GAAG,MAAM,CAAA;AAuBrE,MAAM,WAAW,kBAAkB;IACjC,OAAO,EAAE,OAAO,CAAA;IAChB,QAAQ,EAAE,MAAM,CAAA;IAChB,cAAc,EAAE,cAAc,CAAA;IAC9B,KAAK,CAAC,EAAE,MAAM,CAAA;CACf;AAED,qBAAa,oBAAoB;IAC/B,gBAAgB,CAAC,cAAc,EAAE,cAAc,EAAE,kBAAkB,EAAE,OAAO,GAAG,OAAO;IAItF,eAAe,CAAC,cAAc,EAAE,cAAc,GAAG,MAAM,GAAG,IAAI;IAIxD,UAAU,CAAC,YAAY,EAAE,MAAM,EAAE,cAAc,EAAE,cAAc,GAAG,OAAO,CAAC,kBAAkB,CAAC;IAmC7F,mBAAmB,CAAC,QAAQ,EAAE,MAAM,EAAE,cAAc,EAAE,cAAc,GAAG,OAAO,CAAC,IAAI,CAAC;CAuB3F"}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { exec as execCb } from 'child_process';
|
|
2
|
+
import { promisify } from 'util';
|
|
3
|
+
import { readFile, writeFile, access } from 'fs/promises';
|
|
4
|
+
import { join } from 'path';
|
|
5
|
+
const exec = promisify(execCb);
|
|
6
|
+
const LOCK_FILES = {
|
|
7
|
+
pnpm: 'pnpm-lock.yaml',
|
|
8
|
+
npm: 'package-lock.json',
|
|
9
|
+
yarn: 'yarn.lock',
|
|
10
|
+
bun: 'bun.lockb',
|
|
11
|
+
};
|
|
12
|
+
const INSTALL_COMMANDS = {
|
|
13
|
+
pnpm: 'pnpm install --no-frozen-lockfile',
|
|
14
|
+
npm: 'npm install',
|
|
15
|
+
yarn: 'yarn install',
|
|
16
|
+
bun: 'bun install',
|
|
17
|
+
};
|
|
18
|
+
const GITATTRIBUTES_ENTRIES = {
|
|
19
|
+
pnpm: 'pnpm-lock.yaml merge=ours',
|
|
20
|
+
npm: 'package-lock.json merge=ours',
|
|
21
|
+
yarn: 'yarn.lock merge=ours',
|
|
22
|
+
bun: 'bun.lockb merge=ours',
|
|
23
|
+
};
|
|
24
|
+
export class LockFileRegeneration {
|
|
25
|
+
shouldRegenerate(packageManager, lockFileRegenerate) {
|
|
26
|
+
return lockFileRegenerate && packageManager !== 'none';
|
|
27
|
+
}
|
|
28
|
+
getLockFileName(packageManager) {
|
|
29
|
+
return LOCK_FILES[packageManager] ?? null;
|
|
30
|
+
}
|
|
31
|
+
async regenerate(worktreePath, packageManager) {
|
|
32
|
+
const lockFile = this.getLockFileName(packageManager);
|
|
33
|
+
if (!lockFile) {
|
|
34
|
+
return { success: false, lockFile: '', packageManager, error: `Unsupported package manager: ${packageManager}` };
|
|
35
|
+
}
|
|
36
|
+
const installCommand = INSTALL_COMMANDS[packageManager];
|
|
37
|
+
try {
|
|
38
|
+
// 1. Delete the conflicted lock file (if it exists)
|
|
39
|
+
const lockFilePath = join(worktreePath, lockFile);
|
|
40
|
+
try {
|
|
41
|
+
await access(lockFilePath);
|
|
42
|
+
await exec(`rm "${lockFile}"`, { cwd: worktreePath });
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
// Lock file doesn't exist, that's fine
|
|
46
|
+
}
|
|
47
|
+
// 2. Run package manager install to regenerate
|
|
48
|
+
await exec(installCommand, {
|
|
49
|
+
cwd: worktreePath,
|
|
50
|
+
timeout: 120_000, // 2 minute timeout
|
|
51
|
+
maxBuffer: 10 * 1024 * 1024, // 10MB buffer for install output
|
|
52
|
+
});
|
|
53
|
+
// 3. Stage the regenerated lock file
|
|
54
|
+
await exec(`git add "${lockFile}"`, { cwd: worktreePath });
|
|
55
|
+
return { success: true, lockFile, packageManager };
|
|
56
|
+
}
|
|
57
|
+
catch (error) {
|
|
58
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
59
|
+
return { success: false, lockFile, packageManager, error: message };
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
async ensureGitAttributes(repoPath, packageManager) {
|
|
63
|
+
const entry = GITATTRIBUTES_ENTRIES[packageManager];
|
|
64
|
+
if (!entry)
|
|
65
|
+
return;
|
|
66
|
+
const gitattributesPath = join(repoPath, '.gitattributes');
|
|
67
|
+
let content = '';
|
|
68
|
+
try {
|
|
69
|
+
content = await readFile(gitattributesPath, 'utf-8');
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
// File doesn't exist yet
|
|
73
|
+
}
|
|
74
|
+
if (content.includes(entry)) {
|
|
75
|
+
return; // Already configured
|
|
76
|
+
}
|
|
77
|
+
const newContent = content.endsWith('\n') || content === ''
|
|
78
|
+
? content + entry + '\n'
|
|
79
|
+
: content + '\n' + entry + '\n';
|
|
80
|
+
await writeFile(gitattributesPath, newContent, 'utf-8');
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"lock-file-regeneration.test.d.ts","sourceRoot":"","sources":["../../../src/merge-queue/lock-file-regeneration.test.ts"],"names":[],"mappings":""}
|