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.
Files changed (44) hide show
  1. package/dist/commands/pr-resolve/index.d.ts +10 -0
  2. package/dist/commands/pr-resolve/index.js +44 -0
  3. package/dist/commands/pr-review/index.d.ts +10 -0
  4. package/dist/commands/pr-review/index.js +43 -0
  5. package/dist/index.js +38 -0
  6. package/dist/phases/code-review/__tests__/diff-utils.test.d.ts +1 -0
  7. package/dist/phases/code-review/__tests__/diff-utils.test.js +101 -0
  8. package/dist/phases/code-review/diff-utils.d.ts +36 -0
  9. package/dist/phases/code-review/diff-utils.js +100 -0
  10. package/dist/phases/code-review/index.js +17 -153
  11. package/dist/phases/pr-execution/file-assigner.d.ts +12 -0
  12. package/dist/phases/pr-execution/file-assigner.js +34 -0
  13. package/dist/phases/pr-execution/index.js +25 -8
  14. package/dist/phases/pr-resolve/__tests__/prompts.test.d.ts +1 -0
  15. package/dist/phases/pr-resolve/__tests__/prompts.test.js +116 -0
  16. package/dist/phases/pr-resolve/__tests__/resolve-mapping.test.d.ts +1 -0
  17. package/dist/phases/pr-resolve/__tests__/resolve-mapping.test.js +138 -0
  18. package/dist/phases/pr-resolve/__tests__/workspace.test.d.ts +1 -0
  19. package/dist/phases/pr-resolve/__tests__/workspace.test.js +111 -0
  20. package/dist/phases/pr-resolve/github-reply.d.ts +13 -0
  21. package/dist/phases/pr-resolve/github-reply.js +59 -0
  22. package/dist/phases/pr-resolve/index.d.ts +27 -0
  23. package/dist/phases/pr-resolve/index.js +213 -0
  24. package/dist/phases/pr-resolve/prompts.d.ts +17 -0
  25. package/dist/phases/pr-resolve/prompts.js +106 -0
  26. package/dist/phases/pr-resolve/workspace.d.ts +29 -0
  27. package/dist/phases/pr-resolve/workspace.js +145 -0
  28. package/dist/phases/pr-review/__tests__/prompts.test.d.ts +1 -0
  29. package/dist/phases/pr-review/__tests__/prompts.test.js +49 -0
  30. package/dist/phases/pr-review/__tests__/review-comments.test.d.ts +1 -0
  31. package/dist/phases/pr-review/__tests__/review-comments.test.js +108 -0
  32. package/dist/phases/pr-review/index.d.ts +25 -0
  33. package/dist/phases/pr-review/index.js +213 -0
  34. package/dist/phases/pr-review/prompts.d.ts +5 -0
  35. package/dist/phases/pr-review/prompts.js +87 -0
  36. package/dist/phases/pr-shared/__tests__/agent-utils.test.d.ts +1 -0
  37. package/dist/phases/pr-shared/__tests__/agent-utils.test.js +91 -0
  38. package/dist/phases/pr-shared/__tests__/context.test.d.ts +1 -0
  39. package/dist/phases/pr-shared/__tests__/context.test.js +94 -0
  40. package/dist/phases/pr-shared/agent-utils.d.ts +39 -0
  41. package/dist/phases/pr-shared/agent-utils.js +69 -0
  42. package/dist/phases/pr-shared/context.d.ts +24 -0
  43. package/dist/phases/pr-shared/context.js +78 -0
  44. package/package.json +1 -1
