authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-28 11:33:43-08:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-02-28 11:33:43-08:00
logb500106fde0f90344174cf113d439777c6f3b266
treef9413d88322916a8456614e8e89e07d52ea2b435
parentd48b42c0174f7ed26de8fdd1b0f4f75c82c71fe7
parent7f2a95fe14c18b3feba039df6b88c6c47a846d04
signature Signed by PGP key B5690EEEBB952194

Merge pull request #122 from pingdotgg/codething/10276f55


10 files changed, 345 insertions(+), 49 deletions(-)

apps/server/src/git/Layers/GitCore.ts+1-1
...@@ -393,7 +393,7 @@ const makeGitCore = Effect.gen(function* () {...@@ -393,7 +393,7 @@ const makeGitCore = Effect.gen(function* () {
393 hasUpstream: details.hasUpstream,393 hasUpstream: details.hasUpstream,
394 aheadCount: details.aheadCount,394 aheadCount: details.aheadCount,
395 behindCount: details.behindCount,395 behindCount: details.behindCount,
396 openPr: null,396 pr: null,
397 })),397 })),
398 );398 );
399399
apps/server/src/git/Layers/GitManager.test.ts+90-4
...@@ -297,7 +297,7 @@ function makeManager(input?: {...@@ -297,7 +297,7 @@ function makeManager(input?: {
297const GitManagerTestLayer = Layer.provideMerge(GitServiceLive, NodeServices.layer);297const GitManagerTestLayer = Layer.provideMerge(GitServiceLive, NodeServices.layer);
298298
299it.layer(GitManagerTestLayer)("GitManager", (it) => {299it.layer(GitManagerTestLayer)("GitManager", (it) => {
300 it.effect("status includes open PR metadata when branch already has an open PR", () =>300 it.effect("status includes PR metadata when branch already has an open PR", () =>
301 Effect.gen(function* () {301 Effect.gen(function* () {
302 const repoDir = yield* makeTempDir("t3code-git-manager-");302 const repoDir = yield* makeTempDir("t3code-git-manager-");
303 yield* initRepo(repoDir);303 yield* initRepo(repoDir);
...@@ -324,17 +324,103 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {...@@ -324,17 +324,103 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
324324
325 const status = yield* manager.status({ cwd: repoDir });325 const status = yield* manager.status({ cwd: repoDir });
326 expect(status.branch).toBe("feature/status-open-pr");326 expect(status.branch).toBe("feature/status-open-pr");
327 expect(status.openPr).toEqual({327 expect(status.pr).toEqual({
328 number: 13,328 number: 13,
329 title: "Existing PR",329 title: "Existing PR",
330 url: "https://github.com/pingdotgg/codething-mvp/pull/13",330 url: "https://github.com/pingdotgg/codething-mvp/pull/13",
331 baseBranch: "main",331 baseBranch: "main",
332 headBranch: "feature/status-open-pr",332 headBranch: "feature/status-open-pr",
333 state: "open",
333 });334 });
334 }),335 }),
335 );336 );
336337
337 it.effect("status is resilient to gh lookup failures and returns openPr null", () =>338 it.effect("status returns merged PR state when latest PR was merged", () =>
339 Effect.gen(function* () {
340 const repoDir = yield* makeTempDir("t3code-git-manager-");
341 yield* initRepo(repoDir);
342 yield* runGit(repoDir, ["checkout", "-b", "feature/status-merged-pr"]);
343
344 const { manager } = yield* makeManager({
345 ghScenario: {
346 prListSequence: [
347 JSON.stringify([
348 {
349 number: 22,
350 title: "Merged PR",
351 url: "https://github.com/pingdotgg/codething-mvp/pull/22",
352 baseRefName: "main",
353 headRefName: "feature/status-merged-pr",
354 state: "MERGED",
355 mergedAt: "2026-01-30T10:00:00Z",
356 updatedAt: "2026-01-30T10:00:00Z",
357 },
358 ]),
359 ],
360 },
361 });
362
363 const status = yield* manager.status({ cwd: repoDir });
364 expect(status.branch).toBe("feature/status-merged-pr");
365 expect(status.pr).toEqual({
366 number: 22,
367 title: "Merged PR",
368 url: "https://github.com/pingdotgg/codething-mvp/pull/22",
369 baseBranch: "main",
370 headBranch: "feature/status-merged-pr",
371 state: "merged",
372 });
373 }),
374 );
375
376 it.effect("status prefers open PR when merged PR has newer updatedAt", () =>
377 Effect.gen(function* () {
378 const repoDir = yield* makeTempDir("t3code-git-manager-");
379 yield* initRepo(repoDir);
380 yield* runGit(repoDir, ["checkout", "-b", "feature/status-open-over-merged"]);
381
382 const { manager } = yield* makeManager({
383 ghScenario: {
384 prListSequence: [
385 JSON.stringify([
386 {
387 number: 45,
388 title: "Merged PR",
389 url: "https://github.com/pingdotgg/codething-mvp/pull/45",
390 baseRefName: "main",
391 headRefName: "feature/status-open-over-merged",
392 state: "MERGED",
393 mergedAt: "2026-01-31T10:00:00Z",
394 updatedAt: "2026-02-01T10:00:00Z",
395 },
396 {
397 number: 46,
398 title: "Open PR",
399 url: "https://github.com/pingdotgg/codething-mvp/pull/46",
400 baseRefName: "main",
401 headRefName: "feature/status-open-over-merged",
402 state: "OPEN",
403 updatedAt: "2026-01-30T10:00:00Z",
404 },
405 ]),
406 ],
407 },
408 });
409
410 const status = yield* manager.status({ cwd: repoDir });
411 expect(status.branch).toBe("feature/status-open-over-merged");
412 expect(status.pr).toEqual({
413 number: 46,
414 title: "Open PR",
415 url: "https://github.com/pingdotgg/codething-mvp/pull/46",
416 baseBranch: "main",
417 headBranch: "feature/status-open-over-merged",
418 state: "open",
419 });
420 }),
421 );
422
423 it.effect("status is resilient to gh lookup failures and returns pr null", () =>
338 Effect.gen(function* () {424 Effect.gen(function* () {
339 const repoDir = yield* makeTempDir("t3code-git-manager-");425 const repoDir = yield* makeTempDir("t3code-git-manager-");
340 yield* initRepo(repoDir);426 yield* initRepo(repoDir);
...@@ -354,7 +440,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {...@@ -354,7 +440,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
354440
355 const status = yield* manager.status({ cwd: repoDir });441 const status = yield* manager.status({ cwd: repoDir });
356 expect(status.branch).toBe("feature/status-no-gh");442 expect(status.branch).toBe("feature/status-no-gh");
357 expect(status.openPr).toBeNull();443 expect(status.pr).toBeNull();
358 }),444 }),
359 );445 );
360446
apps/server/src/git/Layers/GitManager.ts+111-7
...@@ -16,6 +16,62 @@ interface OpenPrInfo {...@@ -16,6 +16,62 @@ interface OpenPrInfo {
16 headRefName: string;16 headRefName: string;
17}17}
1818
19interface PullRequestInfo extends OpenPrInfo {
20 state: "open" | "closed" | "merged";
21 updatedAt: string | null;
22}
23
24function parsePullRequestList(raw: unknown): PullRequestInfo[] {
25 if (!Array.isArray(raw)) return [];
26
27 const parsed: PullRequestInfo[] = [];
28 for (const entry of raw) {
29 if (!entry || typeof entry !== "object") continue;
30 const record = entry as Record<string, unknown>;
31 const number = record.number;
32 const title = record.title;
33 const url = record.url;
34 const baseRefName = record.baseRefName;
35 const headRefName = record.headRefName;
36 const state = record.state;
37 const mergedAt = record.mergedAt;
38 const updatedAt = record.updatedAt;
39 if (typeof number !== "number" || !Number.isInteger(number) || number <= 0) {
40 continue;
41 }
42 if (
43 typeof title !== "string" ||
44 typeof url !== "string" ||
45 typeof baseRefName !== "string" ||
46 typeof headRefName !== "string"
47 ) {
48 continue;
49 }
50
51 let normalizedState: "open" | "closed" | "merged";
52 if ((typeof mergedAt === "string" && mergedAt.trim().length > 0) || state === "MERGED") {
53 normalizedState = "merged";
54 } else if (state === "OPEN" || state === undefined || state === null) {
55 normalizedState = "open";
56 } else if (state === "CLOSED") {
57 normalizedState = "closed";
58 } else {
59 continue;
60 }
61
62 parsed.push({
63 number,
64 title,
65 url,
66 baseRefName,
67 headRefName,
68 state: normalizedState,
69 updatedAt: typeof updatedAt === "string" && updatedAt.trim().length > 0 ? updatedAt : null,
70 });
71 }
72 return parsed;
73}
74
19function gitManagerError(operation: string, detail: string, cause?: unknown): GitManagerError {75function gitManagerError(operation: string, detail: string, cause?: unknown): GitManagerError {
20 return new GitManagerError({76 return new GitManagerError({
21 operation,77 operation,
...@@ -79,12 +135,13 @@ function extractBranchFromRef(ref: string): string {...@@ -79,12 +135,13 @@ function extractBranchFromRef(ref: string): string {
79 return normalized.slice(firstSlash + 1).trim();135 return normalized.slice(firstSlash + 1).trim();
80}136}
81137
82function toStatusOpenPr(pr: OpenPrInfo): {138function toStatusPr(pr: PullRequestInfo): {
83 number: number;139 number: number;
84 title: string;140 title: string;
85 url: string;141 url: string;
86 baseBranch: string;142 baseBranch: string;
87 headBranch: string;143 headBranch: string;
144 state: "open" | "closed" | "merged";
88} {145} {
89 return {146 return {
90 number: pr.number,147 number: pr.number,
...@@ -92,6 +149,7 @@ function toStatusOpenPr(pr: OpenPrInfo): {...@@ -92,6 +149,7 @@ function toStatusOpenPr(pr: OpenPrInfo): {
92 url: pr.url,149 url: pr.url,
93 baseBranch: pr.baseRefName,150 baseBranch: pr.baseRefName,
94 headBranch: pr.headRefName,151 headBranch: pr.headRefName,
152 state: pr.state,
95 };153 };
96}154}
97155
...@@ -123,10 +181,56 @@ export const makeGitManager = Effect.gen(function* () {...@@ -123,10 +181,56 @@ export const makeGitManager = Effect.gen(function* () {
123 url: first.url,181 url: first.url,
124 baseRefName: first.baseRefName,182 baseRefName: first.baseRefName,
125 headRefName: first.headRefName,183 headRefName: first.headRefName,
126 } satisfies OpenPrInfo;184 state: "open",
185 updatedAt: null,
186 } satisfies PullRequestInfo;
127 }),187 }),
128 );188 );
129189
190 const findLatestPr = (cwd: string, branch: string) =>
191 Effect.gen(function* () {
192 const stdout = yield* gitHubCli
193 .execute({
194 cwd,
195 args: [
196 "pr",
197 "list",
198 "--head",
199 branch,
200 "--state",
201 "all",
202 "--limit",
203 "20",
204 "--json",
205 "number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt",
206 ],
207 })
208 .pipe(Effect.map((result) => result.stdout));
209
210 const raw = stdout.trim();
211 if (raw.length === 0) {
212 return null;
213 }
214
215 const parsedJson = yield* Effect.try({
216 try: () => JSON.parse(raw) as unknown,
217 catch: (cause) =>
218 gitManagerError("findLatestPr", "GitHub CLI returned invalid PR list JSON.", cause),
219 });
220
221 const parsed = parsePullRequestList(parsedJson).toSorted((a, b) => {
222 const left = a.updatedAt ? Date.parse(a.updatedAt) : 0;
223 const right = b.updatedAt ? Date.parse(b.updatedAt) : 0;
224 return right - left;
225 });
226
227 const latestOpenPr = parsed.find((pr) => pr.state === "open");
228 if (latestOpenPr) {
229 return latestOpenPr;
230 }
231 return parsed[0] ?? null;
232 });
233
130 const resolveBaseBranch = (cwd: string, branch: string, upstreamRef: string | null) =>234 const resolveBaseBranch = (cwd: string, branch: string, upstreamRef: string | null) =>
131 Effect.gen(function* () {235 Effect.gen(function* () {
132 const configured = yield* gitCore.readConfigValue(cwd, `branch.${branch}.gh-merge-base`);236 const configured = yield* gitCore.readConfigValue(cwd, `branch.${branch}.gh-merge-base`);
...@@ -258,10 +362,10 @@ export const makeGitManager = Effect.gen(function* () {...@@ -258,10 +362,10 @@ export const makeGitManager = Effect.gen(function* () {
258 const status: GitManagerShape["status"] = Effect.fnUntraced(function* (input) {362 const status: GitManagerShape["status"] = Effect.fnUntraced(function* (input) {
259 const details = yield* gitCore.statusDetails(input.cwd);363 const details = yield* gitCore.statusDetails(input.cwd);
260364
261 const openPr =365 const pr =
262 details.branch && details.hasUpstream366 details.branch !== null
263 ? yield* findOpenPr(input.cwd, details.branch).pipe(367 ? yield* findLatestPr(input.cwd, details.branch).pipe(
264 Effect.map((pr) => (pr ? toStatusOpenPr(pr) : null)),368 Effect.map((latest) => (latest ? toStatusPr(latest) : null)),
265 Effect.catch(() => Effect.succeed(null)),369 Effect.catch(() => Effect.succeed(null)),
266 )370 )
267 : null;371 : null;
...@@ -273,7 +377,7 @@ export const makeGitManager = Effect.gen(function* () {...@@ -273,7 +377,7 @@ export const makeGitManager = Effect.gen(function* () {
273 hasUpstream: details.hasUpstream,377 hasUpstream: details.hasUpstream,
274 aheadCount: details.aheadCount,378 aheadCount: details.aheadCount,
275 behindCount: details.behindCount,379 behindCount: details.behindCount,
276 openPr,380 pr,
277 };381 };
278 });382 });
279383
apps/server/src/git/Services/GitCore.ts+1-1
...@@ -24,7 +24,7 @@ import type {...@@ -24,7 +24,7 @@ import type {
2424
25import type { GitCommandError } from "../Errors.ts";25import type { GitCommandError } from "../Errors.ts";
2626
27export interface GitStatusDetails extends Omit<GitStatusResult, "openPr"> {27export interface GitStatusDetails extends Omit<GitStatusResult, "pr"> {
28 upstreamRef: string | null;28 upstreamRef: string | null;
29}29}
3030
apps/server/src/wsServer.test.ts+1-1
...@@ -1521,7 +1521,7 @@ describe("WebSocket Server", () => {...@@ -1521,7 +1521,7 @@ describe("WebSocket Server", () => {
1521 hasUpstream: false,1521 hasUpstream: false,
1522 aheadCount: 0,1522 aheadCount: 0,
1523 behindCount: 0,1523 behindCount: 0,
1524 openPr: null,1524 pr: null,
1525 };1525 };
15261526
1527 const status = vi.fn(() => Effect.succeed(statusResult));1527 const status = vi.fn(() => Effect.succeed(statusResult));
apps/web/src/components/GitActionsControl.logic.test.ts+23-16
...@@ -20,7 +20,7 @@ function status(overrides: Partial<GitStatusResult> = {}): GitStatusResult {...@@ -20,7 +20,7 @@ function status(overrides: Partial<GitStatusResult> = {}): GitStatusResult {
20 hasUpstream: true,20 hasUpstream: true,
21 aheadCount: 0,21 aheadCount: 0,
22 behindCount: 0,22 behindCount: 0,
23 openPr: null,23 pr: null,
24 ...overrides,24 ...overrides,
25 };25 };
26}26}
...@@ -29,12 +29,13 @@ describe("when: branch is clean and has an open PR", () => {...@@ -29,12 +29,13 @@ describe("when: branch is clean and has an open PR", () => {
29 it("resolveQuickAction opens the existing PR", () => {29 it("resolveQuickAction opens the existing PR", () => {
30 const quick = resolveQuickAction(30 const quick = resolveQuickAction(
31 status({31 status({
32 openPr: {32 pr: {
33 number: 10,33 number: 10,
34 title: "Open PR",34 title: "Open PR",
35 url: "https://example.com/pr/10",35 url: "https://example.com/pr/10",
36 baseBranch: "main",36 baseBranch: "main",
37 headBranch: "feature/test",37 headBranch: "feature/test",
38 state: "open",
38 },39 },
39 }),40 }),
40 false,41 false,
...@@ -45,12 +46,13 @@ describe("when: branch is clean and has an open PR", () => {...@@ -45,12 +46,13 @@ describe("when: branch is clean and has an open PR", () => {
45 it("buildMenuItems disables commit/push and enables open PR", () => {46 it("buildMenuItems disables commit/push and enables open PR", () => {
46 const items = buildMenuItems(47 const items = buildMenuItems(
47 status({48 status({
48 openPr: {49 pr: {
49 number: 11,50 number: 11,
50 title: "Existing PR",51 title: "Existing PR",
51 url: "https://example.com/pr/11",52 url: "https://example.com/pr/11",
52 baseBranch: "main",53 baseBranch: "main",
53 headBranch: "feature/test",54 headBranch: "feature/test",
55 state: "open",
54 },56 },
55 }),57 }),
56 false,58 false,
...@@ -147,12 +149,13 @@ describe("when: branch is clean, ahead, and has an open PR", () => {...@@ -147,12 +149,13 @@ describe("when: branch is clean, ahead, and has an open PR", () => {
147 const quick = resolveQuickAction(149 const quick = resolveQuickAction(
148 status({150 status({
149 aheadCount: 3,151 aheadCount: 3,
150 openPr: {152 pr: {
151 number: 13,153 number: 13,
152 title: "Open PR",154 title: "Open PR",
153 url: "https://example.com/pr/13",155 url: "https://example.com/pr/13",
154 baseBranch: "main",156 baseBranch: "main",
155 headBranch: "feature/test",157 headBranch: "feature/test",
158 state: "open",
156 },159 },
157 }),160 }),
158 false,161 false,
...@@ -164,12 +167,13 @@ describe("when: branch is clean, ahead, and has an open PR", () => {...@@ -164,12 +167,13 @@ describe("when: branch is clean, ahead, and has an open PR", () => {
164 const items = buildMenuItems(167 const items = buildMenuItems(
165 status({168 status({
166 aheadCount: 2,169 aheadCount: 2,
167 openPr: {170 pr: {
168 number: 12,171 number: 12,
169 title: "Existing PR",172 title: "Existing PR",
170 url: "https://example.com/pr/12",173 url: "https://example.com/pr/12",
171 baseBranch: "main",174 baseBranch: "main",
172 headBranch: "feature/test",175 headBranch: "feature/test",
176 state: "open",
173 },177 },
174 }),178 }),
175 false,179 false,
...@@ -204,7 +208,7 @@ describe("when: branch is clean, ahead, and has an open PR", () => {...@@ -204,7 +208,7 @@ describe("when: branch is clean, ahead, and has an open PR", () => {
204208
205describe("when: branch is clean, ahead, and has no open PR", () => {209describe("when: branch is clean, ahead, and has no open PR", () => {
206 it("resolveQuickAction pushes and creates a PR", () => {210 it("resolveQuickAction pushes and creates a PR", () => {
207 const quick = resolveQuickAction(status({ aheadCount: 2, openPr: null }), false);211 const quick = resolveQuickAction(status({ aheadCount: 2, pr: null }), false);
208 assert.deepInclude(quick, {212 assert.deepInclude(quick, {
209 kind: "run_action",213 kind: "run_action",
210 action: "commit_push_pr",214 action: "commit_push_pr",
...@@ -213,7 +217,7 @@ describe("when: branch is clean, ahead, and has no open PR", () => {...@@ -213,7 +217,7 @@ describe("when: branch is clean, ahead, and has no open PR", () => {
213 });217 });
214218
215 it("buildMenuItems enables push and create PR, with commit disabled", () => {219 it("buildMenuItems enables push and create PR, with commit disabled", () => {
216 const items = buildMenuItems(status({ aheadCount: 2, openPr: null }), false);220 const items = buildMenuItems(status({ aheadCount: 2, pr: null }), false);
217 assert.deepEqual(items, [221 assert.deepEqual(items, [
218 {222 {
219 id: "commit",223 id: "commit",
...@@ -246,14 +250,14 @@ describe("when: branch is clean, ahead, and has no open PR", () => {...@@ -246,14 +250,14 @@ describe("when: branch is clean, ahead, and has no open PR", () => {
246describe("when: branch is clean, up to date, and has no open PR", () => {250describe("when: branch is clean, up to date, and has no open PR", () => {
247 it("resolveQuickAction returns disabled no-action state", () => {251 it("resolveQuickAction returns disabled no-action state", () => {
248 const quick = resolveQuickAction(252 const quick = resolveQuickAction(
249 status({ aheadCount: 0, behindCount: 0, hasWorkingTreeChanges: false, openPr: null }),253 status({ aheadCount: 0, behindCount: 0, hasWorkingTreeChanges: false, pr: null }),
250 false,254 false,
251 );255 );
252 assert.deepInclude(quick, { kind: "show_hint", label: "Commit", disabled: true });256 assert.deepInclude(quick, { kind: "show_hint", label: "Commit", disabled: true });
253 });257 });
254258
255 it("buildMenuItems disables commit, push, and create PR", () => {259 it("buildMenuItems disables commit, push, and create PR", () => {
256 const items = buildMenuItems(status({ aheadCount: 0, behindCount: 0, openPr: null }), false);260 const items = buildMenuItems(status({ aheadCount: 0, behindCount: 0, pr: null }), false);
257 assert.deepEqual(items, [261 assert.deepEqual(items, [
258 {262 {
259 id: "commit",263 id: "commit",
...@@ -290,7 +294,7 @@ describe("when: branch is behind upstream", () => {...@@ -290,7 +294,7 @@ describe("when: branch is behind upstream", () => {
290 });294 });
291295
292 it("buildMenuItems disables push and create PR", () => {296 it("buildMenuItems disables push and create PR", () => {
293 const items = buildMenuItems(status({ behindCount: 1, openPr: null }), false);297 const items = buildMenuItems(status({ behindCount: 1, pr: null }), false);
294 assert.deepEqual(items, [298 assert.deepEqual(items, [
295 {299 {
296 id: "commit",300 id: "commit",
...@@ -346,12 +350,13 @@ describe("when: working tree has local changes", () => {...@@ -346,12 +350,13 @@ describe("when: working tree has local changes", () => {
346 const quick = resolveQuickAction(350 const quick = resolveQuickAction(
347 status({351 status({
348 hasWorkingTreeChanges: true,352 hasWorkingTreeChanges: true,
349 openPr: {353 pr: {
350 number: 16,354 number: 16,
351 title: "Existing PR",355 title: "Existing PR",
352 url: "https://example.com/pr/16",356 url: "https://example.com/pr/16",
353 baseBranch: "main",357 baseBranch: "main",
354 headBranch: "feature/test",358 headBranch: "feature/test",
359 state: "open",
355 },360 },
356 }),361 }),
357 false,362 false,
...@@ -411,7 +416,7 @@ describe("when: on default branch without open PR", () => {...@@ -411,7 +416,7 @@ describe("when: on default branch without open PR", () => {
411416
412 it("resolveQuickAction returns push when branch is ahead", () => {417 it("resolveQuickAction returns push when branch is ahead", () => {
413 const quick = resolveQuickAction(418 const quick = resolveQuickAction(
414 status({ branch: "main", aheadCount: 2, openPr: null }),419 status({ branch: "main", aheadCount: 2, pr: null }),
415 false,420 false,
416 true,421 true,
417 );422 );
...@@ -511,7 +516,7 @@ describe("when: HEAD is detached and there are no local changes", () => {...@@ -511,7 +516,7 @@ describe("when: HEAD is detached and there are no local changes", () => {
511describe("when: branch has no upstream configured", () => {516describe("when: branch has no upstream configured", () => {
512 it("resolveQuickAction returns disabled commit state when clean and no commits are ahead", () => {517 it("resolveQuickAction returns disabled commit state when clean and no commits are ahead", () => {
513 const quick = resolveQuickAction(518 const quick = resolveQuickAction(
514 status({ hasUpstream: false, openPr: null, aheadCount: 0 }),519 status({ hasUpstream: false, pr: null, aheadCount: 0 }),
515 false,520 false,
516 );521 );
517 assert.deepInclude(quick, {522 assert.deepInclude(quick, {
...@@ -526,12 +531,13 @@ describe("when: branch has no upstream configured", () => {...@@ -526,12 +531,13 @@ describe("when: branch has no upstream configured", () => {
526 status({531 status({
527 hasUpstream: false,532 hasUpstream: false,
528 aheadCount: 0,533 aheadCount: 0,
529 openPr: {534 pr: {
530 number: 14,535 number: 14,
531 title: "Existing PR",536 title: "Existing PR",
532 url: "https://example.com/pr/14",537 url: "https://example.com/pr/14",
533 baseBranch: "main",538 baseBranch: "main",
534 headBranch: "feature/test",539 headBranch: "feature/test",
540 state: "open",
535 },541 },
536 }),542 }),
537 false,543 false,
...@@ -548,12 +554,13 @@ describe("when: branch has no upstream configured", () => {...@@ -548,12 +554,13 @@ describe("when: branch has no upstream configured", () => {
548 status({554 status({
549 hasUpstream: false,555 hasUpstream: false,
550 aheadCount: 1,556 aheadCount: 1,
551 openPr: {557 pr: {
552 number: 15,558 number: 15,
553 title: "Existing PR",559 title: "Existing PR",
554 url: "https://example.com/pr/15",560 url: "https://example.com/pr/15",
555 baseBranch: "main",561 baseBranch: "main",
556 headBranch: "feature/test",562 headBranch: "feature/test",
563 state: "open",
557 },564 },
558 }),565 }),
559 false,566 false,
...@@ -568,7 +575,7 @@ describe("when: branch has no upstream configured", () => {...@@ -568,7 +575,7 @@ describe("when: branch has no upstream configured", () => {
568575
569 it("buildMenuItems disables push and create PR when no commits are ahead", () => {576 it("buildMenuItems disables push and create PR when no commits are ahead", () => {
570 const items = buildMenuItems(577 const items = buildMenuItems(
571 status({ hasUpstream: false, openPr: null, aheadCount: 0 }),578 status({ hasUpstream: false, pr: null, aheadCount: 0 }),
572 false,579 false,
573 );580 );
574 assert.deepEqual(items, [581 assert.deepEqual(items, [
apps/web/src/components/GitActionsControl.logic.ts+2-2
...@@ -108,7 +108,7 @@ export function buildMenuItems(...@@ -108,7 +108,7 @@ export function buildMenuItems(
108108
109 const hasBranch = gitStatus.branch !== null;109 const hasBranch = gitStatus.branch !== null;
110 const hasChanges = gitStatus.hasWorkingTreeChanges;110 const hasChanges = gitStatus.hasWorkingTreeChanges;
111 const hasOpenPr = gitStatus.openPr !== null;111 const hasOpenPr = gitStatus.pr?.state === "open";
112 const isBehind = gitStatus.behindCount > 0;112 const isBehind = gitStatus.behindCount > 0;
113 const canCommit = !isBusy && hasChanges;113 const canCommit = !isBusy && hasChanges;
114 const canPush = !isBusy && hasBranch && !hasChanges && !isBehind && gitStatus.aheadCount > 0;114 const canPush = !isBusy && hasBranch && !hasChanges && !isBehind && gitStatus.aheadCount > 0;
...@@ -178,7 +178,7 @@ export function resolveQuickAction(...@@ -178,7 +178,7 @@ export function resolveQuickAction(
178178
179 const hasBranch = gitStatus.branch !== null;179 const hasBranch = gitStatus.branch !== null;
180 const hasChanges = gitStatus.hasWorkingTreeChanges;180 const hasChanges = gitStatus.hasWorkingTreeChanges;
181 const hasOpenPr = gitStatus.openPr !== null;181 const hasOpenPr = gitStatus.pr?.state === "open";
182 const isAhead = gitStatus.aheadCount > 0;182 const isAhead = gitStatus.aheadCount > 0;
183 const isBehind = gitStatus.behindCount > 0;183 const isBehind = gitStatus.behindCount > 0;
184 const isDiverged = isAhead && isBehind;184 const isDiverged = isAhead && isBehind;
apps/web/src/components/GitActionsControl.tsx+7-5
...@@ -58,7 +58,7 @@ function getMenuActionDisabledReason(...@@ -58,7 +58,7 @@ function getMenuActionDisabledReason(
5858
59 const hasBranch = gitStatus.branch !== null;59 const hasBranch = gitStatus.branch !== null;
60 const hasChanges = gitStatus.hasWorkingTreeChanges;60 const hasChanges = gitStatus.hasWorkingTreeChanges;
61 const hasOpenPr = gitStatus.openPr !== null;61 const hasOpenPr = gitStatus.pr?.state === "open";
62 const isAhead = gitStatus.aheadCount > 0;62 const isAhead = gitStatus.aheadCount > 0;
63 const isBehind = gitStatus.behindCount > 0;63 const isBehind = gitStatus.behindCount > 0;
6464
...@@ -219,7 +219,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions...@@ -219,7 +219,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
219 });219 });
220 return;220 return;
221 }221 }
222 const prUrl = gitStatusForActions?.openPr?.url ?? null;222 const prUrl = gitStatusForActions?.pr?.state === "open" ? gitStatusForActions.pr.url : null;
223 if (!prUrl) {223 if (!prUrl) {
224 toastManager.add({224 toastManager.add({
225 type: "error",225 type: "error",
...@@ -236,7 +236,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions...@@ -236,7 +236,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
236 data: threadToastData,236 data: threadToastData,
237 });237 });
238 });238 });
239 }, [gitStatusForActions?.openPr?.url, threadToastData]);239 }, [gitStatusForActions?.pr?.state, gitStatusForActions?.pr?.url, threadToastData]);
240 const runGitActionWithToast = useCallback(240 const runGitActionWithToast = useCallback(
241 async ({241 async ({
242 action,242 action,
...@@ -295,7 +295,9 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions...@@ -295,7 +295,9 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
295 stopProgressUpdates();295 stopProgressUpdates();
296 const resultToast = summarizeGitResult(result);296 const resultToast = summarizeGitResult(result);
297297
298 const prUrl = result.pr.url ?? gitStatusForActions?.openPr?.url;298 const existingOpenPrUrl =
299 gitStatusForActions?.pr?.state === "open" ? gitStatusForActions.pr.url : undefined;
300 const prUrl = result.pr.url ?? existingOpenPrUrl;
299 const shouldOfferPushCta = action === "commit" && result.commit.status === "created";301 const shouldOfferPushCta = action === "commit" && result.commit.status === "created";
300 const shouldOfferOpenPrCta =302 const shouldOfferOpenPrCta =
301 (action === "commit_push" || action === "commit_push_pr") && !!prUrl && !isDefaultBranch;303 (action === "commit_push" || action === "commit_push_pr") && !!prUrl && !isDefaultBranch;
...@@ -365,7 +367,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions...@@ -365,7 +367,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
365 [367 [
366 gitStatusForActions?.branch,368 gitStatusForActions?.branch,
367 gitStatusForActions?.hasWorkingTreeChanges,369 gitStatusForActions?.hasWorkingTreeChanges,
368 gitStatusForActions?.openPr?.url,370 gitStatusForActions?.pr,
369 isDefaultBranch,371 isDefaultBranch,
370 maybeConfirmPushToDefaultBranch,372 maybeConfirmPushToDefaultBranch,
371 runImmediateGitActionMutation,373 runImmediateGitActionMutation,
apps/web/src/components/Sidebar.tsx+96-3
...@@ -1,12 +1,13 @@...@@ -1,12 +1,13 @@
1import { ChevronRightIcon, FolderIcon, TerminalIcon } from "lucide-react";1import { ChevronRightIcon, FolderIcon, GitPullRequestIcon, TerminalIcon } from "lucide-react";
2import { useCallback, useEffect, useMemo, useState } from "react";2import { useCallback, useEffect, useMemo, useState } from "react";
3import {3import {
4 DEFAULT_MODEL,4 DEFAULT_MODEL,
5 ProjectId,5 ProjectId,
6 ThreadId,6 ThreadId,
7 type GitStatusResult,
7 type ResolvedKeybindingsConfig,8 type ResolvedKeybindingsConfig,
8} from "@t3tools/contracts";9} from "@t3tools/contracts";
9import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";10import { useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/react-query";
10import { useNavigate, useParams } from "@tanstack/react-router";11import { useNavigate, useParams } from "@tanstack/react-router";
11import { useAppSettings } from "../appSettings";12import { useAppSettings } from "../appSettings";
12import { isElectron } from "../env";13import { isElectron } from "../env";
...@@ -16,7 +17,7 @@ import { useStore } from "../store";...@@ -16,7 +17,7 @@ import { useStore } from "../store";
16import { isChatNewLocalShortcut, isChatNewShortcut } from "../keybindings";17import { isChatNewLocalShortcut, isChatNewShortcut } from "../keybindings";
17import { type Thread } from "../types";18import { type Thread } from "../types";
18import { derivePendingApprovals } from "../session-logic";19import { derivePendingApprovals } from "../session-logic";
19import { gitRemoveWorktreeMutationOptions } from "../lib/gitReactQuery";20import { gitRemoveWorktreeMutationOptions, gitStatusQueryOptions } from "../lib/gitReactQuery";
20import { serverConfigQueryOptions } from "../lib/serverReactQuery";21import { serverConfigQueryOptions } from "../lib/serverReactQuery";
21import { readNativeApi } from "../nativeApi";22import { readNativeApi } from "../nativeApi";
22import { toastManager } from "./ui/toast";23import { toastManager } from "./ui/toast";
...@@ -63,6 +64,13 @@ interface TerminalStatusIndicator {...@@ -63,6 +64,13 @@ interface TerminalStatusIndicator {
63 pulse: boolean;64 pulse: boolean;
64}65}
6566
67interface PrStatusIndicator {
68 label: "PR open" | "PR closed" | "PR merged";
69 colorClass: string;
70}
71
72type ThreadPrState = NonNullable<GitStatusResult["pr"]>["state"] | null;
73
66function hasUnseenCompletion(thread: Thread): boolean {74function hasUnseenCompletion(thread: Thread): boolean {
67 if (!thread.latestTurn?.completedAt) return false;75 if (!thread.latestTurn?.completedAt) return false;
68 const completedAt = Date.parse(thread.latestTurn.completedAt);76 const completedAt = Date.parse(thread.latestTurn.completedAt);
...@@ -125,6 +133,28 @@ function terminalStatusIndicator(thread: Thread): TerminalStatusIndicator | null...@@ -125,6 +133,28 @@ function terminalStatusIndicator(thread: Thread): TerminalStatusIndicator | null
125 };133 };
126}134}
127135
136function prStatusIndicator(prState: ThreadPrState): PrStatusIndicator | null {
137 if (prState === "open") {
138 return {
139 label: "PR open",
140 colorClass: "text-emerald-600 dark:text-emerald-300/90",
141 };
142 }
143 if (prState === "closed") {
144 return {
145 label: "PR closed",
146 colorClass: "text-zinc-500 dark:text-zinc-400/80",
147 };
148 }
149 if (prState === "merged") {
150 return {
151 label: "PR merged",
152 colorClass: "text-violet-600 dark:text-violet-300/90",
153 };
154 }
155 return null;
156}
157
128function T3Wordmark() {158function T3Wordmark() {
129 return (159 return (
130 <svg160 <svg
...@@ -210,6 +240,58 @@ export default function Sidebar() {...@@ -210,6 +240,58 @@ export default function Sidebar() {
210 }240 }
211 return map;241 return map;
212 }, [state.threads]);242 }, [state.threads]);
243 const projectCwdById = useMemo(
244 () => new Map(state.projects.map((project) => [project.id, project.cwd] as const)),
245 [state.projects],
246 );
247 const threadGitTargets = useMemo(
248 () =>
249 state.threads.map((thread) => ({
250 threadId: thread.id,
251 branch: thread.branch,
252 cwd: thread.worktreePath ?? projectCwdById.get(thread.projectId) ?? null,
253 })),
254 [projectCwdById, state.threads],
255 );
256 const threadGitStatusCwds = useMemo(
257 () =>
258 [
259 ...new Set(
260 threadGitTargets
261 .filter((target) => target.branch !== null)
262 .map((target) => target.cwd)
263 .filter((cwd): cwd is string => cwd !== null),
264 ),
265 ],
266 [threadGitTargets],
267 );
268 const threadGitStatusQueries = useQueries({
269 queries: threadGitStatusCwds.map((cwd) => ({
270 ...gitStatusQueryOptions(cwd),
271 staleTime: 30_000,
272 refetchInterval: 60_000,
273 })),
274 });
275 const prStateByThreadId = useMemo(() => {
276 const statusByCwd = new Map<string, GitStatusResult>();
277 for (let index = 0; index < threadGitStatusCwds.length; index += 1) {
278 const cwd = threadGitStatusCwds[index];
279 if (!cwd) continue;
280 const status = threadGitStatusQueries[index]?.data;
281 if (status) {
282 statusByCwd.set(cwd, status);
283 }
284 }
285
286 const map = new Map<ThreadId, ThreadPrState>();
287 for (const target of threadGitTargets) {
288 const status = target.cwd ? statusByCwd.get(target.cwd) : undefined;
289 const branchMatches =
290 target.branch !== null && status?.branch !== null && status?.branch === target.branch;
291 map.set(target.threadId, branchMatches ? status?.pr?.state ?? null : null);
292 }
293 return map;
294 }, [threadGitStatusCwds, threadGitStatusQueries, threadGitTargets]);
213295
214 const handleNewThread = useCallback(296 const handleNewThread = useCallback(
215 (297 (
...@@ -632,6 +714,7 @@ export default function Sidebar() {...@@ -632,6 +714,7 @@ export default function Sidebar() {
632 thread,714 thread,
633 pendingApprovalByThreadId.get(thread.id) === true,715 pendingApprovalByThreadId.get(thread.id) === true,
634 );716 );
717 const prStatus = prStatusIndicator(prStateByThreadId.get(thread.id) ?? null);
635 const terminalStatus = terminalStatusIndicator(thread);718 const terminalStatus = terminalStatusIndicator(thread);
636719
637 return (720 return (
...@@ -677,6 +760,16 @@ export default function Sidebar() {...@@ -677,6 +760,16 @@ export default function Sidebar() {
677 </span>760 </span>
678 </div>761 </div>
679 <div className="ml-auto flex shrink-0 items-center gap-1.5">762 <div className="ml-auto flex shrink-0 items-center gap-1.5">
763 {prStatus && (
764 <span
765 role="img"
766 aria-label={prStatus.label}
767 title={prStatus.label}
768 className={`inline-flex items-center justify-center ${prStatus.colorClass}`}
769 >
770 <GitPullRequestIcon className="size-3" />
771 </span>
772 )}
680 {terminalStatus && (773 {terminalStatus && (
681 <span774 <span
682 role="img"775 role="img"
packages/contracts/src/git.ts+13-9
...@@ -18,6 +18,8 @@ export const GitPrStepStatus = Schema.Literals([...@@ -18,6 +18,8 @@ export const GitPrStepStatus = Schema.Literals([
18 "opened_existing",18 "opened_existing",
19 "skipped_not_requested",19 "skipped_not_requested",
20]);20]);
21export const GitStatusPrState = Schema.Literals(["open", "closed", "merged"]);
22export type GitStatusPrState = typeof GitStatusPrState.Type;
2123
22export const GitBranch = Schema.Struct({24export const GitBranch = Schema.Struct({
23 name: TrimmedNonEmptyStringSchema,25 name: TrimmedNonEmptyStringSchema,
...@@ -91,6 +93,16 @@ export type GitInitInput = typeof GitInitInput.Type;...@@ -91,6 +93,16 @@ export type GitInitInput = typeof GitInitInput.Type;
9193
92// RPC Results94// RPC Results
9395
96export const GitStatusPr = Schema.Struct({
97 number: PositiveInt,
98 title: TrimmedNonEmptyStringSchema,
99 url: Schema.String,
100 baseBranch: TrimmedNonEmptyStringSchema,
101 headBranch: TrimmedNonEmptyStringSchema,
102 state: GitStatusPrState,
103});
104export type GitStatusPr = typeof GitStatusPr.Type;
105
94export const GitStatusResult = Schema.Struct({106export const GitStatusResult = Schema.Struct({
95 branch: TrimmedNonEmptyStringSchema.pipe(Schema.NullOr),107 branch: TrimmedNonEmptyStringSchema.pipe(Schema.NullOr),
96 hasWorkingTreeChanges: Schema.Boolean,108 hasWorkingTreeChanges: Schema.Boolean,
...@@ -108,15 +120,7 @@ export const GitStatusResult = Schema.Struct({...@@ -108,15 +120,7 @@ export const GitStatusResult = Schema.Struct({
108 hasUpstream: Schema.Boolean,120 hasUpstream: Schema.Boolean,
109 aheadCount: NonNegativeInt,121 aheadCount: NonNegativeInt,
110 behindCount: NonNegativeInt,122 behindCount: NonNegativeInt,
111 openPr: Schema.NullOr(123 pr: Schema.NullOr(GitStatusPr),
112 Schema.Struct({
113 number: PositiveInt,
114 title: TrimmedNonEmptyStringSchema,
115 url: Schema.String,
116 baseBranch: TrimmedNonEmptyStringSchema,
117 headBranch: TrimmedNonEmptyStringSchema,
118 }),
119 ),
120});124});
121export type GitStatusResult = typeof GitStatusResult.Type;125export type GitStatusResult = typeof GitStatusResult.Type;
122126