Refactor to toolState

This commit is contained in:
Colin McDonnell
2025-12-16 20:41:10 -08:00
parent 956245962e
commit 4db8e28bf7
7 changed files with 49 additions and 38 deletions
+13 -4
View File
@@ -11,7 +11,6 @@ import type { AgentName, Payload } from "./external.ts";
import { agentsManifest } from "./external.ts"; import { agentsManifest } from "./external.ts";
import { ensureProgressCommentUpdated, reportProgress } from "./mcp/comment.ts"; import { ensureProgressCommentUpdated, reportProgress } from "./mcp/comment.ts";
import { createMcpConfigs } from "./mcp/config.ts"; import { createMcpConfigs } from "./mcp/config.ts";
import type { ReviewState } from "./mcp/review.ts";
import { startMcpHttpServer } from "./mcp/server.ts"; import { startMcpHttpServer } from "./mcp/server.ts";
import { getModes, type Mode, modes } from "./modes.ts"; import { getModes, type Mode, modes } from "./modes.ts";
import packageJson from "./package.json" with { type: "json" }; import packageJson from "./package.json" with { type: "json" };
@@ -64,6 +63,7 @@ export async function main(inputs: Inputs): Promise<MainResult> {
const partialCtx = await initializeContext(inputs, payload); const partialCtx = await initializeContext(inputs, payload);
const ctx = partialCtx as Context; const ctx = partialCtx as Context;
ctx.toolState = {};
timer.checkpoint("initializeContext"); timer.checkpoint("initializeContext");
await setupGit(ctx); await setupGit(ctx);
@@ -244,8 +244,17 @@ export interface Context {
runId: string; runId: string;
jobId: string | undefined; jobId: string | undefined;
// iterative review state (set by start_review, cleared by submit_review) // tool state - mutable scratchpad for tools
reviewState: ReviewState | undefined; toolState: ToolState;
}
export interface ToolState {
prNumber?: number;
issueNumber?: number;
review?: {
id: number; // REST API database ID (for fix URLs)
nodeId: string; // GraphQL node ID (for mutations)
};
} }
async function initializeContext( async function initializeContext(
@@ -263,7 +272,7 @@ async function initializeContext(
| "prepResults" | "prepResults"
| "runId" | "runId"
| "jobId" | "jobId"
| "reviewState" | "toolState"
> >
> { > {
log.info(`🐸 Running pullfrog/action@${packageJson.version}...`); log.info(`🐸 Running pullfrog/action@${packageJson.version}...`);
+3 -1
View File
@@ -88,6 +88,9 @@ export function CheckoutPrTool(ctx: Context) {
$("git", ["config", `branch.${headBranch}.pushRemote`, "origin"]); $("git", ["config", `branch.${headBranch}.pushRemote`, "origin"]);
} }
// set PR context
ctx.toolState.prNumber = pull_number;
return { return {
success: true, success: true,
number: pr.data.number, number: pr.data.number,
@@ -102,4 +105,3 @@ export function CheckoutPrTool(ctx: Context) {
}), }),
}); });
} }
+3 -1
View File
@@ -204,7 +204,9 @@ export async function reportProgress(
} }
// no existing comment - create one // no existing comment - create one
const issueNumber = ctx.payload.event.issue_number; // use fallback chain: dynamically set context > event payload
const issueNumber =
ctx.toolState.prNumber ?? ctx.toolState.issueNumber ?? ctx.payload.event.issue_number;
if (issueNumber === undefined) { if (issueNumber === undefined) {
// cannot create comment without issue_number (e.g., workflow_dispatch events) // cannot create comment without issue_number (e.g., workflow_dispatch events)
return undefined; return undefined;
+3
View File
@@ -13,6 +13,9 @@ export function GetIssueCommentsTool(ctx: Context) {
"Get all comments for a GitHub issue. Returns all comments including the issue body and all subsequent discussion comments.", "Get all comments for a GitHub issue. Returns all comments including the issue body and all subsequent discussion comments.",
parameters: GetIssueComments, parameters: GetIssueComments,
execute: execute(ctx, async ({ issue_number }) => { execute: execute(ctx, async ({ issue_number }) => {
// set issue context
ctx.toolState.issueNumber = issue_number;
const comments = await ctx.octokit.paginate(ctx.octokit.rest.issues.listComments, { const comments = await ctx.octokit.paginate(ctx.octokit.rest.issues.listComments, {
owner: ctx.owner, owner: ctx.owner,
repo: ctx.name, repo: ctx.name,
+3
View File
@@ -13,6 +13,9 @@ export function GetIssueEventsTool(ctx: Context) {
"Get timeline events for a GitHub issue that aren't reflected in the current state. Returns cross-references to other issues/PRs and commit references. Note: current labels, assignees, state, and milestone are already available via get_issue.", "Get timeline events for a GitHub issue that aren't reflected in the current state. Returns cross-references to other issues/PRs and commit references. Note: current labels, assignees, state, and milestone are already available via get_issue.",
parameters: GetIssueEvents, parameters: GetIssueEvents,
execute: execute(ctx, async ({ issue_number }) => { execute: execute(ctx, async ({ issue_number }) => {
// set issue context
ctx.toolState.issueNumber = issue_number;
const events = await ctx.octokit.paginate(ctx.octokit.rest.issues.listEventsForTimeline, { const events = await ctx.octokit.paginate(ctx.octokit.rest.issues.listEventsForTimeline, {
owner: ctx.owner, owner: ctx.owner,
repo: ctx.name, repo: ctx.name,
+3
View File
@@ -20,6 +20,9 @@ export function IssueInfoTool(ctx: Context) {
const data = issue.data; const data = issue.data;
// set issue context
ctx.toolState.issueNumber = issue_number;
const hints: string[] = []; const hints: string[] = [];
if (data.comments > 0) { if (data.comments > 0) {
hints.push("use get_issue_comments to retrieve all comments for this issue"); hints.push("use get_issue_comments to retrieve all comments for this issue");
+21 -32
View File
@@ -84,15 +84,6 @@ type SubmitPullRequestReviewResponse = {
}; };
}; };
// review state stored in ctx
export interface ReviewState {
reviewId: string; // graphql node ID
reviewDatabaseId: number; // rest API ID
pullNumber: number;
scratchpadPath: string;
commentCount: number;
}
// start_review tool // start_review tool
export const StartReview = type({ export const StartReview = type({
pull_number: type.number.describe("The pull request number to review"), pull_number: type.number.describe("The pull request number to review"),
@@ -106,9 +97,9 @@ export function StartReviewTool(ctx: Context) {
parameters: StartReview, parameters: StartReview,
execute: execute(ctx, async ({ pull_number }) => { execute: execute(ctx, async ({ pull_number }) => {
// check if review already started // check if review already started
if (ctx.reviewState) { if (ctx.toolState.review) {
throw new Error( throw new Error(
`Review session already started for PR #${ctx.reviewState.pullNumber}. Call submit_review first to finish it.` `Review session already in progress. Call submit_review first to finish it.`
); );
} }
@@ -136,13 +127,11 @@ export function StartReviewTool(ctx: Context) {
const scratchpadContent = `# Review ${scratchpadId}\n\n`; const scratchpadContent = `# Review ${scratchpadId}\n\n`;
writeFileSync(scratchpadPath, scratchpadContent); writeFileSync(scratchpadPath, scratchpadContent);
// store review state in ctx // set PR context and review state
ctx.reviewState = { ctx.toolState.prNumber = pull_number;
reviewId, ctx.toolState.review = {
reviewDatabaseId, nodeId: reviewId,
pullNumber: pull_number, id: reviewDatabaseId,
scratchpadPath,
commentCount: 0,
}; };
return { return {
@@ -175,7 +164,7 @@ export function AddReviewCommentTool(ctx: Context) {
parameters: AddReviewComment, parameters: AddReviewComment,
execute: execute(ctx, async ({ path, line, body, side }) => { execute: execute(ctx, async ({ path, line, body, side }) => {
// check if review started // check if review started
if (!ctx.reviewState) { if (!ctx.toolState.review) {
throw new Error("No review session started. Call start_review first."); throw new Error("No review session started. Call start_review first.");
} }
@@ -183,7 +172,7 @@ export function AddReviewCommentTool(ctx: Context) {
await ctx.octokit.graphql<AddPullRequestReviewThreadResponse>( await ctx.octokit.graphql<AddPullRequestReviewThreadResponse>(
ADD_PULL_REQUEST_REVIEW_THREAD, ADD_PULL_REQUEST_REVIEW_THREAD,
{ {
pullRequestReviewId: ctx.reviewState.reviewId, pullRequestReviewId: ctx.toolState.review.nodeId,
path, path,
line, line,
body, body,
@@ -191,11 +180,8 @@ export function AddReviewCommentTool(ctx: Context) {
} }
); );
ctx.reviewState.commentCount++;
return { return {
success: true, success: true,
commentCount: ctx.reviewState.commentCount,
message: `Comment added to ${path}:${line}`, message: `Comment added to ${path}:${line}`,
}; };
}), }),
@@ -219,17 +205,19 @@ export function SubmitReviewTool(ctx: Context) {
parameters: SubmitReview, parameters: SubmitReview,
execute: execute(ctx, async ({ body }) => { execute: execute(ctx, async ({ body }) => {
// check if review started // check if review started
if (!ctx.reviewState) { if (!ctx.toolState.review) {
throw new Error("No review session started. Call start_review first."); throw new Error("No review session started. Call start_review first.");
} }
if (ctx.toolState.prNumber === undefined) {
throw new Error("No PR context. Call checkout_pr or start_review first.");
}
const pullNumber = ctx.reviewState.pullNumber; const reviewId = ctx.toolState.review.id;
const reviewDatabaseId = ctx.reviewState.reviewDatabaseId;
// build quick links footer // build quick links footer
const apiUrl = process.env.API_URL || "https://pullfrog.com"; const apiUrl = process.env.API_URL || "https://pullfrog.com";
const fixAllUrl = `${apiUrl}/trigger/${ctx.owner}/${ctx.name}/${pullNumber}?action=fix&review_id=${reviewDatabaseId}`; const fixAllUrl = `${apiUrl}/trigger/${ctx.owner}/${ctx.name}/${ctx.toolState.prNumber}?action=fix&review_id=${reviewId}`;
const fixApprovedUrl = `${apiUrl}/trigger/${ctx.owner}/${ctx.name}/${pullNumber}?action=fix-approved&review_id=${reviewDatabaseId}`; const fixApprovedUrl = `${apiUrl}/trigger/${ctx.owner}/${ctx.name}/${ctx.toolState.prNumber}?action=fix-approved&review_id=${reviewId}`;
const footer = buildPullfrogFooter({ const footer = buildPullfrogFooter({
workflowRun: { owner: ctx.owner, repo: ctx.name, runId: ctx.runId, jobId: ctx.jobId }, workflowRun: { owner: ctx.owner, repo: ctx.name, runId: ctx.runId, jobId: ctx.jobId },
@@ -242,17 +230,16 @@ export function SubmitReviewTool(ctx: Context) {
const response = await ctx.octokit.graphql<SubmitPullRequestReviewResponse>( const response = await ctx.octokit.graphql<SubmitPullRequestReviewResponse>(
SUBMIT_PULL_REQUEST_REVIEW, SUBMIT_PULL_REQUEST_REVIEW,
{ {
pullRequestReviewId: ctx.reviewState.reviewId, pullRequestReviewId: ctx.toolState.review.nodeId,
body: bodyWithFooter, body: bodyWithFooter,
event: "COMMENT", event: "COMMENT",
} }
); );
const result = response.submitPullRequestReview.pullRequestReview; const result = response.submitPullRequestReview.pullRequestReview;
const commentCount = ctx.reviewState.commentCount;
// clear review state // clear review state
ctx.reviewState = undefined; delete ctx.toolState.review;
// delete progress comment // delete progress comment
await deleteProgressComment(ctx); await deleteProgressComment(ctx);
@@ -262,7 +249,6 @@ export function SubmitReviewTool(ctx: Context) {
reviewId: result.databaseId, reviewId: result.databaseId,
html_url: result.url, html_url: result.url,
state: result.state, state: result.state,
commentCount,
}; };
}), }),
}); });
@@ -316,6 +302,9 @@ export function ReviewTool(ctx: Context) {
"Only use 'body' for a 1-2 sentence summary with urgency and critical callouts.", "Only use 'body' for a 1-2 sentence summary with urgency and critical callouts.",
parameters: Review, parameters: Review,
execute: execute(ctx, async ({ pull_number, body, commit_id, comments = [] }) => { execute: execute(ctx, async ({ pull_number, body, commit_id, comments = [] }) => {
// set PR context
ctx.toolState.prNumber = pull_number;
// get the PR to determine the head commit if commit_id not provided // get the PR to determine the head commit if commit_id not provided
const pr = await ctx.octokit.rest.pulls.get({ const pr = await ctx.octokit.rest.pulls.get({
owner: ctx.owner, owner: ctx.owner,