@@ -0,0 +1,213 @@
1
+ /**
2
+ * Standalone PR Resolve
3
+ * Evaluates unresolved review comments, fixes what's genuinely better,
4
+ * replies to every comment on GitHub explaining the decision.
5
+ */
6
+ import { query } from '@anthropic-ai/claude-agent-sdk';
7
+ import { Octokit } from '@octokit/rest';
8
+ import { execSync } from 'child_process';
9
+ import { rmSync } from 'fs';
10
+ import { callMcpEndpoint } from '../../api/mcp-client.js';
11
+ import { DEFAULT_MODEL } from '../../constants.js';
12
+ import { logError, logInfo, logSuccess } from '../../utils/logger.js';
13
+ import { fetchUnresolvedReviewThreads } from '../code-refine-verification/github.js';
14
+ import { createPromptGenerator, extractTextFromContent, tryExtractResult, } from '../pr-shared/agent-utils.js';
15
+ import { parsePullRequestUrl } from '../pr-shared/context.js';
16
+ import { replyToReviewThread, resolveReviewThread } from './github-reply.js';
17
+ import { createResolveSystemPrompt, createResolveUserPrompt, } from './prompts.js';
18
+ import { hasNewCommits, hasUncommittedChanges, prepareWorkspace, pushChanges, } from './workspace.js';
19
+ /**
20
+ * Resolve PR change requests: evaluate each comment, fix or explain.
21
+ */
22
+ export async function resolveStandalonePR(options) {
23
+ const { pullRequestUrl, githubToken, owner, repo, verbose, prId } = options;
24
+ logInfo(`Starting PR resolve: ${pullRequestUrl}`);
25
+ try {
26
+ // Parse PR URL
27
+ const prInfo = parsePullRequestUrl(pullRequestUrl);
28
+ if (!prInfo) {
29
+ throw new Error(`Invalid PR URL: ${pullRequestUrl}`);
30
+ }
31
+ const octokit = new Octokit({ auth: githubToken });
32
+ // Fetch unresolved review threads
33
+ logInfo('Fetching unresolved review threads...');
34
+ const unresolvedThreads = await fetchUnresolvedReviewThreads(octokit, owner, repo, prInfo.prNumber, verbose);
35
+ if (unresolvedThreads.length === 0) {
36
+ logSuccess('No unresolved review threads found.');
37
+ return {
38
+ status: 'success',
39
+ message: 'No unresolved review threads to resolve',
40
+ threadsAddressed: 0,
41
+ threadsSkipped: 0,
42
+ };
43
+ }
44
+ logInfo(`Found ${unresolvedThreads.length} unresolved threads to evaluate`);
45
+ // Get PR details for head branch
46
+ // Note: workspace will be preserved on failure for manual inspection
47
+ const { data: prData } = await octokit.pulls.get({
48
+ owner,
49
+ repo,
50
+ pull_number: prInfo.prNumber,
51
+ });
52
+ const headRef = prData.head.ref;
53
+ // Clone repo and checkout PR branch
54
+ const repoPath = prepareWorkspace(owner, repo, headRef, prInfo.prNumber, githubToken, verbose);
55
+ try {
56
+ // Run Claude Agent SDK to evaluate and fix comments
57
+ const systemPrompt = createResolveSystemPrompt();
58
+ const { prompt: resolvePrompt, commentIdToThreadId } = createResolveUserPrompt(unresolvedThreads);
59
+ let lastAssistantResponse = '';
60
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
61
+ let resolveResult = null;
62
+ logInfo('Starting Claude agent to evaluate and resolve comments...');
63
+ for await (const message of query({
64
+ prompt: createPromptGenerator(resolvePrompt),
65
+ options: {
66
+ systemPrompt: {
67
+ type: 'preset',
68
+ preset: 'claude_code',
69
+ append: systemPrompt,
70
+ },
71
+ model: DEFAULT_MODEL,
72
+ maxTurns: 2000,
73
+ permissionMode: 'bypassPermissions',
74
+ cwd: repoPath,
75
+ },
76
+ })) {
77
+ if (message.type === 'assistant') {
78
+ lastAssistantResponse += extractTextFromContent(message.message?.content ?? [], verbose);
79
+ continue;
80
+ }
81
+ if (message.type !== 'result') {
82
+ continue;
83
+ }
84
+ if (message.subtype === 'success') {
85
+ logInfo('Agent completed, parsing results...');
86
+ const responseText = message.result || lastAssistantResponse;
87
+ resolveResult = tryExtractResult(responseText, 'resolve_result');
88
+ if (!resolveResult) {
89
+ logError('Failed to parse resolve result JSON');
90
+ }
91
+ }
92
+ else {
93
+ logError(`Agent incomplete: ${message.subtype}`);
94
+ // Try to salvage partial results from last response
95
+ if (lastAssistantResponse) {
96
+ resolveResult = tryExtractResult(lastAssistantResponse, 'resolve_result');
97
+ }
98
+ }
99
+ }
100
+ // Commit and push any changes the agent made
101
+ if (hasUncommittedChanges(repoPath)) {
102
+ logInfo('Committing changes...');
103
+ execSync('git add -A', { cwd: repoPath, stdio: 'pipe' });
104
+ execSync('git commit -m "Resolve PR review comments\n\nAutomated resolution by Edsger AI"', { cwd: repoPath, stdio: 'pipe' });
105
+ }
106
+ // Check if there are commits to push (agent may have made its own commits)
107
+ const newCommitsExist = hasNewCommits(repoPath, headRef);
108
+ if (newCommitsExist) {
109
+ pushChanges(repoPath, headRef, githubToken, verbose);
110
+ }
111
+ // Reply to each comment on GitHub
112
+ let threadsAddressed = 0;
113
+ let threadsSkipped = 0;
114
+ let threadsErrored = 0;
115
+ if (resolveResult?.comments) {
116
+ const comments = resolveResult.comments;
117
+ for (const comment of comments) {
118
+ // Map comment_id back to real GraphQL thread ID
119
+ const threadId = commentIdToThreadId.get(comment.comment_id);
120
+ if (!threadId) {
121
+ logError(`Unknown comment_id "${comment.comment_id}", skipping reply`);
122
+ threadsErrored++;
123
+ continue;
124
+ }
125
+ try {
126
+ const replied = await replyToReviewThread(octokit, threadId, comment.reply, verbose);
127
+ if (replied && comment.action === 'changed') {
128
+ // Resolve the thread since the change was made
129
+ await resolveReviewThread(octokit, threadId, verbose);
130
+ threadsAddressed++;
131
+ }
132
+ else if (replied && comment.action === 'skipped') {
133
+ // Don't resolve - leave open for human to review the explanation
134
+ threadsSkipped++;
135
+ }
136
+ else {
137
+ threadsErrored++;
138
+ }
139
+ }
140
+ catch (error) {
141
+ logError(`Failed to process ${comment.comment_id}: ${error}`);
142
+ threadsErrored++;
143
+ }
144
+ }
145
+ }
146
+ else {
147
+ // No structured result - check if agent made changes and reply generically
148
+ logInfo('No structured resolve result. Replying with generic status to each thread.');
149
+ const agentMadeChanges = newCommitsExist;
150
+ for (const thread of unresolvedThreads) {
151
+ try {
152
+ const genericReply = agentMadeChanges
153
+ ? 'Changes were made to address review feedback. Please re-review.'
154
+ : 'Reviewed this comment. No changes were made at this time.';
155
+ const replied = await replyToReviewThread(octokit, thread.id, genericReply, verbose);
156
+ if (replied) {
157
+ threadsSkipped++;
158
+ }
159
+ else {
160
+ threadsErrored++;
161
+ }
162
+ }
163
+ catch {
164
+ threadsErrored++;
165
+ }
166
+ }
167
+ }
168
+ logSuccess(`PR resolve completed: ${threadsAddressed} addressed, ${threadsSkipped} skipped, ${threadsErrored} errors`);
169
+ if (prId) {
170
+ try {
171
+ await callMcpEndpoint('pull_requests/update', {
172
+ pull_request_id: prId,
173
+ status: 'in_review',
174
+ });
175
+ }
176
+ catch {
177
+ // Non-critical
178
+ }
179
+ }
180
+ return {
181
+ status: 'success',
182
+ message: `Resolved ${threadsAddressed} threads, skipped ${threadsSkipped}, ${threadsErrored} errors`,
183
+ threadsAddressed,
184
+ threadsSkipped,
185
+ threadsErrored,
186
+ filesModified: resolveResult?.files_modified,
187
+ summary: resolveResult?.summary,
188
+ };
189
+ }
190
+ catch (innerError) {
191
+ // On failure, keep workspace for inspection
192
+ logError(`Error during resolve: ${innerError instanceof Error ? innerError.message : String(innerError)}`);
193
+ logInfo(`Workspace preserved for inspection: ${repoPath}`);
194
+ throw innerError;
195
+ }
196
+ // Only clean up on success
197
+ try {
198
+ rmSync(repoPath, { recursive: true, force: true });
199
+ logInfo(`Cleaned up workspace: ${repoPath}`);
200
+ }
201
+ catch {
202
+ // ignore cleanup errors
203
+ }
204
+ }
205
+ catch (error) {
206
+ const errorMessage = error instanceof Error ? error.message : String(error);
207
+ logError(`PR resolve failed: ${errorMessage}`);
208
+ return {
209
+ status: 'error',
210
+ message: `PR resolve failed: ${errorMessage}`,
211
+ };
212
+ }
213
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Prompts for standalone PR resolve.
3
+ * The agent evaluates each review comment, fixes what's genuinely better, and explains what it won't change.
4
+ *
5
+ * Uses simple numeric IDs (comment_1, comment_2...) instead of opaque GraphQL thread IDs
6
+ * to make it easier for the agent to produce correct output. The caller maps back to real IDs.
7
+ */
8
+ import { type ReviewThread } from '../code-refine-verification/types.js';
9
+ export declare function createResolveSystemPrompt(): string;
10
+ /**
11
+ * Build the user prompt with numbered comment IDs.
12
+ * Returns the prompt string and a mapping from comment_id to real thread ID.
13
+ */
14
+ export declare function createResolveUserPrompt(unresolvedThreads: ReviewThread[]): {
15
+ prompt: string;
16
+ commentIdToThreadId: Map<string, string>;
17
+ };
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Prompts for standalone PR resolve.
3
+ * The agent evaluates each review comment, fixes what's genuinely better, and explains what it won't change.
4
+ *
5
+ * Uses simple numeric IDs (comment_1, comment_2...) instead of opaque GraphQL thread IDs
6
+ * to make it easier for the agent to produce correct output. The caller maps back to real IDs.
7
+ */
8
+ export function createResolveSystemPrompt() {
9
+ return `You are an expert software engineer resolving code review feedback on a pull request.
10
+
11
+ **Your Goal**: For each review comment, evaluate whether the suggested change genuinely improves the code. If it does, make the change. If you disagree, do NOT make the change.
12
+
13
+ **Decision Criteria - Make the change when**:
14
+ - The suggestion fixes a real bug or logic error
15
+ - The suggestion improves correctness, security, or error handling
16
+ - The suggestion makes the code clearer or more maintainable
17
+ - The suggestion follows established best practices for the language/framework
18
+
19
+ **Skip the change when**:
20
+ - The suggestion is purely stylistic preference without clear benefit
21
+ - The suggestion would increase complexity without proportional value
22
+ - The suggestion conflicts with the codebase's established patterns
23
+ - You disagree with the technical rationale
24
+
25
+ **Process**:
26
+ 1. Read all the review comments carefully
27
+ 2. For each comment, examine the relevant code
28
+ 3. If you agree: make the change in the file
29
+ 4. If you disagree: skip it (do NOT modify the file for that comment)
30
+ 5. After processing all comments, output a JSON summary
31
+
32
+ **CRITICAL - Result Format**:
33
+ After making all changes, you MUST output a JSON result. Use the exact comment_id from each comment (comment_1, comment_2, etc.):
34
+
35
+ \`\`\`json
36
+ {
37
+ "resolve_result": {
38
+ "comments": [
39
+ {
40
+ "comment_id": "comment_1",
41
+ "action": "changed",
42
+ "reply": "Brief description of what was changed"
43
+ },
44
+ {
45
+ "comment_id": "comment_2",
46
+ "action": "skipped",
47
+ "reply": "Technical explanation of why not changing"
48
+ }
49
+ ],
50
+ "files_modified": ["list of modified file paths"],
51
+ "summary": "Overall summary of what was done"
52
+ }
53
+ }
54
+ \`\`\`
55
+
56
+ **Reply Guidelines**:
57
+ - For "changed": briefly describe what was changed (1-2 sentences)
58
+ - For "skipped": provide a clear, respectful technical explanation of why the current code is better (2-3 sentences)
59
+ - Be professional and constructive in all replies
60
+ - You MUST include an entry for EVERY comment_id`;
61
+ }
62
+ /**
63
+ * Build the user prompt with numbered comment IDs.
64
+ * Returns the prompt string and a mapping from comment_id to real thread ID.
65
+ */
66
+ export function createResolveUserPrompt(unresolvedThreads) {
67
+ const sections = [];
68
+ const commentIdToThreadId = new Map();
69
+ sections.push('# PR Review Comments to Resolve');
70
+ sections.push('');
71
+ sections.push(`There are ${unresolvedThreads.length} unresolved review comment(s) to evaluate.`);
72
+ sections.push('');
73
+ let commentIndex = 0;
74
+ for (const thread of unresolvedThreads) {
75
+ const firstComment = thread.comments.nodes[0];
76
+ if (!firstComment)
77
+ continue;
78
+ commentIndex++;
79
+ const commentId = `comment_${commentIndex}`;
80
+ commentIdToThreadId.set(commentId, thread.id);
81
+ sections.push(`## ${commentId}`);
82
+ sections.push(`**File**: ${firstComment.path}`);
83
+ if (firstComment.line) {
84
+ sections.push(`**Line**: ${firstComment.line}`);
85
+ }
86
+ sections.push(`**Author**: @${firstComment.author.login}`);
87
+ sections.push(`**Comment**:`);
88
+ sections.push(firstComment.body);
89
+ // Include follow-up comments in the thread
90
+ if (thread.comments.nodes.length > 1) {
91
+ sections.push('');
92
+ sections.push('**Follow-up comments**:');
93
+ for (const comment of thread.comments.nodes.slice(1)) {
94
+ sections.push(`- @${comment.author.login}: ${comment.body}`);
95
+ }
96
+ }
97
+ sections.push('');
98
+ }
99
+ sections.push('## Instructions');
100
+ sections.push('');
101
+ sections.push('For each comment above, read the referenced file and evaluate the suggestion.');
102
+ sections.push('Make changes only when they genuinely improve the code. Skip changes you disagree with.');
103
+ sections.push('After processing all comments, output the JSON resolve_result with your decisions and reply messages.');
104
+ sections.push(`Use the exact comment IDs: ${Array.from(commentIdToThreadId.keys()).join(', ')}`);
105
+ return { prompt: sections.join('\n'), commentIdToThreadId };
106
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Git workspace operations for PR resolve.
3
+ * Extracted for testability.
4
+ */
5
+ /**
6
+ * Build git credential helper args for GitHub token auth.
7
+ */
8
+ export declare function buildCredentialArgs(token: string): string[];
9
+ /**
10
+ * Get the workspace path for a PR resolve operation.
11
+ */
12
+ export declare function getResolveWorkspacePath(prNumber: number): string;
13
+ /**
14
+ * Clone or reuse a repo for PR resolve.
15
+ * Returns the workspace path.
16
+ */
17
+ export declare function prepareWorkspace(owner: string, repo: string, headRef: string, prNumber: number, token: string, verbose?: boolean): string;
18
+ /**
19
+ * Push changes from workspace back to remote.
20
+ */
21
+ export declare function pushChanges(repoPath: string, headRef: string, token: string, verbose?: boolean): boolean;
22
+ /**
23
+ * Check if there are uncommitted changes in the workspace.
24
+ */
25
+ export declare function hasUncommittedChanges(repoPath: string): boolean;
26
+ /**
27
+ * Check if there are new commits ahead of remote.
28
+ */
29
+ export declare function hasNewCommits(repoPath: string, headRef: string): boolean;
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Git workspace operations for PR resolve.
3
+ * Extracted for testability.
4
+ */
5
+ import { execFileSync, execSync } from 'child_process';
6
+ import { existsSync } from 'fs';
7
+ import { homedir } from 'os';
8
+ import { join } from 'path';
9
+ import { logError, logInfo, logSuccess } from '../../utils/logger.js';
10
+ /**
11
+ * Build git credential helper args for GitHub token auth.
12
+ */
13
+ export function buildCredentialArgs(token) {
14
+ const credentialHelper = `!f() { echo "username=x-access-token"; echo "password=${token}"; }; f`;
15
+ return [
16
+ '-c',
17
+ 'credential.helper=',
18
+ '-c',
19
+ `credential.helper=${credentialHelper}`,
20
+ ];
21
+ }
22
+ /**
23
+ * Get the workspace path for a PR resolve operation.
24
+ */
25
+ export function getResolveWorkspacePath(prNumber) {
26
+ return join(homedir(), 'edsger', `pr-resolve-${prNumber}`);
27
+ }
28
+ /**
29
+ * Clone or reuse a repo for PR resolve.
30
+ * Returns the workspace path.
31
+ */
32
+ export function prepareWorkspace(owner, repo, headRef, prNumber, token, verbose) {
33
+ const repoPath = getResolveWorkspacePath(prNumber);
34
+ const repoUrl = `https://github.com/${owner}/${repo}.git`;
35
+ const gitCredentialArgs = buildCredentialArgs(token);
36
+ if (existsSync(join(repoPath, '.git'))) {
37
+ logInfo(`Reusing existing repo at ${repoPath}`);
38
+ try {
39
+ execSync(`git remote set-url origin "${repoUrl}"`, {
40
+ cwd: repoPath,
41
+ stdio: 'pipe',
42
+ });
43
+ }
44
+ catch {
45
+ // ignore
46
+ }
47
+ try {
48
+ execFileSync('git', [...gitCredentialArgs, 'fetch', 'origin'], {
49
+ cwd: repoPath,
50
+ stdio: 'pipe',
51
+ });
52
+ }
53
+ catch {
54
+ logError('Could not fetch latest changes');
55
+ }
56
+ try {
57
+ execSync(`git checkout ${headRef}`, { cwd: repoPath, stdio: 'pipe' });
58
+ execSync(`git reset --hard origin/${headRef}`, {
59
+ cwd: repoPath,
60
+ stdio: 'pipe',
61
+ });
62
+ }
63
+ catch {
64
+ logError(`Could not checkout branch ${headRef}`);
65
+ }
66
+ }
67
+ else {
68
+ logInfo(`Cloning ${owner}/${repo} (branch: ${headRef})...`);
69
+ execFileSync('git', [
70
+ ...gitCredentialArgs,
71
+ 'clone',
72
+ '--branch',
73
+ headRef,
74
+ '--single-branch',
75
+ repoUrl,
76
+ repoPath,
77
+ ], { stdio: 'pipe' });
78
+ logSuccess(`Cloned to ${repoPath}`);
79
+ }
80
+ // Configure git user for commits
81
+ try {
82
+ execSync('git config user.email "edsger-bot@edsger.ai"', {
83
+ cwd: repoPath,
84
+ stdio: 'pipe',
85
+ });
86
+ execSync('git config user.name "Edsger Bot"', {
87
+ cwd: repoPath,
88
+ stdio: 'pipe',
89
+ });
90
+ }
91
+ catch {
92
+ if (verbose) {
93
+ logInfo('Note: Could not set git user config');
94
+ }
95
+ }
96
+ return repoPath;
97
+ }
98
+ /**
99
+ * Push changes from workspace back to remote.
100
+ */
101
+ export function pushChanges(repoPath, headRef, token, verbose) {
102
+ const gitCredentialArgs = buildCredentialArgs(token);
103
+ try {
104
+ execFileSync('git', [...gitCredentialArgs, 'push', 'origin', headRef], {
105
+ cwd: repoPath,
106
+ stdio: 'pipe',
107
+ });
108
+ logSuccess('Pushed changes to remote');
109
+ return true;
110
+ }
111
+ catch (error) {
112
+ logError(`Failed to push: ${error instanceof Error ? error.message : String(error)}`);
113
+ return false;
114
+ }
115
+ }
116
+ /**
117
+ * Check if there are uncommitted changes in the workspace.
118
+ */
119
+ export function hasUncommittedChanges(repoPath) {
120
+ try {
121
+ const result = execSync('git status --porcelain', {
122
+ cwd: repoPath,
123
+ encoding: 'utf-8',
124
+ });
125
+ return result.trim().length > 0;
126
+ }
127
+ catch {
128
+ return false;
129
+ }
130
+ }
131
+ /**
132
+ * Check if there are new commits ahead of remote.
133
+ */
134
+ export function hasNewCommits(repoPath, headRef) {
135
+ try {
136
+ const result = execSync(`git log origin/${headRef}..HEAD --oneline`, {
137
+ cwd: repoPath,
138
+ encoding: 'utf-8',
139
+ });
140
+ return result.trim().length > 0;
141
+ }
142
+ catch {
143
+ return false;
144
+ }
145
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,49 @@
1
+ import assert from 'node:assert';
2
+ import { describe, it } from 'node:test';
3
+ import { createStandaloneReviewSystemPrompt, createStandaloneReviewUserPrompt, } from '../prompts.js';
4
+ describe('createStandaloneReviewSystemPrompt', () => {
5
+ it('includes review focus areas', () => {
6
+ const prompt = createStandaloneReviewSystemPrompt();
7
+ assert.ok(prompt.includes('Code Quality'));
8
+ assert.ok(prompt.includes('Security'));
9
+ assert.ok(prompt.includes('Performance'));
10
+ });
11
+ it('specifies JSON result format', () => {
12
+ const prompt = createStandaloneReviewSystemPrompt();
13
+ assert.ok(prompt.includes('review_result'));
14
+ assert.ok(prompt.includes('"file"'));
15
+ assert.ok(prompt.includes('"line"'));
16
+ assert.ok(prompt.includes('"comment"'));
17
+ });
18
+ it('includes assessment options', () => {
19
+ const prompt = createStandaloneReviewSystemPrompt();
20
+ assert.ok(prompt.includes('APPROVE'));
21
+ assert.ok(prompt.includes('REQUEST_CHANGES'));
22
+ assert.ok(prompt.includes('COMMENT'));
23
+ });
24
+ it('does not include feature/checklist instructions', () => {
25
+ const prompt = createStandaloneReviewSystemPrompt();
26
+ assert.ok(!prompt.includes('checklist'));
27
+ assert.ok(!prompt.includes('feature_id'));
28
+ assert.ok(!prompt.includes('user stories'));
29
+ });
30
+ });
31
+ describe('createStandaloneReviewUserPrompt', () => {
32
+ it('includes context info in the prompt', () => {
33
+ const contextInfo = '# Pull Request\n**Title**: Fix auth bug\n';
34
+ const prompt = createStandaloneReviewUserPrompt(contextInfo);
35
+ assert.ok(prompt.includes('Fix auth bug'));
36
+ });
37
+ it('includes review instructions', () => {
38
+ const prompt = createStandaloneReviewUserPrompt('some context');
39
+ assert.ok(prompt.includes('Analyze Each File'));
40
+ assert.ok(prompt.includes('Identify Issues'));
41
+ assert.ok(prompt.includes('Actionable Feedback'));
42
+ });
43
+ it('mentions severity categories', () => {
44
+ const prompt = createStandaloneReviewUserPrompt('context');
45
+ assert.ok(prompt.includes('Critical'));
46
+ assert.ok(prompt.includes('Major'));
47
+ assert.ok(prompt.includes('Minor'));
48
+ });
49
+ });
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Integration-level tests for the review comment mapping logic.
3
+ * Tests the flow: agent JSON result → diff position mapping → GitHub comment payload.
4
+ */
5
+ import assert from 'node:assert';
6
+ import { describe, it } from 'node:test';
7
+ import { buildLineToPositionMap, findClosestPosition, } from '../../code-review/diff-utils.js';
8
+ // Simulate the comment mapping logic from reviewStandalonePR
9
+ function mapCommentsToReviewPayload(agentComments, files) {
10
+ // Build line-to-position maps
11
+ const fileLineToPosition = new Map();
12
+ for (const file of files) {
13
+ if (file.patch) {
14
+ fileLineToPosition.set(file.filename, buildLineToPositionMap(file.patch));
15
+ }
16
+ }
17
+ const result = [];
18
+ for (const comment of agentComments) {
19
+ const lineToPosition = fileLineToPosition.get(comment.file);
20
+ if (!lineToPosition)
21
+ continue;
22
+ const positionResult = findClosestPosition(comment.line, lineToPosition);
23
+ if (!positionResult)
24
+ continue;
25
+ let body = comment.comment;
26
+ if (positionResult.actualLine !== comment.line) {
27
+ body = `**Note**: Comment originally for line ${comment.line}, adjusted to line ${positionResult.actualLine} (nearest line in diff).\n\n${body}`;
28
+ }
29
+ result.push({
30
+ path: comment.file,
31
+ position: positionResult.position,
32
+ body,
33
+ });
34
+ }
35
+ return result;
36
+ }
37
+ describe('review comment mapping (integration)', () => {
38
+ const samplePatch = `@@ -1,5 +1,7 @@
39
+ import { useState } from 'react'
40
+
41
+ +const MAX_RETRIES = 3
42
+ +
43
+ export function App() {
44
+ - const [count, setCount] = useState(0)
45
+ + const [count, setCount] = useState<number>(0)
46
+ return <div>{count}</div>`;
47
+ const files = [{ filename: 'src/App.tsx', patch: samplePatch }];
48
+ it('maps exact line comments to correct diff positions', () => {
49
+ const comments = [
50
+ {
51
+ file: 'src/App.tsx',
52
+ line: 3,
53
+ comment: 'Consider making this configurable',
54
+ },
55
+ ];
56
+ const result = mapCommentsToReviewPayload(comments, files);
57
+ assert.strictEqual(result.length, 1);
58
+ assert.strictEqual(result[0].path, 'src/App.tsx');
59
+ assert.ok(result[0].position > 0);
60
+ assert.ok(result[0].body.includes('Consider making this configurable'));
61
+ // No "Note" prefix since line was exact
62
+ assert.ok(!result[0].body.includes('**Note**'));
63
+ });
64
+ it('adjusts line numbers when exact line not in diff', () => {
65
+ // Line 2 is blank (in diff) but line 100 is way outside
66
+ const comments = [{ file: 'src/App.tsx', line: 8, comment: 'Fix this' }];
67
+ const result = mapCommentsToReviewPayload(comments, files);
68
+ // Line 8 should find closest in range
69
+ if (result.length > 0) {
70
+ assert.ok(result[0].body.includes('**Note**'));
71
+ }
72
+ // If no match within range, it's filtered out - also valid
73
+ });
74
+ it('filters out comments for files not in diff', () => {
75
+ const comments = [
76
+ {
77
+ file: 'src/other.ts',
78
+ line: 5,
79
+ comment: 'This file is not in the diff',
80
+ },
81
+ ];
82
+ const result = mapCommentsToReviewPayload(comments, files);
83
+ assert.strictEqual(result.length, 0);
84
+ });
85
+ it('handles multiple comments on same file', () => {
86
+ const comments = [
87
+ { file: 'src/App.tsx', line: 3, comment: 'Comment A' },
88
+ { file: 'src/App.tsx', line: 6, comment: 'Comment B' },
89
+ ];
90
+ const result = mapCommentsToReviewPayload(comments, files);
91
+ assert.strictEqual(result.length, 2);
92
+ assert.ok(result[0].body.includes('Comment A'));
93
+ assert.ok(result[1].body.includes('Comment B'));
94
+ });
95
+ it('handles files with no patch (binary files)', () => {
96
+ const binaryFiles = [
97
+ { filename: 'image.png' }, // no patch
98
+ { filename: 'src/App.tsx', patch: samplePatch },
99
+ ];
100
+ const comments = [
101
+ { file: 'image.png', line: 1, comment: 'Binary comment' },
102
+ { file: 'src/App.tsx', line: 3, comment: 'Code comment' },
103
+ ];
104
+ const result = mapCommentsToReviewPayload(comments, binaryFiles);
105
+ assert.strictEqual(result.length, 1);
106
+ assert.strictEqual(result[0].path, 'src/App.tsx');
107
+ });
108
+ });