Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .seeds/issues.jsonl

Large diffs are not rendered by default.

13 changes: 13 additions & 0 deletions src/forge/contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,19 @@ export interface ForgeCapabilities {
botIdentity: boolean;
/** drives the §4 re-mint: "static" skips it, "short-lived" requires it */
credentialLifetime: "static" | "short-lived";
/**
* The forge's ecosystem can merge pull requests without a seam call —
* GitHub's auto-merge workflow is the canonical example. A plan-run gates
* each child on the previous PR merging; a forge with `autoMerge: false`
* has no such mechanism, so nothing would transition the PR and the
* coordinator would wait until `parent_pr_merge_timeout`. Dispatch is
* refused up front rather than silently timing out. Single runs are
* unaffected — they only need push + openPullRequest, both of which a
* GitLab forge provides. (GitLabForge: false; GitHub*: true; FakeForge:
* true — the store transitions PRs itself and acceptance scenarios depend
* on that.)
*/
autoMerge: boolean;
}

/**
Expand Down
1 change: 1 addition & 0 deletions src/forge/fake/fake-forge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ describe("FakeForge capabilities", () => {
branchDelete: true,
botIdentity: true,
credentialLifetime: "static",
autoMerge: true,
});
});
});
Expand Down
3 changes: 3 additions & 0 deletions src/forge/fake/fake-forge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,9 @@ export class FakeForge implements Forge {
branchDelete: true,
botIdentity: true,
credentialLifetime: "static",
// The store transitions PRs itself (markMerged), so the plan-run
// coordinator's merge poll resolves and acceptance scenarios work.
autoMerge: true,
};

/** Exposed for the seeding seams; never serialized across the seam. */
Expand Down
1 change: 1 addition & 0 deletions src/forge/github-app/provider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ describe("GitHubAppForge capabilities (forge-contract.md §5)", () => {
branchDelete: true,
botIdentity: true,
credentialLifetime: "short-lived",
autoMerge: true,
});
});
});
Expand Down
1 change: 1 addition & 0 deletions src/forge/github-app/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ export class GitHubAppForge implements Forge {
branchDelete: true,
botIdentity: true,
credentialLifetime: "short-lived",
autoMerge: true,
};

private readonly appId: string;
Expand Down
1 change: 1 addition & 0 deletions src/forge/github/provider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ describe("GitHubForge capabilities", () => {
branchDelete: true,
botIdentity: false,
credentialLifetime: "static",
autoMerge: true,
});
});

Expand Down
1 change: 1 addition & 0 deletions src/forge/github/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,7 @@ export class GitHubForge implements Forge {
// names commits via WARREN_GIT_AUTHOR_* until App mode ships.
botIdentity: false,
credentialLifetime: "static",
autoMerge: true,
};
}

Expand Down
52 changes: 51 additions & 1 deletion src/forge/gitlab/merge-requests.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
import { describe, expect, test } from "bun:test";
import { toForgeError, toPullRequestRef, toPullRequestState } from "./merge-requests.ts";
import { jsonResponse, recordingFetch } from "../http/test-helpers.ts";
import type { GitLabCallContext } from "./merge-requests.ts";
import {
createMergeRequest,
toForgeError,
toPullRequestRef,
toPullRequestState,
} from "./merge-requests.ts";

describe("toPullRequestRef", () => {
test("uses iid — the per-project number — and never the global id", () => {
Expand Down Expand Up @@ -87,6 +94,49 @@ describe("toPullRequestState", () => {
});
});

describe("createMergeRequest — 409 duplicate recovery", () => {
const BASE_CTX: GitLabCallContext = {
apiBase: "https://gitlab.example.com/api/v4",
projectPath: "group/project",
token: "glpat-x",
userAgent: "warren-forge-gitlab",
fetch: globalThis.fetch,
};

const LOCKED_MR = {
iid: 7,
state: "locked",
web_url: "https://gitlab.example.com/group/project/-/merge_requests/7",
source_branch: "warren/run_1",
target_branch: "main",
sha: "deadbeef",
};

test("409 recovery finds a LOCKED MR — GitLab locks an MR while a merge is in flight", async () => {
// GitLab flips an MR to `locked` while a merge is in flight. A
// re-dispatch racing that window hits 409 (duplicate source branch)
// and the recovery path calls findMergeRequest. matchesQueryState
// treats `locked` as `open`, so the recovery resolves to the locked MR
// rather than returning null and surfacing a conflict error.
const { fetch } = recordingFetch([
jsonResponse(409, { message: "Another open merge request already exists" }),
jsonResponse(200, [LOCKED_MR]),
]);
const ctx = { ...BASE_CTX, fetch };
const result = await createMergeRequest(ctx, {
title: "warren: run_1",
body: "",
headBranch: "warren/run_1",
baseBranch: "main",
});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value.number).toBe(7);
expect(result.value.key).toBe("group/project!7");
}
});
});

