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;
7676const WORK_LOG_LAYOUT_TRANSITION = LinearTransition.duration(THREAD_DISCLOSURE_TRANSITION_MS);
7777const WORK_LOG_DETAIL_ENTER_TRANSITION = FadeIn.duration(140);
7878const WORK_LOG_DETAIL_EXIT_TRANSITION = FadeOut.duration(120);
79type WorkContentIcon = AppSymbolName | "browser" | "t3-code";
79type WorkContentIcon = AppSymbolName | "browser" | "t3-code" | "pull-request";
8080
8181function WorkLogIcon(props: {
8282 readonly icon: WorkContentIcon;
......@@ -92,7 +92,13 @@ function WorkLogIcon(props: {
9292 }
9393 return (
9494 <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 }
96102 size={14}
97103 weight="medium"
98104 {...(colorClassName ? { tintColorClassName: colorClassName } : { tintColor: props.color })}
......@@ -893,7 +899,7 @@ export function ThreadWorkGroupToggle(props: {
893899 readonly iconSubtleColor: import("react-native").ColorValue;
894900 readonly summary: string;
895901 readonly summaryKind: ToolGroupSummaryKind;
896 readonly summaryToolIcon?: "browser" | "t3-code";
902 readonly summaryToolIcon?: "browser" | "t3-code" | "pull-request";
897903 readonly themeAppearance: "light" | "dark";
898904 readonly toolSurface?: import("@t3tools/contracts").ToolActivitySurface;
899905 readonly toolIcon?: ToolActivityIcon;
......@@ -1231,6 +1237,11 @@ function ToolActivityImage(props: {
12311237
12321238function toolGroupSummarySymbolName(kind: ToolGroupSummaryKind): AppSymbolName {
12331239 switch (kind) {
1240 case "pull-request":
1241 case "link-pr":
1242 case "unlink-pr":
1243 case "list-prs":
1244 return "arrow.triangle.pull";
12341245 case "read":
12351246 return { ios: "eye", android: "visibility" };
12361247 case "edit":
apps/mobile/src/lib/threadActivity.ts+1-1
......@@ -167,7 +167,7 @@ export type ThreadFeedEntry =
167167 readonly summaryKind: ToolGroupSummaryKind;
168168 readonly toolSurface?: WorkLogEntry["toolSurface"];
169169 readonly toolIcon?: WorkLogEntry["toolIcon"];
170 readonly summaryToolIcon?: "browser" | "t3-code";
170 readonly summaryToolIcon?: "browser" | "t3-code" | "pull-request";
171171 readonly hasFailure: boolean;
172172 readonly live: boolean;
173173 readonly shimmer: boolean;
apps/server/src/environment/ServerEnvironment.ts+1
......@@ -231,6 +231,7 @@ export const make = Effect.gen(function* () {
231231 threadActiveReorder: true,
232232 threadTitleRegeneration: true,
233233 threadPullRequests: true,
234 pullRequestStackActions: true,
234235 threadPullRequestLinking: true,
235236 environmentIcon: true,
236237 ...(serverSelfUpdate === null ? {} : { serverSelfUpdate }),
apps/server/src/orchestration/PullRequestSyncReactor.test.ts+2-1
......@@ -178,8 +178,9 @@ const makeHarness = Effect.fn("makePullRequestSyncHarness")(function* (options:
178178 return yield* options.summary?.(input) ?? Effect.succeed(makeSummary(input));
179179 });
180180
181 const stack: PullRequestService["Service"]["stack"] = (input) =>
181 const stack: PullRequestService["Service"]["stack"] = (input, readOptions) =>
182182 Effect.gen(function* () {
183 assert.strictEqual(readOptions?.includeDetails, false);
183184 yield* Ref.update(stackCalls, (calls) => [...calls, input]);
184185 return yield* options.stack?.(input) ?? Effect.succeed(null);
185186 });
apps/server/src/orchestration/PullRequestSyncReactor.ts+1-1
......@@ -256,7 +256,7 @@ export const make = Effect.gen(function* () {
256256 entry.link.snapshot === null || !snapshotFieldsEqual(entry.link.snapshot, fields),
257257 );
258258 const fetchedStack = needsStack
259 ? yield* pullRequests.stack(ref).pipe(
259 ? yield* pullRequests.stack(ref, { includeDetails: false }).pipe(
260260 Effect.map((stack) => ({
261261 stack: stack === null ? null : ({ kind: "native", ...stack } as const),
262262 })),
apps/server/src/orchestration/ThreadPullRequestReactor.test.ts+121-92
......@@ -1,9 +1,11 @@
11import {
2 CheckpointRef,
23 EventId,
34 GitManagerError,
45 ProjectId,
56 ProviderInstanceId,
67 ThreadId,
8 TurnId,
79 type OrchestrationCommand,
810 type OrchestrationEvent,
911 type OrchestrationProjectShell,
......@@ -311,54 +313,79 @@ describe("ThreadPullRequestReactor", () => {
311313 ),
312314 );
313315
314 it.effect("refreshes discovery after a turn ends without client demand", () =>
315 Effect.scoped(
316 Effect.gen(function* () {
317 const current = thread("turn-thread");
318 const detected = yield* Ref.make<GitBranchPullRequest | null>(null);
319 const fixture = yield* makeHarness({
320 threads: [current],
321 branchPullRequest: (_input, options) =>
322 options?.refresh
323 ? Ref.set(detected, branchPullRequest()).pipe(Effect.andThen(Ref.get(detected)))
324 : Ref.get(detected),
325 });
326 yield* Effect.gen(function* () {
327 const reactor = yield* fixture.start();
328 expect(yield* Ref.get(fixture.commands)).toHaveLength(0);
329 yield* fixture.publish({
330 type: "thread.session-set",
331 sequence: 2,
332 eventId: EventId.make("turn-finished"),
333 aggregateKind: "thread",
334 aggregateId: current.id,
335 occurredAt: NOW,
336 commandId: null,
337 causationEventId: null,
338 correlationId: null,
339 metadata: {},
340 payload: {
341 threadId: current.id,
342 session: {
316 it.effect.each(["session-first", "checkpoint-first"] as const)(
317 "refreshes discovery once after a turn ends with %s event ordering",
318 (order) =>
319 Effect.scoped(
320 Effect.gen(function* () {
321 const current = thread("turn-thread");
322 const detected = yield* Ref.make<GitBranchPullRequest | null>(null);
323 const fixture = yield* makeHarness({
324 threads: [current],
325 branchPullRequest: (_input, options) =>
326 options?.refresh
327 ? Ref.set(detected, branchPullRequest()).pipe(Effect.andThen(Ref.get(detected)))
328 : Ref.get(detected),
329 });
330 yield* Effect.gen(function* () {
331 const reactor = yield* fixture.start();
332 expect(yield* Ref.get(fixture.commands)).toHaveLength(0);
333 const sessionEvent: OrchestrationEvent = {
334 type: "thread.session-set",
335 sequence: 2,
336 eventId: EventId.make("turn-finished"),
337 aggregateKind: "thread",
338 aggregateId: current.id,
339 occurredAt: NOW,
340 commandId: null,
341 causationEventId: null,
342 correlationId: null,
343 metadata: {},
344 payload: {
343345 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"),
344367 status: "ready",
345 providerName: "Codex",
346 runtimeMode: "full-access",
347 activeTurnId: null,
348 lastError: null,
349 updatedAt: NOW,
368 files: [],
369 assistantMessageId: null,
370 completedAt: NOW,
350371 },
351 },
352 });
353 yield* Queue.take(fixture.reads);
354 yield* reactor.drain;
355 expect((yield* Ref.get(fixture.commands))[0]?.branchPullRequest).toEqual(reference(42));
356 expect((yield* Ref.get(fixture.branchCalls)).filter((call) => call.refresh)).toHaveLength(
357 1,
358 );
359 }).pipe(Effect.provide(fixture.layer));
360 }),
361 ),
372 };
373 const events =
374 order === "session-first"
375 ? [sessionEvent, checkpointEvent]
376 : [checkpointEvent, sessionEvent];
377 for (const event of events) {
378 yield* fixture.publish(event);
379 yield* Queue.take(fixture.reads);
380 yield* reactor.drain;
381 }
382 expect((yield* Ref.get(fixture.commands))[0]?.branchPullRequest).toEqual(reference(42));
383 expect(
384 (yield* Ref.get(fixture.branchCalls)).filter((call) => call.refresh),
385 ).toHaveLength(1);
386 }).pipe(Effect.provide(fixture.layer));
387 }),
388 ),
362389 );
363390
364391 it.effect("uses live worktrees and falls back to the project for removed worktrees", () =>
......@@ -455,54 +482,56 @@ describe("ThreadPullRequestReactor", () => {
455482 ),
456483 );
457484
458 it.effect("retries failed settled backfills and stops querying them after success", () =>
459 Effect.scoped(
460 Effect.gen(function* () {
461 const online = yield* Ref.make(false);
462 const fixture = yield* makeHarness({
463 threads: [
464 thread("backfill", { settledOverride: "settled", settledAt: NOW }),
465 thread("known", {
466 branch: "known",
467 settledOverride: "settled",
468 settledAt: NOW,
469 branchPullRequest: reference(1),
470 }),
471 ],
472 branchPullRequest: ({ cwd }) =>
473 Ref.get(online).pipe(
474 Effect.flatMap((connected) =>
475 connected
476 ? Effect.succeed(branchPullRequest(42, "merged"))
477 : Effect.fail(
478 new GitManagerError({
479 operation: "branchPullRequest",
480 cwd,
481 detail: "Offline",
482 }),
483 ),
485 it.effect.each(["settled", null] as const)(
486 "retries settled backfills and stops after success with settledOverride %s",
487 (settledOverride) =>
488 Effect.scoped(
489 Effect.gen(function* () {
490 const online = yield* Ref.make(false);
491 const fixture = yield* makeHarness({
492 threads: [
493 thread("backfill", { settledOverride, settledAt: NOW }),
494 thread("known", {
495 branch: "known",
496 settledOverride,
497 settledAt: NOW,
498 branchPullRequest: reference(1),
499 }),
500 ],
501 branchPullRequest: ({ cwd }) =>
502 Ref.get(online).pipe(
503 Effect.flatMap((connected) =>
504 connected
505 ? Effect.succeed(branchPullRequest(42, "merged"))
506 : Effect.fail(
507 new GitManagerError({
508 operation: "branchPullRequest",
509 cwd,
510 detail: "Offline",
511 }),
512 ),
513 ),
484514 ),
485 ),
486 });
487 yield* Effect.gen(function* () {
488 const reactor = yield* fixture.start();
489 expect(yield* Ref.get(fixture.commands)).toHaveLength(0);
490 yield* Ref.set(online, true);
491 yield* TestClock.adjust("1 minute");
492 yield* Queue.take(fixture.reads);
493 yield* reactor.drain;
494 expect((yield* Ref.get(fixture.commands))[0]?.threadId).toBe("backfill");
495 yield* TestClock.adjust("1 minute");
496 yield* Queue.take(fixture.reads);
497 yield* reactor.drain;
498 expect((yield* Ref.get(fixture.branchCalls)).map((call) => call.branch)).toEqual([
499 "feature",
500 "feature",
501 "feature",
502 ]);
503 }).pipe(Effect.provide(fixture.layer));
504 }),
505 ),
515 });
516 yield* Effect.gen(function* () {
517 const reactor = yield* fixture.start();
518 expect(yield* Ref.get(fixture.commands)).toHaveLength(0);
519 yield* Ref.set(online, true);
520 yield* TestClock.adjust("1 minute");
521 yield* Queue.take(fixture.reads);
522 yield* reactor.drain;
523 expect((yield* Ref.get(fixture.commands))[0]?.threadId).toBe("backfill");
524 yield* TestClock.adjust("1 minute");
525 yield* Queue.take(fixture.reads);
526 yield* reactor.drain;
527 expect((yield* Ref.get(fixture.branchCalls)).map((call) => call.branch)).toEqual([
528 "feature",
529 "feature",
530 "feature",
531 ]);
532 }).pipe(Effect.provide(fixture.layer));
533 }),
534 ),
506535 );
507536
508537 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* () {
101101 const projects = new Map(snapshot.projects.map((project) => [project.id, project]));
102102 if (request.backfill) {
103103 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 ) {
105108 pendingBackfill.set(thread.id, BACKFILL_ATTEMPTS);
106109 }
107110 }
......@@ -114,7 +117,7 @@ export const make = Effect.gen(function* () {
114117 (thread) =>
115118 thread.archivedAt === null &&
116119 (request.threadId === null || thread.id === request.threadId) &&
117 (thread.settledOverride !== "settled" ||
120 ((thread.settledOverride !== "settled" && thread.settledAt === null) ||
118121 request.threadId !== null ||
119122 pendingBackfill.has(thread.id)) &&
120123 (thread.branch !== null || thread.branchPullRequest != null),
......@@ -327,7 +330,9 @@ export const make = Effect.gen(function* () {
327330 event.payload.session.status !== "running" &&
328331 event.payload.session.status !== "starting"
329332 ) {
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 });
331336 }
332337 break;
333338 case "thread.turn-diff-completed":
apps/server/src/pullRequest/GitHubPullRequestCli.test.ts+177-1
......@@ -1,6 +1,7 @@
11import { afterEach, assert, expect, it, vi } from "@effect/vitest";
22import * as Effect from "effect/Effect";
33import * as Layer from "effect/Layer";
4import * as Schema from "effect/Schema";
45import * as TestClock from "effect/testing/TestClock";
56import { ChildProcessSpawner } from "effect/unstable/process";
67
......@@ -9,14 +10,22 @@ import * as GitHubGraphQlBudget from "../sourceControl/githubGraphQlBudget.ts";
910import * as GitHubPullRequestCli from "./GitHubPullRequestCli.ts";
1011import { BASE_COMPARISON_GRAPHQL_QUERY } from "./gitHubPullRequestJson.ts";
1112
13const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));
14
1215const mockedExecute = vi.fn<GitHubCli.GitHubCli["Service"]["execute"]>();
16const mockedStackMemberships = vi.fn<GitHubCli.GitHubCli["Service"]["execute"]>(() =>
17 Effect.succeed(output('{"data":{}}')),
18);
1319const mockedGetPullRequest = vi.fn<GitHubCli.GitHubCli["Service"]["getPullRequest"]>();
1420
1521const layer = it.layer(
1622 GitHubPullRequestCli.layer.pipe(
1723 Layer.provide(
1824 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),
2029 getPullRequest: mockedGetPullRequest,
2130 }),
2231 ),
......@@ -179,6 +188,7 @@ function searchQueryOfCall(index: number): string | undefined {
179188
180189afterEach(() => {
181190 mockedExecute.mockReset();
191 mockedStackMemberships.mockReset();
182192 mockedGetPullRequest.mockReset();
183193});
184194
......@@ -320,6 +330,51 @@ layer("GitHubPullRequestCli.layer", (it) => {
320330 }),
321331 );
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
323378 it.effect("reads an empty stacks listing as not stacked", () =>
324379 Effect.gen(function* () {
325380 mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]")));
......@@ -715,6 +770,127 @@ layer("GitHubPullRequestCli.layer", (it) => {
715770 }),
716771 );
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
718894 it.effect("reads the line counts in chunks, and files them back by position", () =>
719895 Effect.gen(function* () {
720896 const changeRequests = Array.from({ length: 26 }, (_, index) => ({
apps/server/src/pullRequest/GitHubPullRequestCli.ts+82-1
......@@ -1,3 +1,4 @@
1import { runGitHubStackAction, type GitHubStackActionError } from "./githubStackActions.ts";
12import * as Context from "effect/Context";
23import * as Effect from "effect/Effect";
34import * as Layer from "effect/Layer";
......@@ -6,6 +7,7 @@ import * as Schema from "effect/Schema";
67import {
78 resolvePullRequestAuthorFilter,
89 type PullRequestAction,
10 type PullRequestStackHead,
911 type PullRequestActor,
1012 type PullRequestInvolvement,
1113 type PullRequestListFilters,
......@@ -51,6 +53,8 @@ import {
5153 decodeReviewThreadCommentsJson,
5254 decodeReviewThreadsJson,
5355 buildPullRequestStatsGraphQlQuery,
56 buildPullRequestStackMembershipsGraphQlQuery,
57 decodePullRequestStackMembershipsJson,
5458 encodeGraphQlRequestJson,
5559 pullRequestSearchGraphQlQuery,
5660 PULL_REQUEST_SEARCH_MAX_ROWS,
......@@ -334,6 +338,7 @@ export class GitHubWorkflowApprovalHeadChangedError extends Schema.TaggedError<G
334338}
335339
336340export type GitHubPullRequestCliError =
341 | GitHubStackActionError
337342 | GitHubCli.GitHubCliError
338343 | GitHubPullRequestReadError
339344 | GitHubDiffCursorError
......@@ -488,6 +493,7 @@ export class GitHubPullRequestCli extends Context.Service<
488493 */
489494 readonly getPullRequestStack: (input: {
490495 readonly cwd: string;
496 readonly includeDetails?: boolean;
491497 readonly repository: string;
492498 readonly host: string;
493499 readonly number: number;
......@@ -627,6 +633,8 @@ export class GitHubPullRequestCli extends Context.Service<
627633 readonly host: string;
628634 readonly number: number;
629635 readonly action: PullRequestAction;
636 readonly stackNumber?: number;
637 readonly expectedStackHeads?: ReadonlyArray<PullRequestStackHead>;
630638 readonly mergeMethod?: PullRequestMergeMethod;
631639 readonly updateMethod?: PullRequestUpdateMethod;
632640 }) => Effect.Effect<void, GitHubPullRequestCliError>;
......@@ -1557,6 +1565,47 @@ export const make = Effect.gen(function* () {
15571565 ? read(false)
15581566 : Effect.succeed(batch),
15591567 ),
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 }),
15601609 );
15611610 },
15621611
......@@ -1581,7 +1630,7 @@ export const make = Effect.gen(function* () {
15811630 operation: "searchPullRequests",
15821631 // The reader's own words are in the query, so it travels over stdin rather than in argv.
15831632 privateVariables: { q: query },
1584 query: pullRequestSearchGraphQlQuery(rows),
1633 query: pullRequestSearchGraphQlQuery(rows, input.host === "github.com"),
15851634 decode: decodePullRequestSearchJson,
15861635 }).pipe(
15871636 Effect.map((batch) => ({
......@@ -1709,6 +1758,34 @@ export const make = Effect.gen(function* () {
17091758 }),
17101759 );
17111760 }),
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 }),
17121789 // Hosts without the stacks preview return 404. Other failures must preserve the
17131790 // previously synced stack and let the caller retry.
17141791 Effect.catchTags({
......@@ -2109,6 +2186,10 @@ export const make = Effect.gen(function* () {
21092186 },
21102187
21112188 runPullRequestAction: (input) => {
2189 if (input.stackNumber !== undefined)
2190 return runGitHubStackAction({ ...input, stackNumber: input.stackNumber }).pipe(
2191 Effect.provideService(GitHubCli.GitHubCli, github),
2192 );
21122193 if (input.action === "revert") {
21132194 return pullRequestNodeId({ ...input, operation: "revertPullRequest" }).pipe(
21142195 Effect.flatMap((pullRequestId) =>
apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts+1
......@@ -130,6 +130,7 @@ describe("gitHubViewerPermissions", () => {
130130 ],
131131 comment: true,
132132 resolve: true,
133 stackRebase: true,
133134 verdicts: ["comment", "approve", "request-changes"],
134135 requestReviewers: true,
135136 labels: true,
apps/server/src/pullRequest/GitHubPullRequestProvider.ts+6
......@@ -48,6 +48,7 @@ const CAPABILITIES: PullRequestCapabilities = {
4848 reviewers: { request: true, listCandidates: true },
4949 edit: { changeRequest: true, comment: true },
5050 stacks: true,
51 stackActions: true,
5152 labels: true,
5253};
5354
......@@ -70,6 +71,7 @@ const CAPABILITIES: PullRequestCapabilities = {
7071 */
7172export function gitHubViewerPermissions(access: GitHubViewerAccess): PullRequestViewerPermissions {
7273 return {
74 ...(access.canWrite ? { stackRebase: true } : {}),
7375 actions: [
7476 // Arming a merge and taking the arming back are the merge, deferred: whoever may not
7577 // merge here may not leave an instruction to merge later either.
......@@ -558,6 +560,10 @@ export const make = Effect.gen(function* () {
558560 host: input.host,
559561 number: input.number,
560562 action: input.action,
563 ...(input.stackNumber === undefined ? {} : { stackNumber: input.stackNumber }),
564 ...(input.expectedStackHeads === undefined
565 ? {}
566 : { expectedStackHeads: input.expectedStackHeads }),
561567 ...(input.mergeMethod === undefined ? {} : { mergeMethod: input.mergeMethod }),
562568 ...(input.updateMethod === undefined ? {} : { updateMethod: input.updateMethod }),
563569 })
apps/server/src/pullRequest/PullRequestProvider.ts+9-1
......@@ -1,7 +1,9 @@
11import * as Effect from "effect/Effect";
22import * as Schema from "effect/Schema";
33import type {
4 PullRequestStackMembership,
45 PullRequestAction,
6 PullRequestStackHead,
57 PullRequestActor,
68 PullRequestBaseComparison,
79 PullRequestCapabilities,
......@@ -65,6 +67,7 @@ export interface PullRequestProviderFailure {
6567
6668/** A change request as the provider sees it, before the service attaches project context. */
6769export interface ProviderChangeRequest {
70 readonly stack?: PullRequestStackMembership;
6871 readonly number: number;
6972 readonly title: string;
7073 readonly url: string;
......@@ -115,6 +118,9 @@ export interface ProviderChangeRequestSummary {
115118
116119/** One layer of a host-native stack, bottom to top order is the array's. */
117120export interface ProviderChangeRequestStackLayer {
121 readonly title?: string;
122 readonly isDraft?: boolean;
123 readonly headSha?: string;
118124 readonly number: number;
119125 readonly headBranch: string;
120126 readonly state: PullRequestState;
......@@ -364,7 +370,7 @@ export interface PullRequestProviderApi {
364370 * because most hosts have no such object; the service derives chains from base branches there.
365371 */
366372 readonly getChangeRequestStack?: (
367 input: ProviderRepositoryRef & { readonly number: number },
373 input: ProviderRepositoryRef & { readonly includeDetails?: boolean; readonly number: number },
368374 ) => Effect.Effect<ProviderChangeRequestStack | null, PullRequestProviderError>;
369375
370376 /** Comments, line threads, and commits, kept off the critical path for the core detail. */
......@@ -426,6 +432,8 @@ export interface PullRequestProviderApi {
426432 input: ProviderRepositoryRef & {
427433 readonly number: number;
428434 readonly action: PullRequestAction;
435 readonly stackNumber?: number;
436 readonly expectedStackHeads?: ReadonlyArray<PullRequestStackHead>;
429437 /** Meaningful for `merge` and `enable-auto-merge`; absent takes the host's own default. */
430438 readonly mergeMethod?: PullRequestMergeMethod;
431439 /** 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",
15641564 }),
15651565);
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
15671609it.effect("reads a host-native stack through the provider and null where it has none", () =>
15681610 Effect.gen(function* () {
15691611 const service = yield* makeService({
......@@ -3997,6 +4039,91 @@ it.effect('resolves an author filter of "me" to the viewer before narrowing a ho
39974039 }),
39984040);
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
40004127it.effect("refuses a way of updating a branch that the host or the viewer does not allow", () =>
40014128 Effect.gen(function* () {
40024129 let taken: string | null = null;
apps/server/src/pullRequest/PullRequestService.ts+43-8
......@@ -155,6 +155,7 @@ export class PullRequestService extends Context.Service<
155155 */
156156 readonly stack: (
157157 input: PullRequestRef,
158 options?: { readonly includeDetails?: boolean },
158159 ) => Effect.Effect<PullRequestStack | null, PullRequestError>;
159160 readonly subscribeMerges: Effect.Effect<
160161 Stream.Stream<PullRequestMergeEvent>,
......@@ -907,6 +908,7 @@ export const make = Effect.gen(function* () {
907908 }): PullRequestListEntry => {
908909 const viewer = input.viewer.toLowerCase();
909910 return {
911 ...(input.item.stack === undefined ? {} : { stack: input.item.stack }),
910912 provider: input.project.api.kind,
911913 host: input.project.host,
912914 projectId: input.project.project.id,
......@@ -1331,7 +1333,7 @@ export const make = Effect.gen(function* () {
13311333 }),
13321334 );
13331335
1334 const stackUncached: PullRequestService["Service"]["stack"] = (input) =>
1336 const stackUncached: PullRequestService["Service"]["stack"] = (input, options) =>
13351337 requireProject(input).pipe(
13361338 Effect.flatMap((project) => {
13371339 const read = project.api.getChangeRequestStack;
......@@ -1341,6 +1343,7 @@ export const make = Effect.gen(function* () {
13411343 repository: project.repository,
13421344 host: project.host,
13431345 number: input.number,
1346 includeDetails: options?.includeDetails !== false,
13441347 }).pipe(
13451348 Effect.mapError(toPullRequestError("stack")),
13461349 Effect.map((stack): PullRequestStack | null =>
......@@ -1352,6 +1355,7 @@ export const make = Effect.gen(function* () {
13521355 url: stack.url,
13531356 base: stack.base,
13541357 layers: stack.layers.map((layer) => ({
1358 ...layer,
13551359 number: layer.number,
13561360 headBranch: layer.headBranch,
13571361 state: layer.state,
......@@ -1531,6 +1535,20 @@ export const make = Effect.gen(function* () {
15311535 const runAction = (input: PullRequestActionInput): Effect.Effect<string, PullRequestError> =>
15321536 requireProject(input).pipe(
15331537 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 }
15341552 // The surface hides what a host cannot do, and this refuses it as well: a request that
15351553 // reached here anyway must not be handed to a provider that never claimed the action.
15361554 if (!project.api.capabilities.actions.includes(input.action)) {
......@@ -1573,7 +1591,10 @@ export const make = Effect.gen(function* () {
15731591 // above do not.
15741592 return viewerPermissionsOf(project, input, "runAction").pipe(
15751593 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 ) {
15771598 return Effect.fail(
15781599 new PullRequestOperationError({
15791600 operation: "runAction",
......@@ -1582,6 +1603,7 @@ export const make = Effect.gen(function* () {
15821603 );
15831604 }
15841605 if (
1606 !stackRebase &&
15851607 input.updateMethod !== undefined &&
15861608 !(viewer.updateMethods ?? []).includes(input.updateMethod)
15871609 ) {
......@@ -1599,10 +1621,17 @@ export const make = Effect.gen(function* () {
15991621 host: project.host,
16001622 number: input.number,
16011623 action: input.action,
1624 ...(input.stackNumber === undefined ? {} : { stackNumber: input.stackNumber }),
1625 ...(input.expectedStackHeads === undefined
1626 ? {}
1627 : { expectedStackHeads: input.expectedStackHeads }),
16021628 ...(input.mergeMethod === undefined ? {} : { mergeMethod: input.mergeMethod }),
16031629 ...(input.updateMethod === undefined ? {} : { updateMethod: input.updateMethod }),
16041630 })
16051631 .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),
16061635 Effect.mapError(toPullRequestError("runAction")),
16071636 Effect.as(
16081637 project.api.kind === "azure-devops"
......@@ -2327,12 +2356,18 @@ export const make = Effect.gen(function* () {
23272356 );
23282357 };
23292358
2330 const stackCache = yield* Cache.makeWith((key: string) => stackUncached(refOfCacheKey(key)), {
2331 capacity: DETAIL_CACHE_CAPACITY,
2332 timeToLive: (exit) => (Exit.isSuccess(exit) ? SUMMARY_CACHE_TTL : Duration.zero),
2333 });
2334 const stack: PullRequestService["Service"]["stack"] = (input) =>
2335 Cache.get(stackCache, refCacheKey(input));
2359 const stackCache = yield* Cache.makeWith(
2360 (key: string) => {
2361 const [referenceKey, includeDetails] = JSON.parse(key) as [string, boolean];
2362 return stackUncached(refOfCacheKey(referenceKey), { includeDetails });
2363 },
2364 {
2365 capacity: DETAIL_CACHE_CAPACITY,
2366 timeToLive: (exit) => (Exit.isSuccess(exit) ? SUMMARY_CACHE_TTL : Duration.zero),
2367 },
2368 );
2369 const stack: PullRequestService["Service"]["stack"] = (input, options) =>
2370 Cache.get(stackCache, JSON.stringify([refCacheKey(input), options?.includeDetails !== false]));
23362371
23372372 // Keys serialize positionally and parse back in the lookup, so the cache is the only holder
23382373 // 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";
33
44import {
55 buildReviewSubmissionJson,
6 buildPullRequestStackMembershipsGraphQlQuery,
7 decodePullRequestStackMembershipsJson,
68 buildReviewerRequestJson,
79 decodeBaseComparisonJson,
810 decodePullRequestActivityJson,
......@@ -21,6 +23,7 @@ import {
2123 decodeWorkflowRunApprovalsJson,
2224 reviewThreadConversation,
2325 REVIEW_THREADS_GRAPHQL_QUERY,
26 pullRequestSearchGraphQlQuery,
2427} from "./gitHubPullRequestJson.ts";
2528
2629function listJson(entries: ReadonlyArray<Record<string, unknown>>): string {
......@@ -165,6 +168,17 @@ describe("pull request search decoding", () => {
165168 });
166169 }
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
168182 it("maps the rollup enum the search answers with onto the same three words", () => {
169183 // The search asks GitHub for the verdict rather than the checks behind it, so this path sees
170184 // one enum where the listing sees an array.
......@@ -1548,6 +1562,32 @@ describe("host-native stack decoding", () => {
15481562 });
15491563 });
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
15511591 it("accepts a base named as a bare branch, which is what the preview started out sending", () => {
15521592 expect(expectStack({ base: "develop" }).base).toBe("develop");
15531593 });
......@@ -1579,3 +1619,38 @@ describe("host-native stack decoding", () => {
15791619 expect(Result.isSuccess(decodePullRequestStacksJson("{"))).toBe(false);
15801620 });
15811621});
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";
33import * as Result from "effect/Result";
44import * as Schema from "effect/Schema";
55import type {
6 PullRequestStackMembership,
67 PullRequestActor,
78 PullRequestCheck,
89 PullRequestCheckStatus,
......@@ -111,12 +112,22 @@ const RawListItemSchema = Schema.Struct({
111112 statusCheckRollup: Schema.optional(Schema.NullOr(Schema.Array(RawCheckSchema))),
112113});
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
114124/**
115125 * A search's own answer, which is the listing's row one connection deeper: `gh pr list --json`
116126 * flattens reviewers and labels, and GraphQL does not. Everything below the row is optional
117127 * because a node that is not a pull request decodes as an empty object, which is skipped.
118128 */
119129const RawSearchItemSchema = Schema.Struct({
130 ...RawStackMembershipSchema.fields,
120131 number: Schema.Int,
121132 title: Schema.String,
122133 url: Schema.String,
......@@ -221,6 +232,13 @@ const RawStatsSchema = Schema.Struct({
221232 ),
222233});
223234
235const RawStackMembershipsSchema = Schema.Struct({
236 data: Schema.Record(
237 Schema.String,
238 Schema.NullOr(Schema.Struct({ pullRequest: Schema.NullOr(RawStackMembershipSchema) })),
239 ),
240});
241
224242/** How many of a reaction's people the hover names before it counts the rest. */
225243const REACTORS_PER_GROUP = 10;
226244
......@@ -648,12 +666,13 @@ export const PULL_REQUEST_SEARCH_MAX_ROWS = GRAPHQL_PAGE_SIZE;
648666 * than twenty labels shows twenty, and one that has asked more than twenty people for a review
649667 * is already past what a row can say.
650668 */
651export function pullRequestSearchGraphQlQuery(rows: number): string {
669export function pullRequestSearchGraphQlQuery(rows: number, includeStacks = false): string {
652670 return `query($q: String!) {
653671 search(query: $q, type: ISSUE, first: ${Math.min(Math.max(Math.trunc(rows), 1), PULL_REQUEST_SEARCH_MAX_ROWS)}) {
654672 pageInfo { hasNextPage }
655673 nodes {
656674 ... on PullRequest {
675 ${includeStacks ? "stack { number size baseRefName } stackEntry { position }" : ""}
657676 number
658677 title
659678 url
......@@ -1019,6 +1038,7 @@ export const REPOSITORY_ACCESS_JSON_FIELDS =
10191038 "mergeCommitAllowed,squashMergeAllowed,rebaseMergeAllowed,viewerPermission";
10201039
10211040export interface GitHubPullRequestListItem {
1041 readonly stack?: PullRequestStackMembership;
10221042 /** The author's node id, kept so a batch can resolve the avatar the listing does not carry. */
10231043 readonly authorId: string | null;
10241044 readonly number: number;
......@@ -1526,6 +1546,7 @@ export function decodePullRequestSearchJson(
15261546 const node = decodedNode.value;
15271547 const repository = trimmed(node.repository?.nameWithOwner);
15281548 if (repository === null) continue;
1549 const stack = toStackMembership(node);
15291550 items.push({
15301551 ...toListItem({
15311552 ...node,
......@@ -1541,6 +1562,7 @@ export function decodePullRequestSearchJson(
15411562 return state === null ? [] : [{ state }];
15421563 }),
15431564 }),
1565 ...(stack === undefined ? {} : { stack }),
15441566 repository,
15451567 });
15461568 }
......@@ -1551,6 +1573,19 @@ export function decodePullRequestSearchJson(
15511573 });
15521574}
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
15541589/** What a repository selector may hold before it is written into a GraphQL document unquoted. */
15551590const REPOSITORY_PART = /^[A-Za-z0-9._-]+$/;
15561591
......@@ -1581,6 +1616,42 @@ export function buildPullRequestStatsGraphQlQuery(
15811616 return `query {\n${selections.join("\n")}\n}`;
15821617}
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
15841655/**
15851656 * The counts by the position they were asked in. A repository or a pull request GitHub answered
15861657 * nothing for is simply absent, which leaves the row with whatever it already had.
......@@ -2448,8 +2519,10 @@ export function decodePullRequestFilesJson(
24482519
24492520/** One pull request as the stacks API lists it: a number, a head, and whether it is done. */
24502521const RawStackPullRequestSchema = Schema.Struct({
2522 title: Schema.optional(Schema.String),
2523 draft: Schema.optional(Schema.Boolean),
24512524 number: Schema.Int,
2452 head: Schema.Struct({ ref: Schema.String }),
2525 head: Schema.Struct({ ref: Schema.String, sha: Schema.optional(Schema.String) }),
24532526 state: Schema.optional(Schema.NullOr(Schema.String)),
24542527 merged_at: Schema.optional(Schema.NullOr(Schema.String)),
24552528});
......@@ -2473,6 +2546,9 @@ const RawStackSchema = Schema.Struct({
24732546const decodeStacks = decodeJsonResult(Schema.Array(RawStackSchema));
24742547
24752548export interface GitHubPullRequestStackLayer {
2549 readonly title?: string;
2550 readonly isDraft?: boolean;
2551 readonly headSha?: string;
24762552 readonly number: number;
24772553 readonly headBranch: string;
24782554 readonly state: PullRequestState;
......@@ -2505,6 +2581,9 @@ export function decodePullRequestStacksJson(
25052581 url: trimmed(stack.html_url) ?? stack.url,
25062582 base: typeof stack.base === "string" ? stack.base : stack.base.ref,
25072583 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 }),
25082587 number: pullRequest.number,
25092588 headBranch: pullRequest.head.ref,
25102589 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 @@
1import type { EnvironmentId, PullRequestLinkedThreadsResult } from "@t3tools/contracts";
2
3export interface CommandPaletteLinkedThreads {
4 readonly environmentId: EnvironmentId;
5 readonly threads: PullRequestLinkedThreadsResult["threads"];
6}
7
18// Tiny event bus allowing components to programmatically open the command palette
29// without owning its React state.
310const COMMAND_PALETTE_OPEN_EVENT = "t3code:open-command-palette";
411
512export interface CommandPaletteOpenDetail {
613 readonly open?: "add-project" | "new-thread-in";
14 readonly query?: string;
15 readonly linkedThreads?: CommandPaletteLinkedThreads;
716}
817
918export function openCommandPalette(detail?: CommandPaletteOpenDetail): void {
apps/web/src/components/BranchToolbarBranchSelector.tsx+42-43
......@@ -1,3 +1,6 @@
1import { useSupportsMultiplePullRequests } from "~/hooks/useSupportsMultiplePullRequests";
2import { resolveThreadCurrentPullRequestLink } from "@t3tools/shared/threadPullRequests";
3import { useRightPanelStore } from "../rightPanelStore";
14import { RefreshIcon } from "~/components/ui/refresh-icon";
25import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment";
36import {
......@@ -47,7 +50,12 @@ import {
4750 sanitizeNewRefName,
4851 shouldIncludeBranchPickerItem,
4952} from "./BranchToolbar.logic";
50import { ChangeRequestStatusIcon, prStatusIndicator } from "./ThreadStatusIndicators";
53import {
54 ThreadPullRequestBadgeControl,
55 prStatusIndicator,
56 resolveThreadPullRequestBadge,
57 useLinkedThreadPullRequest,
58} from "./ThreadStatusIndicators";
5159import { Button } from "./ui/button";
5260import { Switch } from "./ui/switch";
5361import { getVirtualizedScrollFadeClassName } from "./ui/scroll-area";
......@@ -612,7 +620,7 @@ export function BranchToolbarBranchSelector({
612620 startFromOrigin,
613621 });
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.
616624 const branchPrBranch = resolveBranchToolbarPrBranch({
617625 activeThreadBranch,
618626 resolvedActiveBranch,
......@@ -621,12 +629,27 @@ export function BranchToolbarBranchSelector({
621629 branchPrBranch !== null && branchStatusQuery.data?.refName === branchPrBranch
622630 ? (branchStatusQuery.data.pr ?? null)
623631 : null;
624 const branchPrStatus = prStatusIndicator(branchPr, branchStatusQuery.data?.sourceControlProvider);
625 // Action-oriented tooltip (the pill opens the PR), distinct from the sidebar's
626 // state-description tooltip.
627 const branchPrTooltip = branchPr
628 ? `Open ${sourceControlPresentation.terminology.singular} #${branchPr.number} (${branchPr.state})`
629 : "";
632 const supportsMultiplePullRequests = useSupportsMultiplePullRequests(environmentId);
633 const linkedStatus = useLinkedThreadPullRequest(
634 environmentId,
635 serverThread?.linkedPullRequest,
636 true,
637 serverThread?.pullRequests,
638 serverThread?.branchPullRequest,
639 );
640 const currentLinkedPr = supportsMultiplePullRequests
641 ? resolveThreadCurrentPullRequestLink(serverThread?.pullRequests ?? [])
642 : null;
643 const prBadge = supportsMultiplePullRequests
644 ? resolveThreadPullRequestBadge(serverThread?.pullRequests)
645 : null;
646 const displayedPr = linkedStatus?.pr ?? (currentLinkedPr === null ? branchPr : null);
647 const displayedPrStatus = prStatusIndicator(
648 displayedPr,
649 linkedStatus?.sourceControlProvider ?? branchStatusQuery.data?.sourceControlProvider,
650 );
651 const prNumber = currentLinkedPr?.number ?? displayedPr?.number;
652 const prUrl = currentLinkedPr?.url ?? displayedPr?.url;
630653 const openPrLink = useOpenPrLink(threadRef);
631654
632655 function renderPickerItem(itemValue: string, index: number) {
......@@ -730,41 +753,17 @@ export function BranchToolbarBranchSelector({
730753 className={cn("flex min-w-0 items-center gap-1", className)}
731754 data-composer-context-control
732755 >
733 {branchPr && branchPrStatus ? (
734 <Tooltip>
735 <TooltipTrigger
736 render={
737 <button
738 type="button"
739 aria-label={branchPrTooltip}
740 onClick={(event) => openPrLink(event, branchPrStatus.url)}
741 className={cn(
742 "inline-flex shrink-0 items-center gap-0.5 rounded px-1 py-0.5 text-[11px] font-medium tabular-nums transition-colors hover:bg-muted/60",
743 branchPrStatus.colorClass,
744 )}
745 />
746 }
747 >
748 <ChangeRequestStatusIcon
749 state={branchPr.state}
750 isDraft={branchPr.isDraft}
751 className="size-3"
752 />
753 <span
754 data-composer-label
755 className="min-w-0 max-w-12 overflow-hidden group-data-[compact]/composer-context:max-w-0"
756 >
757 <span
758 data-composer-label-motion
759 className="block w-full min-w-0 max-w-12 truncate transition-opacity duration-180 ease-[cubic-bezier(0.32,0.72,0,1)] group-data-[compact]/composer-context:opacity-0 motion-reduce:transition-none"
760 >
761 #{branchPr.number}
762 </span>
763 </span>
764 </TooltipTrigger>
765 <TooltipPopup side="top">{branchPrTooltip}</TooltipPopup>
766 </Tooltip>
767 ) : null}
756 <ThreadPullRequestBadgeControl
757 variant="ghost"
758 badge={prBadge}
759 number={prNumber}
760 url={prUrl}
761 status={displayedPrStatus}
762 onOpenStack={() => useRightPanelStore.getState().open(threadRef, "pull-requests")}
763 onOpenPullRequest={(event) => {
764 if (prUrl) openPrLink(event, prUrl);
765 }}
766 />
768767 {/* Context menu lives on the wrapper: the disabled Button has
769768 pointer-events-none, so the trigger itself never sees right-clicks
770769 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) {
80548054 <PullRequestDetailPanel
80558055 key={`${renderedRightPanelSurface.host ?? ""}:${renderedRightPanelSurface.repository}#${renderedRightPanelSurface.number}`}
80568056 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 }}
80578066 threadRef={activeThreadRef}
80588067 reference={{
80598068 projectId: renderedRightPanelSurface.projectId as ProjectId,
apps/web/src/components/CommandPalette.logic.test.ts+69
......@@ -5,6 +5,7 @@ import {
55 buildBrowseGroups,
66 buildProjectActionItems,
77 buildThreadActionItems,
8 buildLinkedThreadActionItems,
89 enumerateCommandPaletteItems,
910 filterPinnedBrowseEntries,
1011 filterCommandPaletteGroups,
......@@ -12,6 +13,48 @@ import {
1213 type CommandPaletteGroup,
1314} 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
1558describe("reduceCommandPaletteUiState", () => {
1659 const closedState = { open: false, mode: "command", openIntent: null } as const;
1760
......@@ -47,6 +90,32 @@ describe("reduceCommandPaletteUiState", () => {
4790 );
4891 });
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
50119 it("routes open intents to command mode", () => {
51120 const filesOpen = reduceCommandPaletteUiState(closedState, {
52121 _tag: "ToggleMode",
apps/web/src/components/CommandPalette.logic.ts+42-3
......@@ -1,4 +1,5 @@
11import { threadPullRequestSearchTerms } from "@t3tools/shared/threadPullRequests";
2import type { CommandPaletteLinkedThreads } from "../commandPaletteBus";
23import {
34 type FilesystemBrowseEntry,
45 type KeybindingCommand,
......@@ -18,6 +19,25 @@ export const RECENT_THREAD_LIMIT = 12;
1819export const ITEM_ICON_CLASS = "size-4 text-icon-muted";
1920export 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
2141export function browseInputEndPaddingClass(input: {
2242 readonly willCreateProjectPath: boolean;
2343 readonly hasHighlightedBrowseItem: boolean;
......@@ -39,9 +59,13 @@ export function browseInputEndPaddingClass(input: {
3959 */
4060export type SearchOverlayMode = "command" | "files" | "content";
4161
42export interface CommandPaletteOpenIntent {
43 readonly kind: "add-project" | "new-thread-in";
44}
62export type CommandPaletteOpenIntent =
63 | { readonly kind: "add-project" | "new-thread-in" }
64 | {
65 readonly kind: "search";
66 readonly query: string;
67 readonly linkedThreads?: CommandPaletteLinkedThreads;
68 };
4569
4670export interface CommandPaletteUiState {
4771 readonly open: boolean;
......@@ -52,6 +76,11 @@ export interface CommandPaletteUiState {
5276export type CommandPaletteUiAction =
5377 | { readonly _tag: "SetOpen"; readonly open: boolean }
5478 | { readonly _tag: "ToggleMode"; readonly mode: SearchOverlayMode }
79 | {
80 readonly _tag: "OpenSearch";
81 readonly query: string;
82 readonly linkedThreads?: CommandPaletteLinkedThreads;
83 }
5584 | { readonly _tag: "OpenAddProject" }
5685 | { readonly _tag: "OpenNewThreadIn" }
5786 | { readonly _tag: "ClearOpenIntent" };
......@@ -69,6 +98,16 @@ export function reduceCommandPaletteUiState(
6998 return state.open && state.mode === action.mode
7099 ? { ...state, open: false, openIntent: null }
71100 : { 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 };
72111 case "OpenAddProject":
73112 return { open: true, mode: "command", openIntent: { kind: "add-project" } };
74113 case "OpenNewThreadIn":
apps/web/src/components/CommandPalette.tsx+40-2
......@@ -129,6 +129,7 @@ import {
129129 buildProjectActionItems,
130130 buildRootGroups,
131131 buildThreadActionItems,
132 buildLinkedThreadActionItems,
132133 enumerateCommandPaletteItems,
133134 type CommandPaletteActionItem,
134135 type CommandPaletteOpenIntent,
......@@ -482,6 +483,12 @@ export function CommandPalette({ children }: { children: ReactNode }) {
482483 openNewThreadIn();
483484 } else if (detail.open === "add-project") {
484485 openAddProject();
486 } else if (detail.query !== undefined) {
487 dispatch({
488 _tag: "OpenSearch",
489 query: detail.query,
490 ...(detail.linkedThreads ? { linkedThreads: detail.linkedThreads } : {}),
491 });
485492 } else {
486493 setOpen(true);
487494 }
......@@ -573,7 +580,10 @@ function OpenCommandPaletteDialog(props: {
573580 const navigate = useNavigate();
574581 const pathname = useLocation({ select: (location) => location.pathname });
575582 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 );
577587 const deferredQuery = useDeferredValue(query);
578588 const isActionsOnly = deferredQuery.startsWith(">");
579589 const [highlightedItemValue, setHighlightedItemValue] = useState<string | null>(null);
......@@ -696,6 +706,7 @@ function OpenCommandPaletteDialog(props: {
696706 );
697707 const [isPickingProjectFolder, setIsPickingProjectFolder] = useState(false);
698708 const [addProjectCloneFlow, setAddProjectCloneFlow] = useState<AddProjectCloneFlow | null>(null);
709 const cloneLookupGeneration = useRef(0);
699710 const [isRemoteProjectLookingUp, setIsRemoteProjectLookingUp] = useState(false);
700711 const [isRemoteProjectCloning, setIsRemoteProjectCloning] = useState(false);
701712 const projectGroupingSettings = useMemo(
......@@ -1497,6 +1508,18 @@ function OpenCommandPaletteDialog(props: {
14971508 startAddProjectSourceSelection,
14981509 ]);
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
15001523 useLayoutEffect(() => {
15011524 if (openIntent?.kind !== "add-project") {
15021525 return;
......@@ -1805,7 +1828,20 @@ function OpenCommandPaletteDialog(props: {
18051828 isInSubmenu: currentView !== null,
18061829 projectSearchItems: projectSearchItems,
18071830 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,
18091845 });
18101846
18111847 const handleAddProjectForEnvironment = useCallback(
......@@ -2011,6 +2047,7 @@ function OpenCommandPaletteDialog(props: {
20112047 }
20122048
20132049 setIsRemoteProjectLookingUp(true);
2050 const lookupGeneration = ++cloneLookupGeneration.current;
20142051 const lookupResult = await lookupRepository({
20152052 environmentId: addProjectCloneFlow.environmentId,
20162053 input: {
......@@ -2018,6 +2055,7 @@ function OpenCommandPaletteDialog(props: {
20182055 repository: rawRepository,
20192056 },
20202057 });
2058 if (lookupGeneration !== cloneLookupGeneration.current) return;
20212059 setIsRemoteProjectLookingUp(false);
20222060 if (lookupResult._tag === "Failure") {
20232061 if (!isAtomCommandInterrupted(lookupResult)) {
apps/web/src/components/Sidebar.tsx+14-76
......@@ -192,13 +192,11 @@ import {
192192import { SidebarDragLifecycle, SidebarPointerSensor } from "./Sidebar.pointer";
193193import { createSidebarListMotion } from "./Sidebar.motion";
194194import {
195 PR_STATE_COLOR_CLASS,
196 ThreadPullRequestBadgeIcon,
195 ThreadPullRequestBadgeControl,
197196 ThreadPullRequestsMiniList,
198197 ThreadWorktreeIndicator,
199198 prStatusIndicator,
200199 resolveThreadPullRequestBadge,
201 settledPrHoverColorClass,
202200 terminalStatusFromRunningIds,
203201 type TerminalStatusIndicator,
204202 useLinkedThreadPullRequest,
......@@ -220,7 +218,7 @@ import {
220218} from "../providerInstances";
221219import { useThreadRunningTerminalIds } from "../state/terminalSessions";
222220import { stackedThreadToast, toastManager } from "./ui/toast";
223import { Button, InlineButton } from "./ui/button";
221import { Button } from "./ui/button";
224222import { Input } from "./ui/input";
225223import {
226224 Combobox,
......@@ -1179,7 +1177,6 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
11791177 currentGitBranch: visibleGitStatus?.refName ?? null,
11801178 });
11811179 const prStatus = prStatusIndicator(pr, linkedPullRequestStatus?.sourceControlProvider);
1182 const settledPrHoverClass = pr ? settledPrHoverColorClass(pr.state, pr.isDraft) : undefined;
11831180
11841181 const modelInstanceId = thread.session?.providerInstanceId ?? thread.modelSelection.instanceId;
11851182 const providerEntry = props.providerEntryByInstanceId.get(modelInstanceId) ?? null;
......@@ -1488,85 +1485,26 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
14881485 </span>
14891486 );
14901487
1491 // One badge shape for every thread: the glyph says stack or not, the number is the current
1492 // pull request, and "+N" counts the others behind it. A real link so cmd/ctrl+click and
1493 // middle-click open the host in the browser; a plain click opens T3's pull request view.
1488 // Stacks show their layer count; unrelated links show the current PR and a remainder count.
1489 // Plain clicks open T3; individual PR links also support opening the host in a new tab.
14941490 const prBadgeShape = supportsMultiplePullRequests
14951491 ? resolveThreadPullRequestBadge(thread.pullRequests)
14961492 : 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 );
15091493 const handlePrStackClick = useCallback(() => {
15101494 useRightPanelStore.getState().open(threadRef, "pull-requests");
15111495 if (!props.isActive) onThreadActivate(threadRef);
15121496 }, [onThreadActivate, props.isActive, threadRef]);
15131497 const prBadge =
1514 prBadgeShape?.kind === "stack" ? (
1515 // A stack is one thing with N layers; naming one of them would misrepresent it, so the
1516 // badge counts layers and opens the thread's pull-requests surface.
1517 <InlineButton
1518 underline
1519 onPointerDown={(event) => event.stopPropagation()}
1520 onClick={handlePrStackClick}
1521 className={prBadgeClassName(prBadgeShape.state, PR_STATE_COLOR_CLASS[prBadgeShape.state])}
1522 aria-label={`Stack of ${prBadgeShape.layers} pull requests, ${prBadgeShape.state}`}
1523 >
1524 <ThreadPullRequestBadgeIcon icon="stack" />
1525 {prBadgeShape.layers}
1526 </InlineButton>
1527 ) : prStatus && pr ? (
1528 <a
1529 href={pr.url}
1530 target="_blank"
1531 rel="noopener noreferrer"
1532 onPointerDown={(event) => event.stopPropagation()}
1533 onClick={handlePrClick}
1534 className={cn(
1535 // Sidebar chrome follows the interface font; tabular digits keep the
1536 // number from reflowing as PR states stream in.
1537 "inline-flex shrink-0 items-center gap-0.5 whitespace-nowrap text-xs tabular-nums hover:underline",
1538 variant === "slim" && variantAction === "unsettle"
1539 ? cn("text-secondary-label transition-colors", settledPrHoverClass)
1540 : prStatus.colorClass,
1541 )}
1542 aria-label={
1543 prBadgeShape && prBadgeShape.kind === "pull-request" && prBadgeShape.others > 0
1544 ? `${prStatus.tooltip}, and ${prBadgeShape.others} more linked`
1545 : prStatus.tooltip
1546 }
1547 >
1548 <ThreadPullRequestBadgeIcon icon="pull-request" />
1549 {pr.number}
1550 {prBadgeShape && prBadgeShape.others > 0 ? (
1551 <span className="opacity-70">+{prBadgeShape.others}</span>
1552 ) : null}
1553 </a>
1554 ) : currentLinkedPr ? (
1555 <a
1556 href={currentLinkedPr.url}
1557 target="_blank"
1558 rel="noopener noreferrer"
1559 onPointerDown={(event) => event.stopPropagation()}
1560 onClick={handlePrClick}
1561 className="inline-flex shrink-0 items-center gap-0.5 text-xs tabular-nums text-muted-foreground hover:underline"
1562 aria-label={`PR #${currentLinkedPr.number}, status pending`}
1563 >
1564 <ThreadPullRequestBadgeIcon icon="pull-request" />
1565 {currentLinkedPr.number}
1566 {prBadgeShape?.kind === "pull-request" && prBadgeShape.others > 0 ? (
1567 <span>+{prBadgeShape.others}</span>
1568 ) : null}
1569 </a>
1498 prBadgeShape?.kind === "stack" || pr || currentLinkedPr ? (
1499 <ThreadPullRequestBadgeControl
1500 variant="underline"
1501 badge={prBadgeShape}
1502 number={pr?.number ?? currentLinkedPr?.number}
1503 url={pr?.url ?? currentLinkedPr?.url}
1504 status={prStatus}
1505 onOpenStack={handlePrStackClick}
1506 onOpenPullRequest={handlePrClick}
1507 />
15701508 ) : null;
15711509 const terminalStatusIcon = terminalStatus ? (
15721510 <span
apps/web/src/components/ThreadStatusIndicators.tsx+85-3
......@@ -12,9 +12,11 @@ import {
1212 resolveThreadCurrentPullRequestLink,
1313 resolveThreadPullRequestChains,
1414 visibleThreadPullRequests,
15 type ThreadPullRequestBadge,
1516} from "@t3tools/shared/threadPullRequests";
1617import { FolderGit2Icon, GitPullRequestArrowIcon, LayersIcon, TerminalIcon } from "lucide-react";
17import { useMemo } from "react";
18import { useMemo, type MouseEvent } from "react";
19import { buttonVariants, InlineButton } from "./ui/button";
1820import { cn } from "../lib/utils";
1921import { useEnvironment, usePrimaryEnvironmentId } from "../state/environments";
2022import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon";
......@@ -122,7 +124,7 @@ export {
122124} from "@t3tools/shared/threadPullRequests";
123125
124126/** The glyph a row's badge wears: the layers icon for a stack, the pull-request one otherwise. */
125export function ThreadPullRequestBadgeIcon({
127function ThreadPullRequestBadgeIcon({
126128 icon,
127129 className,
128130}: {
......@@ -133,6 +135,86 @@ export function ThreadPullRequestBadgeIcon({
133135 return <Icon aria-hidden className={cn("size-3 shrink-0", className)} />;
134136}
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
136218/**
137219 * A miniature of the pull-requests panel for the thread tooltip: same order, same indentation,
138220 * so the hover answers "what is in here" without opening the surface.
......@@ -192,7 +274,7 @@ export function ThreadPullRequestsMiniList({
192274}
193275
194276/** 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> = {
196278 open: "text-emerald-600 dark:text-emerald-300/90",
197279 merged: "text-violet-600 dark:text-violet-300/90",
198280 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 =
337337 summaryKind: ToolGroupSummaryKind;
338338 toolSurface?: WorkLogEntry["toolSurface"];
339339 toolIcon?: WorkLogEntry["toolIcon"];
340 summaryToolIcon?: "browser" | "t3-code";
340 summaryToolIcon?: "browser" | "t3-code" | "pull-request";
341341 hasFailure: boolean;
342342 }
343343 | {
apps/web/src/components/chat/MessagesTimeline.tsx+9
......@@ -1,3 +1,4 @@
1import { GitPullRequestIcon } from "lucide-react";
12import {
23 type AssistantCitation,
34 type EnvironmentId,
......@@ -2225,6 +2226,11 @@ function toolGroupSummaryIconName(
22252226 kind: Extract<TimelineRow, { kind: "work-toggle" }>["summaryKind"],
22262227): WorkEntryIconName {
22272228 switch (kind) {
2229 case "pull-request":
2230 case "link-pr":
2231 case "unlink-pr":
2232 case "list-prs":
2233 return "pull-request";
22282234 case "read":
22292235 return "eye";
22302236 case "edit":
......@@ -2801,6 +2807,7 @@ type WorkEntryIconName =
28012807 | "search"
28022808 | "square-pen"
28032809 | "terminal"
2810 | "pull-request"
28042811 | "t3-code"
28052812 | "wrench"
28062813 | "x"
......@@ -3005,6 +3012,8 @@ function ToolActivityImageIcon(props: {
30053012
30063013function WorkEntryIcon({ name, className }: { name: WorkEntryIconName; className: string }) {
30073014 switch (name) {
3015 case "pull-request":
3016 return <GitPullRequestIcon className={className} aria-hidden />;
30083017 case "bot":
30093018 return <BotIcon className={className} aria-hidden />;
30103019 case "brain":
apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx+119-51
......@@ -1,3 +1,5 @@
1import { parseChangeRequestUrl } from "@t3tools/shared/changeRequestUrl";
2import { usePullRequestStack } from "~/state/usePullRequestStack";
13import { RefreshIcon } from "~/components/ui/refresh-icon";
24import { scopedThreadKey, scopeProjectRef } from "@t3tools/client-runtime/environment";
35import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
......@@ -70,10 +72,10 @@ import { useProjects, useServerConfigs } from "~/state/entities";
7072import { useEnvironments, usePrimaryEnvironmentId } from "~/state/environments";
7173import { useEnvironmentQuery } from "~/state/query";
7274import { useLiveRefresh } from "~/hooks/useLiveRefresh";
73import { pullRequestEnvironment, pullRequestStackAtom } from "~/state/pullRequests";
75import { pullRequestEnvironment } from "~/state/pullRequests";
7476import { usePullRequestTurnRefresh, useSharedPullRequestSummary } from "~/state/pullRequests";
7577import { useAtomCommand } from "~/state/use-atom-command";
76import { PullRequestStackMap } from "./PullRequestStackMap";
78import { PullRequestStackMenu } from "./PullRequestStackMenu";
7779import { PullRequestThreadLinks } from "./PullRequestThreadLinks";
7880import { vcsEnvironment } from "~/state/vcs";
7981import { formatRelativeTimeLabel } from "~/timestampFormat";
......@@ -104,7 +106,7 @@ import {
104106} from "../ui/menu";
105107import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";
106108import { toastManager } from "../ui/toast";
107import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
109import { Tooltip, TooltipPopup, TooltipProvider, TooltipTrigger } from "../ui/tooltip";
108110import { PullRequestDetailGhost, PullRequestTimelineGhost } from "./PullRequestGhosts";
109111import { PullRequestActivityUnavailableState } from "./PullRequestActivityUnavailableState";
110112import { DiffPanelLoadingState } from "../DiffPanelShell";
......@@ -136,6 +138,7 @@ import {
136138 readPullRequestDetailSnapshot,
137139 resolveDisplayedPullRequestDetail,
138140 resolvePullRequestPrimaryControl,
141 allowsSinglePullRequestMerge,
139142 resolveBaseFreshness,
140143 resolvePullRequestMergeMethod,
141144 type PullRequestFinding,
......@@ -459,8 +462,10 @@ export function PullRequestDetailPanel({
459462 context = "page",
460463 composerDraftTarget,
461464 onBack,
465 onSelectPullRequest,
462466}: {
463467 environmentId: EnvironmentId;
468 onSelectPullRequest?: ((reference: PullRequestRef) => void) | undefined;
464469 /**
465470 * The thread this panel sits beside, if any. Links that are not the pull
466471 * request itself (check details, host permalinks) can open in that thread's
......@@ -730,17 +735,34 @@ export function PullRequestDetailPanel({
730735 isStackedPullRequestBase(detail.baseBranch, branchRefsQuery.data?.refs ?? []);
731736 // The host's own stack, where it keeps one. Only asked for once the detail has landed so a
732737 // pull request nobody can read costs one request rather than two.
733 const nativeStack = useEnvironmentQuery(
734 detail === null || detail.capabilities.stacks !== true || !supportsThreadPullRequests
735 ? null
736 : pullRequestStackAtom({ environmentId, input: reference }),
737 ).data;
738 const stackReference = useMemo(
739 () =>
740 detail === null || detail.capabilities.stacks !== true || !supportsThreadPullRequests
741 ? null
742 : { ...reference, host: reference.host ?? parseChangeRequestUrl(detail.url)?.host },
743 [detail, reference, supportsThreadPullRequests],
744 );
745 const nativeStackQuery = usePullRequestStack(environmentId, stackReference);
746 const nativeStack = nativeStackQuery.data;
747 const supportsStackActions =
748 supportsThreadPullRequests &&
749 detail?.capabilities.stacks === true &&
750 detail.capabilities.stackActions === true &&
751 environmentConfigs.get(environmentId)?.environment.capabilities.pullRequestStackActions ===
752 true;
753 const canMergeSinglePullRequest = allowsSinglePullRequestMerge({
754 supportsStackActions,
755 hasStack: nativeStack !== null,
756 stackPending: !nativeStackQuery.isSuccess || nativeStackQuery.isPending,
757 stackError: nativeStackQuery.error,
758 });
738759 const activityPending = activityQuery.isPending && activity === null;
739760 const activityError = activity === null ? activityQuery.error : null;
740761 const refreshDetail = useCallback(() => {
741762 detailQuery.refresh();
742763 activityQuery.refresh();
743 }, [activityQuery.refresh, detailQuery.refresh]);
764 nativeStackQuery.refresh();
765 }, [activityQuery.refresh, detailQuery.refresh, nativeStackQuery.refresh]);
744766 const [refreshToken, setRefreshToken] = useState(0);
745767 const codeRefreshToken = refreshToken + (turnRefresh ?? 0);
746768 const activityRevision = useRef<{ readonly key: string; readonly updatedAt: string } | null>(
......@@ -1351,9 +1373,9 @@ export function PullRequestDetailPanel({
13511373 checksState,
13521374 autoMergeEnabled: detail.autoMergeEnabled,
13531375 hasMergeMethod: allowedMergeMethods.length > 0,
1354 canMerge: can("merge"),
1376 canMerge: canMergeSinglePullRequest && can("merge"),
13551377 canMarkReady: can("ready"),
1356 canEnableAutoMerge: can("enable-auto-merge"),
1378 canEnableAutoMerge: canMergeSinglePullRequest && can("enable-auto-merge"),
13571379 })
13581380 : null;
13591381 // What the menu's action group holds. Named once so the separators around it are drawn from
......@@ -1363,6 +1385,7 @@ export function PullRequestDetailPanel({
13631385 can(detail.isDraft ? "ready" : "draft") &&
13641386 !(detail.isDraft && primaryAction === "ready");
13651387 const showsAutoMerge =
1388 canMergeSinglePullRequest &&
13661389 detail?.state === "open" &&
13671390 ((autoMergeArmed && can("disable-auto-merge")) ||
13681391 (!autoMergeArmed &&
......@@ -1372,6 +1395,7 @@ export function PullRequestDetailPanel({
13721395 can("enable-auto-merge") &&
13731396 allowedMergeMethods.length > 0));
13741397 const showsMergeNow =
1398 canMergeSinglePullRequest &&
13751399 detail?.state === "open" &&
13761400 (primaryAction === "enable-auto-merge" || primaryAction === "auto-merge-armed") &&
13771401 can("merge") &&
......@@ -1568,7 +1592,38 @@ export function PullRequestDetailPanel({
15681592 </div>
15691593 <div className="mr-4 flex h-7 shrink-0 items-center justify-end gap-1">
15701594 {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}
15721627 {context === "page" ? (
15731628 <PullRequestThreadLinks
15741629 display="count"
......@@ -1585,24 +1640,34 @@ export function PullRequestDetailPanel({
15851640 the page: beside a thread the branch is already checked out right there. */}
15861641 {context === "page" ? (
15871642 <Menu>
1588 <MenuTrigger
1589 disabled={handoff !== null}
1590 render={
1591 <Button
1592 size="xs"
1593 variant="outline"
1594 aria-label={
1595 handoff?.startsWith("checkout") ? "Checking out..." : "Check out"
1596 }
1597 >
1598 <GitBranchIcon aria-hidden className="size-3.5" />
1599 <span className="@max-[35rem]/pr-header:hidden">
1600 {handoff?.startsWith("checkout") ? "Checking out..." : "Check out"}
1601 </span>
1602 <ChevronDownIcon aria-hidden className="size-3.5 text-muted-foreground" />
1603 </Button>
1604 }
1605 />
1643 <Tooltip>
1644 <TooltipTrigger
1645 render={
1646 <MenuTrigger
1647 disabled={handoff !== null}
1648 render={
1649 <Button
1650 size="xs"
1651 variant="ghost"
1652 aria-label={
1653 handoff?.startsWith("checkout") ? "Checking out..." : "Check out"
1654 }
1655 >
1656 <GitBranchIcon aria-hidden className="size-3.5" />
1657 <span className="@max-[35rem]/pr-header:hidden">
1658 {handoff?.startsWith("checkout") ? "Checking out..." : "Check out"}
1659 </span>
1660 <ChevronDownIcon
1661 aria-hidden
1662 className="size-3.5 text-muted-foreground"
1663 />
1664 </Button>
1665 }
1666 />
1667 }
1668 />
1669 <TooltipPopup>Check out this pull request</TooltipPopup>
1670 </Tooltip>
16061671 <MenuPopup align="end" side="bottom" className="min-w-72">
16071672 <MenuItem onClick={() => startCheckout("worktree")}>
16081673 <GitBranchIcon className="mt-0.5 size-3.5 shrink-0 self-start" />
......@@ -1663,7 +1728,7 @@ export function PullRequestDetailPanel({
16631728 <span className="inline-flex shrink-0">
16641729 <Button
16651730 size="xs"
1666 variant="destructive-outline"
1731 variant="ghost"
16671732 disabled={handoff !== null}
16681733 onClick={startResolveConflicts}
16691734 aria-label={
......@@ -1689,6 +1754,7 @@ export function PullRequestDetailPanel({
16891754 <span className="inline-flex shrink-0">
16901755 <Button
16911756 size="xs"
1757 variant="ghost"
16921758 disabled={actionPending}
16931759 onClick={() => void perform("ready")}
16941760 aria-label="Ready for review"
......@@ -1711,6 +1777,7 @@ export function PullRequestDetailPanel({
17111777 <span className="inline-flex shrink-0">
17121778 <Button
17131779 size="xs"
1780 variant="ghost"
17141781 disabled={actionPending}
17151782 onClick={() =>
17161783 setConfirmation({ open: true, action: "enable-auto-merge" })
......@@ -1762,6 +1829,7 @@ export function PullRequestDetailPanel({
17621829 <span className="inline-flex shrink-0">
17631830 <Button
17641831 size="xs"
1832 variant="ghost"
17651833 disabled={actionPending}
17661834 onClick={() => setConfirmation({ open: true, action: "merge" })}
17671835 aria-label={
......@@ -1791,18 +1859,25 @@ export function PullRequestDetailPanel({
17911859 </Badge>
17921860 ) : null}
17931861 <Menu>
1794 <MenuTrigger
1795 render={
1796 <Button
1797 aria-label="More pull request actions"
1798 className="size-6"
1799 size="icon-xs"
1800 variant="ghost-muted"
1801 />
1802 }
1803 >
1804 <MoreHorizontalIcon className="size-4" />
1805 </MenuTrigger>
1862 <Tooltip>
1863 <TooltipTrigger
1864 render={
1865 <MenuTrigger
1866 render={
1867 <Button
1868 aria-label="More pull request actions"
1869 className="size-6"
1870 size="icon-xs"
1871 variant="ghost-muted"
1872 />
1873 }
1874 >
1875 <MoreHorizontalIcon className="size-4" />
1876 </MenuTrigger>
1877 }
1878 />
1879 <TooltipPopup>More pull request actions</TooltipPopup>
1880 </Tooltip>
18061881 <MenuPopup align="end" side="bottom" className="min-w-72">
18071882 <PullRequestThreadLinks
18081883 display="menu-item"
......@@ -1997,7 +2072,7 @@ export function PullRequestDetailPanel({
19972072 ) : null}
19982073 </MenuPopup>
19992074 </Menu>
2000 </>
2075 </TooltipProvider>
20012076 ) : null}
20022077 {onClose ? (
20032078 <Button
......@@ -2102,13 +2177,6 @@ export function PullRequestDetailPanel({
21022177 />
21032178 </span>
21042179 </div>
2105 {nativeStack ? (
2106 <PullRequestStackMap
2107 stack={nativeStack}
2108 currentNumber={detail.number}
2109 className="mt-1"
2110 />
2111 ) : null}
21122180 </div>
21132181 ) : null}
21142182 </div>
apps/web/src/components/pullRequest/PullRequestRow.tsx+22-1
......@@ -1,4 +1,5 @@
11import { SearchIcon } from "lucide-react";
2import { PullRequestStackPopover } from "./PullRequestStackPopover";
23import { memo, type RefCallback } from "react";
34
45import { cn } from "~/lib/utils";
......@@ -60,6 +61,11 @@ function PullRequestRowLabels({ labels }: { labels: EnvironmentPullRequestEntry[
6061 );
6162}
6263
64export type PullRequestRowTarget = Pick<
65 EnvironmentPullRequestEntry,
66 "environmentId" | "projectId" | "host" | "repository" | "number"
67>;
68
6369function PullRequestRowImpl({
6470 entry,
6571 selected,
......@@ -86,7 +92,7 @@ function PullRequestRowImpl({
8692 /** Used by the list's shared visibility observer to defer optional line-count reads. */
8793 statsKey?: string;
8894 statsRef?: RefCallback<HTMLButtonElement>;
89 onSelect: (entry: EnvironmentPullRequestEntry) => void;
95 onSelect: (entry: PullRequestRowTarget) => void;
9096}) {
9197 const { Icon, providerName } = getSourceControlPresentationForKind(entry.provider);
9298 return (
......@@ -116,6 +122,21 @@ function PullRequestRowImpl({
116122 {entry.title}
117123 </span>
118124 <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}
119140 {/* Only a verdict somebody has actually given: "review required" is the absence of
120141 one, and saying so on every unreviewed row would say nothing. */}
121142 {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 @@
1import { Tooltip, TooltipTrigger, TooltipPopup } from "../ui/tooltip";
12import { scopeThreadRef } from "@t3tools/client-runtime/environment";
2import { Link } from "@tanstack/react-router";
33import type { EnvironmentId, PullRequestRef, ScopedThreadRef, ThreadId } from "@t3tools/contracts";
44import { CheckIcon, LinkIcon, MessageSquareIcon, UnlinkIcon } from "lucide-react";
55import { useState } from "react";
......@@ -11,11 +11,11 @@ import { useProjects, useServerConfigs, useThreadShell, useThreadShells } from "
1111import { pullRequestEnvironment } from "~/state/pullRequests";
1212import { useEnvironmentQuery } from "~/state/query";
1313import { appAtomRegistry } from "~/rpc/atomRegistry";
14import { buildThreadRouteParams } from "~/threadRoutes";
14import { openCommandPalette } from "~/commandPaletteBus";
1515import { Button } from "../ui/button";
1616import { Command, CommandInput, CommandItem, CommandList } from "../ui/command";
1717import { Dialog, DialogPopup, DialogTitle } from "../ui/dialog";
18import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu";
18import { MenuItem } from "../ui/menu";
1919import { toastManager } from "../ui/toast";
2020
2121interface PullRequestThreadLinksProps {
......@@ -61,7 +61,7 @@ function EnabledPullRequestThreadLinks({
6161 : null,
6262 );
6363 // 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.
6565 const [lastRelations, setLastRelations] = useState(relations.data);
6666 if (relations.data !== null && relations.data !== lastRelations) {
6767 setLastRelations(relations.data);
......@@ -101,47 +101,29 @@ function EnabledPullRequestThreadLinks({
101101 return (
102102 <>
103103 {display === "count" && (linkedThreads.length > 0 || relations.error !== null) ? (
104 <Menu>
105 <MenuTrigger
104 <Tooltip>
105 <TooltipTrigger
106106 render={
107107 <Button
108108 size="xs"
109109 variant="ghost"
110110 aria-label={linkedThreadsLabel}
111 title={linkedThreadsLabel}
112 />
113 }
114 >
115 <MessageSquareIcon aria-hidden className="size-3.5" />
116 <span className="@max-[40rem]/pr-header:hidden">{linkedThreadsLabel}</span>
117 <span aria-hidden className="hidden @max-[40rem]/pr-header:inline">
118 {linkedThreads.length || "?"}
119 </span>
120 </MenuTrigger>
121 <MenuPopup align="end" className="max-h-72 max-w-80 overflow-y-auto">
122 {relations.error !== null ? (
123 <MenuItem onClick={relations.refresh}>Could not load linked threads. Retry</MenuItem>
124 ) : null}
125 {linkedThreads.map((linkedThread) => (
126 <MenuItem
127 key={linkedThread.id}
128 render={
129 <Link
130 to="/$environmentId/$threadId"
131 params={buildThreadRouteParams(scopeThreadRef(environmentId, linkedThread.id))}
132 />
111 onClick={() =>
112 openCommandPalette({
113 query: url,
114 ...((relations.data ?? lastRelations) === null
115 ? {}
116 : { linkedThreads: { environmentId, threads: linkedThreads } }),
117 })
133118 }
134119 >
135 <span className="min-w-0 flex-1 truncate">
136 {linkedThread.title || "Untitled thread"}
137 </span>
138 {linkedThread.archivedAt !== null ? (
139 <span className="text-xs text-muted-foreground">Archived</span>
140 ) : null}
141 </MenuItem>
142 ))}
143 </MenuPopup>
144 </Menu>
120 <MessageSquareIcon aria-hidden className="size-3.5" />
121 <span aria-hidden>{linkedThreads.length || "?"}</span>
122 </Button>
123 }
124 />
125 <TooltipPopup>{linkedThreadsLabel}. Search in the command palette.</TooltipPopup>
126 </Tooltip>
145127 ) : null}
146128 {display === "menu-item" ? (
147129 <MenuItem
apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts+19
......@@ -35,6 +35,7 @@ import {
3535 readPullRequestDetailSnapshot,
3636 resolveDisplayedPullRequestDetail,
3737 resolvePullRequestPrimaryControl,
38 allowsSinglePullRequestMerge,
3839 shouldRefreshPullRequestActivity,
3940 resolveBaseFreshness,
4041 resolvePullRequestMergeMethod,
......@@ -1463,3 +1464,21 @@ describe("cached pull request detail", () => {
14631464 expect(readPullRequestDetailSnapshot(undefined, "env-1", reference)).toBeNull();
14641465 });
14651466});
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
2929 rebase: "Rebase and merge",
3030};
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
3245export function resolvePullRequestMergeMethod(
3346 allowed: ReadonlyArray<PullRequestMergeMethod>,
3447 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(
203203 state: previous.state ?? "all",
204204 repository,
205205 number: parsed.number,
206 selectedHost: parsed.host,
206207 selectedProjectId: project.id,
207208 selectedEnvironmentId: project.environmentId,
208209 }),
......@@ -220,6 +221,7 @@ export function useOpenChangeRequestLink(
220221 state: "all",
221222 repository,
222223 number: parsed.number,
224 selectedHost: parsed.host,
223225 selectedProjectId: project.id,
224226 // Named so the page opens the right one of two servers holding this project.
225227 selectedEnvironmentId: project.environmentId,
apps/web/src/routes/_chat.pull-requests.tsx+99-32
......@@ -99,7 +99,10 @@ import {
9999} from "../components/pullRequest/PullRequestListFilters";
100100import { PullRequestListEmptyState } from "../components/pullRequest/PullRequestListEmptyState";
101101import { PullRequestListGhost } from "../components/pullRequest/PullRequestGhosts";
102import { PullRequestRow } from "../components/pullRequest/PullRequestRow";
102import {
103 PullRequestRow,
104 type PullRequestRowTarget,
105} from "../components/pullRequest/PullRequestRow";
103106import { PullRequestsUnavailableState } from "../components/pullRequest/PullRequestsUnavailableState";
104107import { RightPanelTabs, type PullRequestTabStatusSeed } from "../components/RightPanelTabs";
105108import {
......@@ -163,6 +166,8 @@ export interface PullRequestsSearch extends PullRequestListPreferences {
163166 readonly repository?: string;
164167 readonly number?: number;
165168 readonly selectedProjectId?: ProjectId;
169 /** Host of the open review; changing tabs must not narrow the list host filter. */
170 readonly selectedHost?: string;
166171 /**
167172 * Which server the selected pull request was read from. A project id only names a project on
168173 * 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")({
263268 ...(typeof raw.selectedProjectId === "string" && raw.selectedProjectId
264269 ? { selectedProjectId: raw.selectedProjectId as ProjectId }
265270 : {}),
271 ...(typeof raw.selectedHost === "string" && raw.selectedHost
272 ? { selectedHost: raw.selectedHost.slice(0, 200) }
273 : {}),
266274 ...(typeof raw.selectedEnvironmentId === "string" && raw.selectedEnvironmentId
267275 ? { selectedEnvironmentId: raw.selectedEnvironmentId as EnvironmentId }
268276 : {}),
......@@ -367,6 +375,7 @@ function PullRequestsRouteView() {
367375
368376 // A link from a thread or the sidebar only knows the repository, so the owning project is
369377 // resolved here; an explicit `projectId` in the URL still wins.
378 const selectedHost = search.selectedHost ?? search.host;
370379 const projectIdForRepository = useMemo(() => {
371380 const repository = search.repository?.toLowerCase();
372381 if (repository === undefined) return undefined;
......@@ -378,14 +387,14 @@ function PullRequestsRouteView() {
378387 repository &&
379388 // The same `owner/name` can exist on two hosts. Without this the first match wins, and
380389 // a link that named its host opens the pull request from the other one.
381 (search.host === undefined ||
390 (selectedHost === undefined ||
382391 pullRequestHostOf(
383392 project.repositoryIdentity,
384393 project.repositoryIdentity.provider as SourceControlProviderKind,
385 ) === search.host.toLowerCase()),
394 ) === selectedHost.toLowerCase()),
386395 );
387396 return identity?.id;
388 }, [projects, search.host, search.repository]);
397 }, [projects, selectedHost, search.repository]);
389398
390399 // The selection is resolved the same way the scope is: an id no connected environment has can
391400 // never be read here, and one that arrived before the projects did is not yet wrong.
......@@ -472,6 +481,7 @@ function PullRequestsRouteView() {
472481 ...(next.projectId ? { projectId: next.projectId } : {}),
473482 ...(next.environmentId ? { environmentId: next.environmentId } : {}),
474483 ...(next.host ? { host: next.host } : {}),
484 ...(next.selectedHost ? { selectedHost: next.selectedHost } : {}),
475485 ...(next.selectedProjectId ? { selectedProjectId: next.selectedProjectId } : {}),
476486 ...(next.selectedEnvironmentId
477487 ? { selectedEnvironmentId: next.selectedEnvironmentId }
......@@ -494,6 +504,7 @@ function PullRequestsRouteView() {
494504 number: undefined,
495505 selectedProjectId: undefined,
496506 selectedEnvironmentId: undefined,
507 selectedHost: undefined,
497508 };
498509 // List controls change the rows behind the detail, not the independent selected surface. The
499510 // reader can keep working in that panel while narrowing, sorting, or switching projects.
......@@ -799,19 +810,11 @@ function PullRequestsRouteView() {
799810 // from the first moment rather than the second: a button that stays live through the slow half
800811 // of its own work is a button that gets pressed again, and buys the whole cascade twice.
801812 const [invalidating, setInvalidating] = useState(false);
802 const refreshFromHost = async (includeDetail = true) => {
803 const requestedStatsScope = statsScopeRef.current;
804 setInvalidating(true);
805 try {
806 // Every environment the page is reading, since what the reader pressed refresh for is the
807 // list in front of them rather than whichever machine happens to be first.
808 await Promise.all(
809 queryEnvironmentIds.map((environmentId) => invalidate({ environmentId, input: {} })),
810 );
811 } finally {
812 setInvalidating(false);
813 }
814 refreshList(true);
813 const refreshListAndStats = (
814 requestedStatsScope = statsScopeRef.current,
815 actedEnvironmentId?: EnvironmentId,
816 ) => {
817 refreshList(true, actedEnvironmentId);
815818 const visible = visibleStatsKeys.current;
816819 const batches = pullRequestStatsRefreshBatches({
817820 requestedScope: requestedStatsScope,
......@@ -822,9 +825,29 @@ function PullRequestsRouteView() {
822825 });
823826 if (batches !== null) {
824827 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 );
826835 }
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);
828851 };
829852 const refreshing = invalidating || listQuery.isPending;
830853
......@@ -1058,17 +1081,28 @@ function PullRequestsRouteView() {
10581081 // re-reads only its own slice, so the rows loaded before it would never see a merge, a close,
10591082 // or a retitle. Going back to a single page long enough to cover everything on screen lets the
10601083 // merge above bring every row up to date in place.
1061 const refreshList = (includeRelated = false) => {
1062 const related = includeRelated
1063 ? [
1064 ...baselineTargets,
1065 ...facetTargets,
1066 ...partitionTargets.authored,
1067 ...partitionTargets.reviewing,
1068 ]
1069 : [];
1084 const refreshList = (includeRelated = false, actedEnvironmentId?: EnvironmentId) => {
1085 const related = (
1086 includeRelated
1087 ? [
1088 ...baselineTargets,
1089 ...facetTargets,
1090 ...partitionTargets.authored,
1091 ...partitionTargets.reviewing,
1092 ]
1093 : []
1094 ).filter(
1095 ({ environmentId }) =>
1096 actedEnvironmentId === undefined || environmentId === actedEnvironmentId,
1097 );
10701098 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 ]);
10721106 return;
10731107 }
10741108 if (related.length > 0) listQuery.refresh(related);
......@@ -1412,9 +1446,10 @@ function PullRequestsRouteView() {
14121446 repository: search.repository,
14131447 number: search.number,
14141448 projectId: selectedProject.id,
1449 ...(selectedHost ? { host: selectedHost } : {}),
14151450 }
14161451 : null,
1417 [search.number, search.repository, selectedProject],
1452 [search.number, search.repository, selectedProject, selectedHost],
14181453 );
14191454 const rightPanelAvailable = selectedPullRequestSurface !== null;
14201455 useEffect(() => {
......@@ -1429,6 +1464,14 @@ function PullRequestsRouteView() {
14291464 repository: activePullRequestSurface.repository,
14301465 number: activePullRequestSurface.number,
14311466 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),
14321475 }
14331476 : null;
14341477
......@@ -1440,6 +1483,7 @@ function PullRequestsRouteView() {
14401483 repository: surface.repository,
14411484 number: surface.number,
14421485 selectedProjectId: surface.projectId as ProjectId,
1486 selectedHost: surface.host,
14431487 ...(surface.environmentId === undefined
14441488 ? {}
14451489 : { selectedEnvironmentId: surface.environmentId as EnvironmentId }),
......@@ -1503,7 +1547,7 @@ function PullRequestsRouteView() {
15031547
15041548 // Stable so the memoized rows can skip re-rendering when the list around them changes.
15051549 const selectEntry = useCallback(
1506 (entry: EnvironmentPullRequestEntry) => {
1550 (entry: PullRequestRowTarget) => {
15071551 // The surface carries the row's own server, which is what its detail reads and acts on.
15081552 if (rightPanelRef === null) return;
15091553 useRightPanelStore.getState().openPullRequest(rightPanelRef, entry);
......@@ -1512,6 +1556,7 @@ function PullRequestsRouteView() {
15121556 number: entry.number,
15131557 selectedProjectId: entry.projectId,
15141558 selectedEnvironmentId: entry.environmentId,
1559 selectedHost: entry.host,
15151560 });
15161561 },
15171562 [rightPanelRef, updateSearch],
......@@ -1628,6 +1673,7 @@ function PullRequestsRouteView() {
16281673 selected={
16291674 selected?.environmentId === entry.environmentId &&
16301675 selected.repository === entry.repository &&
1676 selected.host?.toLowerCase() === entry.host.toLowerCase() &&
16311677 selected.number === entry.number
16321678 }
16331679 onSelect={selectEntry}
......@@ -1938,10 +1984,30 @@ function PullRequestsRouteView() {
19381984 <PullRequestDetailPanel
19391985 key={renderedPullRequestSurface.id}
19401986 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 }}
19412004 reference={{
19422005 projectId: renderedPullRequestSurface.projectId as ProjectId,
19432006 repository: renderedPullRequestSurface.repository,
19442007 number: renderedPullRequestSurface.number,
2008 ...(renderedPullRequestSurface.host
2009 ? { host: renderedPullRequestSurface.host }
2010 : {}),
19452011 }}
19462012 listEntry={
19472013 listedPullRequestsBySurface.get(
......@@ -1952,7 +2018,8 @@ function PullRequestsRouteView() {
19522018 // Host actions can change both readiness and diff size, so refresh the counts
19532019 // alongside the list. The panel already refreshes itself after each action.
19542020 onActed={() => {
1955 void refreshFromHost(false);
2021 // Mutations already invalidate the host's affected caches.
2022 refreshListAndStats(undefined, panelEnvironmentId);
19562023 }}
19572024 />
19582025 </RightPanelTabs>
apps/web/src/state/pullRequests.ts+4-1
......@@ -74,7 +74,10 @@ export function useSharedPullRequestSummary(
7474 }, [atom, current, environmentId]);
7575 return newestPullRequestSummary(current, observed);
7676}
77export const pullRequestStackAtom = createPullRequestStackAtomFamily(connectionAtomRuntime);
77export const pullRequestStackAtom = createPullRequestStackAtomFamily(
78 connectionAtomRuntime,
79 pullRequestEnvironment.refreshes,
80);
7881
7982export interface EnvironmentQueryTarget<Input> {
8083 readonly environmentId: EnvironmentId;
apps/web/src/state/query.ts+2
......@@ -11,6 +11,7 @@ export interface EnvironmentQueryView<A> {
1111 readonly data: A | null;
1212 readonly error: string | null;
1313 readonly isPending: boolean;
14 readonly isSuccess: boolean;
1415 readonly refresh: () => void;
1516}
1617
......@@ -31,6 +32,7 @@ export function useEnvironmentQuery<A, E>(
3132 data: Option.getOrNull(AsyncResult.value(result)),
3233 error: result._tag === "Failure" ? formatEnvironmentQueryError(result.cause) : null,
3334 isPending: atom !== null && result.waiting,
35 isSuccess: result._tag === "Success",
3436 refresh,
3537 };
3638}
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.
115115
116116Cross-repository links use a project on the same host. Azure DevOps reviews require a project checked
117117out 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 @@
1import { EnvironmentId, ProjectId, WS_METHODS } from "@t3tools/contracts";
1import { EnvironmentId, ProjectId, WS_METHODS, type PullRequestStack } from "@t3tools/contracts";
22import { expect, it } from "@effect/vitest";
33import * as Effect from "effect/Effect";
44import * as Latch from "effect/Latch";
......@@ -19,7 +19,10 @@ import * as EnvironmentRegistry from "../connection/registry.ts";
1919import * as EnvironmentSupervisor from "../connection/supervisor.ts";
2020import type { WsRpcProtocolClient } from "../rpc/protocol.ts";
2121import type { RpcSession } from "../rpc/session.ts";
22import { createPullRequestEnvironmentAtoms } from "./pullRequests.ts";
22import {
23 createPullRequestEnvironmentAtoms,
24 createPullRequestStackAtomFamily,
25} from "./pullRequests.ts";
2326import { PullRequestDiffLoader } from "./pullRequestDiffHttp.ts";
2427import { executeAtomQuery } from "./runtime.ts";
2528
......@@ -80,7 +83,7 @@ const makeTestRuntime = Effect.fn("makeTestRuntime")(function* (client: WsRpcPro
8083 const registry = yield* Effect.acquireRelease(Effect.sync(AtomRegistry.make), (registry) =>
8184 Effect.sync(() => registry.dispose()),
8285 );
83 return { atoms, registry };
86 return { runtime, atoms, registry };
8487});
8588
8689it.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", () =>
212215 }),
213216 ),
214217);
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>(
6363/** The host-native stack a pull request belongs to; null where it is not stacked. */
6464export function createPullRequestStackAtomFamily<R, E>(
6565 runtime: Atom.AtomRuntime<EnvironmentRegistry | R, E>,
66 refreshes = createPullRequestRefreshAtomFamily(runtime),
6667) {
6768 return createEnvironmentRpcQueryAtomFamily(runtime, {
6869 label: "environment-data:pull-requests:stack",
6970 tag: WS_METHODS.pullRequestsStack,
7071 staleTimeMs: 60_000,
7172 idleTtlMs: LINKED_PULL_REQUEST_IDLE_TTL_MS,
73 refreshTrigger: ({ environmentId }) => refreshes({ environmentId, input: {} }),
7274 });
7375}
7476
packages/client-runtime/src/work-log/presentation.test.ts+76
......@@ -571,3 +571,79 @@ describe("resolveViewedImageAsset", () => {
571571 expect(resolveViewedImageAsset("https://example.com/logo.png", { threadId })).toBeNull();
572572 });
573573});
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 {
88} from "@t3tools/contracts";
99import { classifyMarkdownImageSource } from "@t3tools/client-runtime/markdown-images";
1010import { resolveMediaSource } from "@t3tools/client-runtime/media-source";
11import { parseChangeRequestUrl } from "@t3tools/shared/changeRequestUrl";
1112import { isWorkspaceImagePreviewPath } from "@t3tools/shared/filePreview";
1213
1314export function isWorktreeSetupActivity(kind: string): boolean {
......@@ -36,6 +37,9 @@ export interface WorkLogPresentationEntry {
3637}
3738
3839export type ToolGroupAction =
40 | "link-pr"
41 | "unlink-pr"
42 | "list-prs"
3943 | "read"
4044 | "edit"
4145 | "command"
......@@ -46,6 +50,7 @@ export type ToolGroupAction =
4650 | "update";
4751
4852export type ToolGroupSummaryKind =
53 | "pull-request"
4954 | ToolGroupAction
5055 | "dynamic-tool"
5156 | "agent-tool"
......@@ -60,6 +65,9 @@ const T3_MCP_TOOL_LABELS: Record<
6065 string,
6166 readonly [action: string, running: string, completed: string, detail: string]
6267> = {
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"],
6371 orchestrator_capabilities: ["Get", "Getting", "Got", "orchestration capabilities"],
6472 delegate_task: ["Delegate", "Delegating", "Delegated", "a child task"],
6573 task_status: ["Get", "Getting", "Got", "delegated task status"],
......@@ -98,7 +106,17 @@ const T3_MCP_TOOL_LABELS: Record<
98106 preview_recording_stop: ["Stop", "Stopping", "Stopped", "recording the preview browser"],
99107};
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) {
102120 if (!value) return null;
103121 const name = normalizeCompactToolLabel(value).replace(
104122 /^(?: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
120138 ? `Stopped ${running.toLowerCase()}`
121139 : 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;
123155 return {
124 displayName: `${verb} ${detail}`,
125 icon: name.startsWith("preview_") ? ("browser" as const) : ("t3-code" as const),
156 displayName: `${verb} ${target}`,
157 icon:
158 actionKind !== undefined
159 ? ("pull-request" as const)
160 : name.startsWith("preview_")
161 ? ("browser" as const)
162 : ("t3-code" as const),
163 ...(actionKind === undefined ? {} : { action: actionKind }),
126164 };
127165}
128166
......@@ -147,16 +185,16 @@ export function resolveWorkEntryToolPresentation(
147185 "tool" in data &&
148186 typeof data.tool === "string"
149187 ) {
150 return resolveT3McpToolPresentation(`${data.server}.${data.tool}`, status);
188 return resolveT3McpToolPresentation(`${data.server}.${data.tool}`, status, data);
151189 }
152190 if ("toolName" in data && typeof data.toolName === "string") {
153 return resolveT3McpToolPresentation(data.toolName, status);
191 return resolveT3McpToolPresentation(data.toolName, status, data);
154192 }
155193 }
156194
157195 return (
158 resolveT3McpToolPresentation(entry.toolTitle, status) ??
159 resolveT3McpToolPresentation(entry.label, status)
196 resolveT3McpToolPresentation(entry.toolTitle, status, data) ??
197 resolveT3McpToolPresentation(entry.label, status, data)
160198 );
161199}
162200
......@@ -410,7 +448,9 @@ export function toolGroupAction(entry: WorkLogPresentationEntry): ToolGroupActio
410448 ) {
411449 return "update";
412450 }
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";
414454 if (
415455 entry.requestKind === "file-read" ||
416456 entry.itemType === "image_view" ||
......@@ -504,6 +544,14 @@ function toolGroupActionCount(
504544
505545function toolGroupActionLabel(action: ToolGroupAction, count: number): string {
506546 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`;
507555 case "read":
508556 return `Read ${count} ${count === 1 ? "file" : "files"}`;
509557 case "edit":
......@@ -528,7 +576,7 @@ export function summarizeToolGroup(entries: ReadonlyArray<WorkLogPresentationEnt
528576 const sources = new Map<string, ToolActivitySource>();
529577 const groupedEntries = new Map<ToolGroupAction, WorkLogPresentationEntry[]>();
530578 for (const entry of summaryEntries) {
531 if (entry.toolSource) {
579 if (entry.toolSource && resolveWorkEntryToolPresentation(entry)?.icon !== "pull-request") {
532580 sources.set(entry.toolSource.key, entry.toolSource);
533581 continue;
534582 }
......@@ -601,6 +649,11 @@ export function omitSupersededLifecycleMarkers<T>(
601649export function toolGroupSummaryKind(
602650 entries: ReadonlyArray<WorkLogPresentationEntry>,
603651): ToolGroupSummaryKind {
652 if (
653 entries.length > 0 &&
654 entries.every((entry) => resolveWorkEntryToolPresentation(entry)?.icon === "pull-request")
655 )
656 return "pull-request";
604657 const actions = new Set(entries.map(toolGroupAction));
605658 if (actions.size !== 1) return "mixed";
606659
packages/contracts/src/environment.ts+1
......@@ -130,6 +130,7 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({
130130 threads, and routes PullRequestRef.host across projects on the same host. Same
131131 version-skew contract as threadSettlement. */
132132 threadPullRequests: Schema.optionalKey(Schema.Boolean),
133 pullRequestStackActions: Schema.optionalKey(Schema.Boolean),
133134 /** The update path clients should offer for this server. Absent on
134135 servers that must be relaunched manually (dev checkouts, Windows
135136 foreground runs, pre-update servers). */
packages/contracts/src/pullRequest.ts+24
......@@ -418,6 +418,7 @@ export const PullRequestCapabilities = Schema.Struct({
418418 * the stack the host shows. Absent means chains are only ever inferred from base branches.
419419 */
420420 stacks: Schema.optional(Schema.Boolean),
421 stackActions: Schema.optional(Schema.Boolean),
421422 /**
422423 * The repository's labels can be listed, and one put on a change request or taken off it.
423424 * 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;
438439 * offering one they may not use ends in the host's own refusal — which at least says why.
439440 */
440441export const PullRequestViewerPermissions = Schema.Struct({
442 /** May request remote stack rebases, including when this layer is already current. */
443 stackRebase: Schema.optional(Schema.Boolean),
441444 /** Which of the actions this viewer may take; anything absent is theirs to look at only. */
442445 actions: Schema.Array(PullRequestAction),
443446 /** 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({
469472});
470473export 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
472483export const PullRequestListEntry = Schema.Struct({
484 stack: Schema.optional(PullRequestStackMembership),
473485 provider: SourceControlProviderKind,
474486 /**
475487 * The host below which `repository` is addressed, so the same provider kind can serve more
......@@ -686,6 +698,9 @@ export const PullRequestStack = Schema.Struct({
686698 layers: Schema.Array(
687699 Schema.Struct({
688700 number: PositiveInt,
701 title: Schema.optional(Schema.String),
702 isDraft: Schema.optional(Schema.Boolean),
703 headSha: Schema.optional(TrimmedNonEmptyString),
689704 headBranch: TrimmedNonEmptyString,
690705 state: PullRequestState,
691706 }),
......@@ -908,7 +923,16 @@ export const PullRequestDiffFileContentsResult = Schema.Struct({
908923});
909924export 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
911932export 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)),
912936 ...PullRequestRef.fields,
913937 action: PullRequestAction,
914938 /**