Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
163 changes: 163 additions & 0 deletions actions/setup/js/dismiss_pull_request_review.cjs
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();

Copy link
Copy Markdown
Contributor

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: dismisser is captured once at main() factory time, but the MCP pre-validation handler re-reads GITHUB_ACTOR fresh 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:

// Remove the factory-level capture:
// const dismisser = getEffectiveActor();  // <-- DELETE

return async function handleDismissPullRequestReview(message) {
  const dismisser = getEffectiveActor(); // read per invocation
  // ... rest unchanged ...
};

Note: the test suite sets process.env.GITHUB_ACTOR in beforeEach after the module is required — because main() is called inside beforeEach too, the tests would catch a per-call read correctly, but they do not surface the drift caused by a factory-time snapshot.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actor is captured once at factory construction time, not per-call.

dismisser is resolved when main() is called (once per handler lifecycle), not when each message is handled. In most deployments this is fine, but if GITHUB_ACTOR changes after the factory is constructed (e.g., in tests that mutate the environment between calls without re-instantiating the handler), the guard silently uses a stale actor value.

The test currently works around this by setting process.env.GITHUB_ACTOR before calling main() in beforeEach, but calling vi.resetModules() doesn't help if the same handler instance is reused. Consider documenting this contract explicitly, or moving the getEffectiveActor() call inside the inner handler function so it's evaluated per-message:

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) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

parseInt silently truncates malformed review IDs: Number.parseInt("123abc", 10) returns 123 — the trailing non-numeric characters are silently dropped, so the handler proceeds against review 123 instead of rejecting the input.

💡 Details and suggested fix

The Go-layer IsPositiveInteger check (which uses strconv.ParseInt) runs before runtime execution and would reject "123abc". However the runtime handler should not rely on upstream validation as its only guard — a defensive strict check here prevents silent data corruption if the validation pipeline changes or is bypassed.

// 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 safe_output_type_validator.cjs's validatePositiveInteger, but that is pre-existing and outside this diff.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

review_id float inputs are silently truncated.

parseInt("123.5", 10) returns 123, which then satisfies Number.isInteger(123). A caller passing a float review_id would silently target a different review. Consider rejecting non-integer string inputs that contain a decimal:

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) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Runtime handler lacks tests for its own justification < 20 guard.

The MCP handler test (safe_outputs_handlers.test.cjs) validates the MCP-layer rejection of short justifications, but dismiss_pull_request_review.test.cjs (the runtime handler) has no test exercising line 58's rejection path. Since the runtime handler runs in the GitHub Actions step (not the MCP phase), this code path is untested end-to-end.

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,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Misleading inline comment on supportsPR: false.

The comment reads: "In resolveTarget conventions, supportsPR=false means PR-only handlers." This is backwards: supportsPR=false does not mean PR-only by positive assertion. It means the handler does not support issues. PR-only is the implied default when both supportsPR and supportsIssue are false. The JSDoc for resolveTarget says: "When false, handler supports PRs ONLY." The logic is correct; the comment is just confusing and could lead to future misreads.

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,
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Staged mode returns an unverified author field: in staged mode the handler skips the getReview API call (correct — staged mode avoids all API writes), but the success response includes author: expectedAuthor which is the caller-supplied or actor-derived value, not the actual review author on GitHub. A caller relying on the staged response to validate author identity before promoting to real mode gets a false positive.

💡 Details

The real mode fetches the review, validates review.user.login === expectedAuthor, then returns author: reviewAuthor (the API-verified value). The staged mode returns author: expectedAuthor (the local assumption) without any API verification.

This inconsistency makes staged output structurally misleading. Consider either:

  1. Omitting the author field from staged responses (consumers can't trust it anyway), or
  2. Adding a comment like // author unverified in staged mode to make the gap explicit.

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 };
114 changes: 114 additions & 0 deletions actions/setup/js/dismiss_pull_request_review.test.cjs
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 () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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 cases

