diff --git a/mcp/comment.ts b/mcp/comment.ts index a7644ce..a0a8574 100644 --- a/mcp/comment.ts +++ b/mcp/comment.ts @@ -1,49 +1,12 @@ import { type } from "arktype"; -import { apiFetch } from "../utils/apiFetch.ts"; import { getApiUrl } from "../utils/apiUrl.ts"; import { buildPullfrogFooter, stripExistingFooter } from "../utils/buildPullfrogFooter.ts"; import { log } from "../utils/cli.ts"; import { fixDoubleEscapedString } from "../utils/fixDoubleEscapedString.ts"; -import { retry } from "../utils/retry.ts"; +import { patchWorkflowRunFields } from "../utils/patchWorkflowRunFields.ts"; import type { ToolContext } from "./server.ts"; import { execute, tool } from "./shared.ts"; -type CommentNodeIdField = "planCommentNodeId" | "summaryCommentNodeId"; - -// IMPORTANT: this route authenticates via Pullfrog API JWT (verifyApiToken), -// NOT a GitHub token. use ctx.apiToken here. see wiki/api-auth.md. -export async function updateCommentNodeId( - ctx: ToolContext, - field: CommentNodeIdField, - nodeId: string -): Promise { - if (ctx.runId === undefined || !ctx.apiToken) return; - try { - await retry( - async () => { - const response = await apiFetch({ - path: `/api/workflow-run/${ctx.runId}`, - method: "PATCH", - headers: { - authorization: `Bearer ${ctx.apiToken}`, - "content-type": "application/json", - }, - body: JSON.stringify({ [field]: nodeId }), - signal: AbortSignal.timeout(10_000), - }); - if (!response.ok) throw new Error(`PATCH workflow-run: ${response.status}`); - }, - { - maxAttempts: 3, - delayMs: 2000, - label: `updateCommentNodeId(${field})`, - } - ); - } catch (error) { - log.warning(`updateCommentNodeId(${field}) exhausted retries: ${error}`); - } -} - /** * The prefix text for the initial "leaping into action" comment. * This is used to identify if a comment is still in its initial state @@ -118,7 +81,7 @@ export function CreateCommentTool(ctx: ToolContext) { }); if (result.data.node_id) { - await updateCommentNodeId(ctx, "summaryCommentNodeId", result.data.node_id); + await patchWorkflowRunFields(ctx, { summaryCommentNodeId: result.data.node_id }); } return { @@ -138,7 +101,7 @@ export function CreateCommentTool(ctx: ToolContext) { if (commentType === "Plan") { if (result.data.node_id) { - await updateCommentNodeId(ctx, "planCommentNodeId", result.data.node_id); + await patchWorkflowRunFields(ctx, { planCommentNodeId: result.data.node_id }); } // add "Implement plan" link (needs comment ID, so create-then-update) const customParts = [buildImplementPlanLink(ctx, issueNumber, result.data.id)]; @@ -161,7 +124,7 @@ export function CreateCommentTool(ctx: ToolContext) { } if (commentType === "Summary" && result.data.node_id) { - await updateCommentNodeId(ctx, "summaryCommentNodeId", result.data.node_id); + await patchWorkflowRunFields(ctx, { summaryCommentNodeId: result.data.node_id }); } return { @@ -266,7 +229,7 @@ export async function reportProgress( ctx.toolState.wasUpdated = true; if (isPlanMode && result.data.node_id) { - await updateCommentNodeId(ctx, "planCommentNodeId", result.data.node_id); + await patchWorkflowRunFields(ctx, { planCommentNodeId: result.data.node_id }); } return { @@ -300,7 +263,7 @@ export async function reportProgress( ctx.toolState.wasUpdated = true; if (isPlanMode && result.data.node_id) { - await updateCommentNodeId(ctx, "planCommentNodeId", result.data.node_id); + await patchWorkflowRunFields(ctx, { planCommentNodeId: result.data.node_id }); } return { @@ -353,7 +316,7 @@ export async function reportProgress( }); if (updateResult.data.node_id) { - await updateCommentNodeId(ctx, "planCommentNodeId", updateResult.data.node_id); + await patchWorkflowRunFields(ctx, { planCommentNodeId: updateResult.data.node_id }); } return { diff --git a/mcp/issue.ts b/mcp/issue.ts index 178b531..41411ed 100644 --- a/mcp/issue.ts +++ b/mcp/issue.ts @@ -1,5 +1,6 @@ import { type } from "arktype"; import { fixDoubleEscapedString } from "../utils/fixDoubleEscapedString.ts"; +import { patchWorkflowRunFields } from "../utils/patchWorkflowRunFields.ts"; import type { ToolContext } from "./server.ts"; import { execute, tool } from "./shared.ts"; @@ -21,16 +22,23 @@ export function IssueTool(ctx: ToolContext) { name: "create_issue", description: "Create a new GitHub issue", parameters: Issue, - execute: execute(async ({ title, body, labels, assignees }) => { + execute: execute(async (params) => { const result = await ctx.octokit.rest.issues.create({ owner: ctx.repo.owner, repo: ctx.repo.name, - title: title, - body: fixDoubleEscapedString(body), - labels: labels ?? [], - assignees: assignees ?? [], + title: params.title, + body: fixDoubleEscapedString(params.body), + labels: params.labels ?? [], + assignees: params.assignees ?? [], }); + const nodeId = result.data.node_id; + if (typeof nodeId === "string" && nodeId.length > 0) { + await patchWorkflowRunFields(ctx, { + issueNodeId: nodeId, + }); + } + return { success: true, issueId: result.data.id, diff --git a/mcp/pr.ts b/mcp/pr.ts index f695832..841b21e 100644 --- a/mcp/pr.ts +++ b/mcp/pr.ts @@ -2,6 +2,7 @@ import { type } from "arktype"; import { buildPullfrogFooter, stripExistingFooter } from "../utils/buildPullfrogFooter.ts"; import { log } from "../utils/cli.ts"; import { fixDoubleEscapedString } from "../utils/fixDoubleEscapedString.ts"; +import { patchWorkflowRunFields } from "../utils/patchWorkflowRunFields.ts"; import { $ } from "../utils/shell.ts"; import type { ToolContext } from "./server.ts"; import { execute, tool } from "./shared.ts"; @@ -94,6 +95,12 @@ export function CreatePullRequestTool(ctx: ToolContext) { } } + if (typeof result.data.node_id === "string" && result.data.node_id.length > 0) { + await patchWorkflowRunFields(ctx, { + prNodeId: result.data.node_id, + }); + } + return { success: true, pullRequestId: result.data.id, diff --git a/mcp/review.ts b/mcp/review.ts index 29beb46..5c9b161 100644 --- a/mcp/review.ts +++ b/mcp/review.ts @@ -1,11 +1,11 @@ import type { RestEndpointMethodTypes } from "@octokit/rest"; import { type } from "arktype"; import { formatMcpToolRef } from "../external.ts"; -import { apiFetch } from "../utils/apiFetch.ts"; import { getApiUrl } from "../utils/apiUrl.ts"; import { buildPullfrogFooter } from "../utils/buildPullfrogFooter.ts"; import { log } from "../utils/cli.ts"; import { fixDoubleEscapedString } from "../utils/fixDoubleEscapedString.ts"; +import { patchWorkflowRunFields } from "../utils/patchWorkflowRunFields.ts"; import type { ToolContext } from "./server.ts"; import { execute, tool } from "./shared.ts"; @@ -305,34 +305,12 @@ async function createAndSubmitWithFooter( } /** - * report the review node ID to the server so the WorkflowRun is marked as "review submitted". + * report the review node ID so the WorkflowRun is marked as "review submitted". * exported for use in main.ts post-agent cleanup. */ -export async function reportReviewNodeId(ctx: ToolContext, reviewNodeId: string): Promise { - for (let remaining = 2; remaining >= 0; remaining--) { - try { - const response = await apiFetch({ - path: `/api/workflow-run/${ctx.runId}`, - method: "PATCH", - headers: { - authorization: `Bearer ${ctx.apiToken}`, - "content-type": "application/json", - }, - body: JSON.stringify({ reviewNodeId }), - signal: AbortSignal.timeout(10_000), - }); - if (response.ok) return; - if (remaining > 0) { - log.debug(`reportReviewNodeId got ${response.status}, retrying (${remaining} left)`); - await new Promise((r) => setTimeout(r, 2000)); - } - } catch (error) { - if (remaining > 0) { - log.debug(`reportReviewNodeId failed, retrying (${remaining} left): ${error}`); - await new Promise((r) => setTimeout(r, 2000)); - } else { - log.debug(`reportReviewNodeId exhausted retries: ${error}`); - } - } - } +export async function reportReviewNodeId( + ctx: ToolContext, + params: { nodeId: string } +): Promise { + await patchWorkflowRunFields(ctx, { reviewNodeId: params.nodeId }); } diff --git a/utils/patchWorkflowRunFields.ts b/utils/patchWorkflowRunFields.ts new file mode 100644 index 0000000..da502ee --- /dev/null +++ b/utils/patchWorkflowRunFields.ts @@ -0,0 +1,62 @@ +import type { ToolContext } from "../mcp/server.ts"; +import { apiFetch } from "./apiFetch.ts"; +import { log } from "./cli.ts"; +import { retry } from "./retry.ts"; + +/** Keys accepted by PATCH /api/workflow-run/[runId] — keep in sync with `ALLOWED_FIELDS` in `app/api/workflow-run/[runId]/route.ts`. */ +export type WorkflowRunArtifactPatchKey = + | "prNodeId" + | "issueNodeId" + | "reviewNodeId" + | "planCommentNodeId" + | "summaryCommentNodeId"; + +export type WorkflowRunArtifactPatch = Partial>; + +const ARTIFACT_PATCH_KEYS: WorkflowRunArtifactPatchKey[] = [ + "prNodeId", + "issueNodeId", + "reviewNodeId", + "planCommentNodeId", + "summaryCommentNodeId", +]; + +/** PATCH workflow-run artifact fields (Pullfrog JWT, not GitHub). */ +export async function patchWorkflowRunFields( + ctx: ToolContext, + fields: WorkflowRunArtifactPatch +): Promise { + if (ctx.runId === undefined || !ctx.apiToken) return; + const body: Record = {}; + for (const key of ARTIFACT_PATCH_KEYS) { + const value = fields[key]; + if (typeof value === "string" && value.length > 0) { + body[key] = value; + } + } + if (Object.keys(body).length === 0) return; + try { + await retry( + async () => { + const response = await apiFetch({ + path: `/api/workflow-run/${ctx.runId}`, + method: "PATCH", + headers: { + authorization: `Bearer ${ctx.apiToken}`, + "content-type": "application/json", + }, + body: JSON.stringify(body), + signal: AbortSignal.timeout(10_000), + }); + if (!response.ok) throw new Error(`PATCH workflow-run: ${response.status}`); + }, + { + maxAttempts: 3, + delayMs: 2000, + label: "patchWorkflowRunFields", + } + ); + } catch (error) { + log.warning(`patchWorkflowRunFields exhausted retries: ${error}`); + } +} diff --git a/utils/reviewCleanup.ts b/utils/reviewCleanup.ts index 210e5aa..2aa6339 100644 --- a/utils/reviewCleanup.ts +++ b/utils/reviewCleanup.ts @@ -24,7 +24,7 @@ export async function postReviewCleanup(ctx: ToolContext): Promise { delete ctx.toolState.review; // mark review as submitted — unlocks webhook dedup for new pushes - await bestEffort(() => reportReviewNodeId(ctx, review.nodeId), "reportReviewNodeId"); + await bestEffort(() => reportReviewNodeId(ctx, { nodeId: review.nodeId }), "reportReviewNodeId"); // dispatch follow-up if PR HEAD moved past the reviewed commit if (review.reviewedSha) {