edsger 0.40.0 โ 0.41.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.
- package/dist/commands/pr-resolve/index.d.ts +10 -0
- package/dist/commands/pr-resolve/index.js +44 -0
- package/dist/commands/pr-review/index.d.ts +10 -0
- package/dist/commands/pr-review/index.js +43 -0
- package/dist/index.js +38 -0
- package/dist/phases/code-review/__tests__/diff-utils.test.d.ts +1 -0
- package/dist/phases/code-review/__tests__/diff-utils.test.js +101 -0
- package/dist/phases/code-review/diff-utils.d.ts +36 -0
- package/dist/phases/code-review/diff-utils.js +100 -0
- package/dist/phases/code-review/index.js +17 -153
- package/dist/phases/pr-execution/file-assigner.d.ts +12 -0
- package/dist/phases/pr-execution/file-assigner.js +34 -0
- package/dist/phases/pr-execution/index.js +25 -8
- package/dist/phases/pr-resolve/__tests__/prompts.test.d.ts +1 -0
- package/dist/phases/pr-resolve/__tests__/prompts.test.js +116 -0
- package/dist/phases/pr-resolve/__tests__/resolve-mapping.test.d.ts +1 -0
- package/dist/phases/pr-resolve/__tests__/resolve-mapping.test.js +138 -0
- package/dist/phases/pr-resolve/__tests__/workspace.test.d.ts +1 -0
- package/dist/phases/pr-resolve/__tests__/workspace.test.js +111 -0
- package/dist/phases/pr-resolve/github-reply.d.ts +13 -0
- package/dist/phases/pr-resolve/github-reply.js +59 -0
- package/dist/phases/pr-resolve/index.d.ts +27 -0
- package/dist/phases/pr-resolve/index.js +213 -0
- package/dist/phases/pr-resolve/prompts.d.ts +17 -0
- package/dist/phases/pr-resolve/prompts.js +106 -0
- package/dist/phases/pr-resolve/workspace.d.ts +29 -0
- package/dist/phases/pr-resolve/workspace.js +145 -0
- package/dist/phases/pr-review/__tests__/prompts.test.d.ts +1 -0
- package/dist/phases/pr-review/__tests__/prompts.test.js +49 -0
- package/dist/phases/pr-review/__tests__/review-comments.test.d.ts +1 -0
- package/dist/phases/pr-review/__tests__/review-comments.test.js +108 -0
- package/dist/phases/pr-review/index.d.ts +25 -0
- package/dist/phases/pr-review/index.js +213 -0
- package/dist/phases/pr-review/prompts.d.ts +5 -0
- package/dist/phases/pr-review/prompts.js +87 -0
- package/dist/phases/pr-shared/__tests__/agent-utils.test.d.ts +1 -0
- package/dist/phases/pr-shared/__tests__/agent-utils.test.js +91 -0
- package/dist/phases/pr-shared/__tests__/context.test.d.ts +1 -0
- package/dist/phases/pr-shared/__tests__/context.test.js +94 -0
- package/dist/phases/pr-shared/agent-utils.d.ts +39 -0
- package/dist/phases/pr-shared/agent-utils.js +69 -0
- package/dist/phases/pr-shared/context.d.ts +24 -0
- package/dist/phases/pr-shared/context.js +78 -0
- package/package.json +1 -1
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI command: edsger pr-resolve <productId> --pr-url <url>
|
|
3
|
+
* Resolves PR change requests: fixes what's genuinely better, explains what it won't change.
|
|
4
|
+
*/
|
|
5
|
+
export interface PRResolveCliOptions {
|
|
6
|
+
prUrl: string;
|
|
7
|
+
prId?: string;
|
|
8
|
+
verbose?: boolean;
|
|
9
|
+
}
|
|
10
|
+
export declare function runPRResolve(productId: string, options: PRResolveCliOptions): Promise<void>;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI command: edsger pr-resolve <productId> --pr-url <url>
|
|
3
|
+
* Resolves PR change requests: fixes what's genuinely better, explains what it won't change.
|
|
4
|
+
*/
|
|
5
|
+
import { getGitHubConfigByProduct } from '../../api/github.js';
|
|
6
|
+
import { resolveStandalonePR } from '../../phases/pr-resolve/index.js';
|
|
7
|
+
import { logError, logInfo, logSuccess } from '../../utils/logger.js';
|
|
8
|
+
export async function runPRResolve(productId, options) {
|
|
9
|
+
const { prUrl, prId, verbose } = options;
|
|
10
|
+
logInfo(`Starting PR resolve for product ${productId}`);
|
|
11
|
+
logInfo(`PR URL: ${prUrl}`);
|
|
12
|
+
// Get GitHub config via product developer settings
|
|
13
|
+
const githubConfig = await getGitHubConfigByProduct(productId, verbose);
|
|
14
|
+
if (!githubConfig.configured ||
|
|
15
|
+
!githubConfig.token ||
|
|
16
|
+
!githubConfig.owner ||
|
|
17
|
+
!githubConfig.repo) {
|
|
18
|
+
logError(`GitHub not configured for product ${productId}: ${githubConfig.message || 'No installation found'}`);
|
|
19
|
+
process.exit(1);
|
|
20
|
+
}
|
|
21
|
+
const result = await resolveStandalonePR({
|
|
22
|
+
productId,
|
|
23
|
+
pullRequestUrl: prUrl,
|
|
24
|
+
githubToken: githubConfig.token,
|
|
25
|
+
owner: githubConfig.owner,
|
|
26
|
+
repo: githubConfig.repo,
|
|
27
|
+
prId,
|
|
28
|
+
verbose,
|
|
29
|
+
});
|
|
30
|
+
if (result.status === 'success') {
|
|
31
|
+
logSuccess(`PR resolve completed: ${result.message}`);
|
|
32
|
+
if (result.summary) {
|
|
33
|
+
logInfo(`Summary: ${result.summary}`);
|
|
34
|
+
}
|
|
35
|
+
if (result.threadsAddressed !== undefined) {
|
|
36
|
+
logInfo(`Threads addressed: ${result.threadsAddressed}`);
|
|
37
|
+
logInfo(`Threads skipped: ${result.threadsSkipped}`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
logError(`PR resolve failed: ${result.message}`);
|
|
42
|
+
process.exit(1);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI command: edsger pr-review <productId> --pr-url <url>
|
|
3
|
+
* Reviews a standalone GitHub PR and posts comments.
|
|
4
|
+
*/
|
|
5
|
+
export interface PRReviewCliOptions {
|
|
6
|
+
prUrl: string;
|
|
7
|
+
prId?: string;
|
|
8
|
+
verbose?: boolean;
|
|
9
|
+
}
|
|
10
|
+
export declare function runPRReview(productId: string, options: PRReviewCliOptions): Promise<void>;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI command: edsger pr-review <productId> --pr-url <url>
|
|
3
|
+
* Reviews a standalone GitHub PR and posts comments.
|
|
4
|
+
*/
|
|
5
|
+
import { getGitHubConfigByProduct } from '../../api/github.js';
|
|
6
|
+
import { reviewStandalonePR } from '../../phases/pr-review/index.js';
|
|
7
|
+
import { logError, logInfo, logSuccess } from '../../utils/logger.js';
|
|
8
|
+
export async function runPRReview(productId, options) {
|
|
9
|
+
const { prUrl, prId, verbose } = options;
|
|
10
|
+
logInfo(`Starting PR review for product ${productId}`);
|
|
11
|
+
logInfo(`PR URL: ${prUrl}`);
|
|
12
|
+
// Get GitHub config via product developer settings
|
|
13
|
+
const githubConfig = await getGitHubConfigByProduct(productId, verbose);
|
|
14
|
+
if (!githubConfig.configured ||
|
|
15
|
+
!githubConfig.token ||
|
|
16
|
+
!githubConfig.owner ||
|
|
17
|
+
!githubConfig.repo) {
|
|
18
|
+
logError(`GitHub not configured for product ${productId}: ${githubConfig.message || 'No installation found'}`);
|
|
19
|
+
process.exit(1);
|
|
20
|
+
}
|
|
21
|
+
const result = await reviewStandalonePR({
|
|
22
|
+
productId,
|
|
23
|
+
pullRequestUrl: prUrl,
|
|
24
|
+
githubToken: githubConfig.token,
|
|
25
|
+
owner: githubConfig.owner,
|
|
26
|
+
repo: githubConfig.repo,
|
|
27
|
+
prId,
|
|
28
|
+
verbose,
|
|
29
|
+
});
|
|
30
|
+
if (result.status === 'success') {
|
|
31
|
+
logSuccess(`PR review completed: ${result.message}`);
|
|
32
|
+
if (result.reviewUrl) {
|
|
33
|
+
logInfo(`Review URL: ${result.reviewUrl}`);
|
|
34
|
+
}
|
|
35
|
+
if (result.commentsCount !== undefined) {
|
|
36
|
+
logInfo(`Comments posted: ${result.commentsCount}`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
else {
|
|
40
|
+
logError(`PR review failed: ${result.message}`);
|
|
41
|
+
process.exit(1);
|
|
42
|
+
}
|
|
43
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -18,6 +18,8 @@ import { runInit } from './commands/init/index.js';
|
|
|
18
18
|
import { runIntelligence } from './commands/intelligence/index.js';
|
|
19
19
|
import { runRefactor } from './commands/refactor/refactor.js';
|
|
20
20
|
import { runTaskWorker } from './commands/task-worker/index.js';
|
|
21
|
+
import { runPRReview } from './commands/pr-review/index.js';
|
|
22
|
+
import { runPRResolve } from './commands/pr-resolve/index.js';
|
|
21
23
|
import { runWorkflow } from './commands/workflow/index.js';
|
|
22
24
|
import { logError, logInfo } from './utils/logger.js';
|
|
23
25
|
// Get package.json version dynamically
|
|
@@ -272,6 +274,42 @@ program
|
|
|
272
274
|
}
|
|
273
275
|
});
|
|
274
276
|
// ============================================================
|
|
277
|
+
// Subcommand: edsger pr-review <productId>
|
|
278
|
+
// ============================================================
|
|
279
|
+
program
|
|
280
|
+
.command('pr-review <productId>')
|
|
281
|
+
.description('AI-review a GitHub PR for a product')
|
|
282
|
+
.requiredOption('--pr-url <url>', 'GitHub PR URL')
|
|
283
|
+
.option('--pr-id <id>', 'Pull request record ID in database')
|
|
284
|
+
.option('-v, --verbose', 'Verbose output')
|
|
285
|
+
.action(async (productId, opts) => {
|
|
286
|
+
try {
|
|
287
|
+
await runPRReview(productId, opts);
|
|
288
|
+
}
|
|
289
|
+
catch (error) {
|
|
290
|
+
logError(error instanceof Error ? error.message : String(error));
|
|
291
|
+
process.exit(1);
|
|
292
|
+
}
|
|
293
|
+
});
|
|
294
|
+
// ============================================================
|
|
295
|
+
// Subcommand: edsger pr-resolve <productId>
|
|
296
|
+
// ============================================================
|
|
297
|
+
program
|
|
298
|
+
.command('pr-resolve <productId>')
|
|
299
|
+
.description('AI-resolve change requests on a GitHub PR')
|
|
300
|
+
.requiredOption('--pr-url <url>', 'GitHub PR URL')
|
|
301
|
+
.option('--pr-id <id>', 'Pull request record ID in database')
|
|
302
|
+
.option('-v, --verbose', 'Verbose output')
|
|
303
|
+
.action(async (productId, opts) => {
|
|
304
|
+
try {
|
|
305
|
+
await runPRResolve(productId, opts);
|
|
306
|
+
}
|
|
307
|
+
catch (error) {
|
|
308
|
+
logError(error instanceof Error ? error.message : String(error));
|
|
309
|
+
process.exit(1);
|
|
310
|
+
}
|
|
311
|
+
});
|
|
312
|
+
// ============================================================
|
|
275
313
|
// Default command (options-based, backward compatible)
|
|
276
314
|
// ============================================================
|
|
277
315
|
program
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import assert from 'node:assert';
|
|
2
|
+
import { describe, it } from 'node:test';
|
|
3
|
+
import { buildLineToPositionMap, findClosestPosition } from '../diff-utils.js';
|
|
4
|
+
describe('buildLineToPositionMap', () => {
|
|
5
|
+
it('maps simple additions correctly', () => {
|
|
6
|
+
const patch = `@@ -1,3 +1,4 @@
|
|
7
|
+
line 1
|
|
8
|
+
+new line
|
|
9
|
+
line 2
|
|
10
|
+
line 3`;
|
|
11
|
+
const map = buildLineToPositionMap(patch);
|
|
12
|
+
// line 1 is at position 1 (new file line 1)
|
|
13
|
+
assert.strictEqual(map.get(1), 1);
|
|
14
|
+
// new line is at position 2 (new file line 2)
|
|
15
|
+
assert.strictEqual(map.get(2), 2);
|
|
16
|
+
// line 2 is at position 3 (new file line 3)
|
|
17
|
+
assert.strictEqual(map.get(3), 3);
|
|
18
|
+
// line 3 is at position 4 (new file line 4)
|
|
19
|
+
assert.strictEqual(map.get(4), 4);
|
|
20
|
+
});
|
|
21
|
+
it('handles deletions - deleted lines have no new file line number', () => {
|
|
22
|
+
const patch = `@@ -1,3 +1,2 @@
|
|
23
|
+
line 1
|
|
24
|
+
-deleted line
|
|
25
|
+
line 3`;
|
|
26
|
+
const map = buildLineToPositionMap(patch);
|
|
27
|
+
assert.strictEqual(map.get(1), 1);
|
|
28
|
+
// deleted line takes position 2 but no new line
|
|
29
|
+
// line 3 in old file becomes line 2 in new file, position 3
|
|
30
|
+
assert.strictEqual(map.get(2), 3);
|
|
31
|
+
});
|
|
32
|
+
it('handles multiple hunks', () => {
|
|
33
|
+
const patch = `@@ -1,2 +1,2 @@
|
|
34
|
+
line 1
|
|
35
|
+
+added
|
|
36
|
+
@@ -10,2 +10,2 @@
|
|
37
|
+
line 10
|
|
38
|
+
+added at 11`;
|
|
39
|
+
const map = buildLineToPositionMap(patch);
|
|
40
|
+
// First hunk: line 1 -> pos 1, added -> pos 2
|
|
41
|
+
assert.strictEqual(map.get(1), 1);
|
|
42
|
+
assert.strictEqual(map.get(2), 2);
|
|
43
|
+
// Second hunk: line 10 -> pos 3, added at 11 -> pos 4
|
|
44
|
+
assert.strictEqual(map.get(10), 3);
|
|
45
|
+
assert.strictEqual(map.get(11), 4);
|
|
46
|
+
});
|
|
47
|
+
it('returns empty map for patch with only hunk header', () => {
|
|
48
|
+
const patch = '@@ -0,0 +1 @@';
|
|
49
|
+
const map = buildLineToPositionMap(patch);
|
|
50
|
+
// Hunk header only, no content lines
|
|
51
|
+
assert.strictEqual(map.size, 0);
|
|
52
|
+
});
|
|
53
|
+
it('handles hunk starting at line other than 1', () => {
|
|
54
|
+
const patch = `@@ -50,3 +50,3 @@
|
|
55
|
+
context
|
|
56
|
+
-old
|
|
57
|
+
+new`;
|
|
58
|
+
const map = buildLineToPositionMap(patch);
|
|
59
|
+
assert.strictEqual(map.get(50), 1);
|
|
60
|
+
// -old takes position 2 (no new line)
|
|
61
|
+
// +new takes position 3, new file line 51
|
|
62
|
+
assert.strictEqual(map.get(51), 3);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
describe('findClosestPosition', () => {
|
|
66
|
+
it('returns exact match when available', () => {
|
|
67
|
+
const map = new Map([
|
|
68
|
+
[10, 5],
|
|
69
|
+
[11, 6],
|
|
70
|
+
[12, 7],
|
|
71
|
+
]);
|
|
72
|
+
const result = findClosestPosition(11, map);
|
|
73
|
+
assert.deepStrictEqual(result, { position: 6, actualLine: 11 });
|
|
74
|
+
});
|
|
75
|
+
it('returns nearby line below first', () => {
|
|
76
|
+
const map = new Map([
|
|
77
|
+
[10, 5],
|
|
78
|
+
[15, 8],
|
|
79
|
+
]);
|
|
80
|
+
// Line 12 not in map, closest below is 15 (offset 3)
|
|
81
|
+
// closest above is 10 (offset 2) - but we check below first at each offset
|
|
82
|
+
// offset 1: check 13 (no), check 11 (no)
|
|
83
|
+
// offset 2: check 14 (no), check 10 (yes!)
|
|
84
|
+
const result = findClosestPosition(12, map);
|
|
85
|
+
assert.deepStrictEqual(result, { position: 5, actualLine: 10 });
|
|
86
|
+
});
|
|
87
|
+
it('returns null when no line within range', () => {
|
|
88
|
+
const map = new Map([
|
|
89
|
+
[1, 1],
|
|
90
|
+
[100, 50],
|
|
91
|
+
]);
|
|
92
|
+
// Line 50 is too far from both 1 and 100
|
|
93
|
+
const result = findClosestPosition(50, map);
|
|
94
|
+
assert.strictEqual(result, null);
|
|
95
|
+
});
|
|
96
|
+
it('returns null for empty map', () => {
|
|
97
|
+
const map = new Map();
|
|
98
|
+
const result = findClosestPosition(5, map);
|
|
99
|
+
assert.strictEqual(result, null);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared diff utilities for mapping file line numbers to GitHub diff positions.
|
|
3
|
+
* Used by both feature-linked code review and standalone PR review.
|
|
4
|
+
*/
|
|
5
|
+
/**
|
|
6
|
+
* Parse unified diff patch to build a mapping from file line numbers to diff positions
|
|
7
|
+
*
|
|
8
|
+
* GitHub API uses "position" parameter which is the line number in the unified diff:
|
|
9
|
+
* - Position starts at 1 for the first line AFTER the @@ hunk header
|
|
10
|
+
* - Increments for every line (context, additions, deletions)
|
|
11
|
+
* - Continues through all hunks in the file
|
|
12
|
+
*
|
|
13
|
+
* Example unified diff:
|
|
14
|
+
* ```
|
|
15
|
+
* @@ -1,3 +1,4 @@ <- not counted in position
|
|
16
|
+
* context line <- position 1, file line 1
|
|
17
|
+
* -old line <- position 2, (deleted line, no file line number)
|
|
18
|
+
* +new line <- position 3, file line 2
|
|
19
|
+
* +another new <- position 4, file line 3
|
|
20
|
+
* context <- position 5, file line 4
|
|
21
|
+
* ```
|
|
22
|
+
*
|
|
23
|
+
* Returns: Map<file line number, diff position>
|
|
24
|
+
*/
|
|
25
|
+
export declare function buildLineToPositionMap(patch: string): Map<number, number>;
|
|
26
|
+
/**
|
|
27
|
+
* Find the diff position for a target line number, or the closest nearby position
|
|
28
|
+
*
|
|
29
|
+
* @param targetLine - The file line number we want to comment on
|
|
30
|
+
* @param lineToPosition - Map of file line numbers to diff positions
|
|
31
|
+
* @returns Object with position and actual line number, or null if not found
|
|
32
|
+
*/
|
|
33
|
+
export declare function findClosestPosition(targetLine: number, lineToPosition: Map<number, number>): {
|
|
34
|
+
position: number;
|
|
35
|
+
actualLine: number;
|
|
36
|
+
} | null;
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared diff utilities for mapping file line numbers to GitHub diff positions.
|
|
3
|
+
* Used by both feature-linked code review and standalone PR review.
|
|
4
|
+
*/
|
|
5
|
+
/**
|
|
6
|
+
* Parse unified diff patch to build a mapping from file line numbers to diff positions
|
|
7
|
+
*
|
|
8
|
+
* GitHub API uses "position" parameter which is the line number in the unified diff:
|
|
9
|
+
* - Position starts at 1 for the first line AFTER the @@ hunk header
|
|
10
|
+
* - Increments for every line (context, additions, deletions)
|
|
11
|
+
* - Continues through all hunks in the file
|
|
12
|
+
*
|
|
13
|
+
* Example unified diff:
|
|
14
|
+
* ```
|
|
15
|
+
* @@ -1,3 +1,4 @@ <- not counted in position
|
|
16
|
+
* context line <- position 1, file line 1
|
|
17
|
+
* -old line <- position 2, (deleted line, no file line number)
|
|
18
|
+
* +new line <- position 3, file line 2
|
|
19
|
+
* +another new <- position 4, file line 3
|
|
20
|
+
* context <- position 5, file line 4
|
|
21
|
+
* ```
|
|
22
|
+
*
|
|
23
|
+
* Returns: Map<file line number, diff position>
|
|
24
|
+
*/
|
|
25
|
+
export function buildLineToPositionMap(patch) {
|
|
26
|
+
const lineToPosition = new Map();
|
|
27
|
+
const lines = patch.split('\n');
|
|
28
|
+
let position = 0; // Position in the diff (starts at 1 after first line after @@)
|
|
29
|
+
let currentNewLine = 0; // Current line number in the new file (RIGHT side)
|
|
30
|
+
for (const line of lines) {
|
|
31
|
+
// Parse hunk headers like @@ -1,7 +1,7 @@
|
|
32
|
+
// This extracts the starting line number for the new file (+side)
|
|
33
|
+
const hunkMatch = line.match(/^@@\s+-\d+(?:,\d+)?\s+\+(\d+)(?:,\d+)?\s+@@/);
|
|
34
|
+
if (hunkMatch) {
|
|
35
|
+
currentNewLine = parseInt(hunkMatch[1], 10);
|
|
36
|
+
// Don't increment position for the @@ line itself
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
// Every line after @@ increments the position counter
|
|
40
|
+
position++;
|
|
41
|
+
// Map line numbers based on line type
|
|
42
|
+
if (line.startsWith('+')) {
|
|
43
|
+
// Addition: this line exists in the new file at currentNewLine
|
|
44
|
+
lineToPosition.set(currentNewLine, position);
|
|
45
|
+
currentNewLine++;
|
|
46
|
+
}
|
|
47
|
+
else if (line.startsWith('-')) {
|
|
48
|
+
// Deletion: this line existed in old file, not in new file
|
|
49
|
+
// Position still increments, but currentNewLine does not
|
|
50
|
+
}
|
|
51
|
+
else if (!line.startsWith('\\')) {
|
|
52
|
+
// Context line (no prefix): exists in both old and new file
|
|
53
|
+
lineToPosition.set(currentNewLine, position);
|
|
54
|
+
currentNewLine++;
|
|
55
|
+
}
|
|
56
|
+
// Lines starting with '\' (like "") are metadata
|
|
57
|
+
}
|
|
58
|
+
return lineToPosition;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Find the diff position for a target line number, or the closest nearby position
|
|
62
|
+
*
|
|
63
|
+
* @param targetLine - The file line number we want to comment on
|
|
64
|
+
* @param lineToPosition - Map of file line numbers to diff positions
|
|
65
|
+
* @returns Object with position and actual line number, or null if not found
|
|
66
|
+
*/
|
|
67
|
+
export function findClosestPosition(targetLine, lineToPosition) {
|
|
68
|
+
// Check if exact line exists in the diff
|
|
69
|
+
const exactPosition = lineToPosition.get(targetLine);
|
|
70
|
+
if (exactPosition !== undefined) {
|
|
71
|
+
return {
|
|
72
|
+
position: exactPosition,
|
|
73
|
+
actualLine: targetLine,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
// Try to find a nearby line within ยฑ10 lines range
|
|
77
|
+
const range = 10;
|
|
78
|
+
for (let offset = 1; offset <= range; offset++) {
|
|
79
|
+
// Try below first (more likely to be relevant for review comments)
|
|
80
|
+
const lineBelow = targetLine + offset;
|
|
81
|
+
const belowPosition = lineToPosition.get(lineBelow);
|
|
82
|
+
if (belowPosition !== undefined) {
|
|
83
|
+
return {
|
|
84
|
+
position: belowPosition,
|
|
85
|
+
actualLine: lineBelow,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
// Try above
|
|
89
|
+
const lineAbove = targetLine - offset;
|
|
90
|
+
const abovePosition = lineToPosition.get(lineAbove);
|
|
91
|
+
if (abovePosition !== undefined) {
|
|
92
|
+
return {
|
|
93
|
+
position: abovePosition,
|
|
94
|
+
actualLine: lineAbove,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
// No valid position found within range
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
@@ -11,172 +11,36 @@ import { getBaseBranchInfo, getBranches, updateBranch, } from '../../services/br
|
|
|
11
11
|
import { formatChecklistsForContext, } from '../../services/checklist.js';
|
|
12
12
|
import { formatFeedbacksForContext, getFeedbacksForPhase, } from '../../services/feedbacks.js';
|
|
13
13
|
import { prepareCustomBranchGitEnvironmentAsync, preparePhaseGitEnvironmentAsync, syncFeatBranchWithMain, } from '../../utils/git-branch-manager.js';
|
|
14
|
-
import {
|
|
14
|
+
import { logError, logInfo } from '../../utils/logger.js';
|
|
15
|
+
import { createPromptGenerator, extractTextFromContent, tryExtractResult, } from '../pr-shared/agent-utils.js';
|
|
15
16
|
import { fetchCodeReviewContext, formatContextForPrompt, } from './context.js';
|
|
16
|
-
|
|
17
|
-
return {
|
|
18
|
-
type: 'user',
|
|
19
|
-
message: { role: 'user', content },
|
|
20
|
-
};
|
|
21
|
-
}
|
|
22
|
-
async function* prompt(reviewPrompt) {
|
|
23
|
-
yield userMessage(reviewPrompt);
|
|
24
|
-
await new Promise((res) => {
|
|
25
|
-
setTimeout(res, 10000);
|
|
26
|
-
});
|
|
27
|
-
}
|
|
28
|
-
/**
|
|
29
|
-
* Parse unified diff patch to build a mapping from file line numbers to diff positions
|
|
30
|
-
*
|
|
31
|
-
* GitHub API uses "position" parameter which is the line number in the unified diff:
|
|
32
|
-
* - Position starts at 1 for the first line AFTER the @@ hunk header
|
|
33
|
-
* - Increments for every line (context, additions, deletions)
|
|
34
|
-
* - Continues through all hunks in the file
|
|
35
|
-
*
|
|
36
|
-
* Example unified diff:
|
|
37
|
-
* ```
|
|
38
|
-
* @@ -1,3 +1,4 @@ <- not counted in position
|
|
39
|
-
* context line <- position 1, file line 1
|
|
40
|
-
* -old line <- position 2, (deleted line, no file line number)
|
|
41
|
-
* +new line <- position 3, file line 2
|
|
42
|
-
* +another new <- position 4, file line 3
|
|
43
|
-
* context <- position 5, file line 4
|
|
44
|
-
* ```
|
|
45
|
-
*
|
|
46
|
-
* Returns: Map<file line number, diff position>
|
|
47
|
-
*/
|
|
48
|
-
function buildLineToPositionMap(patch) {
|
|
49
|
-
const lineToPosition = new Map();
|
|
50
|
-
const lines = patch.split('\n');
|
|
51
|
-
let position = 0; // Position in the diff (starts at 1 after first line after @@)
|
|
52
|
-
let currentNewLine = 0; // Current line number in the new file (RIGHT side)
|
|
53
|
-
for (const line of lines) {
|
|
54
|
-
// Parse hunk headers like @@ -1,7 +1,7 @@
|
|
55
|
-
// This extracts the starting line number for the new file (+side)
|
|
56
|
-
const hunkMatch = line.match(/^@@\s+-\d+(?:,\d+)?\s+\+(\d+)(?:,\d+)?\s+@@/);
|
|
57
|
-
if (hunkMatch) {
|
|
58
|
-
currentNewLine = parseInt(hunkMatch[1], 10);
|
|
59
|
-
// Don't increment position for the @@ line itself
|
|
60
|
-
continue;
|
|
61
|
-
}
|
|
62
|
-
// Every line after @@ increments the position counter
|
|
63
|
-
position++;
|
|
64
|
-
// Map line numbers based on line type
|
|
65
|
-
if (line.startsWith('+')) {
|
|
66
|
-
// Addition: this line exists in the new file at currentNewLine
|
|
67
|
-
lineToPosition.set(currentNewLine, position);
|
|
68
|
-
currentNewLine++;
|
|
69
|
-
}
|
|
70
|
-
else if (line.startsWith('-')) {
|
|
71
|
-
// Deletion: this line existed in old file, not in new file
|
|
72
|
-
// Position still increments, but currentNewLine does not
|
|
73
|
-
// We can't comment on this line using file line numbers
|
|
74
|
-
}
|
|
75
|
-
else if (!line.startsWith('\\')) {
|
|
76
|
-
// Context line (no prefix): exists in both old and new file
|
|
77
|
-
lineToPosition.set(currentNewLine, position);
|
|
78
|
-
currentNewLine++;
|
|
79
|
-
}
|
|
80
|
-
// Lines starting with '\' (like "") are metadata
|
|
81
|
-
}
|
|
82
|
-
return lineToPosition;
|
|
83
|
-
}
|
|
84
|
-
/**
|
|
85
|
-
* Find the diff position for a target line number, or the closest nearby position
|
|
86
|
-
*
|
|
87
|
-
* @param targetLine - The file line number we want to comment on
|
|
88
|
-
* @param lineToPosition - Map of file line numbers to diff positions
|
|
89
|
-
* @returns Object with position and actual line number, or null if not found
|
|
90
|
-
*/
|
|
91
|
-
function findClosestPosition(targetLine, lineToPosition) {
|
|
92
|
-
// Check if exact line exists in the diff
|
|
93
|
-
const exactPosition = lineToPosition.get(targetLine);
|
|
94
|
-
if (exactPosition !== undefined) {
|
|
95
|
-
return {
|
|
96
|
-
position: exactPosition,
|
|
97
|
-
actualLine: targetLine,
|
|
98
|
-
};
|
|
99
|
-
}
|
|
100
|
-
// Try to find a nearby line within ยฑ10 lines range
|
|
101
|
-
const range = 10;
|
|
102
|
-
for (let offset = 1; offset <= range; offset++) {
|
|
103
|
-
// Try below first (more likely to be relevant for review comments)
|
|
104
|
-
const lineBelow = targetLine + offset;
|
|
105
|
-
const belowPosition = lineToPosition.get(lineBelow);
|
|
106
|
-
if (belowPosition !== undefined) {
|
|
107
|
-
return {
|
|
108
|
-
position: belowPosition,
|
|
109
|
-
actualLine: lineBelow,
|
|
110
|
-
};
|
|
111
|
-
}
|
|
112
|
-
// Try above
|
|
113
|
-
const lineAbove = targetLine - offset;
|
|
114
|
-
const abovePosition = lineToPosition.get(lineAbove);
|
|
115
|
-
if (abovePosition !== undefined) {
|
|
116
|
-
return {
|
|
117
|
-
position: abovePosition,
|
|
118
|
-
actualLine: lineAbove,
|
|
119
|
-
};
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
// No valid position found within range
|
|
123
|
-
return null;
|
|
124
|
-
}
|
|
125
|
-
/**
|
|
126
|
-
* Extract text content from assistant message content array
|
|
127
|
-
*/
|
|
128
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
129
|
-
function extractTextFromContent(content, verbose) {
|
|
130
|
-
let text = '';
|
|
131
|
-
for (const item of content) {
|
|
132
|
-
if (item.type === 'text') {
|
|
133
|
-
text += `${item.text}\n`;
|
|
134
|
-
logDebug(`๐ ${item.text}`, verbose);
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
return text;
|
|
138
|
-
}
|
|
139
|
-
/**
|
|
140
|
-
* Parse review result from a JSON response string
|
|
141
|
-
*/
|
|
142
|
-
function tryParseReviewResult(responseText) {
|
|
143
|
-
const jsonBlockMatch = responseText.match(/```json\s*\n([\s\S]*?)\n\s*```/);
|
|
144
|
-
const jsonResult = jsonBlockMatch
|
|
145
|
-
? JSON.parse(jsonBlockMatch[1])
|
|
146
|
-
: JSON.parse(responseText);
|
|
147
|
-
if (jsonResult && jsonResult.review_result) {
|
|
148
|
-
return jsonResult.review_result;
|
|
149
|
-
}
|
|
150
|
-
throw new Error('Invalid JSON structure');
|
|
151
|
-
}
|
|
17
|
+
import { buildLineToPositionMap, findClosestPosition } from './diff-utils.js';
|
|
152
18
|
/**
|
|
153
19
|
* Process a query result message and extract the structured review result
|
|
154
20
|
*/
|
|
155
21
|
function parseQueryResultMessage(message, lastAssistantResponse) {
|
|
156
22
|
if (message.subtype === 'success') {
|
|
157
|
-
logInfo('\n
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
logError(`Failed to parse structured review result: ${error}`);
|
|
163
|
-
return parseCodeReviewResponse(message.result || lastAssistantResponse);
|
|
23
|
+
logInfo('\n Code review analysis completed, parsing results...');
|
|
24
|
+
const responseText = message.result || lastAssistantResponse;
|
|
25
|
+
const result = tryExtractResult(responseText, 'review_result');
|
|
26
|
+
if (result) {
|
|
27
|
+
return result;
|
|
164
28
|
}
|
|
29
|
+
logError('Failed to parse structured review result');
|
|
30
|
+
return parseCodeReviewResponse(responseText);
|
|
165
31
|
}
|
|
166
|
-
logError(`\n
|
|
32
|
+
logError(`\n Code review incomplete: ${message.subtype}`);
|
|
167
33
|
if (message.subtype === 'error_max_turns') {
|
|
168
|
-
logError('
|
|
34
|
+
logError('Try simplifying the review scope');
|
|
169
35
|
}
|
|
170
36
|
if (!lastAssistantResponse) {
|
|
171
37
|
return null;
|
|
172
38
|
}
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
catch (error) {
|
|
177
|
-
logError(`Failed to parse assistant response: ${error}`);
|
|
178
|
-
return parseCodeReviewResponse(lastAssistantResponse);
|
|
39
|
+
const result = tryExtractResult(lastAssistantResponse, 'review_result');
|
|
40
|
+
if (result) {
|
|
41
|
+
return result;
|
|
179
42
|
}
|
|
43
|
+
return parseCodeReviewResponse(lastAssistantResponse);
|
|
180
44
|
}
|
|
181
45
|
/**
|
|
182
46
|
* Log base branch rebase info for verbose mode
|
|
@@ -374,7 +238,7 @@ export const reviewPullRequest = async (options, config, checklistContext
|
|
|
374
238
|
// Use Claude to analyze the code (not using Claude Code SDK for actual implementation)
|
|
375
239
|
// This is a simplified approach - just analyze the code
|
|
376
240
|
for await (const message of query({
|
|
377
|
-
prompt:
|
|
241
|
+
prompt: createPromptGenerator(reviewPrompt),
|
|
378
242
|
options: {
|
|
379
243
|
systemPrompt: {
|
|
380
244
|
type: 'preset',
|
|
@@ -38,6 +38,18 @@ export declare function removeDeletedFilesFromPRs(changedFiles: ChangedFileInfo[
|
|
|
38
38
|
change_type: string;
|
|
39
39
|
}[];
|
|
40
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>;
|
|
41
53
|
/**
|
|
42
54
|
* Detect unassigned files and use LLM to assign them to existing PRs.
|
|
43
55
|
* Returns the number of files assigned, or 0 if none needed assignment.
|
|
@@ -194,6 +194,40 @@ export async function removeDeletedFilesFromPRs(changedFiles, pullRequests, upda
|
|
|
194
194
|
}
|
|
195
195
|
return updatedCount;
|
|
196
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
|
+
}
|
|
197
231
|
/**
|
|
198
232
|
* Detect unassigned files and use LLM to assign them to existing PRs.
|
|
199
233
|
* Returns the number of files assigned, or 0 if none needed assignment.
|