Skip to content

Commit 231a623

Browse files
committed
feat(mcp): add review_pr MCP tool for EVAL-mode PR review
- Add ReviewPrHandler with review_pr tool accepting pr_number and optional issue_number - Add ReviewPrService orchestrating gh CLI, generate_checklist, and FileSpecialistMapper - Returns PR metadata, diff, checklists, recommended specialists, and review protocol - Register handler in McpModule and service in ShipModule Closes #1409
1 parent bea3d16 commit 231a623

7 files changed

Lines changed: 332 additions & 2 deletions

File tree

‎apps/mcp-server/src/mcp/handlers/index.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,12 @@ export { ResumeHandler } from './resume.handler';
162162
*/
163163
export { RuleImpactHandler } from './rule-impact.handler';
164164

165+
/**
166+
* Handler for PR review tools (review_pr)
167+
* @see {@link ReviewPrHandler}
168+
*/
169+
export { ReviewPrHandler } from './review-pr.handler';
170+
165171
/**
166172
* Injection token for the array of all tool handlers.
167173
*
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { describe, it, expect, beforeEach, vi } from 'vitest';
2+
import { ReviewPrHandler } from './review-pr.handler';
3+
import { ReviewPrService } from '../../ship/review-pr.service';
4+
import type { ReviewPrResult } from '../../ship/review-pr.types';
5+
6+
describe('ReviewPrHandler', () => {
7+
let handler: ReviewPrHandler;
8+
let mockService: { reviewPr: ReturnType<typeof vi.fn> };
9+
10+
const sampleResult: ReviewPrResult = {
11+
prMeta: {
12+
title: 'feat: add feature',
13+
branch: 'feat/add-feature-123',
14+
changedFiles: ['src/auth/login.ts', 'src/auth/login.spec.ts'],
15+
additions: 50,
16+
deletions: 10,
17+
},
18+
diff: 'diff --git a/src/auth/login.ts ...',
19+
checklists: [{ domain: 'security', items: ['Check auth'] }],
20+
recommendedSpecialists: ['security-specialist', 'code-quality-specialist'],
21+
acceptanceCriteria: '- [ ] Login works\n- [ ] Tests pass',
22+
reviewProtocol: 'Follow pr-review-cycle.md',
23+
duration: 150,
24+
};
25+
26+
beforeEach(() => {
27+
mockService = { reviewPr: vi.fn().mockResolvedValue(sampleResult) };
28+
handler = new ReviewPrHandler(mockService as unknown as ReviewPrService);
29+
});
30+
31+
it('should return null for unhandled tools', async () => {
32+
const result = await handler.handle('unknown_tool', {});
33+
expect(result).toBeNull();
34+
});
35+
36+
it('should handle review_pr with pr_number', async () => {
37+
const result = await handler.handle('review_pr', { pr_number: 1406 });
38+
expect(result).not.toBeNull();
39+
expect(result!.isError).toBeUndefined();
40+
const data = JSON.parse(result!.content[0].text);
41+
expect(data.prMeta.title).toBe('feat: add feature');
42+
expect(data.recommendedSpecialists).toContain('security-specialist');
43+
expect(mockService.reviewPr).toHaveBeenCalledWith({
44+
prNumber: 1406,
45+
issueNumber: undefined,
46+
timeout: 30000,
47+
});
48+
});
49+
50+
it('should pass issue_number when provided', async () => {
51+
await handler.handle('review_pr', { pr_number: 1406, issue_number: 1364 });
52+
expect(mockService.reviewPr).toHaveBeenCalledWith({
53+
prNumber: 1406,
54+
issueNumber: 1364,
55+
timeout: 30000,
56+
});
57+
});
58+
59+
it('should return error when pr_number is missing', async () => {
60+
const result = await handler.handle('review_pr', {});
61+
expect(result).not.toBeNull();
62+
expect(result!.isError).toBe(true);
63+
expect(result!.content[0].text).toContain('pr_number');
64+
});
65+
66+
it('should return error when pr_number is not a number', async () => {
67+
const result = await handler.handle('review_pr', { pr_number: 'abc' });
68+
expect(result).not.toBeNull();
69+
expect(result!.isError).toBe(true);
70+
});
71+
72+
it('should handle service errors gracefully', async () => {
73+
mockService.reviewPr.mockRejectedValue(new Error('gh command failed'));
74+
const result = await handler.handle('review_pr', { pr_number: 9999 });
75+
expect(result).not.toBeNull();
76+
expect(result!.isError).toBe(true);
77+
expect(result!.content[0].text).toContain('gh command failed');
78+
});
79+
80+
it('should expose tool definitions', () => {
81+
const defs = handler.getToolDefinitions();
82+
expect(defs).toHaveLength(1);
83+
expect(defs[0].name).toBe('review_pr');
84+
expect(defs[0].inputSchema.required).toContain('pr_number');
85+
});
86+
});
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { Injectable, Logger } from '@nestjs/common';
2+
import type { ToolDefinition } from './base.handler';
3+
import type { ToolResponse } from '../response.utils';
4+
import { AbstractHandler } from './abstract-handler';
5+
import { ReviewPrService } from '../../ship/review-pr.service';
6+
import { createJsonResponse, createErrorResponse } from '../response.utils';
7+
8+
@Injectable()
9+
export class ReviewPrHandler extends AbstractHandler {
10+
private readonly logger = new Logger(ReviewPrHandler.name);
11+
12+
constructor(private readonly reviewPrService: ReviewPrService) {
13+
super();
14+
}
15+
16+
protected getHandledTools(): string[] {
17+
return ['review_pr'];
18+
}
19+
20+
protected async handleTool(
21+
_toolName: string,
22+
args: Record<string, unknown> | undefined,
23+
): Promise<ToolResponse> {
24+
try {
25+
const prNumber = args?.pr_number;
26+
if (typeof prNumber !== 'number' || !Number.isFinite(prNumber) || prNumber <= 0) {
27+
return createErrorResponse('pr_number is required and must be a positive number');
28+
}
29+
30+
const issueNumber =
31+
typeof args?.issue_number === 'number' && args.issue_number > 0
32+
? args.issue_number
33+
: undefined;
34+
35+
const timeout = typeof args?.timeout === 'number' && args.timeout > 0 ? args.timeout : 30000;
36+
37+
const result = await this.reviewPrService.reviewPr({
38+
prNumber,
39+
issueNumber,
40+
timeout,
41+
});
42+
43+
return createJsonResponse(result);
44+
} catch (error) {
45+
this.logger.error(`review_pr failed: ${error}`);
46+
return createErrorResponse(
47+
`review_pr failed: ${error instanceof Error ? error.message : String(error)}`,
48+
);
49+
}
50+
}
51+
52+
getToolDefinitions(): ToolDefinition[] {
53+
return [
54+
{
55+
name: 'review_pr',
56+
description:
57+
'Fetch PR metadata, diff, checklists, and specialist recommendations for EVAL-mode PR review. Returns structured data for comprehensive code review.',
58+
inputSchema: {
59+
type: 'object',
60+
properties: {
61+
pr_number: {
62+
type: 'number',
63+
description: 'PR number to review',
64+
},
65+
issue_number: {
66+
type: 'number',
67+
description: 'Optional linked issue number for spec compliance check',
68+
},
69+
timeout: {
70+
type: 'number',
71+
description: 'Timeout in milliseconds (default: 30000)',
72+
},
73+
},
74+
required: ['pr_number'],
75+
},
76+
},
77+
];
78+
}
79+
}

‎apps/mcp-server/src/mcp/mcp.module.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import {
3939
PluginValidationHandler,
4040
ReleaseCheckHandler,
4141
QualityReportHandler,
42+
ReviewPrHandler,
4243
BriefingHandler,
4344
ResumeHandler,
4445
RuleImpactHandler,
@@ -63,6 +64,7 @@ const handlers = [
6364
PluginValidationHandler,
6465
ReleaseCheckHandler,
6566
QualityReportHandler,
67+
ReviewPrHandler,
6668
BriefingHandler,
6769
ResumeHandler,
6870
RuleImpactHandler,
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
import { Injectable, Logger } from '@nestjs/common';
2+
import { execSync } from 'child_process';
3+
import { FileSpecialistMapper } from './file-specialist-mapper';
4+
import { ChecklistService } from '../checklist/checklist.service';
5+
import type { ReviewPrInput, ReviewPrResult, PrMeta } from './review-pr.types';
6+
7+
@Injectable()
8+
export class ReviewPrService {
9+
private readonly logger = new Logger(ReviewPrService.name);
10+
private readonly mapper = new FileSpecialistMapper();
11+
12+
constructor(private readonly checklistService: ChecklistService) {}
13+
14+
async reviewPr(input: ReviewPrInput): Promise<ReviewPrResult> {
15+
const start = Date.now();
16+
const { prNumber, issueNumber } = input;
17+
18+
const prMeta = this.fetchPrMeta(prNumber);
19+
const diff = this.fetchPrDiff(prNumber);
20+
21+
const checklists = await this.generateChecklists(prMeta.changedFiles);
22+
const recommendedSpecialists = this.mapSpecialists(prMeta.changedFiles);
23+
const acceptanceCriteria = issueNumber ? this.fetchIssueCriteria(issueNumber) : null;
24+
25+
const reviewProtocol = this.buildReviewProtocol(prNumber, issueNumber);
26+
27+
return {
28+
prMeta,
29+
diff,
30+
checklists,
31+
recommendedSpecialists,
32+
acceptanceCriteria,
33+
reviewProtocol,
34+
duration: Date.now() - start,
35+
};
36+
}
37+
38+
private fetchPrMeta(prNumber: number): PrMeta {
39+
try {
40+
const raw = execSync(
41+
`gh pr view ${prNumber} --json title,headRefName,files,additions,deletions`,
42+
{ encoding: 'utf-8', timeout: 15000 },
43+
);
44+
const data = JSON.parse(raw);
45+
return {
46+
title: data.title ?? '',
47+
branch: data.headRefName ?? '',
48+
changedFiles: (data.files ?? []).map((f: { path: string }) => f.path),
49+
additions: data.additions ?? 0,
50+
deletions: data.deletions ?? 0,
51+
};
52+
} catch (error) {
53+
throw new Error(
54+
`Failed to fetch PR #${prNumber}: ${error instanceof Error ? error.message : String(error)}`,
55+
);
56+
}
57+
}
58+
59+
private fetchPrDiff(prNumber: number): string {
60+
try {
61+
return execSync(`gh pr diff ${prNumber}`, {
62+
encoding: 'utf-8',
63+
timeout: 15000,
64+
maxBuffer: 10 * 1024 * 1024,
65+
});
66+
} catch (error) {
67+
this.logger.warn(`Failed to fetch diff for PR #${prNumber}: ${error}`);
68+
return '';
69+
}
70+
}
71+
72+
private async generateChecklists(changedFiles: string[]): Promise<unknown[]> {
73+
if (changedFiles.length === 0) return [];
74+
try {
75+
const result = await this.checklistService.generateChecklist({
76+
files: changedFiles,
77+
});
78+
return result.checklists;
79+
} catch (error) {
80+
this.logger.warn(`Checklist generation failed: ${error}`);
81+
return [];
82+
}
83+
}
84+
85+
private mapSpecialists(changedFiles: string[]): string[] {
86+
const mapped = this.mapper.mapFiles(changedFiles);
87+
const specialists = [...new Set(mapped.map(m => m.specialist))];
88+
return specialists;
89+
}
90+
91+
private fetchIssueCriteria(issueNumber: number): string | null {
92+
try {
93+
const raw = execSync(`gh issue view ${issueNumber} --json body`, {
94+
encoding: 'utf-8',
95+
timeout: 10000,
96+
});
97+
const data = JSON.parse(raw);
98+
const body: string = data.body ?? '';
99+
100+
const criteriaMatch = body.match(/## Acceptance criteria\s*\n([\s\S]*?)(?=\n## |\n---|$)/i);
101+
return criteriaMatch ? criteriaMatch[1].trim() : body.slice(0, 2000);
102+
} catch {
103+
return null;
104+
}
105+
}
106+
107+
private buildReviewProtocol(prNumber: number, issueNumber?: number): string {
108+
const issueCmd = issueNumber ? `\ngh issue view ${issueNumber}` : '';
109+
return [
110+
'Follow the canonical PR review cycle (pr-review-cycle.md):',
111+
'',
112+
'1. CI GATE (BLOCKING): gh pr checks ' + prNumber,
113+
'2. LOCAL VERIFICATION: yarn lint && yarn type-check',
114+
'3. READ THE DIFF: gh pr diff ' + prNumber,
115+
'4. CODE QUALITY SCAN: unused imports, any types, missing error handling, dead code',
116+
'5. SPEC COMPLIANCE: ' + (issueNumber ? `gh issue view ${issueNumber}` : 'N/A'),
117+
'6. TEST COVERAGE: new logic tested? edge cases covered?',
118+
'7. WRITE REVIEW: gh pr review ' + prNumber + ' --comment --body "<structured review>"',
119+
'',
120+
'Severity scale: critical / high / medium / low (see severity-classification.md)',
121+
'Approval gate: Critical=0 AND High=0',
122+
'',
123+
'Commands:',
124+
`gh pr checks ${prNumber}`,
125+
`gh pr diff ${prNumber}`,
126+
issueCmd,
127+
]
128+
.filter(Boolean)
129+
.join('\n');
130+
}
131+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
export interface ReviewPrInput {
2+
prNumber: number;
3+
issueNumber?: number;
4+
timeout?: number;
5+
}
6+
7+
export interface PrMeta {
8+
title: string;
9+
branch: string;
10+
changedFiles: string[];
11+
additions: number;
12+
deletions: number;
13+
}
14+
15+
export interface ReviewPrResult {
16+
prMeta: PrMeta;
17+
diff: string;
18+
checklists: unknown[];
19+
recommendedSpecialists: string[];
20+
acceptanceCriteria: string | null;
21+
reviewProtocol: string;
22+
duration: number;
23+
}
Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
import { Module } from '@nestjs/common';
2+
import { ChecklistModule } from '../checklist/checklist.module';
23
import { QualityReportService } from './quality-report.service';
4+
import { ReviewPrService } from './review-pr.service';
35

46
@Module({
5-
providers: [QualityReportService],
6-
exports: [QualityReportService],
7+
imports: [ChecklistModule],
8+
providers: [QualityReportService, ReviewPrService],
9+
exports: [QualityReportService, ReviewPrService],
710
})
811
export class ShipModule {}

0 commit comments

Comments
 (0)