From de37964db26bd3260d253f138bfde2ac60fe254e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 9 Sep 2026 15:50:59 -0700 Subject: [PATCH] feat(prs): navigate, merge and rebase GitHub stacks (#10875) --- .../src/features/threads/thread-work-log.tsx | 17 +- apps/mobile/src/lib/threadActivity.ts | 2 +- .../src/environment/ServerEnvironment.ts | 1 + .../PullRequestSyncReactor.test.ts | 3 +- .../orchestration/PullRequestSyncReactor.ts | 2 +- .../ThreadPullRequestReactor.test.ts | 213 +++++---- .../orchestration/ThreadPullRequestReactor.ts | 11 +- .../pullRequest/GitHubPullRequestCli.test.ts | 178 +++++++- .../src/pullRequest/GitHubPullRequestCli.ts | 83 +++- .../GitHubPullRequestProvider.test.ts | 1 + .../pullRequest/GitHubPullRequestProvider.ts | 6 + .../src/pullRequest/PullRequestProvider.ts | 10 +- .../pullRequest/PullRequestService.test.ts | 127 ++++++ .../src/pullRequest/PullRequestService.ts | 51 ++- .../pullRequest/gitHubPullRequestJson.test.ts | 75 ++++ .../src/pullRequest/gitHubPullRequestJson.ts | 83 +++- .../pullRequest/githubStackActions.test.ts | 413 +++++++++++++++++ .../src/pullRequest/githubStackActions.ts | 418 ++++++++++++++++++ apps/web/src/commandPaletteBus.ts | 9 + .../BranchToolbarBranchSelector.tsx | 85 ++-- apps/web/src/components/ChatView.tsx | 9 + .../components/CommandPalette.logic.test.ts | 69 +++ .../src/components/CommandPalette.logic.ts | 45 +- apps/web/src/components/CommandPalette.tsx | 42 +- apps/web/src/components/Sidebar.tsx | 90 +--- .../src/components/ThreadStatusIndicators.tsx | 88 +++- .../components/chat/MessagesTimeline.logic.ts | 2 +- .../src/components/chat/MessagesTimeline.tsx | 9 + .../pullRequest/PullRequestDetailPanel.tsx | 170 ++++--- .../components/pullRequest/PullRequestRow.tsx | 23 +- .../pullRequest/PullRequestStackHeader.tsx | 26 ++ .../PullRequestStackLayerContent.tsx | 28 ++ .../pullRequest/PullRequestStackLayers.tsx | 39 ++ .../pullRequest/PullRequestStackMap.tsx | 87 ---- .../pullRequest/PullRequestStackMenu.tsx | 257 +++++++++++ .../pullRequest/PullRequestStackPopover.tsx | 108 +++++ .../pullRequest/PullRequestThreadLinks.tsx | 58 +-- .../pullRequestDetail.logic.test.ts | 19 + .../pullRequest/pullRequestDetail.logic.ts | 13 + .../pullRequestStackSnapshot.test.ts | 93 ++++ .../pullRequest/pullRequestStackSnapshot.ts | 73 +++ apps/web/src/lib/openPullRequestLink.ts | 2 + apps/web/src/routes/_chat.pull-requests.tsx | 131 ++++-- apps/web/src/state/pullRequests.ts | 5 +- apps/web/src/state/query.ts | 2 + apps/web/src/state/usePullRequestStack.ts | 33 ++ docs/user/source-control.md | 12 + .../src/state/pullRequests.test.ts | 73 ++- .../client-runtime/src/state/pullRequests.ts | 2 + .../src/work-log/presentation.test.ts | 76 ++++ .../src/work-log/presentation.ts | 71 ++- packages/contracts/src/environment.ts | 1 + packages/contracts/src/pullRequest.ts | 24 + 53 files changed, 3104 insertions(+), 464 deletions(-) create mode 100644 apps/server/src/pullRequest/githubStackActions.test.ts create mode 100644 apps/server/src/pullRequest/githubStackActions.ts create mode 100644 apps/web/src/components/pullRequest/PullRequestStackHeader.tsx create mode 100644 apps/web/src/components/pullRequest/PullRequestStackLayerContent.tsx create mode 100644 apps/web/src/components/pullRequest/PullRequestStackLayers.tsx delete mode 100644 apps/web/src/components/pullRequest/PullRequestStackMap.tsx create mode 100644 apps/web/src/components/pullRequest/PullRequestStackMenu.tsx create mode 100644 apps/web/src/components/pullRequest/PullRequestStackPopover.tsx create mode 100644 apps/web/src/components/pullRequest/pullRequestStackSnapshot.test.ts create mode 100644 apps/web/src/components/pullRequest/pullRequestStackSnapshot.ts create mode 100644 apps/web/src/state/usePullRequestStack.ts diff --git a/apps/mobile/src/features/threads/thread-work-log.tsx b/apps/mobile/src/features/threads/thread-work-log.tsx index a104d2297cd980c291473ce9e16f01d9b6585668..ab1c69beffa552d9b9dd6bfac6256ebf3742d865 100644 --- a/apps/mobile/src/features/threads/thread-work-log.tsx +++ b/apps/mobile/src/features/threads/thread-work-log.tsx @@ -76,7 +76,7 @@ export const THREAD_DISCLOSURE_TRANSITION_MS = 180; const WORK_LOG_LAYOUT_TRANSITION = LinearTransition.duration(THREAD_DISCLOSURE_TRANSITION_MS); const WORK_LOG_DETAIL_ENTER_TRANSITION = FadeIn.duration(140); const WORK_LOG_DETAIL_EXIT_TRANSITION = FadeOut.duration(120); -type WorkContentIcon = AppSymbolName | "browser" | "t3-code"; +type WorkContentIcon = AppSymbolName | "browser" | "t3-code" | "pull-request"; function WorkLogIcon(props: { readonly icon: WorkContentIcon; @@ -92,7 +92,13 @@ function WorkLogIcon(props: { } return ( + const stack: PullRequestService["Service"]["stack"] = (input, readOptions) => Effect.gen(function* () { + assert.strictEqual(readOptions?.includeDetails, false); yield* Ref.update(stackCalls, (calls) => [...calls, input]); return yield* options.stack?.(input) ?? Effect.succeed(null); }); diff --git a/apps/server/src/orchestration/PullRequestSyncReactor.ts b/apps/server/src/orchestration/PullRequestSyncReactor.ts index e86ef14cd0cc7c816ab8f978bbb26f345ec4d604..0a39fa5d91d1e97f0e53dc9d07eb92646ffdf031 100644 --- a/apps/server/src/orchestration/PullRequestSyncReactor.ts +++ b/apps/server/src/orchestration/PullRequestSyncReactor.ts @@ -256,7 +256,7 @@ export const make = Effect.gen(function* () { entry.link.snapshot === null || !snapshotFieldsEqual(entry.link.snapshot, fields), ); const fetchedStack = needsStack - ? yield* pullRequests.stack(ref).pipe( + ? yield* pullRequests.stack(ref, { includeDetails: false }).pipe( Effect.map((stack) => ({ stack: stack === null ? null : ({ kind: "native", ...stack } as const), })), diff --git a/apps/server/src/orchestration/ThreadPullRequestReactor.test.ts b/apps/server/src/orchestration/ThreadPullRequestReactor.test.ts index 6f0b4d90aca1e667d38906a7358b049ff3293baf..d6572083dad285e3d23ff03d806be3ab641f51be 100644 --- a/apps/server/src/orchestration/ThreadPullRequestReactor.test.ts +++ b/apps/server/src/orchestration/ThreadPullRequestReactor.test.ts @@ -1,9 +1,11 @@ import { + CheckpointRef, EventId, GitManagerError, ProjectId, ProviderInstanceId, ThreadId, + TurnId, type OrchestrationCommand, type OrchestrationEvent, type OrchestrationProjectShell, @@ -311,54 +313,79 @@ describe("ThreadPullRequestReactor", () => { ), ); - it.effect("refreshes discovery after a turn ends without client demand", () => - Effect.scoped( - Effect.gen(function* () { - const current = thread("turn-thread"); - const detected = yield* Ref.make(null); - const fixture = yield* makeHarness({ - threads: [current], - branchPullRequest: (_input, options) => - options?.refresh - ? Ref.set(detected, branchPullRequest()).pipe(Effect.andThen(Ref.get(detected))) - : Ref.get(detected), - }); - yield* Effect.gen(function* () { - const reactor = yield* fixture.start(); - expect(yield* Ref.get(fixture.commands)).toHaveLength(0); - yield* fixture.publish({ - type: "thread.session-set", - sequence: 2, - eventId: EventId.make("turn-finished"), - aggregateKind: "thread", - aggregateId: current.id, - occurredAt: NOW, - commandId: null, - causationEventId: null, - correlationId: null, - metadata: {}, - payload: { - threadId: current.id, - session: { + it.effect.each(["session-first", "checkpoint-first"] as const)( + "refreshes discovery once after a turn ends with %s event ordering", + (order) => + Effect.scoped( + Effect.gen(function* () { + const current = thread("turn-thread"); + const detected = yield* Ref.make(null); + const fixture = yield* makeHarness({ + threads: [current], + branchPullRequest: (_input, options) => + options?.refresh + ? Ref.set(detected, branchPullRequest()).pipe(Effect.andThen(Ref.get(detected))) + : Ref.get(detected), + }); + yield* Effect.gen(function* () { + const reactor = yield* fixture.start(); + expect(yield* Ref.get(fixture.commands)).toHaveLength(0); + const sessionEvent: OrchestrationEvent = { + type: "thread.session-set", + sequence: 2, + eventId: EventId.make("turn-finished"), + aggregateKind: "thread", + aggregateId: current.id, + occurredAt: NOW, + commandId: null, + causationEventId: null, + correlationId: null, + metadata: {}, + payload: { threadId: current.id, + session: { + threadId: current.id, + status: "ready", + providerName: "Codex", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: NOW, + }, + }, + }; + const checkpointEvent: OrchestrationEvent = { + ...sessionEvent, + type: "thread.turn-diff-completed", + sequence: 3, + eventId: EventId.make("checkpoint-finished"), + payload: { + threadId: current.id, + turnId: TurnId.make("turn"), + checkpointTurnCount: 1, + checkpointRef: CheckpointRef.make("checkpoint"), status: "ready", - providerName: "Codex", - runtimeMode: "full-access", - activeTurnId: null, - lastError: null, - updatedAt: NOW, + files: [], + assistantMessageId: null, + completedAt: NOW, }, - }, - }); - yield* Queue.take(fixture.reads); - yield* reactor.drain; - expect((yield* Ref.get(fixture.commands))[0]?.branchPullRequest).toEqual(reference(42)); - expect((yield* Ref.get(fixture.branchCalls)).filter((call) => call.refresh)).toHaveLength( - 1, - ); - }).pipe(Effect.provide(fixture.layer)); - }), - ), + }; + const events = + order === "session-first" + ? [sessionEvent, checkpointEvent] + : [checkpointEvent, sessionEvent]; + for (const event of events) { + yield* fixture.publish(event); + yield* Queue.take(fixture.reads); + yield* reactor.drain; + } + expect((yield* Ref.get(fixture.commands))[0]?.branchPullRequest).toEqual(reference(42)); + expect( + (yield* Ref.get(fixture.branchCalls)).filter((call) => call.refresh), + ).toHaveLength(1); + }).pipe(Effect.provide(fixture.layer)); + }), + ), ); it.effect("uses live worktrees and falls back to the project for removed worktrees", () => @@ -455,54 +482,56 @@ describe("ThreadPullRequestReactor", () => { ), ); - it.effect("retries failed settled backfills and stops querying them after success", () => - Effect.scoped( - Effect.gen(function* () { - const online = yield* Ref.make(false); - const fixture = yield* makeHarness({ - threads: [ - thread("backfill", { settledOverride: "settled", settledAt: NOW }), - thread("known", { - branch: "known", - settledOverride: "settled", - settledAt: NOW, - branchPullRequest: reference(1), - }), - ], - branchPullRequest: ({ cwd }) => - Ref.get(online).pipe( - Effect.flatMap((connected) => - connected - ? Effect.succeed(branchPullRequest(42, "merged")) - : Effect.fail( - new GitManagerError({ - operation: "branchPullRequest", - cwd, - detail: "Offline", - }), - ), + it.effect.each(["settled", null] as const)( + "retries settled backfills and stops after success with settledOverride %s", + (settledOverride) => + Effect.scoped( + Effect.gen(function* () { + const online = yield* Ref.make(false); + const fixture = yield* makeHarness({ + threads: [ + thread("backfill", { settledOverride, settledAt: NOW }), + thread("known", { + branch: "known", + settledOverride, + settledAt: NOW, + branchPullRequest: reference(1), + }), + ], + branchPullRequest: ({ cwd }) => + Ref.get(online).pipe( + Effect.flatMap((connected) => + connected + ? Effect.succeed(branchPullRequest(42, "merged")) + : Effect.fail( + new GitManagerError({ + operation: "branchPullRequest", + cwd, + detail: "Offline", + }), + ), + ), ), - ), - }); - yield* Effect.gen(function* () { - const reactor = yield* fixture.start(); - expect(yield* Ref.get(fixture.commands)).toHaveLength(0); - yield* Ref.set(online, true); - yield* TestClock.adjust("1 minute"); - yield* Queue.take(fixture.reads); - yield* reactor.drain; - expect((yield* Ref.get(fixture.commands))[0]?.threadId).toBe("backfill"); - yield* TestClock.adjust("1 minute"); - yield* Queue.take(fixture.reads); - yield* reactor.drain; - expect((yield* Ref.get(fixture.branchCalls)).map((call) => call.branch)).toEqual([ - "feature", - "feature", - "feature", - ]); - }).pipe(Effect.provide(fixture.layer)); - }), - ), + }); + yield* Effect.gen(function* () { + const reactor = yield* fixture.start(); + expect(yield* Ref.get(fixture.commands)).toHaveLength(0); + yield* Ref.set(online, true); + yield* TestClock.adjust("1 minute"); + yield* Queue.take(fixture.reads); + yield* reactor.drain; + expect((yield* Ref.get(fixture.commands))[0]?.threadId).toBe("backfill"); + yield* TestClock.adjust("1 minute"); + yield* Queue.take(fixture.reads); + yield* reactor.drain; + expect((yield* Ref.get(fixture.branchCalls)).map((call) => call.branch)).toEqual([ + "feature", + "feature", + "feature", + ]); + }).pipe(Effect.provide(fixture.layer)); + }), + ), ); it.effect("stops retrying a settled backfill after repeated lookup failures", () => diff --git a/apps/server/src/orchestration/ThreadPullRequestReactor.ts b/apps/server/src/orchestration/ThreadPullRequestReactor.ts index e6ee699ec4debb14b35843cb630acb5db5f39d3e..86026632a6afb7dcf0bb13a75a3b9a32a947f947 100644 --- a/apps/server/src/orchestration/ThreadPullRequestReactor.ts +++ b/apps/server/src/orchestration/ThreadPullRequestReactor.ts @@ -101,7 +101,10 @@ export const make = Effect.gen(function* () { const projects = new Map(snapshot.projects.map((project) => [project.id, project])); if (request.backfill) { for (const thread of snapshot.threads) { - if (thread.settledOverride === "settled" && thread.branchPullRequest == null) { + if ( + (thread.settledOverride === "settled" || thread.settledAt !== null) && + thread.branchPullRequest == null + ) { pendingBackfill.set(thread.id, BACKFILL_ATTEMPTS); } } @@ -114,7 +117,7 @@ export const make = Effect.gen(function* () { (thread) => thread.archivedAt === null && (request.threadId === null || thread.id === request.threadId) && - (thread.settledOverride !== "settled" || + ((thread.settledOverride !== "settled" && thread.settledAt === null) || request.threadId !== null || pendingBackfill.has(thread.id)) && (thread.branch !== null || thread.branchPullRequest != null), @@ -327,7 +330,9 @@ export const make = Effect.gen(function* () { event.payload.session.status !== "running" && event.payload.session.status !== "starting" ) { - return worker.enqueue({ threadId: event.payload.threadId, refresh: true }); + // Checkpoint completion forces the post-turn read. Session lifecycle + // events reuse it regardless of which event reaches this worker first. + return worker.enqueue({ threadId: event.payload.threadId, refresh: false }); } break; case "thread.turn-diff-completed": diff --git a/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts b/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts index 2941b3643638beb87c64fdd1299eb8431cbf4fe0..6ef485627afbc7649cff1d02d8905ab3da9d5399 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts @@ -1,6 +1,7 @@ import { afterEach, assert, expect, it, vi } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; import * as TestClock from "effect/testing/TestClock"; import { ChildProcessSpawner } from "effect/unstable/process"; @@ -9,14 +10,22 @@ import * as GitHubGraphQlBudget from "../sourceControl/githubGraphQlBudget.ts"; import * as GitHubPullRequestCli from "./GitHubPullRequestCli.ts"; import { BASE_COMPARISON_GRAPHQL_QUERY } from "./gitHubPullRequestJson.ts"; +const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); + const mockedExecute = vi.fn(); +const mockedStackMemberships = vi.fn(() => + Effect.succeed(output('{"data":{}}')), +); const mockedGetPullRequest = vi.fn(); const layer = it.layer( GitHubPullRequestCli.layer.pipe( Layer.provide( Layer.mock(GitHubCli.GitHubCli)({ - execute: mockedExecute, + execute: (input) => + input.args.some((arg) => arg.includes("query PullRequestStackMemberships")) + ? mockedStackMemberships(input) + : mockedExecute(input), getPullRequest: mockedGetPullRequest, }), ), @@ -179,6 +188,7 @@ function searchQueryOfCall(index: number): string | undefined { afterEach(() => { mockedExecute.mockReset(); + mockedStackMemberships.mockReset(); mockedGetPullRequest.mockReset(); }); @@ -320,6 +330,51 @@ layer("GitHubPullRequestCli.layer", (it) => { }), ); + it.effect("fetches layer titles only when the caller asks for stack details", () => + Effect.gen(function* () { + const minimal = { + url: "https://api.github.com/repos/acme/web/stacks/3", + number: 3, + base: { ref: "main" }, + pull_requests: [ + { number: 7, head: { ref: "feat/two", sha: "abc123" }, state: "open", merged_at: null }, + ], + }; + // @effect-diagnostics-next-line preferSchemaOverJson:off + mockedExecute.mockReturnValueOnce(Effect.succeed(output(JSON.stringify([minimal])))); + mockedExecute.mockReturnValueOnce( + Effect.succeed( + output( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ + ...minimal, + pull_requests: [{ ...minimal.pull_requests[0], title: "Second layer", draft: false }], + }), + ), + ), + ); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const stack = yield* cli.getPullRequestStack({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + includeDetails: true, + }); + expect(stack?.layers[0]).toMatchObject({ + title: "Second layer", + headSha: "abc123", + isDraft: false, + }); + expect(callAt(1).args).toEqual([ + "api", + "--hostname", + "github.com", + "repos/acme/web/stacks/3", + ]); + }), + ); + it.effect("reads an empty stacks listing as not stacked", () => Effect.gen(function* () { mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]"))); @@ -715,6 +770,127 @@ layer("GitHubPullRequestCli.layer", (it) => { }), ); + it.effect("enriches only the visible fallback rows after filtering and widening", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]"))); + mockedExecute.mockReturnValueOnce( + Effect.succeed(output(pullRequests(3, 1, () => ({ isDraft: true })))), + ); + mockedExecute.mockReturnValueOnce( + Effect.succeed(output(pullRequests(6, 1, (number) => ({ isDraft: number < 4 })))), + ); + mockedStackMemberships.mockReturnValueOnce( + Effect.succeed( + output( + encodeJson({ + data: { + s0: { + pullRequest: { + stack: { number: 3, size: 2, baseRefName: "main" }, + stackEntry: { position: 1 }, + }, + }, + s1: { pullRequest: { stack: null, stackEntry: null } }, + }, + }), + ), + ), + ); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const batch = yield* cli.listPullRequests({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + state: "open", + involvement: "all", + viewer: "bilal", + limit: 2, + filters: { draft: "hide" }, + }); + expect(batch.items.map((item) => item.number)).toEqual([4, 5]); + expect(batch.items[0]?.stack).toEqual({ number: 3, size: 2, base: "main", position: 1 }); + expect(batch.items[1]?.stack).toBeUndefined(); + expect(batch.truncated).toBe(true); + expect(batch.continues).toBe(false); + expect(mockedStackMemberships).toHaveBeenCalledTimes(1); + const query = mockedStackMemberships.mock.calls[0]?.[0].args.at(-1) ?? ""; + expect(query).toContain("pullRequest(number: 4)"); + expect(query).toContain("pullRequest(number: 5)"); + expect(query).not.toContain("pullRequest(number: 1)"); + expect(query).not.toContain("pullRequest(number: 6)"); + }), + ); + + it.effect("batches membership reads and keeps successful rows when one chunk fails", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequests(27, 1)))); + mockedStackMemberships.mockImplementation((input) => + input.args.at(-1)?.includes("pullRequest(number: 26)") + ? Effect.fail( + new GitHubCli.GitHubCliCommandError({ + command: "gh", + cwd: "/w", + cause: new Error("HTTP 502"), + }), + ) + : Effect.succeed( + output( + encodeJson({ + data: { + s0: { + pullRequest: { + stack: { number: 3, size: 2, baseRefName: "main" }, + stackEntry: { position: 1 }, + }, + }, + }, + }), + ), + ), + ); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const batch = yield* cli.listPullRequests({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + state: "open", + involvement: "all", + viewer: "bilal", + limit: 26, + }); + expect(batch.items.map((item) => item.number)).toEqual( + Array.from({ length: 26 }, (_, i) => i + 1), + ); + expect(batch.items[0]?.stack).toEqual({ number: 3, size: 2, base: "main", position: 1 }); + expect(batch.items[25]?.stack).toBeUndefined(); + expect(batch.truncated).toBe(true); + expect(batch.continues).toBe(true); + expect(mockedStackMemberships).toHaveBeenCalledTimes(2); + }), + ); + + it.effect("skips membership enrichment for empty pages and enterprise hosts", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]"))); + mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]"))); + mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequests(1, 7)))); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const input = { + cwd: "/w", + repository: "acme/web", + state: "open" as const, + involvement: "all" as const, + viewer: "bilal", + limit: 2, + }; + const empty = yield* cli.listPullRequests({ ...input, host: "github.com" }); + const enterprise = yield* cli.listPullRequests({ ...input, host: "github.acme.test" }); + expect(empty.items).toEqual([]); + expect(enterprise.items.map((item) => item.number)).toEqual([7]); + expect(mockedStackMemberships).not.toHaveBeenCalled(); + }), + ); + it.effect("reads the line counts in chunks, and files them back by position", () => Effect.gen(function* () { const changeRequests = Array.from({ length: 26 }, (_, index) => ({ diff --git a/apps/server/src/pullRequest/GitHubPullRequestCli.ts b/apps/server/src/pullRequest/GitHubPullRequestCli.ts index 136f87e6a051654efc31d31a4e0a1e99e83b7a94..5e1950a6eeb049dae2850e47a12740fc0e2eb9c3 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestCli.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestCli.ts @@ -1,3 +1,4 @@ +import { runGitHubStackAction, type GitHubStackActionError } from "./githubStackActions.ts"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -6,6 +7,7 @@ import * as Schema from "effect/Schema"; import { resolvePullRequestAuthorFilter, type PullRequestAction, + type PullRequestStackHead, type PullRequestActor, type PullRequestInvolvement, type PullRequestListFilters, @@ -51,6 +53,8 @@ import { decodeReviewThreadCommentsJson, decodeReviewThreadsJson, buildPullRequestStatsGraphQlQuery, + buildPullRequestStackMembershipsGraphQlQuery, + decodePullRequestStackMembershipsJson, encodeGraphQlRequestJson, pullRequestSearchGraphQlQuery, PULL_REQUEST_SEARCH_MAX_ROWS, @@ -334,6 +338,7 @@ export class GitHubWorkflowApprovalHeadChangedError extends Schema.TaggedError; readonly mergeMethod?: PullRequestMergeMethod; readonly updateMethod?: PullRequestUpdateMethod; }) => Effect.Effect; @@ -1557,6 +1565,47 @@ export const make = Effect.gen(function* () { ? read(false) : Effect.succeed(batch), ), + Effect.flatMap((batch) => { + // Match the search query's host support, and enrich only rows that survived paging. + if (input.host !== "github.com" || batch.items.length === 0) return Effect.succeed(batch); + const chunks: Array> = []; + for (let start = 0; start < batch.items.length; start += STAT_ALIASES_PER_REQUEST) { + chunks.push(batch.items.slice(start, start + STAT_ALIASES_PER_REQUEST)); + } + return Effect.forEach( + chunks, + (chunk) => { + const query = buildPullRequestStackMembershipsGraphQlQuery( + input.repository, + chunk.map((item) => item.number), + ); + if (query === null) return Effect.succeed(chunk); + return graphqlRead({ + cwd: input.cwd, + host: input.host, + operation: "listPullRequestStackMemberships", + query, + decode: decodePullRequestStackMembershipsJson, + }).pipe( + Effect.map((memberships) => + chunk.map((item, index) => { + const stack = memberships.get(index); + return stack === undefined ? item : { ...item, stack }; + }), + ), + // Optional badges must not take down a listing that already read successfully. + Effect.catch(() => + Effect.logWarning("Pull request stack membership enrichment failed", { + operation: "listPullRequestStackMemberships", + host: input.host, + rows: chunk.length, + }).pipe(Effect.as(chunk)), + ), + ); + }, + { concurrency: STAT_REQUEST_CONCURRENCY }, + ).pipe(Effect.map((chunks) => ({ ...batch, items: chunks.flat() }))); + }), ); }, @@ -1581,7 +1630,7 @@ export const make = Effect.gen(function* () { operation: "searchPullRequests", // The reader's own words are in the query, so it travels over stdin rather than in argv. privateVariables: { q: query }, - query: pullRequestSearchGraphQlQuery(rows), + query: pullRequestSearchGraphQlQuery(rows, input.host === "github.com"), decode: decodePullRequestSearchJson, }).pipe( Effect.map((batch) => ({ @@ -1709,6 +1758,34 @@ export const make = Effect.gen(function* () { }), ); }), + Effect.flatMap((stack) => { + if (!input.includeDetails || stack === null) return Effect.succeed(stack); + return github + .execute({ + cwd: input.cwd, + args: [ + "api", + "--hostname", + input.host, + `repos/${owner}/${name}/stacks/${stack.number}`, + ], + }) + .pipe( + Effect.flatMap((result) => { + const decoded = decodePullRequestStacksJson(`[${result.stdout.trim()}]`); + return Result.isSuccess(decoded) + ? Effect.succeed(decoded.success) + : Effect.fail( + new GitHubPullRequestReadError({ + command: "gh", + cwd: input.cwd, + operation: "getPullRequestStack", + cause: decoded.failure, + }), + ); + }), + ); + }), // Hosts without the stacks preview return 404. Other failures must preserve the // previously synced stack and let the caller retry. Effect.catchTags({ @@ -2109,6 +2186,10 @@ export const make = Effect.gen(function* () { }, runPullRequestAction: (input) => { + if (input.stackNumber !== undefined) + return runGitHubStackAction({ ...input, stackNumber: input.stackNumber }).pipe( + Effect.provideService(GitHubCli.GitHubCli, github), + ); if (input.action === "revert") { return pullRequestNodeId({ ...input, operation: "revertPullRequest" }).pipe( Effect.flatMap((pullRequestId) => diff --git a/apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts b/apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts index 47fb593279e719baec4a269452b750eabf41d80d..fe5032d70d5cd884955ed5f9e7f4d610121f1085 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts @@ -130,6 +130,7 @@ describe("gitHubViewerPermissions", () => { ], comment: true, resolve: true, + stackRebase: true, verdicts: ["comment", "approve", "request-changes"], requestReviewers: true, labels: true, diff --git a/apps/server/src/pullRequest/GitHubPullRequestProvider.ts b/apps/server/src/pullRequest/GitHubPullRequestProvider.ts index 0d46b032a5a531a79a6a7be17ea7aa75352eca1c..66a247efb90e23e8360fcc027eddc5fa1ef4993e 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestProvider.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestProvider.ts @@ -48,6 +48,7 @@ const CAPABILITIES: PullRequestCapabilities = { reviewers: { request: true, listCandidates: true }, edit: { changeRequest: true, comment: true }, stacks: true, + stackActions: true, labels: true, }; @@ -70,6 +71,7 @@ const CAPABILITIES: PullRequestCapabilities = { */ export function gitHubViewerPermissions(access: GitHubViewerAccess): PullRequestViewerPermissions { return { + ...(access.canWrite ? { stackRebase: true } : {}), actions: [ // Arming a merge and taking the arming back are the merge, deferred: whoever may not // merge here may not leave an instruction to merge later either. @@ -558,6 +560,10 @@ export const make = Effect.gen(function* () { host: input.host, number: input.number, action: input.action, + ...(input.stackNumber === undefined ? {} : { stackNumber: input.stackNumber }), + ...(input.expectedStackHeads === undefined + ? {} + : { expectedStackHeads: input.expectedStackHeads }), ...(input.mergeMethod === undefined ? {} : { mergeMethod: input.mergeMethod }), ...(input.updateMethod === undefined ? {} : { updateMethod: input.updateMethod }), }) diff --git a/apps/server/src/pullRequest/PullRequestProvider.ts b/apps/server/src/pullRequest/PullRequestProvider.ts index 2c1fda70d51913884fd6bee823c04d562a4ad503..2309268bba688fca22c5feaf123cec16a2b00ad0 100644 --- a/apps/server/src/pullRequest/PullRequestProvider.ts +++ b/apps/server/src/pullRequest/PullRequestProvider.ts @@ -1,7 +1,9 @@ import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; import type { + PullRequestStackMembership, PullRequestAction, + PullRequestStackHead, PullRequestActor, PullRequestBaseComparison, PullRequestCapabilities, @@ -65,6 +67,7 @@ export interface PullRequestProviderFailure { /** A change request as the provider sees it, before the service attaches project context. */ export interface ProviderChangeRequest { + readonly stack?: PullRequestStackMembership; readonly number: number; readonly title: string; readonly url: string; @@ -115,6 +118,9 @@ export interface ProviderChangeRequestSummary { /** One layer of a host-native stack, bottom to top order is the array's. */ export interface ProviderChangeRequestStackLayer { + readonly title?: string; + readonly isDraft?: boolean; + readonly headSha?: string; readonly number: number; readonly headBranch: string; readonly state: PullRequestState; @@ -364,7 +370,7 @@ export interface PullRequestProviderApi { * because most hosts have no such object; the service derives chains from base branches there. */ readonly getChangeRequestStack?: ( - input: ProviderRepositoryRef & { readonly number: number }, + input: ProviderRepositoryRef & { readonly includeDetails?: boolean; readonly number: number }, ) => Effect.Effect; /** Comments, line threads, and commits, kept off the critical path for the core detail. */ @@ -426,6 +432,8 @@ export interface PullRequestProviderApi { input: ProviderRepositoryRef & { readonly number: number; readonly action: PullRequestAction; + readonly stackNumber?: number; + readonly expectedStackHeads?: ReadonlyArray; /** Meaningful for `merge` and `enable-auto-merge`; absent takes the host's own default. */ readonly mergeMethod?: PullRequestMergeMethod; /** Only meaningful for `update-branch`; absent takes the host's own default. */ diff --git a/apps/server/src/pullRequest/PullRequestService.test.ts b/apps/server/src/pullRequest/PullRequestService.test.ts index c78cfb6472172b6952ae81f7e7015e98a2e4786c..db2f6cf2642fafe76afa09de9e9461dc65133003 100644 --- a/apps/server/src/pullRequest/PullRequestService.test.ts +++ b/apps/server/src/pullRequest/PullRequestService.test.ts @@ -1564,6 +1564,48 @@ it.effect("refuses a repository that does not belong to the requested project", }), ); +it.effect("caches stack membership separately from action details", () => + Effect.gen(function* () { + const reads: Array = []; + const service = yield* makeService({ + projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], + providers: [ + fakeProvider("github", { + getChangeRequestStack: (input) => + Effect.sync(() => { + reads.push(input.includeDetails === true); + return { + id: "9", + number: 3, + url: "https://github.com/acme/web/stacks/3", + base: "main", + layers: [ + { + number: 7, + headBranch: "a", + state: "open" as const, + ...(input.includeDetails ? { title: "First layer", headSha: "abc" } : {}), + }, + ], + }; + }), + }), + ], + }); + const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 7 }; + yield* service.stack(reference, { includeDetails: false }); + yield* service.stack(reference, { includeDetails: false }); + const detail = yield* service.stack(reference); + yield* service.stack(reference); + assert.deepStrictEqual(reads, [false, true]); + assert.strictEqual(detail?.layers[0]?.headSha, "abc"); + yield* service.invalidate({ reference }); + yield* service.stack(reference, { includeDetails: false }); + yield* service.stack(reference); + assert.deepStrictEqual(reads, [false, true, false, true]); + }), +); + it.effect("reads a host-native stack through the provider and null where it has none", () => Effect.gen(function* () { const service = yield* makeService({ @@ -3997,6 +4039,91 @@ it.effect('resolves an author filter of "me" to the viewer before narrowing a ho }), ); +it.effect("authorizes stack rebases independently of whether the selected layer is behind", () => + Effect.gen(function* () { + let taken = 0; + let summaryReads = 0; + let mutationFails = false; + let stackRebase = true; + let stackActions = true; + const capabilities = { + diff: true, + comment: true, + actions: ["update-branch"] as const, + mergeMethods: ["merge"] as const, + updateMethods: ["rebase"] as const, + get stackActions() { + return stackActions; + }, + search: true, + reactions: true, + review: FULL_REVIEW, + reviewers: FULL_REVIEWERS, + }; + const service = yield* makeService({ + projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], + providers: [ + fakeProvider("github", { + capabilities, + getViewerPermissions: () => + Effect.succeed({ + actions: [], + stackRebase, + comment: true, + resolve: false, + verdicts: [], + requestReviewers: false, + }), + getChangeRequestSummary: () => + Effect.sync(() => { + summaryReads++; + return changeRequest(8, "2026-07-01T00:00:00Z"); + }), + runAction: () => + Effect.gen(function* () { + taken++; + if (mutationFails) return yield* requestFailed; + }), + }), + ], + }); + const input = { + projectId: "p1" as ProjectId, + repository: "acme/web", + number: 3, + action: "update-branch" as const, + updateMethod: "rebase" as const, + stackNumber: 50, + expectedStackHeads: [{ number: 3, headSha: "ccc" }], + }; + yield* service.runAction(input); + assert.strictEqual(taken, 1); + const unrelated = { ...input, number: 8 }; + yield* service.summary(unrelated); + assert.strictEqual(summaryReads, 1); + stackRebase = false; + assert.strictEqual( + (yield* Effect.flip(service.runAction(input)))._tag, + "PullRequestOperationError", + ); + stackRebase = true; + stackActions = false; + assert.strictEqual( + (yield* Effect.flip(service.runAction(input)))._tag, + "PullRequestOperationError", + ); + assert.strictEqual(taken, 1); + yield* service.summary(unrelated); + assert.strictEqual(summaryReads, 1); + stackActions = true; + mutationFails = true; + yield* Effect.flip(service.runAction(input)); + assert.strictEqual(taken, 2); + yield* service.summary(unrelated); + assert.strictEqual(summaryReads, 2); + }), +); + it.effect("refuses a way of updating a branch that the host or the viewer does not allow", () => Effect.gen(function* () { let taken: string | null = null; diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts index f97366018dcdc800c058ff1bded72203236d4cbd..4c81d0f6d264d1ede5dfc85395660d5d96173868 100644 --- a/apps/server/src/pullRequest/PullRequestService.ts +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -155,6 +155,7 @@ export class PullRequestService extends Context.Service< */ readonly stack: ( input: PullRequestRef, + options?: { readonly includeDetails?: boolean }, ) => Effect.Effect; readonly subscribeMerges: Effect.Effect< Stream.Stream, @@ -907,6 +908,7 @@ export const make = Effect.gen(function* () { }): PullRequestListEntry => { const viewer = input.viewer.toLowerCase(); return { + ...(input.item.stack === undefined ? {} : { stack: input.item.stack }), provider: input.project.api.kind, host: input.project.host, projectId: input.project.project.id, @@ -1331,7 +1333,7 @@ export const make = Effect.gen(function* () { }), ); - const stackUncached: PullRequestService["Service"]["stack"] = (input) => + const stackUncached: PullRequestService["Service"]["stack"] = (input, options) => requireProject(input).pipe( Effect.flatMap((project) => { const read = project.api.getChangeRequestStack; @@ -1341,6 +1343,7 @@ export const make = Effect.gen(function* () { repository: project.repository, host: project.host, number: input.number, + includeDetails: options?.includeDetails !== false, }).pipe( Effect.mapError(toPullRequestError("stack")), Effect.map((stack): PullRequestStack | null => @@ -1352,6 +1355,7 @@ export const make = Effect.gen(function* () { url: stack.url, base: stack.base, layers: stack.layers.map((layer) => ({ + ...layer, number: layer.number, headBranch: layer.headBranch, state: layer.state, @@ -1531,6 +1535,20 @@ export const make = Effect.gen(function* () { const runAction = (input: PullRequestActionInput): Effect.Effect => requireProject(input).pipe( Effect.flatMap((project): Effect.Effect => { + if ( + input.stackNumber !== undefined && + (project.api.capabilities.stackActions !== true || + !["merge", "update-branch"].includes(input.action) || + input.expectedStackHeads === undefined || + (input.action === "update-branch" && input.updateMethod !== "rebase")) + ) { + return Effect.fail( + new PullRequestOperationError({ + operation: "runAction", + detail: "This stack action is not supported or has no expected head revision.", + }), + ); + } // The surface hides what a host cannot do, and this refuses it as well: a request that // reached here anyway must not be handed to a provider that never claimed the action. if (!project.api.capabilities.actions.includes(input.action)) { @@ -1573,7 +1591,10 @@ export const make = Effect.gen(function* () { // above do not. return viewerPermissionsOf(project, input, "runAction").pipe( Effect.flatMap((viewer): Effect.Effect => { - if (!viewer.actions.includes(input.action)) { + const stackRebase = input.stackNumber !== undefined && input.action === "update-branch"; + if ( + stackRebase ? viewer.stackRebase !== true : !viewer.actions.includes(input.action) + ) { return Effect.fail( new PullRequestOperationError({ operation: "runAction", @@ -1582,6 +1603,7 @@ export const make = Effect.gen(function* () { ); } if ( + !stackRebase && input.updateMethod !== undefined && !(viewer.updateMethods ?? []).includes(input.updateMethod) ) { @@ -1599,10 +1621,17 @@ export const make = Effect.gen(function* () { host: project.host, number: input.number, action: input.action, + ...(input.stackNumber === undefined ? {} : { stackNumber: input.stackNumber }), + ...(input.expectedStackHeads === undefined + ? {} + : { expectedStackHeads: input.expectedStackHeads }), ...(input.mergeMethod === undefined ? {} : { mergeMethod: input.mergeMethod }), ...(input.updateMethod === undefined ? {} : { updateMethod: input.updateMethod }), }) .pipe( + // Once the authorized provider action starts, a failure may leave partial + // remote updates. Validation and permission failures above changed nothing. + Effect.ensuring(input.stackNumber === undefined ? Effect.void : refreshAfterTurn), Effect.mapError(toPullRequestError("runAction")), Effect.as( project.api.kind === "azure-devops" @@ -2327,12 +2356,18 @@ export const make = Effect.gen(function* () { ); }; - const stackCache = yield* Cache.makeWith((key: string) => stackUncached(refOfCacheKey(key)), { - capacity: DETAIL_CACHE_CAPACITY, - timeToLive: (exit) => (Exit.isSuccess(exit) ? SUMMARY_CACHE_TTL : Duration.zero), - }); - const stack: PullRequestService["Service"]["stack"] = (input) => - Cache.get(stackCache, refCacheKey(input)); + const stackCache = yield* Cache.makeWith( + (key: string) => { + const [referenceKey, includeDetails] = JSON.parse(key) as [string, boolean]; + return stackUncached(refOfCacheKey(referenceKey), { includeDetails }); + }, + { + capacity: DETAIL_CACHE_CAPACITY, + timeToLive: (exit) => (Exit.isSuccess(exit) ? SUMMARY_CACHE_TTL : Duration.zero), + }, + ); + const stack: PullRequestService["Service"]["stack"] = (input, options) => + Cache.get(stackCache, JSON.stringify([refCacheKey(input), options?.includeDetails !== false])); // Keys serialize positionally and parse back in the lookup, so the cache is the only holder // of in-flight state: concurrent identical reads coalesce on the key into one host request. diff --git a/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts b/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts index 5391e8a567ec9558b7b5bb4030382067db9f4f73..f9f6c4ce3d26acad4b2c956e948e82e8fc193bea 100644 --- a/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts +++ b/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts @@ -3,6 +3,8 @@ import { describe, expect, it } from "vite-plus/test"; import { buildReviewSubmissionJson, + buildPullRequestStackMembershipsGraphQlQuery, + decodePullRequestStackMembershipsJson, buildReviewerRequestJson, decodeBaseComparisonJson, decodePullRequestActivityJson, @@ -21,6 +23,7 @@ import { decodeWorkflowRunApprovalsJson, reviewThreadConversation, REVIEW_THREADS_GRAPHQL_QUERY, + pullRequestSearchGraphQlQuery, } from "./gitHubPullRequestJson.ts"; function listJson(entries: ReadonlyArray>): string { @@ -165,6 +168,17 @@ describe("pull request search decoding", () => { }); } + it("keeps stack membership beside search results without extra per-PR reads", () => { + const raw = JSON.parse(searchJson(["SUCCESS", null])); + raw.data.search.nodes[0].stack = { number: 3, size: 2, baseRefName: "main" }; + raw.data.search.nodes[0].stackEntry = { position: 1 }; + const batch = expectSuccess(decodePullRequestSearchJson(JSON.stringify(raw))); + expect(batch.items[0]?.stack).toEqual({ number: 3, size: 2, position: 1, base: "main" }); + expect(batch.items[1]?.stack).toBeUndefined(); + expect(pullRequestSearchGraphQlQuery(20, true)).toContain("stackEntry"); + expect(pullRequestSearchGraphQlQuery(20)).not.toContain("stackEntry"); + }); + it("maps the rollup enum the search answers with onto the same three words", () => { // The search asks GitHub for the verdict rather than the checks behind it, so this path sees // one enum where the listing sees an array. @@ -1548,6 +1562,32 @@ describe("host-native stack decoding", () => { }); }); + it("retains the detailed layer titles, draft state and expected revision", () => { + expect( + expectStack({ + pull_requests: [ + { + number: 11, + title: "Second layer", + draft: true, + head: { ref: "feat/two", sha: "abc123" }, + state: "open", + merged_at: null, + }, + ], + }).layers, + ).toEqual([ + { + number: 11, + title: "Second layer", + isDraft: true, + headSha: "abc123", + headBranch: "feat/two", + state: "open", + }, + ]); + }); + it("accepts a base named as a bare branch, which is what the preview started out sending", () => { expect(expectStack({ base: "develop" }).base).toBe("develop"); }); @@ -1579,3 +1619,38 @@ describe("host-native stack decoding", () => { expect(Result.isSuccess(decodePullRequestStacksJson("{"))).toBe(false); }); }); + +describe("pull request stack membership batches", () => { + it("maps aliases while skipping missing pull requests and incomplete memberships", () => { + const memberships = expectSuccess( + decodePullRequestStackMembershipsJson( + JSON.stringify({ + data: { + s0: { + pullRequest: { + stack: { number: 3, size: 2, baseRefName: "main" }, + stackEntry: { position: 1 }, + }, + }, + s1: null, + s2: { pullRequest: null }, + s3: { pullRequest: { stack: null, stackEntry: null } }, + s4: { pullRequest: { stack: { number: 3, size: 2, baseRefName: "main" } } }, + }, + }), + ), + ); + expect([...memberships]).toEqual([[0, { number: 3, size: 2, base: "main", position: 1 }]]); + }); + + it("refuses malformed responses and unsafe query selectors", () => { + expect(Result.isFailure(decodePullRequestStackMembershipsJson('{"errors":[]}'))).toBe(true); + expect(buildPullRequestStackMembershipsGraphQlQuery('acme/web") { x } #', [1])).toBeNull(); + expect(buildPullRequestStackMembershipsGraphQlQuery("acme/web", [0])).toBeNull(); + expect(buildPullRequestStackMembershipsGraphQlQuery("acme/web", [1.5])).toBeNull(); + expect(buildPullRequestStackMembershipsGraphQlQuery("acme/web", [])).toBeNull(); + expect(buildPullRequestStackMembershipsGraphQlQuery("acme/web", [7, 8])).toContain( + "pullRequest(number: 8)", + ); + }); +}); diff --git a/apps/server/src/pullRequest/gitHubPullRequestJson.ts b/apps/server/src/pullRequest/gitHubPullRequestJson.ts index 4c1280464672ea3272f56a4913e2d844c99bc126..42fa0ba0e04f31b88cce2d18bc65b0be57f57730 100644 --- a/apps/server/src/pullRequest/gitHubPullRequestJson.ts +++ b/apps/server/src/pullRequest/gitHubPullRequestJson.ts @@ -3,6 +3,7 @@ import * as Exit from "effect/Exit"; import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; import type { + PullRequestStackMembership, PullRequestActor, PullRequestCheck, PullRequestCheckStatus, @@ -111,12 +112,22 @@ const RawListItemSchema = Schema.Struct({ statusCheckRollup: Schema.optional(Schema.NullOr(Schema.Array(RawCheckSchema))), }); +const RawStackMembershipSchema = Schema.Struct({ + stack: Schema.optional( + Schema.NullOr( + Schema.Struct({ number: Schema.Int, size: Schema.Int, baseRefName: Schema.String }), + ), + ), + stackEntry: Schema.optional(Schema.NullOr(Schema.Struct({ position: Schema.Int }))), +}); + /** * A search's own answer, which is the listing's row one connection deeper: `gh pr list --json` * flattens reviewers and labels, and GraphQL does not. Everything below the row is optional * because a node that is not a pull request decodes as an empty object, which is skipped. */ const RawSearchItemSchema = Schema.Struct({ + ...RawStackMembershipSchema.fields, number: Schema.Int, title: Schema.String, url: Schema.String, @@ -221,6 +232,13 @@ const RawStatsSchema = Schema.Struct({ ), }); +const RawStackMembershipsSchema = Schema.Struct({ + data: Schema.Record( + Schema.String, + Schema.NullOr(Schema.Struct({ pullRequest: Schema.NullOr(RawStackMembershipSchema) })), + ), +}); + /** How many of a reaction's people the hover names before it counts the rest. */ const REACTORS_PER_GROUP = 10; @@ -648,12 +666,13 @@ export const PULL_REQUEST_SEARCH_MAX_ROWS = GRAPHQL_PAGE_SIZE; * than twenty labels shows twenty, and one that has asked more than twenty people for a review * is already past what a row can say. */ -export function pullRequestSearchGraphQlQuery(rows: number): string { +export function pullRequestSearchGraphQlQuery(rows: number, includeStacks = false): string { return `query($q: String!) { search(query: $q, type: ISSUE, first: ${Math.min(Math.max(Math.trunc(rows), 1), PULL_REQUEST_SEARCH_MAX_ROWS)}) { pageInfo { hasNextPage } nodes { ... on PullRequest { + ${includeStacks ? "stack { number size baseRefName } stackEntry { position }" : ""} number title url @@ -1019,6 +1038,7 @@ export const REPOSITORY_ACCESS_JSON_FIELDS = "mergeCommitAllowed,squashMergeAllowed,rebaseMergeAllowed,viewerPermission"; export interface GitHubPullRequestListItem { + readonly stack?: PullRequestStackMembership; /** The author's node id, kept so a batch can resolve the avatar the listing does not carry. */ readonly authorId: string | null; readonly number: number; @@ -1526,6 +1546,7 @@ export function decodePullRequestSearchJson( const node = decodedNode.value; const repository = trimmed(node.repository?.nameWithOwner); if (repository === null) continue; + const stack = toStackMembership(node); items.push({ ...toListItem({ ...node, @@ -1541,6 +1562,7 @@ export function decodePullRequestSearchJson( return state === null ? [] : [{ state }]; }), }), + ...(stack === undefined ? {} : { stack }), repository, }); } @@ -1551,6 +1573,19 @@ export function decodePullRequestSearchJson( }); } +function toStackMembership( + raw: Schema.Schema.Type, +): PullRequestStackMembership | undefined { + return raw.stack && raw.stackEntry + ? { + number: raw.stack.number, + size: raw.stack.size, + base: raw.stack.baseRefName, + position: raw.stackEntry.position, + } + : undefined; +} + /** What a repository selector may hold before it is written into a GraphQL document unquoted. */ const REPOSITORY_PART = /^[A-Za-z0-9._-]+$/; @@ -1581,6 +1616,42 @@ export function buildPullRequestStatsGraphQlQuery( return `query {\n${selections.join("\n")}\n}`; } +/** Stack membership for the visible rows of a per-repository listing. */ +export function buildPullRequestStackMembershipsGraphQlQuery( + repository: string, + numbers: ReadonlyArray, +): string | null { + if (numbers.length === 0) return null; + const [owner, name, ...rest] = repository.trim().split("/"); + if (rest.length > 0 || owner === undefined || name === undefined) return null; + if (!REPOSITORY_PART.test(owner) || !REPOSITORY_PART.test(name)) return null; + const selections: string[] = []; + for (const [index, number] of numbers.entries()) { + if (!Number.isSafeInteger(number) || number <= 0) return null; + selections.push( + ` s${index}: repository(owner: "${owner}", name: "${name}") { pullRequest(number: ${number}) { stack { number size baseRefName } stackEntry { position } } }`, + ); + } + return `query PullRequestStackMemberships {\n${selections.join("\n")}\n}`; +} + +const decodeStackMemberships = decodeJsonResult(RawStackMembershipsSchema); + +export function decodePullRequestStackMembershipsJson( + raw: string, +): Result.Result, DecodeFailure> { + const decoded = decodeStackMemberships(raw); + if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure); + const memberships = new Map(); + for (const [alias, value] of Object.entries(decoded.success.data)) { + const index = /^s(\d+)$/.exec(alias)?.[1]; + if (index === undefined || value?.pullRequest == null) continue; + const stack = toStackMembership(value.pullRequest); + if (stack !== undefined) memberships.set(Number(index), stack); + } + return Result.succeed(memberships); +} + /** * The counts by the position they were asked in. A repository or a pull request GitHub answered * nothing for is simply absent, which leaves the row with whatever it already had. @@ -2448,8 +2519,10 @@ export function decodePullRequestFilesJson( /** One pull request as the stacks API lists it: a number, a head, and whether it is done. */ const RawStackPullRequestSchema = Schema.Struct({ + title: Schema.optional(Schema.String), + draft: Schema.optional(Schema.Boolean), number: Schema.Int, - head: Schema.Struct({ ref: Schema.String }), + head: Schema.Struct({ ref: Schema.String, sha: Schema.optional(Schema.String) }), state: Schema.optional(Schema.NullOr(Schema.String)), merged_at: Schema.optional(Schema.NullOr(Schema.String)), }); @@ -2473,6 +2546,9 @@ const RawStackSchema = Schema.Struct({ const decodeStacks = decodeJsonResult(Schema.Array(RawStackSchema)); export interface GitHubPullRequestStackLayer { + readonly title?: string; + readonly isDraft?: boolean; + readonly headSha?: string; readonly number: number; readonly headBranch: string; readonly state: PullRequestState; @@ -2505,6 +2581,9 @@ export function decodePullRequestStacksJson( url: trimmed(stack.html_url) ?? stack.url, base: typeof stack.base === "string" ? stack.base : stack.base.ref, layers: stack.pull_requests.map((pullRequest) => ({ + ...(pullRequest.title === undefined ? {} : { title: pullRequest.title }), + ...(pullRequest.draft === undefined ? {} : { isDraft: pullRequest.draft }), + ...(pullRequest.head.sha === undefined ? {} : { headSha: pullRequest.head.sha }), number: pullRequest.number, headBranch: pullRequest.head.ref, state: toState({ state: pullRequest.state, mergedAt: pullRequest.merged_at }), diff --git a/apps/server/src/pullRequest/githubStackActions.test.ts b/apps/server/src/pullRequest/githubStackActions.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..4ed254514ee029f94e9610b2ddff64d41cb1c126 --- /dev/null +++ b/apps/server/src/pullRequest/githubStackActions.test.ts @@ -0,0 +1,413 @@ +import { expect, it } from "@effect/vitest"; +import * as Layer from "effect/Layer"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as TestClock from "effect/testing/TestClock"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import * as GitHubCli from "../sourceControl/GitHubCli.ts"; +import { runGitHubStackAction as runStackAction } from "./githubStackActions.ts"; + +const runGitHubStackAction = ( + execute: GitHubCli.GitHubCli["Service"]["execute"], + input: Parameters[0], +) => runStackAction(input).pipe(Effect.provide(Layer.mock(GitHubCli.GitHubCli)({ execute }))); + +const stack = [ + { + number: 50, + url: "https://api.github.com/repos/acme/web/stacks/50", + base: { ref: "main" }, + pull_requests: [ + { + number: 1, + title: "Base", + head: { ref: "base", sha: "aaa" }, + state: "closed", + merged_at: "2026-01-01T00:00:00Z", + }, + { + number: 2, + title: "Middle", + head: { ref: "middle", sha: "bbb" }, + state: "open", + draft: false, + }, + { number: 3, title: "Top", head: { ref: "top", sha: "ccc" }, state: "open", draft: false }, + ], + }, +]; +const input = { + cwd: "/repo", + repository: "acme/web", + host: "github.com", + number: 3, + stackNumber: 50, + expectedStackHeads: [ + { number: 2, headSha: "bbb" }, + { number: 3, headSha: "ccc" }, + ], + action: "merge" as const, +}; +const access = { + data: { + repository: { + pr2: { headRepository: { viewerPermission: "WRITE" }, maintainerCanModify: false }, + pr3: { headRepository: { viewerPermission: "WRITE" }, maintainerCanModify: false }, + }, + }, +}; + +const branch = (number: number, headRefOid: string, behindBy = 1, processed: string[] = []) => ({ + data: { + processed: processed.map((headRefOid) => ({ headRefOid })), + repository: { + pullRequest: { id: `PR_${number}`, headRefOid, baseRef: { compare: { behindBy } } }, + }, + }, +}); +const rebased = { + data: { updatePullRequestBranch: { pullRequest: { headRefOid: "rebased-sha" } } }, +}; +const rebaseResponses = [branch(2, "bbb"), rebased, branch(3, "ccc", 1, ["rebased-sha"]), rebased]; + +function fake(responses: readonly unknown[]) { + const calls: ReadonlyArray[] = []; + const execute: GitHubCli.GitHubCli["Service"]["execute"] = (request) => + Effect.sync(() => { + calls.push(request.args); + const value = responses[calls.length - 1]; + if (value === undefined) throw new Error("Unexpected GitHub request"); + return { + exitCode: ChildProcessSpawner.ExitCode(0), + // @effect-diagnostics-next-line preferSchemaOverJson:off + stdout: JSON.stringify(value), + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, + stdoutInvalidUtf8: false, + }; + }); + return { execute, calls }; +} + +it.effect("submits one atomic merge with the reviewed head and respects the merge queue", () => + Effect.gen(function* () { + const api = fake([stack, { status: "enqueued", details: {} }]); + yield* runGitHubStackAction(api.execute, { ...input, mergeMethod: "squash" }); + expect(api.calls).toHaveLength(2); + expect(api.calls[1]).toContain("repos/acme/web/pulls/3/merge-async"); + expect(api.calls[1]).toContain("sha=ccc"); + expect(api.calls[1]).toContain("merge_action=default"); + expect(api.calls[1]).toContain("merge_method=squash"); + }), +); + +it.effect("merges through the selected layer without including later draft layers", () => + Effect.gen(function* () { + const fiveLayers = [ + { + ...stack[0], + pull_requests: Array.from({ length: 5 }, (_, index) => ({ + number: index + 1, + head: { ref: `layer-${index + 1}`, sha: `sha-${index + 1}` }, + state: "open", + draft: index >= 3, + })), + }, + ]; + const api = fake([fiveLayers, { status: "merged", details: {} }]); + yield* runGitHubStackAction(api.execute, { + ...input, + number: 3, + expectedStackHeads: [1, 2, 3].map((number) => ({ number, headSha: `sha-${number}` })), + }); + expect(api.calls).toHaveLength(2); + expect(api.calls[1]).toContain("repos/acme/web/pulls/3/merge-async"); + expect(api.calls[1]).toContain("sha=sha-3"); + expect(api.calls[1]).toContain("merge_action=default"); + }), +); + +it.effect("rejects stale reviewed heads below a selected middle layer", () => + Effect.gen(function* () { + const api = fake([stack]); + const result = yield* runGitHubStackAction(api.execute, { + ...input, + number: 2, + expectedStackHeads: [{ number: 2, headSha: "old-head" }], + }).pipe(Effect.result); + expect(result).toMatchObject({ _tag: "Failure", failure: { _tag: "GitHubStackChangedError" } }); + expect(api.calls).toHaveLength(1); + }), +); + +it.effect("does not merge from an already merged layer", () => + Effect.gen(function* () { + const api = fake([stack]); + const result = yield* runGitHubStackAction(api.execute, { + ...input, + number: 1, + expectedStackHeads: [], + }).pipe(Effect.result); + expect(result).toMatchObject({ + _tag: "Failure", + failure: { _tag: "GitHubStackUnsupportedError" }, + }); + expect(api.calls).toHaveLength(1); + }), +); + +it.effect("polls an accepted merge and reports a later rule rejection", () => + Effect.gen(function* () { + const api = fake([ + stack, + { status: "pending", details: { uuid: "operation" } }, + { status: "failed", details: { message: "Required checks have not passed" } }, + ]); + const fiber = yield* runGitHubStackAction(api.execute, input).pipe( + Effect.result, + Effect.forkChild, + ); + yield* TestClock.adjust("1 second"); + const result = yield* Fiber.join(fiber); + expect(result).toMatchObject({ + _tag: "Failure", + failure: { _tag: "GitHubStackMergeRejectedError" }, + }); + expect(api.calls[2]).toContain("repos/acme/web/pulls/3/merge-async/operation"); + }), +); + +it.effect("retains stack identity and a rejection response without a message", () => + Effect.gen(function* () { + const rejection = { status: "failed", details: {} }; + const api = fake([stack, rejection]); + const result = yield* runGitHubStackAction(api.execute, input).pipe(Effect.result); + expect(result).toMatchObject({ + _tag: "Failure", + failure: { + _tag: "GitHubStackMergeRejectedError", + repository: input.repository, + number: input.number, + stackNumber: input.stackNumber, + cause: rejection, + }, + }); + }), +); + +it.effect("refuses a changed stack before performing any mutation", () => + Effect.gen(function* () { + const api = fake([stack]); + const result = yield* runGitHubStackAction(api.execute, { + ...input, + expectedStackHeads: [ + { number: 2, headSha: "old" }, + { number: 3, headSha: "ccc" }, + ], + }).pipe(Effect.result); + expect(result).toMatchObject({ _tag: "Failure", failure: { _tag: "GitHubStackChangedError" } }); + expect(api.calls).toHaveLength(1); + }), +); + +it.effect("rebases unmerged layers bottom to top without local git commands", () => + Effect.gen(function* () { + const api = fake([stack, access, ...rebaseResponses]); + yield* runGitHubStackAction(api.execute, { ...input, action: "update-branch" }); + const mutations = api.calls.filter((args) => + args.some((arg) => arg.startsWith("query=mutation")), + ); + expect(mutations).toHaveLength(2); + expect(mutations[0]).toContain("id=PR_2"); + expect(mutations[0]).toContain("sha=bbb"); + expect(mutations[1]).toContain("id=PR_3"); + expect(mutations[1]).toContain("sha=ccc"); + expect(api.calls.every((args) => args[0] === "api")).toBe(true); + }), +); + +it.effect("does not update later layers after a rebase failure", () => + Effect.gen(function* () { + const api = fake([stack, access, branch(2, "bbb")]); + const execute: typeof api.execute = (request) => + !request.args.some((arg) => arg.startsWith("query=mutation")) + ? api.execute(request) + : Effect.fail( + new GitHubCli.GitHubCliAuthenticationError({ + command: "gh", + cwd: "/repo", + cause: new Error("denied"), + }), + ); + const result = yield* runGitHubStackAction(execute, { ...input, action: "update-branch" }).pipe( + Effect.result, + ); + expect(result).toMatchObject({ + _tag: "Failure", + failure: { _tag: "GitHubStackRebaseFailedError", number: 2, completed: 0 }, + }); + }), +); + +it.effect("refuses the entire rebase before mutation when a later fork denies write access", () => + Effect.gen(function* () { + const api = fake([ + stack, + { + data: { + repository: { + ...access.data.repository, + pr3: { headRepository: { viewerPermission: "READ" }, maintainerCanModify: false }, + }, + }, + }, + ]); + const result = yield* runGitHubStackAction(api.execute, { + ...input, + action: "update-branch", + }).pipe(Effect.result); + expect(result).toMatchObject({ + _tag: "Failure", + failure: { _tag: "GitHubStackPermissionError" }, + }); + expect(api.calls).toHaveLength(2); + expect(api.calls.every((args) => args[0] === "api")).toBe(true); + }), +); + +it.effect("allows a fork that explicitly permits maintainer updates", () => + Effect.gen(function* () { + const api = fake([ + stack, + { + data: { + repository: { + ...access.data.repository, + pr3: { headRepository: { viewerPermission: "READ" }, maintainerCanModify: true }, + }, + }, + }, + ...rebaseResponses, + ]); + yield* runGitHubStackAction(api.execute, { ...input, action: "update-branch" }); + expect(api.calls.at(-1)).toContain("id=PR_3"); + }), +); + +it.effect("bounds polling and reports a still-running merge without claiming success", () => + Effect.gen(function* () { + const api = fake([ + stack, + ...Array.from({ length: 40 }, () => ({ status: "pending", details: { uuid: "operation" } })), + ]); + const fiber = yield* runGitHubStackAction(api.execute, input).pipe( + Effect.result, + Effect.forkChild, + ); + yield* TestClock.adjust("6 minutes"); + expect(yield* Fiber.join(fiber)).toMatchObject({ + _tag: "Failure", + failure: { _tag: "GitHubStackMergePendingError" }, + }); + expect(api.calls.length).toBeLessThan(40); + }), +); + +it.effect("rejects a push after preflight without rebasing the new revision", () => + Effect.gen(function* () { + const api = fake([stack, access, branch(2, "new-head")]); + const result = yield* runGitHubStackAction(api.execute, { + ...input, + action: "update-branch", + }).pipe(Effect.result); + expect(result).toMatchObject({ + _tag: "Failure", + failure: { _tag: "GitHubStackChangedError", number: 2, completed: 0 }, + }); + expect(api.calls).toHaveLength(3); + }), +); + +it.effect("skips current layers without submitting a rebase mutation", () => + Effect.gen(function* () { + const api = fake([stack, access, branch(2, "bbb", 0), branch(3, "ccc", 0, ["bbb"])]); + yield* runGitHubStackAction(api.execute, { ...input, action: "update-branch" }); + expect(api.calls.some((args) => args.some((arg) => arg.startsWith("query=mutation")))).toBe( + false, + ); + }), +); + +it.effect("keeps earlier progress and stops after a later layer fails", () => + Effect.gen(function* () { + const api = fake([ + stack, + access, + branch(2, "bbb"), + rebased, + branch(3, "ccc", 1, ["rebased-sha"]), + { data: { updatePullRequestBranch: null } }, + ]); + const result = yield* runGitHubStackAction(api.execute, { + ...input, + action: "update-branch", + }).pipe(Effect.result); + expect(result).toMatchObject({ + _tag: "Failure", + failure: { _tag: "GitHubStackRebaseFailedError", number: 3, completed: 1 }, + }); + }), +); + +it.effect("reports partial progress when a later head changes during the rebase", () => + Effect.gen(function* () { + const api = fake([ + stack, + access, + branch(2, "bbb"), + rebased, + branch(3, "concurrent-head", 1, ["rebased-sha"]), + ]); + const result = yield* runGitHubStackAction(api.execute, { + ...input, + action: "update-branch", + }).pipe(Effect.result); + expect(result).toMatchObject({ + _tag: "Failure", + failure: { _tag: "GitHubStackChangedError", number: 3, completed: 1 }, + }); + if (result._tag === "Failure") { + expect(result.failure.message).toContain("Earlier updates remain on GitHub"); + } + expect( + api.calls.filter((args) => args.some((arg) => arg.startsWith("query=mutation"))), + ).toHaveLength(1); + }), +); + +it.effect.each([false, true])("rejects a push to a processed layer, rebased=%s", (rebasedParent) => + Effect.gen(function* () { + const api = fake([ + stack, + access, + branch(2, "bbb", rebasedParent ? 1 : 0), + ...(rebasedParent ? [rebased] : []), + branch(3, "ccc", 1, ["concurrent-parent-head"]), + ]); + const result = yield* runGitHubStackAction(api.execute, { + ...input, + action: "update-branch", + }).pipe(Effect.result); + expect(result).toMatchObject({ + _tag: "Failure", + failure: { _tag: "GitHubStackChangedError", number: 2, completed: 1 }, + }); + expect(api.calls.at(-1)?.some((arg) => arg.includes('processed:nodes(ids:["PR_2"])'))).toBe( + true, + ); + expect( + api.calls.filter((args) => args.some((arg) => arg.startsWith("query=mutation"))), + ).toHaveLength(rebasedParent ? 1 : 0); + }), +); diff --git a/apps/server/src/pullRequest/githubStackActions.ts b/apps/server/src/pullRequest/githubStackActions.ts new file mode 100644 index 0000000000000000000000000000000000000000..6e72836e6e4015c88dac87686de80073fb499e88 --- /dev/null +++ b/apps/server/src/pullRequest/githubStackActions.ts @@ -0,0 +1,418 @@ +import type { + PullRequestAction, + PullRequestMergeMethod, + PullRequestStackHead, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Clock from "effect/Clock"; +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; + +import * as GitHubCli from "../sourceControl/GitHubCli.ts"; +import { decodePullRequestStacksJson } from "./gitHubPullRequestJson.ts"; + +const stackErrorIdentity = { + repository: Schema.String, + number: Schema.Int, + stackNumber: Schema.Int, +}; + +export class GitHubStackChangedError extends Schema.TaggedError()( + "GitHubStackChangedError", + { ...stackErrorIdentity, completed: Schema.Int }, +) { + get detail(): string { + return this.message; + } + + override get message(): string { + return this.completed > 0 + ? `The stack changed at PR #${this.number} after ${this.completed} layers. Earlier updates remain on GitHub. Refresh it before trying again.` + : "The stack changed. Refresh it before trying again."; + } +} + +export class GitHubStackUnsupportedError extends Schema.TaggedError()( + "GitHubStackUnsupportedError", + stackErrorIdentity, +) { + get detail(): string { + return this.message; + } + + override get message(): string { + return "This operation is not supported for this stack."; + } +} + +export class GitHubStackResponseInvalidError extends Schema.TaggedError()( + "GitHubStackResponseInvalidError", + { ...stackErrorIdentity, cause: Schema.optional(Schema.Defect()) }, +) { + get detail(): string { + return this.message; + } + + override get message(): string { + return "GitHub returned an unreadable stack operation response."; + } +} + +export class GitHubStackMergeRejectedError extends Schema.TaggedError()( + "GitHubStackMergeRejectedError", + { ...stackErrorIdentity, cause: Schema.Defect() }, +) { + get detail(): string { + return this.message; + } + + override get message(): string { + return "GitHub refused the stack merge. Check the stack's branch rules and merge requirements."; + } +} + +export class GitHubStackMergePendingError extends Schema.TaggedError()( + "GitHubStackMergePendingError", + stackErrorIdentity, +) { + get detail(): string { + return this.message; + } + + override get message(): string { + return "The merge is still running on GitHub. Check its status there before submitting another request."; + } +} + +export class GitHubStackPermissionError extends Schema.TaggedError()( + "GitHubStackPermissionError", + stackErrorIdentity, +) { + get detail(): string { + return this.message; + } + + override get message(): string { + return "You cannot update every branch in this stack. Check write access and fork maintainer permissions before retrying."; + } +} + +export class GitHubStackRebaseFailedError extends Schema.TaggedError()( + "GitHubStackRebaseFailedError", + { ...stackErrorIdentity, completed: Schema.Int, cause: Schema.Defect() }, +) { + get detail(): string { + return this.message; + } + + override get message(): string { + return `Stack rebase stopped at PR #${this.number} after ${this.completed} layers. Earlier updates remain on GitHub; resolve the failing layer before retrying.`; + } +} + +export type GitHubStackActionError = + | GitHubStackChangedError + | GitHubStackUnsupportedError + | GitHubStackResponseInvalidError + | GitHubStackMergeRejectedError + | GitHubStackMergePendingError + | GitHubStackPermissionError + | GitHubStackRebaseFailedError; + +const MergeResponse = Schema.Struct({ + status: Schema.Literals(["pending", "merged", "enqueued", "failed"]), + details: Schema.Struct({ + uuid: Schema.optional(Schema.String), + message: Schema.optional(Schema.String), + }), +}); + +const decodeBranchAccess = Schema.decodeEffect( + Schema.fromJsonString( + Schema.Struct({ + data: Schema.Struct({ + repository: Schema.NullOr( + Schema.Record( + Schema.String, + Schema.NullOr( + Schema.Struct({ + headRepository: Schema.NullOr( + Schema.Struct({ viewerPermission: Schema.NullOr(Schema.String) }), + ), + maintainerCanModify: Schema.Boolean, + }), + ), + ), + ), + }), + }), + ), +); + +const decodeRebaseBranch = Schema.decodeEffect( + Schema.fromJsonString( + Schema.Struct({ + data: Schema.Struct({ + processed: Schema.optional( + Schema.Array(Schema.NullOr(Schema.Struct({ headRefOid: Schema.String }))), + ), + repository: Schema.Struct({ + pullRequest: Schema.Struct({ + id: Schema.String, + headRefOid: Schema.String, + baseRef: Schema.Struct({ compare: Schema.Struct({ behindBy: Schema.Int }) }), + }), + }), + }), + }), + ), +); +const decodeRebaseResponse = Schema.decodeEffect( + Schema.fromJsonString( + Schema.Struct({ + data: Schema.Struct({ + updatePullRequestBranch: Schema.Struct({ + pullRequest: Schema.Struct({ headRefOid: Schema.String }), + }), + }), + }), + ), +); + +const encodeNodeIds = Schema.encodeSync(Schema.fromJsonString(Schema.Array(Schema.String))); + +const decodeMergeResponse = Schema.decodeEffect(Schema.fromJsonString(MergeResponse)); + +/** Remote-only updates: a stack rebase never switches or rewrites the environment's checkout. */ +export const runGitHubStackAction = Effect.fn("runGitHubStackAction")(function* (input: { + cwd: string; + repository: string; + host: string; + number: number; + stackNumber: number; + expectedStackHeads?: ReadonlyArray; + action: PullRequestAction; + mergeMethod?: PullRequestMergeMethod; +}) { + const github = yield* GitHubCli.GitHubCli; + const identity = { + repository: input.repository, + number: input.number, + stackNumber: input.stackNumber, + }; + if (input.action !== "merge" && input.action !== "update-branch") + return yield* new GitHubStackUnsupportedError({ ...identity }); + const endpoint = `repos/${input.repository}`; + const read = yield* github.execute({ + cwd: input.cwd, + args: ["api", "--hostname", input.host, `${endpoint}/stacks?pull_request=${input.number}`], + }); + const decoded = decodePullRequestStacksJson(read.stdout); + if (Result.isFailure(decoded)) + return yield* new GitHubStackResponseInvalidError({ ...identity, cause: decoded.failure }); + const stack = decoded.success; + const targetIndex = stack?.layers.findIndex((layer) => layer.number === input.number) ?? -1; + const target = stack?.layers[targetIndex]; + if ( + stack?.number !== input.stackNumber || + target === undefined || + (input.action === "update-branch" && targetIndex !== stack.layers.length - 1) + ) { + return yield* new GitHubStackChangedError({ ...identity, number: input.number, completed: 0 }); + } + const affectedLayers = + input.action === "merge" ? stack.layers.slice(0, targetIndex + 1) : stack.layers; + const open = affectedLayers.filter((layer) => layer.state !== "merged"); + if (input.action === "merge" && target.state !== "open") + return yield* new GitHubStackUnsupportedError({ ...identity }); + if ( + !input.expectedStackHeads || + input.expectedStackHeads.length !== open.length || + new Set(input.expectedStackHeads.map((layer) => layer.number)).size !== open.length || + open.some( + (layer) => + !layer.headSha || + !input.expectedStackHeads?.some( + (expected) => expected.number === layer.number && expected.headSha === layer.headSha, + ), + ) + ) { + return yield* new GitHubStackChangedError({ ...identity, number: input.number, completed: 0 }); + } + if (open.length === 0 || open.some((layer) => layer.state !== "open")) + return yield* new GitHubStackUnsupportedError({ ...identity }); + if (input.action === "update-branch") { + const [owner, name] = input.repository.split("/"); + const permissions = yield* github.execute({ + cwd: input.cwd, + args: [ + "api", + "--hostname", + input.host, + "graphql", + "-f", + `owner=${owner}`, + "-f", + `name=${name}`, + "-f", + `query=query($owner:String!,$name:String!){repository(owner:$owner,name:$name){${open + .map( + (layer) => + `pr${layer.number}:pullRequest(number:${layer.number}){headRepository{viewerPermission} maintainerCanModify}`, + ) + .join(" ")}}}`, + ], + }); + const access = yield* decodeBranchAccess(permissions.stdout).pipe( + Effect.mapError((cause) => new GitHubStackResponseInvalidError({ ...identity, cause })), + ); + // viewerCanUpdateBranch is false for an already-current layer, even if rebasing its parent + // will make it stale. Check branch write access separately before touching any layer. + if ( + open.some((layer) => { + const pr = access.data.repository?.[`pr${layer.number}`]; + return ( + !pr?.headRepository || + (!pr.maintainerCanModify && + !["ADMIN", "MAINTAIN", "WRITE"].includes(pr.headRepository.viewerPermission ?? "")) + ); + }) + ) + return yield* new GitHubStackPermissionError({ ...identity }); + const processed: Array<{ id: string; number: number; headSha: string }> = []; + for (const [index, layer] of open.entries()) { + yield* Effect.gen(function* () { + const read = yield* github.execute({ + cwd: input.cwd, + args: [ + "api", + "--hostname", + input.host, + "graphql", + "-f", + `owner=${owner}`, + "-f", + `name=${name}`, + "-F", + `number=${layer.number}`, + "-f", + `sha=${layer.headSha}`, + "-f", + `query=query($owner:String!,$name:String!,$number:Int!,$sha:String!){${ + processed.length === 0 + ? "" + : `processed:nodes(ids:${encodeNodeIds(processed.map((head) => head.id))}){... on PullRequest{headRefOid}}` + } repository(owner:$owner,name:$name){pullRequest(number:$number){id headRefOid baseRef{compare(headRef:$sha){behindBy}}}}}`, + ], + }); + const { + data: { + processed: observed, + repository: { pullRequest: pr }, + }, + } = yield* decodeRebaseBranch(read.stdout); + // A push to an earlier layer must not silently become the next layer's new base. + const changed = processed.find( + (head, index) => observed?.[index]?.headRefOid !== head.headSha, + ); + if (changed !== undefined) + return yield* new GitHubStackChangedError({ + ...identity, + number: changed.number, + completed: index, + }); + if (pr.headRefOid !== layer.headSha) + return yield* new GitHubStackChangedError({ + ...identity, + number: layer.number, + completed: index, + }); + if (pr.baseRef.compare.behindBy === 0) { + processed.push({ id: pr.id, number: layer.number, headSha: pr.headRefOid }); + return; + } + // Pass the reviewed revision to GitHub, including when a push races this read. + const updated = yield* github.execute({ + cwd: input.cwd, + args: [ + "api", + "--hostname", + input.host, + "graphql", + "-f", + `id=${pr.id}`, + "-f", + `sha=${layer.headSha}`, + "-f", + "query=mutation($id:ID!,$sha:GitObjectID!){updatePullRequestBranch(input:{pullRequestId:$id,expectedHeadOid:$sha,updateMethod:REBASE}){pullRequest{headRefOid}}}", + ], + }); + const response = yield* decodeRebaseResponse(updated.stdout); + processed.push({ + id: pr.id, + number: layer.number, + headSha: response.data.updatePullRequestBranch.pullRequest.headRefOid, + }); + }).pipe( + Effect.mapError((cause) => + cause._tag === "GitHubStackChangedError" + ? cause + : new GitHubStackRebaseFailedError({ + ...identity, + number: layer.number, + completed: index, + cause, + }), + ), + ); + } + return; + } + if (open.some((layer) => layer.isDraft)) + return yield* new GitHubStackUnsupportedError({ ...identity }); + const decode = (raw: string) => + decodeMergeResponse(raw).pipe( + Effect.mapError((cause) => new GitHubStackResponseInvalidError({ ...identity, cause })), + ); + const request = yield* github.execute({ + cwd: input.cwd, + args: [ + "api", + "--hostname", + input.host, + "--method", + "PUT", + `${endpoint}/pulls/${input.number}/merge-async`, + "-f", + `merge_method=${input.mergeMethod ?? "merge"}`, + "-f", + "merge_action=default", + "-f", + `sha=${target.headSha}`, + ], + }); + let result = yield* decode(request.stdout); + const deadline = (yield* Clock.currentTimeMillis) + 5 * 60_000; + for ( + let attempt = 0; + result.status === "pending" && (yield* Clock.currentTimeMillis) < deadline; + attempt++ + ) { + const uuid = result.details.uuid; + if (!uuid) return yield* new GitHubStackResponseInvalidError({ ...identity }); + yield* Effect.sleep(Math.min(1_000 * 2 ** attempt, 10_000)); + const poll = yield* github.execute({ + cwd: input.cwd, + args: [ + "api", + "--hostname", + input.host, + `${endpoint}/pulls/${input.number}/merge-async/${encodeURIComponent(uuid)}`, + ], + }); + result = yield* decode(poll.stdout); + } + if (result.status === "pending") return yield* new GitHubStackMergePendingError({ ...identity }); + if (result.status === "failed") + return yield* new GitHubStackMergeRejectedError({ ...identity, cause: result }); +}); diff --git a/apps/web/src/commandPaletteBus.ts b/apps/web/src/commandPaletteBus.ts index 2a953132992ca894bb07fdd459e3b40c9daa718f..7bf122c468177a12c7343e3809b174f01032bc7e 100644 --- a/apps/web/src/commandPaletteBus.ts +++ b/apps/web/src/commandPaletteBus.ts @@ -1,9 +1,18 @@ +import type { EnvironmentId, PullRequestLinkedThreadsResult } from "@t3tools/contracts"; + +export interface CommandPaletteLinkedThreads { + readonly environmentId: EnvironmentId; + readonly threads: PullRequestLinkedThreadsResult["threads"]; +} + // Tiny event bus allowing components to programmatically open the command palette // without owning its React state. const COMMAND_PALETTE_OPEN_EVENT = "t3code:open-command-palette"; export interface CommandPaletteOpenDetail { readonly open?: "add-project" | "new-thread-in"; + readonly query?: string; + readonly linkedThreads?: CommandPaletteLinkedThreads; } export function openCommandPalette(detail?: CommandPaletteOpenDetail): void { diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index e9b5360cbc45502f6d2b0c6d8db5240e7d4c9073..4bb5f0aefd891d3e807bf35b7ac197fc245961ea 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -1,3 +1,6 @@ +import { useSupportsMultiplePullRequests } from "~/hooks/useSupportsMultiplePullRequests"; +import { resolveThreadCurrentPullRequestLink } from "@t3tools/shared/threadPullRequests"; +import { useRightPanelStore } from "../rightPanelStore"; import { RefreshIcon } from "~/components/ui/refresh-icon"; import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment"; import { @@ -47,7 +50,12 @@ import { sanitizeNewRefName, shouldIncludeBranchPickerItem, } from "./BranchToolbar.logic"; -import { ChangeRequestStatusIcon, prStatusIndicator } from "./ThreadStatusIndicators"; +import { + ThreadPullRequestBadgeControl, + prStatusIndicator, + resolveThreadPullRequestBadge, + useLinkedThreadPullRequest, +} from "./ThreadStatusIndicators"; import { Button } from "./ui/button"; import { Switch } from "./ui/switch"; import { getVirtualizedScrollFadeClassName } from "./ui/scroll-area"; @@ -612,7 +620,7 @@ export function BranchToolbarBranchSelector({ startFromOrigin, }); - // PR pill shown next to the branch selector when the active branch has one. + // Branch status is the fallback when this thread has no linked pull requests. const branchPrBranch = resolveBranchToolbarPrBranch({ activeThreadBranch, resolvedActiveBranch, @@ -621,12 +629,27 @@ export function BranchToolbarBranchSelector({ branchPrBranch !== null && branchStatusQuery.data?.refName === branchPrBranch ? (branchStatusQuery.data.pr ?? null) : null; - const branchPrStatus = prStatusIndicator(branchPr, branchStatusQuery.data?.sourceControlProvider); - // Action-oriented tooltip (the pill opens the PR), distinct from the sidebar's - // state-description tooltip. - const branchPrTooltip = branchPr - ? `Open ${sourceControlPresentation.terminology.singular} #${branchPr.number} (${branchPr.state})` - : ""; + const supportsMultiplePullRequests = useSupportsMultiplePullRequests(environmentId); + const linkedStatus = useLinkedThreadPullRequest( + environmentId, + serverThread?.linkedPullRequest, + true, + serverThread?.pullRequests, + serverThread?.branchPullRequest, + ); + const currentLinkedPr = supportsMultiplePullRequests + ? resolveThreadCurrentPullRequestLink(serverThread?.pullRequests ?? []) + : null; + const prBadge = supportsMultiplePullRequests + ? resolveThreadPullRequestBadge(serverThread?.pullRequests) + : null; + const displayedPr = linkedStatus?.pr ?? (currentLinkedPr === null ? branchPr : null); + const displayedPrStatus = prStatusIndicator( + displayedPr, + linkedStatus?.sourceControlProvider ?? branchStatusQuery.data?.sourceControlProvider, + ); + const prNumber = currentLinkedPr?.number ?? displayedPr?.number; + const prUrl = currentLinkedPr?.url ?? displayedPr?.url; const openPrLink = useOpenPrLink(threadRef); function renderPickerItem(itemValue: string, index: number) { @@ -730,41 +753,17 @@ export function BranchToolbarBranchSelector({ className={cn("flex min-w-0 items-center gap-1", className)} data-composer-context-control > - {branchPr && branchPrStatus ? ( - - openPrLink(event, branchPrStatus.url)} - className={cn( - "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", - branchPrStatus.colorClass, - )} - /> - } - > - - - - #{branchPr.number} - - - - {branchPrTooltip} - - ) : null} + useRightPanelStore.getState().open(threadRef, "pull-requests")} + onOpenPullRequest={(event) => { + if (prUrl) openPrLink(event, prUrl); + }} + /> {/* Context menu lives on the wrapper: the disabled Button has pointer-events-none, so the trigger itself never sees right-clicks while refs are loading or a branch action is pending. */} diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 2f511c115a3aee70f4f7da0a1e6b7b6a01ee98b2..9586a6ef0aed05fa4bfb5a8e5d6c9fdf508bd47c 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -8054,6 +8054,15 @@ export default function ChatView(props: ChatViewProps) { { + if (activeThreadRef) + useRightPanelStore.getState().openPullRequest(activeThreadRef, { + projectId: reference.projectId, + repository: reference.repository, + number: reference.number, + ...(reference.host ? { host: reference.host } : {}), + }); + }} threadRef={activeThreadRef} reference={{ projectId: renderedRightPanelSurface.projectId as ProjectId, diff --git a/apps/web/src/components/CommandPalette.logic.test.ts b/apps/web/src/components/CommandPalette.logic.test.ts index e0f2dc23b52311daa40236853bfee7954e666dfe..b82147b376c8492287a6ebc860e96f70bc656076 100644 --- a/apps/web/src/components/CommandPalette.logic.test.ts +++ b/apps/web/src/components/CommandPalette.logic.test.ts @@ -5,6 +5,7 @@ import { buildBrowseGroups, buildProjectActionItems, buildThreadActionItems, + buildLinkedThreadActionItems, enumerateCommandPaletteItems, filterPinnedBrowseEntries, filterCommandPaletteGroups, @@ -12,6 +13,48 @@ import { type CommandPaletteGroup, } from "./CommandPalette.logic"; +describe("linked pull request thread navigation", () => { + it("keeps archived relations searchable and routes them through the PR environment", async () => { + const environmentId = EnvironmentId.make("remote"); + const id = ThreadId.make("archived-thread"); + const runThread = vi.fn(async () => {}); + const query = "https://github.com/acme/web/pull/42"; + const linkedThreads = { + environmentId, + threads: [ + { + id, + projectId: ProjectId.make("project"), + title: "Completed work", + archivedAt: "2026-09-01T00:00:00.000Z", + }, + ], + }; + const state = reduceCommandPaletteUiState( + { open: false, mode: "command", openIntent: null }, + { + _tag: "OpenSearch", + query, + linkedThreads, + }, + ); + expect(state.openIntent).toEqual({ kind: "search", query, linkedThreads }); + const items = buildLinkedThreadActionItems({ ...linkedThreads, query, icon: null, runThread }); + const groups = filterCommandPaletteGroups({ + activeGroups: [], + query, + isInSubmenu: false, + projectSearchItems: [], + settingsSearchItems: [], + threadSearchItems: items, + }); + expect(groups.flatMap((group) => group.items)).toEqual(items); + expect(items[0]?.description).toBe("Archived thread"); + await items[0]?.run(); + expect(runThread).toHaveBeenCalledWith({ environmentId, id }); + }); +}); + describe("reduceCommandPaletteUiState", () => { const closedState = { open: false, mode: "command", openIntent: null } as const; @@ -47,6 +90,32 @@ describe("reduceCommandPaletteUiState", () => { ); }); + it("opens PR search from another overlay and replaces an earlier search", () => { + const first = reduceCommandPaletteUiState( + { open: true, mode: "files", openIntent: null }, + { + _tag: "OpenSearch", + query: "https://github.com/acme/web/pull/7", + }, + ); + expect(first).toEqual({ + open: true, + mode: "command", + openIntent: { kind: "search", query: "https://github.com/acme/web/pull/7" }, + }); + const second = reduceCommandPaletteUiState(first, { + _tag: "OpenSearch", + query: "https://github.com/acme/web/pull/8", + }); + expect(second.openIntent).toEqual({ + kind: "search", + query: "https://github.com/acme/web/pull/8", + }); + expect( + reduceCommandPaletteUiState(second, { _tag: "SetOpen", open: false }).openIntent, + ).toBeNull(); + }); + it("routes open intents to command mode", () => { const filesOpen = reduceCommandPaletteUiState(closedState, { _tag: "ToggleMode", diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts index 8d26e603c14f93201ba4e7e463ac759e0fb1fb6d..95313ee940e68a7f5b1662fffab477d8133b9fa5 100644 --- a/apps/web/src/components/CommandPalette.logic.ts +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -1,4 +1,5 @@ import { threadPullRequestSearchTerms } from "@t3tools/shared/threadPullRequests"; +import type { CommandPaletteLinkedThreads } from "../commandPaletteBus"; import { type FilesystemBrowseEntry, type KeybindingCommand, @@ -18,6 +19,25 @@ export const RECENT_THREAD_LIMIT = 12; export const ITEM_ICON_CLASS = "size-4 text-icon-muted"; export const ADDON_ICON_CLASS = "size-4"; +/** A PR's relations include archived threads that normal palette search omits. */ +export function buildLinkedThreadActionItems( + input: CommandPaletteLinkedThreads & { + query: string; + icon: ReactNode; + runThread: (thread: Pick) => Promise; + }, +): CommandPaletteActionItem[] { + return input.threads.map((thread) => ({ + kind: "action", + value: `thread:${input.environmentId}:${thread.id}`, + title: thread.title || "Untitled thread", + description: thread.archivedAt === null ? "Linked thread" : "Archived thread", + searchTerms: [input.query, thread.title], + icon: input.icon, + run: () => input.runThread({ environmentId: input.environmentId, id: thread.id }), + })); +} + export function browseInputEndPaddingClass(input: { readonly willCreateProjectPath: boolean; readonly hasHighlightedBrowseItem: boolean; @@ -39,9 +59,13 @@ export function browseInputEndPaddingClass(input: { */ export type SearchOverlayMode = "command" | "files" | "content"; -export interface CommandPaletteOpenIntent { - readonly kind: "add-project" | "new-thread-in"; -} +export type CommandPaletteOpenIntent = + | { readonly kind: "add-project" | "new-thread-in" } + | { + readonly kind: "search"; + readonly query: string; + readonly linkedThreads?: CommandPaletteLinkedThreads; + }; export interface CommandPaletteUiState { readonly open: boolean; @@ -52,6 +76,11 @@ export interface CommandPaletteUiState { export type CommandPaletteUiAction = | { readonly _tag: "SetOpen"; readonly open: boolean } | { readonly _tag: "ToggleMode"; readonly mode: SearchOverlayMode } + | { + readonly _tag: "OpenSearch"; + readonly query: string; + readonly linkedThreads?: CommandPaletteLinkedThreads; + } | { readonly _tag: "OpenAddProject" } | { readonly _tag: "OpenNewThreadIn" } | { readonly _tag: "ClearOpenIntent" }; @@ -69,6 +98,16 @@ export function reduceCommandPaletteUiState( return state.open && state.mode === action.mode ? { ...state, open: false, openIntent: null } : { open: true, mode: action.mode, openIntent: null }; + case "OpenSearch": + return { + open: true, + mode: "command", + openIntent: { + kind: "search", + query: action.query, + ...(action.linkedThreads ? { linkedThreads: action.linkedThreads } : {}), + }, + }; case "OpenAddProject": return { open: true, mode: "command", openIntent: { kind: "add-project" } }; case "OpenNewThreadIn": diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index d22759659510074ec8615a35609ba3309fb4012e..c54f919125d37170981d362ca558ec3deea9517c 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -129,6 +129,7 @@ import { buildProjectActionItems, buildRootGroups, buildThreadActionItems, + buildLinkedThreadActionItems, enumerateCommandPaletteItems, type CommandPaletteActionItem, type CommandPaletteOpenIntent, @@ -482,6 +483,12 @@ export function CommandPalette({ children }: { children: ReactNode }) { openNewThreadIn(); } else if (detail.open === "add-project") { openAddProject(); + } else if (detail.query !== undefined) { + dispatch({ + _tag: "OpenSearch", + query: detail.query, + ...(detail.linkedThreads ? { linkedThreads: detail.linkedThreads } : {}), + }); } else { setOpen(true); } @@ -573,7 +580,10 @@ function OpenCommandPaletteDialog(props: { const navigate = useNavigate(); const pathname = useLocation({ select: (location) => location.pathname }); const { clearOpenIntent, openIntent, openOverlayMode, setOpen } = props; - const [query, setQuery] = useState(""); + const [query, setQuery] = useState(openIntent?.kind === "search" ? openIntent.query : ""); + const [linkedThreadSearch, setLinkedThreadSearch] = useState( + openIntent?.kind === "search" ? openIntent : null, + ); const deferredQuery = useDeferredValue(query); const isActionsOnly = deferredQuery.startsWith(">"); const [highlightedItemValue, setHighlightedItemValue] = useState(null); @@ -696,6 +706,7 @@ function OpenCommandPaletteDialog(props: { ); const [isPickingProjectFolder, setIsPickingProjectFolder] = useState(false); const [addProjectCloneFlow, setAddProjectCloneFlow] = useState(null); + const cloneLookupGeneration = useRef(0); const [isRemoteProjectLookingUp, setIsRemoteProjectLookingUp] = useState(false); const [isRemoteProjectCloning, setIsRemoteProjectCloning] = useState(false); const projectGroupingSettings = useMemo( @@ -1497,6 +1508,18 @@ function OpenCommandPaletteDialog(props: { startAddProjectSourceSelection, ]); + useLayoutEffect(() => { + if (openIntent?.kind !== "search") return; + browseNavigation.invalidate(); + cloneLookupGeneration.current += 1; + setIsRemoteProjectLookingUp(false); + setAddProjectCloneFlow(null); + setViewStack([]); + setLinkedThreadSearch(openIntent); + setQuery(openIntent.query); + clearOpenIntent(); + }, [browseNavigation, clearOpenIntent, openIntent]); + useLayoutEffect(() => { if (openIntent?.kind !== "add-project") { return; @@ -1805,7 +1828,20 @@ function OpenCommandPaletteDialog(props: { isInSubmenu: currentView !== null, projectSearchItems: projectSearchItems, settingsSearchItems, - threadSearchItems: allThreadItems, + threadSearchItems: + linkedThreadSearch?.linkedThreads && deferredQuery === linkedThreadSearch.query + ? buildLinkedThreadActionItems({ + ...linkedThreadSearch.linkedThreads, + query: linkedThreadSearch.query, + icon: , + runThread: async (thread) => { + await navigate({ + to: "/$environmentId/$threadId", + params: buildThreadRouteParams(scopeThreadRef(thread.environmentId, thread.id)), + }); + }, + }) + : allThreadItems, }); const handleAddProjectForEnvironment = useCallback( @@ -2011,6 +2047,7 @@ function OpenCommandPaletteDialog(props: { } setIsRemoteProjectLookingUp(true); + const lookupGeneration = ++cloneLookupGeneration.current; const lookupResult = await lookupRepository({ environmentId: addProjectCloneFlow.environmentId, input: { @@ -2018,6 +2055,7 @@ function OpenCommandPaletteDialog(props: { repository: rawRepository, }, }); + if (lookupGeneration !== cloneLookupGeneration.current) return; setIsRemoteProjectLookingUp(false); if (lookupResult._tag === "Failure") { if (!isAtomCommandInterrupted(lookupResult)) { diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index af6381a256b81c43f887e069f930b37927ab913a..78c59e296b90a85e5d98a21d5f29ba02526479e6 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -192,13 +192,11 @@ import { import { SidebarDragLifecycle, SidebarPointerSensor } from "./Sidebar.pointer"; import { createSidebarListMotion } from "./Sidebar.motion"; import { - PR_STATE_COLOR_CLASS, - ThreadPullRequestBadgeIcon, + ThreadPullRequestBadgeControl, ThreadPullRequestsMiniList, ThreadWorktreeIndicator, prStatusIndicator, resolveThreadPullRequestBadge, - settledPrHoverColorClass, terminalStatusFromRunningIds, type TerminalStatusIndicator, useLinkedThreadPullRequest, @@ -220,7 +218,7 @@ import { } from "../providerInstances"; import { useThreadRunningTerminalIds } from "../state/terminalSessions"; import { stackedThreadToast, toastManager } from "./ui/toast"; -import { Button, InlineButton } from "./ui/button"; +import { Button } from "./ui/button"; import { Input } from "./ui/input"; import { Combobox, @@ -1179,7 +1177,6 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { currentGitBranch: visibleGitStatus?.refName ?? null, }); const prStatus = prStatusIndicator(pr, linkedPullRequestStatus?.sourceControlProvider); - const settledPrHoverClass = pr ? settledPrHoverColorClass(pr.state, pr.isDraft) : undefined; const modelInstanceId = thread.session?.providerInstanceId ?? thread.modelSelection.instanceId; const providerEntry = props.providerEntryByInstanceId.get(modelInstanceId) ?? null; @@ -1488,85 +1485,26 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { ); - // One badge shape for every thread: the glyph says stack or not, the number is the current - // pull request, and "+N" counts the others behind it. A real link so cmd/ctrl+click and - // middle-click open the host in the browser; a plain click opens T3's pull request view. + // Stacks show their layer count; unrelated links show the current PR and a remainder count. + // Plain clicks open T3; individual PR links also support opening the host in a new tab. const prBadgeShape = supportsMultiplePullRequests ? resolveThreadPullRequestBadge(thread.pullRequests) : null; - const prBadgeClassName = (state: "open" | "merged" | "closed", colorClass: string) => - cn( - // Sidebar chrome follows the interface font; tabular digits keep the number from - // reflowing as PR states stream in. A border rather than text-decoration, so the line - // runs under the glyph as well as the number. - "text-xs tabular-nums", - variant === "slim" && variantAction === "unsettle" - ? props.isActive - ? "text-secondary-label" - : cn("text-secondary-label transition-colors", settledPrHoverColorClass(state)) - : colorClass, - ); const handlePrStackClick = useCallback(() => { useRightPanelStore.getState().open(threadRef, "pull-requests"); if (!props.isActive) onThreadActivate(threadRef); }, [onThreadActivate, props.isActive, threadRef]); const prBadge = - prBadgeShape?.kind === "stack" ? ( - // A stack is one thing with N layers; naming one of them would misrepresent it, so the - // badge counts layers and opens the thread's pull-requests surface. - event.stopPropagation()} - onClick={handlePrStackClick} - className={prBadgeClassName(prBadgeShape.state, PR_STATE_COLOR_CLASS[prBadgeShape.state])} - aria-label={`Stack of ${prBadgeShape.layers} pull requests, ${prBadgeShape.state}`} - > - - {prBadgeShape.layers} - - ) : prStatus && pr ? ( - event.stopPropagation()} - onClick={handlePrClick} - className={cn( - // Sidebar chrome follows the interface font; tabular digits keep the - // number from reflowing as PR states stream in. - "inline-flex shrink-0 items-center gap-0.5 whitespace-nowrap text-xs tabular-nums hover:underline", - variant === "slim" && variantAction === "unsettle" - ? cn("text-secondary-label transition-colors", settledPrHoverClass) - : prStatus.colorClass, - )} - aria-label={ - prBadgeShape && prBadgeShape.kind === "pull-request" && prBadgeShape.others > 0 - ? `${prStatus.tooltip}, and ${prBadgeShape.others} more linked` - : prStatus.tooltip - } - > - - {pr.number} - {prBadgeShape && prBadgeShape.others > 0 ? ( - +{prBadgeShape.others} - ) : null} - - ) : currentLinkedPr ? ( - event.stopPropagation()} - onClick={handlePrClick} - className="inline-flex shrink-0 items-center gap-0.5 text-xs tabular-nums text-muted-foreground hover:underline" - aria-label={`PR #${currentLinkedPr.number}, status pending`} - > - - {currentLinkedPr.number} - {prBadgeShape?.kind === "pull-request" && prBadgeShape.others > 0 ? ( - +{prBadgeShape.others} - ) : null} - + prBadgeShape?.kind === "stack" || pr || currentLinkedPr ? ( + ) : null; const terminalStatusIcon = terminalStatus ? ( ; } +/** The complete linked-PR control shared by the sidebar and composer footer. */ +export function ThreadPullRequestBadgeControl({ + variant, + badge, + number, + url, + status, + onOpenStack, + onOpenPullRequest, +}: { + variant: "underline" | "ghost"; + badge: ThreadPullRequestBadge | null; + number?: number | undefined; + url?: string | undefined; + status: PrStatusIndicator | null; + onOpenStack: () => void; + onOpenPullRequest: (event: MouseEvent) => void; +}) { + const isStack = badge?.kind === "stack"; + if (!isStack && (number === undefined || url === undefined)) return null; + const label = isStack + ? `Stack of ${badge.layers} pull requests, ${badge.state}` + : `${status?.tooltip ?? `PR #${number}, status pending`}${ + badge?.kind === "pull-request" && badge.others > 0 + ? `, and ${badge.others} more linked` + : "" + }`; + const className = cn( + variant === "ghost" + ? buttonVariants({ variant: "ghost", size: "xs" }) + : "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", + "text-xs tabular-nums", + variant === "ghost" && + "font-normal text-xs! active:scale-100 [--control-icon-color:currentColor]", + isStack ? PR_STATE_COLOR_CLASS[badge.state] : (status?.colorClass ?? "text-muted-foreground"), + ); + const content = ( + <> + + {isStack ? badge.layers : number} + {badge?.kind === "pull-request" && badge.others > 0 ? ( + +{badge.others} + ) : null} + + ); + return ( + + event.stopPropagation()} + onClick={(event) => { + event.preventDefault(); + event.stopPropagation(); + onOpenStack(); + }} + /> + ) : ( + event.stopPropagation()} + onClick={onOpenPullRequest} + /> + ) + } + > + {content} + + {label} + + ); +} + /** * A miniature of the pull-requests panel for the thread tooltip: same order, same indentation, * so the hover answers "what is in here" without opening the surface. @@ -192,7 +274,7 @@ export function ThreadPullRequestsMiniList({ } /** The ink each pull-request state wears in the sidebar, shared by the number and stack badges. */ -export const PR_STATE_COLOR_CLASS: Record["state"], string> = { +const PR_STATE_COLOR_CLASS: Record["state"], string> = { open: "text-emerald-600 dark:text-emerald-300/90", merged: "text-violet-600 dark:text-violet-300/90", closed: "text-red-600 dark:text-red-300/90", diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index a875316735061afe67bdee9d2504b97ef3e328b1..0082c7dd227c984c6c18d80ee670cfe29055321c 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -337,7 +337,7 @@ export type MessagesTimelineRow = summaryKind: ToolGroupSummaryKind; toolSurface?: WorkLogEntry["toolSurface"]; toolIcon?: WorkLogEntry["toolIcon"]; - summaryToolIcon?: "browser" | "t3-code"; + summaryToolIcon?: "browser" | "t3-code" | "pull-request"; hasFailure: boolean; } | { diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index b4993b5647e18b9ab6bb8ebdaa42210dcf2b9ec1..8e9635f89e43c1d91d1061f559e964c068574342 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -1,3 +1,4 @@ +import { GitPullRequestIcon } from "lucide-react"; import { type AssistantCitation, type EnvironmentId, @@ -2225,6 +2226,11 @@ function toolGroupSummaryIconName( kind: Extract["summaryKind"], ): WorkEntryIconName { switch (kind) { + case "pull-request": + case "link-pr": + case "unlink-pr": + case "list-prs": + return "pull-request"; case "read": return "eye"; case "edit": @@ -2801,6 +2807,7 @@ type WorkEntryIconName = | "search" | "square-pen" | "terminal" + | "pull-request" | "t3-code" | "wrench" | "x" @@ -3005,6 +3012,8 @@ function ToolActivityImageIcon(props: { function WorkEntryIcon({ name, className }: { name: WorkEntryIconName; className: string }) { switch (name) { + case "pull-request": + return ; case "bot": return ; case "brain": diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx index 5f066d6ef8820708d9f6c719d4624f9c27027f8c..54ad13980236c0920b366f1250e3ae850c853a91 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx @@ -1,3 +1,5 @@ +import { parseChangeRequestUrl } from "@t3tools/shared/changeRequestUrl"; +import { usePullRequestStack } from "~/state/usePullRequestStack"; import { RefreshIcon } from "~/components/ui/refresh-icon"; import { scopedThreadKey, scopeProjectRef } from "@t3tools/client-runtime/environment"; import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; @@ -70,10 +72,10 @@ import { useProjects, useServerConfigs } from "~/state/entities"; import { useEnvironments, usePrimaryEnvironmentId } from "~/state/environments"; import { useEnvironmentQuery } from "~/state/query"; import { useLiveRefresh } from "~/hooks/useLiveRefresh"; -import { pullRequestEnvironment, pullRequestStackAtom } from "~/state/pullRequests"; +import { pullRequestEnvironment } from "~/state/pullRequests"; import { usePullRequestTurnRefresh, useSharedPullRequestSummary } from "~/state/pullRequests"; import { useAtomCommand } from "~/state/use-atom-command"; -import { PullRequestStackMap } from "./PullRequestStackMap"; +import { PullRequestStackMenu } from "./PullRequestStackMenu"; import { PullRequestThreadLinks } from "./PullRequestThreadLinks"; import { vcsEnvironment } from "~/state/vcs"; import { formatRelativeTimeLabel } from "~/timestampFormat"; @@ -104,7 +106,7 @@ import { } from "../ui/menu"; import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; import { toastManager } from "../ui/toast"; -import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { Tooltip, TooltipPopup, TooltipProvider, TooltipTrigger } from "../ui/tooltip"; import { PullRequestDetailGhost, PullRequestTimelineGhost } from "./PullRequestGhosts"; import { PullRequestActivityUnavailableState } from "./PullRequestActivityUnavailableState"; import { DiffPanelLoadingState } from "../DiffPanelShell"; @@ -136,6 +138,7 @@ import { readPullRequestDetailSnapshot, resolveDisplayedPullRequestDetail, resolvePullRequestPrimaryControl, + allowsSinglePullRequestMerge, resolveBaseFreshness, resolvePullRequestMergeMethod, type PullRequestFinding, @@ -459,8 +462,10 @@ export function PullRequestDetailPanel({ context = "page", composerDraftTarget, onBack, + onSelectPullRequest, }: { environmentId: EnvironmentId; + onSelectPullRequest?: ((reference: PullRequestRef) => void) | undefined; /** * The thread this panel sits beside, if any. Links that are not the pull * request itself (check details, host permalinks) can open in that thread's @@ -730,17 +735,34 @@ export function PullRequestDetailPanel({ isStackedPullRequestBase(detail.baseBranch, branchRefsQuery.data?.refs ?? []); // The host's own stack, where it keeps one. Only asked for once the detail has landed so a // pull request nobody can read costs one request rather than two. - const nativeStack = useEnvironmentQuery( - detail === null || detail.capabilities.stacks !== true || !supportsThreadPullRequests - ? null - : pullRequestStackAtom({ environmentId, input: reference }), - ).data; + const stackReference = useMemo( + () => + detail === null || detail.capabilities.stacks !== true || !supportsThreadPullRequests + ? null + : { ...reference, host: reference.host ?? parseChangeRequestUrl(detail.url)?.host }, + [detail, reference, supportsThreadPullRequests], + ); + const nativeStackQuery = usePullRequestStack(environmentId, stackReference); + const nativeStack = nativeStackQuery.data; + const supportsStackActions = + supportsThreadPullRequests && + detail?.capabilities.stacks === true && + detail.capabilities.stackActions === true && + environmentConfigs.get(environmentId)?.environment.capabilities.pullRequestStackActions === + true; + const canMergeSinglePullRequest = allowsSinglePullRequestMerge({ + supportsStackActions, + hasStack: nativeStack !== null, + stackPending: !nativeStackQuery.isSuccess || nativeStackQuery.isPending, + stackError: nativeStackQuery.error, + }); const activityPending = activityQuery.isPending && activity === null; const activityError = activity === null ? activityQuery.error : null; const refreshDetail = useCallback(() => { detailQuery.refresh(); activityQuery.refresh(); - }, [activityQuery.refresh, detailQuery.refresh]); + nativeStackQuery.refresh(); + }, [activityQuery.refresh, detailQuery.refresh, nativeStackQuery.refresh]); const [refreshToken, setRefreshToken] = useState(0); const codeRefreshToken = refreshToken + (turnRefresh ?? 0); const activityRevision = useRef<{ readonly key: string; readonly updatedAt: string } | null>( @@ -1351,9 +1373,9 @@ export function PullRequestDetailPanel({ checksState, autoMergeEnabled: detail.autoMergeEnabled, hasMergeMethod: allowedMergeMethods.length > 0, - canMerge: can("merge"), + canMerge: canMergeSinglePullRequest && can("merge"), canMarkReady: can("ready"), - canEnableAutoMerge: can("enable-auto-merge"), + canEnableAutoMerge: canMergeSinglePullRequest && can("enable-auto-merge"), }) : null; // What the menu's action group holds. Named once so the separators around it are drawn from @@ -1363,6 +1385,7 @@ export function PullRequestDetailPanel({ can(detail.isDraft ? "ready" : "draft") && !(detail.isDraft && primaryAction === "ready"); const showsAutoMerge = + canMergeSinglePullRequest && detail?.state === "open" && ((autoMergeArmed && can("disable-auto-merge")) || (!autoMergeArmed && @@ -1372,6 +1395,7 @@ export function PullRequestDetailPanel({ can("enable-auto-merge") && allowedMergeMethods.length > 0)); const showsMergeNow = + canMergeSinglePullRequest && detail?.state === "open" && (primaryAction === "enable-auto-merge" || primaryAction === "auto-merge-armed") && can("merge") && @@ -1568,7 +1592,38 @@ export function PullRequestDetailPanel({
{detail ? ( - <> + + {!nativeStack && supportsStackActions && nativeStackQuery.error ? ( + + ) : null} + {nativeStack ? ( + 0 + } + canRebase={ + nativeStackQuery.isFresh && + supportsStackActions && + detail.viewerPermissions.stackRebase === true + } + onActed={() => { + refreshDetail(); + onActed?.(); + }} + /> + ) : null} {context === "page" ? ( - - - - {handoff?.startsWith("checkout") ? "Checking out..." : "Check out"} - - - - } - /> + + + + + {handoff?.startsWith("checkout") ? "Checking out..." : "Check out"} + + + + } + /> + } + /> + Check out this pull request + startCheckout("worktree")}> @@ -1663,7 +1728,7 @@ export function PullRequestDetailPanel({
- {nativeStack ? ( - - ) : null} ) : null} diff --git a/apps/web/src/components/pullRequest/PullRequestRow.tsx b/apps/web/src/components/pullRequest/PullRequestRow.tsx index ae7aee0168f4bdc7de708cf06215eafa9c0db4ed..5f073684eabd5470e1ad970d767adea1631c1b62 100644 --- a/apps/web/src/components/pullRequest/PullRequestRow.tsx +++ b/apps/web/src/components/pullRequest/PullRequestRow.tsx @@ -1,4 +1,5 @@ import { SearchIcon } from "lucide-react"; +import { PullRequestStackPopover } from "./PullRequestStackPopover"; import { memo, type RefCallback } from "react"; import { cn } from "~/lib/utils"; @@ -60,6 +61,11 @@ function PullRequestRowLabels({ labels }: { labels: EnvironmentPullRequestEntry[ ); } +export type PullRequestRowTarget = Pick< + EnvironmentPullRequestEntry, + "environmentId" | "projectId" | "host" | "repository" | "number" +>; + function PullRequestRowImpl({ entry, selected, @@ -86,7 +92,7 @@ function PullRequestRowImpl({ /** Used by the list's shared visibility observer to defer optional line-count reads. */ statsKey?: string; statsRef?: RefCallback; - onSelect: (entry: EnvironmentPullRequestEntry) => void; + onSelect: (entry: PullRequestRowTarget) => void; }) { const { Icon, providerName } = getSourceControlPresentationForKind(entry.provider); return ( @@ -116,6 +122,21 @@ function PullRequestRowImpl({ {entry.title}
+ {entry.stack ? ( + + onSelect({ ...target, host: entry.host, environmentId: entry.environmentId }) + } + /> + ) : null} {/* Only a verdict somebody has actually given: "review required" is the absence of one, and saying so on every unreviewed row would say nothing. */} {entry.reviewDecision === "approved" || entry.reviewDecision === "changes-requested" ? ( diff --git a/apps/web/src/components/pullRequest/PullRequestStackHeader.tsx b/apps/web/src/components/pullRequest/PullRequestStackHeader.tsx new file mode 100644 index 0000000000000000000000000000000000000000..a97c4ab77a29fb941e625a0cba7d493e629b649a --- /dev/null +++ b/apps/web/src/components/pullRequest/PullRequestStackHeader.tsx @@ -0,0 +1,26 @@ +import { MenuGroupLabel } from "../ui/menu"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; + +export function PullRequestStackHeader({ + number, + notice, + stale = false, +}: { + number: number; + notice?: string | null | undefined; + stale?: boolean; +}) { + return ( + + Stack #{number} + {notice ? ( + + }> + {stale ? "May be stale" : "Refreshing…"} + + {notice} + + ) : null} + + ); +} diff --git a/apps/web/src/components/pullRequest/PullRequestStackLayerContent.tsx b/apps/web/src/components/pullRequest/PullRequestStackLayerContent.tsx new file mode 100644 index 0000000000000000000000000000000000000000..64d5737b0c13e8f820f5da88b889a61ef1a696b1 --- /dev/null +++ b/apps/web/src/components/pullRequest/PullRequestStackLayerContent.tsx @@ -0,0 +1,28 @@ +import type { PullRequestStack } from "@t3tools/contracts"; +import { cn } from "~/lib/utils"; +import { resolvePullRequestState } from "./pullRequestPresentation"; + +export function PullRequestStackLayerContent({ + layer, + compact = false, +}: { + layer: PullRequestStack["layers"][number]; + compact?: boolean; +}) { + const state = resolvePullRequestState({ + state: layer.state, + isDraft: layer.isDraft ?? false, + }); + return ( + <> + + + {layer.title || layer.headBranch} + + #{layer.number} · {compact ? null : `${layer.headBranch} · `} + {state.label} + + + + ); +} diff --git a/apps/web/src/components/pullRequest/PullRequestStackLayers.tsx b/apps/web/src/components/pullRequest/PullRequestStackLayers.tsx new file mode 100644 index 0000000000000000000000000000000000000000..cc7e52d63187800075b0af15ecdd163e1cdd4928 --- /dev/null +++ b/apps/web/src/components/pullRequest/PullRequestStackLayers.tsx @@ -0,0 +1,39 @@ +import type { PullRequestRef, PullRequestStack } from "@t3tools/contracts"; +import { CheckIcon } from "lucide-react"; +import { MenuItem, MenuGroupLabel } from "../ui/menu"; +import { PullRequestStackLayerContent } from "./PullRequestStackLayerContent"; + +export function PullRequestStackLayers({ + stack, + reference, + onSelect, + pending = false, +}: { + stack: PullRequestStack; + reference: PullRequestRef; + onSelect?: ((reference: PullRequestRef) => void) | undefined; + pending?: boolean; +}) { + return ( +
+ {stack.layers.toReversed().map((layer) => { + return ( + { + onSelect?.({ ...reference, number: layer.number }); + }} + disabled={!onSelect || pending} + aria-current={layer.number === reference.number ? "true" : undefined} + > + + {layer.number === reference.number ? ( + + ) : null} + + ); + })} + ↳ {stack.base} +
+ ); +} diff --git a/apps/web/src/components/pullRequest/PullRequestStackMap.tsx b/apps/web/src/components/pullRequest/PullRequestStackMap.tsx deleted file mode 100644 index e8e251121cb769a2696caa9165d2f59c73103a45..0000000000000000000000000000000000000000 --- a/apps/web/src/components/pullRequest/PullRequestStackMap.tsx +++ /dev/null @@ -1,87 +0,0 @@ -import type { PullRequestStack } from "@t3tools/contracts"; -import { GitPullRequestArrowIcon } from "lucide-react"; - -import { InlineButton } from "../ui/button"; -import { cn } from "~/lib/utils"; -import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; -import { resolvePullRequestState } from "./pullRequestPresentation"; - -/** - * The host's stack as one line of layers, bottom to top, with this pull request marked. Mirrors - * the map GitHub draws above a stacked pull request so a reader who came from there finds the - * same shape here. - */ -export function PullRequestStackMap({ - stack, - currentNumber, - onSelect, - className, -}: { - stack: PullRequestStack; - currentNumber: number; - /** Opens another layer in the same panel; absent where the panel cannot swap references. */ - onSelect?: ((number: number) => void) | undefined; - className?: string; -}) { - return ( -
- - } - > - - {stack.base} - - - Stack #{stack.number} on {stack.base}. Merging a layer lands every layer below it. - - - {stack.layers.map((layer) => { - const presentation = resolvePullRequestState({ state: layer.state, isDraft: false }); - const isCurrent = layer.number === currentNumber; - const chip = ( - - # - {layer.number} - - ); - return ( - - - → - - - onSelect(layer.number)} /> - ) : ( - - ) - } - > - {chip} - - - #{layer.number} · {layer.headBranch} · {presentation.label} - - - - ); - })} -
- ); -} diff --git a/apps/web/src/components/pullRequest/PullRequestStackMenu.tsx b/apps/web/src/components/pullRequest/PullRequestStackMenu.tsx new file mode 100644 index 0000000000000000000000000000000000000000..a9298e982f679b8703f0c5d2a62108d1dcdb7735 --- /dev/null +++ b/apps/web/src/components/pullRequest/PullRequestStackMenu.tsx @@ -0,0 +1,257 @@ +import { Tooltip, TooltipTrigger, TooltipPopup } from "../ui/tooltip"; +import type { + EnvironmentId, + PullRequestRef, + PullRequestStack, + PullRequestMergeMethod, +} from "@t3tools/contracts"; +import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; +import { GitMergeIcon, LayersIcon, RefreshCwIcon, TriangleAlertIcon } from "lucide-react"; +import { useState } from "react"; +import { useAtomCommand } from "~/state/use-atom-command"; +import { pullRequestEnvironment } from "~/state/pullRequests"; +import { Button } from "../ui/button"; +import { Menu, MenuPopup, MenuTrigger, MenuItem, MenuGroup, MenuSeparator } from "../ui/menu"; +import { + Dialog, + DialogPopup, + DialogTitle, + DialogDescription, + DialogHeader, + DialogPanel, + DialogFooter, +} from "../ui/dialog"; +import { toastManager } from "../ui/toast"; +import { PullRequestStackLayers } from "./PullRequestStackLayers"; +import { PullRequestStackHeader } from "./PullRequestStackHeader"; +import { PullRequestStackLayerContent } from "./PullRequestStackLayerContent"; + +export function PullRequestStackMenu({ + stack, + reference, + environmentId, + canMerge, + canRebase, + mergeMethod, + onSelect, + onActed, + notice, + onRetry, +}: { + notice?: string | null; + onRetry?: (() => void) | undefined; + stack: PullRequestStack; + reference: PullRequestRef; + environmentId: EnvironmentId; + canMerge: boolean; + canRebase: boolean; + mergeMethod: PullRequestMergeMethod; + onSelect?: ((reference: PullRequestRef) => void) | undefined; + onActed: () => void; +}) { + const [open, setOpen] = useState(false); + const [confirmation, setConfirmation] = useState<"merge" | "update-branch" | null>(null); + const [pending, setPending] = useState(false); + const runAction = useAtomCommand(pullRequestEnvironment.runAction, { reportFailure: false }); + const top = stack.layers.at(-1); + const unmerged = stack.layers.filter((layer) => layer.state !== "merged"); + const hasClosed = unmerged.some((layer) => layer.state !== "open"); + const position = stack.layers.findIndex((layer) => layer.number === reference.number) + 1; + const mergeLayers = stack.layers.slice(0, position).filter((layer) => layer.state !== "merged"); + const selectedLayer = stack.layers[position - 1]; + const mergeHasClosed = mergeLayers.some((layer) => layer.state !== "open"); + const expectedStackHeads = unmerged.flatMap((layer) => + layer.headSha ? [{ number: layer.number, headSha: layer.headSha }] : [], + ); + const hasUnknownHead = expectedStackHeads.length !== unmerged.length; + const mergeDisabled = + pending || + selectedLayer?.state !== "open" || + mergeLayers.some((layer) => !layer.headSha) || + mergeHasClosed || + mergeLayers.length === 0 || + mergeLayers.some((layer) => layer.isDraft); + const rebaseDisabled = pending || hasUnknownHead || hasClosed || unmerged.length === 0; + const run = async () => { + if ( + pending || + !confirmation || + (confirmation === "merge" ? !canMerge || mergeDisabled : !canRebase || rebaseDisabled) + ) + return; + const action = confirmation; + const target = action === "merge" ? selectedLayer : top; + if (!target?.headSha) return; + const actionHeads = (action === "merge" ? mergeLayers : unmerged).flatMap((layer) => + layer.headSha ? [{ number: layer.number, headSha: layer.headSha }] : [], + ); + setPending(true); + const result = await runAction({ + environmentId, + input: { + ...reference, + number: target.number, + stackNumber: stack.number, + expectedStackHeads: actionHeads, + action, + ...(action === "merge" ? { mergeMethod } : { updateMethod: "rebase" }), + }, + }); + setPending(false); + setConfirmation(null); + onActed(); + if (result._tag === "Failure") { + toastManager.add({ + type: "error", + title: "Stack operation did not complete", + description: String(squashAtomCommandFailure(result)), + }); + } else { + toastManager.add({ + type: "success", + title: action === "merge" ? "Stack merge request completed" : "Stack rebased", + description: + action === "merge" + ? "GitHub merged the stack or added it to its merge queue." + : undefined, + }); + } + }; + const confirmationLayers = confirmation === "merge" ? mergeLayers : unmerged; + return ( + <> + + + + } + > + {position}/{stack.layers.length} + {onRetry ? : null} + + } + /> + + View stack #{stack.number}, layer {position} of {stack.layers.length} + {notice ? ` · ${notice}` : null} + + + + + + {onRetry ? Retry stack refresh : null} + { + setOpen(false); + onSelect(target); + } + : undefined + } + /> + + {canMerge || canRebase ? ( + <> + + {canMerge ? ( + setConfirmation("merge")}> + + Merge stack ({mergeLayers.length}) + + ) : null} + {canRebase ? ( + setConfirmation("update-branch")} + > + + Rebase stack + + ) : null} + {mergeHasClosed || mergeLayers.some((layer) => layer.isDraft) ? ( +

+ Every layer being merged must be open and ready for review. +

+ ) : null} + + ) : null} +
+
+ {canMerge && selectedLayer?.state === "open" ? ( + + + +
+ } + /> + + Merge stack through #{reference.number} into {stack.base} ({mergeLayers.length}{" "} + {mergeLayers.length === 1 ? "pull request" : "pull requests"}) + + + ) : null} + { + if (!value && !pending) setConfirmation(null); + }} + > + + + + {confirmation === "merge" + ? `Merge ${mergeLayers.length} pull requests?` + : `Rebase ${unmerged.length} pull requests?`} + + + {confirmation === "merge" + ? `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.` + : `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.`} + + + +
    + {confirmationLayers.map((layer) => ( +
  • + +
  • + ))} +
+
+ + + + +
+
+ + ); +} diff --git a/apps/web/src/components/pullRequest/PullRequestStackPopover.tsx b/apps/web/src/components/pullRequest/PullRequestStackPopover.tsx new file mode 100644 index 0000000000000000000000000000000000000000..451131cb11fe4630a2531a7e08db2064bd9684dd --- /dev/null +++ b/apps/web/src/components/pullRequest/PullRequestStackPopover.tsx @@ -0,0 +1,108 @@ +import { Tooltip, TooltipTrigger, TooltipPopup } from "../ui/tooltip"; +import type { EnvironmentId, PullRequestRef, PullRequestStackMembership } from "@t3tools/contracts"; +import { LayersIcon } from "lucide-react"; +import { useState } from "react"; +import { usePullRequestStack } from "~/state/usePullRequestStack"; +import { Menu, MenuTrigger, MenuPopup, MenuGroup, MenuGroupLabel, MenuItem } from "../ui/menu"; +import { PullRequestStackLayers } from "./PullRequestStackLayers"; +import { PullRequestStackHeader } from "./PullRequestStackHeader"; + +/** Mounted only while the menu is open, so list rows do not each fetch a stack. */ +function StackBody({ + environmentId, + reference, + onSelect, + stackNumber, +}: { + environmentId: EnvironmentId; + reference: PullRequestRef; + onSelect: (reference: PullRequestRef) => void; + stackNumber: number; +}) { + const query = usePullRequestStack(environmentId, reference); + if (query.data !== null) { + return ( + <> + + {query.error ? Retry stack refresh : null} + + + ); + } + return ( + <> + + + {query.error ?? + (query.isPending ? "Loading stack…" : "This pull request is no longer in a stack.")} + + + ); +} + +export function PullRequestStackPopover({ + environmentId, + reference, + membership, + onSelect, +}: { + environmentId: EnvironmentId; + reference: PullRequestRef; + membership: PullRequestStackMembership; + onSelect: (reference: PullRequestRef) => void; +}) { + const [open, setOpen] = useState(false); + return ( + + + + } + aria-label={`Stack ${membership.number}, layer ${membership.position} of ${membership.size}`} + onClick={(event) => event.stopPropagation()} + onKeyDown={(event) => event.stopPropagation()} + > + + {membership.position}/{membership.size} + + } + /> + + View stack #{membership.number}, layer {membership.position} of {membership.size} + + + event.stopPropagation()} + onKeyDown={(event) => event.stopPropagation()} + > + + {open ? ( + { + setOpen(false); + onSelect(target); + }} + /> + ) : null} + + + + ); +} diff --git a/apps/web/src/components/pullRequest/PullRequestThreadLinks.tsx b/apps/web/src/components/pullRequest/PullRequestThreadLinks.tsx index 8eba6cdf2317dd33a6d9fb9d00d7702eb4240247..9d8dcc2424e69205347a08650281cf92a6ad27e6 100644 --- a/apps/web/src/components/pullRequest/PullRequestThreadLinks.tsx +++ b/apps/web/src/components/pullRequest/PullRequestThreadLinks.tsx @@ -1,5 +1,5 @@ +import { Tooltip, TooltipTrigger, TooltipPopup } from "../ui/tooltip"; import { scopeThreadRef } from "@t3tools/client-runtime/environment"; -import { Link } from "@tanstack/react-router"; import type { EnvironmentId, PullRequestRef, ScopedThreadRef, ThreadId } from "@t3tools/contracts"; import { CheckIcon, LinkIcon, MessageSquareIcon, UnlinkIcon } from "lucide-react"; import { useState } from "react"; @@ -11,11 +11,11 @@ import { useProjects, useServerConfigs, useThreadShell, useThreadShells } from " import { pullRequestEnvironment } from "~/state/pullRequests"; import { useEnvironmentQuery } from "~/state/query"; import { appAtomRegistry } from "~/rpc/atomRegistry"; -import { buildThreadRouteParams } from "~/threadRoutes"; +import { openCommandPalette } from "~/commandPaletteBus"; import { Button } from "../ui/button"; import { Command, CommandInput, CommandItem, CommandList } from "../ui/command"; import { Dialog, DialogPopup, DialogTitle } from "../ui/dialog"; -import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu"; +import { MenuItem } from "../ui/menu"; import { toastManager } from "../ui/toast"; interface PullRequestThreadLinksProps { @@ -61,7 +61,7 @@ function EnabledPullRequestThreadLinks({ : null, ); // Refreshes can briefly clear the query value. Keep the last response so polling - // does not unmount an open menu or move its highlighted thread. + // does not hide the linked-thread count between responses. const [lastRelations, setLastRelations] = useState(relations.data); if (relations.data !== null && relations.data !== lastRelations) { setLastRelations(relations.data); @@ -101,47 +101,29 @@ function EnabledPullRequestThreadLinks({ return ( <> {display === "count" && (linkedThreads.length > 0 || relations.error !== null) ? ( - - + - } - > - - {linkedThreadsLabel} - - {linkedThreads.length || "?"} - - - - {relations.error !== null ? ( - Could not load linked threads. Retry - ) : null} - {linkedThreads.map((linkedThread) => ( - + onClick={() => + openCommandPalette({ + query: url, + ...((relations.data ?? lastRelations) === null + ? {} + : { linkedThreads: { environmentId, threads: linkedThreads } }), + }) } > - - {linkedThread.title || "Untitled thread"} - - {linkedThread.archivedAt !== null ? ( - Archived - ) : null} - - ))} - - + + {linkedThreads.length || "?"} + + } + /> + {linkedThreadsLabel}. Search in the command palette. + ) : null} {display === "menu-item" ? ( { expect(readPullRequestDetailSnapshot(undefined, "env-1", reference)).toBeNull(); }); }); + +describe("single-PR merge compatibility during stack discovery", () => { + it.each([ + [false, true, false, null, true], + [false, false, true, null, true], + [true, false, true, null, false], + [true, false, false, "Lookup failed", false], + [true, true, false, null, false], + [true, false, false, null, true], + ] as const)( + "capability=%s stack=%s pending=%s error=%s permits=%s", + (supportsStackActions, hasStack, stackPending, stackError, allowed) => { + expect( + allowsSinglePullRequestMerge({ supportsStackActions, hasStack, stackPending, stackError }), + ).toBe(allowed); + }, + ); +}); diff --git a/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts b/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts index 3ce7277f0560dfa8263afdb1181b094fd1616d45..64792164814f554973f6d9a0faa383a60bb7ecfd 100644 --- a/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts +++ b/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts @@ -29,6 +29,19 @@ export const PULL_REQUEST_MERGE_METHOD_LABELS: Record, current: PullRequestMergeMethod | null, diff --git a/apps/web/src/components/pullRequest/pullRequestStackSnapshot.test.ts b/apps/web/src/components/pullRequest/pullRequestStackSnapshot.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..621ba3124e8e113b3d09309d99914d08906ceee4 --- /dev/null +++ b/apps/web/src/components/pullRequest/pullRequestStackSnapshot.test.ts @@ -0,0 +1,93 @@ +import type { ThreadPullRequestLink } from "@t3tools/contracts"; +import { ProjectId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; +import { savedPullRequestStack, pullRequestStackView } from "./pullRequestStackSnapshot"; + +const reference = { + projectId: ProjectId.make("project"), + host: "github.com", + repository: "acme/web", + number: 2, +}; +const link: ThreadPullRequestLink = { + host: "github.com", + repository: "acme/web", + number: 2, + url: "https://github.com/acme/web/pull/2", + source: "manual", + linkedAt: "2026-09-09T10:00:00Z", + snapshot: { + title: "Top layer", + headBranch: "top", + baseBranch: "bottom", + state: "open", + isDraft: false, + updatedAt: null, + syncedAt: "2026-09-09T10:00:00Z", + }, + stack: { + kind: "native", + id: "stack", + number: 3, + url: "https://github.com/acme/web/pull/3", + base: "main", + layers: [ + { number: 1, headBranch: "bottom", state: "open" }, + { number: 2, headBranch: "top", state: "open" }, + ], + }, +}; + +describe("saved stack navigation", () => { + it("preserves all native layers even when only one has a linked snapshot, without action SHAs", () => { + const saved = savedPullRequestStack([link], reference); + expect(saved?.layers).toEqual([ + { number: 1, headBranch: "bottom", state: "open" }, + { number: 2, headBranch: "top", state: "open", title: "Top layer", isDraft: false }, + ]); + expect(savedPullRequestStack([link], { ...reference, number: 1 })?.number).toBe(3); + }); + it("does not borrow stacks across hosts or repositories", () => { + expect(savedPullRequestStack([link], { ...reference, host: "enterprise.example" })).toBeNull(); + expect(savedPullRequestStack([link], { ...reference, repository: "other/web" })).toBeNull(); + expect(savedPullRequestStack([link], { ...reference, host: undefined })).toBeNull(); + }); + it("honors a newer saved removal across linked threads", () => { + expect( + savedPullRequestStack( + [ + link, + { + ...link, + stack: null, + snapshot: { ...link.snapshot!, syncedAt: "2026-09-09T11:00:00Z" }, + }, + ], + reference, + ), + ).toBeNull(); + }); + it("shows saved data during loading and marks a failed refresh stale", () => { + const saved = savedPullRequestStack([link], reference); + const query = { data: null, isSuccess: false, isPending: true, error: null }; + expect(pullRequestStackView(query, saved)).toMatchObject({ + data: saved, + isFresh: false, + notice: expect.stringContaining("Refreshing"), + }); + expect( + pullRequestStackView({ ...query, isPending: false, error: "Rate limited" }, saved), + ).toMatchObject({ data: saved, isFresh: false, notice: expect.stringContaining("stale") }); + }); + it("prefers refreshed data and honors a successful absence", () => { + const saved = savedPullRequestStack([link], reference); + const query = { data: saved, isSuccess: true, isPending: false, error: null }; + expect(pullRequestStackView(query, null)).toEqual({ data: saved, isFresh: true, notice: null }); + expect(pullRequestStackView({ ...query, data: null }, saved)).toEqual({ + data: null, + isFresh: true, + notice: null, + }); + expect(pullRequestStackView({ ...query, isPending: true }, saved).isFresh).toBe(false); + }); +}); diff --git a/apps/web/src/components/pullRequest/pullRequestStackSnapshot.ts b/apps/web/src/components/pullRequest/pullRequestStackSnapshot.ts new file mode 100644 index 0000000000000000000000000000000000000000..4e48a2f3705b26bb5c57c9c705ceb4e9732fbf73 --- /dev/null +++ b/apps/web/src/components/pullRequest/pullRequestStackSnapshot.ts @@ -0,0 +1,73 @@ +import type { PullRequestRef, PullRequestStack, ThreadPullRequestLink } from "@t3tools/contracts"; + +/** Saved native membership is enough for navigation, but never supplies action head SHAs. */ +export function savedPullRequestStack( + links: ReadonlyArray, + reference: PullRequestRef, +): PullRequestStack | null { + const host = reference.host?.toLowerCase(); + if (!host) return null; + const matching = links.filter( + (link) => + link.host.toLowerCase() === host && + link.repository.toLowerCase() === reference.repository.toLowerCase(), + ); + const exact = matching.filter((link) => link.number === reference.number); + const candidates = + exact.length > 0 + ? exact + : matching.filter((link) => + link.stack?.layers.some((layer) => layer.number === reference.number), + ); + const newest = candidates.toSorted( + (a, b) => + Date.parse(b.snapshot?.syncedAt ?? b.linkedAt) - + Date.parse(a.snapshot?.syncedAt ?? a.linkedAt), + )[0]; + const stack = newest?.stack; + if (!stack || !stack.layers.some((layer) => layer.number === reference.number)) return null; + return { + id: stack.id, + number: stack.number, + url: stack.url, + base: stack.base, + layers: stack.layers.map((layer) => { + const snapshot = matching + .filter((link) => link.number === layer.number) + .toSorted( + (a, b) => + Date.parse(b.snapshot?.syncedAt ?? b.linkedAt) - + Date.parse(a.snapshot?.syncedAt ?? a.linkedAt), + )[0]?.snapshot; + return { + ...layer, + ...(snapshot ? { title: snapshot.title, isDraft: snapshot.isDraft } : {}), + }; + }), + }; +} + +/** A fresh absence overrides saved membership; failed refreshes preserve available navigation. */ +export function pullRequestStackView( + query: { + data: PullRequestStack | null; + isSuccess: boolean; + isPending: boolean; + error: string | null; + }, + saved: PullRequestStack | null, +) { + const data = query.isSuccess ? query.data : (query.data ?? saved); + return { + data, + isFresh: query.isSuccess && !query.isPending, + notice: + data === null + ? null + : query.error + ? "Stack data may be stale. We couldn’t refresh it." + : !query.isSuccess || query.isPending + ? "Refreshing stack… Showing saved data." + : null, + }; +} diff --git a/apps/web/src/lib/openPullRequestLink.ts b/apps/web/src/lib/openPullRequestLink.ts index 9671f5ea6512a2d452e6d802db4060369dfe6808..35a9fed4cc5d9d4e1a339e6c1d3e7b9e37ce1134 100644 --- a/apps/web/src/lib/openPullRequestLink.ts +++ b/apps/web/src/lib/openPullRequestLink.ts @@ -203,6 +203,7 @@ export function useOpenChangeRequestLink( state: previous.state ?? "all", repository, number: parsed.number, + selectedHost: parsed.host, selectedProjectId: project.id, selectedEnvironmentId: project.environmentId, }), @@ -220,6 +221,7 @@ export function useOpenChangeRequestLink( state: "all", repository, number: parsed.number, + selectedHost: parsed.host, selectedProjectId: project.id, // Named so the page opens the right one of two servers holding this project. selectedEnvironmentId: project.environmentId, diff --git a/apps/web/src/routes/_chat.pull-requests.tsx b/apps/web/src/routes/_chat.pull-requests.tsx index cca1cb5b5c2cda84570f745e89a30a5faed803fb..62bb23bb305f9703d5e01394eaf4f6be67d2bf9f 100644 --- a/apps/web/src/routes/_chat.pull-requests.tsx +++ b/apps/web/src/routes/_chat.pull-requests.tsx @@ -99,7 +99,10 @@ import { } from "../components/pullRequest/PullRequestListFilters"; import { PullRequestListEmptyState } from "../components/pullRequest/PullRequestListEmptyState"; import { PullRequestListGhost } from "../components/pullRequest/PullRequestGhosts"; -import { PullRequestRow } from "../components/pullRequest/PullRequestRow"; +import { + PullRequestRow, + type PullRequestRowTarget, +} from "../components/pullRequest/PullRequestRow"; import { PullRequestsUnavailableState } from "../components/pullRequest/PullRequestsUnavailableState"; import { RightPanelTabs, type PullRequestTabStatusSeed } from "../components/RightPanelTabs"; import { @@ -163,6 +166,8 @@ export interface PullRequestsSearch extends PullRequestListPreferences { readonly repository?: string; readonly number?: number; readonly selectedProjectId?: ProjectId; + /** Host of the open review; changing tabs must not narrow the list host filter. */ + readonly selectedHost?: string; /** * Which server the selected pull request was read from. A project id only names a project on * 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")({ ...(typeof raw.selectedProjectId === "string" && raw.selectedProjectId ? { selectedProjectId: raw.selectedProjectId as ProjectId } : {}), + ...(typeof raw.selectedHost === "string" && raw.selectedHost + ? { selectedHost: raw.selectedHost.slice(0, 200) } + : {}), ...(typeof raw.selectedEnvironmentId === "string" && raw.selectedEnvironmentId ? { selectedEnvironmentId: raw.selectedEnvironmentId as EnvironmentId } : {}), @@ -367,6 +375,7 @@ function PullRequestsRouteView() { // A link from a thread or the sidebar only knows the repository, so the owning project is // resolved here; an explicit `projectId` in the URL still wins. + const selectedHost = search.selectedHost ?? search.host; const projectIdForRepository = useMemo(() => { const repository = search.repository?.toLowerCase(); if (repository === undefined) return undefined; @@ -378,14 +387,14 @@ function PullRequestsRouteView() { repository && // The same `owner/name` can exist on two hosts. Without this the first match wins, and // a link that named its host opens the pull request from the other one. - (search.host === undefined || + (selectedHost === undefined || pullRequestHostOf( project.repositoryIdentity, project.repositoryIdentity.provider as SourceControlProviderKind, - ) === search.host.toLowerCase()), + ) === selectedHost.toLowerCase()), ); return identity?.id; - }, [projects, search.host, search.repository]); + }, [projects, selectedHost, search.repository]); // The selection is resolved the same way the scope is: an id no connected environment has can // never be read here, and one that arrived before the projects did is not yet wrong. @@ -472,6 +481,7 @@ function PullRequestsRouteView() { ...(next.projectId ? { projectId: next.projectId } : {}), ...(next.environmentId ? { environmentId: next.environmentId } : {}), ...(next.host ? { host: next.host } : {}), + ...(next.selectedHost ? { selectedHost: next.selectedHost } : {}), ...(next.selectedProjectId ? { selectedProjectId: next.selectedProjectId } : {}), ...(next.selectedEnvironmentId ? { selectedEnvironmentId: next.selectedEnvironmentId } @@ -494,6 +504,7 @@ function PullRequestsRouteView() { number: undefined, selectedProjectId: undefined, selectedEnvironmentId: undefined, + selectedHost: undefined, }; // List controls change the rows behind the detail, not the independent selected surface. The // reader can keep working in that panel while narrowing, sorting, or switching projects. @@ -799,7 +810,31 @@ function PullRequestsRouteView() { // from the first moment rather than the second: a button that stays live through the slow half // of its own work is a button that gets pressed again, and buys the whole cascade twice. const [invalidating, setInvalidating] = useState(false); - const refreshFromHost = async (includeDetail = true) => { + const refreshListAndStats = ( + requestedStatsScope = statsScopeRef.current, + actedEnvironmentId?: EnvironmentId, + ) => { + refreshList(true, actedEnvironmentId); + const visible = visibleStatsKeys.current; + const batches = pullRequestStatsRefreshBatches({ + requestedScope: requestedStatsScope, + currentScope: statsScopeRef.current, + entriesByKey: entriesByStatsKey.current, + candidateKeys: visible.key === requestedStatsScope.key ? visible.values : new Set(), + statsByRow: statsByRowRef.current, + }); + if (batches !== null) { + setStatsTargetState({ key: requestedStatsScope.key, batches }); + statsQuery.refresh( + batches + .filter(({ environmentId }) => + actedEnvironmentId === undefined ? true : environmentId === actedEnvironmentId, + ) + .map(({ environmentId, input }) => ({ environmentId, input })), + ); + } + }; + const refreshFromHost = async () => { const requestedStatsScope = statsScopeRef.current; setInvalidating(true); try { @@ -811,20 +846,8 @@ function PullRequestsRouteView() { } finally { setInvalidating(false); } - refreshList(true); - const visible = visibleStatsKeys.current; - const batches = pullRequestStatsRefreshBatches({ - requestedScope: requestedStatsScope, - currentScope: statsScopeRef.current, - entriesByKey: entriesByStatsKey.current, - candidateKeys: visible.key === requestedStatsScope.key ? visible.values : new Set(), - statsByRow: statsByRowRef.current, - }); - if (batches !== null) { - setStatsTargetState({ key: requestedStatsScope.key, batches }); - statsQuery.refresh(batches.map(({ environmentId, input }) => ({ environmentId, input }))); - } - if (includeDetail) setDetailRefreshToken((token) => token + 1); + refreshListAndStats(requestedStatsScope); + setDetailRefreshToken((token) => token + 1); }; const refreshing = invalidating || listQuery.isPending; @@ -1058,17 +1081,28 @@ function PullRequestsRouteView() { // re-reads only its own slice, so the rows loaded before it would never see a merge, a close, // or a retitle. Going back to a single page long enough to cover everything on screen lets the // merge above bring every row up to date in place. - const refreshList = (includeRelated = false) => { - const related = includeRelated - ? [ - ...baselineTargets, - ...facetTargets, - ...partitionTargets.authored, - ...partitionTargets.reviewing, - ] - : []; + const refreshList = (includeRelated = false, actedEnvironmentId?: EnvironmentId) => { + const related = ( + includeRelated + ? [ + ...baselineTargets, + ...facetTargets, + ...partitionTargets.authored, + ...partitionTargets.reviewing, + ] + : [] + ).filter( + ({ environmentId }) => + actedEnvironmentId === undefined || environmentId === actedEnvironmentId, + ); if (sentCursors === null) { - listQuery.refresh([...listTargets, ...related]); + listQuery.refresh([ + ...listTargets.filter( + ({ environmentId }) => + actedEnvironmentId === undefined || environmentId === actedEnvironmentId, + ), + ...related, + ]); return; } if (related.length > 0) listQuery.refresh(related); @@ -1412,9 +1446,10 @@ function PullRequestsRouteView() { repository: search.repository, number: search.number, projectId: selectedProject.id, + ...(selectedHost ? { host: selectedHost } : {}), } : null, - [search.number, search.repository, selectedProject], + [search.number, search.repository, selectedProject, selectedHost], ); const rightPanelAvailable = selectedPullRequestSurface !== null; useEffect(() => { @@ -1429,6 +1464,14 @@ function PullRequestsRouteView() { repository: activePullRequestSurface.repository, number: activePullRequestSurface.number, projectId: activePullRequestSurface.projectId as ProjectId, + host: + activePullRequestSurface.host ?? + (selectedProject?.repositoryIdentity + ? pullRequestHostOf( + selectedProject.repositoryIdentity, + selectedProject.repositoryIdentity.provider as SourceControlProviderKind, + ) + : undefined), } : null; @@ -1440,6 +1483,7 @@ function PullRequestsRouteView() { repository: surface.repository, number: surface.number, selectedProjectId: surface.projectId as ProjectId, + selectedHost: surface.host, ...(surface.environmentId === undefined ? {} : { selectedEnvironmentId: surface.environmentId as EnvironmentId }), @@ -1503,7 +1547,7 @@ function PullRequestsRouteView() { // Stable so the memoized rows can skip re-rendering when the list around them changes. const selectEntry = useCallback( - (entry: EnvironmentPullRequestEntry) => { + (entry: PullRequestRowTarget) => { // The surface carries the row's own server, which is what its detail reads and acts on. if (rightPanelRef === null) return; useRightPanelStore.getState().openPullRequest(rightPanelRef, entry); @@ -1512,6 +1556,7 @@ function PullRequestsRouteView() { number: entry.number, selectedProjectId: entry.projectId, selectedEnvironmentId: entry.environmentId, + selectedHost: entry.host, }); }, [rightPanelRef, updateSearch], @@ -1628,6 +1673,7 @@ function PullRequestsRouteView() { selected={ selected?.environmentId === entry.environmentId && selected.repository === entry.repository && + selected.host?.toLowerCase() === entry.host.toLowerCase() && selected.number === entry.number } onSelect={selectEntry} @@ -1938,10 +1984,30 @@ function PullRequestsRouteView() { { + if (rightPanelRef === null) return; + useRightPanelStore.getState().openPullRequest(rightPanelRef, { + projectId: reference.projectId, + repository: reference.repository, + number: reference.number, + ...(reference.host ? { host: reference.host } : {}), + environmentId: panelEnvironmentId, + }); + updateSearch({ + repository: reference.repository, + number: reference.number, + selectedHost: reference.host, + selectedProjectId: reference.projectId, + selectedEnvironmentId: panelEnvironmentId, + }); + }} reference={{ projectId: renderedPullRequestSurface.projectId as ProjectId, repository: renderedPullRequestSurface.repository, number: renderedPullRequestSurface.number, + ...(renderedPullRequestSurface.host + ? { host: renderedPullRequestSurface.host } + : {}), }} listEntry={ listedPullRequestsBySurface.get( @@ -1952,7 +2018,8 @@ function PullRequestsRouteView() { // Host actions can change both readiness and diff size, so refresh the counts // alongside the list. The panel already refreshes itself after each action. onActed={() => { - void refreshFromHost(false); + // Mutations already invalidate the host's affected caches. + refreshListAndStats(undefined, panelEnvironmentId); }} /> diff --git a/apps/web/src/state/pullRequests.ts b/apps/web/src/state/pullRequests.ts index 182b2dc9709832eabe49fa90bf652ba161a2607c..e2e840c89c4992f3f3f61c718b2eda70ac2247a7 100644 --- a/apps/web/src/state/pullRequests.ts +++ b/apps/web/src/state/pullRequests.ts @@ -74,7 +74,10 @@ export function useSharedPullRequestSummary( }, [atom, current, environmentId]); return newestPullRequestSummary(current, observed); } -export const pullRequestStackAtom = createPullRequestStackAtomFamily(connectionAtomRuntime); +export const pullRequestStackAtom = createPullRequestStackAtomFamily( + connectionAtomRuntime, + pullRequestEnvironment.refreshes, +); export interface EnvironmentQueryTarget { readonly environmentId: EnvironmentId; diff --git a/apps/web/src/state/query.ts b/apps/web/src/state/query.ts index 9bc16b01fe32fa68fbd045516124f70ffb70e648..b2823a51e33232538c753c2dcc9892700ff1ac6d 100644 --- a/apps/web/src/state/query.ts +++ b/apps/web/src/state/query.ts @@ -11,6 +11,7 @@ export interface EnvironmentQueryView
{ readonly data: A | null; readonly error: string | null; readonly isPending: boolean; + readonly isSuccess: boolean; readonly refresh: () => void; } @@ -31,6 +32,7 @@ export function useEnvironmentQuery( data: Option.getOrNull(AsyncResult.value(result)), error: result._tag === "Failure" ? formatEnvironmentQueryError(result.cause) : null, isPending: atom !== null && result.waiting, + isSuccess: result._tag === "Success", refresh, }; } diff --git a/apps/web/src/state/usePullRequestStack.ts b/apps/web/src/state/usePullRequestStack.ts new file mode 100644 index 0000000000000000000000000000000000000000..fb4febddef64c787d8bd6ee59392c145dc0abde4 --- /dev/null +++ b/apps/web/src/state/usePullRequestStack.ts @@ -0,0 +1,33 @@ +import type { EnvironmentId, PullRequestRef } from "@t3tools/contracts"; +import { useMemo } from "react"; +import { + savedPullRequestStack, + pullRequestStackView, +} from "../components/pullRequest/pullRequestStackSnapshot"; +import { useThreadShells } from "./entities"; +import { pullRequestStackAtom } from "./pullRequests"; +import { useEnvironmentQuery } from "./query"; + +/** Detail headers and list popovers keep saved navigation during an unavailable refresh. */ +export function usePullRequestStack( + environmentId: EnvironmentId, + reference: PullRequestRef | null, +) { + const threads = useThreadShells(); + const saved = useMemo( + () => + reference === null + ? null + : savedPullRequestStack( + threads + .filter((thread) => thread.environmentId === environmentId) + .flatMap((thread) => thread.pullRequests ?? []), + reference, + ), + [environmentId, reference, threads], + ); + const query = useEnvironmentQuery( + reference === null ? null : pullRequestStackAtom({ environmentId, input: reference }), + ); + return { ...query, ...pullRequestStackView(query, saved) }; +} diff --git a/docs/user/source-control.md b/docs/user/source-control.md index 5a67f7c64a0a1593455947c77323a5fd254b3670..48e3eb1992cd3deef3d2f0f6e9ca6329ccee9bc7 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -115,3 +115,15 @@ review is terminal. An open or unsynced link keeps it active. Cross-repository links use a project on the same host. Azure DevOps reviews require a project checked out from the matching organization and repository. + +## GitHub stacks + +The Pull Requests page shows each PR's position in its GitHub stack. Open the stack badge in a +review to navigate its layers. **Merge stack** submits the selected pull request and every unmerged +layer below it to GitHub together, respecting branch rules and merge queues. The confirmation shows +the scope and merge strategy. GitHub rebases the remaining stack after merging. + +**Rebase stack** updates remote branches from bottom to top without changing your local checkout. +It can rewrite history and restart checks. If a layer fails, earlier updates remain; resolve that +layer before retrying. GitHub may require manual conflict resolution after a lower layer is amended, +even when its changes look independent. Stack actions require an environment that supports them. diff --git a/packages/client-runtime/src/state/pullRequests.test.ts b/packages/client-runtime/src/state/pullRequests.test.ts index 09c8e110f10ec1f05aef0462ed005163e73c549d..fdd109d6e8c4cd5fcff48a0221e967b599cd1d3b 100644 --- a/packages/client-runtime/src/state/pullRequests.test.ts +++ b/packages/client-runtime/src/state/pullRequests.test.ts @@ -1,4 +1,4 @@ -import { EnvironmentId, ProjectId, WS_METHODS } from "@t3tools/contracts"; +import { EnvironmentId, ProjectId, WS_METHODS, type PullRequestStack } from "@t3tools/contracts"; import { expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Latch from "effect/Latch"; @@ -19,7 +19,10 @@ import * as EnvironmentRegistry from "../connection/registry.ts"; import * as EnvironmentSupervisor from "../connection/supervisor.ts"; import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; import type { RpcSession } from "../rpc/session.ts"; -import { createPullRequestEnvironmentAtoms } from "./pullRequests.ts"; +import { + createPullRequestEnvironmentAtoms, + createPullRequestStackAtomFamily, +} from "./pullRequests.ts"; import { PullRequestDiffLoader } from "./pullRequestDiffHttp.ts"; import { executeAtomQuery } from "./runtime.ts"; @@ -80,7 +83,7 @@ const makeTestRuntime = Effect.fn("makeTestRuntime")(function* (client: WsRpcPro const registry = yield* Effect.acquireRelease(Effect.sync(AtomRegistry.make), (registry) => Effect.sync(() => registry.dispose()), ); - return { atoms, registry }; + return { runtime, atoms, registry }; }); it.effect("keeps concurrent diff file reads on different hosts separate", () => @@ -212,3 +215,67 @@ it.effect("refreshes pull request activity after a comment is updated", () => }), ), ); + +it.effect("refreshes stack state after reopening and head SHAs after a turn", () => + Effect.scoped( + Effect.gen(function* () { + const refreshEvents = yield* PubSub.unbounded(); + let state: "closed" | "open" = "closed"; + let headSha = "old-head"; + const client = { + [WS_METHODS.pullRequestsSubscribeRefreshes]: () => Stream.fromPubSub(refreshEvents), + [WS_METHODS.pullRequestsStack]: () => + Effect.sync( + () => + ({ + id: "stack-1", + number: 1, + url: "https://github.com/acme/web/pull/1", + base: "main", + layers: [ + { + number: 1, + headBranch: "feature", + headSha, + state, + isDraft: false, + }, + ], + }) satisfies PullRequestStack, + ), + } as unknown as WsRpcProtocolClient; + const { runtime, registry } = yield* makeTestRuntime(client); + const stacks = createPullRequestStackAtomFamily(runtime); + const stack = stacks({ + environmentId: TARGET.environmentId, + input: { + projectId: ProjectId.make("project-1"), + repository: "acme/web", + number: 1, + }, + }); + const unmount = registry.mount(stack); + yield* Effect.addFinalizer(() => Effect.sync(unmount)); + yield* Effect.promise(() => executeAtomQuery(registry, stack)); + expect((yield* AtomRegistry.getResult(registry, stack))?.layers[0]?.state).toBe("closed"); + state = "open"; + registry.refresh(stack); + expect( + (yield* AtomRegistry.getResult(registry, stack, { suspendOnWaiting: true }))?.layers[0] + ?.state, + ).toBe("open"); + + const refreshed = Latch.makeUnsafe(); + const stop = registry.subscribe(stack, (result) => { + if (AsyncResult.isSuccess(result) && result.value?.layers[0]?.headSha === "new-head") { + refreshed.openUnsafe(); + } + }); + yield* Effect.addFinalizer(() => Effect.sync(stop)); + headSha = "new-head"; + yield* PubSub.publish(refreshEvents, 1); + yield* refreshed.await; + expect((yield* AtomRegistry.getResult(registry, stack))?.layers[0]?.headSha).toBe("new-head"); + }), + ), +); diff --git a/packages/client-runtime/src/state/pullRequests.ts b/packages/client-runtime/src/state/pullRequests.ts index 10ec49ac9a395d7f1d5ae998f0dcc5bc007fdc2a..c9d23d02bf5537bfc92e329b692e4f35bddfcafe 100644 --- a/packages/client-runtime/src/state/pullRequests.ts +++ b/packages/client-runtime/src/state/pullRequests.ts @@ -63,12 +63,14 @@ export function createLinkedPullRequestSummaryAtomFamily( /** The host-native stack a pull request belongs to; null where it is not stacked. */ export function createPullRequestStackAtomFamily( runtime: Atom.AtomRuntime, + refreshes = createPullRequestRefreshAtomFamily(runtime), ) { return createEnvironmentRpcQueryAtomFamily(runtime, { label: "environment-data:pull-requests:stack", tag: WS_METHODS.pullRequestsStack, staleTimeMs: 60_000, idleTtlMs: LINKED_PULL_REQUEST_IDLE_TTL_MS, + refreshTrigger: ({ environmentId }) => refreshes({ environmentId, input: {} }), }); } diff --git a/packages/client-runtime/src/work-log/presentation.test.ts b/packages/client-runtime/src/work-log/presentation.test.ts index e056e92afe5c50cb1e8352329d58aba2505edd24..5c57b8de3bd68d4d5447e3a17a265bfc485c8f65 100644 --- a/packages/client-runtime/src/work-log/presentation.test.ts +++ b/packages/client-runtime/src/work-log/presentation.test.ts @@ -571,3 +571,79 @@ describe("resolveViewedImageAsset", () => { expect(resolveViewedImageAsset("https://example.com/logo.png", { threadId })).toBeNull(); }); }); + +describe("pull request tool presentation", () => { + it.each([ + "mcp__t3-code__link_pull_request", + "mcp__t3_code__link_pull_request", + "T3-code · link_pull_request", + "t3code/link_pull_request", + "link_pull_request", + ])("recognizes the native linking tool: %s", (label) => { + const entry = { label, tone: "tool" as const, toolLifecycleStatus: "completed" }; + expect(resolveWorkEntryToolPresentation(entry)).toMatchObject({ + displayName: "Linked a pull request", + icon: "pull-request", + }); + expect(toolGroupAction(entry)).toBe("link-pr"); + }); + + it.each([ + ["inProgress", "Linking PR #42"], + ["completed", "Linked PR #42"], + ["failed", "Failed to link PR #42"], + ["declined", "Declined to link PR #42"], + ["stopped", "Stopped linking PR #42"], + ])("describes the target and %s status", (toolLifecycleStatus, displayName) => { + expect( + resolveWorkEntryToolPresentation({ + label: "MCP tool call", + toolTitle: "Custom title", + toolLifecycleStatus, + toolData: { + server: "t3-code", + tool: "link_pull_request", + arguments: { url: "https://github.com/acme/web/pull/42" }, + }, + })?.displayName, + ).toBe(displayName); + }); + + it("recognizes unlink targets supplied as repository and number", () => { + expect( + resolveWorkEntryToolPresentation({ + label: "MCP tool call", + toolLifecycleStatus: "completed", + toolData: { + toolName: "mcp__t3-code__unlink_pull_request", + rawInput: { repository: "acme/web", number: 42 }, + }, + }), + ).toMatchObject({ displayName: "Unlinked PR #42", icon: "pull-request", action: "unlink-pr" }); + }); + + it("summarizes native PR work separately from ordinary tools and integration metadata", () => { + const link: WorkLogPresentationEntry = { + label: "T3-code · link_pull_request", + tone: "tool", + itemType: "mcp_tool_call", + toolLifecycleStatus: "completed", + toolSource: { key: "t3-code", name: "T3 Code", kind: "integration" }, + }; + const list: WorkLogPresentationEntry = { + ...link, + label: "T3-code · list_thread_pull_requests", + }; + expect(summarizeToolGroup([link, link, list])).toBe( + "Linked 2 pull requests and checked linked pull requests", + ); + expect(summarizeToolGroup([{ ...link, label: "T3-code · unlink_pull_request" }])).toBe( + "Unlinked 1 pull request", + ); + expect(toolGroupSummaryKind([link, link, list])).toBe("pull-request"); + expect(summarizeToolGroup([list, list])).toBe("Checked linked pull requests 2 times"); + expect( + resolveWorkEntryToolPresentation({ label: "mcp__another-server__link_pull_request" }), + ).toBeNull(); + }); +}); diff --git a/packages/client-runtime/src/work-log/presentation.ts b/packages/client-runtime/src/work-log/presentation.ts index 2e1ef3bbf0038fbf245cfc7579aff25d60b437d0..3e62f75d510bb077539400debba6de225bcb91d9 100644 --- a/packages/client-runtime/src/work-log/presentation.ts +++ b/packages/client-runtime/src/work-log/presentation.ts @@ -8,6 +8,7 @@ import { } from "@t3tools/contracts"; import { classifyMarkdownImageSource } from "@t3tools/client-runtime/markdown-images"; import { resolveMediaSource } from "@t3tools/client-runtime/media-source"; +import { parseChangeRequestUrl } from "@t3tools/shared/changeRequestUrl"; import { isWorkspaceImagePreviewPath } from "@t3tools/shared/filePreview"; export function isWorktreeSetupActivity(kind: string): boolean { @@ -36,6 +37,9 @@ export interface WorkLogPresentationEntry { } export type ToolGroupAction = + | "link-pr" + | "unlink-pr" + | "list-prs" | "read" | "edit" | "command" @@ -46,6 +50,7 @@ export type ToolGroupAction = | "update"; export type ToolGroupSummaryKind = + | "pull-request" | ToolGroupAction | "dynamic-tool" | "agent-tool" @@ -60,6 +65,9 @@ const T3_MCP_TOOL_LABELS: Record< string, readonly [action: string, running: string, completed: string, detail: string] > = { + link_pull_request: ["Link", "Linking", "Linked", "a pull request"], + unlink_pull_request: ["Unlink", "Unlinking", "Unlinked", "a pull request"], + list_thread_pull_requests: ["Check", "Checking", "Checked", "linked pull requests"], orchestrator_capabilities: ["Get", "Getting", "Got", "orchestration capabilities"], delegate_task: ["Delegate", "Delegating", "Delegated", "a child task"], task_status: ["Get", "Getting", "Got", "delegated task status"], @@ -98,7 +106,17 @@ const T3_MCP_TOOL_LABELS: Record< preview_recording_stop: ["Stop", "Stopping", "Stopped", "recording the preview browser"], }; -function resolveT3McpToolPresentation(value: string | undefined, status: string | undefined) { +const PR_TOOL_ACTIONS: Readonly> = { + link_pull_request: "link-pr", + unlink_pull_request: "unlink-pr", + list_thread_pull_requests: "list-prs", +}; + +function resolveT3McpToolPresentation( + value: string | undefined, + status: string | undefined, + data?: unknown, +) { if (!value) return null; const name = normalizeCompactToolLabel(value).replace( /^(?: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 ? `Stopped ${running.toLowerCase()}` : running; + const actionKind = Object.hasOwn(PR_TOOL_ACTIONS, name) ? PR_TOOL_ACTIONS[name] : undefined; + const payload = asRecord(data); + const input = + asRecord(payload?.arguments) ?? asRecord(payload?.input) ?? asRecord(payload?.rawInput); + const urlTarget = typeof input?.url === "string" ? parseChangeRequestUrl(input.url) : null; + const number = urlTarget?.number ?? input?.number; + const target = + actionKind !== undefined && + actionKind !== "list-prs" && + typeof number === "number" && + Number.isSafeInteger(number) && + number > 0 + ? `PR #${number}` + : detail; return { - displayName: `${verb} ${detail}`, - icon: name.startsWith("preview_") ? ("browser" as const) : ("t3-code" as const), + displayName: `${verb} ${target}`, + icon: + actionKind !== undefined + ? ("pull-request" as const) + : name.startsWith("preview_") + ? ("browser" as const) + : ("t3-code" as const), + ...(actionKind === undefined ? {} : { action: actionKind }), }; } @@ -147,16 +185,16 @@ export function resolveWorkEntryToolPresentation( "tool" in data && typeof data.tool === "string" ) { - return resolveT3McpToolPresentation(`${data.server}.${data.tool}`, status); + return resolveT3McpToolPresentation(`${data.server}.${data.tool}`, status, data); } if ("toolName" in data && typeof data.toolName === "string") { - return resolveT3McpToolPresentation(data.toolName, status); + return resolveT3McpToolPresentation(data.toolName, status, data); } } return ( - resolveT3McpToolPresentation(entry.toolTitle, status) ?? - resolveT3McpToolPresentation(entry.label, status) + resolveT3McpToolPresentation(entry.toolTitle, status, data) ?? + resolveT3McpToolPresentation(entry.label, status, data) ); } @@ -410,7 +448,9 @@ export function toolGroupAction(entry: WorkLogPresentationEntry): ToolGroupActio ) { return "update"; } - if (resolveWorkEntryToolPresentation(entry)?.icon === "browser") return "browser"; + const presentation = resolveWorkEntryToolPresentation(entry); + if (presentation?.action !== undefined) return presentation.action; + if (presentation?.icon === "browser") return "browser"; if ( entry.requestKind === "file-read" || entry.itemType === "image_view" || @@ -504,6 +544,14 @@ function toolGroupActionCount( function toolGroupActionLabel(action: ToolGroupAction, count: number): string { switch (action) { + case "link-pr": + return `Linked ${count} ${count === 1 ? "pull request" : "pull requests"}`; + case "unlink-pr": + return `Unlinked ${count} ${count === 1 ? "pull request" : "pull requests"}`; + case "list-prs": + return count === 1 + ? "Checked linked pull requests" + : `Checked linked pull requests ${count} times`; case "read": return `Read ${count} ${count === 1 ? "file" : "files"}`; case "edit": @@ -528,7 +576,7 @@ export function summarizeToolGroup(entries: ReadonlyArray(); const groupedEntries = new Map(); for (const entry of summaryEntries) { - if (entry.toolSource) { + if (entry.toolSource && resolveWorkEntryToolPresentation(entry)?.icon !== "pull-request") { sources.set(entry.toolSource.key, entry.toolSource); continue; } @@ -601,6 +649,11 @@ export function omitSupersededLifecycleMarkers( export function toolGroupSummaryKind( entries: ReadonlyArray, ): ToolGroupSummaryKind { + if ( + entries.length > 0 && + entries.every((entry) => resolveWorkEntryToolPresentation(entry)?.icon === "pull-request") + ) + return "pull-request"; const actions = new Set(entries.map(toolGroupAction)); if (actions.size !== 1) return "mixed"; diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index 865afd410fa6aeb4e9e5ef7aebf443501375c824..9dcc844e713a04b092145baf9098806d9d37e93b 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -130,6 +130,7 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ threads, and routes PullRequestRef.host across projects on the same host. Same version-skew contract as threadSettlement. */ threadPullRequests: Schema.optionalKey(Schema.Boolean), + pullRequestStackActions: Schema.optionalKey(Schema.Boolean), /** The update path clients should offer for this server. Absent on servers that must be relaunched manually (dev checkouts, Windows foreground runs, pre-update servers). */ diff --git a/packages/contracts/src/pullRequest.ts b/packages/contracts/src/pullRequest.ts index 0715e12c44f7613395010777e52e3e507facee4f..44658bbc6c3e7b8f990dae08255ad589aae84d76 100644 --- a/packages/contracts/src/pullRequest.ts +++ b/packages/contracts/src/pullRequest.ts @@ -418,6 +418,7 @@ export const PullRequestCapabilities = Schema.Struct({ * the stack the host shows. Absent means chains are only ever inferred from base branches. */ stacks: Schema.optional(Schema.Boolean), + stackActions: Schema.optional(Schema.Boolean), /** * The repository's labels can be listed, and one put on a change request or taken off it. * 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; * offering one they may not use ends in the host's own refusal — which at least says why. */ export const PullRequestViewerPermissions = Schema.Struct({ + /** May request remote stack rebases, including when this layer is already current. */ + stackRebase: Schema.optional(Schema.Boolean), /** Which of the actions this viewer may take; anything absent is theirs to look at only. */ actions: Schema.Array(PullRequestAction), /** 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({ }); export type PullRequestMergeCapabilities = typeof PullRequestMergeCapabilities.Type; +export const PullRequestStackMembership = Schema.Struct({ + number: PositiveInt, + position: PositiveInt, + size: PositiveInt, + base: TrimmedNonEmptyString, +}); +export type PullRequestStackMembership = typeof PullRequestStackMembership.Type; + export const PullRequestListEntry = Schema.Struct({ + stack: Schema.optional(PullRequestStackMembership), provider: SourceControlProviderKind, /** * The host below which `repository` is addressed, so the same provider kind can serve more @@ -686,6 +698,9 @@ export const PullRequestStack = Schema.Struct({ layers: Schema.Array( Schema.Struct({ number: PositiveInt, + title: Schema.optional(Schema.String), + isDraft: Schema.optional(Schema.Boolean), + headSha: Schema.optional(TrimmedNonEmptyString), headBranch: TrimmedNonEmptyString, state: PullRequestState, }), @@ -908,7 +923,16 @@ export const PullRequestDiffFileContentsResult = Schema.Struct({ }); export type PullRequestDiffFileContentsResult = typeof PullRequestDiffFileContentsResult.Type; +export const PullRequestStackHead = Schema.Struct({ + number: PositiveInt, + headSha: TrimmedNonEmptyString, +}); +export type PullRequestStackHead = typeof PullRequestStackHead.Type; + export const PullRequestActionInput = Schema.Struct({ + /** Native stack scope; only send to environments advertising pullRequestStackActions. */ + stackNumber: Schema.optional(PositiveInt), + expectedStackHeads: Schema.optional(Schema.Array(PullRequestStackHead)), ...PullRequestRef.fields, action: PullRequestAction, /** -- 2.54.0