describe("toForgeError", () => {
test("renames the transport kind and carries the status as detail", () => {
expect(
Expand Down
17 changes: 17 additions & 0 deletions src/forge/gitlab/provider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,23 @@ describe("GitLabForge capabilities", () => {
expect(f.capabilities.botIdentity).toBe(false);
expect(f.capabilities.credentialLifetime).toBe("static");
});

test("autoMerge is false — plan-runs are refused at dispatch; single-run PR ops are unaffected (warren-3e09)", async () => {
// The forge has no GitHub-style auto-merge workflow. The plan-run
// coordinator would poll forever, so createPlanRun refuses the dispatch.
// Single-run operations (parseRepoRef, openPullRequest) are not gated.
const { forge: f } = forge([jsonResponse(201, MR_JSON)]);
expect(f.capabilities.autoMerge).toBe(false);
// openPullRequest is the single-run PR seam — it must keep working.
const r = ref(f);
const result = await f.openPullRequest(r, {
title: "warren: run_1",
body: "",
headBranch: "warren/run_1",
baseBranch: "main",
});
expect(result.ok).toBe(true);
});
});

describe("GitLabForge.parseRepoRef", () => {
Expand Down
32 changes: 23 additions & 9 deletions src/forge/gitlab/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,15 +22,26 @@
* API (§6.7). The domain degrades per §5 either way: the CI-fixer poller stays
* idle and the trigger emits one notice per project.
*
* PLAN-RUN LIMITATION: this provider supports SINGLE runs. A plan-run gates
* each child on the previous PR merging, and warren performs that merge
* through GitHub's auto-merge workflow rather than through the seam — which is
* why the contract deliberately has no `mergePullRequest`. A GitLab project
* has no such workflow, so nothing transitions the MR and the plan-run waits
* to `parent_pr_merge_timeout`. Closing this needs either a GitLab
* merge-when-pipeline-succeeds call behind a new capability flag, or a
* dispatch-time guard that refuses a plan-run on a forge that cannot
* self-merge. Tracked on warren-75e8.
* PLAN-RUN LIMITATION: this provider supports SINGLE runs only.
* `capabilities.autoMerge` is false here. A plan-run gates each child on the
* previous PR merging; warren performs that merge through GitHub's auto-merge
* workflow, not through the seam (which is why the contract deliberately has
* no `mergePullRequest`). A GitLab project has no such workflow, so dispatch
* is refused up front with `ForgeCannotAutoMergeError` (HTTP 424) rather than
* accepted and left to time out at `parent_pr_merge_timeout`. Filling this gap
* needs a GitLab merge-when-pipeline-succeeds call added here and
* `autoMerge` flipped to true. Tracked on warren-75e8.
*
* NESTED-GROUP LAYOUT COLLISION (DO NOT FIX HERE): a project clones to
* `<projectsRoot>/<owner>/<name>`, and `parseForgeOwnedUrl`
* (`src/projects/url.ts`) derives those two segments from the LAST TWO path
* segments of the clone URL. GitLab paths are N segments deep, so
* `group-a/sub/project` and `group-b/sub/project` both lay out as
* `sub/project` and would collide. The collision fails loudly
* (`cloneProjectRepo` refuses an existing target path, `clone.ts:156`), so no
* data is lost, but the error names a path that mentions neither group. Fixing
* this needs the seam to expose a ref's layout path — a Forge contract change
* tracked separately.
*/

import type {
Expand Down Expand Up @@ -181,6 +192,9 @@ export class GitLabForge implements Forge {
// The token authorizes; it does not name the author (§6.8).
botIdentity: false,
credentialLifetime: "static",
// GitLab has no GitHub-style auto-merge workflow — see PLAN-RUN
// LIMITATION above. Single runs are unaffected.
autoMerge: false,
};
}

Expand Down
11 changes: 5 additions & 6 deletions src/forge/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,11 @@
* back to the default, so a typo can't route runs onto the wrong forge).
*
* Registry CONSTRUCTION only: threading the resolved instance through boot
* wiring and `ServerDeps` is the next step (warren-6c4c). `parseRepoRef`
* chaining operates over the boot-registered forges in their fixed
* registration order (§1.1). The selector still resolves exactly ONE forge
* per process, so the chain has length one whichever kind is chosen; running
* GitHub and GitLab projects on a single instance needs the multi-instance
* config surface, not a second entry here (warren-f012).
* wiring and `ServerDeps` is the next step (warren-6c4c). The selector
* resolves exactly ONE forge per process. `parseRepoRef` returning null
* therefore means "this process's forge does not own the URL" — no chain
* walker exists. Running GitHub and GitLab projects on a single instance
* needs the multi-instance config surface, not a second entry here (warren-f012).
*/

import type { Forge } from "./contract.ts";
Expand Down
36 changes: 35 additions & 1 deletion src/plan-runs/create.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,16 @@ import type {
} from "../core/wire.ts";
import { openDatabase, type WarrenDb } from "../db/client.ts";
import { createRepos, type Repos } from "../db/repos/index.ts";
import type { Forge } from "../forge/contract.ts";
import type { SpawnFn, SpawnOptions, SpawnResult } from "../projects/clone.ts";
import type { IssueTracker, PlanCapableTracker } from "../tracker/contract.ts";
import { SeedsTracker } from "../tracker/seeds-tracker.ts";
import { createPlanRun } from "./create.ts";
import { PlanHasNoOpenChildrenError, ProjectLacksTrackerError } from "./errors.ts";
import {
ForgeCannotAutoMergeError,
PlanHasNoOpenChildrenError,
ProjectLacksTrackerError,
} from "./errors.ts";

/* ----------------------------------------------------------------------- */
/* Stubs (mirror the seeds CLI's wire envelopes without shelling out) */
Expand Down Expand Up @@ -333,6 +338,35 @@ describe("createPlanRun", () => {
}),
).rejects.toThrow();
});

/* ----------- forge autoMerge gate (warren-3e09) ----------- */

test("refuses plan-run dispatch when the forge cannot auto-merge (warren-3e09)", async () => {
// A forge with autoMerge:false (e.g. GitLabForge) is refused at dispatch
// rather than accepted and left to time out at parent_pr_merge_timeout.
const noAutoMergeForge = {
capabilities: { autoMerge: false },
} as unknown as Forge;
await expect(createPlanRun({ ...baseInput(), forge: noAutoMergeForge })).rejects.toBeInstanceOf(
ForgeCannotAutoMergeError,
);
});

test("single runs are unaffected — autoMerge gate only fires in createPlanRun (warren-3e09)", async () => {
// A forge with autoMerge:false does NOT block a plan-run when the forge
// field is absent (backward compat / test paths). The single-run path
// (spawnRun) never calls createPlanRun, so it is structurally unaffected.
const result = await createPlanRun({ ...baseInput() });
expect(result.planRun.state).toBe("queued");
});

test("plan-run is accepted when forge supports auto-merge (warren-3e09)", async () => {
const autoMergeForge = {
capabilities: { autoMerge: true },
} as unknown as Forge;
const result = await createPlanRun({ ...baseInput(), forge: autoMergeForge });
expect(result.planRun.state).toBe("queued");
});
});

