Skip to content

Commit 53cad0d

Browse files
Add resolve-pull-request-review-thread safe output
Adds a new safe output type that allows AI agents to resolve review threads on pull requests after addressing feedback. Uses the GitHub GraphQL resolveReviewThread mutation. Changes: - New Go config: resolve_pr_review_thread.go - New JS handler: resolve_pr_review_thread.cjs with tests - Updated 15+ infrastructure files (schemas, config, permissions, handler registries, type definitions, documentation) Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent bdd5dd4 commit 53cad0d

24 files changed

Lines changed: 566 additions & 6 deletions
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
// @ts-check
2+
/// <reference types="@actions/github-script" />
3+
4+
/**
5+
* @typedef {import('./types/handler-factory').HandlerFactoryFunction} HandlerFactoryFunction
6+
*/
7+
8+
const { getErrorMessage } = require("./error_helpers.cjs");
9+
10+
/**
11+
* Type constant for handler identification
12+
*/
13+
const HANDLER_TYPE = "resolve_pull_request_review_thread";
14+
15+
/**
16+
* Resolve a pull request review thread using the GraphQL API.
17+
* @param {any} github - GitHub GraphQL instance
18+
* @param {string} threadId - Review thread node ID (e.g., 'PRRT_kwDOABCD...')
19+
* @returns {Promise<{threadId: string, isResolved: boolean}>} Resolved thread details
20+
*/
21+
async function resolveReviewThreadAPI(github, threadId) {
22+
const query = /* GraphQL */ `
23+
mutation ($threadId: ID!) {
24+
resolveReviewThread(input: { threadId: $threadId }) {
25+
thread {
26+
id
27+
isResolved
28+
}
29+
}
30+
}
31+
`;
32+
33+
const result = await github.graphql(query, { threadId });
34+
35+
return {
36+
threadId: result.resolveReviewThread.thread.id,
37+
isResolved: result.resolveReviewThread.thread.isResolved,
38+
};
39+
}
40+
41+
/**
42+
* Main handler factory for resolve_pull_request_review_thread
43+
* Returns a message handler function that processes individual resolve messages
44+
* @type {HandlerFactoryFunction}
45+
*/
46+
async function main(config = {}) {
47+
// Extract configuration
48+
const maxCount = config.max || 10;
49+
50+
core.info(`Resolve PR review thread configuration: max=${maxCount}`);
51+
52+
// Track how many items we've processed for max limit
53+
let processedCount = 0;
54+
55+
/**
56+
* Message handler function that processes a single resolve_pull_request_review_thread message
57+
* @param {Object} message - The resolve message to process
58+
* @param {Object} resolvedTemporaryIds - Map of temporary IDs to {repo, number}
59+
* @returns {Promise<Object>} Result with success/error status
60+
*/
61+
return async function handleResolvePRReviewThread(message, resolvedTemporaryIds) {
62+
// Check if we've hit the max limit
63+
if (processedCount >= maxCount) {
64+
core.warning(`Skipping resolve_pull_request_review_thread: max count of ${maxCount} reached`);
65+
return {
66+
success: false,
67+
error: `Max count of ${maxCount} reached`,
68+
};
69+
}
70+
71+
processedCount++;
72+
73+
const item = message;
74+
75+
try {
76+
// Validate required fields
77+
const threadId = item.thread_id;
78+
if (!threadId || typeof threadId !== "string" || threadId.trim().length === 0) {
79+
core.warning('Missing or invalid required field "thread_id" in resolve message');
80+
return {
81+
success: false,
82+
error: 'Missing or invalid required field "thread_id" - must be a non-empty string (GraphQL node ID)',
83+
};
84+
}
85+
86+
core.info(`Resolving review thread: ${threadId}`);
87+
88+
const resolveResult = await resolveReviewThreadAPI(github, threadId);
89+
90+
if (resolveResult.isResolved) {
91+
core.info(`Successfully resolved review thread: ${threadId}`);
92+
return {
93+
success: true,
94+
thread_id: threadId,
95+
is_resolved: true,
96+
};
97+
} else {
98+
core.error(`Failed to resolve review thread: ${threadId}`);
99+
return {
100+
success: false,
101+
error: `Failed to resolve review thread: ${threadId}`,
102+
};
103+
}
104+
} catch (error) {
105+
const errorMessage = getErrorMessage(error);
106+
core.error(`Failed to resolve review thread: ${errorMessage}`);
107+
return {
108+
success: false,
109+
error: errorMessage,
110+
};
111+
}
112+
};
113+
}
114+
115+
module.exports = { main, HANDLER_TYPE };
Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
import { describe, it, expect, beforeEach, vi } from "vitest";
2+
3+
const mockCore = {
4+
debug: vi.fn(),
5+
info: vi.fn(),
6+
warning: vi.fn(),
7+
error: vi.fn(),
8+
setFailed: vi.fn(),
9+
setOutput: vi.fn(),
10+
summary: {
11+
addRaw: vi.fn().mockReturnThis(),
12+
write: vi.fn().mockResolvedValue(),
13+
},
14+
};
15+
16+
global.core = mockCore;
17+
18+
const mockGraphql = vi.fn();
19+
const mockGithub = {
20+
graphql: mockGraphql,
21+
};
22+
23+
global.github = mockGithub;
24+
25+
const mockContext = {
26+
repo: { owner: "test-owner", repo: "test-repo" },
27+
runId: 12345,
28+
eventName: "pull_request",
29+
payload: {
30+
pull_request: { number: 42 },
31+
repository: { html_url: "https://github.com/test-owner/test-repo" },
32+
},
33+
};
34+
35+
global.context = mockContext;
36+
37+
describe("resolve_pr_review_thread", () => {
38+
let handler;
39+
40+
beforeEach(async () => {
41+
vi.clearAllMocks();
42+
43+
mockGraphql.mockResolvedValue({
44+
resolveReviewThread: {
45+
thread: {
46+
id: "PRRT_kwDOABCD123456",
47+
isResolved: true,
48+
},
49+
},
50+
});
51+
52+
const { main } = require("./resolve_pr_review_thread.cjs");
53+
handler = await main({ max: 10 });
54+
});
55+
56+
it("should return a function from main()", async () => {
57+
const { main } = require("./resolve_pr_review_thread.cjs");
58+
const result = await main({});
59+
expect(typeof result).toBe("function");
60+
});
61+
62+
it("should successfully resolve a review thread", async () => {
63+
const message = {
64+
type: "resolve_pull_request_review_thread",
65+
thread_id: "PRRT_kwDOABCD123456",
66+
};
67+
68+
const result = await handler(message, {});
69+
70+
expect(result.success).toBe(true);
71+
expect(result.thread_id).toBe("PRRT_kwDOABCD123456");
72+
expect(result.is_resolved).toBe(true);
73+
expect(mockGraphql).toHaveBeenCalledWith(
74+
expect.stringContaining("resolveReviewThread"),
75+
expect.objectContaining({
76+
threadId: "PRRT_kwDOABCD123456",
77+
})
78+
);
79+
});
80+
81+
it("should fail when thread_id is missing", async () => {
82+
const message = {
83+
type: "resolve_pull_request_review_thread",
84+
};
85+
86+
const result = await handler(message, {});
87+
88+
expect(result.success).toBe(false);
89+
expect(result.error).toContain("thread_id");
90+
});
91+
92+
it("should fail when thread_id is empty string", async () => {
93+
const message = {
94+
type: "resolve_pull_request_review_thread",
95+
thread_id: "",
96+
};
97+
98+
const result = await handler(message, {});
99+
100+
expect(result.success).toBe(false);
101+
expect(result.error).toContain("thread_id");
102+
});
103+
104+
it("should fail when thread_id is whitespace only", async () => {
105+
const message = {
106+
type: "resolve_pull_request_review_thread",
107+
thread_id: " ",
108+
};
109+
110+
const result = await handler(message, {});
111+
112+
expect(result.success).toBe(false);
113+
expect(result.error).toContain("thread_id");
114+
});
115+
116+
it("should fail when thread_id is not a string", async () => {
117+
const message = {
118+
type: "resolve_pull_request_review_thread",
119+
thread_id: 12345,
120+
};
121+
122+
const result = await handler(message, {});
123+
124+
expect(result.success).toBe(false);
125+
expect(result.error).toContain("thread_id");
126+
});
127+
128+
it("should respect max count limit", async () => {
129+
const { main } = require("./resolve_pr_review_thread.cjs");
130+
const limitedHandler = await main({ max: 2 });
131+
132+
const message = {
133+
type: "resolve_pull_request_review_thread",
134+
thread_id: "PRRT_kwDOABCD123456",
135+
};
136+
137+
const result1 = await limitedHandler(message, {});
138+
const result2 = await limitedHandler(message, {});
139+
const result3 = await limitedHandler(message, {});
140+
141+
expect(result1.success).toBe(true);
142+
expect(result2.success).toBe(true);
143+
expect(result3.success).toBe(false);
144+
expect(result3.error).toContain("Max count of 2 reached");
145+
});
146+
147+
it("should handle API errors gracefully", async () => {
148+
mockGraphql.mockRejectedValue(new Error("Could not resolve. Thread not found."));
149+
150+
const message = {
151+
type: "resolve_pull_request_review_thread",
152+
thread_id: "PRRT_invalid",
153+
};
154+
155+
const result = await handler(message, {});
156+
157+
expect(result.success).toBe(false);
158+
expect(result.error).toContain("Could not resolve");
159+
});
160+
161+
it("should handle unexpected resolve failure", async () => {
162+
mockGraphql.mockResolvedValue({
163+
resolveReviewThread: {
164+
thread: {
165+
id: "PRRT_kwDOABCD123456",
166+
isResolved: false,
167+
},
168+
},
169+
});
170+
171+
const message = {
172+
type: "resolve_pull_request_review_thread",
173+
thread_id: "PRRT_kwDOABCD123456",
174+
};
175+
176+
const result = await handler(message, {});
177+
178+
expect(result.success).toBe(false);
179+
expect(result.error).toContain("Failed to resolve");
180+
});
181+
182+
it("should default max to 10", async () => {
183+
const { main } = require("./resolve_pr_review_thread.cjs");
184+
const defaultHandler = await main({});
185+
186+
const message = {
187+
type: "resolve_pull_request_review_thread",
188+
thread_id: "PRRT_kwDOABCD123456",
189+
};
190+
191+
// Process 10 messages successfully
192+
for (let i = 0; i < 10; i++) {
193+
const result = await defaultHandler(message, {});
194+
expect(result.success).toBe(true);
195+
}
196+
197+
// 11th should fail
198+
const result = await defaultHandler(message, {});
199+
expect(result.success).toBe(false);
200+
expect(result.error).toContain("Max count of 10 reached");
201+
});
202+
});

