/** * `epic` — approve, revoke, or list the operator's standing approvals of epic * scope (#1041). * * The operator surface over the `epic_approvals` table. Approving an epic means * exactly one thing: its brief-complete children may pass the ready gate and be * queued without asking the operator again. That is why it is a dedicated * durable fact and not a resolved decision row — a decision's answer is free * text written for a human, and the promotion gate needs a boolean it can read * without parsing prose. * * Whether the issue number names a real epic is the tracker's business, not this * verb's: parents and children are native GitHub sub-issues, resolved by the * daemon at promotion time. Reading the tracker here would trade a network round * trip and a rate-limit failure mode for a check the gate performs anyway. */ import type { CommandContext } from "./context.ts"; import { findProject, loadConfig } from "../config.ts"; import { dbPath, openStore } from "../store.ts"; import { dim, ok } from "../ui/style.ts"; import { SESSION_ROLE_ENV } from "../types.ts"; /** The flags each subcommand accepts after the subcommand itself. Declared so a * typo'd flag exits 2 instead of being ignored while the approval still lands. */ const EPIC_FLAGS: Readonly> = { approve: ["--project"], revoke: ["--project"], list: ["--project", "--json"], }; /** * Rejects trailing tokens a subcommand does not declare (the #793 shape: a * real-looking flag that belongs to another verb must not be silently dropped). * Starts at index 2 because index 0 is the verb and index 1 the subcommand; the * issue positional is consumed by the caller before this runs. */ function assertKnownArgs(ctx: CommandContext, sub: string, from: number): void { const allowed = EPIC_FLAGS[sub] ?? []; for (let i = from; i < ctx.argv.length; i++) { const token = ctx.argv[i]; if (token === undefined) continue; const eq = token.startsWith("--") ? token.indexOf("=") : -1; const name = eq < 0 ? token : token.slice(0, eq); if (allowed.includes(name)) { // `--json` takes no value, so only a value-taking flag swallows the next // token. `--project` is the only one here. if (eq < 0 && name === "--project") i += 1; continue; } process.stderr.write(`omp-conductor: epic ${sub}: unexpected argument "${token}"\n`); process.exit(2); } } export async function epicCommand(ctx: CommandContext): Promise { const sub = ctx.argv[1]; if (sub !== "approve" && sub !== "revoke" && sub !== "list") { process.stderr.write( sub === undefined ? "omp-conductor: epic needs approve, revoke or list\n" : `omp-conductor: unknown epic subcommand "${sub}" — expected approve, revoke or list\n`, ); process.exit(2); } // The issue positional is validated before the config or store is opened, so a // typo'd number never creates a database on a fresh host. const issue = sub === "list" ? 0 : ctx.issueArg(`epic ${sub}`, ctx.argv[2]); assertKnownArgs(ctx, sub, sub === "list" ? 2 : 3); const project = findProject(loadConfig(), ctx.projectFlag); const store = openStore(dbPath()); try { if (sub === "approve") { // Read first, because `approveEpic` is first-write-wins and returns // nothing: the operator must be told which of the two happened, and a // silent second approve that looks identical to the first is exactly how // an operator concludes the approval did not stick. const existing = store.epicApproval(project.name, issue); if (existing !== undefined) { process.stdout.write( `epic #${issue} was already approved for ${project.name} by ${existing.approvedBy} at ` + `${new Date(existing.approvedAt).toISOString()} — nothing changed\n`, ); return; } // The same identity spelling `report withdraw` uses: the session role when // one is set, else orchestrator inside a Herdr pane, else a human at a // shell. Provenance for an audit line, never a gate. const sessionRole = process.env[SESSION_ROLE_ENV]; const inHerdrPane = process.env["HERDR_ENV"] !== undefined && process.env["HERDR_ENV"] !== ""; const by = sessionRole ?? (inHerdrPane ? "orchestrator" : "operator"); store.approveEpic(project.name, issue, Date.now(), by); process.stdout.write( ok(`epic #${issue} approved for ${project.name} by ${by}`) + "\n", ); process.stdout.write( dim( "brief-complete children of this epic may now be queued without a new operator decision — " + `revoke with: omp-conductor epic revoke ${issue} --project ${project.name}`, ) + "\n", ); return; } if (sub === "revoke") { const revoked = store.revokeEpicApproval(project.name, issue); process.stdout.write( revoked ? ok(`epic #${issue} approval revoked for ${project.name} — its children need a decision again`) + "\n" : `epic #${issue} was not approved for ${project.name} — nothing to revoke\n`, ); return; } const approvals = store.epicApprovals(project.name); if (ctx.argv.includes("--json")) { process.stdout.write(`${JSON.stringify({ project: project.name, epics: approvals }, null, 2)}\n`); return; } if (approvals.length === 0) { process.stdout.write("no approved epics\n"); return; } for (const approval of approvals) { process.stdout.write( `#${approval.issue} ${new Date(approval.approvedAt).toISOString()} ${approval.approvedBy}\n`, ); } } finally { store.close(); } }