FN-7577: extend PR-based revert to workspace tasks under autoMerge:false

Extends FN-7554's single-repo PR revert path to workspace (multi-repo) tasks: when autoMerge is disabled, the revert route now opens one dedicated fusion/revert-<id> PR per sub-repo instead of refusing workspace tasks outright.

- Add prepareWorkspaceRevertPrBranches (packages/engine/src/task-revert.ts): classifies every sub-repo first and only prepares a per-sub-repo fusion/revert-<id> branch when all sub-repos are clean/already-reverted (all-or-nothing at branch-prep phase); never force-writes any sub-repo integration branch.
- Export the new helper from packages/engine/src/index.ts.
- Extend POST /api/tasks/:id/revert (register-task-workflow-routes.ts) to resolve owner/repo and check the GitHub rate limiter for every sub-repo before pushing/creating any PR, opening one PR per sub-repo and returning an additive { mode: "pr", clean: true, workspace: { repos: [...] } } result; degrades the whole task to needsHuman if GitHub is unconfigured or any sub-repo is rate-limited, rather than opening a partial subset of PRs.
- Leave existing { mode: "git" | "ai" | "pr" } shapes, the autoMerge:true workspace path, and FN-7554's single-repo PR path unchanged.
- Add engine real-git coverage (task-revert-workspace-pr.real-git.test.ts) and extend dashboard route tests (task-revert-route.test.ts) for the new workspace PR path.
- Add changeset (.changeset/fn-7577-workspace-pr-revert.md, minor) and update docs/task-management.md.

Files changed:
 .changeset/fn-7577-workspace-pr-revert.md          |   7 +
 docs/task-management.md                            |   3 +-
 .../src/__tests__/task-revert-route.test.ts        | 313 ++++++++++++++++-
 .../src/routes/register-task-workflow-routes.ts    | 181 +++++++++-
 .../task-revert-workspace-pr.real-git.test.ts      | 371 +++++++++++++++++++++
 packages/engine/src/index.ts                       |   4 +
 packages/engine/src/task-revert.ts                 | 295 ++++++++++++++++
 7 files changed, 1166 insertions(+), 8 deletions(-)

Fusion-Task-Id: FN-7577

Fusion-Task-Lineage: bedbfab7-5804-485f-9b40-64531edfc64a

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-05 11:03:45 -07:00
parent b173f76adb
commit 74358494a7
7 changed files with 1166 additions and 8 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": minor
---
summary: Open one revert PR per sub-repo for workspace tasks when autoMerge is disabled.
category: feature
dev: `POST /api/tasks/:id/revert` gains an additive workspace `{ mode: "pr", clean: true, workspace: { repos: [{ repo, revertBranch, prUrl, prNumber, existingPr? }] } }` result for clean multi-repo reverts under `autoMerge:false`, extending FN-7554's single-repo `mode:"pr"` path. New engine export `prepareWorkspaceRevertPrBranches` (packages/engine/src/task-revert.ts) classifies every sub-repo first and only prepares a dedicated `fusion/revert-<id>` branch per sub-repo when all are clean/already-reverted (all-or-nothing at the branch-prep phase), never force-writing any sub-repo integration branch. The route resolves owner/repo and checks the rate limiter for every sub-repo before pushing/creating any PR, so GitHub-unconfigured/rate-limited cases degrade the whole task to `needsHuman` rather than opening a partial subset of PRs. Existing `{ mode: "git" | "ai" | "pr", ... }` shapes, the `autoMerge:true` workspace path, and FN-7554's single-repo path are unchanged.

View File

