| 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 | 393 | hasUpstream: details.hasUpstream, |
| 394 | 394 | aheadCount: details.aheadCount, |
| 395 | 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 | 297 | const GitManagerTestLayer = Layer.provideMerge(GitServiceLive, NodeServices.layer); |
| 298 | 298 | |
| 299 | 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 | 301 | Effect.gen(function* () { |
| 302 | 302 | const repoDir = yield* makeTempDir("t3code-git-manager-"); |
| 303 | 303 | yield* initRepo(repoDir); |
| ... | ... | @@ -324,17 +324,103 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { |
| 324 | 324 | |
| 325 | 325 | const status = yield* manager.status({ cwd: repoDir }); |
| 326 | 326 | expect(status.branch).toBe("feature/status-open-pr"); |
| 327 | expect(status.openPr).toEqual({ | |
| 327 | expect(status.pr).toEqual({ | |
| 328 | 328 | number: 13, |
| 329 | 329 | title: "Existing PR", |
| 330 | 330 | url: "https://github.com/pingdotgg/codething-mvp/pull/13", |
| 331 | 331 | baseBranch: "main", |
| 332 | 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 | 424 | Effect.gen(function* () { |
| 339 | 425 | const repoDir = yield* makeTempDir("t3code-git-manager-"); |
| 340 | 426 | yield* initRepo(repoDir); |
| ... | ... | @@ -354,7 +440,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { |
| 354 | 440 | |
| 355 | 441 | const status = yield* manager.status({ cwd: repoDir }); |
| 356 | 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 | 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 | 75 | function gitManagerError(operation: string, detail: string, cause?: unknown): GitManagerError { |
| 20 | 76 | return new GitManagerError({ |
| 21 | 77 | operation, |
| ... | ... | @@ -79,12 +135,13 @@ function extractBranchFromRef(ref: string): string { |
| 79 | 135 | return normalized.slice(firstSlash + 1).trim(); |
| 80 | 136 | } |
| 81 | 137 | |
| 82 | function toStatusOpenPr(pr: OpenPrInfo): { | |
| 138 | function toStatusPr(pr: PullRequestInfo): { | |
| 83 | 139 | number: number; |
| 84 | 140 | title: string; |
| 85 | 141 | url: string; |
| 86 | 142 | baseBranch: string; |
| 87 | 143 | headBranch: string; |
| 144 | state: "open" | "closed" | "merged"; | |
| 88 | 145 | } { |
| 89 | 146 | return { |
| 90 | 147 | number: pr.number, |
| ... | ... | @@ -92,6 +149,7 @@ function toStatusOpenPr(pr: OpenPrInfo): { |
| 92 | 149 | url: pr.url, |
| 93 | 150 | baseBranch: pr.baseRefName, |
| 94 | 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 | 181 | url: first.url, |
| 124 | 182 | baseRefName: first.baseRefName, |
| 125 | 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 | 234 | const resolveBaseBranch = (cwd: string, branch: string, upstreamRef: string | null) => |
| 131 | 235 | Effect.gen(function* () { |
| 132 | 236 | const configured = yield* gitCore.readConfigValue(cwd, `branch.${branch}.gh-merge-base`); |
| ... | ... | @@ -258,10 +362,10 @@ export const makeGitManager = Effect.gen(function* () { |
| 258 | 362 | const status: GitManagerShape["status"] = Effect.fnUntraced(function* (input) { |
| 259 | 363 | const details = yield* gitCore.statusDetails(input.cwd); |
| 260 | 364 | |
| 261 | const openPr = | |
| 262 | details.branch && details.hasUpstream | |
| 263 | ? yield* findOpenPr(input.cwd, details.branch).pipe( | |
| 264 | Effect.map((pr) => (pr ? toStatusOpenPr(pr) : null)), | |
| 365 | const pr = | |
| 366 | details.branch !== null | |
| 367 | ? yield* findLatestPr(input.cwd, details.branch).pipe( | |
| 368 | Effect.map((latest) => (latest ? toStatusPr(latest) : null)), | |
| 265 | 369 | Effect.catch(() => Effect.succeed(null)), |
| 266 | 370 | ) |
| 267 | 371 | : null; |
| ... | ... | @@ -273,7 +377,7 @@ export const makeGitManager = Effect.gen(function* () { |
| 273 | 377 | hasUpstream: details.hasUpstream, |
| 274 | 378 | aheadCount: details.aheadCount, |
| 275 | 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 | 24 | |
| 25 | 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 | 28 | upstreamRef: string | null; |
| 29 | 29 | } |
| 30 | 30 |
apps/server/src/wsServer.test.ts+1-1| ... | ... | @@ -1521,7 +1521,7 @@ describe("WebSocket Server", () => { |
| 1521 | 1521 | hasUpstream: false, |
| 1522 | 1522 | aheadCount: 0, |
| 1523 | 1523 | behindCount: 0, |
| 1524 | openPr: null, | |
| 1524 | pr: null, | |
| 1525 | 1525 | }; |
| 1526 | 1526 | |
| 1527 | 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 | 20 | hasUpstream: true, |
| 21 | 21 | aheadCount: 0, |
| 22 | 22 | behindCount: 0, |
| 23 | openPr: null, | |
| 23 | pr: null, | |
| 24 | 24 | ...overrides, |
| 25 | 25 | }; |
| 26 | 26 | } |
| ... | ... | @@ -29,12 +29,13 @@ describe("when: branch is clean and has an open PR", () => { |
| 29 | 29 | it("resolveQuickAction opens the existing PR", () => { |
| 30 | 30 | const quick = resolveQuickAction( |
| 31 | 31 | status({ |
| 32 | openPr: { | |
| 32 | pr: { | |
| 33 | 33 | number: 10, |
| 34 | 34 | title: "Open PR", |
| 35 | 35 | url: "https://example.com/pr/10", |
| 36 | 36 | baseBranch: "main", |
| 37 | 37 | headBranch: "feature/test", |
| 38 | state: "open", | |
| 38 | 39 | }, |
| 39 | 40 | }), |
| 40 | 41 | false, |
| ... | ... | @@ -45,12 +46,13 @@ describe("when: branch is clean and has an open PR", () => { |
| 45 | 46 | it("buildMenuItems disables commit/push and enables open PR", () => { |
| 46 | 47 | const items = buildMenuItems( |
| 47 | 48 | status({ |
| 48 | openPr: { | |
| 49 | pr: { | |
| 49 | 50 | number: 11, |
| 50 | 51 | title: "Existing PR", |
| 51 | 52 | url: "https://example.com/pr/11", |
| 52 | 53 | baseBranch: "main", |
| 53 | 54 | headBranch: "feature/test", |
| 55 | state: "open", | |
| 54 | 56 | }, |
| 55 | 57 | }), |
| 56 | 58 | false, |
| ... | ... | @@ -147,12 +149,13 @@ describe("when: branch is clean, ahead, and has an open PR", () => { |
| 147 | 149 | const quick = resolveQuickAction( |
| 148 | 150 | status({ |
| 149 | 151 | aheadCount: 3, |
| 150 | openPr: { | |
| 152 | pr: { | |
| 151 | 153 | number: 13, |
| 152 | 154 | title: "Open PR", |
| 153 | 155 | url: "https://example.com/pr/13", |
| 154 | 156 | baseBranch: "main", |
| 155 | 157 | headBranch: "feature/test", |
| 158 | state: "open", | |
| 156 | 159 | }, |
| 157 | 160 | }), |
| 158 | 161 | false, |
| ... | ... | @@ -164,12 +167,13 @@ describe("when: branch is clean, ahead, and has an open PR", () => { |
| 164 | 167 | const items = buildMenuItems( |
| 165 | 168 | status({ |
| 166 | 169 | aheadCount: 2, |
| 167 | openPr: { | |
| 170 | pr: { | |
| 168 | 171 | number: 12, |
| 169 | 172 | title: "Existing PR", |
| 170 | 173 | url: "https://example.com/pr/12", |
| 171 | 174 | baseBranch: "main", |
| 172 | 175 | headBranch: "feature/test", |
| 176 | state: "open", | |
| 173 | 177 | }, |
| 174 | 178 | }), |
| 175 | 179 | false, |
| ... | ... | @@ -204,7 +208,7 @@ describe("when: branch is clean, ahead, and has an open PR", () => { |
| 204 | 208 | |
| 205 | 209 | describe("when: branch is clean, ahead, and has no open PR", () => { |
| 206 | 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 | 212 | assert.deepInclude(quick, { |
| 209 | 213 | kind: "run_action", |
| 210 | 214 | action: "commit_push_pr", |
| ... | ... | @@ -213,7 +217,7 @@ describe("when: branch is clean, ahead, and has no open PR", () => { |
| 213 | 217 | }); |
| 214 | 218 | |
| 215 | 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 | 221 | assert.deepEqual(items, [ |
| 218 | 222 | { |
| 219 | 223 | id: "commit", |
| ... | ... | @@ -246,14 +250,14 @@ describe("when: branch is clean, ahead, and has no open PR", () => { |
| 246 | 250 | describe("when: branch is clean, up to date, and has no open PR", () => { |
| 247 | 251 | it("resolveQuickAction returns disabled no-action state", () => { |
| 248 | 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 | 254 | false, |
| 251 | 255 | ); |
| 252 | 256 | assert.deepInclude(quick, { kind: "show_hint", label: "Commit", disabled: true }); |
| 253 | 257 | }); |
| 254 | 258 | |
| 255 | 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 | 261 | assert.deepEqual(items, [ |
| 258 | 262 | { |
| 259 | 263 | id: "commit", |
| ... | ... | @@ -290,7 +294,7 @@ describe("when: branch is behind upstream", () => { |
| 290 | 294 | }); |
| 291 | 295 | |
| 292 | 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 | 298 | assert.deepEqual(items, [ |
| 295 | 299 | { |
| 296 | 300 | id: "commit", |
| ... | ... | @@ -346,12 +350,13 @@ describe("when: working tree has local changes", () => { |
| 346 | 350 | const quick = resolveQuickAction( |
| 347 | 351 | status({ |
| 348 | 352 | hasWorkingTreeChanges: true, |
| 349 | openPr: { | |
| 353 | pr: { | |
| 350 | 354 | number: 16, |
| 351 | 355 | title: "Existing PR", |
| 352 | 356 | url: "https://example.com/pr/16", |
| 353 | 357 | baseBranch: "main", |
| 354 | 358 | headBranch: "feature/test", |
| 359 | state: "open", | |
| 355 | 360 | }, |
| 356 | 361 | }), |
| 357 | 362 | false, |
| ... | ... | @@ -411,7 +416,7 @@ describe("when: on default branch without open PR", () => { |
| 411 | 416 | |
| 412 | 417 | it("resolveQuickAction returns push when branch is ahead", () => { |
| 413 | 418 | const quick = resolveQuickAction( |
| 414 | status({ branch: "main", aheadCount: 2, openPr: null }), | |
| 419 | status({ branch: "main", aheadCount: 2, pr: null }), | |
| 415 | 420 | false, |
| 416 | 421 | true, |
| 417 | 422 | ); |
| ... | ... | @@ -511,7 +516,7 @@ describe("when: HEAD is detached and there are no local changes", () => { |
| 511 | 516 | describe("when: branch has no upstream configured", () => { |
| 512 | 517 | it("resolveQuickAction returns disabled commit state when clean and no commits are ahead", () => { |
| 513 | 518 | const quick = resolveQuickAction( |
| 514 | status({ hasUpstream: false, openPr: null, aheadCount: 0 }), | |
| 519 | status({ hasUpstream: false, pr: null, aheadCount: 0 }), | |
| 515 | 520 | false, |
| 516 | 521 | ); |
| 517 | 522 | assert.deepInclude(quick, { |
| ... | ... | @@ -526,12 +531,13 @@ describe("when: branch has no upstream configured", () => { |
| 526 | 531 | status({ |
| 527 | 532 | hasUpstream: false, |
| 528 | 533 | aheadCount: 0, |
| 529 | openPr: { | |
| 534 | pr: { | |
| 530 | 535 | number: 14, |
| 531 | 536 | title: "Existing PR", |
| 532 | 537 | url: "https://example.com/pr/14", |
| 533 | 538 | baseBranch: "main", |
| 534 | 539 | headBranch: "feature/test", |
| 540 | state: "open", | |
| 535 | 541 | }, |
| 536 | 542 | }), |
| 537 | 543 | false, |
| ... | ... | @@ -548,12 +554,13 @@ describe("when: branch has no upstream configured", () => { |
| 548 | 554 | status({ |
| 549 | 555 | hasUpstream: false, |
| 550 | 556 | aheadCount: 1, |
| 551 | openPr: { | |
| 557 | pr: { | |
| 552 | 558 | number: 15, |
| 553 | 559 | title: "Existing PR", |
| 554 | 560 | url: "https://example.com/pr/15", |
| 555 | 561 | baseBranch: "main", |
| 556 | 562 | headBranch: "feature/test", |
| 563 | state: "open", | |
| 557 | 564 | }, |
| 558 | 565 | }), |
| 559 | 566 | false, |
| ... | ... | @@ -568,7 +575,7 @@ describe("when: branch has no upstream configured", () => { |
| 568 | 575 | |
| 569 | 576 | it("buildMenuItems disables push and create PR when no commits are ahead", () => { |
| 570 | 577 | const items = buildMenuItems( |
| 571 | status({ hasUpstream: false, openPr: null, aheadCount: 0 }), | |
| 578 | status({ hasUpstream: false, pr: null, aheadCount: 0 }), | |
| 572 | 579 | false, |
| 573 | 580 | ); |
| 574 | 581 | assert.deepEqual(items, [ |
apps/web/src/components/GitActionsControl.logic.ts+2-2| ... | ... | @@ -108,7 +108,7 @@ export function buildMenuItems( |
| 108 | 108 | |
| 109 | 109 | const hasBranch = gitStatus.branch !== null; |
| 110 | 110 | const hasChanges = gitStatus.hasWorkingTreeChanges; |
| 111 | const hasOpenPr = gitStatus.openPr !== null; | |
| 111 | const hasOpenPr = gitStatus.pr?.state === "open"; | |
| 112 | 112 | const isBehind = gitStatus.behindCount > 0; |
| 113 | 113 | const canCommit = !isBusy && hasChanges; |
| 114 | 114 | const canPush = !isBusy && hasBranch && !hasChanges && !isBehind && gitStatus.aheadCount > 0; |
| ... | ... | @@ -178,7 +178,7 @@ export function resolveQuickAction( |
| 178 | 178 | |
| 179 | 179 | const hasBranch = gitStatus.branch !== null; |
| 180 | 180 | const hasChanges = gitStatus.hasWorkingTreeChanges; |
| 181 | const hasOpenPr = gitStatus.openPr !== null; | |
| 181 | const hasOpenPr = gitStatus.pr?.state === "open"; | |
| 182 | 182 | const isAhead = gitStatus.aheadCount > 0; |
| 183 | 183 | const isBehind = gitStatus.behindCount > 0; |
| 184 | 184 | const isDiverged = isAhead && isBehind; |
apps/web/src/components/GitActionsControl.tsx+7-5| ... | ... | @@ -58,7 +58,7 @@ function getMenuActionDisabledReason( |
| 58 | 58 | |
| 59 | 59 | const hasBranch = gitStatus.branch !== null; |
| 60 | 60 | const hasChanges = gitStatus.hasWorkingTreeChanges; |
| 61 | const hasOpenPr = gitStatus.openPr !== null; | |
| 61 | const hasOpenPr = gitStatus.pr?.state === "open"; | |
| 62 | 62 | const isAhead = gitStatus.aheadCount > 0; |
| 63 | 63 | const isBehind = gitStatus.behindCount > 0; |
| 64 | 64 | |
| ... | ... | @@ -219,7 +219,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions |
| 219 | 219 | }); |
| 220 | 220 | return; |
| 221 | 221 | } |
| 222 | const prUrl = gitStatusForActions?.openPr?.url ?? null; | |
| 222 | const prUrl = gitStatusForActions?.pr?.state === "open" ? gitStatusForActions.pr.url : null; | |
| 223 | 223 | if (!prUrl) { |
| 224 | 224 | toastManager.add({ |
| 225 | 225 | type: "error", |
| ... | ... | @@ -236,7 +236,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions |
| 236 | 236 | data: threadToastData, |
| 237 | 237 | }); |
| 238 | 238 | }); |
| 239 | }, [gitStatusForActions?.openPr?.url, threadToastData]); | |
| 239 | }, [gitStatusForActions?.pr?.state, gitStatusForActions?.pr?.url, threadToastData]); | |
| 240 | 240 | const runGitActionWithToast = useCallback( |
| 241 | 241 | async ({ |
| 242 | 242 | action, |
| ... | ... | @@ -295,7 +295,9 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions |
| 295 | 295 | stopProgressUpdates(); |
| 296 | 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 | 301 | const shouldOfferPushCta = action === "commit" && result.commit.status === "created"; |
| 300 | 302 | const shouldOfferOpenPrCta = |
| 301 | 303 | (action === "commit_push" || action === "commit_push_pr") && !!prUrl && !isDefaultBranch; |
| ... | ... | @@ -365,7 +367,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions |
| 365 | 367 | [ |
| 366 | 368 | gitStatusForActions?.branch, |
| 367 | 369 | gitStatusForActions?.hasWorkingTreeChanges, |
| 368 | gitStatusForActions?.openPr?.url, | |
| 370 | gitStatusForActions?.pr, | |
| 369 | 371 | isDefaultBranch, |
| 370 | 372 | maybeConfirmPushToDefaultBranch, |
| 371 | 373 | runImmediateGitActionMutation, |
apps/web/src/components/Sidebar.tsx+96-3| ... | ... | @@ -1,12 +1,13 @@ |
| 1 | import { ChevronRightIcon, FolderIcon, TerminalIcon } from "lucide-react"; | |
| 1 | import { ChevronRightIcon, FolderIcon, GitPullRequestIcon, TerminalIcon } from "lucide-react"; | |
| 2 | 2 | import { useCallback, useEffect, useMemo, useState } from "react"; |
| 3 | 3 | import { |
| 4 | 4 | DEFAULT_MODEL, |
| 5 | 5 | ProjectId, |
| 6 | 6 | ThreadId, |
| 7 | type GitStatusResult, | |
| 7 | 8 | type ResolvedKeybindingsConfig, |
| 8 | 9 | } from "@t3tools/contracts"; |
| 9 | import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; | |
| 10 | import { useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/react-query"; | |
| 10 | 11 | import { useNavigate, useParams } from "@tanstack/react-router"; |
| 11 | 12 | import { useAppSettings } from "../appSettings"; |
| 12 | 13 | import { isElectron } from "../env"; |
| ... | ... | @@ -16,7 +17,7 @@ import { useStore } from "../store"; |
| 16 | 17 | import { isChatNewLocalShortcut, isChatNewShortcut } from "../keybindings"; |
| 17 | 18 | import { type Thread } from "../types"; |
| 18 | 19 | import { derivePendingApprovals } from "../session-logic"; |
| 19 | import { gitRemoveWorktreeMutationOptions } from "../lib/gitReactQuery"; | |
| 20 | import { gitRemoveWorktreeMutationOptions, gitStatusQueryOptions } from "../lib/gitReactQuery"; | |
| 20 | 21 | import { serverConfigQueryOptions } from "../lib/serverReactQuery"; |
| 21 | 22 | import { readNativeApi } from "../nativeApi"; |
| 22 | 23 | import { toastManager } from "./ui/toast"; |
| ... | ... | @@ -63,6 +64,13 @@ interface TerminalStatusIndicator { |
| 63 | 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 | 74 | function hasUnseenCompletion(thread: Thread): boolean { |
| 67 | 75 | if (!thread.latestTurn?.completedAt) return false; |
| 68 | 76 | const completedAt = Date.parse(thread.latestTurn.completedAt); |
| ... | ... | @@ -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 | 158 | function T3Wordmark() { |
| 129 | 159 | return ( |
| 130 | 160 | <svg |
| ... | ... | @@ -210,6 +240,58 @@ export default function Sidebar() { |
| 210 | 240 | } |
| 211 | 241 | return map; |
| 212 | 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 | 296 | const handleNewThread = useCallback( |
| 215 | 297 | ( |
| ... | ... | @@ -632,6 +714,7 @@ export default function Sidebar() { |
| 632 | 714 | thread, |
| 633 | 715 | pendingApprovalByThreadId.get(thread.id) === true, |
| 634 | 716 | ); |
| 717 | const prStatus = prStatusIndicator(prStateByThreadId.get(thread.id) ?? null); | |
| 635 | 718 | const terminalStatus = terminalStatusIndicator(thread); |
| 636 | 719 | |
| 637 | 720 | return ( |
| ... | ... | @@ -677,6 +760,16 @@ export default function Sidebar() { |
| 677 | 760 | </span> |
| 678 | 761 | </div> |
| 679 | 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 | 773 | {terminalStatus && ( |
| 681 | 774 | <span |
| 682 | 775 | role="img" |
packages/contracts/src/git.ts+13-9| ... | ... | @@ -18,6 +18,8 @@ export const GitPrStepStatus = Schema.Literals([ |
| 18 | 18 | "opened_existing", |
| 19 | 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 | 24 | export const GitBranch = Schema.Struct({ |
| 23 | 25 | name: TrimmedNonEmptyStringSchema, |
| ... | ... | @@ -91,6 +93,16 @@ export type GitInitInput = typeof GitInitInput.Type; |
| 91 | 93 | |
| 92 | 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 | 106 | export const GitStatusResult = Schema.Struct({ |
| 95 | 107 | branch: TrimmedNonEmptyStringSchema.pipe(Schema.NullOr), |
| 96 | 108 | hasWorkingTreeChanges: Schema.Boolean, |
| ... | ... | @@ -108,15 +120,7 @@ export const GitStatusResult = Schema.Struct({ |
| 108 | 120 | hasUpstream: Schema.Boolean, |
| 109 | 121 | aheadCount: NonNegativeInt, |
| 110 | 122 | behindCount: NonNegativeInt, |
| 111 | openPr: Schema.NullOr( | |
| 112 | Schema.Struct({ | |
| 113 | number: PositiveInt, | |
| 114 | title: TrimmedNonEmptyStringSchema, | |
| 115 | url: Schema.String, | |
| 116 | baseBranch: TrimmedNonEmptyStringSchema, | |
| 117 | headBranch: TrimmedNonEmptyStringSchema, | |
| 118 | }), | |
| 119 | ), | |
| 123 | pr: Schema.NullOr(GitStatusPr), | |
| 120 | 124 | }); |
| 121 | 125 | export type GitStatusResult = typeof GitStatusResult.Type; |
| 122 | 126 |