import { type } from "arktype"; 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 { patchWorkflowRunFields } from "../utils/patchWorkflowRunFields.ts"; import { createLeapingProgressComment, deleteProgressCommentApi, updateProgressComment, } from "../utils/progressComment.ts"; import type { ToolContext } from "./server.ts"; import { execute, tool } from "./shared.ts"; // re-export for backward compat with anything importing the leaping helpers from mcp/comment export { isLeapingIntoActionCommentBody, LEAPING_INTO_ACTION_PREFIX, } from "../utils/leapingComment.ts"; function buildCommentFooter(ctx: ToolContext, customParts?: string[]): string { const runId = ctx.runId; return buildPullfrogFooter({ triggeredBy: true, workflowRun: runId !== undefined ? { owner: ctx.repo.owner, repo: ctx.repo.name, runId, jobId: ctx.jobId, } : undefined, customParts, model: ctx.toolState.model, }); } function buildImplementPlanLink(ctx: ToolContext, issueNumber: number, commentId: number): string { const apiUrl = getApiUrl(); return `[Implement plan ➔](${apiUrl}/trigger/${ctx.repo.owner}/${ctx.repo.name}/${issueNumber}?action=implement&comment_id=${commentId})`; } export function addFooter(ctx: ToolContext, body: string): string { if (/[ \t]*\n(?!\s*\n)/i.test(body)) { throw new Error( "body contains
followed by a non-blank line, which breaks GitHub markdown rendering. always add a blank line after
tags." ); } const bodyWithoutFooter = stripExistingFooter(fixDoubleEscapedString(body)); const footer = buildCommentFooter(ctx); return `${bodyWithoutFooter}${footer}`; } export const Comment = type({ issueNumber: type.number.describe("the issue number to comment on"), body: type.string.describe("the comment body content"), type: type .enumerated("Plan", "Comment") .describe("Plan: record as the plan for this run. Comment: regular comment (default).") .optional(), }); export function CreateCommentTool(ctx: ToolContext) { return tool({ name: "create_issue_comment", description: "Create a comment on a GitHub issue or PR. For progress/plan updates on the current run use report_progress instead. Use type: 'Plan' for plan comments.", parameters: Comment, execute: execute(async ({ issueNumber, body, type: commentType }) => { const bodyWithFooter = addFooter(ctx, body); const result = await ctx.octokit.rest.issues.createComment({ owner: ctx.repo.owner, repo: ctx.repo.name, issue_number: issueNumber, body: bodyWithFooter, }); ctx.toolState.wasUpdated = true; if (commentType === "Plan") { if (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)]; const footer = buildCommentFooter(ctx, customParts); const bodyWithPlanLink = `${stripExistingFooter(body)}${footer}`; const updateResult = await ctx.octokit.rest.issues.updateComment({ owner: ctx.repo.owner, repo: ctx.repo.name, comment_id: result.data.id, body: bodyWithPlanLink, }); return { success: true, commentId: updateResult.data.id, url: updateResult.data.html_url, body: updateResult.data.body, }; } return { success: true, commentId: result.data.id, url: result.data.html_url, body: result.data.body, }; }), }); } export const EditComment = type({ commentId: type.number.describe("the ID of the comment to edit"), body: type.string.describe("the new comment body content"), }); export function EditCommentTool(ctx: ToolContext) { return tool({ name: "edit_issue_comment", description: "Edit a GitHub issue comment by its ID", parameters: EditComment, execute: execute(async ({ commentId, body }) => { const bodyWithFooter = addFooter(ctx, body); const result = await ctx.octokit.rest.issues.updateComment({ owner: ctx.repo.owner, repo: ctx.repo.name, comment_id: commentId, body: bodyWithFooter, }); return { success: true, commentId: result.data.id, url: result.data.html_url, body: result.data.body, updatedAt: result.data.updated_at, }; }), }); } export const ReportProgress = type({ body: type.string.describe("the progress update content to share"), "target_plan_comment?": type("boolean").describe( "when true, update the existing plan comment (from select_mode lookup) instead of the progress comment; use when editing an existing plan" ), }); /** * Report progress to a GitHub comment. * * progressComment has three states: * - undefined: no comment yet — will create one if an issue/PR target exists * - object: active comment — will update it in place via the right REST endpoint for its type * - null: deliberately deleted (e.g. after submitting a PR review) — skips silently * * The body is always tracked in lastProgressBody for the job summary regardless of comment state. * * The "existing plan comment" path always targets a top-level issue comment (plan comments are * created by create_issue_comment with type:"Plan", never as review-thread replies). */ export async function reportProgress( ctx: ToolContext, params: { body: string; target_plan_comment?: boolean } ): Promise<{ commentId?: number; url?: string; body: string; action: "created" | "updated" | "skipped"; }> { const { body, target_plan_comment } = params; // always track the body for job summary ctx.toolState.lastProgressBody = body; // silent events (e.g., auto-label, pr-summary Task) should never create or update progress comments. // the body is still tracked above for the GitHub Actions job summary. if (ctx.payload.event.silent) { return { body, action: "skipped" }; } const issueNumber = ctx.payload.event.issue_number ?? ctx.toolState.issueNumber; const isPlanMode = ctx.toolState.selectedMode === "Plan"; const apiCtx = { octokit: ctx.octokit, owner: ctx.repo.owner, repo: ctx.repo.name }; // when editing existing plan: update the plan comment from tool state (set by select_mode) if (target_plan_comment === true && ctx.toolState.existingPlanCommentId === undefined) { log.warning("target_plan_comment requested but no existingPlanCommentId in tool state"); } if (target_plan_comment === true && ctx.toolState.existingPlanCommentId !== undefined) { const commentId = ctx.toolState.existingPlanCommentId; const customParts = issueNumber !== undefined ? [buildImplementPlanLink(ctx, issueNumber, commentId)] : undefined; const bodyWithoutFooter = stripExistingFooter(body); const footer = buildCommentFooter(ctx, customParts); const bodyWithFooter = `${bodyWithoutFooter}${footer}`; const result = await updateProgressComment( apiCtx, { id: commentId, type: "issue" }, bodyWithFooter ); ctx.toolState.wasUpdated = true; if (isPlanMode && result.node_id) { await patchWorkflowRunFields(ctx, { planCommentNodeId: result.node_id }); } return { commentId: result.id, url: result.html_url, body: result.body || "", action: "updated", }; } const existingComment = ctx.toolState.progressComment; // if we already have a progress comment, update it if (existingComment) { const customParts = isPlanMode && issueNumber !== undefined ? [buildImplementPlanLink(ctx, issueNumber, existingComment.id)] : undefined; const bodyWithoutFooter = stripExistingFooter(body); const footer = buildCommentFooter(ctx, customParts); const bodyWithFooter = `${bodyWithoutFooter}${footer}`; const result = await updateProgressComment(apiCtx, existingComment, bodyWithFooter); ctx.toolState.wasUpdated = true; if (isPlanMode && result.node_id) { await patchWorkflowRunFields(ctx, { planCommentNodeId: result.node_id }); } return { commentId: result.id, url: result.html_url, body: result.body || "", action: "updated", }; } // null = progress comment was deleted by stranded-comment cleanup in main.ts if (existingComment === null) { return { body, action: "skipped" }; } // no existing comment - need an issue/PR to create one on // use fallback chain: dynamically set context > event payload if (issueNumber === undefined) { // no-op: no comment target (e.g., workflow_dispatch events) // body is already tracked for job summary return { body, action: "skipped" }; } // for new comments, we need to create first, then update with Plan link if in Plan mode // self-created progress comments are always top-level issue comments — review-reply // progress comments only originate from the dispatch path and arrive pre-created. const initialBody = addFooter(ctx, body); const created = await createLeapingProgressComment( apiCtx, { kind: "issue", issueNumber }, initialBody ); ctx.toolState.progressComment = created.comment; ctx.toolState.wasUpdated = true; // if Plan mode, update the comment to add the "Implement plan" link if (isPlanMode) { const customParts = [buildImplementPlanLink(ctx, issueNumber, created.comment.id)]; const bodyWithoutFooter = stripExistingFooter(body); const footer = buildCommentFooter(ctx, customParts); const bodyWithPlanLink = `${bodyWithoutFooter}${footer}`; const updateResult = await updateProgressComment(apiCtx, created.comment, bodyWithPlanLink); if (updateResult.node_id) { await patchWorkflowRunFields(ctx, { planCommentNodeId: updateResult.node_id }); } return { commentId: updateResult.id, url: updateResult.html_url, body: updateResult.body || "", action: "created", }; } return { commentId: created.comment.id, url: created.html_url, body: created.body || "", action: "created", }; } export function ReportProgressTool(ctx: ToolContext) { return tool({ name: "report_progress", description: "Share progress on the associated GitHub issue/PR. The first call creates a comment; subsequent calls update it in place. Call this at the end of every run with a brief final summary (1-3 sentences) unless the mode guidance instructs otherwise. The current task list is automatically appended in a collapsible section — do not restate individual steps.", parameters: ReportProgress, execute: execute(async (params) => { let body = params.body; // for non-plan calls: stop auto-updates, wait for in-flight writes to settle, // then append completed task list collapsible if (!params.target_plan_comment && ctx.toolState.todoTracker) { ctx.toolState.todoTracker.cancel(); await ctx.toolState.todoTracker.settled(); const collapsible = ctx.toolState.todoTracker.renderCollapsible({ completeInProgress: true, }); if (collapsible) { body = `${body}\n\n${collapsible}`; } } const reportParams: { body: string; target_plan_comment?: boolean } = { body }; if (params.target_plan_comment !== undefined) { reportParams.target_plan_comment = params.target_plan_comment; } const result = await reportProgress(ctx, reportParams); if (result.action === "skipped") { return { success: true, message: "progress recorded (no GitHub comment created - this may occur for workflow_dispatch events or when there is no associated issue/PR)", }; } if (!params.target_plan_comment) { ctx.toolState.finalSummaryWritten = true; } return { success: true, ...result, }; }), }); } /** * Delete the progress comment if it exists. * Used by main.ts for stranded-comment cleanup (orphaned "Leaping into action" or * checklist left by the todo tracker when the agent didn't call report_progress). * Sets progressComment to null so subsequent report_progress calls are no-ops. */ export async function deleteProgressComment(ctx: ToolContext): Promise { const existing = ctx.toolState.progressComment; if (!existing) { return false; } try { await deleteProgressCommentApi( { octokit: ctx.octokit, owner: ctx.repo.owner, repo: ctx.repo.name }, existing ); } catch (error) { // ignore 404 - comment already deleted if (error instanceof Error && error.message.includes("Not Found")) { // comment already deleted, continue } else { throw error; } } // set to null (not undefined) so report_progress skips instead of creating a new comment ctx.toolState.progressComment = null; return true; } export const ReplyToReviewComment = type({ pull_number: type.number.describe("the pull request number"), comment_id: type.number.describe("the ID of the review comment to reply to"), body: type.string.describe( "extremely brief reply (1 sentence max) explaining what was fixed, e.g. 'Fixed by renaming to X' or 'Added null check'" ), }); export function ReplyToReviewCommentTool(ctx: ToolContext) { return tool({ name: "reply_to_review_comment", description: "Reply to a PR review comment thread (NOT issue comments — this only works for inline review comments on PR diffs). Call this for EACH comment you address in AddressReviews mode. Keep replies extremely brief (1 sentence max).", parameters: ReplyToReviewComment, execute: execute(async ({ pull_number, comment_id, body }) => { const bodyWithFooter = addFooter(ctx, body); const result = await ctx.octokit.rest.pulls.createReplyForReviewComment({ owner: ctx.repo.owner, repo: ctx.repo.name, pull_number, comment_id, body: bodyWithFooter, }); // mark progress as updated so error reporting + run-result handling know // a substantive write happened (used by reportErrorToComment / handleAgentResult) ctx.toolState.wasUpdated = true; return { success: true, commentId: result.data.id, url: result.data.html_url, body: result.data.body, in_reply_to_id: result.data.in_reply_to_id, }; }, "reply_to_review_comment"), }); }