From dbc993cc378cb5d05ee140ab73aa1472968cd87c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 6 Jul 2026 01:26:43 +0000 Subject: [PATCH 1/5] Handle degenerate review-required states in dismiss review safe output Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .../setup/js/dismiss_pull_request_review.cjs | 72 +++++++++++++++-- .../js/dismiss_pull_request_review.test.cjs | 81 +++++++++++++++++++ actions/setup/js/safe_outputs_tools.json | 2 +- actions/setup/js/types/safe-outputs.d.ts | 2 +- pkg/workflow/js/safe_outputs_tools.json | 2 +- schemas/agent-output.json | 2 +- 6 files changed, 152 insertions(+), 9 deletions(-) diff --git a/actions/setup/js/dismiss_pull_request_review.cjs b/actions/setup/js/dismiss_pull_request_review.cjs index d5170b5e13f..f64d29b5a7a 100644 --- a/actions/setup/js/dismiss_pull_request_review.cjs +++ b/actions/setup/js/dismiss_pull_request_review.cjs @@ -22,6 +22,31 @@ function getEffectiveActor() { return actor || "github-actions[bot]"; } +/** + * @param {any[]} reviews + * @param {string} expectedAuthor + * @returns {any|null} + */ +function findLatestDismissibleReviewForActor(reviews, expectedAuthor) { + if (!Array.isArray(reviews) || !expectedAuthor) return null; + const actor = expectedAuthor.toLowerCase(); + const dismissibleStates = new Set(["CHANGES_REQUESTED", "APPROVED"]); + return ( + reviews + .filter(review => { + const login = typeof review?.user?.login === "string" ? review.user.login.trim().toLowerCase() : ""; + const state = typeof review?.state === "string" ? review.state.trim().toUpperCase() : ""; + return login === actor && dismissibleStates.has(state); + }) + .sort((a, b) => { + const aTime = Date.parse(a?.submitted_at || "") || 0; + const bTime = Date.parse(b?.submitted_at || "") || 0; + if (aTime !== bTime) return bTime - aTime; + return Number(b?.id || 0) - Number(a?.id || 0); + })[0] || null + ); +} + /** * Main handler factory for dismiss_pull_request_review. * @type {HandlerFactoryFunction} @@ -46,13 +71,16 @@ async function main(config = {}) { }; } - const reviewId = Number.parseInt(String(message.review_id || ""), 10); - if (!Number.isInteger(reviewId) || reviewId <= 0) { + const rawReviewId = String(message.review_id || "").trim(); + const useAutoReviewId = rawReviewId.toLowerCase() === "auto"; + const parsedReviewId = Number.parseInt(rawReviewId, 10); + if (!useAutoReviewId && (!Number.isInteger(parsedReviewId) || parsedReviewId <= 0)) { return { success: false, - error: "review_id must be a positive integer", + error: "review_id must be a positive integer or 'auto'", }; } + let reviewId = useAutoReviewId ? null : parsedReviewId; const justification = typeof message.justification === "string" ? message.justification.trim() : ""; if (justification.length < 20) { @@ -99,12 +127,13 @@ async function main(config = {}) { if (filterResult) return filterResult; if (isStaged) { - logStagedPreviewInfo(`Would dismiss review #${reviewId} on PR #${pullRequestNumber} (${owner}/${repo}) as ${dismisser}`); + const previewReviewId = useAutoReviewId ? "auto" : reviewId; + logStagedPreviewInfo(`Would dismiss review #${previewReviewId} on PR #${pullRequestNumber} (${owner}/${repo}) as ${dismisser}`); processedCount++; return { success: true, staged: true, - review_id: reviewId, + review_id: previewReviewId, pull_request_number: pullRequestNumber, repo: `${owner}/${repo}`, author: expectedAuthor, @@ -112,6 +141,39 @@ async function main(config = {}) { } try { + if (useAutoReviewId) { + const [{ data: pullRequest }, { data: reviews }] = await Promise.all([ + githubClient.rest.pulls.get({ + owner, + repo, + pull_number: pullRequestNumber, + }), + githubClient.rest.pulls.listReviews({ + owner, + repo, + pull_number: pullRequestNumber, + per_page: 100, + }), + ]); + + const autoReview = findLatestDismissibleReviewForActor(reviews, expectedAuthor); + if (!autoReview || !Number.isInteger(autoReview.id) || autoReview.id <= 0) { + const noRequestedReviewers = Array.isArray(pullRequest?.requested_reviewers) && pullRequest.requested_reviewers.length === 0 && Array.isArray(pullRequest?.requested_teams) && pullRequest.requested_teams.length === 0; + const reviewRequiredBlockedState = noRequestedReviewers && pullRequest?.mergeable_state === "blocked"; + if (reviewRequiredBlockedState) { + return { + success: false, + error: "detected a degenerate review-required state (PR is blocked but has no requested reviewers/teams); no dismissible actor-authored review was found", + }; + } + return { + success: false, + error: `review_id=auto did not find a dismissible review authored by ${expectedAuthor}`, + }; + } + reviewId = autoReview.id; + } + const { data: review } = await githubClient.rest.pulls.getReview({ owner, repo, diff --git a/actions/setup/js/dismiss_pull_request_review.test.cjs b/actions/setup/js/dismiss_pull_request_review.test.cjs index b5ea9941f2e..a8f76b05e29 100644 --- a/actions/setup/js/dismiss_pull_request_review.test.cjs +++ b/actions/setup/js/dismiss_pull_request_review.test.cjs @@ -17,12 +17,16 @@ global.core = mockCore; const mockGetReview = vi.fn(); const mockDismissReview = vi.fn(); +const mockGetPullRequest = vi.fn(); +const mockListReviews = vi.fn(); const mockGithub = { rest: { pulls: { getReview: mockGetReview, dismissReview: mockDismissReview, + get: mockGetPullRequest, + listReviews: mockListReviews, }, }, }; @@ -54,6 +58,23 @@ describe("dismiss_pull_request_review", () => { html_url: "https://github.com/test-owner/test-repo/pull/42#pullrequestreview-123", }, }); + mockGetPullRequest.mockResolvedValue({ + data: { + mergeable_state: "clean", + requested_reviewers: [], + requested_teams: [], + }, + }); + mockListReviews.mockResolvedValue({ + data: [ + { + id: 123, + state: "CHANGES_REQUESTED", + submitted_at: "2026-07-06T00:00:00Z", + user: { login: "github-actions[bot]" }, + }, + ], + }); const { main } = require("./dismiss_pull_request_review.cjs"); handler = await main({ max: 10 }); @@ -111,4 +132,64 @@ describe("dismiss_pull_request_review", () => { expect(result.error).toContain("review author"); expect(mockDismissReview).not.toHaveBeenCalled(); }); + + it("resolves review_id=auto to latest dismissible review by current actor", async () => { + mockGetReview.mockResolvedValueOnce({ + data: { + html_url: "https://github.com/test-owner/test-repo/pull/42#pullrequestreview-456", + user: { login: "github-actions[bot]" }, + }, + }); + mockListReviews.mockResolvedValueOnce({ + data: [ + { + id: 111, + state: "APPROVED", + submitted_at: "2026-07-01T00:00:00Z", + user: { login: "github-actions[bot]" }, + }, + { + id: 456, + state: "CHANGES_REQUESTED", + submitted_at: "2026-07-02T00:00:00Z", + user: { login: "github-actions[bot]" }, + }, + ], + }); + + const result = await handler({ + type: "dismiss_pull_request_review", + review_id: "auto", + justification: "This stale review no longer reflects the updated implementation.", + }); + + expect(result.success).toBe(true); + expect(mockDismissReview).toHaveBeenCalledWith( + expect.objectContaining({ + pull_number: 42, + review_id: 456, + }) + ); + }); + + it("detects degenerate blocked state with no requested reviewers when review_id=auto has no candidate", async () => { + mockGetPullRequest.mockResolvedValueOnce({ + data: { + mergeable_state: "blocked", + requested_reviewers: [], + requested_teams: [], + }, + }); + mockListReviews.mockResolvedValueOnce({ data: [] }); + + const result = await handler({ + type: "dismiss_pull_request_review", + review_id: "auto", + justification: "This stale review no longer reflects the updated implementation.", + }); + + expect(result.success).toBe(false); + expect(result.error).toContain("degenerate review-required state"); + expect(mockDismissReview).not.toHaveBeenCalled(); + }); }); diff --git a/actions/setup/js/safe_outputs_tools.json b/actions/setup/js/safe_outputs_tools.json index 3a71c122049..6938c3bb34a 100644 --- a/actions/setup/js/safe_outputs_tools.json +++ b/actions/setup/js/safe_outputs_tools.json @@ -524,7 +524,7 @@ "properties": { "review_id": { "type": ["number", "string"], - "description": "Numeric pull request review ID to dismiss (for example, 123456789).", + "description": "Numeric pull request review ID to dismiss (for example, 123456789), or 'auto' to resolve the latest dismissible review authored by the current workflow actor.", "x-synonyms": ["reviewId"] }, "justification": { diff --git a/actions/setup/js/types/safe-outputs.d.ts b/actions/setup/js/types/safe-outputs.d.ts index db96de48a76..ebae4edb52a 100644 --- a/actions/setup/js/types/safe-outputs.d.ts +++ b/actions/setup/js/types/safe-outputs.d.ts @@ -438,7 +438,7 @@ interface ReplyToPullRequestReviewCommentItem extends BaseSafeOutputItem { */ interface DismissPullRequestReviewItem extends BaseSafeOutputItem { type: "dismiss_pull_request_review"; - /** Numeric review ID to dismiss */ + /** Numeric review ID to dismiss, or "auto" to resolve latest dismissible review by current actor */ review_id: number | string; /** Dismissal justification (minimum 20 characters) */ justification: string; diff --git a/pkg/workflow/js/safe_outputs_tools.json b/pkg/workflow/js/safe_outputs_tools.json index 3a71c122049..6938c3bb34a 100644 --- a/pkg/workflow/js/safe_outputs_tools.json +++ b/pkg/workflow/js/safe_outputs_tools.json @@ -524,7 +524,7 @@ "properties": { "review_id": { "type": ["number", "string"], - "description": "Numeric pull request review ID to dismiss (for example, 123456789).", + "description": "Numeric pull request review ID to dismiss (for example, 123456789), or 'auto' to resolve the latest dismissible review authored by the current workflow actor.", "x-synonyms": ["reviewId"] }, "justification": { diff --git a/schemas/agent-output.json b/schemas/agent-output.json index 82d7368c745..38ab32024a8 100644 --- a/schemas/agent-output.json +++ b/schemas/agent-output.json @@ -413,7 +413,7 @@ }, "review_id": { "type": ["number", "string"], - "description": "The numeric review ID to dismiss." + "description": "The numeric review ID to dismiss, or 'auto' to resolve the latest dismissible review authored by the current workflow actor." }, "justification": { "type": "string", From a05d84a2ff669b4c16c5a10e5219a0737292758d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 6 Jul 2026 01:42:01 +0000 Subject: [PATCH 2/5] Add auto and degenerate-state handling for dismiss review safe output Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .../setup/js/dismiss_pull_request_review.cjs | 67 ++++++++--- .../js/dismiss_pull_request_review.test.cjs | 113 ++++++++++++++++++ 2 files changed, 166 insertions(+), 14 deletions(-) diff --git a/actions/setup/js/dismiss_pull_request_review.cjs b/actions/setup/js/dismiss_pull_request_review.cjs index f64d29b5a7a..57cf2879c1b 100644 --- a/actions/setup/js/dismiss_pull_request_review.cjs +++ b/actions/setup/js/dismiss_pull_request_review.cjs @@ -29,24 +29,69 @@ function getEffectiveActor() { */ function findLatestDismissibleReviewForActor(reviews, expectedAuthor) { if (!Array.isArray(reviews) || !expectedAuthor) return null; - const actor = expectedAuthor.toLowerCase(); + const expectedAuthorLower = expectedAuthor.toLowerCase(); const dismissibleStates = new Set(["CHANGES_REQUESTED", "APPROVED"]); + const getSubmittedAtTimestamp = review => { + if (typeof review?.submitted_at !== "string") return -1; + const parsed = Date.parse(review.submitted_at); + // Invalid/missing timestamps use -1 as a sentinel so they sort as older + // than valid submitted_at values. Deterministic ordering is preserved by + // the ID tie-breaker below. + return Number.isFinite(parsed) ? parsed : -1; + }; return ( reviews .filter(review => { const login = typeof review?.user?.login === "string" ? review.user.login.trim().toLowerCase() : ""; const state = typeof review?.state === "string" ? review.state.trim().toUpperCase() : ""; - return login === actor && dismissibleStates.has(state); + return login === expectedAuthorLower && dismissibleStates.has(state); }) .sort((a, b) => { - const aTime = Date.parse(a?.submitted_at || "") || 0; - const bTime = Date.parse(b?.submitted_at || "") || 0; + const aTime = getSubmittedAtTimestamp(a); + const bTime = getSubmittedAtTimestamp(b); if (aTime !== bTime) return bTime - aTime; return Number(b?.id || 0) - Number(a?.id || 0); })[0] || null ); } +/** + * @param {any} pullRequest + * @returns {boolean} + */ +function hasNoRequestedReviewersOrTeams(pullRequest) { + const hasNoRequestedReviewers = Array.isArray(pullRequest?.requested_reviewers) && pullRequest.requested_reviewers.length === 0; + const hasNoRequestedTeams = Array.isArray(pullRequest?.requested_teams) && pullRequest.requested_teams.length === 0; + return hasNoRequestedReviewers && hasNoRequestedTeams; +} + +/** + * @param {any} githubClient + * @param {string} owner + * @param {string} repo + * @param {number} pullRequestNumber + * @returns {Promise} + */ +async function listAllPullRequestReviews(githubClient, owner, repo, pullRequestNumber) { + const all = []; + const maxPages = 10; + let page = 1; + while (page <= maxPages) { + const { data } = await githubClient.rest.pulls.listReviews({ + owner, + repo, + pull_number: pullRequestNumber, + per_page: 100, + page, + }); + if (!Array.isArray(data) || data.length === 0) break; + all.push(...data); + if (data.length < 100) break; + page++; + } + return all; +} + /** * Main handler factory for dismiss_pull_request_review. * @type {HandlerFactoryFunction} @@ -142,25 +187,19 @@ async function main(config = {}) { try { if (useAutoReviewId) { - const [{ data: pullRequest }, { data: reviews }] = await Promise.all([ + const [{ data: pullRequest }, reviews] = await Promise.all([ githubClient.rest.pulls.get({ owner, repo, pull_number: pullRequestNumber, }), - githubClient.rest.pulls.listReviews({ - owner, - repo, - pull_number: pullRequestNumber, - per_page: 100, - }), + listAllPullRequestReviews(githubClient, owner, repo, pullRequestNumber), ]); const autoReview = findLatestDismissibleReviewForActor(reviews, expectedAuthor); if (!autoReview || !Number.isInteger(autoReview.id) || autoReview.id <= 0) { - const noRequestedReviewers = Array.isArray(pullRequest?.requested_reviewers) && pullRequest.requested_reviewers.length === 0 && Array.isArray(pullRequest?.requested_teams) && pullRequest.requested_teams.length === 0; - const reviewRequiredBlockedState = noRequestedReviewers && pullRequest?.mergeable_state === "blocked"; - if (reviewRequiredBlockedState) { + const isBlockedWithoutReviewers = hasNoRequestedReviewersOrTeams(pullRequest) && pullRequest?.mergeable_state === "blocked"; + if (isBlockedWithoutReviewers) { return { success: false, error: "detected a degenerate review-required state (PR is blocked but has no requested reviewers/teams); no dismissible actor-authored review was found", diff --git a/actions/setup/js/dismiss_pull_request_review.test.cjs b/actions/setup/js/dismiss_pull_request_review.test.cjs index a8f76b05e29..6f06f9c6008 100644 --- a/actions/setup/js/dismiss_pull_request_review.test.cjs +++ b/actions/setup/js/dismiss_pull_request_review.test.cjs @@ -192,4 +192,117 @@ describe("dismiss_pull_request_review", () => { expect(result.error).toContain("degenerate review-required state"); expect(mockDismissReview).not.toHaveBeenCalled(); }); + + it("returns actor-specific error when review_id=auto finds no reviews by current actor", async () => { + mockListReviews.mockResolvedValueOnce({ + data: [ + { + id: 900, + state: "CHANGES_REQUESTED", + submitted_at: "2026-07-03T00:00:00Z", + user: { login: "octocat" }, + }, + ], + }); + + const result = await handler({ + type: "dismiss_pull_request_review", + review_id: "auto", + justification: "This stale review no longer reflects the updated implementation.", + }); + + expect(result.success).toBe(false); + expect(result.error).toContain("did not find a dismissible review authored by github-actions[bot]"); + expect(mockDismissReview).not.toHaveBeenCalled(); + }); + + it("paginates reviews when resolving review_id=auto", async () => { + const firstPage = Array.from({ length: 100 }, (_, i) => ({ + id: i + 1, + state: "COMMENTED", + submitted_at: "2026-07-01T00:00:00Z", + user: { login: "octocat" }, + })); + mockListReviews.mockResolvedValueOnce({ data: firstPage }).mockResolvedValueOnce({ + data: [ + { + id: 1001, + state: "APPROVED", + submitted_at: "2026-07-04T00:00:00Z", + user: { login: "github-actions[bot]" }, + }, + ], + }); + mockGetReview.mockResolvedValueOnce({ + data: { + html_url: "https://github.com/test-owner/test-repo/pull/42#pullrequestreview-1001", + user: { login: "github-actions[bot]" }, + }, + }); + + const result = await handler({ + type: "dismiss_pull_request_review", + review_id: "auto", + justification: "This stale review no longer reflects the updated implementation.", + }); + + expect(result.success).toBe(true); + expect(mockListReviews).toHaveBeenCalledTimes(2); + expect(mockDismissReview).toHaveBeenCalledWith( + expect.objectContaining({ + review_id: 1001, + }) + ); + }); + + it("uses GITHUB_ACTOR for review_id=auto actor matching", async () => { + process.env.GITHUB_ACTOR = "custom-bot"; + const { main } = require("./dismiss_pull_request_review.cjs"); + handler = await main({ max: 10 }); + + mockListReviews.mockResolvedValueOnce({ + data: [ + { + id: 777, + state: "APPROVED", + submitted_at: "2026-07-04T00:00:00Z", + user: { login: "custom-bot" }, + }, + ], + }); + mockGetReview.mockResolvedValueOnce({ + data: { + html_url: "https://github.com/test-owner/test-repo/pull/42#pullrequestreview-777", + user: { login: "custom-bot" }, + }, + }); + + const result = await handler({ + type: "dismiss_pull_request_review", + review_id: "auto", + justification: "This stale review no longer reflects the updated implementation.", + }); + + expect(result.success).toBe(true); + expect(mockDismissReview).toHaveBeenCalledWith(expect.objectContaining({ review_id: 777 })); + }); + + it("caps review pagination to avoid excessive API calls", async () => { + const pageData = Array.from({ length: 100 }, (_, i) => ({ + id: i + 1, + state: "COMMENTED", + submitted_at: "2026-07-01T00:00:00Z", + user: { login: "octocat" }, + })); + mockListReviews.mockImplementation(() => Promise.resolve({ data: pageData })); + + const result = await handler({ + type: "dismiss_pull_request_review", + review_id: "auto", + justification: "This stale review no longer reflects the updated implementation.", + }); + + expect(result.success).toBe(false); + expect(mockListReviews).toHaveBeenCalledTimes(10); + }); }); From 75e858775d7ed1080d9f037c6f5f113c55f17fc0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 6 Jul 2026 02:17:48 +0000 Subject: [PATCH 3/5] feat: review_id=auto dismisses all actor-authored reviews, not just the latest Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .../setup/js/dismiss_pull_request_review.cjs | 74 +++++++++++-------- .../js/dismiss_pull_request_review.test.cjs | 63 +++++++++------- actions/setup/js/safe_outputs_tools.json | 2 +- pkg/workflow/js/safe_outputs_tools.json | 2 +- schemas/agent-output.json | 2 +- 5 files changed, 83 insertions(+), 60 deletions(-) diff --git a/actions/setup/js/dismiss_pull_request_review.cjs b/actions/setup/js/dismiss_pull_request_review.cjs index 57cf2879c1b..762c117aa39 100644 --- a/actions/setup/js/dismiss_pull_request_review.cjs +++ b/actions/setup/js/dismiss_pull_request_review.cjs @@ -25,34 +25,17 @@ function getEffectiveActor() { /** * @param {any[]} reviews * @param {string} expectedAuthor - * @returns {any|null} + * @returns {any[]} */ -function findLatestDismissibleReviewForActor(reviews, expectedAuthor) { - if (!Array.isArray(reviews) || !expectedAuthor) return null; +function findAllDismissibleReviewsForActor(reviews, expectedAuthor) { + if (!Array.isArray(reviews) || !expectedAuthor) return []; const expectedAuthorLower = expectedAuthor.toLowerCase(); const dismissibleStates = new Set(["CHANGES_REQUESTED", "APPROVED"]); - const getSubmittedAtTimestamp = review => { - if (typeof review?.submitted_at !== "string") return -1; - const parsed = Date.parse(review.submitted_at); - // Invalid/missing timestamps use -1 as a sentinel so they sort as older - // than valid submitted_at values. Deterministic ordering is preserved by - // the ID tie-breaker below. - return Number.isFinite(parsed) ? parsed : -1; - }; - return ( - reviews - .filter(review => { - const login = typeof review?.user?.login === "string" ? review.user.login.trim().toLowerCase() : ""; - const state = typeof review?.state === "string" ? review.state.trim().toUpperCase() : ""; - return login === expectedAuthorLower && dismissibleStates.has(state); - }) - .sort((a, b) => { - const aTime = getSubmittedAtTimestamp(a); - const bTime = getSubmittedAtTimestamp(b); - if (aTime !== bTime) return bTime - aTime; - return Number(b?.id || 0) - Number(a?.id || 0); - })[0] || null - ); + return reviews.filter(review => { + const login = typeof review?.user?.login === "string" ? review.user.login.trim().toLowerCase() : ""; + const state = typeof review?.state === "string" ? review.state.trim().toUpperCase() : ""; + return login === expectedAuthorLower && dismissibleStates.has(state); + }); } /** @@ -70,7 +53,7 @@ function hasNoRequestedReviewersOrTeams(pullRequest) { * @param {string} owner * @param {string} repo * @param {number} pullRequestNumber - * @returns {Promise} + * @returns {Promise<{reviews: any[], truncated: boolean}>} */ async function listAllPullRequestReviews(githubClient, owner, repo, pullRequestNumber) { const all = []; @@ -89,7 +72,7 @@ async function listAllPullRequestReviews(githubClient, owner, repo, pullRequestN if (data.length < 100) break; page++; } - return all; + return { reviews: all, truncated: page > maxPages }; } /** @@ -173,7 +156,7 @@ async function main(config = {}) { if (isStaged) { const previewReviewId = useAutoReviewId ? "auto" : reviewId; - logStagedPreviewInfo(`Would dismiss review #${previewReviewId} on PR #${pullRequestNumber} (${owner}/${repo}) as ${dismisser}`); + logStagedPreviewInfo(`Would dismiss ${useAutoReviewId ? "all actor-authored" : `review #${previewReviewId}`} on PR #${pullRequestNumber} (${owner}/${repo}) as ${dismisser}`); processedCount++; return { success: true, @@ -187,7 +170,7 @@ async function main(config = {}) { try { if (useAutoReviewId) { - const [{ data: pullRequest }, reviews] = await Promise.all([ + const [{ data: pullRequest }, { reviews, truncated }] = await Promise.all([ githubClient.rest.pulls.get({ owner, repo, @@ -196,8 +179,14 @@ async function main(config = {}) { listAllPullRequestReviews(githubClient, owner, repo, pullRequestNumber), ]); - const autoReview = findLatestDismissibleReviewForActor(reviews, expectedAuthor); - if (!autoReview || !Number.isInteger(autoReview.id) || autoReview.id <= 0) { + const actorReviews = findAllDismissibleReviewsForActor(reviews, expectedAuthor); + if (actorReviews.length === 0) { + if (truncated) { + return { + success: false, + error: `review_id=auto could not resolve: review history exceeds ${10 * 100} entries and was truncated; specify an explicit review_id instead`, + }; + } const isBlockedWithoutReviewers = hasNoRequestedReviewersOrTeams(pullRequest) && pullRequest?.mergeable_state === "blocked"; if (isBlockedWithoutReviewers) { return { @@ -210,7 +199,28 @@ async function main(config = {}) { error: `review_id=auto did not find a dismissible review authored by ${expectedAuthor}`, }; } - reviewId = autoReview.id; + + const dismissedReviews = []; + for (const review of actorReviews) { + const { data: dismissed } = await githubClient.rest.pulls.dismissReview({ + owner, + repo, + pull_number: pullRequestNumber, + review_id: review.id, + message: justification, + }); + dismissedReviews.push({ review_id: review.id, review_url: dismissed?.html_url || review?.html_url }); + } + + processedCount++; + return { + success: true, + review_ids: dismissedReviews.map(d => d.review_id), + dismissed_count: dismissedReviews.length, + pull_request_number: pullRequestNumber, + repo: `${owner}/${repo}`, + author: expectedAuthor, + }; } const { data: review } = await githubClient.rest.pulls.getReview({ diff --git a/actions/setup/js/dismiss_pull_request_review.test.cjs b/actions/setup/js/dismiss_pull_request_review.test.cjs index 6f06f9c6008..d4c932eb5bf 100644 --- a/actions/setup/js/dismiss_pull_request_review.test.cjs +++ b/actions/setup/js/dismiss_pull_request_review.test.cjs @@ -133,13 +133,7 @@ describe("dismiss_pull_request_review", () => { expect(mockDismissReview).not.toHaveBeenCalled(); }); - it("resolves review_id=auto to latest dismissible review by current actor", async () => { - mockGetReview.mockResolvedValueOnce({ - data: { - html_url: "https://github.com/test-owner/test-repo/pull/42#pullrequestreview-456", - user: { login: "github-actions[bot]" }, - }, - }); + it("resolves review_id=auto to all dismissible reviews by current actor", async () => { mockListReviews.mockResolvedValueOnce({ data: [ { @@ -164,12 +158,42 @@ describe("dismiss_pull_request_review", () => { }); expect(result.success).toBe(true); - expect(mockDismissReview).toHaveBeenCalledWith( - expect.objectContaining({ - pull_number: 42, - review_id: 456, - }) - ); + expect(result.dismissed_count).toBe(2); + expect(result.review_ids).toEqual(expect.arrayContaining([111, 456])); + expect(mockDismissReview).toHaveBeenCalledTimes(2); + expect(mockDismissReview).toHaveBeenCalledWith(expect.objectContaining({ pull_number: 42, review_id: 111 })); + expect(mockDismissReview).toHaveBeenCalledWith(expect.objectContaining({ pull_number: 42, review_id: 456 })); + }); + + it("dismisses only actor-authored reviews when review_id=auto (ignores other authors)", async () => { + mockListReviews.mockResolvedValueOnce({ + data: [ + { + id: 100, + state: "CHANGES_REQUESTED", + submitted_at: "2026-07-01T00:00:00Z", + user: { login: "octocat" }, + }, + { + id: 200, + state: "APPROVED", + submitted_at: "2026-07-02T00:00:00Z", + user: { login: "github-actions[bot]" }, + }, + ], + }); + + const result = await handler({ + type: "dismiss_pull_request_review", + review_id: "auto", + justification: "This stale review no longer reflects the updated implementation.", + }); + + expect(result.success).toBe(true); + expect(result.dismissed_count).toBe(1); + expect(result.review_ids).toEqual([200]); + expect(mockDismissReview).toHaveBeenCalledTimes(1); + expect(mockDismissReview).toHaveBeenCalledWith(expect.objectContaining({ review_id: 200 })); }); it("detects degenerate blocked state with no requested reviewers when review_id=auto has no candidate", async () => { @@ -233,12 +257,6 @@ describe("dismiss_pull_request_review", () => { }, ], }); - mockGetReview.mockResolvedValueOnce({ - data: { - html_url: "https://github.com/test-owner/test-repo/pull/42#pullrequestreview-1001", - user: { login: "github-actions[bot]" }, - }, - }); const result = await handler({ type: "dismiss_pull_request_review", @@ -270,12 +288,6 @@ describe("dismiss_pull_request_review", () => { }, ], }); - mockGetReview.mockResolvedValueOnce({ - data: { - html_url: "https://github.com/test-owner/test-repo/pull/42#pullrequestreview-777", - user: { login: "custom-bot" }, - }, - }); const result = await handler({ type: "dismiss_pull_request_review", @@ -303,6 +315,7 @@ describe("dismiss_pull_request_review", () => { }); expect(result.success).toBe(false); + expect(result.error).toContain("truncated"); expect(mockListReviews).toHaveBeenCalledTimes(10); }); }); diff --git a/actions/setup/js/safe_outputs_tools.json b/actions/setup/js/safe_outputs_tools.json index 6938c3bb34a..7682d13448a 100644 --- a/actions/setup/js/safe_outputs_tools.json +++ b/actions/setup/js/safe_outputs_tools.json @@ -524,7 +524,7 @@ "properties": { "review_id": { "type": ["number", "string"], - "description": "Numeric pull request review ID to dismiss (for example, 123456789), or 'auto' to resolve the latest dismissible review authored by the current workflow actor.", + "description": "Numeric pull request review ID to dismiss (for example, 123456789), or 'auto' to dismiss all dismissible (APPROVED or CHANGES_REQUESTED) reviews authored by the current workflow actor.", "x-synonyms": ["reviewId"] }, "justification": { diff --git a/pkg/workflow/js/safe_outputs_tools.json b/pkg/workflow/js/safe_outputs_tools.json index 6938c3bb34a..7682d13448a 100644 --- a/pkg/workflow/js/safe_outputs_tools.json +++ b/pkg/workflow/js/safe_outputs_tools.json @@ -524,7 +524,7 @@ "properties": { "review_id": { "type": ["number", "string"], - "description": "Numeric pull request review ID to dismiss (for example, 123456789), or 'auto' to resolve the latest dismissible review authored by the current workflow actor.", + "description": "Numeric pull request review ID to dismiss (for example, 123456789), or 'auto' to dismiss all dismissible (APPROVED or CHANGES_REQUESTED) reviews authored by the current workflow actor.", "x-synonyms": ["reviewId"] }, "justification": { diff --git a/schemas/agent-output.json b/schemas/agent-output.json index 38ab32024a8..c9064b6db12 100644 --- a/schemas/agent-output.json +++ b/schemas/agent-output.json @@ -413,7 +413,7 @@ }, "review_id": { "type": ["number", "string"], - "description": "The numeric review ID to dismiss, or 'auto' to resolve the latest dismissible review authored by the current workflow actor." + "description": "The numeric review ID to dismiss, or 'auto' to dismiss all dismissible (APPROVED or CHANGES_REQUESTED) reviews authored by the current workflow actor." }, "justification": { "type": "string", From 0a3d3235bd1e141346e878c032ba3f22d363c12a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 6 Jul 2026 02:19:16 +0000 Subject: [PATCH 4/5] refactor: extract MAX_REVIEW_PAGES constant for pagination cap Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/dismiss_pull_request_review.cjs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/actions/setup/js/dismiss_pull_request_review.cjs b/actions/setup/js/dismiss_pull_request_review.cjs index 762c117aa39..d60a3a5bbb6 100644 --- a/actions/setup/js/dismiss_pull_request_review.cjs +++ b/actions/setup/js/dismiss_pull_request_review.cjs @@ -48,6 +48,9 @@ function hasNoRequestedReviewersOrTeams(pullRequest) { return hasNoRequestedReviewers && hasNoRequestedTeams; } +/** Maximum number of pages (100 reviews/page) fetched when enumerating PR reviews. */ +const MAX_REVIEW_PAGES = 10; + /** * @param {any} githubClient * @param {string} owner @@ -57,9 +60,8 @@ function hasNoRequestedReviewersOrTeams(pullRequest) { */ async function listAllPullRequestReviews(githubClient, owner, repo, pullRequestNumber) { const all = []; - const maxPages = 10; let page = 1; - while (page <= maxPages) { + while (page <= MAX_REVIEW_PAGES) { const { data } = await githubClient.rest.pulls.listReviews({ owner, repo, @@ -72,7 +74,7 @@ async function listAllPullRequestReviews(githubClient, owner, repo, pullRequestN if (data.length < 100) break; page++; } - return { reviews: all, truncated: page > maxPages }; + return { reviews: all, truncated: page > MAX_REVIEW_PAGES }; } /** @@ -184,7 +186,7 @@ async function main(config = {}) { if (truncated) { return { success: false, - error: `review_id=auto could not resolve: review history exceeds ${10 * 100} entries and was truncated; specify an explicit review_id instead`, + error: `review_id=auto could not resolve: review history exceeds ${MAX_REVIEW_PAGES * 100} entries and was truncated; specify an explicit review_id instead`, }; } const isBlockedWithoutReviewers = hasNoRequestedReviewersOrTeams(pullRequest) && pullRequest?.mergeable_state === "blocked"; From 88b2ab52d4abbda9706fb79568ae91cd368be4d4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 6 Jul 2026 02:36:38 +0000 Subject: [PATCH 5/5] feat: make review_id optional, defaulting to auto when omitted Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .../setup/js/dismiss_pull_request_review.cjs | 4 ++-- .../js/dismiss_pull_request_review.test.cjs | 23 +++++++++++++++++++ actions/setup/js/safe_outputs_tools.json | 6 ++--- pkg/workflow/js/safe_outputs_tools.json | 6 ++--- schemas/agent-output.json | 4 ++-- 5 files changed, 33 insertions(+), 10 deletions(-) diff --git a/actions/setup/js/dismiss_pull_request_review.cjs b/actions/setup/js/dismiss_pull_request_review.cjs index d60a3a5bbb6..d5a65bf6090 100644 --- a/actions/setup/js/dismiss_pull_request_review.cjs +++ b/actions/setup/js/dismiss_pull_request_review.cjs @@ -101,8 +101,8 @@ async function main(config = {}) { }; } - const rawReviewId = String(message.review_id || "").trim(); - const useAutoReviewId = rawReviewId.toLowerCase() === "auto"; + const rawReviewId = String(message.review_id ?? "").trim(); + const useAutoReviewId = rawReviewId === "" || rawReviewId.toLowerCase() === "auto"; const parsedReviewId = Number.parseInt(rawReviewId, 10); if (!useAutoReviewId && (!Number.isInteger(parsedReviewId) || parsedReviewId <= 0)) { return { diff --git a/actions/setup/js/dismiss_pull_request_review.test.cjs b/actions/setup/js/dismiss_pull_request_review.test.cjs index d4c932eb5bf..970b35cae83 100644 --- a/actions/setup/js/dismiss_pull_request_review.test.cjs +++ b/actions/setup/js/dismiss_pull_request_review.test.cjs @@ -165,6 +165,29 @@ describe("dismiss_pull_request_review", () => { expect(mockDismissReview).toHaveBeenCalledWith(expect.objectContaining({ pull_number: 42, review_id: 456 })); }); + it("defaults to auto when review_id is omitted", async () => { + mockListReviews.mockResolvedValueOnce({ + data: [ + { + id: 789, + state: "APPROVED", + submitted_at: "2026-07-01T00:00:00Z", + user: { login: "github-actions[bot]" }, + }, + ], + }); + + const result = await handler({ + type: "dismiss_pull_request_review", + justification: "This stale review no longer reflects the updated implementation.", + }); + + expect(result.success).toBe(true); + expect(result.dismissed_count).toBe(1); + expect(result.review_ids).toEqual([789]); + expect(mockDismissReview).toHaveBeenCalledWith(expect.objectContaining({ review_id: 789 })); + }); + it("dismisses only actor-authored reviews when review_id=auto (ignores other authors)", async () => { mockListReviews.mockResolvedValueOnce({ data: [ diff --git a/actions/setup/js/safe_outputs_tools.json b/actions/setup/js/safe_outputs_tools.json index 7682d13448a..789b3387ded 100644 --- a/actions/setup/js/safe_outputs_tools.json +++ b/actions/setup/js/safe_outputs_tools.json @@ -517,14 +517,14 @@ }, { "name": "dismiss_pull_request_review", - "description": "Use this tool to dismiss a pull request review when the review is stale after automated updates. The review must be authored by the same workflow actor who is dismissing it. By default, the actor is inferred from the current GitHub Actions context (github.actor / GITHUB_ACTOR). You must provide a justification of at least 20 characters.", + "description": "Use this tool to dismiss a pull request review when the review is stale after automated updates. The review must be authored by the same workflow actor who is dismissing it. By default, the actor is inferred from the current GitHub Actions context (github.actor / GITHUB_ACTOR). If review_id is omitted, all dismissible reviews by the current actor are dismissed automatically. You must provide a justification of at least 20 characters.", "inputSchema": { "type": "object", - "required": ["review_id", "justification"], + "required": ["justification"], "properties": { "review_id": { "type": ["number", "string"], - "description": "Numeric pull request review ID to dismiss (for example, 123456789), or 'auto' to dismiss all dismissible (APPROVED or CHANGES_REQUESTED) reviews authored by the current workflow actor.", + "description": "Numeric pull request review ID to dismiss (for example, 123456789), or 'auto' to dismiss all dismissible (APPROVED or CHANGES_REQUESTED) reviews authored by the current workflow actor. Defaults to 'auto' when omitted.", "x-synonyms": ["reviewId"] }, "justification": { diff --git a/pkg/workflow/js/safe_outputs_tools.json b/pkg/workflow/js/safe_outputs_tools.json index 7682d13448a..789b3387ded 100644 --- a/pkg/workflow/js/safe_outputs_tools.json +++ b/pkg/workflow/js/safe_outputs_tools.json @@ -517,14 +517,14 @@ }, { "name": "dismiss_pull_request_review", - "description": "Use this tool to dismiss a pull request review when the review is stale after automated updates. The review must be authored by the same workflow actor who is dismissing it. By default, the actor is inferred from the current GitHub Actions context (github.actor / GITHUB_ACTOR). You must provide a justification of at least 20 characters.", + "description": "Use this tool to dismiss a pull request review when the review is stale after automated updates. The review must be authored by the same workflow actor who is dismissing it. By default, the actor is inferred from the current GitHub Actions context (github.actor / GITHUB_ACTOR). If review_id is omitted, all dismissible reviews by the current actor are dismissed automatically. You must provide a justification of at least 20 characters.", "inputSchema": { "type": "object", - "required": ["review_id", "justification"], + "required": ["justification"], "properties": { "review_id": { "type": ["number", "string"], - "description": "Numeric pull request review ID to dismiss (for example, 123456789), or 'auto' to dismiss all dismissible (APPROVED or CHANGES_REQUESTED) reviews authored by the current workflow actor.", + "description": "Numeric pull request review ID to dismiss (for example, 123456789), or 'auto' to dismiss all dismissible (APPROVED or CHANGES_REQUESTED) reviews authored by the current workflow actor. Defaults to 'auto' when omitted.", "x-synonyms": ["reviewId"] }, "justification": { diff --git a/schemas/agent-output.json b/schemas/agent-output.json index c9064b6db12..455e6e8e17b 100644 --- a/schemas/agent-output.json +++ b/schemas/agent-output.json @@ -413,7 +413,7 @@ }, "review_id": { "type": ["number", "string"], - "description": "The numeric review ID to dismiss, or 'auto' to dismiss all dismissible (APPROVED or CHANGES_REQUESTED) reviews authored by the current workflow actor." + "description": "The numeric review ID to dismiss, or 'auto' to dismiss all dismissible (APPROVED or CHANGES_REQUESTED) reviews authored by the current workflow actor. Defaults to 'auto' when omitted." }, "justification": { "type": "string", @@ -433,7 +433,7 @@ "description": "Optional target repository in owner/repo format." } }, - "required": ["type", "review_id", "justification"], + "required": ["type", "justification"], "additionalProperties": false }, "ResolvePullRequestReviewThreadOutput": {