| author | |
| committer | |
| log | 46347e2bd273dd387d52b265691ab456d3e936be |
| tree | 93a0eec5dd1f4de200266ae37bf5ab1f8c87339a |
| parent | 7fc53dadc7d66483cdeac70f233059490785c9e2 |
- extend `GitStatusResult` with nullable `mergedPr` metadata
- in server git status, query merged PR only when no open PR is found
- in sidebar, display a `Merged` status pill for threads on branches with merged PRs
- update server/web/contracts tests for the new status shape8 files changed, 152 insertions(+), 8 deletions(-)
apps/server/src/git.ts+2-1| ... | ... | @@ -31,7 +31,7 @@ export interface TerminalCommandInput { |
| 31 | 31 | |
| 32 | 32 | export type TerminalCommandResult = ProcessRunResult; |
| 33 | 33 | |
| 34 | export interface GitStatusDetails extends Omit<GitStatusResult, "openPr"> { | |
| 34 | export interface GitStatusDetails extends Omit<GitStatusResult, "openPr" | "mergedPr"> { | |
| 35 | 35 | upstreamRef: string | null; |
| 36 | 36 | } |
| 37 | 37 | |
| ... | ... | @@ -227,6 +227,7 @@ export class GitCoreService { |
| 227 | 227 | aheadCount: details.aheadCount, |
| 228 | 228 | behindCount: details.behindCount, |
| 229 | 229 | openPr: null, |
| 230 | mergedPr: null, | |
| 230 | 231 | }); |
| 231 | 232 | } |
| 232 | 233 |
apps/server/src/gitManager.test.ts+46-2| ... | ... | @@ -169,7 +169,7 @@ describe("GitManager", () => { |
| 169 | 169 | await runGit(repoDir, ["remote", "add", "origin", remoteDir]); |
| 170 | 170 | await runGit(repoDir, ["push", "-u", "origin", "feature/status-open-pr"]); |
| 171 | 171 | |
| 172 | const { runner } = createRunnerWithFakeGh({ | |
| 172 | const { runner, ghCalls } = createRunnerWithFakeGh({ | |
| 173 | 173 | prListSequence: [ |
| 174 | 174 | JSON.stringify([ |
| 175 | 175 | { |
| ... | ... | @@ -197,9 +197,52 @@ describe("GitManager", () => { |
| 197 | 197 | baseBranch: "main", |
| 198 | 198 | headBranch: "feature/status-open-pr", |
| 199 | 199 | }); |
| 200 | expect(status.mergedPr).toBeNull(); | |
| 201 | expect(ghCalls.some((call) => call.includes("--state merged"))).toBe(false); | |
| 202 | }); | |
| 203 | ||
| 204 | it("status includes merged PR metadata when there is no open PR", async () => { | |
| 205 | const repoDir = makeTempDir("t3code-git-manager-"); | |
| 206 | await initRepo(repoDir); | |
| 207 | await runGit(repoDir, ["checkout", "-b", "feature/status-merged-pr"]); | |
| 208 | const remoteDir = await createBareRemote(); | |
| 209 | await runGit(repoDir, ["remote", "add", "origin", remoteDir]); | |
| 210 | await runGit(repoDir, ["push", "-u", "origin", "feature/status-merged-pr"]); | |
| 211 | ||
| 212 | const { runner, ghCalls } = createRunnerWithFakeGh({ | |
| 213 | prListSequence: [ | |
| 214 | "[]", | |
| 215 | JSON.stringify([ | |
| 216 | { | |
| 217 | number: 26, | |
| 218 | title: "Merged PR", | |
| 219 | url: "https://github.com/pingdotgg/codething-mvp/pull/26", | |
| 220 | baseRefName: "main", | |
| 221 | headRefName: "feature/status-merged-pr", | |
| 222 | }, | |
| 223 | ]), | |
| 224 | ], | |
| 225 | }); | |
| 226 | ||
| 227 | const manager = new GitManager({ | |
| 228 | runProcess: runner, | |
| 229 | textGenerator: createTextGenerator(), | |
| 230 | }); | |
| 231 | ||
| 232 | const status = await manager.status({ cwd: repoDir }); | |
| 233 | expect(status.branch).toBe("feature/status-merged-pr"); | |
| 234 | expect(status.openPr).toBeNull(); | |
| 235 | expect(status.mergedPr).toEqual({ | |
| 236 | number: 26, | |
| 237 | title: "Merged PR", | |
| 238 | url: "https://github.com/pingdotgg/codething-mvp/pull/26", | |
| 239 | baseBranch: "main", | |
| 240 | headBranch: "feature/status-merged-pr", | |
| 241 | }); | |
| 242 | expect(ghCalls.some((call) => call.includes("--state merged"))).toBe(true); | |
| 200 | 243 | }); |
| 201 | 244 | |
| 202 | it("status is resilient to gh lookup failures and returns openPr null", async () => { | |
| 245 | it("status is resilient to gh lookup failures and returns openPr/mergedPr null", async () => { | |
| 203 | 246 | const repoDir = makeTempDir("t3code-git-manager-"); |
| 204 | 247 | await initRepo(repoDir); |
| 205 | 248 | await runGit(repoDir, ["checkout", "-b", "feature/status-no-gh"]); |
| ... | ... | @@ -218,6 +261,7 @@ describe("GitManager", () => { |
| 218 | 261 | const status = await manager.status({ cwd: repoDir }); |
| 219 | 262 | expect(status.branch).toBe("feature/status-no-gh"); |
| 220 | 263 | expect(status.openPr).toBeNull(); |
| 264 | expect(status.mergedPr).toBeNull(); | |
| 221 | 265 | }); |
| 222 | 266 | |
| 223 | 267 | it("creates a commit when working tree is dirty", async () => { |
apps/server/src/gitManager.ts+36-1| ... | ... | @@ -192,11 +192,17 @@ export class GitManager { |
| 192 | 192 | const details = await this.gitCore.statusDetails(input.cwd); |
| 193 | 193 | |
| 194 | 194 | let openPr: ReturnType<typeof toStatusOpenPr> | null = null; |
| 195 | if (details.branch && details.hasUpstream) { | |
| 195 | let mergedPr: ReturnType<typeof toStatusOpenPr> | null = null; | |
| 196 | if (details.branch) { | |
| 196 | 197 | try { |
| 197 | 198 | const existing = await this.findOpenPr(input.cwd, details.branch); |
| 198 | 199 | if (existing) { |
| 199 | 200 | openPr = toStatusOpenPr(existing); |
| 201 | } else { | |
| 202 | const merged = await this.findMergedPr(input.cwd, details.branch); | |
| 203 | if (merged) { | |
| 204 | mergedPr = toStatusOpenPr(merged); | |
| 205 | } | |
| 200 | 206 | } |
| 201 | 207 | } catch { |
| 202 | 208 | // PR lookup is best-effort for status rendering. |
| ... | ... | @@ -211,6 +217,7 @@ export class GitManager { |
| 211 | 217 | aheadCount: details.aheadCount, |
| 212 | 218 | behindCount: details.behindCount, |
| 213 | 219 | openPr, |
| 220 | mergedPr, | |
| 214 | 221 | }; |
| 215 | 222 | } |
| 216 | 223 | |
| ... | ... | @@ -408,6 +415,34 @@ export class GitManager { |
| 408 | 415 | return parsed[0] ?? null; |
| 409 | 416 | } |
| 410 | 417 | |
| 418 | private async findMergedPr(cwd: string, branch: string): Promise<OpenPrInfo | null> { | |
| 419 | const stdout = await this.runGhStdout(cwd, [ | |
| 420 | "pr", | |
| 421 | "list", | |
| 422 | "--head", | |
| 423 | branch, | |
| 424 | "--state", | |
| 425 | "merged", | |
| 426 | "--limit", | |
| 427 | "1", | |
| 428 | "--json", | |
| 429 | "number,title,url,baseRefName,headRefName", | |
| 430 | ]); | |
| 431 | ||
| 432 | const raw = trimStdout(stdout); | |
| 433 | if (raw.length === 0) return null; | |
| 434 | ||
| 435 | let parsedJson: unknown; | |
| 436 | try { | |
| 437 | parsedJson = JSON.parse(raw); | |
| 438 | } catch { | |
| 439 | throw new Error("GitHub CLI returned invalid PR list JSON."); | |
| 440 | } | |
| 441 | ||
| 442 | const parsed = parseOpenPrList(parsedJson); | |
| 443 | return parsed[0] ?? null; | |
| 444 | } | |
| 445 | ||
| 411 | 446 | private async resolveBaseBranch( |
| 412 | 447 | cwd: string, |
| 413 | 448 | branch: string, |
apps/server/src/wsServer.test.ts+2| ... | ... | @@ -564,6 +564,7 @@ describe("WebSocket Server", () => { |
| 564 | 564 | aheadCount: 0, |
| 565 | 565 | behindCount: 0, |
| 566 | 566 | openPr: null, |
| 567 | mergedPr: null, | |
| 567 | 568 | }), |
| 568 | 569 | runStackedAction: vi.fn(), |
| 569 | 570 | }; |
| ... | ... | @@ -593,6 +594,7 @@ describe("WebSocket Server", () => { |
| 593 | 594 | aheadCount: 0, |
| 594 | 595 | behindCount: 0, |
| 595 | 596 | openPr: null, |
| 597 | mergedPr: null, | |
| 596 | 598 | }); |
| 597 | 599 | expect(gitManager.status).toHaveBeenCalledWith({ cwd: "/test" }); |
| 598 | 600 | }); |
apps/web/src/components/GitActionsControl.logic.test.ts+1| ... | ... | @@ -21,6 +21,7 @@ function status(overrides: Partial<GitStatusResult> = {}): GitStatusResult { |
| 21 | 21 | aheadCount: 0, |
| 22 | 22 | behindCount: 0, |
| 23 | 23 | openPr: null, |
| 24 | mergedPr: null, | |
| 24 | 25 | ...overrides, |
| 25 | 26 | }; |
| 26 | 27 | } |
apps/web/src/components/Sidebar.tsx+55-4| ... | ... | @@ -1,6 +1,7 @@ |
| 1 | 1 | import { MonitorIcon, MoonIcon, SunIcon, TerminalIcon } from "lucide-react"; |
| 2 | 2 | import { useCallback, useEffect, useMemo, useState } from "react"; |
| 3 | import { useMutation, useQueryClient } from "@tanstack/react-query"; | |
| 3 | import { useMutation, useQueries, useQueryClient } from "@tanstack/react-query"; | |
| 4 | import type { GitStatusResult } from "@t3tools/contracts"; | |
| 4 | 5 | import { isElectron } from "../env"; |
| 5 | 6 | import { useTheme } from "../hooks/useTheme"; |
| 6 | 7 | import { DEFAULT_MODEL } from "../model-logic"; |
| ... | ... | @@ -13,7 +14,7 @@ import { |
| 13 | 14 | type Thread, |
| 14 | 15 | } from "../types"; |
| 15 | 16 | import { useNativeApi } from "../hooks/useNativeApi"; |
| 16 | import { gitRemoveWorktreeMutationOptions } from "../lib/gitReactQuery"; | |
| 17 | import { gitRemoveWorktreeMutationOptions, gitStatusQueryOptions } from "../lib/gitReactQuery"; | |
| 17 | 18 | import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup"; |
| 18 | 19 | |
| 19 | 20 | const THEME_CYCLE = { system: "light", light: "dark", dark: "system" } as const; |
| ... | ... | @@ -35,7 +36,7 @@ function inferProjectName(cwd: string): string { |
| 35 | 36 | } |
| 36 | 37 | |
| 37 | 38 | interface ThreadStatusPill { |
| 38 | label: "Working" | "Connecting" | "Completed" | "Awaiting response"; | |
| 39 | label: "Working" | "Connecting" | "Completed" | "Awaiting response" | "Merged"; | |
| 39 | 40 | colorClass: string; |
| 40 | 41 | dotClass: string; |
| 41 | 42 | pulse: boolean; |
| ... | ... | @@ -58,7 +59,11 @@ function hasUnseenCompletion(thread: Thread): boolean { |
| 58 | 59 | return completedAt > lastVisitedAt; |
| 59 | 60 | } |
| 60 | 61 | |
| 61 | function threadStatusPill(thread: Thread, hasPendingApprovals: boolean): ThreadStatusPill | null { | |
| 62 | function threadStatusPill( | |
| 63 | thread: Thread, | |
| 64 | hasPendingApprovals: boolean, | |
| 65 | hasMergedPr: boolean, | |
| 66 | ): ThreadStatusPill | null { | |
| 62 | 67 | if (hasPendingApprovals) { |
| 63 | 68 | return { |
| 64 | 69 | label: "Awaiting response", |
| ... | ... | @@ -86,6 +91,15 @@ function threadStatusPill(thread: Thread, hasPendingApprovals: boolean): ThreadS |
| 86 | 91 | }; |
| 87 | 92 | } |
| 88 | 93 | |
| 94 | if (hasMergedPr) { | |
| 95 | return { | |
| 96 | label: "Merged", | |
| 97 | colorClass: "text-violet-600 dark:text-violet-300/90", | |
| 98 | dotClass: "bg-violet-500 dark:bg-violet-300/90", | |
| 99 | pulse: false, | |
| 100 | }; | |
| 101 | } | |
| 102 | ||
| 89 | 103 | if (hasUnseenCompletion(thread)) { |
| 90 | 104 | return { |
| 91 | 105 | label: "Completed", |
| ... | ... | @@ -126,6 +140,42 @@ export default function Sidebar() { |
| 126 | 140 | } |
| 127 | 141 | return map; |
| 128 | 142 | }, [state.threads]); |
| 143 | const projectCwdById = useMemo( | |
| 144 | () => new Map(state.projects.map((project) => [project.id, project.cwd] as const)), | |
| 145 | [state.projects], | |
| 146 | ); | |
| 147 | const threadGitTargets = useMemo( | |
| 148 | () => | |
| 149 | state.threads.map((thread) => ({ | |
| 150 | threadId: thread.id, | |
| 151 | branch: thread.branch, | |
| 152 | cwd: thread.worktreePath ?? projectCwdById.get(thread.projectId) ?? null, | |
| 153 | })), | |
| 154 | [projectCwdById, state.threads], | |
| 155 | ); | |
| 156 | const threadGitStatusQueries = useQueries({ | |
| 157 | queries: threadGitTargets.map((target) => { | |
| 158 | const base = gitStatusQueryOptions(api, target.cwd); | |
| 159 | return { | |
| 160 | ...base, | |
| 161 | enabled: Boolean(base.enabled && target.branch !== null), | |
| 162 | staleTime: 30_000, | |
| 163 | refetchInterval: 60_000, | |
| 164 | }; | |
| 165 | }), | |
| 166 | }); | |
| 167 | const mergedPrByThreadId = useMemo(() => { | |
| 168 | const map = new Map<string, boolean>(); | |
| 169 | for (let index = 0; index < threadGitTargets.length; index += 1) { | |
| 170 | const target = threadGitTargets[index]; | |
| 171 | if (!target) continue; | |
| 172 | const status = threadGitStatusQueries[index]?.data as GitStatusResult | undefined; | |
| 173 | const branchMatches = | |
| 174 | target.branch !== null && status?.branch !== null && status?.branch === target.branch; | |
| 175 | map.set(target.threadId, branchMatches && status?.openPr === null && status?.mergedPr !== null); | |
| 176 | } | |
| 177 | return map; | |
| 178 | }, [threadGitStatusQueries, threadGitTargets]); | |
| 129 | 179 | |
| 130 | 180 | const handleNewThread = useCallback( |
| 131 | 181 | (projectId: string) => { |
| ... | ... | @@ -426,6 +476,7 @@ export default function Sidebar() { |
| 426 | 476 | const threadStatus = threadStatusPill( |
| 427 | 477 | thread, |
| 428 | 478 | pendingApprovalByThreadId.get(thread.id) === true, |
| 479 | mergedPrByThreadId.get(thread.id) === true, | |
| 429 | 480 | ); |
| 430 | 481 | const terminalStatus = terminalStatusIndicator(thread); |
| 431 | 482 | return ( |
packages/contracts/src/git.test.ts+1| ... | ... | @@ -37,6 +37,7 @@ describe("git contracts", () => { |
| 37 | 37 | aheadCount: 0, |
| 38 | 38 | behindCount: 0, |
| 39 | 39 | openPr: null, |
| 40 | mergedPr: null, | |
| 40 | 41 | }); |
| 41 | 42 | expect(parsed.branch).toBe("feature/git-actions"); |
| 42 | 43 | expect(parsed.hasWorkingTreeChanges).toBe(true); |
packages/contracts/src/git.ts+9| ... | ... | @@ -98,6 +98,15 @@ export const gitStatusResultSchema = z.object({ |
| 98 | 98 | headBranch: z.string().min(1), |
| 99 | 99 | }) |
| 100 | 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(), | |
| 101 | 110 | }); |
| 102 | 111 | |
| 103 | 112 | export const gitStackedActionSchema = z.enum(["commit", "commit_push", "commit_push_pr"]); |