Files
shockbot/mcp/comment.ts
T
Colin McDonnell 7144f3de88 Tweaks
2026-01-25 08:38:59 +00:00

439 lines
14 KiB
TypeScript

import { type } from "arktype";
import type { Agent } from "../agents/index.ts";
import { buildPullfrogFooter, stripExistingFooter } from "../utils/buildPullfrogFooter.ts";
import { createOctokit, type OctokitWithPlugins, parseRepoContext } from "../utils/github.ts";
import { getGitHubInstallationToken } from "../utils/token.ts";
import type { ToolContext, ToolState } from "./server.ts";
import { execute, tool } from "./shared.ts";
/**
* 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";
interface BuildCommentFooterParams {
agent: Agent | undefined;
octokit?: OctokitWithPlugins | undefined;
customParts?: string[] | undefined;
}
async function buildCommentFooter({
agent,
octokit,
customParts,
}: BuildCommentFooterParams): Promise<string> {
const repoContext = parseRepoContext();
const runId = process.env.GITHUB_RUN_ID;
let workflowRunHtmlUrl: string | undefined;
if (runId && octokit) {
try {
// fetch jobs to get the job URL for deep linking
const { data: jobs } = await octokit.rest.actions.listJobsForWorkflowRun({
owner: repoContext.owner,
repo: repoContext.name,
run_id: parseInt(runId, 10),
});
// use the first job's URL if available
workflowRunHtmlUrl = jobs.jobs[0]?.html_url ?? undefined;
} catch {
// fall back to building URL from runId if jobs can't be fetched
}
}
const footerParams = {
triggeredBy: true,
agent: {
displayName: agent?.displayName || "Unknown agent",
url: agent?.url || "https://pullfrog.com",
},
workflowRun: runId
? {
owner: repoContext.owner,
repo: repoContext.name,
runId,
...(workflowRunHtmlUrl ? { htmlUrl: workflowRunHtmlUrl } : {}),
}
: undefined,
};
if (customParts && customParts.length > 0) {
return buildPullfrogFooter({ ...footerParams, customParts });
}
return buildPullfrogFooter(footerParams);
}
const SUGGESTION_FORMAT_DESCRIPTION =
"when suggesting code changes, use GitHub's suggestion format with ```suggestion blocks to enable one-click apply (e.g., 'you could do this\\n```suggestion\\nsuggested code here\\n```'). note: suggestions only work on pull request line-level review comments, not on issue/PR-level comments.";
function buildImplementPlanLink(
owner: string,
repo: string,
issueNumber: number,
commentId: number
): string {
const apiUrl = process.env.API_URL || "https://pullfrog.com";
return `[Implement plan ➔](${apiUrl}/trigger/${owner}/${repo}/${issueNumber}?action=implement&comment_id=${commentId})`;
}
interface AddFooterCtx {
agent?: Agent | undefined;
octokit?: OctokitWithPlugins | undefined;
}
async function addFooter(ctx: AddFooterCtx, body: string): Promise<string> {
const bodyWithoutFooter = stripExistingFooter(body);
const footer = await buildCommentFooter({ agent: ctx.agent, octokit: ctx.octokit });
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. ${SUGGESTION_FORMAT_DESCRIPTION}`),
});
export function CreateCommentTool(ctx: ToolContext) {
return tool({
name: "create_issue_comment",
description:
"Create a comment on a GitHub issue. NOTE: Do NOT use this for progress updates or status summaries - use report_progress instead, which updates the existing progress comment.",
parameters: Comment,
execute: execute(async ({ issueNumber, body }) => {
const bodyWithFooter = await addFooter(ctx, body);
const result = await ctx.octokit.rest.issues.createComment({
owner: ctx.repo.owner,
repo: ctx.repo.name,
issue_number: issueNumber,
body: bodyWithFooter,
});
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. ${SUGGESTION_FORMAT_DESCRIPTION}`),
});
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 = await 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"),
});
/**
* Standalone function to report progress to GitHub comment.
* Can be called directly without going through the MCP tool interface.
* Returns result data if successful.
* When there's no comment target (no progressCommentId and no issueNumber), returns a "skipped" result.
*/
export async function reportProgress(
ctx: ToolContext,
{ body }: { body: string }
): Promise<{
commentId?: number;
url?: string;
body: string;
action: "created" | "updated" | "skipped";
}> {
// always track the body for job summary
ctx.toolState.lastProgressBody = body;
const existingCommentId = ctx.toolState.progressComment.id;
const issueNumber =
ctx.toolState.prNumber ?? ctx.toolState.issueNumber ?? ctx.payload.event.issue_number;
const isPlanMode = ctx.toolState.selectedMode === "Plan";
// if we already have a progress comment, update it
if (existingCommentId) {
const customParts =
isPlanMode && issueNumber !== undefined
? [buildImplementPlanLink(ctx.repo.owner, ctx.repo.name, issueNumber, existingCommentId)]
: undefined;
const bodyWithoutFooter = stripExistingFooter(body);
const footer = await buildCommentFooter({
agent: ctx.agent,
octokit: ctx.octokit,
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.progressComment.wasUpdated = true;
return {
commentId: result.data.id,
url: result.data.html_url,
body: result.data.body || "",
action: "updated",
};
}
// 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 = await 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.progressComment = {
id: result.data.id,
wasUpdated: true,
};
// if Plan mode, update the comment to add the "Implement plan" link
if (isPlanMode) {
const customParts = [
buildImplementPlanLink(ctx.repo.owner, ctx.repo.name, issueNumber, result.data.id),
];
const bodyWithoutFooter = stripExistingFooter(body);
const footer = await buildCommentFooter({
agent: ctx.agent,
octokit: ctx.octokit,
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,
});
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. Call this to post updates as you work. The first call creates a comment, subsequent calls update it. Use this throughout your work to keep stakeholders informed.",
parameters: ReportProgress,
execute: execute(async ({ body }) => {
const result = await reportProgress(ctx, { body });
if (result.action === "skipped") {
// no-op: no comment target, but progress is still tracked for job summary
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 after submitting a PR review since the review body contains all necessary info.
*/
export async function deleteProgressComment(ctx: ToolContext): Promise<boolean> {
const existingCommentId = ctx.toolState.progressComment.id;
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;
}
}
// reset state but mark as "updated" so ensureProgressCommentUpdated doesn't try to handle it
ctx.toolState.progressComment = {
id: null,
wasUpdated: true,
};
return true;
}
/**
* Ensure the progress comment is updated with a generic error message if it was never updated.
* This should be called after agent execution completes to handle cases where the agent
* exited without ever calling reportProgress.
*
* Works even if MCP context is not initialized (e.g., if error occurs before MCP server starts).
* Uses comment ID from toolState (set during initToolState from initial fetch).
*/
export async function ensureProgressCommentUpdated(toolState: ToolState): Promise<void> {
// skip if comment was already updated during execution
if (toolState.progressComment.wasUpdated) {
return;
}
// skip if there's already a progress body recorded (agent called report_progress)
if (toolState.lastProgressBody) {
return;
}
// get comment ID from toolState (already fetched during initToolState)
const existingCommentId = toolState.progressComment.id;
// if still no comment ID, nothing to update
if (!existingCommentId) {
return;
}
// check if comment still says "leaping into action" - if it's been updated with an error, don't overwrite it
const repoContext = parseRepoContext();
const octokit = createOctokit(getGitHubInstallationToken());
try {
const existingComment = await octokit.rest.issues.getComment({
owner: repoContext.owner,
repo: repoContext.name,
comment_id: existingCommentId,
});
const commentBody = existingComment.data.body || "";
// if comment doesn't start with the leaping prefix, it's already been updated with an error or progress
if (!commentBody.startsWith(LEAPING_INTO_ACTION_PREFIX)) {
return;
}
} catch {
// can't fetch comment, skip update
return;
}
const runId = process.env.GITHUB_RUN_ID;
const workflowRunLink = runId
? `[workflow run logs](https://github.com/${repoContext.owner}/${repoContext.name}/actions/runs/${runId})`
: "workflow run logs";
const errorMessage = `This run croaked 😵
The workflow encountered an error before any progress could be reported. Please check the ${workflowRunLink} for details.`;
// add footer without agent info (we don't have context here)
const body = await addFooter({ octokit }, errorMessage);
await octokit.rest.issues.updateComment({
owner: repoContext.owner,
repo: repoContext.name,
comment_id: existingCommentId,
body,
});
}
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'. ${SUGGESTION_FORMAT_DESCRIPTION}`
),
});
export function ReplyToReviewCommentTool(ctx: ToolContext) {
return tool({
name: "reply_to_review_comment",
description:
"Reply to a PR review comment thread. Call this for EACH comment you address. Keep replies extremely brief (1 sentence max).",
parameters: ReplyToReviewComment,
execute: execute(async ({ pull_number, comment_id, body }) => {
const bodyWithFooter = await 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 ensureProgressCommentUpdated doesn't think the run failed
ctx.toolState.progressComment.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"),
});
}