Skip to content

Commit d647f2f

Browse files
authored
fix(safe-outputs): treat stale/resolved review threads as no-ops; allow bot review dismissal (#49648)
1 parent 9cce393 commit d647f2f

8 files changed

Lines changed: 212 additions & 24 deletions

.github/workflows/pr-sous-chef.lock.yml

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

.github/workflows/pr-sous-chef.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -377,6 +377,7 @@ For each PR that is not skipped:
377377
- For `schedule` and `workflow_dispatch` runs, use the `resolve_review_threads` list returned by the `pr-processor` sub-agent.
378378
- Include a thread only when all of the following are true: the thread is currently unresolved; contains reviewer feedback; and has a later reply from the PR author or `@copilot`.
379379
- For each thread ID, call `safeoutputs resolve_pull_request_review_thread --thread_id <ID>`.
380+
- Each `<ID>` must be the review thread node ID (`PRRT_...`) taken from `reviewThreads`; never pass a review comment node ID (`PRRC_...`).
380381
- If resolving one thread fails, record `{thread_id: <ID>, skip_reason: "resolve_review_thread_failed"}` in the `skipped` array and continue.
381382

382383
4. **Dismiss stale `github-actions[bot]` blocking reviews when all PR review threads are resolved**
@@ -446,7 +447,7 @@ Given one PR number and compact metadata:
446447
- a single combined nudge comment body:
447448
- if `conflicting` is true: a targeted nudge asking `@copilot` to run `make merge-main` to resolve conflicts
448449
- otherwise: a combined nudge covering unresolved review feedback, failed checks (from `failed_checks` in the compact JSON — list each by name with URL when available), branch refresh, and any other forward-progress action including a direct instruction to run the `pr-finisher` skill — one comment only, never two; if unresolved PR reviews exist, include an explicit unresolved-reviews list (reviewer + direct link per unresolved review thread)
449-
- `resolve_review_threads`: an array of unresolved PR review thread node IDs to resolve via safe output; include a thread only when the thread already contains a follow-up response from the PR author or `@copilot` that addresses the feedback
450+
- `resolve_review_threads`: an array of unresolved PR review thread node IDs (`PRRT_...`) from `reviewThreads` to resolve via safe output; never emit review comment node IDs (`PRRC_...`). Include a thread only when the thread already contains a follow-up response from the PR author or `@copilot` that addresses the feedback
450451
- `dismiss_reviews`: an array of review IDs — include a review ID only when the review was authored by `github-actions[bot]` with `CHANGES_REQUESTED` state AND all review threads on the PR are resolved (no unresolved threads remain); return an empty array if there are unresolved threads or no qualifying reviews
451452
4. Make at most 8 tool calls total. If 8 calls are insufficient to reach a confident decision, set all fields to `null` and set `skip_reason: "insufficient_context"`.
452453
5. Keep output compact JSON only — a single object, no prose.

.github/workflows/smoke-checkout-pr-dispatch.lock.yml

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

actions/setup/js/dismiss_pull_request_review.cjs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,7 @@ async function main(config = {}) {
233233
});
234234

