authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-15 19:46:24-08:00
committergravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-15 19:46:24-08:00
log973e62373e5b442ac9c13e0e65f771132c5e66d4
tree25c2ce7bbf57f53550cac5984f411d2a51d06701
parentdcdb9ac92320d62028fd2d12be26167406666920

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`

5 files changed, 67 insertions(+), 55 deletions(-)

apps/server/src/git.ts+2-3
...@@ -31,7 +31,7 @@ export interface TerminalCommandInput {...@@ -31,7 +31,7 @@ export interface TerminalCommandInput {
3131
32export type TerminalCommandResult = ProcessRunResult;32export type TerminalCommandResult = ProcessRunResult;
3333
34export interface GitStatusDetails extends Omit<GitStatusResult, "openPr" | "mergedPr"> {34export interface GitStatusDetails extends Omit<GitStatusResult, "pr"> {
35 upstreamRef: string | null;35 upstreamRef: string | null;
36}36}
3737
...@@ -226,8 +226,7 @@ export class GitCoreService {...@@ -226,8 +226,7 @@ export class GitCoreService {
226 hasUpstream: details.hasUpstream,226 hasUpstream: details.hasUpstream,
227 aheadCount: details.aheadCount,227 aheadCount: details.aheadCount,
228 behindCount: details.behindCount,228 behindCount: details.behindCount,
229 openPr: null,229 pr: null,
230 mergedPr: null,
231 });230 });
232 }231 }
233232
apps/server/src/gitManager.ts+48-28
...@@ -7,6 +7,7 @@ import {...@@ -7,6 +7,7 @@ import {
7 gitRunStackedActionInputSchema,7 gitRunStackedActionInputSchema,
8 gitRunStackedActionResultSchema,8 gitRunStackedActionResultSchema,
9 gitStatusInputSchema,9 gitStatusInputSchema,
10 type GitStatusPrState,
10 type GitRunStackedActionInput,11 type GitRunStackedActionInput,
11 type GitRunStackedActionResult,12 type GitRunStackedActionResult,
12 type GitStatusInput,13 type GitStatusInput,
...@@ -34,18 +35,20 @@ interface GitManagerDeps {...@@ -34,18 +35,20 @@ interface GitManagerDeps {
34 gitCore?: GitCoreService;35 gitCore?: GitCoreService;
35}36}
3637
37interface OpenPrInfo {38interface PullRequestInfo {
38 number: number;39 number: number;
39 title: string;40 title: string;
40 url: string;41 url: string;
41 baseRefName: string;42 baseRefName: string;
42 headRefName: string;43 headRefName: string;
44 state: GitStatusPrState;
45 updatedAt: string | null;
43}46}
4447
45function parseOpenPrList(raw: unknown): OpenPrInfo[] {48function parsePullRequestList(raw: unknown): PullRequestInfo[] {
46 if (!Array.isArray(raw)) return [];49 if (!Array.isArray(raw)) return [];
4750
48 const parsed: OpenPrInfo[] = [];51 const parsed: PullRequestInfo[] = [];
49 for (const entry of raw) {52 for (const entry of raw) {
50 if (!entry || typeof entry !== "object") continue;53 if (!entry || typeof entry !== "object") continue;
51 const record = entry as Record<string, unknown>;54 const record = entry as Record<string, unknown>;
...@@ -54,6 +57,9 @@ function parseOpenPrList(raw: unknown): OpenPrInfo[] {...@@ -54,6 +57,9 @@ function parseOpenPrList(raw: unknown): OpenPrInfo[] {
54 const url = record.url;57 const url = record.url;
55 const baseRefName = record.baseRefName;58 const baseRefName = record.baseRefName;
56 const headRefName = record.headRefName;59 const headRefName = record.headRefName;
60 const state = record.state;
61 const mergedAt = record.mergedAt;
62 const updatedAt = record.updatedAt;
57 if (typeof number !== "number" || !Number.isInteger(number) || number <= 0) {63 if (typeof number !== "number" || !Number.isInteger(number) || number <= 0) {
58 continue;64 continue;
59 }65 }
...@@ -61,16 +67,34 @@ function parseOpenPrList(raw: unknown): OpenPrInfo[] {...@@ -61,16 +67,34 @@ function parseOpenPrList(raw: unknown): OpenPrInfo[] {
61 typeof title !== "string" ||67 typeof title !== "string" ||
62 typeof url !== "string" ||68 typeof url !== "string" ||
63 typeof baseRefName !== "string" ||69 typeof baseRefName !== "string" ||
64 typeof headRefName !== "string"70 typeof headRefName !== "string" ||
71 typeof state !== "string"
65 ) {72 ) {
66 continue;73 continue;
67 }74 }
75
76 let normalizedState: GitStatusPrState;
77 if ((typeof mergedAt === "string" && mergedAt.trim().length > 0) || state === "MERGED") {
78 normalizedState = "merged";
79 } else if (state === "OPEN") {
80 normalizedState = "open";
81 } else if (state === "CLOSED") {
82 normalizedState = "closed";
83 } else {
84 continue;
85 }
86
87 const normalizedUpdatedAt =
88 typeof updatedAt === "string" && updatedAt.trim().length > 0 ? updatedAt : null;
89
68 parsed.push({90 parsed.push({
69 number,91 number,
70 title,92 title,
71 url,93 url,
72 baseRefName,94 baseRefName,
73 headRefName,95 headRefName,
96 state: normalizedState,
97 updatedAt: normalizedUpdatedAt,
74 });98 });
75 }99 }
76 return parsed;100 return parsed;
...@@ -160,12 +184,13 @@ function normalizeGitHubAuthError(error: unknown): Error | undefined {...@@ -160,12 +184,13 @@ function normalizeGitHubAuthError(error: unknown): Error | undefined {
160 return undefined;184 return undefined;
161}185}
162186
163function toStatusOpenPr(pr: OpenPrInfo): {187function toStatusPr(pr: PullRequestInfo): {
164 number: number;188 number: number;
165 title: string;189 title: string;
166 url: string;190 url: string;
167 baseBranch: string;191 baseBranch: string;
168 headBranch: string;192 headBranch: string;
193 state: GitStatusPrState;
169} {194} {
170 return {195 return {
171 number: pr.number,196 number: pr.number,
...@@ -173,6 +198,7 @@ function toStatusOpenPr(pr: OpenPrInfo): {...@@ -173,6 +198,7 @@ function toStatusOpenPr(pr: OpenPrInfo): {
173 url: pr.url,198 url: pr.url,
174 baseBranch: pr.baseRefName,199 baseBranch: pr.baseRefName,
175 headBranch: pr.headRefName,200 headBranch: pr.headRefName,
201 state: pr.state,
176 };202 };
177}203}
178204
...@@ -191,21 +217,12 @@ export class GitManager {...@@ -191,21 +217,12 @@ export class GitManager {
191 const input = gitStatusInputSchema.parse(raw);217 const input = gitStatusInputSchema.parse(raw);
192 const details = await this.gitCore.statusDetails(input.cwd);218 const details = await this.gitCore.statusDetails(input.cwd);
193219
194 let openPr: ReturnType<typeof toStatusOpenPr> | null = null;220 let pr: ReturnType<typeof toStatusPr> | null = null;
195 let mergedPr: ReturnType<typeof toStatusOpenPr> | null = null;
196 if (details.branch) {221 if (details.branch) {
197 try {222 try {
198 if (details.hasUpstream) {223 const existing = await this.findLatestPr(input.cwd, details.branch);
199 const existing = await this.findOpenPr(input.cwd, details.branch);224 if (existing) {
200 if (existing) {225 pr = toStatusPr(existing);
201 openPr = toStatusOpenPr(existing);
202 }
203 }
204 if (!openPr) {
205 const merged = await this.findMergedPr(input.cwd, details.branch);
206 if (merged) {
207 mergedPr = toStatusOpenPr(merged);
208 }
209 }226 }
210 } catch {227 } catch {
211 // PR lookup is best-effort for status rendering.228 // PR lookup is best-effort for status rendering.
...@@ -219,8 +236,7 @@ export class GitManager {...@@ -219,8 +236,7 @@ export class GitManager {
219 hasUpstream: details.hasUpstream,236 hasUpstream: details.hasUpstream,
220 aheadCount: details.aheadCount,237 aheadCount: details.aheadCount,
221 behindCount: details.behindCount,238 behindCount: details.behindCount,
222 openPr,239 pr,
223 mergedPr,
224 };240 };
225 }241 }
226242
...@@ -390,7 +406,7 @@ export class GitManager {...@@ -390,7 +406,7 @@ export class GitManager {
390 };406 };
391 }407 }
392408
393 private async findOpenPr(cwd: string, branch: string): Promise<OpenPrInfo | null> {409 private async findOpenPr(cwd: string, branch: string): Promise<PullRequestInfo | null> {
394 const stdout = await this.runGhStdout(cwd, [410 const stdout = await this.runGhStdout(cwd, [
395 "pr",411 "pr",
396 "list",412 "list",
...@@ -401,7 +417,7 @@ export class GitManager {...@@ -401,7 +417,7 @@ export class GitManager {
401 "--limit",417 "--limit",
402 "1",418 "1",
403 "--json",419 "--json",
404 "number,title,url,baseRefName,headRefName",420 "number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt",
405 ]);421 ]);
406422
407 const raw = trimStdout(stdout);423 const raw = trimStdout(stdout);
...@@ -414,22 +430,22 @@ export class GitManager {...@@ -414,22 +430,22 @@ export class GitManager {
414 throw new Error("GitHub CLI returned invalid PR list JSON.");430 throw new Error("GitHub CLI returned invalid PR list JSON.");
415 }431 }
416432
417 const parsed = parseOpenPrList(parsedJson);433 const parsed = parsePullRequestList(parsedJson);
418 return parsed[0] ?? null;434 return parsed[0] ?? null;
419 }435 }
420436
421 private async findMergedPr(cwd: string, branch: string): Promise<OpenPrInfo | null> {437 private async findLatestPr(cwd: string, branch: string): Promise<PullRequestInfo | null> {
422 const stdout = await this.runGhStdout(cwd, [438 const stdout = await this.runGhStdout(cwd, [
423 "pr",439 "pr",
424 "list",440 "list",
425 "--head",441 "--head",
426 branch,442 branch,
427 "--state",443 "--state",
428 "merged",444 "all",
429 "--limit",445 "--limit",
430 "1",446 "20",
431 "--json",447 "--json",
432 "number,title,url,baseRefName,headRefName",448 "number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt",
433 ]);449 ]);
434450
435 const raw = trimStdout(stdout);451 const raw = trimStdout(stdout);
...@@ -442,7 +458,11 @@ export class GitManager {...@@ -442,7 +458,11 @@ export class GitManager {
442 throw new Error("GitHub CLI returned invalid PR list JSON.");458 throw new Error("GitHub CLI returned invalid PR list JSON.");
443 }459 }
444460
445 const parsed = parseOpenPrList(parsedJson);461 const parsed = parsePullRequestList(parsedJson).toSorted((a, b) => {
462 const left = a.updatedAt ? Date.parse(a.updatedAt) : 0;
463 const right = b.updatedAt ? Date.parse(b.updatedAt) : 0;
464 return right - left;
465 });
446 return parsed[0] ?? null;466 return parsed[0] ?? null;
447 }467 }
448468
apps/server/src/wsServer.test.ts+2-4
...@@ -563,8 +563,7 @@ describe("WebSocket Server", () => {...@@ -563,8 +563,7 @@ describe("WebSocket Server", () => {
563 hasUpstream: false,563 hasUpstream: false,
564 aheadCount: 0,564 aheadCount: 0,
565 behindCount: 0,565 behindCount: 0,
566 openPr: null,566 pr: null,
567 mergedPr: null,
568 }),567 }),
569 runStackedAction: vi.fn(),568 runStackedAction: vi.fn(),
570 };569 };
...@@ -593,8 +592,7 @@ describe("WebSocket Server", () => {...@@ -593,8 +592,7 @@ describe("WebSocket Server", () => {
593 hasUpstream: false,592 hasUpstream: false,
594 aheadCount: 0,593 aheadCount: 0,
595 behindCount: 0,594 behindCount: 0,
596 openPr: null,595 pr: null,
597 mergedPr: null,
598 });596 });
599 expect(gitManager.status).toHaveBeenCalledWith({ cwd: "/test" });597 expect(gitManager.status).toHaveBeenCalledWith({ cwd: "/test" });
600 });598 });
packages/contracts/src/git.test.ts+1-2
...@@ -36,8 +36,7 @@ describe("git contracts", () => {...@@ -36,8 +36,7 @@ describe("git contracts", () => {
36 hasUpstream: false,36 hasUpstream: false,
37 aheadCount: 0,37 aheadCount: 0,
38 behindCount: 0,38 behindCount: 0,
39 openPr: null,39 pr: null,
40 mergedPr: null,
41 });40 });
42 expect(parsed.branch).toBe("feature/git-actions");41 expect(parsed.branch).toBe("feature/git-actions");
43 expect(parsed.hasWorkingTreeChanges).toBe(true);42 expect(parsed.hasWorkingTreeChanges).toBe(true);
packages/contracts/src/git.ts+14-18
...@@ -72,6 +72,17 @@ export const gitPullInputSchema = z.object({...@@ -72,6 +72,17 @@ export const gitPullInputSchema = z.object({
72 cwd: z.string().trim().min(1),72 cwd: z.string().trim().min(1),
73});73});
7474
75export const gitStatusPrStateSchema = z.enum(["open", "closed", "merged"]);
76
77export const gitStatusPrSchema = z.object({
78 number: z.number().int().positive(),
79 title: z.string().min(1),
80 url: z.string().url(),
81 baseBranch: z.string().min(1),
82 headBranch: z.string().min(1),
83 state: gitStatusPrStateSchema,
84});
85
75export const gitStatusResultSchema = z.object({86export const gitStatusResultSchema = z.object({
76 branch: z.string().min(1).nullable(),87 branch: z.string().min(1).nullable(),
77 hasWorkingTreeChanges: z.boolean(),88 hasWorkingTreeChanges: z.boolean(),
...@@ -89,24 +100,7 @@ export const gitStatusResultSchema = z.object({...@@ -89,24 +100,7 @@ export const gitStatusResultSchema = z.object({
89 hasUpstream: z.boolean(),100 hasUpstream: z.boolean(),
90 aheadCount: z.number().int().nonnegative(),101 aheadCount: z.number().int().nonnegative(),
91 behindCount: z.number().int().nonnegative(),102 behindCount: z.number().int().nonnegative(),
92 openPr: z103 pr: gitStatusPrSchema.nullable(),
93 .object({
94 number: z.number().int().positive(),
95 title: z.string().min(1),
96 url: z.string().url(),
97 baseBranch: z.string().min(1),
98 headBranch: z.string().min(1),
99 })
100 .nullable(),
101 mergedPr: z
102 .object({
103 number: z.number().int().positive(),
104 title: z.string().min(1),
105 url: z.string().url(),
106 baseBranch: z.string().min(1),
107 headBranch: z.string().min(1),
108 })
109 .nullable(),
110});104});
111105
112export const gitStackedActionSchema = z.enum(["commit", "commit_push", "commit_push_pr"]);106export const gitStackedActionSchema = z.enum(["commit", "commit_push", "commit_push_pr"]);
...@@ -146,6 +140,8 @@ export const gitRunStackedActionResultSchema = z.object({...@@ -146,6 +140,8 @@ export const gitRunStackedActionResultSchema = z.object({
146140
147export type GitStatusInput = z.input<typeof gitStatusInputSchema>;141export type GitStatusInput = z.input<typeof gitStatusInputSchema>;
148export type GitPullInput = z.input<typeof gitPullInputSchema>;142export type GitPullInput = z.input<typeof gitPullInputSchema>;
143export type GitStatusPrState = z.infer<typeof gitStatusPrStateSchema>;
144export type GitStatusPr = z.infer<typeof gitStatusPrSchema>;
149export type GitStatusResult = z.infer<typeof gitStatusResultSchema>;145export type GitStatusResult = z.infer<typeof gitStatusResultSchema>;
150export type GitStackedAction = z.infer<typeof gitStackedActionSchema>;146export type GitStackedAction = z.infer<typeof gitStackedActionSchema>;
151export type GitRunStackedActionInput = z.input<typeof gitRunStackedActionInputSchema>;147export type GitRunStackedActionInput = z.input<typeof gitRunStackedActionInputSchema>;