import { copyFile, mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { isAbsolute, join, resolve } from "node:path"; 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, digestBuilds, modelOutputBytes, modelOutputLimit, pushRepeated, QUERY_DESCRIPTION, REF_DESCRIPTION, requireParam, runInContext, runListInContext, toolResult, withFooter, type ToolDeps, } from "./common.js"; import { BUILD_RUN_TIMEOUT_MS, runTod, TodError, type TodOutput, } from "../tod.js"; import type { MutationOperation } from "../mutation-approvals.js"; const BUILD_ACTIONS = [ "list", "get", "get_log", "get_code_problems", "get_unit_test_report", "get_changes_since_success", "run", "check_spec", "get_spec_schema", "query_description", ] as const; export type BuildAction = (typeof BUILD_ACTIONS)[number]; const BUILD_MUTATIONS: Partial> = { run: "build.run", check_spec: "build.check_spec", }; export const SEVERITY_LEVELS = ["CRITICAL", "HIGH", "MEDIUM", "LOW"] as const; export const RUN_MODES = ["local", "branch", "tag"] as const; export interface BuildParams { action?: BuildAction; ref?: string; report_name?: string; severity?: (typeof SEVERITY_LEVELS)[number]; job?: string; mode?: (typeof RUN_MODES)[number]; branch?: string; tag?: string; params?: string[]; project?: string; query?: string; count?: number; offset?: number; full?: boolean; max_bytes?: number; } /** * Local unless another mode or an explicit branch/tag is given. Names the * non-obvious defaulting rule shared by the arg builder and the isolated * index path; both call sites must resolve it identically. */ function isLocalRunMode( params: Pick, ): boolean { return ( params.mode === "local" || (params.mode === undefined && !params.branch && !params.tag) ); } export function buildBuildArgs(params: BuildParams): string[] { const args: string[] = ["build"]; const action = requireParam(params.action, "build", "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_log": return [...args, "get-log", ref()]; case "get_code_problems": return [ ...args, "get-code-problems", ref(), requireParam(params.report_name, action, "report_name"), requireParam(params.severity, action, "severity"), ]; case "get_unit_test_report": return [ ...args, "get-unit-test-report", ref(), requireParam(params.report_name, action, "report_name"), ]; case "get_changes_since_success": return [...args, "get-changes-since-success", ref()]; case "run": args.push("run", requireParam(params.job, action, "job")); if (params.branch !== undefined && params.tag !== undefined) { throw new Error("run accepts either branch or tag, not both"); } if ( params.mode === "local" && (params.branch !== undefined || params.tag !== undefined) ) { throw new Error("local run mode does not accept branch or tag"); } if (params.mode === "branch" && params.tag !== undefined) { throw new Error("branch run mode does not accept tag"); } if (params.mode === "tag" && params.branch !== undefined) { throw new Error("tag run mode does not accept branch"); } if (isLocalRunMode(params)) { args.push("--local"); } else if (params.mode === "branch" || params.branch !== undefined) { args.push("--branch", requireParam(params.branch, action, "branch")); } else { args.push("--tag", requireParam(params.tag, action, "tag")); } pushRepeated(args, "-p", params.params); return args; case "check_spec": return [...args, "check-spec"]; case "get_spec_schema": return [...args, "get-spec-schema"]; case "query_description": return [...args, "get-query-description"]; default: throw new Error(`unsupported build action: ${String(action)}`); } } async function ensureUnitTestReportSupport( deps: ToolDeps, signal?: AbortSignal, ): Promise { const help = await runInContext(deps, ["build", "--help"], { signal, maxOutputBytes: 12_000, }); if (!/^\s+get-unit-test-report\s/m.test(help.text)) { throw new TodError( "the installed tod does not support build get-unit-test-report; update tod from https://code.onedev.io/onedev/tod", ); } } async function runLocalBuildWithIsolatedIndex( deps: ToolDeps, args: string[], signal?: AbortSignal, maxOutputBytes?: number, ): Promise { if (process.platform === "win32") { throw new TodError( "local build mode is disabled on Windows because TOD modifies the git index; use branch or tag mode", ); } const context = deps.context(); const indexResult = await deps.exec( "git", ["rev-parse", "--git-path", "index"], { cwd: context.cwd, timeout: 10_000, signal }, ); if (indexResult.killed || indexResult.code !== 0) { throw new TodError( `could not isolate the git index for local build mode: ${(indexResult.stderr || indexResult.stdout).trim() || "git rev-parse failed"}`, ); } const indexPath = indexResult.stdout.trim(); const sourceIndex = isAbsolute(indexPath) ? indexPath : resolve(context.cwd, indexPath); const directory = await mkdtemp(join(tmpdir(), "pi-onedev-index-")); const isolatedIndex = join(directory, "index"); try { await copyFile(sourceIndex, isolatedIndex); return await runTod(deps.exec, args, { cwd: context.cwd, signal, timeoutMs: BUILD_RUN_TIMEOUT_MS, maxOutputBytes: modelOutputLimit(maxOutputBytes, 24_000), environment: { GIT_INDEX_FILE: isolatedIndex }, }); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") { throw new TodError( "local build mode requires an existing git index and at least one commit", { cause: error }, ); } throw error; } finally { await rm(directory, { recursive: true, force: true }); } } export function registerBuildTool(pi: ExtensionAPI, deps: ToolDeps): void { const parameters = Type.Object({ action: StringEnum(BUILD_ACTIONS), ref: Type.Optional(Type.String({ description: REF_DESCRIPTION })), report_name: Type.Optional(Type.String()), severity: Type.Optional(StringEnum(SEVERITY_LEVELS)), job: Type.Optional(Type.String()), mode: Type.Optional( StringEnum(RUN_MODES, { description: "Default: local" }), ), branch: Type.Optional(Type.String()), tag: Type.Optional(Type.String()), params: Type.Optional( Type.Array(Type.String(), { description: '"key=value" parameters' }), ), 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_build", label: "OneDev Builds", description: "Read OneDev builds, logs, reports, and changes; validate or run jobs.", parameters, async execute(_toolCallId, params: Params, signal, _onUpdate, ctx) { const isRun = params.action === "run"; const args = buildBuildArgs(params); if (params.action === "get_unit_test_report") { await ensureUnitTestReportSupport(deps, signal); } const operation = BUILD_MUTATIONS[params.action]; if (operation) { await confirmMutation(deps, ctx, { operation, title: `Approve OneDev build action: ${params.action}`, details: params.action === "check_spec" ? "TOD may upgrade .onedev-buildspec.yml in the working tree." : `Job: ${params.job ?? "?"} ยท mode: ${params.mode ?? "local"}`, signal, }); } // ponytail: build run streams the full log; preserve bounded metadata and the failure tail const isLocalRun = isRun && isLocalRunMode(params); let output: TodOutput; if (params.action === "list") { output = await runListInContext(deps, args, { signal, project: params.project, full: params.full, maxOutputBytes: params.max_bytes, digest: digestBuilds, }); } else if (isLocalRun) { output = await runLocalBuildWithIsolatedIndex( deps, args, signal, params.max_bytes, ); } else { output = await runInContext(deps, args, { signal, timeoutMs: isRun ? BUILD_RUN_TIMEOUT_MS : undefined, maxOutputBytes: params.max_bytes ?? (isRun ? 24_000 : params.action === "get_log" || params.action === "get_spec_schema" ? DEFAULT_LARGE_MODEL_OUTPUT_BYTES : undefined), }); } return toolResult( withFooter(output.text, deps.context(), output.truncated), { action: params.action, truncated: output.truncated, }, ); }, }); }