235235
const reviewAuthorLogin = review?.user?.login;
236+
const reviewAuthorType = typeof review?.user?.type === "string" ? review.user.type.trim() : "";
236237
if (typeof reviewAuthorLogin !== "string" || reviewAuthorLogin.trim() === "") {
237238
return {
238239
success: false,
@@ -241,6 +242,18 @@ async function main(config = {}) {
241242
}
242243
const reviewAuthor = reviewAuthorLogin.trim();
243244
if (reviewAuthor !== expectedAuthor) {
245+
if (reviewAuthorType === "Bot") {
246+
const warningMessage =
247+
`Skipping dismiss_pull_request_review for review ${reviewId}: ` +
248+
`review author (${reviewAuthor}) does not match dismisser (${dismisser}). ` +
249+
`Actor-bound dismissal only permits dismissing reviews authored by the current workflow actor.`;
250+
core.warning(warningMessage);
251+
return {
252+
success: false,
253+
skipped: true,
254+
error: warningMessage,
255+
};
256+
}
244257
return {
245258
success: false,
246259
error: `review author (${reviewAuthor || "unknown"}) must match dismisser (${dismisser})`,

actions/setup/js/dismiss_pull_request_review.test.cjs

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ describe("dismiss_pull_request_review", () => {
5050
mockGetReview.mockResolvedValue({
5151
data: {
5252
html_url: "https://github.com/test-owner/test-repo/pull/42#pullrequestreview-123",
53-
user: { login: "github-actions[bot]" },
53+
user: { login: "github-actions[bot]", type: "Bot" },
5454
},
5555
});
5656
mockDismissReview.mockResolvedValue({
@@ -118,7 +118,7 @@ describe("dismiss_pull_request_review", () => {
118118
it("rejects when fetched review author differs from current actor", async () => {
119119
mockGetReview.mockResolvedValueOnce({
120120
data: {
121-
user: { login: "octocat" },
121+
user: { login: "octocat", type: "User" },
122122
},
123123
});
124124

@@ -133,6 +133,30 @@ describe("dismiss_pull_request_review", () => {
133133
expect(mockDismissReview).not.toHaveBeenCalled();
134134
});
135135

136+
it("skips dismissal when review was authored by a bot and actor is a different user", async () => {
137+
process.env.GITHUB_ACTOR = "pelikhan";
138+
const { main } = require("./dismiss_pull_request_review.cjs");
139+
handler = await main({ max: 10 });
140+
141+
mockGetReview.mockResolvedValueOnce({
142+
data: {
143+
html_url: "https://github.com/test-owner/test-repo/pull/42#pullrequestreview-123",
144+
user: { login: "github-actions[bot]", type: "Bot" },
145+
},
146+
});
147+
148+
const result = await handler({
149+
type: "dismiss_pull_request_review",
150+
review_id: 123,
151+
justification: "Dismissing stale github-actions review because all PR review threads are resolved.",
152+
});
153+
154+
expect(result.success).toBe(false);
155+
expect(result.skipped).toBe(true);
156+
expect(result.error).toContain("Actor-bound dismissal only permits");
157+
expect(mockDismissReview).not.toHaveBeenCalled();
158+
});
159+
136160
it("resolves review_id=auto to all dismissible reviews by current actor", async () => {
137161
mockListReviews.mockResolvedValueOnce({
138162
data: [

actions/setup/js/resolve_pr_review_thread.cjs

Lines changed: 42 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -22,13 +22,19 @@ const HANDLER_TYPE = "resolve_pull_request_review_thread";
2222
* Used to validate the thread before resolving.
2323
* @param {any} github - GitHub GraphQL instance
2424
* @param {string} threadId - Review thread node ID (e.g., 'PRRT_kwDOABCD...')
25-
* @returns {Promise<{prNumber: number, repoNameWithOwner: string|null}|null>} The PR number and repo, or null if not found
25+
* @returns {Promise<
26+
* | {status: "missing"}
27+
* | {status: "thread", prNumber: number, repoNameWithOwner: string|null, isResolved: boolean}
28+
* | {status: "invalid_type", nodeType: string}
29+
* >} Thread lookup result
2630
*/
2731
async function getThreadPullRequestInfo(github, threadId) {
2832
const query = /* GraphQL */ `
2933
query ($threadId: ID!) {
3034
node(id: $threadId) {
35+
__typename
3136
... on PullRequestReviewThread {
37+
isResolved
3238
pullRequest {
3339
number
3440
repository {
@@ -42,14 +48,23 @@ async function getThreadPullRequestInfo(github, threadId) {
4248

4349
const result = await github.graphql(query, { threadId });
4450

45-
const pullRequest = result?.node?.pullRequest;
46-
if (!pullRequest) {
47-
return null;
51+
const threadNode = result?.node;
52+
if (!threadNode) {
53+
return { status: "missing" };
54+
}
55+
if (threadNode.__typename !== "PullRequestReviewThread") {
56+
return {
57+
status: "invalid_type",
58+
nodeType: threadNode.__typename || "unknown",
59+
};
4860
}
61+
const pullRequest = threadNode.pullRequest;
4962

5063
return {
64+
status: "thread",
5165
prNumber: pullRequest.number,
5266
repoNameWithOwner: pullRequest.repository?.nameWithOwner ?? null,
67+
isResolved: threadNode?.isResolved === true,
5368
};
5469
}
5570

@@ -174,15 +189,24 @@ async function main(config = {}) {
174189

175190
// Look up the thread's PR number and repository
176191
const threadInfo = await getThreadPullRequestInfo(githubClient, threadId);
177-
if (threadInfo === null) {
178-
core.warning(`Review thread not found or not a PullRequestReviewThread: ${threadId}`);
192+
if (threadInfo.status === "missing") {
193+
core.info(`Review thread ${threadId} not found — already resolved or stale; skipping`);
194+
return {
195+
success: true,
196+
thread_id: threadId,
197+
is_resolved: true,
198+
skipped: true,
199+
};
200+
}
201+
202+
if (threadInfo.status !== "thread") {
179203
return {
180204
success: false,
181-
error: `Review thread not found: ${threadId}`,
205+
error: `thread_id must reference a PullRequestReviewThread node ID (PRRT_...); received ${threadInfo.nodeType} for ${threadId}`,
182206
};
183207
}
184208

185-
const { prNumber: threadPRNumber, repoNameWithOwner: threadRepo } = threadInfo;
209+
const { prNumber: threadPRNumber, repoNameWithOwner: threadRepo, isResolved } = threadInfo;
186210

187211
// When the user explicitly configured target-repo or allowed-repos, validate the thread's
188212
// repository using validateTargetRepo (supports wildcards like "*", "org/*").
@@ -282,6 +306,16 @@ async function main(config = {}) {
282306
const filterResult = await checkRequiredFilter(githubClient, repoParts, threadPRNumber, requiredLabels, requiredTitlePrefix, "resolve_pull_request_review_thread");
283307
if (filterResult) return filterResult;
284308

309+
if (isResolved) {
310+
core.info(`Review thread ${threadId} is already resolved; skipping`);
311+
return {
312+
success: true,
313+
thread_id: threadId,
314+
is_resolved: true,
315+
skipped: true,
316+
};
317+
}
318+
285319
// If in staged mode, preview without executing
286320
if (isStaged) {
287321
logStagedPreviewInfo(`Would resolve review thread ${threadId}`);

0 commit comments

Comments
 (0)