import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import { StringEnum } from "@earendil-works/pi-ai"; import { Type, type Static } from "typebox"; import { confirmMutation, DEFAULT_LARGE_MODEL_OUTPUT_BYTES, digestPulls, modelOutputBytes, pushRepeated, QUERY_DESCRIPTION, REF_DESCRIPTION, requireParam, runInContext, runListInContext, toolResult, withFooter, type ToolDeps, } from "./common.js"; import type { MutationOperation } from "../mutation-approvals.js"; const PULL_ACTIONS = [ "list", "get", "get_comments", "get_code_comments", "get_builds", "get_patch", "create", "edit", "add_comment", "add_code_comment", "reply_code_comment", "resolve_code_comment", "unresolve_code_comment", "approve", "request_changes", "merge", "discard", "checkout", "current_reference", "valid_labels", "query_description", "title_description_requirement", "commit_message_requirement", ] as const; export type PullAction = (typeof PULL_ACTIONS)[number]; const PULL_MUTATIONS: Partial> = { create: "pull.create", edit: "pull.edit", add_comment: "pull.add_comment", add_code_comment: "pull.add_code_comment", reply_code_comment: "pull.reply_code_comment", resolve_code_comment: "pull.resolve_code_comment", unresolve_code_comment: "pull.unresolve_code_comment", approve: "pull.approve", request_changes: "pull.request_changes", merge: "pull.merge", discard: "pull.discard", checkout: "pull.checkout", }; export interface PullParams { action?: PullAction; ref?: string; title?: string; description?: string; content?: string; summary?: string; commit_message?: string; comment?: string; file?: string; from_line?: number; to_line?: number; comment_id?: number; source_branch?: string; target_branch?: string; source_project?: string; target_project?: string; assignees?: string[]; reviewers?: string[]; remove_reviewers?: string[]; labels?: string[]; merge_strategy?: string; auto_merge?: boolean; auto_merge_commit_message?: string; for_code_review?: boolean; for_write?: boolean; project?: string; query?: string; count?: number; offset?: number; full?: boolean; max_bytes?: number; } export function buildPullArgs(params: PullParams): string[] { const args: string[] = ["pr"]; const action = requireParam(params.action, "pr", "action"); const ref = (): string => requireParam(params.ref, action, "ref"); const branchScope = (): void => { if (params.source_branch !== undefined) args.push("--source-branch", params.source_branch); if (params.target_branch !== undefined) args.push("--target-branch", params.target_branch); if (params.source_project !== undefined) args.push("--source-project", params.source_project); if (params.target_project !== undefined) args.push("--target-project", params.target_project); }; 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 "get_code_comments": return [...args, "get-code-comments", ref()]; case "get_builds": return [...args, "get-builds", ref()]; case "get_patch": args.push("get-patch", ref()); if (params.for_code_review === true) args.push("--for-code-review"); return args; case "create": args.push("create", requireParam(params.title, action, "title")); branchScope(); if (params.description !== undefined) args.push("--description", params.description); pushRepeated(args, "--assignee", params.assignees); pushRepeated(args, "--reviewer", params.reviewers); pushRepeated(args, "--label", params.labels); if (params.merge_strategy !== undefined) args.push("--merge-strategy", params.merge_strategy); 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, "--assignee", params.assignees); pushRepeated(args, "--add-reviewer", params.reviewers); pushRepeated(args, "--remove-reviewer", params.remove_reviewers); pushRepeated(args, "--label", params.labels); if (params.merge_strategy !== undefined) args.push("--merge-strategy", params.merge_strategy); if (params.auto_merge !== undefined) { args.push(`--auto-merge=${params.auto_merge}`); } if (params.auto_merge_commit_message !== undefined) args.push("--auto-merge-commit-message", params.auto_merge_commit_message); return args; case "add_comment": return [ ...args, "add-comment", ref(), requireParam(params.content, action, "content"), ]; case "add_code_comment": args.push( "add-code-comment", ref(), requireParam(params.content, action, "content"), ); args.push("--file", requireParam(params.file, action, "file")); args.push( "--from-line", String(requireParam(params.from_line, action, "from_line")), ); args.push("--to-line", String(params.to_line ?? params.from_line)); return args; case "reply_code_comment": return [ "code-comment", "add-reply", String(requireParam(params.comment_id, action, "comment_id")), requireParam(params.content, action, "content"), ]; case "resolve_code_comment": return [ "code-comment", "resolve", String(requireParam(params.comment_id, action, "comment_id")), ]; case "unresolve_code_comment": return [ "code-comment", "unresolve", String(requireParam(params.comment_id, action, "comment_id")), ]; case "approve": args.push("approve", ref()); if (params.summary !== undefined) args.push("--summary", params.summary); return args; case "request_changes": args.push("request-changes", ref()); if (params.summary !== undefined) args.push("--summary", params.summary); return args; case "merge": args.push("merge", ref()); if (params.commit_message !== undefined) args.push("--commit-message", params.commit_message); return args; case "discard": args.push("discard", ref()); if (params.comment !== undefined) args.push("--comment", params.comment); return args; 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_labels": return ["get-valid-labels"]; case "query_description": return [...args, "get-query-description"]; case "title_description_requirement": args.push("get-title-and-description-requirement"); branchScope(); return args; case "commit_message_requirement": args.push("get-commit-message-requirement"); branchScope(); return args; default: throw new Error(`unsupported pull action: ${String(action)}`); } } export function registerPullTool(pi: ExtensionAPI, deps: ToolDeps): void { const parameters = Type.Object({ action: StringEnum(PULL_ACTIONS), ref: Type.Optional(Type.String({ description: REF_DESCRIPTION })), title: Type.Optional(Type.String()), description: Type.Optional(Type.String()), content: Type.Optional(Type.String()), summary: Type.Optional(Type.String()), comment: Type.Optional(Type.String()), commit_message: Type.Optional(Type.String()), file: Type.Optional(Type.String()), from_line: Type.Optional( Type.Integer({ minimum: 1, description: "Right-side line" }), ), to_line: Type.Optional(Type.Integer({ minimum: 1 })), comment_id: Type.Optional(Type.Integer({ minimum: 1 })), source_branch: Type.Optional(Type.String()), target_branch: Type.Optional(Type.String()), source_project: Type.Optional(Type.String()), target_project: Type.Optional(Type.String()), assignees: Type.Optional(Type.Array(Type.String())), reviewers: Type.Optional(Type.Array(Type.String())), remove_reviewers: Type.Optional(Type.Array(Type.String())), labels: Type.Optional( Type.Array(Type.String(), { description: "Use valid_labels" }), ), merge_strategy: Type.Optional(Type.String()), auto_merge: Type.Optional(Type.Boolean()), auto_merge_commit_message: Type.Optional(Type.String()), for_code_review: Type.Optional( Type.Boolean({ description: "Review-scoped patch" }), ), 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 })), 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_pull", label: "OneDev Pull Requests", description: "Read/manage OneDev PRs, patch, comments, reviews, and guarded merge.", parameters, async execute(_toolCallId, params: Params, signal, _onUpdate, ctx) { const args = buildPullArgs(params); const operation = PULL_MUTATIONS[params.action]; if (operation) { await confirmMutation(deps, ctx, { operation, title: `Approve OneDev pull request action: ${params.action}`, details: [ params.ref ? `Pull request: ${params.ref}` : undefined, params.comment_id ? `Code comment: ${params.comment_id}` : undefined, params.title ? `Title: ${params.title}` : undefined, ].filter(Boolean).join("\n") || "This action changes pull request 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: digestPulls, }) : await runInContext(deps, args, { signal, maxOutputBytes: params.max_bytes ?? (params.action === "get_patch" ? DEFAULT_LARGE_MODEL_OUTPUT_BYTES : undefined), }); return toolResult( withFooter(output.text, deps.context(), output.truncated), { action: params.action, truncated: output.truncated, }, ); }, }); }