Skip to content
2 changes: 1 addition & 1 deletion apps/server/src/git/Layers/GitCore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -393,7 +393,7 @@ const makeGitCore = Effect.gen(function* () {
hasUpstream: details.hasUpstream,
aheadCount: details.aheadCount,
behindCount: details.behindCount,
openPr: null,
pr: null,
})),
);

Expand Down
94 changes: 90 additions & 4 deletions apps/server/src/git/Layers/GitManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -297,7 +297,7 @@ function makeManager(input?: {
const GitManagerTestLayer = Layer.provideMerge(GitServiceLive, NodeServices.layer);

it.layer(GitManagerTestLayer)("GitManager", (it) => {
it.effect("status includes open PR metadata when branch already has an open PR", () =>
it.effect("status includes PR metadata when branch already has an open PR", () =>
Effect.gen(function* () {
const repoDir = yield* makeTempDir("t3code-git-manager-");
yield* initRepo(repoDir);
Expand All @@ -324,17 +324,103 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {

const status = yield* manager.status({ cwd: repoDir });
expect(status.branch).toBe("feature/status-open-pr");
expect(status.openPr).toEqual({
expect(status.pr).toEqual({
number: 13,
title: "Existing PR",
url: "https://github.com/pingdotgg/codething-mvp/pull/13",
baseBranch: "main",
headBranch: "feature/status-open-pr",
state: "open",
});
}),
);

it.effect("status is resilient to gh lookup failures and returns openPr null", () =>
it.effect("status returns merged PR state when latest PR was merged", () =>
Effect.gen(function* () {
const repoDir = yield* makeTempDir("t3code-git-manager-");
yield* initRepo(repoDir);
yield* runGit(repoDir, ["checkout", "-b", "feature/status-merged-pr"]);

const { manager } = yield* makeManager({
ghScenario: {
prListSequence: [
JSON.stringify([
{
number: 22,
title: "Merged PR",
url: "https://github.com/pingdotgg/codething-mvp/pull/22",
baseRefName: "main",
headRefName: "feature/status-merged-pr",
state: "MERGED",
mergedAt: "2026-01-30T10:00:00Z",
updatedAt: "2026-01-30T10:00:00Z",
},
]),
],
},
});

const status = yield* manager.status({ cwd: repoDir });
expect(status.branch).toBe("feature/status-merged-pr");
expect(status.pr).toEqual({
number: 22,
title: "Merged PR",
url: "https://github.com/pingdotgg/codething-mvp/pull/22",
baseBranch: "main",
headBranch: "feature/status-merged-pr",
state: "merged",
});
}),
);

it.effect("status prefers open PR when merged PR has newer updatedAt", () =>
Effect.gen(function* () {
const repoDir = yield* makeTempDir("t3code-git-manager-");
yield* initRepo(repoDir);
yield* runGit(repoDir, ["checkout", "-b", "feature/status-open-over-merged"]);

const { manager } = yield* makeManager({
ghScenario: {
prListSequence: [
JSON.stringify([
{
number: 45,
title: "Merged PR",
url: "https://github.com/pingdotgg/codething-mvp/pull/45",
baseRefName: "main",
headRefName: "feature/status-open-over-merged",
state: "MERGED",
mergedAt: "2026-01-31T10:00:00Z",
updatedAt: "2026-02-01T10:00:00Z",
},
{
number: 46,
title: "Open PR",
url: "https://github.com/pingdotgg/codething-mvp/pull/46",
baseRefName: "main",
headRefName: "feature/status-open-over-merged",
state: "OPEN",
updatedAt: "2026-01-30T10:00:00Z",
},
]),
],
},
});

const status = yield* manager.status({ cwd: repoDir });
expect(status.branch).toBe("feature/status-open-over-merged");
expect(status.pr).toEqual({
number: 46,
title: "Open PR",
url: "https://github.com/pingdotgg/codething-mvp/pull/46",
baseBranch: "main",
headBranch: "feature/status-open-over-merged",
state: "open",
});
}),
);

it.effect("status is resilient to gh lookup failures and returns pr null", () =>
Effect.gen(function* () {
const repoDir = yield* makeTempDir("t3code-git-manager-");
yield* initRepo(repoDir);
Expand All @@ -354,7 +440,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {

const status = yield* manager.status({ cwd: repoDir });
expect(status.branch).toBe("feature/status-no-gh");
expect(status.openPr).toBeNull();
expect(status.pr).toBeNull();
}),
);

Expand Down
118 changes: 111 additions & 7 deletions apps/server/src/git/Layers/GitManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,62 @@ interface OpenPrInfo {
headRefName: string;
}

interface PullRequestInfo extends OpenPrInfo {
state: "open" | "closed" | "merged";
updatedAt: string | null;
}

function parsePullRequestList(raw: unknown): PullRequestInfo[] {
if (!Array.isArray(raw)) return [];

const parsed: PullRequestInfo[] = [];
for (const entry of raw) {
if (!entry || typeof entry !== "object") continue;
const record = entry as Record<string, unknown>;
const number = record.number;
const title = record.title;
const url = record.url;
const baseRefName = record.baseRefName;
const headRefName = record.headRefName;
const state = record.state;
const mergedAt = record.mergedAt;
const updatedAt = record.updatedAt;
if (typeof number !== "number" || !Number.isInteger(number) || number <= 0) {
continue;
}
if (
typeof title !== "string" ||
typeof url !== "string" ||
typeof baseRefName !== "string" ||
typeof headRefName !== "string"
) {
continue;
}

let normalizedState: "open" | "closed" | "merged";
if ((typeof mergedAt === "string" && mergedAt.trim().length > 0) || state === "MERGED") {
normalizedState = "merged";
} else if (state === "OPEN" || state === undefined || state === null) {
normalizedState = "open";
} else if (state === "CLOSED") {
normalizedState = "closed";
} else {
continue;
}

parsed.push({
number,
title,
url,
baseRefName,
headRefName,
state: normalizedState,
updatedAt: typeof updatedAt === "string" && updatedAt.trim().length > 0 ? updatedAt : null,
});
}
return parsed;
}

function gitManagerError(operation: string, detail: string, cause?: unknown): GitManagerError {
return new GitManagerError({
operation,
Expand Down Expand Up @@ -79,19 +135,21 @@ function extractBranchFromRef(ref: string): string {
return normalized.slice(firstSlash + 1).trim();
}

function toStatusOpenPr(pr: OpenPrInfo): {
function toStatusPr(pr: PullRequestInfo): {
number: number;
title: string;
url: string;
baseBranch: string;
headBranch: string;
state: "open" | "closed" | "merged";
} {
return {
number: pr.number,
title: pr.title,
url: pr.url,
baseBranch: pr.baseRefName,
headBranch: pr.headRefName,
state: pr.state,
};
}

Expand Down Expand Up @@ -123,10 +181,56 @@ export const makeGitManager = Effect.gen(function* () {
url: first.url,
baseRefName: first.baseRefName,
headRefName: first.headRefName,
} satisfies OpenPrInfo;
state: "open",
updatedAt: null,
} satisfies PullRequestInfo;
}),
);

const findLatestPr = (cwd: string, branch: string) =>
Effect.gen(function* () {
const stdout = yield* gitHubCli
.execute({
cwd,
args: [
"pr",
"list",
"--head",
branch,
"--state",
"all",
"--limit",
"20",
"--json",
"number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt",
],
})
.pipe(Effect.map((result) => result.stdout));

const raw = stdout.trim();
if (raw.length === 0) {
return null;
}

const parsedJson = yield* Effect.try({
try: () => JSON.parse(raw) as unknown,
catch: (cause) =>
gitManagerError("findLatestPr", "GitHub CLI returned invalid PR list JSON.", cause),
});

const parsed = parsePullRequestList(parsedJson).toSorted((a, b) => {
const left = a.updatedAt ? Date.parse(a.updatedAt) : 0;
const right = b.updatedAt ? Date.parse(b.updatedAt) : 0;
return right - left;
});

const latestOpenPr = parsed.find((pr) => pr.state === "open");
if (latestOpenPr) {
return latestOpenPr;
}
return parsed[0] ?? null;
});

const resolveBaseBranch = (cwd: string, branch: string, upstreamRef: string | null) =>
Effect.gen(function* () {
const configured = yield* gitCore.readConfigValue(cwd, `branch.${branch}.gh-merge-base`);
Expand Down Expand Up @@ -258,10 +362,10 @@ export const makeGitManager = Effect.gen(function* () {
const status: GitManagerShape["status"] = Effect.fnUntraced(function* (input) {
const details = yield* gitCore.statusDetails(input.cwd);

const openPr =
details.branch && details.hasUpstream
? yield* findOpenPr(input.cwd, details.branch).pipe(
Effect.map((pr) => (pr ? toStatusOpenPr(pr) : null)),
const pr =
details.branch !== null
? yield* findLatestPr(input.cwd, details.branch).pipe(
Effect.map((latest) => (latest ? toStatusPr(latest) : null)),
Effect.catch(() => Effect.succeed(null)),
)
: null;
Expand All @@ -273,7 +377,7 @@ export const makeGitManager = Effect.gen(function* () {
hasUpstream: details.hasUpstream,
aheadCount: details.aheadCount,
behindCount: details.behindCount,
openPr,
pr,
};
});

Expand Down
2 changes: 1 addition & 1 deletion apps/server/src/git/Services/GitCore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import type {

import type { GitCommandError } from "../Errors.ts";

export interface GitStatusDetails extends Omit<GitStatusResult, "openPr"> {
export interface GitStatusDetails extends Omit<GitStatusResult, "pr"> {
upstreamRef: string | null;
}

Expand Down
2 changes: 1 addition & 1 deletion apps/server/src/wsServer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1521,7 +1521,7 @@ describe("WebSocket Server", () => {
hasUpstream: false,
aheadCount: 0,
behindCount: 0,
openPr: null,
pr: null,
};

const status = vi.fn(() => Effect.succeed(statusResult));
Expand Down
Loading