Skip to content

Commit 74526ed

Browse files
plaskowskiPiotr Laskowski
andauthored
Add get_pull_request_threads tool for retrieving PR review threads with comment IDs (#3)
* Implement get_pull_request_threads tool - Add GetPullRequestThreads function using GraphQL API - Retrieves review threads/discussions for pull requests - Includes all comments in each thread with their IDs - Supports filtering for unresolved threads only via unresolvedOnly parameter - Returns comprehensive thread data: resolution status, file paths, line numbers, comment metadata - Add comprehensive test coverage with GraphQL mocking - Register tool in pull_requests toolset as read-only tool - Auto-generated toolsnap file for schema validation Closes: Request for pull request threads tool with comment IDs * Fix DatabaseID type issue in get_pull_request_threads - Change DatabaseID field from *githubv4.Int to *int64 - GitHub database IDs can exceed int32 range (e.g., 2220472109) - Fixes GraphQL unmarshaling error when retrieving PR threads - Tool now works correctly with real PR data Tested with: plaskowski/dancer-diary-app-cursor/pull/24 --------- Co-authored-by: Piotr Laskowski <plaskowski@box.com>
1 parent 5f5d853 commit 74526ed

4 files changed

Lines changed: 522 additions & 0 deletions

File tree

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
{
2+
"annotations": {
3+
"title": "Get pull request review threads",
4+
"readOnlyHint": true
5+
},
6+
"description": "Get review threads (discussions) for a specific pull request, including all comments in each thread with their IDs.",
7+
"inputSchema": {
8+
"properties": {
9+
"owner": {
10+
"description": "Repository owner",
11+
"type": "string"
12+
},
13+
"pullNumber": {
14+
"description": "Pull request number",
15+
"type": "number"
16+
},
17+
"repo": {
18+
"description": "Repository name",
19+
"type": "string"
20+
},
21+
"unresolvedOnly": {
22+
"description": "If true, only return unresolved threads",
23+
"type": "boolean"
24+
}
25+
},
26+
"required": [
27+
"owner",
28+
"repo",
29+
"pullNumber"
30+
],
31+
"type": "object"
32+
},
33+
"name": "get_pull_request_threads"
34+
}

pkg/github/pullrequests.go

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1818,3 +1818,122 @@ func newGQLIntPtr(i *int32) *githubv4.Int {
18181818
gi := githubv4.Int(*i)
18191819
return &gi
18201820
}
1821+
1822+
// GetPullRequestThreads creates a tool to get review threads (discussions) for a pull request.
1823+
func GetPullRequestThreads(getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc) (mcp.Tool, server.ToolHandlerFunc) {
1824+
return mcp.NewTool("get_pull_request_threads",
1825+
mcp.WithDescription(t("TOOL_GET_PULL_REQUEST_THREADS_DESCRIPTION", "Get review threads (discussions) for a specific pull request, including all comments in each thread with their IDs.")),
1826+
mcp.WithToolAnnotation(mcp.ToolAnnotation{
1827+
Title: t("TOOL_GET_PULL_REQUEST_THREADS_USER_TITLE", "Get pull request review threads"),
1828+
ReadOnlyHint: ToBoolPtr(true),
1829+
}),
1830+
mcp.WithString("owner",
1831+
mcp.Required(),
1832+
mcp.Description("Repository owner"),
1833+
),
1834+
mcp.WithString("repo",
1835+
mcp.Required(),
1836+
mcp.Description("Repository name"),
1837+
),
1838+
mcp.WithNumber("pullNumber",
1839+
mcp.Required(),
1840+
mcp.Description("Pull request number"),
1841+
),
1842+
mcp.WithBoolean("unresolvedOnly",
1843+
mcp.Description("If true, only return unresolved threads"),
1844+
),
1845+
),
1846+
func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
1847+
var params struct {
1848+
Owner string
1849+
Repo string
1850+
PullNumber int32
1851+
UnresolvedOnly *bool
1852+
}
1853+
if err := mapstructure.Decode(request.Params.Arguments, &params); err != nil {
1854+
return mcp.NewToolResultError(err.Error()), nil
1855+
}
1856+
1857+
client, err := getGQLClient(ctx)
1858+
if err != nil {
1859+
return nil, fmt.Errorf("failed to get GitHub GQL client: %w", err)
1860+
}
1861+
1862+
var getPullRequestThreadsQuery struct {
1863+
Repository struct {
1864+
PullRequest struct {
1865+
ReviewThreads struct {
1866+
Nodes []struct {
1867+
ID githubv4.ID `json:"id"`
1868+
IsResolved githubv4.Boolean `json:"isResolved"`
1869+
IsOutdated githubv4.Boolean `json:"isOutdated"`
1870+
Line *githubv4.Int `json:"line"`
1871+
OriginalLine *githubv4.Int `json:"originalLine"`
1872+
StartLine *githubv4.Int `json:"startLine"`
1873+
OriginalStartLine *githubv4.Int `json:"originalStartLine"`
1874+
DiffSide githubv4.DiffSide `json:"diffSide"`
1875+
StartDiffSide *githubv4.DiffSide `json:"startDiffSide"`
1876+
Path githubv4.String `json:"path"`
1877+
SubjectType githubv4.PullRequestReviewThreadSubjectType `json:"subjectType"`
1878+
Comments struct {
1879+
Nodes []struct {
1880+
ID githubv4.ID `json:"id"`
1881+
Body githubv4.String `json:"body"`
1882+
CreatedAt githubv4.DateTime `json:"createdAt"`
1883+
UpdatedAt githubv4.DateTime `json:"updatedAt"`
1884+
MinimizedReason *githubv4.String `json:"minimizedReason"`
1885+
IsMinimized githubv4.Boolean `json:"isMinimized"`
1886+
Author struct {
1887+
Login githubv4.String `json:"login"`
1888+
} `json:"author"`
1889+
AuthorAssociation githubv4.CommentAuthorAssociation `json:"authorAssociation"`
1890+
URL githubv4.URI `json:"url"`
1891+
DatabaseID *int64 `json:"databaseId"`
1892+
} `json:"comments"`
1893+
} `graphql:"comments(first: 100)" json:"comments"`
1894+
} `json:"reviewThreads"`
1895+
} `graphql:"reviewThreads(first: 100)" json:"reviewThreads"`
1896+
} `graphql:"pullRequest(number: $prNum)" json:"pullRequest"`
1897+
} `graphql:"repository(owner: $owner, name: $repo)" json:"repository"`
1898+
}
1899+
1900+
vars := map[string]any{
1901+
"owner": githubv4.String(params.Owner),
1902+
"repo": githubv4.String(params.Repo),
1903+
"prNum": githubv4.Int(params.PullNumber),
1904+
}
1905+
1906+
if err := client.Query(ctx, &getPullRequestThreadsQuery, vars); err != nil {
1907+
return ghErrors.NewGitHubGraphQLErrorResponse(ctx,
1908+
"failed to get pull request threads",
1909+
err,
1910+
), nil
1911+
}
1912+
1913+
threads := getPullRequestThreadsQuery.Repository.PullRequest.ReviewThreads.Nodes
1914+
1915+
// Filter for unresolved threads if requested
1916+
if params.UnresolvedOnly != nil && *params.UnresolvedOnly {
1917+
var unresolvedThreads []interface{}
1918+
1919+
for _, thread := range threads {
1920+
if !thread.IsResolved {
1921+
unresolvedThreads = append(unresolvedThreads, thread)
1922+
}
1923+
}
1924+
1925+
r, err := json.Marshal(unresolvedThreads)
1926+
if err != nil {
1927+
return nil, fmt.Errorf("failed to marshal response: %w", err)
1928+
}
1929+
return mcp.NewToolResultText(string(r)), nil
1930+
}
1931+
1932+
r, err := json.Marshal(threads)
1933+
if err != nil {
1934+
return nil, fmt.Errorf("failed to marshal response: %w", err)
1935+
}
1936+
1937+
return mcp.NewToolResultText(string(r)), nil
1938+
}
1939+
}

0 commit comments

Comments
 (0)