From 8601797233f2723d83faac7fd5677b137a0b8781 Mon Sep 17 00:00:00 2001 From: "t3-code[bot]" <269035359+t3-code[bot]@users.noreply.github.com> Date: Wed, 12 Aug 2026 14:32:10 -0400 Subject: [PATCH 001/170] fix(web): align update toast release notes link (#6322) Co-authored-by: t3-code[bot] <219304759+t3-code[bot]@users.noreply.github.com> Co-authored-by: Utkarsh Patil <73941998+UtkarshUsername@users.noreply.github.com> Co-authored-by: Exotic <118054752+extoci@users.noreply.github.com> --- apps/web/src/components/desktopUpdate.toast.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/desktopUpdate.toast.tsx b/apps/web/src/components/desktopUpdate.toast.tsx index 004a76a81cd7..4e55f3a28d12 100644 --- a/apps/web/src/components/desktopUpdate.toast.tsx +++ b/apps/web/src/components/desktopUpdate.toast.tsx @@ -18,7 +18,7 @@ function ReleaseNotesLink({ }) { return ( ); } From 770946d026208359450bf94083ad807d469d9f07 Mon Sep 17 00:00:00 2001 From: Exotic <118054752+extoci@users.noreply.github.com> Date: Wed, 12 Aug 2026 21:32:20 +0300 Subject: [PATCH 002/170] fix(web): render tooltips above dropdowns (#6241) --- apps/web/src/components/ui/tooltip.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/ui/tooltip.tsx b/apps/web/src/components/ui/tooltip.tsx index 5a891990cb1a..77b15a01e16b 100644 --- a/apps/web/src/components/ui/tooltip.tsx +++ b/apps/web/src/components/ui/tooltip.tsx @@ -33,7 +33,7 @@ function TooltipPopup({ Date: Wed, 12 Aug 2026 14:32:44 -0400 Subject: [PATCH 003/170] fix(web): open modified PR clicks in browser (#6278) Co-authored-by: t3-code[bot] <269035359+t3-code[bot]@users.noreply.github.com> Co-authored-by: maria <254055478+maria-rcks@users.noreply.github.com> --- apps/web/src/lib/openPullRequestLink.test.ts | 12 ++++++++++++ apps/web/src/lib/openPullRequestLink.ts | 12 +++++++++++- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/apps/web/src/lib/openPullRequestLink.test.ts b/apps/web/src/lib/openPullRequestLink.test.ts index a08b992bb009..9d26fa292124 100644 --- a/apps/web/src/lib/openPullRequestLink.test.ts +++ b/apps/web/src/lib/openPullRequestLink.test.ts @@ -5,6 +5,7 @@ import { openPullRequestLink, parseChangeRequestUrl, PullRequestLinkOpenError, + shouldOpenPullRequestExternally, } from "./openPullRequestLink"; describe("openPullRequestLink", () => { @@ -34,6 +35,17 @@ describe("openPullRequestLink", () => { }); }); +describe("shouldOpenPullRequestExternally", () => { + it("uses the browser for command-click and control-click", () => { + expect(shouldOpenPullRequestExternally({ metaKey: true, ctrlKey: false })).toBe(true); + expect(shouldOpenPullRequestExternally({ metaKey: false, ctrlKey: true })).toBe(true); + }); + + it("keeps an unmodified click in the pull request view", () => { + expect(shouldOpenPullRequestExternally({ metaKey: false, ctrlKey: false })).toBe(false); + }); +}); + describe("parseChangeRequestUrl", () => { it("reads a GitHub pull request", () => { expect(parseChangeRequestUrl("https://github.com/T3Tools/T3Code/pull/123")).toEqual({ diff --git a/apps/web/src/lib/openPullRequestLink.ts b/apps/web/src/lib/openPullRequestLink.ts index 951c02e4b4df..8943f907b3fd 100644 --- a/apps/web/src/lib/openPullRequestLink.ts +++ b/apps/web/src/lib/openPullRequestLink.ts @@ -173,10 +173,19 @@ export function findProjectForChangeRequest( * should still be reading it afterwards. Any change request opens there, not only the thread's * own, since the panel is told which one to show. */ +export function shouldOpenPullRequestExternally( + event: Pick, "metaKey" | "ctrlKey">, +): boolean { + return event.metaKey || event.ctrlKey; +} + export function useOpenChangeRequestLink( threadRef?: ScopedThreadRef, ): ( - event: Pick, "preventDefault" | "stopPropagation">, + event: Pick< + MouseEvent, + "preventDefault" | "stopPropagation" | "metaKey" | "ctrlKey" + >, targetUrl: string, targetThreadRef?: ScopedThreadRef, ) => boolean { @@ -186,6 +195,7 @@ export function useOpenChangeRequestLink( const primaryEnvironmentId = usePrimaryEnvironmentId(); return useCallback( (event, targetUrl, targetThreadRef) => { + if (shouldOpenPullRequestExternally(event)) return false; const resolvedThreadRef = targetThreadRef ?? threadRef; const environmentId = resolvedThreadRef?.environmentId ?? primaryEnvironmentId; if ( From 18918d1c4d0933b565d1336a75cc5069547ff5e6 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 12 Aug 2026 22:21:45 +0200 Subject: [PATCH 004/170] Fix mobile command popover glass rendering (#6370) --- .../threads/ComposerCommandPopover.tsx | 31 ++++--------------- 1 file changed, 6 insertions(+), 25 deletions(-) diff --git a/apps/mobile/src/features/threads/ComposerCommandPopover.tsx b/apps/mobile/src/features/threads/ComposerCommandPopover.tsx index ccf6a307122f..17758721f0ab 100644 --- a/apps/mobile/src/features/threads/ComposerCommandPopover.tsx +++ b/apps/mobile/src/features/threads/ComposerCommandPopover.tsx @@ -1,4 +1,3 @@ -import { isLiquidGlassSupported, LiquidGlassView } from "@callstack/liquid-glass"; import type { ComposerTriggerKind } from "@t3tools/shared/composerTrigger"; import type { ServerProviderSkill, ServerProviderSlashCommand } from "@t3tools/contracts"; import { SymbolView } from "../../components/AppSymbol"; @@ -6,6 +5,7 @@ import { memo } from "react"; import { Pressable, ScrollView, useColorScheme, View, type ViewStyle } from "react-native"; import { AppText as Text } from "../../components/AppText"; +import { GlassSurface } from "../../components/GlassSurface"; import { PierreEntryIcon } from "../../components/PierreEntryIcon"; export type ComposerCommandItem = | { @@ -56,33 +56,14 @@ function PopoverSurface(props: { ...props.style, }; - if (isLiquidGlassSupported) { - return ( - - {props.children} - - ); - } - return ( - {props.children} - + ); } From e3a9c2518d5f71ca24872cb3238c1b921c5ae5a1 Mon Sep 17 00:00:00 2001 From: Alex Date: Wed, 12 Aug 2026 13:43:18 -0700 Subject: [PATCH 005/170] test(mobile): seed snoozed showcase threads (#5155) --- scripts/mobile-showcase-environment.ts | 39 +++++++++++++++++++++++--- scripts/mobile-showcase.test.ts | 17 ++++++++++- 2 files changed, 51 insertions(+), 5 deletions(-) diff --git a/scripts/mobile-showcase-environment.ts b/scripts/mobile-showcase-environment.ts index c172f79ba09c..93738b61325a 100644 --- a/scripts/mobile-showcase-environment.ts +++ b/scripts/mobile-showcase-environment.ts @@ -198,6 +198,7 @@ export const SHOWCASE_THREADS = [ "Keep hydration errors precise, but make the development copy unexpectedly delightful.", response: "The diagnostics still lead with the exact mismatch and component stack. A tiny optional haiku now closes the expanded explanation.", + snoozeMinutes: 90, }, { id: "beautiful-boot", @@ -211,6 +212,17 @@ export const SHOWCASE_THREADS = [ response: "The plan groups milestones without changing the underlying log stream, preserves plain-text output, and adds zero work to the hot path.", }, + { + id: "patient-penguins", + projectId: "linux", + title: "Teach penguins to wait patiently", + branch: "feat/patient-penguins", + minutesAgo: 52, + request: "Make delayed work easier to follow without adding noise to the scheduler trace.", + response: + "Delayed work now carries a concise reason through the trace, so the wait is legible without changing scheduling behavior.", + snoozeMinutes: 8 * 60, + }, // Finished work, settled by hand: the list keeps it as a receded tail so // the active block above reads as everything still in flight. The active // block stays small enough that the settled tail begins above the fold — @@ -337,20 +349,29 @@ function insertThread( readonly minutesAgo: number; readonly state?: "working" | "approval" | "plan"; readonly settled?: boolean; + readonly snoozeMinutes?: number; readonly workspaceRoot: string; }, ): void { const turnId = `${input.id}-turn`; const updatedAt = minutesBefore(now, input.minutesAgo); const isWorking = input.state === "working"; + const snoozedUntil = + input.snoozeMinutes === undefined + ? null + : new Date(now + input.snoozeMinutes * 60_000).toISOString(); + const snoozedAt = + input.snoozeMinutes === undefined + ? null + : minutesBefore(now, Math.max(1, Math.floor(input.minutesAgo / 2))); database .prepare( `INSERT INTO projection_threads ( thread_id, project_id, title, model_selection_json, runtime_mode, interaction_mode, branch, worktree_path, latest_turn_id, latest_user_message_at, pending_approval_count, pending_user_input_count, has_actionable_proposed_plan, created_at, updated_at, - archived_at, deleted_at, settled_override, settled_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, NULL, NULL, ?, ?)`, + archived_at, deleted_at, settled_override, settled_at, snoozed_until, snoozed_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, NULL, NULL, ?, ?, ?, ?)`, ) .run( input.id, @@ -369,6 +390,8 @@ function insertThread( updatedAt, input.settled ? "settled" : null, input.settled ? updatedAt : null, + snoozedUntil, + snoozedAt, ); database .prepare( @@ -409,6 +432,8 @@ const SEEDED_PROJECTION_TABLES = [ "projection_state", ] as const; +const SEEDED_THREAD_COLUMNS = ["snoozed_until", "snoozed_at"] as const; + function hasSeedableSchema(dbPath: string): boolean { let database: NodeSqlite.DatabaseSync; try { @@ -417,12 +442,18 @@ function hasSeedableSchema(dbPath: string): boolean { return false; } try { - const row = database + const tableCount = database .prepare( `SELECT COUNT(*) AS count FROM sqlite_master WHERE type = 'table' AND name IN (${SEEDED_PROJECTION_TABLES.map(() => "?").join(", ")})`, ) .get(...SEEDED_PROJECTION_TABLES) as { count: number }; - return row.count === SEEDED_PROJECTION_TABLES.length; + if (tableCount.count !== SEEDED_PROJECTION_TABLES.length) return false; + + const threadColumns = database.prepare("PRAGMA table_info(projection_threads)").all() as Array<{ + name: string; + }>; + const threadColumnNames = new Set(threadColumns.map((column) => column.name)); + return SEEDED_THREAD_COLUMNS.every((column) => threadColumnNames.has(column)); } catch { return false; } finally { diff --git a/scripts/mobile-showcase.test.ts b/scripts/mobile-showcase.test.ts index 16fb3e230bb2..d061ff8f95f3 100644 --- a/scripts/mobile-showcase.test.ts +++ b/scripts/mobile-showcase.test.ts @@ -270,8 +270,23 @@ it("seeds a playful multi-environment project spectrum", () => { SHOWCASE_ENVIRONMENTS.map((environment) => environment.label), ["Moonbase Terminal", "Suspense Station", "Kernel Cabin"], ); - assert.equal(SHOWCASE_THREADS.length, 8); + assert.equal(SHOWCASE_THREADS.length, 9); assert.equal(new Set(SHOWCASE_THREADS.map((thread) => thread.projectId)).size, 3); + const snoozedThreads = SHOWCASE_THREADS.filter((thread) => "snoozeMinutes" in thread); + assert.equal(snoozedThreads.length, 2); + assert.deepStrictEqual( + snoozedThreads.map((thread) => thread.id), + ["hydration-haikus", "patient-penguins"], + ); + assert.equal(new Set(snoozedThreads.map((thread) => thread.snoozeMinutes)).size, 2); + for (const thread of snoozedThreads) { + assert.equal(thread.response !== null, true, `${thread.title} is not completed`); + assert.equal("state" in thread, false, `${thread.title} is blocked or working`); + assert.equal("settled" in thread, false, `${thread.title} is settled`); + assert.equal(thread.snoozeMinutes > 60, true, `${thread.title} wakes too soon`); + } + const primaryThread = SHOWCASE_THREADS.find((thread) => thread.id === "remote-command-center"); + assert.equal(primaryThread !== undefined && !("snoozeMinutes" in primaryThread), true); // Every project contributes to both the active block and the settled tail, // so each list scope screenshots with the same two-part structure. for (const project of SHOWCASE_PROJECTS) { From 9666b875164581b67e5d75cf26aa05753e83e49e Mon Sep 17 00:00:00 2001 From: Exotic <118054752+extoci@users.noreply.github.com> Date: Wed, 12 Aug 2026 23:43:40 +0300 Subject: [PATCH 006/170] fix(web): preserve appearance mode when changing themes (#6343) --- apps/web/src/hooks/useTheme.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/web/src/hooks/useTheme.ts b/apps/web/src/hooks/useTheme.ts index 68de5d474cae..8696e41b71d1 100644 --- a/apps/web/src/hooks/useTheme.ts +++ b/apps/web/src/hooks/useTheme.ts @@ -473,6 +473,10 @@ export function useTheme() { const setTheme = useCallback((next: Theme): boolean => { if (typeof window === "undefined") return false; try { + // Preserve the current mode before replacing a legacy or inferred theme + // preference. Otherwise a fresh System preference is re-inferred from + // the new theme's base appearance, which can switch a dark UI to light. + writeAppearanceModePreference(readAppearanceModePreference(getStored())); // Choosing a whole theme replaces any automatic-mode mix. The mix is // captured first so a failed preference write can put it back instead // of erasing it or leaving it attached to the new theme. From d0b8d6306b4686528913948b21c8a9eeda01645a Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Wed, 12 Aug 2026 22:46:23 +0200 Subject: [PATCH 007/170] feat(connect): deregister account environments from any client (#4844) Co-authored-by: Julius Marminge Co-authored-by: codex --- apps/web/src/cloud/managedRelayState.ts | 22 ++ .../components/clerk/ClerkUserProfilePage.tsx | 86 ++++++ .../clerk/MobileClientsUserProfilePage.tsx | 115 ++++---- .../clerk/T3ConnectSidebarSignIn.tsx | 10 +- .../clerk/T3ConnectUserProfilePage.test.tsx | 63 +++++ .../clerk/T3ConnectUserProfilePage.tsx | 260 ++++++++++++++++++ apps/web/src/components/ui/alert-dialog.tsx | 4 +- docs/user/remote-access.md | 11 + .../src/relay/managedRelayState.test.ts | 53 +++- .../src/relay/managedRelayState.ts | 19 ++ 10 files changed, 573 insertions(+), 70 deletions(-) create mode 100644 apps/web/src/components/clerk/ClerkUserProfilePage.tsx create mode 100644 apps/web/src/components/clerk/T3ConnectUserProfilePage.test.tsx create mode 100644 apps/web/src/components/clerk/T3ConnectUserProfilePage.tsx diff --git a/apps/web/src/cloud/managedRelayState.ts b/apps/web/src/cloud/managedRelayState.ts index 5f29c121dbcd..9a56bde88514 100644 --- a/apps/web/src/cloud/managedRelayState.ts +++ b/apps/web/src/cloud/managedRelayState.ts @@ -1,14 +1,20 @@ import { useAtomValue } from "@effect/atom-react"; import { createManagedRelayQueryManager, + deregisterManagedRelayEnvironment, ManagedRelay, managedRelaySessionAtom, readManagedRelaySnapshotState, } from "@t3tools/client-runtime/relay"; +import { + createAtomCommandScheduler, + createRuntimeCommand, +} from "@t3tools/client-runtime/state/runtime"; import type { RelayClientDeviceRecord, RelayClientEnvironmentRecord, } from "@t3tools/contracts/relay"; +import type { EnvironmentId } from "@t3tools/contracts"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -29,6 +35,22 @@ const managedRelayAtomRuntime = Atom.runtime( export const managedRelayQueryManager = createManagedRelayQueryManager(managedRelayAtomRuntime); +const managedRelayMutationScheduler = createAtomCommandScheduler(); + +export const deregisterManagedRelayEnvironmentCommand = createRuntimeCommand( + managedRelayAtomRuntime, + { + label: "web:managed-relay:deregister-environment", + scheduler: managedRelayMutationScheduler, + concurrency: { + mode: "serial", + key: (input: { readonly accountId: string; readonly environmentId: EnvironmentId }) => + input.accountId, + }, + execute: (input, registry) => deregisterManagedRelayEnvironment(registry, input), + }, +); + const EMPTY_ENVIRONMENTS_ATOM = Atom.make( AsyncResult.success>([]), ).pipe(Atom.keepAlive, Atom.withLabel("managed-relay:web:environments:null")); diff --git a/apps/web/src/components/clerk/ClerkUserProfilePage.tsx b/apps/web/src/components/clerk/ClerkUserProfilePage.tsx new file mode 100644 index 000000000000..09021aaad51c --- /dev/null +++ b/apps/web/src/components/clerk/ClerkUserProfilePage.tsx @@ -0,0 +1,86 @@ +import { RefreshCwIcon } from "lucide-react"; +import type { ReactNode } from "react"; + +import { cn } from "../../lib/utils"; +import { Button } from "../ui/button"; + +export function ClerkUserProfilePage({ + action, + children, + className, + description, + title, +}: { + readonly action?: ReactNode; + readonly children: ReactNode; + readonly className?: string; + readonly description?: ReactNode; + readonly title: ReactNode; +}) { + return ( +
+
+
+

{title}

+ {description ? ( +

+ {description} +

+ ) : null} +
+ {action ?
{action}
: null} +
+ + {children} +
+ ); +} + +export function ClerkUserProfileRefreshButton({ + className, + disabled = false, + isPending, + onClick, +}: { + readonly className?: string; + readonly disabled?: boolean; + readonly isPending: boolean; + readonly onClick: () => void; +}) { + return ( + + ); +} + +export function ClerkUserProfileRow({ + children, + className, + icon, +}: { + readonly children: ReactNode; + readonly className?: string; + readonly icon: ReactNode; +}) { + return ( +
  • +
    + +
    {children}
    +
    +
  • + ); +} diff --git a/apps/web/src/components/clerk/MobileClientsUserProfilePage.tsx b/apps/web/src/components/clerk/MobileClientsUserProfilePage.tsx index 26af10ba5b83..22449c336742 100644 --- a/apps/web/src/components/clerk/MobileClientsUserProfilePage.tsx +++ b/apps/web/src/components/clerk/MobileClientsUserProfilePage.tsx @@ -1,8 +1,7 @@ import type { RelayClientDeviceRecord } from "@t3tools/contracts/relay"; -import { RefreshCwIcon, SmartphoneIcon } from "lucide-react"; +import { SmartphoneIcon } from "lucide-react"; import { useManagedRelayDevices } from "../../cloud/managedRelayState"; -import { cn } from "../../lib/utils"; import { Badge } from "../ui/badge"; import { Button } from "../ui/button"; import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "../ui/empty"; @@ -12,6 +11,11 @@ import { mobileClientPlatformLabel, mobileClientUpdatedAtLabel, } from "./MobileClientsUserProfilePage.logic"; +import { + ClerkUserProfilePage, + ClerkUserProfileRefreshButton, + ClerkUserProfileRow, +} from "./ClerkUserProfilePage"; const MOBILE_CLIENT_SKELETON_ROWS = ["primary", "secondary"] as const; @@ -31,53 +35,47 @@ function MobileClientStatusBadge({ function MobileClientRow({ device }: { readonly device: RelayClientDeviceRecord }) { return ( -
  • -
    -
    - -
    -
    -
    -
    -

    {device.label}

    -

    {mobileClientPlatformLabel(device)}

    -
    -

    - {mobileClientUpdatedAtLabel(device.updatedAt)} -

    -
    -
    - - -
    -

    - {mobileClientNotificationDetail(device)} + }> +

    +
    +

    + {device.label} +

    +

    + {mobileClientPlatformLabel(device)}

    +

    + {mobileClientUpdatedAtLabel(device.updatedAt)} +

    -
  • +
    + + +
    +

    + {mobileClientNotificationDetail(device)} +

    + ); } function MobileClientsSkeleton() { return ( -
    +
    {MOBILE_CLIENT_SKELETON_ROWS.map((row) => ( -
    +
    - +
    - + -
    - - +
    + +
    @@ -89,13 +87,13 @@ function MobileClientsSkeleton() { function EmptyMobileClients() { return ( - - + + - No mobile clients - + No mobile clients + Sign in to T3 Code on your iPhone to register it for push notifications and Live Activities. @@ -112,29 +110,20 @@ export function MobileClientsUserProfilePage() { const hasErrorWithoutData = devicesState.error !== null && devicesState.data === null; return ( -
    -
    -
    -

    Mobile clients

    -

    - Devices registered to receive T3 Connect activity from your environments. -

    -
    - -
    - -
    + /> + } + > +
    {devicesState.error ? (
    @@ -152,7 +141,7 @@ export function MobileClientsUserProfilePage() { {isInitialLoad ? ( ) : hasErrorWithoutData ? null : devices.length > 0 ? ( -
      +
        {devices.map((device) => ( ))} @@ -161,6 +150,6 @@ export function MobileClientsUserProfilePage() { )}
    -
    + ); } diff --git a/apps/web/src/components/clerk/T3ConnectSidebarSignIn.tsx b/apps/web/src/components/clerk/T3ConnectSidebarSignIn.tsx index 51ee5aa5b328..9dfd8dce13b1 100644 --- a/apps/web/src/components/clerk/T3ConnectSidebarSignIn.tsx +++ b/apps/web/src/components/clerk/T3ConnectSidebarSignIn.tsx @@ -1,9 +1,10 @@ import { UserButton, useAuth } from "@clerk/react"; -import { LogInIcon, SmartphoneIcon } from "lucide-react"; +import { LogInIcon, ServerIcon, SmartphoneIcon } from "lucide-react"; import { hasCloudPublicConfig } from "../../cloud/publicConfig"; import { SidebarMenu, SidebarMenuButton, SidebarMenuItem } from "../ui/sidebar"; import { MobileClientsUserProfilePage } from "./MobileClientsUserProfilePage"; +import { T3ConnectUserProfilePage } from "./T3ConnectUserProfilePage"; import { useT3ConnectAuthPrompt } from "./useT3ConnectAuthPrompt"; export function T3ConnectSidebarSignIn() { @@ -39,6 +40,13 @@ function ConfiguredT3ConnectSidebarAvatar() { > + } + url="t3-connect" + > + + ); } diff --git a/apps/web/src/components/clerk/T3ConnectUserProfilePage.test.tsx b/apps/web/src/components/clerk/T3ConnectUserProfilePage.test.tsx new file mode 100644 index 000000000000..377c9c945559 --- /dev/null +++ b/apps/web/src/components/clerk/T3ConnectUserProfilePage.test.tsx @@ -0,0 +1,63 @@ +import type { EnvironmentId } from "@t3tools/contracts"; +import type { RelayClientEnvironmentRecord } from "@t3tools/contracts/relay"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import { T3ConnectEnvironmentRow } from "./T3ConnectUserProfilePage"; + +const environment: RelayClientEnvironmentRecord = { + environmentId: "environment-1" as EnvironmentId, + label: "Studio Mac", + endpoint: { + httpBaseUrl: "https://studio.example.com", + wsBaseUrl: "wss://studio.example.com", + providerKind: "cloudflare_tunnel", + }, + linkedAt: "2026-08-12T12:00:00.000Z", +}; + +function renderRow({ + confirmationOpen = false, + mutationPending = false, +}: { + readonly confirmationOpen?: boolean; + readonly mutationPending?: boolean; +} = {}) { + return renderToStaticMarkup( + , + ); +} + +describe("T3 Connect environment row", () => { + it("keeps deregistration confirmation inline and collapsed by default", () => { + const markup = renderRow(); + + expect(markup).toContain("Studio Mac"); + expect(markup).toContain("Deregister"); + expect(markup).not.toContain("Deregister server"); + expect(markup).not.toContain("Confirm deregistration of Studio Mac"); + }); + + it("expands Clerk-style confirmation content beneath the environment row", () => { + const markup = renderRow({ confirmationOpen: true }); + + expect(markup).toContain("Deregister server"); + expect(markup).toContain("“Studio Mac” will be removed from this account."); + expect(markup).toContain("Confirm deregistration of Studio Mac"); + expect(markup).toContain("Local connections on your devices are not changed."); + expect(markup).toContain("Cancel"); + }); + + it("locks the confirmation actions while deregistration is pending", () => { + const markup = renderRow({ confirmationOpen: true, mutationPending: true }); + + expect(markup).toContain("Deregistering…"); + expect(markup.match(/ disabled=""/g)).toHaveLength(3); + }); +}); diff --git a/apps/web/src/components/clerk/T3ConnectUserProfilePage.tsx b/apps/web/src/components/clerk/T3ConnectUserProfilePage.tsx new file mode 100644 index 000000000000..15ed569052be --- /dev/null +++ b/apps/web/src/components/clerk/T3ConnectUserProfilePage.tsx @@ -0,0 +1,260 @@ +import { findErrorTraceId } from "@t3tools/client-runtime/errors"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import type { EnvironmentId } from "@t3tools/contracts"; +import type { RelayClientEnvironmentRecord } from "@t3tools/contracts/relay"; +import { ServerIcon } from "lucide-react"; +import { useRef, useState } from "react"; + +import { + deregisterManagedRelayEnvironmentCommand, + useManagedRelayEnvironments, +} from "../../cloud/managedRelayState"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { Button } from "../ui/button"; +import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../ui/collapsible"; +import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "../ui/empty"; +import { toastManager } from "../ui/toast"; +import { + ClerkUserProfilePage, + ClerkUserProfileRefreshButton, + ClerkUserProfileRow, +} from "./ClerkUserProfilePage"; + +const linkedAtFormatter = new Intl.DateTimeFormat(undefined, { dateStyle: "medium" }); + +function linkedAtLabel(value: string): string { + const linkedAt = new Date(value); + return Number.isNaN(linkedAt.getTime()) + ? "Link date unavailable" + : `Linked ${linkedAtFormatter.format(linkedAt)}`; +} + +function endpointLabel(environment: RelayClientEnvironmentRecord): string { + return environment.endpoint.providerKind === "cloudflare_tunnel" + ? "Managed tunnel" + : "Activity publishing only"; +} + +export function T3ConnectEnvironmentRow(props: { + readonly environment: RelayClientEnvironmentRecord; + readonly confirmationOpen: boolean; + readonly mutationPending: boolean; + readonly onConfirmationChange: (open: boolean) => void; + readonly onDeregister: (environment: RelayClientEnvironmentRecord) => void; +}) { + const { environment } = props; + return ( + }> + +
    +
    +

    + {environment.label} +

    +

    + {linkedAtLabel(environment.linkedAt)} · {endpointLabel(environment)} +

    +
    + + Deregister + + } + /> +
    + + +
    +
    +

    + Deregister server +

    +

    + “{environment.label}” will be removed from this account. +

    +

    + T3 Connect access will be revoked, any managed tunnel will be removed, and a host + space will become available. Local connections on your devices are not changed. +

    +
    + + +
    +
    +
    +
    +
    +
    + ); +} + +export function T3ConnectUserProfilePage() { + const environmentsState = useManagedRelayEnvironments(); + const deregisterEnvironment = useAtomCommand(deregisterManagedRelayEnvironmentCommand, { + reportFailure: false, + }); + const [deregisteringEnvironmentId, setDeregisteringEnvironmentId] = + useState(null); + const [confirmingEnvironmentId, setConfirmingEnvironmentId] = useState( + null, + ); + const mutationPendingRef = useRef(false); + const [removedEnvironments, setRemovedEnvironments] = useState<{ + readonly accountId: string | null; + readonly linkedAtById: ReadonlyMap; + }>({ accountId: null, linkedAtById: new Map() }); + + const handleDeregister = async (environment: RelayClientEnvironmentRecord) => { + const accountId = environmentsState.accountId; + if (!accountId || mutationPendingRef.current) return; + + mutationPendingRef.current = true; + setDeregisteringEnvironmentId(environment.environmentId); + const result = await deregisterEnvironment({ + accountId, + environmentId: environment.environmentId, + }); + mutationPendingRef.current = false; + setDeregisteringEnvironmentId(null); + + if (result._tag === "Success") { + setConfirmingEnvironmentId(null); + setRemovedEnvironments((current) => { + const linkedAtById = new Map(current.accountId === accountId ? current.linkedAtById : []); + linkedAtById.set(environment.environmentId, environment.linkedAt); + return { accountId, linkedAtById }; + }); + environmentsState.refresh(); + toastManager.add({ + type: "success", + title: "Server deregistered", + description: "T3 Connect access was revoked and a host space is now available.", + }); + return; + } + if (isAtomCommandInterrupted(result)) return; + + const cause = squashAtomCommandFailure(result); + const message = cause instanceof Error ? cause.message : "Could not deregister the server."; + const traceId = findErrorTraceId(cause); + console.error("[t3-connect] Could not deregister environment", { + environmentId: environment.environmentId, + message, + traceId, + cause, + }); + toastManager.add({ + type: "error", + title: "Could not deregister server", + description: message, + data: traceId + ? { + secondaryActionProps: { + children: "Copy trace ID", + onClick: () => void navigator.clipboard?.writeText(traceId), + }, + } + : undefined, + }); + }; + + const removedEnvironmentLinkedAt = + removedEnvironments.accountId === environmentsState.accountId + ? removedEnvironments.linkedAtById + : new Map(); + const environments = (environmentsState.data ?? []).filter( + (environment) => + removedEnvironmentLinkedAt.get(environment.environmentId) !== environment.linkedAt, + ); + const isInitialLoad = + !environmentsState.accountId || (environmentsState.data === null && !environmentsState.error); + + return ( + + } + > +
    + {environmentsState.error ? ( +
    +

    + Could not load T3 Connect environments +

    +

    {environmentsState.error}

    +
    + ) : null} + + {isInitialLoad ? ( +

    + Loading environments… +

    + ) : environments.length > 0 ? ( +
      + {environments.map((environment) => ( + + setConfirmingEnvironmentId(open ? environment.environmentId : null) + } + onDeregister={(selected) => void handleDeregister(selected)} + /> + ))} +
    + ) : environmentsState.error ? null : ( + + + + + + + No T3 Connect environments + + + Link an environment from its local Settings to make it available through T3 Connect. + + + + )} +
    +
    + ); +} diff --git a/apps/web/src/components/ui/alert-dialog.tsx b/apps/web/src/components/ui/alert-dialog.tsx index 006c3f9e93c6..4f57e920118b 100644 --- a/apps/web/src/components/ui/alert-dialog.tsx +++ b/apps/web/src/components/ui/alert-dialog.tsx @@ -46,12 +46,14 @@ function AlertDialogViewport({ className, ...props }: AlertDialogPrimitive.Viewp function AlertDialogPopup({ className, bottomStickOnMobile = true, + portalContainer, ...props }: AlertDialogPrimitive.Popup.Props & { bottomStickOnMobile?: boolean; + portalContainer?: AlertDialogPrimitive.Portal.Props["container"]; }) { return ( - + , - onQueryEvent?: (event: ManagedRelayQueryEvent) => void, -) { - const client = ManagedRelay.ManagedRelayClient.of({ +function createClient(overrides?: Partial) { + return ManagedRelay.ManagedRelayClient.of({ relayUrl: "https://relay.example.test", listEnvironments: () => Effect.succeed([environment]), listDevices: () => Effect.succeed([device]), @@ -87,6 +86,13 @@ function createManager( resetTokenCache: Effect.void, ...overrides, }); +} + +function createManager( + overrides?: Partial, + onQueryEvent?: (event: ManagedRelayQueryEvent) => void, +) { + const client = createClient(overrides); const runtime = Atom.runtime(Layer.succeed(ManagedRelay.ManagedRelayClient, client)); return createManagedRelayQueryManager(runtime, { staleTimeMs: 60_000, @@ -121,6 +127,43 @@ describe("createManagedRelayQueryManager", () => { }), ); + it.effect("deregisters an environment through the current Clerk session", () => + Effect.gen(function* () { + const unlinkEnvironment = vi.fn(() => Effect.succeed({ ok: true })); + setSession(); + + yield* deregisterManagedRelayEnvironment(registry, { + accountId: "account-1", + environmentId: environment.environmentId, + }).pipe( + Effect.provideService(ManagedRelay.ManagedRelayClient, createClient({ unlinkEnvironment })), + ); + + expect(unlinkEnvironment).toHaveBeenCalledWith({ + clerkToken: "clerk-token", + environmentId: environment.environmentId, + }); + }), + ); + + it.effect("rejects deregistration after the account changes", () => + Effect.gen(function* () { + const unlinkEnvironment = vi.fn(() => Effect.succeed({ ok: true })); + setSession(); + + const error = yield* deregisterManagedRelayEnvironment(registry, { + accountId: "previous-account", + environmentId: environment.environmentId, + }).pipe( + Effect.provideService(ManagedRelay.ManagedRelayClient, createClient({ unlinkEnvironment })), + Effect.flip, + ); + + expect(error).toBeInstanceOf(ManagedRelaySessionError); + expect(unlinkEnvironment).not.toHaveBeenCalled(); + }), + ); + it.effect("deduplicates concurrent Clerk token reads and reuses the token until JWT expiry", () => Effect.gen(function* () { const token = clerkToken(4_102_444_800); diff --git a/packages/client-runtime/src/relay/managedRelayState.ts b/packages/client-runtime/src/relay/managedRelayState.ts index 1d12c90aae5a..1a3a22efb204 100644 --- a/packages/client-runtime/src/relay/managedRelayState.ts +++ b/packages/client-runtime/src/relay/managedRelayState.ts @@ -2,6 +2,7 @@ import type { RelayClientEnvironmentRecord, RelayEnvironmentStatusResponse, } from "@t3tools/contracts/relay"; +import type { EnvironmentId } from "@t3tools/contracts"; import { RelayEnvironmentConnectScope, RelayEnvironmentStatusScope, @@ -218,6 +219,24 @@ export const waitForManagedRelayClerkToken = Effect.fn( }); }); +/** Removes an environment from the signed-in account without contacting that environment. */ +export const deregisterManagedRelayEnvironment = Effect.fn( + "clientRuntime.managedRelaySession.deregisterEnvironment", +)(function* ( + registry: AtomRegistry.AtomRegistry, + input: { readonly accountId: string; readonly environmentId: EnvironmentId }, +) { + const session = registry.get(managedRelaySessionAtom); + if (!session || session.accountId !== input.accountId) { + return yield* new ManagedRelaySessionError({ + message: "Sign in to T3 Connect before deregistering an environment.", + }); + } + const clerkToken = yield* readSessionClerkToken(session); + const relay = yield* ManagedRelay.ManagedRelayClient; + yield* relay.unlinkEnvironment({ clerkToken, environmentId: input.environmentId }); +}); + function requireClerkToken( get: Atom.AtomContext, accountId: string, From b28f9bf0a1bd562623c027c5ed80b5ca50395b28 Mon Sep 17 00:00:00 2001 From: Bilal Bakr <62337003+Bil0000@users.noreply.github.com> Date: Wed, 12 Aug 2026 23:58:36 +0300 Subject: [PATCH 008/170] =?UTF-8?q?feat(web):=20pull=20request=20surfaces?= =?UTF-8?q?=20=E2=80=94=20filters=20&=20qualifiers,=20all-server=20listing?= =?UTF-8?q?,=20update=20branch,=20reactions,=20in-place=20editing,=20smart?= =?UTF-8?q?er=20diffs=20(#6039)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Julius Marminge --- apps/server/src/auth/RpcAuthorization.ts | 3 + apps/server/src/git/GitManager.test.ts | 54 + apps/server/src/git/GitManager.ts | 11 + .../AzureDevOpsPullRequestCli.test.ts | 106 ++ .../pullRequest/AzureDevOpsPullRequestCli.ts | 40 + .../AzureDevOpsPullRequestProvider.test.ts | 10 +- .../AzureDevOpsPullRequestProvider.ts | 28 +- .../BitbucketPullRequestApi.test.ts | 68 ++ .../pullRequest/BitbucketPullRequestApi.ts | 47 + .../BitbucketPullRequestProvider.ts | 35 + .../pullRequest/GitHubPullRequestCli.test.ts | 630 +++++++++++- .../src/pullRequest/GitHubPullRequestCli.ts | 425 +++++++- .../GitHubPullRequestProvider.test.ts | 232 ++++- .../pullRequest/GitHubPullRequestProvider.ts | 155 ++- .../pullRequest/GitLabPullRequestCli.test.ts | 256 +++++ .../src/pullRequest/GitLabPullRequestCli.ts | 261 ++++- .../GitLabPullRequestProvider.test.ts | 165 +++- .../pullRequest/GitLabPullRequestProvider.ts | 115 ++- .../src/pullRequest/PullRequestProvider.ts | 77 ++ .../pullRequest/PullRequestService.test.ts | 924 +++++++++++++++++- .../src/pullRequest/PullRequestService.ts | 373 ++++++- .../azureDevOpsPullRequestJson.test.ts | 15 + .../pullRequest/azureDevOpsPullRequestJson.ts | 9 + .../bitbucketPullRequestJson.test.ts | 16 + .../pullRequest/bitbucketPullRequestJson.ts | 26 +- .../pullRequest/gitHubPullRequestJson.test.ts | 380 +++++++ .../src/pullRequest/gitHubPullRequestJson.ts | 666 ++++++++++++- .../gitLabMergeRequestJson.test.ts | 155 +++ .../src/pullRequest/gitLabMergeRequestJson.ts | 242 +++++ .../src/pullRequest/pullRequestChecks.test.ts | 93 ++ .../src/pullRequest/pullRequestChecks.ts | 55 ++ .../src/sourceControl/GitHubCli.test.ts | 30 + apps/server/src/sourceControl/GitHubCli.ts | 17 + apps/server/src/sourceControl/GitLabCli.ts | 1 + apps/server/src/vcs/VcsProcess.test.ts | 25 + apps/server/src/vcs/VcsProcess.ts | 8 + apps/server/src/ws.ts | 16 + apps/web/src/components/ChatMarkdown.tsx | 3 +- apps/web/src/components/ChatView.tsx | 39 +- .../chat/externalLinkContextMenu.test.ts | 19 + .../chat/externalLinkContextMenu.ts | 19 +- .../components/diffs/StyledDiffCodeView.tsx | 10 +- .../pullRequest/PullRequestChecksPopover.tsx | 134 +++ .../pullRequest/PullRequestCodeTab.tsx | 113 ++- .../pullRequest/PullRequestDetailPanel.tsx | 618 ++++++++++-- .../PullRequestListFilters.test.tsx | 76 +- .../pullRequest/PullRequestListFilters.tsx | 170 +++- .../pullRequest/PullRequestMarkdownEditor.tsx | 113 +++ .../pullRequest/PullRequestReactions.tsx | 178 ++++ .../PullRequestReviewAnnotation.tsx | 82 +- .../components/pullRequest/PullRequestRow.tsx | 59 +- .../pullRequest/PullRequestSummaryTab.tsx | 388 +++++++- .../pullRequest/PullRequestTimelineTab.tsx | 127 ++- .../pullRequest/pullRequestChecks.test.tsx | 85 ++ .../pullRequestDetail.logic.test.ts | 160 ++- .../pullRequest/pullRequestDetail.logic.ts | 114 +++ .../pullRequestEditing.logic.test.ts | 172 ++++ .../pullRequest/pullRequestEditing.logic.ts | 41 + .../pullRequestFileOrder.logic.test.ts | 167 ++++ .../pullRequest/pullRequestFileOrder.logic.ts | 197 ++++ .../pullRequestLinkContextMenu.test.ts | 20 + .../pullRequest/pullRequestLinkContextMenu.ts | 68 ++ .../pullRequest/pullRequestList.logic.test.ts | 590 ++++++++++- .../pullRequest/pullRequestList.logic.ts | 507 +++++++++- .../pullRequest/pullRequestPresentation.tsx | 47 + ...pullRequestProjectAssignment.logic.test.ts | 259 +++++ .../pullRequestProjectAssignment.logic.ts | 130 +++ .../pullRequestReactions.logic.test.ts | 210 ++++ .../pullRequest/pullRequestReactions.logic.ts | 108 ++ .../pullRequestSummaryScroll.logic.test.ts | 49 + .../pullRequestSummaryScroll.logic.ts | 20 + .../src/components/sidebar/SidebarChrome.tsx | 11 +- apps/web/src/lib/openPullRequestLink.ts | 34 +- apps/web/src/rightPanelStore.test.ts | 122 +++ apps/web/src/rightPanelStore.ts | 43 +- apps/web/src/routes/_chat.pull-requests.tsx | 834 ++++++++++++---- apps/web/src/state/pullRequests.ts | 124 +++ apps/web/src/state/query.ts | 4 +- docs/user/source-control.md | 8 + .../client-runtime/src/state/pullRequests.ts | 18 + .../contracts/src/environmentHttp.test.ts | 62 ++ packages/contracts/src/environmentHttp.ts | 24 + packages/contracts/src/pullRequest.test.ts | 77 ++ packages/contracts/src/pullRequest.ts | 285 +++++- packages/contracts/src/rpc.ts | 27 + packages/contracts/src/vcs.ts | 17 +- 86 files changed, 11929 insertions(+), 622 deletions(-) create mode 100644 apps/server/src/pullRequest/pullRequestChecks.test.ts create mode 100644 apps/server/src/pullRequest/pullRequestChecks.ts create mode 100644 apps/web/src/components/pullRequest/PullRequestChecksPopover.tsx create mode 100644 apps/web/src/components/pullRequest/PullRequestMarkdownEditor.tsx create mode 100644 apps/web/src/components/pullRequest/PullRequestReactions.tsx create mode 100644 apps/web/src/components/pullRequest/pullRequestChecks.test.tsx create mode 100644 apps/web/src/components/pullRequest/pullRequestEditing.logic.test.ts create mode 100644 apps/web/src/components/pullRequest/pullRequestEditing.logic.ts create mode 100644 apps/web/src/components/pullRequest/pullRequestFileOrder.logic.test.ts create mode 100644 apps/web/src/components/pullRequest/pullRequestFileOrder.logic.ts create mode 100644 apps/web/src/components/pullRequest/pullRequestLinkContextMenu.test.ts create mode 100644 apps/web/src/components/pullRequest/pullRequestLinkContextMenu.ts create mode 100644 apps/web/src/components/pullRequest/pullRequestProjectAssignment.logic.test.ts create mode 100644 apps/web/src/components/pullRequest/pullRequestProjectAssignment.logic.ts create mode 100644 apps/web/src/components/pullRequest/pullRequestReactions.logic.test.ts create mode 100644 apps/web/src/components/pullRequest/pullRequestReactions.logic.ts create mode 100644 apps/web/src/components/pullRequest/pullRequestSummaryScroll.logic.test.ts create mode 100644 apps/web/src/components/pullRequest/pullRequestSummaryScroll.logic.ts create mode 100644 packages/contracts/src/environmentHttp.test.ts diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 34853209dbd0..36f348d6370a 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -58,10 +58,13 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.pullRequestsActivity]: AuthOrchestrationReadScope, [WS_METHODS.pullRequestsDiffFileContents]: AuthOrchestrationReadScope, [WS_METHODS.pullRequestsRunAction]: AuthOrchestrationOperateScope, + [WS_METHODS.pullRequestsUpdate]: AuthOrchestrationOperateScope, [WS_METHODS.pullRequestsComment]: AuthOrchestrationOperateScope, + [WS_METHODS.pullRequestsUpdateComment]: AuthOrchestrationOperateScope, [WS_METHODS.pullRequestsSubmitReview]: AuthOrchestrationOperateScope, [WS_METHODS.pullRequestsReplyToThread]: AuthOrchestrationOperateScope, [WS_METHODS.pullRequestsSetThreadResolution]: AuthOrchestrationOperateScope, + [WS_METHODS.pullRequestsSetReaction]: AuthOrchestrationOperateScope, // Read scope like the reads it un-caches: refreshing is part of reading, and a read-only // client pressing refresh must not be told it may not look again. [WS_METHODS.pullRequestsInvalidate]: AuthOrchestrationReadScope, diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index 7cc252d3bf28..a5f8fa659f93 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -9,8 +9,10 @@ import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Logger from "effect/Logger"; import * as Option from "effect/Option"; import * as PlatformError from "effect/PlatformError"; +import * as References from "effect/References"; import * as Scope from "effect/Scope"; import { ChildProcessSpawner } from "effect/unstable/process"; import { expect } from "vite-plus/test"; @@ -1386,6 +1388,58 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); + it.effect("status logs actionable provider detail without exposing the upstream cause", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["checkout", "-b", "feature/status-rate-limited"]); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/status-rate-limited"]); + + const upstreamCause = "GraphQL rate limit for user ID 51714798 and token secret-value"; + const { manager } = yield* makeManager({ + ghScenario: { + failWith: new GitHubCli.GitHubCliRateLimitError({ + command: "gh", + cwd: repoDir, + cause: new Error(upstreamCause), + }), + }, + }); + const logs: Array<{ message: string; annotations: Record }> = []; + const logger = Logger.make(({ fiber, message }) => { + logs.push({ + message: String(message), + annotations: { ...fiber.getRef(References.CurrentLogAnnotations) }, + }); + }); + + const status = yield* manager + .status({ cwd: repoDir }) + .pipe(Effect.provide(Logger.layer([logger], { mergeWithExisting: false }))); + + expect(status.pr).toBeNull(); + const warning = logs.find((entry) => entry.message.includes("PR lookup failed")); + expect(warning?.annotations).toMatchObject({ + operation: "lookupStatusPr", + branch: "feature/status-rate-limited", + errorTag: "SourceControlProviderError", + provider: "github", + providerOperation: "listChangeRequests", + providerCommand: "gh", + errorDetail: + "GitHub API rate limit exceeded. Run `gh api rate_limit` to inspect the quota and reset time.", + }); + const loggedText = [ + warning?.message ?? "", + ...Object.values(warning?.annotations ?? {}).map(String), + ].join("\n"); + expect(loggedText).not.toContain(upstreamCause); + expect(loggedText).not.toContain("secret-value"); + }), + ); + it.effect("status keeps the last known PR when a later lookup fails", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index b4d1240c6a28..bed5a8839a7c 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -12,6 +12,7 @@ import * as Option from "effect/Option"; import * as Order from "effect/Order"; import * as Path from "effect/Path"; import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; import { GitActionProgressEvent, GitActionProgressPhase, @@ -28,6 +29,7 @@ import { type VcsStatusRemoteResult, VcsStatusResult, ModelSelection, + SourceControlProviderError, type SourceControlWritingStyleSettings, } from "@t3tools/contracts"; import { @@ -113,6 +115,7 @@ const PR_LOOKUP_CACHE_TTL = Duration.minutes(2); const PR_LOOKUP_FAILURE_BASE_TTL = Duration.seconds(20); const PR_LOOKUP_FAILURE_MAX_TTL = Duration.minutes(15); const PR_LOOKUP_CACHE_CAPACITY = 2_048; +const isSourceControlProviderError = Schema.is(SourceControlProviderError); /** * How long a failed PR lookup is cached, given the number of consecutive @@ -1048,6 +1051,14 @@ export const make = Effect.gen(function* () { typeof error === "object" && error !== null && "_tag" in error ? String(error._tag) : typeof error, + ...(isSourceControlProviderError(error) + ? { + provider: error.provider, + providerOperation: error.operation, + providerCommand: error.command ?? "unknown", + errorDetail: error.detail, + } + : {}), }), Effect.andThen(resolveBranchHeadContext(cwd, details)), Effect.map((headContext) => diff --git a/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.test.ts b/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.test.ts index b52b6d497d69..5baf18a1ff6a 100644 --- a/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.test.ts +++ b/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.test.ts @@ -342,7 +342,40 @@ layer("AzureDevOpsPullRequestCli.layer", (it) => { }), ); + it.effect("stores the squash choice with an auto-completion, as a merge now does", () => + Effect.gen(function* () { + mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); + const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; + + yield* cli.runPullRequestAction({ + cwd: "/w", + number: 42, + action: "enable-auto-merge", + mergeMethod: "squash", + }); + + expect(argsOfCall(0)).toEqual([ + "repos", + "pr", + "update", + "--detect", + "true", + "--id", + "42", + "--auto-complete", + "true", + "--squash", + "true", + "--only-show-errors", + "--output", + "json", + ]); + }), + ); + it.effect.each([ + { action: "enable-auto-merge", expected: ["--auto-complete", "true", "--squash", "false"] }, + { action: "disable-auto-merge", expected: ["--auto-complete", "false"] }, { action: "draft", expected: ["--draft", "true"] }, { action: "ready", expected: ["--draft", "false"] }, { action: "close", expected: ["--status", "abandoned"] }, @@ -370,6 +403,79 @@ layer("AzureDevOpsPullRequestCli.layer", (it) => { }), ); + it.effect.each([ + { name: "a title", rewrite: { title: "Add the page" }, expected: ["--title=Add the page"] }, + { + name: "a description", + rewrite: { body: "Why the page changed" }, + expected: ["--description=Why the page changed"], + }, + { + name: "both", + rewrite: { title: "Add the page", body: "Why the page changed" }, + expected: ["--title=Add the page", "--description=Why the page changed"], + }, + ] as const)("rewrites $name, sending nothing it was not given", ({ rewrite, expected }) => + Effect.gen(function* () { + mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); + const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; + + yield* cli.updatePullRequest({ cwd: "/w", number: 42, ...rewrite }); + + expect(argsOfCall(0)).toEqual([ + "repos", + "pr", + "update", + "--detect", + "true", + "--id", + "42", + ...expected, + "--only-show-errors", + "--output", + "json", + ]); + }), + ); + + it.effect("sends a description that starts with a dash as one value, not as a flag", () => + Effect.gen(function* () { + mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); + const cli = yield* AzureDevOpsPullRequestCli.AzureDevOpsPullRequestCli; + + yield* cli.updatePullRequest({ + cwd: "/w", + number: 42, + body: "- rewrote the page\n- kept the rest", + }); + + // One argument, so the leading dash of an ordinary bullet list never reaches az as a flag, + // and the whole text stays together where `--description` would otherwise take several. + expect(argsOfCall(0)).toContain("--description=- rewrote the page\n- kept the rest"); + }), + ); + + it.effect("rewrites through the provider, which says it takes one", () => + Effect.gen(function* () { + mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); + const provider = yield* AzureDevOpsPullRequestProvider.make; + + // False for a remark because nothing here can post one, so there is none to rewrite. + expect(provider.capabilities.edit).toEqual({ changeRequest: true, comment: false }); + assert.isDefined(provider.updateChangeRequest); + yield* provider.updateChangeRequest({ + cwd: "/w", + repository: "web", + host: "dev.azure.com", + number: 42, + title: "Add the page", + }); + + expect(argsOfCall(0)).toContain("--title=Add the page"); + expect(argsOfCall(0)).not.toContain("--description"); + }), + ); + it.effect("reads the conversation through the REST API, pinned to a version", () => Effect.gen(function* () { mockedExecute.mockReturnValueOnce( diff --git a/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.ts b/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.ts index 43f929163db1..549a172b3646 100644 --- a/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.ts +++ b/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.ts @@ -159,6 +159,14 @@ export class AzureDevOpsPullRequestCli extends Context.Service< readonly mergeMethod?: PullRequestMergeMethod; }) => Effect.Effect; + /** Rewrites the pull request's own words, through the same command that moves it. */ + readonly updatePullRequest: (input: { + readonly cwd: string; + readonly number: number; + readonly title?: string | undefined; + readonly body?: string | undefined; + }) => Effect.Effect; + /** * Adds reviewers to a pull request, or takes them off it. `az repos pr reviewer` is the whole * of what Azure offers here: it adds and removes named identities, and has no counterpart that @@ -212,12 +220,21 @@ function actionArgs( switch (action) { case "merge": return ["--status", "completed", "--squash", mergeMethod === "squash" ? "true" : "false"]; + // Auto-complete is Azure's own name for it: the pull request stays active and Azure completes + // it once its policies pass. The squash choice is stored with it, as it is for a merge now. + case "enable-auto-merge": + return ["--auto-complete", "true", "--squash", mergeMethod === "squash" ? "true" : "false"]; + case "disable-auto-merge": + return ["--auto-complete", "false"]; case "ready": return ["--draft", "false"]; case "draft": return ["--draft", "true"]; case "close": return ["--status", "abandoned"]; + // Never reached: this host does not declare the action, so nothing offers it. + case "update-branch": + return []; case "reopen": return ["--status", "active"]; } @@ -481,6 +498,29 @@ export const make = Effect.gen(function* () { ], }) .pipe(Effect.asVoid), + + updatePullRequest: (input) => + azure + .execute({ + cwd: input.cwd, + args: [ + "repos", + "pr", + "update", + ...detectArgs, + "--id", + String(input.number), + // One argument rather than a flag and a value beside it: a description usually opens + // with a bullet, and az reads a dash in the next argv slot as a flag of its own. + // `--description` also takes several strings, and this keeps the whole text as one. + ...(input.title === undefined ? [] : [`--title=${input.title}`]), + ...(input.body === undefined ? [] : [`--description=${input.body}`]), + "--only-show-errors", + "--output", + "json", + ], + }) + .pipe(Effect.asVoid), }); }); diff --git a/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.test.ts b/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.test.ts index cce581ce6c9b..51d8f74bbc45 100644 --- a/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.test.ts +++ b/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.test.ts @@ -9,7 +9,15 @@ describe("azure devops viewer permissions", () => { // and an unknown permission is granted rather than guessed away. Azure refuses the ones it // will not allow, at the moment they are taken, in words this could not have written. expect(AZURE_DEVOPS_VIEWER_PERMISSIONS).toEqual({ - actions: ["merge", "ready", "draft", "close", "reopen"], + actions: [ + "merge", + "ready", + "draft", + "close", + "reopen", + "enable-auto-merge", + "disable-auto-merge", + ], // False because the host itself cannot post one, not because this viewer may not. comment: false, resolve: false, diff --git a/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts b/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts index 5d8f7a093e31..5607b0cd4f5e 100644 --- a/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts +++ b/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts @@ -18,11 +18,20 @@ const CAPABILITIES: PullRequestCapabilities = { // Reading a conversation is a plain REST read, but posting one is not something this can // claim without having run it, so the composer stays hidden. comment: false, - actions: ["merge", "ready", "draft", "close", "reopen"], + actions: [ + "merge", + "ready", + "draft", + "close", + "reopen", + "enable-auto-merge", + "disable-auto-merge", + ], // Azure squashes as a completion option; it has no rebase strategy of its own. mergeMethods: ["merge", "squash"], // `az repos pr list` filters by status, creator, reviewer and branch, and by no text at all. search: false, + reactions: false, // With no patch to show there are no lines to write against, so nothing here is offered. review: { inlineComment: false, reply: false, resolve: false, verdicts: [] }, // `az repos pr reviewer add` and `remove` name identities, and nothing anywhere in `az repos` @@ -30,6 +39,10 @@ const CAPABILITIES: PullRequestCapabilities = { // different service with its own permissions. So the page takes a name here rather than being // handed a menu built out of a guess. reviewers: { request: true, listCandidates: false }, + // A new title and description travel on the same `az repos pr update` that moves a pull request. + // Rewriting a remark is false for the same reason posting one is: this cannot put a remark on + // Azure DevOps at all, so there is nothing here it could rewrite either. + edit: { changeRequest: true, comment: false }, }; /** @@ -153,6 +166,7 @@ export const make = Effect.gen(function* () { checks: [], mergeCapabilities: { merge: true, squash: true, rebase: false }, viewerPermissions: AZURE_DEVOPS_VIEWER_PERMISSIONS, + autoMergeEnabled: pullRequest.autoMergeEnabled, }), ), ), @@ -206,6 +220,16 @@ export const make = Effect.gen(function* () { }) .pipe(Effect.mapError(fail("runAction"))), + updateChangeRequest: (input) => + cli + .updatePullRequest({ + cwd: input.cwd, + number: input.number, + title: input.title, + body: input.body, + }) + .pipe(Effect.mapError(fail("updateChangeRequest"))), + // Never called: `capabilities.reviewers.listCandidates` is false, and the service refuses the // list without it. listReviewerCandidates: () => @@ -240,6 +264,8 @@ export const make = Effect.gen(function* () { replyToThread: () => unsupported("replyToThread"), setThreadResolution: () => unsupported("setThreadResolution"), + + setReaction: () => unsupported("setReaction"), }; return provider; diff --git a/apps/server/src/pullRequest/BitbucketPullRequestApi.test.ts b/apps/server/src/pullRequest/BitbucketPullRequestApi.test.ts index 1248b396956c..f57bb67a4c40 100644 --- a/apps/server/src/pullRequest/BitbucketPullRequestApi.test.ts +++ b/apps/server/src/pullRequest/BitbucketPullRequestApi.test.ts @@ -510,6 +510,74 @@ layer("BitbucketPullRequestApi.layer", (it) => { }), ); + it.effect("rewrites a title alone, without touching anything else", () => + Effect.gen(function* () { + mockedRequest.mockReturnValue(Effect.succeed(response("{}"))); + const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; + + yield* api.updateChangeRequest({ repository: "acme/web", number: 7, title: "A new title" }); + + const call = callAt(0); + expect(call.method).toBe("PUT"); + expect(call.url).toBe("/repositories/acme/web/pullrequests/7"); + // Bitbucket's PUT is a partial update, so a field left out of the body is left as it was. + // @effect-diagnostics-next-line preferSchemaOverJson:off + expect(JSON.parse(call.body ?? "")).toEqual({ title: "A new title" }); + }), + ); + + it.effect("leaves out the half of the pull request it was not asked about", () => + Effect.gen(function* () { + mockedRequest.mockReturnValue(Effect.succeed(response("{}"))); + const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; + + yield* api.updateChangeRequest({ repository: "acme/web", number: 7, body: "New body." }); + + // @effect-diagnostics-next-line preferSchemaOverJson:off + expect(JSON.parse(callAt(0).body ?? "")).toEqual({ description: "New body." }); + }), + ); + + it.effect("writes both fields when both were rewritten", () => + Effect.gen(function* () { + mockedRequest.mockReturnValue(Effect.succeed(response("{}"))); + const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; + + yield* api.updateChangeRequest({ + repository: "acme/web", + number: 7, + title: "A new title", + body: "New body.", + }); + + // @effect-diagnostics-next-line preferSchemaOverJson:off + expect(JSON.parse(callAt(0).body ?? "")).toEqual({ + title: "A new title", + description: "New body.", + }); + }), + ); + + it.effect("rewrites a comment where it stands, whichever kind it is", () => + Effect.gen(function* () { + mockedRequest.mockReturnValue(Effect.succeed(response("{}"))); + const api = yield* BitbucketPullRequestApi.BitbucketPullRequestApi; + + yield* api.updateComment({ + repository: "acme/web", + number: 7, + commentId: "10", + body: "Edited.", + }); + + expect(callAt(0)).toMatchObject({ + method: "PUT", + url: "/repositories/acme/web/pullrequests/7/comments/10", + body: '{"content":{"raw":"Edited."}}', + }); + }), + ); + it.effect("fails the read when Bitbucket answers with something unreadable", () => Effect.gen(function* () { mockedRequest.mockReturnValueOnce( diff --git a/apps/server/src/pullRequest/BitbucketPullRequestApi.ts b/apps/server/src/pullRequest/BitbucketPullRequestApi.ts index 7c0a7d117448..a20d4aaaa056 100644 --- a/apps/server/src/pullRequest/BitbucketPullRequestApi.ts +++ b/apps/server/src/pullRequest/BitbucketPullRequestApi.ts @@ -222,12 +222,26 @@ export class BitbucketPullRequestApi extends Context.Service< readonly mergeMethod?: PullRequestMergeMethod; }) => Effect.Effect; + readonly updateChangeRequest: (input: { + readonly repository: string; + readonly number: number; + readonly title?: string | undefined; + readonly body?: string | undefined; + }) => Effect.Effect; + readonly comment: (input: { readonly repository: string; readonly number: number; readonly body: string; }) => Effect.Effect; + readonly updateComment: (input: { + readonly repository: string; + readonly number: number; + readonly commentId: string; + readonly body: string; + }) => Effect.Effect; + readonly submitReview: (input: { readonly repository: string; readonly number: number; @@ -701,6 +715,24 @@ export const make = Effect.gen(function* () { .pipe(Effect.asVoid); }), + updateChangeRequest: (input) => + withRepository(input.repository, (path) => + // Only the words this call rewrites travel in the body: as `setReviewerRequest` above + // relies on, Bitbucket's PUT is a partial update, so any field left out is left as it + // was — sending `reviewers` back here would overwrite a change another user made to it + // between this call being issued and the request landing. + bitbucket + .request({ + method: "PUT", + url: `${path}/pullrequests/${input.number}`, + body: JSON.stringify({ + ...(input.title === undefined ? {} : { title: input.title }), + ...(input.body === undefined ? {} : { description: input.body }), + }), + }) + .pipe(Effect.asVoid), + ), + comment: (input) => withRepository(input.repository, (path) => bitbucket @@ -713,6 +745,21 @@ export const make = Effect.gen(function* () { .pipe(Effect.asVoid), ), + updateComment: (input) => + withRepository(input.repository, (path) => + bitbucket + .request({ + // Bitbucket keeps a pull request's remarks and its line comments in the one + // collection, so this endpoint rewrites either kind. + method: "PUT", + url: `${path}/pullrequests/${input.number}/comments/${encodeURIComponent( + input.commentId, + )}`, + body: JSON.stringify({ content: { raw: input.body } }), + }) + .pipe(Effect.asVoid), + ), + submitReview: (input) => withRepository(input.repository, (path) => Effect.gen(function* () { diff --git a/apps/server/src/pullRequest/BitbucketPullRequestProvider.ts b/apps/server/src/pullRequest/BitbucketPullRequestProvider.ts index 8a0ea9806920..00e558588efa 100644 --- a/apps/server/src/pullRequest/BitbucketPullRequestProvider.ts +++ b/apps/server/src/pullRequest/BitbucketPullRequestProvider.ts @@ -19,6 +19,9 @@ const CAPABILITIES: PullRequestCapabilities = { actions: ["merge", "close"], mergeMethods: ["merge", "squash", "rebase"], search: true, + // Bitbucket Cloud's API exposes no reaction on a pull request or on a comment, so none is + // read and none is offered. + reactions: false, review: { inlineComment: true, reply: true, @@ -26,6 +29,7 @@ const CAPABILITIES: PullRequestCapabilities = { verdicts: ["comment", "approve", "request-changes"], }, reviewers: { request: true, listCandidates: true }, + edit: { changeRequest: true, comment: true }, }; /** @@ -249,11 +253,31 @@ export const make = Effect.gen(function* () { }) .pipe(Effect.mapError(fail("runAction"))), + updateChangeRequest: (input) => + api + .updateChangeRequest({ + repository: input.repository, + number: input.number, + title: input.title, + body: input.body, + }) + .pipe(Effect.mapError(fail("updateChangeRequest"))), + comment: (input) => api .comment({ repository: input.repository, number: input.number, body: input.body }) .pipe(Effect.mapError(fail("comment"))), + updateComment: (input) => + api + .updateComment({ + repository: input.repository, + number: input.number, + commentId: input.commentId, + body: input.body, + }) + .pipe(Effect.mapError(fail("updateComment"))), + submitReview: (input) => api .submitReview({ @@ -275,6 +299,17 @@ export const make = Effect.gen(function* () { }) .pipe(Effect.mapError(fail("replyToThread"))), + // Never called: `capabilities.reactions` is false, and the service refuses without it. + setReaction: () => + Effect.fail( + new PullRequestProviderError({ + provider: "bitbucket", + operation: "setReaction", + reason: "failed", + detail: "Bitbucket does not support reactions.", + }), + ), + setThreadResolution: (input) => api .setCommentResolution({ diff --git a/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts b/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts index bf7e8951afe2..848c4cd5ebc3 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts @@ -5,6 +5,7 @@ import { ChildProcessSpawner } from "effect/unstable/process"; import * as GitHubCli from "../sourceControl/GitHubCli.ts"; import * as GitHubPullRequestCli from "./GitHubPullRequestCli.ts"; +import { BASE_COMPARISON_GRAPHQL_QUERY } from "./gitHubPullRequestJson.ts"; const mockedExecute = vi.fn(); @@ -551,6 +552,206 @@ layer("GitHubPullRequestCli.layer", (it) => { }), ); + it.effect("carries the further narrowings into the search as qualifiers", () => + Effect.gen(function* () { + mockedExecute.mockReturnValue(Effect.succeed(output("[]"))); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + yield* cli.listPullRequests({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + state: "open", + involvement: "all", + viewer: "bilal", + limit: 10, + filters: { + draft: "hide", + review: "changes-requested", + checks: "failing", + labels: [["needs design"], ['quo"te']], + excludedLabels: ["wip"], + author: "octocat", + }, + }); + + // Quotes around anything a reader typed, and the one character that could end a quoted + // value early dropped rather than escaped. + expect(searchOfCall(0)).toBe( + 'label:"needs design" label:"quote" -label:"wip" author:"octocat" draft:false ' + + "review:changes_requested status:failure sort:updated-desc", + ); + }), + ); + + it.effect('resolves an author filter of "me" to the viewer, not the literal word', () => + Effect.gen(function* () { + mockedExecute.mockReturnValue(Effect.succeed(output("[]"))); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + yield* cli.listPullRequests({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + state: "open", + involvement: "all", + viewer: "bilal", + limit: 10, + filters: { author: "me" }, + }); + + expect(searchOfCall(0)).toBe('author:"bilal" sort:updated-desc'); + }), + ); + + it.effect("sends one label qualifier per group, its names joined the way GitHub ors them", () => + Effect.gen(function* () { + mockedExecute.mockReturnValue(Effect.succeed(output("[]"))); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + yield* cli.listPullRequests({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + state: "open", + involvement: "all", + viewer: "bilal", + limit: 10, + filters: { labels: [["size:S", "size:XS"], ["bug"]] }, + }); + + // One qualifier satisfied by either size, and a second one that must hold as well. + expect(searchOfCall(0)).toBe('label:"size:S","size:XS" label:"bug" sort:updated-desc'); + expect(callAt(0).args).toContain('label:"size:S","size:XS" label:"bug" sort:updated-desc'); + }), + ); + + it.effect( + "falls back for a repository the index does not cover under a checks filter, keeping only the matching rows", + () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]"))); + mockedExecute.mockReturnValueOnce( + Effect.succeed( + output( + pullRequests(2, 1, (number) => ({ + statusCheckRollup: + number === 1 + ? [{ name: "lint", status: "COMPLETED", conclusion: "SUCCESS" }] + : [{ name: "test", status: "COMPLETED", conclusion: "FAILURE" }], + })), + ), + ), + ); + 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: 10, + filters: { checks: "passing" }, + }); + + // The fallback's rows carry `checksState` exactly as a search's rows do, so `checks` is + // now a filter the fallback judges itself, the same as `draft`: an empty search answer + // under it is still ambiguous, and the row picked out afterwards is the one whose own + // `checksState` reads "passing". + expect(searchOfCall(1)).toBeUndefined(); + assert.deepStrictEqual( + batch.items.map((item) => item.number), + [1], + ); + }), + ); + + it.effect("fails a checks filter for a row whose checks are still pending", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]"))); + mockedExecute.mockReturnValueOnce( + Effect.succeed( + output( + pullRequests(1, 1, () => ({ + statusCheckRollup: [{ name: "build", status: "IN_PROGRESS" }], + })), + ), + ), + ); + 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: 10, + filters: { checks: "passing" }, + }); + + // Pending equals neither "passing" nor "failing", so it satisfies neither filter value — + // the same row would also be dropped by `checks: "failing"`. + assert.deepStrictEqual(batch.items, []); + }), + ); + + it.effect( + "falls back for a repository the index does not cover even under a judgeable filter", + () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]"))); + mockedExecute.mockReturnValueOnce( + Effect.succeed(output(pullRequests(2, 1, (number) => ({ isDraft: number === 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: 10, + filters: { draft: "hide" }, + }); + + // `draft` is a filter the fallback can judge over its own rows just as search judges it, + // so an empty search answer under it alone is still ambiguous between "nothing matches" + // and "this repository is not indexed" — and the fallback applies the filter itself, + // keeping only the non-draft row. + expect(searchOfCall(1)).toBeUndefined(); + expect(batch.items.map((item) => item.number)).toEqual([2]); + }), + ); + + it.effect("carries the further narrowings into a batched search", () => + Effect.gen(function* () { + mockedExecute.mockReturnValue(Effect.succeed(searchPage([]))); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + yield* cli.searchPullRequests({ + cwd: "/w", + host: "github.com", + repositories: ["acme/web"], + state: "open", + involvement: "all", + viewer: "bilal", + limit: 10, + filters: { draft: "only", review: "none", labels: [["bug"]] }, + }); + + assert.strictEqual( + searchQueryOfCall(0), + 'is:pr is:open label:"bug" draft:true review:none sort:updated-desc repo:acme/web', + ); + }), + ); + it.effect("quotes a search, so it cannot add a qualifier or a flag of its own", () => Effect.gen(function* () { mockedExecute.mockReturnValue(Effect.succeed(output("[]"))); @@ -819,6 +1020,40 @@ layer("GitHubPullRequestCli.layer", (it) => { }), ); + it.effect("updates a stale branch with a merge commit unless asked to rebase", () => + Effect.gen(function* () { + mockedExecute.mockReturnValue(Effect.succeed(output(""))); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + yield* cli.runPullRequestAction({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + action: "update-branch", + }); + // GitHub's own default, and `gh`'s: a merge commit unless the rebase flag says otherwise. + expect(callAt(0).args).toEqual(["pr", "update-branch", "7", "--repo", "github.com/acme/web"]); + + yield* cli.runPullRequestAction({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + action: "update-branch", + updateMethod: "rebase", + }); + expect(callAt(1).args).toEqual([ + "pr", + "update-branch", + "7", + "--repo", + "github.com/acme/web", + "--rebase", + ]); + }), + ); + it.effect("merges with the strategy it was asked for", () => Effect.gen(function* () { mockedExecute.mockReturnValue(Effect.succeed(output(""))); @@ -844,6 +1079,74 @@ layer("GitHubPullRequestCli.layer", (it) => { }), ); + it.effect("arms auto-merge with the same strategy a merge would have used", () => + Effect.gen(function* () { + mockedExecute.mockReturnValue(Effect.succeed(output(""))); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + yield* cli.runPullRequestAction({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + action: "enable-auto-merge", + mergeMethod: "squash", + }); + expect(callAt(0).args).toEqual([ + "pr", + "merge", + "7", + "--repo", + "github.com/acme/web", + "--auto", + "--squash", + ]); + + // No strategy asked for is GitHub's own default, exactly as it is for a merge now. + yield* cli.runPullRequestAction({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + action: "enable-auto-merge", + }); + expect(callAt(1).args).toEqual([ + "pr", + "merge", + "7", + "--repo", + "github.com/acme/web", + "--auto", + "--merge", + ]); + }), + ); + + it.effect("takes auto-merge back off without naming a strategy", () => + Effect.gen(function* () { + mockedExecute.mockReturnValue(Effect.succeed(output(""))); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + yield* cli.runPullRequestAction({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + action: "disable-auto-merge", + mergeMethod: "squash", + }); + + expect(callAt(0).args).toEqual([ + "pr", + "merge", + "7", + "--repo", + "github.com/acme/web", + "--disable-auto", + ]); + }), + ); + it.effect("returns a pull request to draft by undoing ready", () => Effect.gen(function* () { mockedExecute.mockReturnValue(Effect.succeed(output(""))); @@ -1406,6 +1709,279 @@ layer("GitHubPullRequestCli.layer", (it) => { }), ); + it.effect("confirms a given subject belongs to the named pull request, then reacts to it", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce( + Effect.succeed( + output( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ + data: { + repository: { pullRequest: { id: "PR_kwDOA" } }, + node: { id: "IC_1", pullRequest: { id: "PR_kwDOA" } }, + }, + }), + ), + ), + ); + mockedExecute.mockReturnValueOnce(Effect.succeed(output("{}"))); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + yield* cli.setReaction({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + subjectId: "IC_1", + content: "heart", + reacted: true, + }); + + assert.strictEqual(mockedExecute.mock.calls.length, 2); + const scopeCheck = callAt(0).args; + expect(scopeCheck).toContain("owner=acme"); + expect(scopeCheck).toContain("name=web"); + expect(scopeCheck).toContain("number=7"); + expect(scopeCheck).toContain("subjectId=IC_1"); + // @effect-diagnostics-next-line preferSchemaOverJson:off + const request = JSON.parse(callAt(1).stdin ?? "") as { + query: string; + variables: Record; + }; + expect(request.query).toContain("addReaction("); + expect(request.variables).toEqual({ subjectId: "IC_1", content: "HEART" }); + }), + ); + + it.effect("refuses a given subject that belongs to a different pull request", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce( + Effect.succeed( + output( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ + data: { + repository: { pullRequest: { id: "PR_thisOne" } }, + // A comment on pull request #99 of a different repository, named as though it + // belonged to #7 here. + node: { id: "IC_99", pullRequest: { id: "PR_someOtherOne" } }, + }, + }), + ), + ), + ); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + const error = yield* Effect.flip( + cli.setReaction({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + subjectId: "IC_99", + content: "heart", + reacted: true, + }), + ); + + assert.strictEqual(error._tag, "GitHubSubjectScopeError"); + // Refused before any mutation was sent. + assert.strictEqual(mockedExecute.mock.calls.length, 1); + }), + ); + + it.effect("looks up the pull request's own node id when no subject was given", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce( + Effect.succeed( + output( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ data: { repository: { pullRequest: { id: "PR_kwDOA" } } } }), + ), + ), + ); + mockedExecute.mockReturnValueOnce(Effect.succeed(output("{}"))); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + yield* cli.setReaction({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + content: "rocket", + reacted: true, + }); + + assert.strictEqual(mockedExecute.mock.calls.length, 2); + const lookup = callAt(0).args; + expect(lookup).toContain("owner=acme"); + expect(lookup).toContain("name=web"); + expect(lookup).toContain("number=7"); + // @effect-diagnostics-next-line preferSchemaOverJson:off + const request = JSON.parse(callAt(1).stdin ?? "") as { + query: string; + variables: Record; + }; + expect(request.query).toContain("addReaction("); + expect(request.variables).toEqual({ subjectId: "PR_kwDOA", content: "ROCKET" }); + }), + ); + + it.effect("takes a reaction back through the remove mutation", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce( + Effect.succeed( + output( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ + data: { + repository: { pullRequest: { id: "PR_kwDOA" } }, + node: { id: "IC_1", pullRequest: { id: "PR_kwDOA" } }, + }, + }), + ), + ), + ); + mockedExecute.mockReturnValueOnce(Effect.succeed(output("{}"))); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + yield* cli.setReaction({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + subjectId: "IC_1", + content: "heart", + reacted: false, + }); + + // @effect-diagnostics-next-line preferSchemaOverJson:off + const request = JSON.parse(callAt(1).stdin ?? "") as { query: string }; + expect(request.query).toContain("removeReaction("); + }), + ); + + it.effect("rewrites only the words a request named", () => + Effect.gen(function* () { + mockedExecute.mockReturnValue( + Effect.succeed( + output( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ data: { repository: { pullRequest: { id: "PR_kwDOA" } } } }), + ), + ), + ); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const rewrite = (fields: { readonly title?: string; readonly body?: string }) => + cli.updatePullRequest({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + ...fields, + }); + + yield* rewrite({ title: "A better title" }); + yield* rewrite({ body: "A better description." }); + yield* rewrite({ title: "Both", body: "at once." }); + + // Each rewrite looks the pull request's node id up first, then mutates. + const variablesAt = (index: number) => + (JSON.parse(callAt(index).stdin ?? "") as { variables: Record }).variables; + expect(variablesAt(1)).toEqual({ pullRequestId: "PR_kwDOA", title: "A better title" }); + expect(variablesAt(3)).toEqual({ + pullRequestId: "PR_kwDOA", + body: "A better description.", + }); + expect(variablesAt(5)).toEqual({ + pullRequestId: "PR_kwDOA", + title: "Both", + body: "at once.", + }); + // The reader's own words, so they travel the way every other body does. + expect(callAt(5).args.join(" ")).not.toContain("at once."); + }), + ); + + it.effect("rewrites a remark through the mutation its kind needs", () => + Effect.gen(function* () { + mockedExecute.mockReturnValue( + Effect.succeed( + output( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ + data: { + repository: { pullRequest: { id: "PR_kwDOA" } }, + node: { id: "IC_1", pullRequest: { id: "PR_kwDOA" } }, + }, + }), + ), + ), + ); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const rewrite = (kind: "issue-comment" | "review-comment") => + cli.updateComment({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + commentId: "IC_1", + kind, + body: "Reworded.", + }); + + yield* rewrite("issue-comment"); + yield* rewrite("review-comment"); + + const parse = (index: number) => + JSON.parse(callAt(index).stdin ?? "") as { + query: string; + variables: Record; + }; + expect(callAt(0).args).toContain("subjectId=IC_1"); + expect(parse(1).query).toContain("updateIssueComment("); + expect(parse(1).variables).toEqual({ commentId: "IC_1", body: "Reworded." }); + expect(parse(3).query).toContain("updatePullRequestReviewComment("); + expect(parse(3).variables).toEqual({ commentId: "IC_1", body: "Reworded." }); + }), + ); + + it.effect("refuses a comment that belongs to a different pull request", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce( + Effect.succeed( + output( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ + data: { + repository: { pullRequest: { id: "PR_thisOne" } }, + node: { id: "IC_99", pullRequest: { id: "PR_someOtherOne" } }, + }, + }), + ), + ), + ); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + const error = yield* Effect.flip( + cli.updateComment({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + commentId: "IC_99", + kind: "issue-comment", + body: "Reworded.", + }), + ); + + assert.strictEqual(error._tag, "GitHubSubjectScopeError"); + expect(error.message).toContain("updateComment"); + // Refused before any mutation was sent. + assert.strictEqual(mockedExecute.mock.calls.length, 1); + }), + ); + it.effect("fails the read when gh returns something unreadable", () => Effect.gen(function* () { mockedExecute.mockReturnValueOnce(Effect.succeed(output('{"message":"not found"}'))); @@ -1472,7 +2048,7 @@ layer("GitHubPullRequestCli.layer", (it) => { expect(detail.body).toBe("Core body"); expect(activity.author?.login).toBe("octocat"); expect(callAt(0).args.at(-1)).toBe( - "number,title,url,author,headRefName,baseRefName,state,isDraft,mergeable,additions,deletions,createdAt,updatedAt,mergedAt,reviewRequests,labels,body,changedFiles,closedAt,statusCheckRollup", + "number,title,url,author,headRefName,baseRefName,state,isDraft,mergeable,reviewDecision,additions,deletions,createdAt,updatedAt,mergedAt,reviewRequests,labels,statusCheckRollup,body,changedFiles,closedAt,headRepositoryOwner,autoMergeRequest", ); expect(callAt(1).args.at(-1)).toBe("author,comments,reviews,commits"); }), @@ -1634,6 +2210,58 @@ layer("GitHubPullRequestCli.layer", (it) => { }), ); + it.effect("sends the base comparison's variables as gh flags, not as bare words", () => + Effect.gen(function* () { + mockedExecute.mockReturnValue( + Effect.succeed( + output( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ + data: { + repository: { + pullRequest: { + viewerCanUpdateBranch: true, + baseRef: { compare: { behindBy: 4 } }, + }, + }, + }, + }), + ), + ), + ); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + const comparison = yield* cli.getPullRequestBaseComparison({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + headRef: "fork:feat/page", + }); + + // The tuples are flattened straight into argv, so a variable without its flag is a + // positional argument gh refuses outright. + const args = callAt(0).args; + expect(args).toEqual([ + "api", + "graphql", + "--hostname", + "github.com", + "-f", + "owner=acme", + "-f", + "name=web", + "-F", + "number=7", + "-f", + "headRef=fork:feat/page", + "-f", + `query=${BASE_COMPARISON_GRAPHQL_QUERY}`, + ]); + expect(comparison).toEqual({ behindBy: 4, viewerCanUpdate: true }); + }), + ); + it.effect("reads the viewer's role off the same call as the merge settings", () => Effect.gen(function* () { mockedExecute.mockReturnValue( diff --git a/apps/server/src/pullRequest/GitHubPullRequestCli.ts b/apps/server/src/pullRequest/GitHubPullRequestCli.ts index 392fac8564f9..27402c2115ad 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestCli.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestCli.ts @@ -3,22 +3,29 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; -import type { - PullRequestAction, - PullRequestActor, - PullRequestInvolvement, - PullRequestListState, - PullRequestMergeMethod, - PullRequestReviewCommentDraft, - PullRequestReviewVerdict, - PullRequestReviewerCandidateList, - PullRequestReviewerKind, - PullRequestThreadComment, +import { + resolvePullRequestAuthorFilter, + type PullRequestAction, + type PullRequestActor, + type PullRequestInvolvement, + type PullRequestListFilters, + type PullRequestListState, + type PullRequestMergeMethod, + type PullRequestOmittedFileStat, + type PullRequestReaction, + type PullRequestReactionContent, + type PullRequestReviewCommentDraft, + type PullRequestReviewVerdict, + type PullRequestReviewerCandidateList, + type PullRequestReviewerKind, + type PullRequestThreadComment, + type PullRequestUpdateMethod, } from "@t3tools/contracts"; import * as GitHubCli from "../sourceControl/GitHubCli.ts"; import { ACTOR_AVATARS_GRAPHQL_QUERY, + ADD_REACTION_GRAPHQL_MUTATION, buildReviewSubmissionJson, buildReviewerRequestJson, decodeActorAvatarsJson, @@ -26,10 +33,13 @@ import { decodePullRequestDetailJson, decodePullRequestFilesJson, decodePullRequestListJson, + decodePullRequestNodeIdJson, decodePullRequestSearchJson, decodePullRequestStatsJson, + decodeReactionSubjectScopeJson, decodeRepositoryAccessJson, decodeReviewerCandidatesJson, + decodeReviewDismissalsJson, decodeReviewThreadCommentsJson, decodeReviewThreadsJson, buildPullRequestStatsGraphQlQuery, @@ -37,18 +47,29 @@ import { pullRequestSearchGraphQlQuery, PULL_REQUEST_SEARCH_MAX_ROWS, PULL_REQUEST_ACTIVITY_JSON_FIELDS, + BASE_COMPARISON_GRAPHQL_QUERY, + decodeBaseComparisonJson, PULL_REQUEST_DETAIL_JSON_FIELDS, PULL_REQUEST_LIST_JSON_FIELDS, + PULL_REQUEST_NODE_ID_GRAPHQL_QUERY, + REACTION_SUBJECT_PULL_REQUEST_GRAPHQL_QUERY, + REMOVE_REACTION_GRAPHQL_MUTATION, + gitHubReactionContent, REPOSITORY_ACCESS_JSON_FIELDS, RESOLVE_REVIEW_THREAD_GRAPHQL_MUTATION, REVIEWER_CANDIDATES_GRAPHQL_QUERY, REVIEW_THREAD_COMMENTS_GRAPHQL_QUERY, + REVIEW_DISMISSALS_GRAPHQL_QUERY, REVIEW_THREAD_REPLY_GRAPHQL_MUTATION, REVIEW_THREADS_GRAPHQL_QUERY, reviewThreadConversation, UNRESOLVE_REVIEW_THREAD_GRAPHQL_MUTATION, + UPDATE_ISSUE_COMMENT_GRAPHQL_MUTATION, + UPDATE_PULL_REQUEST_GRAPHQL_MUTATION, + UPDATE_REVIEW_COMMENT_GRAPHQL_MUTATION, VIEWER_PERMISSIONS_GRAPHQL_QUERY, decodeViewerPermissionsJson, + type GitHubBaseComparison, type GitHubPullRequestDetail, type GitHubPullRequestActivity, type GitHubPullRequestListItem, @@ -199,6 +220,24 @@ export class GitHubRepositorySelectorError extends Schema.TaggedErrorClass()( + "GitHubSubjectScopeError", + { + command: Schema.Literal("gh"), + cwd: Schema.String, + operation: Schema.String, + }, +) { + get detail(): string { + return "The named subject did not belong to the named pull request."; + } + + override get message(): string { + return `GitHub CLI failed in ${this.operation}: ${this.detail}`; + } +} + export type GitHubPullRequestCliError = | GitHubCli.GitHubCliError | GitHubPullRequestReadError @@ -207,6 +246,7 @@ export type GitHubPullRequestCliError = | GitHubDiffRevisionsUnavailableError | GitHubDiffFileContentsUnavailableError | GitHubRepositorySelectorError + | GitHubSubjectScopeError | GitHubViewerLoginUnavailableError; /** A large pull request can produce a multi-megabyte patch; past this it is truncated. */ @@ -270,6 +310,8 @@ export interface GitHubPullRequestDiffSlice { readonly truncated: boolean; /** Where the next slice starts, or null once the patch is whole. */ readonly nextCursor: string | null; + /** GitHub's own counts for the files whose hunks it withheld from this slice. */ + readonly omittedFileStats?: ReadonlyArray; } export class GitHubPullRequestCli extends Context.Service< @@ -291,6 +333,8 @@ export class GitHubPullRequestCli extends Context.Service< readonly query?: string | undefined; /** Where to carry on from, as a `updated:` qualifier on the same search. */ readonly cursor?: ProviderListCursor | undefined; + /** Further narrowings, as qualifiers on the search and as a local pass on the fallback. */ + readonly filters?: PullRequestListFilters | undefined; }) => Effect.Effect; /** @@ -309,6 +353,7 @@ export class GitHubPullRequestCli extends Context.Service< readonly limit: number; readonly query?: string | undefined; readonly cursor?: ProviderListCursor | undefined; + readonly filters?: PullRequestListFilters | undefined; }) => Effect.Effect; /** The line counts the search leaves out, for rows already on the page. */ @@ -328,6 +373,20 @@ export class GitHubPullRequestCli extends Context.Service< readonly number: number; }) => Effect.Effect; + /** + * How far the branch trails its base, and whether this viewer may update it. Its own read + * because the comparison needs the head ref the detail answers with — a fork's branch is not + * addressable in the base repository by name alone. + */ + readonly getPullRequestBaseComparison: (input: { + readonly cwd: string; + readonly repository: string; + readonly host: string; + readonly number: number; + /** Qualified `owner:branch`, which is the only form a fork's head resolves under. */ + readonly headRef: string; + }) => Effect.Effect; + readonly getPullRequestActivity: (input: { readonly cwd: string; readonly repository: string; @@ -418,6 +477,7 @@ export class GitHubPullRequestCli extends Context.Service< readonly number: number; readonly action: PullRequestAction; readonly mergeMethod?: PullRequestMergeMethod; + readonly updateMethod?: PullRequestUpdateMethod; }) => Effect.Effect; readonly commentOnPullRequest: (input: { @@ -453,6 +513,47 @@ export class GitHubPullRequestCli extends Context.Service< readonly threadId: string; readonly resolved: boolean; }) => Effect.Effect; + + /** + * Adds a reaction to a remark, or takes it back. `subjectId` is any node GitHub calls + * reactable — a comment, a review, or the pull request itself, which is looked up here + * because nothing in the conversation names it. A given `subjectId` is confirmed to belong + * to this pull request before the mutation runs, since nothing else ties the two together. + */ + readonly setReaction: (input: { + readonly cwd: string; + readonly repository: string; + readonly host: string; + readonly number: number; + readonly subjectId?: string | undefined; + readonly content: PullRequestReactionContent; + readonly reacted: boolean; + }) => Effect.Effect; + + /** Rewrites the pull request's own words, leaving whichever of the two was not given. */ + readonly updatePullRequest: (input: { + readonly cwd: string; + readonly repository: string; + readonly host: string; + readonly number: number; + readonly title?: string | undefined; + readonly body?: string | undefined; + }) => Effect.Effect; + + /** + * Rewrites a remark. `commentId` is trusted to be whatever node it names, so it is confirmed + * to belong to this pull request before the mutation runs, the way a reaction subject is. + * Whether the remark is the reader's to rewrite is GitHub's own answer, not one asked here. + */ + readonly updateComment: (input: { + readonly cwd: string; + readonly repository: string; + readonly host: string; + readonly number: number; + readonly commentId: string; + readonly kind: "issue-comment" | "review-comment"; + readonly body: string; + }) => Effect.Effect; } >()("t3/pullRequest/GitHubPullRequestCli") {} @@ -501,6 +602,76 @@ function searchPhrase(query: string): string { return `"${query.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`; } +/** GitHub's own spelling of a review state, which is not the contract's. */ +const REVIEW_QUALIFIERS = { + approved: "approved", + "changes-requested": "changes_requested", + "review-required": "required", + none: "none", +} as const; + +/** + * The extra narrowings as GitHub search qualifiers. Values a reader typed are quoted, and the + * one character that could end the quoted value early is dropped rather than escaped: no GitHub + * label or login holds a double quote, so there is nothing to preserve and everything to lose. + */ +function qualifierValue(value: string): string { + return `"${value.replaceAll('"', "").trim()}"`; +} + +function filterQualifiers( + filters: PullRequestListFilters | undefined, + viewer: string, +): ReadonlyArray { + if (filters === undefined) return []; + return [ + // One qualifier per group, its names joined by commas — GitHub's own OR. + ...(filters.labels ?? []).flatMap((group) => + group.length === 0 ? [] : [`label:${group.map(qualifierValue).join(",")}`], + ), + ...(filters.excludedLabels ?? []).map((label) => `-label:${qualifierValue(label)}`), + ...(filters.author === undefined + ? [] + : [`author:${qualifierValue(resolvePullRequestAuthorFilter(filters.author, viewer))}`]), + ...(filters.draft === undefined ? [] : [`draft:${filters.draft === "only"}`]), + ...(filters.review === undefined ? [] : [`review:${REVIEW_QUALIFIERS[filters.review]}`]), + ...(filters.checks === undefined + ? [] + : [`status:${filters.checks === "passing" ? "success" : "failure"}`]), + ]; +} + +/** + * The same narrowings over a row that has already arrived, for the search-free fallback. Every + * listed row now carries its own `checksState`, so `checks` is judged the way `review` is: by + * equality against the row's field. Unlike `review`, `checks` has no `"none"` value to catch an + * absent state on purpose — a row with no checks configured, or whose checks are still `pending`, + * equals neither `"passing"` nor `"failing"` and so fails both, the same as a row search would + * not have surfaced for `status:success` or `status:failure`. + */ +function matchesFilters( + item: GitHubPullRequestListItem, + filters: PullRequestListFilters | undefined, + viewer: string, +): boolean { + if (filters === undefined) return true; + const labels = item.labels.map((label) => label.name.trim().toLowerCase()); + const holds = (label: string) => labels.includes(label.trim().toLowerCase()); + return ( + (filters.draft === undefined || item.isDraft === (filters.draft === "only")) && + (filters.review === undefined || + (filters.review === "none" + ? item.reviewDecision === null + : item.reviewDecision === filters.review)) && + (filters.checks === undefined || item.checksState === filters.checks) && + (filters.labels === undefined || filters.labels.every((group) => group.some(holds))) && + (filters.excludedLabels === undefined || !filters.excludedLabels.some(holds)) && + (filters.author === undefined || + item.author?.login.toLowerCase() === + resolvePullRequestAuthorFilter(filters.author, viewer).toLowerCase()) + ); +} + function involvementArgs(input: { readonly state: PullRequestListState; readonly involvement: PullRequestInvolvement; @@ -513,6 +684,7 @@ function involvementArgs(input: { * cannot use search at all and takes whatever order `gh pr list` answers in. */ readonly sorted: boolean; + readonly filters?: PullRequestListFilters | undefined; }): ReadonlyArray { // `--state closed` includes merged pull requests, so the Closed tab additionally excludes // them through search; `--author` and `review-requested:` are GitHub's own filters. `gh` @@ -531,6 +703,7 @@ function involvementArgs(input: { // sharing one instant are ordinary and the caller drops the ones it has already sent — // asking for strictly older would lose the rest of them instead. ...(input.cursor === undefined ? [] : [`updated:<=${input.cursor.updatedBefore}`]), + ...filterQualifiers(input.filters, input.viewer), // `gh pr list` answers newest-created first, which is not the order the page reads rows in // and not an order a continuation can carry on from: a change request opened last year and // touched this morning belongs at the top of the list and at the front of the first slice. @@ -550,6 +723,7 @@ function matchesUnsortedListing( readonly state: PullRequestListState; readonly involvement: PullRequestInvolvement; readonly viewer: string; + readonly filters?: PullRequestListFilters | undefined; }, ): boolean { const matchesState = input.state === "all" || item.state === input.state; @@ -560,7 +734,7 @@ function matchesUnsortedListing( ? item.author?.login.toLowerCase() === viewer : item.hasTeamReviewRequest || item.reviewRequestLogins.some((login) => login.toLowerCase() === viewer)); - return matchesState && matchesInvolvement; + return matchesState && matchesInvolvement && matchesFilters(item, input.filters, input.viewer); } /** What a repository selector may hold before it goes into a search as itself. */ @@ -587,6 +761,7 @@ function searchQuery(input: { readonly viewer: string; readonly query?: string | undefined; readonly cursor?: ProviderListCursor | undefined; + readonly filters?: PullRequestListFilters | undefined; }): string | null { if (input.repositories.length === 0) return null; const repositories = input.repositories.map((repository) => repository.trim()); @@ -603,6 +778,7 @@ function searchQuery(input: { ...(query.length === 0 ? [] : [searchPhrase(query)]), // Inclusive, and de-duplicated by the caller, for the reason the per-repository read gives. ...(input.cursor === undefined ? [] : [`updated:<=${input.cursor.updatedBefore}`]), + ...filterQualifiers(input.filters, input.viewer), // The order the page reads its rows in, and the only order a continuation can carry on from. "sort:updated-desc", ...repositories.map((repository) => `repo:${repository}`), @@ -621,10 +797,20 @@ function cursorVariable(cursor: string | null): readonly [string, string] { function actionArgs( action: PullRequestAction, mergeMethod: PullRequestMergeMethod | undefined, + updateMethod: PullRequestUpdateMethod | undefined, ): ReadonlyArray { switch (action) { case "merge": return ["merge", `--${mergeMethod ?? "merge"}`]; + // `--auto` arms the same command instead of running it, and still needs the strategy: GitHub + // stores the strategy with the standing instruction rather than choosing one at merge time. + case "enable-auto-merge": + return ["merge", "--auto", `--${mergeMethod ?? "merge"}`]; + case "disable-auto-merge": + return ["merge", "--disable-auto"]; + // `gh` updates with a merge commit unless asked to rebase, which is GitHub's own default. + case "update-branch": + return ["update-branch", ...(updateMethod === "rebase" ? ["--rebase"] : [])]; case "ready": return ["ready"]; case "draft": @@ -639,6 +825,62 @@ function actionArgs( export const make = Effect.gen(function* () { const github = yield* GitHubCli.GitHubCli; + /** + * The pull request's own node id, which is what a mutation against the pull request itself is + * addressed by: a reaction on its description, or a rewrite of its words. + */ + const pullRequestNodeId = (input: { + readonly cwd: string; + readonly repository: string; + readonly host: string; + readonly number: number; + readonly operation: string; + }) => { + const { owner, name } = parseRepositorySelector(input.repository); + return graphqlRead({ + cwd: input.cwd, + host: input.host, + operation: input.operation, + variables: [ + ["-f", `owner=${owner}`], + ["-f", `name=${name}`], + ["-F", `number=${input.number}`], + ], + query: PULL_REQUEST_NODE_ID_GRAPHQL_QUERY, + decode: decodePullRequestNodeIdJson, + }); + }; + + /** + * Whether a client-given subject actually belongs to the pull request the request names. A + * subject id is trusted to be whatever node it names, and that node can hang off any pull + * request on the host — so the mutation itself would write wherever the id actually belongs, + * not wherever the request says it does, unless this confirms the two agree first. + */ + const subjectBelongsToPullRequest = (input: { + readonly cwd: string; + readonly repository: string; + readonly host: string; + readonly number: number; + readonly subjectId: string; + readonly operation: string; + }) => { + const { owner, name } = parseRepositorySelector(input.repository); + return graphqlRead({ + cwd: input.cwd, + host: input.host, + operation: input.operation, + variables: [ + ["-f", `owner=${owner}`], + ["-f", `name=${name}`], + ["-F", `number=${input.number}`], + ["-f", `subjectId=${input.subjectId}`], + ], + query: REACTION_SUBJECT_PULL_REQUEST_GRAPHQL_QUERY, + decode: decodeReactionSubjectScopeJson, + }); + }; + // `gh` resolves a bare `owner/repo` against whichever host it defaults to, which is // github.com. Naming the host makes a GitHub Enterprise repository resolve to its own // install rather than to a same-named repository on github.com. @@ -796,6 +1038,9 @@ export const make = Effect.gen(function* () { patch: decoded.success.patch, truncated: decoded.success.truncated, nextCursor: morePages ? String(input.page + 1) : null, + ...(decoded.success.omittedFileStats.length === 0 + ? {} + : { omittedFileStats: decoded.success.omittedFileStats }), }); }), ); @@ -977,10 +1222,15 @@ export const make = Effect.gen(function* () { // repository's whole list, which is every row the reader did not search for. The fallback // is for a repository the index does not cover, and a listing with no text to match is the // only place an empty answer can mean that. - const searched = (input.query?.trim().length ?? 0) > 0; + // Every filter is a qualifier `matchesFilters` can judge over the fallback's own rows just + // as well as search judges them over its own, so carrying them into the fallback answers + // the same read rather than a wider one. Free text is the one thing the fallback cannot + // judge locally — it lists rows, it does not search their text — so a query still rules + // the fallback out: an empty answer under one is already the answer. + const hasQuery = (input.query?.trim().length ?? 0) > 0; return read(true).pipe( Effect.flatMap((batch) => - batch.items.length === 0 && input.cursor === undefined && !searched + batch.items.length === 0 && input.cursor === undefined && !hasQuery ? read(false) : Effect.succeed(batch), ), @@ -1085,6 +1335,23 @@ export const make = Effect.gen(function* () { }), ), + getPullRequestBaseComparison: (input) => { + const { owner, name } = parseRepositorySelector(input.repository); + return graphqlRead({ + cwd: input.cwd, + host: input.host, + operation: "getPullRequestBaseComparison", + variables: [ + ["-f", `owner=${owner}`], + ["-f", `name=${name}`], + ["-F", `number=${input.number}`], + ["-f", `headRef=${input.headRef}`], + ], + query: BASE_COMPARISON_GRAPHQL_QUERY, + decode: decodeBaseComparisonJson, + }); + }, + getPullRequestActivity: (input) => github .execute({ @@ -1216,8 +1483,12 @@ export const make = Effect.gen(function* () { { readonly additions: number; readonly deletions: number } >(); let reviewers: ReadonlyArray = []; + let reactions: GitHubReviewThreadPage["reactions"] = []; + const reactionsById = new Map>(); let commits: GitHubReviewThreadPage["commits"] = []; let viewer: GitHubReviewThreadPage["viewer"] = { canUpdate: true, didAuthor: false }; + const dismissalsByReviewId = new Map(); + let dismissalCursor: string | null = null; let cursor: string | null = null; let page = 0; do { @@ -1229,14 +1500,46 @@ export const make = Effect.gen(function* () { // first one already carries all of them. if (page === 0) { reviewers = read.reviewers; + reactions = read.reactions; + for (const [id, entry] of read.reactionsById) reactionsById.set(id, entry); commits = read.commits; viewer = read.viewer; + for (const [id, message] of read.dismissalsByReviewId) + dismissalsByReviewId.set(id, message); + dismissalCursor = read.nextDismissalCursor; for (const [oid, stat] of read.commitStats) commitStats.set(oid, stat); } cursor = read.nextCursor; page += 1; } while (cursor !== null && page < REVIEW_THREAD_PAGES); + // Almost never entered: the embedded page already holds every dismissal a pull request + // ordinarily accrues. Followed so a review whose event fell past that page still finds + // its reason. + let dismissalPage = 0; + while (dismissalCursor !== null && dismissalPage < REVIEW_THREAD_PAGES) { + const read: { + readonly dismissalsByReviewId: ReadonlyMap; + readonly nextCursor: string | null; + } = yield* graphqlRead({ + cwd: input.cwd, + host: input.host, + operation: "listReviewThreadComments", + variables: [ + ["-f", `owner=${owner}`], + ["-f", `name=${name}`], + ["-F", `number=${input.number}`], + ["-f", `cursor=${dismissalCursor}`], + ], + query: REVIEW_DISMISSALS_GRAPHQL_QUERY, + decode: decodeReviewDismissalsJson, + }); + for (const [id, message] of read.dismissalsByReviewId) + dismissalsByReviewId.set(id, message); + dismissalCursor = read.nextCursor; + dismissalPage += 1; + } + // Only the threads GitHub said were unfinished cost a request; the rest arrived whole // with the page they were listed on. const finished = yield* Effect.forEach( @@ -1264,11 +1567,14 @@ export const make = Effect.gen(function* () { const reviewThreads = finished.map((entry) => entry.thread); return { comments: reviewThreadConversation(reviewThreads), + dismissalsByReviewId, reviewThreads, // GitHub's own count of each thread, so the number the page shows is the host's even // where a bound kept some of the words on GitHub. commentCount: finished.reduce((total, entry) => total + entry.commentCount, 0), truncated: cursor !== null || finished.some((entry) => entry.truncated), + reactions, + reactionsById, reviewers, avatarsByLogin, commitStats, @@ -1396,7 +1702,11 @@ export const make = Effect.gen(function* () { }, runPullRequestAction: (input) => { - const [subcommand, ...flags] = actionArgs(input.action, input.mergeMethod); + const [subcommand, ...flags] = actionArgs( + input.action, + input.mergeMethod, + input.updateMethod, + ); return github .execute({ cwd: input.cwd, @@ -1467,6 +1777,91 @@ export const make = Effect.gen(function* () { : UNRESOLVE_REVIEW_THREAD_GRAPHQL_MUTATION, variables: { threadId: input.threadId }, }), + + setReaction: (input) => { + const givenSubjectId = input.subjectId; + const subjectId = + givenSubjectId === undefined + ? pullRequestNodeId({ ...input, operation: "setReaction" }) + : subjectBelongsToPullRequest({ + ...input, + subjectId: givenSubjectId, + operation: "setReaction", + }).pipe( + Effect.flatMap((belongs) => + belongs + ? Effect.succeed(givenSubjectId) + : Effect.fail( + new GitHubSubjectScopeError({ + command: "gh", + cwd: input.cwd, + operation: "setReaction", + }), + ), + ), + ); + return subjectId.pipe( + Effect.flatMap((subjectId) => + graphql({ + cwd: input.cwd, + host: input.host, + query: input.reacted ? ADD_REACTION_GRAPHQL_MUTATION : REMOVE_REACTION_GRAPHQL_MUTATION, + variables: { subjectId, content: gitHubReactionContent(input.content) }, + }), + ), + ); + }, + + updatePullRequest: (input) => + pullRequestNodeId({ ...input, operation: "updatePullRequest" }).pipe( + Effect.flatMap((pullRequestId) => + graphql({ + cwd: input.cwd, + host: input.host, + query: UPDATE_PULL_REQUEST_GRAPHQL_MUTATION, + // A field the caller did not name is left out of the request entirely, so GitHub + // keeps the words that are there rather than being asked for an empty one. + variables: { + pullRequestId, + ...(input.title === undefined ? {} : { title: input.title }), + ...(input.body === undefined ? {} : { body: input.body }), + }, + }), + ), + ), + + updateComment: (input) => + subjectBelongsToPullRequest({ + cwd: input.cwd, + repository: input.repository, + host: input.host, + number: input.number, + subjectId: input.commentId, + operation: "updateComment", + }).pipe( + Effect.flatMap((belongs) => + belongs + ? Effect.succeed(input.commentId) + : Effect.fail( + new GitHubSubjectScopeError({ + command: "gh", + cwd: input.cwd, + operation: "updateComment", + }), + ), + ), + Effect.flatMap((commentId) => + graphql({ + cwd: input.cwd, + host: input.host, + query: + input.kind === "issue-comment" + ? UPDATE_ISSUE_COMMENT_GRAPHQL_MUTATION + : UPDATE_REVIEW_COMMENT_GRAPHQL_MUTATION, + variables: { commentId, body: input.body }, + }), + ), + ), }); }); diff --git a/apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts b/apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts index 453ac31dfdb8..13098ce387ff 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import type { PullRequestReaction } from "@t3tools/contracts"; import * as GitHubPullRequestCli from "./GitHubPullRequestCli.ts"; import { gitHubViewerPermissions, loginAvatarUrl, make } from "./GitHubPullRequestProvider.ts"; @@ -9,7 +10,16 @@ import type { GitHubReviewThreadComments } from "./gitHubPullRequestJson.ts"; describe("gitHubViewerPermissions", () => { it("offers everything to a viewer who can write to the repository", () => { expect(gitHubViewerPermissions({ canWrite: true, canUpdate: true, didAuthor: false })).toEqual({ - actions: ["merge", "ready", "draft", "close", "reopen"], + // Arming a merge for later is the merge, so it travels with it. + actions: [ + "merge", + "enable-auto-merge", + "disable-auto-merge", + "ready", + "draft", + "close", + "reopen", + ], comment: true, resolve: true, verdicts: ["comment", "approve", "request-changes"], @@ -34,7 +44,7 @@ describe("gitHubViewerPermissions", () => { it("keeps an author's own pull request theirs to close, with read access and no more", () => { expect(gitHubViewerPermissions({ canWrite: false, canUpdate: true, didAuthor: true })).toEqual({ - // Merging is the one thing writing is needed for; the rest an author may do. + // Merging is the one thing writing is needed for, now or later; the rest an author may do. actions: ["ready", "draft", "close", "reopen"], comment: true, resolve: true, @@ -71,17 +81,20 @@ describe("gitHubViewerPermissions", () => { title: "Pull request 7", url: "https://github.com/acme/web/pull/7", author: null, + headRepositoryOwner: null, headBranch: "feat/page", baseBranch: "main", state: "open", isDraft: false, mergeability: "mergeable", + reviewDecision: null, additions: 1, deletions: 1, createdAt: "2026-07-01T00:00:00Z", updatedAt: "2026-07-02T00:00:00Z", reviewRequestLogins: [], hasTeamReviewRequest: false, + checksState: null, labels: [], body: "", changedFiles: 1, @@ -104,6 +117,101 @@ describe("gitHubViewerPermissions", () => { ); }); +describe("getViewerPermissions", () => { + const openDetail = { + authorId: null, + number: 7, + title: "Pull request 7", + url: "https://github.com/acme/web/pull/7", + author: null, + headRepositoryOwner: "acme", + headBranch: "feat/page", + baseBranch: "main", + state: "open" as const, + isDraft: false, + mergeability: "mergeable" as const, + reviewDecision: null, + additions: 1, + deletions: 1, + createdAt: "2026-07-01T00:00:00Z", + updatedAt: "2026-07-02T00:00:00Z", + reviewRequestLogins: [], + hasTeamReviewRequest: false, + checksState: null, + labels: [], + body: "", + changedFiles: 1, + mergedAt: null, + closedAt: null, + checks: [], + comments: [], + commits: [], + }; + + const layerWithComparison = ( + comparison: Effect.Effect<{ + readonly behindBy: number | null; + readonly viewerCanUpdate: boolean; + }>, + ) => + Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({ + getPullRequestDetail: () => Effect.succeed(openDetail), + getPullRequestBaseComparison: () => comparison, + getViewerAccess: () => Effect.succeed({ canWrite: true, canUpdate: true, didAuthor: false }), + }); + + it.effect("offers update-branch when the comparison grants it", () => + Effect.gen(function* () { + const provider = yield* make; + const permissions = yield* provider.getViewerPermissions({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + }); + + expect(permissions.actions).toContain("update-branch"); + expect(permissions.updateMethods).toEqual(["merge", "rebase"]); + }).pipe( + Effect.provide(layerWithComparison(Effect.succeed({ behindBy: 3, viewerCanUpdate: true }))), + ), + ); + + it.effect("withholds update-branch when the comparison cannot be read", () => + Effect.gen(function* () { + const provider = yield* make; + const permissions = yield* provider.getViewerPermissions({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + }); + + expect(permissions.actions).not.toContain("update-branch"); + expect(permissions.updateMethods).toBeUndefined(); + // The rest of the answer survives a comparison nobody could make. + expect(permissions.actions).toContain("merge"); + }).pipe( + Effect.provide( + Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({ + getPullRequestDetail: () => Effect.succeed(openDetail), + getPullRequestBaseComparison: () => + Effect.fail( + new GitHubPullRequestCli.GitHubPullRequestReadError({ + command: "gh", + cwd: "/w", + operation: "getPullRequestBaseComparison", + cause: new Error("unreadable"), + }), + ), + getViewerAccess: () => + Effect.succeed({ canWrite: true, canUpdate: true, didAuthor: false }), + }), + ), + ), + ); +}); + describe("getChangeRequest commits", () => { const baseDetail = { authorId: null, @@ -122,6 +230,7 @@ describe("getChangeRequest commits", () => { updatedAt: "2026-07-02T00:00:00Z", reviewRequestLogins: [], hasTeamReviewRequest: false, + checksState: null, labels: [], body: "", changedFiles: 1, @@ -133,9 +242,12 @@ describe("getChangeRequest commits", () => { const baseThreadComments = { comments: [], + dismissalsByReviewId: new Map(), reviewThreads: [], commentCount: 0, truncated: false, + reactions: [], + reactionsById: new Map>(), reviewers: [], avatarsByLogin: new Map(), commitStats: new Map(), @@ -200,6 +312,122 @@ describe("getChangeRequest commits", () => { ); }); +describe("getChangeRequestActivity dismissed reviews", () => { + const dismissedReview = (body: string) => ({ + id: "PRR_1", + kind: "review" as const, + author: null, + body, + createdAt: "2026-07-03T00:00:00Z", + url: null, + path: null, + reviewState: "DISMISSED", + }); + const threadComments: GitHubReviewThreadComments = { + comments: [], + dismissalsByReviewId: new Map([["PRR_1", "Dismissing prior approval to re-evaluate 9b66581"]]), + reviewThreads: [], + commentCount: 0, + truncated: false, + reactions: [], + reactionsById: new Map(), + reviewers: [], + avatarsByLogin: new Map(), + commitStats: new Map(), + commits: [], + viewer: { canUpdate: true, didAuthor: false }, + }; + const layerFor = (body: string) => + Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({ + getPullRequestActivity: () => + Effect.succeed({ author: null, comments: [dismissedReview(body)], commits: [] }), + listReviewThreadComments: () => Effect.succeed(threadComments), + }); + const readActivity = Effect.gen(function* () { + const provider = yield* make; + return yield* provider.getChangeRequestActivity({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + }); + }); + + it.effect("fills a marker-only dismissed review with the timeline's reason", () => + // Macroscope's approvals carry only an HTML comment, which markdown renders as nothing — + // an empty-string check misses them and the card opens onto nothing. + readActivity.pipe( + Effect.map((activity) => { + expect(activity.comments[0]?.body).toBe("Dismissing prior approval to re-evaluate 9b66581"); + }), + Effect.provide(layerFor("")), + ), + ); + + it.effect("keeps the words of a dismissed review that has its own", () => + readActivity.pipe( + Effect.map((activity) => { + expect(activity.comments[0]?.body).toBe("These findings still stand."); + }), + Effect.provide(layerFor("These findings still stand.")), + ), + ); +}); + +describe("editing", () => { + const rewrites: Array = []; + + it.effect("hands a rewrite to the CLI as the request named it", () => + Effect.gen(function* () { + const provider = yield* make; + + expect(provider.capabilities.edit).toEqual({ changeRequest: true, comment: true }); + yield* provider.updateChangeRequest!({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + title: "A better title", + }); + yield* provider.updateComment!({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + commentId: "IC_1", + kind: "review-comment", + body: "Reworded.", + }); + + expect(rewrites).toEqual([ + { + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + title: "A better title", + }, + { + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + commentId: "IC_1", + kind: "review-comment", + body: "Reworded.", + }, + ]); + }).pipe( + Effect.provide( + Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({ + updatePullRequest: (input) => Effect.sync(() => void rewrites.push(input)), + updateComment: (input) => Effect.sync(() => void rewrites.push(input)), + }), + ), + ), + ); +}); + describe("loginAvatarUrl", () => { it("serves a user's picture from the host they belong to", () => { expect(loginAvatarUrl("octocat", "github.com")).toBe("https://github.com/octocat.png?size=80"); diff --git a/apps/server/src/pullRequest/GitHubPullRequestProvider.ts b/apps/server/src/pullRequest/GitHubPullRequestProvider.ts index b77c20a541c5..57d18e8ab917 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestProvider.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestProvider.ts @@ -2,6 +2,7 @@ import * as Effect from "effect/Effect"; import type { PullRequestActor, PullRequestCapabilities, + PullRequestReaction, PullRequestViewerPermissions, } from "@t3tools/contracts"; @@ -17,9 +18,20 @@ import type { GitHubViewerAccess } from "./gitHubPullRequestJson.ts"; const CAPABILITIES: PullRequestCapabilities = { diff: true, comment: true, - actions: ["merge", "ready", "draft", "close", "reopen"], + actions: [ + "merge", + "ready", + "draft", + "close", + "reopen", + "update-branch", + "enable-auto-merge", + "disable-auto-merge", + ], mergeMethods: ["merge", "squash", "rebase"], + updateMethods: ["merge", "rebase"], search: true, + reactions: true, review: { inlineComment: true, reply: true, @@ -27,6 +39,7 @@ const CAPABILITIES: PullRequestCapabilities = { verdicts: ["comment", "approve", "request-changes"], }, reviewers: { request: true, listCandidates: true }, + edit: { changeRequest: true, comment: true }, }; /** @@ -49,8 +62,13 @@ const CAPABILITIES: PullRequestCapabilities = { export function gitHubViewerPermissions(access: GitHubViewerAccess): PullRequestViewerPermissions { return { actions: [ - ...(access.canWrite ? (["merge"] as const) : []), + // 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. + ...(access.canWrite ? (["merge", "enable-auto-merge", "disable-auto-merge"] as const) : []), ...(access.canUpdate ? (["ready", "draft", "close", "reopen"] as const) : []), + // Whether this viewer may update the branch is GitHub's own answer, read with the + // comparison; without it the action is offered to nobody rather than to everybody. + ...(access.canUpdateBranch === true ? (["update-branch"] as const) : []), ], comment: true, resolve: access.canWrite || access.didAuthor, @@ -59,6 +77,7 @@ export function gitHubViewerPermissions(access: GitHubViewerAccess): PullRequest // leaves them commenting, which is what an author has to say about their own change anyway. verdicts: access.didAuthor ? (["comment"] as const) : CAPABILITIES.review.verdicts, requestReviewers: access.canWrite, + ...(access.canUpdateBranch === true ? { updateMethods: CAPABILITIES.updateMethods } : {}), }; } @@ -98,6 +117,10 @@ export function loginAvatarUrl(login: string, host: string): string | null { return /^[a-z0-9][a-z0-9-]{0,38}$/iu.test(login) ? `https://${host}/${login}.png?size=80` : null; } +/** True where markdown would render nothing: whitespace, or only HTML comments. */ +const rendersEmpty = (body: string): boolean => + body.replace(//g, "").trim().length === 0; + export const make = Effect.gen(function* () { const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; @@ -129,6 +152,7 @@ export const make = Effect.gen(function* () { limit: input.limit, query: input.query, cursor: input.cursor, + filters: input.filters, }) .pipe( Effect.mapError(fail("listChangeRequests")), @@ -172,6 +196,7 @@ export const make = Effect.gen(function* () { limit: input.limit, query: input.query, cursor: input.cursor, + filters: input.filters, }) .pipe( Effect.mapError(fail("listChangeRequestsAcross")), @@ -196,7 +221,24 @@ export const make = Effect.gen(function* () { getChangeRequest: (input) => Effect.all( [ - cli.getPullRequestDetail(input), + cli.getPullRequestDetail(input).pipe( + Effect.flatMap((pullRequest) => + // Only an open pull request can be behind anything worth saying so about, and only + // one whose head repository is known can be compared at all. A comparison that + // fails is left unknown: the banner is an offer, never a blocker. + pullRequest.state !== "open" || pullRequest.headRepositoryOwner === null + ? Effect.succeed({ pullRequest, comparison: null }) + : cli + .getPullRequestBaseComparison({ + ...input, + headRef: `${pullRequest.headRepositoryOwner}:${pullRequest.headBranch}`, + }) + .pipe( + Effect.map((comparison) => ({ pullRequest, comparison })), + Effect.orElseSucceed(() => ({ pullRequest, comparison: null })), + ), + ), + ), cli.getRepositoryAccess({ cwd: input.cwd, repository: input.repository, @@ -210,15 +252,27 @@ export const make = Effect.gen(function* () { ).pipe( Effect.mapError(fail("getChangeRequest")), Effect.map( - ([pullRequest, repository, viewerAccess]): ProviderChangeRequestDetail => ({ - ...pullRequest, - reviewers: pullRequest.reviewRequestLogins.map((login) => ({ + ([detail, repository, viewerAccess]): ProviderChangeRequestDetail => ({ + ...detail.pullRequest, + reviewers: detail.pullRequest.reviewRequestLogins.map((login) => ({ login, name: null, avatarUrl: null, })), mergeCapabilities: repository.mergeCapabilities, - viewerPermissions: gitHubViewerPermissions(viewerAccess), + viewerPermissions: gitHubViewerPermissions({ + ...viewerAccess, + canUpdateBranch: detail.comparison?.viewerCanUpdate === true, + }), + baseComparison: + detail.comparison === null || detail.comparison.behindBy === null + ? "unknown" + : detail.comparison.behindBy > 0 + ? "behind" + : "up-to-date", + ...(detail.comparison?.behindBy == null + ? {} + : { behindBy: detail.comparison.behindBy }), }), ), ), @@ -232,6 +286,9 @@ export const make = Effect.gen(function* () { cli.listReviewThreadComments(input).pipe( Effect.orElseSucceed(() => ({ comments: [], + dismissalsByReviewId: new Map(), + reactions: [], + reactionsById: new Map>(), reviewThreads: [], commentCount: 0, truncated: true, @@ -253,6 +310,7 @@ export const make = Effect.gen(function* () { ([pullRequest, reviewThreads]): ProviderChangeRequestActivity => ({ author: withAvatar(pullRequest.author, reviewThreads.avatarsByLogin, input.host), reviewers: reviewThreads.reviewers, + reactions: reviewThreads.reactions, commits: (reviewThreads.commits.length > 0 ? reviewThreads.commits : pullRequest.commits @@ -266,7 +324,20 @@ export const make = Effect.gen(function* () { comments: [...pullRequest.comments, ...reviewThreads.comments] .map((comment) => ({ ...comment, + // GitHub keeps the dismissal reason on the timeline event, not on the review, + // so a dismissed review with nothing visible of its own reads its words from + // there. "Visible" and not "empty": bot reviews often carry only an HTML + // marker comment, which markdown renders as nothing. + body: + comment.kind === "review" && + comment.reviewState?.toUpperCase() === "DISMISSED" && + rendersEmpty(comment.body) + ? (reviewThreads.dismissalsByReviewId.get(comment.id) ?? comment.body) + : comment.body, author: withAvatar(comment.author, reviewThreads.avatarsByLogin, input.host), + // A comment out of `gh pr view --json` carries none of its own: that read + // reports no reaction at all, so they arrive from the GraphQL page by node id. + reactions: comment.reactions ?? reviewThreads.reactionsById.get(comment.id) ?? [], })) .toSorted((left, right) => left.createdAt.localeCompare(right.createdAt)), // `gh pr view --json comments,reviews` follows GitHub's cursors itself, so those two @@ -285,9 +356,34 @@ export const make = Effect.gen(function* () { ), getViewerPermissions: (input) => - cli - .getViewerAccess(input) - .pipe(Effect.mapError(fail("getViewerPermissions")), Effect.map(gitHubViewerPermissions)), + Effect.all( + [ + cli.getViewerAccess(input), + // Whether this viewer may update the branch is only on the comparison, and the + // comparison only resolves through the head ref the detail carries. A failure here + // withholds that one action rather than the whole answer, the way the detail path + // leaves the banner unknown. + cli.getPullRequestDetail(input).pipe( + Effect.flatMap((pullRequest) => + pullRequest.state !== "open" || pullRequest.headRepositoryOwner === null + ? Effect.succeed(false) + : cli + .getPullRequestBaseComparison({ + ...input, + headRef: `${pullRequest.headRepositoryOwner}:${pullRequest.headBranch}`, + }) + .pipe(Effect.map((comparison) => comparison.viewerCanUpdate === true)), + ), + Effect.orElseSucceed(() => false), + ), + ], + { concurrency: 2 }, + ).pipe( + Effect.mapError(fail("getViewerPermissions")), + Effect.map(([access, canUpdateBranch]) => + gitHubViewerPermissions({ ...access, canUpdateBranch }), + ), + ), getDiff: (input) => cli.getPullRequestDiff(input).pipe(Effect.mapError(fail("getDiff"))), @@ -318,11 +414,37 @@ export const make = Effect.gen(function* () { number: input.number, action: input.action, ...(input.mergeMethod === undefined ? {} : { mergeMethod: input.mergeMethod }), + ...(input.updateMethod === undefined ? {} : { updateMethod: input.updateMethod }), }) .pipe(Effect.mapError(fail("runAction"))), + updateChangeRequest: (input) => + cli + .updatePullRequest({ + cwd: input.cwd, + repository: input.repository, + host: input.host, + number: input.number, + ...(input.title === undefined ? {} : { title: input.title }), + ...(input.body === undefined ? {} : { body: input.body }), + }) + .pipe(Effect.mapError(fail("updateChangeRequest"))), + comment: (input) => cli.commentOnPullRequest(input).pipe(Effect.mapError(fail("comment"))), + updateComment: (input) => + cli + .updateComment({ + cwd: input.cwd, + repository: input.repository, + host: input.host, + number: input.number, + commentId: input.commentId, + kind: input.kind, + body: input.body, + }) + .pipe(Effect.mapError(fail("updateComment"))), + submitReview: (input) => cli.submitReview(input).pipe(Effect.mapError(fail("submitReview"))), replyToThread: (input) => @@ -336,6 +458,19 @@ export const make = Effect.gen(function* () { }) .pipe(Effect.mapError(fail("replyToThread"))), + setReaction: (input) => + cli + .setReaction({ + cwd: input.cwd, + repository: input.repository, + host: input.host, + number: input.number, + ...(input.subjectId === undefined ? {} : { subjectId: input.subjectId }), + content: input.content, + reacted: input.reacted, + }) + .pipe(Effect.mapError(fail("setReaction"))), + setThreadResolution: (input) => cli .setReviewThreadResolution({ diff --git a/apps/server/src/pullRequest/GitLabPullRequestCli.test.ts b/apps/server/src/pullRequest/GitLabPullRequestCli.test.ts index 9bfad2648c1e..c33e01c2d721 100644 --- a/apps/server/src/pullRequest/GitLabPullRequestCli.test.ts +++ b/apps/server/src/pullRequest/GitLabPullRequestCli.test.ts @@ -414,6 +414,69 @@ layer("GitLabPullRequestCli.layer", (it) => { }), ); + it.effect("arms auto-merge with the same strategy a merge would have used", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output(""))); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + yield* cli.runMergeRequestAction({ + cwd: "/w", + repository: "acme/web", + number: 7, + action: "enable-auto-merge", + mergeMethod: "squash", + }); + + expect(argsOfCall(0)).toEqual([ + "mr", + "merge", + "7", + "--repo", + "acme/web", + "--auto-merge=true", + "--yes", + "--squash", + ]); + }), + ); + + it.effect("cancels an armed auto-merge through the API glab has no flag for", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output("{}"))); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + yield* cli.runMergeRequestAction({ + cwd: "/w", + repository: "acme/platform/web", + number: 7, + action: "disable-auto-merge", + }); + + expect(argsOfCall(0)).toEqual([ + "api", + "projects/acme%2Fplatform%2Fweb/merge_requests/7/cancel_merge_when_pipeline_succeeds", + "--method", + "POST", + ]); + }), + ); + + it.effect("brings a stale branch up to date by rebasing it, the only way GitLab has", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output(""))); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + yield* cli.runMergeRequestAction({ + cwd: "/w", + repository: "acme/web", + number: 7, + action: "update-branch", + }); + + expect(argsOfCall(0)).toEqual(["mr", "rebase", "7", "--repo", "acme/web"]); + }), + ); + it.effect("moves a merge request back to draft through glab", () => Effect.gen(function* () { mockedExecute.mockReturnValueOnce(Effect.succeed(output(""))); @@ -791,6 +854,21 @@ layer("GitLabPullRequestCli.layer", (it) => { }), ); + it.effect("asks the detail read for the divergence GitLab withholds by default", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce(Effect.succeed(output('{"message":"404 Not Found"}'))); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + yield* Effect.ignore( + cli.getMergeRequestDetail({ cwd: "/w", repository: "acme/web", number: 7 }), + ); + + expect(argsOfCall(0)[1]).toBe( + "projects/acme%2Fweb/merge_requests/7?include_diverged_commits_count=true", + ); + }), + ); + it.effect("fails the read when GitLab returns something unreadable", () => Effect.gen(function* () { mockedExecute.mockReturnValueOnce(Effect.succeed(output('{"message":"404 Not Found"}'))); @@ -1008,6 +1086,89 @@ layer("GitLabPullRequestCli.layer", (it) => { }), ); + it.effect("awards an emoji through a POST naming it, not a body", () => + Effect.gen(function* () { + mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + yield* cli.setReaction({ + cwd: "/w", + repository: "acme/web", + number: 7, + content: "thumbs-up", + reacted: true, + }); + + assert.strictEqual(mockedExecute.mock.calls.length, 1); + expect(argsOfCall(0)).toEqual([ + "api", + "projects/acme%2Fweb/merge_requests/7/award_emoji?name=thumbsup", + "--method", + "POST", + ]); + }), + ); + + it.effect("removes an award by listing them and deleting the reader's own id", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce( + // @effect-diagnostics-next-line preferSchemaOverJson:off + Effect.succeed(output(JSON.stringify({ username: "bilal" }))), + ); + mockedExecute.mockReturnValueOnce( + Effect.succeed( + output( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify([ + { id: 5, name: "thumbsup", user: { username: "bilal" } }, + { id: 6, name: "thumbsup", user: { username: "julius" } }, + ]), + ), + ), + ); + mockedExecute.mockReturnValueOnce(Effect.succeed(output("{}"))); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + yield* cli.setReaction({ + cwd: "/w", + repository: "acme/web", + number: 7, + content: "thumbs-up", + reacted: false, + }); + + assert.strictEqual(mockedExecute.mock.calls.length, 3); + expect(argsOfCall(2)).toEqual([ + "api", + "projects/acme%2Fweb/merge_requests/7/award_emoji/5", + "--method", + "DELETE", + ]); + }), + ); + + it.effect("does nothing when the reader has no award of that name to take back", () => + Effect.gen(function* () { + mockedExecute.mockReturnValueOnce( + // @effect-diagnostics-next-line preferSchemaOverJson:off + Effect.succeed(output(JSON.stringify({ username: "bilal" }))), + ); + mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]"))); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + yield* cli.setReaction({ + cwd: "/w", + repository: "acme/web", + number: 7, + content: "thumbs-up", + reacted: false, + }); + + // Nothing to delete: the reaction the caller asked to take back is already gone. + assert.strictEqual(mockedExecute.mock.calls.length, 2); + }), + ); + it.effect("names a merge request with no diff revisions rather than calling it unreadable", () => Effect.gen(function* () { mockedExecute.mockReturnValueOnce( @@ -1141,4 +1302,99 @@ layer("GitLabPullRequestCli.layer", (it) => { expect(JSON.parse(callAt(1).stdin ?? "")).toEqual({ reviewer_ids: [5] }); }), ); + + it.effect("rewrites a title without touching the description", () => + Effect.gen(function* () { + mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + yield* cli.updateMergeRequest({ + cwd: "/w", + repository: "acme/web", + number: 7, + title: "A better title", + }); + + expect(argsOfCall(0)).toEqual([ + "api", + "projects/acme%2Fweb/merge_requests/7", + "--method", + "PUT", + "--input", + "-", + "--header", + "Content-Type: application/json", + ]); + // @effect-diagnostics-next-line preferSchemaOverJson:off + expect(JSON.parse(callAt(0).stdin ?? "")).toEqual({ title: "A better title" }); + }), + ); + + it.effect("sends a rewritten body as GitLab's description, and nothing else", () => + Effect.gen(function* () { + mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + yield* cli.updateMergeRequest({ + cwd: "/w", + repository: "acme/web", + number: 7, + description: "What this changes.", + }); + + // A title sent as an empty string would wipe the one the merge request already has. + // @effect-diagnostics-next-line preferSchemaOverJson:off + expect(JSON.parse(callAt(0).stdin ?? "")).toEqual({ description: "What this changes." }); + }), + ); + + it.effect("rewrites title and description together in one request", () => + Effect.gen(function* () { + mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + yield* cli.updateMergeRequest({ + cwd: "/w", + repository: "acme/web", + number: 7, + title: "A better title", + description: "What this changes.", + }); + + assert.strictEqual(mockedExecute.mock.calls.length, 1); + // @effect-diagnostics-next-line preferSchemaOverJson:off + expect(JSON.parse(callAt(0).stdin ?? "")).toEqual({ + title: "A better title", + description: "What this changes.", + }); + }), + ); + + it.effect("rewrites a note in place through the note it names", () => + Effect.gen(function* () { + mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); + const cli = yield* GitLabPullRequestCli.GitLabPullRequestCli; + + yield* cli.updateNote({ + cwd: "/w", + repository: "acme/web", + number: 7, + noteId: "42", + body: "true", + }); + + expect(argsOfCall(0)).toEqual([ + "api", + "projects/acme%2Fweb/merge_requests/7/notes/42", + "--method", + "PUT", + "--input", + "-", + "--header", + "Content-Type: application/json", + ]); + // A JSON body, so a note rewritten to a literal `true` stays text. + expect(callAt(0).stdin).toBe('{"body":"true"}'); + }), + ); }); diff --git a/apps/server/src/pullRequest/GitLabPullRequestCli.ts b/apps/server/src/pullRequest/GitLabPullRequestCli.ts index 4cbb74d32a64..17c23bf86f48 100644 --- a/apps/server/src/pullRequest/GitLabPullRequestCli.ts +++ b/apps/server/src/pullRequest/GitLabPullRequestCli.ts @@ -11,6 +11,8 @@ import type { PullRequestListState, PullRequestMergeCapabilities, PullRequestMergeMethod, + PullRequestReaction, + PullRequestReactionContent, PullRequestReviewCommentDraft, PullRequestReviewThread, PullRequestReviewVerdict, @@ -19,6 +21,8 @@ import type { import * as GitLabCli from "../sourceControl/GitLabCli.ts"; import { + AWARD_EMOJI_GRAPHQL_QUERY, + decodeAwardEmojiJson, decodeCommitDiffRefsJson, decodeCommitsJson, decodeDiffRefsJson, @@ -27,9 +31,11 @@ import { decodeMergeRequestDiffsJson, decodeMergeRequestListJson, decodeNotesJson, + decodeOwnAwardIdJson, decodeProjectMergeCapabilitiesJson, decodeProjectUsersJson, decodeViewerJson, + gitLabAwardName, type GitLabDiffRefs, type GitLabMergeRequestDetail, type GitLabMergeRequestListItem, @@ -303,6 +309,15 @@ export class GitLabPullRequestCli extends Context.Service< readonly mergeMethod?: PullRequestMergeMethod; }) => Effect.Effect; + /** Whichever of the two is given is sent. GitLab calls a merge request's body its description. */ + readonly updateMergeRequest: (input: { + readonly cwd: string; + readonly repository: string; + readonly number: number; + readonly title?: string | undefined; + readonly description?: string | undefined; + }) => Effect.Effect; + readonly commentOnMergeRequest: (input: { readonly cwd: string; readonly repository: string; @@ -310,6 +325,14 @@ export class GitLabPullRequestCli extends Context.Service< readonly body: string; }) => Effect.Effect; + readonly updateNote: (input: { + readonly cwd: string; + readonly repository: string; + readonly number: number; + readonly noteId: string; + readonly body: string; + }) => Effect.Effect; + readonly listDiscussions: (input: { readonly cwd: string; readonly repository: string; @@ -343,6 +366,32 @@ export class GitLabPullRequestCli extends Context.Service< readonly discussionId: string; readonly resolved: boolean; }) => Effect.Effect; + + /** The awards on the merge request and on every note of it, keyed by the note's REST id. */ + readonly listReactions: (input: { + readonly cwd: string; + readonly repository: string; + readonly number: number; + }) => Effect.Effect< + { + readonly reactions: ReadonlyArray; + readonly reactionsByNoteId: ReadonlyMap>; + }, + GitLabPullRequestCliError + >; + + /** + * Awards an emoji, or takes the award back. `noteId` is a note of the merge request; absent + * awards the merge request itself, which is where its description's reactions live. + */ + readonly setReaction: (input: { + readonly cwd: string; + readonly repository: string; + readonly number: number; + readonly noteId?: string | undefined; + readonly content: PullRequestReactionContent; + readonly reacted: boolean; + }) => Effect.Effect; } >()("t3/pullRequest/GitLabPullRequestCli") {} @@ -412,12 +461,30 @@ function actionArgs( ...(mergeMethod === "squash" ? ["--squash"] : []), ...(mergeMethod === "rebase" ? ["--rebase"] : []), ]; + // The same command with the flag the other way up: here the wait is the whole point, so + // glab is told to arm the merge rather than talked out of it. + case "enable-auto-merge": + return [ + "merge", + "--auto-merge=true", + "--yes", + ...(mergeMethod === "squash" ? ["--squash"] : []), + ...(mergeMethod === "rebase" ? ["--rebase"] : []), + ]; + // Never reached: taking the arming back has no `glab mr` command, so it goes to the API. + case "disable-auto-merge": + return []; case "ready": return ["update", "--ready"]; case "draft": return ["update", "--draft"]; case "close": return ["close"]; + // A rebase, because GitLab has no other way to move a branch onto its target: there is no + // merge-the-target-in equivalent of GitHub's update button, which is why this host declares + // `rebase` alone and never has to read the method it was handed. + case "update-branch": + return ["rebase"]; case "reopen": return ["reopen"]; } @@ -819,7 +886,11 @@ export const make = Effect.gen(function* () { }): Effect.Effect => api({ cwd: input.cwd, - path: `projects/${projectPath(input.repository)}/merge_requests/${input.number}`, + // How far behind the target branch this one is comes only when asked for by name, and it + // is asked for here rather than on a second read because it is the same merge request. + path: `projects/${projectPath(input.repository)}/merge_requests/${input.number}?${query([ + ["include_diverged_commits_count", "true"], + ])}`, }).pipe( Effect.flatMap((result) => { const decoded = decodeMergeRequestDetailJson(result.stdout.trim()); @@ -862,26 +933,104 @@ export const make = Effect.gen(function* () { }), ); + /** Where an award is written: a note of the merge request, or the merge request itself. */ + const awardSubjectPath = (input: { + readonly repository: string; + readonly number: number; + readonly noteId?: string | undefined; + }) => { + const mergeRequest = `projects/${projectPath(input.repository)}/merge_requests/${input.number}`; + return input.noteId === undefined + ? `${mergeRequest}/award_emoji` + : `${mergeRequest}/notes/${encodeURIComponent(input.noteId)}/award_emoji`; + }; + + /** + * The awards on the merge request and its notes, a page of notes at a time. Bounded by the same + * count as the conversation itself: awards past the notes that were read belong to notes the + * page is not showing. + */ + const awardsPage = (input: { + readonly cwd: string; + readonly repository: string; + readonly number: number; + readonly cursor: string | null; + readonly page: number; + readonly collected: { + readonly reactions: ReadonlyArray; + readonly reactionsByNoteId: Map>; + } | null; + }): Effect.Effect< + { + readonly reactions: ReadonlyArray; + readonly reactionsByNoteId: ReadonlyMap>; + }, + GitLabPullRequestCliError + > => + api({ + cwd: input.cwd, + path: "graphql", + method: "POST", + stdin: JSON.stringify({ + query: AWARD_EMOJI_GRAPHQL_QUERY, + variables: { + fullPath: input.repository, + iid: String(input.number), + cursor: input.cursor, + }, + }), + }).pipe( + Effect.flatMap((result) => { + const decoded = decodeAwardEmojiJson(result.stdout.trim()); + if (!Result.isSuccess(decoded)) { + return Effect.fail( + new GitLabMergeRequestReadError({ + command: "glab", + cwd: input.cwd, + operation: "listReactions", + cause: decoded.failure, + }), + ); + } + const collected = input.collected ?? { + reactions: decoded.success.reactions, + reactionsByNoteId: new Map>(), + }; + for (const [id, reactions] of decoded.success.reactionsByNoteId) + collected.reactionsByNoteId.set(id, reactions); + return decoded.success.nextCursor === null || input.page >= CONVERSATION_PAGES + ? Effect.succeed(collected) + : awardsPage({ + ...input, + cursor: decoded.success.nextCursor, + page: input.page + 1, + collected, + }); + }), + ); + + const viewerUsername = (input: { readonly cwd: string }) => + api({ cwd: input.cwd, path: "user" }).pipe( + Effect.flatMap((result): Effect.Effect => { + const decoded = decodeViewerJson(result.stdout.trim()); + if (!Result.isSuccess(decoded)) { + return Effect.fail( + new GitLabMergeRequestReadError({ + command: "glab", + cwd: input.cwd, + operation: "getViewerUsername", + cause: decoded.failure, + }), + ); + } + return decoded.success === null + ? Effect.fail(new GitLabViewerUnavailableError({ command: "glab", cwd: input.cwd })) + : Effect.succeed(decoded.success); + }), + ); + return GitLabPullRequestCli.of({ - getViewerUsername: (input) => - api({ cwd: input.cwd, path: "user" }).pipe( - Effect.flatMap((result): Effect.Effect => { - const decoded = decodeViewerJson(result.stdout.trim()); - if (!Result.isSuccess(decoded)) { - return Effect.fail( - new GitLabMergeRequestReadError({ - command: "glab", - cwd: input.cwd, - operation: "getViewerUsername", - cause: decoded.failure, - }), - ); - } - return decoded.success === null - ? Effect.fail(new GitLabViewerUnavailableError({ command: "glab", cwd: input.cwd })) - : Effect.succeed(decoded.success); - }), - ), + getViewerUsername: viewerUsername, listMergeRequests: (input) => { const perPage = Math.min(input.limit + 1, MAX_PAGE_SIZE); @@ -893,6 +1042,44 @@ export const make = Effect.gen(function* () { listNotes: (input) => notesPage({ ...input, page: 1, collected: [] }), + listReactions: (input) => awardsPage({ ...input, cursor: null, page: 1, collected: null }), + + setReaction: (input) => + Effect.gen(function* () { + const subject = awardSubjectPath(input); + if (input.reacted) { + yield* api({ + cwd: input.cwd, + path: `${subject}?${query([["name", gitLabAwardName(input.content)]])}`, + method: "POST", + }); + return; + } + // GitLab deletes an award by its id and takes no emoji name there, so the reader's own + // award of that name is looked up first. Nothing to delete is success: the reaction the + // caller asked to take back is already gone. + const viewer = yield* viewerUsername({ cwd: input.cwd }); + const listed = yield* api({ cwd: input.cwd, path: subject }); + const own = decodeOwnAwardIdJson(listed.stdout.trim(), { + content: input.content, + viewer, + }); + if (!Result.isSuccess(own)) { + return yield* new GitLabMergeRequestReadError({ + command: "glab", + cwd: input.cwd, + operation: "setReaction", + cause: own.failure, + }); + } + if (own.success === null) return; + yield* api({ + cwd: input.cwd, + path: `${subject}/${own.success}`, + method: "DELETE", + }); + }), + listCommits: (input) => api({ cwd: input.cwd, @@ -1053,6 +1240,16 @@ export const make = Effect.gen(function* () { ), runMergeRequestAction: (input) => { + // `glab mr merge` arms auto-merge and never disarms it, so the one direction the CLI has + // no flag for is asked of GitLab directly through the same `api` passthrough the rest of + // this module writes with. + if (input.action === "disable-auto-merge") { + return api({ + cwd: input.cwd, + path: `projects/${projectPath(input.repository)}/merge_requests/${input.number}/cancel_merge_when_pipeline_succeeds`, + method: "POST", + }).pipe(Effect.asVoid); + } const [subcommand, ...flags] = actionArgs(input.action, input.mergeMethod); return gitlab .execute({ @@ -1062,6 +1259,20 @@ export const make = Effect.gen(function* () { .pipe(Effect.asVoid); }, + updateMergeRequest: (input) => + api({ + cwd: input.cwd, + path: `projects/${projectPath(input.repository)}/merge_requests/${input.number}`, + method: "PUT", + // Only the fields the caller asked to change: GitLab leaves out what it is not sent, and + // clears what it is sent empty — so a title corrected on its own must carry no + // description at all. + stdin: JSON.stringify({ + ...(input.title === undefined ? {} : { title: input.title }), + ...(input.description === undefined ? {} : { description: input.description }), + }), + }).pipe(Effect.asVoid), + commentOnMergeRequest: (input) => api({ cwd: input.cwd, @@ -1072,6 +1283,16 @@ export const make = Effect.gen(function* () { stdin: JSON.stringify({ body: input.body }), }).pipe(Effect.asVoid), + updateNote: (input) => + api({ + cwd: input.cwd, + path: `projects/${projectPath(input.repository)}/merge_requests/${input.number}/notes/${encodeURIComponent( + input.noteId, + )}`, + method: "PUT", + stdin: JSON.stringify({ body: input.body }), + }).pipe(Effect.asVoid), + listDiscussions: (input) => discussionsPage({ ...input, page: 1, collected: [] }), submitReview: (input) => diff --git a/apps/server/src/pullRequest/GitLabPullRequestProvider.test.ts b/apps/server/src/pullRequest/GitLabPullRequestProvider.test.ts index 55bbbe38d65d..5d36d58dfc2b 100644 --- a/apps/server/src/pullRequest/GitLabPullRequestProvider.test.ts +++ b/apps/server/src/pullRequest/GitLabPullRequestProvider.test.ts @@ -1,20 +1,36 @@ -import { describe, expect, it } from "vite-plus/test"; +import { assert, describe, expect, it, vi } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; -import { gitLabViewerPermissions } from "./GitLabPullRequestProvider.ts"; +import * as GitLabPullRequestCli from "./GitLabPullRequestCli.ts"; +import { gitLabViewerPermissions, make } from "./GitLabPullRequestProvider.ts"; describe("gitLabViewerPermissions", () => { it("offers everything to a viewer GitLab says can merge", () => { expect(gitLabViewerPermissions({ viewerCanMerge: true })).toEqual({ - actions: ["merge", "ready", "draft", "close", "reopen"], + // Arming a merge for later and taking the arming back answer to the same `can_merge`. + actions: [ + "merge", + "ready", + "draft", + "close", + "reopen", + "update-branch", + "enable-auto-merge", + "disable-auto-merge", + ], comment: true, resolve: true, verdicts: ["comment", "approve"], // GitLab says nothing about who may set a reviewer, and an unreported permission is granted. requestReviewers: true, + // Rebase and nothing else: GitLab cannot merge a target branch into a source branch, so + // offering the choice would be offering something no request could carry out. + updateMethods: ["rebase"], }); }); - it("keeps merge from a viewer GitLab says cannot", () => { + it("keeps merge, now and later, from a viewer GitLab says cannot", () => { // `user.can_merge` already accounts for the role, the approval rules and a protected target // branch, so it is the one answer here that does not have to be inferred. expect(gitLabViewerPermissions({ viewerCanMerge: false })).toEqual({ @@ -26,6 +42,12 @@ describe("gitLabViewerPermissions", () => { }); }); + it("names no way of updating a branch it will not let this viewer update", () => { + // The action and the strategy behind it go together: a button offered with nothing to press + // it with, or a strategy left standing next to a withheld button, is a half-refusal. + expect(gitLabViewerPermissions({ viewerCanMerge: false }).updateMethods).toBeUndefined(); + }); + it("treats an author with read access as any other reader, which is all GitLab says", () => { // Its REST API names no relationship between the viewer and the merge request beyond // `can_merge`, so the four an author keeps stay offered to everyone rather than being taken @@ -38,3 +60,138 @@ describe("gitLabViewerPermissions", () => { ]); }); }); + +describe("getChangeRequest base freshness", () => { + const detail = { + number: 7, + title: "Merge request 7", + url: "https://gitlab.com/acme/web/-/merge_requests/7", + author: null, + headBranch: "feat/page", + baseBranch: "main", + state: "open" as const, + isDraft: false, + mergeability: "mergeable" as const, + additions: 0, + deletions: 0, + createdAt: "2026-07-01T00:00:00Z", + updatedAt: "2026-07-02T00:00:00Z", + reviewRequestLogins: [], + labels: [], + body: "", + changedFiles: 1, + mergedAt: null, + closedAt: null, + reviewers: [], + checks: [], + viewerCanMerge: true, + reviewerIds: [], + }; + + const readWith = (divergence: { readonly divergedCommits?: number }) => + Effect.gen(function* () { + const provider = yield* make; + return yield* provider.getChangeRequest({ + cwd: "/w", + repository: "acme/web", + host: "gitlab.com", + number: 7, + }); + }).pipe( + Effect.provide( + Layer.mock(GitLabPullRequestCli.GitLabPullRequestCli)({ + getMergeRequestDetail: () => Effect.succeed({ ...detail, ...divergence }), + getProjectMergeCapabilities: () => + Effect.succeed({ merge: true, squash: true, rebase: true }), + }), + ), + ); + + it.effect("reads a counted divergence as a branch that has fallen behind", () => + Effect.gen(function* () { + const changeRequest = yield* readWith({ divergedCommits: 3 }); + + expect(changeRequest.baseComparison).toBe("behind"); + expect(changeRequest.behindBy).toBe(3); + }), + ); + + it.effect("reads a divergence of none as a branch that is current", () => + Effect.gen(function* () { + const changeRequest = yield* readWith({ divergedCommits: 0 }); + + expect(changeRequest.baseComparison).toBe("up-to-date"); + expect(changeRequest.behindBy).toBe(0); + }), + ); + + it.effect("says nothing at all where GitLab counted nothing", () => + Effect.gen(function* () { + // An install too old to answer has to leave the page silent rather than let it claim the + // branch is current, which is the one wrong thing this banner could say. + const changeRequest = yield* readWith({}); + + expect(changeRequest.baseComparison).toBe("unknown"); + expect(changeRequest.behindBy).toBeUndefined(); + }), + ); +}); + +describe("rewriting what has already been said", () => { + const updateMergeRequest = vi.fn(() => Effect.void); + const updateNote = vi.fn(() => Effect.void); + + const providerWith = make.pipe( + Effect.provide( + Layer.mock(GitLabPullRequestCli.GitLabPullRequestCli)({ updateMergeRequest, updateNote }), + ), + ); + + it.effect("sends only the half of the merge request the reader rewrote", () => + Effect.gen(function* () { + const provider = yield* providerWith; + assert.isDefined(provider.updateChangeRequest); + + yield* provider.updateChangeRequest({ + cwd: "/w", + repository: "acme/web", + host: "gitlab.com", + number: 7, + body: "What this changes.", + }); + + // GitLab calls it the description, and the title stays out of the request entirely. + expect(updateMergeRequest).toHaveBeenCalledWith({ + cwd: "/w", + repository: "acme/web", + number: 7, + description: "What this changes.", + }); + }), + ); + + it.effect("rewrites a positioned comment through the same note as any other", () => + Effect.gen(function* () { + const provider = yield* providerWith; + assert.isDefined(provider.updateComment); + + yield* provider.updateComment({ + cwd: "/w", + repository: "acme/web", + host: "gitlab.com", + number: 7, + commentId: "42", + kind: "review-comment", + body: "Reworded.", + }); + + expect(updateNote).toHaveBeenCalledWith({ + cwd: "/w", + repository: "acme/web", + number: 7, + noteId: "42", + body: "Reworded.", + }); + }), + ); +}); diff --git a/apps/server/src/pullRequest/GitLabPullRequestProvider.ts b/apps/server/src/pullRequest/GitLabPullRequestProvider.ts index 50396e27ea57..07edf08e582f 100644 --- a/apps/server/src/pullRequest/GitLabPullRequestProvider.ts +++ b/apps/server/src/pullRequest/GitLabPullRequestProvider.ts @@ -1,5 +1,9 @@ import * as Effect from "effect/Effect"; -import type { PullRequestCapabilities, PullRequestViewerPermissions } from "@t3tools/contracts"; +import type { + PullRequestCapabilities, + PullRequestReaction, + PullRequestViewerPermissions, +} from "@t3tools/contracts"; import * as GitLabPullRequestCli from "./GitLabPullRequestCli.ts"; import { @@ -12,10 +16,24 @@ import { const CAPABILITIES: PullRequestCapabilities = { diff: true, comment: true, - actions: ["merge", "ready", "draft", "close", "reopen"], + actions: [ + "merge", + "ready", + "draft", + "close", + "reopen", + "update-branch", + "enable-auto-merge", + "disable-auto-merge", + ], // GitLab offers all three, though a project settles on one; `mergeCapabilities` narrows it. mergeMethods: ["merge", "squash", "rebase"], + // Rebase alone: GitLab moves a stale branch onto its target by replaying it, and has nothing + // that merges the target back in the way GitHub's update button can. Declaring only what it + // does is what lets a request to merge the target in be refused instead of quietly rebasing. + updateMethods: ["rebase"], search: true, + reactions: true, review: { inlineComment: true, reply: true, @@ -25,8 +43,21 @@ const CAPABILITIES: PullRequestCapabilities = { verdicts: ["comment", "approve"], }, reviewers: { request: true, listCandidates: true }, + edit: { changeRequest: true, comment: true }, }; +/** + * The actions `user.can_merge` answers for. Rebasing writes to the source branch rather than to + * the target, so it is not literally the same permission — but GitLab reports nothing narrower, + * and someone it will not let land this change has no business rewriting its branch either. + */ +const MERGE_ACTIONS: ReadonlySet = new Set([ + "merge", + "update-branch", + "enable-auto-merge", + "disable-auto-merge", +]); + /** * What the signed-in account may do here. GitLab answers exactly one of these questions per * viewer, on the merge request itself: `user.can_merge`, which is why merging is the only thing @@ -45,11 +76,16 @@ export function gitLabViewerPermissions(input: { readonly viewerCanMerge: boolean; }): PullRequestViewerPermissions { return { - actions: CAPABILITIES.actions.filter((action) => action !== "merge" || input.viewerCanMerge), + // Arming the merge and taking the arming back are the merge, deferred, so they answer to + // the same `can_merge` the merge itself does. + actions: CAPABILITIES.actions.filter( + (action) => !MERGE_ACTIONS.has(action) || input.viewerCanMerge, + ), comment: true, resolve: true, verdicts: CAPABILITIES.review.verdicts, requestReviewers: true, + ...(input.viewerCanMerge ? { updateMethods: CAPABILITIES.updateMethods } : {}), }; } @@ -114,6 +150,17 @@ export const make = Effect.gen(function* () { ...mergeRequest, mergeCapabilities, viewerPermissions: gitLabViewerPermissions(mergeRequest), + // A GitLab too old to count the divergence says nothing here rather than "up to + // date": the banner is worth missing, and a wrong all-clear is not worth showing. + baseComparison: + mergeRequest.divergedCommits === undefined + ? "unknown" + : mergeRequest.divergedCommits > 0 + ? "behind" + : "up-to-date", + ...(mergeRequest.divergedCommits === undefined + ? {} + : { behindBy: mergeRequest.divergedCommits }), }), ), ), @@ -128,19 +175,37 @@ export const make = Effect.gen(function* () { cli .listDiscussions(input) .pipe(Effect.orElseSucceed(() => ({ threads: [], truncated: true }))), + // The notes endpoint carries no award of any kind, so they are read alongside it. A + // failed read costs the conversation its reactions rather than its words. + cli.listReactions(input).pipe( + Effect.orElseSucceed(() => ({ + reactions: [] as ReadonlyArray, + reactionsByNoteId: new Map>(), + })), + ), ], - { concurrency: 3 }, + { concurrency: 4 }, ).pipe( Effect.mapError(fail("getChangeRequestActivity")), Effect.map( - ([notes, commits, discussions]): ProviderChangeRequestActivity => ({ - comments: notes.comments, + ([notes, commits, discussions, awards]): ProviderChangeRequestActivity => ({ + reactions: awards.reactions, + comments: notes.comments.map((comment) => ({ + ...comment, + reactions: awards.reactionsByNoteId.get(comment.id) ?? [], + })), // GitLab reports no count of its own, so the walk's own total is the host's: the // notes endpoint carries every comment on the merge request, including the ones // written under a discussion, and it is read until GitLab runs out. commentCount: notes.comments.length, commentsTruncated: notes.truncated || discussions.truncated, - reviewThreads: discussions.threads, + reviewThreads: discussions.threads.map((thread) => ({ + ...thread, + comments: thread.comments.map((comment) => ({ + ...comment, + reactions: awards.reactionsByNoteId.get(comment.id) ?? [], + })), + })), commits, }), ), @@ -188,8 +253,32 @@ export const make = Effect.gen(function* () { }) .pipe(Effect.mapError(fail("runAction"))), + updateChangeRequest: (input) => + cli + .updateMergeRequest({ + cwd: input.cwd, + repository: input.repository, + number: input.number, + ...(input.title === undefined ? {} : { title: input.title }), + ...(input.body === undefined ? {} : { description: input.body }), + }) + .pipe(Effect.mapError(fail("updateChangeRequest"))), + comment: (input) => cli.commentOnMergeRequest(input).pipe(Effect.mapError(fail("comment"))), + // The kind is not read: every comment this provider hands out, positioned or not, carries a + // plain REST note id, and one endpoint rewrites both. + updateComment: (input) => + cli + .updateNote({ + cwd: input.cwd, + repository: input.repository, + number: input.number, + noteId: input.commentId, + body: input.body, + }) + .pipe(Effect.mapError(fail("updateComment"))), + submitReview: (input) => cli.submitReview(input).pipe(Effect.mapError(fail("submitReview"))), replyToThread: (input) => @@ -203,6 +292,18 @@ export const make = Effect.gen(function* () { }) .pipe(Effect.mapError(fail("replyToThread"))), + setReaction: (input) => + cli + .setReaction({ + cwd: input.cwd, + repository: input.repository, + number: input.number, + ...(input.subjectId === undefined ? {} : { noteId: input.subjectId }), + content: input.content, + reacted: input.reacted, + }) + .pipe(Effect.mapError(fail("setReaction"))), + setThreadResolution: (input) => cli .setDiscussionResolution({ diff --git a/apps/server/src/pullRequest/PullRequestProvider.ts b/apps/server/src/pullRequest/PullRequestProvider.ts index 34ec28b41069..6356d593b957 100644 --- a/apps/server/src/pullRequest/PullRequestProvider.ts +++ b/apps/server/src/pullRequest/PullRequestProvider.ts @@ -3,22 +3,30 @@ import * as Schema from "effect/Schema"; import type { PullRequestAction, PullRequestActor, + PullRequestBaseComparison, PullRequestCapabilities, + PullRequestChecksState, PullRequestCheck, PullRequestComment, PullRequestCommit, PullRequestInvolvement, PullRequestLabel, + PullRequestListFilters, PullRequestListState, PullRequestMergeCapabilities, PullRequestMergeMethod, PullRequestMergeability, + PullRequestOmittedFileStat, + PullRequestReaction, + PullRequestReactionContent, PullRequestReviewCommentDraft, + PullRequestReviewDecision, PullRequestReviewThread, PullRequestReviewVerdict, PullRequestReviewerCandidateList, PullRequestReviewerKind, PullRequestState, + PullRequestUpdateMethod, PullRequestViewerPermissions, SourceControlProviderKind, } from "@t3tools/contracts"; @@ -64,6 +72,10 @@ export interface ProviderChangeRequest { /** Accounts with a review requested. Team-level requests are excluded by each provider. */ readonly reviewRequestLogins: ReadonlyArray; readonly labels: ReadonlyArray; + /** Absent from a host that does not summarise its reviews, which is every host but GitHub. */ + readonly reviewDecision?: PullRequestReviewDecision | null | undefined; + /** Absent from a host that reports no check rollup on its listings. */ + readonly checksState?: PullRequestChecksState | null | undefined; } export interface ProviderChangeRequestPage { @@ -140,6 +152,11 @@ export interface ProviderChangeRequestDetail extends ProviderChangeRequest { readonly checks: ReadonlyArray; readonly mergeCapabilities: PullRequestMergeCapabilities; readonly viewerPermissions: PullRequestViewerPermissions; + /** Absent from a host that cannot compare the branch with its base, which is most of them. */ + readonly baseComparison?: PullRequestBaseComparison; + readonly behindBy?: number; + /** Absent from a host that does not report whether it is armed to merge this on its own. */ + readonly autoMergeEnabled?: boolean; } /** The conversation-shaped half of a detail, loaded after the core can already render. */ @@ -158,6 +175,8 @@ export interface ProviderChangeRequestActivity { readonly commentsTruncated: boolean; readonly reviewThreads: ReadonlyArray; readonly commits: ReadonlyArray; + /** The change request's own reactions, from a host that has them. */ + readonly reactions?: ReadonlyArray; } export interface ProviderDiffSlice { @@ -165,6 +184,8 @@ export interface ProviderDiffSlice { /** Something in this slice could not be shown, as opposed to there being more slices. */ readonly truncated: boolean; readonly nextCursor: string | null; + /** The host's own counts for the files whose hunks it withheld from this slice. */ + readonly omittedFileStats?: ReadonlyArray; } export interface ProviderDiffFileContents { @@ -216,6 +237,12 @@ export interface PullRequestProviderApi { * asks for the first slice, which is every listing that has not been continued. */ readonly cursor?: ProviderListCursor | undefined; + /** + * Further narrowings, which a host applies as far as it can and ignores the rest of — + * an unnarrowed page is a wider answer rather than a wrong one, and the caller narrows + * what it gets for the fields a row carries. + */ + readonly filters?: PullRequestListFilters | undefined; }, ) => Effect.Effect; @@ -244,6 +271,7 @@ export interface PullRequestProviderApi { readonly limit: number; readonly query?: string | undefined; readonly cursor?: ProviderListCursor | undefined; + readonly filters?: PullRequestListFilters | undefined; }) => Effect.Effect; /** @@ -314,7 +342,23 @@ export interface PullRequestProviderApi { input: ProviderRepositoryRef & { readonly number: number; readonly action: PullRequestAction; + /** 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. */ + readonly updateMethod?: PullRequestUpdateMethod; + }, + ) => Effect.Effect; + + /** + * Rewrites the change request's own words. Only called when `capabilities.edit.changeRequest` + * is true, and never with both fields absent — the caller refuses that before it gets here, + * because a host asked to change nothing answers differently on each of them. + */ + readonly updateChangeRequest?: ( + input: ProviderRepositoryRef & { + readonly number: number; + readonly title?: string | undefined; + readonly body?: string | undefined; }, ) => Effect.Effect; @@ -322,6 +366,23 @@ export interface PullRequestProviderApi { input: ProviderRepositoryRef & { readonly number: number; readonly body: string }, ) => Effect.Effect; + /** + * Rewrites a remark somebody already posted. Only called when `capabilities.edit.comment` is + * true, with an id exactly as the conversation carried it. + * + * Whether this remark is the reader's to rewrite is the host's own answer: no read here can + * settle it, since access can be taken away between the conversation being read and the + * rewrite being sent, and a host refuses a stranger's remark with a sentence saying so. + */ + readonly updateComment?: ( + input: ProviderRepositoryRef & { + readonly number: number; + readonly commentId: string; + readonly kind: "issue-comment" | "review-comment"; + readonly body: string; + }, + ) => Effect.Effect; + /** * Sends a whole review at once. Only called for a verdict the host declared in * `capabilities.review.verdicts`, and with line comments only where it declared @@ -376,6 +437,22 @@ export interface PullRequestProviderApi { }, ) => Effect.Effect; + /** + * Adds a reaction, or takes it back. Only called when `capabilities.reactions` is true. + * + * `subjectId` is a remark's id as the conversation carried it; absent means the change request + * itself, whose reactions sit on its description. Whatever a host needs to address either of + * them is worked out here, because the id a conversation travels with is the one the reader has. + */ + readonly setReaction: ( + input: ProviderRepositoryRef & { + readonly number: number; + readonly subjectId?: string | undefined; + readonly content: PullRequestReactionContent; + readonly reacted: boolean; + }, + ) => Effect.Effect; + /** Only called when `capabilities.review.resolve` is true. */ readonly setThreadResolution: ( input: ProviderRepositoryRef & { diff --git a/apps/server/src/pullRequest/PullRequestService.test.ts b/apps/server/src/pullRequest/PullRequestService.test.ts index 77a7118d961a..243cfe06c21d 100644 --- a/apps/server/src/pullRequest/PullRequestService.test.ts +++ b/apps/server/src/pullRequest/PullRequestService.test.ts @@ -114,8 +114,10 @@ function fakeProvider( actions: ["merge", "ready", "draft", "close", "reopen"], mergeMethods: ["merge"], search: true, + reactions: true, review: FULL_REVIEW, reviewers: FULL_REVIEWERS, + edit: { changeRequest: true, comment: true }, }, getViewer: () => Effect.succeed("bilal"), // A viewer who may do everything the host can, so a test only narrows what it is about. @@ -132,10 +134,13 @@ function fakeProvider( getChangeRequestActivity: () => Effect.die("unused"), getDiff: () => Effect.die("unused"), runAction: () => Effect.void, + updateChangeRequest: () => Effect.void, comment: () => Effect.void, + updateComment: () => Effect.void, submitReview: () => Effect.void, replyToThread: () => Effect.void, setThreadResolution: () => Effect.void, + setReaction: () => Effect.void, listReviewerCandidates: () => Effect.succeed({ candidates: [], truncated: false }), setReviewerRequest: () => Effect.void, ...overrides, @@ -435,8 +440,9 @@ it.effect("uses a provider's raw cursor advance when it consumed malformed rows" const result = yield* service.list({ state: "open" }); + // Keyed by the selector Azure is actually asked with, which is the repository's own name. assert.deepStrictEqual(result.nextCursors, { - "dev.azure.com acme/web": "2026-07-02T00:00:00Z|4|7", + "dev.azure.com web": "2026-07-02T00:00:00Z|4|7", }); }), ); @@ -945,6 +951,7 @@ it.effect("refuses an action the host never claimed it could run", () => actions: ["merge", "close"], mergeMethods: ["merge"], search: true, + reactions: true, review: FULL_REVIEW, reviewers: FULL_REVIEWERS, }, @@ -1007,6 +1014,136 @@ it.effect("refuses an action this viewer may not take, and says what access it t }), ); +it.effect("gates arming a merge for later exactly as it gates merging now", () => + Effect.gen(function* () { + let ranWith: { readonly action: string; readonly mergeMethod?: string } | null = null; + const service = yield* makeService({ + projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], + providers: [ + fakeProvider("github", { + capabilities: { + diff: true, + comment: true, + actions: ["merge", "close", "enable-auto-merge", "disable-auto-merge"], + mergeMethods: ["merge", "squash"], + search: true, + reactions: true, + review: FULL_REVIEW, + reviewers: FULL_REVIEWERS, + }, + // This account may close the change request it opened, and nothing else here. + getViewerPermissions: () => + Effect.succeed({ + actions: ["close"], + comment: true, + resolve: true, + verdicts: ["comment", "approve", "request-changes"], + requestReviewers: false, + }), + runAction: (input) => { + ranWith = { + action: input.action, + ...(input.mergeMethod === undefined ? {} : { mergeMethod: input.mergeMethod }), + }; + return Effect.void; + }, + }), + ], + }); + const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; + + const refused = yield* Effect.flip( + service.runAction({ ...reference, action: "enable-auto-merge", mergeMethod: "squash" }), + ); + assert.strictEqual(refused._tag, "PullRequestOperationError"); + assert.include(refused.message, "merged for you once it is ready"); + assert.strictEqual(ranWith, null); + + // The strategy is checked against the host for an armed merge too: a merge it performs + // later is still a merge, and one it cannot spell must not be passed on. + const wrongStrategy = yield* Effect.flip( + service.runAction({ ...reference, action: "enable-auto-merge", mergeMethod: "rebase" }), + ); + assert.strictEqual(wrongStrategy._tag, "PullRequestOperationError"); + assert.strictEqual(ranWith, null); + }), +); + +it.effect("hands the host the strategy an armed merge was asked for", () => + Effect.gen(function* () { + let ranWith: { readonly action: string; readonly mergeMethod?: string } | null = null; + const service = yield* makeService({ + projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], + providers: [ + fakeProvider("github", { + capabilities: { + diff: true, + comment: true, + actions: ["merge", "enable-auto-merge", "disable-auto-merge"], + mergeMethods: ["merge", "squash"], + search: true, + reactions: true, + review: FULL_REVIEW, + reviewers: FULL_REVIEWERS, + }, + getViewerPermissions: () => + Effect.succeed({ + actions: ["merge", "enable-auto-merge", "disable-auto-merge"], + comment: true, + resolve: true, + verdicts: ["comment", "approve", "request-changes"], + requestReviewers: true, + }), + runAction: (input) => { + ranWith = { + action: input.action, + ...(input.mergeMethod === undefined ? {} : { mergeMethod: input.mergeMethod }), + }; + return Effect.void; + }, + }), + ], + }); + const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; + + yield* service.runAction({ ...reference, action: "enable-auto-merge", mergeMethod: "squash" }); + assert.deepStrictEqual(ranWith, { action: "enable-auto-merge", mergeMethod: "squash" }); + + yield* service.runAction({ ...reference, action: "disable-auto-merge" }); + assert.deepStrictEqual(ranWith, { action: "disable-auto-merge" }); + }), +); + +it.effect("refuses an auto-merge the host never claimed, without asking it", () => + Effect.gen(function* () { + let ran = false; + const service = yield* makeService({ + projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], + providers: [ + // Bitbucket's shape: it merges, and has nothing that merges later on its own. + fakeProvider("github", { + runAction: () => { + ran = true; + return Effect.void; + }, + }), + ], + }); + + const error = yield* Effect.flip( + service.runAction({ + projectId: "p1" as ProjectId, + repository: "acme/web", + number: 1, + action: "enable-auto-merge", + }), + ); + + assert.strictEqual(error._tag, "PullRequestOperationError"); + assert.isFalse(ran); + }), +); + it.effect("refuses to resolve a conversation this viewer may not, without asking the host", () => Effect.gen(function* () { const service = yield* makeService({ @@ -1054,6 +1191,7 @@ it.effect("asks nobody what the viewer may do when the host cannot do it at all" actions: ["merge", "close"], mergeMethods: ["merge"], search: true, + reactions: true, review: FULL_REVIEW, reviewers: FULL_REVIEWERS, }, @@ -1092,6 +1230,7 @@ it.effect("refuses a comment on a host that cannot post one", () => actions: ["merge"], mergeMethods: ["merge"], search: true, + reactions: true, review: FULL_REVIEW, reviewers: FULL_REVIEWERS, }, @@ -1271,6 +1410,7 @@ it.effect("refuses a diff on a host that cannot produce one", () => actions: ["merge", "close"], mergeMethods: ["merge"], search: true, + reactions: true, review: FULL_REVIEW, reviewers: FULL_REVIEWERS, }, @@ -1330,6 +1470,7 @@ it.effect("refuses a verdict the host never claimed, without asking the provider actions: ["merge"], mergeMethods: ["merge"], search: true, + reactions: true, // GitLab's shape: it approves, and has nothing that rejects. review: { inlineComment: true, @@ -1377,6 +1518,7 @@ it.effect("refuses line comments on a host that takes only a summary", () => actions: ["merge"], mergeMethods: ["merge"], search: true, + reactions: true, review: { inlineComment: false, reply: false, resolve: false, verdicts: ["comment"] }, reviewers: FULL_REVIEWERS, }, @@ -1454,6 +1596,7 @@ it.effect("refuses to resolve a conversation on a host that cannot", () => actions: ["merge"], mergeMethods: ["merge"], search: true, + reactions: true, review: { inlineComment: true, reply: false, resolve: false, verdicts: ["comment"] }, reviewers: FULL_REVIEWERS, }, @@ -1480,6 +1623,149 @@ it.effect("refuses to resolve a conversation on a host that cannot", () => }), ); +it.effect("refuses to react on a host with no reactions", () => + Effect.gen(function* () { + const service = yield* makeService({ + projects: [ + project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), + ], + providers: [ + fakeProvider("github", { + capabilities: { + diff: true, + comment: true, + actions: ["merge"], + mergeMethods: ["merge"], + search: true, + reactions: false, + review: FULL_REVIEW, + reviewers: FULL_REVIEWERS, + }, + setReaction: () => Effect.die("must not be called"), + }), + ], + }); + + const error = yield* Effect.flip( + service.setReaction({ + projectId: "p1" as ProjectId, + repository: "pingdotgg/t3code", + number: 1, + content: "heart", + reacted: true, + }), + ); + + assert.strictEqual(error._tag, "PullRequestOperationError"); + }), +); + +it.effect("refuses to react on a host whose capabilities omit reactions entirely", () => + Effect.gen(function* () { + const service = yield* makeService({ + projects: [ + project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), + ], + providers: [ + fakeProvider("github", { + capabilities: { + diff: true, + comment: true, + actions: ["merge"], + mergeMethods: ["merge"], + search: true, + review: FULL_REVIEW, + reviewers: FULL_REVIEWERS, + }, + setReaction: () => Effect.die("must not be called"), + }), + ], + }); + + const error = yield* Effect.flip( + service.setReaction({ + projectId: "p1" as ProjectId, + repository: "pingdotgg/t3code", + number: 1, + content: "heart", + reacted: true, + }), + ); + + assert.strictEqual(error._tag, "PullRequestOperationError"); + }), +); + +it.effect("passes a reaction through with its subject id on a host that has them", () => + Effect.gen(function* () { + let received: { + readonly subjectId: string | undefined; + readonly content: string; + readonly reacted: boolean; + } | null = null; + const service = yield* makeService({ + projects: [ + project({ id: "p1", title: "t3code", workspaceRoot: "/a", repository: "pingdotgg/t3code" }), + ], + providers: [ + fakeProvider("github", { + setReaction: (input) => { + received = { + subjectId: input.subjectId, + content: input.content, + reacted: input.reacted, + }; + return Effect.void; + }, + }), + ], + }); + + yield* service.setReaction({ + projectId: "p1" as ProjectId, + repository: "pingdotgg/t3code", + number: 1, + subjectId: "IC_1", + content: "heart", + reacted: true, + }); + + assert.deepStrictEqual(received, { subjectId: "IC_1", content: "heart", reacted: true }); + }), +); + +it.effect("invalidates the cached activity after reacting, like the other mutations", () => + Effect.gen(function* () { + let activityCalls = 0; + const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; + const service = yield* makeService({ + projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], + providers: [ + fakeProvider("github", { + getChangeRequestActivity: () => { + activityCalls += 1; + return Effect.succeed({ + comments: [], + commentCount: 0, + commentsTruncated: false, + reviewThreads: [], + commits: [], + }); + }, + }), + ], + }); + + yield* service.activity(reference); + assert.strictEqual(activityCalls, 1); + + yield* service.setReaction({ ...reference, content: "heart", reacted: true }); + yield* service.activity(reference); + + assert.strictEqual(activityCalls, 2); + }), +); + it.effect("refuses an empty reply before it reaches the host", () => Effect.gen(function* () { const service = yield* makeService({ @@ -1521,6 +1807,7 @@ it.effect("refuses a merge strategy the host does not offer", () => // Azure DevOps's shape: it squashes as a completion option and has no rebase. mergeMethods: ["merge", "squash"], search: true, + reactions: true, review: FULL_REVIEW, reviewers: FULL_REVIEWERS, }, @@ -1704,6 +1991,7 @@ it.effect("refuses to ask for a review on a host that cannot, before any call is actions: ["merge"], mergeMethods: ["merge"], search: true, + reactions: true, review: FULL_REVIEW, reviewers: { request: false, listCandidates: false }, }, @@ -1744,6 +2032,7 @@ it.effect("refuses the candidate list on a host that has no such list to give", actions: ["merge"], mergeMethods: ["merge"], search: false, + reactions: true, review: FULL_REVIEW, // Azure's shape: it takes a reviewer, and names nobody who could be one. reviewers: { request: true, listCandidates: false }, @@ -1910,6 +2199,45 @@ it.effect("answers a repeated listing from cache, and concurrent readers share o }), ); +it.effect("a listing narrowed to some projects is its own cache entry", () => + Effect.gen(function* () { + const asked: ReadonlyArray[] = []; + const service = yield* makeService({ + projects: [ + project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }), + project({ id: "p2", title: "docs", workspaceRoot: "/b", repository: "acme/docs" }), + ], + providers: [ + fakeProvider("github", { + listChangeRequestsAcross: (input) => { + asked.push(input.repositories); + return Effect.succeed({ + items: input.repositories.map((repository, index) => + batchedChangeRequest(index + 1, repository, "2026-07-02T00:00:00Z"), + ), + truncated: false, + }); + }, + }), + ], + }); + + yield* service.list({ state: "open" }); + const narrowed = yield* service.list({ state: "open", projectIds: ["p2" as ProjectId] }); + + // The narrowing is part of the key, so it reads its own scope instead of the wider answer. + assert.deepStrictEqual(asked, [["acme/web", "acme/docs"], ["acme/docs"]]); + assert.deepStrictEqual( + narrowed.entries.map((entry) => entry.repository), + ["acme/docs"], + ); + + // Asking again with the same narrowing, ordered differently, is still the same answer. + yield* service.list({ state: "open", projectIds: ["p2" as ProjectId] }); + assert.strictEqual(asked.length, 2); + }), +); + it.effect("an explicit invalidation makes the next listing ask the host again", () => Effect.gen(function* () { let hostCalls = 0; @@ -2370,3 +2698,597 @@ it.effect( assert.strictEqual(activityCalls, 2); }), ); + +it.effect("carries an armed auto-merge through to the detail, and silence as silence", () => + Effect.gen(function* () { + const detailWith = (autoMergeEnabled: boolean | undefined) => + Effect.gen(function* () { + const service = yield* makeService({ + projects: [ + project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }), + ], + providers: [ + fakeProvider("github", { + getChangeRequest: () => + Effect.succeed({ + ...changeRequest(1, "2026-07-02T00:00:00Z"), + body: "", + changedFiles: 0, + mergedAt: null, + closedAt: null, + reviewers: [], + checks: [], + mergeCapabilities: { merge: true, squash: true, rebase: true }, + viewerPermissions: { + actions: ["merge"], + comment: true, + resolve: true, + verdicts: ["comment", "approve", "request-changes"], + requestReviewers: true, + }, + ...(autoMergeEnabled === undefined ? {} : { autoMergeEnabled }), + }), + }), + ], + }); + return yield* service.detail({ + projectId: "p1" as ProjectId, + repository: "acme/web", + number: 1, + }); + }); + + assert.strictEqual((yield* detailWith(true)).autoMergeEnabled, true); + assert.strictEqual((yield* detailWith(false)).autoMergeEnabled, false); + // A host that says nothing leaves the field absent rather than claiming the merge is unarmed. + assert.isUndefined((yield* detailWith(undefined)).autoMergeEnabled); + }), +); + +it("names an Azure DevOps repository by its own name, not its project path", () => { + // `az repos pr list --repository` takes a name and detects the organisation and project from + // the checkout; the recorded `org/project/_git/repo` path is refused, and the repository then + // reads as unavailable on the page. + const selector = PullRequestService.repositoryIdentityOf({ + repositoryIdentity: { + provider: "azure-devops", + displayName: "contoso/payments/_git/checkout", + owner: "contoso", + name: "checkout", + }, + } as never); + assert.strictEqual(selector, "checkout"); +}); + +it("falls back to the path's last segment where an Azure identity has no name", () => { + const selector = PullRequestService.repositoryIdentityOf({ + repositoryIdentity: { + provider: "azure-devops", + displayName: "contoso/payments/_git/checkout", + }, + } as never); + assert.strictEqual(selector, "checkout"); +}); + +it("keeps a GitLab identity's whole path, because a nested group is part of the name", () => { + const selector = PullRequestService.repositoryIdentityOf({ + repositoryIdentity: { + provider: "gitlab", + displayName: "group/subgroup/service", + owner: "group", + name: "service", + }, + } as never); + assert.strictEqual(selector, "group/subgroup/service"); +}); + +it.effect("narrows the rows of a host that ignored the filters it was handed", () => + Effect.gen(function* () { + const service = yield* makeService({ + projects: [ + project({ + id: "p1", + title: "web", + workspaceRoot: "/a", + repository: "acme/web", + provider: "gitlab", + }), + ], + providers: [ + // Only GitHub narrows a listing for itself; every other host answers unnarrowed, and + // sending it a draft filter it quietly ignores used to put drafts on a filtered page. + fakeProvider("gitlab", { + listChangeRequests: () => + Effect.succeed({ + items: [ + { ...changeRequest(1, "2026-07-02T00:00:00Z"), isDraft: true }, + changeRequest(2, "2026-07-01T00:00:00Z"), + ], + truncated: false, + continues: false, + }), + }), + ], + }); + + const result = yield* service.list({ state: "open", filters: { draft: "hide" } }); + + assert.deepStrictEqual( + result.entries.map((entry) => entry.number), + [2], + ); + }), +); + +it.effect("keeps a row of a host that ignored the filters if any name of a label group holds", () => + Effect.gen(function* () { + const sized = (number: number, updatedAt: string, ...names: ReadonlyArray) => ({ + ...changeRequest(number, updatedAt), + labels: names.map((name) => ({ name, color: null })), + }); + const service = yield* makeService({ + projects: [ + project({ + id: "p1", + title: "web", + workspaceRoot: "/a", + repository: "acme/web", + provider: "gitlab", + }), + ], + providers: [ + fakeProvider("gitlab", { + listChangeRequests: () => + Effect.succeed({ + items: [ + sized(1, "2026-07-04T00:00:00Z", "size:S", "bug"), + sized(2, "2026-07-03T00:00:00Z", "size:XS", "bug"), + sized(3, "2026-07-02T00:00:00Z", "size:L", "bug"), + sized(4, "2026-07-01T00:00:00Z", "size:S"), + ], + truncated: false, + continues: false, + }), + }), + ], + }); + + // Either size satisfies the first group; the second group is its own question, so the row + // carrying a size but no bug goes. + const result = yield* service.list({ + state: "open", + filters: { labels: [["size:S", "size:XS"], ["bug"]] }, + }); + + assert.deepStrictEqual( + result.entries.map((entry) => entry.number), + [1, 2], + ); + }), +); + +it.effect('resolves an author filter of "me" to the viewer before narrowing a host\'s rows', () => + Effect.gen(function* () { + const service = yield* makeService({ + projects: [ + project({ + id: "p1", + title: "web", + workspaceRoot: "/a", + repository: "acme/web", + provider: "gitlab", + }), + ], + providers: [ + // Only GitHub narrows a listing for itself, so this fixture's "me" has to be resolved + // locally too — the same helper both call sites lean on. + fakeProvider("gitlab", { + listChangeRequests: () => + Effect.succeed({ + items: [ + changeRequest(1, "2026-07-02T00:00:00Z"), + { + ...changeRequest(2, "2026-07-01T00:00:00Z"), + author: { login: "bilal", name: null, avatarUrl: null }, + }, + ], + truncated: false, + continues: false, + }), + }), + ], + }); + + const result = yield* service.list({ state: "open", filters: { author: "me" } }); + + assert.deepStrictEqual( + result.entries.map((entry) => entry.number), + [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; + const service = yield* makeService({ + projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], + providers: [ + fakeProvider("github", { + capabilities: { + diff: true, + comment: true, + actions: ["merge", "close", "update-branch"], + mergeMethods: ["merge"], + // This host brings a stale branch up to date with a merge commit and nothing else. + updateMethods: ["merge"], + search: true, + reactions: true, + review: FULL_REVIEW, + reviewers: FULL_REVIEWERS, + }, + getViewerPermissions: () => + Effect.succeed({ + actions: ["close", "update-branch"], + comment: true, + resolve: true, + verdicts: ["comment"], + requestReviewers: false, + updateMethods: ["merge"], + }), + runAction: (input) => { + taken = input.updateMethod ?? "default"; + return Effect.void; + }, + }), + ], + }); + const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; + + // Asking for a rebase a host does not offer must fail rather than quietly merge instead. + const error = yield* Effect.flip( + service.runAction({ ...reference, action: "update-branch", updateMethod: "rebase" }), + ); + assert.strictEqual(error._tag, "PullRequestOperationError"); + assert.strictEqual(taken, null); + + yield* service.runAction({ ...reference, action: "update-branch", updateMethod: "merge" }); + assert.strictEqual(taken, "merge"); + }), +); + +it.effect("refuses to merge a target branch into a source branch on a host that only rebases", () => + Effect.gen(function* () { + let taken = 0; + const service = yield* makeService({ + projects: [ + project({ + id: "p1", + title: "on gitlab", + workspaceRoot: "/a", + repository: "group/project", + provider: "gitlab", + }), + ], + providers: [ + fakeProvider("gitlab", { + capabilities: { + diff: true, + comment: true, + actions: ["merge", "close", "update-branch"], + mergeMethods: ["merge"], + // What GitLab declares: it replays the branch, and has no update that merges the + // target back in. + updateMethods: ["rebase"], + search: true, + reactions: true, + review: FULL_REVIEW, + reviewers: FULL_REVIEWERS, + }, + getViewerPermissions: () => + Effect.succeed({ + actions: ["close", "update-branch"], + comment: true, + resolve: true, + verdicts: ["comment"], + requestReviewers: false, + updateMethods: ["rebase"], + }), + runAction: () => { + taken += 1; + return Effect.void; + }, + }), + ], + }); + const reference = { projectId: "p1" as ProjectId, repository: "group/project", number: 1 }; + + // A merge asked of a host that rebases must fail here rather than reach the provider, which + // would rebase instead and report the wrong thing as done. + const error = yield* Effect.flip( + service.runAction({ ...reference, action: "update-branch", updateMethod: "merge" }), + ); + assert.strictEqual(error._tag, "PullRequestOperationError"); + assert.strictEqual(taken, 0); + + yield* service.runAction({ ...reference, action: "update-branch", updateMethod: "rebase" }); + assert.strictEqual(taken, 1); + }), +); + +it.effect("judges the review filter only on a host that summarises its reviews", () => + Effect.gen(function* () { + const service = yield* makeService({ + projects: [ + project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }), + project({ + id: "p2", + title: "on gitlab", + workspaceRoot: "/b", + repository: "group/project", + provider: "gitlab", + }), + ], + providers: [ + // GitHub answers with the field on every row: null is "nobody has decided yet". + fakeProvider("github", { + listChangeRequests: () => + Effect.succeed({ + items: [ + { ...changeRequest(1, "2026-07-02T00:00:00Z"), reviewDecision: null }, + { + ...changeRequest(2, "2026-07-02T00:00:00Z"), + reviewDecision: "approved" as const, + }, + ], + truncated: false, + continues: true, + }), + }), + // GitLab never supplies the field, so its rows are not the filter's to judge. + fakeProvider("gitlab", { + listChangeRequests: () => + Effect.succeed({ + items: [changeRequest(3, "2026-07-02T00:00:00Z")], + truncated: false, + continues: true, + }), + }), + ], + }); + + const none = yield* service.list({ state: "open", filters: { review: "none" } }); + assert.deepStrictEqual(none.entries.map((entry) => entry.number).toSorted(), [1, 3]); + + const approved = yield* service.list({ state: "open", filters: { review: "approved" } }); + assert.deepStrictEqual(approved.entries.map((entry) => entry.number).toSorted(), [2, 3]); + }), +); + +it.effect("sends only the words a rewrite carries", () => + Effect.gen(function* () { + const received: Array<{ title?: string | undefined; body?: string | undefined }> = []; + const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; + const service = yield* makeService({ + projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], + providers: [ + fakeProvider("github", { + updateChangeRequest: (input) => { + received.push({ title: input.title, body: input.body }); + return Effect.void; + }, + }), + ], + }); + + yield* service.update({ ...reference, title: "A better title" }); + yield* service.update({ ...reference, body: "" }); + yield* service.update({ ...reference, title: "Both", body: "at once" }); + + assert.deepStrictEqual(received, [ + { title: "A better title", body: undefined }, + { title: undefined, body: "" }, + { title: "Both", body: "at once" }, + ]); + }), +); + +it.effect("refuses a rewrite that changes nothing, before any call is made", () => + Effect.gen(function* () { + const service = yield* makeService({ + projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], + providers: [ + fakeProvider("github", { updateChangeRequest: () => Effect.die("must not be called") }), + ], + }); + + const error = yield* Effect.flip( + service.update({ projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }), + ); + + assert.strictEqual(error._tag, "PullRequestOperationError"); + assert.include(error.message, "Nothing was changed."); + }), +); + +it.effect("refuses to rewrite anything on a host that never claimed it", () => + Effect.gen(function* () { + const service = yield* makeService({ + projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], + providers: [ + fakeProvider("github", { + capabilities: { + diff: true, + comment: true, + actions: ["merge"], + mergeMethods: ["merge"], + search: true, + reactions: true, + review: FULL_REVIEW, + reviewers: FULL_REVIEWERS, + }, + updateChangeRequest: () => Effect.die("must not be called"), + updateComment: () => Effect.die("must not be called"), + }), + ], + }); + const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; + + const rewriteRefused = yield* Effect.flip(service.update({ ...reference, title: "New" })); + const commentRefused = yield* Effect.flip( + service.updateComment({ + ...reference, + commentId: "IC_1", + kind: "issue-comment", + body: "New", + }), + ); + + assert.include(rewriteRefused.message, "cannot rewrite a change request."); + assert.include(commentRefused.message, "cannot rewrite a comment."); + }), +); + +it.effect("passes a rewritten remark through with the id and kind it arrived under", () => + Effect.gen(function* () { + let received: { id: string; kind: string; body: string } | null = null; + const service = yield* makeService({ + projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], + providers: [ + fakeProvider("github", { + updateComment: (input) => { + received = { id: input.commentId, kind: input.kind, body: input.body }; + return Effect.void; + }, + }), + ], + }); + + yield* service.updateComment({ + projectId: "p1" as ProjectId, + repository: "acme/web", + number: 1, + commentId: "PRRC_1", + kind: "review-comment", + body: "Second thoughts", + }); + + assert.deepStrictEqual(received, { + id: "PRRC_1", + kind: "review-comment", + body: "Second thoughts", + }); + }), +); + +it.effect("refuses a remark rewritten into nothing but whitespace", () => + Effect.gen(function* () { + const service = yield* makeService({ + projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], + providers: [ + fakeProvider("github", { updateComment: () => Effect.die("must not be called") }), + ], + }); + + const error = yield* Effect.flip( + service.updateComment({ + projectId: "p1" as ProjectId, + repository: "acme/web", + number: 1, + commentId: "IC_1", + kind: "issue-comment", + body: " \n ", + }), + ); + + assert.include(error.message, "A comment cannot be empty."); + }), +); + +it.effect("forgets the cached detail after a rewrite, like the other mutations", () => + Effect.gen(function* () { + let coreCalls = 0; + const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; + const service = yield* makeService({ + projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], + providers: [ + fakeProvider("github", { + getChangeRequest: () => { + coreCalls += 1; + return Effect.succeed({ + ...changeRequest(1, "2026-07-02T00:00:00Z"), + body: "", + changedFiles: 0, + mergedAt: null, + closedAt: null, + reviewers: [], + checks: [], + mergeCapabilities: { merge: true, squash: true, rebase: true }, + viewerPermissions: { + actions: ["merge"], + comment: true, + resolve: true, + verdicts: ["comment", "approve", "request-changes"], + requestReviewers: true, + }, + }); + }, + }), + ], + }); + + yield* service.detail(reference); + yield* service.update({ ...reference, title: "Renamed" }); + yield* service.detail(reference); + + assert.strictEqual(coreCalls, 2); + }), +); + +it.effect("names the signed-in account in the detail, and says nothing where the host cannot", () => + Effect.gen(function* () { + const detailFrom = (provider: PullRequestProviderApi) => + Effect.gen(function* () { + const service = yield* makeService({ + projects: [ + project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }), + ], + providers: [provider], + }); + return yield* service.detail({ + projectId: "p1" as ProjectId, + repository: "acme/web", + number: 1, + }); + }); + const readable = fakeProvider("github", { + getChangeRequest: () => + Effect.succeed({ + ...changeRequest(1, "2026-07-02T00:00:00Z"), + body: "", + changedFiles: 0, + mergedAt: null, + closedAt: null, + reviewers: [], + checks: [], + mergeCapabilities: { merge: true, squash: true, rebase: true }, + viewerPermissions: { + actions: ["merge"], + comment: true, + resolve: true, + verdicts: ["comment", "approve", "request-changes"], + requestReviewers: true, + }, + }), + }); + + const named = yield* detailFrom(readable); + const unnamed = yield* detailFrom({ + ...readable, + getViewer: () => Effect.fail(unusable("github", "unauthenticated")), + }); + + assert.strictEqual(named.viewer, "bilal"); + assert.strictEqual(unnamed.viewer, undefined); + }), +); diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts index adff1f83729b..f12f72bdafac 100644 --- a/apps/server/src/pullRequest/PullRequestService.ts +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -10,11 +10,13 @@ import { PullRequestUnavailableError, pullRequestHostOf, pullRequestProviderRequirement, + resolvePullRequestAuthorFilter, type OrchestrationProjectShell, type PullRequestAction, type PullRequestActionInput, type PullRequestActivity, type PullRequestCommentInput, + type PullRequestCommentUpdateInput, type PullRequestDetail, type PullRequestDiffFileContentsInput, type PullRequestDiffFileContentsResult, @@ -23,12 +25,14 @@ import { type PullRequestDiffResult, type PullRequestInvalidateInput, type PullRequestListEntry, + type PullRequestListFilters, type PullRequestListInput, type PullRequestListProjectError, type PullRequestListResult, type PullRequestListStatsInput, type PullRequestListStatsResult, type PullRequestProviderSummary, + type PullRequestReactionInput, type PullRequestRef, type PullRequestReviewVerdict, type PullRequestReviewerCandidateList, @@ -36,6 +40,7 @@ import { type PullRequestSubmitReviewInput, type PullRequestThreadReplyInput, type PullRequestThreadResolutionInput, + type PullRequestUpdateInput, type SourceControlProviderInfo, type SourceControlProviderKind, } from "@t3tools/contracts"; @@ -134,7 +139,11 @@ export class PullRequestService extends Context.Service< input: PullRequestDiffFileContentsInput, ) => Effect.Effect; readonly runAction: (input: PullRequestActionInput) => Effect.Effect; + readonly update: (input: PullRequestUpdateInput) => Effect.Effect; readonly comment: (input: PullRequestCommentInput) => Effect.Effect; + readonly updateComment: ( + input: PullRequestCommentUpdateInput, + ) => Effect.Effect; readonly submitReview: ( input: PullRequestSubmitReviewInput, ) => Effect.Effect; @@ -144,6 +153,9 @@ export class PullRequestService extends Context.Service< readonly setThreadResolution: ( input: PullRequestThreadResolutionInput, ) => Effect.Effect; + readonly setReaction: ( + input: PullRequestReactionInput, + ) => Effect.Effect; readonly reviewerCandidates: ( input: PullRequestRef, ) => Effect.Effect; @@ -174,8 +186,14 @@ const ACTION_ACCESS_REFUSALS: Record = { "You need write access on this repository, or to have opened this change request, to return it to a draft.", close: "You need write access on this repository, or to have opened this change request, to close it.", + "update-branch": + "You need write access on this repository, or to have opened this change request, to update its branch.", reopen: "You need write access on this repository, or to have opened this change request, to reopen it.", + "enable-auto-merge": + "You need write access on this repository to have it merged for you once it is ready.", + "disable-auto-merge": + "You need write access on this repository to stop it being merged for you once it is ready.", }; /** @@ -349,13 +367,25 @@ function toPullRequestError( } /** - * The provider-native repository identity. `displayName` is the full path below the host, which - * is what nested GitLab groups and Azure project paths need; owner/name is the two-segment - * fallback for identities recorded before that field existed. + * The provider-native repository selector. `displayName` is the full path below the host, which + * is what nested GitLab groups need; owner/name is the two-segment fallback for identities + * recorded before that field existed. + * + * Azure DevOps is the exception: `az repos pr list --repository` takes a repository name, and + * takes the organisation and project from the checkout it detects — so the recorded + * `org/project/_git/repo` path is refused outright and the whole repository reads as + * unavailable. Its name is the last segment, which is what this hands over. + * + * One function because everything downstream is keyed by what it answers: the rows' own + * `repository`, the per-repository cursors, and the detail and diff reads a row leads to. */ -function repositoryIdentityOf(project: OrchestrationProjectShell): string | null { +export function repositoryIdentityOf(project: OrchestrationProjectShell): string | null { const identity = project.repositoryIdentity; if (!identity) return null; + if (identity.provider === "azure-devops") { + const segments = (identity.displayName ?? "").split("/").filter((part) => part !== "_git"); + return identity.name || segments.at(-1) || null; + } if (identity.displayName) return identity.displayName; return identity.owner && identity.name ? `${identity.owner}/${identity.name}` : null; } @@ -424,7 +454,7 @@ export const make = Effect.gen(function* () { }; const listWorkspaceProjects = ( - filter: Pick, + filter: Pick, ): Effect.Effect => projections.getShellSnapshot().pipe( Effect.mapError( @@ -450,6 +480,7 @@ export const make = Effect.gen(function* () { const seen = new Set(); for (const project of snapshot.projects) { if (filter.projectId !== undefined && project.id !== filter.projectId) continue; + if (filter.projectIds !== undefined && !filter.projectIds.includes(project.id)) continue; const identity = project.repositoryIdentity; let kind = identity?.provider as SourceControlProviderKind | undefined; const repository = repositoryIdentityOf(project); @@ -603,6 +634,43 @@ export const make = Effect.gen(function* () { { concurrency: REPOSITORY_CONCURRENCY }, ); + /** + * The narrowings a row can be judged by from its own fields, applied here rather than trusted + * to the host. Only GitHub is asked to narrow a listing for itself; every other provider + * answers unnarrowed, and without this pass a draft filter or a label filter would be sent, + * accepted and quietly ignored. Idempotent for the hosts that did narrow. + * + * `checks` is absent because no listed row carries its check state: that one filter is the + * host's alone, and a row nobody narrowed stays rather than being guessed at. + */ + const matchesRowFilters = ( + item: ProviderChangeRequest, + filters: PullRequestListFilters | undefined, + viewer: string, + ): boolean => { + if (filters === undefined) return true; + const labels = item.labels.map((label) => label.name.trim().toLowerCase()); + const holds = (label: string) => labels.includes(label.trim().toLowerCase()); + return ( + (filters.draft === undefined || item.isDraft === (filters.draft === "only")) && + // Judged on the provider row rather than the entry, because the two absences mean + // different things and the entry keeps only one of them: `null` is a host that summarises + // its reviews saying there is no decision yet, which is what "none" asks for, while + // `undefined` is a host that does not summarise at all — an unjudgeable row, left alone + // the way an unreadable check state is. + (filters.review === undefined || + item.reviewDecision === undefined || + (filters.review === "none" + ? item.reviewDecision === null + : item.reviewDecision === filters.review)) && + (filters.labels === undefined || filters.labels.every((group) => group.some(holds))) && + (filters.excludedLabels === undefined || !filters.excludedLabels.some(holds)) && + (filters.author === undefined || + item.author?.login.toLowerCase() === + resolvePullRequestAuthorFilter(filters.author, viewer).toLowerCase()) + ); + }; + const toEntry = (input: { readonly project: SupportedProject; readonly item: ProviderChangeRequest; @@ -628,10 +696,16 @@ export const make = Effect.gen(function* () { deletions: input.item.deletions, createdAt: input.item.createdAt, updatedAt: input.item.updatedAt, + ...(input.item.checksState === undefined || input.item.checksState === null + ? {} + : { checksState: input.item.checksState }), viewerReviewRequested: input.item.author?.login.toLowerCase() !== viewer && input.item.reviewRequestLogins.some((login) => login.toLowerCase() === viewer), labels: input.item.labels, + ...(input.item.reviewDecision === undefined || input.item.reviewDecision === null + ? {} + : { reviewDecision: input.item.reviewDecision }), }; }; @@ -755,6 +829,7 @@ export const make = Effect.gen(function* () { // Each host matches this its own way, and one that cannot match text at all // answers unnarrowed rather than failing. query: input.query, + filters: input.filters, // Only the two fields a host can act on: which rows have already been sent at the // boundary instant is this service's business, not a provider's. ...(cursor === undefined @@ -778,7 +853,9 @@ export const make = Effect.gen(function* () { ); return { key, - entries: items.map((item) => toEntry({ project, item, viewer })), + entries: items + .filter((item) => matchesRowFilters(item, input.filters, viewer)) + .map((item) => toEntry({ project, item, viewer })), errors: [], truncated: page.truncated, nextCursor: @@ -836,6 +913,7 @@ export const make = Effect.gen(function* () { viewer, limit, query: input.query, + filters: input.filters, ...(cursor === undefined ? {} : { cursor: { updatedBefore: cursor.updatedBefore, delivered: cursor.delivered } }), @@ -881,7 +959,9 @@ export const make = Effect.gen(function* () { ); return Effect.succeed({ key: listCursorKey(project.host, project.repository), - entries: items.map((item) => toEntry({ project, item, viewer })), + entries: items + .filter((item) => matchesRowFilters(item, input.filters, viewer)) + .map((item) => toEntry({ project, item, viewer })), errors: [], truncated: page.truncated, nextCursor: @@ -939,51 +1019,72 @@ export const make = Effect.gen(function* () { }; }); + /** + * Who this project's host says the reader is. Shared with the listing's own lookup — the same + * ten-minute answer per host — so a page that has already listed anything pays nothing for it, + * and a host that cannot say leaves it null rather than failing the read it decorates. + */ + const viewerOf = (project: SupportedProject): Effect.Effect => + resolveViewers([project], new Map()).pipe(Effect.map(([resolved]) => resolved?.viewer ?? null)); + const detailUncached: PullRequestService["Service"]["detail"] = (input) => requireProject(input).pipe( Effect.flatMap((project) => - project.api - .getChangeRequest({ - cwd: project.project.workspaceRoot, - repository: project.repository, - host: project.host, - number: input.number, - }) - .pipe( - Effect.mapError(toPullRequestError("detail")), - Effect.map( - (changeRequest): PullRequestDetail => ({ - provider: project.api.kind, - capabilities: project.api.capabilities, - projectId: project.project.id, - projectTitle: project.project.title, - workspaceRoot: project.project.workspaceRoot, + Effect.all( + [ + project.api + .getChangeRequest({ + cwd: project.project.workspaceRoot, repository: project.repository, - number: changeRequest.number, - title: changeRequest.title, - body: changeRequest.body, - url: changeRequest.url, - author: changeRequest.author, - state: changeRequest.state, - isDraft: changeRequest.isDraft, - mergeability: changeRequest.mergeability, - additions: changeRequest.additions, - deletions: changeRequest.deletions, - changedFiles: changeRequest.changedFiles, - headBranch: changeRequest.headBranch, - baseBranch: changeRequest.baseBranch, - createdAt: changeRequest.createdAt, - updatedAt: changeRequest.updatedAt, - mergedAt: changeRequest.mergedAt, - closedAt: changeRequest.closedAt, - reviewers: changeRequest.reviewers, - labels: changeRequest.labels, - checks: changeRequest.checks, - mergeCapabilities: changeRequest.mergeCapabilities, - viewerPermissions: changeRequest.viewerPermissions, - }), - ), + host: project.host, + number: input.number, + }) + .pipe(Effect.mapError(toPullRequestError("detail"))), + viewerOf(project), + ], + { concurrency: 2 }, + ).pipe( + Effect.map( + ([changeRequest, viewer]): PullRequestDetail => ({ + provider: project.api.kind, + capabilities: project.api.capabilities, + projectId: project.project.id, + projectTitle: project.project.title, + workspaceRoot: project.project.workspaceRoot, + repository: project.repository, + number: changeRequest.number, + title: changeRequest.title, + body: changeRequest.body, + url: changeRequest.url, + author: changeRequest.author, + state: changeRequest.state, + isDraft: changeRequest.isDraft, + mergeability: changeRequest.mergeability, + additions: changeRequest.additions, + deletions: changeRequest.deletions, + changedFiles: changeRequest.changedFiles, + headBranch: changeRequest.headBranch, + baseBranch: changeRequest.baseBranch, + createdAt: changeRequest.createdAt, + updatedAt: changeRequest.updatedAt, + mergedAt: changeRequest.mergedAt, + closedAt: changeRequest.closedAt, + reviewers: changeRequest.reviewers, + labels: changeRequest.labels, + checks: changeRequest.checks, + mergeCapabilities: changeRequest.mergeCapabilities, + viewerPermissions: changeRequest.viewerPermissions, + ...(viewer === null || viewer.trim().length === 0 ? {} : { viewer }), + ...(changeRequest.baseComparison === undefined + ? {} + : { baseComparison: changeRequest.baseComparison }), + ...(changeRequest.behindBy === undefined ? {} : { behindBy: changeRequest.behindBy }), + ...(changeRequest.autoMergeEnabled === undefined + ? {} + : { autoMergeEnabled: changeRequest.autoMergeEnabled }), + }), ), + ), ), ); @@ -1008,6 +1109,7 @@ export const make = Effect.gen(function* () { commentsTruncated: activity.commentsTruncated, reviewThreads: activity.reviewThreads, commits: activity.commits, + ...(activity.reactions === undefined ? {} : { reactions: activity.reactions }), }), ), ), @@ -1088,6 +1190,19 @@ export const make = Effect.gen(function* () { }), ); } + // The same for the way a stale branch is brought up to date: a host that only merges + // must not be asked to rebase and left to pick something else. + if ( + input.updateMethod !== undefined && + !(project.api.capabilities.updateMethods ?? []).includes(input.updateMethod) + ) { + return Effect.fail( + new PullRequestOperationError({ + operation: "runAction", + detail: `This host cannot update a branch by ${input.updateMethod}.`, + }), + ); + } // What the host can do and what this account may ask of it are two questions, and both // have to say yes. The second is asked last, because it costs a request and the checks // above do not. @@ -1101,6 +1216,17 @@ export const make = Effect.gen(function* () { }), ); } + if ( + input.updateMethod !== undefined && + !(viewer.updateMethods ?? []).includes(input.updateMethod) + ) { + return Effect.fail( + new PullRequestOperationError({ + operation: "runAction", + detail: ACTION_ACCESS_REFUSALS["update-branch"], + }), + ); + } return project.api .runAction({ cwd: project.project.workspaceRoot, @@ -1109,6 +1235,7 @@ export const make = Effect.gen(function* () { number: input.number, action: input.action, ...(input.mergeMethod === undefined ? {} : { mergeMethod: input.mergeMethod }), + ...(input.updateMethod === undefined ? {} : { updateMethod: input.updateMethod }), }) .pipe(Effect.mapError(toPullRequestError("runAction"))); }), @@ -1162,6 +1289,76 @@ export const make = Effect.gen(function* () { }), ); + /** + * Rewriting the change request's own words, and rewriting a remark, are both left to the host to + * allow or refuse. Neither is a question a permission read answers: every host lets the person + * who wrote something rewrite it whatever access they have otherwise, and none of them reports + * that as a permission — so a check here could only guess, and a wrong guess takes the control + * away from the one person certain to be allowed. + */ + const update: PullRequestService["Service"]["update"] = (input) => + requireProject(input).pipe( + Effect.flatMap((project): Effect.Effect => { + const rewrite = project.api.updateChangeRequest; + if (project.api.capabilities.edit?.changeRequest !== true || rewrite === undefined) { + return Effect.fail( + new PullRequestOperationError({ + operation: "update", + detail: "This host cannot rewrite a change request.", + }), + ); + } + if (input.title === undefined && input.body === undefined) { + return Effect.fail( + new PullRequestOperationError({ + operation: "update", + detail: "Nothing was changed.", + }), + ); + } + return rewrite({ + cwd: project.project.workspaceRoot, + repository: project.repository, + host: project.host, + number: input.number, + ...(input.title === undefined ? {} : { title: input.title }), + ...(input.body === undefined ? {} : { body: input.body }), + }).pipe(Effect.mapError(toPullRequestError("update"))); + }), + ); + + const updateComment: PullRequestService["Service"]["updateComment"] = (input) => + (input.body.trim().length === 0 + ? Effect.fail( + new PullRequestOperationError({ + operation: "updateComment", + detail: "A comment cannot be empty.", + }), + ) + : requireProject(input) + ).pipe( + Effect.flatMap((project): Effect.Effect => { + const rewrite = project.api.updateComment; + if (project.api.capabilities.edit?.comment !== true || rewrite === undefined) { + return Effect.fail( + new PullRequestOperationError({ + operation: "updateComment", + detail: "This host cannot rewrite a comment.", + }), + ); + } + return rewrite({ + cwd: project.project.workspaceRoot, + repository: project.repository, + host: project.host, + number: input.number, + commentId: input.commentId, + kind: input.kind, + body: input.body, + }).pipe(Effect.mapError(toPullRequestError("updateComment"))); + }), + ); + const submitReview: PullRequestService["Service"]["submitReview"] = (input) => requireProject(input).pipe( Effect.flatMap((project): Effect.Effect => { @@ -1297,6 +1494,36 @@ export const make = Effect.gen(function* () { }), ); + /** + * Reacting is gated on the host alone. Every host with reactions takes one from whoever can read + * the change request, so there is no access left to check that reading it has not already + * settled. + */ + const setReaction: PullRequestService["Service"]["setReaction"] = (input) => + requireProject(input).pipe( + Effect.flatMap((project): Effect.Effect => { + if (project.api.capabilities.reactions !== true) { + return Effect.fail( + new PullRequestOperationError({ + operation: "setReaction", + detail: "This host has no reactions.", + }), + ); + } + return project.api + .setReaction({ + cwd: project.project.workspaceRoot, + repository: project.repository, + host: project.host, + number: input.number, + ...(input.subjectId === undefined ? {} : { subjectId: input.subjectId }), + content: input.content, + reacted: input.reacted, + }) + .pipe(Effect.mapError(toPullRequestError("setReaction"))); + }), + ); + /** * Who may be asked is only ever wanted by somebody about to ask, because the menu it fills is * the one the request is made from. So the same permission guards both: a page that could open @@ -1520,6 +1747,23 @@ export const make = Effect.gen(function* () { refEpochs.set(scope, ++epochCounter); }; + /** The positional filter slot of a cache key, back as the record `listUncached` takes. */ + const filtersOfKey = ( + slots: ReadonlyArray< + string | ReadonlyArray | ReadonlyArray> | null + >, + ): PullRequestListFilters => { + const [draft, review, checks, author, labels, excludedLabels] = slots; + return { + ...(typeof draft === "string" ? { draft: draft as "only" | "hide" } : {}), + ...(typeof review === "string" ? { review: review as PullRequestListFilters["review"] } : {}), + ...(typeof checks === "string" ? { checks: checks as PullRequestListFilters["checks"] } : {}), + ...(typeof author === "string" ? { author } : {}), + ...(Array.isArray(labels) ? { labels: labels as ReadonlyArray> } : {}), + ...(Array.isArray(excludedLabels) ? { excludedLabels } : {}), + }; + }; + // 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. // The continuation cursors are part of the key, entries sorted so one continuation is one @@ -1528,13 +1772,24 @@ export const make = Effect.gen(function* () { (key: string) => { // The parse undoes this module's own serialization, so the shapes are known exactly; // the cast restores the branded field types JSON cannot carry. - const [, state, involvement, projectId, host, limit, query, cursorEntries] = JSON.parse( - key, - ) as [ + const [ + , + state, + involvement, + filters, + projectId, + projectIds, + host, + limit, + query, + cursorEntries, + ] = JSON.parse(key) as [ number, string, string | null, + ReadonlyArray | null> | null, string | null, + ReadonlyArray | null, string | null, number | null, string | null, @@ -1543,7 +1798,9 @@ export const make = Effect.gen(function* () { return listUncached({ state, ...(involvement === null ? {} : { involvement }), + ...(filters === null ? {} : { filters: filtersOfKey(filters) }), ...(projectId === null ? {} : { projectId }), + ...(projectIds === null ? {} : { projectIds }), ...(host === null ? {} : { host }), ...(limit === null ? {} : { limit }), ...(query === null ? {} : { query }), @@ -1564,7 +1821,20 @@ export const make = Effect.gen(function* () { listingsEpoch, input.state, input.involvement ?? null, + // Positional so two identical filter sets key alike however their record was assembled. + input.filters === undefined + ? null + : [ + input.filters.draft ?? null, + input.filters.review ?? null, + input.filters.checks ?? null, + input.filters.author ?? null, + input.filters.labels ?? null, + input.filters.excludedLabels ?? null, + ], input.projectId ?? null, + // Sorted so the same narrowing keys alike however the caller ordered it. + input.projectIds === undefined ? null : [...input.projectIds].sort(), input.host ?? null, input.limit ?? null, input.query ?? null, @@ -1726,10 +1996,13 @@ export const make = Effect.gen(function* () { diff, diffFileContents, runAction: invalidatedByMutation(runAction), + update: invalidatedByMutation(update), comment: invalidatedByMutation(comment), + updateComment: invalidatedByMutation(updateComment), submitReview: invalidatedByMutation(submitReview), replyToThread: invalidatedByMutation(replyToThread), setThreadResolution: invalidatedByMutation(setThreadResolution), + setReaction: invalidatedByMutation(setReaction), // The candidate list is deliberately read fresh per menu-open, so it stays uncached. reviewerCandidates, requestReviewers: invalidatedByMutation(requestReviewers), diff --git a/apps/server/src/pullRequest/azureDevOpsPullRequestJson.test.ts b/apps/server/src/pullRequest/azureDevOpsPullRequestJson.test.ts index 3ac55cde1e8d..a975c89f858c 100644 --- a/apps/server/src/pullRequest/azureDevOpsPullRequestJson.test.ts +++ b/apps/server/src/pullRequest/azureDevOpsPullRequestJson.test.ts @@ -144,6 +144,21 @@ describe("decodePullRequestJson", () => { ]); }); + it("reads auto-complete from whoever armed it, and its absence as nobody", () => { + const armed = expectSuccess( + decodePullRequestJson( + asJson(pullRequest({ autoCompleteSetBy: { displayName: "Bilal Hassan" } })), + ), + ); + expect(armed?.autoMergeEnabled).toBe(true); + + // Azure leaves the field out entirely rather than sending it empty, so its absence is the + // whole of what it says about auto-complete being off. + expect(expectSuccess(decodePullRequestJson(asJson(pullRequest())))?.autoMergeEnabled).toBe( + false, + ); + }); + it("works out where the conversation lives from what Azure returned", () => { const detail = expectSuccess(decodePullRequestJson(asJson(pullRequest()))); diff --git a/apps/server/src/pullRequest/azureDevOpsPullRequestJson.ts b/apps/server/src/pullRequest/azureDevOpsPullRequestJson.ts index a51eef4f0ce2..39ca4a551d27 100644 --- a/apps/server/src/pullRequest/azureDevOpsPullRequestJson.ts +++ b/apps/server/src/pullRequest/azureDevOpsPullRequestJson.ts @@ -35,6 +35,12 @@ const RawPullRequestSchema = Schema.Struct({ description: Schema.optional(Schema.NullOr(Schema.String)), status: Schema.optional(Schema.NullOr(Schema.String)), isDraft: Schema.optional(Schema.NullOr(Schema.Boolean)), + /** + * Who armed auto-complete, which is the only thing Azure says about it: the field carries an + * identity while the pull request is set to complete on its own, and Azure leaves it out + * entirely once nobody has. So its presence is the answer, and there is no third state. + */ + autoCompleteSetBy: Schema.optional(Schema.NullOr(RawIdentitySchema)), mergeStatus: Schema.optional(Schema.NullOr(Schema.String)), createdBy: Schema.optional(Schema.NullOr(RawIdentitySchema)), reviewers: Schema.optional(Schema.NullOr(Schema.Array(RawIdentitySchema))), @@ -124,6 +130,8 @@ export interface AzureDevOpsPullRequest { readonly reviewers: ReadonlyArray; /** Where this pull request's threads live, when Azure said enough to work it out. */ readonly threadsUrl: string | null; + /** Whether Azure is set to complete this on its own once its policies pass. */ + readonly autoMergeEnabled: boolean; } function trimmed(value: string | null | undefined): string | null { @@ -223,6 +231,7 @@ function toPullRequest( reviewRequestLogins: reviewers.map((reviewer) => reviewer.login), reviewers, threadsUrl: toThreadsUrl(raw), + autoMergeEnabled: (raw.autoCompleteSetBy ?? null) !== null, }; } diff --git a/apps/server/src/pullRequest/bitbucketPullRequestJson.test.ts b/apps/server/src/pullRequest/bitbucketPullRequestJson.test.ts index 81212949fdbd..a348ac4b30e8 100644 --- a/apps/server/src/pullRequest/bitbucketPullRequestJson.test.ts +++ b/apps/server/src/pullRequest/bitbucketPullRequestJson.test.ts @@ -315,6 +315,22 @@ describe("decodeStatusesJson", () => { expect(decoded.items[0]?.status).toBe(expected); }); + + it("keeps two statuses that share a display name but have different keys", () => { + const decoded = expectSuccess( + decodeStatusesJson( + page([ + { key: "build", name: "Pipeline", state: "SUCCESSFUL" }, + { key: "deploy", name: "Pipeline", state: "FAILED" }, + ]), + ), + ); + + expect(decoded.items.map((check) => [check.name, check.status])).toEqual([ + ["build / Pipeline", "success"], + ["deploy / Pipeline", "failure"], + ]); + }); }); describe("decodeDiffstatJson", () => { diff --git a/apps/server/src/pullRequest/bitbucketPullRequestJson.ts b/apps/server/src/pullRequest/bitbucketPullRequestJson.ts index 697ab2bbb973..b0711b8ff6d8 100644 --- a/apps/server/src/pullRequest/bitbucketPullRequestJson.ts +++ b/apps/server/src/pullRequest/bitbucketPullRequestJson.ts @@ -18,6 +18,8 @@ import type { import { TrimmedNonEmptyString } from "@t3tools/contracts"; import { decodeJsonResult } from "@t3tools/shared/schemaJson"; +import { dedupeChecks } from "./pullRequestChecks.ts"; + /** * Bitbucket's enums are decoded as plain strings and normalized here, in the same tolerant * style as the GitHub and GitLab decoders: a new pull request state or build status must not @@ -547,21 +549,33 @@ export function decodeStatusesJson( if (!Result.isSuccess(decoded)) { return Result.fail(decoded.failure); } - const checks: PullRequestCheck[] = []; + const checks: Array<{ + readonly check: PullRequestCheck; + readonly workflowName: string | null; + readonly at: string | null; + }> = []; for (const entry of decoded.success.values) { const decodedStatus = decodeStatusEntry(entry); if (Exit.isFailure(decodedStatus)) continue; const status = decodedStatus.value; const name = trimmed(status.name) ?? trimmed(status.key); if (name === null) continue; + // Bitbucket re-uses a status key when a pipeline is run again, so the same check can appear + // twice on one page. Nothing decoded here says which copy is newer, so the later one wins, + // which is the order Bitbucket writes an update in. The key is kept as the workflow name so + // two different pipelines that display the same name are not folded into one. checks.push({ - name, - status: toBuildStatus(status.state), - description: trimmed(status.description), - url: trimmed(status.url), + check: { + name, + status: toBuildStatus(status.state), + description: trimmed(status.description), + url: trimmed(status.url), + }, + workflowName: trimmed(status.key), + at: null, }); } - return Result.succeed({ items: checks, next: trimmed(decoded.success.next) }); + return Result.succeed({ items: dedupeChecks(checks), next: trimmed(decoded.success.next) }); } export interface BitbucketDiffStat { diff --git a/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts b/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts index d3d9945da3ad..a3c3524a6d38 100644 --- a/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts +++ b/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts @@ -4,16 +4,20 @@ import { describe, expect, it } from "vite-plus/test"; import { buildReviewSubmissionJson, buildReviewerRequestJson, + decodeBaseComparisonJson, decodePullRequestActivityJson, decodePullRequestDetailJson, decodePullRequestFilesJson, decodePullRequestListJson, + decodePullRequestNodeIdJson, + decodePullRequestSearchJson, decodeRepositoryAccessJson, decodeReviewerCandidatesJson, decodeReviewThreadCommentsJson, decodeReviewThreadsJson, decodeViewerPermissionsJson, reviewThreadConversation, + REVIEW_THREADS_GRAPHQL_QUERY, } from "./gitHubPullRequestJson.ts"; function listJson(entries: ReadonlyArray>): string { @@ -67,6 +71,65 @@ describe("pull request list decoding", () => { expect(entry?.reviewRequestLogins).toEqual(["octocat"]); }); + it("normalizes the review decision and reports nothing for one GitHub does not summarize", () => { + const batch = expectSuccess( + decodePullRequestListJson( + listJson([ + { reviewDecision: "APPROVED" }, + { reviewDecision: "CHANGES_REQUESTED" }, + { reviewDecision: "REVIEW_REQUIRED" }, + { reviewDecision: null }, + ]), + ), + ); + expect(batch.items.map((entry) => entry.reviewDecision)).toEqual([ + "approved", + "changes-requested", + "review-required", + null, + ]); + }); + + it("rolls the head commit's checks up to the one word a row has space for", () => { + const batch = expectSuccess( + decodePullRequestListJson( + listJson([ + // A failure outranks a run still going, and a completed run has to be read through its + // conclusion rather than its status. + { + statusCheckRollup: [ + { name: "lint", status: "COMPLETED", conclusion: "SUCCESS" }, + { name: "build", status: "IN_PROGRESS" }, + { name: "test", status: "COMPLETED", conclusion: "FAILURE" }, + ], + }, + { + statusCheckRollup: [ + { name: "lint", status: "COMPLETED", conclusion: "SUCCESS" }, + { name: "build", status: "QUEUED" }, + ], + }, + { statusCheckRollup: [{ name: "lint", status: "COMPLETED", conclusion: "SUCCESS" }] }, + // A commit status reports one `state` and no `status` at all. + { statusCheckRollup: [{ context: "ci/legacy", state: "ERROR" }] }, + // Neither a pass, a failure nor a wait is no verdict rather than a green tick. + { statusCheckRollup: [{ name: "lint", status: "COMPLETED", conclusion: "SKIPPED" }] }, + { statusCheckRollup: [] }, + {}, + ]), + ), + ); + expect(batch.items.map((entry) => entry.checksState)).toEqual([ + "failing", + "pending", + "passing", + "failing", + null, + null, + null, + ]); + }); + it("skips malformed entries but still counts them, so paging does not stop early", () => { const raw = `[${listJson([{}]).slice(1, -1)},{"number":"not-a-number"}]`; const batch = expectSuccess(decodePullRequestListJson(raw)); @@ -75,6 +138,49 @@ describe("pull request list decoding", () => { }); }); +describe("pull request search decoding", () => { + function searchJson(rollupStates: ReadonlyArray): string { + return JSON.stringify({ + data: { + search: { + pageInfo: { hasNextPage: false }, + nodes: rollupStates.map((state, index) => ({ + number: index + 1, + title: "Add the pull requests page", + url: "https://github.com/pingdotgg/t3code/pull/1", + headRefName: "feat/page", + baseRefName: "main", + createdAt: "2026-07-01T00:00:00Z", + updatedAt: "2026-07-02T00:00:00Z", + repository: { nameWithOwner: "pingdotgg/t3code" }, + commits: { + nodes: [{ commit: { statusCheckRollup: state === null ? null : { state } } }], + }, + })), + }, + }, + }); + } + + 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. + const batch = expectSuccess( + decodePullRequestSearchJson( + searchJson(["SUCCESS", "FAILURE", "ERROR", "PENDING", "EXPECTED", null]), + ), + ); + expect(batch.items.map((entry) => entry.checksState)).toEqual([ + "passing", + "failing", + "failing", + "pending", + "pending", + null, + ]); + }); +}); + describe("pull request detail decoding", () => { const detailJson = JSON.stringify({ number: 7, @@ -117,6 +223,58 @@ describe("pull request detail decoding", () => { ]); }); + it("reads an auto-merge request as armed, its null as off and its absence as neither", () => { + const raw = JSON.parse(detailJson) as Record; + const armed = (entry: Record) => + expectSuccess(decodePullRequestDetailJson(JSON.stringify({ ...raw, ...entry }))) + .autoMergeEnabled; + + expect( + armed({ autoMergeRequest: { enabledBy: { login: "octocat" }, mergeMethod: "SQUASH" } }), + ).toBe(true); + expect(armed({ autoMergeRequest: null })).toBe(false); + // `gh` not answering for the field at all is not GitHub saying the merge is unarmed. + expect(armed({})).toBeUndefined(); + }); + + it("shows a re-running check once, as the run that is happening now", () => { + // What `statusCheckRollup` reports while a workflow is being re-run: the same check twice, + // the finished run and the one that replaced it, with no id to tell them apart. + const raw = JSON.parse(detailJson) as Record; + const detail = expectSuccess( + decodePullRequestDetailJson( + JSON.stringify({ + ...raw, + statusCheckRollup: [ + { + __typename: "CheckRun", + name: "Prepare PR size config", + workflowName: "PR Size", + status: "COMPLETED", + conclusion: "SUCCESS", + startedAt: "2026-08-11T16:06:20Z", + completedAt: "2026-08-11T16:06:25Z", + }, + { + __typename: "CheckRun", + name: "Prepare PR size config", + workflowName: "PR Size", + status: "IN_PROGRESS", + conclusion: "", + startedAt: "2026-08-11T17:01:04Z", + completedAt: "0001-01-01T00:00:00Z", + }, + ], + }), + ), + ); + + expect(detail.checks.map((check) => [check.name, check.status])).toEqual([ + ["Prepare PR size config", "pending"], + ]); + expect(detail.checksState).toBe("pending"); + }); + it("merges reviews with comments in time order and keeps a bodyless approval", () => { const detail = expectSuccess(decodePullRequestActivityJson(detailJson)); // r2 approved without writing anything, which is still the event worth seeing. @@ -461,6 +619,89 @@ describe("review thread decoding", () => { }); }); +describe("reaction decoding", () => { + const commentWithGroups = (reactionGroups: ReadonlyArray>) => + JSON.stringify({ + data: { + node: { + comments: { + pageInfo: { hasNextPage: false, endCursor: null }, + nodes: [{ id: "t1", body: "nice", createdAt: "2026-07-01T00:00:00Z", reactionGroups }], + }, + }, + }, + }); + + it("keeps a named group, widens a group whose reactors were cut short, drops an unknown content and an empty group", () => { + const decoded = expectSuccess( + decodeReviewThreadCommentsJson( + commentWithGroups([ + { + content: "THUMBS_UP", + viewerHasReacted: true, + reactors: { totalCount: 2, nodes: [{ login: "julius" }, { login: "bilal" }] }, + }, + // Not one of the eight the contract carries. + { + content: "PARTY_PARROT", + reactors: { totalCount: 1, nodes: [{ login: "hubot" }] }, + }, + // Nobody behind it, which GitHub still answers a group for. + { content: "HEART", reactors: { totalCount: 0, nodes: [] } }, + // More reactors than the bounded read named, and no `viewerHasReacted` at all. + { + content: "ROCKET", + reactors: { totalCount: 140, nodes: [{ login: "a" }, { login: "b" }, { login: "c" }] }, + }, + ]), + ), + ); + + expect(decoded.comments[0]?.reactions).toEqual([ + { content: "thumbs-up", count: 2, actors: ["julius", "bilal"], viewerHasReacted: true }, + { content: "rocket", count: 140, actors: ["a", "b", "c"], viewerHasReacted: false }, + ]); + }); + + it("leaves the viewer's own login out of actors, matched case-insensitively, while count still counts them", () => { + const decoded = expectSuccess( + decodeReviewThreadCommentsJson( + JSON.stringify({ + data: { + viewer: { login: "Bilal" }, + node: { + comments: { + pageInfo: { hasNextPage: false, endCursor: null }, + nodes: [ + { + id: "t1", + body: "nice", + createdAt: "2026-07-01T00:00:00Z", + reactionGroups: [ + { + content: "HEART", + viewerHasReacted: true, + reactors: { + totalCount: 2, + nodes: [{ login: "bilal" }, { login: "julius" }], + }, + }, + ], + }, + ], + }, + }, + }, + }), + ), + ); + + expect(decoded.comments[0]?.reactions).toEqual([ + { content: "heart", count: 2, actors: ["julius"], viewerHasReacted: true }, + ]); + }); +}); + describe("repository access decoding", () => { const repositoryJson = (viewerPermission?: string | null) => JSON.stringify({ @@ -618,6 +859,7 @@ describe("review thread decoding", () => { body: "first", createdAt: "2026-07-01T00:00:00Z", url: "https://github.com/acme/web/pull/1#discussion_rc1", + reactions: [], }, { id: "c2", @@ -625,6 +867,7 @@ describe("review thread decoding", () => { body: "second", createdAt: "2026-07-01T00:00:00Z", url: "https://github.com/acme/web/pull/1#discussion_rc2", + reactions: [], }, ], }, @@ -676,6 +919,101 @@ describe("review thread decoding", () => { expect(reviewThreadConversation(threads).map((comment) => comment.id)).toEqual(["c4"]); expect(threads).toHaveLength(1); }); + + it("puts an issue comment's and a review's reactions in reactionsById, and the pull request's own in reactions", () => { + const result = expectSuccess( + decodeReviewThreadsJson( + threadsJson([], { + reactionGroups: [ + { + content: "HEART", + viewerHasReacted: true, + reactors: { totalCount: 1, nodes: [{ login: "bilal" }] }, + }, + ], + comments: { + nodes: [ + { + id: "c1", + reactionGroups: [ + { + content: "THUMBS_UP", + reactors: { totalCount: 1, nodes: [{ login: "julius" }] }, + }, + ], + }, + ], + }, + reviews: { + nodes: [ + { + id: "r1", + reactionGroups: [ + { content: "EYES", reactors: { totalCount: 1, nodes: [{ login: "hubot" }] } }, + ], + }, + ], + }, + }), + ), + ); + + expect(result.reactions).toEqual([ + { content: "heart", count: 1, actors: ["bilal"], viewerHasReacted: true }, + ]); + expect([...result.reactionsById]).toEqual([ + ["c1", [{ content: "thumbs-up", count: 1, actors: ["julius"], viewerHasReacted: false }]], + ["r1", [{ content: "eyes", count: 1, actors: ["hubot"], viewerHasReacted: false }]], + ]); + }); + + it("leaves the viewer's own login out of the pull request's own reactions, matched case-insensitively, while count still counts them", () => { + const result = expectSuccess( + decodeReviewThreadsJson( + JSON.stringify({ + data: { + viewer: { login: "Bilal" }, + repository: { + pullRequest: { + reviewThreads: { totalCount: 0, nodes: [] }, + reactionGroups: [ + { + content: "HEART", + viewerHasReacted: true, + reactors: { totalCount: 2, nodes: [{ login: "bilal" }, { login: "julius" }] }, + }, + ], + }, + }, + }, + }), + ), + ); + + expect(result.reactions).toEqual([ + { content: "heart", count: 2, actors: ["julius"], viewerHasReacted: true }, + ]); + }); +}); + +describe("decodePullRequestNodeIdJson", () => { + it("reads the pull request's own node id, which a reaction on its description is addressed by", () => { + expect( + expectSuccess( + decodePullRequestNodeIdJson( + JSON.stringify({ data: { repository: { pullRequest: { id: "PR_kwDOA" } } } }), + ), + ), + ).toBe("PR_kwDOA"); + }); +}); + +describe("REVIEW_THREADS_GRAPHQL_QUERY", () => { + it("asks for reactionGroups on the pull request itself, its comments, its reviews and each thread's comments", () => { + expect(REVIEW_THREADS_GRAPHQL_QUERY.match(/reactionGroups/g)).toHaveLength(4); + // The reviews connection is new: only reactions were ever wanted off it. + expect(REVIEW_THREADS_GRAPHQL_QUERY).toContain("reviews(first:"); + }); }); describe("reviewer candidate decoding", () => { @@ -957,3 +1295,45 @@ describe("decodePullRequestFilesJson", () => { expect(result.truncated).toBe(false); }); }); + +describe("how far a branch trails its base", () => { + const comparison = (pullRequest: unknown) => + JSON.stringify({ data: { repository: { pullRequest } } }); + + it("reads the commit count and whether this viewer may move the branch", () => { + const decoded = expectSuccess( + decodeBaseComparisonJson( + comparison({ viewerCanUpdateBranch: true, baseRef: { compare: { behindBy: 12 } } }), + ), + ); + expect(decoded).toEqual({ behindBy: 12, viewerCanUpdate: true }); + }); + + it("reads a current branch as nothing to do", () => { + expect( + expectSuccess( + decodeBaseComparisonJson( + comparison({ viewerCanUpdateBranch: false, baseRef: { compare: { behindBy: 0 } } }), + ), + ), + ).toEqual({ behindBy: 0, viewerCanUpdate: false }); + }); + + it("answers unknown where the head could not be compared", () => { + // A pull request from a fork whose repository is gone, which GitHub answers with a null + // comparison beside a perfectly good pull request. + expect( + expectSuccess( + decodeBaseComparisonJson(comparison({ viewerCanUpdateBranch: true, baseRef: null })), + ).behindBy, + ).toBeNull(); + expect(expectSuccess(decodeBaseComparisonJson(comparison(null)))).toEqual({ + behindBy: null, + viewerCanUpdate: false, + }); + }); + + it("refuses a body that is not the answer to this question", () => { + expect(Result.isSuccess(decodeBaseComparisonJson("{"))).toBe(false); + }); +}); diff --git a/apps/server/src/pullRequest/gitHubPullRequestJson.ts b/apps/server/src/pullRequest/gitHubPullRequestJson.ts index 8668d840ce10..e113b87d81da 100644 --- a/apps/server/src/pullRequest/gitHubPullRequestJson.ts +++ b/apps/server/src/pullRequest/gitHubPullRequestJson.ts @@ -6,12 +6,17 @@ import type { PullRequestActor, PullRequestCheck, PullRequestCheckStatus, + PullRequestChecksState, PullRequestComment, PullRequestCommit, PullRequestLabel, PullRequestMergeCapabilities, + PullRequestOmittedFileStat, PullRequestMergeability, + PullRequestReaction, + PullRequestReactionContent, PullRequestReviewCommentDraft, + PullRequestReviewDecision, PullRequestReviewThread, PullRequestReviewVerdict, PullRequestReviewerCandidate, @@ -22,6 +27,8 @@ import type { } from "@t3tools/contracts"; import { decodeJsonResult } from "@t3tools/shared/schemaJson"; +import { dedupeChecks } from "./pullRequestChecks.ts"; + /** * Enum-ish GitHub CLI fields are decoded as plain strings and normalized here: a `gh` * release that adds a conclusion or a review state must not fail the whole payload. @@ -51,6 +58,27 @@ const RawReviewRequestSchema = Schema.Struct({ name: Schema.optional(Schema.NullOr(Schema.String)), }); +const RawCheckSchema = Schema.Struct({ + __typename: Schema.optional(Schema.String), + name: Schema.optional(Schema.NullOr(Schema.String)), + context: Schema.optional(Schema.NullOr(Schema.String)), + status: Schema.optional(Schema.NullOr(Schema.String)), + conclusion: Schema.optional(Schema.NullOr(Schema.String)), + state: Schema.optional(Schema.NullOr(Schema.String)), + description: Schema.optional(Schema.NullOr(Schema.String)), + detailsUrl: Schema.optional(Schema.NullOr(Schema.String)), + targetUrl: Schema.optional(Schema.NullOr(Schema.String)), + /** + * What tells two same-named checks apart, and which run of one is the newest. All three ride + * along with `statusCheckRollup` already — it is asked for as a whole field — so reading them + * costs no request. Empty for an app-provided check run, which belongs to no workflow, and + * absent entirely on a commit status, which is not a run at all. + */ + workflowName: Schema.optional(Schema.NullOr(Schema.String)), + startedAt: Schema.optional(Schema.NullOr(Schema.String)), + completedAt: Schema.optional(Schema.NullOr(Schema.String)), +}); + const RawListItemSchema = Schema.Struct({ number: Schema.Int, title: Schema.String, @@ -61,6 +89,7 @@ const RawListItemSchema = Schema.Struct({ state: Schema.optional(Schema.NullOr(Schema.String)), isDraft: Schema.optional(Schema.Boolean), mergeable: Schema.optional(Schema.NullOr(Schema.String)), + reviewDecision: Schema.optional(Schema.NullOr(Schema.String)), additions: Schema.optional(Schema.Int), deletions: Schema.optional(Schema.Int), createdAt: Schema.String, @@ -68,6 +97,14 @@ const RawListItemSchema = Schema.Struct({ mergedAt: Schema.optional(Schema.NullOr(Schema.String)), reviewRequests: Schema.optional(Schema.Array(RawReviewRequestSchema)), labels: Schema.optional(Schema.Array(RawLabelSchema)), + /** + * Every check of the head commit, which is the only rollup `gh pr list --json` can give: there + * is no field for the one-word verdict. Measured against `pingdotgg/t3code`, asking for it costs + * 0.6s -> 7.9s at a hundred rows and 0.9s -> 2.1s at thirty, for 425 KB of checks a listing + * reduces to one word. The listing pays it because the alternative is a request per row; the + * cross-repository search below asks GitHub for the verdict itself instead. + */ + statusCheckRollup: Schema.optional(Schema.NullOr(Schema.Array(RawCheckSchema))), }); /** @@ -85,6 +122,7 @@ const RawSearchItemSchema = Schema.Struct({ state: Schema.optional(Schema.NullOr(Schema.String)), isDraft: Schema.optional(Schema.Boolean), mergeable: Schema.optional(Schema.NullOr(Schema.String)), + reviewDecision: Schema.optional(Schema.NullOr(Schema.String)), createdAt: Schema.String, updatedAt: Schema.String, mergedAt: Schema.optional(Schema.NullOr(Schema.String)), @@ -113,6 +151,36 @@ const RawSearchItemSchema = Schema.Struct({ }), ), ), + /** + * GraphQL answers the rollup a listing actually wants — one enum for the head commit, rather + * than the whole check array `gh pr list --json` insists on. Measured at a hundred rows across + * this repository: 0.8s -> 3.0s and 15 KB, against 425 KB for the same verdict over `gh`. + */ + commits: Schema.optional( + Schema.NullOr( + Schema.Struct({ + nodes: Schema.optional( + Schema.NullOr( + Schema.Array( + Schema.NullOr( + Schema.Struct({ + commit: Schema.optional( + Schema.NullOr( + Schema.Struct({ + statusCheckRollup: Schema.optional( + Schema.NullOr(Schema.Struct({ state: Schema.String })), + ), + }), + ), + ), + }), + ), + ), + ), + ), + }), + ), + ), }); const RawSearchSchema = Schema.Struct({ @@ -149,17 +217,109 @@ const RawStatsSchema = Schema.Struct({ ), }); -const RawCheckSchema = Schema.Struct({ - __typename: Schema.optional(Schema.String), - name: Schema.optional(Schema.NullOr(Schema.String)), - context: Schema.optional(Schema.NullOr(Schema.String)), - status: Schema.optional(Schema.NullOr(Schema.String)), - conclusion: Schema.optional(Schema.NullOr(Schema.String)), - state: Schema.optional(Schema.NullOr(Schema.String)), - description: Schema.optional(Schema.NullOr(Schema.String)), - detailsUrl: Schema.optional(Schema.NullOr(Schema.String)), - targetUrl: Schema.optional(Schema.NullOr(Schema.String)), -}); +/** How many of a reaction's people the hover names before it counts the rest. */ +const REACTORS_PER_GROUP = 10; + +/** + * A reaction group as every reactable node reports it. `reactors` is bounded rather than paged: + * a hover says who reacted, and a hundred and forty names is a count, not a sentence. + */ +const REACTION_GROUPS_FIELDS = `reactionGroups { + content + viewerHasReacted + reactors(first: ${REACTORS_PER_GROUP}) { + totalCount + nodes { + ... on User { login } + ... on Bot { login } + ... on Organization { login } + ... on Mannequin { login } + } + } +}`; + +/** GitHub's reaction names, which are the same eight the contract carries under other spellings. */ +const REACTION_CONTENT_BY_GITHUB: Readonly> = { + THUMBS_UP: "thumbs-up", + THUMBS_DOWN: "thumbs-down", + LAUGH: "laugh", + HOORAY: "hooray", + CONFUSED: "confused", + HEART: "heart", + ROCKET: "rocket", + EYES: "eyes", +}; + +const GITHUB_REACTION_BY_CONTENT: Readonly> = { + "thumbs-up": "THUMBS_UP", + "thumbs-down": "THUMBS_DOWN", + laugh: "LAUGH", + hooray: "HOORAY", + confused: "CONFUSED", + heart: "HEART", + rocket: "ROCKET", + eyes: "EYES", +}; + +export function gitHubReactionContent(content: PullRequestReactionContent): string { + return GITHUB_REACTION_BY_CONTENT[content]; +} + +const RawReactionGroupsSchema = Schema.optional( + Schema.NullOr( + Schema.Array( + Schema.Struct({ + content: Schema.optional(Schema.NullOr(Schema.String)), + viewerHasReacted: Schema.optional(Schema.Boolean), + reactors: Schema.optional( + Schema.NullOr( + Schema.Struct({ + totalCount: Schema.optional(Schema.Int), + nodes: Schema.optional( + Schema.NullOr( + Schema.Array( + Schema.NullOr( + Schema.Struct({ login: Schema.optional(Schema.NullOr(Schema.String)) }), + ), + ), + ), + ), + }), + ), + ), + }), + ), + ), +); + +type RawReactionGroups = typeof RawReactionGroupsSchema.Type; + +/** + * The groups GitHub answered with, as the contract carries them. A group with nobody behind it is + * dropped: GitHub answers with a group per content it knows, including the ones nobody chose. The + * viewer's own login is left out of `actors` — the page names them "You" instead, and leaving it + * in would name them twice — but `count` still counts them along with everyone else. + */ +function toReactions( + groups: RawReactionGroups, + viewer: string | null, +): ReadonlyArray { + const normalizedViewer = viewer?.toLowerCase() ?? null; + const reactions: PullRequestReaction[] = []; + for (const group of groups ?? []) { + const content = REACTION_CONTENT_BY_GITHUB[trimmed(group.content)?.toUpperCase() ?? ""]; + if (content === undefined) continue; + const logins = (group.reactors?.nodes ?? []).flatMap((node) => trimmed(node?.login) ?? []); + const count = Math.max(group.reactors?.totalCount ?? logins.length, logins.length); + if (count <= 0) continue; + const actors = + normalizedViewer === null + ? logins + : logins.filter((login) => login.toLowerCase() !== normalizedViewer); + reactions.push({ content, count, actors, viewerHasReacted: group.viewerHasReacted === true }); + } + return reactions; +} const RawCommentSchema = Schema.Struct({ id: Schema.String, @@ -167,6 +327,8 @@ const RawCommentSchema = Schema.Struct({ body: Schema.optional(Schema.String), createdAt: Schema.String, url: Schema.optional(Schema.NullOr(Schema.String)), + /** Only ever present on a GraphQL read; `gh pr view --json` reports no reaction at all. */ + reactionGroups: RawReactionGroupsSchema, }); const RawReviewSchema = Schema.Struct({ @@ -196,10 +358,17 @@ const RawCommitSchema = Schema.Struct({ const RawDetailSchema = Schema.Struct({ ...RawListItemSchema.fields, + /** Names the fork a pull request came from, which is what qualifies its head ref. */ + headRepositoryOwner: Schema.optional(Schema.NullOr(Schema.Struct({ login: Schema.String }))), body: Schema.optional(Schema.String), changedFiles: Schema.optional(Schema.Int), closedAt: Schema.optional(Schema.NullOr(Schema.String)), - statusCheckRollup: Schema.optional(Schema.NullOr(Schema.Array(RawCheckSchema))), + /** + * The standing instruction to merge once GitHub's own requirements are met, which is an object + * describing who armed it and how, and a JSON null where nobody has. Nothing inside it is read: + * the question the page asks is whether one exists. + */ + autoMergeRequest: Schema.optional(Schema.NullOr(Schema.Unknown)), }); const RawActivitySchema = Schema.Struct({ @@ -234,6 +403,11 @@ const RawThreadCommentsSchema = Schema.Struct({ /** `gh pr view --json` cannot reach review threads, so they come from the GraphQL API. */ const RawReviewThreadsSchema = Schema.Struct({ data: Schema.Struct({ + // Rides along in the same request: GitHub names who reacted but never says whether that is + // the reader, so the comparison is made here rather than paid for with a request of its own. + viewer: Schema.optional( + Schema.NullOr(Schema.Struct({ login: Schema.optional(Schema.NullOr(Schema.String)) })), + ), repository: Schema.Struct({ pullRequest: Schema.Struct({ reviewThreads: Schema.Struct({ @@ -254,11 +428,32 @@ const RawReviewThreadsSchema = Schema.Struct({ }), ...RawViewerFieldsSchema.fields, author: Schema.optional(Schema.NullOr(RawActorSchema)), + reactionGroups: RawReactionGroupsSchema, comments: Schema.optional( Schema.NullOr( Schema.Struct({ nodes: Schema.Array( - Schema.Struct({ author: Schema.optional(Schema.NullOr(RawActorSchema)) }), + Schema.Struct({ + id: Schema.optional(Schema.NullOr(Schema.String)), + author: Schema.optional(Schema.NullOr(RawActorSchema)), + reactionGroups: RawReactionGroupsSchema, + }), + ), + }), + ), + ), + /** + * Reviews for their reactions alone: the words and the verdict arrive with + * `gh pr view --json reviews`, which reports no reaction of any kind. + */ + reviews: Schema.optional( + Schema.NullOr( + Schema.Struct({ + nodes: Schema.Array( + Schema.Struct({ + id: Schema.optional(Schema.NullOr(Schema.String)), + reactionGroups: RawReactionGroupsSchema, + }), ), }), ), @@ -284,6 +479,23 @@ const RawReviewThreadsSchema = Schema.Struct({ }), ), ), + reviewDismissals: Schema.optional( + Schema.NullOr( + Schema.Struct({ + pageInfo: Schema.optional(RawPageInfoSchema), + nodes: Schema.Array( + Schema.Struct({ + dismissalMessage: Schema.optional(Schema.NullOr(Schema.String)), + review: Schema.optional( + Schema.NullOr( + Schema.Struct({ id: Schema.optional(Schema.NullOr(Schema.String)) }), + ), + ), + }), + ), + }), + ), + ), commits: Schema.optional( Schema.NullOr( Schema.Struct({ @@ -384,9 +596,9 @@ export function decodeActorAvatarsJson( } export const PULL_REQUEST_LIST_JSON_FIELDS = - "number,title,url,author,headRefName,baseRefName,state,isDraft,mergeable,additions,deletions,createdAt,updatedAt,mergedAt,reviewRequests,labels"; + "number,title,url,author,headRefName,baseRefName,state,isDraft,mergeable,reviewDecision,additions,deletions,createdAt,updatedAt,mergedAt,reviewRequests,labels,statusCheckRollup"; -export const PULL_REQUEST_DETAIL_JSON_FIELDS = `${PULL_REQUEST_LIST_JSON_FIELDS},body,changedFiles,closedAt,statusCheckRollup`; +export const PULL_REQUEST_DETAIL_JSON_FIELDS = `${PULL_REQUEST_LIST_JSON_FIELDS},body,changedFiles,closedAt,headRepositoryOwner,autoMergeRequest`; export const PULL_REQUEST_ACTIVITY_JSON_FIELDS = "author,comments,reviews,commits"; /** GitHub's own ceiling on a connection page, which is what both thread reads ask for. */ @@ -429,12 +641,14 @@ export function pullRequestSearchGraphQlQuery(rows: number): string { state isDraft mergeable + reviewDecision createdAt updatedAt mergedAt repository { nameWithOwner } reviewRequests(first: 20) { nodes { requestedReviewer { ... on User { login } } } } labels(first: 20) { nodes { name color } } + commits(last: 1) { nodes { commit { statusCheckRollup { state } } } } } } } @@ -461,6 +675,7 @@ export function pullRequestSearchGraphQlQuery(rows: number): string { * wants, and stands in for the `gh` list wherever it came back non-empty. */ export const REVIEW_THREADS_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $number: Int!, $cursor: String) { + viewer { login } repository(owner: $owner, name: $name) { pullRequest(number: $number) { reviewThreads(first: ${GRAPHQL_PAGE_SIZE}, after: $cursor) { @@ -476,14 +691,18 @@ export const REVIEW_THREADS_GRAPHQL_QUERY = `query($owner: String!, $name: Strin comments(first: ${GRAPHQL_PAGE_SIZE}) { totalCount pageInfo { hasNextPage endCursor } - nodes { id author { login avatarUrl } body createdAt url } + nodes { id author { login avatarUrl } body createdAt url ${REACTION_GROUPS_FIELDS} } } } } viewerCanUpdate viewerDidAuthor author { login avatarUrl } - comments(first: ${GRAPHQL_PAGE_SIZE}) { nodes { author { login avatarUrl } } } + ${REACTION_GROUPS_FIELDS} + comments(first: ${GRAPHQL_PAGE_SIZE}) { + nodes { id author { login avatarUrl } ${REACTION_GROUPS_FIELDS} } + } + reviews(first: ${GRAPHQL_PAGE_SIZE}) { nodes { id ${REACTION_GROUPS_FIELDS} } } reviewRequests(first: 50) { nodes { requestedReviewer { @@ -495,6 +714,10 @@ export const REVIEW_THREADS_GRAPHQL_QUERY = `query($owner: String!, $name: Strin latestReviews(first: 50) { nodes { author { login avatarUrl } } } + reviewDismissals: timelineItems(itemTypes: [REVIEW_DISMISSED_EVENT], first: ${GRAPHQL_PAGE_SIZE}) { + pageInfo { hasNextPage endCursor } + nodes { ... on ReviewDismissedEvent { dismissalMessage review { id } } } + } commits(last: ${GRAPHQL_PAGE_SIZE}) { nodes { commit { @@ -517,11 +740,12 @@ export const REVIEW_THREADS_GRAPHQL_QUERY = `query($owner: String!, $name: Strin * GitHub makes necessary, and one no ordinary pull request ever provokes. */ export const REVIEW_THREAD_COMMENTS_GRAPHQL_QUERY = `query($threadId: ID!, $cursor: String) { + viewer { login } node(id: $threadId) { ... on PullRequestReviewThread { comments(first: ${GRAPHQL_PAGE_SIZE}, after: $cursor) { pageInfo { hasNextPage endCursor } - nodes { id author { login avatarUrl } body createdAt url } + nodes { id author { login avatarUrl } body createdAt url ${REACTION_GROUPS_FIELDS} } } } } @@ -529,6 +753,9 @@ export const REVIEW_THREAD_COMMENTS_GRAPHQL_QUERY = `query($threadId: ID!, $curs const RawReviewThreadCommentsSchema = Schema.Struct({ data: Schema.Struct({ + viewer: Schema.optional( + Schema.NullOr(Schema.Struct({ login: Schema.optional(Schema.NullOr(Schema.String)) })), + ), /** Null for an id that names nothing the viewer can read, which is not a thread to page. */ node: Schema.NullOr(Schema.Struct({ comments: Schema.optional(RawThreadCommentsSchema) })), }), @@ -540,6 +767,84 @@ export const REVIEW_THREAD_REPLY_GRAPHQL_MUTATION = `mutation($threadId: ID!, $b } }`; +/** + * The pull request's own node id, which is what a reaction on its description is addressed by. + * Read only when one is being written: the conversation carries an id for every remark in it, and + * the pull request is the one subject nothing in it names. + */ +export const PULL_REQUEST_NODE_ID_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $number: Int!) { + repository(owner: $owner, name: $name) { pullRequest(number: $number) { id } } +}`; + +const RawPullRequestNodeIdSchema = Schema.Struct({ + data: Schema.Struct({ + repository: Schema.Struct({ + pullRequest: Schema.Struct({ id: Schema.String }), + }), + }), +}); + +const decodePullRequestNodeId = decodeJsonResult(RawPullRequestNodeIdSchema); + +export function decodePullRequestNodeIdJson(raw: string): Result.Result { + const decoded = decodePullRequestNodeId(raw); + return Result.isSuccess(decoded) + ? Result.succeed(decoded.success.data.repository.pullRequest.id) + : Result.fail(decoded.failure); +} + +/** + * Where a client-given reaction subject actually hangs: the pull request itself, or the pull + * request an issue comment, a review comment, or a review belongs to. Read before a mutation + * reaches it, so a subject named for one pull request cannot react on another's behalf. + */ +export const REACTION_SUBJECT_PULL_REQUEST_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $number: Int!, $subjectId: ID!) { + repository(owner: $owner, name: $name) { pullRequest(number: $number) { id } } + node(id: $subjectId) { + id + ... on IssueComment { pullRequest { id } } + ... on PullRequestReviewComment { pullRequest { id } } + ... on PullRequestReview { pullRequest { id } } + } +}`; + +const RawReactionSubjectScopeSchema = Schema.Struct({ + data: Schema.Struct({ + repository: Schema.NullOr( + Schema.Struct({ pullRequest: Schema.NullOr(Schema.Struct({ id: Schema.String })) }), + ), + node: Schema.NullOr( + Schema.Struct({ + id: Schema.String, + pullRequest: Schema.optional(Schema.Struct({ id: Schema.String })), + }), + ), + }), +}); + +const decodeReactionSubjectScope = decodeJsonResult(RawReactionSubjectScopeSchema); + +/** + * True when the subject named is the pull request itself, or hangs off it — false for anything + * else, including a subject or a pull request this host could not find. + */ +export function decodeReactionSubjectScopeJson(raw: string): Result.Result { + const decoded = decodeReactionSubjectScope(raw); + if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure); + const expected = decoded.success.data.repository?.pullRequest?.id ?? null; + const node = decoded.success.data.node; + const actual = node === null ? null : (node.pullRequest?.id ?? node.id); + return Result.succeed(expected !== null && actual !== null && expected === actual); +} + +export const ADD_REACTION_GRAPHQL_MUTATION = `mutation($subjectId: ID!, $content: ReactionContent!) { + addReaction(input: { subjectId: $subjectId, content: $content }) { reaction { content } } +}`; + +export const REMOVE_REACTION_GRAPHQL_MUTATION = `mutation($subjectId: ID!, $content: ReactionContent!) { + removeReaction(input: { subjectId: $subjectId, content: $content }) { reaction { content } } +}`; + export const RESOLVE_REVIEW_THREAD_GRAPHQL_MUTATION = `mutation($threadId: ID!) { resolveReviewThread(input: { threadId: $threadId }) { thread { isResolved } } }`; @@ -548,6 +853,31 @@ export const UNRESOLVE_REVIEW_THREAD_GRAPHQL_MUTATION = `mutation($threadId: ID! unresolveReviewThread(input: { threadId: $threadId }) { thread { isResolved } } }`; +/** + * Rewrites the pull request's own words. Both are nullable so that one document serves a change + * to the title, to the description, or to the two together: a variable the request does not send + * puts no entry in the input at all, which leaves that field as it was rather than clearing it. + */ +export const UPDATE_PULL_REQUEST_GRAPHQL_MUTATION = `mutation($pullRequestId: ID!, $title: String, $body: String) { + updatePullRequest(input: { pullRequestId: $pullRequestId, title: $title, body: $body }) { + pullRequest { id } + } +}`; + +/** + * The two comment mutations name their comment differently. The variable is spelled the same in + * both, so a rewrite sends one set of variables whichever kind of remark it is. + */ +export const UPDATE_ISSUE_COMMENT_GRAPHQL_MUTATION = `mutation($commentId: ID!, $body: String!) { + updateIssueComment(input: { id: $commentId, body: $body }) { issueComment { id } } +}`; + +export const UPDATE_REVIEW_COMMENT_GRAPHQL_MUTATION = `mutation($commentId: ID!, $body: String!) { + updatePullRequestReviewComment(input: { pullRequestReviewCommentId: $commentId, body: $body }) { + pullRequestReviewComment { id } + } +}`; + /** * A GraphQL request as `gh api graphql --input -` takes it. Variables travel in the document * rather than as `-f name=value` flags, so a reader's own words never reach argv. @@ -588,6 +918,21 @@ const REVIEW_EVENTS: Record; + /** Null where the head commit reported no checks, which is not the same as passing none. */ + readonly checksState: PullRequestChecksState | null; } export interface GitHubPullRequestDetail extends GitHubPullRequestListItem { + /** The owner of the head branch's repository; null where `gh` did not say. */ + readonly headRepositoryOwner: string | null; readonly body: string; readonly changedFiles: number; readonly mergedAt: string | null; readonly closedAt: string | null; readonly checks: ReadonlyArray; + /** Absent where `gh` did not answer for auto-merge at all, which is not the same as off. */ + readonly autoMergeEnabled?: boolean; } export interface GitHubPullRequestActivity { @@ -731,6 +1084,19 @@ function toMergeability(value: string | null | undefined): PullRequestMergeabili } } +function toReviewDecision(value: string | null | undefined): PullRequestReviewDecision | null { + switch (value?.trim().toUpperCase()) { + case "APPROVED": + return "approved"; + case "CHANGES_REQUESTED": + return "changes-requested"; + case "REVIEW_REQUIRED": + return "review-required"; + default: + return null; + } +} + function toLabels( raw: ReadonlyArray> | undefined, ): ReadonlyArray { @@ -792,23 +1158,81 @@ function toCheckStatus(raw: Schema.Schema.Type): PullRequ } } -function toChecks( +/** What GitHub writes where a run has not reached that moment yet, which is not a time. */ +const UNSET_TIMESTAMP = "0001-01-01T00:00:00Z"; + +function realTimestamp(value: string | null | undefined): string | null { + const at = trimmed(value); + return at === null || at === UNSET_TIMESTAMP ? null : at; +} + +/** Only a row the rollup gives no name of any kind, which is not a check anyone can show. */ +function isNamelessCheck(raw: Schema.Schema.Type): boolean { + return trimmed(raw.name) === null && trimmed(raw.context) === null; +} + +/** + * The rollup as the deduper reads it: a check, the workflow that owns it, and when the run last + * had something to say. A queued run reports a completion time it has not reached, so the start + * stands in for it rather than sorting the newest run to the bottom. + */ +function toCheckEntries( raw: ReadonlyArray> | null | undefined, -): ReadonlyArray { +): ReadonlyArray<{ + readonly check: PullRequestCheck; + readonly workflowName: string | null; + readonly at: string | null; +}> { return (raw ?? []).flatMap((check) => { const name = trimmed(check.name) ?? trimmed(check.context); if (name === null) return []; return [ { - name, - status: toCheckStatus(check), - description: trimmed(check.description), - url: trimmed(check.detailsUrl) ?? trimmed(check.targetUrl), + check: { + name, + status: toCheckStatus(check), + description: trimmed(check.description), + url: trimmed(check.detailsUrl) ?? trimmed(check.targetUrl), + }, + workflowName: trimmed(check.workflowName), + at: realTimestamp(check.completedAt) ?? realTimestamp(check.startedAt), }, ]; }); } +/** + * The one word a listing row has space for. A failure outranks anything still running, the way + * GitHub's own indicator reads: a run that has already gone red will not go green by finishing. + * + * Null rather than "passing" for a head commit with no checks at all, so a repository that runs + * none shows nothing instead of a green tick it never earned. Checks whose verdict is neither a + * pass, a failure nor a wait — skipped, cancelled, neutral — count towards neither. + * + * Counted off the deduped checks rather than the raw rollup, so the word and the list under it + * cannot disagree: the run a re-run replaced is not a verdict twice. A row with no name at all is + * counted as it comes, since the cross-repository search dresses GitHub's own rollup enum as one + * nameless row, and nothing nameless can collide with anything. + */ +function rollupChecksState( + raw: ReadonlyArray> | null | undefined, +): PullRequestChecksState | null { + const statuses = [ + ...toChecks(raw).map((check) => check.status), + ...(raw ?? []).filter(isNamelessCheck).map((check) => toCheckStatus(check)), + ]; + if (statuses.length === 0) return null; + if (statuses.includes("failure")) return "failing"; + if (statuses.includes("pending")) return "pending"; + return statuses.includes("success") ? "passing" : null; +} + +function toChecks( + raw: ReadonlyArray> | null | undefined, +): ReadonlyArray { + return dedupeChecks(toCheckEntries(raw)); +} + /** The states that are a verdict in themselves, rather than a wrapper around line comments. */ function isReviewVerdict(reviewState: string | null): boolean { switch (reviewState?.toUpperCase()) { @@ -894,6 +1318,7 @@ function toListItem(raw: Schema.Schema.Type): GitHubPu state: toState(raw), isDraft: raw.isDraft ?? false, mergeability: toMergeability(raw.mergeable), + reviewDecision: toReviewDecision(raw.reviewDecision), additions: raw.additions ?? 0, deletions: raw.deletions ?? 0, createdAt: raw.createdAt, @@ -901,17 +1326,24 @@ function toListItem(raw: Schema.Schema.Type): GitHubPu reviewRequestLogins: toReviewRequestLogins(raw.reviewRequests), hasTeamReviewRequest: hasTeamReviewRequest(raw.reviewRequests), labels: toLabels(raw.labels), + checksState: rollupChecksState(raw.statusCheckRollup), }; } function toDetail(raw: Schema.Schema.Type): GitHubPullRequestDetail { return { ...toListItem(raw), + headRepositoryOwner: trimmed(raw.headRepositoryOwner?.login), body: raw.body ?? "", changedFiles: raw.changedFiles ?? 0, mergedAt: trimmed(raw.mergedAt), closedAt: trimmed(raw.closedAt), checks: toChecks(raw.statusCheckRollup), + // A JSON null is GitHub saying "nobody armed this"; a missing key is GitHub not saying, and + // the difference survives here rather than being flattened into false. + ...(raw.autoMergeRequest === undefined + ? {} + : { autoMergeEnabled: raw.autoMergeRequest !== null }), }; } @@ -1006,6 +1438,12 @@ export function decodePullRequestSearchJson( return login === null ? [] : [{ login }]; }), labels: (node.labels?.nodes ?? []).flatMap((label) => (label === null ? [] : [label])), + // The search asks for the verdict rather than the checks behind it, so it arrives as one + // enum. Dressed as a single check here so the rollup is read the same way on both paths. + statusCheckRollup: (node.commits?.nodes ?? []).flatMap((commitNode) => { + const state = trimmed(commitNode?.commit?.statusCheckRollup?.state); + return state === null ? [] : [{ state }]; + }), }), repository, }); @@ -1094,11 +1532,17 @@ export function decodePullRequestActivityJson( export interface GitHubReviewThreadComments { readonly comments: ReadonlyArray; + /** Dismissal reasons by the dismissed review's node id, read off the timeline. */ + readonly dismissalsByReviewId: ReadonlyMap; /** Whole conversations, kept anchored so the diff can pin them to their line. */ readonly reviewThreads: ReadonlyArray; /** The host's own count of the conversation, which a bounded read can fall short of. */ readonly commentCount: number; readonly truncated: boolean; + /** The pull request's own reactions, which sit on its description. */ + readonly reactions: ReadonlyArray; + /** Reactions by node id, for the comments and reviews the `gh` JSON read carries no reaction on. */ + readonly reactionsById: ReadonlyMap>; /** * Everyone on the review: those still asked and those who have already answered. Whoever has * reviewed is no longer an outstanding request, so asking only for requests reports nobody on @@ -1140,6 +1584,13 @@ export interface GitHubReviewThreadPage { readonly threads: ReadonlyArray; /** Where the next page of threads starts, or null once the host has handed them all over. */ readonly nextCursor: string | null; + /** The pull request's own reactions, which sit on its description. */ + readonly reactions: ReadonlyArray; + /** + * Reactions by node id, for the conversation comments and reviews `gh pr view --json` answers + * for without any. Only ids with a reaction are here; the rest carry none. + */ + readonly reactionsById: ReadonlyMap>; readonly reviewers: ReadonlyArray; readonly avatarsByLogin: ReadonlyMap; readonly commitStats: ReadonlyMap< @@ -1148,6 +1599,10 @@ export interface GitHubReviewThreadPage { >; readonly commits: ReadonlyArray; readonly viewer: { readonly canUpdate: boolean; readonly didAuthor: boolean }; + /** Dismissal reasons by the dismissed review's node id, which the review itself never carries. */ + readonly dismissalsByReviewId: ReadonlyMap; + /** Where the rest of the dismissal events start, or null once this page carried them all. */ + readonly nextDismissalCursor: string | null; } /** @@ -1169,12 +1624,71 @@ export function reviewThreadConversation( url: comment.url, path: thread.path, reviewState: null, + reactions: comment.reactions ?? [], }), ), ); } /** One page of review threads. Following the cursors it hands back is the caller's job. */ +function toDismissalEntries( + nodes: + | ReadonlyArray<{ + readonly dismissalMessage?: string | null | undefined; + readonly review?: { readonly id?: string | null | undefined } | null | undefined; + }> + | undefined, +): Map { + const entries = new Map(); + for (const node of nodes ?? []) { + const reviewId = trimmed(node.review?.id); + const message = trimmed(node.dismissalMessage); + if (reviewId !== null && message !== null) entries.set(reviewId, message); + } + return entries; +} + +const RawReviewDismissalsSchema = Schema.Struct({ + data: Schema.Struct({ + repository: Schema.Struct({ + pullRequest: Schema.Struct({ + timelineItems: Schema.Struct({ + pageInfo: Schema.optional(RawPageInfoSchema), + nodes: Schema.Array( + Schema.Struct({ + dismissalMessage: Schema.optional(Schema.NullOr(Schema.String)), + review: Schema.optional( + Schema.NullOr(Schema.Struct({ id: Schema.optional(Schema.NullOr(Schema.String)) })), + ), + }), + ), + }), + }), + }), + }), +}); + +const decodeReviewDismissals = decodeJsonResult(RawReviewDismissalsSchema); + +/** One further page of dismissal events, in the shape the thread read's own page carries. */ +export function decodeReviewDismissalsJson(raw: string): Result.Result< + { + readonly dismissalsByReviewId: ReadonlyMap; + readonly nextCursor: string | null; + }, + DecodeFailure +> { + const decoded = decodeReviewDismissals(raw); + if (!Result.isSuccess(decoded)) { + return Result.fail(decoded.failure); + } + const items = decoded.success.data.repository.pullRequest.timelineItems; + return Result.succeed({ + dismissalsByReviewId: toDismissalEntries(items.nodes), + nextCursor: nextCursorOf(items.pageInfo), + }); +} + export function decodeReviewThreadsJson( raw: string, ): Result.Result { @@ -1182,6 +1696,7 @@ export function decodeReviewThreadsJson( if (!Result.isSuccess(decoded)) { return Result.fail(decoded.failure); } + const viewer = trimmed(decoded.success.data.viewer?.login); const threads = decoded.success.data.repository.pullRequest.reviewThreads; const entries = threads.nodes.flatMap((thread): ReadonlyArray => { const path = trimmed(thread.path); @@ -1207,6 +1722,7 @@ export function decodeReviewThreadsJson( body: comment.body ?? "", createdAt: comment.createdAt, url: trimmed(comment.url), + reactions: toReactions(comment.reactionGroups, viewer), })), }, commentCount: thread.comments.totalCount ?? thread.comments.nodes.length, @@ -1260,14 +1776,28 @@ export function decodeReviewThreadsJson( }), }); } + const reactionsById = new Map>(); + for (const node of [ + ...(pullRequest.comments?.nodes ?? []), + ...(pullRequest.reviews?.nodes ?? []), + ]) { + const id = trimmed(node.id); + if (id === null) continue; + const reactions = toReactions(node.reactionGroups, viewer); + if (reactions.length > 0) reactionsById.set(id, reactions); + } return Result.succeed({ threads: entries, nextCursor: nextCursorOf(threads.pageInfo), + reactions: toReactions(pullRequest.reactionGroups, viewer), + reactionsById, reviewers: [...reviewers.values()], avatarsByLogin, commitStats, commits, viewer: toPullRequestViewerFields(pullRequest), + dismissalsByReviewId: toDismissalEntries(pullRequest.reviewDismissals?.nodes), + nextDismissalCursor: nextCursorOf(pullRequest.reviewDismissals?.pageInfo), }); } @@ -1283,6 +1813,7 @@ export function decodeReviewThreadCommentsJson(raw: string): Result.Result< if (!Result.isSuccess(decoded)) { return Result.fail(decoded.failure); } + const viewer = trimmed(decoded.success.data.viewer?.login); const comments = decoded.success.data.node?.comments; return Result.succeed({ comments: (comments?.nodes ?? []).map((comment) => ({ @@ -1291,6 +1822,7 @@ export function decodeReviewThreadCommentsJson(raw: string): Result.Result< body: comment.body ?? "", createdAt: comment.createdAt, url: trimmed(comment.url), + reactions: toReactions(comment.reactionGroups, viewer), })), nextCursor: nextCursorOf(comments?.pageInfo), }); @@ -1351,6 +1883,75 @@ export function decodeRepositoryAccessJson( * need `read:org`, which a repository-scoped token need not carry — and a query GitHub refuses * fails whole, taking the people down with the teams. */ +/** + * Where the branch stands against its base, and whether this viewer may move it. + * + * `mergeStateStatus` is not the answer: GitHub only reports BEHIND where the repository requires + * branches to be up to date before merging, so on every other repository a stale branch reads as + * CLEAN or BLOCKED like any other. The comparison counts the commits instead, which is the same + * number GitHub's own "out-of-date" banner shows. + * + * `headRef` is qualified `owner:branch` because a pull request from a fork has no branch of that + * name in the base repository, and an unqualified name is simply not found there. + */ +export const BASE_COMPARISON_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $number: Int!, $headRef: String!) { + repository(owner: $owner, name: $name) { + pullRequest(number: $number) { + viewerCanUpdateBranch + baseRef { + compare(headRef: $headRef) { + behindBy + } + } + } + } +}`; + +const RawBaseComparisonSchema = Schema.Struct({ + data: Schema.Struct({ + repository: Schema.NullOr( + Schema.Struct({ + pullRequest: Schema.NullOr( + Schema.Struct({ + viewerCanUpdateBranch: Schema.optional(Schema.NullOr(Schema.Boolean)), + /** Null where the head repository is gone, which is a comparison nobody can make. */ + baseRef: Schema.optional( + Schema.NullOr( + Schema.Struct({ + compare: Schema.optional( + Schema.NullOr(Schema.Struct({ behindBy: Schema.Number })), + ), + }), + ), + ), + }), + ), + }), + ), + }), +}); + +const decodeBaseComparison = decodeJsonResult(RawBaseComparisonSchema); + +export interface GitHubBaseComparison { + /** Null where the host could not compare, which the page reads as "unknown". */ + readonly behindBy: number | null; + readonly viewerCanUpdate: boolean; +} + +export function decodeBaseComparisonJson( + raw: string, +): Result.Result { + const decoded = decodeBaseComparison(raw); + if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure); + const pullRequest = decoded.success.data.repository?.pullRequest; + const behindBy = pullRequest?.baseRef?.compare?.behindBy; + return Result.succeed({ + behindBy: typeof behindBy === "number" && behindBy >= 0 ? behindBy : null, + viewerCanUpdate: pullRequest?.viewerCanUpdateBranch === true, + }); +} + export const REVIEWER_CANDIDATES_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $number: Int!) { repository(owner: $owner, name: $name) { assignableUsers(first: ${GRAPHQL_PAGE_SIZE}) { @@ -1492,6 +2093,12 @@ export interface GitHubViewerAccess { /** GitHub's own `viewerCanUpdate`, true for the author as well as for anyone with write. */ readonly canUpdate: boolean; readonly didAuthor: boolean; + /** + * GitHub's own `viewerCanUpdateBranch`, read with the base comparison rather than here: it is + * false for a branch that is already current, so it answers "may update, and there is + * something to update" at once. Absent where the comparison was not read. + */ + readonly canUpdateBranch?: boolean; } /** @@ -1539,6 +2146,8 @@ export interface GitHubPullRequestFilesPatch { readonly truncated: boolean; /** Files GitHub returned, counted before decoding, so the caller can page. */ readonly rawCount: number; + /** GitHub's own counts for the files whose hunks it withheld. */ + readonly omittedFileStats: ReadonlyArray; } /** @@ -1554,6 +2163,7 @@ export function decodePullRequestFilesJson( return Result.fail(decoded.failure); } const sections: string[] = []; + const omittedFileStats: PullRequestOmittedFileStat[] = []; let truncated = false; for (const entry of decoded.success) { const file = decodeFileEntry(entry); @@ -1565,7 +2175,12 @@ export function decodePullRequestFilesJson( // A file with no hunks is still a file that changed: a pure rename has none to give, and // a binary one has none that can be shown. Both are listed, and only the second is a hole // in the patch — leaving them out entirely would drop them from the change altogether. - if ((value.additions ?? 0) + (value.deletions ?? 0) > 0) truncated = true; + const additions = value.additions ?? 0; + const deletions = value.deletions ?? 0; + if (additions + deletions > 0) { + truncated = true; + omittedFileStats.push({ path: value.filename, additions, deletions }); + } } // A rename counts its hunks against the old path, which is the only place it is named. const oldPath = @@ -1586,5 +2201,6 @@ export function decodePullRequestFilesJson( patch: sections.join(""), truncated, rawCount: decoded.success.length, + omittedFileStats, }); } diff --git a/apps/server/src/pullRequest/gitLabMergeRequestJson.test.ts b/apps/server/src/pullRequest/gitLabMergeRequestJson.test.ts index 553bd7fb6ef1..9221c1ab8e04 100644 --- a/apps/server/src/pullRequest/gitLabMergeRequestJson.test.ts +++ b/apps/server/src/pullRequest/gitLabMergeRequestJson.test.ts @@ -2,12 +2,15 @@ import * as Result from "effect/Result"; import { describe, expect, it } from "vite-plus/test"; import { + decodeAwardEmojiJson, decodeCommitsJson, decodeMergeRequestDetailJson, decodeMergeRequestDiffsJson, decodeMergeRequestListJson, decodeNotesJson, + decodeOwnAwardIdJson, decodeViewerJson, + gitLabAwardName, } from "./gitLabMergeRequestJson.ts"; function listJson(entries: ReadonlyArray>): string { @@ -177,6 +180,31 @@ describe("decodeMergeRequestDetailJson", () => { expect(detail.changedFiles).toBe(0); }); + it("reads either auto-merge field, and says nothing where GitLab named neither", () => { + const armed = (entry: Record) => + expectSuccess(decodeMergeRequestDetailJson(detailJson(entry))).autoMergeEnabled; + + expect(armed({ merge_when_pipeline_succeeds: true })).toBe(true); + // The newer name for the same fact, which older GitLab installs do not send. + expect(armed({ auto_merge_enabled: true })).toBe(true); + expect(armed({ merge_when_pipeline_succeeds: false })).toBe(false); + // Absent is GitLab not saying, which the page must not read as "not armed". + expect(armed({})).toBeUndefined(); + }); + + it("keeps a divergence GitLab did not count apart from a divergence of none", () => { + const behind = (entry: Record) => + expectSuccess(decodeMergeRequestDetailJson(detailJson(entry))).divergedCommits; + + expect(behind({ diverged_commits_count: 3 })).toBe(3); + // Counted and found level, which is the one answer that entitles the page to say so. + expect(behind({ diverged_commits_count: 0 })).toBe(0); + // An install that does not answer, and a null where the answer would have gone, are both + // silence: reading either as zero would tell a stale branch it is current. + expect(behind({})).toBeUndefined(); + expect(behind({ diverged_commits_count: null })).toBeUndefined(); + }); + it("maps a pipeline waiting on a person to neutral, not failure", () => { const detail = expectSuccess( decodeMergeRequestDetailJson(detailJson({ head_pipeline: { status: "manual" } })), @@ -453,3 +481,130 @@ describe("merge request viewer fields", () => { ).toBe(true); }); }); + +describe("decodeAwardEmojiJson", () => { + it("reads MR-level awards, keys per-note awards by the REST id inside their gid, and ignores an award outside the eight", () => { + const result = expectSuccess( + decodeAwardEmojiJson( + JSON.stringify({ + data: { + currentUser: { username: "bilal" }, + project: { + mergeRequest: { + awardEmoji: { + nodes: [ + { name: "thumbsup", user: { username: "bilal" } }, + { name: "thumbsup", user: { username: "julius" } }, + ], + }, + notes: { + pageInfo: { hasNextPage: false, endCursor: null }, + nodes: [ + { + id: "gid://gitlab/DiffNote/42", + awardEmoji: { nodes: [{ name: "heart", user: { username: "julius" } }] }, + }, + { + id: "gid://gitlab/Note/7", + // Not one of the eight the contract carries. + awardEmoji: { + nodes: [{ name: "partyparrot", user: { username: "bilal" } }], + }, + }, + ], + }, + }, + }, + }, + }), + ), + ); + + // `bilal` is `currentUser`, so the group they are in reads back as reacted, but their own + // username is left out of `actors` — the page names them "You" instead — while `count` still + // counts them; `julius` alone does not turn a group's own `viewerHasReacted` on. + expect(result.reactions).toEqual([ + { content: "thumbs-up", count: 2, actors: ["julius"], viewerHasReacted: true }, + ]); + // Note 7's only award named nobody the eight recognise, so it carries no reactions and is + // left out of the map rather than kept empty. + expect([...result.reactionsByNoteId]).toEqual([ + ["42", [{ content: "heart", count: 1, actors: ["julius"], viewerHasReacted: false }]], + ]); + expect(result.nextCursor).toBeNull(); + }); + + it("hands back a cursor when GitLab has more notes to page", () => { + const result = expectSuccess( + decodeAwardEmojiJson( + JSON.stringify({ + data: { + currentUser: null, + project: { + mergeRequest: { + awardEmoji: { nodes: [] }, + notes: { pageInfo: { hasNextPage: true, endCursor: "Y3Vyc29yOjE" }, nodes: [] }, + }, + }, + }, + }), + ), + ); + + expect(result.nextCursor).toBe("Y3Vyc29yOjE"); + }); + + it("matches the viewer's username case-insensitively, since GitLab is not consistent about case", () => { + const result = expectSuccess( + decodeAwardEmojiJson( + JSON.stringify({ + data: { + currentUser: { username: "Bilal" }, + project: { + mergeRequest: { + awardEmoji: { + nodes: [ + { name: "heart", user: { username: "bilal" } }, + { name: "heart", user: { username: "julius" } }, + ], + }, + notes: { pageInfo: { hasNextPage: false, endCursor: null }, nodes: [] }, + }, + }, + }, + }), + ), + ); + + expect(result.reactions).toEqual([ + { content: "heart", count: 2, actors: ["julius"], viewerHasReacted: true }, + ]); + }); +}); + +describe("decodeOwnAwardIdJson", () => { + const awards = JSON.stringify([ + { id: 101, name: "thumbsup", user: { username: "julius" } }, + { id: 102, name: "thumbsup", user: { username: "bilal" } }, + ]); + + it("finds the reader's own award of that name, which is the one a removal deletes", () => { + expect( + expectSuccess(decodeOwnAwardIdJson(awards, { content: "thumbs-up", viewer: "bilal" })), + ).toBe(102); + }); + + it("returns nothing where the reader has no award of that name", () => { + expect( + expectSuccess(decodeOwnAwardIdJson(awards, { content: "heart", viewer: "bilal" })), + ).toBeNull(); + }); +}); + +describe("gitLabAwardName", () => { + it("spells the contents whose GitLab award name is not their own kebab-case", () => { + expect(gitLabAwardName("thumbs-up")).toBe("thumbsup"); + expect(gitLabAwardName("laugh")).toBe("laughing"); + expect(gitLabAwardName("hooray")).toBe("tada"); + }); +}); diff --git a/apps/server/src/pullRequest/gitLabMergeRequestJson.ts b/apps/server/src/pullRequest/gitLabMergeRequestJson.ts index 5c0fd0ac7537..9f4bd96bae08 100644 --- a/apps/server/src/pullRequest/gitLabMergeRequestJson.ts +++ b/apps/server/src/pullRequest/gitLabMergeRequestJson.ts @@ -11,6 +11,8 @@ import type { PullRequestLabel, PullRequestMergeability, PullRequestMergeCapabilities, + PullRequestReaction, + PullRequestReactionContent, PullRequestReviewThread, PullRequestReviewerCandidate, PullRequestState, @@ -70,6 +72,21 @@ const RawMergeRequestSchema = Schema.Struct({ user: Schema.optional( Schema.NullOr(Schema.Struct({ can_merge: Schema.optional(Schema.Boolean) })), ), + /** + * Whether GitLab is holding this merge request to merge it once its pipeline goes green. + * `merge_when_pipeline_succeeds` is the field every version answers with; newer ones also + * carry `auto_merge_enabled`, which is the same fact under the name GitLab settled on, so + * either one saying yes is a yes. + */ + merge_when_pipeline_succeeds: Schema.optional(Schema.NullOr(Schema.Boolean)), + auto_merge_enabled: Schema.optional(Schema.NullOr(Schema.Boolean)), + /** + * How far the target branch has moved on since this one left it, which is the same number + * GitLab's own "out of date" wording counts. It costs a walk of the two branches, so GitLab + * withholds it unless `include_diverged_commits_count` asks for it, and answers it only for a + * single merge request — a list never carries it, however it is asked for. + */ + diverged_commits_count: Schema.optional(Schema.NullOr(Schema.Int)), }); const RawNoteSchema = Schema.Struct({ @@ -209,6 +226,13 @@ export interface GitLabMergeRequestDetail extends GitLabMergeRequestListItem { readonly viewerCanMerge: boolean; /** The reviewers as GitLab addresses them, which is what writing the set back takes. */ readonly reviewerIds: ReadonlyArray; + /** Absent where GitLab named neither auto-merge field, which is not the same as off. */ + readonly autoMergeEnabled?: boolean; + /** + * Absent where GitLab did not count, which is not the same as a branch that has nothing behind + * it: an install too old to answer must not be read as saying the branch is current. + */ + readonly divergedCommits?: number; } function trimmed(value: string | null | undefined): string | null { @@ -334,6 +358,10 @@ function toListItem( function toDetail(raw: Schema.Schema.Type): GitLabMergeRequestDetail { const listItem = toListItem(raw); + const autoMerge = + raw.merge_when_pipeline_succeeds == null && raw.auto_merge_enabled == null + ? undefined + : raw.merge_when_pipeline_succeeds === true || raw.auto_merge_enabled === true; return { ...listItem, body: raw.description ?? "", @@ -350,6 +378,8 @@ function toDetail(raw: Schema.Schema.Type): GitLab reviewerIds: (raw.reviewers ?? []).flatMap((reviewer) => reviewer.id === undefined ? [] : [reviewer.id], ), + ...(autoMerge === undefined ? {} : { autoMergeEnabled: autoMerge }), + ...(raw.diverged_commits_count == null ? {} : { divergedCommits: raw.diverged_commits_count }), }; } @@ -696,3 +726,215 @@ export function decodeMergeRequestDiffsJson( rawCount: decoded.success.length, }); } + +/** GitLab's award names for the eight reactions the contract carries. */ +const GITLAB_AWARD_BY_CONTENT: Readonly> = { + "thumbs-up": "thumbsup", + "thumbs-down": "thumbsdown", + laugh: "laughing", + hooray: "tada", + confused: "confused", + heart: "heart", + rocket: "rocket", + eyes: "eyes", +}; + +const CONTENT_BY_GITLAB_AWARD: Readonly> = + Object.fromEntries( + Object.entries(GITLAB_AWARD_BY_CONTENT).map(([content, name]) => [name, content]), + ) as Readonly>; + +export function gitLabAwardName(content: PullRequestReactionContent): string { + return GITLAB_AWARD_BY_CONTENT[content]; +} + +/** + * Awards on the merge request and on every note of it, in one read. The REST notes endpoint the + * conversation comes from carries no award at all, and asking per note would be a request each. + * + * `currentUser` rides along because GitLab names who awarded but never says whether that is the + * reader — so the comparison is made here rather than paid for with a request of its own. + */ +export const AWARD_EMOJI_GRAPHQL_QUERY = `query($fullPath: ID!, $iid: String!, $cursor: String) { + currentUser { username } + project(fullPath: $fullPath) { + mergeRequest(iid: $iid) { + awardEmoji { nodes { name user { username } } } + notes(first: 100, after: $cursor) { + pageInfo { hasNextPage endCursor } + nodes { id awardEmoji { nodes { name user { username } } } } + } + } + } +}`; + +const RawAwardEmojiNodesSchema = Schema.optional( + Schema.NullOr( + Schema.Struct({ + nodes: Schema.optional( + Schema.NullOr( + Schema.Array( + Schema.NullOr( + Schema.Struct({ + name: Schema.optional(Schema.NullOr(Schema.String)), + user: Schema.optional( + Schema.NullOr( + Schema.Struct({ username: Schema.optional(Schema.NullOr(Schema.String)) }), + ), + ), + }), + ), + ), + ), + ), + }), + ), +); + +const RawAwardEmojiPageSchema = Schema.Struct({ + data: Schema.Struct({ + currentUser: Schema.optional( + Schema.NullOr(Schema.Struct({ username: Schema.optional(Schema.NullOr(Schema.String)) })), + ), + project: Schema.NullOr( + Schema.Struct({ + mergeRequest: Schema.NullOr( + Schema.Struct({ + awardEmoji: RawAwardEmojiNodesSchema, + notes: Schema.optional( + Schema.NullOr( + Schema.Struct({ + pageInfo: Schema.optional( + Schema.Struct({ + hasNextPage: Schema.optional(Schema.Boolean), + endCursor: Schema.optional(Schema.NullOr(Schema.String)), + }), + ), + nodes: Schema.Array( + Schema.NullOr( + Schema.Struct({ + id: Schema.optional(Schema.NullOr(Schema.String)), + awardEmoji: RawAwardEmojiNodesSchema, + }), + ), + ), + }), + ), + ), + }), + ), + }), + ), + }), +}); + +const decodeAwardEmojiPage = decodeJsonResult(RawAwardEmojiPageSchema); + +/** + * The awards on one subject, grouped the way a reaction pill is drawn. The viewer's own username + * is left out of `actors` — the page names them "You" instead, and leaving it in would name them + * twice — but `count` still counts them along with everyone else. + */ +function toReactions( + nodes: Schema.Schema.Type, + viewer: string | null, +): ReadonlyArray { + const normalizedViewer = viewer?.toLowerCase() ?? null; + const groups = new Map< + PullRequestReactionContent, + { count: number; actors: string[]; viewer: boolean } + >(); + for (const node of nodes?.nodes ?? []) { + // An award outside the eight is left out rather than shown under a name the picker has no + // way to take back: GitLab accepts any emoji, and the other hosts accept none of them. + const content = CONTENT_BY_GITLAB_AWARD[trimmed(node?.name)?.toLowerCase() ?? ""]; + if (content === undefined) continue; + const username = trimmed(node?.user?.username); + if (username === null) continue; + const group = groups.get(content) ?? { count: 0, actors: [], viewer: false }; + group.count++; + if (normalizedViewer !== null && username.toLowerCase() === normalizedViewer) { + group.viewer = true; + } else { + group.actors.push(username); + } + groups.set(content, group); + } + return [...groups].flatMap(([content, group]) => + group.count === 0 + ? [] + : [{ content, count: group.count, actors: group.actors, viewerHasReacted: group.viewer }], + ); +} + +/** `gid://gitlab/DiffNote/42` is note 42, which is the id the REST conversation carries. */ +function noteIdOf(gid: string | null | undefined): string | null { + const id = trimmed(gid)?.split("/").at(-1); + return id !== undefined && /^\d+$/.test(id) ? id : null; +} + +export interface GitLabAwardEmojiPage { + /** The merge request's own awards, which are the ones on its description. */ + readonly reactions: ReadonlyArray; + readonly reactionsByNoteId: ReadonlyMap>; + readonly nextCursor: string | null; +} + +export function decodeAwardEmojiJson( + raw: string, +): Result.Result { + const decoded = decodeAwardEmojiPage(raw); + if (!Result.isSuccess(decoded)) { + return Result.fail(decoded.failure); + } + const data = decoded.success.data; + const viewer = trimmed(data.currentUser?.username); + const mergeRequest = data.project?.mergeRequest; + const reactionsByNoteId = new Map>(); + for (const node of mergeRequest?.notes?.nodes ?? []) { + const id = noteIdOf(node?.id); + if (id === null) continue; + const reactions = toReactions(node?.awardEmoji, viewer); + if (reactions.length > 0) reactionsByNoteId.set(id, reactions); + } + const pageInfo = mergeRequest?.notes?.pageInfo; + return Result.succeed({ + reactions: toReactions(mergeRequest?.awardEmoji, viewer), + reactionsByNoteId, + nextCursor: pageInfo?.hasNextPage === true ? (trimmed(pageInfo.endCursor) ?? null) : null, + }); +} + +const RawAwardSchema = Schema.Struct({ + id: Schema.Int, + name: Schema.optional(Schema.NullOr(Schema.String)), + user: Schema.optional( + Schema.NullOr(Schema.Struct({ username: Schema.optional(Schema.NullOr(Schema.String)) })), + ), +}); + +const decodeAward = Schema.decodeUnknownExit(RawAwardSchema); + +/** + * The reader's own award of one name on a subject, which is what taking a reaction back is + * addressed by: GitLab deletes an award by its id and has no way to name one by its emoji. + */ +export function decodeOwnAwardIdJson( + raw: string, + input: { readonly content: PullRequestReactionContent; readonly viewer: string }, +): Result.Result { + const decoded = decodeUnknownList(raw); + if (!Result.isSuccess(decoded)) { + return Result.fail(decoded.failure); + } + const name = gitLabAwardName(input.content); + for (const entry of decoded.success) { + const award = decodeAward(entry); + if (Exit.isFailure(award)) continue; + const value = award.value; + if (trimmed(value.name)?.toLowerCase() !== name) continue; + if (trimmed(value.user?.username) !== input.viewer) continue; + return Result.succeed(value.id); + } + return Result.succeed(null); +} diff --git a/apps/server/src/pullRequest/pullRequestChecks.test.ts b/apps/server/src/pullRequest/pullRequestChecks.test.ts new file mode 100644 index 000000000000..ba6850832924 --- /dev/null +++ b/apps/server/src/pullRequest/pullRequestChecks.test.ts @@ -0,0 +1,93 @@ +import type { PullRequestCheck, PullRequestCheckStatus } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { dedupeChecks } from "./pullRequestChecks.ts"; + +function entry( + name: string, + status: PullRequestCheckStatus, + extra: { readonly workflowName?: string | null; readonly at?: string | null } = {}, +): { + readonly check: PullRequestCheck; + readonly workflowName: string | null; + readonly at: string | null; +} { + return { + check: { name, status, description: null, url: null }, + workflowName: extra.workflowName ?? null, + at: extra.at ?? null, + }; +} + +describe("dedupeChecks", () => { + it("keeps the newest run of a check the host listed twice", () => { + const checks = dedupeChecks([ + entry("Prepare PR size config", "success", { + workflowName: "PR Size", + at: "2026-08-11T16:06:25Z", + }), + entry("Prepare PR size config", "pending", { + workflowName: "PR Size", + at: "2026-08-11T17:01:04Z", + }), + ]); + + expect(checks.map((check) => [check.name, check.status])).toEqual([ + ["Prepare PR size config", "pending"], + ]); + }); + + it("holds a check at the place it first appeared, so a re-run does not reshuffle the list", () => { + const checks = dedupeChecks([ + entry("lint", "success", { workflowName: "CI", at: "2026-08-11T16:00:00Z" }), + entry("test", "success", { workflowName: "CI", at: "2026-08-11T16:00:00Z" }), + entry("lint", "failure", { workflowName: "CI", at: "2026-08-11T18:00:00Z" }), + ]); + + expect(checks.map((check) => [check.name, check.status])).toEqual([ + ["lint", "failure"], + ["test", "success"], + ]); + }); + + it("loses a run that never said when it happened to one that did, whichever came first", () => { + const undated = dedupeChecks([ + entry("build", "success", { at: "2026-08-11T16:00:00Z" }), + entry("build", "pending"), + ]); + const dated = dedupeChecks([ + entry("build", "pending"), + entry("build", "success", { at: "2026-08-11T16:00:00Z" }), + ]); + + expect([undated[0]?.status, dated[0]?.status]).toEqual(["success", "success"]); + }); + + it("takes the last copy when neither run is dated, which is how an update is listed", () => { + const checks = dedupeChecks([entry("build", "pending"), entry("build", "failure")]); + + expect(checks.map((check) => check.status)).toEqual(["failure"]); + }); + + it("keeps two workflows that name a job the same thing, and says which is which", () => { + const checks = dedupeChecks([ + entry("build", "success", { workflowName: "CI", at: "2026-08-11T16:00:00Z" }), + entry("build", "failure", { workflowName: "Release", at: "2026-08-11T16:00:00Z" }), + ]); + + expect(checks.map((check) => [check.name, check.status])).toEqual([ + ["CI / build", "success"], + ["Release / build", "failure"], + ]); + }); + + it("leaves a colliding check with no workflow of its own unqualified", () => { + // An app-provided check run belongs to no workflow, which GitHub reports as an empty name. + const checks = dedupeChecks([ + entry("build", "success", { workflowName: "", at: "2026-08-11T16:00:00Z" }), + entry("build", "failure", { workflowName: "CI", at: "2026-08-11T16:00:00Z" }), + ]); + + expect(checks.map((check) => check.name)).toEqual(["build", "CI / build"]); + }); +}); diff --git a/apps/server/src/pullRequest/pullRequestChecks.ts b/apps/server/src/pullRequest/pullRequestChecks.ts new file mode 100644 index 000000000000..819cf47b01af --- /dev/null +++ b/apps/server/src/pullRequest/pullRequestChecks.ts @@ -0,0 +1,55 @@ +import type { PullRequestCheck } from "@t3tools/contracts"; + +/** ISO-8601 timestamps in UTC compare correctly as plain text, which is all the ordering needs. */ +function isAtLeastAsNew(candidate: string | null, kept: string | null): boolean { + if (candidate === null) return kept === null; + return kept === null || candidate >= kept; +} + +/** + * One row per check rather than one per run of it. + * + * A host's rollup is a list of runs, not a list of checks: while a workflow is being re-run — or + * while a second run of it is already live — the same check arrives twice, and both copies reach + * the reader as what looks like a duplicate. Nothing in a check carries an id, so the name is what + * identifies it, qualified by the workflow it belongs to since two workflows are free to name a + * job the same thing. + * + * Within a group the newest run is the one worth showing: a re-run is the answer that replaces the + * one before it, and a run that never said when it happened loses to one that did. A tie goes to + * whichever came last, because a host lists a re-run after the run it repeats. + * + * The order is the host's own, held at the place each check first appeared, so a re-run landing + * mid-read replaces a row where it stands instead of reshuffling the list under the reader. + * + * Two checks that survive under the same name are then genuinely different ones, since they came + * from different workflows — each is shown as `workflow / name`, the way GitHub writes it itself. + * A survivor whose workflow the host did not name keeps its bare name rather than being qualified + * with nothing. + */ +export function dedupeChecks( + entries: ReadonlyArray<{ + readonly check: PullRequestCheck; + readonly workflowName: string | null; + readonly at: string | null; + }>, +): ReadonlyArray { + const newestByCheck = new Map(); + for (const entry of entries) { + const key = `${entry.workflowName ?? ""} ${entry.check.name}`; + const kept = newestByCheck.get(key); + // Re-setting a key a Map already holds keeps its first position, which is the order wanted. + if (kept === undefined || isAtLeastAsNew(entry.at, kept.at)) newestByCheck.set(key, entry); + } + const survivors = [...newestByCheck.values()]; + const countsByName = new Map(); + for (const entry of survivors) { + countsByName.set(entry.check.name, (countsByName.get(entry.check.name) ?? 0) + 1); + } + return survivors.map((entry) => { + const workflowName = entry.workflowName ?? ""; + return workflowName.length > 0 && (countsByName.get(entry.check.name) ?? 0) > 1 + ? { ...entry.check, name: `${workflowName} / ${entry.check.name}` } + : entry.check; + }); +} diff --git a/apps/server/src/sourceControl/GitHubCli.test.ts b/apps/server/src/sourceControl/GitHubCli.test.ts index 5daf7676d60c..964ed3d021c1 100644 --- a/apps/server/src/sourceControl/GitHubCli.test.ts +++ b/apps/server/src/sourceControl/GitHubCli.test.ts @@ -373,4 +373,34 @@ describe("GitHubCli.layer", () => { assert.equal(error.message.includes(cause.detail), false); }).pipe(Effect.provide(layer)), ); + + it.effect("surfaces an actionable rate-limit error without exposing provider stderr", () => + Effect.gen(function* () { + const cause = new VcsProcessExitError({ + operation: "GitHubCli.execute", + command: "gh", + cwd: "/repo", + exitCode: 1, + failureKind: "rate-limited", + detail: "API rate limit exceeded.", + stderrLength: 82, + stderrTruncated: false, + }); + mockRun.mockReturnValueOnce(Effect.fail(cause)); + + const gh = yield* GitHubCli.GitHubCli; + const error = yield* gh + .listOpenPullRequests({ + cwd: "/repo", + headSelector: "feature/rate-limited", + }) + .pipe(Effect.flip); + + assert.strictEqual(error._tag, "GitHubCliRateLimitError"); + assert.include(error.detail, "GitHub API rate limit exceeded"); + assert.include(error.detail, "gh api rate_limit"); + assert.strictEqual(error.cause, cause); + assert.notInclude(error.message, "user ID"); + }).pipe(Effect.provide(layer)), + ); }); diff --git a/apps/server/src/sourceControl/GitHubCli.ts b/apps/server/src/sourceControl/GitHubCli.ts index a705b0fb0b3e..974574cbd20e 100644 --- a/apps/server/src/sourceControl/GitHubCli.ts +++ b/apps/server/src/sourceControl/GitHubCli.ts @@ -51,6 +51,19 @@ export class GitHubCliAuthenticationError extends Schema.TaggedErrorClass()( + "GitHubCliRateLimitError", + gitHubCliFailureFields, +) { + get detail(): string { + return "GitHub API rate limit exceeded. Run `gh api rate_limit` to inspect the quota and reset time."; + } + + override get message(): string { + return `GitHub CLI failed in execute: ${this.detail}`; + } +} + export class GitHubPullRequestNotFoundError extends Schema.TaggedErrorClass()( "GitHubPullRequestNotFoundError", gitHubCliFailureFields, @@ -138,6 +151,7 @@ export class GitHubRepositoryDecodeError extends Schema.TaggedErrorClass { }).pipe(provideLive), ); + it.effect("classifies API rate limits without retaining provider stderr", () => + Effect.gen(function* () { + const providerStderr = + "GraphQL: API rate limit already exceeded for user ID 51714798 and token secret-value."; + const error = yield* run({ + operation: "test.rate-limit", + command: "node", + args: ["-e", "process.stderr.write(process.argv[1]); process.exit(1)", providerStderr], + cwd: process.cwd(), + }).pipe(Effect.flip); + + expect(error).toBeInstanceOf(VcsProcessExitError); + expect(error).toMatchObject({ + command: "node", + exitCode: 1, + detail: "API rate limit exceeded.", + failureKind: "rate-limited", + stderrLength: providerStderr.length, + stderrTruncated: false, + }); + expect(error.message).not.toContain(providerStderr); + expect(error.message).not.toContain("secret-value"); + }).pipe(provideLive), + ); + it.effect("retains spawn causes without exposing process arguments in the error message", () => Effect.gen(function* () { const secretArgument = "--token=super-secret-token"; diff --git a/apps/server/src/vcs/VcsProcess.ts b/apps/server/src/vcs/VcsProcess.ts index 299990e56ea0..ee4ed9712412 100644 --- a/apps/server/src/vcs/VcsProcess.ts +++ b/apps/server/src/vcs/VcsProcess.ts @@ -69,6 +69,14 @@ const classifyNonZeroExit = (command: string, stderr: string): VcsProcessExitFai return "authentication"; } + if ( + normalized.includes("api rate limit") || + normalized.includes("rate limit exceeded") || + normalized.includes("secondary rate limit") + ) { + return "rate-limited"; + } + if ( (command === "gh" && (normalized.includes("could not resolve to a pullrequest") || diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 126222d214a2..173c89ecabff 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1662,10 +1662,22 @@ const makeWsRpcLayer = ( observeRpcEffect(WS_METHODS.pullRequestsRunAction, pullRequests.runAction(input), { "rpc.aggregate": "pull-requests", }), + [WS_METHODS.pullRequestsUpdate]: (input) => + observeRpcEffect(WS_METHODS.pullRequestsUpdate, pullRequests.update(input), { + "rpc.aggregate": "pull-requests", + }), [WS_METHODS.pullRequestsComment]: (input) => observeRpcEffect(WS_METHODS.pullRequestsComment, pullRequests.comment(input), { "rpc.aggregate": "pull-requests", }), + [WS_METHODS.pullRequestsUpdateComment]: (input) => + observeRpcEffect( + WS_METHODS.pullRequestsUpdateComment, + pullRequests.updateComment(input), + { + "rpc.aggregate": "pull-requests", + }, + ), [WS_METHODS.pullRequestsSubmitReview]: (input) => observeRpcEffect(WS_METHODS.pullRequestsSubmitReview, pullRequests.submitReview(input), { "rpc.aggregate": "pull-requests", @@ -1682,6 +1694,10 @@ const makeWsRpcLayer = ( pullRequests.setThreadResolution(input), { "rpc.aggregate": "pull-requests" }, ), + [WS_METHODS.pullRequestsSetReaction]: (input) => + observeRpcEffect(WS_METHODS.pullRequestsSetReaction, pullRequests.setReaction(input), { + "rpc.aggregate": "pull-requests", + }), [WS_METHODS.pullRequestsInvalidate]: (input) => observeRpcEffect(WS_METHODS.pullRequestsInvalidate, pullRequests.invalidate(input), { "rpc.aggregate": "pull-requests", diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 8dc545d31eb0..e9390ed0a8aa 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -1569,13 +1569,14 @@ function ChatMarkdown({ if (href) openChangeRequestLink(event, href); }} onContextMenu={(event) => { - if (!canOpenInPreview || !href || !faviconHost) return; + if (!href || !faviconHost) return; event.preventDefault(); event.stopPropagation(); const api = readLocalApi(); if (!api) return; void showExternalLinkContextMenu({ href, + canOpenInPreview, position: { x: event.clientX, y: event.clientY }, showContextMenu: (items, position) => api.contextMenu.show(items, position), openInPreview: async (target) => { diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 5c2fb3d6d4c7..1f00c177c307 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -123,11 +123,11 @@ import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git"; import { useMediaQuery } from "../hooks/useMediaQuery"; import { RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY } from "../rightPanelLayout"; import { - pullRequestSurfaceId, selectActiveRightPanel, selectActiveRightPanelSurface, selectThreadRightPanelState, type RightPanelSurface, + updatePullRequestTabStatus, useRightPanelStore, } from "../rightPanelStore"; import { @@ -144,6 +144,7 @@ import { selectThreadPreviewMiniPlayer, usePreviewMiniPlayerStore, } from "../previewMiniPlayerStore"; +import { isThreadOwnPullRequest } from "./pullRequest/pullRequestDetail.logic"; import { PullRequestDetailPanel } from "./pullRequest/PullRequestDetailPanel"; import { PullRequestDetailGhost } from "./pullRequest/PullRequestGhosts"; import { PullRequestsUnavailableState } from "./pullRequest/PullRequestsUnavailableState"; @@ -1600,14 +1601,18 @@ function ChatViewContent(props: ChatViewProps) { const [pullRequestTabStatuses, setPullRequestTabStatuses] = useState< Record >({}); - const handlePullRequestTabStatusChange = useCallback((status: PullRequestTabStatus) => { - const id = pullRequestSurfaceId(status); - setPullRequestTabStatuses((current) => - current[id]?.state === status.state && current[id]?.isDraft === status.isDraft - ? current - : { ...current, [id]: status }, - ); - }, []); + // Keyed by the surface the panel is showing rather than by a key rebuilt from the status, so + // the tab is found again whether or not that surface was opened with an environment on it. + const activePullRequestSurfaceId = + activeRightPanelSurface?.kind === "pull-request" ? activeRightPanelSurface.id : undefined; + const handlePullRequestTabStatusChange = useCallback( + (status: PullRequestTabStatus) => { + const id = activePullRequestSurfaceId; + if (id === undefined) return; + setPullRequestTabStatuses((current) => updatePullRequestTabStatus(current, id, status)); + }, + [activePullRequestSurfaceId], + ); const activeFileSurface = activeRightPanelSurface?.kind === "file" ? activeRightPanelSurface : null; const activePreviewState = useThreadPreviewState(activeThreadRef); @@ -6082,11 +6087,23 @@ function ChatViewContent(props: ChatViewProps) { number: activeRightPanelSurface.number, }} context={ - activeThreadPr?.number === activeRightPanelSurface.number && - threadRepository === activeRightPanelSurface.repository + isThreadOwnPullRequest( + { + projectId: activeProject?.id ?? null, + repository: threadRepository, + number: activeThreadPr?.number ?? null, + }, + { + projectId: activeRightPanelSurface.projectId, + repository: activeRightPanelSurface.repository, + number: activeRightPanelSurface.number, + }, + ) ? "thread" : "page" } + chromeVariant="collapse" + composerDraftTarget={composerDraftTarget} onStateChange={handlePullRequestTabStatusChange} /> ) : activeRightPanelSurface?.kind === "agents" ? ( diff --git a/apps/web/src/components/chat/externalLinkContextMenu.test.ts b/apps/web/src/components/chat/externalLinkContextMenu.test.ts index 64935d53e46c..4f3dd1a153de 100644 --- a/apps/web/src/components/chat/externalLinkContextMenu.test.ts +++ b/apps/web/src/components/chat/externalLinkContextMenu.test.ts @@ -41,6 +41,25 @@ describe("external chat link context menu", () => { expect(harness.copyLink).not.toHaveBeenCalled(); }); + it("still offers the link's own actions where the integrated browser cannot be opened", async () => { + const harness = createHarness(null); + + await showExternalLinkContextMenu({ + href: "https://github.com/pingdotgg/t3code/pull/6169", + canOpenInPreview: false, + position: { x: 4, y: 8 }, + ...harness, + }); + + expect(harness.showContextMenu).toHaveBeenCalledWith( + [ + { id: "open-external", label: "Open in system browser" }, + { id: "copy-link", label: "Copy Link" }, + ], + { x: 4, y: 8 }, + ); + }); + it("copies the exact destination without opening it", async () => { const harness = createHarness("copy-link"); const href = "https://example.com/docs?topic=menus#copy"; diff --git a/apps/web/src/components/chat/externalLinkContextMenu.ts b/apps/web/src/components/chat/externalLinkContextMenu.ts index 398ca40da511..e93061c9fcb2 100644 --- a/apps/web/src/components/chat/externalLinkContextMenu.ts +++ b/apps/web/src/components/chat/externalLinkContextMenu.ts @@ -20,9 +20,25 @@ const EXTERNAL_LINK_CONTEXT_MENU_ITEMS = [ { id: "copy-link", label: "Copy Link" }, ] as const satisfies readonly ContextMenuItem[]; +/** + * The integrated browser is not always there to offer — it needs a thread to open beside and a + * runtime that can show it — but the other two answers hold wherever a link does. Dropping the + * whole menu with the one item that cannot be honoured is what left a right-click on a link + * showing the platform's cut-and-paste menu instead of a way to copy the link. + */ +export function externalLinkContextMenuItems(options: { + readonly canOpenInPreview: boolean; +}): readonly ContextMenuItem[] { + return options.canOpenInPreview + ? EXTERNAL_LINK_CONTEXT_MENU_ITEMS + : EXTERNAL_LINK_CONTEXT_MENU_ITEMS.filter((item) => item.id !== "open-in-preview"); +} + interface ShowExternalLinkContextMenuOptions { readonly href: string; readonly position: { readonly x: number; readonly y: number }; + /** Absent means yes, which is what every caller before the browser could be missing meant. */ + readonly canOpenInPreview?: boolean; readonly showContextMenu: ( items: readonly ContextMenuItem[], position: { readonly x: number; readonly y: number }, @@ -50,6 +66,7 @@ export function resolveExternalWebLinkHost(href: string | undefined): string | n export async function showExternalLinkContextMenu({ href, position, + canOpenInPreview = true, showContextMenu, openInPreview, openExternal, @@ -58,7 +75,7 @@ export async function showExternalLinkContextMenu({ }: ShowExternalLinkContextMenuOptions): Promise { let action: ExternalLinkContextMenuAction | null; try { - action = await showContextMenu(EXTERNAL_LINK_CONTEXT_MENU_ITEMS, position); + action = await showContextMenu(externalLinkContextMenuItems({ canOpenInPreview }), position); } catch (cause) { reportFailure("show-link-context-menu", cause); return; diff --git a/apps/web/src/components/diffs/StyledDiffCodeView.tsx b/apps/web/src/components/diffs/StyledDiffCodeView.tsx index f422c7aebbd8..37ce2085b71b 100644 --- a/apps/web/src/components/diffs/StyledDiffCodeView.tsx +++ b/apps/web/src/components/diffs/StyledDiffCodeView.tsx @@ -269,6 +269,11 @@ type StyledDiffCodeViewProps = ( ) & { readonly options?: StyledDiffCodeViewOptions; readonly viewerRef?: Ref>; + /** + * Appended to the shared stylesheet inside the viewer's shadow root, for a surface that has + * to restyle chrome the viewer owns — such as replacing its per-file line counts. + */ + readonly unsafeCSSExtra?: string; }; /** The shared web CodeView surface: app styling and virtualized geometry stay paired here. */ @@ -276,6 +281,7 @@ export function StyledDiffCodeView({ options, viewerRef, className, + unsafeCSSExtra, ...props }: StyledDiffCodeViewProps) { return ( @@ -291,7 +297,9 @@ export function StyledDiffCodeView({ } options={{ ...options, - unsafeCSS: DIFF_VIEW_UNSAFE_CSS, + unsafeCSS: unsafeCSSExtra + ? `${DIFF_VIEW_UNSAFE_CSS}\n${unsafeCSSExtra}` + : DIFF_VIEW_UNSAFE_CSS, itemMetrics: { diffHeaderHeight: 32, hunkSeparatorHeight: 24, diff --git a/apps/web/src/components/pullRequest/PullRequestChecksPopover.tsx b/apps/web/src/components/pullRequest/PullRequestChecksPopover.tsx new file mode 100644 index 000000000000..3b8ffd2b2f28 --- /dev/null +++ b/apps/web/src/components/pullRequest/PullRequestChecksPopover.tsx @@ -0,0 +1,134 @@ +import type { + EnvironmentId, + PullRequestCheck, + PullRequestChecksState, + PullRequestRef, +} from "@t3tools/contracts"; + +import { readLocalApi } from "~/localApi"; +import { cn } from "~/lib/utils"; +import { pullRequestEnvironment } from "~/state/pullRequests"; +import { useEnvironmentQuery } from "~/state/query"; + +import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; +import { + PullRequestCheckStatusIcon, + pullRequestCheckStatusLabel, + pullRequestChecksStatePresentation, + summarizePullRequestChecks, +} from "./pullRequestPresentation"; + +/** + * The checks behind the rollup, for a row that only carries the rollup. Mounted by the popup, so + * the read starts when somebody opens it rather than once per row of a listing — the detail read + * is a request per pull request, and a page of them at rest would be a hundred. + */ +function LazyChecksBody({ + environmentId, + reference, +}: { + environmentId: EnvironmentId; + reference: PullRequestRef; +}) { + const detailQuery = useEnvironmentQuery( + pullRequestEnvironment.detail({ environmentId, input: reference }), + ); + if (detailQuery.error !== null) { + return

    {detailQuery.error}

    ; + } + if (detailQuery.data === null) { + return ( +

    + {detailQuery.isPending ? "Loading checks…" : "No checks reported"} +

    + ); + } + return ; +} + +function ChecksBody({ checks }: { checks: ReadonlyArray }) { + if (checks.length === 0) { + return

    No checks reported

    ; + } + return ( +
      + {/* Keyed by position as well as by name: the host is the one that decides how many runs + share a name, and a repeated key is a rendering fault rather than a wrong list. */} + {checks.map((check, index) => ( +
    • + + + {check.name} + + + {pullRequestCheckStatusLabel(check.status)} + + {check.url === null ? null : ( + + )} +
    • + ))} +
    + ); +} + +/** + * The checks indicator and what it opens, in both places a change request is shown: a listing + * row, which knows only the rollup, and the detail header, which is already holding every check. + * + * `checks` decides between the two. Given them, nothing is read; without them, the popup reads + * the detail itself, which is why the row must also say which environment it came from. + */ +export function PullRequestChecksPopover({ + checksState, + checks, + environmentId, + reference, + className, +}: { + checksState: PullRequestChecksState; + /** The checks already in hand, for the detail header. Absent on a listing row. */ + checks?: ReadonlyArray; + environmentId?: EnvironmentId; + reference?: PullRequestRef; + className?: string; +}) { + const presentation = pullRequestChecksStatePresentation(checksState); + // Counts beat the rollup's own wording where they are known, the way GitHub's own header reads. + const summary = checks === undefined ? null : summarizePullRequestChecks(checks); + return ( + + {/* A listing row is itself a button, so the trigger renders as a span: a nested button is + not valid inside one. The click is stopped here so opening the checks does not also + select the row it sits on. */} + + } + onClick={(event) => event.stopPropagation()} + > + + + +

    {presentation.label}

    + {summary === null ? null :

    {summary}

    } + {checks !== undefined ? ( + + ) : environmentId !== undefined && reference !== undefined ? ( + + ) : null} +
    +
    + ); +} diff --git a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx index 93418e7c301d..a5b3c97395a7 100644 --- a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx @@ -4,6 +4,7 @@ import type { EnvironmentId, PullRequestDetailView, PullRequestDiffSide, + PullRequestOmittedFileStat, PullRequestRef, PullRequestReviewThread, } from "@t3tools/contracts"; @@ -28,6 +29,8 @@ import { useClientSettings } from "~/hooks/useSettings"; import { useTheme } from "~/hooks/useTheme"; import { areAllDiffFilesCollapsed } from "~/lib/diffCollapse"; import { pullRequestFindingKey, type PullRequestFinding } from "./pullRequestDetail.logic"; +import { canEditPullRequestComment } from "./pullRequestEditing.logic"; +import { orderDiffFiles } from "./pullRequestFileOrder.logic"; import { buildFileDiffRenderKey, fnv1a32, @@ -95,8 +98,20 @@ interface DiffSlice { readonly patch: string; readonly truncated: boolean; readonly nextCursor: string | null; + readonly omittedFileStats: ReadonlyArray; } +/** + * The viewer's own per-file counts are hidden and drawn from this side of its shadow root + * instead: its counts are hunk sums, and a file whose hunks the host withheld would read as + * an empty change rather than as the counts the host did report. + */ +const REPLACE_FILE_COUNTS_CSS = ` +[data-diffs-header] [data-additions-count], +[data-diffs-header] [data-deletions-count] { + display: none !important; +}`; + /** Nothing loaded yet, as one identity, so the memos below do not see a new array every render. */ const NO_SLICES: ReadonlyArray = []; @@ -155,6 +170,7 @@ export function PullRequestCodeTab({ selectedCommitOid, onSelectedCommitChange, pendingFinding, + fixFindingLabel = "Fix in a thread", onFixFinding, onAskAboutSelection, onRefresh, @@ -168,6 +184,7 @@ export function PullRequestCodeTab({ onSelectedCommitChange: (oid: string | null) => void; /** The hand-off currently preparing, if any, so only the finding it belongs to says so. */ pendingFinding?: string | null; + fixFindingLabel?: string; onFixFinding?: (finding: PullRequestFinding) => void; /** Absent where a selection has no agent to go to, which takes the Ask button off the box. */ onAskAboutSelection?: (input: PullRequestAskSelectionInput) => void; @@ -246,6 +263,7 @@ export function PullRequestCodeTab({ patch: data.patch, truncated: data.truncated, nextCursor: data.nextCursor, + omittedFileStats: data.omittedFileStats ?? [], }; const index = slices.findIndex((slice) => slice.cursor === cursor); if (index === -1) { @@ -256,7 +274,17 @@ export function PullRequestCodeTab({ existing !== undefined && existing.patch === next.patch && existing.truncated === next.truncated && - existing.nextCursor === next.nextCursor + existing.nextCursor === next.nextCursor && + existing.omittedFileStats.length === next.omittedFileStats.length && + existing.omittedFileStats.every((file, index) => { + const refreshed = next.omittedFileStats[index]; + return ( + refreshed !== undefined && + refreshed.path === file.path && + refreshed.additions === file.additions && + refreshed.deletions === file.deletions + ); + }) ) { return previous; } @@ -290,6 +318,9 @@ export function PullRequestCodeTab({ const setThreadResolution = useAtomCommand(pullRequestEnvironment.setThreadResolution, { reportFailure: false, }); + const updateComment = useAtomCommand(pullRequestEnvironment.updateComment, { + reportFailure: false, + }); const getDiffFileContents = useAtomCommand(pullRequestEnvironment.diffFileContents); const loadDiffFiles = useMemo( () => @@ -337,16 +368,12 @@ export function PullRequestCodeTab({ }), [loadedSlices, resolvedTheme, scopeKey], ); - // Sorted within a slice rather than across them: sorting the accumulated set would let a late + // Ordered within a slice rather than across them: ordering the accumulated set would let a late // slice push a file the reader is part way through further down the page. const files = useMemo( () => parsedSlices.flatMap((parsed) => - parsed?.kind === "files" - ? parsed.files.toSorted((left, right) => - resolveFileDiffPath(left).localeCompare(resolveFileDiffPath(right)), - ) - : [], + parsed?.kind === "files" ? orderDiffFiles(parsed.files) : [], ), [parsedSlices], ); @@ -449,7 +476,13 @@ export function PullRequestCodeTab({ }:${thread.comments .map( (comment) => - `${comment.id}:${comment.author?.login ?? ""}:${comment.createdAt}:${comment.body}`, + `${comment.id}:${comment.author?.login ?? ""}:${comment.createdAt}:${comment.body}:${( + comment.reactions ?? [] + ) + .map( + (r) => `${r.content}:${r.count}:${r.viewerHasReacted ? "v" : ""}`, + ) + .join(",")}`, ) .join(";")}`, ) @@ -471,6 +504,15 @@ export function PullRequestCodeTab({ ], ); const lineStat = useMemo(() => getDiffLineStat(files), [files]); + const omittedFileStats = useMemo( + () => + new Map( + loadedSlices.flatMap((slice) => + slice.omittedFileStats.map((file) => [file.path, file] as const), + ), + ), + [loadedSlices], + ); const fileKeys = useMemo(() => items.map((item) => item.id), [items]); const collapsedFileKeys = useMemo( () => new Set(items.filter((item) => item.collapsed === true).map((item) => item.id)), @@ -649,6 +691,30 @@ export function PullRequestCodeTab({ [toggleFile], ); + const renderHeaderMetadata = useCallback( + (item: CodeViewItem) => { + if (item.type !== "diff") return null; + let additions = 0; + let deletions = 0; + for (const hunk of item.fileDiff.hunks) { + additions += hunk.additionLines; + deletions += hunk.deletionLines; + } + if (additions === 0 && deletions === 0) { + const withheld = omittedFileStats.get(resolveFileDiffPath(item.fileDiff)); + if (withheld) ({ additions, deletions } = withheld); + } + return ( + + ); + }, + [omittedFileStats], + ); + const diffViewOptions = useMemo( () => ({ diffStyle: diffRenderMode === "split" ? ("split" as const) : ("unified" as const), @@ -699,13 +765,20 @@ export function PullRequestCodeTab({ const renderThreadCard = useCallback( (thread: PullRequestReviewThread) => ( onFixFinding({ kind: "thread", thread }) } : {})} onReply={(body) => runThreadCommand("Reply could not be posted", () => @@ -715,6 +788,18 @@ export function PullRequestCodeTab({ }), ) } + // A conversation on a line is made of review comments, whatever the host filed them as. + canEditComment={(comment) => + canEditPullRequestComment(detail, { author: comment.author, kind: "review-comment" }) + } + onEditComment={(commentId, body) => + runThreadCommand("The comment could not be saved", () => + updateComment({ + environmentId, + input: { ...reference, commentId, kind: "review-comment", body }, + }), + ) + } onToggleResolved={() => void runThreadCommand("The conversation could not be updated", () => setThreadResolution({ @@ -723,11 +808,14 @@ export function PullRequestCodeTab({ }), ) } + onReacted={onRefresh} /> ), [ - detail.workspaceRoot, + detail, environmentId, + fixFindingLabel, + onRefresh, onFixFinding, pendingFinding, reference, @@ -737,12 +825,13 @@ export function PullRequestCodeTab({ runThreadCommand, setThreadResolution, threadPending, + updateComment, ], ); const renderAnnotation = useCallback( (annotation: ReviewAnnotation) => ( -
    +
    {annotation.metadata.threads.map(renderThreadCard)} {annotation.metadata.pending.map((comment) => ( {reviewOverlay}
    diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx index 4ac181280109..7237b4357481 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx @@ -1,11 +1,13 @@ -import { scopeProjectRef } from "@t3tools/client-runtime/environment"; +import { scopedThreadKey, scopeProjectRef } from "@t3tools/client-runtime/environment"; import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; import type { EnvironmentId, PullRequestAction, PullRequestMergeMethod, + PullRequestUpdateMethod, PullRequestRef, PullRequestState, + ScopedThreadRef, } from "@t3tools/contracts"; import { ArrowDownUpIcon, @@ -28,12 +30,15 @@ import { LinkIcon, MoreHorizontalIcon, PanelRightIcon, + PencilIcon, RefreshCwIcon, + ServerIcon, TriangleAlertIcon, } from "lucide-react"; import { lazy, Suspense, + type MouseEvent as ReactMouseEvent, useCallback, useEffect, useLayoutEffect, @@ -49,6 +54,8 @@ import { usePreparePullRequestThreadAction } from "~/lib/sourceControlActions"; import { cn } from "~/lib/utils"; import { readLocalApi } from "~/localApi"; import type { ReviewCommentContext } from "~/reviewCommentContext"; +import { useProjects } from "~/state/entities"; +import { useEnvironments } from "~/state/environments"; import { useEnvironmentQuery } from "~/state/query"; import { useLiveRefresh } from "~/hooks/useLiveRefresh"; import { pullRequestEnvironment } from "~/state/pullRequests"; @@ -66,6 +73,7 @@ import { } from "../ui/alert-dialog"; import { Badge } from "../ui/badge"; import { Button } from "../ui/button"; +import { Input } from "../ui/input"; import { Menu, MenuItem, @@ -75,12 +83,14 @@ import { MenuSeparator, MenuTrigger, } from "../ui/menu"; +import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; import { toastManager } from "../ui/toast"; import { PullRequestDetailGhost, PullRequestTimelineGhost } from "./PullRequestGhosts"; import { PullRequestActivityUnavailableState } from "./PullRequestActivityUnavailableState"; import { DiffPanelLoadingState } from "../DiffPanelShell"; import { PullRequestsUnavailableState } from "./PullRequestsUnavailableState"; import type { PullRequestAskSelectionInput } from "./PullRequestCodeTab"; +import { openOnHostLabel, showPullRequestLinkContextMenu } from "./pullRequestLinkContextMenu"; import { PullRequestSummaryTab } from "./PullRequestSummaryTab"; import { PullRequestTimelineTab } from "./PullRequestTimelineTab"; import { @@ -92,14 +102,24 @@ import { buildResolveConflictsPrompt, handoffPrompt, handoffReviewComments, + pullRequestActionNeedsHostRefresh, pullRequestFindingKey, + pullRequestHandoffLabels, readableFailure, + resolveBaseFreshness, type PullRequestFinding, } from "./pullRequestDetail.logic"; +import { canEditPullRequestChangeRequest } from "./pullRequestEditing.logic"; +import { + resolvePickableEnvironments, + type PickableEnvironment, +} from "./pullRequestProjectAssignment.logic"; +import { PullRequestChecksPopover } from "./PullRequestChecksPopover"; import { PullRequestActorLabel, PullRequestDiffStat, PullRequestMetaLine, + pullRequestChecksState, resolvePullRequestState, summarizePullRequestChecks, } from "./pullRequestPresentation"; @@ -112,6 +132,12 @@ const ACTION_SUCCESS_LABELS: Record = { draft: "Converted to draft", close: "Pull request closed", reopen: "Pull request reopened", + "update-branch": "Branch updated with the base branch", + // True whichever it did: a pull request that was already mergeable merges the moment this is + // armed, and the client has no way to tell that apart from one still waiting on something. + "enable-auto-merge": + "Auto-merge turned on — merges as soon as this is ready, sooner if it already is", + "disable-auto-merge": "Auto-merge turned off", }; /** Said as the thing that did not happen, rather than as the operation that returned an error. */ @@ -121,6 +147,9 @@ const ACTION_FAILURE_LABELS: Record = { draft: "Could not convert this to a draft", close: "Could not close this pull request", reopen: "Could not reopen this pull request", + "update-branch": "Could not update this branch", + "enable-auto-merge": "Could not turn on auto-merge", + "disable-auto-merge": "Could not turn off auto-merge", }; /** What to try, for the times the host says only that it refused. */ @@ -132,15 +161,25 @@ const ACTION_FAILURE_HINTS: Record = { close: "The host refused it. Check that you have write access, or that you opened it.", reopen: "The host refused it. Check that you have write access, and that the branch still exists.", + // Said for the merge commit, which is what an update is unless a rebase was asked for. The + // rebase has its own reasons to fail and its own sentence below. + "update-branch": + "The host refused it. Check that you have write access to the branch — one from a fork also needs its author to allow edits from maintainers — and that it does not conflict with the base.", + // The one refusal that is usually a repository setting rather than anything about this branch: + // GitHub will not arm an auto-merge at all unless the repository has the feature switched on. + "enable-auto-merge": + "The host refused it. Check that this repository allows auto-merge, that you have write access, and that there is something left for it to wait on.", + "disable-auto-merge": + "The host refused it. Check that you have write access, and that the merge has not already happened.", }; -/** Named for the host rather than "externally": the point is where you will land. */ -const OPEN_ON_HOST_LABELS: Partial> = { - github: "Open on GitHub", - gitlab: "Open on GitLab", - bitbucket: "Open on Bitbucket", - "azure-devops": "Open on Azure DevOps", -}; +/** + * Said instead of the update hint when the reader asked for a rebase: it is the one that fails on + * its own merits, because GitHub replays the commits and stops at the first that does not apply. + * Offering the merge commit only makes sense to somebody who did not already choose it. + */ +const UPDATE_BRANCH_REBASE_FAILURE_HINT = + "The host refused it. A rebase stops at the first commit that does not apply cleanly; updating with a merge commit may still work."; const TABS: ReadonlyArray<{ value: DetailTab; label: string }> = [ { value: "summary", label: "Summary" }, @@ -159,7 +198,148 @@ const PullRequestCodeTab = lazy(loadCodeTab); * is closed by the time the next one opens. It is how a prompt the reader has since edited is told * apart from the one they were handed: only the sentence still exactly as written may be replaced. */ -const lastHandoffPromptByDraft = new Map(); +const lastHandoffPromptByDraft = new Map(); + +const composerTargetKey = (target: ScopedThreadRef | DraftId): string => + typeof target === "string" ? target : scopedThreadKey(target); + +/** + * Which server the checkout and the hand-offs land on, where more than one of them holds this + * repository. The list picked one of them to show the pull request under, so that everything on + * it is read from somewhere; where the reader wants to work is a separate answer, and this is + * where they give it. + */ +function ActOnEnvironmentPicker({ + environments, + value, + onChange, + disabled, +}: { + environments: ReadonlyArray; + value: EnvironmentId; + onChange: (environmentId: EnvironmentId) => void; + disabled: boolean; +}) { + return ( + <> + + onChange(environmentId as EnvironmentId)} + > + {environments.map((environment) => ( + + {/* The radio item lays its children out as one block, so the icon and the label + need their own row to share a line. */} + + + {environment.label} + + + ))} + + + ); +} + +/** The number is a link in every place the host writes it, so the right-click that copies one + has to answer here too — otherwise the platform's own cut/paste menu opens over it. */ +const openNumberContextMenu = ( + event: ReactMouseEvent, + detail: { readonly url: string; readonly provider: string }, +): void => { + event.preventDefault(); + event.stopPropagation(); + void showPullRequestLinkContextMenu({ + url: detail.url, + openLabel: openOnHostLabel(detail.provider), + position: { x: event.clientX, y: event.clientY }, + }); +}; + +/** + * The stale-branch warning, said beside the branch it is about rather than as a bar of its own. + * The banner this replaces held a row of chrome open across the top of every pull request that + * had fallen behind, pushing the reading down to say something that is true of the base branch + * and nothing else; as a mark on the base branch it is where a reader would look for it, and the + * sentence and the way out of it arrive together the moment the mark is pointed at. + * + * A popover rather than a tooltip because what it holds can be pressed: a tooltip's layer takes + * no pointer, and a control nobody can reach is worse than no control. + */ +function PullRequestBaseFreshnessWarning({ + baseBranch, + freshness, + pending, + onUpdate, + iconClassName, +}: { + readonly baseBranch: string; + readonly freshness: { + readonly behindBy: number | null; + readonly methods: ReadonlyArray; + }; + readonly pending: boolean; + readonly onUpdate: (method: PullRequestUpdateMethod) => void; + readonly iconClassName?: string; +}) { + const behind = + freshness.behindBy === null + ? "" + : ` by ${freshness.behindBy.toLocaleString()} ${ + freshness.behindBy === 1 ? "commit" : "commits" + }`; + const summary = `This branch is out-of-date with ${baseBranch}${behind}.`; + return ( + + + } + > + + + +

    {summary}

    +

    Changes can be cleanly merged.

    + {/* Each way the host offers and this reader may take, as its own button: a split button + would need a menu inside a popover, and two buttons say the same thing in one layer. */} + {freshness.methods.length > 0 ? ( + + {freshness.methods.map((method) => ( + + ))} + + ) : null} +
    +
    + ); +} export function PullRequestDetailPanel({ environmentId, @@ -170,6 +350,7 @@ export function PullRequestDetailPanel({ onStateChange, context = "page", chromeVariant = "full", + composerDraftTarget, }: { environmentId: EnvironmentId; reference: PullRequestRef; @@ -206,6 +387,11 @@ export function PullRequestDetailPanel({ * top — the chrome spends its height on what is being read. */ chromeVariant?: "full" | "collapse"; + /** + * The open thread's composer. Beside the thread whose own pull request this is, hand-offs + * land here instead of opening a new thread — the branch is already under the reader's feet. + */ + composerDraftTarget?: ScopedThreadRef | DraftId; }) { const pullRequestKey = `${reference.projectId}:${reference.repository}#${reference.number}`; const [tab, setTab] = useState("summary"); @@ -267,7 +453,9 @@ export function PullRequestDetailPanel({ if (scroller) scroller.scrollTop = Math.max(0, scroller.scrollTop + delta); }, [condensed]); const [mergeMethod, setMergeMethod] = useState("merge"); - const [confirmAction, setConfirmAction] = useState<"merge" | "close" | null>(null); + const [confirmAction, setConfirmAction] = useState< + "merge" | "close" | "enable-auto-merge" | null + >(null); // Which handoff is preparing, keyed so a per-finding button can say "Preparing..." on itself // alone. One at a time whatever the key: they all check the same pull request out. const [handoff, setHandoff] = useState(null); @@ -310,6 +498,7 @@ export function PullRequestDetailPanel({ commentsTruncated: activity?.commentsTruncated ?? false, reviewThreads: activity?.reviewThreads ?? [], commits: activity?.commits ?? [], + reactions: activity?.reactions ?? [], }, [activity, coreDetail], ); @@ -355,44 +544,157 @@ export function PullRequestDetailPanel({ void refreshFromHost(); }, [forcedRefreshToken, refreshFromHost]); const runAction = useAtomCommand(pullRequestEnvironment.runAction, { reportFailure: false }); - const [actionPending, setActionPending] = useState(false); + // Which action is in flight, not merely that one is: every control here is disabled while any + // of them runs, but only the button that was pressed may say what it is doing. + const [pendingAction, setPendingAction] = useState(null); + const actionPending = pendingAction !== null; + const update = useAtomCommand(pullRequestEnvironment.update, { reportFailure: false }); + // Scoped to the pull request it was typed against, since this one panel shows a different one + // every time it is opened and a half-written title must not follow it there. + const [titleScope, setTitleScope] = useState<{ + readonly pullRequestKey: string; + readonly text: string; + } | null>(null); + const titleDraft = titleScope?.pullRequestKey === pullRequestKey ? titleScope.text : null; + const [titleSaving, setTitleSaving] = useState(false); const newThread = useNewThreadHandler(); + const { environments } = useEnvironments(); + const projects = useProjects(); + // Beside a thread there is nothing to pick: the hand-offs land in that thread's composer, and + // the thread is already on one server's copy of the branch. + const pickableEnvironments = useMemo( + () => + context === "page" + ? resolvePickableEnvironments( + { environmentId, projectId: reference.projectId }, + projects, + environments, + ) + : [], + [context, environmentId, environments, projects, reference.projectId], + ); + // Which server the reader chose, and only for the pull request they chose it on: this one panel + // shows a different pull request every time it is opened, and the choice does not follow. + const [actingScope, setActingScope] = useState<{ + readonly pullRequestKey: string; + readonly environmentId: EnvironmentId; + } | null>(null); + const chosenEnvironmentId = + actingScope?.pullRequestKey === pullRequestKey ? actingScope.environmentId : environmentId; + // Null wherever there is no choice on offer — one server, or a chosen one that has since gone — + // and then the panel's own server and its own checkout are the answer, as they always were. + const acting = + pickableEnvironments.find((entry) => entry.environmentId === chosenEnvironmentId) ?? null; + const actingEnvironmentId = acting?.environmentId ?? environmentId; const prepareThread = usePreparePullRequestThreadAction({ - environmentId, - cwd: detail?.workspaceRoot ?? null, + environmentId: actingEnvironmentId, + cwd: acting?.workspaceRoot ?? detail?.workspaceRoot ?? null, }); - const perform = async (action: PullRequestAction, method?: PullRequestMergeMethod) => { - if (actionPending) return; - setActionPending(true); + const perform = async ( + action: PullRequestAction, + method?: PullRequestMergeMethod, + updateMethod?: PullRequestUpdateMethod, + ) => { + if (pendingAction !== null) return; + setPendingAction(action); const result = await runAction({ environmentId, - input: { ...reference, action, ...(method ? { mergeMethod: method } : {}) }, + input: { + ...reference, + action, + ...(method ? { mergeMethod: method } : {}), + ...(updateMethod ? { updateMethod } : {}), + }, }); - setActionPending(false); + setPendingAction(null); if (result._tag === "Failure") { // The host's own sentence, because it is the only thing that says why. A merge strategy a // branch policy forbids is refused at completion and nowhere earlier — Azure DevOps // publishes no per-strategy availability to hide the control with — so "action failed" // would leave the reader pressing the same button again. const failure = squashAtomCommandFailure(result); + // The hint stands for what was actually asked for: a reader who pressed Update branch is + // told to check their access, not offered the merge commit they already chose. + const hint = + updateMethod === "rebase" + ? UPDATE_BRANCH_REBASE_FAILURE_HINT + : ACTION_FAILURE_HINTS[action]; toastManager.add({ type: "error", title: ACTION_FAILURE_LABELS[action], - description: readableFailure(failure, ACTION_FAILURE_HINTS[action]), + description: readableFailure(failure, hint), }); return; } toastManager.add({ type: "success", title: ACTION_SUCCESS_LABELS[action] }); - refreshDetail(); + // A branch update moves the head commit, which leaves the diff atom pointed at a comparison + // that no longer exists — the same staleness the manual refresh button fixes, so it goes + // through that path rather than a second one. Every other action here only changes metadata; + // a merge does move the branch too, but it also closes the pull request, where the diff is + // no longer what anyone is looking at. + if (pullRequestActionNeedsHostRefresh(action)) { + void refreshFromHost(); + } else { + refreshDetail(); + } onActed?.(); }; + const saveTitle = async (next: string) => { + const title = next.trim(); + if (detail === null || titleSaving) return; + if (title.length === 0 || title === detail.title) { + setTitleScope(null); + return; + } + setTitleSaving(true); + const result = await update({ environmentId, input: { ...reference, title } }); + setTitleSaving(false); + if (result._tag === "Failure") { + // The draft stays open with the words still in it: retyping a title somebody has just + // rewritten is the one thing a failed save must not cost them. + toastManager.add({ + type: "error", + title: "The title could not be saved", + description: readableFailure( + squashAtomCommandFailure(result), + "The host refused the new title.", + ), + }); + return; + } + setTitleScope(null); + refreshDetail(); + }; + type ThreadTask = { prompt: string; reviewComments?: ReadonlyArray; }; + // Beside the thread whose own pull request this is, a task belongs in that thread's composer: + // the branch is already checked out under it, so opening a second thread would only scatter + // the work. + const attachTarget = context === "thread" ? (composerDraftTarget ?? null) : null; + const handoffLabels = pullRequestHandoffLabels(attachTarget !== null); + + const writeTaskToComposer = (target: ScopedThreadRef | DraftId, task: ThreadTask) => { + const store = useComposerDraftStore.getState(); + const draft = store.getComposerDraft(target); + const key = composerTargetKey(target); + const prompt = handoffPrompt( + { prompt: draft?.prompt ?? "", lastHandoffPrompt: lastHandoffPromptByDraft.get(key) }, + task.prompt, + ); + lastHandoffPromptByDraft.set(key, task.prompt); + store.setPrompt(target, prompt); + store.setReviewComments( + target, + handoffReviewComments(draft?.reviewComments ?? [], task.reviewComments ?? []), + ); + }; + /** * Opens a thread on this project and leaves the task in its composer for the reader to send. * @@ -412,37 +714,32 @@ export function PullRequestDetailPanel({ () => null, )); if (session === null) return null; - const store = useComposerDraftStore.getState(); if (task === null) return session; // The latest press is the ask: it takes over what an earlier hand-off left, prompt and chips // both, rather than stacking a second one under the first. What the reader typed themselves // survives — the composer they are handed is not always a fresh one, and a prompt they have // since edited is theirs rather than the hand-off's. - const draft = store.getComposerDraft(session.draftId); - const existingComments = draft?.reviewComments ?? []; - const prompt = handoffPrompt( - { - prompt: draft?.prompt ?? "", - lastHandoffPrompt: lastHandoffPromptByDraft.get(session.draftId), - }, - task.prompt, - ); - // Remember the hand-off's own contribution, not the merged prompt: only that sentence is - // this session's to take back next time, and the reader's text around it is not. - lastHandoffPromptByDraft.set(session.draftId, task.prompt); - store.setPrompt(session.draftId, prompt); - store.setReviewComments( - session.draftId, - handoffReviewComments(existingComments, task.reviewComments ?? []), - ); + writeTaskToComposer(session.draftId, task); return session; }; /** A question about the change, which needs a thread and nothing else. */ const startAsk = async (kind: string, task: ThreadTask) => { if (!detail || handoff !== null) return; + if (attachTarget !== null) { + writeTaskToComposer(attachTarget, task); + toastManager.add({ + type: "success", + title: "Added to the composer", + description: + task.prompt.length > 0 + ? "The question is in the composer — read it over, then send." + : "The pull request is in the composer — type your question, then send.", + }); + return; + } setHandoff(kind); - const projectRef = scopeProjectRef(environmentId, detail.projectId); + const projectRef = scopeProjectRef(actingEnvironmentId, acting?.projectId ?? detail.projectId); const opened = await openThreadWithTask(projectRef, task); setHandoff(null); if (opened === null) { @@ -477,6 +774,15 @@ export function PullRequestDetailPanel({ mode: "worktree" | "local" = "worktree", ) => { if (!detail || handoff !== null) return; + if (attachTarget !== null && task !== null) { + writeTaskToComposer(attachTarget, task); + toastManager.add({ + type: "success", + title: "Added to the composer", + description: "The task is in the composer — read it over, then send.", + }); + return; + } setHandoff(kind); // The menu closes on the press and takes its "Preparing..." label with it, so this is the // only thing answering for the checkout. It carries no timeout of its own: a loading toast @@ -485,7 +791,9 @@ export function PullRequestDetailPanel({ type: "loading", title: "Preparing the pull request checkout...", }); - const projectRef = scopeProjectRef(environmentId, detail.projectId); + // Wherever the reader chose to act: the thread, the checkout it is pointed at and the composer + // the task lands in are all one server's, and picking another one moves all three. + const projectRef = scopeProjectRef(actingEnvironmentId, acting?.projectId ?? detail.projectId); // The thread is opened before the checkout rather than after it, because the project's setup // script only runs for a checkout that knows which thread it is for — and a worktree with no // dependencies installed is not something anyone can test. @@ -688,6 +996,12 @@ export function PullRequestDetailPanel({ ? mergeMethod : (allowedMergeMethods[0] ?? "merge"); const conflicting = detail?.state === "open" && detail.mergeability === "conflicting"; + // Only an outright yes arms it. A host that reports nothing has not said the merge is already + // spoken for, and an off switch for something that may not be on says the wrong thing twice. + const autoMergeArmed = detail?.state === "open" && detail.autoMergeEnabled === true; + // Out of date with the base, and still cleanly mergeable — the one pairing an update button + // exists for. Null everywhere else, including hosts that cannot compare at all. + const freshness = detail === null ? null : resolveBaseFreshness(detail); // A host that cannot produce a patch has no Code tab to open. The tabs themselves stay hidden // until the detail arrives, so the loading ghost is the panel's only unfinished UI. const visibleTabs = TABS.filter( @@ -725,6 +1039,7 @@ export function PullRequestDetailPanel({ ? resolvePullRequestState({ state: detail.state, isDraft: detail.isDraft }) : null; const checksSummary = detail ? summarizePullRequestChecks(detail.checks) : null; + const checksState = detail ? pullRequestChecksState(detail.checks) : null; return (
    @@ -755,11 +1070,12 @@ export function PullRequestDetailPanel({ ) : null} @@ -1015,7 +1391,16 @@ export function PullRequestDetailPanel({ {/* The condensed chrome's second row: the tabs that the closing fold takes with it, and compact copies of the branch pair and diff stat so they stay in sight while the full rows are folded away. Same zero-track mechanism as the fold, inverted. */} -
    +
    {detail.baseBranch} + {freshness ? ( + void perform("update-branch", undefined, method)} + iconClassName="size-3" + /> + ) : null} {detail.headBranch} @@ -1077,10 +1471,13 @@ export function PullRequestDetailPanel({
    {detail ? (
    -

    {detail.title}

    + {titleDraft === null ? ( +
    +

    + {detail.title} +

    + {canEditPullRequestChangeRequest(detail) ? ( + + ) : null} +
    + ) : ( + // A title is one line of text, not markdown, so it takes an input rather than + // the editor the description and the remarks share. +
    + + setTitleScope({ pullRequestKey, text: event.target.value }) + } + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + void saveTitle(titleDraft); + } else if (event.key === "Escape") { + event.preventDefault(); + setTitleScope(null); + } + }} + /> +
    + + +
    +
    + )} updated {formatRelativeTimeLabel(detail.updatedAt)} @@ -1111,6 +1567,14 @@ export function PullRequestDetailPanel({ > {detail.baseBranch} + {freshness ? ( + void perform("update-branch", undefined, method)} + /> + ) : null}
    @@ -1178,7 +1642,7 @@ export function PullRequestDetailPanel({ {detail ? (
    @@ -1363,6 +1837,7 @@ export function PullRequestDetailPanel({ selectedCommitOid={selectedCodeCommitOid} onSelectedCommitChange={selectCodeCommit} pendingFinding={handoff} + fixFindingLabel={handoffLabels.fixFinding} onFixFinding={startFixFinding} onRefresh={refreshDetail} refreshToken={refreshToken} @@ -1381,12 +1856,21 @@ export function PullRequestDetailPanel({ - {confirmAction === "merge" ? "Merge pull request?" : "Close pull request?"} + {confirmAction === "merge" + ? "Merge pull request?" + : confirmAction === "enable-auto-merge" + ? "Enable auto-merge?" + : "Close pull request?"} {confirmAction === "merge" ? `This merges #${reference.number} using ${selectedMergeMethod}.` - : `This closes #${reference.number} without merging it.`} + : confirmAction === "enable-auto-merge" + ? // The host merges this as soon as it considers the pull request ready, which + // may be immediately — there is no telling from here whether anything is + // still outstanding. + `This merges #${reference.number} using ${selectedMergeMethod} as soon as the host considers it ready, which may be immediately.` + : `This closes #${reference.number} without merging it.`} @@ -1401,10 +1885,16 @@ export function PullRequestDetailPanel({ const action = confirmAction; setConfirmAction(null); if (action === "merge") void perform("merge", selectedMergeMethod); + if (action === "enable-auto-merge") + void perform("enable-auto-merge", selectedMergeMethod); if (action === "close") void perform("close"); }} > - {confirmAction === "merge" ? "Merge" : "Close"} + {confirmAction === "merge" + ? "Merge" + : confirmAction === "enable-auto-merge" + ? "Enable auto-merge" + : "Close"} diff --git a/apps/web/src/components/pullRequest/PullRequestListFilters.test.tsx b/apps/web/src/components/pullRequest/PullRequestListFilters.test.tsx index 545e3066f81d..f8224f70ae09 100644 --- a/apps/web/src/components/pullRequest/PullRequestListFilters.test.tsx +++ b/apps/web/src/components/pullRequest/PullRequestListFilters.test.tsx @@ -1,4 +1,4 @@ -import type { ProjectId } from "@t3tools/contracts"; +import type { EnvironmentId, ProjectId } from "@t3tools/contracts"; import { CircleIcon } from "lucide-react"; import { Children, isValidElement, type ReactElement, type ReactNode } from "react"; import { describe, expect, it, vi } from "vite-plus/test"; @@ -53,12 +53,17 @@ function menu(overrides: Partial[0]>) involvement: "all", involvementOptions: [{ value: "all", label: "All", Icon: CircleIcon }], onInvolvement: () => undefined, + filters: {}, + onFilters: () => undefined, host: undefined, hostOptions: [], onHost: () => undefined, - environmentId: null, + server: undefined, + serverOptions: [], + onServer: () => undefined, projects: [], projectId: undefined, + projectEnvironmentId: undefined, unavailable: new Map(), onProject: () => undefined, ...overrides, @@ -79,21 +84,82 @@ describe("pull request filters menu", () => { expect(onState).toHaveBeenCalledWith("closed"); }); + it("names the chosen narrowing and leaves the others alone", () => { + const onFilters = vi.fn(); + const group = findValueChange( + findLabeledGroup(menu({ filters: { review: "approved" }, onFilters }), "Draft"), + ); + expect(group).toBeDefined(); + + group?.props.onValueChange("hide"); + expect(onFilters).toHaveBeenCalledWith({ review: "approved", draft: "hide" }); + }); + + it("drops a narrowing chosen back to all rather than sending it as undefined", () => { + const onFilters = vi.fn(); + const group = findValueChange( + findLabeledGroup( + menu({ filters: { review: "none", checks: "failing" }, onFilters }), + "Review", + ), + ); + expect(group).toBeDefined(); + + group?.props.onValueChange("all"); + expect(onFilters).toHaveBeenCalledWith({ checks: "failing" }); + }); + it("does not emit a change when the selected project is chosen again", () => { const projectId = "project-1" as ProjectId; + const environmentId = "env-1" as EnvironmentId; const onProject = vi.fn(); const view = menu({ - projects: [{ id: projectId, title: "T3 Code", workspaceRoot: "/work/t3code" }], + projects: [ + { + id: projectId, + environmentId, + title: "T3 Code", + workspaceRoot: "/work/t3code", + }, + ], projectId, + projectEnvironmentId: environmentId, onProject, }); const radioGroup = findValueChange(view); expect(radioGroup).toBeDefined(); - radioGroup?.props.onValueChange(projectId); + radioGroup?.props.onValueChange(`${environmentId} ${projectId}`); expect(onProject).not.toHaveBeenCalled(); radioGroup?.props.onValueChange("all"); - expect(onProject).toHaveBeenCalledWith(undefined); + expect(onProject).toHaveBeenCalledWith(undefined, undefined); + }); + + it("passes the environment along so a duplicate project id on another server is told apart", () => { + const projectId = "project-1" as ProjectId; + const onProject = vi.fn(); + const view = menu({ + projects: [ + { + id: projectId, + environmentId: "env-1" as EnvironmentId, + title: "T3 Code · one", + workspaceRoot: "/work/t3code-1", + }, + { + id: projectId, + environmentId: "env-2" as EnvironmentId, + title: "T3 Code · two", + workspaceRoot: "/work/t3code-2", + }, + ], + onProject, + }); + const radioGroup = findValueChange(view); + expect(radioGroup).toBeDefined(); + + radioGroup?.props.onValueChange(`env-2 ${projectId}`); + expect(onProject).toHaveBeenCalledWith(projectId, "env-2"); }); }); diff --git a/apps/web/src/components/pullRequest/PullRequestListFilters.tsx b/apps/web/src/components/pullRequest/PullRequestListFilters.tsx index cb95a5be35b9..71d7c65700df 100644 --- a/apps/web/src/components/pullRequest/PullRequestListFilters.tsx +++ b/apps/web/src/components/pullRequest/PullRequestListFilters.tsx @@ -2,10 +2,23 @@ import type { EnvironmentId, ProjectId, PullRequestInvolvement, + PullRequestListFilters, PullRequestListState, SourceControlProviderKind, } from "@t3tools/contracts"; -import { FolderGit2Icon, LayersIcon, ListFilterIcon, LoaderIcon, SearchIcon } from "lucide-react"; +import { + CircleCheckIcon, + CircleDashedIcon, + CircleSlashIcon, + CircleXIcon, + EyeOffIcon, + FolderGit2Icon, + GitPullRequestDraftIcon, + LayersIcon, + ListFilterIcon, + LoaderIcon, + SearchIcon, +} from "lucide-react"; import type { ElementType } from "react"; import { cn } from "~/lib/utils"; @@ -82,7 +95,7 @@ export function PullRequestSearchInput({ type="text" value={value} onChange={(event) => onChange(event.currentTarget.value)} - placeholder="Search pull requests" + placeholder="Search pull requests, or label:bug" aria-label="Search pull requests" // Tracks the shared input's height at both widths, so it stays level with the icon // button beside it rather than towering over it on wide screens. @@ -101,6 +114,38 @@ export function PullRequestSearchInput({ const ALL_PROJECTS_VALUE = "all"; /** MenuRadioGroup wants a string, so "every host" wears the one value no host can be. */ const ALL_HOSTS_VALUE = ""; +/** The same trick for the servers, which are named by an id no empty string can collide with. */ +const ALL_SERVERS_VALUE = ""; +/** The unset value of each narrowing group, which no filter of theirs is named after. */ +const UNFILTERED_VALUE = "all"; +/** + * A project's own radio value, carrying the server along with the id: the id alone is only + * unique within its own server, so two rows sharing one would otherwise both read as checked. + */ +const projectMenuValue = (project: { + readonly id: ProjectId; + readonly environmentId: EnvironmentId; +}) => `${project.environmentId} ${project.id}`; + +const DRAFT_OPTIONS = [ + { value: UNFILTERED_VALUE, label: "All", Icon: LayersIcon }, + { value: "only", label: "Drafts only", Icon: GitPullRequestDraftIcon }, + { value: "hide", label: "Hide drafts", Icon: EyeOffIcon }, +] as const satisfies ReadonlyArray>; + +const REVIEW_OPTIONS = [ + { value: UNFILTERED_VALUE, label: "All", Icon: LayersIcon }, + { value: "approved", label: "Approved", Icon: CircleCheckIcon }, + { value: "changes-requested", label: "Changes requested", Icon: CircleXIcon }, + { value: "review-required", label: "Review required", Icon: CircleDashedIcon }, + { value: "none", label: "No reviews", Icon: CircleSlashIcon }, +] as const satisfies ReadonlyArray>; + +const CHECKS_OPTIONS = [ + { value: UNFILTERED_VALUE, label: "All", Icon: LayersIcon }, + { value: "passing", label: "Passing", Icon: CircleCheckIcon }, + { value: "failing", label: "Failing", Icon: CircleXIcon }, +] as const satisfies ReadonlyArray>; function PullRequestFilterRadioGroup({ label, @@ -147,12 +192,17 @@ export function PullRequestFiltersMenu({ involvement, involvementOptions, onInvolvement, + filters, + onFilters, host, hostOptions, onHost, - environmentId, + server, + serverOptions, + onServer, projects, projectId, + projectEnvironmentId, unavailable, onProject, }: { @@ -162,6 +212,9 @@ export function PullRequestFiltersMenu({ involvement: PullRequestInvolvement; involvementOptions: ReadonlyArray>; onInvolvement: (involvement: PullRequestInvolvement) => void; + /** The narrowings beyond state and involvement; an absent field is that group unfiltered. */ + filters: PullRequestListFilters; + onFilters: (filters: PullRequestListFilters) => void; host: string | undefined; /** * Includes the "all hosts" entry, whose value is the empty string. With fewer than two real @@ -169,24 +222,52 @@ export function PullRequestFiltersMenu({ */ hostOptions: ReadonlyArray>; onHost: (host: string | undefined) => void; - /** Where the projects' own favicons are read from; null before the environment is known. */ - environmentId: EnvironmentId | null; + server: EnvironmentId | undefined; + /** + * Includes the "all servers" entry, whose value is the empty string. With one server there is + * nothing to switch between, so the whole group stays out of the menu. + */ + serverOptions: ReadonlyArray>; + onServer: (server: EnvironmentId | undefined) => void; + /** The projects of every connected environment, each carrying the one its favicon is read from. */ projects: ReadonlyArray<{ readonly id: ProjectId; + readonly environmentId: EnvironmentId; readonly title: string; readonly workspaceRoot: string; }>; projectId: ProjectId | undefined; + /** + * The server the selected project belongs to. A project id is only unique within its own + * server, so without this two rows sharing an id would both read as checked here. + */ + projectEnvironmentId: EnvironmentId | undefined; /** * Projects whose repository could not be read this time round. They are named here, where * the reader is already choosing between projects, rather than as a count above the list * that says something is missing without saying which. */ unavailable: ReadonlyMap; - onProject: (projectId: ProjectId | undefined) => void; + /** The environment comes with the project id, since picking a row picks a specific server's copy of it. */ + onProject: (projectId: ProjectId | undefined, environmentId: EnvironmentId | undefined) => void; }) { const filtered = - state !== "open" || involvement !== "all" || host !== undefined || projectId !== undefined; + state !== "open" || + involvement !== "all" || + host !== undefined || + server !== undefined || + projectId !== undefined || + Object.keys(filters).length > 0; + /** + * Rebuilt rather than spread so an unfiltered group leaves the record instead of lingering in + * it as an explicit `undefined`, which the listing input does not accept. + */ + const withFilter = (key: keyof PullRequestListFilters, value: string): PullRequestListFilters => + Object.fromEntries( + Object.entries({ ...filters, [key]: value === UNFILTERED_VALUE ? undefined : value }).filter( + ([, held]) => held !== undefined, + ), + ) as PullRequestListFilters; return ( + + onFilters(withFilter("draft", next))} + /> + + onFilters(withFilter("review", next))} + /> + + onFilters(withFilter("checks", next))} + /> {hostOptions.length > 2 ? ( <> @@ -230,12 +332,40 @@ export function PullRequestFiltersMenu({ /> ) : null} + {serverOptions.length > 2 ? ( + <> + + + onServer(next === ALL_SERVERS_VALUE ? undefined : (next as EnvironmentId)) + } + /> + + ) : null} { - const nextProjectId = next === ALL_PROJECTS_VALUE ? undefined : (next as ProjectId); - if (nextProjectId !== projectId) onProject(nextProjectId); + if (next === ALL_PROJECTS_VALUE) { + if (projectId !== undefined) onProject(undefined, undefined); + return; + } + // The value carries both halves, since the id alone cannot tell two servers' rows + // apart once they share one. + const project = projects.find((candidate) => projectMenuValue(candidate) === next); + if ( + project !== undefined && + (project.id !== projectId || project.environmentId !== projectEnvironmentId) + ) { + onProject(project.id, project.environmentId); + } }} > Project @@ -255,22 +385,18 @@ export function PullRequestFiltersMenu({ const reason = unavailable.get(project.id); return ( - {environmentId === null ? ( - - ) : ( - - )} + {project.title} {reason === undefined ? null : ( diff --git a/apps/web/src/components/pullRequest/PullRequestMarkdownEditor.tsx b/apps/web/src/components/pullRequest/PullRequestMarkdownEditor.tsx new file mode 100644 index 000000000000..f0145c059c0d --- /dev/null +++ b/apps/web/src/components/pullRequest/PullRequestMarkdownEditor.tsx @@ -0,0 +1,113 @@ +import { useState } from "react"; + +import { cn } from "~/lib/utils"; + +import { Button } from "../ui/button"; +import { Textarea } from "../ui/textarea"; +import { PullRequestMarkdown } from "./PullRequestMarkdown"; + +/** + * The box a body is rewritten in — a description, or a remark already posted. It owns the draft + * and nothing else: the caller sends the request and says whether it is still in flight, so the + * same box serves every mutation without knowing which one it is. + * + * Preview renders through the same component the saved body will be read through, which is the + * only way to see what a host's markdown will actually become before it is sent. + */ +export function PullRequestMarkdownEditor({ + value, + cwd, + placeholder, + label, + saving, + allowEmpty = false, + className, + onSave, + onCancel, +}: { + readonly value: string; + readonly cwd: string; + readonly placeholder?: string | undefined; + readonly label: string; + readonly saving: boolean; + /** A description may be cleared, which is how one is removed; a remark may not be emptied. */ + readonly allowEmpty?: boolean; + readonly className?: string | undefined; + readonly onSave: (next: string) => void; + readonly onCancel: () => void; +}) { + const [draft, setDraft] = useState(value); + const [preview, setPreview] = useState(false); + // The words this draft started from. React keeps a component instance wherever the same + // position and key come round again, so an editor opened on one remark can be handed another's + // words without being rebuilt — and saving would then write the first remark's text onto the + // second. Different words mean a different subject, and the draft starts again from them. + const [seed, setSeed] = useState(value); + if (seed !== value) { + setSeed(value); + setDraft(value); + } + const empty = draft.trim().length === 0; + + return ( +
    { + if (event.key !== "Escape" || saving) return; + event.preventDefault(); + onCancel(); + }} + > +
    + + +
    + {preview ? ( +
    + {empty ? ( +

    Nothing to preview.

    + ) : ( + + )} +
    + ) : ( +