/** Fake hosted (non-git-native) tracker double for the domain tests (warren-2d98). */
Expand Down
30 changes: 29 additions & 1 deletion src/plan-runs/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,13 +34,18 @@ import type { PlanRunSource, PlanStatus } from "../core/wire.ts";
import type { Repos } from "../db/repos/index.ts";
import type { CreatePlanRunResult } from "../db/repos/plan-runs.ts";
import type { ProjectRow } from "../db/schema.ts";
import type { Forge } from "../forge/contract.ts";
import type { SpawnFn } from "../projects/clone.ts";
import type { ProjectsConfig } from "../projects/config.ts";
import { refreshProject } from "../projects/index.ts";
import type { IssueTracker, PlanCapableTracker, TrackerContext } from "../tracker/contract.ts";
import type { WarrenConfigCache } from "../warren-config/index.ts";
import type { GitSpawnCredential } from "../workspace/git/credential-env.ts";
import { PlanHasNoOpenChildrenError, ProjectLacksTrackerError } from "./errors.ts";
import {
ForgeCannotAutoMergeError,
PlanHasNoOpenChildrenError,
ProjectLacksTrackerError,
} from "./errors.ts";

export const PLAN_RUN_ACCEPTED_PLAN_STATUSES: readonly PlanStatus[] = [
"approved",
Expand Down Expand Up @@ -81,6 +86,13 @@ export interface CreatePlanRunOrchestrationInput {
readonly repos: Repos;
/** Undefined ⇒ ValidationError — plan-runs require an issue tracker. */
readonly issueTracker: IssueTracker | undefined;
/**
* Boot-resolved forge (warren-3e09). When provided, `createPlanRun`
* checks `capabilities.autoMerge` and refuses the dispatch if false —
* plan-runs require a forge whose ecosystem can merge PRs automatically.
* Omitting it (legacy or test paths) skips the gate.
*/
readonly forge?: Forge;
/** Git spawn seam. When wired, the host clone is refreshed before the plan walk (warren-6d60). */
readonly spawn?: SpawnFn;
readonly projectsConfig: ProjectsConfig;
Expand Down Expand Up @@ -289,6 +301,22 @@ async function requireTrackedProject(
export async function createPlanRun(
input: CreatePlanRunOrchestrationInput,
): Promise<CreatePlanRunResult> {
// warren-3e09: refuse up front if the forge cannot self-merge PRs.
// Plan-runs gate each child on the previous PR merging via the forge's
// ecosystem auto-merge mechanism (e.g. GitHub Actions). A forge with
// autoMerge:false has no such mechanism and would silently time out.
if (input.forge !== undefined && !input.forge.capabilities.autoMerge) {
throw new ForgeCannotAutoMergeError(
"plan-runs require a forge whose ecosystem can auto-merge pull requests; " +
"the configured forge reports autoMerge:false — dispatch refused to avoid a silent timeout",
{
recoveryHint:
"use a GitHub project (WARREN_FORGE=github or WARREN_FORGE=app), or wait for " +
"GitLab merge-when-pipeline-succeeds support (warren-75e8)",
},
);
}

if (input.promptTemplate !== undefined) assertPlanRunPromptTemplate(input.promptTemplate);

const { project, tracker } = await requireTrackedProject(input);
Expand Down
15 changes: 15 additions & 0 deletions src/plan-runs/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,18 @@ export class ProjectLacksTrackerError extends WarrenError {
export class PlanHasNoOpenChildrenError extends WarrenError {
readonly code = "plan_has_no_open_children";
}

/**
* `POST /plan-runs` rejection when the boot-resolved forge reports
* `capabilities.autoMerge: false` (warren-3e09). Plan-runs gate each child
* on the previous PR merging; warren performs that merge through the forge's
* ecosystem auto-merge mechanism (e.g. GitHub Actions). A forge that cannot
* self-merge would accept the dispatch and then time out at
* `parent_pr_merge_timeout` with no cause named. This error surfaces the
* incapacity up front so the operator can switch to a supported forge or wait
* for the missing capability to be implemented. HTTP 424 — the request is
* valid but unsatisfiable by this forge, matching the `unsupported` family.
*/
export class ForgeCannotAutoMergeError extends WarrenError {
readonly code = "forge_cannot_auto_merge";
}
9 changes: 8 additions & 1 deletion src/server/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,11 @@ import {
ValidationError,
WarrenError,
} from "../core/errors.ts";
import { PlanHasNoOpenChildrenError, ProjectLacksTrackerError } from "../plan-runs/errors.ts";
import {
ForgeCannotAutoMergeError,
PlanHasNoOpenChildrenError,
ProjectLacksTrackerError,
} from "../plan-runs/errors.ts";
import { ProjectUnavailableError } from "../projects/errors.ts";
import { AgentSchemaError } from "../registry/errors.ts";
import { RunSpawnError } from "../runs/errors.ts";
Expand Down Expand Up @@ -218,6 +222,9 @@ function warrenStatusFor(err: WarrenError): number {
if (err instanceof ValidationError) return 400;
if (err instanceof ProjectLacksTrackerError) return 400;
if (err instanceof PlanHasNoOpenChildrenError) return 400;
// warren-3e09: 424 matches the `unsupported` ForgeError family — the
// request is valid but unsatisfiable by this forge configuration.
if (err instanceof ForgeCannotAutoMergeError) return 424;
if (err instanceof StateTransitionError) return 409;
// Provider-neutral runtime errors (warren-36cb): K8sProvider transport
// failures ride `RuntimeUnreachableError`; run-not-found maps to 404 and a
Expand Down
2 changes: 2 additions & 0 deletions src/server/handlers/plan-runs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,8 @@ export function createPlanRunHandler(deps: ServerDeps): RouteHandler {
...(dispatcherHandle !== undefined ? { dispatcherHandle } : {}),
repos: deps.repos,
issueTracker: deps.issueTracker,
// warren-3e09: thread forge so createPlanRun can gate on autoMerge.
forge: deps.forge,
projectsConfig: deps.projectsConfig,
...(deps.spawn !== undefined ? { spawn: deps.spawn } : {}),
...(gitSecret !== undefined ? { gitCredential: gitSecret } : {}),
Expand Down
Loading