From 588d32e22b10932404856d9bdd9f2f39c7e38070 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 7 Oct 2026 11:30:29 -0700 Subject: [PATCH 01/25] feat(models): add Claude Haiku 5.5 and retire Sonnet 5 and Opus 5 to legacy (#16903) Co-authored-by: Claude Opus 5.5 (1M context) --- apps/server/src/provider/model-manifest.json | 63 +++++++++++++++++--- 1 file changed, 54 insertions(+), 9 deletions(-) diff --git a/apps/server/src/provider/model-manifest.json b/apps/server/src/provider/model-manifest.json index 5e63f842ba30..da2ef052344d 100644 --- a/apps/server/src/provider/model-manifest.json +++ b/apps/server/src/provider/model-manifest.json @@ -1,6 +1,6 @@ { "version": 1, - "updatedAt": "2026-10-02T19:45:00Z", + "updatedAt": "2026-10-07T18:00:00Z", "compatibility": [ { "driver": "codex", @@ -96,7 +96,7 @@ "gpt-daybreak-blue-latest", "gpt-daybreak-red-latest" ], - "claudeAgent": ["claude-fable-5-1", "claude-opus-5-5", "claude-sonnet-5-5", "claude-sonnet-5"], + "claudeAgent": ["claude-fable-5-1", "claude-opus-5-5", "claude-sonnet-5-5", "claude-haiku-5-5"], "antigravity": ["gemini-3.8-flash-high", "gemini-3.8-flash-medium", "gemini-3.8-flash-low"] }, "providers": { @@ -666,6 +666,42 @@ } } }, + "haiku-5-5": { + "capabilities": { + "optionDescriptors": [ + { + "id": "effort", + "label": "Reasoning", + "type": "select", + "options": [ + { "id": "low", "label": "Low" }, + { "id": "medium", "label": "Medium", "isDefault": true }, + { "id": "high", "label": "High" }, + { "id": "xhigh", "label": "Extra High" }, + { "id": "max", "label": "Max" }, + { "id": "ultrathink", "label": "Ultrathink" } + ], + "promptInjectedValues": ["ultrathink"] + }, + { + "id": "contextWindow", + "label": "Context Window", + "type": "select", + "options": [ + { "id": "200k", "label": "200k" }, + { "id": "1m", "label": "1M", "isDefault": true } + ] + } + ] + }, + "adapter": { + "claudeCode": { + "effortMap": { "ultrathink": null }, + "modelSuffixes": { "contextWindow": { "1m": "[1m]" } }, + "contextWindowTokens": { "200k": 200000, "1m": 1000000 } + } + } + }, "haiku-4-5": { "capabilities": { "optionDescriptors": [ @@ -685,7 +721,7 @@ { "slug": "claude-opus-5-5", "name": "Claude Opus 5.5", - "aliases": ["opus-5.5", "claude-opus-5.5"], + "aliases": ["opus", "opus-5.5", "claude-opus-5.5"], "status": "current", "badge": "new", "profile": "opus-5-5", @@ -694,7 +730,7 @@ { "slug": "claude-sonnet-5-5", "name": "Claude Sonnet 5.5", - "aliases": ["sonnet-5.5", "claude-sonnet-5.5"], + "aliases": ["sonnet", "sonnet-5.5", "claude-sonnet-5.5"], "status": "current", "badge": "new", "profile": "sonnet-5", @@ -726,8 +762,8 @@ { "slug": "claude-opus-5", "name": "Claude Opus 5", - "aliases": ["opus", "opus-5", "claude-opus-5.0", "claude-opus-5-0"], - "status": "current", + "aliases": ["opus-5", "claude-opus-5.0", "claude-opus-5-0"], + "status": "legacy", "profile": "opus-5", "adapter": { "claudeCode": { @@ -775,8 +811,8 @@ { "slug": "claude-sonnet-5", "name": "Claude Sonnet 5", - "aliases": ["sonnet", "sonnet-5", "claude-sonnet-5.0", "claude-sonnet-5-0"], - "status": "current", + "aliases": ["sonnet-5", "claude-sonnet-5.0", "claude-sonnet-5-0"], + "status": "legacy", "profile": "sonnet-5" }, { @@ -786,10 +822,19 @@ "status": "legacy", "profile": "sonnet-4-6" }, + { + "slug": "claude-haiku-5-5", + "name": "Claude Haiku 5.5", + "aliases": ["haiku", "haiku-5.5", "claude-haiku-5.5"], + "status": "current", + "badge": "new", + "profile": "haiku-5-5", + "adapter": { "claudeCode": { "minVersion": "2.1.293" } } + }, { "slug": "claude-haiku-4-5", "name": "Claude Haiku 4.5", - "aliases": ["haiku", "haiku-4.5", "claude-haiku-4.5", "claude-haiku-4-5-20251001"], + "aliases": ["haiku-4.5", "claude-haiku-4.5", "claude-haiku-4-5-20251001"], "status": "legacy", "profile": "haiku-4-5" } From ba0ea3d152150f399827d36a5b4b86c5bbda2d7f Mon Sep 17 00:00:00 2001 From: Gabriel De Andrade <30420087+gabrielelpidio@users.noreply.github.com> Date: Wed, 7 Oct 2026 14:36:18 -0400 Subject: [PATCH 02/25] fix(web): iPhone Duo folds animate, center on the hinge, and keep the phone's orientation (#16885) Co-authored-by: Claude Opus 5.5 (1M context) --- .../components/device/DeviceDuoControls.tsx | 33 +++- .../components/device/DeviceDuoViewport.tsx | 2 +- .../src/device/duoControl.test.ts | 43 ++++- .../client-runtime/src/device/duoControl.ts | 25 +++ .../src/device/duoViewer.test.ts | 148 +++++++++++++++++- .../client-runtime/src/device/duoViewer.ts | 66 ++++++-- 6 files changed, 289 insertions(+), 28 deletions(-) diff --git a/apps/web/src/components/device/DeviceDuoControls.tsx b/apps/web/src/components/device/DeviceDuoControls.tsx index 57922a0243d3..824af5a644fc 100644 --- a/apps/web/src/components/device/DeviceDuoControls.tsx +++ b/apps/web/src/components/device/DeviceDuoControls.tsx @@ -1,5 +1,7 @@ +import { useState } from "react"; import { duoFoldState, + duoHoldOrientation, type DuoCommand, type DuoControlState, } from "@t3tools/client-runtime/device/duo-control"; @@ -31,7 +33,18 @@ export function DeviceDuoControls(props: { onCommand: (command: DuoCommand) => void; }) { const { screen } = props; - const { fold, stand, phoneVertical } = duoFoldState(screen); + const { fold, stand, phoneVertical: reportedVertical, settled } = duoFoldState(screen); + // A fold never changes how the phone is held. Keep the last settled reading + // through display handoffs, whose interim orientation belongs to the other display. + const [settledVertical, setSettledVertical] = useState(reportedVertical); + if (settled && settledVertical !== reportedVertical) setSettledVertical(reportedVertical); + // Stands rotate the device. Folding out of one returns it to how it was held before. + const [standVertical, setStandVertical] = useState(settledVertical); + const phoneVertical = stand ? standVertical : settledVertical; + // Folding out of a stand sends the rotation back, then the fold, and only one command can + // wait. Hold the fold buttons until a stand, or anything queued on one, has landed; the + // rotation must also be read in the frame of the display the stand settles on. + const foldWaits = props.state.pending && (stand || props.state.requested?.control === "pose"); const foldLabels = { closed: "Closed", half: phoneVertical ? "Book" : "Laptop", @@ -43,6 +56,7 @@ export function DeviceDuoControls(props: { pressed: boolean, onClick: () => void, glyph: React.ReactNode, + waits = false, ) => ( props.onCommand({ control: "angle", value }), + () => { + if (stand) + props.onCommand({ + control: "orientation", + value: duoHoldOrientation(standVertical, screen.screenId), + }); + props.onCommand({ control: "angle", value }); + }, , + foldWaits, ), )} @@ -84,7 +106,10 @@ export function DeviceDuoControls(props: { id, label, screen.hingePose === id, - () => props.onCommand({ control: "pose", value: id }), + () => { + if (!stand) setStandVertical(settledVertical); + props.onCommand({ control: "pose", value: id }); + }, , ), )} diff --git a/apps/web/src/components/device/DeviceDuoViewport.tsx b/apps/web/src/components/device/DeviceDuoViewport.tsx index f168ceccc30d..125ff9a03cfc 100644 --- a/apps/web/src/components/device/DeviceDuoViewport.tsx +++ b/apps/web/src/components/device/DeviceDuoViewport.tsx @@ -127,7 +127,7 @@ export function DeviceDuoViewport(props: { (screenRef.current?.screenId === 1 ? 0 : 180), contains: (x, y) => !!screenRef.current && viewer.beginHinge(x, y), change: (angle) => { - viewer.setHingePreview(angle ?? previewRef.current); + viewer.setHingePreview(angle ?? previewRef.current, true); if (angle !== null) client.current?.controlDuo({ control: "angle", value: angle }); }, }); diff --git a/packages/client-runtime/src/device/duoControl.test.ts b/packages/client-runtime/src/device/duoControl.test.ts index f325d3a25de7..2755e1975939 100644 --- a/packages/client-runtime/src/device/duoControl.test.ts +++ b/packages/client-runtime/src/device/duoControl.test.ts @@ -1,5 +1,11 @@ import { afterEach, expect, it, vi } from "vite-plus/test"; -import { createDuoControl, createDuoPinch, duoFoldState, type DuoCommand } from "./duoControl.ts"; +import { + createDuoControl, + createDuoPinch, + duoFoldState, + duoHoldOrientation, + type DuoCommand, +} from "./duoControl.ts"; afterEach(() => vi.useRealTimers()); it("keeps a failed send visible, including a disconnect while draining queued motion", () => { @@ -136,13 +142,27 @@ it.each([ ], ["half-open book", { screenId: 3, orientation: "landscape_left", hingeAngle: 90 }, "half", true], ] as const)("reads a %s", (_name, screen, fold, phoneVertical) => { - expect(duoFoldState(screen)).toEqual({ fold, stand: false, phoneVertical }); + expect(duoFoldState(screen)).toEqual({ fold, stand: false, phoneVertical, settled: true }); +}); + +it("marks a display handoff unsettled while the cover reports the inner display's orientation", () => { + // Recorded from the iPhone Duo simulator opening a closed vertical phone to a book. + const handoff = [ + { screenId: 1, orientation: "portrait", hingeAngle: 90 }, + { screenId: 1, orientation: "landscape_left", hingeAngle: 90 }, + { screenId: 3, orientation: "landscape_left", hingeAngle: 90 }, + ] as const; + expect(handoff.map((screen) => duoFoldState(screen).settled)).toEqual([false, false, true]); + // Closing hands back to the cover the same way. + expect(duoFoldState({ screenId: 3, orientation: "landscape_left", hingeAngle: 0 }).settled).toBe( + false, + ); }); it("marks native stands so the fold group does not also claim them", () => { expect( duoFoldState({ screenId: 3, orientation: "portrait", hingeAngle: 90, hingePose: "laptop" }), - ).toEqual({ fold: "half", stand: true, phoneVertical: false }); + ).toEqual({ fold: "half", stand: true, phoneVertical: false, settled: true }); }); it("falls back like the 3D view when hinge fields are missing", () => { @@ -156,3 +176,20 @@ it("falls back like the 3D view when hinge fields are missing", () => { phoneVertical: true, }); }); + +it("rotates a stand back to how the phone was held, in the frame of the display receiving it", () => { + // Recorded from the iPhone Duo simulator: Tent can rest on either display. + // Rotating the inner display to landscape_left, or the cover to portrait, + // leaves a phone that opens and closes vertical. + expect(duoHoldOrientation(true, 3)).toBe("landscape_left"); + expect(duoHoldOrientation(true, 1)).toBe("portrait"); + // A quarter turn apart, these describe the same horizontal phone. + expect(duoHoldOrientation(false, 1)).toBe("landscape_left"); + expect(duoHoldOrientation(false, 3)).toBe("portrait_upside_down"); + for (const screenId of [1, 3]) + for (const vertical of [true, false]) + expect( + duoFoldState({ screenId, orientation: duoHoldOrientation(vertical, screenId) }) + .phoneVertical, + ).toBe(vertical); +}); diff --git a/packages/client-runtime/src/device/duoControl.ts b/packages/client-runtime/src/device/duoControl.ts index e5be353ca625..3a4026b97650 100644 --- a/packages/client-runtime/src/device/duoControl.ts +++ b/packages/client-runtime/src/device/duoControl.ts @@ -20,6 +20,17 @@ export type DuoControlState = { error: string | null; }; +/** + * Whether the reporting display is the one the fold shows: the cover when closed, the inner + * display when open. A fold across the closed position hands off in steps, and the outgoing + * display first reports the incoming one's orientation, so an unsettled screen's orientation + * does not say how the phone is held. Stands such as Tent can also rest unsettled on the cover. + */ +export function duoScreenSettled(screen: Pick) { + const angle = screen.hingeAngle ?? (screen.screenId === 1 ? 0 : 180); + return (screen.screenId === 1) === (angle === 0); +} + /** * The fold the device is in and the way it is held. Missing hinge fields fall back the same way * the 3D view does, so controls never disagree with what is drawn. The inner panel is mounted a @@ -34,9 +45,23 @@ export function duoFoldState( fold: angle === 0 ? "closed" : angle === 180 ? "open" : "half", stand: screen.hingePose === "laptop" || screen.hingePose === "tent", phoneVertical: screen.screenId === 1 ? !landscape : landscape, + settled: duoScreenSettled(screen), } as const; } +/** + * The rotation that holds the phone vertical or horizontal. Native rotation is + * read in the frame of the display active when it arrives, and the inner panel + * is mounted a quarter turn from the cover. + */ +export function duoHoldOrientation( + vertical: boolean, + screenId: number | undefined, +): DuoOrientation { + if (screenId === 1) return vertical ? "portrait" : "landscape_left"; + return vertical ? "landscape_left" : "portrait_upside_down"; +} + /** One in-flight native transaction. Hinge motion coalesces; presets replace queued motion. Nothing replays after reconnect. */ export function createDuoControl(options: { send: (request: { requestId: number; command: DuoCommand }) => boolean; diff --git a/packages/client-runtime/src/device/duoViewer.test.ts b/packages/client-runtime/src/device/duoViewer.test.ts index b85d306bcfd6..abe3da650650 100644 --- a/packages/client-runtime/src/device/duoViewer.test.ts +++ b/packages/client-runtime/src/device/duoViewer.test.ts @@ -331,6 +331,108 @@ it("faces a display handed off by native rotation without requesting another sen viewer.dispose(); }); +// Recorded from the iPhone Duo simulator: a fold button across the closed +// position lets the cover report the inner display's orientation before handing +// off. A button's pending angle reaches the viewer as a hinge preview first. +it("presents fold buttons centered on the hinge: book, tablet, and an upright closed phone", async () => { + // Configurations arrive while the view is still easing; time only advances to settle. + let now = 0; + vi.stubGlobal("performance", { now: () => now }); + const { viewer, draw, state, onOrientationRequested } = fixture(false); + const loaded = asset(); + models.resolve({ asset: loaded, dispose: vi.fn() }); + await Promise.resolve(); + viewer.resize(500, 700, 2); + const cover = { width: 1398, height: 2034, screenId: 1, hingePose: null } as const; + const inner = { width: 2007, height: 2853, screenId: 3, hingePose: null } as const; + const press = ( + angle: number, + configs: ReadonlyArray<{ orientation: "portrait" | "landscape_left"; screenId: 1 | 3 }>, + ) => { + viewer.setHingePreview(angle); + configs.forEach((config, index) => { + viewer.setScreen({ + ...(config.screenId === 1 ? cover : inner), + ...config, + hingeAngle: angle, + }); + // The command reply follows the first configuration. + if (index === 0) viewer.setHingePreview(null); + draw(); + }); + now += 5_000; + draw(); + draw(); + }; + const expectInnerFacing = (minimum: number) => { + for (const name of ["inner-display-left", "inner-display-right"]) { + const mesh = loaded.getObjectByName(name) as Mesh; + const normal = new Vector3() + .fromBufferAttribute(mesh.geometry.getAttribute("normal"), 0) + .transformDirection(mesh.matrixWorld); + expect(normal.z).toBeGreaterThan(minimum); + } + }; + const upright = new Rotation().setFromEuler(new Euler(0, Math.PI / 2, 0, "YXZ")); + const closeAndExpectUpright = () => { + press(0, [ + { screenId: 3, orientation: "landscape_left" }, + { screenId: 1, orientation: "portrait" }, + ]); + expect(state.views.at(-1)!.quaternion.angleTo(upright)).toBeLessThan(0.00001); + }; + viewer.setScreen({ ...inner, orientation: "landscape_left", hingeAngle: 180 }); + draw(); + closeAndExpectUpright(); + press(90, [ + { screenId: 1, orientation: "portrait" }, + { screenId: 1, orientation: "landscape_left" }, + { screenId: 3, orientation: "landscape_left" }, + // Election can flap back to the cover once before settling. + { screenId: 1, orientation: "landscape_left" }, + { screenId: 3, orientation: "landscape_left" }, + ]); + expectInnerFacing(0.45); + // Opening the book flat faces the whole tablet; folding back centers the book again. + press(180, [{ screenId: 3, orientation: "landscape_left" }]); + expectInnerFacing(0.99); + press(90, [{ screenId: 3, orientation: "landscape_left" }]); + expectInnerFacing(0.45); + closeAndExpectUpright(); + expect(onOrientationRequested).not.toHaveBeenCalled(); + viewer.dispose(); +}); + +it("eases a fold button's hinge while a pinch follows the fingers", async () => { + let now = 0; + vi.stubGlobal("performance", { now: () => now }); + const { viewer, draw } = fixture(false); + const loaded = asset(); + models.resolve({ asset: loaded, dispose: vi.fn() }); + await Promise.resolve(); + viewer.resize(500, 700, 2); + viewer.setScreen({ + width: 2007, + height: 2853, + orientation: "landscape_left", + screenId: 3, + hingeAngle: 180, + }); + draw(); + const leaf = loaded.getObjectByName("left-half")!; + const open = ((180 - 90) * Math.PI) / 360; + viewer.setHingePreview(90); + draw(); + expect(leaf.rotation.y).toBeGreaterThan(0); + expect(leaf.rotation.y).toBeLessThan(open * 0.9); + now += 1_000; + draw(); + expect(leaf.rotation.y).toBeCloseTo(open); + viewer.setHingePreview(30, true); + expect(leaf.rotation.y).toBeCloseTo(((180 - 30) * Math.PI) / 360); + viewer.dispose(); +}); + it.each(["book", "laptop", "open"] as const)( "faces native cover readback for %s even when configuration arrives before the model", async (hingePose) => { @@ -402,7 +504,7 @@ it("keeps default-zoom orbits framed across folds and fits the current assembly" viewer.dispose(); }); -it("previews hinge articulation while preserving the laptop lid orientation and blocking unconfirmed input", async () => { +it("pinches the hinge while preserving the laptop lid orientation and blocking unconfirmed input", async () => { const { viewer, draw, pending } = fixture(); const loaded = asset(); models.resolve({ asset: loaded, dispose: vi.fn() }); @@ -420,7 +522,7 @@ it("previews hinge articulation while preserving the laptop lid orientation and const lid = loaded.getObjectByName("right-half")!; const presentation = lid.getWorldQuaternion(new Rotation()); for (const value of [127, 50, 170]) { - viewer.setHingePreview(value); + viewer.setHingePreview(value, true); draw(); expect(loaded.getObjectByName("left-half")!.rotation.y).toBeCloseTo( ((180 - value) * Math.PI) / 360, @@ -438,7 +540,7 @@ it("previews hinge articulation while preserving the laptop lid orientation and draw(); expect(lid.getWorldQuaternion(new Rotation()).angleTo(presentation)).toBeLessThan(0.00001); } - viewer.setHingePreview(null); + viewer.setHingePreview(null, true); draw(); expect(pending.size).toBe(0); expect(lid.getWorldQuaternion(new Rotation()).angleTo(presentation)).toBeLessThan(0.00001); @@ -480,6 +582,42 @@ it("does not restart an animated preset on duplicate native configurations", asy viewer.dispose(); }); +it.each([true, false])( + "stands Tent on both edges when the cover reads it back (configuration before model: %s)", + async (configurationFirst) => { + // Recorded from the iPhone Duo simulator: Tent can rest with the cover active. + const { viewer, draw } = fixture(); + viewer.resize(500, 700, 2); + const tent = { + width: 1398, + height: 2034, + orientation: "portrait", + screenId: 1, + hingeAngle: 80, + hingePose: "tent", + } as const; + if (configurationFirst) viewer.setScreen(tent); + const loaded = asset(); + models.resolve({ asset: loaded, dispose: vi.fn() }); + await Promise.resolve(); + if (!configurationFirst) viewer.setScreen(tent); + draw(); + draw(); + // Each leaf runs from the hinge down to the surface it rests on. + for (const [name, outward] of [ + ["left-half", -1], + ["right-half", 1], + ] as const) { + const leaf = loaded.getObjectByName(name)!; + const down = new Vector3(outward, 0, 0).applyQuaternion( + leaf.getWorldQuaternion(new Rotation()), + ); + expect(down.y).toBeLessThan(-0.4); + } + viewer.dispose(); + }, +); + it("lets standalone rotation leave Laptop and Tent and stand a closed device upright after hinge edits", async () => { const { viewer, draw, state } = fixture(); models.resolve({ asset: asset(), dispose: vi.fn() }); @@ -672,7 +810,7 @@ it.each(["left", "right"] as const)( const partner = loaded.getObjectByName("left-half")!; const partnerStart = partner.getWorldQuaternion(new Rotation()); for (const value of [150, 90, 30, 0, 30, 90, 180]) { - viewer.setHingePreview(value); + viewer.setHingePreview(value, true); draw(); expect(leaf.getWorldQuaternion(new Rotation()).angleTo(orientation)).toBeLessThan(1e-6); if (value === 90) @@ -682,7 +820,7 @@ it.each(["left", "right"] as const)( ? { ...inner, width: 1398, height: 2034, screenId: 1, hingeAngle: value, hingePose: null } : { ...inner, hingeAngle: value, hingePose: null }; viewer.setScreen(next); - viewer.setHingePreview(null); + viewer.setHingePreview(null, true); draw(); viewer.setScreen({ ...next }); // Late native readback cannot change the view. draw(); diff --git a/packages/client-runtime/src/device/duoViewer.ts b/packages/client-runtime/src/device/duoViewer.ts index 7feca7452e0d..6e4b443da317 100644 --- a/packages/client-runtime/src/device/duoViewer.ts +++ b/packages/client-runtime/src/device/duoViewer.ts @@ -29,11 +29,12 @@ import { createDeviceMotion } from "./deviceMotion.ts"; import { duoViewSnaps, nearestDuoView, type DuoRestFace } from "./duoSnap.ts"; import { createRenderScheduler } from "./renderScheduler.ts"; import type { DeviceScreenSize } from "./stream.ts"; -import type { DuoPose } from "./duoControl.ts"; +import { duoScreenSettled, type DuoPose } from "./duoControl.ts"; export interface DuoViewer { readonly setScreen: (screen: DeviceScreenSize | null) => void; - readonly setHingePreview: (angle: number | null) => void; + /** A pinch holds the leaf under it and follows the fingers; a fold button eases the hinge and re-presents the device. */ + readonly setHingePreview: (angle: number | null, pinch?: boolean) => void; readonly setInteractionActive: (active: boolean, mode?: "touch" | "orbit") => void; readonly rejectOrientation: () => void; readonly frameUpdated: (panel: DuoPanelId, primary?: HTMLCanvasElement) => void; @@ -186,6 +187,7 @@ export function createDuoViewer(options: { const framing = createDeviceFraming(); const framingBounds = new Box3(); let firstPose = true; + let foldPending = false; let physicalPose: DuoPose = "open"; let presentationAngle = 180; let targetPresentation = new Quaternion(); @@ -291,7 +293,7 @@ export function createDuoViewer(options: { if (!model || disposed) return; scene.add(model.root); applyPose(); - if (screen?.screenId === 1) { + if (screen?.screenId === 1 && physicalPose !== "tent") { const snap = nearestDuoView(orbit.rotation, activeSnaps()); if (snap) { restFace = snap.face; @@ -323,13 +325,25 @@ export function createDuoViewer(options: { const previous = screen; const ownedHandoff = requestedPanel !== null; if (!next || next.screenId === requestedPanel) clearHandoff(); - const ownedRotation = requestedOrientation !== null && next?.screenId === previous?.screenId; + const requestedRotation = requestedOrientation !== null; + const ownedRotation = requestedRotation && next?.screenId === previous?.screenId; const changedDisplay = next?.screenId !== previous?.screenId; requestedOrientation = null; screen = next; const changedPose = next?.hingePose && next.hingePose !== previous?.hingePose; const folding = hingeLeaf !== null && !changedPose; + // A hinge change no held leaf owns re-presents the device, centered on the + // hinge, once the display that shows the new fold reports. Across the closed + // position that takes steps: the outgoing display first reports the incoming + // one's orientation. + const angleBefore = previous?.hingeAngle ?? (previous?.screenId === 1 ? 0 : 180); + const angleAfter = next?.hingeAngle ?? (next?.screenId === 1 ? 0 : 180); + if (!next || folding || changedPose) foldPending = false; + else if (previous && angleBefore !== angleAfter) foldPending = true; + const landedFold = foldPending && !!next && duoScreenSettled(next) && !ownedHandoff; + if (next && duoScreenSettled(next)) foldPending = false; const rotated = + !foldPending && next?.screenId === previous?.screenId && next?.hingeAngle === previous?.hingeAngle && next?.orientation !== previous?.orientation; @@ -340,16 +354,21 @@ export function createDuoViewer(options: { !folding && !next?.hingePose && previewAngle === null; - if (firstPose || changedPose || leftPhysicalPose) { + if (firstPose || changedPose || leftPhysicalPose || landedFold) { physicalPose = next?.hingePose ?? (next?.screenId === 1 ? "closed" : "open"); presentationAngle = next?.hingeAngle ?? (next?.screenId === 1 ? 0 : 180); } // A command reply/config confirms hinge state. Display identity, never an angle heuristic, owns input. targetAngle = previewAngle ?? next?.hingeAngle ?? (next?.screenId === 1 ? 0 : 180); - // Native angle commands clear hingePose. They change articulation only; - // preserve the viewing pose, including laptop/tent and user orbit. A - // separate rotation clears the physical preset and follows panel orientation. - if (firstPose || changedPose || (rotated && !ownedRotation && !ownedHandoff && !folding)) { + // Native angle commands clear hingePose. While a leaf is held (a pinch, or a + // fold on a stand) they change articulation only and preserve the viewing + // pose. A separate rotation clears the physical preset and follows panel orientation. + if ( + firstPose || + changedPose || + landedFold || + (rotated && !ownedRotation && !ownedHandoff && !folding) + ) { hingeLeaf = null; viewOrientation = next?.orientation ?? null; const poseAngle = presentationAngle; @@ -371,9 +390,12 @@ export function createDuoViewer(options: { targetPresentation.premultiply( new Quaternion().setFromAxisAngle(new Vector3(0, 0, 1), roll), ); - if (next?.screenId === 1) { + if (next?.screenId === 1 && !landedFold && physicalPose !== "tent") { // Native readback can retain the cover even for an open physical preset. // A screen-facing rest must use that display's actual hinged normal. + // A landed fold is shut, so its pose already faces the cover; the + // hinge is still moving and its current normal would be stale. + // Tent stands on both edges with the cover outward; facing it flat would tip it over. const snap = nearestDuoView(targetPresentation, activeSnaps()); if (snap) targetPresentation.copy(snap.rotation); } @@ -381,9 +403,18 @@ export function createDuoViewer(options: { restFace = next?.screenId === 1 ? "cover" : physicalPose === "laptop" ? "right" : "inside"; orbit.setPose(targetPresentation, performance.now(), firstPose); firstPose = false; - } else if (next && changedDisplay && !ownedHandoff && !folding) { + } else if ( + next && + changedDisplay && + !ownedHandoff && + !folding && + (restFace === "cover") !== (next.screenId === 1) && + (requestedRotation || duoScreenSettled(next)) + ) { // A sensor rotation can hand ownership to the other native display. // Face that display without sending another rotation and creating a feedback loop. + // Native election can briefly flap to the display a fold does not show and + // back; neither is a handoff, and the view may still be easing into place. const snap = nearestDuoView(orbit.rotation, activeSnaps()); if (snap) { restFace = snap.face; @@ -395,19 +426,24 @@ export function createDuoViewer(options: { fit(); scheduler.invalidate(); }, - setHingePreview(next) { + setHingePreview(next, pinch = false) { if (disposed || (next !== null && (!Number.isFinite(next) || next < 0 || next > 180))) return; if (!moving()) lastTime = performance.now(); previewAngle = next; - if (next !== null && !hingeLeaf && model) { + if (next !== null && !pinch) { + // A fold button holds no leaf, so the device is presented, centered on + // the hinge, once the fold lands. + hingeLeaf = null; + } else if (next !== null && !hingeLeaf && model) { clearHandoff(); requestedOrientation = null; hingeLeaf = screen?.screenId === 1 && angle > 20 ? "left" : "right"; orbit.setPose(orbit.rotation.clone(), performance.now(), true); } - orbit.hold(interactionActive || next !== null, performance.now()); + orbit.hold(interactionActive || (pinch && next !== null), performance.now()); targetAngle = next ?? screen?.hingeAngle ?? (screen?.screenId === 1 ? 0 : 180); - if (next !== null) { + // A pinch follows the fingers; anything else eases toward the target. + if (next !== null && pinch) { angle = next; applyPose(); fit(); From 9f48092ec95dd9eab78931601de95be0107a930c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 7 Oct 2026 12:09:52 -0700 Subject: [PATCH 03/25] fix(server): Claude 5-series models always run 1M context (#16908) Co-authored-by: Claude Opus 5.5 (1M context) --- apps/server/src/claudeModelOptions.test.ts | 11 +- .../Adapters/ClaudeAdapterV2.ts | 10 +- .../server/src/provider/ClaudeModelCatalog.ts | 2 +- apps/server/src/provider/model-manifest.json | 110 ++---------------- 4 files changed, 19 insertions(+), 114 deletions(-) diff --git a/apps/server/src/claudeModelOptions.test.ts b/apps/server/src/claudeModelOptions.test.ts index 005609008f33..e03ac150652a 100644 --- a/apps/server/src/claudeModelOptions.test.ts +++ b/apps/server/src/claudeModelOptions.test.ts @@ -17,15 +17,16 @@ describe("compileClaudeModelSelection", () => { it("compiles context, effort, and settings together", () => { expect( compileClaudeModelSelection( - selection("claude-fable-5", [ + selection("claude-opus-4-6", [ { id: "contextWindow", value: "1m" }, - { id: "effort", value: "ultracode" }, + { id: "effort", value: "max" }, + { id: "fastMode", value: true }, ]), ), ).toMatchObject({ - apiModelId: "claude-fable-5[1m]", - effort: "xhigh", - settings: { ultracode: true }, + apiModelId: "claude-opus-4-6[1m]", + effort: "max", + settings: { fastMode: true }, }); }); diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 26246b2038ba..1ad916ec2548 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -97,7 +97,6 @@ import { } from "../../provider/Drivers/ClaudeHome.ts"; import { BUNDLED_CLAUDE_MODEL_CATALOG, - resolveClaudeCatalogContextWindow, resolveClaudeCatalogContextWindowTokens, } from "../../provider/ClaudeModelCatalog.ts"; import { @@ -138,12 +137,9 @@ export const CLAUDE_PROVIDER = ProviderDriverKind.make("claudeAgent"); export const CLAUDE_AGENT_SDK_QUERY_PROTOCOL = "claude-agent-sdk.query" as const; function claudeContextWindow(modelSelection: ModelSelection): number | null { - if (modelSelection.model === "claude-opus-4-6" || modelSelection.model === "claude-opus-4-7") { - return 1_000_000; - } - return resolveClaudeCatalogContextWindow(BUNDLED_CLAUDE_MODEL_CATALOG, modelSelection) === "1m" - ? 1_000_000 - : 200_000; + return ( + resolveClaudeCatalogContextWindowTokens(BUNDLED_CLAUDE_MODEL_CATALOG, modelSelection) ?? null + ); } export function claudeProviderTurnTokenUsage( diff --git a/apps/server/src/provider/ClaudeModelCatalog.ts b/apps/server/src/provider/ClaudeModelCatalog.ts index 1fb80cb4a57b..e601e9dc9392 100644 --- a/apps/server/src/provider/ClaudeModelCatalog.ts +++ b/apps/server/src/provider/ClaudeModelCatalog.ts @@ -215,7 +215,7 @@ export function isClaudeCatalogUltracodeEffort(effort: string | null | undefined return effort === "ultracode"; } -export function resolveClaudeCatalogContextWindow( +function resolveClaudeCatalogContextWindow( catalog: ClaudeModelCatalog, modelSelection: ModelSelection | undefined, ): string | undefined { diff --git a/apps/server/src/provider/model-manifest.json b/apps/server/src/provider/model-manifest.json index da2ef052344d..0ac967281144 100644 --- a/apps/server/src/provider/model-manifest.json +++ b/apps/server/src/provider/model-manifest.json @@ -1,6 +1,6 @@ { "version": 1, - "updatedAt": "2026-10-07T18:00:00Z", + "updatedAt": "2026-10-07T19:00:00Z", "compatibility": [ { "driver": "codex", @@ -145,22 +145,6 @@ } ], "promptInjectedValues": ["ultrathink"] - }, - { - "id": "contextWindow", - "label": "Context Window", - "type": "select", - "options": [ - { - "id": "200k", - "label": "200k" - }, - { - "id": "1m", - "label": "1M", - "isDefault": true - } - ] } ] }, @@ -170,15 +154,7 @@ "ultracode": "xhigh", "ultrathink": null }, - "modelSuffixes": { - "contextWindow": { - "1m": "[1m]" - } - }, - "contextWindowTokens": { - "200k": 200000, - "1m": 1000000 - } + "fixedContextWindowTokens": 1000000 } } }, @@ -204,23 +180,13 @@ ], "promptInjectedValues": ["ultrathink"] }, - { "id": "fastMode", "label": "Fast Mode", "type": "boolean" }, - { - "id": "contextWindow", - "label": "Context Window", - "type": "select", - "options": [ - { "id": "200k", "label": "200k" }, - { "id": "1m", "label": "1M", "isDefault": true } - ] - } + { "id": "fastMode", "label": "Fast Mode", "type": "boolean" } ] }, "adapter": { "claudeCode": { "effortMap": { "ultracode": "xhigh", "ultrathink": null }, - "modelSuffixes": { "contextWindow": { "1m": "[1m]" } }, - "contextWindowTokens": { "200k": 200000, "1m": 1000000 } + "fixedContextWindowTokens": 1000000 } } }, @@ -269,22 +235,6 @@ "id": "fastMode", "label": "Fast Mode", "type": "boolean" - }, - { - "id": "contextWindow", - "label": "Context Window", - "type": "select", - "options": [ - { - "id": "200k", - "label": "200k" - }, - { - "id": "1m", - "label": "1M", - "isDefault": true - } - ] } ] }, @@ -294,15 +244,7 @@ "ultracode": "xhigh", "ultrathink": null }, - "modelSuffixes": { - "contextWindow": { - "1m": "[1m]" - } - }, - "contextWindowTokens": { - "200k": 200000, - "1m": 1000000 - } + "fixedContextWindowTokens": 1000000 } } }, @@ -524,7 +466,7 @@ ] }, "adapter": { - "claudeCode": {} + "claudeCode": { "fixedContextWindowTokens": 200000 } } }, "sonnet-5": { @@ -562,22 +504,6 @@ } ], "promptInjectedValues": ["ultrathink"] - }, - { - "id": "contextWindow", - "label": "Context Window", - "type": "select", - "options": [ - { - "id": "200k", - "label": "200k", - "isDefault": true - }, - { - "id": "1m", - "label": "1M" - } - ] } ] }, @@ -586,15 +512,7 @@ "effortMap": { "ultrathink": null }, - "modelSuffixes": { - "contextWindow": { - "1m": "[1m]" - } - }, - "contextWindowTokens": { - "200k": 200000, - "1m": 1000000 - } + "fixedContextWindowTokens": 1000000 } } }, @@ -682,23 +600,13 @@ { "id": "ultrathink", "label": "Ultrathink" } ], "promptInjectedValues": ["ultrathink"] - }, - { - "id": "contextWindow", - "label": "Context Window", - "type": "select", - "options": [ - { "id": "200k", "label": "200k" }, - { "id": "1m", "label": "1M", "isDefault": true } - ] } ] }, "adapter": { "claudeCode": { "effortMap": { "ultrathink": null }, - "modelSuffixes": { "contextWindow": { "1m": "[1m]" } }, - "contextWindowTokens": { "200k": 200000, "1m": 1000000 } + "fixedContextWindowTokens": 1000000 } } }, @@ -713,7 +621,7 @@ ] }, "adapter": { - "claudeCode": {} + "claudeCode": { "fixedContextWindowTokens": 200000 } } } }, From 73e87f683ae1455feb663481f1fa588f01b5784b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 7 Oct 2026 12:42:53 -0700 Subject: [PATCH 04/25] fix(desktop): setup prompts name a t3 that runs on desktop installs (#16676) Co-authored-by: Claude Opus 5.5 (1M context) --- apps/desktop/src/app/DesktopCliShim.test.ts | 181 ++++++++++++++++++ apps/desktop/src/app/DesktopCliShim.ts | 179 +++++++++++++++++ .../backend/DesktopBackendConfiguration.ts | 10 +- apps/server/src/cli/invocation.test.ts | 102 ++++++++-- apps/server/src/cli/invocation.ts | 46 ++++- docs/user/remote-access.md | 3 +- 6 files changed, 496 insertions(+), 25 deletions(-) create mode 100644 apps/desktop/src/app/DesktopCliShim.test.ts create mode 100644 apps/desktop/src/app/DesktopCliShim.ts diff --git a/apps/desktop/src/app/DesktopCliShim.test.ts b/apps/desktop/src/app/DesktopCliShim.test.ts new file mode 100644 index 000000000000..c5a1bb109e4d --- /dev/null +++ b/apps/desktop/src/app/DesktopCliShim.test.ts @@ -0,0 +1,181 @@ +// @effect-diagnostics nodeBuiltinImport:off - Runs the rendered launcher in a real shell. +import * as NodeChildProcess from "node:child_process"; +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + +import { afterEach, describe, expect, it } from "vite-plus/test"; + +import { type CliShimTarget, renderCliShim } from "./DesktopCliShim.ts"; + +const directories: Array = []; +afterEach(() => { + for (const directory of directories.splice(0)) { + NodeFS.rmSync(directory, { recursive: true, force: true }); + } +}); + +const tempRoot = () => { + const root = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-cli-shim-")); + directories.push(root); + return root; +}; + +const writeExecutable = (path: string, content: string) => { + NodeFS.mkdirSync(NodePath.dirname(path), { recursive: true }); + NodeFS.writeFileSync(path, content, { mode: 0o755 }); +}; + +/** Writes the launcher for `target` at `/bin/t3`, with `/home` as its T3 home. */ +const writeShim = (root: string, target: CliShimTarget) => { + const shim = NodePath.join(root, "bin", "t3"); + writeExecutable(shim, renderCliShim({ target, shimPath: shim, t3Home: `${root}/home` })); + return shim; +}; + +const run = (shim: string, args: ReadonlyArray, env: NodeJS.ProcessEnv = {}) => + NodeChildProcess.spawnSync(shim, args, { + encoding: "utf8", + env: { PATH: process.env.PATH, ...env }, + }); + +/** An app executable that reports its environment and arguments. */ +const REPORTER = + '#!/bin/sh\necho "node=$ELECTRON_RUN_AS_NODE cli=$T3CODE_CLI_PATH home=$T3CODE_HOME"\nprintf "%s\\n" "$@"\nexit 3\n'; + +/** + * A stand-in AppImage whose image holds the reporter. `--appimage-mount` + * prints the mount and stays up until killed, recording that it was; with + * `canMount` false it fails like a host without FUSE. `--appimage-extract` + * unpacks into ./squashfs-root. + */ +const fakeAppImage = (canMount = true) => { + const root = tempRoot(); + const image = NodePath.join(root, "image"); + writeExecutable(NodePath.join(image, "t3code"), REPORTER); + const appImage = NodePath.join(root, "T3 Code.AppImage"); + writeExecutable( + appImage, + [ + "#!/bin/sh", + `if [ "$1" = --appimage-extract ]; then cp -R '${image}' squashfs-root; exit 0; fi`, + '[ "$1" = --appimage-mount ] || exit 9', + '[ -n "$APPIMAGE_EXTRACT_AND_RUN" ] && exit 8', + canMount ? `echo '${image}'` : 'echo "Cannot mount AppImage"; exit 1', + `trap 'touch "${root}/unmounted"; exit 0' TERM`, + "while :; do sleep 0.05; done", + "", + ].join("\n"), + ); + const shim = writeShim(root, { kind: "appimage", appImage, executableName: "t3code" }); + return { root, image, appImage, shim }; +}; + +describe("renderCliShim", () => { + it("runs the AppImage's bundled CLI with every argument and unmounts after", () => { + const { root, image, shim } = fakeAppImage(); + const result = run(shim, ["browser", "setup", "a b"]); + expect(result.status).toBe(3); + expect(result.stdout.split("\n")).toEqual([ + `node=1 cli=${shim} home=${root}/home`, + `${image}/resources/app.asar/apps/server/dist/bin.mjs`, + "browser", + "setup", + "a b", + "", + ]); + // The trap kills the mount helper as the launcher exits. + NodeChildProcess.spawnSync("sh", [ + "-c", + `i=0; while [ ! -e '${root}/unmounted' ] && [ $i -lt 40 ]; do sleep 0.05; i=$((i+1)); done`, + ]); + expect(NodeFS.existsSync(NodePath.join(root, "unmounted"))).toBe(true); + }); + + it("extracts the AppImage where it cannot be mounted", () => { + const { shim } = fakeAppImage(false); + // Extract-and-run is ignored too, so the runtime never starts the app. + const result = run(shim, ["--version"], { APPIMAGE_EXTRACT_AND_RUN: "1" }); + expect(result.status).toBe(3); + expect(result.stdout).toMatch( + /squashfs-root\/resources\/app\.asar\/apps\/server\/dist\/bin\.mjs/, + ); + }); + + it("keeps the install's T3 home under sudo, unless one is set", () => { + const root = tempRoot(); + writeExecutable(NodePath.join(root, "app"), REPORTER); + const shim = writeShim(root, { + kind: "direct", + executable: NodePath.join(root, "app"), + entry: "/bin.mjs", + }); + // sudo clears the environment, so the launcher supplies its own. + expect(run(shim, []).stdout).toContain(`home=${root}/home`); + expect(run(shim, [], { T3CODE_HOME: "/elsewhere" }).stdout).toContain("home=/elsewhere"); + // Run by a relative path, it still names itself absolutely. + const relative = NodeChildProcess.spawnSync("./bin/t3", [], { cwd: root, encoding: "utf8" }); + expect(relative.stdout).toContain(`cli=${shim}`); + }); + + it("runs an installed app's server directly", () => { + const root = tempRoot(); + const executable = NodePath.join(root, "opt", "T3 Code", "t3code"); + writeExecutable(executable, REPORTER); + const shim = writeShim(root, { + kind: "direct", + executable, + entry: "/opt/T3's/app.asar/bin.mjs", + }); + expect(run(shim, ["browser", "setup"]).stdout.split("\n").slice(1)).toEqual([ + "/opt/T3's/app.asar/bin.mjs", + "browser", + "setup", + "", + ]); + }); + + it("tells a person whose app moved to reopen it", () => { + const root = tempRoot(); + const shim = writeShim(root, { + kind: "direct", + executable: NodePath.join(root, "Gone.app/Contents/MacOS/T3 Code"), + entry: "/bin.mjs", + }); + const result = run(shim, ["--version"]); + expect(result.status).toBe(127); + expect(result.stderr).toContain("Open the app once to update this command."); + }); + + it("writes a Windows launcher that keeps cmd from reinterpreting paths", () => { + const executable = "C:\\Apps\\R&whoami&X 100%\\!CHANNEL!\\T3 Code.exe"; + const script = renderCliShim({ + target: { kind: "windows", executable, entry: "C:\\Apps\\server.asar\\bin.mjs" }, + shimPath: "C:\\Users\\José\\.t3\\bin\\t3.cmd", + t3Home: "C:\\Users\\José\\.t3", + }); + const lines = script.split("\r\n"); + expect(lines).toContain("setlocal EnableExtensions DisableDelayedExpansion"); + expect(lines).toContain("chcp 65001 >nul"); + expect(lines).toContain( + 'if exist "C:\\Apps\\R&whoami&X 100%%\\!CHANNEL!\\T3 Code.exe" goto run', + ); + // Paths only ever appear quoted or inside `set "..."`, never bare where `&` would split them. + const bare = lines.filter( + (line) => line.includes("whoami") && !/"[^"]*whoami[^"]*"/.test(line), + ); + expect(bare).toEqual([]); + expect(lines).toContain('set "T3CODE_CLI_PATH=C:\\Users\\José\\.t3\\bin\\t3.cmd"'); + }); + + it("switches the Windows console to UTF-8 only when a path needs it", () => { + const ascii = renderCliShim({ + target: { kind: "windows", executable: "C:\\T3\\T3 Code.exe", entry: "C:\\T3\\bin.mjs" }, + shimPath: "C:\\Users\\me\\.t3\\bin\\t3.cmd", + t3Home: "C:\\Users\\me\\.t3", + }); + // A Ctrl-C that ends the batch would leave the console switched. + expect(ascii).not.toContain("chcp"); + expect(ascii.split("\r\n")).toContain("exit /b %t3_exit%"); + }); +}); diff --git a/apps/desktop/src/app/DesktopCliShim.ts b/apps/desktop/src/app/DesktopCliShim.ts new file mode 100644 index 000000000000..c7dd42e2bf50 --- /dev/null +++ b/apps/desktop/src/app/DesktopCliShim.ts @@ -0,0 +1,179 @@ +import * as Cause from "effect/Cause"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; + +import * as DesktopEnvironment from "./DesktopEnvironment.ts"; +import { makeComponentLogger } from "./DesktopObservability.ts"; + +// A desktop install puts no `t3` on PATH, so commands the server asks a person +// to run (`sudo t3 browser setup`) had nothing to call. The app keeps a small +// launcher for its bundled CLI in the T3 home, which is never on PATH and so +// never shadows another `t3`, and the server names it by absolute path in those +// commands through T3CODE_CLI_PATH. An AppImage mounts somewhere new each run, +// so its launcher mounts the AppImage itself instead of pointing into it. +const { logInfo, logWarning } = makeComponentLogger("desktop-cli-shim"); + +const MARKER = "Written by T3 Code: runs the desktop app's bundled t3 CLI."; + +/** Server entry inside the app, relative to its server root (an asar archive when packaged). */ +const SERVER_ENTRY = "apps/server/dist/bin.mjs"; + +const shellWord = (value: string) => `'${value.replaceAll("'", `'"'"'`)}'`; +/** cmd.exe expands `%` even inside quotes; Windows paths cannot contain `"`. */ +const cmdText = (value: string) => value.replaceAll("%", "%%"); +const cmdWord = (value: string) => `"${cmdText(value)}"`; + +const MOVED = "T3 Code has moved or been removed. Open the app once to update this command."; + +export type CliShimTarget = + | { readonly kind: "appimage"; readonly appImage: string; readonly executableName: string } + | { readonly kind: "direct"; readonly executable: string; readonly entry: string } + | { readonly kind: "windows"; readonly executable: string; readonly entry: string }; + +/** + * The launcher script. Electron runs the server as plain Node with + * `ELECTRON_RUN_AS_NODE`, which reads the entry from inside the asar archive. + * The launcher's own path and the app's T3 home are written in, so the command + * the server shows is absolute and `sudo`, which clears the environment, still + * runs against this install's home. + */ +export const renderCliShim = (input: { + readonly target: CliShimTarget; + readonly shimPath: string; + readonly t3Home: string; +}) => { + const { target } = input; + if (target.kind === "windows") { + const utf8 = [target.executable, target.entry, input.shimPath, input.t3Home].some((value) => + [...value].some((character) => character.codePointAt(0)! > 0x7f), + ); + const restore = utf8 ? ["chcp %t3_codepage% >nul"] : []; + return [ + "@echo off", + `rem ${MARKER}`, + // Delayed expansion would rewrite `!name!` in paths and arguments. + "setlocal EnableExtensions DisableDelayedExpansion", + // The file is UTF-8. Only a non-ASCII path needs the console switched to + // UTF-8 to read it; a Ctrl-C that ends the batch skips the restore, so the + // switch is left out for the common all-ASCII install. + ...(utf8 + ? [ + `for /f "tokens=2 delims=:." %%c in ('chcp') do set "t3_codepage=%%c"`, + "chcp 65001 >nul", + ] + : []), + `set "T3CODE_CLI_PATH=${cmdText(input.shimPath)}"`, + `if not defined T3CODE_HOME set "T3CODE_HOME=${cmdText(input.t3Home)}"`, + 'set "ELECTRON_RUN_AS_NODE=1"', + // A goto, not a parenthesized block: "Program Files (x86)" would close the block early. + `if exist ${cmdWord(target.executable)} goto run`, + ...restore, + `echo ${MOVED} 1>&2`, + "exit /b 127", + ":run", + `${cmdWord(target.executable)} ${cmdWord(target.entry)} %*`, + 'set "t3_exit=%ERRORLEVEL%"', + ...restore, + "exit /b %t3_exit%", + "", + ].join("\r\n"); + } + const header = [ + "#!/bin/sh", + `# ${MARKER}`, + `export T3CODE_CLI_PATH=${shellWord(input.shimPath)}`, + `home=${shellWord(input.t3Home)}`, + 'export T3CODE_HOME="${T3CODE_HOME:-$home}"', + "export ELECTRON_RUN_AS_NODE=1", + `app=${shellWord(target.kind === "appimage" ? target.appImage : target.executable)}`, + 'if [ ! -x "$app" ]; then', + ` echo "${MOVED}" >&2`, + " exit 127", + "fi", + ]; + if (target.kind === "direct") { + return [...header, `exec "$app" ${shellWord(target.entry)} "$@"`, ""].join("\n"); + } + const run = (root: string) => + `"${root}/${target.executableName}" "${root}/resources/app.asar/${SERVER_ENTRY}" "$@"`; + return [ + ...header, + // Extract-and-run would make the runtime start the app instead of mounting it. + "unset APPIMAGE_EXTRACT_AND_RUN", + // The AppImage runtime prints its mount point, then stays mounted until + // killed. Background jobs ignore Ctrl-C, so the traps unmount it on any exit. + "work=$(mktemp -d) || exit 1", + '"$app" --appimage-mount >"$work/mount" 2>/dev/null &', + "mounter=$!", + `trap 'kill "$mounter" 2>/dev/null; rm -rf "$work"' EXIT`, + "trap 'exit 130' INT TERM", + "mount=", + 'while kill -0 "$mounter" 2>/dev/null; do', + ' mount=$(head -n 1 "$work/mount")', + ' [ -d "$mount" ] && break', + " mount=", + " sleep 0.05", + "done", + // Without FUSE the runtime cannot mount (it prints why and exits); extracting the image still works. + 'if [ -z "$mount" ]; then', + ' (cd "$work" && "$app" --appimage-extract >/dev/null) || { echo "Could not open $app." >&2; exit 1; }', + ' mount="$work/squashfs-root"', + "fi", + run("$mount"), + "", + ].join("\n"); +}; + +/** + * Writes the packaged app's launcher to `/bin` and returns its path + * for the backend's T3CODE_CLI_PATH. Development builds run from a checkout + * and get none. + */ +export const install = Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + if (!environment.isPackaged) return Option.none(); + const fs = yield* FileSystem.FileSystem; + const path = environment.path; + const windows = environment.platform === "win32"; + const shimPath = path.join(environment.baseDir, "bin", windows ? "t3.cmd" : "t3"); + const entry = path.join(environment.serverRoot, SERVER_ENTRY); + const target: CliShimTarget = windows + ? { kind: "windows", executable: process.execPath, entry } + : Option.match(environment.appImagePath, { + onSome: (appImage) => ({ + kind: "appimage" as const, + appImage, + executableName: path.basename(process.execPath), + }), + // macOS and .deb installs live at a fixed path, so the launcher runs the app directly. + onNone: () => ({ kind: "direct" as const, executable: process.execPath, entry }), + }); + const content = renderCliShim({ target, shimPath, t3Home: environment.baseDir }); + + return yield* Effect.gen(function* () { + const existing = yield* fs.readFileString(shimPath).pipe(Effect.option); + if (Option.isSome(existing) && !existing.value.includes(MARKER)) { + // Someone else's file; leave it, and let commands fall back to plain `t3`. + yield* logWarning("leaving a t3 launcher the app did not write", { shimPath }); + return Option.none(); + } + if (Option.getOrUndefined(existing) !== content) { + yield* fs.makeDirectory(path.dirname(shimPath), { recursive: true }); + // Written beside the launcher and renamed over it, so a running `t3` never reads half a file. + const staging = `${shimPath}.${process.pid}.tmp`; + yield* fs.writeFileString(staging, content, { mode: 0o755 }); + yield* fs.rename(staging, shimPath); + yield* logInfo("installed t3 launcher", { shimPath }); + } + return Option.some(shimPath); + }).pipe( + // Best-effort: nothing here may block the backend's start; commands then fall back to plain `t3`. + Effect.catchCause((cause) => + logWarning("could not install t3 launcher", { shimPath, cause: Cause.pretty(cause) }).pipe( + Effect.as(Option.none()), + ), + ), + Effect.withSpan("desktop.cliShim.install"), + ); +}); diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.ts index 289e3fb321f0..be923b47c5af 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.ts @@ -17,6 +17,7 @@ import * as SynchronizedRef from "effect/SynchronizedRef"; import serverPackageJson from "../../../server/package.json" with { type: "json" }; import * as DesktopBackendManager from "./DesktopBackendManager.ts"; +import * as DesktopCliShim from "../app/DesktopCliShim.ts"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import * as DesktopServerExposure from "./DesktopServerExposure.ts"; import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; @@ -547,6 +548,7 @@ const resolvePrimaryStartConfig = Effect.fn("desktop.backendConfiguration.resolv function* ( input: SharedBootstrapInput & { readonly resourceMonitorPath: Option.Option; + readonly cliPath: Option.Option; }, ): Effect.fn.Return< DesktopBackendManager.DesktopBackendStartConfig, @@ -595,6 +597,8 @@ const resolvePrimaryStartConfig = Effect.fn("desktop.backendConfiguration.resolv env: { ...backendChildEnvPatch(), ELECTRON_RUN_AS_NODE: "1", + // The server names this launcher in commands it asks a person to run. + T3CODE_CLI_PATH: Option.getOrUndefined(input.cliPath), }, // Primary wants process.env (PATH, dev-runner's T3CODE_HOME, etc.). extendEnv: true, @@ -912,7 +916,11 @@ export const make = Effect.gen(function* () { Effect.provideService(FileSystem.FileSystem, fileSystem), Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment), ); - return yield* resolvePrimaryStartConfig({ ...shared, resourceMonitorPath }).pipe( + const cliPath = yield* DesktopCliShim.install.pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment), + ); + return yield* resolvePrimaryStartConfig({ ...shared, resourceMonitorPath, cliPath }).pipe( Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment), Effect.provideService(DesktopServerExposure.DesktopServerExposure, serverExposure), ); diff --git a/apps/server/src/cli/invocation.test.ts b/apps/server/src/cli/invocation.test.ts index 56e4b9c06434..bf2dcba2b9f4 100644 --- a/apps/server/src/cli/invocation.test.ts +++ b/apps/server/src/cli/invocation.test.ts @@ -2,6 +2,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, expect, it } from "@effect/vitest"; import { HostProcessArguments, + HostProcessEnvironment, HostProcessExecutablePath, HostProcessIsExecutable, HostProcessPlatform, @@ -102,28 +103,87 @@ it("formats serve suggestions to match the launching command", () => { ); }); -it.effect("keeps a user-installed Node reachable when the command runs under sudo", () => - Effect.gen(function* () { - const command = (node: string, entry: string) => - resolveRootCliCommand("browser setup").pipe( - Effect.provideService(HostProcessExecutablePath, node), - Effect.provideService(HostProcessArguments, [node, entry]), - ); - const npx = "/home/theo/.npm/_npx/abc/node_modules/t3/dist/bin.mjs"; - // sudo's secure_path already has a system Node. - expect(yield* command("/usr/bin/node", npx)).toBe("sudo npx t3 browser setup"); - // nvm, fnm, and tarball installs are dropped by sudo's PATH reset. - expect(yield* command("/home/theo/.nvm/versions/node/v24/bin/node", npx)).toBe( - 'sudo env "PATH=$PATH" npx t3 browser setup', +it.layer(NodeServices.layer)("root CLI commands", (it) => { + /** `sudo t3 browser setup` as this process would render it, with `t3` on PATH or not. */ + const rootCommand = (input: { + readonly node: string; + readonly entry: string; + readonly path?: string; + readonly env?: Record; + readonly executable?: boolean; + }) => + resolveRootCliCommand("browser setup").pipe( + Effect.provideService(HostProcessExecutablePath, input.node), + Effect.provideService(HostProcessArguments, [input.node, input.entry]), + Effect.provideService(HostProcessIsExecutable, input.executable ?? false), + Effect.provideService(HostProcessPlatform, "linux"), + Effect.provideService(HostProcessEnvironment, { PATH: input.path ?? "", ...input.env }), ); - expect( - yield* command( - "/home/theo/.local/node/bin/node", - "/home/theo/.local/lib/node_modules/t3/dist/bin.mjs", - ), - ).toBe('sudo env "PATH=$PATH" t3 browser setup'); - }), -); + + /** A directory holding an executable `t3`, to stand in for one on PATH. */ + const pathWithT3 = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const bin = yield* fs.makeTempDirectoryScoped(); + yield* fs.writeFileString(path.join(bin, "t3"), "#!/bin/sh\n", { mode: 0o755 }); + return bin; + }); + + it.effect("keeps a user-installed Node reachable when the command runs under sudo", () => + Effect.gen(function* () { + const npx = "/home/theo/.npm/_npx/abc/node_modules/t3/dist/bin.mjs"; + // sudo's secure_path already has a system Node. + expect(yield* rootCommand({ node: "/usr/bin/node", entry: npx })).toBe( + "sudo npx t3 browser setup", + ); + // nvm, fnm, and tarball installs are dropped by sudo's PATH reset. + expect( + yield* rootCommand({ node: "/home/theo/.nvm/versions/node/v24/bin/node", entry: npx }), + ).toBe('sudo env "PATH=$PATH" npx t3 browser setup'); + expect( + yield* rootCommand({ + node: "/home/theo/.local/node/bin/node", + entry: "/home/theo/.local/lib/node_modules/t3/dist/bin.mjs", + path: yield* pathWithT3, + }), + ).toBe('sudo env "PATH=$PATH" t3 browser setup'); + }).pipe(Effect.scoped), + ); + + it.effect("names this install's launcher when t3 is not on PATH", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const home = yield* fs.makeTempDirectoryScoped(); + const shim = path.join(home, ".local bin", "t3"); + yield* fs.makeDirectory(path.dirname(shim), { recursive: true }); + yield* fs.writeFileString(shim, "#!/bin/sh\n", { mode: 0o755 }); + const desktop = { + node: "/tmp/.mount_T3abc/t3code", + entry: "/tmp/.mount_T3abc/resources/app.asar/apps/server/dist/bin.mjs", + env: { T3CODE_CLI_PATH: shim }, + }; + // The desktop app's shim, quoted for the shell and run as root as is. + expect(yield* rootCommand(desktop)).toBe(`sudo '${shim}' browser setup`); + // A `t3` the person put on PATH still wins. + expect(yield* rootCommand({ ...desktop, path: yield* pathWithT3 })).toBe( + 'sudo env "PATH=$PATH" t3 browser setup', + ); + // A standalone binary names itself. + expect( + yield* rootCommand({ + node: "/opt/t3/t3", + entry: "/opt/t3/t3", + executable: true, + }), + ).toBe("sudo /opt/t3/t3 browser setup"); + // A stale shim path, then no launcher at all, fall back to plain `t3`. + expect( + yield* rootCommand({ ...desktop, env: { T3CODE_CLI_PATH: path.join(home, "gone") } }), + ).toBe('sudo env "PATH=$PATH" t3 browser setup'); + }).pipe(Effect.scoped), + ); +}); it.layer(NodeServices.layer)("manual server installation ownership", (it) => { it.effect("recognizes runner caches for both script and executable packages", () => diff --git a/apps/server/src/cli/invocation.ts b/apps/server/src/cli/invocation.ts index cbfc4dff8b19..59843abd7289 100644 --- a/apps/server/src/cli/invocation.ts +++ b/apps/server/src/cli/invocation.ts @@ -1,15 +1,18 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import type { ServerInstallation } from "@t3tools/contracts"; import { HostProcessArguments, + HostProcessEnvironment, HostProcessExecutablePath, HostProcessIsExecutable, HostProcessPlatform, } from "@t3tools/shared/hostProcess"; +import { isCommandAvailable } from "@t3tools/shared/shell"; import packageJson from "../../package.json" with { type: "json" }; @@ -146,15 +149,54 @@ export const resolveCliCommand = (subcommand: string) => }), ); +/** Quotes `value` for a POSIX shell when it needs it. */ +const shellWord = (value: string) => + /^[\w@%+=:,./-]+$/.test(value) ? value : `'${value.replaceAll("'", `'"'"'`)}'`; + +/** + * The launcher a person can type to run this install when `t3` is not on + * PATH: the desktop app's `t3` shim, which the app and the shim itself name in + * `T3CODE_CLI_PATH`, or a standalone binary's own path. Script installs (a + * repo checkout) have no single launcher and keep plain `t3`. + */ +const resolveInstallLauncher = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const shim = (yield* HostProcessEnvironment).T3CODE_CLI_PATH?.trim(); + if (shim && (yield* fs.exists(shim).pipe(Effect.orElseSucceed(() => false)))) { + return Option.some(shim); + } + return (yield* HostProcessIsExecutable) + ? Option.some(yield* HostProcessExecutablePath) + : Option.none(); +}); + +/** + * `t3 ` for a person to run on this host: `t3` when it is on PATH, + * the package runner this process came from, or else the absolute path of the + * launcher for this install, such as the one the desktop app installs. + */ +const resolveHostCliCommand = (subcommand: string) => + Effect.gen(function* () { + const command = yield* resolveCliCommand(subcommand); + if (command !== `t3 ${subcommand}`) return { command, launcher: false }; + if (yield* isCommandAvailable("t3")) return { command, launcher: false }; + const launcher = yield* resolveInstallLauncher; + return Option.isSome(launcher) + ? { command: `${shellWord(launcher.value)} ${subcommand}`, launcher: true } + : { command, launcher: false }; + }); + /** * `t3 ` as root, for setup a person runs once on the host. `sudo` * resets PATH on most distributions, which drops a user-installed Node (nvm, * fnm, a tarball) and with it `npx` or a global `t3`, so the command carries - * PATH through unless Node is on root's PATH too. + * PATH through unless Node is on root's PATH too. An absolute launcher needs + * neither. */ export const resolveRootCliCommand = (subcommand: string) => Effect.gen(function* () { - const command = yield* resolveCliCommand(subcommand); + const { command, launcher } = yield* resolveHostCliCommand(subcommand); + if (launcher) return `sudo ${command}`; const executablePath = yield* HostProcessExecutablePath; const systemNode = ROOT_PATH_DIRECTORIES.some((directory) => executablePath.startsWith(`${directory}/`), diff --git a/docs/user/remote-access.md b/docs/user/remote-access.md index 491470bfa2f0..87c8ee9bea67 100644 --- a/docs/user/remote-access.md +++ b/docs/user/remote-access.md @@ -213,7 +213,8 @@ sudo t3 browser setup The server shows the exact line for how you started it, such as `sudo npx t3 browser setup`, and keeps your `PATH` when Node is installed only -for your user. It allows Chrome's sandbox with an AppArmor profile and installs +for your user. Where `t3` is not on your `PATH`, such as with only the +desktop app installed, it names the full path of the app's own `t3` instead. It allows Chrome's sandbox with an AppArmor profile and installs any missing libraries with apt. It is safe to run again. Without `sudo`, it only reports what it would change. From 0678e4e23d8675ef88f9ac08e1ae90cfe7d6ef2e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 7 Oct 2026 12:42:53 -0700 Subject: [PATCH 05/25] feat(desktop): install the t3 command from Settings (#16683) Co-authored-by: Claude Opus 5.5 (1M context) --- .../desktop/src/app/DesktopCliCommand.test.ts | 243 ++++++++++++++++ apps/desktop/src/app/DesktopCliCommand.ts | 261 ++++++++++++++++++ apps/desktop/src/app/DesktopCliShim.ts | 12 +- apps/desktop/src/ipc/DesktopIpcHandlers.ts | 8 + apps/desktop/src/ipc/channels.ts | 3 + apps/desktop/src/ipc/methods/cliCommand.ts | 34 +++ apps/desktop/src/main.ts | 2 + apps/desktop/src/preload.ts | 5 + .../settings/CliCommandSettingsRow.tsx | 76 +++++ .../components/settings/SettingsPanels.tsx | 2 + .../src/components/settings/settingsSearch.ts | 7 + docs/user/install.md | 9 + packages/contracts/src/ipc.ts | 17 ++ 13 files changed, 677 insertions(+), 2 deletions(-) create mode 100644 apps/desktop/src/app/DesktopCliCommand.test.ts create mode 100644 apps/desktop/src/app/DesktopCliCommand.ts create mode 100644 apps/desktop/src/ipc/methods/cliCommand.ts create mode 100644 apps/web/src/components/settings/CliCommandSettingsRow.tsx diff --git a/apps/desktop/src/app/DesktopCliCommand.test.ts b/apps/desktop/src/app/DesktopCliCommand.test.ts new file mode 100644 index 000000000000..dc2e7fb962d1 --- /dev/null +++ b/apps/desktop/src/app/DesktopCliCommand.test.ts @@ -0,0 +1,243 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Sink from "effect/Sink"; +import * as Stream from "effect/Stream"; +import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; + +import * as DesktopCliCommand from "./DesktopCliCommand.ts"; +import * as DesktopCliShim from "./DesktopCliShim.ts"; +import * as DesktopEnvironment from "./DesktopEnvironment.ts"; + +const environmentFor = ( + path: Path.Path, + input: { home: string; baseDir: string; platform?: NodeJS.Platform; isPackaged?: boolean }, +) => + DesktopEnvironment.DesktopEnvironment.of({ + path, + platform: input.platform ?? "linux", + isPackaged: input.isPackaged ?? true, + homeDirectory: input.home, + baseDir: input.baseDir, + stateDir: path.join(input.baseDir, "userdata"), + serverRoot: "/opt/T3 Code/resources/app.asar", + appImagePath: Option.none(), + } as unknown as DesktopEnvironment.DesktopEnvironment["Service"]); + +/** The service for a packaged app; the launcher is written on Install. */ +const commandIn = ( + input: { home: string; baseDir?: string; platform?: NodeJS.Platform; isPackaged?: boolean }, + spawner?: ChildProcessSpawner.ChildProcessSpawner["Service"], +) => + Effect.gen(function* () { + const path = yield* Path.Path; + const fs = yield* FileSystem.FileSystem; + const baseDir = input.baseDir ?? path.join(input.home, ".t3"); + yield* fs.makeDirectory(path.join(baseDir, "userdata"), { recursive: true }); + const make = DesktopCliCommand.make.pipe( + Effect.provideService( + DesktopEnvironment.DesktopEnvironment, + environmentFor(path, { ...input, baseDir }), + ), + ); + return yield* spawner + ? make.pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner)) + : make; + }); + +/** A PowerShell that keeps the user's PATH in memory and can be told to fail reads. */ +const fakePowerShell = (initial: string) => { + const registry = { path: initial, failReads: false, writes: 0 }; + const spawner = ChildProcessSpawner.make((command) => + Effect.sync(() => { + const { options } = command as unknown as { + readonly options: { readonly env?: Record }; + }; + const env = options.env ?? {}; + let exitCode = 0; + let stdout = ""; + if (env.T3_SET === "1") { + registry.path = env.T3_PATH ?? ""; + registry.writes += 1; + } else if (registry.failReads) { + exitCode = 1; + } else { + stdout = Buffer.from(registry.path, "utf8").toString("base64"); + } + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(exitCode)), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: Stream.make(new TextEncoder().encode(stdout)), + stderr: Stream.empty, + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); + }), + ); + return { registry, spawner }; +}; + +it.layer(NodeServices.layer)("DesktopCliCommand", (it) => { + it.effect("links the launcher onto PATH and removes only that link", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const home = yield* fs.makeTempDirectoryScoped(); + const command = yield* commandIn({ home }); + const link = path.join(home, ".local", "bin", "t3"); + + expect(yield* command.state).toEqual({ supported: true, installedPath: null, onPath: false }); + // Install writes the launcher itself, even when no local backend ever did. + const installed = yield* command.install; + expect(installed.installedPath).toBe(link); + expect(yield* fs.readLink(link)).toBe(path.join(home, ".t3", "bin", "t3")); + expect((yield* command.install).installedPath).toBe(link); + + expect((yield* command.uninstall).installedPath).toBeNull(); + expect(yield* fs.exists(link)).toBe(false); + // The launcher itself stays for setup commands. + expect(yield* fs.exists(path.join(home, ".t3", "bin", "t3"))).toBe(true); + }).pipe(Effect.scoped), + ); + + it.effect("never replaces or removes a t3 it did not create", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const home = yield* fs.makeTempDirectoryScoped(); + const command = yield* commandIn({ home }); + const theirs = path.join(home, ".local", "bin", "t3"); + yield* fs.makeDirectory(path.dirname(theirs), { recursive: true }); + yield* fs.writeFileString(theirs, "npm's t3\n"); + // Even a broken link in the next folder is someone else's. + yield* fs.makeDirectory(path.join(home, "bin"), { recursive: true }); + yield* fs.symlink(path.join(home, "gone"), path.join(home, "bin", "t3")); + + const error = yield* Effect.flip(command.install); + expect(error.message).toContain("Another t3 command is already installed"); + yield* command.uninstall; + expect(yield* fs.readFileString(theirs)).toBe("npm's t3\n"); + expect(yield* fs.readLink(path.join(home, "bin", "t3"))).toBe(path.join(home, "gone")); + }).pipe(Effect.scoped), + ); + + it.effect("finds and removes a link left by a previous T3 home", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const home = yield* fs.makeTempDirectoryScoped(); + const before = yield* commandIn({ home, baseDir: path.join(home, "old-t3") }); + const link = (yield* before.install).installedPath; + + const after = yield* commandIn({ home, baseDir: path.join(home, "new-t3") }); + expect((yield* after.state).installedPath).toBe(link); + // Installing again points the link at this home's launcher. + expect((yield* after.install).installedPath).toBe(link); + expect(yield* fs.readLink(link!)).toBe(path.join(home, "new-t3", "bin", "t3")); + yield* after.uninstall; + expect(yield* fs.exists(path.join(home, ".local", "bin", "t3"))).toBe(false); + expect(yield* fs.exists(path.join(home, "bin", "t3"))).toBe(false); + }).pipe(Effect.scoped), + ); + + it.effect("does not read a large binary another t3 links to", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const home = yield* fs.makeTempDirectoryScoped(); + // A big executable that happens to contain the marker text is still not ours. + const binary = path.join(home, "native-t3"); + yield* fs.writeFileString(binary, `${"\0".repeat(64 * 1024)}${DesktopCliShim.MARKER}`); + yield* fs.makeDirectory(path.join(home, ".local", "bin"), { recursive: true }); + yield* fs.symlink(binary, path.join(home, ".local", "bin", "t3")); + const command = yield* commandIn({ home }); + expect((yield* command.state).installedPath).toBeNull(); + yield* command.uninstall; + expect(yield* fs.readLink(path.join(home, ".local", "bin", "t3"))).toBe(binary); + }).pipe(Effect.scoped), + ); + + it.effect("reports when another t3 earlier on PATH would run instead", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const home = yield* fs.makeTempDirectoryScoped(); + const shadow = path.join(home, "shadow"); + yield* fs.makeDirectory(shadow); + yield* fs.writeFileString(path.join(shadow, "t3"), "#!/bin/sh\n", { mode: 0o755 }); + const previous = process.env.PATH; + process.env.PATH = [shadow, path.join(home, ".local", "bin")].join(":"); + yield* Effect.addFinalizer(() => Effect.sync(() => (process.env.PATH = previous))); + + const command = yield* commandIn({ home }); + const installed = yield* command.install; + expect(installed.installedPath).toBe(path.join(home, ".local", "bin", "t3")); + expect(installed.onPath).toBe(false); + yield* fs.remove(path.join(shadow, "t3")); + expect((yield* command.state).onPath).toBe(true); + }).pipe(Effect.scoped), + ); + + it.effect("leaves the Windows PATH alone when it cannot be read", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const home = yield* fs.makeTempDirectoryScoped(); + const userPath = "C:\\Users\\Me\\npm;%USERPROFILE%\\tools"; + const { registry, spawner } = fakePowerShell(userPath); + const command = yield* commandIn({ home, platform: "win32" }, spawner); + + registry.failReads = true; + const error = yield* Effect.flip(command.install); + expect(error.message).toContain("left unchanged"); + expect(registry).toMatchObject({ path: userPath, writes: 0 }); + + registry.failReads = false; + const launcherDir = DesktopCliShim.launcherPath( + environmentFor(yield* Path.Path, { home, baseDir: `${home}/.t3`, platform: "win32" }), + ).replace(/[\\/]t3\.cmd$/, ""); + yield* command.install; + expect(registry.path).toBe(`${userPath};${launcherDir}`); + yield* command.uninstall; + expect(registry.path).toBe(userPath); + }).pipe(Effect.scoped), + ); + + it.effect("does not claim or remove a Windows PATH entry the user added", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const home = yield* fs.makeTempDirectoryScoped(); + const launcherDir = path.dirname( + DesktopCliShim.launcherPath( + environmentFor(path, { home, baseDir: path.join(home, ".t3"), platform: "win32" }), + ), + ); + const userPath = `C:\\Tools;${launcherDir}`; + const { registry, spawner } = fakePowerShell(userPath); + const command = yield* commandIn({ home, platform: "win32" }, spawner); + + expect((yield* command.state).installedPath).toBeNull(); + yield* command.uninstall; + expect(registry.path).toBe(userPath); + }).pipe(Effect.scoped), + ); + + it.effect("offers nothing for a development build", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const command = yield* commandIn({ + home: yield* fs.makeTempDirectoryScoped(), + isPackaged: false, + }); + expect((yield* command.state).supported).toBe(false); + }).pipe(Effect.scoped), + ); +}); diff --git a/apps/desktop/src/app/DesktopCliCommand.ts b/apps/desktop/src/app/DesktopCliCommand.ts new file mode 100644 index 000000000000..534af3716db7 --- /dev/null +++ b/apps/desktop/src/app/DesktopCliCommand.ts @@ -0,0 +1,261 @@ +import type { DesktopCliCommandState } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import * as ChildProcess from "effect/process/ChildProcess"; +import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; + +import * as DesktopCliShim from "./DesktopCliShim.ts"; +import * as DesktopEnvironment from "./DesktopEnvironment.ts"; + +// Settings → Install `t3` command, like VS Code's "Install 'code' command". +// The app's launcher (see DesktopCliShim) lives in the T3 home and is off PATH +// by default. Installing links it into a folder on the user's PATH, or on +// Windows adds the launcher's folder to the user's PATH. Removing undoes only +// what installing did: a link that points at one of the app's launchers, or a +// PATH entry the app recorded adding. + +export class DesktopCliCommandError extends Schema.TaggedError()( + "DesktopCliCommandError", + { message: Schema.String }, +) {} + +/** User-writable folders that login shells commonly put on PATH, in preference order. */ +const unixCandidates = (home: string, platform: NodeJS.Platform) => + platform === "darwin" + ? ["/opt/homebrew/bin", "/usr/local/bin", `${home}/.local/bin`, `${home}/bin`] + : [`${home}/.local/bin`, `${home}/bin`]; + +const pathEntries = (value: string | undefined, separator: string) => + (value ?? "").split(separator).filter((entry) => entry.length > 0); + +/** Windows paths compare without case or a trailing separator. */ +const sameWindowsPath = (left: string, right: string) => + left.replace(/[\\/]+$/, "").toLowerCase() === right.replace(/[\\/]+$/, "").toLowerCase(); + +/** + * Reads, or with `T3_SET` set writes, the user's PATH in the registry, keeping + * `%VAR%` entries unexpanded. Writes keep REG_EXPAND_SZ (Windows' default for + * PATH; `SetEnvironmentVariable` would store REG_SZ and break every `%VAR%` + * entry), then broadcast WM_SETTINGCHANGE so Explorer and the terminals it + * opens see the change. A failed read exits nonzero rather than reading as + * empty, so a write never replaces the user's PATH wholesale. + */ +const WINDOWS_USER_PATH_SCRIPT = ` +$ErrorActionPreference = 'Stop' +if ($env:T3_SET -eq '1') { + $key = [Microsoft.Win32.Registry]::CurrentUser.CreateSubKey('Environment') + $kind = [Microsoft.Win32.RegistryValueKind]::ExpandString + if ($key.GetValueNames() -contains 'Path' -and $key.GetValueKind('Path') -eq 'String') { + $kind = [Microsoft.Win32.RegistryValueKind]::String + } + $key.SetValue('Path', $env:T3_PATH, $kind) + Add-Type -Namespace T3 -Name Env -MemberDefinition '[DllImport("user32.dll", CharSet = CharSet.Unicode)] public static extern System.IntPtr SendMessageTimeout(System.IntPtr hWnd, uint Msg, System.UIntPtr wParam, string lParam, uint fuFlags, uint uTimeout, out System.UIntPtr lpdwResult);' + $result = [System.UIntPtr]::Zero + [void][T3.Env]::SendMessageTimeout([System.IntPtr]0xffff, 0x1A, [System.UIntPtr]::Zero, 'Environment', 2, 5000, [ref]$result) +} else { + $key = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey('Environment') + $value = if ($key) { $key.GetValue('Path', '', 'DoNotExpandEnvironmentNames') } else { '' } + [Console]::Out.Write([Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes([string]$value))) +} +`; + +export class DesktopCliCommand extends Context.Service< + DesktopCliCommand, + { + readonly state: Effect.Effect; + readonly install: Effect.Effect; + readonly uninstall: Effect.Effect; + } +>()("@t3tools/desktop/app/DesktopCliCommand") {} + +/** @public Service construction is part of the canonical Effect module API. */ +export const make = Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fs = yield* FileSystem.FileSystem; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const path = environment.path; + const windows = environment.platform === "win32"; + const launcher = DesktopCliShim.launcherPath(environment); + const binDirectory = path.dirname(launcher); + /** Records that Install added `binDirectory` to the Windows PATH, so Remove takes out only that. */ + const ownedPathMarker = path.join(environment.stateDir, "cli-command-path-entry"); + + const fail = (message: string) => new DesktopCliCommandError({ message }); + const exists = (target: string) => fs.exists(target).pipe(Effect.orElseSucceed(() => false)); + const writableDirectory = (directory: string) => + fs.access(directory, { writable: true }).pipe( + Effect.as(true), + Effect.orElseSucceed(() => false), + ); + + /** + * A link to one of the app's launchers, by the marker the launcher carries. + * This also finds links made under a previous T3 home, so Remove can clean + * them up and Install does not add a second. + */ + const isOurLink = (link: string) => + Effect.gen(function* () { + yield* fs.readLink(link); + // The launcher is a few KB; never read a large binary another `t3` links to. + const info = yield* fs.stat(link); + if (info.type !== "File" || Number(info.size) > 16_384) return false; + const content = yield* fs.readFileString(link); + return content.includes(DesktopCliShim.MARKER); + }).pipe(Effect.orElseSucceed(() => false)); + + const powershell = (env: Record) => + Effect.scoped( + Effect.gen(function* () { + const root = process.env.SystemRoot ?? process.env.WINDIR; + const handle = yield* spawner.spawn( + ChildProcess.make( + root ? `${root}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe` : "powershell.exe", + ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", WINDOWS_USER_PATH_SCRIPT], + { stdin: "ignore", stderr: "ignore", env, extendEnv: true }, + ), + ); + const [stdout, exitCode] = yield* Effect.all( + [handle.stdout.pipe(Stream.decodeText(), Stream.mkString), handle.exitCode], + { concurrency: "unbounded" }, + ); + if (exitCode !== 0) return yield* Effect.fail(`powershell exited with ${exitCode}`); + return stdout; + }), + ); + const readUserPath = powershell({}).pipe( + Effect.map((encoded) => Buffer.from(encoded.trim(), "base64").toString("utf8")), + Effect.mapError(() => fail("Could not read your PATH, so it was left unchanged.")), + ); + const writeUserPath = (value: string) => + powershell({ T3_SET: "1", T3_PATH: value }).pipe( + Effect.asVoid, + Effect.mapError(() => fail("Could not update your PATH.")), + ); + + /** The `t3` a new shell runs, by PATH order, or none. */ + const firstOnPath = Effect.gen(function* () { + for (const directory of pathEntries(process.env.PATH, ":")) { + const candidate = path.join(directory, "t3"); + if (yield* exists(candidate)) return Option.some(candidate); + } + return Option.none(); + }); + + /** Where this app's command is installed now, if anywhere. */ + const installedAt = Effect.gen(function* () { + if (windows) { + if (!(yield* exists(ownedPathMarker))) return Option.none(); + const entries = pathEntries(yield* readUserPath, ";"); + return entries.some((entry) => sameWindowsPath(entry, binDirectory)) + ? Option.some(launcher) + : Option.none(); + } + for (const directory of unixCandidates(environment.homeDirectory, environment.platform)) { + const link = path.join(directory, "t3"); + if (yield* isOurLink(link)) return Option.some(link); + } + return Option.none(); + }); + + const state: DesktopCliCommand["Service"]["state"] = Effect.gen(function* () { + if (!environment.isPackaged) { + return { supported: false, installedPath: null, onPath: false } as const; + } + const installed = yield* installedAt; + if (Option.isNone(installed)) return { supported: true, installedPath: null, onPath: false }; + // On Windows only terminals opened after the change see it. On Unix the + // first `t3` on PATH must be ours; a `t3` earlier on PATH would shadow it. + const first = yield* firstOnPath; + const onPath = windows || (Option.isSome(first) && (yield* isOurLink(first.value))); + return { supported: true, installedPath: installed.value, onPath }; + }).pipe(Effect.orElseSucceed(() => ({ supported: false, installedPath: null, onPath: false }))); + + /** Writes the launcher if the app has not yet, e.g. when no local backend runs. */ + const ensureLauncher = DesktopCliShim.install.pipe( + Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment), + Effect.provideService(FileSystem.FileSystem, fs), + Effect.flatMap( + Option.match({ + onNone: () => Effect.fail(fail(`Could not set up the t3 launcher at ${launcher}.`)), + onSome: () => Effect.void, + }), + ), + ); + + const install: DesktopCliCommand["Service"]["install"] = Effect.gen(function* () { + if (!environment.isPackaged) return yield* fail("The t3 command needs an installed app."); + yield* ensureLauncher; + if (windows) { + const entries = pathEntries(yield* readUserPath, ";"); + if (!entries.some((entry) => sameWindowsPath(entry, binDirectory))) { + yield* writeUserPath([...entries, binDirectory].join(";")); + yield* fs + .writeFileString(ownedPathMarker, `${binDirectory}\n`) + .pipe(Effect.mapError(() => fail("Added t3 to your PATH but could not record it."))); + } + return yield* state; + } + const existing = yield* installedAt; + if (Option.isSome(existing)) { + const target = yield* fs.readLink(existing.value).pipe(Effect.option); + if (Option.getOrUndefined(target) === launcher) return yield* state; + // A link to a previous T3 home's launcher: point it at this one instead. + yield* fs + .remove(existing.value) + .pipe(Effect.mapError(() => fail(`Could not replace ${existing.value}.`))); + } + const onPath = pathEntries(process.env.PATH, ":"); + const candidates = unixCandidates(environment.homeDirectory, environment.platform); + // Prefer a folder already on PATH that the user can write to without admin rights. + for (const directory of [ + ...candidates.filter((candidate) => onPath.includes(candidate)), + ...candidates.filter((candidate) => !onPath.includes(candidate)), + ]) { + const link = path.join(directory, "t3"); + const created = (yield* exists(directory)) + ? yield* writableDirectory(directory) + : yield* fs.makeDirectory(directory, { recursive: true }).pipe( + Effect.as(true), + Effect.orElseSucceed(() => false), + ); + if (!created) continue; + // symlink fails if anything, even a broken link, is already there. + const linked = yield* fs.symlink(launcher, link).pipe( + Effect.as(true), + Effect.orElseSucceed(() => false), + ); + if (linked) return yield* state; + } + return yield* fail( + `Another t3 command is already installed, or no folder on your PATH is writable. Run the launcher directly at ${launcher}.`, + ); + }).pipe(Effect.withSpan("desktop.cliCommand.install")); + + const uninstall: DesktopCliCommand["Service"]["uninstall"] = Effect.gen(function* () { + if (windows) { + if (yield* exists(ownedPathMarker)) { + const entries = pathEntries(yield* readUserPath, ";"); + const kept = entries.filter((entry) => !sameWindowsPath(entry, binDirectory)); + if (kept.length !== entries.length) yield* writeUserPath(kept.join(";")); + yield* fs.remove(ownedPathMarker).pipe(Effect.ignore); + } + return yield* state; + } + for (const directory of unixCandidates(environment.homeDirectory, environment.platform)) { + const link = path.join(directory, "t3"); + if (yield* isOurLink(link)) { + yield* fs.remove(link).pipe(Effect.mapError(() => fail(`Could not remove ${link}.`))); + } + } + return yield* state; + }).pipe(Effect.withSpan("desktop.cliCommand.uninstall")); + + return DesktopCliCommand.of({ state, install, uninstall }); +}); + +export const layer = Layer.effect(DesktopCliCommand, make); diff --git a/apps/desktop/src/app/DesktopCliShim.ts b/apps/desktop/src/app/DesktopCliShim.ts index c7dd42e2bf50..5cd942f66ced 100644 --- a/apps/desktop/src/app/DesktopCliShim.ts +++ b/apps/desktop/src/app/DesktopCliShim.ts @@ -14,7 +14,7 @@ import { makeComponentLogger } from "./DesktopObservability.ts"; // so its launcher mounts the AppImage itself instead of pointing into it. const { logInfo, logWarning } = makeComponentLogger("desktop-cli-shim"); -const MARKER = "Written by T3 Code: runs the desktop app's bundled t3 CLI."; +export const MARKER = "Written by T3 Code: runs the desktop app's bundled t3 CLI."; /** Server entry inside the app, relative to its server root (an asar archive when packaged). */ const SERVER_ENTRY = "apps/server/dist/bin.mjs"; @@ -125,6 +125,14 @@ export const renderCliShim = (input: { ].join("\n"); }; +/** Where the packaged app keeps its launcher: `/bin/t3`, `t3.cmd` on Windows. */ +export const launcherPath = (environment: DesktopEnvironment.DesktopEnvironment["Service"]) => + environment.path.join( + environment.baseDir, + "bin", + environment.platform === "win32" ? "t3.cmd" : "t3", + ); + /** * Writes the packaged app's launcher to `/bin` and returns its path * for the backend's T3CODE_CLI_PATH. Development builds run from a checkout @@ -136,7 +144,7 @@ export const install = Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = environment.path; const windows = environment.platform === "win32"; - const shimPath = path.join(environment.baseDir, "bin", windows ? "t3.cmd" : "t3"); + const shimPath = launcherPath(environment); const entry = path.join(environment.serverRoot, SERVER_ENTRY); const target: CliShimTarget = windows ? { kind: "windows", executable: process.execPath, entry } diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index a16535c4a5eb..a67424a795a9 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -75,6 +75,11 @@ import { takeLegacyLocalStorage, } from "./methods/legacyLocalStorage.ts"; import { getWslState, setWslBackendEnabled, setWslDistro, setWslOnly } from "./methods/wsl.ts"; +import { + getCliCommandState, + installCliCommand, + uninstallCliCommand, +} from "./methods/cliCommand.ts"; export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers")(function* () { const ipc = yield* DesktopIpc.DesktopIpc; @@ -149,6 +154,9 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* ipc.handle(downloadUpdate); yield* ipc.handle(installUpdate); yield* ipc.handle(checkForUpdate); + yield* ipc.handle(getCliCommandState); + yield* ipc.handle(installCliCommand); + yield* ipc.handle(uninstallCliCommand); for (const previewMethod of PreviewIpc.methods) { yield* ipc.handle(previewMethod); } diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index 9ae71cab1df8..5b078314ac49 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -23,6 +23,9 @@ export const UPDATE_SET_CHANNEL_CHANNEL = "desktop:update-set-channel"; export const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download"; export const UPDATE_INSTALL_CHANNEL = "desktop:update-install"; export const UPDATE_CHECK_CHANNEL = "desktop:update-check"; +export const CLI_COMMAND_GET_STATE_CHANNEL = "desktop:cli-command-get-state"; +export const CLI_COMMAND_INSTALL_CHANNEL = "desktop:cli-command-install"; +export const CLI_COMMAND_UNINSTALL_CHANNEL = "desktop:cli-command-uninstall"; export const GET_APP_BRANDING_CHANNEL = "desktop:get-app-branding"; export const GET_SYSTEM_LOCALE_CHANNEL = "desktop:get-system-locale"; export const GET_LOCAL_ENVIRONMENT_BOOTSTRAPS_CHANNEL = "desktop:get-local-environment-bootstraps"; diff --git a/apps/desktop/src/ipc/methods/cliCommand.ts b/apps/desktop/src/ipc/methods/cliCommand.ts new file mode 100644 index 000000000000..be9672c68251 --- /dev/null +++ b/apps/desktop/src/ipc/methods/cliCommand.ts @@ -0,0 +1,34 @@ +import { DesktopCliCommandStateSchema } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +import * as DesktopCliCommand from "../../app/DesktopCliCommand.ts"; +import * as IpcChannels from "../channels.ts"; +import { makeIpcMethod } from "../DesktopIpc.ts"; + +export const getCliCommandState = makeIpcMethod({ + channel: IpcChannels.CLI_COMMAND_GET_STATE_CHANNEL, + payload: Schema.Void, + result: DesktopCliCommandStateSchema, + handler: Effect.fn("desktop.ipc.cliCommand.getState")(function* () { + return yield* (yield* DesktopCliCommand.DesktopCliCommand).state; + }), +}); + +export const installCliCommand = makeIpcMethod({ + channel: IpcChannels.CLI_COMMAND_INSTALL_CHANNEL, + payload: Schema.Void, + result: DesktopCliCommandStateSchema, + handler: Effect.fn("desktop.ipc.cliCommand.install")(function* () { + return yield* (yield* DesktopCliCommand.DesktopCliCommand).install; + }), +}); + +export const uninstallCliCommand = makeIpcMethod({ + channel: IpcChannels.CLI_COMMAND_UNINSTALL_CHANNEL, + payload: Schema.Void, + result: DesktopCliCommandStateSchema, + handler: Effect.fn("desktop.ipc.cliCommand.uninstall")(function* () { + return yield* (yield* DesktopCliCommand.DesktopCliCommand).uninstall; + }), +}); diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 2c56f6b39c8e..01c3d7892c66 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -38,6 +38,7 @@ import * as DesktopAppActivation from "./app/DesktopAppActivation.ts"; import * as DesktopAppIdentity from "./app/DesktopAppIdentity.ts"; import * as DesktopConnectionCatalogStore from "./app/DesktopConnectionCatalogStore.ts"; import * as DesktopClerk from "./app/DesktopClerk.ts"; +import * as DesktopCliCommand from "./app/DesktopCliCommand.ts"; import * as DesktopApplicationMenu from "./window/DesktopApplicationMenu.ts"; import * as DesktopAssets from "./app/DesktopAssets.ts"; import * as DesktopBackendConfiguration from "./backend/DesktopBackendConfiguration.ts"; @@ -213,6 +214,7 @@ const layerDesktopApplication = Layer.mergeAll( layerDesktopAppActivation, DesktopApplicationMenu.layer, DesktopLinuxUrlHandler.layer, + DesktopCliCommand.layer, DesktopShellEnvironment.layer, layerDesktopSsh, ).pipe( diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index a97f224a1b41..0dd9604f9865 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -268,6 +268,11 @@ contextBridge.exposeInMainWorld("desktopBridge", { checkForUpdate: () => ipcRenderer.invoke(IpcChannels.UPDATE_CHECK_CHANNEL), downloadUpdate: () => ipcRenderer.invoke(IpcChannels.UPDATE_DOWNLOAD_CHANNEL), installUpdate: () => ipcRenderer.invoke(IpcChannels.UPDATE_INSTALL_CHANNEL), + cliCommand: { + getState: () => ipcRenderer.invoke(IpcChannels.CLI_COMMAND_GET_STATE_CHANNEL), + install: () => ipcRenderer.invoke(IpcChannels.CLI_COMMAND_INSTALL_CHANNEL), + uninstall: () => ipcRenderer.invoke(IpcChannels.CLI_COMMAND_UNINSTALL_CHANNEL), + }, onUpdateState: (listener) => { const wrappedListener = (_event: Electron.IpcRendererEvent, state: unknown) => { if (typeof state !== "object" || state === null) return; diff --git a/apps/web/src/components/settings/CliCommandSettingsRow.tsx b/apps/web/src/components/settings/CliCommandSettingsRow.tsx new file mode 100644 index 000000000000..f7c12345d7db --- /dev/null +++ b/apps/web/src/components/settings/CliCommandSettingsRow.tsx @@ -0,0 +1,76 @@ +import type { DesktopCliCommandState } from "@t3tools/contracts"; +import { useCallback, useEffect, useState } from "react"; + +import { Button } from "../ui/button"; +import { stackedThreadToast, toastManager } from "../ui/toast"; +import { SettingsRow } from "./settingsLayout"; +import { searchableSetting } from "./settingsSearch"; + +/** + * Settings → `t3` command: puts the desktop app's bundled CLI on PATH, or takes + * it off again. Hidden where the desktop build has no launcher to install. + */ +export function CliCommandSettingsRow() { + const bridge = typeof window === "undefined" ? undefined : window.desktopBridge?.cliCommand; + const [state, setState] = useState(null); + const [pending, setPending] = useState(false); + + useEffect(() => { + if (!bridge) return; + let cancelled = false; + void bridge + .getState() + .then((next) => { + if (!cancelled) setState(next); + }) + .catch(() => undefined); + return () => { + cancelled = true; + }; + }, [bridge]); + + const change = useCallback( + (action: "install" | "uninstall") => { + if (!bridge || pending) return; + setPending(true); + void bridge[action]() + .then(setState) + .catch((error: unknown) => { + toastManager.add( + stackedThreadToast({ + type: "error", + title: action === "install" ? "Could not install t3" : "Could not remove t3", + description: error instanceof Error ? error.message : "Something went wrong.", + }), + ); + }) + .finally(() => setPending(false)); + }, + [bridge, pending], + ); + + if (!bridge || !state?.supported) return null; + const installed = state.installedPath !== null; + const description = !installed + ? "Run T3 Code's CLI as `t3` from any terminal." + : state.onPath + ? `Installed at ${state.installedPath}. Open a new terminal to use it.` + : `Installed at ${state.installedPath}, which is not on your PATH yet. Add its folder to your PATH to run \`t3\`.`; + + return ( + change(installed ? "uninstall" : "install")} + > + {installed ? "Remove" : "Install"} + + } + /> + ); +} diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 5e52b4827891..bee57d21b253 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -55,6 +55,7 @@ import * as Equal from "effect/Equal"; import * as Schema from "effect/Schema"; import { APP_VERSION, HOSTED_APP_CHANNEL, HOSTED_APP_CHANNEL_LABEL } from "../../branding"; import { IS_NIGHTLY_BUILD, NightlyMobileBetaRow } from "../NightlyMobileBeta"; +import { CliCommandSettingsRow } from "./CliCommandSettingsRow"; import { canCheckForUpdate, getDesktopUpdateButtonTooltip, @@ -509,6 +510,7 @@ function AboutVersionSection() { /> ) : null} {showNightlyMobileBeta ? : null} + {hasDesktopBridge ? : null} ); } diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index 81b2d1248f19..853886d81fcd 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -503,6 +503,13 @@ export const SETTINGS_SEARCH_ITEMS = [ scope: "project-defaults", searchTerms: ["generated thread titles source control content default provider"], }, + { + id: "cli-command", + title: "t3 command", + to: "/settings/general", + searchTerms: ["cli terminal shell path install command line"], + desktopOnly: true, + }, { id: "privacy-policy", title: "Privacy policy", diff --git a/docs/user/install.md b/docs/user/install.md index 980837199e9b..b9f2930776ae 100644 --- a/docs/user/install.md +++ b/docs/user/install.md @@ -76,6 +76,15 @@ The `.deb` updates itself like the other desktop builds. It asks for your password to install each update. If your desktop has no password prompt, the update fails. Download the new `.deb` and install it the same way. +### The `t3` command + +The desktop app includes the `t3` command-line tool. To run it from any +terminal, open **Settings → General → About** and choose **Install** next to +**t3 command**. On macOS and Linux it adds a `t3` link to a folder on your +`PATH`; on Windows it adds the app's command folder to your `PATH`. Open a new +terminal afterwards. **Remove** takes it off again. If you already have `t3` +from npm, it stays as it is. + ### Windows Subsystem for Linux Choose a WSL distro in **Settings → Connections** to run agents and projects diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index e661bc2b187d..149f282194a5 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -317,6 +317,17 @@ export const DesktopUpdateStateSchema = Schema.Struct({ canRetry: Schema.Boolean, }); +/** The desktop app's `t3` command on PATH, managed from Settings. */ +export const DesktopCliCommandStateSchema = Schema.Struct({ + /** Only installed builds have a launcher to put on PATH. */ + supported: Schema.Boolean, + /** The `t3` the app installed, or null when it is not installed. */ + installedPath: Schema.NullOr(Schema.String), + /** Whether a new terminal finds it; false when the folder is not on PATH yet. */ + onPath: Schema.Boolean, +}); +export type DesktopCliCommandState = typeof DesktopCliCommandStateSchema.Type; + export interface DesktopUpdateActionResult { accepted: boolean; completed: boolean; @@ -1215,6 +1226,12 @@ export interface DesktopBridge { downloadUpdate: () => Promise; installUpdate: () => Promise; onUpdateState: (listener: (state: DesktopUpdateState) => void) => () => void; + /** Settings → `t3` command. Optional: older desktop builds lack it. */ + cliCommand?: { + getState: () => Promise; + install: () => Promise; + uninstall: () => Promise; + }; /** Present when the desktop shell accepts `t3 app` activation requests. */ appActivation?: { setReady: (ready: boolean) => Promise; From a9fb6a80635c77a1d4fd988e0018059e13628eff Mon Sep 17 00:00:00 2001 From: Bilal Bakr <62337003+Bil0000@users.noreply.github.com> Date: Wed, 7 Oct 2026 23:11:04 +0300 Subject: [PATCH 06/25] fix(web): show live names in thread-read activity (#13140) Co-authored-by: Julius Marminge Co-authored-by: PR Batch Tester --- .../chat/MessagesTimeline.logic.test.ts | 46 ++++++++++++ .../components/chat/MessagesTimeline.logic.ts | 37 +++++++++- .../src/components/chat/MessagesTimeline.tsx | 73 ++++++++++++++++++- 3 files changed, 152 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index 0f22324e0ca1..e1ce7145d63a 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -33,6 +33,9 @@ import { resolveWorkGroupScrollIndex, shouldFollowWorkGroupAppend, shouldPreserveAssistantLineBreaks, + threadReadLabelPrefix, + threadReadTargetId, + threadReadTargetTitle, type MessagesTimelineRow, resolveTimelineToolPresentation, workEntryDisplayLabel, @@ -91,6 +94,18 @@ describe("expanded tool group scrolling", () => { }); describe("work entry labels", () => { + it("uses live titles only for active thread shells", () => { + const shell = { title: " Review auth flow ", archivedAt: null, deletedAt: null }; + expect(threadReadTargetTitle(shell)).toBe("Review auth flow"); + expect(threadReadTargetTitle({ ...shell, title: "Harden session refresh" })).toBe( + "Harden session refresh", + ); + expect(threadReadTargetTitle({ ...shell, archivedAt: "2026-10-07T12:00:00Z" })).toBeNull(); + expect(threadReadTargetTitle({ ...shell, deletedAt: "2026-10-07T12:00:00Z" })).toBeNull(); + expect(threadReadTargetTitle({ ...shell, title: " " })).toBeNull(); + expect(threadReadTargetTitle(null)).toBeNull(); + }); + const entry = { id: "tool-1", createdAt: "2026-09-01T12:00:00Z", @@ -150,6 +165,37 @@ describe("work entry labels", () => { expect(workEntryDisplayLabel(browserEntry, undefined)).toBe(label); }); + it.each([ + ["inProgress", true, "Reading thread"], + ["completed", false, "Read thread"], + ["failed", false, "Failed to read thread"], + ["declined", false, "Declined to read thread"], + ["stopped", false, "Stopped reading thread"], + ] as const)("names the read thread in the %s label", (toolLifecycleStatus, active, prefix) => { + const threadRead = { + ...entry, + itemType: "dynamic_tool" as const, + toolLifecycleStatus, + structuredPayload: { + type: "dynamic_tool", + toolName: "t3-code.t3_thread_read", + input: { threadId: " thread-child ", view: "activity" }, + } as never, + }; + expect(threadReadTargetId(threadRead)).toBe("thread-child"); + expect(threadReadLabelPrefix(liveWorkEntryLabel(threadRead, undefined, active))).toBe(prefix); + expect(threadReadLabelPrefix(workEntryDisplayLabel(threadRead, undefined))).toBe(prefix); + }); + + it("finds no target for other tools or thread reads without one", () => { + const payload = (toolName: string, input: unknown) => ({ + structuredPayload: { type: "dynamic_tool", toolName, input } as never, + }); + expect(threadReadTargetId(payload("t3-code.t3_thread_wait", { threadId: "t" }))).toBeNull(); + expect(threadReadTargetId(payload("t3-code.t3_thread_read", { threadId: " " }))).toBeNull(); + expect(threadReadTargetId(payload("t3-code.t3_thread_read", null))).toBeNull(); + }); + it("uses the active summary state for legacy tools without a lifecycle status", () => { const browserEntry = { ...entry, toolTitle: "T3-code.preview_click" }; expect(liveWorkEntryLabel(browserEntry, undefined, true)).toBe( diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 5a2259e961f4..b619a347255b 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -34,7 +34,12 @@ import { type TimelineEntry, type WorkLogEntry, } from "../../session-logic"; -import { type ChatMessage, type ProposedPlan, type TurnDiffSummary } from "../../types"; +import { + type ChatMessage, + type ProposedPlan, + type ThreadShell, + type TurnDiffSummary, +} from "../../types"; import { type MessageId, type WorktreeSetupSnapshot, @@ -169,6 +174,36 @@ export function workEntryDisplayLabel(entry: WorkLogEntry, workspaceRoot: string return `${heading.charAt(0).toUpperCase()}${heading.slice(1)}`; } +/** The trimmed thread id a `t3_thread_read` call targets, or null for any other entry. */ +export function threadReadTargetId(entry: Pick) { + const item = entry.structuredPayload; + if (item?.type !== "dynamic_tool") return null; + if (resolveT3McpToolDefinition(item.toolName)?.summaryAction !== "thread-read") return null; + const input = item.input; + const threadId = + input !== null && typeof input === "object" && "threadId" in input ? input.threadId : null; + return typeof threadId === "string" && threadId.trim().length > 0 ? threadId.trim() : null; +} + +const THREAD_READ_OBJECT = " a T3 thread"; + +export function threadReadTargetTitle( + shell: Pick | null, +) { + if (!shell || shell.archivedAt !== null || shell.deletedAt !== null) return null; + return shell.title.trim() || null; +} + +/** + * Names the read thread in place of the generic object ("Read a T3 thread" becomes + * `Read thread “Title”`), keeping the label's tense. Null keeps the generic label. + */ +export function threadReadLabelPrefix(label: string) { + return label.endsWith(THREAD_READ_OBJECT) + ? `${label.slice(0, -THREAD_READ_OBJECT.length)} thread` + : null; +} + /** Inspectable read-file output is the path when we have one, otherwise nothing. */ export function workEntryReadOutput( entry: Pick< diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 463fbfd30b87..43f9746a6590 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -28,7 +28,7 @@ import { type ServerProvider, type ServerProviderSkill, type RunId, - type ThreadId, + ThreadId, type ToolActivityIcon, } from "@t3tools/contracts"; import { parseScopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment"; @@ -208,6 +208,9 @@ import { resolveWorkGroupScrollIndex, shouldFollowWorkGroupAppend, shouldPreserveAssistantLineBreaks, + threadReadLabelPrefix, + threadReadTargetId, + threadReadTargetTitle, toolGroupAction, workEntryDisplayLabel, workEntryReadOutput, @@ -3722,19 +3725,67 @@ function LiveActivityContent({ ); } +/** The thread a `t3_thread_read` call targets, titled from live shell state so renames show. */ +function useThreadReadTarget(entry: TimelineWorkEntry, environmentId: EnvironmentId) { + const rawThreadId = threadReadTargetId(entry); + const threadId = rawThreadId === null ? null : ThreadId.make(rawThreadId); + const shell = useThreadShell(threadId ? scopeThreadRef(environmentId, threadId) : null); + const title = threadReadTargetTitle(shell); + return threadId && title ? { threadId, title } : null; +} + +function threadReadLabel(label: string, target: ReturnType) { + const prefix = target && threadReadLabelPrefix(label); + return prefix ? { ...target, prefix, text: `${prefix} “${target.title}”` } : null; +} + +/** Only settled rows link the title; the live row is itself a button. */ +function ThreadReadLabel({ + label, + environmentId, + linked, +}: { + label: NonNullable>; + environmentId: EnvironmentId; + linked: boolean; +}) { + return ( + + {label.prefix} “ + {linked ? ( + + {label.title} + + ) : ( + {label.title} + )} + ” + + ); +} + function LiveWorkEntryTimelineRow({ row }: { row: Extract }) { const ctx = use(TimelineRowCtx); + const threadTarget = useThreadReadTarget(row.entry, ctx.activeThreadEnvironmentId); const questionHeading = row.entry.questionAnswer ? getQuestionTextPreview(row.entry.questionAnswer) : ""; const label = questionHeading || liveWorkEntryLabel(row.entry, ctx.workspaceRoot, row.active); + const threadLabel = threadReadLabel(label, threadTarget); + const failed = workEntryDisplayIndicatesToolFailure(row.entry); return ( ); } - return ( - } - > - {children} - - - - - ); + return control; } diff --git a/apps/web/src/components/chat/ThreadDetailsPanel.test.tsx b/apps/web/src/components/chat/ThreadDetailsPanel.test.tsx index ec7d84aef49b..a9f1867f39d2 100644 --- a/apps/web/src/components/chat/ThreadDetailsPanel.test.tsx +++ b/apps/web/src/components/chat/ThreadDetailsPanel.test.tsx @@ -74,8 +74,6 @@ describe("ThreadDetailsPanel", () => { startFromOrigin: false, onStartFromOriginChange: vi.fn(), onComposerFocusRequest: vi.fn(), - versionMismatch: null, - onDismissVersionMismatch: vi.fn(), onRunProjectScript: vi.fn(), onAddProjectScript: vi.fn() as ThreadDetailsPanelProps["onAddProjectScript"], onUpdateProjectScript: vi.fn() as ThreadDetailsPanelProps["onUpdateProjectScript"], diff --git a/apps/web/src/components/chat/ThreadDetailsPanel.tsx b/apps/web/src/components/chat/ThreadDetailsPanel.tsx index b4a709297229..4bfcaa788118 100644 --- a/apps/web/src/components/chat/ThreadDetailsPanel.tsx +++ b/apps/web/src/components/chat/ThreadDetailsPanel.tsx @@ -5,23 +5,16 @@ import type { ResolvedKeybindingsConfig, ThreadId, } from "@t3tools/contracts"; -import { AlertTriangleIcon, XIcon } from "lucide-react"; import type { DraftId } from "../../composerDraftStore"; import { useT3ProjectFileScripts } from "../../hooks/useT3ProjectFileScripts"; -import { - shouldShowEnvironmentIndicator, - type EnvMode, - type EnvironmentOption, -} from "../BranchToolbar.logic"; +import { type EnvMode, type EnvironmentOption } from "../BranchToolbar.logic"; import { BranchToolbar } from "../BranchToolbar"; -import { BranchToolbarEnvironmentSelector } from "../BranchToolbarEnvironmentSelector"; import GitActionsControl from "../GitActionsControl"; import ProjectScriptsControl, { type NewProjectScriptInput, type ProjectScriptActionResult, } from "../ProjectScriptsControl"; -import { Button } from "../ui/button"; import type { ComponentProps } from "react"; import { ThreadDetailsCard } from "./ThreadDetailsCard"; import { OpenInPicker } from "./OpenInPicker"; @@ -29,12 +22,6 @@ import { ThreadDetailsSection } from "./ThreadDetailsSection"; import { ThreadAutomationsPanel } from "./ThreadAutomationsPanel"; import { ThreadRelationshipsPanel } from "./ThreadRelationshipsControl"; -interface VersionMismatchIssue { - readonly clientVersion: string; - readonly serverVersion: string; - readonly serverLabel: string; -} - export interface ThreadDetailsPanelProps extends Pick< ComponentProps, "anchor" | "handle" | "onPresentationChange" @@ -66,8 +53,6 @@ export interface ThreadDetailsPanelProps extends Pick< onCheckoutPullRequestRequest?: (reference: string) => void; onComposerFocusRequest: () => void; onOpenChanges?: () => void; - versionMismatch: VersionMismatchIssue | null; - onDismissVersionMismatch: () => void; onRunProjectScript: (script: ProjectScript) => void; onAddProjectScript: (input: NewProjectScriptInput) => Promise; onUpdateProjectScript: ( @@ -82,14 +67,6 @@ export function ThreadDetailsPanel(props: ThreadDetailsPanelProps) { props.environmentId, props.activeProjectScripts ? props.gitCwd : null, ); - // Same rule as the composer strip: a lone remote machine still gets a row, - // shown as a static label because there is nothing to pick. - const canPickEnvironment = props.availableEnvironments.length > 1; - const showEnvironment = shouldShowEnvironmentIndicator({ - activeEnvironment: - props.availableEnvironments.find((env) => env.environmentId === props.environmentId) ?? null, - canPickEnvironment, - }); const branchToolbarProps = { showGitControls: props.isGitRepo, environmentId: props.environmentId, @@ -128,46 +105,19 @@ export function ThreadDetailsPanel(props: ThreadDetailsPanelProps) { separated={false} showHeading={false} > - {props.versionMismatch ? ( -
- -
-

Client and server versions differ

-

- Client {props.versionMismatch.clientVersion} ·{" "} - {props.versionMismatch.serverLabel} {props.versionMismatch.serverVersion} -

-
- -
- ) : null} -
- {density === "full" && showEnvironment ? ( - ) : null} - {density === "full" ? ( - - ) : null} - {density !== "essential" && props.showOpenInPicker ? ( ) : null; - const openRow = (text: string, part: "row" | "link-primary") => ( + const openRow = (text: string, part: "row" | "primary") => (
) : watching ? ( -
- {openRow(label, "link-primary")} +
+ {openRow(label, "primary")}
diff --git a/apps/web/src/components/chat/ThreadRelationshipsControl.tsx b/apps/web/src/components/chat/ThreadRelationshipsControl.tsx index cd1265e46a60..ef96d4105880 100644 --- a/apps/web/src/components/chat/ThreadRelationshipsControl.tsx +++ b/apps/web/src/components/chat/ThreadRelationshipsControl.tsx @@ -59,7 +59,7 @@ import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { toastManager } from "../ui/toast"; import { - THREAD_DETAILS_PANEL_LINK_SPLIT_GROUP_CLASS, + THREAD_DETAILS_PANEL_SPLIT_GROUP_CLASS, THREAD_DETAILS_PANEL_ROW_CONTENT_CLASS, THREAD_DETAILS_PANEL_SPLIT_SEPARATOR_CLASS, } from "./threadDetailsPanelStyles"; @@ -461,12 +461,9 @@ export function ThreadRelationshipsPanel(props: { ); return ( -
  • +
  • {isMergeTarget ? ( -
    +
    openThread(threadId)} @@ -534,7 +531,7 @@ export function ThreadRelationshipsPanel(props: { variant="ghost" disabled={node?.missing === true} onClick={() => openThread(threadId)} - part={canStop ? "group-row" : "row"} + part="row" /> } > diff --git a/apps/web/src/components/chat/threadDetailsPanelStyles.ts b/apps/web/src/components/chat/threadDetailsPanelStyles.ts index 33c454e4232b..bd74bd95cd5e 100644 --- a/apps/web/src/components/chat/threadDetailsPanelStyles.ts +++ b/apps/web/src/components/chat/threadDetailsPanelStyles.ts @@ -6,29 +6,20 @@ const THREAD_DETAILS_PANEL_RESTING_BUTTON_SURFACE_CLASS = "bg-transparent shadow-none"; const THREAD_DETAILS_PANEL_HOVER_SURFACE_CLASS = - "hover:!bg-black/[0.055] data-pressed:!bg-black/[0.055] dark:hover:!bg-white/[0.075] dark:data-pressed:!bg-white/[0.075]"; + "hover:!bg-black/[0.035] data-pressed:!bg-black/[0.035] dark:hover:!bg-white/[0.08] dark:data-pressed:!bg-white/[0.08]"; const THREAD_DETAILS_PANEL_ROW_SURFACE_CLASS = `${THREAD_DETAILS_PANEL_RESTING_BUTTON_SURFACE_CLASS} ${THREAD_DETAILS_PANEL_HOVER_SURFACE_CLASS}`; +const THREAD_DETAILS_PANEL_SPLIT_BUTTON_SURFACE_CLASS = `${THREAD_DETAILS_PANEL_ROW_SURFACE_CLASS} data-popup-open:!bg-black/[0.035] dark:data-popup-open:!bg-white/[0.08]`; +const THREAD_DETAILS_PANEL_SPLIT_GROUP_SURFACE_CLASS = `${THREAD_DETAILS_PANEL_HOVER_SURFACE_CLASS} has-[[data-popup-open]]:bg-black/[0.035] dark:has-[[data-popup-open]]:bg-white/[0.08]`; export const THREAD_DETAILS_PANEL_ROW_CONTENT_CLASS = "gap-2.5 px-2.5 text-left"; -// The row supplies the first tint; the hovered or open segment adds a second tint. -const THREAD_DETAILS_PANEL_SPLIT_BUTTON_SURFACE_CLASS = `${THREAD_DETAILS_PANEL_ROW_SURFACE_CLASS} data-popup-open:!bg-black/[0.055] dark:data-popup-open:!bg-white/[0.075]`; - const THREAD_DETAILS_PANEL_CONTROL_CLASS = `h-8 min-w-0 rounded-lg border-transparent ${THREAD_DETAILS_PANEL_ROW_CONTENT_CLASS} text-[13px] font-medium text-foreground/80`; -const THREAD_DETAILS_PANEL_SPLIT_GROUP_SURFACE_CLASS = `${THREAD_DETAILS_PANEL_HOVER_SURFACE_CLASS} has-[[data-popup-open]]:bg-black/[0.055] dark:has-[[data-popup-open]]:bg-white/[0.075]`; export const THREAD_DETAILS_PANEL_ROW_CLASS = `${THREAD_DETAILS_PANEL_CONTROL_CLASS} w-full justify-start ${THREAD_DETAILS_PANEL_ROW_SURFACE_CLASS}`; export const THREAD_DETAILS_PANEL_SELECT_ROW_CLASS = `${THREAD_DETAILS_PANEL_ROW_CLASS} pe-0 [&_[data-slot=select-icon]]:-me-px [&_[data-slot=select-icon]]:relative [&_[data-slot=select-icon]]:flex [&_[data-slot=select-icon]]:h-full [&_[data-slot=select-icon]]:w-8 [&_[data-slot=select-icon]]:shrink-0 [&_[data-slot=select-icon]]:items-center [&_[data-slot=select-icon]]:justify-center [&_[data-slot=select-icon]]:before:absolute [&_[data-slot=select-icon]]:before:-left-px [&_[data-slot=select-icon]]:before:top-1/2 [&_[data-slot=select-icon]]:before:h-4 [&_[data-slot=select-icon]]:before:w-px [&_[data-slot=select-icon]]:before:-translate-y-1/2 [&_[data-slot=select-icon]]:before:bg-border/65 [&_[data-slot=select-icon]>svg]:me-0 [&_[data-slot=select-icon]>svg]:size-4 [&_[data-slot=select-icon]>svg]:text-muted-foreground [&_[data-slot=select-icon]>svg]:opacity-100`; -// No transition on the group: the halves are Buttons whose background snaps (their base only -// transitions shadow), so an eased group tint would land frames later and the hover would -// visibly commit in two steps. -export const THREAD_DETAILS_PANEL_LINK_SPLIT_GROUP_CLASS = `group/thread-details-link flex w-full items-center rounded-lg ${THREAD_DETAILS_PANEL_SPLIT_GROUP_SURFACE_CLASS}`; - -export const THREAD_DETAILS_PANEL_LINK_SPLIT_PRIMARY_CLASS = `${THREAD_DETAILS_PANEL_CONTROL_CLASS} flex-1 justify-start rounded-e-none ${THREAD_DETAILS_PANEL_SPLIT_BUTTON_SURFACE_CLASS}`; - export const THREAD_DETAILS_PANEL_LOCKED_ROW_CLASS = `h-8 w-full min-w-0 justify-start rounded-lg border border-transparent ${THREAD_DETAILS_PANEL_ROW_CONTENT_CLASS} text-[13px] font-medium text-foreground/80 sm:h-8 sm:text-[13px]`; export const THREAD_DETAILS_PANEL_ICON_CLASS = "size-4 shrink-0 text-muted-foreground"; @@ -40,7 +31,7 @@ export const THREAD_DETAILS_PANEL_ICON_ACTION_CLASS = `size-6 justify-center rou /** A small text control on a row's second line, such as a pull request's checks or next action. */ export const THREAD_DETAILS_PANEL_META_ACTION_CLASS = `h-6 gap-1 rounded-md border-transparent px-1.5 text-xs font-medium text-muted-foreground ${THREAD_DETAILS_PANEL_ROW_SURFACE_CLASS}`; -export const THREAD_DETAILS_PANEL_SPLIT_GROUP_CLASS = `group/thread-details-action flex w-full items-center rounded-lg ${THREAD_DETAILS_PANEL_SPLIT_GROUP_SURFACE_CLASS}`; +export const THREAD_DETAILS_PANEL_SPLIT_GROUP_CLASS = `flex w-full items-center rounded-lg ${THREAD_DETAILS_PANEL_SPLIT_GROUP_SURFACE_CLASS}`; export const THREAD_DETAILS_PANEL_SPLIT_PRIMARY_CLASS = `${THREAD_DETAILS_PANEL_CONTROL_CLASS} flex-1 justify-start rounded-e-none ${THREAD_DETAILS_PANEL_SPLIT_BUTTON_SURFACE_CLASS}`; diff --git a/apps/web/src/routes/_chat.pull-requests.tsx b/apps/web/src/routes/_chat.pull-requests.tsx index 133fe9176238..c6b0591ee257 100644 --- a/apps/web/src/routes/_chat.pull-requests.tsx +++ b/apps/web/src/routes/_chat.pull-requests.tsx @@ -1843,7 +1843,6 @@ function PullRequestsRouteView() { threadPanelOpen={false} threadPanelPresentation="inline" threadPanelShortcutLabel={null} - threadPanelHasAttention={false} onToggleThreadPanel={() => undefined} rightPanelAvailable={rightPanelAvailable} rightPanelOpen={rightPanelState.isOpen} From 7ac5ec6b15593f5b682fe73b483bc8e24de76665 Mon Sep 17 00:00:00 2001 From: Dara Adedeji <76637177+SunkenInTime@users.noreply.github.com> Date: Wed, 7 Oct 2026 17:51:31 -0400 Subject: [PATCH 14/25] fix(web): Copy MCP URL shows up for environments reached over plain http (#16909) Co-authored-by: Claude Opus 5.5 (1M context) --- .../settings/ConnectionsSettings.tsx | 9 +-- docs/user/outside-agents.md | 9 ++- .../src/connection/presentation.test.ts | 76 ++++++++++++++----- .../src/connection/presentation.ts | 39 ++++++---- 4 files changed, 92 insertions(+), 41 deletions(-) diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index 5877f1bbda6c..625efb562f97 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -1665,21 +1665,20 @@ function SavedBackendListRow({ ) { setLastRelayHttpBaseUrl(discoveredRelayHttpBaseUrl); } + const prepared = usePreparedConnection(environmentId); + const connectedTarget = isConnected && prepared._tag === "Some" ? prepared.value.target : null; const mcpUrl = environmentMcpUrl({ entry: environment.entry, relayHttpBaseUrl: discoveredRelayHttpBaseUrl ?? lastRelayHttpBaseUrl, + connectedTarget, }); const machineKind = resolveEnvironmentMachineKind( environment.serverConfig ?? (lastDescriptor === undefined ? null : { environment: lastDescriptor }), ); - const prepared = usePreparedConnection(environmentId); const routeCount = connectionRoutes(environment.entry).length; const subtitleText = [ - environmentTransportLabel( - environment, - isConnected && prepared._tag === "Some" ? prepared.value.target : null, - ), + environmentTransportLabel(environment, connectedTarget), resumingServerUpdate ? "Restarting" : status.text, enabled && versionMismatch ? serverVersion : null, ] diff --git a/docs/user/outside-agents.md b/docs/user/outside-agents.md index 70129139206f..86294d68dc3c 100644 --- a/docs/user/outside-agents.md +++ b/docs/user/outside-agents.md @@ -16,11 +16,12 @@ example: https:///mcp ``` -The address must use HTTPS, or `localhost` when the agent runs on the host -itself. Agents refuse to sign in over a plain `http://` LAN or tailnet address. +The copied URL uses the route this device is connected over, so an agent on the +same device can reach it. -- **An agent on your own computers** can use T3 Connect, Tailscale HTTPS, or - `localhost` on the host. See [remote access](./remote-access.md). +- **An agent on your own computers** can use any address that computer reaches + the environment at: a LAN or Tailscale address, T3 Connect, or `localhost` on + the host. See [remote access](./remote-access.md). - **A hosted agent**, such as ChatGPT or a bot running in the cloud, must reach the environment from the internet. Use the T3 Connect address. A Tailscale address only works from your own tailnet. diff --git a/packages/client-runtime/src/connection/presentation.test.ts b/packages/client-runtime/src/connection/presentation.test.ts index e9f4fe0d57eb..61d6278a5305 100644 --- a/packages/client-runtime/src/connection/presentation.test.ts +++ b/packages/client-runtime/src/connection/presentation.test.ts @@ -2,11 +2,16 @@ import { EnvironmentId } from "@t3tools/contracts"; import { describe, expect, it } from "@effect/vitest"; import * as Option from "effect/Option"; -import { BearerConnectionProfile, type ConnectionCatalogEntry } from "./catalog.ts"; +import { + BearerConnectionProfile, + type ConnectionCatalogEntry, + type ConnectionRoute, +} from "./catalog.ts"; import { BearerConnectionTarget, ConnectionBlockedError, ConnectionTransientError, + RelayConnectionTarget, type SupervisorConnectionState, } from "./model.ts"; import { @@ -72,25 +77,62 @@ describe("connection presentation", () => { expect(connectionCatalogDisplayUrl(ENTRY)).toBe("https://environment.example.test"); }); - it("offers an MCP address only where an MCP client can sign in", () => { - expect(environmentMcpUrl({ entry: ENTRY })).toBe("https://environment.example.test/mcp"); - const withBase = (httpBaseUrl: string): ConnectionCatalogEntry => ({ + it("copies the MCP address of the route this device is connected over", () => { + const route = (connectionId: string, httpBaseUrl: string): ConnectionRoute => { + const target = new BearerConnectionTarget({ ...TARGET, connectionId }); + return { + target, + profile: Option.some( + new BearerConnectionProfile({ + connectionId, + environmentId: TARGET.environmentId, + label: TARGET.label, + httpBaseUrl, + wsBaseUrl: httpBaseUrl.replace(/^http/, "ws"), + }), + ), + }; + }; + const lan = route("lan", "http://192.168.4.53:3773/"); + const tailnet = route("tailnet", "http://100.115.1.44:3773/"); + const serve = route("serve", "https://machine.tailnet.ts.net/"); + const entry: ConnectionCatalogEntry = { ...ENTRY, - profile: Option.some( - new BearerConnectionProfile({ - connectionId: TARGET.connectionId, - environmentId: TARGET.environmentId, - label: TARGET.label, - httpBaseUrl, - wsBaseUrl: httpBaseUrl.replace(/^http/, "ws"), - }), - ), + target: lan.target, + profile: lan.profile, + alternateRoutes: [tailnet, serve], + }; + + expect(environmentMcpUrl({ entry, connectedTarget: tailnet.target })).toBe( + "http://100.115.1.44:3773/mcp", + ); + // Not connected: the preferred route, plain http or not. + expect(environmentMcpUrl({ entry })).toBe("http://192.168.4.53:3773/mcp"); + }); + + it("passes over routes without an address of their own", () => { + const relay = new RelayConnectionTarget({ + environmentId: TARGET.environmentId, + label: TARGET.label, }); - expect(environmentMcpUrl({ entry: withBase("http://127.0.0.1:3773/") })).toBe( - "http://127.0.0.1:3773/mcp", + const entry: ConnectionCatalogEntry = { + ...ENTRY, + target: relay, + profile: Option.none(), + alternateRoutes: [{ target: ENTRY.target, profile: ENTRY.profile }], + }; + + // Relay discovery has not reported the tunnel address yet. + expect(environmentMcpUrl({ entry, connectedTarget: relay })).toBe( + "https://environment.example.test/mcp", ); - // A plain-http LAN or tailnet address is refused by MCP clients' token checks. - expect(environmentMcpUrl({ entry: withBase("http://100.81.102.68:3773") })).toBeNull(); + expect( + environmentMcpUrl({ + entry, + connectedTarget: relay, + relayHttpBaseUrl: "https://tunnel.example.test", + }), + ).toBe("https://tunnel.example.test/mcp"); }); it("distinguishes initial connection, reconnect, and retry errors", () => { diff --git a/packages/client-runtime/src/connection/presentation.ts b/packages/client-runtime/src/connection/presentation.ts index a409847f0b5e..9015a755133e 100644 --- a/packages/client-runtime/src/connection/presentation.ts +++ b/packages/client-runtime/src/connection/presentation.ts @@ -2,7 +2,8 @@ import type { ServerConfig } from "@t3tools/contracts"; import * as Option from "effect/Option"; import type { ConnectionCatalogEntry } from "./catalog.ts"; -import type { SupervisorConnectionState } from "./model.ts"; +import type { ConnectionTarget, SupervisorConnectionState } from "./model.ts"; +import { connectionRouteId, connectionRoutes, routeHttpBaseUrl } from "./routes.ts"; export type EnvironmentConnectionPhase = | "available" @@ -94,31 +95,39 @@ export function presentEnvironmentConnection( /** * The address an agent outside T3 (Claude Code, Codex) uses to reach this - * environment's MCP server. Only HTTPS and loopback addresses qualify: MCP - * clients refuse to sign in through a plain-http token endpoint elsewhere. - * SSH connections ride a local forward that disappears with the client, so - * they have no stable address to hand out. + * environment's MCP server: the route this device is connected over, since an + * agent beside this client can reach it too, else the first route in + * preference order that has an address. SSH connections ride a local forward + * that disappears with the client, so they have no stable address. */ export function environmentMcpUrl(input: { readonly entry: ConnectionCatalogEntry; readonly relayHttpBaseUrl?: string | undefined; + readonly connectedTarget?: ConnectionTarget | null | undefined; }): string | null { - const httpBaseUrl = - input.entry.target._tag === "RelayConnectionTarget" - ? (input.relayHttpBaseUrl ?? null) - : input.entry.target._tag === "SshConnectionTarget" - ? null - : connectionCatalogDisplayUrl(input.entry); - if (httpBaseUrl === null) return null; + const connectedRouteId = input.connectedTarget ? connectionRouteId(input.connectedTarget) : null; + const routes = connectionRoutes(input.entry); + const connectedRoute = routes.find( + (route) => connectionRouteId(route.target) === connectedRouteId, + ); + for (const route of connectedRoute ? [connectedRoute, ...routes] : routes) { + const httpBaseUrl = + route.target._tag === "RelayConnectionTarget" + ? (input.relayHttpBaseUrl ?? null) + : routeHttpBaseUrl(route); + const mcpUrl = httpBaseUrl === null ? null : mcpUrlFromBase(httpBaseUrl); + if (mcpUrl !== null) return mcpUrl; + } + return null; +} + +function mcpUrlFromBase(httpBaseUrl: string): string | null { let url: URL; try { url = new URL(httpBaseUrl); } catch { return null; } - const loopback = - url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "[::1]"; - if (url.protocol !== "https:" && !(url.protocol === "http:" && loopback)) return null; url.pathname = "/mcp"; url.search = ""; url.hash = ""; From abbf5d5bcd045cdb6baf16128ef54d4df3a7f3fd Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 7 Oct 2026 15:05:31 -0700 Subject: [PATCH 15/25] fix(web): C#, Java, PHP and 11 other languages get file icons (#16974) Co-authored-by: Claude Opus 5.5 (1M context) --- apps/web/src/pierre-icons.test.ts | 24 +++++++++- apps/web/src/pierre-icons.ts | 79 +++++++++++++++++++++++++++++-- 2 files changed, 99 insertions(+), 4 deletions(-) diff --git a/apps/web/src/pierre-icons.test.ts b/apps/web/src/pierre-icons.test.ts index 6090d7ed9e72..c90b130b33ea 100644 --- a/apps/web/src/pierre-icons.test.ts +++ b/apps/web/src/pierre-icons.test.ts @@ -38,7 +38,10 @@ describe("Pierre file icons", () => { it("ships every custom icon referenced by the extended resolver", () => { const customIconNames = new Set( - Object.values(T3_PIERRE_ICONS.byFileName).filter((name) => name.startsWith("t3-")), + [ + ...Object.values(T3_PIERRE_ICONS.byFileName), + ...Object.values(T3_PIERRE_ICONS.byFileExtension), + ].filter((name) => name.startsWith("t3-")), ); for (const iconName of customIconNames) { assert.include(T3_PIERRE_ICONS.spriteSheet, `id="${iconName}"`); @@ -64,4 +67,23 @@ describe("Pierre file icons", () => { it("gives a dockerfile fence the Docker icon", () => { assert.isTrue(hasSpecificPierreIconForFileName(syntheticFileNameForLanguageId("dockerfile"))); }); + + it.each([ + "csharp", + "dart", + "diff", + "elixir", + "haskell", + "java", + "kotlin", + "lua", + "php", + "powershell", + "r", + "scala", + "toml", + "xml", + ])("gives a %s fence a language icon", (language) => { + assert.isTrue(hasSpecificPierreIconForFileName(syntheticFileNameForLanguageId(language))); + }); }); diff --git a/apps/web/src/pierre-icons.ts b/apps/web/src/pierre-icons.ts index 46f0099fedee..ead46e89d6ee 100644 --- a/apps/web/src/pierre-icons.ts +++ b/apps/web/src/pierre-icons.ts @@ -28,6 +28,51 @@ const T3_FILE_ICON_SPRITE = ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + `; export const T3_PIERRE_ICONS = { @@ -41,9 +86,33 @@ export const T3_PIERRE_ICONS = { "pnpm-lock.yaml": "t3-file-icon-pnpm", "pnpm-workspace.yaml": "t3-file-icon-pnpm", }, - byFileExtension: Object.fromEntries( - VIDEO_FILE_EXTENSIONS.map((extension) => [extension, "t3-file-icon-video"]), - ), + byFileExtension: { + ...Object.fromEntries( + VIDEO_FILE_EXTENSIONS.map((extension) => [extension, "t3-file-icon-video"]), + ), + // Languages the complete set has no icon for. + cs: "t3-file-icon-csharp", + csx: "t3-file-icon-csharp", + dart: "t3-file-icon-dart", + diff: "t3-file-icon-diff", + patch: "t3-file-icon-diff", + ex: "t3-file-icon-elixir", + exs: "t3-file-icon-elixir", + hs: "t3-file-icon-haskell", + lhs: "t3-file-icon-haskell", + java: "t3-file-icon-java", + kt: "t3-file-icon-kotlin", + kts: "t3-file-icon-kotlin", + lua: "t3-file-icon-lua", + php: "t3-file-icon-php", + ps1: "t3-file-icon-powershell", + psm1: "t3-file-icon-powershell", + psd1: "t3-file-icon-powershell", + r: "t3-file-icon-r", + scala: "t3-file-icon-scala", + toml: "t3-file-icon-toml", + xml: "t3-file-icon-xml", + }, } satisfies FileTreeIcons; const completeIconResolver = createFileTreeIconResolver(T3_PIERRE_ICONS); @@ -51,11 +120,15 @@ const completeIconResolver = createFileTreeIconResolver(T3_PIERRE_ICONS); const LANGUAGE_EXTENSION_ALIASES: Record = { bash: "sh", csharp: "cs", + elixir: "ex", + haskell: "hs", javascript: "js", jsx: "jsx", + kotlin: "kt", markdown: "md", mdx: "mdx", plaintext: "txt", + powershell: "ps1", python: "py", ruby: "rb", rust: "rs", From 95ccbf406626a8a80fa545777a6388bdf5153086 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Wed, 7 Oct 2026 15:06:03 -0700 Subject: [PATCH 16/25] feat(clients): live row shows the agent's latest thought (#16284) Co-authored-by: Claude Opus 5.5 (1M context) --- .../src/features/threads/ThreadFeed.tsx | 3 ++ .../src/features/threads/thread-work-log.tsx | 28 ++++++++-- apps/mobile/src/lib/threadActivity.test.ts | 40 ++++++++++++-- apps/mobile/src/lib/threadActivity.ts | 15 +++++- .../chat/MessagesTimeline.logic.test.ts | 54 +++++++++++++++++++ .../components/chat/MessagesTimeline.logic.ts | 6 +++ .../src/components/chat/MessagesTimeline.tsx | 42 +++++++++++++-- apps/web/src/components/chat/WorkLog.tsx | 8 ++- .../src/work-log/presentation.test.ts | 38 +++++++++++++ .../src/work-log/presentation.ts | 20 +++++++ 10 files changed, 241 insertions(+), 13 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index c18874b1cb03..b5f258a0889f 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -1628,6 +1628,7 @@ function renderFeedEntry( summaryToolIcon={entry.summaryToolIcon} hasFailure={entry.hasFailure} shimmer={entry.shimmer} + thought={entry.thought} onToggle={() => props.onToggleWorkGroup(entry.groupId, entry.id)} /> ); @@ -2937,6 +2938,8 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { case "run-fold": return resolveThreadFeedFixedItemSize(entry.type); case "work-toggle": + // A live thought wraps up to four lines, so that row measures itself. + return entry.thought ? undefined : WORK_GROUP_TOGGLE_HEIGHT; case "thinking": return WORK_GROUP_TOGGLE_HEIGHT; case "activity-group": diff --git a/apps/mobile/src/features/threads/thread-work-log.tsx b/apps/mobile/src/features/threads/thread-work-log.tsx index 4a9067529510..32e6d272b678 100644 --- a/apps/mobile/src/features/threads/thread-work-log.tsx +++ b/apps/mobile/src/features/threads/thread-work-log.tsx @@ -1222,11 +1222,11 @@ export function ThreadWorkGroupToggle(props: { readonly toolIcon?: ToolActivityIcon; readonly hasFailure: boolean; readonly shimmer: boolean; + readonly thought?: string | undefined; readonly onToggle: () => void; }) { - const accessibilityLabel = props.hasFailure - ? `${props.summary}, tool call failed` - : props.summary; + const statusLabel = props.hasFailure ? `${props.summary}, tool call failed` : props.summary; + const accessibilityLabel = props.thought ? `${props.thought} ${statusLabel}` : statusLabel; const icon = props.summaryToolIcon ?? (props.toolSurface @@ -1235,6 +1235,28 @@ export function ThreadWorkGroupToggle(props: { return ( + {props.thought ? ( + // The latest thought sits above the status line as an ordinary work-log + // row, wrapped up to four lines. + + + + + + {props.thought} + + + ) : null} row.type === "work-toggle")).toMatchObject({ - summary: "First paragraph. Second paragraph.", + summary: "Thinking", + thought: "First paragraph.", live: true, }); } else { @@ -2293,6 +2294,32 @@ it.each(["First paragraph.\n\nSecond paragraph.", ""])( }, ); +it("keeps the latest thought under the live tool status", () => { + const at = "2026-06-20T00:00:02.000Z"; + const thought: OrchestrationV2TurnItem = { + ...base("found-thought", at, 1), + type: "reasoning", + status: "completed", + streaming: false, + text: "Found the cause: no commits yet. Checking the UI next.", + }; + const tool = { ...command(at), status: "running" as const, completedAt: null }; + const rows = deriveThreadFeedPresentation( + buildThreadFeed([projected(userMessage(), 0), projected(thought, 1), projected(tool, 2)]), + { runId, status: "running", startedAt: at, completedAt: null }, + new Set(), + new Set(), + at, + ); + expect(rows.find((row) => row.type === "work-toggle")).toMatchObject({ + thought: "Found the cause: no commits yet.", + live: true, + }); + expect(rows.find((row) => row.type === "work-toggle")).not.toMatchObject({ + summary: "Thinking", + }); +}); + it("stops stranded thinking after a steer and follows the next thought or tool", () => { const at = "2026-06-20T00:00:02.000Z"; const thought = (id: string): OrchestrationV2TurnItem => ({ @@ -2316,7 +2343,8 @@ it("stops stranded thinking after a steer and follows the next thought or tool", at, ); expect(rows([first]).find((row) => row.type === "work-toggle")).toMatchObject({ - summary: "first-thought", + summary: "Thinking", + thought: "first-thought", live: true, shimmer: true, }); @@ -2340,9 +2368,11 @@ it("stops stranded thinking after a steer and follows the next thought or tool", ]) { const live = rows(items).filter((row) => row.type === "work-toggle" && row.shimmer); expect(live).toHaveLength(1); - expect(live[0]).toMatchObject({ - summary: items.at(-1)!.type === "reasoning" ? "next-thought" : "Running vp", - }); + expect(live[0]).toMatchObject( + items.at(-1)!.type === "reasoning" + ? { summary: "Thinking", thought: "next-thought" } + : { summary: "Running vp" }, + ); } expect(first.status).toBe("running"); }); diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index d88b1dba2ea2..2dc513e40f02 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -18,6 +18,7 @@ import { } from "@t3tools/client-runtime/work-log/command-label"; import { contextCompactionLabel, + liveThoughtLine, toolItemForDisplay, workEntryDisplayIndicatesToolFailure, liveActivityToolStatus, @@ -213,6 +214,8 @@ type ThreadFeedEntryContent = readonly hasFailure: boolean; readonly live: boolean; readonly shimmer: boolean; + /** First sentence of the latest thought, shown above the live status line. */ + readonly thought?: string; } | { readonly type: "run-fold"; @@ -1496,8 +1499,17 @@ function appendToolGroupRows( const shimmer = activeTail && (active || latestActivity.status === "success"); const singleActivity = activities.length === 1 ? latestActivity : null; const groupSummary = summarizeToolGroup(activities.map((activity) => activity.workEntry)); + const latestThought = + live && !expanded + ? activities.findLast( + (activity) => + activity.workEntry.itemType === "reasoning" && + (activity.workEntry.detail?.trim() ?? "") !== "", + )?.workEntry.detail + : undefined; + const thought = latestThought ? liveThoughtLine(latestThought) : ""; const summary = live - ? expanded && latestActivity.workEntry.itemType === "reasoning" + ? (expanded || thought) && latestActivity.workEntry.itemType === "reasoning" ? latestActivity.lifecycleStatus === "inProgress" ? "Thinking" : "Thought" @@ -1552,6 +1564,7 @@ function appendToolGroupRows( ...(groupToolSurface ? { toolSurface: groupToolSurface } : {}), ...(groupToolIcon ? { toolIcon: groupToolIcon } : {}), ...(summaryToolIcon ? { summaryToolIcon } : {}), + ...(thought ? { thought } : {}), hasFailure: (() => { const lastToolLike = activities.findLast((activity) => activity.toolLike); return ( diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index e1ce7145d63a..98339041f68b 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -2044,6 +2044,60 @@ describe("deriveMessagesTimelineRows", () => { expect(rows.some((row) => row.kind === "thinking")).toBe(false); }); + it("carries the latest thought on the live row while a later tool runs", () => { + type WorkEntry = Extract< + Parameters[0]["timelineEntries"][number], + { kind: "work" } + >; + const work = ( + id: string, + at: string, + fields: Omit, + ): WorkEntry => ({ + id: `${id}-entry`, + kind: "work", + createdAt: at, + entry: { id, createdAt: at, runId: "turn-1" as never, label: id, ...fields }, + }); + const rows = deriveMessagesTimelineRows({ + timelineEntries: [ + work("old-thought", "2026-01-01T00:00:01Z", { + itemType: "reasoning", + detail: "First idea.", + tone: "thinking" as const, + toolLifecycleStatus: "completed" as const, + }), + work("new-thought", "2026-01-01T00:00:02Z", { + itemType: "reasoning", + detail: "Found the cause.", + tone: "thinking" as const, + toolLifecycleStatus: "completed" as const, + }), + work("running-command", "2026-01-01T00:00:03Z", { + command: "rg cause", + requestKind: "command", + tone: "tool" as const, + toolLifecycleStatus: "inProgress" as const, + }), + ], + latestRun: { + runId: "turn-1" as never, + status: "running", + startedAt: "2026-01-01T00:00:00Z", + completedAt: null, + }, + isWorking: true, + activeTurnStartedAt: "2026-01-01T00:00:00Z", + turnDiffSummaries: [], + supportsConversationRollback: false, + }); + + expect(rows.find((row) => row.kind === "work-live")).toMatchObject({ + entry: { id: "running-command" }, + thought: { id: "new-thought" }, + }); + }); + it("keeps an actually running tool in the shared activity row", () => { const rows = deriveMessagesTimelineRows({ timelineEntries: [ diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index b619a347255b..74f3991396ef 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -540,6 +540,8 @@ type MessagesTimelineRowContent = groupId: string; expanded: boolean; active: boolean; + /** Latest reasoning in the live group, shown above the status line. */ + thought?: WorkLogEntry; } | { kind: "working"; @@ -1434,6 +1436,9 @@ export function deriveMessagesTimelineRows(input: { latestVisibleToolEntry.entry.toolLifecycleStatus !== "declined" && workEntryDisplayIndicatesToolFailure(latestVisibleToolEntry.entry); + const latestThoughtEntry = visibleActiveToolEntries.findLast( + (entry) => entry.entry.itemType === "reasoning" && (entry.entry.detail?.trim() ?? "") !== "", + ); const activeWorkPlacementEntryId = latestVisibleToolEntry?.id; const activeWorkRow = activeWorkAnchor && latestVisibleToolEntry && !latestToolFailed @@ -1450,6 +1455,7 @@ export function deriveMessagesTimelineRows(input: { groupId, expanded: input.expandedWorkGroupIds?.has(groupId) ?? false, active: latestToolKeepsActivityLive, + ...(latestThoughtEntry ? { thought: latestThoughtEntry.entry } : {}), }; })() : null; diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 43f9746a6590..6145098f6043 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -40,6 +40,7 @@ import { canForkProjectedAssistantItem } from "@t3tools/client-runtime/state/thr import { notificationChildThreadId } from "@t3tools/client-runtime/state/thread-execution"; import { replaceComposerContextReferences } from "@t3tools/shared/composerContextReferences"; import { + liveThoughtLine, resolveWorkEntryToolPresentation, resolveViewedImageAsset, workEntryViewedImagePath, @@ -3780,18 +3781,53 @@ function LiveWorkEntryTimelineRow({ row }: { row: Extract ctx.onToggleWorkGroup(row.groupId, row.id)} > + {thoughtLine ? ( + // The latest thought's first sentence sits above the status line as an + // ordinary work-log row, so a finding never hides behind the next tool call. + + + + } + label={{thoughtLine}} + wrapLabel + /> + ) : null} {label} diff --git a/apps/web/src/components/chat/WorkLog.tsx b/apps/web/src/components/chat/WorkLog.tsx index 4358f30f4de3..8ac63e0dae46 100644 --- a/apps/web/src/components/chat/WorkLog.tsx +++ b/apps/web/src/components/chat/WorkLog.tsx @@ -46,7 +46,13 @@ type RowContent = { function WorkLogLine({ icon, label, trailing, wrapLabel }: RowContent) { return ( -
    +
    {icon ? ( {icon} ) : null} diff --git a/packages/client-runtime/src/work-log/presentation.test.ts b/packages/client-runtime/src/work-log/presentation.test.ts index 8a73dd70fb73..6e41a53478a1 100644 --- a/packages/client-runtime/src/work-log/presentation.test.ts +++ b/packages/client-runtime/src/work-log/presentation.test.ts @@ -7,6 +7,7 @@ import { T3_MCP_TOOL_NAMES } from "@t3tools/shared/t3McpToolPresentation"; import { commandDetailRepeatsCommand, extractCommandOutputText, + liveThoughtLine, resolveViewedImageAsset, resolveWorkEntryToolPresentation, summarizeToolGroup, @@ -960,3 +961,40 @@ describe("device group summaries", () => { ).toBe("Used 1 tool"); }); }); + +describe("liveThoughtLine", () => { + it("keeps the first sentence, including a closing quote", () => { + expect( + liveThoughtLine( + 'Found the cause: the repo has no commits, so `git worktree add` fails with "invalid reference: main." Now checking the UI.', + ), + ).toBe( + 'Found the cause: the repo has no commits, so git worktree add fails with "invalid reference: main."', + ); + }); + + it("does not cut at dots inside file names or long dashes", () => { + expect( + liveThoughtLine("I read ThreadLaunchService.ts \u2014 it skips the fetch. Next step."), + ).toBe("I read ThreadLaunchService.ts \u2014 it skips the fetch."); + }); + + it("uses a bold-only opening line as the whole line", () => { + expect( + liveThoughtLine("**Narrowing dispatch files**\n\nI should check the adapter. Then more."), + ).toBe("Narrowing dispatch files"); + expect(liveThoughtLine("**Narrowing dispatch files**\r\n\r\nI should check the adapter.")).toBe( + "Narrowing dispatch files", + ); + }); + + it("returns unpunctuated text whole and flattens markdown", () => { + expect(liveThoughtLine("- Checking [the docs](https://x.dev) for **limits**")).toBe( + "Checking the docs for limits", + ); + expect(liveThoughtLine("This is *really* ~~not~~ _fine_ in snake_case_names.")).toBe( + "This is really not fine in snake_case_names.", + ); + expect(liveThoughtLine(" ")).toBe(""); + }); +}); diff --git a/packages/client-runtime/src/work-log/presentation.ts b/packages/client-runtime/src/work-log/presentation.ts index f5e9daab7490..8ef6f58be595 100644 --- a/packages/client-runtime/src/work-log/presentation.ts +++ b/packages/client-runtime/src/work-log/presentation.ts @@ -754,3 +754,23 @@ export function toolGroupSummaryKind( ); return fallbackKinds.size === 1 ? fallbackKinds.values().next().value! : "mixed"; } + +/** + * Plain-text line for the latest thought in the live activity row. A + * bold-only opening line (the Codex summary heading) wins; otherwise this is + * the first sentence of the reasoning text. Web and mobile both render it. + */ +export function liveThoughtLine(markdown: string): string { + const heading = /^\s*\*\*([^*\r\n]+)\*\*[ \t]*\r?(?:\n|$)/.exec(markdown)?.[1]; + const text = (heading ?? markdown) + .replace(/!?\[([^\]]*)\]\([^)]*\)/g, "$1") + .replace(/^[ \t]*(?:#{1,6}|[-*+]|\d+\.)[ \t]+/gm, "") + .replace(/`+|\*\*|~~/g, "") + .replace(/(^|[^\w*])[*_]([^*_\n]+)[*_](?![\w*])/g, "$1$2") + .replace(/\s+/g, " ") + .trim(); + if (heading !== undefined) return text; + // Cut after the first . ? or ! (plus a closing quote or paren) that a space follows. + const end = /[.?!]["'”’)]?(?=\s)/.exec(text); + return end ? text.slice(0, end.index + end[0].length) : text; +} From 300f7f9d45ff19c01bc987dbf1c3bc3e9a3a59ee Mon Sep 17 00:00:00 2001 From: Chris Deeming Date: Wed, 7 Oct 2026 23:29:43 +0100 Subject: [PATCH 17/25] feat(web): block-level Markdown in the rich text composer (#14677) Co-authored-by: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 (1M context) Co-authored-by: Julius Marminge --- apps/web/package.json | 5 + apps/web/src/components/ChatMarkdown.tsx | 81 +- .../components/ComposerPromptEditorTiptap.tsx | 422 ++++++- .../chat/ComposerCodeBlockLanguagePicker.tsx | 121 ++ .../chat/ComposerCodeBlockNodeView.tsx | 67 ++ .../composerCodeBlockHighlight.test.ts | 152 +++ .../components/composerCodeBlockHighlight.ts | 231 ++++ apps/web/src/composer-block-shortcuts.test.ts | 126 +++ apps/web/src/composer-code-block.test.ts | 391 +++++++ apps/web/src/composer-code-block.ts | 254 +++++ apps/web/src/composer-code-languages.test.ts | 59 + apps/web/src/composer-code-languages.ts | 97 ++ .../src/composer-list-continuation.test.ts | 19 +- apps/web/src/composer-list-continuation.ts | 21 +- apps/web/src/composer-list-split.test.ts | 265 +++++ apps/web/src/composer-rich-text-doc.test.ts | 529 +++++++++ apps/web/src/composer-rich-text-doc.ts | 1002 +++++++++++++++-- apps/web/src/index.css | 84 +- docs/internals/composer-editors.md | 15 + docs/user/composer.md | 17 + pnpm-lock.yaml | 15 + 21 files changed, 3798 insertions(+), 175 deletions(-) create mode 100644 apps/web/src/components/chat/ComposerCodeBlockLanguagePicker.tsx create mode 100644 apps/web/src/components/chat/ComposerCodeBlockNodeView.tsx create mode 100644 apps/web/src/components/composerCodeBlockHighlight.test.ts create mode 100644 apps/web/src/components/composerCodeBlockHighlight.ts create mode 100644 apps/web/src/composer-block-shortcuts.test.ts create mode 100644 apps/web/src/composer-code-block.test.ts create mode 100644 apps/web/src/composer-code-block.ts create mode 100644 apps/web/src/composer-code-languages.test.ts create mode 100644 apps/web/src/composer-code-languages.ts create mode 100644 apps/web/src/composer-list-split.test.ts diff --git a/apps/web/package.json b/apps/web/package.json index d17795f15c5d..915466100bcf 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -32,7 +32,12 @@ "@tanstack/react-pacer": "^0.19.4", "@tanstack/react-router": "^1.160.2", "@tiptap/core": "^3.31.3", + "@tiptap/extension-blockquote": "^3.31.3", "@tiptap/extension-code": "^3.31.3", + "@tiptap/extension-code-block": "^3.31.3", + "@tiptap/extension-heading": "^3.31.3", + "@tiptap/extension-horizontal-rule": "^3.31.3", + "@tiptap/extension-list": "^3.31.3", "@tiptap/extension-task-item": "^3.31.3", "@tiptap/extension-task-list": "^3.31.3", "@tiptap/pm": "^3.31.3", diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 42a34d8b5d25..e595efdc5a07 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -989,6 +989,63 @@ function MarkdownDetails({ ); } +/** + * The code block frame, shared with the composer so an editable fence and a + * rendered one cannot drift apart: the wrapper, its header row with the + * language title, an optional actions slot, and the body as children. The + * composer passes Tiptap's node view wrapper as the outer element. + */ +export function MarkdownCodeBlockFrame({ + as: Wrapper = "div", + language, + fenceTitle, + theme, + wrapped = true, + title, + actions, + headerProps, + children, +}: { + as?: React.ElementType; + language: string; + fenceTitle: string | null; + theme: "light" | "dark"; + wrapped?: boolean; + /** Replaces the language title, as the composer does with its language picker. */ + title?: React.ReactNode; + actions?: React.ReactNode; + headerProps?: React.HTMLAttributes; + children: React.ReactNode; +}) { + return ( + +
    + {title ?? ( + + + + )} + {actions} +
    + {children} +
    + ); +} + /** * Filename titles render icon + text; language-only titles render just the * icon (redundant next to its own name) and fall back to the language text @@ -1141,19 +1198,12 @@ function MarkdownCodeBlock({ } return ( -
    -
    - - - + {leadingActions} {canWrap ? ( @@ -1195,9 +1245,10 @@ function MarkdownCodeBlock({ ) : null} {copyButton} -
    + } + > {children} -
    + ); } diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.tsx index bac965c0b97d..6e830991ab7e 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.tsx @@ -1,11 +1,17 @@ -import { Extension, Node, wrappingInputRule, type JSONContent } from "@tiptap/core"; -import { TaskList } from "@tiptap/extension-task-list"; +import { Extension, InputRule, Node, wrappingInputRule, type JSONContent } from "@tiptap/core"; import { ReactNodeViewRenderer, NodeViewWrapper, type NodeViewProps } from "@tiptap/react"; import StarterKit from "@tiptap/starter-kit"; import { type Node as ProseMirrorNode } from "@tiptap/pm/model"; -import { splitBlockKeepMarks } from "@tiptap/pm/commands"; -import { type EditorState, Plugin, PluginKey, TextSelection } from "@tiptap/pm/state"; -import { Decoration, DecorationSet } from "@tiptap/pm/view"; +import { newlineInCode, splitBlockKeepMarks } from "@tiptap/pm/commands"; +import { + type EditorState, + NodeSelection, + Plugin, + PluginKey, + TextSelection, +} from "@tiptap/pm/state"; +import { Decoration, DecorationSet, type EditorView } from "@tiptap/pm/view"; +import type { NodeType, ResolvedPos } from "@tiptap/pm/model"; import type { AssistantCitation, ComposerContextClipboardFragment, @@ -45,19 +51,34 @@ import { } from "~/composer-editor-mentions"; import { buildDocJson, + ComposerBlockExtensions, + ComposerCodeBlockExtension, + ComposerListExtensions, buildTiptapContent, collapsedToFlat, caretTakesMarksBefore, + convertBulletItemToTask, ComposerCodeExtension, ComposerTaskItemExtension, + ComposerTaskListExtension, flatToCollapsed, flatToMarkdown, flatToPm, pmToFlat, serializeEditorDoc, + serializeSelection, + splitOrLiftListItem, stepCaretAcrossStyledEdge, type SkillMeta, } from "~/composer-rich-text-doc"; +import { + convertCodeFenceOnEnter, + exitCodeBlockOnClosingFence, + exitCodeBlockOnTrailingBlankLines, + indentCodeBlock, + indentedNewlineInCodeBlock, + selectionInOneCodeBlock, +} from "~/composer-code-block"; import { COMPOSER_UNDO_GROUP_DELAY, type ComposerChangeKind, @@ -65,6 +86,7 @@ import { markAsClipboardEdit, } from "~/composer-undo-grouping"; import { collectInlineContextIds } from "~/lib/composerContextReferences"; +import { resolveDiffThemeName } from "~/lib/diffRendering"; import { cn, isMacPlatform } from "~/lib/utils"; import { basenameOfPath } from "~/pierre-icons"; import { FileTagChipContent } from "./chat/FileTagChip"; @@ -82,6 +104,8 @@ import { import type { AssistantCitationSourceAnchor } from "~/lib/assistantTextSelection"; import { formatProviderSkillDisplayName } from "@t3tools/client-runtime/providerSkills"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; +import { ComposerCodeBlockNodeView } from "./chat/ComposerCodeBlockNodeView"; +import { composerCodeBlockHighlight } from "./composerCodeBlockHighlight"; import { importPastedComposerText } from "./composerInlineTokenPaste"; import { didComposerSelectionChangeVisibly } from "./composerSelection"; import type { ComposerDraftContextRecords } from "./composerContextPresentation"; @@ -491,6 +515,9 @@ function collectStyledRanges(doc: ProseMirrorNode): StyledRange[] { let range: StyledRange | null = null; let openLength = 0; for (const run of map.runs) { + // A fence is drawn as a block, not revealed a character at a time, so its + // delimiters never become marker widgets. + if (run.nodeName === "codeBlock") continue; if (run.openLen > 0) { range ??= { from: run.pmPos, to: run.pmPos, markers: [] }; range.markers.push({ @@ -518,6 +545,162 @@ function collectStyledRanges(doc: ProseMirrorNode): StyledRange[] { return ranges; } +/** + * A typed marker becomes a list item that remembers the marker it was typed + * with, so the stored Markdown keeps `*` or `3)` rather than a canonical `-`. + * + * `- ` alone is not enough for a dash: it waits for the first character after + * the space, which it carries into the new item. That is what lets the GFM + * task gesture `- [ ] ` or `- [x] ` be typed whole and reach the task rule, + * instead of being cut off by an instant bullet. A `- ` left on its own is + * still a bullet the next time the draft is rebuilt. + */ +function listMarkerInputRule(find: RegExp, listType: "bulletList" | "orderedList"): InputRule { + return new InputRule({ + find, + handler: ({ state, range, match, chain }) => { + const marker = match.groups?.marker ?? "-"; + const space = match.groups?.space ?? " "; + const carried = match.groups?.carried ?? ""; + // Top-level paragraphs only: inside an item or a quote the new list + // would nest under a line the stored draft writes flat. + const $from = state.doc.resolve(range.from); + if ($from.parent.type.name !== "paragraph" || $from.depth !== 1) return null; + const command = chain() + .deleteRange(range) + .wrapInList( + listType, + listType === "orderedList" ? { start: Number.parseInt(marker, 10) || 1 } : {}, + ) + .updateAttributes("listItem", { marker, space }); + (carried ? command.insertContent(carried) : command).run(); + return undefined; + }, + }); +} + +/** + * `[ ] ` at the start of an existing bullet item turns it into a task, for + * items that were already a list when the checkbox was wanted. New tasks are + * typed whole, `- [ ] `, and reach the task rule directly. + */ +const bulletToTaskInputRule = new InputRule({ + find: /^\[([ xX])\] $/, + handler: ({ state, range, match, chain }) => { + const $from = state.doc.resolve(range.from); + const item = $from.node(-1); + if ($from.parent.type.name !== "paragraph" || item?.type.name !== "listItem") return null; + // Any bullet converts; the task grammar only knows `-`, so a `*` or `+` + // item comes back out as `- [ ]`. + if (!["-", "*", "+"].includes((item.attrs as { marker?: string }).marker ?? "")) return null; + const checked = (match[1] ?? " ").toLowerCase() === "x"; + chain() + .command(({ tr }) => { + convertBulletItemToTask(tr, range.from, range.to, checked); + return true; + }) + .run(); + return undefined; + }, +}); + +/** `- [ ] ` or `- [x] ` at a top-level paragraph, for the same reason as the list markers. */ +function taskInputRule(type: NodeType): InputRule { + const rule = wrappingInputRule({ + find: /^- \[([ xX])\] $/, + type, + getAttributes: (match) => ({ checked: match[1]?.toLowerCase() === "x" }), + }); + return new InputRule({ + find: rule.find, + handler: (props) => + props.state.doc.resolve(props.range.from).depth === 1 ? rule.handler(props) : null, + }); +} + +function hasAncestor($pos: ResolvedPos, name: string): boolean { + for (let depth = $pos.depth; depth > 0; depth -= 1) { + if ($pos.node(depth).type.name === name) return true; + } + return false; +} + +/** + * `> ` at the start of a top-level paragraph opens a quote. Not inside a list: + * a quote holds prose lines, and a list item is not one. + */ +const blockquoteInputRule = new InputRule({ + find: /^>(\s)$/, + handler: ({ state, range, match, chain }) => { + const $from = state.doc.resolve(range.from); + if ($from.parent.type.name !== "paragraph" || $from.depth !== 1) return null; + chain() + .deleteRange(range) + .wrapIn("blockquote", { prefix: `>${match[1] ?? " "}` }) + .run(); + return undefined; + }, +}); + +/** + * `---` becomes a rule as the third dash lands; `***` and `___` need a space + * after them so typing bold or an underscore is not interrupted, matching + * Tiptap's own rule. Only at a top-level paragraph: the list and quote + * serializers have no line to write a rule into. The typed characters are + * kept as the rule's source, and `setHorizontalRule` adds a paragraph after a + * rule at the end so the caret has somewhere to go. + */ +const horizontalRuleInputRule = new InputRule({ + find: /^(---|\*\*\*|___)\s?$/, + handler: ({ state, range, match, chain }) => { + const source = match[0] ?? "---"; + if (!source.startsWith("---") && !/\s$/.test(source)) return null; + const $from = state.doc.resolve(range.from); + if ($from.parent.type.name !== "paragraph" || $from.depth !== 1) return null; + chain() + .deleteRange(range) + .setHorizontalRule() + .command(({ tr }) => { + // The rule is the block before the caret's paragraph. + const $pos = tr.selection.$from; + const index = $pos.index(0) - 1; + if (index < 0) return true; + const rulePos = $pos.posAtIndex(index, 0); + const rule = tr.doc.nodeAt(rulePos); + if (rule?.type.name === "horizontalRule") { + tr.setNodeMarkup(rulePos, undefined, { ...rule.attrs, source }); + } + return true; + }) + .run(); + return undefined; + }, +}); + +/** + * `# ` through `###### ` at a top-level paragraph make a heading. The space + * is required, which is exactly what keeps `#1234` a pull request reference + * with its picker rather than a heading. Not inside lists or quotes, whose + * serializers have no line for one. + */ +const headingInputRule = new InputRule({ + find: /^(#{1,6})(\s)$/, + handler: ({ state, range, match, chain }) => { + const $from = state.doc.resolve(range.from); + if ($from.parent.type.name !== "paragraph" || $from.depth !== 1) return null; + chain() + .deleteRange(range) + .setNode("heading", { level: match[1]?.length ?? 1, space: match[2] ?? " " }) + .run(); + return undefined; + }, +}); + +/** Whether the caret sits inside a fenced code block. */ +function isInCodeBlock(view: EditorView): boolean { + return view.state.selection.$from.parent.type.spec.code === true; +} + const MarkerPluginKey = new PluginKey("composer-rich-markers"); const ComposerMarkerPlugin = new Plugin({ @@ -766,16 +949,16 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { expandedCursor: nextExpandedCursor, contextIds: map.contextIds, }; - const cursorAdjacentToMention = + // A fence holds no chips, so nothing in it should summon the mention or + // command menu: `@` in code is a decorator, not a file. Suppressing the + // trigger here also keeps the store from inserting a link the block can + // only show as literal text, which the store would then count as a chip. + const inCodeBlock = updated.state.selection.$from.parent.type.spec.code === true; + const suppressTrigger = + inCodeBlock || isCollapsedCursorAdjacentToInlineToken(nextValue, nextCursor, "left") || isCollapsedCursorAdjacentToInlineToken(nextValue, nextCursor, "right"); - onChangeRef.current( - nextValue, - nextCursor, - nextExpandedCursor, - cursorAdjacentToMention, - map.contextIds, - ); + onChangeRef.current(nextValue, nextCursor, nextExpandedCursor, suppressTrigger, map.contextIds); }, []); const editorAttributes = useMemo( @@ -824,6 +1007,8 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { gapcursor: false, trailingNode: false, code: false, + // The list extensions handle Backspace and Delete themselves. + listKeymap: false, undoRedo: { newGroupDelay: COMPOSER_UNDO_GROUP_DELAY }, // Plain mode has no marks: typed markers stay literal characters. ...(richText ? {} : { bold: false, italic: false, strike: false }), @@ -837,16 +1022,78 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { ...(richText ? [ ComposerCodeExtension, - TaskList, - ComposerTaskItemExtension.extend({ + ComposerCodeBlockExtension.extend({ + addNodeView() { + return ReactNodeViewRenderer(ComposerCodeBlockNodeView, { + // The header holds the language picker. Tiptap only shields + // events aimed at the button element itself, so a click on + // its icon or label would reach ProseMirror and move the + // selection; keep every header event away from the editor. + stopEvent: ({ event }) => + event.target instanceof Element && + event.target.closest(".chat-markdown-codeblock-header") !== null, + }); + }, + // Tiptap's own ``` + space rule would open a fence inside a + // list item or quote, where the serializer has no line for + // it. Enter on a fence line covers the gesture at top level. addInputRules() { - return [ - wrappingInputRule({ - find: /^- \[([ xX])\] $/, - type: this.type, - getAttributes: (match) => ({ checked: match[1]?.toLowerCase() === "x" }), + return []; + }, + }), + composerCodeBlockHighlight({ + resolveTheme: () => + resolveDiffThemeName( + document.documentElement.classList.contains("dark") ? "dark" : "light", + ), + }), + ...ComposerBlockExtensions.map((extension) => + extension.name === "blockquote" + ? extension.extend({ + addInputRules() { + return [blockquoteInputRule]; + }, + }) + : extension.name === "horizontalRule" + ? extension.extend({ + addInputRules() { + return [horizontalRuleInputRule]; + }, + }) + : extension.name === "heading" + ? extension.extend({ + addInputRules() { + return [headingInputRule]; + }, + }) + : extension, + ), + ...ComposerListExtensions.map((extension) => + extension.name === "listItem" + ? extension + : extension.extend({ + addInputRules() { + return this.name === "bulletList" + ? [ + listMarkerInputRule(/^(?[*+])(?\s)$/, "bulletList"), + listMarkerInputRule( + /^(?-)(?[ \t]+)(?[^\s[])$/, + "bulletList", + ), + ] + : [ + listMarkerInputRule( + /^(?\d+[.)])(?\s)$/, + "orderedList", + ), + ]; + }, }), - ]; + ), + ComposerTaskListExtension, + ComposerTaskItemExtension.extend({ + addInputRules() { + return [taskInputRule(this.type), bulletToTaskInputRule]; }, }), ] @@ -982,23 +1229,71 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { event.preventDefault(); return true; } + // Inside a fence Tab belongs to the code, not to the composer's + // focus order or its autocomplete. + if ( + event.key === "Tab" && + !event.metaKey && + !event.ctrlKey && + selectionInOneCodeBlock(view.state) + ) { + event.preventDefault(); + event.stopPropagation(); + return indentCodeBlock(view.state, event.shiftKey ? "out" : "in", (tr) => + view.dispatch(tr), + ); + } + // A fence is multi-line by definition, so Enter and Shift+Enter + // belong to the code rather than to sending or splitting: inside a + // block they make a line (a generic split would cut the fence in + // two), and on a line that is only an opening fence they open the + // block. Sending from inside a fence is still Cmd/Ctrl+Enter, which + // falls through. + if ( + event.key === "Enter" && + richText && + !event.metaKey && + !event.ctrlKey && + !event.isComposing + ) { + if (selectionInOneCodeBlock(view.state)) { + event.preventDefault(); + event.stopPropagation(); + const dispatch = (tr: typeof view.state.tr) => view.dispatch(tr.scrollIntoView()); + return ( + exitCodeBlockOnClosingFence(view.state, dispatch) || + exitCodeBlockOnTrailingBlankLines(view) || + indentedNewlineInCodeBlock(view.state, dispatch) || + newlineInCode(view.state, dispatch) + ); + } + if (convertCodeFenceOnEnter(view.state, (tr) => view.dispatch(tr))) { + event.preventDefault(); + event.stopPropagation(); + return true; + } + } const handler = onCommandKeyDownRef.current; if (event.key === "Enter") { const instance = editorHolder.current; const isTaskItem = richText && (instance?.isActive("taskItem") ?? false); - const handled = handler?.("Enter", event, isTaskItem) ?? false; + const isListItem = richText && (instance?.isActive("listItem") ?? false); + const handled = handler?.("Enter", event, isTaskItem || isListItem) ?? false; if (handled) { event.preventDefault(); event.stopPropagation(); return true; } event.preventDefault(); + if ((isTaskItem || isListItem) && instance && splitOrLiftListItem(instance)) { + return true; + } if ( - isTaskItem && + richText && instance && - (instance.commands.splitListItem("taskItem", { checked: false }) || - (view.state.selection.$from.parent.content.size === 0 && - instance.commands.liftListItem("taskItem"))) + hasAncestor(view.state.selection.$from, "blockquote") && + view.state.selection.$from.parent.content.size === 0 && + instance.commands.lift("blockquote") ) { return true; } @@ -1050,6 +1345,19 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { const pastedText = clipboardData.getData("text/plain"); if (!pastedText) return false; event.preventDefault(); + // A fence takes the clipboard verbatim. Running the markdown path + // here would split the block on newlines, nest a pasted fence and + // build chips the code block's schema cannot hold anyway. + if (isInCodeBlock(view)) { + const { from, to } = view.state.selection; + view.dispatch( + markAsClipboardEdit( + view.state.tr.insertText(pastedText, from, to), + "paste", + ).scrollIntoView(), + ); + return true; + } const importFragment = importFragmentRef.current; let text = importFragment ? importPastedComposerText(clipboardData, importFragment) @@ -1073,18 +1381,44 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { } const editorInstance = editorHolder.current; if (editorInstance) { - insertMarkdownParagraphs(text, skillLabelFor, { styling: richText }, (content) => { - // Tagged on the same transaction insertContent builds, so the - // paste is one undo step of its own. - editorInstance - .chain() - .command(({ tr }) => { - markAsClipboardEdit(tr, "paste"); - return true; - }) - .insertContent(content) - .run(); - }); + // Inside a list item or quote, pasted block markup has nowhere to + // go: it stays literal lines the next rebuild reads back. Inline + // marks still apply. + const $paste = view.state.selection.$from; + const nested = ["listItem", "taskItem", "blockquote"].some((name) => + hasAncestor($paste, name), + ); + insertMarkdownParagraphs( + text, + skillLabelFor, + { styling: richText, blocks: !nested }, + (content) => { + // Tagged on the same transaction insertContent builds, so the + // paste is one undo step of its own. + editorInstance + .chain() + .command(({ tr }) => { + markAsClipboardEdit(tr, "paste"); + return true; + }) + .insertContent(content) + .command(({ tr }) => { + // A paste ending in a rule leaves the rule selected, and + // the next keystroke would replace it. The caret goes to + // the line after it, which is added when there is none. + const { selection } = tr; + if (!(selection instanceof NodeSelection) || !selection.node.isBlock) { + return true; + } + if (!tr.doc.resolve(selection.to).nodeAfter?.isTextblock) { + tr.insert(selection.to, tr.doc.type.schema.nodes.paragraph!.create()); + } + tr.setSelection(TextSelection.create(tr.doc, selection.to + 1)); + return true; + }) + .run(); + }, + ); scrollTiptapCaretIntoView(editorInstance); } return true; @@ -1330,15 +1664,7 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { const clipboardData = event.clipboardData; const { from, to } = editor.state.selection; if (from === to) return; - const { doc, schema } = editor.state; - const slice = doc.slice(from, to); - const first = slice.content.firstChild; - const content = first?.isInline - ? schema.nodes.paragraph!.create(null, slice.content) - : first?.type.name === "taskItem" - ? schema.nodes.taskList!.create(null, slice.content) - : slice.content; - const text = serializeEditorDoc(doc.type.create(null, content)).value; + const text = serializeSelection(editor.state.doc, from, to); const contextIds = Array.from(new Set(collectInlineContextIds(text))); const fragment = contextIds.length > 0 ? build?.(contextIds) : null; event.preventDefault(); @@ -1442,7 +1768,7 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { function insertMarkdownParagraphs( value: string, skillLabelFor: (name: string) => SkillMeta, - options: { styling: boolean }, + options: { styling: boolean; blocks?: boolean }, insertContent: (content: JSONContent[] | JSONContent) => void, ): void { const blocks = buildTiptapContent(value, skillLabelFor, options); diff --git a/apps/web/src/components/chat/ComposerCodeBlockLanguagePicker.tsx b/apps/web/src/components/chat/ComposerCodeBlockLanguagePicker.tsx new file mode 100644 index 000000000000..bf200bab53d8 --- /dev/null +++ b/apps/web/src/components/chat/ComposerCodeBlockLanguagePicker.tsx @@ -0,0 +1,121 @@ +import { CheckIcon, ChevronDownIcon } from "lucide-react"; +import { useMemo, useState } from "react"; + +import { + CODE_BLOCK_LANGUAGES, + type CodeBlockLanguage, + codeLanguageEntry, + codeLanguageLabel, +} from "~/composer-code-languages"; +import { syntheticFileNameForLanguageId } from "~/pierre-icons"; + +import { Button } from "../ui/button"; +import { + Combobox, + ComboboxEmpty, + ComboboxItem, + ComboboxList, + ComboboxPopup, + ComboboxSearchInput, + ComboboxTrigger, +} from "../ui/combobox"; +import { PierreEntryIcon } from "./PierreEntryIcon"; + +/** + * The language's file icon: plain text gets the text-file icon, and a + * language the icon set does not cover gets the default file icon, so every + * entry has one and the labels line up. + */ +function LanguageIcon(props: { language: string; theme: "light" | "dark" }) { + const fileName = props.language ? syntheticFileNameForLanguageId(props.language) : "file.txt"; + return ( + + ); +} + +/** + * The language control in a composer fence's header: shows what the fence + * declares and switches it from a searchable list. A language typed on the + * fence line that the list does not carry is offered as its own entry, so + * the current choice is always visible and checked. + */ +export function ComposerCodeBlockLanguagePicker(props: { + language: string; + theme: "light" | "dark"; + disabled: boolean; + onChange: (language: string) => void; +}) { + const [query, setQuery] = useState(""); + const current = codeLanguageEntry(props.language)?.id ?? props.language; + const items = useMemo( + () => + CODE_BLOCK_LANGUAGES.some((entry) => entry.id === current) + ? [...CODE_BLOCK_LANGUAGES] + : [{ id: current, label: current }, ...CODE_BLOCK_LANGUAGES], + [current], + ); + const filteredItems = useMemo(() => { + const needle = query.trim().toLowerCase(); + if (!needle) return items; + return items.filter( + (entry) => entry.label.toLowerCase().includes(needle) || entry.id.includes(needle), + ); + }, [items, query]); + const selected = items.find((entry) => entry.id === current) ?? null; + + return ( + // The trigger's pill pads its content by 8px (padding plus border) and the + // button pulls a leading icon back 2px; offsetting the difference lines + // the icon up with the code below while keeping the hover pill. + + entry.label} + isItemEqualToValue={(a, b) => a.id === b.id} + value={selected} + onOpenChange={(open) => { + if (!open) setQuery(""); + }} + onValueChange={(entry) => { + if (entry && entry.id !== current) props.onChange(entry.id); + }} + > + + } + > + + {codeLanguageLabel(props.language)} + + + + setQuery(event.target.value)} + /> + No matching languages. + + {(entry: CodeBlockLanguage) => ( + + + {entry.label} + {entry.id === current ? : null} + + )} + + + + + ); +} diff --git a/apps/web/src/components/chat/ComposerCodeBlockNodeView.tsx b/apps/web/src/components/chat/ComposerCodeBlockNodeView.tsx new file mode 100644 index 000000000000..fcaf93af9413 --- /dev/null +++ b/apps/web/src/components/chat/ComposerCodeBlockNodeView.tsx @@ -0,0 +1,67 @@ +import { NodeViewContent, NodeViewWrapper, type NodeViewProps } from "@tiptap/react"; + +import { languageOfInfoString, withInfoStringLanguage } from "~/composer-code-languages"; + +import { useTheme } from "../../hooks/useTheme"; +import { MarkdownCodeBlockFrame } from "../ChatMarkdown"; +import { ComposerCodeBlockLanguagePicker } from "./ComposerCodeBlockLanguagePicker"; + +/** + * Draws a composer fence in the chat view's own code block frame, so a draft + * looks like the message it is about to become, with the language title + * swapped for a picker that rewrites the fence's language in place. + * + * The chrome stops at the language. Chat's wrap and copy buttons act on text + * the reader cannot change; here the text is the draft, and both are already + * a selection away. + */ +export function ComposerCodeBlockNodeView({ + node, + editor, + getPos, + updateAttributes, +}: NodeViewProps) { + const { resolvedTheme } = useTheme(); + const info = typeof node.attrs.language === "string" ? node.attrs.language : ""; + const language = languageOfInfoString(info); + + const changeLanguage = (next: string) => { + updateAttributes({ language: withInfoStringLanguage(info, next) }); + // Back into the code, at its end, the way a picker hands focus back. + const position = getPos(); + if (typeof position === "number") { + editor + .chain() + .focus(position + 1 + node.content.size) + .run(); + } + }; + + return ( + + } + > +
    +
    +          {/* A caret needs a line to sit on even before any code is typed. */}
    +          
    +            as="code"
    +            className="block min-h-[1lh] font-mono whitespace-pre-wrap text-inherit [overflow-wrap:anywhere]"
    +          />
    +        
    +
    +
    + ); +} diff --git a/apps/web/src/components/composerCodeBlockHighlight.test.ts b/apps/web/src/components/composerCodeBlockHighlight.test.ts new file mode 100644 index 000000000000..bd765dbd7b44 --- /dev/null +++ b/apps/web/src/components/composerCodeBlockHighlight.test.ts @@ -0,0 +1,152 @@ +import { Editor } from "@tiptap/core"; +import type { EditorView } from "@tiptap/pm/view"; +import StarterKit from "@tiptap/starter-kit"; +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; + +import { ComposerCodeBlockExtension } from "~/composer-rich-text-doc"; +import type { DiffThemeName } from "~/lib/diffRendering"; +import { getSyntaxHighlighterPromise } from "~/lib/syntaxHighlighting"; + +import { + composerCodeBlockHighlight, + MAX_HIGHLIGHTED_CODE_BLOCK_LENGTH, + shouldHighlightCodeBlock, + tokenizeBlock, +} from "./composerCodeBlockHighlight"; + +vi.mock("~/lib/syntaxHighlighting", () => ({ getSyntaxHighlighterPromise: vi.fn() })); + +const THEME = "github-dark" as DiffThemeName; + +/** + * Stands in for the Shiki highlighter so the offset arithmetic can be checked + * without loading a real grammar. Splits each line into whitespace-delimited + * tokens, which is enough shape for the flattening logic under test. + */ +function fakeHighlighter(colorFor: (content: string) => string | undefined) { + return { + codeToTokens(code: string) { + return { + tokens: code.split(/\r?\n/).map((line) => + line + .split(/(\s+)/) + .filter((part) => part.length > 0) + .map((content) => ({ content, color: colorFor(content) })), + ), + }; + }, + } as unknown as Parameters[0]; +} + +describe("composer code block highlighting", () => { + it("maps token offsets to positions within the block text", () => { + const highlighter = fakeHighlighter((content) => (content === "const" ? "#ff0000" : undefined)); + + const decorations = tokenizeBlock(highlighter, "const answer = 42", "ts", THEME); + + expect(decorations).toEqual([{ from: 0, to: 5, color: "#ff0000" }]); + }); + + it("accounts for the newline between lines", () => { + const highlighter = fakeHighlighter((content) => (content === "two" ? "#00ff00" : undefined)); + + const decorations = tokenizeBlock(highlighter, "one\ntwo", "ts", THEME); + + // "one" is 3 characters, then the newline, so "two" starts at 4. + expect(decorations).toEqual([{ from: 4, to: 7, color: "#00ff00" }]); + }); + + it("accounts for both characters of a CRLF line break", () => { + const highlighter = fakeHighlighter((content) => (content === "two" ? "#00ff00" : undefined)); + + const decorations = tokenizeBlock(highlighter, "one\r\ntwo", "ts", THEME); + + expect(decorations).toEqual([{ from: 5, to: 8, color: "#00ff00" }]); + }); + + it("skips whitespace-only tokens so indentation is not decorated", () => { + const highlighter = fakeHighlighter(() => "#0000ff"); + + const decorations = tokenizeBlock(highlighter, " indented", "ts", THEME); + + expect(decorations).toEqual([{ from: 2, to: 10, color: "#0000ff" }]); + }); + + it("falls back to plain text when the grammar throws", () => { + const throwing = { + codeToTokens() { + throw new Error("unsupported language"); + }, + } as unknown as Parameters[0]; + + expect(tokenizeBlock(throwing, "const answer = 42", "nope", THEME)).toEqual([]); + }); +}); + +describe("the highlighting size cap", () => { + // Each keystroke re-tokenizes the whole fence on the main thread; past a + // few hundred lines that stalls typing, so large fences stay plain. + it("highlights a fence up to the cap", () => { + expect(shouldHighlightCodeBlock("x".repeat(MAX_HIGHLIGHTED_CODE_BLOCK_LENGTH))).toBe(true); + }); + + it("leaves a fence past the cap plain", () => { + expect(shouldHighlightCodeBlock("x".repeat(MAX_HIGHLIGHTED_CODE_BLOCK_LENGTH + 1))).toBe(false); + }); +}); + +describe("a highlighter that fails to load", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + /** + * Shiki can fail to initialize at all. The blocks it could not reach stay + * plain, the ones it could are still painted, and nothing is left rejected. + */ + it("still repaints the blocks whose highlighter loaded", async () => { + vi.mocked(getSyntaxHighlighterPromise).mockImplementation((language) => + language === "ts" + ? Promise.resolve(fakeHighlighter(() => "#ff0000")) + : Promise.reject(new Error("Shiki failed to initialize")), + ); + const editor = new Editor({ + extensions: [ + StarterKit.configure({ codeBlock: false, trailingNode: false }), + ComposerCodeBlockExtension, + composerCodeBlockHighlight({ resolveTheme: () => THEME }), + ], + content: { + type: "doc", + content: [ + { type: "codeBlock", attrs: { language: "ts" }, content: [{ type: "text", text: "a" }] }, + { type: "codeBlock", attrs: { language: "py" }, content: [{ type: "text", text: "b" }] }, + ], + }, + }); + // The plugin watches for theme changes; the unit suite has no DOM. + vi.stubGlobal("document", { documentElement: {} }); + vi.stubGlobal( + "MutationObserver", + class { + observe() {} + disconnect() {} + }, + ); + // ProseMirror names each plugin after its key, though it does not type it. + const plugin = editor.extensionManager.plugins.find((candidate) => + (candidate as unknown as { key: string }).key.startsWith("composerCodeBlockHighlight"), + ); + const repainted = new Promise((resolve) => { + const view = { + get state() { + return editor.state; + }, + dispatch: () => resolve(), + } as unknown as EditorView; + plugin?.spec.view?.(view); + }); + + await expect(repainted).resolves.toBeUndefined(); + }); +}); diff --git a/apps/web/src/components/composerCodeBlockHighlight.ts b/apps/web/src/components/composerCodeBlockHighlight.ts new file mode 100644 index 000000000000..68f024cb907a --- /dev/null +++ b/apps/web/src/components/composerCodeBlockHighlight.ts @@ -0,0 +1,231 @@ +import { Extension } from "@tiptap/core"; +import { type EditorState, Plugin, PluginKey } from "@tiptap/pm/state"; +import { Decoration, DecorationSet } from "@tiptap/pm/view"; +import type { Node as ProseMirrorNode } from "@tiptap/pm/model"; + +import type { DiffThemeName } from "~/lib/diffRendering"; +import { languageOfInfoString } from "~/composer-code-languages"; +import { getSyntaxHighlighterPromise } from "~/lib/syntaxHighlighting"; + +interface HighlightedBlock { + /** Guards against repainting a block whose text and language are unchanged. */ + readonly signature: string; + readonly decorations: ReadonlyArray<{ from: number; to: number; color: string }>; +} + +const composerCodeBlockHighlightKey = new PluginKey("composerCodeBlockHighlight"); + +/** + * Past this many characters a fence is left plain. Any edit changes a block's + * signature, so each keystroke re-tokenizes the whole block synchronously on + * the main thread: a few milliseconds for a couple of hundred lines, but ~80ms + * at a thousand, which stalls typing. 20k characters is roughly 400–500 lines + * of ordinary code; a fence that shrinks back under it is highlighted again. + */ +export const MAX_HIGHLIGHTED_CODE_BLOCK_LENGTH = 20_000; + +export function shouldHighlightCodeBlock(code: string): boolean { + return code.length <= MAX_HIGHLIGHTED_CODE_BLOCK_LENGTH; +} +function blockSignature(node: ProseMirrorNode, theme: DiffThemeName): string { + // A separator that cannot occur in a language name keeps the parts distinct. + return [theme, String(node.attrs.language ?? ""), node.textContent].join("\u0000"); +} + +/** + * The composer's code blocks, which are always top-level: list items and + * quotes cannot hold one. Scanning only the top level keeps a keystroke in a + * long draft from walking every node in it. + */ +function collectCodeBlocks(state: EditorState): Array<{ node: ProseMirrorNode; pos: number }> { + const blocks: Array<{ node: ProseMirrorNode; pos: number }> = []; + state.doc.forEach((node, pos) => { + if (node.type.name === "codeBlock") blocks.push({ node, pos }); + }); + return blocks; +} + +/** + * Highlights composer code blocks with the same Shiki instance the chat view + * uses, painted as inline decorations so the text nodes stay editable. + * + * Highlighting is asynchronous and per block: a keystroke re-tokenizes only the + * block that changed, and only after its highlighter has resolved. Blocks whose + * text and language are unchanged reuse their previous decorations. + */ +export function composerCodeBlockHighlight(options: { + resolveTheme: () => DiffThemeName; +}): Extension { + return Extension.create({ + name: "composerCodeBlockHighlight", + + addProseMirrorPlugins() { + // Every keystroke in a code block mints a new signature, so each scan + // drops the signatures the document no longer holds. The cache is then + // bounded by the document itself, and no block it holds is ever evicted. + const cache = new Map(); + // The latest scan's signatures, so a tokenize that resolves after its + // block was edited away does not put it back. + let live = new Set(); + + return [ + new Plugin({ + key: composerCodeBlockHighlightKey, + + state: { + init: () => DecorationSet.empty, + apply(transaction, value, _oldState, newState) { + if (!transaction.docChanged && !transaction.getMeta(composerCodeBlockHighlightKey)) { + return value.map(transaction.mapping, transaction.doc); + } + return buildDecorations(newState, cache, options.resolveTheme()); + }, + }, + + props: { + decorations(state) { + return composerCodeBlockHighlightKey.getState(state); + }, + }, + + view(view) { + let disposed = false; + let paintedTheme = options.resolveTheme(); + let scannedDoc: ProseMirrorNode | null = null; + + /** + * Tokenizes any block missing from the cache, then repaints once. + * Returning early when nothing is pending is what stops the + * dispatch below from re-triggering this on its own update. + */ + const refresh = () => { + const theme = options.resolveTheme(); + const themeChanged = theme !== paintedTheme; + paintedTheme = theme; + // A selection change cannot alter what needs tokenizing, and + // building a signature means concatenating every block's text. + if (!themeChanged && view.state.doc === scannedDoc) return; + scannedDoc = view.state.doc; + const blocks = collectCodeBlocks(view.state); + live = new Set(blocks.map(({ node }) => blockSignature(node, theme))); + for (const signature of cache.keys()) { + if (!live.has(signature)) cache.delete(signature); + } + const pending = blocks.filter( + ({ node }) => + shouldHighlightCodeBlock(node.textContent) && + !cache.has(blockSignature(node, theme)), + ); + if (pending.length === 0) { + // A theme switch keeps every signature but changes which one + // applies, so the painted decorations still have to be rebuilt. + if (themeChanged) { + view.dispatch(view.state.tr.setMeta(composerCodeBlockHighlightKey, true)); + } + return; + } + + // Settled rather than all: a block whose highlighter fails to + // load stays plain, and the others are still painted. + void Promise.allSettled( + pending.map(async ({ node }) => { + // The stored info string keeps everything after the language + // (`js title=x`); Shiki wants only the name. + const language = + languageOfInfoString(String(node.attrs.language ?? "")) || "text"; + const signature = blockSignature(node, theme); + const highlighter = await getSyntaxHighlighterPromise(language); + if (disposed || cache.has(signature) || !live.has(signature)) return; + cache.set(signature, { + signature, + decorations: tokenizeBlock(highlighter, node.textContent, language, theme), + }); + }), + ).then(() => { + if (disposed) return; + view.dispatch(view.state.tr.setMeta(composerCodeBlockHighlightKey, true)); + }); + }; + + refresh(); + // The theme lives on , outside the editor, so nothing would + // otherwise tell the view a repaint is due. + const themeObserver = new MutationObserver(refresh); + themeObserver.observe(document.documentElement, { + attributeFilter: ["class"], + }); + + return { + update: refresh, + destroy() { + disposed = true; + themeObserver.disconnect(); + }, + }; + }, + }), + ]; + }, + }); +} + +type Highlighter = Awaited>; + +/** Flattens Shiki's line/token structure into offsets within the block's text. */ +export function tokenizeBlock( + highlighter: Highlighter, + code: string, + language: string, + theme: DiffThemeName, +): ReadonlyArray<{ from: number; to: number; color: string }> { + let tokens; + try { + tokens = highlighter.codeToTokens(code, { lang: language, theme }).tokens; + } catch { + // An unsupported language should read as plain text, not break the editor. + return []; + } + + const decorations: Array<{ from: number; to: number; color: string }> = []; + let offset = 0; + for (const [lineIndex, line] of tokens.entries()) { + // The separator between lines, which Shiki drops; CRLF is two characters. + if (lineIndex > 0) offset += code.startsWith("\r\n", offset) ? 2 : 1; + for (const token of line) { + const length = token.content.length; + if (token.color && token.content.trim()) { + decorations.push({ from: offset, to: offset + length, color: token.color }); + } + offset += length; + } + } + return decorations; +} + +function buildDecorations( + state: EditorState, + cache: Map, + theme: DiffThemeName, +): DecorationSet { + const decorations: Decoration[] = []; + for (const { node, pos } of collectCodeBlocks(state)) { + if (!shouldHighlightCodeBlock(node.textContent)) continue; + const highlighted = cache.get(blockSignature(node, theme)); + if (!highlighted) continue; + // Token offsets index the block's text, which lines up with document + // positions only because `codeBlock` is `text*` with no marks — one + // unmarked text node, so `content.size === textContent.length`. Allowing + // marks or hard breaks in the code block would desync these silently. + if (node.content.size !== node.textContent.length) continue; + // +1 steps past the code block's opening token into its text. + const start = pos + 1; + for (const decoration of highlighted.decorations) { + decorations.push( + Decoration.inline(start + decoration.from, start + decoration.to, { + style: `color:${decoration.color}`, + }), + ); + } + } + return DecorationSet.create(state.doc, decorations); +} diff --git a/apps/web/src/composer-block-shortcuts.test.ts b/apps/web/src/composer-block-shortcuts.test.ts new file mode 100644 index 000000000000..8ecd47d0e299 --- /dev/null +++ b/apps/web/src/composer-block-shortcuts.test.ts @@ -0,0 +1,126 @@ +import { Editor } from "@tiptap/core"; +import { TextSelection } from "@tiptap/pm/state"; +import StarterKit from "@tiptap/starter-kit"; +import { describe, expect, it } from "vite-plus/test"; + +import { + buildDocJson, + ComposerBlockExtensions, + ComposerCodeBlockExtension, + ComposerListExtensions, + ComposerTaskItemExtension, + ComposerTaskListExtension, + serializeEditorDoc, +} from "./composer-rich-text-doc"; + +// The same modifier ProseMirror's keymap reads "Mod" as. +const mac = typeof navigator !== "undefined" && /Mac|iP(hone|[oa]d)/.test(navigator.platform); + +/** A key chord as the browser reports it: key, keyCode and modifiers. */ +function chord(spec: string) { + const parts = spec.split("-"); + const key = parts.pop()!; + const has = (name: string) => parts.includes(name); + const base = key.length === 1 ? key.toUpperCase() : key; + return { + key: has("Shift") && /^\d$/.test(key) ? "!@#$%^&*("[Number(key) - 1]! : key, + keyCode: base.length === 1 ? base.charCodeAt(0) : 0, + shiftKey: has("Shift"), + altKey: has("Alt"), + metaKey: has("Mod") && mac, + ctrlKey: has("Mod") && !mac, + preventDefault() {}, + stopPropagation() {}, + } as unknown as KeyboardEvent; +} + +/** + * Presses `spec` with the caret inside `value`, the way the composer's + * editor would receive it, and returns the stored Markdown. + */ +function press(value: string, spec: string) { + const editor = new Editor({ + extensions: [ + StarterKit.configure({ + blockquote: false, + bulletList: false, + codeBlock: false, + heading: false, + horizontalRule: false, + listItem: false, + orderedList: false, + trailingNode: false, + }), + ComposerCodeBlockExtension, + ...ComposerBlockExtensions, + ...ComposerListExtensions, + ComposerTaskListExtension, + ComposerTaskItemExtension, + ], + content: buildDocJson(value, (name) => ({ label: name, description: null })), + }); + editor.view.dispatch(editor.state.tr.setSelection(TextSelection.atEnd(editor.state.doc))); + // What the view's `someProp("handleKeyDown")` does, without mounting one: + // offer the key to each extension plugin in order until one handles it. + // An unmounted editor's state does not carry them yet, so they come from + // the extension manager. + const event = chord(spec); + const before = editor.state.doc; + const handled = editor.extensionManager.plugins.some((plugin) => + plugin.props.handleKeyDown?.call(plugin, editor.view, event), + ); + return { + handled, + changed: !editor.state.doc.eq(before), + value: serializeEditorDoc(editor.state.doc).value, + }; +} + +// Tiptap's own block shortcuts would nest a list, task list, code block or +// heading inside a quote, or restructure a list item. The serializers only +// write what the composer's grammar has, so the nested block, and the text +// in it, would vanish from the stored draft. None of these are the +// composer's shortcuts; they must do nothing. +const BLOCK_CHORDS = [ + "Mod-Shift-8", + "Mod-Shift-7", + "Mod-Shift-9", + "Mod-Shift-b", + "Mod-Alt-c", + "Mod-Alt-1", + "Mod-Alt-3", +]; + +describe("Tiptap's block shortcuts", () => { + it.each(BLOCK_CHORDS)("leave a quote's text alone on %s", (spec) => { + expect(press("> keep this text", spec).value).toBe("> keep this text"); + }); + + it.each(BLOCK_CHORDS)("leave a list item alone on %s", (spec) => { + expect(press("- item", spec).value).toBe("- item"); + }); + + it.each(BLOCK_CHORDS)("leave a paragraph alone on %s", (spec) => { + expect(press("plain text", spec).value).toBe("plain text"); + }); + + // Guards the harness itself: a chord the composer keeps must still reach + // the keymap, or every case above would pass without pressing anything. + it("still delivers the composer's own keys", () => { + expect(press("**bold**", "Mod-b").handled).toBe(true); + }); + + // Sinking or lifting an item moves it without touching its `indent`, so + // the stored draft keeps the old nesting and the next rebuild undoes the + // move. Nesting belongs to the composer's own Tab, which edits the source. + it.each([ + ["- p\n- a", "Tab"], + ["- p\n - a", "Shift-Tab"], + ["- [ ] p\n- [ ] a", "Tab"], + ["- [ ] p\n - [ ] a", "Shift-Tab"], + ])("leave the nesting of %j alone on %s", (value, spec) => { + const result = press(value, spec); + expect(result.changed).toBe(false); + expect(result.value).toBe(value); + }); +}); diff --git a/apps/web/src/composer-code-block.test.ts b/apps/web/src/composer-code-block.test.ts new file mode 100644 index 000000000000..3a0ec2fe127c --- /dev/null +++ b/apps/web/src/composer-code-block.test.ts @@ -0,0 +1,391 @@ +import { describe, expect, it } from "vite-plus/test"; +import { Editor, Node } from "@tiptap/core"; +import { newlineInCode } from "@tiptap/pm/commands"; +import { TextSelection } from "@tiptap/pm/state"; + +import { + CODE_BLOCK_INDENT, + indentCodeBlock, + indentLines, + convertCodeFenceOnEnter, + exitCodeBlockOnClosingFence, + exitCodeBlockOnTrailingBlankLines, + indentedNewlineInCodeBlock, + leadingWhitespace, + outdentLine, +} from "./composer-code-block"; +import StarterKit from "@tiptap/starter-kit"; + +import { + ComposerCodeBlockExtension, + ComposerListExtensions, + serializeEditorDoc, +} from "./composer-rich-text-doc"; + +const extensions = [ + StarterKit.configure({ codeBlock: false, trailingNode: false }), + ComposerCodeBlockExtension, + ...ComposerListExtensions, + Node.create({ + name: "composer-mention", + group: "inline", + inline: true, + atom: true, + addAttributes: () => ({ path: { default: "" }, source: { default: "" } }), + }), +]; + +describe("leadingWhitespace", () => { + it("reads the indent a new line should inherit", () => { + expect(leadingWhitespace(" nested()")).toBe(" "); + expect(leadingWhitespace("\tnested()")).toBe("\t"); + expect(leadingWhitespace("flush()")).toBe(""); + }); + + it("stops at the first non-space so code is never treated as indent", () => { + expect(leadingWhitespace(" a b")).toBe(" "); + }); +}); + +describe("outdentLine", () => { + it("removes a full indent when there is one", () => { + expect(outdentLine(" deep")).toBe(" deep"); + }); + + /** Hand-indented lines should still outdent rather than refuse to move. */ + it("falls back to a single space or tab", () => { + expect(outdentLine(" odd")).toBe("odd"); + expect(outdentLine("\ttabbed")).toBe("tabbed"); + }); + + it("bottoms out instead of eating code", () => { + expect(outdentLine("flush()")).toBe("flush()"); + }); +}); + +describe("indentLines", () => { + it("indents every line it is given", () => { + expect(indentLines(["a", "b"], "in")).toEqual([ + `${CODE_BLOCK_INDENT}a`, + `${CODE_BLOCK_INDENT}b`, + ]); + }); + + /** Indenting a blank line would leave whitespace the user cannot see. */ + it("leaves blank lines alone", () => { + expect(indentLines(["a", "", "b"], "in")).toEqual([ + `${CODE_BLOCK_INDENT}a`, + "", + `${CODE_BLOCK_INDENT}b`, + ]); + }); + + it("outdents every line it is given", () => { + expect(indentLines([" a", " b", "c"], "out")).toEqual([" a", "b", "c"]); + }); +}); + +/** Builds an editor holding a single fenced block, with the caret placed by offset. */ +function codeEditor(code: string, at?: { from: number; to?: number }) { + const editor = new Editor({ + extensions, + content: { + type: "doc", + content: [{ type: "codeBlock", content: [{ type: "text", text: code }] }], + }, + }); + const start = 1; + const from = start + (at?.from ?? code.length); + const to = start + (at?.to ?? at?.from ?? code.length); + editor.view.dispatch( + editor.state.tr.setSelection(TextSelection.create(editor.state.doc, from, to)), + ); + return editor; +} + +const textOf = (editor: Editor) => editor.state.doc.textContent; + +describe("indentedNewlineInCodeBlock", () => { + it("carries the current indentation onto the new line", () => { + const editor = codeEditor("function f() {\n body()"); + const handled = indentedNewlineInCodeBlock(editor.state, (tr) => editor.view.dispatch(tr)); + expect(handled).toBe(true); + expect(textOf(editor)).toBe("function f() {\n body()\n "); + }); + + /** + * With no indent to carry there is nothing to add, so the default newline + * should handle it and stay a single undo step. + */ + it("declines an unindented line", () => { + const editor = codeEditor("flush()"); + expect(indentedNewlineInCodeBlock(editor.state, (tr) => editor.view.dispatch(tr))).toBe(false); + expect(textOf(editor)).toBe("flush()"); + }); + + it("indents from the line the caret is on, not the last line", () => { + const editor = codeEditor(" deep()\nflush()", { from: 10 }); + indentedNewlineInCodeBlock(editor.state, (tr) => editor.view.dispatch(tr)); + expect(textOf(editor)).toBe(" deep()\n \nflush()"); + }); + + it("replaces the selected text rather than keeping it", () => { + const editor = codeEditor(" keep()DROP", { from: 8, to: 12 }); + indentedNewlineInCodeBlock(editor.state, (tr) => editor.view.dispatch(tr)); + expect(textOf(editor)).toBe(" keep()\n "); + }); + + it("does nothing outside a code block", () => { + const editor = new Editor({ + extensions, + content: { + type: "doc", + content: [{ type: "paragraph", content: [{ type: "text", text: " hi" }] }], + }, + }); + expect(indentedNewlineInCodeBlock(editor.state, (tr) => editor.view.dispatch(tr))).toBe(false); + }); +}); + +/** Enter inside a fence, in the order the composer's key handler tries it. */ +function pressEnter(editor: Editor) { + const dispatch = (tr: Parameters[0]) => editor.view.dispatch(tr); + return ( + exitCodeBlockOnTrailingBlankLines(editor.view) || + indentedNewlineInCodeBlock(editor.state, dispatch) || + newlineInCode(editor.state, dispatch) + ); +} + +describe("exitCodeBlockOnTrailingBlankLines", () => { + it("leaves the block once two blank lines end it", () => { + const editor = codeEditor("flush()"); + pressEnter(editor); + pressEnter(editor); + expect(editor.state.doc.childCount).toBe(1); + pressEnter(editor); + expect(editor.state.doc.firstChild?.textContent).toBe("flush()"); + expect(editor.state.selection.$from.parent.type.name).toBe("paragraph"); + }); + + /** Enter carries indentation, so the blank lines after nested code are not empty. */ + it("counts lines holding only carried indentation as blank", () => { + const editor = codeEditor(" nested()"); + pressEnter(editor); + pressEnter(editor); + expect(editor.state.doc.childCount).toBe(1); + pressEnter(editor); + expect(editor.state.doc.firstChild?.textContent).toBe(" nested()"); + expect(editor.state.selection.$from.parent.type.name).toBe("paragraph"); + }); + + /** An unclosed fence runs to the end of the draft, so leaving it must close it. */ + it("closes an unclosed fence on the way out", () => { + const editor = codeEditor("flush()"); + editor.view.dispatch(editor.state.tr.setNodeAttribute(0, "close", "")); + pressEnter(editor); + pressEnter(editor); + pressEnter(editor); + expect(editor.state.doc.firstChild?.attrs.close).toBe("\n```"); + }); + + it("stays in the block when the caret is not at its end", () => { + const editor = codeEditor("a\n\n\nb", { from: 3 }); + expect(exitCodeBlockOnTrailingBlankLines(editor.view)).toBe(false); + }); +}); + +describe("exitCodeBlockOnClosingFence", () => { + const exit = (editor: Editor) => + exitCodeBlockOnClosingFence(editor.state, (tr) => editor.view.dispatch(tr)); + + it("ends the block at a typed closing fence and starts a line after it", () => { + const editor = codeEditor("const a = 1\n```"); + editor.view.dispatch(editor.state.tr.setNodeAttribute(0, "language", "ts")); + expect(exit(editor)).toBe(true); + editor.view.dispatch(editor.state.tr.insertText("explain this")); + expect(serializeEditorDoc(editor.state.doc).value).toBe( + "```ts\nconst a = 1\n```\nexplain this", + ); + }); + + it("moves the lines after the fence out of the block", () => { + const editor = codeEditor("a\n```\nb", { from: "a\n```".length }); + expect(exit(editor)).toBe(true); + expect(serializeEditorDoc(editor.state.doc).value).toBe("```\na\n```\n\nb"); + expect(editor.state.selection.$from.parent.type.name).toBe("paragraph"); + expect(editor.state.selection.$from.parent.textContent).toBe(""); + }); + + it.each([ + ["a line that cannot close the fence", "a\n``", undefined], + ["a caret before the end of the line", "a\n```", { from: "a\n``".length }], + ])("ignores %s", (_, code, at) => { + expect(exit(codeEditor(code, at))).toBe(false); + }); +}); + +describe("indentCodeBlock", () => { + it("inserts an indent at a collapsed caret", () => { + const editor = codeEditor("ab", { from: 1 }); + expect(indentCodeBlock(editor.state, "in", (tr) => editor.view.dispatch(tr))).toBe(true); + expect(textOf(editor)).toBe(`a${CODE_BLOCK_INDENT}b`); + }); + + it("indents every line a selection touches", () => { + const editor = codeEditor("one\ntwo\nthree", { from: 1, to: 9 }); + indentCodeBlock(editor.state, "in", (tr) => editor.view.dispatch(tr)); + expect(textOf(editor)).toBe(" one\n two\n three"); + }); + + /** A selection of whole lines ends at the start of the next one, which it does not touch. */ + it("leaves the line a selection ends at the start of", () => { + const editor = codeEditor("one\ntwo", { from: 0, to: 4 }); + indentCodeBlock(editor.state, "in", (tr) => editor.view.dispatch(tr)); + expect(textOf(editor)).toBe(" one\ntwo"); + }); + + it("outdents every line a selection touches", () => { + const editor = codeEditor(" one\n two", { from: 2, to: 9 }); + indentCodeBlock(editor.state, "out", (tr) => editor.view.dispatch(tr)); + expect(textOf(editor)).toBe("one\ntwo"); + }); + + /** Repeated Tab presses should keep working on the same block. */ + it("keeps the selection across the shifted lines", () => { + const editor = codeEditor("one\ntwo", { from: 0, to: 7 }); + indentCodeBlock(editor.state, "in", (tr) => editor.view.dispatch(tr)); + indentCodeBlock(editor.state, "in", (tr) => editor.view.dispatch(tr)); + expect(textOf(editor)).toBe(" one\n two"); + }); + + it("reports handled when outdenting can go no further, so Tab never escapes the editor", () => { + const editor = codeEditor("one\ntwo", { from: 0, to: 7 }); + expect(indentCodeBlock(editor.state, "out", (tr) => editor.view.dispatch(tr))).toBe(true); + expect(textOf(editor)).toBe("one\ntwo"); + }); + + it("does nothing outside a code block", () => { + const editor = new Editor({ + extensions, + content: { + type: "doc", + content: [{ type: "paragraph", content: [{ type: "text", text: "hi" }] }], + }, + }); + expect(indentCodeBlock(editor.state, "in", (tr) => editor.view.dispatch(tr))).toBe(false); + }); +}); + +describe("selections across two identical fences", () => { + /** Two structurally equal code blocks; the selection spans from one into the other. */ + function twoBlockEditor() { + const block = { type: "codeBlock", content: [{ type: "text", text: " a" }] }; + const editor = new Editor({ extensions, content: { type: "doc", content: [block, block] } }); + // First block: 1..4 (text " a" occupies 1..4); second block starts at 5. + editor.view.dispatch( + editor.state.tr.setSelection(TextSelection.create(editor.state.doc, 2, 7)), + ); + return editor; + } + + it("leaves Tab and Enter alone rather than editing through the boundary", () => { + const editor = twoBlockEditor(); + const before = editor.getJSON(); + expect(indentCodeBlock(editor.state, "in", (tr) => editor.view.dispatch(tr))).toBe(false); + expect(indentedNewlineInCodeBlock(editor.state, (tr) => editor.view.dispatch(tr))).toBe(false); + expect(editor.getJSON()).toEqual(before); + }); +}); + +describe("convertCodeFenceOnEnter", () => { + /** Builds an editor holding one paragraph with the caret at its end. */ + function paragraphEditor(text: string) { + const editor = new Editor({ + extensions, + content: { type: "doc", content: [{ type: "paragraph", content: [{ type: "text", text }] }] }, + }); + editor.view.dispatch( + editor.state.tr.setSelection(TextSelection.create(editor.state.doc, 1 + text.length)), + ); + return editor; + } + + it.each([ + ["```", "```", ""], + ["```ts", "```", "ts"], + ["~~~py", "~~~", "py"], + ["````", "````", ""], + ["```ts title=example", "```", "ts title=example"], + ["``` js", "```", " js"], + ])("turns %s into a code block", (text, fence, language) => { + const editor = paragraphEditor(text); + expect(convertCodeFenceOnEnter(editor.state, (tr) => editor.view.dispatch(tr))).toBe(true); + const block = editor.state.doc.firstChild!; + expect(block.type.name).toBe("codeBlock"); + expect(block.attrs).toMatchObject({ fence, language, close: `\n${fence}` }); + expect(block.textContent).toBe(""); + // The caret belongs inside the new block, ready for the first line. + expect(editor.state.selection.from).toBe(1); + }); + + it.each(["``", "text ```", "```a`b"])("leaves %s alone", (text) => { + const editor = paragraphEditor(text); + expect(convertCodeFenceOnEnter(editor.state, (tr) => editor.view.dispatch(tr))).toBe(false); + }); + + it("refuses to open a fence inside a list item", () => { + const text = "```ts"; + const editor = new Editor({ + extensions, + content: { + type: "doc", + content: [ + { + type: "bulletList", + content: [ + { + type: "listItem", + content: [{ type: "paragraph", content: [{ type: "text", text }] }], + }, + ], + }, + ], + }, + }); + // Inside listItem > paragraph: position 3 is the paragraph start. + editor.view.dispatch( + editor.state.tr.setSelection(TextSelection.create(editor.state.doc, 3 + text.length)), + ); + const before = editor.getJSON(); + expect(convertCodeFenceOnEnter(editor.state, (tr) => editor.view.dispatch(tr))).toBe(false); + expect(editor.getJSON()).toEqual(before); + }); + + it("keeps a chip's source in the info string", () => { + const editor = new Editor({ + extensions, + content: { + type: "doc", + content: [ + { + type: "paragraph", + content: [ + { type: "text", text: "```ts " }, + { type: "composer-mention", attrs: { path: "a.md", source: "@a.md" } }, + ], + }, + ], + }, + }); + editor.view.dispatch(editor.state.tr.setSelection(TextSelection.atEnd(editor.state.doc))); + expect(convertCodeFenceOnEnter(editor.state, (tr) => editor.view.dispatch(tr))).toBe(true); + expect(editor.state.doc.firstChild?.attrs.language).toBe("ts @a.md"); + }); + + it("ignores a fence with the caret before its end", () => { + const editor = paragraphEditor("```ts"); + editor.view.dispatch(editor.state.tr.setSelection(TextSelection.create(editor.state.doc, 3))); + expect(convertCodeFenceOnEnter(editor.state, (tr) => editor.view.dispatch(tr))).toBe(false); + }); +}); diff --git a/apps/web/src/composer-code-block.ts b/apps/web/src/composer-code-block.ts new file mode 100644 index 000000000000..f4f28765f76c --- /dev/null +++ b/apps/web/src/composer-code-block.ts @@ -0,0 +1,254 @@ +import { exitCode } from "@tiptap/pm/commands"; +import type { EditorState, Transaction } from "@tiptap/pm/state"; +import { TextSelection } from "@tiptap/pm/state"; +import type { EditorView } from "@tiptap/pm/view"; + +import { isClosingFence, parseOpeningFence, serializeSelection } from "~/composer-rich-text-doc"; + +/** + * Spaces rather than a tab: the fence round-trips through Markdown on its way + * to the agent, and a literal tab there renders at whatever width the reader + * happens to use. Two matches how this repo indents its own source. + */ +export const CODE_BLOCK_INDENT = " "; + +/** The leading run of spaces and tabs on a line, which Enter carries forward. */ +export function leadingWhitespace(line: string): string { + return /^[ \t]*/.exec(line)?.[0] ?? ""; +} + +/** + * Removes one indent's worth of leading whitespace, tolerating lines that were + * indented by hand with an odd number of spaces or with tabs. A line with no + * leading whitespace is returned unchanged, so outdenting bottoms out quietly + * rather than eating the first character of code. + */ +export function outdentLine(line: string): string { + if (line.startsWith(CODE_BLOCK_INDENT)) return line.slice(CODE_BLOCK_INDENT.length); + if (line.startsWith("\t")) return line.slice(1); + if (line.startsWith(" ")) return line.slice(1); + return line; +} + +/** Applies Tab/Shift+Tab to every line a selection touches, whole lines at a time. */ +export function indentLines(lines: ReadonlyArray, direction: "in" | "out"): string[] { + return lines.map((line) => { + // Indenting a blank line would leave trailing whitespace behind on a line + // the user cannot see the caret move on, so leave it be. + if (direction === "in") return line.length === 0 ? line : `${CODE_BLOCK_INDENT}${line}`; + return outdentLine(line); + }); +} + +/** Whether every endpoint of the selection sits in the same code block. */ +export function selectionInOneCodeBlock(state: EditorState): boolean { + const { $from, $to } = state.selection; + return $from.parent.type.spec.code === true && $from.sameParent($to); +} + +/** + * The code block containing the selection, or null when the selection is + * outside a fence or spans out of one. Both ends must sit in the same block: a + * selection that reaches past the fence belongs to the document, not to the + * code. + */ +function codeBlockRange( + state: EditorState, +): { readonly from: number; readonly to: number; readonly text: string } | null { + const { $from, $to } = state.selection; + const parent = $from.parent; + if (parent.type.spec.code !== true) return null; + // Same occurrence, not merely equal structure: two identical fences would + // otherwise let a selection across them edit through the block boundary. + if (!$from.sameParent($to)) return null; + const from = $from.start(); + return { from, to: from + parent.content.size, text: parent.textContent }; +} + +/** + * Enter inside a fence, carrying the current line's indentation onto the new + * line. ProseMirror's default splits the text but starts the new line at column + * zero, which means re-typing the indent on every line of a nested block. + */ +export function indentedNewlineInCodeBlock( + state: EditorState, + dispatch?: (transaction: Transaction) => void, +): boolean { + const block = codeBlockRange(state); + if (!block) return false; + + const { from, to } = state.selection; + const beforeCursor = block.text.slice(0, from - block.from); + const currentLine = beforeCursor.slice(beforeCursor.lastIndexOf("\n") + 1); + const indent = leadingWhitespace(currentLine); + + // Nothing to carry, so let the default newline handle it and keep this path + // out of the undo history. + if (indent.length === 0) return false; + + if (dispatch) { + const inserted = `\n${indent}`; + const transaction = state.tr.insertText(inserted, from, to); + const caret = from + inserted.length; + transaction.setSelection(TextSelection.create(transaction.doc, caret)); + dispatch(transaction.scrollIntoView()); + } + return true; +} + +/** + * Two blank lines at the end of a fence leave it, the way every code editor + * does. Without this a fence at the end of the prompt is a trap: Enter only + * ever adds another line and there is no way back to prose. A line holding + * only the indentation Enter carried forward counts as blank, and is removed + * with the rest. + */ +export function exitCodeBlockOnTrailingBlankLines(view: EditorView): boolean { + const { $from, empty } = view.state.selection; + if (!empty || $from.parent.type.spec.code !== true) return false; + if ($from.parentOffset !== $from.parent.content.size) return false; + const trailing = /\n[ \t]*\n[ \t]*$/.exec($from.parent.textContent); + if (!trailing) return false; + const transaction = view.state.tr.delete($from.pos - trailing[0].length, $from.pos); + // An unclosed fence runs to the end of the draft; the prose after it would + // be read back as code, so leaving it writes the closing fence. + if (!$from.parent.attrs.close) { + transaction.setNodeAttribute($from.before(), "close", `\n${$from.parent.attrs.fence}`); + } + view.dispatch(transaction); + return exitCode(view.state, (tr) => view.dispatch(tr.scrollIntoView())); +} + +/** + * Enter at the end of a line that would close the fence, such as ```` ``` ```` + * typed under the code, ends the block there. That line becomes the closing + * fence, and any lines after it continue as paragraphs after a new empty one, + * which is how the stored draft reads them. + */ +export function exitCodeBlockOnClosingFence( + state: EditorState, + dispatch?: (transaction: Transaction) => void, +): boolean { + const block = codeBlockRange(state); + if (!block || !state.selection.empty) return false; + const caret = state.selection.from - block.from; + const lineStart = block.text.lastIndexOf("\n", caret - 1) + 1; + const newlineAfter = block.text.indexOf("\n", caret); + const lineEnd = newlineAfter === -1 ? block.text.length : newlineAfter; + const line = block.text.slice(lineStart, lineEnd); + const node = state.selection.$from.parent; + if (caret !== lineEnd || !isClosingFence(line, String(node.attrs.fence))) return false; + + if (dispatch) { + const { schema } = state; + const paragraph = (text: string) => + schema.nodes.paragraph!.create(null, text ? schema.text(text) : null); + const code = block.text.slice(0, Math.max(0, lineStart - 1)); + const rest = newlineAfter === -1 ? [] : block.text.slice(newlineAfter + 1).split("\n"); + const closed = node.type.create( + { ...node.attrs, close: `\n${line}` }, + code ? schema.text(code) : null, + ); + const start = state.selection.$from.before(); + const transaction = state.tr.replaceWith(start, start + node.nodeSize, [ + closed, + paragraph(""), + ...rest.map(paragraph), + ]); + transaction.setSelection(TextSelection.create(transaction.doc, start + closed.nodeSize + 1)); + dispatch(transaction.scrollIntoView()); + } + return true; +} + +/** + * Tab and Shift+Tab inside a fence. A collapsed cursor indents at the caret so + * Tab works mid-line the way typing does; any selection spanning text indents + * the whole lines it touches, which is what makes re-indenting a block possible. + */ +export function indentCodeBlock( + state: EditorState, + direction: "in" | "out", + dispatch?: (transaction: Transaction) => void, +): boolean { + const block = codeBlockRange(state); + if (!block) return false; + + const { from, to } = state.selection; + const startOffset = from - block.from; + const endOffset = to - block.from; + + if (from === to && direction === "in") { + if (dispatch) { + const transaction = state.tr.insertText(CODE_BLOCK_INDENT, from, to); + dispatch(transaction.scrollIntoView()); + } + return true; + } + + // Grow the range to whole lines so indenting is stable no matter where in the + // first and last lines the selection happens to start and end. + const lineStart = block.text.lastIndexOf("\n", Math.max(0, startOffset - 1)) + 1; + // A selection ending at the start of a line does not touch that line. + const newlineAfter = block.text.indexOf("\n", Math.max(startOffset, endOffset - 1)); + const lineEnd = newlineAfter === -1 ? block.text.length : newlineAfter; + + const originalLines = block.text.slice(lineStart, lineEnd).split("\n"); + const nextLines = indentLines(originalLines, direction); + if (nextLines.join("\n") === originalLines.join("\n")) return true; + + if (dispatch) { + const transaction = state.tr.insertText( + nextLines.join("\n"), + block.from + lineStart, + block.from + lineEnd, + ); + // Keep the same lines selected after the shift, so repeated Tab presses + // keep indenting the block rather than collapsing the selection. + // `split` always yields at least one entry, so these stand in only for the + // compiler's benefit. + const firstDelta = (nextLines[0]?.length ?? 0) - (originalLines[0]?.length ?? 0); + const totalDelta = nextLines.join("\n").length - originalLines.join("\n").length; + const nextFrom = Math.max(block.from + lineStart, from + firstDelta); + const nextTo = Math.max(nextFrom, to + totalDelta); + transaction.setSelection(TextSelection.create(transaction.doc, nextFrom, nextTo)); + dispatch(transaction.scrollIntoView()); + } + return true; +} + +/** + * Enter on a line that is nothing but an opening fence turns it into a code + * block. Tiptap's own input rules only fire on a space, so without this the + * gesture every markdown editor has — ```` ```ts ```` then Enter — leaves the + * fence as literal text. + */ +export function convertCodeFenceOnEnter( + state: EditorState, + dispatch?: (transaction: Transaction) => void, +): boolean { + const { $from, empty } = state.selection; + // Top-level paragraphs only: the list and quote serializers have no line + // to write a fence into, so one created inside them would vanish. + if (!empty || $from.parent.type.name !== "paragraph" || $from.depth !== 1) return false; + if ($from.parentOffset !== $from.parent.content.size) return false; + // The parser's own grammar, read from the line's stored source, so a line + // Enter turns into a fence is exactly one the stored draft reads back as a + // fence, info string and all, including any chip's source in it. + const opening = parseOpeningFence(serializeSelection(state.doc, $from.start(), $from.end())); + const codeBlock = state.schema.nodes.codeBlock; + if (!opening || !codeBlock) return false; + + if (dispatch) { + const blockStart = $from.before(); + const { fence, language } = opening; + const transaction = state.tr.replaceWith( + blockStart, + blockStart + $from.parent.nodeSize, + codeBlock.create({ language, fence, close: `\n${fence}` }), + ); + transaction.setSelection(TextSelection.create(transaction.doc, blockStart + 1)); + dispatch(transaction.scrollIntoView()); + } + return true; +} diff --git a/apps/web/src/composer-code-languages.test.ts b/apps/web/src/composer-code-languages.test.ts new file mode 100644 index 000000000000..cc29b29dfd39 --- /dev/null +++ b/apps/web/src/composer-code-languages.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + CODE_BLOCK_LANGUAGES, + codeLanguageLabel, + languageOfInfoString, + withInfoStringLanguage, +} from "./composer-code-languages"; + +describe("languageOfInfoString", () => { + it.each([ + ["", ""], + ["ts", "ts"], + [" ts ", "ts"], + ["js title=example", "js"], + ["typescript ", "typescript"], + ])("reads %j as %j", (info, language) => { + expect(languageOfInfoString(info)).toBe(language); + }); +}); + +describe("withInfoStringLanguage", () => { + it.each([ + ["", "ts", "ts"], + ["js", "python", "python"], + // Anything after the language belongs to the fence and stays put. + ["js title=example", "ts", "ts title=example"], + ["js {1,3}", "ts", "ts {1,3}"], + // Plain text clears the language... + ["js", "", ""], + // ...but a bare `title=x` would read as the language, so it keeps `text`. + ["js title=example", "", "text title=example"], + // Whitespace between the fence and the language is the user's formatting. + [" js title=x", "python", " python title=x"], + [" js title=x", "", " text title=x"], + [" js", "go", " go"], + [" ", "go", " go"], + ])("sets %j to %j as %j", (info, language, expected) => { + expect(withInfoStringLanguage(info, language)).toBe(expected); + }); +}); + +describe("codeLanguageLabel", () => { + it.each([ + ["", "Plain text"], + ["text", "Plain text"], + ["cpp", "C++"], + ["ts", "TypeScript"], + ["arduino", "arduino"], + ])("labels %j as %j", (language, label) => { + expect(codeLanguageLabel(language)).toBe(label); + }); + + it("lists every language once, plain text first", () => { + const ids = CODE_BLOCK_LANGUAGES.map((language) => language.id); + expect(ids[0]).toBe(""); + expect(new Set(ids).size).toBe(ids.length); + }); +}); diff --git a/apps/web/src/composer-code-languages.ts b/apps/web/src/composer-code-languages.ts new file mode 100644 index 000000000000..1c6a68aab750 --- /dev/null +++ b/apps/web/src/composer-code-languages.ts @@ -0,0 +1,97 @@ +/** + * The languages a composer fence can be switched to from its header, and how + * a choice is written back into the fence's info string. + * + * Ids are the names the highlighter and the file icons both know. The list + * is curated rather than every grammar the highlighter bundles: a picker is + * for the common case, and any other language can still be typed on the + * fence line, where it shows as its own entry. + */ +export const CODE_BLOCK_LANGUAGES = [ + { id: "", label: "Plain text" }, + { id: "bash", label: "Bash" }, + { id: "c", label: "C" }, + { id: "cpp", label: "C++" }, + { id: "csharp", label: "C#" }, + { id: "css", label: "CSS" }, + { id: "dart", label: "Dart" }, + { id: "diff", label: "Diff" }, + { id: "dockerfile", label: "Dockerfile" }, + { id: "elixir", label: "Elixir" }, + { id: "go", label: "Go" }, + { id: "graphql", label: "GraphQL" }, + { id: "haskell", label: "Haskell" }, + { id: "html", label: "HTML" }, + { id: "java", label: "Java" }, + { id: "javascript", label: "JavaScript" }, + { id: "json", label: "JSON" }, + { id: "jsx", label: "JSX" }, + { id: "kotlin", label: "Kotlin" }, + { id: "lua", label: "Lua" }, + { id: "markdown", label: "Markdown" }, + { id: "php", label: "PHP" }, + { id: "powershell", label: "PowerShell" }, + { id: "python", label: "Python" }, + { id: "r", label: "R" }, + { id: "ruby", label: "Ruby" }, + { id: "rust", label: "Rust" }, + { id: "scala", label: "Scala" }, + { id: "scss", label: "SCSS" }, + { id: "sql", label: "SQL" }, + { id: "svelte", label: "Svelte" }, + { id: "swift", label: "Swift" }, + { id: "toml", label: "TOML" }, + { id: "tsx", label: "TSX" }, + { id: "typescript", label: "TypeScript" }, + { id: "vue", label: "Vue" }, + { id: "xml", label: "XML" }, + { id: "yaml", label: "YAML" }, + { id: "zig", label: "Zig" }, +] as const; + +export type CodeBlockLanguage = { readonly id: string; readonly label: string }; + +/** Short names people type on the fence line, labelled like the entry they mean. */ +const LANGUAGE_ALIASES: Record = { + cs: "csharp", + "c++": "cpp", + js: "javascript", + md: "markdown", + py: "python", + rb: "ruby", + rs: "rust", + sh: "bash", + shell: "bash", + ts: "typescript", + yml: "yaml", +}; + +/** The language is the info string's first word; the rest belongs to the fence. */ +export function languageOfInfoString(info: string): string { + return info.trim().split(/\s+/, 1)[0] ?? ""; +} + +/** + * Replaces the language and keeps everything around it: the whitespace the + * fence had before it and whatever follows it. Plain text clears it, unless + * something follows: a bare `title=x` would read as the language, so that + * keeps `text` in front. + */ +export function withInfoStringLanguage(info: string, language: string): string { + const leading = /^\s*/.exec(info)![0]; + // The language is the first word, so what follows it is empty or starts + // with whitespace. + const rest = info.slice(leading.length + languageOfInfoString(info).length); + if (language) return `${leading}${language}${rest}`; + return rest.trim() ? `${leading}text${rest}` : ""; +} + +/** The canonical entry a language names, if the picker lists it. */ +export function codeLanguageEntry(language: string): CodeBlockLanguage | null { + const id = language === "text" ? "" : (LANGUAGE_ALIASES[language.toLowerCase()] ?? language); + return CODE_BLOCK_LANGUAGES.find((entry) => entry.id === id.toLowerCase()) ?? null; +} + +export function codeLanguageLabel(language: string): string { + return codeLanguageEntry(language)?.label ?? language; +} diff --git a/apps/web/src/composer-list-continuation.test.ts b/apps/web/src/composer-list-continuation.test.ts index b92514dd2714..a878ed95a125 100644 --- a/apps/web/src/composer-list-continuation.test.ts +++ b/apps/web/src/composer-list-continuation.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "vite-plus/test"; -import { listContinuationForEnter, listIndentForTab } from "./composer-list-continuation"; +import { + listContinuationForEnter, + listIndentForTab, + nextOrderedMarkerText, +} from "./composer-list-continuation"; function applyEdit(value: string, edit: { start: number; end: number; replacement: string }) { return value.slice(0, edit.start) + edit.replacement + value.slice(edit.end); @@ -66,3 +70,16 @@ describe("composer list continuation", () => { expect(listIndentForTab("- foo", 1, 3)).toBeNull(); }); }); + +describe("nextOrderedMarkerText", () => { + it.each([ + ["1.", "2."], + ["1)", "2)"], + ["09.", "10."], + ["001)", "002)"], + ["99.", "100."], + ["99999999999999999999.", "99999999999999999999."], + ])("counts %s up to %s", (marker, expected) => { + expect(nextOrderedMarkerText(marker)).toBe(expected); + }); +}); diff --git a/apps/web/src/composer-list-continuation.ts b/apps/web/src/composer-list-continuation.ts index 952dfa549475..be617da2a268 100644 --- a/apps/web/src/composer-list-continuation.ts +++ b/apps/web/src/composer-list-continuation.ts @@ -55,13 +55,24 @@ function parseListMarker(line: string): { marker: ListMarker; markerEnd: number return null; } +/** + * The ordered marker that follows `3.` or `09)`: one higher, same delimiter, + * zero padding kept, and a number too large to count left as typed. Shared + * with the rich editor's native split so both modes count the same way. + */ +export function nextOrderedMarkerText(marker: string): string { + const numberText = marker.slice(0, -1); + const delimiter = marker.slice(-1); + const number = Number.parseInt(numberText, 10); + const next = Number.isSafeInteger(number) + ? String(number + 1).padStart(numberText.length, "0") + : numberText; + return `${next}${delimiter}`; +} + function nextMarkerText(marker: ListMarker): string { if (marker.kind === "ordered") { - const number = Number.parseInt(marker.numberText, 10); - const next = Number.isSafeInteger(number) - ? String(number + 1).padStart(marker.numberText.length, "0") - : marker.numberText; - return `${marker.indent}${next}${marker.delimiter} `; + return `${marker.indent}${nextOrderedMarkerText(`${marker.numberText}${marker.delimiter}`)} `; } if (marker.kind === "task") { return `${marker.indent}- [ ] `; diff --git a/apps/web/src/composer-list-split.test.ts b/apps/web/src/composer-list-split.test.ts new file mode 100644 index 000000000000..0e5c4d6a3958 --- /dev/null +++ b/apps/web/src/composer-list-split.test.ts @@ -0,0 +1,265 @@ +// @vitest-environment jsdom + +import { Editor } from "@tiptap/core"; +import { TaskList } from "@tiptap/extension-task-list"; +import { TextSelection } from "@tiptap/pm/state"; +import StarterKit from "@tiptap/starter-kit"; +import { afterEach, describe, expect, it } from "vite-plus/test"; + +import { + backspaceAcrossList, + buildDocJson, + buildTiptapContent, + ComposerBlockExtensions, + ComposerCodeBlockExtension, + ComposerListExtensions, + ComposerTaskItemExtension, + convertBulletItemToTask, + deleteAcrossList, + serializeEditorDoc, + splitOrLiftListItem, +} from "./composer-rich-text-doc"; + +// ProseMirror's DOM observer can flush on a timer after a test ends, which +// throws once jsdom is torn down, so every editor is destroyed after its test. +const editors: Editor[] = []; +afterEach(() => { + for (const editor of editors.splice(0)) editor.destroy(); +}); + +/** + * Splits the last item of `value` the way Shift+Enter does, types `b`, and + * returns the stored Markdown. Tiptap carries every attribute left at the + * default `keepOnSplit: true` onto the new item and merges the overrides on + * top, so the new item must keep the source marker, indent and spacing. + */ +function makeEditor(value: string) { + const editor = new Editor({ + extensions: [ + StarterKit.configure({ + bulletList: false, + orderedList: false, + listItem: false, + codeBlock: false, + blockquote: false, + heading: false, + horizontalRule: false, + trailingNode: false, + listKeymap: false, + }), + ...ComposerListExtensions, + TaskList, + ComposerTaskItemExtension, + ComposerCodeBlockExtension, + ...ComposerBlockExtensions, + ], + content: buildDocJson(value, (name) => ({ label: name, description: null })), + }); + editors.push(editor); + return editor; +} + +function splitLastItem(value: string, type: "listItem" | "taskItem", overrides: object) { + const editor = makeEditor(value); + editor.view.dispatch(editor.state.tr.setSelection(TextSelection.atEnd(editor.state.doc))); + editor.commands.splitListItem(type, overrides); + editor.view.dispatch(editor.state.tr.insertText("b")); + return serializeEditorDoc(editor.state.doc).value; +} + +describe("splitting a list item", () => { + it.each([ + ["* a", { space: " " }, "* a\n* b"], + ["+ a", { space: " " }, "+ a\n+ b"], + ["- p\n - a", { space: " " }, "- p\n - a\n - b"], + ["3) a", { marker: "4)", space: " " }, "3) a\n4) b"], + ])("keeps the source marker and indent of %s", (value, overrides, expected) => { + expect(splitLastItem(value, "listItem", overrides)).toBe(expected); + }); + + it("keeps the indent of a nested task", () => { + expect(splitLastItem("- [ ] p\n - [ ] a", "taskItem", { checked: false })).toBe( + "- [ ] p\n - [ ] a\n - [ ] b", + ); + }); +}); + +/** Puts the caret `offset` characters into the first text node holding `text`. */ +function placeCaret(editor: Editor, text: string, offset = 0) { + let caret = -1; + editor.state.doc.descendants((node, pos) => { + if (caret < 0 && node.isText && node.text!.includes(text)) { + caret = pos + node.text!.indexOf(text) + offset; + } + }); + editor.view.dispatch(editor.state.tr.setSelection(TextSelection.create(editor.state.doc, caret))); +} + +/** The stored draft, after checking a rebuild from it gives back the same document. */ +function storedDraft(editor: Editor) { + const stored = serializeEditorDoc(editor.state.doc).value; + expect(makeEditor(stored).state.doc.toString()).toBe(editor.state.doc.toString()); + return stored; +} + +describe("Shift+Enter twice on an item", () => { + // The second Shift+Enter lifts the new empty item one level. The stored + // draft must write it at its new depth, so a rebuild from that draft gives + // back the document the user is looking at. + it.each([ + ["- a\n - x", "- a\n - x\n- b"], + ["* a\n * x", "* a\n * x\n* b"], + ["1. a\n 1. x", "1. a\n 1. x\n2. b"], + ["- a\n - x\n- c", "- a\n - x\n- b\n- c"], + ["- a\n - x\n - y", "- a\n - x\n- b\n - y"], + ["- [ ] a\n - [ ] x", "- [ ] a\n - [ ] x\n- [ ] b"], + ["- [x] a\n - [ ] x\n- [ ] c", "- [x] a\n - [ ] x\n- [ ] b\n- [ ] c"], + ])("after x in %j writes %j", (value, expected) => { + const editor = makeEditor(value); + let caret = -1; + editor.state.doc.descendants((node, pos) => { + if (node.isText && node.text === "x") caret = pos + 1; + }); + editor.view.dispatch( + editor.state.tr.setSelection(TextSelection.create(editor.state.doc, caret)), + ); + expect(splitOrLiftListItem(editor)).toBe(true); + expect(splitOrLiftListItem(editor)).toBe(true); + editor.view.dispatch(editor.state.tr.insertText("b")); + const stored = serializeEditorDoc(editor.state.doc).value; + expect(stored).toBe(expected); + // Same nesting after a rebuild; spacing defaults may differ but write the same. + expect(makeEditor(stored).state.doc.toString()).toBe(editor.state.doc.toString()); + }); +}); + +describe("Shift+Enter in the middle of an ordered list", () => { + it.each([ + ["1. a\n2. x\n3. c\n4. d", "1. a\n2. x\n3. b\n4. c\n5. d"], + ["1) a\n2) x\n3) c", "1) a\n2) x\n3) b\n4) c"], + ["1. a\n 1. x\n 2. c\n2. d", "1. a\n 1. x\n 2. b\n 3. c\n2. d"], + ])("after x in %j writes %j", (value, expected) => { + const editor = makeEditor(value); + placeCaret(editor, "x", 1); + expect(splitOrLiftListItem(editor)).toBe(true); + editor.view.dispatch(editor.state.tr.insertText("b")); + expect(storedDraft(editor)).toBe(expected); + }); +}); + +describe("Shift+Enter on an empty task under a bullet", () => { + // There is no task list one level up to move it into, and the item cannot + // hold a second line, so the draft stays what the editor shows. + it.each(["- a\n - [ ] x", "- [ ] a\n - x"])("in %j keeps the draft honest", (value) => { + const editor = makeEditor(value); + placeCaret(editor, "x", 1); + splitOrLiftListItem(editor); + splitOrLiftListItem(editor); + storedDraft(editor); + }); +}); + +describe("Backspace at the start of a line", () => { + it.each([ + ["- a\n - x", "x", "- a\n- x"], + ["- a\n - x\n - y", "x", "- a\n- x\n - y"], + ["- [ ] a\n - [ ] x", "x", "- [ ] a\n- [ ] x"], + ["- a\n- b\n- c", "b", "- a\nb\n- c"], + // A task under a bullet has no task list to move into, so it joins up. + ["- a\n - [ ] x", "x", "- ax"], + ["- a\n - x\nb", "b", "- a\n - xb"], + ["1. a\nb", "b", "1. ab"], + ])("in %j at %s writes %j", (value, at, expected) => { + const editor = makeEditor(value); + placeCaret(editor, at); + expect(backspaceAcrossList(editor)).toBe(true); + expect(storedDraft(editor)).toBe(expected); + }); + + it.each([ + ["- a\n```\nl1\nl2\n```", "- a\nl1\nl2"], + ["p\n```ts\nl1\n```", "p\nl1"], + ])("removes the fence of %j rather than joining its code up", (value, expected) => { + const editor = makeEditor(value); + placeCaret(editor, "l1"); + editor.commands.keyboardShortcut("Backspace"); + expect(storedDraft(editor)).toBe(expected); + }); +}); + +describe("Delete at the end of a line", () => { + it.each([ + ["- a\n- b", "- ab"], + ["- a\n - x\n- c", "- ax\n- c"], + ["- a\n\nb", "- a\nb"], + ])("in %j writes %j", (value, expected) => { + const editor = makeEditor(value); + placeCaret(editor, "a", 1); + expect(deleteAcrossList(editor)).toBe(true); + expect(storedDraft(editor)).toBe(expected); + }); + + it.each(["- a\n```\nl1\n```", "a\n```\nl1\n```"])("leaves the fence in %j alone", (value) => { + const editor = makeEditor(value); + placeCaret(editor, "a", 1); + editor.commands.keyboardShortcut("Delete"); + expect(storedDraft(editor)).toBe(value); + }); +}); + +describe("blocks that a list item or quote cannot hold", () => { + it("puts lines pasted at the end of an item after the list", () => { + const editor = makeEditor("- a"); + editor.view.dispatch(editor.state.tr.setSelection(TextSelection.atEnd(editor.state.doc))); + const lines = buildTiptapContent("one\ntwo", (name) => ({ label: name, description: null })); + editor.commands.insertContent(lines); + expect(storedDraft(editor)).toBe("- a\none\ntwo"); + }); + + it("will not make a task list inside a quote", () => { + const editor = makeEditor("> q"); + placeCaret(editor, "q"); + expect(editor.commands.wrapInList("taskList")).toBe(false); + expect(storedDraft(editor)).toBe("> q"); + }); +}); + +describe("[ ] typed at the start of a bullet item", () => { + // The value holds `[ ]` already; the rule fires on the space after it. + it.each([ + ["* [ ]x", "- [ ] x"], + ["* a\n* [ ]x\n* c", "* a\n- [ ] x\n* c"], + ["- a\n * [ ]x", "- a\n - [ ] x"], + ["- a\n - [ ]x\n - y", "- a\n - [ ] x\n - y"], + ["- [ ]x\n - y", "- [ ] x\n - y"], + ["- [ ]x", "- [ ] x"], + ])("makes the item in %j a task where it stands", (value, expected) => { + const editor = makeEditor(value); + placeCaret(editor, "[ ]x"); + const from = editor.state.selection.from; + editor.view.dispatch( + editor.state.tr.setSelection(TextSelection.create(editor.state.doc, from + 3)), + ); + const { tr } = editor.state; + convertBulletItemToTask(tr, from, from + 3, false); + editor.view.dispatch(tr); + expect(editor.state.selection.$from.parent.textContent).toBe("x"); + expect(editor.state.selection.$from.parentOffset).toBe(0); + expect(storedDraft(editor)).toBe(expected); + }); +}); + +describe("a rule at the end of the draft", () => { + it("keeps an empty line after it to type on", () => { + const editor = makeEditor("a\n---"); + // Removing the line after the rule puts one straight back. + editor.view.dispatch( + editor.state.tr.delete(editor.state.doc.content.size - 2, editor.state.doc.content.size), + ); + expect(editor.state.doc.lastChild?.type.name).toBe("paragraph"); + expect(storedDraft(editor)).toBe("a\n---"); + editor.view.dispatch(editor.state.tr.setSelection(TextSelection.atEnd(editor.state.doc))); + editor.view.dispatch(editor.state.tr.insertText("b")); + expect(storedDraft(editor)).toBe("a\n---\nb"); + }); +}); diff --git a/apps/web/src/composer-rich-text-doc.test.ts b/apps/web/src/composer-rich-text-doc.test.ts index 44f8e6d1f6d1..5b26b74cacfd 100644 --- a/apps/web/src/composer-rich-text-doc.test.ts +++ b/apps/web/src/composer-rich-text-doc.test.ts @@ -5,9 +5,15 @@ import { Node as ProseMirrorNode } from "@tiptap/pm/model"; import { EditorState, TextSelection } from "@tiptap/pm/state"; import { describe, expect, it } from "vite-plus/test"; +import { collapseExpandedComposerCursor } from "./composer-logic"; + import { buildDocJson, + buildTiptapContent, caretTakesMarksBefore, + ComposerBlockExtensions, + ComposerCodeBlockExtension, + ComposerListExtensions, collapsedToFlat, ComposerCodeExtension, ComposerTaskItemExtension, @@ -16,6 +22,7 @@ import { flatToPm, pmToFlat, serializeEditorDoc, + serializeSelection, stepCaretAcrossStyledEdge, } from "./composer-rich-text-doc"; @@ -64,6 +71,9 @@ const schema = getSchemaByResolvedExtensions( }), TaskList, ComposerTaskItemExtension, + ComposerCodeBlockExtension, + ...ComposerListExtensions, + ...ComposerBlockExtensions, ]), ); @@ -344,6 +354,437 @@ describe("composer rich text document model", () => { expect(roundTripPlain(value).value).toBe(value); }); + it.each([ + "- one\n- two", + "* star\n* star two", + "+ plus", + "* star\n+ plus", + "1. first\n2. second", + "1) paren\n2) paren", + "3. starts at three\n4. four", + "01. zero padded\n02. two", + "- parent\n - child\n - sibling\n- uncle", + "1. ordered\n - bullet child\n2. next", + "- outer\n 1. inner ordered\n 2. more\n- outer again", + "- wide space item", + "-", + "1.", + "- \n- second", + "- **bold** item with @README.md", + "- [ ] task\n- bullet after", + "- bullet\n- [x] task after", + "para\n- item\npara", + "\t- tab indented\n\t- again", + " - leading indent only", + "- item\n\n- after a blank", + "- item one\n```ts\ncode\n```\n- item two", + "-no space stays literal", + "1.no space stays literal", + "10. ten\n11. eleven", + ])("round-trips the list %s through a real ProseMirror document", (value) => { + expect(roundTrip(value).value).toBe(value); + }); + + it.each([ + "- one\n- two", + "1. first\n2. second", + "- parent\n - child", + "* star\n+ plus", + "-no space stays literal", + ])("keeps the list %s literal in plain mode", (value) => { + expect(roundTripPlain(value).value).toBe(value); + }); + + it.each(["- one\n- two", "1. a\n - b\n2. c", "- **bold** @README.md tail", "-"])( + "maps every document offset of the list %s through collapsed coordinates and back", + (value) => { + const map = roundTrip(value); + expect(map.value).toBe(value); + for (let flat = 0; flat <= map.docLength; flat += 1) { + expect(collapsedToFlat(map, flatToCollapsed(map, flat))).toBe(flat); + } + }, + ); + + it("clamps offsets inside a list marker to the start of the item text", () => { + const value = "- item"; + const map = roundTrip(value); + // The marker owns no document characters, like a checkbox. + for (let collapsed = 0; collapsed <= "- ".length; collapsed += 1) { + expect(collapsedToFlat(map, collapsed)).toBe(0); + } + expect(collapsedToFlat(map, "- it".length)).toBe(2); + expect(flatToCollapsed(map, 0)).toBe("- ".length); + }); + + it.each([ + "> quoted", + "> line one\n> line two", + ">no space", + "> two spaces", + "> a\n>b", + ">", + "> **bold** and @README.md inside", + "> - looks like a list but stays quote text", + "> > nested stays literal inside the quote", + "before\n> quoted\nafter", + "> quote\n\n> another", + "- item\n> quote after list", + ])("round-trips the quote %s through a real ProseMirror document", (value) => { + expect(roundTrip(value).value).toBe(value); + }); + + it.each([ + "---", + "***", + "___", + "- - -", + "* * *", + "-----", + "--- ", + "text\n---\nmore", + "- item\n---\n- item two", + "```\n---\n```", + "--", + "-- -", + "---text", + ])("round-trips the rule %s through a real ProseMirror document", (value) => { + expect(roundTrip(value).value).toBe(value); + }); + + it("parses rules ahead of lists and emphasis", () => { + const json = buildDocJson("- - -\n***\n___\ntext\n---\n- item", (n) => ({ + label: n, + description: null, + })); + expect(json.content.map((block) => block.type)).toEqual([ + "horizontalRule", + "horizontalRule", + "horizontalRule", + "paragraph", + "horizontalRule", + "bulletList", + ]); + }); + + it.each([ + "# Heading", + "## Two", + "###### Six", + "####### seven hashes stays a paragraph", + "# two spaces", + "#\tTab", + "# Trailing hashes stay literal #", + "#1234", + "#1234 is a pull request, not a heading", + "# Heading with **bold** and @README.md", + "#", + "# ", + "text\n# Heading\ntext", + "# Heading\n- item\n> quote\n---", + ])("round-trips the heading %s through a real ProseMirror document", (value) => { + expect(roundTrip(value).value).toBe(value); + }); + + it.each(["> quoted", "> a\n>b", "---", "- - -", "# Heading", "#1234"])( + "keeps the block %s literal in plain mode", + (value) => { + expect(roundTripPlain(value).value).toBe(value); + }, + ); + + it("parses the blocks it renders as the right node types", () => { + const json = buildDocJson("# Title\n> quote\n---\n#1234 ref\n- - -", (n) => ({ + label: n, + description: null, + })); + expect(json.content.map((block) => block.type)).toEqual([ + "heading", + "blockquote", + "horizontalRule", + "paragraph", + "horizontalRule", + // The empty line a final rule keeps for the caret. + "paragraph", + ]); + }); + + it.each([ + ["---", "---"], + ["a\n---", "a\n---"], + // That line and a trailing newline are the same document; the newline goes. + ["---\n", "---"], + ])("gives the caret a line after the final rule of %j, written %j", (value, expected) => { + const map = roundTrip(value); + expect(map.value).toBe(expected); + const json = buildDocJson(value, () => ({ label: "", description: null })); + const doc = ProseMirrorNode.fromJSON(schema, json); + expect(doc.resolve(flatToPm(map, map.docLength)).parent.isTextblock).toBe(true); + }); + + it("parses rules ahead of lists and emphasis", () => { + const json = buildDocJson("- - -\n***\n___\ntext\n---\n- item", (n) => ({ + label: n, + description: null, + })); + expect(json.content.map((block) => block.type)).toEqual([ + "horizontalRule", + "horizontalRule", + "horizontalRule", + "paragraph", + "horizontalRule", + "bulletList", + ]); + }); + + it.each([ + "# Heading", + "## Two", + "###### Six", + "####### seven hashes stays a paragraph", + "# two spaces", + "#\tTab", + "# Trailing hashes stay literal #", + "#1234", + "#1234 is a pull request, not a heading", + "# Heading with **bold** and @README.md", + "#", + "# ", + "text\n# Heading\ntext", + "# Heading\n- item\n> quote\n---", + ])("round-trips the heading %s through a real ProseMirror document", (value) => { + expect(roundTrip(value).value).toBe(value); + }); + + it.each(["> quoted", "> a\n>b", "---", "- - -", "# Heading", "#1234"])( + "keeps the block %s literal in plain mode", + (value) => { + expect(roundTripPlain(value).value).toBe(value); + }, + ); + + it("parses a quote as a blockquote of one paragraph per line", () => { + const json = buildDocJson("> a\n> b\n>c", (n) => ({ label: n, description: null })); + expect(json.content.map((block) => block.type)).toEqual(["blockquote", "blockquote"]); + expect((json.content[0] as { content: unknown[] }).content).toHaveLength(2); + }); + + it.each([ + "> a\n> b", + "> **q** @README.md", + "text\n---\nmore", + "---\ntext", + "# Heading text", + "## **b** @README.md", + ])( + "maps every document offset of the block %s through collapsed coordinates and back", + (value) => { + const map = roundTrip(value); + expect(map.value).toBe(value); + for (let flat = 0; flat <= map.docLength; flat += 1) { + expect(collapsedToFlat(map, flatToCollapsed(map, flat))).toBe(flat); + } + }, + ); + + it("clamps offsets inside a quote or heading marker to the start of the text", () => { + for (const [value, prefix] of [ + ["> quoted", "> "], + ["# Heading", "# "], + ] as const) { + const map = roundTrip(value); + for (let collapsed = 0; collapsed <= prefix.length; collapsed += 1) { + expect(collapsedToFlat(map, collapsed)).toBe(0); + } + expect(flatToCollapsed(map, 0)).toBe(prefix.length); + } + }); + + it.each([ + "```\ncode\n```", + "```ts\nconst a = 1;\n```", + "```ts\nconst a = 1;\n```\n", + "before\n```ts\nconst a = 1;\n```\nafter", + "```\n```", + "```ts\nline one\nline two\nline three\n```", + "```ts\n indented\n deeper\n```", + "```js title=example\ncode\n```", + "~~~py\ncode\n~~~", + "````\n```\n````", + "```ts\ncode without a closing fence", + "```", + "```ts\n**not bold** and @README.md stay literal\n```", + "```ts\ncode\n````", + "- [ ] task\n```ts\ncode\n```\n- [ ] after", + " ```ts\nindented fence stays a paragraph\n ```", + ])("round-trips the fenced block %s", (value) => { + expect(roundTrip(value).value).toBe(value); + }); + + it.each(["```ts\nconst a = 1;\n```", "```\n```", "before\n```ts\ncode\n```\nafter"])( + "maps every document offset of %s through collapsed coordinates and back", + (value) => { + const map = roundTrip(value); + expect(map.value).toBe(value); + for (let flat = 0; flat <= map.docLength; flat += 1) { + expect(collapsedToFlat(map, flatToCollapsed(map, flat))).toBe(flat); + } + }, + ); + + it("reads blocks written with CRLF line endings", () => { + const value = "# Title\r\n> quote\r\n---\r\n```ts\r\nconst a = 1;\r\n```\r\nafter"; + const doc = buildDocJson(value, () => ({ label: "", description: null })); + expect(doc.content.map((block) => block.type)).toEqual([ + "heading", + "blockquote", + "horizontalRule", + "codeBlock", + "paragraph", + ]); + expect(roundTrip(value).value).toBe(value); + }); + + it("keeps the end of the code inside the fence rather than after it", () => { + const value = "```ts\nfunc();\n```"; + const map = roundTrip(value); + const endOfCode = "func();".length; + // Not the end of the string: the closing fence is a line of its own. + expect(flatToCollapsed(map, endOfCode)).toBe("```ts\nfunc();".length); + expect(flatToMarkdown(map, endOfCode)).toBe("```ts\nfunc();".length); + expect(collapsedToFlat(map, flatToCollapsed(map, endOfCode))).toBe(endOfCode); + }); + + it("keeps the caret inside an empty fence", () => { + const value = "before\n```\n```"; + const map = roundTrip(value); + const inside = "before\n".length; + expect(flatToCollapsed(map, inside)).toBe("before\n```".length); + expect(collapsedToFlat(map, flatToCollapsed(map, inside))).toBe(inside); + }); + + it("still places the end of an inline mark after its markers", () => { + // The fence rule must not leak into inline marks, whose trailing edge is + // deliberately the position after the closing delimiter. + const map = roundTrip("a **bold** c"); + expect(flatToMarkdown(map, 6)).toBe(10); + }); + + it("clamps offsets inside a fence to the edge of the code", () => { + const value = "```ts\nab\n```"; + const map = roundTrip(value); + expect(map.value).toBe(value); + // The opening fence owns no document characters, so every offset in it + // lands on the first character of the code. + for (let collapsed = 0; collapsed <= "```ts\n".length; collapsed += 1) { + expect(collapsedToFlat(map, collapsed)).toBe(0); + } + expect(collapsedToFlat(map, "```ts\na".length)).toBe(1); + // Everything from the closing newline onwards clamps to the code's end. + for (let collapsed = "```ts\nab".length; collapsed <= value.length; collapsed += 1) { + expect(collapsedToFlat(map, collapsed)).toBe(2); + } + }); + + it.each([ + ["```\n\n```", "```\n```"], + ["```\n", "```"], + ])("canonicalizes the empty fence %s", (value, expected) => { + expect(roundTrip(value).value).toBe(expected); + }); + + it("keeps a chip in a fence info string as source and keeps later chips aligned", () => { + const value = "```@README.md\ncode\n```\n$my-skill after"; + expect(roundTrip(value).value).toBe(value); + const json = buildDocJson(value, (n) => ({ label: n, description: null })); + const after = json.content[1] as { + content: { type: string; attrs?: { skillName?: string } }[]; + }; + expect(after.content.map((n) => n.type)).toEqual(["composer-skill", "text"]); + expect(after.content[0]?.attrs?.skillName).toBe("my-skill"); + }); + + it("keeps chips in a fence's info string and body each in their own place", () => { + const value = "``` @a.md\nsee @b.md\n```"; + expect(roundTrip(value).value).toBe(value); + const json = buildDocJson(value, (n) => ({ label: n, description: null })); + const code = json.content[0] as { attrs: { language: string } }; + expect(code.attrs.language).toBe(" @a.md"); + }); + + it.each([ + ["listItem", { marker: "-", space: "" }, "bulletList", "- text"], + ["listItem", { marker: "1.", space: "" }, "orderedList", "1. text"], + ])("gives a bare %s a space once it has text", (item, attrs, list, expected) => { + const doc = ProseMirrorNode.fromJSON(schema, { + type: "doc", + content: [ + { + type: list, + content: [ + { + type: item, + attrs, + content: [{ type: "paragraph", content: [{ type: "text", text: "text" }] }], + }, + ], + }, + ], + }); + expect(serializeEditorDoc(doc).value).toBe(expected); + expect(roundTrip("-").value).toBe("-"); + }); + + it("writes code holding a closing fence inside a longer fence", () => { + const doc = ProseMirrorNode.fromJSON(schema, { + type: "doc", + content: [ + { + type: "codeBlock", + attrs: { fence: "```", close: "\n```" }, + content: [{ type: "text", text: "initial\n```\nsecret" }], + }, + ], + }); + const stored = serializeEditorDoc(doc).value; + expect(stored).toBe("````\ninitial\n```\nsecret\n````"); + expect(buildDocJson(stored, () => ({ label: "", description: null })).content).toHaveLength(1); + expect(roundTrip(stored).value).toBe(stored); + }); + + it("closes an unclosed fence that has a block after it", () => { + const doc = ProseMirrorNode.fromJSON(schema, { + type: "doc", + content: [ + { type: "codeBlock", attrs: { close: "" }, content: [{ type: "text", text: "code" }] }, + { type: "paragraph", content: [{ type: "text", text: "after" }] }, + ], + }); + expect(serializeEditorDoc(doc).value).toBe("```\ncode\n```\nafter"); + // At the end of the draft it stays unclosed, as it was written. + expect(roundTrip("```\ncode").value).toBe("```\ncode"); + }); + + it("counts a chip's source in a fence as one cursor position, as the draft store does", () => { + const value = "```\nsee @b.md now\n```\nafter"; + const map = roundTrip(value); + expect(map.value).toBe(value); + for (let flat = 0; flat <= map.docLength; flat += 1) { + expect(flatToCollapsed(map, flat)).toBe( + collapseExpandedComposerCursor(value, flatToMarkdown(map, flat)), + ); + } + const afterChip = "see @b.md".length; + expect(collapsedToFlat(map, flatToCollapsed(map, afterChip))).toBe(afterChip); + const end = map.docLength; + expect(collapsedToFlat(map, flatToCollapsed(map, end))).toBe(end); + }); + + it("keeps fences literal in plain mode", () => { + const value = "```ts\nconst a = 1;\n```"; + expect(roundTripPlain(value).value).toBe(value); + }); + it("maps every document offset through collapsed coordinates and back", () => { const value = "hi **bold** @README.md bye"; const map = roundTrip(value); @@ -353,6 +794,37 @@ describe("composer rich text document model", () => { } }); + // Flipping the rich text setting remounts the editor, and the caret is + // restored from the stored collapsed cursor. That only works because the + // coordinate means the same thing on both sides of the flip. + it.each([ + "plain prose with no styling at all", + "a chip @README.md counts one character in both modes", + "$my-skill leads the line", + "trailing newline\n", + ])("resolves a collapsed cursor identically in both modes for %s", (value) => { + const rich = roundTrip(value); + const plain = roundTripPlain(value); + expect(rich.value).toBe(value); + expect(plain.value).toBe(value); + for (let collapsed = 0; collapsed <= value.length; collapsed += 1) { + expect(flatToMarkdown(rich, collapsedToFlat(rich, collapsed))).toBe( + flatToMarkdown(plain, collapsedToFlat(plain, collapsed)), + ); + } + }); + + it("clamps a cursor that was sitting inside a marker onto the styled text", () => { + const value = "a **bold** c"; + const plain = roundTripPlain(value); + const rich = roundTrip(value); + // Between the two asterisks: a real caret position in plain mode, and no + // position at all in rich mode, where it lands on the first styled + // character instead. The flip moves the caret by a marker's width at most. + expect(flatToMarkdown(plain, collapsedToFlat(plain, 3))).toBe(3); + expect(flatToMarkdown(rich, collapsedToFlat(rich, 3))).toBe(4); + }); + it("maps markdown offsets at styled edges onto document text", () => { const value = "a **bold** c"; const map = roundTrip(value); @@ -365,6 +837,63 @@ describe("composer rich text document model", () => { }); }); +describe("serializeSelection", () => { + /** The stored Markdown of the selection from the start of `first` to the end of `last`. */ + function copy(value: string, first: string, last: string) { + const doc = ProseMirrorNode.fromJSON( + schema, + buildDocJson(value, (name) => ({ label: name, description: null })), + ); + let from = -1; + let to = -1; + doc.descendants((node, pos) => { + if (!node.isText) return true; + const text = node.text ?? ""; + if (from < 0 && text.includes(first)) from = pos + text.indexOf(first); + if (text.includes(last)) to = pos + text.lastIndexOf(last) + last.length; + return false; + }); + return serializeSelection(doc, from, to); + } + + it("keeps the markers of the list items it spans", () => { + expect(copy("- one\n- two\n- three", "one", "two")).toBe("- one\n- two"); + expect(copy("3) one\n4) two", "one", "two")).toBe("3) one\n4) two"); + expect(copy("- [ ] one\n- [x] two", "one", "two")).toBe("- [ ] one\n- [x] two"); + }); + + it("keeps the nesting of an item and its children", () => { + expect(copy("- p\n - a\n - b", "p", "a")).toBe("- p\n - a"); + }); + + it("keeps the prefix of the quote lines it spans", () => { + expect(copy("> one\n> two", "one", "two")).toBe("> one\n> two"); + }); + + it("copies text within one block without its marker", () => { + expect(copy("- one two", "one", "one")).toBe("one"); + }); + + it("copies across blocks as they are written", () => { + expect(copy("intro\n- one", "intro", "one")).toBe("intro\n- one"); + }); +}); + +describe("pasting into a list item or quote", () => { + it("keeps inline marks but builds no blocks", () => { + const content = buildTiptapContent( + "**bold** text\n- item\n> quote", + () => ({ label: "", description: null }), + { styling: true, blocks: false }, + ); + expect(content.map((block) => block.type)).toEqual(["paragraph", "paragraph", "paragraph"]); + const first = content[0] as { content: { text: string; marks?: { type: string }[] }[] }; + expect(first.content[0]).toMatchObject({ text: "bold", marks: [{ type: "bold" }] }); + expect(JSON.stringify(content[1])).toContain("- item"); + expect(JSON.stringify(content[2])).toContain("> quote"); + }); +}); + describe("caret stops at styled edges", () => { function stateAt(value: string, pmPos: number) { const doc = ProseMirrorNode.fromJSON( diff --git a/apps/web/src/composer-rich-text-doc.ts b/apps/web/src/composer-rich-text-doc.ts index 49a489c82ad4..efdfb145dc1d 100644 --- a/apps/web/src/composer-rich-text-doc.ts +++ b/apps/web/src/composer-rich-text-doc.ts @@ -1,9 +1,24 @@ -import { Mark, type Node as ProseMirrorNode } from "@tiptap/pm/model"; -import type { EditorState, Transaction } from "@tiptap/pm/state"; +import { joinBackward, joinTextblockBackward, joinTextblockForward } from "@tiptap/pm/commands"; +import { Fragment, Mark, type Node as ProseMirrorNode, type ResolvedPos } from "@tiptap/pm/model"; import { Code } from "@tiptap/extension-code"; +import { Blockquote } from "@tiptap/extension-blockquote"; +import { CodeBlock } from "@tiptap/extension-code-block"; +import { Heading } from "@tiptap/extension-heading"; +import { HorizontalRule } from "@tiptap/extension-horizontal-rule"; +import { type Command, type Editor, mergeAttributes } from "@tiptap/core"; +import { BulletList, ListItem, OrderedList } from "@tiptap/extension-list"; import { TaskItem } from "@tiptap/extension-task-item"; +import { TaskList } from "@tiptap/extension-task-list"; +import { + type EditorState, + Plugin, + Selection, + TextSelection, + type Transaction, +} from "@tiptap/pm/state"; import { splitPromptIntoComposerSegments } from "~/composer-editor-mentions"; +import { nextOrderedMarkerText } from "~/composer-list-continuation"; import { parseInlineMarkdown, RICH_TEXT_DELIMITERS, type RichTextMark } from "~/composer-rich-text"; import { collectInlineContextIds } from "~/lib/composerContextReferences"; @@ -55,12 +70,53 @@ export const ComposerCodeExtension = Code.extend({ exitable: false, }); +/** + * Tiptap's block extensions each bind a chord that turns the current block + * into their node (Mod-Shift-8 for a list, Mod-Alt-c for a fence, and so on). + * The composer does not offer them, and they are not harmless: run inside a + * quote they nest a block the quote serializer cannot write, and the text in + * it drops out of the stored draft. `drop` names the chords to remove; the + * keys the composer relies on, such as Backspace and the arrows at a block's + * edge, stay. + */ +function withoutBlockChords>( + shortcuts: Shortcuts | undefined, + drop: readonly string[], +): Shortcuts { + return Object.fromEntries( + Object.entries(shortcuts ?? {}).filter(([key]) => !drop.includes(key)), + ) as Shortcuts; +} + +/** + * What a list item may hold: its own line, then nested lists. That is all the + * Markdown can write, so ProseMirror refuses any join, wrap or paste that + * would put a second line or another block into an item. + */ +const LIST_ITEM_CONTENT = "paragraph list*"; + +/** Task lists come from `- [ ]` alone; Mod-Shift-9 would nest one in a quote. */ +export const ComposerTaskListExtension = TaskList.extend({ + addKeyboardShortcuts() { + return withoutBlockChords(this.parent?.(), ["Mod-Shift-9"]); + }, +}); + +/** + * Tiptap's Tab and Shift-Tab sink and lift an item without touching its + * `indent`, so the stored draft keeps the old nesting and the next rebuild + * undoes the move. The composer nests through its own Tab, which edits the + * source the way plain mode does. + */ +const LIST_NESTING_KEYS = ["Tab", "Shift-Tab"]; + /** * Task list items keep their exact source indent in an attribute so nesting * round-trips byte-identically. Checkbox case (`[X]`) normalizes to `[x]` — * the same fixed-point deal as `__bold__` becoming `**bold**`. */ export const ComposerTaskItemExtension = TaskItem.extend({ + content: LIST_ITEM_CONTENT, addAttributes() { return { ...this.parent?.(), @@ -69,8 +125,222 @@ export const ComposerTaskItemExtension = TaskItem.extend({ contentSpace: { default: null }, }; }, + addKeyboardShortcuts() { + return withoutBlockChords(this.parent?.(), LIST_NESTING_KEYS); + }, }).configure({ nested: true }); +/** + * Fenced code blocks keep their exact source delimiters so a fence round-trips + * byte-identically: `fence` is the opening run of backticks or tildes, + * `language` its info string, and `close` the closing newline and fence, or + * the empty string when the fence was never closed. + * + * Neither delimiter owns a document character, so the caret can never land + * inside a fence — the same deal as a checkbox marker. + * + * An empty block written with a blank line (```` ```\n\n``` ````) canonicalizes + * to the blank-free form, the same fixed-point deal as `__bold__` becoming + * `**bold**`. + */ +export const ComposerCodeBlockExtension = CodeBlock.extend({ + addAttributes() { + return { + ...this.parent?.(), + language: { default: "" }, + fence: { default: "```" }, + close: { default: "\n```" }, + }; + }, + addKeyboardShortcuts() { + return { + ...withoutBlockChords(this.parent?.(), ["Mod-Alt-c"]), + Backspace: () => unwrapCodeBlockAtStart(this.editor), + }; + }, +}); + +/** + * Backspace at the start of a fence removes the fence and keeps its lines as + * paragraphs, the way it removes a list marker. Joining the code onto the line + * above instead would drop the fence from the draft and leave the rest of the + * code as stray lines. + */ +function unwrapCodeBlockAtStart(editor: Editor): boolean { + const { $from, empty } = editor.state.selection; + if (!empty || $from.parent.type.name !== "codeBlock" || $from.parentOffset !== 0) return false; + const { schema } = editor.state; + const paragraphs = $from.parent.textContent + .split("\n") + .map((line) => schema.nodes.paragraph!.create(null, line ? schema.text(line) : null)); + const start = $from.before(); + const tr = editor.state.tr.replaceWith(start, $from.after(), paragraphs); + editor.view.dispatch(tr.setSelection(TextSelection.create(tr.doc, start + 1))); + return true; +} + +/** + * The markdown a code block node serializes to, delimiters included. `followed` + * says another block comes after it in the draft. + */ +function codeBlockSource( + node: ProseMirrorNode, + followed: boolean, +): { + open: string; + content: string; + close: string; +} { + const attrs = node.attrs as Record; + let fence = typeof attrs.fence === "string" && attrs.fence ? attrs.fence : "```"; + const language = typeof attrs.language === "string" ? attrs.language : ""; + let close = typeof attrs.close === "string" ? attrs.close : ""; + const content = node.textContent; + // Code holding a line that would close the fence, such as a pasted fence, + // is written inside a longer one, so the agent reads all of it as code. A + // parsed block never holds such a line, so its source stays byte-identical. + const longest = Math.max( + 0, + ...content + .split("\n") + .filter((line) => isClosingFence(line, fence)) + .map((line) => /^[`~]+/.exec(line)![0].length), + ); + if (longest > 0) { + fence = fence[0]!.repeat(longest + 1); + if (close) close = `\n${fence}`; + } + // An unclosed fence runs to the end of the draft, so with a block after it + // the fence is written closed, or the next rebuild would read that block as code. + if (!close && followed) close = `\n${fence}`; + return { open: `${fence}${language}${content ? "\n" : ""}`, content, close }; +} + +/** + * Bullet and ordered items keep their exact source marker so a list + * round-trips byte-identically: `marker` is the literal `-`, `*`, `+`, `3.` + * or `3)`, `space` what followed it, and `indent` the leading whitespace. + * Numbering is not renumbered: what the user typed is what the agent gets. + */ +const ComposerListItemExtension = ListItem.extend({ + content: LIST_ITEM_CONTENT, + addAttributes() { + return { + ...this.parent?.(), + indent: { default: "" }, + marker: { default: "-" }, + space: { default: " " }, + }; + }, + addKeyboardShortcuts() { + return { + ...withoutBlockChords(this.parent?.(), LIST_NESTING_KEYS), + // Task items share these: the list extensions are always loaded together. + Backspace: () => backspaceAcrossList(this.editor), + Delete: () => deleteAcrossList(this.editor), + }; + }, + // The source marker rides on the item so the composer draws `3)` and a + // nested `7.` as written, rather than the browser own numbering. + renderHTML({ node, HTMLAttributes }) { + return ["li", mergeAttributes(HTMLAttributes, { "data-marker": node.attrs.marker }), 0]; + }, +}); + +export const ComposerListExtensions = [ + BulletList.extend({ + addKeyboardShortcuts() { + return withoutBlockChords(this.parent?.(), ["Mod-Shift-8"]); + }, + }), + OrderedList.extend({ + addKeyboardShortcuts() { + return withoutBlockChords(this.parent?.(), ["Mod-Shift-7"]); + }, + }), + ComposerListItemExtension, +]; + +/** + * A quote keeps the exact `>` prefix its lines were written with, applied to + * every line, so it round-trips byte-identically and a new line typed inside + * it gets the same prefix. One source line is one paragraph; a line whose + * prefix differs starts a sibling quote. Nested markers and list markers + * inside a quote stay literal text: the composer quotes prose, not documents. + */ +const ComposerBlockquoteExtension = Blockquote.extend({ + content: "paragraph+", + addAttributes() { + return { ...this.parent?.(), prefix: { default: "> " } }; + }, + addKeyboardShortcuts() { + return withoutBlockChords(this.parent?.(), ["Mod-Shift-b"]); + }, +}); + +/** + * A rule keeps the exact line it was written as (`---`, `* * *`, `_____`), so + * it round-trips byte-identically. It owns no document characters: offsets + * inside its source clamp to the block after it. + * + * A rule cannot hold the caret, so a document never ends in one: an empty + * line follows it, which the draft does not write (see `isTrailingLineAfterRule`). + * A draft ending in `---\n` reads back the same way and so loses that newline, + * the same fixed-point deal as `__bold__` becoming `**bold**`. + */ +const ComposerHorizontalRuleExtension = HorizontalRule.extend({ + addAttributes() { + return { ...this.parent?.(), source: { default: "---" } }; + }, + addProseMirrorPlugins() { + return [ + ...(this.parent?.() ?? []), + new Plugin({ + appendTransaction: (transactions, _, state) => { + if (!transactions.some((tr) => tr.docChanged)) return null; + if (state.doc.lastChild?.type.name !== "horizontalRule") return null; + return state.tr.insert(state.doc.content.size, state.schema.nodes.paragraph!.create()); + }, + }), + ]; + }, +}); + +/** The empty line after a rule that ends the document, which writes nothing. */ +function isTrailingLineAfterRule(doc: ProseMirrorNode, index: number): boolean { + const block = doc.child(index); + return ( + index === doc.childCount - 1 && + index > 0 && + block.type.name === "paragraph" && + block.content.size === 0 && + doc.child(index - 1).type.name === "horizontalRule" + ); +} + +/** + * A heading keeps the exact whitespace between its `#`s and its text. The + * `#`s must be followed by whitespace to count, which is also what keeps a + * `#1234` pull request reference a reference: the marker owns no document + * characters, closing `#`s stay literal text, and nothing is ever stripped. + */ +const ComposerHeadingExtension = Heading.extend({ + addAttributes() { + return { ...this.parent?.(), space: { default: " " } }; + }, + // Its only shortcuts are the Mod-Alt-1…6 block chords. + addKeyboardShortcuts() { + return {}; + }, +}); + +/** Block-level nodes beyond lists and fences, in the order the parser tries them. */ +export const ComposerBlockExtensions = [ + ComposerBlockquoteExtension, + ComposerHorizontalRuleExtension, + ComposerHeadingExtension, +]; + function randomNodeKey(): string { return `tiptap-${Math.random().toString(36).slice(2)}`; } @@ -98,10 +368,73 @@ function parseTaskPrefix(head: string): { prefix: TaskLinePrefix; markerLength: }; } +/** + * An opening fence: three or more backticks or tildes at the start of a line, + * followed by an info string. A backtick fence cannot carry a backtick in its + * info string, which is what keeps `` `code` `` on its own line literal. + * Indented fences stay paragraphs — the composer is a prompt box, not a + * CommonMark renderer, and honoring indentation would cost another attribute + * for no case anyone writes. + */ +export function parseOpeningFence(line: string): { fence: string; language: string } | null { + // `[^\n]` rather than `.`, here and in the block rules: a CRLF draft's lines + // end in `\r`, which `.` refuses, and the `\r` stays in the stored text. + const match = /^(`{3,}|~{3,})([^\n]*)$/.exec(line); + if (!match) return null; + const fence = match[1]!; + const language = match[2]!; + if (fence.startsWith("`") && language.includes("`")) return null; + return { fence, language }; +} + +/** A closing fence matches the opening run's character and is at least as long. */ +export function isClosingFence(line: string, fence: string): boolean { + const match = /^(`{3,}|~{3,})[ \t]*\r?$/.exec(line); + if (!match) return false; + const run = match[1]!; + return run[0] === fence[0] && run.length >= fence.length; +} + +type ListLinePrefix = + | ({ kind: "task" } & TaskLinePrefix) + | { kind: "bullet" | "ordered"; indent: string; marker: string; space: string }; + +/** + * The same grammar the literal continuation uses, so plain and rich mode agree + * on what a list line is: a task first (it also looks like a bullet), then an + * ordered marker, then a bullet. A marker followed by nothing is an empty item. + */ +function parseListPrefix(line: string): { prefix: ListLinePrefix; markerLength: number } | null { + const task = parseTaskPrefix(line); + if (task) return { prefix: { kind: "task", ...task.prefix }, markerLength: task.markerLength }; + const ordered = /^([ \t]*)(\d+[.)])((?:[ \t]+)|$)/.exec(line); + if (ordered) { + return { + prefix: { kind: "ordered", indent: ordered[1]!, marker: ordered[2]!, space: ordered[3]! }, + markerLength: ordered[0].length, + }; + } + const bullet = /^([ \t]*)([-*+])((?:[ \t]+)|$)/.exec(line); + if (bullet) { + return { + prefix: { kind: "bullet", indent: bullet[1]!, marker: bullet[2]!, space: bullet[3]! }, + markerLength: bullet[0].length, + }; + } + return null; +} + +/** Items of one kind and marker family belong to one list; a change starts a sibling list. */ +function listKey(prefix: ListLinePrefix): string { + if (prefix.kind === "task") return "task"; + if (prefix.kind === "ordered") return `ordered:${prefix.marker.slice(-1)}`; + return `bullet:${prefix.marker}`; +} + type InlineJson = Record; interface DocLine { - task: TaskLinePrefix | null; + list: ListLinePrefix | null; inline: InlineJson[]; } @@ -143,28 +476,52 @@ function atomJsonForSegment( }; } -interface PendingTaskItem extends TaskLinePrefix { +interface PendingItem { + prefix: ListLinePrefix; content: InlineJson[]; - children: PendingTaskItem[]; + /** Nested lists, in order; a parent can hold lists of different kinds. */ + children: PendingList[]; } -function taskListJson(items: PendingTaskItem[]): InlineJson { - return { - type: "taskList", - content: items.map((item) => ({ - type: "taskItem", - attrs: { - checked: item.checked, - indent: item.indent, - markerSpace: item.markerSpace, - contentSpace: item.contentSpace, - }, - content: [ - { type: "paragraph", content: item.content }, - ...(item.children.length > 0 ? [taskListJson(item.children)] : []), - ], - })), - }; +interface PendingList { + key: string; + items: PendingItem[]; +} + +function listJson(list: PendingList): InlineJson { + const first = list.items[0]!.prefix; + const items = list.items.map((item) => { + const content = [ + { type: "paragraph", content: item.content }, + ...item.children.map((child) => listJson(child)), + ]; + if (item.prefix.kind === "task") { + return { + type: "taskItem", + attrs: { + checked: item.prefix.checked, + indent: item.prefix.indent, + markerSpace: item.prefix.markerSpace, + contentSpace: item.prefix.contentSpace, + }, + content, + }; + } + return { + type: "listItem", + attrs: { indent: item.prefix.indent, marker: item.prefix.marker, space: item.prefix.space }, + content, + }; + }); + if (first.kind === "task") return { type: "taskList", content: items }; + if (first.kind === "ordered") { + return { + type: "orderedList", + attrs: { start: Number.parseInt(first.marker, 10) || 1 }, + content: items, + }; + } + return { type: "bulletList", content: items }; } function textJsonForSpan(text: string, marks: RichTextMark[]): Record { @@ -180,9 +537,12 @@ function textJsonForSpan(text: string, marks: RichTextMark[]): Record SkillMeta, - options?: { styling?: boolean }, + options?: { styling?: boolean; blocks?: boolean }, ): Record[] { const styling = options?.styling ?? true; + // Inline marks without block structure: text pasted into a list item or + // quote, which has no line to write a nested block into. + const blockSyntax = styling && (options?.blocks ?? true); // Hide token source from the markdown parser, then restore the atoms with // the marks of their surrounding text. Choose a sentinel absent from input. let sentinel = "\uFFFC"; @@ -190,17 +550,19 @@ export function buildTiptapContent( sentinel = String.fromCodePoint(codePoint); } const atoms: InlineJson[] = []; + // Code fences hold no chips, so their lines put the original source back in + // place of the sentinel rather than building an atom for it. + const atomSources: string[] = []; const text = splitPromptIntoComposerSegments(value) .map((segment) => { if (segment.type === "text") return segment.text; atoms.push(atomJsonForSegment(segment, skillLabelFor)); + atomSources.push(segment.source); return sentinel; }) .join(""); let atomIndex = 0; - const lines: DocLine[] = text.split("\n").map((line) => { - const parsed = styling ? parseTaskPrefix(line) : null; - const content = parsed ? line.slice(parsed.markerLength) : line; + const buildInline = (content: string): InlineJson[] => { const spans = styling ? parseInlineMarkdown(content) : [{ text: content, marks: [] }]; const inline: InlineJson[] = []; for (const span of spans) { @@ -212,61 +574,185 @@ export function buildTiptapContent( if (piece) inline.push(textJsonForSpan(piece, span.marks)); }); } - return { task: parsed?.prefix ?? null, inline }; - }); + return inline; + }; + const buildDocLine = (line: string): DocLine => { + const parsed = blockSyntax ? parseListPrefix(line) : null; + const content = parsed ? line.slice(parsed.markerLength) : line; + return { list: parsed?.prefix ?? null, inline: buildInline(content) }; + }; - // Pass 2: consecutive task lines group into (possibly nested) task lists - // by indent prefix; everything else stays a paragraph. - const blocks: Record[] = []; - let stack: { indent: string; items: PendingTaskItem[] }[] = []; - const flushTasks = () => { - if (stack.length > 0) { - blocks.push(taskListJson(stack[0]!.items)); - stack = []; + // Pass 1: fenced blocks claim their lines whole; everything else becomes an + // inline-parsed line. Fence bodies restore chip sources as literal text. + const sourceLines = text.split("\n"); + const entries: ( + | { code: Record } + | { rule: string } + | { heading: { level: number; space: string; inline: InlineJson[] } } + | { quote: { prefix: string; inline: InlineJson[] } } + | { line: DocLine } + )[] = []; + const restoreSources = (line: string) => + line.split(sentinel).reduce((joined, piece, index) => { + if (index === 0) return piece; + atomIndex += 1; + return joined + atomSources[atomIndex - 1]! + piece; + }, ""); + + for (let index = 0; index < sourceLines.length; index += 1) { + const line = sourceLines[index]!; + const opening = blockSyntax ? parseOpeningFence(line) : null; + if (!opening) { + // A thematic break outranks a list: `- - -` and `* * *` are rules, and + // `***` on its own line is a rule rather than an empty bold span. + if (blockSyntax && /^([-*_])(?:[ \t]*\1){2,}[ \t]*\r?$/.test(line)) { + entries.push({ rule: line }); + continue; + } + const heading = blockSyntax ? /^(#{1,6})([ \t]+)([^\n]*)$/.exec(line) : null; + if (heading) { + entries.push({ + heading: { + level: heading[1]!.length, + space: heading[2]!, + inline: buildInline(heading[3]!), + }, + }); + continue; + } + const quote = blockSyntax ? /^(>[ \t]*)([^\n]*)$/.exec(line) : null; + if (quote) entries.push({ quote: { prefix: quote[1]!, inline: buildInline(quote[2]!) } }); + else entries.push({ line: buildDocLine(line) }); + continue; + } + // The info string went through the sentinel pass like every line, so a + // token in it is put back as source here, in order, before the body. + const language = restoreSources(opening.language); + const body: string[] = []; + let cursor = index + 1; + let close = ""; + while (cursor < sourceLines.length) { + const candidate = sourceLines[cursor]!; + if (isClosingFence(candidate, opening.fence)) { + close = `\n${candidate}`; + break; + } + body.push(restoreSources(candidate)); + cursor += 1; } + // An unclosed fence runs to the end of the prompt, which is what the user + // is looking at while they are still typing the block. + const closed = cursor < sourceLines.length; + const content = body.join("\n"); + entries.push({ + code: { + type: "codeBlock", + attrs: { language, fence: opening.fence, close }, + ...(content ? { content: [{ type: "text", text: content }] } : {}), + }, + }); + index = closed ? cursor : sourceLines.length; + } + + // Pass 2: consecutive list lines group into (possibly nested) lists by + // indent prefix; everything else stays a paragraph. Items of a different + // kind or marker at the same indent start a sibling list, so `* a` under + // `- b` keeps its star and a task list can follow a bullet list. + const blocks: Record[] = []; + let rootLists: PendingList[] = []; + let stack: { indent: string; list: PendingList; container: PendingList[] }[] = []; + const flushLists = () => { + for (const list of rootLists) blocks.push(listJson(list)); + rootLists = []; + stack = []; + }; + const openList = (container: PendingList[], key: string): PendingList => { + const list = { key, items: [] }; + container.push(list); + return list; + }; + let openQuote: { prefix: string; content: InlineJson[][] } | null = null; + const flushQuote = () => { + if (!openQuote) return; + blocks.push({ + type: "blockquote", + attrs: { prefix: openQuote.prefix }, + content: openQuote.content.map((inline) => ({ type: "paragraph", content: inline })), + }); + openQuote = null; }; - for (const line of lines) { - if (!line.task) { - flushTasks(); + for (const entry of entries) { + if ("quote" in entry) { + flushLists(); + if (openQuote && openQuote.prefix !== entry.quote.prefix) flushQuote(); + openQuote ??= { prefix: entry.quote.prefix, content: [] }; + openQuote.content.push(entry.quote.inline); + continue; + } + flushQuote(); + if ("code" in entry) { + flushLists(); + blocks.push(entry.code); + continue; + } + if ("rule" in entry) { + flushLists(); + blocks.push({ type: "horizontalRule", attrs: { source: entry.rule } }); + continue; + } + if ("heading" in entry) { + flushLists(); + blocks.push({ + type: "heading", + attrs: { level: entry.heading.level, space: entry.heading.space }, + content: entry.heading.inline, + }); + continue; + } + const line = entry.line; + if (!line.list) { + flushLists(); blocks.push({ type: "paragraph", content: line.inline }); continue; } - const item: PendingTaskItem = { - ...line.task, - content: line.inline, - children: [], - }; + const item: PendingItem = { prefix: line.list, content: line.inline, children: [] }; + const key = listKey(line.list); + const indent = line.list.indent; for (;;) { const top = stack[stack.length - 1]; if (!top) { // A leading indented item with no parent flattens but keeps indent. - stack.push({ indent: item.indent, items: [] }); + stack.push({ indent, list: openList(rootLists, key), container: rootLists }); continue; } - if (top.indent === item.indent) { - top.items.push(item); + if (top.indent === indent) { + if (top.list.key !== key) top.list = openList(top.container, key); + top.list.items.push(item); break; } - if (top.indent !== "" && !item.indent.startsWith(top.indent)) { + if (top.indent !== "" && !indent.startsWith(top.indent)) { if (stack.length > 1) { stack.pop(); continue; } - top.indent = item.indent; - top.items.push(item); + top.indent = indent; + if (top.list.key !== key) top.list = openList(top.container, key); + top.list.items.push(item); break; } - const parent = top.items[top.items.length - 1]; + const parent = top.list.items[top.list.items.length - 1]; if (!parent) { - top.items.push(item); + top.list.items.push(item); break; } - parent.children.push(item); - stack.push({ indent: item.indent, items: parent.children }); + const list = openList(parent.children, key); + stack.push({ indent, list, container: parent.children }); + list.items.push(item); break; } } - flushTasks(); + flushQuote(); + flushLists(); return blocks; } @@ -275,7 +761,10 @@ export function buildDocJson( skillLabelFor: (name: string) => SkillMeta, options?: { styling?: boolean }, ) { - return { type: "doc", content: buildTiptapContent(value, skillLabelFor, options) }; + const content = buildTiptapContent(value, skillLabelFor, options); + // The empty line the rule extension keeps after a final rule. + if (content.at(-1)?.type === "horizontalRule") content.push({ type: "paragraph" }); + return { type: "doc", content }; } export interface RichRun { @@ -495,7 +984,210 @@ function appendInlineRuns( } } -function walkTaskList(list: ProseMirrorNode, listStart: number, acc: RichAccumulator): void { +const LIST_NODE_NAMES = new Set(["taskList", "bulletList", "orderedList"]); +const LIST_ITEM_NODE_NAMES = new Set(["taskItem", "listItem"]); + +/** + * Gives the list item at the caret the indent and marker of the sibling it + * follows, or of the one after it when it is first. Tiptap moves items between + * lists without touching these attributes, so without this the stored draft + * keeps an outdented item at its old depth, and the next rebuild nests it again. + */ +function alignListItemWithSiblings(tr: Transaction, itemType: string): void { + const $pos = tr.selection.$from; + // A lift out of the list leaves the caret in a paragraph, possibly inside + // an item of another kind, which did not move. + if ($pos.depth < 3 || $pos.node(-1).type.name !== itemType) return; + const item = $pos.node(-1); + const list = $pos.node(-2); + const index = $pos.index(-2); + const sibling = + index > 0 ? list.child(index - 1) : index + 1 < list.childCount ? list.child(index + 1) : null; + if (!sibling) return; + const attrs: Record = { ...item.attrs, indent: sibling.attrs.indent }; + if (item.type.name === "taskItem") { + attrs.markerSpace = sibling.attrs.markerSpace; + } else { + const marker = typeof sibling.attrs.marker === "string" ? sibling.attrs.marker : "-"; + attrs.marker = index > 0 && /^\d+[.)]$/.test(marker) ? nextOrderedMarkerText(marker) : marker; + } + tr.setNodeMarkup($pos.before(-1), undefined, attrs); + if (typeof attrs.marker === "string" && /^\d+[.)]$/.test(attrs.marker)) { + renumberFollowingItems(tr, $pos.before(-2), index, attrs.marker); + } +} + +/** + * Counts the items after `index` on from `marker`, so an item inserted in the + * middle of an ordered list pushes the rest down instead of repeating a number. + * Only marker attributes change, so positions stay valid while walking. + */ +function renumberFollowingItems( + tr: Transaction, + listPos: number, + index: number, + marker: string, +): void { + const list = tr.doc.nodeAt(listPos); + if (!list) return; + let pos = listPos + 1; + let previous = marker; + list.forEach((child, _, childIndex) => { + if (childIndex > index) { + previous = nextOrderedMarkerText(previous); + if (child.attrs.marker !== previous) { + tr.setNodeMarkup(pos, undefined, { ...child.attrs, marker: previous }); + } + } + pos += child.nodeSize; + }); +} + +/** + * Shift+Enter in a list or task item: split it, or on an empty item leave one + * level of nesting. Either way the item at the caret ends up written with the + * indent and marker of its new siblings. + */ +export function splitOrLiftListItem(editor: Editor): boolean { + const $from = editor.state.selection.$from; + const itemType = listItemTypeAt($from); + if (!itemType) return false; + const empty = $from.parent.content.size === 0; + // One transaction, so the split or lift and the realignment undo together. + const move: Command = ({ commands, tr }) => { + const moved = + commands.splitListItem( + itemType, + itemType === "taskItem" ? { checked: false } : { space: " " }, + ) || + (empty && commands.liftListItem(itemType)); + if (moved) alignListItemWithSiblings(tr, itemType); + return moved; + }; + return editor.can().command(move) && editor.chain().command(move).run(); +} + +/** + * Deletes `from`–`to`, the typed `[ ]` at the start of a bullet item's text, + * and makes that item a task where it stands. A task list holds only tasks, so + * the bullet list splits around it into sibling lists, which is also how the + * parser reads a change of list kind. Nested items and the item's own nested + * lists stay where they are. + */ +export function convertBulletItemToTask( + tr: Transaction, + from: number, + to: number, + checked: boolean, +): void { + tr.delete(from, to); + const $pos = tr.doc.resolve(from); + const item = $pos.node(-1); + const list = $pos.node(-2); + const index = $pos.index(-2); + const { schema } = tr.doc.type; + const before: ProseMirrorNode[] = []; + const after: ProseMirrorNode[] = []; + list.forEach((child, _, childIndex) => { + if (childIndex < index) before.push(child); + else if (childIndex > index) after.push(child); + }); + // The spacing after the bullet stays between the dash and the box, as typed. + const markerSpace = + typeof item.attrs.space === "string" && item.attrs.space ? item.attrs.space : " "; + const task = schema.nodes.taskItem!.create( + { checked, indent: item.attrs.indent, markerSpace }, + item.content, + ); + const lists = [ + ...(before.length > 0 ? [list.copy(Fragment.from(before))] : []), + schema.nodes.taskList!.create(null, task), + ...(after.length > 0 ? [list.copy(Fragment.from(after))] : []), + ]; + const listStart = $pos.before(-2); + tr.replaceWith(listStart, listStart + list.nodeSize, lists); + // Into the task list, its item and its paragraph. + const caret = listStart + (before.length > 0 ? lists[0]!.nodeSize : 0) + 3; + tr.setSelection(TextSelection.create(tr.doc, caret)); +} + +function listItemTypeAt($pos: ResolvedPos): "listItem" | "taskItem" | null { + const name = $pos.depth > 1 ? $pos.node(-1).type.name : null; + return name === "listItem" || name === "taskItem" ? name : null; +} + +/** + * Backspace at the start of an item's text leaves one level of nesting, or + * the list itself at the top, written at its new depth. At the start of a + * line right after a list it joins that line onto the list's last line. + * Tiptap's list keymap, which StarterKit loads, does both by moving nodes + * without touching their indent or marker, so the composer turns it off. + */ +export function backspaceAcrossList(editor: Editor): boolean { + const { state } = editor; + const { $from, empty } = state.selection; + if (!empty || $from.parentOffset !== 0) return false; + const itemType = listItemTypeAt($from); + if (itemType && $from.index(-1) === 0) { + const lift: Command = ({ commands, tr }) => { + const moved = commands.liftListItem(itemType); + if (moved) alignListItemWithSiblings(tr, itemType); + return moved; + }; + // Not `can()` first: a dry run of the lift skips the schema check, so it + // says yes to a task under a bullet, which has no list to lift into. Its + // text joins the line above instead. + return editor.chain().command(lift).run() || joinBackward(state, editor.view.dispatch); + } + const before = $from.depth === 1 ? $from.node(0).maybeChild($from.index(0) - 1) : null; + if (before && LIST_NODE_NAMES.has(before.type.name) && $from.parent.type.name !== "codeBlock") { + return joinTextblockBackward(state, editor.view.dispatch); + } + return false; +} + +/** + * Delete at the end of a line next to list structure pulls the next line's + * text up onto it. Tiptap's default would instead nest the next item, or a + * whole list, under this one at its old indent. Before a fence, anywhere, it + * does nothing: pulling the code into the line would drop the fence from the + * draft and leave the rest of the code as stray lines. + */ +export function deleteAcrossList(editor: Editor): boolean { + const { state } = editor; + const { $from, empty } = state.selection; + if (!empty || !$from.parent.isTextblock || $from.depth === 0) return false; + if ($from.parentOffset !== $from.parent.content.size) return false; + const next = Selection.findFrom(state.doc.resolve($from.after()), 1, true); + if (!next) return false; + if (next.$from.parent.type.name === "codeBlock") return $from.parent.type.name !== "codeBlock"; + if (!listItemTypeAt($from) && !listItemTypeAt(next.$from)) return false; + return joinTextblockForward(state, editor.view.dispatch); +} + +/** The literal prefix an item serializes to. Empty items keep their exact spacing. */ +function listItemPrefix(item: ProseMirrorNode, empty: boolean): string { + const attrs = item.attrs as Record; + const indent = typeof attrs.indent === "string" ? attrs.indent : ""; + if (item.type.name === "taskItem") { + const markerSpace = typeof attrs.markerSpace === "string" ? attrs.markerSpace : " "; + const contentSpace = + typeof attrs.contentSpace === "string" + ? attrs.contentSpace || (empty ? "" : " ") + : empty + ? "" + : " "; + return `${indent}-${markerSpace}[${attrs.checked === true ? "x" : " "}]${contentSpace}`; + } + const marker = typeof attrs.marker === "string" && attrs.marker ? attrs.marker : "-"; + // A bare `-` keeps its missing space only while the item is empty: once it + // has text, `-text` would not be a list line any more. + const space = + typeof attrs.space === "string" ? attrs.space || (empty ? "" : " ") : empty ? "" : " "; + return `${indent}${marker}${space}`; +} + +function walkList(list: ProseMirrorNode, listStart: number, acc: RichAccumulator): void { let itemPos = listStart + 1; let firstItem = true; list.content.forEach((item) => { @@ -505,17 +1197,8 @@ function walkTaskList(list: ProseMirrorNode, listStart: number, acc: RichAccumul const itemContentStart = itemPos + 1; const first = item.firstChild; const empty = first?.type.name === "paragraph" && first.content.childCount === 0; - const attrs = item.attrs as Record; - const indent = typeof attrs.indent === "string" ? attrs.indent : ""; - const markerSpace = typeof attrs.markerSpace === "string" ? attrs.markerSpace : " "; - const contentSpace = - typeof attrs.contentSpace === "string" - ? attrs.contentSpace || (empty ? "" : " ") - : empty - ? "" - : " "; - const prefix = `${indent}-${markerSpace}[${attrs.checked === true ? "x" : " "}]${contentSpace}`; - // The checkbox owns no document characters; every prefix offset clamps + const prefix = listItemPrefix(item, empty); + // The marker owns no document characters; every prefix offset clamps // to the start of the item text, exactly like style markers. acc.runs.push({ kind: "prefix", @@ -537,8 +1220,8 @@ function walkTaskList(list: ProseMirrorNode, listStart: number, acc: RichAccumul item.content.forEach((child) => { if (!firstBlock) pushBreakRun(acc); firstBlock = false; - if (child.type.name === "taskList") { - walkTaskList(child, childPos, acc); + if (LIST_NODE_NAMES.has(child.type.name)) { + walkList(child, childPos, acc); } else if (child.type.name === "paragraph") { appendInlineRuns(child, childPos + 1, acc); } @@ -548,6 +1231,111 @@ function walkTaskList(list: ProseMirrorNode, listStart: number, acc: RichAccumul }); } +/** + * A fence becomes one run whose open and close lengths are the delimiters, so + * every cursor rule that already clamps out of a style marker clamps out of a + * fence too. `nodeName` keeps the marker decorations off it: a code block is + * drawn as a block, not revealed a character at a time. + * + * The code is literal text in the editor, but the draft store still counts a + * chip's source in it as one cursor position, as it does everywhere. So the + * run splits at each chip source, which spans its characters in the document + * and one position in the collapsed space. + */ +function appendCodeBlockRun( + block: ProseMirrorNode, + pmPos: number, + followed: boolean, + acc: RichAccumulator, +): void { + const { open, content, close } = codeBlockSource(block, followed); + const pieces = content + ? splitPromptIntoComposerSegments(content).map((segment) => + segment.type === "text" + ? { length: segment.text.length, collapsedLen: segment.text.length } + : { length: segment.source.length, collapsedLen: 1 }, + ) + : [{ length: 0, collapsedLen: 0 }]; + let offset = 0; + pieces.forEach((piece, index) => { + const openLen = index === 0 ? open.length : 0; + const closeLen = index === pieces.length - 1 ? close.length : 0; + const collapsedLen = openLen + piece.collapsedLen + closeLen; + const mdLen = openLen + piece.length + closeLen; + acc.runs.push({ + kind: "text", + flatStart: acc.flat, + docLen: piece.length, + collapsedLen, + mdLen, + openLen, + closeLen, + pmPos: pmPos + offset, + mdStart: acc.md, + collapsedStart: acc.collapsed, + nodeName: "codeBlock", + }); + offset += piece.length; + acc.flat += piece.length; + acc.collapsed += collapsedLen; + acc.md += mdLen; + }); + acc.value += open + content + close; +} + +/** Each paragraph of a quote is one source line behind the quote's prefix. */ +function walkBlockquote(quote: ProseMirrorNode, quoteStart: number, acc: RichAccumulator): void { + const attrs = quote.attrs as Record; + const prefix = typeof attrs.prefix === "string" ? attrs.prefix : "> "; + let childPos = quoteStart + 1; + let firstLine = true; + quote.content.forEach((child) => { + if (!firstLine) pushBreakRun(acc); + firstLine = false; + acc.runs.push({ + kind: "prefix", + flatStart: acc.flat, + docLen: 0, + collapsedLen: prefix.length, + mdLen: prefix.length, + openLen: 0, + closeLen: 0, + pmPos: childPos + 1, + mdStart: acc.md, + collapsedStart: acc.collapsed, + }); + acc.value += prefix; + acc.collapsed += prefix.length; + acc.md += prefix.length; + if (child.type.name === "paragraph") appendInlineRuns(child, childPos + 1, acc); + childPos += child.nodeSize; + }); +} + +/** + * The stored Markdown of a selection, as copy and cut put it on the clipboard. + * A slice holds only the content of the blocks the ends share, so items or + * quote lines arrive without the list or quote that writes their markers. The + * shared block is put back around them, and an item's list around it. + */ +export function serializeSelection(doc: ProseMirrorNode, from: number, to: number): string { + const slice = doc.slice(from, to); + const $from = doc.resolve(from); + let depth = $from.sharedDepth(to); + let content: Fragment | ProseMirrorNode = slice.content; + if (slice.content.firstChild?.isInline) { + content = doc.type.schema.nodes.paragraph!.create(null, slice.content); + } else { + while (depth > 0) { + const shared = $from.node(depth); + content = shared.copy(Fragment.from(content)); + if (!LIST_ITEM_NODE_NAMES.has(shared.type.name)) break; + depth -= 1; + } + } + return serializeEditorDoc(doc.type.create(null, content)).value; +} + export function serializeEditorDoc(doc: ProseMirrorNode): RichDocMap { const acc: RichAccumulator = { runs: [], value: "", flat: 0, collapsed: 0, md: 0 }; const blocks: ProseMirrorNode[] = []; @@ -557,9 +1345,52 @@ export function serializeEditorDoc(doc: ProseMirrorNode): RichDocMap { let pmBlockStart = 0; blocks.forEach((block, blockIndex) => { - if (blockIndex > 0) pushBreakRun(acc); - if (block.type.name === "taskList") { - walkTaskList(block, pmBlockStart, acc); + if (blockIndex > 0 && !isTrailingLineAfterRule(doc, blockIndex)) pushBreakRun(acc); + if (LIST_NODE_NAMES.has(block.type.name)) { + walkList(block, pmBlockStart, acc); + } else if (block.type.name === "codeBlock") { + appendCodeBlockRun(block, pmBlockStart + 1, blockIndex < blocks.length - 1, acc); + } else if (block.type.name === "blockquote") { + walkBlockquote(block, pmBlockStart, acc); + } else if (block.type.name === "heading") { + const attrs = block.attrs as Record; + const level = typeof attrs.level === "number" ? attrs.level : 1; + const space = typeof attrs.space === "string" ? attrs.space : " "; + const prefix = `${"#".repeat(level)}${space}`; + acc.runs.push({ + kind: "prefix", + flatStart: acc.flat, + docLen: 0, + collapsedLen: prefix.length, + mdLen: prefix.length, + openLen: 0, + closeLen: 0, + pmPos: pmBlockStart + 1, + mdStart: acc.md, + collapsedStart: acc.collapsed, + }); + acc.value += prefix; + acc.collapsed += prefix.length; + acc.md += prefix.length; + appendInlineRuns(block, pmBlockStart + 1, acc); + } else if (block.type.name === "horizontalRule") { + const attrs = block.attrs as Record; + const source = typeof attrs.source === "string" && attrs.source ? attrs.source : "---"; + acc.runs.push({ + kind: "prefix", + flatStart: acc.flat, + docLen: 0, + collapsedLen: source.length, + mdLen: source.length, + openLen: 0, + closeLen: 0, + pmPos: pmBlockStart + block.nodeSize, + mdStart: acc.md, + collapsedStart: acc.collapsed, + }); + acc.value += source; + acc.collapsed += source.length; + acc.md += source.length; } else if (block.type.name === "paragraph") { appendInlineRuns(block, pmBlockStart + 1, acc); } @@ -582,12 +1413,28 @@ function lastRunEnd(map: RichDocMap, space: "collapsed" | "md"): number { : last.mdStart + last.mdLen; } +/** + * The end of a run belongs to the next run, which is how the end of `**bold**` + * lands after its markers. A fence is the exception: its close is a line of + * its own, so the end of the code stays inside the block rather than jumping + * past the closing fence. Inline marks keep their trailing position. + */ +function runOwnsOffset(run: RichRun, flatOffset: number): boolean { + const end = run.flatStart + run.docLen; + return flatOffset < end || (flatOffset === end && run.nodeName === "codeBlock"); +} + export function flatToCollapsed(map: RichDocMap, flatOffset: number): number { const bounded = Math.max(0, Math.min(flatOffset, map.docLength)); for (const run of map.runs) { - if (bounded < run.flatStart + run.docLen) { + if (runOwnsOffset(run, bounded)) { if (run.kind === "text" || run.kind === "token") { - return run.collapsedStart + run.openLen + (bounded - run.flatStart); + // A chip's source in a fence spans its characters but one position. + const within = Math.min( + bounded - run.flatStart, + run.collapsedLen - run.openLen - run.closeLen, + ); + return run.collapsedStart + run.openLen + within; } return run.collapsedStart + (bounded - run.flatStart); } @@ -598,7 +1445,7 @@ export function flatToCollapsed(map: RichDocMap, flatOffset: number): number { export function flatToMarkdown(map: RichDocMap, flatOffset: number): number { const bounded = Math.max(0, Math.min(flatOffset, map.docLength)); for (const run of map.runs) { - if (bounded < run.flatStart + run.docLen) { + if (runOwnsOffset(run, bounded)) { if (run.kind === "text" || run.kind === "token") { return run.mdStart + run.openLen + (bounded - run.flatStart); } @@ -616,9 +1463,10 @@ export function collapsedToFlat(map: RichDocMap, collapsedOffset: number): numbe if (run.kind === "prefix") return run.flatStart; if (run.kind === "text" || run.kind === "token") { const within = collapsedOffset - run.collapsedStart; + const contentLen = run.collapsedLen - run.openLen - run.closeLen; // Marker characters clamp to the styled edge: they are shown, never edited. if (within <= run.openLen) return run.flatStart; - if (within >= run.openLen + run.docLen) return run.flatStart + run.docLen; + if (within >= run.openLen + contentLen) return run.flatStart + run.docLen; return run.flatStart + (within - run.openLen); } return run.flatStart + (collapsedOffset - run.collapsedStart); diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 46b57325a3ff..d43fc36769a1 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -1422,7 +1422,7 @@ html[data-theme-id] [data-chat-header] [data-slot="separator"] { /* Chat code blocks join the other code surfaces on the code tokens. The default look keeps its secondary/input tints; themed palettes tint those surfaces with the accent, which fights syntax highlighting. */ -html[data-theme-id] .chat-markdown .chat-markdown-codeblock { +html[data-theme-id] :is(.chat-markdown, .composer-tiptap) .chat-markdown-codeblock { background-color: var(--code-background); border-color: var(--contrast-border); color: var(--code-foreground); @@ -1433,11 +1433,11 @@ html[data-theme-id] .chat-markdown pre { color: var(--code-foreground); } -html[data-theme-id] .chat-markdown .chat-markdown-codeblock pre { +html[data-theme-id] :is(.chat-markdown, .composer-tiptap) .chat-markdown-codeblock pre { background-color: transparent; } -html[data-theme-id] .chat-markdown .chat-markdown-codeblock-header { +html[data-theme-id] :is(.chat-markdown, .composer-tiptap) .chat-markdown-codeblock-header { color: color-mix(in srgb, var(--code-foreground) 72%, transparent); } @@ -1685,7 +1685,7 @@ code { Inline code stays relative to its sentence so it never towers over prose; diffs and file previews take the size through --diffs-font-size. */ .chat-markdown .chat-markdown-shiki .shiki, -.chat-markdown pre code { +:is(.chat-markdown, .composer-tiptap) pre code { font-size: var(--font-size-code, inherit); } @@ -1755,45 +1755,47 @@ code { .chat-markdown ol, .chat-markdown blockquote, .chat-markdown pre, -.chat-markdown .chat-markdown-table-container { +.chat-markdown .chat-markdown-table-container, +.composer-tiptap ul, +.composer-tiptap ol { margin: 0.65rem 0; } -.chat-markdown h1, -.chat-markdown h2, -.chat-markdown h3, -.chat-markdown h4, -.chat-markdown h5, -.chat-markdown h6 { +:is(.chat-markdown, .composer-tiptap) h1, +:is(.chat-markdown, .composer-tiptap) h2, +:is(.chat-markdown, .composer-tiptap) h3, +:is(.chat-markdown, .composer-tiptap) h4, +:is(.chat-markdown, .composer-tiptap) h5, +:is(.chat-markdown, .composer-tiptap) h6 { margin: 1.25rem 0 0.5rem; font-weight: 600; line-height: 1.3; color: var(--contrast-foreground); } -.chat-markdown h1 { +:is(.chat-markdown, .composer-tiptap) h1 { font-size: 1.25rem; } -.chat-markdown h2 { +:is(.chat-markdown, .composer-tiptap) h2 { font-size: 1.125rem; } -.chat-markdown h3 { +:is(.chat-markdown, .composer-tiptap) h3 { font-size: 1rem; } -.chat-markdown h4, -.chat-markdown h5, -.chat-markdown h6 { +:is(.chat-markdown, .composer-tiptap) h4, +:is(.chat-markdown, .composer-tiptap) h5, +:is(.chat-markdown, .composer-tiptap) h6 { font-size: 0.875rem; } -.chat-markdown h6 { +:is(.chat-markdown, .composer-tiptap) h6 { color: var(--contrast-muted-foreground); } -.chat-markdown ul { +:is(.chat-markdown, .composer-tiptap) ul { /* Reset for nested uls under a widened ol. --list-gutter is an inherited custom property, so without this a task-list under a multi-digit ordered list would inherit the outer gutter instead of its own default. */ @@ -1807,21 +1809,27 @@ code { widest marker has multiple characters so it isn't clipped by list-style-position: outside painting the marker past the padding box. Reset it here too so a nested ol without its own widened marker doesn't inherit the outer one. */ -.chat-markdown ol { +:is(.chat-markdown, .composer-tiptap) ol { --list-gutter: 1.25rem; padding-left: var(--list-gutter, 1.25rem); list-style-type: decimal; } -.chat-markdown ol > li::marker { +:is(.chat-markdown, .composer-tiptap) ol > li::marker { font-variant-numeric: tabular-nums; } -.chat-markdown ul ul { +/* The composer shows the marker the source has, `3)` or a nested `7.`, not the + browser counter. Browsers without ::marker content fall back to `start`. */ +.composer-tiptap ol > li[data-marker]::marker { + content: attr(data-marker) " "; +} + +:is(.chat-markdown, .composer-tiptap) ul ul { list-style-type: circle; } -.chat-markdown ul ul ul { +:is(.chat-markdown, .composer-tiptap) ul ul ul { list-style-type: square; } @@ -1870,12 +1878,30 @@ code { background-size: 4px 2px; } -.chat-markdown blockquote { +:is(.chat-markdown, .composer-tiptap) blockquote { border-left: 2px solid var(--contrast-border); padding-left: 0.8rem; color: var(--contrast-muted-foreground); } +/* A quote in the composer is text the user is still writing; muting it reads + as disabled in an input, so the border alone carries the quote. */ +.composer-tiptap blockquote { + margin: 0.65rem 0; + color: var(--foreground); +} + +/* Preflight already draws the 1px rule, as it does in chat; only the block + spacing needs to match. */ +.composer-tiptap hr { + margin: 0.65rem 0; +} + +/* A heading at the top of a draft should not push the first line down. */ +.composer-tiptap :is(h1, h2, h3, h4, h5, h6):first-child { + margin-top: 0; +} + .chat-markdown section[data-footnotes] { margin-top: 1.25rem; border-top: 1px solid var(--contrast-border); @@ -1939,23 +1965,23 @@ code { background: color-mix(in srgb, var(--contrast-border) 78%, transparent); } -.chat-markdown .chat-markdown-codeblock-header { +:is(.chat-markdown, .composer-tiptap) .chat-markdown-codeblock-header { color: color-mix(in srgb, var(--contrast-foreground) 72%, transparent); } -.chat-markdown .chat-markdown-codeblock pre { +:is(.chat-markdown, .composer-tiptap) .chat-markdown-codeblock pre { margin: 0; border: none; border-radius: 0; background: transparent; } -.chat-markdown .chat-markdown-codeblock[data-wrap="true"] pre { +:is(.chat-markdown, .composer-tiptap) .chat-markdown-codeblock[data-wrap="true"] pre { white-space: pre-wrap; overflow-wrap: anywhere; } -.chat-markdown .chat-markdown-shiki .shiki { +:is(.chat-markdown, .composer-tiptap) .chat-markdown-shiki .shiki { background: transparent !important; } @@ -2181,7 +2207,7 @@ code { outline: none; } -.composer-tiptap code { +.composer-tiptap :not(pre) > code { font-family: var(--font-mono); font-size: 0.92em; background: color-mix(in oklab, var(--muted-foreground) 12%, transparent); diff --git a/docs/internals/composer-editors.md b/docs/internals/composer-editors.md index 6ed0a8c2f7bf..650564f751b6 100644 --- a/docs/internals/composer-editors.md +++ b/docs/internals/composer-editors.md @@ -25,3 +25,18 @@ Clipboard text must come from the Markdown serializer, not DOM text: chip labels the source and marker decorations are not content. Structured context records accompany that text when available. Paste completes trailing chip delimiters and adds a leading boundary when inserting a chip directly after text. + +Block nodes (fences, lists, quotes, rules, headings) keep their exact Markdown +delimiters as node attributes, so the round trip is byte-identical and offsets +inside a delimiter clamp to the block's text like task and style markers. The +serializers write only what the grammar has: a quote holds paragraphs, a list +holds paragraphs and lists. So blocks may only form at a top-level paragraph. +Input rules check this, and Tiptap's own block chords (Mod-Shift-8, Mod-Alt-c, +and so on) are removed, because nesting a block the serializer cannot write +drops its text from the stored draft. The list grammar is the plain-mode +continuation's in [composer-list-continuation](../../apps/web/src/composer-list-continuation.ts); +keep the two in step. + +Enter sends, so the fence handling in `handleKeyDown` must run before the send +handler: checked afterwards, a fence never opens and a newline inside one sends +the draft. diff --git a/docs/user/composer.md b/docs/user/composer.md index f4c2829019a7..ba8d26fd81c2 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -12,6 +12,23 @@ becomes an attachment when inserting it would exceed the message limit. On a hardware keyboard, use `Cmd+Shift+V` on Apple devices or `Ctrl+Shift+V` elsewhere to keep a large paste editable in the composer instead. +## Formatting + +The composer writes Markdown and shows it styled as you type. Markers such as +`**` stay in the text and show beside the styled words when your cursor is on +them. Lines starting with `- `, `1. `, `- [ ] `, `> `, `# ` or `---` become lists, +task lists, quotes, headings and rules; ` ``` ` followed by Enter opens a code +block. What you typed is what the agent receives, markers and numbering +included, and `#1234` without a space still looks up a pull request. + +Enter sends. In a list or quote, **Shift+Enter** continues it, and Shift+Enter +on an empty line leaves it; **Tab** nests a list item. In a code block, Enter +starts a new line at the current indentation, **Tab** and **Shift+Tab** indent +the selected lines, and a closing ` ``` ` followed by Enter, or two blank lines +at the end, leave the block. **Backspace** at the start of a code block turns it +back into plain lines. Choose the language in a code block's corner to change +it. Very large code blocks are shown without syntax highlighting. + ## Attach files Attach up to 100 files per message. Each image can be up to 10 MiB, with at most diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fe1e71770eb8..445556401867 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -687,9 +687,24 @@ importers: '@tiptap/core': specifier: ^3.31.3 version: 3.31.3(@tiptap/pm@3.31.3) + '@tiptap/extension-blockquote': + specifier: ^3.31.3 + version: 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) '@tiptap/extension-code': specifier: ^3.31.3 version: 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) + '@tiptap/extension-code-block': + specifier: ^3.31.3 + version: 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + '@tiptap/extension-heading': + specifier: ^3.31.3 + version: 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3)) + '@tiptap/extension-horizontal-rule': + specifier: ^3.31.3 + version: 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) + '@tiptap/extension-list': + specifier: ^3.31.3 + version: 3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3) '@tiptap/extension-task-item': specifier: ^3.31.3 version: 3.31.3(@tiptap/extension-list@3.31.3(@tiptap/core@3.31.3(@tiptap/pm@3.31.3))(@tiptap/pm@3.31.3)) From 46dd0949053ae387e785c5e9801725a9ccc28de1 Mon Sep 17 00:00:00 2001 From: Afonso Ferreira <99990310+Aforno@users.noreply.github.com> Date: Wed, 7 Oct 2026 23:58:50 +0100 Subject: [PATCH 18/25] fix(web): center project monograms in settled rows (#16841) --- apps/web/src/components/Sidebar.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 43d1f8dacffb..779f662e0b8d 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1821,7 +1821,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { hover so the tail stays scannable when you're hunting. */} Date: Wed, 7 Oct 2026 23:58:53 +0100 Subject: [PATCH 19/25] fix(web): cancelling a new citation no longer leaves a stray space (#16828) --- .../components/ComposerPromptEditorTiptap.tsx | 30 +++++++++++-------- apps/web/src/components/chat/ChatComposer.tsx | 15 ++++++++-- 2 files changed, 31 insertions(+), 14 deletions(-) diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.tsx index 6e830991ab7e..ae2c6e460f26 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.tsx @@ -183,12 +183,16 @@ export type ComposerCitationCommentRequest = { value: string; citationStart: number; sourceAnchor: AssistantCitationSourceAnchor; + insertedSpaces: CitationInsertedSpaces; }; +// Spaces added around a freshly inserted citation, removed with it if its comment is cancelled. +type CitationInsertedSpaces = { before: boolean; after: boolean }; + type OpenCitationComment = { key: string; sourceAnchor?: AssistantCitationSourceAnchor; - removeOnCancel?: boolean; + removeOnCancel?: CitationInsertedSpaces; }; const ComposerCitationCommentContext = createContext<{ @@ -392,18 +396,20 @@ function ComposerCitationNodeView({ node, editor, getPos }: NodeViewProps) { [editor, nodePos], ); + // Undo a fresh insertion: drop the chip and the spaces inserted with it. + const removeOnCancel = commentTarget?.removeOnCancel; const onRemove = useCallback(() => { - if (!editor.isEditable) return; + if (!editor.isEditable || !removeOnCancel) return; const pos = nodePos(); if (pos === null) return; - const current = editor.state.doc.nodeAt(pos); + const { doc } = editor.state; + const current = doc.nodeAt(pos); if (!current) return; - editor - .chain() - .focus() - .deleteRange({ from: pos, to: pos + current.nodeSize }) - .run(); - }, [editor, nodePos]); + const end = pos + current.nodeSize; + const from = removeOnCancel.before && doc.textBetween(pos - 1, pos) === " " ? pos - 1 : pos; + const to = removeOnCancel.after && doc.textBetween(end, end + 1) === " " ? end + 1 : end; + editor.chain().focus().deleteRange({ from, to }).run(); + }, [editor, nodePos, removeOnCancel]); // Put the caret right after the chip so Enter sends and typing continues the prompt. const onRestoreFocus = useCallback(() => { @@ -450,7 +456,7 @@ function ComposerCitationNodeView({ node, editor, getPos }: NodeViewProps) { if (open && !editor.isEditable) return; commentContext.onOpenChange(citeKey, open); }, - ...(commentTarget?.removeOnCancel ? { onCancel: onRemove } : {}), + ...(removeOnCancel ? { onCancel: onRemove } : {}), onSave: onSaveComment, onSaveAndSend: (comment) => { if (!onSaveComment(comment)) return false; @@ -1531,7 +1537,7 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { setOpenCitation({ key: citeKey, sourceAnchor: pendingCitation.sourceAnchor, - removeOnCancel: true, + removeOnCancel: pendingCitation.insertedSpaces, }); } } @@ -1618,7 +1624,7 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { setOpenCitation({ key: citeKey, sourceAnchor: request.sourceAnchor, - removeOnCancel: true, + removeOnCancel: request.insertedSpaces, }); } }, diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 4923e1fc47e7..c7eb2628e1c9 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -200,7 +200,11 @@ import { shouldUseRestingComposerLayout, } from "../composerFooterLayout"; import { measureRestingComposerControls } from "./restingComposerControlsMeasurement"; -import { type ComposerPromptEditorHandle, ComposerPromptEditor } from "../ComposerPromptEditor"; +import { + type ComposerCitationCommentRequest, + type ComposerPromptEditorHandle, + ComposerPromptEditor, +} from "../ComposerPromptEditor"; import { ComposerContextActionsContext, composerContextRecordsFromDraft, @@ -3832,7 +3836,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) expectedText?: string; expandedCursorAfterReplace?: number; focusEditorAfterReplace?: boolean; - citationComment?: { start: number; sourceAnchor: AssistantCitationSourceAnchor }; + citationComment?: { + start: number; + sourceAnchor: AssistantCitationSourceAnchor; + insertedSpaces: ComposerCitationCommentRequest["insertedSpaces"]; + }; }, ): boolean => { if ( @@ -3862,6 +3870,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) value: next.text, citationStart: options.citationComment.start, sourceAnchor: options.citationComment.sourceAnchor, + insertedSpaces: options.citationComment.insertedSpaces, }); } promptRef.current = next.text; @@ -6122,6 +6131,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) citationComment: { start: cursor + (needsLeadingSpace ? 1 : 0), sourceAnchor: options.citationCommentAnchor, + // An existing space after the cursor is consumed and re-emitted, so it stays. + insertedSpaces: { before: needsLeadingSpace, after: rangeEnd === cursor }, }, focusEditorAfterReplace: false, } From ff3a04a8ebaaf6bb74b8b5b996013b82e044c700 Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Thu, 8 Oct 2026 05:32:58 +0530 Subject: [PATCH 20/25] fix(settings): provider updates show live progress instead of a bare spinner (#16958) Co-authored-by: PR Batch Tester Co-authored-by: Claude Opus 5.5 (1M context) --- .../providerMaintenanceRunner.test.ts | 66 +++++++++++- .../src/provider/providerMaintenanceRunner.ts | 101 +++++++++++++++--- .../settings/ProviderInstanceCard.tsx | 59 ++++++++-- 3 files changed, 201 insertions(+), 25 deletions(-) diff --git a/apps/server/src/provider/providerMaintenanceRunner.test.ts b/apps/server/src/provider/providerMaintenanceRunner.test.ts index c82ec00143f2..b6580c78856d 100644 --- a/apps/server/src/provider/providerMaintenanceRunner.test.ts +++ b/apps/server/src/provider/providerMaintenanceRunner.test.ts @@ -7,6 +7,8 @@ import { } from "@t3tools/contracts"; import { ServerProviderUpdateError } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; +import * as Deferred from "effect/Deferred"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; @@ -15,6 +17,7 @@ import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; import { HttpClient, HttpClientResponse } from "effect/http"; import { ChildProcessSpawner } from "effect/process"; import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; @@ -259,8 +262,14 @@ describe("providerMaintenanceRunner", () => { ]); assert.strictEqual(result.providers[0]?.updateState?.status, "succeeded"); assert.deepStrictEqual( - (yield* Ref.get(updateStatesRef)).map((state) => state.status), - ["queued", "running", "succeeded"], + (yield* Ref.get(updateStatesRef)).map((state) => [state.status, state.message]), + [ + ["queued", "Waiting for another provider update to finish."], + ["running", "Checking for the latest version"], + ["running", "Running native-agent update"], + ["running", "Verifying the installed version"], + ["succeeded", "Provider updated."], + ], ); }).pipe( Effect.provide( @@ -406,6 +415,59 @@ describe("providerMaintenanceRunner", () => { ); }); + it.effect("reports the installer's latest output line while the update runs", () => { + const exited = Deferred.makeUnsafe(); + return Effect.gen(function* () { + const { registry, updateStatesRef } = yield* makeRegistry(baseNativeCliProvider); + const updater = yield* makeTestRunner(registry); + const runningMessages = Ref.get(updateStatesRef).pipe( + Effect.map((states) => + states.filter((state) => state.status === "running").map((state) => state.message), + ), + ); + + const fiber = yield* updater.updateProvider(NATIVE_CLI_DRIVER).pipe(Effect.forkChild); + yield* TestClock.adjust(Duration.seconds(1)); + assert.deepStrictEqual(yield* runningMessages, [ + "Checking for the latest version", + "Running native-agent update", + "80%", + ]); + + yield* Deferred.succeed(exited, ChildProcessSpawner.ExitCode(0)); + const result = yield* Fiber.join(fiber); + assert.strictEqual(result.providers[0]?.updateState?.status, "succeeded"); + }).pipe( + Effect.provide( + Layer.mergeAll( + layerNonWindowsPlatform, + layerLatestVersionHttpClient("0.0.0"), + layerMockSpawner(() => ({ + stdout: "Checking for updates\nDownloading 2.1.293\n\u001b[32m 40%\u001b[0m\r 80%", + exitCode: Deferred.await(exited), + })), + ), + ), + ); + }); + + it("splits installer output into clean status lines", () => { + assert.deepStrictEqual(ProviderMaintenanceRunner.splitOutputLines("Down", "loading\r\n 5"), { + lines: ["Downloading"], + partialLine: " 5", + }); + assert.strictEqual( + ProviderMaintenanceRunner.toProgressLine("\u001b[1m==>\u001b[0m Pouring"), + "==> Pouring", + ); + assert.strictEqual(ProviderMaintenanceRunner.toProgressLine(" \t "), null); + assert.strictEqual( + ProviderMaintenanceRunner.splitOutputLines("x".repeat(5_000), "y").partialLine.length, + 4_096, + ); + assert.strictEqual(ProviderMaintenanceRunner.toProgressLine("x".repeat(300))?.length, 200); + }); + it.effect("aborts without running when the installation changed since the advisory", () => { const calls: Array = []; return Effect.gen(function* () { diff --git a/apps/server/src/provider/providerMaintenanceRunner.ts b/apps/server/src/provider/providerMaintenanceRunner.ts index 1e22ba899bec..cc8b3728785f 100644 --- a/apps/server/src/provider/providerMaintenanceRunner.ts +++ b/apps/server/src/provider/providerMaintenanceRunner.ts @@ -17,9 +17,12 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; +import * as Schedule from "effect/Schedule"; import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; import { HttpClient } from "effect/http"; import { ChildProcess, ChildProcessSpawner } from "effect/process"; +import * as NodeUtil from "node:util"; import * as ModelManifest from "./ModelManifest.ts"; import { resolveProviderCompatibility } from "./providerCompatibility.ts"; @@ -38,6 +41,12 @@ const isServerProviderUpdateError = Schema.is(ServerProviderUpdateError); const UPDATE_TIMEOUT_MS = 5 * 60_000; const UPDATE_OUTPUT_MAX_BYTES = 10_000; +// Every progress publish resends the provider list to each client, so the +// installer's output is sampled rather than forwarded line by line. +const UPDATE_PROGRESS_INTERVAL = Duration.seconds(1); +const UPDATE_PROGRESS_MAX_LENGTH = 200; +// An installer may write for minutes without a line ending; only its tail matters. +const UPDATE_PARTIAL_LINE_MAX_LENGTH = 4_096; export interface ProviderMaintenanceCommandResult { readonly stdout: string; @@ -83,6 +92,7 @@ const runProviderMaintenanceCommandWithSpawner = Effect.fn("ProviderMaintenanceR readonly command: string; readonly args: ReadonlyArray; readonly env?: NodeJS.ProcessEnv; + readonly onProgress?: (line: string) => Effect.Effect; }) { const collectCommandResult = Effect.fn("ProviderMaintenanceRunner.collectCommandResult")( function* () { @@ -110,14 +120,44 @@ const runProviderMaintenanceCommandWithSpawner = Effect.fn("ProviderMaintenanceR ); yield* Effect.addFinalizer(() => child.kill().pipe(Effect.ignore)); + // Holds the newest output line not yet reported; the sampler takes it. + const pendingProgress = yield* Ref.make(null); + const onProgress = input.onProgress; + if (onProgress) { + yield* Ref.getAndSet(pendingProgress, null).pipe( + Effect.flatMap((line) => (line === null ? Effect.void : onProgress(line))), + Effect.repeat(Schedule.spaced(UPDATE_PROGRESS_INTERVAL)), + Effect.forkScoped, + ); + } + const trackProgress = (stream: Stream.Stream) => { + if (!onProgress) { + return stream; + } + const decoder = new TextDecoder(); + let partialLine = ""; + return stream.pipe( + Stream.tap((chunk) => { + const split = splitOutputLines(partialLine, decoder.decode(chunk, { stream: true })); + partialLine = split.partialLine; + // A progress bar that redraws with a leading `\r` keeps its newest + // frame unterminated, so that frame is the latest status. + const line = + toProgressLine(split.partialLine) ?? + split.lines.map(toProgressLine).findLast((value) => value !== null); + return line ? Ref.set(pendingProgress, line) : Effect.void; + }), + ); + }; + const [stdout, stderr, exitCode] = yield* Effect.all( [ collectUint8StreamText({ - stream: child.stdout, + stream: trackProgress(child.stdout), maxBytes: UPDATE_OUTPUT_MAX_BYTES, }), collectUint8StreamText({ - stream: child.stderr, + stream: trackProgress(child.stderr), maxBytes: UPDATE_OUTPUT_MAX_BYTES, }), child.exitCode, @@ -165,6 +205,35 @@ const runProviderMaintenanceCommandWithSpawner = Effect.fn("ProviderMaintenanceR }, ); +/** + * Split a chunk of installer output into complete lines. `\r` ends a line too, + * so a redrawn progress bar reports its latest frame. + */ +export function splitOutputLines( + partialLine: string, + text: string, +): { readonly lines: ReadonlyArray; readonly partialLine: string } { + const parts = (partialLine + text).split(/\r\n|\r|\n/); + return { partialLine: (parts.pop() ?? "").slice(-UPDATE_PARTIAL_LINE_MAX_LENGTH), lines: parts }; +} + +/** Turn one raw output line into a short status message, or null if it has no text. */ +export function toProgressLine(line: string): string | null { + const text = NodeUtil.stripVTControlCharacters(line).replace(/\s+/g, " ").trim(); + if (text.length === 0) { + return null; + } + return text.length <= UPDATE_PROGRESS_MAX_LENGTH + ? text + : `${text.slice(0, UPDATE_PROGRESS_MAX_LENGTH - 1)}…`; +} + +/** `claude update` reads better in a status line than the full executable path. */ +function describeCommand(command: ProviderMaintenanceCommandAction): string { + const executable = command.executable.split(/[\\/]/).pop() || command.executable; + return [executable, ...command.args].join(" "); +} + function trimNullable(value: string): string | null { const trimmed = value.trim(); return trimmed.length > 0 ? trimmed : null; @@ -223,11 +292,15 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const httpClient = yield* HttpClient.HttpClient; const versionCache = yield* ProviderVersionCache; - const runMaintenanceCommand = (update: ProviderMaintenanceCommandAction) => + const runMaintenanceCommand = ( + update: ProviderMaintenanceCommandAction, + onProgress: (line: string) => Effect.Effect, + ) => runProviderMaintenanceCommandWithSpawner({ spawner, command: update.executable, args: update.args, + onProgress, ...(update.env ? { env: update.env } : {}), }); const commandCoordinator = yield* makeProviderMaintenanceCommandCoordinator({ @@ -355,14 +428,16 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { function* () { const startedAt = yield* nowIso; yield* Ref.set(startedAtRef, startedAt); - yield* setUpdateState( - makeUpdateState({ - status: "running", - startedAt, - finishedAt: null, - message: "Updating provider.", - }), - ); + const setRunningMessage = (message: string) => + setUpdateState( + makeUpdateState({ + status: "running", + startedAt, + finishedAt: null, + message, + }), + ).pipe(Effect.asVoid); + yield* setRunningMessage("Checking for the latest version"); // The cached capabilities chose the lock; re-derive ownership // now so the command that runs matches the executable as it is @@ -420,7 +495,8 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { }), ); } - const result = yield* runMaintenanceCommand(command); + yield* setRunningMessage(`Running ${describeCommand(command)}`); + const result = yield* runMaintenanceCommand(command, setRunningMessage); const finishedAt = yield* nowIso; if (result.timedOut || result.exitCode !== 0) { return yield* finish( @@ -434,6 +510,7 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { ); } + yield* setRunningMessage("Verifying the installed version"); // Homebrew's "latest" moves once the upgrade lands; read it again. const verified = yield* providerRegistry.getProviderMaintenanceCapabilitiesForInstance( instanceId, diff --git a/apps/web/src/components/settings/ProviderInstanceCard.tsx b/apps/web/src/components/settings/ProviderInstanceCard.tsx index 381dcd864e42..eab2fba04cb2 100644 --- a/apps/web/src/components/settings/ProviderInstanceCard.tsx +++ b/apps/web/src/components/settings/ProviderInstanceCard.tsx @@ -590,6 +590,18 @@ export function ProviderInstanceCard({ enabled, ); const updateCommand = versionAdvisory?.updateCommand ?? null; + const updateState = liveProvider?.updateState; + // The server reports each update step. `isUpdating` also covers the moment + // between the click and the server's first report. + const updateProgress = isUpdating + ? ((updateState?.status === "queued" || updateState?.status === "running" + ? updateState.message + : null) ?? "Starting update") + : null; + const updateProblem = + !isUpdating && (updateState?.status === "failed" || updateState?.status === "unchanged") + ? updateState.message + : null; const hasCompatibilityWarning = compatibility !== undefined && compatibility.status !== "supported" && @@ -791,20 +803,26 @@ export function ProviderInstanceCard({ size={mode === "list" ? "icon-micro" : "icon-xs"} variant="ghost-muted" className={mode === "list" ? "pointer-events-auto relative shrink-0" : undefined} - aria-label={`${versionAdvisory.title} — view details`} + aria-label={`${updateProgress ? "Updating" : versionAdvisory.title} — view details`} > - + {updateProgress ? ( + + ) : ( + + )} } /> } /> - {versionAdvisory.title} + + {updateProgress ? "Updating" : versionAdvisory.title} +
    @@ -838,6 +856,17 @@ export function ProviderInstanceCard({ : "Update now"} ) : null} + {updateProgress || updateProblem ? ( +

    + {updateProgress ?? updateProblem} +

    + ) : null} {onRunVersionAction && updateCommand ? (
    @@ -908,16 +937,24 @@ export function ProviderInstanceCard({ {versionAdvisoryNode} - {statusDotNode ? ( + {/* The dot describes provider health, not the update in progress. */} + {statusDotNode && !updateProgress ? ( {statusDotNode} ) : null} - {summary.headline} - {needsAttention && inlineStatusDetail ? ` · ${inlineStatusDetail}` : null} + {updateProgress ? ( + `Updating · ${updateProgress}` + ) : ( + <> + {summary.headline} + {needsAttention && inlineStatusDetail ? ` · ${inlineStatusDetail}` : null} + + )} From 14fe0158ede3d964f183e281f9fec225d30a6b32 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 7 Oct 2026 17:11:05 -0700 Subject: [PATCH 21/25] feat(web): find in diffs with Cmd+F (#14623) Co-authored-by: Claude Opus 5.5 (1M context) --- apps/web/src/components/DiffPanel.tsx | 13 + apps/web/src/components/RightPanelTabs.tsx | 3 +- .../components/diffs/AnnotatableCodeView.tsx | 4 + .../components/diffs/codeViewSearch.test.ts | 106 +++ .../pullRequest/PullRequestCodeTab.tsx | 6 + apps/web/src/index.css | 8 + apps/web/src/lib/editableFocus.test.ts | 25 + apps/web/src/lib/editableFocus.ts | 6 +- docs/user/keybindings.md | 9 + patches/@pierre%2Fdiffs@1.5.2.patch | 869 +++++++++++++++++- pnpm-lock.yaml | 10 +- 11 files changed, 1042 insertions(+), 17 deletions(-) create mode 100644 apps/web/src/components/diffs/codeViewSearch.test.ts create mode 100644 apps/web/src/lib/editableFocus.test.ts diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index cc5354f38354..633094738e9e 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -695,6 +695,18 @@ export default function DiffPanel({ return { scopeKey: collapseScopeKey, fileKeys: next }; }); }, []); + // Find can ask again before the unfolded file reaches the viewer, so this must never fold. + const unfoldDiffFile = useCallback((fileKey: string) => { + const { collapseScopeKey, defaultCollapsedDiffFileKeys } = collapseDefaultsRef.current; + setCollapsedDiffFiles((current) => { + const fileKeys = + current.scopeKey === collapseScopeKey ? current.fileKeys : defaultCollapsedDiffFileKeys; + if (!fileKeys.has(fileKey)) return current; + const next = new Set(fileKeys); + next.delete(fileKey); + return { scopeKey: collapseScopeKey, fileKeys: next }; + }); + }, []); const toggleDiffFileCollapse = useCallback(() => { setCodeViewRevision((current) => current + 1); @@ -1197,6 +1209,7 @@ export default function DiffPanel({ }, } : {})} + onRevealSearchMatch={unfoldDiffFile} renderHeaderPrefix={(fileDiff, fileKey) => { const unavailable = fileDiff.cacheKey?.endsWith(":pending") === true; return ( diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx index 4f6f3607f470..2c7e3e3da68c 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -415,7 +415,8 @@ function RightPanelEmptyState(props: { const action = surfaceShortcutActionForKey(shortcutActionsRef.current, event); if (!action) return; if (document.querySelector(LAUNCHER_SHORTCUT_BLOCKING_LAYERS)) return; - const target = event.target; + // The composed path starts at the real target, which may sit inside a shadow root. + const target = event.composedPath()[0] ?? event.target; if (target instanceof Element && surfaceShortcutTargetsTypingContext(target)) return; event.preventDefault(); event.stopPropagation(); diff --git a/apps/web/src/components/diffs/AnnotatableCodeView.tsx b/apps/web/src/components/diffs/AnnotatableCodeView.tsx index ab4f545ed07b..b36fd8b3cb37 100644 --- a/apps/web/src/components/diffs/AnnotatableCodeView.tsx +++ b/apps/web/src/components/diffs/AnnotatableCodeView.tsx @@ -95,6 +95,8 @@ interface AnnotatableCodeViewProps { fileKey: string, collapsed: boolean, ) => ReactNode; + /** Unfold a collapsed file that holds the find match being navigated to. */ + onRevealSearchMatch: (fileKey: string) => void; } interface DiffSelectionContext { @@ -115,6 +117,7 @@ export function AnnotatableCodeView({ renderHeaderMetadata, renderHeaderFilenameSuffix, renderHeaderPrefix, + onRevealSearchMatch, }: AnnotatableCodeViewProps) { const addReviewComment = useComposerDraftStore((store) => store.addReviewComment); const removeReviewComment = useComposerDraftStore((store) => store.removeReviewComment); @@ -262,6 +265,7 @@ export function AnnotatableCodeView({ items={items} selectedLines={selectedLines} onSelectedLinesChange={setSelectedLines} + onRevealSearchMatch={(item) => onRevealSearchMatch(item.id)} options={{ ...options, enableGutterUtility: !hasOpenComment, diff --git a/apps/web/src/components/diffs/codeViewSearch.test.ts b/apps/web/src/components/diffs/codeViewSearch.test.ts new file mode 100644 index 000000000000..80af9f5ed2f9 --- /dev/null +++ b/apps/web/src/components/diffs/codeViewSearch.test.ts @@ -0,0 +1,106 @@ +import type { CodeViewItem } from "@pierre/diffs"; +import { parsePatchFiles } from "@pierre/diffs/utils/parsePatchFiles"; +import { describe, expect, it } from "vite-plus/test"; + +// Find-in-diff lives in our @pierre/diffs patch and is not a package export. +const searchUrl = new URL("./components/CodeViewSearch.js", import.meta.resolve("@pierre/diffs")); +const { collectMatches } = (await import(/* @vite-ignore */ searchUrl.href)) as { + collectMatches( + items: ReadonlyArray>, + params: { text: string; caseSensitive: boolean; wholeWord: boolean; regex: boolean }, + ): Array<{ + id: string; + side: string; + isContext: boolean; + lineNumber: number; + start: number; + end: number; + }>; +}; + +const PATCH = `diff --git a/src/a.ts b/src/a.ts +index 1111111..2222222 100644 +--- a/src/a.ts ++++ b/src/a.ts +@@ -1,4 +1,5 @@ + const keep = 1; +-const oldName = 2; ++const newName = 2; ++const added = oldName; + const tail = 4; +@@ -20,3 +21,3 @@ function x() { + ctx20 name +-del21 Name ++add22 names + ctx22 +diff --git a/src/b.ts b/src/b.ts +index 3333333..4444444 100644 +--- a/src/b.ts ++++ b/src/b.ts +@@ -1,1 +1,1 @@ +-nil ++: nil +`; + +const items: CodeViewItem[] = parsePatchFiles(PATCH)[0]!.files.map( + (fileDiff, index) => ({ id: `file-${index}`, type: "diff", fileDiff, collapsed: index === 1 }), +); + +function find( + text: string, + flags: { caseSensitive?: boolean; wholeWord?: boolean; regex?: boolean } = {}, +) { + return collectMatches(items, { + text, + caseSensitive: flags.caseSensitive ?? false, + wholeWord: flags.wholeWord ?? false, + regex: flags.regex ?? false, + }).map(({ id, side, lineNumber, start, end }) => `${id}:${side}:${lineNumber}:${start}-${end}`); +} + +describe("collectMatches", () => { + it("finds removed, added, and context lines in render order, folded files included", () => { + expect(find("oldName")).toEqual(["file-0:deletions:2:6-13", "file-0:additions:3:14-21"]); + expect(find("nil")).toEqual(["file-1:deletions:1:0-3", "file-1:additions:1:2-5"]); + }); + + it("honours case, whole-word, and regex toggles", () => { + expect(find("Name", { caseSensitive: true })).toEqual([ + "file-0:deletions:2:9-13", + "file-0:additions:2:9-13", + "file-0:additions:3:17-21", + "file-0:deletions:21:6-10", + ]); + expect(find("name", { wholeWord: true })).toEqual([ + "file-0:additions:21:6-10", + "file-0:deletions:21:6-10", + ]); + expect(find("n[a-z]+e", { regex: true })).toHaveLength(6); + }); + + it("marks unchanged lines, which split view draws in both columns", () => { + const flags = collectMatches(items, { + text: "ctx", + caseSensitive: false, + wholeWord: false, + regex: false, + }).map(({ lineNumber, isContext }) => [lineNumber, isContext]); + expect(flags).toEqual([ + [21, true], + [23, true], + ]); + expect( + collectMatches(items, { + text: "oldName", + caseSensitive: false, + wholeWord: false, + regex: false, + }).map(({ isContext }) => isContext), + ).toEqual([false, false]); + }); + + it("returns nothing for an empty query or an invalid pattern", () => { + expect(find("")).toEqual([]); + expect(find("(", { regex: true })).toEqual([]); + }); +}); diff --git a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx index e1c6f1de0453..9af92ee96a81 100644 --- a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx @@ -1516,6 +1516,12 @@ function PullRequestCodeTab({ renderHeaderPrefix={renderHeaderPrefix} renderHeaderMetadata={renderHeaderMetadata} renderAnnotation={renderAnnotation} + // Find can ask again before the unfolded file arrives, so this unfolds and never folds. + onRevealSearchMatch={(item) => + setToggledFiles((current) => + toggleFileDiffFoldForViewed(item.id, false, effectiveFoldOverride, current), + ) + } unsafeCSSExtra={REPLACE_FILE_COUNTS_CSS} />
    diff --git a/apps/web/src/index.css b/apps/web/src/index.css index d43fc36769a1..453a9fd599fe 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -2087,6 +2087,14 @@ code { color: var(--code-foreground); } +/* Pierre's find panel and match highlights read these. */ +.diff-render-surface { + --diffs-search-panel-bg: var(--popover); + --diffs-search-panel-fg: var(--popover-foreground); + --diffs-search-panel-accent: var(--primary); + --diffs-search-panel-font: var(--font-sans); +} + /* Pierre's file renderer owns its own surface tokens. Keep the root of a file preview on the same themeable code surface as the diff panel. */ .diff-render-surface diffs-container, diff --git a/apps/web/src/lib/editableFocus.test.ts b/apps/web/src/lib/editableFocus.test.ts new file mode 100644 index 000000000000..1028b98c6071 --- /dev/null +++ b/apps/web/src/lib/editableFocus.test.ts @@ -0,0 +1,25 @@ +// @vitest-environment jsdom + +import { afterEach, describe, expect, it } from "vite-plus/test"; + +import { isEditableFocused } from "./editableFocus"; + +afterEach(() => { + document.body.replaceChildren(); +}); + +describe("isEditableFocused", () => { + it("sees a text field focused inside an open shadow root", () => { + // A key event from a shadow input is retargeted to its host, so the host is + // what page-level shortcut handlers receive as the event target. + const host = document.createElement("div"); + const input = document.createElement("input"); + host.attachShadow({ mode: "open" }).append(input); + document.body.append(host); + + expect(isEditableFocused(host)).toBe(false); + input.focus(); + expect(isEditableFocused(host)).toBe(true); + expect(isEditableFocused()).toBe(true); + }); +}); diff --git a/apps/web/src/lib/editableFocus.ts b/apps/web/src/lib/editableFocus.ts index ad704a39f616..79af4774b49e 100644 --- a/apps/web/src/lib/editableFocus.ts +++ b/apps/web/src/lib/editableFocus.ts @@ -11,7 +11,11 @@ const EDITABLE_SELECTOR = [ /** * Whether a text-editing element owns the keyboard. Shortcuts that share * their chord with native editing (mod+z) must yield when this is true. + * Focus inside an open shadow root counts: a key event's target is retargeted + * to the shadow host, so the host's focused descendant is what is typed into. */ export function isEditableFocused(target: EventTarget | null = document.activeElement): boolean { - return target instanceof Element && target.closest(EDITABLE_SELECTOR) !== null; + let element = target instanceof Element ? target : null; + while (element?.shadowRoot?.activeElement) element = element.shadowRoot.activeElement; + return element !== null && element.closest(EDITABLE_SELECTOR) !== null; } diff --git a/docs/user/keybindings.md b/docs/user/keybindings.md index 488b57b997cb..2d7c786cc3f8 100644 --- a/docs/user/keybindings.md +++ b/docs/user/keybindings.md @@ -46,6 +46,15 @@ to copy its URL and `mod+shift+k` to copy its number with a `#` prefix. Both shortcuts can be changed in Settings. Search for “Copy Link or Thread ID” or “Copy Number”. They copy the selected PR and leave terminal input alone. +## Find in a diff + +Click into a diff in the Diff panel or a pull request's Code tab, then press +`mod+f` to search every file in it, including folded files and unchanged lines +hidden between changes. Enter and `Shift+Enter` move between matches, and a +match in a folded file opens it. Escape closes the search. This shortcut is not +configurable. A very large uncommitted diff loads its files as you scroll, and +find only searches the files loaded so far. + ## iPad With a hardware keyboard, use `Cmd+1` through `Cmd+9` to open the first nine diff --git a/patches/@pierre%2Fdiffs@1.5.2.patch b/patches/@pierre%2Fdiffs@1.5.2.patch index f1984ea41835..fbb672dc90ab 100644 --- a/patches/@pierre%2Fdiffs@1.5.2.patch +++ b/patches/@pierre%2Fdiffs@1.5.2.patch @@ -1,7 +1,704 @@ +diff --git a/dist/components/CodeView.d.ts b/dist/components/CodeView.d.ts +index cc32d28..3a46bf4 100644 +--- a/dist/components/CodeView.d.ts ++++ b/dist/components/CodeView.d.ts +@@ -158,6 +158,12 @@ interface CodeViewOptions extends CodeViewPassThroughOptions + * final pre-detach state. + */ + onItemEditComplete?: CodeViewItemEditCompleteHandler; ++ /** ++ * Called when find navigates to a match inside a collapsed item. Folding is ++ * host state, so the host unfolds the item (a controlled update with ++ * `collapsed: false` and a new `version`); the viewer then scrolls to it. ++ */ ++ onRevealSearchMatch?(item: CodeViewItem): void; + /** Render a non-virtualized element at the very start of the scroll content, + * before the first item. It is always rendered and scrolls with the content. + * Return the same element across calls and mutate it in place to update; +@@ -268,6 +274,13 @@ declare class CodeView { + getSelectedLines(): CodeViewLineSelection | null; + clearSelectedLines(options?: SelectionWriteOptions): void; + getItem(itemId: string): CodeViewItem | undefined; ++ /** ++ * Open the find panel, or refocus it when open. It searches every diff ++ * item's lines (including folded files and virtualized rows), and is also ++ * opened by the primary-modifier+F shortcut while focus is in the viewer. ++ */ ++ openSearch(): void; ++ closeSearch(): void; + /** + * Returns the editor while this item is being edited. Partial diffs get an + * editor after their files load; scrolling away or collapsing keeps it. +diff --git a/dist/components/CodeView.js b/dist/components/CodeView.js +index 140f94f..96fc774 100644 +--- a/dist/components/CodeView.js ++++ b/dist/components/CodeView.js +@@ -16,6 +16,7 @@ import { prefersReducedMotion } from "../utils/prefersReducedMotion.js"; + import { roundToDevicePixel } from "../utils/roundToDevicePixel.js"; + import { VirtualizedFile } from "./VirtualizedFile.js"; + import { VirtualizedFileDiff } from "./VirtualizedFileDiff.js"; ++import { CodeViewSearch } from "./CodeViewSearch.js"; + //#region src/components/CodeView.ts + const CODE_VIEW_DIFF_OPTION_KEYS = [ + "theme", +@@ -114,6 +115,8 @@ function defineItemOption(target, key, get) { + } + const DEFAULT_SCROLL_INTERACTION_RESTORE_DELAY_MS = 120; + const SUB_PIXEL_TOLERANCE = 1; ++/** Room the find panel covers at the top of the viewport; matches under it get scrolled clear. */ ++const SEARCH_PANEL_RESERVED_HEIGHT = 48; + const SCROLLING_CODE_OVERFLOW_FIX_VARIABLE = "--diffs-overflow-override"; + function createScrollRebaseConfig(containerHeight, triggerTop, targetTop) { + return { +@@ -211,6 +214,8 @@ var CodeView = class CodeView { + isReadySubscription; + pendingHighlighterTheme; + isContainerManaged; ++ search = new CodeViewSearch(this); ++ pendingSearchReveal; + constructor(options = { theme: DEFAULT_THEMES }, workerManager, isContainerManaged = false) { + this.options = options; + this.computeMetricsCache(options.itemMetrics); +@@ -392,6 +397,7 @@ var CodeView = class CodeView { + this.root.addEventListener("touchstart", this.clearPendingScroll, { passive: true }); + this.root.addEventListener("pointerdown", this.clearPendingScroll, { passive: true }); + this.root.addEventListener("keydown", this.clearPendingScroll, { passive: true }); ++ this.root.addEventListener("keydown", this.search.handleKeyDown); + this.resizeObserver.observe(this.root); + this.render(true); + window.__INSTANCE = this; +@@ -464,6 +470,7 @@ var CodeView = class CodeView { + this.heightDirty = true; + this.resetRenderState(); + if (!this.isContainerManaged) this.flushSlotCoordinator(); ++ this.search.refresh(); + if (failed) throw failure; + } + cleanUp() { +@@ -485,6 +492,8 @@ var CodeView = class CodeView { + this.root?.removeEventListener("touchstart", this.clearPendingScroll); + this.root?.removeEventListener("pointerdown", this.clearPendingScroll); + this.root?.removeEventListener("keydown", this.clearPendingScroll); ++ this.root?.removeEventListener("keydown", this.search.handleKeyDown); ++ this.search.cleanUp(); + this.root?.style.removeProperty("overflow-anchor"); + this.container?.remove(); + this.stickyOffset.remove(); +@@ -626,6 +635,137 @@ var CodeView = class CodeView { + getItem(itemId) { + return this.idToItem.get(itemId)?.item; + } ++ /** Open the find panel, or refocus it when it is already open. */ ++ openSearch() { ++ this.search.open(); ++ } ++ closeSearch() { ++ this.search.close(); ++ } ++ getSearchItems() { ++ return this.items.map((item) => item.item); ++ } ++ /** Index of the first match at or below the top of the viewport, or 0 when every match is above it. */ ++ getFirstVisibleSearchMatchIndex(entries) { ++ if (this.items.length === 0) return 0; ++ const visibleTop = this.getScrollTop() - this.getItemTopOffset() + this.getStickyHeaderOffset(); ++ const itemIndex = this.findFirstVisibleIndex(Math.max(this.getScrollTop() - this.getItemTopOffset(), 0)); ++ const item = this.items[itemIndex]; ++ for (let index = 0; index < entries.length; index++) { ++ const entry = entries[index]; ++ if (entry.itemIndex < itemIndex) continue; ++ if (entry.itemIndex > itemIndex) return index; ++ const position = this.getLineScrollPosition(item, entry); ++ if (position == null || item.top + position.top >= visibleTop) return index; ++ } ++ return 0; ++ } ++ /** ++ * Bring a match on screen. A folded file is unfolded by the host through ++ * `onRevealSearchMatch` (folding is host state); the reveal resumes once the ++ * item arrives unfolded. Collapsed context around the line is expanded here. ++ */ ++ revealSearchMatch(match, afterUnfold = false) { ++ const item = this.idToItem.get(match.id); ++ if (item == null || item.type !== "diff") return; ++ if (item.item.collapsed === true) { ++ if (this.options.onRevealSearchMatch == null) { ++ this.scrollTo({ ++ type: "item", ++ id: match.id, ++ align: "start" ++ }); ++ return; ++ } ++ this.pendingSearchReveal = match; ++ this.options.onRevealSearchMatch(item.item); ++ return; ++ } ++ const target = { ++ type: "line", ++ id: match.id, ++ lineNumber: match.lineNumber, ++ side: match.side, ++ align: "center", ++ // Center in the space below the sticky header and the find panel. ++ offset: -(this.getStickyHeaderOffset() + SEARCH_PANEL_RESERVED_HEIGHT) / 2 ++ }; ++ // Before its first render a file reports every line renderable, so collapsed ++ // context can't be expanded yet: scroll there and finish once it has rendered. ++ const rendered = item.instance.getRenderedDiff() != null; ++ this.pendingSearchReveal = rendered ? void 0 : match; ++ if (rendered && match.side === "additions" && !item.instance.isLineRenderable(match.lineNumber)) item.instance.revealLine(match.lineNumber); ++ else if (rendered && !afterUnfold && this.isScrollTargetInView(target)) return; ++ this.scrollTo(target); ++ } ++ cancelSearchReveal() { ++ this.pendingSearchReveal = void 0; ++ } ++ /** Finish a reveal that was waiting for its file to render for the first time. */ ++ resumePendingSearchReveal(updatedItems) { ++ const reveal = this.pendingSearchReveal; ++ if (reveal == null) return; ++ for (const item of updatedItems) if (item.item.id === reveal.id && item.item.collapsed !== true && item.instance.getRenderedDiff() != null) { ++ queueMicrotask(() => { ++ if (this.pendingSearchReveal === reveal) this.revealSearchMatch(reveal, true); ++ }); ++ return; ++ } ++ } ++ /** Scroll a wide code column so the current match is visible when lines don't wrap. */ ++ revealSearchMatchHorizontally(match, element) { ++ const item = this.idToItem.get(match.id); ++ const code = element.closest("[data-code]"); ++ if (item == null || code == null || code.scrollWidth <= code.clientWidth) return; ++ const codeRect = code.getBoundingClientRect(); ++ const matchRect = getTextRangeRect(element, match.start, match.end); ++ if (matchRect == null) return; ++ // The line-number gutter is sticky at the left edge and covers the code under it. ++ const gutterRight = code.querySelector(":scope > [data-gutter]")?.getBoundingClientRect().right; ++ const visibleLeft = gutterRight != null && gutterRight > codeRect.left ? gutterRight : codeRect.left; ++ const padding = 24; ++ let next = code.scrollLeft; ++ if (matchRect.left < visibleLeft + padding) next -= visibleLeft + padding - matchRect.left; ++ else if (matchRect.right > codeRect.right - padding) next += matchRect.right - (codeRect.right - padding); ++ else return; ++ item.instance.setCodeScrollLeft(Math.max(0, next)); ++ } ++ /** Whether a line already sits fully below the sticky header and find panel. */ ++ isScrollTargetInView(target) { ++ const rect = this.getScrollTargetRect(target); ++ if (rect == null) return false; ++ const top = this.getItemTopOffset() + rect.top; ++ const scrollTop = this.getScrollTop(); ++ return top >= scrollTop + this.getStickyHeaderOffset() + SEARCH_PANEL_RESERVED_HEIGHT && top + rect.height <= scrollTop + this.getHeight(); ++ } ++ /** Pair each match with its mounted line element; rows outside the window are skipped. */ ++ getMountedSearchMatches(entriesByItem) { ++ const mounted = []; ++ const { firstIndex, lastIndex } = this.renderState; ++ if (firstIndex === -1) return mounted; ++ for (let index = firstIndex; index <= lastIndex; index++) { ++ const itemEntries = entriesByItem.get(index); ++ const shadowRoot = itemEntries != null ? this.items[index]?.element?.shadowRoot : void 0; ++ if (shadowRoot == null) continue; ++ const lines = /* @__PURE__ */ new Map(); ++ for (const element of shadowRoot.querySelectorAll("[data-line][data-line-index]")) { ++ const unifiedIndex = element.getAttribute("data-line-index").split(",")[0]; ++ const column = element.closest("[data-deletions]") != null ? "deletions" : "additions"; ++ lines.set(`${column}:${unifiedIndex}`, element); ++ } ++ for (const entry of itemEntries) { ++ // Split view draws unchanged context in both columns, so paint each copy. ++ const additions = lines.get(`additions:${entry.unifiedLineIndex}`); ++ const deletions = lines.get(`deletions:${entry.unifiedLineIndex}`); ++ const elements = entry.side === "deletions" ? [deletions ?? additions] : entry.isContext ? [additions, deletions] : [additions]; ++ for (const element of elements) if (element != null) mounted.push({ ++ entry, ++ element ++ }); ++ } ++ } ++ return mounted; ++ } + /** + * Returns the editor while this item is being edited. Partial diffs get an + * editor after their files load; scrolling away or collapsing keeps it. +@@ -645,6 +785,7 @@ var CodeView = class CodeView { + this.render(); + this.syncItemEditors(); + this.syncSelection(); ++ this.search.refresh(); + return true; + } + updateItemId(oldId, newId) { +@@ -678,6 +819,7 @@ var CodeView = class CodeView { + this.renamePendingScrollTarget(oldId, newId); + this.renamePendingLayoutAnchor(oldId, newId); + this.render(); ++ this.search.refresh(); + return true; + } + addItem(input) { +@@ -687,6 +829,7 @@ var CodeView = class CodeView { + this.appendItemsInternal(inputs); + this.syncItemEditors(); + this.syncSelection(); ++ this.search.refresh(); + } + removeItem(itemId) { + const item = this.idToItem.get(itemId); +@@ -709,6 +852,7 @@ var CodeView = class CodeView { + for (const removedItem of removedItemsById?.values() ?? []) removedItem.instance.cleanUp(); + } + this.syncSelection(); ++ this.search.refresh(); + } + /** + * Append new records to the viewer while preserving existing layout state. +@@ -1330,6 +1474,10 @@ var CodeView = class CodeView { + item.item = nextItem; + item.version = nextItem.version; + item.renderedOptionsRevision = -1; ++ const reveal = this.pendingSearchReveal; ++ if (reveal != null && reveal.id === nextItem.id && nextItem.collapsed !== true) queueMicrotask(() => { ++ if (this.pendingSearchReveal === reveal) this.revealSearchMatch(reveal, true); ++ }); + return true; + } + getMaxScrollTopForHeight(scrollHeight) { +@@ -1710,6 +1858,10 @@ var CodeView = class CodeView { + this.updateStickyPositioning(); + this.validateStickyContainerHeight(); + this.fixContainerFocus(); ++ if (updatedItems.size > 0) { ++ this.resumePendingSearchReveal(updatedItems); ++ this.search.repaint(); ++ } + if (fitPerfectly || this.scrollAnimation != null) this.render(); + }; + flushManagers(updatedItems) { +@@ -1804,7 +1956,8 @@ var CodeView = class CodeView { + this.notifyScroll(); + this.render(); + }; +- clearPendingScroll = () => { ++ clearPendingScroll = (event) => { ++ if (event != null && this.search.isOpen && this.search.ownsEvent(event)) return; + this.pendingScrollTarget = void 0; + this.pendingLayoutAnchor = void 0; + this.scrollAnimation = void 0; +@@ -2066,7 +2219,12 @@ var CodeView = class CodeView { + const item = this.items[index]; + if (item == null) throw new Error("CodeView.recomputeLayout: invalid item index"); + item.top = runningTop; +- if (item.type === "diff") item.height = item.instance.updateCodeViewLayout(item.item.fileDiff, runningTop, reset, item.item.annotations ?? []); ++ if (item.type === "diff") { ++ const wasPartial = item.item.fileDiff.isPartial; ++ item.height = item.instance.updateCodeViewLayout(item.item.fileDiff, runningTop, reset, item.item.annotations ?? []); ++ // Hydration fills the diff's lines in place, so the search has new text to cover. ++ if (wasPartial && !item.item.fileDiff.isPartial) this.search.refresh(true); ++ } + else item.height = item.instance.updateCodeViewLayout(item.item.file, runningTop, reset, item.item.annotations ?? []); + runningTop += item.height; + if (index < this.items.length - 1) runningTop += layout.gap; +@@ -2086,6 +2244,27 @@ var CodeView = class CodeView { + return this.getLayout().gap + this.itemMetricsCache.diffHeaderHeight; + } + }; ++function getTextRangeRect(lineElement, start, end) { ++ const walker = document.createTreeWalker(lineElement, NodeFilter.SHOW_TEXT); ++ let consumed = 0; ++ let startNode; ++ let startOffset = 0; ++ let node; ++ while ((node = walker.nextNode()) != null) { ++ const length = node.data.length; ++ if (startNode == null && start < consumed + length) { ++ startNode = node; ++ startOffset = start - consumed; ++ } ++ if (startNode != null && end <= consumed + length) { ++ const range = document.createRange(); ++ range.setStart(startNode, startOffset); ++ range.setEnd(node, end - consumed); ++ return range.getBoundingClientRect(); ++ } ++ consumed += length; ++ } ++} + function prepareItemInstance(item) { + item.instance.cleanUp(true); + if (item.type === "diff") return item.instance.updateCodeViewLayout(item.item.fileDiff, item.top, void 0, item.item.annotations ?? []); +diff --git a/dist/components/CodeViewSearch.js b/dist/components/CodeViewSearch.js +new file mode 100644 +index 0000000..29370ad +--- /dev/null ++++ b/dist/components/CodeViewSearch.js +@@ -0,0 +1,360 @@ ++import { isPrimaryModifier } from "../editor/platform.js"; ++import { resolveFindAgainShortcut } from "../editor/command.js"; ++import { SearchPanelWidget } from "../editor/searchPanel.js"; ++import { SVGSpriteSheet } from "../editor/sprite.js"; ++import editor_default from "../editor/editor2.js"; ++import { iterateOverDiff } from "../utils/iterateOverDiff.js"; ++//#region src/components/CodeViewSearch.ts ++const MAX_SEARCH_MATCHES = 1e5; ++const WORD_SEPARATORS = "`~!@#$%^&*()-=+[{]}\\|;:'\",.<>/?"; ++const SEARCH_HOST_ATTRIBUTE = "data-diffs-search"; ++const SEARCH_PANEL_HOST_CSS = `:host { ++ display: block; ++ position: sticky; ++ z-index: 100; ++ height: 0; ++} ++[data-icon-sprite] { ++ position: absolute; ++} ++[data-search-panel] { ++ position: relative; ++ top: 8px; ++} ++[data-editor-widget] { ++ --diffs-bg: var(--diffs-search-panel-bg, Canvas); ++ --diffs-fg: var(--diffs-search-panel-fg, CanvasText); ++ --diffs-modified-base: var(--diffs-search-panel-accent, light-dark(#009fff, #69b1ff)); ++ --diffs-header-font-fallback: var(--diffs-search-panel-font, system-ui, sans-serif); ++}`; ++let sharedHighlights; ++/** ++* Find-in-diff for a CodeView. It searches every diff item's line data rather ++* than the rendered rows, so matches in virtualized rows, expanded context, and ++* folded files are all found, and navigating to one reveals it. ++* ++* Matches are painted with the CSS Custom Highlight API. Ranges are rebuilt for ++* whichever rows a render leaves mounted, so highlighting never touches the ++* rendered DOM or the renderer's caches. ++*/ ++var CodeViewSearch = class { ++ #view; ++ #host; ++ #widget; ++ #entries = []; ++ #entriesByItem = /* @__PURE__ */ new Map(); ++ #current; ++ #revealHorizontally; ++ #ranges = []; ++ #searchedDiffs = []; ++ constructor(view) { ++ this.#view = view; ++ } ++ get isOpen() { ++ return this.#host != null; ++ } ++ /** Open the panel, or refocus it when it is already open. */ ++ open() { ++ if (this.#widget != null) { ++ this.#widget.setMode("find"); ++ return; ++ } ++ const root = this.#view.getContainerElement(); ++ if (root == null) return; ++ const host = document.createElement("div"); ++ host.setAttribute(SEARCH_HOST_ATTRIBUTE, ""); ++ host.style.top = `${this.#view.getStickyHeaderOffset()}px`; ++ const shadowRoot = host.attachShadow({ mode: "open" }); ++ const style = document.createElement("style"); ++ style.textContent = `${editor_default}\n${SEARCH_PANEL_HOST_CSS}`; ++ const sprite = document.createElement("div"); ++ sprite.innerHTML = SVGSpriteSheet; ++ const anchor = document.createElement("div"); ++ shadowRoot.append(style, ...sprite.children, anchor); ++ root.prepend(host); ++ this.#host = host; ++ const defaultQuery = getSelectionQuery(); ++ // The widget runs its first search a frame after opening, so callbacks from a ++ // panel that has since closed must not touch the current search. ++ const isLive = () => this.#widget === widget; ++ // Without a selection that first frame reports no matches; ignore it if a ++ // query typed before then has already found some. ++ let initializing = defaultQuery === ""; ++ const widget = new SearchPanelWidget({ ++ textDocument: { search: (params) => isLive() ? this.#search(params) : [] }, ++ containerElement: anchor, ++ defaultQuery, ++ mode: "find", ++ allowReplace: false, ++ initialMatch: defaultQuery !== "" ? [0, 0] : void 0, ++ scrollToMatch: ([offset]) => { ++ if (isLive()) this.#goTo(offset); ++ }, ++ applyReplace: () => {}, ++ onUpdate: (matches, options) => { ++ if (!isLive()) return; ++ if (matches.length === 0) { ++ if (!initializing || this.#entries.length === 0) this.#setEntries([]); ++ return; ++ } ++ if (options?.syncSelection === false) { ++ this.#paint(); ++ return this.#current != null ? matches[this.#current] : void 0; ++ } ++ const next = matches[this.#current ?? this.#view.getFirstVisibleSearchMatchIndex(this.#entries)]; ++ if (next != null) this.#goTo(next[0]); ++ return next; ++ }, ++ onClose: () => this.#teardown(true) ++ }); ++ this.#widget = widget; ++ // Runs after the widget's own first-frame callback, which was scheduled first. ++ if (initializing) requestAnimationFrame(() => { ++ initializing = false; ++ }); ++ widget.focus(); ++ } ++ close() { ++ this.#widget?.close(); ++ } ++ /** ++ * Rerun the query after the items change, keeping the current match when it ++ * survives. Updates that keep every diff (folding, annotations) only repaint; ++ * `force` covers diffs whose lines were filled in place. ++ */ ++ refresh(force = false) { ++ if (this.#widget == null) return; ++ if (!force && haveSameDiffs(this.#searchedDiffs, this.#view.getSearchItems())) { ++ this.#paint(); ++ return; ++ } ++ this.#widget.updateMatches({ syncSelection: false }); ++ } ++ /** Repaint highlights for the rows the last render pass left mounted. */ ++ repaint() { ++ if (this.#entries.length > 0) this.#paint(); ++ } ++ /** Whether an event came from the panel, which handles its own keys and pointer input. */ ++ ownsEvent(event) { ++ return event.composedPath().some((node) => node instanceof Element && node.hasAttribute(SEARCH_HOST_ATTRIBUTE)); ++ } ++ handleKeyDown = (event) => { ++ if (event.isComposing || event.keyCode === 229) return; ++ // The panel's inputs handle their own keys; its buttons get the shortcuts below. ++ if (this.ownsEvent(event) && isEditableTarget(event)) { ++ if (event.key === "Escape") event.stopPropagation(); ++ return; ++ } ++ if (event.defaultPrevented) return; ++ if (isPrimaryModifier(event) && !event.altKey && !event.shiftKey && (event.key.toLowerCase() === "f" || event.code === "KeyF")) { ++ event.preventDefault(); ++ this.open(); ++ return; ++ } ++ if (this.#widget == null) return; ++ const findAgain = resolveFindAgainShortcut(event); ++ if (findAgain != null) { ++ event.preventDefault(); ++ this.#widget.navigate(findAgain === "previous"); ++ } else if (event.key === "Escape" && !isEditableTarget(event)) { ++ event.preventDefault(); ++ event.stopPropagation(); ++ this.close(); ++ } ++ }; ++ cleanUp() { ++ this.#teardown(false); ++ } ++ #teardown(restoreFocus) { ++ if (this.#host == null) return; ++ this.#widget?.cleanup(); ++ this.#widget = void 0; ++ this.#host.remove(); ++ this.#host = void 0; ++ this.#searchedDiffs = []; ++ this.#setEntries([]); ++ if (restoreFocus) this.#view.getContainerElement()?.focus({ preventScroll: true }); ++ } ++ #search(params) { ++ const previous = this.#current != null ? this.#entries[this.#current] : void 0; ++ const items = this.#view.getSearchItems(); ++ this.#searchedDiffs = items.map((item) => [item.id, item.fileDiff]); ++ this.#setEntries(collectMatches(items, params), previous); ++ return this.#entries.map((entry) => entry.range); ++ } ++ #setEntries(entries, previous) { ++ this.#entries = entries; ++ this.#entriesByItem = groupByItem(entries); ++ this.#current = previous != null ? findSameMatch(entries, previous) : void 0; ++ if (entries.length === 0) { ++ this.#view.cancelSearchReveal(); ++ this.#paint(); ++ } ++ } ++ #goTo(offset) { ++ const index = this.#entries.findIndex((entry) => entry.range[0] === offset); ++ if (index === -1) return; ++ this.#current = index; ++ this.#revealHorizontally = this.#entries[index]; ++ this.#view.revealSearchMatch(this.#entries[index]); ++ this.#paint(); ++ } ++ #paint() { ++ const highlights = getSharedHighlights(); ++ if (highlights == null) return; ++ for (const range of this.#ranges) { ++ highlights.match.delete(range); ++ highlights.focus.delete(range); ++ } ++ this.#ranges = []; ++ const current = this.#current != null ? this.#entries[this.#current] : void 0; ++ let currentElement; ++ for (const { entry, element } of this.#view.getMountedSearchMatches(this.#entriesByItem)) { ++ const range = createTextRange(element, entry.start, entry.end); ++ if (range == null) continue; ++ (entry === current ? highlights.focus : highlights.match).add(range); ++ this.#ranges.push(range); ++ if (entry === current) currentElement ??= element; ++ } ++ if (current != null && currentElement != null && this.#revealHorizontally === current) { ++ this.#revealHorizontally = void 0; ++ this.#view.revealSearchMatchHorizontally(current, currentElement); ++ } ++ } ++}; ++/** ++* Collect matches across diff items in render order. Each match gets a ++* synthetic, increasing offset so the panel's offset-based navigation works ++* across files; nothing else reads those offsets. ++*/ ++function collectMatches(items, params) { ++ if (params.text === "" || params.text.includes("\n")) return []; ++ let pattern; ++ try { ++ pattern = new RegExp(params.regex ? params.text : escapeRegExp(params.text), params.caseSensitive ? "g" : "gi"); ++ } catch { ++ return []; ++ } ++ const entries = []; ++ let offset = 0; ++ for (let itemIndex = 0; itemIndex < items.length && entries.length < MAX_SEARCH_MATCHES; itemIndex++) { ++ const item = items[itemIndex]; ++ if (item.type !== "diff") continue; ++ const { fileDiff } = item; ++ iterateOverDiff({ ++ diff: fileDiff, ++ diffStyle: "unified", ++ expandedHunks: true, ++ callback: ({ type, deletionLine, additionLine }) => { ++ const isDeletion = type === "change" && deletionLine != null; ++ const line = isDeletion ? deletionLine : additionLine; ++ const text = trimLineBreak((isDeletion ? fileDiff.deletionLines : fileDiff.additionLines)[line.lineIndex] ?? ""); ++ pattern.lastIndex = 0; ++ let match; ++ while ((match = pattern.exec(text)) !== null) { ++ const length = match[0].length; ++ if (length === 0) { ++ pattern.lastIndex++; ++ continue; ++ } ++ if (params.wholeWord && !isWholeWord(text, match.index, length)) continue; ++ entries.push({ ++ id: item.id, ++ itemIndex, ++ side: isDeletion ? "deletions" : "additions", ++ isContext: type !== "change", ++ lineNumber: line.lineNumber, ++ unifiedLineIndex: line.unifiedLineIndex, ++ start: match.index, ++ end: match.index + length, ++ range: [offset + match.index, offset + match.index + length] ++ }); ++ if (entries.length >= MAX_SEARCH_MATCHES) return true; ++ } ++ offset += text.length + 1; ++ } ++ }); ++ } ++ return entries; ++} ++function haveSameDiffs(searched, items) { ++ return searched.length === items.length && items.every((item, index) => item.id === searched[index][0] && item.fileDiff === searched[index][1]); ++} ++function groupByItem(entries) { ++ const byItem = /* @__PURE__ */ new Map(); ++ for (const entry of entries) { ++ let list = byItem.get(entry.itemIndex); ++ if (list == null) byItem.set(entry.itemIndex, list = []); ++ list.push(entry); ++ } ++ return byItem; ++} ++function findSameMatch(entries, previous) { ++ const index = entries.findIndex((entry) => entry.id === previous.id && entry.side === previous.side && entry.lineNumber === previous.lineNumber && entry.start === previous.start); ++ return index === -1 ? void 0 : index; ++} ++function createTextRange(lineElement, start, end) { ++ const walker = document.createTreeWalker(lineElement, NodeFilter.SHOW_TEXT); ++ let consumed = 0; ++ let startNode; ++ let startOffset = 0; ++ let node; ++ while ((node = walker.nextNode()) != null) { ++ const length = node.data.length; ++ if (startNode == null && start < consumed + length) { ++ startNode = node; ++ startOffset = start - consumed; ++ } ++ if (startNode != null && end <= consumed + length) { ++ const range = document.createRange(); ++ range.setStart(startNode, startOffset); ++ range.setEnd(node, end - consumed); ++ return range; ++ } ++ consumed += length; ++ } ++} ++function trimLineBreak(text) { ++ if (text.endsWith("\r\n")) return text.slice(0, -2); ++ if (text.endsWith("\n") || text.endsWith("\r")) return text.slice(0, -1); ++ return text; ++} ++function isWholeWord(text, start, length) { ++ return isSeparator(text, start - 1) && isSeparator(text, start + length); ++} ++function isSeparator(text, index) { ++ if (index < 0 || index >= text.length) return true; ++ const code = text.charCodeAt(index); ++ return code <= 32 || code === 127 || WORD_SEPARATORS.includes(text[index]); ++} ++function escapeRegExp(text) { ++ return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); ++} ++function isEditableTarget(event) { ++ const target = event.composedPath()[0]; ++ return target instanceof HTMLElement && (target.isContentEditable || target.matches("input, textarea, select")); ++} ++function getSelectionQuery() { ++ const text = document.getSelection()?.toString() ?? ""; ++ return text.includes("\n") ? "" : text; ++} ++/** ++* Two registry entries shared by every CodeView on the page; each search adds ++* and removes only its own ranges. The `::highlight()` colours live in the core ++* stylesheet, because a highlight rule only styles text in its own tree. ++*/ ++function getSharedHighlights() { ++ if (sharedHighlights != null) return sharedHighlights; ++ if (typeof CSS === "undefined" || !("highlights" in CSS) || typeof Highlight === "undefined") return; ++ sharedHighlights = { ++ match: new Highlight(), ++ focus: new Highlight() ++ }; ++ CSS.highlights.set("diffs-search-match", sharedHighlights.match); ++ CSS.highlights.set("diffs-search-focus", sharedHighlights.focus); ++ return sharedHighlights; ++} ++//#endregion ++export { CodeViewSearch, collectMatches }; ++ ++//# sourceMappingURL=CodeViewSearch.js.map diff --git a/dist/components/VirtualizedFile.d.ts b/dist/components/VirtualizedFile.d.ts +index 5e68e35..79dcd88 100644 --- a/dist/components/VirtualizedFile.d.ts +++ b/dist/components/VirtualizedFile.d.ts -@@ -49,7 +49,7 @@ +@@ -49,7 +49,7 @@ declare class VirtualizedFile extend setVisibility(visible: boolean): void; rerender(): void; syncGhostTextRows(): void; @@ -11,9 +708,10 @@ diff --git a/dist/components/VirtualizedFile.d.ts b/dist/components/VirtualizedF protected finalizeRender(): void; private updatePendingRender; diff --git a/dist/components/VirtualizedFile.js b/dist/components/VirtualizedFile.js +index d1f57cd..5908ad7 100644 --- a/dist/components/VirtualizedFile.js +++ b/dist/components/VirtualizedFile.js -@@ -23,6 +23,7 @@ +@@ -23,6 +23,7 @@ var VirtualizedFile = class extends File { heights: /* @__PURE__ */ new Map(), checkpoints: [], fileAnnotationHeight: 0, @@ -21,7 +719,7 @@ diff --git a/dist/components/VirtualizedFile.js b/dist/components/VirtualizedFil ghostTextRows: NO_GHOST_TEXT_ROWS }; pendingRender; -@@ -35,6 +36,8 @@ +@@ -35,6 +36,8 @@ var VirtualizedFile = class extends File { super(options, workerManager, isContainerManaged); this.virtualizer = virtualizer; this.metrics = metrics; @@ -30,7 +728,7 @@ diff --git a/dist/components/VirtualizedFile.js b/dist/components/VirtualizedFil } setMetrics(metrics, force = false) { const nextMetrics = computeVirtualFileMetrics(metrics); -@@ -86,10 +89,12 @@ +@@ -86,10 +89,12 @@ var VirtualizedFile = class extends File { if (this.isAdvancedMode()) throw new Error("VirtualizedFile.setThemeType cannot be used inside CodeView. Update CodeView options instead."); super.setThemeType(themeType); } @@ -46,7 +744,7 @@ diff --git a/dist/components/VirtualizedFile.js b/dist/components/VirtualizedFil if (this.cache.checkpoints.length > 0) this.cache.checkpoints.length = 0; this.cache.ghostTextRows = NO_GHOST_TEXT_ROWS; if (this.renderRange != null && resetRenderRange) this.renderRange = void 0; -@@ -135,6 +140,13 @@ +@@ -135,6 +140,13 @@ var VirtualizedFile = class extends File { if (this.code == null) return hasHeightChange; const content = this.code.children[1]; if (!(content instanceof HTMLElement)) return hasHeightChange; @@ -60,7 +758,7 @@ diff --git a/dist/components/VirtualizedFile.js b/dist/components/VirtualizedFil if (this.renderRange != null && shouldRenderFileAnnotations(this.renderRange)) { const nextFileAnnotationHeight = measureFileAnnotationHeight(content) ?? 0; if (nextFileAnnotationHeight !== this.cache.fileAnnotationHeight) { -@@ -358,12 +370,12 @@ +@@ -358,12 +370,12 @@ var VirtualizedFile = class extends File { codeView.instanceChanged(this, true); } else this.getSimpleVirtualizer()?.requestHeightReconcile(this); } @@ -75,10 +773,45 @@ diff --git a/dist/components/VirtualizedFile.js b/dist/components/VirtualizedFil const file = this.getRenderedFile(); if (!this.isSimpleMode()) this.computeApproximateSize(true); else if (shouldUpdateBuffer && previousRenderRange != null && file != null) { +diff --git a/dist/components/web-components.js b/dist/components/web-components.js +index 6e7b486..5b7290f 100644 +--- a/dist/components/web-components.js ++++ b/dist/components/web-components.js +@@ -2,8 +2,17 @@ import { DIFFS_TAG_NAME } from "../constants.js"; + import style_default from "../style.js"; + import { getMeasuredScrollbarGutter } from "../utils/scrollbarGutter.js"; + //#region src/components/web-components.ts ++// `::highlight()` rules only style text in their own tree, so find's match colours ride ++// along with every container's shadow root. ++const SEARCH_HIGHLIGHT_CSS = `::highlight(diffs-search-match) { ++ background-color: var(--diffs-search-match-bg, light-dark(#ff963288, #ff963266)); ++} ++::highlight(diffs-search-focus) { ++ background-color: var(--diffs-search-focus-bg, color-mix(in lab, var(--diffs-modified-base) 45%, transparent)); ++}`; + if (typeof HTMLElement !== "undefined" && customElements.get("diffs-container") == null) { + let sheet; ++ let searchSheet; + class FileDiffContainer extends HTMLElement { + constructor() { + super(); +@@ -12,8 +21,10 @@ if (typeof HTMLElement !== "undefined" && customElements.get("diffs-container") + if (sheet == null) { + sheet = new CSSStyleSheet(); + sheet.replaceSync(style_default); ++ searchSheet = new CSSStyleSheet(); ++ searchSheet.replaceSync(SEARCH_HIGHLIGHT_CSS); + } +- shadowRoot.adoptedStyleSheets = [sheet]; ++ shadowRoot.adoptedStyleSheets = [sheet, searchSheet]; + } + connectedCallback() { + const shadowRoot = this.shadowRoot ?? this.attachShadow({ mode: "open" }); diff --git a/dist/editor/editor.js b/dist/editor/editor.js +index 8f04a6f..c203023 100644 --- a/dist/editor/editor.js +++ b/dist/editor/editor.js -@@ -1899,7 +1899,11 @@ +@@ -1899,7 +1899,11 @@ var Editor = class { changedDocumentLines: this.#isDiff && !didLineCountChange ? getChangedDocumentLines(textDocument, change) : void 0, documentLineCount: textDocument.lineCount }); @@ -91,10 +824,46 @@ diff --git a/dist/editor/editor.js b/dist/editor/editor.js if (didLineCountChange || this.#isDiff && this.#diffSyle === "unified") this.#resetCache(); if (newLineAnnotations != null) { this.#lineAnnotations = newLineAnnotations; +diff --git a/dist/editor/searchPanel.d.ts b/dist/editor/searchPanel.d.ts +index 9542849..1c1a51f 100644 +--- a/dist/editor/searchPanel.d.ts ++++ b/dist/editor/searchPanel.d.ts +@@ -15,6 +15,8 @@ interface SearchPanelOptions { + containerElement: HTMLElement; + defaultQuery: string; + mode?: SearchPanelMode; ++ /** False keeps the panel in find mode, for hosts with nothing to replace into. */ ++ allowReplace?: boolean; + initialMatch?: MatchRange; + scrollToMatch: (nextMatch: MatchRange, retainFocus: boolean) => void; + applyReplace: (edits: ResolvedTextEdit[]) => void; +diff --git a/dist/editor/searchPanel.js b/dist/editor/searchPanel.js +index df1ba63..676751b 100644 +--- a/dist/editor/searchPanel.js ++++ b/dist/editor/searchPanel.js +@@ -12,7 +12,7 @@ var SearchPanelWidget = class { + #navigate; + #close; + constructor(options) { +- const { textDocument, containerElement, defaultQuery, mode = "find", initialMatch, scrollToMatch, applyReplace, onUpdate, onClose } = options; ++ const { textDocument, containerElement, defaultQuery, mode = "find", allowReplace = true, initialMatch, scrollToMatch, applyReplace, onUpdate, onClose } = options; + const searchParams = { + text: defaultQuery, + replaceText: "", +@@ -278,7 +278,7 @@ var SearchPanelWidget = class { + ] + }); + const applyMode = (next) => { +- gridElement.dataset.mode = next; ++ gridElement.dataset.mode = allowReplace ? next : "find"; + this.#inputElement.focus(); + this.#inputElement.select(); + }; diff --git a/dist/managers/ResizeManager.d.ts b/dist/managers/ResizeManager.d.ts +index 3efafc4..9e5b751 100644 --- a/dist/managers/ResizeManager.d.ts +++ b/dist/managers/ResizeManager.d.ts -@@ -5,6 +5,8 @@ +@@ -5,6 +5,8 @@ interface ResizeManagerSetupOptions { columnVariables?: ResizeManagerColumnVariableMode; } declare class ResizeManager { @@ -104,9 +873,10 @@ diff --git a/dist/managers/ResizeManager.d.ts b/dist/managers/ResizeManager.d.ts private static managersByElement; private static getResizeObserver; diff --git a/dist/managers/ResizeManager.js b/dist/managers/ResizeManager.js +index 9724cb1..14145d8 100644 --- a/dist/managers/ResizeManager.js +++ b/dist/managers/ResizeManager.js -@@ -19,6 +19,7 @@ +@@ -19,6 +19,7 @@ var ResizeManager = class ResizeManager { for (const [manager, managerEntries] of entriesByManager) manager.handleResizeEntries(managerEntries); } observedNodes = /* @__PURE__ */ new Map(); @@ -114,7 +884,7 @@ diff --git a/dist/managers/ResizeManager.js b/dist/managers/ResizeManager.js setup(pre, { disableAnnotations, columnVariables = "apply" }) { const annotationUpdates = /* @__PURE__ */ new Set(); const applyColumnVariables = columnVariables === "apply"; -@@ -212,6 +213,7 @@ +@@ -212,6 +213,7 @@ var ResizeManager = class ResizeManager { this.applyAnnotationUpdates(annotationUpdates); annotationUpdates.clear(); this.applyColumnUpdates(codeUpdates); @@ -122,7 +892,86 @@ diff --git a/dist/managers/ResizeManager.js b/dist/managers/ResizeManager.js codeUpdates.clear(); } applyAnnotationUpdates(annotationUpdates) { +diff --git a/dist/react/CodeView.d.ts b/dist/react/CodeView.d.ts +index e78e713..642b235 100644 +--- a/dist/react/CodeView.d.ts ++++ b/dist/react/CodeView.d.ts +@@ -56,6 +56,8 @@ interface CodeViewBaseProps { + * is frozen; re-key the accepted value in place before accepting. + */ + onItemEditComplete?: CodeViewItemEditCompleteHandler; ++ /** Unfold a collapsed item that holds the find match being navigated to. */ ++ onRevealSearchMatch?(item: CodeViewItem): void; + renderCustomHeader?(item: CodeViewItem): ReactNode; + renderHeaderPrefix?(item: CodeViewItem): ReactNode; + renderHeaderFilenameSuffix?(item: CodeViewItem): ReactNode; +diff --git a/dist/react/CodeView.js b/dist/react/CodeView.js +index 84c7915..d1930bf 100644 +--- a/dist/react/CodeView.js ++++ b/dist/react/CodeView.js +@@ -24,7 +24,7 @@ function createDefaultCache(controlled) { + }; + } + function CodeViewInner(props, ref) { +- const { className, containerRef, disableWorkerPool = false, editorOptions, getEditStateKey, initialItems, items: controlledItems, onItemEditChange, onItemEditComplete, onScroll, onSelectedLinesChange, options, renderAnnotation, renderCodeViewFooter, renderCodeViewHeader, renderCustomHeader, renderGutterUtility, renderHeaderFilenameSuffix, renderHeaderMetadata, renderHeaderPrefix, selectedLines, style } = props; ++ const { className, containerRef, disableWorkerPool = false, editorOptions, getEditStateKey, initialItems, items: controlledItems, onItemEditChange, onItemEditComplete, onRevealSearchMatch, onScroll, onSelectedLinesChange, options, renderAnnotation, renderCodeViewFooter, renderCodeViewHeader, renderCustomHeader, renderGutterUtility, renderHeaderFilenameSuffix, renderHeaderMetadata, renderHeaderPrefix, selectedLines, style } = props; + const controlled = controlledItems !== void 0; + const contextCreateEditor = useCreateEditor(); + const poolManager = useContext(WorkerPoolContext); +@@ -52,6 +52,9 @@ function CodeViewInner(props, ref) { + onItemEditChange?.(event, item); + }); + const emitItemEditComplete = useStableCallback((event, item, nextItem) => onItemEditComplete?.(event, item, nextItem) ?? "reject"); ++ const emitRevealSearchMatch = useStableCallback((item) => { ++ onRevealSearchMatch?.(item); ++ }); + const managedOptions = useMemo(() => createManagedCodeViewOptions({ + options, + hasCustomHeader, +@@ -63,13 +66,15 @@ function CodeViewInner(props, ref) { + getEditStateKey, + createEditor: contextCreateEditor != null ? createEditor : void 0, + onItemEditChange: onItemEditChange != null ? emitItemEditChange : void 0, +- onItemEditComplete: onItemEditComplete != null ? emitItemEditComplete : void 0 ++ onItemEditComplete: onItemEditComplete != null ? emitItemEditComplete : void 0, ++ onRevealSearchMatch: onRevealSearchMatch != null ? emitRevealSearchMatch : void 0 + }), [ + contextCreateEditor, + controlledSelection, + createEditor, + emitItemEditChange, + emitItemEditComplete, ++ emitRevealSearchMatch, + emitSelectedLinesChange, + hasCodeViewFooter, + hasCodeViewHeader, +@@ -78,6 +83,7 @@ function CodeViewInner(props, ref) { + getEditStateKey, + onItemEditChange, + onItemEditComplete, ++ onRevealSearchMatch, + onSelectedLinesChange, + options + ]); +@@ -302,7 +308,7 @@ function createSlotContentStore() { + } + }; + } +-function createManagedCodeViewOptions({ options, hasCustomHeader, hasGutterRenderer, hasCodeViewHeader, hasCodeViewFooter, onSelectedLinesChange, controlledSelection, getEditStateKey, createEditor, onItemEditChange, onItemEditComplete }) { ++function createManagedCodeViewOptions({ options, hasCustomHeader, hasGutterRenderer, hasCodeViewHeader, hasCodeViewFooter, onSelectedLinesChange, controlledSelection, getEditStateKey, createEditor, onItemEditChange, onItemEditComplete, onRevealSearchMatch }) { + const managedOptions = { + ...options, + controlledSelection, +@@ -312,6 +318,7 @@ function createManagedCodeViewOptions({ options, hasCustomHeader, hasGutterRende + if (getEditStateKey != null) managedOptions.getEditStateKey = getEditStateKey; + if (onItemEditChange != null) managedOptions.onItemEditChange = onItemEditChange; + if (onItemEditComplete != null) managedOptions.onItemEditComplete = onItemEditComplete; ++ if (onRevealSearchMatch != null) managedOptions.onRevealSearchMatch = onRevealSearchMatch; + if (hasCustomHeader) managedOptions.renderCustomHeader = noopRender; + if (hasGutterRenderer) managedOptions.renderGutterUtility = noopRender; + if (hasCodeViewHeader) managedOptions.renderCodeViewHeader = noopRender; diff --git a/package.json b/package.json +index d9dc7b5..3c37bae 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,18 @@ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 445556401867..8a54dc0d1fa2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -103,7 +103,7 @@ patchedDependencies: '@opencode/client@2.0.23': f725d6e6d9af0d56dc6ed71b863518fbfc751af1773cff2311209d2c404cf4cd '@opencode/protocol@2.0.23': 17a82f6c2c98fd0371e786c211a4fbdaaba403577a25733e5e99a0c25a16a685 '@opencode/schema@2.0.23': 650f1b8602789407c010861284272dcb6bd6de01ec6a75bf3c0904a0e7596d59 - '@pierre/diffs@1.5.2': ba1766e9669d5699e31f0dde5a52f5d47f389f49a286de54489a409ba2527289 + '@pierre/diffs@1.5.2': b33a575eb1382270ed0df956307e4b6ffe4b8e4349cefeb43e14f648c0fdc3b0 '@react-native-ai/apple@0.12.0': 2d09870c2848d185cb05b53ed823a46e12dba519324d8dd8e584e28731990f9d '@react-native-menu/menu@2.0.0': a4866ecfcd44f318043de839d20110c17e14909004900f002ffe12050376f2ba '@react-navigation/native-stack@7.17.6': e667c3cef8c78bb9ff4882ee5bd23a432247b843060a9499eb5f07e9e2295552 @@ -276,7 +276,7 @@ importers: version: 1.9.1 '@pierre/diffs': specifier: 'catalog:' - version: 1.5.2(patch_hash=ba1766e9669d5699e31f0dde5a52f5d47f389f49a286de54489a409ba2527289)(@shikijs/themes@4.2.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + version: 1.5.2(patch_hash=b33a575eb1382270ed0df956307e4b6ffe4b8e4349cefeb43e14f648c0fdc3b0)(@shikijs/themes@4.2.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) '@react-native-ai/apple': specifier: 0.12.0 version: 0.12.0(patch_hash=2d09870c2848d185cb05b53ed823a46e12dba519324d8dd8e584e28731990f9d)(react-native@0.88.0-rc.3(@babel/core@7.29.7)(@react-native/metro-config@0.88.0-rc.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6)) @@ -665,7 +665,7 @@ importers: version: 1.8.0 '@pierre/diffs': specifier: 'catalog:' - version: 1.5.2(patch_hash=ba1766e9669d5699e31f0dde5a52f5d47f389f49a286de54489a409ba2527289)(@shikijs/themes@4.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + version: 1.5.2(patch_hash=b33a575eb1382270ed0df956307e4b6ffe4b8e4349cefeb43e14f648c0fdc3b0)(@shikijs/themes@4.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@pierre/trees': specifier: 1.0.0-beta.6 version: 1.0.0-beta.6(@pierre/theme@1.1.0)(@shikijs/themes@4.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(shiki@4.2.0) @@ -14913,7 +14913,7 @@ snapshots: tslib: 2.8.1 webcrypto-core: 1.9.2 - '@pierre/diffs@1.5.2(patch_hash=ba1766e9669d5699e31f0dde5a52f5d47f389f49a286de54489a409ba2527289)(@shikijs/themes@4.2.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': + '@pierre/diffs@1.5.2(patch_hash=b33a575eb1382270ed0df956307e4b6ffe4b8e4349cefeb43e14f648c0fdc3b0)(@shikijs/themes@4.2.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': dependencies: '@pierre/theme': 2.0.0 '@pierre/theming': 1.0.1(@pierre/theme@2.0.0)(@shikijs/themes@4.2.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(shiki@4.2.0) @@ -14928,7 +14928,7 @@ snapshots: transitivePeerDependencies: - '@shikijs/themes' - '@pierre/diffs@1.5.2(patch_hash=ba1766e9669d5699e31f0dde5a52f5d47f389f49a286de54489a409ba2527289)(@shikijs/themes@4.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@pierre/diffs@1.5.2(patch_hash=b33a575eb1382270ed0df956307e4b6ffe4b8e4349cefeb43e14f648c0fdc3b0)(@shikijs/themes@4.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@pierre/theme': 2.0.0 '@pierre/theming': 1.0.1(@pierre/theme@2.0.0)(@shikijs/themes@4.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(shiki@4.2.0) From 08b1222d0bae670433c275ec70165c7b5048726f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 7 Oct 2026 17:26:24 -0700 Subject: [PATCH 22/25] refactor(server): GitHub services are named for the API they call, not gh (#16967) Co-authored-by: Claude Opus 5.5 (1M context) --- apps/server/scripts/evaluate-thread-titles.ts | 4 +- apps/server/scripts/measure-pr-preview.ts | 4 +- apps/server/src/git/GitManager.test.ts | 79 +++-- apps/server/src/git/GitManager.ts | 2 +- .../ThreadPullRequestService.ts | 2 +- ...i.test.ts => GitHubPullRequestApi.test.ts} | 272 +++++++++--------- ...lRequestCli.ts => GitHubPullRequestApi.ts} | 159 +++++----- .../GitHubPullRequestProvider.test.ts | 54 ++-- .../pullRequest/GitHubPullRequestProvider.ts | 12 +- .../PullRequestProviderRegistry.ts | 6 +- .../src/pullRequest/PullRequestService.ts | 2 +- .../pullRequest/gitHubConditionalChecks.ts | 4 +- apps/server/src/server.ts | 6 +- ...li.test.ts => GitHubRepositoryApi.test.ts} | 64 +++-- .../{GitHubCli.ts => GitHubRepositoryApi.ts} | 141 +++++---- .../GitHubSourceControlProvider.test.ts | 26 +- .../GitHubSourceControlProvider.ts | 14 +- .../SourceControlDiscovery.test.ts | 4 +- .../SourceControlProviderRegistry.test.ts | 6 +- apps/server/src/ws.ts | 4 +- 20 files changed, 410 insertions(+), 455 deletions(-) rename apps/server/src/pullRequest/{GitHubPullRequestCli.test.ts => GitHubPullRequestApi.test.ts} (94%) rename apps/server/src/pullRequest/{GitHubPullRequestCli.ts => GitHubPullRequestApi.ts} (96%) rename apps/server/src/sourceControl/{GitHubCli.test.ts => GitHubRepositoryApi.test.ts} (91%) rename apps/server/src/sourceControl/{GitHubCli.ts => GitHubRepositoryApi.ts} (90%) diff --git a/apps/server/scripts/evaluate-thread-titles.ts b/apps/server/scripts/evaluate-thread-titles.ts index 414bd99d0745..a8d2f9d986d1 100644 --- a/apps/server/scripts/evaluate-thread-titles.ts +++ b/apps/server/scripts/evaluate-thread-titles.ts @@ -23,7 +23,7 @@ import { } from "../src/textGeneration/ThreadTitleContext.ts"; import * as ThreadTitleLinks from "../src/textGeneration/ThreadTitleLinks.ts"; import * as SourceControlProviderRegistry from "../src/sourceControl/SourceControlProviderRegistry.ts"; -import * as GitHubCli from "../src/sourceControl/GitHubCli.ts"; +import * as GitHubRepositoryApi from "../src/sourceControl/GitHubRepositoryApi.ts"; import * as GitLabCli from "../src/sourceControl/GitLabCli.ts"; import * as ForgejoCli from "../src/sourceControl/ForgejoCli.ts"; import * as AzureDevOpsCli from "../src/sourceControl/AzureDevOpsCli.ts"; @@ -152,7 +152,7 @@ await Effect.runPromise( SourceControlProviderRegistry.layer.pipe( Layer.provide( Layer.mergeAll( - GitHubCli.layer, + GitHubRepositoryApi.layer, GitLabCli.layer, ForgejoCli.layer, AzureDevOpsCli.layer, diff --git a/apps/server/scripts/measure-pr-preview.ts b/apps/server/scripts/measure-pr-preview.ts index c4a688b1aa7a..f1fc491b1a2b 100644 --- a/apps/server/scripts/measure-pr-preview.ts +++ b/apps/server/scripts/measure-pr-preview.ts @@ -10,7 +10,7 @@ import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import { FetchHttpClient } from "effect/http"; -import * as GitHubPullRequestCli from "../src/pullRequest/GitHubPullRequestCli.ts"; +import * as GitHubPullRequestApi from "../src/pullRequest/GitHubPullRequestApi.ts"; import * as GitHubPullRequestProvider from "../src/pullRequest/GitHubPullRequestProvider.ts"; import * as GitHubApi from "../src/sourceControl/GitHubApi.ts"; import * as GitHubCredentials from "../src/sourceControl/GitHubCredentials.ts"; @@ -73,7 +73,7 @@ const measuredApi = Layer.effect( Layer.provide(NodeServices.layer), ); -const services = GitHubPullRequestCli.layer.pipe( +const services = GitHubPullRequestApi.layer.pipe( Layer.provideMerge(measuredApi), Layer.provideMerge(VcsProcess.layer), Layer.provideMerge(NodeServices.layer), diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index 61dd000633ef..f30af2c23c5b 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -42,7 +42,7 @@ import { } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; import * as GitHubApi from "../sourceControl/GitHubApi.ts"; -import * as GitHubCli from "../sourceControl/GitHubCli.ts"; +import * as GitHubRepositoryApi from "../sourceControl/GitHubRepositoryApi.ts"; import { decodeGitHubPullRequestListJson } from "../sourceControl/gitHubPullRequests.ts"; import * as GitLabCli from "../sourceControl/GitLabCli.ts"; import * as TextGeneration from "../textGeneration/TextGeneration.ts"; @@ -92,7 +92,7 @@ interface FakeGhScenario { headRepositoryOwnerLogin?: string | null; }; repositoryCloneUrls?: Record; - failWith?: GitHubCli.GitHubCliError; + failWith?: GitHubRepositoryApi.GitHubRepositoryApiError; /** Let this many gh calls succeed before failWith kicks in (default 0 = fail immediately). */ failAfterCalls?: number; } @@ -111,7 +111,9 @@ type FakeGitTextGeneration = TextGeneration.TextGeneration["Service"]; type FakePullRequest = NonNullable; -function normalizeFakePullRequestSummary(raw: unknown): GitHubCli.GitHubPullRequestSummary | null { +function normalizeFakePullRequestSummary( + raw: unknown, +): GitHubRepositoryApi.GitHubPullRequestSummary | null { if (!raw || typeof raw !== "object") { return null; } @@ -381,8 +383,8 @@ function createTextGeneration( }; } -function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { - service: GitHubCli.GitHubCli["Service"]; +function createGitHubRepositoryApiWithFakeGh(scenario: FakeGhScenario = {}): { + service: GitHubRepositoryApi.GitHubRepositoryApi["Service"]; ghCalls: string[]; } { const prListQueue = [...(scenario.prListSequence ?? [])]; @@ -398,7 +400,7 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { const execute = (input: { readonly cwd: string; readonly args: ReadonlyArray; - }): Effect.Effect => { + }): Effect.Effect => { const args = [...input.args]; ghCalls.push(args.join(" ")); @@ -486,10 +488,9 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { return fakeGhOutput(""); }, catch: (error) => - GitHubCli.isGitHubCliError(error) + GitHubRepositoryApi.isGitHubRepositoryApiError(error) ? error - : new GitHubCli.GitHubCliCommandError({ - command: "gh", + : new GitHubRepositoryApi.GitHubRepositoryCommandError({ cwd: input.cwd, cause: error, }), @@ -502,8 +503,7 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { const cloneUrls = scenario.repositoryCloneUrls?.[repository]; if (!cloneUrls) { return Effect.fail( - new GitHubCli.GitHubCliCommandError({ - command: "gh", + new GitHubRepositoryApi.GitHubRepositoryCommandError({ cwd: input.cwd, cause: new Error(`Unexpected repository lookup: ${repository}`), }), @@ -523,8 +523,7 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { } return Effect.fail( - new GitHubCli.GitHubCliCommandError({ - command: "gh", + new GitHubRepositoryApi.GitHubRepositoryCommandError({ cwd: input.cwd, cause: new Error(`Unexpected gh command: ${args.join(" ")}`), }), @@ -577,7 +576,9 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { Effect.map((raw) => raw .map((entry) => normalizeFakePullRequestSummary(entry)) - .filter((entry): entry is GitHubCli.GitHubPullRequestSummary => entry !== null), + .filter( + (entry): entry is GitHubRepositoryApi.GitHubPullRequestSummary => entry !== null, + ), ), ), createPullRequest: (input) => @@ -617,7 +618,9 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,isCrossRepository,headRepository,headRepositoryOwner", ], }).pipe( - Effect.map((result) => JSON.parse(result.stdout) as GitHubCli.GitHubPullRequestSummary), + Effect.map( + (result) => JSON.parse(result.stdout) as GitHubRepositoryApi.GitHubPullRequestSummary, + ), ), getRepositoryCloneUrls: (input) => execute({ @@ -626,8 +629,7 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { }).pipe(Effect.map((result) => JSON.parse(result.stdout))), createRepository: (input) => Effect.fail( - new GitHubCli.GitHubCliCommandError({ - command: "gh", + new GitHubRepositoryApi.GitHubRepositoryCommandError({ cwd: input.cwd, cause: new Error(`Unexpected repository create: ${input.repository}`), }), @@ -691,7 +693,9 @@ function makeManager(input?: { ProjectionStore.ProjectionStoreV2 | ProjectStore.ProjectStoreV2 >; }) { - const { service: gitHubCli, ghCalls } = createGitHubCliWithFakeGh(input?.ghScenario); + const { service: gitHubRepositoryApi, ghCalls } = createGitHubRepositoryApiWithFakeGh( + input?.ghScenario, + ); const textGeneration = createTextGeneration(input?.textGeneration); const layerServerConfig = ServerConfig.layerTest(process.cwd(), { prefix: "t3-git-manager-test-", @@ -740,7 +744,7 @@ function makeManager(input?: { ), Effect.provide( Layer.merge( - Layer.succeed(GitHubCli.GitHubCli, gitHubCli), + Layer.succeed(GitHubRepositoryApi.GitHubRepositoryApi, gitHubRepositoryApi), Layer.mock(GitHubApi.GitHubApi)({}), ), ), @@ -1296,8 +1300,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { yield* runGit(repoDir, ["push", "-u", "origin", "feature/rate-limited"]); const { manager, ghCalls } = yield* makeManager({ ghScenario: { - failWith: new GitHubCli.GitHubCliUnavailableError({ - command: "gh", + failWith: new GitHubRepositoryApi.GitHubRepositoryUnavailableError({ cwd: repoDir, cause: new Error("rate limited"), }), @@ -1779,8 +1782,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { const { manager, ghCalls } = yield* makeManager({ ghScenario: { - failWith: new GitHubCli.GitHubCliUnavailableError({ - command: "gh", + failWith: new GitHubRepositoryApi.GitHubRepositoryUnavailableError({ cwd: repoDir, cause: new Error("gh is not available on PATH"), }), @@ -2640,8 +2642,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { }, ]), }, - failWith: new GitHubCli.GitHubCliUnavailableError({ - command: "gh", + failWith: new GitHubRepositoryApi.GitHubRepositoryUnavailableError({ cwd: repoDir, cause: new Error("rate limited"), }), @@ -2717,8 +2718,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { const { manager } = yield* makeManager({ ghScenario: { - failWith: new GitHubCli.GitHubCliUnavailableError({ - command: "gh", + failWith: new GitHubRepositoryApi.GitHubRepositoryUnavailableError({ cwd: repoDir, cause: new Error("gh is not available on PATH"), }), @@ -2743,8 +2743,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { const upstreamCause = "GraphQL rate limit for user ID 51714798 and token secret-value"; const { manager } = yield* makeManager({ ghScenario: { - failWith: new GitHubCli.GitHubCliRateLimitError({ - command: "gh", + failWith: new GitHubRepositoryApi.GitHubRepositoryRateLimitError({ cwd: repoDir, cause: new Error(upstreamCause), }), @@ -2770,7 +2769,6 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { errorTag: "SourceControlProviderError", provider: "github", providerOperation: "listChangeRequests", - providerCommand: "gh", errorDetail: "GitHub API rate limit exceeded. Requests resume when the limit resets.", }); const loggedText = [ @@ -2801,8 +2799,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { const { manager } = yield* makeManager({ ghScenario: { prListSequence: [JSON.stringify([existingPr])], - failWith: new GitHubCli.GitHubCliUnavailableError({ - command: "gh", + failWith: new GitHubRepositoryApi.GitHubRepositoryUnavailableError({ cwd: repoDir, cause: new Error("rate limited"), }), @@ -2844,8 +2841,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { const { manager } = yield* makeManager({ ghScenario: { prListSequence: [JSON.stringify([existingPr])], - failWith: new GitHubCli.GitHubCliUnavailableError({ - command: "gh", + failWith: new GitHubRepositoryApi.GitHubRepositoryUnavailableError({ cwd: repoDir, cause: new Error("rate limited"), }), @@ -2892,8 +2888,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { const { manager } = yield* makeManager({ ghScenario: { prListSequence: [JSON.stringify([existingPr])], - failWith: new GitHubCli.GitHubCliUnavailableError({ - command: "gh", + failWith: new GitHubRepositoryApi.GitHubRepositoryUnavailableError({ cwd: repoDir, cause: new Error("rate limited"), }), @@ -2931,8 +2926,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { const { manager } = yield* makeManager({ ghScenario: { prListSequence: [JSON.stringify([existingPr])], - failWith: new GitHubCli.GitHubCliUnavailableError({ - command: "gh", + failWith: new GitHubRepositoryApi.GitHubRepositoryUnavailableError({ cwd: repoDir, cause: new Error("rate limited"), }), @@ -2973,8 +2967,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { const { manager } = yield* makeManager({ ghScenario: { prListSequence: [JSON.stringify([existingPr])], - failWith: new GitHubCli.GitHubCliUnavailableError({ - command: "gh", + failWith: new GitHubRepositoryApi.GitHubRepositoryUnavailableError({ cwd: repoDir, cause: new Error("rate limited"), }), @@ -4618,8 +4611,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { const { manager } = yield* makeManager({ ghScenario: { - failWith: new GitHubCli.GitHubCliUnavailableError({ - command: "gh", + failWith: new GitHubRepositoryApi.GitHubRepositoryUnavailableError({ cwd: repoDir, cause: new Error("gh is not available on PATH"), }), @@ -4648,8 +4640,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { const { manager } = yield* makeManager({ ghScenario: { - failWith: new GitHubCli.GitHubCliAuthenticationError({ - command: "gh", + failWith: new GitHubRepositoryApi.GitHubRepositoryAuthenticationError({ cwd: repoDir, cause: new Error("gh is not authenticated"), }), diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index cbc0490a95c1..e9b244dfbe86 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -1286,7 +1286,7 @@ export const make = Effect.gen(function* () { ? { provider: error.provider, providerOperation: error.operation, - providerCommand: error.command ?? "unknown", + ...(error.command === undefined ? {} : { providerCommand: error.command }), errorDetail: error.detail, } : {}), diff --git a/apps/server/src/orchestration-v2/ThreadPullRequestService.ts b/apps/server/src/orchestration-v2/ThreadPullRequestService.ts index 072bf930c038..c1e0cff2658f 100644 --- a/apps/server/src/orchestration-v2/ThreadPullRequestService.ts +++ b/apps/server/src/orchestration-v2/ThreadPullRequestService.ts @@ -345,7 +345,7 @@ export const make = Effect.gen(function* () { }).pipe(Effect.tap(() => Effect.sync(() => failBackfill(group)))), ), ), - // Wide enough that a sweep's GitHub branch lookups reach GitHubCli together and share one + // Wide enough that a sweep's GitHub branch lookups reach GitHubRepositoryApi together and share one // GraphQL document, instead of one `gh pr list` per branch. { concurrency: 32, discard: true }, ); diff --git a/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts b/apps/server/src/pullRequest/GitHubPullRequestApi.test.ts similarity index 94% rename from apps/server/src/pullRequest/GitHubPullRequestCli.test.ts rename to apps/server/src/pullRequest/GitHubPullRequestApi.test.ts index 28aca8b6a9b3..c42bf6d147bf 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestApi.test.ts @@ -12,13 +12,13 @@ import * as Redacted from "effect/Redacted"; import { HttpClient, HttpClientResponse } from "effect/http"; -import { AllowGitHubReserve } from "../sourceControl/GitHubCli.ts"; +import { AllowGitHubReserve } from "../sourceControl/GitHubRepositoryApi.ts"; import * as GitHubApi from "../sourceControl/GitHubApi.ts"; import * as GitHubCredentials from "../sourceControl/GitHubCredentials.ts"; import * as GitHubGraphQlBudget from "../sourceControl/githubGraphQlBudget.ts"; import * as SourceControlRateLimit from "../sourceControl/SourceControlRateLimit.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; -import * as GitHubPullRequestCli from "./GitHubPullRequestCli.ts"; +import * as GitHubPullRequestApi from "./GitHubPullRequestApi.ts"; import { KnownWorkflowRuns } from "./gitHubConditionalChecks.ts"; const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); @@ -96,7 +96,7 @@ const mockApi = Layer.effect( ); const layer = it.layer( - GitHubPullRequestCli.layer.pipe( + GitHubPullRequestApi.layer.pipe( Layer.provideMerge(mockApi), Layer.provideMerge(GitHubGraphQlBudget.layer), Layer.provide(NodeCrypto.layer), @@ -489,7 +489,7 @@ it.effect( }), ), ); - const cli = yield* GitHubPullRequestCli.make.pipe( + const cli = yield* GitHubPullRequestApi.make.pipe( Effect.provide( GitHubApi.layer.pipe( Layer.provide(Layer.mergeAll(credentials, http)), @@ -537,7 +537,7 @@ it.effect( }), ); -layer("GitHubPullRequestCli.layer", (it) => { +layer("GitHubPullRequestApi.layer", (it) => { it.effect("admits only one concurrent preview above the reserve and resumes after reset", () => Effect.gen(function* () { const now = yield* Clock.currentTimeMillis; @@ -569,7 +569,7 @@ layer("GitHubPullRequestCli.layer", (it) => { ), ), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const input = { cwd: "/w", repository: "acme/web", @@ -623,7 +623,7 @@ layer("GitHubPullRequestCli.layer", (it) => { ), ), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const preview = yield* cli.getPullRequestPreview({ cwd: "/w", repository: "acme/web", @@ -653,7 +653,7 @@ layer("GitHubPullRequestCli.layer", (it) => { mockedExecute.mockImplementation(() => Effect.yieldNow.pipe(Effect.as(output('{"id":123,"login":"viewer"}'))), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const results = yield* Effect.all( Array.from({ length: 4 }, () => cli.getRoutingIdentity({ cwd: "/w", host: "github.identity-flight.test" }), @@ -683,7 +683,7 @@ layer("GitHubPullRequestCli.layer", (it) => { return output('{"id":123,"login":"viewer"}'); }), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const input = { cwd: "/w", host: "github.identity-cancel.test" }; const first = yield* cli.getRoutingIdentity(input).pipe(Effect.forkChild); yield* Deferred.await(firstStarted); @@ -727,7 +727,7 @@ layer("GitHubPullRequestCli.layer", (it) => { ), ), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const reads = yield* Effect.forEach( [7, 8], @@ -795,7 +795,7 @@ layer("GitHubPullRequestCli.layer", (it) => { ), ), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const reads = yield* Effect.forEach( [7, 8], @@ -855,7 +855,7 @@ layer("GitHubPullRequestCli.layer", (it) => { ), ), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const read = yield* cli .getPullRequestSummary({ cwd: "/w", repository: "acme/web", host: "github.com", number: 7 }) @@ -895,7 +895,7 @@ layer("GitHubPullRequestCli.layer", (it) => { ), ), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const stack = yield* cli.getPullRequestStack({ cwd: "/w", @@ -943,7 +943,7 @@ layer("GitHubPullRequestCli.layer", (it) => { ), ), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const stack = yield* cli.getPullRequestStack({ cwd: "/w", repository: "acme/web", @@ -963,7 +963,7 @@ layer("GitHubPullRequestCli.layer", (it) => { it.effect("reads an empty stacks listing as not stacked", () => Effect.gen(function* () { mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]"))); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const stack = yield* cli.getPullRequestStack({ cwd: "/w", @@ -986,7 +986,7 @@ layer("GitHubPullRequestCli.layer", (it) => { }), ), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const stack = yield* cli.getPullRequestStack({ cwd: "/w", @@ -1009,7 +1009,7 @@ layer("GitHubPullRequestCli.layer", (it) => { }), ), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const error = yield* Effect.flip( cli.getPullRequestStack({ @@ -1032,7 +1032,7 @@ layer("GitHubPullRequestCli.layer", (it) => { status: 503, }); mockedExecute.mockReturnValueOnce(Effect.fail(failure)); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const error = yield* Effect.flip( cli.getPullRequestStack({ cwd: "/w", @@ -1048,7 +1048,7 @@ layer("GitHubPullRequestCli.layer", (it) => { it.effect("reports a stacks answer it cannot read against the stack read", () => Effect.gen(function* () { mockedExecute.mockReturnValueOnce(Effect.succeed(output('[{"id":42}]'))); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const error = yield* Effect.flip( cli.getPullRequestStack({ @@ -1068,7 +1068,7 @@ layer("GitHubPullRequestCli.layer", (it) => { it.effect("asks for one row more than the page, to probe for a next page", () => Effect.gen(function* () { mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequests(3, 1)))); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const batch = yield* cli.listPullRequests({ cwd: "/w", @@ -1091,7 +1091,7 @@ layer("GitHubPullRequestCli.layer", (it) => { it.effect("reports truncation from the extra row, counted before decoding", () => Effect.gen(function* () { mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequests(11, 1)))); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const batch = yield* cli.listPullRequests({ cwd: "/w", @@ -1117,7 +1117,7 @@ layer("GitHubPullRequestCli.layer", (it) => { : emptySearch(), ), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; yield* cli.listPullRequests({ cwd: "/w", @@ -1143,7 +1143,7 @@ layer("GitHubPullRequestCli.layer", (it) => { : emptySearch(), ), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; yield* cli.listPullRequests({ cwd: "/w", @@ -1168,7 +1168,7 @@ layer("GitHubPullRequestCli.layer", (it) => { : emptySearch(), ), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; yield* cli.listPullRequests({ cwd: "/w", @@ -1189,7 +1189,7 @@ layer("GitHubPullRequestCli.layer", (it) => { it.effect("carries every repository and every qualifier into one search", () => Effect.gen(function* () { mockedExecute.mockReturnValue(Effect.succeed(searchPage([]))); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; yield* cli.searchPullRequests({ cwd: "/w", @@ -1218,7 +1218,7 @@ layer("GitHubPullRequestCli.layer", (it) => { it.effect("narrows a search to the author, and to merged on the merged tab", () => Effect.gen(function* () { mockedExecute.mockReturnValue(Effect.succeed(searchPage([]))); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; yield* cli.searchPullRequests({ cwd: "/w", @@ -1240,7 +1240,7 @@ layer("GitHubPullRequestCli.layer", (it) => { it.effect("keeps a searched-for qualifier inside the phrase", () => Effect.gen(function* () { mockedExecute.mockReturnValue(Effect.succeed(searchPage([]))); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; yield* cli.searchPullRequests({ cwd: "/w", @@ -1263,7 +1263,7 @@ layer("GitHubPullRequestCli.layer", (it) => { it.effect("refuses to search for a repository GitHub cannot address", () => Effect.gen(function* () { - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const failure = yield* Effect.flip( cli.searchPullRequests({ @@ -1295,7 +1295,7 @@ layer("GitHubPullRequestCli.layer", (it) => { ]), ), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const batch = yield* cli.searchPullRequests({ cwd: "/w", @@ -1341,7 +1341,7 @@ layer("GitHubPullRequestCli.layer", (it) => { .mockReturnValueOnce( Effect.succeed(searchPage([searchItem(1, "acme/web", "2026-07-03T00:00:00Z")], true)), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const read = () => cli.searchPullRequests({ cwd: "/w", @@ -1390,7 +1390,7 @@ layer("GitHubPullRequestCli.layer", (it) => { ), ), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const batch = yield* cli.listPullRequests({ cwd: "/w", repository: "acme/web", @@ -1443,7 +1443,7 @@ layer("GitHubPullRequestCli.layer", (it) => { ), ), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const batch = yield* cli.listPullRequests({ cwd: "/w", repository: "acme/web", @@ -1469,7 +1469,7 @@ layer("GitHubPullRequestCli.layer", (it) => { mockedExecute.mockReturnValueOnce(Effect.succeed(emptySearch())); mockedExecute.mockReturnValueOnce(Effect.succeed(emptyList())); mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequests(1, 7)))); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const input = { cwd: "/w", repository: "acme/web", @@ -1499,7 +1499,7 @@ layer("GitHubPullRequestCli.layer", (it) => { output(encodeJson({ data: { s0: { pullRequest: { additions: 4, deletions: 1 } } } })), ), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const stats = yield* cli.listPullRequestStats({ cwd: "/w", @@ -1521,7 +1521,7 @@ layer("GitHubPullRequestCli.layer", (it) => { it.effect("refuses to look up counts for a repository GitHub cannot address", () => Effect.gen(function* () { - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const failure = yield* Effect.flip( cli.listPullRequestStats({ @@ -1545,7 +1545,7 @@ layer("GitHubPullRequestCli.layer", (it) => { : emptySearch(), ), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; yield* cli.listPullRequests({ cwd: "/w", @@ -1575,7 +1575,7 @@ layer("GitHubPullRequestCli.layer", (it) => { : emptySearch(), ), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; yield* cli.listPullRequests({ cwd: "/w", @@ -1603,7 +1603,7 @@ layer("GitHubPullRequestCli.layer", (it) => { : emptySearch(), ), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; yield* cli.listPullRequests({ cwd: "/w", @@ -1641,7 +1641,7 @@ layer("GitHubPullRequestCli.layer", (it) => { : emptySearch(), ), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; yield* cli.listPullRequests({ cwd: "/w", @@ -1667,7 +1667,7 @@ layer("GitHubPullRequestCli.layer", (it) => { : emptySearch(), ), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; yield* cli.listPullRequests({ cwd: "/w", @@ -1701,7 +1701,7 @@ layer("GitHubPullRequestCli.layer", (it) => { ), ), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const batch = yield* cli.listPullRequests({ cwd: "/w", @@ -1732,7 +1732,7 @@ layer("GitHubPullRequestCli.layer", (it) => { mockedExecute.mockReturnValueOnce( Effect.succeed(output(listedPullRequests(1, 1, () => ({ checks: "PENDING" })))), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const batch = yield* cli.listPullRequests({ cwd: "/w", @@ -1759,7 +1759,7 @@ layer("GitHubPullRequestCli.layer", (it) => { mockedExecute.mockReturnValueOnce( Effect.succeed(output(listedPullRequests(2, 1, (number) => ({ isDraft: number === 1 })))), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const batch = yield* cli.listPullRequests({ cwd: "/w", @@ -1784,7 +1784,7 @@ layer("GitHubPullRequestCli.layer", (it) => { it.effect("carries the further narrowings into a batched search", () => Effect.gen(function* () { mockedExecute.mockReturnValue(Effect.succeed(searchPage([]))); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; yield* cli.searchPullRequests({ cwd: "/w", @@ -1813,7 +1813,7 @@ layer("GitHubPullRequestCli.layer", (it) => { : emptySearch(), ), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; yield* cli.listPullRequests({ cwd: "/w", @@ -1843,7 +1843,7 @@ layer("GitHubPullRequestCli.layer", (it) => { : emptySearch(), ), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; yield* cli.listPullRequests({ cwd: "/w", @@ -1873,7 +1873,7 @@ layer("GitHubPullRequestCli.layer", (it) => { : emptySearch(), ), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; yield* cli.listPullRequests({ cwd: "/w", @@ -1895,7 +1895,7 @@ layer("GitHubPullRequestCli.layer", (it) => { it.effect("carries on from the instant the last slice ended on", () => Effect.gen(function* () { mockedExecute.mockReturnValue(Effect.succeed(output(pullRequests(3, 1)))); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const batch = yield* cli.listPullRequests({ cwd: "/w", @@ -1923,7 +1923,7 @@ layer("GitHubPullRequestCli.layer", (it) => { // answer means the text matched nothing, and listing everything instead would fill the // page with rows the reader did not search for. mockedExecute.mockReturnValueOnce(Effect.succeed(emptySearch())); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const batch = yield* cli.listPullRequests({ cwd: "/w", @@ -1948,7 +1948,7 @@ layer("GitHubPullRequestCli.layer", (it) => { mockedExecute.mockReturnValueOnce( Effect.succeed(output(listedPullRequests(3, 1, () => ({ state: "CLOSED" })))), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const batch = yield* cli.listPullRequests({ cwd: "/w", @@ -1984,7 +1984,7 @@ layer("GitHubPullRequestCli.layer", (it) => { ), ), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const batch = yield* cli.listPullRequests({ cwd: "/w", @@ -2020,7 +2020,7 @@ layer("GitHubPullRequestCli.layer", (it) => { ), ), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const batch = yield* cli.listPullRequests({ cwd: "/w", @@ -2064,7 +2064,7 @@ layer("GitHubPullRequestCli.layer", (it) => { ), ); }); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const batch = yield* cli.listPullRequests({ cwd: "/w", @@ -2094,7 +2094,7 @@ layer("GitHubPullRequestCli.layer", (it) => { : emptySearch(), ), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; yield* cli.listPullRequests({ cwd: "/w", @@ -2139,7 +2139,7 @@ layer("GitHubPullRequestCli.layer", (it) => { it.effect("updates a stale branch with a merge commit unless asked to rebase", () => Effect.gen(function* () { route(["query PullRequestActionState", actionState()]); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; yield* cli.runPullRequestAction({ cwd: "/w", @@ -2168,7 +2168,7 @@ layer("GitHubPullRequestCli.layer", (it) => { it.effect("merges with the strategy it was asked for", () => Effect.gen(function* () { route(["query PullRequestActionState", actionState()]); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; yield* cli.runPullRequestAction({ cwd: "/w", @@ -2199,7 +2199,7 @@ layer("GitHubPullRequestCli.layer", (it) => { ], ["query PullRequestActionState", actionState()], ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; yield* cli.runPullRequestAction({ cwd: "/w", repository: "acme/web", @@ -2243,7 +2243,7 @@ layer("GitHubPullRequestCli.layer", (it) => { ["query PullRequestMergeMessage", mergeMessage(body, queued)], ["query PullRequestActionState", actionState({ isMergeQueueEnabled: queued })], ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; yield* cli.runPullRequestAction({ cwd: "/w", repository: "acme/web", @@ -2269,7 +2269,7 @@ layer("GitHubPullRequestCli.layer", (it) => { ], ["query PullRequestActionState", actionState()], ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; yield* cli.runPullRequestAction({ cwd: "/w", repository: "acme/web", @@ -2294,7 +2294,7 @@ layer("GitHubPullRequestCli.layer", (it) => { it.effect("does not fetch a message for rebase merges", () => Effect.gen(function* () { route(["query PullRequestActionState", actionState()]); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; yield* cli.runPullRequestAction({ cwd: "/w", repository: "acme/web", @@ -2317,7 +2317,7 @@ layer("GitHubPullRequestCli.layer", (it) => { ["query PullRequestMergeMessage", { data: { repository: { pullRequest: null } } }], ["query PullRequestActionState", actionState()], ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const result = yield* Effect.result( cli.runPullRequestAction({ cwd: "/w", @@ -2336,7 +2336,7 @@ layer("GitHubPullRequestCli.layer", (it) => { it.effect("arms auto-merge with the same strategy a merge would have used", () => Effect.gen(function* () { route(["query PullRequestActionState", actionState()]); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; yield* cli.runPullRequestAction({ cwd: "/w", @@ -2366,7 +2366,7 @@ layer("GitHubPullRequestCli.layer", (it) => { it.effect("merges at once when auto-merge is asked of a pull request that is ready now", () => Effect.gen(function* () { route(["query PullRequestActionState", actionState({ mergeStateStatus: "CLEAN" })]); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; yield* cli.runPullRequestAction({ cwd: "/w", @@ -2387,7 +2387,7 @@ layer("GitHubPullRequestCli.layer", (it) => { it.effect("takes auto-merge back off without naming a strategy", () => Effect.gen(function* () { route([NODE_ID_QUERY, nodeIdAnswer("PR_7")]); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; yield* cli.runPullRequestAction({ cwd: "/w", @@ -2405,7 +2405,7 @@ layer("GitHubPullRequestCli.layer", (it) => { it.effect("opens a pull request that reverts a merged pull request", () => Effect.gen(function* () { route([NODE_ID_QUERY, nodeIdAnswer("PR_7")]); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; yield* cli.runPullRequestAction({ cwd: "/w", @@ -2441,7 +2441,7 @@ layer("GitHubPullRequestCli.layer", (it) => { ), ), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; yield* cli.runPullRequestAction({ cwd: "/w", @@ -2458,7 +2458,7 @@ layer("GitHubPullRequestCli.layer", (it) => { it.effect("finds and approves every workflow waiting on a maintainer", () => Effect.gen(function* () { workflowApprovalRoutes(() => crossRepositoryDetail(), heads([7]), workflowRuns([10, 11])); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; yield* cli.runPullRequestAction({ cwd: "/w", @@ -2490,7 +2490,7 @@ layer("GitHubPullRequestCli.layer", (it) => { it.effect("counts revalidated runs that wait on a maintainer as GitHub reports them", () => Effect.gen(function* () { workflowApprovalRoutes(() => crossRepositoryDetail(), heads([7]), workflowRuns([])); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const runs = yield* cli .listWorkflowRunsRequiringApproval({ @@ -2532,7 +2532,7 @@ layer("GitHubPullRequestCli.layer", (it) => { heads([7]), workflowRuns([10]), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const error = yield* Effect.flip( cli.runPullRequestAction({ @@ -2568,7 +2568,7 @@ layer("GitHubPullRequestCli.layer", (it) => { Effect.as(output(encodeJson(workflowRuns([10])))), ), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const runs = yield* cli.listWorkflowRunsRequiringApproval({ cwd: "/w", @@ -2588,7 +2588,7 @@ layer("GitHubPullRequestCli.layer", (it) => { it.effect("refuses workflow approval when one head belongs to several pull requests", () => Effect.gen(function* () { workflowApprovalRoutes(() => crossRepositoryDetail(), heads([7, 8]), workflowRuns([])); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const error = yield* Effect.flip( cli.listWorkflowRunsRequiringApproval({ @@ -2636,7 +2636,7 @@ layer("GitHubPullRequestCli.layer", (it) => { ), ), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const error = yield* Effect.flip( cli.runPullRequestAction({ @@ -2670,7 +2670,7 @@ layer("GitHubPullRequestCli.layer", (it) => { ), ); }); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const error = yield* Effect.flip( cli.listWorkflowRunsRequiringApproval({ @@ -2701,7 +2701,7 @@ layer("GitHubPullRequestCli.layer", (it) => { it.effect("returns a pull request to draft by converting it", () => Effect.gen(function* () { route([NODE_ID_QUERY, nodeIdAnswer("PR_7")]); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; yield* cli.runPullRequestAction({ cwd: "/w", @@ -2719,7 +2719,7 @@ layer("GitHubPullRequestCli.layer", (it) => { it.effect("sends a comment body as a variable, never inside the document", () => Effect.gen(function* () { route([NODE_ID_QUERY, nodeIdAnswer("PR_7")]); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; yield* cli.commentOnPullRequest({ cwd: "/w", @@ -2743,7 +2743,7 @@ layer("GitHubPullRequestCli.layer", (it) => { : emptySearch(), ), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; yield* cli.listPullRequests({ cwd: "/w", @@ -2777,7 +2777,7 @@ layer("GitHubPullRequestCli.layer", (it) => { ), ), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; yield* cli.listReviewThreadComments({ cwd: "/w", @@ -2794,7 +2794,7 @@ layer("GitHubPullRequestCli.layer", (it) => { it.effect("serves a diff GitHub hands over whole in one request, with no next slice", () => Effect.gen(function* () { mockedExecute.mockReturnValueOnce(Effect.succeed(output("diff --git a/a b/a"))); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const diff = yield* cli.getPullRequestDiff({ cwd: "/w", @@ -2823,7 +2823,7 @@ layer("GitHubPullRequestCli.layer", (it) => { // GitHub answers 406 rather than a diff past 300 changed files. mockedExecute.mockReturnValueOnce(Effect.fail(diffRefused)); mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequestFiles(2, 1)))); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const diff = yield* cli.getPullRequestDiff({ cwd: "/w", @@ -2846,7 +2846,7 @@ layer("GitHubPullRequestCli.layer", (it) => { Effect.gen(function* () { mockedExecute.mockReturnValueOnce(Effect.fail(diffRefused)); mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequestFiles(100, 0)))); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const diff = yield* cli.getPullRequestDiff({ cwd: "/w", @@ -2866,7 +2866,7 @@ layer("GitHubPullRequestCli.layer", (it) => { Effect.gen(function* () { mockedExecute.mockReturnValueOnce(Effect.fail(diffRefused)); mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequestFiles(100, 0)))); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const target = { cwd: "/w", repository: "acme/web", host: "github.com", number: 7 }; const first = yield* cli.getPullRequestDiff(target); @@ -2884,7 +2884,7 @@ layer("GitHubPullRequestCli.layer", (it) => { it.effect("refuses a cursor it never handed out rather than reading it into a request", () => Effect.gen(function* () { - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const error = yield* Effect.flip( cli.getPullRequestDiff({ @@ -2906,7 +2906,7 @@ layer("GitHubPullRequestCli.layer", (it) => { mockedExecute.mockReturnValueOnce( Effect.succeed(output(`{"files":${pullRequestFiles(2, 1)}}`)), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const diff = yield* cli.getPullRequestDiff({ cwd: "/w", @@ -2933,7 +2933,7 @@ layer("GitHubPullRequestCli.layer", (it) => { mockedExecute.mockReturnValueOnce( Effect.succeed(output(`{"files":${pullRequestFiles(100, 0)}}`)), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const target = { cwd: "/w", repository: "acme/web", @@ -2957,7 +2957,7 @@ layer("GitHubPullRequestCli.layer", (it) => { it.effect("refuses a commit that is not a sha rather than reading it into a request", () => Effect.gen(function* () { - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const error = yield* Effect.flip( cli.getPullRequestDiff({ @@ -2980,7 +2980,7 @@ layer("GitHubPullRequestCli.layer", (it) => { Effect.succeed(output(encodeJson({ sha: "a1b2c3d", parents: [] }))), ); mockedExecute.mockReturnValueOnce(Effect.succeed(output("root contents\n"))); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const contents = yield* cli.getPullRequestDiffFileContents({ cwd: "/w", @@ -3003,7 +3003,7 @@ layer("GitHubPullRequestCli.layer", (it) => { it.effect("reports unusable diff revisions as a structured error", () => Effect.gen(function* () { mockedExecute.mockReturnValueOnce(Effect.succeed(output("not-a-sha\tstill-not-a-sha\n"))); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const error = yield* Effect.flip( cli.getPullRequestDiffFileContents({ @@ -3030,7 +3030,7 @@ layer("GitHubPullRequestCli.layer", (it) => { Effect.gen(function* () { mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequestRefs))); mockedExecute.mockReturnValueOnce(Effect.succeed(output("partial", true))); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const error = yield* Effect.flip( cli.getPullRequestDiffFileContents({ @@ -3058,7 +3058,7 @@ layer("GitHubPullRequestCli.layer", (it) => { Effect.gen(function* () { mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequestRefs))); mockedExecute.mockReturnValueOnce(Effect.succeed(output("binary�contents", false, true))); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const error = yield* Effect.flip( cli.getPullRequestDiffFileContents({ @@ -3084,7 +3084,7 @@ layer("GitHubPullRequestCli.layer", (it) => { Effect.gen(function* () { mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequestRefs))); mockedExecute.mockReturnValueOnce(Effect.succeed(output("before�after"))); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const contents = yield* cli.getPullRequestDiffFileContents({ cwd: "/w", @@ -3103,7 +3103,7 @@ layer("GitHubPullRequestCli.layer", (it) => { it.effect("ends the diff on a page with no files rather than asking for it again", () => Effect.gen(function* () { mockedExecute.mockReturnValue(Effect.succeed(output("[]"))); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const diff = yield* cli.getPullRequestDiff({ cwd: "/w", @@ -3122,7 +3122,7 @@ layer("GitHubPullRequestCli.layer", (it) => { Effect.gen(function* () { mockedExecute.mockReturnValueOnce(Effect.fail(diffRefused)); mockedExecute.mockReturnValueOnce(Effect.succeed(output("not json"))); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const error = yield* Effect.flip( cli.getPullRequestDiff({ @@ -3139,7 +3139,7 @@ layer("GitHubPullRequestCli.layer", (it) => { it.effect("skips the avatar lookup when a listing named nobody", () => Effect.gen(function* () { - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const avatars = yield* cli.listActorAvatars({ cwd: "/w", @@ -3172,7 +3172,7 @@ layer("GitHubPullRequestCli.layer", (it) => { ), ), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const avatars = yield* cli.listActorAvatars({ cwd: "/w", @@ -3190,7 +3190,7 @@ layer("GitHubPullRequestCli.layer", (it) => { it.effect("fails when the authenticated account has no login", () => Effect.gen(function* () { mockedExecute.mockReturnValueOnce(Effect.succeed(output(" "))); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const error = yield* Effect.flip(cli.getViewerLogin({ cwd: "/w", host: "github.com" })); @@ -3203,7 +3203,7 @@ layer("GitHubPullRequestCli.layer", (it) => { mockedExecute.mockReturnValueOnce( Effect.succeed(output('{"id":456,"login":"enterprise-user"}')), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const login = yield* cli.getViewerLogin({ cwd: "/w", host: "github.acme.com" }); @@ -3215,7 +3215,7 @@ layer("GitHubPullRequestCli.layer", (it) => { it.effect("reuses verified credentials offline and refuses an unverified replacement", () => Effect.gen(function* () { - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const input = { cwd: "/w", host: "github.identity-cache.test" }; mockedCredential.mockImplementation((host) => Effect.succeed({ token: Redacted.make("test-credential-a"), fingerprint: `${host}:a` }), @@ -3263,7 +3263,7 @@ layer("GitHubPullRequestCli.layer", (it) => { it.effect("sends a whole review as one request", () => Effect.gen(function* () { mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; yield* cli.submitReview({ cwd: "/w", @@ -3294,7 +3294,7 @@ layer("GitHubPullRequestCli.layer", (it) => { it.effect("sends a reply body as a variable, never inside the document", () => Effect.gen(function* () { mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; yield* cli.replyToReviewThread({ cwd: "/w", @@ -3314,7 +3314,7 @@ layer("GitHubPullRequestCli.layer", (it) => { it.effect("resolves and unresolves through the mutation each one needs", () => Effect.gen(function* () { mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; yield* cli.setReviewThreadResolution({ cwd: "/w", @@ -3343,7 +3343,7 @@ layer("GitHubPullRequestCli.layer", (it) => { it.effect("confirms a given subject belongs to the named pull request, then reacts to it", () => Effect.gen(function* () { route([SUBJECT_SCOPE_QUERY, subjectScope("IC_1", "PR_kwDOA")]); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; yield* cli.setReaction({ cwd: "/w", @@ -3379,7 +3379,7 @@ layer("GitHubPullRequestCli.layer", (it) => { ), ), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const error = yield* Effect.flip( cli.setReaction({ @@ -3402,7 +3402,7 @@ layer("GitHubPullRequestCli.layer", (it) => { it.effect("looks up the pull request's own node id when no subject was given", () => Effect.gen(function* () { route([NODE_ID_QUERY, nodeIdAnswer("PR_kwDOA")]); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; // Its own pull request: a node id looked up once is remembered for the life of the service. yield* cli.setReaction({ @@ -3423,7 +3423,7 @@ layer("GitHubPullRequestCli.layer", (it) => { it.effect("takes a reaction back through the remove mutation", () => Effect.gen(function* () { route([SUBJECT_SCOPE_QUERY, subjectScope("IC_1", "PR_kwDOA")]); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; yield* cli.setReaction({ cwd: "/w", @@ -3443,7 +3443,7 @@ layer("GitHubPullRequestCli.layer", (it) => { it.effect("rewrites only the words a request named", () => Effect.gen(function* () { route([NODE_ID_QUERY, nodeIdAnswer("PR_kwDOA")]); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const rewrite = (fields: { readonly title?: string; readonly body?: string }) => cli.updatePullRequest({ cwd: "/w", @@ -3470,7 +3470,7 @@ layer("GitHubPullRequestCli.layer", (it) => { it.effect("rewrites a remark through the mutation its kind needs", () => Effect.gen(function* () { route([SUBJECT_SCOPE_QUERY, subjectScope("IC_1", "PR_kwDOA")]); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const rewrite = (kind: "issue-comment" | "review-comment") => cli.updateComment({ cwd: "/w", @@ -3512,7 +3512,7 @@ layer("GitHubPullRequestCli.layer", (it) => { ), ), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const error = yield* Effect.flip( cli.updateComment({ @@ -3537,7 +3537,7 @@ layer("GitHubPullRequestCli.layer", (it) => { it.effect("fails the read when gh returns something unreadable", () => Effect.gen(function* () { mockedExecute.mockReturnValueOnce(Effect.succeed(output('{"message":"not found"}'))); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const error = yield* Effect.flip( cli.getPullRequestDetail({ @@ -3586,7 +3586,7 @@ layer("GitHubPullRequestCli.layer", (it) => { ), ), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const input = { cwd: "/w", repository: "acme/web", @@ -3664,7 +3664,7 @@ layer("GitHubPullRequestCli.layer", (it) => { ), ), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const detail = yield* cli.getPullRequestDetail({ cwd: "/w", repository: "acme/web", @@ -3720,7 +3720,7 @@ layer("GitHubPullRequestCli.layer", (it) => { ), ), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const detail = yield* cli.getPullRequestDetail({ cwd: "/w", repository: "acme/web", @@ -3768,7 +3768,7 @@ layer("GitHubPullRequestCli.layer", (it) => { ), ), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const error = yield* Effect.flip( cli.getPullRequestDetail({ cwd: "/w", @@ -3802,7 +3802,7 @@ layer("GitHubPullRequestCli.layer", (it) => { ), ), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const input = { cwd: "/w", repository: "acme/web", @@ -3822,7 +3822,7 @@ layer("GitHubPullRequestCli.layer", (it) => { Effect.gen(function* () { mockedExecute.mockReturnValueOnce(Effect.fail(diffRefused)); mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequestFiles(1, 1), true))); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const error = yield* Effect.flip( cli.getPullRequestDiff({ @@ -3847,7 +3847,7 @@ layer("GitHubPullRequestCli.layer", (it) => { Effect.succeed(output("diff --git a/a b/a\n@@ -1 +1 @@", true)), ); mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequestFiles(1, 1)))); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const slice = yield* cli.getPullRequestDiff({ cwd: "/w", @@ -3872,7 +3872,7 @@ layer("GitHubPullRequestCli.layer", (it) => { mockedExecute.mockReturnValueOnce( Effect.succeed(output(reviewThreadsPage([thread("PRRT_2", "c2")], null))), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const conversation = yield* cli.listReviewThreadComments({ cwd: "/w", @@ -3902,7 +3902,7 @@ layer("GitHubPullRequestCli.layer", (it) => { ), ), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const conversation = yield* cli.listReviewThreadComments({ cwd: "/w", @@ -3929,7 +3929,7 @@ layer("GitHubPullRequestCli.layer", (it) => { ), ), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const conversation = yield* cli.listReviewThreadComments({ cwd: "/w", @@ -3954,7 +3954,7 @@ layer("GitHubPullRequestCli.layer", (it) => { mockedExecute.mockReturnValueOnce( Effect.succeed(output(threadCommentsPage(["c2", "c3"], null, 3))), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const page = yield* cli.getReviewThreadComments({ cwd: "/w", @@ -3983,7 +3983,7 @@ layer("GitHubPullRequestCli.layer", (it) => { mockedExecute.mockReturnValueOnce( Effect.succeed(output(threadCommentsPage(["foreign"], null, 1, "PR_8"))), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const error = yield* Effect.flip( cli.getReviewThreadComments({ @@ -4021,7 +4021,7 @@ layer("GitHubPullRequestCli.layer", (it) => { ), ), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const access = yield* cli.getViewerAccess({ cwd: "/w", @@ -4069,7 +4069,7 @@ layer("GitHubPullRequestCli.layer", (it) => { ), ), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const input = { cwd: "/w", repository: "acme/web", @@ -4134,7 +4134,7 @@ layer("GitHubPullRequestCli.layer", (it) => { ), ), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; yield* cli.getViewerAccess({ cwd: "/w", @@ -4165,7 +4165,7 @@ layer("GitHubPullRequestCli.layer", (it) => { it.effect("asks GitHub to review, naming the collection a request is added to", () => Effect.gen(function* () { mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; yield* cli.setReviewerRequest({ cwd: "/w", @@ -4191,7 +4191,7 @@ layer("GitHubPullRequestCli.layer", (it) => { it.effect("takes a request back by deleting from the same collection it was added to", () => Effect.gen(function* () { mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; yield* cli.setReviewerRequest({ cwd: "/w", @@ -4233,7 +4233,7 @@ layer("GitHubPullRequestCli.layer", (it) => { ), ), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const list = yield* cli.listReviewerCandidates({ cwd: "/w", @@ -4256,7 +4256,7 @@ layer("GitHubPullRequestCli.layer", (it) => { it.effect("puts labels on by posting to the issue's own collection, all at once", () => Effect.gen(function* () { mockedExecute.mockReturnValue(Effect.succeed(output("[]"))); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; yield* cli.setLabels({ cwd: "/w", @@ -4280,7 +4280,7 @@ layer("GitHubPullRequestCli.layer", (it) => { it.effect("takes labels off one at a time, naming each in the path encoded", () => Effect.gen(function* () { mockedExecute.mockReturnValue(Effect.succeed(output("[]"))); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; yield* cli.setLabels({ cwd: "/w", @@ -4329,7 +4329,7 @@ layer("GitHubPullRequestCli.layer", (it) => { .mockReturnValueOnce(page(0, true)) .mockReturnValueOnce(page(1, true)) .mockReturnValueOnce(page(2, false)); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const viewed = yield* cli.getPullRequestFilesViewed({ cwd: "/w", @@ -4377,7 +4377,7 @@ layer("GitHubPullRequestCli.layer", (it) => { ), ), ); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const viewed = yield* cli.getPullRequestFilesViewed({ cwd: "/w", @@ -4401,7 +4401,7 @@ layer("GitHubPullRequestCli.layer", (it) => { ), ) .mockReturnValueOnce(Effect.succeed(output("{}"))); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; yield* cli.setPullRequestFilesViewed({ cwd: "/w", @@ -4428,7 +4428,7 @@ layer("GitHubPullRequestCli.layer", (it) => { it.effect("asks the host nothing when nothing was pressed", () => Effect.gen(function* () { - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; yield* cli.setPullRequestFilesViewed({ cwd: "/w", @@ -4451,7 +4451,7 @@ layer("GitHubPullRequestCli.layer", (it) => { ), ) .mockReturnValue(Effect.succeed(output("{}"))); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const pullRequest = { cwd: "/w", repository: "acme/web", host: "github.com", number: 24 }; yield* cli.setPullRequestFilesViewed({ @@ -4485,7 +4485,7 @@ layer("GitHubPullRequestCli.layer", (it) => { ), ) .mockReturnValueOnce(Effect.succeed(output("{}"))); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const write = () => cli.setPullRequestFilesViewed({ cwd: "/w", @@ -4522,7 +4522,7 @@ layer("GitHubPullRequestCli.layer", (it) => { output(encodeJson({ data: { repository: { pullRequest: { id: `PR_${number}` } } } })), ); }); - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const tick = (number: number) => cli.setPullRequestFilesViewed({ cwd: "/w", @@ -4534,7 +4534,7 @@ layer("GitHubPullRequestCli.layer", (it) => { yield* tick(HOT); // A cache's worth of cold pull requests, with the open one pressed in between each of them. - for (let filled = 0; filled < GitHubPullRequestCli.NODE_ID_CACHE_CAPACITY; filled += 1) { + for (let filled = 0; filled < GitHubPullRequestApi.NODE_ID_CACHE_CAPACITY; filled += 1) { yield* tick(HOT + 1 + filled); yield* tick(HOT); } diff --git a/apps/server/src/pullRequest/GitHubPullRequestCli.ts b/apps/server/src/pullRequest/GitHubPullRequestApi.ts similarity index 96% rename from apps/server/src/pullRequest/GitHubPullRequestCli.ts rename to apps/server/src/pullRequest/GitHubPullRequestApi.ts index ce944b931aa8..116a8a742d1a 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestCli.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestApi.ts @@ -42,7 +42,7 @@ import { type PullRequestPreview, } from "@t3tools/contracts"; -import { AllowGitHubReserve } from "../sourceControl/GitHubCli.ts"; +import { AllowGitHubReserve } from "../sourceControl/GitHubRepositoryApi.ts"; import * as GitHubApi from "../sourceControl/GitHubApi.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as SourceControlRateLimit from "../sourceControl/SourceControlRateLimit.ts"; @@ -138,7 +138,6 @@ import type { ProviderChangeRequestSummary, ProviderListCursor } from "./PullReq export class GitHubPullRequestReadError extends Schema.TaggedError()( "GitHubPullRequestReadError", { - command: Schema.Literal("gh"), cwd: Schema.String, operation: Schema.String, cause: Schema.Defect(), @@ -149,11 +148,10 @@ export class GitHubPullRequestReadError extends Schema.TaggedError()( "GitHubViewerLoginUnavailableError", { - command: Schema.Literal("gh"), cwd: Schema.String, }, ) { @@ -162,11 +160,10 @@ export class GitHubViewerLoginUnavailableError extends Schema.TaggedError()( "GitHubPullRequestUpdatedAtUnavailableError", { - command: Schema.Literal("gh"), cwd: Schema.String, repository: Schema.String, number: Schema.Int, @@ -181,7 +178,6 @@ export class GitHubPullRequestUpdatedAtUnavailableError extends Schema.TaggedErr export class GitHubDiffCursorError extends Schema.TaggedError()( "GitHubDiffCursorError", { - command: Schema.Literal("gh"), cwd: Schema.String, }, ) { @@ -194,7 +190,6 @@ export class GitHubDiffCursorError extends Schema.TaggedError()( "GitHubDiffCommitError", { - command: Schema.Literal("gh"), cwd: Schema.String, }, ) { @@ -207,7 +202,6 @@ export class GitHubDiffCommitError extends Schema.TaggedError()( "GitHubDiffRevisionsUnavailableError", { - command: Schema.Literal("gh"), cwd: Schema.String, number: Schema.Int, commit: Schema.optional(Schema.String), @@ -224,7 +218,6 @@ export class GitHubDiffRevisionsUnavailableError extends Schema.TaggedError()( "GitHubDiffFileContentsUnavailableError", { - command: Schema.Literal("gh"), cwd: Schema.String, path: Schema.String, reason: Schema.Literals(["oversized", "binary"]), @@ -246,7 +239,6 @@ export class GitHubDiffFileContentsUnavailableError extends Schema.TaggedError()( "GitHubRepositorySelectorError", { - command: Schema.Literal("gh"), cwd: Schema.String, operation: Schema.String, }, @@ -260,7 +252,6 @@ export class GitHubRepositorySelectorError extends Schema.TaggedError()( "GitHubSubjectScopeError", { - command: Schema.Literal("gh"), cwd: Schema.String, operation: Schema.String, }, @@ -274,7 +265,6 @@ export class GitHubSubjectScopeError extends Schema.TaggedError()( "GitHubWorkflowApprovalRefusedError", { - command: Schema.Literal("gh"), cwd: Schema.String, number: Schema.Int, reason: Schema.Literals(["head-list-truncated", "head-not-unique", "run-list-truncated"]), @@ -297,7 +287,6 @@ export class GitHubWorkflowApprovalRefusedError extends Schema.TaggedError()( "GitHubWorkflowApprovalHeadUnavailableError", { - command: Schema.Literal("gh"), cwd: Schema.String, number: Schema.Int, }, @@ -311,7 +300,6 @@ export class GitHubWorkflowApprovalHeadUnavailableError extends Schema.TaggedErr export class GitHubWorkflowApprovalHeadChangedError extends Schema.TaggedError()( "GitHubWorkflowApprovalHeadChangedError", { - command: Schema.Literal("gh"), cwd: Schema.String, number: Schema.Int, }, @@ -321,7 +309,7 @@ export class GitHubWorkflowApprovalHeadChangedError extends Schema.TaggedError {} class PullRequestWatchFingerprintRead extends Request.Class< @@ -435,7 +423,7 @@ class PullRequestWatchFingerprintRead extends Request.Class< readonly number: number; }, GitHubPullRequestWatchFingerprint | null, - GitHubPullRequestCliError + GitHubPullRequestApiError > {} export interface GitHubPullRequestSearchBatch { @@ -460,8 +448,8 @@ export interface GitHubPullRequestFilesViewed { readonly truncated: boolean; } -export class GitHubPullRequestCli extends Context.Service< - GitHubPullRequestCli, +export class GitHubPullRequestApi extends Context.Service< + GitHubPullRequestApi, { readonly withVerifiedCredential: ( input: { readonly cwd: string; readonly host: string }, @@ -470,18 +458,18 @@ export class GitHubPullRequestCli extends Context.Service< readonly viewer: string; readonly credentialFingerprint: string; }) => Effect.Effect, - ) => Effect.Effect; + ) => Effect.Effect; readonly getRoutingIdentity: (input: { readonly cwd: string; readonly host: string; }) => Effect.Effect< { readonly accountId: string; readonly viewer: string }, - GitHubPullRequestCliError + GitHubPullRequestApiError >; readonly getViewerLogin: (input: { readonly cwd: string; readonly host: string; - }) => Effect.Effect; + }) => Effect.Effect; readonly listPullRequests: (input: { readonly cwd: string; @@ -497,7 +485,7 @@ export class GitHubPullRequestCli extends Context.Service< 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; + }) => Effect.Effect; /** * The same listing for a whole host in one search. `limit` is the size of the slice across @@ -516,7 +504,7 @@ export class GitHubPullRequestCli extends Context.Service< readonly query?: string | undefined; readonly cursor?: ProviderListCursor | undefined; readonly filters?: PullRequestListFilters | undefined; - }) => Effect.Effect; + }) => Effect.Effect; /** The line counts the search leaves out, for rows already on the page. */ readonly listPullRequestStats: (input: { @@ -526,14 +514,14 @@ export class GitHubPullRequestCli extends Context.Service< readonly repository: string; readonly number: number; }>; - }) => Effect.Effect, GitHubPullRequestCliError>; + }) => Effect.Effect, GitHubPullRequestApiError>; readonly getPullRequestSummary: (input: { readonly cwd: string; readonly repository: string; readonly host: string; readonly number: number; - }) => Effect.Effect; + }) => Effect.Effect; /** * What a watch compares between passes, batched like summaries. Null when GitHub gave no * answer for this pull request, so the watch reads it in full instead. @@ -543,7 +531,7 @@ export class GitHubPullRequestCli extends Context.Service< readonly repository: string; readonly host: string; readonly number: number; - }) => Effect.Effect; + }) => Effect.Effect; readonly revalidateChecks: Effect.Success; @@ -552,7 +540,7 @@ export class GitHubPullRequestCli extends Context.Service< readonly repository: string; readonly host: string; readonly number: number; - }) => Effect.Effect; + }) => Effect.Effect; readonly getPullRequestPreview: (input: { readonly cwd: string; @@ -561,7 +549,7 @@ export class GitHubPullRequestCli extends Context.Service< readonly number: number; }) => Effect.Effect< Omit, - GitHubPullRequestCliError + GitHubPullRequestApiError >; readonly listWorkflowRunsRequiringApproval: (input: { @@ -573,7 +561,7 @@ export class GitHubPullRequestCli extends Context.Service< readonly headBranch: string; readonly headRepositoryOwner: string; readonly isCrossRepository: true; - }) => Effect.Effect, GitHubPullRequestCliError>; + }) => Effect.Effect, GitHubPullRequestApiError>; /** * The host-native stack this pull request is in, or null when it is in none — which is also @@ -585,14 +573,14 @@ export class GitHubPullRequestCli extends Context.Service< readonly repository: string; readonly host: string; readonly number: number; - }) => Effect.Effect; + }) => Effect.Effect; readonly getPullRequestActivity: (input: { readonly cwd: string; readonly repository: string; readonly host: string; readonly number: number; - }) => Effect.Effect; + }) => Effect.Effect; readonly getPullRequestDiff: (input: { readonly cwd: string; @@ -603,7 +591,7 @@ export class GitHubPullRequestCli extends Context.Service< readonly cursor?: string | undefined; /** One commit's own changes, rather than everything the pull request carries. */ readonly commit?: string | undefined; - }) => Effect.Effect; + }) => Effect.Effect; readonly getPullRequestDiffFileContents: (input: { readonly cwd: string; @@ -616,7 +604,7 @@ export class GitHubPullRequestCli extends Context.Service< readonly newPath: string; }) => Effect.Effect< { readonly oldContents: string; readonly newContents: string }, - GitHubPullRequestCliError + GitHubPullRequestApiError >; /** @@ -628,7 +616,7 @@ export class GitHubPullRequestCli extends Context.Service< readonly repository: string; readonly host: string; readonly number: number; - }) => Effect.Effect; + }) => Effect.Effect; /** * Clears files, or puts them back, as one request. GitHub takes a single path per mutation, @@ -640,14 +628,14 @@ export class GitHubPullRequestCli extends Context.Service< readonly host: string; readonly number: number; readonly files: ReadonlyArray<{ readonly path: string; readonly viewed: boolean }>; - }) => Effect.Effect; + }) => Effect.Effect; readonly listReviewThreadComments: (input: { readonly cwd: string; readonly repository: string; readonly host: string; readonly number: number; - }) => Effect.Effect; + }) => Effect.Effect; /** One request for a listing's authors, since no `gh` JSON field reports an avatar. */ readonly listActorAvatars: (input: { @@ -655,7 +643,7 @@ export class GitHubPullRequestCli extends Context.Service< readonly repository: string; readonly host: string; readonly ids: ReadonlyArray; - }) => Effect.Effect, GitHubPullRequestCliError>; + }) => Effect.Effect, GitHubPullRequestApiError>; readonly getReviewThreadComments: (input: { readonly cwd: string; @@ -664,7 +652,7 @@ export class GitHubPullRequestCli extends Context.Service< readonly number: number; readonly threadId: string; readonly cursor: string; - }) => Effect.Effect; + }) => Effect.Effect; /** The viewer's standing on its own, for deciding a write without reading the whole detail. */ readonly getViewerAccess: (input: { @@ -674,7 +662,7 @@ export class GitHubPullRequestCli extends Context.Service< readonly number: number; /** Manual action checks may use the quota held back from automatic reads. */ readonly allowReserve?: boolean | undefined; - }) => Effect.Effect; + }) => Effect.Effect; /** Who this pull request may be sent to, and who it has already been sent to. */ readonly listReviewerCandidates: (input: { @@ -682,7 +670,7 @@ export class GitHubPullRequestCli extends Context.Service< readonly repository: string; readonly host: string; readonly number: number; - }) => Effect.Effect; + }) => Effect.Effect; readonly setReviewerRequest: (input: { readonly cwd: string; @@ -695,7 +683,7 @@ export class GitHubPullRequestCli extends Context.Service< }>; /** False deletes the same collection a request posts to, which takes the request back. */ readonly requested: boolean; - }) => Effect.Effect; + }) => Effect.Effect; /** The repository's labels, and which of them this pull request already wears. */ readonly listLabelCandidates: (input: { @@ -703,7 +691,7 @@ export class GitHubPullRequestCli extends Context.Service< readonly repository: string; readonly host: string; readonly number: number; - }) => Effect.Effect; + }) => Effect.Effect; readonly setLabels: (input: { readonly cwd: string; @@ -713,7 +701,7 @@ export class GitHubPullRequestCli extends Context.Service< readonly labels: ReadonlyArray; /** False takes each label off; true adds each to whatever is already there. */ readonly applied: boolean; - }) => Effect.Effect; + }) => Effect.Effect; readonly runPullRequestAction: (input: { readonly cwd: string; @@ -726,7 +714,7 @@ export class GitHubPullRequestCli extends Context.Service< readonly removeAgentCreditsOnMerge?: boolean; readonly mergeMethod?: PullRequestMergeMethod; readonly updateMethod?: PullRequestUpdateMethod; - }) => Effect.Effect; + }) => Effect.Effect; readonly commentOnPullRequest: (input: { readonly cwd: string; @@ -734,7 +722,7 @@ export class GitHubPullRequestCli extends Context.Service< readonly host: string; readonly number: number; readonly body: string; - }) => Effect.Effect; + }) => Effect.Effect; readonly submitReview: (input: { readonly cwd: string; @@ -744,7 +732,7 @@ export class GitHubPullRequestCli extends Context.Service< readonly verdict: PullRequestReviewVerdict; readonly body: string; readonly comments: ReadonlyArray; - }) => Effect.Effect; + }) => Effect.Effect; readonly replyToReviewThread: (input: { readonly cwd: string; @@ -752,7 +740,7 @@ export class GitHubPullRequestCli extends Context.Service< readonly host: string; readonly threadId: string; readonly body: string; - }) => Effect.Effect; + }) => Effect.Effect; readonly setReviewThreadResolution: (input: { readonly cwd: string; @@ -760,7 +748,7 @@ export class GitHubPullRequestCli extends Context.Service< readonly host: string; readonly threadId: string; readonly resolved: boolean; - }) => Effect.Effect; + }) => Effect.Effect; /** * Adds a reaction to a remark, or takes it back. `subjectId` is any node GitHub calls @@ -776,7 +764,7 @@ export class GitHubPullRequestCli extends Context.Service< readonly subjectId?: string | undefined; readonly content: PullRequestReactionContent; readonly reacted: boolean; - }) => Effect.Effect; + }) => Effect.Effect; /** Rewrites the pull request's own words, leaving whichever of the two was not given. */ readonly updatePullRequest: (input: { @@ -786,7 +774,7 @@ export class GitHubPullRequestCli extends Context.Service< readonly number: number; readonly title?: string | undefined; readonly body?: string | undefined; - }) => Effect.Effect; + }) => Effect.Effect; /** * Rewrites a remark. `commentId` is trusted to be whatever node it names, so it is confirmed @@ -801,9 +789,9 @@ export class GitHubPullRequestCli extends Context.Service< readonly commentId: string; readonly kind: "issue-comment" | "review-comment"; readonly body: string; - }) => Effect.Effect; + }) => Effect.Effect; } ->()("t3/pullRequest/GitHubPullRequestCli") {} +>()("t3/pullRequest/GitHubPullRequestApi") {} /** * The GraphQL API takes owner and name as separate arguments, so `owner/repo` is split here. @@ -1134,10 +1122,9 @@ export const make = Effect.gen(function* () { }), ), ); - const captureVerifiedCredential = Effect.fn("GitHubPullRequestCli.captureVerifiedCredential")( + const captureVerifiedCredential = Effect.fn("GitHubPullRequestApi.captureVerifiedCredential")( function* (input: { readonly cwd: string; readonly host: string }) { - const unavailable = () => - new GitHubViewerLoginUnavailableError({ command: "gh", cwd: input.cwd }); + const unavailable = () => new GitHubViewerLoginUnavailableError({ cwd: input.cwd }); const host = input.host.toLowerCase(); // A missing or signed-out credential keeps its own error, so the page can say which. const { token, fingerprint: key } = yield* api.credential(host); @@ -1183,7 +1170,7 @@ export const make = Effect.gen(function* () { ); }, ); - const withVerifiedCredential: GitHubPullRequestCli["Service"]["withVerifiedCredential"] = ( + const withVerifiedCredential: GitHubPullRequestApi["Service"]["withVerifiedCredential"] = ( input, use, ) => @@ -1199,7 +1186,7 @@ export const make = Effect.gen(function* () { ), ), ); - const getRoutingIdentity: GitHubPullRequestCli["Service"]["getRoutingIdentity"] = (input) => + const getRoutingIdentity: GitHubPullRequestApi["Service"]["getRoutingIdentity"] = (input) => captureVerifiedCredential(input).pipe( Effect.map(({ accountId, viewer }) => ({ accountId, viewer })), ); @@ -1219,7 +1206,7 @@ export const make = Effect.gen(function* () { readonly host: string; readonly number: number; readonly operation: string; - }): Effect.Effect => { + }): Effect.Effect => { const { owner, name } = parseRepositorySelector(input.repository); const key = `${input.host} ${owner}/${name} ${input.number}`; const held = nodeIds.get(key); @@ -1287,7 +1274,7 @@ export const make = Effect.gen(function* () { }) => api.graphql(input).pipe(Effect.asVoid); const readError = (cwd: string, operation: string, cause: unknown) => - new GitHubPullRequestReadError({ command: "gh", cwd, operation, cause }); + new GitHubPullRequestReadError({ cwd, operation, cause }); /** Decodes an answer, reporting a failure against the read that made it. */ const decodeWith =
    ( @@ -1311,7 +1298,7 @@ export const make = Effect.gen(function* () { readonly variables?: Readonly>; readonly query: string; readonly decode: (raw: string) => Result.Result; - }): Effect.Effect => + }): Effect.Effect => api .graphql({ host: input.host, @@ -1329,7 +1316,7 @@ export const make = Effect.gen(function* () { readonly operation: string; readonly path: string; readonly decode: (raw: string) => Result.Result; - }): Effect.Effect => + }): Effect.Effect => api .rest({ host: input.host, operation: input.operation, path: input.path }) .pipe( @@ -1355,7 +1342,7 @@ export const make = Effect.gen(function* () { readonly number: number; readonly page: number; readonly commit?: string | undefined; - }): Effect.Effect => { + }): Effect.Effect => { const { owner, name } = parseRepositorySelector(input.repository); const paging = `per_page=${DIFF_FILES_PAGE_SIZE}&page=${input.page}`; return api @@ -1407,11 +1394,11 @@ export const make = Effect.gen(function* () { ); }; - const getPullRequestDiffFileContents: GitHubPullRequestCli["Service"]["getPullRequestDiffFileContents"] = + const getPullRequestDiffFileContents: GitHubPullRequestApi["Service"]["getPullRequestDiffFileContents"] = (input) => Effect.gen(function* () { if (input.commit !== undefined && !isCommitSha(input.commit)) { - return yield* new GitHubDiffCommitError({ command: "gh", cwd: input.cwd }); + return yield* new GitHubDiffCommitError({ cwd: input.cwd }); } const { owner, name } = parseRepositorySelector(input.repository); const refsResponse = yield* api.rest({ @@ -1444,7 +1431,6 @@ export const make = Effect.gen(function* () { !isCommitSha(headRef) ) { return yield* new GitHubDiffRevisionsUnavailableError({ - command: "gh", cwd: input.cwd, number: input.number, ...(input.commit === undefined ? {} : { commit: input.commit }), @@ -1469,7 +1455,6 @@ export const make = Effect.gen(function* () { response.truncated || response.body.includes("\0") || response.invalidUtf8 ? Effect.fail( new GitHubDiffFileContentsUnavailableError({ - command: "gh", cwd: input.cwd, path: filePath, reason: response.truncated ? "oversized" : "binary", @@ -1497,7 +1482,7 @@ export const make = Effect.gen(function* () { * mixing two revisions' checks. */ const readAllCheckContexts = ( - input: Parameters[0], + input: Parameters[0], allowReserve: boolean, ) => Effect.gen(function* () { @@ -1534,7 +1519,7 @@ export const make = Effect.gen(function* () { return { headSha, contexts, truncated: after !== null }; }); - const getPullRequestDetail: GitHubPullRequestCli["Service"]["getPullRequestDetail"] = (input) => { + const getPullRequestDetail: GitHubPullRequestApi["Service"]["getPullRequestDetail"] = (input) => { const { owner, name } = parseRepositorySelector(input.repository); return AllowGitHubReserve.pipe( Effect.flatMap((allowReserve) => @@ -1659,7 +1644,7 @@ export const make = Effect.gen(function* () { return runs; }); - const listWorkflowRunsRequiringApproval: GitHubPullRequestCli["Service"]["listWorkflowRunsRequiringApproval"] = + const listWorkflowRunsRequiringApproval: GitHubPullRequestApi["Service"]["listWorkflowRunsRequiringApproval"] = (input) => Effect.all( [ @@ -1681,7 +1666,6 @@ export const make = Effect.gen(function* () { if (heads.length > workflowApprovalLimit) { return Effect.fail( new GitHubWorkflowApprovalRefusedError({ - command: "gh", cwd: input.cwd, number: input.number, reason: "head-list-truncated", @@ -1693,7 +1677,6 @@ export const make = Effect.gen(function* () { if (exactHeads.length !== 1 || exactHeads[0]?.number !== input.number) { return Effect.fail( new GitHubWorkflowApprovalRefusedError({ - command: "gh", cwd: input.cwd, number: input.number, reason: "head-not-unique", @@ -1711,7 +1694,6 @@ export const make = Effect.gen(function* () { runs.length > workflowApprovalLimit ? Effect.fail( new GitHubWorkflowApprovalRefusedError({ - command: "gh", cwd: input.cwd, number: input.number, reason: "run-list-truncated", @@ -1830,7 +1812,7 @@ export const make = Effect.gen(function* () { RequestResolver.setDelay(SUMMARY_BATCH_WINDOW), RequestResolver.batchN(STAT_ALIASES_PER_REQUEST), ); - const getPullRequestSummary: GitHubPullRequestCli["Service"]["getPullRequestSummary"] = (input) => + const getPullRequestSummary: GitHubPullRequestApi["Service"]["getPullRequestSummary"] = (input) => Effect.request(new PullRequestSummaryRead(input), summaryResolver); // Every watched pull request on a host in one read per pass. A pull request the batch has no @@ -1884,10 +1866,10 @@ export const make = Effect.gen(function* () { RequestResolver.setDelay(SUMMARY_BATCH_WINDOW), RequestResolver.batchN(STAT_ALIASES_PER_REQUEST), ); - const getPullRequestWatchFingerprint: GitHubPullRequestCli["Service"]["getPullRequestWatchFingerprint"] = + const getPullRequestWatchFingerprint: GitHubPullRequestApi["Service"]["getPullRequestWatchFingerprint"] = (input) => Effect.request(new PullRequestWatchFingerprintRead(input), watchFingerprintResolver); - return GitHubPullRequestCli.of({ + return GitHubPullRequestApi.of({ withVerifiedCredential, revalidateChecks, getRoutingIdentity, @@ -1901,7 +1883,6 @@ export const make = Effect.gen(function* () { if (query === null) { return Effect.fail( new GitHubRepositorySelectorError({ - command: "gh", cwd: input.cwd, operation: "listPullRequests", }), @@ -1913,7 +1894,7 @@ export const make = Effect.gen(function* () { page: ( after: string | null, rows: number, - ) => Effect.Effect, + ) => Effect.Effect, ) => Effect.gen(function* () { const items: GitHubPullRequestListItem[] = []; @@ -1930,7 +1911,7 @@ export const make = Effect.gen(function* () { const read = ( continues: boolean, requestedRows = input.limit + 1, - ): Effect.Effect => + ): Effect.Effect => collect(requestedRows, (after, rows) => continues ? graphqlRead({ @@ -2047,7 +2028,6 @@ export const make = Effect.gen(function* () { if (query === null) { return Effect.fail( new GitHubRepositorySelectorError({ - command: "gh", cwd: input.cwd, operation: "searchPullRequests", }), @@ -2085,7 +2065,6 @@ export const make = Effect.gen(function* () { if (query === null) { return Effect.fail( new GitHubRepositorySelectorError({ - command: "gh", cwd: input.cwd, operation: "listPullRequestStats", }), @@ -2214,14 +2193,14 @@ export const make = Effect.gen(function* () { ...(input.commit === undefined ? {} : { commit: input.commit }), }); if (input.commit !== undefined && !isCommitSha(input.commit)) { - return Effect.fail(new GitHubDiffCommitError({ command: "gh", cwd: input.cwd })); + return Effect.fail(new GitHubDiffCommitError({ cwd: input.cwd })); } // A cursor only ever comes from the files walk, so a reader carrying one is already past // the point where `gh pr diff` had anything to say. if (input.cursor !== undefined) { const page = diffCursorPage(input.cursor); return page === null - ? Effect.fail(new GitHubDiffCursorError({ command: "gh", cwd: input.cwd })) + ? Effect.fail(new GitHubDiffCursorError({ cwd: input.cwd })) : filesPage(page); } // `gh pr diff` speaks for the whole pull request and has no way to name one commit of it. @@ -2283,7 +2262,6 @@ export const make = Effect.gen(function* () { ? Effect.succeed({ comments, nextCursor }) : Effect.fail( new GitHubSubjectScopeError({ - command: "gh", cwd: input.cwd, operation: "getReviewThreadComments", }), @@ -2297,7 +2275,7 @@ export const make = Effect.gen(function* () { const { owner, name } = parseRepositorySelector(input.repository); const threadPage = ( cursor: string | null, - ): Effect.Effect => + ): Effect.Effect => graphqlRead({ cwd: input.cwd, host: input.host, @@ -2522,7 +2500,6 @@ export const make = Effect.gen(function* () { if (detail.headSha == null || detail.headRepositoryOwner == null) { return Effect.fail( new GitHubWorkflowApprovalHeadUnavailableError({ - command: "gh", cwd: input.cwd, number: input.number, }), @@ -2547,7 +2524,6 @@ export const make = Effect.gen(function* () { if (current.headSha == null || current.headRepositoryOwner == null) { return Effect.fail( new GitHubWorkflowApprovalHeadUnavailableError({ - command: "gh", cwd: input.cwd, number: input.number, }), @@ -2562,7 +2538,6 @@ export const make = Effect.gen(function* () { ) { return Effect.fail( new GitHubWorkflowApprovalHeadChangedError({ - command: "gh", cwd: input.cwd, number: input.number, }), @@ -2737,7 +2712,7 @@ export const make = Effect.gen(function* () { after: string | null, collected: ReadonlyArray, pagesLeft: number, - ): Effect.Effect => + ): Effect.Effect => graphqlRead({ cwd: input.cwd, host: input.host, @@ -2799,7 +2774,6 @@ export const make = Effect.gen(function* () { ? Effect.succeed(givenSubjectId) : Effect.fail( new GitHubSubjectScopeError({ - command: "gh", cwd: input.cwd, operation: "setReaction", }), @@ -2850,7 +2824,6 @@ export const make = Effect.gen(function* () { ? Effect.succeed(input.commentId) : Effect.fail( new GitHubSubjectScopeError({ - command: "gh", cwd: input.cwd, operation: "updateComment", }), @@ -2871,4 +2844,4 @@ export const make = Effect.gen(function* () { }); }); -export const layer = Layer.effect(GitHubPullRequestCli, make); +export const layer = Layer.effect(GitHubPullRequestApi, make); diff --git a/apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts b/apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts index 88079ebb94c7..f00cbfaf5d32 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts @@ -7,8 +7,8 @@ import type { PullRequestReaction } from "@t3tools/contracts"; import { decodePullRequestDetailJson } from "./gitHubPullRequestJson.ts"; import * as GitHubApi from "../sourceControl/GitHubApi.ts"; -import * as GitHubCli from "../sourceControl/GitHubCli.ts"; -import * as GitHubPullRequestCli from "./GitHubPullRequestCli.ts"; +import * as GitHubRepositoryApi from "../sourceControl/GitHubRepositoryApi.ts"; +import * as GitHubPullRequestApi from "./GitHubPullRequestApi.ts"; import type { GitHubPullRequestCore } from "./gitHubPullRequestJson.ts"; import { gitHubViewerPermissions, loginAvatarUrl, make } from "./GitHubPullRequestProvider.ts"; import type { GitHubReviewThreadComments } from "./gitHubPullRequestJson.ts"; @@ -31,13 +31,12 @@ it.effect("maps credential verification failures without relabeling operation fa let operations = 0; const provider = yield* make.pipe( Effect.provide( - Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({ + Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ revalidateChecks: (_input, read) => read, withVerifiedCredential: (_input, use) => verificationFails ? Effect.fail( - new GitHubPullRequestCli.GitHubViewerLoginUnavailableError({ - command: "gh", + new GitHubPullRequestApi.GitHubViewerLoginUnavailableError({ cwd: "/w", }), ) @@ -76,7 +75,7 @@ it.effect("refreshes checks without permissions or comparison reads", () => ); const provider = yield* make.pipe( Effect.provide( - Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({ + Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ revalidateChecks: (_input, read) => read, getPullRequestDetail: () => Effect.sync(() => { @@ -119,7 +118,7 @@ it.effect("uses one narrow read for a linked pull request summary", () => let summaryReads = 0; const provider = yield* make.pipe( Effect.provide( - Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({ + Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ revalidateChecks: (_input, read) => read, getPullRequestSummary: () => Effect.sync(() => { @@ -169,7 +168,7 @@ it.effect("declares host-native stacks and passes the one the CLI reads through" }; const provider = yield* make.pipe( Effect.provide( - Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({ + Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ revalidateChecks: (_input, read) => read, getPullRequestStack: (input) => Effect.succeed(input.number === 7 ? stack : null), }), @@ -189,12 +188,11 @@ it.effect("reports a failed stack read against its own operation", () => Effect.gen(function* () { const provider = yield* make.pipe( Effect.provide( - Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({ + Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ revalidateChecks: (_input, read) => read, getPullRequestStack: () => Effect.fail( - new GitHubPullRequestCli.GitHubPullRequestReadError({ - command: "gh", + new GitHubPullRequestApi.GitHubPullRequestReadError({ cwd: "/w", operation: "getPullRequestStack", cause: new Error("unreadable"), @@ -343,7 +341,7 @@ describe("gitHubViewerPermissions", () => { } }).pipe( Effect.provide( - Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({ + Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ revalidateChecks: (_input, read) => read, getPullRequestDetail: () => Effect.succeed({ @@ -437,7 +435,7 @@ describe("gitHubViewerPermissions", () => { ]); }).pipe( Effect.provide( - Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({ + Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ revalidateChecks: (_input, read) => read, getPullRequestDetail: () => Effect.succeed({ @@ -541,7 +539,7 @@ it.effect( Effect.gen(function* () { const provider = yield* make.pipe( Effect.provide( - Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({ + Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ getPullRequestDetail: () => Effect.succeed({ ...openDetail, comparison: { behindBy: 2, viewerCanUpdate: true } }), listWorkflowRunsRequiringApproval: () => @@ -582,7 +580,7 @@ it.effect("does not classify same-repository gates as fork workflow approvals", expect(detail.checks).toEqual([]); }).pipe( Effect.provide( - Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({ + Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ revalidateChecks: (_input, read) => read, getPullRequestDetail: () => Effect.succeed({ ...openDetail, isCrossRepository: false }), listWorkflowRunsRequiringApproval: () => @@ -621,13 +619,12 @@ it.effect("keeps an unsafe workflow approval scope visible as unknown", () => ]); }).pipe( Effect.provide( - Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({ + Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ revalidateChecks: (_input, read) => read, getPullRequestDetail: () => Effect.succeed(openDetail), listWorkflowRunsRequiringApproval: () => Effect.fail( - new GitHubPullRequestCli.GitHubWorkflowApprovalRefusedError({ - command: "gh", + new GitHubPullRequestApi.GitHubWorkflowApprovalRefusedError({ cwd: "/w", number: 7, reason: "head-not-unique", @@ -664,7 +661,7 @@ it.effect("propagates workflow discovery rate limits", () => expect(error.reason).toBe("rate-limited"); }).pipe( Effect.provide( - Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({ + Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ revalidateChecks: (_input, read) => read, getPullRequestDetail: () => Effect.succeed(openDetail), listWorkflowRunsRequiringApproval: () => @@ -713,7 +710,7 @@ describe("getViewerPermissions", () => { expect(permissions.actions).not.toContain("update-branch"); }).pipe( Effect.provide( - Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({ + Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ revalidateChecks: (_input, read) => read, getPullRequestDetail: () => Effect.die("Unexpected detail read"), getViewerAccess: () => Effect.sync(() => (accessReads++, access)), @@ -723,13 +720,13 @@ describe("getViewerPermissions", () => { }); const layerWithDetail = ( - detail: Effect.Effect, + detail: Effect.Effect, onDetail: (allowReserve: boolean) => void = () => {}, ) => - Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({ + Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ revalidateChecks: (_input, read) => read, getPullRequestDetail: () => - GitHubCli.AllowGitHubReserve.pipe( + GitHubRepositoryApi.AllowGitHubReserve.pipe( Effect.tap((allowReserve) => Effect.sync(() => onDetail(allowReserve))), Effect.flatMap(() => detail), ), @@ -789,12 +786,11 @@ describe("getViewerPermissions", () => { expect(permissions.actions).not.toContain("update-branch"); }).pipe( Effect.provide( - Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({ + Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ revalidateChecks: (_input, read) => read, getPullRequestDetail: () => Effect.fail( - new GitHubPullRequestCli.GitHubPullRequestReadError({ - command: "gh", + new GitHubPullRequestApi.GitHubPullRequestReadError({ cwd: "/w", operation: "getPullRequestDetail", cause: new Error("head changed"), @@ -869,7 +865,7 @@ describe("getChangeRequest commits", () => { }; const layerWith = (commits: GitHubReviewThreadComments["commits"]) => - Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({ + Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ revalidateChecks: (_input, read) => read, getPullRequestActivity: () => Effect.succeed({ @@ -956,7 +952,7 @@ describe("getChangeRequestActivity dismissed reviews", () => { viewer: { canUpdate: true, didAuthor: false }, }; const layerFor = (body: string) => - Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({ + Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ revalidateChecks: (_input, read) => read, getPullRequestActivity: () => Effect.succeed({ author: null, comments: [dismissedReview(body)], commits: [] }), @@ -1040,7 +1036,7 @@ describe("editing", () => { ]); }).pipe( Effect.provide( - Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({ + Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ revalidateChecks: (_input, read) => read, updatePullRequest: (input) => Effect.sync(() => void rewrites.push(input)), updateComment: (input) => Effect.sync(() => void rewrites.push(input)), diff --git a/apps/server/src/pullRequest/GitHubPullRequestProvider.ts b/apps/server/src/pullRequest/GitHubPullRequestProvider.ts index 74c5852a48a4..5e6f24207299 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestProvider.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestProvider.ts @@ -7,8 +7,8 @@ import type { PullRequestViewerPermissions, } from "@t3tools/contracts"; -import * as GitHubCli from "../sourceControl/GitHubCli.ts"; -import * as GitHubPullRequestCli from "./GitHubPullRequestCli.ts"; +import * as GitHubRepositoryApi from "../sourceControl/GitHubRepositoryApi.ts"; +import * as GitHubPullRequestApi from "./GitHubPullRequestApi.ts"; import { PullRequestProviderError, type PullRequestProviderFailure, @@ -104,7 +104,7 @@ export function gitHubViewerPermissions(access: GitHubViewerAccess): PullRequest /** The tags that mean GitHub is out of reach for this account, rather than one request failing. */ export function gitHubProviderFailure( - error: GitHubPullRequestCli.GitHubPullRequestCliError, + error: GitHubPullRequestApi.GitHubPullRequestApiError, ): PullRequestProviderFailure { switch (error._tag) { case "GitHubCliMissingError": @@ -197,9 +197,9 @@ const rendersEmpty = (body: string): boolean => body.replace(//g, "").trim().length === 0; export const make = Effect.gen(function* () { - const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; - const fail = (operation: string) => (error: GitHubPullRequestCli.GitHubPullRequestCliError) => + const fail = (operation: string) => (error: GitHubPullRequestApi.GitHubPullRequestApiError) => new PullRequestProviderError({ provider: "github", operation, @@ -526,7 +526,7 @@ export const make = Effect.gen(function* () { // comparison, so one read usually answers what used to take three. When that heavier read // fails, the light access read still answers, withholding only update-branch. return cli.getPullRequestDetail(input).pipe( - Effect.provideService(GitHubCli.AllowGitHubReserve, true), + Effect.provideService(GitHubRepositoryApi.AllowGitHubReserve, true), Effect.map((pullRequest) => gitHubViewerPermissions({ ...pullRequest.viewerAccess, diff --git a/apps/server/src/pullRequest/PullRequestProviderRegistry.ts b/apps/server/src/pullRequest/PullRequestProviderRegistry.ts index 60de91d0e99a..efbe37f991ee 100644 --- a/apps/server/src/pullRequest/PullRequestProviderRegistry.ts +++ b/apps/server/src/pullRequest/PullRequestProviderRegistry.ts @@ -16,7 +16,7 @@ import * as AzureDevOpsPullRequestCli from "./AzureDevOpsPullRequestCli.ts"; import * as AzureDevOpsPullRequestProvider from "./AzureDevOpsPullRequestProvider.ts"; import * as BitbucketPullRequestApi from "./BitbucketPullRequestApi.ts"; import * as BitbucketPullRequestProvider from "./BitbucketPullRequestProvider.ts"; -import * as GitHubPullRequestCli from "./GitHubPullRequestCli.ts"; +import * as GitHubPullRequestApi from "./GitHubPullRequestApi.ts"; import * as GitHubPullRequestProvider from "./GitHubPullRequestProvider.ts"; import * as GitLabPullRequestCli from "./GitLabPullRequestCli.ts"; import * as GitLabPullRequestProvider from "./GitLabPullRequestProvider.ts"; @@ -61,11 +61,11 @@ export const make = Effect.map( export const layer = Layer.effect(PullRequestProviderRegistry, make).pipe( Layer.provide( - GitHubPullRequestCli.layer.pipe( + GitHubPullRequestApi.layer.pipe( Layer.provide( GitHubApi.layer.pipe( Layer.provide(GitHubCredentials.layer), - // The same layers GitHubCli merges, so both share one budget and one pause per host. + // The same layers GitHubRepositoryApi merges, so both share one budget and one pause per host. Layer.provide(GitHubGraphQlBudget.layer), Layer.provide(SourceControlRateLimit.layer), ), diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts index 3da0b9ba2e03..9e2b81d97d5f 100644 --- a/apps/server/src/pullRequest/PullRequestService.ts +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -77,7 +77,7 @@ import { import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; import { detectSourceControlProviderFromRemoteUrl } from "@t3tools/shared/sourceControl"; -import { AllowGitHubReserve } from "../sourceControl/GitHubCli.ts"; +import { AllowGitHubReserve } from "../sourceControl/GitHubRepositoryApi.ts"; import * as ProjectService from "../project/ProjectService.ts"; import * as ServerSettings from "../serverSettings.ts"; import * as PullRequestFilesViewed from "../persistence/PullRequestFilesViewed.ts"; diff --git a/apps/server/src/pullRequest/gitHubConditionalChecks.ts b/apps/server/src/pullRequest/gitHubConditionalChecks.ts index 8172bf544074..6975da251936 100644 --- a/apps/server/src/pullRequest/gitHubConditionalChecks.ts +++ b/apps/server/src/pullRequest/gitHubConditionalChecks.ts @@ -9,7 +9,7 @@ import { PositiveInt, type PullRequestChecks } from "@t3tools/contracts"; import * as GitHubApi from "../sourceControl/GitHubApi.ts"; import type { GitHubPullRequestDetail } from "./gitHubPullRequestJson.ts"; -import type { GitHubPullRequestCliError } from "./GitHubPullRequestCli.ts"; +import type { GitHubPullRequestApiError } from "./GitHubPullRequestApi.ts"; import type { ProviderRepositoryRef } from "./PullRequestProvider.ts"; const HeadSchema = Schema.Struct({ @@ -70,7 +70,7 @@ export const makeChecksRevalidator = Effect.gen(function* () { Pick & { workflowApprovalsRequired?: number; }, - GitHubPullRequestCliError + GitHubPullRequestApiError >, ) => Effect.gen(function* () { diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index ebbc73758a43..fdd7d6c7165f 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -50,7 +50,7 @@ import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts"; import * as CheckpointStore from "./checkpointing/CheckpointStore.ts"; import * as AzureDevOpsCli from "./sourceControl/AzureDevOpsCli.ts"; import * as BitbucketApi from "./sourceControl/BitbucketApi.ts"; -import * as GitHubCli from "./sourceControl/GitHubCli.ts"; +import * as GitHubRepositoryApi from "./sourceControl/GitHubRepositoryApi.ts"; import * as GitLabCli from "./sourceControl/GitLabCli.ts"; import * as ForgejoCli from "./sourceControl/ForgejoCli.ts"; import * as TextGeneration from "./textGeneration/TextGeneration.ts"; @@ -270,7 +270,7 @@ const layerSourceControlProviderRegistry = SourceControlProviderRegistry.layer.p Layer.mergeAll( AzureDevOpsCli.layer, BitbucketApi.layer, - GitHubCli.layer, + GitHubRepositoryApi.layer, GitLabCli.layer, ForgejoCli.layer, ), @@ -565,7 +565,7 @@ const layerRuntimeCoreDependenciesBase = Layer.mergeAll( Layer.provideMerge(layerServerSettings), // The asset route uses the registry's GitHub credential for private PR media. Layer.provideMerge(layerSourceControlProviderRegistry), - Layer.provideMerge(GitHubCli.layer), + Layer.provideMerge(GitHubRepositoryApi.layer), Layer.provideMerge(layerGit), Layer.provideMerge(layerVcs), Layer.provideMerge(Layer.mergeAll(layerTerminal, layerPreview, layerDevice)), diff --git a/apps/server/src/sourceControl/GitHubCli.test.ts b/apps/server/src/sourceControl/GitHubRepositoryApi.test.ts similarity index 91% rename from apps/server/src/sourceControl/GitHubCli.test.ts rename to apps/server/src/sourceControl/GitHubRepositoryApi.test.ts index 3954617008a5..688216211f1f 100644 --- a/apps/server/src/sourceControl/GitHubCli.test.ts +++ b/apps/server/src/sourceControl/GitHubRepositoryApi.test.ts @@ -11,7 +11,7 @@ import { ChildProcessSpawner } from "effect/process"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as GitHubApi from "./GitHubApi.ts"; -import * as GitHubCli from "./GitHubCli.ts"; +import * as GitHubRepositoryApi from "./GitHubRepositoryApi.ts"; import * as GitHubCredentials from "./GitHubCredentials.ts"; const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); @@ -51,7 +51,7 @@ const node = (number: number, headRefName: string, owner = "acme") => ({ }); /** - * A GitHubCli over a mocked GitHubApi, git driver and process. `remotes` is what + * A GitHubRepositoryApi over a mocked GitHubApi, git driver and process. `remotes` is what * `git remote -v` prints; `git` records every driver call. */ function harness(input: { @@ -92,7 +92,10 @@ function harness(input: { args.args[0] === "remote" ? processOutput(input.remotes) : processOutput("", 1), ), }); - const layer = Layer.effect(GitHubCli.GitHubCli, GitHubCli.make).pipe( + const layer = Layer.effect( + GitHubRepositoryApi.GitHubRepositoryApi, + GitHubRepositoryApi.make, + ).pipe( Layer.provide( Layer.mergeAll( driver, @@ -111,7 +114,7 @@ describe("selectGitHubBaseRepository", () => { .flatMap(([name, url]) => [`${name}\t${url} (fetch)`, `${name}\t${url} (push)`]) .join("\n"); const select = (input: { remotes: string; resolved?: string }) => - GitHubCli.selectGitHubBaseRepository({ resolved: "", host: "github.com", ...input }); + GitHubRepositoryApi.selectGitHubBaseRepository({ resolved: "", host: "github.com", ...input }); it("picks the repository gh reads without a prompt", () => { assert.deepStrictEqual(select({ remotes: remotes(["fork", "git@github.com:me/web.git"]) }), { @@ -175,7 +178,7 @@ describe("selectGitHubBaseRepository", () => { }); }); -describe("GitHubCli repository resolution", () => { +describe("GitHubRepositoryApi repository resolution", () => { it.effect("reads the repository gh would pick from the remotes", () => { const paths: string[] = []; const { layer } = harness({ @@ -197,7 +200,7 @@ describe("GitHubCli repository resolution", () => { }, }); return Effect.gen(function* () { - const gh = yield* GitHubCli.GitHubCli; + const gh = yield* GitHubRepositoryApi.GitHubRepositoryApi; assert.strictEqual(yield* gh.getDefaultBranch({ cwd: "/repo" }), "trunk"); assert.deepStrictEqual(paths, ["github.com repos/acme/web"]); }).pipe(Effect.provide(layer)); @@ -221,16 +224,19 @@ describe("GitHubCli repository resolution", () => { }, }); return Effect.gen(function* () { - const gh = yield* GitHubCli.GitHubCli; + const gh = yield* GitHubRepositoryApi.GitHubRepositoryApi; yield* gh.getDefaultBranch({ cwd: "/repo" }); // A provider's host hint for the same alias (`github` here) resolves the same way. yield* gh.getDefaultBranch({ cwd: "/repo", rateLimitHost: "github" }); assert.deepStrictEqual(hosts, ["github.com repos/acme/web", "github.com repos/acme/web"]); assert.strictEqual( - GitHubCli.gitHubApiHostForRemote("git@github.example.com:a/b.git"), + GitHubRepositoryApi.gitHubApiHostForRemote("git@github.example.com:a/b.git"), "github.example.com", ); - assert.strictEqual(GitHubCli.gitHubApiHostForRemote("git@gitlab.com:a/b.git"), null); + assert.strictEqual( + GitHubRepositoryApi.gitHubApiHostForRemote("git@gitlab.com:a/b.git"), + null, + ); }).pipe(Effect.provide(layer)); }); @@ -240,15 +246,15 @@ describe("GitHubCli repository resolution", () => { api: {}, }); return Effect.gen(function* () { - const gh = yield* GitHubCli.GitHubCli; + const gh = yield* GitHubRepositoryApi.GitHubRepositoryApi; const error = yield* gh.getDefaultBranch({ cwd: "/repo" }).pipe(Effect.flip); - assert.strictEqual(error._tag, "GitHubCliCommandError"); + assert.strictEqual(error._tag, "GitHubRepositoryCommandError"); assert.include(String((error.cause as Error).message), "No GitHub repository"); }).pipe(Effect.provide(layer)); }); }); -describe("GitHubCli.listPullRequestsByHead", () => { +describe("GitHubRepositoryApi.listPullRequestsByHead", () => { const remotes = remotesOutput(["origin", "git@github.com:acme/web.git"]); it.effect("reads a background sweep's staggered heads in one GraphQL document", () => { @@ -271,7 +277,7 @@ describe("GitHubCli.listPullRequestsByHead", () => { }, }); return Effect.gen(function* () { - const gh = yield* GitHubCli.GitHubCli; + const gh = yield* GitHubRepositoryApi.GitHubRepositoryApi; const lookup = (headSelector: string) => gh .listPullRequestsByHead({ @@ -324,7 +330,7 @@ describe("GitHubCli.listPullRequestsByHead", () => { }, }); return Effect.gen(function* () { - const gh = yield* GitHubCli.GitHubCli; + const gh = yield* GitHubRepositoryApi.GitHubRepositoryApi; const lookups = yield* Effect.all( Array.from({ length: 26 }, (_, index) => gh.listPullRequestsByHead({ @@ -368,7 +374,7 @@ describe("GitHubCli.listPullRequestsByHead", () => { }, }); return Effect.gen(function* () { - const gh = yield* GitHubCli.GitHubCli; + const gh = yield* GitHubRepositoryApi.GitHubRepositoryApi; const open = yield* gh .listOpenPullRequests({ cwd: "/repo", headSelector: "me:main", limit: 1 }) .pipe(Effect.forkChild); @@ -397,24 +403,24 @@ describe("GitHubCli.listPullRequestsByHead", () => { }, }); return Effect.gen(function* () { - const gh = yield* GitHubCli.GitHubCli; + const gh = yield* GitHubRepositoryApi.GitHubRepositoryApi; const read = (headSelector: string) => gh .listPullRequestsByHead({ cwd: "/repo", headSelector, state: "open", limit: 1 }) .pipe(Effect.flip, Effect.forkChild); const missing = yield* read("missing"); yield* TestClock.adjust("500 millis"); - assert.strictEqual((yield* Fiber.join(missing))._tag, "GitHubCliUnavailableError"); + assert.strictEqual((yield* Fiber.join(missing))._tag, "GitHubRepositoryUnavailableError"); const limited = yield* read("limited"); yield* TestClock.adjust("500 millis"); const error = yield* Fiber.join(limited); - assert.strictEqual(error._tag, "GitHubCliRateLimitError"); + assert.strictEqual(error._tag, "GitHubRepositoryRateLimitError"); assert.propertyVal(error, "retryAt", 123); }).pipe(Effect.provide(layer)); }); }); -describe("GitHubCli.getPullRequest", () => { +describe("GitHubRepositoryApi.getPullRequest", () => { it.effect("reads a pull request by number, and by URL on its own repository", () => { const variables: Array = []; const { layer } = harness({ @@ -428,7 +434,7 @@ describe("GitHubCli.getPullRequest", () => { }, }); return Effect.gen(function* () { - const gh = yield* GitHubCli.GitHubCli; + const gh = yield* GitHubRepositoryApi.GitHubRepositoryApi; assert.strictEqual((yield* gh.getPullRequest({ cwd: "/repo", reference: "#42" })).number, 42); yield* gh.getPullRequest({ cwd: "/repo", @@ -449,14 +455,14 @@ describe("GitHubCli.getPullRequest", () => { }, }); return Effect.gen(function* () { - const gh = yield* GitHubCli.GitHubCli; + const gh = yield* GitHubRepositoryApi.GitHubRepositoryApi; const error = yield* gh.getPullRequest({ cwd: "/repo", reference: "7" }).pipe(Effect.flip); assert.strictEqual(error._tag, "GitHubPullRequestNotFoundError"); }).pipe(Effect.provide(layer)); }); }); -describe("GitHubCli writes", () => { +describe("GitHubRepositoryApi writes", () => { it.effect("creates a cross-repository pull request with an owner:branch head", () => { const requests: Array = []; const { layer } = harness({ @@ -476,7 +482,7 @@ describe("GitHubCli writes", () => { const fs = yield* FileSystem.FileSystem; const bodyFile = yield* fs.makeTempFileScoped({ suffix: ".md" }); yield* fs.writeFileString(bodyFile, "Body"); - const gh = yield* GitHubCli.GitHubCli; + const gh = yield* GitHubRepositoryApi.GitHubRepositoryApi; yield* gh.createPullRequest({ cwd: "/repo", baseBranch: "main", @@ -515,7 +521,7 @@ describe("GitHubCli writes", () => { }, }); return Effect.gen(function* () { - const gh = yield* GitHubCli.GitHubCli; + const gh = yield* GitHubRepositoryApi.GitHubRepositoryApi; const urls = yield* gh.createRepository({ cwd: "/repo", repository: "acme/new", @@ -531,7 +537,7 @@ describe("GitHubCli writes", () => { }); }); -describe("GitHubCli.checkoutPullRequest", () => { +describe("GitHubRepositoryApi.checkoutPullRequest", () => { const repository = (fullName: string, defaultBranch = "main") => restResponse({ full_name: fullName, @@ -551,7 +557,7 @@ describe("GitHubCli.checkoutPullRequest", () => { }, }); return Effect.gen(function* () { - const gh = yield* GitHubCli.GitHubCli; + const gh = yield* GitHubRepositoryApi.GitHubRepositoryApi; yield* gh.checkoutPullRequest({ cwd: "/repo", reference: "5" }); assert.deepStrictEqual(git, [ [ @@ -588,7 +594,7 @@ describe("GitHubCli.checkoutPullRequest", () => { }, }); return Effect.gen(function* () { - const gh = yield* GitHubCli.GitHubCli; + const gh = yield* GitHubRepositoryApi.GitHubRepositoryApi; yield* Effect.flip(gh.checkoutPullRequest({ cwd: "/repo", reference: "6", force: true })); // Nothing touched the local branches: `main` must not be reset to the fork's commit. assert.deepStrictEqual(git, []); @@ -609,7 +615,7 @@ describe("GitHubCli.checkoutPullRequest", () => { }, }); return Effect.gen(function* () { - const gh = yield* GitHubCli.GitHubCli; + const gh = yield* GitHubRepositoryApi.GitHubRepositoryApi; yield* gh.checkoutPullRequest({ cwd: "/repo", reference: "6", force: true }); assert.deepStrictEqual(git, [ [ @@ -632,7 +638,7 @@ describe("GitHubCli.checkoutPullRequest", () => { it("names the local branch the way gh pr checkout does", () => { const name = (headRefName: string, isCrossRepository: boolean) => - GitHubCli.pullRequestCheckoutBranchName({ + GitHubRepositoryApi.pullRequestCheckoutBranchName({ headRefName, headOwner: "someone", isCrossRepository, diff --git a/apps/server/src/sourceControl/GitHubCli.ts b/apps/server/src/sourceControl/GitHubRepositoryApi.ts similarity index 90% rename from apps/server/src/sourceControl/GitHubCli.ts rename to apps/server/src/sourceControl/GitHubRepositoryApi.ts index d12e5f334feb..595d869d7227 100644 --- a/apps/server/src/sourceControl/GitHubCli.ts +++ b/apps/server/src/sourceControl/GitHubRepositoryApi.ts @@ -33,24 +33,23 @@ import { export const AllowGitHubReserve = GitHubApi.AllowGitHubReserve; -const gitHubCliFailureFields = { - command: Schema.Literal("gh"), +const gitHubFailureFields = { cwd: Schema.String, cause: Schema.Defect(), } as const; -export class GitHubCliUnavailableError extends Schema.TaggedError()( - "GitHubCliUnavailableError", - gitHubCliFailureFields, +export class GitHubRepositoryUnavailableError extends Schema.TaggedError()( + "GitHubRepositoryUnavailableError", + gitHubFailureFields, ) { override get message(): string { return "No GitHub credential on the server. Set GH_TOKEN, or install the GitHub CLI and run `gh auth login`."; } } -export class GitHubCliAuthenticationError extends Schema.TaggedError()( - "GitHubCliAuthenticationError", - gitHubCliFailureFields, +export class GitHubRepositoryAuthenticationError extends Schema.TaggedError()( + "GitHubRepositoryAuthenticationError", + gitHubFailureFields, ) { override get message(): string { // A missing or turned-off credential already says what to do about it. @@ -60,9 +59,9 @@ export class GitHubCliAuthenticationError extends Schema.TaggedError()( - "GitHubCliRateLimitError", - { ...gitHubCliFailureFields, retryAt: Schema.optionalKey(Schema.Finite) }, +export class GitHubRepositoryRateLimitError extends Schema.TaggedError()( + "GitHubRepositoryRateLimitError", + { ...gitHubFailureFields, retryAt: Schema.optionalKey(Schema.Finite) }, ) { override get message(): string { return "GitHub API rate limit exceeded. Requests resume when the limit resets."; @@ -71,16 +70,16 @@ export class GitHubCliRateLimitError extends Schema.TaggedError()( "GitHubPullRequestNotFoundError", - gitHubCliFailureFields, + gitHubFailureFields, ) { override get message(): string { return "Pull request not found. Check the PR number or URL and try again."; } } -export class GitHubCliCommandError extends Schema.TaggedError()( - "GitHubCliCommandError", - { ...gitHubCliFailureFields, httpStatus: Schema.optional(Schema.Int) }, +export class GitHubRepositoryCommandError extends Schema.TaggedError()( + "GitHubRepositoryCommandError", + { ...gitHubFailureFields, httpStatus: Schema.optional(Schema.Int) }, ) { override get message(): string { // GitHub's own reason ("A pull request already exists…") or the failed step's, when known. @@ -92,15 +91,14 @@ export class GitHubCliCommandError extends Schema.TaggedError()( "GitHubPullRequestListDecodeError", - gitHubCliDecodeFields, + gitHubDecodeFields, ) { override get message(): string { return "GitHub returned an invalid pull request list."; @@ -109,7 +107,7 @@ export class GitHubPullRequestListDecodeError extends Schema.TaggedError()( "GitHubChangeRequestListDecodeError", - gitHubCliDecodeFields, + gitHubDecodeFields, ) { override get message(): string { return "GitHub returned an invalid change request list."; @@ -118,7 +116,7 @@ export class GitHubChangeRequestListDecodeError extends Schema.TaggedError()( "GitHubPullRequestDecodeError", - gitHubCliDecodeFields, + gitHubDecodeFields, ) { override get message(): string { return "GitHub returned an invalid pull request."; @@ -127,53 +125,56 @@ export class GitHubPullRequestDecodeError extends Schema.TaggedError()( "GitHubRepositoryDecodeError", - gitHubCliDecodeFields, + gitHubDecodeFields, ) { override get message(): string { return "GitHub returned an invalid repository."; } } -export const GitHubCliError = Schema.Union([ - GitHubCliUnavailableError, - GitHubCliAuthenticationError, - GitHubCliRateLimitError, +export const GitHubRepositoryApiError = Schema.Union([ + GitHubRepositoryUnavailableError, + GitHubRepositoryAuthenticationError, + GitHubRepositoryRateLimitError, GitHubPullRequestNotFoundError, - GitHubCliCommandError, + GitHubRepositoryCommandError, GitHubPullRequestListDecodeError, GitHubChangeRequestListDecodeError, GitHubPullRequestDecodeError, GitHubRepositoryDecodeError, ]); -export type GitHubCliError = typeof GitHubCliError.Type; +export type GitHubRepositoryApiError = typeof GitHubRepositoryApiError.Type; -export const isGitHubCliError = Schema.is(GitHubCliError); +export const isGitHubRepositoryApiError = Schema.is(GitHubRepositoryApiError); /** Maps a GitHub API failure onto the errors callers of this service already handle. */ -function fromGitHubApiError(cwd: string, error: GitHubApi.GitHubApiError): GitHubCliError { - const context = { command: "gh" as const, cwd, cause: error }; +function fromGitHubApiError( + cwd: string, + error: GitHubApi.GitHubApiError, +): GitHubRepositoryApiError { + const context = { cwd, cause: error }; switch (error._tag) { case "GitHubCliMissingError": - return new GitHubCliUnavailableError(context); + return new GitHubRepositoryUnavailableError(context); case "GitHubNotSignedInError": case "GitHubHostDisabledError": case "GitHubApiAuthenticationError": - return new GitHubCliAuthenticationError(context); + return new GitHubRepositoryAuthenticationError(context); case "GitHubCliFailedError": - return new GitHubCliCommandError(context); + return new GitHubRepositoryCommandError(context); case "GitHubApiRateLimitError": - return new GitHubCliRateLimitError({ + return new GitHubRepositoryRateLimitError({ ...context, ...(error.retryAt === undefined ? {} : { retryAt: error.retryAt }), }); case "SourceControlRateLimitPausedError": - return new GitHubCliRateLimitError({ ...context, retryAt: error.retryAt }); + return new GitHubRepositoryRateLimitError({ ...context, retryAt: error.retryAt }); case "GitHubApiNotFoundError": return new GitHubPullRequestNotFoundError(context); case "GitHubApiResponseError": - return new GitHubCliCommandError({ ...context, httpStatus: error.status }); + return new GitHubRepositoryCommandError({ ...context, httpStatus: error.status }); case "GitHubApiRequestError": - return new GitHubCliCommandError(context); + return new GitHubRepositoryCommandError(context); } } @@ -208,15 +209,15 @@ export interface GitHubRepositoryCloneUrls { readonly sshUrl: string; } -export class GitHubCli extends Context.Service< - GitHubCli, +export class GitHubRepositoryApi extends Context.Service< + GitHubRepositoryApi, { readonly listOpenPullRequests: (input: { readonly cwd: string; readonly headSelector: string; readonly limit?: number; readonly rateLimitHost?: string; - }) => Effect.Effect, GitHubCliError>; + }) => Effect.Effect, GitHubRepositoryApiError>; /** * Pull requests whose head is `headSelector` (a branch, or `owner:branch` for a fork), in the @@ -230,24 +231,24 @@ export class GitHubCli extends Context.Service< readonly limit: number; /** The checkout's GitHub API host. Without it, the host comes from the git remotes. */ readonly rateLimitHost?: string; - }) => Effect.Effect, GitHubCliError>; + }) => Effect.Effect, GitHubRepositoryApiError>; readonly getPullRequest: (input: { readonly cwd: string; readonly reference: string; readonly rateLimitHost?: string; - }) => Effect.Effect; + }) => Effect.Effect; readonly getRepositoryCloneUrls: (input: { readonly cwd: string; readonly repository: string; - }) => Effect.Effect; + }) => Effect.Effect; readonly createRepository: (input: { readonly cwd: string; readonly repository: string; readonly visibility: SourceControlRepositoryVisibility; - }) => Effect.Effect; + }) => Effect.Effect; readonly createPullRequest: (input: { readonly cwd: string; @@ -255,20 +256,20 @@ export class GitHubCli extends Context.Service< readonly headSelector: string; readonly title: string; readonly bodyFile: string; - }) => Effect.Effect; + }) => Effect.Effect; readonly getDefaultBranch: (input: { readonly cwd: string; readonly rateLimitHost?: string; - }) => Effect.Effect; + }) => Effect.Effect; readonly checkoutPullRequest: (input: { readonly cwd: string; readonly reference: string; readonly force?: boolean; - }) => Effect.Effect; + }) => Effect.Effect; } ->()("t3/sourceControl/GitHubCli") {} +>()("t3/sourceControl/GitHubRepositoryApi") {} /** * The repository `gh pr list` reads in a checkout, picked the way gh picks one without a @@ -385,7 +386,7 @@ class PullRequestsByHeadRead extends Request.Class< readonly allowReserve: boolean; }, ReadonlyArray, - GitHubCliError + GitHubRepositoryApiError > {} /** One aliased `pullRequests` connection per lookup, each head and state passed as a variable. */ @@ -549,7 +550,7 @@ export const make = Effect.gen(function* () { const gitRead = (cwd: string, args: ReadonlyArray) => process.run({ - operation: "GitHubCli.resolveRepository", + operation: "GitHubRepositoryApi.resolveRepository", command: "git", args, cwd, @@ -558,13 +559,13 @@ export const make = Effect.gen(function* () { }); const commandFailure = (cwd: string, detail: string) => - new GitHubCliCommandError({ command: "gh", cwd, cause: new Error(detail) }); + new GitHubRepositoryCommandError({ cwd, cause: new Error(detail) }); /** * The repository `gh` would act on in `cwd`: GH_REPO, else the remote `gh` would pick, else * the remote the caller resolved the provider from, else the best-ranked GitHub remote. */ - const resolveRepository = Effect.fn("GitHubCli.resolveRepository")(function* (input: { + const resolveRepository = Effect.fn("GitHubRepositoryApi.resolveRepository")(function* (input: { readonly cwd: string; readonly host?: string | undefined; }) { @@ -623,7 +624,7 @@ export const make = Effect.gen(function* () { cwd: string, input: GitHubApi.GitHubGraphQlInput, decode: (raw: string) => Result.Result, - onDecodeFailure: (cause: unknown) => GitHubCliError, + onDecodeFailure: (cause: unknown) => GitHubRepositoryApiError, ) => api.graphql(input).pipe( Effect.mapError((error) => fromGitHubApiError(cwd, error)), @@ -646,7 +647,7 @@ export const make = Effect.gen(function* () { Pick >; readonly allowReserve: boolean; - readonly onDecodeFailure: (cause: unknown) => GitHubCliError; + readonly onDecodeFailure: (cause: unknown) => GitHubRepositoryApiError; }) => { const query = buildPullRequestsByHeadQuery(input.lookups); return graphqlJson( @@ -668,7 +669,6 @@ export const make = Effect.gen(function* () { decoded.data.repository === null ? Effect.fail( new GitHubPullRequestNotFoundError({ - command: "gh", cwd: input.cwd, cause: new Error("The repository could not be read."), }), @@ -697,8 +697,7 @@ export const make = Effect.gen(function* () { locator: { host, owner, name }, lookups: entries.map((entry) => entry.request), allowReserve, - onDecodeFailure: (cause) => - new GitHubChangeRequestListDecodeError({ command: "gh", cwd, cause }), + onDecodeFailure: (cause) => new GitHubChangeRequestListDecodeError({ cwd, cause }), }).pipe( Effect.map((aliases) => { for (const [index, entry] of entries.entries()) { @@ -724,7 +723,7 @@ export const make = Effect.gen(function* () { RequestResolver.setDelay(BACKGROUND_HEAD_LOOKUP_BATCH_WINDOW), ); - const listByHead = Effect.fn("GitHubCli.listByHead")(function* (input: { + const listByHead = Effect.fn("GitHubRepositoryApi.listByHead")(function* (input: { readonly cwd: string; readonly headSelector: string; readonly state: PullRequestListState; @@ -759,7 +758,7 @@ export const make = Effect.gen(function* () { const toSummaries = (rows: ReadonlyArray) => rows.map(pullRequestSummary); - const readPullRequest = Effect.fn("GitHubCli.readPullRequest")(function* (input: { + const readPullRequest = Effect.fn("GitHubRepositoryApi.readPullRequest")(function* (input: { readonly cwd: string; readonly reference: string; readonly rateLimitHost?: string | undefined; @@ -787,7 +786,6 @@ export const make = Effect.gen(function* () { }))[0]; if (found === undefined) { return yield* new GitHubPullRequestNotFoundError({ - command: "gh", cwd: input.cwd, cause: new Error("No pull request has this head branch."), }); @@ -799,7 +797,7 @@ export const make = Effect.gen(function* () { ? parsed.locator : yield* resolveRepository({ cwd: input.cwd, host: input.rateLimitHost }); const decodeFailure = (cause: unknown) => - new GitHubPullRequestDecodeError({ command: "gh", cwd: input.cwd, cause }); + new GitHubPullRequestDecodeError({ cwd: input.cwd, cause }); const decoded = yield* graphqlJson( input.cwd, { @@ -815,7 +813,6 @@ export const make = Effect.gen(function* () { const node = decoded.data.repository?.pullRequest; if (node == null) { return yield* new GitHubPullRequestNotFoundError({ - command: "gh", cwd: input.cwd, cause: new Error("The pull request does not exist."), }); @@ -825,7 +822,7 @@ export const make = Effect.gen(function* () { return record; }); - const readRepository = Effect.fn("GitHubCli.readRepository")(function* ( + const readRepository = Effect.fn("GitHubRepositoryApi.readRepository")(function* ( cwd: string, locator: GitHubRepositoryLocator, ) { @@ -838,7 +835,6 @@ export const make = Effect.gen(function* () { const decoded = decodeRawRepository(response.body); if (Result.isFailure(decoded)) { return yield* new GitHubRepositoryDecodeError({ - command: "gh", cwd, cause: decoded.failure, }); @@ -846,23 +842,23 @@ export const make = Effect.gen(function* () { return decoded.success; }); - const readViewerLogin = Effect.fn("GitHubCli.readViewerLogin")(function* ( + const readViewerLogin = Effect.fn("GitHubRepositoryApi.readViewerLogin")(function* ( cwd: string, host: string, ) { const response = yield* rest(cwd, { host, operation: "getViewer", path: "user" }); const decoded = decodeViewerLogin(response.body); if (Result.isFailure(decoded)) { - return yield* new GitHubCliCommandError({ command: "gh", cwd, cause: decoded.failure }); + return yield* new GitHubRepositoryCommandError({ cwd, cause: decoded.failure }); } return decoded.success.login; }); const gitFailure = (cwd: string) => (cause: unknown) => - new GitHubCliCommandError({ command: "gh", cwd, cause }); + new GitHubRepositoryCommandError({ cwd, cause }); const runGit = (cwd: string, operation: string, args: ReadonlyArray) => - git.execute({ operation: `GitHubCli.checkoutPullRequest.${operation}`, cwd, args }); + git.execute({ operation: `GitHubRepositoryApi.checkoutPullRequest.${operation}`, cwd, args }); /** * `gh pr checkout` in plain git: the head branch is fetched from the remote that holds it (a @@ -870,8 +866,8 @@ export const make = Effect.gen(function* () { * track the head. A head branch that is gone is read from the base's `refs/pull//head`. * An existing branch fast-forwards, or with `force` is reset to the pull request. */ - const checkoutPullRequest: GitHubCli["Service"]["checkoutPullRequest"] = Effect.fn( - "GitHubCli.checkoutPullRequest", + const checkoutPullRequest: GitHubRepositoryApi["Service"]["checkoutPullRequest"] = Effect.fn( + "GitHubRepositoryApi.checkoutPullRequest", )(function* (input) { const reference = parsePullRequestReference(input.reference); const pullRequest = yield* readPullRequest({ cwd: input.cwd, reference: input.reference }); @@ -994,7 +990,7 @@ export const make = Effect.gen(function* () { }).pipe(Effect.mapError(gitFailure(input.cwd))); }); - return GitHubCli.of({ + return GitHubRepositoryApi.of({ listPullRequestsByHead: (input) => AllowGitHubReserve.pipe( Effect.flatMap((allowReserve) => listByHead({ ...input, allowReserve })), @@ -1043,7 +1039,6 @@ export const make = Effect.gen(function* () { const decoded = decodeRawRepository(response.body); if (Result.isFailure(decoded)) { return yield* new GitHubRepositoryDecodeError({ - command: "gh", cwd: input.cwd, cause: decoded.failure, }); @@ -1083,7 +1078,7 @@ export const make = Effect.gen(function* () { }); }); -export const layer = Layer.effect(GitHubCli, make).pipe( +export const layer = Layer.effect(GitHubRepositoryApi, make).pipe( Layer.provideMerge(GitHubApi.layer), Layer.provideMerge(GitHubCredentials.layer), Layer.provideMerge(GitHubGraphQlBudget.layer), diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts index f3f7f31ed4e3..89398b634786 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts @@ -8,7 +8,7 @@ import { ChildProcessSpawner } from "effect/process"; import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as GitHubApi from "./GitHubApi.ts"; -import * as GitHubCli from "./GitHubCli.ts"; +import * as GitHubRepositoryApi from "./GitHubRepositoryApi.ts"; import { parseGitHubAuthStatus } from "./gitHubAuthStatus.ts"; import * as GitHubSourceControlProvider from "./GitHubSourceControlProvider.ts"; @@ -27,12 +27,15 @@ const processResult = ( }); function makeProvider( - github: Partial, + github: Partial, api: Partial = {}, ) { return GitHubSourceControlProvider.make.pipe( Effect.provide( - Layer.merge(Layer.mock(GitHubCli.GitHubCli)(github), Layer.mock(GitHubApi.GitHubApi)(api)), + Layer.merge( + Layer.mock(GitHubRepositoryApi.GitHubRepositoryApi)(github), + Layer.mock(GitHubApi.GitHubApi)(api), + ), ), ); } @@ -87,10 +90,9 @@ it.effect("maps GitHub PR summaries into provider-neutral change requests", () = }), ); -it.effect("adds safe request context while retaining GitHub CLI causes", () => +it.effect("adds safe request context while retaining GitHub API causes", () => Effect.gen(function* () { - const cause = new GitHubCli.GitHubPullRequestNotFoundError({ - command: "gh", + const cause = new GitHubRepositoryApi.GitHubPullRequestNotFoundError({ cwd: "/repo", cause: new Error("raw upstream detail that should remain in the cause"), }); @@ -109,7 +111,6 @@ it.effect("adds safe request context while retaining GitHub CLI causes", () => { provider: error.provider, operation: error.operation, - command: error.command, cwd: error.cwd, reference: error.reference, detail: error.detail, @@ -117,7 +118,6 @@ it.effect("adds safe request context while retaining GitHub CLI causes", () => { provider: "github", operation: "getChangeRequest", - command: "gh", cwd: "/repo", reference: "https://github.com/pingdotgg/t3code/pull/42", detail: "Pull request not found. Check the PR number or URL and try again.", @@ -130,8 +130,9 @@ it.effect("adds safe request context while retaining GitHub CLI causes", () => it.effect("lists change request history through the batched head lookup", () => Effect.gen(function* () { - let lookup: Parameters[0] | null = - null; + let lookup: + | Parameters[0] + | null = null; const provider = yield* makeProvider({ listPullRequestsByHead: (input) => { lookup = input; @@ -183,8 +184,9 @@ it.effect("lists change request history through the batched head lookup", () => it.effect("creates GitHub PRs through provider-neutral input names", () => Effect.gen(function* () { - let createInput: Parameters[0] | null = - null; + let createInput: + | Parameters[0] + | null = null; const provider = yield* makeProvider({ createPullRequest: (input) => { createInput = input; diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts index cf06c6efdfcd..a3b7a22f8d55 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts @@ -15,7 +15,7 @@ import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; import * as ServerSettings from "../serverSettings.ts"; import * as GitHubApi from "./GitHubApi.ts"; -import * as GitHubCli from "./GitHubCli.ts"; +import * as GitHubRepositoryApi from "./GitHubRepositoryApi.ts"; import { effectiveGitHubAccount, findAuthenticatedGitHubAccount, @@ -40,7 +40,7 @@ const decodeLinkSubject = Schema.decodeUnknownEffect( ), ); -function toChangeRequest(summary: GitHubCli.GitHubPullRequestSummary): ChangeRequest { +function toChangeRequest(summary: GitHubRepositoryApi.GitHubPullRequestSummary): ChangeRequest { return { provider: "github", number: summary.number, @@ -264,7 +264,7 @@ export const makeDiscovery = Effect.gen(function* () { }); export const make = Effect.gen(function* () { - const github = yield* GitHubCli.GitHubCli; + const github = yield* GitHubRepositoryApi.GitHubRepositoryApi; const api = yield* GitHubApi.GitHubApi; const listChangeRequests: SourceControlProvider.SourceControlProvider["Service"]["listChangeRequests"] = @@ -286,7 +286,6 @@ export const make = Effect.gen(function* () { new SourceControlProviderError({ provider: "github", operation: "listChangeRequests", - command: error.command, cwd: input.cwd, reference: SourceControlProvider.transportSafeSourceControlErrorValue( input.headSelector, @@ -325,7 +324,6 @@ export const make = Effect.gen(function* () { new SourceControlProviderError({ provider: "github", operation: "listChangeRequests", - command: error.command, cwd: input.cwd, reference: SourceControlProvider.transportSafeSourceControlErrorValue( input.headSelector, @@ -403,7 +401,6 @@ export const make = Effect.gen(function* () { new SourceControlProviderError({ provider: "github", operation: "getChangeRequest", - command: error.command, cwd: input.cwd, reference: SourceControlProvider.transportSafeSourceControlErrorValue( input.reference, @@ -428,7 +425,6 @@ export const make = Effect.gen(function* () { new SourceControlProviderError({ provider: "github", operation: "createChangeRequest", - command: error.command, cwd: input.cwd, reference: SourceControlProvider.transportSafeSourceControlErrorValue( input.headSelector, @@ -445,7 +441,6 @@ export const make = Effect.gen(function* () { new SourceControlProviderError({ provider: "github", operation: "getRepositoryCloneUrls", - command: error.command, cwd: input.cwd, repository: SourceControlProvider.transportSafeSourceControlErrorValue( input.repository, @@ -462,7 +457,6 @@ export const make = Effect.gen(function* () { new SourceControlProviderError({ provider: "github", operation: "createRepository", - command: error.command, cwd: input.cwd, repository: SourceControlProvider.transportSafeSourceControlErrorValue( input.repository, @@ -486,7 +480,6 @@ export const make = Effect.gen(function* () { new SourceControlProviderError({ provider: "github", operation: "getDefaultBranch", - command: error.command, cwd: input.cwd, detail: error.message, cause: error, @@ -500,7 +493,6 @@ export const make = Effect.gen(function* () { new SourceControlProviderError({ provider: "github", operation: "checkoutChangeRequest", - command: error.command, cwd: input.cwd, reference: SourceControlProvider.transportSafeSourceControlErrorValue( input.reference, diff --git a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts index 54de8cc14fde..d9bbb51d7a61 100644 --- a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts +++ b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts @@ -18,7 +18,7 @@ import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as AzureDevOpsCli from "./AzureDevOpsCli.ts"; import * as BitbucketApi from "./BitbucketApi.ts"; import * as GitHubApi from "./GitHubApi.ts"; -import * as GitHubCli from "./GitHubCli.ts"; +import * as GitHubRepositoryApi from "./GitHubRepositoryApi.ts"; import * as GitLabCli from "./GitLabCli.ts"; import * as ForgejoCli from "./ForgejoCli.ts"; import * as ForgejoSourceControlProvider from "./ForgejoSourceControlProvider.ts"; @@ -40,7 +40,7 @@ const layerSourceControlProviderRegistryTest = (input: { Layer.mock(AzureDevOpsCli.AzureDevOpsCli)({}), Layer.mock(BitbucketApi.BitbucketApi)(input.bitbucket), ServerSettings.ServerSettingsService.layerTest(), - Layer.mock(GitHubCli.GitHubCli)({}), + Layer.mock(GitHubRepositoryApi.GitHubRepositoryApi)({}), Layer.mock(GitHubApi.GitHubApi)({}), Layer.mock(GitLabCli.GitLabCli)({}), Layer.mock(ForgejoCli.ForgejoCli)({ listLogins: () => Effect.succeed([]) }), diff --git a/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts index 08751017b351..c9a7164c8b31 100644 --- a/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts +++ b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts @@ -15,7 +15,7 @@ import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as AzureDevOpsCli from "./AzureDevOpsCli.ts"; import * as BitbucketApi from "./BitbucketApi.ts"; import * as GitHubApi from "./GitHubApi.ts"; -import * as GitHubCli from "./GitHubCli.ts"; +import * as GitHubRepositoryApi from "./GitHubRepositoryApi.ts"; import * as GitLabCli from "./GitLabCli.ts"; import * as ForgejoCli from "./ForgejoCli.ts"; import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts"; @@ -42,7 +42,7 @@ function makeRegistry(input: { readonly url: string; }>; readonly process?: Partial; - readonly github?: Partial; + readonly github?: Partial; readonly githubApi?: Partial; readonly gitlab?: Partial; readonly resolve?: VcsDriverRegistry.VcsDriverRegistry["Service"]["resolve"]; @@ -98,7 +98,7 @@ function makeRegistry(input: { Layer.mock(AzureDevOpsCli.AzureDevOpsCli)({}), Layer.mock(BitbucketApi.BitbucketApi)({}), ServerSettings.ServerSettingsService.layerTest(), - Layer.mock(GitHubCli.GitHubCli)(input.github ?? {}), + Layer.mock(GitHubRepositoryApi.GitHubRepositoryApi)(input.github ?? {}), Layer.mock(GitHubApi.GitHubApi)(input.githubApi ?? {}), Layer.mock(GitLabCli.GitLabCli)(input.gitlab ?? {}), Layer.mock(ForgejoCli.ForgejoCli)({ listLogins: () => Effect.succeed([]) }), diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index af3fc819a4c7..5f64ecb12f87 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -229,7 +229,7 @@ import * as SourceControlDiscovery from "./sourceControl/SourceControlDiscovery. import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts"; import * as AzureDevOpsCli from "./sourceControl/AzureDevOpsCli.ts"; import * as BitbucketApi from "./sourceControl/BitbucketApi.ts"; -import * as GitHubCli from "./sourceControl/GitHubCli.ts"; +import * as GitHubRepositoryApi from "./sourceControl/GitHubRepositoryApi.ts"; import * as GitLabCli from "./sourceControl/GitLabCli.ts"; import * as ForgejoCli from "./sourceControl/ForgejoCli.ts"; import * as SourceControlProviderRegistry from "./sourceControl/SourceControlProviderRegistry.ts"; @@ -3190,7 +3190,7 @@ export const layer = Layer.unwrap( Layer.mergeAll( AzureDevOpsCli.layer, BitbucketApi.layer, - GitHubCli.layer, + GitHubRepositoryApi.layer, GitLabCli.layer, ForgejoCli.layer, ), From a21ac5fb153e3bf418ccd2a4abef2787c241813c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 7 Oct 2026 17:26:25 -0700 Subject: [PATCH 23/25] refactor(server): GitHub GraphQL batches use variables and share one pager (#16960) Co-authored-by: Claude Opus 5.5 (1M context) --- .../pullRequest/GitHubPullRequestApi.test.ts | 107 +++++-- .../src/pullRequest/GitHubPullRequestApi.ts | 268 +++++++++--------- .../pullRequest/gitHubPullRequestJson.test.ts | 55 ++-- .../src/pullRequest/gitHubPullRequestJson.ts | 165 ++++++----- .../src/pullRequest/githubStackActions.ts | 20 +- .../src/sourceControl/githubGraphQl.test.ts | 105 +++++++ .../server/src/sourceControl/githubGraphQl.ts | 100 +++++++ 7 files changed, 565 insertions(+), 255 deletions(-) create mode 100644 apps/server/src/sourceControl/githubGraphQl.test.ts create mode 100644 apps/server/src/sourceControl/githubGraphQl.ts diff --git a/apps/server/src/pullRequest/GitHubPullRequestApi.test.ts b/apps/server/src/pullRequest/GitHubPullRequestApi.test.ts index c42bf6d147bf..64259e337e92 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestApi.test.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestApi.test.ts @@ -767,11 +767,15 @@ layer("GitHubPullRequestApi.layer", (it) => { ); assert.strictEqual(eight?.headBranch, "feat/8"); expect(mockedExecute).toHaveBeenCalledOnce(); - const document = queryAt(0); - expect(document).toContain( - 's0: repository(owner: "acme", name: "web") { pullRequest(number: 7)', + expect(queryAt(0)).toContain( + "s0: repository(owner: $s0_owner, name: $s0_name) { pullRequest(number: $s0_number)", ); - expect(document).toContain("pullRequest(number: 8)"); + expect(varsAt(0)).toMatchObject({ + s0_owner: "acme", + s0_name: "web", + s0_number: 7, + s1_number: 8, + }); }), ); @@ -815,10 +819,10 @@ layer("GitHubPullRequestApi.layer", (it) => { // GitHub had no answer for #8, so its watch reads it in full. expect(eight).toBeNull(); expect(mockedExecute).toHaveBeenCalledOnce(); - const call = callAt(0); - expect(call.kind === "graphql" ? call.query : "").toContain( - 'w1: repository(owner: "acme", name: "web") { pullRequest(number: 8)', + expect(queryAt(0)).toContain( + "w1: repository(owner: $w1_owner, name: $w1_name) { pullRequest(number: $w1_number)", ); + expect(varsAt(0)).toMatchObject({ w1_owner: "acme", w1_name: "web", w1_number: 8 }); }), ); @@ -1108,6 +1112,37 @@ layer("GitHubPullRequestApi.layer", (it) => { }), ); + it.effect("reports truncation when GitHub repeats a cursor before the page is full", () => + Effect.gen(function* () { + // Two pages of 100 that hand back the same cursor, for a page of 250. + const repeating = output( + encodeJson({ + data: { + search: { + pageInfo: { hasNextPage: true, endCursor: "same" }, + nodes: rows(100, 1), + }, + }, + }), + ); + mockedExecute.mockReturnValue(Effect.succeed(repeating)); + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + + const batch = yield* cli.listPullRequests({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + state: "open", + involvement: "all", + viewer: "bilal", + limit: 250, + }); + + assert.strictEqual(mockedExecute.mock.calls.length, 2); + assert.isTrue(batch.truncated); + }), + ); + it.effect("excludes merged pull requests from the Closed tab", () => Effect.gen(function* () { mockedExecute.mockImplementation((call) => @@ -1408,11 +1443,12 @@ layer("GitHubPullRequestApi.layer", (it) => { expect(batch.continues).toBe(false); expect(mockedStackMemberships).toHaveBeenCalledTimes(1); const membership = mockedStackMemberships.mock.calls[0]?.[0]; - const query = membership?.kind === "graphql" ? membership.query : ""; - expect(query).toContain("pullRequest(number: 4)"); - expect(query).toContain("pullRequest(number: 5)"); - expect(query).not.toContain("pullRequest(number: 1)"); - expect(query).not.toContain("pullRequest(number: 6)"); + const numbers = Object.entries( + membership?.kind === "graphql" ? (membership.variables ?? {}) : {}, + ) + .filter(([name]) => name.endsWith("_number")) + .map(([, number]) => number); + expect(numbers).toEqual([4, 5]); }), ); @@ -1420,7 +1456,7 @@ layer("GitHubPullRequestApi.layer", (it) => { Effect.gen(function* () { mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequests(27, 1)))); mockedStackMemberships.mockImplementation((input) => - input.kind === "graphql" && input.query.includes("pullRequest(number: 26)") + input.kind === "graphql" && Object.values(input.variables ?? {}).includes(26) ? Effect.fail( new GitHubApi.GitHubApiResponseError({ host: "github.com", @@ -1513,9 +1549,8 @@ layer("GitHubPullRequestApi.layer", (it) => { { repository: "acme/web", number: 1, additions: 4, deletions: 1 }, { repository: "acme/web", number: 26, additions: 4, deletions: 1 }, ]); - const document = queryAt(0); - expect(document).toContain('s0: repository(owner: "acme", name: "web")'); - expect(document).toContain("pullRequest(number: 25)"); + expect(queryAt(0)).toContain("s0: repository(owner: $s0_owner, name: $s0_name)"); + expect(varsAt(0)).toMatchObject({ s0_owner: "acme", s0_name: "web", s24_number: 25 }); }), ); @@ -2614,6 +2649,38 @@ layer("GitHubPullRequestApi.layer", (it) => { }), ); + it.effect("refuses workflow approval when the head list stops before its end", () => + Effect.gen(function* () { + // GitHub hands the same cursor back, so the second page is never read past. #7 looks + // unique on what was read, and an unread page could still hold another head like it. + const repeating = heads([7]); + repeating.data.repository.pullRequests.pageInfo = { + hasNextPage: true, + endCursor: "same", + } as never; + workflowApprovalRoutes(() => crossRepositoryDetail(), repeating, workflowRuns([])); + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + + const error = yield* Effect.flip( + cli.listWorkflowRunsRequiringApproval({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + headSha: "abc123", + headBranch: "feat/page", + headRepositoryOwner: "octocat", + isCrossRepository: true, + }), + ); + + expect(error).toMatchObject({ + _tag: "GitHubWorkflowApprovalRefusedError", + reason: "head-list-truncated", + }); + }), + ); + it.effect("refuses workflow approval when GitHub omits the head repository", () => Effect.gen(function* () { mockedExecute.mockReturnValueOnce( @@ -4386,9 +4453,9 @@ layer("GitHubPullRequestApi.layer", (it) => { number: 7, }); - assert.strictEqual(mockedExecute.mock.calls.length, 5); + // The same cursor twice is a page GitHub already gave, so reading stops there. + assert.strictEqual(mockedExecute.mock.calls.length, 2); assert.isTrue(viewed.truncated); - assert.strictEqual(viewed.files.length, 5); }), ); @@ -4420,8 +4487,8 @@ layer("GitHubPullRequestApi.layer", (it) => { expect(queryAt(1)).toContain("f1: unmarkFileAsViewed"); expect(varsAt(1)).toEqual({ pullRequestId: "PR_1", - path0: "src/a.ts", - path1: "src/b.ts", + f0_path: "src/a.ts", + f1_path: "src/b.ts", }); }), ); diff --git a/apps/server/src/pullRequest/GitHubPullRequestApi.ts b/apps/server/src/pullRequest/GitHubPullRequestApi.ts index 116a8a742d1a..59281964df4a 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestApi.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestApi.ts @@ -44,6 +44,7 @@ import { import { AllowGitHubReserve } from "../sourceControl/GitHubRepositoryApi.ts"; import * as GitHubApi from "../sourceControl/GitHubApi.ts"; +import { readGraphQlPages } from "../sourceControl/githubGraphQl.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as SourceControlRateLimit from "../sourceControl/SourceControlRateLimit.ts"; import { @@ -64,7 +65,6 @@ import { PULL_REQUEST_ACTIVITY_GRAPHQL_QUERY, PULL_REQUEST_HEADS_GRAPHQL_QUERY, decodeWorkflowRunsJson, - type GitHubCheckContext, pullRequestCoreGraphQlQuery, type GitHubPullRequestCore, type GitHubPullRequestSummary, @@ -1481,42 +1481,45 @@ export const make = Effect.gen(function* () { * its first hundred. Each page names the head it read, so a push mid-walk fails rather than * mixing two revisions' checks. */ + type CheckContextsPage = Result.Result.Success< + ReturnType + >; const readAllCheckContexts = ( input: Parameters[0], allowReserve: boolean, ) => Effect.gen(function* () { const { owner, name } = parseRepositorySelector(input.repository); - const contexts: GitHubCheckContext[] = []; - let headSha: string | null = null; - let after: string | null = null; - for (let page = 0; page < CHECK_CONTEXT_PAGES; page++) { - const read: { - readonly headSha: string; - readonly contexts: ReadonlyArray; - readonly nextCursor: string | null; - } = yield* graphqlRead({ - cwd: input.cwd, - host: input.host, - operation: "getPullRequestDetail", - allowReserve, - variables: { owner, name, number: input.number, after }, - query: pullRequestCheckContextsGraphQlQuery(input.host), - decode: decodePullRequestCheckContextsJson, - }); - if (headSha !== null && read.headSha !== headSha) { - return yield* readError( - input.cwd, - "getPullRequestDetail", - new Error("Pull request head changed while reading checks."), - ); - } - headSha = read.headSha; - contexts.push(...read.contexts); - after = read.nextCursor; - if (after === null) break; - } - return { headSha, contexts, truncated: after !== null }; + const { pages, truncated } = yield* readGraphQlPages( + (after, previous: ReadonlyArray) => + graphqlRead({ + cwd: input.cwd, + host: input.host, + operation: "getPullRequestDetail", + allowReserve, + variables: { owner, name, number: input.number, after }, + query: pullRequestCheckContextsGraphQlQuery(input.host), + decode: decodePullRequestCheckContextsJson, + }).pipe( + // Each page names the head it read, so a push mid-walk fails rather than mixing + // two revisions' checks. + Effect.filterOrFail( + (page) => previous.length === 0 || page.headSha === previous[0]!.headSha, + () => + readError( + input.cwd, + "getPullRequestDetail", + new Error("Pull request head changed while reading checks."), + ), + ), + ), + { maxPages: CHECK_CONTEXT_PAGES, nextCursor: (page) => page.nextCursor }, + ); + return { + headSha: pages[0]?.headSha ?? null, + contexts: pages.flatMap((page) => page.contexts), + truncated, + }; }); const getPullRequestDetail: GitHubPullRequestApi["Service"]["getPullRequestDetail"] = (input) => { @@ -1568,7 +1571,10 @@ export const make = Effect.gen(function* () { const workflowApprovalReadError = (cwd: string, cause: unknown) => readError(cwd, "listWorkflowRunsRequiringApproval", cause); - /** Every open pull request whose head branch carries this name, up to one past the limit. */ + /** + * Every open pull request whose head branch carries this name, up to one past the limit. + * `truncated` says some were never read, which is never enough to call a head unique. + */ const listHeadsByBranch = (input: { readonly cwd: string; readonly repository: string; @@ -1577,25 +1583,25 @@ export const make = Effect.gen(function* () { }) => Effect.gen(function* () { const { owner, name } = parseRepositorySelector(input.repository); - const heads: GitHubPullRequestHead[] = []; - let after: string | null = null; - do { - const page: { - readonly heads: ReadonlyArray; - readonly nextCursor: string | null; - } = yield* graphqlRead({ - cwd: input.cwd, - host: input.host, - operation: "listWorkflowRunsRequiringApproval", - allowReserve: true, - variables: { owner, name, head: input.headBranch, after }, - query: PULL_REQUEST_HEADS_GRAPHQL_QUERY, - decode: decodePullRequestHeadsJson, - }); - heads.push(...page.heads); - after = page.nextCursor; - } while (after !== null && heads.length <= workflowApprovalLimit); - return heads; + const countHeads = (pages: ReadonlyArray<{ readonly heads: ReadonlyArray }>) => + pages.reduce((total, page) => total + page.heads.length, 0); + const { pages, truncated } = yield* readGraphQlPages( + (after) => + graphqlRead({ + cwd: input.cwd, + host: input.host, + operation: "listWorkflowRunsRequiringApproval", + allowReserve: true, + variables: { owner, name, head: input.headBranch, after }, + query: PULL_REQUEST_HEADS_GRAPHQL_QUERY, + decode: decodePullRequestHeadsJson, + }), + { + nextCursor: (page) => page.nextCursor, + until: (pages) => countHeads(pages) > workflowApprovalLimit, + }, + ); + return { heads: pages.flatMap((page) => page.heads), truncated }; }); /** @@ -1650,9 +1656,10 @@ export const make = Effect.gen(function* () { [ listHeadsByBranch(input).pipe( Effect.flatMap( - ( + ({ heads, - ): Effect.Effect< + truncated, + }): Effect.Effect< GitHubPullRequestHead, GitHubPullRequestReadError | GitHubWorkflowApprovalRefusedError > => { @@ -1663,7 +1670,7 @@ export const make = Effect.gen(function* () { pullRequest.headRepositoryOwner?.toLowerCase() === input.headRepositoryOwner.toLowerCase(), ); - if (heads.length > workflowApprovalLimit) { + if (truncated || heads.length > workflowApprovalLimit) { return Effect.fail( new GitHubWorkflowApprovalRefusedError({ cwd: input.cwd, @@ -1755,18 +1762,18 @@ export const make = Effect.gen(function* () { ); // Stack membership rides along where GitHub serves stacks, so the background sync can skip // the REST stack read for pull requests that are in none. - const query = buildPullRequestSummariesGraphQlQuery( + const document = buildPullRequestSummariesGraphQlQuery( batchable.map((entry) => entry.request), first.request.host === "github.com", ); const batched = - query === null + document === null ? Effect.succeed(new Map()) : graphqlRead({ cwd: first.request.cwd, host: first.request.host, operation: "getPullRequestSummary", - query, + ...document, decode: decodePullRequestSummariesJson, }); return batched.pipe( @@ -1833,17 +1840,17 @@ export const make = Effect.gen(function* () { const batchable = entries.filter( (entry) => buildPullRequestWatchFingerprintsGraphQlQuery([entry.request]) !== null, ); - const query = buildPullRequestWatchFingerprintsGraphQlQuery( + const document = buildPullRequestWatchFingerprintsGraphQlQuery( batchable.map((entry) => entry.request), ); const read = - query === null + document === null ? Effect.succeed(new Map()) : graphqlRead({ cwd: first.request.cwd, host: first.request.host, operation: "getPullRequestWatchFingerprint", - query, + ...document, decode: decodePullRequestWatchFingerprintsJson, }); return read.pipe( @@ -1897,16 +1904,22 @@ export const make = Effect.gen(function* () { ) => Effect.Effect, ) => Effect.gen(function* () { - const items: GitHubPullRequestListItem[] = []; - let rawCount = 0; - let after: string | null = null; - do { - const read: GitHubPullRequestListPage = yield* page(after, rows - rawCount); - items.push(...read.items); - rawCount += read.rawCount; - after = read.endCursor; - } while (after !== null && rawCount < rows); - return { items, rawCount }; + const countRows = (pages: ReadonlyArray) => + pages.reduce((total, read) => total + read.rawCount, 0); + const { pages, truncated } = yield* readGraphQlPages( + (after, pages: ReadonlyArray) => + page(after, rows - countRows(pages)), + { + nextCursor: (read) => read.endCursor, + until: (pages) => countRows(pages) >= rows, + }, + ); + // `truncated` also covers a cursor GitHub repeated before `rows` arrived. + return { + items: pages.flatMap((read) => read.items), + rawCount: countRows(pages), + truncated, + }; }); const read = ( continues: boolean, @@ -1932,7 +1945,7 @@ export const make = Effect.gen(function* () { decode: decodePullRequestListJson, }), ).pipe( - Effect.flatMap(({ items: rawItems, rawCount }) => { + Effect.flatMap(({ items: rawItems, rawCount, truncated: cutShort }) => { const items = continues ? rawItems : rawItems.filter((item) => matchesUnsortedListing(item, input)); @@ -1949,9 +1962,11 @@ export const make = Effect.gen(function* () { items: items.slice(0, input.limit), // One row over the page size is the probe for a next page, and it is // counted before decoding: a skipped malformed row must not end paging. - truncated: continues - ? rawCount > input.limit - : items.length > input.limit || rawCount >= requestedRows, + truncated: + cutShort || + (continues + ? rawCount > input.limit + : items.length > input.limit || rawCount >= requestedRows), continues, }); }), @@ -1989,16 +2004,16 @@ export const make = Effect.gen(function* () { return Effect.forEach( chunks, (chunk) => { - const query = buildPullRequestStackMembershipsGraphQlQuery( + const document = buildPullRequestStackMembershipsGraphQlQuery( input.repository, chunk.map((item) => item.number), ); - if (query === null) return Effect.succeed(chunk); + if (document === null) return Effect.succeed(chunk); return graphqlRead({ cwd: input.cwd, host: input.host, operation: "listPullRequestStackMemberships", - query, + ...document, decode: decodePullRequestStackMembershipsJson, }).pipe( Effect.map((memberships) => @@ -2061,8 +2076,8 @@ export const make = Effect.gen(function* () { return Effect.forEach( chunks, (chunk) => { - const query = buildPullRequestStatsGraphQlQuery(chunk); - if (query === null) { + const document = buildPullRequestStatsGraphQlQuery(chunk); + if (document === null) { return Effect.fail( new GitHubRepositorySelectorError({ cwd: input.cwd, @@ -2074,7 +2089,7 @@ export const make = Effect.gen(function* () { cwd: input.cwd, host: input.host, operation: "listPullRequestStats", - query, + ...document, decode: decodePullRequestStatsJson, }).pipe( Effect.map((stats) => @@ -2328,23 +2343,26 @@ export const make = Effect.gen(function* () { // 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: { owner, name, number: input.number, 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; + if (dismissalCursor !== null) { + const { pages } = yield* readGraphQlPages( + (cursor) => + graphqlRead({ + cwd: input.cwd, + host: input.host, + operation: "listReviewThreadComments", + variables: { owner, name, number: input.number, cursor }, + query: REVIEW_DISMISSALS_GRAPHQL_QUERY, + decode: decodeReviewDismissalsJson, + }), + { + from: dismissalCursor, + maxPages: REVIEW_THREAD_PAGES, + nextCursor: (read) => read.nextCursor, + }, + ); + for (const read of pages) + for (const [id, message] of read.dismissalsByReviewId) + dismissalsByReviewId.set(id, message); } const reviewThreads = entries.map((entry) => ({ @@ -2708,44 +2726,34 @@ export const make = Effect.gen(function* () { getPullRequestFilesViewed: (input) => { const { owner, name } = parseRepositorySelector(input.repository); - const read = ( - after: string | null, - collected: ReadonlyArray, - pagesLeft: number, - ): Effect.Effect => - graphqlRead({ - cwd: input.cwd, - host: input.host, - operation: "getPullRequestFilesViewed", - variables: { owner, name, number: input.number, after }, - query: PULL_REQUEST_FILES_VIEWED_GRAPHQL_QUERY, - decode: decodePullRequestFilesViewedJson, - }).pipe( - Effect.flatMap((page) => { - const files = [...collected, ...page.files]; - if (page.nextCursor === null) { - return Effect.succeed({ files, truncated: false }); - } - return pagesLeft <= 1 - ? Effect.succeed({ files, truncated: true }) - : read(page.nextCursor, files, pagesLeft - 1); + return readGraphQlPages( + (after) => + graphqlRead({ + cwd: input.cwd, + host: input.host, + operation: "getPullRequestFilesViewed", + variables: { owner, name, number: input.number, after }, + query: PULL_REQUEST_FILES_VIEWED_GRAPHQL_QUERY, + decode: decodePullRequestFilesViewedJson, }), - ); - return read(null, [], FILES_VIEWED_MAX_PAGES); + { maxPages: FILES_VIEWED_MAX_PAGES, nextCursor: (page) => page.nextCursor }, + ).pipe( + Effect.map(({ pages, truncated }) => ({ + files: pages.flatMap((page) => page.files), + truncated, + })), + ); }, setPullRequestFilesViewed: (input) => { - const mutation = buildSetFilesViewedGraphQlMutation(input.files); - if (mutation === null) return Effect.void; + if (input.files.length === 0) return Effect.void; return pullRequestNodeId({ ...input, operation: "setPullRequestFilesViewed" }).pipe( - Effect.flatMap((pullRequestId) => - graphql({ - host: input.host, - operation: "setPullRequestFilesViewed", - query: mutation.query, - variables: { pullRequestId, ...mutation.variables }, - }), - ), + Effect.flatMap((pullRequestId) => { + const mutation = buildSetFilesViewedGraphQlMutation(pullRequestId, input.files); + return mutation === null + ? Effect.void + : graphql({ host: input.host, operation: "setPullRequestFilesViewed", ...mutation }); + }), ); }, diff --git a/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts b/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts index 3105634dc087..06e25c6e4709 100644 --- a/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts +++ b/apps/server/src/pullRequest/gitHubPullRequestJson.test.ts @@ -1754,36 +1754,40 @@ describe("decodePullRequestFilesViewedJson", () => { describe("buildSetFilesViewedGraphQlMutation", () => { it("asks for nothing when nothing was pressed", () => { - expect(buildSetFilesViewedGraphQlMutation([])).toBeNull(); + expect(buildSetFilesViewedGraphQlMutation("PR_1", [])).toBeNull(); }); it("clears and restores in one document, each file under its own alias", () => { - const mutation = buildSetFilesViewedGraphQlMutation([ + const mutation = buildSetFilesViewedGraphQlMutation("PR_1", [ { path: "src/a.ts", viewed: true }, { path: "src/b.ts", viewed: false }, ]); expect(mutation).not.toBeNull(); if (mutation === null) return; expect(mutation.query).toContain( - "mutation($pullRequestId: ID!, $path0: String!, $path1: String!)", + "mutation($pullRequestId: ID!, $f0_path: String!, $f1_path: String!)", ); expect(mutation.query).toContain( - "f0: markFileAsViewed(input: { pullRequestId: $pullRequestId, path: $path0 })", + "f0: markFileAsViewed(input: { pullRequestId: $pullRequestId, path: $f0_path })", ); expect(mutation.query).toContain( - "f1: unmarkFileAsViewed(input: { pullRequestId: $pullRequestId, path: $path1 })", + "f1: unmarkFileAsViewed(input: { pullRequestId: $pullRequestId, path: $f1_path })", ); - expect(mutation.variables).toEqual({ path0: "src/a.ts", path1: "src/b.ts" }); + expect(mutation.variables).toEqual({ + pullRequestId: "PR_1", + f0_path: "src/a.ts", + f1_path: "src/b.ts", + }); }); it("keeps a path out of the document, so one cannot be read as part of it", () => { - const mutation = buildSetFilesViewedGraphQlMutation([ + const mutation = buildSetFilesViewedGraphQlMutation("PR_1", [ { path: '") { __typename } evil: markFileAsViewed(input: { path: "x', viewed: true }, ]); expect(mutation).not.toBeNull(); if (mutation === null) return; expect(mutation.query).not.toContain("evil"); - expect(mutation.variables.path0).toBe( + expect(mutation.variables.f0_path).toBe( '") { __typename } evil: markFileAsViewed(input: { path: "x', ); }); @@ -1922,9 +1926,14 @@ describe("pull request stack membership batches", () => { expect(buildPullRequestStackMembershipsGraphQlQuery("acme/web", [0])).toBeNull(); expect(buildPullRequestStackMembershipsGraphQlQuery("acme/web", [1.5])).toBeNull(); expect(buildPullRequestStackMembershipsGraphQlQuery("acme/web", [])).toBeNull(); - expect(buildPullRequestStackMembershipsGraphQlQuery("acme/web", [7, 8])).toContain( - "pullRequest(number: 8)", - ); + expect(buildPullRequestStackMembershipsGraphQlQuery("acme/web", [7, 8])?.variables).toEqual({ + s0_owner: "acme", + s0_name: "web", + s0_number: 7, + s1_owner: "acme", + s1_name: "web", + s1_number: 8, + }); }); }); @@ -1982,10 +1991,10 @@ describe("batched pull request summaries", () => { it("reads stack membership only where the document asked for it", () => { expect( - buildPullRequestSummariesGraphQlQuery([{ repository: "acme/web", number: 7 }], true), + buildPullRequestSummariesGraphQlQuery([{ repository: "acme/web", number: 7 }], true)?.query, ).toContain("stack { number size baseRefName } stackEntry { position }"); expect( - buildPullRequestSummariesGraphQlQuery([{ repository: "acme/web", number: 7 }]), + buildPullRequestSummariesGraphQlQuery([{ repository: "acme/web", number: 7 }])?.query, ).not.toContain("stack {"); const pullRequest = (number: number, stack: Record) => ({ pullRequest: { @@ -2019,16 +2028,22 @@ describe("batched pull request summaries", () => { describe("pull request watch fingerprints", () => { it("asks for every pull request in one aliased read, and refuses an unsafe selector", () => { - const query = buildPullRequestWatchFingerprintsGraphQlQuery([ + const document = buildPullRequestWatchFingerprintsGraphQlQuery([ { repository: "pingdotgg/t3code", number: 7 }, { repository: "pingdotgg/lakebed", number: 8 }, ]); - expect(query).toContain( - 'w0: repository(owner: "pingdotgg", name: "t3code") { pullRequest(number: 7)', - ); - expect(query).toContain( - 'w1: repository(owner: "pingdotgg", name: "lakebed") { pullRequest(number: 8)', - ); + expect(document?.query).toContain( + "w0: repository(owner: $w0_owner, name: $w0_name) { pullRequest(number: $w0_number)", + ); + expect(document?.query).toContain("w1: repository(owner: $w1_owner, name: $w1_name)"); + expect(document?.variables).toEqual({ + w0_owner: "pingdotgg", + w0_name: "t3code", + w0_number: 7, + w1_owner: "pingdotgg", + w1_name: "lakebed", + w1_number: 8, + }); expect( buildPullRequestWatchFingerprintsGraphQlQuery([{ repository: 'evil") { x', number: 1 }]), ).toBeNull(); diff --git a/apps/server/src/pullRequest/gitHubPullRequestJson.ts b/apps/server/src/pullRequest/gitHubPullRequestJson.ts index ce89cf6fe44a..028231c513f2 100644 --- a/apps/server/src/pullRequest/gitHubPullRequestJson.ts +++ b/apps/server/src/pullRequest/gitHubPullRequestJson.ts @@ -35,6 +35,7 @@ import type { import { quoteGitPatchPath } from "@t3tools/shared/gitPatchPath"; import { decodeJsonResult } from "@t3tools/shared/schemaJson"; +import { aliasedGraphQlDocument, type GraphQlDocument } from "../sourceControl/githubGraphQl.ts"; import { dedupeChecks } from "./pullRequestChecks.ts"; /** @@ -1917,53 +1918,81 @@ function toStackMembership( : undefined; } -/** What a repository selector may hold before it is written into a GraphQL document unquoted. */ +/** What GitHub allows in an owner or repository name. */ const REPOSITORY_PART = /^[A-Za-z0-9._-]+$/; +/** + * The owner and name of an `owner/name` selector, or null for one GitHub cannot name. A batch is + * one document, and a single unanswerable alias fails all of it, so these are refused up front. + */ +function repositoryParts( + repository: string, +): { readonly owner: string; readonly name: string } | null { + const [owner, name, ...rest] = repository.trim().split("/"); + if (rest.length > 0 || owner === undefined || name === undefined) return null; + return REPOSITORY_PART.test(owner) && REPOSITORY_PART.test(name) ? { owner, name } : null; +} + +const isPullRequestNumber = (number: number) => Number.isSafeInteger(number) && number > 0; + +/** One aliased `repository { pullRequest { selection } }` per change request, on one host. */ +function aliasedPullRequestsDocument( + name: string, + alias: string, + changeRequests: ReadonlyArray<{ readonly repository: string; readonly number: number }>, + selection: string, +): GraphQlDocument | null { + const addressed = changeRequests.map((changeRequest) => ({ + parts: repositoryParts(changeRequest.repository), + number: changeRequest.number, + })); + if (addressed.some(({ parts, number }) => parts === null || !isPullRequestNumber(number))) { + return null; + } + return aliasedGraphQlDocument({ + operation: "query", + name, + alias, + items: addressed, + variables: ({ parts, number }) => ({ + owner: ["String!", parts!.owner], + name: ["String!", parts!.name], + number: ["Int!", number], + }), + field: ({ owner, name, number }) => + `repository(owner: ${owner}, name: ${name}) { pullRequest(number: ${number}) { ${selection} } }`, + }); +} + /** * The line counts for rows a listing already handed over, as one aliased lookup each. * * Aliases rather than `nodes(ids:)` because the caller asks in the terms the page holds — a - * repository and a number — and never sees a node id. Owner, name and number are written into - * the document, so each is checked against what GitHub can actually name first: null for anything - * else, which the caller reports rather than sends. - * - * Null too for an empty request, since a GraphQL document with no selection is not a document. + * repository and a number — and never sees a node id. Null for a selector GitHub cannot name, + * which the caller reports rather than sends, and for an empty request. */ export function buildPullRequestStatsGraphQlQuery( changeRequests: ReadonlyArray<{ readonly repository: string; readonly number: number }>, -): string | null { - if (changeRequests.length === 0) return null; - const selections: string[] = []; - for (const [index, changeRequest] of changeRequests.entries()) { - const [owner, name, ...rest] = changeRequest.repository.trim().split("/"); - if (rest.length > 0 || owner === undefined || name === undefined) return null; - if (!REPOSITORY_PART.test(owner) || !REPOSITORY_PART.test(name)) return null; - if (!Number.isSafeInteger(changeRequest.number) || changeRequest.number <= 0) return null; - selections.push( - ` s${index}: repository(owner: "${owner}", name: "${name}") { pullRequest(number: ${changeRequest.number}) { additions deletions } }`, - ); - } - return `query {\n${selections.join("\n")}\n}`; +): GraphQlDocument | null { + return aliasedPullRequestsDocument( + "PullRequestStats", + "s", + changeRequests, + "additions deletions", + ); } /** Stack membership for the visible rows of a per-repository listing. */ export function buildPullRequestStackMembershipsGraphQlQuery( repository: string, numbers: ReadonlyArray, -): string | null { - if (numbers.length === 0) return null; - const [owner, name, ...rest] = repository.trim().split("/"); - if (rest.length > 0 || owner === undefined || name === undefined) return null; - if (!REPOSITORY_PART.test(owner) || !REPOSITORY_PART.test(name)) return null; - const selections: string[] = []; - for (const [index, number] of numbers.entries()) { - if (!Number.isSafeInteger(number) || number <= 0) return null; - selections.push( - ` s${index}: repository(owner: "${owner}", name: "${name}") { pullRequest(number: ${number}) { stack { number size baseRefName } stackEntry { position } } }`, - ); - } - return `query PullRequestStackMemberships {\n${selections.join("\n")}\n}`; +): GraphQlDocument | null { + return aliasedPullRequestsDocument( + "PullRequestStackMemberships", + "s", + numbers.map((number) => ({ repository, number })), + STACK_MEMBERSHIP_SELECTION, + ); } const decodeStackMemberships = decodeJsonResult(RawStackMembershipsSchema); @@ -2024,26 +2053,17 @@ const PULL_REQUEST_SUMMARY_SELECTION = "latestReviews(first: 20) { nodes { state author { login } } } " + "commits(last: 1) { nodes { commit { statusCheckRollup { state } } } }"; -/** - * Summaries for pull requests anywhere on one host, one aliased lookup each. Checked and written - * into the document the way `buildPullRequestStatsGraphQlQuery` does, so null means "do not send". - */ +/** Summaries for pull requests anywhere on one host, one aliased lookup each; null when unsafe. */ export function buildPullRequestSummariesGraphQlQuery( changeRequests: ReadonlyArray<{ readonly repository: string; readonly number: number }>, includeStacks = false, -): string | null { - if (changeRequests.length === 0) return null; - const selections: string[] = []; - for (const [index, changeRequest] of changeRequests.entries()) { - const [owner, name, ...rest] = changeRequest.repository.trim().split("/"); - if (rest.length > 0 || owner === undefined || name === undefined) return null; - if (!REPOSITORY_PART.test(owner) || !REPOSITORY_PART.test(name)) return null; - if (!Number.isSafeInteger(changeRequest.number) || changeRequest.number <= 0) return null; - selections.push( - ` s${index}: repository(owner: "${owner}", name: "${name}") { pullRequest(number: ${changeRequest.number}) { ${PULL_REQUEST_SUMMARY_SELECTION}${includeStacks ? ` ${STACK_MEMBERSHIP_SELECTION}` : ""} } }`, - ); - } - return `query PullRequestSummaries {\n${selections.join("\n")}\n}`; +): GraphQlDocument | null { + return aliasedPullRequestsDocument( + "PullRequestSummaries", + "s", + changeRequests, + `${PULL_REQUEST_SUMMARY_SELECTION}${includeStacks ? ` ${STACK_MEMBERSHIP_SELECTION}` : ""}`, + ); } /** One pull request's summary, aliased the way the batch is so the same decoder reads it. */ @@ -2170,19 +2190,13 @@ const PULL_REQUEST_WATCH_FINGERPRINT_SELECTION = /** Watch fingerprints for pull requests on one host, one aliased lookup each; null when unsafe. */ export function buildPullRequestWatchFingerprintsGraphQlQuery( changeRequests: ReadonlyArray<{ readonly repository: string; readonly number: number }>, -): string | null { - if (changeRequests.length === 0) return null; - const selections: string[] = []; - for (const [index, changeRequest] of changeRequests.entries()) { - const [owner, name, ...rest] = changeRequest.repository.trim().split("/"); - if (rest.length > 0 || owner === undefined || name === undefined) return null; - if (!REPOSITORY_PART.test(owner) || !REPOSITORY_PART.test(name)) return null; - if (!Number.isSafeInteger(changeRequest.number) || changeRequest.number <= 0) return null; - selections.push( - ` w${index}: repository(owner: "${owner}", name: "${name}") { pullRequest(number: ${changeRequest.number}) { ${PULL_REQUEST_WATCH_FINGERPRINT_SELECTION} } }`, - ); - } - return `query PullRequestWatchFingerprints {\n${selections.join("\n")}\n}`; +): GraphQlDocument | null { + return aliasedPullRequestsDocument( + "PullRequestWatchFingerprints", + "w", + changeRequests, + PULL_REQUEST_WATCH_FINGERPRINT_SELECTION, + ); } /** @@ -3357,25 +3371,20 @@ export function decodePullRequestFilesViewedJson( * request each. GitHub has no bulk form of `markFileAsViewed`/`unmarkFileAsViewed`, which each * take a single path, so the batching is done with aliases; top-level mutation fields run in * write order, so the last word about a path is the one that sticks. - * - * Paths travel as variables rather than interpolated into the document, since a path is data - * and a document is not. */ export function buildSetFilesViewedGraphQlMutation( + pullRequestId: string, files: ReadonlyArray<{ readonly path: string; readonly viewed: boolean }>, -): { readonly query: string; readonly variables: Readonly> } | null { - if (files.length === 0) return null; - const parameters = files.map((_, index) => `$path${index}: String!`).join(", "); - const fields = files - .map( - (file, index) => - ` f${index}: ${file.viewed ? "markFileAsViewed" : "unmarkFileAsViewed"}(input: { pullRequestId: $pullRequestId, path: $path${index} }) { clientMutationId }`, - ) - .join("\n"); - return { - query: `mutation($pullRequestId: ID!, ${parameters}) {\n${fields}\n}`, - variables: Object.fromEntries(files.map((file, index) => [`path${index}`, file.path])), - }; +): GraphQlDocument | null { + return aliasedGraphQlDocument({ + operation: "mutation", + alias: "f", + items: files, + shared: { pullRequestId: ["ID!", pullRequestId] }, + variables: (file) => ({ path: ["String!", file.path] }), + field: ({ path }, file) => + `${file.viewed ? "markFileAsViewed" : "unmarkFileAsViewed"}(input: { pullRequestId: $pullRequestId, path: ${path} }) { clientMutationId }`, + }); } /** One pull request as the stacks API lists it: a number, a head, and whether it is done. */ diff --git a/apps/server/src/pullRequest/githubStackActions.ts b/apps/server/src/pullRequest/githubStackActions.ts index 59d29627b81b..c2e8fadb751d 100644 --- a/apps/server/src/pullRequest/githubStackActions.ts +++ b/apps/server/src/pullRequest/githubStackActions.ts @@ -9,6 +9,7 @@ import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; import * as GitHubApi from "../sourceControl/GitHubApi.ts"; +import { aliasedGraphQlDocument } from "../sourceControl/githubGraphQl.ts"; import { decodePullRequestStacksJson } from "./gitHubPullRequestJson.ts"; import { cascadeRebaseStack } from "./githubStackRebase.ts"; @@ -192,13 +193,18 @@ export const runGitHubStackAction = Effect.fn("runGitHubStackAction")(function* host: input.host, operation: "runGitHubStackAction", allowReserve: true, - variables: { owner, name }, - query: `query($owner:String!,$name:String!){repository(owner:$owner,name:$name){${open - .map( - (layer) => - `pr${layer.number}:pullRequest(number:${layer.number}){headRepository{viewerPermission} maintainerCanModify}`, - ) - .join(" ")}}}`, + // `open` is non-empty here, so there is always a document. + ...aliasedGraphQlDocument({ + operation: "query", + alias: "pr", + key: (layer) => layer.number, + items: open, + shared: { owner: ["String!", owner], name: ["String!", name] }, + variables: (layer) => ({ number: ["Int!", layer.number] }), + field: ({ number }) => + `pullRequest(number: ${number}) { headRepository { viewerPermission } maintainerCanModify }`, + within: (fields) => `repository(owner: $owner, name: $name) {\n${fields}\n}`, + })!, }); const access = yield* decodeBranchAccess(permissions).pipe( Effect.mapError((cause) => new GitHubStackResponseInvalidError({ ...identity, cause })), diff --git a/apps/server/src/sourceControl/githubGraphQl.test.ts b/apps/server/src/sourceControl/githubGraphQl.test.ts new file mode 100644 index 000000000000..9ab8f9354493 --- /dev/null +++ b/apps/server/src/sourceControl/githubGraphQl.test.ts @@ -0,0 +1,105 @@ +import { assert, describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; + +import { aliasedGraphQlDocument, readGraphQlPages } from "./githubGraphQl.ts"; + +describe("aliasedGraphQlDocument", () => { + it("keeps every value out of the document, each under its alias's own variable", () => { + const document = aliasedGraphQlDocument({ + operation: "query", + name: "Lookups", + alias: "s", + items: ['acme") { x } #', "web"], + shared: { owner: ["String!", "acme"] }, + variables: (repo) => ({ name: ["String!", repo] }), + field: ({ name }) => `repository(owner: $owner, name: ${name}) { id }`, + }); + assert.isNotNull(document); + expect(document!.query).toBe( + "query Lookups($owner: String!, $s0_name: String!, $s1_name: String!) {\n" + + " s0: repository(owner: $owner, name: $s0_name) { id }\n" + + " s1: repository(owner: $owner, name: $s1_name) { id }\n}", + ); + expect(document!.variables).toEqual({ + owner: "acme", + s0_name: 'acme") { x } #', + s1_name: "web", + }); + }); + + it("aliases by key and nests inside a shared parent when asked", () => { + const document = aliasedGraphQlDocument({ + operation: "query", + alias: "pr", + key: (number) => number, + items: [2, 3], + variables: (number) => ({ number: ["Int!", number] }), + field: ({ number }) => `pullRequest(number: ${number}) { id }`, + within: (fields) => `repository(owner: "a", name: "b") {\n${fields}\n}`, + }); + expect(document?.query).toContain(" pr2: pullRequest(number: $pr2_number) { id }"); + expect(document?.query).toContain(" pr3: pullRequest(number: $pr3_number) { id }"); + expect(document?.variables).toEqual({ pr2_number: 2, pr3_number: 3 }); + }); + + it("asks for nothing when there is nothing to ask", () => { + expect( + aliasedGraphQlDocument({ + operation: "mutation", + alias: "f", + items: [], + variables: () => ({}), + field: () => "x", + }), + ).toBeNull(); + }); +}); + +describe("readGraphQlPages", () => { + const pages = (cursors: ReadonlyArray) => { + const asked: Array = []; + const read = (after: string | null) => + Effect.sync(() => { + asked.push(after); + return { next: cursors[asked.length - 1] ?? null }; + }); + return { asked, read }; + }; + + it.effect("follows the cursor until GitHub has no more", () => + Effect.gen(function* () { + const { asked, read } = pages(["a", "b", null]); + const result = yield* readGraphQlPages(read, { nextCursor: (page) => page.next }); + expect(asked).toEqual([null, "a", "b"]); + expect(result.truncated).toBe(false); + }), + ); + + it.effect("stops as truncated at the page limit, at `until`, or on a repeated cursor", () => + Effect.gen(function* () { + const limited = pages(["a", "b", "c"]); + const atLimit = yield* readGraphQlPages(limited.read, { + nextCursor: (page) => page.next, + maxPages: 2, + }); + expect(limited.asked).toEqual([null, "a"]); + expect(atLimit.truncated).toBe(true); + + const enough = pages(["a", "b", "c"]); + const satisfied = yield* readGraphQlPages(enough.read, { + nextCursor: (page) => page.next, + until: (read) => read.length === 1, + }); + expect(enough.asked).toEqual([null]); + expect(satisfied.truncated).toBe(true); + + const stuck = pages(["a", "a", "a"]); + const repeated = yield* readGraphQlPages(stuck.read, { + nextCursor: (page) => page.next, + from: "start", + }); + expect(stuck.asked).toEqual(["start", "a"]); + expect(repeated.truncated).toBe(true); + }), + ); +}); diff --git a/apps/server/src/sourceControl/githubGraphQl.ts b/apps/server/src/sourceControl/githubGraphQl.ts new file mode 100644 index 000000000000..0b1e3ca65844 --- /dev/null +++ b/apps/server/src/sourceControl/githubGraphQl.ts @@ -0,0 +1,100 @@ +import * as Effect from "effect/Effect"; + +/** GraphQL variables by name, each with the type it is declared as and the value it carries. */ +export type GraphQlVariables = Readonly>; + +export interface GraphQlDocument { + readonly query: string; + readonly variables: Readonly>; +} + +/** + * One document asking the same field for many items, each under its own alias: the prefix and + * the item's position (`s0`, `s1`, …), or its `key` when the answer is easier read by one. GitHub + * has no bulk form of most lookups, so aliases are how a batch travels in one request. + * + * Every value travels as a variable renamed per alias (`$s0_owner`), and `field` is handed those + * placeholders rather than the values: nothing an item holds is written into the document. + * `shared` variables are declared once and referenced by their own name; `within` nests the aliased + * fields inside one parent selection, such as a single repository. + * + * Null for no items, since a document with no selection is not a document. + */ +export function aliasedGraphQlDocument(input: { + readonly operation: "query" | "mutation"; + /** The operation's name, which tells one batch from another in logs and recorded traffic. */ + readonly name?: string; + readonly alias: string; + readonly key?: (item: Item, index: number) => number; + readonly items: ReadonlyArray; + readonly variables: (item: Item) => Variables; + readonly field: ( + placeholders: { readonly [Name in keyof Variables]: string }, + item: Item, + ) => string; + readonly shared?: GraphQlVariables; + readonly within?: (fields: string) => string; +}): GraphQlDocument | null { + if (input.items.length === 0) return null; + const declarations: string[] = []; + const variables: Record = {}; + for (const [name, [type, value]] of Object.entries(input.shared ?? {})) { + declarations.push(`$${name}: ${type}`); + variables[name] = value; + } + const fields = input.items.map((item, index) => { + const alias = `${input.alias}${input.key?.(item, index) ?? index}`; + const placeholders: Record = {}; + for (const [name, [type, value]] of Object.entries(input.variables(item))) { + const variable = `${alias}_${name}`; + declarations.push(`$${variable}: ${type}`); + variables[variable] = value; + placeholders[name] = `$${variable}`; + } + return ` ${alias}: ${input.field(placeholders as { readonly [Name in keyof Variables]: string }, item)}`; + }); + const selection = fields.join("\n"); + const parameters = declarations.length === 0 ? "" : `(${declarations.join(", ")})`; + return { + query: `${input.operation}${input.name === undefined ? "" : ` ${input.name}`}${parameters} {\n${input.within?.(selection) ?? selection}\n}`, + variables, + }; +} + +/** + * The pages of a GraphQL connection, read one after another from `from` (the first page by + * default) until GitHub has no more, `until` is satisfied, or `maxPages` have been read. `read` is + * handed the pages so far, for reads that size or check the next page by what has already arrived. + * + * `truncated` says pages remained when reading stopped. A cursor GitHub hands back a second time + * would page forever, so it stops the read as truncated too. + */ +export const readGraphQlPages = ( + read: (after: string | null, pages: ReadonlyArray) => Effect.Effect, + options: { + readonly nextCursor: (page: NoInfer) => string | null; + readonly from?: string | null; + readonly maxPages?: number; + readonly until?: (pages: ReadonlyArray>) => boolean; + }, +): Effect.Effect<{ readonly pages: ReadonlyArray; readonly truncated: boolean }, E, R> => + Effect.gen(function* () { + const pages: Page[] = []; + const seen = new Set(); + let after = options.from ?? null; + while (true) { + const page: Page = yield* read(after, pages); + pages.push(page); + const next = options.nextCursor(page); + if (next === null) return { pages, truncated: false }; + if ( + seen.has(next) || + pages.length >= (options.maxPages ?? Infinity) || + options.until?.(pages) === true + ) { + return { pages, truncated: true }; + } + seen.add(next); + after = next; + } + }); From 42d92ec59775c187af69071c81b2b91180810bdd Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 7 Oct 2026 17:26:25 -0700 Subject: [PATCH 24/25] refactor(server): GitHub source control reads GitHubApi directly (#16982) Co-authored-by: Claude Opus 5.5 (1M context) --- apps/server/scripts/evaluate-thread-titles.ts | 4 +- apps/server/src/git/GitManager.test.ts | 280 ++--- .../ThreadPullRequestService.ts | 2 +- .../pullRequest/GitHubPullRequestApi.test.ts | 2 +- .../src/pullRequest/GitHubPullRequestApi.ts | 3 +- .../GitHubPullRequestProvider.test.ts | 3 +- .../pullRequest/GitHubPullRequestProvider.ts | 4 +- .../PullRequestProviderRegistry.ts | 11 +- .../src/pullRequest/PullRequestService.ts | 2 +- apps/server/src/server.ts | 6 +- apps/server/src/sourceControl/GitHubApi.ts | 10 + .../src/sourceControl/GitHubCredentials.ts | 4 - .../sourceControl/GitHubRepositoryApi.test.ts | 651 ---------- .../src/sourceControl/GitHubRepositoryApi.ts | 1086 ----------------- .../GitHubSourceControlProvider.test.ts | 768 +++++++++--- .../GitHubSourceControlProvider.ts | 911 +++++++++++--- .../SourceControlDiscovery.test.ts | 4 +- .../SourceControlProviderRegistry.test.ts | 5 +- .../gitHubRepositoryResolution.test.ts | 87 ++ .../gitHubRepositoryResolution.ts | 190 +++ apps/server/src/ws.ts | 4 +- 21 files changed, 1746 insertions(+), 2291 deletions(-) delete mode 100644 apps/server/src/sourceControl/GitHubRepositoryApi.test.ts delete mode 100644 apps/server/src/sourceControl/GitHubRepositoryApi.ts create mode 100644 apps/server/src/sourceControl/gitHubRepositoryResolution.test.ts create mode 100644 apps/server/src/sourceControl/gitHubRepositoryResolution.ts diff --git a/apps/server/scripts/evaluate-thread-titles.ts b/apps/server/scripts/evaluate-thread-titles.ts index a8d2f9d986d1..b91196f28f84 100644 --- a/apps/server/scripts/evaluate-thread-titles.ts +++ b/apps/server/scripts/evaluate-thread-titles.ts @@ -23,7 +23,7 @@ import { } from "../src/textGeneration/ThreadTitleContext.ts"; import * as ThreadTitleLinks from "../src/textGeneration/ThreadTitleLinks.ts"; import * as SourceControlProviderRegistry from "../src/sourceControl/SourceControlProviderRegistry.ts"; -import * as GitHubRepositoryApi from "../src/sourceControl/GitHubRepositoryApi.ts"; +import * as GitHubApi from "../src/sourceControl/GitHubApi.ts"; import * as GitLabCli from "../src/sourceControl/GitLabCli.ts"; import * as ForgejoCli from "../src/sourceControl/ForgejoCli.ts"; import * as AzureDevOpsCli from "../src/sourceControl/AzureDevOpsCli.ts"; @@ -152,7 +152,7 @@ await Effect.runPromise( SourceControlProviderRegistry.layer.pipe( Layer.provide( Layer.mergeAll( - GitHubRepositoryApi.layer, + GitHubApi.layerWithDependencies, GitLabCli.layer, ForgejoCli.layer, AzureDevOpsCli.layer, diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index f30af2c23c5b..1ce25b4106be 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -24,6 +24,7 @@ import { TestClock } from "effect/testing"; import { ChildProcessSpawner } from "effect/process"; import { expect } from "vite-plus/test"; import type { + ChangeRequest, GitActionProgressEvent, GitManagerServiceError, GitPreparePullRequestThreadInput, @@ -37,12 +38,11 @@ import { ProjectId, ProviderDriverKind, ProviderInstanceId, + SourceControlProviderError as SourceControlProviderFailure, TextGenerationError, ThreadId, } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; -import * as GitHubApi from "../sourceControl/GitHubApi.ts"; -import * as GitHubRepositoryApi from "../sourceControl/GitHubRepositoryApi.ts"; import { decodeGitHubPullRequestListJson } from "../sourceControl/gitHubPullRequests.ts"; import * as GitLabCli from "../sourceControl/GitLabCli.ts"; import * as TextGeneration from "../textGeneration/TextGeneration.ts"; @@ -53,7 +53,6 @@ import * as VcsProjectConfig from "../vcs/VcsProjectConfig.ts"; import * as VcsStatusBroadcaster from "../vcs/VcsStatusBroadcaster.ts"; import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; import * as GitWorkflowService from "./GitWorkflowService.ts"; -import * as GitHubSourceControlProvider from "../sourceControl/GitHubSourceControlProvider.ts"; import * as GitLabSourceControlProvider from "../sourceControl/GitLabSourceControlProvider.ts"; import { ForgejoPullRequestSchema, @@ -92,7 +91,10 @@ interface FakeGhScenario { headRepositoryOwnerLogin?: string | null; }; repositoryCloneUrls?: Record; - failWith?: GitHubRepositoryApi.GitHubRepositoryApiError; + /** The detail the provider fails with, the way a GitHub failure reaches GitManager. */ + failWith?: string; + /** The raw failure behind `failWith`, which must stay out of anything shown or logged. */ + failCause?: unknown; /** Let this many gh calls succeed before failWith kicks in (default 0 = fail immediately). */ failAfterCalls?: number; } @@ -111,9 +113,12 @@ type FakeGitTextGeneration = TextGeneration.TextGeneration["Service"]; type FakePullRequest = NonNullable; -function normalizeFakePullRequestSummary( - raw: unknown, -): GitHubRepositoryApi.GitHubPullRequestSummary | null { +type FakePullRequestSummary = Omit & { + readonly state?: ChangeRequest["state"]; + readonly updatedAt?: string; +}; + +function normalizeFakePullRequestSummary(raw: unknown): FakePullRequestSummary | null { if (!raw || typeof raw !== "object") { return null; } @@ -383,8 +388,12 @@ function createTextGeneration( }; } -function createGitHubRepositoryApiWithFakeGh(scenario: FakeGhScenario = {}): { - service: GitHubRepositoryApi.GitHubRepositoryApi["Service"]; +/** + * A GitHub source control provider over a fake `gh`. The fake still speaks gh's command shapes, + * which is what `ghCalls` records and the tests assert on; the provider methods translate to them. + */ +function createGitHubProviderWithFakeGh(scenario: FakeGhScenario = {}): { + service: SourceControlProvider["Service"]; ghCalls: string[]; } { const prListQueue = [...(scenario.prListSequence ?? [])]; @@ -396,16 +405,20 @@ function createGitHubRepositoryApiWithFakeGh(scenario: FakeGhScenario = {}): { ); const ghCalls: string[] = []; - // The fake still speaks in gh's command shapes; the service methods below translate to them. + const fail = (cwd: string, detail: string, cause?: unknown, operation = "fakeGh") => + new SourceControlProviderFailure({ provider: "github", operation, cwd, detail, cause }); + + /** `operation` is the provider method the call answers, which a failure reports. */ const execute = (input: { readonly cwd: string; readonly args: ReadonlyArray; - }): Effect.Effect => { + readonly operation: string; + }): Effect.Effect => { const args = [...input.args]; ghCalls.push(args.join(" ")); if (scenario.failWith && ghCalls.length > (scenario.failAfterCalls ?? 0)) { - return Effect.fail(scenario.failWith); + return Effect.fail(fail(input.cwd, scenario.failWith, scenario.failCause, input.operation)); } if (args[0] === "pr" && args[1] === "list") { @@ -487,13 +500,7 @@ function createGitHubRepositoryApiWithFakeGh(scenario: FakeGhScenario = {}): { } return fakeGhOutput(""); }, - catch: (error) => - GitHubRepositoryApi.isGitHubRepositoryApiError(error) - ? error - : new GitHubRepositoryApi.GitHubRepositoryCommandError({ - cwd: input.cwd, - cause: error, - }), + catch: (error) => fail(input.cwd, "GitHub request failed.", error), }); } @@ -502,12 +509,7 @@ function createGitHubRepositoryApiWithFakeGh(scenario: FakeGhScenario = {}): { if (typeof repository === "string" && args.includes("nameWithOwner,url,sshUrl")) { const cloneUrls = scenario.repositoryCloneUrls?.[repository]; if (!cloneUrls) { - return Effect.fail( - new GitHubRepositoryApi.GitHubRepositoryCommandError({ - cwd: input.cwd, - cause: new Error(`Unexpected repository lookup: ${repository}`), - }), - ); + return Effect.fail(fail(input.cwd, `Unexpected repository lookup: ${repository}`)); } return Effect.succeed( fakeGhOutput( @@ -522,73 +524,86 @@ function createGitHubRepositoryApiWithFakeGh(scenario: FakeGhScenario = {}): { return Effect.succeed(fakeGhOutput(`${scenario.defaultBranch ?? "main"}\n`)); } - return Effect.fail( - new GitHubRepositoryApi.GitHubRepositoryCommandError({ - cwd: input.cwd, - cause: new Error(`Unexpected gh command: ${args.join(" ")}`), - }), - ); + return Effect.fail(fail(input.cwd, `Unexpected gh command: ${args.join(" ")}`)); }; + /** What GitHubSourceControlProvider makes of a pull request it read. */ + const toChangeRequest = (summary: FakePullRequestSummary): ChangeRequest => ({ + ...summary, + provider: "github", + state: summary.state ?? "open", + closedAt: summary.closedAt ?? null, + mergedAt: summary.mergedAt ?? null, + updatedAt: + summary.updatedAt === undefined + ? Option.none() + : Option.some(DateTime.makeUnsafe(summary.updatedAt)), + }); + return { service: { - // The fake answers the CLI shape, so batched lookups read it the way the fallback does. - listPullRequestsByHead: (input) => - execute({ - cwd: input.cwd, - args: [ - "pr", - "list", - "--head", - input.headSelector, - "--state", - input.state, - "--limit", - String(input.limit), - "--json", - "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", - ], - }).pipe( - Effect.map((result) => { - const raw = result.stdout.trim(); - if (raw.length === 0) return []; - const decoded = decodeGitHubPullRequestListJson(raw); - return Result.isSuccess(decoded) ? decoded.success : []; - }), - ), - listOpenPullRequests: (input) => - execute({ - cwd: input.cwd, - args: [ - "pr", - "list", - "--head", - input.headSelector, - "--state", - "open", - "--limit", - String(input.limit ?? 1), - "--json", - "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,isCrossRepository,headRepository,headRepositoryOwner", - ], - }).pipe( - Effect.map((result) => JSON.parse(result.stdout) as unknown[]), - Effect.map((raw) => - raw - .map((entry) => normalizeFakePullRequestSummary(entry)) - .filter( - (entry): entry is GitHubRepositoryApi.GitHubPullRequestSummary => entry !== null, + kind: "github", + listChangeRequests: (input) => + input.state === "open" + ? execute({ + operation: "listChangeRequests", + cwd: input.cwd, + args: [ + "pr", + "list", + "--head", + input.headSelector, + "--state", + "open", + "--limit", + String(input.limit ?? 1), + "--json", + "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,isCrossRepository,headRepository,headRepositoryOwner", + ], + }).pipe( + Effect.map((result) => JSON.parse(result.stdout) as unknown[]), + Effect.map((raw) => + raw + .map((entry) => normalizeFakePullRequestSummary(entry)) + .filter((entry): entry is FakePullRequestSummary => entry !== null) + .map(toChangeRequest), ), - ), - ), - createPullRequest: (input) => + ) + : // The fake answers the CLI shape, so batched lookups read it the way the fallback does. + execute({ + operation: "listChangeRequests", + cwd: input.cwd, + args: [ + "pr", + "list", + "--head", + input.headSelector, + "--state", + input.state, + "--limit", + String(input.limit ?? 20), + "--json", + "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", + ], + }).pipe( + Effect.map((result) => { + const raw = result.stdout.trim(); + if (raw.length === 0) return []; + const decoded = decodeGitHubPullRequestListJson(raw); + return Result.isSuccess(decoded) + ? decoded.success.map((record) => ({ provider: "github" as const, ...record })) + : []; + }), + ), + createChangeRequest: (input) => execute({ + operation: "createChangeRequest", cwd: input.cwd, args: [ "pr", "create", "--base", - input.baseBranch, + input.baseRefName, "--head", input.headSelector, "--title", @@ -599,6 +614,7 @@ function createGitHubRepositoryApiWithFakeGh(scenario: FakeGhScenario = {}): { }).pipe(Effect.asVoid), getDefaultBranch: (input) => execute({ + operation: "getDefaultBranch", cwd: input.cwd, args: ["repo", "view", "--json", "defaultBranchRef", "--jq", ".defaultBranchRef.name"], }).pipe( @@ -607,8 +623,9 @@ function createGitHubRepositoryApiWithFakeGh(scenario: FakeGhScenario = {}): { return value.length > 0 ? value : null; }), ), - getPullRequest: (input) => + getChangeRequest: (input) => execute({ + operation: "getChangeRequest", cwd: input.cwd, args: [ "pr", @@ -618,24 +635,21 @@ function createGitHubRepositoryApiWithFakeGh(scenario: FakeGhScenario = {}): { "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,isCrossRepository,headRepository,headRepositoryOwner", ], }).pipe( - Effect.map( - (result) => JSON.parse(result.stdout) as GitHubRepositoryApi.GitHubPullRequestSummary, + Effect.map((result) => + toChangeRequest(JSON.parse(result.stdout) as FakePullRequestSummary), ), ), getRepositoryCloneUrls: (input) => execute({ + operation: "getRepositoryCloneUrls", cwd: input.cwd, args: ["repo", "view", input.repository, "--json", "nameWithOwner,url,sshUrl"], }).pipe(Effect.map((result) => JSON.parse(result.stdout))), createRepository: (input) => - Effect.fail( - new GitHubRepositoryApi.GitHubRepositoryCommandError({ - cwd: input.cwd, - cause: new Error(`Unexpected repository create: ${input.repository}`), - }), - ), - checkoutPullRequest: (input) => + Effect.fail(fail(input.cwd, `Unexpected repository create: ${input.repository}`)), + checkoutChangeRequest: (input) => execute({ + operation: "checkoutChangeRequest", cwd: input.cwd, args: ["pr", "checkout", input.reference, ...(input.force ? ["--force"] : [])], }).pipe(Effect.asVoid), @@ -693,7 +707,7 @@ function makeManager(input?: { ProjectionStore.ProjectionStoreV2 | ProjectStore.ProjectStoreV2 >; }) { - const { service: gitHubRepositoryApi, ghCalls } = createGitHubRepositoryApiWithFakeGh( + const { service: fakeGitHubProvider, ghCalls } = createGitHubProviderWithFakeGh( input?.ghScenario, ); const textGeneration = createTextGeneration(input?.textGeneration); @@ -729,10 +743,7 @@ function makeManager(input?: { ); const layerSourceControlRegistry = Layer.effect( SourceControlProviderRegistry.SourceControlProviderRegistry, - (input?.sourceControlProvider === undefined - ? GitHubSourceControlProvider.make - : Effect.succeed(input.sourceControlProvider) - ).pipe( + Effect.succeed(input?.sourceControlProvider ?? fakeGitHubProvider).pipe( Effect.map((provider) => SourceControlProviderRegistry.SourceControlProviderRegistry.of({ resolveLink: (input) => provider.resolveLink?.(input), @@ -742,12 +753,6 @@ function makeManager(input?: { discover: Effect.succeed([]), }), ), - Effect.provide( - Layer.merge( - Layer.succeed(GitHubRepositoryApi.GitHubRepositoryApi, gitHubRepositoryApi), - Layer.mock(GitHubApi.GitHubApi)({}), - ), - ), ), ); @@ -1300,10 +1305,8 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { yield* runGit(repoDir, ["push", "-u", "origin", "feature/rate-limited"]); const { manager, ghCalls } = yield* makeManager({ ghScenario: { - failWith: new GitHubRepositoryApi.GitHubRepositoryUnavailableError({ - cwd: repoDir, - cause: new Error("rate limited"), - }), + failWith: + "No GitHub credential on the server. Set GH_TOKEN, or install the GitHub CLI and run `gh auth login`.", }, }); yield* manager.remoteStatus({ cwd: repoDir }); @@ -1782,10 +1785,8 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { const { manager, ghCalls } = yield* makeManager({ ghScenario: { - failWith: new GitHubRepositoryApi.GitHubRepositoryUnavailableError({ - cwd: repoDir, - cause: new Error("gh is not available on PATH"), - }), + failWith: + "No GitHub credential on the server. Set GH_TOKEN, or install the GitHub CLI and run `gh auth login`.", }, }); @@ -2642,10 +2643,8 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { }, ]), }, - failWith: new GitHubRepositoryApi.GitHubRepositoryUnavailableError({ - cwd: repoDir, - cause: new Error("rate limited"), - }), + failWith: + "No GitHub credential on the server. Set GH_TOKEN, or install the GitHub CLI and run `gh auth login`.", failAfterCalls: 1, }, }); @@ -2718,10 +2717,8 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { const { manager } = yield* makeManager({ ghScenario: { - failWith: new GitHubRepositoryApi.GitHubRepositoryUnavailableError({ - cwd: repoDir, - cause: new Error("gh is not available on PATH"), - }), + failWith: + "No GitHub credential on the server. Set GH_TOKEN, or install the GitHub CLI and run `gh auth login`.", }, }); @@ -2743,10 +2740,8 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { const upstreamCause = "GraphQL rate limit for user ID 51714798 and token secret-value"; const { manager } = yield* makeManager({ ghScenario: { - failWith: new GitHubRepositoryApi.GitHubRepositoryRateLimitError({ - cwd: repoDir, - cause: new Error(upstreamCause), - }), + failWith: "GitHub API rate limit exceeded. Requests resume when the limit resets.", + failCause: new Error(upstreamCause), }, }); const logs: Array<{ message: string; annotations: Record }> = []; @@ -2799,10 +2794,8 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { const { manager } = yield* makeManager({ ghScenario: { prListSequence: [JSON.stringify([existingPr])], - failWith: new GitHubRepositoryApi.GitHubRepositoryUnavailableError({ - cwd: repoDir, - cause: new Error("rate limited"), - }), + failWith: + "No GitHub credential on the server. Set GH_TOKEN, or install the GitHub CLI and run `gh auth login`.", failAfterCalls: 1, }, }); @@ -2841,10 +2834,8 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { const { manager } = yield* makeManager({ ghScenario: { prListSequence: [JSON.stringify([existingPr])], - failWith: new GitHubRepositoryApi.GitHubRepositoryUnavailableError({ - cwd: repoDir, - cause: new Error("rate limited"), - }), + failWith: + "No GitHub credential on the server. Set GH_TOKEN, or install the GitHub CLI and run `gh auth login`.", failAfterCalls: 1, }, }); @@ -2888,10 +2879,8 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { const { manager } = yield* makeManager({ ghScenario: { prListSequence: [JSON.stringify([existingPr])], - failWith: new GitHubRepositoryApi.GitHubRepositoryUnavailableError({ - cwd: repoDir, - cause: new Error("rate limited"), - }), + failWith: + "No GitHub credential on the server. Set GH_TOKEN, or install the GitHub CLI and run `gh auth login`.", failAfterCalls: 1, }, }); @@ -2926,10 +2915,8 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { const { manager } = yield* makeManager({ ghScenario: { prListSequence: [JSON.stringify([existingPr])], - failWith: new GitHubRepositoryApi.GitHubRepositoryUnavailableError({ - cwd: repoDir, - cause: new Error("rate limited"), - }), + failWith: + "No GitHub credential on the server. Set GH_TOKEN, or install the GitHub CLI and run `gh auth login`.", failAfterCalls: 1, }, }); @@ -2967,10 +2954,8 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { const { manager } = yield* makeManager({ ghScenario: { prListSequence: [JSON.stringify([existingPr])], - failWith: new GitHubRepositoryApi.GitHubRepositoryUnavailableError({ - cwd: repoDir, - cause: new Error("rate limited"), - }), + failWith: + "No GitHub credential on the server. Set GH_TOKEN, or install the GitHub CLI and run `gh auth login`.", failAfterCalls: 1, }, }); @@ -4611,10 +4596,8 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { const { manager } = yield* makeManager({ ghScenario: { - failWith: new GitHubRepositoryApi.GitHubRepositoryUnavailableError({ - cwd: repoDir, - cause: new Error("gh is not available on PATH"), - }), + failWith: + "No GitHub credential on the server. Set GH_TOKEN, or install the GitHub CLI and run `gh auth login`.", }, }); @@ -4640,10 +4623,7 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { const { manager } = yield* makeManager({ ghScenario: { - failWith: new GitHubRepositoryApi.GitHubRepositoryAuthenticationError({ - cwd: repoDir, - cause: new Error("gh is not authenticated"), - }), + failWith: "GitHub is not authenticated. Run `gh auth login` (or set GH_TOKEN) and retry.", }, }); diff --git a/apps/server/src/orchestration-v2/ThreadPullRequestService.ts b/apps/server/src/orchestration-v2/ThreadPullRequestService.ts index c1e0cff2658f..cd88d9397776 100644 --- a/apps/server/src/orchestration-v2/ThreadPullRequestService.ts +++ b/apps/server/src/orchestration-v2/ThreadPullRequestService.ts @@ -345,7 +345,7 @@ export const make = Effect.gen(function* () { }).pipe(Effect.tap(() => Effect.sync(() => failBackfill(group)))), ), ), - // Wide enough that a sweep's GitHub branch lookups reach GitHubRepositoryApi together and share one + // Wide enough that a sweep's GitHub branch lookups reach the GitHub provider together and share one // GraphQL document, instead of one `gh pr list` per branch. { concurrency: 32, discard: true }, ); diff --git a/apps/server/src/pullRequest/GitHubPullRequestApi.test.ts b/apps/server/src/pullRequest/GitHubPullRequestApi.test.ts index 64259e337e92..20fc1dd88526 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestApi.test.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestApi.test.ts @@ -12,7 +12,7 @@ import * as Redacted from "effect/Redacted"; import { HttpClient, HttpClientResponse } from "effect/http"; -import { AllowGitHubReserve } from "../sourceControl/GitHubRepositoryApi.ts"; +import { AllowGitHubReserve } from "../sourceControl/GitHubApi.ts"; import * as GitHubApi from "../sourceControl/GitHubApi.ts"; import * as GitHubCredentials from "../sourceControl/GitHubCredentials.ts"; import * as GitHubGraphQlBudget from "../sourceControl/githubGraphQlBudget.ts"; diff --git a/apps/server/src/pullRequest/GitHubPullRequestApi.ts b/apps/server/src/pullRequest/GitHubPullRequestApi.ts index 59281964df4a..5e3572915460 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestApi.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestApi.ts @@ -42,7 +42,6 @@ import { type PullRequestPreview, } from "@t3tools/contracts"; -import { AllowGitHubReserve } from "../sourceControl/GitHubRepositoryApi.ts"; import * as GitHubApi from "../sourceControl/GitHubApi.ts"; import { readGraphQlPages } from "../sourceControl/githubGraphQl.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; @@ -1524,7 +1523,7 @@ export const make = Effect.gen(function* () { const getPullRequestDetail: GitHubPullRequestApi["Service"]["getPullRequestDetail"] = (input) => { const { owner, name } = parseRepositorySelector(input.repository); - return AllowGitHubReserve.pipe( + return GitHubApi.AllowGitHubReserve.pipe( Effect.flatMap((allowReserve) => graphqlRead({ allowReserve, diff --git a/apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts b/apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts index f00cbfaf5d32..df4f7da4cd2c 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts @@ -7,7 +7,6 @@ import type { PullRequestReaction } from "@t3tools/contracts"; import { decodePullRequestDetailJson } from "./gitHubPullRequestJson.ts"; import * as GitHubApi from "../sourceControl/GitHubApi.ts"; -import * as GitHubRepositoryApi from "../sourceControl/GitHubRepositoryApi.ts"; import * as GitHubPullRequestApi from "./GitHubPullRequestApi.ts"; import type { GitHubPullRequestCore } from "./gitHubPullRequestJson.ts"; import { gitHubViewerPermissions, loginAvatarUrl, make } from "./GitHubPullRequestProvider.ts"; @@ -726,7 +725,7 @@ describe("getViewerPermissions", () => { Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ revalidateChecks: (_input, read) => read, getPullRequestDetail: () => - GitHubRepositoryApi.AllowGitHubReserve.pipe( + GitHubApi.AllowGitHubReserve.pipe( Effect.tap((allowReserve) => Effect.sync(() => onDetail(allowReserve))), Effect.flatMap(() => detail), ), diff --git a/apps/server/src/pullRequest/GitHubPullRequestProvider.ts b/apps/server/src/pullRequest/GitHubPullRequestProvider.ts index 5e6f24207299..f2672dbe991f 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestProvider.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestProvider.ts @@ -7,7 +7,7 @@ import type { PullRequestViewerPermissions, } from "@t3tools/contracts"; -import * as GitHubRepositoryApi from "../sourceControl/GitHubRepositoryApi.ts"; +import * as GitHubApi from "../sourceControl/GitHubApi.ts"; import * as GitHubPullRequestApi from "./GitHubPullRequestApi.ts"; import { PullRequestProviderError, @@ -526,7 +526,7 @@ export const make = Effect.gen(function* () { // comparison, so one read usually answers what used to take three. When that heavier read // fails, the light access read still answers, withholding only update-branch. return cli.getPullRequestDetail(input).pipe( - Effect.provideService(GitHubRepositoryApi.AllowGitHubReserve, true), + Effect.provideService(GitHubApi.AllowGitHubReserve, true), Effect.map((pullRequest) => gitHubViewerPermissions({ ...pullRequest.viewerAccess, diff --git a/apps/server/src/pullRequest/PullRequestProviderRegistry.ts b/apps/server/src/pullRequest/PullRequestProviderRegistry.ts index efbe37f991ee..316cd1e7ae92 100644 --- a/apps/server/src/pullRequest/PullRequestProviderRegistry.ts +++ b/apps/server/src/pullRequest/PullRequestProviderRegistry.ts @@ -6,9 +6,6 @@ import type { SourceControlProviderKind } from "@t3tools/contracts"; import * as AzureDevOpsCli from "../sourceControl/AzureDevOpsCli.ts"; import * as BitbucketApi from "../sourceControl/BitbucketApi.ts"; import * as GitHubApi from "../sourceControl/GitHubApi.ts"; -import * as GitHubCredentials from "../sourceControl/GitHubCredentials.ts"; -import * as GitHubGraphQlBudget from "../sourceControl/githubGraphQlBudget.ts"; -import * as SourceControlRateLimit from "../sourceControl/SourceControlRateLimit.ts"; import * as GitLabCli from "../sourceControl/GitLabCli.ts"; import * as ForgejoCli from "../sourceControl/ForgejoCli.ts"; import * as ForgejoPullRequestProvider from "./ForgejoPullRequestProvider.ts"; @@ -63,12 +60,8 @@ export const layer = Layer.effect(PullRequestProviderRegistry, make).pipe( Layer.provide( GitHubPullRequestApi.layer.pipe( Layer.provide( - GitHubApi.layer.pipe( - Layer.provide(GitHubCredentials.layer), - // The same layers GitHubRepositoryApi merges, so both share one budget and one pause per host. - Layer.provide(GitHubGraphQlBudget.layer), - Layer.provide(SourceControlRateLimit.layer), - ), + // Built here too, so a registry built alone still has one budget and pause per host. + GitHubApi.layerWithDependencies, ), ), ), diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts index 9e2b81d97d5f..a44bd322adcb 100644 --- a/apps/server/src/pullRequest/PullRequestService.ts +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -77,7 +77,7 @@ import { import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; import { detectSourceControlProviderFromRemoteUrl } from "@t3tools/shared/sourceControl"; -import { AllowGitHubReserve } from "../sourceControl/GitHubRepositoryApi.ts"; +import { AllowGitHubReserve } from "../sourceControl/GitHubApi.ts"; import * as ProjectService from "../project/ProjectService.ts"; import * as ServerSettings from "../serverSettings.ts"; import * as PullRequestFilesViewed from "../persistence/PullRequestFilesViewed.ts"; diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index fdd7d6c7165f..192b17204aea 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -50,7 +50,7 @@ import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts"; import * as CheckpointStore from "./checkpointing/CheckpointStore.ts"; import * as AzureDevOpsCli from "./sourceControl/AzureDevOpsCli.ts"; import * as BitbucketApi from "./sourceControl/BitbucketApi.ts"; -import * as GitHubRepositoryApi from "./sourceControl/GitHubRepositoryApi.ts"; +import * as GitHubApi from "./sourceControl/GitHubApi.ts"; import * as GitLabCli from "./sourceControl/GitLabCli.ts"; import * as ForgejoCli from "./sourceControl/ForgejoCli.ts"; import * as TextGeneration from "./textGeneration/TextGeneration.ts"; @@ -270,7 +270,7 @@ const layerSourceControlProviderRegistry = SourceControlProviderRegistry.layer.p Layer.mergeAll( AzureDevOpsCli.layer, BitbucketApi.layer, - GitHubRepositoryApi.layer, + GitHubApi.layerWithDependencies, GitLabCli.layer, ForgejoCli.layer, ), @@ -565,7 +565,7 @@ const layerRuntimeCoreDependenciesBase = Layer.mergeAll( Layer.provideMerge(layerServerSettings), // The asset route uses the registry's GitHub credential for private PR media. Layer.provideMerge(layerSourceControlProviderRegistry), - Layer.provideMerge(GitHubRepositoryApi.layer), + Layer.provideMerge(GitHubApi.layerWithDependencies), Layer.provideMerge(layerGit), Layer.provideMerge(layerVcs), Layer.provideMerge(Layer.mergeAll(layerTerminal, layerPreview, layerDevice)), diff --git a/apps/server/src/sourceControl/GitHubApi.ts b/apps/server/src/sourceControl/GitHubApi.ts index 01aa1c9ccfcf..0da53e0230b1 100644 --- a/apps/server/src/sourceControl/GitHubApi.ts +++ b/apps/server/src/sourceControl/GitHubApi.ts @@ -598,3 +598,13 @@ export const make = Effect.gen(function* () { }); export const layer = Layer.effect(GitHubApi, make); + +/** + * The transport with the credential, GraphQL budget and rate-limit pause it reads through. An + * application builds this once so every GitHub reader shares one budget and one pause per host. + */ +export const layerWithDependencies = layer.pipe( + Layer.provideMerge(GitHubCredentials.layer), + Layer.provideMerge(GitHubGraphQlBudget.layer), + Layer.provideMerge(SourceControlRateLimit.layer), +); diff --git a/apps/server/src/sourceControl/GitHubCredentials.ts b/apps/server/src/sourceControl/GitHubCredentials.ts index fb043d809c68..d47d57ea7ffe 100644 --- a/apps/server/src/sourceControl/GitHubCredentials.ts +++ b/apps/server/src/sourceControl/GitHubCredentials.ts @@ -81,10 +81,6 @@ export type GitHubCredentialUnavailableError = | GitHubHostDisabledError | GitHubCliFailedError; -export const isGitHubCredentialUnavailableError = Schema.is( - Schema.Union([GitHubCliMissingError, GitHubNotSignedInError, GitHubHostDisabledError]), -); - /** * Where GitHub tokens come from. Callers ask per host and never see how the token was found, * so another source (an in-app OAuth login) slots in here without touching any of them. diff --git a/apps/server/src/sourceControl/GitHubRepositoryApi.test.ts b/apps/server/src/sourceControl/GitHubRepositoryApi.test.ts deleted file mode 100644 index 688216211f1f..000000000000 --- a/apps/server/src/sourceControl/GitHubRepositoryApi.test.ts +++ /dev/null @@ -1,651 +0,0 @@ -import { assert, it, describe } from "@effect/vitest"; -import * as NodeServices from "@effect/platform-node/NodeServices"; -import * as TestClock from "effect/testing/TestClock"; -import * as Effect from "effect/Effect"; -import * as Fiber from "effect/Fiber"; -import * as FileSystem from "effect/FileSystem"; -import * as Layer from "effect/Layer"; -import * as Schema from "effect/Schema"; -import { ChildProcessSpawner } from "effect/process"; - -import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; -import * as VcsProcess from "../vcs/VcsProcess.ts"; -import * as GitHubApi from "./GitHubApi.ts"; -import * as GitHubRepositoryApi from "./GitHubRepositoryApi.ts"; -import * as GitHubCredentials from "./GitHubCredentials.ts"; - -const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); - -const processOutput = (stdout: string, exitCode = 0): VcsProcess.VcsProcessOutput => ({ - exitCode: ChildProcessSpawner.ExitCode(exitCode), - stdout, - stderr: "", - stdoutTruncated: false, - stderrTruncated: false, -}); - -const remotesOutput = (...entries: ReadonlyArray) => - entries - .flatMap(([name, url]) => [`${name}\t${url} (fetch)`, `${name}\t${url} (push)`]) - .join("\n"); - -const restResponse = (body: unknown, status = 200): GitHubApi.GitHubRestResponse => ({ - status, - headers: {}, - body: body === undefined ? "" : encodeJson(body), - truncated: false, - invalidUtf8: false, -}); - -const node = (number: number, headRefName: string, owner = "acme") => ({ - number, - title: `PR ${number}`, - url: `https://github.com/acme/web/pull/${number}`, - baseRefName: "main", - headRefName, - state: "OPEN", - isCrossRepository: owner !== "acme", - updatedAt: "2026-01-02T00:00:00Z", - headRepository: { name: "web", nameWithOwner: `${owner}/web` }, - headRepositoryOwner: { login: owner }, -}); - -/** - * A GitHubRepositoryApi over a mocked GitHubApi, git driver and process. `remotes` is what - * `git remote -v` prints; `git` records every driver call. - */ -function harness(input: { - readonly remotes: string; - readonly api: Partial; - readonly localBranches?: ReadonlyArray; -}) { - const git: Array = []; - const record = - (name: string, value: A) => - (args: unknown) => - Effect.sync(() => { - git.push([name, args]); - return value; - }); - const driver = Layer.mock(GitVcsDriver.GitVcsDriver)({ - execute: (args) => - Effect.sync(() => { - git.push(["execute", args.args]); - return processOutput(""); - }), - resolvePrimaryRemoteName: () => Effect.succeed("origin"), - readConfigValue: () => Effect.succeed("git@github.com:acme/web.git"), - ensureRemote: (args) => - Effect.sync(() => { - git.push(["ensureRemote", args]); - return args.preferredName; - }), - fetchRemoteTrackingBranch: (args) => record("fetchRemoteTrackingBranch", undefined)(args), - setBranchUpstream: (args) => record("setBranchUpstream", undefined)(args), - switchRef: (args) => record("switchRef", { refName: args.refName })(args) as never, - listLocalBranchNames: () => Effect.succeed([...(input.localBranches ?? [])]), - resolveCommit: () => Effect.succeed({ commitSha: "abc123" }), - }); - const process = Layer.mock(VcsProcess.VcsProcess)({ - run: (args) => - Effect.succeed( - args.args[0] === "remote" ? processOutput(input.remotes) : processOutput("", 1), - ), - }); - const layer = Layer.effect( - GitHubRepositoryApi.GitHubRepositoryApi, - GitHubRepositoryApi.make, - ).pipe( - Layer.provide( - Layer.mergeAll( - driver, - process, - Layer.mock(GitHubApi.GitHubApi)(input.api), - NodeServices.layer, - ), - ), - ); - return { layer, git }; -} - -describe("selectGitHubBaseRepository", () => { - const remotes = (...entries: ReadonlyArray) => - entries - .flatMap(([name, url]) => [`${name}\t${url} (fetch)`, `${name}\t${url} (push)`]) - .join("\n"); - const select = (input: { remotes: string; resolved?: string }) => - GitHubRepositoryApi.selectGitHubBaseRepository({ resolved: "", host: "github.com", ...input }); - - it("picks the repository gh reads without a prompt", () => { - assert.deepStrictEqual(select({ remotes: remotes(["fork", "git@github.com:me/web.git"]) }), { - owner: "me", - name: "web", - }); - const fork = remotes( - ["origin", "git@github.com:me/web.git"], - ["upstream", "https://github.com/Acme/Web.git"], - ); - assert.deepStrictEqual(select({ remotes: fork }), { owner: "acme", name: "web" }); - assert.deepStrictEqual(select({ remotes: fork, resolved: "remote.origin.gh-resolved base" }), { - owner: "me", - name: "web", - }); - assert.deepStrictEqual( - select({ remotes: fork, resolved: "remote.origin.gh-resolved acme/other" }), - { owner: "acme", name: "other" }, - ); - // gh ranks remote names in any case. - assert.deepStrictEqual( - select({ - remotes: remotes( - ["origin", "git@github.com:me/web.git"], - ["Upstream", "git@github.com:acme/web.git"], - ), - }), - { owner: "acme", name: "web" }, - ); - }); - - it("leaves gh to choose when it might weigh the remotes differently", () => { - for (const input of [ - { remotes: "" }, - { - remotes: remotes(["a", "git@github.com:me/web.git"], ["b", "git@github.com:acme/web.git"]), - }, - { - remotes: remotes( - ["origin", "git@github.com:me/web.git"], - ["mirror", "git@gitlab.com:me/web.git"], - ), - }, - { remotes: remotes(["origin", "git@github-work:me/web.git"]) }, - { - remotes: remotes( - ["origin", "git@github.com:me/web.git"], - ["Origin", "git@github.com:acme/web.git"], - ), - }, - { - remotes: remotes( - ["origin", "git@github.com:me/web.git"], - ["upstream", "git@github.com:acme/web.git"], - ), - resolved: "remote.origin.gh-resolved base\nremote.upstream.gh-resolved base", - }, - ]) { - assert.strictEqual(select(input), null); - } - }); -}); - -describe("GitHubRepositoryApi repository resolution", () => { - it.effect("reads the repository gh would pick from the remotes", () => { - const paths: string[] = []; - const { layer } = harness({ - remotes: remotesOutput( - ["origin", "git@github.com:me/web.git"], - ["upstream", "https://github.com/acme/web.git"], - ), - api: { - rest: (input) => - Effect.sync(() => { - paths.push(`${input.host} ${input.path}`); - return restResponse({ - full_name: "acme/web", - html_url: "https://github.com/acme/web", - ssh_url: "git@github.com:acme/web.git", - default_branch: "trunk", - }); - }), - }, - }); - return Effect.gen(function* () { - const gh = yield* GitHubRepositoryApi.GitHubRepositoryApi; - assert.strictEqual(yield* gh.getDefaultBranch({ cwd: "/repo" }), "trunk"); - assert.deepStrictEqual(paths, ["github.com repos/acme/web"]); - }).pipe(Effect.provide(layer)); - }); - - it.effect("reads an SSH alias remote through github.com", () => { - const hosts: string[] = []; - const { layer } = harness({ - remotes: remotesOutput(["origin", "git@github:acme/web.git"]), - api: { - rest: (input) => - Effect.sync(() => { - hosts.push(`${input.host} ${input.path}`); - return restResponse({ - full_name: "acme/web", - html_url: "https://github.com/acme/web", - ssh_url: "git@github.com:acme/web.git", - default_branch: "main", - }); - }), - }, - }); - return Effect.gen(function* () { - const gh = yield* GitHubRepositoryApi.GitHubRepositoryApi; - yield* gh.getDefaultBranch({ cwd: "/repo" }); - // A provider's host hint for the same alias (`github` here) resolves the same way. - yield* gh.getDefaultBranch({ cwd: "/repo", rateLimitHost: "github" }); - assert.deepStrictEqual(hosts, ["github.com repos/acme/web", "github.com repos/acme/web"]); - assert.strictEqual( - GitHubRepositoryApi.gitHubApiHostForRemote("git@github.example.com:a/b.git"), - "github.example.com", - ); - assert.strictEqual( - GitHubRepositoryApi.gitHubApiHostForRemote("git@gitlab.com:a/b.git"), - null, - ); - }).pipe(Effect.provide(layer)); - }); - - it.effect("fails clearly when no remote is on GitHub", () => { - const { layer } = harness({ - remotes: remotesOutput(["origin", "git@gitlab.com:a/b.git"]), - api: {}, - }); - return Effect.gen(function* () { - const gh = yield* GitHubRepositoryApi.GitHubRepositoryApi; - const error = yield* gh.getDefaultBranch({ cwd: "/repo" }).pipe(Effect.flip); - assert.strictEqual(error._tag, "GitHubRepositoryCommandError"); - assert.include(String((error.cause as Error).message), "No GitHub repository"); - }).pipe(Effect.provide(layer)); - }); -}); - -describe("GitHubRepositoryApi.listPullRequestsByHead", () => { - const remotes = remotesOutput(["origin", "git@github.com:acme/web.git"]); - - it.effect("reads a background sweep's staggered heads in one GraphQL document", () => { - const documents: Array = []; - const { layer } = harness({ - remotes, - api: { - graphql: (input) => - Effect.sync(() => { - documents.push(input); - return encodeJson({ - data: { - repository: { - h0: { nodes: [{ ...node(7, "feature/a"), headRefOid: "a".repeat(40) }] }, - h1: { nodes: [] }, - }, - }, - }); - }), - }, - }); - return Effect.gen(function* () { - const gh = yield* GitHubRepositoryApi.GitHubRepositoryApi; - const lookup = (headSelector: string) => - gh - .listPullRequestsByHead({ - cwd: "/repo", - headSelector, - state: "all", - limit: 100, - rateLimitHost: "github.com", - }) - .pipe(Effect.forkChild); - // Each branch's own git reads come first, so a sweep's lookups arrive spread out. - const firstLookup = yield* lookup("feature/a"); - yield* TestClock.adjust("200 millis"); - const secondLookup = yield* lookup("feature/b"); - yield* TestClock.adjust("300 millis"); - const first = yield* Fiber.join(firstLookup); - const second = yield* Fiber.join(secondLookup); - assert.deepStrictEqual( - first?.map((pr) => pr.number), - [7], - ); - assert.strictEqual(first?.[0]?.headSha, "a".repeat(40)); - assert.deepStrictEqual(second, []); - assert.strictEqual(documents.length, 1); - assert.include(documents[0]!.query, "headRefOid"); - assert.deepStrictEqual(documents[0]!.variables, { - owner: "acme", - name: "web", - h0: "feature/a", - s0: ["OPEN", "CLOSED", "MERGED"], - h1: "feature/b", - s1: ["OPEN", "CLOSED", "MERGED"], - }); - }).pipe(Effect.provide(layer)); - }); - - it.effect("caps a background document at twenty-five heads", () => { - const headCounts: Array = []; - const { layer } = harness({ - remotes, - api: { - graphql: (input) => - Effect.sync(() => { - const heads = Object.keys(input.variables ?? {}).filter((key) => /^h\d+$/.test(key)); - headCounts.push(heads.length); - return encodeJson({ - data: { repository: Object.fromEntries(heads.map((key) => [key, { nodes: [] }])) }, - }); - }), - }, - }); - return Effect.gen(function* () { - const gh = yield* GitHubRepositoryApi.GitHubRepositoryApi; - const lookups = yield* Effect.all( - Array.from({ length: 26 }, (_, index) => - gh.listPullRequestsByHead({ - cwd: "/repo", - headSelector: `feature/${index}`, - state: "all", - limit: 100, - rateLimitHost: "github.com", - }), - ), - { concurrency: "unbounded" }, - ).pipe(Effect.forkChild); - yield* TestClock.adjust("500 millis"); - yield* Fiber.join(lookups); - assert.deepStrictEqual( - headCounts.toSorted((a, b) => a - b), - [1, 25], - ); - }).pipe(Effect.provide(layer)); - }); - - it.effect("matches an owner:branch selector on the head owner", () => { - const { layer } = harness({ - remotes, - api: { - graphql: (input) => - Effect.succeed( - encodeJson({ - data: { - repository: { - h0: { - nodes: - input.variables?.h0 === "main" - ? [node(9, "main", "someone"), node(8, "main", "me"), node(7, "main", "me")] - : [], - }, - }, - }, - }), - ), - }, - }); - return Effect.gen(function* () { - const gh = yield* GitHubRepositoryApi.GitHubRepositoryApi; - const open = yield* gh - .listOpenPullRequests({ cwd: "/repo", headSelector: "me:main", limit: 1 }) - .pipe(Effect.forkChild); - yield* TestClock.adjust("50 millis"); - assert.deepStrictEqual( - (yield* Fiber.join(open)).map((pr) => pr.number), - [8], - ); - }).pipe(Effect.provide(layer)); - }); - - it.effect("maps API failures onto the errors callers handle", () => { - const { layer } = harness({ - remotes, - api: { - graphql: (input) => - Effect.fail( - input.variables?.h0 === "missing" - ? new GitHubCredentials.GitHubCliMissingError({ host: "github.com" }) - : new GitHubApi.GitHubApiRateLimitError({ - host: "github.com", - operation: "x", - retryAt: 123, - }), - ), - }, - }); - return Effect.gen(function* () { - const gh = yield* GitHubRepositoryApi.GitHubRepositoryApi; - const read = (headSelector: string) => - gh - .listPullRequestsByHead({ cwd: "/repo", headSelector, state: "open", limit: 1 }) - .pipe(Effect.flip, Effect.forkChild); - const missing = yield* read("missing"); - yield* TestClock.adjust("500 millis"); - assert.strictEqual((yield* Fiber.join(missing))._tag, "GitHubRepositoryUnavailableError"); - const limited = yield* read("limited"); - yield* TestClock.adjust("500 millis"); - const error = yield* Fiber.join(limited); - assert.strictEqual(error._tag, "GitHubRepositoryRateLimitError"); - assert.propertyVal(error, "retryAt", 123); - }).pipe(Effect.provide(layer)); - }); -}); - -describe("GitHubRepositoryApi.getPullRequest", () => { - it.effect("reads a pull request by number, and by URL on its own repository", () => { - const variables: Array = []; - const { layer } = harness({ - remotes: remotesOutput(["origin", "git@github.com:acme/web.git"]), - api: { - graphql: (input) => - Effect.sync(() => { - variables.push(input.variables); - return encodeJson({ data: { repository: { pullRequest: node(42, "feature") } } }); - }), - }, - }); - return Effect.gen(function* () { - const gh = yield* GitHubRepositoryApi.GitHubRepositoryApi; - assert.strictEqual((yield* gh.getPullRequest({ cwd: "/repo", reference: "#42" })).number, 42); - yield* gh.getPullRequest({ - cwd: "/repo", - reference: "https://github.com/other/thing/pull/42", - }); - assert.deepStrictEqual(variables, [ - { owner: "acme", name: "web", number: 42 }, - { owner: "other", name: "thing", number: 42 }, - ]); - }).pipe(Effect.provide(layer)); - }); - - it.effect("fails a missing pull request as not found", () => { - const { layer } = harness({ - remotes: remotesOutput(["origin", "git@github.com:acme/web.git"]), - api: { - graphql: () => Effect.succeed(encodeJson({ data: { repository: { pullRequest: null } } })), - }, - }); - return Effect.gen(function* () { - const gh = yield* GitHubRepositoryApi.GitHubRepositoryApi; - const error = yield* gh.getPullRequest({ cwd: "/repo", reference: "7" }).pipe(Effect.flip); - assert.strictEqual(error._tag, "GitHubPullRequestNotFoundError"); - }).pipe(Effect.provide(layer)); - }); -}); - -describe("GitHubRepositoryApi writes", () => { - it.effect("creates a cross-repository pull request with an owner:branch head", () => { - const requests: Array = []; - const { layer } = harness({ - remotes: remotesOutput( - ["origin", "git@github.com:me/web.git"], - ["upstream", "git@github.com:acme/web.git"], - ), - api: { - rest: (input) => - Effect.sync(() => { - requests.push(input); - return restResponse({ number: 1 }, 201); - }), - }, - }); - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const bodyFile = yield* fs.makeTempFileScoped({ suffix: ".md" }); - yield* fs.writeFileString(bodyFile, "Body"); - const gh = yield* GitHubRepositoryApi.GitHubRepositoryApi; - yield* gh.createPullRequest({ - cwd: "/repo", - baseBranch: "main", - headSelector: "me:feature", - title: "Title", - bodyFile, - }); - assert.strictEqual(requests[0]!.method, "POST"); - assert.strictEqual(requests[0]!.path, "repos/acme/web/pulls"); - assert.deepStrictEqual(requests[0]!.body, { - base: "main", - head: "me:feature", - title: "Title", - body: "Body", - maintainer_can_modify: true, - }); - }).pipe(Effect.provide(Layer.merge(layer, NodeServices.layer)), Effect.scoped); - }); - - it.effect("creates a repository under an organization the viewer is not", () => { - const requests: Array = []; - const { layer } = harness({ - remotes: "", - api: { - rest: (input) => - Effect.sync(() => { - requests.push(`${input.method ?? "GET"} ${input.path}`); - return input.path === "user" - ? restResponse({ login: "me" }) - : restResponse({ - full_name: "acme/new", - html_url: "https://github.com/acme/new", - ssh_url: "git@github.com:acme/new.git", - }); - }), - }, - }); - return Effect.gen(function* () { - const gh = yield* GitHubRepositoryApi.GitHubRepositoryApi; - const urls = yield* gh.createRepository({ - cwd: "/repo", - repository: "acme/new", - visibility: "private", - }); - assert.deepStrictEqual(urls, { - nameWithOwner: "acme/new", - url: "https://github.com/acme/new", - sshUrl: "git@github.com:acme/new.git", - }); - assert.deepStrictEqual(requests, ["GET user", "POST orgs/acme/repos"]); - }).pipe(Effect.provide(layer)); - }); -}); - -describe("GitHubRepositoryApi.checkoutPullRequest", () => { - const repository = (fullName: string, defaultBranch = "main") => - restResponse({ - full_name: fullName, - html_url: `https://github.com/${fullName}`, - ssh_url: `git@github.com:${fullName}.git`, - default_branch: defaultBranch, - }); - - it.effect("checks a same-repository pull request out from its head branch", () => { - const { layer, git } = harness({ - remotes: remotesOutput(["origin", "git@github.com:acme/web.git"]), - api: { - graphql: () => - Effect.succeed( - encodeJson({ data: { repository: { pullRequest: node(5, "feature/x") } } }), - ), - }, - }); - return Effect.gen(function* () { - const gh = yield* GitHubRepositoryApi.GitHubRepositoryApi; - yield* gh.checkoutPullRequest({ cwd: "/repo", reference: "5" }); - assert.deepStrictEqual(git, [ - [ - "fetchRemoteTrackingBranch", - { cwd: "/repo", remoteName: "origin", remoteBranch: "feature/x" }, - ], - ["execute", ["branch", "feature/x", "refs/remotes/origin/feature/x"]], - ["switchRef", { cwd: "/repo", refName: "feature/x" }], - [ - "setBranchUpstream", - { cwd: "/repo", branch: "feature/x", remoteName: "origin", remoteBranch: "feature/x" }, - ], - ]); - }).pipe(Effect.provide(layer)); - }); - - it.effect("refuses a fork checkout when the base's default branch cannot be read", () => { - const { layer, git } = harness({ - remotes: remotesOutput(["origin", "git@github.com:acme/web.git"]), - localBranches: ["main"], - api: { - graphql: () => - Effect.succeed( - encodeJson({ data: { repository: { pullRequest: node(6, "main", "someone") } } }), - ), - rest: () => - Effect.fail( - new GitHubApi.GitHubApiRequestError({ - host: "github.com", - operation: "x", - cause: "offline", - }), - ), - }, - }); - return Effect.gen(function* () { - const gh = yield* GitHubRepositoryApi.GitHubRepositoryApi; - yield* Effect.flip(gh.checkoutPullRequest({ cwd: "/repo", reference: "6", force: true })); - // Nothing touched the local branches: `main` must not be reset to the fork's commit. - assert.deepStrictEqual(git, []); - }).pipe(Effect.provide(layer)); - }); - - it.effect("adds a remote for a fork and names a default-branch head after its owner", () => { - const { layer, git } = harness({ - remotes: remotesOutput(["origin", "git@github.com:acme/web.git"]), - localBranches: ["someone/main"], - api: { - graphql: () => - Effect.succeed( - encodeJson({ data: { repository: { pullRequest: node(6, "main", "someone") } } }), - ), - rest: (input) => - Effect.succeed(repository(input.path === "repos/acme/web" ? "acme/web" : "someone/web")), - }, - }); - return Effect.gen(function* () { - const gh = yield* GitHubRepositoryApi.GitHubRepositoryApi; - yield* gh.checkoutPullRequest({ cwd: "/repo", reference: "6", force: true }); - assert.deepStrictEqual(git, [ - [ - "ensureRemote", - { cwd: "/repo", preferredName: "someone", url: "git@github.com:someone/web.git" }, - ], - [ - "fetchRemoteTrackingBranch", - { cwd: "/repo", remoteName: "someone", remoteBranch: "main" }, - ], - ["switchRef", { cwd: "/repo", refName: "someone/main" }], - ["execute", ["reset", "--hard", "--quiet", "refs/remotes/someone/main"]], - [ - "setBranchUpstream", - { cwd: "/repo", branch: "someone/main", remoteName: "someone", remoteBranch: "main" }, - ], - ]); - }).pipe(Effect.provide(layer)); - }); - - it("names the local branch the way gh pr checkout does", () => { - const name = (headRefName: string, isCrossRepository: boolean) => - GitHubRepositoryApi.pullRequestCheckoutBranchName({ - headRefName, - headOwner: "someone", - isCrossRepository, - defaultBranch: "main", - }); - assert.strictEqual(name("main", true), "someone/main"); - assert.strictEqual(name("feature", true), "feature"); - assert.strictEqual(name("main", false), "main"); - }); -}); diff --git a/apps/server/src/sourceControl/GitHubRepositoryApi.ts b/apps/server/src/sourceControl/GitHubRepositoryApi.ts deleted file mode 100644 index 595d869d7227..000000000000 --- a/apps/server/src/sourceControl/GitHubRepositoryApi.ts +++ /dev/null @@ -1,1086 +0,0 @@ -import * as Context from "effect/Context"; -import * as DateTime from "effect/DateTime"; -import * as Effect from "effect/Effect"; -import * as Exit from "effect/Exit"; -import * as FileSystem from "effect/FileSystem"; -import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; -import * as Request from "effect/Request"; -import * as RequestResolver from "effect/RequestResolver"; -import * as Result from "effect/Result"; -import * as Schema from "effect/Schema"; - -import { TrimmedNonEmptyString, type SourceControlRepositoryVisibility } from "@t3tools/contracts"; -import { normalizeGitRemoteUrl } from "@t3tools/shared/git"; -import { decodeJsonResult } from "@t3tools/shared/schemaJson"; -import { - detectSourceControlProviderFromRemoteUrl, - isSshRemoteUrl, -} from "@t3tools/shared/sourceControl"; - -import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; -import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; - -import * as VcsProcess from "../vcs/VcsProcess.ts"; -import * as GitHubApi from "./GitHubApi.ts"; -import * as GitHubCredentials from "./GitHubCredentials.ts"; -import * as GitHubGraphQlBudget from "./githubGraphQlBudget.ts"; -import * as SourceControlRateLimit from "./SourceControlRateLimit.ts"; -import { - decodeGitHubPullRequestEntries, - type NormalizedGitHubPullRequestRecord, -} from "./gitHubPullRequests.ts"; - -export const AllowGitHubReserve = GitHubApi.AllowGitHubReserve; - -const gitHubFailureFields = { - cwd: Schema.String, - cause: Schema.Defect(), -} as const; - -export class GitHubRepositoryUnavailableError extends Schema.TaggedError()( - "GitHubRepositoryUnavailableError", - gitHubFailureFields, -) { - override get message(): string { - return "No GitHub credential on the server. Set GH_TOKEN, or install the GitHub CLI and run `gh auth login`."; - } -} - -export class GitHubRepositoryAuthenticationError extends Schema.TaggedError()( - "GitHubRepositoryAuthenticationError", - gitHubFailureFields, -) { - override get message(): string { - // A missing or turned-off credential already says what to do about it. - return GitHubCredentials.isGitHubCredentialUnavailableError(this.cause) - ? this.cause.message - : "GitHub is not authenticated. Run `gh auth login` (or set GH_TOKEN) and retry."; - } -} - -export class GitHubRepositoryRateLimitError extends Schema.TaggedError()( - "GitHubRepositoryRateLimitError", - { ...gitHubFailureFields, retryAt: Schema.optionalKey(Schema.Finite) }, -) { - override get message(): string { - return "GitHub API rate limit exceeded. Requests resume when the limit resets."; - } -} - -export class GitHubPullRequestNotFoundError extends Schema.TaggedError()( - "GitHubPullRequestNotFoundError", - gitHubFailureFields, -) { - override get message(): string { - return "Pull request not found. Check the PR number or URL and try again."; - } -} - -export class GitHubRepositoryCommandError extends Schema.TaggedError()( - "GitHubRepositoryCommandError", - { ...gitHubFailureFields, httpStatus: Schema.optional(Schema.Int) }, -) { - override get message(): string { - // GitHub's own reason ("A pull request already exists…") or the failed step's, when known. - const reason = - this.cause instanceof Error && this.cause.message.trim() !== "" - ? this.cause.message.trim() - : null; - return reason === null ? "GitHub request failed." : reason; - } -} - -const gitHubDecodeFields = { - cwd: Schema.String, - cause: Schema.Defect(), -} as const; - -export class GitHubPullRequestListDecodeError extends Schema.TaggedError()( - "GitHubPullRequestListDecodeError", - gitHubDecodeFields, -) { - override get message(): string { - return "GitHub returned an invalid pull request list."; - } -} - -export class GitHubChangeRequestListDecodeError extends Schema.TaggedError()( - "GitHubChangeRequestListDecodeError", - gitHubDecodeFields, -) { - override get message(): string { - return "GitHub returned an invalid change request list."; - } -} - -export class GitHubPullRequestDecodeError extends Schema.TaggedError()( - "GitHubPullRequestDecodeError", - gitHubDecodeFields, -) { - override get message(): string { - return "GitHub returned an invalid pull request."; - } -} - -export class GitHubRepositoryDecodeError extends Schema.TaggedError()( - "GitHubRepositoryDecodeError", - gitHubDecodeFields, -) { - override get message(): string { - return "GitHub returned an invalid repository."; - } -} - -export const GitHubRepositoryApiError = Schema.Union([ - GitHubRepositoryUnavailableError, - GitHubRepositoryAuthenticationError, - GitHubRepositoryRateLimitError, - GitHubPullRequestNotFoundError, - GitHubRepositoryCommandError, - GitHubPullRequestListDecodeError, - GitHubChangeRequestListDecodeError, - GitHubPullRequestDecodeError, - GitHubRepositoryDecodeError, -]); -export type GitHubRepositoryApiError = typeof GitHubRepositoryApiError.Type; - -export const isGitHubRepositoryApiError = Schema.is(GitHubRepositoryApiError); - -/** Maps a GitHub API failure onto the errors callers of this service already handle. */ -function fromGitHubApiError( - cwd: string, - error: GitHubApi.GitHubApiError, -): GitHubRepositoryApiError { - const context = { cwd, cause: error }; - switch (error._tag) { - case "GitHubCliMissingError": - return new GitHubRepositoryUnavailableError(context); - case "GitHubNotSignedInError": - case "GitHubHostDisabledError": - case "GitHubApiAuthenticationError": - return new GitHubRepositoryAuthenticationError(context); - case "GitHubCliFailedError": - return new GitHubRepositoryCommandError(context); - case "GitHubApiRateLimitError": - return new GitHubRepositoryRateLimitError({ - ...context, - ...(error.retryAt === undefined ? {} : { retryAt: error.retryAt }), - }); - case "SourceControlRateLimitPausedError": - return new GitHubRepositoryRateLimitError({ ...context, retryAt: error.retryAt }); - case "GitHubApiNotFoundError": - return new GitHubPullRequestNotFoundError(context); - case "GitHubApiResponseError": - return new GitHubRepositoryCommandError({ ...context, httpStatus: error.status }); - case "GitHubApiRequestError": - return new GitHubRepositoryCommandError(context); - } -} - -export interface GitHubPullRequestSummary { - readonly number: number; - readonly title: string; - readonly url: string; - readonly baseRefName: string; - readonly headRefName: string; - readonly headSha?: string; - readonly state?: "open" | "closed" | "merged"; - readonly isDraft?: boolean; - readonly closedAt?: string | null; - readonly mergedAt?: string | null; - readonly updatedAt?: string; - readonly isCrossRepository?: boolean; - readonly headRepositoryNameWithOwner?: string | null; - readonly headRepositoryOwnerLogin?: string | null; -} - -function pullRequestSummary(input: NormalizedGitHubPullRequestRecord): GitHubPullRequestSummary { - const { updatedAt, ...summary } = input; - return { - ...summary, - ...(Option.isSome(updatedAt) ? { updatedAt: DateTime.formatIso(updatedAt.value) } : {}), - }; -} - -export interface GitHubRepositoryCloneUrls { - readonly nameWithOwner: string; - readonly url: string; - readonly sshUrl: string; -} - -export class GitHubRepositoryApi extends Context.Service< - GitHubRepositoryApi, - { - readonly listOpenPullRequests: (input: { - readonly cwd: string; - readonly headSelector: string; - readonly limit?: number; - readonly rateLimitHost?: string; - }) => Effect.Effect, GitHubRepositoryApiError>; - - /** - * Pull requests whose head is `headSelector` (a branch, or `owner:branch` for a fork), in the - * repository `gh pr list` would read in `cwd`. Lookups on one repository that arrive - * together share one GraphQL document. - */ - readonly listPullRequestsByHead: (input: { - readonly cwd: string; - readonly headSelector: string; - readonly state: "open" | "closed" | "merged" | "all"; - readonly limit: number; - /** The checkout's GitHub API host. Without it, the host comes from the git remotes. */ - readonly rateLimitHost?: string; - }) => Effect.Effect, GitHubRepositoryApiError>; - - readonly getPullRequest: (input: { - readonly cwd: string; - readonly reference: string; - readonly rateLimitHost?: string; - }) => Effect.Effect; - - readonly getRepositoryCloneUrls: (input: { - readonly cwd: string; - readonly repository: string; - }) => Effect.Effect; - - readonly createRepository: (input: { - readonly cwd: string; - readonly repository: string; - readonly visibility: SourceControlRepositoryVisibility; - }) => Effect.Effect; - - readonly createPullRequest: (input: { - readonly cwd: string; - readonly baseBranch: string; - readonly headSelector: string; - readonly title: string; - readonly bodyFile: string; - }) => Effect.Effect; - - readonly getDefaultBranch: (input: { - readonly cwd: string; - readonly rateLimitHost?: string; - }) => Effect.Effect; - - readonly checkoutPullRequest: (input: { - readonly cwd: string; - readonly reference: string; - readonly force?: boolean; - }) => Effect.Effect; - } ->()("t3/sourceControl/GitHubRepositoryApi") {} - -/** - * The repository `gh pr list` reads in a checkout, picked the way gh picks one without a - * prompt: the remote `gh repo set-default` marked, else the first of upstream, github, origin - * (in any case), else the only remote. `remotes` is `git remote -v` output and `resolved` is the output of - * `git config --get-regexp '^remote\..*\.gh-resolved$'`. - * - * Null whenever gh might weigh the remotes differently: a remote on another host or under an - * SSH alias, more than one mark, or several remotes with none of those names. Callers then - * fall back to the provider's remote, then to the best-ranked remote on the host. - */ -export function selectGitHubBaseRepository(input: { - readonly remotes: string; - readonly resolved: string; - readonly host: string; -}): { readonly owner: string; readonly name: string } | null { - const host = input.host.toLowerCase(); - const repositories = new Map(); - for (const line of input.remotes.split("\n")) { - const match = /^(\S+)\s+(\S+)\s+\(fetch\)$/u.exec(line.trim()); - if (!match) continue; - const [remoteHost, owner, name, ...rest] = normalizeGitRemoteUrl(match[2]!).split("/"); - if (remoteHost !== host || !owner || !name || rest.length > 0) return null; - repositories.set(match[1]!, { owner, name }); - } - const marks = input.resolved - .split("\n") - .map((line) => /^remote\.(.+)\.gh-resolved\s+(\S+)$/u.exec(line.trim())) - .filter((match): match is RegExpExecArray => match !== null && repositories.has(match[1]!)); - if (marks.length > 1) return null; - const [mark] = marks; - if (mark) { - if (mark[2] === "base") return repositories.get(mark[1]!) ?? null; - const [owner, name, ...rest] = mark[2]!.toLowerCase().split("/"); - return owner && name && rest.length === 0 ? { owner, name } : null; - } - // gh sorts remotes by these names, case-insensitively, and takes the first. A tie for the - // top place has no defined winner. - const score = (remoteName: string) => - ["origin", "github", "upstream"].indexOf(remoteName.toLowerCase()) + 1; - const ranked = [...repositories.entries()].toSorted( - ([left], [right]) => score(right) - score(left), - ); - const [top, next] = ranked; - return top !== undefined && (next === undefined || score(top[0]) > score(next[0])) - ? top[1] - : null; -} - -const RawRepositorySchema = Schema.Struct({ - full_name: TrimmedNonEmptyString, - html_url: TrimmedNonEmptyString, - ssh_url: TrimmedNonEmptyString, - default_branch: Schema.optional(Schema.NullOr(Schema.String)), -}); -const decodeRawRepository = decodeJsonResult(RawRepositorySchema); - -function repositoryCloneUrls( - raw: Schema.Schema.Type, -): GitHubRepositoryCloneUrls { - return { nameWithOwner: raw.full_name, url: raw.html_url, sshUrl: raw.ssh_url }; -} - -const decodeViewerLogin = decodeJsonResult(Schema.Struct({ login: TrimmedNonEmptyString })); - -type PullRequestListState = "open" | "closed" | "merged" | "all"; - -/** The pull request fields every read selects, in GraphQL. */ -const PULL_REQUEST_NODE_SELECTION = - "number title url baseRefName headRefName headRefOid state isDraft mergedAt closedAt updatedAt isCrossRepository headRepository { name nameWithOwner } headRepositoryOwner { login }"; -const GRAPHQL_STATES: Record> = { - open: ["OPEN"], - closed: ["CLOSED"], - merged: ["MERGED"], - all: ["OPEN", "CLOSED", "MERGED"], -}; -/** - * Head lookups per GraphQL document. A document of a hundred costs one point, the same as one - * single lookup, but half that keeps each answer near half a second. - */ -const HEAD_LOOKUPS_PER_DOCUMENT = 50; -/** - * How long a head lookup waits for company. Branch discovery reaches GitHub only after each - * branch's own git reads, so lookups started together arrive tens of milliseconds apart. - * A background sweep's lookups spread over up to ~300ms, and every document costs a point no - * matter how few heads it holds, so reads without reserve wait longer. - */ -const HEAD_LOOKUP_BATCH_WINDOW = "50 millis"; -const BACKGROUND_HEAD_LOOKUP_BATCH_WINDOW = "500 millis"; -/** - * Background documents fill up under the longer window, and a failed document fails every head - * in it. Fifty `main`-like heads of a hundred pull requests each took up to ~10s, GitHub's own - * processing limit; twenty-five took ~7s. - */ -const BACKGROUND_HEAD_LOOKUPS_PER_DOCUMENT = 25; -/** A full document is 5,000 rows of well under 2 KB each. */ -const HEAD_LOOKUP_MAX_RESPONSE_BYTES = 16_000_000; -/** - * Rows read for a fork's `owner:branch` head, which GitHub cannot filter by owner. A branch - * named like a busy default (`main`) is the case this bounds; the owner's own row is near the - * top because the newest come first. - */ -const OWNER_HEAD_SCAN_LIMIT = 100; - -class PullRequestsByHeadRead extends Request.Class< - { - readonly cwd: string; - readonly host: string; - readonly owner: string; - readonly name: string; - readonly headRefName: string; - readonly state: PullRequestListState; - readonly limit: number; - readonly allowReserve: boolean; - }, - ReadonlyArray, - GitHubRepositoryApiError -> {} - -/** One aliased `pullRequests` connection per lookup, each head and state passed as a variable. */ -function buildPullRequestsByHeadQuery( - lookups: ReadonlyArray>, -): { readonly document: string; readonly variables: Record } { - const variables: Record = {}; - const declarations: string[] = ["$owner: String!", "$name: String!"]; - const selections: string[] = []; - for (const [index, lookup] of lookups.entries()) { - variables[`h${index}`] = lookup.headRefName; - variables[`s${index}`] = GRAPHQL_STATES[lookup.state]; - declarations.push(`$h${index}: String!`, `$s${index}: [PullRequestState!]`); - // `gh pr list` orders the same way, so a head with more matches than the limit keeps the - // same rows. - selections.push( - ` h${index}: pullRequests(headRefName: $h${index}, states: $s${index}, first: ${lookup.limit}, orderBy: { field: CREATED_AT, direction: DESC }) { nodes { ${PULL_REQUEST_NODE_SELECTION} } }`, - ); - } - return { - document: `query PullRequestsByHead(${declarations.join(", ")}) {\n repository(owner: $owner, name: $name) {\n${selections.join("\n")}\n }\n}`, - variables, - }; -} - -const PULL_REQUEST_BY_NUMBER_QUERY = `query PullRequestByNumber($owner: String!, $name: String!, $number: Int!) { - repository(owner: $owner, name: $name) { - pullRequest(number: $number) { ${PULL_REQUEST_NODE_SELECTION} } - } -}`; - -const decodePullRequestsByHead = decodeJsonResult( - Schema.Struct({ - data: Schema.Struct({ - repository: Schema.NullOr( - Schema.Record( - Schema.String, - Schema.NullOr(Schema.Struct({ nodes: Schema.Array(Schema.Unknown) })), - ), - ), - }), - }), -); - -const decodePullRequestByNumber = decodeJsonResult( - Schema.Struct({ - data: Schema.Struct({ - repository: Schema.NullOr(Schema.Struct({ pullRequest: Schema.NullOr(Schema.Unknown) })), - }), - }), -); - -/** A repository on a GitHub host: the API it is read through, and its owner and name. */ -export interface GitHubRepositoryLocator { - readonly host: string; - readonly owner: string; - readonly name: string; -} - -/** `owner/name` or `host/owner/name`, as `gh --repo` and GH_REPO take them. */ -function parseGitHubRepositorySelector( - selector: string, - defaultHost: string, -): GitHubRepositoryLocator | null { - const trimmed = selector.trim().replace(/\.git$/i, ""); - if (/^https?:\/\//i.test(trimmed)) { - try { - const url = new URL(trimmed); - const [owner, name, ...rest] = url.pathname.split("/").filter(Boolean); - return owner && name && rest.length === 0 - ? { host: url.host.toLowerCase(), owner, name } - : null; - } catch { - return null; - } - } - const parts = trimmed.split("/").filter(Boolean); - if (parts.length === 2) return { host: defaultHost, owner: parts[0]!, name: parts[1]! }; - if (parts.length === 3) - return { host: parts[0]!.toLowerCase(), owner: parts[1]!, name: parts[2]! }; - return null; -} - -/** - * A pull request reference the way `gh pr view` takes one: a number (`#7` too), a pull request - * URL, or a branch name. - */ -function parsePullRequestReference( - reference: string, -): - | { readonly kind: "number"; readonly number: number } - | { readonly kind: "url"; readonly locator: GitHubRepositoryLocator; readonly number: number } - | { readonly kind: "branch"; readonly headSelector: string } { - const trimmed = reference.trim(); - const numbered = /^#?([1-9]\d*)$/.exec(trimmed); - if (numbered) return { kind: "number", number: Number(numbered[1]) }; - if (/^https?:\/\//i.test(trimmed)) { - try { - const url = new URL(trimmed); - const match = /^\/([^/]+)\/([^/]+)\/pull\/([1-9]\d*)(?:\/.*)?$/.exec(url.pathname); - if (match) { - return { - kind: "url", - locator: { host: url.host.toLowerCase(), owner: match[1]!, name: match[2]! }, - number: Number(match[3]), - }; - } - } catch { - // Not a URL after all; read it as a branch. - } - } - return { kind: "branch", headSelector: trimmed }; -} - -/** - * The GitHub host a remote URL is served from, or null for a remote that is not GitHub. An SSH - * alias (`git@github-work:owner/repo`) names no API host of its own; it is read through - * `github.com`, which is what such an alias almost always stands for (issue #6198). - */ -export function gitHubApiHostForRemote(remoteUrl: string): string | null { - const provider = detectSourceControlProviderFromRemoteUrl(remoteUrl); - if (provider === null) return null; - const host = new URL(provider.baseUrl).host.toLowerCase(); - // A dotless SSH host is an alias from ~/.ssh/config, never a real API host. - if (isSshRemoteUrl(remoteUrl) && !host.includes(".")) { - return host.includes("github") ? "github.com" : null; - } - return provider.kind === "github" ? host : null; -} - -/** A caller's host hint, read the way a remote's host is: a dotless alias is not an API host. */ -function apiHostForHint(host: string): string { - const normalized = host.toLowerCase(); - return !normalized.includes(".") && normalized.includes("github") ? "github.com" : normalized; -} - -/** The local branch a pull request checks out into, the way `gh pr checkout` names it. */ -export function pullRequestCheckoutBranchName(input: { - readonly headRefName: string; - readonly headOwner: string | null; - readonly isCrossRepository: boolean; - readonly defaultBranch: string | null; -}): string { - // gh prefixes the owner only where the fork's branch would take over the default branch's - // name, which is the one collision every fork pull request from `main` would hit. - return input.isCrossRepository && - input.headOwner !== null && - input.defaultBranch !== null && - input.headRefName === input.defaultBranch - ? `${input.headOwner}/${input.headRefName}` - : input.headRefName; -} - -/** @public Service construction is part of the canonical Effect module API. */ -export const make = Effect.gen(function* () { - const process = yield* VcsProcess.VcsProcess; - const environment = yield* HostProcessEnvironment; - const api = yield* GitHubApi.GitHubApi; - const git = yield* GitVcsDriver.GitVcsDriver; - const fileSystem = yield* FileSystem.FileSystem; - - const gitRead = (cwd: string, args: ReadonlyArray) => - process.run({ - operation: "GitHubRepositoryApi.resolveRepository", - command: "git", - args, - cwd, - allowNonZeroExit: true, - timeoutMs: 5_000, - }); - - const commandFailure = (cwd: string, detail: string) => - new GitHubRepositoryCommandError({ cwd, cause: new Error(detail) }); - - /** - * The repository `gh` would act on in `cwd`: GH_REPO, else the remote `gh` would pick, else - * the remote the caller resolved the provider from, else the best-ranked GitHub remote. - */ - const resolveRepository = Effect.fn("GitHubRepositoryApi.resolveRepository")(function* (input: { - readonly cwd: string; - readonly host?: string | undefined; - }) { - const envRepository = environment.GH_REPO?.trim(); - const defaultHost = (input.host ?? environment.GH_HOST ?? "github.com").toLowerCase(); - if (envRepository) { - const locator = parseGitHubRepositorySelector(envRepository, defaultHost); - if (locator !== null) return locator; - } - const [remotes, resolved] = yield* Effect.all([ - gitRead(input.cwd, ["remote", "-v"]), - gitRead(input.cwd, ["config", "--get-regexp", "^remote\\..*\\.gh-resolved$"]), - ]).pipe(Effect.orElseSucceed(() => [null, null] as const)); - const remoteOutput = remotes?.exitCode === 0 ? remotes.stdout : ""; - const fetchRemotes = remoteOutput - .split("\n") - .map((line) => /^(\S+)\s+(\S+)\s+\(fetch\)$/u.exec(line.trim())) - .filter((match): match is RegExpExecArray => match !== null) - .map((match) => ({ - name: match[1]!, - url: match[2]!, - host: gitHubApiHostForRemote(match[2]!), - })); - const host = - (input.host === undefined ? undefined : apiHostForHint(input.host)) ?? - fetchRemotes.find((remote) => remote.name === "origin" && remote.host !== null)?.host ?? - fetchRemotes.find((remote) => remote.host !== null)?.host ?? - defaultHost; - const selected = selectGitHubBaseRepository({ - remotes: remoteOutput, - resolved: resolved?.exitCode === 0 ? resolved.stdout : "", - host, - }); - if (selected !== null) return { host, ...selected }; - // gh's own order without its prompt: upstream, github, origin, then the first remote, among - // the ones on this host (an SSH alias counts as its API host). - const rank = (name: string) => ["upstream", "github", "origin"].indexOf(name.toLowerCase()); - const candidates = fetchRemotes - .filter((remote) => remote.host === host) - .toSorted((left, right) => { - const l = rank(left.name); - const r = rank(right.name); - return (l === -1 ? 99 : l) - (r === -1 ? 99 : r); - }); - for (const remote of candidates) { - const [owner, name, ...rest] = normalizeGitRemoteUrl(remote.url).split("/").slice(1); - if (owner && name && rest.length === 0) return { host, owner, name }; - } - return yield* commandFailure( - input.cwd, - `No GitHub repository on ${host} was found among this checkout's git remotes.`, - ); - }); - - const graphqlJson = ( - cwd: string, - input: GitHubApi.GitHubGraphQlInput, - decode: (raw: string) => Result.Result, - onDecodeFailure: (cause: unknown) => GitHubRepositoryApiError, - ) => - api.graphql(input).pipe( - Effect.mapError((error) => fromGitHubApiError(cwd, error)), - Effect.flatMap((raw) => { - const decoded = decode(raw); - return Result.isSuccess(decoded) - ? Effect.succeed(decoded.success) - : Effect.fail(onDecodeFailure(decoded.failure)); - }), - ); - - const rest = (cwd: string, input: GitHubApi.GitHubRestInput) => - api.rest(input).pipe(Effect.mapError((error) => fromGitHubApiError(cwd, error))); - - /** Pull requests whose head branch is `headRefName` on one repository. */ - const readPullRequestsByHead = (input: { - readonly cwd: string; - readonly locator: GitHubRepositoryLocator; - readonly lookups: ReadonlyArray< - Pick - >; - readonly allowReserve: boolean; - readonly onDecodeFailure: (cause: unknown) => GitHubRepositoryApiError; - }) => { - const query = buildPullRequestsByHeadQuery(input.lookups); - return graphqlJson( - input.cwd, - { - host: input.locator.host, - operation: "listPullRequestsByHead", - query: query.document, - variables: { owner: input.locator.owner, name: input.locator.name, ...query.variables }, - allowReserve: input.allowReserve, - // Up to 50 heads of 100 rows each. A default branch such as `main` can match a hundred - // fork pull requests, so the usual cap would cut the answer short. - maxResponseBytes: HEAD_LOOKUP_MAX_RESPONSE_BYTES, - }, - decodePullRequestsByHead, - input.onDecodeFailure, - ).pipe( - Effect.flatMap((decoded) => - decoded.data.repository === null - ? Effect.fail( - new GitHubPullRequestNotFoundError({ - cwd: input.cwd, - cause: new Error("The repository could not be read."), - }), - ) - : Effect.succeed(decoded.data.repository), - ), - ); - }; - - const headResolver = RequestResolver.makeGrouped({ - key: ({ request, context }) => - [ - request.host, - request.owner, - request.name, - String(request.allowReserve), - Context.getOrElse(context, GitHubApi.PinnedGitHubCredential, () => null) - ?.credentialFingerprint ?? "", - Context.getOrElse(context, SourceControlRateLimit.CredentialScope, () => ""), - ].join("\0"), - resolver: (entries) => { - const [first] = entries; - const { cwd, host, owner, name, allowReserve } = first.request; - return readPullRequestsByHead({ - cwd, - locator: { host, owner, name }, - lookups: entries.map((entry) => entry.request), - allowReserve, - onDecodeFailure: (cause) => new GitHubChangeRequestListDecodeError({ cwd, cause }), - }).pipe( - Effect.map((aliases) => { - for (const [index, entry] of entries.entries()) { - const alias = aliases[`h${index}`]; - entry.completeUnsafe( - Exit.succeed(alias == null ? [] : decodeGitHubPullRequestEntries(alias.nodes)), - ); - } - }), - Effect.catchCause((cause) => - Effect.sync(() => { - for (const entry of entries) entry.completeUnsafe(Exit.failCause(cause)); - }), - ), - ); - }, - }).pipe(RequestResolver.batchN(HEAD_LOOKUPS_PER_DOCUMENT)); - const interactiveHeadResolver = headResolver.pipe( - RequestResolver.setDelay(HEAD_LOOKUP_BATCH_WINDOW), - ); - const backgroundHeadResolver = headResolver.pipe( - RequestResolver.batchN(BACKGROUND_HEAD_LOOKUPS_PER_DOCUMENT), - RequestResolver.setDelay(BACKGROUND_HEAD_LOOKUP_BATCH_WINDOW), - ); - - const listByHead = Effect.fn("GitHubRepositoryApi.listByHead")(function* (input: { - readonly cwd: string; - readonly headSelector: string; - readonly state: PullRequestListState; - readonly limit: number; - readonly rateLimitHost?: string | undefined; - readonly allowReserve: boolean; - }) { - const locator = yield* resolveRepository({ cwd: input.cwd, host: input.rateLimitHost }); - const limit = Math.min(Math.max(Math.trunc(input.limit), 1), 100); - // `owner:branch` names a fork's branch. GitHub filters on the branch name only, so the - // owner is matched on the rows it returns. - const ownerMatch = /^([^:/\s]+):(.+)$/u.exec(input.headSelector.trim()); - const headRefName = ownerMatch?.[2] ?? input.headSelector.trim(); - const rows = yield* Effect.request( - new PullRequestsByHeadRead({ - cwd: input.cwd, - ...locator, - headRefName, - state: input.state, - limit: ownerMatch ? OWNER_HEAD_SCAN_LIMIT : limit, - allowReserve: input.allowReserve, - }), - input.allowReserve ? interactiveHeadResolver : backgroundHeadResolver, - ); - if (!ownerMatch) return rows; - const headOwner = ownerMatch[1]!.toLowerCase(); - return rows - .filter((row) => row.headRepositoryOwnerLogin?.toLowerCase() === headOwner) - .slice(0, limit); - }); - - const toSummaries = (rows: ReadonlyArray) => - rows.map(pullRequestSummary); - - const readPullRequest = Effect.fn("GitHubRepositoryApi.readPullRequest")(function* (input: { - readonly cwd: string; - readonly reference: string; - readonly rateLimitHost?: string | undefined; - }) { - const parsed = parsePullRequestReference(input.reference); - if (parsed.kind === "branch") { - // `gh pr view ` prefers an open pull request, then the newest of any state. - const [open] = yield* listByHead({ - cwd: input.cwd, - headSelector: parsed.headSelector, - state: "open", - limit: 1, - rateLimitHost: input.rateLimitHost, - allowReserve: true, - }); - const found = - open ?? - (yield* listByHead({ - cwd: input.cwd, - headSelector: parsed.headSelector, - state: "all", - limit: 1, - rateLimitHost: input.rateLimitHost, - allowReserve: true, - }))[0]; - if (found === undefined) { - return yield* new GitHubPullRequestNotFoundError({ - cwd: input.cwd, - cause: new Error("No pull request has this head branch."), - }); - } - return found; - } - const locator = - parsed.kind === "url" - ? parsed.locator - : yield* resolveRepository({ cwd: input.cwd, host: input.rateLimitHost }); - const decodeFailure = (cause: unknown) => - new GitHubPullRequestDecodeError({ cwd: input.cwd, cause }); - const decoded = yield* graphqlJson( - input.cwd, - { - host: locator.host, - operation: "getPullRequest", - query: PULL_REQUEST_BY_NUMBER_QUERY, - variables: { owner: locator.owner, name: locator.name, number: parsed.number }, - allowReserve: true, - }, - decodePullRequestByNumber, - decodeFailure, - ); - const node = decoded.data.repository?.pullRequest; - if (node == null) { - return yield* new GitHubPullRequestNotFoundError({ - cwd: input.cwd, - cause: new Error("The pull request does not exist."), - }); - } - const [record] = decodeGitHubPullRequestEntries([node]); - if (record === undefined) return yield* decodeFailure(new Error("Malformed pull request.")); - return record; - }); - - const readRepository = Effect.fn("GitHubRepositoryApi.readRepository")(function* ( - cwd: string, - locator: GitHubRepositoryLocator, - ) { - const response = yield* rest(cwd, { - host: locator.host, - operation: "getRepository", - path: `repos/${encodeURIComponent(locator.owner)}/${encodeURIComponent(locator.name)}`, - allowReserve: true, - }); - const decoded = decodeRawRepository(response.body); - if (Result.isFailure(decoded)) { - return yield* new GitHubRepositoryDecodeError({ - cwd, - cause: decoded.failure, - }); - } - return decoded.success; - }); - - const readViewerLogin = Effect.fn("GitHubRepositoryApi.readViewerLogin")(function* ( - cwd: string, - host: string, - ) { - const response = yield* rest(cwd, { host, operation: "getViewer", path: "user" }); - const decoded = decodeViewerLogin(response.body); - if (Result.isFailure(decoded)) { - return yield* new GitHubRepositoryCommandError({ cwd, cause: decoded.failure }); - } - return decoded.success.login; - }); - - const gitFailure = (cwd: string) => (cause: unknown) => - new GitHubRepositoryCommandError({ cwd, cause }); - - const runGit = (cwd: string, operation: string, args: ReadonlyArray) => - git.execute({ operation: `GitHubRepositoryApi.checkoutPullRequest.${operation}`, cwd, args }); - - /** - * `gh pr checkout` in plain git: the head branch is fetched from the remote that holds it (a - * fork gets a remote of its own), checked out under the name gh would give it, and set to - * track the head. A head branch that is gone is read from the base's `refs/pull//head`. - * An existing branch fast-forwards, or with `force` is reset to the pull request. - */ - const checkoutPullRequest: GitHubRepositoryApi["Service"]["checkoutPullRequest"] = Effect.fn( - "GitHubRepositoryApi.checkoutPullRequest", - )(function* (input) { - const reference = parsePullRequestReference(input.reference); - const pullRequest = yield* readPullRequest({ cwd: input.cwd, reference: input.reference }); - const base = - reference.kind === "url" ? reference.locator : yield* resolveRepository({ cwd: input.cwd }); - const baseNameWithOwner = `${base.owner}/${base.name}`.toLowerCase(); - const headNameWithOwner = pullRequest.headRepositoryNameWithOwner ?? null; - const isCrossRepository = - pullRequest.isCrossRepository ?? - (headNameWithOwner !== null && headNameWithOwner.toLowerCase() !== baseNameWithOwner); - const headOwner = - pullRequest.headRepositoryOwnerLogin ?? headNameWithOwner?.split("/")[0] ?? null; - - const remotes = yield* gitRead(input.cwd, ["remote", "-v"]).pipe( - Effect.map((result) => (result.exitCode === 0 ? result.stdout : "")), - Effect.mapError(gitFailure(input.cwd)), - ); - const remoteFor = (nameWithOwner: string) => - remotes - .split("\n") - .map((line) => /^(\S+)\s+(\S+)\s+\(fetch\)$/u.exec(line.trim())) - .find( - (match) => - match !== null && - normalizeGitRemoteUrl(match[2]!).split("/").slice(1).join("/") === - nameWithOwner.toLowerCase(), - )?.[1] ?? null; - const baseRemote = Effect.suspend(() => { - const known = remoteFor(baseNameWithOwner); - return known === null ? git.resolvePrimaryRemoteName(input.cwd) : Effect.succeed(known); - }); - // A fork's branch named like the base's default branch is checked out under the owner's - // prefix. Without the default branch that collision cannot be ruled out, and the checkout - // would reset the local default branch to the fork's commit, so it fails instead. - const defaultBranch = isCrossRepository - ? yield* readRepository(input.cwd, base).pipe( - Effect.map((repository) => repository.default_branch ?? null), - ) - : null; - const localBranch = pullRequestCheckoutBranchName({ - headRefName: pullRequest.headRefName, - headOwner, - isCrossRepository, - defaultBranch, - }); - - /** The remote the head branch lives on; a fork the checkout does not know yet is added. */ - const headRemote = Effect.gen(function* () { - if (!isCrossRepository) return yield* baseRemote; - if (headNameWithOwner === null) return yield* commandFailure(input.cwd, "The fork is gone."); - const known = remoteFor(headNameWithOwner); - if (known !== null) return known; - const [owner, name] = headNameWithOwner.split("/"); - const fork = yield* readRepository(input.cwd, { - host: base.host, - owner: owner!, - name: name!, - }); - const originUrl = yield* git.readConfigValue(input.cwd, "remote.origin.url"); - return yield* git.ensureRemote({ - cwd: input.cwd, - preferredName: headOwner ?? "fork", - url: originUrl !== null && isSshRemoteUrl(originUrl) ? fork.ssh_url : fork.html_url, - }); - }); - - const exists = (yield* git - .listLocalBranchNames(input.cwd) - .pipe(Effect.mapError(gitFailure(input.cwd)))).includes(localBranch); - - // The commit to check out, fetched from the head branch where it still exists. - const target = yield* Effect.gen(function* () { - const remoteName = yield* headRemote; - yield* git.fetchRemoteTrackingBranch({ - cwd: input.cwd, - remoteName, - remoteBranch: pullRequest.headRefName, - }); - return { - ref: `refs/remotes/${remoteName}/${pullRequest.headRefName}`, - upstream: { remoteName, remoteBranch: pullRequest.headRefName }, - }; - }).pipe( - Effect.catch(() => - Effect.gen(function* () { - yield* runGit(input.cwd, "fetchPullRef", [ - "fetch", - "--quiet", - "--no-tags", - yield* baseRemote, - `refs/pull/${pullRequest.number}/head`, - ]); - const { commitSha } = yield* git.resolveCommit({ - cwd: input.cwd, - revision: "FETCH_HEAD", - }); - return { ref: commitSha, upstream: null }; - }), - ), - Effect.mapError(gitFailure(input.cwd)), - ); - - yield* Effect.gen(function* () { - if (!exists) yield* runGit(input.cwd, "branch", ["branch", localBranch, target.ref]); - yield* Effect.scoped(git.switchRef({ cwd: input.cwd, refName: localBranch })); - if (exists) { - yield* runGit( - input.cwd, - "sync", - input.force === true - ? ["reset", "--hard", "--quiet", target.ref] - : ["merge", "--ff-only", "--quiet", target.ref], - ); - } - // Tracking is set once the branch is the pull request's, so a sync that fails leaves an - // existing branch's upstream as it was. - if (target.upstream !== null) { - yield* git.setBranchUpstream({ cwd: input.cwd, branch: localBranch, ...target.upstream }); - } - }).pipe(Effect.mapError(gitFailure(input.cwd))); - }); - - return GitHubRepositoryApi.of({ - listPullRequestsByHead: (input) => - AllowGitHubReserve.pipe( - Effect.flatMap((allowReserve) => listByHead({ ...input, allowReserve })), - ), - listOpenPullRequests: (input) => - listByHead({ - cwd: input.cwd, - headSelector: input.headSelector, - state: "open", - limit: input.limit ?? 1, - rateLimitHost: input.rateLimitHost, - allowReserve: true, - }).pipe(Effect.map(toSummaries)), - getPullRequest: (input) => readPullRequest(input).pipe(Effect.map(pullRequestSummary)), - getRepositoryCloneUrls: (input) => - Effect.gen(function* () { - const fallbackHost = (yield* resolveRepository({ cwd: input.cwd }).pipe( - Effect.map((locator) => locator.host), - Effect.orElseSucceed(() => environment.GH_HOST ?? "github.com"), - )).toLowerCase(); - const locator = parseGitHubRepositorySelector(input.repository, fallbackHost); - if (locator === null) { - return yield* commandFailure(input.cwd, "Repositories are named owner/name."); - } - return repositoryCloneUrls(yield* readRepository(input.cwd, locator)); - }), - createRepository: (input) => - Effect.gen(function* () { - const locator = parseGitHubRepositorySelector( - input.repository, - (environment.GH_HOST ?? "github.com").toLowerCase(), - ); - const viewer = locator === null ? null : yield* readViewerLogin(input.cwd, locator.host); - const owner = locator?.owner ?? viewer; - const name = locator?.name ?? input.repository.trim(); - const host = locator?.host ?? (environment.GH_HOST?.trim().toLowerCase() || "github.com"); - const isViewer = viewer !== null && owner?.toLowerCase() === viewer.toLowerCase(); - const response = yield* rest(input.cwd, { - host, - operation: "createRepository", - method: "POST", - path: - isViewer || owner === null ? "user/repos" : `orgs/${encodeURIComponent(owner)}/repos`, - body: { name, private: input.visibility === "private" }, - }); - const decoded = decodeRawRepository(response.body); - if (Result.isFailure(decoded)) { - return yield* new GitHubRepositoryDecodeError({ - cwd: input.cwd, - cause: decoded.failure, - }); - } - return repositoryCloneUrls(decoded.success); - }), - createPullRequest: (input) => - Effect.gen(function* () { - const locator = yield* resolveRepository({ cwd: input.cwd }); - const body = yield* fileSystem - .readFileString(input.bodyFile) - .pipe(Effect.mapError(gitFailure(input.cwd))); - yield* rest(input.cwd, { - host: locator.host, - operation: "createPullRequest", - method: "POST", - path: `repos/${encodeURIComponent(locator.owner)}/${encodeURIComponent(locator.name)}/pulls`, - // `owner:branch` is how the REST API takes a fork's head, the same as `gh --head`. - // gh allows maintainer edits unless told otherwise; the API's default is not documented. - body: { - base: input.baseBranch, - head: input.headSelector, - title: input.title, - body, - maintainer_can_modify: true, - }, - }); - }), - getDefaultBranch: (input) => - Effect.gen(function* () { - const locator = yield* resolveRepository({ cwd: input.cwd, host: input.rateLimitHost }); - const repository = yield* readRepository(input.cwd, locator); - const branch = repository.default_branch?.trim() ?? ""; - return branch.length > 0 ? branch : null; - }), - checkoutPullRequest, - }); -}); - -export const layer = Layer.effect(GitHubRepositoryApi, make).pipe( - Layer.provideMerge(GitHubApi.layer), - Layer.provideMerge(GitHubCredentials.layer), - Layer.provideMerge(GitHubGraphQlBudget.layer), - Layer.provideMerge(SourceControlRateLimit.layer), -); diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts index 89398b634786..b70bfff78f6b 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts @@ -1,17 +1,23 @@ -import { assert, it } from "@effect/vitest"; -import * as DateTime from "effect/DateTime"; +import { assert, describe, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import { ChildProcessSpawner } from "effect/process"; +import * as TestClock from "effect/testing/TestClock"; +import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as GitHubApi from "./GitHubApi.ts"; -import * as GitHubRepositoryApi from "./GitHubRepositoryApi.ts"; +import * as GitHubCredentials from "./GitHubCredentials.ts"; import { parseGitHubAuthStatus } from "./gitHubAuthStatus.ts"; import * as GitHubSourceControlProvider from "./GitHubSourceControlProvider.ts"; +const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); + const processResult = ( stdout: string, options?: { @@ -26,191 +32,611 @@ const processResult = ( stderrTruncated: false, }); -function makeProvider( - github: Partial, - api: Partial = {}, -) { - return GitHubSourceControlProvider.make.pipe( - Effect.provide( - Layer.merge( - Layer.mock(GitHubRepositoryApi.GitHubRepositoryApi)(github), - Layer.mock(GitHubApi.GitHubApi)(api), - ), - ), - ); -} +const processOutput = (stdout: string, exitCode = 0): VcsProcess.VcsProcessOutput => ({ + exitCode: ChildProcessSpawner.ExitCode(exitCode), + stdout, + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, +}); -const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); +const remotesOutput = (...entries: ReadonlyArray) => + entries + .flatMap(([name, url]) => [`${name}\t${url} (fetch)`, `${name}\t${url} (push)`]) + .join("\n"); -const restResponse = (body: string): GitHubApi.GitHubRestResponse => ({ - status: 200, +const restResponse = (body: unknown, status = 200): GitHubApi.GitHubRestResponse => ({ + status, headers: {}, - body, + body: body === undefined ? "" : encodeJson(body), truncated: false, invalidUtf8: false, }); -it.effect("maps GitHub PR summaries into provider-neutral change requests", () => - Effect.gen(function* () { - const provider = yield* makeProvider({ - getPullRequest: () => - Effect.succeed({ - number: 42, - title: "Add GitHub provider", - url: "https://github.com/pingdotgg/t3code/pull/42", - baseRefName: "main", - headRefName: "feature/source-control", - state: "open", - isCrossRepository: true, - headRepositoryNameWithOwner: "fork/t3code", - headRepositoryOwnerLogin: "fork", - }), +const node = (number: number, headRefName: string, owner = "acme") => ({ + number, + title: `PR ${number}`, + url: `https://github.com/acme/web/pull/${number}`, + baseRefName: "main", + headRefName, + state: "OPEN", + isCrossRepository: owner !== "acme", + updatedAt: "2026-01-02T00:00:00Z", + headRepository: { name: "web", nameWithOwner: `${owner}/web` }, + headRepositoryOwner: { login: owner }, +}); + +/** + * The provider over a mocked GitHubApi, git driver and process. `remotes` is what + * `git remote -v` prints; `git` records every driver call. + */ +function harness(input: { + readonly remotes: string; + readonly api: Partial; + readonly localBranches?: ReadonlyArray; + /** Fails the local branch listing, the first git read a checkout makes. */ + readonly gitFailure?: unknown; +}) { + const git: Array = []; + const record = + (name: string, value: A) => + (args: unknown) => + Effect.sync(() => { + git.push([name, args]); + return value; + }); + const driver = Layer.mock(GitVcsDriver.GitVcsDriver)({ + execute: (args) => + Effect.sync(() => { + git.push(["execute", args.args]); + return processOutput(""); + }), + resolvePrimaryRemoteName: () => Effect.succeed("origin"), + readConfigValue: () => Effect.succeed("git@github.com:acme/web.git"), + ensureRemote: (args) => + Effect.sync(() => { + git.push(["ensureRemote", args]); + return args.preferredName; + }), + fetchRemoteTrackingBranch: (args) => record("fetchRemoteTrackingBranch", undefined)(args), + setBranchUpstream: (args) => record("setBranchUpstream", undefined)(args), + switchRef: (args) => record("switchRef", { refName: args.refName })(args) as never, + listLocalBranchNames: () => + input.gitFailure === undefined + ? Effect.succeed([...(input.localBranches ?? [])]) + : Effect.fail(input.gitFailure as never), + resolveCommit: () => Effect.succeed({ commitSha: "abc123" }), + }); + const process = Layer.mock(VcsProcess.VcsProcess)({ + run: (args) => + Effect.succeed( + args.args[0] === "remote" ? processOutput(input.remotes) : processOutput("", 1), + ), + }); + const layer = Layer.mergeAll( + driver, + process, + Layer.mock(GitHubApi.GitHubApi)(input.api), + NodeServices.layer, + ); + return { layer, git }; +} + +/** The provider context for a checkout whose remote is on `host`. */ +/** The provider over a mocked GitHubApi, for reads that never touch the checkout. */ +const makeProvider = (api: Partial) => + GitHubSourceControlProvider.make.pipe(Effect.provide(harness({ remotes: "", api }).layer)); + +const githubContext = (host: string) => ({ + provider: { kind: "github" as const, name: "GitHub", baseUrl: `https://${host}` }, + remoteName: "origin", + remoteUrl: `git@${host}:acme/web.git`, +}); + +describe("GitHubSourceControlProvider repository resolution", () => { + it.effect("reads the repository gh would pick from the remotes", () => { + const paths: string[] = []; + const { layer } = harness({ + remotes: remotesOutput( + ["origin", "git@github.com:me/web.git"], + ["upstream", "https://github.com/acme/web.git"], + ), + api: { + rest: (input) => + Effect.sync(() => { + paths.push(`${input.host} ${input.path}`); + return restResponse({ + full_name: "acme/web", + html_url: "https://github.com/acme/web", + ssh_url: "git@github.com:acme/web.git", + default_branch: "trunk", + }); + }), + }, }); + return Effect.gen(function* () { + const gh = yield* GitHubSourceControlProvider.make; + assert.strictEqual(yield* gh.getDefaultBranch({ cwd: "/repo" }), "trunk"); + assert.deepStrictEqual(paths, ["github.com repos/acme/web"]); + }).pipe(Effect.provide(layer)); + }); - const changeRequest = yield* provider.getChangeRequest({ - cwd: "/repo", - reference: "42", + it.effect("reads an SSH alias remote through github.com", () => { + const hosts: string[] = []; + const { layer } = harness({ + remotes: remotesOutput(["origin", "git@github:acme/web.git"]), + api: { + rest: (input) => + Effect.sync(() => { + hosts.push(`${input.host} ${input.path}`); + return restResponse({ + full_name: "acme/web", + html_url: "https://github.com/acme/web", + ssh_url: "git@github.com:acme/web.git", + default_branch: "main", + }); + }), + }, }); + return Effect.gen(function* () { + const gh = yield* GitHubSourceControlProvider.make; + yield* gh.getDefaultBranch({ cwd: "/repo" }); + // A provider's host hint for the same alias (`github` here) resolves the same way. + yield* gh.getDefaultBranch({ cwd: "/repo", context: githubContext("github") }); + assert.deepStrictEqual(hosts, ["github.com repos/acme/web", "github.com repos/acme/web"]); + }).pipe(Effect.provide(layer)); + }); - assert.deepStrictEqual(changeRequest, { - provider: "github", - number: 42, - title: "Add GitHub provider", - url: "https://github.com/pingdotgg/t3code/pull/42", - baseRefName: "main", - headRefName: "feature/source-control", - state: "open", - closedAt: null, - mergedAt: null, - updatedAt: Option.none(), - isCrossRepository: true, - headRepositoryNameWithOwner: "fork/t3code", - headRepositoryOwnerLogin: "fork", + it.effect("names a missing repository rather than a pull request", () => { + const { layer } = harness({ + remotes: remotesOutput(["origin", "git@github.com:acme/gone.git"]), + api: { + rest: (input) => + Effect.fail( + new GitHubApi.GitHubApiNotFoundError({ host: input.host, operation: input.operation }), + ), + }, }); - }), -); + return Effect.gen(function* () { + const gh = yield* GitHubSourceControlProvider.make; + const error = yield* gh.getDefaultBranch({ cwd: "/repo" }).pipe(Effect.flip); + assert.include(error.detail, "Repository not found"); + }).pipe(Effect.provide(layer)); + }); -it.effect("adds safe request context while retaining GitHub API causes", () => - Effect.gen(function* () { - const cause = new GitHubRepositoryApi.GitHubPullRequestNotFoundError({ - cwd: "/repo", - cause: new Error("raw upstream detail that should remain in the cause"), + it.effect("fails clearly when no remote is on GitHub", () => { + const { layer } = harness({ + remotes: remotesOutput(["origin", "git@gitlab.com:a/b.git"]), + api: {}, }); - const provider = yield* makeProvider({ - getPullRequest: () => Effect.fail(cause), + return Effect.gen(function* () { + const gh = yield* GitHubSourceControlProvider.make; + const error = yield* gh.getDefaultBranch({ cwd: "/repo" }).pipe(Effect.flip); + assert.strictEqual(error._tag, "SourceControlProviderError"); + assert.include(error.detail, "No GitHub repository"); + }).pipe(Effect.provide(layer)); + }); +}); + +describe("GitHubSourceControlProvider change request lookups by head", () => { + const remotes = remotesOutput(["origin", "git@github.com:acme/web.git"]); + + it.effect("reads a background sweep's staggered heads in one GraphQL document", () => { + const documents: Array = []; + const { layer } = harness({ + remotes, + api: { + graphql: (input) => + Effect.sync(() => { + documents.push(input); + return encodeJson({ + data: { + repository: { + h0: { nodes: [{ ...node(7, "feature/a"), headRefOid: "a".repeat(40) }] }, + h1: { nodes: [] }, + }, + }, + }); + }), + }, }); + return Effect.gen(function* () { + const gh = yield* GitHubSourceControlProvider.make; + const lookup = (headSelector: string) => + gh + .listChangeRequests({ + cwd: "/repo", + headSelector, + state: "all", + limit: 100, + context: githubContext("github.com"), + }) + .pipe(Effect.forkChild); + // Each branch's own git reads come first, so a sweep's lookups arrive spread out. + const firstLookup = yield* lookup("feature/a"); + yield* TestClock.adjust("200 millis"); + const secondLookup = yield* lookup("feature/b"); + yield* TestClock.adjust("300 millis"); + const first = yield* Fiber.join(firstLookup); + const second = yield* Fiber.join(secondLookup); + assert.deepStrictEqual( + first?.map((pr) => pr.number), + [7], + ); + assert.strictEqual(first?.[0]?.headSha, "a".repeat(40)); + assert.deepStrictEqual(second, []); + assert.strictEqual(documents.length, 1); + assert.include(documents[0]!.query, "headRefOid"); + assert.deepStrictEqual(documents[0]!.variables, { + owner: "acme", + name: "web", + h0: "feature/a", + s0: ["OPEN", "CLOSED", "MERGED"], + h1: "feature/b", + s1: ["OPEN", "CLOSED", "MERGED"], + }); + }).pipe(Effect.provide(layer)); + }); - const error = yield* provider - .getChangeRequest({ - cwd: "/repo", - reference: "https://user:secret@github.com/pingdotgg/t3code/pull/42?token=secret#diff", - }) - .pipe(Effect.flip); + it.effect("caps a background document at twenty-five heads", () => { + const headCounts: Array = []; + const { layer } = harness({ + remotes, + api: { + graphql: (input) => + Effect.sync(() => { + const heads = Object.keys(input.variables ?? {}).filter((key) => /^h\d+$/.test(key)); + headCounts.push(heads.length); + return encodeJson({ + data: { repository: Object.fromEntries(heads.map((key) => [key, { nodes: [] }])) }, + }); + }), + }, + }); + return Effect.gen(function* () { + const gh = yield* GitHubSourceControlProvider.make; + const lookups = yield* Effect.all( + Array.from({ length: 26 }, (_, index) => + gh.listChangeRequests({ + cwd: "/repo", + headSelector: `feature/${index}`, + state: "all", + limit: 100, + context: githubContext("github.com"), + }), + ), + { concurrency: "unbounded" }, + ).pipe(Effect.forkChild); + yield* TestClock.adjust("500 millis"); + yield* Fiber.join(lookups); + assert.deepStrictEqual( + headCounts.toSorted((a, b) => a - b), + [1, 25], + ); + }).pipe(Effect.provide(layer)); + }); - assert.deepStrictEqual( - { - provider: error.provider, - operation: error.operation, - cwd: error.cwd, - reference: error.reference, - detail: error.detail, + it.effect("matches an owner:branch selector on the head owner", () => { + const { layer } = harness({ + remotes, + api: { + graphql: (input) => + Effect.succeed( + encodeJson({ + data: { + repository: { + h0: { + nodes: + input.variables?.h0 === "main" + ? [node(9, "main", "someone"), node(8, "main", "me"), node(7, "main", "me")] + : [], + }, + }, + }, + }), + ), }, - { - provider: "github", - operation: "getChangeRequest", + }); + return Effect.gen(function* () { + const gh = yield* GitHubSourceControlProvider.make; + const open = yield* gh + .listChangeRequests({ cwd: "/repo", headSelector: "me:main", state: "open", limit: 1 }) + .pipe(Effect.forkChild); + yield* TestClock.adjust("50 millis"); + assert.deepStrictEqual( + (yield* Fiber.join(open)).map((pr) => pr.number), + [8], + ); + }).pipe(Effect.provide(layer)); + }); + + it.effect("maps API failures onto the errors callers handle", () => { + const { layer } = harness({ + remotes, + api: { + graphql: (input) => + Effect.fail( + input.variables?.h0 === "missing" + ? new GitHubCredentials.GitHubCliMissingError({ host: "github.com" }) + : new GitHubApi.GitHubApiRateLimitError({ + host: "github.com", + operation: "x", + retryAt: 123, + }), + ), + }, + }); + return Effect.gen(function* () { + const gh = yield* GitHubSourceControlProvider.make; + const read = (headSelector: string) => + gh + .listChangeRequests({ cwd: "/repo", headSelector, state: "open", limit: 1 }) + .pipe(Effect.flip, Effect.forkChild); + const missing = yield* read("missing"); + yield* TestClock.adjust("500 millis"); + assert.include((yield* Fiber.join(missing)).detail, "No GitHub credential on the server"); + const limited = yield* read("limited"); + yield* TestClock.adjust("500 millis"); + const error = yield* Fiber.join(limited); + assert.include(error.detail, "rate limit exceeded"); + assert.propertyVal(error.cause, "retryAt", 123); + }).pipe(Effect.provide(layer)); + }); +}); + +describe("GitHubSourceControlProvider.getChangeRequest", () => { + it.effect("reads a pull request by number, and by URL on its own repository", () => { + const variables: Array = []; + const { layer } = harness({ + remotes: remotesOutput(["origin", "git@github.com:acme/web.git"]), + api: { + graphql: (input) => + Effect.sync(() => { + variables.push(input.variables); + return encodeJson({ data: { repository: { pullRequest: node(42, "feature") } } }); + }), + }, + }); + return Effect.gen(function* () { + const gh = yield* GitHubSourceControlProvider.make; + assert.strictEqual( + (yield* gh.getChangeRequest({ cwd: "/repo", reference: "#42" })).number, + 42, + ); + yield* gh.getChangeRequest({ cwd: "/repo", - reference: "https://github.com/pingdotgg/t3code/pull/42", - detail: "Pull request not found. Check the PR number or URL and try again.", + reference: "https://github.com/other/thing/pull/42", + }); + assert.deepStrictEqual(variables, [ + { owner: "acme", name: "web", number: 42 }, + { owner: "other", name: "thing", number: 42 }, + ]); + }).pipe(Effect.provide(layer)); + }); + + it.effect("fails a missing pull request as not found", () => { + const { layer } = harness({ + remotes: remotesOutput(["origin", "git@github.com:acme/web.git"]), + api: { + graphql: () => Effect.succeed(encodeJson({ data: { repository: { pullRequest: null } } })), }, - ); - assert.strictEqual(error.cause, cause); - assert.equal(error.message.includes("raw upstream detail"), false); - }), -); + }); + return Effect.gen(function* () { + const gh = yield* GitHubSourceControlProvider.make; + const error = yield* gh.getChangeRequest({ cwd: "/repo", reference: "7" }).pipe(Effect.flip); + assert.include(error.detail, "Pull request not found"); + }).pipe(Effect.provide(layer)); + }); +}); -it.effect("lists change request history through the batched head lookup", () => - Effect.gen(function* () { - let lookup: - | Parameters[0] - | null = null; - const provider = yield* makeProvider({ - listPullRequestsByHead: (input) => { - lookup = input; - return Effect.succeed([ - { - number: 7, - title: "Merged work", - url: "https://enterprise.test/acme/web/pull/7", - baseRefName: "main", - headRefName: "feature/merged", - headSha: "a".repeat(40), - state: "merged", - mergedAt: "2026-01-01T00:00:00Z", - updatedAt: Option.some(DateTime.makeUnsafe("2026-01-02T00:00:00.000Z")), - }, - ]); +describe("GitHubSourceControlProvider writes", () => { + it.effect("creates a cross-repository pull request with an owner:branch head", () => { + const requests: Array = []; + const { layer } = harness({ + remotes: remotesOutput( + ["origin", "git@github.com:me/web.git"], + ["upstream", "git@github.com:acme/web.git"], + ), + api: { + rest: (input) => + Effect.sync(() => { + requests.push(input); + return restResponse({ number: 1 }, 201); + }), }, }); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const bodyFile = yield* fs.makeTempFileScoped({ suffix: ".md" }); + yield* fs.writeFileString(bodyFile, "Body"); + const gh = yield* GitHubSourceControlProvider.make; + yield* gh.createChangeRequest({ + cwd: "/repo", + baseRefName: "main", + headSelector: "me:feature", + title: "Title", + bodyFile, + }); + assert.strictEqual(requests[0]!.method, "POST"); + assert.strictEqual(requests[0]!.path, "repos/acme/web/pulls"); + assert.deepStrictEqual(requests[0]!.body, { + base: "main", + head: "me:feature", + title: "Title", + body: "Body", + maintainer_can_modify: true, + }); + }).pipe(Effect.provide(Layer.merge(layer, NodeServices.layer)), Effect.scoped); + }); - const changeRequests = yield* provider.listChangeRequests({ - cwd: "/repo", - context: { - provider: { kind: "github", name: "GitHub Enterprise", baseUrl: "https://enterprise.test" }, - remoteName: "origin", - remoteUrl: "https://enterprise.test/acme/web.git", + it.effect("creates a repository under an organization the viewer is not", () => { + const requests: Array = []; + const { layer } = harness({ + remotes: "", + api: { + rest: (input) => + Effect.sync(() => { + requests.push(`${input.method ?? "GET"} ${input.path}`); + return input.path === "user" + ? restResponse({ login: "me" }) + : restResponse({ + full_name: "acme/new", + html_url: "https://github.com/acme/new", + ssh_url: "git@github.com:acme/new.git", + }); + }), }, - headSelector: "feature/merged", - state: "all", - limit: 10, + }); + return Effect.gen(function* () { + const gh = yield* GitHubSourceControlProvider.make; + const urls = yield* gh.createRepository({ + cwd: "/repo", + repository: "acme/new", + visibility: "private", + }); + assert.deepStrictEqual(urls, { + nameWithOwner: "acme/new", + url: "https://github.com/acme/new", + sshUrl: "git@github.com:acme/new.git", + }); + assert.deepStrictEqual(requests, ["GET user", "POST orgs/acme/repos"]); + }).pipe(Effect.provide(layer)); + }); +}); + +describe("GitHubSourceControlProvider.checkoutChangeRequest", () => { + const repository = (fullName: string, defaultBranch = "main") => + restResponse({ + full_name: fullName, + html_url: `https://github.com/${fullName}`, + ssh_url: `git@github.com:${fullName}.git`, + default_branch: defaultBranch, }); - assert.deepStrictEqual(lookup, { - cwd: "/repo", - headSelector: "feature/merged", - state: "all", - limit: 10, - rateLimitHost: "enterprise.test", + it.effect("checks a same-repository pull request out from its head branch", () => { + const { layer, git } = harness({ + remotes: remotesOutput(["origin", "git@github.com:acme/web.git"]), + api: { + graphql: () => + Effect.succeed( + encodeJson({ data: { repository: { pullRequest: node(5, "feature/x") } } }), + ), + }, }); - assert.strictEqual(changeRequests[0]?.provider, "github"); - assert.strictEqual(changeRequests[0]?.state, "merged"); - assert.strictEqual(changeRequests[0]?.headSha, "a".repeat(40)); - assert.strictEqual(changeRequests[0]?.mergedAt, "2026-01-01T00:00:00Z"); - assert.deepStrictEqual( - changeRequests[0]?.updatedAt, - Option.some(DateTime.makeUnsafe("2026-01-02T00:00:00.000Z")), - ); - }), -); + return Effect.gen(function* () { + const gh = yield* GitHubSourceControlProvider.make; + yield* gh.checkoutChangeRequest({ cwd: "/repo", reference: "5" }); + assert.deepStrictEqual(git, [ + [ + "fetchRemoteTrackingBranch", + { cwd: "/repo", remoteName: "origin", remoteBranch: "feature/x" }, + ], + ["execute", ["branch", "feature/x", "refs/remotes/origin/feature/x"]], + ["switchRef", { cwd: "/repo", refName: "feature/x" }], + [ + "setBranchUpstream", + { cwd: "/repo", branch: "feature/x", remoteName: "origin", remoteBranch: "feature/x" }, + ], + ]); + }).pipe(Effect.provide(layer)); + }); -it.effect("creates GitHub PRs through provider-neutral input names", () => - Effect.gen(function* () { - let createInput: - | Parameters[0] - | null = null; - const provider = yield* makeProvider({ - createPullRequest: (input) => { - createInput = input; - return Effect.void; + it.effect("refuses a fork checkout when the base's default branch cannot be read", () => { + const { layer, git } = harness({ + remotes: remotesOutput(["origin", "git@github.com:acme/web.git"]), + localBranches: ["main"], + api: { + graphql: () => + Effect.succeed( + encodeJson({ data: { repository: { pullRequest: node(6, "main", "someone") } } }), + ), + rest: () => + Effect.fail( + new GitHubApi.GitHubApiRequestError({ + host: "github.com", + operation: "x", + cause: "offline", + }), + ), }, }); + return Effect.gen(function* () { + const gh = yield* GitHubSourceControlProvider.make; + yield* Effect.flip(gh.checkoutChangeRequest({ cwd: "/repo", reference: "6", force: true })); + // Nothing touched the local branches: `main` must not be reset to the fork's commit. + assert.deepStrictEqual(git, []); + }).pipe(Effect.provide(layer)); + }); - yield* provider.createChangeRequest({ - cwd: "/repo", - baseRefName: "main", - headSelector: "owner:feature/provider", - title: "Provider PR", - bodyFile: "/tmp/body.md", + it.effect("adds a remote for a fork and names a default-branch head after its owner", () => { + const { layer, git } = harness({ + remotes: remotesOutput(["origin", "git@github.com:acme/web.git"]), + localBranches: ["someone/main"], + api: { + graphql: () => + Effect.succeed( + encodeJson({ data: { repository: { pullRequest: node(6, "main", "someone") } } }), + ), + rest: (input) => + Effect.succeed(repository(input.path === "repos/acme/web" ? "acme/web" : "someone/web")), + }, }); + return Effect.gen(function* () { + const gh = yield* GitHubSourceControlProvider.make; + yield* gh.checkoutChangeRequest({ cwd: "/repo", reference: "6", force: true }); + assert.deepStrictEqual(git, [ + [ + "ensureRemote", + { cwd: "/repo", preferredName: "someone", url: "git@github.com:someone/web.git" }, + ], + [ + "fetchRemoteTrackingBranch", + { cwd: "/repo", remoteName: "someone", remoteBranch: "main" }, + ], + ["switchRef", { cwd: "/repo", refName: "someone/main" }], + ["execute", ["reset", "--hard", "--quiet", "refs/remotes/someone/main"]], + [ + "setBranchUpstream", + { cwd: "/repo", branch: "someone/main", remoteName: "someone", remoteBranch: "main" }, + ], + ]); + }).pipe(Effect.provide(layer)); + }); - assert.deepStrictEqual(createInput, { - cwd: "/repo", - baseBranch: "main", - headSelector: "owner:feature/provider", - title: "Provider PR", - bodyFile: "/tmp/body.md", + it.effect("keeps git's own output out of what a failed checkout reports", () => { + const { layer } = harness({ + remotes: remotesOutput(["origin", "git@github.com:acme/web.git"]), + gitFailure: new Error("fatal: /home/me/secret-path: permission denied"), + api: { + graphql: () => + Effect.succeed( + encodeJson({ data: { repository: { pullRequest: node(5, "feature/x") } } }), + ), + }, }); - }), -); + return Effect.gen(function* () { + const gh = yield* GitHubSourceControlProvider.make; + const error = yield* gh + .checkoutChangeRequest({ cwd: "/repo", reference: "5" }) + .pipe(Effect.flip); + assert.strictEqual(error.detail, "The pull request could not be checked out with git."); + assert.notInclude(error.message, "secret-path"); + }).pipe(Effect.provide(layer)); + }); + + it("names the local branch the way gh pr checkout does", () => { + const name = (headRefName: string, isCrossRepository: boolean) => + GitHubSourceControlProvider.pullRequestCheckoutBranchName({ + headRefName, + headOwner: "someone", + isCrossRepository, + defaultBranch: "main", + }); + assert.strictEqual(name("main", true), "someone/main"); + assert.strictEqual(name("feature", true), "feature"); + assert.strictEqual(name("main", false), "main"); + }); +}); it("accepts active authenticated GitHub accounts when another account fails", () => { const auth = GitHubSourceControlProvider.discovery.parseAuth( @@ -409,20 +835,15 @@ it.effect.each(["pull", "issues"])( "resolves %s subjects on the linked host without using the checkout", (kind) => Effect.gen(function* () { - const provider = yield* makeProvider( - {}, - { - rest: (input) => { - assert.strictEqual(input.host, "github.com"); - assert.strictEqual(input.path, "repos/owner/repo/issues/42"); - return Effect.succeed( - restResponse( - encodeJson({ title: "Pairing expiry", body: "Preserve remote access", id: 1 }), - ), - ); - }, + const provider = yield* makeProvider({ + rest: (input) => { + assert.strictEqual(input.host, "github.com"); + assert.strictEqual(input.path, "repos/owner/repo/issues/42"); + return Effect.succeed( + restResponse({ title: "Pairing expiry", body: "Preserve remote access", id: 1 }), + ); }, - ); + }); const lookup = provider.resolveLink?.({ cwd: "/unrelated", url: new URL(`https://github.com/owner/repo/${kind}/42`), @@ -451,15 +872,12 @@ it.effect.each(["read", "decode"] as const)( operation: "resolveLink", status: 500, }); - const provider = yield* makeProvider( - {}, - { - rest: () => - stage === "read" - ? Effect.fail(cause) - : Effect.succeed(restResponse("private response text")), - }, - ); + const provider = yield* makeProvider({ + rest: () => + stage === "read" + ? Effect.fail(cause) + : Effect.succeed({ ...restResponse(undefined), body: "private response text" }), + }); const lookup = provider.resolveLink?.({ cwd: "/repo", url: new URL("https://github.com/owner/repo/issues/42"), diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts index a3b7a22f8d55..86edda994cce 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts @@ -1,21 +1,42 @@ -import * as Schema from "effect/Schema"; -import * as DateTime from "effect/DateTime"; +import * as Context from "effect/Context"; +import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; +import * as Request from "effect/Request"; +import * as RequestResolver from "effect/RequestResolver"; import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; import { DEFAULT_SERVER_SETTINGS, SourceControlProviderError, + TrimmedNonEmptyString, type ChangeRequest, type GitHubSettings, type SourceControlProviderDiscoveryItem, + type SourceControlRepositoryCloneUrls, } from "@t3tools/contracts"; - +import { normalizeGitRemoteUrl } from "@t3tools/shared/git"; import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; +import { decodeJsonResult } from "@t3tools/shared/schemaJson"; +import { isSshRemoteUrl } from "@t3tools/shared/sourceControl"; import * as ServerSettings from "../serverSettings.ts"; +import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as GitHubApi from "./GitHubApi.ts"; -import * as GitHubRepositoryApi from "./GitHubRepositoryApi.ts"; +import { + decodeGitHubPullRequestEntries, + type NormalizedGitHubPullRequestRecord, +} from "./gitHubPullRequests.ts"; +import { + parseFetchRemotes, + parseGitHubRepositorySelector, + parsePullRequestReference, + resolveGitHubRepository, + type GitHubRepositoryLocator, +} from "./gitHubRepositoryResolution.ts"; +import * as SourceControlRateLimit from "./SourceControlRateLimit.ts"; import { effectiveGitHubAccount, findAuthenticatedGitHubAccount, @@ -40,33 +61,8 @@ const decodeLinkSubject = Schema.decodeUnknownEffect( ), ); -function toChangeRequest(summary: GitHubRepositoryApi.GitHubPullRequestSummary): ChangeRequest { - return { - provider: "github", - number: summary.number, - title: summary.title, - url: summary.url, - baseRefName: summary.baseRefName, - headRefName: summary.headRefName, - ...(summary.headSha !== undefined ? { headSha: summary.headSha } : {}), - state: summary.state ?? "open", - ...(summary.isDraft === true ? { isDraft: true } : {}), - closedAt: summary.closedAt ?? null, - mergedAt: summary.mergedAt ?? null, - updatedAt: - summary.updatedAt === undefined - ? Option.none() - : Option.some(DateTime.makeUnsafe(summary.updatedAt)), - ...(summary.isCrossRepository !== undefined - ? { isCrossRepository: summary.isCrossRepository } - : {}), - ...(summary.headRepositoryNameWithOwner !== undefined - ? { headRepositoryNameWithOwner: summary.headRepositoryNameWithOwner } - : {}), - ...(summary.headRepositoryOwnerLogin !== undefined - ? { headRepositoryOwnerLogin: summary.headRepositoryOwnerLogin } - : {}), - }; +function toChangeRequest(record: NormalizedGitHubPullRequestRecord): ChangeRequest { + return { provider: "github", ...record }; } function authAccounts(accounts: ReadonlyArray) { @@ -263,77 +259,601 @@ export const makeDiscovery = Effect.gen(function* () { } satisfies SourceControlManagedCliDiscoverySpec; }); +/** + * Why a GitHub read or write failed, in words a user can act on. The provider methods attach the + * operation and request context; `cause` keeps the raw failure for logs, never for transport. + */ +class GitHubFailure extends Data.TaggedError("GitHubFailure")<{ + readonly detail: string; + readonly cause: unknown; +}> {} + +const failure = (detail: string, cause?: unknown) => new GitHubFailure({ detail, cause }); + +const PULL_REQUEST_NOT_FOUND = "Pull request not found. Check the PR number or URL and try again."; +const REPOSITORY_NOT_FOUND = "Repository not found. Check the owner and name and try again."; + +/** `notFound` names what was missing, which only the operation knows. */ +function fromGitHubApiError( + error: GitHubApi.GitHubApiError, + notFound = PULL_REQUEST_NOT_FOUND, +): GitHubFailure { + switch (error._tag) { + case "GitHubCliMissingError": + return failure( + "No GitHub credential on the server. Set GH_TOKEN, or install the GitHub CLI and run `gh auth login`.", + error, + ); + case "GitHubNotSignedInError": + case "GitHubHostDisabledError": + // A missing or turned-off credential already says what to do about it. + return failure(error.message, error); + case "GitHubApiAuthenticationError": + return failure( + "GitHub is not authenticated. Run `gh auth login` (or set GH_TOKEN) and retry.", + error, + ); + case "GitHubApiRateLimitError": + case "SourceControlRateLimitPausedError": + return failure( + "GitHub API rate limit exceeded. Requests resume when the limit resets.", + error, + ); + case "GitHubApiNotFoundError": + return failure(notFound, error); + case "GitHubCliFailedError": + case "GitHubApiResponseError": + case "GitHubApiRequestError": + // GitHub's own reason ("A pull request already exists…") when it gave one. + return failure(error.message.trim() || "GitHub request failed.", error); + } +} + +const notFound = (cause: string) => failure(PULL_REQUEST_NOT_FOUND, new Error(cause)); + +const RawRepositorySchema = Schema.Struct({ + full_name: TrimmedNonEmptyString, + html_url: TrimmedNonEmptyString, + ssh_url: TrimmedNonEmptyString, + default_branch: Schema.optional(Schema.NullOr(Schema.String)), +}); +const decodeRawRepository = decodeJsonResult(RawRepositorySchema); + +function repositoryCloneUrls( + raw: Schema.Schema.Type, +): SourceControlRepositoryCloneUrls { + return { nameWithOwner: raw.full_name, url: raw.html_url, sshUrl: raw.ssh_url }; +} + +const decodeViewerLogin = decodeJsonResult(Schema.Struct({ login: TrimmedNonEmptyString })); + +type PullRequestListState = "open" | "closed" | "merged" | "all"; + +/** The pull request fields every read selects, in GraphQL. */ +const PULL_REQUEST_NODE_SELECTION = + "number title url baseRefName headRefName headRefOid state isDraft mergedAt closedAt updatedAt isCrossRepository headRepository { name nameWithOwner } headRepositoryOwner { login }"; +const GRAPHQL_STATES: Record> = { + open: ["OPEN"], + closed: ["CLOSED"], + merged: ["MERGED"], + all: ["OPEN", "CLOSED", "MERGED"], +}; +/** + * Head lookups per GraphQL document. A document of a hundred costs one point, the same as one + * single lookup, but half that keeps each answer near half a second. + */ +const HEAD_LOOKUPS_PER_DOCUMENT = 50; +/** + * How long a head lookup waits for company. Branch discovery reaches GitHub only after each + * branch's own git reads, so lookups started together arrive tens of milliseconds apart. + * A background sweep's lookups spread over up to ~300ms, and every document costs a point no + * matter how few heads it holds, so reads without reserve wait longer. + */ +const HEAD_LOOKUP_BATCH_WINDOW = "50 millis"; +const BACKGROUND_HEAD_LOOKUP_BATCH_WINDOW = "500 millis"; +/** + * Background documents fill up under the longer window, and a failed document fails every head + * in it. Fifty `main`-like heads of a hundred pull requests each took up to ~10s, GitHub's own + * processing limit; twenty-five took ~7s. + */ +const BACKGROUND_HEAD_LOOKUPS_PER_DOCUMENT = 25; +/** A full document is 5,000 rows of well under 2 KB each. */ +const HEAD_LOOKUP_MAX_RESPONSE_BYTES = 16_000_000; +/** + * Rows read for a fork's `owner:branch` head, which GitHub cannot filter by owner. A branch + * named like a busy default (`main`) is the case this bounds; the owner's own row is near the + * top because the newest come first. + */ +const OWNER_HEAD_SCAN_LIMIT = 100; + +class PullRequestsByHeadRead extends Request.Class< + { + readonly host: string; + readonly owner: string; + readonly name: string; + readonly headRefName: string; + readonly state: PullRequestListState; + readonly limit: number; + readonly allowReserve: boolean; + }, + ReadonlyArray, + GitHubFailure +> {} + +/** One aliased `pullRequests` connection per lookup, each head and state passed as a variable. */ +function buildPullRequestsByHeadQuery( + lookups: ReadonlyArray>, +): { readonly document: string; readonly variables: Record } { + const variables: Record = {}; + const declarations: string[] = ["$owner: String!", "$name: String!"]; + const selections: string[] = []; + for (const [index, lookup] of lookups.entries()) { + variables[`h${index}`] = lookup.headRefName; + variables[`s${index}`] = GRAPHQL_STATES[lookup.state]; + declarations.push(`$h${index}: String!`, `$s${index}: [PullRequestState!]`); + // `gh pr list` orders the same way, so a head with more matches than the limit keeps the + // same rows. + selections.push( + ` h${index}: pullRequests(headRefName: $h${index}, states: $s${index}, first: ${lookup.limit}, orderBy: { field: CREATED_AT, direction: DESC }) { nodes { ${PULL_REQUEST_NODE_SELECTION} } }`, + ); + } + return { + document: `query PullRequestsByHead(${declarations.join(", ")}) {\n repository(owner: $owner, name: $name) {\n${selections.join("\n")}\n }\n}`, + variables, + }; +} + +const PULL_REQUEST_BY_NUMBER_QUERY = `query PullRequestByNumber($owner: String!, $name: String!, $number: Int!) { + repository(owner: $owner, name: $name) { + pullRequest(number: $number) { ${PULL_REQUEST_NODE_SELECTION} } + } +}`; + +const decodePullRequestsByHead = decodeJsonResult( + Schema.Struct({ + data: Schema.Struct({ + repository: Schema.NullOr( + Schema.Record( + Schema.String, + Schema.NullOr(Schema.Struct({ nodes: Schema.Array(Schema.Unknown) })), + ), + ), + }), + }), +); + +const decodePullRequestByNumber = decodeJsonResult( + Schema.Struct({ + data: Schema.Struct({ + repository: Schema.NullOr(Schema.Struct({ pullRequest: Schema.NullOr(Schema.Unknown) })), + }), + }), +); + +/** The local branch a pull request checks out into, the way `gh pr checkout` names it. */ +export function pullRequestCheckoutBranchName(input: { + readonly headRefName: string; + readonly headOwner: string | null; + readonly isCrossRepository: boolean; + readonly defaultBranch: string | null; +}): string { + // gh prefixes the owner only where the fork's branch would take over the default branch's + // name, which is the one collision every fork pull request from `main` would hit. + return input.isCrossRepository && + input.headOwner !== null && + input.defaultBranch !== null && + input.headRefName === input.defaultBranch + ? `${input.headOwner}/${input.headRefName}` + : input.headRefName; +} + +/** The checkout's GitHub API host, from the remote the caller resolved the provider from. */ +const contextHost = (context: SourceControlProvider.SourceControlProviderContext | undefined) => + context === undefined ? undefined : new URL(context.provider.baseUrl).host; + export const make = Effect.gen(function* () { - const github = yield* GitHubRepositoryApi.GitHubRepositoryApi; const api = yield* GitHubApi.GitHubApi; + const process = yield* VcsProcess.VcsProcess; + const environment = yield* HostProcessEnvironment; + const git = yield* GitVcsDriver.GitVcsDriver; + const fileSystem = yield* FileSystem.FileSystem; + + const gitRead = (cwd: string, args: ReadonlyArray) => + process.run({ + operation: "GitHubSourceControlProvider.resolveRepository", + command: "git", + args, + cwd, + allowNonZeroExit: true, + timeoutMs: 5_000, + }); + + /** + * The repository `gh` would act on in `cwd`: GH_REPO, else the remote `gh` would pick, else + * the remote the caller resolved the provider from, else the best-ranked GitHub remote. + */ + const resolveRepository = Effect.fn("GitHubSourceControlProvider.resolveRepository")( + function* (input: { readonly cwd: string; readonly host?: string | undefined }) { + const envRepository = environment.GH_REPO?.trim(); + const defaultHost = (input.host ?? environment.GH_HOST ?? "github.com").toLowerCase(); + if (envRepository) { + const locator = parseGitHubRepositorySelector(envRepository, defaultHost); + if (locator !== null) return locator; + } + const [remotes, resolved] = yield* Effect.all([ + gitRead(input.cwd, ["remote", "-v"]), + gitRead(input.cwd, ["config", "--get-regexp", "^remote\\..*\\.gh-resolved$"]), + ]).pipe(Effect.orElseSucceed(() => [null, null] as const)); + const { host, locator } = resolveGitHubRepository({ + remotes: remotes?.exitCode === 0 ? remotes.stdout : "", + resolved: resolved?.exitCode === 0 ? resolved.stdout : "", + hostHint: input.host, + defaultHost, + }); + if (locator !== null) return locator; + return yield* failure( + `No GitHub repository on ${host} was found among this checkout's git remotes.`, + ); + }, + ); + + const graphqlJson = ( + input: GitHubApi.GitHubGraphQlInput, + decode: (raw: string) => Result.Result, + decodeDetail: string, + ) => + api.graphql(input).pipe( + Effect.mapError(fromGitHubApiError), + Effect.flatMap((raw) => { + const decoded = decode(raw); + return Result.isSuccess(decoded) + ? Effect.succeed(decoded.success) + : Effect.fail(failure(decodeDetail, decoded.failure)); + }), + ); + + const rest = (input: GitHubApi.GitHubRestInput, notFoundDetail?: string) => + api.rest(input).pipe(Effect.mapError((error) => fromGitHubApiError(error, notFoundDetail))); + + const headResolver = RequestResolver.makeGrouped({ + key: ({ request, context }) => + [ + request.host, + request.owner, + request.name, + String(request.allowReserve), + Context.getOrElse(context, GitHubApi.PinnedGitHubCredential, () => null) + ?.credentialFingerprint ?? "", + Context.getOrElse(context, SourceControlRateLimit.CredentialScope, () => ""), + ].join("\0"), + resolver: (entries) => { + const [first] = entries; + const { host, owner, name, allowReserve } = first.request; + const query = buildPullRequestsByHeadQuery(entries.map((entry) => entry.request)); + return graphqlJson( + { + host, + operation: "listPullRequestsByHead", + query: query.document, + variables: { owner, name, ...query.variables }, + allowReserve, + // Up to 50 heads of 100 rows each. A default branch such as `main` can match a hundred + // fork pull requests, so the usual cap would cut the answer short. + maxResponseBytes: HEAD_LOOKUP_MAX_RESPONSE_BYTES, + }, + decodePullRequestsByHead, + "GitHub returned an invalid change request list.", + ).pipe( + Effect.flatMap((decoded) => + decoded.data.repository === null + ? Effect.fail(notFound("The repository could not be read.")) + : Effect.succeed(decoded.data.repository), + ), + Effect.map((aliases) => { + for (const [index, entry] of entries.entries()) { + const alias = aliases[`h${index}`]; + entry.completeUnsafe( + Exit.succeed(alias == null ? [] : decodeGitHubPullRequestEntries(alias.nodes)), + ); + } + }), + Effect.catchCause((cause) => + Effect.sync(() => { + for (const entry of entries) entry.completeUnsafe(Exit.failCause(cause)); + }), + ), + ); + }, + }).pipe(RequestResolver.batchN(HEAD_LOOKUPS_PER_DOCUMENT)); + const interactiveHeadResolver = headResolver.pipe( + RequestResolver.setDelay(HEAD_LOOKUP_BATCH_WINDOW), + ); + const backgroundHeadResolver = headResolver.pipe( + RequestResolver.batchN(BACKGROUND_HEAD_LOOKUPS_PER_DOCUMENT), + RequestResolver.setDelay(BACKGROUND_HEAD_LOOKUP_BATCH_WINDOW), + ); + + /** + * Pull requests whose head is `headSelector` (a branch, or `owner:branch` for a fork), in the + * repository `gh pr list` would read in `cwd`. Lookups on one repository that arrive together + * share one GraphQL document. + */ + const listByHead = Effect.fn("GitHubSourceControlProvider.listByHead")(function* (input: { + readonly cwd: string; + readonly headSelector: string; + readonly state: PullRequestListState; + readonly limit: number; + readonly host?: string | undefined; + readonly allowReserve: boolean; + }) { + const locator = yield* resolveRepository({ cwd: input.cwd, host: input.host }); + const limit = Math.min(Math.max(Math.trunc(input.limit), 1), 100); + // `owner:branch` names a fork's branch. GitHub filters on the branch name only, so the + // owner is matched on the rows it returns. + const ownerMatch = /^([^:/\s]+):(.+)$/u.exec(input.headSelector.trim()); + const headRefName = ownerMatch?.[2] ?? input.headSelector.trim(); + const rows = yield* Effect.request( + new PullRequestsByHeadRead({ + ...locator, + headRefName, + state: input.state, + limit: ownerMatch ? OWNER_HEAD_SCAN_LIMIT : limit, + allowReserve: input.allowReserve, + }), + input.allowReserve ? interactiveHeadResolver : backgroundHeadResolver, + ); + if (!ownerMatch) return rows; + const headOwner = ownerMatch[1]!.toLowerCase(); + return rows + .filter((row) => row.headRepositoryOwnerLogin?.toLowerCase() === headOwner) + .slice(0, limit); + }); - const listChangeRequests: SourceControlProvider.SourceControlProvider["Service"]["listChangeRequests"] = - (input) => { - if (input.state === "open") { - return github - .listOpenPullRequests({ + const readPullRequest = Effect.fn("GitHubSourceControlProvider.readPullRequest")( + function* (input: { + readonly cwd: string; + readonly reference: string; + readonly host?: string | undefined; + }) { + const parsed = parsePullRequestReference(input.reference); + if (parsed.kind === "branch") { + // `gh pr view ` prefers an open pull request, then the newest of any state. + const lookup = (state: PullRequestListState) => + listByHead({ cwd: input.cwd, - headSelector: input.headSelector, - ...(input.context === undefined - ? {} - : { rateLimitHost: new URL(input.context.provider.baseUrl).host }), - ...(input.limit !== undefined ? { limit: input.limit } : {}), - }) - .pipe( - Effect.map((items) => items.map(toChangeRequest)), - Effect.mapError( - (error) => - new SourceControlProviderError({ - provider: "github", - operation: "listChangeRequests", - cwd: input.cwd, - reference: SourceControlProvider.transportSafeSourceControlErrorValue( - input.headSelector, - ), - detail: error.message, - cause: error, - }), - ), - ); + headSelector: parsed.headSelector, + state, + limit: 1, + host: input.host, + allowReserve: true, + }); + const [open] = yield* lookup("open"); + const found = open ?? (yield* lookup("all"))[0]; + if (found === undefined) return yield* notFound("No pull request has this head branch."); + return found; } + const locator = + parsed.kind === "url" + ? parsed.locator + : yield* resolveRepository({ cwd: input.cwd, host: input.host }); + const decodeDetail = "GitHub returned an invalid pull request."; + const decoded = yield* graphqlJson( + { + host: locator.host, + operation: "getPullRequest", + query: PULL_REQUEST_BY_NUMBER_QUERY, + variables: { owner: locator.owner, name: locator.name, number: parsed.number }, + allowReserve: true, + }, + decodePullRequestByNumber, + decodeDetail, + ); + const node = decoded.data.repository?.pullRequest; + if (node == null) return yield* notFound("The pull request does not exist."); + const [record] = decodeGitHubPullRequestEntries([node]); + if (record === undefined) + return yield* failure(decodeDetail, new Error("Malformed pull request.")); + return record; + }, + ); + + const readRepository = Effect.fn("GitHubSourceControlProvider.readRepository")(function* ( + locator: GitHubRepositoryLocator, + ) { + const response = yield* rest( + { + host: locator.host, + operation: "getRepository", + path: `repos/${encodeURIComponent(locator.owner)}/${encodeURIComponent(locator.name)}`, + allowReserve: true, + }, + REPOSITORY_NOT_FOUND, + ); + const decoded = decodeRawRepository(response.body); + if (Result.isFailure(decoded)) { + return yield* failure("GitHub returned an invalid repository.", decoded.failure); + } + return decoded.success; + }); + + const readViewerLogin = Effect.fn("GitHubSourceControlProvider.readViewerLogin")(function* ( + host: string, + ) { + const response = yield* rest({ host, operation: "getViewer", path: "user" }); + const decoded = decodeViewerLogin(response.body); + if (Result.isFailure(decoded)) { + return yield* failure("GitHub request failed.", decoded.failure); + } + return decoded.success.login; + }); + + // Git and filesystem errors can carry arguments, paths and stderr, so the detail a client + // sees is fixed and the raw error stays in `cause`. + const gitFailure = (cause: unknown) => + failure("The pull request could not be checked out with git.", cause); + + const runGit = (cwd: string, operation: string, args: ReadonlyArray) => + git.execute({ + operation: `GitHubSourceControlProvider.checkoutPullRequest.${operation}`, + cwd, + args, + }); + + /** + * `gh pr checkout` in plain git: the head branch is fetched from the remote that holds it (a + * fork gets a remote of its own), checked out under the name gh would give it, and set to + * track the head. A head branch that is gone is read from the base's `refs/pull//head`. + * An existing branch fast-forwards, or with `force` is reset to the pull request. + */ + const checkoutPullRequest = Effect.fn("GitHubSourceControlProvider.checkoutPullRequest")( + function* (input: { + readonly cwd: string; + readonly reference: string; + readonly force?: boolean; + }) { + const reference = parsePullRequestReference(input.reference); + const pullRequest = yield* readPullRequest({ cwd: input.cwd, reference: input.reference }); + const base = + reference.kind === "url" ? reference.locator : yield* resolveRepository({ cwd: input.cwd }); + const baseNameWithOwner = `${base.owner}/${base.name}`.toLowerCase(); + const headNameWithOwner = pullRequest.headRepositoryNameWithOwner ?? null; + const isCrossRepository = + pullRequest.isCrossRepository ?? + (headNameWithOwner !== null && headNameWithOwner.toLowerCase() !== baseNameWithOwner); + const headOwner = + pullRequest.headRepositoryOwnerLogin ?? headNameWithOwner?.split("/")[0] ?? null; + + const remotes = yield* gitRead(input.cwd, ["remote", "-v"]).pipe( + Effect.map((result) => (result.exitCode === 0 ? result.stdout : "")), + Effect.mapError(gitFailure), + ); + const remoteFor = (nameWithOwner: string) => + parseFetchRemotes(remotes).find( + (remote) => + normalizeGitRemoteUrl(remote.url).split("/").slice(1).join("/") === + nameWithOwner.toLowerCase(), + )?.name ?? null; + const baseRemote = Effect.suspend(() => { + const known = remoteFor(baseNameWithOwner); + return known === null ? git.resolvePrimaryRemoteName(input.cwd) : Effect.succeed(known); + }); + // A fork's branch named like the base's default branch is checked out under the owner's + // prefix. Without the default branch that collision cannot be ruled out, and the checkout + // would reset the local default branch to the fork's commit, so it fails instead. + const defaultBranch = isCrossRepository + ? yield* readRepository(base).pipe( + Effect.map((repository) => repository.default_branch ?? null), + ) + : null; + const localBranch = pullRequestCheckoutBranchName({ + headRefName: pullRequest.headRefName, + headOwner, + isCrossRepository, + defaultBranch, + }); - return github - .listPullRequestsByHead({ + /** The remote the head branch lives on; a fork the checkout does not know yet is added. */ + const headRemote = Effect.gen(function* () { + if (!isCrossRepository) return yield* baseRemote; + if (headNameWithOwner === null) return yield* failure("The fork is gone."); + const known = remoteFor(headNameWithOwner); + if (known !== null) return known; + const [owner, name] = headNameWithOwner.split("/"); + const fork = yield* readRepository({ host: base.host, owner: owner!, name: name! }); + const originUrl = yield* git.readConfigValue(input.cwd, "remote.origin.url"); + return yield* git.ensureRemote({ cwd: input.cwd, - headSelector: input.headSelector, - state: input.state, - limit: input.limit ?? 20, - ...(input.context === undefined - ? {} - : { rateLimitHost: new URL(input.context.provider.baseUrl).host }), - }) - .pipe( - Effect.map((items) => - items.map(({ updatedAt, ...summary }) => ({ - ...toChangeRequest({ - ...summary, - ...(Option.isSome(updatedAt) - ? { updatedAt: DateTime.formatIso(updatedAt.value) } - : {}), - }), - updatedAt, - })), - ), - Effect.mapError( - (error) => - new SourceControlProviderError({ - provider: "github", - operation: "listChangeRequests", - cwd: input.cwd, - reference: SourceControlProvider.transportSafeSourceControlErrorValue( - input.headSelector, - ), - detail: error.message, - cause: error, - }), - ), - ); - }; + preferredName: headOwner ?? "fork", + url: originUrl !== null && isSshRemoteUrl(originUrl) ? fork.ssh_url : fork.html_url, + }); + }); + + const exists = (yield* git + .listLocalBranchNames(input.cwd) + .pipe(Effect.mapError(gitFailure))).includes(localBranch); + + // The commit to check out, fetched from the head branch where it still exists. + const target = yield* Effect.gen(function* () { + const remoteName = yield* headRemote; + yield* git.fetchRemoteTrackingBranch({ + cwd: input.cwd, + remoteName, + remoteBranch: pullRequest.headRefName, + }); + return { + ref: `refs/remotes/${remoteName}/${pullRequest.headRefName}`, + upstream: { remoteName, remoteBranch: pullRequest.headRefName }, + }; + }).pipe( + Effect.catch(() => + Effect.gen(function* () { + yield* runGit(input.cwd, "fetchPullRef", [ + "fetch", + "--quiet", + "--no-tags", + yield* baseRemote, + `refs/pull/${pullRequest.number}/head`, + ]); + const { commitSha } = yield* git.resolveCommit({ + cwd: input.cwd, + revision: "FETCH_HEAD", + }); + return { ref: commitSha, upstream: null }; + }), + ), + Effect.mapError(gitFailure), + ); + + yield* Effect.gen(function* () { + if (!exists) yield* runGit(input.cwd, "branch", ["branch", localBranch, target.ref]); + yield* Effect.scoped(git.switchRef({ cwd: input.cwd, refName: localBranch })); + if (exists) { + yield* runGit( + input.cwd, + "sync", + input.force === true + ? ["reset", "--hard", "--quiet", target.ref] + : ["merge", "--ff-only", "--quiet", target.ref], + ); + } + // Tracking is set once the branch is the pull request's, so a sync that fails leaves an + // existing branch's upstream as it was. + if (target.upstream !== null) { + yield* git.setBranchUpstream({ cwd: input.cwd, branch: localBranch, ...target.upstream }); + } + }).pipe(Effect.mapError(gitFailure)); + }, + ); + + /** Reports a failure against the operation and request that made it, with transport-safe context. */ + const providerError = + ( + operation: string, + cwd: string, + context?: { readonly reference?: string; readonly repository?: string }, + ) => + (error: GitHubFailure) => + new SourceControlProviderError({ + provider: "github", + operation, + cwd, + ...(context?.reference === undefined + ? {} + : { + reference: SourceControlProvider.transportSafeSourceControlErrorValue( + context.reference, + ), + }), + ...(context?.repository === undefined + ? {} + : { + repository: SourceControlProvider.transportSafeSourceControlErrorValue( + context.repository, + ), + }), + detail: error.detail, + cause: error.cause, + }); const readLinkSubject = Effect.fn("GitHubSourceControlProvider.readLinkSubject")(function* ( input: { readonly cwd: string; readonly url: URL }, @@ -385,121 +905,122 @@ export const make = Effect.gen(function* () { if (!match) return undefined; return readLinkSubject(input, `repos/${match[1]}/${match[2]}/issues/${match[3]}`); }, - listChangeRequests, + listChangeRequests: (input) => + // An open lookup is a user waiting on a status; the rest may be a background sweep. + (input.state === "open" ? Effect.succeed(true) : GitHubApi.AllowGitHubReserve).pipe( + Effect.flatMap((allowReserve) => + listByHead({ + cwd: input.cwd, + headSelector: input.headSelector, + state: input.state, + limit: input.limit ?? (input.state === "open" ? 1 : 20), + host: contextHost(input.context), + allowReserve, + }), + ), + Effect.map((records) => records.map(toChangeRequest)), + Effect.mapError( + providerError("listChangeRequests", input.cwd, { reference: input.headSelector }), + ), + ), getChangeRequest: (input) => - github - .getPullRequest({ - ...input, - ...(input.context === undefined - ? {} - : { rateLimitHost: new URL(input.context.provider.baseUrl).host }), - }) - .pipe( - Effect.map(toChangeRequest), - Effect.mapError( - (error) => - new SourceControlProviderError({ - provider: "github", - operation: "getChangeRequest", - cwd: input.cwd, - reference: SourceControlProvider.transportSafeSourceControlErrorValue( - input.reference, - ), - detail: error.message, - cause: error, - }), - ), + readPullRequest({ ...input, host: contextHost(input.context) }).pipe( + Effect.map(toChangeRequest), + Effect.mapError( + providerError("getChangeRequest", input.cwd, { reference: input.reference }), ), + ), createChangeRequest: (input) => - github - .createPullRequest({ - cwd: input.cwd, - baseBranch: input.baseRefName, - headSelector: input.headSelector, - title: input.title, - bodyFile: input.bodyFile, - }) - .pipe( - Effect.mapError( - (error) => - new SourceControlProviderError({ - provider: "github", - operation: "createChangeRequest", - cwd: input.cwd, - reference: SourceControlProvider.transportSafeSourceControlErrorValue( - input.headSelector, - ), - detail: error.message, - cause: error, - }), - ), + Effect.gen(function* () { + const locator = yield* resolveRepository({ cwd: input.cwd }); + const body = yield* fileSystem + .readFileString(input.bodyFile) + .pipe( + Effect.mapError((cause) => + failure("The pull request description could not be read.", cause), + ), + ); + yield* rest({ + host: locator.host, + operation: "createPullRequest", + method: "POST", + path: `repos/${encodeURIComponent(locator.owner)}/${encodeURIComponent(locator.name)}/pulls`, + // `owner:branch` is how the REST API takes a fork's head, the same as `gh --head`. + // gh allows maintainer edits unless told otherwise; the API's default is not documented. + body: { + base: input.baseRefName, + head: input.headSelector, + title: input.title, + body, + maintainer_can_modify: true, + }, + }); + }).pipe( + Effect.mapError( + providerError("createChangeRequest", input.cwd, { reference: input.headSelector }), ), + ), getRepositoryCloneUrls: (input) => - github.getRepositoryCloneUrls(input).pipe( + Effect.gen(function* () { + const fallbackHost = (yield* resolveRepository({ cwd: input.cwd }).pipe( + Effect.map((locator) => locator.host), + Effect.orElseSucceed(() => environment.GH_HOST ?? "github.com"), + )).toLowerCase(); + const locator = parseGitHubRepositorySelector(input.repository, fallbackHost); + if (locator === null) return yield* failure("Repositories are named owner/name."); + return repositoryCloneUrls(yield* readRepository(locator)); + }).pipe( Effect.mapError( - (error) => - new SourceControlProviderError({ - provider: "github", - operation: "getRepositoryCloneUrls", - cwd: input.cwd, - repository: SourceControlProvider.transportSafeSourceControlErrorValue( - input.repository, - ), - detail: error.message, - cause: error, - }), + providerError("getRepositoryCloneUrls", input.cwd, { repository: input.repository }), ), ), createRepository: (input) => - github.createRepository(input).pipe( + Effect.gen(function* () { + const locator = parseGitHubRepositorySelector( + input.repository, + (environment.GH_HOST ?? "github.com").toLowerCase(), + ); + const viewer = locator === null ? null : yield* readViewerLogin(locator.host); + const owner = locator?.owner ?? viewer; + const name = locator?.name ?? input.repository.trim(); + const host = locator?.host ?? (environment.GH_HOST?.trim().toLowerCase() || "github.com"); + const isViewer = viewer !== null && owner?.toLowerCase() === viewer.toLowerCase(); + const response = yield* rest( + { + host, + operation: "createRepository", + method: "POST", + path: + isViewer || owner === null ? "user/repos" : `orgs/${encodeURIComponent(owner)}/repos`, + body: { name, private: input.visibility === "private" }, + }, + // GitHub answers 404 for an organization the account cannot create repositories in. + `No organization named ${owner ?? "that"} that this account can create repositories in.`, + ); + const decoded = decodeRawRepository(response.body); + if (Result.isFailure(decoded)) { + return yield* failure("GitHub returned an invalid repository.", decoded.failure); + } + return repositoryCloneUrls(decoded.success); + }).pipe( Effect.mapError( - (error) => - new SourceControlProviderError({ - provider: "github", - operation: "createRepository", - cwd: input.cwd, - repository: SourceControlProvider.transportSafeSourceControlErrorValue( - input.repository, - ), - detail: error.message, - cause: error, - }), + providerError("createRepository", input.cwd, { repository: input.repository }), ), ), getDefaultBranch: (input) => - github - .getDefaultBranch({ - ...input, - ...(input.context === undefined - ? {} - : { rateLimitHost: new URL(input.context.provider.baseUrl).host }), - }) - .pipe( - Effect.mapError( - (error) => - new SourceControlProviderError({ - provider: "github", - operation: "getDefaultBranch", - cwd: input.cwd, - detail: error.message, - cause: error, - }), - ), - ), + Effect.gen(function* () { + const locator = yield* resolveRepository({ + cwd: input.cwd, + host: contextHost(input.context), + }); + const repository = yield* readRepository(locator); + const branch = repository.default_branch?.trim() ?? ""; + return branch.length > 0 ? branch : null; + }).pipe(Effect.mapError(providerError("getDefaultBranch", input.cwd))), checkoutChangeRequest: (input) => - github.checkoutPullRequest(input).pipe( + checkoutPullRequest(input).pipe( Effect.mapError( - (error) => - new SourceControlProviderError({ - provider: "github", - operation: "checkoutChangeRequest", - cwd: input.cwd, - reference: SourceControlProvider.transportSafeSourceControlErrorValue( - input.reference, - ), - detail: error.message, - cause: error, - }), + providerError("checkoutChangeRequest", input.cwd, { reference: input.reference }), ), ), }); diff --git a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts index d9bbb51d7a61..6101d148a632 100644 --- a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts +++ b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts @@ -13,12 +13,12 @@ import { VcsProcessSpawnError } from "@t3tools/contracts"; import * as ServerSettings from "../serverSettings.ts"; import * as ServerConfig from "../config.ts"; +import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as AzureDevOpsCli from "./AzureDevOpsCli.ts"; import * as BitbucketApi from "./BitbucketApi.ts"; import * as GitHubApi from "./GitHubApi.ts"; -import * as GitHubRepositoryApi from "./GitHubRepositoryApi.ts"; import * as GitLabCli from "./GitLabCli.ts"; import * as ForgejoCli from "./ForgejoCli.ts"; import * as ForgejoSourceControlProvider from "./ForgejoSourceControlProvider.ts"; @@ -40,8 +40,8 @@ const layerSourceControlProviderRegistryTest = (input: { Layer.mock(AzureDevOpsCli.AzureDevOpsCli)({}), Layer.mock(BitbucketApi.BitbucketApi)(input.bitbucket), ServerSettings.ServerSettingsService.layerTest(), - Layer.mock(GitHubRepositoryApi.GitHubRepositoryApi)({}), Layer.mock(GitHubApi.GitHubApi)({}), + Layer.mock(GitVcsDriver.GitVcsDriver)({}), Layer.mock(GitLabCli.GitLabCli)({}), Layer.mock(ForgejoCli.ForgejoCli)({ listLogins: () => Effect.succeed([]) }), Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({}), diff --git a/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts index c9a7164c8b31..f715eff1dea0 100644 --- a/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts +++ b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts @@ -14,8 +14,8 @@ import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as AzureDevOpsCli from "./AzureDevOpsCli.ts"; import * as BitbucketApi from "./BitbucketApi.ts"; +import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as GitHubApi from "./GitHubApi.ts"; -import * as GitHubRepositoryApi from "./GitHubRepositoryApi.ts"; import * as GitLabCli from "./GitLabCli.ts"; import * as ForgejoCli from "./ForgejoCli.ts"; import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts"; @@ -42,7 +42,6 @@ function makeRegistry(input: { readonly url: string; }>; readonly process?: Partial; - readonly github?: Partial; readonly githubApi?: Partial; readonly gitlab?: Partial; readonly resolve?: VcsDriverRegistry.VcsDriverRegistry["Service"]["resolve"]; @@ -98,8 +97,8 @@ function makeRegistry(input: { Layer.mock(AzureDevOpsCli.AzureDevOpsCli)({}), Layer.mock(BitbucketApi.BitbucketApi)({}), ServerSettings.ServerSettingsService.layerTest(), - Layer.mock(GitHubRepositoryApi.GitHubRepositoryApi)(input.github ?? {}), Layer.mock(GitHubApi.GitHubApi)(input.githubApi ?? {}), + Layer.mock(GitVcsDriver.GitVcsDriver)({}), Layer.mock(GitLabCli.GitLabCli)(input.gitlab ?? {}), Layer.mock(ForgejoCli.ForgejoCli)({ listLogins: () => Effect.succeed([]) }), ServerConfig.layerTest(process.cwd(), { diff --git a/apps/server/src/sourceControl/gitHubRepositoryResolution.test.ts b/apps/server/src/sourceControl/gitHubRepositoryResolution.test.ts new file mode 100644 index 000000000000..3b33f0c38e40 --- /dev/null +++ b/apps/server/src/sourceControl/gitHubRepositoryResolution.test.ts @@ -0,0 +1,87 @@ +import { assert, describe, it } from "@effect/vitest"; + +import { + gitHubApiHostForRemote, + selectGitHubBaseRepository, +} from "./gitHubRepositoryResolution.ts"; + +describe("selectGitHubBaseRepository", () => { + const remotes = (...entries: ReadonlyArray) => + entries + .flatMap(([name, url]) => [`${name}\t${url} (fetch)`, `${name}\t${url} (push)`]) + .join("\n"); + const select = (input: { remotes: string; resolved?: string }) => + selectGitHubBaseRepository({ resolved: "", host: "github.com", ...input }); + + it("picks the repository gh reads without a prompt", () => { + assert.deepStrictEqual(select({ remotes: remotes(["fork", "git@github.com:me/web.git"]) }), { + owner: "me", + name: "web", + }); + const fork = remotes( + ["origin", "git@github.com:me/web.git"], + ["upstream", "https://github.com/Acme/Web.git"], + ); + assert.deepStrictEqual(select({ remotes: fork }), { owner: "acme", name: "web" }); + assert.deepStrictEqual(select({ remotes: fork, resolved: "remote.origin.gh-resolved base" }), { + owner: "me", + name: "web", + }); + assert.deepStrictEqual( + select({ remotes: fork, resolved: "remote.origin.gh-resolved acme/other" }), + { owner: "acme", name: "other" }, + ); + // gh ranks remote names in any case. + assert.deepStrictEqual( + select({ + remotes: remotes( + ["origin", "git@github.com:me/web.git"], + ["Upstream", "git@github.com:acme/web.git"], + ), + }), + { owner: "acme", name: "web" }, + ); + }); + + it("leaves gh to choose when it might weigh the remotes differently", () => { + for (const input of [ + { remotes: "" }, + { + remotes: remotes(["a", "git@github.com:me/web.git"], ["b", "git@github.com:acme/web.git"]), + }, + { + remotes: remotes( + ["origin", "git@github.com:me/web.git"], + ["mirror", "git@gitlab.com:me/web.git"], + ), + }, + { remotes: remotes(["origin", "git@github-work:me/web.git"]) }, + { + remotes: remotes( + ["origin", "git@github.com:me/web.git"], + ["Origin", "git@github.com:acme/web.git"], + ), + }, + { + remotes: remotes( + ["origin", "git@github.com:me/web.git"], + ["upstream", "git@github.com:acme/web.git"], + ), + resolved: "remote.origin.gh-resolved base\nremote.upstream.gh-resolved base", + }, + ]) { + assert.strictEqual(select(input), null); + } + }); +}); + +describe("gitHubApiHostForRemote", () => { + it("reads an SSH alias through github.com and a real host as itself", () => { + assert.strictEqual(gitHubApiHostForRemote("git@github:acme/web.git"), "github.com"); + assert.strictEqual( + gitHubApiHostForRemote("git@github.example.com:a/b.git"), + "github.example.com", + ); + assert.strictEqual(gitHubApiHostForRemote("git@gitlab.com:a/b.git"), null); + }); +}); diff --git a/apps/server/src/sourceControl/gitHubRepositoryResolution.ts b/apps/server/src/sourceControl/gitHubRepositoryResolution.ts new file mode 100644 index 000000000000..8d4cbf038bfd --- /dev/null +++ b/apps/server/src/sourceControl/gitHubRepositoryResolution.ts @@ -0,0 +1,190 @@ +import { normalizeGitRemoteUrl } from "@t3tools/shared/git"; +import { + detectSourceControlProviderFromRemoteUrl, + isSshRemoteUrl, +} from "@t3tools/shared/sourceControl"; + +/** A repository on a GitHub host: the API it is read through, and its owner and name. */ +export interface GitHubRepositoryLocator { + readonly host: string; + readonly owner: string; + readonly name: string; +} + +/** The fetch remotes in `git remote -v` output, by name. */ +export function parseFetchRemotes( + remotes: string, +): ReadonlyArray<{ readonly name: string; readonly url: string }> { + return remotes + .split("\n") + .map((line) => /^(\S+)\s+(\S+)\s+\(fetch\)$/u.exec(line.trim())) + .filter((match): match is RegExpExecArray => match !== null) + .map((match) => ({ name: match[1]!, url: match[2]! })); +} + +/** + * The repository `gh pr list` reads in a checkout, picked the way gh picks one without a + * prompt: the remote `gh repo set-default` marked, else the first of upstream, github, origin + * (in any case), else the only remote. `remotes` is `git remote -v` output and `resolved` is the output of + * `git config --get-regexp '^remote\..*\.gh-resolved$'`. + * + * Null whenever gh might weigh the remotes differently: a remote on another host or under an + * SSH alias, more than one mark, or several remotes with none of those names. Callers then + * fall back to the provider's remote, then to the best-ranked remote on the host. + */ +export function selectGitHubBaseRepository(input: { + readonly remotes: string; + readonly resolved: string; + readonly host: string; +}): { readonly owner: string; readonly name: string } | null { + const host = input.host.toLowerCase(); + const repositories = new Map(); + for (const remote of parseFetchRemotes(input.remotes)) { + const [remoteHost, owner, name, ...rest] = normalizeGitRemoteUrl(remote.url).split("/"); + if (remoteHost !== host || !owner || !name || rest.length > 0) return null; + repositories.set(remote.name, { owner, name }); + } + const marks = input.resolved + .split("\n") + .map((line) => /^remote\.(.+)\.gh-resolved\s+(\S+)$/u.exec(line.trim())) + .filter((match): match is RegExpExecArray => match !== null && repositories.has(match[1]!)); + if (marks.length > 1) return null; + const [mark] = marks; + if (mark) { + if (mark[2] === "base") return repositories.get(mark[1]!) ?? null; + const [owner, name, ...rest] = mark[2]!.toLowerCase().split("/"); + return owner && name && rest.length === 0 ? { owner, name } : null; + } + // gh sorts remotes by these names, case-insensitively, and takes the first. A tie for the + // top place has no defined winner. + const score = (remoteName: string) => + ["origin", "github", "upstream"].indexOf(remoteName.toLowerCase()) + 1; + const ranked = [...repositories.entries()].toSorted( + ([left], [right]) => score(right) - score(left), + ); + const [top, next] = ranked; + return top !== undefined && (next === undefined || score(top[0]) > score(next[0])) + ? top[1] + : null; +} + +/** + * The repository `gh` would act on in a checkout, from its remotes: the remote `gh` would pick, + * else the best-ranked remote on the host. `host` is the API host it settled on, which a caller + * names when no repository was found there. GH_REPO is the caller's to check first. + */ +export function resolveGitHubRepository(input: { + readonly remotes: string; + readonly resolved: string; + /** The checkout's GitHub API host, when the caller already knows it. */ + readonly hostHint: string | undefined; + readonly defaultHost: string; +}): { readonly host: string; readonly locator: GitHubRepositoryLocator | null } { + const fetchRemotes = parseFetchRemotes(input.remotes).map((remote) => ({ + ...remote, + host: gitHubApiHostForRemote(remote.url), + })); + const host = + (input.hostHint === undefined ? undefined : apiHostForHint(input.hostHint)) ?? + fetchRemotes.find((remote) => remote.name === "origin" && remote.host !== null)?.host ?? + fetchRemotes.find((remote) => remote.host !== null)?.host ?? + input.defaultHost; + const selected = selectGitHubBaseRepository({ + remotes: input.remotes, + resolved: input.resolved, + host, + }); + if (selected !== null) return { host, locator: { host, ...selected } }; + // gh's own order without its prompt: upstream, github, origin, then the first remote, among + // the ones on this host (an SSH alias counts as its API host). + const rank = (name: string) => ["upstream", "github", "origin"].indexOf(name.toLowerCase()); + const candidates = fetchRemotes + .filter((remote) => remote.host === host) + .toSorted((left, right) => { + const l = rank(left.name); + const r = rank(right.name); + return (l === -1 ? 99 : l) - (r === -1 ? 99 : r); + }); + for (const remote of candidates) { + const [owner, name, ...rest] = normalizeGitRemoteUrl(remote.url).split("/").slice(1); + if (owner && name && rest.length === 0) return { host, locator: { host, owner, name } }; + } + return { host, locator: null }; +} + +/** `owner/name` or `host/owner/name`, as `gh --repo` and GH_REPO take them. */ +export function parseGitHubRepositorySelector( + selector: string, + defaultHost: string, +): GitHubRepositoryLocator | null { + const trimmed = selector.trim().replace(/\.git$/i, ""); + if (/^https?:\/\//i.test(trimmed)) { + try { + const url = new URL(trimmed); + const [owner, name, ...rest] = url.pathname.split("/").filter(Boolean); + return owner && name && rest.length === 0 + ? { host: url.host.toLowerCase(), owner, name } + : null; + } catch { + return null; + } + } + const parts = trimmed.split("/").filter(Boolean); + if (parts.length === 2) return { host: defaultHost, owner: parts[0]!, name: parts[1]! }; + if (parts.length === 3) + return { host: parts[0]!.toLowerCase(), owner: parts[1]!, name: parts[2]! }; + return null; +} + +/** + * A pull request reference the way `gh pr view` takes one: a number (`#7` too), a pull request + * URL, or a branch name. + */ +export function parsePullRequestReference( + reference: string, +): + | { readonly kind: "number"; readonly number: number } + | { readonly kind: "url"; readonly locator: GitHubRepositoryLocator; readonly number: number } + | { readonly kind: "branch"; readonly headSelector: string } { + const trimmed = reference.trim(); + const numbered = /^#?([1-9]\d*)$/.exec(trimmed); + if (numbered) return { kind: "number", number: Number(numbered[1]) }; + if (/^https?:\/\//i.test(trimmed)) { + try { + const url = new URL(trimmed); + const match = /^\/([^/]+)\/([^/]+)\/pull\/([1-9]\d*)(?:\/.*)?$/.exec(url.pathname); + if (match) { + return { + kind: "url", + locator: { host: url.host.toLowerCase(), owner: match[1]!, name: match[2]! }, + number: Number(match[3]), + }; + } + } catch { + // Not a URL after all; read it as a branch. + } + } + return { kind: "branch", headSelector: trimmed }; +} + +/** + * The GitHub host a remote URL is served from, or null for a remote that is not GitHub. An SSH + * alias (`git@github-work:owner/repo`) names no API host of its own; it is read through + * `github.com`, which is what such an alias almost always stands for (issue #6198). + */ +export function gitHubApiHostForRemote(remoteUrl: string): string | null { + const provider = detectSourceControlProviderFromRemoteUrl(remoteUrl); + if (provider === null) return null; + const host = new URL(provider.baseUrl).host.toLowerCase(); + // A dotless SSH host is an alias from ~/.ssh/config, never a real API host. + if (isSshRemoteUrl(remoteUrl) && !host.includes(".")) { + return host.includes("github") ? "github.com" : null; + } + return provider.kind === "github" ? host : null; +} + +/** A caller's host hint, read the way a remote's host is: a dotless alias is not an API host. */ +function apiHostForHint(host: string): string { + const normalized = host.toLowerCase(); + return !normalized.includes(".") && normalized.includes("github") ? "github.com" : normalized; +} diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 5f64ecb12f87..55d5ec85b5a5 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -229,7 +229,7 @@ import * as SourceControlDiscovery from "./sourceControl/SourceControlDiscovery. import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts"; import * as AzureDevOpsCli from "./sourceControl/AzureDevOpsCli.ts"; import * as BitbucketApi from "./sourceControl/BitbucketApi.ts"; -import * as GitHubRepositoryApi from "./sourceControl/GitHubRepositoryApi.ts"; +import * as GitHubApi from "./sourceControl/GitHubApi.ts"; import * as GitLabCli from "./sourceControl/GitLabCli.ts"; import * as ForgejoCli from "./sourceControl/ForgejoCli.ts"; import * as SourceControlProviderRegistry from "./sourceControl/SourceControlProviderRegistry.ts"; @@ -3190,7 +3190,7 @@ export const layer = Layer.unwrap( Layer.mergeAll( AzureDevOpsCli.layer, BitbucketApi.layer, - GitHubRepositoryApi.layer, + GitHubApi.layerWithDependencies, GitLabCli.layer, ForgejoCli.layer, ), From d720210996a514368ba99f4860063110033d93fa Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 7 Oct 2026 17:26:26 -0700 Subject: [PATCH 25/25] refactor(server): GitHub rate limits read the response headers (#16986) Co-authored-by: Claude Opus 5.5 (1M context) --- apps/server/scripts/measure-pr-preview.ts | 16 +- .../pullRequest/GitHubPullRequestApi.test.ts | 122 ++++----- .../src/sourceControl/GitHubApi.test.ts | 76 ++++-- apps/server/src/sourceControl/GitHubApi.ts | 50 ++-- .../GitHubSourceControlProvider.test.ts | 2 + .../GitHubSourceControlProvider.ts | 10 +- .../sourceControl/githubGraphQlBudget.test.ts | 241 ------------------ .../src/sourceControl/githubGraphQlBudget.ts | 150 ----------- .../src/sourceControl/githubQuota.test.ts | 86 +++++++ apps/server/src/sourceControl/githubQuota.ts | 122 +++++++++ 10 files changed, 350 insertions(+), 525 deletions(-) delete mode 100644 apps/server/src/sourceControl/githubGraphQlBudget.test.ts delete mode 100644 apps/server/src/sourceControl/githubGraphQlBudget.ts create mode 100644 apps/server/src/sourceControl/githubQuota.test.ts create mode 100644 apps/server/src/sourceControl/githubQuota.ts diff --git a/apps/server/scripts/measure-pr-preview.ts b/apps/server/scripts/measure-pr-preview.ts index f1fc491b1a2b..e8f0727ac93c 100644 --- a/apps/server/scripts/measure-pr-preview.ts +++ b/apps/server/scripts/measure-pr-preview.ts @@ -1,7 +1,7 @@ // Run with: node apps/server/scripts/measure-pr-preview.ts owner/repo 123 456 // Numbers form a session with shared repository-permission caches. Browser and // service caches are excluded. Uses real GitHub reads, without a server or database. -// Every GraphQL read carries `rateLimit`, so its cost is read off its own answer. +// Each GraphQL read asks for `rateLimit` here, so its cost is read off its own answer. import * as NodeServices from "@effect/platform-node/NodeServices"; import * as Effect from "effect/Effect"; import * as Console from "effect/Console"; @@ -15,7 +15,7 @@ import * as GitHubPullRequestProvider from "../src/pullRequest/GitHubPullRequest import * as GitHubApi from "../src/sourceControl/GitHubApi.ts"; import * as GitHubCredentials from "../src/sourceControl/GitHubCredentials.ts"; import * as ServerSettings from "../src/serverSettings.ts"; -import * as GitHubGraphQlBudget from "../src/sourceControl/githubGraphQlBudget.ts"; +import * as GitHubQuota from "../src/sourceControl/githubQuota.ts"; import * as SourceControlRateLimit from "../src/sourceControl/SourceControlRateLimit.ts"; import * as VcsProcess from "../src/vcs/VcsProcess.ts"; @@ -33,6 +33,14 @@ const decodeCost = Schema.decodeUnknownOption( }), ), ); +/** The query with GitHub's own count of what it cost, for a read rather than a mutation. */ +function withRateLimit(query: string): string { + const end = query.lastIndexOf("}"); + return query.trimStart().startsWith("mutation") || end === -1 + ? query + : `${query.slice(0, end)}\n rateLimit { cost }\n${query.slice(end)}`; +} + const measuredApi = Layer.effect( GitHubApi.GitHubApi, Effect.gen(function* () { @@ -40,7 +48,7 @@ const measuredApi = Layer.effect( return GitHubApi.GitHubApi.of({ ...api, graphql: (input) => - api.graphql(input).pipe( + api.graphql({ ...input, query: withRateLimit(input.query) }).pipe( Effect.tap((body) => Effect.sync(() => reads.push({ @@ -66,7 +74,7 @@ const measuredApi = Layer.effect( }), ).pipe( Layer.provide(GitHubCredentials.layer.pipe(Layer.provide(ServerSettings.layerTest()))), - Layer.provide(GitHubGraphQlBudget.layer), + Layer.provide(GitHubQuota.layer), Layer.provide(SourceControlRateLimit.layer), Layer.provide(FetchHttpClient.layer), Layer.provide(VcsProcess.layer), diff --git a/apps/server/src/pullRequest/GitHubPullRequestApi.test.ts b/apps/server/src/pullRequest/GitHubPullRequestApi.test.ts index 20fc1dd88526..294d053d6f05 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestApi.test.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestApi.test.ts @@ -15,7 +15,7 @@ import { HttpClient, HttpClientResponse } from "effect/http"; import { AllowGitHubReserve } from "../sourceControl/GitHubApi.ts"; import * as GitHubApi from "../sourceControl/GitHubApi.ts"; import * as GitHubCredentials from "../sourceControl/GitHubCredentials.ts"; -import * as GitHubGraphQlBudget from "../sourceControl/githubGraphQlBudget.ts"; +import * as GitHubQuota from "../sourceControl/githubQuota.ts"; import * as SourceControlRateLimit from "../sourceControl/SourceControlRateLimit.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as GitHubPullRequestApi from "./GitHubPullRequestApi.ts"; @@ -69,26 +69,29 @@ const mockedStackMemberships = vi.fn<(call: ApiCall) => ApiAnswer>(() => ); /** - * A transport that answers from the mocks above but spends the real GraphQL budget, the way - * GitHubApi does, so the reserve and pause behaviour is still the module's to prove. + * A transport that answers from the mocks above but spends the real GraphQL quota from each + * answer's headers, the way GitHubApi does, so the reserve behaviour is still the module's to prove. */ const mockApi = Layer.effect( GitHubApi.GitHubApi, Effect.gen(function* () { - const budget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget; + const quota = yield* GitHubQuota.GitHubQuota; return GitHubApi.GitHubApi.of({ graphql: (input) => - budget - .query(input.host, input.query, input.allowReserve ? { allowReserve: true } : undefined) - .pipe( - Effect.flatMap((query) => - (input.query.includes("query PullRequestStackMemberships") - ? mockedStackMemberships - : mockedExecute)({ kind: "graphql", ...input, query }), - ), - Effect.map((answer) => answer.body), - Effect.tap((body) => budget.observe(input.host, body)), + GitHubApi.AllowGitHubReserve.pipe( + Effect.flatMap((interactive) => + quota.admit(input.host, "graphql", { + allowReserve: input.allowReserve ?? interactive, + }), ), + Effect.andThen(() => + (input.query.includes("query PullRequestStackMemberships") + ? mockedStackMemberships + : mockedExecute)({ kind: "graphql", ...input }), + ), + Effect.tap((answer) => quota.observe(input.host, answer.headers)), + Effect.map((answer) => answer.body), + ), rest: (input) => mockedExecute({ kind: "rest", ...input }), credential: (host) => mockedCredential(host), }); @@ -98,7 +101,7 @@ const mockApi = Layer.effect( const layer = it.layer( GitHubPullRequestApi.layer.pipe( Layer.provideMerge(mockApi), - Layer.provideMerge(GitHubGraphQlBudget.layer), + Layer.provideMerge(GitHubQuota.layer), Layer.provide(NodeCrypto.layer), Layer.provide(VcsProcess.layer.pipe(Layer.provideMerge(NodeServices.layer))), ), @@ -109,8 +112,19 @@ function output( body: string, truncated = false, invalidUtf8 = false, + headers: Readonly> = {}, ): GitHubApi.GitHubRestResponse { - return { status: 200, headers: {}, body, truncated, invalidUtf8 }; + return { status: 200, headers, body, truncated, invalidUtf8 }; +} + +/** GitHub's GraphQL quota headers, leaving `remaining` of 5,000 until `resetAt`. */ +function graphqlQuota(remaining: number, resetAt = "2099-08-13T14:00:00Z") { + return { + "x-ratelimit-resource": "graphql", + "x-ratelimit-limit": "5000", + "x-ratelimit-remaining": String(remaining), + "x-ratelimit-reset": String(Date.parse(resetAt) / 1000), + }; } /** @@ -493,7 +507,7 @@ it.effect( Effect.provide( GitHubApi.layer.pipe( Layer.provide(Layer.mergeAll(credentials, http)), - Layer.provide(GitHubGraphQlBudget.layer), + Layer.provide(GitHubQuota.layer), Layer.provide(SourceControlRateLimit.layer), Layer.merge(VcsProcess.layer), Layer.provideMerge(NodeServices.layer), @@ -538,15 +552,13 @@ it.effect( ); layer("GitHubPullRequestApi.layer", (it) => { - it.effect("admits only one concurrent preview above the reserve and resumes after reset", () => + it.effect("holds background previews once an answer reports the reserve, until it resets", () => Effect.gen(function* () { const now = yield* Clock.currentTimeMillis; - const budget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget; + const quota = yield* GitHubQuota.GitHubQuota; const resetAt = "2099-08-13T14:00:00Z"; - yield* budget.observe( - "preview-budget.example", - encodeJson({ data: { rateLimit: { cost: 1, limit: 5_000, remaining: 501, resetAt } } }), - ); + yield* quota.observe("preview-budget.example", graphqlQuota(501, resetAt)); + // GitHub's count, not ours: the answer below reports the reserve has been reached. mockedExecute.mockReturnValue( Effect.succeed( output( @@ -563,9 +575,11 @@ layer("GitHubPullRequestApi.layer", (it) => { author: null, }, }, - rateLimit: { cost: 1, limit: 5_000, remaining: 500, resetAt }, }, }), + false, + false, + graphqlQuota(499, resetAt), ), ), ); @@ -576,18 +590,9 @@ layer("GitHubPullRequestApi.layer", (it) => { host: "preview-budget.example", number: 7, }; - const results = yield* Effect.all( - Array.from({ length: 20 }, (_, index) => - cli.getPullRequestPreview({ ...input, number: index + 1 }).pipe(Effect.result), - ), - { concurrency: "unbounded" }, - ); - expect(results.filter((result) => result._tag === "Success")).toHaveLength(1); - for (const result of results) { - if (result._tag === "Failure") { - expect(result.failure._tag).toBe("SourceControlRateLimitPausedError"); - } - } + yield* cli.getPullRequestPreview(input); + const held = yield* Effect.flip(cli.getPullRequestPreview({ ...input, number: 8 })); + expect(held._tag).toBe("SourceControlRateLimitPausedError"); expect(mockedExecute).toHaveBeenCalledTimes(1); yield* TestClock.setTime(Date.parse(resetAt)); yield* cli.getPullRequestPreview(input); @@ -3220,7 +3225,7 @@ layer("GitHubPullRequestApi.layer", (it) => { }), ); - it.effect("accounts for the avatar lookup in the GraphQL budget", () => + it.effect("looks up avatars by node id in one GraphQL read", () => Effect.gen(function* () { mockedExecute.mockReturnValueOnce( Effect.succeed( @@ -3228,12 +3233,6 @@ layer("GitHubPullRequestApi.layer", (it) => { encodeJson({ data: { nodes: [{ login: "octocat", avatarUrl: "https://avatars/octocat" }], - rateLimit: { - cost: 1, - limit: 5_000, - remaining: 4_999, - resetAt: "2099-08-13T14:00:00Z", - }, }, }), ), @@ -3249,7 +3248,6 @@ layer("GitHubPullRequestApi.layer", (it) => { }); expect(varsAt(0)).toEqual({ ids: ["MDQ6VXNlcjE="] }); - expect(queryAt(0)).toContain("rateLimit { cost limit remaining resetAt }"); expect(avatars.get("octocat")).toBe("https://avatars/octocat"); }), ); @@ -3852,22 +3850,7 @@ layer("GitHubPullRequestApi.layer", (it) => { Effect.gen(function* () { const response = coreResponse(); mockedExecute.mockReturnValue( - Effect.succeed( - output( - encodeJson({ - ...response, - data: { - ...response.data, - rateLimit: { - cost: 1, - limit: 5000, - remaining: 500, - resetAt: "2099-08-13T14:00:00Z", - }, - }, - }), - ), - ), + Effect.succeed(output(encodeJson(response), false, false, graphqlQuota(499))), ); const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; const input = { @@ -4125,14 +4108,11 @@ layer("GitHubPullRequestApi.layer", (it) => { viewerPermission: "READ", pullRequest: { viewerCanUpdate: true, viewerDidAuthor: true }, }, - rateLimit: { - cost: 1, - limit: 5_000, - remaining: 500, - resetAt: "2099-08-13T14:00:00Z", - }, }, }), + false, + false, + graphqlQuota(499), ), ), ); @@ -4145,7 +4125,6 @@ layer("GitHubPullRequestApi.layer", (it) => { } as const; yield* cli.getViewerAccess(input); - expect(queryAt(0)).toContain("rateLimit { cost limit remaining resetAt }"); const error = yield* Effect.flip(cli.getViewerAccess(input)); @@ -4173,14 +4152,11 @@ layer("GitHubPullRequestApi.layer", (it) => { viewerPermission: "READ", pullRequest: { viewerCanUpdate: true, viewerDidAuthor: true }, }, - rateLimit: { - cost: 1, - limit: 5_000, - remaining: 500, - resetAt: "2099-08-13T14:00:00Z", - }, }, }), + false, + false, + graphqlQuota(499), ), ), ) diff --git a/apps/server/src/sourceControl/GitHubApi.test.ts b/apps/server/src/sourceControl/GitHubApi.test.ts index a5c0f82be443..60e2c6f1b45d 100644 --- a/apps/server/src/sourceControl/GitHubApi.test.ts +++ b/apps/server/src/sourceControl/GitHubApi.test.ts @@ -12,7 +12,7 @@ import { HttpClient, HttpClientResponse, type HttpClientRequest } from "effect/h import * as GitHubApi from "./GitHubApi.ts"; import * as GitHubCredentials from "./GitHubCredentials.ts"; -import * as GitHubGraphQlBudget from "./githubGraphQlBudget.ts"; +import * as GitHubQuota from "./githubQuota.ts"; import * as SourceControlRateLimit from "./SourceControlRateLimit.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as ServerSettings from "../serverSettings.ts"; @@ -49,7 +49,7 @@ function harness(respond: (request: HttpClientRequest.HttpClientRequest) => Resp ); const layer = GitHubApi.layer.pipe( Layer.provide(Layer.mergeAll(credentials, http)), - Layer.provideMerge(GitHubGraphQlBudget.layer), + Layer.provideMerge(GitHubQuota.layer), Layer.provideMerge(SourceControlRateLimit.layer), ); return { layer, requests, invalidations: () => invalidations }; @@ -97,15 +97,8 @@ describe("environmentToken", () => { }); describe("GitHubApi", () => { - it.effect("sends GraphQL with the token and records the reported budget", () => { - const { layer, requests } = harness(() => - json({ - data: { - viewer: { login: "julius" }, - rateLimit: { cost: 1, limit: 5000, remaining: 4999, resetAt: "2026-10-05T13:00:00Z" }, - }, - }), - ); + it.effect("sends GraphQL with the token, and the document as written", () => { + const { layer, requests } = harness(() => json({ data: { viewer: { login: "julius" } } })); return Effect.gen(function* () { yield* TestClock.setTime(NOW); const api = yield* GitHubApi.GitHubApi; @@ -117,11 +110,16 @@ describe("GitHubApi", () => { expect(body).toContain('"login":"julius"'); expect(requests[0]!.url).toBe("https://api.github.com/graphql"); expect(requests[0]!.headers.authorization).toBe("Bearer first"); + // No `rateLimit` selection is added: the quota is read off the response headers. + const sent = requests[0]!.body; + expect(sent._tag === "Uint8Array" ? new TextDecoder().decode(sent.body) : "").toContain( + '"query":"query { viewer { login } }"', + ); }).pipe(Effect.provide(layer)); }); it.effect( - "traces the operation, path, query and cost, never the query string or variables", + "traces the operation, path, query and quota, never the query string or variables", () => { const spans: Array = []; const tracer = Tracer.make({ @@ -134,17 +132,7 @@ describe("GitHubApi", () => { const { layer } = harness((request) => request.url.endsWith("/graphql") ? json( - { - data: { - viewer: { login: "julius" }, - rateLimit: { - cost: 3, - limit: 5000, - remaining: 4990, - resetAt: "2026-10-05T13:00:00Z", - }, - }, - }, + { data: { viewer: { login: "julius" } } }, { headers: { "x-ratelimit-remaining": "4990", "x-ratelimit-resource": "graphql" } }, ) : json({ ok: true }, { headers: { "x-ratelimit-remaining": "4800" } }), @@ -168,8 +156,10 @@ describe("GitHubApi", () => { expect(graphql).toMatchObject({ "github.operation": "getPullRequestDetail", "github.graphql.query": "query($body: String!) { viewer { login } }", - "github.graphql.cost": 3, - "github.graphql.remaining": 4990, + }); + expect(Object.fromEntries(byName("GitHubApi.send")[0]!.attributes)).toMatchObject({ + "github.ratelimit.remaining": 4990, + "github.ratelimit.resource": "graphql", }); expect(String(graphql["github.graphql.query_hash"])).toMatch(/^[0-9a-f]{8}$/); const rest = Object.fromEntries(byName("GitHubApi.send")[1]!.attributes); @@ -258,6 +248,42 @@ describe("GitHubApi", () => { }).pipe(Effect.provide(layer)); }); + it.effect("keeps the last tenth of a quota for interactive requests until it resets", () => { + const reset = Math.floor(NOW / 1000) + 600; + const quota = (remaining: number) => ({ + "x-ratelimit-resource": "core", + "x-ratelimit-limit": "5000", + "x-ratelimit-remaining": String(remaining), + "x-ratelimit-reset": String(reset), + }); + let remaining = 499; + const { layer, requests } = harness((request) => + request.url.endsWith("/graphql") + ? json({ data: { viewer: { id: "1" } } }) + : json({ ok: true }, { headers: quota(remaining--) }), + ); + return Effect.gen(function* () { + yield* TestClock.setTime(NOW); + const api = yield* GitHubApi.GitHubApi; + const read = { host: "github.com", operation: "sweep", path: "repos/acme/web/pulls" }; + // The first answer leaves 499 of 5000, under the last tenth; the next background read waits. + yield* api.rest(read); + const refused = yield* Effect.flip(api.rest(read)); + expect(refused).toMatchObject({ + _tag: "SourceControlRateLimitPausedError", + retryAt: reset * 1000, + }); + // A user's own request may spend the reserve, and GraphQL has a quota of its own. + yield* api.rest(read).pipe(Effect.provideService(GitHubApi.AllowGitHubReserve, true)); + yield* api.graphql({ host: "github.com", operation: "x", query: "query { viewer { id } }" }); + expect(requests).toHaveLength(3); + // The reset gives the background its quota back. + yield* TestClock.setTime(reset * 1000); + yield* api.rest(read); + expect(requests).toHaveLength(4); + }).pipe(Effect.provide(layer)); + }); + it.effect("carries GitHub's own reason for a refused REST request", () => { const { layer } = harness(() => json( diff --git a/apps/server/src/sourceControl/GitHubApi.ts b/apps/server/src/sourceControl/GitHubApi.ts index 0da53e0230b1..5f29e8b4053d 100644 --- a/apps/server/src/sourceControl/GitHubApi.ts +++ b/apps/server/src/sourceControl/GitHubApi.ts @@ -12,7 +12,7 @@ import { HttpClient, HttpClientRequest, type HttpClientResponse } from "effect/h import { collectUint8StreamText } from "../stream/collectUint8StreamText.ts"; import * as GitHubCredentials from "./GitHubCredentials.ts"; -import * as GitHubGraphQlBudget from "./githubGraphQlBudget.ts"; +import * as GitHubQuota from "./githubQuota.ts"; import * as SourceControlRateLimit from "./SourceControlRateLimit.ts"; const DEFAULT_TIMEOUT = Duration.seconds(30); @@ -218,16 +218,6 @@ function rateLimitAttributes( return attributes; } -const decodeGraphQlCost = Schema.decodeUnknownOption( - Schema.fromJsonString( - Schema.Struct({ - data: Schema.Struct({ - rateLimit: Schema.Struct({ cost: Schema.Number, remaining: Schema.Number }), - }), - }), - ), -); - /** The pause GitHub asked for, from `retry-after` or the primary limit's reset. */ function retryAtFrom( headers: Readonly>, @@ -354,7 +344,7 @@ function classify(input: { export const make = Effect.gen(function* () { const httpClient = yield* HttpClient.HttpClient; const credentials = yield* GitHubCredentials.GitHubCredentials; - const budget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget; + const quota = yield* GitHubQuota.GitHubQuota; const limits = yield* SourceControlRateLimit.SourceControlRateLimit; const gate = yield* Semaphore.make(CONCURRENCY); @@ -403,10 +393,17 @@ export const make = Effect.gen(function* () { }); const { token, fingerprint } = yield* credential(host); const scope = yield* SourceControlRateLimit.CredentialScope; + // REST spends `core` and GraphQL its own quota, each with its own reserve. Every REST path + // this reads today is `core`; a `search/` read would need its own resource here. A refusal + // still pauses the whole host, the key PullRequestService records its own backoff under. + const resource = input.graphql === true ? "graphql" : "core"; const key = { provider: "github" as const, host }; const run = Effect.gen(function* () { - const lease = yield* limits - .check(key, input.allowReserve ? { allowPaused: true } : undefined) + const lease = yield* quota + .admit(host, resource, { allowReserve: input.allowReserve }) + .pipe( + Effect.andThen(limits.check(key, input.allowReserve ? { allowPaused: true } : undefined)), + ) .pipe( Effect.tapError((paused) => Effect.annotateCurrentSpan({ @@ -452,6 +449,7 @@ export const make = Effect.gen(function* () { ); const headers = response.headers; const status = response.status; + yield* quota.observe(host, headers); yield* Effect.annotateCurrentSpan({ "http.response.status_code": status, ...rateLimitAttributes(headers), @@ -556,17 +554,15 @@ export const make = Effect.gen(function* () { : input.query, }); return yield* Effect.gen(function* () { - const query = yield* budget.query( - host, - input.query, - allowReserve ? { allowReserve: true } : undefined, - ); const response = yield* send({ host, operation: input.operation, request: HttpClientRequest.post(gitHubApiUrls(host).graphql).pipe( HttpClientRequest.acceptJson, - HttpClientRequest.bodyJsonUnsafe({ query, variables: input.variables ?? {} }), + HttpClientRequest.bodyJsonUnsafe({ + query: input.query, + variables: input.variables ?? {}, + }), ), maxResponseBytes: input.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES, allowReserve, @@ -581,14 +577,6 @@ export const make = Effect.gen(function* () { status: response.status, }); } - yield* budget.observe(host, response.body); - const cost = Option.getOrUndefined(decodeGraphQlCost(response.body)); - if (cost !== undefined) { - yield* Effect.annotateCurrentSpan({ - "github.graphql.cost": cost.data.rateLimit.cost, - "github.graphql.remaining": cost.data.rateLimit.remaining, - }); - } return response.body; }).pipe(Effect.provideService(SourceControlRateLimit.CredentialScope, scope)); }, @@ -600,11 +588,11 @@ export const make = Effect.gen(function* () { export const layer = Layer.effect(GitHubApi, make); /** - * The transport with the credential, GraphQL budget and rate-limit pause it reads through. An - * application builds this once so every GitHub reader shares one budget and one pause per host. + * The transport with the credential, quota reserve and rate-limit pause it reads through. An + * application builds this once so every GitHub reader shares one view of each quota per host. */ export const layerWithDependencies = layer.pipe( Layer.provideMerge(GitHubCredentials.layer), - Layer.provideMerge(GitHubGraphQlBudget.layer), + Layer.provideMerge(GitHubQuota.layer), Layer.provideMerge(SourceControlRateLimit.layer), ); diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts index b70bfff78f6b..caa91a5ac937 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts @@ -459,6 +459,8 @@ describe("GitHubSourceControlProvider writes", () => { }); assert.strictEqual(requests[0]!.method, "POST"); assert.strictEqual(requests[0]!.path, "repos/acme/web/pulls"); + // A user's own write, so the background's reserve is not held against it. + assert.strictEqual(requests[0]!.allowReserve, true); assert.deepStrictEqual(requests[0]!.body, { base: "main", head: "me:feature", diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts index 86edda994cce..223a2ec58a26 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts @@ -679,7 +679,12 @@ export const make = Effect.gen(function* () { const readViewerLogin = Effect.fn("GitHubSourceControlProvider.readViewerLogin")(function* ( host: string, ) { - const response = yield* rest({ host, operation: "getViewer", path: "user" }); + const response = yield* rest({ + host, + operation: "getViewer", + path: "user", + allowReserve: true, + }); const decoded = decodeViewerLogin(response.body); if (Result.isFailure(decoded)) { return yield* failure("GitHub request failed.", decoded.failure); @@ -944,6 +949,8 @@ export const make = Effect.gen(function* () { host: locator.host, operation: "createPullRequest", method: "POST", + // A user's own write may spend the reserve the background leaves for it. + allowReserve: true, path: `repos/${encodeURIComponent(locator.owner)}/${encodeURIComponent(locator.name)}/pulls`, // `owner:branch` is how the REST API takes a fork's head, the same as `gh --head`. // gh allows maintainer edits unless told otherwise; the API's default is not documented. @@ -990,6 +997,7 @@ export const make = Effect.gen(function* () { host, operation: "createRepository", method: "POST", + allowReserve: true, path: isViewer || owner === null ? "user/repos" : `orgs/${encodeURIComponent(owner)}/repos`, body: { name, private: input.visibility === "private" }, diff --git a/apps/server/src/sourceControl/githubGraphQlBudget.test.ts b/apps/server/src/sourceControl/githubGraphQlBudget.test.ts deleted file mode 100644 index 26f9747f3202..000000000000 --- a/apps/server/src/sourceControl/githubGraphQlBudget.test.ts +++ /dev/null @@ -1,241 +0,0 @@ -import { describe, expect, it } from "@effect/vitest"; -import * as Effect from "effect/Effect"; -import * as TestClock from "effect/testing/TestClock"; - -import * as GitHubGraphQlBudget from "./githubGraphQlBudget.ts"; -import { CredentialScope } from "./SourceControlRateLimit.ts"; - -const RESET_AT = "2026-08-13T14:00:00.000Z"; -const NEXT_RESET_AT = "2026-08-13T15:00:00.000Z"; -const BEFORE_RESET = Date.parse("2026-08-13T13:30:00.000Z"); -const AFTER_RESET = Date.parse("2026-08-13T14:00:01.000Z"); - -function rateLimit(remaining: number, limit = 5_000, resetAt = RESET_AT, cost = 14): string { - return JSON.stringify({ - data: { - viewer: { login: "bilal" }, - rateLimit: { cost, limit, remaining, resetAt }, - }, - }); -} - -describe("GitHub GraphQL budget", () => { - it.effect("adds rate metadata to a read query", () => - Effect.gen(function* () { - const budget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget; - - const query = yield* budget.query( - "github.com", - 'query { repository(owner: "acme", name: "web") { name } }', - ); - - expect(query).toContain("rateLimit { cost limit remaining resetAt }"); - expect(query).toContain('repository(owner: "acme", name: "web") { name }'); - }).pipe(Effect.provide(GitHubGraphQlBudget.layer)), - ); - - it.effect("protects the last ten percent with the shared provider cooldown error", () => - Effect.gen(function* () { - yield* TestClock.setTime(BEFORE_RESET); - const budget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget; - yield* budget.observe("github.com", rateLimit(500)); - - const error = yield* Effect.flip(budget.query("github.com", "query { viewer { login } }")); - expect(error).toMatchObject({ - _tag: "SourceControlRateLimitPausedError", - provider: "github", - host: "github.com", - retryAt: Date.parse(RESET_AT), - }); - expect(error.message).toBe( - "github requests to github.com are paused until the rate limit resets.", - ); - - yield* TestClock.setTime(AFTER_RESET); - expect(yield* budget.query("github.com", "query { viewer { login } }")).toContain( - "rateLimit", - ); - }).pipe(Effect.provide(GitHubGraphQlBudget.layer)), - ); - - it.effect("keeps hosts isolated", () => - Effect.gen(function* () { - yield* TestClock.setTime(BEFORE_RESET); - const budget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget; - yield* budget.observe("github.com", rateLimit(0)); - - const error = yield* Effect.flip(budget.query("github.com", "query { viewer { login } }")); - expect(error._tag).toBe("SourceControlRateLimitPausedError"); - expect(yield* budget.query("github.example.com", "query { viewer { login } }")).toContain( - "rateLimit", - ); - }).pipe(Effect.provide(GitHubGraphQlBudget.layer)), - ); - - it.effect("isolates query reservations and observations by credential", () => - Effect.gen(function* () { - yield* TestClock.setTime(BEFORE_RESET); - const budget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget; - const query = budget.query("github.com", "query { viewer { login } }"); - yield* budget - .observe("github.com", rateLimit(0)) - .pipe(Effect.provideService(CredentialScope, "first")); - yield* budget - .observe("github.com", rateLimit(5000)) - .pipe(Effect.provideService(CredentialScope, "second")); - yield* query.pipe(Effect.provideService(CredentialScope, "second")); - const error = yield* query.pipe(Effect.provideService(CredentialScope, "first"), Effect.flip); - expect(error.retryAt).toBe(Date.parse(RESET_AT)); - }).pipe(Effect.provide(GitHubGraphQlBudget.layer)), - ); - - it.effect("keeps the lower remaining value from out-of-order responses", () => - Effect.gen(function* () { - yield* TestClock.setTime(BEFORE_RESET); - const budget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget; - yield* budget.observe("github.com", rateLimit(400)); - yield* budget.observe("github.com", rateLimit(600)); - - const error = yield* Effect.flip(budget.query("github.com", "query { viewer { login } }")); - expect(error).toMatchObject({ - _tag: "SourceControlRateLimitPausedError", - retryAt: Date.parse(RESET_AT), - }); - }).pipe(Effect.provide(GitHubGraphQlBudget.layer)), - ); - - it.effect("learns a cheaper observed cost without restoring reserved quota", () => - Effect.gen(function* () { - yield* TestClock.setTime(BEFORE_RESET); - const budget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget; - const query = "query { viewer { login } }"; - yield* budget.observe("github.com", rateLimit(512, 5_000, RESET_AT, 8)); - yield* budget.query("github.com", query); - // The admission reserved eight points, but the completed read only cost one. - yield* budget.observe("github.com", rateLimit(511, 5_000, RESET_AT, 1)); - - for (let count = 0; count < 4; count += 1) { - expect(yield* budget.query("github.com", query)).toContain("rateLimit"); - } - const error = yield* Effect.flip(budget.query("github.com", query)); - expect(error).toMatchObject({ - _tag: "SourceControlRateLimitPausedError", - retryAt: Date.parse(RESET_AT), - }); - }).pipe(Effect.provide(GitHubGraphQlBudget.layer)), - ); - - it.effect("ignores a response from an older reset window", () => - Effect.gen(function* () { - yield* TestClock.setTime(BEFORE_RESET); - const budget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget; - yield* budget.observe("github.com", rateLimit(400, 5_000, NEXT_RESET_AT)); - yield* budget.observe("github.com", rateLimit(1_000, 5_000, RESET_AT)); - - const error = yield* Effect.flip(budget.query("github.com", "query { viewer { login } }")); - expect(error).toMatchObject({ - _tag: "SourceControlRateLimitPausedError", - retryAt: Date.parse(NEXT_RESET_AT), - }); - }).pipe(Effect.provide(GitHubGraphQlBudget.layer)), - ); - - it.effect("accepts a response from a later reset window", () => - Effect.gen(function* () { - yield* TestClock.setTime(BEFORE_RESET); - const budget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget; - yield* budget.observe("github.com", rateLimit(1_000)); - yield* budget.observe("github.com", rateLimit(400, 5_000, NEXT_RESET_AT)); - - const error = yield* Effect.flip(budget.query("github.com", "query { viewer { login } }")); - expect(error).toMatchObject({ - _tag: "SourceControlRateLimitPausedError", - retryAt: Date.parse(NEXT_RESET_AT), - }); - }).pipe(Effect.provide(GitHubGraphQlBudget.layer)), - ); - - it.effect("allows reads above the reserve", () => - Effect.gen(function* () { - yield* TestClock.setTime(BEFORE_RESET); - const budget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget; - yield* budget.observe("github.com", rateLimit(515)); - - expect(yield* budget.query("github.com", "query { viewer { login } }")).toContain( - "rateLimit", - ); - }).pipe(Effect.provide(GitHubGraphQlBudget.layer)), - ); - - it.effect("allows an interactive read to use the reserve", () => - Effect.gen(function* () { - yield* TestClock.setTime(BEFORE_RESET); - const budget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget; - yield* budget.observe("github.com", rateLimit(500)); - - expect( - yield* budget.query("github.com", "query { viewer { login } }", { - allowReserve: true, - }), - ).toContain("rateLimit"); - }).pipe(Effect.provide(GitHubGraphQlBudget.layer)), - ); - - it.effect("reserves an admitted query cost before its response", () => - Effect.gen(function* () { - yield* TestClock.setTime(BEFORE_RESET); - const budget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget; - yield* budget.observe("github.com", rateLimit(514)); - - expect(yield* budget.query("github.com", "query { viewer { login } }")).toContain( - "rateLimit", - ); - const error = yield* Effect.flip(budget.query("github.com", "query { viewer { login } }")); - - expect(error).toMatchObject({ - _tag: "SourceControlRateLimitPausedError", - retryAt: Date.parse(RESET_AT), - }); - }).pipe(Effect.provide(GitHubGraphQlBudget.layer)), - ); - - it.effect("stops interactive reads when the reserve is exhausted", () => - Effect.gen(function* () { - yield* TestClock.setTime(BEFORE_RESET); - const budget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget; - yield* budget.observe("github.com", rateLimit(1, 5000, RESET_AT, 1)); - yield* budget.query("github.com", "query { viewer { login } }", { allowReserve: true }); - const error = yield* budget - .query("github.com", "query { viewer { login } }", { allowReserve: true }) - .pipe(Effect.flip); - expect(error.retryAt).toBe(Date.parse(RESET_AT)); - }).pipe(Effect.provide(GitHubGraphQlBudget.layer)), - ); - - it.effect("ignores malformed or partial rate metadata", () => - Effect.gen(function* () { - yield* TestClock.setTime(BEFORE_RESET); - const budget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget; - yield* budget.observe("github.com", "{"); - yield* budget.observe( - "github.com", - '{"data":{"rateLimit":{"limit":0,"remaining":-1,"resetAt":"never"}}}', - ); - - expect(yield* budget.query("github.com", "query { viewer { login } }")).toContain( - "rateLimit", - ); - }).pipe(Effect.provide(GitHubGraphQlBudget.layer)), - ); - - it.effect("does not add a read field to a mutation", () => - Effect.gen(function* () { - yield* TestClock.setTime(BEFORE_RESET); - const budget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget; - const mutation = "mutation { addComment(input: {}) { clientMutationId } }"; - yield* budget.observe("github.com", rateLimit(0)); - - expect(yield* budget.query("github.com", mutation)).toBe(mutation); - }).pipe(Effect.provide(GitHubGraphQlBudget.layer)), - ); -}); diff --git a/apps/server/src/sourceControl/githubGraphQlBudget.ts b/apps/server/src/sourceControl/githubGraphQlBudget.ts deleted file mode 100644 index 1021f6cde9fa..000000000000 --- a/apps/server/src/sourceControl/githubGraphQlBudget.ts +++ /dev/null @@ -1,150 +0,0 @@ -import * as Clock from "effect/Clock"; -import * as Context from "effect/Context"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as Ref from "effect/Ref"; -import * as Predicate from "effect/Predicate"; - -import * as SourceControlRateLimit from "./SourceControlRateLimit.ts"; - -const GRAPHQL_RESERVE_RATIO = 0.1; -const RATE_LIMIT_SELECTION = "rateLimit { cost limit remaining resetAt }"; - -interface GraphQlBudgetSnapshot { - readonly cost: number; - readonly limit: number; - readonly remaining: number; - readonly resetAtMs: number; -} - -export class GitHubGraphQlBudget extends Context.Service< - GitHubGraphQlBudget, - { - readonly query: ( - host: string, - document: string, - options?: { readonly allowReserve: boolean }, - ) => Effect.Effect; - readonly observe: (host: string, raw: string) => Effect.Effect; - } ->()("t3/sourceControl/githubGraphQlBudget") {} - -function hostKey(host: string): string { - return host.trim().toLowerCase(); -} - -function snapshotFrom(raw: string): GraphQlBudgetSnapshot | null { - try { - const parsed: unknown = JSON.parse(raw); - if ( - !Predicate.isObject(parsed) || - !Predicate.isObject(parsed.data) || - !Predicate.isObject(parsed.data.rateLimit) - ) { - return null; - } - const { cost, limit, remaining, resetAt } = parsed.data.rateLimit; - if ( - typeof cost !== "number" || - !Number.isFinite(cost) || - cost < 0 || - typeof limit !== "number" || - !Number.isFinite(limit) || - limit <= 0 || - typeof remaining !== "number" || - !Number.isFinite(remaining) || - remaining < 0 || - typeof resetAt !== "string" - ) { - return null; - } - const resetAtMs = Date.parse(resetAt); - return Number.isFinite(resetAtMs) ? { cost, limit, remaining, resetAtMs } : null; - } catch { - return null; - } -} - -function isReadOperation(document: string): boolean { - const operation = document.trimStart(); - return operation.startsWith("query") || operation.startsWith("{"); -} - -function withRateLimit(document: string): string { - if (!isReadOperation(document)) return document; - const end = document.lastIndexOf("}"); - if (end === -1 || document.includes(RATE_LIMIT_SELECTION)) return document; - return `${document.slice(0, end)}\n ${RATE_LIMIT_SELECTION}\n${document.slice(end)}`; -} - -/** @public Service construction is part of the canonical Effect module API. */ -export const make = Effect.gen(function* () { - const snapshots = yield* Ref.make>(new Map()); - - const query: GitHubGraphQlBudget["Service"]["query"] = Effect.fn("GitHubGraphQlBudget.query")( - function* (host, document, options) { - if (!isReadOperation(document)) return document; - const now = yield* Clock.currentTimeMillis; - const key = `${hostKey(host)}\0${yield* SourceControlRateLimit.CredentialScope}`; - const retryAt = yield* Ref.modify(snapshots, (current) => { - const snapshot = current.get(key); - if (snapshot === undefined) return [null, current] as const; - if (snapshot.resetAtMs <= now) { - const next = new Map(current); - next.delete(key); - return [null, next] as const; - } - const remaining = snapshot.remaining - Math.max(1, snapshot.cost); - if ( - remaining < 0 || - (options?.allowReserve !== true && remaining < snapshot.limit * GRAPHQL_RESERVE_RATIO) - ) { - return [snapshot.resetAtMs, current] as const; - } - const next = new Map(current); - next.set(key, { ...snapshot, remaining }); - return [null, next] as const; - }); - if (retryAt !== null) { - return yield* new SourceControlRateLimit.SourceControlRateLimitPausedError({ - provider: "github", - host: hostKey(host), - retryAt, - }); - } - return withRateLimit(document); - }, - ); - - const observe: GitHubGraphQlBudget["Service"]["observe"] = Effect.fn( - "GitHubGraphQlBudget.observe", - )(function* (host, raw) { - const snapshot = snapshotFrom(raw); - if (snapshot === null) return; - const key = `${hostKey(host)}\0${yield* SourceControlRateLimit.CredentialScope}`; - yield* Ref.update(snapshots, (current) => { - const previous = current.get(key); - // Concurrent reads can finish out of order. Quota only falls within one reset window, and - // an answer from an older window must not replace the current one. - if (previous !== undefined && snapshot.resetAtMs < previous.resetAtMs) { - return current; - } - const next = new Map(current); - // Keep the conservative balance, but learn the observed cost even when our reservation - // was larger. Otherwise one expensive read makes every later cheap read spend its cost. - next.set( - key, - previous !== undefined && - snapshot.resetAtMs === previous.resetAtMs && - snapshot.remaining >= previous.remaining - ? { ...previous, cost: snapshot.cost } - : snapshot, - ); - return next; - }); - }); - - return GitHubGraphQlBudget.of({ query, observe }); -}); - -export const layer = Layer.effect(GitHubGraphQlBudget, make); diff --git a/apps/server/src/sourceControl/githubQuota.test.ts b/apps/server/src/sourceControl/githubQuota.test.ts new file mode 100644 index 000000000000..e21970cecca7 --- /dev/null +++ b/apps/server/src/sourceControl/githubQuota.test.ts @@ -0,0 +1,86 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as TestClock from "effect/testing/TestClock"; + +import * as GitHubQuota from "./githubQuota.ts"; +import { CredentialScope } from "./SourceControlRateLimit.ts"; + +const RESET = Date.parse("2099-08-13T14:00:00Z"); +const headers = (remaining: number, resource = "graphql", reset = RESET) => ({ + "x-ratelimit-resource": resource, + "x-ratelimit-limit": "5000", + "x-ratelimit-remaining": String(remaining), + "x-ratelimit-reset": String(reset / 1000), +}); + +describe("GitHubQuota", () => { + it.effect("refuses a background request below the reserve, per quota and account", () => + Effect.gen(function* () { + const quota = yield* GitHubQuota.GitHubQuota; + yield* quota.observe("github.com", headers(499)); + const refused = yield* Effect.flip(quota.admit("github.com", "graphql")); + assert.strictEqual(refused.retryAt, RESET); + // Another quota, another host and another account each have their own balance. + yield* quota.admit("github.com", "core"); + yield* quota.admit("ghe.example", "graphql"); + yield* quota.admit("github.com", "graphql").pipe(Effect.provideService(CredentialScope, "b")); + yield* quota.admit("github.com", "graphql", { allowReserve: true }); + }).pipe(Effect.provide(GitHubQuota.layer)), + ); + + it.effect("takes the latest balance in a window, and ignores an older window", () => + Effect.gen(function* () { + const quota = yield* GitHubQuota.GitHubQuota; + yield* quota.observe("github.com", headers(400)); + yield* Effect.flip(quota.admit("github.com", "graphql")); + // GitHub can report more left later in the same window, from another region. + yield* quota.observe("github.com", headers(900)); + yield* quota.admit("github.com", "graphql"); + yield* quota.observe("github.com", headers(400)); + yield* Effect.flip(quota.admit("github.com", "graphql")); + // An answer from an older window says nothing about this one. + yield* quota.observe("github.com", headers(4000, "graphql", RESET - 3_600_000)); + yield* Effect.flip(quota.admit("github.com", "graphql")); + // A later window replaces it. + yield* quota.observe("github.com", headers(4000, "graphql", RESET + 3_600_000)); + yield* quota.admit("github.com", "graphql"); + }).pipe(Effect.provide(GitHubQuota.layer)), + ); + + it.effect("lets interactive requests spend the reserve but not an empty quota", () => + Effect.gen(function* () { + const quota = yield* GitHubQuota.GitHubQuota; + yield* quota.observe("github.com", headers(1)); + yield* quota.admit("github.com", "graphql", { allowReserve: true }); + yield* quota.observe("github.com", headers(0)); + const refused = yield* Effect.flip( + quota.admit("github.com", "graphql", { allowReserve: true }), + ); + assert.strictEqual(refused.retryAt, RESET); + }).pipe(Effect.provide(GitHubQuota.layer)), + ); + + it.effect("spends nothing locally, so a free 304 cannot run the balance down", () => + Effect.gen(function* () { + const quota = yield* GitHubQuota.GitHubQuota; + yield* quota.observe("github.com", headers(501, "core")); + // Conditional reads answer 304 with the balance unchanged, however many are sent. + for (let index = 0; index < 5; index++) { + yield* quota.admit("github.com", "core"); + yield* quota.observe("github.com", headers(501, "core")); + } + yield* quota.admit("github.com", "core"); + }).pipe(Effect.provide(GitHubQuota.layer)), + ); + + it.effect("forgets a balance once its window resets, and ignores answers naming no quota", () => + Effect.gen(function* () { + const quota = yield* GitHubQuota.GitHubQuota; + yield* quota.observe("github.com", headers(0)); + yield* TestClock.setTime(RESET); + yield* quota.admit("github.com", "graphql"); + yield* quota.observe("other.example", { "x-ratelimit-remaining": "0" }); + yield* quota.admit("other.example", "graphql"); + }).pipe(Effect.provide(GitHubQuota.layer)), + ); +}); diff --git a/apps/server/src/sourceControl/githubQuota.ts b/apps/server/src/sourceControl/githubQuota.ts new file mode 100644 index 000000000000..c553535019f4 --- /dev/null +++ b/apps/server/src/sourceControl/githubQuota.ts @@ -0,0 +1,122 @@ +import * as Clock from "effect/Clock"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; + +import * as SourceControlRateLimit from "./SourceControlRateLimit.ts"; + +/** The share of each quota a background read leaves for a user's next click. */ +const RESERVE_RATIO = 0.1; + +/** + * GitHub's quotas: `core` for REST, `graphql` for GraphQL, and a few smaller ones (`search`, …). + * Each is spent and reset on its own, so each is tracked on its own. + */ +type GitHubQuotaResource = string; + +interface QuotaSnapshot { + readonly limit: number; + readonly remaining: number; + readonly resetAtMs: number; +} + +/** + * What GitHub says is left of one quota, from the `x-ratelimit-*` headers every API answer + * carries. Null when the answer named no quota, which an Enterprise host with limits off does. + */ +function quotaFromHeaders( + headers: Readonly>, +): (QuotaSnapshot & { readonly resource: GitHubQuotaResource }) | null { + const resource = headers["x-ratelimit-resource"]?.trim(); + const limit = Number(headers["x-ratelimit-limit"]); + const remaining = Number(headers["x-ratelimit-remaining"]); + const reset = Number(headers["x-ratelimit-reset"]); + if ( + !resource || + !Number.isFinite(limit) || + limit <= 0 || + !Number.isFinite(remaining) || + remaining < 0 || + !Number.isFinite(reset) + ) { + return null; + } + return { resource, limit, remaining, resetAtMs: reset * 1_000 }; +} + +/** + * Keeps the last tenth of each GitHub quota for interactive requests. A background sweep that + * would dip below it is refused until the quota resets, so a user's next click still has budget. + * + * The balance is GitHub's own count from the latest answer's headers, so it already includes + * what anything else spent with the same token, such as an agent's `gh` commands. Requests in + * flight together all see that one balance; the reserve absorbs what they spend before their + * answers land. Nothing is debited locally, because a 304 or a request that never reached GitHub + * spends nothing, and a local count would drift below GitHub's for the rest of the window. + * + * Interactive requests may spend the reserve, but not a quota GitHub already reported empty. + */ +export class GitHubQuota extends Context.Service< + GitHubQuota, + { + readonly admit: ( + host: string, + resource: GitHubQuotaResource, + options?: { readonly allowReserve: boolean }, + ) => Effect.Effect; + readonly observe: ( + host: string, + headers: Readonly>, + ) => Effect.Effect; + } +>()("t3/sourceControl/githubQuota") {} + +const make = Effect.gen(function* () { + const snapshots = yield* Ref.make>(new Map()); + const keyOf = (host: string, resource: string, scope: string) => + `${host.trim().toLowerCase()}\0${resource}\0${scope}`; + + const admit: GitHubQuota["Service"]["admit"] = Effect.fn("GitHubQuota.admit")( + function* (host, resource, options) { + const now = yield* Clock.currentTimeMillis; + const key = keyOf(host, resource, yield* SourceControlRateLimit.CredentialScope); + const snapshot = (yield* Ref.get(snapshots)).get(key); + if (snapshot === undefined || snapshot.resetAtMs <= now) return; + const floor = options?.allowReserve === true ? 1 : snapshot.limit * RESERVE_RATIO; + if (snapshot.remaining >= floor) return; + return yield* new SourceControlRateLimit.SourceControlRateLimitPausedError({ + provider: "github", + host: host.trim().toLowerCase(), + retryAt: snapshot.resetAtMs, + }); + }, + ); + + const observe: GitHubQuota["Service"]["observe"] = Effect.fn("GitHubQuota.observe")( + function* (host, headers) { + const quota = quotaFromHeaders(headers); + if (quota === null) return; + const key = keyOf(host, quota.resource, yield* SourceControlRateLimit.CredentialScope); + yield* Ref.update(snapshots, (current) => { + const previous = current.get(key); + // The latest answer wins. GitHub serves requests from several regions, so a later answer + // can report more left in the same window, and keeping the lower one would hold + // background work at the reserve until the reset. An answer from an older window says + // nothing of this one. + if (previous !== undefined && quota.resetAtMs < previous.resetAtMs) return current; + const next = new Map(current); + next.set(key, { + limit: quota.limit, + remaining: quota.remaining, + resetAtMs: quota.resetAtMs, + }); + return next; + }); + }, + ); + + return GitHubQuota.of({ admit, observe }); +}); + +export const layer = Layer.effect(GitHubQuota, make);