Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions src/lib/VersionControlProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,18 @@ export interface ExistingPR {
url: string
}

/**
* Represents a review submission on a pull request (the body text submitted via "Submit review")
*/
export interface ReviewSubmission {
id: string
body: string
state: string // APPROVED, CHANGES_REQUESTED, COMMENTED, DISMISSED, PENDING (GitHub); similar for other providers
author: { id: string; displayName: string } | null
submittedAt: string
htmlUrl: string
}

/**
* Represents an inline review comment on a pull request
*/
Expand Down Expand Up @@ -76,6 +88,7 @@ export interface VersionControlProvider {
createPRComment(prNumber: number, body: string, cwd?: string): Promise<{ id: string; url: string }>
updatePRComment?(prNumber: number, commentId: string, body: string, cwd?: string): Promise<{ id: string; url: string }>
getReviewComments?(prNumber: number, cwd?: string): Promise<ReviewComment[]>
getReviewSubmissions?(prNumber: number, cwd?: string): Promise<ReviewSubmission[]>
createReviewComment?(prNumber: number, path: string, line: number, body: string, cwd?: string): Promise<{ id: string; url: string }>

// Remote and repository detection
Expand Down
7 changes: 6 additions & 1 deletion src/lib/providers/bitbucket/BitBucketVCSProvider.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// BitBucketVCSProvider - Implements VersionControlProvider for BitBucket
// Provides PR/VCS operations via BitBucket REST API

import type { VersionControlProvider, ExistingPR, PRCreationResult, ReviewComment } from '../../VersionControlProvider.js'
import type { VersionControlProvider, ExistingPR, PRCreationResult, ReviewComment, ReviewSubmission } from '../../VersionControlProvider.js'
import type { PullRequest } from '../../../types/index.js'
import { BitBucketApiClient, type BitBucketConfig, type BitBucketPullRequest } from './BitBucketApiClient.js'
import type { IloomSettings } from '../../SettingsManager.js'
Expand Down Expand Up @@ -244,6 +244,11 @@ export class BitBucketVCSProvider implements VersionControlProvider {
return inlineComments
}

// BitBucket has no "Submit review" flow — approvals are a simple toggle with no body text.
async getReviewSubmissions(_prNumber: number, _cwd?: string): Promise<ReviewSubmission[]> {
return []
}

/**
* Create an inline review comment on a specific file and line in a PR
*/
Expand Down
2 changes: 1 addition & 1 deletion src/mcp/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ This directory contains MCP (Model Context Protocol) servers that expose tools t

Exposes issue/PR operations to agents. Provider-agnostic — routes to GitHub, Linear, or Jira based on `ISSUE_PROVIDER` env var.

**Tools (27):** `get_issue`, `get_pr`, `get_review_comments`, `get_comment`, `create_comment`, `update_comment`, `create_issue`, `create_child_issue`, `create_dependency`, `get_dependencies`, `remove_dependency`, `get_child_issues`, `close_issue`, `reopen_issue`, `edit_issue`, and more.
**Tools (28):** `get_issue`, `get_pr`, `get_review_comments`, `get_reviews`, `get_comment`, `create_comment`, `update_comment`, `create_issue`, `create_child_issue`, `create_dependency`, `get_dependencies`, `remove_dependency`, `get_child_issues`, `close_issue`, `reopen_issue`, `edit_issue`, and more.

**Used by:** All execution contexts (regular, swarm child, swarm orchestrator).

Expand Down
123 changes: 121 additions & 2 deletions src/mcp/GitHubIssueManagementProvider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -836,7 +836,7 @@ describe('GitHubIssueManagementProvider', () => {
createdAt: '2025-01-01T00:00:00Z',
updatedAt: '2025-01-01T01:00:00Z',
inReplyToId: null,
pullRequestReviewId: 5000,
pullRequestReviewId: '5000',
})
expect(result[1].id).toBe('1002')
expect(result[1].path).toBe('src/bar.ts')
Expand Down Expand Up @@ -876,7 +876,7 @@ describe('GitHubIssueManagementProvider', () => {

expect(result).toHaveLength(1)
expect(result[0].id).toBe('2001')
expect(result[0].pullRequestReviewId).toBe(100)
expect(result[0].pullRequestReviewId).toBe('100')
})

it('handles empty review comments', async () => {
Expand Down Expand Up @@ -962,6 +962,125 @@ describe('GitHubIssueManagementProvider', () => {
})
})

describe('getReviewSubmissions', () => {
it('returns review submissions with state, body, and author', async () => {
const mockResponse = [
{
id: 9001,
body: 'LGTM — dismissing the CodeRabbit finding as a false positive',
state: 'APPROVED',
user: { login: 'reviewer1' },
submitted_at: '2025-01-01T00:00:00Z',
html_url: 'https://github.com/owner/repo/pull/42#pullrequestreview-9001',
},
{
id: 9002,
body: 'Please fix the null check issue',
state: 'CHANGES_REQUESTED',
user: { login: 'reviewer2' },
submitted_at: '2025-01-02T00:00:00Z',
html_url: 'https://github.com/owner/repo/pull/42#pullrequestreview-9002',
},
]

vi.mocked(executeGhCommand).mockResolvedValueOnce(mockResponse)

const result = await provider.getReviewSubmissions({ number: '42' })

expect(result).toHaveLength(2)
expect(result[0]).toEqual({
id: '9001',
body: 'LGTM — dismissing the CodeRabbit finding as a false positive',
state: 'APPROVED',
author: { id: 'reviewer1', displayName: 'reviewer1', login: 'reviewer1' },
submittedAt: '2025-01-01T00:00:00Z',
htmlUrl: 'https://github.com/owner/repo/pull/42#pullrequestreview-9001',
})
expect(result[1].state).toBe('CHANGES_REQUESTED')
})

it('handles reviews with empty body', async () => {
const mockResponse = [
{
id: 9003,
body: '',
state: 'APPROVED',
user: { login: 'reviewer' },
submitted_at: '2025-01-01T00:00:00Z',
html_url: 'https://github.com/owner/repo/pull/42#pullrequestreview-9003',
},
]

vi.mocked(executeGhCommand).mockResolvedValueOnce(mockResponse)

const result = await provider.getReviewSubmissions({ number: '42' })

expect(result).toHaveLength(1)
expect(result[0].body).toBe('')
})

it('handles empty reviews list', async () => {
vi.mocked(executeGhCommand).mockResolvedValueOnce([])

const result = await provider.getReviewSubmissions({ number: '42' })

expect(result).toEqual([])
})

it('passes repo to API path when provided', async () => {
vi.mocked(executeGhCommand).mockResolvedValueOnce([])

await provider.getReviewSubmissions({ number: '42', repo: 'other-owner/other-repo' })

expect(executeGhCommand).toHaveBeenCalledWith([
'api',
'repos/other-owner/other-repo/pulls/42/reviews',
'--paginate',
'--jq',
expect.any(String),
])
})

it('uses :owner/:repo placeholder when repo is not provided', async () => {
vi.mocked(executeGhCommand).mockResolvedValueOnce([])

await provider.getReviewSubmissions({ number: '42' })

expect(executeGhCommand).toHaveBeenCalledWith([
'api',
'repos/:owner/:repo/pulls/42/reviews',
'--paginate',
'--jq',
expect.any(String),
])
})

it('throws error for non-numeric PR number', async () => {
await expect(provider.getReviewSubmissions({ number: 'not-a-number' })).rejects.toThrow(
'Invalid GitHub PR number: not-a-number. GitHub PR IDs must be numeric.'
)
})

it('handles reviews with null user', async () => {
const mockResponse = [
{
id: 9004,
body: 'Automated review',
state: 'COMMENTED',
user: null,
submitted_at: '2025-01-01T00:00:00Z',
html_url: 'https://github.com/owner/repo/pull/42#pullrequestreview-9004',
},
]

vi.mocked(executeGhCommand).mockResolvedValueOnce(mockResponse)

const result = await provider.getReviewSubmissions({ number: '42' })

expect(result[0].author).toBeNull()
})
})

describe('createDependency', () => {
it('should create dependency between two issues', async () => {
vi.mocked(getIssueDatabaseId).mockResolvedValueOnce(123456789)
Expand Down
55 changes: 54 additions & 1 deletion src/mcp/GitHubIssueManagementProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type {
GetIssueInput,
GetPRInput,
GetReviewCommentsInput,
GetReviewsInput,
GetCommentInput,
CreateCommentInput,
UpdateCommentInput,
Expand All @@ -25,6 +26,7 @@ import type {
IssueResult,
PRResult,
ReviewCommentResult,
ReviewSubmissionResult,
CommentDetailResult,
CommentResult,
DependenciesResult,
Expand Down Expand Up @@ -433,7 +435,58 @@ export class GitHubIssueManagementProvider implements IssueManagementProvider {
createdAt: comment.created_at,
updatedAt: comment.updated_at ?? null,
inReplyToId: comment.in_reply_to_id ? String(comment.in_reply_to_id) : null,
pullRequestReviewId: comment.pull_request_review_id,
pullRequestReviewId: comment.pull_request_review_id != null ? String(comment.pull_request_review_id) : null,
})
}

return results
}

/**
* Fetch PR review submissions (the body text submitted via "Submit review")
* Uses gh api with --paginate to handle PRs with many reviews
*/
async getReviewSubmissions(input: GetReviewsInput): Promise<ReviewSubmissionResult[]> {
const { number, repo } = input

const prNumber = parseInt(number, 10)
if (isNaN(prNumber)) {
throw new Error(`Invalid GitHub PR number: ${number}. GitHub PR IDs must be numeric.`)
}

interface GitHubReview {
id: number
body: string
state: string
user: GitHubAuthor | null
submitted_at: string
html_url: string
}

const apiPath = repo
? `repos/${repo}/pulls/${prNumber}/reviews`
: `repos/:owner/:repo/pulls/${prNumber}/reviews`

const args = [
'api',
apiPath,
'--paginate',
'--jq',
'[.[] | {id: .id, body: .body, state: .state, user: .user, submitted_at: .submitted_at, html_url: .html_url}]',
]

const raw = await executeGhCommand<GitHubReview[]>(args)

const results: ReviewSubmissionResult[] = []
for (const review of raw) {
const processedBody = review.body ? await processMarkdownImages(review.body, 'github') : ''
results.push({
id: String(review.id),
body: processedBody,
state: review.state,
author: normalizeAuthor(review.user),
submittedAt: review.submitted_at,
htmlUrl: review.html_url,
})
}

Expand Down
99 changes: 98 additions & 1 deletion src/mcp/issue-management-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import type {
ReopenIssueInput,
EditIssueInput,
GetReviewCommentsInput,
GetReviewsInput,
} from './types.js'

// Module-level settings loaded at startup
Expand Down Expand Up @@ -360,7 +361,7 @@ server.registerTool(
createdAt: z.string().describe('Comment creation timestamp'),
updatedAt: z.string().nullable().describe('Comment last updated timestamp'),
inReplyToId: z.string().nullable().describe('ID of the comment this replies to'),
pullRequestReviewId: z.number().nullable().describe('The review this comment belongs to'),
pullRequestReviewId: z.string().nullable().describe('The review submission ID this comment belongs to (matches get_reviews id)'),
})
).describe('Inline review comments on the PR'),
},
Expand Down Expand Up @@ -427,6 +428,102 @@ server.registerTool(
}
)

// Register get_reviews tool (review submissions — the body text submitted via "Submit review")
server.registerTool(
'get_reviews',
{
title: 'Get PR Review Submissions',
description:
'Fetch review submissions on a pull request (the body text and verdict submitted via the "Submit review" flow). ' +
'Returns reviews with state (APPROVED, CHANGES_REQUESTED, COMMENTED, DISMISSED), body text, author, and timestamp. ' +
'These are distinct from inline review comments (use get_review_comments for those). ' +
'Uses the configured VCS provider when available, falls back to GitHub.',
inputSchema: {
number: z.string().describe('The PR number'),
repo: z
.string()
.optional()
.describe(
'Optional repository in "owner/repo" format or full GitHub URL. ' +
'When not provided, uses the current repository.'
),
},
outputSchema: {
reviews: z.array(
z.object({
id: z.string().describe('Review submission ID'),
body: z.string().describe('Review body text'),
state: z.string().describe('Review state: APPROVED, CHANGES_REQUESTED, COMMENTED, DISMISSED, PENDING'),
author: flexibleAuthorSchema.nullable().describe('Review author'),
submittedAt: z.string().describe('Review submission timestamp'),
htmlUrl: z.string().describe('URL to the review on the provider'),
})
).describe('Review submissions on the PR'),
},
},
async ({ number, repo }: GetReviewsInput) => {
console.error(`Fetching review submissions for PR ${number}${repo ? ` from ${repo}` : ''}`)

const prNumber = parseInt(number, 10)
if (isNaN(prNumber)) {
throw new Error(`Invalid PR number: ${number}. PR numbers must be numeric.`)
}

try {
if (vcsProvider?.getReviewSubmissions) {
if (repo) {
console.error(`VCS provider path does not support 'repo' override parameter, using configured repository`)
}
const reviewSubmissions = await vcsProvider.getReviewSubmissions(prNumber)

const reviews = reviewSubmissions.map(r => ({
id: r.id,
body: r.body,
state: r.state,
author: r.author ? { id: r.author.id, displayName: r.author.displayName } : null,
submittedAt: r.submittedAt,
htmlUrl: r.htmlUrl,
}))

console.error(`Review submissions fetched successfully: ${reviews.length} reviews`)

const result = { reviews }
return {
content: [
{
type: 'text' as const,
text: JSON.stringify(result),
},
],
structuredContent: result as unknown as { [x: string]: unknown },
}
}

// Fall back to GitHub provider
const provider = new GitHubIssueManagementProvider()
const reviews = await provider.getReviewSubmissions({ number, repo })

console.error(`Review submissions fetched successfully: ${reviews.length} reviews`)

const result = { reviews }
return {
content: [
{
type: 'text' as const,
text: JSON.stringify(result),
},
],
structuredContent: result as unknown as { [x: string]: unknown },
}
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : 'Unknown error'
console.error(`Failed to fetch review submissions: ${errorMessage}`)
throw new Error(`Failed to fetch review submissions: ${errorMessage}`)
}
}
)

// Register get_comment tool
server.registerTool(
'get_comment',
Expand Down
Loading
Loading