Skip to content
106 changes: 106 additions & 0 deletions apps/server/src/gits/Layers/AutomodeDriver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { AutomodeUsageMeter } from "../Services/AutomodeUsageMeter.ts";
import { AutomodeDriver } from "../Services/AutomodeDriver.ts";
import { GitsReviewPipeline } from "../Services/GitsReviewPipeline.ts";
import { AutomodeLanding, type AutomodeLandResult } from "../Services/AutomodeLanding.ts";
import { AutomodeHeldPr, type AutomodeOpenHeldPrResult } from "../Services/AutomodeHeldPr.ts";
import { AutomodeSupervisorLive } from "./AutomodeSupervisor.ts";
import { AutomodeDriverLive } from "./AutomodeDriver.ts";

Expand Down Expand Up @@ -94,6 +95,9 @@ const emptyList: Omit<DelamainPeerListResult, "peers"> = {
interface MakeLayerOptions {
readonly review?: GitsReviewResult;
readonly landResult?: AutomodeLandResult;
readonly openResult?: AutomodeOpenHeldPrResult;
readonly onOpenHeldPr?: () => void;
readonly mergeResults?: boolean[];
}

// Mutable holder so a test can change what listPeers returns between ticks.
Expand Down Expand Up @@ -137,6 +141,21 @@ function makeLayer(peerStatus: { current: PeerStatus | "absent" }, options?: Mak
const landing = Layer.mock(AutomodeLanding)({
land_slice: () => Effect.succeed(options?.landResult ?? { status: "landed" }),
});
const mergeQueue = [...(options?.mergeResults ?? [])];
const heldPr = Layer.mock(AutomodeHeldPr)({
open_held_pr: () =>
Effect.sync(() => {
options?.onOpenHeldPr?.();
return (
options?.openResult ?? {
status: "opened",
url: "https://github.com/o/r/pull/30",
number: 30,
}
);
}),
detect_merge: () => Effect.succeed({ merged: mergeQueue.shift() ?? false }),
});
const config = ServerConfig.layerTest(process.cwd(), {
prefix: "gits-automode-driver-test-",
}).pipe(Layer.provide(NodeServices.layer));
Expand All @@ -151,6 +170,7 @@ function makeLayer(peerStatus: { current: PeerStatus | "absent" }, options?: Mak
Layer.provide(delamain),
Layer.provide(reviewPipeline),
Layer.provide(landing),
Layer.provide(heldPr),
);
}

Expand Down Expand Up @@ -385,4 +405,90 @@ describe("AutomodeDriver", () => {
assert.equal(snapshot.driverHalted, true);
}).pipe(Effect.provide(makeLayer(peerStatus)));
});

it.effect("opens a held PR when the queue drains with a landed goal", () => {
const peerStatus = { current: "absent" as PeerStatus | "absent" };
let openCalls = 0;
return Effect.gen(function* () {
const supervisor = yield* AutomodeSupervisor;
const driver = yield* AutomodeDriver;
yield* armAutonomous(supervisor);
yield* supervisor.enqueueGoal({ title: "One", repo: "/tmp/source-repo", prompt: "x" });
yield* driver.tickOnce(); // dispatch
peerStatus.current = "done";
yield* driver.tickOnce(); // gate → land → complete
peerStatus.current = "absent";
yield* driver.tickOnce(); // queue drained → open held PR

const snapshot = yield* supervisor.getSnapshot();
assert.equal(snapshot.heldPrNumber, 30);
assert.equal(openCalls, 1);
}).pipe(
Effect.provide(
makeLayer(peerStatus, {
onOpenHeldPr: () => {
openCalls += 1;
},
openResult: {
status: "opened",
url: "https://github.com/o/r/pull/30",
number: 30,
},
}),
),
);
});

it.effect("polls the held PR and marks the run merged when GitHub reports merged", () => {
const peerStatus = { current: "absent" as PeerStatus | "absent" };
return Effect.gen(function* () {
const supervisor = yield* AutomodeSupervisor;
const driver = yield* AutomodeDriver;
yield* armAutonomous(supervisor);
yield* supervisor.enqueueGoal({ title: "One", repo: "/tmp/source-repo", prompt: "x" });
yield* driver.tickOnce(); // dispatch
peerStatus.current = "done";
yield* driver.tickOnce(); // land + complete
peerStatus.current = "absent";
yield* driver.tickOnce(); // open held PR
yield* driver.tickOnce(); // poll → not merged
yield* driver.tickOnce(); // poll → merged

const snapshot = yield* supervisor.getSnapshot();
assert.equal(snapshot.runMerged, true);
}).pipe(
Effect.provide(
makeLayer(peerStatus, {
openResult: {
status: "opened",
url: "https://github.com/o/r/pull/30",
number: 30,
},
mergeResults: [false, true],
}),
),
);
});

