Files
shockbot/mcp/review.ts
T
Colin McDonnell 3a7145db1a Scope installation token permissions in restricted mode (#226)
* Scope installation token permissions in restricted mode

In restricted/disabled bash mode, the installation token is now scoped
to match the workflow's permissions block. This preserves fork push
capability while limiting what the agent can do with the token.

- Read workflow permissions from pullfrog.yml at runtime
- Pass permissions to API when acquiring installation token
- Clear OIDC env vars in restricted mode to prevent token minting
- Simplify setupGit by moving token resolution to main.ts

* Address review feedback: fail closed with default permissions

- Add restrictive default permissions (contents:read, pull_requests:read,
  issues:read) as fallback when workflow permissions can't be read
- Add support for job-level permissions via GITHUB_JOB env var
- Fix misleading comment about token resolution in restricted mode
- Add documentation about fork PR checkout behavior

* Simplify to separate git/MCP tokens without workflow permission scoping

- gitToken: minimal contents:write only (assumed exfiltratable)
- mcpToken: full installation token (not exfiltratable via MCP tools)
- Remove workflowPermissions.ts - security-conscious users can pass
  their own token via GH_TOKEN or inputs.token
- Add type-safe InstallationTokenPermissions to github.ts and API route

* Rename `write` permission to `push` and remove vestigial tool blocking

The `write` permission was previously used to block local file write tools
in agents. This was security theater since bash can write files anyway.

Now `push` only controls the git token scope:
- push: enabled → contents:write (can push commits)
- push: disabled → contents:read (read-only, can't push)

Changes:
- Rename `write` to `push` in action.yml, Prisma schema, and all TS types
- Remove vestigial write tool blocking from all agents (claude, cursor,
  gemini, opencode, codex)
- Add data-preserving Prisma migration using RENAME COLUMN
- Update UI: "Write files" → "Git push" with updated description

* add PID namespace isolation for bash sandbox

when running in CI, attempts to use unshare --pid to create a new PID
namespace for bash subprocesses. this prevents the /proc/$PPID/environ
attack where a malicious command could read secrets from the parent
process's environment.

the protection works by:
1. creating a new PID namespace (subprocess becomes PID 1)
2. mounting fresh /proc showing only sandbox PIDs
3. parent PIDs become invisible (PPID = 0, /proc/0 doesn't exist)

combined with filterEnv(), this provides complete protection against
/proc-based secret theft. falls back gracefully if namespaces aren't
available.

includes test script to verify the protection works.

* add PID namespace test to CI workflow

tests whether unshare --pid works on GHA runners out of the box,
and if not, whether enabling via sysctl helps. also runs the
pidNamespace.ts test to verify the full protection.

* fix pnpm setup and add procIsolation agent test

- fix pnpm/action-setup by specifying package_json_file path
- add procIsolation crossagent test that has agent attempt to
  read secrets via /proc/$PPID/environ
- add procIsolation to CI test matrix

* add pid-namespace test job to main workflow

this job tests unshare --pid capabilities on GHA runners and runs
the pidNamespace.ts adhoc test to verify /proc isolation works

* test bubblewrap's sysctl approach for enabling namespaces

- write to /etc/sysctl.d/99-userns.conf and run sysctl --system
- try aa-complain on unshare binary
- more detailed diagnostics

* fix pidNamespace test and add sudo-unshare fallback for GHA

- fix reference error in pidNamespace.ts (renamed function but didn't update calls)
- add sudo-unshare as fallback method for GHA runners where unprivileged
  namespaces are blocked but sudo is available
- update bash.ts to detect and use sudo unshare when unprivileged fails

* consolidate security docs and document PID namespace isolation

- update security.md with current implementation details
  - document sudo unshare fallback for GHA runners
  - add testing instructions for local Docker and CI
  - add "Further Exploration" section with Landlock and path validation ideas
- delete bash-sandbox.md and landlock.md (consolidated into security.md)

* move procIsolation test to adhoc folder

the procIsolation test requires PID namespace capabilities that aren't
available in the Docker test environment. moved to adhoc/ so it's excluded
from default test runs and can be run explicitly when needed (e.g. via
the pid-namespace CI job or locally with --privileged docker).

* fix Docker test environment for PID namespace isolation

- add CI and GITHUB_ACTIONS to testEnvAllowList so sandbox detection runs
- add --privileged to Docker run for PID namespace support (unshare)

this fixes the test environment to properly test the sandbox. in production,
the action runs directly on GHA runner where sudo unshare works.

* fix getJobToken() to work in test environment

add fallback to GH_TOKEN and GITHUB_TOKEN when INPUT_TOKEN is not set.
this allows tests to run without requiring workflow-level token input.

the token resolution order is:
1. INPUT_TOKEN (from workflow `with: token:`)
2. GH_TOKEN (external token override)
3. GITHUB_TOKEN (pre-acquired in tests or from GHA env)

* security: filter secrets from all subprocess environments

- extract filterEnv() to shared utils/secrets.ts
- make $() utility filter secrets by default (git, npm, etc. don't need them)
- disable git hooks via core.hooksPath to prevent hook-based exfiltration
- git auth uses token embedded in URL, not env vars

this prevents malicious git hooks, npm postinstall scripts, and other
code execution vectors from exfiltrating GITHUB_TOKEN and API keys.

* docs: clarify defense-in-depth security model

update security.md to explain why BOTH layers are required:
- filterEnv(): cleans child's own /proc/self/environ
- PID namespace: hides parent's /proc entries

PID namespace alone isn't sufficient - with --mount-proc, the child
becomes PID 1, so /proc/1/environ is the child's OWN environment.
without filterEnv(), secrets would still be accessible there.

* add procSandbox crossagent test for PID namespace security

- add crossagent/procSandbox.ts: security test that instructs agent to try
  various /proc attack vectors and validates sandbox blocks them
- update wiki/security.md: document PID namespace isolation details, add
  verification commands, explain why sudo inside sandbox doesn't break security
- update docker.ts: use node:24 with sudo for GHA-like test environment
- update instructions.ts: allow disabling security messaging for pentests
- clean up adhoc test files (procIsolation.ts, securityAudit.ts)

the procSandbox test sets SANDBOX_TEST_TOKEN (matches *_TOKEN filter), gives
the agent explicit hints about attack vectors (/proc, sudo, nsenter, etc.),
disables prompt-based mitigations, and validates the secret is never leaked.

* move procSandbox test to agnostic/ (runs with one agent)

* WIP

* docs: add agent testing guide (pnpm play, Docker, pentesting)

* docs: add CI details to agent testing guide

* docs: add interesting findings and gotchas from pentesting

* improve test fidelity: auto-set CI=true, verify sandbox active

- docker.ts: always set CI=true in container so sandbox activates
- docker.ts: skip sudo/user setup if already done (faster reruns)
- bash.ts: export getSandboxMethod() for diagnostics
- bash.ts: add debug log when sandbox disabled
- procSandbox.ts: add sandbox_was_active check to catch vacuous passes

the CI=true change is critical: without it, PID namespace isolation
is skipped and security tests pass without actually testing anything.

* docs: update agent-testing.md with CI=true auto-set note

* docs: clarify log format is agent-specific

* fix git auth, simplify MCP tools, add adversarial tests

- fix gitWithAuth to use Basic auth format (Bearer doesn't work with git's http.extraheader)
- fix token.ts: push:restricted now correctly gets contents:write
- fix github.ts: pass permissions when acquiring installation tokens locally
- remove commit_files and create_branch MCP tools (redundant, don't require credentials)
- remove containsSecrets() - trivially bypassable, not sound security
- add agnostic adversarial tests: pushDisabled, pushRestricted, tokenExfil
- update instructions.ts to clearly list available git MCP tools
- add wiki/git.md documenting credential isolation and push permission tiers
- update wiki/docker.md with custom image considerations
- update wiki/agent-testing.md with adversarial testing patterns

* fix type errors after rebase

- change ResolveTokensParams.push from ToolPermission to PushPermission
- use tags: ["agnostic"] instead of agnostic: true in test files

* fix cleanup permission error in sandbox tests

when sandbox isolation is enabled (CI=true), files created by the unshare
subprocess may have different ownership, causing rmSync to fail with EACCES.
this error in the finally block was overriding the test's success result.

fix: wrap cleanup in try-catch and fall back to sudo rm if rmSync fails.

* Add adhoc

* Handle git config/remote bypasses

* add git hooks protection and simplify ToolState

- disable git hooks in restricted mode via -c core.hooksPath=/dev/null
- add gitHooks adversarial test to verify hook protection works
- unify prNumber/issueNumber into single issueNumber field
- add pushUrl to ToolState for push validation
- add generateTestMarker() for simpler single-agent test markers
- export SENSITIVE_PATTERNS and isSensitiveEnvName from secrets.ts
- remove redundant pidNamespace.ts (duplicated by procSandbox.ts)
- update documentation

* harden $git() auth: subcommand whitelist, binary tamper detection

- rename gitWithAuth() to $git() with explicit subcommand first arg
- restrict to "fetch" | "push" at type level (filters don't run for these)
- resolve git binary path at startup via resolveGit(), sha256 fingerprint
- verify hash before each $git() call to detect binary replacement
- rename disableHooks to restricted for cleaner semantics
- document filter exfiltration attack and empirical verification in wiki

* remove redundant pid-namespace CI job

the PID namespace isolation testing is now handled by
action/test/agnostic/procSandbox.ts via pnpm runtest agnostic

* fix push_branch for new branches and improve token leak detection

- getPushDestination now falls back to origin/<branch> when @{push}
  is not configured (happens for new branches created locally)
- gitPerms validator now checks for actual token patterns instead
  of matching "x-access-token" string in test instructions

* use kebab-case for test names

* simplify shell env API: "restricted" | "inherit" | object

replace passFullEnv boolean with cleaner env option that accepts:
- "restricted" (default): filterEnv() to prevent secret leakage
- "inherit": full process.env
- object: custom env merged with restricted base

* share EnvMode and resolveEnv between shell.ts and bash.ts

move shared env resolution logic to secrets.ts

* add env option to bash tool (default: restricted)

* delete agent-testing.md (renamed to adversarial.md)

* Add checkout tests

* reframe githooks test prompt to avoid claude safety refusal

claude was refusing to execute the test because the prompt used words
like "malicious" and "security testing". reframed as a debugging task
with innocuous env var name (TESTING_DEBUG_TAG) per adversarial.md guidance.

Co-authored-by: Cursor <cursoragent@cursor.com>

* clean up verbose token acquisition logs

move logging responsibility to call sites which have better context
(git token vs MCP token). remove redundant intermediate OIDC logs
and unused "(permission-scoped)" suffix.

Co-authored-by: Cursor <cursoragent@cursor.com>

* isolate agnostic tests with matrix strategy, fix .pullfrog-env secret leak

- split action-agnostic into per-test matrix jobs for isolated logs and filesystems
- only write explicitly opted-in env vars to .pullfrog-env via fileAgentEnv
  (fixes token-exfil test where claude found SANDBOX_TEST_TOKEN on disk)
- mcpmerge test opts in via fileAgentEnv for cursor's repo-level MCP fallback

Co-authored-by: Cursor <cursoragent@cursor.com>

* remove env parameter from bash tool to prevent agents bypassing filterEnv

the bash tool exposed an `env` parameter accepting "restricted" | "inherit"
which allowed agents to pass env: "inherit" and see all secrets including
SANDBOX_TEST_TOKEN, bypassing the restricted environment filtering entirely.
env mode is now determined internally (always restricted).

Co-authored-by: Cursor <cursoragent@cursor.com>

* use pullfrog/test-repo for push tests to stop polluting main repo

push tests were creating branches and tags on pullfrog/app directly.
now all push tests (push-restricted, push-disabled, push-enabled,
git-permissions) target pullfrog/test-repo instead.

Co-authored-by: Cursor <cursoragent@cursor.com>

* use pullfrog/test-repo for all tests, not just push tests

no test should clone or operate on pullfrog/app directly.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix token scoping for test-repo and bash timeout defaults

- acquireTokenViaOIDC now includes GITHUB_REPOSITORY repo in token
  scope so push tests work against pullfrog/test-repo
- bash tool default timeout: 120s -> 30s, cap: 600s -> 120s
- activity timeout: 30s -> 60s
- prevents hung bash commands (e.g. find /) from killing the agent
  via activity timeout

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-06 06:26:26 +00:00

456 lines
17 KiB
TypeScript

import type { RestEndpointMethodTypes } from "@octokit/rest";
import { type } from "arktype";
import { buildPullfrogFooter } from "../utils/buildPullfrogFooter.ts";
import { log } from "../utils/cli.ts";
import { deleteProgressComment } from "./comment.ts";
import type { ToolContext } from "./server.ts";
import { execute, tool } from "./shared.ts";
// one-shot review tool
export const CreatePullRequestReview = type({
pull_number: type.number.describe("The pull request number to review"),
body: type.string
.describe(
"1-2 sentence high-level summary with urgency level, critical callouts, and feedback about code outside the diff. Specific feedback on diff lines goes in 'comments' array."
)
.optional(),
commit_id: type.string
.describe("Optional SHA of the commit being reviewed. Defaults to latest.")
.optional(),
comments: type({
path: type.string.describe("The file path to comment on (relative to repo root)"),
line: type.number.describe(
"End line of the comment range. For single-line comments, set equal to 'start_line'. Use NEW column from diff format."
),
side: type
.enumerated("LEFT", "RIGHT")
.describe(
"Side of the diff: LEFT (old code, lines starting with -) or RIGHT (new code, lines starting with + or unchanged). Defaults to RIGHT."
)
.optional(),
body: type.string
.describe("Explanatory comment text (optional if suggestion is provided)")
.optional(),
suggestion: type.string
.describe(
"Full replacement code for the line range [start_line, line]. MUST preserve the exact indentation of the original code."
)
.optional(),
start_line: type.number.describe(
"Start line of the comment range. For single-line comments, set equal to 'line'. The range [start_line, line] defines which lines a suggestion replaces."
),
})
.array()
.describe(
"Inline comments on lines within diff hunks. Feedback about code outside the diff goes in 'body' instead."
)
.optional(),
});
export function CreatePullRequestReviewTool(ctx: ToolContext) {
return tool({
name: "create_pull_request_review",
description:
"Submit a review for an existing pull request. " +
"IMPORTANT: 95%+ of feedback should be in 'comments' array with file paths and line numbers. " +
"Only use 'body' for a 1-2 sentence summary with urgency and critical callouts. " +
"Use 'suggestion' to propose replacement code - MUST preserve exact indentation of original code. " +
"Example replacing lines 42-44 (3 lines) with 5 lines: " +
`{ path: 'src/api.ts', start_line: 42, line: 44, suggestion: ' const result = await fetch(url);\\n if (!result.ok) {\\n log.error(result.status);\\n throw new Error("request failed");\\n }' }`,
parameters: CreatePullRequestReview,
execute: execute(async ({ pull_number, body, commit_id, comments = [] }) => {
// set issue context (PRs are issues)
ctx.toolState.issueNumber = pull_number;
// compose the request
const params: RestEndpointMethodTypes["pulls"]["createReview"]["parameters"] = {
owner: ctx.repo.owner,
repo: ctx.repo.name,
pull_number,
event: "COMMENT",
};
if (body) params.body = body;
if (commit_id) {
params.commit_id = commit_id;
} else {
// get the PR to determine the head commit if commit_id not provided
const pr = await ctx.octokit.rest.pulls.get({
owner: ctx.repo.owner,
repo: ctx.repo.name,
pull_number,
});
params.commit_id = pr.data.head.sha;
}
if (comments.length > 0) {
type ReviewComment = (typeof params.comments & {})[number];
// convert comments to the format expected by GitHub API
params.comments = comments.map((comment) => {
// build comment body with suggestion block if provided
let commentBody = comment.body || "";
if (comment.suggestion !== undefined) {
const suggestionBlock = "```suggestion\n" + comment.suggestion + "\n```";
commentBody = commentBody ? commentBody + "\n\n" + suggestionBlock : suggestionBlock;
}
const side = comment.side || "RIGHT";
const reviewComment: ReviewComment = {
path: comment.path,
line: comment.line,
body: commentBody,
side,
start_line: comment.start_line,
start_side: side,
};
return reviewComment;
});
}
const result = await ctx.octokit.rest.pulls.createReview(params);
log.debug(`createReview response: ${JSON.stringify(result.data)}`);
if (!result.data.id) {
throw new Error(`createReview returned invalid data: ${JSON.stringify(result.data)}`);
}
const reviewId = result.data.id;
// build quick links footer and update the review body
// only include "Fix all" and "Fix 👍s" links if there are actual review comments
const customParts: string[] = [];
if (comments.length > 0) {
const apiUrl = process.env.API_URL || "https://pullfrog.com";
const fixAllUrl = `${apiUrl}/trigger/${ctx.repo.owner}/${ctx.repo.name}/${pull_number}?action=fix&review_id=${reviewId}`;
const fixApprovedUrl = `${apiUrl}/trigger/${ctx.repo.owner}/${ctx.repo.name}/${pull_number}?action=fix-approved&review_id=${reviewId}`;
customParts.push(`[Fix all ➔](${fixAllUrl})`, `[Fix 👍s ➔](${fixApprovedUrl})`);
}
const footer = buildPullfrogFooter({
workflowRun: {
owner: ctx.repo.owner,
repo: ctx.repo.name,
runId: ctx.runId,
jobId: ctx.jobId,
},
customParts,
});
const updatedBody = (body || "") + footer;
// update the review with the footer
await ctx.octokit.rest.pulls.updateReview({
owner: ctx.repo.owner,
repo: ctx.repo.name,
pull_number,
review_id: reviewId,
body: updatedBody,
});
await deleteProgressComment(ctx);
return {
success: true,
reviewId,
html_url: result.data.html_url,
state: result.data.state,
user: result.data.user?.login,
submitted_at: result.data.submitted_at,
};
}),
});
}
// =============================================================================
// COMMENTED OUT: Three-step review flow (start_review, add_review_comment, submit_review)
// This approach used GraphQL to add comments to a pending review one-by-one,
// but GitHub's API was returning null for valid lines. Keeping for reference.
// =============================================================================
/*
// graphql mutation to add a comment thread to a pending review
// note: REST API doesn't support adding comments to an existing pending review
const ADD_PULL_REQUEST_REVIEW_THREAD = `
mutation AddPullRequestReviewThread($pullRequestReviewId: ID!, $path: String!, $line: Int!, $body: String!, $side: DiffSide, $subjectType: PullRequestReviewThreadSubjectType) {
addPullRequestReviewThread(input: {
pullRequestReviewId: $pullRequestReviewId,
path: $path,
line: $line,
body: $body,
side: $side,
subjectType: $subjectType
}) {
thread {
id
}
}
}
`;
type AddPullRequestReviewThreadResponse = {
addPullRequestReviewThread: {
thread: {
id: string;
};
};
};
// helper to find existing pending review for the authenticated user
async function findPendingReview(
ctx: ToolContext,
pull_number: number
): Promise<{ id: number; node_id: string } | null> {
const reviews = await ctx.octokit.rest.pulls.listReviews({
owner: ctx.repo.owner,
repo: ctx.repo.name,
pull_number,
per_page: 100,
});
// find a PENDING review from our bot
// note: authenticated user is the GitHub App, reviews show as "pullfrog[bot]"
const pendingReview = reviews.data.find((r) => r.state === "PENDING");
if (pendingReview) {
return { id: pendingReview.id, node_id: pendingReview.node_id };
}
return null;
}
// start_review tool
export const StartReview = type({
pull_number: type.number.describe("The pull request number to review"),
});
export function StartReviewTool(ctx: ToolContext) {
return tool({
name: "start_review",
description:
"Start a new review session for a pull request. Creates a pending review on GitHub. Must be called before add_review_comment.",
parameters: StartReview,
execute: execute(async ({ pull_number }) => {
// check if review already started in this session
if (ctx.toolState.review) {
throw new Error(
`Review session already in progress. Call submit_review first to finish it.`
);
}
// get the PR to get head commit SHA
const pr = await ctx.octokit.rest.pulls.get({
owner: ctx.repo.owner,
repo: ctx.repo.name,
pull_number,
});
let reviewId: number;
let reviewNodeId: string;
// try to create a new pending review (omitting 'event' creates PENDING state)
log.debug(`creating pending review for PR #${pull_number}...`);
try {
const result = await ctx.octokit.rest.pulls.createReview({
owner: ctx.repo.owner,
repo: ctx.repo.name,
pull_number,
commit_id: pr.data.head.sha,
// no 'event' = PENDING review
});
log.debug(`createReview response: ${JSON.stringify(result.data)}`);
if (!result.data.id || !result.data.node_id) {
log.debug(result);
throw new Error(
`createReview returned invalid data: id=${result.data.id}, node_id=${result.data.node_id}`
);
}
reviewId = result.data.id;
reviewNodeId = result.data.node_id;
log.debug(`created new pending review: id=${reviewId}`);
} catch (error) {
// check for "already has pending review" error
const errorMessage = error instanceof Error ? error.message : String(error);
log.debug(`createReview failed: ${errorMessage}`);
if (errorMessage.includes("pending review")) {
// find the existing pending review
log.debug(`pending review already exists, fetching existing review...`);
const existing = await findPendingReview(ctx, pull_number);
if (!existing) {
throw new Error(
"GitHub says a pending review exists but we couldn't find it. Try again or check the PR reviews."
);
}
reviewId = existing.id;
reviewNodeId = existing.node_id;
log.debug(`reusing existing pending review: id=${reviewId}`);
} else {
throw error;
}
}
// set issue context (PRs are issues) and review state
ctx.toolState.issueNumber = pull_number;
ctx.toolState.review = {
nodeId: reviewNodeId,
id: reviewId,
};
log.debug(`review session started: id=${reviewId}, nodeId=${reviewNodeId}`);
return {
message: `Review session started for PR #${pull_number}. Add comments with add_review_comment, then submit with submit_review.`,
};
}),
});
}
// add_review_comment tool
export const AddReviewComment = type({
path: type.string.describe("The file path to comment on (relative to repo root)"),
line: type.number.describe(
"The line number in the file (use line numbers from the diff - the NEW file line number)"
),
body: type.string.describe("The comment text for this specific line"),
side: type
.enumerated("LEFT", "RIGHT")
.describe("Side of the diff: LEFT (old code) or RIGHT (new code). Defaults to RIGHT.")
.optional(),
});
export function AddReviewCommentTool(ctx: ToolContext) {
return tool({
name: "add_review_comment",
description:
"Add a comment to the current review session. Must call start_review first. Comments are stored in draft state until submit_review is called.",
parameters: AddReviewComment,
execute: execute(async ({ path, line, body, side }) => {
// check if review started
if (!ctx.toolState.review) {
throw new Error("No review session started. Call start_review first.");
}
const reviewNodeId = ctx.toolState.review.nodeId;
log.debug(
`adding review comment: reviewNodeId=${reviewNodeId}, path=${path}, line=${line}, side=${side || "RIGHT"}`
);
// add comment thread via GraphQL (REST doesn't support adding to existing pending review)
let result: AddPullRequestReviewThreadResponse;
try {
result = await ctx.octokit.graphql<AddPullRequestReviewThreadResponse>(
ADD_PULL_REQUEST_REVIEW_THREAD,
{
pullRequestReviewId: reviewNodeId,
path,
line,
body,
side: side || "RIGHT",
subjectType: "LINE",
}
);
log.debug(`addPullRequestReviewThread response: ${JSON.stringify(result)}`);
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
log.debug(`addPullRequestReviewThread error: ${errorMsg}`);
throw new Error(
`Failed to add comment to ${path}:${line}. GraphQL error: ${errorMsg}. ` +
`Ensure the line is part of the diff and the path is correct.`
);
}
// check if the mutation succeeded - null means the line is not in the diff
if (!result) {
throw new Error(
`Failed to add comment to ${path}:${line}. GraphQL returned null response.`
);
}
if (!result.addPullRequestReviewThread) {
throw new Error(
`Failed to add comment to ${path}:${line}. addPullRequestReviewThread is null. Response: ${JSON.stringify(result)}`
);
}
if (!result.addPullRequestReviewThread.thread) {
throw new Error(
`Failed to add comment to ${path}:${line}. thread is null. The line must be part of the diff. Response: ${JSON.stringify(result)}`
);
}
const threadId = result.addPullRequestReviewThread.thread.id;
log.debug(`review comment added: threadId=${threadId}`);
return {
success: true,
message: `Comment added to ${path}:${line}`,
threadId,
};
}),
});
}
// submit_review tool
export const SubmitReview = type({
body: type.string
.describe(
"Review body text. Typically 1-3 sentences with high-level overview and urgency level. Action links are auto-appended."
)
.optional(),
});
export function SubmitReviewTool(ctx: ToolContext) {
return tool({
name: "submit_review",
description:
"Submit the current review session. All comments added via add_review_comment will be published. Must call start_review first.",
parameters: SubmitReview,
execute: execute(async ({ body }) => {
// check if review started
if (!ctx.toolState.review) {
throw new Error("No review session started. Call start_review first.");
}
if (ctx.toolState.issueNumber === undefined) {
throw new Error("No PR context. Call checkout_pr or start_review first.");
}
const reviewId = ctx.toolState.review.id;
log.debug(
`submitting review: id=${reviewId}, nodeId=${ctx.toolState.review.nodeId}, issueNumber=${ctx.toolState.issueNumber}`
);
// build quick links footer
const apiUrl = process.env.API_URL || "https://pullfrog.com";
const fixAllUrl = `${apiUrl}/trigger/${ctx.repo.owner}/${ctx.repo.name}/${ctx.toolState.issueNumber}?action=fix&review_id=${reviewId}`;
const fixApprovedUrl = `${apiUrl}/trigger/${ctx.repo.owner}/${ctx.repo.name}/${ctx.toolState.issueNumber}?action=fix-approved&review_id=${reviewId}`;
const footer = buildPullfrogFooter({
workflowRun: { owner: ctx.repo.owner, repo: ctx.repo.name, runId: ctx.runId, jobId: ctx.jobId },
customParts: [`[Fix all ➔](${fixAllUrl})`, `[Fix 👍s ➔](${fixApprovedUrl})`],
});
const bodyWithFooter = (body || "") + footer;
// submit the pending review via REST
const result = await ctx.octokit.rest.pulls.submitReview({
owner: ctx.repo.owner,
repo: ctx.repo.name,
pull_number: ctx.toolState.issueNumber,
review_id: reviewId,
event: "COMMENT",
body: bodyWithFooter,
});
log.debug(`submitReview response: ${JSON.stringify(result.data)}`);
if (!result.data.id) {
throw new Error(`submitReview returned invalid data: ${JSON.stringify(result.data)}`);
}
log.debug(`review submitted: reviewId=${result.data.id}, state=${result.data.state}`);
// clear review state
delete ctx.toolState.review;
// delete progress comment
await deleteProgressComment(ctx);
return {
success: true,
reviewId: result.data.id,
html_url: result.data.html_url,
state: result.data.state,
};
}),
});
}
*/