fix: issues with pagination not resolving correct url templates

This commit is contained in:
2026-05-31 01:43:42 -05:00
parent 2aca1a3aa3
commit 0438688e32
14 changed files with 287 additions and 467 deletions
+5 -5
View File
@@ -187,11 +187,11 @@ export async function main(): Promise<MainResult> {
let defaultBranch: string | undefined; let defaultBranch: string | undefined;
try { try {
const repoData = await gitea.rest.repository.repoGet({ const repoData = await gitea.request(
owner: repoContext.owner, "GET /repos/{owner}/{repo}",
repo: repoContext.name, { owner: repoContext.owner, repo: repoContext.name }
}); );
defaultBranch = repoData.data.default_branch; defaultBranch = (repoData.data as { default_branch?: string }).default_branch;
} catch { } catch {
defaultBranch = "main"; defaultBranch = "main";
} }
+27 -24
View File
@@ -136,15 +136,13 @@ export async function fetchAndFormatPrDiff(
ctx: ToolContext, ctx: ToolContext,
pullNumber: number pullNumber: number
): Promise<FetchAndFormatPrDiffResult> { ): Promise<FetchAndFormatPrDiffResult> {
const raw = await ctx.gitea.paginate(ctx.gitea.rest.repository.repoGetPullRequestFiles, { const r = await ctx.gitea.request(
owner: ctx.repo.owner, "GET /repos/{owner}/{repo}/pulls/{index}/files",
repo: ctx.repo.name, { owner: ctx.repo.owner, repo: ctx.repo.name, index: pullNumber, limit: 50 }
index: pullNumber, );
}); const files: DiffFile[] = (r.data as Array<{ filename?: string; patch?: string }>).map((f) => ({
// ChangedFile from the SDK omits `patch`; Gitea does return it, so we map here.
const files: DiffFile[] = raw.map((f) => ({
filename: f.filename, filename: f.filename,
patch: (f as unknown as { patch?: string }).patch, patch: f.patch,
})); }));
return { ...formatFilesWithLineNumbers(files), files }; return { ...formatFilesWithLineNumbers(files), files };
} }
@@ -190,7 +188,8 @@ type CheckoutPrBranchParams = {
async function abortIfPullRequestMoved(args: { gitea: Gitea; owner: string; repo: string; pr: PrData }): Promise<void> { async function abortIfPullRequestMoved(args: { gitea: Gitea; owner: string; repo: string; pr: PrData }): Promise<void> {
try { try {
const data = (await args.gitea.rest.repository.repoGetPullRequest({ owner: args.owner, repo: args.repo, index: args.pr.number })).data; const r = await args.gitea.request("GET /repos/{owner}/{repo}/pulls/{index}", { owner: args.owner, repo: args.repo, index: args.pr.number });
const data = r.data as { state?: string; head?: { sha?: string } };
if (data.state !== "open" || data.head?.sha !== args.pr.headSha) { if (data.state !== "open" || data.head?.sha !== args.pr.headSha) {
throw new Error(`PR #${args.pr.number} is no longer in the state it was at dispatch. Aborting.`); throw new Error(`PR #${args.pr.number} is no longer in the state it was at dispatch. Aborting.`);
} }
@@ -203,15 +202,14 @@ async function abortIfPullRequestMoved(args: { gitea: Gitea; owner: string; repo
type CreateTempBranchParams = { gitea: Gitea; owner: string; repo: string; branchName: string; sha: string }; type CreateTempBranchParams = { gitea: Gitea; owner: string; repo: string; branchName: string; sha: string };
async function createTempBranch(params: CreateTempBranchParams) { async function createTempBranch(params: CreateTempBranchParams) {
await params.gitea.rest.repository.repoCreateBranch({ await params.gitea.request("POST /repos/{owner}/{repo}/branches", {
owner: params.owner, owner: params.owner, repo: params.repo,
repo: params.repo, new_branch_name: params.branchName, old_ref_name: params.sha,
body: { new_branch_name: params.branchName, old_ref_name: params.sha },
}); });
return { return {
async [Symbol.asyncDispose]() { async [Symbol.asyncDispose]() {
try { try {
await params.gitea.rest.repository.repoDeleteBranch({ owner: params.owner, repo: params.repo, branch: params.branchName }); await params.gitea.request("DELETE /repos/{owner}/{repo}/branches/{branch}", { owner: params.owner, repo: params.repo, branch: params.branchName });
log.debug(`» deleted temp branch ${params.branchName}`); log.debug(`» deleted temp branch ${params.branchName}`);
} catch (e) { } catch (e) {
log.debug(`» failed to delete temp branch ${params.branchName}: ${e instanceof Error ? e.message : String(e)}`); log.debug(`» failed to delete temp branch ${params.branchName}: ${e instanceof Error ? e.message : String(e)}`);
@@ -300,13 +298,13 @@ export async function checkoutPrBranch(
let deepenDepth = 0; let deepenDepth = 0;
try { try {
const [prComp, beforeComp] = await Promise.all([ const [prComp, beforeComp] = await Promise.all([
gitea.rest.repository.repoCompareDiff({ owner, repo: name, basehead: `${pr.baseRef}...${toolState.checkoutSha}` }), gitea.request("GET /repos/{owner}/{repo}/compare/{basehead}", { owner, repo: name, basehead: `${pr.baseRef}...${toolState.checkoutSha}` }),
beforeSha && beforeShaReachable beforeSha && beforeShaReachable
? gitea.rest.repository.repoCompareDiff({ owner, repo: name, basehead: `${pr.baseRef}...${beforeSha}` }) ? gitea.request("GET /repos/{owner}/{repo}/compare/{basehead}", { owner, repo: name, basehead: `${pr.baseRef}...${beforeSha}` })
: undefined, : undefined,
]); ]);
const prTotal = prComp.data.total_commits ?? 0; const prTotal = (prComp.data as { total_commits?: number }).total_commits ?? 0;
const beforeTotal = beforeComp?.data.total_commits ?? 0; const beforeTotal = (beforeComp?.data as { total_commits?: number } | undefined)?.total_commits ?? 0;
deepenDepth = Math.max(prTotal, beforeTotal) + 10; deepenDepth = Math.max(prTotal, beforeTotal) + 10;
log.debug(`» compare: PR=${prTotal}, before=${beforeTotal}, deepen=${deepenDepth}`); log.debug(`» compare: PR=${prTotal}, before=${beforeTotal}, deepen=${deepenDepth}`);
} catch { } catch {
@@ -358,12 +356,17 @@ function describeHead(h: InitialHead): string {
export function CheckoutPrTool(ctx: ToolContext) { export function CheckoutPrTool(ctx: ToolContext) {
const runCheckout = async (pull_number: number): Promise<CheckoutPrResult> => { const runCheckout = async (pull_number: number): Promise<CheckoutPrResult> => {
const prResult = await ctx.gitea.rest.repository.repoGetPullRequest({ const prResult = await ctx.gitea.request(
owner: ctx.repo.owner, "GET /repos/{owner}/{repo}/pulls/{index}",
repo: ctx.repo.name, { owner: ctx.repo.owner, repo: ctx.repo.name, index: pull_number }
index: pull_number, );
}); const prData = prResult.data as {
const prData = prResult.data; number?: number; title?: string; body?: string | null; html_url?: string;
merged?: boolean; allow_maintainer_edit?: boolean;
head?: { sha?: string; ref?: string; repo?: { full_name?: string } | null };
base?: { ref?: string; repo?: { full_name?: string } };
state?: string;
};
const headRepo = prData.head?.repo; const headRepo = prData.head?.repo;
if (!headRepo) throw new Error(`PR #${pull_number} source repository was deleted`); if (!headRepo) throw new Error(`PR #${pull_number} source repository was deleted`);
+41 -121
View File
@@ -15,57 +15,41 @@ export {
LEAPING_INTO_ACTION_PREFIX, LEAPING_INTO_ACTION_PREFIX,
} from "../utils/leapingComment.ts"; } from "../utils/leapingComment.ts";
interface GiteaComment { id: number; body?: string | null; html_url?: string; updated_at?: string }
function buildCommentFooter(ctx: ToolContext): string { function buildCommentFooter(ctx: ToolContext): string {
return buildShockbotFooter({ model: ctx.toolState.model }); return buildShockbotFooter({ model: ctx.toolState.model });
} }
export function addFooter(ctx: ToolContext, body: string): string { export function addFooter(ctx: ToolContext, body: string): string {
if (/<br\s*\/?>[ \t]*\n(?!\s*\n)/i.test(body)) { if (/<br\s*\/?>[ \t]*\n(?!\s*\n)/i.test(body)) {
throw new Error( throw new Error("body contains <br/> followed by a non-blank line — add a blank line after <br/> tags.");
"body contains <br/> followed by a non-blank line, which breaks Gitea markdown rendering. always add a blank line after <br/> tags."
);
} }
const bodyWithoutFooter = stripExistingFooter(fixDoubleEscapedString(body)); return `${stripExistingFooter(fixDoubleEscapedString(body))}${buildCommentFooter(ctx)}`;
const footer = buildCommentFooter(ctx);
return `${bodyWithoutFooter}${footer}`;
} }
export const Comment = type({ export const Comment = type({
issueNumber: type.number.describe("the issue number to comment on"), issueNumber: type.number.describe("the issue number to comment on"),
body: type.string.describe("the comment body content"), body: type.string.describe("the comment body content"),
type: type type: type.enumerated("Plan", "Comment").optional(),
.enumerated("Plan", "Comment")
.describe("Plan: record as the plan for this run. Comment: regular comment (default).")
.optional(),
}); });
export function CreateCommentTool(ctx: ToolContext) { export function CreateCommentTool(ctx: ToolContext) {
return tool({ return tool({
name: "create_issue_comment", name: "create_issue_comment",
description: description:
"Create a comment on a Gitea issue or PR. " + "Create a comment on a Gitea issue or PR. For progress/plan updates use report_progress instead.",
'Example: `create_issue_comment({ issueNumber: 1234, body: "Thanks for the report." })`. ' +
"For progress/plan updates use report_progress instead.",
parameters: Comment, parameters: Comment,
execute: execute(async ({ issueNumber, body }) => { execute: execute(async ({ issueNumber, body }) => {
const bodyWithFooter = addFooter(ctx, body); const bodyWithFooter = addFooter(ctx, body);
const r = await ctx.gitea.request(
const result = await ctx.gitea.rest.issue.issueCreateComment({ "POST /repos/{owner}/{repo}/issues/{index}/comments",
owner: ctx.repo.owner, { owner: ctx.repo.owner, repo: ctx.repo.name, index: issueNumber, body: bodyWithFooter }
repo: ctx.repo.name, );
index: issueNumber, const data = r.data as GiteaComment;
body: { body: bodyWithFooter },
});
ctx.toolState.wasUpdated = true; ctx.toolState.wasUpdated = true;
log.info(`» created comment ${result.data.id}`); log.info(`» created comment ${data.id}`);
return { success: true, commentId: data.id, url: data.html_url, body: data.body };
return {
success: true,
commentId: result.data.id,
url: result.data.html_url,
body: result.data.body,
};
}), }),
}); });
} }
@@ -82,48 +66,30 @@ export function EditCommentTool(ctx: ToolContext) {
parameters: EditComment, parameters: EditComment,
execute: execute(async ({ commentId, body }) => { execute: execute(async ({ commentId, body }) => {
const bodyWithFooter = addFooter(ctx, body); const bodyWithFooter = addFooter(ctx, body);
const r = await ctx.gitea.request(
const result = await ctx.gitea.rest.issue.issueEditComment({ "PATCH /repos/{owner}/{repo}/issues/comments/{id}",
owner: ctx.repo.owner, { owner: ctx.repo.owner, repo: ctx.repo.name, id: commentId, body: bodyWithFooter }
repo: ctx.repo.name, );
id: commentId, const data = r.data as GiteaComment;
body: { body: bodyWithFooter }, log.info(`» updated comment ${data.id}`);
}); return { success: true, commentId: data.id, url: data.html_url, body: data.body, updatedAt: data.updated_at };
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({ export const ReportProgress = type({
body: type.string.describe("the progress update content to share"), body: type.string.describe("the progress update content to share"),
"target_plan_comment?": type("boolean").describe( "target_plan_comment?": type("boolean"),
"for revising an existing plan comment ONLY."
),
}); });
export async function reportProgress( export async function reportProgress(
ctx: ToolContext, ctx: ToolContext,
params: { body: string; target_plan_comment?: boolean } params: { body: string; target_plan_comment?: boolean }
): Promise<{ ): Promise<{ commentId?: number; url?: string; body: string; action: "created" | "updated" | "skipped" }> {
commentId?: number;
url?: string;
body: string;
action: "created" | "updated" | "skipped";
}> {
const { body, target_plan_comment } = params; const { body, target_plan_comment } = params;
ctx.toolState.lastProgressBody = body; ctx.toolState.lastProgressBody = body;
if (ctx.payload.event.silent) { if (ctx.payload.event.silent) return { body, action: "skipped" };
return { body, action: "skipped" };
}
const issueNumber = ctx.payload.event.issue_number ?? ctx.toolState.issueNumber; const issueNumber = ctx.payload.event.issue_number ?? ctx.toolState.issueNumber;
const apiCtx = { gitea: ctx.gitea, owner: ctx.repo.owner, repo: ctx.repo.name }; const apiCtx = { gitea: ctx.gitea, owner: ctx.repo.owner, repo: ctx.repo.name };
@@ -145,30 +111,14 @@ export async function reportProgress(
return { commentId: result.id, url: result.html_url, body: result.body || "", action: "updated" }; return { commentId: result.id, url: result.html_url, body: result.body || "", action: "updated" };
} }
if (existingComment === null) { if (existingComment === null) return { body, action: "skipped" };
return { body, action: "skipped" }; if (issueNumber === undefined) return { body, action: "skipped" };
}
if (issueNumber === undefined) {
return { body, action: "skipped" };
}
const initialBody = addFooter(ctx, body); const initialBody = addFooter(ctx, body);
const created = await createLeapingProgressComment( const created = await createLeapingProgressComment(apiCtx, { kind: "issue", issueNumber }, initialBody);
apiCtx,
{ kind: "issue", issueNumber },
initialBody
);
ctx.toolState.progressComment = created.comment; ctx.toolState.progressComment = created.comment;
ctx.toolState.wasUpdated = true; ctx.toolState.wasUpdated = true;
return { commentId: created.comment.id, url: created.html_url, body: created.body || "", action: "created" };
return {
commentId: created.comment.id,
url: created.html_url,
body: created.body || "",
action: "created",
};
} }
export function ReportProgressTool(ctx: ToolContext) { export function ReportProgressTool(ctx: ToolContext) {
@@ -180,26 +130,18 @@ export function ReportProgressTool(ctx: ToolContext) {
parameters: ReportProgress, parameters: ReportProgress,
execute: execute(async (params) => { execute: execute(async (params) => {
let body = params.body; let body = params.body;
if (!params.target_plan_comment && ctx.toolState.todoTracker) { if (!params.target_plan_comment && ctx.toolState.todoTracker) {
ctx.toolState.todoTracker.cancel(); ctx.toolState.todoTracker.cancel();
await ctx.toolState.todoTracker.settled(); await ctx.toolState.todoTracker.settled();
const collapsible = ctx.toolState.todoTracker.renderCollapsible({ completeInProgress: true }); const collapsible = ctx.toolState.todoTracker.renderCollapsible({ completeInProgress: true });
if (collapsible) body = `${body}\n\n${collapsible}`; if (collapsible) body = `${body}\n\n${collapsible}`;
} }
const reportParams: { body: string; target_plan_comment?: boolean } = { body }; const reportParams: { body: string; target_plan_comment?: boolean } = { body };
if (params.target_plan_comment !== undefined) reportParams.target_plan_comment = params.target_plan_comment; if (params.target_plan_comment !== undefined) reportParams.target_plan_comment = params.target_plan_comment;
const result = await reportProgress(ctx, reportParams); const result = await reportProgress(ctx, reportParams);
if (result.action === "skipped") return { success: true, message: "progress recorded (no comment created)" };
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 (result.commentId !== undefined) log.info(`» ${result.action} comment ${result.commentId}`);
if (!params.target_plan_comment) ctx.toolState.finalSummaryWritten = true; if (!params.target_plan_comment) ctx.toolState.finalSummaryWritten = true;
return { success: true, ...result }; return { success: true, ...result };
}), }),
}); });
@@ -208,16 +150,11 @@ export function ReportProgressTool(ctx: ToolContext) {
export async function deleteProgressComment(ctx: ToolContext): Promise<boolean> { export async function deleteProgressComment(ctx: ToolContext): Promise<boolean> {
const existing = ctx.toolState.progressComment; const existing = ctx.toolState.progressComment;
if (!existing) return false; if (!existing) return false;
try { try {
await deleteProgressCommentApi( await deleteProgressCommentApi({ gitea: ctx.gitea, owner: ctx.repo.owner, repo: ctx.repo.name }, existing);
{ gitea: ctx.gitea, owner: ctx.repo.owner, repo: ctx.repo.name },
existing
);
} catch (error) { } catch (error) {
if (!(error instanceof Error && error.message.includes("Not Found"))) throw error; if (!(error instanceof Error && error.message.includes("Not Found"))) throw error;
} }
ctx.toolState.progressComment = null; ctx.toolState.progressComment = null;
return true; return true;
} }
@@ -229,10 +166,7 @@ export const ReplyToReviewComment = type({
}); });
export interface DuplicateReplyDecision { export interface DuplicateReplyDecision {
kind: "already-replied"; kind: "already-replied"; commentId: number; url: string | undefined; reason: string;
commentId: number;
url: string | undefined;
reason: string;
} }
export function duplicateReplyDecision(params: { export function duplicateReplyDecision(params: {
@@ -246,47 +180,33 @@ export function duplicateReplyDecision(params: {
kind: "already-replied", kind: "already-replied",
commentId: existing.commentId, commentId: existing.commentId,
url: existing.url, url: existing.url,
reason: `reply ${existing.commentId} with identical body was already posted in this session; ignoring duplicate call`, reason: `reply ${existing.commentId} with identical body was already posted in this session`,
}; };
} }
export function ReplyToReviewCommentTool(ctx: ToolContext) { export function ReplyToReviewCommentTool(ctx: ToolContext) {
return tool({ return tool({
name: "reply_to_review_comment", name: "reply_to_review_comment",
description: description: "Reply to a PR review comment. Posts an issue comment on the PR. Keep replies to 1 sentence max.",
"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, parameters: ReplyToReviewComment,
execute: execute(async ({ pull_number, comment_id, body }) => { execute: execute(async ({ pull_number, comment_id, body }) => {
const bodyWithFooter = addFooter(ctx, body); const bodyWithFooter = addFooter(ctx, body);
const dup = duplicateReplyDecision({ existing: ctx.toolState.reviewReplies?.get(comment_id), bodyWithFooter });
const dup = duplicateReplyDecision({
existing: ctx.toolState.reviewReplies?.get(comment_id),
bodyWithFooter,
});
if (dup) { if (dup) {
log.info(`skipping duplicate review reply: ${dup.reason}`); log.info(`skipping duplicate review reply: ${dup.reason}`);
return { success: true, skipped: true, reason: dup.reason, commentId: dup.commentId, url: dup.url }; 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 replyBody = `> Reply to review comment #${comment_id}\n\n${bodyWithFooter}`;
const result = await ctx.gitea.rest.issue.issueCreateComment({ const r = await ctx.gitea.request(
owner: ctx.repo.owner, "POST /repos/{owner}/{repo}/issues/{index}/comments",
repo: ctx.repo.name, { owner: ctx.repo.owner, repo: ctx.repo.name, index: pull_number, body: replyBody }
index: pull_number, );
body: { body: replyBody }, const data = r.data as GiteaComment;
}); log.info(`» created reply comment ${data.id}`);
log.info(`» created reply comment ${result.data.id}`);
ctx.toolState.wasUpdated = true; ctx.toolState.wasUpdated = true;
ctx.toolState.reviewReplies ??= new Map(); ctx.toolState.reviewReplies ??= new Map();
ctx.toolState.reviewReplies.set(comment_id, { ctx.toolState.reviewReplies.set(comment_id, { commentId: data.id!, url: data.html_url, bodyWithFooter });
commentId: result.data.id!, return { success: true, commentId: data.id, url: data.html_url, body: data.body };
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"), }, "reply_to_review_comment"),
}); });
} }
+20 -29
View File
@@ -6,52 +6,43 @@ import { formatFilesWithLineNumbers, type DiffFile } from "./checkout.ts";
import type { ToolContext } from "./server.ts"; import type { ToolContext } from "./server.ts";
import { execute, tool } from "./shared.ts"; import { execute, tool } from "./shared.ts";
export const CommitInfo = type({ interface GiteaCommit {
sha: type.string.describe("the commit SHA (full or abbreviated) to fetch"), sha?: string; html_url?: string; parents?: Array<{ sha?: string }>;
}); commit?: { message?: string; author?: { name?: string; date?: string }; committer?: { name?: string; date?: string } };
author?: { login?: string }; committer?: { login?: string };
stats?: { additions?: number; deletions?: number; total?: number };
files?: Array<{ filename?: string; status?: string; patch?: string }>;
}
export const CommitInfo = type({ sha: type.string.describe("the commit SHA to fetch") });
export function CommitInfoTool(ctx: ToolContext) { export function CommitInfoTool(ctx: ToolContext) {
return tool({ return tool({
name: "get_commit_info", name: "get_commit_info",
description: description: "Retrieve commit metadata and diff via Gitea API. Returns diffPath pointing to formatted diff file.",
"Retrieve commit metadata and diff via Gitea API. Returns diffPath pointing to formatted diff file. " +
'Example: `get_commit_info({ sha: "2a6ab5d" })`.',
parameters: CommitInfo, parameters: CommitInfo,
execute: execute(async ({ sha }) => { execute: execute(async ({ sha }) => {
const result = await ctx.gitea.rest.repository.repoGetSingleCommit({ const r = await ctx.gitea.request(
owner: ctx.repo.owner, "GET /repos/{owner}/{repo}/git/commits/{sha}",
repo: ctx.repo.name, { owner: ctx.repo.owner, repo: ctx.repo.name, sha }
sha, );
}); const data = r.data as GiteaCommit;
const data = result.data; const files: DiffFile[] = (data.files ?? []).map((f) => ({ filename: f.filename, patch: f.patch }));
// The SDK's CommitAffectedFiles type omits `patch`, which Gitea does return
// in practice. Cast explicitly so we can pass it to formatFilesWithLineNumbers.
const files: DiffFile[] = (data.files ?? []).map((f) => ({
filename: f.filename,
patch: (f as unknown as { patch?: string }).patch,
}));
const formatResult = formatFilesWithLineNumbers(files); const formatResult = formatFilesWithLineNumbers(files);
const tempDir = process.env.SHOCKBOT_TEMP_DIR; const tempDir = process.env.SHOCKBOT_TEMP_DIR;
if (!tempDir) { if (!tempDir) throw new Error("SHOCKBOT_TEMP_DIR not set");
throw new Error("SHOCKBOT_TEMP_DIR not set - get_commit_info must run in shockbot action context");
}
const diffFile = join(tempDir, `commit-${sha.slice(0, 7)}.diff`); const diffFile = join(tempDir, `commit-${sha.slice(0, 7)}.diff`);
writeFileSync(diffFile, formatResult.content); writeFileSync(diffFile, formatResult.content);
log.debug(`wrote commit diff to ${diffFile} (${formatResult.content.length} bytes)`); log.debug(`wrote commit diff to ${diffFile}`);
return { return {
sha: data.sha, sha: data.sha, message: data.commit?.message,
message: data.commit?.message,
author: data.author?.login ?? data.commit?.author?.name ?? null, author: data.author?.login ?? data.commit?.author?.name ?? null,
committer: data.committer?.login ?? data.commit?.committer?.name ?? null, committer: data.committer?.login ?? data.commit?.committer?.name ?? null,
date: data.commit?.author?.date ?? data.commit?.committer?.date ?? "", date: data.commit?.author?.date ?? data.commit?.committer?.date ?? "",
url: data.html_url, url: data.html_url,
parents: (data.parents ?? []).map((p) => p.sha), parents: (data.parents ?? []).map((p) => p.sha),
stats: data.stats ?? { additions: 0, deletions: 0, total: 0 }, stats: data.stats ?? { additions: 0, deletions: 0, total: 0 },
fileCount: files.length, fileCount: files.length, diffFile,
diffFile,
}; };
}), }),
}); });
+20 -31
View File
@@ -4,17 +4,16 @@ import { fixDoubleEscapedString } from "../utils/fixDoubleEscapedString.ts";
import type { ToolContext } from "./server.ts"; import type { ToolContext } from "./server.ts";
import { execute, tool } from "./shared.ts"; import { execute, tool } from "./shared.ts";
interface GiteaIssue {
number: number; html_url: string; title: string; state: string;
labels?: Array<{ name?: string }>; assignees?: Array<{ login: string }>;
}
export const Issue = type({ export const Issue = type({
title: type.string.describe("the title of the issue"), title: type.string.describe("the title of the issue"),
body: type.string.describe("the body content of the issue"), body: type.string.describe("the body content of the issue"),
labels: type.string labels: type.string.array().optional(),
.array() assignees: type.string.array().optional(),
.describe("optional array of label names to apply to the issue")
.optional(),
assignees: type.string
.array()
.describe("optional array of usernames to assign to the issue")
.optional(),
}); });
export function IssueTool(ctx: ToolContext) { export function IssueTool(ctx: ToolContext) {
@@ -23,30 +22,20 @@ export function IssueTool(ctx: ToolContext) {
description: "Create a new Gitea issue", description: "Create a new Gitea issue",
parameters: Issue, parameters: Issue,
execute: execute(async (params) => { execute: execute(async (params) => {
const result = await ctx.gitea.rest.issue.issueCreateIssue({ const r = await ctx.gitea.request(
owner: ctx.repo.owner, "POST /repos/{owner}/{repo}/issues",
repo: ctx.repo.name, {
body: { owner: ctx.repo.owner, repo: ctx.repo.name,
title: params.title, title: params.title, body: fixDoubleEscapedString(params.body),
body: fixDoubleEscapedString(params.body), ...(params.assignees ? { assignees: params.assignees } : {}),
assignees: params.assignees, }
}, );
}); const data = r.data as GiteaIssue;
log.info(`» created issue #${data.number}`);
log.info(`» created issue #${result.data.number}`);
return { return {
success: true, success: true, number: data.number, url: data.html_url, title: data.title, state: data.state,
number: result.data.number, labels: data.labels?.map((l) => l.name).filter((n): n is string => n !== undefined),
url: result.data.html_url, assignees: data.assignees?.map((a) => a.login).filter((n): n is string => n !== undefined),
title: result.data.title,
state: result.data.state,
labels: result.data.labels
?.map((l) => (typeof l === "string" ? l : l.name))
.filter((n): n is string => n !== undefined),
assignees: result.data.assignees
?.map((a) => a.login)
.filter((n): n is string => n !== undefined),
}; };
}), }),
}); });
+9 -15
View File
@@ -2,6 +2,8 @@ import { type } from "arktype";
import type { ToolContext } from "./server.ts"; import type { ToolContext } from "./server.ts";
import { execute, tool } from "./shared.ts"; import { execute, tool } from "./shared.ts";
interface GiteaComment { id: number; body?: string | null; user?: { login?: string } }
export const GetIssueComments = type({ export const GetIssueComments = type({
issue_number: type.number.describe("The issue number to get comments for"), issue_number: type.number.describe("The issue number to get comments for"),
}); });
@@ -9,26 +11,18 @@ export const GetIssueComments = type({
export function GetIssueCommentsTool(ctx: ToolContext) { export function GetIssueCommentsTool(ctx: ToolContext) {
return tool({ return tool({
name: "get_issue_comments", name: "get_issue_comments",
description: description: "Get all comments for a Gitea issue or PR. Example: `get_issue_comments({ issue_number: 1234 })`.",
"Get all comments for a Gitea issue or PR. " +
"Example: `get_issue_comments({ issue_number: 1234 })`.",
parameters: GetIssueComments, parameters: GetIssueComments,
execute: execute(async ({ issue_number }) => { execute: execute(async ({ issue_number }) => {
ctx.toolState.issueNumber = issue_number; ctx.toolState.issueNumber = issue_number;
const r = await ctx.gitea.request(
const comments = await ctx.gitea.paginate(ctx.gitea.rest.issue.issueGetComments, { "GET /repos/{owner}/{repo}/issues/{index}/comments",
owner: ctx.repo.owner, { owner: ctx.repo.owner, repo: ctx.repo.name, index: issue_number, limit: 50 }
repo: ctx.repo.name, );
index: issue_number, const comments = r.data as GiteaComment[];
});
return { return {
issue_number, issue_number,
comments: comments.map((c) => ({ comments: comments.map((c) => ({ id: c.id, body: c.body, user: c.user?.login })),
id: c.id,
body: c.body,
user: c.user?.login,
})),
count: comments.length, count: comments.length,
}; };
}), }),
+19 -30
View File
@@ -2,6 +2,13 @@ import { type } from "arktype";
import type { ToolContext } from "./server.ts"; import type { ToolContext } from "./server.ts";
import { execute, tool } from "./shared.ts"; import { execute, tool } from "./shared.ts";
interface GiteaIssue {
number: number; title: string; body?: string | null; state: string; html_url: string;
user?: { login: string }; labels?: Array<{ name?: string }>; assignees?: Array<{ login: string }>;
comments: number; created_at: string; updated_at: string; closed_at?: string | null;
milestone?: { title: string } | null; pull_request?: { html_url?: string } | null;
}
export const IssueInfo = type({ export const IssueInfo = type({
issue_number: type.number.describe("The issue number to fetch"), issue_number: type.number.describe("The issue number to fetch"),
}); });
@@ -9,42 +16,24 @@ export const IssueInfo = type({
export function IssueInfoTool(ctx: ToolContext) { export function IssueInfoTool(ctx: ToolContext) {
return tool({ return tool({
name: "get_issue", name: "get_issue",
description: description: "Retrieve Gitea issue information by issue number. Example: `get_issue({ issue_number: 1234 })`.",
"Retrieve Gitea issue information by issue number. " +
"Example: `get_issue({ issue_number: 1234 })`.",
parameters: IssueInfo, parameters: IssueInfo,
execute: execute(async ({ issue_number }) => { execute: execute(async ({ issue_number }) => {
const result = await ctx.gitea.rest.issue.issueGetIssue({ const r = await ctx.gitea.request(
owner: ctx.repo.owner, "GET /repos/{owner}/{repo}/issues/{index}",
repo: ctx.repo.name, { owner: ctx.repo.owner, repo: ctx.repo.name, index: issue_number }
index: issue_number, );
}); const data = r.data as GiteaIssue;
const data = result.data;
ctx.toolState.issueNumber = issue_number; ctx.toolState.issueNumber = issue_number;
const hints: string[] = []; const hints: string[] = [];
if ((data.comments ?? 0) > 0) { if (data.comments > 0) hints.push("use get_issue_comments to retrieve all comments for this issue");
hints.push("use get_issue_comments to retrieve all comments for this issue");
}
return { return {
number: data.number, number: data.number, url: data.html_url, title: data.title, body: data.body,
url: data.html_url,
title: data.title,
body: data.body,
state: data.state, state: data.state,
labels: data.labels labels: data.labels?.map((l) => l.name).filter((n): n is string => n !== undefined),
?.map((l) => (typeof l === "string" ? l : l.name)) assignees: data.assignees?.map((a) => a.login).filter((n): n is string => n !== undefined),
.filter((n): n is string => n !== undefined), user: data.user?.login, created_at: data.created_at, updated_at: data.updated_at,
assignees: data.assignees closed_at: data.closed_at, comments: data.comments, milestone: data.milestone?.title,
?.map((a) => a.login)
.filter((n): n is string => n !== undefined),
user: data.user?.login,
created_at: data.created_at,
updated_at: data.updated_at,
closed_at: data.closed_at,
comments: data.comments,
milestone: data.milestone?.title,
pull_request: data.pull_request ? { html_url: data.pull_request.html_url } : null, pull_request: data.pull_request ? { html_url: data.pull_request.html_url } : null,
hints, hints,
}; };
+14 -18
View File
@@ -3,6 +3,8 @@ import { log } from "../utils/cli.ts";
import type { ToolContext } from "./server.ts"; import type { ToolContext } from "./server.ts";
import { execute, tool } from "./shared.ts"; import { execute, tool } from "./shared.ts";
interface GiteaLabel { id: number; name?: string }
export const AddLabelsParams = type({ export const AddLabelsParams = type({
issue_number: type.number.describe("the issue or PR number to add labels to"), issue_number: type.number.describe("the issue or PR number to add labels to"),
labels: type.string.array().atLeastLength(1).describe("array of label names to add"), labels: type.string.array().atLeastLength(1).describe("array of label names to add"),
@@ -11,15 +13,14 @@ export const AddLabelsParams = type({
export function AddLabelsTool(ctx: ToolContext) { export function AddLabelsTool(ctx: ToolContext) {
return tool({ return tool({
name: "add_labels", name: "add_labels",
description: description: "Add labels to a Gitea issue or pull request. Only use labels that already exist in the repository.",
"Add labels to a Gitea issue or pull request. Only use labels that already exist in the repository.",
parameters: AddLabelsParams, parameters: AddLabelsParams,
execute: execute(async ({ issue_number, labels }) => { execute: execute(async ({ issue_number, labels }) => {
// Resolve label names to IDs (Gitea uses IDs, not names) const allLabelsR = await ctx.gitea.request(
const allLabels = await ctx.gitea.paginate(ctx.gitea.rest.issue.issueListLabels, { "GET /repos/{owner}/{repo}/labels",
owner: ctx.repo.owner, { owner: ctx.repo.owner, repo: ctx.repo.name, limit: 50 }
repo: ctx.repo.name, );
}); const allLabels = allLabelsR.data as GiteaLabel[];
const labelIds = labels const labelIds = labels
.map((name) => allLabels.find((l) => l.name === name)?.id) .map((name) => allLabels.find((l) => l.name === name)?.id)
.filter((id): id is number => typeof id === "number"); .filter((id): id is number => typeof id === "number");
@@ -28,18 +29,13 @@ export function AddLabelsTool(ctx: ToolContext) {
return { success: true, labels: [], message: "No matching labels found in repository" }; return { success: true, labels: [], message: "No matching labels found in repository" };
} }
const result = await ctx.gitea.rest.issue.issueAddLabel({ const r = await ctx.gitea.request(
owner: ctx.repo.owner, "POST /repos/{owner}/{repo}/issues/{index}/labels",
repo: ctx.repo.name, { owner: ctx.repo.owner, repo: ctx.repo.name, index: issue_number, labels: labelIds }
index: issue_number, );
body: { labels: labelIds },
});
log.info(`» added labels [${labels.join(", ")}] to issue #${issue_number}`); log.info(`» added labels [${labels.join(", ")}] to issue #${issue_number}`);
const result = r.data as GiteaLabel[];
return { return { success: true, labels: result.map((l) => l.name).filter(Boolean) };
success: true,
labels: result.data.map((label) => label.name).filter(Boolean),
};
}), }),
}); });
} }
+33 -48
View File
@@ -6,16 +6,10 @@ import { $ } from "../utils/shell.ts";
import type { ToolContext } from "./server.ts"; import type { ToolContext } from "./server.ts";
import { execute, tool } from "./shared.ts"; import { execute, tool } from "./shared.ts";
export const PullRequest = type({ interface GiteaPull { number: number; html_url?: string; title?: string; head?: { ref?: string }; base?: { ref?: string } }
title: type.string.describe("the title of the pull request"),
body: type.string.describe("the body content of the pull request"),
base: type.string.describe("the base branch to merge into (e.g., 'main')"),
"draft?": type.boolean.describe("if true, create the pull request as a draft."),
});
function buildPrBodyWithFooter(ctx: ToolContext, body: string): string { function buildPrBodyWithFooter(ctx: ToolContext, body: string): string {
const footer = buildShockbotFooter({ model: ctx.toolState.model }); return `${stripExistingFooter(fixDoubleEscapedString(body))}${buildShockbotFooter({ model: ctx.toolState.model })}`;
return `${stripExistingFooter(fixDoubleEscapedString(body))}${footer}`;
} }
export const UpdatePullRequestBody = type({ export const UpdatePullRequestBody = type({
@@ -29,19 +23,25 @@ export function UpdatePullRequestBodyTool(ctx: ToolContext) {
description: "Update the body/description of an existing pull request", description: "Update the body/description of an existing pull request",
parameters: UpdatePullRequestBody, parameters: UpdatePullRequestBody,
execute: execute(async (params) => { execute: execute(async (params) => {
const result = await ctx.gitea.rest.repository.repoEditPullRequest({ const r = await ctx.gitea.request(
owner: ctx.repo.owner, "PATCH /repos/{owner}/{repo}/pulls/{index}",
repo: ctx.repo.name, { owner: ctx.repo.owner, repo: ctx.repo.name, index: params.pull_number, body: buildPrBodyWithFooter(ctx, params.body) }
index: params.pull_number, );
body: { body: buildPrBodyWithFooter(ctx, params.body) }, const data = r.data as GiteaPull;
}); log.info(`» updated pull request #${data.number}`);
log.info(`» updated pull request #${result.data.number}`);
ctx.toolState.wasUpdated = true; ctx.toolState.wasUpdated = true;
return { success: true, number: result.data.number, url: result.data.html_url }; return { success: true, number: data.number, url: data.html_url };
}), }),
}); });
} }
export const PullRequest = type({
title: type.string.describe("the title of the pull request"),
body: type.string.describe("the body content of the pull request"),
base: type.string.describe("the base branch to merge into (e.g., 'main')"),
"draft?": type.boolean,
});
export function CreatePullRequestTool(ctx: ToolContext) { export function CreatePullRequestTool(ctx: ToolContext) {
return tool({ return tool({
name: "create_pull_request", name: "create_pull_request",
@@ -49,42 +49,27 @@ export function CreatePullRequestTool(ctx: ToolContext) {
parameters: PullRequest, parameters: PullRequest,
execute: execute(async (params) => { execute: execute(async (params) => {
const currentBranch = $("git", ["rev-parse", "--abbrev-ref", "HEAD"], { log: false }).trim(); const currentBranch = $("git", ["rev-parse", "--abbrev-ref", "HEAD"], { log: false }).trim();
const r = await ctx.gitea.request(
const result = await ctx.gitea.rest.repository.repoCreatePullRequest({ "POST /repos/{owner}/{repo}/pulls",
owner: ctx.repo.owner, {
repo: ctx.repo.name, owner: ctx.repo.owner, repo: ctx.repo.name,
body: { title: params.title, body: buildPrBodyWithFooter(ctx, params.body),
title: params.title, head: currentBranch, base: params.base,
body: buildPrBodyWithFooter(ctx, params.body), }
head: currentBranch, );
base: params.base, const data = r.data as GiteaPull;
// draft: params.draft ?? false, // not in all Gitea versions log.info(`» created pull request #${data.number}`);
},
});
log.info(`» created pull request #${result.data.number}`);
const reviewer = ctx.payload.triggerer; const reviewer = ctx.payload.triggerer;
if (reviewer && result.data.number) { if (reviewer && data.number) {
try { try {
await ctx.gitea.rest.repository.repoCreatePullReviewRequests({ await ctx.gitea.request(
owner: ctx.repo.owner, "POST /repos/{owner}/{repo}/pulls/{index}/requested_reviewers",
repo: ctx.repo.name, { owner: ctx.repo.owner, repo: ctx.repo.name, index: data.number, reviewers: [reviewer] }
index: result.data.number, );
body: { reviewers: [reviewer] }, } catch { log.debug(`failed to request review from ${reviewer}`); }
});
} catch {
log.debug(`failed to request review from ${reviewer}`);
}
} }
return { success: true, number: data.number, url: data.html_url, title: data.title, head: data.head?.ref, base: data.base?.ref };
return {
success: true,
number: result.data.number,
url: result.data.html_url,
title: result.data.title,
head: result.data.head?.label ?? result.data.head?.ref,
base: result.data.base?.label ?? result.data.base?.ref,
};
}), }),
}); });
} }
+19 -22
View File
@@ -2,6 +2,15 @@ import { type } from "arktype";
import type { ToolContext } from "./server.ts"; import type { ToolContext } from "./server.ts";
import { execute, tool } from "./shared.ts"; import { execute, tool } from "./shared.ts";
interface GiteaPull {
number: number; html_url: string; title: string; body?: string | null;
state: string; draft?: boolean; merged?: boolean; allow_maintainer_edit?: boolean;
head?: { sha: string; ref: string; repo?: { full_name: string } | null };
base?: { ref: string; repo?: { full_name: string } };
user?: { login: string }; assignees?: Array<{ login: string }>;
labels?: Array<string | { name?: string }>;
}
export const PullRequestInfo = type({ export const PullRequestInfo = type({
pull_number: type.number.describe("The pull request number to fetch"), pull_number: type.number.describe("The pull request number to fetch"),
}); });
@@ -11,34 +20,22 @@ export function PullRequestInfoTool(ctx: ToolContext) {
name: "get_pull_request", name: "get_pull_request",
description: description:
"Retrieve PR metadata (title, body, state, branches, author, labels). " + "Retrieve PR metadata (title, body, state, branches, author, labels). " +
"Example: `get_pull_request({ pull_number: 1234 })`. " + "Example: `get_pull_request({ pull_number: 1234 })`. To checkout a PR branch locally, use checkout_pr instead.",
"To checkout a PR branch locally, use checkout_pr instead.",
parameters: PullRequestInfo, parameters: PullRequestInfo,
execute: execute(async ({ pull_number }) => { execute: execute(async ({ pull_number }) => {
const result = await ctx.gitea.rest.repository.repoGetPullRequest({ const r = await ctx.gitea.request(
owner: ctx.repo.owner, "GET /repos/{owner}/{repo}/pulls/{index}",
repo: ctx.repo.name, { owner: ctx.repo.owner, repo: ctx.repo.name, index: pull_number }
index: pull_number, );
}); const data = r.data as GiteaPull;
const data = result.data;
const isFork = data.head?.repo?.full_name !== data.base?.repo?.full_name; const isFork = data.head?.repo?.full_name !== data.base?.repo?.full_name;
return { return {
number: data.number, number: data.number, url: data.html_url, title: data.title, body: data.body,
url: data.html_url, state: data.state, draft: data.draft, merged: data.merged,
title: data.title,
body: data.body,
state: data.state,
draft: data.draft,
merged: data.merged,
maintainerCanModify: data.allow_maintainer_edit, maintainerCanModify: data.allow_maintainer_edit,
base: data.base?.label, base: data.base?.ref, head: data.head?.ref, isFork,
head: data.head?.label,
isFork,
author: data.user?.login, author: data.user?.login,
assignees: data.assignees assignees: data.assignees?.map((a) => a.login).filter((n): n is string => n !== undefined),
?.map((a) => a.login)
.filter((n): n is string => n !== undefined),
labels: data.labels labels: data.labels
?.map((l) => (typeof l === "string" ? l : l.name)) ?.map((l) => (typeof l === "string" ? l : l.name))
.filter((n): n is string => n !== undefined), .filter((n): n is string => n !== undefined),
+22 -24
View File
@@ -62,11 +62,11 @@ export async function buildCommentableMap(
const currentSha = ctx.toolState.checkoutSha; const currentSha = ctx.toolState.checkoutSha;
if (cached && cachedFor === pullNumber && cachedSha && cachedSha === currentSha) return cached; if (cached && cachedFor === pullNumber && cachedSha && cachedSha === currentSha) return cached;
const files = (await ctx.gitea.paginate(ctx.gitea.rest.repository.repoGetPullRequestFiles, { const r = await ctx.gitea.request(
owner: ctx.repo.owner, "GET /repos/{owner}/{repo}/pulls/{index}/files",
repo: ctx.repo.name, { owner: ctx.repo.owner, repo: ctx.repo.name, index: pullNumber, limit: 50 }
index: pullNumber, );
})) as ChangedFileWithPatch[]; const files = r.data as ChangedFileWithPatch[];
const map = new Map<string, CommentableLines>(); const map = new Map<string, CommentableLines>();
for (const file of files) { for (const file of files) {
@@ -244,12 +244,11 @@ export function CreatePullRequestReviewTool(ctx: ToolContext) {
let effectiveCommitId = commit_id; let effectiveCommitId = commit_id;
if (!effectiveCommitId) { if (!effectiveCommitId) {
try { try {
const pr = await ctx.gitea.rest.repository.repoGetPullRequest({ const pr = await ctx.gitea.request(
owner: ctx.repo.owner, "GET /repos/{owner}/{repo}/pulls/{index}",
repo: ctx.repo.name, { owner: ctx.repo.owner, repo: ctx.repo.name, index: pull_number }
index: pull_number, );
}); latestHeadSha = (pr.data as { head?: { sha?: string } }).head?.sha;
latestHeadSha = pr.data.head?.sha;
effectiveCommitId = ctx.toolState.checkoutSha ?? latestHeadSha; effectiveCommitId = ctx.toolState.checkoutSha ?? latestHeadSha;
} catch { } catch {
effectiveCommitId = ctx.toolState.checkoutSha; effectiveCommitId = ctx.toolState.checkoutSha;
@@ -301,18 +300,16 @@ export function CreatePullRequestReviewTool(ctx: ToolContext) {
const footer = buildShockbotFooter({ model: ctx.toolState.model }); const footer = buildShockbotFooter({ model: ctx.toolState.model });
const fullBody = body ? `${body}${footer}` : footer.trimStart(); const fullBody = body ? `${body}${footer}` : footer.trimStart();
const result = await retry<Awaited<ReturnType<typeof ctx.gitea.rest.repository.repoCreatePullReview>>>( const result = await retry(
() => () =>
ctx.gitea.rest.repository.repoCreatePullReview({ ctx.gitea.request("POST /repos/{owner}/{repo}/pulls/{index}/reviews", {
owner: ctx.repo.owner, owner: ctx.repo.owner,
repo: ctx.repo.name, repo: ctx.repo.name,
index: pull_number, index: pull_number,
body: { body: fullBody,
body: fullBody, commit_id: effectiveCommitId,
commit_id: effectiveCommitId, event,
event, comments: sdkComments,
comments: sdkComments,
},
}), }),
{ {
delaysMs: [1_000, 3_000], delaysMs: [1_000, 3_000],
@@ -321,7 +318,8 @@ export function CreatePullRequestReviewTool(ctx: ToolContext) {
} }
); );
const reviewId = result.data.id!; const reviewData = result.data as { id?: number; html_url?: string; state?: string };
const reviewId = reviewData.id!;
log.info(`» created review ${reviewId} on pull request #${pull_number}`); log.info(`» created review ${reviewId} on pull request #${pull_number}`);
ctx.toolState.review = { ctx.toolState.review = {
@@ -344,8 +342,8 @@ export function CreatePullRequestReviewTool(ctx: ToolContext) {
return { return {
success: true, success: true,
reviewId, reviewId,
html_url: result.data.html_url, html_url: reviewData.html_url,
state: result.data.state, state: reviewData.state,
droppedComments: droppedComments.length > 0 ? droppedComments : undefined, droppedComments: droppedComments.length > 0 ? droppedComments : undefined,
newCommits: { newCommits: {
from: fromSha, from: fromSha,
@@ -358,8 +356,8 @@ export function CreatePullRequestReviewTool(ctx: ToolContext) {
return { return {
success: true, success: true,
reviewId, reviewId,
html_url: result.data.html_url, html_url: reviewData.html_url,
state: result.data.state, state: reviewData.state,
droppedComments: droppedComments.length > 0 ? droppedComments : undefined, droppedComments: droppedComments.length > 0 ? droppedComments : undefined,
}; };
}), }),
+27 -57
View File
@@ -1,57 +1,45 @@
import { writeFileSync } from "node:fs"; import { writeFileSync } from "node:fs";
import { join } from "node:path"; import { join } from "node:path";
import { type } from "arktype"; import { type } from "arktype";
import type { PullReviewComment } from "@go-gitea/sdk.js";
import { stripExistingFooter } from "../utils/buildShockbotFooter.ts"; import { stripExistingFooter } from "../utils/buildShockbotFooter.ts";
import { log } from "../utils/log.ts"; import { log } from "../utils/log.ts";
import type { ToolContext } from "./server.ts"; import type { ToolContext } from "./server.ts";
import { execute, tool } from "./shared.ts"; import { execute, tool } from "./shared.ts";
export const GetReviewComments = type({ interface GiteaReview { id: number; user?: { login: string }; state?: string; body?: string; submitted_at?: string; commit_id?: string }
pull_number: type.number.describe("The pull request number to get review comments for"), interface GiteaReviewComment { id: number; body?: string; path?: string; diff_hunk?: string; original_position?: number; position?: number; pull_request_review_id?: number; user?: { login: string } }
});
export const GetReviewComments = type({ pull_number: type.number });
export function GetReviewCommentsTool(ctx: ToolContext) { export function GetReviewCommentsTool(ctx: ToolContext) {
return tool({ return tool({
name: "get_review_comments", name: "get_review_comments",
description: description: "Get all inline review comments for a pull request. Example: `get_review_comments({ pull_number: 1234 })`.",
"Get all inline review comments for a pull request. " +
"Example: `get_review_comments({ pull_number: 1234 })`.",
parameters: GetReviewComments, parameters: GetReviewComments,
execute: execute(async ({ pull_number }) => { execute: execute(async ({ pull_number }) => {
ctx.toolState.issueNumber = pull_number; ctx.toolState.issueNumber = pull_number;
const reviewsR = await ctx.gitea.request(
"GET /repos/{owner}/{repo}/pulls/{index}/reviews",
{ owner: ctx.repo.owner, repo: ctx.repo.name, index: pull_number, limit: 50 }
);
const reviews = reviewsR.data as GiteaReview[];
// Get all reviews, then collect their comments const allComments: GiteaReviewComment[] = [];
const reviews = await ctx.gitea.paginate(ctx.gitea.rest.repository.repoListPullReviews, {
owner: ctx.repo.owner,
repo: ctx.repo.name,
index: pull_number,
});
const allComments: PullReviewComment[] = [];
for (const review of reviews) { for (const review of reviews) {
if (!review.id) continue; if (!review.id) continue;
try { try {
const commentsResult = await ctx.gitea.rest.repository.repoGetPullReviewComments({ const cr = await ctx.gitea.request(
owner: ctx.repo.owner, "GET /repos/{owner}/{repo}/pulls/{index}/reviews/{id}/comments",
repo: ctx.repo.name, { owner: ctx.repo.owner, repo: ctx.repo.name, index: pull_number, id: review.id }
index: pull_number, );
id: review.id, allComments.push(...(cr.data as GiteaReviewComment[]));
}); } catch { /* best-effort */ }
allComments.push(...commentsResult.data);
} catch {
// best-effort
}
} }
const tempDir = process.env.SHOCKBOT_TEMP_DIR; const tempDir = process.env.SHOCKBOT_TEMP_DIR;
let filePath: string | undefined; let filePath: string | undefined;
const rendered = allComments.map((c) => ({ const rendered = allComments.map((c) => ({
id: c.id, id: c.id, path: c.path,
path: c.path,
// PullReviewComment uses `position` for the diff offset; Gitea doesn't
// expose separate new_position/old_position on GET — use original_position as fallback
line: c.original_position ?? c.position, line: c.original_position ?? c.position,
side: "RIGHT" as const, side: "RIGHT" as const,
body: stripExistingFooter(c.body ?? ""), body: stripExistingFooter(c.body ?? ""),
@@ -65,45 +53,27 @@ export function GetReviewCommentsTool(ctx: ToolContext) {
writeFileSync(filePath, JSON.stringify(rendered, null, 2)); writeFileSync(filePath, JSON.stringify(rendered, null, 2));
log.debug(`wrote review comments to ${filePath}`); log.debug(`wrote review comments to ${filePath}`);
} }
return { pull_number, comments: rendered, count: rendered.length, ...(filePath ? { filePath } : {}) };
return {
pull_number,
comments: rendered,
count: rendered.length,
...(filePath ? { filePath } : {}),
};
}), }),
}); });
} }
export const ListPullRequestReviews = type({ export const ListPullRequestReviews = type({ pull_number: type.number });
pull_number: type.number.describe("The pull request number"),
});
export function ListPullRequestReviewsTool(ctx: ToolContext) { export function ListPullRequestReviewsTool(ctx: ToolContext) {
return tool({ return tool({
name: "list_pull_request_reviews", name: "list_pull_request_reviews",
description: description: "List all reviews submitted on a pull request. Example: `list_pull_request_reviews({ pull_number: 1234 })`.",
"List all reviews submitted on a pull request. " +
"Example: `list_pull_request_reviews({ pull_number: 1234 })`.",
parameters: ListPullRequestReviews, parameters: ListPullRequestReviews,
execute: execute(async ({ pull_number }) => { execute: execute(async ({ pull_number }) => {
const reviews = await ctx.gitea.paginate(ctx.gitea.rest.repository.repoListPullReviews, { const r = await ctx.gitea.request(
owner: ctx.repo.owner, "GET /repos/{owner}/{repo}/pulls/{index}/reviews",
repo: ctx.repo.name, { owner: ctx.repo.owner, repo: ctx.repo.name, index: pull_number, limit: 50 }
index: pull_number, );
}); const reviews = r.data as GiteaReview[];
return { return {
pull_number, pull_number,
reviews: reviews.map((r) => ({ reviews: reviews.map((r) => ({ id: r.id, user: r.user?.login, state: r.state, body: r.body, submitted_at: r.submitted_at, commit_id: r.commit_id })),
id: r.id,
user: r.user?.login,
state: r.state,
body: r.body,
submitted_at: r.submitted_at,
commit_id: r.commit_id,
})),
count: reviews.length, count: reviews.length,
}; };
}), }),
+5 -6
View File
@@ -67,12 +67,11 @@ export function SelectModeTool(ctx: ToolContext) {
// For Plan mode with issue_number, look up existing plan comment // For Plan mode with issue_number, look up existing plan comment
if (foundMode.name === "Plan" && issue_number !== undefined) { if (foundMode.name === "Plan" && issue_number !== undefined) {
try { try {
const commentsResp = await ctx.gitea.paginate(ctx.gitea.rest.issue.issueGetComments, { const commentsR = await ctx.gitea.request(
owner: ctx.repo.owner, "GET /repos/{owner}/{repo}/issues/{index}/comments",
repo: ctx.repo.name, { owner: ctx.repo.owner, repo: ctx.repo.name, index: issue_number, limit: 50 }
index: issue_number, );
}); const comments = commentsR.data as Array<{ id?: number; body?: string | null }>;
const comments = commentsResp;
// Look for a plan comment (one with our footer) // Look for a plan comment (one with our footer)
const planComment = comments.find((c) => c.body?.includes("<!-- shockbot-footer -->")); const planComment = comments.find((c) => c.body?.includes("<!-- shockbot-footer -->"));
if (planComment) { if (planComment) {
+26 -37
View File
@@ -1,6 +1,5 @@
/** /**
* Single source of truth for reading, updating, deleting, and creating "progress comments" * Single source of truth for reading, updating, deleting, and creating "progress comments".
* the Gitea comments shockbot uses to surface a run's status.
*/ */
import type { Gitea } from "./gitea.ts"; import type { Gitea } from "./gitea.ts";
@@ -27,16 +26,22 @@ interface ApiCtx {
repo: string; repo: string;
} }
interface GiteaComment {
id: number;
body?: string | null;
html_url?: string;
node_id?: string;
}
export async function getProgressComment( export async function getProgressComment(
ctx: ApiCtx, ctx: ApiCtx,
comment: ProgressComment comment: ProgressComment
): Promise<{ id: number; body: string | undefined; html_url: string }> { ): Promise<{ id: number; body: string | undefined; html_url: string }> {
const result = await ctx.gitea.rest.issue.issueGetComment({ const r = await ctx.gitea.request("GET /repos/{owner}/{repo}/issues/comments/{id}", {
owner: ctx.owner, owner: ctx.owner, repo: ctx.repo, id: comment.id,
repo: ctx.repo,
id: comment.id,
}); });
return { id: result.data.id!, body: result.data.body ?? undefined, html_url: result.data.html_url! }; const data = r.data as GiteaComment;
return { id: data.id, body: data.body ?? undefined, html_url: data.html_url ?? "" };
} }
export async function updateProgressComment( export async function updateProgressComment(
@@ -44,28 +49,16 @@ export async function updateProgressComment(
comment: ProgressComment, comment: ProgressComment,
body: string body: string
): Promise<{ id: number; body: string | undefined; html_url: string; node_id: string | undefined }> { ): Promise<{ id: number; body: string | undefined; html_url: string; node_id: string | undefined }> {
const result = await ctx.gitea.rest.issue.issueEditComment({ const r = await ctx.gitea.request("PATCH /repos/{owner}/{repo}/issues/comments/{id}", {
owner: ctx.owner, owner: ctx.owner, repo: ctx.repo, id: comment.id, body,
repo: ctx.repo,
id: comment.id,
body: { body },
}); });
return { const data = r.data as GiteaComment;
id: result.data.id!, return { id: data.id, body: data.body ?? undefined, html_url: data.html_url ?? "", node_id: undefined };
body: result.data.body ?? undefined,
html_url: result.data.html_url!,
node_id: undefined,
};
} }
export async function deleteProgressCommentApi( export async function deleteProgressCommentApi(ctx: ApiCtx, comment: ProgressComment): Promise<void> {
ctx: ApiCtx, await ctx.gitea.request("DELETE /repos/{owner}/{repo}/issues/comments/{id}", {
comment: ProgressComment owner: ctx.owner, repo: ctx.repo, id: comment.id,
): Promise<void> {
await ctx.gitea.rest.issue.issueDeleteComment({
owner: ctx.owner,
repo: ctx.repo,
id: comment.id,
}); });
} }
@@ -84,18 +77,14 @@ export async function createLeapingProgressComment(
target: CreateProgressCommentTarget, target: CreateProgressCommentTarget,
body: string body: string
): Promise<CreatedProgressComment> { ): Promise<CreatedProgressComment> {
const issueNumber = const issueNumber = target.kind === "issue" ? target.issueNumber : target.pullNumber;
target.kind === "issue" ? target.issueNumber : target.pullNumber; const r = await ctx.gitea.request("POST /repos/{owner}/{repo}/issues/{index}/comments", {
owner: ctx.owner, repo: ctx.repo, index: issueNumber, body,
const result = await ctx.gitea.rest.issue.issueCreateComment({
owner: ctx.owner,
repo: ctx.repo,
index: issueNumber,
body: { body },
}); });
const data = r.data as GiteaComment;
return { return {
comment: { id: result.data.id!, type: "issue" }, comment: { id: data.id, type: "issue" },
body: result.data.body ?? undefined, body: data.body ?? undefined,
html_url: result.data.html_url!, html_url: data.html_url ?? "",
}; };
} }