it.effect("does not open a held PR when nothing landed (empty arm)", () => {
const peerStatus = { current: "absent" as PeerStatus | "absent" };
let openCalls = 0;
return Effect.gen(function* () {
const supervisor = yield* AutomodeSupervisor;
const driver = yield* AutomodeDriver;
yield* armAutonomous(supervisor); // no goals enqueued
yield* driver.tickOnce(); // drained, nothing landed
const snapshot = yield* supervisor.getSnapshot();
assert.equal(snapshot.heldPrUrl, null);
assert.equal(openCalls, 0);
}).pipe(
Effect.provide(
makeLayer(peerStatus, {
onOpenHeldPr: () => {
openCalls += 1;
},
}),
),
);
});
});
53 changes: 52 additions & 1 deletion apps/server/src/gits/Layers/AutomodeDriver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { AutomodeSupervisor } from "../Services/AutomodeSupervisor.ts";
import { AutomodeDriver, type AutomodeDriverShape } from "../Services/AutomodeDriver.ts";
import { GitsReviewPipeline } from "../Services/GitsReviewPipeline.ts";
import { AutomodeLanding } from "../Services/AutomodeLanding.ts";
import { AutomodeHeldPr } from "../Services/AutomodeHeldPr.ts";
import { decide_automode_gate } from "./AutomodeReviewGate.ts";

