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 {
3131
3232export type TerminalCommandResult = ProcessRunResult;
3333
34export interface GitStatusDetails extends Omit<GitStatusResult, "openPr" | "mergedPr"> {
34export interface GitStatusDetails extends Omit<GitStatusResult, "pr"> {
3535 upstreamRef: string | null;
3636}
3737
......@@ -226,8 +226,7 @@ export class GitCoreService {
226226 hasUpstream: details.hasUpstream,
227227 aheadCount: details.aheadCount,
228228 behindCount: details.behindCount,
229 openPr: null,
230 mergedPr: null,
229 pr: null,
231230 });
232231 }
233232
apps/server/src/gitManager.ts+48-28
......@@ -7,6 +7,7 @@ import {
77 gitRunStackedActionInputSchema,
88 gitRunStackedActionResultSchema,
99 gitStatusInputSchema,
10 type GitStatusPrState,
1011 type GitRunStackedActionInput,
1112 type GitRunStackedActionResult,
1213 type GitStatusInput,
......@@ -34,18 +35,20 @@ interface GitManagerDeps {
3435 gitCore?: GitCoreService;
3536}
3637
37interface OpenPrInfo {
38interface PullRequestInfo {
3839 number: number;
3940 title: string;
4041 url: string;
4142 baseRefName: string;
4243 headRefName: string;
44 state: GitStatusPrState;
45 updatedAt: string | null;
4346}
4447
45function parseOpenPrList(raw: unknown): OpenPrInfo[] {
48function parsePullRequestList(raw: unknown): PullRequestInfo[] {
4649 if (!Array.isArray(raw)) return [];
4750
48 const parsed: OpenPrInfo[] = [];
51 const parsed: PullRequestInfo[] = [];
4952 for (const entry of raw) {
5053 if (!entry || typeof entry !== "object") continue;
5154 const record = entry as Record<string, unknown>;
......@@ -54,6 +57,9 @@ function parseOpenPrList(raw: unknown): OpenPrInfo[] {
5457 const url = record.url;
5558 const baseRefName = record.baseRefName;
5659 const headRefName = record.headRefName;
60 const state = record.state;
61 const mergedAt = record.mergedAt;
62 const updatedAt = record.updatedAt;
5763 if (typeof number !== "number" || !Number.isInteger(number) || number <= 0) {
5864 continue;
5965 }
......@@ -61,16 +67,34 @@ function parseOpenPrList(raw: unknown): OpenPrInfo[] {
6167 typeof title !== "string" ||
6268 typeof url !== "string" ||
6369 typeof baseRefName !== "string" ||
64 typeof headRefName !== "string"
70 typeof headRefName !== "string" ||
71 typeof state !== "string"
6572 ) {
6673 continue;
6774 }
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
6890 parsed.push({
6991 number,
7092 title,
7193 url,
7294 baseRefName,
7395 headRefName,
96 state: normalizedState,
97 updatedAt: normalizedUpdatedAt,
7498 });
7599 }
76100 return parsed;
......@@ -160,12 +184,13 @@ function normalizeGitHubAuthError(error: unknown): Error | undefined {
160184 return undefined;
161185}
162186
163function toStatusOpenPr(pr: OpenPrInfo): {
187function toStatusPr(pr: PullRequestInfo): {
164188 number: number;
165189 title: string;
166190 url: string;
167191 baseBranch: string;
168192 headBranch: string;
193 state: GitStatusPrState;
169194} {
170195 return {
171196 number: pr.number,
......@@ -173,6 +198,7 @@ function toStatusOpenPr(pr: OpenPrInfo): {
173198 url: pr.url,
174199 baseBranch: pr.baseRefName,
175200 headBranch: pr.headRefName,
201 state: pr.state,
176202 };
177203}
178204
......@@ -191,21 +217,12 @@ export class GitManager {
191217 const input = gitStatusInputSchema.parse(raw);
192218 const details = await this.gitCore.statusDetails(input.cwd);
193219
194 let openPr: ReturnType<typeof toStatusOpenPr> | null = null;
195 let mergedPr: ReturnType<typeof toStatusOpenPr> | null = null;
220 let pr: ReturnType<typeof toStatusPr> | null = null;
196221 if (details.branch) {
197222 try {
198 if (details.hasUpstream) {
199 const existing = await this.findOpenPr(input.cwd, details.branch);
200 if (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 }
223 const existing = await this.findLatestPr(input.cwd, details.branch);
224 if (existing) {
225 pr = toStatusPr(existing);
209226 }
210227 } catch {
211228 // PR lookup is best-effort for status rendering.
......@@ -219,8 +236,7 @@ export class GitManager {
219236 hasUpstream: details.hasUpstream,
220237 aheadCount: details.aheadCount,
221238 behindCount: details.behindCount,
222 openPr,
223 mergedPr,
239 pr,
224240 };
225241 }
226242
......@@ -390,7 +406,7 @@ export class GitManager {
390406 };
391407 }
392408
393 private async findOpenPr(cwd: string, branch: string): Promise<OpenPrInfo | null> {
409 private async findOpenPr(cwd: string, branch: string): Promise<PullRequestInfo | null> {
394410 const stdout = await this.runGhStdout(cwd, [
395411 "pr",
396412 "list",
......@@ -401,7 +417,7 @@ export class GitManager {
401417 "--limit",
402418 "1",
403419 "--json",
404 "number,title,url,baseRefName,headRefName",
420 "number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt",
405421 ]);
406422
407423 const raw = trimStdout(stdout);
......@@ -414,22 +430,22 @@ export class GitManager {
414430 throw new Error("GitHub CLI returned invalid PR list JSON.");
415431 }
416432
417 const parsed = parseOpenPrList(parsedJson);
433 const parsed = parsePullRequestList(parsedJson);
418434 return parsed[0] ?? null;
419435 }
420436
421 private async findMergedPr(cwd: string, branch: string): Promise<OpenPrInfo | null> {
437 private async findLatestPr(cwd: string, branch: string): Promise<PullRequestInfo | null> {
422438 const stdout = await this.runGhStdout(cwd, [
423439 "pr",
424440 "list",
425441 "--head",
426442 branch,
427443 "--state",
428 "merged",
444 "all",
429445 "--limit",
430 "1",
446 "20",
431447 "--json",
432 "number,title,url,baseRefName,headRefName",
448 "number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt",
433449 ]);
434450
435451 const raw = trimStdout(stdout);
......@@ -442,7 +458,11 @@ export class GitManager {
442458 throw new Error("GitHub CLI returned invalid PR list JSON.");
443459 }
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 });
446466 return parsed[0] ?? null;
447467 }
448468
apps/server/src/wsServer.test.ts+2-4
......@@ -563,8 +563,7 @@ describe("WebSocket Server", () => {
563563 hasUpstream: false,
564564 aheadCount: 0,
565565 behindCount: 0,
566 openPr: null,
567 mergedPr: null,
566 pr: null,
568567 }),
569568 runStackedAction: vi.fn(),
570569 };
......@@ -593,8 +592,7 @@ describe("WebSocket Server", () => {
593592 hasUpstream: false,
594593 aheadCount: 0,
595594 behindCount: 0,
596 openPr: null,
597 mergedPr: null,
595 pr: null,
598596 });
599597 expect(gitManager.status).toHaveBeenCalledWith({ cwd: "/test" });
600598 });
packages/contracts/src/git.test.ts+1-2
......@@ -36,8 +36,7 @@ describe("git contracts", () => {
3636 hasUpstream: false,
3737 aheadCount: 0,
3838 behindCount: 0,
39 openPr: null,
40 mergedPr: null,
39 pr: null,
4140 });
4241 expect(parsed.branch).toBe("feature/git-actions");
4342 expect(parsed.hasWorkingTreeChanges).toBe(true);
packages/contracts/src/git.ts+14-18
......@@ -72,6 +72,17 @@ export const gitPullInputSchema = z.object({
7272 cwd: z.string().trim().min(1),
7373});
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
7586export const gitStatusResultSchema = z.object({
7687 branch: z.string().min(1).nullable(),
7788 hasWorkingTreeChanges: z.boolean(),
......@@ -89,24 +100,7 @@ export const gitStatusResultSchema = z.object({
89100 hasUpstream: z.boolean(),
90101 aheadCount: z.number().int().nonnegative(),
91102 behindCount: z.number().int().nonnegative(),
92 openPr: z
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(),
103 pr: gitStatusPrSchema.nullable(),
110104});
111105
112106export const gitStackedActionSchema = z.enum(["commit", "commit_push", "commit_push_pr"]);
......@@ -146,6 +140,8 @@ export const gitRunStackedActionResultSchema = z.object({
146140
147141export type GitStatusInput = z.input<typeof gitStatusInputSchema>;
148142export type GitPullInput = z.input<typeof gitPullInputSchema>;
143export type GitStatusPrState = z.infer<typeof gitStatusPrStateSchema>;
144export type GitStatusPr = z.infer<typeof gitStatusPrSchema>;
149145export type GitStatusResult = z.infer<typeof gitStatusResultSchema>;
150146export type GitStackedAction = z.infer<typeof gitStackedActionSchema>;
151147export type GitRunStackedActionInput = z.input<typeof gitRunStackedActionInputSchema>;