Skip to content

Commit aeffbf6

Browse files
committed
refactor comments into single GH review
1 parent 7a336a0 commit aeffbf6

File tree

8 files changed

+216
-120
lines changed

8 files changed

+216
-120
lines changed

FILE_BASED_COMMENTS.md

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
# File-Based Comment Collection System
2+
3+
## Overview
4+
This implementation uses file-based inter-process communication to collect comments from MCP tools and create a single GitHub review.
5+
6+
## Architecture
7+
8+
### 1. Comment Collection (MCP Server)
9+
- **File**: `src/mcp/comment-collector.ts`
10+
- **Purpose**: Handles JSONL append-only writes to avoid concurrency issues
11+
- **Format**: Each line is a JSON object with `type`, `message`, and optional `path`/`line` for inline comments
12+
13+
### 2. Filename Generation (Main Server)
14+
- **Location**: `src/review/reviewer.ts`
15+
- **Format**: `comments-${installationId}-${prNumber}-${uuid}.jsonl`
16+
- **Passed via**: `COMMENTS_FILE` environment variable to MCP server
17+
18+
### 3. MCP Tools (Collection Mode)
19+
- **leave_general_comment**: Appends `{"type":"general","message":"..."}`
20+
- **leave_inline_comment**: Appends `{"type":"inline","message":"...","path":"...","line":123}`
21+
- **Fallback**: If no collector available, logs warning but continues
22+
23+
### 4. Review Creation (Main Server)
24+
- **Location**: `src/github/process-review.ts`
25+
- **Process**:
26+
1. Reads JSONL file after Amp CLI completion
27+
2. Parses comment types and aggregates
28+
3. Creates single GitHub review with all comments
29+
4. Cleans up temporary file
30+
31+
## Data Flow
32+
33+
```
34+
1. PR Event → Queue Job
35+
2. Generate filename: comments-${installationId}-${prNumber}-${uuid}.jsonl
36+
3. Pass filename via COMMENTS_FILE env var to MCP server
37+
4. Amp CLI executes → MCP tools append to file
38+
5. Main server reads file → Creates GitHub review
39+
6. Clean up temp file
40+
```
41+
42+
## File Format (JSONL)
43+
44+
```jsonl
45+
{"type":"inline","message":"Fix this bug","path":"src/auth.js","line":25}
46+
{"type":"inline","message":"Add error handling","path":"src/api.js","line":10}
47+
{"type":"general","message":"Overall looks good, just minor issues above"}
48+
```
49+
50+
## Concurrency Safety
51+
- **appendFileSync()**: Atomic at OS level for small writes
52+
- **JSONL format**: No read-modify-write cycles
53+
- **Unique filenames**: Prevent collisions across jobs
54+
55+
## Benefits
56+
- ✅ Simple file-based IPC
57+
- ✅ No complex env var passing
58+
- ✅ Atomic review creation
59+
- ✅ Robust error handling with cleanup
60+
- ✅ Maintains tool abstractions
61+
- ✅ Enables "Request re-review" button
62+
63+
## Error Handling
64+
- **MCP server crash**: File remains, main server handles gracefully
65+
- **File corruption**: JSON parsing errors logged, review skipped
66+
- **Missing file**: Logged and skipped (no comments case)
67+
- **Cleanup failures**: Logged but don't fail the job

src/github/process-review.ts

Lines changed: 52 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import { getConfig } from "../config.js";
22
import { reviewDiff } from "../review/reviewer.js";
33
import { GitHubClient } from "./client.js";
44
import { CHECK_STATUS, CHECK_CONCLUSION, PRDetails, GitHubPullRequestEvent } from "./types.js";
5-
import { startReviewSession, endReviewSession } from "../review/comment-collector.js";
65

