Unified · Split
| @@ -76,7 +76,7 @@ export const THREAD_DISCLOSURE_TRANSITION_MS = 180; · 75 unmodified lines | ||
| 76 | 76 | const WORK_LOG_LAYOUT_TRANSITION = LinearTransition.duration(THREAD_DISCLOSURE_TRANSITION_MS); |
| 77 | 77 | const WORK_LOG_DETAIL_ENTER_TRANSITION = FadeIn.duration(140); |
| 78 | 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 | 81 | function WorkLogIcon(props: { |
| 82 | 82 | readonly icon: WorkContentIcon; |
| @@ -92,7 +92,13 @@ function WorkLogIcon(props: { · 9 unmodified lines | ||
| 92 | 92 | } |
| 93 | 93 | return ( |
| 94 | 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 | 102 | size={14} |
| 97 | 103 | weight="medium" |
| 98 | 104 | {...(colorClassName ? { tintColorClassName: colorClassName } : { tintColor: props.color })} |
| @@ -893,7 +899,7 @@ export function ThreadWorkGroupToggle(props: { · 794 unmodified lines | ||
| 893 | 899 | readonly iconSubtleColor: import("react-native").ColorValue; |
| 894 | 900 | readonly summary: string; |
| 895 | 901 | readonly summaryKind: ToolGroupSummaryKind; |
| 896 | − readonly summaryToolIcon?: "browser" | "t3-code"; | |
| 902 | + readonly summaryToolIcon?: "browser" | "t3-code" | "pull-request"; | |
| 897 | 903 | readonly themeAppearance: "light" | "dark"; |
| 898 | 904 | readonly toolSurface?: import("@t3tools/contracts").ToolActivitySurface; |
| 899 | 905 | readonly toolIcon?: ToolActivityIcon; |
| @@ -1231,6 +1237,11 @@ function ToolActivityImage(props: { · 331 unmodified lines | ||
| 1231 | 1237 | |
| 1232 | 1238 | function toolGroupSummarySymbolName(kind: ToolGroupSummaryKind): AppSymbolName { |
| 1233 | 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 | 1245 | case "read": |
| 1235 | 1246 | return { ios: "eye", android: "visibility" }; |
| 1236 | 1247 | case "edit": |
| @@ -167,7 +167,7 @@ export type ThreadFeedEntry = · 166 unmodified lines | ||
| 167 | 167 | readonly summaryKind: ToolGroupSummaryKind; |
| 168 | 168 | readonly toolSurface?: WorkLogEntry["toolSurface"]; |
| 169 | 169 | readonly toolIcon?: WorkLogEntry["toolIcon"]; |
| 170 | − readonly summaryToolIcon?: "browser" | "t3-code"; | |
| 170 | + readonly summaryToolIcon?: "browser" | "t3-code" | "pull-request"; | |
| 171 | 171 | readonly hasFailure: boolean; |
| 172 | 172 | readonly live: boolean; |
| 173 | 173 | readonly shimmer: boolean; |
| @@ -231,6 +231,7 @@ export const make = Effect.gen(function* () { · 230 unmodified lines | ||
| 231 | 231 | threadActiveReorder: true, |
| 232 | 232 | threadTitleRegeneration: true, |
| 233 | 233 | threadPullRequests: true, |
| 234 | + pullRequestStackActions: true, | |
| 234 | 235 | threadPullRequestLinking: true, |
| 235 | 236 | environmentIcon: true, |
| 236 | 237 | ...(serverSelfUpdate === null ? {} : { serverSelfUpdate }), |
| @@ -178,8 +178,9 @@ const makeHarness = Effect.fn("makePullRequestSyncHarness")(function* (options: · 177 unmodified lines | ||
| 178 | 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 | 182 | Effect.gen(function* () { |
| 183 | + assert.strictEqual(readOptions?.includeDetails, false); | |
| 183 | 184 | yield* Ref.update(stackCalls, (calls) => [...calls, input]); |
| 184 | 185 | return yield* options.stack?.(input) ?? Effect.succeed(null); |
| 185 | 186 | }); |
| @@ -256,7 +256,7 @@ export const make = Effect.gen(function* () { · 255 unmodified lines | ||
| 256 | 256 | entry.link.snapshot === null || !snapshotFieldsEqual(entry.link.snapshot, fields), |
| 257 | 257 | ); |
| 258 | 258 | const fetchedStack = needsStack |
| 259 | − ? yield* pullRequests.stack(ref).pipe( | |
| 259 | + ? yield* pullRequests.stack(ref, { includeDetails: false }).pipe( | |
| 260 | 260 | Effect.map((stack) => ({ |
| 261 | 261 | stack: stack === null ? null : ({ kind: "native", ...stack } as const), |
| 262 | 262 | })), |
| @@ -1,9 +1,11 @@ | ||
| 1 | 1 | import { |
| 2 | + CheckpointRef, | |
| 2 | 3 | EventId, |
| 3 | 4 | GitManagerError, |
| 4 | 5 | ProjectId, |
| 5 | 6 | ProviderInstanceId, |
| 6 | 7 | ThreadId, |
| 8 | + TurnId, | |
| 7 | 9 | type OrchestrationCommand, |
| 8 | 10 | type OrchestrationEvent, |
| 9 | 11 | type OrchestrationProjectShell, |
| @@ -311,54 +313,79 @@ describe("ThreadPullRequestReactor", () => { · 301 unmodified lines | ||
| 311 | 313 | ), |
| 312 | 314 | ); |
| 313 | 315 | |
| 314 | − it.effect("refreshes discovery after a turn ends without client demand", () => | |
| 315 | − Effect.scoped( | |
| 316 | − Effect.gen(function* () { | |
| 317 | − const current = thread("turn-thread"); | |
| 318 | − const detected = yield* Ref.make<GitBranchPullRequest | null>(null); | |
| 319 | − const fixture = yield* makeHarness({ | |
| 320 | − threads: [current], | |
| 321 | − branchPullRequest: (_input, options) => | |
| 322 | − options?.refresh | |
| 323 | − ? Ref.set(detected, branchPullRequest()).pipe(Effect.andThen(Ref.get(detected))) | |
| 324 | − : Ref.get(detected), | |
| 325 | − }); | |
| 326 | − yield* Effect.gen(function* () { | |
| 327 | − const reactor = yield* fixture.start(); | |
| 328 | − expect(yield* Ref.get(fixture.commands)).toHaveLength(0); | |
| 329 | − yield* fixture.publish({ | |
| 330 | − type: "thread.session-set", | |
| 331 | − sequence: 2, | |
| 332 | − eventId: EventId.make("turn-finished"), | |
| 333 | − aggregateKind: "thread", | |
| 334 | − aggregateId: current.id, | |
| 335 | − occurredAt: NOW, | |
| 336 | − commandId: null, | |
| 337 | − causationEventId: null, | |
| 338 | − correlationId: null, | |
| 339 | − metadata: {}, | |
| 340 | − payload: { | |
| 341 | − threadId: current.id, | |
| 342 | − session: { | |
| 316 | + it.effect.each(["session-first", "checkpoint-first"] as const)( | |
| 317 | + "refreshes discovery once after a turn ends with %s event ordering", | |
| 318 | + (order) => | |
| 319 | + Effect.scoped( | |
| 320 | + Effect.gen(function* () { | |
| 321 | + const current = thread("turn-thread"); | |
| 322 | + const detected = yield* Ref.make<GitBranchPullRequest | null>(null); | |
| 323 | + const fixture = yield* makeHarness({ | |
| 324 | + threads: [current], | |
| 325 | + branchPullRequest: (_input, options) => | |
| 326 | + options?.refresh | |
| 327 | + ? Ref.set(detected, branchPullRequest()).pipe(Effect.andThen(Ref.get(detected))) | |
| 328 | + : Ref.get(detected), | |
| 329 | + }); | |
| 330 | + yield* Effect.gen(function* () { | |
| 331 | + const reactor = yield* fixture.start(); | |
| 332 | + expect(yield* Ref.get(fixture.commands)).toHaveLength(0); | |
| 333 | + const sessionEvent: OrchestrationEvent = { | |
| 334 | + type: "thread.session-set", | |
| 335 | + sequence: 2, | |
| 336 | + eventId: EventId.make("turn-finished"), | |
| 337 | + aggregateKind: "thread", | |
| 338 | + aggregateId: current.id, | |
| 339 | + occurredAt: NOW, | |
| 340 | + commandId: null, | |
| 341 | + causationEventId: null, | |
| 342 | + correlationId: null, | |
| 343 | + metadata: {}, | |
| 344 | + payload: { | |
| 343 | 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 | 367 | status: "ready", |
| 345 | − providerName: "Codex", | |
| 346 | − runtimeMode: "full-access", | |
| 347 | − activeTurnId: null, | |
| 348 | − lastError: null, | |
| 349 | − updatedAt: NOW, | |
| 368 | + files: [], | |
| 369 | + assistantMessageId: null, | |
| 370 | + completedAt: NOW, | |
| 350 | 371 | }, |
| 351 | − }, | |
| 352 | − }); | |
| 353 | − yield* Queue.take(fixture.reads); | |
| 354 | − yield* reactor.drain; | |
| 355 | − expect((yield* Ref.get(fixture.commands))[0]?.branchPullRequest).toEqual(reference(42)); | |
| 356 | − expect((yield* Ref.get(fixture.branchCalls)).filter((call) => call.refresh)).toHaveLength( | |
| 357 | − 1, | |
| 358 | − ); | |
| 359 | − }).pipe(Effect.provide(fixture.layer)); | |
| 360 | − }), | |
| 361 | − ), | |
| 372 | + }; | |
| 373 | + const events = | |
| 374 | + order === "session-first" | |
| 375 | + ? [sessionEvent, checkpointEvent] | |
| 376 | + : [checkpointEvent, sessionEvent]; | |
| 377 | + for (const event of events) { | |
| 378 | + yield* fixture.publish(event); | |
| 379 | + yield* Queue.take(fixture.reads); | |
| 380 | + yield* reactor.drain; | |
| 381 | + } | |
| 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 | 391 | it.effect("uses live worktrees and falls back to the project for removed worktrees", () => |
| @@ -455,54 +482,56 @@ describe("ThreadPullRequestReactor", () => { · 90 unmodified lines | ||
| 455 | 482 | ), |
| 456 | 483 | ); |
| 457 | 484 | |
| 458 | − it.effect("retries failed settled backfills and stops querying them after success", () => | |
| 459 | − Effect.scoped( | |
| 460 | − Effect.gen(function* () { | |
| 461 | − const online = yield* Ref.make(false); | |
| 462 | − const fixture = yield* makeHarness({ | |
| 463 | − threads: [ | |
| 464 | − thread("backfill", { settledOverride: "settled", settledAt: NOW }), | |
| 465 | − thread("known", { | |
| 466 | − branch: "known", | |
| 467 | − settledOverride: "settled", | |
| 468 | − settledAt: NOW, | |
| 469 | − branchPullRequest: reference(1), | |
| 470 | − }), | |
| 471 | − ], | |
| 472 | − branchPullRequest: ({ cwd }) => | |
| 473 | − Ref.get(online).pipe( | |
| 474 | − Effect.flatMap((connected) => | |
| 475 | − connected | |
| 476 | − ? Effect.succeed(branchPullRequest(42, "merged")) | |
| 477 | − : Effect.fail( | |
| 478 | − new GitManagerError({ | |
| 479 | − operation: "branchPullRequest", | |
| 480 | − cwd, | |
| 481 | − detail: "Offline", | |
| 482 | − }), | |
| 483 | − ), | |
| 485 | + it.effect.each(["settled", null] as const)( | |
| 486 | + "retries settled backfills and stops after success with settledOverride %s", | |
| 487 | + (settledOverride) => | |
| 488 | + Effect.scoped( | |
| 489 | + Effect.gen(function* () { | |
| 490 | + const online = yield* Ref.make(false); | |
| 491 | + const fixture = yield* makeHarness({ | |
| 492 | + threads: [ | |
| 493 | + thread("backfill", { settledOverride, settledAt: NOW }), | |
| 494 | + thread("known", { | |
| 495 | + branch: "known", | |
| 496 | + settledOverride, | |
| 497 | + settledAt: NOW, | |
| 498 | + branchPullRequest: reference(1), | |
| 499 | + }), | |
| 500 | + ], | |
| 501 | + branchPullRequest: ({ cwd }) => | |
| 502 | + Ref.get(online).pipe( | |
| 503 | + Effect.flatMap((connected) => | |
| 504 | + connected | |
| 505 | + ? Effect.succeed(branchPullRequest(42, "merged")) | |
| 506 | + : Effect.fail( | |
| 507 | + new GitManagerError({ | |
| 508 | + operation: "branchPullRequest", | |
| 509 | + cwd, | |
| 510 | + detail: "Offline", | |
| 511 | + }), | |
| 512 | + ), | |
| 513 | + ), | |
| 484 | 514 | ), |
| 485 | − ), | |
| 486 | − }); | |
| 487 | − yield* Effect.gen(function* () { | |
| 488 | − const reactor = yield* fixture.start(); | |
| 489 | − expect(yield* Ref.get(fixture.commands)).toHaveLength(0); | |
| 490 | − yield* Ref.set(online, true); | |
| 491 | − yield* TestClock.adjust("1 minute"); | |
| 492 | − yield* Queue.take(fixture.reads); | |
| 493 | − yield* reactor.drain; | |
| 494 | − expect((yield* Ref.get(fixture.commands))[0]?.threadId).toBe("backfill"); | |
| 495 | − yield* TestClock.adjust("1 minute"); | |
| 496 | − yield* Queue.take(fixture.reads); | |
| 497 | − yield* reactor.drain; | |
| 498 | − expect((yield* Ref.get(fixture.branchCalls)).map((call) => call.branch)).toEqual([ | |
| 499 | − "feature", | |
| 500 | − "feature", | |
| 501 | − "feature", | |
| 502 | − ]); | |
| 503 | − }).pipe(Effect.provide(fixture.layer)); | |
| 504 | − }), | |
| 505 | − ), | |
| 515 | + }); | |
| 516 | + yield* Effect.gen(function* () { | |
| 517 | + const reactor = yield* fixture.start(); | |
| 518 | + expect(yield* Ref.get(fixture.commands)).toHaveLength(0); | |
| 519 | + yield* Ref.set(online, true); | |
| 520 | + yield* TestClock.adjust("1 minute"); | |
| 521 | + yield* Queue.take(fixture.reads); | |
| 522 | + yield* reactor.drain; | |
| 523 | + expect((yield* Ref.get(fixture.commands))[0]?.threadId).toBe("backfill"); | |
| 524 | + yield* TestClock.adjust("1 minute"); | |
| 525 | + yield* Queue.take(fixture.reads); | |
| 526 | + yield* reactor.drain; | |
| 527 | + expect((yield* Ref.get(fixture.branchCalls)).map((call) => call.branch)).toEqual([ | |
| 528 | + "feature", | |
| 529 | + "feature", | |
| 530 | + "feature", | |
| 531 | + ]); | |
| 532 | + }).pipe(Effect.provide(fixture.layer)); | |
| 533 | + }), | |
| 534 | + ), | |
| 506 | 535 | ); |
| 507 | 536 | |
| 508 | 537 | it.effect("stops retrying a settled backfill after repeated lookup failures", () => |
| @@ -101,7 +101,10 @@ export const make = Effect.gen(function* () { · 100 unmodified lines | ||
| 101 | 101 | const projects = new Map(snapshot.projects.map((project) => [project.id, project])); |
| 102 | 102 | if (request.backfill) { |
| 103 | 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 | 108 | pendingBackfill.set(thread.id, BACKFILL_ATTEMPTS); |
| 106 | 109 | } |
| 107 | 110 | } |
| @@ -114,7 +117,7 @@ export const make = Effect.gen(function* () { · 6 unmodified lines | ||
| 114 | 117 | (thread) => |
| 115 | 118 | thread.archivedAt === null && |
| 116 | 119 | (request.threadId === null || thread.id === request.threadId) && |
| 117 | − (thread.settledOverride !== "settled" || | |
| 120 | + ((thread.settledOverride !== "settled" && thread.settledAt === null) || | |
| 118 | 121 | request.threadId !== null || |
| 119 | 122 | pendingBackfill.has(thread.id)) && |
| 120 | 123 | (thread.branch !== null || thread.branchPullRequest != null), |
| @@ -327,7 +330,9 @@ export const make = Effect.gen(function* () { · 206 unmodified lines | ||
| 327 | 330 | event.payload.session.status !== "running" && |
| 328 | 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 | 337 | break; |
| 333 | 338 | case "thread.turn-diff-completed": |
| @@ -1,6 +1,7 @@ | ||
| 1 | 1 | import { afterEach, assert, expect, it, vi } from "@effect/vitest"; |
| 2 | 2 | import * as Effect from "effect/Effect"; |
| 3 | 3 | import * as Layer from "effect/Layer"; |
| 4 | +import * as Schema from "effect/Schema"; | |
| 4 | 5 | import * as TestClock from "effect/testing/TestClock"; |
| 5 | 6 | import { ChildProcessSpawner } from "effect/unstable/process"; |
| 6 | 7 | |
| @@ -9,14 +10,22 @@ import * as GitHubGraphQlBudget from "../sourceControl/githubGraphQlBudget.ts"; · 2 unmodified lines | ||
| 9 | 10 | import * as GitHubPullRequestCli from "./GitHubPullRequestCli.ts"; |
| 10 | 11 | import { BASE_COMPARISON_GRAPHQL_QUERY } from "./gitHubPullRequestJson.ts"; |
| 11 | 12 | |
| 13 | +const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); | |
| 14 | + | |
| 12 | 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 | 19 | const mockedGetPullRequest = vi.fn<GitHubCli.GitHubCli["Service"]["getPullRequest"]>(); |
| 14 | 20 | |
| 15 | 21 | const layer = it.layer( |
| 16 | 22 | GitHubPullRequestCli.layer.pipe( |
| 17 | 23 | Layer.provide( |
| 18 | 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 | 29 | getPullRequest: mockedGetPullRequest, |
| 21 | 30 | }), |
| 22 | 31 | ), |
| @@ -179,6 +188,7 @@ function searchQueryOfCall(index: number): string | undefined { · 156 unmodified lines | ||
| 179 | 188 | |
| 180 | 189 | afterEach(() => { |
| 181 | 190 | mockedExecute.mockReset(); |
| 191 | + mockedStackMemberships.mockReset(); | |
| 182 | 192 | mockedGetPullRequest.mockReset(); |
| 183 | 193 | }); |
| 184 | 194 | |
| @@ -320,6 +330,51 @@ layer("GitHubPullRequestCli.layer", (it) => { · 135 unmodified lines | ||
| 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 | 378 | it.effect("reads an empty stacks listing as not stacked", () => |
| 324 | 379 | Effect.gen(function* () { |
| 325 | 380 | mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]"))); |
| @@ -715,6 +770,127 @@ layer("GitHubPullRequestCli.layer", (it) => { · 389 unmodified lines | ||
| 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 | 894 | it.effect("reads the line counts in chunks, and files them back by position", () => |
| 719 | 895 | Effect.gen(function* () { |
| 720 | 896 | const changeRequests = Array.from({ length: 26 }, (_, index) => ({ |
| @@ -1,3 +1,4 @@ | ||
| 1 | +import { runGitHubStackAction, type GitHubStackActionError } from "./githubStackActions.ts"; | |
| 1 | 2 | import * as Context from "effect/Context"; |
| 2 | 3 | import * as Effect from "effect/Effect"; |
| 3 | 4 | import * as Layer from "effect/Layer"; |
| @@ -6,6 +7,7 @@ import * as Schema from "effect/Schema"; · 2 unmodified lines | ||
| 6 | 7 | import { |
| 7 | 8 | resolvePullRequestAuthorFilter, |
| 8 | 9 | type PullRequestAction, |
| 10 | + type PullRequestStackHead, | |
| 9 | 11 | type PullRequestActor, |
| 10 | 12 | type PullRequestInvolvement, |
| 11 | 13 | type PullRequestListFilters, |
| @@ -51,6 +53,8 @@ import { · 39 unmodified lines | ||
| 51 | 53 | decodeReviewThreadCommentsJson, |
| 52 | 54 | decodeReviewThreadsJson, |
| 53 | 55 | buildPullRequestStatsGraphQlQuery, |
| 56 | + buildPullRequestStackMembershipsGraphQlQuery, | |
| 57 | + decodePullRequestStackMembershipsJson, | |
| 54 | 58 | encodeGraphQlRequestJson, |
| 55 | 59 | pullRequestSearchGraphQlQuery, |
| 56 | 60 | PULL_REQUEST_SEARCH_MAX_ROWS, |
| @@ -334,6 +338,7 @@ export class GitHubWorkflowApprovalHeadChangedError extends Schema.TaggedError<G · 277 unmodified lines | ||
| 334 | 338 | } |
| 335 | 339 | |
| 336 | 340 | export type GitHubPullRequestCliError = |
| 341 | + | GitHubStackActionError | |
| 337 | 342 | | GitHubCli.GitHubCliError |
| 338 | 343 | | GitHubPullRequestReadError |
| 339 | 344 | | GitHubDiffCursorError |
| @@ -488,6 +493,7 @@ export class GitHubPullRequestCli extends Context.Service< · 148 unmodified lines | ||
| 488 | 493 | */ |
| 489 | 494 | readonly getPullRequestStack: (input: { |
| 490 | 495 | readonly cwd: string; |
| 496 | + readonly includeDetails?: boolean; | |
| 491 | 497 | readonly repository: string; |
| 492 | 498 | readonly host: string; |
| 493 | 499 | readonly number: number; |
| @@ -627,6 +633,8 @@ export class GitHubPullRequestCli extends Context.Service< · 133 unmodified lines | ||
| 627 | 633 | readonly host: string; |
| 628 | 634 | readonly number: number; |
| 629 | 635 | readonly action: PullRequestAction; |
| 636 | + readonly stackNumber?: number; | |
| 637 | + readonly expectedStackHeads?: ReadonlyArray<PullRequestStackHead>; | |
| 630 | 638 | readonly mergeMethod?: PullRequestMergeMethod; |
| 631 | 639 | readonly updateMethod?: PullRequestUpdateMethod; |
| 632 | 640 | }) => Effect.Effect<void, GitHubPullRequestCliError>; |
| @@ -1557,6 +1565,47 @@ export const make = Effect.gen(function* () { · 924 unmodified lines | ||
| 1557 | 1565 | ? read(false) |
| 1558 | 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* () { · 18 unmodified lines | ||
| 1581 | 1630 | operation: "searchPullRequests", |
| 1582 | 1631 | // The reader's own words are in the query, so it travels over stdin rather than in argv. |
| 1583 | 1632 | privateVariables: { q: query }, |
| 1584 | − query: pullRequestSearchGraphQlQuery(rows), | |
| 1633 | + query: pullRequestSearchGraphQlQuery(rows, input.host === "github.com"), | |
| 1585 | 1634 | decode: decodePullRequestSearchJson, |
| 1586 | 1635 | }).pipe( |
| 1587 | 1636 | Effect.map((batch) => ({ |
| @@ -1709,6 +1758,34 @@ export const make = Effect.gen(function* () { · 121 unmodified lines | ||
| 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 | 1789 | // Hosts without the stacks preview return 404. Other failures must preserve the |
| 1713 | 1790 | // previously synced stack and let the caller retry. |
| 1714 | 1791 | Effect.catchTags({ |
| @@ -2109,6 +2186,10 @@ export const make = Effect.gen(function* () { · 394 unmodified lines | ||
| 2109 | 2186 | }, |
| 2110 | 2187 | |
| 2111 | 2188 | runPullRequestAction: (input) => { |
| 2189 | + if (input.stackNumber !== undefined) | |
| 2190 | + return runGitHubStackAction({ ...input, stackNumber: input.stackNumber }).pipe( | |
| 2191 | + Effect.provideService(GitHubCli.GitHubCli, github), | |
| 2192 | + ); | |
| 2112 | 2193 | if (input.action === "revert") { |
| 2113 | 2194 | return pullRequestNodeId({ ...input, operation: "revertPullRequest" }).pipe( |
| 2114 | 2195 | Effect.flatMap((pullRequestId) => |
| @@ -130,6 +130,7 @@ describe("gitHubViewerPermissions", () => { · 129 unmodified lines | ||
| 130 | 130 | ], |
| 131 | 131 | comment: true, |
| 132 | 132 | resolve: true, |
| 133 | + stackRebase: true, | |
| 133 | 134 | verdicts: ["comment", "approve", "request-changes"], |
| 134 | 135 | requestReviewers: true, |
| 135 | 136 | labels: true, |
| @@ -48,6 +48,7 @@ const CAPABILITIES: PullRequestCapabilities = { · 47 unmodified lines | ||
| 48 | 48 | reviewers: { request: true, listCandidates: true }, |
| 49 | 49 | edit: { changeRequest: true, comment: true }, |
| 50 | 50 | stacks: true, |
| 51 | + stackActions: true, | |
| 51 | 52 | labels: true, |
| 52 | 53 | }; |
| 53 | 54 | |
| @@ -70,6 +71,7 @@ const CAPABILITIES: PullRequestCapabilities = { · 16 unmodified lines | ||
| 70 | 71 | */ |
| 71 | 72 | export function gitHubViewerPermissions(access: GitHubViewerAccess): PullRequestViewerPermissions { |
| 72 | 73 | return { |
| 74 | + ...(access.canWrite ? { stackRebase: true } : {}), | |
| 73 | 75 | actions: [ |
| 74 | 76 | // Arming a merge and taking the arming back are the merge, deferred: whoever may not |
| 75 | 77 | // merge here may not leave an instruction to merge later either. |
| @@ -558,6 +560,10 @@ export const make = Effect.gen(function* () { · 482 unmodified lines | ||
| 558 | 560 | host: input.host, |
| 559 | 561 | number: input.number, |
| 560 | 562 | action: input.action, |
| 563 | + ...(input.stackNumber === undefined ? {} : { stackNumber: input.stackNumber }), | |
| 564 | + ...(input.expectedStackHeads === undefined | |
| 565 | + ? {} | |
| 566 | + : { expectedStackHeads: input.expectedStackHeads }), | |
| 561 | 567 | ...(input.mergeMethod === undefined ? {} : { mergeMethod: input.mergeMethod }), |
| 562 | 568 | ...(input.updateMethod === undefined ? {} : { updateMethod: input.updateMethod }), |
| 563 | 569 | }) |
| @@ -1,7 +1,9 @@ | ||
| 1 | 1 | import * as Effect from "effect/Effect"; |
| 2 | 2 | import * as Schema from "effect/Schema"; |
| 3 | 3 | import type { |
| 4 | + PullRequestStackMembership, | |
| 4 | 5 | PullRequestAction, |
| 6 | + PullRequestStackHead, | |
| 5 | 7 | PullRequestActor, |
| 6 | 8 | PullRequestBaseComparison, |
| 7 | 9 | PullRequestCapabilities, |
| @@ -65,6 +67,7 @@ export interface PullRequestProviderFailure { · 57 unmodified lines | ||
| 65 | 67 | |
| 66 | 68 | /** A change request as the provider sees it, before the service attaches project context. */ |
| 67 | 69 | export interface ProviderChangeRequest { |
| 70 | + readonly stack?: PullRequestStackMembership; | |
| 68 | 71 | readonly number: number; |
| 69 | 72 | readonly title: string; |
| 70 | 73 | readonly url: string; |
| @@ -115,6 +118,9 @@ export interface ProviderChangeRequestSummary { · 44 unmodified lines | ||
| 115 | 118 | |
| 116 | 119 | /** One layer of a host-native stack, bottom to top order is the array's. */ |
| 117 | 120 | export interface ProviderChangeRequestStackLayer { |
| 121 | + readonly title?: string; | |
| 122 | + readonly isDraft?: boolean; | |
| 123 | + readonly headSha?: string; | |
| 118 | 124 | readonly number: number; |
| 119 | 125 | readonly headBranch: string; |
| 120 | 126 | readonly state: PullRequestState; |
| @@ -364,7 +370,7 @@ export interface PullRequestProviderApi { · 243 unmodified lines | ||
| 364 | 370 | * because most hosts have no such object; the service derives chains from base branches there. |
| 365 | 371 | */ |
| 366 | 372 | readonly getChangeRequestStack?: ( |
| 367 | − input: ProviderRepositoryRef & { readonly number: number }, | |
| 373 | + input: ProviderRepositoryRef & { readonly includeDetails?: boolean; readonly number: number }, | |
| 368 | 374 | ) => Effect.Effect<ProviderChangeRequestStack | null, PullRequestProviderError>; |
| 369 | 375 | |
| 370 | 376 | /** Comments, line threads, and commits, kept off the critical path for the core detail. */ |
| @@ -426,6 +432,8 @@ export interface PullRequestProviderApi { · 55 unmodified lines | ||
| 426 | 432 | input: ProviderRepositoryRef & { |
| 427 | 433 | readonly number: number; |
| 428 | 434 | readonly action: PullRequestAction; |
| 435 | + readonly stackNumber?: number; | |
| 436 | + readonly expectedStackHeads?: ReadonlyArray<PullRequestStackHead>; | |
| 429 | 437 | /** Meaningful for `merge` and `enable-auto-merge`; absent takes the host's own default. */ |
| 430 | 438 | readonly mergeMethod?: PullRequestMergeMethod; |
| 431 | 439 | /** Only meaningful for `update-branch`; absent takes the host's own default. */ |
| @@ -1564,6 +1564,48 @@ it.effect("refuses a repository that does not belong to the requested project", · 1563 unmodified lines | ||
| 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 | 1609 | it.effect("reads a host-native stack through the provider and null where it has none", () => |
| 1568 | 1610 | Effect.gen(function* () { |
| 1569 | 1611 | const service = yield* makeService({ |
| @@ -3997,6 +4039,91 @@ it.effect('resolves an author filter of "me" to the viewer before narrowing a ho · 2427 unmodified lines | ||
| 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 | 4127 | it.effect("refuses a way of updating a branch that the host or the viewer does not allow", () => |
| 4001 | 4128 | Effect.gen(function* () { |
| 4002 | 4129 | let taken: string | null = null; |
| @@ -155,6 +155,7 @@ export class PullRequestService extends Context.Service< · 154 unmodified lines | ||
| 155 | 155 | */ |
| 156 | 156 | readonly stack: ( |
| 157 | 157 | input: PullRequestRef, |
| 158 | + options?: { readonly includeDetails?: boolean }, | |
| 158 | 159 | ) => Effect.Effect<PullRequestStack | null, PullRequestError>; |
| 159 | 160 | readonly subscribeMerges: Effect.Effect< |
| 160 | 161 | Stream.Stream<PullRequestMergeEvent>, |
| @@ -907,6 +908,7 @@ export const make = Effect.gen(function* () { · 746 unmodified lines | ||
| 907 | 908 | }): PullRequestListEntry => { |
| 908 | 909 | const viewer = input.viewer.toLowerCase(); |
| 909 | 910 | return { |
| 911 | + ...(input.item.stack === undefined ? {} : { stack: input.item.stack }), | |
| 910 | 912 | provider: input.project.api.kind, |
| 911 | 913 | host: input.project.host, |
| 912 | 914 | projectId: input.project.project.id, |
| @@ -1331,7 +1333,7 @@ export const make = Effect.gen(function* () { · 418 unmodified lines | ||
| 1331 | 1333 | }), |
| 1332 | 1334 | ); |
| 1333 | 1335 | |
| 1334 | − const stackUncached: PullRequestService["Service"]["stack"] = (input) => | |
| 1336 | + const stackUncached: PullRequestService["Service"]["stack"] = (input, options) => | |
| 1335 | 1337 | requireProject(input).pipe( |
| 1336 | 1338 | Effect.flatMap((project) => { |
| 1337 | 1339 | const read = project.api.getChangeRequestStack; |
| @@ -1341,6 +1343,7 @@ export const make = Effect.gen(function* () { · 3 unmodified lines | ||
| 1341 | 1343 | repository: project.repository, |
| 1342 | 1344 | host: project.host, |
| 1343 | 1345 | number: input.number, |
| 1346 | + includeDetails: options?.includeDetails !== false, | |
| 1344 | 1347 | }).pipe( |
| 1345 | 1348 | Effect.mapError(toPullRequestError("stack")), |
| 1346 | 1349 | Effect.map((stack): PullRequestStack | null => |
| @@ -1352,6 +1355,7 @@ export const make = Effect.gen(function* () { · 5 unmodified lines | ||
| 1352 | 1355 | url: stack.url, |
| 1353 | 1356 | base: stack.base, |
| 1354 | 1357 | layers: stack.layers.map((layer) => ({ |
| 1358 | + ...layer, | |
| 1355 | 1359 | number: layer.number, |
| 1356 | 1360 | headBranch: layer.headBranch, |
| 1357 | 1361 | state: layer.state, |
| @@ -1531,6 +1535,20 @@ export const make = Effect.gen(function* () { · 173 unmodified lines | ||
| 1531 | 1535 | const runAction = (input: PullRequestActionInput): Effect.Effect<string, PullRequestError> => |
| 1532 | 1536 | requireProject(input).pipe( |
| 1533 | 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 | 1552 | // The surface hides what a host cannot do, and this refuses it as well: a request that |
| 1535 | 1553 | // reached here anyway must not be handed to a provider that never claimed the action. |
| 1536 | 1554 | if (!project.api.capabilities.actions.includes(input.action)) { |
| @@ -1573,7 +1591,10 @@ export const make = Effect.gen(function* () { · 36 unmodified lines | ||
| 1573 | 1591 | // above do not. |
| 1574 | 1592 | return viewerPermissionsOf(project, input, "runAction").pipe( |
| 1575 | 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 | 1598 | return Effect.fail( |
| 1578 | 1599 | new PullRequestOperationError({ |
| 1579 | 1600 | operation: "runAction", |
| @@ -1582,6 +1603,7 @@ export const make = Effect.gen(function* () { · 2 unmodified lines | ||
| 1582 | 1603 | ); |
| 1583 | 1604 | } |
| 1584 | 1605 | if ( |
| 1606 | + !stackRebase && | |
| 1585 | 1607 | input.updateMethod !== undefined && |
| 1586 | 1608 | !(viewer.updateMethods ?? []).includes(input.updateMethod) |
| 1587 | 1609 | ) { |
| @@ -1599,10 +1621,17 @@ export const make = Effect.gen(function* () { · 11 unmodified lines | ||
| 1599 | 1621 | host: project.host, |
| 1600 | 1622 | number: input.number, |
| 1601 | 1623 | action: input.action, |
| 1624 | + ...(input.stackNumber === undefined ? {} : { stackNumber: input.stackNumber }), | |
| 1625 | + ...(input.expectedStackHeads === undefined | |
| 1626 | + ? {} | |
| 1627 | + : { expectedStackHeads: input.expectedStackHeads }), | |
| 1602 | 1628 | ...(input.mergeMethod === undefined ? {} : { mergeMethod: input.mergeMethod }), |
| 1603 | 1629 | ...(input.updateMethod === undefined ? {} : { updateMethod: input.updateMethod }), |
| 1604 | 1630 | }) |
| 1605 | 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 | 1635 | Effect.mapError(toPullRequestError("runAction")), |
| 1607 | 1636 | Effect.as( |
| 1608 | 1637 | project.api.kind === "azure-devops" |
| @@ -2327,12 +2356,18 @@ export const make = Effect.gen(function* () { · 718 unmodified lines | ||
| 2327 | 2356 | ); |
| 2328 | 2357 | }; |
| 2329 | 2358 | |
| 2330 | − const stackCache = yield* Cache.makeWith((key: string) => stackUncached(refOfCacheKey(key)), { | |
| 2331 | − capacity: DETAIL_CACHE_CAPACITY, | |
| 2332 | − timeToLive: (exit) => (Exit.isSuccess(exit) ? SUMMARY_CACHE_TTL : Duration.zero), | |
| 2333 | − }); | |
| 2334 | − const stack: PullRequestService["Service"]["stack"] = (input) => | |
| 2335 | − Cache.get(stackCache, refCacheKey(input)); | |
| 2359 | + const stackCache = yield* Cache.makeWith( | |
| 2360 | + (key: string) => { | |
| 2361 | + const [referenceKey, includeDetails] = JSON.parse(key) as [string, boolean]; | |
| 2362 | + return stackUncached(refOfCacheKey(referenceKey), { includeDetails }); | |
| 2363 | + }, | |
| 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 | 2372 | // Keys serialize positionally and parse back in the lookup, so the cache is the only holder |
| 2338 | 2373 | // of in-flight state: concurrent identical reads coalesce on the key into one host request. |
| @@ -3,6 +3,8 @@ import { describe, expect, it } from "vite-plus/test"; · 2 unmodified lines | ||
| 3 | 3 | |
| 4 | 4 | import { |
| 5 | 5 | buildReviewSubmissionJson, |
| 6 | + buildPullRequestStackMembershipsGraphQlQuery, | |
| 7 | + decodePullRequestStackMembershipsJson, | |
| 6 | 8 | buildReviewerRequestJson, |
| 7 | 9 | decodeBaseComparisonJson, |
| 8 | 10 | decodePullRequestActivityJson, |
| @@ -21,6 +23,7 @@ import { · 12 unmodified lines | ||
| 21 | 23 | decodeWorkflowRunApprovalsJson, |
| 22 | 24 | reviewThreadConversation, |
| 23 | 25 | REVIEW_THREADS_GRAPHQL_QUERY, |
| 26 | + pullRequestSearchGraphQlQuery, | |
| 24 | 27 | } from "./gitHubPullRequestJson.ts"; |
| 25 | 28 | |
| 26 | 29 | function listJson(entries: ReadonlyArray<Record<string, unknown>>): string { |
| @@ -165,6 +168,17 @@ describe("pull request search decoding", () => { · 138 unmodified lines | ||
| 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 | 182 | it("maps the rollup enum the search answers with onto the same three words", () => { |
| 169 | 183 | // The search asks GitHub for the verdict rather than the checks behind it, so this path sees |
| 170 | 184 | // one enum where the listing sees an array. |
| @@ -1548,6 +1562,32 @@ describe("host-native stack decoding", () => { · 1377 unmodified lines | ||
| 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 | 1591 | it("accepts a base named as a bare branch, which is what the preview started out sending", () => { |
| 1552 | 1592 | expect(expectStack({ base: "develop" }).base).toBe("develop"); |
| 1553 | 1593 | }); |
| @@ -1579,3 +1619,38 @@ describe("host-native stack decoding", () => { · 25 unmodified lines | ||
| 1579 | 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 | +}); | |
| @@ -3,6 +3,7 @@ import * as Exit from "effect/Exit"; · 2 unmodified lines | ||
| 3 | 3 | import * as Result from "effect/Result"; |
| 4 | 4 | import * as Schema from "effect/Schema"; |
| 5 | 5 | import type { |
| 6 | + PullRequestStackMembership, | |
| 6 | 7 | PullRequestActor, |
| 7 | 8 | PullRequestCheck, |
| 8 | 9 | PullRequestCheckStatus, |
| @@ -111,12 +112,22 @@ const RawListItemSchema = Schema.Struct({ · 102 unmodified lines | ||
| 111 | 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 | 125 | * A search's own answer, which is the listing's row one connection deeper: `gh pr list --json` |
| 116 | 126 | * flattens reviewers and labels, and GraphQL does not. Everything below the row is optional |
| 117 | 127 | * because a node that is not a pull request decodes as an empty object, which is skipped. |
| 118 | 128 | */ |
| 119 | 129 | const RawSearchItemSchema = Schema.Struct({ |
| 130 | + ...RawStackMembershipSchema.fields, | |
| 120 | 131 | number: Schema.Int, |
| 121 | 132 | title: Schema.String, |
| 122 | 133 | url: Schema.String, |
| @@ -221,6 +232,13 @@ const RawStatsSchema = Schema.Struct({ · 98 unmodified lines | ||
| 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 | 242 | /** How many of a reaction's people the hover names before it counts the rest. */ |
| 225 | 243 | const REACTORS_PER_GROUP = 10; |
| 226 | 244 | |
| @@ -648,12 +666,13 @@ export const PULL_REQUEST_SEARCH_MAX_ROWS = GRAPHQL_PAGE_SIZE; · 421 unmodified lines | ||
| 648 | 666 | * than twenty labels shows twenty, and one that has asked more than twenty people for a review |
| 649 | 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 | 670 | return `query($q: String!) { |
| 653 | 671 | search(query: $q, type: ISSUE, first: ${Math.min(Math.max(Math.trunc(rows), 1), PULL_REQUEST_SEARCH_MAX_ROWS)}) { |
| 654 | 672 | pageInfo { hasNextPage } |
| 655 | 673 | nodes { |
| 656 | 674 | ... on PullRequest { |
| 675 | + ${includeStacks ? "stack { number size baseRefName } stackEntry { position }" : ""} | |
| 657 | 676 | number |
| 658 | 677 | title |
| 659 | 678 | url |
| @@ -1019,6 +1038,7 @@ export const REPOSITORY_ACCESS_JSON_FIELDS = · 359 unmodified lines | ||
| 1019 | 1038 | "mergeCommitAllowed,squashMergeAllowed,rebaseMergeAllowed,viewerPermission"; |
| 1020 | 1039 | |
| 1021 | 1040 | export interface GitHubPullRequestListItem { |
| 1041 | + readonly stack?: PullRequestStackMembership; | |
| 1022 | 1042 | /** The author's node id, kept so a batch can resolve the avatar the listing does not carry. */ |
| 1023 | 1043 | readonly authorId: string | null; |
| 1024 | 1044 | readonly number: number; |
| @@ -1526,6 +1546,7 @@ export function decodePullRequestSearchJson( · 501 unmodified lines | ||
| 1526 | 1546 | const node = decodedNode.value; |
| 1527 | 1547 | const repository = trimmed(node.repository?.nameWithOwner); |
| 1528 | 1548 | if (repository === null) continue; |
| 1549 | + const stack = toStackMembership(node); | |
| 1529 | 1550 | items.push({ |
| 1530 | 1551 | ...toListItem({ |
| 1531 | 1552 | ...node, |
| @@ -1541,6 +1562,7 @@ export function decodePullRequestSearchJson( · 9 unmodified lines | ||
| 1541 | 1562 | return state === null ? [] : [{ state }]; |
| 1542 | 1563 | }), |
| 1543 | 1564 | }), |
| 1565 | + ...(stack === undefined ? {} : { stack }), | |
| 1544 | 1566 | repository, |
| 1545 | 1567 | }); |
| 1546 | 1568 | } |
| @@ -1551,6 +1573,19 @@ export function decodePullRequestSearchJson( · 4 unmodified lines | ||
| 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 | 1589 | /** What a repository selector may hold before it is written into a GraphQL document unquoted. */ |
| 1555 | 1590 | const REPOSITORY_PART = /^[A-Za-z0-9._-]+$/; |
| 1556 | 1591 | |
| @@ -1581,6 +1616,42 @@ export function buildPullRequestStatsGraphQlQuery( · 24 unmodified lines | ||
| 1581 | 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 | 1656 | * The counts by the position they were asked in. A repository or a pull request GitHub answered |
| 1586 | 1657 | * nothing for is simply absent, which leaves the row with whatever it already had. |
| @@ -2448,8 +2519,10 @@ export function decodePullRequestFilesJson( · 861 unmodified lines | ||
| 2448 | 2519 | |
| 2449 | 2520 | /** One pull request as the stacks API lists it: a number, a head, and whether it is done. */ |
| 2450 | 2521 | const RawStackPullRequestSchema = Schema.Struct({ |
| 2522 | + title: Schema.optional(Schema.String), | |
| 2523 | + draft: Schema.optional(Schema.Boolean), | |
| 2451 | 2524 | number: Schema.Int, |
| 2452 | − head: Schema.Struct({ ref: Schema.String }), | |
| 2525 | + head: Schema.Struct({ ref: Schema.String, sha: Schema.optional(Schema.String) }), | |
| 2453 | 2526 | state: Schema.optional(Schema.NullOr(Schema.String)), |
| 2454 | 2527 | merged_at: Schema.optional(Schema.NullOr(Schema.String)), |
| 2455 | 2528 | }); |
| @@ -2473,6 +2546,9 @@ const RawStackSchema = Schema.Struct({ · 17 unmodified lines | ||
| 2473 | 2546 | const decodeStacks = decodeJsonResult(Schema.Array(RawStackSchema)); |
| 2474 | 2547 | |
| 2475 | 2548 | export interface GitHubPullRequestStackLayer { |
| 2549 | + readonly title?: string; | |
| 2550 | + readonly isDraft?: boolean; | |
| 2551 | + readonly headSha?: string; | |
| 2476 | 2552 | readonly number: number; |
| 2477 | 2553 | readonly headBranch: string; |
| 2478 | 2554 | readonly state: PullRequestState; |
| @@ -2505,6 +2581,9 @@ export function decodePullRequestStacksJson( · 26 unmodified lines | ||
| 2505 | 2581 | url: trimmed(stack.html_url) ?? stack.url, |
| 2506 | 2582 | base: typeof stack.base === "string" ? stack.base : stack.base.ref, |
| 2507 | 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 | 2587 | number: pullRequest.number, |
| 2509 | 2588 | headBranch: pullRequest.head.ref, |
| 2510 | 2589 | state: toState({ state: pullRequest.state, mergedAt: pullRequest.merged_at }), |
| @@ -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 | +); | |
| @@ -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 | +}); | |
| @@ -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 | 8 | // Tiny event bus allowing components to programmatically open the command palette |
| 2 | 9 | // without owning its React state. |
| 3 | 10 | const COMMAND_PALETTE_OPEN_EVENT = "t3code:open-command-palette"; |
| 4 | 11 | |
| 5 | 12 | export interface CommandPaletteOpenDetail { |
| 6 | 13 | readonly open?: "add-project" | "new-thread-in"; |
| 14 | + readonly query?: string; | |
| 15 | + readonly linkedThreads?: CommandPaletteLinkedThreads; | |
| 7 | 16 | } |
| 8 | 17 | |
| 9 | 18 | export function openCommandPalette(detail?: CommandPaletteOpenDetail): void { |
| @@ -1,3 +1,6 @@ | ||
| 1 | +import { useSupportsMultiplePullRequests } from "~/hooks/useSupportsMultiplePullRequests"; | |
| 2 | +import { resolveThreadCurrentPullRequestLink } from "@t3tools/shared/threadPullRequests"; | |
| 3 | +import { useRightPanelStore } from "../rightPanelStore"; | |
| 1 | 4 | import { RefreshIcon } from "~/components/ui/refresh-icon"; |
| 2 | 5 | import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment"; |
| 3 | 6 | import { |
| @@ -47,7 +50,12 @@ import { · 43 unmodified lines | ||
| 47 | 50 | sanitizeNewRefName, |
| 48 | 51 | shouldIncludeBranchPickerItem, |
| 49 | 52 | } from "./BranchToolbar.logic"; |
| 50 | −import { ChangeRequestStatusIcon, prStatusIndicator } from "./ThreadStatusIndicators"; | |
| 53 | +import { | |
| 54 | + ThreadPullRequestBadgeControl, | |
| 55 | + prStatusIndicator, | |
| 56 | + resolveThreadPullRequestBadge, | |
| 57 | + useLinkedThreadPullRequest, | |
| 58 | +} from "./ThreadStatusIndicators"; | |
| 51 | 59 | import { Button } from "./ui/button"; |
| 52 | 60 | import { Switch } from "./ui/switch"; |
| 53 | 61 | import { getVirtualizedScrollFadeClassName } from "./ui/scroll-area"; |
| @@ -612,7 +620,7 @@ export function BranchToolbarBranchSelector({ · 558 unmodified lines | ||
| 612 | 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 | 624 | const branchPrBranch = resolveBranchToolbarPrBranch({ |
| 617 | 625 | activeThreadBranch, |
| 618 | 626 | resolvedActiveBranch, |
| @@ -621,12 +629,27 @@ export function BranchToolbarBranchSelector({ · 2 unmodified lines | ||
| 621 | 629 | branchPrBranch !== null && branchStatusQuery.data?.refName === branchPrBranch |
| 622 | 630 | ? (branchStatusQuery.data.pr ?? null) |
| 623 | 631 | : null; |
| 624 | − const branchPrStatus = prStatusIndicator(branchPr, branchStatusQuery.data?.sourceControlProvider); | |
| 625 | − // Action-oriented tooltip (the pill opens the PR), distinct from the sidebar's | |
| 626 | − // state-description tooltip. | |
| 627 | − const branchPrTooltip = branchPr | |
| 628 | − ? `Open ${sourceControlPresentation.terminology.singular} #${branchPr.number} (${branchPr.state})` | |
| 629 | − : ""; | |
| 632 | + const supportsMultiplePullRequests = useSupportsMultiplePullRequests(environmentId); | |
| 633 | + const linkedStatus = useLinkedThreadPullRequest( | |
| 634 | + environmentId, | |
| 635 | + serverThread?.linkedPullRequest, | |
| 636 | + true, | |
| 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 | 653 | const openPrLink = useOpenPrLink(threadRef); |
| 631 | 654 | |
| 632 | 655 | function renderPickerItem(itemValue: string, index: number) { |
| @@ -730,41 +753,17 @@ export function BranchToolbarBranchSelector({ · 97 unmodified lines | ||
| 730 | 753 | className={cn("flex min-w-0 items-center gap-1", className)} |
| 731 | 754 | data-composer-context-control |
| 732 | 755 | > |
| 733 | − {branchPr && branchPrStatus ? ( | |
| 734 | − <Tooltip> | |
| 735 | − <TooltipTrigger | |
| 736 | − render={ | |
| 737 | − <button | |
| 738 | − type="button" | |
| 739 | − aria-label={branchPrTooltip} | |
| 740 | − onClick={(event) => openPrLink(event, branchPrStatus.url)} | |
| 741 | − className={cn( | |
| 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", | |
| 743 | − branchPrStatus.colorClass, | |
| 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} | |
| 756 | + <ThreadPullRequestBadgeControl | |
| 757 | + variant="ghost" | |
| 758 | + badge={prBadge} | |
| 759 | + number={prNumber} | |
| 760 | + url={prUrl} | |
| 761 | + status={displayedPrStatus} | |
| 762 | + onOpenStack={() => useRightPanelStore.getState().open(threadRef, "pull-requests")} | |
| 763 | + onOpenPullRequest={(event) => { | |
| 764 | + if (prUrl) openPrLink(event, prUrl); | |
| 765 | + }} | |
| 766 | + /> | |
| 768 | 767 | {/* Context menu lives on the wrapper: the disabled Button has |
| 769 | 768 | pointer-events-none, so the trigger itself never sees right-clicks |
| 770 | 769 | while refs are loading or a branch action is pending. */} |
| @@ -8054,6 +8054,15 @@ export default function ChatView(props: ChatViewProps) { · 8053 unmodified lines | ||
| 8054 | 8054 | <PullRequestDetailPanel |
| 8055 | 8055 | key={`${renderedRightPanelSurface.host ?? ""}:${renderedRightPanelSurface.repository}#${renderedRightPanelSurface.number}`} |
| 8056 | 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 | 8066 | threadRef={activeThreadRef} |
| 8058 | 8067 | reference={{ |
| 8059 | 8068 | projectId: renderedRightPanelSurface.projectId as ProjectId, |
| @@ -5,6 +5,7 @@ import { · 4 unmodified lines | ||
| 5 | 5 | buildBrowseGroups, |
| 6 | 6 | buildProjectActionItems, |
| 7 | 7 | buildThreadActionItems, |
| 8 | + buildLinkedThreadActionItems, | |
| 8 | 9 | enumerateCommandPaletteItems, |
| 9 | 10 | filterPinnedBrowseEntries, |
| 10 | 11 | filterCommandPaletteGroups, |
| @@ -12,6 +13,48 @@ import { · 1 unmodified line | ||
| 12 | 13 | type CommandPaletteGroup, |
| 13 | 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 | 58 | describe("reduceCommandPaletteUiState", () => { |
| 16 | 59 | const closedState = { open: false, mode: "command", openIntent: null } as const; |
| 17 | 60 | |
| @@ -47,6 +90,32 @@ describe("reduceCommandPaletteUiState", () => { · 29 unmodified lines | ||
| 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 | 119 | it("routes open intents to command mode", () => { |
| 51 | 120 | const filesOpen = reduceCommandPaletteUiState(closedState, { |
| 52 | 121 | _tag: "ToggleMode", |
| @@ -1,4 +1,5 @@ | ||
| 1 | 1 | import { threadPullRequestSearchTerms } from "@t3tools/shared/threadPullRequests"; |
| 2 | +import type { CommandPaletteLinkedThreads } from "../commandPaletteBus"; | |
| 2 | 3 | import { |
| 3 | 4 | type FilesystemBrowseEntry, |
| 4 | 5 | type KeybindingCommand, |
| @@ -18,6 +19,25 @@ export const RECENT_THREAD_LIMIT = 12; · 13 unmodified lines | ||
| 18 | 19 | export const ITEM_ICON_CLASS = "size-4 text-icon-muted"; |
| 19 | 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 | 41 | export function browseInputEndPaddingClass(input: { |
| 22 | 42 | readonly willCreateProjectPath: boolean; |
| 23 | 43 | readonly hasHighlightedBrowseItem: boolean; |
| @@ -39,9 +59,13 @@ export function browseInputEndPaddingClass(input: { · 15 unmodified lines | ||
| 39 | 59 | */ |
| 40 | 60 | export type SearchOverlayMode = "command" | "files" | "content"; |
| 41 | 61 | |
| 42 | −export interface CommandPaletteOpenIntent { | |
| 43 | − readonly kind: "add-project" | "new-thread-in"; | |
| 44 | −} | |
| 62 | +export type CommandPaletteOpenIntent = | |
| 63 | + | { readonly kind: "add-project" | "new-thread-in" } | |
| 64 | + | { | |
| 65 | + readonly kind: "search"; | |
| 66 | + readonly query: string; | |
| 67 | + readonly linkedThreads?: CommandPaletteLinkedThreads; | |
| 68 | + }; | |
| 45 | 69 | |
| 46 | 70 | export interface CommandPaletteUiState { |
| 47 | 71 | readonly open: boolean; |
| @@ -52,6 +76,11 @@ export interface CommandPaletteUiState { · 4 unmodified lines | ||
| 52 | 76 | export type CommandPaletteUiAction = |
| 53 | 77 | | { readonly _tag: "SetOpen"; readonly open: boolean } |
| 54 | 78 | | { readonly _tag: "ToggleMode"; readonly mode: SearchOverlayMode } |
| 79 | + | { | |
| 80 | + readonly _tag: "OpenSearch"; | |
| 81 | + readonly query: string; | |
| 82 | + readonly linkedThreads?: CommandPaletteLinkedThreads; | |
| 83 | + } | |
| 55 | 84 | | { readonly _tag: "OpenAddProject" } |
| 56 | 85 | | { readonly _tag: "OpenNewThreadIn" } |
| 57 | 86 | | { readonly _tag: "ClearOpenIntent" }; |
| @@ -69,6 +98,16 @@ export function reduceCommandPaletteUiState( · 11 unmodified lines | ||
| 69 | 98 | return state.open && state.mode === action.mode |
| 70 | 99 | ? { ...state, open: false, openIntent: null } |
| 71 | 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 | 111 | case "OpenAddProject": |
| 73 | 112 | return { open: true, mode: "command", openIntent: { kind: "add-project" } }; |
| 74 | 113 | case "OpenNewThreadIn": |
| @@ -129,6 +129,7 @@ import { · 128 unmodified lines | ||
| 129 | 129 | buildProjectActionItems, |
| 130 | 130 | buildRootGroups, |
| 131 | 131 | buildThreadActionItems, |
| 132 | + buildLinkedThreadActionItems, | |
| 132 | 133 | enumerateCommandPaletteItems, |
| 133 | 134 | type CommandPaletteActionItem, |
| 134 | 135 | type CommandPaletteOpenIntent, |
| @@ -482,6 +483,12 @@ export function CommandPalette({ children }: { children: ReactNode }) { · 347 unmodified lines | ||
| 482 | 483 | openNewThreadIn(); |
| 483 | 484 | } else if (detail.open === "add-project") { |
| 484 | 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 | 492 | } else { |
| 486 | 493 | setOpen(true); |
| 487 | 494 | } |
| @@ -573,7 +580,10 @@ function OpenCommandPaletteDialog(props: { · 85 unmodified lines | ||
| 573 | 580 | const navigate = useNavigate(); |
| 574 | 581 | const pathname = useLocation({ select: (location) => location.pathname }); |
| 575 | 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 | 587 | const deferredQuery = useDeferredValue(query); |
| 578 | 588 | const isActionsOnly = deferredQuery.startsWith(">"); |
| 579 | 589 | const [highlightedItemValue, setHighlightedItemValue] = useState<string | null>(null); |
| @@ -696,6 +706,7 @@ function OpenCommandPaletteDialog(props: { · 116 unmodified lines | ||
| 696 | 706 | ); |
| 697 | 707 | const [isPickingProjectFolder, setIsPickingProjectFolder] = useState(false); |
| 698 | 708 | const [addProjectCloneFlow, setAddProjectCloneFlow] = useState<AddProjectCloneFlow | null>(null); |
| 709 | + const cloneLookupGeneration = useRef(0); | |
| 699 | 710 | const [isRemoteProjectLookingUp, setIsRemoteProjectLookingUp] = useState(false); |
| 700 | 711 | const [isRemoteProjectCloning, setIsRemoteProjectCloning] = useState(false); |
| 701 | 712 | const projectGroupingSettings = useMemo( |
| @@ -1497,6 +1508,18 @@ function OpenCommandPaletteDialog(props: { · 795 unmodified lines | ||
| 1497 | 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 | 1523 | useLayoutEffect(() => { |
| 1501 | 1524 | if (openIntent?.kind !== "add-project") { |
| 1502 | 1525 | return; |
| @@ -1805,7 +1828,20 @@ function OpenCommandPaletteDialog(props: { · 302 unmodified lines | ||
| 1805 | 1828 | isInSubmenu: currentView !== null, |
| 1806 | 1829 | projectSearchItems: projectSearchItems, |
| 1807 | 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 | 1847 | const handleAddProjectForEnvironment = useCallback( |
| @@ -2011,6 +2047,7 @@ function OpenCommandPaletteDialog(props: { · 199 unmodified lines | ||
| 2011 | 2047 | } |
| 2012 | 2048 | |
| 2013 | 2049 | setIsRemoteProjectLookingUp(true); |
| 2050 | + const lookupGeneration = ++cloneLookupGeneration.current; | |
| 2014 | 2051 | const lookupResult = await lookupRepository({ |
| 2015 | 2052 | environmentId: addProjectCloneFlow.environmentId, |
| 2016 | 2053 | input: { |
| @@ -2018,6 +2055,7 @@ function OpenCommandPaletteDialog(props: { · 1 unmodified line | ||
| 2018 | 2055 | repository: rawRepository, |
| 2019 | 2056 | }, |
| 2020 | 2057 | }); |
| 2058 | + if (lookupGeneration !== cloneLookupGeneration.current) return; | |
| 2021 | 2059 | setIsRemoteProjectLookingUp(false); |
| 2022 | 2060 | if (lookupResult._tag === "Failure") { |
| 2023 | 2061 | if (!isAtomCommandInterrupted(lookupResult)) { |
| @@ -192,13 +192,11 @@ import { · 191 unmodified lines | ||
| 192 | 192 | import { SidebarDragLifecycle, SidebarPointerSensor } from "./Sidebar.pointer"; |
| 193 | 193 | import { createSidebarListMotion } from "./Sidebar.motion"; |
| 194 | 194 | import { |
| 195 | − PR_STATE_COLOR_CLASS, | |
| 196 | − ThreadPullRequestBadgeIcon, | |
| 195 | + ThreadPullRequestBadgeControl, | |
| 197 | 196 | ThreadPullRequestsMiniList, |
| 198 | 197 | ThreadWorktreeIndicator, |
| 199 | 198 | prStatusIndicator, |
| 200 | 199 | resolveThreadPullRequestBadge, |
| 201 | − settledPrHoverColorClass, | |
| 202 | 200 | terminalStatusFromRunningIds, |
| 203 | 201 | type TerminalStatusIndicator, |
| 204 | 202 | useLinkedThreadPullRequest, |
| @@ -220,7 +218,7 @@ import { · 15 unmodified lines | ||
| 220 | 218 | } from "../providerInstances"; |
| 221 | 219 | import { useThreadRunningTerminalIds } from "../state/terminalSessions"; |
| 222 | 220 | import { stackedThreadToast, toastManager } from "./ui/toast"; |
| 223 | −import { Button, InlineButton } from "./ui/button"; | |
| 221 | +import { Button } from "./ui/button"; | |
| 224 | 222 | import { Input } from "./ui/input"; |
| 225 | 223 | import { |
| 226 | 224 | Combobox, |
| @@ -1179,7 +1177,6 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { · 952 unmodified lines | ||
| 1179 | 1177 | currentGitBranch: visibleGitStatus?.refName ?? null, |
| 1180 | 1178 | }); |
| 1181 | 1179 | const prStatus = prStatusIndicator(pr, linkedPullRequestStatus?.sourceControlProvider); |
| 1182 | − const settledPrHoverClass = pr ? settledPrHoverColorClass(pr.state, pr.isDraft) : undefined; | |
| 1183 | 1180 | |
| 1184 | 1181 | const modelInstanceId = thread.session?.providerInstanceId ?? thread.modelSelection.instanceId; |
| 1185 | 1182 | const providerEntry = props.providerEntryByInstanceId.get(modelInstanceId) ?? null; |
| @@ -1488,85 +1485,26 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { · 302 unmodified lines | ||
| 1488 | 1485 | </span> |
| 1489 | 1486 | ); |
| 1490 | 1487 | |
| 1491 | − // One badge shape for every thread: the glyph says stack or not, the number is the current | |
| 1492 | − // pull request, and "+N" counts the others behind it. A real link so cmd/ctrl+click and | |
| 1493 | − // middle-click open the host in the browser; a plain click opens T3's pull request view. | |
| 1488 | + // Stacks show their layer count; unrelated links show the current PR and a remainder count. | |
| 1489 | + // Plain clicks open T3; individual PR links also support opening the host in a new tab. | |
| 1494 | 1490 | const prBadgeShape = supportsMultiplePullRequests |
| 1495 | 1491 | ? resolveThreadPullRequestBadge(thread.pullRequests) |
| 1496 | 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 | 1493 | const handlePrStackClick = useCallback(() => { |
| 1510 | 1494 | useRightPanelStore.getState().open(threadRef, "pull-requests"); |
| 1511 | 1495 | if (!props.isActive) onThreadActivate(threadRef); |
| 1512 | 1496 | }, [onThreadActivate, props.isActive, threadRef]); |
| 1513 | 1497 | const prBadge = |
| 1514 | − prBadgeShape?.kind === "stack" ? ( | |
| 1515 | − // A stack is one thing with N layers; naming one of them would misrepresent it, so the | |
| 1516 | − // badge counts layers and opens the thread's pull-requests surface. | |
| 1517 | − <InlineButton | |
| 1518 | − underline | |
| 1519 | − onPointerDown={(event) => event.stopPropagation()} | |
| 1520 | − onClick={handlePrStackClick} | |
| 1521 | − className={prBadgeClassName(prBadgeShape.state, PR_STATE_COLOR_CLASS[prBadgeShape.state])} | |
| 1522 | − aria-label={`Stack of ${prBadgeShape.layers} pull requests, ${prBadgeShape.state}`} | |
| 1523 | − > | |
| 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> | |
| 1498 | + prBadgeShape?.kind === "stack" || pr || currentLinkedPr ? ( | |
| 1499 | + <ThreadPullRequestBadgeControl | |
| 1500 | + variant="underline" | |
| 1501 | + badge={prBadgeShape} | |
| 1502 | + number={pr?.number ?? currentLinkedPr?.number} | |
| 1503 | + url={pr?.url ?? currentLinkedPr?.url} | |
| 1504 | + status={prStatus} | |
| 1505 | + onOpenStack={handlePrStackClick} | |
| 1506 | + onOpenPullRequest={handlePrClick} | |
| 1507 | + /> | |
| 1570 | 1508 | ) : null; |
| 1571 | 1509 | const terminalStatusIcon = terminalStatus ? ( |
| 1572 | 1510 | <span |
| @@ -12,9 +12,11 @@ import { · 11 unmodified lines | ||
| 12 | 12 | resolveThreadCurrentPullRequestLink, |
| 13 | 13 | resolveThreadPullRequestChains, |
| 14 | 14 | visibleThreadPullRequests, |
| 15 | + type ThreadPullRequestBadge, | |
| 15 | 16 | } from "@t3tools/shared/threadPullRequests"; |
| 16 | 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 | 20 | import { cn } from "../lib/utils"; |
| 19 | 21 | import { useEnvironment, usePrimaryEnvironmentId } from "../state/environments"; |
| 20 | 22 | import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; |
| @@ -122,7 +124,7 @@ export { · 101 unmodified lines | ||
| 122 | 124 | } from "@t3tools/shared/threadPullRequests"; |
| 123 | 125 | |
| 124 | 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 | 128 | icon, |
| 127 | 129 | className, |
| 128 | 130 | }: { |
| @@ -133,6 +135,86 @@ export function ThreadPullRequestBadgeIcon({ · 4 unmodified lines | ||
| 133 | 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 | 219 | * A miniature of the pull-requests panel for the thread tooltip: same order, same indentation, |
| 138 | 220 | * so the hover answers "what is in here" without opening the surface. |
| @@ -192,7 +274,7 @@ export function ThreadPullRequestsMiniList({ · 53 unmodified lines | ||
| 192 | 274 | } |
| 193 | 275 | |
| 194 | 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 | 278 | open: "text-emerald-600 dark:text-emerald-300/90", |
| 197 | 279 | merged: "text-violet-600 dark:text-violet-300/90", |
| 198 | 280 | closed: "text-red-600 dark:text-red-300/90", |
| @@ -337,7 +337,7 @@ export type MessagesTimelineRow = · 336 unmodified lines | ||
| 337 | 337 | summaryKind: ToolGroupSummaryKind; |
| 338 | 338 | toolSurface?: WorkLogEntry["toolSurface"]; |
| 339 | 339 | toolIcon?: WorkLogEntry["toolIcon"]; |
| 340 | − summaryToolIcon?: "browser" | "t3-code"; | |
| 340 | + summaryToolIcon?: "browser" | "t3-code" | "pull-request"; | |
| 341 | 341 | hasFailure: boolean; |
| 342 | 342 | } |
| 343 | 343 | | { |
| @@ -1,3 +1,4 @@ | ||
| 1 | +import { GitPullRequestIcon } from "lucide-react"; | |
| 1 | 2 | import { |
| 2 | 3 | type AssistantCitation, |
| 3 | 4 | type EnvironmentId, |
| @@ -2225,6 +2226,11 @@ function toolGroupSummaryIconName( · 2221 unmodified lines | ||
| 2225 | 2226 | kind: Extract<TimelineRow, { kind: "work-toggle" }>["summaryKind"], |
| 2226 | 2227 | ): WorkEntryIconName { |
| 2227 | 2228 | switch (kind) { |
| 2229 | + case "pull-request": | |
| 2230 | + case "link-pr": | |
| 2231 | + case "unlink-pr": | |
| 2232 | + case "list-prs": | |
| 2233 | + return "pull-request"; | |
| 2228 | 2234 | case "read": |
| 2229 | 2235 | return "eye"; |
| 2230 | 2236 | case "edit": |
| @@ -2801,6 +2807,7 @@ type WorkEntryIconName = · 570 unmodified lines | ||
| 2801 | 2807 | | "search" |
| 2802 | 2808 | | "square-pen" |
| 2803 | 2809 | | "terminal" |
| 2810 | + | "pull-request" | |
| 2804 | 2811 | | "t3-code" |
| 2805 | 2812 | | "wrench" |
| 2806 | 2813 | | "x" |
| @@ -3005,6 +3012,8 @@ function ToolActivityImageIcon(props: { · 198 unmodified lines | ||
| 3005 | 3012 | |
| 3006 | 3013 | function WorkEntryIcon({ name, className }: { name: WorkEntryIconName; className: string }) { |
| 3007 | 3014 | switch (name) { |
| 3015 | + case "pull-request": | |
| 3016 | + return <GitPullRequestIcon className={className} aria-hidden />; | |
| 3008 | 3017 | case "bot": |
| 3009 | 3018 | return <BotIcon className={className} aria-hidden />; |
| 3010 | 3019 | case "brain": |
| @@ -1,3 +1,5 @@ | ||
| 1 | +import { parseChangeRequestUrl } from "@t3tools/shared/changeRequestUrl"; | |
| 2 | +import { usePullRequestStack } from "~/state/usePullRequestStack"; | |
| 1 | 3 | import { RefreshIcon } from "~/components/ui/refresh-icon"; |
| 2 | 4 | import { scopedThreadKey, scopeProjectRef } from "@t3tools/client-runtime/environment"; |
| 3 | 5 | import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; |
| @@ -70,10 +72,10 @@ import { useProjects, useServerConfigs } from "~/state/entities"; · 66 unmodified lines | ||
| 70 | 72 | import { useEnvironments, usePrimaryEnvironmentId } from "~/state/environments"; |
| 71 | 73 | import { useEnvironmentQuery } from "~/state/query"; |
| 72 | 74 | import { useLiveRefresh } from "~/hooks/useLiveRefresh"; |
| 73 | −import { pullRequestEnvironment, pullRequestStackAtom } from "~/state/pullRequests"; | |
| 75 | +import { pullRequestEnvironment } from "~/state/pullRequests"; | |
| 74 | 76 | import { usePullRequestTurnRefresh, useSharedPullRequestSummary } from "~/state/pullRequests"; |
| 75 | 77 | import { useAtomCommand } from "~/state/use-atom-command"; |
| 76 | −import { PullRequestStackMap } from "./PullRequestStackMap"; | |
| 78 | +import { PullRequestStackMenu } from "./PullRequestStackMenu"; | |
| 77 | 79 | import { PullRequestThreadLinks } from "./PullRequestThreadLinks"; |
| 78 | 80 | import { vcsEnvironment } from "~/state/vcs"; |
| 79 | 81 | import { formatRelativeTimeLabel } from "~/timestampFormat"; |
| @@ -104,7 +106,7 @@ import { · 24 unmodified lines | ||
| 104 | 106 | } from "../ui/menu"; |
| 105 | 107 | import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; |
| 106 | 108 | import { toastManager } from "../ui/toast"; |
| 107 | −import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; | |
| 109 | +import { Tooltip, TooltipPopup, TooltipProvider, TooltipTrigger } from "../ui/tooltip"; | |
| 108 | 110 | import { PullRequestDetailGhost, PullRequestTimelineGhost } from "./PullRequestGhosts"; |
| 109 | 111 | import { PullRequestActivityUnavailableState } from "./PullRequestActivityUnavailableState"; |
| 110 | 112 | import { DiffPanelLoadingState } from "../DiffPanelShell"; |
| @@ -136,6 +138,7 @@ import { · 25 unmodified lines | ||
| 136 | 138 | readPullRequestDetailSnapshot, |
| 137 | 139 | resolveDisplayedPullRequestDetail, |
| 138 | 140 | resolvePullRequestPrimaryControl, |
| 141 | + allowsSinglePullRequestMerge, | |
| 139 | 142 | resolveBaseFreshness, |
| 140 | 143 | resolvePullRequestMergeMethod, |
| 141 | 144 | type PullRequestFinding, |
| @@ -459,8 +462,10 @@ export function PullRequestDetailPanel({ · 317 unmodified lines | ||
| 459 | 462 | context = "page", |
| 460 | 463 | composerDraftTarget, |
| 461 | 464 | onBack, |
| 465 | + onSelectPullRequest, | |
| 462 | 466 | }: { |
| 463 | 467 | environmentId: EnvironmentId; |
| 468 | + onSelectPullRequest?: ((reference: PullRequestRef) => void) | undefined; | |
| 464 | 469 | /** |
| 465 | 470 | * The thread this panel sits beside, if any. Links that are not the pull |
| 466 | 471 | * request itself (check details, host permalinks) can open in that thread's |
| @@ -730,17 +735,34 @@ export function PullRequestDetailPanel({ · 263 unmodified lines | ||
| 730 | 735 | isStackedPullRequestBase(detail.baseBranch, branchRefsQuery.data?.refs ?? []); |
| 731 | 736 | // The host's own stack, where it keeps one. Only asked for once the detail has landed so a |
| 732 | 737 | // pull request nobody can read costs one request rather than two. |
| 733 | − const nativeStack = useEnvironmentQuery( | |
| 734 | − detail === null || detail.capabilities.stacks !== true || !supportsThreadPullRequests | |
| 735 | − ? null | |
| 736 | − : pullRequestStackAtom({ environmentId, input: reference }), | |
| 737 | − ).data; | |
| 738 | + const stackReference = useMemo( | |
| 739 | + () => | |
| 740 | + detail === null || detail.capabilities.stacks !== true || !supportsThreadPullRequests | |
| 741 | + ? null | |
| 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 | 759 | const activityPending = activityQuery.isPending && activity === null; |
| 739 | 760 | const activityError = activity === null ? activityQuery.error : null; |
| 740 | 761 | const refreshDetail = useCallback(() => { |
| 741 | 762 | detailQuery.refresh(); |
| 742 | 763 | activityQuery.refresh(); |
| 743 | − }, [activityQuery.refresh, detailQuery.refresh]); | |
| 764 | + nativeStackQuery.refresh(); | |
| 765 | + }, [activityQuery.refresh, detailQuery.refresh, nativeStackQuery.refresh]); | |
| 744 | 766 | const [refreshToken, setRefreshToken] = useState(0); |
| 745 | 767 | const codeRefreshToken = refreshToken + (turnRefresh ?? 0); |
| 746 | 768 | const activityRevision = useRef<{ readonly key: string; readonly updatedAt: string } | null>( |
| @@ -1351,9 +1373,9 @@ export function PullRequestDetailPanel({ · 604 unmodified lines | ||
| 1351 | 1373 | checksState, |
| 1352 | 1374 | autoMergeEnabled: detail.autoMergeEnabled, |
| 1353 | 1375 | hasMergeMethod: allowedMergeMethods.length > 0, |
| 1354 | − canMerge: can("merge"), | |
| 1376 | + canMerge: canMergeSinglePullRequest && can("merge"), | |
| 1355 | 1377 | canMarkReady: can("ready"), |
| 1356 | − canEnableAutoMerge: can("enable-auto-merge"), | |
| 1378 | + canEnableAutoMerge: canMergeSinglePullRequest && can("enable-auto-merge"), | |
| 1357 | 1379 | }) |
| 1358 | 1380 | : null; |
| 1359 | 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({ · 3 unmodified lines | ||
| 1363 | 1385 | can(detail.isDraft ? "ready" : "draft") && |
| 1364 | 1386 | !(detail.isDraft && primaryAction === "ready"); |
| 1365 | 1387 | const showsAutoMerge = |
| 1388 | + canMergeSinglePullRequest && | |
| 1366 | 1389 | detail?.state === "open" && |
| 1367 | 1390 | ((autoMergeArmed && can("disable-auto-merge")) || |
| 1368 | 1391 | (!autoMergeArmed && |
| @@ -1372,6 +1395,7 @@ export function PullRequestDetailPanel({ · 3 unmodified lines | ||
| 1372 | 1395 | can("enable-auto-merge") && |
| 1373 | 1396 | allowedMergeMethods.length > 0)); |
| 1374 | 1397 | const showsMergeNow = |
| 1398 | + canMergeSinglePullRequest && | |
| 1375 | 1399 | detail?.state === "open" && |
| 1376 | 1400 | (primaryAction === "enable-auto-merge" || primaryAction === "auto-merge-armed") && |
| 1377 | 1401 | can("merge") && |
| @@ -1568,7 +1592,38 @@ export function PullRequestDetailPanel({ · 190 unmodified lines | ||
| 1568 | 1592 | </div> |
| 1569 | 1593 | <div className="mr-4 flex h-7 shrink-0 items-center justify-end gap-1"> |
| 1570 | 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 | 1627 | {context === "page" ? ( |
| 1573 | 1628 | <PullRequestThreadLinks |
| 1574 | 1629 | display="count" |
| @@ -1585,24 +1640,34 @@ export function PullRequestDetailPanel({ · 10 unmodified lines | ||
| 1585 | 1640 | the page: beside a thread the branch is already checked out right there. */} |
| 1586 | 1641 | {context === "page" ? ( |
| 1587 | 1642 | <Menu> |
| 1588 | − <MenuTrigger | |
| 1589 | − disabled={handoff !== null} | |
| 1590 | − render={ | |
| 1591 | − <Button | |
| 1592 | − size="xs" | |
| 1593 | − variant="outline" | |
| 1594 | − aria-label={ | |
| 1595 | − handoff?.startsWith("checkout") ? "Checking out..." : "Check out" | |
| 1596 | − } | |
| 1597 | − > | |
| 1598 | − <GitBranchIcon aria-hidden className="size-3.5" /> | |
| 1599 | − <span className="@max-[35rem]/pr-header:hidden"> | |
| 1600 | − {handoff?.startsWith("checkout") ? "Checking out..." : "Check out"} | |
| 1601 | − </span> | |
| 1602 | − <ChevronDownIcon aria-hidden className="size-3.5 text-muted-foreground" /> | |
| 1603 | − </Button> | |
| 1604 | − } | |
| 1605 | − /> | |
| 1643 | + <Tooltip> | |
| 1644 | + <TooltipTrigger | |
| 1645 | + render={ | |
| 1646 | + <MenuTrigger | |
| 1647 | + disabled={handoff !== null} | |
| 1648 | + render={ | |
| 1649 | + <Button | |
| 1650 | + size="xs" | |
| 1651 | + variant="ghost" | |
| 1652 | + aria-label={ | |
| 1653 | + handoff?.startsWith("checkout") ? "Checking out..." : "Check out" | |
| 1654 | + } | |
| 1655 | + > | |
| 1656 | + <GitBranchIcon aria-hidden className="size-3.5" /> | |
| 1657 | + <span className="@max-[35rem]/pr-header:hidden"> | |
| 1658 | + {handoff?.startsWith("checkout") ? "Checking out..." : "Check out"} | |
| 1659 | + </span> | |
| 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 | 1671 | <MenuPopup align="end" side="bottom" className="min-w-72"> |
| 1607 | 1672 | <MenuItem onClick={() => startCheckout("worktree")}> |
| 1608 | 1673 | <GitBranchIcon className="mt-0.5 size-3.5 shrink-0 self-start" /> |
| @@ -1663,7 +1728,7 @@ export function PullRequestDetailPanel({ · 54 unmodified lines | ||
| 1663 | 1728 | <span className="inline-flex shrink-0"> |
| 1664 | 1729 | <Button |
| 1665 | 1730 | size="xs" |
| 1666 | − variant="destructive-outline" | |
| 1731 | + variant="ghost" | |
| 1667 | 1732 | disabled={handoff !== null} |
| 1668 | 1733 | onClick={startResolveConflicts} |
| 1669 | 1734 | aria-label={ |
| @@ -1689,6 +1754,7 @@ export function PullRequestDetailPanel({ · 19 unmodified lines | ||
| 1689 | 1754 | <span className="inline-flex shrink-0"> |
| 1690 | 1755 | <Button |
| 1691 | 1756 | size="xs" |
| 1757 | + variant="ghost" | |
| 1692 | 1758 | disabled={actionPending} |
| 1693 | 1759 | onClick={() => void perform("ready")} |
| 1694 | 1760 | aria-label="Ready for review" |
| @@ -1711,6 +1777,7 @@ export function PullRequestDetailPanel({ · 16 unmodified lines | ||
| 1711 | 1777 | <span className="inline-flex shrink-0"> |
| 1712 | 1778 | <Button |
| 1713 | 1779 | size="xs" |
| 1780 | + variant="ghost" | |
| 1714 | 1781 | disabled={actionPending} |
| 1715 | 1782 | onClick={() => |
| 1716 | 1783 | setConfirmation({ open: true, action: "enable-auto-merge" }) |
| @@ -1762,6 +1829,7 @@ export function PullRequestDetailPanel({ · 45 unmodified lines | ||
| 1762 | 1829 | <span className="inline-flex shrink-0"> |
| 1763 | 1830 | <Button |
| 1764 | 1831 | size="xs" |
| 1832 | + variant="ghost" | |
| 1765 | 1833 | disabled={actionPending} |
| 1766 | 1834 | onClick={() => setConfirmation({ open: true, action: "merge" })} |
| 1767 | 1835 | aria-label={ |
| @@ -1791,18 +1859,25 @@ export function PullRequestDetailPanel({ · 23 unmodified lines | ||
| 1791 | 1859 | </Badge> |
| 1792 | 1860 | ) : null} |
| 1793 | 1861 | <Menu> |
| 1794 | − <MenuTrigger | |
| 1795 | − render={ | |
| 1796 | − <Button | |
| 1797 | − aria-label="More pull request actions" | |
| 1798 | − className="size-6" | |
| 1799 | − size="icon-xs" | |
| 1800 | − variant="ghost-muted" | |
| 1801 | − /> | |
| 1802 | − } | |
| 1803 | − > | |
| 1804 | − <MoreHorizontalIcon className="size-4" /> | |
| 1805 | − </MenuTrigger> | |
| 1862 | + <Tooltip> | |
| 1863 | + <TooltipTrigger | |
| 1864 | + render={ | |
| 1865 | + <MenuTrigger | |
| 1866 | + render={ | |
| 1867 | + <Button | |
| 1868 | + aria-label="More pull request actions" | |
| 1869 | + className="size-6" | |
| 1870 | + size="icon-xs" | |
| 1871 | + variant="ghost-muted" | |
| 1872 | + /> | |
| 1873 | + } | |
| 1874 | + > | |
| 1875 | + <MoreHorizontalIcon className="size-4" /> | |
| 1876 | + </MenuTrigger> | |
| 1877 | + } | |
| 1878 | + /> | |
| 1879 | + <TooltipPopup>More pull request actions</TooltipPopup> | |
| 1880 | + </Tooltip> | |
| 1806 | 1881 | <MenuPopup align="end" side="bottom" className="min-w-72"> |
| 1807 | 1882 | <PullRequestThreadLinks |
| 1808 | 1883 | display="menu-item" |
| @@ -1997,7 +2072,7 @@ export function PullRequestDetailPanel({ · 188 unmodified lines | ||
| 1997 | 2072 | ) : null} |
| 1998 | 2073 | </MenuPopup> |
| 1999 | 2074 | </Menu> |
| 2000 | − </> | |
| 2075 | + </TooltipProvider> | |
| 2001 | 2076 | ) : null} |
| 2002 | 2077 | {onClose ? ( |
| 2003 | 2078 | <Button |
| @@ -2102,13 +2177,6 @@ export function PullRequestDetailPanel({ · 98 unmodified lines | ||
| 2102 | 2177 | /> |
| 2103 | 2178 | </span> |
| 2104 | 2179 | </div> |
| 2105 | − {nativeStack ? ( | |
| 2106 | − <PullRequestStackMap | |
| 2107 | − stack={nativeStack} | |
| 2108 | − currentNumber={detail.number} | |
| 2109 | − className="mt-1" | |
| 2110 | − /> | |
| 2111 | − ) : null} | |
| 2112 | 2180 | </div> |
| 2113 | 2181 | ) : null} |
| 2114 | 2182 | </div> |
| @@ -1,4 +1,5 @@ | ||
| 1 | 1 | import { SearchIcon } from "lucide-react"; |
| 2 | +import { PullRequestStackPopover } from "./PullRequestStackPopover"; | |
| 2 | 3 | import { memo, type RefCallback } from "react"; |
| 3 | 4 | |
| 4 | 5 | import { cn } from "~/lib/utils"; |
| @@ -60,6 +61,11 @@ function PullRequestRowLabels({ labels }: { labels: EnvironmentPullRequestEntry[ · 55 unmodified lines | ||
| 60 | 61 | ); |
| 61 | 62 | } |
| 62 | 63 | |
| 64 | +export type PullRequestRowTarget = Pick< | |
| 65 | + EnvironmentPullRequestEntry, | |
| 66 | + "environmentId" | "projectId" | "host" | "repository" | "number" | |
| 67 | +>; | |
| 68 | + | |
| 63 | 69 | function PullRequestRowImpl({ |
| 64 | 70 | entry, |
| 65 | 71 | selected, |
| @@ -86,7 +92,7 @@ function PullRequestRowImpl({ · 20 unmodified lines | ||
| 86 | 92 | /** Used by the list's shared visibility observer to defer optional line-count reads. */ |
| 87 | 93 | statsKey?: string; |
| 88 | 94 | statsRef?: RefCallback<HTMLButtonElement>; |
| 89 | − onSelect: (entry: EnvironmentPullRequestEntry) => void; | |
| 95 | + onSelect: (entry: PullRequestRowTarget) => void; | |
| 90 | 96 | }) { |
| 91 | 97 | const { Icon, providerName } = getSourceControlPresentationForKind(entry.provider); |
| 92 | 98 | return ( |
| @@ -116,6 +122,21 @@ function PullRequestRowImpl({ · 23 unmodified lines | ||
| 116 | 122 | {entry.title} |
| 117 | 123 | </span> |
| 118 | 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 | 140 | {/* Only a verdict somebody has actually given: "review required" is the absence of |
| 120 | 141 | one, and saying so on every unreviewed row would say nothing. */} |
| 121 | 142 | {entry.reviewDecision === "approved" || entry.reviewDecision === "changes-requested" ? ( |
| @@ -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 | +} | |
| @@ -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 | +} | |
| @@ -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 | +} | |
| @@ -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 | −} | |
| @@ -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 | +} | |
| @@ -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 | +} | |
| @@ -1,5 +1,5 @@ | ||
| 1 | +import { Tooltip, TooltipTrigger, TooltipPopup } from "../ui/tooltip"; | |
| 1 | 2 | import { scopeThreadRef } from "@t3tools/client-runtime/environment"; |
| 2 | −import { Link } from "@tanstack/react-router"; | |
| 3 | 3 | import type { EnvironmentId, PullRequestRef, ScopedThreadRef, ThreadId } from "@t3tools/contracts"; |
| 4 | 4 | import { CheckIcon, LinkIcon, MessageSquareIcon, UnlinkIcon } from "lucide-react"; |
| 5 | 5 | import { useState } from "react"; |
| @@ -11,11 +11,11 @@ import { useProjects, useServerConfigs, useThreadShell, useThreadShells } from " · 5 unmodified lines | ||
| 11 | 11 | import { pullRequestEnvironment } from "~/state/pullRequests"; |
| 12 | 12 | import { useEnvironmentQuery } from "~/state/query"; |
| 13 | 13 | import { appAtomRegistry } from "~/rpc/atomRegistry"; |
| 14 | −import { buildThreadRouteParams } from "~/threadRoutes"; | |
| 14 | +import { openCommandPalette } from "~/commandPaletteBus"; | |
| 15 | 15 | import { Button } from "../ui/button"; |
| 16 | 16 | import { Command, CommandInput, CommandItem, CommandList } from "../ui/command"; |
| 17 | 17 | import { Dialog, DialogPopup, DialogTitle } from "../ui/dialog"; |
| 18 | −import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu"; | |
| 18 | +import { MenuItem } from "../ui/menu"; | |
| 19 | 19 | import { toastManager } from "../ui/toast"; |
| 20 | 20 | |
| 21 | 21 | interface PullRequestThreadLinksProps { |
| @@ -61,7 +61,7 @@ function EnabledPullRequestThreadLinks({ · 39 unmodified lines | ||
| 61 | 61 | : null, |
| 62 | 62 | ); |
| 63 | 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 | 65 | const [lastRelations, setLastRelations] = useState(relations.data); |
| 66 | 66 | if (relations.data !== null && relations.data !== lastRelations) { |
| 67 | 67 | setLastRelations(relations.data); |
| @@ -101,47 +101,29 @@ function EnabledPullRequestThreadLinks({ · 33 unmodified lines | ||
| 101 | 101 | return ( |
| 102 | 102 | <> |
| 103 | 103 | {display === "count" && (linkedThreads.length > 0 || relations.error !== null) ? ( |
| 104 | − <Menu> | |
| 105 | − <MenuTrigger | |
| 104 | + <Tooltip> | |
| 105 | + <TooltipTrigger | |
| 106 | 106 | render={ |
| 107 | 107 | <Button |
| 108 | 108 | size="xs" |
| 109 | 109 | variant="ghost" |
| 110 | 110 | aria-label={linkedThreadsLabel} |
| 111 | − title={linkedThreadsLabel} | |
| 112 | − /> | |
| 113 | − } | |
| 114 | − > | |
| 115 | − <MessageSquareIcon aria-hidden className="size-3.5" /> | |
| 116 | − <span className="@max-[40rem]/pr-header:hidden">{linkedThreadsLabel}</span> | |
| 117 | − <span aria-hidden className="hidden @max-[40rem]/pr-header:inline"> | |
| 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 | − /> | |
| 111 | + onClick={() => | |
| 112 | + openCommandPalette({ | |
| 113 | + query: url, | |
| 114 | + ...((relations.data ?? lastRelations) === null | |
| 115 | + ? {} | |
| 116 | + : { linkedThreads: { environmentId, threads: linkedThreads } }), | |
| 117 | + }) | |
| 133 | 118 | } |
| 134 | 119 | > |
| 135 | − <span className="min-w-0 flex-1 truncate"> | |
| 136 | − {linkedThread.title || "Untitled thread"} | |
| 137 | − </span> | |
| 138 | − {linkedThread.archivedAt !== null ? ( | |
| 139 | − <span className="text-xs text-muted-foreground">Archived</span> | |
| 140 | − ) : null} | |
| 141 | − </MenuItem> | |
| 142 | − ))} | |
| 143 | − </MenuPopup> | |
| 144 | − </Menu> | |
| 120 | + <MessageSquareIcon aria-hidden className="size-3.5" /> | |
| 121 | + <span aria-hidden>{linkedThreads.length || "?"}</span> | |
| 122 | + </Button> | |
| 123 | + } | |
| 124 | + /> | |
| 125 | + <TooltipPopup>{linkedThreadsLabel}. Search in the command palette.</TooltipPopup> | |
| 126 | + </Tooltip> | |
| 145 | 127 | ) : null} |
| 146 | 128 | {display === "menu-item" ? ( |
| 147 | 129 | <MenuItem |
| @@ -35,6 +35,7 @@ import { · 34 unmodified lines | ||
| 35 | 35 | readPullRequestDetailSnapshot, |
| 36 | 36 | resolveDisplayedPullRequestDetail, |
| 37 | 37 | resolvePullRequestPrimaryControl, |
| 38 | + allowsSinglePullRequestMerge, | |
| 38 | 39 | shouldRefreshPullRequestActivity, |
| 39 | 40 | resolveBaseFreshness, |
| 40 | 41 | resolvePullRequestMergeMethod, |
| @@ -1463,3 +1464,21 @@ describe("cached pull request detail", () => { · 1422 unmodified lines | ||
| 1463 | 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 | +}); | |
| @@ -29,6 +29,19 @@ export const PULL_REQUEST_MERGE_METHOD_LABELS: Record<PullRequestMergeMethod, st · 28 unmodified lines | ||
| 29 | 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 | 45 | export function resolvePullRequestMergeMethod( |
| 33 | 46 | allowed: ReadonlyArray<PullRequestMergeMethod>, |
| 34 | 47 | current: PullRequestMergeMethod | null, |
| @@ -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 | +}); | |
| @@ -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 | +} | |
| @@ -203,6 +203,7 @@ export function useOpenChangeRequestLink( · 202 unmodified lines | ||
| 203 | 203 | state: previous.state ?? "all", |
| 204 | 204 | repository, |
| 205 | 205 | number: parsed.number, |
| 206 | + selectedHost: parsed.host, | |
| 206 | 207 | selectedProjectId: project.id, |
| 207 | 208 | selectedEnvironmentId: project.environmentId, |
| 208 | 209 | }), |
| @@ -220,6 +221,7 @@ export function useOpenChangeRequestLink( · 11 unmodified lines | ||
| 220 | 221 | state: "all", |
| 221 | 222 | repository, |
| 222 | 223 | number: parsed.number, |
| 224 | + selectedHost: parsed.host, | |
| 223 | 225 | selectedProjectId: project.id, |
| 224 | 226 | // Named so the page opens the right one of two servers holding this project. |
| 225 | 227 | selectedEnvironmentId: project.environmentId, |
| @@ -99,7 +99,10 @@ import { · 98 unmodified lines | ||
| 99 | 99 | } from "../components/pullRequest/PullRequestListFilters"; |
| 100 | 100 | import { PullRequestListEmptyState } from "../components/pullRequest/PullRequestListEmptyState"; |
| 101 | 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 | 106 | import { PullRequestsUnavailableState } from "../components/pullRequest/PullRequestsUnavailableState"; |
| 104 | 107 | import { RightPanelTabs, type PullRequestTabStatusSeed } from "../components/RightPanelTabs"; |
| 105 | 108 | import { |
| @@ -163,6 +166,8 @@ export interface PullRequestsSearch extends PullRequestListPreferences { · 57 unmodified lines | ||
| 163 | 166 | readonly repository?: string; |
| 164 | 167 | readonly number?: number; |
| 165 | 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 | 172 | * Which server the selected pull request was read from. A project id only names a project on |
| 168 | 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")({ · 94 unmodified lines | ||
| 263 | 268 | ...(typeof raw.selectedProjectId === "string" && raw.selectedProjectId |
| 264 | 269 | ? { selectedProjectId: raw.selectedProjectId as ProjectId } |
| 265 | 270 | : {}), |
| 271 | + ...(typeof raw.selectedHost === "string" && raw.selectedHost | |
| 272 | + ? { selectedHost: raw.selectedHost.slice(0, 200) } | |
| 273 | + : {}), | |
| 266 | 274 | ...(typeof raw.selectedEnvironmentId === "string" && raw.selectedEnvironmentId |
| 267 | 275 | ? { selectedEnvironmentId: raw.selectedEnvironmentId as EnvironmentId } |
| 268 | 276 | : {}), |
| @@ -367,6 +375,7 @@ function PullRequestsRouteView() { · 98 unmodified lines | ||
| 367 | 375 | |
| 368 | 376 | // A link from a thread or the sidebar only knows the repository, so the owning project is |
| 369 | 377 | // resolved here; an explicit `projectId` in the URL still wins. |
| 378 | + const selectedHost = search.selectedHost ?? search.host; | |
| 370 | 379 | const projectIdForRepository = useMemo(() => { |
| 371 | 380 | const repository = search.repository?.toLowerCase(); |
| 372 | 381 | if (repository === undefined) return undefined; |
| @@ -378,14 +387,14 @@ function PullRequestsRouteView() { · 5 unmodified lines | ||
| 378 | 387 | repository && |
| 379 | 388 | // The same `owner/name` can exist on two hosts. Without this the first match wins, and |
| 380 | 389 | // a link that named its host opens the pull request from the other one. |
| 381 | − (search.host === undefined || | |
| 390 | + (selectedHost === undefined || | |
| 382 | 391 | pullRequestHostOf( |
| 383 | 392 | project.repositoryIdentity, |
| 384 | 393 | project.repositoryIdentity.provider as SourceControlProviderKind, |
| 385 | − ) === search.host.toLowerCase()), | |
| 394 | + ) === selectedHost.toLowerCase()), | |
| 386 | 395 | ); |
| 387 | 396 | return identity?.id; |
| 388 | − }, [projects, search.host, search.repository]); | |
| 397 | + }, [projects, selectedHost, search.repository]); | |
| 389 | 398 | |
| 390 | 399 | // The selection is resolved the same way the scope is: an id no connected environment has can |
| 391 | 400 | // never be read here, and one that arrived before the projects did is not yet wrong. |
| @@ -472,6 +481,7 @@ function PullRequestsRouteView() { · 80 unmodified lines | ||
| 472 | 481 | ...(next.projectId ? { projectId: next.projectId } : {}), |
| 473 | 482 | ...(next.environmentId ? { environmentId: next.environmentId } : {}), |
| 474 | 483 | ...(next.host ? { host: next.host } : {}), |
| 484 | + ...(next.selectedHost ? { selectedHost: next.selectedHost } : {}), | |
| 475 | 485 | ...(next.selectedProjectId ? { selectedProjectId: next.selectedProjectId } : {}), |
| 476 | 486 | ...(next.selectedEnvironmentId |
| 477 | 487 | ? { selectedEnvironmentId: next.selectedEnvironmentId } |
| @@ -494,6 +504,7 @@ function PullRequestsRouteView() { · 16 unmodified lines | ||
| 494 | 504 | number: undefined, |
| 495 | 505 | selectedProjectId: undefined, |
| 496 | 506 | selectedEnvironmentId: undefined, |
| 507 | + selectedHost: undefined, | |
| 497 | 508 | }; |
| 498 | 509 | // List controls change the rows behind the detail, not the independent selected surface. The |
| 499 | 510 | // reader can keep working in that panel while narrowing, sorting, or switching projects. |
| @@ -799,19 +810,11 @@ function PullRequestsRouteView() { · 299 unmodified lines | ||
| 799 | 810 | // from the first moment rather than the second: a button that stays live through the slow half |
| 800 | 811 | // of its own work is a button that gets pressed again, and buys the whole cascade twice. |
| 801 | 812 | const [invalidating, setInvalidating] = useState(false); |
| 802 | − const refreshFromHost = async (includeDetail = true) => { | |
| 803 | − const requestedStatsScope = statsScopeRef.current; | |
| 804 | − setInvalidating(true); | |
| 805 | − try { | |
| 806 | − // Every environment the page is reading, since what the reader pressed refresh for is the | |
| 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); | |
| 813 | + const refreshListAndStats = ( | |
| 814 | + requestedStatsScope = statsScopeRef.current, | |
| 815 | + actedEnvironmentId?: EnvironmentId, | |
| 816 | + ) => { | |
| 817 | + refreshList(true, actedEnvironmentId); | |
| 815 | 818 | const visible = visibleStatsKeys.current; |
| 816 | 819 | const batches = pullRequestStatsRefreshBatches({ |
| 817 | 820 | requestedScope: requestedStatsScope, |
| @@ -822,9 +825,29 @@ function PullRequestsRouteView() { · 4 unmodified lines | ||
| 822 | 825 | }); |
| 823 | 826 | if (batches !== null) { |
| 824 | 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 | 852 | const refreshing = invalidating || listQuery.isPending; |
| 830 | 853 | |
| @@ -1058,17 +1081,28 @@ function PullRequestsRouteView() { · 227 unmodified lines | ||
| 1058 | 1081 | // re-reads only its own slice, so the rows loaded before it would never see a merge, a close, |
| 1059 | 1082 | // or a retitle. Going back to a single page long enough to cover everything on screen lets the |
| 1060 | 1083 | // merge above bring every row up to date in place. |
| 1061 | − const refreshList = (includeRelated = false) => { | |
| 1062 | − const related = includeRelated | |
| 1063 | − ? [ | |
| 1064 | − ...baselineTargets, | |
| 1065 | − ...facetTargets, | |
| 1066 | − ...partitionTargets.authored, | |
| 1067 | − ...partitionTargets.reviewing, | |
| 1068 | − ] | |
| 1069 | − : []; | |
| 1084 | + const refreshList = (includeRelated = false, actedEnvironmentId?: EnvironmentId) => { | |
| 1085 | + const related = ( | |
| 1086 | + includeRelated | |
| 1087 | + ? [ | |
| 1088 | + ...baselineTargets, | |
| 1089 | + ...facetTargets, | |
| 1090 | + ...partitionTargets.authored, | |
| 1091 | + ...partitionTargets.reviewing, | |
| 1092 | + ] | |
| 1093 | + : [] | |
| 1094 | + ).filter( | |
| 1095 | + ({ environmentId }) => | |
| 1096 | + actedEnvironmentId === undefined || environmentId === actedEnvironmentId, | |
| 1097 | + ); | |
| 1070 | 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 | 1106 | return; |
| 1073 | 1107 | } |
| 1074 | 1108 | if (related.length > 0) listQuery.refresh(related); |
| @@ -1412,9 +1446,10 @@ function PullRequestsRouteView() { · 337 unmodified lines | ||
| 1412 | 1446 | repository: search.repository, |
| 1413 | 1447 | number: search.number, |
| 1414 | 1448 | projectId: selectedProject.id, |
| 1449 | + ...(selectedHost ? { host: selectedHost } : {}), | |
| 1415 | 1450 | } |
| 1416 | 1451 | : null, |
| 1417 | − [search.number, search.repository, selectedProject], | |
| 1452 | + [search.number, search.repository, selectedProject, selectedHost], | |
| 1418 | 1453 | ); |
| 1419 | 1454 | const rightPanelAvailable = selectedPullRequestSurface !== null; |
| 1420 | 1455 | useEffect(() => { |
| @@ -1429,6 +1464,14 @@ function PullRequestsRouteView() { · 8 unmodified lines | ||
| 1429 | 1464 | repository: activePullRequestSurface.repository, |
| 1430 | 1465 | number: activePullRequestSurface.number, |
| 1431 | 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 | 1476 | : null; |
| 1434 | 1477 | |
| @@ -1440,6 +1483,7 @@ function PullRequestsRouteView() { · 5 unmodified lines | ||
| 1440 | 1483 | repository: surface.repository, |
| 1441 | 1484 | number: surface.number, |
| 1442 | 1485 | selectedProjectId: surface.projectId as ProjectId, |
| 1486 | + selectedHost: surface.host, | |
| 1443 | 1487 | ...(surface.environmentId === undefined |
| 1444 | 1488 | ? {} |
| 1445 | 1489 | : { selectedEnvironmentId: surface.environmentId as EnvironmentId }), |
| @@ -1503,7 +1547,7 @@ function PullRequestsRouteView() { · 57 unmodified lines | ||
| 1503 | 1547 | |
| 1504 | 1548 | // Stable so the memoized rows can skip re-rendering when the list around them changes. |
| 1505 | 1549 | const selectEntry = useCallback( |
| 1506 | − (entry: EnvironmentPullRequestEntry) => { | |
| 1550 | + (entry: PullRequestRowTarget) => { | |
| 1507 | 1551 | // The surface carries the row's own server, which is what its detail reads and acts on. |
| 1508 | 1552 | if (rightPanelRef === null) return; |
| 1509 | 1553 | useRightPanelStore.getState().openPullRequest(rightPanelRef, entry); |
| @@ -1512,6 +1556,7 @@ function PullRequestsRouteView() { · 2 unmodified lines | ||
| 1512 | 1556 | number: entry.number, |
| 1513 | 1557 | selectedProjectId: entry.projectId, |
| 1514 | 1558 | selectedEnvironmentId: entry.environmentId, |
| 1559 | + selectedHost: entry.host, | |
| 1515 | 1560 | }); |
| 1516 | 1561 | }, |
| 1517 | 1562 | [rightPanelRef, updateSearch], |
| @@ -1628,6 +1673,7 @@ function PullRequestsRouteView() { · 110 unmodified lines | ||
| 1628 | 1673 | selected={ |
| 1629 | 1674 | selected?.environmentId === entry.environmentId && |
| 1630 | 1675 | selected.repository === entry.repository && |
| 1676 | + selected.host?.toLowerCase() === entry.host.toLowerCase() && | |
| 1631 | 1677 | selected.number === entry.number |
| 1632 | 1678 | } |
| 1633 | 1679 | onSelect={selectEntry} |
| @@ -1938,10 +1984,30 @@ function PullRequestsRouteView() { · 304 unmodified lines | ||
| 1938 | 1984 | <PullRequestDetailPanel |
| 1939 | 1985 | key={renderedPullRequestSurface.id} |
| 1940 | 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 | 2004 | reference={{ |
| 1942 | 2005 | projectId: renderedPullRequestSurface.projectId as ProjectId, |
| 1943 | 2006 | repository: renderedPullRequestSurface.repository, |
| 1944 | 2007 | number: renderedPullRequestSurface.number, |
| 2008 | + ...(renderedPullRequestSurface.host | |
| 2009 | + ? { host: renderedPullRequestSurface.host } | |
| 2010 | + : {}), | |
| 1945 | 2011 | }} |
| 1946 | 2012 | listEntry={ |
| 1947 | 2013 | listedPullRequestsBySurface.get( |
| @@ -1952,7 +2018,8 @@ function PullRequestsRouteView() { · 4 unmodified lines | ||
| 1952 | 2018 | // Host actions can change both readiness and diff size, so refresh the counts |
| 1953 | 2019 | // alongside the list. The panel already refreshes itself after each action. |
| 1954 | 2020 | onActed={() => { |
| 1955 | − void refreshFromHost(false); | |
| 2021 | + // Mutations already invalidate the host's affected caches. | |
| 2022 | + refreshListAndStats(undefined, panelEnvironmentId); | |
| 1956 | 2023 | }} |
| 1957 | 2024 | /> |
| 1958 | 2025 | </RightPanelTabs> |
| @@ -74,7 +74,10 @@ export function useSharedPullRequestSummary( · 73 unmodified lines | ||
| 74 | 74 | }, [atom, current, environmentId]); |
| 75 | 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 | 82 | export interface EnvironmentQueryTarget<Input> { |
| 80 | 83 | readonly environmentId: EnvironmentId; |
| @@ -11,6 +11,7 @@ export interface EnvironmentQueryView<A> { · 10 unmodified lines | ||
| 11 | 11 | readonly data: A | null; |
| 12 | 12 | readonly error: string | null; |
| 13 | 13 | readonly isPending: boolean; |
| 14 | + readonly isSuccess: boolean; | |
| 14 | 15 | readonly refresh: () => void; |
| 15 | 16 | } |
| 16 | 17 | |
| @@ -31,6 +32,7 @@ export function useEnvironmentQuery<A, E>( · 14 unmodified lines | ||
| 31 | 32 | data: Option.getOrNull(AsyncResult.value(result)), |
| 32 | 33 | error: result._tag === "Failure" ? formatEnvironmentQueryError(result.cause) : null, |
| 33 | 34 | isPending: atom !== null && result.waiting, |
| 35 | + isSuccess: result._tag === "Success", | |
| 34 | 36 | refresh, |
| 35 | 37 | }; |
| 36 | 38 | } |
| @@ -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 | +} | |
| @@ -115,3 +115,15 @@ review is terminal. An open or unsynced link keeps it active. · 114 unmodified lines | ||
| 115 | 115 | |
| 116 | 116 | Cross-repository links use a project on the same host. Azure DevOps reviews require a project checked |
| 117 | 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. | |
| @@ -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 | 2 | import { expect, it } from "@effect/vitest"; |
| 3 | 3 | import * as Effect from "effect/Effect"; |
| 4 | 4 | import * as Latch from "effect/Latch"; |
| @@ -19,7 +19,10 @@ import * as EnvironmentRegistry from "../connection/registry.ts"; · 14 unmodified lines | ||
| 19 | 19 | import * as EnvironmentSupervisor from "../connection/supervisor.ts"; |
| 20 | 20 | import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; |
| 21 | 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 | 26 | import { PullRequestDiffLoader } from "./pullRequestDiffHttp.ts"; |
| 24 | 27 | import { executeAtomQuery } from "./runtime.ts"; |
| 25 | 28 | |
| @@ -80,7 +83,7 @@ const makeTestRuntime = Effect.fn("makeTestRuntime")(function* (client: WsRpcPro · 54 unmodified lines | ||
| 80 | 83 | const registry = yield* Effect.acquireRelease(Effect.sync(AtomRegistry.make), (registry) => |
| 81 | 84 | Effect.sync(() => registry.dispose()), |
| 82 | 85 | ); |
| 83 | − return { atoms, registry }; | |
| 86 | + return { runtime, atoms, registry }; | |
| 84 | 87 | }); |
| 85 | 88 | |
| 86 | 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", () => · 125 unmodified lines | ||
| 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 | +); | |
| @@ -63,12 +63,14 @@ export function createLinkedPullRequestSummaryAtomFamily<R, E>( · 62 unmodified lines | ||
| 63 | 63 | /** The host-native stack a pull request belongs to; null where it is not stacked. */ |
| 64 | 64 | export function createPullRequestStackAtomFamily<R, E>( |
| 65 | 65 | runtime: Atom.AtomRuntime<EnvironmentRegistry | R, E>, |
| 66 | + refreshes = createPullRequestRefreshAtomFamily(runtime), | |
| 66 | 67 | ) { |
| 67 | 68 | return createEnvironmentRpcQueryAtomFamily(runtime, { |
| 68 | 69 | label: "environment-data:pull-requests:stack", |
| 69 | 70 | tag: WS_METHODS.pullRequestsStack, |
| 70 | 71 | staleTimeMs: 60_000, |
| 71 | 72 | idleTtlMs: LINKED_PULL_REQUEST_IDLE_TTL_MS, |
| 73 | + refreshTrigger: ({ environmentId }) => refreshes({ environmentId, input: {} }), | |
| 72 | 74 | }); |
| 73 | 75 | } |
| 74 | 76 | |
| @@ -571,3 +571,79 @@ describe("resolveViewedImageAsset", () => { · 570 unmodified lines | ||
| 571 | 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 | +}); | |
| @@ -8,6 +8,7 @@ import { · 7 unmodified lines | ||
| 8 | 8 | } from "@t3tools/contracts"; |
| 9 | 9 | import { classifyMarkdownImageSource } from "@t3tools/client-runtime/markdown-images"; |
| 10 | 10 | import { resolveMediaSource } from "@t3tools/client-runtime/media-source"; |
| 11 | +import { parseChangeRequestUrl } from "@t3tools/shared/changeRequestUrl"; | |
| 11 | 12 | import { isWorkspaceImagePreviewPath } from "@t3tools/shared/filePreview"; |
| 12 | 13 | |
| 13 | 14 | export function isWorktreeSetupActivity(kind: string): boolean { |
| @@ -36,6 +37,9 @@ export interface WorkLogPresentationEntry { · 22 unmodified lines | ||
| 36 | 37 | } |
| 37 | 38 | |
| 38 | 39 | export type ToolGroupAction = |
| 40 | + | "link-pr" | |
| 41 | + | "unlink-pr" | |
| 42 | + | "list-prs" | |
| 39 | 43 | | "read" |
| 40 | 44 | | "edit" |
| 41 | 45 | | "command" |
| @@ -46,6 +50,7 @@ export type ToolGroupAction = · 4 unmodified lines | ||
| 46 | 50 | | "update"; |
| 47 | 51 | |
| 48 | 52 | export type ToolGroupSummaryKind = |
| 53 | + | "pull-request" | |
| 49 | 54 | | ToolGroupAction |
| 50 | 55 | | "dynamic-tool" |
| 51 | 56 | | "agent-tool" |
| @@ -60,6 +65,9 @@ const T3_MCP_TOOL_LABELS: Record< · 8 unmodified lines | ||
| 60 | 65 | string, |
| 61 | 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 | 71 | orchestrator_capabilities: ["Get", "Getting", "Got", "orchestration capabilities"], |
| 64 | 72 | delegate_task: ["Delegate", "Delegating", "Delegated", "a child task"], |
| 65 | 73 | task_status: ["Get", "Getting", "Got", "delegated task status"], |
| @@ -98,7 +106,17 @@ const T3_MCP_TOOL_LABELS: Record< · 32 unmodified lines | ||
| 98 | 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 | 120 | if (!value) return null; |
| 103 | 121 | const name = normalizeCompactToolLabel(value).replace( |
| 104 | 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 · 15 unmodified lines | ||
| 120 | 138 | ? `Stopped ${running.toLowerCase()}` |
| 121 | 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 | 155 | return { |
| 124 | − displayName: `${verb} ${detail}`, | |
| 125 | − icon: name.startsWith("preview_") ? ("browser" as const) : ("t3-code" as const), | |
| 156 | + displayName: `${verb} ${target}`, | |
| 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( · 18 unmodified lines | ||
| 147 | 185 | "tool" in data && |
| 148 | 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 | 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 | 195 | return ( |
| 158 | − resolveT3McpToolPresentation(entry.toolTitle, status) ?? | |
| 159 | − resolveT3McpToolPresentation(entry.label, status) | |
| 196 | + resolveT3McpToolPresentation(entry.toolTitle, status, data) ?? | |
| 197 | + resolveT3McpToolPresentation(entry.label, status, data) | |
| 160 | 198 | ); |
| 161 | 199 | } |
| 162 | 200 | |
| @@ -410,7 +448,9 @@ export function toolGroupAction(entry: WorkLogPresentationEntry): ToolGroupActio · 247 unmodified lines | ||
| 410 | 448 | ) { |
| 411 | 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 | 454 | if ( |
| 415 | 455 | entry.requestKind === "file-read" || |
| 416 | 456 | entry.itemType === "image_view" || |
| @@ -504,6 +544,14 @@ function toolGroupActionCount( · 87 unmodified lines | ||
| 504 | 544 | |
| 505 | 545 | function toolGroupActionLabel(action: ToolGroupAction, count: number): string { |
| 506 | 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 | 555 | case "read": |
| 508 | 556 | return `Read ${count} ${count === 1 ? "file" : "files"}`; |
| 509 | 557 | case "edit": |
| @@ -528,7 +576,7 @@ export function summarizeToolGroup(entries: ReadonlyArray<WorkLogPresentationEnt · 18 unmodified lines | ||
| 528 | 576 | const sources = new Map<string, ToolActivitySource>(); |
| 529 | 577 | const groupedEntries = new Map<ToolGroupAction, WorkLogPresentationEntry[]>(); |
| 530 | 578 | for (const entry of summaryEntries) { |
| 531 | − if (entry.toolSource) { | |
| 579 | + if (entry.toolSource && resolveWorkEntryToolPresentation(entry)?.icon !== "pull-request") { | |
| 532 | 580 | sources.set(entry.toolSource.key, entry.toolSource); |
| 533 | 581 | continue; |
| 534 | 582 | } |
| @@ -601,6 +649,11 @@ export function omitSupersededLifecycleMarkers<T>( · 66 unmodified lines | ||
| 601 | 649 | export function toolGroupSummaryKind( |
| 602 | 650 | entries: ReadonlyArray<WorkLogPresentationEntry>, |
| 603 | 651 | ): ToolGroupSummaryKind { |
| 652 | + if ( | |
| 653 | + entries.length > 0 && | |
| 654 | + entries.every((entry) => resolveWorkEntryToolPresentation(entry)?.icon === "pull-request") | |
| 655 | + ) | |
| 656 | + return "pull-request"; | |
| 604 | 657 | const actions = new Set(entries.map(toolGroupAction)); |
| 605 | 658 | if (actions.size !== 1) return "mixed"; |
| 606 | 659 | |
| @@ -130,6 +130,7 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ · 129 unmodified lines | ||
| 130 | 130 | threads, and routes PullRequestRef.host across projects on the same host. Same |
| 131 | 131 | version-skew contract as threadSettlement. */ |
| 132 | 132 | threadPullRequests: Schema.optionalKey(Schema.Boolean), |
| 133 | + pullRequestStackActions: Schema.optionalKey(Schema.Boolean), | |
| 133 | 134 | /** The update path clients should offer for this server. Absent on |
| 134 | 135 | servers that must be relaunched manually (dev checkouts, Windows |
| 135 | 136 | foreground runs, pre-update servers). */ |
| @@ -418,6 +418,7 @@ export const PullRequestCapabilities = Schema.Struct({ · 417 unmodified lines | ||
| 418 | 418 | * the stack the host shows. Absent means chains are only ever inferred from base branches. |
| 419 | 419 | */ |
| 420 | 420 | stacks: Schema.optional(Schema.Boolean), |
| 421 | + stackActions: Schema.optional(Schema.Boolean), | |
| 421 | 422 | /** |
| 422 | 423 | * The repository's labels can be listed, and one put on a change request or taken off it. |
| 423 | 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; · 14 unmodified lines | ||
| 438 | 439 | * offering one they may not use ends in the host's own refusal — which at least says why. |
| 439 | 440 | */ |
| 440 | 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 | 444 | /** Which of the actions this viewer may take; anything absent is theirs to look at only. */ |
| 442 | 445 | actions: Schema.Array(PullRequestAction), |
| 443 | 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({ · 25 unmodified lines | ||
| 469 | 472 | }); |
| 470 | 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 | 483 | export const PullRequestListEntry = Schema.Struct({ |
| 484 | + stack: Schema.optional(PullRequestStackMembership), | |
| 473 | 485 | provider: SourceControlProviderKind, |
| 474 | 486 | /** |
| 475 | 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({ · 210 unmodified lines | ||
| 686 | 698 | layers: Schema.Array( |
| 687 | 699 | Schema.Struct({ |
| 688 | 700 | number: PositiveInt, |
| 701 | + title: Schema.optional(Schema.String), | |
| 702 | + isDraft: Schema.optional(Schema.Boolean), | |
| 703 | + headSha: Schema.optional(TrimmedNonEmptyString), | |
| 689 | 704 | headBranch: TrimmedNonEmptyString, |
| 690 | 705 | state: PullRequestState, |
| 691 | 706 | }), |
| @@ -908,7 +923,16 @@ export const PullRequestDiffFileContentsResult = Schema.Struct({ · 216 unmodified lines | ||
| 908 | 923 | }); |
| 909 | 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 | 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 | 936 | ...PullRequestRef.fields, |
| 913 | 937 | action: PullRequestAction, |
| 914 | 938 | /** |