actions/setup/js/safe_output_handler_manager.cjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ const HANDLER_MAP = {
3636
update_release: "./update_release.cjs",
3737
create_pull_request_review_comment: "./create_pr_review_comment.cjs",
3838
submit_pull_request_review: "./submit_pr_review.cjs",
39+
resolve_pull_request_review_thread: "./resolve_pr_review_thread.cjs",
3940
create_pull_request: "./create_pull_request.cjs",
4041
push_to_pull_request_branch: "./push_to_pull_request_branch.cjs",
4142
update_pull_request: "./update_pull_request.cjs",

actions/setup/js/safe_output_unified_handler_manager.cjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ const HANDLER_MAP = {
4444
update_release: "./update_release.cjs",
4545
create_pull_request_review_comment: "./create_pr_review_comment.cjs",
4646
submit_pull_request_review: "./submit_pr_review.cjs",
47+
resolve_pull_request_review_thread: "./resolve_pr_review_thread.cjs",
4748
create_pull_request: "./create_pull_request.cjs",
4849
push_to_pull_request_branch: "./push_to_pull_request_branch.cjs",
4950
update_pull_request: "./update_pull_request.cjs",

actions/setup/js/safe_outputs_tools.json

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,21 @@
256256
"additionalProperties": false
257257
}
258258
},
259+
{
260+
"name": "resolve_pull_request_review_thread",
261+
"description": "Resolve a review thread on a pull request. Use this to mark a review conversation as resolved after addressing the feedback. The thread_id must be the node ID of the review thread (e.g., PRRT_kwDO...).",
262+
"inputSchema": {
263+
"type": "object",
264+
"required": ["thread_id"],
265+
"properties": {
266+
"thread_id": {
267+
"type": "string",
268+
"description": "The node ID of the review thread to resolve (e.g., 'PRRT_kwDOABCD...'). This is the GraphQL node ID, not a numeric ID."
269+
}
270+
},
271+
"additionalProperties": false
272+
}
273+
},
259274
{
260275
"name": "create_code_scanning_alert",
261276
"description": "Create a code scanning alert for security vulnerabilities, code quality issues, or other findings. Alerts appear in the repository's Security tab and integrate with GitHub's security features. Use this for automated security analysis results.",

actions/setup/js/types/safe-outputs-config.d.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,15 @@ interface CreatePullRequestReviewCommentConfig extends SafeOutputConfig {
9494
target?: string;
9595
}
9696

97+
/**
98+
* Configuration for resolving pull request review threads
99+
*/
100+
interface ResolvePullRequestReviewThreadConfig extends SafeOutputConfig {
101+
target?: string;
102+
"target-repo"?: string;
103+
allowed_repos?: string[];
104+
}
105+
97106
/**
98107
* Configuration for creating code scanning alerts
99108
*/
@@ -282,7 +291,8 @@ type SpecificSafeOutputConfig =
282291
| NoOpConfig
283292
| MissingToolConfig
284293
| LinkSubIssueConfig
285-
| ThreatDetectionConfig;
294+
| ThreatDetectionConfig
295+
| ResolvePullRequestReviewThreadConfig;
286296

287297
type SafeOutputConfigs = Record<string, SafeOutputConfig | SpecificSafeOutputConfig>;
288298

@@ -315,6 +325,7 @@ export {
315325
MissingToolConfig,
316326
LinkSubIssueConfig,
317327
ThreatDetectionConfig,
328+
ResolvePullRequestReviewThreadConfig,
318329
SpecificSafeOutputConfig,
319330
// Safe job configuration types
320331
SafeJobInput,

0 commit comments

Comments
 (0)