From 973e62373e5b442ac9c13e0e65f771132c5e66d4 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 15 Feb 2026 19:46:24 -0800 Subject: [PATCH] Unify git status PR info into a single `pr` field - Replace `openPr`/`mergedPr` with `pr` in server and contracts - Add PR `state` (`open`/`closed`/`merged`) to git status schema - Resolve latest branch PR via `gh pr list --state all` sorted by `updatedAt` --- apps/server/src/git.ts | 5 +- apps/server/src/gitManager.ts | 76 +++++++++++++++++++----------- apps/server/src/wsServer.test.ts | 6 +-- packages/contracts/src/git.test.ts | 3 +- packages/contracts/src/git.ts | 32 ++++++------- 5 files changed, 67 insertions(+), 55 deletions(-) diff --git a/apps/server/src/git.ts b/apps/server/src/git.ts index cad9cad4c344f081b3f8cd8be5bba4f2e034e486..7c2da5ccf5a306c89f327089ae344b3d6cca82f8 100644 --- a/apps/server/src/git.ts +++ b/apps/server/src/git.ts @@ -31,7 +31,7 @@ export interface TerminalCommandInput { export type TerminalCommandResult = ProcessRunResult; -export interface GitStatusDetails extends Omit { +export interface GitStatusDetails extends Omit { upstreamRef: string | null; } @@ -226,8 +226,7 @@ export class GitCoreService { hasUpstream: details.hasUpstream, aheadCount: details.aheadCount, behindCount: details.behindCount, - openPr: null, - mergedPr: null, + pr: null, }); } diff --git a/apps/server/src/gitManager.ts b/apps/server/src/gitManager.ts index c8668d1cb8f111c48601c7d73a87d043e650a514..d1ca7b0b57f4f99506811a960bfe65f2892254f4 100644 --- a/apps/server/src/gitManager.ts +++ b/apps/server/src/gitManager.ts @@ -7,6 +7,7 @@ import { gitRunStackedActionInputSchema, gitRunStackedActionResultSchema, gitStatusInputSchema, + type GitStatusPrState, type GitRunStackedActionInput, type GitRunStackedActionResult, type GitStatusInput, @@ -34,18 +35,20 @@ interface GitManagerDeps { gitCore?: GitCoreService; } -interface OpenPrInfo { +interface PullRequestInfo { number: number; title: string; url: string; baseRefName: string; headRefName: string; + state: GitStatusPrState; + updatedAt: string | null; } -function parseOpenPrList(raw: unknown): OpenPrInfo[] { +function parsePullRequestList(raw: unknown): PullRequestInfo[] { if (!Array.isArray(raw)) return []; - const parsed: OpenPrInfo[] = []; + const parsed: PullRequestInfo[] = []; for (const entry of raw) { if (!entry || typeof entry !== "object") continue; const record = entry as Record; @@ -54,6 +57,9 @@ function parseOpenPrList(raw: unknown): OpenPrInfo[] { 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; } @@ -61,16 +67,34 @@ function parseOpenPrList(raw: unknown): OpenPrInfo[] { typeof title !== "string" || typeof url !== "string" || typeof baseRefName !== "string" || - typeof headRefName !== "string" + typeof headRefName !== "string" || + typeof state !== "string" ) { continue; } + + let normalizedState: GitStatusPrState; + if ((typeof mergedAt === "string" && mergedAt.trim().length > 0) || state === "MERGED") { + normalizedState = "merged"; + } else if (state === "OPEN") { + normalizedState = "open"; + } else if (state === "CLOSED") { + normalizedState = "closed"; + } else { + continue; + } + + const normalizedUpdatedAt = + typeof updatedAt === "string" && updatedAt.trim().length > 0 ? updatedAt : null; + parsed.push({ number, title, url, baseRefName, headRefName, + state: normalizedState, + updatedAt: normalizedUpdatedAt, }); } return parsed; @@ -160,12 +184,13 @@ function normalizeGitHubAuthError(error: unknown): Error | undefined { return undefined; } -function toStatusOpenPr(pr: OpenPrInfo): { +function toStatusPr(pr: PullRequestInfo): { number: number; title: string; url: string; baseBranch: string; headBranch: string; + state: GitStatusPrState; } { return { number: pr.number, @@ -173,6 +198,7 @@ function toStatusOpenPr(pr: OpenPrInfo): { url: pr.url, baseBranch: pr.baseRefName, headBranch: pr.headRefName, + state: pr.state, }; } @@ -191,21 +217,12 @@ export class GitManager { const input = gitStatusInputSchema.parse(raw); const details = await this.gitCore.statusDetails(input.cwd); - let openPr: ReturnType | null = null; - let mergedPr: ReturnType | null = null; + let pr: ReturnType | null = null; if (details.branch) { try { - if (details.hasUpstream) { - const existing = await this.findOpenPr(input.cwd, details.branch); - if (existing) { - openPr = toStatusOpenPr(existing); - } - } - if (!openPr) { - const merged = await this.findMergedPr(input.cwd, details.branch); - if (merged) { - mergedPr = toStatusOpenPr(merged); - } + const existing = await this.findLatestPr(input.cwd, details.branch); + if (existing) { + pr = toStatusPr(existing); } } catch { // PR lookup is best-effort for status rendering. @@ -219,8 +236,7 @@ export class GitManager { hasUpstream: details.hasUpstream, aheadCount: details.aheadCount, behindCount: details.behindCount, - openPr, - mergedPr, + pr, }; } @@ -390,7 +406,7 @@ export class GitManager { }; } - private async findOpenPr(cwd: string, branch: string): Promise { + private async findOpenPr(cwd: string, branch: string): Promise { const stdout = await this.runGhStdout(cwd, [ "pr", "list", @@ -401,7 +417,7 @@ export class GitManager { "--limit", "1", "--json", - "number,title,url,baseRefName,headRefName", + "number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt", ]); const raw = trimStdout(stdout); @@ -414,22 +430,22 @@ export class GitManager { throw new Error("GitHub CLI returned invalid PR list JSON."); } - const parsed = parseOpenPrList(parsedJson); + const parsed = parsePullRequestList(parsedJson); return parsed[0] ?? null; } - private async findMergedPr(cwd: string, branch: string): Promise { + private async findLatestPr(cwd: string, branch: string): Promise { const stdout = await this.runGhStdout(cwd, [ "pr", "list", "--head", branch, "--state", - "merged", + "all", "--limit", - "1", + "20", "--json", - "number,title,url,baseRefName,headRefName", + "number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt", ]); const raw = trimStdout(stdout); @@ -442,7 +458,11 @@ export class GitManager { throw new Error("GitHub CLI returned invalid PR list JSON."); } - const parsed = parseOpenPrList(parsedJson); + 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; + }); return parsed[0] ?? null; } diff --git a/apps/server/src/wsServer.test.ts b/apps/server/src/wsServer.test.ts index 169a6b2bd4ec8ea3e33aeb37bdff90d87eacb948..d9bee192f7a5e8fb5d15faf21317e80b2d576c68 100644 --- a/apps/server/src/wsServer.test.ts +++ b/apps/server/src/wsServer.test.ts @@ -563,8 +563,7 @@ describe("WebSocket Server", () => { hasUpstream: false, aheadCount: 0, behindCount: 0, - openPr: null, - mergedPr: null, + pr: null, }), runStackedAction: vi.fn(), }; @@ -593,8 +592,7 @@ describe("WebSocket Server", () => { hasUpstream: false, aheadCount: 0, behindCount: 0, - openPr: null, - mergedPr: null, + pr: null, }); expect(gitManager.status).toHaveBeenCalledWith({ cwd: "/test" }); }); diff --git a/packages/contracts/src/git.test.ts b/packages/contracts/src/git.test.ts index 05b467c35833e4335b6a13a6566f88a48714383d..633702d432e706371294347d161ebc691fad954a 100644 --- a/packages/contracts/src/git.test.ts +++ b/packages/contracts/src/git.test.ts @@ -36,8 +36,7 @@ describe("git contracts", () => { hasUpstream: false, aheadCount: 0, behindCount: 0, - openPr: null, - mergedPr: null, + pr: null, }); expect(parsed.branch).toBe("feature/git-actions"); expect(parsed.hasWorkingTreeChanges).toBe(true); diff --git a/packages/contracts/src/git.ts b/packages/contracts/src/git.ts index 29fd8d8caa466aeda9750b5ad52aae997a101019..b2a2416e4b525f666f62d790ba9e42255d1eee4a 100644 --- a/packages/contracts/src/git.ts +++ b/packages/contracts/src/git.ts @@ -72,6 +72,17 @@ export const gitPullInputSchema = z.object({ cwd: z.string().trim().min(1), }); +export const gitStatusPrStateSchema = z.enum(["open", "closed", "merged"]); + +export const gitStatusPrSchema = z.object({ + number: z.number().int().positive(), + title: z.string().min(1), + url: z.string().url(), + baseBranch: z.string().min(1), + headBranch: z.string().min(1), + state: gitStatusPrStateSchema, +}); + export const gitStatusResultSchema = z.object({ branch: z.string().min(1).nullable(), hasWorkingTreeChanges: z.boolean(), @@ -89,24 +100,7 @@ export const gitStatusResultSchema = z.object({ hasUpstream: z.boolean(), aheadCount: z.number().int().nonnegative(), behindCount: z.number().int().nonnegative(), - openPr: z - .object({ - number: z.number().int().positive(), - title: z.string().min(1), - url: z.string().url(), - baseBranch: z.string().min(1), - headBranch: z.string().min(1), - }) - .nullable(), - mergedPr: z - .object({ - number: z.number().int().positive(), - title: z.string().min(1), - url: z.string().url(), - baseBranch: z.string().min(1), - headBranch: z.string().min(1), - }) - .nullable(), + pr: gitStatusPrSchema.nullable(), }); export const gitStackedActionSchema = z.enum(["commit", "commit_push", "commit_push_pr"]); @@ -146,6 +140,8 @@ export const gitRunStackedActionResultSchema = z.object({ export type GitStatusInput = z.input; export type GitPullInput = z.input; +export type GitStatusPrState = z.infer; +export type GitStatusPr = z.infer; export type GitStatusResult = z.infer; export type GitStackedAction = z.infer; export type GitRunStackedActionInput = z.input; -- 2.54.0