import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import type { ExtensionContext } from "@earendil-works/pi-coding-agent"; import { Type } from "typebox"; import type { ContextRefreshOptions, OneDevSessionContext, } from "../context.js"; import type { MutationApprovalManager, MutationApprovalRequest, } from "../mutation-approvals.js"; import { TodError, runTod, truncateMiddle, type CommandExecutor, type TodOutput, } from "../tod.js"; export interface ToolDeps { exec: CommandExecutor; /** Session OneDev context; throws a friendly error before initialization. */ context: () => OneDevSessionContext; refreshContext: ( options?: ContextRefreshOptions, ) => Promise; approvals: MutationApprovalManager; } export const DEFAULT_MODEL_OUTPUT_BYTES = 16_000; export const DEFAULT_LARGE_MODEL_OUTPUT_BYTES = 32_000; export const MAX_MODEL_OUTPUT_BYTES = 128_000; export const REF_DESCRIPTION = "Ref: 123, #123, project#123, or PROJ-123; plain numbers use the current project."; export const FIELDS_DESCRIPTION = '"key=value" assignments; use valid_fields to discover values.'; export const QUERY_DESCRIPTION = 'OneDev query; e.g. \'open\', \'submitted by "user"\', \'Assignees is "user"\', \'order by "Submit Date" desc\'; full syntax: query_description.'; export function modelOutputBytes() { return Type.Optional( Type.Integer({ minimum: 1_000, maximum: MAX_MODEL_OUTPUT_BYTES }), ); } export function modelOutputLimit( requested: number | undefined, fallback = DEFAULT_MODEL_OUTPUT_BYTES, ): number { return Math.min( MAX_MODEL_OUTPUT_BYTES, Math.max(1_000, requested ?? fallback), ); } export function toolResult( text: string, data?: unknown, ): { content: Array<{ type: "text"; text: string }>; details: { data?: unknown }; } { return { content: [{ type: "text", text }], details: data === undefined ? {} : { data }, }; } /** Run tod with session cwd and pass the abort signal through. */ export async function runInContext( deps: ToolDeps, args: readonly string[], options: { timeoutMs?: number; signal?: AbortSignal; maxOutputBytes?: number; } = {}, ): Promise { const context = deps.context(); if (context.status !== "ready" || !context.project) { throw new TodError( context.problem ?? "OneDev tools require a repository whose project can be inferred from a OneDev remote", ); } return runTod(deps.exec, args, { cwd: context.cwd, signal: options.signal, timeoutMs: options.timeoutMs, maxOutputBytes: modelOutputLimit(options.maxOutputBytes), }); } export function requireParam( value: T | undefined, action: string, name: string, ): T { if (value === undefined || value === null) { throw new TodError(`action '${action}' requires parameter '${name}'`); } return value; } /** Append `--flag value` pairs for every entry of a repeatable string array. */ export function pushRepeated( args: string[], flag: string, values: readonly string[] | undefined, ): void { for (const value of values ?? []) args.push(flag, value); } /** * One-line-per-item digests for list results. Raw OneDev list payloads embed * full Markdown descriptions (~2 KB per item); digests keep bulk queries in a * few KB so pagination stays lossless. Digests return undefined when the * payload is not a JSON object array so callers fall back to the raw text. */ export const DIGEST_FETCH_BYTES = 512_000; type DigestItem = Record; function parseDigestItems(text: string): DigestItem[] | undefined { let parsed: unknown; try { parsed = JSON.parse(text); } catch { return undefined; } if (!Array.isArray(parsed)) return undefined; return parsed.every( (item) => typeof item === "object" && item !== null && !Array.isArray(item), ) ? (parsed as DigestItem[]) : undefined; } function stringAt( item: DigestItem, keys: readonly string[], ): string | undefined { for (const key of keys) { const value = item[key]; if (typeof value === "number") return String(value); if (typeof value === "string" && value.trim() !== "") return value.trim(); } return undefined; } function listAt(item: DigestItem, keys: readonly string[]): string[] { for (const key of keys) { const value = item[key]; if (Array.isArray(value) && value.length > 0) return value.map(String); } return []; } function clip(value: string, maxChars: number): string { if (value.length <= maxChars) return value; let end = maxChars - 1; const previous = value.charCodeAt(end - 1); if (previous >= 0xd800 && previous <= 0xdbff) end -= 1; return `${value.slice(0, Math.max(end, 0))}…`; } function day(value: string | undefined): string | undefined { return value === undefined ? undefined : value.slice(0, 10); } function digestLine( ref: string, tags: ReadonlyArray, summary: string, trailer: ReadonlyArray, ): string { const one = (value: string): string => value.replace(/\s+/g, " "); const bracketed = tags .filter((tag): tag is string => tag !== undefined) .map(one); const filled = trailer .filter((part): part is string => part !== undefined) .map(one); const label = one(ref); const head = bracketed.length > 0 ? `${label} [${bracketed.join(" ")}]` : label; const text = one(summary); return `${head}${text !== "" ? ` ${text}` : ""}${filled.length > 0 ? ` · ${filled.join(" · ")}` : ""}`; } export function digestIssues(text: string): string | undefined { const items = parseDigestItems(text); if (!items) return undefined; if (items.length === 0) return "(no issues)"; return items .map((item) => { const assignees = listAt(item, ["Assignees"]); return digestLine( stringAt(item, ["reference", "number"]) ?? "#?", [ stringAt(item, ["state", "status"]), ...listAt(item, ["Priority"]), ...listAt(item, ["Type"]), ], clip(stringAt(item, ["title"]) ?? "(untitled)", 120), [ stringAt(item, ["submitter"]), day(stringAt(item, ["submitDate"])), assignees.length > 0 ? `assigned: ${assignees.join(", ")}` : undefined, ], ); }) .join("\n"); } export function digestPulls(text: string): string | undefined { const items = parseDigestItems(text); if (!items) return undefined; if (items.length === 0) return "(no pull requests)"; return items .map((item) => { const flow = [ stringAt(item, ["sourceBranch"]), stringAt(item, ["targetBranch"]), ] .filter((branch): branch is string => branch !== undefined) .join("→"); return digestLine( stringAt(item, ["reference", "number"]) ?? "#?", [stringAt(item, ["status", "state"])], clip(stringAt(item, ["title"]) ?? "(untitled)", 120), [ flow !== "" ? flow : undefined, stringAt(item, ["submitter"]), day(stringAt(item, ["submitDate"])), ], ); }) .join("\n"); } export function digestBuilds(text: string): string | undefined { const items = parseDigestItems(text); if (!items) return undefined; if (items.length === 0) return "(no builds)"; return items .map((item) => digestLine( stringAt(item, ["reference", "number"]) ?? "#?", [stringAt(item, ["status"])], clip(stringAt(item, ["jobName"]) ?? "(unknown job)", 80), [ stringAt(item, ["refName"])?.replace("refs/heads/", ""), stringAt(item, ["agent", "submitter"]), day(stringAt(item, ["submitDate"])), ], ), ) .join("\n"); } export function digestProjects(text: string): string | undefined { const items = parseDigestItems(text); if (!items) return undefined; if (items.length === 0) return "(no projects)"; return items .map((item) => { const features = [ item.codeManagement === true ? "code" : undefined, item.issueManagement === true ? "issues" : undefined, ].filter((feature): feature is string => feature !== undefined); return digestLine( stringAt(item, ["path"]) ?? "#?", [], clip( stringAt(item, ["description"]) ?? stringAt(item, ["name"]) ?? "", 80, ), [features.length > 0 ? `[${features.join("+")}]` : undefined], ); }) .join("\n"); } /** * Run tod inside a throwaway Git repository whose only remote points at the * requested OneDev project. TOD refuses `--project` queries when the working * directory has no OneDev remote; the scratch repository supplies one * without touching the user's checkout. */ async function runTodWithScratchRemote( exec: CommandExecutor, args: readonly string[], serverUrl: string, project: string, options: { signal?: AbortSignal; maxOutputBytes: number }, ): Promise { const directory = await mkdtemp(join(tmpdir(), "pi-onedev-scratch-")); try { for (const gitArgs of [ ["init", "--quiet", directory], [ "remote", "add", "origin", `${serverUrl.replace(/\/$/, "")}/${project}.git`, ], ]) { const done = await exec("git", gitArgs, { cwd: directory }); if (done.code !== 0 || done.killed) { throw new TodError( `could not prepare a temporary OneDev project context: ${(done.stderr || done.stdout).trim() || `git ${gitArgs[0] ?? ""} failed`}`, ); } } return await runTod(exec, [...args, "--working-dir", directory], { signal: options.signal, maxOutputBytes: options.maxOutputBytes, }); } finally { await rm(directory, { recursive: true, force: true }); } } async function finishListOutput( fetch: (maxOutputBytes: number) => Promise, options: { full?: boolean; maxOutputBytes?: number; digest: (text: string) => string | undefined; }, ): Promise { if (options.full === true) { return fetch(modelOutputLimit(options.maxOutputBytes)); } const output = await fetch(DIGEST_FETCH_BYTES); const limit = modelOutputLimit(options.maxOutputBytes); const digested = options.digest(output.text); if ( digested !== undefined && Buffer.byteLength(digested, "utf8") <= limit ) { return { text: digested, truncated: false, originalBytes: output.originalBytes, }; } // no digest, or one that outgrew the budget: the final model-visible // text is still bounded by the normal output limit const bounded = truncateMiddle(digested ?? output.text, limit); return { text: bounded.text, truncated: bounded.truncated || output.truncated, originalBytes: output.originalBytes, }; } /** * Fetch a whole bounded list payload, then reduce it to a digest unless the * caller opted into raw output. The internal fetch budget exceeds the * model-visible cap on purpose: the digest, not the payload, reaches the * model. Without an inferred project, an explicit `project` list query runs * against a temporary scratch remote instead of failing. */ export async function runListInContext( deps: ToolDeps, args: readonly string[], options: { signal?: AbortSignal; full?: boolean; maxOutputBytes?: number; project?: string; digest: (text: string) => string | undefined; }, ): Promise { const context = deps.context(); if (context.status !== "ready" || !context.project) { const scratchServer = context.serverUrl; const scratchProject = options.project; if ( context.status !== "no_project" || scratchServer === undefined || scratchProject === undefined ) { throw new TodError( context.problem ?? "OneDev tools require a repository whose project can be inferred from a OneDev remote", ); } return finishListOutput( (maxOutputBytes) => runTodWithScratchRemote( deps.exec, args, scratchServer, scratchProject, { signal: options.signal, maxOutputBytes }, ), options, ); } return finishListOutput( (maxOutputBytes) => runTod(deps.exec, args, { cwd: context.cwd, signal: options.signal, maxOutputBytes, }), options, ); } export function confirmMutation( deps: ToolDeps, ctx: ExtensionContext, request: MutationApprovalRequest, ): Promise { return deps.approvals.confirm(deps.context(), ctx, request); } function contextFooter(context: OneDevSessionContext): string { if (context.status === "ready" && context.project) { return `Context: project ${context.project} · server ${context.serverUrl}`; } return `Context unavailable: ${context.problem ?? "no OneDev project inferred from git remotes"}`; } export function withFooter( body: string, context: OneDevSessionContext, truncated: boolean, ): string { const parts = [body || "(no output)"]; if (truncated) parts.push("(output truncated; narrow the query or raise max_bytes)"); parts.push(contextFooter(context)); return parts.filter(Boolean).join("\n\n"); }