Files
fusion/packages/cli/src/commands/pr.ts
gsxdsm c15c78feeb feat: migrate storage from SQLite to PostgreSQL (#1793)
# Migrate storage from SQLite to PostgreSQL — full dashboard cutover

Migrates Fusion's storage layer to the embedded PostgreSQL
`AsyncDataLayer` (the default backend) and **completes the
satellite-store + feature cutover** so every dashboard and Command
Center surface works in PG mode.

## Status — every surface works in embedded-PG mode

Verified live against a running embedded-Postgres dashboard (all
**200**, zero 5xx) and gate-tested (**23 files / 99 tests** on embedded
PG, plus engine-core 294 and ci-shape 63 in the blocking merge gate;
core/engine/cli/dashboard typecheck clean).

| Area | Surfaces | State |
|---|---|---|
| Satellite stores | workflows, todos, insights, research, missions,
goals, mailbox | ✅ |
| Views | artifacts, documents, evals | ✅ |
| Command Center | activity, productivity, team, tokens, tools,
**workflows**, **github**, **signals**, **plugin-activations**, **live**
(all 10) | ✅ |
| Run execution | insight generation, research run execution | ✅
(store-path; AI step needs a provider) |
| Live updates | SSE push for mission/research/insight events | ✅ |
| Workflow editing | create / update / delete / select (+ id counter) |
✅ |
| Engine | mission autopilot, incident-signal ingestion, regression
storm-guard, agent wake-on-message | ✅ |
| Core | tasks, agents, secrets, automations, memory, chat, usage, PRs,
git | ✅ |

## Approach

Each satellite store gets an `Async<Store>` wrapper exposing the sync
store's method names over the existing `async-*-store.ts` helpers;
`get<Store>Store()` returns a `Sync | Async` union; consumers `await`
(harmless on sync), and engine/CLI paths that can't convert use
`instanceof Sync` graceful fallback. Analytics aggregators branch on
`"ping" in dbOrLayer` to run schema-qualified raw SQL over `project.*`
(snake_case) in PG. Executors/orchestrators/autopilot are
await-converted to drive the union store; the async store wrappers
extend `EventEmitter` so SSE live-push fires in both backends.

Not-yet-ported capabilities degrade gracefully (never 500) and are
individually called out in commits.

## Sync with main

The branch is kept continuously merged with `main` (currently through
FN-7845, 2026-07-12); the earlier "final rebase deferred" note no longer
applies. Use **Create a merge commit** (or squash) to land it — GitHub's
rebase-merge cannot replay a merge-maintained branch.

## Residual Review Findings

Multi-agent code review of the PostgreSQL satellite-store ports (U1–U5)
applied 3 safe fixes (see `fix(review): apply autofix feedback`). The
following are **real but gated** — recorded here as follow-up work
rather than auto-applied. All are SQLite→PostgreSQL
**concurrency/atomicity regressions**: the sync stores were immune only
by SQLite's single-writer, single-threaded-handler execution; the async
ports open multi-await read-modify-write windows. **Reachability is low
today** because the execution engines that generate concurrent same-run
mutations (insight run executor, research orchestrator/dispatcher) are
`instanceof`-gated to sync mode in PG. No process-crash class survived
(all engine fallbacks correctly guard the sync store).

- **[P1] Research `appendResearchEvent` dual-write is non-atomic**
(`packages/core/src/async-research-store.ts`, corroborated: adversarial
+ reliability). The `research_run_events` insert (own transaction) and
the `run.events` jsonb update are separate writes — a crash between
them, or two concurrent appends, splits the table count from the jsonb
array. **Fix:** perform the seq-insert and the jsonb update in one
`layer.transactionImmediate`.
- **[P1] Research run terminal-reversion via stale full-row persist**
(`async-research-store.ts` `persistResearchRun`/`updateResearchStatus`).
Concurrent `PATCH /runs/:id/status` + `POST /runs/:id/events` can revert
a terminal run to `running` by overwriting the whole row, bypassing the
transition guard. **Fix:** scoped column `UPDATE`s with a `WHERE status
…` guard, or optimistic version column.
- **[P2] `updateResearchRun`/`updateInsightRun` read-then-write TOCTOU**
— concurrent PATCHes last-writer-wins on the lifecycle merge. **Fix:**
`SELECT … FOR UPDATE` / enclosing transaction.
- **[P2] `upsertRun`/`createRunOrThrowConflict` check-then-create race**
(`async-insight-store.ts`) — two callers can each create an "active"
run. **Fix:** partial unique index on `(projectId, trigger) WHERE status
IN ('pending','running')`.
- **[P3] `createResearchRetryRun` return-value divergence** — sync
returns the pre-update `queued` snapshot; async returns the reloaded
`retry_waiting` run (persisted state is identical). Pick one side for
cross-backend parity.
- **[P2/perf] Mission `getMissionWithHierarchy`/`getMissionHealth` N+1
fan-out** — O(milestones×slices) sequential round-trips hold one pool
slot per request; can starve the pool for large hierarchies. **Fix:**
batched/joined reads.
- **Testing gaps:** no PG-mode concurrency tests (interleaved
status/event mutations), no sync↔async parity assertion for the
lifecycle-error codes, and no mission status/health rollup parity test
vs the sync `MissionStore`.

~~Out of scope (deferred): AI run *execution* (insight/research) +
mission autopilot + live SSE mission events remain sync-gated/degraded
in PG mode.~~ **Since ported** — insight/research run execution, mission
autopilot, and SSE live push all run on the async layer now, which also
makes the concurrency findings above genuinely reachable; they remain
open follow-ups.







---

## Update — 2026-07-12: production-readiness hardening & live acceptance

Everything below landed on this branch since the description above was
written:

**Production blockers from review — fixed**
- `recoverStaleTransitionPending` ported to the async layer (backend
moves write + clear the crash-safe marker; startup/maintenance sweeps no
longer throw).
- Lost-update class fixed: `atomicWriteTaskJson`/`WithAudit` write
changed columns only (full-row upserts silently resurrected stale fields
across concurrent store instances — the "task stuck unplanned forever"
bug).
- First-boot **auto-migration**: booting the PG backend over a project
with a legacy `fusion.db` migrates it automatically (loud failure,
SQLite kept as backup), and the dashboard shows a one-time **"your data
was migrated" banner** with the backup paths and a Need-help Discord
link.
- `pg_dump`/`pg_restore` discovered from common install locations for
embedded-mode backups.
- The PG suite is part of the blocking merge gate (`test:pg-gate`).

**Multi-project isolation (PR #2007, merged into this branch)**
- `project_id` partition key on tasks / archived tasks / config,
`taskProjectScope` threaded through every scan/claim/count, per-project
config rows, layer bound to the project at startup.
- Review P1 follow-up: the shared cold-storage `archive.archived_tasks`
table is also partitioned and all archived-board reads/counts/searches
are scoped.
- Schema drift self-heal generalized to schema-qualified columns so
existing databases upgrade in place.

**Other changes**
- Node settings sync **removed** in PG mode (409
`settings-sync-disabled-postgres`) — nodes share state by connecting to
the same database; auth sync kept (per-machine file).
- Perf (review findings): `listTasks` pushes column filter + ORDER BY +
LIMIT/OFFSET into SQL; `getConversation` capped to the most recent 200
messages.
- Fixed a false "operator action required" pause-abort log fired on
every successfully auto-merged task.

**Live acceptance — PASSED (2026-07-12)**
A sandboxed instance (isolated HOME, embedded PG, real Opus executor)
ran a task through the complete cycle: create → triage (AI spec) →
execute → in-review → AI squash-merge landed on the project's `main` →
done. A write+read sweep of every data surface (settings, comments,
documents, attachments + artifact bridge + artifact edit, chat with real
generation, goals, missions, agent mail, secrets, workflows, memory, CC
analytics) was green on embedded PG.

**Known remaining work**
- The per-project `config` PK re-key has no upgrade path for
pre-isolation embedded-PG databases (needs a real `DROP
CONSTRAINT`/re-key migration; fresh databases are fine).
- `pg_dump`/`pg_restore` binaries are not yet bundled in release
artifacts (PATH/common-location discovery only).
- The satellite-store concurrency findings listed above.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Phil Larson <hello@phillarson.xyz>
Co-authored-by: fusion-merge <fusion-merge@local>
2026-07-13 19:07:58 -07:00

576 lines
22 KiB
TypeScript

import {
TaskStore,
isPrEntityActive,
isPrEntityActionable,
autoMergeGateReason,
type PrEntity,
type PrThreadState,
} from "@fusion/core";
import { classifyGhError, getGhErrorMessage, getCurrentRepo, isGhAuthenticated, isGhAvailable } from "@fusion/core/gh-cli";
import { releaseHeldTaskByEvent } from "@fusion/engine";
import * as dashboard from "@fusion/dashboard";
import { resolveProject, createLocalStore, closeProjectStore, asLocalProjectContext, type ProjectContext } from "../project-context.js";
import { retryOnLock, LockRetryExhaustedError } from "../lock-retry.js";
/**
* FNXC:CliBoardMutation 2026-07-09-00:00:
* FN-7738 audit finding: `getPrContext` resolves a `TaskStore` (cached via
* `resolveProject`, OR an UNCACHED `new TaskStore(process.cwd())`
* CWD-fallback) and, before this change, NO `runPr*` handler ever closed it
* — the same leaked-handle class FN-7731 fixed for `fn task show`/`move`.
* None of the board mutations here (`updatePrInfo`, `ensurePrEntityForSource`,
* `updatePrEntity`, `releaseHeldTaskByEvent`, `reconcileLegacyAutoMergeStamps`)
* retried through a momentary `database is locked` either. The fix below
* reuses the SAME `retryOnLock`/`closeProjectStore` helpers (no forked
* second implementation), scoped to the discrete store read/write calls —
* NOT the GitHub API calls (`client.createPr`, `dashboard.generatePrMetadata`)
* or `releaseHeldTaskByEvent` (which itself owns further workflow/GitHub side
* effects) — to avoid re-issuing an already-completed external side effect on
* a later lock-triggered retry. The resolved store is closed on every exit
* path, including guard/not-found `process.exit()` calls (closed explicitly
* before the exit, since a pending `finally` does not run after
* `process.exit()` — see project memory) and including the uncached
* CWD-fallback branch via `asLocalProjectContext`.
*/
/**
* Agent-native parity (R13, U8): expose the SAME unified-PR-entity surface and
* user-controlled actions a dashboard user gets (the U7
* `GET/POST /api/pull-requests/*` routes) from the CLI, via `fn pr <subcommand>`.
*
* Capability → path mapping (kept identical to the dashboard so the two surfaces
* can never diverge — register-integrated-routers.ts wires the route callbacks to
* exactly these primitives):
*
* create → store.ensurePrEntityForSource (same store path the pr-create
* workflow node uses) + the actual GitHub PR via GitHubClient
* show/list → store.getPrEntity / store.listActivePrEntities
* approve → releaseHeldTaskByEvent(store, entity.sourceId, "pr-approve")
* respond → releaseHeldTaskByEvent(store, entity.sourceId, "pr-respond")
* retry → releaseHeldTaskByEvent(store, entity.sourceId, "pr-retry")
* merge → releaseHeldTaskByEvent(store, entity.sourceId, "pr-merge")
* close → releaseHeldTaskByEvent(store, entity.sourceId, "pr-close")
* automerge → store.updatePrEntity(id, { autoMerge })
*
* The release actions fire the workflow's user-controlled release edges — the
* same hold-release authority the dashboard routes and the scheduler sweep use —
* so the GitHub side effects are owned by the workflow, not duplicated here.
*
* This mirrors the established CLI convention (branch-group.ts) of operating
* against the resolved `TaskStore` and engine helpers directly rather than
* calling the dashboard HTTP API.
*/
async function getPrContext(projectName?: string): Promise<ProjectContext> {
try {
const context = await resolveProject(projectName);
if (context) {
return context;
}
} catch {
// fall through to a local store rooted at cwd
}
if (projectName) {
throw new Error(`Project ${projectName} not found`);
}
// FNXC:PostgresCutover 2026-07-05-12:00: boot the cwd fallback through the
// PostgreSQL startup factory; bare `new TaskStore` throws in backend mode.
const store = await createLocalStore(process.cwd());
return asLocalProjectContext(store);
}
/**
* FNXC:CliBoardMutation 2026-07-09-00:00:
* Translate a `LockRetryExhaustedError` (or any other error) from a PR board
* interaction into the CLI's standard "print + exit(1)" failure shape,
* matching `task.ts`'s `failBoardCommand`. When `context` is still open,
* close it BEFORE exiting.
*/
async function failPrCommand(error: unknown, context?: ProjectContext): Promise<never> {
const message = error instanceof Error ? error.message : String(error);
console.error(`\n \u2717 ${message}\n`);
if (context) {
await closeProjectStore(context);
}
return process.exit(1);
}
function formatGhErrorForCli(err: unknown): string {
const structured = classifyGhError(err);
const lines = [`GitHub error: ${structured.message}`];
if (structured.hint) lines.push(` Hint: ${structured.hint}`);
if (structured.action?.kind === "shell") lines.push(` Action: run \`${structured.action.command}\``);
if (structured.action?.kind === "open") lines.push(` Action: open ${structured.action.url}`);
if (structured.action?.kind === "retry") lines.push(" Action: retry the command");
if (structured.retryable) lines.push(" (retryable — re-run `fn pr create <task-id>` to try again)");
return lines.join("\n") + "\n";
}
// ── PR creation (the retired `fn task pr-create`, now `fn pr create`) ─────────
export interface PrCreateOptions {
title?: string;
base?: string;
body?: string;
draft?: boolean;
/** When true (default), call generatePrMetadata for title/body unless user provided both. */
ai?: boolean;
/** Repeatable --reviewer flag values. */
reviewers?: string[];
}
export async function runPrCreate(id: string, options: PrCreateOptions = {}, projectName?: string) {
let context: ProjectContext | undefined;
try {
context = await retryOnLock(() => getPrContext(projectName), { id, action: "resolve project" });
const { store, projectPath } = context;
// Fetch task and validate it exists
let task;
try {
task = await retryOnLock(async () => store.getTask(id), { id, action: "read task" });
} catch (err) {
if (err instanceof LockRetryExhaustedError) {
throw err;
}
if (typeof err === "object" && err !== null && (err as Record<string, unknown>).code === "ENOENT") {
console.error(`Error: Task ${id} not found`);
await closeProjectStore(context);
process.exit(1);
}
throw err;
}
if (!task) {
console.error(`Error: Task ${id} not found`);
await closeProjectStore(context);
process.exit(1);
}
// Validate task is in 'in-review' column
if (task.column !== "in-review") {
console.error(`Error: Task must be in 'in-review' column to create a PR (current: ${task.column})`);
await closeProjectStore(context);
process.exit(1);
}
// Check if task already has PR info
if (task.prInfo) {
console.error(`Error: Task already has PR #${task.prInfo.number}: ${task.prInfo.url}`);
await closeProjectStore(context);
process.exit(1);
}
// Determine owner/repo from GITHUB_REPOSITORY env or git remote
let owner: string;
let repo: string;
const envRepo = process.env.GITHUB_REPOSITORY;
if (envRepo) {
const [o, r] = envRepo.split("/");
if (!o || !r) {
console.error("Error: GITHUB_REPOSITORY format is invalid (expected: owner/repo)");
await closeProjectStore(context);
process.exit(1);
}
owner = o;
repo = r;
} else {
const gitRepo = getCurrentRepo(projectPath);
if (!gitRepo) {
console.error("Error: Could not determine GitHub repository. Set GITHUB_REPOSITORY env var or configure git remote.");
await closeProjectStore(context);
process.exit(1);
}
owner = gitRepo.owner;
repo = gitRepo.repo;
}
// Validate GitHub auth
if (!isGhAvailable() || !isGhAuthenticated()) {
console.error("Error: GitHub CLI (gh) is not available or not authenticated. Run 'gh auth login'.");
await closeProjectStore(context);
process.exit(1);
}
// Build branch name using the established project convention
const branchName = `fusion/${id.toLowerCase()}`;
// Build deterministic fallback PR title
const fallbackTitle = options.title
? options.title
: task.title
? task.title
: (() => {
const desc = task.description.trim();
let derived = desc.charAt(0).toUpperCase() + desc.slice(1, 50);
if (desc.length > 50) {
derived += "…";
}
return derived;
})();
let resolvedTitle = fallbackTitle;
let resolvedBody = options.body;
const shouldUseAi = options.ai !== false && !(options.title && options.body);
if (shouldUseAi) {
try {
const settings = ("getSettings" in store
? await retryOnLock(async () => store.getSettings(), { id, action: "read settings" })
: {}) as Parameters<typeof dashboard.generatePrMetadata>[0]["settings"];
const generated = await dashboard.generatePrMetadata({ task, repoRoot: projectPath, settings });
if (!options.title) {
resolvedTitle = generated.title;
}
if (!options.body) {
resolvedBody = generated.body;
}
console.log(" → Using AI-generated title/body (use --no-ai to skip)");
} catch (err) {
process.stderr.write(`AI metadata generation failed; using fallback PR metadata. ${getGhErrorMessage(err)}\n`);
}
}
// Create PR via GitHubClient
const client = new dashboard.GitHubClient();
try {
// NOT retried as a whole — `client.createPr` is a GitHub network call with a
// real, non-idempotent side effect (opening a PR); only the discrete store
// writes afterward are wrapped in retryOnLock.
const prInfo = await client.createPr({
owner,
repo,
title: resolvedTitle,
body: resolvedBody,
head: branchName,
base: options.base,
draft: options.draft,
reviewers: options.reviewers,
});
await retryOnLock(
async () => {
// Store PR info (legacy field, still read by some surfaces during migration).
await store.updatePrInfo(task.id, prInfo);
// Also write the unified PR entity via the SAME store path the pr-create
// workflow node uses (mirrors pr-nodes.ts: ensure → flip to open with the
// persisted PR number/url). Without this the PR would be invisible to
// `fn pr list/show`, the reconciler, and the workflow nodes (R13 parity).
const entity = await store.ensurePrEntityForSource({
sourceType: "task",
sourceId: task.id,
repo: `${owner}/${repo}`,
headBranch: branchName,
baseBranch: prInfo.baseBranch,
state: "creating",
});
await store.updatePrEntity(entity.id, {
state: "open",
prNumber: prInfo.number,
prUrl: prInfo.url,
});
await store.logEntry(task.id, "Created PR", `PR #${prInfo.number}: ${prInfo.url}`);
},
{ id, action: "record created PR" },
);
console.log();
console.log(` ✓ Created PR for ${task.id}`);
console.log(` PR #${prInfo.number}: ${prInfo.url}`);
console.log(` Branch: ${branchName} → ${prInfo.baseBranch}`);
console.log();
} catch (err) {
if (err instanceof LockRetryExhaustedError) {
throw err;
}
const msg = err instanceof Error ? err.message : String(err);
if (msg.includes("already exists")) {
console.error(`Error: A pull request already exists for ${owner}/${repo}:${branchName}`);
} else if (msg.includes("No commits between")) {
console.error(`Error: No commits between ${options.base || "default base"} and ${branchName}. Push changes before creating PR.`);
} else {
process.stderr.write(formatGhErrorForCli(err));
}
await closeProjectStore(context);
process.exit(1);
}
} catch (error) {
if (error instanceof LockRetryExhaustedError) {
await failPrCommand(error, context);
}
throw error;
} finally {
if (context) {
await closeProjectStore(context);
}
}
}
// ── Entity read commands (parity with GET /api/pull-requests[/:id]) ───────────
/**
* Resolve a PR entity by its id (or 404-style exit). Wraps the discrete read
* in `retryOnLock` and, on not-found, closes `context`'s store BEFORE calling
* `process.exit(1)` (a pending `finally` does not run after `process.exit()`).
*/
async function requireEntity(context: ProjectContext, id: string): Promise<PrEntity> {
const entity = await retryOnLock(async () => context.store.getPrEntity(id), { id, action: "read PR entity" });
if (!entity) {
console.error(`\n \u2717 PR entity ${id} not found\n`);
await closeProjectStore(context);
process.exit(1);
}
return entity;
}
export async function runPrList(projectName?: string) {
try {
await retryOnLock(
async () => {
const context = await getPrContext(projectName);
try {
const { store } = context;
const entities = await store.listActivePrEntities();
if (entities.length === 0) {
console.log("\n No active pull requests.\n");
return;
}
console.log();
for (const entity of entities) {
const num = entity.prNumber != null ? `#${entity.prNumber}` : "(no #)";
const am = entity.autoMerge ? " auto-merge" : "";
console.log(` ${entity.id} ${num} ${entity.repo} [${entity.state}]${am}`);
}
console.log();
} finally {
await closeProjectStore(context);
}
},
{ id: "pull-requests", action: "list PR entities" },
);
} catch (error) {
if (error instanceof LockRetryExhaustedError) {
await failPrCommand(error);
}
throw error;
}
}
export async function runPrShow(id: string, projectName?: string) {
if (!id) {
console.error("Usage: fn pr show <pr-entity-id>");
process.exit(1);
}
let context: ProjectContext | undefined;
try {
context = await retryOnLock(() => getPrContext(projectName), { id, action: "resolve project" });
const entity = await requireEntity(context, id);
const threads: PrThreadState[] = await retryOnLock(async () => context!.store.listPrThreadStates(entity.id), { id, action: "read PR thread states" });
const pending = threads.filter((t) => t.outcome === "pending").length;
const disagreed = threads.filter((t) => t.outcome === "disagreed").length;
console.log();
console.log(` PR entity ${entity.id}`);
console.log(` Source: ${entity.sourceType}/${entity.sourceId}`);
console.log(` Repo: ${entity.repo}`);
console.log(` Branch: ${entity.headBranch}${entity.baseBranch ? ` → ${entity.baseBranch}` : ""}`);
console.log(` State: ${entity.state}${entity.prNumber != null ? ` (#${entity.prNumber})` : ""}`);
if (entity.prUrl) console.log(` URL: ${entity.prUrl}`);
console.log(` Mergeable: ${entity.mergeable ?? "unknown"}`);
console.log(` Review: ${entity.reviewDecision ?? "none"}`);
console.log(` Checks: ${entity.checksRollup ?? "none"}`);
console.log(` Auto-merge: ${entity.autoMerge ? "on" : "off"} (${autoMergeGateReason(entity)})`);
console.log(` Active: ${isPrEntityActive(entity) ? "yes" : "no"}; actionable: ${isPrEntityActionable(entity) ? "yes" : "no"}`);
console.log(` Rounds: ${entity.responseRounds}; threads: ${threads.length} (${pending} pending, ${disagreed} disagreed)`);
console.log();
} catch (error) {
if (error instanceof LockRetryExhaustedError) {
await failPrCommand(error, context);
}
throw error;
} finally {
if (context) {
await closeProjectStore(context);
}
}
}
// ── User-controlled actions (parity with POST /api/pull-requests/:id/*) ───────
/**
* Shared release action: re-read the AUTHORITATIVE entity (never trust a stale
* copy), gate it the same way the dashboard route does, then fire the workflow's
* user-controlled release edge via the SAME engine primitive the route uses.
*/
async function runReleaseAction(
id: string,
eventTag: string,
label: string,
opts: { rejectConflict?: boolean },
projectName?: string,
) {
if (!id) {
console.error(`Usage: fn pr ${label} <pr-entity-id>`);
process.exit(1);
}
let context: ProjectContext | undefined;
try {
context = await retryOnLock(() => getPrContext(projectName), { id, action: "resolve project" });
const { store } = context;
const entity = await requireEntity(context, id);
if (!isPrEntityActive(entity)) {
console.error(`\n \u2717 PR ${id} is already terminal (merged/closed/failed)\n`);
await closeProjectStore(context);
process.exit(1);
}
if (opts.rejectConflict && entity.mergeable === "conflicting") {
console.error(`\n \u2717 Resolve conflicts on GitHub before merging\n`);
await closeProjectStore(context);
process.exit(1);
}
// NOT wrapped in retryOnLock: `releaseHeldTaskByEvent` fires the workflow's
// user-controlled release edge, owning further engine/GitHub side effects —
// retrying it on a later lock error would risk re-firing an already-applied
// release.
const result = await releaseHeldTaskByEvent(store, entity.sourceId, eventTag);
if (!result.released) {
console.error(`\n \u2717 ${label} did not release ${id}: ${result.rejection ?? "unknown"}\n`);
await closeProjectStore(context);
process.exit(1);
}
console.log(`\n \u2713 ${label} fired for ${id}${result.toColumn ? ` → ${result.toColumn}` : ""}\n`);
} catch (error) {
if (error instanceof LockRetryExhaustedError) {
await failPrCommand(error, context);
}
throw error;
} finally {
if (context) {
await closeProjectStore(context);
}
}
}
export async function runPrApprove(id: string, projectName?: string) {
await runReleaseAction(id, "pr-approve", "approve", {}, projectName);
}
export async function runPrRespond(id: string, projectName?: string) {
await runReleaseAction(id, "pr-respond", "respond", {}, projectName);
}
export async function runPrRetry(id: string, projectName?: string) {
await runReleaseAction(id, "pr-retry", "retry", {}, projectName);
}
export async function runPrMerge(id: string, projectName?: string) {
await runReleaseAction(id, "pr-merge", "merge", { rejectConflict: true }, projectName);
}
export async function runPrClose(id: string, projectName?: string) {
await runReleaseAction(id, "pr-close", "close", {}, projectName);
}
export async function runPrAutomerge(id: string, enabled: boolean | undefined, projectName?: string) {
if (!id) {
console.error("Usage: fn pr automerge <pr-entity-id> [on|off]");
process.exit(1);
}
let context: ProjectContext | undefined;
try {
context = await retryOnLock(() => getPrContext(projectName), { id, action: "resolve project" });
const { store } = context;
const entity = await requireEntity(context, id);
if (!isPrEntityActive(entity)) {
console.error(`\n \u2717 PR ${id} is already terminal (merged/closed/failed)\n`);
await closeProjectStore(context);
process.exit(1);
}
const next = typeof enabled === "boolean" ? enabled : !entity.autoMerge;
const updated = await retryOnLock(async () => store.updatePrEntity(id, { autoMerge: next }), { id, action: "update PR auto-merge" });
console.log(`\n \u2713 Auto-merge ${updated.autoMerge ? "enabled" : "disabled"} for ${id} (${autoMergeGateReason(updated)})\n`);
} catch (error) {
if (error instanceof LockRetryExhaustedError) {
await failPrCommand(error, context);
}
throw error;
} finally {
if (context) {
await closeProjectStore(context);
}
}
}
export interface PrAutomergeCleanupOptions {
apply?: boolean;
json?: boolean;
}
export async function runPrAutomergeCleanup(options: PrAutomergeCleanupOptions = {}, projectName?: string) {
let context: ProjectContext | undefined;
try {
context = await retryOnLock(() => getPrContext(projectName), { id: "pr-automerge-cleanup", action: "resolve project" });
const { store } = context;
const results = await retryOnLock(
async () =>
options.apply
? store.reconcileLegacyAutoMergeStamps({ apply: true })
: store.reconcileLegacyAutoMergeStamps(),
{ id: "pr-automerge-cleanup", action: "reconcile legacy auto-merge stamps" },
);
printAutomergeCleanupResults(results, options);
} catch (error) {
if (error instanceof LockRetryExhaustedError) {
await failPrCommand(error, context);
}
throw error;
} finally {
if (context) {
await closeProjectStore(context);
}
}
}
function printAutomergeCleanupResults(
results: Awaited<ReturnType<TaskStore["reconcileLegacyAutoMergeStamps"]>>,
options: PrAutomergeCleanupOptions,
): void {
if (options.json) {
console.log(JSON.stringify({
mode: options.apply ? "apply" : "dry-run",
count: results.length,
candidates: options.apply ? undefined : results,
cleared: options.apply ? results : undefined,
}, null, 2));
return;
}
if (results.length === 0) {
console.log("\n ✓ No legacy auto-merge stamps to clean up.\n");
return;
}
if (options.apply) {
console.log(`\n ✓ Cleared ${results.length} legacy auto-merge stamp${results.length === 1 ? "" : "s"}:`);
} else {
console.log(`\n Legacy auto-merge stamp candidate${results.length === 1 ? "" : "s"} (${results.length}):`);
}
for (const result of results) {
console.log(` - ${result.taskId} (${result.column})`);
}
if (!options.apply) {
console.log("\n Re-run with --apply to clear these legacy non-override stamps. Genuine per-task overrides are preserved.\n");
} else {
console.log("");
}
}