Files
shockbot/mcp/comment.ts
T
Colin McDonnell b282e8b599 Improve incremental review output and fix todo tracker race (#529)
* improve incremental review output and fix todo tracker race

- reviewed changes section: summarize at logical-change level with
  past-tense verbs, not per-file enumerations
- add TodoTracker.completeAll() to mark all non-cancelled items as
  completed before snapshotting the collapsible in review/progress posts

Made-with: Cursor

* completeAll -> completeInProgress: only mark in-progress items as completed

Pending items that were genuinely skipped stay as-is in the collapsible,
so the task list honestly reflects what the agent actually did.

Made-with: Cursor
2026-04-10 20:15:34 +00:00

493 lines
17 KiB
TypeScript

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 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<void> {
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
* and hasn't been updated with progress or error messages.
*/
export const LEAPING_INTO_ACTION_PREFIX = "Leaping into action";
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 (/<br\s*\/?>[ \t]*\n(?!\s*\n)/i.test(body)) {
throw new Error(
"body contains <br/> followed by a non-blank line, which breaks GitHub markdown rendering. always add a blank line after <br/> 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", "Summary", "Comment")
.describe(
"Plan: record as the plan for this run. Summary: record as the PR summary comment (one per PR, updated in place). 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, type: 'Summary' for PR summary comments.",
parameters: Comment,
execute: execute(async ({ issueNumber, body, type: commentType }) => {
const bodyWithFooter = addFooter(ctx, body);
// if a summary comment already exists (found by select_mode), update instead of creating
if (commentType === "Summary" && ctx.toolState.existingSummaryCommentId) {
log.info(
`» redirecting create_issue_comment(Summary) to update existing comment ${ctx.toolState.existingSummaryCommentId}`
);
const result = await ctx.octokit.rest.issues.updateComment({
owner: ctx.repo.owner,
repo: ctx.repo.name,
comment_id: ctx.toolState.existingSummaryCommentId,
body: bodyWithFooter,
});
if (result.data.node_id) {
await updateCommentNodeId(ctx, "summaryCommentNodeId", result.data.node_id);
}
return {
success: true,
commentId: result.data.id,
url: result.data.html_url,
body: result.data.body,
};
}
const result = await ctx.octokit.rest.issues.createComment({
owner: ctx.repo.owner,
repo: ctx.repo.name,
issue_number: issueNumber,
body: bodyWithFooter,
});
if (commentType === "Plan") {
if (result.data.node_id) {
await updateCommentNodeId(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,
};
}
if (commentType === "Summary" && result.data.node_id) {
await updateCommentNodeId(ctx, "summaryCommentNodeId", result.data.node_id);
}
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.
*
* progressCommentId has three states:
* - undefined: no comment yet — will create one if an issue/PR target exists
* - number: active comment — will update it in place
* - 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.
*/
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) 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";
// 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 ctx.octokit.rest.issues.updateComment({
owner: ctx.repo.owner,
repo: ctx.repo.name,
comment_id: commentId,
body: bodyWithFooter,
});
ctx.toolState.wasUpdated = true;
if (isPlanMode && result.data.node_id) {
await updateCommentNodeId(ctx, "planCommentNodeId", result.data.node_id);
}
return {
commentId: result.data.id,
url: result.data.html_url,
body: result.data.body || "",
action: "updated",
};
}
const existingCommentId = ctx.toolState.progressCommentId;
// if we already have a progress comment, update it
if (existingCommentId) {
const customParts =
isPlanMode && issueNumber !== undefined
? [buildImplementPlanLink(ctx, issueNumber, existingCommentId)]
: undefined;
const bodyWithoutFooter = stripExistingFooter(body);
const footer = buildCommentFooter(ctx, customParts);
const bodyWithFooter = `${bodyWithoutFooter}${footer}`;
const result = await ctx.octokit.rest.issues.updateComment({
owner: ctx.repo.owner,
repo: ctx.repo.name,
comment_id: existingCommentId,
body: bodyWithFooter,
});
ctx.toolState.wasUpdated = true;
if (isPlanMode && result.data.node_id) {
await updateCommentNodeId(ctx, "planCommentNodeId", result.data.node_id);
}
return {
commentId: result.data.id,
url: result.data.html_url,
body: result.data.body || "",
action: "updated",
};
}
// null = progress comment was deleted by stranded-comment cleanup in main.ts
if (existingCommentId === 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
const initialBody = addFooter(ctx, body);
const result = await ctx.octokit.rest.issues.createComment({
owner: ctx.repo.owner,
repo: ctx.repo.name,
issue_number: issueNumber,
body: initialBody,
});
// store the comment ID for future updates
ctx.toolState.progressCommentId = result.data.id;
ctx.toolState.wasUpdated = true;
// if Plan mode, update the comment to add the "Implement plan" link
if (isPlanMode) {
const customParts = [buildImplementPlanLink(ctx, issueNumber, result.data.id)];
const bodyWithoutFooter = stripExistingFooter(body);
const footer = buildCommentFooter(ctx, customParts);
const bodyWithPlanLink = `${bodyWithoutFooter}${footer}`;
const updateResult = await ctx.octokit.rest.issues.updateComment({
owner: ctx.repo.owner,
repo: ctx.repo.name,
comment_id: result.data.id,
body: bodyWithPlanLink,
});
if (updateResult.data.node_id) {
await updateCommentNodeId(ctx, "planCommentNodeId", updateResult.data.node_id);
}
return {
commentId: updateResult.data.id,
url: updateResult.data.html_url,
body: updateResult.data.body || "",
action: "created",
};
}
return {
commentId: result.data.id,
url: result.data.html_url,
body: result.data.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();
ctx.toolState.todoTracker.completeInProgress();
const collapsible = ctx.toolState.todoTracker.renderCollapsible();
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 (!params.target_plan_comment) {
ctx.toolState.finalSummaryWritten = true;
}
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)",
};
}
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 progressCommentId to null so subsequent report_progress calls are no-ops.
*/
export async function deleteProgressComment(ctx: ToolContext): Promise<boolean> {
const existingCommentId = ctx.toolState.progressCommentId;
if (!existingCommentId) {
return false;
}
try {
await ctx.octokit.rest.issues.deleteComment({
owner: ctx.repo.owner,
repo: ctx.repo.name,
comment_id: existingCommentId,
});
} 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.progressCommentId = 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 post script doesn't think the run failed
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"),
});
}