authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-09 15:50:59-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-09 15:50:59-07:00
logde37964db26bd3260d253f138bfde2ac60fe254e
tree4af30ad8428fb97e56286dfe0b173d4df018b13e
parentf0401c629038100edafc496aca79fdc53d1d4db8
signature Signed by PGP key B5690EEEBB952194

feat(prs): navigate, merge and rebase GitHub stacks (#10875)


53 files changed, 3104 insertions(+), 464 deletions(-)

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