import { join } from "node:path"; import { getAgentDir, type ExtensionContext, } from "@earendil-works/pi-coding-agent"; import type { OneDevSessionContext } from "./context.js"; import { readAllowedMutationScopes, saveAllowedMutationScope, } from "./config-storage.js"; const ALLOW_ONCE = "Allow once"; const ALLOW_SESSION = "Allow ALL OneDev mutations in this project this session"; const ALLOW_SAVED = "Always allow ALL OneDev mutations in this project"; const CANCEL = "Cancel"; const ALL_OPERATIONS = "*"; export const MUTATION_OPERATIONS = [ "issue.create", "issue.edit", "issue.change_state", "issue.add_comment", "issue.link", "issue.log_work", "issue.create_branch", "issue.checkout", "pull.create", "pull.edit", "pull.add_comment", "pull.add_code_comment", "pull.reply_code_comment", "pull.resolve_code_comment", "pull.unresolve_code_comment", "pull.approve", "pull.request_changes", "pull.merge", "pull.discard", "pull.checkout", "build.run", "build.check_spec", ] as const; export type MutationOperation = (typeof MUTATION_OPERATIONS)[number]; export interface MutationApprovalRequest { operation: MutationOperation; title: string; details: string; signal?: AbortSignal; } function scopeKey( context: OneDevSessionContext, operation: MutationOperation | typeof ALL_OPERATIONS, ): string { return JSON.stringify([context.serverUrl, context.project, operation]); } export class MutationApprovalManager { readonly #session = new Set(); readonly #pending = new Map>(); constructor( readonly configPath = join(getAgentDir(), "onedev-toolkit.json"), ) {} resetSession(): void { this.#session.clear(); } async confirm( context: OneDevSessionContext, ctx: ExtensionContext, request: MutationApprovalRequest, ): Promise { const key = `${context.serverUrl}\n${context.project}\n${request.operation}`; // ponytail: coalesces identical in-flight confirms only; parallel // different-operation dialogs still queue on Pi core's single selector const inFlight = this.#pending.get(key); if (inFlight) return inFlight; const approval = this.#runConfirm(context, ctx, request).finally(() => { this.#pending.delete(key); }); this.#pending.set(key, approval); return approval; } async #runConfirm( context: OneDevSessionContext, ctx: ExtensionContext, request: MutationApprovalRequest, ): Promise { if (request.signal?.aborted) { throw request.signal.reason instanceof Error ? request.signal.reason : new Error("OneDev mutation cancelled"); } if (context.status !== "ready" || !context.serverUrl || !context.project) { throw new Error("OneDev mutation requires a ready server and project context"); } const operationScope = scopeKey(context, request.operation); const projectScope = scopeKey(context, ALL_OPERATIONS); if (this.#session.has(projectScope)) return; try { const savedScopes = await readAllowedMutationScopes(this.configPath); // legacy per-operation entries saved by older versions stay honored if (savedScopes.includes(projectScope) || savedScopes.includes(operationScope)) return; } catch (error) { if (ctx.hasUI) { ctx.ui.notify( `Could not read saved OneDev approvals: ${error instanceof Error ? error.message : String(error)}`, "warning", ); } } if (!ctx.hasUI) { throw new Error( `${request.operation} requires interactive confirmation; run it in TUI mode or save the scoped approval there first`, ); } const prompt = [ request.title, request.details, `Server: ${context.serverUrl}`, `Project: ${context.project}`, ].join("\n"); const choice = await ctx.ui.select( prompt, [ALLOW_SESSION, ALLOW_SAVED, ALLOW_ONCE, CANCEL], { signal: request.signal }, ); if (!choice || choice === CANCEL) { throw new Error(`${request.operation} cancelled by user`); } if (choice === ALLOW_ONCE) return; this.#session.add(projectScope); if (choice === ALLOW_SAVED) { try { await saveAllowedMutationScope(this.configPath, projectScope); } catch (error) { ctx.ui.notify( `Approval applies to this session only; it could not be saved: ${error instanceof Error ? error.message : String(error)}`, "warning", ); } } } }