@@ -695,7 +695,8 @@ Recovery/backfill guidance:
- Git-path response contract (additive only): `{ mode: "git", clean, revertCommitSha?, revertCommitShas?, conflicts?, alreadyReverted?, unsupported?, needsHuman?, reason? }`. A clean revert lands a `revert(FN-xxxx): ...` commit carrying a `Fusion-Task-Id` trailer on the resolved base branch; `revertCommitShas` reports every commit created (all of them for `per-sha`, the single one for `squash`) alongside the existing `revertCommitSha`.
- AI-undo response contract: `{ mode: "ai", createdTaskId: "FN-YYYY", alreadyOpen?: true }`. The created task is an ordinary `triage`-column board task (via the normal `store.createTask` path) that references the source task's id, mission, and landed files, and instructs undoing the source task's behavior while preserving unrelated later changes to the same files, using a `revert(FN-xxxx): ...` commit convention. It carries NO dependency on the (already done/archived) source task. A `sourceMetadata.revertOf` marker makes repeated fallback calls idempotent — while an AI-undo task for that source is still open, a further call returns the same `createdTaskId` with `alreadyOpen: true` instead of creating a duplicate; a prior undo task that itself reached `done`/`archived` does not suppress a fresh one.
- **Workspace (multi-repo) tasks (FN-7547):** tasks with `workspaceWorktrees` populated (`isWorkspaceTask`) are revertable too — the route dispatches to a dedicated workspace path that reasons about every sub-repo's integration branch as ONE all-or-nothing unit. It resolves each sub-repo's attributable commit(s), dry-run classifies every sub-repo first, and only commits a `revert(FN-xxxx): ...` commit on EACH sub-repo when every sub-repo classifies clean/already-reverted; if any sub-repo conflicts, no sub-repo is committed and every touched sub-repo worktree is rolled back to its pre-call state. Response contract for workspace tasks: `{ mode: "git", clean, workspace: { repos: [{ repo, classification, revertCommitSha?, conflicts?, alreadyReverted? }] }, conflicts?: {repo, file, ...}[] }`. A conflicting workspace result still falls back to the AI-undo task under `"auto"` mode, same as a single-repo conflicting result.
- **`autoMerge:false` PR-based revert (FN-7554):** for a single-repo task whose git revert classifies **clean**, `autoMerge:false` no longer dead-ends at `needsHuman`. The route prepares a dedicated `fusion/revert-<id>` branch off the resolved base branch (via the engine's `prepareRevertPrBranch`, which NEVER writes to the base branch itself), pushes it, and opens a GitHub PR through the same owner/repo resolution, `githubRateLimiter` gate, `findPrForBranch` idempotency, and `manual: true` handoff as `POST /tasks/:id/pr/create`. Response: `{ mode: "pr", clean: true, prUrl, prNumber, revertBranch, existingPr? }` — a second call while the PR is still open links the existing PR (`existingPr: true`) instead of re-pushing. GitHub unconfigured or rate-limited still degrades gracefully to `{ mode: "git", needsHuman: true, reason }`, and a conflicting/unsupported/already-reverted classification is unaffected (no PR is opened; `"auto"` mode still falls back to the AI-undo task on conflict/unsupported). Workspace (multi-repo) tasks are not yet covered by this PR path — they keep the existing `needsHuman` result under `autoMerge:false`.
- **`autoMerge:false` PR-based revert (FN-7554):** for a single-repo task whose git revert classifies **clean**, `autoMerge:false` no longer dead-ends at `needsHuman`. The route prepares a dedicated `fusion/revert-<id>` branch off the resolved base branch (via the engine's `prepareRevertPrBranch`, which NEVER writes to the base branch itself), pushes it, and opens a GitHub PR through the same owner/repo resolution, `githubRateLimiter` gate, `findPrForBranch` idempotency, and `manual: true` handoff as `POST /tasks/:id/pr/create`. Response: `{ mode: "pr", clean: true, prUrl, prNumber, revertBranch, existingPr? }` — a second call while the PR is still open links the existing PR (`existingPr: true`) instead of re-pushing. GitHub unconfigured or rate-limited still degrades gracefully to `{ mode: "git", needsHuman: true, reason }`, and a conflicting/unsupported/already-reverted classification is unaffected (no PR is opened; `"auto"` mode still falls back to the AI-undo task on conflict/unsupported).
- **`autoMerge:false` PR-based revert extended to workspace tasks (FN-7577):** a workspace task whose git revert classifies **clean across every sub-repo** also opens PRs instead of dead-ending at `needsHuman` under `autoMerge:false`. The engine's `prepareWorkspaceRevertPrBranches` mirrors the workspace all-or-nothing classify-all contract: it dry-run classifies EVERY sub-repo first, and only prepares one `fusion/revert-<id>` branch per sub-repo (never writing any sub-repo's integration branch) when every sub-repo classifies clean/already-reverted — a single conflicting sub-repo aborts the WHOLE preparation with no branch created anywhere. The route then resolves owner/repo and checks the rate limiter for EVERY sub-repo before pushing/creating any PR (so a GitHub-unconfigured or rate-limited sub-repo degrades the whole task to `needsHuman` rather than opening a partial subset), then opens one PR per sub-repo reusing FN-7554's per-sub-repo `findPrForBranch` idempotency and `manual: true` handoff. Response: `{ mode: "pr", clean: true, workspace: { repos: [{ repo, revertBranch, prUrl, prNumber, existingPr? }] } }`. Existing `{ mode: "git" | "ai" | "pr" }` shapes, the `autoMerge:true` workspace path, and FN-7554's single-repo path are unchanged.
- **Dashboard auto-linking (FN-7555):** the AI-undo task's card shows an "Undo of FN-xxxx" chip and its detail view shows a clickable "Created to undo FN-xxxx" link back to the source task. The source task's detail view shows an "Undo task: FN-YYYY" link whenever an OPEN undo task referencing it exists in the loaded tasks (matching `TaskStore.findOpenRevertTaskForSource`'s open-only semantics — a `done`/`archived`/soft-deleted undo task is never surfaced as active). Both directions are derived client-side from `sourceMetadata.revertOf`; no new API. A dedicated Done/Archived card revert-trigger action is still a separate follow-up (see FN-7525).
- **Configurable AI-undo workflow default (FN-7556, UI: FN-7578):** the project setting `aiUndoTaskWorkflowId` (default `builtin:review-heavy`) selects the workflow applied to every AI-undo task created above (`mode:"ai"` and the `auto`/workspace conflict fallbacks all share one creation seam, so all three inherit this default) — a stricter review posture is warranted because these tasks reverse already-shipped code. A blank/unset value means the created task inherits the project default workflow (pre-FN-7556 behavior); the route falls back to inherit (with a logged warning) if the configured id is blank or does not resolve to a real workflow, so a misconfigured id never breaks AI-undo task creation. Editable from **Settings → General → AI-undo task workflow** (choose "Inherit project default workflow" to store the blank/inherit sentinel). See [Settings Reference → Project Settings](./settings-reference.md#project-settings).

View File

@@ -11,7 +11,7 @@ this suite stubs `performTaskRevert` at the route boundary and asserts:
*/
import { afterEach, describe, expect, it, vi } from "vitest";
import express from "express";
import { mkdtempSync } from "node:fs";
import { mkdirSync, mkdtempSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { execFileSync } from "node:child_process";
@@ -20,6 +20,25 @@ import { createApiRoutes } from "../routes.js";
import { request as performRequest } from "../test-request.js";
import { githubRateLimiter } from "../github-poll.js";
// FN-7577: `getCurrentRepo` is mocked at the `@fusion/core` boundary (partial
// mock, everything else passes through to the real module) so workspace
// mode:"pr" tests can resolve distinct owner/repo per sub-repo without a real
// GitHub remote. The returned wrapper defers reading `getCurrentRepoMock` (and
// falls back to the REAL `getCurrentRepo`) until CALL time — never inside the
// synchronous factory body — so existing single-repo FN-7554 tests (which
// rely on real local-remote resolution / the `GITHUB_REPOSITORY` env
// override) are unaffected; workspace tests below override via
// `mockImplementation`.
const getCurrentRepoMock = vi.fn();
vi.mock("@fusion/core", async (importOriginal) => {
const actual = await importOriginal<typeof import("@fusion/core")>();
return {
...actual,
getCurrentRepo: (...args: [string?]) =>
getCurrentRepoMock.getMockImplementation() ? getCurrentRepoMock(...args) : actual.getCurrentRepo(...args),
};
});
// FNXC:TaskRevert 2026-07-04-00:00: the route now guards against `rootDir`
// (the shared user checkout) sitting on a branch other than the resolved
// base branch (see the branch-mismatch check in register-task-workflow-routes.ts).
@@ -44,6 +63,7 @@ function makeGitRepoOnMain(): string {
const performTaskRevertMock = vi.fn();
const revertWorkspaceTaskMock = vi.fn();
const prepareRevertPrBranchMock = vi.fn();
const prepareWorkspaceRevertPrBranchesMock = vi.fn();
vi.mock("@fusion/engine", async (importOriginal) => {
const actual = await importOriginal<typeof import("@fusion/engine")>();
@@ -52,6 +72,7 @@ vi.mock("@fusion/engine", async (importOriginal) => {
performTaskRevert: (...args: unknown[]) => performTaskRevertMock(...args),
revertWorkspaceTask: (...args: unknown[]) => revertWorkspaceTaskMock(...args),
prepareRevertPrBranch: (...args: unknown[]) => prepareRevertPrBranchMock(...args),
prepareWorkspaceRevertPrBranches: (...args: unknown[]) => prepareWorkspaceRevertPrBranchesMock(...args),
};
});
@@ -106,6 +127,29 @@ function makeWorkspaceTask(overrides: Partial<Task>): Task {
});
}
// FN-7577: real multi-sub-repo git fixture for workspace mode:"pr" tests —
// each sub-repo is its own real git repo with a real bare "origin" remote, so
// the route's REAL `git push -u origin <revertBranch>` has something to push
// (mirrors `makeGitRepoOnMain`'s single-repo pattern, once per sub-repo).
function makeWorkspaceGitRoot(repoRels: string[]): { rootDir: string; repoDirs: Record<string, string> } {
const rootDir = mkdtempSync(join(tmpdir(), "kb-task-revert-ws-route-"));
const repoDirs: Record<string, string> = {};
for (const rel of repoRels) {
const dir = join(rootDir, rel);
mkdirSync(dir, { recursive: true });
execFileSync("git", ["init", "-b", "main"], { cwd: dir });
execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: dir });
execFileSync("git", ["config", "user.name", "Test"], { cwd: dir });
execFileSync("git", ["commit", "--allow-empty", "-m", "init"], { cwd: dir });
const originDir = mkdtempSync(join(tmpdir(), "kb-task-revert-ws-route-origin-"));
execFileSync("git", ["init", "--bare", "-b", "main"], { cwd: originDir });
execFileSync("git", ["remote", "add", "origin", originDir], { cwd: dir });
execFileSync("git", ["push", "-u", "origin", "main"], { cwd: dir });
repoDirs[rel] = dir;
}
return { rootDir, repoDirs };
}
function createMockStore(
task: Task,
opts?: {
@@ -114,6 +158,7 @@ function createMockStore(
autoMerge?: boolean;
aiUndoTaskWorkflowId?: string;
knownWorkflowIds?: string[];
rootDir?: string;
},
): TaskStore {
let nextId = 800;
@@ -145,7 +190,7 @@ function createMockStore(
aiUndoTaskWorkflowId: opts?.aiUndoTaskWorkflowId,
}),
getWorkflowDefinition,
getRootDir: vi.fn().mockReturnValue(makeGitRepoOnMain()),
getRootDir: vi.fn().mockReturnValue(opts?.rootDir ?? makeGitRepoOnMain()),
getTask: vi.fn().mockResolvedValue(task),
getTaskCommitAssociationsByLineageId: vi.fn().mockResolvedValue([]),
createTask,
@@ -699,3 +744,267 @@ describe("POST /tasks/:id/revert — FN-7554 mode:'pr' (autoMerge:false)", () =>
expect(createPrMock).not.toHaveBeenCalled();
});
});
// FN-7577: mode:"pr" — PR-based revert extended to WORKSPACE (multi-repo)
// tasks under autoMerge:false. Real per-sub-repo branch-prep behavior is
// proven by packages/engine/src/__tests__/task-revert-workspace-pr.real-git.test.ts;
// this suite stubs `prepareWorkspaceRevertPrBranches` at the engine boundary
// and `getCurrentRepo` at the core boundary, and asserts the route's
// per-sub-repo PR orchestration, atomic pre-check degrade ordering, and
// idempotency.
describe("POST /tasks/:id/revert — FN-7577 workspace mode:'pr' (autoMerge:false)", () => {
const originalGithubRepository = process.env.GITHUB_REPOSITORY;
afterEach(() => {
vi.clearAllMocks();
vi.restoreAllMocks();
if (originalGithubRepository === undefined) {
delete process.env.GITHUB_REPOSITORY;
} else {
process.env.GITHUB_REPOSITORY = originalGithubRepository;
}
});
function mockRepoResolution(resolvable: Record<string, { owner: string; repo: string } | null>): void {
getCurrentRepoMock.mockImplementation((cwd?: string) => {
for (const [rel, value] of Object.entries(resolvable)) {
if (typeof cwd === "string" && cwd.endsWith(rel)) return value;
}
return null;
});
}
it("all clean + autoMerge:false → mode:'pr' (multi-PR), one PR per sub-repo, manual:true persistence", async () => {
delete process.env.GITHUB_REPOSITORY;
const task = makeWorkspaceTask({ id: "FN-100", column: "done" });
const { rootDir, repoDirs } = makeWorkspaceGitRoot(["repo-a", "repo-b"]);
const store = createMockStore(task, { autoMerge: false, rootDir });
// `prepareWorkspaceRevertPrBranches` is mocked (real branch-prep behavior is
// proven by the engine real-git suite) — create the branches it would have
// created locally, so the route's REAL `git push -u origin <branch>` per
// sub-repo has something to push.
execFileSync("git", ["branch", "fusion/revert-fn-100"], { cwd: repoDirs["repo-a"] });
execFileSync("git", ["branch", "fusion/revert-fn-100"], { cwd: repoDirs["repo-b"] });
mockRepoResolution({ "repo-a": { owner: "o", repo: "repo-a" }, "repo-b": { owner: "o", repo: "repo-b" } });
vi.spyOn(githubRateLimiter, "canMakeRequest").mockReturnValue(true);
findPrForBranchMock.mockResolvedValue(null);
prepareWorkspaceRevertPrBranchesMock.mockResolvedValue({
eligible: true,
repos: [
{ repo: "repo-a", revertBranch: "fusion/revert-fn-100", integrationBranch: "main", revertCommitShas: ["a"] },
{ repo: "repo-b", revertBranch: "fusion/revert-fn-100", integrationBranch: "main", revertCommitShas: ["b"] },
],
});
let callCount = 0;
createPrMock.mockImplementation(async () => {
callCount += 1;
return { number: 100 + callCount, url: `https://github.com/o/repo/pull/${100 + callCount}` };
});
const res = await REQUEST(createApp(store), "POST", `/api/tasks/${task.id}/revert`);
expect(res.status).toBe(200);
expect(res.body).toMatchObject({
mode: "pr",
clean: true,
workspace: {
repos: [
{ repo: "repo-a", revertBranch: "fusion/revert-fn-100" },
{ repo: "repo-b", revertBranch: "fusion/revert-fn-100" },
],
},
});
expect(createPrMock).toHaveBeenCalledTimes(2);
expect(createPrMock.mock.calls[0]?.[0]).toMatchObject({ owner: "o", repo: "repo-a", head: "fusion/revert-fn-100", base: "main" });
expect(createPrMock.mock.calls[1]?.[0]).toMatchObject({ owner: "o", repo: "repo-b", head: "fusion/revert-fn-100", base: "main" });
for (const call of createPrMock.mock.calls) {
expect(typeof call[0]?.body).toBe("string");
expect((call[0]?.body as string).length).toBeGreaterThan(0);
}
expect(store.updatePrInfo as ReturnType<typeof vi.fn>).toHaveBeenCalledWith(task.id, expect.objectContaining({ manual: true }));
expect(revertWorkspaceTaskMock).not.toHaveBeenCalled();
expect(performTaskRevertMock).not.toHaveBeenCalled();
});
it("existing PR idempotency: links repo-a's existing PR and only creates a PR for repo-b", async () => {
delete process.env.GITHUB_REPOSITORY;
const task = makeWorkspaceTask({ id: "FN-101", column: "done" });
const { rootDir, repoDirs } = makeWorkspaceGitRoot(["repo-a", "repo-b"]);
const store = createMockStore(task, { autoMerge: false, rootDir });
execFileSync("git", ["branch", "fusion/revert-fn-101"], { cwd: repoDirs["repo-b"] });
mockRepoResolution({ "repo-a": { owner: "o", repo: "repo-a" }, "repo-b": { owner: "o", repo: "repo-b" } });
vi.spyOn(githubRateLimiter, "canMakeRequest").mockReturnValue(true);
findPrForBranchMock.mockImplementation(async ({ repo }: { repo: string }) =>
repo === "repo-a" ? { number: 55, url: "https://github.com/o/repo-a/pull/55" } : null,
);
prepareWorkspaceRevertPrBranchesMock.mockResolvedValue({
eligible: true,
repos: [
{ repo: "repo-a", revertBranch: "fusion/revert-fn-101", integrationBranch: "main", revertCommitShas: ["a"] },
{ repo: "repo-b", revertBranch: "fusion/revert-fn-101", integrationBranch: "main", revertCommitShas: ["b"] },
],
});
createPrMock.mockResolvedValue({ number: 56, url: "https://github.com/o/repo-b/pull/56" });
const res = await REQUEST(createApp(store), "POST", `/api/tasks/${task.id}/revert`);
expect(res.status).toBe(200);
expect(res.body).toMatchObject({
mode: "pr",
clean: true,
workspace: {
repos: [
{ repo: "repo-a", prNumber: 55, existingPr: true },
{ repo: "repo-b", prNumber: 56 },
],
},
});
expect(createPrMock).toHaveBeenCalledTimes(1);
expect(createPrMock.mock.calls[0]?.[0]).toMatchObject({ repo: "repo-b" });
});
it("GitHub unconfigured degrade (whole-task): needsHuman, no createPr for ANY sub-repo", async () => {
delete process.env.GITHUB_REPOSITORY;
const task = makeWorkspaceTask({ id: "FN-102", column: "done" });
const { rootDir, repoDirs } = makeWorkspaceGitRoot(["repo-a", "repo-b"]);
const store = createMockStore(task, { autoMerge: false, rootDir });
execFileSync("git", ["branch", "fusion/revert-fn-102"], { cwd: repoDirs["repo-a"] });
execFileSync("git", ["branch", "fusion/revert-fn-102"], { cwd: repoDirs["repo-b"] });
// repo-b has NO configured GitHub repository.
mockRepoResolution({ "repo-a": { owner: "o", repo: "repo-a" }, "repo-b": null });
prepareWorkspaceRevertPrBranchesMock.mockResolvedValue({
eligible: true,
repos: [
{ repo: "repo-a", revertBranch: "fusion/revert-fn-102", integrationBranch: "main", revertCommitShas: ["a"] },
{ repo: "repo-b", revertBranch: "fusion/revert-fn-102", integrationBranch: "main", revertCommitShas: ["b"] },
],
});
const res = await REQUEST(createApp(store), "POST", `/api/tasks/${task.id}/revert`);
expect(res.status).toBe(200);
expect(res.body).toMatchObject({ mode: "git", needsHuman: true });
expect(String((res.body as { reason?: string }).reason ?? "")).toMatch(/no GitHub repository/i);
expect(createPrMock).not.toHaveBeenCalled();
expect(findPrForBranchMock).not.toHaveBeenCalled();
});
it("rate-limited degrade (whole-task): needsHuman without touching createPr for any sub-repo", async () => {
delete process.env.GITHUB_REPOSITORY;
const task = makeWorkspaceTask({ id: "FN-103", column: "done" });
const { rootDir, repoDirs } = makeWorkspaceGitRoot(["repo-a", "repo-b"]);
const store = createMockStore(task, { autoMerge: false, rootDir });
execFileSync("git", ["branch", "fusion/revert-fn-103"], { cwd: repoDirs["repo-a"] });
execFileSync("git", ["branch", "fusion/revert-fn-103"], { cwd: repoDirs["repo-b"] });
mockRepoResolution({ "repo-a": { owner: "o", repo: "repo-a" }, "repo-b": { owner: "o", repo: "repo-b" } });
vi.spyOn(githubRateLimiter, "canMakeRequest").mockImplementation((repoKey: string) => repoKey !== "o/repo-b");
prepareWorkspaceRevertPrBranchesMock.mockResolvedValue({
eligible: true,
repos: [
{ repo: "repo-a", revertBranch: "fusion/revert-fn-103", integrationBranch: "main", revertCommitShas: ["a"] },
{ repo: "repo-b", revertBranch: "fusion/revert-fn-103", integrationBranch: "main", revertCommitShas: ["b"] },
],
});
const res = await REQUEST(createApp(store), "POST", `/api/tasks/${task.id}/revert`);
expect(res.status).toBe(200);
expect(res.body).toMatchObject({ mode: "git", needsHuman: true });
expect(String((res.body as { reason?: string }).reason ?? "")).toMatch(/rate limit/i);
expect(createPrMock).not.toHaveBeenCalled();
expect(findPrForBranchMock).not.toHaveBeenCalled();
});
it("conflicting under autoMerge:false, mode:'git' → { mode: 'git', clean: false, workspace, conflicts }, no PR", async () => {
delete process.env.GITHUB_REPOSITORY;
const task = makeWorkspaceTask({ id: "FN-104", column: "done" });
const { rootDir } = makeWorkspaceGitRoot(["repo-a", "repo-b"]);
const store = createMockStore(task, { autoMerge: false, rootDir });
prepareWorkspaceRevertPrBranchesMock.mockResolvedValue({
eligible: false,
classification: "conflicting",
conflicts: [{ repo: "repo-b", file: "b.ts", status: "UU" }],
repos: [
{ repo: "repo-a", classification: "clean" },
{ repo: "repo-b", classification: "conflicting", conflicts: [{ file: "b.ts", status: "UU" }] },
],
});
const res = await POST_JSON(createApp(store), `/api/tasks/${task.id}/revert`, { mode: "git" });
expect(res.status).toBe(200);
expect(res.body).toMatchObject({
mode: "git",
clean: false,
conflicts: [{ repo: "repo-b", file: "b.ts" }],
});
expect(createPrMock).not.toHaveBeenCalled();
});
it("conflicting under autoMerge:false, mode:'auto' → falls back to the AI-undo task", async () => {
delete process.env.GITHUB_REPOSITORY;
const task = makeWorkspaceTask({ id: "FN-105", column: "done" });
const { rootDir } = makeWorkspaceGitRoot(["repo-a", "repo-b"]);
const store = createMockStore(task, { autoMerge: false, rootDir });
prepareWorkspaceRevertPrBranchesMock.mockResolvedValue({
eligible: false,
classification: "conflicting",
conflicts: [{ repo: "repo-b", file: "b.ts", status: "UU" }],
repos: [
{ repo: "repo-a", classification: "clean" },
{ repo: "repo-b", classification: "conflicting", conflicts: [{ file: "b.ts", status: "UU" }] },
],
});
const res = await POST_JSON(createApp(store), `/api/tasks/${task.id}/revert`, { mode: "auto" });
expect(res.status).toBe(200);
expect(res.body).toMatchObject({ mode: "ai" });
expect((res.body as { createdTaskId?: string }).createdTaskId).toBeTruthy();
expect(createPrMock).not.toHaveBeenCalled();
});
it("empty prep (all already-reverted) → { mode: 'git', clean: true, workspace: { repos: [] } }, no createPr", async () => {
delete process.env.GITHUB_REPOSITORY;
const task = makeWorkspaceTask({ id: "FN-106", column: "done" });
const { rootDir } = makeWorkspaceGitRoot(["repo-a", "repo-b"]);
const store = createMockStore(task, { autoMerge: false, rootDir });
prepareWorkspaceRevertPrBranchesMock.mockResolvedValue({ eligible: true, repos: [] });
const res = await REQUEST(createApp(store), "POST", `/api/tasks/${task.id}/revert`);
expect(res.status).toBe(200);
expect(res.body).toMatchObject({ mode: "git", clean: true, workspace: { repos: [] } });
expect(createPrMock).not.toHaveBeenCalled();
expect(findPrForBranchMock).not.toHaveBeenCalled();
});
it("regression — workspace autoMerge:true unchanged: still calls revertWorkspaceTask, prepareWorkspaceRevertPrBranches/createPr not called", async () => {
const task = makeWorkspaceTask({ id: "FN-107", column: "done" });
const { rootDir } = makeWorkspaceGitRoot(["repo-a", "repo-b"]);
const store = createMockStore(task, { autoMerge: true, rootDir });
revertWorkspaceTaskMock.mockResolvedValue({
mode: "git",
clean: true,
workspace: {
repos: [
{ repo: "repo-a", classification: "clean", revertCommitSha: "rev-a" },
{ repo: "repo-b", classification: "clean", revertCommitSha: "rev-b" },
],
},
});
const res = await REQUEST(createApp(store), "POST", `/api/tasks/${task.id}/revert`);
expect(res.status).toBe(200);
expect(res.body).toMatchObject({ mode: "git", clean: true });
expect(revertWorkspaceTaskMock).toHaveBeenCalledTimes(1);
expect(prepareWorkspaceRevertPrBranchesMock).not.toHaveBeenCalled();
expect(createPrMock).not.toHaveBeenCalled();
});
it("regression — single-repo autoMerge:false unchanged: still takes prepareRevertPrBranch, prepareWorkspaceRevertPrBranches not called", async () => {
process.env.GITHUB_REPOSITORY = "o/r";
const task = makeTask({ id: "FN-108", column: "done" });
const store = createMockStore(task, { autoMerge: false });
vi.spyOn(githubRateLimiter, "canMakeRequest").mockReturnValue(true);
findPrForBranchMock.mockResolvedValue({ number: 21, url: "https://github.com/o/r/pull/21" });
const res = await REQUEST(createApp(store), "POST", `/api/tasks/${task.id}/revert`);
expect(res.status).toBe(200);
expect(res.body).toMatchObject({ mode: "pr", clean: true, prNumber: 21, existingPr: true });
expect(prepareWorkspaceRevertPrBranchesMock).not.toHaveBeenCalled();
});
});