const TICK_INTERVAL_MS = (() => {
Expand Down Expand Up @@ -37,6 +38,7 @@ export const AutomodeDriverLive = Layer.effect(
const delamainAdapter = yield* DelamainAdapter;
const reviewPipeline = yield* GitsReviewPipeline;
const landing = yield* AutomodeLanding;
const heldPr = yield* AutomodeHeldPr;

const tickOnce: AutomodeDriverShape["tickOnce"] = () =>
Effect.gen(function* () {
Expand Down Expand Up @@ -162,9 +164,58 @@ export const AutomodeDriverLive = Layer.effect(
return;
}

// 3) Dispatch the oldest queued goal (sequential start).
// 3) Queue drained → held-PR lifecycle, then dispatch.
const next = oldestQueued(snapshot.goals);
if (next === null) {
// Run is terminal once the held PR merged.
if (snapshot.runMerged) {
return;
}
const policy = snapshot.policy;
if (policy.integrationBranch === null) {
return;
}
const landedRepo =
snapshot.goals.find((goal) => goal.status === "completed")?.repo ?? null;

if (snapshot.heldPrUrl === null || snapshot.heldPrNumber === null) {
// Open the held PR exactly once, only if at least one slice landed.
if (landedRepo === null) {
return;
}
const landedTitles = snapshot.goals
.filter((goal) => goal.status === "completed")
.map((goal) => `- ${goal.title}`)
.join("\n");
const result = yield* heldPr.open_held_pr({
repo: landedRepo,
integrationBranch: policy.integrationBranch,
baseBranch: "gits",
title: `automode: held PR for ${policy.integrationBranch}`,
body: `Autonomous run — landed slices (held for review, not auto-merged):\n\n${landedTitles}`,
});
if (result.status === "rejected") {
yield* supervisor.haltDriver({
reason: `Halted: could not open held PR — ${result.reason}`,
});
return;
}
yield* supervisor.recordHeldPr({ url: result.url, number: result.number });
return;
}

// Held PR already open → poll GitHub for the merge.
if (landedRepo === null) {
return;
}
const detect = yield* heldPr.detect_merge({
repo: landedRepo,
prNumber: snapshot.heldPrNumber,
});
if (detect.merged) {
yield* supervisor.markRunMerged();
yield* Effect.logInfo("gits.automode.run-merged", { heldPrUrl: snapshot.heldPrUrl });
}
return;
}
const result = yield* supervisor.dispatchGoal({ goalId: next.id });
Expand Down
100 changes: 100 additions & 0 deletions apps/server/src/gits/Layers/AutomodeHeldPr.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { assert, describe, it } from "@effect/vitest";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import { GitHubCli, type GitHubPullRequestSummary } from "../../sourceControl/GitHubCli.ts";
import { AutomodeHeldPr } from "../Services/AutomodeHeldPr.ts";
import { AutomodeHeldPrLive } from "./AutomodeHeldPr.ts";

const pr: GitHubPullRequestSummary = {
number: 30,
title: "t",
url: "https://github.com/o/r/pull/30",
baseRefName: "gits",
headRefName: "auto/gits-self",
state: "open",
};

function gh(overrides: {
list?: ReadonlyArray<GitHubPullRequestSummary>;
created?: ReadonlyArray<GitHubPullRequestSummary>;
get?: GitHubPullRequestSummary;
}) {
let createCalls = 0;
return Layer.mock(GitHubCli)({
execute: () =>
Effect.sync(() => {
createCalls += 1;
return {
stdout: "",
stderr: "",
exitCode: 0,
stdoutTruncated: false,
stderrTruncated: false,
} as never;
}),
listOpenPullRequests: () =>
Effect.succeed(createCalls === 0 ? (overrides.list ?? []) : (overrides.created ?? [])),
getPullRequest: () => Effect.succeed(overrides.get ?? { ...pr }),
});
}

const openInput = {
repo: "/tmp/repo",
integrationBranch: "auto/gits-self",
baseBranch: "gits",
title: "automode held PR",
body: "landed slices",
};

describe("AutomodeHeldPrLive", () => {
it.effect("returns the existing open PR without creating a duplicate", () =>
Effect.gen(function* () {
const held = yield* AutomodeHeldPr;
const result = yield* held.open_held_pr(openInput);
assert.equal(result.status, "opened");
if (result.status === "opened") {
assert.equal(result.number, 30);
}
}).pipe(Effect.provide(AutomodeHeldPrLive.pipe(Layer.provide(gh({ list: [pr] }))))),
);

it.effect("creates then looks up the PR when none exists yet", () =>
Effect.gen(function* () {
const held = yield* AutomodeHeldPr;
const result = yield* held.open_held_pr(openInput);
assert.equal(result.status, "opened");
}).pipe(
Effect.provide(AutomodeHeldPrLive.pipe(Layer.provide(gh({ list: [], created: [pr] })))),
),
);

it.effect("rejects when the PR still cannot be found after create", () =>
Effect.gen(function* () {
const held = yield* AutomodeHeldPr;
const result = yield* held.open_held_pr(openInput);
assert.equal(result.status, "rejected");
}).pipe(Effect.provide(AutomodeHeldPrLive.pipe(Layer.provide(gh({ list: [], created: [] }))))),
);

it.effect("detect_merge true when PR state is merged", () =>
Effect.gen(function* () {
const held = yield* AutomodeHeldPr;
const result = yield* held.detect_merge({ repo: "/tmp/repo", prNumber: 30 });
assert.equal(result.merged, true);
}).pipe(
Effect.provide(
AutomodeHeldPrLive.pipe(Layer.provide(gh({ get: { ...pr, state: "merged" } }))),
),
),
);

it.effect("detect_merge false when PR state is open", () =>
Effect.gen(function* () {
const held = yield* AutomodeHeldPr;
const result = yield* held.detect_merge({ repo: "/tmp/repo", prNumber: 30 });
assert.equal(result.merged, false);
}).pipe(
Effect.provide(AutomodeHeldPrLive.pipe(Layer.provide(gh({ get: { ...pr, state: "open" } })))),
),
);
});
70 changes: 70 additions & 0 deletions apps/server/src/gits/Layers/AutomodeHeldPr.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import { AutomodeSupervisorError } from "@t3tools/contracts";
import { GitHubCli } from "../../sourceControl/GitHubCli.ts";
import { AutomodeHeldPr, type AutomodeHeldPrShape } from "../Services/AutomodeHeldPr.ts";

export const AutomodeHeldPrLive = Layer.effect(
AutomodeHeldPr,
Effect.gen(function* () {
const gh = yield* GitHubCli;

const findForHead = (repo: string, head: string, base: string) =>
gh.listOpenPullRequests({ cwd: repo, headSelector: head }).pipe(
Effect.map((prs) => prs.find((candidate) => candidate.baseRefName === base) ?? null),
Effect.mapError(
(cause) => new AutomodeSupervisorError({ message: "gh pr list failed.", cause }),
),
);

const open_held_pr: AutomodeHeldPrShape["open_held_pr"] = (input) =>
Effect.gen(function* () {
// Idempotent: if a held PR for this head already exists, return it.
const existing = yield* findForHead(input.repo, input.integrationBranch, input.baseBranch);
if (existing !== null) {
return { status: "opened" as const, url: existing.url, number: existing.number };
}

yield* gh
.execute({
cwd: input.repo,
args: [
"pr",
"create",
"--base",
input.baseBranch,
"--head",
input.integrationBranch,
"--title",
input.title,
"--body",
input.body,
],
})
.pipe(
Effect.mapError(
(cause) => new AutomodeSupervisorError({ message: "gh pr create failed.", cause }),
),
);

const created = yield* findForHead(input.repo, input.integrationBranch, input.baseBranch);
if (created === null) {
return {
status: "rejected" as const,
reason: `Held PR for ${input.integrationBranch} could not be found after creation.`,
};
}
return { status: "opened" as const, url: created.url, number: created.number };
});

const detect_merge: AutomodeHeldPrShape["detect_merge"] = (input) =>
gh.getPullRequest({ cwd: input.repo, reference: String(input.prNumber) }).pipe(
Effect.map((pr) => ({ merged: pr.state === "merged" })),
Effect.mapError(
(cause) => new AutomodeSupervisorError({ message: "gh pr view failed.", cause }),
),
);

return { open_held_pr, detect_merge } satisfies AutomodeHeldPrShape;
}),
);
Loading
Loading