Minimum additional tests needed for a security-gating handler:

  1. Max count exhaustion — create handler with max: 1, call it twice; second call must return { success: false, error: /Max count/ }.
  2. Invalid review_id — missing, zero, negative, and string "0" should all fail with review_id must be a positive integer.
  3. Staged mode — handler with staged: true must not call getReview or dismissReview, must return success: true, staged: true.
  4. Short justificationjustification: "short" (< 20 chars) must fail before any API call.
  5. Review author unavailablegetReview returns { data: { user: null } }; handler must return the unavailability error.

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();
});
});
2 changes: 2 additions & 0 deletions actions/setup/js/safe_output_handler_manager.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ const HANDLER_MAP = {
update_release: "./update_release.cjs",
create_pull_request_review_comment: "./create_pr_review_comment.cjs",
submit_pull_request_review: "./submit_pr_review.cjs",
dismiss_pull_request_review: "./dismiss_pull_request_review.cjs",
reply_to_pull_request_review_comment: "./reply_to_pr_review_comment.cjs",
resolve_pull_request_review_thread: "./resolve_pr_review_thread.cjs",
create_pull_request: "./create_pull_request.cjs",
Expand Down Expand Up @@ -164,6 +165,7 @@ const THREAT_WARNING_ABORT_TYPES = new Set([
"merge_pull_request",
"mark_pull_request_as_ready_for_review",
"resolve_pull_request_review_thread",
"dismiss_pull_request_review",
"add_labels",
"remove_labels",
"add_reviewer",
Expand Down
26 changes: 26 additions & 0 deletions actions/setup/js/safe_outputs_handlers.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -1978,6 +1978,31 @@ function createHandlers(server, appendSafeOutput, config = {}) {
return defaultHandler("submit_pull_request_review")(args);
};

/**
* Handler for dismiss_pull_request_review tool (MCP server phase).
* Enforces justification minimum length and actor-author consistency before recording.
*/
const dismissPullRequestReviewHandler = args => {
const justification = (args && typeof args.justification === "string" ? args.justification : "").trim();
if (justification.length < 20) {
throw {
code: -32602,
message: `${ERR_VALIDATION}: dismiss_pull_request_review: 'justification' must be at least 20 characters`,
};
}

const actor = (process.env.GITHUB_ACTOR || context?.actor || "github-actions[bot]").trim() || "github-actions[bot]";
const author = args && typeof args.author === "string" ? args.author.trim() : "";
if (author && author !== actor) {
throw {
code: -32602,
message: `${ERR_VALIDATION}: dismiss_pull_request_review: 'author' must match current workflow actor (${actor})`,
};
}

return defaultHandler("dismiss_pull_request_review")(args);
};

/**
* Recursively copy all regular files from srcDir into destDir, preserving the relative
* path structure under srcDir. Non-regular entries (sockets, devices, pipes, symlinks)
Expand Down Expand Up @@ -2187,6 +2212,7 @@ function createHandlers(server, appendSafeOutput, config = {}) {
addCommentHandler,
createPullRequestReviewCommentHandler,
submitPullRequestReviewHandler,
dismissPullRequestReviewHandler,
updateIssueHandler,
updatePullRequestHandler,
};
Expand Down
50 changes: 50 additions & 0 deletions actions/setup/js/safe_outputs_handlers.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ describe("safe_outputs_handlers", () => {
delete process.env.GH_AW_ASSETS_BRANCH;
delete process.env.GH_AW_ASSETS_MAX_SIZE_KB;
delete process.env.GH_AW_ASSETS_ALLOWED_EXTS;
delete process.env.GITHUB_ACTOR;
});

describe("probe intent helpers", () => {
Expand Down Expand Up @@ -2707,6 +2708,55 @@ describe("safe_outputs_handlers", () => {
});
});

describe("dismissPullRequestReviewHandler", () => {
it("should write entry and return success with valid justification", () => {
const result = handlers.dismissPullRequestReviewHandler({
review_id: 123,
justification: "This stale review no longer matches the latest patch.",
});

const data = JSON.parse(result.content[0].text);
expect(data.result).toBe("success");
expect(mockAppendSafeOutput).toHaveBeenCalledWith(
expect.objectContaining({
type: "dismiss_pull_request_review",
review_id: 123,
})
);
});

it("should throw MCP error when justification is shorter than 20 characters", () => {
expect(() =>
handlers.dismissPullRequestReviewHandler({
review_id: 123,
justification: "too short",
})
).toThrow(
expect.objectContaining({
code: -32602,
message: expect.stringContaining("at least 20 characters"),
})
);
});

it("should throw MCP error when author does not match current workflow actor", () => {
process.env.GITHUB_ACTOR = "github-actions[bot]";

expect(() =>
handlers.dismissPullRequestReviewHandler({
review_id: 123,
justification: "This stale review no longer matches the latest patch.",
author: "octocat",
})
).toThrow(
expect.objectContaining({
code: -32602,
message: expect.stringContaining("must match current workflow actor"),
})
);
});
});

describe("createPullRequestReviewCommentHandler", () => {
it("should write entry and return success", () => {
const result = handlers.createPullRequestReviewCommentHandler({ path: "src/foo.js", line: 5, body: "Consider renaming." });
Expand Down
Loading
Loading