import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import { StringEnum } from "@earendil-works/pi-ai"; import { Type, type Static } from "typebox"; import { FIELDS_DESCRIPTION, confirmMutation, digestIssues, modelOutputBytes, pushRepeated, QUERY_DESCRIPTION, REF_DESCRIPTION, requireParam, runInContext, runListInContext, toolResult, withFooter, type ToolDeps, } from "./common.js"; import type { MutationOperation } from "../mutation-approvals.js"; const ISSUE_ACTIONS = [ "list", "get", "get_comments", "create", "edit", "change_state", "add_comment", "link", "log_work", "create_branch", "checkout", "current_reference", "valid_fields", "valid_links", "query_description", ] as const; export type IssueAction = (typeof ISSUE_ACTIONS)[number]; const ISSUE_MUTATIONS: Partial> = { create: "issue.create", edit: "issue.edit", change_state: "issue.change_state", add_comment: "issue.add_comment", link: "issue.link", log_work: "issue.log_work", create_branch: "issue.create_branch", checkout: "issue.checkout", }; export interface IssueParams { action?: IssueAction; ref?: string; title?: string; description?: string; state?: string; comment?: string; content?: string; link_name?: string; target_ref?: string; hours?: number; fields?: string[]; iterations?: string[]; own_estimated_time?: number; confidential?: boolean; for_write?: boolean; project?: string; query?: string; count?: number; offset?: number; full?: boolean; max_bytes?: number; } export function buildIssueArgs(params: IssueParams): string[] { const args: string[] = ["issue"]; const action = requireParam(params.action, "issue", "action"); const ref = (): string => requireParam(params.ref, action, "ref"); switch (action) { case "list": args.push("list"); if (params.project !== undefined) args.push("--project", params.project); if (params.query !== undefined) args.push("--query", params.query); if (params.offset !== undefined) args.push("--offset", String(params.offset)); args.push("--count", String(params.count ?? 25)); return args; case "get": return [...args, "get", ref()]; case "get_comments": return [...args, "get-comments", ref()]; case "create": args.push("create", requireParam(params.title, action, "title")); if (params.description !== undefined) args.push("--description", params.description); if (params.project !== undefined) args.push("--project", params.project); pushRepeated(args, "--field", params.fields); pushRepeated(args, "--iteration", params.iterations); if (params.own_estimated_time !== undefined) args.push("--own-estimated-time", String(params.own_estimated_time)); if (params.confidential !== undefined) args.push(`--confidential=${params.confidential}`); return args; case "edit": args.push("edit", ref()); if (params.title !== undefined) args.push("--title", params.title); if (params.description !== undefined) args.push("--description", params.description); pushRepeated(args, "--field", params.fields); pushRepeated(args, "--iteration", params.iterations); if (params.own_estimated_time !== undefined) args.push("--own-estimated-time", String(params.own_estimated_time)); if (params.confidential !== undefined) args.push(`--confidential=${params.confidential}`); return args; case "change_state": args.push( "change-state", ref(), requireParam(params.state, action, "state"), ); if (params.comment !== undefined) args.push("--comment", params.comment); pushRepeated(args, "--field", params.fields); return args; case "add_comment": return [ ...args, "add-comment", ref(), requireParam(params.content, action, "content"), ]; case "link": return [ ...args, "link", ref(), requireParam(params.link_name, action, "link_name"), requireParam(params.target_ref, action, "target_ref"), ]; case "log_work": args.push( "log-work", ref(), String(requireParam(params.hours, action, "hours")), ); if (params.comment !== undefined) args.push("--comment", params.comment); return args; case "create_branch": return [...args, "create-branch", ref()]; case "checkout": args.push("checkout", ref()); if (params.for_write === true) args.push("--for-write"); return args; case "current_reference": return [...args, "current-reference"]; case "valid_fields": return [...args, "get-valid-fields"]; case "valid_links": return [...args, "get-valid-links"]; case "query_description": return [...args, "get-query-description"]; default: throw new Error(`unsupported issue action: ${String(action)}`); } } export function registerIssueTool(pi: ExtensionAPI, deps: ToolDeps): void { const parameters = Type.Object({ action: StringEnum(ISSUE_ACTIONS), ref: Type.Optional(Type.String({ description: REF_DESCRIPTION })), title: Type.Optional(Type.String()), description: Type.Optional(Type.String()), state: Type.Optional( Type.String({ description: "Use valid_fields to discover states" }), ), comment: Type.Optional(Type.String()), content: Type.Optional(Type.String()), link_name: Type.Optional( Type.String({ description: "Use valid_links to discover names" }), ), target_ref: Type.Optional(Type.String()), hours: Type.Optional(Type.Integer({ minimum: 1 })), fields: Type.Optional( Type.Array(Type.String(), { description: FIELDS_DESCRIPTION }), ), iterations: Type.Optional(Type.Array(Type.String())), own_estimated_time: Type.Optional( Type.Number({ minimum: 0, description: "Hours" }), ), confidential: Type.Optional(Type.Boolean()), for_write: Type.Optional( Type.Boolean({ description: "Prepare checkout for push" }), ), project: Type.Optional( Type.String({ description: "Default: current project" }), ), query: Type.Optional(Type.String({ description: QUERY_DESCRIPTION })), count: Type.Optional( Type.Integer({ minimum: 1, maximum: 100, description: "Default: 25", }), ), offset: Type.Optional(Type.Integer({ minimum: 0 })), full: Type.Optional( Type.Boolean({ description: "Raw JSON instead of digest" }), ), max_bytes: modelOutputBytes(), }, { additionalProperties: false }); type Params = Static; pi.registerTool({ name: "onedev_issue", label: "OneDev Issues", description: "Read/manage OneDev issues, comments, fields, work, and branches.", parameters, async execute(_toolCallId, params: Params, signal, _onUpdate, ctx) { const args = buildIssueArgs(params); const operation = ISSUE_MUTATIONS[params.action]; if (operation) { await confirmMutation(deps, ctx, { operation, title: `Approve OneDev issue action: ${params.action}`, details: [ params.ref ? `Issue: ${params.ref}` : undefined, params.title ? `Title: ${params.title}` : undefined, ].filter(Boolean).join("\n") || "This action changes issue or local branch state.", signal, }); } const output = params.action === "list" ? await runListInContext(deps, args, { signal, project: params.project, full: params.full, maxOutputBytes: params.max_bytes, digest: digestIssues, }) : await runInContext(deps, args, { signal, maxOutputBytes: params.max_bytes, }); return toolResult( withFooter(output.text, deps.context(), output.truncated), { action: params.action, truncated: output.truncated, }, ); }, }); }