| author | |
| committer | |
| log | de37964db26bd3260d253f138bfde2ac60fe254e |
| tree | 4af30ad8428fb97e56286dfe0b173d4df018b13e |
| parent | f0401c629038100edafc496aca79fdc53d1d4db8 |
| signature | Signed by PGP key B5690EEEBB952194 |
53 files changed, 3104 insertions(+), 464 deletions(-)
apps/mobile/src/features/threads/thread-work-log.tsx+14-3| ... | @@ -76,7 +76,7 @@ export const THREAD_DISCLOSURE_TRANSITION_MS = 180; | ... | @@ -76,7 +76,7 @@ export const THREAD_DISCLOSURE_TRANSITION_MS = 180; |
| 76 | const WORK_LOG_LAYOUT_TRANSITION = LinearTransition.duration(THREAD_DISCLOSURE_TRANSITION_MS); | 76 | const WORK_LOG_LAYOUT_TRANSITION = LinearTransition.duration(THREAD_DISCLOSURE_TRANSITION_MS); |
| 77 | const WORK_LOG_DETAIL_ENTER_TRANSITION = FadeIn.duration(140); | 77 | const WORK_LOG_DETAIL_ENTER_TRANSITION = FadeIn.duration(140); |
| 78 | const WORK_LOG_DETAIL_EXIT_TRANSITION = FadeOut.duration(120); | 78 | const WORK_LOG_DETAIL_EXIT_TRANSITION = FadeOut.duration(120); |
| 79 | type WorkContentIcon = AppSymbolName | "browser" | "t3-code"; | 79 | type WorkContentIcon = AppSymbolName | "browser" | "t3-code" | "pull-request"; |
| 80 | 80 | ||
| 81 | function WorkLogIcon(props: { | 81 | function WorkLogIcon(props: { |
| 82 | readonly icon: WorkContentIcon; | 82 | readonly icon: WorkContentIcon; |
| ... | @@ -92,7 +92,13 @@ function WorkLogIcon(props: { | ... | @@ -92,7 +92,13 @@ function WorkLogIcon(props: { |
| 92 | } | 92 | } |
| 93 | return ( | 93 | return ( |
| 94 | <SymbolView | 94 | <SymbolView |
| 95 | name={props.icon === "browser" ? { ios: "globe", android: "public" } : props.icon} | 95 | name={ |
| 96 | props.icon === "pull-request" | ||
| 97 | ? "arrow.triangle.pull" | ||
| 98 | : props.icon === "browser" | ||
| 99 | ? { ios: "globe", android: "public" } | ||
| 100 | : props.icon | ||
| 101 | } | ||
| 96 | size={14} | 102 | size={14} |
| 97 | weight="medium" | 103 | weight="medium" |
| 98 | {...(colorClassName ? { tintColorClassName: colorClassName } : { tintColor: props.color })} | 104 | {...(colorClassName ? { tintColorClassName: colorClassName } : { tintColor: props.color })} |
| ... | @@ -893,7 +899,7 @@ export function ThreadWorkGroupToggle(props: { | ... | @@ -893,7 +899,7 @@ export function ThreadWorkGroupToggle(props: { |
| 893 | readonly iconSubtleColor: import("react-native").ColorValue; | 899 | readonly iconSubtleColor: import("react-native").ColorValue; |
| 894 | readonly summary: string; | 900 | readonly summary: string; |
| 895 | readonly summaryKind: ToolGroupSummaryKind; | 901 | readonly summaryKind: ToolGroupSummaryKind; |
| 896 | readonly summaryToolIcon?: "browser" | "t3-code"; | 902 | readonly summaryToolIcon?: "browser" | "t3-code" | "pull-request"; |
| 897 | readonly themeAppearance: "light" | "dark"; | 903 | readonly themeAppearance: "light" | "dark"; |
| 898 | readonly toolSurface?: import("@t3tools/contracts").ToolActivitySurface; | 904 | readonly toolSurface?: import("@t3tools/contracts").ToolActivitySurface; |
| 899 | readonly toolIcon?: ToolActivityIcon; | 905 | readonly toolIcon?: ToolActivityIcon; |
| ... | @@ -1231,6 +1237,11 @@ function ToolActivityImage(props: { | ... | @@ -1231,6 +1237,11 @@ function ToolActivityImage(props: { |
| 1231 | 1237 | ||
| 1232 | function toolGroupSummarySymbolName(kind: ToolGroupSummaryKind): AppSymbolName { | 1238 | function toolGroupSummarySymbolName(kind: ToolGroupSummaryKind): AppSymbolName { |
| 1233 | switch (kind) { | 1239 | switch (kind) { |
| 1240 | case "pull-request": | ||
| 1241 | case "link-pr": | ||
| 1242 | case "unlink-pr": | ||
| 1243 | case "list-prs": | ||
| 1244 | return "arrow.triangle.pull"; | ||
| 1234 | case "read": | 1245 | case "read": |
| 1235 | return { ios: "eye", android: "visibility" }; | 1246 | return { ios: "eye", android: "visibility" }; |
| 1236 | case "edit": | 1247 | case "edit": |
apps/mobile/src/lib/threadActivity.ts+1-1| ... | @@ -167,7 +167,7 @@ export type ThreadFeedEntry = | ... | @@ -167,7 +167,7 @@ export type ThreadFeedEntry = |
| 167 | readonly summaryKind: ToolGroupSummaryKind; | 167 | readonly summaryKind: ToolGroupSummaryKind; |
| 168 | readonly toolSurface?: WorkLogEntry["toolSurface"]; | 168 | readonly toolSurface?: WorkLogEntry["toolSurface"]; |
| 169 | readonly toolIcon?: WorkLogEntry["toolIcon"]; | 169 | readonly toolIcon?: WorkLogEntry["toolIcon"]; |
| 170 | readonly summaryToolIcon?: "browser" | "t3-code"; | 170 | readonly summaryToolIcon?: "browser" | "t3-code" | "pull-request"; |
| 171 | readonly hasFailure: boolean; | 171 | readonly hasFailure: boolean; |
| 172 | readonly live: boolean; | 172 | readonly live: boolean; |
| 173 | readonly shimmer: boolean; | 173 | readonly shimmer: boolean; |
apps/server/src/environment/ServerEnvironment.ts+1| ... | @@ -231,6 +231,7 @@ export const make = Effect.gen(function* () { | ... | @@ -231,6 +231,7 @@ export const make = Effect.gen(function* () { |
| 231 | threadActiveReorder: true, | 231 | threadActiveReorder: true, |
| 232 | threadTitleRegeneration: true, | 232 | threadTitleRegeneration: true, |
| 233 | threadPullRequests: true, | 233 | threadPullRequests: true, |
| 234 | pullRequestStackActions: true, | ||
| 234 | threadPullRequestLinking: true, | 235 | threadPullRequestLinking: true, |
| 235 | environmentIcon: true, | 236 | environmentIcon: true, |
| 236 | ...(serverSelfUpdate === null ? {} : { serverSelfUpdate }), | 237 | ...(serverSelfUpdate === null ? {} : { serverSelfUpdate }), |
apps/server/src/orchestration/PullRequestSyncReactor.test.ts+2-1| ... | @@ -178,8 +178,9 @@ const makeHarness = Effect.fn("makePullRequestSyncHarness")(function* (options: | ... | @@ -178,8 +178,9 @@ const makeHarness = Effect.fn("makePullRequestSyncHarness")(function* (options: |
| 178 | return yield* options.summary?.(input) ?? Effect.succeed(makeSummary(input)); | 178 | return yield* options.summary?.(input) ?? Effect.succeed(makeSummary(input)); |
| 179 | }); | 179 | }); |
| 180 | 180 | ||
| 181 | const stack: PullRequestService["Service"]["stack"] = (input) => | 181 | const stack: PullRequestService["Service"]["stack"] = (input, readOptions) => |
| 182 | Effect.gen(function* () { | 182 | Effect.gen(function* () { |
| 183 | assert.strictEqual(readOptions?.includeDetails, false); | ||
| 183 | yield* Ref.update(stackCalls, (calls) => [...calls, input]); | 184 | yield* Ref.update(stackCalls, (calls) => [...calls, input]); |
| 184 | return yield* options.stack?.(input) ?? Effect.succeed(null); | 185 | return yield* options.stack?.(input) ?? Effect.succeed(null); |
| 185 | }); | 186 | }); |
apps/server/src/orchestration/PullRequestSyncReactor.ts+1-1| ... | @@ -256,7 +256,7 @@ export const make = Effect.gen(function* () { | ... | @@ -256,7 +256,7 @@ export const make = Effect.gen(function* () { |
| 256 | entry.link.snapshot === null || !snapshotFieldsEqual(entry.link.snapshot, fields), | 256 | entry.link.snapshot === null || !snapshotFieldsEqual(entry.link.snapshot, fields), |
| 257 | ); | 257 | ); |
| 258 | const fetchedStack = needsStack | 258 | const fetchedStack = needsStack |
| 259 | ? yield* pullRequests.stack(ref).pipe( | 259 | ? yield* pullRequests.stack(ref, { includeDetails: false }).pipe( |
| 260 | Effect.map((stack) => ({ | 260 | Effect.map((stack) => ({ |
| 261 | stack: stack === null ? null : ({ kind: "native", ...stack } as const), | 261 | stack: stack === null ? null : ({ kind: "native", ...stack } as const), |
| 262 | })), | 262 | })), |
apps/server/src/orchestration/ThreadPullRequestReactor.test.ts+121-92| ... | @@ -1,9 +1,11 @@ | ... | @@ -1,9 +1,11 @@ |
| 1 | import { | 1 | import { |
| 2 | CheckpointRef, | ||
| 2 | EventId, | 3 | EventId, |
| 3 | GitManagerError, | 4 | GitManagerError, |
| 4 | ProjectId, | 5 | ProjectId, |
| 5 | ProviderInstanceId, | 6 | ProviderInstanceId, |
| 6 | ThreadId, | 7 | ThreadId, |
| 8 | TurnId, | ||
| 7 | type OrchestrationCommand, | 9 | type OrchestrationCommand, |
| 8 | type OrchestrationEvent, | 10 | type OrchestrationEvent, |
| 9 | type OrchestrationProjectShell, | 11 | type OrchestrationProjectShell, |
| ... | @@ -311,54 +313,79 @@ describe("ThreadPullRequestReactor", () => { | ... | @@ -311,54 +313,79 @@ describe("ThreadPullRequestReactor", () => { |
| 311 | ), | 313 | ), |
| 312 | ); | 314 | ); |
| 313 | 315 | ||
| 314 | it.effect("refreshes discovery after a turn ends without client demand", () => | 316 | it.effect.each(["session-first", "checkpoint-first"] as const)( |
| 315 | Effect.scoped( | 317 | "refreshes discovery once after a turn ends with %s event ordering", |
| 316 | Effect.gen(function* () { | 318 | (order) => |
| 317 | const current = thread("turn-thread"); | 319 | Effect.scoped( |
| 318 | const detected = yield* Ref.make<GitBranchPullRequest | null>(null); | 320 | Effect.gen(function* () { |
| 319 | const fixture = yield* makeHarness({ | 321 | const current = thread("turn-thread"); |
| 320 | threads: [current], | 322 | const detected = yield* Ref.make<GitBranchPullRequest | null>(null); |
| 321 | branchPullRequest: (_input, options) => | 323 | const fixture = yield* makeHarness({ |
| 322 | options?.refresh | 324 | threads: [current], |
| 323 | ? Ref.set(detected, branchPullRequest()).pipe(Effect.andThen(Ref.get(detected))) | 325 | branchPullRequest: (_input, options) => |
| 324 | : Ref.get(detected), | 326 | options?.refresh |
| 325 | }); | 327 | ? Ref.set(detected, branchPullRequest()).pipe(Effect.andThen(Ref.get(detected))) |
| 326 | yield* Effect.gen(function* () { | 328 | : Ref.get(detected), |
| 327 | const reactor = yield* fixture.start(); | 329 | }); |
| 328 | expect(yield* Ref.get(fixture.commands)).toHaveLength(0); | 330 | yield* Effect.gen(function* () { |
| 329 | yield* fixture.publish({ | 331 | const reactor = yield* fixture.start(); |
| 330 | type: "thread.session-set", | 332 | expect(yield* Ref.get(fixture.commands)).toHaveLength(0); |
| 331 | sequence: 2, | 333 | const sessionEvent: OrchestrationEvent = { |
| 332 | eventId: EventId.make("turn-finished"), | 334 | type: "thread.session-set", |
| 333 | aggregateKind: "thread", | 335 | sequence: 2, |
| 334 | aggregateId: current.id, | 336 | eventId: EventId.make("turn-finished"), |
| 335 | occurredAt: NOW, | 337 | aggregateKind: "thread", |
| 336 | commandId: null, | 338 | aggregateId: current.id, |
| 337 | causationEventId: null, | 339 | occurredAt: NOW, |
| 338 | correlationId: null, | 340 | commandId: null, |
| 339 | metadata: {}, | 341 | causationEventId: null, |
| 340 | payload: { | 342 | correlationId: null, |
| 341 | threadId: current.id, | 343 | metadata: {}, |
| 342 | session: { | 344 | payload: { |
| 343 | threadId: current.id, | 345 | threadId: current.id, |
| 346 | session: { | ||
| 347 | threadId: current.id, | ||
| 348 | status: "ready", | ||
| 349 | providerName: "Codex", | ||
| 350 | runtimeMode: "full-access", | ||
| 351 | activeTurnId: null, | ||
| 352 | lastError: null, | ||
| 353 | updatedAt: NOW, | ||
| 354 | }, | ||
| 355 | }, | ||
| 356 | }; | ||
| 357 | const checkpointEvent: OrchestrationEvent = { | ||
| 358 | ...sessionEvent, | ||
| 359 | type: "thread.turn-diff-completed", | ||
| 360 | sequence: 3, | ||
| 361 | eventId: EventId.make("checkpoint-finished"), | ||
| 362 | payload: { | ||
| 363 | threadId: current.id, | ||
| 364 | turnId: TurnId.make("turn"), | ||
| 365 | checkpointTurnCount: 1, | ||
| 366 | checkpointRef: CheckpointRef.make("checkpoint"), | ||
| 344 | status: "ready", | 367 | status: "ready", |
| 345 | providerName: "Codex", | 368 | files: [], |
| 346 | runtimeMode: "full-access", | 369 | assistantMessageId: null, |
| 347 | activeTurnId: null, | 370 | completedAt: NOW, |
| 348 | lastError: null, | ||
| 349 | updatedAt: NOW, | ||
| 350 | }, | 371 | }, |
| 351 | }, | 372 | }; |
| 352 | }); | 373 | const events = |
| 353 | yield* Queue.take(fixture.reads); | 374 | order === "session-first" |
| 354 | yield* reactor.drain; | 375 | ? [sessionEvent, checkpointEvent] |
| 355 | expect((yield* Ref.get(fixture.commands))[0]?.branchPullRequest).toEqual(reference(42)); | 376 | : [checkpointEvent, sessionEvent]; |
| 356 | expect((yield* Ref.get(fixture.branchCalls)).filter((call) => call.refresh)).toHaveLength( | 377 | for (const event of events) { |
| 357 | 1, | 378 | yield* fixture.publish(event); |
| 358 | ); | 379 | yield* Queue.take(fixture.reads); |
| 359 | }).pipe(Effect.provide(fixture.layer)); | 380 | yield* reactor.drain; |
| 360 | }), | 381 | } |
| 361 | ), | 382 | expect((yield* Ref.get(fixture.commands))[0]?.branchPullRequest).toEqual(reference(42)); |
| 383 | expect( | ||
| 384 | (yield* Ref.get(fixture.branchCalls)).filter((call) => call.refresh), | ||
| 385 | ).toHaveLength(1); | ||
| 386 | }).pipe(Effect.provide(fixture.layer)); | ||
| 387 | }), | ||
| 388 | ), | ||
| 362 | ); | 389 | ); |
| 363 | 390 | ||
| 364 | it.effect("uses live worktrees and falls back to the project for removed worktrees", () => | 391 | it.effect("uses live worktrees and falls back to the project for removed worktrees", () => |
| ... | @@ -455,54 +482,56 @@ describe("ThreadPullRequestReactor", () => { | ... | @@ -455,54 +482,56 @@ describe("ThreadPullRequestReactor", () => { |
| 455 | ), | 482 | ), |
| 456 | ); | 483 | ); |
| 457 | 484 | ||
| 458 | it.effect("retries failed settled backfills and stops querying them after success", () => | 485 | it.effect.each(["settled", null] as const)( |
| 459 | Effect.scoped( | 486 | "retries settled backfills and stops after success with settledOverride %s", |
| 460 | Effect.gen(function* () { | 487 | (settledOverride) => |
| 461 | const online = yield* Ref.make(false); | 488 | Effect.scoped( |
| 462 | const fixture = yield* makeHarness({ | 489 | Effect.gen(function* () { |
| 463 | threads: [ | 490 | const online = yield* Ref.make(false); |
| 464 | thread("backfill", { settledOverride: "settled", settledAt: NOW }), | 491 | const fixture = yield* makeHarness({ |
| 465 | thread("known", { | 492 | threads: [ |
| 466 | branch: "known", | 493 | thread("backfill", { settledOverride, settledAt: NOW }), |
| 467 | settledOverride: "settled", | 494 | thread("known", { |
| 468 | settledAt: NOW, | 495 | branch: "known", |
| 469 | branchPullRequest: reference(1), | 496 | settledOverride, |
| 470 | }), | 497 | settledAt: NOW, |
| 471 | ], | 498 | branchPullRequest: reference(1), |
| 472 | branchPullRequest: ({ cwd }) => | 499 | }), |
| 473 | Ref.get(online).pipe( | 500 | ], |
| 474 | Effect.flatMap((connected) => | 501 | branchPullRequest: ({ cwd }) => |
| 475 | connected | 502 | Ref.get(online).pipe( |
| 476 | ? Effect.succeed(branchPullRequest(42, "merged")) | 503 | Effect.flatMap((connected) => |
| 477 | : Effect.fail( | 504 | connected |
| 478 | new GitManagerError({ | 505 | ? Effect.succeed(branchPullRequest(42, "merged")) |
| 479 | operation: "branchPullRequest", | 506 | : Effect.fail( |
| 480 | cwd, | 507 | new GitManagerError({ |
| 481 | detail: "Offline", | 508 | operation: "branchPullRequest", |
| 482 | }), | 509 | cwd, |
| 483 | ), | 510 | detail: "Offline", |
| 511 | }), | ||
| 512 | ), | ||
| 513 | ), | ||
| 484 | ), | 514 | ), |
| 485 | ), | 515 | }); |
| 486 | }); | 516 | yield* Effect.gen(function* () { |
| 487 | yield* Effect.gen(function* () { | 517 | const reactor = yield* fixture.start(); |
| 488 | const reactor = yield* fixture.start(); | 518 | expect(yield* Ref.get(fixture.commands)).toHaveLength(0); |
| 489 | expect(yield* Ref.get(fixture.commands)).toHaveLength(0); | 519 | yield* Ref.set(online, true); |
| 490 | yield* Ref.set(online, true); | 520 | yield* TestClock.adjust("1 minute"); |
| 491 | yield* TestClock.adjust("1 minute"); | 521 | yield* Queue.take(fixture.reads); |
| 492 | yield* Queue.take(fixture.reads); | 522 | yield* reactor.drain; |
| 493 | yield* reactor.drain; | 523 | expect((yield* Ref.get(fixture.commands))[0]?.threadId).toBe("backfill"); |
| 494 | expect((yield* Ref.get(fixture.commands))[0]?.threadId).toBe("backfill"); | 524 | yield* TestClock.adjust("1 minute"); |
| 495 | yield* TestClock.adjust("1 minute"); | 525 | yield* Queue.take(fixture.reads); |
| 496 | yield* Queue.take(fixture.reads); | 526 | yield* reactor.drain; |
| 497 | yield* reactor.drain; | 527 | expect((yield* Ref.get(fixture.branchCalls)).map((call) => call.branch)).toEqual([ |
| 498 | expect((yield* Ref.get(fixture.branchCalls)).map((call) => call.branch)).toEqual([ | 528 | "feature", |
| 499 | "feature", | 529 | "feature", |
| 500 | "feature", | 530 | "feature", |
| 501 | "feature", | 531 | ]); |
| 502 | ]); | 532 | }).pipe(Effect.provide(fixture.layer)); |
| 503 | }).pipe(Effect.provide(fixture.layer)); | 533 | }), |
| 504 | }), | 534 | ), |
| 505 | ), | ||
| 506 | ); | 535 | ); |
| 507 | 536 | ||
| 508 | it.effect("stops retrying a settled backfill after repeated lookup failures", () => | 537 | it.effect("stops retrying a settled backfill after repeated lookup failures", () => |
apps/server/src/orchestration/ThreadPullRequestReactor.ts+8-3| ... | @@ -101,7 +101,10 @@ export const make = Effect.gen(function* () { | ... | @@ -101,7 +101,10 @@ export const make = Effect.gen(function* () { |
| 101 | const projects = new Map(snapshot.projects.map((project) => [project.id, project])); | 101 | const projects = new Map(snapshot.projects.map((project) => [project.id, project])); |
| 102 | if (request.backfill) { | 102 | if (request.backfill) { |
| 103 | for (const thread of snapshot.threads) { | 103 | for (const thread of snapshot.threads) { |
| 104 | if (thread.settledOverride === "settled" && thread.branchPullRequest == null) { | 104 | if ( |
| 105 | (thread.settledOverride === "settled" || thread.settledAt !== null) && | ||
| 106 | thread.branchPullRequest == null | ||
| 107 | ) { | ||
| 105 | pendingBackfill.set(thread.id, BACKFILL_ATTEMPTS); | 108 | pendingBackfill.set(thread.id, BACKFILL_ATTEMPTS); |
| 106 | } | 109 | } |
| 107 | } | 110 | } |
| ... | @@ -114,7 +117,7 @@ export const make = Effect.gen(function* () { | ... | @@ -114,7 +117,7 @@ export const make = Effect.gen(function* () { |
| 114 | (thread) => | 117 | (thread) => |
| 115 | thread.archivedAt === null && | 118 | thread.archivedAt === null && |
| 116 | (request.threadId === null || thread.id === request.threadId) && | 119 | (request.threadId === null || thread.id === request.threadId) && |
| 117 | (thread.settledOverride !== "settled" || | 120 | ((thread.settledOverride !== "settled" && thread.settledAt === null) || |
| 118 | request.threadId !== null || | 121 | request.threadId !== null || |
| 119 | pendingBackfill.has(thread.id)) && | 122 | pendingBackfill.has(thread.id)) && |
| 120 | (thread.branch !== null || thread.branchPullRequest != null), | 123 | (thread.branch !== null || thread.branchPullRequest != null), |
| ... | @@ -327,7 +330,9 @@ export const make = Effect.gen(function* () { | ... | @@ -327,7 +330,9 @@ export const make = Effect.gen(function* () { |
| 327 | event.payload.session.status !== "running" && | 330 | event.payload.session.status !== "running" && |
| 328 | event.payload.session.status !== "starting" | 331 | event.payload.session.status !== "starting" |
| 329 | ) { | 332 | ) { |
| 330 | return worker.enqueue({ threadId: event.payload.threadId, refresh: true }); | 333 | // Checkpoint completion forces the post-turn read. Session lifecycle |
| 334 | // events reuse it regardless of which event reaches this worker first. | ||
| 335 | return worker.enqueue({ threadId: event.payload.threadId, refresh: false }); | ||
| 331 | } | 336 | } |
| 332 | break; | 337 | break; |
| 333 | case "thread.turn-diff-completed": | 338 | case "thread.turn-diff-completed": |
apps/server/src/pullRequest/GitHubPullRequestCli.test.ts+177-1| ... | @@ -1,6 +1,7 @@ | ... | @@ -1,6 +1,7 @@ |
| 1 | import { afterEach, assert, expect, it, vi } from "@effect/vitest"; | 1 | import { afterEach, assert, expect, it, vi } from "@effect/vitest"; |
| 2 | import * as Effect from "effect/Effect"; | 2 | import * as Effect from "effect/Effect"; |
| 3 | import * as Layer from "effect/Layer"; | 3 | import * as Layer from "effect/Layer"; |
| 4 | import * as Schema from "effect/Schema"; | ||
| 4 | import * as TestClock from "effect/testing/TestClock"; | 5 | import * as TestClock from "effect/testing/TestClock"; |
| 5 | import { ChildProcessSpawner } from "effect/unstable/process"; | 6 | import { ChildProcessSpawner } from "effect/unstable/process"; |
| 6 | 7 | ||
| ... | @@ -9,14 +10,22 @@ import * as GitHubGraphQlBudget from "../sourceControl/githubGraphQlBudget.ts"; | ... | @@ -9,14 +10,22 @@ import * as GitHubGraphQlBudget from "../sourceControl/githubGraphQlBudget.ts"; |
| 9 | import * as GitHubPullRequestCli from "./GitHubPullRequestCli.ts"; | 10 | import * as GitHubPullRequestCli from "./GitHubPullRequestCli.ts"; |
| 10 | import { BASE_COMPARISON_GRAPHQL_QUERY } from "./gitHubPullRequestJson.ts"; | 11 | import { BASE_COMPARISON_GRAPHQL_QUERY } from "./gitHubPullRequestJson.ts"; |
| 11 | 12 | ||
| 13 | const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); | ||
| 14 | |||
| 12 | const mockedExecute = vi.fn<GitHubCli.GitHubCli["Service"]["execute"]>(); | 15 | const mockedExecute = vi.fn<GitHubCli.GitHubCli["Service"]["execute"]>(); |
| 16 | const mockedStackMemberships = vi.fn<GitHubCli.GitHubCli["Service"]["execute"]>(() => | ||
| 17 | Effect.succeed(output('{"data":{}}')), | ||
| 18 | ); | ||
| 13 | const mockedGetPullRequest = vi.fn<GitHubCli.GitHubCli["Service"]["getPullRequest"]>(); | 19 | const mockedGetPullRequest = vi.fn<GitHubCli.GitHubCli["Service"]["getPullRequest"]>(); |
| 14 | 20 | ||
| 15 | const layer = it.layer( | 21 | const layer = it.layer( |
| 16 | GitHubPullRequestCli.layer.pipe( | 22 | GitHubPullRequestCli.layer.pipe( |
| 17 | Layer.provide( | 23 | Layer.provide( |
| 18 | Layer.mock(GitHubCli.GitHubCli)({ | 24 | Layer.mock(GitHubCli.GitHubCli)({ |
| 19 | execute: mockedExecute, | 25 | execute: (input) => |
| 26 | input.args.some((arg) => arg.includes("query PullRequestStackMemberships")) | ||
| 27 | ? mockedStackMemberships(input) | ||
| 28 | : mockedExecute(input), | ||
| 20 | getPullRequest: mockedGetPullRequest, | 29 | getPullRequest: mockedGetPullRequest, |
| 21 | }), | 30 | }), |
| 22 | ), | 31 | ), |
| ... | @@ -179,6 +188,7 @@ function searchQueryOfCall(index: number): string | undefined { | ... | @@ -179,6 +188,7 @@ function searchQueryOfCall(index: number): string | undefined { |
| 179 | 188 | ||
| 180 | afterEach(() => { | 189 | afterEach(() => { |
| 181 | mockedExecute.mockReset(); | 190 | mockedExecute.mockReset(); |
| 191 | mockedStackMemberships.mockReset(); | ||
| 182 | mockedGetPullRequest.mockReset(); | 192 | mockedGetPullRequest.mockReset(); |
| 183 | }); | 193 | }); |
| 184 | 194 | ||
| ... | @@ -320,6 +330,51 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -320,6 +330,51 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 320 | }), | 330 | }), |
| 321 | ); | 331 | ); |
| 322 | 332 | ||
| 333 | it.effect("fetches layer titles only when the caller asks for stack details", () => | ||
| 334 | Effect.gen(function* () { | ||
| 335 | const minimal = { | ||
| 336 | url: "https://api.github.com/repos/acme/web/stacks/3", | ||
| 337 | number: 3, | ||
| 338 | base: { ref: "main" }, | ||
| 339 | pull_requests: [ | ||
| 340 | { number: 7, head: { ref: "feat/two", sha: "abc123" }, state: "open", merged_at: null }, | ||
| 341 | ], | ||
| 342 | }; | ||
| 343 | // @effect-diagnostics-next-line preferSchemaOverJson:off | ||
| 344 | mockedExecute.mockReturnValueOnce(Effect.succeed(output(JSON.stringify([minimal])))); | ||
| 345 | mockedExecute.mockReturnValueOnce( | ||
| 346 | Effect.succeed( | ||
| 347 | output( | ||
| 348 | // @effect-diagnostics-next-line preferSchemaOverJson:off | ||
| 349 | JSON.stringify({ | ||
| 350 | ...minimal, | ||
| 351 | pull_requests: [{ ...minimal.pull_requests[0], title: "Second layer", draft: false }], | ||
| 352 | }), | ||
| 353 | ), | ||
| 354 | ), | ||
| 355 | ); | ||
| 356 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | ||
| 357 | const stack = yield* cli.getPullRequestStack({ | ||
| 358 | cwd: "/w", | ||
| 359 | repository: "acme/web", | ||
| 360 | host: "github.com", | ||
| 361 | number: 7, | ||
| 362 | includeDetails: true, | ||
| 363 | }); | ||
| 364 | expect(stack?.layers[0]).toMatchObject({ | ||
| 365 | title: "Second layer", | ||
| 366 | headSha: "abc123", | ||
| 367 | isDraft: false, | ||
| 368 | }); | ||
| 369 | expect(callAt(1).args).toEqual([ | ||
| 370 | "api", | ||
| 371 | "--hostname", | ||
| 372 | "github.com", | ||
| 373 | "repos/acme/web/stacks/3", | ||
| 374 | ]); | ||
| 375 | }), | ||
| 376 | ); | ||
| 377 | |||
| 323 | it.effect("reads an empty stacks listing as not stacked", () => | 378 | it.effect("reads an empty stacks listing as not stacked", () => |
| 324 | Effect.gen(function* () { | 379 | Effect.gen(function* () { |
| 325 | mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]"))); | 380 | mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]"))); |
| ... | @@ -715,6 +770,127 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -715,6 +770,127 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 715 | }), | 770 | }), |
| 716 | ); | 771 | ); |
| 717 | 772 | ||
| 773 | it.effect("enriches only the visible fallback rows after filtering and widening", () => | ||
| 774 | Effect.gen(function* () { | ||
| 775 | mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]"))); | ||
| 776 | mockedExecute.mockReturnValueOnce( | ||
| 777 | Effect.succeed(output(pullRequests(3, 1, () => ({ isDraft: true })))), | ||
| 778 | ); | ||
| 779 | mockedExecute.mockReturnValueOnce( | ||
| 780 | Effect.succeed(output(pullRequests(6, 1, (number) => ({ isDraft: number < 4 })))), | ||
| 781 | ); | ||
| 782 | mockedStackMemberships.mockReturnValueOnce( | ||
| 783 | Effect.succeed( | ||
| 784 | output( | ||
| 785 | encodeJson({ | ||
| 786 | data: { | ||
| 787 | s0: { | ||
| 788 | pullRequest: { | ||
| 789 | stack: { number: 3, size: 2, baseRefName: "main" }, | ||
| 790 | stackEntry: { position: 1 }, | ||
| 791 | }, | ||
| 792 | }, | ||
| 793 | s1: { pullRequest: { stack: null, stackEntry: null } }, | ||
| 794 | }, | ||
| 795 | }), | ||
| 796 | ), | ||
| 797 | ), | ||
| 798 | ); | ||
| 799 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | ||
| 800 | const batch = yield* cli.listPullRequests({ | ||
| 801 | cwd: "/w", | ||
| 802 | repository: "acme/web", | ||
| 803 | host: "github.com", | ||
| 804 | state: "open", | ||
| 805 | involvement: "all", | ||
| 806 | viewer: "bilal", | ||
| 807 | limit: 2, | ||
| 808 | filters: { draft: "hide" }, | ||
| 809 | }); | ||
| 810 | expect(batch.items.map((item) => item.number)).toEqual([4, 5]); | ||
| 811 | expect(batch.items[0]?.stack).toEqual({ number: 3, size: 2, base: "main", position: 1 }); | ||
| 812 | expect(batch.items[1]?.stack).toBeUndefined(); | ||
| 813 | expect(batch.truncated).toBe(true); | ||
| 814 | expect(batch.continues).toBe(false); | ||
| 815 | expect(mockedStackMemberships).toHaveBeenCalledTimes(1); | ||
| 816 | const query = mockedStackMemberships.mock.calls[0]?.[0].args.at(-1) ?? ""; | ||
| 817 | expect(query).toContain("pullRequest(number: 4)"); | ||
| 818 | expect(query).toContain("pullRequest(number: 5)"); | ||
| 819 | expect(query).not.toContain("pullRequest(number: 1)"); | ||
| 820 | expect(query).not.toContain("pullRequest(number: 6)"); | ||
| 821 | }), | ||
| 822 | ); | ||
| 823 | |||
| 824 | it.effect("batches membership reads and keeps successful rows when one chunk fails", () => | ||
| 825 | Effect.gen(function* () { | ||
| 826 | mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequests(27, 1)))); | ||
| 827 | mockedStackMemberships.mockImplementation((input) => | ||
| 828 | input.args.at(-1)?.includes("pullRequest(number: 26)") | ||
| 829 | ? Effect.fail( | ||
| 830 | new GitHubCli.GitHubCliCommandError({ | ||
| 831 | command: "gh", | ||
| 832 | cwd: "/w", | ||
| 833 | cause: new Error("HTTP 502"), | ||
| 834 | }), | ||
| 835 | ) | ||
| 836 | : Effect.succeed( | ||
| 837 | output( | ||
| 838 | encodeJson({ | ||
| 839 | data: { | ||
| 840 | s0: { | ||
| 841 | pullRequest: { | ||
| 842 | stack: { number: 3, size: 2, baseRefName: "main" }, | ||
| 843 | stackEntry: { position: 1 }, | ||
| 844 | }, | ||
| 845 | }, | ||
| 846 | }, | ||
| 847 | }), | ||
| 848 | ), | ||
| 849 | ), | ||
| 850 | ); | ||
| 851 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | ||
| 852 | const batch = yield* cli.listPullRequests({ | ||
| 853 | cwd: "/w", | ||
| 854 | repository: "acme/web", | ||
| 855 | host: "github.com", | ||
| 856 | state: "open", | ||
| 857 | involvement: "all", | ||
| 858 | viewer: "bilal", | ||
| 859 | limit: 26, | ||
| 860 | }); | ||
| 861 | expect(batch.items.map((item) => item.number)).toEqual( | ||
| 862 | Array.from({ length: 26 }, (_, i) => i + 1), | ||
| 863 | ); | ||
| 864 | expect(batch.items[0]?.stack).toEqual({ number: 3, size: 2, base: "main", position: 1 }); | ||
| 865 | expect(batch.items[25]?.stack).toBeUndefined(); | ||
| 866 | expect(batch.truncated).toBe(true); | ||
| 867 | expect(batch.continues).toBe(true); | ||
| 868 | expect(mockedStackMemberships).toHaveBeenCalledTimes(2); | ||
| 869 | }), | ||
| 870 | ); | ||
| 871 | |||
| 872 | it.effect("skips membership enrichment for empty pages and enterprise hosts", () => | ||
| 873 | Effect.gen(function* () { | ||
| 874 | mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]"))); | ||
| 875 | mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]"))); | ||
| 876 | mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequests(1, 7)))); | ||
| 877 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | ||
| 878 | const input = { | ||
| 879 | cwd: "/w", | ||
| 880 | repository: "acme/web", | ||
| 881 | state: "open" as const, | ||
| 882 | involvement: "all" as const, | ||
| 883 | viewer: "bilal", | ||
| 884 | limit: 2, | ||
| 885 | }; | ||
| 886 | const empty = yield* cli.listPullRequests({ ...input, host: "github.com" }); | ||
| 887 | const enterprise = yield* cli.listPullRequests({ ...input, host: "github.acme.test" }); | ||
| 888 | expect(empty.items).toEqual([]); | ||
| 889 | expect(enterprise.items.map((item) => item.number)).toEqual([7]); | ||
| 890 | expect(mockedStackMemberships).not.toHaveBeenCalled(); | ||
| 891 | }), | ||
| 892 | ); | ||
| 893 | |||
| 718 | it.effect("reads the line counts in chunks, and files them back by position", () => | 894 | it.effect("reads the line counts in chunks, and files them back by position", () => |
| 719 | Effect.gen(function* () { | 895 | Effect.gen(function* () { |
| 720 | const changeRequests = Array.from({ length: 26 }, (_, index) => ({ | 896 | const changeRequests = Array.from({ length: 26 }, (_, index) => ({ |
apps/server/src/pullRequest/GitHubPullRequestCli.ts+82-1| ... | @@ -1,3 +1,4 @@ | ... | @@ -1,3 +1,4 @@ |
| 1 | import { runGitHubStackAction, type GitHubStackActionError } from "./githubStackActions.ts"; | ||
| 1 | import * as Context from "effect/Context"; | 2 | import * as Context from "effect/Context"; |
| 2 | import * as Effect from "effect/Effect"; | 3 | import * as Effect from "effect/Effect"; |
| 3 | import * as Layer from "effect/Layer"; | 4 | import * as Layer from "effect/Layer"; |
| ... | @@ -6,6 +7,7 @@ import * as Schema from "effect/Schema"; | ... | @@ -6,6 +7,7 @@ import * as Schema from "effect/Schema"; |
| 6 | import { | 7 | import { |
| 7 | resolvePullRequestAuthorFilter, | 8 | resolvePullRequestAuthorFilter, |
| 8 | type PullRequestAction, | 9 | type PullRequestAction, |
| 10 | type PullRequestStackHead, | ||
| 9 | type PullRequestActor, | 11 | type PullRequestActor, |
| 10 | type PullRequestInvolvement, | 12 | type PullRequestInvolvement, |
| 11 | type PullRequestListFilters, | 13 | type PullRequestListFilters, |
| ... | @@ -51,6 +53,8 @@ import { | ... | @@ -51,6 +53,8 @@ import { |
| 51 | decodeReviewThreadCommentsJson, | 53 | decodeReviewThreadCommentsJson, |
| 52 | decodeReviewThreadsJson, | 54 | decodeReviewThreadsJson, |
| 53 | buildPullRequestStatsGraphQlQuery, | 55 | buildPullRequestStatsGraphQlQuery, |
| 56 | buildPullRequestStackMembershipsGraphQlQuery, | ||
| 57 | decodePullRequestStackMembershipsJson, | ||
| 54 | encodeGraphQlRequestJson, | 58 | encodeGraphQlRequestJson, |
| 55 | pullRequestSearchGraphQlQuery, | 59 | pullRequestSearchGraphQlQuery, |
| 56 | PULL_REQUEST_SEARCH_MAX_ROWS, | 60 | PULL_REQUEST_SEARCH_MAX_ROWS, |
| ... | @@ -334,6 +338,7 @@ export class GitHubWorkflowApprovalHeadChangedError extends Schema.TaggedError<G | ... | @@ -334,6 +338,7 @@ export class GitHubWorkflowApprovalHeadChangedError extends Schema.TaggedError<G |
| 334 | } | 338 | } |
| 335 | 339 | ||
| 336 | export type GitHubPullRequestCliError = | 340 | export type GitHubPullRequestCliError = |
| 341 | | GitHubStackActionError | ||
| 337 | | GitHubCli.GitHubCliError | 342 | | GitHubCli.GitHubCliError |
| 338 | | GitHubPullRequestReadError | 343 | | GitHubPullRequestReadError |
| 339 | | GitHubDiffCursorError | 344 | | GitHubDiffCursorError |
| ... | @@ -488,6 +493,7 @@ export class GitHubPullRequestCli extends Context.Service< | ... | @@ -488,6 +493,7 @@ export class GitHubPullRequestCli extends Context.Service< |
| 488 | */ | 493 | */ |
| 489 | readonly getPullRequestStack: (input: { | 494 | readonly getPullRequestStack: (input: { |
| 490 | readonly cwd: string; | 495 | readonly cwd: string; |
| 496 | readonly includeDetails?: boolean; | ||
| 491 | readonly repository: string; | 497 | readonly repository: string; |
| 492 | readonly host: string; | 498 | readonly host: string; |
| 493 | readonly number: number; | 499 | readonly number: number; |
| ... | @@ -627,6 +633,8 @@ export class GitHubPullRequestCli extends Context.Service< | ... | @@ -627,6 +633,8 @@ export class GitHubPullRequestCli extends Context.Service< |
| 627 | readonly host: string; | 633 | readonly host: string; |
| 628 | readonly number: number; | 634 | readonly number: number; |
| 629 | readonly action: PullRequestAction; | 635 | readonly action: PullRequestAction; |
| 636 | readonly stackNumber?: number; | ||
| 637 | readonly expectedStackHeads?: ReadonlyArray<PullRequestStackHead>; | ||
| 630 | readonly mergeMethod?: PullRequestMergeMethod; | 638 | readonly mergeMethod?: PullRequestMergeMethod; |
| 631 | readonly updateMethod?: PullRequestUpdateMethod; | 639 | readonly updateMethod?: PullRequestUpdateMethod; |
| 632 | }) => Effect.Effect<void, GitHubPullRequestCliError>; | 640 | }) => Effect.Effect<void, GitHubPullRequestCliError>; |
| ... | @@ -1557,6 +1565,47 @@ export const make = Effect.gen(function* () { | ... | @@ -1557,6 +1565,47 @@ export const make = Effect.gen(function* () { |
| 1557 | ? read(false) | 1565 | ? read(false) |
| 1558 | : Effect.succeed(batch), | 1566 | : Effect.succeed(batch), |
| 1559 | ), | 1567 | ), |
| 1568 | Effect.flatMap((batch) => { | ||
| 1569 | // Match the search query's host support, and enrich only rows that survived paging. | ||
| 1570 | if (input.host !== "github.com" || batch.items.length === 0) return Effect.succeed(batch); | ||
| 1571 | const chunks: Array<ReadonlyArray<GitHubPullRequestListItem>> = []; | ||
| 1572 | for (let start = 0; start < batch.items.length; start += STAT_ALIASES_PER_REQUEST) { | ||
| 1573 | chunks.push(batch.items.slice(start, start + STAT_ALIASES_PER_REQUEST)); | ||
| 1574 | } | ||
| 1575 | return Effect.forEach( | ||
| 1576 | chunks, | ||
| 1577 | (chunk) => { | ||
| 1578 | const query = buildPullRequestStackMembershipsGraphQlQuery( | ||
| 1579 | input.repository, | ||
| 1580 | chunk.map((item) => item.number), | ||
| 1581 | ); | ||
| 1582 | if (query === null) return Effect.succeed(chunk); | ||
| 1583 | return graphqlRead({ | ||
| 1584 | cwd: input.cwd, | ||
| 1585 | host: input.host, | ||
| 1586 | operation: "listPullRequestStackMemberships", | ||
| 1587 | query, | ||
| 1588 | decode: decodePullRequestStackMembershipsJson, | ||
| 1589 | }).pipe( | ||
| 1590 | Effect.map((memberships) => | ||
| 1591 | chunk.map((item, index) => { | ||
| 1592 | const stack = memberships.get(index); | ||
| 1593 | return stack === undefined ? item : { ...item, stack }; | ||
| 1594 | }), | ||
| 1595 | ), | ||
| 1596 | // Optional badges must not take down a listing that already read successfully. | ||
| 1597 | Effect.catch(() => | ||
| 1598 | Effect.logWarning("Pull request stack membership enrichment failed", { | ||
| 1599 | operation: "listPullRequestStackMemberships", | ||
| 1600 | host: input.host, | ||
| 1601 | rows: chunk.length, | ||
| 1602 | }).pipe(Effect.as(chunk)), | ||
| 1603 | ), | ||
| 1604 | ); | ||
| 1605 | }, | ||
| 1606 | { concurrency: STAT_REQUEST_CONCURRENCY }, | ||
| 1607 | ).pipe(Effect.map((chunks) => ({ ...batch, items: chunks.flat() }))); | ||
| 1608 | }), | ||
| 1560 | ); | 1609 | ); |
| 1561 | }, | 1610 | }, |
| 1562 | 1611 | ||
| ... | @@ -1581,7 +1630,7 @@ export const make = Effect.gen(function* () { | ... | @@ -1581,7 +1630,7 @@ export const make = Effect.gen(function* () { |
| 1581 | operation: "searchPullRequests", | 1630 | operation: "searchPullRequests", |
| 1582 | // The reader's own words are in the query, so it travels over stdin rather than in argv. | 1631 | // The reader's own words are in the query, so it travels over stdin rather than in argv. |
| 1583 | privateVariables: { q: query }, | 1632 | privateVariables: { q: query }, |
| 1584 | query: pullRequestSearchGraphQlQuery(rows), | 1633 | query: pullRequestSearchGraphQlQuery(rows, input.host === "github.com"), |
| 1585 | decode: decodePullRequestSearchJson, | 1634 | decode: decodePullRequestSearchJson, |
| 1586 | }).pipe( | 1635 | }).pipe( |
| 1587 | Effect.map((batch) => ({ | 1636 | Effect.map((batch) => ({ |
| ... | @@ -1709,6 +1758,34 @@ export const make = Effect.gen(function* () { | ... | @@ -1709,6 +1758,34 @@ export const make = Effect.gen(function* () { |
| 1709 | }), | 1758 | }), |
| 1710 | ); | 1759 | ); |
| 1711 | }), | 1760 | }), |
| 1761 | Effect.flatMap((stack) => { | ||
| 1762 | if (!input.includeDetails || stack === null) return Effect.succeed(stack); | ||
| 1763 | return github | ||
| 1764 | .execute({ | ||
| 1765 | cwd: input.cwd, | ||
| 1766 | args: [ | ||
| 1767 | "api", | ||
| 1768 | "--hostname", | ||
| 1769 | input.host, | ||
| 1770 | `repos/${owner}/${name}/stacks/${stack.number}`, | ||
| 1771 | ], | ||
| 1772 | }) | ||
| 1773 | .pipe( | ||
| 1774 | Effect.flatMap((result) => { | ||
| 1775 | const decoded = decodePullRequestStacksJson(`[${result.stdout.trim()}]`); | ||
| 1776 | return Result.isSuccess(decoded) | ||
| 1777 | ? Effect.succeed(decoded.success) | ||
| 1778 | : Effect.fail( | ||
| 1779 | new GitHubPullRequestReadError({ | ||
| 1780 | command: "gh", | ||
| 1781 | cwd: input.cwd, | ||
| 1782 | operation: "getPullRequestStack", | ||
| 1783 | cause: decoded.failure, | ||
| 1784 | }), | ||
| 1785 | ); | ||
| 1786 | }), | ||
| 1787 | ); | ||
| 1788 | }), | ||
| 1712 | // Hosts without the stacks preview return 404. Other failures must preserve the | 1789 | // Hosts without the stacks preview return 404. Other failures must preserve the |
| 1713 | // previously synced stack and let the caller retry. | 1790 | // previously synced stack and let the caller retry. |
| 1714 | Effect.catchTags({ | 1791 | Effect.catchTags({ |
| ... | @@ -2109,6 +2186,10 @@ export const make = Effect.gen(function* () { | ... | @@ -2109,6 +2186,10 @@ export const make = Effect.gen(function* () { |
| 2109 | }, | 2186 | }, |
| 2110 | 2187 | ||
| 2111 | runPullRequestAction: (input) => { | 2188 | runPullRequestAction: (input) => { |
| 2189 | if (input.stackNumber !== undefined) | ||
| 2190 | return runGitHubStackAction({ ...input, stackNumber: input.stackNumber }).pipe( | ||
| 2191 | Effect.provideService(GitHubCli.GitHubCli, github), | ||
| 2192 | ); | ||
| 2112 | if (input.action === "revert") { | 2193 | if (input.action === "revert") { |
| 2113 | return pullRequestNodeId({ ...input, operation: "revertPullRequest" }).pipe( | 2194 | return pullRequestNodeId({ ...input, operation: "revertPullRequest" }).pipe( |
| 2114 | Effect.flatMap((pullRequestId) => | 2195 | Effect.flatMap((pullRequestId) => |
apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts+1| ... | @@ -130,6 +130,7 @@ describe("gitHubViewerPermissions", () => { | ... | @@ -130,6 +130,7 @@ describe("gitHubViewerPermissions", () => { |
| 130 | ], | 130 | ], |
| 131 | comment: true, | 131 | comment: true, |
| 132 | resolve: true, | 132 | resolve: true, |
| 133 | stackRebase: true, | ||
| 133 | verdicts: ["comment", "approve", "request-changes"], | 134 | verdicts: ["comment", "approve", "request-changes"], |
| 134 | requestReviewers: true, | 135 | requestReviewers: true, |
| 135 | labels: true, | 136 | labels: true, |
apps/server/src/pullRequest/GitHubPullRequestProvider.ts+6| ... | @@ -48,6 +48,7 @@ const CAPABILITIES: PullRequestCapabilities = { | ... | @@ -48,6 +48,7 @@ const CAPABILITIES: PullRequestCapabilities = { |
| 48 | reviewers: { request: true, listCandidates: true }, | 48 | reviewers: { request: true, listCandidates: true }, |
| 49 | edit: { changeRequest: true, comment: true }, | 49 | edit: { changeRequest: true, comment: true }, |
| 50 | stacks: true, | 50 | stacks: true, |
| 51 | stackActions: true, | ||
| 51 | labels: true, | 52 | labels: true, |
| 52 | }; | 53 | }; |
| 53 | 54 | ||
| ... | @@ -70,6 +71,7 @@ const CAPABILITIES: PullRequestCapabilities = { | ... | @@ -70,6 +71,7 @@ const CAPABILITIES: PullRequestCapabilities = { |
| 70 | */ | 71 | */ |
| 71 | export function gitHubViewerPermissions(access: GitHubViewerAccess): PullRequestViewerPermissions { | 72 | export function gitHubViewerPermissions(access: GitHubViewerAccess): PullRequestViewerPermissions { |
| 72 | return { | 73 | return { |
| 74 | ...(access.canWrite ? { stackRebase: true } : {}), | ||
| 73 | actions: [ | 75 | actions: [ |
| 74 | // Arming a merge and taking the arming back are the merge, deferred: whoever may not | 76 | // Arming a merge and taking the arming back are the merge, deferred: whoever may not |
| 75 | // merge here may not leave an instruction to merge later either. | 77 | // merge here may not leave an instruction to merge later either. |
| ... | @@ -558,6 +560,10 @@ export const make = Effect.gen(function* () { | ... | @@ -558,6 +560,10 @@ export const make = Effect.gen(function* () { |
| 558 | host: input.host, | 560 | host: input.host, |
| 559 | number: input.number, | 561 | number: input.number, |
| 560 | action: input.action, | 562 | action: input.action, |
| 563 | ...(input.stackNumber === undefined ? {} : { stackNumber: input.stackNumber }), | ||
| 564 | ...(input.expectedStackHeads === undefined | ||
| 565 | ? {} | ||
| 566 | : { expectedStackHeads: input.expectedStackHeads }), | ||
| 561 | ...(input.mergeMethod === undefined ? {} : { mergeMethod: input.mergeMethod }), | 567 | ...(input.mergeMethod === undefined ? {} : { mergeMethod: input.mergeMethod }), |
| 562 | ...(input.updateMethod === undefined ? {} : { updateMethod: input.updateMethod }), | 568 | ...(input.updateMethod === undefined ? {} : { updateMethod: input.updateMethod }), |
| 563 | }) | 569 | }) |
apps/server/src/pullRequest/PullRequestProvider.ts+9-1| ... | @@ -1,7 +1,9 @@ | ... | @@ -1,7 +1,9 @@ |
| 1 | import * as Effect from "effect/Effect"; | 1 | import * as Effect from "effect/Effect"; |
| 2 | import * as Schema from "effect/Schema"; | 2 | import * as Schema from "effect/Schema"; |
| 3 | import type { | 3 | import type { |
| 4 | PullRequestStackMembership, | ||
| 4 | PullRequestAction, | 5 | PullRequestAction, |
| 6 | PullRequestStackHead, | ||
| 5 | PullRequestActor, | 7 | PullRequestActor, |
| 6 | PullRequestBaseComparison, | 8 | PullRequestBaseComparison, |
| 7 | PullRequestCapabilities, | 9 | PullRequestCapabilities, |
| ... | @@ -65,6 +67,7 @@ export interface PullRequestProviderFailure { | ... | @@ -65,6 +67,7 @@ export interface PullRequestProviderFailure { |
| 65 | 67 | ||
| 66 | /** A change request as the provider sees it, before the service attaches project context. */ | 68 | /** A change request as the provider sees it, before the service attaches project context. */ |
| 67 | export interface ProviderChangeRequest { | 69 | export interface ProviderChangeRequest { |
| 70 | readonly stack?: PullRequestStackMembership; | ||
| 68 | readonly number: number; | 71 | readonly number: number; |
| 69 | readonly title: string; | 72 | readonly title: string; |
| 70 | readonly url: string; | 73 | readonly url: string; |
| ... | @@ -115,6 +118,9 @@ export interface ProviderChangeRequestSummary { | ... | @@ -115,6 +118,9 @@ export interface ProviderChangeRequestSummary { |
| 115 | 118 | ||
| 116 | /** One layer of a host-native stack, bottom to top order is the array's. */ | 119 | /** One layer of a host-native stack, bottom to top order is the array's. */ |
| 117 | export interface ProviderChangeRequestStackLayer { | 120 | export interface ProviderChangeRequestStackLayer { |
| 121 | readonly title?: string; | ||
| 122 | readonly isDraft?: boolean; | ||
| 123 | readonly headSha?: string; | ||
| 118 | readonly number: number; | 124 | readonly number: number; |
| 119 | readonly headBranch: string; | 125 | readonly headBranch: string; |
| 120 | readonly state: PullRequestState; | 126 | readonly state: PullRequestState; |
| ... | @@ -364,7 +370,7 @@ export interface PullRequestProviderApi { | ... | @@ -364,7 +370,7 @@ export interface PullRequestProviderApi { |
| 364 | * because most hosts have no such object; the service derives chains from base branches there. | 370 | * because most hosts have no such object; the service derives chains from base branches there. |
| 365 | */ | 371 | */ |
| 366 | readonly getChangeRequestStack?: ( | 372 | readonly getChangeRequestStack?: ( |
| 367 | input: ProviderRepositoryRef & { readonly number: number }, | 373 | input: ProviderRepositoryRef & { readonly includeDetails?: boolean; readonly number: number }, |
| 368 | ) => Effect.Effect<ProviderChangeRequestStack | null, PullRequestProviderError>; | 374 | ) => Effect.Effect<ProviderChangeRequestStack | null, PullRequestProviderError>; |
| 369 | 375 | ||
| 370 | /** Comments, line threads, and commits, kept off the critical path for the core detail. */ | 376 | /** Comments, line threads, and commits, kept off the critical path for the core detail. */ |
| ... | @@ -426,6 +432,8 @@ export interface PullRequestProviderApi { | ... | @@ -426,6 +432,8 @@ export interface PullRequestProviderApi { |
| 426 | input: ProviderRepositoryRef & { | 432 | input: ProviderRepositoryRef & { |
| 427 | readonly number: number; | 433 | readonly number: number; |
| 428 | readonly action: PullRequestAction; | 434 | readonly action: PullRequestAction; |
| 435 | readonly stackNumber?: number; | ||
| 436 | readonly expectedStackHeads?: ReadonlyArray<PullRequestStackHead>; | ||
| 429 | /** Meaningful for `merge` and `enable-auto-merge`; absent takes the host's own default. */ | 437 | /** Meaningful for `merge` and `enable-auto-merge`; absent takes the host's own default. */ |
| 430 | readonly mergeMethod?: PullRequestMergeMethod; | 438 | readonly mergeMethod?: PullRequestMergeMethod; |
| 431 | /** Only meaningful for `update-branch`; absent takes the host's own default. */ | 439 | /** Only meaningful for `update-branch`; absent takes the host's own default. */ |
apps/server/src/pullRequest/PullRequestService.test.ts+127| ... | @@ -1564,6 +1564,48 @@ it.effect("refuses a repository that does not belong to the requested project", | ... | @@ -1564,6 +1564,48 @@ it.effect("refuses a repository that does not belong to the requested project", |
| 1564 | }), | 1564 | }), |
| 1565 | ); | 1565 | ); |
| 1566 | 1566 | ||
| 1567 | it.effect("caches stack membership separately from action details", () => | ||
| 1568 | Effect.gen(function* () { | ||
| 1569 | const reads: Array<boolean> = []; | ||
| 1570 | const service = yield* makeService({ | ||
| 1571 | projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], | ||
| 1572 | providers: [ | ||
| 1573 | fakeProvider("github", { | ||
| 1574 | getChangeRequestStack: (input) => | ||
| 1575 | Effect.sync(() => { | ||
| 1576 | reads.push(input.includeDetails === true); | ||
| 1577 | return { | ||
| 1578 | id: "9", | ||
| 1579 | number: 3, | ||
| 1580 | url: "https://github.com/acme/web/stacks/3", | ||
| 1581 | base: "main", | ||
| 1582 | layers: [ | ||
| 1583 | { | ||
| 1584 | number: 7, | ||
| 1585 | headBranch: "a", | ||
| 1586 | state: "open" as const, | ||
| 1587 | ...(input.includeDetails ? { title: "First layer", headSha: "abc" } : {}), | ||
| 1588 | }, | ||
| 1589 | ], | ||
| 1590 | }; | ||
| 1591 | }), | ||
| 1592 | }), | ||
| 1593 | ], | ||
| 1594 | }); | ||
| 1595 | const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 7 }; | ||
| 1596 | yield* service.stack(reference, { includeDetails: false }); | ||
| 1597 | yield* service.stack(reference, { includeDetails: false }); | ||
| 1598 | const detail = yield* service.stack(reference); | ||
| 1599 | yield* service.stack(reference); | ||
| 1600 | assert.deepStrictEqual(reads, [false, true]); | ||
| 1601 | assert.strictEqual(detail?.layers[0]?.headSha, "abc"); | ||
| 1602 | yield* service.invalidate({ reference }); | ||
| 1603 | yield* service.stack(reference, { includeDetails: false }); | ||
| 1604 | yield* service.stack(reference); | ||
| 1605 | assert.deepStrictEqual(reads, [false, true, false, true]); | ||
| 1606 | }), | ||
| 1607 | ); | ||
| 1608 | |||
| 1567 | it.effect("reads a host-native stack through the provider and null where it has none", () => | 1609 | it.effect("reads a host-native stack through the provider and null where it has none", () => |
| 1568 | Effect.gen(function* () { | 1610 | Effect.gen(function* () { |
| 1569 | const service = yield* makeService({ | 1611 | const service = yield* makeService({ |
| ... | @@ -3997,6 +4039,91 @@ it.effect('resolves an author filter of "me" to the viewer before narrowing a ho | ... | @@ -3997,6 +4039,91 @@ it.effect('resolves an author filter of "me" to the viewer before narrowing a ho |
| 3997 | }), | 4039 | }), |
| 3998 | ); | 4040 | ); |
| 3999 | 4041 | ||
| 4042 | it.effect("authorizes stack rebases independently of whether the selected layer is behind", () => | ||
| 4043 | Effect.gen(function* () { | ||
| 4044 | let taken = 0; | ||
| 4045 | let summaryReads = 0; | ||
| 4046 | let mutationFails = false; | ||
| 4047 | let stackRebase = true; | ||
| 4048 | let stackActions = true; | ||
| 4049 | const capabilities = { | ||
| 4050 | diff: true, | ||
| 4051 | comment: true, | ||
| 4052 | actions: ["update-branch"] as const, | ||
| 4053 | mergeMethods: ["merge"] as const, | ||
| 4054 | updateMethods: ["rebase"] as const, | ||
| 4055 | get stackActions() { | ||
| 4056 | return stackActions; | ||
| 4057 | }, | ||
| 4058 | search: true, | ||
| 4059 | reactions: true, | ||
| 4060 | review: FULL_REVIEW, | ||
| 4061 | reviewers: FULL_REVIEWERS, | ||
| 4062 | }; | ||
| 4063 | const service = yield* makeService({ | ||
| 4064 | projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], | ||
| 4065 | providers: [ | ||
| 4066 | fakeProvider("github", { | ||
| 4067 | capabilities, | ||
| 4068 | getViewerPermissions: () => | ||
| 4069 | Effect.succeed({ | ||
| 4070 | actions: [], | ||
| 4071 | stackRebase, | ||
| 4072 | comment: true, | ||
| 4073 | resolve: false, | ||
| 4074 | verdicts: [], | ||
| 4075 | requestReviewers: false, | ||
| 4076 | }), | ||
| 4077 | getChangeRequestSummary: () => | ||
| 4078 | Effect.sync(() => { | ||
| 4079 | summaryReads++; | ||
| 4080 | return changeRequest(8, "2026-07-01T00:00:00Z"); | ||
| 4081 | }), | ||
| 4082 | runAction: () => | ||
| 4083 | Effect.gen(function* () { | ||
| 4084 | taken++; | ||
| 4085 | if (mutationFails) return yield* requestFailed; | ||
| 4086 | }), | ||
| 4087 | }), | ||
| 4088 | ], | ||
| 4089 | }); | ||
| 4090 | const input = { | ||
| 4091 | projectId: "p1" as ProjectId, | ||
| 4092 | repository: "acme/web", | ||
| 4093 | number: 3, | ||
| 4094 | action: "update-branch" as const, | ||
| 4095 | updateMethod: "rebase" as const, | ||
| 4096 | stackNumber: 50, | ||
| 4097 | expectedStackHeads: [{ number: 3, headSha: "ccc" }], | ||
| 4098 | }; | ||
| 4099 | yield* service.runAction(input); | ||
| 4100 | assert.strictEqual(taken, 1); | ||
| 4101 | const unrelated = { ...input, number: 8 }; | ||
| 4102 | yield* service.summary(unrelated); | ||
| 4103 | assert.strictEqual(summaryReads, 1); | ||
| 4104 | stackRebase = false; | ||
| 4105 | assert.strictEqual( | ||
| 4106 | (yield* Effect.flip(service.runAction(input)))._tag, | ||
| 4107 | "PullRequestOperationError", | ||
| 4108 | ); | ||
| 4109 | stackRebase = true; | ||
| 4110 | stackActions = false; | ||
| 4111 | assert.strictEqual( | ||
| 4112 | (yield* Effect.flip(service.runAction(input)))._tag, | ||
| 4113 | "PullRequestOperationError", | ||
| 4114 | ); | ||
| 4115 | assert.strictEqual(taken, 1); | ||
| 4116 | yield* service.summary(unrelated); | ||
| 4117 | assert.strictEqual(summaryReads, 1); | ||
| 4118 | stackActions = true; | ||
| 4119 | mutationFails = true; | ||
| 4120 | yield* Effect.flip(service.runAction(input)); | ||
| 4121 | assert.strictEqual(taken, 2); | ||
| 4122 | yield* service.summary(unrelated); | ||
| 4123 | assert.strictEqual(summaryReads, 2); | ||
| 4124 | }), | ||
| 4125 | ); | ||
| 4126 | |||
| 4000 | it.effect("refuses a way of updating a branch that the host or the viewer does not allow", () => | 4127 | it.effect("refuses a way of updating a branch that the host or the viewer does not allow", () => |
| 4001 | Effect.gen(function* () { | 4128 | Effect.gen(function* () { |
| 4002 | let taken: string | null = null; | 4129 | let taken: string | null = null; |
apps/server/src/pullRequest/PullRequestService.ts+43-8| ... | @@ -155,6 +155,7 @@ export class PullRequestService extends Context.Service< | ... | @@ -155,6 +155,7 @@ export class PullRequestService extends Context.Service< |
| 155 | */ | 155 | */ |
| 156 | readonly stack: ( | 156 | readonly stack: ( |
| 157 | input: PullRequestRef, | 157 | input: PullRequestRef, |
| 158 | options?: { readonly includeDetails?: boolean }, | ||
| 158 | ) => Effect.Effect<PullRequestStack | null, PullRequestError>; | 159 | ) => Effect.Effect<PullRequestStack | null, PullRequestError>; |
| 159 | readonly subscribeMerges: Effect.Effect< | 160 | readonly subscribeMerges: Effect.Effect< |
| 160 | Stream.Stream<PullRequestMergeEvent>, | 161 | Stream.Stream<PullRequestMergeEvent>, |
| ... | @@ -907,6 +908,7 @@ export const make = Effect.gen(function* () { | ... | @@ -907,6 +908,7 @@ export const make = Effect.gen(function* () { |
| 907 | }): PullRequestListEntry => { | 908 | }): PullRequestListEntry => { |
| 908 | const viewer = input.viewer.toLowerCase(); | 909 | const viewer = input.viewer.toLowerCase(); |
| 909 | return { | 910 | return { |
| 911 | ...(input.item.stack === undefined ? {} : { stack: input.item.stack }), | ||
| 910 | provider: input.project.api.kind, | 912 | provider: input.project.api.kind, |
| 911 | host: input.project.host, | 913 | host: input.project.host, |
| 912 | projectId: input.project.project.id, | 914 | projectId: input.project.project.id, |
| ... | @@ -1331,7 +1333,7 @@ export const make = Effect.gen(function* () { | ... | @@ -1331,7 +1333,7 @@ export const make = Effect.gen(function* () { |
| 1331 | }), | 1333 | }), |
| 1332 | ); | 1334 | ); |
| 1333 | 1335 | ||
| 1334 | const stackUncached: PullRequestService["Service"]["stack"] = (input) => | 1336 | const stackUncached: PullRequestService["Service"]["stack"] = (input, options) => |
| 1335 | requireProject(input).pipe( | 1337 | requireProject(input).pipe( |
| 1336 | Effect.flatMap((project) => { | 1338 | Effect.flatMap((project) => { |
| 1337 | const read = project.api.getChangeRequestStack; | 1339 | const read = project.api.getChangeRequestStack; |
| ... | @@ -1341,6 +1343,7 @@ export const make = Effect.gen(function* () { | ... | @@ -1341,6 +1343,7 @@ export const make = Effect.gen(function* () { |
| 1341 | repository: project.repository, | 1343 | repository: project.repository, |
| 1342 | host: project.host, | 1344 | host: project.host, |
| 1343 | number: input.number, | 1345 | number: input.number, |
| 1346 | includeDetails: options?.includeDetails !== false, | ||
| 1344 | }).pipe( | 1347 | }).pipe( |
| 1345 | Effect.mapError(toPullRequestError("stack")), | 1348 | Effect.mapError(toPullRequestError("stack")), |
| 1346 | Effect.map((stack): PullRequestStack | null => | 1349 | Effect.map((stack): PullRequestStack | null => |
| ... | @@ -1352,6 +1355,7 @@ export const make = Effect.gen(function* () { | ... | @@ -1352,6 +1355,7 @@ export const make = Effect.gen(function* () { |
| 1352 | url: stack.url, | 1355 | url: stack.url, |
| 1353 | base: stack.base, | 1356 | base: stack.base, |
| 1354 | layers: stack.layers.map((layer) => ({ | 1357 | layers: stack.layers.map((layer) => ({ |
| 1358 | ...layer, | ||
| 1355 | number: layer.number, | 1359 | number: layer.number, |
| 1356 | headBranch: layer.headBranch, | 1360 | headBranch: layer.headBranch, |
| 1357 | state: layer.state, | 1361 | state: layer.state, |
| ... | @@ -1531,6 +1535,20 @@ export const make = Effect.gen(function* () { | ... | @@ -1531,6 +1535,20 @@ export const make = Effect.gen(function* () { |
| 1531 | const runAction = (input: PullRequestActionInput): Effect.Effect<string, PullRequestError> => | 1535 | const runAction = (input: PullRequestActionInput): Effect.Effect<string, PullRequestError> => |
| 1532 | requireProject(input).pipe( | 1536 | requireProject(input).pipe( |
| 1533 | Effect.flatMap((project): Effect.Effect<string, PullRequestError> => { | 1537 | Effect.flatMap((project): Effect.Effect<string, PullRequestError> => { |
| 1538 | if ( | ||
| 1539 | input.stackNumber !== undefined && | ||
| 1540 | (project.api.capabilities.stackActions !== true || | ||
| 1541 | !["merge", "update-branch"].includes(input.action) || | ||
| 1542 | input.expectedStackHeads === undefined || | ||
| 1543 | (input.action === "update-branch" && input.updateMethod !== "rebase")) | ||
| 1544 | ) { | ||
| 1545 | return Effect.fail( | ||
| 1546 | new PullRequestOperationError({ | ||
| 1547 | operation: "runAction", | ||
| 1548 | detail: "This stack action is not supported or has no expected head revision.", | ||
| 1549 | }), | ||
| 1550 | ); | ||
| 1551 | } | ||
| 1534 | // The surface hides what a host cannot do, and this refuses it as well: a request that | 1552 | // The surface hides what a host cannot do, and this refuses it as well: a request that |
| 1535 | // reached here anyway must not be handed to a provider that never claimed the action. | 1553 | // reached here anyway must not be handed to a provider that never claimed the action. |
| 1536 | if (!project.api.capabilities.actions.includes(input.action)) { | 1554 | if (!project.api.capabilities.actions.includes(input.action)) { |
| ... | @@ -1573,7 +1591,10 @@ export const make = Effect.gen(function* () { | ... | @@ -1573,7 +1591,10 @@ export const make = Effect.gen(function* () { |
| 1573 | // above do not. | 1591 | // above do not. |
| 1574 | return viewerPermissionsOf(project, input, "runAction").pipe( | 1592 | return viewerPermissionsOf(project, input, "runAction").pipe( |
| 1575 | Effect.flatMap((viewer): Effect.Effect<string, PullRequestError> => { | 1593 | Effect.flatMap((viewer): Effect.Effect<string, PullRequestError> => { |
| 1576 | if (!viewer.actions.includes(input.action)) { | 1594 | const stackRebase = input.stackNumber !== undefined && input.action === "update-branch"; |
| 1595 | if ( | ||
| 1596 | stackRebase ? viewer.stackRebase !== true : !viewer.actions.includes(input.action) | ||
| 1597 | ) { | ||
| 1577 | return Effect.fail( | 1598 | return Effect.fail( |
| 1578 | new PullRequestOperationError({ | 1599 | new PullRequestOperationError({ |
| 1579 | operation: "runAction", | 1600 | operation: "runAction", |
| ... | @@ -1582,6 +1603,7 @@ export const make = Effect.gen(function* () { | ... | @@ -1582,6 +1603,7 @@ export const make = Effect.gen(function* () { |
| 1582 | ); | 1603 | ); |
| 1583 | } | 1604 | } |
| 1584 | if ( | 1605 | if ( |
| 1606 | !stackRebase && | ||
| 1585 | input.updateMethod !== undefined && | 1607 | input.updateMethod !== undefined && |
| 1586 | !(viewer.updateMethods ?? []).includes(input.updateMethod) | 1608 | !(viewer.updateMethods ?? []).includes(input.updateMethod) |
| 1587 | ) { | 1609 | ) { |
| ... | @@ -1599,10 +1621,17 @@ export const make = Effect.gen(function* () { | ... | @@ -1599,10 +1621,17 @@ export const make = Effect.gen(function* () { |
| 1599 | host: project.host, | 1621 | host: project.host, |
| 1600 | number: input.number, | 1622 | number: input.number, |
| 1601 | action: input.action, | 1623 | action: input.action, |
| 1624 | ...(input.stackNumber === undefined ? {} : { stackNumber: input.stackNumber }), | ||
| 1625 | ...(input.expectedStackHeads === undefined | ||
| 1626 | ? {} | ||
| 1627 | : { expectedStackHeads: input.expectedStackHeads }), | ||
| 1602 | ...(input.mergeMethod === undefined ? {} : { mergeMethod: input.mergeMethod }), | 1628 | ...(input.mergeMethod === undefined ? {} : { mergeMethod: input.mergeMethod }), |
| 1603 | ...(input.updateMethod === undefined ? {} : { updateMethod: input.updateMethod }), | 1629 | ...(input.updateMethod === undefined ? {} : { updateMethod: input.updateMethod }), |
| 1604 | }) | 1630 | }) |
| 1605 | .pipe( | 1631 | .pipe( |
| 1632 | // Once the authorized provider action starts, a failure may leave partial | ||
| 1633 | // remote updates. Validation and permission failures above changed nothing. | ||
| 1634 | Effect.ensuring(input.stackNumber === undefined ? Effect.void : refreshAfterTurn), | ||
| 1606 | Effect.mapError(toPullRequestError("runAction")), | 1635 | Effect.mapError(toPullRequestError("runAction")), |
| 1607 | Effect.as( | 1636 | Effect.as( |
| 1608 | project.api.kind === "azure-devops" | 1637 | project.api.kind === "azure-devops" |
| ... | @@ -2327,12 +2356,18 @@ export const make = Effect.gen(function* () { | ... | @@ -2327,12 +2356,18 @@ export const make = Effect.gen(function* () { |
| 2327 | ); | 2356 | ); |
| 2328 | }; | 2357 | }; |
| 2329 | 2358 | ||
| 2330 | const stackCache = yield* Cache.makeWith((key: string) => stackUncached(refOfCacheKey(key)), { | 2359 | const stackCache = yield* Cache.makeWith( |
| 2331 | capacity: DETAIL_CACHE_CAPACITY, | 2360 | (key: string) => { |
| 2332 | timeToLive: (exit) => (Exit.isSuccess(exit) ? SUMMARY_CACHE_TTL : Duration.zero), | 2361 | const [referenceKey, includeDetails] = JSON.parse(key) as [string, boolean]; |
| 2333 | }); | 2362 | return stackUncached(refOfCacheKey(referenceKey), { includeDetails }); |
| 2334 | const stack: PullRequestService["Service"]["stack"] = (input) => | 2363 | }, |
| 2335 | Cache.get(stackCache, refCacheKey(input)); | 2364 | { |
| 2365 | capacity: DETAIL_CACHE_CAPACITY, | ||
| 2366 | timeToLive: (exit) => (Exit.isSuccess(exit) ? SUMMARY_CACHE_TTL : Duration.zero), | ||
| 2367 | }, | ||
| 2368 | ); | ||
| 2369 | const stack: PullRequestService["Service"]["stack"] = (input, options) => | ||
| 2370 | Cache.get(stackCache, JSON.stringify([refCacheKey(input), options?.includeDetails !== false])); | ||
| 2336 | 2371 | ||
| 2337 | // Keys serialize positionally and parse back in the lookup, so the cache is the only holder | 2372 | // Keys serialize positionally and parse back in the lookup, so the cache is the only holder |
| 2338 | // of in-flight state: concurrent identical reads coalesce on the key into one host request. | 2373 | // of in-flight state: concurrent identical reads coalesce on the key into one host request. |
apps/server/src/pullRequest/gitHubPullRequestJson.test.ts+75| ... | @@ -3,6 +3,8 @@ import { describe, expect, it } from "vite-plus/test"; | ... | @@ -3,6 +3,8 @@ import { describe, expect, it } from "vite-plus/test"; |
| 3 | 3 | ||
| 4 | import { | 4 | import { |
| 5 | buildReviewSubmissionJson, | 5 | buildReviewSubmissionJson, |
| 6 | buildPullRequestStackMembershipsGraphQlQuery, | ||
| 7 | decodePullRequestStackMembershipsJson, | ||
| 6 | buildReviewerRequestJson, | 8 | buildReviewerRequestJson, |
| 7 | decodeBaseComparisonJson, | 9 | decodeBaseComparisonJson, |
| 8 | decodePullRequestActivityJson, | 10 | decodePullRequestActivityJson, |
| ... | @@ -21,6 +23,7 @@ import { | ... | @@ -21,6 +23,7 @@ import { |
| 21 | decodeWorkflowRunApprovalsJson, | 23 | decodeWorkflowRunApprovalsJson, |
| 22 | reviewThreadConversation, | 24 | reviewThreadConversation, |
| 23 | REVIEW_THREADS_GRAPHQL_QUERY, | 25 | REVIEW_THREADS_GRAPHQL_QUERY, |
| 26 | pullRequestSearchGraphQlQuery, | ||
| 24 | } from "./gitHubPullRequestJson.ts"; | 27 | } from "./gitHubPullRequestJson.ts"; |
| 25 | 28 | ||
| 26 | function listJson(entries: ReadonlyArray<Record<string, unknown>>): string { | 29 | function listJson(entries: ReadonlyArray<Record<string, unknown>>): string { |
| ... | @@ -165,6 +168,17 @@ describe("pull request search decoding", () => { | ... | @@ -165,6 +168,17 @@ describe("pull request search decoding", () => { |
| 165 | }); | 168 | }); |
| 166 | } | 169 | } |
| 167 | 170 | ||
| 171 | it("keeps stack membership beside search results without extra per-PR reads", () => { | ||
| 172 | const raw = JSON.parse(searchJson(["SUCCESS", null])); | ||
| 173 | raw.data.search.nodes[0].stack = { number: 3, size: 2, baseRefName: "main" }; | ||
| 174 | raw.data.search.nodes[0].stackEntry = { position: 1 }; | ||
| 175 | const batch = expectSuccess(decodePullRequestSearchJson(JSON.stringify(raw))); | ||
| 176 | expect(batch.items[0]?.stack).toEqual({ number: 3, size: 2, position: 1, base: "main" }); | ||
| 177 | expect(batch.items[1]?.stack).toBeUndefined(); | ||
| 178 | expect(pullRequestSearchGraphQlQuery(20, true)).toContain("stackEntry"); | ||
| 179 | expect(pullRequestSearchGraphQlQuery(20)).not.toContain("stackEntry"); | ||
| 180 | }); | ||
| 181 | |||
| 168 | it("maps the rollup enum the search answers with onto the same three words", () => { | 182 | it("maps the rollup enum the search answers with onto the same three words", () => { |
| 169 | // The search asks GitHub for the verdict rather than the checks behind it, so this path sees | 183 | // The search asks GitHub for the verdict rather than the checks behind it, so this path sees |
| 170 | // one enum where the listing sees an array. | 184 | // one enum where the listing sees an array. |
| ... | @@ -1548,6 +1562,32 @@ describe("host-native stack decoding", () => { | ... | @@ -1548,6 +1562,32 @@ describe("host-native stack decoding", () => { |
| 1548 | }); | 1562 | }); |
| 1549 | }); | 1563 | }); |
| 1550 | 1564 | ||
| 1565 | it("retains the detailed layer titles, draft state and expected revision", () => { | ||
| 1566 | expect( | ||
| 1567 | expectStack({ | ||
| 1568 | pull_requests: [ | ||
| 1569 | { | ||
| 1570 | number: 11, | ||
| 1571 | title: "Second layer", | ||
| 1572 | draft: true, | ||
| 1573 | head: { ref: "feat/two", sha: "abc123" }, | ||
| 1574 | state: "open", | ||
| 1575 | merged_at: null, | ||
| 1576 | }, | ||
| 1577 | ], | ||
| 1578 | }).layers, | ||
| 1579 | ).toEqual([ | ||
| 1580 | { | ||
| 1581 | number: 11, | ||
| 1582 | title: "Second layer", | ||
| 1583 | isDraft: true, | ||
| 1584 | headSha: "abc123", | ||
| 1585 | headBranch: "feat/two", | ||
| 1586 | state: "open", | ||
| 1587 | }, | ||
| 1588 | ]); | ||
| 1589 | }); | ||
| 1590 | |||
| 1551 | it("accepts a base named as a bare branch, which is what the preview started out sending", () => { | 1591 | it("accepts a base named as a bare branch, which is what the preview started out sending", () => { |
| 1552 | expect(expectStack({ base: "develop" }).base).toBe("develop"); | 1592 | expect(expectStack({ base: "develop" }).base).toBe("develop"); |
| 1553 | }); | 1593 | }); |
| ... | @@ -1579,3 +1619,38 @@ describe("host-native stack decoding", () => { | ... | @@ -1579,3 +1619,38 @@ describe("host-native stack decoding", () => { |
| 1579 | expect(Result.isSuccess(decodePullRequestStacksJson("{"))).toBe(false); | 1619 | expect(Result.isSuccess(decodePullRequestStacksJson("{"))).toBe(false); |
| 1580 | }); | 1620 | }); |
| 1581 | }); | 1621 | }); |
| 1622 | |||
| 1623 | describe("pull request stack membership batches", () => { | ||
| 1624 | it("maps aliases while skipping missing pull requests and incomplete memberships", () => { | ||
| 1625 | const memberships = expectSuccess( | ||
| 1626 | decodePullRequestStackMembershipsJson( | ||
| 1627 | JSON.stringify({ | ||
| 1628 | data: { | ||
| 1629 | s0: { | ||
| 1630 | pullRequest: { | ||
| 1631 | stack: { number: 3, size: 2, baseRefName: "main" }, | ||
| 1632 | stackEntry: { position: 1 }, | ||
| 1633 | }, | ||
| 1634 | }, | ||
| 1635 | s1: null, | ||
| 1636 | s2: { pullRequest: null }, | ||
| 1637 | s3: { pullRequest: { stack: null, stackEntry: null } }, | ||
| 1638 | s4: { pullRequest: { stack: { number: 3, size: 2, baseRefName: "main" } } }, | ||
| 1639 | }, | ||
| 1640 | }), | ||
| 1641 | ), | ||
| 1642 | ); | ||
| 1643 | expect([...memberships]).toEqual([[0, { number: 3, size: 2, base: "main", position: 1 }]]); | ||
| 1644 | }); | ||
| 1645 | |||
| 1646 | it("refuses malformed responses and unsafe query selectors", () => { | ||
| 1647 | expect(Result.isFailure(decodePullRequestStackMembershipsJson('{"errors":[]}'))).toBe(true); | ||
| 1648 | expect(buildPullRequestStackMembershipsGraphQlQuery('acme/web") { x } #', [1])).toBeNull(); | ||
| 1649 | expect(buildPullRequestStackMembershipsGraphQlQuery("acme/web", [0])).toBeNull(); | ||
| 1650 | expect(buildPullRequestStackMembershipsGraphQlQuery("acme/web", [1.5])).toBeNull(); | ||
| 1651 | expect(buildPullRequestStackMembershipsGraphQlQuery("acme/web", [])).toBeNull(); | ||
| 1652 | expect(buildPullRequestStackMembershipsGraphQlQuery("acme/web", [7, 8])).toContain( | ||
| 1653 | "pullRequest(number: 8)", | ||
| 1654 | ); | ||
| 1655 | }); | ||
| 1656 | }); |
apps/server/src/pullRequest/gitHubPullRequestJson.ts+81-2| ... | @@ -3,6 +3,7 @@ import * as Exit from "effect/Exit"; | ... | @@ -3,6 +3,7 @@ import * as Exit from "effect/Exit"; |
| 3 | import * as Result from "effect/Result"; | 3 | import * as Result from "effect/Result"; |
| 4 | import * as Schema from "effect/Schema"; | 4 | import * as Schema from "effect/Schema"; |
| 5 | import type { | 5 | import type { |
| 6 | PullRequestStackMembership, | ||
| 6 | PullRequestActor, | 7 | PullRequestActor, |
| 7 | PullRequestCheck, | 8 | PullRequestCheck, |
| 8 | PullRequestCheckStatus, | 9 | PullRequestCheckStatus, |
| ... | @@ -111,12 +112,22 @@ const RawListItemSchema = Schema.Struct({ | ... | @@ -111,12 +112,22 @@ const RawListItemSchema = Schema.Struct({ |
| 111 | statusCheckRollup: Schema.optional(Schema.NullOr(Schema.Array(RawCheckSchema))), | 112 | statusCheckRollup: Schema.optional(Schema.NullOr(Schema.Array(RawCheckSchema))), |
| 112 | }); | 113 | }); |
| 113 | 114 | ||
| 115 | const RawStackMembershipSchema = Schema.Struct({ | ||
| 116 | stack: Schema.optional( | ||
| 117 | Schema.NullOr( | ||
| 118 | Schema.Struct({ number: Schema.Int, size: Schema.Int, baseRefName: Schema.String }), | ||
| 119 | ), | ||
| 120 | ), | ||
| 121 | stackEntry: Schema.optional(Schema.NullOr(Schema.Struct({ position: Schema.Int }))), | ||
| 122 | }); | ||
| 123 | |||
| 114 | /** | 124 | /** |
| 115 | * A search's own answer, which is the listing's row one connection deeper: `gh pr list --json` | 125 | * A search's own answer, which is the listing's row one connection deeper: `gh pr list --json` |
| 116 | * flattens reviewers and labels, and GraphQL does not. Everything below the row is optional | 126 | * flattens reviewers and labels, and GraphQL does not. Everything below the row is optional |
| 117 | * because a node that is not a pull request decodes as an empty object, which is skipped. | 127 | * because a node that is not a pull request decodes as an empty object, which is skipped. |
| 118 | */ | 128 | */ |
| 119 | const RawSearchItemSchema = Schema.Struct({ | 129 | const RawSearchItemSchema = Schema.Struct({ |
| 130 | ...RawStackMembershipSchema.fields, | ||
| 120 | number: Schema.Int, | 131 | number: Schema.Int, |
| 121 | title: Schema.String, | 132 | title: Schema.String, |
| 122 | url: Schema.String, | 133 | url: Schema.String, |
| ... | @@ -221,6 +232,13 @@ const RawStatsSchema = Schema.Struct({ | ... | @@ -221,6 +232,13 @@ const RawStatsSchema = Schema.Struct({ |
| 221 | ), | 232 | ), |
| 222 | }); | 233 | }); |
| 223 | 234 | ||
| 235 | const RawStackMembershipsSchema = Schema.Struct({ | ||
| 236 | data: Schema.Record( | ||
| 237 | Schema.String, | ||
| 238 | Schema.NullOr(Schema.Struct({ pullRequest: Schema.NullOr(RawStackMembershipSchema) })), | ||
| 239 | ), | ||
| 240 | }); | ||
| 241 | |||
| 224 | /** How many of a reaction's people the hover names before it counts the rest. */ | 242 | /** How many of a reaction's people the hover names before it counts the rest. */ |
| 225 | const REACTORS_PER_GROUP = 10; | 243 | const REACTORS_PER_GROUP = 10; |
| 226 | 244 | ||
| ... | @@ -648,12 +666,13 @@ export const PULL_REQUEST_SEARCH_MAX_ROWS = GRAPHQL_PAGE_SIZE; | ... | @@ -648,12 +666,13 @@ export const PULL_REQUEST_SEARCH_MAX_ROWS = GRAPHQL_PAGE_SIZE; |
| 648 | * than twenty labels shows twenty, and one that has asked more than twenty people for a review | 666 | * than twenty labels shows twenty, and one that has asked more than twenty people for a review |
| 649 | * is already past what a row can say. | 667 | * is already past what a row can say. |
| 650 | */ | 668 | */ |
| 651 | export function pullRequestSearchGraphQlQuery(rows: number): string { | 669 | export function pullRequestSearchGraphQlQuery(rows: number, includeStacks = false): string { |
| 652 | return `query($q: String!) { | 670 | return `query($q: String!) { |
| 653 | search(query: $q, type: ISSUE, first: ${Math.min(Math.max(Math.trunc(rows), 1), PULL_REQUEST_SEARCH_MAX_ROWS)}) { | 671 | search(query: $q, type: ISSUE, first: ${Math.min(Math.max(Math.trunc(rows), 1), PULL_REQUEST_SEARCH_MAX_ROWS)}) { |
| 654 | pageInfo { hasNextPage } | 672 | pageInfo { hasNextPage } |
| 655 | nodes { | 673 | nodes { |
| 656 | ... on PullRequest { | 674 | ... on PullRequest { |
| 675 | ${includeStacks ? "stack { number size baseRefName } stackEntry { position }" : ""} | ||
| 657 | number | 676 | number |
| 658 | title | 677 | title |
| 659 | url | 678 | url |
| ... | @@ -1019,6 +1038,7 @@ export const REPOSITORY_ACCESS_JSON_FIELDS = | ... | @@ -1019,6 +1038,7 @@ export const REPOSITORY_ACCESS_JSON_FIELDS = |
| 1019 | "mergeCommitAllowed,squashMergeAllowed,rebaseMergeAllowed,viewerPermission"; | 1038 | "mergeCommitAllowed,squashMergeAllowed,rebaseMergeAllowed,viewerPermission"; |
| 1020 | 1039 | ||
| 1021 | export interface GitHubPullRequestListItem { | 1040 | export interface GitHubPullRequestListItem { |
| 1041 | readonly stack?: PullRequestStackMembership; | ||
| 1022 | /** The author's node id, kept so a batch can resolve the avatar the listing does not carry. */ | 1042 | /** The author's node id, kept so a batch can resolve the avatar the listing does not carry. */ |
| 1023 | readonly authorId: string | null; | 1043 | readonly authorId: string | null; |
| 1024 | readonly number: number; | 1044 | readonly number: number; |
| ... | @@ -1526,6 +1546,7 @@ export function decodePullRequestSearchJson( | ... | @@ -1526,6 +1546,7 @@ export function decodePullRequestSearchJson( |
| 1526 | const node = decodedNode.value; | 1546 | const node = decodedNode.value; |
| 1527 | const repository = trimmed(node.repository?.nameWithOwner); | 1547 | const repository = trimmed(node.repository?.nameWithOwner); |
| 1528 | if (repository === null) continue; | 1548 | if (repository === null) continue; |
| 1549 | const stack = toStackMembership(node); | ||
| 1529 | items.push({ | 1550 | items.push({ |
| 1530 | ...toListItem({ | 1551 | ...toListItem({ |
| 1531 | ...node, | 1552 | ...node, |
| ... | @@ -1541,6 +1562,7 @@ export function decodePullRequestSearchJson( | ... | @@ -1541,6 +1562,7 @@ export function decodePullRequestSearchJson( |
| 1541 | return state === null ? [] : [{ state }]; | 1562 | return state === null ? [] : [{ state }]; |
| 1542 | }), | 1563 | }), |
| 1543 | }), | 1564 | }), |
| 1565 | ...(stack === undefined ? {} : { stack }), | ||
| 1544 | repository, | 1566 | repository, |
| 1545 | }); | 1567 | }); |
| 1546 | } | 1568 | } |
| ... | @@ -1551,6 +1573,19 @@ export function decodePullRequestSearchJson( | ... | @@ -1551,6 +1573,19 @@ export function decodePullRequestSearchJson( |
| 1551 | }); | 1573 | }); |
| 1552 | } | 1574 | } |
| 1553 | 1575 | ||
| 1576 | function toStackMembership( | ||
| 1577 | raw: Schema.Schema.Type<typeof RawStackMembershipSchema>, | ||
| 1578 | ): PullRequestStackMembership | undefined { | ||
| 1579 | return raw.stack && raw.stackEntry | ||
| 1580 | ? { | ||
| 1581 | number: raw.stack.number, | ||
| 1582 | size: raw.stack.size, | ||
| 1583 | base: raw.stack.baseRefName, | ||
| 1584 | position: raw.stackEntry.position, | ||
| 1585 | } | ||
| 1586 | : undefined; | ||
| 1587 | } | ||
| 1588 | |||
| 1554 | /** What a repository selector may hold before it is written into a GraphQL document unquoted. */ | 1589 | /** What a repository selector may hold before it is written into a GraphQL document unquoted. */ |
| 1555 | const REPOSITORY_PART = /^[A-Za-z0-9._-]+$/; | 1590 | const REPOSITORY_PART = /^[A-Za-z0-9._-]+$/; |
| 1556 | 1591 | ||
| ... | @@ -1581,6 +1616,42 @@ export function buildPullRequestStatsGraphQlQuery( | ... | @@ -1581,6 +1616,42 @@ export function buildPullRequestStatsGraphQlQuery( |
| 1581 | return `query {\n${selections.join("\n")}\n}`; | 1616 | return `query {\n${selections.join("\n")}\n}`; |
| 1582 | } | 1617 | } |
| 1583 | 1618 | ||
| 1619 | /** Stack membership for the visible rows of a per-repository listing. */ | ||
| 1620 | export function buildPullRequestStackMembershipsGraphQlQuery( | ||
| 1621 | repository: string, | ||
| 1622 | numbers: ReadonlyArray<number>, | ||
| 1623 | ): string | null { | ||
| 1624 | if (numbers.length === 0) return null; | ||
| 1625 | const [owner, name, ...rest] = repository.trim().split("/"); | ||
| 1626 | if (rest.length > 0 || owner === undefined || name === undefined) return null; | ||
| 1627 | if (!REPOSITORY_PART.test(owner) || !REPOSITORY_PART.test(name)) return null; | ||
| 1628 | const selections: string[] = []; | ||
| 1629 | for (const [index, number] of numbers.entries()) { | ||
| 1630 | if (!Number.isSafeInteger(number) || number <= 0) return null; | ||
| 1631 | selections.push( | ||
| 1632 | ` s${index}: repository(owner: "${owner}", name: "${name}") { pullRequest(number: ${number}) { stack { number size baseRefName } stackEntry { position } } }`, | ||
| 1633 | ); | ||
| 1634 | } | ||
| 1635 | return `query PullRequestStackMemberships {\n${selections.join("\n")}\n}`; | ||
| 1636 | } | ||
| 1637 | |||
| 1638 | const decodeStackMemberships = decodeJsonResult(RawStackMembershipsSchema); | ||
| 1639 | |||
| 1640 | export function decodePullRequestStackMembershipsJson( | ||
| 1641 | raw: string, | ||
| 1642 | ): Result.Result<ReadonlyMap<number, PullRequestStackMembership>, DecodeFailure> { | ||
| 1643 | const decoded = decodeStackMemberships(raw); | ||
| 1644 | if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure); | ||
| 1645 | const memberships = new Map<number, PullRequestStackMembership>(); | ||
| 1646 | for (const [alias, value] of Object.entries(decoded.success.data)) { | ||
| 1647 | const index = /^s(\d+)$/.exec(alias)?.[1]; | ||
| 1648 | if (index === undefined || value?.pullRequest == null) continue; | ||
| 1649 | const stack = toStackMembership(value.pullRequest); | ||
| 1650 | if (stack !== undefined) memberships.set(Number(index), stack); | ||
| 1651 | } | ||
| 1652 | return Result.succeed(memberships); | ||
| 1653 | } | ||
| 1654 | |||
| 1584 | /** | 1655 | /** |
| 1585 | * The counts by the position they were asked in. A repository or a pull request GitHub answered | 1656 | * The counts by the position they were asked in. A repository or a pull request GitHub answered |
| 1586 | * nothing for is simply absent, which leaves the row with whatever it already had. | 1657 | * nothing for is simply absent, which leaves the row with whatever it already had. |
| ... | @@ -2448,8 +2519,10 @@ export function decodePullRequestFilesJson( | ... | @@ -2448,8 +2519,10 @@ export function decodePullRequestFilesJson( |
| 2448 | 2519 | ||
| 2449 | /** One pull request as the stacks API lists it: a number, a head, and whether it is done. */ | 2520 | /** One pull request as the stacks API lists it: a number, a head, and whether it is done. */ |
| 2450 | const RawStackPullRequestSchema = Schema.Struct({ | 2521 | const RawStackPullRequestSchema = Schema.Struct({ |
| 2522 | title: Schema.optional(Schema.String), | ||
| 2523 | draft: Schema.optional(Schema.Boolean), | ||
| 2451 | number: Schema.Int, | 2524 | number: Schema.Int, |
| 2452 | head: Schema.Struct({ ref: Schema.String }), | 2525 | head: Schema.Struct({ ref: Schema.String, sha: Schema.optional(Schema.String) }), |
| 2453 | state: Schema.optional(Schema.NullOr(Schema.String)), | 2526 | state: Schema.optional(Schema.NullOr(Schema.String)), |
| 2454 | merged_at: Schema.optional(Schema.NullOr(Schema.String)), | 2527 | merged_at: Schema.optional(Schema.NullOr(Schema.String)), |
| 2455 | }); | 2528 | }); |
| ... | @@ -2473,6 +2546,9 @@ const RawStackSchema = Schema.Struct({ | ... | @@ -2473,6 +2546,9 @@ const RawStackSchema = Schema.Struct({ |
| 2473 | const decodeStacks = decodeJsonResult(Schema.Array(RawStackSchema)); | 2546 | const decodeStacks = decodeJsonResult(Schema.Array(RawStackSchema)); |
| 2474 | 2547 | ||
| 2475 | export interface GitHubPullRequestStackLayer { | 2548 | export interface GitHubPullRequestStackLayer { |
| 2549 | readonly title?: string; | ||
| 2550 | readonly isDraft?: boolean; | ||
| 2551 | readonly headSha?: string; | ||
| 2476 | readonly number: number; | 2552 | readonly number: number; |
| 2477 | readonly headBranch: string; | 2553 | readonly headBranch: string; |
| 2478 | readonly state: PullRequestState; | 2554 | readonly state: PullRequestState; |
| ... | @@ -2505,6 +2581,9 @@ export function decodePullRequestStacksJson( | ... | @@ -2505,6 +2581,9 @@ export function decodePullRequestStacksJson( |
| 2505 | url: trimmed(stack.html_url) ?? stack.url, | 2581 | url: trimmed(stack.html_url) ?? stack.url, |
| 2506 | base: typeof stack.base === "string" ? stack.base : stack.base.ref, | 2582 | base: typeof stack.base === "string" ? stack.base : stack.base.ref, |
| 2507 | layers: stack.pull_requests.map((pullRequest) => ({ | 2583 | layers: stack.pull_requests.map((pullRequest) => ({ |
| 2584 | ...(pullRequest.title === undefined ? {} : { title: pullRequest.title }), | ||
| 2585 | ...(pullRequest.draft === undefined ? {} : { isDraft: pullRequest.draft }), | ||
| 2586 | ...(pullRequest.head.sha === undefined ? {} : { headSha: pullRequest.head.sha }), | ||
| 2508 | number: pullRequest.number, | 2587 | number: pullRequest.number, |
| 2509 | headBranch: pullRequest.head.ref, | 2588 | headBranch: pullRequest.head.ref, |
| 2510 | state: toState({ state: pullRequest.state, mergedAt: pullRequest.merged_at }), | 2589 | state: toState({ state: pullRequest.state, mergedAt: pullRequest.merged_at }), |
apps/server/src/pullRequest/githubStackActions.test.ts created+413| ... | @@ -0,0 +1,413 @@ | ||
| 1 | import { expect, it } from "@effect/vitest"; | ||
| 2 | import * as Layer from "effect/Layer"; | ||
| 3 | import * as Effect from "effect/Effect"; | ||
| 4 | import * as Fiber from "effect/Fiber"; | ||
| 5 | import * as TestClock from "effect/testing/TestClock"; | ||
| 6 | import { ChildProcessSpawner } from "effect/unstable/process"; | ||
| 7 | import * as GitHubCli from "../sourceControl/GitHubCli.ts"; | ||
| 8 | import { runGitHubStackAction as runStackAction } from "./githubStackActions.ts"; | ||
| 9 | |||
| 10 | const runGitHubStackAction = ( | ||
| 11 | execute: GitHubCli.GitHubCli["Service"]["execute"], | ||
| 12 | input: Parameters<typeof runStackAction>[0], | ||
| 13 | ) => runStackAction(input).pipe(Effect.provide(Layer.mock(GitHubCli.GitHubCli)({ execute }))); | ||
| 14 | |||
| 15 | const stack = [ | ||
| 16 | { | ||
| 17 | number: 50, | ||
| 18 | url: "https://api.github.com/repos/acme/web/stacks/50", | ||
| 19 | base: { ref: "main" }, | ||
| 20 | pull_requests: [ | ||
| 21 | { | ||
| 22 | number: 1, | ||
| 23 | title: "Base", | ||
| 24 | head: { ref: "base", sha: "aaa" }, | ||
| 25 | state: "closed", | ||
| 26 | merged_at: "2026-01-01T00:00:00Z", | ||
| 27 | }, | ||
| 28 | { | ||
| 29 | number: 2, | ||
| 30 | title: "Middle", | ||
| 31 | head: { ref: "middle", sha: "bbb" }, | ||
| 32 | state: "open", | ||
| 33 | draft: false, | ||
| 34 | }, | ||
| 35 | { number: 3, title: "Top", head: { ref: "top", sha: "ccc" }, state: "open", draft: false }, | ||
| 36 | ], | ||
| 37 | }, | ||
| 38 | ]; | ||
| 39 | const input = { | ||
| 40 | cwd: "/repo", | ||
| 41 | repository: "acme/web", | ||
| 42 | host: "github.com", | ||
| 43 | number: 3, | ||
| 44 | stackNumber: 50, | ||
| 45 | expectedStackHeads: [ | ||
| 46 | { number: 2, headSha: "bbb" }, | ||
| 47 | { number: 3, headSha: "ccc" }, | ||
| 48 | ], | ||
| 49 | action: "merge" as const, | ||
| 50 | }; | ||
| 51 | const access = { | ||
| 52 | data: { | ||
| 53 | repository: { | ||
| 54 | pr2: { headRepository: { viewerPermission: "WRITE" }, maintainerCanModify: false }, | ||
| 55 | pr3: { headRepository: { viewerPermission: "WRITE" }, maintainerCanModify: false }, | ||
| 56 | }, | ||
| 57 | }, | ||
| 58 | }; | ||
| 59 | |||
| 60 | const branch = (number: number, headRefOid: string, behindBy = 1, processed: string[] = []) => ({ | ||
| 61 | data: { | ||
| 62 | processed: processed.map((headRefOid) => ({ headRefOid })), | ||
| 63 | repository: { | ||
| 64 | pullRequest: { id: `PR_${number}`, headRefOid, baseRef: { compare: { behindBy } } }, | ||
| 65 | }, | ||
| 66 | }, | ||
| 67 | }); | ||
| 68 | const rebased = { | ||
| 69 | data: { updatePullRequestBranch: { pullRequest: { headRefOid: "rebased-sha" } } }, | ||
| 70 | }; | ||
| 71 | const rebaseResponses = [branch(2, "bbb"), rebased, branch(3, "ccc", 1, ["rebased-sha"]), rebased]; | ||
| 72 | |||
| 73 | function fake(responses: readonly unknown[]) { | ||
| 74 | const calls: ReadonlyArray<string>[] = []; | ||
| 75 | const execute: GitHubCli.GitHubCli["Service"]["execute"] = (request) => | ||
| 76 | Effect.sync(() => { | ||
| 77 | calls.push(request.args); | ||
| 78 | const value = responses[calls.length - 1]; | ||
| 79 | if (value === undefined) throw new Error("Unexpected GitHub request"); | ||
| 80 | return { | ||
| 81 | exitCode: ChildProcessSpawner.ExitCode(0), | ||
| 82 | // @effect-diagnostics-next-line preferSchemaOverJson:off | ||
| 83 | stdout: JSON.stringify(value), | ||
| 84 | stderr: "", | ||
| 85 | stdoutTruncated: false, | ||
| 86 | stderrTruncated: false, | ||
| 87 | stdoutInvalidUtf8: false, | ||
| 88 | }; | ||
| 89 | }); | ||
| 90 | return { execute, calls }; | ||
| 91 | } | ||
| 92 | |||
| 93 | it.effect("submits one atomic merge with the reviewed head and respects the merge queue", () => | ||
| 94 | Effect.gen(function* () { | ||
| 95 | const api = fake([stack, { status: "enqueued", details: {} }]); | ||
| 96 | yield* runGitHubStackAction(api.execute, { ...input, mergeMethod: "squash" }); | ||
| 97 | expect(api.calls).toHaveLength(2); | ||
| 98 | expect(api.calls[1]).toContain("repos/acme/web/pulls/3/merge-async"); | ||
| 99 | expect(api.calls[1]).toContain("sha=ccc"); | ||
| 100 | expect(api.calls[1]).toContain("merge_action=default"); | ||
| 101 | expect(api.calls[1]).toContain("merge_method=squash"); | ||
| 102 | }), | ||
| 103 | ); | ||
| 104 | |||
| 105 | it.effect("merges through the selected layer without including later draft layers", () => | ||
| 106 | Effect.gen(function* () { | ||
| 107 | const fiveLayers = [ | ||
| 108 | { | ||
| 109 | ...stack[0], | ||
| 110 | pull_requests: Array.from({ length: 5 }, (_, index) => ({ | ||
| 111 | number: index + 1, | ||
| 112 | head: { ref: `layer-${index + 1}`, sha: `sha-${index + 1}` }, | ||
| 113 | state: "open", | ||
| 114 | draft: index >= 3, | ||
| 115 | })), | ||
| 116 | }, | ||
| 117 | ]; | ||
| 118 | const api = fake([fiveLayers, { status: "merged", details: {} }]); | ||
| 119 | yield* runGitHubStackAction(api.execute, { | ||
| 120 | ...input, | ||
| 121 | number: 3, | ||
| 122 | expectedStackHeads: [1, 2, 3].map((number) => ({ number, headSha: `sha-${number}` })), | ||
| 123 | }); | ||
| 124 | expect(api.calls).toHaveLength(2); | ||
| 125 | expect(api.calls[1]).toContain("repos/acme/web/pulls/3/merge-async"); | ||
| 126 | expect(api.calls[1]).toContain("sha=sha-3"); | ||
| 127 | expect(api.calls[1]).toContain("merge_action=default"); | ||
| 128 | }), | ||
| 129 | ); | ||
| 130 | |||
| 131 | it.effect("rejects stale reviewed heads below a selected middle layer", () => | ||
| 132 | Effect.gen(function* () { | ||
| 133 | const api = fake([stack]); | ||
| 134 | const result = yield* runGitHubStackAction(api.execute, { | ||
| 135 | ...input, | ||
| 136 | number: 2, | ||
| 137 | expectedStackHeads: [{ number: 2, headSha: "old-head" }], | ||
| 138 | }).pipe(Effect.result); | ||
| 139 | expect(result).toMatchObject({ _tag: "Failure", failure: { _tag: "GitHubStackChangedError" } }); | ||
| 140 | expect(api.calls).toHaveLength(1); | ||
| 141 | }), | ||
| 142 | ); | ||
| 143 | |||
| 144 | it.effect("does not merge from an already merged layer", () => | ||
| 145 | Effect.gen(function* () { | ||
| 146 | const api = fake([stack]); | ||
| 147 | const result = yield* runGitHubStackAction(api.execute, { | ||
| 148 | ...input, | ||
| 149 | number: 1, | ||
| 150 | expectedStackHeads: [], | ||
| 151 | }).pipe(Effect.result); | ||
| 152 | expect(result).toMatchObject({ | ||
| 153 | _tag: "Failure", | ||
| 154 | failure: { _tag: "GitHubStackUnsupportedError" }, | ||
| 155 | }); | ||
| 156 | expect(api.calls).toHaveLength(1); | ||
| 157 | }), | ||
| 158 | ); | ||
| 159 | |||
| 160 | it.effect("polls an accepted merge and reports a later rule rejection", () => | ||
| 161 | Effect.gen(function* () { | ||
| 162 | const api = fake([ | ||
| 163 | stack, | ||
| 164 | { status: "pending", details: { uuid: "operation" } }, | ||
| 165 | { status: "failed", details: { message: "Required checks have not passed" } }, | ||
| 166 | ]); | ||
| 167 | const fiber = yield* runGitHubStackAction(api.execute, input).pipe( | ||
| 168 | Effect.result, | ||
| 169 | Effect.forkChild, | ||
| 170 | ); | ||
| 171 | yield* TestClock.adjust("1 second"); | ||
| 172 | const result = yield* Fiber.join(fiber); | ||
| 173 | expect(result).toMatchObject({ | ||
| 174 | _tag: "Failure", | ||
| 175 | failure: { _tag: "GitHubStackMergeRejectedError" }, | ||
| 176 | }); | ||
| 177 | expect(api.calls[2]).toContain("repos/acme/web/pulls/3/merge-async/operation"); | ||
| 178 | }), | ||
| 179 | ); | ||
| 180 | |||
| 181 | it.effect("retains stack identity and a rejection response without a message", () => | ||
| 182 | Effect.gen(function* () { | ||
| 183 | const rejection = { status: "failed", details: {} }; | ||
| 184 | const api = fake([stack, rejection]); | ||
| 185 | const result = yield* runGitHubStackAction(api.execute, input).pipe(Effect.result); | ||
| 186 | expect(result).toMatchObject({ | ||
| 187 | _tag: "Failure", | ||
| 188 | failure: { | ||
| 189 | _tag: "GitHubStackMergeRejectedError", | ||
| 190 | repository: input.repository, | ||
| 191 | number: input.number, | ||
| 192 | stackNumber: input.stackNumber, | ||
| 193 | cause: rejection, | ||
| 194 | }, | ||
| 195 | }); | ||
| 196 | }), | ||
| 197 | ); | ||
| 198 | |||
| 199 | it.effect("refuses a changed stack before performing any mutation", () => | ||
| 200 | Effect.gen(function* () { | ||
| 201 | const api = fake([stack]); | ||
| 202 | const result = yield* runGitHubStackAction(api.execute, { | ||
| 203 | ...input, | ||
| 204 | expectedStackHeads: [ | ||
| 205 | { number: 2, headSha: "old" }, | ||
| 206 | { number: 3, headSha: "ccc" }, | ||
| 207 | ], | ||
| 208 | }).pipe(Effect.result); | ||
| 209 | expect(result).toMatchObject({ _tag: "Failure", failure: { _tag: "GitHubStackChangedError" } }); | ||
| 210 | expect(api.calls).toHaveLength(1); | ||
| 211 | }), | ||
| 212 | ); | ||
| 213 | |||
| 214 | it.effect("rebases unmerged layers bottom to top without local git commands", () => | ||
| 215 | Effect.gen(function* () { | ||
| 216 | const api = fake([stack, access, ...rebaseResponses]); | ||
| 217 | yield* runGitHubStackAction(api.execute, { ...input, action: "update-branch" }); | ||
| 218 | const mutations = api.calls.filter((args) => | ||
| 219 | args.some((arg) => arg.startsWith("query=mutation")), | ||
| 220 | ); | ||
| 221 | expect(mutations).toHaveLength(2); | ||
| 222 | expect(mutations[0]).toContain("id=PR_2"); | ||
| 223 | expect(mutations[0]).toContain("sha=bbb"); | ||
| 224 | expect(mutations[1]).toContain("id=PR_3"); | ||
| 225 | expect(mutations[1]).toContain("sha=ccc"); | ||
| 226 | expect(api.calls.every((args) => args[0] === "api")).toBe(true); | ||
| 227 | }), | ||
| 228 | ); | ||
| 229 | |||
| 230 | it.effect("does not update later layers after a rebase failure", () => | ||
| 231 | Effect.gen(function* () { | ||
| 232 | const api = fake([stack, access, branch(2, "bbb")]); | ||
| 233 | const execute: typeof api.execute = (request) => | ||
| 234 | !request.args.some((arg) => arg.startsWith("query=mutation")) | ||
| 235 | ? api.execute(request) | ||
| 236 | : Effect.fail( | ||
| 237 | new GitHubCli.GitHubCliAuthenticationError({ | ||
| 238 | command: "gh", | ||
| 239 | cwd: "/repo", | ||
| 240 | cause: new Error("denied"), | ||
| 241 | }), | ||
| 242 | ); | ||
| 243 | const result = yield* runGitHubStackAction(execute, { ...input, action: "update-branch" }).pipe( | ||
| 244 | Effect.result, | ||
| 245 | ); | ||
| 246 | expect(result).toMatchObject({ | ||
| 247 | _tag: "Failure", | ||
| 248 | failure: { _tag: "GitHubStackRebaseFailedError", number: 2, completed: 0 }, | ||
| 249 | }); | ||
| 250 | }), | ||
| 251 | ); | ||
| 252 | |||
| 253 | it.effect("refuses the entire rebase before mutation when a later fork denies write access", () => | ||
| 254 | Effect.gen(function* () { | ||
| 255 | const api = fake([ | ||
| 256 | stack, | ||
| 257 | { | ||
| 258 | data: { | ||
| 259 | repository: { | ||
| 260 | ...access.data.repository, | ||
| 261 | pr3: { headRepository: { viewerPermission: "READ" }, maintainerCanModify: false }, | ||
| 262 | }, | ||
| 263 | }, | ||
| 264 | }, | ||
| 265 | ]); | ||
| 266 | const result = yield* runGitHubStackAction(api.execute, { | ||
| 267 | ...input, | ||
| 268 | action: "update-branch", | ||
| 269 | }).pipe(Effect.result); | ||
| 270 | expect(result).toMatchObject({ | ||
| 271 | _tag: "Failure", | ||
| 272 | failure: { _tag: "GitHubStackPermissionError" }, | ||
| 273 | }); | ||
| 274 | expect(api.calls).toHaveLength(2); | ||
| 275 | expect(api.calls.every((args) => args[0] === "api")).toBe(true); | ||
| 276 | }), | ||
| 277 | ); | ||
| 278 | |||
| 279 | it.effect("allows a fork that explicitly permits maintainer updates", () => | ||
| 280 | Effect.gen(function* () { | ||
| 281 | const api = fake([ | ||
| 282 | stack, | ||
| 283 | { | ||
| 284 | data: { | ||
| 285 | repository: { | ||
| 286 | ...access.data.repository, | ||
| 287 | pr3: { headRepository: { viewerPermission: "READ" }, maintainerCanModify: true }, | ||
| 288 | }, | ||
| 289 | }, | ||
| 290 | }, | ||
| 291 | ...rebaseResponses, | ||
| 292 | ]); | ||
| 293 | yield* runGitHubStackAction(api.execute, { ...input, action: "update-branch" }); | ||
| 294 | expect(api.calls.at(-1)).toContain("id=PR_3"); | ||
| 295 | }), | ||
| 296 | ); | ||
| 297 | |||
| 298 | it.effect("bounds polling and reports a still-running merge without claiming success", () => | ||
| 299 | Effect.gen(function* () { | ||
| 300 | const api = fake([ | ||
| 301 | stack, | ||
| 302 | ...Array.from({ length: 40 }, () => ({ status: "pending", details: { uuid: "operation" } })), | ||
| 303 | ]); | ||
| 304 | const fiber = yield* runGitHubStackAction(api.execute, input).pipe( | ||
| 305 | Effect.result, | ||
| 306 | Effect.forkChild, | ||
| 307 | ); | ||
| 308 | yield* TestClock.adjust("6 minutes"); | ||
| 309 | expect(yield* Fiber.join(fiber)).toMatchObject({ | ||
| 310 | _tag: "Failure", | ||
| 311 | failure: { _tag: "GitHubStackMergePendingError" }, | ||
| 312 | }); | ||
| 313 | expect(api.calls.length).toBeLessThan(40); | ||
| 314 | }), | ||
| 315 | ); | ||
| 316 | |||
| 317 | it.effect("rejects a push after preflight without rebasing the new revision", () => | ||
| 318 | Effect.gen(function* () { | ||
| 319 | const api = fake([stack, access, branch(2, "new-head")]); | ||
| 320 | const result = yield* runGitHubStackAction(api.execute, { | ||
| 321 | ...input, | ||
| 322 | action: "update-branch", | ||
| 323 | }).pipe(Effect.result); | ||
| 324 | expect(result).toMatchObject({ | ||
| 325 | _tag: "Failure", | ||
| 326 | failure: { _tag: "GitHubStackChangedError", number: 2, completed: 0 }, | ||
| 327 | }); | ||
| 328 | expect(api.calls).toHaveLength(3); | ||
| 329 | }), | ||
| 330 | ); | ||
| 331 | |||
| 332 | it.effect("skips current layers without submitting a rebase mutation", () => | ||
| 333 | Effect.gen(function* () { | ||
| 334 | const api = fake([stack, access, branch(2, "bbb", 0), branch(3, "ccc", 0, ["bbb"])]); | ||
| 335 | yield* runGitHubStackAction(api.execute, { ...input, action: "update-branch" }); | ||
| 336 | expect(api.calls.some((args) => args.some((arg) => arg.startsWith("query=mutation")))).toBe( | ||
| 337 | false, | ||
| 338 | ); | ||
| 339 | }), | ||
| 340 | ); | ||
| 341 | |||
| 342 | it.effect("keeps earlier progress and stops after a later layer fails", () => | ||
| 343 | Effect.gen(function* () { | ||
| 344 | const api = fake([ | ||
| 345 | stack, | ||
| 346 | access, | ||
| 347 | branch(2, "bbb"), | ||
| 348 | rebased, | ||
| 349 | branch(3, "ccc", 1, ["rebased-sha"]), | ||
| 350 | { data: { updatePullRequestBranch: null } }, | ||
| 351 | ]); | ||
| 352 | const result = yield* runGitHubStackAction(api.execute, { | ||
| 353 | ...input, | ||
| 354 | action: "update-branch", | ||
| 355 | }).pipe(Effect.result); | ||
| 356 | expect(result).toMatchObject({ | ||
| 357 | _tag: "Failure", | ||
| 358 | failure: { _tag: "GitHubStackRebaseFailedError", number: 3, completed: 1 }, | ||
| 359 | }); | ||
| 360 | }), | ||
| 361 | ); | ||
| 362 | |||
| 363 | it.effect("reports partial progress when a later head changes during the rebase", () => | ||
| 364 | Effect.gen(function* () { | ||
| 365 | const api = fake([ | ||
| 366 | stack, | ||
| 367 | access, | ||
| 368 | branch(2, "bbb"), | ||
| 369 | rebased, | ||
| 370 | branch(3, "concurrent-head", 1, ["rebased-sha"]), | ||
| 371 | ]); | ||
| 372 | const result = yield* runGitHubStackAction(api.execute, { | ||
| 373 | ...input, | ||
| 374 | action: "update-branch", | ||
| 375 | }).pipe(Effect.result); | ||
| 376 | expect(result).toMatchObject({ | ||
| 377 | _tag: "Failure", | ||
| 378 | failure: { _tag: "GitHubStackChangedError", number: 3, completed: 1 }, | ||
| 379 | }); | ||
| 380 | if (result._tag === "Failure") { | ||
| 381 | expect(result.failure.message).toContain("Earlier updates remain on GitHub"); | ||
| 382 | } | ||
| 383 | expect( | ||
| 384 | api.calls.filter((args) => args.some((arg) => arg.startsWith("query=mutation"))), | ||
| 385 | ).toHaveLength(1); | ||
| 386 | }), | ||
| 387 | ); | ||
| 388 | |||
| 389 | it.effect.each([false, true])("rejects a push to a processed layer, rebased=%s", (rebasedParent) => | ||
| 390 | Effect.gen(function* () { | ||
| 391 | const api = fake([ | ||
| 392 | stack, | ||
| 393 | access, | ||
| 394 | branch(2, "bbb", rebasedParent ? 1 : 0), | ||
| 395 | ...(rebasedParent ? [rebased] : []), | ||
| 396 | branch(3, "ccc", 1, ["concurrent-parent-head"]), | ||
| 397 | ]); | ||
| 398 | const result = yield* runGitHubStackAction(api.execute, { | ||
| 399 | ...input, | ||
| 400 | action: "update-branch", | ||
| 401 | }).pipe(Effect.result); | ||
| 402 | expect(result).toMatchObject({ | ||
| 403 | _tag: "Failure", | ||
| 404 | failure: { _tag: "GitHubStackChangedError", number: 2, completed: 1 }, | ||
| 405 | }); | ||
| 406 | expect(api.calls.at(-1)?.some((arg) => arg.includes('processed:nodes(ids:["PR_2"])'))).toBe( | ||
| 407 | true, | ||
| 408 | ); | ||
| 409 | expect( | ||
| 410 | api.calls.filter((args) => args.some((arg) => arg.startsWith("query=mutation"))), | ||
| 411 | ).toHaveLength(rebasedParent ? 1 : 0); | ||
| 412 | }), | ||
| 413 | ); | ||
apps/server/src/pullRequest/githubStackActions.ts created+418| ... | @@ -0,0 +1,418 @@ | ||
| 1 | import type { | ||
| 2 | PullRequestAction, | ||
| 3 | PullRequestMergeMethod, | ||
| 4 | PullRequestStackHead, | ||
| 5 | } from "@t3tools/contracts"; | ||
| 6 | import * as Effect from "effect/Effect"; | ||
| 7 | import * as Clock from "effect/Clock"; | ||
| 8 | import * as Result from "effect/Result"; | ||
| 9 | import * as Schema from "effect/Schema"; | ||
| 10 | |||
| 11 | import * as GitHubCli from "../sourceControl/GitHubCli.ts"; | ||
| 12 | import { decodePullRequestStacksJson } from "./gitHubPullRequestJson.ts"; | ||
| 13 | |||
| 14 | const stackErrorIdentity = { | ||
| 15 | repository: Schema.String, | ||
| 16 | number: Schema.Int, | ||
| 17 | stackNumber: Schema.Int, | ||
| 18 | }; | ||
| 19 | |||
| 20 | export class GitHubStackChangedError extends Schema.TaggedError<GitHubStackChangedError>()( | ||
| 21 | "GitHubStackChangedError", | ||
| 22 | { ...stackErrorIdentity, completed: Schema.Int }, | ||
| 23 | ) { | ||
| 24 | get detail(): string { | ||
| 25 | return this.message; | ||
| 26 | } | ||
| 27 | |||
| 28 | override get message(): string { | ||
| 29 | return this.completed > 0 | ||
| 30 | ? `The stack changed at PR #${this.number} after ${this.completed} layers. Earlier updates remain on GitHub. Refresh it before trying again.` | ||
| 31 | : "The stack changed. Refresh it before trying again."; | ||
| 32 | } | ||
| 33 | } | ||
| 34 | |||
| 35 | export class GitHubStackUnsupportedError extends Schema.TaggedError<GitHubStackUnsupportedError>()( | ||
| 36 | "GitHubStackUnsupportedError", | ||
| 37 | stackErrorIdentity, | ||
| 38 | ) { | ||
| 39 | get detail(): string { | ||
| 40 | return this.message; | ||
| 41 | } | ||
| 42 | |||
| 43 | override get message(): string { | ||
| 44 | return "This operation is not supported for this stack."; | ||
| 45 | } | ||
| 46 | } | ||
| 47 | |||
| 48 | export class GitHubStackResponseInvalidError extends Schema.TaggedError<GitHubStackResponseInvalidError>()( | ||
| 49 | "GitHubStackResponseInvalidError", | ||
| 50 | { ...stackErrorIdentity, cause: Schema.optional(Schema.Defect()) }, | ||
| 51 | ) { | ||
| 52 | get detail(): string { | ||
| 53 | return this.message; | ||
| 54 | } | ||
| 55 | |||
| 56 | override get message(): string { | ||
| 57 | return "GitHub returned an unreadable stack operation response."; | ||
| 58 | } | ||
| 59 | } | ||
| 60 | |||
| 61 | export class GitHubStackMergeRejectedError extends Schema.TaggedError<GitHubStackMergeRejectedError>()( | ||
| 62 | "GitHubStackMergeRejectedError", | ||
| 63 | { ...stackErrorIdentity, cause: Schema.Defect() }, | ||
| 64 | ) { | ||
| 65 | get detail(): string { | ||
| 66 | return this.message; | ||
| 67 | } | ||
| 68 | |||
| 69 | override get message(): string { | ||
| 70 | return "GitHub refused the stack merge. Check the stack's branch rules and merge requirements."; | ||
| 71 | } | ||
| 72 | } | ||
| 73 | |||
| 74 | export class GitHubStackMergePendingError extends Schema.TaggedError<GitHubStackMergePendingError>()( | ||
| 75 | "GitHubStackMergePendingError", | ||
| 76 | stackErrorIdentity, | ||
| 77 | ) { | ||
| 78 | get detail(): string { | ||
| 79 | return this.message; | ||
| 80 | } | ||
| 81 | |||
| 82 | override get message(): string { | ||
| 83 | return "The merge is still running on GitHub. Check its status there before submitting another request."; | ||
| 84 | } | ||
| 85 | } | ||
| 86 | |||
| 87 | export class GitHubStackPermissionError extends Schema.TaggedError<GitHubStackPermissionError>()( | ||
| 88 | "GitHubStackPermissionError", | ||
| 89 | stackErrorIdentity, | ||
| 90 | ) { | ||
| 91 | get detail(): string { | ||
| 92 | return this.message; | ||
| 93 | } | ||
| 94 | |||
| 95 | override get message(): string { | ||
| 96 | return "You cannot update every branch in this stack. Check write access and fork maintainer permissions before retrying."; | ||
| 97 | } | ||
| 98 | } | ||
| 99 | |||
| 100 | export class GitHubStackRebaseFailedError extends Schema.TaggedError<GitHubStackRebaseFailedError>()( | ||
| 101 | "GitHubStackRebaseFailedError", | ||
| 102 | { ...stackErrorIdentity, completed: Schema.Int, cause: Schema.Defect() }, | ||
| 103 | ) { | ||
| 104 | get detail(): string { | ||
| 105 | return this.message; | ||
| 106 | } | ||
| 107 | |||
| 108 | override get message(): string { | ||
| 109 | return `Stack rebase stopped at PR #${this.number} after ${this.completed} layers. Earlier updates remain on GitHub; resolve the failing layer before retrying.`; | ||
| 110 | } | ||
| 111 | } | ||
| 112 | |||
| 113 | export type GitHubStackActionError = | ||
| 114 | | GitHubStackChangedError | ||
| 115 | | GitHubStackUnsupportedError | ||
| 116 | | GitHubStackResponseInvalidError | ||
| 117 | | GitHubStackMergeRejectedError | ||
| 118 | | GitHubStackMergePendingError | ||
| 119 | | GitHubStackPermissionError | ||
| 120 | | GitHubStackRebaseFailedError; | ||
| 121 | |||
| 122 | const MergeResponse = Schema.Struct({ | ||
| 123 | status: Schema.Literals(["pending", "merged", "enqueued", "failed"]), | ||
| 124 | details: Schema.Struct({ | ||
| 125 | uuid: Schema.optional(Schema.String), | ||
| 126 | message: Schema.optional(Schema.String), | ||
| 127 | }), | ||
| 128 | }); | ||
| 129 | |||
| 130 | const decodeBranchAccess = Schema.decodeEffect( | ||
| 131 | Schema.fromJsonString( | ||
| 132 | Schema.Struct({ | ||
| 133 | data: Schema.Struct({ | ||
| 134 | repository: Schema.NullOr( | ||
| 135 | Schema.Record( | ||
| 136 | Schema.String, | ||
| 137 | Schema.NullOr( | ||
| 138 | Schema.Struct({ | ||
| 139 | headRepository: Schema.NullOr( | ||
| 140 | Schema.Struct({ viewerPermission: Schema.NullOr(Schema.String) }), | ||
| 141 | ), | ||
| 142 | maintainerCanModify: Schema.Boolean, | ||
| 143 | }), | ||
| 144 | ), | ||
| 145 | ), | ||
| 146 | ), | ||
| 147 | }), | ||
| 148 | }), | ||
| 149 | ), | ||
| 150 | ); | ||
| 151 | |||
| 152 | const decodeRebaseBranch = Schema.decodeEffect( | ||
| 153 | Schema.fromJsonString( | ||
| 154 | Schema.Struct({ | ||
| 155 | data: Schema.Struct({ | ||
| 156 | processed: Schema.optional( | ||
| 157 | Schema.Array(Schema.NullOr(Schema.Struct({ headRefOid: Schema.String }))), | ||
| 158 | ), | ||
| 159 | repository: Schema.Struct({ | ||
| 160 | pullRequest: Schema.Struct({ | ||
| 161 | id: Schema.String, | ||
| 162 | headRefOid: Schema.String, | ||
| 163 | baseRef: Schema.Struct({ compare: Schema.Struct({ behindBy: Schema.Int }) }), | ||
| 164 | }), | ||
| 165 | }), | ||
| 166 | }), | ||
| 167 | }), | ||
| 168 | ), | ||
| 169 | ); | ||
| 170 | const decodeRebaseResponse = Schema.decodeEffect( | ||
| 171 | Schema.fromJsonString( | ||
| 172 | Schema.Struct({ | ||
| 173 | data: Schema.Struct({ | ||
| 174 | updatePullRequestBranch: Schema.Struct({ | ||
| 175 | pullRequest: Schema.Struct({ headRefOid: Schema.String }), | ||
| 176 | }), | ||
| 177 | }), | ||
| 178 | }), | ||
| 179 | ), | ||
| 180 | ); | ||
| 181 | |||
| 182 | const encodeNodeIds = Schema.encodeSync(Schema.fromJsonString(Schema.Array(Schema.String))); | ||
| 183 | |||
| 184 | const decodeMergeResponse = Schema.decodeEffect(Schema.fromJsonString(MergeResponse)); | ||
| 185 | |||
| 186 | /** Remote-only updates: a stack rebase never switches or rewrites the environment's checkout. */ | ||
| 187 | export const runGitHubStackAction = Effect.fn("runGitHubStackAction")(function* (input: { | ||
| 188 | cwd: string; | ||
| 189 | repository: string; | ||
| 190 | host: string; | ||
| 191 | number: number; | ||
| 192 | stackNumber: number; | ||
| 193 | expectedStackHeads?: ReadonlyArray<PullRequestStackHead>; | ||
| 194 | action: PullRequestAction; | ||
| 195 | mergeMethod?: PullRequestMergeMethod; | ||
| 196 | }) { | ||
| 197 | const github = yield* GitHubCli.GitHubCli; | ||
| 198 | const identity = { | ||
| 199 | repository: input.repository, | ||
| 200 | number: input.number, | ||
| 201 | stackNumber: input.stackNumber, | ||
| 202 | }; | ||
| 203 | if (input.action !== "merge" && input.action !== "update-branch") | ||
| 204 | return yield* new GitHubStackUnsupportedError({ ...identity }); | ||
| 205 | const endpoint = `repos/${input.repository}`; | ||
| 206 | const read = yield* github.execute({ | ||
| 207 | cwd: input.cwd, | ||
| 208 | args: ["api", "--hostname", input.host, `${endpoint}/stacks?pull_request=${input.number}`], | ||
| 209 | }); | ||
| 210 | const decoded = decodePullRequestStacksJson(read.stdout); | ||
| 211 | if (Result.isFailure(decoded)) | ||
| 212 | return yield* new GitHubStackResponseInvalidError({ ...identity, cause: decoded.failure }); | ||
| 213 | const stack = decoded.success; | ||
| 214 | const targetIndex = stack?.layers.findIndex((layer) => layer.number === input.number) ?? -1; | ||
| 215 | const target = stack?.layers[targetIndex]; | ||
| 216 | if ( | ||
| 217 | stack?.number !== input.stackNumber || | ||
| 218 | target === undefined || | ||
| 219 | (input.action === "update-branch" && targetIndex !== stack.layers.length - 1) | ||
| 220 | ) { | ||
| 221 | return yield* new GitHubStackChangedError({ ...identity, number: input.number, completed: 0 }); | ||
| 222 | } | ||
| 223 | const affectedLayers = | ||
| 224 | input.action === "merge" ? stack.layers.slice(0, targetIndex + 1) : stack.layers; | ||
| 225 | const open = affectedLayers.filter((layer) => layer.state !== "merged"); | ||
| 226 | if (input.action === "merge" && target.state !== "open") | ||
| 227 | return yield* new GitHubStackUnsupportedError({ ...identity }); | ||
| 228 | if ( | ||
| 229 | !input.expectedStackHeads || | ||
| 230 | input.expectedStackHeads.length !== open.length || | ||
| 231 | new Set(input.expectedStackHeads.map((layer) => layer.number)).size !== open.length || | ||
| 232 | open.some( | ||
| 233 | (layer) => | ||
| 234 | !layer.headSha || | ||
| 235 | !input.expectedStackHeads?.some( | ||
| 236 | (expected) => expected.number === layer.number && expected.headSha === layer.headSha, | ||
| 237 | ), | ||
| 238 | ) | ||
| 239 | ) { | ||
| 240 | return yield* new GitHubStackChangedError({ ...identity, number: input.number, completed: 0 }); | ||
| 241 | } | ||
| 242 | if (open.length === 0 || open.some((layer) => layer.state !== "open")) | ||
| 243 | return yield* new GitHubStackUnsupportedError({ ...identity }); | ||
| 244 | if (input.action === "update-branch") { | ||
| 245 | const [owner, name] = input.repository.split("/"); | ||
| 246 | const permissions = yield* github.execute({ | ||
| 247 | cwd: input.cwd, | ||
| 248 | args: [ | ||
| 249 | "api", | ||
| 250 | "--hostname", | ||
| 251 | input.host, | ||
| 252 | "graphql", | ||
| 253 | "-f", | ||
| 254 | `owner=${owner}`, | ||
| 255 | "-f", | ||
| 256 | `name=${name}`, | ||
| 257 | "-f", | ||
| 258 | `query=query($owner:String!,$name:String!){repository(owner:$owner,name:$name){${open | ||
| 259 | .map( | ||
| 260 | (layer) => | ||
| 261 | `pr${layer.number}:pullRequest(number:${layer.number}){headRepository{viewerPermission} maintainerCanModify}`, | ||
| 262 | ) | ||
| 263 | .join(" ")}}}`, | ||
| 264 | ], | ||
| 265 | }); | ||
| 266 | const access = yield* decodeBranchAccess(permissions.stdout).pipe( | ||
| 267 | Effect.mapError((cause) => new GitHubStackResponseInvalidError({ ...identity, cause })), | ||
| 268 | ); | ||
| 269 | // viewerCanUpdateBranch is false for an already-current layer, even if rebasing its parent | ||
| 270 | // will make it stale. Check branch write access separately before touching any layer. | ||
| 271 | if ( | ||
| 272 | open.some((layer) => { | ||
| 273 | const pr = access.data.repository?.[`pr${layer.number}`]; | ||
| 274 | return ( | ||
| 275 | !pr?.headRepository || | ||
| 276 | (!pr.maintainerCanModify && | ||
| 277 | !["ADMIN", "MAINTAIN", "WRITE"].includes(pr.headRepository.viewerPermission ?? "")) | ||
| 278 | ); | ||
| 279 | }) | ||
| 280 | ) | ||
| 281 | return yield* new GitHubStackPermissionError({ ...identity }); | ||
| 282 | const processed: Array<{ id: string; number: number; headSha: string }> = []; | ||
| 283 | for (const [index, layer] of open.entries()) { | ||
| 284 | yield* Effect.gen(function* () { | ||
| 285 | const read = yield* github.execute({ | ||
| 286 | cwd: input.cwd, | ||
| 287 | args: [ | ||
| 288 | "api", | ||
| 289 | "--hostname", | ||
| 290 | input.host, | ||
| 291 | "graphql", | ||
| 292 | "-f", | ||
| 293 | `owner=${owner}`, | ||
| 294 | "-f", | ||
| 295 | `name=${name}`, | ||
| 296 | "-F", | ||
| 297 | `number=${layer.number}`, | ||
| 298 | "-f", | ||
| 299 | `sha=${layer.headSha}`, | ||
| 300 | "-f", | ||
| 301 | `query=query($owner:String!,$name:String!,$number:Int!,$sha:String!){${ | ||
| 302 | processed.length === 0 | ||
| 303 | ? "" | ||
| 304 | : `processed:nodes(ids:${encodeNodeIds(processed.map((head) => head.id))}){... on PullRequest{headRefOid}}` | ||
| 305 | } repository(owner:$owner,name:$name){pullRequest(number:$number){id headRefOid baseRef{compare(headRef:$sha){behindBy}}}}}`, | ||
| 306 | ], | ||
| 307 | }); | ||
| 308 | const { | ||
| 309 | data: { | ||
| 310 | processed: observed, | ||
| 311 | repository: { pullRequest: pr }, | ||
| 312 | }, | ||
| 313 | } = yield* decodeRebaseBranch(read.stdout); | ||
| 314 | // A push to an earlier layer must not silently become the next layer's new base. | ||
| 315 | const changed = processed.find( | ||
| 316 | (head, index) => observed?.[index]?.headRefOid !== head.headSha, | ||
| 317 | ); | ||
| 318 | if (changed !== undefined) | ||
| 319 | return yield* new GitHubStackChangedError({ | ||
| 320 | ...identity, | ||
| 321 | number: changed.number, | ||
| 322 | completed: index, | ||
| 323 | }); | ||
| 324 | if (pr.headRefOid !== layer.headSha) | ||
| 325 | return yield* new GitHubStackChangedError({ | ||
| 326 | ...identity, | ||
| 327 | number: layer.number, | ||
| 328 | completed: index, | ||
| 329 | }); | ||
| 330 | if (pr.baseRef.compare.behindBy === 0) { | ||
| 331 | processed.push({ id: pr.id, number: layer.number, headSha: pr.headRefOid }); | ||
| 332 | return; | ||
| 333 | } | ||
| 334 | // Pass the reviewed revision to GitHub, including when a push races this read. | ||
| 335 | const updated = yield* github.execute({ | ||
| 336 | cwd: input.cwd, | ||
| 337 | args: [ | ||
| 338 | "api", | ||
| 339 | "--hostname", | ||
| 340 | input.host, | ||
| 341 | "graphql", | ||
| 342 | "-f", | ||
| 343 | `id=${pr.id}`, | ||
| 344 | "-f", | ||
| 345 | `sha=${layer.headSha}`, | ||
| 346 | "-f", | ||
| 347 | "query=mutation($id:ID!,$sha:GitObjectID!){updatePullRequestBranch(input:{pullRequestId:$id,expectedHeadOid:$sha,updateMethod:REBASE}){pullRequest{headRefOid}}}", | ||
| 348 | ], | ||
| 349 | }); | ||
| 350 | const response = yield* decodeRebaseResponse(updated.stdout); | ||
| 351 | processed.push({ | ||
| 352 | id: pr.id, | ||
| 353 | number: layer.number, | ||
| 354 | headSha: response.data.updatePullRequestBranch.pullRequest.headRefOid, | ||
| 355 | }); | ||
| 356 | }).pipe( | ||
| 357 | Effect.mapError((cause) => | ||
| 358 | cause._tag === "GitHubStackChangedError" | ||
| 359 | ? cause | ||
| 360 | : new GitHubStackRebaseFailedError({ | ||
| 361 | ...identity, | ||
| 362 | number: layer.number, | ||
| 363 | completed: index, | ||
| 364 | cause, | ||
| 365 | }), | ||
| 366 | ), | ||
| 367 | ); | ||
| 368 | } | ||
| 369 | return; | ||
| 370 | } | ||
| 371 | if (open.some((layer) => layer.isDraft)) | ||
| 372 | return yield* new GitHubStackUnsupportedError({ ...identity }); | ||
| 373 | const decode = (raw: string) => | ||
| 374 | decodeMergeResponse(raw).pipe( | ||
| 375 | Effect.mapError((cause) => new GitHubStackResponseInvalidError({ ...identity, cause })), | ||
| 376 | ); | ||
| 377 | const request = yield* github.execute({ | ||
| 378 | cwd: input.cwd, | ||
| 379 | args: [ | ||
| 380 | "api", | ||
| 381 | "--hostname", | ||
| 382 | input.host, | ||
| 383 | "--method", | ||
| 384 | "PUT", | ||
| 385 | `${endpoint}/pulls/${input.number}/merge-async`, | ||
| 386 | "-f", | ||
| 387 | `merge_method=${input.mergeMethod ?? "merge"}`, | ||
| 388 | "-f", | ||
| 389 | "merge_action=default", | ||
| 390 | "-f", | ||
| 391 | `sha=${target.headSha}`, | ||
| 392 | ], | ||
| 393 | }); | ||
| 394 | let result = yield* decode(request.stdout); | ||
| 395 | const deadline = (yield* Clock.currentTimeMillis) + 5 * 60_000; | ||
| 396 | for ( | ||
| 397 | let attempt = 0; | ||
| 398 | result.status === "pending" && (yield* Clock.currentTimeMillis) < deadline; | ||
| 399 | attempt++ | ||
| 400 | ) { | ||
| 401 | const uuid = result.details.uuid; | ||
| 402 | if (!uuid) return yield* new GitHubStackResponseInvalidError({ ...identity }); | ||
| 403 | yield* Effect.sleep(Math.min(1_000 * 2 ** attempt, 10_000)); | ||
| 404 | const poll = yield* github.execute({ | ||
| 405 | cwd: input.cwd, | ||
| 406 | args: [ | ||
| 407 | "api", | ||
| 408 | "--hostname", | ||
| 409 | input.host, | ||
| 410 | `${endpoint}/pulls/${input.number}/merge-async/${encodeURIComponent(uuid)}`, | ||
| 411 | ], | ||
| 412 | }); | ||
| 413 | result = yield* decode(poll.stdout); | ||
| 414 | } | ||
| 415 | if (result.status === "pending") return yield* new GitHubStackMergePendingError({ ...identity }); | ||
| 416 | if (result.status === "failed") | ||
| 417 | return yield* new GitHubStackMergeRejectedError({ ...identity, cause: result }); | ||
| 418 | }); | ||
apps/web/src/commandPaletteBus.ts+9| ... | @@ -1,9 +1,18 @@ | ... | @@ -1,9 +1,18 @@ |
| 1 | import type { EnvironmentId, PullRequestLinkedThreadsResult } from "@t3tools/contracts"; | ||
| 2 | |||
| 3 | export interface CommandPaletteLinkedThreads { | ||
| 4 | readonly environmentId: EnvironmentId; | ||
| 5 | readonly threads: PullRequestLinkedThreadsResult["threads"]; | ||
| 6 | } | ||
| 7 | |||
| 1 | // Tiny event bus allowing components to programmatically open the command palette | 8 | // Tiny event bus allowing components to programmatically open the command palette |
| 2 | // without owning its React state. | 9 | // without owning its React state. |
| 3 | const COMMAND_PALETTE_OPEN_EVENT = "t3code:open-command-palette"; | 10 | const COMMAND_PALETTE_OPEN_EVENT = "t3code:open-command-palette"; |
| 4 | 11 | ||
| 5 | export interface CommandPaletteOpenDetail { | 12 | export interface CommandPaletteOpenDetail { |
| 6 | readonly open?: "add-project" | "new-thread-in"; | 13 | readonly open?: "add-project" | "new-thread-in"; |
| 14 | readonly query?: string; | ||
| 15 | readonly linkedThreads?: CommandPaletteLinkedThreads; | ||
| 7 | } | 16 | } |
| 8 | 17 | ||
| 9 | export function openCommandPalette(detail?: CommandPaletteOpenDetail): void { | 18 | export function openCommandPalette(detail?: CommandPaletteOpenDetail): void { |
apps/web/src/components/BranchToolbarBranchSelector.tsx+42-43| ... | @@ -1,3 +1,6 @@ | ... | @@ -1,3 +1,6 @@ |
| 1 | import { useSupportsMultiplePullRequests } from "~/hooks/useSupportsMultiplePullRequests"; | ||
| 2 | import { resolveThreadCurrentPullRequestLink } from "@t3tools/shared/threadPullRequests"; | ||
| 3 | import { useRightPanelStore } from "../rightPanelStore"; | ||
| 1 | import { RefreshIcon } from "~/components/ui/refresh-icon"; | 4 | import { RefreshIcon } from "~/components/ui/refresh-icon"; |
| 2 | import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment"; | 5 | import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment"; |
| 3 | import { | 6 | import { |
| ... | @@ -47,7 +50,12 @@ import { | ... | @@ -47,7 +50,12 @@ import { |
| 47 | sanitizeNewRefName, | 50 | sanitizeNewRefName, |
| 48 | shouldIncludeBranchPickerItem, | 51 | shouldIncludeBranchPickerItem, |
| 49 | } from "./BranchToolbar.logic"; | 52 | } from "./BranchToolbar.logic"; |
| 50 | import { ChangeRequestStatusIcon, prStatusIndicator } from "./ThreadStatusIndicators"; | 53 | import { |
| 54 | ThreadPullRequestBadgeControl, | ||
| 55 | prStatusIndicator, | ||
| 56 | resolveThreadPullRequestBadge, | ||
| 57 | useLinkedThreadPullRequest, | ||
| 58 | } from "./ThreadStatusIndicators"; | ||
| 51 | import { Button } from "./ui/button"; | 59 | import { Button } from "./ui/button"; |
| 52 | import { Switch } from "./ui/switch"; | 60 | import { Switch } from "./ui/switch"; |
| 53 | import { getVirtualizedScrollFadeClassName } from "./ui/scroll-area"; | 61 | import { getVirtualizedScrollFadeClassName } from "./ui/scroll-area"; |
| ... | @@ -612,7 +620,7 @@ export function BranchToolbarBranchSelector({ | ... | @@ -612,7 +620,7 @@ export function BranchToolbarBranchSelector({ |
| 612 | startFromOrigin, | 620 | startFromOrigin, |
| 613 | }); | 621 | }); |
| 614 | 622 | ||
| 615 | // PR pill shown next to the branch selector when the active branch has one. | 623 | // Branch status is the fallback when this thread has no linked pull requests. |
| 616 | const branchPrBranch = resolveBranchToolbarPrBranch({ | 624 | const branchPrBranch = resolveBranchToolbarPrBranch({ |
| 617 | activeThreadBranch, | 625 | activeThreadBranch, |
| 618 | resolvedActiveBranch, | 626 | resolvedActiveBranch, |
| ... | @@ -621,12 +629,27 @@ export function BranchToolbarBranchSelector({ | ... | @@ -621,12 +629,27 @@ export function BranchToolbarBranchSelector({ |
| 621 | branchPrBranch !== null && branchStatusQuery.data?.refName === branchPrBranch | 629 | branchPrBranch !== null && branchStatusQuery.data?.refName === branchPrBranch |
| 622 | ? (branchStatusQuery.data.pr ?? null) | 630 | ? (branchStatusQuery.data.pr ?? null) |
| 623 | : null; | 631 | : null; |
| 624 | const branchPrStatus = prStatusIndicator(branchPr, branchStatusQuery.data?.sourceControlProvider); | 632 | const supportsMultiplePullRequests = useSupportsMultiplePullRequests(environmentId); |
| 625 | // Action-oriented tooltip (the pill opens the PR), distinct from the sidebar's | 633 | const linkedStatus = useLinkedThreadPullRequest( |
| 626 | // state-description tooltip. | 634 | environmentId, |
| 627 | const branchPrTooltip = branchPr | 635 | serverThread?.linkedPullRequest, |
| 628 | ? `Open ${sourceControlPresentation.terminology.singular} #${branchPr.number} (${branchPr.state})` | 636 | true, |
| 629 | : ""; | 637 | serverThread?.pullRequests, |
| 638 | serverThread?.branchPullRequest, | ||
| 639 | ); | ||
| 640 | const currentLinkedPr = supportsMultiplePullRequests | ||
| 641 | ? resolveThreadCurrentPullRequestLink(serverThread?.pullRequests ?? []) | ||
| 642 | : null; | ||
| 643 | const prBadge = supportsMultiplePullRequests | ||
| 644 | ? resolveThreadPullRequestBadge(serverThread?.pullRequests) | ||
| 645 | : null; | ||
| 646 | const displayedPr = linkedStatus?.pr ?? (currentLinkedPr === null ? branchPr : null); | ||
| 647 | const displayedPrStatus = prStatusIndicator( | ||
| 648 | displayedPr, | ||
| 649 | linkedStatus?.sourceControlProvider ?? branchStatusQuery.data?.sourceControlProvider, | ||
| 650 | ); | ||
| 651 | const prNumber = currentLinkedPr?.number ?? displayedPr?.number; | ||
| 652 | const prUrl = currentLinkedPr?.url ?? displayedPr?.url; | ||
| 630 | const openPrLink = useOpenPrLink(threadRef); | 653 | const openPrLink = useOpenPrLink(threadRef); |
| 631 | 654 | ||
| 632 | function renderPickerItem(itemValue: string, index: number) { | 655 | function renderPickerItem(itemValue: string, index: number) { |
| ... | @@ -730,41 +753,17 @@ export function BranchToolbarBranchSelector({ | ... | @@ -730,41 +753,17 @@ export function BranchToolbarBranchSelector({ |
| 730 | className={cn("flex min-w-0 items-center gap-1", className)} | 753 | className={cn("flex min-w-0 items-center gap-1", className)} |
| 731 | data-composer-context-control | 754 | data-composer-context-control |
| 732 | > | 755 | > |
| 733 | {branchPr && branchPrStatus ? ( | 756 | <ThreadPullRequestBadgeControl |
| 734 | <Tooltip> | 757 | variant="ghost" |
| 735 | <TooltipTrigger | 758 | badge={prBadge} |
| 736 | render={ | 759 | number={prNumber} |
| 737 | <button | 760 | url={prUrl} |
| 738 | type="button" | 761 | status={displayedPrStatus} |
| 739 | aria-label={branchPrTooltip} | 762 | onOpenStack={() => useRightPanelStore.getState().open(threadRef, "pull-requests")} |
| 740 | onClick={(event) => openPrLink(event, branchPrStatus.url)} | 763 | onOpenPullRequest={(event) => { |
| 741 | className={cn( | 764 | if (prUrl) openPrLink(event, prUrl); |
| 742 | "inline-flex shrink-0 items-center gap-0.5 rounded px-1 py-0.5 text-[11px] font-medium tabular-nums transition-colors hover:bg-muted/60", | 765 | }} |
| 743 | branchPrStatus.colorClass, | 766 | /> |
| 744 | )} | ||
| 745 | /> | ||
| 746 | } | ||
| 747 | > | ||
| 748 | <ChangeRequestStatusIcon | ||
| 749 | state={branchPr.state} | ||
| 750 | isDraft={branchPr.isDraft} | ||
| 751 | className="size-3" | ||
| 752 | /> | ||
| 753 | <span | ||
| 754 | data-composer-label | ||
| 755 | className="min-w-0 max-w-12 overflow-hidden group-data-[compact]/composer-context:max-w-0" | ||
| 756 | > | ||
| 757 | <span | ||
| 758 | data-composer-label-motion | ||
| 759 | className="block w-full min-w-0 max-w-12 truncate transition-opacity duration-180 ease-[cubic-bezier(0.32,0.72,0,1)] group-data-[compact]/composer-context:opacity-0 motion-reduce:transition-none" | ||
| 760 | > | ||
| 761 | #{branchPr.number} | ||
| 762 | </span> | ||
| 763 | </span> | ||
| 764 | </TooltipTrigger> | ||
| 765 | <TooltipPopup side="top">{branchPrTooltip}</TooltipPopup> | ||
| 766 | </Tooltip> | ||
| 767 | ) : null} | ||
| 768 | {/* Context menu lives on the wrapper: the disabled Button has | 767 | {/* Context menu lives on the wrapper: the disabled Button has |
| 769 | pointer-events-none, so the trigger itself never sees right-clicks | 768 | pointer-events-none, so the trigger itself never sees right-clicks |
| 770 | while refs are loading or a branch action is pending. */} | 769 | while refs are loading or a branch action is pending. */} |
apps/web/src/components/ChatView.tsx+9| ... | @@ -8054,6 +8054,15 @@ export default function ChatView(props: ChatViewProps) { | ... | @@ -8054,6 +8054,15 @@ export default function ChatView(props: ChatViewProps) { |
| 8054 | <PullRequestDetailPanel | 8054 | <PullRequestDetailPanel |
| 8055 | key={`${renderedRightPanelSurface.host ?? ""}:${renderedRightPanelSurface.repository}#${renderedRightPanelSurface.number}`} | 8055 | key={`${renderedRightPanelSurface.host ?? ""}:${renderedRightPanelSurface.repository}#${renderedRightPanelSurface.number}`} |
| 8056 | environmentId={activeThread.environmentId} | 8056 | environmentId={activeThread.environmentId} |
| 8057 | onSelectPullRequest={(reference) => { | ||
| 8058 | if (activeThreadRef) | ||
| 8059 | useRightPanelStore.getState().openPullRequest(activeThreadRef, { | ||
| 8060 | projectId: reference.projectId, | ||
| 8061 | repository: reference.repository, | ||
| 8062 | number: reference.number, | ||
| 8063 | ...(reference.host ? { host: reference.host } : {}), | ||
| 8064 | }); | ||
| 8065 | }} | ||
| 8057 | threadRef={activeThreadRef} | 8066 | threadRef={activeThreadRef} |
| 8058 | reference={{ | 8067 | reference={{ |
| 8059 | projectId: renderedRightPanelSurface.projectId as ProjectId, | 8068 | projectId: renderedRightPanelSurface.projectId as ProjectId, |
apps/web/src/components/CommandPalette.logic.test.ts+69| ... | @@ -5,6 +5,7 @@ import { | ... | @@ -5,6 +5,7 @@ import { |
| 5 | buildBrowseGroups, | 5 | buildBrowseGroups, |
| 6 | buildProjectActionItems, | 6 | buildProjectActionItems, |
| 7 | buildThreadActionItems, | 7 | buildThreadActionItems, |
| 8 | buildLinkedThreadActionItems, | ||
| 8 | enumerateCommandPaletteItems, | 9 | enumerateCommandPaletteItems, |
| 9 | filterPinnedBrowseEntries, | 10 | filterPinnedBrowseEntries, |
| 10 | filterCommandPaletteGroups, | 11 | filterCommandPaletteGroups, |
| ... | @@ -12,6 +13,48 @@ import { | ... | @@ -12,6 +13,48 @@ import { |
| 12 | type CommandPaletteGroup, | 13 | type CommandPaletteGroup, |
| 13 | } from "./CommandPalette.logic"; | 14 | } from "./CommandPalette.logic"; |
| 14 | 15 | ||
| 16 | describe("linked pull request thread navigation", () => { | ||
| 17 | it("keeps archived relations searchable and routes them through the PR environment", async () => { | ||
| 18 | const environmentId = EnvironmentId.make("remote"); | ||
| 19 | const id = ThreadId.make("archived-thread"); | ||
| 20 | const runThread = vi.fn(async () => {}); | ||
| 21 | const query = "https://github.com/acme/web/pull/42"; | ||
| 22 | const linkedThreads = { | ||
| 23 | environmentId, | ||
| 24 | threads: [ | ||
| 25 | { | ||
| 26 | id, | ||
| 27 | projectId: ProjectId.make("project"), | ||
| 28 | title: "Completed work", | ||
| 29 | archivedAt: "2026-09-01T00:00:00.000Z", | ||
| 30 | }, | ||
| 31 | ], | ||
| 32 | }; | ||
| 33 | const state = reduceCommandPaletteUiState( | ||
| 34 | { open: false, mode: "command", openIntent: null }, | ||
| 35 | { | ||
| 36 | _tag: "OpenSearch", | ||
| 37 | query, | ||
| 38 | linkedThreads, | ||
| 39 | }, | ||
| 40 | ); | ||
| 41 | expect(state.openIntent).toEqual({ kind: "search", query, linkedThreads }); | ||
| 42 | const items = buildLinkedThreadActionItems({ ...linkedThreads, query, icon: null, runThread }); | ||
| 43 | const groups = filterCommandPaletteGroups({ | ||
| 44 | activeGroups: [], | ||
| 45 | query, | ||
| 46 | isInSubmenu: false, | ||
| 47 | projectSearchItems: [], | ||
| 48 | settingsSearchItems: [], | ||
| 49 | threadSearchItems: items, | ||
| 50 | }); | ||
| 51 | expect(groups.flatMap((group) => group.items)).toEqual(items); | ||
| 52 | expect(items[0]?.description).toBe("Archived thread"); | ||
| 53 | await items[0]?.run(); | ||
| 54 | expect(runThread).toHaveBeenCalledWith({ environmentId, id }); | ||
| 55 | }); | ||
| 56 | }); | ||
| 57 | |||
| 15 | describe("reduceCommandPaletteUiState", () => { | 58 | describe("reduceCommandPaletteUiState", () => { |
| 16 | const closedState = { open: false, mode: "command", openIntent: null } as const; | 59 | const closedState = { open: false, mode: "command", openIntent: null } as const; |
| 17 | 60 | ||
| ... | @@ -47,6 +90,32 @@ describe("reduceCommandPaletteUiState", () => { | ... | @@ -47,6 +90,32 @@ describe("reduceCommandPaletteUiState", () => { |
| 47 | ); | 90 | ); |
| 48 | }); | 91 | }); |
| 49 | 92 | ||
| 93 | it("opens PR search from another overlay and replaces an earlier search", () => { | ||
| 94 | const first = reduceCommandPaletteUiState( | ||
| 95 | { open: true, mode: "files", openIntent: null }, | ||
| 96 | { | ||
| 97 | _tag: "OpenSearch", | ||
| 98 | query: "https://github.com/acme/web/pull/7", | ||
| 99 | }, | ||
| 100 | ); | ||
| 101 | expect(first).toEqual({ | ||
| 102 | open: true, | ||
| 103 | mode: "command", | ||
| 104 | openIntent: { kind: "search", query: "https://github.com/acme/web/pull/7" }, | ||
| 105 | }); | ||
| 106 | const second = reduceCommandPaletteUiState(first, { | ||
| 107 | _tag: "OpenSearch", | ||
| 108 | query: "https://github.com/acme/web/pull/8", | ||
| 109 | }); | ||
| 110 | expect(second.openIntent).toEqual({ | ||
| 111 | kind: "search", | ||
| 112 | query: "https://github.com/acme/web/pull/8", | ||
| 113 | }); | ||
| 114 | expect( | ||
| 115 | reduceCommandPaletteUiState(second, { _tag: "SetOpen", open: false }).openIntent, | ||
| 116 | ).toBeNull(); | ||
| 117 | }); | ||
| 118 | |||
| 50 | it("routes open intents to command mode", () => { | 119 | it("routes open intents to command mode", () => { |
| 51 | const filesOpen = reduceCommandPaletteUiState(closedState, { | 120 | const filesOpen = reduceCommandPaletteUiState(closedState, { |
| 52 | _tag: "ToggleMode", | 121 | _tag: "ToggleMode", |
apps/web/src/components/CommandPalette.logic.ts+42-3| ... | @@ -1,4 +1,5 @@ | ... | @@ -1,4 +1,5 @@ |
| 1 | import { threadPullRequestSearchTerms } from "@t3tools/shared/threadPullRequests"; | 1 | import { threadPullRequestSearchTerms } from "@t3tools/shared/threadPullRequests"; |
| 2 | import type { CommandPaletteLinkedThreads } from "../commandPaletteBus"; | ||
| 2 | import { | 3 | import { |
| 3 | type FilesystemBrowseEntry, | 4 | type FilesystemBrowseEntry, |
| 4 | type KeybindingCommand, | 5 | type KeybindingCommand, |
| ... | @@ -18,6 +19,25 @@ export const RECENT_THREAD_LIMIT = 12; | ... | @@ -18,6 +19,25 @@ export const RECENT_THREAD_LIMIT = 12; |
| 18 | export const ITEM_ICON_CLASS = "size-4 text-icon-muted"; | 19 | export const ITEM_ICON_CLASS = "size-4 text-icon-muted"; |
| 19 | export const ADDON_ICON_CLASS = "size-4"; | 20 | export const ADDON_ICON_CLASS = "size-4"; |
| 20 | 21 | ||
| 22 | /** A PR's relations include archived threads that normal palette search omits. */ | ||
| 23 | export function buildLinkedThreadActionItems( | ||
| 24 | input: CommandPaletteLinkedThreads & { | ||
| 25 | query: string; | ||
| 26 | icon: ReactNode; | ||
| 27 | runThread: (thread: Pick<SidebarThreadSummary, "environmentId" | "id">) => Promise<void>; | ||
| 28 | }, | ||
| 29 | ): CommandPaletteActionItem[] { | ||
| 30 | return input.threads.map((thread) => ({ | ||
| 31 | kind: "action", | ||
| 32 | value: `thread:${input.environmentId}:${thread.id}`, | ||
| 33 | title: thread.title || "Untitled thread", | ||
| 34 | description: thread.archivedAt === null ? "Linked thread" : "Archived thread", | ||
| 35 | searchTerms: [input.query, thread.title], | ||
| 36 | icon: input.icon, | ||
| 37 | run: () => input.runThread({ environmentId: input.environmentId, id: thread.id }), | ||
| 38 | })); | ||
| 39 | } | ||
| 40 | |||
| 21 | export function browseInputEndPaddingClass(input: { | 41 | export function browseInputEndPaddingClass(input: { |
| 22 | readonly willCreateProjectPath: boolean; | 42 | readonly willCreateProjectPath: boolean; |
| 23 | readonly hasHighlightedBrowseItem: boolean; | 43 | readonly hasHighlightedBrowseItem: boolean; |
| ... | @@ -39,9 +59,13 @@ export function browseInputEndPaddingClass(input: { | ... | @@ -39,9 +59,13 @@ export function browseInputEndPaddingClass(input: { |
| 39 | */ | 59 | */ |
| 40 | export type SearchOverlayMode = "command" | "files" | "content"; | 60 | export type SearchOverlayMode = "command" | "files" | "content"; |
| 41 | 61 | ||
| 42 | export interface CommandPaletteOpenIntent { | 62 | export type CommandPaletteOpenIntent = |
| 43 | readonly kind: "add-project" | "new-thread-in"; | 63 | | { readonly kind: "add-project" | "new-thread-in" } |
| 44 | } | 64 | | { |
| 65 | readonly kind: "search"; | ||
| 66 | readonly query: string; | ||
| 67 | readonly linkedThreads?: CommandPaletteLinkedThreads; | ||
| 68 | }; | ||
| 45 | 69 | ||
| 46 | export interface CommandPaletteUiState { | 70 | export interface CommandPaletteUiState { |
| 47 | readonly open: boolean; | 71 | readonly open: boolean; |
| ... | @@ -52,6 +76,11 @@ export interface CommandPaletteUiState { | ... | @@ -52,6 +76,11 @@ export interface CommandPaletteUiState { |
| 52 | export type CommandPaletteUiAction = | 76 | export type CommandPaletteUiAction = |
| 53 | | { readonly _tag: "SetOpen"; readonly open: boolean } | 77 | | { readonly _tag: "SetOpen"; readonly open: boolean } |
| 54 | | { readonly _tag: "ToggleMode"; readonly mode: SearchOverlayMode } | 78 | | { readonly _tag: "ToggleMode"; readonly mode: SearchOverlayMode } |
| 79 | | { | ||
| 80 | readonly _tag: "OpenSearch"; | ||
| 81 | readonly query: string; | ||
| 82 | readonly linkedThreads?: CommandPaletteLinkedThreads; | ||
| 83 | } | ||
| 55 | | { readonly _tag: "OpenAddProject" } | 84 | | { readonly _tag: "OpenAddProject" } |
| 56 | | { readonly _tag: "OpenNewThreadIn" } | 85 | | { readonly _tag: "OpenNewThreadIn" } |
| 57 | | { readonly _tag: "ClearOpenIntent" }; | 86 | | { readonly _tag: "ClearOpenIntent" }; |
| ... | @@ -69,6 +98,16 @@ export function reduceCommandPaletteUiState( | ... | @@ -69,6 +98,16 @@ export function reduceCommandPaletteUiState( |
| 69 | return state.open && state.mode === action.mode | 98 | return state.open && state.mode === action.mode |
| 70 | ? { ...state, open: false, openIntent: null } | 99 | ? { ...state, open: false, openIntent: null } |
| 71 | : { open: true, mode: action.mode, openIntent: null }; | 100 | : { open: true, mode: action.mode, openIntent: null }; |
| 101 | case "OpenSearch": | ||
| 102 | return { | ||
| 103 | open: true, | ||
| 104 | mode: "command", | ||
| 105 | openIntent: { | ||
| 106 | kind: "search", | ||
| 107 | query: action.query, | ||
| 108 | ...(action.linkedThreads ? { linkedThreads: action.linkedThreads } : {}), | ||
| 109 | }, | ||
| 110 | }; | ||
| 72 | case "OpenAddProject": | 111 | case "OpenAddProject": |
| 73 | return { open: true, mode: "command", openIntent: { kind: "add-project" } }; | 112 | return { open: true, mode: "command", openIntent: { kind: "add-project" } }; |
| 74 | case "OpenNewThreadIn": | 113 | case "OpenNewThreadIn": |
apps/web/src/components/CommandPalette.tsx+40-2| ... | @@ -129,6 +129,7 @@ import { | ... | @@ -129,6 +129,7 @@ import { |
| 129 | buildProjectActionItems, | 129 | buildProjectActionItems, |
| 130 | buildRootGroups, | 130 | buildRootGroups, |
| 131 | buildThreadActionItems, | 131 | buildThreadActionItems, |
| 132 | buildLinkedThreadActionItems, | ||
| 132 | enumerateCommandPaletteItems, | 133 | enumerateCommandPaletteItems, |
| 133 | type CommandPaletteActionItem, | 134 | type CommandPaletteActionItem, |
| 134 | type CommandPaletteOpenIntent, | 135 | type CommandPaletteOpenIntent, |
| ... | @@ -482,6 +483,12 @@ export function CommandPalette({ children }: { children: ReactNode }) { | ... | @@ -482,6 +483,12 @@ export function CommandPalette({ children }: { children: ReactNode }) { |
| 482 | openNewThreadIn(); | 483 | openNewThreadIn(); |
| 483 | } else if (detail.open === "add-project") { | 484 | } else if (detail.open === "add-project") { |
| 484 | openAddProject(); | 485 | openAddProject(); |
| 486 | } else if (detail.query !== undefined) { | ||
| 487 | dispatch({ | ||
| 488 | _tag: "OpenSearch", | ||
| 489 | query: detail.query, | ||
| 490 | ...(detail.linkedThreads ? { linkedThreads: detail.linkedThreads } : {}), | ||
| 491 | }); | ||
| 485 | } else { | 492 | } else { |
| 486 | setOpen(true); | 493 | setOpen(true); |
| 487 | } | 494 | } |
| ... | @@ -573,7 +580,10 @@ function OpenCommandPaletteDialog(props: { | ... | @@ -573,7 +580,10 @@ function OpenCommandPaletteDialog(props: { |
| 573 | const navigate = useNavigate(); | 580 | const navigate = useNavigate(); |
| 574 | const pathname = useLocation({ select: (location) => location.pathname }); | 581 | const pathname = useLocation({ select: (location) => location.pathname }); |
| 575 | const { clearOpenIntent, openIntent, openOverlayMode, setOpen } = props; | 582 | const { clearOpenIntent, openIntent, openOverlayMode, setOpen } = props; |
| 576 | const [query, setQuery] = useState(""); | 583 | const [query, setQuery] = useState(openIntent?.kind === "search" ? openIntent.query : ""); |
| 584 | const [linkedThreadSearch, setLinkedThreadSearch] = useState( | ||
| 585 | openIntent?.kind === "search" ? openIntent : null, | ||
| 586 | ); | ||
| 577 | const deferredQuery = useDeferredValue(query); | 587 | const deferredQuery = useDeferredValue(query); |
| 578 | const isActionsOnly = deferredQuery.startsWith(">"); | 588 | const isActionsOnly = deferredQuery.startsWith(">"); |
| 579 | const [highlightedItemValue, setHighlightedItemValue] = useState<string | null>(null); | 589 | const [highlightedItemValue, setHighlightedItemValue] = useState<string | null>(null); |
| ... | @@ -696,6 +706,7 @@ function OpenCommandPaletteDialog(props: { | ... | @@ -696,6 +706,7 @@ function OpenCommandPaletteDialog(props: { |
| 696 | ); | 706 | ); |
| 697 | const [isPickingProjectFolder, setIsPickingProjectFolder] = useState(false); | 707 | const [isPickingProjectFolder, setIsPickingProjectFolder] = useState(false); |
| 698 | const [addProjectCloneFlow, setAddProjectCloneFlow] = useState<AddProjectCloneFlow | null>(null); | 708 | const [addProjectCloneFlow, setAddProjectCloneFlow] = useState<AddProjectCloneFlow | null>(null); |
| 709 | const cloneLookupGeneration = useRef(0); | ||
| 699 | const [isRemoteProjectLookingUp, setIsRemoteProjectLookingUp] = useState(false); | 710 | const [isRemoteProjectLookingUp, setIsRemoteProjectLookingUp] = useState(false); |
| 700 | const [isRemoteProjectCloning, setIsRemoteProjectCloning] = useState(false); | 711 | const [isRemoteProjectCloning, setIsRemoteProjectCloning] = useState(false); |
| 701 | const projectGroupingSettings = useMemo( | 712 | const projectGroupingSettings = useMemo( |
| ... | @@ -1497,6 +1508,18 @@ function OpenCommandPaletteDialog(props: { | ... | @@ -1497,6 +1508,18 @@ function OpenCommandPaletteDialog(props: { |
| 1497 | startAddProjectSourceSelection, | 1508 | startAddProjectSourceSelection, |
| 1498 | ]); | 1509 | ]); |
| 1499 | 1510 | ||
| 1511 | useLayoutEffect(() => { | ||
| 1512 | if (openIntent?.kind !== "search") return; | ||
| 1513 | browseNavigation.invalidate(); | ||
| 1514 | cloneLookupGeneration.current += 1; | ||
| 1515 | setIsRemoteProjectLookingUp(false); | ||
| 1516 | setAddProjectCloneFlow(null); | ||
| 1517 | setViewStack([]); | ||
| 1518 | setLinkedThreadSearch(openIntent); | ||
| 1519 | setQuery(openIntent.query); | ||
| 1520 | clearOpenIntent(); | ||
| 1521 | }, [browseNavigation, clearOpenIntent, openIntent]); | ||
| 1522 | |||
| 1500 | useLayoutEffect(() => { | 1523 | useLayoutEffect(() => { |
| 1501 | if (openIntent?.kind !== "add-project") { | 1524 | if (openIntent?.kind !== "add-project") { |
| 1502 | return; | 1525 | return; |
| ... | @@ -1805,7 +1828,20 @@ function OpenCommandPaletteDialog(props: { | ... | @@ -1805,7 +1828,20 @@ function OpenCommandPaletteDialog(props: { |
| 1805 | isInSubmenu: currentView !== null, | 1828 | isInSubmenu: currentView !== null, |
| 1806 | projectSearchItems: projectSearchItems, | 1829 | projectSearchItems: projectSearchItems, |
| 1807 | settingsSearchItems, | 1830 | settingsSearchItems, |
| 1808 | threadSearchItems: allThreadItems, | 1831 | threadSearchItems: |
| 1832 | linkedThreadSearch?.linkedThreads && deferredQuery === linkedThreadSearch.query | ||
| 1833 | ? buildLinkedThreadActionItems({ | ||
| 1834 | ...linkedThreadSearch.linkedThreads, | ||
| 1835 | query: linkedThreadSearch.query, | ||
| 1836 | icon: <MessageSquareIcon className={ITEM_ICON_CLASS} />, | ||
| 1837 | runThread: async (thread) => { | ||
| 1838 | await navigate({ | ||
| 1839 | to: "/$environmentId/$threadId", | ||
| 1840 | params: buildThreadRouteParams(scopeThreadRef(thread.environmentId, thread.id)), | ||
| 1841 | }); | ||
| 1842 | }, | ||
| 1843 | }) | ||
| 1844 | : allThreadItems, | ||
| 1809 | }); | 1845 | }); |
| 1810 | 1846 | ||
| 1811 | const handleAddProjectForEnvironment = useCallback( | 1847 | const handleAddProjectForEnvironment = useCallback( |
| ... | @@ -2011,6 +2047,7 @@ function OpenCommandPaletteDialog(props: { | ... | @@ -2011,6 +2047,7 @@ function OpenCommandPaletteDialog(props: { |
| 2011 | } | 2047 | } |
| 2012 | 2048 | ||
| 2013 | setIsRemoteProjectLookingUp(true); | 2049 | setIsRemoteProjectLookingUp(true); |
| 2050 | const lookupGeneration = ++cloneLookupGeneration.current; | ||
| 2014 | const lookupResult = await lookupRepository({ | 2051 | const lookupResult = await lookupRepository({ |
| 2015 | environmentId: addProjectCloneFlow.environmentId, | 2052 | environmentId: addProjectCloneFlow.environmentId, |
| 2016 | input: { | 2053 | input: { |
| ... | @@ -2018,6 +2055,7 @@ function OpenCommandPaletteDialog(props: { | ... | @@ -2018,6 +2055,7 @@ function OpenCommandPaletteDialog(props: { |
| 2018 | repository: rawRepository, | 2055 | repository: rawRepository, |
| 2019 | }, | 2056 | }, |
| 2020 | }); | 2057 | }); |
| 2058 | if (lookupGeneration !== cloneLookupGeneration.current) return; | ||
| 2021 | setIsRemoteProjectLookingUp(false); | 2059 | setIsRemoteProjectLookingUp(false); |
| 2022 | if (lookupResult._tag === "Failure") { | 2060 | if (lookupResult._tag === "Failure") { |
| 2023 | if (!isAtomCommandInterrupted(lookupResult)) { | 2061 | if (!isAtomCommandInterrupted(lookupResult)) { |
apps/web/src/components/Sidebar.tsx+14-76| ... | @@ -192,13 +192,11 @@ import { | ... | @@ -192,13 +192,11 @@ import { |
| 192 | import { SidebarDragLifecycle, SidebarPointerSensor } from "./Sidebar.pointer"; | 192 | import { SidebarDragLifecycle, SidebarPointerSensor } from "./Sidebar.pointer"; |
| 193 | import { createSidebarListMotion } from "./Sidebar.motion"; | 193 | import { createSidebarListMotion } from "./Sidebar.motion"; |
| 194 | import { | 194 | import { |
| 195 | PR_STATE_COLOR_CLASS, | 195 | ThreadPullRequestBadgeControl, |
| 196 | ThreadPullRequestBadgeIcon, | ||
| 197 | ThreadPullRequestsMiniList, | 196 | ThreadPullRequestsMiniList, |
| 198 | ThreadWorktreeIndicator, | 197 | ThreadWorktreeIndicator, |
| 199 | prStatusIndicator, | 198 | prStatusIndicator, |
| 200 | resolveThreadPullRequestBadge, | 199 | resolveThreadPullRequestBadge, |
| 201 | settledPrHoverColorClass, | ||
| 202 | terminalStatusFromRunningIds, | 200 | terminalStatusFromRunningIds, |
| 203 | type TerminalStatusIndicator, | 201 | type TerminalStatusIndicator, |
| 204 | useLinkedThreadPullRequest, | 202 | useLinkedThreadPullRequest, |
| ... | @@ -220,7 +218,7 @@ import { | ... | @@ -220,7 +218,7 @@ import { |
| 220 | } from "../providerInstances"; | 218 | } from "../providerInstances"; |
| 221 | import { useThreadRunningTerminalIds } from "../state/terminalSessions"; | 219 | import { useThreadRunningTerminalIds } from "../state/terminalSessions"; |
| 222 | import { stackedThreadToast, toastManager } from "./ui/toast"; | 220 | import { stackedThreadToast, toastManager } from "./ui/toast"; |
| 223 | import { Button, InlineButton } from "./ui/button"; | 221 | import { Button } from "./ui/button"; |
| 224 | import { Input } from "./ui/input"; | 222 | import { Input } from "./ui/input"; |
| 225 | import { | 223 | import { |
| 226 | Combobox, | 224 | Combobox, |
| ... | @@ -1179,7 +1177,6 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { | ... | @@ -1179,7 +1177,6 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { |
| 1179 | currentGitBranch: visibleGitStatus?.refName ?? null, | 1177 | currentGitBranch: visibleGitStatus?.refName ?? null, |
| 1180 | }); | 1178 | }); |
| 1181 | const prStatus = prStatusIndicator(pr, linkedPullRequestStatus?.sourceControlProvider); | 1179 | const prStatus = prStatusIndicator(pr, linkedPullRequestStatus?.sourceControlProvider); |
| 1182 | const settledPrHoverClass = pr ? settledPrHoverColorClass(pr.state, pr.isDraft) : undefined; | ||
| 1183 | 1180 | ||
| 1184 | const modelInstanceId = thread.session?.providerInstanceId ?? thread.modelSelection.instanceId; | 1181 | const modelInstanceId = thread.session?.providerInstanceId ?? thread.modelSelection.instanceId; |
| 1185 | const providerEntry = props.providerEntryByInstanceId.get(modelInstanceId) ?? null; | 1182 | const providerEntry = props.providerEntryByInstanceId.get(modelInstanceId) ?? null; |
| ... | @@ -1488,85 +1485,26 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { | ... | @@ -1488,85 +1485,26 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { |
| 1488 | </span> | 1485 | </span> |
| 1489 | ); | 1486 | ); |
| 1490 | 1487 | ||
| 1491 | // One badge shape for every thread: the glyph says stack or not, the number is the current | 1488 | // Stacks show their layer count; unrelated links show the current PR and a remainder count. |
| 1492 | // pull request, and "+N" counts the others behind it. A real link so cmd/ctrl+click and | 1489 | // Plain clicks open T3; individual PR links also support opening the host in a new tab. |
| 1493 | // middle-click open the host in the browser; a plain click opens T3's pull request view. | ||
| 1494 | const prBadgeShape = supportsMultiplePullRequests | 1490 | const prBadgeShape = supportsMultiplePullRequests |
| 1495 | ? resolveThreadPullRequestBadge(thread.pullRequests) | 1491 | ? resolveThreadPullRequestBadge(thread.pullRequests) |
| 1496 | : null; | 1492 | : null; |
| 1497 | const prBadgeClassName = (state: "open" | "merged" | "closed", colorClass: string) => | ||
| 1498 | cn( | ||
| 1499 | // Sidebar chrome follows the interface font; tabular digits keep the number from | ||
| 1500 | // reflowing as PR states stream in. A border rather than text-decoration, so the line | ||
| 1501 | // runs under the glyph as well as the number. | ||
| 1502 | "text-xs tabular-nums", | ||
| 1503 | variant === "slim" && variantAction === "unsettle" | ||
| 1504 | ? props.isActive | ||
| 1505 | ? "text-secondary-label" | ||
| 1506 | : cn("text-secondary-label transition-colors", settledPrHoverColorClass(state)) | ||
| 1507 | : colorClass, | ||
| 1508 | ); | ||
| 1509 | const handlePrStackClick = useCallback(() => { | 1493 | const handlePrStackClick = useCallback(() => { |
| 1510 | useRightPanelStore.getState().open(threadRef, "pull-requests"); | 1494 | useRightPanelStore.getState().open(threadRef, "pull-requests"); |
| 1511 | if (!props.isActive) onThreadActivate(threadRef); | 1495 | if (!props.isActive) onThreadActivate(threadRef); |
| 1512 | }, [onThreadActivate, props.isActive, threadRef]); | 1496 | }, [onThreadActivate, props.isActive, threadRef]); |
| 1513 | const prBadge = | 1497 | const prBadge = |
| 1514 | prBadgeShape?.kind === "stack" ? ( | 1498 | prBadgeShape?.kind === "stack" || pr || currentLinkedPr ? ( |
| 1515 | // A stack is one thing with N layers; naming one of them would misrepresent it, so the | 1499 | <ThreadPullRequestBadgeControl |
| 1516 | // badge counts layers and opens the thread's pull-requests surface. | 1500 | variant="underline" |
| 1517 | <InlineButton | 1501 | badge={prBadgeShape} |
| 1518 | underline | 1502 | number={pr?.number ?? currentLinkedPr?.number} |
| 1519 | onPointerDown={(event) => event.stopPropagation()} | 1503 | url={pr?.url ?? currentLinkedPr?.url} |
| 1520 | onClick={handlePrStackClick} | 1504 | status={prStatus} |
| 1521 | className={prBadgeClassName(prBadgeShape.state, PR_STATE_COLOR_CLASS[prBadgeShape.state])} | 1505 | onOpenStack={handlePrStackClick} |
| 1522 | aria-label={`Stack of ${prBadgeShape.layers} pull requests, ${prBadgeShape.state}`} | 1506 | onOpenPullRequest={handlePrClick} |
| 1523 | > | 1507 | /> |
| 1524 | <ThreadPullRequestBadgeIcon icon="stack" /> | ||
| 1525 | {prBadgeShape.layers} | ||
| 1526 | </InlineButton> | ||
| 1527 | ) : prStatus && pr ? ( | ||
| 1528 | <a | ||
| 1529 | href={pr.url} | ||
| 1530 | target="_blank" | ||
| 1531 | rel="noopener noreferrer" | ||
| 1532 | onPointerDown={(event) => event.stopPropagation()} | ||
| 1533 | onClick={handlePrClick} | ||
| 1534 | className={cn( | ||
| 1535 | // Sidebar chrome follows the interface font; tabular digits keep the | ||
| 1536 | // number from reflowing as PR states stream in. | ||
| 1537 | "inline-flex shrink-0 items-center gap-0.5 whitespace-nowrap text-xs tabular-nums hover:underline", | ||
| 1538 | variant === "slim" && variantAction === "unsettle" | ||
| 1539 | ? cn("text-secondary-label transition-colors", settledPrHoverClass) | ||
| 1540 | : prStatus.colorClass, | ||
| 1541 | )} | ||
| 1542 | aria-label={ | ||
| 1543 | prBadgeShape && prBadgeShape.kind === "pull-request" && prBadgeShape.others > 0 | ||
| 1544 | ? `${prStatus.tooltip}, and ${prBadgeShape.others} more linked` | ||
| 1545 | : prStatus.tooltip | ||
| 1546 | } | ||
| 1547 | > | ||
| 1548 | <ThreadPullRequestBadgeIcon icon="pull-request" /> | ||
| 1549 | {pr.number} | ||
| 1550 | {prBadgeShape && prBadgeShape.others > 0 ? ( | ||
| 1551 | <span className="opacity-70">+{prBadgeShape.others}</span> | ||
| 1552 | ) : null} | ||
| 1553 | </a> | ||
| 1554 | ) : currentLinkedPr ? ( | ||
| 1555 | <a | ||
| 1556 | href={currentLinkedPr.url} | ||
| 1557 | target="_blank" | ||
| 1558 | rel="noopener noreferrer" | ||
| 1559 | onPointerDown={(event) => event.stopPropagation()} | ||
| 1560 | onClick={handlePrClick} | ||
| 1561 | className="inline-flex shrink-0 items-center gap-0.5 text-xs tabular-nums text-muted-foreground hover:underline" | ||
| 1562 | aria-label={`PR #${currentLinkedPr.number}, status pending`} | ||
| 1563 | > | ||
| 1564 | <ThreadPullRequestBadgeIcon icon="pull-request" /> | ||
| 1565 | {currentLinkedPr.number} | ||
| 1566 | {prBadgeShape?.kind === "pull-request" && prBadgeShape.others > 0 ? ( | ||
| 1567 | <span>+{prBadgeShape.others}</span> | ||
| 1568 | ) : null} | ||
| 1569 | </a> | ||
| 1570 | ) : null; | 1508 | ) : null; |
| 1571 | const terminalStatusIcon = terminalStatus ? ( | 1509 | const terminalStatusIcon = terminalStatus ? ( |
| 1572 | <span | 1510 | <span |
apps/web/src/components/ThreadStatusIndicators.tsx+85-3| ... | @@ -12,9 +12,11 @@ import { | ... | @@ -12,9 +12,11 @@ import { |
| 12 | resolveThreadCurrentPullRequestLink, | 12 | resolveThreadCurrentPullRequestLink, |
| 13 | resolveThreadPullRequestChains, | 13 | resolveThreadPullRequestChains, |
| 14 | visibleThreadPullRequests, | 14 | visibleThreadPullRequests, |
| 15 | type ThreadPullRequestBadge, | ||
| 15 | } from "@t3tools/shared/threadPullRequests"; | 16 | } from "@t3tools/shared/threadPullRequests"; |
| 16 | import { FolderGit2Icon, GitPullRequestArrowIcon, LayersIcon, TerminalIcon } from "lucide-react"; | 17 | import { FolderGit2Icon, GitPullRequestArrowIcon, LayersIcon, TerminalIcon } from "lucide-react"; |
| 17 | import { useMemo } from "react"; | 18 | import { useMemo, type MouseEvent } from "react"; |
| 19 | import { buttonVariants, InlineButton } from "./ui/button"; | ||
| 18 | import { cn } from "../lib/utils"; | 20 | import { cn } from "../lib/utils"; |
| 19 | import { useEnvironment, usePrimaryEnvironmentId } from "../state/environments"; | 21 | import { useEnvironment, usePrimaryEnvironmentId } from "../state/environments"; |
| 20 | import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; | 22 | import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; |
| ... | @@ -122,7 +124,7 @@ export { | ... | @@ -122,7 +124,7 @@ export { |
| 122 | } from "@t3tools/shared/threadPullRequests"; | 124 | } from "@t3tools/shared/threadPullRequests"; |
| 123 | 125 | ||
| 124 | /** The glyph a row's badge wears: the layers icon for a stack, the pull-request one otherwise. */ | 126 | /** The glyph a row's badge wears: the layers icon for a stack, the pull-request one otherwise. */ |
| 125 | export function ThreadPullRequestBadgeIcon({ | 127 | function ThreadPullRequestBadgeIcon({ |
| 126 | icon, | 128 | icon, |
| 127 | className, | 129 | className, |
| 128 | }: { | 130 | }: { |
| ... | @@ -133,6 +135,86 @@ export function ThreadPullRequestBadgeIcon({ | ... | @@ -133,6 +135,86 @@ export function ThreadPullRequestBadgeIcon({ |
| 133 | return <Icon aria-hidden className={cn("size-3 shrink-0", className)} />; | 135 | return <Icon aria-hidden className={cn("size-3 shrink-0", className)} />; |
| 134 | } | 136 | } |
| 135 | 137 | ||
| 138 | /** The complete linked-PR control shared by the sidebar and composer footer. */ | ||
| 139 | export function ThreadPullRequestBadgeControl({ | ||
| 140 | variant, | ||
| 141 | badge, | ||
| 142 | number, | ||
| 143 | url, | ||
| 144 | status, | ||
| 145 | onOpenStack, | ||
| 146 | onOpenPullRequest, | ||
| 147 | }: { | ||
| 148 | variant: "underline" | "ghost"; | ||
| 149 | badge: ThreadPullRequestBadge | null; | ||
| 150 | number?: number | undefined; | ||
| 151 | url?: string | undefined; | ||
| 152 | status: PrStatusIndicator | null; | ||
| 153 | onOpenStack: () => void; | ||
| 154 | onOpenPullRequest: (event: MouseEvent<HTMLAnchorElement>) => void; | ||
| 155 | }) { | ||
| 156 | const isStack = badge?.kind === "stack"; | ||
| 157 | if (!isStack && (number === undefined || url === undefined)) return null; | ||
| 158 | const label = isStack | ||
| 159 | ? `Stack of ${badge.layers} pull requests, ${badge.state}` | ||
| 160 | : `${status?.tooltip ?? `PR #${number}, status pending`}${ | ||
| 161 | badge?.kind === "pull-request" && badge.others > 0 | ||
| 162 | ? `, and ${badge.others} more linked` | ||
| 163 | : "" | ||
| 164 | }`; | ||
| 165 | const className = cn( | ||
| 166 | variant === "ghost" | ||
| 167 | ? buttonVariants({ variant: "ghost", size: "xs" }) | ||
| 168 | : "inline-flex shrink-0 cursor-pointer items-center gap-0.5 whitespace-nowrap border-b border-transparent hover:border-current focus-visible:outline-2 focus-visible:outline-ring", | ||
| 169 | "text-xs tabular-nums", | ||
| 170 | variant === "ghost" && | ||
| 171 | "font-normal text-xs! active:scale-100 [--control-icon-color:currentColor]", | ||
| 172 | isStack ? PR_STATE_COLOR_CLASS[badge.state] : (status?.colorClass ?? "text-muted-foreground"), | ||
| 173 | ); | ||
| 174 | const content = ( | ||
| 175 | <> | ||
| 176 | <ThreadPullRequestBadgeIcon icon={badge?.kind ?? "pull-request"} /> | ||
| 177 | {isStack ? badge.layers : number} | ||
| 178 | {badge?.kind === "pull-request" && badge.others > 0 ? ( | ||
| 179 | <span className="opacity-70">+{badge.others}</span> | ||
| 180 | ) : null} | ||
| 181 | </> | ||
| 182 | ); | ||
| 183 | return ( | ||
| 184 | <Tooltip> | ||
| 185 | <TooltipTrigger | ||
| 186 | render={ | ||
| 187 | isStack ? ( | ||
| 188 | <InlineButton | ||
| 189 | className={className} | ||
| 190 | aria-label={label} | ||
| 191 | onPointerDown={(event) => event.stopPropagation()} | ||
| 192 | onClick={(event) => { | ||
| 193 | event.preventDefault(); | ||
| 194 | event.stopPropagation(); | ||
| 195 | onOpenStack(); | ||
| 196 | }} | ||
| 197 | /> | ||
| 198 | ) : ( | ||
| 199 | <a | ||
| 200 | href={url} | ||
| 201 | target="_blank" | ||
| 202 | rel="noopener noreferrer" | ||
| 203 | className={className} | ||
| 204 | aria-label={label} | ||
| 205 | onPointerDown={(event) => event.stopPropagation()} | ||
| 206 | onClick={onOpenPullRequest} | ||
| 207 | /> | ||
| 208 | ) | ||
| 209 | } | ||
| 210 | > | ||
| 211 | {content} | ||
| 212 | </TooltipTrigger> | ||
| 213 | <TooltipPopup side="top">{label}</TooltipPopup> | ||
| 214 | </Tooltip> | ||
| 215 | ); | ||
| 216 | } | ||
| 217 | |||
| 136 | /** | 218 | /** |
| 137 | * A miniature of the pull-requests panel for the thread tooltip: same order, same indentation, | 219 | * A miniature of the pull-requests panel for the thread tooltip: same order, same indentation, |
| 138 | * so the hover answers "what is in here" without opening the surface. | 220 | * so the hover answers "what is in here" without opening the surface. |
| ... | @@ -192,7 +274,7 @@ export function ThreadPullRequestsMiniList({ | ... | @@ -192,7 +274,7 @@ export function ThreadPullRequestsMiniList({ |
| 192 | } | 274 | } |
| 193 | 275 | ||
| 194 | /** The ink each pull-request state wears in the sidebar, shared by the number and stack badges. */ | 276 | /** The ink each pull-request state wears in the sidebar, shared by the number and stack badges. */ |
| 195 | export const PR_STATE_COLOR_CLASS: Record<NonNullable<ThreadPr>["state"], string> = { | 277 | const PR_STATE_COLOR_CLASS: Record<NonNullable<ThreadPr>["state"], string> = { |
| 196 | open: "text-emerald-600 dark:text-emerald-300/90", | 278 | open: "text-emerald-600 dark:text-emerald-300/90", |
| 197 | merged: "text-violet-600 dark:text-violet-300/90", | 279 | merged: "text-violet-600 dark:text-violet-300/90", |
| 198 | closed: "text-red-600 dark:text-red-300/90", | 280 | closed: "text-red-600 dark:text-red-300/90", |
apps/web/src/components/chat/MessagesTimeline.logic.ts+1-1| ... | @@ -337,7 +337,7 @@ export type MessagesTimelineRow = | ... | @@ -337,7 +337,7 @@ export type MessagesTimelineRow = |
| 337 | summaryKind: ToolGroupSummaryKind; | 337 | summaryKind: ToolGroupSummaryKind; |
| 338 | toolSurface?: WorkLogEntry["toolSurface"]; | 338 | toolSurface?: WorkLogEntry["toolSurface"]; |
| 339 | toolIcon?: WorkLogEntry["toolIcon"]; | 339 | toolIcon?: WorkLogEntry["toolIcon"]; |
| 340 | summaryToolIcon?: "browser" | "t3-code"; | 340 | summaryToolIcon?: "browser" | "t3-code" | "pull-request"; |
| 341 | hasFailure: boolean; | 341 | hasFailure: boolean; |
| 342 | } | 342 | } |
| 343 | | { | 343 | | { |
apps/web/src/components/chat/MessagesTimeline.tsx+9| ... | @@ -1,3 +1,4 @@ | ... | @@ -1,3 +1,4 @@ |
| 1 | import { GitPullRequestIcon } from "lucide-react"; | ||
| 1 | import { | 2 | import { |
| 2 | type AssistantCitation, | 3 | type AssistantCitation, |
| 3 | type EnvironmentId, | 4 | type EnvironmentId, |
| ... | @@ -2225,6 +2226,11 @@ function toolGroupSummaryIconName( | ... | @@ -2225,6 +2226,11 @@ function toolGroupSummaryIconName( |
| 2225 | kind: Extract<TimelineRow, { kind: "work-toggle" }>["summaryKind"], | 2226 | kind: Extract<TimelineRow, { kind: "work-toggle" }>["summaryKind"], |
| 2226 | ): WorkEntryIconName { | 2227 | ): WorkEntryIconName { |
| 2227 | switch (kind) { | 2228 | switch (kind) { |
| 2229 | case "pull-request": | ||
| 2230 | case "link-pr": | ||
| 2231 | case "unlink-pr": | ||
| 2232 | case "list-prs": | ||
| 2233 | return "pull-request"; | ||
| 2228 | case "read": | 2234 | case "read": |
| 2229 | return "eye"; | 2235 | return "eye"; |
| 2230 | case "edit": | 2236 | case "edit": |
| ... | @@ -2801,6 +2807,7 @@ type WorkEntryIconName = | ... | @@ -2801,6 +2807,7 @@ type WorkEntryIconName = |
| 2801 | | "search" | 2807 | | "search" |
| 2802 | | "square-pen" | 2808 | | "square-pen" |
| 2803 | | "terminal" | 2809 | | "terminal" |
| 2810 | | "pull-request" | ||
| 2804 | | "t3-code" | 2811 | | "t3-code" |
| 2805 | | "wrench" | 2812 | | "wrench" |
| 2806 | | "x" | 2813 | | "x" |
| ... | @@ -3005,6 +3012,8 @@ function ToolActivityImageIcon(props: { | ... | @@ -3005,6 +3012,8 @@ function ToolActivityImageIcon(props: { |
| 3005 | 3012 | ||
| 3006 | function WorkEntryIcon({ name, className }: { name: WorkEntryIconName; className: string }) { | 3013 | function WorkEntryIcon({ name, className }: { name: WorkEntryIconName; className: string }) { |
| 3007 | switch (name) { | 3014 | switch (name) { |
| 3015 | case "pull-request": | ||
| 3016 | return <GitPullRequestIcon className={className} aria-hidden />; | ||
| 3008 | case "bot": | 3017 | case "bot": |
| 3009 | return <BotIcon className={className} aria-hidden />; | 3018 | return <BotIcon className={className} aria-hidden />; |
| 3010 | case "brain": | 3019 | case "brain": |
apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx+119-51| ... | @@ -1,3 +1,5 @@ | ... | @@ -1,3 +1,5 @@ |
| 1 | import { parseChangeRequestUrl } from "@t3tools/shared/changeRequestUrl"; | ||
| 2 | import { usePullRequestStack } from "~/state/usePullRequestStack"; | ||
| 1 | import { RefreshIcon } from "~/components/ui/refresh-icon"; | 3 | import { RefreshIcon } from "~/components/ui/refresh-icon"; |
| 2 | import { scopedThreadKey, scopeProjectRef } from "@t3tools/client-runtime/environment"; | 4 | import { scopedThreadKey, scopeProjectRef } from "@t3tools/client-runtime/environment"; |
| 3 | import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; | 5 | import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; |
| ... | @@ -70,10 +72,10 @@ import { useProjects, useServerConfigs } from "~/state/entities"; | ... | @@ -70,10 +72,10 @@ import { useProjects, useServerConfigs } from "~/state/entities"; |
| 70 | import { useEnvironments, usePrimaryEnvironmentId } from "~/state/environments"; | 72 | import { useEnvironments, usePrimaryEnvironmentId } from "~/state/environments"; |
| 71 | import { useEnvironmentQuery } from "~/state/query"; | 73 | import { useEnvironmentQuery } from "~/state/query"; |
| 72 | import { useLiveRefresh } from "~/hooks/useLiveRefresh"; | 74 | import { useLiveRefresh } from "~/hooks/useLiveRefresh"; |
| 73 | import { pullRequestEnvironment, pullRequestStackAtom } from "~/state/pullRequests"; | 75 | import { pullRequestEnvironment } from "~/state/pullRequests"; |
| 74 | import { usePullRequestTurnRefresh, useSharedPullRequestSummary } from "~/state/pullRequests"; | 76 | import { usePullRequestTurnRefresh, useSharedPullRequestSummary } from "~/state/pullRequests"; |
| 75 | import { useAtomCommand } from "~/state/use-atom-command"; | 77 | import { useAtomCommand } from "~/state/use-atom-command"; |
| 76 | import { PullRequestStackMap } from "./PullRequestStackMap"; | 78 | import { PullRequestStackMenu } from "./PullRequestStackMenu"; |
| 77 | import { PullRequestThreadLinks } from "./PullRequestThreadLinks"; | 79 | import { PullRequestThreadLinks } from "./PullRequestThreadLinks"; |
| 78 | import { vcsEnvironment } from "~/state/vcs"; | 80 | import { vcsEnvironment } from "~/state/vcs"; |
| 79 | import { formatRelativeTimeLabel } from "~/timestampFormat"; | 81 | import { formatRelativeTimeLabel } from "~/timestampFormat"; |
| ... | @@ -104,7 +106,7 @@ import { | ... | @@ -104,7 +106,7 @@ import { |
| 104 | } from "../ui/menu"; | 106 | } from "../ui/menu"; |
| 105 | import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; | 107 | import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; |
| 106 | import { toastManager } from "../ui/toast"; | 108 | import { toastManager } from "../ui/toast"; |
| 107 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; | 109 | import { Tooltip, TooltipPopup, TooltipProvider, TooltipTrigger } from "../ui/tooltip"; |
| 108 | import { PullRequestDetailGhost, PullRequestTimelineGhost } from "./PullRequestGhosts"; | 110 | import { PullRequestDetailGhost, PullRequestTimelineGhost } from "./PullRequestGhosts"; |
| 109 | import { PullRequestActivityUnavailableState } from "./PullRequestActivityUnavailableState"; | 111 | import { PullRequestActivityUnavailableState } from "./PullRequestActivityUnavailableState"; |
| 110 | import { DiffPanelLoadingState } from "../DiffPanelShell"; | 112 | import { DiffPanelLoadingState } from "../DiffPanelShell"; |
| ... | @@ -136,6 +138,7 @@ import { | ... | @@ -136,6 +138,7 @@ import { |
| 136 | readPullRequestDetailSnapshot, | 138 | readPullRequestDetailSnapshot, |
| 137 | resolveDisplayedPullRequestDetail, | 139 | resolveDisplayedPullRequestDetail, |
| 138 | resolvePullRequestPrimaryControl, | 140 | resolvePullRequestPrimaryControl, |
| 141 | allowsSinglePullRequestMerge, | ||
| 139 | resolveBaseFreshness, | 142 | resolveBaseFreshness, |
| 140 | resolvePullRequestMergeMethod, | 143 | resolvePullRequestMergeMethod, |
| 141 | type PullRequestFinding, | 144 | type PullRequestFinding, |
| ... | @@ -459,8 +462,10 @@ export function PullRequestDetailPanel({ | ... | @@ -459,8 +462,10 @@ export function PullRequestDetailPanel({ |
| 459 | context = "page", | 462 | context = "page", |
| 460 | composerDraftTarget, | 463 | composerDraftTarget, |
| 461 | onBack, | 464 | onBack, |
| 465 | onSelectPullRequest, | ||
| 462 | }: { | 466 | }: { |
| 463 | environmentId: EnvironmentId; | 467 | environmentId: EnvironmentId; |
| 468 | onSelectPullRequest?: ((reference: PullRequestRef) => void) | undefined; | ||
| 464 | /** | 469 | /** |
| 465 | * The thread this panel sits beside, if any. Links that are not the pull | 470 | * The thread this panel sits beside, if any. Links that are not the pull |
| 466 | * request itself (check details, host permalinks) can open in that thread's | 471 | * request itself (check details, host permalinks) can open in that thread's |
| ... | @@ -730,17 +735,34 @@ export function PullRequestDetailPanel({ | ... | @@ -730,17 +735,34 @@ export function PullRequestDetailPanel({ |
| 730 | isStackedPullRequestBase(detail.baseBranch, branchRefsQuery.data?.refs ?? []); | 735 | isStackedPullRequestBase(detail.baseBranch, branchRefsQuery.data?.refs ?? []); |
| 731 | // The host's own stack, where it keeps one. Only asked for once the detail has landed so a | 736 | // The host's own stack, where it keeps one. Only asked for once the detail has landed so a |
| 732 | // pull request nobody can read costs one request rather than two. | 737 | // pull request nobody can read costs one request rather than two. |
| 733 | const nativeStack = useEnvironmentQuery( | 738 | const stackReference = useMemo( |
| 734 | detail === null || detail.capabilities.stacks !== true || !supportsThreadPullRequests | 739 | () => |
| 735 | ? null | 740 | detail === null || detail.capabilities.stacks !== true || !supportsThreadPullRequests |
| 736 | : pullRequestStackAtom({ environmentId, input: reference }), | 741 | ? null |
| 737 | ).data; | 742 | : { ...reference, host: reference.host ?? parseChangeRequestUrl(detail.url)?.host }, |
| 743 | [detail, reference, supportsThreadPullRequests], | ||
| 744 | ); | ||
| 745 | const nativeStackQuery = usePullRequestStack(environmentId, stackReference); | ||
| 746 | const nativeStack = nativeStackQuery.data; | ||
| 747 | const supportsStackActions = | ||
| 748 | supportsThreadPullRequests && | ||
| 749 | detail?.capabilities.stacks === true && | ||
| 750 | detail.capabilities.stackActions === true && | ||
| 751 | environmentConfigs.get(environmentId)?.environment.capabilities.pullRequestStackActions === | ||
| 752 | true; | ||
| 753 | const canMergeSinglePullRequest = allowsSinglePullRequestMerge({ | ||
| 754 | supportsStackActions, | ||
| 755 | hasStack: nativeStack !== null, | ||
| 756 | stackPending: !nativeStackQuery.isSuccess || nativeStackQuery.isPending, | ||
| 757 | stackError: nativeStackQuery.error, | ||
| 758 | }); | ||
| 738 | const activityPending = activityQuery.isPending && activity === null; | 759 | const activityPending = activityQuery.isPending && activity === null; |
| 739 | const activityError = activity === null ? activityQuery.error : null; | 760 | const activityError = activity === null ? activityQuery.error : null; |
| 740 | const refreshDetail = useCallback(() => { | 761 | const refreshDetail = useCallback(() => { |
| 741 | detailQuery.refresh(); | 762 | detailQuery.refresh(); |
| 742 | activityQuery.refresh(); | 763 | activityQuery.refresh(); |
| 743 | }, [activityQuery.refresh, detailQuery.refresh]); | 764 | nativeStackQuery.refresh(); |
| 765 | }, [activityQuery.refresh, detailQuery.refresh, nativeStackQuery.refresh]); | ||
| 744 | const [refreshToken, setRefreshToken] = useState(0); | 766 | const [refreshToken, setRefreshToken] = useState(0); |
| 745 | const codeRefreshToken = refreshToken + (turnRefresh ?? 0); | 767 | const codeRefreshToken = refreshToken + (turnRefresh ?? 0); |
| 746 | const activityRevision = useRef<{ readonly key: string; readonly updatedAt: string } | null>( | 768 | const activityRevision = useRef<{ readonly key: string; readonly updatedAt: string } | null>( |
| ... | @@ -1351,9 +1373,9 @@ export function PullRequestDetailPanel({ | ... | @@ -1351,9 +1373,9 @@ export function PullRequestDetailPanel({ |
| 1351 | checksState, | 1373 | checksState, |
| 1352 | autoMergeEnabled: detail.autoMergeEnabled, | 1374 | autoMergeEnabled: detail.autoMergeEnabled, |
| 1353 | hasMergeMethod: allowedMergeMethods.length > 0, | 1375 | hasMergeMethod: allowedMergeMethods.length > 0, |
| 1354 | canMerge: can("merge"), | 1376 | canMerge: canMergeSinglePullRequest && can("merge"), |
| 1355 | canMarkReady: can("ready"), | 1377 | canMarkReady: can("ready"), |
| 1356 | canEnableAutoMerge: can("enable-auto-merge"), | 1378 | canEnableAutoMerge: canMergeSinglePullRequest && can("enable-auto-merge"), |
| 1357 | }) | 1379 | }) |
| 1358 | : null; | 1380 | : null; |
| 1359 | // What the menu's action group holds. Named once so the separators around it are drawn from | 1381 | // What the menu's action group holds. Named once so the separators around it are drawn from |
| ... | @@ -1363,6 +1385,7 @@ export function PullRequestDetailPanel({ | ... | @@ -1363,6 +1385,7 @@ export function PullRequestDetailPanel({ |
| 1363 | can(detail.isDraft ? "ready" : "draft") && | 1385 | can(detail.isDraft ? "ready" : "draft") && |
| 1364 | !(detail.isDraft && primaryAction === "ready"); | 1386 | !(detail.isDraft && primaryAction === "ready"); |
| 1365 | const showsAutoMerge = | 1387 | const showsAutoMerge = |
| 1388 | canMergeSinglePullRequest && | ||
| 1366 | detail?.state === "open" && | 1389 | detail?.state === "open" && |
| 1367 | ((autoMergeArmed && can("disable-auto-merge")) || | 1390 | ((autoMergeArmed && can("disable-auto-merge")) || |
| 1368 | (!autoMergeArmed && | 1391 | (!autoMergeArmed && |
| ... | @@ -1372,6 +1395,7 @@ export function PullRequestDetailPanel({ | ... | @@ -1372,6 +1395,7 @@ export function PullRequestDetailPanel({ |
| 1372 | can("enable-auto-merge") && | 1395 | can("enable-auto-merge") && |
| 1373 | allowedMergeMethods.length > 0)); | 1396 | allowedMergeMethods.length > 0)); |
| 1374 | const showsMergeNow = | 1397 | const showsMergeNow = |
| 1398 | canMergeSinglePullRequest && | ||
| 1375 | detail?.state === "open" && | 1399 | detail?.state === "open" && |
| 1376 | (primaryAction === "enable-auto-merge" || primaryAction === "auto-merge-armed") && | 1400 | (primaryAction === "enable-auto-merge" || primaryAction === "auto-merge-armed") && |
| 1377 | can("merge") && | 1401 | can("merge") && |
| ... | @@ -1568,7 +1592,38 @@ export function PullRequestDetailPanel({ | ... | @@ -1568,7 +1592,38 @@ export function PullRequestDetailPanel({ |
| 1568 | </div> | 1592 | </div> |
| 1569 | <div className="mr-4 flex h-7 shrink-0 items-center justify-end gap-1"> | 1593 | <div className="mr-4 flex h-7 shrink-0 items-center justify-end gap-1"> |
| 1570 | {detail ? ( | 1594 | {detail ? ( |
| 1571 | <> | 1595 | <TooltipProvider delay={150} closeDelay={150} timeout={400}> |
| 1596 | {!nativeStack && supportsStackActions && nativeStackQuery.error ? ( | ||
| 1597 | <Button variant="ghost" size="xs" onClick={nativeStackQuery.refresh}> | ||
| 1598 | Retry stack lookup | ||
| 1599 | </Button> | ||
| 1600 | ) : null} | ||
| 1601 | {nativeStack ? ( | ||
| 1602 | <PullRequestStackMenu | ||
| 1603 | stack={nativeStack} | ||
| 1604 | notice={nativeStackQuery.notice} | ||
| 1605 | onRetry={nativeStackQuery.error ? nativeStackQuery.refresh : undefined} | ||
| 1606 | reference={reference} | ||
| 1607 | environmentId={environmentId} | ||
| 1608 | onSelect={onSelectPullRequest} | ||
| 1609 | mergeMethod={selectedMergeMethod} | ||
| 1610 | canMerge={ | ||
| 1611 | nativeStackQuery.isFresh && | ||
| 1612 | supportsStackActions && | ||
| 1613 | can("merge") && | ||
| 1614 | allowedMergeMethods.length > 0 | ||
| 1615 | } | ||
| 1616 | canRebase={ | ||
| 1617 | nativeStackQuery.isFresh && | ||
| 1618 | supportsStackActions && | ||
| 1619 | detail.viewerPermissions.stackRebase === true | ||
| 1620 | } | ||
| 1621 | onActed={() => { | ||
| 1622 | refreshDetail(); | ||
| 1623 | onActed?.(); | ||
| 1624 | }} | ||
| 1625 | /> | ||
| 1626 | ) : null} | ||
| 1572 | {context === "page" ? ( | 1627 | {context === "page" ? ( |
| 1573 | <PullRequestThreadLinks | 1628 | <PullRequestThreadLinks |
| 1574 | display="count" | 1629 | display="count" |
| ... | @@ -1585,24 +1640,34 @@ export function PullRequestDetailPanel({ | ... | @@ -1585,24 +1640,34 @@ export function PullRequestDetailPanel({ |
| 1585 | the page: beside a thread the branch is already checked out right there. */} | 1640 | the page: beside a thread the branch is already checked out right there. */} |
| 1586 | {context === "page" ? ( | 1641 | {context === "page" ? ( |
| 1587 | <Menu> | 1642 | <Menu> |
| 1588 | <MenuTrigger | 1643 | <Tooltip> |
| 1589 | disabled={handoff !== null} | 1644 | <TooltipTrigger |
| 1590 | render={ | 1645 | render={ |
| 1591 | <Button | 1646 | <MenuTrigger |
| 1592 | size="xs" | 1647 | disabled={handoff !== null} |
| 1593 | variant="outline" | 1648 | render={ |
| 1594 | aria-label={ | 1649 | <Button |
| 1595 | handoff?.startsWith("checkout") ? "Checking out..." : "Check out" | 1650 | size="xs" |
| 1596 | } | 1651 | variant="ghost" |
| 1597 | > | 1652 | aria-label={ |
| 1598 | <GitBranchIcon aria-hidden className="size-3.5" /> | 1653 | handoff?.startsWith("checkout") ? "Checking out..." : "Check out" |
| 1599 | <span className="@max-[35rem]/pr-header:hidden"> | 1654 | } |
| 1600 | {handoff?.startsWith("checkout") ? "Checking out..." : "Check out"} | 1655 | > |
| 1601 | </span> | 1656 | <GitBranchIcon aria-hidden className="size-3.5" /> |
| 1602 | <ChevronDownIcon aria-hidden className="size-3.5 text-muted-foreground" /> | 1657 | <span className="@max-[35rem]/pr-header:hidden"> |
| 1603 | </Button> | 1658 | {handoff?.startsWith("checkout") ? "Checking out..." : "Check out"} |
| 1604 | } | 1659 | </span> |
| 1605 | /> | 1660 | <ChevronDownIcon |
| 1661 | aria-hidden | ||
| 1662 | className="size-3.5 text-muted-foreground" | ||
| 1663 | /> | ||
| 1664 | </Button> | ||
| 1665 | } | ||
| 1666 | /> | ||
| 1667 | } | ||
| 1668 | /> | ||
| 1669 | <TooltipPopup>Check out this pull request</TooltipPopup> | ||
| 1670 | </Tooltip> | ||
| 1606 | <MenuPopup align="end" side="bottom" className="min-w-72"> | 1671 | <MenuPopup align="end" side="bottom" className="min-w-72"> |
| 1607 | <MenuItem onClick={() => startCheckout("worktree")}> | 1672 | <MenuItem onClick={() => startCheckout("worktree")}> |
| 1608 | <GitBranchIcon className="mt-0.5 size-3.5 shrink-0 self-start" /> | 1673 | <GitBranchIcon className="mt-0.5 size-3.5 shrink-0 self-start" /> |
| ... | @@ -1663,7 +1728,7 @@ export function PullRequestDetailPanel({ | ... | @@ -1663,7 +1728,7 @@ export function PullRequestDetailPanel({ |
| 1663 | <span className="inline-flex shrink-0"> | 1728 | <span className="inline-flex shrink-0"> |
| 1664 | <Button | 1729 | <Button |
| 1665 | size="xs" | 1730 | size="xs" |
| 1666 | variant="destructive-outline" | 1731 | variant="ghost" |
| 1667 | disabled={handoff !== null} | 1732 | disabled={handoff !== null} |
| 1668 | onClick={startResolveConflicts} | 1733 | onClick={startResolveConflicts} |
| 1669 | aria-label={ | 1734 | aria-label={ |
| ... | @@ -1689,6 +1754,7 @@ export function PullRequestDetailPanel({ | ... | @@ -1689,6 +1754,7 @@ export function PullRequestDetailPanel({ |
| 1689 | <span className="inline-flex shrink-0"> | 1754 | <span className="inline-flex shrink-0"> |
| 1690 | <Button | 1755 | <Button |
| 1691 | size="xs" | 1756 | size="xs" |
| 1757 | variant="ghost" | ||
| 1692 | disabled={actionPending} | 1758 | disabled={actionPending} |
| 1693 | onClick={() => void perform("ready")} | 1759 | onClick={() => void perform("ready")} |
| 1694 | aria-label="Ready for review" | 1760 | aria-label="Ready for review" |
| ... | @@ -1711,6 +1777,7 @@ export function PullRequestDetailPanel({ | ... | @@ -1711,6 +1777,7 @@ export function PullRequestDetailPanel({ |
| 1711 | <span className="inline-flex shrink-0"> | 1777 | <span className="inline-flex shrink-0"> |
| 1712 | <Button | 1778 | <Button |
| 1713 | size="xs" | 1779 | size="xs" |
| 1780 | variant="ghost" | ||
| 1714 | disabled={actionPending} | 1781 | disabled={actionPending} |
| 1715 | onClick={() => | 1782 | onClick={() => |
| 1716 | setConfirmation({ open: true, action: "enable-auto-merge" }) | 1783 | setConfirmation({ open: true, action: "enable-auto-merge" }) |
| ... | @@ -1762,6 +1829,7 @@ export function PullRequestDetailPanel({ | ... | @@ -1762,6 +1829,7 @@ export function PullRequestDetailPanel({ |
| 1762 | <span className="inline-flex shrink-0"> | 1829 | <span className="inline-flex shrink-0"> |
| 1763 | <Button | 1830 | <Button |
| 1764 | size="xs" | 1831 | size="xs" |
| 1832 | variant="ghost" | ||
| 1765 | disabled={actionPending} | 1833 | disabled={actionPending} |
| 1766 | onClick={() => setConfirmation({ open: true, action: "merge" })} | 1834 | onClick={() => setConfirmation({ open: true, action: "merge" })} |
| 1767 | aria-label={ | 1835 | aria-label={ |
| ... | @@ -1791,18 +1859,25 @@ export function PullRequestDetailPanel({ | ... | @@ -1791,18 +1859,25 @@ export function PullRequestDetailPanel({ |
| 1791 | </Badge> | 1859 | </Badge> |
| 1792 | ) : null} | 1860 | ) : null} |
| 1793 | <Menu> | 1861 | <Menu> |
| 1794 | <MenuTrigger | 1862 | <Tooltip> |
| 1795 | render={ | 1863 | <TooltipTrigger |
| 1796 | <Button | 1864 | render={ |
| 1797 | aria-label="More pull request actions" | 1865 | <MenuTrigger |
| 1798 | className="size-6" | 1866 | render={ |
| 1799 | size="icon-xs" | 1867 | <Button |
| 1800 | variant="ghost-muted" | 1868 | aria-label="More pull request actions" |
| 1801 | /> | 1869 | className="size-6" |
| 1802 | } | 1870 | size="icon-xs" |
| 1803 | > | 1871 | variant="ghost-muted" |
| 1804 | <MoreHorizontalIcon className="size-4" /> | 1872 | /> |
| 1805 | </MenuTrigger> | 1873 | } |
| 1874 | > | ||
| 1875 | <MoreHorizontalIcon className="size-4" /> | ||
| 1876 | </MenuTrigger> | ||
| 1877 | } | ||
| 1878 | /> | ||
| 1879 | <TooltipPopup>More pull request actions</TooltipPopup> | ||
| 1880 | </Tooltip> | ||
| 1806 | <MenuPopup align="end" side="bottom" className="min-w-72"> | 1881 | <MenuPopup align="end" side="bottom" className="min-w-72"> |
| 1807 | <PullRequestThreadLinks | 1882 | <PullRequestThreadLinks |
| 1808 | display="menu-item" | 1883 | display="menu-item" |
| ... | @@ -1997,7 +2072,7 @@ export function PullRequestDetailPanel({ | ... | @@ -1997,7 +2072,7 @@ export function PullRequestDetailPanel({ |
| 1997 | ) : null} | 2072 | ) : null} |
| 1998 | </MenuPopup> | 2073 | </MenuPopup> |
| 1999 | </Menu> | 2074 | </Menu> |
| 2000 | </> | 2075 | </TooltipProvider> |
| 2001 | ) : null} | 2076 | ) : null} |
| 2002 | {onClose ? ( | 2077 | {onClose ? ( |
| 2003 | <Button | 2078 | <Button |
| ... | @@ -2102,13 +2177,6 @@ export function PullRequestDetailPanel({ | ... | @@ -2102,13 +2177,6 @@ export function PullRequestDetailPanel({ |
| 2102 | /> | 2177 | /> |
| 2103 | </span> | 2178 | </span> |
| 2104 | </div> | 2179 | </div> |
| 2105 | {nativeStack ? ( | ||
| 2106 | <PullRequestStackMap | ||
| 2107 | stack={nativeStack} | ||
| 2108 | currentNumber={detail.number} | ||
| 2109 | className="mt-1" | ||
| 2110 | /> | ||
| 2111 | ) : null} | ||
| 2112 | </div> | 2180 | </div> |
| 2113 | ) : null} | 2181 | ) : null} |
| 2114 | </div> | 2182 | </div> |
apps/web/src/components/pullRequest/PullRequestRow.tsx+22-1| ... | @@ -1,4 +1,5 @@ | ... | @@ -1,4 +1,5 @@ |
| 1 | import { SearchIcon } from "lucide-react"; | 1 | import { SearchIcon } from "lucide-react"; |
| 2 | import { PullRequestStackPopover } from "./PullRequestStackPopover"; | ||
| 2 | import { memo, type RefCallback } from "react"; | 3 | import { memo, type RefCallback } from "react"; |
| 3 | 4 | ||
| 4 | import { cn } from "~/lib/utils"; | 5 | import { cn } from "~/lib/utils"; |
| ... | @@ -60,6 +61,11 @@ function PullRequestRowLabels({ labels }: { labels: EnvironmentPullRequestEntry[ | ... | @@ -60,6 +61,11 @@ function PullRequestRowLabels({ labels }: { labels: EnvironmentPullRequestEntry[ |
| 60 | ); | 61 | ); |
| 61 | } | 62 | } |
| 62 | 63 | ||
| 64 | export type PullRequestRowTarget = Pick< | ||
| 65 | EnvironmentPullRequestEntry, | ||
| 66 | "environmentId" | "projectId" | "host" | "repository" | "number" | ||
| 67 | >; | ||
| 68 | |||
| 63 | function PullRequestRowImpl({ | 69 | function PullRequestRowImpl({ |
| 64 | entry, | 70 | entry, |
| 65 | selected, | 71 | selected, |
| ... | @@ -86,7 +92,7 @@ function PullRequestRowImpl({ | ... | @@ -86,7 +92,7 @@ function PullRequestRowImpl({ |
| 86 | /** Used by the list's shared visibility observer to defer optional line-count reads. */ | 92 | /** Used by the list's shared visibility observer to defer optional line-count reads. */ |
| 87 | statsKey?: string; | 93 | statsKey?: string; |
| 88 | statsRef?: RefCallback<HTMLButtonElement>; | 94 | statsRef?: RefCallback<HTMLButtonElement>; |
| 89 | onSelect: (entry: EnvironmentPullRequestEntry) => void; | 95 | onSelect: (entry: PullRequestRowTarget) => void; |
| 90 | }) { | 96 | }) { |
| 91 | const { Icon, providerName } = getSourceControlPresentationForKind(entry.provider); | 97 | const { Icon, providerName } = getSourceControlPresentationForKind(entry.provider); |
| 92 | return ( | 98 | return ( |
| ... | @@ -116,6 +122,21 @@ function PullRequestRowImpl({ | ... | @@ -116,6 +122,21 @@ function PullRequestRowImpl({ |
| 116 | {entry.title} | 122 | {entry.title} |
| 117 | </span> | 123 | </span> |
| 118 | <span className="col-start-2 row-start-1 flex max-w-36 items-center justify-self-end gap-2 text-xs"> | 124 | <span className="col-start-2 row-start-1 flex max-w-36 items-center justify-self-end gap-2 text-xs"> |
| 125 | {entry.stack ? ( | ||
| 126 | <PullRequestStackPopover | ||
| 127 | environmentId={entry.environmentId} | ||
| 128 | reference={{ | ||
| 129 | projectId: entry.projectId, | ||
| 130 | host: entry.host, | ||
| 131 | repository: entry.repository, | ||
| 132 | number: entry.number, | ||
| 133 | }} | ||
| 134 | membership={entry.stack} | ||
| 135 | onSelect={(target) => | ||
| 136 | onSelect({ ...target, host: entry.host, environmentId: entry.environmentId }) | ||
| 137 | } | ||
| 138 | /> | ||
| 139 | ) : null} | ||
| 119 | {/* Only a verdict somebody has actually given: "review required" is the absence of | 140 | {/* Only a verdict somebody has actually given: "review required" is the absence of |
| 120 | one, and saying so on every unreviewed row would say nothing. */} | 141 | one, and saying so on every unreviewed row would say nothing. */} |
| 121 | {entry.reviewDecision === "approved" || entry.reviewDecision === "changes-requested" ? ( | 142 | {entry.reviewDecision === "approved" || entry.reviewDecision === "changes-requested" ? ( |
apps/web/src/components/pullRequest/PullRequestStackHeader.tsx created+26| ... | @@ -0,0 +1,26 @@ | ||
| 1 | import { MenuGroupLabel } from "../ui/menu"; | ||
| 2 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; | ||
| 3 | |||
| 4 | export function PullRequestStackHeader({ | ||
| 5 | number, | ||
| 6 | notice, | ||
| 7 | stale = false, | ||
| 8 | }: { | ||
| 9 | number: number; | ||
| 10 | notice?: string | null | undefined; | ||
| 11 | stale?: boolean; | ||
| 12 | }) { | ||
| 13 | return ( | ||
| 14 | <MenuGroupLabel className="flex items-center justify-between gap-2"> | ||
| 15 | <span>Stack #{number}</span> | ||
| 16 | {notice ? ( | ||
| 17 | <Tooltip> | ||
| 18 | <TooltipTrigger render={<span role="status" className="text-xs font-normal" />}> | ||
| 19 | {stale ? "May be stale" : "Refreshing…"} | ||
| 20 | </TooltipTrigger> | ||
| 21 | <TooltipPopup>{notice}</TooltipPopup> | ||
| 22 | </Tooltip> | ||
| 23 | ) : null} | ||
| 24 | </MenuGroupLabel> | ||
| 25 | ); | ||
| 26 | } | ||
apps/web/src/components/pullRequest/PullRequestStackLayerContent.tsx created+28| ... | @@ -0,0 +1,28 @@ | ||
| 1 | import type { PullRequestStack } from "@t3tools/contracts"; | ||
| 2 | import { cn } from "~/lib/utils"; | ||
| 3 | import { resolvePullRequestState } from "./pullRequestPresentation"; | ||
| 4 | |||
| 5 | export function PullRequestStackLayerContent({ | ||
| 6 | layer, | ||
| 7 | compact = false, | ||
| 8 | }: { | ||
| 9 | layer: PullRequestStack["layers"][number]; | ||
| 10 | compact?: boolean; | ||
| 11 | }) { | ||
| 12 | const state = resolvePullRequestState({ | ||
| 13 | state: layer.state, | ||
| 14 | isDraft: layer.isDraft ?? false, | ||
| 15 | }); | ||
| 16 | return ( | ||
| 17 | <> | ||
| 18 | <state.Icon aria-hidden className={cn("size-4 shrink-0", state.toneClassName)} /> | ||
| 19 | <span className="min-w-0 flex-1"> | ||
| 20 | <span className="block truncate">{layer.title || layer.headBranch}</span> | ||
| 21 | <span className="block truncate text-xs font-normal text-muted-foreground"> | ||
| 22 | #{layer.number} · {compact ? null : `${layer.headBranch} · `} | ||
| 23 | {state.label} | ||
| 24 | </span> | ||
| 25 | </span> | ||
| 26 | </> | ||
| 27 | ); | ||
| 28 | } | ||
apps/web/src/components/pullRequest/PullRequestStackLayers.tsx created+39| ... | @@ -0,0 +1,39 @@ | ||
| 1 | import type { PullRequestRef, PullRequestStack } from "@t3tools/contracts"; | ||
| 2 | import { CheckIcon } from "lucide-react"; | ||
| 3 | import { MenuItem, MenuGroupLabel } from "../ui/menu"; | ||
| 4 | import { PullRequestStackLayerContent } from "./PullRequestStackLayerContent"; | ||
| 5 | |||
| 6 | export function PullRequestStackLayers({ | ||
| 7 | stack, | ||
| 8 | reference, | ||
| 9 | onSelect, | ||
| 10 | pending = false, | ||
| 11 | }: { | ||
| 12 | stack: PullRequestStack; | ||
| 13 | reference: PullRequestRef; | ||
| 14 | onSelect?: ((reference: PullRequestRef) => void) | undefined; | ||
| 15 | pending?: boolean; | ||
| 16 | }) { | ||
| 17 | return ( | ||
| 18 | <div className="max-h-80 overflow-y-auto"> | ||
| 19 | {stack.layers.toReversed().map((layer) => { | ||
| 20 | return ( | ||
| 21 | <MenuItem | ||
| 22 | key={layer.number} | ||
| 23 | onClick={() => { | ||
| 24 | onSelect?.({ ...reference, number: layer.number }); | ||
| 25 | }} | ||
| 26 | disabled={!onSelect || pending} | ||
| 27 | aria-current={layer.number === reference.number ? "true" : undefined} | ||
| 28 | > | ||
| 29 | <PullRequestStackLayerContent layer={layer} /> | ||
| 30 | {layer.number === reference.number ? ( | ||
| 31 | <CheckIcon aria-hidden className="size-3.5" /> | ||
| 32 | ) : null} | ||
| 33 | </MenuItem> | ||
| 34 | ); | ||
| 35 | })} | ||
| 36 | <MenuGroupLabel>↳ {stack.base}</MenuGroupLabel> | ||
| 37 | </div> | ||
| 38 | ); | ||
| 39 | } | ||
apps/web/src/components/pullRequest/PullRequestStackMap.tsx deleted-87| ... | @@ -1,87 +0,0 @@ | ||
| 1 | import type { PullRequestStack } from "@t3tools/contracts"; | ||
| 2 | import { GitPullRequestArrowIcon } from "lucide-react"; | ||
| 3 | |||
| 4 | import { InlineButton } from "../ui/button"; | ||
| 5 | import { cn } from "~/lib/utils"; | ||
| 6 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; | ||
| 7 | import { resolvePullRequestState } from "./pullRequestPresentation"; | ||
| 8 | |||
| 9 | /** | ||
| 10 | * The host's stack as one line of layers, bottom to top, with this pull request marked. Mirrors | ||
| 11 | * the map GitHub draws above a stacked pull request so a reader who came from there finds the | ||
| 12 | * same shape here. | ||
| 13 | */ | ||
| 14 | export function PullRequestStackMap({ | ||
| 15 | stack, | ||
| 16 | currentNumber, | ||
| 17 | onSelect, | ||
| 18 | className, | ||
| 19 | }: { | ||
| 20 | stack: PullRequestStack; | ||
| 21 | currentNumber: number; | ||
| 22 | /** Opens another layer in the same panel; absent where the panel cannot swap references. */ | ||
| 23 | onSelect?: ((number: number) => void) | undefined; | ||
| 24 | className?: string; | ||
| 25 | }) { | ||
| 26 | return ( | ||
| 27 | <div | ||
| 28 | className={cn( | ||
| 29 | "flex min-w-0 items-center gap-1 overflow-x-auto text-[11px] text-muted-foreground", | ||
| 30 | className, | ||
| 31 | )} | ||
| 32 | aria-label={`Stack ${stack.number} on ${stack.base}, ${stack.layers.length} layers`} | ||
| 33 | > | ||
| 34 | <Tooltip> | ||
| 35 | <TooltipTrigger | ||
| 36 | render={<span className="inline-flex shrink-0 items-center gap-1 font-mono" />} | ||
| 37 | > | ||
| 38 | <GitPullRequestArrowIcon aria-hidden className="size-3" /> | ||
| 39 | <code className="truncate">{stack.base}</code> | ||
| 40 | </TooltipTrigger> | ||
| 41 | <TooltipPopup side="top"> | ||
| 42 | Stack #{stack.number} on {stack.base}. Merging a layer lands every layer below it. | ||
| 43 | </TooltipPopup> | ||
| 44 | </Tooltip> | ||
| 45 | {stack.layers.map((layer) => { | ||
| 46 | const presentation = resolvePullRequestState({ state: layer.state, isDraft: false }); | ||
| 47 | const isCurrent = layer.number === currentNumber; | ||
| 48 | const chip = ( | ||
| 49 | <span | ||
| 50 | className={cn( | ||
| 51 | "inline-flex shrink-0 items-center gap-1 rounded-full border px-1.5 py-0 font-mono tabular-nums", | ||
| 52 | isCurrent | ||
| 53 | ? "border-foreground/40 bg-accent text-foreground" | ||
| 54 | : "border-border/70 hover:bg-accent/60", | ||
| 55 | )} | ||
| 56 | > | ||
| 57 | <presentation.Icon aria-hidden className={cn("size-3", presentation.toneClassName)} /># | ||
| 58 | {layer.number} | ||
| 59 | </span> | ||
| 60 | ); | ||
| 61 | return ( | ||
| 62 | <span key={layer.number} className="inline-flex shrink-0 items-center gap-1"> | ||
| 63 | <span aria-hidden className="text-muted-foreground/50"> | ||
| 64 | → | ||
| 65 | </span> | ||
| 66 | <Tooltip> | ||
| 67 | <TooltipTrigger | ||
| 68 | render={ | ||
| 69 | onSelect && !isCurrent ? ( | ||
| 70 | <InlineButton onClick={() => onSelect(layer.number)} /> | ||
| 71 | ) : ( | ||
| 72 | <span aria-current={isCurrent ? "true" : undefined} /> | ||
| 73 | ) | ||
| 74 | } | ||
| 75 | > | ||
| 76 | {chip} | ||
| 77 | </TooltipTrigger> | ||
| 78 | <TooltipPopup side="top"> | ||
| 79 | #{layer.number} · {layer.headBranch} · {presentation.label} | ||
| 80 | </TooltipPopup> | ||
| 81 | </Tooltip> | ||
| 82 | </span> | ||
| 83 | ); | ||
| 84 | })} | ||
| 85 | </div> | ||
| 86 | ); | ||
| 87 | } | ||
apps/web/src/components/pullRequest/PullRequestStackMenu.tsx created+257| ... | @@ -0,0 +1,257 @@ | ||
| 1 | import { Tooltip, TooltipTrigger, TooltipPopup } from "../ui/tooltip"; | ||
| 2 | import type { | ||
| 3 | EnvironmentId, | ||
| 4 | PullRequestRef, | ||
| 5 | PullRequestStack, | ||
| 6 | PullRequestMergeMethod, | ||
| 7 | } from "@t3tools/contracts"; | ||
| 8 | import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; | ||
| 9 | import { GitMergeIcon, LayersIcon, RefreshCwIcon, TriangleAlertIcon } from "lucide-react"; | ||
| 10 | import { useState } from "react"; | ||
| 11 | import { useAtomCommand } from "~/state/use-atom-command"; | ||
| 12 | import { pullRequestEnvironment } from "~/state/pullRequests"; | ||
| 13 | import { Button } from "../ui/button"; | ||
| 14 | import { Menu, MenuPopup, MenuTrigger, MenuItem, MenuGroup, MenuSeparator } from "../ui/menu"; | ||
| 15 | import { | ||
| 16 | Dialog, | ||
| 17 | DialogPopup, | ||
| 18 | DialogTitle, | ||
| 19 | DialogDescription, | ||
| 20 | DialogHeader, | ||
| 21 | DialogPanel, | ||
| 22 | DialogFooter, | ||
| 23 | } from "../ui/dialog"; | ||
| 24 | import { toastManager } from "../ui/toast"; | ||
| 25 | import { PullRequestStackLayers } from "./PullRequestStackLayers"; | ||
| 26 | import { PullRequestStackHeader } from "./PullRequestStackHeader"; | ||
| 27 | import { PullRequestStackLayerContent } from "./PullRequestStackLayerContent"; | ||
| 28 | |||
| 29 | export function PullRequestStackMenu({ | ||
| 30 | stack, | ||
| 31 | reference, | ||
| 32 | environmentId, | ||
| 33 | canMerge, | ||
| 34 | canRebase, | ||
| 35 | mergeMethod, | ||
| 36 | onSelect, | ||
| 37 | onActed, | ||
| 38 | notice, | ||
| 39 | onRetry, | ||
| 40 | }: { | ||
| 41 | notice?: string | null; | ||
| 42 | onRetry?: (() => void) | undefined; | ||
| 43 | stack: PullRequestStack; | ||
| 44 | reference: PullRequestRef; | ||
| 45 | environmentId: EnvironmentId; | ||
| 46 | canMerge: boolean; | ||
| 47 | canRebase: boolean; | ||
| 48 | mergeMethod: PullRequestMergeMethod; | ||
| 49 | onSelect?: ((reference: PullRequestRef) => void) | undefined; | ||
| 50 | onActed: () => void; | ||
| 51 | }) { | ||
| 52 | const [open, setOpen] = useState(false); | ||
| 53 | const [confirmation, setConfirmation] = useState<"merge" | "update-branch" | null>(null); | ||
| 54 | const [pending, setPending] = useState(false); | ||
| 55 | const runAction = useAtomCommand(pullRequestEnvironment.runAction, { reportFailure: false }); | ||
| 56 | const top = stack.layers.at(-1); | ||
| 57 | const unmerged = stack.layers.filter((layer) => layer.state !== "merged"); | ||
| 58 | const hasClosed = unmerged.some((layer) => layer.state !== "open"); | ||
| 59 | const position = stack.layers.findIndex((layer) => layer.number === reference.number) + 1; | ||
| 60 | const mergeLayers = stack.layers.slice(0, position).filter((layer) => layer.state !== "merged"); | ||
| 61 | const selectedLayer = stack.layers[position - 1]; | ||
| 62 | const mergeHasClosed = mergeLayers.some((layer) => layer.state !== "open"); | ||
| 63 | const expectedStackHeads = unmerged.flatMap((layer) => | ||
| 64 | layer.headSha ? [{ number: layer.number, headSha: layer.headSha }] : [], | ||
| 65 | ); | ||
| 66 | const hasUnknownHead = expectedStackHeads.length !== unmerged.length; | ||
| 67 | const mergeDisabled = | ||
| 68 | pending || | ||
| 69 | selectedLayer?.state !== "open" || | ||
| 70 | mergeLayers.some((layer) => !layer.headSha) || | ||
| 71 | mergeHasClosed || | ||
| 72 | mergeLayers.length === 0 || | ||
| 73 | mergeLayers.some((layer) => layer.isDraft); | ||
| 74 | const rebaseDisabled = pending || hasUnknownHead || hasClosed || unmerged.length === 0; | ||
| 75 | const run = async () => { | ||
| 76 | if ( | ||
| 77 | pending || | ||
| 78 | !confirmation || | ||
| 79 | (confirmation === "merge" ? !canMerge || mergeDisabled : !canRebase || rebaseDisabled) | ||
| 80 | ) | ||
| 81 | return; | ||
| 82 | const action = confirmation; | ||
| 83 | const target = action === "merge" ? selectedLayer : top; | ||
| 84 | if (!target?.headSha) return; | ||
| 85 | const actionHeads = (action === "merge" ? mergeLayers : unmerged).flatMap((layer) => | ||
| 86 | layer.headSha ? [{ number: layer.number, headSha: layer.headSha }] : [], | ||
| 87 | ); | ||
| 88 | setPending(true); | ||
| 89 | const result = await runAction({ | ||
| 90 | environmentId, | ||
| 91 | input: { | ||
| 92 | ...reference, | ||
| 93 | number: target.number, | ||
| 94 | stackNumber: stack.number, | ||
| 95 | expectedStackHeads: actionHeads, | ||
| 96 | action, | ||
| 97 | ...(action === "merge" ? { mergeMethod } : { updateMethod: "rebase" }), | ||
| 98 | }, | ||
| 99 | }); | ||
| 100 | setPending(false); | ||
| 101 | setConfirmation(null); | ||
| 102 | onActed(); | ||
| 103 | if (result._tag === "Failure") { | ||
| 104 | toastManager.add({ | ||
| 105 | type: "error", | ||
| 106 | title: "Stack operation did not complete", | ||
| 107 | description: String(squashAtomCommandFailure(result)), | ||
| 108 | }); | ||
| 109 | } else { | ||
| 110 | toastManager.add({ | ||
| 111 | type: "success", | ||
| 112 | title: action === "merge" ? "Stack merge request completed" : "Stack rebased", | ||
| 113 | description: | ||
| 114 | action === "merge" | ||
| 115 | ? "GitHub merged the stack or added it to its merge queue." | ||
| 116 | : undefined, | ||
| 117 | }); | ||
| 118 | } | ||
| 119 | }; | ||
| 120 | const confirmationLayers = confirmation === "merge" ? mergeLayers : unmerged; | ||
| 121 | return ( | ||
| 122 | <> | ||
| 123 | <Menu open={open} onOpenChange={setOpen}> | ||
| 124 | <Tooltip> | ||
| 125 | <TooltipTrigger | ||
| 126 | render={ | ||
| 127 | <MenuTrigger | ||
| 128 | render={ | ||
| 129 | <Button | ||
| 130 | variant="ghost" | ||
| 131 | size="xs" | ||
| 132 | aria-label={`Stack ${stack.number}, layer ${position} of ${stack.layers.length}`} | ||
| 133 | /> | ||
| 134 | } | ||
| 135 | > | ||
| 136 | <LayersIcon aria-hidden className="size-3.5" /> {position}/{stack.layers.length} | ||
| 137 | {onRetry ? <TriangleAlertIcon aria-hidden className="size-3 text-warning" /> : null} | ||
| 138 | </MenuTrigger> | ||
| 139 | } | ||
| 140 | /> | ||
| 141 | <TooltipPopup> | ||
| 142 | View stack #{stack.number}, layer {position} of {stack.layers.length} | ||
| 143 | {notice ? ` · ${notice}` : null} | ||
| 144 | </TooltipPopup> | ||
| 145 | </Tooltip> | ||
| 146 | <MenuPopup align="start" className="w-96 max-w-[calc(100vw-2rem)]"> | ||
| 147 | <MenuGroup> | ||
| 148 | <PullRequestStackHeader number={stack.number} notice={notice} stale={!!onRetry} /> | ||
| 149 | {onRetry ? <MenuItem onClick={onRetry}>Retry stack refresh</MenuItem> : null} | ||
| 150 | <PullRequestStackLayers | ||
| 151 | stack={stack} | ||
| 152 | reference={reference} | ||
| 153 | pending={pending} | ||
| 154 | onSelect={ | ||
| 155 | onSelect | ||
| 156 | ? (target) => { | ||
| 157 | setOpen(false); | ||
| 158 | onSelect(target); | ||
| 159 | } | ||
| 160 | : undefined | ||
| 161 | } | ||
| 162 | /> | ||
| 163 | </MenuGroup> | ||
| 164 | {canMerge || canRebase ? ( | ||
| 165 | <> | ||
| 166 | <MenuSeparator /> | ||
| 167 | {canMerge ? ( | ||
| 168 | <MenuItem disabled={mergeDisabled} onClick={() => setConfirmation("merge")}> | ||
| 169 | <GitMergeIcon aria-hidden /> | ||
| 170 | Merge stack ({mergeLayers.length}) | ||
| 171 | </MenuItem> | ||
| 172 | ) : null} | ||
| 173 | {canRebase ? ( | ||
| 174 | <MenuItem | ||
| 175 | disabled={rebaseDisabled} | ||
| 176 | onClick={() => setConfirmation("update-branch")} | ||
| 177 | > | ||
| 178 | <RefreshCwIcon aria-hidden /> | ||
| 179 | Rebase stack | ||
| 180 | </MenuItem> | ||
| 181 | ) : null} | ||
| 182 | {mergeHasClosed || mergeLayers.some((layer) => layer.isDraft) ? ( | ||
| 183 | <p className="px-2 py-1 text-xs text-muted-foreground"> | ||
| 184 | Every layer being merged must be open and ready for review. | ||
| 185 | </p> | ||
| 186 | ) : null} | ||
| 187 | </> | ||
| 188 | ) : null} | ||
| 189 | </MenuPopup> | ||
| 190 | </Menu> | ||
| 191 | {canMerge && selectedLayer?.state === "open" ? ( | ||
| 192 | <Tooltip> | ||
| 193 | <TooltipTrigger | ||
| 194 | render={ | ||
| 195 | <span className="inline-flex"> | ||
| 196 | <Button | ||
| 197 | variant="default" | ||
| 198 | size="xs" | ||
| 199 | disabled={mergeDisabled} | ||
| 200 | onClick={() => setConfirmation("merge")} | ||
| 201 | > | ||
| 202 | <GitMergeIcon aria-hidden className="size-3.5" /> | ||
| 203 | Merge stack | ||
| 204 | </Button> | ||
| 205 | </span> | ||
| 206 | } | ||
| 207 | /> | ||
| 208 | <TooltipPopup> | ||
| 209 | Merge stack through #{reference.number} into {stack.base} ({mergeLayers.length}{" "} | ||
| 210 | {mergeLayers.length === 1 ? "pull request" : "pull requests"}) | ||
| 211 | </TooltipPopup> | ||
| 212 | </Tooltip> | ||
| 213 | ) : null} | ||
| 214 | <Dialog | ||
| 215 | open={confirmation !== null} | ||
| 216 | onOpenChange={(value) => { | ||
| 217 | if (!value && !pending) setConfirmation(null); | ||
| 218 | }} | ||
| 219 | > | ||
| 220 | <DialogPopup className="max-w-md" showCloseButton={!pending}> | ||
| 221 | <DialogHeader> | ||
| 222 | <DialogTitle> | ||
| 223 | {confirmation === "merge" | ||
| 224 | ? `Merge ${mergeLayers.length} pull requests?` | ||
| 225 | : `Rebase ${unmerged.length} pull requests?`} | ||
| 226 | </DialogTitle> | ||
| 227 | <DialogDescription> | ||
| 228 | {confirmation === "merge" | ||
| 229 | ? `Merge #${reference.number} and its unmerged layers below into ${stack.base} using ${mergeMethod}. GitHub checks their rules before merging or queueing them and rebases the remaining stack after merging.` | ||
| 230 | : `Rebase the remote branches from bottom to top onto ${stack.base}. This rewrites branch history and may restart checks. If a layer fails, earlier updates remain.`} | ||
| 231 | </DialogDescription> | ||
| 232 | </DialogHeader> | ||
| 233 | <DialogPanel> | ||
| 234 | <ul className="max-h-48 space-y-1 overflow-y-auto text-sm"> | ||
| 235 | {confirmationLayers.map((layer) => ( | ||
| 236 | <li | ||
| 237 | key={layer.number} | ||
| 238 | className="flex items-center gap-2 rounded-md bg-muted/50 px-3 py-2" | ||
| 239 | > | ||
| 240 | <PullRequestStackLayerContent layer={layer} compact /> | ||
| 241 | </li> | ||
| 242 | ))} | ||
| 243 | </ul> | ||
| 244 | </DialogPanel> | ||
| 245 | <DialogFooter> | ||
| 246 | <Button variant="outline" disabled={pending} onClick={() => setConfirmation(null)}> | ||
| 247 | Cancel | ||
| 248 | </Button> | ||
| 249 | <Button disabled={pending} onClick={() => void run()}> | ||
| 250 | {pending ? "Working…" : confirmation === "merge" ? "Merge stack" : "Rebase stack"} | ||
| 251 | </Button> | ||
| 252 | </DialogFooter> | ||
| 253 | </DialogPopup> | ||
| 254 | </Dialog> | ||
| 255 | </> | ||
| 256 | ); | ||
| 257 | } | ||
apps/web/src/components/pullRequest/PullRequestStackPopover.tsx created+108| ... | @@ -0,0 +1,108 @@ | ||
| 1 | import { Tooltip, TooltipTrigger, TooltipPopup } from "../ui/tooltip"; | ||
| 2 | import type { EnvironmentId, PullRequestRef, PullRequestStackMembership } from "@t3tools/contracts"; | ||
| 3 | import { LayersIcon } from "lucide-react"; | ||
| 4 | import { useState } from "react"; | ||
| 5 | import { usePullRequestStack } from "~/state/usePullRequestStack"; | ||
| 6 | import { Menu, MenuTrigger, MenuPopup, MenuGroup, MenuGroupLabel, MenuItem } from "../ui/menu"; | ||
| 7 | import { PullRequestStackLayers } from "./PullRequestStackLayers"; | ||
| 8 | import { PullRequestStackHeader } from "./PullRequestStackHeader"; | ||
| 9 | |||
| 10 | /** Mounted only while the menu is open, so list rows do not each fetch a stack. */ | ||
| 11 | function StackBody({ | ||
| 12 | environmentId, | ||
| 13 | reference, | ||
| 14 | onSelect, | ||
| 15 | stackNumber, | ||
| 16 | }: { | ||
| 17 | environmentId: EnvironmentId; | ||
| 18 | reference: PullRequestRef; | ||
| 19 | onSelect: (reference: PullRequestRef) => void; | ||
| 20 | stackNumber: number; | ||
| 21 | }) { | ||
| 22 | const query = usePullRequestStack(environmentId, reference); | ||
| 23 | if (query.data !== null) { | ||
| 24 | return ( | ||
| 25 | <> | ||
| 26 | <PullRequestStackHeader | ||
| 27 | number={query.data.number} | ||
| 28 | notice={query.notice} | ||
| 29 | stale={!!query.error} | ||
| 30 | /> | ||
| 31 | {query.error ? <MenuItem onClick={query.refresh}>Retry stack refresh</MenuItem> : null} | ||
| 32 | <PullRequestStackLayers stack={query.data} reference={reference} onSelect={onSelect} /> | ||
| 33 | </> | ||
| 34 | ); | ||
| 35 | } | ||
| 36 | return ( | ||
| 37 | <> | ||
| 38 | <PullRequestStackHeader number={stackNumber} /> | ||
| 39 | <MenuGroupLabel> | ||
| 40 | {query.error ?? | ||
| 41 | (query.isPending ? "Loading stack…" : "This pull request is no longer in a stack.")} | ||
| 42 | </MenuGroupLabel> | ||
| 43 | </> | ||
| 44 | ); | ||
| 45 | } | ||
| 46 | |||
| 47 | export function PullRequestStackPopover({ | ||
| 48 | environmentId, | ||
| 49 | reference, | ||
| 50 | membership, | ||
| 51 | onSelect, | ||
| 52 | }: { | ||
| 53 | environmentId: EnvironmentId; | ||
| 54 | reference: PullRequestRef; | ||
| 55 | membership: PullRequestStackMembership; | ||
| 56 | onSelect: (reference: PullRequestRef) => void; | ||
| 57 | }) { | ||
| 58 | const [open, setOpen] = useState(false); | ||
| 59 | return ( | ||
| 60 | <Menu open={open} onOpenChange={setOpen}> | ||
| 61 | <Tooltip> | ||
| 62 | <TooltipTrigger | ||
| 63 | render={ | ||
| 64 | <MenuTrigger | ||
| 65 | nativeButton={false} | ||
| 66 | render={ | ||
| 67 | <span | ||
| 68 | role="button" | ||
| 69 | tabIndex={0} | ||
| 70 | className="inline-flex shrink-0 cursor-pointer items-center gap-1 text-xs font-normal text-muted-foreground" | ||
| 71 | /> | ||
| 72 | } | ||
| 73 | aria-label={`Stack ${membership.number}, layer ${membership.position} of ${membership.size}`} | ||
| 74 | onClick={(event) => event.stopPropagation()} | ||
| 75 | onKeyDown={(event) => event.stopPropagation()} | ||
| 76 | > | ||
| 77 | <LayersIcon aria-hidden className="size-3" /> | ||
| 78 | {membership.position}/{membership.size} | ||
| 79 | </MenuTrigger> | ||
| 80 | } | ||
| 81 | /> | ||
| 82 | <TooltipPopup> | ||
| 83 | View stack #{membership.number}, layer {membership.position} of {membership.size} | ||
| 84 | </TooltipPopup> | ||
| 85 | </Tooltip> | ||
| 86 | <MenuPopup | ||
| 87 | align="start" | ||
| 88 | className="w-96 max-w-[calc(100vw-2rem)]" | ||
| 89 | onClick={(event) => event.stopPropagation()} | ||
| 90 | onKeyDown={(event) => event.stopPropagation()} | ||
| 91 | > | ||
| 92 | <MenuGroup> | ||
| 93 | {open ? ( | ||
| 94 | <StackBody | ||
| 95 | environmentId={environmentId} | ||
| 96 | reference={reference} | ||
| 97 | stackNumber={membership.number} | ||
| 98 | onSelect={(target) => { | ||
| 99 | setOpen(false); | ||
| 100 | onSelect(target); | ||
| 101 | }} | ||
| 102 | /> | ||
| 103 | ) : null} | ||
| 104 | </MenuGroup> | ||
| 105 | </MenuPopup> | ||
| 106 | </Menu> | ||
| 107 | ); | ||
| 108 | } | ||
apps/web/src/components/pullRequest/PullRequestThreadLinks.tsx+20-38| ... | @@ -1,5 +1,5 @@ | ... | @@ -1,5 +1,5 @@ |
| 1 | import { Tooltip, TooltipTrigger, TooltipPopup } from "../ui/tooltip"; | ||
| 1 | import { scopeThreadRef } from "@t3tools/client-runtime/environment"; | 2 | import { scopeThreadRef } from "@t3tools/client-runtime/environment"; |
| 2 | import { Link } from "@tanstack/react-router"; | ||
| 3 | import type { EnvironmentId, PullRequestRef, ScopedThreadRef, ThreadId } from "@t3tools/contracts"; | 3 | import type { EnvironmentId, PullRequestRef, ScopedThreadRef, ThreadId } from "@t3tools/contracts"; |
| 4 | import { CheckIcon, LinkIcon, MessageSquareIcon, UnlinkIcon } from "lucide-react"; | 4 | import { CheckIcon, LinkIcon, MessageSquareIcon, UnlinkIcon } from "lucide-react"; |
| 5 | import { useState } from "react"; | 5 | import { useState } from "react"; |
| ... | @@ -11,11 +11,11 @@ import { useProjects, useServerConfigs, useThreadShell, useThreadShells } from " | ... | @@ -11,11 +11,11 @@ import { useProjects, useServerConfigs, useThreadShell, useThreadShells } from " |
| 11 | import { pullRequestEnvironment } from "~/state/pullRequests"; | 11 | import { pullRequestEnvironment } from "~/state/pullRequests"; |
| 12 | import { useEnvironmentQuery } from "~/state/query"; | 12 | import { useEnvironmentQuery } from "~/state/query"; |
| 13 | import { appAtomRegistry } from "~/rpc/atomRegistry"; | 13 | import { appAtomRegistry } from "~/rpc/atomRegistry"; |
| 14 | import { buildThreadRouteParams } from "~/threadRoutes"; | 14 | import { openCommandPalette } from "~/commandPaletteBus"; |
| 15 | import { Button } from "../ui/button"; | 15 | import { Button } from "../ui/button"; |
| 16 | import { Command, CommandInput, CommandItem, CommandList } from "../ui/command"; | 16 | import { Command, CommandInput, CommandItem, CommandList } from "../ui/command"; |
| 17 | import { Dialog, DialogPopup, DialogTitle } from "../ui/dialog"; | 17 | import { Dialog, DialogPopup, DialogTitle } from "../ui/dialog"; |
| 18 | import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu"; | 18 | import { MenuItem } from "../ui/menu"; |
| 19 | import { toastManager } from "../ui/toast"; | 19 | import { toastManager } from "../ui/toast"; |
| 20 | 20 | ||
| 21 | interface PullRequestThreadLinksProps { | 21 | interface PullRequestThreadLinksProps { |
| ... | @@ -61,7 +61,7 @@ function EnabledPullRequestThreadLinks({ | ... | @@ -61,7 +61,7 @@ function EnabledPullRequestThreadLinks({ |
| 61 | : null, | 61 | : null, |
| 62 | ); | 62 | ); |
| 63 | // Refreshes can briefly clear the query value. Keep the last response so polling | 63 | // Refreshes can briefly clear the query value. Keep the last response so polling |
| 64 | // does not unmount an open menu or move its highlighted thread. | 64 | // does not hide the linked-thread count between responses. |
| 65 | const [lastRelations, setLastRelations] = useState(relations.data); | 65 | const [lastRelations, setLastRelations] = useState(relations.data); |
| 66 | if (relations.data !== null && relations.data !== lastRelations) { | 66 | if (relations.data !== null && relations.data !== lastRelations) { |
| 67 | setLastRelations(relations.data); | 67 | setLastRelations(relations.data); |
| ... | @@ -101,47 +101,29 @@ function EnabledPullRequestThreadLinks({ | ... | @@ -101,47 +101,29 @@ function EnabledPullRequestThreadLinks({ |
| 101 | return ( | 101 | return ( |
| 102 | <> | 102 | <> |
| 103 | {display === "count" && (linkedThreads.length > 0 || relations.error !== null) ? ( | 103 | {display === "count" && (linkedThreads.length > 0 || relations.error !== null) ? ( |
| 104 | <Menu> | 104 | <Tooltip> |
| 105 | <MenuTrigger | 105 | <TooltipTrigger |
| 106 | render={ | 106 | render={ |
| 107 | <Button | 107 | <Button |
| 108 | size="xs" | 108 | size="xs" |
| 109 | variant="ghost" | 109 | variant="ghost" |
| 110 | aria-label={linkedThreadsLabel} | 110 | aria-label={linkedThreadsLabel} |
| 111 | title={linkedThreadsLabel} | 111 | onClick={() => |
| 112 | /> | 112 | openCommandPalette({ |
| 113 | } | 113 | query: url, |
| 114 | > | 114 | ...((relations.data ?? lastRelations) === null |
| 115 | <MessageSquareIcon aria-hidden className="size-3.5" /> | 115 | ? {} |
| 116 | <span className="@max-[40rem]/pr-header:hidden">{linkedThreadsLabel}</span> | 116 | : { linkedThreads: { environmentId, threads: linkedThreads } }), |
| 117 | <span aria-hidden className="hidden @max-[40rem]/pr-header:inline"> | 117 | }) |
| 118 | {linkedThreads.length || "?"} | ||
| 119 | </span> | ||
| 120 | </MenuTrigger> | ||
| 121 | <MenuPopup align="end" className="max-h-72 max-w-80 overflow-y-auto"> | ||
| 122 | {relations.error !== null ? ( | ||
| 123 | <MenuItem onClick={relations.refresh}>Could not load linked threads. Retry</MenuItem> | ||
| 124 | ) : null} | ||
| 125 | {linkedThreads.map((linkedThread) => ( | ||
| 126 | <MenuItem | ||
| 127 | key={linkedThread.id} | ||
| 128 | render={ | ||
| 129 | <Link | ||
| 130 | to="/$environmentId/$threadId" | ||
| 131 | params={buildThreadRouteParams(scopeThreadRef(environmentId, linkedThread.id))} | ||
| 132 | /> | ||
| 133 | } | 118 | } |
| 134 | > | 119 | > |
| 135 | <span className="min-w-0 flex-1 truncate"> | 120 | <MessageSquareIcon aria-hidden className="size-3.5" /> |
| 136 | {linkedThread.title || "Untitled thread"} | 121 | <span aria-hidden>{linkedThreads.length || "?"}</span> |
| 137 | </span> | 122 | </Button> |
| 138 | {linkedThread.archivedAt !== null ? ( | 123 | } |
| 139 | <span className="text-xs text-muted-foreground">Archived</span> | 124 | /> |
| 140 | ) : null} | 125 | <TooltipPopup>{linkedThreadsLabel}. Search in the command palette.</TooltipPopup> |
| 141 | </MenuItem> | 126 | </Tooltip> |
| 142 | ))} | ||
| 143 | </MenuPopup> | ||
| 144 | </Menu> | ||
| 145 | ) : null} | 127 | ) : null} |
| 146 | {display === "menu-item" ? ( | 128 | {display === "menu-item" ? ( |
| 147 | <MenuItem | 129 | <MenuItem |
apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts+19| ... | @@ -35,6 +35,7 @@ import { | ... | @@ -35,6 +35,7 @@ import { |
| 35 | readPullRequestDetailSnapshot, | 35 | readPullRequestDetailSnapshot, |
| 36 | resolveDisplayedPullRequestDetail, | 36 | resolveDisplayedPullRequestDetail, |
| 37 | resolvePullRequestPrimaryControl, | 37 | resolvePullRequestPrimaryControl, |
| 38 | allowsSinglePullRequestMerge, | ||
| 38 | shouldRefreshPullRequestActivity, | 39 | shouldRefreshPullRequestActivity, |
| 39 | resolveBaseFreshness, | 40 | resolveBaseFreshness, |
| 40 | resolvePullRequestMergeMethod, | 41 | resolvePullRequestMergeMethod, |
| ... | @@ -1463,3 +1464,21 @@ describe("cached pull request detail", () => { | ... | @@ -1463,3 +1464,21 @@ describe("cached pull request detail", () => { |
| 1463 | expect(readPullRequestDetailSnapshot(undefined, "env-1", reference)).toBeNull(); | 1464 | expect(readPullRequestDetailSnapshot(undefined, "env-1", reference)).toBeNull(); |
| 1464 | }); | 1465 | }); |
| 1465 | }); | 1466 | }); |
| 1467 | |||
| 1468 | describe("single-PR merge compatibility during stack discovery", () => { | ||
| 1469 | it.each([ | ||
| 1470 | [false, true, false, null, true], | ||
| 1471 | [false, false, true, null, true], | ||
| 1472 | [true, false, true, null, false], | ||
| 1473 | [true, false, false, "Lookup failed", false], | ||
| 1474 | [true, true, false, null, false], | ||
| 1475 | [true, false, false, null, true], | ||
| 1476 | ] as const)( | ||
| 1477 | "capability=%s stack=%s pending=%s error=%s permits=%s", | ||
| 1478 | (supportsStackActions, hasStack, stackPending, stackError, allowed) => { | ||
| 1479 | expect( | ||
| 1480 | allowsSinglePullRequestMerge({ supportsStackActions, hasStack, stackPending, stackError }), | ||
| 1481 | ).toBe(allowed); | ||
| 1482 | }, | ||
| 1483 | ); | ||
| 1484 | }); |
apps/web/src/components/pullRequest/pullRequestDetail.logic.ts+13| ... | @@ -29,6 +29,19 @@ export const PULL_REQUEST_MERGE_METHOD_LABELS: Record<PullRequestMergeMethod, st | ... | @@ -29,6 +29,19 @@ export const PULL_REQUEST_MERGE_METHOD_LABELS: Record<PullRequestMergeMethod, st |
| 29 | rebase: "Rebase and merge", | 29 | rebase: "Rebase and merge", |
| 30 | }; | 30 | }; |
| 31 | 31 | ||
| 32 | /** Old environments keep their existing actions; new ones must finish stack discovery first. */ | ||
| 33 | export function allowsSinglePullRequestMerge(input: { | ||
| 34 | supportsStackActions: boolean; | ||
| 35 | hasStack: boolean; | ||
| 36 | stackPending: boolean; | ||
| 37 | stackError: string | null; | ||
| 38 | }): boolean { | ||
| 39 | return ( | ||
| 40 | !input.supportsStackActions || | ||
| 41 | (!input.hasStack && !input.stackPending && input.stackError === null) | ||
| 42 | ); | ||
| 43 | } | ||
| 44 | |||
| 32 | export function resolvePullRequestMergeMethod( | 45 | export function resolvePullRequestMergeMethod( |
| 33 | allowed: ReadonlyArray<PullRequestMergeMethod>, | 46 | allowed: ReadonlyArray<PullRequestMergeMethod>, |
| 34 | current: PullRequestMergeMethod | null, | 47 | current: PullRequestMergeMethod | null, |
apps/web/src/components/pullRequest/pullRequestStackSnapshot.test.ts created+93| ... | @@ -0,0 +1,93 @@ | ||
| 1 | import type { ThreadPullRequestLink } from "@t3tools/contracts"; | ||
| 2 | import { ProjectId } from "@t3tools/contracts"; | ||
| 3 | import { describe, expect, it } from "vite-plus/test"; | ||
| 4 | import { savedPullRequestStack, pullRequestStackView } from "./pullRequestStackSnapshot"; | ||
| 5 | |||
| 6 | const reference = { | ||
| 7 | projectId: ProjectId.make("project"), | ||
| 8 | host: "github.com", | ||
| 9 | repository: "acme/web", | ||
| 10 | number: 2, | ||
| 11 | }; | ||
| 12 | const link: ThreadPullRequestLink = { | ||
| 13 | host: "github.com", | ||
| 14 | repository: "acme/web", | ||
| 15 | number: 2, | ||
| 16 | url: "https://github.com/acme/web/pull/2", | ||
| 17 | source: "manual", | ||
| 18 | linkedAt: "2026-09-09T10:00:00Z", | ||
| 19 | snapshot: { | ||
| 20 | title: "Top layer", | ||
| 21 | headBranch: "top", | ||
| 22 | baseBranch: "bottom", | ||
| 23 | state: "open", | ||
| 24 | isDraft: false, | ||
| 25 | updatedAt: null, | ||
| 26 | syncedAt: "2026-09-09T10:00:00Z", | ||
| 27 | }, | ||
| 28 | stack: { | ||
| 29 | kind: "native", | ||
| 30 | id: "stack", | ||
| 31 | number: 3, | ||
| 32 | url: "https://github.com/acme/web/pull/3", | ||
| 33 | base: "main", | ||
| 34 | layers: [ | ||
| 35 | { number: 1, headBranch: "bottom", state: "open" }, | ||
| 36 | { number: 2, headBranch: "top", state: "open" }, | ||
| 37 | ], | ||
| 38 | }, | ||
| 39 | }; | ||
| 40 | |||
| 41 | describe("saved stack navigation", () => { | ||
| 42 | it("preserves all native layers even when only one has a linked snapshot, without action SHAs", () => { | ||
| 43 | const saved = savedPullRequestStack([link], reference); | ||
| 44 | expect(saved?.layers).toEqual([ | ||
| 45 | { number: 1, headBranch: "bottom", state: "open" }, | ||
| 46 | { number: 2, headBranch: "top", state: "open", title: "Top layer", isDraft: false }, | ||
| 47 | ]); | ||
| 48 | expect(savedPullRequestStack([link], { ...reference, number: 1 })?.number).toBe(3); | ||
| 49 | }); | ||
| 50 | it("does not borrow stacks across hosts or repositories", () => { | ||
| 51 | expect(savedPullRequestStack([link], { ...reference, host: "enterprise.example" })).toBeNull(); | ||
| 52 | expect(savedPullRequestStack([link], { ...reference, repository: "other/web" })).toBeNull(); | ||
| 53 | expect(savedPullRequestStack([link], { ...reference, host: undefined })).toBeNull(); | ||
| 54 | }); | ||
| 55 | it("honors a newer saved removal across linked threads", () => { | ||
| 56 | expect( | ||
| 57 | savedPullRequestStack( | ||
| 58 | [ | ||
| 59 | link, | ||
| 60 | { | ||
| 61 | ...link, | ||
| 62 | stack: null, | ||
| 63 | snapshot: { ...link.snapshot!, syncedAt: "2026-09-09T11:00:00Z" }, | ||
| 64 | }, | ||
| 65 | ], | ||
| 66 | reference, | ||
| 67 | ), | ||
| 68 | ).toBeNull(); | ||
| 69 | }); | ||
| 70 | it("shows saved data during loading and marks a failed refresh stale", () => { | ||
| 71 | const saved = savedPullRequestStack([link], reference); | ||
| 72 | const query = { data: null, isSuccess: false, isPending: true, error: null }; | ||
| 73 | expect(pullRequestStackView(query, saved)).toMatchObject({ | ||
| 74 | data: saved, | ||
| 75 | isFresh: false, | ||
| 76 | notice: expect.stringContaining("Refreshing"), | ||
| 77 | }); | ||
| 78 | expect( | ||
| 79 | pullRequestStackView({ ...query, isPending: false, error: "Rate limited" }, saved), | ||
| 80 | ).toMatchObject({ data: saved, isFresh: false, notice: expect.stringContaining("stale") }); | ||
| 81 | }); | ||
| 82 | it("prefers refreshed data and honors a successful absence", () => { | ||
| 83 | const saved = savedPullRequestStack([link], reference); | ||
| 84 | const query = { data: saved, isSuccess: true, isPending: false, error: null }; | ||
| 85 | expect(pullRequestStackView(query, null)).toEqual({ data: saved, isFresh: true, notice: null }); | ||
| 86 | expect(pullRequestStackView({ ...query, data: null }, saved)).toEqual({ | ||
| 87 | data: null, | ||
| 88 | isFresh: true, | ||
| 89 | notice: null, | ||
| 90 | }); | ||
| 91 | expect(pullRequestStackView({ ...query, isPending: true }, saved).isFresh).toBe(false); | ||
| 92 | }); | ||
| 93 | }); | ||
apps/web/src/components/pullRequest/pullRequestStackSnapshot.ts created+73| ... | @@ -0,0 +1,73 @@ | ||
| 1 | import type { PullRequestRef, PullRequestStack, ThreadPullRequestLink } from "@t3tools/contracts"; | ||
| 2 | |||
| 3 | /** Saved native membership is enough for navigation, but never supplies action head SHAs. */ | ||
| 4 | export function savedPullRequestStack( | ||
| 5 | links: ReadonlyArray<ThreadPullRequestLink>, | ||
| 6 | reference: PullRequestRef, | ||
| 7 | ): PullRequestStack | null { | ||
| 8 | const host = reference.host?.toLowerCase(); | ||
| 9 | if (!host) return null; | ||
| 10 | const matching = links.filter( | ||
| 11 | (link) => | ||
| 12 | link.host.toLowerCase() === host && | ||
| 13 | link.repository.toLowerCase() === reference.repository.toLowerCase(), | ||
| 14 | ); | ||
| 15 | const exact = matching.filter((link) => link.number === reference.number); | ||
| 16 | const candidates = | ||
| 17 | exact.length > 0 | ||
| 18 | ? exact | ||
| 19 | : matching.filter((link) => | ||
| 20 | link.stack?.layers.some((layer) => layer.number === reference.number), | ||
| 21 | ); | ||
| 22 | const newest = candidates.toSorted( | ||
| 23 | (a, b) => | ||
| 24 | Date.parse(b.snapshot?.syncedAt ?? b.linkedAt) - | ||
| 25 | Date.parse(a.snapshot?.syncedAt ?? a.linkedAt), | ||
| 26 | )[0]; | ||
| 27 | const stack = newest?.stack; | ||
| 28 | if (!stack || !stack.layers.some((layer) => layer.number === reference.number)) return null; | ||
| 29 | return { | ||
| 30 | id: stack.id, | ||
| 31 | number: stack.number, | ||
| 32 | url: stack.url, | ||
| 33 | base: stack.base, | ||
| 34 | layers: stack.layers.map((layer) => { | ||
| 35 | const snapshot = matching | ||
| 36 | .filter((link) => link.number === layer.number) | ||
| 37 | .toSorted( | ||
| 38 | (a, b) => | ||
| 39 | Date.parse(b.snapshot?.syncedAt ?? b.linkedAt) - | ||
| 40 | Date.parse(a.snapshot?.syncedAt ?? a.linkedAt), | ||
| 41 | )[0]?.snapshot; | ||
| 42 | return { | ||
| 43 | ...layer, | ||
| 44 | ...(snapshot ? { title: snapshot.title, isDraft: snapshot.isDraft } : {}), | ||
| 45 | }; | ||
| 46 | }), | ||
| 47 | }; | ||
| 48 | } | ||
| 49 | |||
| 50 | /** A fresh absence overrides saved membership; failed refreshes preserve available navigation. */ | ||
| 51 | export function pullRequestStackView( | ||
| 52 | query: { | ||
| 53 | data: PullRequestStack | null; | ||
| 54 | isSuccess: boolean; | ||
| 55 | isPending: boolean; | ||
| 56 | error: string | null; | ||
| 57 | }, | ||
| 58 | saved: PullRequestStack | null, | ||
| 59 | ) { | ||
| 60 | const data = query.isSuccess ? query.data : (query.data ?? saved); | ||
| 61 | return { | ||
| 62 | data, | ||
| 63 | isFresh: query.isSuccess && !query.isPending, | ||
| 64 | notice: | ||
| 65 | data === null | ||
| 66 | ? null | ||
| 67 | : query.error | ||
| 68 | ? "Stack data may be stale. We couldn’t refresh it." | ||
| 69 | : !query.isSuccess || query.isPending | ||
| 70 | ? "Refreshing stack… Showing saved data." | ||
| 71 | : null, | ||
| 72 | }; | ||
| 73 | } | ||
apps/web/src/lib/openPullRequestLink.ts+2| ... | @@ -203,6 +203,7 @@ export function useOpenChangeRequestLink( | ... | @@ -203,6 +203,7 @@ export function useOpenChangeRequestLink( |
| 203 | state: previous.state ?? "all", | 203 | state: previous.state ?? "all", |
| 204 | repository, | 204 | repository, |
| 205 | number: parsed.number, | 205 | number: parsed.number, |
| 206 | selectedHost: parsed.host, | ||
| 206 | selectedProjectId: project.id, | 207 | selectedProjectId: project.id, |
| 207 | selectedEnvironmentId: project.environmentId, | 208 | selectedEnvironmentId: project.environmentId, |
| 208 | }), | 209 | }), |
| ... | @@ -220,6 +221,7 @@ export function useOpenChangeRequestLink( | ... | @@ -220,6 +221,7 @@ export function useOpenChangeRequestLink( |
| 220 | state: "all", | 221 | state: "all", |
| 221 | repository, | 222 | repository, |
| 222 | number: parsed.number, | 223 | number: parsed.number, |
| 224 | selectedHost: parsed.host, | ||
| 223 | selectedProjectId: project.id, | 225 | selectedProjectId: project.id, |
| 224 | // Named so the page opens the right one of two servers holding this project. | 226 | // Named so the page opens the right one of two servers holding this project. |
| 225 | selectedEnvironmentId: project.environmentId, | 227 | selectedEnvironmentId: project.environmentId, |
apps/web/src/routes/_chat.pull-requests.tsx+99-32| ... | @@ -99,7 +99,10 @@ import { | ... | @@ -99,7 +99,10 @@ import { |
| 99 | } from "../components/pullRequest/PullRequestListFilters"; | 99 | } from "../components/pullRequest/PullRequestListFilters"; |
| 100 | import { PullRequestListEmptyState } from "../components/pullRequest/PullRequestListEmptyState"; | 100 | import { PullRequestListEmptyState } from "../components/pullRequest/PullRequestListEmptyState"; |
| 101 | import { PullRequestListGhost } from "../components/pullRequest/PullRequestGhosts"; | 101 | import { PullRequestListGhost } from "../components/pullRequest/PullRequestGhosts"; |
| 102 | import { PullRequestRow } from "../components/pullRequest/PullRequestRow"; | 102 | import { |
| 103 | PullRequestRow, | ||
| 104 | type PullRequestRowTarget, | ||
| 105 | } from "../components/pullRequest/PullRequestRow"; | ||
| 103 | import { PullRequestsUnavailableState } from "../components/pullRequest/PullRequestsUnavailableState"; | 106 | import { PullRequestsUnavailableState } from "../components/pullRequest/PullRequestsUnavailableState"; |
| 104 | import { RightPanelTabs, type PullRequestTabStatusSeed } from "../components/RightPanelTabs"; | 107 | import { RightPanelTabs, type PullRequestTabStatusSeed } from "../components/RightPanelTabs"; |
| 105 | import { | 108 | import { |
| ... | @@ -163,6 +166,8 @@ export interface PullRequestsSearch extends PullRequestListPreferences { | ... | @@ -163,6 +166,8 @@ export interface PullRequestsSearch extends PullRequestListPreferences { |
| 163 | readonly repository?: string; | 166 | readonly repository?: string; |
| 164 | readonly number?: number; | 167 | readonly number?: number; |
| 165 | readonly selectedProjectId?: ProjectId; | 168 | readonly selectedProjectId?: ProjectId; |
| 169 | /** Host of the open review; changing tabs must not narrow the list host filter. */ | ||
| 170 | readonly selectedHost?: string; | ||
| 166 | /** | 171 | /** |
| 167 | * Which server the selected pull request was read from. A project id only names a project on | 172 | * Which server the selected pull request was read from. A project id only names a project on |
| 168 | * its own server, so this is what tells two servers holding one project apart. Optional: a | 173 | * its own server, so this is what tells two servers holding one project apart. Optional: a |
| ... | @@ -263,6 +268,9 @@ export const Route = createFileRoute("/_chat/pull-requests")({ | ... | @@ -263,6 +268,9 @@ export const Route = createFileRoute("/_chat/pull-requests")({ |
| 263 | ...(typeof raw.selectedProjectId === "string" && raw.selectedProjectId | 268 | ...(typeof raw.selectedProjectId === "string" && raw.selectedProjectId |
| 264 | ? { selectedProjectId: raw.selectedProjectId as ProjectId } | 269 | ? { selectedProjectId: raw.selectedProjectId as ProjectId } |
| 265 | : {}), | 270 | : {}), |
| 271 | ...(typeof raw.selectedHost === "string" && raw.selectedHost | ||
| 272 | ? { selectedHost: raw.selectedHost.slice(0, 200) } | ||
| 273 | : {}), | ||
| 266 | ...(typeof raw.selectedEnvironmentId === "string" && raw.selectedEnvironmentId | 274 | ...(typeof raw.selectedEnvironmentId === "string" && raw.selectedEnvironmentId |
| 267 | ? { selectedEnvironmentId: raw.selectedEnvironmentId as EnvironmentId } | 275 | ? { selectedEnvironmentId: raw.selectedEnvironmentId as EnvironmentId } |
| 268 | : {}), | 276 | : {}), |
| ... | @@ -367,6 +375,7 @@ function PullRequestsRouteView() { | ... | @@ -367,6 +375,7 @@ function PullRequestsRouteView() { |
| 367 | 375 | ||
| 368 | // A link from a thread or the sidebar only knows the repository, so the owning project is | 376 | // A link from a thread or the sidebar only knows the repository, so the owning project is |
| 369 | // resolved here; an explicit `projectId` in the URL still wins. | 377 | // resolved here; an explicit `projectId` in the URL still wins. |
| 378 | const selectedHost = search.selectedHost ?? search.host; | ||
| 370 | const projectIdForRepository = useMemo(() => { | 379 | const projectIdForRepository = useMemo(() => { |
| 371 | const repository = search.repository?.toLowerCase(); | 380 | const repository = search.repository?.toLowerCase(); |
| 372 | if (repository === undefined) return undefined; | 381 | if (repository === undefined) return undefined; |
| ... | @@ -378,14 +387,14 @@ function PullRequestsRouteView() { | ... | @@ -378,14 +387,14 @@ function PullRequestsRouteView() { |
| 378 | repository && | 387 | repository && |
| 379 | // The same `owner/name` can exist on two hosts. Without this the first match wins, and | 388 | // The same `owner/name` can exist on two hosts. Without this the first match wins, and |
| 380 | // a link that named its host opens the pull request from the other one. | 389 | // a link that named its host opens the pull request from the other one. |
| 381 | (search.host === undefined || | 390 | (selectedHost === undefined || |
| 382 | pullRequestHostOf( | 391 | pullRequestHostOf( |
| 383 | project.repositoryIdentity, | 392 | project.repositoryIdentity, |
| 384 | project.repositoryIdentity.provider as SourceControlProviderKind, | 393 | project.repositoryIdentity.provider as SourceControlProviderKind, |
| 385 | ) === search.host.toLowerCase()), | 394 | ) === selectedHost.toLowerCase()), |
| 386 | ); | 395 | ); |
| 387 | return identity?.id; | 396 | return identity?.id; |
| 388 | }, [projects, search.host, search.repository]); | 397 | }, [projects, selectedHost, search.repository]); |
| 389 | 398 | ||
| 390 | // The selection is resolved the same way the scope is: an id no connected environment has can | 399 | // The selection is resolved the same way the scope is: an id no connected environment has can |
| 391 | // never be read here, and one that arrived before the projects did is not yet wrong. | 400 | // never be read here, and one that arrived before the projects did is not yet wrong. |
| ... | @@ -472,6 +481,7 @@ function PullRequestsRouteView() { | ... | @@ -472,6 +481,7 @@ function PullRequestsRouteView() { |
| 472 | ...(next.projectId ? { projectId: next.projectId } : {}), | 481 | ...(next.projectId ? { projectId: next.projectId } : {}), |
| 473 | ...(next.environmentId ? { environmentId: next.environmentId } : {}), | 482 | ...(next.environmentId ? { environmentId: next.environmentId } : {}), |
| 474 | ...(next.host ? { host: next.host } : {}), | 483 | ...(next.host ? { host: next.host } : {}), |
| 484 | ...(next.selectedHost ? { selectedHost: next.selectedHost } : {}), | ||
| 475 | ...(next.selectedProjectId ? { selectedProjectId: next.selectedProjectId } : {}), | 485 | ...(next.selectedProjectId ? { selectedProjectId: next.selectedProjectId } : {}), |
| 476 | ...(next.selectedEnvironmentId | 486 | ...(next.selectedEnvironmentId |
| 477 | ? { selectedEnvironmentId: next.selectedEnvironmentId } | 487 | ? { selectedEnvironmentId: next.selectedEnvironmentId } |
| ... | @@ -494,6 +504,7 @@ function PullRequestsRouteView() { | ... | @@ -494,6 +504,7 @@ function PullRequestsRouteView() { |
| 494 | number: undefined, | 504 | number: undefined, |
| 495 | selectedProjectId: undefined, | 505 | selectedProjectId: undefined, |
| 496 | selectedEnvironmentId: undefined, | 506 | selectedEnvironmentId: undefined, |
| 507 | selectedHost: undefined, | ||
| 497 | }; | 508 | }; |
| 498 | // List controls change the rows behind the detail, not the independent selected surface. The | 509 | // List controls change the rows behind the detail, not the independent selected surface. The |
| 499 | // reader can keep working in that panel while narrowing, sorting, or switching projects. | 510 | // reader can keep working in that panel while narrowing, sorting, or switching projects. |
| ... | @@ -799,19 +810,11 @@ function PullRequestsRouteView() { | ... | @@ -799,19 +810,11 @@ function PullRequestsRouteView() { |
| 799 | // from the first moment rather than the second: a button that stays live through the slow half | 810 | // from the first moment rather than the second: a button that stays live through the slow half |
| 800 | // of its own work is a button that gets pressed again, and buys the whole cascade twice. | 811 | // of its own work is a button that gets pressed again, and buys the whole cascade twice. |
| 801 | const [invalidating, setInvalidating] = useState(false); | 812 | const [invalidating, setInvalidating] = useState(false); |
| 802 | const refreshFromHost = async (includeDetail = true) => { | 813 | const refreshListAndStats = ( |
| 803 | const requestedStatsScope = statsScopeRef.current; | 814 | requestedStatsScope = statsScopeRef.current, |
| 804 | setInvalidating(true); | 815 | actedEnvironmentId?: EnvironmentId, |
| 805 | try { | 816 | ) => { |
| 806 | // Every environment the page is reading, since what the reader pressed refresh for is the | 817 | refreshList(true, actedEnvironmentId); |
| 807 | // list in front of them rather than whichever machine happens to be first. | ||
| 808 | await Promise.all( | ||
| 809 | queryEnvironmentIds.map((environmentId) => invalidate({ environmentId, input: {} })), | ||
| 810 | ); | ||
| 811 | } finally { | ||
| 812 | setInvalidating(false); | ||
| 813 | } | ||
| 814 | refreshList(true); | ||
| 815 | const visible = visibleStatsKeys.current; | 818 | const visible = visibleStatsKeys.current; |
| 816 | const batches = pullRequestStatsRefreshBatches({ | 819 | const batches = pullRequestStatsRefreshBatches({ |
| 817 | requestedScope: requestedStatsScope, | 820 | requestedScope: requestedStatsScope, |
| ... | @@ -822,9 +825,29 @@ function PullRequestsRouteView() { | ... | @@ -822,9 +825,29 @@ function PullRequestsRouteView() { |
| 822 | }); | 825 | }); |
| 823 | if (batches !== null) { | 826 | if (batches !== null) { |
| 824 | setStatsTargetState({ key: requestedStatsScope.key, batches }); | 827 | setStatsTargetState({ key: requestedStatsScope.key, batches }); |
| 825 | statsQuery.refresh(batches.map(({ environmentId, input }) => ({ environmentId, input }))); | 828 | statsQuery.refresh( |
| 829 | batches | ||
| 830 | .filter(({ environmentId }) => | ||
| 831 | actedEnvironmentId === undefined ? true : environmentId === actedEnvironmentId, | ||
| 832 | ) | ||
| 833 | .map(({ environmentId, input }) => ({ environmentId, input })), | ||
| 834 | ); | ||
| 826 | } | 835 | } |
| 827 | if (includeDetail) setDetailRefreshToken((token) => token + 1); | 836 | }; |
| 837 | const refreshFromHost = async () => { | ||
| 838 | const requestedStatsScope = statsScopeRef.current; | ||
| 839 | setInvalidating(true); | ||
| 840 | try { | ||
| 841 | // Every environment the page is reading, since what the reader pressed refresh for is the | ||
| 842 | // list in front of them rather than whichever machine happens to be first. | ||
| 843 | await Promise.all( | ||
| 844 | queryEnvironmentIds.map((environmentId) => invalidate({ environmentId, input: {} })), | ||
| 845 | ); | ||
| 846 | } finally { | ||
| 847 | setInvalidating(false); | ||
| 848 | } | ||
| 849 | refreshListAndStats(requestedStatsScope); | ||
| 850 | setDetailRefreshToken((token) => token + 1); | ||
| 828 | }; | 851 | }; |
| 829 | const refreshing = invalidating || listQuery.isPending; | 852 | const refreshing = invalidating || listQuery.isPending; |
| 830 | 853 | ||
| ... | @@ -1058,17 +1081,28 @@ function PullRequestsRouteView() { | ... | @@ -1058,17 +1081,28 @@ function PullRequestsRouteView() { |
| 1058 | // re-reads only its own slice, so the rows loaded before it would never see a merge, a close, | 1081 | // re-reads only its own slice, so the rows loaded before it would never see a merge, a close, |
| 1059 | // or a retitle. Going back to a single page long enough to cover everything on screen lets the | 1082 | // or a retitle. Going back to a single page long enough to cover everything on screen lets the |
| 1060 | // merge above bring every row up to date in place. | 1083 | // merge above bring every row up to date in place. |
| 1061 | const refreshList = (includeRelated = false) => { | 1084 | const refreshList = (includeRelated = false, actedEnvironmentId?: EnvironmentId) => { |
| 1062 | const related = includeRelated | 1085 | const related = ( |
| 1063 | ? [ | 1086 | includeRelated |
| 1064 | ...baselineTargets, | 1087 | ? [ |
| 1065 | ...facetTargets, | 1088 | ...baselineTargets, |
| 1066 | ...partitionTargets.authored, | 1089 | ...facetTargets, |
| 1067 | ...partitionTargets.reviewing, | 1090 | ...partitionTargets.authored, |
| 1068 | ] | 1091 | ...partitionTargets.reviewing, |
| 1069 | : []; | 1092 | ] |
| 1093 | : [] | ||
| 1094 | ).filter( | ||
| 1095 | ({ environmentId }) => | ||
| 1096 | actedEnvironmentId === undefined || environmentId === actedEnvironmentId, | ||
| 1097 | ); | ||
| 1070 | if (sentCursors === null) { | 1098 | if (sentCursors === null) { |
| 1071 | listQuery.refresh([...listTargets, ...related]); | 1099 | listQuery.refresh([ |
| 1100 | ...listTargets.filter( | ||
| 1101 | ({ environmentId }) => | ||
| 1102 | actedEnvironmentId === undefined || environmentId === actedEnvironmentId, | ||
| 1103 | ), | ||
| 1104 | ...related, | ||
| 1105 | ]); | ||
| 1072 | return; | 1106 | return; |
| 1073 | } | 1107 | } |
| 1074 | if (related.length > 0) listQuery.refresh(related); | 1108 | if (related.length > 0) listQuery.refresh(related); |
| ... | @@ -1412,9 +1446,10 @@ function PullRequestsRouteView() { | ... | @@ -1412,9 +1446,10 @@ function PullRequestsRouteView() { |
| 1412 | repository: search.repository, | 1446 | repository: search.repository, |
| 1413 | number: search.number, | 1447 | number: search.number, |
| 1414 | projectId: selectedProject.id, | 1448 | projectId: selectedProject.id, |
| 1449 | ...(selectedHost ? { host: selectedHost } : {}), | ||
| 1415 | } | 1450 | } |
| 1416 | : null, | 1451 | : null, |
| 1417 | [search.number, search.repository, selectedProject], | 1452 | [search.number, search.repository, selectedProject, selectedHost], |
| 1418 | ); | 1453 | ); |
| 1419 | const rightPanelAvailable = selectedPullRequestSurface !== null; | 1454 | const rightPanelAvailable = selectedPullRequestSurface !== null; |
| 1420 | useEffect(() => { | 1455 | useEffect(() => { |
| ... | @@ -1429,6 +1464,14 @@ function PullRequestsRouteView() { | ... | @@ -1429,6 +1464,14 @@ function PullRequestsRouteView() { |
| 1429 | repository: activePullRequestSurface.repository, | 1464 | repository: activePullRequestSurface.repository, |
| 1430 | number: activePullRequestSurface.number, | 1465 | number: activePullRequestSurface.number, |
| 1431 | projectId: activePullRequestSurface.projectId as ProjectId, | 1466 | projectId: activePullRequestSurface.projectId as ProjectId, |
| 1467 | host: | ||
| 1468 | activePullRequestSurface.host ?? | ||
| 1469 | (selectedProject?.repositoryIdentity | ||
| 1470 | ? pullRequestHostOf( | ||
| 1471 | selectedProject.repositoryIdentity, | ||
| 1472 | selectedProject.repositoryIdentity.provider as SourceControlProviderKind, | ||
| 1473 | ) | ||
| 1474 | : undefined), | ||
| 1432 | } | 1475 | } |
| 1433 | : null; | 1476 | : null; |
| 1434 | 1477 | ||
| ... | @@ -1440,6 +1483,7 @@ function PullRequestsRouteView() { | ... | @@ -1440,6 +1483,7 @@ function PullRequestsRouteView() { |
| 1440 | repository: surface.repository, | 1483 | repository: surface.repository, |
| 1441 | number: surface.number, | 1484 | number: surface.number, |
| 1442 | selectedProjectId: surface.projectId as ProjectId, | 1485 | selectedProjectId: surface.projectId as ProjectId, |
| 1486 | selectedHost: surface.host, | ||
| 1443 | ...(surface.environmentId === undefined | 1487 | ...(surface.environmentId === undefined |
| 1444 | ? {} | 1488 | ? {} |
| 1445 | : { selectedEnvironmentId: surface.environmentId as EnvironmentId }), | 1489 | : { selectedEnvironmentId: surface.environmentId as EnvironmentId }), |
| ... | @@ -1503,7 +1547,7 @@ function PullRequestsRouteView() { | ... | @@ -1503,7 +1547,7 @@ function PullRequestsRouteView() { |
| 1503 | 1547 | ||
| 1504 | // Stable so the memoized rows can skip re-rendering when the list around them changes. | 1548 | // Stable so the memoized rows can skip re-rendering when the list around them changes. |
| 1505 | const selectEntry = useCallback( | 1549 | const selectEntry = useCallback( |
| 1506 | (entry: EnvironmentPullRequestEntry) => { | 1550 | (entry: PullRequestRowTarget) => { |
| 1507 | // The surface carries the row's own server, which is what its detail reads and acts on. | 1551 | // The surface carries the row's own server, which is what its detail reads and acts on. |
| 1508 | if (rightPanelRef === null) return; | 1552 | if (rightPanelRef === null) return; |
| 1509 | useRightPanelStore.getState().openPullRequest(rightPanelRef, entry); | 1553 | useRightPanelStore.getState().openPullRequest(rightPanelRef, entry); |
| ... | @@ -1512,6 +1556,7 @@ function PullRequestsRouteView() { | ... | @@ -1512,6 +1556,7 @@ function PullRequestsRouteView() { |
| 1512 | number: entry.number, | 1556 | number: entry.number, |
| 1513 | selectedProjectId: entry.projectId, | 1557 | selectedProjectId: entry.projectId, |
| 1514 | selectedEnvironmentId: entry.environmentId, | 1558 | selectedEnvironmentId: entry.environmentId, |
| 1559 | selectedHost: entry.host, | ||
| 1515 | }); | 1560 | }); |
| 1516 | }, | 1561 | }, |
| 1517 | [rightPanelRef, updateSearch], | 1562 | [rightPanelRef, updateSearch], |
| ... | @@ -1628,6 +1673,7 @@ function PullRequestsRouteView() { | ... | @@ -1628,6 +1673,7 @@ function PullRequestsRouteView() { |
| 1628 | selected={ | 1673 | selected={ |
| 1629 | selected?.environmentId === entry.environmentId && | 1674 | selected?.environmentId === entry.environmentId && |
| 1630 | selected.repository === entry.repository && | 1675 | selected.repository === entry.repository && |
| 1676 | selected.host?.toLowerCase() === entry.host.toLowerCase() && | ||
| 1631 | selected.number === entry.number | 1677 | selected.number === entry.number |
| 1632 | } | 1678 | } |
| 1633 | onSelect={selectEntry} | 1679 | onSelect={selectEntry} |
| ... | @@ -1938,10 +1984,30 @@ function PullRequestsRouteView() { | ... | @@ -1938,10 +1984,30 @@ function PullRequestsRouteView() { |
| 1938 | <PullRequestDetailPanel | 1984 | <PullRequestDetailPanel |
| 1939 | key={renderedPullRequestSurface.id} | 1985 | key={renderedPullRequestSurface.id} |
| 1940 | environmentId={panelEnvironmentId} | 1986 | environmentId={panelEnvironmentId} |
| 1987 | onSelectPullRequest={(reference) => { | ||
| 1988 | if (rightPanelRef === null) return; | ||
| 1989 | useRightPanelStore.getState().openPullRequest(rightPanelRef, { | ||
| 1990 | projectId: reference.projectId, | ||
| 1991 | repository: reference.repository, | ||
| 1992 | number: reference.number, | ||
| 1993 | ...(reference.host ? { host: reference.host } : {}), | ||
| 1994 | environmentId: panelEnvironmentId, | ||
| 1995 | }); | ||
| 1996 | updateSearch({ | ||
| 1997 | repository: reference.repository, | ||
| 1998 | number: reference.number, | ||
| 1999 | selectedHost: reference.host, | ||
| 2000 | selectedProjectId: reference.projectId, | ||
| 2001 | selectedEnvironmentId: panelEnvironmentId, | ||
| 2002 | }); | ||
| 2003 | }} | ||
| 1941 | reference={{ | 2004 | reference={{ |
| 1942 | projectId: renderedPullRequestSurface.projectId as ProjectId, | 2005 | projectId: renderedPullRequestSurface.projectId as ProjectId, |
| 1943 | repository: renderedPullRequestSurface.repository, | 2006 | repository: renderedPullRequestSurface.repository, |
| 1944 | number: renderedPullRequestSurface.number, | 2007 | number: renderedPullRequestSurface.number, |
| 2008 | ...(renderedPullRequestSurface.host | ||
| 2009 | ? { host: renderedPullRequestSurface.host } | ||
| 2010 | : {}), | ||
| 1945 | }} | 2011 | }} |
| 1946 | listEntry={ | 2012 | listEntry={ |
| 1947 | listedPullRequestsBySurface.get( | 2013 | listedPullRequestsBySurface.get( |
| ... | @@ -1952,7 +2018,8 @@ function PullRequestsRouteView() { | ... | @@ -1952,7 +2018,8 @@ function PullRequestsRouteView() { |
| 1952 | // Host actions can change both readiness and diff size, so refresh the counts | 2018 | // Host actions can change both readiness and diff size, so refresh the counts |
| 1953 | // alongside the list. The panel already refreshes itself after each action. | 2019 | // alongside the list. The panel already refreshes itself after each action. |
| 1954 | onActed={() => { | 2020 | onActed={() => { |
| 1955 | void refreshFromHost(false); | 2021 | // Mutations already invalidate the host's affected caches. |
| 2022 | refreshListAndStats(undefined, panelEnvironmentId); | ||
| 1956 | }} | 2023 | }} |
| 1957 | /> | 2024 | /> |
| 1958 | </RightPanelTabs> | 2025 | </RightPanelTabs> |
apps/web/src/state/pullRequests.ts+4-1| ... | @@ -74,7 +74,10 @@ export function useSharedPullRequestSummary( | ... | @@ -74,7 +74,10 @@ export function useSharedPullRequestSummary( |
| 74 | }, [atom, current, environmentId]); | 74 | }, [atom, current, environmentId]); |
| 75 | return newestPullRequestSummary(current, observed); | 75 | return newestPullRequestSummary(current, observed); |
| 76 | } | 76 | } |
| 77 | export const pullRequestStackAtom = createPullRequestStackAtomFamily(connectionAtomRuntime); | 77 | export const pullRequestStackAtom = createPullRequestStackAtomFamily( |
| 78 | connectionAtomRuntime, | ||
| 79 | pullRequestEnvironment.refreshes, | ||
| 80 | ); | ||
| 78 | 81 | ||
| 79 | export interface EnvironmentQueryTarget<Input> { | 82 | export interface EnvironmentQueryTarget<Input> { |
| 80 | readonly environmentId: EnvironmentId; | 83 | readonly environmentId: EnvironmentId; |
apps/web/src/state/query.ts+2| ... | @@ -11,6 +11,7 @@ export interface EnvironmentQueryView<A> { | ... | @@ -11,6 +11,7 @@ export interface EnvironmentQueryView<A> { |
| 11 | readonly data: A | null; | 11 | readonly data: A | null; |
| 12 | readonly error: string | null; | 12 | readonly error: string | null; |
| 13 | readonly isPending: boolean; | 13 | readonly isPending: boolean; |
| 14 | readonly isSuccess: boolean; | ||
| 14 | readonly refresh: () => void; | 15 | readonly refresh: () => void; |
| 15 | } | 16 | } |
| 16 | 17 | ||
| ... | @@ -31,6 +32,7 @@ export function useEnvironmentQuery<A, E>( | ... | @@ -31,6 +32,7 @@ export function useEnvironmentQuery<A, E>( |
| 31 | data: Option.getOrNull(AsyncResult.value(result)), | 32 | data: Option.getOrNull(AsyncResult.value(result)), |
| 32 | error: result._tag === "Failure" ? formatEnvironmentQueryError(result.cause) : null, | 33 | error: result._tag === "Failure" ? formatEnvironmentQueryError(result.cause) : null, |
| 33 | isPending: atom !== null && result.waiting, | 34 | isPending: atom !== null && result.waiting, |
| 35 | isSuccess: result._tag === "Success", | ||
| 34 | refresh, | 36 | refresh, |
| 35 | }; | 37 | }; |
| 36 | } | 38 | } |
apps/web/src/state/usePullRequestStack.ts created+33| ... | @@ -0,0 +1,33 @@ | ||
| 1 | import type { EnvironmentId, PullRequestRef } from "@t3tools/contracts"; | ||
| 2 | import { useMemo } from "react"; | ||
| 3 | import { | ||
| 4 | savedPullRequestStack, | ||
| 5 | pullRequestStackView, | ||
| 6 | } from "../components/pullRequest/pullRequestStackSnapshot"; | ||
| 7 | import { useThreadShells } from "./entities"; | ||
| 8 | import { pullRequestStackAtom } from "./pullRequests"; | ||
| 9 | import { useEnvironmentQuery } from "./query"; | ||
| 10 | |||
| 11 | /** Detail headers and list popovers keep saved navigation during an unavailable refresh. */ | ||
| 12 | export function usePullRequestStack( | ||
| 13 | environmentId: EnvironmentId, | ||
| 14 | reference: PullRequestRef | null, | ||
| 15 | ) { | ||
| 16 | const threads = useThreadShells(); | ||
| 17 | const saved = useMemo( | ||
| 18 | () => | ||
| 19 | reference === null | ||
| 20 | ? null | ||
| 21 | : savedPullRequestStack( | ||
| 22 | threads | ||
| 23 | .filter((thread) => thread.environmentId === environmentId) | ||
| 24 | .flatMap((thread) => thread.pullRequests ?? []), | ||
| 25 | reference, | ||
| 26 | ), | ||
| 27 | [environmentId, reference, threads], | ||
| 28 | ); | ||
| 29 | const query = useEnvironmentQuery( | ||
| 30 | reference === null ? null : pullRequestStackAtom({ environmentId, input: reference }), | ||
| 31 | ); | ||
| 32 | return { ...query, ...pullRequestStackView(query, saved) }; | ||
| 33 | } | ||
docs/user/source-control.md+12| ... | @@ -115,3 +115,15 @@ review is terminal. An open or unsynced link keeps it active. | ... | @@ -115,3 +115,15 @@ review is terminal. An open or unsynced link keeps it active. |
| 115 | 115 | ||
| 116 | Cross-repository links use a project on the same host. Azure DevOps reviews require a project checked | 116 | Cross-repository links use a project on the same host. Azure DevOps reviews require a project checked |
| 117 | out from the matching organization and repository. | 117 | out from the matching organization and repository. |
| 118 | |||
| 119 | ## GitHub stacks | ||
| 120 | |||
| 121 | The Pull Requests page shows each PR's position in its GitHub stack. Open the stack badge in a | ||
| 122 | review to navigate its layers. **Merge stack** submits the selected pull request and every unmerged | ||
| 123 | layer below it to GitHub together, respecting branch rules and merge queues. The confirmation shows | ||
| 124 | the scope and merge strategy. GitHub rebases the remaining stack after merging. | ||
| 125 | |||
| 126 | **Rebase stack** updates remote branches from bottom to top without changing your local checkout. | ||
| 127 | It can rewrite history and restart checks. If a layer fails, earlier updates remain; resolve that | ||
| 128 | layer before retrying. GitHub may require manual conflict resolution after a lower layer is amended, | ||
| 129 | even when its changes look independent. Stack actions require an environment that supports them. |
packages/client-runtime/src/state/pullRequests.test.ts+70-3| ... | @@ -1,4 +1,4 @@ | ... | @@ -1,4 +1,4 @@ |
| 1 | import { EnvironmentId, ProjectId, WS_METHODS } from "@t3tools/contracts"; | 1 | import { EnvironmentId, ProjectId, WS_METHODS, type PullRequestStack } from "@t3tools/contracts"; |
| 2 | import { expect, it } from "@effect/vitest"; | 2 | import { expect, it } from "@effect/vitest"; |
| 3 | import * as Effect from "effect/Effect"; | 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as Latch from "effect/Latch"; | 4 | import * as Latch from "effect/Latch"; |
| ... | @@ -19,7 +19,10 @@ import * as EnvironmentRegistry from "../connection/registry.ts"; | ... | @@ -19,7 +19,10 @@ import * as EnvironmentRegistry from "../connection/registry.ts"; |
| 19 | import * as EnvironmentSupervisor from "../connection/supervisor.ts"; | 19 | import * as EnvironmentSupervisor from "../connection/supervisor.ts"; |
| 20 | import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; | 20 | import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; |
| 21 | import type { RpcSession } from "../rpc/session.ts"; | 21 | import type { RpcSession } from "../rpc/session.ts"; |
| 22 | import { createPullRequestEnvironmentAtoms } from "./pullRequests.ts"; | 22 | import { |
| 23 | createPullRequestEnvironmentAtoms, | ||
| 24 | createPullRequestStackAtomFamily, | ||
| 25 | } from "./pullRequests.ts"; | ||
| 23 | import { PullRequestDiffLoader } from "./pullRequestDiffHttp.ts"; | 26 | import { PullRequestDiffLoader } from "./pullRequestDiffHttp.ts"; |
| 24 | import { executeAtomQuery } from "./runtime.ts"; | 27 | import { executeAtomQuery } from "./runtime.ts"; |
| 25 | 28 | ||
| ... | @@ -80,7 +83,7 @@ const makeTestRuntime = Effect.fn("makeTestRuntime")(function* (client: WsRpcPro | ... | @@ -80,7 +83,7 @@ const makeTestRuntime = Effect.fn("makeTestRuntime")(function* (client: WsRpcPro |
| 80 | const registry = yield* Effect.acquireRelease(Effect.sync(AtomRegistry.make), (registry) => | 83 | const registry = yield* Effect.acquireRelease(Effect.sync(AtomRegistry.make), (registry) => |
| 81 | Effect.sync(() => registry.dispose()), | 84 | Effect.sync(() => registry.dispose()), |
| 82 | ); | 85 | ); |
| 83 | return { atoms, registry }; | 86 | return { runtime, atoms, registry }; |
| 84 | }); | 87 | }); |
| 85 | 88 | ||
| 86 | it.effect("keeps concurrent diff file reads on different hosts separate", () => | 89 | it.effect("keeps concurrent diff file reads on different hosts separate", () => |
| ... | @@ -212,3 +215,67 @@ it.effect("refreshes pull request activity after a comment is updated", () => | ... | @@ -212,3 +215,67 @@ it.effect("refreshes pull request activity after a comment is updated", () => |
| 212 | }), | 215 | }), |
| 213 | ), | 216 | ), |
| 214 | ); | 217 | ); |
| 218 | |||
| 219 | it.effect("refreshes stack state after reopening and head SHAs after a turn", () => | ||
| 220 | Effect.scoped( | ||
| 221 | Effect.gen(function* () { | ||
| 222 | const refreshEvents = yield* PubSub.unbounded<number>(); | ||
| 223 | let state: "closed" | "open" = "closed"; | ||
| 224 | let headSha = "old-head"; | ||
| 225 | const client = { | ||
| 226 | [WS_METHODS.pullRequestsSubscribeRefreshes]: () => Stream.fromPubSub(refreshEvents), | ||
| 227 | [WS_METHODS.pullRequestsStack]: () => | ||
| 228 | Effect.sync( | ||
| 229 | () => | ||
| 230 | ({ | ||
| 231 | id: "stack-1", | ||
| 232 | number: 1, | ||
| 233 | url: "https://github.com/acme/web/pull/1", | ||
| 234 | base: "main", | ||
| 235 | layers: [ | ||
| 236 | { | ||
| 237 | number: 1, | ||
| 238 | headBranch: "feature", | ||
| 239 | headSha, | ||
| 240 | state, | ||
| 241 | isDraft: false, | ||
| 242 | }, | ||
| 243 | ], | ||
| 244 | }) satisfies PullRequestStack, | ||
| 245 | ), | ||
| 246 | } as unknown as WsRpcProtocolClient; | ||
| 247 | const { runtime, registry } = yield* makeTestRuntime(client); | ||
| 248 | const stacks = createPullRequestStackAtomFamily(runtime); | ||
| 249 | const stack = stacks({ | ||
| 250 | environmentId: TARGET.environmentId, | ||
| 251 | input: { | ||
| 252 | projectId: ProjectId.make("project-1"), | ||
| 253 | repository: "acme/web", | ||
| 254 | number: 1, | ||
| 255 | }, | ||
| 256 | }); | ||
| 257 | const unmount = registry.mount(stack); | ||
| 258 | yield* Effect.addFinalizer(() => Effect.sync(unmount)); | ||
| 259 | yield* Effect.promise(() => executeAtomQuery(registry, stack)); | ||
| 260 | expect((yield* AtomRegistry.getResult(registry, stack))?.layers[0]?.state).toBe("closed"); | ||
| 261 | state = "open"; | ||
| 262 | registry.refresh(stack); | ||
| 263 | expect( | ||
| 264 | (yield* AtomRegistry.getResult(registry, stack, { suspendOnWaiting: true }))?.layers[0] | ||
| 265 | ?.state, | ||
| 266 | ).toBe("open"); | ||
| 267 | |||
| 268 | const refreshed = Latch.makeUnsafe(); | ||
| 269 | const stop = registry.subscribe(stack, (result) => { | ||
| 270 | if (AsyncResult.isSuccess(result) && result.value?.layers[0]?.headSha === "new-head") { | ||
| 271 | refreshed.openUnsafe(); | ||
| 272 | } | ||
| 273 | }); | ||
| 274 | yield* Effect.addFinalizer(() => Effect.sync(stop)); | ||
| 275 | headSha = "new-head"; | ||
| 276 | yield* PubSub.publish(refreshEvents, 1); | ||
| 277 | yield* refreshed.await; | ||
| 278 | expect((yield* AtomRegistry.getResult(registry, stack))?.layers[0]?.headSha).toBe("new-head"); | ||
| 279 | }), | ||
| 280 | ), | ||
| 281 | ); |
packages/client-runtime/src/state/pullRequests.ts+2| ... | @@ -63,12 +63,14 @@ export function createLinkedPullRequestSummaryAtomFamily<R, E>( | ... | @@ -63,12 +63,14 @@ export function createLinkedPullRequestSummaryAtomFamily<R, E>( |
| 63 | /** The host-native stack a pull request belongs to; null where it is not stacked. */ | 63 | /** The host-native stack a pull request belongs to; null where it is not stacked. */ |
| 64 | export function createPullRequestStackAtomFamily<R, E>( | 64 | export function createPullRequestStackAtomFamily<R, E>( |
| 65 | runtime: Atom.AtomRuntime<EnvironmentRegistry | R, E>, | 65 | runtime: Atom.AtomRuntime<EnvironmentRegistry | R, E>, |
| 66 | refreshes = createPullRequestRefreshAtomFamily(runtime), | ||
| 66 | ) { | 67 | ) { |
| 67 | return createEnvironmentRpcQueryAtomFamily(runtime, { | 68 | return createEnvironmentRpcQueryAtomFamily(runtime, { |
| 68 | label: "environment-data:pull-requests:stack", | 69 | label: "environment-data:pull-requests:stack", |
| 69 | tag: WS_METHODS.pullRequestsStack, | 70 | tag: WS_METHODS.pullRequestsStack, |
| 70 | staleTimeMs: 60_000, | 71 | staleTimeMs: 60_000, |
| 71 | idleTtlMs: LINKED_PULL_REQUEST_IDLE_TTL_MS, | 72 | idleTtlMs: LINKED_PULL_REQUEST_IDLE_TTL_MS, |
| 73 | refreshTrigger: ({ environmentId }) => refreshes({ environmentId, input: {} }), | ||
| 72 | }); | 74 | }); |
| 73 | } | 75 | } |
| 74 | 76 |
packages/client-runtime/src/work-log/presentation.test.ts+76| ... | @@ -571,3 +571,79 @@ describe("resolveViewedImageAsset", () => { | ... | @@ -571,3 +571,79 @@ describe("resolveViewedImageAsset", () => { |
| 571 | expect(resolveViewedImageAsset("https://example.com/logo.png", { threadId })).toBeNull(); | 571 | expect(resolveViewedImageAsset("https://example.com/logo.png", { threadId })).toBeNull(); |
| 572 | }); | 572 | }); |
| 573 | }); | 573 | }); |
| 574 | |||
| 575 | describe("pull request tool presentation", () => { | ||
| 576 | it.each([ | ||
| 577 | "mcp__t3-code__link_pull_request", | ||
| 578 | "mcp__t3_code__link_pull_request", | ||
| 579 | "T3-code · link_pull_request", | ||
| 580 | "t3code/link_pull_request", | ||
| 581 | "link_pull_request", | ||
| 582 | ])("recognizes the native linking tool: %s", (label) => { | ||
| 583 | const entry = { label, tone: "tool" as const, toolLifecycleStatus: "completed" }; | ||
| 584 | expect(resolveWorkEntryToolPresentation(entry)).toMatchObject({ | ||
| 585 | displayName: "Linked a pull request", | ||
| 586 | icon: "pull-request", | ||
| 587 | }); | ||
| 588 | expect(toolGroupAction(entry)).toBe("link-pr"); | ||
| 589 | }); | ||
| 590 | |||
| 591 | it.each([ | ||
| 592 | ["inProgress", "Linking PR #42"], | ||
| 593 | ["completed", "Linked PR #42"], | ||
| 594 | ["failed", "Failed to link PR #42"], | ||
| 595 | ["declined", "Declined to link PR #42"], | ||
| 596 | ["stopped", "Stopped linking PR #42"], | ||
| 597 | ])("describes the target and %s status", (toolLifecycleStatus, displayName) => { | ||
| 598 | expect( | ||
| 599 | resolveWorkEntryToolPresentation({ | ||
| 600 | label: "MCP tool call", | ||
| 601 | toolTitle: "Custom title", | ||
| 602 | toolLifecycleStatus, | ||
| 603 | toolData: { | ||
| 604 | server: "t3-code", | ||
| 605 | tool: "link_pull_request", | ||
| 606 | arguments: { url: "https://github.com/acme/web/pull/42" }, | ||
| 607 | }, | ||
| 608 | })?.displayName, | ||
| 609 | ).toBe(displayName); | ||
| 610 | }); | ||
| 611 | |||
| 612 | it("recognizes unlink targets supplied as repository and number", () => { | ||
| 613 | expect( | ||
| 614 | resolveWorkEntryToolPresentation({ | ||
| 615 | label: "MCP tool call", | ||
| 616 | toolLifecycleStatus: "completed", | ||
| 617 | toolData: { | ||
| 618 | toolName: "mcp__t3-code__unlink_pull_request", | ||
| 619 | rawInput: { repository: "acme/web", number: 42 }, | ||
| 620 | }, | ||
| 621 | }), | ||
| 622 | ).toMatchObject({ displayName: "Unlinked PR #42", icon: "pull-request", action: "unlink-pr" }); | ||
| 623 | }); | ||
| 624 | |||
| 625 | it("summarizes native PR work separately from ordinary tools and integration metadata", () => { | ||
| 626 | const link: WorkLogPresentationEntry = { | ||
| 627 | label: "T3-code · link_pull_request", | ||
| 628 | tone: "tool", | ||
| 629 | itemType: "mcp_tool_call", | ||
| 630 | toolLifecycleStatus: "completed", | ||
| 631 | toolSource: { key: "t3-code", name: "T3 Code", kind: "integration" }, | ||
| 632 | }; | ||
| 633 | const list: WorkLogPresentationEntry = { | ||
| 634 | ...link, | ||
| 635 | label: "T3-code · list_thread_pull_requests", | ||
| 636 | }; | ||
| 637 | expect(summarizeToolGroup([link, link, list])).toBe( | ||
| 638 | "Linked 2 pull requests and checked linked pull requests", | ||
| 639 | ); | ||
| 640 | expect(summarizeToolGroup([{ ...link, label: "T3-code · unlink_pull_request" }])).toBe( | ||
| 641 | "Unlinked 1 pull request", | ||
| 642 | ); | ||
| 643 | expect(toolGroupSummaryKind([link, link, list])).toBe("pull-request"); | ||
| 644 | expect(summarizeToolGroup([list, list])).toBe("Checked linked pull requests 2 times"); | ||
| 645 | expect( | ||
| 646 | resolveWorkEntryToolPresentation({ label: "mcp__another-server__link_pull_request" }), | ||
| 647 | ).toBeNull(); | ||
| 648 | }); | ||
| 649 | }); |
packages/client-runtime/src/work-log/presentation.ts+62-9| ... | @@ -8,6 +8,7 @@ import { | ... | @@ -8,6 +8,7 @@ import { |
| 8 | } from "@t3tools/contracts"; | 8 | } from "@t3tools/contracts"; |
| 9 | import { classifyMarkdownImageSource } from "@t3tools/client-runtime/markdown-images"; | 9 | import { classifyMarkdownImageSource } from "@t3tools/client-runtime/markdown-images"; |
| 10 | import { resolveMediaSource } from "@t3tools/client-runtime/media-source"; | 10 | import { resolveMediaSource } from "@t3tools/client-runtime/media-source"; |
| 11 | import { parseChangeRequestUrl } from "@t3tools/shared/changeRequestUrl"; | ||
| 11 | import { isWorkspaceImagePreviewPath } from "@t3tools/shared/filePreview"; | 12 | import { isWorkspaceImagePreviewPath } from "@t3tools/shared/filePreview"; |
| 12 | 13 | ||
| 13 | export function isWorktreeSetupActivity(kind: string): boolean { | 14 | export function isWorktreeSetupActivity(kind: string): boolean { |
| ... | @@ -36,6 +37,9 @@ export interface WorkLogPresentationEntry { | ... | @@ -36,6 +37,9 @@ export interface WorkLogPresentationEntry { |
| 36 | } | 37 | } |
| 37 | 38 | ||
| 38 | export type ToolGroupAction = | 39 | export type ToolGroupAction = |
| 40 | | "link-pr" | ||
| 41 | | "unlink-pr" | ||
| 42 | | "list-prs" | ||
| 39 | | "read" | 43 | | "read" |
| 40 | | "edit" | 44 | | "edit" |
| 41 | | "command" | 45 | | "command" |
| ... | @@ -46,6 +50,7 @@ export type ToolGroupAction = | ... | @@ -46,6 +50,7 @@ export type ToolGroupAction = |
| 46 | | "update"; | 50 | | "update"; |
| 47 | 51 | ||
| 48 | export type ToolGroupSummaryKind = | 52 | export type ToolGroupSummaryKind = |
| 53 | | "pull-request" | ||
| 49 | | ToolGroupAction | 54 | | ToolGroupAction |
| 50 | | "dynamic-tool" | 55 | | "dynamic-tool" |
| 51 | | "agent-tool" | 56 | | "agent-tool" |
| ... | @@ -60,6 +65,9 @@ const T3_MCP_TOOL_LABELS: Record< | ... | @@ -60,6 +65,9 @@ const T3_MCP_TOOL_LABELS: Record< |
| 60 | string, | 65 | string, |
| 61 | readonly [action: string, running: string, completed: string, detail: string] | 66 | readonly [action: string, running: string, completed: string, detail: string] |
| 62 | > = { | 67 | > = { |
| 68 | link_pull_request: ["Link", "Linking", "Linked", "a pull request"], | ||
| 69 | unlink_pull_request: ["Unlink", "Unlinking", "Unlinked", "a pull request"], | ||
| 70 | list_thread_pull_requests: ["Check", "Checking", "Checked", "linked pull requests"], | ||
| 63 | orchestrator_capabilities: ["Get", "Getting", "Got", "orchestration capabilities"], | 71 | orchestrator_capabilities: ["Get", "Getting", "Got", "orchestration capabilities"], |
| 64 | delegate_task: ["Delegate", "Delegating", "Delegated", "a child task"], | 72 | delegate_task: ["Delegate", "Delegating", "Delegated", "a child task"], |
| 65 | task_status: ["Get", "Getting", "Got", "delegated task status"], | 73 | task_status: ["Get", "Getting", "Got", "delegated task status"], |
| ... | @@ -98,7 +106,17 @@ const T3_MCP_TOOL_LABELS: Record< | ... | @@ -98,7 +106,17 @@ const T3_MCP_TOOL_LABELS: Record< |
| 98 | preview_recording_stop: ["Stop", "Stopping", "Stopped", "recording the preview browser"], | 106 | preview_recording_stop: ["Stop", "Stopping", "Stopped", "recording the preview browser"], |
| 99 | }; | 107 | }; |
| 100 | 108 | ||
| 101 | function resolveT3McpToolPresentation(value: string | undefined, status: string | undefined) { | 109 | const PR_TOOL_ACTIONS: Readonly<Record<string, ToolGroupAction>> = { |
| 110 | link_pull_request: "link-pr", | ||
| 111 | unlink_pull_request: "unlink-pr", | ||
| 112 | list_thread_pull_requests: "list-prs", | ||
| 113 | }; | ||
| 114 | |||
| 115 | function resolveT3McpToolPresentation( | ||
| 116 | value: string | undefined, | ||
| 117 | status: string | undefined, | ||
| 118 | data?: unknown, | ||
| 119 | ) { | ||
| 102 | if (!value) return null; | 120 | if (!value) return null; |
| 103 | const name = normalizeCompactToolLabel(value).replace( | 121 | const name = normalizeCompactToolLabel(value).replace( |
| 104 | /^(?:mcp__(?:t3-code|t3_code|t3code)__|(?:t3-code|t3_code|t3code)(?:[.:/]|\s*·\s*))/i, | 122 | /^(?:mcp__(?:t3-code|t3_code|t3code)__|(?:t3-code|t3_code|t3code)(?:[.:/]|\s*·\s*))/i, |
| ... | @@ -120,9 +138,29 @@ function resolveT3McpToolPresentation(value: string | undefined, status: string | ... | @@ -120,9 +138,29 @@ function resolveT3McpToolPresentation(value: string | undefined, status: string |
| 120 | ? `Stopped ${running.toLowerCase()}` | 138 | ? `Stopped ${running.toLowerCase()}` |
| 121 | : running; | 139 | : running; |
| 122 | 140 | ||
| 141 | const actionKind = Object.hasOwn(PR_TOOL_ACTIONS, name) ? PR_TOOL_ACTIONS[name] : undefined; | ||
| 142 | const payload = asRecord(data); | ||
| 143 | const input = | ||
| 144 | asRecord(payload?.arguments) ?? asRecord(payload?.input) ?? asRecord(payload?.rawInput); | ||
| 145 | const urlTarget = typeof input?.url === "string" ? parseChangeRequestUrl(input.url) : null; | ||
| 146 | const number = urlTarget?.number ?? input?.number; | ||
| 147 | const target = | ||
| 148 | actionKind !== undefined && | ||
| 149 | actionKind !== "list-prs" && | ||
| 150 | typeof number === "number" && | ||
| 151 | Number.isSafeInteger(number) && | ||
| 152 | number > 0 | ||
| 153 | ? `PR #${number}` | ||
| 154 | : detail; | ||
| 123 | return { | 155 | return { |
| 124 | displayName: `${verb} ${detail}`, | 156 | displayName: `${verb} ${target}`, |
| 125 | icon: name.startsWith("preview_") ? ("browser" as const) : ("t3-code" as const), | 157 | icon: |
| 158 | actionKind !== undefined | ||
| 159 | ? ("pull-request" as const) | ||
| 160 | : name.startsWith("preview_") | ||
| 161 | ? ("browser" as const) | ||
| 162 | : ("t3-code" as const), | ||
| 163 | ...(actionKind === undefined ? {} : { action: actionKind }), | ||
| 126 | }; | 164 | }; |
| 127 | } | 165 | } |
| 128 | 166 | ||
| ... | @@ -147,16 +185,16 @@ export function resolveWorkEntryToolPresentation( | ... | @@ -147,16 +185,16 @@ export function resolveWorkEntryToolPresentation( |
| 147 | "tool" in data && | 185 | "tool" in data && |
| 148 | typeof data.tool === "string" | 186 | typeof data.tool === "string" |
| 149 | ) { | 187 | ) { |
| 150 | return resolveT3McpToolPresentation(`${data.server}.${data.tool}`, status); | 188 | return resolveT3McpToolPresentation(`${data.server}.${data.tool}`, status, data); |
| 151 | } | 189 | } |
| 152 | if ("toolName" in data && typeof data.toolName === "string") { | 190 | if ("toolName" in data && typeof data.toolName === "string") { |
| 153 | return resolveT3McpToolPresentation(data.toolName, status); | 191 | return resolveT3McpToolPresentation(data.toolName, status, data); |
| 154 | } | 192 | } |
| 155 | } | 193 | } |
| 156 | 194 | ||
| 157 | return ( | 195 | return ( |
| 158 | resolveT3McpToolPresentation(entry.toolTitle, status) ?? | 196 | resolveT3McpToolPresentation(entry.toolTitle, status, data) ?? |
| 159 | resolveT3McpToolPresentation(entry.label, status) | 197 | resolveT3McpToolPresentation(entry.label, status, data) |
| 160 | ); | 198 | ); |
| 161 | } | 199 | } |
| 162 | 200 | ||
| ... | @@ -410,7 +448,9 @@ export function toolGroupAction(entry: WorkLogPresentationEntry): ToolGroupActio | ... | @@ -410,7 +448,9 @@ export function toolGroupAction(entry: WorkLogPresentationEntry): ToolGroupActio |
| 410 | ) { | 448 | ) { |
| 411 | return "update"; | 449 | return "update"; |
| 412 | } | 450 | } |
| 413 | if (resolveWorkEntryToolPresentation(entry)?.icon === "browser") return "browser"; | 451 | const presentation = resolveWorkEntryToolPresentation(entry); |
| 452 | if (presentation?.action !== undefined) return presentation.action; | ||
| 453 | if (presentation?.icon === "browser") return "browser"; | ||
| 414 | if ( | 454 | if ( |
| 415 | entry.requestKind === "file-read" || | 455 | entry.requestKind === "file-read" || |
| 416 | entry.itemType === "image_view" || | 456 | entry.itemType === "image_view" || |
| ... | @@ -504,6 +544,14 @@ function toolGroupActionCount( | ... | @@ -504,6 +544,14 @@ function toolGroupActionCount( |
| 504 | 544 | ||
| 505 | function toolGroupActionLabel(action: ToolGroupAction, count: number): string { | 545 | function toolGroupActionLabel(action: ToolGroupAction, count: number): string { |
| 506 | switch (action) { | 546 | switch (action) { |
| 547 | case "link-pr": | ||
| 548 | return `Linked ${count} ${count === 1 ? "pull request" : "pull requests"}`; | ||
| 549 | case "unlink-pr": | ||
| 550 | return `Unlinked ${count} ${count === 1 ? "pull request" : "pull requests"}`; | ||
| 551 | case "list-prs": | ||
| 552 | return count === 1 | ||
| 553 | ? "Checked linked pull requests" | ||
| 554 | : `Checked linked pull requests ${count} times`; | ||
| 507 | case "read": | 555 | case "read": |
| 508 | return `Read ${count} ${count === 1 ? "file" : "files"}`; | 556 | return `Read ${count} ${count === 1 ? "file" : "files"}`; |
| 509 | case "edit": | 557 | case "edit": |
| ... | @@ -528,7 +576,7 @@ export function summarizeToolGroup(entries: ReadonlyArray<WorkLogPresentationEnt | ... | @@ -528,7 +576,7 @@ export function summarizeToolGroup(entries: ReadonlyArray<WorkLogPresentationEnt |
| 528 | const sources = new Map<string, ToolActivitySource>(); | 576 | const sources = new Map<string, ToolActivitySource>(); |
| 529 | const groupedEntries = new Map<ToolGroupAction, WorkLogPresentationEntry[]>(); | 577 | const groupedEntries = new Map<ToolGroupAction, WorkLogPresentationEntry[]>(); |
| 530 | for (const entry of summaryEntries) { | 578 | for (const entry of summaryEntries) { |
| 531 | if (entry.toolSource) { | 579 | if (entry.toolSource && resolveWorkEntryToolPresentation(entry)?.icon !== "pull-request") { |
| 532 | sources.set(entry.toolSource.key, entry.toolSource); | 580 | sources.set(entry.toolSource.key, entry.toolSource); |
| 533 | continue; | 581 | continue; |
| 534 | } | 582 | } |
| ... | @@ -601,6 +649,11 @@ export function omitSupersededLifecycleMarkers<T>( | ... | @@ -601,6 +649,11 @@ export function omitSupersededLifecycleMarkers<T>( |
| 601 | export function toolGroupSummaryKind( | 649 | export function toolGroupSummaryKind( |
| 602 | entries: ReadonlyArray<WorkLogPresentationEntry>, | 650 | entries: ReadonlyArray<WorkLogPresentationEntry>, |
| 603 | ): ToolGroupSummaryKind { | 651 | ): ToolGroupSummaryKind { |
| 652 | if ( | ||
| 653 | entries.length > 0 && | ||
| 654 | entries.every((entry) => resolveWorkEntryToolPresentation(entry)?.icon === "pull-request") | ||
| 655 | ) | ||
| 656 | return "pull-request"; | ||
| 604 | const actions = new Set(entries.map(toolGroupAction)); | 657 | const actions = new Set(entries.map(toolGroupAction)); |
| 605 | if (actions.size !== 1) return "mixed"; | 658 | if (actions.size !== 1) return "mixed"; |
| 606 | 659 |
packages/contracts/src/environment.ts+1| ... | @@ -130,6 +130,7 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ | ... | @@ -130,6 +130,7 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ |
| 130 | threads, and routes PullRequestRef.host across projects on the same host. Same | 130 | threads, and routes PullRequestRef.host across projects on the same host. Same |
| 131 | version-skew contract as threadSettlement. */ | 131 | version-skew contract as threadSettlement. */ |
| 132 | threadPullRequests: Schema.optionalKey(Schema.Boolean), | 132 | threadPullRequests: Schema.optionalKey(Schema.Boolean), |
| 133 | pullRequestStackActions: Schema.optionalKey(Schema.Boolean), | ||
| 133 | /** The update path clients should offer for this server. Absent on | 134 | /** The update path clients should offer for this server. Absent on |
| 134 | servers that must be relaunched manually (dev checkouts, Windows | 135 | servers that must be relaunched manually (dev checkouts, Windows |
| 135 | foreground runs, pre-update servers). */ | 136 | foreground runs, pre-update servers). */ |
packages/contracts/src/pullRequest.ts+24| ... | @@ -418,6 +418,7 @@ export const PullRequestCapabilities = Schema.Struct({ | ... | @@ -418,6 +418,7 @@ export const PullRequestCapabilities = Schema.Struct({ |
| 418 | * the stack the host shows. Absent means chains are only ever inferred from base branches. | 418 | * the stack the host shows. Absent means chains are only ever inferred from base branches. |
| 419 | */ | 419 | */ |
| 420 | stacks: Schema.optional(Schema.Boolean), | 420 | stacks: Schema.optional(Schema.Boolean), |
| 421 | stackActions: Schema.optional(Schema.Boolean), | ||
| 421 | /** | 422 | /** |
| 422 | * The repository's labels can be listed, and one put on a change request or taken off it. | 423 | * The repository's labels can be listed, and one put on a change request or taken off it. |
| 423 | * Optional for the same reason `edit` is: a server that says nothing about labels has no way | 424 | * Optional for the same reason `edit` is: a server that says nothing about labels has no way |
| ... | @@ -438,6 +439,8 @@ export type PullRequestCapabilities = typeof PullRequestCapabilities.Type; | ... | @@ -438,6 +439,8 @@ export type PullRequestCapabilities = typeof PullRequestCapabilities.Type; |
| 438 | * offering one they may not use ends in the host's own refusal — which at least says why. | 439 | * offering one they may not use ends in the host's own refusal — which at least says why. |
| 439 | */ | 440 | */ |
| 440 | export const PullRequestViewerPermissions = Schema.Struct({ | 441 | export const PullRequestViewerPermissions = Schema.Struct({ |
| 442 | /** May request remote stack rebases, including when this layer is already current. */ | ||
| 443 | stackRebase: Schema.optional(Schema.Boolean), | ||
| 441 | /** Which of the actions this viewer may take; anything absent is theirs to look at only. */ | 444 | /** Which of the actions this viewer may take; anything absent is theirs to look at only. */ |
| 442 | actions: Schema.Array(PullRequestAction), | 445 | actions: Schema.Array(PullRequestAction), |
| 443 | /** This viewer may write a remark: a comment, a reply, or a note against a line. */ | 446 | /** This viewer may write a remark: a comment, a reply, or a note against a line. */ |
| ... | @@ -469,7 +472,16 @@ export const PullRequestMergeCapabilities = Schema.Struct({ | ... | @@ -469,7 +472,16 @@ export const PullRequestMergeCapabilities = Schema.Struct({ |
| 469 | }); | 472 | }); |
| 470 | export type PullRequestMergeCapabilities = typeof PullRequestMergeCapabilities.Type; | 473 | export type PullRequestMergeCapabilities = typeof PullRequestMergeCapabilities.Type; |
| 471 | 474 | ||
| 475 | export const PullRequestStackMembership = Schema.Struct({ | ||
| 476 | number: PositiveInt, | ||
| 477 | position: PositiveInt, | ||
| 478 | size: PositiveInt, | ||
| 479 | base: TrimmedNonEmptyString, | ||
| 480 | }); | ||
| 481 | export type PullRequestStackMembership = typeof PullRequestStackMembership.Type; | ||
| 482 | |||
| 472 | export const PullRequestListEntry = Schema.Struct({ | 483 | export const PullRequestListEntry = Schema.Struct({ |
| 484 | stack: Schema.optional(PullRequestStackMembership), | ||
| 473 | provider: SourceControlProviderKind, | 485 | provider: SourceControlProviderKind, |
| 474 | /** | 486 | /** |
| 475 | * The host below which `repository` is addressed, so the same provider kind can serve more | 487 | * The host below which `repository` is addressed, so the same provider kind can serve more |
| ... | @@ -686,6 +698,9 @@ export const PullRequestStack = Schema.Struct({ | ... | @@ -686,6 +698,9 @@ export const PullRequestStack = Schema.Struct({ |
| 686 | layers: Schema.Array( | 698 | layers: Schema.Array( |
| 687 | Schema.Struct({ | 699 | Schema.Struct({ |
| 688 | number: PositiveInt, | 700 | number: PositiveInt, |
| 701 | title: Schema.optional(Schema.String), | ||
| 702 | isDraft: Schema.optional(Schema.Boolean), | ||
| 703 | headSha: Schema.optional(TrimmedNonEmptyString), | ||
| 689 | headBranch: TrimmedNonEmptyString, | 704 | headBranch: TrimmedNonEmptyString, |
| 690 | state: PullRequestState, | 705 | state: PullRequestState, |
| 691 | }), | 706 | }), |
| ... | @@ -908,7 +923,16 @@ export const PullRequestDiffFileContentsResult = Schema.Struct({ | ... | @@ -908,7 +923,16 @@ export const PullRequestDiffFileContentsResult = Schema.Struct({ |
| 908 | }); | 923 | }); |
| 909 | export type PullRequestDiffFileContentsResult = typeof PullRequestDiffFileContentsResult.Type; | 924 | export type PullRequestDiffFileContentsResult = typeof PullRequestDiffFileContentsResult.Type; |
| 910 | 925 | ||
| 926 | export const PullRequestStackHead = Schema.Struct({ | ||
| 927 | number: PositiveInt, | ||
| 928 | headSha: TrimmedNonEmptyString, | ||
| 929 | }); | ||
| 930 | export type PullRequestStackHead = typeof PullRequestStackHead.Type; | ||
| 931 | |||
| 911 | export const PullRequestActionInput = Schema.Struct({ | 932 | export const PullRequestActionInput = Schema.Struct({ |
| 933 | /** Native stack scope; only send to environments advertising pullRequestStackActions. */ | ||
| 934 | stackNumber: Schema.optional(PositiveInt), | ||
| 935 | expectedStackHeads: Schema.optional(Schema.Array(PullRequestStackHead)), | ||
| 912 | ...PullRequestRef.fields, | 936 | ...PullRequestRef.fields, |
| 913 | action: PullRequestAction, | 937 | action: PullRequestAction, |
| 914 | /** | 938 | /** |