edsger 0.39.3 → 0.40.0

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.
@@ -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: string[];
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
- * Get list of changed files between two refs
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-only ${baseRef}...${headRef}`, {
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.split('\n').filter((f) => f.length > 0);
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', [...credArgs, 'fetch', 'origin', devBranchName], {
99
- encoding: 'utf-8',
100
- stdio: 'pipe',
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
- const lastSyncedCommit = getLastSyncedCommit(pullRequests);
130
- const isIncrementalSync = lastSyncedCommit !== null;
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,45 @@
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
+ * Detect unassigned files and use LLM to assign them to existing PRs.
43
+ * Returns the number of files assigned, or 0 if none needed assignment.
44
+ */
45
+ export declare function assignNewFilesToPRs(changedFiles: ChangedFileInfo[], pullRequests: PullRequest[], verbose?: boolean): Promise<number>;
@@ -0,0 +1,244 @@
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
+ * Detect unassigned files and use LLM to assign them to existing PRs.
199
+ * Returns the number of files assigned, or 0 if none needed assignment.
200
+ */
201
+ export async function assignNewFilesToPRs(changedFiles, pullRequests, verbose) {
202
+ const unassigned = findUnassignedFiles(changedFiles, pullRequests);
203
+ if (unassigned.length === 0) {
204
+ if (verbose) {
205
+ logInfo('All changed files are already assigned to PRs.');
206
+ }
207
+ return 0;
208
+ }
209
+ if (verbose) {
210
+ logInfo(`\nšŸ“‚ ${unassigned.length} changed file(s) not assigned to any PR:`);
211
+ for (const f of unassigned) {
212
+ logInfo(` - ${f.path} (${f.change_type})`);
213
+ }
214
+ logInfo('šŸ¤– Asking LLM to assign them...');
215
+ }
216
+ const prompt = buildAssignmentPrompt(unassigned, pullRequests);
217
+ const result = await callLLMForAssignment(prompt, verbose);
218
+ if (!result) {
219
+ logError('LLM file assignment failed. Proceeding with existing file lists.');
220
+ return 0;
221
+ }
222
+ // Validate: every unassigned file should appear in assignments
223
+ const assignedFiles = new Set(result.assignments.map((a) => a.file));
224
+ const missing = unassigned.filter((f) => !assignedFiles.has(f.path));
225
+ if (missing.length > 0 && verbose) {
226
+ logInfo(`āš ļø LLM did not assign ${missing.length} file(s): ${missing.map((f) => f.path).join(', ')}`);
227
+ }
228
+ // Validate: all pr_ids reference existing PRs
229
+ const validPRIds = new Set(pullRequests.map((pr) => pr.id));
230
+ const validAssignments = result.assignments.filter((a) => {
231
+ if (!validPRIds.has(a.pr_id)) {
232
+ if (verbose) {
233
+ logError(`Assignment for ${a.file} references unknown PR: ${a.pr_id}`);
234
+ }
235
+ return false;
236
+ }
237
+ return true;
238
+ });
239
+ const updatedCount = await applyAssignments(validAssignments, unassigned, pullRequests, updatePullRequest, verbose);
240
+ if (verbose) {
241
+ logInfo(`āœ… Assigned ${validAssignments.length} file(s) to ${updatedCount} PR(s)`);
242
+ }
243
+ return validAssignments.length;
244
+ }
@@ -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, } 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,20 @@ export const executeFeaturePRs = async (options, config) => {
65
67
  return buildNoChangeResult(featureId, context.pullRequests.length);
66
68
  }
67
69
  // ======================================
70
+ // Assign unassigned files to PRs (incremental sync only)
71
+ // ======================================
72
+ let activePullRequests = context.pullRequests;
73
+ if (context.isIncrementalSync) {
74
+ // Remove deleted files from PR file lists
75
+ const deletedCount = await removeDeletedFilesFromPRs(context.changedFiles, activePullRequests, undefined, verbose);
76
+ // Assign new/modified files not covered by any PR
77
+ const assignedCount = await assignNewFilesToPRs(context.changedFiles, activePullRequests, verbose);
78
+ if (assignedCount > 0 || deletedCount > 0) {
79
+ // Re-fetch PR records so prompts use the updated file lists
80
+ activePullRequests = await getPullRequests({ featureId, verbose });
81
+ }
82
+ }
83
+ // ======================================
68
84
  // Agent: Create/Update branches
69
85
  // ======================================
70
86
  if (verbose) {
@@ -87,7 +103,7 @@ export const executeFeaturePRs = async (options, config) => {
87
103
  userPrompt = createIncrementalSyncPrompt({
88
104
  featureId,
89
105
  devBranchName: context.devBranchName,
90
- pullRequests: context.pullRequests,
106
+ pullRequests: activePullRequests,
91
107
  lastSyncedCommit: context.lastSyncedCommit,
92
108
  diffStat: context.diffStat,
93
109
  changedFiles: context.changedFiles,
@@ -95,7 +111,7 @@ export const executeFeaturePRs = async (options, config) => {
95
111
  }
96
112
  else {
97
113
  systemPrompt = await createPRExecutionSystemPrompt(featureId, context.devBranchName);
98
- userPrompt = createPRExecutionPrompt(featureId, context.devBranchName, context.pullRequests);
114
+ userPrompt = createPRExecutionPrompt(featureId, context.devBranchName, activePullRequests);
99
115
  }
100
116
  // Execute agent query
101
117
  const agentResult = await executeAgentQuery(userPrompt, systemPrompt, config, verbose);
@@ -130,12 +146,12 @@ export const executeFeaturePRs = async (options, config) => {
130
146
  };
131
147
  // Build PR id → branch name map for stacked PR base resolution
132
148
  const prIdToBranch = new Map();
133
- for (const pr of context.pullRequests) {
149
+ for (const pr of activePullRequests) {
134
150
  if (pr.branch_name) {
135
151
  prIdToBranch.set(pr.id, pr.branch_name);
136
152
  }
137
153
  }
138
- for (const pr of context.pullRequests) {
154
+ for (const pr of activePullRequests) {
139
155
  if (!pr.branch_name) {
140
156
  if (verbose) {
141
157
  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: string[];
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 pr = sorted[prIdx];
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 depPRIdx = fileToPRIndex.get(dep);
204
- if (depPRIdx === undefined || depPRIdx <= prIdx)
205
- continue;
206
- // Violation: dep is in a later PR but needed here
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
- const prInputs = [];
155
- const prBranchNameToId = new Map();
156
- for (const pr of finalPRs) {
157
- let basePrId;
158
- if (pr.depends_on_branch_name) {
159
- basePrId = prBranchNameToId.get(pr.depends_on_branch_name);
160
- }
161
- prInputs.push({
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "edsger",
3
- "version": "0.39.3",
3
+ "version": "0.40.0",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "edsger": "dist/index.js"