edsger 0.39.2 → 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>;