76
export async function processReview(
87
jobId: string,
@@ -62,36 +61,61 @@ export async function processReview(
6261

6362
const prDetailsContent = `Repository: ${payload.repository.full_name}, PR Number: ${prDetails.pr_number}, Commit SHA: ${prDetails.commit_sha}, PR URL: ${prDetails.pr_url}`;
6463

65-
console.log(`Starting review session for job ${jobId}`);
66-
startReviewSession();
67-
6864
console.log(`Calling reviewDiff() for job ${jobId}`);
69-
await reviewDiff(diffContent, prDetailsContent, installationId);
65+
const reviewResult = await reviewDiff(diffContent, prDetailsContent, installationId);
7066
console.log(`Review completed for job ${jobId}`);
7167

72-
// Collect all comments from the review session
73-
const collector = endReviewSession();
74-
console.log(`Collected ${collector.getInlineComments().length} inline comments and ${collector.getGeneralComments().length} general comments`);
75-
76-
// Post aggregated review if there are any comments
77-
if (collector.hasComments()) {
78-
console.log('📋 Posting aggregated PR review...');
79-
const reviewSummary = collector.getReviewSummary();
80-
const inlineComments = collector.getInlineComments();
81-
82-
await githubClient.createPRReview(
83-
owner,
84-
repo,
85-
prNumber,
86-
reviewSummary,
87-
'COMMENT',
88-
inlineComments.map(comment => ({
89-
path: comment.path,
90-
line: comment.line,
91-
body: comment.body
92-
}))
93-
);
94-
console.log('✅ PR review posted successfully');
68+
// Read collected comments from file
69+
const fs = await import('fs');
70+
const commentsFilePath = reviewResult.commentsFilePath;
71+
72+
if (fs.existsSync(commentsFilePath)) {
73+
try {
74+
console.log(`📖 Reading collected comments from ${commentsFilePath}`);
75+
const fileContent = fs.readFileSync(commentsFilePath, 'utf8').trim();
76+
77+
if (fileContent) {
78+
const commentLines = fileContent.split('\n');
79+
const comments = commentLines.map(line => JSON.parse(line));
80+
81+
const inlineComments = comments.filter(c => c.type === 'inline');
82+
const generalComments = comments.filter(c => c.type === 'general');
83+
84+
console.log(`📝 Collected ${inlineComments.length} inline comments and ${generalComments.length} general comments`);
85+
86+
// Create review summary from general comments
87+
const reviewSummary = generalComments.length > 0
88+
? generalComments.map(c => c.message).join('\n\n')
89+
: 'Code review completed.';
90+
91+
// Post aggregated review
92+
console.log('📋 Posting aggregated PR review...');
93+
await githubClient.createPRReview(
94+
owner,
95+
repo,
96+
prNumber,
97+
reviewSummary,
98+
'COMMENT',
99+
inlineComments.map(comment => ({
100+
path: comment.path,
101+
line: comment.line,
102+
body: comment.message
103+
}))
104+
);
105+
console.log('✅ PR review posted successfully');
106+
} else {
107+
console.log('📝 No comments collected, skipping review creation');
108+
}
109+
110+
// Clean up the comments file
111+
fs.unlinkSync(commentsFilePath);
112+
console.log('🗑️ Cleaned up comments file');
113+
114+
} catch (error) {
115+
console.error('❌ Failed to read or process comments file:', error);
116+
}
117+
} else {
118+
console.log('📝 No comments file found, skipping review creation');
95119
}
96120

97121
// Update check run with success (simplified since review details are now in PR review)
@@ -108,13 +132,6 @@ export async function processReview(
108132
} catch (error) {
109133
console.error(`Review job ${jobId} failed with exception:`, error);
110134

111-
// End review session if it was started
112-
try {
113-
endReviewSession();
114-
} catch {
115-
console.log('No active review session to end');
116-
}
117-
118135
// Post FAILED check run for exceptions
119136
await postFailureCheckRun(githubClient, payload, error instanceof Error ? error.message : String(error));
120137
}

src/mcp/comment-collector.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import { writeFileSync, appendFileSync } from 'fs';
2+
3+
export interface CommentData {
4+
type: 'inline' | 'general';
5+
message: string;
6+
path?: string; // Only for inline comments
7+
line?: number; // Only for inline comments
8+
}
9+
10+
export class FileBasedCommentCollector {
11+
private filePath: string;
12+
13+
constructor(filePath: string) {
14+
this.filePath = filePath;
15+
// Initialize empty file
16+
writeFileSync(this.filePath, '', 'utf8');
17+
}
18+
19+
addInlineComment(path: string, line: number, message: string): void {
20+
const comment: CommentData = {
21+
type: 'inline',
22+
message,
23+
path,
24+
line
25+
};
26+
27+
this.appendComment(comment);
28+
}
29+
30+
addGeneralComment(message: string): void {
31+
const comment: CommentData = {
32+
type: 'general',
33+
message
34+
};
35+
36+
this.appendComment(comment);
37+
}
38+
39+
private appendComment(comment: CommentData): void {
40+
const jsonLine = JSON.stringify(comment) + '\n';
41+
appendFileSync(this.filePath, jsonLine, 'utf8');
42+
}
43+
}
44+
45+
// Global collector instance for the MCP server
46+
let globalCollector: FileBasedCommentCollector | null = null;
47+
48+
export function initializeCollector(): FileBasedCommentCollector | null {
49+
const commentsFile = process.env.COMMENTS_FILE;
50+
51+
if (!commentsFile) {
52+
console.log('📝 No COMMENTS_FILE environment variable, collector not initialized');
53+
return null;
54+
}
55+
56+
try {
57+
globalCollector = new FileBasedCommentCollector(commentsFile);
58+
console.log('📝 Comment collector initialized with file:', commentsFile);
59+
return globalCollector;
60+
} catch (error) {
61+
console.error('❌ Failed to initialize comment collector:', error);
62+
return null;
63+
}
64+
}
65+
66+
export function getCollector(): FileBasedCommentCollector | null {
67+
return globalCollector;
68+
}

src/mcp/server.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
validateLeaveInlineCommentArgs,
2020
validateGetPRCommentsArgs
2121
} from './validation.js';
22+
import { initializeCollector } from './comment-collector.js';
2223

2324
class GitHubMCPServer {
2425
private server: Server;
@@ -39,6 +40,7 @@ class GitHubMCPServer {
3940
);
4041

4142
this.config = getConfig();
43+
initializeCollector(); // Initialize comment collector
4244
this.setupToolHandlers();
4345
}
4446

src/mcp/tools/leave_comment.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { getCurrentCollector } from '../../review/comment-collector.js';
1+
import { getCollector } from '../comment-collector.js';
22

33
export interface LeaveGeneralCommentArgs {
44
message: string;
@@ -15,8 +15,12 @@ export async function leaveGeneralComment(
1515

1616
console.log('📝 Collecting general comment for later review');
1717

18-
const collector = getCurrentCollector();
19-
collector.addGeneralComment(message);
18+
const collector = getCollector();
19+
if (collector) {
20+
collector.addGeneralComment(message);
21+
} else {
22+
console.log('⚠️ No collector available, comment will be skipped');
23+
}
2024

2125
return {
2226
success: true

src/mcp/tools/leave_inline_comment.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { getCurrentCollector } from '../../review/comment-collector.js';
1+
import { getCollector } from '../comment-collector.js';
22

33
export interface LeaveInlineCommentArgs {
44
message: string;
@@ -18,8 +18,12 @@ export async function leaveInlineComment(
1818

1919
console.log('📝 Collecting inline comment for later review:', { path, line });
2020

21-
const collector = getCurrentCollector();
22-
collector.addInlineComment(path, line, message);
21+
const collector = getCollector();
22+
if (collector) {
23+
collector.addInlineComment(path, line, message);
24+
} else {
25+
console.log('⚠️ No collector available, comment will be skipped');
26+
}
2327

2428
return {
2529
success: true

src/review/comment-collector.ts

Lines changed: 0 additions & 75 deletions
This file was deleted.

0 commit comments

Comments
 (0)