View File

@@ -1,5 +1,5 @@
import { createReadStream } from "node:fs";
import { resolve, sep } from "node:path";
import { join, resolve, sep } from "node:path";
import type {
TaskStore,
Task,
@@ -60,8 +60,11 @@ import {
TaskRevertError,
createAiUndoTask,
prepareRevertPrBranch,
prepareWorkspaceRevertPrBranches,
type AiUndoTaskResult,
type PrepareRevertPrBranchResult,
type PrepareWorkspaceRevertPrBranchesResult,
type WorkspaceRepoRevertPrBranch,
} from "@fusion/engine";
import { buildBoardWorkflowsPayload } from "./board-workflows.js";
import { isBackwardMoveBlockedByOpenPr, PR_OPEN_BLOCKS_MOVE_BACK_MESSAGE } from "./register-pull-requests-routes.js";
@@ -1704,10 +1707,15 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
conflicts?, alreadyReverted?, unsupported?, needsHuman?, reason? }` OR, for workspace tasks (FN-7547),
`{ mode: "git", clean, workspace: { repos: [{ repo, classification, revertCommitSha?, conflicts?,
alreadyReverted? }] }, conflicts?: {repo, file, ...}[] }` OR
`{ mode: "ai", createdTaskId: "FN-YYYY", alreadyOpen?: true }`. The AI-undo task is created via
`createAiUndoTask` (engine) + `TaskStore.findOpenRevertTaskForSource` (core) for the idempotency
guard — a second call while an undo task is still open returns the SAME `createdTaskId` with
`alreadyOpen: true` rather than creating a duplicate.
`{ mode: "ai", createdTaskId: "FN-YYYY", alreadyOpen?: true }` OR, for a single-repo task under
`autoMerge:false` (FN-7554), `{ mode: "pr", clean: true, prUrl, prNumber, revertBranch, existingPr? }`
OR, for a WORKSPACE task under `autoMerge:false` (FN-7577 — additive over FN-7554/FN-7547),
`{ mode: "pr", clean: true, workspace: { repos: [{ repo, revertBranch, prUrl, prNumber, existingPr? }] } }`
— one revert PR opened per sub-repo, all-or-nothing at the branch-prep phase
(`prepareWorkspaceRevertPrBranches`), never force-writing any sub-repo integration branch. The
AI-undo task is created via `createAiUndoTask` (engine) + `TaskStore.findOpenRevertTaskForSource`
(core) for the idempotency guard — a second call while an undo task is still open returns the
SAME `createdTaskId` with `alreadyOpen: true` rather than creating a duplicate.
*/
router.post("/tasks/:id/revert", async (req, res) => {
try {
@@ -1800,6 +1808,169 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
UNCHANGED. `granularity` does not apply to the workspace path.
*/
if (isWorkspaceTask(task)) {
/*
FNXC:TaskRevert 2026-07-05-00:00 (FN-7577 — workspace mode:"pr" dispatch,
additive over FN-7554/FN-7547): `revertWorkspaceTask` refuses
(`needsHuman`) whenever autoMerge is effectively off, the same dead end
`performTaskRevert` hits for single-repo tasks. Instead of stopping
there, take the multi-PR path: `prepareWorkspaceRevertPrBranches`
classifies EVERY sub-repo first and only prepares one dedicated
`fusion/revert-<id>` branch per sub-repo (never writing any sub-repo
integration branch) when every sub-repo is clean/already-reverted; this
route then opens ONE revert PR per prepared sub-repo branch, reusing
FN-7554's per-repo owner/repo resolution, rate-limiter gate,
`findPrForBranch` idempotency, and `manual:true` handoff. The
`autoMerge:true` workspace path below (the existing `revertWorkspaceTask`
call) is UNCHANGED.
*/
const effectiveAutoMerge = task.autoMerge ?? settings.autoMerge ?? true;
if (effectiveAutoMerge === false) {
const revertBranch = `fusion/revert-${task.id.toLowerCase()}`;
const prepared: PrepareWorkspaceRevertPrBranchesResult = await prepareWorkspaceRevertPrBranches({
task,
workspaceRootDir: rootDir,
settings,
revertBranch,
commitAssociationSource: {
getTaskCommitAssociationsByLineageId: (lineageId: string) =>
scopedStore.getTaskCommitAssociationsByLineageId(lineageId),
},
});
if (!prepared.eligible) {
if ("classification" in prepared && prepared.classification === "conflicting") {
if (mode === "auto") {
res.json(await createAiUndoResult());
return;
}
res.json({ mode: "git", clean: false, workspace: { repos: prepared.repos }, conflicts: prepared.conflicts });
return;
}
if ("unsupported" in prepared && prepared.unsupported) {
if (mode === "auto") {
res.json(await createAiUndoResult());
return;
}
res.json({ mode: "git", unsupported: true, reason: prepared.reason });
return;
}
}
if (prepared.eligible) {
// prepared.eligible === true
if (prepared.repos.length === 0) {
// Every sub-repo was already-reverted — nothing to PR.
res.json({ mode: "git", clean: true, workspace: { repos: [] } });
return;
}
/*
FNXC:TaskRevert 2026-07-05-00:00 (FN-7577 — atomic pre-check ordering):
Resolve owner/repo AND check the rate limiter for EVERY prepared
sub-repo BEFORE pushing/creating any PR, so the two common degrade
cases (GitHub unconfigured / rate-limited) never leave a partial
subset of PRs open across sub-repos. Nothing has been pushed to any
remote yet at this point, so degrading here only needs to delete the
purely-local prepared branches.
*/
const cleanupPreparedBranches = async (): Promise<void> => {
for (const repoBranch of prepared.repos) {
const repoRootDir = join(rootDir, repoBranch.repo);
await runGitCommand(["checkout", repoBranch.integrationBranch], repoRootDir, 10_000).catch(() => undefined);
await runGitCommand(["branch", "-D", revertBranch], repoRootDir, 10_000).catch(() => undefined);
}
};
const targets: { repoBranch: WorkspaceRepoRevertPrBranch; owner: string; repo: string }[] = [];
for (const repoBranch of prepared.repos) {
const gitRepo = getCurrentRepo(join(rootDir, repoBranch.repo));
if (!gitRepo) {
await cleanupPreparedBranches();
res.json({
mode: "git",
needsHuman: true,
reason: "autoMerge is disabled and one or more sub-repos have no GitHub repository configured; cannot open revert PRs",
});
return;
}
targets.push({ repoBranch, owner: gitRepo.owner, repo: gitRepo.repo });
}
for (const target of targets) {
const repoKey = `${target.owner}/${target.repo}`;
if (!githubRateLimiter.canMakeRequest(repoKey)) {
await cleanupPreparedBranches();
res.json({
mode: "git",
needsHuman: true,
reason: "GitHub API rate limit exceeded; try again later",
});
return;
}
}
/*
FNXC:TaskRevert 2026-07-05-00:00 (FN-7577 — idempotent multi-PR
recovery contract): from this point on, a thrown error (network down,
push rejected, GitHub 5xx, etc.) is surfaced via the shared `catch`
below rather than a graceful needsHuman degrade, because an earlier
sub-repo in this loop may already have an open remote PR by the time a
later sub-repo fails — this route NEVER attempts to close/delete an
already-created remote PR. A re-run of this endpoint is safe:
`findPrForBranch` links any already-created sub-repo PR instead of
re-creating it, and `prepareWorkspaceRevertPrBranches`'s `checkout -B`
re-preps local branches for any sub-repo not yet pushed.
*/
const resultRepos: { repo: string; revertBranch: string; prUrl: string; prNumber: number; existingPr?: boolean }[] = [];
const persistPrInfo = async (prInfo: PrInfo): Promise<void> => {
const existingPrs = task.prInfos ?? (task.prInfo ? [task.prInfo] : []);
if (existingPrs.length > 0) {
await scopedStore.addPrInfo(task.id, prInfo);
} else {
await scopedStore.updatePrInfo(task.id, prInfo);
}
};
for (const target of targets) {
const client = new GitHubClient();
const existingPr = await client.findPrForBranch({ head: revertBranch, state: "all", owner: target.owner, repo: target.repo });
if (existingPr) {
// Idempotency — never re-push/re-create when an open (or all-state)
// PR already exists for this sub-repo's branch, just link it.
const prInfo: PrInfo = { ...existingPr, manual: true };
await persistPrInfo(prInfo);
await scopedStore.logEntry(task.id, "Linked existing revert PR", `${target.repoBranch.repo}: PR #${prInfo.number}: ${prInfo.url}`);
resultRepos.push({ repo: target.repoBranch.repo, revertBranch, prUrl: prInfo.url, prNumber: prInfo.number, existingPr: true });
continue;
}
await runGitCommand(["push", "-u", "origin", revertBranch], join(rootDir, target.repoBranch.repo), 60_000);
const prTitle = `revert(${task.id}): undo landed work (${target.repoBranch.repo})`;
const prBody =
`This PR reverts the work landed by task ${task.id} in sub-repo \`${target.repoBranch.repo}\`.\n\n` +
`See \`GET /api/tasks/${task.id}/diff\` for the full landed diff being reverted.\n`;
const created = await client.createPr({
owner: target.owner,
repo: target.repo,
title: prTitle,
body: prBody,
head: revertBranch,
base: target.repoBranch.integrationBranch,
});
const prInfo: PrInfo = { ...created, manual: true };
await persistPrInfo(prInfo);
await scopedStore.logEntry(task.id, "Created revert PR", `${target.repoBranch.repo}: PR #${prInfo.number}: ${prInfo.url}`);
resultRepos.push({ repo: target.repoBranch.repo, revertBranch, prUrl: prInfo.url, prNumber: prInfo.number });
}
res.json({ mode: "pr", clean: true, workspace: { repos: resultRepos } });
return;
}
}
const workspaceResult = await revertWorkspaceTask({
task,
workspaceRootDir: rootDir,

View File

@@ -0,0 +1,371 @@
import { exec, execSync, spawnSync } from "node:child_process";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { promisify } from "node:util";
import { afterEach, describe, expect, it } from "vitest";
import { prepareWorkspaceRevertPrBranches } from "../task-revert.js";
import type { Task } from "@fusion/core";
const realExecAsync = promisify(exec);
const hasGit = spawnSync("git", ["--version"], { stdio: "pipe" }).status === 0;
const describeIfGit = hasGit ? describe : describe.skip;
function git(repo: string, command: string): string {
return execSync(command, { cwd: repo, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim();
}
function makeTask(overrides: Partial<Task>): Task {
return {
id: "FN-A",
lineageId: "FN-A",
description: "",
column: "done",
dependencies: [],
steps: [],
currentStep: 0,
...overrides,
} as Task;
}
/*
FNXC:TaskRevert 2026-07-05-00:00 (FN-7577):
Real multi-sub-repo git fixture coverage for `prepareWorkspaceRevertPrBranches`
— the Symptom Verification regression suite for the workspace `mode:"pr"`
branch-prep primitive. Mirrors the two-sub-repo fixture pattern from
`task-revert.workspace.real-git.test.ts` (FN-7547) combined with the
single-repo branch-prep assertions from `task-revert-pr.real-git.test.ts`
(FN-7554): clean → per-sub-repo `fusion/revert-<id>` branches with
integration branches left byte-identical; one conflicting sub-repo aborts the
WHOLE preparation with no branch created anywhere; already-reverted →
eligible with empty repos; mixed clean/already-reverted → only the
still-clean sub-repo gets a branch; non-workspace task → unsupported;
idempotent local branch reset; dirty-tree/branch-mismatch refusal; and a
late-conflict multi-branch cleanup.
*/
describeIfGit("prepareWorkspaceRevertPrBranches real-git scenarios", { timeout: 30_000 }, () => {
const dirs: string[] = [];
afterEach(() => {
for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true });
});
function subRepoFixture(workspaceRoot: string, repoRel: string, initialFile: string, initialContent: string): string {
const repoRootDir = join(workspaceRoot, repoRel);
git(workspaceRoot, `mkdir -p ${repoRel}`);
git(repoRootDir, "git init -b main");
git(repoRootDir, 'git config user.email "test@example.com"');
git(repoRootDir, 'git config user.name "Test User"');
git(repoRootDir, "git config commit.gpgsign false");
writeFileSync(join(repoRootDir, initialFile), initialContent);
git(repoRootDir, `git add ${initialFile} && git commit -m 'init'`);
return repoRootDir;
}
function workspaceFixture() {
const workspaceRoot = mkdtempSync(join(tmpdir(), "kb-revert-ws-pr-"));
dirs.push(workspaceRoot);
const repoA = subRepoFixture(workspaceRoot, "repo-a", "a.ts", "line1\n");
const repoB = subRepoFixture(workspaceRoot, "repo-b", "b.ts", "line1\n");
return { workspaceRoot, repoA, repoB };
}
function landTaskCommit(repoRootDir: string, file: string, content: string, commitSubject: string): string {
writeFileSync(join(repoRootDir, file), content);
git(repoRootDir, `git commit -am ${JSON.stringify(commitSubject)}`);
return git(repoRootDir, "git rev-parse HEAD");
}
function makeWorkspaceTask(shaA: string, shaB: string, overrides: Partial<Task> = {}): Task {
return makeTask({
column: "done",
workspaceWorktrees: {
"repo-a": { worktreePath: "repo-a", branch: "fusion/FN-A", landedSha: shaA },
"repo-b": { worktreePath: "repo-b", branch: "fusion/FN-A", landedSha: shaB },
},
mergeDetails: { commitSha: shaA, workspaceLandedShas: { "repo-a": shaA, "repo-b": shaB } },
...overrides,
});
}
it("all clean → eligible, per-sub-repo branches, integration branches unwritten", async () => {
const { workspaceRoot, repoA, repoB } = workspaceFixture();
const shaA = landTaskCommit(repoA, "a.ts", "line1\nfeature-a\n", "feat(FN-A): add feature in repo-a");
const shaB = landTaskCommit(repoB, "b.ts", "line1\nfeature-a\n", "feat(FN-A): add feature in repo-b");
const mainHeadBeforeA = git(repoA, "git rev-parse main");
const mainHeadBeforeB = git(repoB, "git rev-parse main");
const task = makeWorkspaceTask(shaA, shaB);
const result = await prepareWorkspaceRevertPrBranches({
task,
workspaceRootDir: workspaceRoot,
settings: {},
revertBranch: "fusion/revert-fn-a",
});
expect(result).toMatchObject({ eligible: true });
if (result.eligible) {
expect(result.repos).toHaveLength(2);
const byRepo = Object.fromEntries(result.repos.map((r) => [r.repo, r]));
expect(byRepo["repo-a"]).toMatchObject({ revertBranch: "fusion/revert-fn-a", integrationBranch: "main" });
expect(byRepo["repo-b"]).toMatchObject({ revertBranch: "fusion/revert-fn-a", integrationBranch: "main" });
expect(byRepo["repo-a"].revertCommitShas).toHaveLength(1);
expect(byRepo["repo-b"].revertCommitShas).toHaveLength(1);
}
for (const repoRootDir of [repoA, repoB]) {
const branchTipSubject = git(repoRootDir, "git log -1 --format=%s fusion/revert-fn-a");
expect(branchTipSubject).toMatch(/^revert\(FN-A\):/);
const branchTipBody = git(repoRootDir, "git log -1 --format=%B fusion/revert-fn-a");
expect(branchTipBody).toContain("Fusion-Task-Id: FN-A");
// checkout restored to main, clean.
expect(git(repoRootDir, "git rev-parse --abbrev-ref HEAD")).toBe("main");
expect(git(repoRootDir, "git status --porcelain")).toBe("");
}
// (b) each sub-repo's main HEAD is byte-identical to before — integration
// branch never written.
expect(git(repoA, "git rev-parse main")).toBe(mainHeadBeforeA);
expect(git(repoB, "git rev-parse main")).toBe(mainHeadBeforeB);
expect(git(repoA, "git show fusion/revert-fn-a:a.ts")).toBe("line1");
expect(git(repoB, "git show fusion/revert-fn-a:b.ts")).toBe("line1");
});
it("one sub-repo conflicting → whole-task aborted, NO branches anywhere (Symptom Verification)", async () => {
const { workspaceRoot, repoA, repoB } = workspaceFixture();
const shaA = landTaskCommit(repoA, "a.ts", "line1\nfeature-a\n", "feat(FN-A): add feature in repo-a");
const shaB = landTaskCommit(repoB, "b.ts", "line1\nfeature-a\n", "feat(FN-A): add feature in repo-b");
// Task B later modifies the exact same region touched by task A in repo-b only.
landTaskCommit(repoB, "b.ts", "line1\nfeature-a-modified-by-b\n", "feat(FN-B): modify same region in repo-b");
const mainHeadBeforeA = git(repoA, "git rev-parse main");
const mainHeadBeforeB = git(repoB, "git rev-parse main");
const task = makeWorkspaceTask(shaA, shaB);
const result = await prepareWorkspaceRevertPrBranches({
task,
workspaceRootDir: workspaceRoot,
settings: {},
revertBranch: "fusion/revert-fn-a",
});
expect(result).toMatchObject({ eligible: false, classification: "conflicting" });
if (!result.eligible && result.classification === "conflicting") {
expect(result.conflicts.some((c) => c.repo === "repo-b")).toBe(true);
}
for (const repoRootDir of [repoA, repoB]) {
expect(git(repoRootDir, "git branch --list fusion/revert-fn-a")).toBe("");
}
expect(git(repoA, "git rev-parse main")).toBe(mainHeadBeforeA);
expect(git(repoB, "git rev-parse main")).toBe(mainHeadBeforeB);
expect(git(repoA, "git rev-parse --abbrev-ref HEAD")).toBe("main");
expect(git(repoB, "git rev-parse --abbrev-ref HEAD")).toBe("main");
});
it("all already-reverted → eligible with empty repos, no branches, integration branches unchanged", async () => {
const { workspaceRoot, repoA, repoB } = workspaceFixture();
const shaA = landTaskCommit(repoA, "a.ts", "line1\nfeature-a\n", "feat(FN-A): add feature in repo-a");
const shaB = landTaskCommit(repoB, "b.ts", "line1\nfeature-a\n", "feat(FN-A): add feature in repo-b");
// Manually revert both sub-repos on main before calling the branch-prep primitive.
git(repoA, `git revert --no-edit ${shaA}`);
git(repoB, `git revert --no-edit ${shaB}`);
const mainHeadBeforeA = git(repoA, "git rev-parse main");
const mainHeadBeforeB = git(repoB, "git rev-parse main");
const task = makeWorkspaceTask(shaA, shaB);
const result = await prepareWorkspaceRevertPrBranches({
task,
workspaceRootDir: workspaceRoot,
settings: {},
revertBranch: "fusion/revert-fn-a",
});
expect(result).toMatchObject({ eligible: true, repos: [] });
for (const repoRootDir of [repoA, repoB]) {
expect(git(repoRootDir, "git branch --list fusion/revert-fn-a")).toBe("");
}
expect(git(repoA, "git rev-parse main")).toBe(mainHeadBeforeA);
expect(git(repoB, "git rev-parse main")).toBe(mainHeadBeforeB);
});
it("mixed clean + already-reverted → only the still-clean sub-repo gets a branch", async () => {
const { workspaceRoot, repoA, repoB } = workspaceFixture();
const shaA = landTaskCommit(repoA, "a.ts", "line1\nfeature-a\n", "feat(FN-A): add feature in repo-a");
const shaB = landTaskCommit(repoB, "b.ts", "line1\nfeature-a\n", "feat(FN-A): add feature in repo-b");
// Manually revert repo-b only.
git(repoB, `git revert --no-edit ${shaB}`);
const mainHeadBeforeA = git(repoA, "git rev-parse main");
const mainHeadBeforeB = git(repoB, "git rev-parse main");
const task = makeWorkspaceTask(shaA, shaB);
const result = await prepareWorkspaceRevertPrBranches({
task,
workspaceRootDir: workspaceRoot,
settings: {},
revertBranch: "fusion/revert-fn-a",
});
expect(result).toMatchObject({ eligible: true });
if (result.eligible) {
expect(result.repos).toHaveLength(1);
expect(result.repos[0].repo).toBe("repo-a");
}
const branchTipSubject = git(repoA, "git log -1 --format=%s fusion/revert-fn-a");
expect(branchTipSubject).toMatch(/^revert\(FN-A\):/);
expect(git(repoB, "git branch --list fusion/revert-fn-a")).toBe("");
expect(git(repoA, "git rev-parse main")).toBe(mainHeadBeforeA);
expect(git(repoB, "git rev-parse main")).toBe(mainHeadBeforeB);
});
it("non-workspace task → unsupported", async () => {
const { workspaceRoot } = workspaceFixture();
const task = makeTask({ column: "done" });
const result = await prepareWorkspaceRevertPrBranches({
task,
workspaceRootDir: workspaceRoot,
settings: {},
revertBranch: "fusion/revert-fn-a",
});
expect(result).toMatchObject({ eligible: false, unsupported: true, reason: "not-a-workspace-task" });
});
it("idempotent local branch reset: a stale local branch in one sub-repo is reset off integration with the fresh revert commit", async () => {
const { workspaceRoot, repoA, repoB } = workspaceFixture();
const shaA = landTaskCommit(repoA, "a.ts", "line1\nfeature-a\n", "feat(FN-A): add feature in repo-a");
const shaB = landTaskCommit(repoB, "b.ts", "line1\nfeature-a\n", "feat(FN-A): add feature in repo-b");
// Pre-create a stale local revert branch in repo-a pointing at an unrelated commit.
git(repoA, "git branch fusion/revert-fn-a main~1");
const staleTip = git(repoA, "git rev-parse fusion/revert-fn-a");
expect(staleTip).not.toBe(git(repoA, "git rev-parse main"));
const task = makeWorkspaceTask(shaA, shaB);
const result = await prepareWorkspaceRevertPrBranches({
task,
workspaceRootDir: workspaceRoot,
settings: {},
revertBranch: "fusion/revert-fn-a",
});
expect(result).toMatchObject({ eligible: true });
const branchTipSubject = git(repoA, "git log -1 --format=%s fusion/revert-fn-a");
expect(branchTipSubject).toMatch(/^revert\(FN-A\):/);
expect(git(repoA, "git rev-parse --abbrev-ref HEAD")).toBe("main");
});
it("dirty-tree refusal: refuses without mutating any sub-repo when one has a stray uncommitted change", async () => {
const { workspaceRoot, repoA, repoB } = workspaceFixture();
const shaA = landTaskCommit(repoA, "a.ts", "line1\nfeature-a\n", "feat(FN-A): add feature in repo-a");
const shaB = landTaskCommit(repoB, "b.ts", "line1\nfeature-a\n", "feat(FN-A): add feature in repo-b");
writeFileSync(join(repoB, "b.ts"), "line1\nfeature-a\nSTRAY UNCOMMITTED CHANGE\n");
const mainHeadBeforeA = git(repoA, "git rev-parse main");
const mainHeadBeforeB = git(repoB, "git rev-parse main");
const task = makeWorkspaceTask(shaA, shaB);
await expect(
prepareWorkspaceRevertPrBranches({
task,
workspaceRootDir: workspaceRoot,
settings: {},
revertBranch: "fusion/revert-fn-a",
}),
).rejects.toMatchObject({ code: "dirty-working-tree" });
for (const repoRootDir of [repoA, repoB]) {
expect(git(repoRootDir, "git branch --list fusion/revert-fn-a")).toBe("");
}
expect(git(repoA, "git rev-parse main")).toBe(mainHeadBeforeA);
expect(git(repoB, "git rev-parse main")).toBe(mainHeadBeforeB);
});
it("branch-mismatch refusal: refuses without mutating any sub-repo when one is checked out on a different branch", async () => {
const { workspaceRoot, repoA, repoB } = workspaceFixture();
const shaA = landTaskCommit(repoA, "a.ts", "line1\nfeature-a\n", "feat(FN-A): add feature in repo-a");
const shaB = landTaskCommit(repoB, "b.ts", "line1\nfeature-a\n", "feat(FN-A): add feature in repo-b");
git(repoB, "git checkout -b some-other-branch");
const mainHeadBeforeA = git(repoA, "git rev-parse main");
const mainHeadBeforeB = git(repoB, "git rev-parse main");
const task = makeWorkspaceTask(shaA, shaB);
await expect(
prepareWorkspaceRevertPrBranches({
task,
workspaceRootDir: workspaceRoot,
settings: {},
revertBranch: "fusion/revert-fn-a",
}),
).rejects.toMatchObject({ code: "branch-mismatch" });
for (const repoRootDir of [repoA, repoB]) {
expect(git(repoRootDir, "git branch --list fusion/revert-fn-a")).toBe("");
}
expect(git(repoA, "git rev-parse main")).toBe(mainHeadBeforeA);
expect(git(repoB, "git rev-parse main")).toBe(mainHeadBeforeB);
});
it("late-conflict multi-branch cleanup: repo-a's prepped branch is deleted when repo-b conflicts during apply (branch moved between classify and apply)", async () => {
const { workspaceRoot, repoA, repoB } = workspaceFixture();
const shaA = landTaskCommit(repoA, "a.ts", "line1\nfeature-a\n", "feat(FN-A): add feature in repo-a");
const shaB = landTaskCommit(repoB, "b.ts", "line1\nfeature-a\n", "feat(FN-A): add feature in repo-b");
const mainHeadBeforeA = git(repoA, "git rev-parse main");
const mainHeadBeforeB = git(repoB, "git rev-parse main");
const task = makeWorkspaceTask(shaA, shaB);
/*
FNXC:TaskRevert 2026-07-05-00:00 (FN-7577 test): both sub-repos classify
CLEAN in Phase 1 (repo-b's main is still untouched at that point). Once
Phase 2 starts checking out repo-a's branch (repo-a sorts first), inject a
conflicting commit directly onto repo-b's `main` — simulating repo-b's
branch moving between classify and apply. When Phase 2 reaches repo-b,
`checkout -B fusion/revert-fn-a main` branches off the NEW (conflicting)
tip, so applying repo-b's revert commit now conflicts — a genuine late
conflict. Assert repo-a's already-prepped branch is rolled back too.
*/
let injected = false;
const execAsyncImpl: typeof realExecAsync = (async (command: string, options: Record<string, unknown>) => {
if (!injected && options?.cwd === repoA && /git checkout -B/.test(command)) {
injected = true;
writeFileSync(join(repoB, "b.ts"), "line1\nfeature-a-modified-by-b\n");
execSync("git commit -am 'feat(FN-B): modify same region in repo-b'", { cwd: repoB, stdio: "pipe" });
}
return realExecAsync(command, options as never);
}) as typeof realExecAsync;
const result = await prepareWorkspaceRevertPrBranches({
task,
workspaceRootDir: workspaceRoot,
settings: {},
revertBranch: "fusion/revert-fn-a",
execAsyncImpl,
});
expect(result).toMatchObject({ eligible: false, classification: "conflicting" });
if (!result.eligible && result.classification === "conflicting") {
expect(result.conflicts.some((c) => c.repo === "repo-b")).toBe(true);
}
// repo-a's already-prepped branch from this pass is rolled back too — all-or-nothing.
expect(git(repoA, "git branch --list fusion/revert-fn-a")).toBe("");
expect(git(repoB, "git branch --list fusion/revert-fn-a")).toBe("");
expect(git(repoA, "git rev-parse main")).toBe(mainHeadBeforeA);
// repo-b's main legitimately advanced due to the injected commit (this test
// simulates an external actor landing work mid-preparation) — the
// invariant is that NO revert branch/commit was created anywhere, not that
// repo-b's HEAD is frozen (that HEAD moved before this function ever ran
// Phase 2 for repo-b).
expect(git(repoB, "git rev-parse main")).not.toBe(mainHeadBeforeB);
});
});

View File

@@ -295,6 +295,10 @@ export {
prepareRevertPrBranch,
type PrepareRevertPrBranchResult,
type PrepareRevertPrBranchOptions,
prepareWorkspaceRevertPrBranches,
type PrepareWorkspaceRevertPrBranchesResult,
type PrepareWorkspaceRevertPrBranchesOptions,
type WorkspaceRepoRevertPrBranch,
} from "./task-revert.js";
export {
resolveBranchGroupMergeRouting,

View File

@@ -35,6 +35,17 @@
* captures `preRevertHead` before touching the tree and guarantees a full
* `git revert --abort` + `git reset --hard <preRevertHead>` rollback in a
* `finally` block, regardless of how the dry-run terminates.
*
* FNXC:TaskRevert 2026-07-05-00:00 (FN-7577 — PR-based revert extended to
* workspace tasks): `prepareRevertPrBranch` (FN-7554) explicitly refuses
* workspace tasks (`workspace-task-pr-revert-unsupported`) because a single
* PR against a single base branch cannot represent a multi-repo revert.
* `prepareWorkspaceRevertPrBranches` fills that gap: it mirrors
* `revertWorkspaceTask`'s classify-all-then-commit-all skeleton but, instead
* of force-committing onto each sub-repo's integration branch, prepares one
* dedicated `<revertBranch>` per sub-repo (never writing any integration
* branch) so the caller can open one PR per sub-repo. See its own doc
* comment below for the full contract.
*/
import { exec } from "node:child_process";
import { join } from "node:path";
@@ -1225,6 +1236,290 @@ export async function revertWorkspaceTask(opts: RevertWorkspaceTaskOptions): Pro
return { mode: "git", clean: true, workspace: { repos } };
}
// ---------------------------------------------------------------------------
// FN-7577: PR-based revert for WORKSPACE (multi-repo) tasks under autoMerge:false.
// ---------------------------------------------------------------------------
export interface WorkspaceRepoRevertPrBranch {
repo: string;
/** Same branch NAME across every sub-repo (`fusion/revert-<task-id-lowercase>`). */
revertBranch: string;
/** This sub-repo's resolved integration branch — the PR base. NEVER written to. */
integrationBranch: string;
revertCommitShas: string[];
}
export type PrepareWorkspaceRevertPrBranchesResult =
| { eligible: true; repos: WorkspaceRepoRevertPrBranch[] }
| {
eligible: false;
classification: "conflicting";
conflicts: (TaskRevertConflict & { repo: string })[];
repos: WorkspaceRepoRevertResult[];
}
| { eligible: false; unsupported: true; reason: string };
export interface PrepareWorkspaceRevertPrBranchesOptions {
task: Pick<Task, "id" | "lineageId" | "column" | "mergeDetails" | "workspaceWorktrees">;
/** Project root dir; each sub-repo lives at `join(workspaceRootDir, repoRel)` (mirrors `revertWorkspaceTask`). */
workspaceRootDir: string;
/** Project settings, passed through to `resolveIntegrationBranch` per sub-repo with `integrationBranch`/`baseBranch` stripped (KTD1). */
settings: IntegrationBranchSettings;
/** e.g. `fusion/revert-<task-id-lowercase>` — the SAME branch name prepared in every sub-repo. */
revertBranch: string;
execAsyncImpl?: ExecAsyncImpl;
commitAssociationSource?: TaskCommitAssociationSource;
}
interface WorkspaceRepoRevertPrContext {
repo: string;
repoRootDir: string;
integrationBranch: string;
commits: string[];
classification: ClassifyTaskRevertResult;
}
type PrepareOneWorkspaceRepoBranchOutcome =
| { kind: "applied"; revertCommitSha: string }
| { kind: "already-reverted" }
| { kind: "conflicts"; conflicts: TaskRevertConflict[] };
/**
* FNXC:TaskRevert 2026-07-05-00:00 (FN-7577 — per-sub-repo branch prep
* primitive, mirrors FN-7554's single-repo `prepareRevertPrBranch` body):
* Prepares ONE sub-repo's dedicated `revertBranch` off `integrationBranch`
* HEAD (`-B`, idempotent re-run), applies+commits via the shared
* `applyAndCommitRevert` (REUSE, not reimplementation — same helper
* `performTaskRevert`/`revertWorkspaceTask`/`prepareRevertPrBranch` all use),
* and ALWAYS restores the sub-repo checkout back to `integrationBranch`
* before returning — `integrationBranch`'s ref itself is never advanced,
* reset, or committed to. A redundant/failed local `revertBranch` (nothing
* to commit, or a late apply-time conflict) is deleted so this sub-repo is
* left byte-identical to its pre-call state whenever no PR branch results.
*/
async function prepareOneWorkspaceRepoRevertBranch(opts: {
execImpl: ExecAsyncImpl;
repoRootDir: string;
integrationBranch: string;
revertBranch: string;
commits: string[];
taskId: string;
}): Promise<PrepareOneWorkspaceRepoBranchOutcome> {
const { execImpl, repoRootDir, integrationBranch, revertBranch, commits, taskId } = opts;
let branchCreated = false;
try {
// FNXC:TaskRevert 2026-07-05-00:00: `-B` (create-or-reset) makes
// re-running this idempotent when a stale local `revertBranch` already
// exists from a prior failed/aborted attempt — reset off
// `integrationBranch` HEAD rather than accumulating on top of whatever it
// previously pointed at. `integrationBranch` is only ever READ here.
await runGit(execImpl, `git checkout -B ${quoteShellArg(revertBranch)} ${quoteShellArg(integrationBranch)}`, repoRootDir);
branchCreated = true;
const applied = await applyAndCommitRevert({ worktreePath: repoRootDir, commits, taskId, execAsyncImpl: execImpl });
if ("alreadyReverted" in applied) {
// Defensive: the branch moved between classify and apply — nothing to
// commit on the fresh revertBranch. Delete the now-redundant branch so
// this sub-repo contributes no branch to the caller.
await runGit(execImpl, `git checkout ${quoteShellArg(integrationBranch)}`, repoRootDir).catch(() => undefined);
await runGit(execImpl, `git branch -D ${quoteShellArg(revertBranch)}`, repoRootDir).catch(() => undefined);
return { kind: "already-reverted" };
}
if ("conflicts" in applied) {
// Late conflict — applyAndCommitRevert already rolled repoRootDir back to
// the tip of revertBranch (== integrationBranch HEAD). Delete the
// now-redundant branch; the caller handles multi-repo rollback.
await runGit(execImpl, `git checkout ${quoteShellArg(integrationBranch)}`, repoRootDir).catch(() => undefined);
await runGit(execImpl, `git branch -D ${quoteShellArg(revertBranch)}`, repoRootDir).catch(() => undefined);
return { kind: "conflicts", conflicts: applied.conflicts };
}
// applied.applied === true — leave `revertBranch` in place (it IS the PR
// branch) but restore the sub-repo checkout back to `integrationBranch`,
// never leaving it mid-revert on `revertBranch`.
await runGit(execImpl, `git checkout ${quoteShellArg(integrationBranch)}`, repoRootDir).catch(() => undefined);
return { kind: "applied", revertCommitSha: applied.revertCommitSha };
} catch (error) {
// On any thrown failure after branch creation, never leave a dangling
// partial revert branch behind — best-effort restore + delete.
if (branchCreated) {
await runGit(execImpl, `git checkout ${quoteShellArg(integrationBranch)}`, repoRootDir).catch(() => undefined);
await runGit(execImpl, `git branch -D ${quoteShellArg(revertBranch)}`, repoRootDir).catch(() => undefined);
}
throw error instanceof TaskRevertError
? error
: new TaskRevertError("failed to prepare revert branch for sub-repo", "revert-pr-branch-prepare-failed", error);
}
}
/**
* FNXC:TaskRevert 2026-07-05-00:00 (FN-7577 — workspace PR-revert branch prep,
* the all-or-nothing gate this task adds on top of FN-7554/FN-7547):
*
* NEVER-WRITE-TO-INTEGRATION-BRANCH INVARIANT: mirrors `prepareRevertPrBranch`
* (single-repo) extended to N sub-repos — no sub-repo's integration branch
* ref is EVER advanced, reset, or committed to. Every revert commit lives
* ONLY on that sub-repo's `<revertBranch>` (same branch NAME across every
* sub-repo, distinct branch OBJECT per sub-repo git history).
*
* CLASSIFY-ALL-THEN-PREP-ALL (Phase 1 / Phase 2), mirroring
* `revertWorkspaceTask`'s whole-task all-or-nothing contract: Phase 1
* dry-run classifies EVERY sub-repo first (reusing the shared
* `classifyTaskRevert`, itself always rolling each tree back
* byte-identical). If ANY sub-repo classifies `conflicting`, this function
* returns immediately with NO branch created anywhere — Phase 2 (branch
* prep) never runs for ANY sub-repo. Only when every sub-repo classifies
* clean/already-reverted does Phase 2 run, preparing one dedicated
* `<revertBranch>` per sub-repo that actually has commits to revert
* (`prepareOneWorkspaceRepoRevertBranch`, reusing `applyAndCommitRevert` — no
* commit-message/trailer duplication). A LATE conflict during Phase 2 (a
* sub-repo's branch moved between classify and apply) rolls back every
* PREVIOUSLY prepped sub-repo's branch in this pass (checkout back +
* `git branch -D`) before returning conflicting, so the whole preparation
* stays all-or-nothing even when the failure surfaces mid-pass rather than
* during classification.
*
* THIS FUNCTION DOES NOT GATE ON `autoMerge` — the caller (the API route)
* decides when to invoke this primitive; it stays a pure branch-prep
* building block usable independent of that policy decision.
*/
export async function prepareWorkspaceRevertPrBranches(
opts: PrepareWorkspaceRevertPrBranchesOptions,
): Promise<PrepareWorkspaceRevertPrBranchesResult> {
const { task, workspaceRootDir, revertBranch } = opts;
const execImpl = opts.execAsyncImpl ?? defaultExecAsync;
if (!isWorkspaceTask(task) || Object.keys(task.workspaceWorktrees ?? {}).length === 0) {
return { eligible: false, unsupported: true, reason: "not-a-workspace-task" };
}
const workspaceWorktrees = task.workspaceWorktrees ?? {};
const repoKeys = Object.keys(workspaceWorktrees).sort();
const attribution = await resolveWorkspaceTaskRevertCommits(task, {
workspaceRootDir,
execAsyncImpl: execImpl,
commitAssociationSource: opts.commitAssociationSource,
});
// Phase 1: resolve each sub-repo's integration branch, refuse (without
// mutating) on branch-mismatch/dirty-tree, then dry-run classify EVERY
// sub-repo — mirrors `revertWorkspaceTask`'s Phase 1 verbatim.
const contexts: WorkspaceRepoRevertPrContext[] = [];
for (const repoRel of repoKeys) {
const repoRootDir = join(workspaceRootDir, repoRel);
let integrationBranch: string;
try {
integrationBranch = await resolveIntegrationBranch(repoRootDir, { ...opts.settings, integrationBranch: undefined, baseBranch: undefined });
} catch (error) {
throw new TaskRevertError(`failed to resolve integration branch for sub-repo ${repoRel}`, "integration-branch-resolve-failed", error);
}
const currentBranch = (await runGit(execImpl, "git rev-parse --abbrev-ref HEAD", repoRootDir)).stdout.trim();
if (currentBranch !== integrationBranch) {
throw new TaskRevertError(
`sub-repo ${repoRel} checkout is on "${currentBranch}", not its integration branch "${integrationBranch}"; switch to "${integrationBranch}" before preparing a revert PR branch`,
"branch-mismatch",
);
}
const { stdout: statusOut } = await runGit(execImpl, "git status --porcelain", repoRootDir);
if (statusOut.trim().length > 0) {
throw new TaskRevertError(
`working tree for sub-repo ${repoRel} is dirty; refusing to prepare a revert PR branch`,
"dirty-working-tree",
);
}
const commits = attribution[repoRel]?.commits ?? [];
const classification = await classifyTaskRevert({ worktreePath: repoRootDir, commits, execAsyncImpl: execImpl });
contexts.push({ repo: repoRel, repoRootDir, integrationBranch, commits, classification });
}
const anyConflicting = contexts.some((ctx) => ctx.classification.classification === "conflicting");
if (anyConflicting) {
const repos: WorkspaceRepoRevertResult[] = contexts.map((ctx) => ({
repo: ctx.repo,
classification: ctx.classification.classification,
conflicts: ctx.classification.conflicts,
alreadyReverted: ctx.classification.alreadyReverted,
}));
const conflicts = contexts.flatMap((ctx) =>
(ctx.classification.conflicts ?? []).map((conflict) => ({ ...conflict, repo: ctx.repo })),
);
return { eligible: false, classification: "conflicting", conflicts, repos };
}
// Phase 2: every sub-repo classified clean/already-reverted — prepare a
// dedicated revert branch per sub-repo that actually has commits to revert.
const preppedRepos: WorkspaceRepoRevertPrBranch[] = [];
const preppedForRollback: { repo: string; repoRootDir: string; integrationBranch: string }[] = [];
try {
for (const ctx of contexts) {
if (ctx.classification.classification === "already-reverted" || ctx.commits.length === 0) {
// Nothing to revert in this sub-repo — contributes no branch.
continue;
}
const outcome = await prepareOneWorkspaceRepoRevertBranch({
execImpl,
repoRootDir: ctx.repoRootDir,
integrationBranch: ctx.integrationBranch,
revertBranch,
commits: ctx.commits,
taskId: task.id,
});
if (outcome.kind === "already-reverted") {
continue;
}
if (outcome.kind === "conflicts") {
// Late conflict — roll back every PREVIOUSLY prepped sub-repo's branch
// in this pass so the whole preparation stays all-or-nothing.
for (const prepped of preppedForRollback) {
await runGit(execImpl, `git checkout ${quoteShellArg(prepped.integrationBranch)}`, prepped.repoRootDir).catch(() => undefined);
await runGit(execImpl, `git branch -D ${quoteShellArg(revertBranch)}`, prepped.repoRootDir).catch(() => undefined);
}
const conflicts = outcome.conflicts.map((conflict) => ({ ...conflict, repo: ctx.repo }));
const repos: WorkspaceRepoRevertResult[] = contexts.map((c) => ({
repo: c.repo,
classification: c.repo === ctx.repo ? "conflicting" : c.classification.classification,
conflicts: c.repo === ctx.repo ? outcome.conflicts : c.classification.conflicts,
alreadyReverted: c.classification.alreadyReverted,
}));
return { eligible: false, classification: "conflicting", conflicts, repos };
}
// outcome.kind === "applied"
preppedRepos.push({
repo: ctx.repo,
revertBranch,
integrationBranch: ctx.integrationBranch,
revertCommitShas: [outcome.revertCommitSha],
});
preppedForRollback.push({ repo: ctx.repo, repoRootDir: ctx.repoRootDir, integrationBranch: ctx.integrationBranch });
}
} catch (error) {
// Unexpected thrown failure mid-pass — best-effort restore + delete every
// already-prepped sub-repo branch so a failed prep never leaves dangling
// half-built revert branches.
for (const prepped of preppedForRollback) {
await runGit(execImpl, `git checkout ${quoteShellArg(prepped.integrationBranch)}`, prepped.repoRootDir).catch(() => undefined);
await runGit(execImpl, `git branch -D ${quoteShellArg(revertBranch)}`, prepped.repoRootDir).catch(() => undefined);
}
throw error instanceof TaskRevertError
? error
: new TaskRevertError("failed to prepare workspace revert PR branches", "workspace-revert-pr-branch-prepare-failed", error);
}
return { eligible: true, repos: preppedRepos };
}
// ────────────────────────────────────────────────────────────────────────
// FN-7524: AI-undo fallback
// ────────────────────────────────────────────────────────────────────────