| author | |
| committer | |
| log | b500106fde0f90344174cf113d439777c6f3b266 |
| tree | f9413d88322916a8456614e8e89e07d52ea2b435 |
| parent | d48b42c0174f7ed26de8fdd1b0f4f75c82c71fe7 |
| parent | 7f2a95fe14c18b3feba039df6b88c6c47a846d04 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | ); |
| 399 | 399 |
apps/server/src/git/Layers/GitManager.test.ts+90-4| ... | @@ -297,7 +297,7 @@ function makeManager(input?: { | ... | @@ -297,7 +297,7 @@ function makeManager(input?: { |
| 297 | const GitManagerTestLayer = Layer.provideMerge(GitServiceLive, NodeServices.layer); | 297 | const GitManagerTestLayer = Layer.provideMerge(GitServiceLive, NodeServices.layer); |
| 298 | 298 | ||
| 299 | it.layer(GitManagerTestLayer)("GitManager", (it) => { | 299 | it.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) => { |
| 324 | 324 | ||
| 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 | ); |
| 336 | 337 | ||
| 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) => { |
| 354 | 440 | ||
| 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 | ); |
| 360 | 446 |
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 | } |
| 18 | 18 | ||
| 19 | interface PullRequestInfo extends OpenPrInfo { | ||
| 20 | state: "open" | "closed" | "merged"; | ||
| 21 | updatedAt: string | null; | ||
| 22 | } | ||
| 23 | |||
| 24 | function 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 | |||
| 19 | function gitManagerError(operation: string, detail: string, cause?: unknown): GitManagerError { | 75 | function 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 | } |
| 81 | 137 | ||
| 82 | function toStatusOpenPr(pr: OpenPrInfo): { | 138 | function 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 | } |
| 97 | 155 | ||
| ... | @@ -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 | ); |
| 129 | 189 | ||
| 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); |
| 260 | 364 | ||
| 261 | const openPr = | 365 | const pr = |
| 262 | details.branch && details.hasUpstream | 366 | 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 | }); |
| 279 | 383 |
apps/server/src/git/Services/GitCore.ts+1-1| ... | @@ -24,7 +24,7 @@ import type { | ... | @@ -24,7 +24,7 @@ import type { |
| 24 | 24 | ||
| 25 | import type { GitCommandError } from "../Errors.ts"; | 25 | import type { GitCommandError } from "../Errors.ts"; |
| 26 | 26 | ||
| 27 | export interface GitStatusDetails extends Omit<GitStatusResult, "openPr"> { | 27 | export interface GitStatusDetails extends Omit<GitStatusResult, "pr"> { |
| 28 | upstreamRef: string | null; | 28 | upstreamRef: string | null; |
| 29 | } | 29 | } |
| 30 | 30 |
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 | }; |
| 1526 | 1526 | ||
| 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", () => { |
| 204 | 208 | ||
| 205 | describe("when: branch is clean, ahead, and has no open PR", () => { | 209 | describe("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 | }); |
| 214 | 218 | ||
| 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", () => { |
| 246 | describe("when: branch is clean, up to date, and has no open PR", () => { | 250 | describe("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 | }); |
| 254 | 258 | ||
| 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 | }); |
| 291 | 295 | ||
| 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", () => { |
| 411 | 416 | ||
| 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", () => { |
| 511 | describe("when: branch has no upstream configured", () => { | 516 | describe("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", () => { |
| 568 | 575 | ||
| 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( |
| 108 | 108 | ||
| 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( |
| 178 | 178 | ||
| 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( |
| 58 | 58 | ||
| 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; |
| 64 | 64 | ||
| ... | @@ -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); |
| 297 | 297 | ||
| 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 @@ |
| 1 | import { ChevronRightIcon, FolderIcon, TerminalIcon } from "lucide-react"; | 1 | import { ChevronRightIcon, FolderIcon, GitPullRequestIcon, TerminalIcon } from "lucide-react"; |
| 2 | import { useCallback, useEffect, useMemo, useState } from "react"; | 2 | import { useCallback, useEffect, useMemo, useState } from "react"; |
| 3 | import { | 3 | import { |
| 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"; |
| 9 | import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; | 10 | import { useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/react-query"; |
| 10 | import { useNavigate, useParams } from "@tanstack/react-router"; | 11 | import { useNavigate, useParams } from "@tanstack/react-router"; |
| 11 | import { useAppSettings } from "../appSettings"; | 12 | import { useAppSettings } from "../appSettings"; |
| 12 | import { isElectron } from "../env"; | 13 | import { isElectron } from "../env"; |
| ... | @@ -16,7 +17,7 @@ import { useStore } from "../store"; | ... | @@ -16,7 +17,7 @@ import { useStore } from "../store"; |
| 16 | import { isChatNewLocalShortcut, isChatNewShortcut } from "../keybindings"; | 17 | import { isChatNewLocalShortcut, isChatNewShortcut } from "../keybindings"; |
| 17 | import { type Thread } from "../types"; | 18 | import { type Thread } from "../types"; |
| 18 | import { derivePendingApprovals } from "../session-logic"; | 19 | import { derivePendingApprovals } from "../session-logic"; |
| 19 | import { gitRemoveWorktreeMutationOptions } from "../lib/gitReactQuery"; | 20 | import { gitRemoveWorktreeMutationOptions, gitStatusQueryOptions } from "../lib/gitReactQuery"; |
| 20 | import { serverConfigQueryOptions } from "../lib/serverReactQuery"; | 21 | import { serverConfigQueryOptions } from "../lib/serverReactQuery"; |
| 21 | import { readNativeApi } from "../nativeApi"; | 22 | import { readNativeApi } from "../nativeApi"; |
| 22 | import { toastManager } from "./ui/toast"; | 23 | import { toastManager } from "./ui/toast"; |
| ... | @@ -63,6 +64,13 @@ interface TerminalStatusIndicator { | ... | @@ -63,6 +64,13 @@ interface TerminalStatusIndicator { |
| 63 | pulse: boolean; | 64 | pulse: boolean; |
| 64 | } | 65 | } |
| 65 | 66 | ||
| 67 | interface PrStatusIndicator { | ||
| 68 | label: "PR open" | "PR closed" | "PR merged"; | ||
| 69 | colorClass: string; | ||
| 70 | } | ||
| 71 | |||
| 72 | type ThreadPrState = NonNullable<GitStatusResult["pr"]>["state"] | null; | ||
| 73 | |||
| 66 | function hasUnseenCompletion(thread: Thread): boolean { | 74 | function 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 | } |
| 127 | 135 | ||
| 136 | function 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 | |||
| 128 | function T3Wordmark() { | 158 | function T3Wordmark() { |
| 129 | return ( | 159 | return ( |
| 130 | <svg | 160 | <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]); | ||
| 213 | 295 | ||
| 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); |
| 636 | 719 | ||
| 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 | <span | 774 | <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 | ]); |
| 21 | export const GitStatusPrState = Schema.Literals(["open", "closed", "merged"]); | ||
| 22 | export type GitStatusPrState = typeof GitStatusPrState.Type; | ||
| 21 | 23 | ||
| 22 | export const GitBranch = Schema.Struct({ | 24 | export 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; |
| 91 | 93 | ||
| 92 | // RPC Results | 94 | // RPC Results |
| 93 | 95 | ||
| 96 | export const GitStatusPr = Schema.Struct({ | ||
| 97 | number: PositiveInt, | ||
| 98 | title: TrimmedNonEmptyStringSchema, | ||
| 99 | url: Schema.String, | ||
| 100 | baseBranch: TrimmedNonEmptyStringSchema, | ||
| 101 | headBranch: TrimmedNonEmptyStringSchema, | ||
| 102 | state: GitStatusPrState, | ||
| 103 | }); | ||
| 104 | export type GitStatusPr = typeof GitStatusPr.Type; | ||
| 105 | |||
| 94 | export const GitStatusResult = Schema.Struct({ | 106 | export 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 | }); |
| 121 | export type GitStatusResult = typeof GitStatusResult.Type; | 125 | export type GitStatusResult = typeof GitStatusResult.Type; |
| 122 | 126 |