edsger 0.39.3 ā 0.40.1
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/phases/pr-execution/__tests__/file-assigner.test.d.ts +1 -0
- package/dist/phases/pr-execution/__tests__/file-assigner.test.js +294 -0
- package/dist/phases/pr-execution/context.d.ts +5 -1
- package/dist/phases/pr-execution/context.js +81 -9
- package/dist/phases/pr-execution/file-assigner.d.ts +57 -0
- package/dist/phases/pr-execution/file-assigner.js +278 -0
- package/dist/phases/pr-execution/index.js +35 -4
- package/dist/phases/pr-execution/prompts.d.ts +2 -1
- package/dist/phases/pr-execution/prompts.js +1 -1
- package/dist/phases/pr-splitting/import-dep-validator.js +36 -25
- package/dist/phases/pr-splitting/index.js +22 -17
- package/package.json +1 -1
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
import assert from 'node:assert';
|
|
2
|
+
import { describe, it } from 'node:test';
|
|
3
|
+
import { applyAssignments, findUnassignedFiles, removeDeletedFilesFromPRs, } from '../file-assigner.js';
|
|
4
|
+
function makePR(overrides) {
|
|
5
|
+
return {
|
|
6
|
+
feature_id: 'feat-1',
|
|
7
|
+
name: `PR ${overrides.sequence}`,
|
|
8
|
+
description: null,
|
|
9
|
+
branch_name: null,
|
|
10
|
+
base_pr_id: null,
|
|
11
|
+
pull_request_url: null,
|
|
12
|
+
pull_request_number: null,
|
|
13
|
+
compare_url: null,
|
|
14
|
+
status: 'pending',
|
|
15
|
+
last_synced_commit: null,
|
|
16
|
+
files: null,
|
|
17
|
+
created_by: 'user',
|
|
18
|
+
created_at: '2026-01-01',
|
|
19
|
+
updated_at: '2026-01-01',
|
|
20
|
+
...overrides,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
function cf(path, change_type = 'modified') {
|
|
24
|
+
return { path, change_type };
|
|
25
|
+
}
|
|
26
|
+
// ============================================================
|
|
27
|
+
// findUnassignedFiles
|
|
28
|
+
// ============================================================
|
|
29
|
+
void describe('findUnassignedFiles', () => {
|
|
30
|
+
void it('returns all files when no PR has files', () => {
|
|
31
|
+
const prs = [makePR({ id: '1', sequence: 1, files: null })];
|
|
32
|
+
const result = findUnassignedFiles([cf('a.ts'), cf('b.ts')], prs);
|
|
33
|
+
assert.deepStrictEqual(result, [cf('a.ts'), cf('b.ts')]);
|
|
34
|
+
});
|
|
35
|
+
void it('returns empty when all files are assigned', () => {
|
|
36
|
+
const prs = [
|
|
37
|
+
makePR({
|
|
38
|
+
id: '1',
|
|
39
|
+
sequence: 1,
|
|
40
|
+
files: [
|
|
41
|
+
{ path: 'a.ts', change_type: 'modified' },
|
|
42
|
+
{ path: 'b.ts', change_type: 'added' },
|
|
43
|
+
],
|
|
44
|
+
}),
|
|
45
|
+
];
|
|
46
|
+
const result = findUnassignedFiles([cf('a.ts'), cf('b.ts', 'added')], prs);
|
|
47
|
+
assert.deepStrictEqual(result, []);
|
|
48
|
+
});
|
|
49
|
+
void it('returns only unassigned files', () => {
|
|
50
|
+
const prs = [
|
|
51
|
+
makePR({
|
|
52
|
+
id: '1',
|
|
53
|
+
sequence: 1,
|
|
54
|
+
files: [{ path: 'a.ts', change_type: 'modified' }],
|
|
55
|
+
}),
|
|
56
|
+
makePR({
|
|
57
|
+
id: '2',
|
|
58
|
+
sequence: 2,
|
|
59
|
+
files: [{ path: 'b.ts', change_type: 'added' }],
|
|
60
|
+
}),
|
|
61
|
+
];
|
|
62
|
+
const result = findUnassignedFiles([cf('a.ts'), cf('b.ts'), cf('c.ts'), cf('d.ts', 'added')], prs);
|
|
63
|
+
assert.deepStrictEqual(result, [cf('c.ts'), cf('d.ts', 'added')]);
|
|
64
|
+
});
|
|
65
|
+
void it('handles PRs with empty files array', () => {
|
|
66
|
+
const prs = [
|
|
67
|
+
makePR({ id: '1', sequence: 1, files: [] }),
|
|
68
|
+
makePR({
|
|
69
|
+
id: '2',
|
|
70
|
+
sequence: 2,
|
|
71
|
+
files: [{ path: 'a.ts', change_type: 'modified' }],
|
|
72
|
+
}),
|
|
73
|
+
];
|
|
74
|
+
const result = findUnassignedFiles([cf('a.ts'), cf('b.ts')], prs);
|
|
75
|
+
assert.deepStrictEqual(result, [cf('b.ts')]);
|
|
76
|
+
});
|
|
77
|
+
void it('returns empty for empty changed files', () => {
|
|
78
|
+
const prs = [
|
|
79
|
+
makePR({
|
|
80
|
+
id: '1',
|
|
81
|
+
sequence: 1,
|
|
82
|
+
files: [{ path: 'a.ts', change_type: 'modified' }],
|
|
83
|
+
}),
|
|
84
|
+
];
|
|
85
|
+
const result = findUnassignedFiles([], prs);
|
|
86
|
+
assert.deepStrictEqual(result, []);
|
|
87
|
+
});
|
|
88
|
+
void it('handles files spread across multiple PRs', () => {
|
|
89
|
+
const prs = [
|
|
90
|
+
makePR({
|
|
91
|
+
id: '1',
|
|
92
|
+
sequence: 1,
|
|
93
|
+
files: [
|
|
94
|
+
{ path: 'src/auth/login.ts', change_type: 'modified' },
|
|
95
|
+
{ path: 'src/auth/session.ts', change_type: 'modified' },
|
|
96
|
+
],
|
|
97
|
+
}),
|
|
98
|
+
makePR({
|
|
99
|
+
id: '2',
|
|
100
|
+
sequence: 2,
|
|
101
|
+
files: [
|
|
102
|
+
{ path: 'src/api/users.ts', change_type: 'added' },
|
|
103
|
+
{ path: 'src/api/roles.ts', change_type: 'added' },
|
|
104
|
+
],
|
|
105
|
+
}),
|
|
106
|
+
];
|
|
107
|
+
const changedFiles = [
|
|
108
|
+
cf('src/auth/login.ts'),
|
|
109
|
+
cf('src/auth/session.ts'),
|
|
110
|
+
cf('src/api/users.ts'),
|
|
111
|
+
cf('src/api/roles.ts'),
|
|
112
|
+
cf('src/auth/oauth.ts', 'added'),
|
|
113
|
+
cf('README.md'),
|
|
114
|
+
];
|
|
115
|
+
const result = findUnassignedFiles(changedFiles, prs);
|
|
116
|
+
assert.deepStrictEqual(result, [
|
|
117
|
+
cf('src/auth/oauth.ts', 'added'),
|
|
118
|
+
cf('README.md'),
|
|
119
|
+
]);
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
// ============================================================
|
|
123
|
+
// applyAssignments
|
|
124
|
+
// ============================================================
|
|
125
|
+
void describe('applyAssignments', () => {
|
|
126
|
+
void it('merges new files into existing PR files', async () => {
|
|
127
|
+
const prs = [
|
|
128
|
+
makePR({
|
|
129
|
+
id: 'pr-1',
|
|
130
|
+
sequence: 1,
|
|
131
|
+
files: [{ path: 'a.ts', change_type: 'modified' }],
|
|
132
|
+
}),
|
|
133
|
+
];
|
|
134
|
+
const unassigned = [cf('b.ts', 'added')];
|
|
135
|
+
const updates = [];
|
|
136
|
+
const mockUpdater = async (prId, u) => {
|
|
137
|
+
updates.push({ prId, files: u.files });
|
|
138
|
+
};
|
|
139
|
+
const count = await applyAssignments([{ file: 'b.ts', pr_id: 'pr-1' }], unassigned, prs, mockUpdater);
|
|
140
|
+
assert.strictEqual(count, 1);
|
|
141
|
+
assert.strictEqual(updates.length, 1);
|
|
142
|
+
assert.strictEqual(updates[0].prId, 'pr-1');
|
|
143
|
+
assert.deepStrictEqual(updates[0].files, [
|
|
144
|
+
{ path: 'a.ts', change_type: 'modified' },
|
|
145
|
+
{ path: 'b.ts', change_type: 'added' },
|
|
146
|
+
]);
|
|
147
|
+
});
|
|
148
|
+
void it('deduplicates files already in PR', async () => {
|
|
149
|
+
const prs = [
|
|
150
|
+
makePR({
|
|
151
|
+
id: 'pr-1',
|
|
152
|
+
sequence: 1,
|
|
153
|
+
files: [{ path: 'a.ts', change_type: 'modified' }],
|
|
154
|
+
}),
|
|
155
|
+
];
|
|
156
|
+
const unassigned = [cf('a.ts')];
|
|
157
|
+
const updates = [];
|
|
158
|
+
const mockUpdater = async (_prId, u) => {
|
|
159
|
+
updates.push({ files: u.files });
|
|
160
|
+
};
|
|
161
|
+
await applyAssignments([{ file: 'a.ts', pr_id: 'pr-1' }], unassigned, prs, mockUpdater);
|
|
162
|
+
// File list should not have duplicates
|
|
163
|
+
assert.deepStrictEqual(updates[0].files, [
|
|
164
|
+
{ path: 'a.ts', change_type: 'modified' },
|
|
165
|
+
]);
|
|
166
|
+
});
|
|
167
|
+
void it('skips unknown PR ids', async () => {
|
|
168
|
+
const prs = [makePR({ id: 'pr-1', sequence: 1, files: [] })];
|
|
169
|
+
const unassigned = [cf('x.ts', 'added')];
|
|
170
|
+
let called = false;
|
|
171
|
+
const mockUpdater = async () => {
|
|
172
|
+
called = true;
|
|
173
|
+
};
|
|
174
|
+
const count = await applyAssignments([{ file: 'x.ts', pr_id: 'unknown-id' }], unassigned, prs, mockUpdater);
|
|
175
|
+
assert.strictEqual(count, 0);
|
|
176
|
+
assert.strictEqual(called, false);
|
|
177
|
+
});
|
|
178
|
+
void it('uses change_type from git, not a hardcoded default', async () => {
|
|
179
|
+
const prs = [makePR({ id: 'pr-1', sequence: 1, files: [] })];
|
|
180
|
+
const unassigned = [cf('new.ts', 'added')];
|
|
181
|
+
const updates = [];
|
|
182
|
+
const mockUpdater = async (_prId, u) => {
|
|
183
|
+
updates.push({ files: u.files });
|
|
184
|
+
};
|
|
185
|
+
await applyAssignments([{ file: 'new.ts', pr_id: 'pr-1' }], unassigned, prs, mockUpdater);
|
|
186
|
+
assert.strictEqual(updates[0].files[0].change_type, 'added');
|
|
187
|
+
});
|
|
188
|
+
void it('distributes files to multiple PRs', async () => {
|
|
189
|
+
const prs = [
|
|
190
|
+
makePR({ id: 'pr-1', sequence: 1, files: [] }),
|
|
191
|
+
makePR({ id: 'pr-2', sequence: 2, files: [] }),
|
|
192
|
+
];
|
|
193
|
+
const unassigned = [
|
|
194
|
+
cf('a.ts', 'added'),
|
|
195
|
+
cf('b.ts', 'modified'),
|
|
196
|
+
];
|
|
197
|
+
const updates = [];
|
|
198
|
+
const mockUpdater = async (prId) => {
|
|
199
|
+
updates.push({ prId });
|
|
200
|
+
};
|
|
201
|
+
const count = await applyAssignments([
|
|
202
|
+
{ file: 'a.ts', pr_id: 'pr-1' },
|
|
203
|
+
{ file: 'b.ts', pr_id: 'pr-2' },
|
|
204
|
+
], unassigned, prs, mockUpdater);
|
|
205
|
+
assert.strictEqual(count, 2);
|
|
206
|
+
assert.deepStrictEqual(updates.map((u) => u.prId), ['pr-1', 'pr-2']);
|
|
207
|
+
});
|
|
208
|
+
void it('handles updater failure gracefully', async () => {
|
|
209
|
+
const prs = [makePR({ id: 'pr-1', sequence: 1, files: [] })];
|
|
210
|
+
const unassigned = [cf('a.ts')];
|
|
211
|
+
const mockUpdater = async () => {
|
|
212
|
+
throw new Error('DB error');
|
|
213
|
+
};
|
|
214
|
+
const count = await applyAssignments([{ file: 'a.ts', pr_id: 'pr-1' }], unassigned, prs, mockUpdater);
|
|
215
|
+
assert.strictEqual(count, 0);
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
// ============================================================
|
|
219
|
+
// removeDeletedFilesFromPRs
|
|
220
|
+
// ============================================================
|
|
221
|
+
void describe('removeDeletedFilesFromPRs', () => {
|
|
222
|
+
void it('removes deleted files from PR file lists', async () => {
|
|
223
|
+
const prs = [
|
|
224
|
+
makePR({
|
|
225
|
+
id: 'pr-1',
|
|
226
|
+
sequence: 1,
|
|
227
|
+
files: [
|
|
228
|
+
{ path: 'keep.ts', change_type: 'modified' },
|
|
229
|
+
{ path: 'gone.ts', change_type: 'added' },
|
|
230
|
+
],
|
|
231
|
+
}),
|
|
232
|
+
];
|
|
233
|
+
const changedFiles = [
|
|
234
|
+
cf('keep.ts'),
|
|
235
|
+
cf('gone.ts', 'deleted'),
|
|
236
|
+
];
|
|
237
|
+
const updates = [];
|
|
238
|
+
const mockUpdater = async (prId, u) => {
|
|
239
|
+
updates.push({ prId, files: u.files });
|
|
240
|
+
};
|
|
241
|
+
const count = await removeDeletedFilesFromPRs(changedFiles, prs, mockUpdater);
|
|
242
|
+
assert.strictEqual(count, 1);
|
|
243
|
+
assert.deepStrictEqual(updates[0].files, [
|
|
244
|
+
{ path: 'keep.ts', change_type: 'modified' },
|
|
245
|
+
]);
|
|
246
|
+
});
|
|
247
|
+
void it('returns 0 when no files are deleted', async () => {
|
|
248
|
+
const prs = [
|
|
249
|
+
makePR({
|
|
250
|
+
id: 'pr-1',
|
|
251
|
+
sequence: 1,
|
|
252
|
+
files: [{ path: 'a.ts', change_type: 'modified' }],
|
|
253
|
+
}),
|
|
254
|
+
];
|
|
255
|
+
let called = false;
|
|
256
|
+
const mockUpdater = async () => {
|
|
257
|
+
called = true;
|
|
258
|
+
};
|
|
259
|
+
const count = await removeDeletedFilesFromPRs([cf('a.ts')], prs, mockUpdater);
|
|
260
|
+
assert.strictEqual(count, 0);
|
|
261
|
+
assert.strictEqual(called, false);
|
|
262
|
+
});
|
|
263
|
+
void it('skips PRs with no matching deleted files', async () => {
|
|
264
|
+
const prs = [
|
|
265
|
+
makePR({
|
|
266
|
+
id: 'pr-1',
|
|
267
|
+
sequence: 1,
|
|
268
|
+
files: [{ path: 'a.ts', change_type: 'modified' }],
|
|
269
|
+
}),
|
|
270
|
+
makePR({
|
|
271
|
+
id: 'pr-2',
|
|
272
|
+
sequence: 2,
|
|
273
|
+
files: [{ path: 'b.ts', change_type: 'added' }],
|
|
274
|
+
}),
|
|
275
|
+
];
|
|
276
|
+
const updates = [];
|
|
277
|
+
const mockUpdater = async (prId) => {
|
|
278
|
+
updates.push(prId);
|
|
279
|
+
};
|
|
280
|
+
await removeDeletedFilesFromPRs([cf('b.ts', 'deleted')], prs, mockUpdater);
|
|
281
|
+
// Only pr-2 should be updated
|
|
282
|
+
assert.deepStrictEqual(updates, ['pr-2']);
|
|
283
|
+
});
|
|
284
|
+
void it('handles PRs with null files', async () => {
|
|
285
|
+
const prs = [makePR({ id: 'pr-1', sequence: 1, files: null })];
|
|
286
|
+
let called = false;
|
|
287
|
+
const mockUpdater = async () => {
|
|
288
|
+
called = true;
|
|
289
|
+
};
|
|
290
|
+
const count = await removeDeletedFilesFromPRs([cf('a.ts', 'deleted')], prs, mockUpdater);
|
|
291
|
+
assert.strictEqual(count, 0);
|
|
292
|
+
assert.strictEqual(called, false);
|
|
293
|
+
});
|
|
294
|
+
});
|
|
@@ -8,6 +8,10 @@ export interface GitHubConfigInfo {
|
|
|
8
8
|
repo?: string;
|
|
9
9
|
message?: string;
|
|
10
10
|
}
|
|
11
|
+
export interface ChangedFileInfo {
|
|
12
|
+
path: string;
|
|
13
|
+
change_type: 'added' | 'modified' | 'deleted' | 'renamed';
|
|
14
|
+
}
|
|
11
15
|
export interface PRExecutionContext {
|
|
12
16
|
feature: FeatureInfo;
|
|
13
17
|
pullRequests: PullRequest[];
|
|
@@ -18,7 +22,7 @@ export interface PRExecutionContext {
|
|
|
18
22
|
isIncrementalSync: boolean;
|
|
19
23
|
lastSyncedCommit: string | null;
|
|
20
24
|
diffStat: string;
|
|
21
|
-
changedFiles:
|
|
25
|
+
changedFiles: ChangedFileInfo[];
|
|
22
26
|
}
|
|
23
27
|
/**
|
|
24
28
|
* Fetch context for PR execution phase
|
|
@@ -32,22 +32,61 @@ function getDiffStat(baseRef, headRef) {
|
|
|
32
32
|
}
|
|
33
33
|
}
|
|
34
34
|
/**
|
|
35
|
-
*
|
|
35
|
+
* Map git diff --name-status letter to a readable change type
|
|
36
|
+
*/
|
|
37
|
+
function parseGitStatus(status) {
|
|
38
|
+
switch (status[0]) {
|
|
39
|
+
case 'A':
|
|
40
|
+
return 'added';
|
|
41
|
+
case 'D':
|
|
42
|
+
return 'deleted';
|
|
43
|
+
case 'R':
|
|
44
|
+
return 'renamed';
|
|
45
|
+
default:
|
|
46
|
+
return 'modified';
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Get list of changed files with their change types between two refs
|
|
36
51
|
*/
|
|
37
52
|
function getChangedFiles(baseRef, headRef) {
|
|
38
53
|
try {
|
|
39
|
-
const output = execSync(`git diff --name-
|
|
54
|
+
const output = execSync(`git diff --name-status ${baseRef}...${headRef}`, {
|
|
40
55
|
encoding: 'utf-8',
|
|
41
56
|
}).trim();
|
|
42
57
|
if (!output) {
|
|
43
58
|
return [];
|
|
44
59
|
}
|
|
45
|
-
return output
|
|
60
|
+
return output
|
|
61
|
+
.split('\n')
|
|
62
|
+
.filter((line) => line.length > 0)
|
|
63
|
+
.map((line) => {
|
|
64
|
+
const [status, ...pathParts] = line.split('\t');
|
|
65
|
+
// For renames (R100\told\tnew), use the new path
|
|
66
|
+
const path = pathParts.length > 1 ? pathParts[1] : pathParts[0];
|
|
67
|
+
return { path, change_type: parseGitStatus(status) };
|
|
68
|
+
});
|
|
46
69
|
}
|
|
47
70
|
catch {
|
|
48
71
|
return [];
|
|
49
72
|
}
|
|
50
73
|
}
|
|
74
|
+
/**
|
|
75
|
+
* Check if a commit exists in the ancestry of a given ref.
|
|
76
|
+
* Returns false if the commit was orphaned (e.g. after a rebase).
|
|
77
|
+
*/
|
|
78
|
+
function isAncestor(commitSha, ref) {
|
|
79
|
+
try {
|
|
80
|
+
execSync(`git merge-base --is-ancestor ${commitSha} ${ref}`, {
|
|
81
|
+
encoding: 'utf-8',
|
|
82
|
+
stdio: 'pipe',
|
|
83
|
+
});
|
|
84
|
+
return true;
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
51
90
|
/**
|
|
52
91
|
* Determine the common last_synced_commit from existing PRs
|
|
53
92
|
* Returns null if any PR hasn't been synced yet (first run)
|
|
@@ -82,6 +121,27 @@ export async function fetchPRExecutionContext(featureId, verbose) {
|
|
|
82
121
|
getPullRequests({ featureId, verbose }),
|
|
83
122
|
getGitHubConfig(featureId, verbose),
|
|
84
123
|
]);
|
|
124
|
+
// Fetch latest remote refs and fast-forward local main
|
|
125
|
+
try {
|
|
126
|
+
const credArgs = buildCredentialArgs(githubConfig.token);
|
|
127
|
+
execFileSync('git', [...credArgs, 'fetch', 'origin'], {
|
|
128
|
+
encoding: 'utf-8',
|
|
129
|
+
stdio: 'pipe',
|
|
130
|
+
});
|
|
131
|
+
execSync('git checkout main', { encoding: 'utf-8', stdio: 'pipe' });
|
|
132
|
+
execSync('git merge --ff-only origin/main', {
|
|
133
|
+
encoding: 'utf-8',
|
|
134
|
+
stdio: 'pipe',
|
|
135
|
+
});
|
|
136
|
+
if (verbose) {
|
|
137
|
+
logInfo('ā
Local main synced with origin/main');
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
catch (error) {
|
|
141
|
+
if (verbose) {
|
|
142
|
+
logInfo(`ā ļø Could not sync main with origin: ${error instanceof Error ? error.message : String(error)}`);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
85
145
|
// Verify dev branch exists
|
|
86
146
|
const localExists = branchExists(devBranchName);
|
|
87
147
|
const remoteExists = !localExists && remoteBranchExists(devBranchName, githubConfig.token);
|
|
@@ -95,10 +155,12 @@ export async function fetchPRExecutionContext(featureId, verbose) {
|
|
|
95
155
|
logInfo(`Fetching remote branch ${devBranchName}...`);
|
|
96
156
|
}
|
|
97
157
|
const credArgs = buildCredentialArgs(githubConfig.token);
|
|
98
|
-
execFileSync('git', [
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
158
|
+
execFileSync('git', [
|
|
159
|
+
...credArgs,
|
|
160
|
+
'fetch',
|
|
161
|
+
'origin',
|
|
162
|
+
`${devBranchName}:refs/remotes/origin/${devBranchName}`,
|
|
163
|
+
], { encoding: 'utf-8', stdio: 'pipe' });
|
|
102
164
|
}
|
|
103
165
|
if (pullRequests.length === 0) {
|
|
104
166
|
throw new Error('No PR plan found. Run the pr-splitting phase first to create a PR plan.');
|
|
@@ -126,8 +188,18 @@ export async function fetchPRExecutionContext(featureId, verbose) {
|
|
|
126
188
|
// Determine sync mode
|
|
127
189
|
const devRef = localExists ? devBranchName : `origin/${devBranchName}`;
|
|
128
190
|
const devBranchHeadSha = getBranchHeadSha(devRef);
|
|
129
|
-
|
|
130
|
-
|
|
191
|
+
let lastSyncedCommit = getLastSyncedCommit(pullRequests);
|
|
192
|
+
let isIncrementalSync = lastSyncedCommit !== null;
|
|
193
|
+
// If the branch was rebased, lastSyncedCommit is no longer in the history.
|
|
194
|
+
// Treat this as a full re-execution so downstream logic uses the correct
|
|
195
|
+
// prompt and skips incremental file-assignment against an invalid base.
|
|
196
|
+
if (isIncrementalSync && !isAncestor(lastSyncedCommit, devRef)) {
|
|
197
|
+
if (verbose) {
|
|
198
|
+
logInfo(`ā ļø Last synced commit ${lastSyncedCommit} is no longer in branch history (likely rebased). Treating as full re-execution.`);
|
|
199
|
+
}
|
|
200
|
+
lastSyncedCommit = null;
|
|
201
|
+
isIncrementalSync = false;
|
|
202
|
+
}
|
|
131
203
|
// Get diff info: for incremental, diff from last sync; for first run, diff from main
|
|
132
204
|
const diffBase = isIncrementalSync ? lastSyncedCommit : 'main';
|
|
133
205
|
const diffStat = getDiffStat(diffBase, devRef);
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File assignment for incremental sync.
|
|
3
|
+
*
|
|
4
|
+
* When new changes on the dev branch include files not covered by any
|
|
5
|
+
* existing PR's file list, this module asks an LLM to assign them to
|
|
6
|
+
* the most appropriate PR, then updates the PR records in the database.
|
|
7
|
+
*/
|
|
8
|
+
import { type PullRequest } from '../../services/pull-requests.js';
|
|
9
|
+
import type { ChangedFileInfo } from './context.js';
|
|
10
|
+
export interface FileAssignment {
|
|
11
|
+
file: string;
|
|
12
|
+
pr_id: string;
|
|
13
|
+
}
|
|
14
|
+
export interface FileAssignmentResult {
|
|
15
|
+
assignments: FileAssignment[];
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Find changed files that are not covered by any PR's file list.
|
|
19
|
+
*/
|
|
20
|
+
export declare function findUnassignedFiles(changedFiles: ChangedFileInfo[], pullRequests: PullRequest[]): ChangedFileInfo[];
|
|
21
|
+
/**
|
|
22
|
+
* Update PR records in the database with newly assigned files.
|
|
23
|
+
* Exported for testing via dependency injection of the updater function.
|
|
24
|
+
*/
|
|
25
|
+
export declare function applyAssignments(assignments: FileAssignment[], unassignedFiles: ChangedFileInfo[], pullRequests: PullRequest[], updater?: (prId: string, updates: {
|
|
26
|
+
files: {
|
|
27
|
+
path: string;
|
|
28
|
+
change_type: string;
|
|
29
|
+
}[];
|
|
30
|
+
}, verbose?: boolean) => Promise<unknown>, verbose?: boolean): Promise<number>;
|
|
31
|
+
/**
|
|
32
|
+
* Remove files that were deleted on the dev branch from PR file lists.
|
|
33
|
+
* Returns the number of PRs updated.
|
|
34
|
+
*/
|
|
35
|
+
export declare function removeDeletedFilesFromPRs(changedFiles: ChangedFileInfo[], pullRequests: PullRequest[], updater?: (prId: string, updates: {
|
|
36
|
+
files: {
|
|
37
|
+
path: string;
|
|
38
|
+
change_type: string;
|
|
39
|
+
}[];
|
|
40
|
+
}, verbose?: boolean) => Promise<unknown>, verbose?: boolean): Promise<number>;
|
|
41
|
+
/**
|
|
42
|
+
* Remove files from PR file lists that are no longer in the current diff.
|
|
43
|
+
* Used after rebase when the full diff against main may differ from what
|
|
44
|
+
* was previously recorded in PR file lists.
|
|
45
|
+
* Returns the number of PRs updated.
|
|
46
|
+
*/
|
|
47
|
+
export declare function removeStaleFilesFromPRs(changedFiles: ChangedFileInfo[], pullRequests: PullRequest[], updater?: (prId: string, updates: {
|
|
48
|
+
files: {
|
|
49
|
+
path: string;
|
|
50
|
+
change_type: string;
|
|
51
|
+
}[];
|
|
52
|
+
}, verbose?: boolean) => Promise<unknown>, verbose?: boolean): Promise<number>;
|
|
53
|
+
/**
|
|
54
|
+
* Detect unassigned files and use LLM to assign them to existing PRs.
|
|
55
|
+
* Returns the number of files assigned, or 0 if none needed assignment.
|
|
56
|
+
*/
|
|
57
|
+
export declare function assignNewFilesToPRs(changedFiles: ChangedFileInfo[], pullRequests: PullRequest[], verbose?: boolean): Promise<number>;
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File assignment for incremental sync.
|
|
3
|
+
*
|
|
4
|
+
* When new changes on the dev branch include files not covered by any
|
|
5
|
+
* existing PR's file list, this module asks an LLM to assign them to
|
|
6
|
+
* the most appropriate PR, then updates the PR records in the database.
|
|
7
|
+
*/
|
|
8
|
+
import { query } from '@anthropic-ai/claude-agent-sdk';
|
|
9
|
+
import { DEFAULT_MODEL } from '../../constants.js';
|
|
10
|
+
import { updatePullRequest, } from '../../services/pull-requests.js';
|
|
11
|
+
import { extractJsonFromResponse } from '../../utils/json-extractor.js';
|
|
12
|
+
import { logError, logInfo } from '../../utils/logger.js';
|
|
13
|
+
/**
|
|
14
|
+
* Find changed files that are not covered by any PR's file list.
|
|
15
|
+
*/
|
|
16
|
+
export function findUnassignedFiles(changedFiles, pullRequests) {
|
|
17
|
+
const assignedPaths = new Set();
|
|
18
|
+
for (const pr of pullRequests) {
|
|
19
|
+
if (pr.files) {
|
|
20
|
+
for (const f of pr.files) {
|
|
21
|
+
assignedPaths.add(f.path);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return changedFiles.filter((f) => !assignedPaths.has(f.path));
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Build a prompt asking the LLM to assign unassigned files to PRs.
|
|
29
|
+
*/
|
|
30
|
+
function buildAssignmentPrompt(unassignedFiles, pullRequests) {
|
|
31
|
+
const prList = pullRequests
|
|
32
|
+
.map((pr) => {
|
|
33
|
+
const files = pr.files
|
|
34
|
+
? pr.files.map((f) => ` - ${f.path} (${f.change_type})`).join('\n')
|
|
35
|
+
: ' (no files)';
|
|
36
|
+
return `### PR ${pr.sequence}: ${pr.name} (id: ${pr.id})
|
|
37
|
+
- Description: ${pr.description || 'No description'}
|
|
38
|
+
- Files:
|
|
39
|
+
${files}`;
|
|
40
|
+
})
|
|
41
|
+
.join('\n\n');
|
|
42
|
+
const fileList = unassignedFiles
|
|
43
|
+
.map((f) => `- ${f.path} (${f.change_type})`)
|
|
44
|
+
.join('\n');
|
|
45
|
+
return `# Assign New Files to Existing PRs
|
|
46
|
+
|
|
47
|
+
The following files were changed on the dev branch but are not assigned to any existing PR.
|
|
48
|
+
Assign each file to the most appropriate PR based on the PR's purpose and existing file list.
|
|
49
|
+
|
|
50
|
+
## Unassigned Files (${unassignedFiles.length})
|
|
51
|
+
${fileList}
|
|
52
|
+
|
|
53
|
+
## Existing PRs
|
|
54
|
+
${prList}
|
|
55
|
+
|
|
56
|
+
## Instructions
|
|
57
|
+
For each unassigned file, decide which PR it belongs to based on:
|
|
58
|
+
1. Directory proximity (same package/module as existing PR files)
|
|
59
|
+
2. Logical cohesion (related functionality)
|
|
60
|
+
3. Dependency order (if file B imports from file A, they may belong together)
|
|
61
|
+
|
|
62
|
+
Return ONLY a JSON object:
|
|
63
|
+
|
|
64
|
+
\`\`\`json
|
|
65
|
+
{
|
|
66
|
+
"assignments": [
|
|
67
|
+
{
|
|
68
|
+
"file": "path/to/file.ts",
|
|
69
|
+
"pr_id": "uuid-of-target-pr"
|
|
70
|
+
}
|
|
71
|
+
]
|
|
72
|
+
}
|
|
73
|
+
\`\`\`
|
|
74
|
+
|
|
75
|
+
Every file in the unassigned list MUST appear exactly once in the assignments array.
|
|
76
|
+
Do NOT create new PRs ā assign all files to existing PRs only.`;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Call LLM to assign files, returning structured assignments.
|
|
80
|
+
*/
|
|
81
|
+
async function callLLMForAssignment(prompt, verbose) {
|
|
82
|
+
let responseText = '';
|
|
83
|
+
function* userMessage() {
|
|
84
|
+
yield {
|
|
85
|
+
type: 'user',
|
|
86
|
+
message: { role: 'user', content: prompt },
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
for await (const message of query({
|
|
90
|
+
prompt: userMessage(),
|
|
91
|
+
options: {
|
|
92
|
+
model: DEFAULT_MODEL,
|
|
93
|
+
maxTurns: 1,
|
|
94
|
+
permissionMode: 'bypassPermissions',
|
|
95
|
+
},
|
|
96
|
+
})) {
|
|
97
|
+
if (message.type === 'assistant' && message.message?.content) {
|
|
98
|
+
for (const item of message.message.content) {
|
|
99
|
+
if (item.type === 'text') {
|
|
100
|
+
responseText += `${item.text}\n`;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
const parsed = extractJsonFromResponse(responseText, '"assignments"');
|
|
106
|
+
if (!parsed || !Array.isArray(parsed.assignments)) {
|
|
107
|
+
if (verbose) {
|
|
108
|
+
logError('Failed to parse file assignment response from LLM');
|
|
109
|
+
}
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
return parsed;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Update PR records in the database with newly assigned files.
|
|
116
|
+
* Exported for testing via dependency injection of the updater function.
|
|
117
|
+
*/
|
|
118
|
+
export async function applyAssignments(assignments, unassignedFiles, pullRequests, updater = updatePullRequest, verbose) {
|
|
119
|
+
// Build a lookup from file path to its git change_type
|
|
120
|
+
const changeTypeByPath = new Map();
|
|
121
|
+
for (const f of unassignedFiles) {
|
|
122
|
+
changeTypeByPath.set(f.path, f.change_type);
|
|
123
|
+
}
|
|
124
|
+
// Group assignments by PR id
|
|
125
|
+
const byPR = new Map();
|
|
126
|
+
for (const a of assignments) {
|
|
127
|
+
const list = byPR.get(a.pr_id) || [];
|
|
128
|
+
list.push(a);
|
|
129
|
+
byPR.set(a.pr_id, list);
|
|
130
|
+
}
|
|
131
|
+
let updatedCount = 0;
|
|
132
|
+
for (const [prId, newFiles] of byPR) {
|
|
133
|
+
const pr = pullRequests.find((p) => p.id === prId);
|
|
134
|
+
if (!pr) {
|
|
135
|
+
if (verbose) {
|
|
136
|
+
logError(`Assignment references unknown PR id: ${prId}`);
|
|
137
|
+
}
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
const existingFiles = pr.files || [];
|
|
141
|
+
const existingPaths = new Set(existingFiles.map((f) => f.path));
|
|
142
|
+
const mergedFiles = [...existingFiles];
|
|
143
|
+
for (const nf of newFiles) {
|
|
144
|
+
if (!existingPaths.has(nf.file)) {
|
|
145
|
+
const changeType = changeTypeByPath.get(nf.file) || 'modified';
|
|
146
|
+
mergedFiles.push({ path: nf.file, change_type: changeType });
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
try {
|
|
150
|
+
await updater(prId, { files: mergedFiles }, verbose);
|
|
151
|
+
updatedCount++;
|
|
152
|
+
if (verbose) {
|
|
153
|
+
logInfo(` Updated PR "${pr.name}": +${newFiles.length} files (${newFiles.map((f) => f.file).join(', ')})`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
catch (error) {
|
|
157
|
+
logError(`Failed to update PR ${prId}: ${error instanceof Error ? error.message : String(error)}`);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return updatedCount;
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Remove files that were deleted on the dev branch from PR file lists.
|
|
164
|
+
* Returns the number of PRs updated.
|
|
165
|
+
*/
|
|
166
|
+
export async function removeDeletedFilesFromPRs(changedFiles, pullRequests, updater = updatePullRequest, verbose) {
|
|
167
|
+
const deletedPaths = new Set(changedFiles.filter((f) => f.change_type === 'deleted').map((f) => f.path));
|
|
168
|
+
if (deletedPaths.size === 0) {
|
|
169
|
+
return 0;
|
|
170
|
+
}
|
|
171
|
+
let updatedCount = 0;
|
|
172
|
+
for (const pr of pullRequests) {
|
|
173
|
+
if (!pr.files || pr.files.length === 0) {
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
const filtered = pr.files.filter((f) => !deletedPaths.has(f.path));
|
|
177
|
+
if (filtered.length === pr.files.length) {
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
const removed = pr.files.length - filtered.length;
|
|
181
|
+
try {
|
|
182
|
+
await updater(pr.id, { files: filtered }, verbose);
|
|
183
|
+
updatedCount++;
|
|
184
|
+
if (verbose) {
|
|
185
|
+
logInfo(` Cleaned PR "${pr.name}": removed ${removed} deleted file(s)`);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
catch (error) {
|
|
189
|
+
logError(`Failed to clean PR ${pr.id}: ${error instanceof Error ? error.message : String(error)}`);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
if (verbose && updatedCount > 0) {
|
|
193
|
+
logInfo(`šļø Cleaned deleted files from ${updatedCount} PR(s)`);
|
|
194
|
+
}
|
|
195
|
+
return updatedCount;
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Remove files from PR file lists that are no longer in the current diff.
|
|
199
|
+
* Used after rebase when the full diff against main may differ from what
|
|
200
|
+
* was previously recorded in PR file lists.
|
|
201
|
+
* Returns the number of PRs updated.
|
|
202
|
+
*/
|
|
203
|
+
export async function removeStaleFilesFromPRs(changedFiles, pullRequests, updater = updatePullRequest, verbose) {
|
|
204
|
+
const currentPaths = new Set(changedFiles.map((f) => f.path));
|
|
205
|
+
let updatedCount = 0;
|
|
206
|
+
for (const pr of pullRequests) {
|
|
207
|
+
if (!pr.files || pr.files.length === 0) {
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
const filtered = pr.files.filter((f) => currentPaths.has(f.path));
|
|
211
|
+
if (filtered.length === pr.files.length) {
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
const removed = pr.files.length - filtered.length;
|
|
215
|
+
try {
|
|
216
|
+
await updater(pr.id, { files: filtered }, verbose);
|
|
217
|
+
updatedCount++;
|
|
218
|
+
if (verbose) {
|
|
219
|
+
logInfo(` Cleaned PR "${pr.name}": removed ${removed} stale file(s)`);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
catch (error) {
|
|
223
|
+
logError(`Failed to clean PR ${pr.id}: ${error instanceof Error ? error.message : String(error)}`);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
if (verbose && updatedCount > 0) {
|
|
227
|
+
logInfo(`š§¹ Removed stale files from ${updatedCount} PR(s)`);
|
|
228
|
+
}
|
|
229
|
+
return updatedCount;
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* Detect unassigned files and use LLM to assign them to existing PRs.
|
|
233
|
+
* Returns the number of files assigned, or 0 if none needed assignment.
|
|
234
|
+
*/
|
|
235
|
+
export async function assignNewFilesToPRs(changedFiles, pullRequests, verbose) {
|
|
236
|
+
const unassigned = findUnassignedFiles(changedFiles, pullRequests);
|
|
237
|
+
if (unassigned.length === 0) {
|
|
238
|
+
if (verbose) {
|
|
239
|
+
logInfo('All changed files are already assigned to PRs.');
|
|
240
|
+
}
|
|
241
|
+
return 0;
|
|
242
|
+
}
|
|
243
|
+
if (verbose) {
|
|
244
|
+
logInfo(`\nš ${unassigned.length} changed file(s) not assigned to any PR:`);
|
|
245
|
+
for (const f of unassigned) {
|
|
246
|
+
logInfo(` - ${f.path} (${f.change_type})`);
|
|
247
|
+
}
|
|
248
|
+
logInfo('š¤ Asking LLM to assign them...');
|
|
249
|
+
}
|
|
250
|
+
const prompt = buildAssignmentPrompt(unassigned, pullRequests);
|
|
251
|
+
const result = await callLLMForAssignment(prompt, verbose);
|
|
252
|
+
if (!result) {
|
|
253
|
+
logError('LLM file assignment failed. Proceeding with existing file lists.');
|
|
254
|
+
return 0;
|
|
255
|
+
}
|
|
256
|
+
// Validate: every unassigned file should appear in assignments
|
|
257
|
+
const assignedFiles = new Set(result.assignments.map((a) => a.file));
|
|
258
|
+
const missing = unassigned.filter((f) => !assignedFiles.has(f.path));
|
|
259
|
+
if (missing.length > 0 && verbose) {
|
|
260
|
+
logInfo(`ā ļø LLM did not assign ${missing.length} file(s): ${missing.map((f) => f.path).join(', ')}`);
|
|
261
|
+
}
|
|
262
|
+
// Validate: all pr_ids reference existing PRs
|
|
263
|
+
const validPRIds = new Set(pullRequests.map((pr) => pr.id));
|
|
264
|
+
const validAssignments = result.assignments.filter((a) => {
|
|
265
|
+
if (!validPRIds.has(a.pr_id)) {
|
|
266
|
+
if (verbose) {
|
|
267
|
+
logError(`Assignment for ${a.file} references unknown PR: ${a.pr_id}`);
|
|
268
|
+
}
|
|
269
|
+
return false;
|
|
270
|
+
}
|
|
271
|
+
return true;
|
|
272
|
+
});
|
|
273
|
+
const updatedCount = await applyAssignments(validAssignments, unassigned, pullRequests, updatePullRequest, verbose);
|
|
274
|
+
if (verbose) {
|
|
275
|
+
logInfo(`ā
Assigned ${validAssignments.length} file(s) to ${updatedCount} PR(s)`);
|
|
276
|
+
}
|
|
277
|
+
return validAssignments.length;
|
|
278
|
+
}
|
|
@@ -2,9 +2,11 @@ import { query } from '@anthropic-ai/claude-agent-sdk';
|
|
|
2
2
|
import { execSync } from 'child_process';
|
|
3
3
|
import { DEFAULT_MODEL } from '../../constants.js';
|
|
4
4
|
import { logFeaturePhaseEvent } from '../../services/audit-logs.js';
|
|
5
|
+
import { getPullRequests } from '../../services/pull-requests.js';
|
|
5
6
|
import { getCurrentBranch, returnToMainBranch, } from '../../utils/git-branch-manager.js';
|
|
6
7
|
import { logDebug, logError, logInfo } from '../../utils/logger.js';
|
|
7
8
|
import { fetchPRExecutionContext } from './context.js';
|
|
9
|
+
import { assignNewFilesToPRs, removeDeletedFilesFromPRs, removeStaleFilesFromPRs, } from './file-assigner.js';
|
|
8
10
|
import { buildExecutionErrorResult, buildExecutionSuccessResult, buildNoChangeResult, } from './outcome.js';
|
|
9
11
|
import { pushBranchAndBuildUrl, updatePRDatabaseRecord, } from './pr-executor.js';
|
|
10
12
|
import { createIncrementalSyncPrompt, createIncrementalSyncSystemPrompt, createPRExecutionPrompt, createPRExecutionSystemPrompt, } from './prompts.js';
|
|
@@ -65,6 +67,35 @@ export const executeFeaturePRs = async (options, config) => {
|
|
|
65
67
|
return buildNoChangeResult(featureId, context.pullRequests.length);
|
|
66
68
|
}
|
|
67
69
|
// ======================================
|
|
70
|
+
// Reconcile file assignments
|
|
71
|
+
// ======================================
|
|
72
|
+
let activePullRequests = context.pullRequests;
|
|
73
|
+
// Reconcile file lists against the current diff.
|
|
74
|
+
if (context.changedFiles.length > 0) {
|
|
75
|
+
let needsRefetch = false;
|
|
76
|
+
if (context.isIncrementalSync) {
|
|
77
|
+
// Incremental: only remove explicitly deleted files
|
|
78
|
+
const deletedCount = await removeDeletedFilesFromPRs(context.changedFiles, activePullRequests, undefined, verbose);
|
|
79
|
+
if (deletedCount > 0)
|
|
80
|
+
needsRefetch = true;
|
|
81
|
+
}
|
|
82
|
+
else {
|
|
83
|
+
// Full re-execution (e.g. after rebase): remove any PR files that
|
|
84
|
+
// are no longer in the current diff against main
|
|
85
|
+
const staleCount = await removeStaleFilesFromPRs(context.changedFiles, activePullRequests, undefined, verbose);
|
|
86
|
+
if (staleCount > 0)
|
|
87
|
+
needsRefetch = true;
|
|
88
|
+
}
|
|
89
|
+
// Assign changed files not covered by any PR
|
|
90
|
+
if (needsRefetch) {
|
|
91
|
+
activePullRequests = await getPullRequests({ featureId, verbose });
|
|
92
|
+
}
|
|
93
|
+
const assignedCount = await assignNewFilesToPRs(context.changedFiles, activePullRequests, verbose);
|
|
94
|
+
if (assignedCount > 0) {
|
|
95
|
+
activePullRequests = await getPullRequests({ featureId, verbose });
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
// ======================================
|
|
68
99
|
// Agent: Create/Update branches
|
|
69
100
|
// ======================================
|
|
70
101
|
if (verbose) {
|
|
@@ -87,7 +118,7 @@ export const executeFeaturePRs = async (options, config) => {
|
|
|
87
118
|
userPrompt = createIncrementalSyncPrompt({
|
|
88
119
|
featureId,
|
|
89
120
|
devBranchName: context.devBranchName,
|
|
90
|
-
pullRequests:
|
|
121
|
+
pullRequests: activePullRequests,
|
|
91
122
|
lastSyncedCommit: context.lastSyncedCommit,
|
|
92
123
|
diffStat: context.diffStat,
|
|
93
124
|
changedFiles: context.changedFiles,
|
|
@@ -95,7 +126,7 @@ export const executeFeaturePRs = async (options, config) => {
|
|
|
95
126
|
}
|
|
96
127
|
else {
|
|
97
128
|
systemPrompt = await createPRExecutionSystemPrompt(featureId, context.devBranchName);
|
|
98
|
-
userPrompt = createPRExecutionPrompt(featureId, context.devBranchName,
|
|
129
|
+
userPrompt = createPRExecutionPrompt(featureId, context.devBranchName, activePullRequests);
|
|
99
130
|
}
|
|
100
131
|
// Execute agent query
|
|
101
132
|
const agentResult = await executeAgentQuery(userPrompt, systemPrompt, config, verbose);
|
|
@@ -130,12 +161,12 @@ export const executeFeaturePRs = async (options, config) => {
|
|
|
130
161
|
};
|
|
131
162
|
// Build PR id ā branch name map for stacked PR base resolution
|
|
132
163
|
const prIdToBranch = new Map();
|
|
133
|
-
for (const pr of
|
|
164
|
+
for (const pr of activePullRequests) {
|
|
134
165
|
if (pr.branch_name) {
|
|
135
166
|
prIdToBranch.set(pr.id, pr.branch_name);
|
|
136
167
|
}
|
|
137
168
|
}
|
|
138
|
-
for (const pr of
|
|
169
|
+
for (const pr of activePullRequests) {
|
|
139
170
|
if (!pr.branch_name) {
|
|
140
171
|
if (verbose) {
|
|
141
172
|
logError(`PR "${pr.name}" has no branch name, skipping`);
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { PullRequest } from '../../services/pull-requests.js';
|
|
2
|
+
import type { ChangedFileInfo } from './context.js';
|
|
2
3
|
/**
|
|
3
4
|
* Create the system prompt for branch creation and code splitting
|
|
4
5
|
*/
|
|
@@ -20,6 +21,6 @@ export interface IncrementalSyncPromptOptions {
|
|
|
20
21
|
pullRequests: PullRequest[];
|
|
21
22
|
lastSyncedCommit: string;
|
|
22
23
|
diffStat: string;
|
|
23
|
-
changedFiles:
|
|
24
|
+
changedFiles: ChangedFileInfo[];
|
|
24
25
|
}
|
|
25
26
|
export declare function createIncrementalSyncPrompt(opts: IncrementalSyncPromptOptions): string;
|
|
@@ -106,7 +106,7 @@ ${diffStat || 'No changes detected'}
|
|
|
106
106
|
\`\`\`
|
|
107
107
|
|
|
108
108
|
### Changed Files (${changedFiles.length} files)
|
|
109
|
-
${changedFiles.map((f) => `- ${f}`).join('\n') || 'No files changed'}
|
|
109
|
+
${changedFiles.map((f) => `- ${f.path} (${f.change_type})`).join('\n') || 'No files changed'}
|
|
110
110
|
|
|
111
111
|
## Existing PR Branches (Stacked)
|
|
112
112
|
|
|
@@ -156,8 +156,9 @@ export function getTransitiveDependencies(file, graph) {
|
|
|
156
156
|
while (stack.length > 0) {
|
|
157
157
|
const current = stack.pop();
|
|
158
158
|
const deps = graph.get(current);
|
|
159
|
-
if (!deps)
|
|
159
|
+
if (!deps) {
|
|
160
160
|
continue;
|
|
161
|
+
}
|
|
161
162
|
for (const dep of deps) {
|
|
162
163
|
if (!result.has(dep)) {
|
|
163
164
|
result.add(dep);
|
|
@@ -168,6 +169,35 @@ export function getTransitiveDependencies(file, graph) {
|
|
|
168
169
|
return result;
|
|
169
170
|
}
|
|
170
171
|
const MAX_FIX_ITERATIONS = 100;
|
|
172
|
+
/**
|
|
173
|
+
* Move a dependency file from a later PR to an earlier PR that needs it.
|
|
174
|
+
* Returns true if a file was moved.
|
|
175
|
+
*/
|
|
176
|
+
function moveDepToEarlierPR(dep, prIdx, sorted, fileToPRIndex, movedFiles, reason) {
|
|
177
|
+
const depPRIdx = fileToPRIndex.get(dep);
|
|
178
|
+
if (depPRIdx === undefined || depPRIdx <= prIdx) {
|
|
179
|
+
return false;
|
|
180
|
+
}
|
|
181
|
+
const sourcePR = sorted[depPRIdx];
|
|
182
|
+
const targetPR = sorted[prIdx];
|
|
183
|
+
const depFileEntry = sourcePR.files?.find((f) => f.path === dep);
|
|
184
|
+
if (!depFileEntry) {
|
|
185
|
+
return false;
|
|
186
|
+
}
|
|
187
|
+
sourcePR.files = sourcePR.files?.filter((f) => f.path !== dep) ?? [];
|
|
188
|
+
if (!targetPR.files) {
|
|
189
|
+
targetPR.files = [];
|
|
190
|
+
}
|
|
191
|
+
targetPR.files.push(depFileEntry);
|
|
192
|
+
fileToPRIndex.set(dep, prIdx);
|
|
193
|
+
movedFiles.push({
|
|
194
|
+
file: dep,
|
|
195
|
+
fromPR: `PR #${sourcePR.sequence} (${sourcePR.name})`,
|
|
196
|
+
toPR: `PR #${targetPR.sequence} (${targetPR.name})`,
|
|
197
|
+
reason,
|
|
198
|
+
});
|
|
199
|
+
return true;
|
|
200
|
+
}
|
|
171
201
|
/**
|
|
172
202
|
* Given PRs sorted by sequence and a dependency graph, detect violations
|
|
173
203
|
* and auto-fix by moving files to earlier PRs.
|
|
@@ -196,32 +226,13 @@ export function autoFixPROrdering(pullRequests, dependencyGraph) {
|
|
|
196
226
|
changed = false;
|
|
197
227
|
iterations++;
|
|
198
228
|
for (let prIdx = 0; prIdx < sorted.length; prIdx++) {
|
|
199
|
-
const
|
|
200
|
-
for (const file of pr.files ?? []) {
|
|
229
|
+
for (const file of sorted[prIdx].files ?? []) {
|
|
201
230
|
const transitiveDeps = getTransitiveDependencies(file.path, dependencyGraph);
|
|
202
231
|
for (const dep of transitiveDeps) {
|
|
203
|
-
const
|
|
204
|
-
if (
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
const sourcePR = sorted[depPRIdx];
|
|
208
|
-
const targetPR = sorted[prIdx];
|
|
209
|
-
const depFileEntry = sourcePR.files?.find((f) => f.path === dep);
|
|
210
|
-
if (!depFileEntry)
|
|
211
|
-
continue;
|
|
212
|
-
// Move from source to target
|
|
213
|
-
sourcePR.files = sourcePR.files?.filter((f) => f.path !== dep) ?? [];
|
|
214
|
-
if (!targetPR.files)
|
|
215
|
-
targetPR.files = [];
|
|
216
|
-
targetPR.files.push(depFileEntry);
|
|
217
|
-
fileToPRIndex.set(dep, prIdx);
|
|
218
|
-
movedFiles.push({
|
|
219
|
-
file: dep,
|
|
220
|
-
fromPR: `PR #${sourcePR.sequence} (${sourcePR.name})`,
|
|
221
|
-
toPR: `PR #${targetPR.sequence} (${targetPR.name})`,
|
|
222
|
-
reason: `imported by ${file.path}`,
|
|
223
|
-
});
|
|
224
|
-
changed = true;
|
|
232
|
+
const moved = moveDepToEarlierPR(dep, prIdx, sorted, fileToPRIndex, movedFiles, `imported by ${file.path}`);
|
|
233
|
+
if (moved) {
|
|
234
|
+
changed = true;
|
|
235
|
+
}
|
|
225
236
|
}
|
|
226
237
|
}
|
|
227
238
|
}
|
|
@@ -2,7 +2,7 @@ import { query } from '@anthropic-ai/claude-agent-sdk';
|
|
|
2
2
|
import { DEFAULT_MODEL } from '../../constants.js';
|
|
3
3
|
import { logFeaturePhaseEvent } from '../../services/audit-logs.js';
|
|
4
4
|
import { formatFeedbacksForContext, getFeedbacksForPhase, } from '../../services/feedbacks.js';
|
|
5
|
-
import { clearPullRequests, createPullRequests, } from '../../services/pull-requests.js';
|
|
5
|
+
import { clearPullRequests, createPullRequests, updatePullRequest, } from '../../services/pull-requests.js';
|
|
6
6
|
import { logDebug, logError, logInfo } from '../../utils/logger.js';
|
|
7
7
|
import { fetchPRSplittingContext } from './context.js';
|
|
8
8
|
import { validateAndFixImportDependencies } from './import-dep-validator.js';
|
|
@@ -151,28 +151,33 @@ export const splitFeatureIntoPRs = async (options, config
|
|
|
151
151
|
await clearPullRequests({ featureId, verbose: false }, true);
|
|
152
152
|
}
|
|
153
153
|
// Save PR plan to database
|
|
154
|
-
|
|
155
|
-
const
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
sequence: pr.sequence,
|
|
163
|
-
name: pr.name,
|
|
164
|
-
description: pr.description,
|
|
165
|
-
branch_name: pr.branch_name,
|
|
166
|
-
base_pr_id: basePrId,
|
|
167
|
-
files: pr.files,
|
|
168
|
-
});
|
|
169
|
-
}
|
|
154
|
+
// Step 1: Create all PRs without base_pr_id (batch)
|
|
155
|
+
const prInputs = finalPRs.map((pr) => ({
|
|
156
|
+
sequence: pr.sequence,
|
|
157
|
+
name: pr.name,
|
|
158
|
+
description: pr.description,
|
|
159
|
+
branch_name: pr.branch_name,
|
|
160
|
+
files: pr.files,
|
|
161
|
+
}));
|
|
170
162
|
const createdPRRecords = await createPullRequests({ featureId, verbose: false }, prInputs);
|
|
163
|
+
// Step 2: Build branch_name ā ID map from created records
|
|
164
|
+
const prBranchNameToId = new Map();
|
|
171
165
|
createdPRRecords.forEach((createdPR) => {
|
|
172
166
|
if (createdPR.branch_name) {
|
|
173
167
|
prBranchNameToId.set(createdPR.branch_name, createdPR.id);
|
|
174
168
|
}
|
|
175
169
|
});
|
|
170
|
+
// Step 3: Update base_pr_id for PRs with dependencies
|
|
171
|
+
for (const pr of finalPRs) {
|
|
172
|
+
if (!pr.depends_on_branch_name || !pr.branch_name) {
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
const basePrId = prBranchNameToId.get(pr.depends_on_branch_name);
|
|
176
|
+
const thisPrId = prBranchNameToId.get(pr.branch_name);
|
|
177
|
+
if (basePrId && thisPrId) {
|
|
178
|
+
await updatePullRequest(thisPrId, { base_pr_id: basePrId }, false);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
176
181
|
if (verbose) {
|
|
177
182
|
logInfo(`\nš Saved ${createdPRRecords.length} PR records to database. Awaiting human review before execution.`);
|
|
178
183
|
}
|