import { type } from "arktype"; import { buildShockbotFooter, stripExistingFooter } from "../utils/buildShockbotFooter.ts"; import { log } from "../utils/cli.ts"; import { fixDoubleEscapedString } from "../utils/fixDoubleEscapedString.ts"; import { createLeapingProgressComment, deleteProgressCommentApi, updateProgressComment, } from "../utils/progressComment.ts"; import type { ToolContext } from "./server.ts"; import { execute, tool } from "./shared.ts"; export { isLeapingIntoActionCommentBody, LEAPING_INTO_ACTION_PREFIX, } from "../utils/leapingComment.ts"; function buildCommentFooter(ctx: ToolContext): string { return buildShockbotFooter({ model: ctx.toolState.model }); } 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 Gitea 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 Gitea issue or PR. " + 'Example: `create_issue_comment({ issueNumber: 1234, body: "Thanks for the report." })`. ' + "For progress/plan updates use report_progress instead.", parameters: Comment, execute: execute(async ({ issueNumber, body }) => { const bodyWithFooter = addFooter(ctx, body); const result = await ctx.gitea.rest.issue.issueCreateComment({ owner: ctx.repo.owner, repo: ctx.repo.name, index: issueNumber, body: { body: bodyWithFooter }, }); ctx.toolState.wasUpdated = true; log.info(`» created comment ${result.data.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 Gitea issue comment by its ID", parameters: EditComment, execute: execute(async ({ commentId, body }) => { const bodyWithFooter = addFooter(ctx, body); const result = await ctx.gitea.rest.issue.issueEditComment({ owner: ctx.repo.owner, repo: ctx.repo.name, id: commentId, body: { body: bodyWithFooter }, }); log.info(`» updated comment ${result.data.id}`); 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( "for revising an existing plan comment ONLY." ), }); 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; ctx.toolState.lastProgressBody = body; if (ctx.payload.event.silent) { return { body, action: "skipped" }; } const issueNumber = ctx.payload.event.issue_number ?? ctx.toolState.issueNumber; const apiCtx = { gitea: ctx.gitea, owner: ctx.repo.owner, repo: ctx.repo.name }; if (target_plan_comment === true && ctx.toolState.existingPlanCommentId !== undefined) { const commentId = ctx.toolState.existingPlanCommentId; const bodyWithFooter = `${stripExistingFooter(body)}${buildCommentFooter(ctx)}`; const result = await updateProgressComment(apiCtx, { id: commentId, type: "issue" }, bodyWithFooter); ctx.toolState.wasUpdated = true; return { commentId: result.id, url: result.html_url, body: result.body || "", action: "updated" }; } const existingComment = ctx.toolState.progressComment; if (existingComment) { const bodyWithFooter = `${stripExistingFooter(body)}${buildCommentFooter(ctx)}`; const result = await updateProgressComment(apiCtx, existingComment, bodyWithFooter); ctx.toolState.wasUpdated = true; return { commentId: result.id, url: result.html_url, body: result.body || "", action: "updated" }; } if (existingComment === null) { return { body, action: "skipped" }; } if (issueNumber === undefined) { return { body, action: "skipped" }; } const initialBody = addFooter(ctx, body); const created = await createLeapingProgressComment( apiCtx, { kind: "issue", issueNumber }, initialBody ); ctx.toolState.progressComment = created.comment; ctx.toolState.wasUpdated = true; 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 Gitea issue/PR. First call creates a comment; subsequent calls update it. " + "Call once at the end of every run with a brief final summary (1-3 sentences).", parameters: ReportProgress, execute: execute(async (params) => { let body = params.body; 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 comment created)" }; } if (result.commentId !== undefined) log.info(`» ${result.action} comment ${result.commentId}`); if (!params.target_plan_comment) ctx.toolState.finalSummaryWritten = true; return { success: true, ...result }; }), }); } export async function deleteProgressComment(ctx: ToolContext): Promise { const existing = ctx.toolState.progressComment; if (!existing) return false; try { await deleteProgressCommentApi( { gitea: ctx.gitea, owner: ctx.repo.owner, repo: ctx.repo.name }, existing ); } catch (error) { if (!(error instanceof Error && error.message.includes("Not Found"))) throw error; } 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)"), }); export interface DuplicateReplyDecision { kind: "already-replied"; commentId: number; url: string | undefined; reason: string; } export function duplicateReplyDecision(params: { existing: { commentId: number; url: string | undefined; bodyWithFooter: string } | undefined; bodyWithFooter: string; }): DuplicateReplyDecision | null { const existing = params.existing; if (!existing) return null; if (existing.bodyWithFooter !== params.bodyWithFooter) return null; return { kind: "already-replied", commentId: existing.commentId, url: existing.url, reason: `reply ${existing.commentId} with identical body was already posted in this session; ignoring duplicate call`, }; } export function ReplyToReviewCommentTool(ctx: ToolContext) { return tool({ name: "reply_to_review_comment", description: "Reply to a PR review comment. Posts an issue comment on the PR referencing the original. " + "Keep replies extremely brief (1 sentence max).", parameters: ReplyToReviewComment, execute: execute(async ({ pull_number, comment_id, body }) => { const bodyWithFooter = addFooter(ctx, body); const dup = duplicateReplyDecision({ existing: ctx.toolState.reviewReplies?.get(comment_id), bodyWithFooter, }); if (dup) { log.info(`skipping duplicate review reply: ${dup.reason}`); return { success: true, skipped: true, reason: dup.reason, commentId: dup.commentId, url: dup.url }; } const replyBody = `> Reply to review comment #${comment_id}\n\n${bodyWithFooter}`; const result = await ctx.gitea.rest.issue.issueCreateComment({ owner: ctx.repo.owner, repo: ctx.repo.name, index: pull_number, body: { body: replyBody }, }); log.info(`» created reply comment ${result.data.id}`); ctx.toolState.wasUpdated = true; ctx.toolState.reviewReplies ??= new Map(); ctx.toolState.reviewReplies.set(comment_id, { commentId: result.data.id!, url: result.data.html_url, bodyWithFooter, }); return { success: true, commentId: result.data.id, url: result.data.html_url, body: result.data.body }; }, "reply_to_review_comment"), }); }