| 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,7 +31,7 @@ export interface TerminalCommandInput { |
| 31 | 31 | ||
| 32 | export type TerminalCommandResult = ProcessRunResult; | 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 | upstreamRef: string | null; | 35 | upstreamRef: string | null; |
| 36 | } | 36 | } |
| 37 | 37 | ||
| ... | @@ -227,6 +227,7 @@ export class GitCoreService { | ... | @@ -227,6 +227,7 @@ export class GitCoreService { |
| 227 | aheadCount: details.aheadCount, | 227 | aheadCount: details.aheadCount, |
| 228 | behindCount: details.behindCount, | 228 | behindCount: details.behindCount, |
| 229 | openPr: null, | 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,7 +169,7 @@ describe("GitManager", () => { |
| 169 | await runGit(repoDir, ["remote", "add", "origin", remoteDir]); | 169 | await runGit(repoDir, ["remote", "add", "origin", remoteDir]); |
| 170 | await runGit(repoDir, ["push", "-u", "origin", "feature/status-open-pr"]); | 170 | await runGit(repoDir, ["push", "-u", "origin", "feature/status-open-pr"]); |
| 171 | 171 | ||
| 172 | const { runner } = createRunnerWithFakeGh({ | 172 | const { runner, ghCalls } = createRunnerWithFakeGh({ |
| 173 | prListSequence: [ | 173 | prListSequence: [ |
| 174 | JSON.stringify([ | 174 | JSON.stringify([ |
| 175 | { | 175 | { |
| ... | @@ -197,9 +197,52 @@ describe("GitManager", () => { | ... | @@ -197,9 +197,52 @@ describe("GitManager", () => { |
| 197 | baseBranch: "main", | 197 | baseBranch: "main", |
| 198 | headBranch: "feature/status-open-pr", | 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 | const repoDir = makeTempDir("t3code-git-manager-"); | 246 | const repoDir = makeTempDir("t3code-git-manager-"); |
| 204 | await initRepo(repoDir); | 247 | await initRepo(repoDir); |
| 205 | await runGit(repoDir, ["checkout", "-b", "feature/status-no-gh"]); | 248 | await runGit(repoDir, ["checkout", "-b", "feature/status-no-gh"]); |
| ... | @@ -218,6 +261,7 @@ describe("GitManager", () => { | ... | @@ -218,6 +261,7 @@ describe("GitManager", () => { |
| 218 | const status = await manager.status({ cwd: repoDir }); | 261 | const status = await manager.status({ cwd: repoDir }); |
| 219 | expect(status.branch).toBe("feature/status-no-gh"); | 262 | expect(status.branch).toBe("feature/status-no-gh"); |
| 220 | expect(status.openPr).toBeNull(); | 263 | expect(status.openPr).toBeNull(); |
| 264 | expect(status.mergedPr).toBeNull(); | ||
| 221 | }); | 265 | }); |
| 222 | 266 | ||
| 223 | it("creates a commit when working tree is dirty", async () => { | 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,11 +192,17 @@ export class GitManager { |
| 192 | const details = await this.gitCore.statusDetails(input.cwd); | 192 | const details = await this.gitCore.statusDetails(input.cwd); |
| 193 | 193 | ||
| 194 | let openPr: ReturnType<typeof toStatusOpenPr> | null = null; | 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 | try { | 197 | try { |
| 197 | const existing = await this.findOpenPr(input.cwd, details.branch); | 198 | const existing = await this.findOpenPr(input.cwd, details.branch); |
| 198 | if (existing) { | 199 | if (existing) { |
| 199 | openPr = toStatusOpenPr(existing); | 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 | } catch { | 207 | } catch { |
| 202 | // PR lookup is best-effort for status rendering. | 208 | // PR lookup is best-effort for status rendering. |
| ... | @@ -211,6 +217,7 @@ export class GitManager { | ... | @@ -211,6 +217,7 @@ export class GitManager { |
| 211 | aheadCount: details.aheadCount, | 217 | aheadCount: details.aheadCount, |
| 212 | behindCount: details.behindCount, | 218 | behindCount: details.behindCount, |
| 213 | openPr, | 219 | openPr, |
| 220 | mergedPr, | ||
| 214 | }; | 221 | }; |
| 215 | } | 222 | } |
| 216 | 223 | ||
| ... | @@ -408,6 +415,34 @@ export class GitManager { | ... | @@ -408,6 +415,34 @@ export class GitManager { |
| 408 | return parsed[0] ?? null; | 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 | private async resolveBaseBranch( | 446 | private async resolveBaseBranch( |
| 412 | cwd: string, | 447 | cwd: string, |
| 413 | branch: string, | 448 | branch: string, |
apps/server/src/wsServer.test.ts+2| ... | @@ -564,6 +564,7 @@ describe("WebSocket Server", () => { | ... | @@ -564,6 +564,7 @@ describe("WebSocket Server", () => { |
| 564 | aheadCount: 0, | 564 | aheadCount: 0, |
| 565 | behindCount: 0, | 565 | behindCount: 0, |
| 566 | openPr: null, | 566 | openPr: null, |
| 567 | mergedPr: null, | ||
| 567 | }), | 568 | }), |
| 568 | runStackedAction: vi.fn(), | 569 | runStackedAction: vi.fn(), |
| 569 | }; | 570 | }; |
| ... | @@ -593,6 +594,7 @@ describe("WebSocket Server", () => { | ... | @@ -593,6 +594,7 @@ describe("WebSocket Server", () => { |
| 593 | aheadCount: 0, | 594 | aheadCount: 0, |
| 594 | behindCount: 0, | 595 | behindCount: 0, |
| 595 | openPr: null, | 596 | openPr: null, |
| 597 | mergedPr: null, | ||
| 596 | }); | 598 | }); |
| 597 | expect(gitManager.status).toHaveBeenCalledWith({ cwd: "/test" }); | 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,6 +21,7 @@ function status(overrides: Partial<GitStatusResult> = {}): GitStatusResult { |
| 21 | aheadCount: 0, | 21 | aheadCount: 0, |
| 22 | behindCount: 0, | 22 | behindCount: 0, |
| 23 | openPr: null, | 23 | openPr: null, |
| 24 | mergedPr: null, | ||
| 24 | ...overrides, | 25 | ...overrides, |
| 25 | }; | 26 | }; |
| 26 | } | 27 | } |
apps/web/src/components/Sidebar.tsx+55-4| ... | @@ -1,6 +1,7 @@ | ... | @@ -1,6 +1,7 @@ |
| 1 | import { MonitorIcon, MoonIcon, SunIcon, TerminalIcon } from "lucide-react"; | 1 | import { MonitorIcon, MoonIcon, SunIcon, TerminalIcon } from "lucide-react"; |
| 2 | import { useCallback, useEffect, useMemo, useState } from "react"; | 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 | import { isElectron } from "../env"; | 5 | import { isElectron } from "../env"; |
| 5 | import { useTheme } from "../hooks/useTheme"; | 6 | import { useTheme } from "../hooks/useTheme"; |
| 6 | import { DEFAULT_MODEL } from "../model-logic"; | 7 | import { DEFAULT_MODEL } from "../model-logic"; |
| ... | @@ -13,7 +14,7 @@ import { | ... | @@ -13,7 +14,7 @@ import { |
| 13 | type Thread, | 14 | type Thread, |
| 14 | } from "../types"; | 15 | } from "../types"; |
| 15 | import { useNativeApi } from "../hooks/useNativeApi"; | 16 | import { useNativeApi } from "../hooks/useNativeApi"; |
| 16 | import { gitRemoveWorktreeMutationOptions } from "../lib/gitReactQuery"; | 17 | import { gitRemoveWorktreeMutationOptions, gitStatusQueryOptions } from "../lib/gitReactQuery"; |
| 17 | import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup"; | 18 | import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup"; |
| 18 | 19 | ||
| 19 | const THEME_CYCLE = { system: "light", light: "dark", dark: "system" } as const; | 20 | const THEME_CYCLE = { system: "light", light: "dark", dark: "system" } as const; |
| ... | @@ -35,7 +36,7 @@ function inferProjectName(cwd: string): string { | ... | @@ -35,7 +36,7 @@ function inferProjectName(cwd: string): string { |
| 35 | } | 36 | } |
| 36 | 37 | ||
| 37 | interface ThreadStatusPill { | 38 | interface ThreadStatusPill { |
| 38 | label: "Working" | "Connecting" | "Completed" | "Awaiting response"; | 39 | label: "Working" | "Connecting" | "Completed" | "Awaiting response" | "Merged"; |
| 39 | colorClass: string; | 40 | colorClass: string; |
| 40 | dotClass: string; | 41 | dotClass: string; |
| 41 | pulse: boolean; | 42 | pulse: boolean; |
| ... | @@ -58,7 +59,11 @@ function hasUnseenCompletion(thread: Thread): boolean { | ... | @@ -58,7 +59,11 @@ function hasUnseenCompletion(thread: Thread): boolean { |
| 58 | return completedAt > lastVisitedAt; | 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 | if (hasPendingApprovals) { | 67 | if (hasPendingApprovals) { |
| 63 | return { | 68 | return { |
| 64 | label: "Awaiting response", | 69 | label: "Awaiting response", |
| ... | @@ -86,6 +91,15 @@ function threadStatusPill(thread: Thread, hasPendingApprovals: boolean): ThreadS | ... | @@ -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 | if (hasUnseenCompletion(thread)) { | 103 | if (hasUnseenCompletion(thread)) { |
| 90 | return { | 104 | return { |
| 91 | label: "Completed", | 105 | label: "Completed", |
| ... | @@ -126,6 +140,42 @@ export default function Sidebar() { | ... | @@ -126,6 +140,42 @@ export default function Sidebar() { |
| 126 | } | 140 | } |
| 127 | return map; | 141 | return map; |
| 128 | }, [state.threads]); | 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 | const handleNewThread = useCallback( | 180 | const handleNewThread = useCallback( |
| 131 | (projectId: string) => { | 181 | (projectId: string) => { |
| ... | @@ -426,6 +476,7 @@ export default function Sidebar() { | ... | @@ -426,6 +476,7 @@ export default function Sidebar() { |
| 426 | const threadStatus = threadStatusPill( | 476 | const threadStatus = threadStatusPill( |
| 427 | thread, | 477 | thread, |
| 428 | pendingApprovalByThreadId.get(thread.id) === true, | 478 | pendingApprovalByThreadId.get(thread.id) === true, |
| 479 | mergedPrByThreadId.get(thread.id) === true, | ||
| 429 | ); | 480 | ); |
| 430 | const terminalStatus = terminalStatusIndicator(thread); | 481 | const terminalStatus = terminalStatusIndicator(thread); |
| 431 | return ( | 482 | return ( |
packages/contracts/src/git.test.ts+1| ... | @@ -37,6 +37,7 @@ describe("git contracts", () => { | ... | @@ -37,6 +37,7 @@ describe("git contracts", () => { |
| 37 | aheadCount: 0, | 37 | aheadCount: 0, |
| 38 | behindCount: 0, | 38 | behindCount: 0, |
| 39 | openPr: null, | 39 | openPr: null, |
| 40 | mergedPr: null, | ||
| 40 | }); | 41 | }); |
| 41 | expect(parsed.branch).toBe("feature/git-actions"); | 42 | expect(parsed.branch).toBe("feature/git-actions"); |
| 42 | expect(parsed.hasWorkingTreeChanges).toBe(true); | 43 | expect(parsed.hasWorkingTreeChanges).toBe(true); |
packages/contracts/src/git.ts+9| ... | @@ -98,6 +98,15 @@ export const gitStatusResultSchema = z.object({ | ... | @@ -98,6 +98,15 @@ export const gitStatusResultSchema = z.object({ |
| 98 | headBranch: z.string().min(1), | 98 | headBranch: z.string().min(1), |
| 99 | }) | 99 | }) |
| 100 | .nullable(), | 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 | export const gitStackedActionSchema = z.enum(["commit", "commit_push", "commit_push_pr"]); | 112 | export const gitStackedActionSchema = z.enum(["commit", "commit_push", "commit_push_pr"]); |