diff --git a/actions/setup/js/dismiss_pull_request_review.cjs b/actions/setup/js/dismiss_pull_request_review.cjs index d5170b5e13f..d5a65bf6090 100644 --- a/actions/setup/js/dismiss_pull_request_review.cjs +++ b/actions/setup/js/dismiss_pull_request_review.cjs @@ -22,6 +22,61 @@ function getEffectiveActor() { return actor || "github-actions[bot]"; } +/** + * @param {any[]} reviews + * @param {string} expectedAuthor + * @returns {any[]} + */ +function findAllDismissibleReviewsForActor(reviews, expectedAuthor) { + if (!Array.isArray(reviews) || !expectedAuthor) return []; + const expectedAuthorLower = 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 === expectedAuthorLower && dismissibleStates.has(state); + }); +} + +/** + * @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; +} + +/** Maximum number of pages (100 reviews/page) fetched when enumerating PR reviews. */ +const MAX_REVIEW_PAGES = 10; + +/** + * @param {any} githubClient + * @param {string} owner + * @param {string} repo + * @param {number} pullRequestNumber + * @returns {Promise<{reviews: any[], truncated: boolean}>} + */ +async function listAllPullRequestReviews(githubClient, owner, repo, pullRequestNumber) { + const all = []; + let page = 1; + while (page <= MAX_REVIEW_PAGES) { + 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 { reviews: all, truncated: page > MAX_REVIEW_PAGES }; +} + /** * Main handler factory for dismiss_pull_request_review. * @type {HandlerFactoryFunction} @@ -46,13 +101,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 === "" || 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 +157,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 ${useAutoReviewId ? "all actor-authored" : `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 +171,60 @@ async function main(config = {}) { } try { + if (useAutoReviewId) { + const [{ data: pullRequest }, { reviews, truncated }] = await Promise.all([ + githubClient.rest.pulls.get({ + owner, + repo, + pull_number: pullRequestNumber, + }), + listAllPullRequestReviews(githubClient, owner, repo, pullRequestNumber), + ]); + + const actorReviews = findAllDismissibleReviewsForActor(reviews, expectedAuthor); + if (actorReviews.length === 0) { + if (truncated) { + return { + success: false, + 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"; + 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", + }; + } + return { + success: false, + error: `review_id=auto did not find a dismissible review authored by ${expectedAuthor}`, + }; + } + + 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({ 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..970b35cae83 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,213 @@ describe("dismiss_pull_request_review", () => { expect(result.error).toContain("review author"); expect(mockDismissReview).not.toHaveBeenCalled(); }); + + it("resolves review_id=auto to all dismissible reviews by current actor", async () => { + 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(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("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: [ + { + 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 () => { + 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(); + }); + + 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]" }, + }, + ], + }); + + 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" }, + }, + ], + }); + + 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(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 3a71c122049..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).", + "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/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..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).", + "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 82d7368c745..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." + "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": {