import type { ExtensionAPI, ExtensionCommandContext, } from "@earendil-works/pi-coding-agent"; import { executeGitHubMerge } from "./pr-merge.ts"; import { linkInferredPullRequest, loadCurrentPullRequest, samePullRequestSnapshot, type CurrentPullRequest, } from "./pr-github.ts"; import { deriveNextStep, deriveRouteDecision, type FeedbackRouteBlocker, type NextStep, type PullRequestTarget, type RouteIntent, } from "./pr-routing.ts"; export type WorkflowNextStep = Extract; const WORKFLOWS: Record = { create: { command: "skill:pi-pr-create" }, "update-branch": { command: "skill:pi-pr-update-branch" }, sweep: { command: "skill:pi-pr-comment-sweep" }, "fix-ci": { command: "skill:pi-pr-fix-ci" }, }; export type WorkflowReservation = | { route: "create"; target: PullRequestTarget; base?: string } | { route: Exclude; pullRequest: CurrentPullRequest }; export type WorkflowLaunchAction = "prepare" | "merge" | "start" | "resume" | "collect"; export type WorkflowReservationResult = { runId: string; action: WorkflowLaunchAction }; type PrCommandPi = Pick; export type PrCommandInvocation = ((nextStep: NextStep) => void) & { sessionGeneration: number; assertCurrent(): void; }; export type PrCommandHandler = ( args: string, ctx: ExtensionCommandContext, onRouteResolved?: PrCommandInvocation | ((nextStep: NextStep) => void), ) => Promise; export type WorkflowPromptIdentity = Readonly<{ route: WorkflowNextStep; skill: string; runId: string; action: WorkflowLaunchAction; }>; export type PrCommandDependencies = { loadCurrentPullRequest?: typeof loadCurrentPullRequest; linkInferredPullRequest?: typeof linkInferredPullRequest; reserveWorkflow?: ( reservation: WorkflowReservation, ctx: ExtensionCommandContext, invocation?: PrCommandInvocation, ) => Promise; markWorkflowPromptQueued?: (identity: WorkflowPromptIdentity, queued: boolean) => void; releaseWorkflow?: (runId: string, invocation?: PrCommandInvocation) => void; }; type ParsedPrArguments = { base?: string; intent: RouteIntent; instructions: string; }; function parsePrArguments(args: string): ParsedPrArguments { const leading = args.trimStart(); const trimmed = leading.trim(); if (leading.startsWith("--feedback=")) throw new Error("/pr feedback syntax is --feedback"); if (/^--feedback(?:\s|$)/.test(leading)) { if (trimmed !== "--feedback") throw new Error("/pr --feedback cannot be combined with other options or instructions"); return { intent: "feedback", instructions: "" }; } if (/(?:^|\s)--feedback(?=\s|=|$)/.test(leading)) { throw new Error("/pr --feedback cannot be combined with other options or instructions"); } if (leading.startsWith("--base=")) throw new Error("/pr base syntax is --base "); if (/^--base(?:\s|$)/.test(leading)) { const value = /^--base\s+(\S+)/.exec(leading); if (!value) throw new Error("/pr --base requires a branch"); const instructions = leading.slice(value[0].length).trim(); if (instructions.startsWith("--")) throw new Error(`Unknown /pr option: ${instructions.split(/\s+/, 1)[0]}`); return { base: value[1]!, intent: "automatic", instructions }; } if (leading.startsWith("--")) throw new Error(`Unknown /pr option: ${leading.split(/\s+/, 1)[0]}`); return { intent: "automatic", instructions: args.trim() }; } function feedbackBlockerMessage(blocker: FeedbackRouteBlocker): string { switch (blocker.kind) { case "discovery-blocked": return "/pr --feedback is blocked because pull request discovery is unsafe"; case "pull-request-unavailable": return "/pr --feedback requires a current pull request"; case "pull-request-not-open": return "/pr --feedback requires an open pull request"; case "target-not-configured": return "/pr --feedback requires a configured pull request target; run /pr to link the branch first"; case "worktree-dirty": return "/pr --feedback is blocked by a dirty worktree"; case "head-not-equal": return `/pr --feedback is blocked by local HEAD ${blocker.relation}`; } } function workflowReservation( nextStep: WorkflowNextStep, discovery: Awaited>, base: string | undefined, instructions: string, ): WorkflowReservation { if (nextStep === "create") { if (discovery.kind !== "none") throw new Error("/pr create failed: creation target is unavailable"); return { route: "create", target: discovery.creationTarget, ...(base === undefined ? {} : { base }) }; } if (instructions) throw new Error("The current /pr helper route does not accept instructions"); if (discovery.kind !== "current") throw new Error(`/pr ${nextStep} failed: pull request is unavailable`); return { route: nextStep, pullRequest: discovery.pullRequest }; } function packageWorkflowCommand(pi: PrCommandPi, route: WorkflowNextStep) { const workflow = WORKFLOWS[route]; const command = pi.getCommands().find((candidate) => candidate.name === workflow.command && candidate.source === "skill" && candidate.sourceInfo.origin === "package" ); if (!command) throw new Error(`${workflow.command} failed: bundled workflow is unavailable`); return command; } async function dispatchWorkflow( pi: PrCommandPi, ctx: ExtensionCommandContext, route: WorkflowNextStep, reservation: WorkflowReservation, invocation: PrCommandInvocation | undefined, reserve: NonNullable, markPromptQueued: NonNullable, release: NonNullable, instructions: string, ): Promise { const workflow = packageWorkflowCommand(pi, route); let runId: string | undefined; try { const reserved = await reserve(reservation, ctx, invocation); runId = reserved.runId; invocation?.assertCurrent(); const queued = !ctx.isIdle(); const identity = { route, skill: workflow.name, runId, action: reserved.action }; markPromptQueued(identity, queued); const options = queued ? { deliverAs: "followUp" as const, expandPromptTemplates: true } : { expandPromptTemplates: true }; invocation?.assertCurrent(); pi.sendUserMessage(`/${identity.skill} runId=${identity.runId} action=${identity.action}${instructions ? ` ${instructions}` : ""}`, options); } catch (error) { if (runId !== undefined) release(runId, invocation); throw error; } } function noActionNotification(pullRequest: CurrentPullRequest): { message: string; type: "info" | "warning" } { if (pullRequest.lifecycle === "merged" || pullRequest.lifecycle === "closed") { return { message: `PR #${pullRequest.number} is ${pullRequest.lifecycle}; no action needed`, type: "info" }; } if (pullRequest.conditions.draft) { return { message: `PR #${pullRequest.number} is draft; no action available`, type: "warning" }; } if (pullRequest.conditions.ci === "failure-blocked") { return { message: `PR #${pullRequest.number} has a failed CI check that cannot run the CI fix workflow`, type: "warning" }; } const mutatingWorkflowSelected = pullRequest.conditions.baseUpdateRequired || pullRequest.conditions.conflict || pullRequest.conditions.changesRequested || pullRequest.conditions.unresolvedThreads > 0 || pullRequest.conditions.ci === "failure"; if (mutatingWorkflowSelected && pullRequest.local.worktree === "dirty") { return { message: `PR #${pullRequest.number} is blocked by a dirty worktree`, type: "warning" }; } if (mutatingWorkflowSelected && pullRequest.local.head !== "equal") { return { message: `PR #${pullRequest.number} is blocked by local HEAD ${pullRequest.local.head}`, type: "warning" }; } if (pullRequest.conditions.ci === "running") { return { message: `PR #${pullRequest.number} is waiting for CI`, type: "warning" }; } if (pullRequest.conditions.review === "pending") { return { message: `PR #${pullRequest.number} is waiting for review`, type: "warning" }; } if (pullRequest.conditions.policy === "pending") { return { message: `PR #${pullRequest.number} is blocked by merge policy`, type: "warning" }; } if (pullRequest.local.worktree === "dirty") { return { message: `PR #${pullRequest.number} is blocked by a dirty worktree`, type: "warning" }; } if (pullRequest.local.head === "ahead" || pullRequest.local.head === "diverged") { return { message: `PR #${pullRequest.number} is blocked by local HEAD ${pullRequest.local.head}`, type: "warning" }; } return { message: `PR #${pullRequest.number} has no available action`, type: "warning" }; } function isSameConfirmedMerge(current: CurrentPullRequest, fresh: CurrentPullRequest): boolean { return current.id === fresh.id && current.number === fresh.number && current.url.href === fresh.url.href && current.host === fresh.host && current.head.repository === fresh.head.repository && current.head.ref === fresh.head.ref && current.head.oid === fresh.head.oid && current.base.repository === fresh.base.repository && current.base.ref === fresh.base.ref && current.base.oid === fresh.base.oid; } async function mergePullRequest( pi: PrCommandPi, ctx: ExtensionCommandContext, current: CurrentPullRequest, load: typeof loadCurrentPullRequest, ): Promise { const confirmed = await ctx.ui.confirm( `Merge PR #${current.number}?`, "Method: squash.", ); if (!confirmed) return false; await executeGitHubMerge({ exec: (command, args, options) => pi.exec(command, args, { ...options, signal: ctx.signal, timeout: 10_000, }), cwd: ctx.cwd, pullRequestId: current.id, hostname: current.host, expectedHead: current.head.oid, expectedBase: current.base, headFetchSource: current.headFetchSource, revalidateReadiness: async (local) => { const discovery = await load(pi, ctx, local); if (discovery.kind !== "current") { throw new Error(`PR #${current.number} merge cancelled: pull request is no longer current`); } const fresh = discovery.pullRequest; if (!isSameConfirmedMerge(current, fresh)) { throw new Error(`PR #${current.number} merge cancelled: confirmed pull request context changed`); } if (deriveNextStep(discovery) !== "merge") { throw new Error(`PR #${fresh.number} merge cancelled: pull request is no longer merge-ready`); } }, }); return true; } async function linkPullRequest( pi: PrCommandPi, ctx: ExtensionCommandContext, current: CurrentPullRequest, load: typeof loadCurrentPullRequest, link: typeof linkInferredPullRequest, ): Promise { const targetName = `${current.target.remote}/${current.target.ref}`; const confirmed = await ctx.ui.confirm( `Link pull request branch to ${targetName}?`, `Set ${targetName} as the push target for this branch.`, ); if (!confirmed) return; const discovery = await load(pi, ctx); if ( discovery.kind !== "current" || discovery.pullRequest.target.provenance !== "inferred" || !samePullRequestSnapshot(current, discovery.pullRequest) ) throw new Error("Link branch cancelled: inferred pull request context changed"); await link(pi, ctx, discovery.pullRequest); } export function createPrCommandHandler( pi: PrCommandPi, dependencies: PrCommandDependencies = {}, ): PrCommandHandler { const load = dependencies.loadCurrentPullRequest ?? loadCurrentPullRequest; const link = dependencies.linkInferredPullRequest ?? linkInferredPullRequest; const reserve = dependencies.reserveWorkflow ?? (async () => { throw new Error("/pr workflow tools are unavailable"); }); const markPromptQueued = dependencies.markWorkflowPromptQueued ?? (() => {}); const release = dependencies.releaseWorkflow ?? (() => {}); return async (args, ctx, onRouteResolved) => { const commandInvocation = onRouteResolved && "assertCurrent" in onRouteResolved ? onRouteResolved as PrCommandInvocation : undefined; const { base, intent, instructions } = parsePrArguments(args); const discovery = await load(pi, ctx, undefined, undefined, base); commandInvocation?.assertCurrent(); const decision = deriveRouteDecision(discovery, intent); const nextStep = decision.nextStep; onRouteResolved?.(nextStep); if (decision.kind === "feedback-blocked") throw new Error(feedbackBlockerMessage(decision.blocker)); if (base !== undefined && nextStep !== "create") { throw new Error("/pr --base is accepted only for pull request creation"); } if (instructions && !(nextStep in WORKFLOWS)) { throw new Error("The current /pr route does not accept instructions"); } if (discovery.kind === "inactive") return nextStep; if (discovery.kind === "blocked") { return nextStep; } if (nextStep === "none") { if (discovery.kind === "current") { const notification = noActionNotification(discovery.pullRequest); ctx.ui.notify(notification.message, notification.type); } return nextStep; } if (nextStep === "link-branch") { if (discovery.kind !== "current") throw new Error("/pr link failed: pull request is unavailable"); await linkPullRequest(pi, ctx, discovery.pullRequest, load, link); return nextStep; } if (nextStep === "merge") { if (discovery.kind !== "current") throw new Error("/pr merge failed: pull request is unavailable"); return await mergePullRequest(pi, ctx, discovery.pullRequest, load) ? "merge" : "none"; } if (!(nextStep in WORKFLOWS)) throw new Error(`/pr cannot dispatch route ${nextStep}`); const route = nextStep as WorkflowNextStep; const reservation = workflowReservation(route, discovery, base, instructions); await dispatchWorkflow( pi, ctx, route, reservation, commandInvocation, reserve, markPromptQueued, release, route === "create" ? instructions : "", ); return nextStep; }; }