-
Notifications
You must be signed in to change notification settings - Fork 479
Add dismiss-review safe output with actor-bound PR review dismissal guards
#43125
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
69f60af
fea663c
6958b01
411ced0
76ecad6
797d4c3
d42f335
17c72fc
b344f46
6df8a21
af014be
41a302d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,163 @@ | ||
| // @ts-check | ||
| /// <reference types="@actions/github-script" /> | ||
|
|
||
| /** | ||
| * @typedef {import('./types/handler-factory').HandlerFactoryFunction} HandlerFactoryFunction | ||
| */ | ||
|
|
||
| const { getErrorMessage } = require("./error_helpers.cjs"); | ||
| const { resolveTarget, isStagedMode, logStagedPreviewInfo, checkRequiredFilter } = require("./safe_output_helpers.cjs"); | ||
| const { resolveTargetRepoConfig, resolveAndValidateRepo } = require("./repo_helpers.cjs"); | ||
| const { createAuthenticatedGitHubClient } = require("./handler_auth.cjs"); | ||
|
|
||
| /** @type {string} Safe output type handled by this module */ | ||
| const HANDLER_TYPE = "dismiss_pull_request_review"; | ||
|
|
||
| /** | ||
| * Resolve the effective actor used as both the dismisser and default expected author. | ||
| * @returns {string} | ||
| */ | ||
| function getEffectiveActor() { | ||
| const actor = (process.env.GITHUB_ACTOR || context?.actor || "github-actions[bot]").trim(); | ||
| return actor || "github-actions[bot]"; | ||
| } | ||
|
|
||
| /** | ||
| * Main handler factory for dismiss_pull_request_review. | ||
| * @type {HandlerFactoryFunction} | ||
| */ | ||
| async function main(config = {}) { | ||
| const maxCount = config.max || 10; | ||
| const targetConfig = config.target || "triggering"; | ||
| const isStaged = isStagedMode(config); | ||
| const { defaultTargetRepo, allowedRepos } = resolveTargetRepoConfig(config); | ||
| const githubClient = await createAuthenticatedGitHubClient(config); | ||
| const requiredLabels = Array.isArray(config.required_labels) ? config.required_labels : []; | ||
| const requiredTitlePrefix = config.required_title_prefix || ""; | ||
| const dismisser = getEffectiveActor(); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Actor is captured once at factory construction time, not per-call.
The test currently works around this by setting return async function handleDismissPullRequestReview(message) {
const dismisser = getEffectiveActor(); // evaluate per call
...
};@copilot please address this. |
||
|
|
||
| let processedCount = 0; | ||
|
|
||
| return async function handleDismissPullRequestReview(message) { | ||
| if (processedCount >= maxCount) { | ||
| return { | ||
| success: false, | ||
| error: `Max count of ${maxCount} reached`, | ||
| }; | ||
| } | ||
|
|
||
| const reviewId = Number.parseInt(String(message.review_id || ""), 10); | ||
| if (!Number.isInteger(reviewId) || reviewId <= 0) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
💡 Details and suggested fixThe Go-layer // Current (fragile — silently truncates "123abc" -> 123):
const reviewId = Number.parseInt(String(message.review_id || ""), 10);
if (!Number.isInteger(reviewId) || reviewId <= 0) { ... }
// Safer — reject if the full string is not a pure integer:
const rawId = String(message.review_id ?? "").trim();
const reviewId = Number(rawId);
if (!Number.isInteger(reviewId) || reviewId <= 0 || String(reviewId) !== rawId) {
return { success: false, error: "review_id must be a positive integer" };
}The same truncation bug is present in
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
const rawStr = String(message.review_id || "");
if (!/^\d+$/.test(rawStr)) {
return { success: false, error: "review_id must be a positive integer" };
}
const reviewId = Number.parseInt(rawStr, 10);@copilot please address this. |
||
| return { | ||
| success: false, | ||
| error: "review_id must be a positive integer", | ||
| }; | ||
| } | ||
|
|
||
| const justification = typeof message.justification === "string" ? message.justification.trim() : ""; | ||
| if (justification.length < 20) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Runtime handler lacks tests for its own The MCP handler test ( Add a test case like: it("rejects when justification is shorter than 20 characters", async () => {
const result = await handler({
type: "dismiss_pull_request_review",
review_id: 123,
justification: "too short",
});
expect(result.success).toBe(false);
expect(result.error).toContain("at least 20 characters");
expect(mockDismissReview).not.toHaveBeenCalled();
});@copilot please address this. |
||
| return { | ||
| success: false, | ||
| error: "justification must be at least 20 characters", | ||
| }; | ||
| } | ||
|
|
||
| const expectedAuthor = typeof message.author === "string" && message.author.trim().length > 0 ? message.author.trim() : dismisser; | ||
| if (expectedAuthor !== dismisser) { | ||
| return { | ||
| success: false, | ||
| error: `author must match the current workflow actor (${dismisser})`, | ||
| }; | ||
| } | ||
|
|
||
| const targetResult = resolveTarget({ | ||
| targetConfig, | ||
| item: message, | ||
| context, | ||
| itemType: "pull request review dismissal", | ||
| // In resolveTarget conventions, supportsPR=false means PR-only handlers. | ||
| supportsPR: false, | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Misleading inline comment on The comment reads: "In resolveTarget conventions, supportsPR=false means PR-only handlers." This is backwards: Suggested replacement: // supportsPR: false + supportsIssue: false (defaults) → PR-only handler per resolveTarget contract\nsupportsPR: false,\n```
@copilot please address this. |
||
| }); | ||
| if (!targetResult.success) { | ||
| return { | ||
| success: false, | ||
| error: targetResult.error, | ||
| }; | ||
| } | ||
| const pullRequestNumber = targetResult.number; | ||
|
|
||
| const repoResult = resolveAndValidateRepo(message, defaultTargetRepo, allowedRepos, "pull request review dismissal"); | ||
| if (!repoResult.success) { | ||
| return { | ||
| success: false, | ||
| error: repoResult.error, | ||
| }; | ||
| } | ||
| const { owner, repo } = repoResult.repoParts; | ||
|
|
||
| const filterResult = await checkRequiredFilter(githubClient, repoResult.repoParts, pullRequestNumber, requiredLabels, requiredTitlePrefix, HANDLER_TYPE); | ||
| if (filterResult) return filterResult; | ||
|
|
||
| if (isStaged) { | ||
| logStagedPreviewInfo(`Would dismiss review #${reviewId} on PR #${pullRequestNumber} (${owner}/${repo}) as ${dismisser}`); | ||
| processedCount++; | ||
| return { | ||
| success: true, | ||
| staged: true, | ||
| review_id: reviewId, | ||
| pull_request_number: pullRequestNumber, | ||
| repo: `${owner}/${repo}`, | ||
| author: expectedAuthor, | ||
| }; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Staged mode returns an unverified 💡 DetailsThe real mode fetches the review, validates This inconsistency makes staged output structurally misleading. Consider either:
There are no tests for staged mode, so this behavioral gap has no coverage. |
||
| } | ||
|
|
||
| try { | ||
| const { data: review } = await githubClient.rest.pulls.getReview({ | ||
| owner, | ||
| repo, | ||
| pull_number: pullRequestNumber, | ||
| review_id: reviewId, | ||
| }); | ||
|
|
||
| const reviewAuthorLogin = review?.user?.login; | ||
| if (typeof reviewAuthorLogin !== "string" || reviewAuthorLogin.trim() === "") { | ||
| return { | ||
| success: false, | ||
| error: "review author is unavailable for dismissal validation", | ||
| }; | ||
| } | ||
| const reviewAuthor = reviewAuthorLogin.trim(); | ||
| if (reviewAuthor !== expectedAuthor) { | ||
| return { | ||
| success: false, | ||
| error: `review author (${reviewAuthor || "unknown"}) must match dismisser (${dismisser})`, | ||
| }; | ||
| } | ||
|
|
||
| const { data: dismissed } = await githubClient.rest.pulls.dismissReview({ | ||
| owner, | ||
| repo, | ||
| pull_number: pullRequestNumber, | ||
| review_id: reviewId, | ||
| message: justification, | ||
| }); | ||
|
|
||
| processedCount++; | ||
| return { | ||
| success: true, | ||
| review_id: reviewId, | ||
| pull_request_number: pullRequestNumber, | ||
| repo: `${owner}/${repo}`, | ||
| author: reviewAuthor, | ||
| review_url: dismissed?.html_url || review?.html_url, | ||
| }; | ||
| } catch (error) { | ||
| return { | ||
| success: false, | ||
| error: getErrorMessage(error), | ||
| }; | ||
| } | ||
| }; | ||
| } | ||
|
|
||
| module.exports = { main, HANDLER_TYPE }; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,114 @@ | ||
| import { describe, it, expect, beforeEach, vi } from "vitest"; | ||
|
|
||
| const mockCore = { | ||
| debug: vi.fn(), | ||
| info: vi.fn(), | ||
| warning: vi.fn(), | ||
| error: vi.fn(), | ||
| setFailed: vi.fn(), | ||
| setOutput: vi.fn(), | ||
| summary: { | ||
| addRaw: vi.fn().mockReturnThis(), | ||
| write: vi.fn().mockResolvedValue(undefined), | ||
| }, | ||
| }; | ||
|
|
||
| global.core = mockCore; | ||
|
|
||
| const mockGetReview = vi.fn(); | ||
| const mockDismissReview = vi.fn(); | ||
|
|
||
| const mockGithub = { | ||
| rest: { | ||
| pulls: { | ||
| getReview: mockGetReview, | ||
| dismissReview: mockDismissReview, | ||
| }, | ||
| }, | ||
| }; | ||
|
|
||
| global.github = mockGithub; | ||
| global.context = { | ||
| actor: "github-actions[bot]", | ||
| eventName: "pull_request", | ||
| repo: { owner: "test-owner", repo: "test-repo" }, | ||
| payload: { pull_request: { number: 42 } }, | ||
| }; | ||
|
|
||
| describe("dismiss_pull_request_review", () => { | ||
| let handler; | ||
|
|
||
| beforeEach(async () => { | ||
| vi.resetModules(); | ||
| vi.clearAllMocks(); | ||
| process.env.GITHUB_ACTOR = "github-actions[bot]"; | ||
|
|
||
| mockGetReview.mockResolvedValue({ | ||
| data: { | ||
| html_url: "https://github.com/test-owner/test-repo/pull/42#pullrequestreview-123", | ||
| user: { login: "github-actions[bot]" }, | ||
| }, | ||
| }); | ||
| mockDismissReview.mockResolvedValue({ | ||
| data: { | ||
| html_url: "https://github.com/test-owner/test-repo/pull/42#pullrequestreview-123", | ||
| }, | ||
| }); | ||
|
|
||
| const { main } = require("./dismiss_pull_request_review.cjs"); | ||
| handler = await main({ max: 10 }); | ||
| }); | ||
|
|
||
| it("dismisses a review when author matches current actor", async () => { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Test suite covers only the happy path and two author-mismatch cases — critical paths are untested: the security contract of this handler rests on actor-identity enforcement and the max-count budget, yet neither has any test coverage. 💡 Missing test casesMinimum additional tests needed for a security-gating handler:
The first two are correctness/security; the rest are basic robustness coverage expected for any safe-output handler. |
||
| const result = await handler({ | ||
| type: "dismiss_pull_request_review", | ||
| review_id: 123, | ||
| justification: "This stale review no longer reflects the updated implementation.", | ||
| }); | ||
|
|
||
| expect(result.success).toBe(true); | ||
| expect(mockGetReview).toHaveBeenCalledWith( | ||
| expect.objectContaining({ | ||
| pull_number: 42, | ||
| review_id: 123, | ||
| }) | ||
| ); | ||
| expect(mockDismissReview).toHaveBeenCalledWith( | ||
| expect.objectContaining({ | ||
| pull_number: 42, | ||
| review_id: 123, | ||
| }) | ||
| ); | ||
| }); | ||
|
|
||
| it("rejects when provided author differs from current actor", async () => { | ||
| const result = await handler({ | ||
| type: "dismiss_pull_request_review", | ||
| review_id: 123, | ||
| author: "octocat", | ||
| justification: "This stale review no longer reflects the updated implementation.", | ||
| }); | ||
|
|
||
| expect(result.success).toBe(false); | ||
| expect(result.error).toContain("author must match the current workflow actor"); | ||
| expect(mockDismissReview).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it("rejects when fetched review author differs from current actor", async () => { | ||
| mockGetReview.mockResolvedValueOnce({ | ||
| data: { | ||
| user: { login: "octocat" }, | ||
| }, | ||
| }); | ||
|
|
||
| const result = await handler({ | ||
| type: "dismiss_pull_request_review", | ||
| review_id: 123, | ||
| justification: "This stale review no longer reflects the updated implementation.", | ||
| }); | ||
|
|
||
| expect(result.success).toBe(false); | ||
| expect(result.error).toContain("review author"); | ||
| expect(mockDismissReview).not.toHaveBeenCalled(); | ||
| }); | ||
| }); | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Stale actor snapshot breaks actor-identity coherence between MCP and runtime phases:
dismisseris captured once atmain()factory time, but the MCP pre-validation handler re-readsGITHUB_ACTORfresh on every call. If the environment changes between factory creation and invocation, the two layers enforce identity against different values.💡 Suggested fix
getEffectiveActor()is a pure, side-effect-free function — there is no reason to cache its result. Moving the call inside the returned handler makes both layers consistent:Note: the test suite sets
process.env.GITHUB_ACTORinbeforeEachafter the module is required — becausemain()is called insidebeforeEachtoo, the tests would catch a per-call read correctly, but they do not surface the drift caused by a factory-time snapshot.