From 0e0ddaeedf30698bec131caf040a8e8d7b2e3f37 Mon Sep 17 00:00:00 2001 From: maria Date: Sat, 12 Sep 2026 22:36:24 -0300 Subject: [PATCH 01/15] feat(web): add opt-in thread notifications and sounds (#11481) Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com> --- .../settings/DesktopClientSettings.test.ts | 1 + .../src/assets/notification-completion.mp3 | Bin 0 -> 11947 bytes apps/web/src/assets/notification-input.mp3 | Bin 0 -> 11633 bytes .../ThreadNotificationCoordinator.tsx | 113 ++++++++++++++++++ .../settings/NotificationSettings.tsx | 83 +++++++++++++ .../components/settings/SettingsPanels.tsx | 7 ++ .../src/components/settings/settingsSearch.ts | 6 + apps/web/src/routes/__root.tsx | 2 + apps/web/src/threadNotifications.ts | 55 +++++++++ packages/contracts/src/settings.test.ts | 26 ++++ packages/contracts/src/settings.ts | 12 ++ third-party-licenses.config.json | 28 +++++ 12 files changed, 333 insertions(+) create mode 100644 apps/web/src/assets/notification-completion.mp3 create mode 100644 apps/web/src/assets/notification-input.mp3 create mode 100644 apps/web/src/components/ThreadNotificationCoordinator.tsx create mode 100644 apps/web/src/components/settings/NotificationSettings.tsx create mode 100644 apps/web/src/threadNotifications.ts diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index f6622049ab49..5c1e01cb1471 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -18,6 +18,7 @@ import * as DesktopClientSettings from "./DesktopClientSettings.ts"; const clientSettings: ClientSettings = { ...DEFAULT_CLIENT_SETTINGS, + notificationMode: "notifications-and-sound", appearanceContrast: 100, browserDefaultViewport: { _tag: "preset", width: 1024, height: 600, presetId: "nest-hub" }, browserDefaultZoomFactor: 1.25, diff --git a/apps/web/src/assets/notification-completion.mp3 b/apps/web/src/assets/notification-completion.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..1955f5eb05f16bf90d2290dc14f46eaa210f4958 GIT binary patch literal 11947 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 literal 0 HcmV?d00001 diff --git a/apps/web/src/assets/notification-input.mp3 b/apps/web/src/assets/notification-input.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..07e10da3bd574a1d8f2f913644bfae69c6ba5c55 GIT binary patch literal 11633 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 S0r?k;|1)m?;z0c`)&2uuH^6WJ literal 0 HcmV?d00001 diff --git a/apps/web/src/components/ThreadNotificationCoordinator.tsx b/apps/web/src/components/ThreadNotificationCoordinator.tsx new file mode 100644 index 000000000000..e89175a77808 --- /dev/null +++ b/apps/web/src/components/ThreadNotificationCoordinator.tsx @@ -0,0 +1,113 @@ +import { useAtomValue } from "@effect/atom-react"; +import { useNavigate } from "@tanstack/react-router"; +import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import { useEffect, useRef } from "react"; + +import { getClientSettings, useClientSettings } from "../hooks/useSettings"; +import { useEnvironments } from "../state/environments"; +import { environmentShell } from "../state/shell"; +import { + hasDesktopNotifications, + hasNotificationSound, + playNotificationSound, + unlockNotificationAudio, +} from "../threadNotifications"; +import { resolveSidebarThreadStatus } from "./Sidebar.logic"; + +export function ThreadNotificationCoordinator() { + const { environments } = useEnvironments(); + const mode = useClientSettings((settings) => settings.notificationMode); + + useEffect(() => { + if (!hasNotificationSound(mode)) return; + document.addEventListener("pointerdown", unlockNotificationAudio); + document.addEventListener("keydown", unlockNotificationAudio); + return () => { + document.removeEventListener("pointerdown", unlockNotificationAudio); + document.removeEventListener("keydown", unlockNotificationAudio); + }; + }, [mode]); + + if (mode === "off") return null; + + return environments.map((environment) => ( + + )); +} + +function EnvironmentNotifications({ environmentId }: { environmentId: EnvironmentId }) { + const shell = useAtomValue(environmentShell.stateValueAtom(environmentId)); + const mode = useClientSettings((settings) => settings.notificationMode); + const navigate = useNavigate(); + const previous = useRef(new Map()); + + useEffect(() => { + if (shell.status !== "live" || Option.isNone(shell.snapshot)) { + previous.current.clear(); + return; + } + const next = new Map(); + for (const thread of shell.snapshot.value.threads) { + const status = resolveSidebarThreadStatus(thread); + const prior = previous.current.get(thread.id); + const input = + status === "input" || status === "approval" + ? `${thread.latestTurn?.turnId ?? ""}:${status}` + : null; + const completedAt = Date.parse(thread.latestTurn?.completedAt ?? ""); + const completion = + status === "ready" && + thread.latestTurn?.state === "completed" && + Number.isFinite(completedAt) + ? completedAt + : (prior?.completion ?? null); + next.set(thread.id, { input, completion }); + if (!prior || mode === "off" || thread.archivedAt !== null) continue; + const kind = + input && input !== prior.input + ? "input" + : completion !== null && (prior.completion === null || completion > prior.completion) + ? "completion" + : null; + if (!kind) continue; + if (hasNotificationSound(mode)) { + void playNotificationSound(kind, () => + hasNotificationSound(getClientSettings().notificationMode), + ); + } + if ( + !hasDesktopNotifications(mode) || + typeof Notification === "undefined" || + Notification.permission !== "granted" + ) + continue; + try { + const notification = new Notification( + kind === "completion" + ? "Thread completed" + : status === "approval" + ? "Approval needed" + : "Input needed", + { body: thread.title, tag: `${environmentId}:${thread.id}`, silent: true }, + ); + notification.addEventListener("click", () => { + notification.close(); + window.focus(); + void navigate({ + to: "/$environmentId/$threadId", + params: { environmentId, threadId: thread.id }, + }); + }); + } catch { + // Some browsers expose Notification but reject desktop presentation. + } + } + previous.current = next; + }, [environmentId, mode, navigate, shell]); + + return null; +} diff --git a/apps/web/src/components/settings/NotificationSettings.tsx b/apps/web/src/components/settings/NotificationSettings.tsx new file mode 100644 index 000000000000..22b4d2912302 --- /dev/null +++ b/apps/web/src/components/settings/NotificationSettings.tsx @@ -0,0 +1,83 @@ +import { useState } from "react"; + +import { + hasDesktopNotifications, + hasNotificationSound, + NOTIFICATION_MODE_LABELS, + unlockNotificationAudio, +} from "../../threadNotifications"; +import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; +import { SettingsRow } from "./settingsLayout"; +import { searchableSetting } from "./settingsSearch"; +import { useScopedSettings, useUpdateScopedSettings } from "./useScopedSettings"; + +export function NotificationSettings() { + const mode = useScopedSettings((settings) => settings.notificationMode); + const updateSettings = useUpdateScopedSettings(); + const [permissionMessage, setPermissionMessage] = useState(null); + const [requesting, setRequesting] = useState(false); + + return ( + { + if ( + value !== "off" && + value !== "notifications" && + value !== "sound" && + value !== "notifications-and-sound" + ) + return; + setPermissionMessage(null); + if (hasNotificationSound(value)) unlockNotificationAudio(); + if (hasDesktopNotifications(value)) { + if (typeof Notification === "undefined" || !window.isSecureContext) { + setPermissionMessage( + "Notifications need a supported browser over HTTPS, or the desktop app. Sound only is still available.", + ); + return; + } + setRequesting(true); + try { + const permission = await Notification.requestPermission(); + if (permission !== "granted") { + setPermissionMessage( + "Allow notifications in your browser or system settings, then choose this option again. Sound only is still available.", + ); + return; + } + } catch { + setPermissionMessage( + "Notifications are unavailable in this browser. Sound only is still available.", + ); + return; + } finally { + setRequesting(false); + } + } + updateSettings({ notificationMode: value }); + }} + > + + {NOTIFICATION_MODE_LABELS[mode]} + + + {Object.entries(NOTIFICATION_MODE_LABELS).map(([value, label]) => ( + + {label} + + ))} + + + } + /> + ); +} diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 98684f50721b..c9700496f420 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -1,4 +1,5 @@ import { Spinner } from "~/components/ui/spinner"; +import { NotificationSettings } from "./NotificationSettings"; import { ArchiveIcon, ArchiveX, ChevronRightIcon, SettingsIcon } from "lucide-react"; import { Link, useNavigate } from "@tanstack/react-router"; import type { CSSProperties, ReactNode } from "react"; @@ -517,6 +518,9 @@ export function useSettingsRestore(onRestored?: () => void) { ...(settings.timestampFormat !== DEFAULT_UNIFIED_SETTINGS.timestampFormat ? ["Time format"] : []), + ...(settings.notificationMode !== DEFAULT_UNIFIED_SETTINGS.notificationMode + ? ["Thread notifications"] + : []), ...(settings.sidebarThreadPreviewCount !== DEFAULT_UNIFIED_SETTINGS.sidebarThreadPreviewCount ? ["Visible threads"] : []), @@ -636,6 +640,7 @@ export function useSettingsRestore(onRestored?: () => void) { settings.sidebarThreadPreviewCount, settings.showSkillsInSlashMenu, settings.timestampFormat, + settings.notificationMode, settings.wordWrap, followSystem, theme, @@ -709,6 +714,7 @@ export function useSettingsRestore(onRestored?: () => void) { appearanceContrast: DEFAULT_UNIFIED_SETTINGS.appearanceContrast, diffColorScheme: DEFAULT_UNIFIED_SETTINGS.diffColorScheme, timestampFormat: DEFAULT_UNIFIED_SETTINGS.timestampFormat, + notificationMode: DEFAULT_UNIFIED_SETTINGS.notificationMode, wordWrap: DEFAULT_UNIFIED_SETTINGS.wordWrap, diffFilesCollapsed: DEFAULT_UNIFIED_SETTINGS.diffFilesCollapsed, diffIgnoreWhitespace: DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace, @@ -2243,6 +2249,7 @@ export function GeneralSettingsPanel() { + + diff --git a/apps/web/src/threadNotifications.ts b/apps/web/src/threadNotifications.ts new file mode 100644 index 000000000000..8050411382aa --- /dev/null +++ b/apps/web/src/threadNotifications.ts @@ -0,0 +1,55 @@ +import type { ClientSettings } from "@t3tools/contracts/settings"; + +import completionUrl from "./assets/notification-completion.mp3"; +import inputUrl from "./assets/notification-input.mp3"; + +type NotificationMode = ClientSettings["notificationMode"]; +export const NOTIFICATION_MODE_LABELS = { + off: "Off", + notifications: "Notifications only", + sound: "Sound only", + "notifications-and-sound": "Notifications with sound", +} satisfies Record; + +export function hasNotificationSound(mode: NotificationMode) { + return mode === "sound" || mode === "notifications-and-sound"; +} + +export function hasDesktopNotifications(mode: NotificationMode) { + return mode === "notifications" || mode === "notifications-and-sound"; +} + +let audioContext: AudioContext | undefined; +const buffers = new Map>(); + +/** Called from a gesture so browsers allow later background playback. */ +export function unlockNotificationAudio() { + audioContext ??= new AudioContext(); + void audioContext.resume().catch(() => undefined); +} + +export async function playNotificationSound( + kind: "completion" | "input", + shouldPlay: () => boolean, +) { + if (!audioContext || audioContext.state !== "running") return; + const context = audioContext; + const url = kind === "completion" ? completionUrl : inputUrl; + try { + let buffer = buffers.get(url); + if (!buffer) { + buffer = fetch(url) + .then((response) => response.arrayBuffer()) + .then((data) => context.decodeAudioData(data)); + buffers.set(url, buffer); + } + const decoded = await buffer; + if (!shouldPlay() || context.state !== "running") return; + const source = context.createBufferSource(); + source.buffer = decoded; + source.connect(context.destination); + source.start(); + } catch { + buffers.delete(url); + } +} diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 48f147be051e..7bb2e1da5c0b 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -164,6 +164,32 @@ describe("ClaudeSettings auto-compaction", () => { }); }); +describe("ClientSettings notifications", () => { + it("requires opt-in when existing settings omit notification preferences", () => { + expect(decodeClientSettings({}).notificationMode).toBe("off"); + expect(decodeClientSettingsPatch({})).not.toHaveProperty("notificationMode"); + }); + + it.each(["off", "notifications", "sound", "notifications-and-sound"])( + "round-trips the %s mode", + (notificationMode) => { + const settings = decodeClientSettings({ notificationMode }); + expect(encodeClientSettings(settings).notificationMode).toBe(notificationMode); + expect(decodeClientSettingsPatch({ notificationMode }).notificationMode).toBe( + notificationMode, + ); + }, + ); + + it.each(["always", true, null])( + "rejects unsupported notification mode %s", + (notificationMode) => { + expect(() => decodeClientSettings({ notificationMode })).toThrow(); + expect(() => decodeClientSettingsPatch({ notificationMode })).toThrow(); + }, + ); +}); + describe("ClientSettings default diff file state", () => { it("keeps files expanded when existing settings omit the preference", () => { expect(decodeClientSettings({}).diffFilesCollapsed).toBe(false); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index b420b737ee0a..004a53b320e8 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -214,6 +214,14 @@ const DEFAULT_SNAP_SHOT_SHORTCUT: SnapShotShortcut = { kind: "both-shift-keys", }; +export const NotificationMode = Schema.Literals([ + "off", + "notifications", + "sound", + "notifications-and-sound", +]); +export type NotificationMode = typeof NotificationMode.Type; + export const QuitConfirmationMode = Schema.Literals(["direct", "hold", "double-click"]); export type QuitConfirmationMode = typeof QuitConfirmationMode.Type; const DEFAULT_QUIT_CONFIRMATION_MODE: QuitConfirmationMode = "hold"; @@ -280,6 +288,9 @@ export const LoadBalancingWeights = Schema.Record( export const DiffColorScheme = Schema.Literals(["red-green", "blue-orange"]); export const ClientSettingsSchema = Schema.Struct({ + notificationMode: NotificationMode.pipe( + Schema.withDecodingDefault(Effect.succeed("off" as const)), + ), diffColorScheme: DiffColorScheme.pipe( Schema.withDecodingDefault(Effect.succeed("red-green" as const)), ), @@ -1416,6 +1427,7 @@ export const ServerSettingsPatch = Schema.Struct({ export type ServerSettingsPatch = typeof ServerSettingsPatch.Type; export const ClientSettingsPatch = Schema.Struct({ + notificationMode: Schema.optionalKey(NotificationMode), diffColorScheme: Schema.optionalKey(DiffColorScheme), loadBalancingEnabled: Schema.optionalKey(Schema.Boolean), loadBalancingWeights: Schema.optionalKey(LoadBalancingWeights), diff --git a/third-party-licenses.config.json b/third-party-licenses.config.json index 8670eaf64ad5..01955554adf6 100644 --- a/third-party-licenses.config.json +++ b/third-party-licenses.config.json @@ -1,5 +1,33 @@ { "customNotices": [ + { + "bundles": ["assets", "desktop", "web"], + "license": "CC0-1.0", + "name": "Notification Sound 1 by deadrobotmusic", + "sourceUrl": "https://freesound.org/people/deadrobotmusic/sounds/750607/", + "generatedNotices": [ + { + "licenseId": "CC0-1.0", + "preamble": [ + "The bundled notification-completion.mp3 is derived from Notification Sound 1 by deadrobotmusic:\nhttps://freesound.org/people/deadrobotmusic/sounds/750607/\n\nThe original sound is dedicated to the public domain under CC0 1.0 Universal." + ] + } + ] + }, + { + "bundles": ["assets", "desktop", "web"], + "license": "CC0-1.0", + "name": "Notification Sound 3 by deadrobotmusic", + "sourceUrl": "https://freesound.org/people/deadrobotmusic/sounds/750609/", + "generatedNotices": [ + { + "licenseId": "CC0-1.0", + "preamble": [ + "The bundled notification-input.mp3 is derived from Notification Sound 3 by deadrobotmusic:\nhttps://freesound.org/people/deadrobotmusic/sounds/750609/\n\nThe original sound is dedicated to the public domain under CC0 1.0 Universal." + ] + } + ] + }, { "bundles": ["assets", "desktop", "web"], "license": "CC0-1.0", From c29976458a7dbbb61c83b3d06ea894869b718607 Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Sat, 12 Sep 2026 21:19:24 -0500 Subject: [PATCH 02/15] fix(server): open Cursor links in classic IDE mode (#11498) --- .../src/process/externalLauncher.test.ts | 89 +++++++++++++++++++ packages/contracts/src/editor.ts | 2 + 2 files changed, 91 insertions(+) diff --git a/apps/server/src/process/externalLauncher.test.ts b/apps/server/src/process/externalLauncher.test.ts index 2ae1684b67d4..b7092d70f0e2 100644 --- a/apps/server/src/process/externalLauncher.test.ts +++ b/apps/server/src/process/externalLauncher.test.ts @@ -159,6 +159,95 @@ it.effect("launches an installed editor with platform-safe arguments", () => }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); +for (const platform of ["darwin", "linux"] as const) { + it.effect.skipIf(windowsHost)(`launches Cursor in classic IDE mode on ${platform}`, () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); + const cursorPath = path.join(binDir, "cursor"); + yield* fileSystem.writeFileString(cursorPath, "#!/bin/sh\n"); + yield* fileSystem.chmod(cursorPath, 0o755); + + const spawned: ChildProcess.StandardCommand[] = []; + yield* Effect.gen(function* () { + const launcher = yield* ExternalLauncher.ExternalLauncher; + for (const cwd of [ + "/workspace with spaces", + "/workspace with spaces/src/index.ts", + "/workspace with spaces/src/index.ts:12", + "/workspace with spaces/src/index.ts:12:4", + ]) { + yield* launcher.launchEditor({ editor: "cursor", cwd }); + } + }).pipe( + Effect.provide( + testLayer({ + platform, + env: { PATH: binDir }, + onSpawn: (command) => spawned.push(command), + }), + ), + ); + + assert.deepEqual( + spawned.map((command) => ({ command: command.command, args: command.args })), + [ + { command: "cursor", args: ["--classic", "/workspace with spaces"] }, + { command: "cursor", args: ["--classic", "/workspace with spaces/src/index.ts"] }, + { + command: "cursor", + args: ["--classic", "--goto", "/workspace with spaces/src/index.ts:12"], + }, + { + command: "cursor", + args: ["--classic", "--goto", "/workspace with spaces/src/index.ts:12:4"], + }, + ], + ); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); +} + +it.effect("launches Cursor in classic IDE mode through the Windows command shim", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const binDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-editors-" }); + yield* fileSystem.writeFileString(path.join(binDir, "cursor.CMD"), "@echo off\r\n"); + + let spawned: ChildProcess.StandardCommand | undefined; + yield* Effect.gen(function* () { + const launcher = yield* ExternalLauncher.ExternalLauncher; + yield* launcher.launchEditor({ + editor: "cursor", + cwd: "C:\\workspace with spaces\\src\\index.ts:12:4", + }); + }).pipe( + Effect.provide( + testLayer({ + platform: "win32", + env: { PATH: binDir, PATHEXT: ".COM;.EXE;.BAT;.CMD" }, + resolveExecutable: (command) => + command === "cursor" ? "C:\\Program Files\\Cursor\\bin\\cursor.CMD" : command, + onSpawn: (command) => { + spawned = command; + }, + }), + ), + ); + + assert.ok(spawned); + assert.equal(spawned.command, '^"C:\\Program^ Files\\Cursor\\bin\\cursor.CMD^"'); + assert.deepEqual(spawned.args, [ + '^"--classic^"', + '^"--goto^"', + '^"C:\\workspace^ with^ spaces\\src\\index.ts:12:4^"', + ]); + assert.equal(spawned.options.shell, true); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + it.effect.skipIf(windowsHost)("reveals a file in Finder with open -R on macOS", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; diff --git a/packages/contracts/src/editor.ts b/packages/contracts/src/editor.ts index 0680a398879e..88028b796991 100644 --- a/packages/contracts/src/editor.ts +++ b/packages/contracts/src/editor.ts @@ -24,6 +24,8 @@ export const EDITORS = [ id: "cursor", label: "Cursor", commands: ["cursor"], + // File and workspace opens must target the IDE even when the Agents Window is active. + baseArgs: ["--classic"], launchStyle: "goto", remoteScheme: "cursor", }, From 6fd68f5c3e7c81a9650ba36df32f2241912e00ba Mon Sep 17 00:00:00 2001 From: maria Date: Sun, 13 Sep 2026 00:02:42 -0300 Subject: [PATCH 03/15] feat(source-control): support Forgejo and Gitea with fj and tea (#11436) --- .../src/components/SourceControlIcon.tsx | 17 +- .../projects/AddProjectRepositoryRoute.tsx | 1 + .../features/projects/AddProjectScreen.tsx | 1 + apps/server/src/git/GitManager.test.ts | 68 + apps/server/src/git/GitManager.ts | 38 +- .../toolkits/pullRequests/handlers.test.ts | 46 + .../src/mcp/toolkits/pullRequests/handlers.ts | 11 +- .../Layers/ProjectionPipeline.test.ts | 33 + .../Layers/ProjectionPipeline.ts | 16 +- .../PullRequestSyncReactor.test.ts | 25 + .../orchestration/PullRequestSyncReactor.ts | 11 +- .../decider.pullRequests.test.ts | 43 + .../RepositoryIdentityResolver.test.ts | 43 +- .../src/project/RepositoryIdentityResolver.ts | 10 +- .../pullRequest/ForgejoPullRequestProvider.ts | 584 +++++++ .../src/pullRequest/PullRequestProvider.ts | 1 + .../PullRequestProviderRegistry.ts | 4 + .../pullRequest/PullRequestService.test.ts | 112 ++ .../src/pullRequest/PullRequestService.ts | 56 +- .../src/pullRequest/forgejoPullRequestJson.ts | 262 ++++ .../src/pullRequest/linkedThreads.test.ts | 32 +- apps/server/src/pullRequest/linkedThreads.ts | 24 +- apps/server/src/server.ts | 53 +- apps/server/src/sourceControl/ForgejoCli.ts | 720 +++++++++ .../ForgejoSourceControlProvider.ts | 387 +++++ .../SourceControlDiscovery.test.ts | 1349 ++++++++++++++++- .../sourceControl/SourceControlProvider.ts | 2 + .../SourceControlProviderDiscovery.ts | 29 +- .../SourceControlProviderRegistry.test.ts | 3 + .../SourceControlProviderRegistry.ts | 4 + .../src/sourceControl/forgejoPullRequests.ts | 47 + apps/server/src/ws.ts | 2 + apps/web/src/components/CommandPalette.tsx | 13 +- apps/web/src/components/GitActionsControl.tsx | 28 +- apps/web/src/components/Icons.tsx | 13 + .../src/components/ThreadStatusIndicators.tsx | 4 +- .../pullRequest/LinkPullRequestDialog.tsx | 5 +- .../pullRequest/PullRequestCodeTab.tsx | 1 + .../pullRequest/PullRequestDetailPanel.tsx | 6 +- .../pullRequest/PullRequestReviewBar.tsx | 14 +- .../pullRequest/PullRequestThreadLinks.tsx | 11 +- .../pullRequestDetail.logic.test.ts | 27 + .../pullRequest/pullRequestDetail.logic.ts | 5 + .../pullRequest/pullRequestLinkContextMenu.ts | 1 + .../settings/SourceControlSettings.tsx | 2 + .../src/components/settings/settingsSearch.ts | 2 +- apps/web/src/lib/openPullRequestLink.test.ts | 84 + apps/web/src/lib/openPullRequestLink.ts | 68 +- apps/web/src/pullRequestReference.ts | 5 + apps/web/src/sourceControlPresentation.ts | 14 +- apps/web/src/state/sourceControlActions.ts | 2 +- docs/user/source-control.md | 22 +- .../src/operations/projects.test.ts | 7 + .../client-runtime/src/operations/projects.ts | 14 +- .../threadPullRequestCompatibility.test.ts | 18 + .../src/threadPullRequestCompatibility.ts | 8 +- packages/contracts/src/environment.ts | 2 + packages/contracts/src/pullRequest.test.ts | 15 + packages/contracts/src/pullRequest.ts | 23 +- packages/contracts/src/sourceControl.ts | 1 + packages/shared/src/changeRequestUrl.test.ts | 48 + packages/shared/src/changeRequestUrl.ts | 33 +- packages/shared/src/sourceControl.test.ts | 16 + packages/shared/src/sourceControl.ts | 29 +- .../shared/src/threadPullRequests.test.ts | 22 + packages/shared/src/threadPullRequests.ts | 50 +- 66 files changed, 4545 insertions(+), 102 deletions(-) create mode 100644 apps/server/src/pullRequest/ForgejoPullRequestProvider.ts create mode 100644 apps/server/src/pullRequest/forgejoPullRequestJson.ts create mode 100644 apps/server/src/sourceControl/ForgejoCli.ts create mode 100644 apps/server/src/sourceControl/ForgejoSourceControlProvider.ts create mode 100644 apps/server/src/sourceControl/forgejoPullRequests.ts diff --git a/apps/mobile/src/components/SourceControlIcon.tsx b/apps/mobile/src/components/SourceControlIcon.tsx index 3b371c021adc..873f3729a8a1 100644 --- a/apps/mobile/src/components/SourceControlIcon.tsx +++ b/apps/mobile/src/components/SourceControlIcon.tsx @@ -1,9 +1,9 @@ -import Svg, { Defs, LinearGradient, Path, Stop } from "react-native-svg"; +import Svg, { Circle, Defs, G, LinearGradient, Path, Stop } from "react-native-svg"; import { withUniwind } from "uniwind"; const ThemedSvg = withUniwind(Svg); -export type SourceControlIconKind = "github" | "gitlab" | "bitbucket" | "azure-devops"; +export type SourceControlIconKind = "github" | "gitlab" | "forgejo" | "bitbucket" | "azure-devops"; export function SourceControlIcon(props: { readonly kind: SourceControlIconKind; @@ -14,6 +14,19 @@ export function SourceControlIcon(props: { const size = props.size ?? 18; switch (props.kind) { + case "forgejo": + // Official two-color mark from https://forgejo.org/favicon.svg. + return ( + + + + + + + + + + ); case "github": return ( { 20_000, ); + it.effect("matches mounted Forgejo heads without confusing forks sharing a branch", () => + Effect.gen(function* () { + for (const owner of ["maria", "reviewer"]) { + const mapped = toForgejoChangeRequest( + yield* decodeForgejoPullRequest({ + number: 42, + title: "Greeting", + html_url: "https://forgejo.example/forgejo/maria/project/pulls/42", + state: "open", + merged: false, + base: { + ref: "main", + sha: "base", + repo: { full_name: "maria/project", owner: { login: "maria" } }, + }, + head: { + ref: "greeting", + sha: "head", + repo: { full_name: `${owner}/project`, owner: { login: owner } }, + }, + }), + ); + const pr = { + ...mapped, + isDraft: mapped.isDraft ?? false, + closedAt: mapped.closedAt ?? null, + mergedAt: mapped.mergedAt ?? null, + }; + const repository = GitManager.parseRepositoryNameWithOwnerFromRemoteUrl( + `https://forgejo.example/forgejo/${owner}/project.git`, + "forgejo", + ); + expect(repository).toBe(`${owner}/project`); + const context = { + headBranch: "greeting", + headRepositoryNameWithOwner: repository, + headRepositoryOwnerLogin: repository?.split("/")[0] ?? null, + isCrossRepository: owner !== "maria", + }; + expect(GitManager.matchesBranchHeadContext(pr, context)).toBe(true); + expect( + GitManager.matchesBranchHeadContext(pr, { + ...context, + headRepositoryNameWithOwner: "other/project", + headRepositoryOwnerLogin: "other", + }), + ).toBe(false); + } + expect( + GitManager.parseRepositoryNameWithOwnerFromRemoteUrl( + "git@forgejo.example:maria/project.git", + "forgejo", + ), + ).toBe("maria/project"); + expect( + GitManager.parseRepositoryNameWithOwnerFromRemoteUrl( + "https://gitlab.example/group/maria/project.git", + "gitlab", + ), + ).toBe("group/maria/project"); + }), + ); + it.effect("rejects same-repo PR metadata when matching a cross-repo head context", () => Effect.sync(() => { const headContext = { diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index 1a14fb5be5bc..33f041fb6cd1 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -277,7 +277,10 @@ function resolvePullRequestWorktreeLocalBranchName( return `t3code/pr-${pullRequest.number}/${suffix}`; } -function parseRepositoryNameWithOwnerFromRemoteUrl(url: string | null): string | null { +export function parseRepositoryNameWithOwnerFromRemoteUrl( + url: string | null, + providerKind?: ChangeRequest["provider"], +): string | null { const trimmed = url?.trim() ?? ""; if (trimmed.length === 0) { return null; @@ -288,6 +291,12 @@ function parseRepositoryNameWithOwnerFromRemoteUrl(url: string | null): string | trimmed, ); const repositoryNameWithOwner = match?.[1]?.trim() ?? ""; + // Forgejo HTTP paths can include an installation mount; its API always names owner/repo. + if (providerKind === "forgejo" && /^https?:\/\//iu.test(trimmed)) { + return repositoryNameWithOwner.length > 0 + ? repositoryNameWithOwner.split("/").slice(-2).join("/") + : null; + } return repositoryNameWithOwner.length > 0 ? repositoryNameWithOwner : null; } @@ -1291,7 +1300,15 @@ export const make = Effect.gen(function* () { (yield* readConfigValueNullable(cwd, `remote.${preferredRemoteName}.url`)) ?? (yield* readConfigValueNullable(cwd, "remote.origin.url")); - return remoteUrl ? detectSourceControlProviderFromGitRemoteUrl(remoteUrl) : null; + const provider = remoteUrl ? detectSourceControlProviderFromGitRemoteUrl(remoteUrl) : null; + if (!remoteUrl || provider?.kind !== "unknown") return provider; + const handle = yield* sourceControlProviders + .resolveHandle({ + cwd, + context: { provider, remoteName: preferredRemoteName, remoteUrl }, + }) + .pipe(Effect.orElseSucceed(() => null)); + return handle?.context?.provider ?? provider; }); const resolveRemoteRepositoryContext = Effect.fn("resolveRemoteRepositoryContext")(function* ( @@ -1307,7 +1324,22 @@ export const make = Effect.gen(function* () { } const remoteUrl = yield* readConfigValueNullable(cwd, `remote.${remoteName}.url`); - const repositoryNameWithOwner = parseRepositoryNameWithOwnerFromRemoteUrl(remoteUrl); + let repositoryNameWithOwner = parseRepositoryNameWithOwnerFromRemoteUrl(remoteUrl); + if ( + remoteUrl !== null && + /^https?:\/\//iu.test(remoteUrl) && + (repositoryNameWithOwner?.split("/").length ?? 0) > 2 + ) { + const detected = detectSourceControlProviderFromGitRemoteUrl(remoteUrl); + const kind = + detected?.kind === "unknown" + ? yield* sourceControlProvider(cwd).pipe( + Effect.map((provider) => provider.kind), + Effect.orElseSucceed(() => undefined), + ) + : detected?.kind; + repositoryNameWithOwner = parseRepositoryNameWithOwnerFromRemoteUrl(remoteUrl, kind); + } return { remoteUrlKey: remoteUrl ? normalizeGitRemoteUrl(remoteUrl) : null, repositoryNameWithOwner, diff --git a/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts b/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts index 18062a216bb4..70da749edeab 100644 --- a/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts +++ b/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts @@ -265,6 +265,40 @@ describe("pull request toolkit handlers", () => { }), ); + it.effect("links a numeric Forgejo reference with its remote's web origin and mount path", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ + project: makeProject({ + canonicalKey: "forge.example/git/owner/repo", + locator: { + source: "git-remote", + remoteName: "origin", + remoteUrl: "http://forge.example:3000/git/owner/repo.git", + }, + provider: "forgejo", + displayName: "git/owner/repo", + }), + }); + const result = yield* harness.call("link_pull_request", { + repository: "git/owner/repo", + number: 42, + }); + expect(result).toEqual({ + host: "forge.example:3000", + repository: "git/owner/repo", + number: 42, + url: "http://forge.example:3000/git/owner/repo/pulls/42", + alreadyLinked: false, + }); + const other = yield* harness.call("link_pull_request", { + host: "other.example", + repository: "owner/repo", + number: 42, + }); + expect(other.url).toBe("https://other.example/owner/repo/pull/42"); + }), + ); + it.effect("rejects a target that names neither a URL nor repository and number", () => Effect.gen(function* () { const harness = yield* makeHarness(); @@ -334,6 +368,18 @@ describe("pull request toolkit handlers", () => { }), ); + it("reports an older Forgejo link's HTTP port when listing thread links", () => { + const result = listThreadPullRequests( + makeThread([ + makeLink(42, { + host: "forge.example", + url: "http://forge.example:3000/t3tools/t3code/pulls/42", + }), + ]), + ); + expect(result.pullRequests[0]?.host).toBe("forge.example:3000"); + }); + it.effect("fails cleanly when the token's thread no longer exists", () => Effect.gen(function* () { const harness = yield* makeHarness({ thread: null }); diff --git a/apps/server/src/mcp/toolkits/pullRequests/handlers.ts b/apps/server/src/mcp/toolkits/pullRequests/handlers.ts index 1106bf435327..80cd49257fba 100644 --- a/apps/server/src/mcp/toolkits/pullRequests/handlers.ts +++ b/apps/server/src/mcp/toolkits/pullRequests/handlers.ts @@ -9,6 +9,7 @@ import { } from "@t3tools/contracts"; import { changeRequestUrlFor, parseChangeRequestUrl } from "@t3tools/shared/changeRequestUrl"; import { + normalizeThreadPullRequestKey, resolveThreadPullRequestChains, threadPullRequestKeyOf, visibleThreadPullRequests, @@ -71,7 +72,7 @@ const resolveTarget = Effect.fn("PullRequestsToolkit.resolveTarget")(function* ( if (parsed === null) { return yield* new PullRequestUrlInvalidError({}); } - return { ...parsed, url: input.url } satisfies ResolvedTarget; + return { ...normalizeThreadPullRequestKey(parsed), url: input.url } satisfies ResolvedTarget; } if (input.repository === undefined || input.number === undefined) { return yield* new PullRequestTargetIncompleteError({}); @@ -89,8 +90,12 @@ const resolveTarget = Effect.fn("PullRequestsToolkit.resolveTarget")(function* ( host, repository, input.number, + project?.repositoryIdentity?.locator.remoteUrl, ) ?? `https://${host}/${repository}/pull/${input.number}`; - return { host, repository, number: input.number, url } satisfies ResolvedTarget; + return { + ...normalizeThreadPullRequestKey({ host, repository, number: input.number, url }), + url, + } satisfies ResolvedTarget; }); function entryOf( @@ -108,7 +113,7 @@ function entryOf( } } return { - host: link.host, + host: normalizeThreadPullRequestKey(link).host, repository: link.repository, number: link.number, url: link.url, diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index a038a5662169..21c95142929f 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -889,6 +889,39 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-pull assert.deepEqual(yield* readLinks(), []); assert.deepEqual(yield* readThreadUpdatedAt(), [{ updatedAt: "2026-01-01T00:00:05.000Z" }]); + // Older Forgejo rows stored a portless host; unlink by their URL's authority. + yield* eventStore.append({ + ...base("2026-01-01T00:00:05.100Z"), + type: "thread.pull-request-linked", + payload: { + threadId, + link: { + host: "forge.example", + repository: "team/repo", + number: 42, + url: "http://forge.example:3000/team/repo/pulls/42", + source: "agent", + linkedAt: "2026-01-01T00:00:05.100Z", + snapshot: null, + stack: null, + }, + updatedAt: "2026-01-01T00:00:05.100Z", + }, + }); + yield* eventStore.append({ + ...base("2026-01-01T00:00:05.200Z"), + type: "thread.pull-request-unlinked", + payload: { + threadId, + host: "forge.example:3000", + repository: "team/repo", + number: 42, + updatedAt: "2026-01-01T00:00:05.200Z", + }, + }); + yield* projectionPipeline.bootstrap; + assert.deepEqual(yield* readLinks(), []); + // Deleting the thread clears whatever links it still had. yield* eventStore.append({ ...base("2026-01-01T00:00:06.000Z"), diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index bbd0aa5ddaa5..7e1710a760ec 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -880,12 +880,20 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti if (Option.isNone(existingRow)) { return; } - yield* projectionThreadPullRequestRepository.delete({ + const links = yield* projectionThreadPullRequestRepository.listByThreadId({ threadId: event.payload.threadId, - host: event.payload.host.toLowerCase(), - repository: event.payload.repository.toLowerCase(), - number: event.payload.number, }); + const link = links.find((candidate) => + threadPullRequestKeysEqual(candidate, event.payload), + ); + if (link !== undefined) { + yield* projectionThreadPullRequestRepository.delete({ + threadId: event.payload.threadId, + host: link.host, + repository: link.repository, + number: link.number, + }); + } yield* projectionThreadRepository.upsert({ ...existingRow.value, updatedAt: event.payload.updatedAt, diff --git a/apps/server/src/orchestration/PullRequestSyncReactor.test.ts b/apps/server/src/orchestration/PullRequestSyncReactor.test.ts index 414f76f32496..4a5b450ea699 100644 --- a/apps/server/src/orchestration/PullRequestSyncReactor.test.ts +++ b/apps/server/src/orchestration/PullRequestSyncReactor.test.ts @@ -420,6 +420,31 @@ describe("PullRequestSyncReactor", () => { ), ); + it.effect("recovers the HTTP port when syncing an older Forgejo link", () => + Effect.scoped( + Effect.gen(function* () { + const fixture = yield* makeHarness({ + snapshot: makeSnapshot([ + makeThread("one", { + pullRequests: [ + makeLink(42, null, { + host: "forge.example", + url: "http://forge.example:3000/owner/repository/pulls/42", + }), + ], + }), + ]), + summary: (input) => Effect.succeed(makeSummary(input)), + }); + yield* Effect.gen(function* () { + yield* startAndSweep(fixture); + assert.strictEqual((yield* Ref.get(fixture.summaryCalls))[0]?.host, "forge.example:3000"); + assert.strictEqual((yield* Ref.get(fixture.syncCommands))[0]?.host, "forge.example:3000"); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); + it.effect("dispatches nothing when the host snapshot is unchanged", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/orchestration/PullRequestSyncReactor.ts b/apps/server/src/orchestration/PullRequestSyncReactor.ts index 0a39fa5d91d1..388eab34be42 100644 --- a/apps/server/src/orchestration/PullRequestSyncReactor.ts +++ b/apps/server/src/orchestration/PullRequestSyncReactor.ts @@ -11,6 +11,7 @@ import { import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; import { threadPullRequestKeyOf, + normalizeThreadPullRequestKey, threadPullRequestKeysEqual, visibleThreadPullRequests, } from "@t3tools/shared/threadPullRequests"; @@ -191,7 +192,7 @@ export const make = Effect.gen(function* () { type: "thread.pull-request-link.sync", commandId: CommandId.make(`server:pr-sync:${thread.id}:${uuid}`), threadId: thread.id, - host: link.host, + host: normalizeThreadPullRequestKey(link).host, repository: link.repository, number: link.number, snapshot: { ...fields, syncedAt: nowIso }, @@ -200,7 +201,11 @@ export const make = Effect.gen(function* () { } if (fetchedStack === null || fetchedStack.stack === null) return; for (const layer of fetchedStack.stack.layers) { - const layerKey = { host: link.host, repository: link.repository, number: layer.number }; + const layerKey = { + host: normalizeThreadPullRequestKey(link).host, + repository: link.repository, + number: layer.number, + }; const dedupeKey = `${thread.id}:${threadPullRequestKeyOf(layerKey)}`; if (linkedThisSweep.has(dedupeKey)) continue; // Tombstones count as present: a dismissed layer is never re-added. @@ -240,7 +245,7 @@ export const make = Effect.gen(function* () { const first = entries[0]!; const ref = { projectId: first.thread.projectId, - host: first.link.host, + host: normalizeThreadPullRequestKey(first.link).host, repository: first.link.repository, number: first.link.number, }; diff --git a/apps/server/src/orchestration/decider.pullRequests.test.ts b/apps/server/src/orchestration/decider.pullRequests.test.ts index 49bfd4bea4de..b79ca01a8dea 100644 --- a/apps/server/src/orchestration/decider.pullRequests.test.ts +++ b/apps/server/src/orchestration/decider.pullRequests.test.ts @@ -115,6 +115,49 @@ const snapshot: ThreadPullRequestSnapshot = { }; it.layer(NodeServices.layer)("pull request link decider", (it) => { + it.effect("links the same Forgejo number on two ports and unlinks an older portless record", () => + Effect.gen(function* () { + const existing = makeLink({ + host: "forge.example", + url: "http://forge.example:3000/t3tools/t3code/pulls/42", + }); + let model = makeReadModel([existing]); + const command = yield* decodeCommand({ + type: "thread.pull-request.link", + commandId: "link-other-port", + threadId: THREAD_ID, + host: "forge.example", + repository: "t3tools/t3code", + number: 42, + url: "http://forge.example:4000/t3tools/t3code/pulls/42", + source: "manual", + }); + const linked = expectSingleEvent( + yield* decideOrchestrationCommand({ readModel: model, command }), + "thread.pull-request-linked", + ); + expect(linked.payload.link.host).toBe("forge.example:4000"); + model = yield* projectEvent(model, { ...linked, sequence: 1 }); + expect(model.threads[0]!.pullRequests).toHaveLength(2); + const unlink = yield* decodeCommand({ + type: "thread.pull-request.unlink", + commandId: "unlink-old-port", + threadId: THREAD_ID, + host: "forge.example:3000", + repository: "t3tools/t3code", + number: 42, + }); + const unlinked = expectSingleEvent( + yield* decideOrchestrationCommand({ readModel: model, command: unlink }), + "thread.pull-request-unlinked", + ); + model = yield* projectEvent(model, { ...unlinked, sequence: 2 }); + expect(model.threads[0]!.pullRequests.map((link) => link.url)).toEqual([ + linked.payload.link.url, + ]); + }), + ); + it.effect("legacy unlink cannot remove a newer cross-host link", () => Effect.gen(function* () { const own = makeLink(); diff --git a/apps/server/src/project/RepositoryIdentityResolver.test.ts b/apps/server/src/project/RepositoryIdentityResolver.test.ts index ac1cbfb44d0e..d6ddb0b9263f 100644 --- a/apps/server/src/project/RepositoryIdentityResolver.test.ts +++ b/apps/server/src/project/RepositoryIdentityResolver.test.ts @@ -1,6 +1,7 @@ // @effect-diagnostics nodeBuiltinImport:off - realpathSync.native resolves Windows 8.3 short names, which the Effect realPath does not. import * as NodeFS from "node:fs"; import * as NodeServices from "@effect/platform-node/NodeServices"; +import { SourceControlProviderError } from "@t3tools/contracts"; import { expect, it } from "@effect/vitest"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; @@ -41,6 +42,9 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { it.effect("refreshes the Git root only when requested", () => { const calls: Array> = []; let rootPath = "/repo"; + let remoteUrl = "git@github.com:T3Tools/t3code.git"; + let refinements = 0; + let refinementFails = false; const processRunner = Layer.succeed(ProcessRunner.ProcessRunner, { run: (input) => Effect.sync(() => { @@ -48,7 +52,7 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { return { stdout: input.args.includes("rev-parse") ? `${rootPath}\n` - : "origin\tgit@github.com:T3Tools/t3code.git (fetch)\n", + : `origin\t${remoteUrl} (fetch)\n`, stderr: "", code: ChildProcessSpawner.ExitCode(0), timedOut: false, @@ -61,7 +65,29 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { }); const resolverLayer = Layer.effect( RepositoryIdentityResolver.RepositoryIdentityResolver, - RepositoryIdentityResolver.make(), + RepositoryIdentityResolver.make({ + refine: (identity) => { + refinements++; + if (refinementFails) + return Effect.fail( + new SourceControlProviderError({ + provider: "forgejo", + operation: "detectProvider", + cwd: rootPath, + detail: "account unavailable", + }), + ); + return Effect.succeed( + identity.canonicalKey.startsWith("ssh.forge.test/") + ? { + ...identity, + provider: "forgejo", + webUrl: "http://forge.test:3000/git/team/repo", + } + : identity, + ); + }, + }), ).pipe(Layer.provide(processRunner)); return Effect.gen(function* () { @@ -72,6 +98,7 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { expect(first?.canonicalKey).toBe("github.com/t3tools/t3code"); expect(second).toEqual(first); + expect(refinements).toBe(1); expect(calls).toEqual([ ["-C", "/repo/packages/web", "rev-parse", "--show-toplevel"], ["-C", "/repo", "remote", "-v"], @@ -84,6 +111,18 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { ["-C", "/repo/packages/web", "rev-parse", "--show-toplevel"], ["-C", "/repo/packages/web", "remote", "-v"], ]); + remoteUrl = "git@ssh.forge.test:team/repo.git"; + const forgejo = yield* resolver.resolve(rootPath, { refresh: true }); + expect(forgejo?.webUrl).toBe("http://forge.test:3000/git/team/repo"); + expect(forgejo?.provider).toBe("forgejo"); + expect(forgejo?.canonicalKey).toBe("ssh.forge.test/team/repo"); + expect(forgejo?.locator.remoteUrl).toBe(remoteUrl); + expect(yield* resolver.resolve(rootPath)).toEqual(forgejo); + expect(refinements).toBe(3); + refinementFails = true; + const unavailable = yield* resolver.resolve(rootPath, { refresh: true }); + expect(unavailable?.webUrl).toBeUndefined(); + expect(unavailable?.canonicalKey).toBe("ssh.forge.test/team/repo"); }).pipe(Effect.provide(resolverLayer)); }); diff --git a/apps/server/src/project/RepositoryIdentityResolver.ts b/apps/server/src/project/RepositoryIdentityResolver.ts index 755008f6ded1..88ecb4186f67 100644 --- a/apps/server/src/project/RepositoryIdentityResolver.ts +++ b/apps/server/src/project/RepositoryIdentityResolver.ts @@ -1,4 +1,4 @@ -import type { RepositoryIdentity } from "@t3tools/contracts"; +import type { RepositoryIdentity, SourceControlProviderError } from "@t3tools/contracts"; import { detectSourceControlProviderFromGitRemoteUrl, normalizeGitRemoteUrl, @@ -20,6 +20,9 @@ export interface RepositoryIdentityResolverOptions { readonly cacheCapacity?: number; readonly positiveCacheTtl?: Duration.Input; readonly negativeCacheTtl?: Duration.Input; + readonly refine?: ( + identity: RepositoryIdentity, + ) => Effect.Effect; } export class RepositoryIdentityResolver extends Context.Service< @@ -158,6 +161,11 @@ export const make = Effect.fn("RepositoryIdentityResolver.make")(function* ( (cacheKey) => resolveRepositoryIdentityFromCacheKey(cacheKey).pipe( Effect.provideService(ProcessRunner.ProcessRunner, processRunner), + Effect.flatMap((identity) => + identity !== null && options.refine + ? options.refine(identity).pipe(Effect.catch(() => Effect.succeed(identity))) + : Effect.succeed(identity), + ), ), { capacity: cacheCapacity, diff --git a/apps/server/src/pullRequest/ForgejoPullRequestProvider.ts b/apps/server/src/pullRequest/ForgejoPullRequestProvider.ts new file mode 100644 index 000000000000..526be38f7df7 --- /dev/null +++ b/apps/server/src/pullRequest/ForgejoPullRequestProvider.ts @@ -0,0 +1,584 @@ +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import * as Result from "effect/Result"; +import type { PullRequestCapabilities, PullRequestViewerPermissions } from "@t3tools/contracts"; +import { decodeJsonResult } from "@t3tools/shared/schemaJson"; +import { ForgejoCli, type ForgejoApiInput } from "../sourceControl/ForgejoCli.ts"; +import { + PullRequestProviderError, + type ProviderChangeRequestDetail, + type ProviderRepositoryRef, + type PullRequestProviderApi, +} from "./PullRequestProvider.ts"; +import { + ForgejoPullRequest, + ForgejoRepository, + ForgejoUser, + ForgejoComment, + ForgejoReview, + ForgejoReviewComment, + ForgejoCommit, + ForgejoStatus, + ForgejoLabel, + ForgejoReaction, + FORGEJO_REACTIONS, + forgejoChangeRequest, + forgejoActor, + forgejoComment, + forgejoReview, + forgejoReviewThread, + forgejoCommit, + forgejoChecks, + forgejoReactions, +} from "./forgejoPullRequestJson.ts"; + +const CAPABILITIES: PullRequestCapabilities = { + diff: true, + comment: true, + actions: ["merge", "close", "reopen", "update-branch"], + mergeMethods: ["merge", "squash", "rebase"], + updateMethods: ["merge", "rebase"], + search: false, + reactions: true, + labels: true, + // Forgejo's public API has no thread replies/resolution or draft conversion endpoint. + review: { + inlineComment: true, + reply: false, + resolve: false, + verdicts: ["comment", "approve", "request-changes"], + }, + reviewers: { request: true, listCandidates: true }, + edit: { changeRequest: true, comment: true }, +}; +const repoPath = (input: ProviderRepositoryRef) => + `repos/${input.repository.split("/").map(encodeURIComponent).join("/")}`; +const pullPath = (input: ProviderRepositoryRef & { readonly number: number }) => + `${repoPath(input)}/pulls/${input.number}`; +const issuePath = (input: ProviderRepositoryRef & { readonly number: number }) => + `${repoPath(input)}/issues/${input.number}`; +// Review IDs differ from the issue-comment IDs used by Forgejo's reactions API. +const reviewCommentId = (review: typeof ForgejoReview.Type) => + /#issuecomment-([1-9]\d*)$/.exec(review.html_url ?? "")?.[1]; + +export const make = Effect.gen(function* () { + const cli = yield* ForgejoCli; + const failure = (operation: string, detail: string, cause?: unknown) => + new PullRequestProviderError({ + provider: "forgejo", + operation, + reason: "failed", + detail, + ...(cause === undefined ? {} : { cause }), + }); + const request = (input: ForgejoApiInput) => + cli.api(input).pipe( + Effect.mapError( + (error) => + new PullRequestProviderError({ + provider: "forgejo", + operation: input.path, + detail: error.detail, + cause: error, + reason: + error.reason === "missing-cli" + ? "missing-tool" + : error.reason === "authentication" + ? "unauthenticated" + : error.reason === "rate-limit" + ? "rate-limited" + : "failed", + }), + ), + ); + const read = Effect.fn("ForgejoPullRequestProvider.read")(function* ( + input: ForgejoApiInput, + schema: Schema.Codec, + ) { + const result = yield* request(input); + if (result.stdoutTruncated) + return yield* failure(input.path, "Forgejo response exceeded the output limit."); + const decoded = decodeJsonResult(schema)(result.stdout); + return Result.isSuccess(decoded) + ? decoded.success + : yield* failure(input.path, "Forgejo returned an invalid response.", decoded.failure); + }); + const readArray = (input: ForgejoApiInput, schema: Schema.Codec) => + read(input, Schema.NullOr(Schema.Array(schema))).pipe(Effect.map((rows) => rows ?? [])); + const readPage = Effect.fn("ForgejoPullRequestProvider.readPage")(function* ( + input: ForgejoApiInput, + schema: Schema.Codec, + index: number, + ) { + const result = yield* request({ + ...input, + path: `${input.path}${input.path.includes("?") ? "&" : "?"}limit=50&page=${index}`, + }); + if (result.stdoutTruncated) + return yield* failure(input.path, "Forgejo response exceeded the output limit."); + const decoded = decodeJsonResult(Schema.NullOr(Schema.Array(schema)))(result.stdout); + if (Result.isFailure(decoded)) + return yield* failure(input.path, "Forgejo returned an invalid response.", decoded.failure); + const rows = decoded.success ?? []; + const links = /^link:\s*(.*)$/im.exec(result.stderr)?.[1]; + return { + rows, + more: rows.length > 0 && (links === undefined || /rel="?next"?/i.test(links)), + }; + }); + // Use a stable, small page size that also works with Forgejo's default maximum of 50. + const page = Effect.fn("ForgejoPullRequestProvider.page")(function* ( + input: ForgejoApiInput, + schema: Schema.Codec, + limit = 500, + ) { + const items: A[] = []; + for (let index = 1; items.length < limit; index++) { + const { rows, more } = yield* readPage(input, schema, index); + items.push(...rows); + if (!more) return { items, truncated: false }; + } + return { items, truncated: true }; + }); + const write = (input: ForgejoApiInput) => request(input).pipe(Effect.asVoid); + const getPull = (input: ProviderRepositoryRef & { readonly number: number }) => + read( + { + cwd: input.cwd, + repository: input.repository, + host: input.host, + path: pullPath(input), + }, + ForgejoPullRequest, + ); + const getRepo = (input: ProviderRepositoryRef) => + read({ ...input, path: repoPath(input) }, ForgejoRepository); + const getViewer = (input: { + readonly cwd: string; + readonly repository?: string; + readonly host?: string; + }) => read({ ...input, path: "user" }, ForgejoUser).pipe(Effect.map((user) => user.login)); + const permissions = ( + repo: typeof ForgejoRepository.Type, + pr: typeof ForgejoPullRequest.Type, + viewer: string, + ): PullRequestViewerPermissions => { + const canWrite = repo.permissions?.push ?? false; + const canEdit = canWrite || pr.user?.login === viewer; + const active = !repo.archived; + return { + actions: active + ? CAPABILITIES.actions.filter((action) => + action === "merge" || action === "update-branch" ? canWrite : canEdit, + ) + : [], + comment: active && (!pr.is_locked || canWrite), + resolve: false, + verdicts: active + ? pr.user?.login === viewer + ? ["comment"] + : CAPABILITIES.review.verdicts + : [], + requestReviewers: active && canEdit, + labels: active && canWrite, + updateMethods: + active && canWrite ? (repo.allow_rebase_update ? ["merge", "rebase"] : ["merge"]) : [], + }; + }; + const getPermissions = Effect.fn("ForgejoPullRequestProvider.getPermissions")(function* ( + input: ProviderRepositoryRef & { readonly number: number }, + ) { + const [repo, pr, viewer] = yield* Effect.all( + [getRepo(input), getPull(input), getViewer(input)], + { concurrency: 3 }, + ); + return permissions(repo, pr, viewer); + }); + const unsupported = (operation: string) => + Effect.fail(failure(operation, `Forgejo does not expose ${operation} through its API.`)); + const provider: PullRequestProviderApi = { + kind: "forgejo", + capabilities: CAPABILITIES, + getViewer, + listChangeRequests: Effect.fn("ForgejoPullRequestProvider.listChangeRequests")( + function* (input) { + const offset = input.cursor?.delivered ?? 0; + const items: ReturnType[] = []; + const state = input.state === "merged" ? "closed" : input.state; + const query = { + ...input, + path: `${repoPath(input)}/pulls?state=${state}&sort=recentupdate`, + }; + // Self-hosted servers may cap pages below 50. Establish their actual page size before + // translating the service's row offset into an API page number. + const first = yield* readPage(query, Schema.NullOr(ForgejoPullRequest), 1); + const pageSize = first.rows.length || 50; + const firstIndex = Math.floor(offset / pageSize) + 1; + let consumed = 0; + let more = true; + for (let index = firstIndex; more && consumed < input.limit; index++) { + const result = + index === 1 ? first : yield* readPage(query, Schema.NullOr(ForgejoPullRequest), index); + const rows = result.rows; + const start = index === firstIndex ? offset % pageSize : 0; + const countBefore = consumed; + for (const row of rows.slice(start)) { + if (consumed >= input.limit) break; + consumed++; + if (row) items.push(forgejoChangeRequest(row)); + } + more = result.more || rows.length - start > consumed - countBefore; + } + return { items, truncated: more, continues: true, cursorAdvance: consumed }; + }, + ), + getChangeRequestSummary: (input) => getPull(input).pipe(Effect.map(forgejoChangeRequest)), + getChangeRequest: Effect.fn("ForgejoPullRequestProvider.getChangeRequest")(function* (input) { + const [pr, repo, viewer] = yield* Effect.all( + [getPull(input), getRepo(input), getViewer(input)], + { concurrency: 3 }, + ); + const statuses = yield* page( + { + ...input, + path: `${repoPath(input)}/statuses/${encodeURIComponent(pr.head.sha)}?sort=recentupdate`, + }, + ForgejoStatus, + ); + return { + ...forgejoChangeRequest(pr), + body: pr.body ?? "", + changedFiles: pr.changed_files ?? 0, + reviewers: (pr.requested_reviewers ?? []).flatMap((user) => { + const actor = forgejoActor(user); + return actor ? [actor] : []; + }), + checks: forgejoChecks(statuses.items), + baseComparison: !pr.merge_base + ? "unknown" + : pr.merge_base === pr.base.sha + ? "up-to-date" + : "behind", + viewerPermissions: permissions(repo, pr, viewer), + mergeCapabilities: { + merge: repo.allow_merge_commits ?? true, + squash: repo.allow_squash_merge ?? true, + rebase: repo.allow_rebase ?? true, + }, + } satisfies ProviderChangeRequestDetail; + }), + getViewerPermissions: getPermissions, + getChangeRequestActivity: Effect.fn("ForgejoPullRequestProvider.getChangeRequestActivity")( + function* (input) { + const [comments, reviews, commits, reactions, viewer] = yield* Effect.all( + [ + // Issue comments ignore page/limit; fetch this unpaginated endpoint once. + readArray({ ...input, path: `${issuePath(input)}/comments` }, ForgejoComment).pipe( + Effect.map((items) => ({ + items: items.slice(0, 500), + truncated: items.length > 500, + })), + ), + page({ ...input, path: `${pullPath(input)}/reviews` }, ForgejoReview), + page({ ...input, path: `${pullPath(input)}/commits` }, ForgejoCommit), + page({ ...input, path: `${issuePath(input)}/reactions` }, ForgejoReaction), + getViewer(input), + ], + { concurrency: 5 }, + ); + const reviewComments = yield* Effect.forEach( + reviews.items.filter((review) => review.comments_count > 0 && review.state !== "PENDING"), + (review) => + readArray( + { ...input, path: `${pullPath(input)}/reviews/${review.id}/comments` }, + ForgejoReviewComment, + ), + { concurrency: 4 }, + ); + const allInline = reviewComments.flat(); + const inline = allInline.slice(0, 500); + const entries = [ + ...comments.items.map((comment) => ({ + comment: forgejoComment(comment), + reactionId: String(comment.id), + })), + ...reviews.items + .filter((review) => review.state !== "PENDING" && review.state !== "REQUEST_REVIEW") + .map((review) => ({ + comment: forgejoReview(review), + reactionId: reviewCommentId(review), + })), + ...inline.map((comment) => ({ + comment: { + ...forgejoComment(comment), + kind: "review-comment" as const, + path: comment.path, + }, + reactionId: String(comment.id), + })), + ]; + const enriched = yield* Effect.forEach( + entries, + ({ comment, reactionId }) => + (reactionId === undefined + ? Effect.succeed([]) + : readArray( + { ...input, path: `${repoPath(input)}/issues/comments/${reactionId}/reactions` }, + ForgejoReaction, + ) + ).pipe( + Effect.map((rows) => ({ ...comment, reactions: forgejoReactions(rows, viewer) })), + ), + { concurrency: 4 }, + ); + const byId = new Map(enriched.map((comment) => [comment.id, comment])); + const timeline = enriched.toSorted((a, b) => a.createdAt.localeCompare(b.createdAt)); + return { + comments: timeline, + commentCount: timeline.length, + commentsTruncated: + comments.truncated || reviews.truncated || allInline.length > inline.length, + reviewThreads: inline.map((comment) => ({ + ...forgejoReviewThread(comment), + comments: [byId.get(String(comment.id)) ?? forgejoComment(comment)], + })), + commits: commits.items.map(forgejoCommit), + reactions: forgejoReactions(reactions.items, viewer), + }; + }, + ), + getDiff: (input) => + request({ + ...input, + path: input.commit + ? `${repoPath(input)}/git/commits/${encodeURIComponent(input.commit)}.diff` + : `${pullPath(input)}.diff`, + }).pipe( + Effect.map((result) => ({ + patch: result.stdout, + truncated: result.stdoutTruncated, + nextCursor: null, + })), + ), + getDiffFileContents: Effect.fn("ForgejoPullRequestProvider.getDiffFileContents")( + function* (input) { + const pr = yield* getPull(input); + const commit = input.commit + ? yield* read( + { + ...input, + path: `${repoPath(input)}/git/commits/${encodeURIComponent(input.commit)}`, + }, + ForgejoCommit, + ) + : null; + const oldRef = commit ? commit.parents[0]?.sha : pr.merge_base || pr.base.sha; + const newRef = input.commit ?? pr.head.sha; + const content = (repository: string, ref: string, path: string) => + read( + { + ...input, + repository, + path: `${repoPath({ ...input, repository })}/contents/${path.split("/").map(encodeURIComponent).join("/")}?ref=${encodeURIComponent(ref)}`, + }, + Schema.Struct({ content: Schema.String, encoding: Schema.Literal("base64") }), + ).pipe(Effect.map((file) => Buffer.from(file.content, "base64").toString("utf8"))); + const [oldContents, newContents] = yield* Effect.all( + [ + input.changeType === "new" || !oldRef + ? Effect.succeed("") + : content(input.repository, oldRef, input.oldPath), + input.changeType === "deleted" + ? Effect.succeed("") + : content(pr.head.repo?.full_name ?? input.repository, newRef, input.newPath), + ], + { concurrency: 2 }, + ); + return { oldContents, newContents }; + }, + ), + runAction: (input) => { + switch (input.action) { + case "merge": + return write({ + ...input, + path: `${pullPath(input)}/merge`, + method: "POST", + body: { Do: input.mergeMethod ?? "merge" }, + }); + case "close": + case "reopen": + return write({ + ...input, + path: pullPath(input), + method: "PATCH", + body: { state: input.action === "close" ? "closed" : "open" }, + }); + case "update-branch": + return write({ + ...input, + path: `${pullPath(input)}/update?style=${input.updateMethod ?? "merge"}`, + method: "POST", + }); + default: + return unsupported(input.action); + } + }, + updateChangeRequest: (input) => + write({ + ...input, + path: pullPath(input), + method: "PATCH", + body: { + ...(input.title === undefined ? {} : { title: input.title }), + ...(input.body === undefined ? {} : { body: input.body }), + }, + }), + comment: (input) => + write({ + ...input, + path: `${issuePath(input)}/comments`, + method: "POST", + body: { body: input.body }, + }), + updateComment: (input) => + write({ + ...input, + path: `${repoPath(input)}/issues/comments/${encodeURIComponent(input.commentId)}`, + method: "PATCH", + body: { body: input.body }, + }), + submitReview: Effect.fn("ForgejoPullRequestProvider.submitReview")(function* (input) { + const pr = yield* getPull(input); + yield* write({ + ...input, + path: `${pullPath(input)}/reviews`, + method: "POST", + body: { + event: + input.verdict === "approve" + ? "APPROVED" + : input.verdict === "request-changes" + ? "REQUEST_CHANGES" + : "COMMENT", + body: input.body, + commit_id: pr.head.sha, + comments: input.comments.map((comment) => { + const position = comment.position; + const old = + position.kind === "deleted" || + (position.kind === "context" && position.side === "left"); + return { + path: old ? (comment.oldPath ?? comment.path) : comment.path, + body: comment.body, + old_position: old ? position.oldLine : 0, + new_position: old ? 0 : position.newLine, + }; + }), + }, + }); + }), + listReviewerCandidates: Effect.fn("ForgejoPullRequestProvider.listReviewerCandidates")( + function* (input) { + const [pr, users] = yield* Effect.all( + [getPull(input), page({ ...input, path: `${repoPath(input)}/assignees` }, ForgejoUser)], + { concurrency: 2 }, + ); + return { + candidates: users.items + .filter((user) => user.login !== pr.user?.login) + .flatMap((user) => { + const actor = forgejoActor(user); + return actor + ? [ + { + ...actor, + id: user.login, + kind: "user" as const, + isRequested: + pr.requested_reviewers?.some((reviewer) => reviewer.login === user.login) ?? + false, + }, + ] + : []; + }), + truncated: users.truncated, + }; + }, + ), + setReviewerRequest: (input) => + write({ + ...input, + path: `${pullPath(input)}/requested_reviewers`, + method: input.requested ? "POST" : "DELETE", + body: { reviewers: input.reviewers.map((reviewer) => reviewer.id) }, + }), + listLabelCandidates: Effect.fn("ForgejoPullRequestProvider.listLabelCandidates")( + function* (input) { + const [pr, labels] = yield* Effect.all( + [getPull(input), page({ ...input, path: `${repoPath(input)}/labels` }, ForgejoLabel)], + { concurrency: 2 }, + ); + return { + candidates: labels.items.map((label) => ({ + name: label.name, + color: label.color ?? null, + description: label.description ?? null, + isApplied: pr.labels?.some((applied) => applied.id === label.id) ?? false, + })), + truncated: labels.truncated, + }; + }, + ), + setLabels: Effect.fn("ForgejoPullRequestProvider.setLabels")(function* (input) { + const labels = yield* page({ ...input, path: `${repoPath(input)}/labels` }, ForgejoLabel); + const selected = labels.items.filter((label) => input.labels.includes(label.name)); + if (selected.length !== input.labels.length) + return yield* failure("setLabels", "One or more requested labels could not be found."); + if (input.applied) + yield* write({ + ...input, + path: `${issuePath(input)}/labels`, + method: "POST", + body: { labels: selected.map((label) => label.id) }, + }); + else + yield* Effect.forEach( + selected, + (label) => + write({ ...input, path: `${issuePath(input)}/labels/${label.id}`, method: "DELETE" }), + { concurrency: 1 }, + ); + }), + setReaction: Effect.fn("ForgejoPullRequestProvider.setReaction")(function* (input) { + let commentId = input.subjectId; + if (commentId?.startsWith("review:")) { + const reviewId = /^review:([1-9]\d*)$/.exec(commentId)?.[1]; + if (!reviewId) return yield* failure("setReaction", "Invalid Forgejo review ID."); + const review = yield* read( + { ...input, path: `${pullPath(input)}/reviews/${reviewId}` }, + ForgejoReview, + ); + commentId = reviewCommentId(review); + if (!commentId) + return yield* failure( + "setReaction", + "Forgejo did not return a comment ID for this review.", + ); + } + if (commentId && !/^[1-9]\d*$/.test(commentId)) + return yield* failure("setReaction", "Invalid Forgejo comment ID."); + yield* write({ + ...input, + path: commentId + ? `${repoPath(input)}/issues/comments/${commentId}/reactions` + : `${issuePath(input)}/reactions`, + method: input.reacted ? "POST" : "DELETE", + body: { content: FORGEJO_REACTIONS[input.content] }, + }); + }), + replyToThread: () => unsupported("thread replies"), + setThreadResolution: () => unsupported("thread resolution"), + }; + return provider; +}); diff --git a/apps/server/src/pullRequest/PullRequestProvider.ts b/apps/server/src/pullRequest/PullRequestProvider.ts index 2309268bba68..ba8d13908b5e 100644 --- a/apps/server/src/pullRequest/PullRequestProvider.ts +++ b/apps/server/src/pullRequest/PullRequestProvider.ts @@ -282,6 +282,7 @@ export interface PullRequestProviderApi { /** The signed-in account, which is what involvement filtering compares against. */ readonly getViewer: (input: { readonly cwd: string; + readonly host?: string; }) => Effect.Effect; readonly listChangeRequests: ( diff --git a/apps/server/src/pullRequest/PullRequestProviderRegistry.ts b/apps/server/src/pullRequest/PullRequestProviderRegistry.ts index f5b4251f7d9a..9e8727ed7a77 100644 --- a/apps/server/src/pullRequest/PullRequestProviderRegistry.ts +++ b/apps/server/src/pullRequest/PullRequestProviderRegistry.ts @@ -8,6 +8,8 @@ import * as BitbucketApi from "../sourceControl/BitbucketApi.ts"; import * as GitHubCli from "../sourceControl/GitHubCli.ts"; import * as GitHubGraphQlBudget from "../sourceControl/githubGraphQlBudget.ts"; import * as GitLabCli from "../sourceControl/GitLabCli.ts"; +import * as ForgejoCli from "../sourceControl/ForgejoCli.ts"; +import * as ForgejoPullRequestProvider from "./ForgejoPullRequestProvider.ts"; import * as AzureDevOpsPullRequestCli from "./AzureDevOpsPullRequestCli.ts"; import * as AzureDevOpsPullRequestProvider from "./AzureDevOpsPullRequestProvider.ts"; import * as BitbucketPullRequestApi from "./BitbucketPullRequestApi.ts"; @@ -48,6 +50,7 @@ export const make = Effect.map( Effect.all([ GitHubPullRequestProvider.make, GitLabPullRequestProvider.make, + ForgejoPullRequestProvider.make, BitbucketPullRequestProvider.make, AzureDevOpsPullRequestProvider.make, ]), @@ -62,6 +65,7 @@ export const layer = Layer.effect(PullRequestProviderRegistry, make).pipe( ), ), Layer.provide(GitLabPullRequestCli.layer.pipe(Layer.provide(GitLabCli.layer))), + Layer.provide(ForgejoCli.layer), Layer.provide(BitbucketPullRequestApi.layer.pipe(Layer.provide(BitbucketApi.layer))), Layer.provide(AzureDevOpsPullRequestCli.layer.pipe(Layer.provide(AzureDevOpsCli.layer))), ); diff --git a/apps/server/src/pullRequest/PullRequestService.test.ts b/apps/server/src/pullRequest/PullRequestService.test.ts index 66257a6c3af9..da554ffc4001 100644 --- a/apps/server/src/pullRequest/PullRequestService.test.ts +++ b/apps/server/src/pullRequest/PullRequestService.test.ts @@ -1659,6 +1659,118 @@ it.effect("reads a host-native stack through the provider and null where it has }), ); +it.effect("routes explicit Forgejo HTTP authorities through SSH checkouts after refinement", () => + Effect.gen(function* () { + for (const provider of ["forgejo", "unknown"] as const) { + const seen: string[] = []; + const viewers: Array = []; + const service = yield* makeService({ + projects: [ + project({ + id: "ssh", + title: "ssh", + workspaceRoot: "/ssh", + repository: "team/repo", + provider, + host: "ssh.code.example", + remoteUrl: "git@ssh.code.example:team/repo.git", + }), + ], + providers: [ + fakeProvider("forgejo", { + getViewer: (input) => { + viewers.push(input.host); + assert.strictEqual(input.host, "code.example:3000"); + return Effect.succeed("bilal"); + }, + listChangeRequests: (input) => { + assert.strictEqual(input.host, "code.example:3000"); + return Effect.succeed({ items: [], truncated: false, continues: true }); + }, + getChangeRequest: (input) => { + assert.strictEqual(input.host, "code.example:3000"); + return Effect.succeed({ ...hostedChangeRequest("Forgejo detail"), number: 42 }); + }, + getChangeRequestSummary: (input) => + Effect.sync(() => { + seen.push(input.host); + return changeRequest(42, "2026-07-02T00:00:00Z"); + }), + }), + ], + resolveHandle: ({ context }) => { + if (context?.requestedHost === undefined) { + return Effect.succeed({ context: context!, provider: undefined as never }); + } + assert.strictEqual(context.requestedHost, "code.example:3000"); + return Effect.succeed({ + context: { + ...context, + provider: { kind: "forgejo", name: "Forgejo", baseUrl: "http://code.example:3000" }, + }, + provider: undefined as never, + }); + }, + }); + yield* service.summary( + { + projectId: "ssh" as ProjectId, + host: "code.example:3000", + repository: "team/repo", + number: 42, + }, + { recoverTransientFailure: false }, + ); + assert.deepStrictEqual(seen, ["code.example:3000"]); + const listed = yield* service.list({ + projectId: "ssh" as ProjectId, + host: "code.example:3000", + state: "open", + }); + assert.strictEqual(listed.viewers["code.example:3000"], "bilal"); + const detail = yield* service.detail({ + projectId: "ssh" as ProjectId, + host: "code.example:3000", + repository: "team/repo", + number: 42, + }); + assert.strictEqual(detail.body, "Forgejo detail"); + assert.deepStrictEqual(viewers, ["code.example:3000"]); + } + }), +); + +it.effect("rejects a different Forgejo HTTP port for an HTTP checkout", () => + Effect.gen(function* () { + const service = yield* makeService({ + projects: [ + project({ + id: "http", + title: "http", + workspaceRoot: "/http", + repository: "team/repo", + provider: "forgejo", + host: "code.example", + remoteUrl: "http://code.example:4000/team/repo.git", + }), + ], + providers: [fakeProvider("forgejo")], + }); + const failure = yield* service + .summary( + { + projectId: "http" as ProjectId, + host: "code.example:3000", + repository: "team/repo", + number: 42, + }, + { recoverTransientFailure: false }, + ) + .pipe(Effect.flip); + assert.strictEqual(failure._tag, "PullRequestUnavailableError"); + }), +); + it.effect("routes a hosted reference to another repository through a project on that host", () => Effect.gen(function* () { const seen: Array<{ cwd: string; repository: string; host: string }> = []; diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts index 241a9e8b1836..005acfa0f5c0 100644 --- a/apps/server/src/pullRequest/PullRequestService.ts +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -1,5 +1,6 @@ import { canonicalRepositoryKey, + isSshRemoteUrl, sourceControlRepositorySelector, } from "@t3tools/shared/sourceControl"; import * as Cache from "effect/Cache"; @@ -553,14 +554,21 @@ export const make = Effect.gen(function* () { if (filter.projectId !== undefined && project.id !== filter.projectId) continue; const identity = project.repositoryIdentity; if ( - identity?.provider !== "unknown" || + (identity?.provider !== "unknown" && + !(identity?.provider === "forgejo" && isSshRemoteUrl(identity.locator.remoteUrl))) || sourceControlRepositorySelector(project.repositoryIdentity) === null ) continue; const host = pullRequestHostOf(identity, "unknown"); // A legacy identity has no canonical host until its provider is refined, so it must reach // the refinement before a host filter can decide whether it belongs in the result. - if (filter.host !== undefined && host !== "unknown" && host !== filter.host.toLowerCase()) { + if ( + filter.host !== undefined && + host !== "unknown" && + host !== filter.host.toLowerCase() && + pullRequestHostOf(identity, "forgejo") !== filter.host.toLowerCase() && + !isSshRemoteUrl(identity.locator.remoteUrl) + ) { continue; } const { remoteName, remoteUrl } = identity.locator; @@ -581,20 +589,28 @@ export const make = Effect.gen(function* () { Effect.suspend(() => sourceControlProviders.resolveHandle({ cwd: project.workspaceRoot, - context: { provider, remoteName, remoteUrl }, + context: { + provider: + provider.kind === "forgejo" ? { ...provider, kind: "unknown" } : provider, + remoteName, + remoteUrl, + ...(filter.host !== undefined && isSshRemoteUrl(remoteUrl) + ? { requestedHost: filter.host } + : {}), + }, }), ).pipe( Effect.flatMap((handle) => { - const kind = handle.context?.provider.kind; - return kind === undefined || kind === "unknown" + const refined = handle.context?.provider; + return refined === undefined || refined.kind === "unknown" ? Effect.fail(undefined) - : Effect.succeed(kind); + : Effect.succeed(refined); }), ), ), ).pipe( - Effect.map((kind) => [baseUrl, kind] as const), - Effect.orElseSucceed(() => [baseUrl, "unknown"] as const), + Effect.map((provider) => [baseUrl, provider] as const), + Effect.orElseSucceed(() => [baseUrl, null] as const), ), { concurrency: REPOSITORY_CONCURRENCY }, ).pipe(Effect.map((resolved) => new Map(resolved))); @@ -617,10 +633,10 @@ export const make = Effect.gen(function* () { ), Effect.flatMap((projects) => refineUnknownProjectKinds(projects, filter).pipe( - Effect.map((refinedKinds) => ({ refinedKinds, projects })), + Effect.map((refinedProviders) => ({ refinedProviders, projects })), ), ), - Effect.map(({ refinedKinds, projects }) => { + Effect.map(({ refinedProviders, projects }) => { const supported: SupportedProject[] = []; const unimplemented = new Map< string, @@ -638,12 +654,22 @@ export const make = Effect.gen(function* () { // Worktrees of one repository are separate projects; reading the remote once keeps // the page from repeating every change request per local checkout. The host is part // of the key, so the same `owner/repo` on two hosts stays two repositories. - if (kind === "unknown") { + let refinedProvider: SourceControlProviderInfo | null | undefined; + if ( + kind === "unknown" || + (kind === "forgejo" && isSshRemoteUrl(identity.locator.remoteUrl)) + ) { const provider = detectSourceControlProviderFromRemoteUrl(identity.locator.remoteUrl); - kind = provider === null ? kind : (refinedKinds.get(provider.baseUrl) ?? kind); + refinedProvider = provider === null ? null : refinedProviders.get(provider.baseUrl); + kind = refinedProvider?.kind ?? kind; + } + const host = + refinedProvider?.kind === "forgejo" + ? new URL(refinedProvider.baseUrl).host.toLowerCase() + : pullRequestHostOf(identity, kind); + if (filter.host !== undefined && host !== filter.host.toLowerCase()) { + continue; } - const host = pullRequestHostOf(identity, kind); - if (filter.host !== undefined && host !== filter.host.toLowerCase()) continue; const api = registry.get(kind); // Recorded before the de-duplication below, so the viewer lookup keeps the alternates // the listing is about to drop. @@ -816,7 +842,7 @@ export const make = Effect.gen(function* () { return Effect.die(new Error(`Missing pull request provider: ${kind}`)); } const api = withRateLimitBackoff(registered, host, rateLimits); - return Effect.firstSuccessOf(roots.map((cwd) => api.getViewer({ cwd }))).pipe( + return Effect.firstSuccessOf(roots.map((cwd) => api.getViewer({ cwd, host }))).pipe( Effect.map((viewer) => ({ host, kind, diff --git a/apps/server/src/pullRequest/forgejoPullRequestJson.ts b/apps/server/src/pullRequest/forgejoPullRequestJson.ts new file mode 100644 index 000000000000..1d0b5c1d4ccd --- /dev/null +++ b/apps/server/src/pullRequest/forgejoPullRequestJson.ts @@ -0,0 +1,262 @@ +import * as Schema from "effect/Schema"; +import * as DateTime from "effect/DateTime"; +import * as Option from "effect/Option"; +import type { + PullRequestActor, + PullRequestCheck, + PullRequestComment, + PullRequestCommit, + PullRequestReaction, + PullRequestReactionContent, + PullRequestReviewThread, +} from "@t3tools/contracts"; +import type { ProviderChangeRequest } from "./PullRequestProvider.ts"; +import { dedupeChecks } from "./pullRequestChecks.ts"; + +export const ForgejoUser = Schema.Struct({ + login: Schema.String, + full_name: Schema.optional(Schema.NullOr(Schema.String)), + avatar_url: Schema.optional(Schema.NullOr(Schema.String)), +}); +export const ForgejoLabel = Schema.Struct({ + id: Schema.Int, + name: Schema.String, + color: Schema.optional(Schema.NullOr(Schema.String)), + description: Schema.optional(Schema.NullOr(Schema.String)), +}); +export const ForgejoRepository = Schema.Struct({ + full_name: Schema.String, + permissions: Schema.optional(Schema.Struct({ push: Schema.Boolean, admin: Schema.Boolean })), + archived: Schema.optional(Schema.Boolean), + allow_merge_commits: Schema.optional(Schema.Boolean), + allow_squash_merge: Schema.optional(Schema.Boolean), + allow_rebase: Schema.optional(Schema.Boolean), + allow_rebase_update: Schema.optional(Schema.Boolean), +}); +const Branch = Schema.Struct({ + ref: Schema.String, + sha: Schema.String, + repo: Schema.NullOr(ForgejoRepository), +}); +export const ForgejoPullRequest = Schema.Struct({ + number: Schema.Int, + title: Schema.String, + body: Schema.NullOr(Schema.String), + html_url: Schema.String, + user: Schema.NullOr(ForgejoUser), + state: Schema.String, + draft: Schema.optional(Schema.Boolean), + merged: Schema.Boolean, + mergeable: Schema.optional(Schema.Boolean), + is_locked: Schema.optional(Schema.Boolean), + head: Branch, + base: Branch, + merge_base: Schema.optional(Schema.String), + created_at: Schema.String, + updated_at: Schema.String, + closed_at: Schema.NullOr(Schema.String), + merged_at: Schema.NullOr(Schema.String), + additions: Schema.optional(Schema.NullOr(Schema.Int)), + deletions: Schema.optional(Schema.NullOr(Schema.Int)), + changed_files: Schema.optional(Schema.NullOr(Schema.Int)), + comments: Schema.optional(Schema.Int), + labels: Schema.NullOr(Schema.Array(ForgejoLabel)), + requested_reviewers: Schema.optional(Schema.NullOr(Schema.Array(ForgejoUser))), +}); +export const ForgejoComment = Schema.Struct({ + id: Schema.Int, + body: Schema.String, + user: Schema.NullOr(ForgejoUser), + created_at: Schema.String, + html_url: Schema.optional(Schema.String), +}); +export const ForgejoReview = Schema.Struct({ + id: Schema.Int, + body: Schema.String, + user: Schema.NullOr(ForgejoUser), + state: Schema.String, + submitted_at: Schema.String, + html_url: Schema.optional(Schema.String), + comments_count: Schema.Int, +}); +export const ForgejoReviewComment = Schema.Struct({ + ...ForgejoComment.fields, + path: Schema.String, + position: Schema.Int, + original_position: Schema.Int, + commit_id: Schema.String, + original_commit_id: Schema.String, + resolver: Schema.NullOr(ForgejoUser), +}); +export const ForgejoCommit = Schema.Struct({ + sha: Schema.String, + author: Schema.NullOr(ForgejoUser), + commit: Schema.Struct({ + message: Schema.String, + committer: Schema.Struct({ date: Schema.String }), + }), + parents: Schema.Array(Schema.Struct({ sha: Schema.String })), + stats: Schema.optional( + Schema.NullOr(Schema.Struct({ additions: Schema.Int, deletions: Schema.Int })), + ), +}); +export const ForgejoStatus = Schema.Struct({ + context: Schema.String, + status: Schema.String, + description: Schema.NullOr(Schema.String), + target_url: Schema.NullOr(Schema.String), + updated_at: Schema.String, +}); +export const ForgejoReaction = Schema.Struct({ + content: Schema.String, + user: Schema.NullOr(ForgejoUser), +}); + +export function forgejoActor( + user: typeof ForgejoUser.Type | null | undefined, +): PullRequestActor | null { + return user?.login + ? { login: user.login, name: user.full_name || null, avatarUrl: user.avatar_url || null } + : null; +} + +function toIsoUtc(value: string): string { + return Option.match(DateTime.make(value), { onNone: () => value, onSome: DateTime.formatIso }); +} + +export function forgejoChangeRequest(pr: typeof ForgejoPullRequest.Type) { + return { + number: pr.number, + title: pr.title, + url: pr.html_url, + author: forgejoActor(pr.user), + headBranch: pr.head.ref, + baseBranch: pr.base.ref, + headRepositoryNameWithOwner: pr.head.repo?.full_name ?? null, + state: pr.merged ? "merged" : pr.state === "closed" ? "closed" : "open", + isDraft: pr.draft ?? /^(?:\[WIP\]|WIP:)/i.test(pr.title), + mergeability: + pr.mergeable === undefined ? "unknown" : pr.mergeable ? "mergeable" : "conflicting", + additions: pr.additions ?? 0, + deletions: pr.deletions ?? 0, + createdAt: toIsoUtc(pr.created_at), + updatedAt: toIsoUtc(pr.updated_at), + closedAt: pr.closed_at === null ? null : toIsoUtc(pr.closed_at), + mergedAt: pr.merged_at === null ? null : toIsoUtc(pr.merged_at), + reviewRequestLogins: (pr.requested_reviewers ?? []).map((user) => user.login), + labels: (pr.labels ?? []).map((label) => ({ name: label.name, color: label.color ?? null })), + } satisfies ProviderChangeRequest; +} + +export function forgejoComment(comment: typeof ForgejoComment.Type): PullRequestComment { + return { + id: String(comment.id), + kind: "issue-comment", + author: forgejoActor(comment.user), + body: comment.body, + createdAt: toIsoUtc(comment.created_at), + url: comment.html_url || null, + path: null, + reviewState: null, + }; +} + +export function forgejoReview(review: typeof ForgejoReview.Type): PullRequestComment { + return { + id: `review:${review.id}`, + kind: "review", + author: forgejoActor(review.user), + body: review.body, + createdAt: toIsoUtc(review.submitted_at), + url: review.html_url || null, + path: null, + reviewState: + review.state === "REQUEST_CHANGES" + ? "CHANGES_REQUESTED" + : review.state === "COMMENT" + ? "COMMENTED" + : review.state, + }; +} + +export function forgejoReviewThread( + comment: typeof ForgejoReviewComment.Type, +): PullRequestReviewThread { + const oldSide = comment.position === 0 && comment.original_position > 0; + const line = oldSide ? comment.original_position : comment.position; + return { + id: String(comment.id), + path: comment.path, + line: line > 0 ? line : null, + side: oldSide ? "left" : "right", + isResolved: comment.resolver !== null, + isOutdated: false, + comments: [forgejoComment(comment)], + }; +} + +export function forgejoCommit(commit: typeof ForgejoCommit.Type): PullRequestCommit { + const author = forgejoActor(commit.author); + return { + oid: commit.sha, + messageHeadline: commit.commit.message.split("\n")[0] ?? "", + committedDate: toIsoUtc(commit.commit.committer.date), + authors: author ? [author] : [], + ...(commit.stats + ? { additions: commit.stats.additions, deletions: commit.stats.deletions } + : {}), + }; +} + +export function forgejoChecks( + statuses: ReadonlyArray, +): ReadonlyArray { + return dedupeChecks( + statuses.map((status) => ({ + workflowName: null, + at: status.updated_at, + check: { + name: status.context || "check", + description: status.description || null, + url: status.target_url || null, + status: + status.status === "success" + ? "success" + : status.status === "failure" || status.status === "error" + ? "failure" + : "pending", + }, + })), + ); +} + +export const FORGEJO_REACTIONS: Record = { + "thumbs-up": "+1", + "thumbs-down": "-1", + laugh: "laugh", + hooray: "hooray", + confused: "confused", + heart: "heart", + rocket: "rocket", + eyes: "eyes", +}; +export function forgejoReactions( + reactions: ReadonlyArray, + viewer: string, +): ReadonlyArray { + return Object.entries(FORGEJO_REACTIONS).flatMap(([content, emoji]) => { + const matching = reactions.filter((reaction) => reaction.content === emoji); + return matching.length === 0 + ? [] + : [ + { + content: content as PullRequestReactionContent, + count: matching.length, + actors: matching.flatMap((reaction) => + reaction.user?.login && reaction.user.login !== viewer ? [reaction.user.login] : [], + ), + viewerHasReacted: matching.some((reaction) => reaction.user?.login === viewer), + }, + ]; + }); +} diff --git a/apps/server/src/pullRequest/linkedThreads.test.ts b/apps/server/src/pullRequest/linkedThreads.test.ts index 08237d0e3ef4..a87b33c439ec 100644 --- a/apps/server/src/pullRequest/linkedThreads.test.ts +++ b/apps/server/src/pullRequest/linkedThreads.test.ts @@ -17,6 +17,22 @@ it.effect( VALUES ('project-1', 'Project', '/tmp/project', '[]', ${createdAt}, ${createdAt}) `; const fixtures = [ + { + id: "forgejo-old", + host: "forge.example", + repository: "acme/web", + number: 7, + source: "manual", + url: "http://forge.example:3000/acme/web/pulls/7", + }, + { + id: "forgejo-other-port", + host: "forge.example:4000", + repository: "acme/web", + number: 7, + source: "manual", + url: "http://forge.example:4000/acme/web/pulls/7", + }, { id: "azure", host: "dev.azure.com", @@ -83,10 +99,24 @@ it.effect( yield* sql` INSERT INTO projection_thread_pull_requests (thread_id, host, repository, number, url, source, linked_at) VALUES (${fixture.id}, ${fixture.host}, ${fixture.repository}, ${fixture.number}, - 'https://github.com/acme/web/pull/7', ${fixture.source}, ${createdAt}) + ${fixture.url ?? "https://github.com/acme/web/pull/7"}, ${fixture.source}, ${createdAt}) `; } + expect( + (yield* listLinkedPullRequestThreads({ + host: "forge.example:3000", + repository: "acme/web", + number: 7, + })).threads.map((thread) => thread.id), + ).toEqual(["forgejo-old"]); + expect( + (yield* listLinkedPullRequestThreads({ + host: "forge.example:4000", + repository: "acme/web", + number: 7, + })).threads.map((thread) => thread.id), + ).toEqual(["forgejo-other-port"]); expect( (yield* listLinkedPullRequestThreads({ host: "org.visualstudio.com", diff --git a/apps/server/src/pullRequest/linkedThreads.ts b/apps/server/src/pullRequest/linkedThreads.ts index 26c751f0b817..7d6efc939dc9 100644 --- a/apps/server/src/pullRequest/linkedThreads.ts +++ b/apps/server/src/pullRequest/linkedThreads.ts @@ -1,4 +1,7 @@ -import { normalizeThreadPullRequestKey } from "@t3tools/shared/threadPullRequests"; +import { + normalizeThreadPullRequestKey, + threadPullRequestKeysEqual, +} from "@t3tools/shared/threadPullRequests"; import { PullRequestLinkedThreadsResult, PullRequestOperationError, @@ -13,19 +16,32 @@ const decodeLinkedThreads = Schema.decodeUnknownEffect(PullRequestLinkedThreadsR export const listLinkedPullRequestThreads = Effect.fn("listLinkedPullRequestThreads")( function* (input: ThreadPullRequestKey) { const key = normalizeThreadPullRequestKey(input); + const hostname = key.host.replace(/:\d+$/u, ""); const sql = yield* SqlClient.SqlClient; - const threads = yield* sql` + const rows = yield* sql<{ + id: string; + projectId: string; + title: string; + archivedAt: string | null; + host: string; + repository: string; + number: number; + url: string; + }>` SELECT t.thread_id AS id, t.project_id AS "projectId", t.title, - t.archived_at AS "archivedAt" + t.archived_at AS "archivedAt", link.host, link.repository, link.number, link.url FROM projection_thread_pull_requests AS link JOIN projection_threads AS t ON t.thread_id = link.thread_id - WHERE link.host = ${key.host.toLowerCase()} + WHERE (link.host = ${key.host} OR link.host = ${hostname}) AND link.repository = ${key.repository.toLowerCase()} AND link.number = ${key.number} AND link.source != 'stack-dismissed' AND t.deleted_at IS NULL ORDER BY t.updated_at DESC, t.thread_id ASC `; + const threads = rows + .filter((row) => threadPullRequestKeysEqual(row, key)) + .map(({ id, projectId, title, archivedAt }) => ({ id, projectId, title, archivedAt })); return yield* decodeLinkedThreads({ threads }); }, Effect.mapError( diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index e3b42a2637a1..b8c3ca6096e9 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -1,4 +1,8 @@ -import { EnvironmentHttpApi, ProviderDriverKind } from "@t3tools/contracts"; +import { + EnvironmentHttpApi, + ProviderDriverKind, + type RepositoryIdentity, +} from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as Duration from "effect/Duration"; import * as Deferred from "effect/Deferred"; @@ -51,6 +55,7 @@ import * as AzureDevOpsCli from "./sourceControl/AzureDevOpsCli.ts"; import * as BitbucketApi from "./sourceControl/BitbucketApi.ts"; import * as GitHubCli from "./sourceControl/GitHubCli.ts"; import * as GitLabCli from "./sourceControl/GitLabCli.ts"; +import * as ForgejoCli from "./sourceControl/ForgejoCli.ts"; import * as TextGeneration from "./textGeneration/TextGeneration.ts"; import { ProviderInstanceRegistryHydrationLive } from "./provider/Layers/ProviderInstanceRegistryHydration.ts"; import * as TerminalManager from "./terminal/Manager.ts"; @@ -313,12 +318,54 @@ const VcsDriverRegistryLayerLive = VcsDriverRegistry.layer.pipe( const SourceControlProviderRegistryLayerLive = SourceControlProviderRegistry.layer.pipe( Layer.provide( - Layer.mergeAll(AzureDevOpsCli.layer, BitbucketApi.layer, GitHubCli.layer, GitLabCli.layer), + Layer.mergeAll( + AzureDevOpsCli.layer, + BitbucketApi.layer, + GitHubCli.layer, + GitLabCli.layer, + ForgejoCli.layer, + ), ), Layer.provideMerge(GitVcsDriver.layer), Layer.provideMerge(VcsDriverRegistryLayerLive), ); +const RepositoryIdentityResolverLayerLive = Layer.effect( + RepositoryIdentityResolver.RepositoryIdentityResolver, + Effect.gen(function* () { + const registry = yield* SourceControlProviderRegistry.SourceControlProviderRegistry; + return yield* RepositoryIdentityResolver.make({ + refine: Effect.fn(function* (identity: RepositoryIdentity) { + const remote = ForgejoCli.parseForgejoRemote(identity.locator.remoteUrl); + if ( + !remote || + !identity.rootPath || + (identity.provider !== undefined && + identity.provider !== "unknown" && + identity.provider !== "forgejo") + ) + return identity; + const handle = yield* registry.resolveHandle({ + cwd: identity.rootPath, + context: { + provider: { kind: "unknown", name: "Unknown", baseUrl: "" }, + remoteName: identity.locator.remoteName, + remoteUrl: identity.locator.remoteUrl, + }, + }); + if (handle.context?.provider.kind !== "forgejo") return identity; + const baseUrl = handle.context.provider.baseUrl.replace(/\/+$/, ""); + const basePath = new URL(baseUrl).pathname.replace(/^\/+|\/+$/g, ""); + const path = + !remote.ssh && basePath && remote.path.startsWith(`${basePath}/`) + ? remote.path.slice(basePath.length + 1) + : remote.path; + return { ...identity, provider: "forgejo", webUrl: `${baseUrl}/${path}` }; + }), + }); + }), +).pipe(Layer.provide(SourceControlProviderRegistryLayerLive), Layer.provide(ProcessRunner.layer)); + const PullRequestServiceLive = PullRequestService.layer.pipe( Layer.provide(PullRequestProviderRegistry.layer), Layer.provide(PullRequestReadCache.layer), @@ -512,7 +559,7 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive), Layer.provideMerge(WorkspaceLayerLive), Layer.provideMerge(Layer.mergeAll(NativeAppIconResolver.layer, ProjectFaviconResolverLayerLive)), - Layer.provideMerge(RepositoryIdentityResolver.layer), + Layer.provideMerge(RepositoryIdentityResolverLayerLive), Layer.provideMerge(ServerEnvironmentLayerLive), Layer.provideMerge(AuthLayerLive), Layer.provideMerge(ServerSecretStore.layer), diff --git a/apps/server/src/sourceControl/ForgejoCli.ts b/apps/server/src/sourceControl/ForgejoCli.ts new file mode 100644 index 000000000000..9fdb4149c8b6 --- /dev/null +++ b/apps/server/src/sourceControl/ForgejoCli.ts @@ -0,0 +1,720 @@ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import * as Result from "effect/Result"; +import * as Clock from "effect/Clock"; +import * as FileSystem from "effect/FileSystem"; +import * as Semaphore from "effect/Semaphore"; +import * as NodeOS from "node:os"; +// @effect-diagnostics-next-line nodeBuiltinImport:off - fj storage paths use explicit Windows and POSIX layouts, independently of this process's platform. +import * as NodePath from "node:path"; +import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import { decodeJsonResult } from "@t3tools/shared/schemaJson"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as VcsProcess from "../vcs/VcsProcess.ts"; +import { collectUint8StreamText } from "../stream/collectUint8StreamText.ts"; +import type { SourceControlProviderContext } from "./SourceControlProvider.ts"; + +const encodeApiBody = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); + +export class ForgejoCliError extends Schema.TaggedError()("ForgejoCliError", { + command: Schema.Literals(["fj", "tea"]), + cwd: Schema.String, + detail: Schema.String, + reason: Schema.optional( + Schema.Literals([ + "missing-cli", + "authentication", + "forbidden", + "not-found", + "rate-limit", + "invalid-response", + ]), + ), + httpStatus: Schema.optional(Schema.Int), + cause: Schema.optional(Schema.Defect()), +}) {} + +export const ForgejoLoginSchema = Schema.Struct({ + name: Schema.String, + url: Schema.String, + ssh_host: Schema.optional(Schema.String), + valid: Schema.optional(Schema.String), + user: Schema.String, + default: Schema.String, +}); + +export function parseForgejoLogins(raw: string) { + const decoded = decodeJsonResult(Schema.Array(ForgejoLoginSchema))(raw); + return Result.isSuccess(decoded) ? decoded.success : []; +} + +export const ForgejoKeysSchema = Schema.Struct({ + hosts: Schema.Record( + Schema.String, + Schema.Struct({ + type: Schema.Literals(["Application", "OAuth"]), + token: Schema.String, + }), + ), + aliases: Schema.optional(Schema.Record(Schema.String, Schema.String)), +}); + +const parseForgejoKeys = decodeJsonResult(ForgejoKeysSchema); + +/** Matches fj's directories::ProjectDirs, including its pre-0.6 organization name. */ +function forgejoKeysPaths(input: { + readonly platform: string; + readonly home: string; + readonly dataHome?: string; + readonly appData?: string; +}) { + if (input.platform === "darwin") + return ["forgejo-cli", "Cyborus"].map((organization) => + NodePath.join( + input.home, + "Library", + "Application Support", + `${organization}.forgejo-cli`, + "keys.json", + ), + ); + if (input.platform === "win32") + return ["forgejo-cli", "Cyborus"].map((organization) => + NodePath.win32.join( + input.appData || NodePath.win32.join(input.home, "AppData", "Roaming"), + organization, + "forgejo-cli", + "data", + "keys.json", + ), + ); + return [ + NodePath.join( + input.dataHome && NodePath.isAbsolute(input.dataHome) + ? input.dataHome + : NodePath.join(input.home, ".local", "share"), + "forgejo-cli", + "keys.json", + ), + ]; +} + +export interface ForgejoRepositoryInput { + readonly cwd: string; + readonly context?: SourceControlProviderContext; + readonly repository?: string; + readonly reference?: string; + readonly host?: string; +} + +export interface ForgejoRepository { + readonly command?: "fj" | "tea"; + readonly login: string; + readonly repository: string; + readonly baseUrl: string; +} + +export interface ForgejoApiInput extends ForgejoRepositoryInput { + readonly path: string; + readonly method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; + readonly body?: unknown; +} + +export class ForgejoCli extends Context.Service< + ForgejoCli, + { + readonly execute: (input: { + readonly command?: "fj" | "tea"; + readonly cwd: string; + readonly args: ReadonlyArray; + readonly stdin?: string; + readonly timeoutMs?: number; + readonly maxOutputBytes?: number; + }) => Effect.Effect; + readonly listLogins?: (input: { + readonly cwd: string; + readonly command: "fj" | "tea"; + readonly remoteUrl?: string; + }) => Effect.Effect, ForgejoCliError>; + readonly getAccount?: (input: { + readonly cwd: string; + readonly baseUrl: string; + }) => Effect.Effect; + readonly resolveRepository: ( + input: ForgejoRepositoryInput, + ) => Effect.Effect; + readonly api: ( + input: ForgejoApiInput, + ) => Effect.Effect; + } +>()("t3/sourceControl/ForgejoCli") {} + +export function parseForgejoRemote(value: string) { + if (/^(?:https?|ssh):\/\//i.test(value)) { + try { + const url = new URL(value); + return { + host: url.host.toLowerCase(), + hostname: url.hostname.toLowerCase(), + ssh: url.protocol === "ssh:", + path: url.pathname.replace(/^\/+|\/+$/g, "").replace(/\.git$/, ""), + }; + } catch { + return null; + } + } + // SCP remotes may omit the username; URL treats these as a custom scheme. + const ssh = /^(?:[^@/]+@)?([^:/]+):([^/].*)$/.exec(value); + return ssh?.[1] && ssh[2] + ? { + host: ssh[1].toLowerCase(), + hostname: ssh[1].toLowerCase(), + ssh: true, + path: ssh[2].replace(/\.git$/, ""), + } + : null; +} + +export function matchForgejoLogin( + logins: ReturnType, + remote: NonNullable>, + requestedHost?: string, + hostOnly = false, +) { + const matches = [ + ...new Map( + logins + .filter((login) => { + const url = parseForgejoRemote(login.url); + if (!url) return false; + if (requestedHost !== undefined && url.host !== requestedHost.toLowerCase()) return false; + return remote.ssh + ? login.ssh_host?.toLowerCase() === remote.host || + login.ssh_host?.toLowerCase() === remote.hostname || + url.hostname === remote.hostname + : url.host === remote.host && + ((hostOnly && !remote.path) || + !url.path || + remote.path === url.path || + remote.path.startsWith(`${url.path}/`)); + }) + .map((login) => [login.name, login]), + ).values(), + ]; + return matches.length === 1 + ? matches[0] + : new Set(matches.map((login) => login.url)).size === 1 + ? matches.find((login) => login.default === "true") + : undefined; +} + +export const make = Effect.gen(function* () { + const process = yield* VcsProcess.VcsProcess; + const fileSystem = yield* FileSystem.FileSystem; + const httpClient = yield* HttpClient.HttpClient; + const authLock = yield* Semaphore.make(1); + const authenticated = new Map(); + const execute: ForgejoCli["Service"]["execute"] = (input) => + process + .run({ + ...input, + operation: "ForgejoCli.execute", + command: input.command ?? "tea", + timeoutMs: input.timeoutMs ?? 30_000, + }) + .pipe( + Effect.mapError( + (cause) => + new ForgejoCliError({ + command: input.command ?? "tea", + cwd: input.cwd, + ...(input.command === "fj" ? {} : { cause }), + ...(cause._tag === "VcsProcessSpawnError" + ? { reason: "missing-cli" as const } + : cause._tag === "VcsProcessExitError" && cause.failureKind === "authentication" + ? { reason: "authentication" as const } + : {}), + detail: + cause._tag === "VcsProcessSpawnError" + ? "Install Forgejo CLI (`fj` 0.6 or later) or Gitea CLI (`tea` 0.16 or later) and retry." + : cause._tag === "VcsProcessExitError" && cause.failureKind === "authentication" + ? "Authenticate this server with `fj auth login`, `fj auth add-token`, or `tea login add`." + : "Forgejo CLI command failed.", + }), + ), + ); + + const readKeys = Effect.fn("ForgejoCli.readKeys")(function* (cwd: string) { + for (const path of forgejoKeysPaths({ + platform: yield* HostProcessPlatform, + home: NodeOS.homedir(), + ...(globalThis.process.env.XDG_DATA_HOME + ? { dataHome: globalThis.process.env.XDG_DATA_HOME } + : {}), + ...(globalThis.process.env.APPDATA ? { appData: globalThis.process.env.APPDATA } : {}), + })) { + const exists = yield* fileSystem.exists(path).pipe( + Effect.mapError( + () => + new ForgejoCliError({ + command: "fj", + cwd, + reason: "authentication", + detail: "Could not read fj authentication storage.", + }), + ), + ); + if (!exists) continue; + const raw = yield* fileSystem.readFileString(path).pipe( + Effect.mapError( + () => + new ForgejoCliError({ + command: "fj", + cwd, + reason: "authentication", + detail: "Could not read fj authentication storage.", + }), + ), + ); + const decoded = parseForgejoKeys(raw); + if (Result.isFailure(decoded)) + return yield* new ForgejoCliError({ + command: "fj", + cwd, + reason: "authentication", + detail: "fj authentication storage is invalid. Authenticate again with fj.", + }); + return decoded.success; + } + const empty: typeof ForgejoKeysSchema.Type = { hosts: {}, aliases: {} }; + return empty; + }); + + const publicLogins = ( + keys: typeof ForgejoKeysSchema.Type, + remoteUrl?: string, + ): ReturnType => { + const remote = remoteUrl ? parseForgejoRemote(remoteUrl) : null; + return Object.keys(keys.hosts).flatMap((host) => { + const url = parseForgejoRemote(`https://${host}`); + // fj 0.6 drops URL mounts during whoami and OAuth renewal; tea supports them. + if (!url || url.path) return []; + // fj omits the scheme in storage. Only an explicit matching HTTP remote opts into HTTP. + const scheme = + remote && !remote.ssh && remote.host === url.host && /^http:\/\//i.test(remoteUrl ?? "") + ? "http" + : "https"; + const login = { name: host, url: `${scheme}://${host}`, user: "", default: "false" }; + const aliases = Object.entries(keys.aliases ?? {}) + .filter(([, target]) => target === host) + .map(([alias]) => ({ ...login, ssh_host: alias })); + return aliases.length ? aliases : [login]; + }); + }; + + const listLogins: NonNullable = Effect.fn( + "ForgejoCli.listLogins", + )(function* (input) { + if (input.command === "fj") { + const keys = yield* readKeys(input.cwd).pipe(Effect.result); + if (Result.isFailure(keys)) { + // Stale credentials from an uninstalled fj must not disable an available tea login. + const available = yield* execute({ command: "fj", cwd: input.cwd, args: ["version"] }).pipe( + Effect.result, + ); + if (Result.isFailure(available) && available.failure.reason === "missing-cli") return []; + return yield* keys.failure; + } + return publicLogins(keys.success, input.remoteUrl); + } + return parseForgejoLogins( + (yield* execute({ cwd: input.cwd, args: ["login", "list", "--output", "json"] })).stdout, + ); + }); + + const requestFj = Effect.fn("ForgejoCli.requestFj")( + function* (input: { + readonly cwd: string; + readonly baseUrl: string; + readonly token: string; + readonly path: string; + readonly method?: ForgejoApiInput["method"]; + readonly body?: string; + }) { + const base = new URL(`${input.baseUrl}/api/v1/`); + const url = new URL(input.path, base); + if ( + url.origin !== base.origin || + !url.pathname.startsWith(base.pathname) || + url.username || + url.password + ) + return yield* new ForgejoCliError({ + command: "fj", + cwd: input.cwd, + detail: "Invalid Forgejo API path.", + }); + let request = HttpClientRequest.make(input.method ?? "GET")(url.toString()).pipe( + HttpClientRequest.setHeader("Authorization", `token ${input.token}`), + ); + if (input.body !== undefined) + request = request.pipe(HttpClientRequest.bodyText(input.body, "application/json")); + const response = yield* httpClient + .execute(request) + .pipe(Effect.provideService(FetchHttpClient.RequestInit, { redirect: "manual" })); + const status = response.status; + if (status < 200 || status >= 300) + return yield* new ForgejoCliError({ + command: "fj", + cwd: input.cwd, + httpStatus: status, + ...(status === 401 + ? { reason: "authentication" as const } + : status === 403 + ? { reason: "forbidden" as const } + : status === 404 + ? { reason: "not-found" as const } + : status === 429 + ? { reason: "rate-limit" as const } + : {}), + detail: + status === 404 + ? "Forgejo repository or pull request was not found." + : `Forgejo API request failed (HTTP ${status}). Check this server's fj credentials and permissions.`, + }); + const body = + status === 204 || status === 205 + ? { text: "", truncated: false, invalidUtf8: false } + : yield* collectUint8StreamText({ + stream: response.stream, + maxBytes: 8 * 1024 * 1024, + }); + if (body.truncated || body.invalidUtf8) + return yield* new ForgejoCliError({ + command: "fj", + cwd: input.cwd, + reason: "invalid-response", + detail: "Forgejo returned an oversized or invalid response.", + }); + return { + exitCode: ChildProcessSpawner.ExitCode(0), + stdout: body.text, + stderr: `HTTP/1.1 ${status}\n${response.headers.link ? `link: ${response.headers.link}\n` : ""}`, + stdoutTruncated: false, + stderrTruncated: false, + }; + }, + (effect, input) => + effect.pipe( + Effect.timeout(30_000), + Effect.mapError((error) => + error._tag === "ForgejoCliError" + ? error + : new ForgejoCliError({ + command: "fj", + cwd: input.cwd, + detail: "Forgejo API request failed or timed out.", + }), + ), + ), + ); + + const authenticateFj = Effect.fn("ForgejoCli.authenticateFj")(function* ( + cwd: string, + login: typeof ForgejoLoginSchema.Type, + ) { + const keys = yield* readKeys(cwd); + const token = keys.hosts[login.name]?.token; + const now = yield* Clock.currentTimeMillis; + const cached = authenticated.get(login.url); + if (token && cached?.token === token && now - cached.time < 30_000) return token; + yield* execute({ command: "fj", cwd, args: ["--host", login.url, "whoami"] }); + // fj owns OAuth renewal. Re-read the file after it has refreshed an expired token. + const refreshed = (yield* readKeys(cwd)).hosts[login.name]?.token; + if (!refreshed) + return yield* new ForgejoCliError({ + command: "fj", + cwd, + reason: "authentication", + detail: "fj has no credentials for this server. Authenticate again with fj.", + }); + authenticated.set(login.url, { token: refreshed, time: now }); + return refreshed; + }, authLock.withPermits(1)); + + const getAccount: NonNullable = Effect.fn( + "ForgejoCli.getAccount", + )(function* (input) { + const logins = yield* listLogins({ cwd: input.cwd, command: "fj", remoteUrl: input.baseUrl }); + const login = logins.find( + (item) => item.url.replace(/\/+$/, "") === input.baseUrl.replace(/\/+$/, ""), + ); + if (!login) + return yield* new ForgejoCliError({ + command: "fj", + cwd: input.cwd, + reason: "authentication", + detail: "fj has no credentials for this server.", + }); + const token = yield* authenticateFj(input.cwd, login); + const currentUser = yield* requestFj({ + cwd: input.cwd, + baseUrl: login.url.replace(/\/+$/, ""), + token, + path: "user", + }); + const user = decodeJsonResult(Schema.Struct({ login: Schema.String }))(currentUser.stdout); + if (Result.isFailure(user) || !user.success.login.trim()) + return yield* new ForgejoCliError({ + command: "fj", + cwd: input.cwd, + reason: "invalid-response", + detail: "Forgejo returned an invalid account response.", + }); + return user.success.login; + }); + + const resolveTarget = Effect.fn("ForgejoCli.resolveTarget")(function* ( + input: ForgejoRepositoryInput, + hostOnly = false, + ) { + const referenceRemote = input.reference ? parseForgejoRemote(input.reference) : null; + let remoteUrl = [input.reference, input.repository, input.context?.remoteUrl].find( + (value) => value && parseForgejoRemote(value), + ); + let remote = + referenceRemote ?? + (input.repository ? parseForgejoRemote(input.repository) : null) ?? + (input.context ? parseForgejoRemote(input.context.remoteUrl) : null); + if (!remote && (!input.repository || input.host)) { + const result = yield* process + .run({ + operation: "ForgejoCli.remote", + command: "git", + args: input.host ? ["remote", "-v"] : ["remote", "get-url", "origin"], + cwd: input.cwd, + allowNonZeroExit: true, + }) + .pipe( + Effect.mapError( + (cause) => + new ForgejoCliError({ + command: "tea", + cwd: input.cwd, + detail: "Could not resolve the Forgejo repository remote.", + cause, + }), + ), + ); + if (input.host) { + const matchingUrls = [ + ...new Set( + result.stdout.split("\n").flatMap((line) => { + const url = /^\S+\s+(https?:\/\/\S+)\s+\(fetch\)$/.exec(line.trim())?.[1]; + return url && parseForgejoRemote(url)?.host === input.host?.toLowerCase() + ? [url] + : []; + }), + ), + ]; + const origins = [...new Set(matchingUrls.map((url) => new URL(url).origin))]; + remoteUrl = + matchingUrls.length === 1 + ? matchingUrls[0] + : origins.length === 1 + ? origins[0] + : undefined; + } else { + remoteUrl = result.stdout.trim(); + } + remote = remoteUrl ? parseForgejoRemote(remoteUrl) : null; + } + if ( + input.host && + !remote?.ssh && + remote?.host !== input.host.toLowerCase() && + remote?.hostname !== input.host.toLowerCase() + ) + remote = { + host: input.host.toLowerCase(), + hostname: input.host.split(":")[0] ?? input.host, + ssh: false, + path: remote?.path ?? "", + }; + const schemeRemoteUrl = remote?.ssh ? input.context?.provider.baseUrl : remoteUrl; + const fjLogins = yield* listLogins({ + cwd: input.cwd, + command: "fj", + ...(schemeRemoteUrl ? { remoteUrl: schemeRemoteUrl } : {}), + }); + const requestedHost = input.host ?? input.context?.requestedHost; + const matchHostOnly = hostOnly || (!!input.host && !remote?.path); + const selectLogin = (logins: ReturnType) => + remote + ? matchForgejoLogin(logins, remote, remote.ssh ? requestedHost : undefined, matchHostOnly) + : (logins.find((item) => item.default === "true") ?? + (new Set(logins.map((item) => item.name)).size === 1 ? logins[0] : undefined)); + let login = selectLogin(fjLogins); + let command: "fj" | "tea" = "fj"; + if ( + !login && + fjLogins.some( + (item) => + !remote || + matchForgejoLogin([item], remote, remote.ssh ? requestedHost : undefined, matchHostOnly), + ) + ) { + const available = yield* execute({ command: "fj", cwd: input.cwd, args: ["version"] }).pipe( + Effect.result, + ); + if (Result.isSuccess(available)) + return yield* new ForgejoCliError({ + command: "fj", + cwd: input.cwd, + reason: "authentication", + detail: "Multiple fj logins match this repository. Specify its full server URL.", + }); + if (available.failure.reason !== "missing-cli") return yield* available.failure; + } + if (login) { + const auth = yield* authenticateFj(input.cwd, login).pipe(Effect.result); + if (Result.isFailure(auth)) { + if (auth.failure.reason === "missing-cli") login = undefined; + else return yield* auth.failure; + } + } + if (!login) { + command = "tea"; + login = selectLogin(yield* listLogins({ cwd: input.cwd, command: "tea" })); + } + if (!login) + return yield* new ForgejoCliError({ + command: "tea", + cwd: input.cwd, + reason: "authentication", + detail: + "No matching Forgejo login. Use `fj auth login`, `fj auth add-token`, or `tea login add` for this server; choose a default when multiple tea accounts match.", + }); + if (hostOnly) + return { command, login: login.name, repository: "", baseUrl: login.url.replace(/\/+$/, "") }; + const path = + referenceRemote?.path ?? + (input.repository && !parseForgejoRemote(input.repository) + ? input.repository + : remote?.path) ?? + ""; + const basePath = new URL(login.url).pathname.replace(/^\/+|\/+$/g, ""); + const relativePath = + basePath && path.split("/").length > 2 && path.startsWith(`${basePath}/`) + ? path.slice(basePath.length + 1) + : path; + const repositoryPath = relativePath.replace(/\/pulls\/\d+.*$/, "").replace(/\.git$/, ""); + if (command === "fj" && !repositoryPath.includes("/")) { + login = { ...login, user: yield* getAccount({ cwd: input.cwd, baseUrl: login.url }) }; + } + const repository = repositoryPath.includes("/") + ? repositoryPath + : `${login.user}/${repositoryPath}`; + if (!/^[^/\s]+\/[^/\s]+$/.test(repository)) + return yield* new ForgejoCliError({ + command, + cwd: input.cwd, + detail: "Specify a Forgejo repository as owner/repository or its full server URL.", + }); + return { command, login: login.name, repository, baseUrl: login.url.replace(/\/+$/, "") }; + }); + const resolveRepository = (input: ForgejoRepositoryInput) => resolveTarget(input); + const api = Effect.fn("ForgejoCli.api")(function* (input: ForgejoApiInput) { + const repository = yield* resolveTarget( + input, + input.path.replace(/^\/+/, "") === "user" && (!input.method || input.method === "GET"), + ); + const stdin = + input.body === undefined + ? undefined + : yield* encodeApiBody(input.body).pipe( + Effect.mapError( + (cause) => + new ForgejoCliError({ + command: "tea", + cwd: input.cwd, + detail: "Could not encode the Forgejo request body.", + cause, + }), + ), + ); + let path = input.path.replace(/^\/+/, ""); + if (input.repository && input.repository !== repository.repository) { + // Repository identities retain the server mount path; API routes do not. + const prefix = `repos/${input.repository.split("/").map(encodeURIComponent).join("/")}`; + if (path === prefix || path.startsWith(`${prefix}/`) || path.startsWith(`${prefix}?`)) { + path = `repos/${repository.repository.split("/").map(encodeURIComponent).join("/")}${path.slice(prefix.length)}`; + } + } + if (repository.command === "fj") { + const token = (yield* readKeys(input.cwd)).hosts[repository.login]?.token; + if (!token) + return yield* new ForgejoCliError({ + command: "fj", + cwd: input.cwd, + reason: "authentication", + detail: "fj has no credentials for this server.", + }); + return yield* requestFj({ + cwd: input.cwd, + baseUrl: repository.baseUrl, + token, + path, + ...(input.method === undefined ? {} : { method: input.method }), + ...(stdin === undefined ? {} : { body: stdin }), + }); + } + const result = yield* execute({ + cwd: input.cwd, + args: [ + "api", + "--include", + "--login", + repository.login, + ...(repository.repository ? ["--repo", repository.repository] : []), + "--method", + input.method ?? "GET", + ...(input.body === undefined ? [] : ["--data", "@-"]), + `${repository.baseUrl}/api/v1/${path}`, + ], + ...(stdin === undefined ? {} : { stdin }), + }); + // tea reports HTTP failures with exit code zero; use its response status. + const status = Number(/^HTTP\/\S+ (\d{3})/m.exec(result.stderr)?.[1]); + if (!status || status >= 400) + return yield* new ForgejoCliError({ + command: "tea", + cwd: input.cwd, + ...(status ? { httpStatus: status } : {}), + ...(status === 401 + ? { reason: "authentication" as const } + : status === 403 + ? { reason: "forbidden" as const } + : status === 404 + ? { reason: "not-found" as const } + : status === 429 + ? { reason: "rate-limit" as const } + : {}), + detail: + status === 401 || status === 403 + ? "Forgejo denied access. Check this server's `tea login` credentials and permissions." + : status === 404 + ? "Forgejo repository or pull request was not found." + : status === 429 + ? "Forgejo API rate limit exceeded." + : `Forgejo API request failed${status ? ` (HTTP ${status})` : " without an HTTP status"}.`, + }); + return result; + }); + return ForgejoCli.of({ execute, listLogins, getAccount, resolveRepository, api }); +}); + +export const layer = Layer.effect(ForgejoCli, make); diff --git a/apps/server/src/sourceControl/ForgejoSourceControlProvider.ts b/apps/server/src/sourceControl/ForgejoSourceControlProvider.ts new file mode 100644 index 000000000000..5a501d96b235 --- /dev/null +++ b/apps/server/src/sourceControl/ForgejoSourceControlProvider.ts @@ -0,0 +1,387 @@ +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Schema from "effect/Schema"; +import * as Result from "effect/Result"; +import { SourceControlProviderError } from "@t3tools/contracts"; +import * as VcsProcess from "../vcs/VcsProcess.ts"; +import * as ForgejoCli from "./ForgejoCli.ts"; +import * as SourceControlProvider from "./SourceControlProvider.ts"; +import { + providerAuth, + probeSourceControlProvider, + type SourceControlCliDiscoverySpec, + type SourceControlManagedCliDiscoverySpec, +} from "./SourceControlProviderDiscovery.ts"; +import { ForgejoPullRequestSchema, toForgejoChangeRequest } from "./forgejoPullRequests.ts"; + +const isForgejoCliError = Schema.is(ForgejoCli.ForgejoCliError); + +export const discovery = { + type: "cli", + kind: "forgejo", + label: "Forgejo / Gitea", + executable: "tea", + versionArgs: ["--version"], + authArgs: ["login", "status", "--output", "json"], + remoteRefinementArgs: ["login", "list", "--output", "json"], + parseAuth: (input) => { + const logins = ForgejoCli.parseForgejoLogins(input.stdout); + const login = logins.find((entry) => entry.default === "true") ?? logins[0]; + return login + ? providerAuth({ + status: login.valid === "true" ? "authenticated" : "unauthenticated", + account: login.user, + host: ForgejoCli.parseForgejoRemote(login.url)?.host, + }) + : providerAuth({ + status: "unauthenticated", + detail: "Run `tea login add` to authenticate a Forgejo or Gitea server.", + }); + }, + refineUnknownRemote: (input) => { + const remote = ForgejoCli.parseForgejoRemote(input.context.remoteUrl); + const login = + remote && + ForgejoCli.matchForgejoLogin( + ForgejoCli.parseForgejoLogins(input.auth.stdout), + remote, + input.context.requestedHost, + ); + return login ? { kind: "forgejo", name: "Forgejo / Gitea", baseUrl: login.url } : null; + }, + installHint: + "Install `fj` 0.6 or later from https://codeberg.org/forgejo-contrib/forgejo-cli and run `fj --host auth add-token`, or install `tea` 0.16 or later from https://gitea.com/gitea/tea and run `tea login add` for each Forgejo or Gitea server.", +} satisfies SourceControlCliDiscoverySpec; + +export const makeDiscovery = Effect.gen(function* () { + const cli = yield* ForgejoCli.ForgejoCli; + const process = yield* VcsProcess.VcsProcess; + const listLogins = cli.listLogins; + if (!listLogins) return discovery; + return { + type: "managed-cli", + kind: "forgejo", + label: discovery.label, + installHint: discovery.installHint, + probe: Effect.fn("ForgejoSourceControlProvider.discovery")(function* (cwd: string) { + const remoteUrl = yield* process + .run({ + operation: "source-control.discovery.remote", + command: "git", + args: ["remote", "get-url", "origin"], + cwd, + allowNonZeroExit: true, + timeoutMs: 5_000, + maxOutputBytes: 8_000, + }) + .pipe( + Effect.map((result) => result.stdout.trim()), + Effect.orElseSucceed(() => ""), + ); + const credentials = yield* Effect.result(listLogins({ cwd, command: "fj", remoteUrl })); + const logins = Result.isSuccess(credentials) ? credentials.success : []; + const remote = ForgejoCli.parseForgejoRemote(remoteUrl); + const login = + (remote && ForgejoCli.matchForgejoLogin(logins, remote)) || + logins.find((entry) => entry.default === "true") || + logins[0]; + const fj = yield* probeSourceControlProvider({ + cwd, + process, + spec: { + ...discovery, + executable: "fj", + versionArgs: ["version"], + authArgs: login ? ["--host", login.url, "whoami"] : ["auth", "list"], + parseAuth: (result) => + Result.isFailure(credentials) + ? providerAuth({ + status: "unknown", + detail: "Could not read fj authentication storage. Authenticate again with fj.", + }) + : login && result.exitCode === 0 + ? providerAuth({ + status: "authenticated", + account: login.user, + host: ForgejoCli.parseForgejoRemote(login.url)?.host, + }) + : providerAuth({ + status: "unauthenticated", + detail: + "Authenticate this server with `fj --host auth add-token`.", + }), + }, + }); + // A configured fj account owns its requests, including authentication errors. + if (fj.status === "available" && (login || Result.isFailure(credentials))) { + if (login && fj.auth.status === "authenticated" && cli.getAccount) { + const account = yield* cli.getAccount({ cwd, baseUrl: login.url }).pipe(Effect.result); + return { + ...fj, + auth: Result.isSuccess(account) + ? providerAuth({ + status: "authenticated", + account: account.success, + host: ForgejoCli.parseForgejoRemote(login.url)?.host, + }) + : providerAuth({ + status: "unknown", + detail: account.failure.detail, + host: ForgejoCli.parseForgejoRemote(login.url)?.host, + }), + }; + } + return fj; + } + const tea = yield* probeSourceControlProvider({ cwd, process, spec: discovery }); + return tea.status === "available" || fj.status === "missing" ? tea : fj; + }), + refineUnknownRemote: Effect.fn("ForgejoSourceControlProvider.refineUnknownRemote")( + function* (input: { + readonly cwd: string; + readonly context: SourceControlProvider.SourceControlProviderContext; + }) { + const remote = ForgejoCli.parseForgejoRemote(input.context.remoteUrl); + if (!remote) return null; + for (const command of ["fj", "tea"] as const) { + const logins = yield* listLogins({ + cwd: input.cwd, + command, + remoteUrl: input.context.remoteUrl, + }).pipe(Effect.orElseSucceed(() => [])); + const login = ForgejoCli.matchForgejoLogin(logins, remote, input.context.requestedHost); + if (login) return { kind: "forgejo" as const, name: discovery.label, baseUrl: login.url }; + } + return null; + }, + ), + } satisfies SourceControlManagedCliDiscoverySpec; +}); + +const RepositorySchema = Schema.Struct({ + full_name: Schema.String, + clone_url: Schema.String, + ssh_url: Schema.String, + default_branch: Schema.optional(Schema.NullOr(Schema.String)), +}); +const cloneUrls = (raw: typeof RepositorySchema.Type) => ({ + nameWithOwner: raw.full_name, + url: raw.clone_url, + sshUrl: raw.ssh_url, +}); +const repositoryPath = (repository: string) => + `repos/${repository.split("/").map(encodeURIComponent).join("/")}`; + +export const make = Effect.gen(function* () { + const cli = yield* ForgejoCli.ForgejoCli; + const fs = yield* FileSystem.FileSystem; + const process = yield* VcsProcess.VcsProcess; + const request = >( + input: ForgejoCli.ForgejoApiInput, + schema: S, + ) => + cli.api(input).pipe( + Effect.flatMap((result) => + Schema.decodeEffect(Schema.fromJsonString(schema))(result.stdout).pipe( + Effect.mapError( + (cause) => + new ForgejoCli.ForgejoCliError({ + command: "tea", + cwd: input.cwd, + detail: "Forgejo API returned an invalid response.", + reason: "invalid-response", + cause, + }), + ), + ), + ), + ); + const mapError = (operation: string, cwd: string) => + Effect.mapError( + (cause: unknown) => + new SourceControlProviderError({ + provider: "forgejo", + operation, + cwd, + ...(isForgejoCliError(cause) ? { command: cause.command } : {}), + detail: isForgejoCliError(cause) ? cause.detail : "Forgejo operation failed.", + cause, + }), + ); + const getPull = Effect.fn("ForgejoSourceControlProvider.getPull")(function* ( + input: Parameters< + SourceControlProvider.SourceControlProvider["Service"]["getChangeRequest"] + >[0], + ) { + const repo = yield* cli.resolveRepository(input); + const number = /(?:^#?|\/pulls\/)(\d+)(?:\/[^?#]*)?(?:[?#].*)?$/.exec(input.reference)?.[1]; + if (!number) + return yield* new ForgejoCli.ForgejoCliError({ + command: "tea", + cwd: input.cwd, + detail: "Specify a pull request number or Forgejo pull request URL.", + }); + return yield* request( + { ...input, path: `${repositoryPath(repo.repository)}/pulls/${number}` }, + ForgejoPullRequestSchema, + ); + }); + return SourceControlProvider.SourceControlProvider.of({ + kind: "forgejo", + listChangeRequests: (input) => + Effect.gen(function* () { + const repo = yield* cli.resolveRepository(input); + const source = SourceControlProvider.sourceControlRefFromInput(input); + const branch = SourceControlProvider.sourceBranch(input); + const results: ReturnType[] = []; + const limit = input.limit ?? 20; + for (let page = 1; results.length < limit; page++) { + const items = yield* request( + { + ...input, + path: `${repositoryPath(repo.repository)}/pulls?state=${input.state === "merged" ? "closed" : input.state}&sort=recentupdate&limit=50&page=${page}`, + }, + Schema.Array(ForgejoPullRequestSchema), + ); + for (const item of items) { + if ( + item.head.ref !== branch || + (source?.repository && item.head.repo?.full_name !== source.repository) || + (source?.owner && item.head.repo?.owner.login !== source.owner) + ) + continue; + const normalized = toForgejoChangeRequest(item); + if (input.state === "all" || normalized.state === input.state) results.push(normalized); + } + if (items.length === 0) break; + } + return results.slice(0, limit); + }).pipe(mapError("listChangeRequests", input.cwd)), + getChangeRequest: (input) => + getPull(input).pipe( + Effect.map(toForgejoChangeRequest), + mapError("getChangeRequest", input.cwd), + ), + createChangeRequest: (input) => + Effect.gen(function* () { + const repo = yield* cli.resolveRepository(input); + const source = SourceControlProvider.sourceControlRefFromInput(input); + const owner = source?.owner ?? source?.repository?.split("/")[0]; + const head = SourceControlProvider.sourceBranch(input); + yield* cli.api({ + ...input, + path: `${repositoryPath(input.target?.repository ?? repo.repository)}/pulls`, + method: "POST", + body: { + base: input.target?.refName ?? input.baseRefName, + head: owner ? `${owner}:${head}` : head, + title: input.title, + body: yield* fs.readFileString(input.bodyFile), + }, + }); + }).pipe(mapError("createChangeRequest", input.cwd)), + getRepositoryCloneUrls: (input) => + Effect.gen(function* () { + const repo = yield* cli.resolveRepository(input); + return cloneUrls( + yield* request({ ...input, path: repositoryPath(repo.repository) }, RepositorySchema), + ); + }).pipe(mapError("getRepositoryCloneUrls", input.cwd)), + createRepository: (input) => + Effect.gen(function* () { + const repo = yield* cli.resolveRepository(input); + const user = yield* request( + { ...input, path: "user" }, + Schema.Struct({ login: Schema.String }), + ); + const [owner, name] = repo.repository.split("/"); + return cloneUrls( + yield* request( + { + ...input, + path: + owner === user.login + ? "user/repos" + : `orgs/${encodeURIComponent(owner ?? "")}/repos`, + method: "POST", + body: { name, private: input.visibility === "private", auto_init: false }, + }, + RepositorySchema, + ), + ); + }).pipe(mapError("createRepository", input.cwd)), + getDefaultBranch: (input) => + Effect.gen(function* () { + const repo = yield* cli.resolveRepository(input); + return ( + (yield* request({ ...input, path: repositoryPath(repo.repository) }, RepositorySchema)) + .default_branch ?? null + ); + }).pipe(mapError("getDefaultBranch", input.cwd)), + checkoutChangeRequest: (input) => + Effect.gen(function* () { + const repo = yield* cli.resolveRepository(input); + const pull = yield* getPull(input); + if (repo.command === "fj") { + // fj checkout cannot target a repository outside the local remotes. + const urls = yield* request( + { ...input, path: repositoryPath(repo.repository) }, + RepositorySchema, + ); + const remote = input.context?.remoteUrl; + const useSsh = remote && ForgejoCli.parseForgejoRemote(remote)?.ssh; + yield* process.run({ + operation: "ForgejoSourceControlProvider.checkoutChangeRequest", + command: "git", + cwd: input.cwd, + args: [ + "fetch", + "--", + useSsh ? urls.ssh_url : urls.clone_url, + `refs/pull/${pull.number}/head`, + ], + }); + const branch = `pulls/${pull.number}`; + const existing = yield* process.run({ + operation: "ForgejoSourceControlProvider.checkoutChangeRequest", + command: "git", + cwd: input.cwd, + args: ["show-ref", "--verify", "--quiet", `refs/heads/${branch}`], + allowNonZeroExit: true, + }); + yield* process.run({ + operation: "ForgejoSourceControlProvider.checkoutChangeRequest", + command: "git", + cwd: input.cwd, + args: + existing.exitCode === 0 + ? ["checkout", branch] + : ["checkout", "-b", branch, "FETCH_HEAD"], + }); + } else + yield* cli.execute({ + cwd: input.cwd, + args: [ + "pulls", + "checkout", + "--login", + repo.login, + "--repo", + repo.repository, + "--branch", + String(pull.number), + ], + }); + if (input.force) { + // tea leaves an existing PR branch at its old tip. Keep dirty files safe + // while bringing the selected branch to the PR revision we fetched. + yield* process.run({ + operation: "ForgejoSourceControlProvider.checkoutChangeRequest", + command: "git", + cwd: input.cwd, + args: ["reset", "--keep", pull.head.sha], + }); + } + }).pipe(mapError("checkoutChangeRequest", input.cwd)), + }); +}); diff --git a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts index 9e4702af04cd..30aa22995b95 100644 --- a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts +++ b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts @@ -1,9 +1,14 @@ import { assert, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as Effect from "effect/Effect"; +import type * as Context from "effect/Context"; +import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; import { ChildProcessSpawner } from "effect/unstable/process"; +import { FetchHttpClient, HttpClient, HttpClientResponse } from "effect/unstable/http"; import { VcsProcessSpawnError } from "@t3tools/contracts"; import * as ServerConfig from "../config.ts"; @@ -13,8 +18,12 @@ import * as AzureDevOpsCli from "./AzureDevOpsCli.ts"; import * as BitbucketApi from "./BitbucketApi.ts"; import * as GitHubCli from "./GitHubCli.ts"; import * as GitLabCli from "./GitLabCli.ts"; +import * as ForgejoCli from "./ForgejoCli.ts"; +import * as ForgejoSourceControlProvider from "./ForgejoSourceControlProvider.ts"; +import * as ForgejoPullRequestProvider from "../pullRequest/ForgejoPullRequestProvider.ts"; import * as SourceControlDiscovery from "./SourceControlDiscovery.ts"; import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts"; +import { firstNonEmptyLine } from "./SourceControlProviderDiscovery.ts"; const sourceControlProviderRegistryTestLayer = (input: { readonly bitbucket: Partial; @@ -30,6 +39,7 @@ const sourceControlProviderRegistryTestLayer = (input: { Layer.mock(BitbucketApi.BitbucketApi)(input.bitbucket), Layer.mock(GitHubCli.GitHubCli)({}), Layer.mock(GitLabCli.GitLabCli)({}), + Layer.mock(ForgejoCli.ForgejoCli)({ listLogins: () => Effect.succeed([]) }), Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({}), Layer.mock(VcsProcess.VcsProcess)(input.process), ), @@ -50,6 +60,314 @@ const processOutput = ( stderrTruncated: false, }); +const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); +const encodeJsonEffect = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); + +it.effect("submits a Forgejo review without sending its summary in the preliminary GET", () => { + const methods: string[] = []; + const fetchReview = async ( + ...[input, init]: Parameters> + ) => { + const request = new Request(input instanceof Request ? input.url : String(input), { + ...(init?.method === undefined ? {} : { method: init.method }), + ...(init?.headers === undefined ? {} : { headers: init.headers }), + ...(init?.body === undefined ? {} : { body: init.body }), + }); + methods.push(request.method); + if (request.method === "GET") { + assert.strictEqual(request.url, "https://forgejo.test/api/v1/repos/maria/project/pulls/42"); + return new Response( + encodeJson({ + number: 42, + title: "Review target", + body: "", + html_url: "https://forgejo.test/maria/project/pulls/42", + user: { login: "maria" }, + state: "open", + merged: false, + head: { ref: "feature", sha: "head", repo: null }, + base: { ref: "main", sha: "base", repo: null }, + created_at: "2026-09-13T00:00:00Z", + updated_at: "2026-09-13T00:00:00Z", + closed_at: null, + merged_at: null, + labels: [], + }), + ); + } + assert.strictEqual(request.method, "POST"); + assert.strictEqual( + request.url, + "https://forgejo.test/api/v1/repos/maria/project/pulls/42/reviews", + ); + assert.deepStrictEqual(JSON.parse(await request.text()), { + event: "COMMENT", + body: "Review summary", + commit_id: "head", + comments: [], + }); + return new Response('{"id":1}', { status: 200 }); + }; + return Effect.gen(function* () { + const cli = yield* ForgejoCli.make; + const provider = yield* ForgejoPullRequestProvider.make.pipe( + Effect.provideService(ForgejoCli.ForgejoCli, cli), + ); + yield* provider.submitReview({ + cwd: "/repo", + repository: "maria/project", + host: "forgejo.test", + number: 42, + verdict: "comment", + body: "Review summary", + comments: [], + }); + assert.deepStrictEqual(methods, ["GET", "POST"]); + }).pipe( + Effect.provideService( + FetchHttpClient.Fetch, + Object.assign(fetchReview, { preconnect: () => undefined }), + ), + Effect.provide(FetchHttpClient.layer), + Effect.provideService( + FileSystem.FileSystem, + FileSystem.makeNoop({ + exists: () => Effect.succeed(true), + readFileString: () => + Effect.succeed( + encodeJson({ hosts: { "forgejo.test": { type: "Application", token: "test-token" } } }), + ), + }), + ), + Effect.provide( + Layer.mock(VcsProcess.VcsProcess)({ + run: (input) => { + if (input.command === "git") { + assert.deepStrictEqual(input.args, ["remote", "-v"]); + return Effect.succeed( + processOutput("origin\thttps://forgejo.test/maria/project.git (fetch)"), + ); + } + assert.strictEqual(input.command, "fj"); + assert.deepStrictEqual(input.args, ["--host", "https://forgejo.test", "whoami"]); + return Effect.succeed(processOutput("")); + }, + }), + ), + ); +}); + +it.effect("loads Forgejo pull request references from files and commits views", () => + Effect.gen(function* () { + const provider = yield* ForgejoSourceControlProvider.make; + for (const reference of [ + "42", + "#42", + "https://forgejo.test/maria/project/pulls/42", + "https://forgejo.test/maria/project/pulls/42/", + "https://forgejo.test/maria/project/pulls/42/files?w=1#diff-1", + "http://forgejo.test:3000/git/maria/project/pulls/42/commits", + ]) { + const result = yield* provider.getChangeRequest({ cwd: "/repo", reference }); + assert.strictEqual(result.number, 42); + assert.strictEqual(result.title, "Forgejo view reference"); + } + const invalid = yield* provider + .getChangeRequest({ + cwd: "/repo", + reference: "https://forgejo.test/maria/project/pulls/42invalid/files", + }) + .pipe(Effect.result); + assert.strictEqual(invalid._tag, "Failure"); + }).pipe( + Effect.provide( + Layer.mergeAll( + Layer.succeed(FileSystem.FileSystem, FileSystem.makeNoop({})), + Layer.mock(VcsProcess.VcsProcess)({}), + Layer.mock(ForgejoCli.ForgejoCli)({ + resolveRepository: () => + Effect.succeed({ + login: "work", + repository: "maria/project", + baseUrl: "https://forgejo.test", + }), + api: (input) => { + assert.strictEqual(input.path, "repos/maria/project/pulls/42"); + return encodeJsonEffect({ + number: 42, + title: "Forgejo view reference", + html_url: "https://forgejo.test/maria/project/pulls/42", + state: "open", + merged: false, + base: { ref: "main", sha: "base", repo: null }, + head: { ref: "feature", sha: "head", repo: null }, + }).pipe(Effect.orDie, Effect.map(processOutput)); + }, + }), + ), + ), + ), +); + +it.effect( + "loads Forgejo reactions on comments, reviews and inline threads and resolves review mutations", + () => { + const user = { login: "maria" }; + const review = { + id: 8, + body: "review body", + user, + state: "COMMENT", + submitted_at: "2026-09-12T00:00:00Z", + html_url: "https://forgejo.test/maria/project/pulls/2#issuecomment-37", + comments_count: 1, + }; + const comment = { id: 12, body: "ordinary", user, created_at: review.submitted_at }; + const responses: Record = { + user, + "repos/maria/project/issues/2/comments": [comment], + "repos/maria/project/pulls/2/reviews": [review], + "repos/maria/project/pulls/2/reviews/8": review, + "repos/maria/project/pulls/2/reviews/9": { ...review, id: 9, html_url: "" }, + "repos/maria/project/pulls/2/commits": [], + "repos/maria/project/issues/2/reactions": [], + "repos/maria/project/pulls/2/reviews/8/comments": [ + { + ...comment, + id: 38, + body: "inline", + path: "file.ts", + position: 1, + original_position: 1, + commit_id: "head", + original_commit_id: "head", + resolver: null, + }, + ], + "repos/maria/project/issues/comments/12/reactions": [{ content: "+1", user }], + "repos/maria/project/issues/comments/37/reactions": [{ content: "heart", user }], + "repos/maria/project/issues/comments/38/reactions": [ + { content: "rocket", user: { login: "reviewer" } }, + ], + }; + const writes: ForgejoCli.ForgejoApiInput[] = []; + let reactionReads = 0; + return Effect.gen(function* () { + const provider = yield* ForgejoPullRequestProvider.make; + const input = { cwd: "/repo", repository: "maria/project", host: "forgejo.test", number: 2 }; + const activity = yield* provider.getChangeRequestActivity(input); + assert.deepStrictEqual( + activity.comments.map((entry) => ({ + id: entry.id, + kind: entry.kind, + reactions: entry.reactions, + })), + [ + { + id: "12", + kind: "issue-comment", + reactions: [{ content: "thumbs-up", count: 1, actors: [], viewerHasReacted: true }], + }, + { + id: "review:8", + kind: "review", + reactions: [{ content: "heart", count: 1, actors: [], viewerHasReacted: true }], + }, + { + id: "38", + kind: "review-comment", + reactions: [ + { content: "rocket", count: 1, actors: ["reviewer"], viewerHasReacted: false }, + ], + }, + ], + ); + const inlineComment = activity.comments[2]; + assert.ok(inlineComment); + assert.deepStrictEqual(activity.reviewThreads[0]?.comments, [inlineComment]); + for (const reacted of [true, false]) { + yield* provider.setReaction({ ...input, subjectId: "review:8", content: "heart", reacted }); + yield* provider.setReaction({ ...input, subjectId: "38", content: "rocket", reacted }); + } + assert.deepStrictEqual( + writes.map(({ path, method, body }) => ({ path, method, body })), + [ + { + path: "repos/maria/project/issues/comments/37/reactions", + method: "POST", + body: { content: "heart" }, + }, + { + path: "repos/maria/project/issues/comments/38/reactions", + method: "POST", + body: { content: "rocket" }, + }, + { + path: "repos/maria/project/issues/comments/37/reactions", + method: "DELETE", + body: { content: "heart" }, + }, + { + path: "repos/maria/project/issues/comments/38/reactions", + method: "DELETE", + body: { content: "rocket" }, + }, + ], + ); + const missing = yield* provider + .setReaction({ ...input, subjectId: "review:9", content: "heart", reacted: true }) + .pipe(Effect.result); + assert.strictEqual(missing._tag, "Failure"); + if (missing._tag === "Failure") assert.include(missing.failure.detail, "comment ID"); + assert.strictEqual(writes.length, 4); + responses["repos/maria/project/pulls/2/reviews/8/comments"] = Array.from( + { length: 501 }, + (_, index) => ({ + ...comment, + id: 1000 + index, + path: "file.ts", + position: 1, + original_position: 1, + commit_id: "head", + original_commit_id: "head", + resolver: null, + }), + ); + for (let index = 0; index < 500; index++) { + responses[`repos/maria/project/issues/comments/${1000 + index}/reactions`] = []; + } + reactionReads = 0; + const bounded = yield* provider.getChangeRequestActivity(input); + assert.strictEqual(bounded.reviewThreads.length, 500); + assert.strictEqual( + bounded.comments.filter((entry) => entry.kind === "review-comment").length, + 500, + ); + assert.strictEqual(bounded.commentsTruncated, true); + assert.strictEqual(reactionReads, 502); + }).pipe( + Effect.provide( + Layer.mock(ForgejoCli.ForgejoCli)({ + api: (input) => { + if (input.method) { + writes.push(input); + return Effect.succeed(processOutput("{}")); + } + const path = input.path.split("?")[0]!; + if (/\/issues\/comments\/\d+\/reactions$/.test(path)) reactionReads++; + assert.ok(Object.hasOwn(responses, path), `Unexpected Forgejo request: ${path}`); + const page = Number(new URLSearchParams(input.path.split("?")[1]).get("page")); + return encodeJsonEffect(page > 1 ? [] : responses[path]).pipe( + Effect.orDie, + Effect.map(processOutput), + ); + }, + }), + ), + ); + }, +); + it.effect("reports implemented tools separately from locally available executables", () => { const processMock = { run: (input: VcsProcess.VcsProcessInput) => { @@ -62,7 +380,7 @@ it.effect("reports implemented tools separately from locally available executabl if (input.command === "gh" && input.args.join(" ") === "auth status --json hosts") { return Effect.succeed( processOutput( - JSON.stringify({ + encodeJson({ hosts: { "github.com": [ { @@ -161,6 +479,12 @@ it.effect("reports implemented tools separately from locally available executabl auth: "unauthenticated", account: Option.none(), }, + { + kind: "forgejo", + status: "missing", + auth: "unknown", + account: Option.none(), + }, ], ); const bitbucket = result.sourceControlProviders.find((item) => item.kind === "bitbucket"); @@ -178,7 +502,7 @@ it.effect("probes provider authentication without exposing token details", () => if (input.command === "gh" && input.args.join(" ") === "auth status --json hosts") { return Effect.succeed( processOutput( - JSON.stringify({ + encodeJson({ hosts: { "github.com": [ { @@ -202,6 +526,22 @@ Logged in to gitlab.com as gitlab-user `), ); } + if (input.command === "tea" && input.args[0] === "login") { + return Effect.succeed( + processOutput( + encodeJson([ + { + name: "forgejo", + url: "https://forgejo.example.com", + ssh_host: "forgejo.example.com", + user: "forgejo-user", + valid: "true", + default: "true", + }, + ]), + ), + ); + } if ( input.command === "az" && input.args.join(" ") === "account show --query user.name -o tsv" @@ -277,7 +617,1012 @@ Logged in to gitlab.com as gitlab-user account: Option.some("bitbucket-user"), detail: Option.none(), }, + { + kind: "forgejo", + auth: "authenticated", + account: Option.some("forgejo-user"), + detail: Option.none(), + }, ], ); }).pipe(Effect.provide(testLayer)); }); + +it.effect("discovers Forgejo accounts and retains the server port", () => + Effect.gen(function* () { + const auth = ForgejoSourceControlProvider.discovery.parseAuth( + processOutput( + yield* encodeJsonEffect([ + { + name: "work", + url: "http://forgejo.local:3000", + ssh_host: "git.forgejo.local", + user: "maria", + default: "true", + valid: "true", + }, + ]), + ), + ); + assert.deepStrictEqual( + firstNonEmptyLine("\u001b[1mtea version 0.16.0\u001b[0m\n"), + Option.some("tea version 0.16.0"), + ); + assert.strictEqual(auth.status, "authenticated"); + assert.deepStrictEqual(auth.account, Option.some("maria")); + assert.deepStrictEqual(auth.host, Option.some("forgejo.local:3000")); + const revoked = ForgejoSourceControlProvider.discovery.parseAuth( + processOutput( + encodeJson([ + { + name: "work", + url: "http://forgejo.local:3000", + user: "maria", + default: "true", + valid: "false", + }, + ]), + ), + ); + assert.strictEqual(revoked.status, "unauthenticated"); + const refined = ForgejoSourceControlProvider.discovery.refineUnknownRemote({ + cwd: "/repo", + context: { + provider: { + kind: "unknown", + name: "git.forgejo.local", + baseUrl: "https://git.forgejo.local", + }, + remoteName: "origin", + remoteUrl: "git@git.forgejo.local:maria/project.git", + }, + auth: processOutput( + yield* encodeJsonEffect([ + { + name: "work", + url: "http://forgejo.local:3000", + ssh_host: "git.forgejo.local", + user: "maria", + default: "true", + }, + ]), + ), + }); + assert.deepStrictEqual(refined, { + kind: "forgejo", + name: "Forgejo / Gitea", + baseUrl: "http://forgejo.local:3000", + }); + }), +); + +it.effect("does not choose a default Forgejo login across ambiguous SSH server ports", () => + Effect.gen(function* () { + const logins = ForgejoCli.parseForgejoLogins( + yield* encodeJsonEffect([ + { + name: "one", + url: "http://forgejo.local:3000", + ssh_host: "forgejo.local", + user: "maria", + default: "true", + }, + { + name: "two", + url: "http://forgejo.local:4000", + ssh_host: "forgejo.local", + user: "maria", + default: "false", + }, + ]), + ); + const remote = ForgejoCli.parseForgejoRemote("git@forgejo.local:maria/project.git"); + assert.isNotNull(remote); + assert.deepStrictEqual( + ForgejoCli.parseForgejoRemote("forgejo.local:maria/project.git"), + remote, + ); + assert.isUndefined(ForgejoCli.matchForgejoLogin(logins, remote!)); + assert.strictEqual( + ForgejoCli.matchForgejoLogin(logins, remote!, "forgejo.local:4000")?.name, + "two", + ); + assert.isUndefined(ForgejoCli.matchForgejoLogin(logins, remote!, "other.local:4000")); + const alias = ForgejoCli.parseForgejoRemote("git@ssh.forgejo.local:maria/project.git"); + assert.isNotNull(alias); + assert.isUndefined(ForgejoCli.matchForgejoLogin(logins, alias!, "forgejo.local:4000")); + const refined = ForgejoSourceControlProvider.discovery.refineUnknownRemote({ + cwd: "/repo", + context: { + provider: { kind: "unknown", name: "Forgejo", baseUrl: "https://forgejo.local" }, + remoteName: "origin", + remoteUrl: "git@forgejo.local:maria/project.git", + requestedHost: "forgejo.local:4000", + }, + auth: processOutput(yield* encodeJsonEffect(logins)), + }); + assert.strictEqual(refined?.baseUrl, "http://forgejo.local:4000"); + const https = ForgejoCli.parseForgejoRemote("http://forgejo.local:4000/maria/project.git"); + assert.isNotNull(https); + assert.strictEqual(ForgejoCli.matchForgejoLogin(logins, https!)?.name, "two"); + const hostOnly = ForgejoCli.parseForgejoRemote("http://forgejo.local:4000"); + assert.strictEqual( + ForgejoCli.matchForgejoLogin(logins, hostOnly!, undefined, true)?.name, + "two", + ); + const mounted = logins.map((login) => ({ + ...login, + url: `http://forgejo.local:4000/${login.name}`, + })); + assert.isUndefined(ForgejoCli.matchForgejoLogin(mounted, hostOnly!, undefined, true)); + }), +); + +it.effect("rejects HTTP failures even when tea exits successfully", () => + Effect.gen(function* () { + const cli = yield* ForgejoCli.make; + const result = yield* cli + .api({ + cwd: "/repo", + repository: "http://forgejo.local:3000/maria/project", + path: "repos/maria/project/pulls/42", + method: "PATCH", + body: { state: "closed" }, + }) + .pipe(Effect.result); + assert.strictEqual(result._tag, "Failure"); + if (result._tag === "Failure") + assert.strictEqual( + result.failure.detail, + "Forgejo repository or pull request was not found.", + ); + }).pipe( + Effect.provideService( + FileSystem.FileSystem, + FileSystem.makeNoop({ + exists: () => Effect.succeed(false), + }), + ), + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make(() => { + throw new Error("tea must handle its own HTTP request"); + }), + ), + Effect.provide( + Layer.mock(VcsProcess.VcsProcess)({ + run: (input) => { + if (input.args[0] === "api") { + assert.strictEqual(input.stdin, '{"state":"closed"}'); + assert.include(input.args, "work"); + assert.include( + input.args, + "http://forgejo.local:3000/api/v1/repos/maria/project/pulls/42", + ); + } + return Effect.succeed( + input.args[0] === "login" + ? processOutput( + encodeJson([ + { + name: "work", + url: "http://forgejo.local:3000", + ssh_host: "forgejo.local", + user: "maria", + default: "true", + }, + ]), + ) + : processOutput('{"message":"not found"}', { stderr: "HTTP/1.1 404 Not Found\n" }), + ); + }, + }), + ), + ), +); + +it.effect("routes mounted Forgejo repositories without repeating the mount in API paths", () => + Effect.gen(function* () { + const cli = yield* ForgejoCli.make; + const viewer = yield* cli.api({ cwd: "/upstream-only", host: "code.test", path: "user" }); + assert.strictEqual(viewer.stdout, "[]"); + const mountedRepository = yield* cli.resolveRepository({ + cwd: "/upstream-only", + host: "code.test", + repository: "maria/project", + }); + assert.strictEqual(mountedRepository.baseUrl, "https://code.test/forgejo"); + assert.strictEqual(mountedRepository.repository, "maria/project"); + for (const path of [ + "repos/forgejo/maria/project/pulls?state=open", + "repos/forgejo/maria/project", + "repos/reviewer/project/contents/file.ts", + ]) { + const result = yield* cli.api({ + cwd: "/repo", + repository: "forgejo/maria/project", + context: { + provider: { kind: "forgejo", name: "Forgejo", baseUrl: "https://code.test/forgejo" }, + remoteName: "origin", + remoteUrl: "https://code.test/forgejo/maria/project.git", + }, + path, + }); + assert.strictEqual(result.stdout, "[]"); + } + const sameOwnerAsMount = yield* cli.resolveRepository({ + cwd: "/repo", + repository: "forgejo/project", + context: { + provider: { kind: "forgejo", name: "Forgejo", baseUrl: "https://code.test/forgejo" }, + remoteName: "origin", + remoteUrl: "ssh://git@code.test/forgejo/project.git", + }, + }); + assert.strictEqual(sameOwnerAsMount.command, "tea"); + assert.strictEqual(sameOwnerAsMount.repository, "forgejo/project"); + }).pipe( + Effect.provideService( + FileSystem.FileSystem, + FileSystem.makeNoop({ + exists: () => Effect.succeed(true), + readFileString: () => + Effect.succeed( + encodeJson({ + hosts: { "code.test/forgejo": { type: "Application", token: "test-token" } }, + }), + ), + }), + ), + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make(() => { + throw new Error("tea must handle its own HTTP request"); + }), + ), + Effect.provide( + Layer.mock(VcsProcess.VcsProcess)({ + run: (input) => { + if (input.command === "git") + return Effect.succeed(processOutput("", { exitCode: ChildProcessSpawner.ExitCode(2) })); + if (input.args[0] === "login") + return Effect.succeed( + processOutput( + encodeJson([ + { + name: "mounted", + url: "https://code.test/forgejo", + ssh_host: "code.test", + user: "maria", + default: "true", + }, + ]), + ), + ); + const supported = [ + "https://code.test/forgejo/api/v1/user", + "https://code.test/forgejo/api/v1/repos/maria/project/pulls?state=open", + "https://code.test/forgejo/api/v1/repos/maria/project", + "https://code.test/forgejo/api/v1/repos/reviewer/project/contents/file.ts", + ]; + if (input.args.at(-1)?.endsWith("/user")) assert.notInclude(input.args, "--repo"); + assert.strictEqual(input.command, "tea"); + return Effect.succeed( + supported.includes(input.args.at(-1) ?? "") + ? processOutput("[]", { stderr: "HTTP/1.1 200 OK\n" }) + : processOutput("{}", { stderr: "HTTP/1.1 404 Not Found\n" }), + ); + }, + }), + ), + ), +); + +it.effect("prefers fj for HTTP and ported SSH aliases on root servers", () => { + const commands: string[] = []; + const requests: string[] = []; + return Effect.gen(function* () { + const cli = yield* ForgejoCli.make; + for (const remoteUrl of [ + "http://forgejo.local:3000/maria/project.git", + "ssh://git@ssh.forgejo.local:2222/maria/project.git", + "ssh://git@forgejo.local:2222/maria/project.git", + ]) { + const result = yield* cli.api({ + cwd: "/repo", + repository: "maria/project", + context: { + provider: { + kind: "forgejo", + name: "Forgejo", + baseUrl: "http://forgejo.local:3000", + }, + remoteName: "origin", + remoteUrl, + requestedHost: "forgejo.local:3000", + }, + path: "repos/maria/project/issues/42/comments", + method: "POST", + body: { body: "verified through fj" }, + }); + assert.strictEqual(result.stdout, '{"id":99}'); + } + assert.deepStrictEqual(commands, ["fj"]); + assert.deepStrictEqual(requests, [ + "http://forgejo.local:3000/api/v1/repos/maria/project/issues/42/comments", + "http://forgejo.local:3000/api/v1/repos/maria/project/issues/42/comments", + "http://forgejo.local:3000/api/v1/repos/maria/project/issues/42/comments", + ]); + const viewer = yield* cli.api({ + cwd: "/no-remotes", + host: "forgejo.local:3000", + path: "user", + }); + assert.strictEqual(viewer.stdout, '{"login":"maria"}'); + assert.strictEqual(requests.at(-1), "https://forgejo.local:3000/api/v1/user"); + const upstreamViewer = yield* cli.api({ + cwd: "/upstream-only", + host: "forgejo.local:3000", + path: "user", + }); + assert.strictEqual(upstreamViewer.stdout, '{"login":"maria"}'); + assert.strictEqual(requests.at(-1), "http://forgejo.local:3000/api/v1/user"); + const upstreamRepository = yield* cli.resolveRepository({ + cwd: "/upstream-only", + host: "forgejo.local:3000", + repository: "maria/project", + }); + assert.strictEqual(upstreamRepository.baseUrl, "http://forgejo.local:3000"); + assert.strictEqual(upstreamRepository.repository, "maria/project"); + const httpViewer = yield* cli.api({ cwd: "/repo", host: "forgejo.local:3000", path: "user" }); + assert.strictEqual(httpViewer.stdout, '{"login":"maria"}'); + assert.strictEqual(requests.at(-1), "http://forgejo.local:3000/api/v1/user"); + }).pipe( + Effect.provideService( + FileSystem.FileSystem, + FileSystem.makeNoop({ + exists: () => Effect.succeed(true), + readFileString: () => + Effect.succeed( + encodeJson({ + hosts: { + "forgejo.local:3000": { type: "Application", token: "test-token" }, + "forgejo.local:4000": { type: "Application", token: "other-token" }, + }, + aliases: { "ssh.forgejo.local:2222": "forgejo.local:3000" }, + }), + ), + }), + ), + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make((request) => { + requests.push(request.url); + if (request.url.endsWith("/user")) { + assert.strictEqual(request.method, "GET"); + assert.strictEqual(request.headers.authorization, "token test-token"); + return Effect.succeed( + HttpClientResponse.fromWeb(request, new Response('{"login":"maria"}')), + ); + } + assert.strictEqual(request.method, "POST"); + assert.strictEqual(request.headers.authorization, "token test-token"); + assert.strictEqual(request.body._tag, "Uint8Array"); + if (request.body._tag === "Uint8Array") + assert.deepStrictEqual(JSON.parse(new TextDecoder().decode(request.body.body)), { + body: "verified through fj", + }); + return Effect.succeed( + HttpClientResponse.fromWeb(request, new Response('{"id":99}', { status: 201 })), + ); + }), + ), + Effect.provide( + Layer.mock(VcsProcess.VcsProcess)({ + run: (input) => { + commands.push(input.command); + if (input.command === "git") + return Effect.succeed( + input.cwd === "/no-remotes" + ? processOutput("", { exitCode: ChildProcessSpawner.ExitCode(2) }) + : processOutput( + `${input.cwd === "/upstream-only" ? "upstream" : "origin"}\thttp://forgejo.local:3000/maria/project.git (fetch)\nother\thttp://forgejo.local:3000/maria/other.git (fetch)\nunrelated\thttp://other.local:3000/maria/project.git (fetch)`, + ), + ); + assert.strictEqual(input.command, "fj"); + assert.deepStrictEqual(input.args, [ + "--host", + input.cwd === "/no-remotes" + ? "https://forgejo.local:3000" + : "http://forgejo.local:3000", + "whoami", + ]); + return Effect.succeed(processOutput("")); + }, + }), + ), + ); +}); + +it.effect("loads later fj review pages when the server caps pages below the requested size", () => { + const pages: number[] = []; + let issueCommentRequests = 0; + return Effect.gen(function* () { + const cli = yield* ForgejoCli.make; + const provider = yield* ForgejoPullRequestProvider.make.pipe( + Effect.provideService(ForgejoCli.ForgejoCli, cli), + ); + const activity = yield* provider.getChangeRequestActivity({ + cwd: "/repo", + repository: "maria/project", + host: "forgejo.test", + number: 42, + }); + assert.strictEqual(activity.commentCount, 42); + assert.strictEqual(activity.comments.at(-1)?.id, "review:41"); + assert.strictEqual(activity.commentsTruncated, false); + assert.deepStrictEqual(pages, [1, 2, 3]); + assert.strictEqual(issueCommentRequests, 1); + }).pipe( + Effect.provideService( + FileSystem.FileSystem, + FileSystem.makeNoop({ + exists: () => Effect.succeed(true), + readFileString: () => + Effect.succeed( + encodeJson({ hosts: { "forgejo.test": { type: "Application", token: "test-token" } } }), + ), + }), + ), + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make((request) => { + const url = new URL(request.url); + if (url.pathname === "/api/v1/repos/maria/project/issues/42/comments") { + issueCommentRequests++; + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response( + encodeJson([ + { + id: 100, + body: "Unpaginated issue comment", + user: { login: "maria" }, + created_at: "2026-09-13T00:00:00Z", + }, + ]), + ), + ), + ); + } + if (url.pathname.endsWith("/reviews")) { + const page = Number(url.searchParams.get("page")); + pages.push(page); + assert.ok(page >= 1 && page <= 3); + const reviews = Array.from({ length: page < 3 ? 20 : 1 }, (_, index) => ({ + id: (page - 1) * 20 + index + 1, + body: "Review from a capped page", + user: { login: "maria" }, + state: "COMMENT", + submitted_at: "2026-09-13T00:00:00Z", + comments_count: 0, + })); + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response(encodeJson(reviews), { + headers: + page === 2 + ? {} + : { + Link: + page === 1 + ? `<${url.origin}${url.pathname}?limit=50&page=2>; rel="next"` + : `<${url.origin}${url.pathname}?limit=50&page=1>; rel="prev"`, + }, + }), + ), + ); + } + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response(encodeJson(url.pathname === "/api/v1/user" ? { login: "maria" } : [])), + ), + ); + }), + ), + Effect.provide( + Layer.mock(VcsProcess.VcsProcess)({ + run: (input) => { + if (input.command === "git") { + assert.deepStrictEqual(input.args, ["remote", "-v"]); + return Effect.succeed( + processOutput("origin\thttps://forgejo.test/maria/project.git (fetch)"), + ); + } + assert.strictEqual(input.command, "fj"); + assert.deepStrictEqual(input.args, ["--host", "https://forgejo.test", "whoami"]); + return Effect.succeed(processOutput("")); + }, + }), + ), + ); +}); + +it.effect("falls back to tea when fj is missing or has no account for this server", () => + Effect.gen(function* () { + for (const scenario of ["missing-cli", "missing-account", "stale-invalid-storage"] as const) { + const commands: string[] = []; + yield* Effect.gen(function* () { + const cli = yield* ForgejoCli.make; + const result = yield* cli.api({ + cwd: "/repo", + repository: "https://forgejo.local:3000/maria/project", + path: "repos/maria/project/pulls", + }); + assert.strictEqual(result.stdout, "[]"); + assert.deepStrictEqual( + commands, + scenario === "missing-account" ? ["tea", "tea"] : ["fj", "tea", "tea"], + ); + const viewer = yield* cli.api({ + cwd: "/upstream-only", + host: "forgejo.local:3000", + path: "user", + }); + assert.strictEqual(viewer.stdout, "[]"); + }).pipe( + Effect.provideService( + FileSystem.FileSystem, + FileSystem.makeNoop({ + exists: () => Effect.succeed(true), + readFileString: () => + Effect.succeed( + scenario === "stale-invalid-storage" + ? "invalid json" + : encodeJson({ + hosts: { + [scenario === "missing-cli" ? "forgejo.local:3000" : "other.local"]: { + type: "Application", + token: "test-token", + }, + }, + }), + ), + }), + ), + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make(() => { + throw new Error("tea must handle its own HTTP request"); + }), + ), + Effect.provide( + Layer.mock(VcsProcess.VcsProcess)({ + run: (input) => { + commands.push(input.command); + if (input.command === "git") + return Effect.succeed( + processOutput("", { exitCode: ChildProcessSpawner.ExitCode(2) }), + ); + if (input.command === "fj") + return Effect.fail( + new VcsProcessSpawnError({ + operation: input.operation, + command: input.command, + cwd: input.cwd, + cause: new Error("fj not found"), + }), + ); + assert.strictEqual(input.command, "tea"); + if (input.args.at(-1)?.endsWith("/user")) assert.notInclude(input.args, "--repo"); + return Effect.succeed( + input.args[0] === "login" + ? processOutput( + encodeJson([ + { + name: "work", + url: "https://forgejo.local:3000", + user: "maria", + default: "true", + valid: "true", + }, + ]), + ) + : processOutput("[]", { stderr: "HTTP/1.1 200 OK\n" }), + ); + }, + }), + ), + ); + } + }), +); + +it.effect("handles fj mutation statuses without retrying failures or reading absent bodies", () => + Effect.gen(function* () { + for (const status of [204, 205, 302, 401, 403, 404, 429, 500]) { + let writes = 0; + yield* Effect.gen(function* () { + const cli = yield* ForgejoCli.make; + const result = yield* cli + .api({ + cwd: "/repo", + repository: "https://forgejo.local/maria/project", + path: "repos/maria/project/issues/42/comments", + method: "POST", + body: { body: "only once" }, + }) + .pipe(Effect.result); + assert.strictEqual(result._tag, status < 300 ? "Success" : "Failure"); + if (result._tag === "Success") assert.strictEqual(result.success.stdout, ""); + if (result._tag === "Failure") { + assert.strictEqual(result.failure.command, "fj"); + assert.strictEqual(result.failure.httpStatus, status); + } + assert.strictEqual(writes, 1); + }).pipe( + Effect.provideService( + FileSystem.FileSystem, + FileSystem.makeNoop({ + exists: () => Effect.succeed(true), + readFileString: () => + Effect.succeed( + encodeJson({ + hosts: { + "forgejo.local": { type: "Application", token: "test-token" }, + }, + }), + ), + }), + ), + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make((request) => { + writes++; + assert.strictEqual( + request.url, + "https://forgejo.local/api/v1/repos/maria/project/issues/42/comments", + ); + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response(status < 300 ? null : "", { + status, + headers: { location: "https://other.local/" }, + }), + ), + ); + }), + ), + Effect.provide( + Layer.mock(VcsProcess.VcsProcess)({ + run: (input) => { + assert.strictEqual( + input.command, + "fj", + "a failed mutation must never switch accounts or CLI", + ); + return Effect.succeed(processOutput("")); + }, + }), + ), + ); + } + }), +); + +it.effect( + "discovers fj first and retains configured authentication failures instead of switching accounts", + () => + Effect.gen(function* () { + for (const scenario of ["authenticated", "revoked", "missing", "invalid-storage"] as const) { + const commands: string[] = []; + yield* Effect.gen(function* () { + const spec = yield* ForgejoSourceControlProvider.makeDiscovery; + assert.strictEqual(spec.type, "managed-cli"); + if (spec.type !== "managed-cli") return; + const result = yield* spec.probe("/repo"); + assert.strictEqual(result.executable, scenario === "missing" ? "tea" : "fj"); + assert.strictEqual( + result.auth.status, + scenario === "revoked" + ? "unauthenticated" + : scenario === "invalid-storage" + ? "unknown" + : "authenticated", + ); + if (scenario !== "revoked" && scenario !== "invalid-storage") + assert.deepStrictEqual(result.auth.host, Option.some("forgejo.local:3000")); + assert.deepStrictEqual( + result.auth.account, + scenario === "authenticated" || scenario === "missing" + ? Option.some("maria") + : Option.none(), + ); + assert.strictEqual( + commands.some((command) => command.startsWith("tea ")), + scenario === "missing", + ); + assert.include(commands, "fj version"); + }).pipe( + Effect.provide( + Layer.mergeAll( + Layer.mock(ForgejoCli.ForgejoCli)({ + getAccount: (input) => { + assert.strictEqual(scenario, "authenticated"); + assert.deepStrictEqual(input, { + cwd: "/repo", + baseUrl: "http://forgejo.local:3000", + }); + return Effect.succeed("maria"); + }, + listLogins: (input) => { + assert.strictEqual( + input.remoteUrl, + "http://forgejo.local:3000/maria/project.git", + ); + if (scenario === "invalid-storage") + return Effect.fail( + new ForgejoCli.ForgejoCliError({ + command: "fj", + cwd: input.cwd, + reason: "authentication", + detail: "fj authentication storage is invalid.", + }), + ); + return Effect.succeed([ + { + name: "forgejo.local:3000", + url: "http://forgejo.local:3000", + user: "", + default: "false", + }, + ]); + }, + }), + Layer.mock(VcsProcess.VcsProcess)({ + run: (input) => { + commands.push(`${input.command} ${input.args.join(" ")}`); + if (input.command === "git") + return Effect.succeed( + processOutput("http://forgejo.local:3000/maria/project.git\n"), + ); + if (input.command === "fj") { + if (scenario === "missing") + return Effect.fail( + new VcsProcessSpawnError({ + operation: input.operation, + command: input.command, + cwd: input.cwd, + cause: new Error("fj not found"), + }), + ); + if (input.args[0] === "version") + return Effect.succeed(processOutput("fj 0.10.0")); + if (scenario === "invalid-storage") { + assert.deepStrictEqual(input.args, ["auth", "list"]); + return Effect.succeed(processOutput("")); + } + assert.deepStrictEqual(input.args, [ + "--host", + "http://forgejo.local:3000", + "whoami", + ]); + return Effect.succeed( + processOutput("", { + exitCode: ChildProcessSpawner.ExitCode(scenario === "revoked" ? 1 : 0), + }), + ); + } + assert.strictEqual(input.command, "tea"); + return Effect.succeed( + input.args[0] === "--version" + ? processOutput("tea version 0.16.0") + : processOutput( + encodeJson([ + { + name: "work", + url: "http://forgejo.local:3000", + user: "maria", + default: "true", + valid: "true", + }, + ]), + ), + ); + }, + }), + ), + ), + ); + } + }), +); + +it.effect( + "checks out fj pull refs and preserves existing branches and dirty files until forced", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const git = yield* VcsProcess.VcsProcess; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-fj-checkout-" }); + const source = path.join(root, "source"); + const cwd = path.join(root, "checkout"); + yield* fs.makeDirectory(source); + for (const args of [ + ["init", "-b", "main"], + ["config", "user.name", "Test"], + ["config", "user.email", "test@example.com"], + ]) + yield* git.run({ operation: "test.setup", command: "git", cwd: source, args }); + yield* fs.writeFileString(path.join(source, "base.txt"), "base\n"); + for (const args of [ + ["add", "base.txt"], + ["commit", "-m", "base"], + ]) + yield* git.run({ operation: "test.setup", command: "git", cwd: source, args }); + const base = (yield* git.run({ + operation: "test.setup", + command: "git", + cwd: source, + args: ["rev-parse", "HEAD"], + })).stdout.trim(); + yield* git.run({ + operation: "test.setup", + command: "git", + cwd: root, + args: ["clone", source, cwd], + }); + yield* fs.writeFileString(path.join(source, "feature.txt"), "pull request change\n"); + for (const args of [ + ["add", "feature.txt"], + ["commit", "-m", "feature"], + ["update-ref", "refs/pull/42/head", "HEAD"], + ]) + yield* git.run({ operation: "test.setup", command: "git", cwd: source, args }); + const head = (yield* git.run({ + operation: "test.setup", + command: "git", + cwd: source, + args: ["rev-parse", "HEAD"], + })).stdout.trim(); + const fetched: string[] = []; + const provider = yield* ForgejoSourceControlProvider.make.pipe( + Effect.provideService( + VcsProcess.VcsProcess, + VcsProcess.VcsProcess.of({ + run: (input) => { + if (input.args[0] !== "fetch") return git.run(input); + const url = input.args[2]; + assert.isDefined(url); + fetched.push(url!); + // Only SSH transport is substituted; both paths fetch the real pull ref. + return git.run({ + ...input, + args: input.args.map((arg) => + arg === "git@forgejo.test:reviewer/project.git" ? source : arg, + ), + }); + }, + }), + ), + Effect.provide( + Layer.mock(ForgejoCli.ForgejoCli)({ + resolveRepository: () => + Effect.succeed({ + command: "fj", + login: "work", + repository: "reviewer/project", + baseUrl: "https://forgejo.test", + }), + api: (input) => { + assert.include( + ["repos/reviewer/project", "repos/reviewer/project/pulls/42"], + input.path, + ); + return Effect.succeed( + processOutput( + encodeJson( + input.path.endsWith("/pulls/42") + ? { + number: 42, + title: "Checkout", + html_url: "https://forgejo.test/reviewer/project/pulls/42", + state: "open", + merged: false, + base: { ref: "main", sha: base, repo: null }, + head: { ref: "feature", sha: head, repo: null }, + } + : { + full_name: "reviewer/project", + clone_url: source, + ssh_url: "git@forgejo.test:reviewer/project.git", + default_branch: "main", + }, + ), + ), + ); + }, + }), + ), + ); + yield* provider.checkoutChangeRequest({ + cwd, + reference: "https://forgejo.test/reviewer/project/pulls/42", + }); + assert.strictEqual( + (yield* git.run({ + operation: "test.verify", + command: "git", + cwd, + args: ["branch", "--show-current"], + })).stdout.trim(), + "pulls/42", + ); + assert.strictEqual( + (yield* git.run({ + operation: "test.verify", + command: "git", + cwd, + args: ["rev-parse", "HEAD"], + })).stdout.trim(), + head, + ); + assert.strictEqual( + yield* fs.readFileString(path.join(cwd, "feature.txt")), + "pull request change\n", + ); + for (const args of [ + ["checkout", "main"], + ["branch", "-f", "pulls/42", "main"], + ]) + yield* git.run({ operation: "test.setup", command: "git", cwd, args }); + yield* fs.writeFileString(path.join(cwd, "base.txt"), "uncommitted work\n"); + yield* provider.checkoutChangeRequest({ cwd, reference: "42" }); + assert.strictEqual( + (yield* git.run({ + operation: "test.verify", + command: "git", + cwd, + args: ["rev-parse", "HEAD"], + })).stdout.trim(), + base, + ); + assert.strictEqual(yield* fs.exists(path.join(cwd, "feature.txt")), false); + yield* provider.checkoutChangeRequest({ + cwd, + reference: "42", + force: true, + context: { + provider: { kind: "forgejo", name: "Forgejo", baseUrl: "https://forgejo.test" }, + remoteName: "origin", + remoteUrl: "git@forgejo.test:maria/project.git", + }, + }); + assert.strictEqual( + (yield* git.run({ + operation: "test.verify", + command: "git", + cwd, + args: ["rev-parse", "HEAD"], + })).stdout.trim(), + head, + ); + assert.strictEqual( + yield* fs.readFileString(path.join(cwd, "base.txt")), + "uncommitted work\n", + ); + assert.strictEqual( + yield* fs.readFileString(path.join(cwd, "feature.txt")), + "pull request change\n", + ); + assert.deepStrictEqual(fetched, [source, source, "git@forgejo.test:reviewer/project.git"]); + }).pipe( + Effect.scoped, + Effect.provide(VcsProcess.layer.pipe(Layer.provideMerge(NodeServices.layer))), + ), +); diff --git a/apps/server/src/sourceControl/SourceControlProvider.ts b/apps/server/src/sourceControl/SourceControlProvider.ts index 1844e1bfa7cb..d295944f97f7 100644 --- a/apps/server/src/sourceControl/SourceControlProvider.ts +++ b/apps/server/src/sourceControl/SourceControlProvider.ts @@ -14,6 +14,8 @@ export interface SourceControlProviderContext { readonly provider: SourceControlProviderInfo; readonly remoteName: string; readonly remoteUrl: string; + /** An explicit web authority can disambiguate Forgejo logins sharing an SSH alias. */ + readonly requestedHost?: string; } export interface SourceControlRefSelector { diff --git a/apps/server/src/sourceControl/SourceControlProviderDiscovery.ts b/apps/server/src/sourceControl/SourceControlProviderDiscovery.ts index 69ac90edbfb3..466e66230649 100644 --- a/apps/server/src/sourceControl/SourceControlProviderDiscovery.ts +++ b/apps/server/src/sourceControl/SourceControlProviderDiscovery.ts @@ -1,3 +1,4 @@ +import * as NodeUtil from "node:util"; import type { SourceControlProviderAuth, SourceControlProviderDiscoveryItem, @@ -33,6 +34,7 @@ export type SourceControlCliDiscoverySpec = SourceControlDiscoverySpecBase & { readonly executable: string; readonly versionArgs: ReadonlyArray; readonly authArgs: ReadonlyArray; + readonly remoteRefinementArgs?: ReadonlyArray; readonly probeTimeoutMs?: number; readonly parseAuth: (input: SourceControlAuthProbeInput) => SourceControlProviderAuth; readonly refineUnknownRemote?: ( @@ -45,8 +47,18 @@ export type SourceControlApiDiscoverySpec = SourceControlDiscoverySpecBase & { readonly probeAuth: Effect.Effect; }; +export type SourceControlManagedCliDiscoverySpec = SourceControlDiscoverySpecBase & { + readonly type: "managed-cli"; + readonly probe: (cwd: string) => Effect.Effect; + readonly refineUnknownRemote: (input: { + readonly cwd: string; + readonly context: SourceControlProvider.SourceControlProviderContext; + }) => Effect.Effect; +}; + export type SourceControlProviderDiscoverySpec = | SourceControlCliDiscoverySpec + | SourceControlManagedCliDiscoverySpec | SourceControlApiDiscoverySpec; type SourceControlCliRemoteRefinementSpec = SourceControlCliDiscoverySpec & { @@ -72,7 +84,7 @@ interface DiscoveryProbeResult { } export function firstNonEmptyLine(text: string): Option.Option { - const line = text + const line = NodeUtil.stripVTControlCharacters(text) .split(/\r?\n/) .map((entry) => entry.trim()) .find((entry) => entry.length > 0); @@ -214,6 +226,7 @@ export function probeSourceControlProvider(input: { readonly process: VcsProcess.VcsProcess["Service"]; readonly cwd: string; }): Effect.Effect { + if (input.spec.type === "managed-cli") return input.spec.probe(input.cwd); if (input.spec.type === "api") { return input.spec.probeAuth.pipe( Effect.map( @@ -288,12 +301,16 @@ export const refineUnknownRemoteProvider = Effect.fn("refineUnknownRemoteProvide } const context = input.context; - const providers = yield* Effect.forEach(input.specs.filter(isCliRemoteRefinementSpec), (spec) => - input.process + const providers = yield* Effect.forEach(input.specs, (spec) => { + if (spec.type === "managed-cli") { + return spec.refineUnknownRemote({ cwd: input.cwd, context }); + } + if (!isCliRemoteRefinementSpec(spec)) return Effect.succeed(null); + return input.process .run({ operation: "source-control.discovery.refine-unknown-remote", command: spec.executable, - args: spec.authArgs, + args: spec.remoteRefinementArgs ?? spec.authArgs, cwd: input.cwd, allowNonZeroExit: true, timeoutMs: probeTimeoutMs(spec), @@ -309,8 +326,8 @@ export const refineUnknownRemoteProvider = Effect.fn("refineUnknownRemoteProvide }), ), Effect.orElseSucceed(() => null), - ), - ); + ); + }); const provider = providers.find((candidate) => candidate !== null); return provider ? { ...context, provider } : context; diff --git a/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts index 54038502bfde..02e9b03e1f29 100644 --- a/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts +++ b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts @@ -15,6 +15,7 @@ import * as AzureDevOpsCli from "./AzureDevOpsCli.ts"; import * as BitbucketApi from "./BitbucketApi.ts"; import * as GitHubCli from "./GitHubCli.ts"; import * as GitLabCli from "./GitLabCli.ts"; +import * as ForgejoCli from "./ForgejoCli.ts"; import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts"; const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z"); @@ -86,12 +87,14 @@ function makeRegistry(input: { return SourceControlProviderRegistry.make.pipe( Effect.provide( Layer.mergeAll( + NodeServices.layer, registryLayer, processLayer, Layer.mock(AzureDevOpsCli.AzureDevOpsCli)({}), Layer.mock(BitbucketApi.BitbucketApi)({}), Layer.mock(GitHubCli.GitHubCli)({}), Layer.mock(GitLabCli.GitLabCli)({}), + Layer.mock(ForgejoCli.ForgejoCli)({ listLogins: () => Effect.succeed([]) }), ServerConfig.layerTest(process.cwd(), { prefix: "t3-source-control-registry-test-", }).pipe(Layer.provide(NodeServices.layer)), diff --git a/apps/server/src/sourceControl/SourceControlProviderRegistry.ts b/apps/server/src/sourceControl/SourceControlProviderRegistry.ts index e9b61c17a4f4..57dfc78b6672 100644 --- a/apps/server/src/sourceControl/SourceControlProviderRegistry.ts +++ b/apps/server/src/sourceControl/SourceControlProviderRegistry.ts @@ -15,6 +15,7 @@ import * as AzureDevOpsSourceControlProvider from "./AzureDevOpsSourceControlPro import * as BitbucketSourceControlProvider from "./BitbucketSourceControlProvider.ts"; import * as GitHubSourceControlProvider from "./GitHubSourceControlProvider.ts"; import * as GitLabSourceControlProvider from "./GitLabSourceControlProvider.ts"; +import * as ForgejoSourceControlProvider from "./ForgejoSourceControlProvider.ts"; import * as SourceControlProvider from "./SourceControlProvider.ts"; import { probeSourceControlProvider, @@ -296,6 +297,8 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit export const make = Effect.gen(function* () { const github = yield* GitHubSourceControlProvider.make; const gitlab = yield* GitLabSourceControlProvider.make; + const forgejo = yield* ForgejoSourceControlProvider.make; + const forgejoDiscovery = yield* ForgejoSourceControlProvider.makeDiscovery; const bitbucket = yield* BitbucketSourceControlProvider.make; const bitbucketDiscovery = yield* BitbucketSourceControlProvider.makeDiscovery; const azureDevOps = yield* AzureDevOpsSourceControlProvider.make; @@ -320,6 +323,7 @@ export const make = Effect.gen(function* () { provider: bitbucket, discovery: bitbucketDiscovery, }, + { kind: "forgejo", provider: forgejo, discovery: forgejoDiscovery }, ]); }); diff --git a/apps/server/src/sourceControl/forgejoPullRequests.ts b/apps/server/src/sourceControl/forgejoPullRequests.ts new file mode 100644 index 000000000000..affb9bae74c7 --- /dev/null +++ b/apps/server/src/sourceControl/forgejoPullRequests.ts @@ -0,0 +1,47 @@ +import * as Schema from "effect/Schema"; +import * as Option from "effect/Option"; +import type { ChangeRequest } from "@t3tools/contracts"; + +const Repository = Schema.Struct({ + full_name: Schema.String, + owner: Schema.Struct({ login: Schema.String }), +}); +const Branch = Schema.Struct({ + ref: Schema.String, + sha: Schema.String, + repo: Schema.NullOr(Repository), +}); +export const ForgejoPullRequestSchema = Schema.Struct({ + number: Schema.Int, + title: Schema.String, + html_url: Schema.String, + state: Schema.String, + merged: Schema.Boolean, + draft: Schema.optional(Schema.Boolean), + base: Branch, + head: Branch, + closed_at: Schema.optional(Schema.NullOr(Schema.String)), + merged_at: Schema.optional(Schema.NullOr(Schema.String)), + updated_at: Schema.optional(Schema.OptionFromNullOr(Schema.DateTimeUtcFromString)), +}); +export function toForgejoChangeRequest(raw: typeof ForgejoPullRequestSchema.Type): ChangeRequest { + return { + provider: "forgejo", + number: raw.number, + title: raw.title, + url: raw.html_url, + state: raw.merged ? "merged" : raw.state === "closed" ? "closed" : "open", + isDraft: raw.draft ?? /^(?:\[WIP\]|WIP:)/i.test(raw.title), + baseRefName: raw.base.ref, + headRefName: raw.head.ref, + closedAt: raw.closed_at ?? null, + mergedAt: raw.merged_at ?? null, + updatedAt: raw.updated_at ?? Option.none(), + isCrossRepository: + raw.head.repo !== null && + raw.base.repo !== null && + raw.head.repo.full_name !== raw.base.repo.full_name, + headRepositoryNameWithOwner: raw.head.repo?.full_name ?? null, + headRepositoryOwnerLogin: raw.head.repo?.owner.login ?? null, + }; +} diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index e91fadd3155e..10d35e92040d 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -155,6 +155,7 @@ import * as AzureDevOpsCli from "./sourceControl/AzureDevOpsCli.ts"; import * as BitbucketApi from "./sourceControl/BitbucketApi.ts"; import * as GitHubCli from "./sourceControl/GitHubCli.ts"; import * as GitLabCli from "./sourceControl/GitLabCli.ts"; +import * as ForgejoCli from "./sourceControl/ForgejoCli.ts"; import * as SourceControlProviderRegistry from "./sourceControl/SourceControlProviderRegistry.ts"; import * as GitVcsDriver from "./vcs/GitVcsDriver.ts"; import * as VcsDriverRegistry from "./vcs/VcsDriverRegistry.ts"; @@ -3111,6 +3112,7 @@ export const websocketRpcRouteLayer = Layer.unwrap( BitbucketApi.layer, GitHubCli.layer, GitLabCli.layer, + ForgejoCli.layer, ), ), Layer.provideMerge(GitVcsDriver.layer), diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 72842dd2a1dc..8af4419fca31 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -150,7 +150,7 @@ import { orderItemsByPreferredIds, sortLogicalProjectsForSidebar } from "./Sideb import { resolveEnvironmentOptionLabel } from "./BranchToolbar.logic"; import { CommandPaletteContent } from "./CommandPaletteContent"; import { CommandPaletteResults } from "./CommandPaletteResults"; -import { AzureDevOpsIcon, BitbucketIcon, GitHubIcon, GitLabIcon } from "./Icons"; +import { AzureDevOpsIcon, BitbucketIcon, GitHubIcon, GitLabIcon, ForgejoIcon } from "./Icons"; import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; import { ProjectFavicon } from "./ProjectFavicon"; import { ProjectFilePicker } from "./files/ProjectFilePicker"; @@ -244,7 +244,7 @@ interface AddProjectEnvironmentOption { type AddProjectRemoteProviderKind = Extract< SourceControlProviderKind, - "github" | "gitlab" | "bitbucket" | "azure-devops" + "github" | "gitlab" | "forgejo" | "bitbucket" | "azure-devops" >; type AddProjectRemoteSource = AddProjectRemoteProviderKind | "url"; @@ -267,12 +267,14 @@ const REMOTE_PROJECT_SOURCES: ReadonlyArray = [ "url", "github", "gitlab", + "forgejo", "bitbucket", "azure-devops", ]; const REMOTE_PROJECT_PROVIDER_SOURCES: ReadonlyArray = [ "github", "gitlab", + "forgejo", "bitbucket", "azure-devops", ]; @@ -281,6 +283,8 @@ function remoteProjectSourceLabel(source: AddProjectRemoteSource): string { switch (source) { case "github": return "GitHub"; + case "forgejo": + return "Forgejo / Gitea"; case "gitlab": return "GitLab"; case "bitbucket": @@ -294,6 +298,7 @@ function remoteProjectSourceLabel(source: AddProjectRemoteSource): string { function remoteProjectSourcePathHint(source: AddProjectRemoteSource): string { switch (source) { + case "forgejo": case "github": return "owner/repo"; case "gitlab": @@ -317,6 +322,8 @@ function remoteProjectSourceIcon(source: AddProjectRemoteSource, className: stri switch (source) { case "github": return ; + case "forgejo": + return ; case "gitlab": return ; case "bitbucket": @@ -370,6 +377,7 @@ function buildAddProjectRemoteSourceReadiness( url: { ready: true, hint: null }, github: unavailable, gitlab: unavailable, + forgejo: unavailable, bitbucket: unavailable, "azure-devops": unavailable, }; @@ -1759,6 +1767,7 @@ function OpenCommandPaletteDialog(props: { "git", "github", "gitlab", + "forgejo", "bitbucket", "azure", "devops", diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 746a1bbe0247..99db055b667b 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -39,7 +39,13 @@ import { GlobeIcon, } from "lucide-react"; import { Radio as RadioPrimitive } from "@base-ui/react/radio"; -import { AzureDevOpsIcon, BitbucketIcon, GitHubIcon, GitLabIcon } from "~/components/Icons"; +import { + AzureDevOpsIcon, + BitbucketIcon, + GitHubIcon, + GitLabIcon, + ForgejoIcon, +} from "~/components/Icons"; import { RadioGroup } from "~/components/ui/radio-group"; import { Spinner } from "~/components/ui/spinner"; import { toggleVariants } from "~/components/ui/toggle"; @@ -123,7 +129,7 @@ interface PendingDefaultBranchAction { type PublishProviderKind = Extract< SourceControlProviderKind, - "github" | "gitlab" | "bitbucket" | "azure-devops" + "github" | "gitlab" | "forgejo" | "bitbucket" | "azure-devops" >; type GitActionToastId = ReturnType; @@ -171,6 +177,14 @@ function requestVcsStatusRefresh( const RUNNING_SOURCE_CONTROL_ACTIONS = ["runStackedAction", "pull", "publishRepository"] as const; const PUBLISH_PROVIDER_OPTIONS = [ + { + value: "forgejo", + label: "Forgejo / Gitea", + description: "Your signed-in server", + host: "your server", + pathPlaceholder: "owner/repo", + Icon: ForgejoIcon, + }, { value: "github", label: "GitHub", @@ -425,6 +439,7 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { const accounts: Record = { github: null, gitlab: null, + forgejo: null, bitbucket: null, "azure-devops": null, }; @@ -476,7 +491,14 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { : ""; const publishRepository = publishRepositoryOverride ?? publishRepositoryPrefill; const currentPublishProvider = publishProviderOption(publishProvider); - const publishHost = currentPublishProvider.host; + const publishHost = + publishProvider === "forgejo" + ? (Option.getOrNull( + sourceControlDiscovery.data?.sourceControlProviders.find( + (provider) => provider.kind === "forgejo", + )?.auth.host ?? Option.none(), + ) ?? currentPublishProvider.host) + : currentPublishProvider.host; const publishPathPlaceholder = currentPublishProvider.pathPlaceholder; const publishProviderLabel = currentPublishProvider.label; const publishWizardSteps = ["Provider", "Repository", "Summary"] as const; diff --git a/apps/web/src/components/Icons.tsx b/apps/web/src/components/Icons.tsx index 99880a2512ad..e4d41d53c3a8 100644 --- a/apps/web/src/components/Icons.tsx +++ b/apps/web/src/components/Icons.tsx @@ -768,3 +768,16 @@ export const PiAgentIcon: Icon = ({ className, ...props }) => ( ); + +// Official two-color mark from https://forgejo.org/favicon.svg. +export const ForgejoIcon: Icon = (props) => ( + +); diff --git a/apps/web/src/components/ThreadStatusIndicators.tsx b/apps/web/src/components/ThreadStatusIndicators.tsx index 2a69eaeddc9d..7201779f13f1 100644 --- a/apps/web/src/components/ThreadStatusIndicators.tsx +++ b/apps/web/src/components/ThreadStatusIndicators.tsx @@ -102,7 +102,9 @@ export function linkedPullRequestSnapshotStatus( ? "azure-devops" : link.url.includes("/pull-requests/") ? "bitbucket" - : "github"; + : link.url.includes("/pulls/") + ? "forgejo" + : "github"; return { pr: { number: link.number, diff --git a/apps/web/src/components/pullRequest/LinkPullRequestDialog.tsx b/apps/web/src/components/pullRequest/LinkPullRequestDialog.tsx index aa5dfa789524..88e8144eed0b 100644 --- a/apps/web/src/components/pullRequest/LinkPullRequestDialog.tsx +++ b/apps/web/src/components/pullRequest/LinkPullRequestDialog.tsx @@ -144,7 +144,10 @@ function LinkPullRequestDialog({ return { host, repository, - webUrl: (number: number) => changeRequestWebUrl(kind, host, repository, number), + webUrl: (number: number) => + kind === "forgejo" && identity.webUrl + ? `${identity.webUrl.replace(/\/+$/, "")}/pulls/${number}` + : changeRequestWebUrl(kind, host, repository, number, identity.locator.remoteUrl), }; }, [environmentProjects, projectId]); const linking = usePullRequestLinking(threadRef.environmentId); diff --git a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx index b0bc3fdb8e22..42bf7ab5102e 100644 --- a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx @@ -976,6 +976,7 @@ function PullRequestCodeTab({ environmentId={environmentId} reference={reference} verdicts={review.verdicts} + requestChangesSummaryRequired={detail.provider === "forgejo"} onSubmitted={() => { onRefresh(); setReviewOpen(false); diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx index 60188e546d4d..3be7b5a1a196 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx @@ -727,6 +727,7 @@ export function PullRequestDetailPanel({ detail.number, detail.headBranch, detail.headRepositoryNameWithOwner, + repositoryUrl, ) : null; const branchRefsQuery = useEnvironmentQuery( @@ -784,11 +785,14 @@ export function PullRequestDetailPanel({ if (!coreDetail) return; const next = { key: tabScopeKey, updatedAt: coreDetail.updatedAt }; if (shouldRefreshPullRequestActivity(activityRevision.current, next)) { + // Let an existing read settle before revalidating the new revision. Interrupting a + // mutation's activity refresh can leave SWR displaying its previous value. + if (activityQuery.isPending) return; activityQuery.refresh(); setRefreshToken((token) => token + 1); } activityRevision.current = next; - }, [activityQuery.refresh, coreDetail, tabScopeKey]); + }, [activityQuery.isPending, activityQuery.refresh, coreDetail, tabScopeKey]); // Reuse activity and diff until core detail reports a changed revision. Keyed by // the pull request rather than by the panel, because this one panel shows a different pull // request every time it is opened. diff --git a/apps/web/src/components/pullRequest/PullRequestReviewBar.tsx b/apps/web/src/components/pullRequest/PullRequestReviewBar.tsx index f32352734685..d0b9ab47af53 100644 --- a/apps/web/src/components/pullRequest/PullRequestReviewBar.tsx +++ b/apps/web/src/components/pullRequest/PullRequestReviewBar.tsx @@ -50,11 +50,13 @@ export function PullRequestReviewBar({ environmentId, reference, verdicts, + requestChangesSummaryRequired, onSubmitted, }: { environmentId: EnvironmentId; reference: PullRequestRef; verdicts: ReadonlyArray; + requestChangesSummaryRequired: boolean; onSubmitted: () => void; }) { const [pending, setPending] = useState(false); @@ -106,9 +108,11 @@ export function PullRequestReviewBar({ onSubmitted(); }; - // An approval needs no words; anything else does, unless it carries line comments instead. + // Forgejo requires a summary when requesting changes, even with inline comments. const canSubmit = (verdict: PullRequestReviewVerdict) => - verdict === "approve" || body.trim().length > 0 || comments.length > 0; + verdict === "request-changes" && requestChangesSummaryRequired + ? body.trim().length > 0 + : verdict === "approve" || body.trim().length > 0 || comments.length > 0; return (
@@ -128,7 +132,11 @@ export function PullRequestReviewBar({ size="sm" className="mt-2" value={body} - placeholder="Summarize your review (optional)" + placeholder={ + requestChangesSummaryRequired && verdicts.includes("request-changes") + ? "Summarize your review (required to request changes)" + : "Summarize your review (optional)" + } aria-label="Review summary" onChange={(event) => setSummary(reviewKey, event.target.value)} /> diff --git a/apps/web/src/components/pullRequest/PullRequestThreadLinks.tsx b/apps/web/src/components/pullRequest/PullRequestThreadLinks.tsx index 9d8dcc2424e6..57d08218f80a 100644 --- a/apps/web/src/components/pullRequest/PullRequestThreadLinks.tsx +++ b/apps/web/src/components/pullRequest/PullRequestThreadLinks.tsx @@ -7,6 +7,7 @@ import { threadPullRequestLinkMode } from "@t3tools/client-runtime/thread-pull-r import { usePullRequestLinking } from "~/hooks/usePullRequestLinking"; import { parseChangeRequestUrl } from "~/lib/openPullRequestLink"; +import { normalizeThreadPullRequestKey } from "@t3tools/shared/threadPullRequests"; import { useProjects, useServerConfigs, useThreadShell, useThreadShells } from "~/state/entities"; import { pullRequestEnvironment } from "~/state/pullRequests"; import { useEnvironmentQuery } from "~/state/query"; @@ -56,7 +57,10 @@ function EnabledPullRequestThreadLinks({ linking.mode === "multiple" && display !== "menu-item" ? pullRequestEnvironment.linkedThreads({ environmentId, - input: parsed === null ? reference : { ...reference, ...parsed }, + input: + parsed === null + ? reference + : { ...reference, ...normalizeThreadPullRequestKey(parsed) }, }) : null, ); @@ -85,7 +89,10 @@ function EnabledPullRequestThreadLinks({ } if (linking.mode === "multiple") { appAtomRegistry.refresh( - pullRequestEnvironment.linkedThreads({ environmentId, input: { ...reference, ...parsed } }), + pullRequestEnvironment.linkedThreads({ + environmentId, + input: { ...reference, ...normalizeThreadPullRequestKey(parsed) }, + }), ); } onPickerOpenChange?.(false); diff --git a/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts b/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts index 40685aa6ed80..ae5a5067bf12 100644 --- a/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts +++ b/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts @@ -55,6 +55,7 @@ describe("pull request checkout commands", () => { it.each([ ["github", "feature", null, "gh pr checkout 42"], ["gitlab", "feature", null, "glab mr checkout 42"], + ["forgejo", "feature", null, null], ["azure-devops", "feature", null, "az repos pr checkout --id 42"], [ "bitbucket", @@ -66,6 +67,32 @@ describe("pull request checkout commands", () => { ] as const)("builds the %s command", (provider, branch, repository, expected) => { expect(pullRequestCheckoutCommand(provider, 42, branch, repository)).toBe(expected); }); + it("fetches Forgejo pull refs from the actual repository, including a mounted host and port", () => { + expect( + pullRequestCheckoutCommand( + "forgejo", + 42, + "feature", + null, + "https://forgejo.local:3000/git/maria/repo", + ), + ).toBe( + "git fetch 'https://forgejo.local:3000/git/maria/repo' refs/pull/42/head && git checkout -B pulls/42 FETCH_HEAD", + ); + }); + it("quotes shell metacharacters in Forgejo repository URLs", () => { + expect( + pullRequestCheckoutCommand( + "forgejo", + 42, + "feature", + null, + "https://forgejo.local/maria/repo'$(echo nope)", + ), + ).toBe( + "git fetch 'https://forgejo.local/maria/repo'\\''$(echo nope)' refs/pull/42/head && git checkout -B pulls/42 FETCH_HEAD", + ); + }); }); const TIMELINE_SOURCE: Pick< diff --git a/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts b/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts index e4c5cd35f1cd..be71c95ddc40 100644 --- a/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts +++ b/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts @@ -104,12 +104,17 @@ export function pullRequestCheckoutCommand( number: number, headBranch: string, headRepositoryNameWithOwner?: string | null, + repositoryUrl?: string | null, ): string | null { switch (provider) { case "github": return `gh pr checkout ${number}`; case "gitlab": return `glab mr checkout ${number}`; + case "forgejo": + return repositoryUrl + ? `git fetch '${repositoryUrl.replaceAll("'", "'\\''")}' refs/pull/${number}/head && git checkout -B pulls/${number} FETCH_HEAD` + : null; case "azure-devops": return `az repos pr checkout --id ${number}`; case "bitbucket": { diff --git a/apps/web/src/components/pullRequest/pullRequestLinkContextMenu.ts b/apps/web/src/components/pullRequest/pullRequestLinkContextMenu.ts index 1ccdb64b73f4..16c4fa5d3559 100644 --- a/apps/web/src/components/pullRequest/pullRequestLinkContextMenu.ts +++ b/apps/web/src/components/pullRequest/pullRequestLinkContextMenu.ts @@ -11,6 +11,7 @@ export type PullRequestLinkContextMenuAction = "copy-link" | "open-external"; const OPEN_ON_HOST_LABELS: Partial> = { github: "Open on GitHub", gitlab: "Open on GitLab", + forgejo: "Open on Forgejo", bitbucket: "Open on Bitbucket", "azure-devops": "Open on Azure DevOps", }; diff --git a/apps/web/src/components/settings/SourceControlSettings.tsx b/apps/web/src/components/settings/SourceControlSettings.tsx index 2a4858ded4a7..733791518d35 100644 --- a/apps/web/src/components/settings/SourceControlSettings.tsx +++ b/apps/web/src/components/settings/SourceControlSettings.tsx @@ -52,6 +52,7 @@ import { GitHubIcon, GitIcon, GitLabIcon, + ForgejoIcon, JujutsuIcon, type Icon, } from "../Icons"; @@ -75,6 +76,7 @@ const EMPTY_DISCOVERY_RESULT: SourceControlDiscoveryResult = { const SOURCE_CONTROL_PROVIDER_ICONS: Partial> = { github: GitHubIcon, gitlab: GitLabIcon, + forgejo: ForgejoIcon, "azure-devops": AzureDevOpsIcon, bitbucket: BitbucketIcon, }; diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index bf9103ad4779..9a8af52b90f5 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -567,7 +567,7 @@ export const SETTINGS_SEARCH_ITEMS = [ to: "/settings/source-control", scope: "environment-defaults", searchTerms: [ - "version control git github gitlab bitbucket azure devops hosting integrations credentials scan server environment", + "version control git github gitlab forgejo gitea tea codeberg bitbucket azure devops hosting integrations credentials scan server environment", ], }, { diff --git a/apps/web/src/lib/openPullRequestLink.test.ts b/apps/web/src/lib/openPullRequestLink.test.ts index ce9c2193a58e..a709b7d8add1 100644 --- a/apps/web/src/lib/openPullRequestLink.test.ts +++ b/apps/web/src/lib/openPullRequestLink.test.ts @@ -170,6 +170,37 @@ describe("matchesLinkedPullRequestUrl", () => { ).toBe(true); }); + it.each([ + ["http://forge.example:3000/git/team/repo/pulls/42/files", true], + ["http://forge.example:4000/git/team/repo/pulls/42", false], + ["http://forge.example/git/team/repo/pulls/42", false], + ])("matches Forgejo links by web authority: %s", (url, expected) => { + expect( + matchesLinkedPullRequestUrl( + { ...linkedPullRequest, url: "http://forge.example:3000/git/team/repo/pulls/42" }, + url, + ), + ).toBe(expected); + }); + + it("keeps other providers' existing port normalization", () => { + expect( + matchesLinkedPullRequestUrl( + linkedPullRequest, + "https://github.com:8443/pingdotgg/t3code/pull/42", + ), + ).toBe(true); + }); + + it("keeps Forgejo and GitHub path shapes distinct on a GitHub-named host", () => { + expect( + matchesLinkedPullRequestUrl( + { ...linkedPullRequest, url: "https://github.internal/team/repo/pulls/42" }, + "https://github.internal/team/repo/pull/42", + ), + ).toBe(false); + }); + it("rejects a different pull request or host", () => { expect( matchesLinkedPullRequestUrl(linkedPullRequest, "https://github.com/pingdotgg/t3code/pull/43"), @@ -350,6 +381,59 @@ describe("findProjectOnChangeRequestHost", () => { ).toBe(backend); }); + it("selects the Forgejo HTTP port for repository and host-level matches", () => { + const projects = [3000, 4000].map((port) => + project(`forgejo-${port}`, { + canonicalKey: "forge.example/git/team/repo", + provider: "forgejo", + displayName: "git/team/repo", + locator: { remoteUrl: `http://forge.example:${port}/git/team/repo.git` }, + }), + ); + const reference = parseChangeRequestUrl("http://forge.example:4000/git/team/repo/pulls/42")!; + expect(findProjectForChangeRequest(projects, reference)).toBe(projects[1]); + expect(findProjectOnChangeRequestHost(projects, reference)).toBe(projects[1]); + expect( + findProjectOnChangeRequestHost(projects, { ...reference, repository: "git/team/other" }), + ).toBe(projects[1]); + expect(findProjectForChangeRequest([projects[0]!], reference)).toBeUndefined(); + expect(findProjectOnChangeRequestHost([projects[0]!], reference)).toBeUndefined(); + }); + + it("lets tea resolve the web port for Forgejo SSH remotes", () => { + const checkout = project("forgejo-ssh", { + canonicalKey: "forge.example/git/team/repo", + provider: "forgejo", + displayName: "git/team/repo", + locator: { remoteUrl: "git@forge.example:git/team/repo.git" }, + }); + const reference = parseChangeRequestUrl("http://forge.example:4000/git/team/repo/pulls/42")!; + expect(findProjectForChangeRequest([checkout], reference)).toBe(checkout); + const aliased = project("forgejo-alias", { + canonicalKey: "ssh.forge.example/team/repo", + provider: "forgejo", + displayName: "team/repo", + locator: { remoteUrl: "git@ssh.forge.example:team/repo.git" }, + webUrl: "http://forge.example:4000/git/team/repo", + }); + expect(findProjectForChangeRequest([aliased], reference)).toBe(aliased); + expect(findProjectOnChangeRequestHost([aliased], reference)).toBe(aliased); + expect( + findProjectOnChangeRequestHost([aliased], { ...reference, repository: "git/team/other" }), + ).toBe(aliased); + for (const url of [ + "http://other.example:4000/git/team/repo/pulls/42", + "http://forge.example:3000/git/team/repo/pulls/42", + ]) { + const other = parseChangeRequestUrl(url)!; + expect(findProjectForChangeRequest([aliased], other)).toBeUndefined(); + expect(findProjectOnChangeRequestHost([aliased], other)).toBeUndefined(); + } + const otherMount = parseChangeRequestUrl("http://forge.example:4000/other/team/repo/pulls/42")!; + expect(findProjectForChangeRequest([aliased], otherMount)).toBeUndefined(); + expect(findProjectOnChangeRequestHost([aliased], otherMount)).toBeUndefined(); + }); + it("lends any project on the host to a repository nobody has checked out", () => { expect( findProjectOnChangeRequestHost([frontend], { diff --git a/apps/web/src/lib/openPullRequestLink.ts b/apps/web/src/lib/openPullRequestLink.ts index 35a9fed4cc5d..b691aa58cce3 100644 --- a/apps/web/src/lib/openPullRequestLink.ts +++ b/apps/web/src/lib/openPullRequestLink.ts @@ -26,13 +26,34 @@ export { changeRequestRepositoryUrl, } from "@t3tools/shared/changeRequestUrl"; -/** - * Returns a click handler that opens a pull request URL in the system browser. - * - * Stops event propagation/default so activating the link does not also trigger - * an enclosing row or trigger (e.g. opening the branch dropdown), and surfaces a - * toast when the local API is unavailable or the open fails. - */ +function resolvedForgejoRepository(project: EnvironmentProject): URL | null { + const identity = project.repositoryIdentity; + if (identity?.provider !== "forgejo" || !identity.webUrl) return null; + try { + const url = new URL(identity.webUrl); + return url.protocol === "http:" || url.protocol === "https:" ? url : null; + } catch { + return null; + } +} + +/** Keep Forgejo servers on different HTTP ports separate when selecting a project. */ +function matchesChangeRequestAuthority( + project: EnvironmentProject, + link: ChangeRequestLink, +): boolean { + if (link.authority === undefined) return true; + try { + const remote = new URL(project.repositoryIdentity?.locator.remoteUrl ?? ""); + if (remote.protocol === "http:" || remote.protocol === "https:") { + return remote.host.toLowerCase() === link.authority; + } + } catch { + // SSH remotes do not specify the server's HTTP port; tea resolves the configured login. + } + return true; +} + /** * The project a link belongs to, or nothing. Matched the way the server matches: the repository * identity is the full path below the host where one was recorded — which is what nested GitLab @@ -45,9 +66,15 @@ export function findProjectForChangeRequest( ): EnvironmentProject | undefined { return projects.find((project) => { const identity = project.repositoryIdentity; - if (!identity) return false; + if (!identity || !matchesChangeRequestAuthority(project, link)) return false; const kind = identity.provider as SourceControlProviderKind | undefined; if (kind === undefined) return false; + const web = resolvedForgejoRepository(project); + if (web) + return ( + web.host.toLowerCase() === (link.authority ?? link.host).toLowerCase() && + web.pathname.replace(/^\/+|\/+$/g, "").toLowerCase() === link.repository.toLowerCase() + ); if (kind === "azure-devops") { return ( canonicalRepositoryKey(identity.canonicalKey.toLowerCase()) === @@ -60,7 +87,8 @@ export function findProjectForChangeRequest( return ( repository !== null && repository.toLowerCase() === link.repository.toLowerCase() && - pullRequestHostOf(identity, kind) === link.host.toLowerCase() + (pullRequestHostOf(identity, kind) === link.host.toLowerCase() || + pullRequestHostOf(identity, kind) === link.authority) ); }); } @@ -86,11 +114,25 @@ export function findProjectOnChangeRequestHost( return projects.find((project) => { const identity = project.repositoryIdentity; const kind = identity?.provider as SourceControlProviderKind | undefined; + const web = resolvedForgejoRepository(project); + if (web) { + const mount = web.pathname + .replace(/^\/+|\/+$/g, "") + .split("/") + .slice(0, -2) + .join("/"); + return ( + web.host.toLowerCase() === (link.authority ?? link.host).toLowerCase() && + (!mount || link.repository.toLowerCase().startsWith(`${mount.toLowerCase()}/`)) + ); + } return ( identity != null && kind !== undefined && kind !== "azure-devops" && - pullRequestHostOf(identity, kind) === link.host.toLowerCase() + matchesChangeRequestAuthority(project, link) && + (pullRequestHostOf(identity, kind) === link.host.toLowerCase() || + pullRequestHostOf(identity, kind) === link.authority) ); }); } @@ -188,7 +230,7 @@ export function useOpenChangeRequestLink( projectId: project.id, ...(serverConfigs.get(project.environmentId)?.environment.capabilities .threadPullRequests === true - ? { host: parsed.host } + ? { host: parsed.authority ?? parsed.host } : {}), repository, url: targetUrl, @@ -203,7 +245,7 @@ export function useOpenChangeRequestLink( state: previous.state ?? "all", repository, number: parsed.number, - selectedHost: parsed.host, + selectedHost: parsed.authority ?? parsed.host, selectedProjectId: project.id, selectedEnvironmentId: project.environmentId, }), @@ -221,7 +263,7 @@ export function useOpenChangeRequestLink( state: "all", repository, number: parsed.number, - selectedHost: parsed.host, + selectedHost: parsed.authority ?? parsed.host, selectedProjectId: project.id, // Named so the page opens the right one of two servers holding this project. selectedEnvironmentId: project.environmentId, diff --git a/apps/web/src/pullRequestReference.ts b/apps/web/src/pullRequestReference.ts index b919e736cc09..421f60f03687 100644 --- a/apps/web/src/pullRequestReference.ts +++ b/apps/web/src/pullRequestReference.ts @@ -1,3 +1,6 @@ +const FORGEJO_PULL_REQUEST_URL_PATTERN = + /^https?:\/\/[^/\s]+\/(?:[^/\s]+\/)+[^/\s]+\/pulls\/(\d+)(?:[/?#].*)?$/i; +const FORGEJO_CLI_PR_CHECKOUT_PATTERN = /^tea\s+(?:pr|pulls)\s+checkout\s+(.+)$/i; const GITHUB_PULL_REQUEST_URL_PATTERN = /^https:\/\/github\.com\/[^/\s]+\/[^/\s]+\/pull\/(\d+)(?:[/?#].*)?$/i; const GITLAB_MERGE_REQUEST_URL_PATTERN = @@ -32,6 +35,7 @@ export function parsePullRequestReference(input: string): string | null { const glabCliCheckoutMatch = GITLAB_CLI_MR_CHECKOUT_PATTERN.exec(trimmed); const azureDevOpsCliCheckoutMatch = AZURE_DEVOPS_CLI_PR_CHECKOUT_PATTERN.exec(trimmed); const normalizedInput = + FORGEJO_CLI_PR_CHECKOUT_PATTERN.exec(trimmed)?.[1]?.trim() ?? ghCliCheckoutMatch?.[1]?.trim() ?? glabCliCheckoutMatch?.[1]?.trim() ?? (azureDevOpsCliCheckoutMatch?.[1] @@ -43,6 +47,7 @@ export function parsePullRequestReference(input: string): string | null { } const urlMatch = + FORGEJO_PULL_REQUEST_URL_PATTERN.exec(normalizedInput) ?? GITHUB_PULL_REQUEST_URL_PATTERN.exec(normalizedInput) ?? GITLAB_MERGE_REQUEST_URL_PATTERN.exec(normalizedInput) ?? AZURE_DEVOPS_PULL_REQUEST_URL_PATTERN.exec(normalizedInput); diff --git a/apps/web/src/sourceControlPresentation.ts b/apps/web/src/sourceControlPresentation.ts index 4e757e6954a5..0f627bca5a17 100644 --- a/apps/web/src/sourceControlPresentation.ts +++ b/apps/web/src/sourceControlPresentation.ts @@ -13,7 +13,13 @@ import { resolveChangeRequestPresentation, type ChangeRequestTerminology, } from "@t3tools/shared/sourceControl"; -import { AzureDevOpsIcon, BitbucketIcon, GitHubIcon, GitLabIcon } from "./components/Icons"; +import { + AzureDevOpsIcon, + BitbucketIcon, + GitHubIcon, + GitLabIcon, + ForgejoIcon, +} from "./components/Icons"; export interface SourceControlPresentation { readonly providerName: string; @@ -32,6 +38,12 @@ export function getSourceControlPresentation( terminology: getChangeRequestTerminology(provider), Icon: GitHubIcon, }; + case "forgejo": + return { + providerName: provider?.name || presentation.providerName, + terminology: getChangeRequestTerminology(provider), + Icon: ForgejoIcon, + }; case "gitlab": return { providerName: provider?.name || presentation.providerName, diff --git a/apps/web/src/state/sourceControlActions.ts b/apps/web/src/state/sourceControlActions.ts index 021fb255a4b9..efda37c14403 100644 --- a/apps/web/src/state/sourceControlActions.ts +++ b/apps/web/src/state/sourceControlActions.ts @@ -269,7 +269,7 @@ export function useSourceControlPublishRepositoryAction(scope: SourceControlActi ); const action = useCallback( async (input: { - provider: "github" | "gitlab" | "bitbucket" | "azure-devops"; + provider: "github" | "gitlab" | "forgejo" | "bitbucket" | "azure-devops"; repository: string; visibility: SourceControlRepositoryVisibility; remoteName: string; diff --git a/docs/user/source-control.md b/docs/user/source-control.md index 48e3eb1992cd..b6b4f89af104 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -1,6 +1,6 @@ # Source control -T3 Code integrates with GitHub, GitLab, Bitbucket, and Azure DevOps to clone and publish +T3 Code integrates with GitHub, GitLab, Forgejo, Gitea, Bitbucket, and Azure DevOps to clone and publish repositories, create pull requests, and review changes. ## Connect an account @@ -17,6 +17,26 @@ Install [GitHub CLI](https://cli.github.com/) 2.81.0 or newer, then sign in: gh auth login ``` +### Forgejo and Gitea + +Install [Forgejo CLI (`fj`)](https://codeberg.org/forgejo-contrib/forgejo-cli) or +[Gitea CLI (`tea`)](https://gitea.com/gitea/tea) 0.16 or later on your T3 Code server. +Sign in with `fj --host https://your-server auth add-token` or `tea login add`. +Repeat for each server you use, including Codeberg. + +T3 Code prefers a matching `fj` login and falls back to `tea` when `fj` is unavailable +or has no login for that server. Once an account is selected, failed actions stay on that +account. Settings shows the detected CLI. Forgejo and Gitea share one integration entry. +Servers hosted under a URL subpath, such as `https://example.com/forgejo`, use `tea` because +fj 0.6 does not preserve the subpath when checking its account. + +When cloning or publishing, use a full repository URL to select a specific server. +You can use `owner/repo` when only one fj server is configured, or with your default `tea` +login when fj is unavailable or unconfigured. With multiple fj servers, use the full URL. +If you have multiple `tea` accounts on one server, select one with +`tea login default `. Git push and clone also need Git credentials or an SSH key +for that server. + ### GitLab Install [GitLab CLI](https://gitlab.com/gitlab-org/cli), then sign in: diff --git a/packages/client-runtime/src/operations/projects.test.ts b/packages/client-runtime/src/operations/projects.test.ts index cbbd121959d5..4b29e67a2176 100644 --- a/packages/client-runtime/src/operations/projects.test.ts +++ b/packages/client-runtime/src/operations/projects.test.ts @@ -76,6 +76,13 @@ describe("add project shared logic", () => { sshUrl: "git@github.com:imputnet/helium.git", }), ).toBe("https://github.com/imputnet/helium"); + expect( + getDefaultCloneUrl({ + provider: "forgejo", + url: "https://forgejo.example.test:8443/owner/repo.git", + sshUrl: "ssh://git@forgejo.example.test:2222/owner/repo.git", + }), + ).toBe("https://forgejo.example.test:8443/owner/repo.git"); }); it("preserves existing clone transport behavior for other providers", () => { diff --git a/packages/client-runtime/src/operations/projects.ts b/packages/client-runtime/src/operations/projects.ts index 3f5fc3667f31..597b0b5d7b4f 100644 --- a/packages/client-runtime/src/operations/projects.ts +++ b/packages/client-runtime/src/operations/projects.ts @@ -25,7 +25,7 @@ import type { EnvironmentProject } from "../state/models.ts"; export type AddProjectRemoteProviderKind = Extract< SourceControlProviderKind, - "github" | "gitlab" | "bitbucket" | "azure-devops" + "github" | "gitlab" | "forgejo" | "bitbucket" | "azure-devops" >; export type AddProjectRemoteSource = AddProjectRemoteProviderKind | "url"; @@ -59,6 +59,7 @@ const ADD_PROJECT_REMOTE_SOURCES: ReadonlyArray = [ "url", "github", "gitlab", + "forgejo", "bitbucket", "azure-devops", ]; @@ -66,6 +67,7 @@ const ADD_PROJECT_REMOTE_SOURCES: ReadonlyArray = [ const ADD_PROJECT_REMOTE_PROVIDER_SOURCES: ReadonlyArray = [ "github", "gitlab", + "forgejo", "bitbucket", "azure-devops", ]; @@ -74,6 +76,8 @@ export function addProjectRemoteSourceLabel(source: AddProjectRemoteSource): str switch (source) { case "github": return "GitHub"; + case "forgejo": + return "Forgejo / Gitea"; case "gitlab": return "GitLab"; case "bitbucket": @@ -87,6 +91,7 @@ export function addProjectRemoteSourceLabel(source: AddProjectRemoteSource): str export function addProjectRemoteSourcePathHint(source: AddProjectRemoteSource): string { switch (source) { + case "forgejo": case "github": return "owner/repo"; case "gitlab": @@ -117,11 +122,13 @@ export function normalizePastedCloneUrl(input: string): string { return `https://github.com/${repository}`; } -/** GitHub defaults to HTTPS; other providers retain their existing SSH default. */ +/** GitHub and Forgejo default to HTTPS; other providers retain their existing SSH default. */ export function getDefaultCloneUrl( repository: Pick, ): string { - return repository.provider === "github" ? repository.url : repository.sshUrl; + return repository.provider === "github" || repository.provider === "forgejo" + ? repository.url + : repository.sshUrl; } export function sortAddProjectProviderSources( @@ -153,6 +160,7 @@ export function buildAddProjectRemoteSourceReadiness( url: { ready: true, hint: null }, github: unavailable, gitlab: unavailable, + forgejo: unavailable, bitbucket: unavailable, "azure-devops": unavailable, }; diff --git a/packages/client-runtime/src/threadPullRequestCompatibility.test.ts b/packages/client-runtime/src/threadPullRequestCompatibility.test.ts index 4ce682cca395..a8f444800a16 100644 --- a/packages/client-runtime/src/threadPullRequestCompatibility.test.ts +++ b/packages/client-runtime/src/threadPullRequestCompatibility.test.ts @@ -19,6 +19,24 @@ const input = { }; describe("thread pull request capability negotiation", () => { + it.each([true, false])( + "preserves Forgejo ports in a multi-link mutation: linked=%s", + (linked) => { + const mutation = planThreadPullRequestMutation({ + ...input, + linked, + capabilities: { threadPullRequests: true }, + reference: { + host: "forge.example", + repository: "team/repo", + number: 7, + url: "http://forge.example:3000/team/repo/pulls/7", + }, + }); + expect(mutation).toMatchObject({ input: { host: "forge.example:3000", number: 7 } }); + }, + ); + it.each([undefined, {}, { threadPullRequests: false, threadPullRequestLinking: false }])( "does not dispatch when linking is unadvertised: %j", (capabilities) => { diff --git a/packages/client-runtime/src/threadPullRequestCompatibility.ts b/packages/client-runtime/src/threadPullRequestCompatibility.ts index b747e7c7863b..4a31f82ec052 100644 --- a/packages/client-runtime/src/threadPullRequestCompatibility.ts +++ b/packages/client-runtime/src/threadPullRequestCompatibility.ts @@ -4,6 +4,7 @@ import type { ThreadId, ThreadPullRequestKey, } from "@t3tools/contracts"; +import { normalizeThreadPullRequestKey } from "@t3tools/shared/threadPullRequests"; type LinkingCapabilities = Pick< ExecutionEnvironmentCapabilities, @@ -36,20 +37,19 @@ export function planThreadPullRequestMutation({ legacyRepository?: string | undefined; linked: boolean; }) { + const key = normalizeThreadPullRequestKey(reference); switch (threadPullRequestLinkMode(capabilities)) { case "multiple": return linked ? { type: "thread.pull-request.link" as const, - input: { threadId, ...reference, source: "manual" as const }, + input: { threadId, ...reference, ...key, source: "manual" as const }, } : { type: "thread.pull-request.unlink" as const, input: { threadId, - host: reference.host, - repository: reference.repository, - number: reference.number, + ...key, }, }; case "single": diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index 10e4cda462dc..88e2661523a3 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -192,6 +192,8 @@ export type RepositoryIdentityLocator = typeof RepositoryIdentityLocator.Type; export const RepositoryIdentity = Schema.Struct({ canonicalKey: TrimmedNonEmptyString, locator: RepositoryIdentityLocator, + /** Repository browser URL resolved from the server's configured hosting account. */ + webUrl: Schema.optionalKey(TrimmedNonEmptyString), rootPath: Schema.optionalKey(TrimmedNonEmptyString), displayName: Schema.optionalKey(TrimmedNonEmptyString), provider: Schema.optionalKey(TrimmedNonEmptyString), diff --git a/packages/contracts/src/pullRequest.test.ts b/packages/contracts/src/pullRequest.test.ts index 599006994d00..f268e309ae65 100644 --- a/packages/contracts/src/pullRequest.test.ts +++ b/packages/contracts/src/pullRequest.test.ts @@ -7,6 +7,7 @@ import { PullRequestListInput, PullRequestListResult, PullRequestReviewerRequestInput, + pullRequestHostOf, resolvePullRequestAuthorFilter, } from "./pullRequest.ts"; @@ -65,6 +66,20 @@ const LIST_RESULT: PullRequestListResult = { }; describe("PullRequestListResult", () => { + it("separates Forgejo HTTP ports while preserving other provider host identities", () => { + const identity = { + canonicalKey: "forge.example/team/repo", + locator: { remoteUrl: "http://forge.example:3000/team/repo.git" }, + }; + expect(pullRequestHostOf(identity, "forgejo")).toBe("forge.example:3000"); + expect(pullRequestHostOf(identity, "gitlab")).toBe("forge.example"); + expect( + pullRequestHostOf( + { ...identity, locator: { remoteUrl: "ssh://git@forge.example:2222/team/repo.git" } }, + "forgejo", + ), + ).toBe("forge.example"); + }); /** * The RPC builds this codec at call time, so a shape it cannot lower — an open-keyed record * with an optional value, for one — fails as an interrupted request rather than as a schema diff --git a/packages/contracts/src/pullRequest.ts b/packages/contracts/src/pullRequest.ts index 44658bbc6c3e..0194521d0d89 100644 --- a/packages/contracts/src/pullRequest.ts +++ b/packages/contracts/src/pullRequest.ts @@ -1134,6 +1134,12 @@ const PROVIDER_REQUIREMENT: Partial< "GitHub CLI (`gh`) is required to browse change requests on this host. Install it from https://cli.github.com/ and reload.", unauthenticated: "GitHub CLI is not authenticated. Run `gh auth login` and retry.", }, + forgejo: { + missing: + "Install Forgejo CLI (`fj` 0.6 or later) from https://codeberg.org/forgejo-contrib/forgejo-cli or Gitea CLI (`tea` 0.16 or later) from https://gitea.com/gitea/tea to browse Forgejo pull requests.", + unauthenticated: + "Authenticate your Forgejo or Gitea server with `fj --host auth add-token` on the T3 Code server. If fj is missing or unconfigured for that server, use `tea login add`. A configured fj account must be repaired with fj.", + }, gitlab: { missing: "GitLab CLI (`glab`) is required to browse change requests on this host. Install it from https://gitlab.com/gitlab-org/cli and reload.", @@ -1161,9 +1167,24 @@ const PROVIDER_REQUIREMENT: Partial< * knows its hosts before the listing answers, and the two must agree on what they are called. */ export function pullRequestHostOf( - identity: { readonly canonicalKey?: string | undefined } | null | undefined, + identity: + | { + readonly canonicalKey?: string | undefined; + readonly locator?: { readonly remoteUrl: string } | undefined; + } + | null + | undefined, kind: SourceControlProviderKind, ): string { + if (kind === "forgejo") { + try { + const remote = new URL(identity?.locator?.remoteUrl ?? ""); + if (remote.protocol === "http:" || remote.protocol === "https:") + return remote.host.toLowerCase(); + } catch { + // SSH remotes retain their canonical host; the CLI resolves their web endpoint. + } + } const host = identity?.canonicalKey?.split("/")[0]?.trim(); return host === undefined || host.length === 0 ? kind : host.toLowerCase(); } diff --git a/packages/contracts/src/sourceControl.ts b/packages/contracts/src/sourceControl.ts index cc98fbc1bbcc..b0d2bd4a75bd 100644 --- a/packages/contracts/src/sourceControl.ts +++ b/packages/contracts/src/sourceControl.ts @@ -5,6 +5,7 @@ import { VcsDriverKind } from "./vcs.ts"; export const SourceControlProviderKind = Schema.Literals([ "github", "gitlab", + "forgejo", "azure-devops", "bitbucket", "unknown", diff --git a/packages/shared/src/changeRequestUrl.test.ts b/packages/shared/src/changeRequestUrl.test.ts index 2af718e453ff..68211c0faf5c 100644 --- a/packages/shared/src/changeRequestUrl.test.ts +++ b/packages/shared/src/changeRequestUrl.test.ts @@ -24,6 +24,15 @@ describe("parseChangeRequestUrl", () => { }); }); + it("reads Forgejo URLs even when the hostname contains github", () => { + expect(parseChangeRequestUrl("https://github.internal/team/repo/pulls/7")).toEqual({ + host: "github.internal", + authority: "github.internal", + repository: "team/repo", + number: 7, + }); + }); + it("reads a supported GitHub host with a middle DNS label", () => { expect(parseChangeRequestUrl("https://code.github.example.com/acme/web/pull/42")).toEqual({ host: "code.github.example.com", @@ -90,7 +99,23 @@ describe("parseChangeRequestUrl", () => { }); describe("siblingPullRequestUrl", () => { + it("recognizes Forgejo on custom HTTP hosts", () => { + expect(parseChangeRequestUrl("http://git.example.test:3000/team/repo/pulls/42/files")).toEqual({ + host: "git.example.test", + authority: "git.example.test:3000", + repository: "team/repo", + number: 42, + }); + }); it.each([ + [ + "http://git.example.test:3000/team/repo/pulls/42/files", + "http://git.example.test:3000/team/repo/pulls/43", + ], + [ + "https://git.example.test/forgejo/team/repo/pulls/42/files", + "https://git.example.test/forgejo/team/repo/pulls/43", + ], ["https://github.com/pull/1/pull/42/files", "https://github.com/pull/1/pull/43"], [ "https://git.acme.test/team/merge_requests/1/repo/-/merge_requests/42/diffs", @@ -125,6 +150,29 @@ describe("siblingPullRequestUrl", () => { }); describe("changeRequestUrlFor", () => { + it("preserves the origin when the Forgejo host already contains its port", () => { + expect( + changeRequestUrlFor( + "forgejo", + "forge.example:3000", + "team/repo", + 42, + "http://forge.example:3000/team/repo.git", + ), + ).toBe("http://forge.example:3000/team/repo/pulls/42"); + }); + + it.each([ + ["http://forge.example:3000/git/owner/repo.git", "http://forge.example:3000"], + ["https://forge.example:8443/git/owner/repo.git", "https://forge.example:8443"], + ["git@forge.example:git/owner/repo.git", "https://forge.example"], + ["http://other.example:3000/git/owner/repo.git", "https://forge.example"], + ])("preserves the matching Forgejo web origin from %s", (remoteUrl, origin) => { + expect(changeRequestUrlFor("forgejo", "forge.example", "git/owner/repo", 42, remoteUrl)).toBe( + `${origin}/git/owner/repo/pulls/42`, + ); + }); + it.each([ ["ssh.dev.azure.com", "v3/org/project/web"], ["vs-ssh.visualstudio.com", "v3/org/project/web"], diff --git a/packages/shared/src/changeRequestUrl.ts b/packages/shared/src/changeRequestUrl.ts index 0687afd38eba..5be8d355a166 100644 --- a/packages/shared/src/changeRequestUrl.ts +++ b/packages/shared/src/changeRequestUrl.ts @@ -13,6 +13,8 @@ export interface ChangeRequestLink { readonly host: string; readonly repository: string; readonly number: number; + /** Forgejo's HTTP host and port, separate from the portless repository identity. */ + readonly authority?: string; } /** The host itself, one of its subdomains, or an install named after the provider. */ @@ -49,7 +51,13 @@ export function parseChangeRequestUrl(targetUrl: string): ChangeRequestLink | nu // GitHub, and any Enterprise install: /{owner}/{repo}/pull/{n} if (isHostOf(host, "github.com", "github")) { const match = /^\/([^/]+\/[^/]+)\/pull\/(\d+)(?:\/|$)/u.exec(url.pathname); - return claim(host, match); + if (match) return claim(host, match); + } + // Forgejo and Gitea use /pulls/ on arbitrary self-hosted domains. + const forgejo = /^\/([^/]+(?:\/[^/]+)+)\/pulls\/(\d+)(?:\/|$)/u.exec(url.pathname); + if (forgejo) { + const link = claim(host, forgejo); + return link === null ? null : { ...link, authority: url.host.toLowerCase() }; } // GitLab, self-hosted included: /{group}/[{subgroup}/...]{repo}/-/merge_requests/{n}. The `/-/` // separator is GitLab's own, so the hostname is not asked about. @@ -83,10 +91,26 @@ export function changeRequestUrlFor( host: string, repository: string, number: number, + remoteUrl?: string, ): string | null { switch (kind) { case "github": return `https://${host}/${repository}/pull/${number}`; + case "forgejo": { + try { + const remote = new URL(remoteUrl ?? ""); + if ( + (remote.protocol === "http:" || remote.protocol === "https:") && + (remote.hostname.toLowerCase() === host.toLowerCase() || + remote.host.toLowerCase() === host.toLowerCase()) + ) { + return `${remote.origin}/${repository}/pulls/${number}`; + } + } catch { + // SSH remotes do not specify the server's web origin. + } + return `https://${host}/${repository}/pulls/${number}`; + } case "gitlab": return `https://${host}/${repository}/-/merge_requests/${number}`; case "bitbucket": @@ -174,7 +198,8 @@ export function matchesLinkedPullRequestUrl( target !== null && linked.host === target.host && linked.repository === target.repository && - linked.number === target.number + linked.number === target.number && + linked.authority === target.authority ); } @@ -185,7 +210,7 @@ export function changeRequestRepositoryUrl(targetUrl: string): string | null { const url = new URL(targetUrl); const repositoryPath = /^(.*?)\/-\/merge_requests\/\d+(?:\/|$)/iu.exec(url.pathname)?.[1] ?? - /^(.*?)(?:\/pull\/\d+|\/-\/merge_requests\/\d+|\/pull-requests\/\d+|\/pullrequest\/\d+)(?:\/|$)/iu.exec( + /^(.*?)(?:\/pulls?\/\d+|\/-\/merge_requests\/\d+|\/pull-requests\/\d+|\/pullrequest\/\d+)(?:\/|$)/iu.exec( url.pathname, )?.[1]; if (!repositoryPath) return null; @@ -199,7 +224,7 @@ export function siblingPullRequestUrl(url: string, number: number): string | nul const reference = parseChangeRequestUrl(url); if (reference === null || !Number.isSafeInteger(number) || number < 1) return null; const sibling = new URL(url); - const route = /^\/(-\/merge_requests|pull|pull-requests|pullrequest)\/\d+(?:\/|$)/u.exec( + const route = /^\/(-\/merge_requests|pulls?|pull-requests|pullrequest)\/\d+(?:\/|$)/u.exec( sibling.pathname.slice(reference.repository.length + 1), )?.[1]; if (route === undefined) return null; diff --git a/packages/shared/src/sourceControl.test.ts b/packages/shared/src/sourceControl.test.ts index 6a60d6f9b38d..f9c009fac044 100644 --- a/packages/shared/src/sourceControl.test.ts +++ b/packages/shared/src/sourceControl.test.ts @@ -59,6 +59,22 @@ describe("detectSourceControlProviderFromRemoteUrl", () => { ).toBe("bitbucket"); }); + it("detects Forgejo and Gitea hosts while preserving HTTP origins", () => { + for (const host of ["codeberg.org", "forgejo.example.test", "gitea.example.test"]) { + expect(detectSourceControlProviderFromRemoteUrl(`http://${host}:3000/team/repo.git`)).toEqual( + { + kind: "forgejo", + name: "Forgejo", + baseUrl: `http://${host}:3000`, + }, + ); + } + expect(getChangeRequestTerminologyForKind("forgejo")).toEqual({ + shortLabel: "PR", + singular: "pull request", + }); + }); + it("detects Azure DevOps SSH remotes", () => { // The default Azure DevOps SSH clone URL uses the ssh.dev.azure.com host. expect( diff --git a/packages/shared/src/sourceControl.ts b/packages/shared/src/sourceControl.ts index 9f1dc384e749..0b2e0e2699c6 100644 --- a/packages/shared/src/sourceControl.ts +++ b/packages/shared/src/sourceControl.ts @@ -5,7 +5,7 @@ import type { } from "@t3tools/contracts"; export interface ChangeRequestPresentation { - readonly icon: "github" | "gitlab" | "azure-devops" | "bitbucket" | "change-request"; + readonly icon: "github" | "gitlab" | "forgejo" | "azure-devops" | "bitbucket" | "change-request"; readonly providerName: string; readonly shortName: string; readonly longName: string; @@ -47,6 +47,17 @@ const GITLAB_CHANGE_REQUEST_PRESENTATION: ChangeRequestPresentation = { urlExample: "https://gitlab.com/group/project/-/merge_requests/42", }; +const FORGEJO_CHANGE_REQUEST_PRESENTATION: ChangeRequestPresentation = { + icon: "forgejo", + providerName: "Forgejo", + shortName: "PR", + longName: "pull request", + pluralLongName: "pull requests", + providerLongName: "Forgejo pull request", + checkoutCommandExample: "tea pr checkout 123", + urlExample: "https://codeberg.org/owner/repo/pulls/42", +}; + const AZURE_DEVOPS_CHANGE_REQUEST_PRESENTATION: ChangeRequestPresentation = { icon: "azure-devops", providerName: "Azure DevOps", @@ -87,6 +98,8 @@ export function resolveChangeRequestPresentation( return GITHUB_CHANGE_REQUEST_PRESENTATION; case "gitlab": return GITLAB_CHANGE_REQUEST_PRESENTATION; + case "forgejo": + return FORGEJO_CHANGE_REQUEST_PRESENTATION; case "azure-devops": return AZURE_DEVOPS_CHANGE_REQUEST_PRESENTATION; case "bitbucket": @@ -200,6 +213,20 @@ export function detectSourceControlProviderFromRemoteUrl( } const hostname = parseHostName(host); + if ( + hostname === "codeberg.org" || + hasDnsLabel(hostname, "forgejo") || + hasDnsLabel(hostname, "gitea") + ) { + return { + kind: "forgejo", + name: "Forgejo", + baseUrl: /^https?:/iu.test(remoteUrl.trim()) + ? new URL(remoteUrl.trim()).origin + : toBaseUrl(host), + }; + } + if (isGitHubHost(hostname)) { return { kind: "github", diff --git a/packages/shared/src/threadPullRequests.test.ts b/packages/shared/src/threadPullRequests.test.ts index 6f231f28989b..4844df8f7786 100644 --- a/packages/shared/src/threadPullRequests.test.ts +++ b/packages/shared/src/threadPullRequests.test.ts @@ -62,6 +62,28 @@ function link( } describe("threadPullRequestKeysEqual", () => { + it("recovers Forgejo ports from old stored URLs and keeps separate servers distinct", () => { + const old = link(1, { + host: "forge.example", + repository: "team/repo", + url: "http://forge.example:3000/team/repo/pulls/1", + }); + expect(threadPullRequestKeysEqual(old, { ...old, host: "forge.example:3000" })).toBe(true); + expect( + threadPullRequestKeysEqual(old, { + host: "forge.example:3000", + repository: "team/repo", + number: 1, + }), + ).toBe(true); + expect( + threadPullRequestKeysEqual(old, { + ...old, + url: "http://forge.example:4000/team/repo/pulls/1", + }), + ).toBe(false); + }); + it("ignores host and repository case", () => { expect( threadPullRequestKeysEqual( diff --git a/packages/shared/src/threadPullRequests.ts b/packages/shared/src/threadPullRequests.ts index 55e23027832d..e747c151bd1c 100644 --- a/packages/shared/src/threadPullRequests.ts +++ b/packages/shared/src/threadPullRequests.ts @@ -10,10 +10,23 @@ import { pullRequestHostOf } from "@t3tools/contracts"; import { parseChangeRequestUrl } from "./changeRequestUrl.ts"; import { canonicalRepositoryKey, sourceControlRepositorySelector } from "./sourceControl.ts"; -/** Normalize stored link identity, including Azure's SSH and browser host aliases. */ -export function normalizeThreadPullRequestKey(key: ThreadPullRequestKey): ThreadPullRequestKey { +type ThreadPullRequestKeySource = ThreadPullRequestKey & { + readonly authority?: string; + readonly url?: string; +}; + +/** Normalize stored links, recovering Forgejo HTTP ports from old links' URLs. */ +export function normalizeThreadPullRequestKey( + key: ThreadPullRequestKeySource, +): ThreadPullRequestKey { + const parsed = key.url === undefined ? null : parseChangeRequestUrl(key.url); + const authority = + key.authority ?? + (parsed?.repository === key.repository.trim().toLowerCase() && parsed.number === key.number + ? parsed.authority + : undefined); const canonical = canonicalRepositoryKey( - `${key.host.trim().toLowerCase()}/${key.repository.trim().toLowerCase()}`, + `${(authority ?? key.host).trim().toLowerCase()}/${key.repository.trim().toLowerCase()}`, ); const separator = canonical.indexOf("/"); return { @@ -31,7 +44,7 @@ export function legacyThreadPullRequestKey( const parsed = parseChangeRequestUrl(linked.url); if (parsed !== null && parsed.number === linked.number) { const canonical = canonicalRepositoryKey(`${parsed.host}/${parsed.repository}`); - if (canonical.startsWith("dev.azure.com/")) { + if (parsed.authority !== undefined || canonical.startsWith("dev.azure.com/")) { return normalizeThreadPullRequestKey(parsed); } } @@ -52,13 +65,13 @@ export function legacyThreadPullRequestKey( /** Identity comparison for links: host-level, case-insensitive on host and repository. */ export function threadPullRequestKeysEqual( - left: ThreadPullRequestKey, - right: ThreadPullRequestKey, + left: ThreadPullRequestKeySource, + right: ThreadPullRequestKeySource, ): boolean { return threadPullRequestKeyOf(left) === threadPullRequestKeyOf(right); } -export function threadPullRequestKeyOf(key: ThreadPullRequestKey): string { +export function threadPullRequestKeyOf(key: ThreadPullRequestKeySource): string { const normalized = normalizeThreadPullRequestKey(key); return `${normalized.host}/${normalized.repository}#${normalized.number}`; } @@ -157,6 +170,20 @@ export function legacyLinkedPullRequestOf( const key = legacyThreadPullRequestKey(link, link.host); return canonicalRepositoryKey(`${key.host}/${key.repository}`) === azureKey; } + const parsed = parseChangeRequestUrl(link.url); + if (parsed?.authority !== undefined) { + try { + const remote = new URL(identity.locator.remoteUrl); + if (remote.protocol === "http:" || remote.protocol === "https:") { + return ( + parsed.authority === remote.host && parsed.repository === repository.toLowerCase() + ); + } + } catch { + // SSH web ports are resolved by the provider's configured login. + } + return parsed.host === host && parsed.repository === repository.toLowerCase(); + } return ( link.host.toLowerCase() === host.toLowerCase() && link.repository.toLowerCase() === repository.toLowerCase() @@ -193,7 +220,8 @@ export function resolveThreadPullRequestChains( const nativeStacks = new Map>(); for (const link of visible) { if (link.stack === null) continue; - const stackKey = `${link.host.toLowerCase()}/${link.repository.toLowerCase()}#stack:${link.stack.id}`; + const key = normalizeThreadPullRequestKey(link); + const stackKey = `${key.host}/${key.repository}#stack:${link.stack.id}`; const members = nativeStacks.get(stackKey) ?? []; members.push(link); nativeStacks.set(stackKey, members); @@ -206,8 +234,10 @@ export function resolveThreadPullRequestChains( } const remaining = visible.filter((link) => !placed.has(threadPullRequestKeyOf(link))); - const branchKey = (link: ThreadPullRequestLink, branch: string) => - `${link.host.toLowerCase()}/${link.repository.toLowerCase()}:${branch}`; + const branchKey = (link: ThreadPullRequestLink, branch: string) => { + const key = normalizeThreadPullRequestKey(link); + return `${key.host}/${key.repository}:${branch}`; + }; // Reused head names cannot identify a parent unambiguously. const byHead = new Map(); for (const link of remaining) { From 0c5771d60a8ef2db34dfbbc142f7524badc829e0 Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Sat, 12 Sep 2026 22:34:18 -0500 Subject: [PATCH 04/15] fix(web): match draft row heights to thread rows (#11512) --- 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 96f059fb23ea..e7a55e16e0cd 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -755,7 +755,7 @@ const SidebarDraftRow = memo(function SidebarDraftRow(props: { onClick={handleActivate} onKeyDown={handleKeyDown} > -
+
{props.project ? ( From 3138f5716098a331f9a7d4cfc1bcd07118967a83 Mon Sep 17 00:00:00 2001 From: Svyk <152941963+Svyk@users.noreply.github.com> Date: Sat, 12 Sep 2026 21:29:23 -0700 Subject: [PATCH 05/15] fix(grok): emit task lifecycle for monitors and background shells (#9139) Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com> --- apps/server/scripts/acp-mock-agent.ts | 104 +++++++++ .../src/provider/Layers/GrokAdapter.test.ts | 64 +++++- .../server/src/provider/Layers/GrokAdapter.ts | 25 +++ .../provider/acp/XAiBackgroundTasks.test.ts | 202 ++++++++++++++++++ .../src/provider/acp/XAiBackgroundTasks.ts | 156 ++++++++++++++ 5 files changed, 549 insertions(+), 2 deletions(-) create mode 100644 apps/server/src/provider/acp/XAiBackgroundTasks.test.ts create mode 100644 apps/server/src/provider/acp/XAiBackgroundTasks.ts diff --git a/apps/server/scripts/acp-mock-agent.ts b/apps/server/scripts/acp-mock-agent.ts index c923fa401021..e4fd848ab6e1 100644 --- a/apps/server/scripts/acp-mock-agent.ts +++ b/apps/server/scripts/acp-mock-agent.ts @@ -30,6 +30,8 @@ const emitXAiAskUserQuestionThenHang = const emitContentThenHang = process.env.T3_ACP_EMIT_CONTENT_THEN_HANG === "1"; const emitPlanThenHang = process.env.T3_ACP_EMIT_PLAN_THEN_HANG === "1"; const emitActiveToolThenHang = process.env.T3_ACP_EMIT_ACTIVE_TOOL_THEN_HANG === "1"; +const emitGrokMonitorPostTurnPoll = process.env.T3_ACP_EMIT_GROK_MONITOR_POST_TURN_POLL === "1"; +const emitGrokBackgroundTaskStarted = process.env.T3_ACP_EMIT_GROK_BACKGROUND_TASK_STARTED === "1"; const emitForeignSessionUpdates = process.env.T3_ACP_EMIT_FOREIGN_SESSION_UPDATES === "1"; const waitForResumeRelease = process.env.T3_ACP_WAIT_FOR_RESUME_RELEASE === "1"; const completeFirstPromptOnCancel = process.env.T3_ACP_COMPLETE_FIRST_PROMPT_ON_CANCEL === "1"; @@ -835,6 +837,108 @@ const program = Effect.gen(function* () { return yield* Effect.never; } + if (emitGrokMonitorPostTurnPoll) { + const monitorCallId = "call-monitor-1"; + const pollCallId = "call-monitor-poll-1"; + const taskId = "01a05f41-5107-7550-821e-79e8d1cd7687"; + const description = "Watch count-sheet Typst unit until done"; + writeJsonRpcNotification("session/update", { + sessionId: requestedSessionId, + update: { + sessionUpdate: "tool_call", + toolCallId: monitorCallId, + title: "monitor", + kind: "other", + status: "pending", + rawInput: { description }, + _meta: { + "x.ai/tool": { version: 1, name: "monitor", kind: "task", namespace: "grok_build" }, + }, + }, + }); + writeJsonRpcNotification("session/update", { + sessionId: requestedSessionId, + update: { + sessionUpdate: "tool_call_update", + toolCallId: monitorCallId, + status: "completed", + rawInput: { description }, + rawOutput: { + type: "Monitor", + taskId, + timeoutMs: 36_000_000, + }, + }, + }); + writeJsonRpcNotification("_x.ai/session/prompt_complete", { + sessionId: requestedSessionId, + promptId: promptIdFromRequestMeta(request) ?? "mock-xai-prompt-1", + stopReason: "end_turn", + agentResult: null, + }); + yield* Effect.sleep("120 millis"); + writeJsonRpcNotification("session/update", { + sessionId: requestedSessionId, + update: { + sessionUpdate: "tool_call", + toolCallId: pollCallId, + title: "get_command_or_subagent_output", + kind: "other", + status: "completed", + rawInput: { variant: "TaskOutput", task_ids: [taskId], timeout_ms: 0 }, + rawOutput: { + type: "TaskOutput", + Result: { + task_id: taskId, + command: `[monitor] ${description}`, + status: "completed", + exit_code: 0, + output: "Monitor finished.", + }, + }, + }, + }); + return yield* Effect.never; + } + + if (emitGrokBackgroundTaskStarted) { + const toolCallId = "call-fb9d0000-0000-0000-0000-000000000026"; + const command = "sleep 40; echo done-a"; + writeJsonRpcNotification("session/update", { + sessionId: requestedSessionId, + update: { + sessionUpdate: "tool_call", + toolCallId, + title: "run_terminal_command", + kind: "execute", + status: "in_progress", + rawInput: { command }, + }, + }); + writeJsonRpcNotification("session/update", { + sessionId: requestedSessionId, + update: { + sessionUpdate: "tool_call_update", + toolCallId, + status: "completed", + rawOutput: { + type: "BackgroundTaskStarted", + task_id: toolCallId, + task_type: "bash", + status: "running", + command, + }, + }, + }); + writeJsonRpcNotification("_x.ai/session/prompt_complete", { + sessionId: requestedSessionId, + promptId: promptIdFromRequestMeta(request) ?? "mock-xai-prompt-1", + stopReason: "end_turn", + agentResult: null, + }); + return yield* Effect.never; + } + if (emitInterleavedAssistantToolCalls) { const toolCallId = "tool-call-1"; diff --git a/apps/server/src/provider/Layers/GrokAdapter.test.ts b/apps/server/src/provider/Layers/GrokAdapter.test.ts index 9efb28d80628..545a6a094e4e 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.test.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.test.ts @@ -24,8 +24,10 @@ import { TurnId, type ProviderRuntimeEvent, } from "@t3tools/contracts"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { ServerConfig } from "../../config.ts"; +import { execScriptSource, writeFakeCli } from "../../testUtils/fakeCli.ts"; import { grokPromptSettlementBelongsToContext, isGrokEnterPlanModeToolCall, @@ -33,8 +35,7 @@ import { nextGrokPlanModeActive, selectGrokPermissionOptionId, } from "./GrokAdapter.ts"; -import { execScriptSource, writeFakeCli } from "../../testUtils/fakeCli.ts"; -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; + const decodeGrokSettings = Schema.decodeSync(GrokSettings); const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); @@ -232,6 +233,65 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => { }), ); + for (const taskType of ["monitor", "shell"] as const) { + it.effect(`emits the ${taskType} background lifecycle`, () => + Effect.gen(function* () { + const threadId = ThreadId.make(`grok-background-${taskType}`); + const wrapperPath = yield* Effect.promise(() => + makeMockGrokWrapper({ + [taskType === "monitor" + ? "T3_ACP_EMIT_GROK_MONITOR_POST_TURN_POLL" + : "T3_ACP_EMIT_GROK_BACKGROUND_TASK_STARTED"]: "1", + }), + ); + const adapter = yield* makeTestAdapter(wrapperPath); + const events: ProviderRuntimeEvent[] = []; + const finished = yield* Deferred.make(); + const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.gen(function* () { + events.push(event); + if (event.type === (taskType === "monitor" ? "task.completed" : "turn.completed")) { + yield* Deferred.succeed(finished, undefined); + } + }), + ).pipe(Effect.forkChild); + yield* adapter.startSession({ threadId, cwd: process.cwd(), runtimeMode: "full-access" }); + yield* adapter.sendTurn({ threadId, input: "watch the unit" }); + yield* Deferred.await(finished).pipe(Effect.timeout("3 seconds")); + + const started = events.find((event) => event.type === "task.started"); + const taskId = + taskType === "monitor" + ? "01a05f41-5107-7550-821e-79e8d1cd7687" + : "call-fb9d0000-0000-0000-0000-000000000026"; + assert.equal(started?.payload.taskType, taskType); + assert.equal(started?.payload.taskId, taskId); + if (taskType === "monitor") { + const completed = events.find((event) => event.type === "task.completed"); + const turnEnd = events.findIndex((event) => event.type === "turn.completed"); + assert.equal(completed?.payload.status, "completed"); + assert.equal(completed?.payload.taskId, taskId); + assert.equal(completed?.turnId, undefined); + assert.isAtLeast(turnEnd, 0); + assert.isAbove( + events.findIndex((event) => event.type === "task.completed"), + turnEnd, + ); + assert.deepEqual( + events + .slice(turnEnd + 1) + .filter((event) => event.type === "item.updated" || event.type === "item.completed"), + [], + ); + } else { + assert.equal(started?.payload.description, "sleep 40; echo done-a"); + } + yield* Fiber.interrupt(eventsFiber); + yield* adapter.stopSession(threadId); + }).pipe(TestClock.withLive), + ); + } + it.effect("sends runtime context with the current model without changing saved prompts", () => Effect.gen(function* () { const threadId = ThreadId.make("grok-runtime-context"); diff --git a/apps/server/src/provider/Layers/GrokAdapter.ts b/apps/server/src/provider/Layers/GrokAdapter.ts index 395d7e546f9a..01ec3d118a3e 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.ts @@ -68,6 +68,10 @@ import { normalizeGrokReasoningEffort, resolveGrokAcpBaseModelId, } from "../acp/GrokAcpSupport.ts"; +import { + buildGrokBackgroundTaskEvents, + type GrokBackgroundTaskRecord, +} from "../acp/XAiBackgroundTasks.ts"; import { extractGrokPlanMarkdownFromToolCallData, extractXAiAskUserQuestions, @@ -171,6 +175,8 @@ interface GrokSessionContext { currentModelId: string | undefined; currentReasoningEffort: string | undefined; stopped: boolean; + /** Live monitor/shell identities and their originating turns. */ + readonly backgroundTasks: Map; } function settlePendingApprovalsAsCancelled( @@ -1309,6 +1315,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte ? normalizeGrokReasoningEffort(requestedStartReasoningEffort) : currentStartReasoningEffort, stopped: false, + backgroundTasks: new Map(), }; const nf = yield* Stream.runDrain( @@ -1331,6 +1338,24 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte } const notificationTurnId = resolveNotificationTurnId(ctx); + if (event._tag === "ToolCallUpdated" && !ctx.stopped) { + for (const taskEvent of buildGrokBackgroundTaskEvents({ + tasks: ctx.backgroundTasks, + toolCallId: event.toolCall.toolCallId, + rawInput: event.toolCall.data.rawInput, + rawOutput: event.toolCall.data.rawOutput, + toolCallStatus: event.toolCall.status, + turnId: notificationTurnId, + })) { + yield* offerRuntimeEvent({ + ...taskEvent, + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: ctx.threadId, + }); + } + } + if ( notificationTurnId === undefined || ctx.interruptedTurnIds.has(notificationTurnId) diff --git a/apps/server/src/provider/acp/XAiBackgroundTasks.test.ts b/apps/server/src/provider/acp/XAiBackgroundTasks.test.ts new file mode 100644 index 000000000000..93227b260481 --- /dev/null +++ b/apps/server/src/provider/acp/XAiBackgroundTasks.test.ts @@ -0,0 +1,202 @@ +import { describe, expect, it } from "vite-plus/test"; +import { TurnId } from "@t3tools/contracts"; + +import { + buildGrokBackgroundTaskEvents, + type GrokBackgroundTaskRecord, +} from "./XAiBackgroundTasks.ts"; + +const turnId = TurnId.make("turn-1"); +const monitor = { type: "Monitor", taskId: "monitor-1", timeoutMs: 60_000 }; +const shell = { type: "BackgroundTaskStarted", task_id: "shell-1", command: "sleep 40" }; + +function mapper() { + const tasks = new Map(); + const update = ( + rawOutput: unknown, + overrides: Partial[0]> = {}, + ) => + buildGrokBackgroundTaskEvents({ + tasks, + toolCallId: "call-1", + rawInput: { description: "Watch" }, + rawOutput, + toolCallStatus: "completed", + turnId, + ...overrides, + }); + return { tasks, update }; +} + +describe("Grok background tasks", () => { + it.each([ + [monitor, "monitor-1", "monitor", "Watch"], + [shell, "shell-1", "shell", "sleep 40"], + ] as const)("starts and deduplicates %j", (output, id, taskType, description) => { + const { tasks, update } = mapper(); + expect(update(output)).toEqual([ + { + type: "task.started", + turnId, + payload: { taskId: id, taskType, description, title: description, toolUseId: "call-1" }, + }, + ]); + expect(update(output)).toEqual([]); + expect(tasks.size).toBe(1); + }); + + it("accepts automatic backgrounding before the tool becomes terminal", () => { + const { update } = mapper(); + expect(update(shell, { toolCallStatus: "inProgress" })[0]?.type).toBe("task.started"); + expect(update(monitor, { toolCallStatus: "inProgress" })).toEqual([]); + expect(update(monitor, { toolCallStatus: "failed" })).toEqual([]); + }); + + it.each([ + ["running", null, "task.progress", undefined], + ["pending", null, "task.progress", undefined], + ["completed", 0, "task.completed", "completed"], + ["success", 0, "task.completed", "completed"], + ["succeeded", 0, "task.completed", "completed"], + ["failed", 1, "task.completed", "failed"], + ["error", 1, "task.completed", "failed"], + ["stopped", 137, "task.completed", "stopped"], + ["killed", 137, "task.completed", "stopped"], + ["cancelled", 137, "task.completed", "stopped"], + [undefined, 0, "task.completed", "completed"], + [undefined, 1, "task.completed", "failed"], + ])("maps poll status %s / exit %s", (status, exit_code, type, expectedStatus) => { + const { tasks, update } = mapper(); + update(monitor); + const events = update({ + type: "TaskOutput", + Result: { + task_id: "monitor-1", + command: "[monitor:Watch]", + status, + exit_code, + output: "\n result\nmore", + }, + }); + expect(events).toEqual([ + { + type, + turnId, + payload: { + taskId: "monitor-1", + taskType: "monitor", + description: "Watch", + title: "Watch", + toolUseId: "call-1", + summary: "result", + ...(expectedStatus ? { status: expectedStatus } : {}), + }, + }, + ]); + expect(tasks.size).toBe(type === "task.progress" ? 1 : 0); + }); + + it.each([undefined, TurnId.make("turn-2")])( + "does not attribute old tasks to a later turn: %s", + (laterTurnId) => { + const { tasks, update } = mapper(); + update(shell); + const events = update( + { + type: "TaskOutput", + Result: { task_id: "shell-1", command: "sleep 40", status: "completed" }, + }, + { turnId: laterTurnId }, + ); + expect(events).toHaveLength(1); + expect(events[0]?.turnId).toBeUndefined(); + expect(tasks.size).toBe(0); + }, + ); + + it("starts unknown poll tasks before progress or completion and ignores subagents", () => { + const { tasks, update } = mapper(); + const events = update({ + type: "TaskOutput", + MultiResult: { + results: [ + { task_id: "shell-1", command: "sleep 40", status: "running" }, + { task_id: "monitor-1", command: "[monitor] Watch", status: "completed" }, + { task_id: "agent-1", command: "[subagent:executor] work", status: "running" }, + ], + }, + }); + expect(events.map(({ type }) => type)).toEqual([ + "task.started", + "task.progress", + "task.started", + "task.completed", + ]); + expect(events.map(({ payload }) => payload.taskType)).toEqual([ + "shell", + "shell", + "monitor", + "monitor", + ]); + expect([...tasks.keys()]).toEqual(["shell-1"]); + expect(events.map((event) => event.turnId)).toEqual([ + undefined, + undefined, + undefined, + undefined, + ]); + }); + + it("retires only successfully killed tasks, including mixed results", () => { + const { tasks, update } = mapper(); + update(shell); + update(monitor); + const result = { type: "KillTask", Result: { task_id: "shell-1", outcome: "killed" } }; + expect(update(result, { toolCallStatus: "failed" })).toEqual([]); + expect(tasks.size).toBe(2); + const events = update({ + type: "KillTask", + MultiResult: { + results: [ + result.Result, + { task_id: "monitor-1", outcome: "error" }, + { task_id: "unknown", outcome: "killed" }, + ], + }, + }); + expect(events).toEqual([ + { + type: "task.completed", + turnId, + payload: { + taskId: "shell-1", + taskType: "shell", + description: "sleep 40", + title: "sleep 40", + toolUseId: "call-1", + status: "stopped", + }, + }, + ]); + expect([...tasks.keys()]).toEqual(["monitor-1"]); + }); + + it.each([ + null, + [], + {}, + { type: "Text", text: "subagent_id: fake\ntype: executor\ndescription: fake" }, + { type: "Monitor", taskId: " " }, + { type: "BackgroundTaskStarted", task_id: "shell-1" }, + { + type: "TaskOutput", + MultiResult: { + results: [null, {}, { task_id: "task", command: "sleep 40", exit_code: Infinity }], + }, + }, + ])("ignores malformed or unrelated outputs: %j", (output) => { + const { tasks, update } = mapper(); + expect(update(output)).toEqual([]); + expect(tasks.size).toBe(0); + }); +}); diff --git a/apps/server/src/provider/acp/XAiBackgroundTasks.ts b/apps/server/src/provider/acp/XAiBackgroundTasks.ts new file mode 100644 index 000000000000..beca3472255a --- /dev/null +++ b/apps/server/src/provider/acp/XAiBackgroundTasks.ts @@ -0,0 +1,156 @@ +import { + RuntimeTaskId, + type ProviderRuntimeTaskStartedEvent, + type ProviderRuntimeTaskProgressEvent, + type ProviderRuntimeTaskCompletedEvent, + type TurnId, +} from "@t3tools/contracts"; + +type TaskEvent = + | Pick + | Pick + | Pick; + +export interface GrokBackgroundTaskRecord { + readonly payload: { + readonly taskId: RuntimeTaskId; + readonly taskType: "monitor" | "shell"; + readonly description: string; + readonly title: string; + readonly toolUseId?: string; + }; + readonly turnId: TurnId | undefined; +} + +function record(value: unknown): Record { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as Record) + : {}; +} + +function text(value: unknown): string | undefined { + return typeof value === "string" ? value.trim() || undefined : undefined; +} + +function lifecycle(status: unknown, exitCode: unknown) { + switch (text(status)?.toLowerCase()) { + case "pending": + case "running": + return "running"; + case "completed": + case "success": + case "succeeded": + return "completed"; + case "failed": + case "error": + return "failed"; + case "stopped": + case "killed": + case "cancelled": + return "stopped"; + default: + return typeof exitCode === "number" && Number.isFinite(exitCode) + ? exitCode === 0 + ? "completed" + : "failed" + : undefined; + } +} + +/** Map Grok's discriminated tool results, including notifications after the turn ends. */ +export function buildGrokBackgroundTaskEvents(input: { + readonly tasks: Map; + readonly toolCallId: string; + readonly rawInput: unknown; + readonly rawOutput: unknown; + readonly toolCallStatus: string | undefined; + readonly turnId?: TurnId | undefined; +}): TaskEvent[] { + const { tasks, toolCallId, toolCallStatus, turnId } = input; + const output = record(input.rawOutput); + const events: TaskEvent[] = []; + if ( + toolCallStatus !== "completed" && + toolCallStatus !== "failed" && + output.type !== "BackgroundTaskStarted" + ) { + return events; + } + const attribution = (task: GrokBackgroundTaskRecord) => + task.turnId !== undefined && task.turnId === turnId ? { turnId } : {}; + const start = ( + id: string, + taskType: "monitor" | "shell", + description: string, + toolUseId?: string, + ) => { + const known = tasks.get(id); + if (known) return known; + const task: GrokBackgroundTaskRecord = { + payload: { + taskId: RuntimeTaskId.make(id), + taskType, + description, + title: description, + ...(toolUseId ? { toolUseId } : {}), + }, + // Polls can rediscover older tasks without establishing their originating turn. + turnId: toolUseId ? turnId : undefined, + }; + tasks.set(id, task); + events.push({ type: "task.started", payload: task.payload, ...attribution(task) }); + return task; + }; + const complete = ( + task: GrokBackgroundTaskRecord, + status: "completed" | "failed" | "stopped", + summary?: string, + ) => { + tasks.delete(task.payload.taskId); + events.push({ + type: "task.completed", + payload: { ...task.payload, status, ...(summary ? { summary } : {}) }, + ...attribution(task), + }); + }; + + if (output.type === "Monitor" && toolCallStatus === "completed") { + const id = text(output.taskId); + if (id) start(id, "monitor", text(record(input.rawInput).description) ?? "Monitor", toolCallId); + } else if (output.type === "BackgroundTaskStarted") { + const id = text(output.task_id) ?? text(output.taskId); + const command = text(output.command); + if (id && command) start(id, "shell", command.split("\n")[0]!.slice(0, 200), toolCallId); + } else if (output.type === "TaskOutput" || output.type === "KillTask") { + const results = record(output.MultiResult).results; + for (const value of Array.isArray(results) ? results : [output.Result]) { + const result = record(value); + const id = text(result.task_id); + if (!id) continue; + if (output.type === "KillTask") { + const task = tasks.get(id); + if (task && toolCallStatus === "completed" && result.outcome === "killed") + complete(task, "stopped"); + continue; + } + const command = text(result.command); + const status = lifecycle(result.status, result.exit_code); + if (!command || !status || command.startsWith("[subagent:")) continue; + const task = start(id, /^\[monitor[:\]]/.test(command) ? "monitor" : "shell", command); + const summary = text(result.output) + ?.split("\n") + .find((line) => line.trim()) + ?.trim(); + if (status === "running") { + events.push({ + type: "task.progress", + payload: { ...task.payload, ...(summary ? { summary } : {}) }, + ...attribution(task), + }); + } else { + complete(task, status, summary); + } + } + } + return events; +} From 46140c96afe1d8a8b5b4bde1fccb671f8760017a Mon Sep 17 00:00:00 2001 From: maria Date: Sun, 13 Sep 2026 02:30:17 -0300 Subject: [PATCH 06/15] fix(web): unify panel resizing and retain final drag width (#11529) --- apps/web/src/components/ui/sidebar.tsx | 269 ++++++------------ apps/web/src/hooks/useResizableWidth.test.tsx | 67 ++++- apps/web/src/hooks/useResizableWidth.ts | 136 ++------- apps/web/src/hooks/useResizeDrag.ts | 116 ++++++++ 4 files changed, 281 insertions(+), 307 deletions(-) create mode 100644 apps/web/src/hooks/useResizeDrag.ts diff --git a/apps/web/src/components/ui/sidebar.tsx b/apps/web/src/components/ui/sidebar.tsx index a7abcb3bb550..307feda7abfc 100644 --- a/apps/web/src/components/ui/sidebar.tsx +++ b/apps/web/src/components/ui/sidebar.tsx @@ -17,6 +17,7 @@ import { } from "~/components/ui/sheet"; import { Skeleton } from "~/components/ui/skeleton"; import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; +import { useResizeDrag } from "~/hooks/useResizeDrag"; import { useIsMobile } from "~/hooks/useMediaQuery"; import { getLocalStorageItem, setLocalStorageItem } from "~/hooks/useLocalStorage"; import { resolveSidebarState, type ResponsiveSidebarState } from "./sidebarState"; @@ -353,6 +354,7 @@ function SidebarRail({ className, onClick, onPointerCancel, + onLostPointerCapture, onPointerDown, onPointerMove, onPointerUp, @@ -362,182 +364,79 @@ function SidebarRail({ const sidebarInstance = React.use(SidebarInstanceContext); const railRef = React.useRef(null); const suppressClickRef = React.useRef(false); - const resizeStateRef = React.useRef<{ - moved: boolean; - pointerId: number; - pendingWidth: number; - rail: HTMLButtonElement; - rafId: number | null; - sidebarRoot: HTMLElement; - side: "left" | "right"; - startWidth: number; - startX: number; - transitionTargets: HTMLElement[]; - width: number; - wrapper: HTMLElement; - } | null>(null); const resolvedResizable = sidebarInstance?.resizable ?? null; + const latestResizable = React.useRef(resolvedResizable); + React.useLayoutEffect(() => { + latestResizable.current = resolvedResizable; + }, [resolvedResizable]); const canResize = resolvedResizable !== null && open; const railLabel = canResize ? "Resize Sidebar" : "Toggle Sidebar"; const railTitle = canResize ? "Drag to resize sidebar" : "Toggle Sidebar"; + const resize = useResizeDrag((event) => { + if (!resolvedResizable || !open) return null; + const rail = event.currentTarget; + const wrapper = rail.closest("[data-slot='sidebar-wrapper']"); + const sidebarRoot = rail.closest("[data-slot='sidebar']"); + const sidebarContainer = sidebarRoot?.querySelector( + "[data-slot='sidebar-container']", + ); + if (!wrapper || !sidebarRoot || !sidebarContainer) return null; - const stopResize = React.useCallback( - (pointerId: number) => { - const resizeState = resizeStateRef.current; - if (!resizeState) { - return; - } - if (resizeState.rafId !== null) { - window.cancelAnimationFrame(resizeState.rafId); - } - resizeState.transitionTargets.forEach((element) => { - element.style.removeProperty("transition-duration"); - }); - if (resolvedResizable?.storageKey && typeof window !== "undefined") { - setLocalStorageItem(resolvedResizable.storageKey, resizeState.width, Schema.Finite); - } - resolvedResizable?.onResize?.(resizeState.width); - resizeStateRef.current = null; - if (resizeState.rail.hasPointerCapture(pointerId)) { - resizeState.rail.releasePointerCapture(pointerId); - } - document.body.style.removeProperty("cursor"); - document.body.style.removeProperty("user-select"); - }, - [resolvedResizable], - ); - - const handlePointerDown = React.useCallback( - (event: React.PointerEvent) => { - onPointerDown?.(event); - if (event.defaultPrevented) return; - if (!resolvedResizable || !open || event.button !== 0) return; - - const wrapper = event.currentTarget.closest("[data-slot='sidebar-wrapper']"); - const sidebarRoot = event.currentTarget.closest("[data-slot='sidebar']"); - if (!wrapper || !sidebarRoot) { - return; - } - - const sidebarContainer = sidebarRoot.querySelector( - "[data-slot='sidebar-container']", - ); - if (!sidebarContainer) { - return; - } - - const startWidth = sidebarContainer.getBoundingClientRect().width; - const initialWidth = clampSidebarWidth(startWidth, resolvedResizable); - const transitionTargets = [ - sidebarRoot.querySelector("[data-slot='sidebar-gap']"), - sidebarRoot.querySelector("[data-slot='sidebar-container']"), - ].filter((element): element is HTMLElement => element !== null); - transitionTargets.forEach((element) => { - element.style.setProperty("transition-duration", "0ms"); - }); - - event.preventDefault(); - event.stopPropagation(); - resizeStateRef.current = { - moved: false, - pointerId: event.pointerId, - pendingWidth: initialWidth, - rail: event.currentTarget, - rafId: null, - sidebarRoot, - side: sidebarInstance?.side ?? "left", - startWidth: initialWidth, - startX: event.clientX, - transitionTargets, - width: initialWidth, - wrapper, - }; - wrapper.style.setProperty("--sidebar-width", `${initialWidth}px`); - event.currentTarget.setPointerCapture(event.pointerId); - document.body.style.cursor = "col-resize"; - document.body.style.userSelect = "none"; - }, - [onPointerDown, open, resolvedResizable, sidebarInstance?.side], - ); - - const handlePointerMove = React.useCallback( - (event: React.PointerEvent) => { - onPointerMove?.(event); - if (event.defaultPrevented) return; - const resizeState = resizeStateRef.current; - if (!resizeState || resizeState.pointerId !== event.pointerId || !resolvedResizable) return; - - event.preventDefault(); - const delta = - resizeState.side === "right" - ? resizeState.startX - event.clientX - : event.clientX - resizeState.startX; - if (Math.abs(delta) > 2) { - resizeState.moved = true; - } - resizeState.pendingWidth = clampSidebarWidth( - resizeState.startWidth + delta, - resolvedResizable, - ); - if (resizeState.rafId !== null) { - return; - } - - resizeState.rafId = window.requestAnimationFrame(() => { - const activeResizeState = resizeStateRef.current; - if (!activeResizeState || !resolvedResizable) return; + const side = sidebarInstance?.side ?? "left"; + let width = clampSidebarWidth( + sidebarContainer.getBoundingClientRect().width, + resolvedResizable, + ); + const transitionTargets = [ + sidebarRoot.querySelector("[data-slot='sidebar-gap']"), + sidebarContainer, + ].filter((element): element is HTMLElement => element !== null); + transitionTargets.forEach((element) => { + element.style.setProperty("transition-duration", "0ms"); + }); + wrapper.style.setProperty("--sidebar-width", `${width}px`); - activeResizeState.rafId = null; - const nextWidth = activeResizeState.pendingWidth; + return { + width, + edge: side === "left" ? "right" : "left", + resize(value) { + const options = latestResizable.current; + if (!options) return width; + const nextWidth = clampSidebarWidth(value, options); const accepted = - resolvedResizable.shouldAcceptWidth?.({ - currentWidth: activeResizeState.width, + options.shouldAcceptWidth?.({ + currentWidth: width, nextWidth, - rail: activeResizeState.rail, - side: activeResizeState.side, - sidebarRoot: activeResizeState.sidebarRoot, - wrapper: activeResizeState.wrapper, + rail, + side, + sidebarRoot, + wrapper, }) ?? true; - if (!accepted) { - return; + if (accepted) { + wrapper.style.setProperty("--sidebar-width", `${nextWidth}px`); + width = nextWidth; } - - activeResizeState.wrapper.style.setProperty("--sidebar-width", `${nextWidth}px`); - activeResizeState.width = nextWidth; - }); - }, - [onPointerMove, resolvedResizable], - ); - - const endResizeInteraction = React.useCallback( - (event: React.PointerEvent) => { - const resizeState = resizeStateRef.current; - if (!resizeState || resizeState.pointerId !== event.pointerId) return; - - event.preventDefault(); - suppressClickRef.current = resizeState.moved; - stopResize(event.pointerId); - }, - [stopResize], - ); - - const handlePointerUp = React.useCallback( - (event: React.PointerEvent) => { - onPointerUp?.(event); - if (event.defaultPrevented) return; - endResizeInteraction(event); - }, - [endResizeInteraction, onPointerUp], - ); - - const handlePointerCancel = React.useCallback( - (event: React.PointerEvent) => { - onPointerCancel?.(event); - if (event.defaultPrevented) return; - endResizeInteraction(event); - }, - [endResizeInteraction, onPointerCancel], - ); + return width; + }, + finish(finalWidth, moved) { + suppressClickRef.current = moved; + const options = latestResizable.current; + if (options?.storageKey) { + try { + setLocalStorageItem(options.storageKey, finalWidth, Schema.Finite); + } catch (error) { + console.error("Could not persist sidebar width.", error); + } + } + options?.onResize?.(finalWidth); + }, + cleanup() { + transitionTargets.forEach((element) => { + element.style.removeProperty("transition-duration"); + }); + }, + }; + }); const handleClick = React.useCallback( (event: React.MouseEvent) => { @@ -579,20 +478,6 @@ function SidebarRail({ resolvedResizable.onResize?.(clampedWidth); }, [resolvedResizable]); - React.useEffect(() => { - return () => { - const resizeState = resizeStateRef.current; - if (resizeState?.rafId != null) { - window.cancelAnimationFrame(resizeState.rafId); - } - resizeState?.transitionTargets.forEach((element) => { - element.style.removeProperty("transition-duration"); - }); - document.body.style.removeProperty("cursor"); - document.body.style.removeProperty("user-select"); - }; - }, []); - return ( { + onLostPointerCapture?.(event); + if (!event.defaultPrevented) resize.onLostPointerCapture(event); + }} + onPointerCancel={(event) => { + onPointerCancel?.(event); + if (!event.defaultPrevented) resize.onPointerCancel(event); + }} + onPointerDown={(event) => { + onPointerDown?.(event); + if (!event.defaultPrevented) resize.onPointerDown(event); + }} + onPointerMove={(event) => { + onPointerMove?.(event); + if (!event.defaultPrevented) resize.onPointerMove(event); + }} + onPointerUp={(event) => { + onPointerUp?.(event); + if (!event.defaultPrevented) resize.onPointerUp(event); + }} ref={railRef} tabIndex={-1} type="button" diff --git a/apps/web/src/hooks/useResizableWidth.test.tsx b/apps/web/src/hooks/useResizableWidth.test.tsx index 204e8fc7e4dd..423c38095680 100644 --- a/apps/web/src/hooks/useResizableWidth.test.tsx +++ b/apps/web/src/hooks/useResizableWidth.test.tsx @@ -40,13 +40,13 @@ function pointer(clientX = 100) { } as unknown as PointerEvent; } -function Panel() { +function Panel({ edge = "left", maxWidth = 800 }: { edge?: "left" | "right"; maxWidth?: number }) { const resize = useResizableWidth({ storageKey: "test-panel-width", defaultWidth: 400, minWidth: 200, - maxWidth: 800, - edge: "left", + maxWidth, + edge, }); useLayoutEffect(() => { result = resize; @@ -107,11 +107,68 @@ describe("panel resize cleanup", () => { expect(style.userSelect).toBe(""); expect(captured).toBe(false); expect(cancelAnimationFrame).toHaveBeenCalledWith(42); - expect(setItem).not.toHaveBeenCalled(); - if (reason !== "unmount") expect(result.width).toBe(400); + if (reason === "unmount") { + expect(setItem).not.toHaveBeenCalled(); + } else { + expect(result.width).toBe(475); + expect(setItem).toHaveBeenCalledExactlyOnceWith("test-panel-width", "475"); + } }, ); + it.each(["left", "right"] as const)( + "uses the release position for a fast %s-edge drag", + async (edge) => { + await act(() => renderer.update()); + await act(() => { + result.handlers.onPointerDown(pointer()); + result.handlers.onPointerMove(pointer(edge === "left" ? 50 : 150)); + result.handlers.onPointerUp(pointer(edge === "left" ? 25 : 175)); + }); + expect(result.width).toBe(475); + expect(setItem).toHaveBeenCalledExactlyOnceWith("test-panel-width", "475"); + expect(cancelAnimationFrame).toHaveBeenCalledWith(42); + }, + ); + + it.each([ + [800, 450], + [450, 800], + ])("uses updated bounds during a drag from max %s to %s", async (initialMax, nextMax) => { + await act(() => renderer.update()); + await act(() => { + result.handlers.onPointerDown(pointer()); + result.handlers.onPointerMove(pointer(-200)); + }); + await act(() => frame?.(0)); + expect(result.width).toBe(Math.min(700, initialMax)); + await act(() => renderer.update()); + await act(() => result.handlers.onPointerMove(pointer(-250))); + await act(() => frame?.(0)); + expect(result.width).toBe(Math.min(750, nextMax)); + await act(() => result.handlers.onPointerUp(pointer(-300))); + expect(result.width).toBe(nextMax); + expect(setItem).toHaveBeenCalledExactlyOnceWith("test-panel-width", String(nextMax)); + }); + + it("handles release before any move event", async () => { + await act(() => { + result.handlers.onPointerDown(pointer()); + result.handlers.onPointerUp(pointer(25)); + }); + expect(result.width).toBe(475); + expect(setItem).toHaveBeenCalledExactlyOnceWith("test-panel-width", "475"); + }); + + it("clamps the release position to the panel bounds", async () => { + await act(() => { + result.handlers.onPointerDown(pointer()); + result.handlers.onPointerUp(pointer(-1000)); + }); + expect(result.width).toBe(800); + expect(setItem).toHaveBeenCalledExactlyOnceWith("test-panel-width", "800"); + }); + it("saves the final width when release is followed by lost capture", async () => { await act(() => { result.handlers.onPointerDown(pointer()); diff --git a/apps/web/src/hooks/useResizableWidth.ts b/apps/web/src/hooks/useResizableWidth.ts index 7bdb6be339a0..93ab2218d54a 100644 --- a/apps/web/src/hooks/useResizableWidth.ts +++ b/apps/web/src/hooks/useResizableWidth.ts @@ -2,12 +2,13 @@ import * as Schema from "effect/Schema"; import { type PointerEvent as ReactPointerEvent, useCallback, - useEffect, + useLayoutEffect, useRef, useState, } from "react"; import { getLocalStorageItem, setLocalStorageItem } from "./useLocalStorage"; +import { useResizeDrag } from "./useResizeDrag"; const WidthSchema = Schema.Finite; @@ -40,7 +41,7 @@ export interface ResizableWidthHandlers { * * The hook updates an internal `width` state during drag (so the panel * follows the cursor live) and only commits to localStorage when the user - * lifts the pointer. + * lifts the pointer or the drag is interrupted. */ export function useResizableWidth(options: UseResizableWidthOptions): { readonly width: number; @@ -69,129 +70,28 @@ export function useResizableWidth(options: UseResizableWidthOptions): { }); const clampedWidth = clamp(width); + const latestOptions = useRef({ clamp, storageKey }); + useLayoutEffect(() => { + latestOptions.current = { clamp, storageKey }; + }, [clamp, storageKey]); - const dragStateRef = useRef<{ - pointerId: number; - startX: number; - startWidth: number; - pending: number; - rafId: number | null; - target: HTMLElement; - } | null>(null); - - const releasePointer = useCallback((pointerId: number) => { - const state = dragStateRef.current; - if (!state) return; - // Clear first because releasing capture can trigger another cleanup. - dragStateRef.current = null; - if (state.rafId !== null) { - cancelAnimationFrame(state.rafId); - } - try { - if (state.target.hasPointerCapture(pointerId)) { - state.target.releasePointerCapture(pointerId); - } - } catch { - // pointer may already be released; harmless. - } - document.body.style.removeProperty("cursor"); - document.body.style.removeProperty("user-select"); - }, []); - - const cancelDrag = useCallback(() => { - const state = dragStateRef.current; - if (!state) return; - releasePointer(state.pointerId); - setWidth(state.startWidth); - }, [releasePointer]); - - useEffect(() => { - window.addEventListener("blur", cancelDrag); - return () => { - window.removeEventListener("blur", cancelDrag); - const state = dragStateRef.current; - if (state) releasePointer(state.pointerId); - }; - }, [cancelDrag, releasePointer]); - - const onPointerDown = useCallback( - (event: ReactPointerEvent) => { - if (event.button !== 0 || dragStateRef.current) return; - event.preventDefault(); - event.stopPropagation(); - const target = event.currentTarget; - try { - target.setPointerCapture(event.pointerId); - } catch { - return; - } - document.body.style.cursor = "col-resize"; - document.body.style.userSelect = "none"; - dragStateRef.current = { - pointerId: event.pointerId, - startX: event.clientX, - startWidth: clampedWidth, - pending: clampedWidth, - rafId: null, - target, - }; - }, - [clampedWidth], - ); - - const onPointerMove = useCallback( - (event: ReactPointerEvent) => { - const state = dragStateRef.current; - if (!state || state.pointerId !== event.pointerId) return; - event.preventDefault(); - const delta = edge === "left" ? state.startX - event.clientX : event.clientX - state.startX; - state.pending = clamp(state.startWidth + delta); - if (state.rafId !== null) return; - state.rafId = requestAnimationFrame(() => { - const active = dragStateRef.current; - if (!active) return; - active.rafId = null; - setWidth(active.pending); - }); + const handlers = useResizeDrag(() => ({ + width: clampedWidth, + edge, + resize(value) { + const nextWidth = latestOptions.current.clamp(value); + setWidth(nextWidth); + return nextWidth; }, - [clamp, edge], - ); - - const onPointerUp = useCallback( - (event: ReactPointerEvent) => { - const state = dragStateRef.current; - if (!state || state.pointerId !== event.pointerId) return; - const finalWidth = clamp(state.pending); - releasePointer(event.pointerId); + finish(finalWidth) { // Commit once at drag-end to avoid 60Hz localStorage writes. try { - setLocalStorageItem(storageKey, finalWidth, WidthSchema); + setLocalStorageItem(latestOptions.current.storageKey, finalWidth, WidthSchema); } catch (error) { console.error("Could not persist panel width.", error); } - setWidth(finalWidth); }, - [clamp, releasePointer, storageKey], - ); + })); - const onPointerCancel = useCallback( - (event: ReactPointerEvent) => { - const state = dragStateRef.current; - if (!state || state.pointerId !== event.pointerId) return; - // Don't persist a cancelled drag; revert to the start width. - cancelDrag(); - }, - [cancelDrag], - ); - - return { - width: clampedWidth, - handlers: { - onPointerDown, - onPointerMove, - onPointerUp, - onPointerCancel, - onLostPointerCapture: onPointerCancel, - }, - }; + return { width: clampedWidth, handlers }; } diff --git a/apps/web/src/hooks/useResizeDrag.ts b/apps/web/src/hooks/useResizeDrag.ts new file mode 100644 index 000000000000..e9e645436174 --- /dev/null +++ b/apps/web/src/hooks/useResizeDrag.ts @@ -0,0 +1,116 @@ +import { type PointerEvent, useCallback, useEffect, useRef } from "react"; + +interface ResizeSession { + width: number; + edge: "left" | "right"; + resize: (width: number) => number; + finish: (width: number, moved: boolean) => void; + cleanup?: () => void; +} + +/** Shared pointer lifecycle for side panels, including interrupted and sub-frame drags. */ +export function useResizeDrag( + start: (event: PointerEvent) => ResizeSession | null, +) { + const drag = useRef<{ + session: ResizeSession; + target: T; + pointerId: number; + startX: number; + pendingX: number; + width: number; + moved: boolean; + frame: number | null; + } | null>(null); + + const flush = useCallback(() => { + const active = drag.current; + if (!active) return; + const delta = (active.pendingX - active.startX) * (active.session.edge === "left" ? -1 : 1); + active.moved ||= Math.abs(delta) > 2; + active.width = active.session.resize(active.session.width + delta); + }, []); + + const finish = useCallback( + (commit = true) => { + const active = drag.current; + if (!active) return; + if (active.frame !== null) cancelAnimationFrame(active.frame); + if (commit) flush(); + // Release can synchronously dispatch lostpointercapture. + drag.current = null; + try { + if (active.target.hasPointerCapture(active.pointerId)) { + active.target.releasePointerCapture(active.pointerId); + } + } catch { + // Capture may already have been released by the browser. + } + document.body.style.removeProperty("cursor"); + document.body.style.removeProperty("user-select"); + active.session.cleanup?.(); + if (commit) active.session.finish(active.width, active.moved); + }, + [flush], + ); + + useEffect(() => { + const onBlur = () => finish(); + window.addEventListener("blur", onBlur); + return () => { + window.removeEventListener("blur", onBlur); + finish(false); + }; + }, [finish]); + + const end = (event: PointerEvent, usePosition: boolean) => { + const active = drag.current; + if (!active || active.pointerId !== event.pointerId) return; + event.preventDefault(); + if (usePosition) active.pendingX = event.clientX; + finish(); + }; + + return { + onPointerDown(event: PointerEvent) { + if (event.button !== 0 || drag.current) return; + const session = start(event); + if (!session) return; + try { + event.currentTarget.setPointerCapture(event.pointerId); + } catch { + session.cleanup?.(); + return; + } + event.preventDefault(); + event.stopPropagation(); + drag.current = { + session, + target: event.currentTarget, + pointerId: event.pointerId, + startX: event.clientX, + pendingX: event.clientX, + width: session.width, + moved: false, + frame: null, + }; + document.body.style.cursor = "col-resize"; + document.body.style.userSelect = "none"; + }, + onPointerMove(event: PointerEvent) { + const active = drag.current; + if (!active || active.pointerId !== event.pointerId) return; + event.preventDefault(); + active.pendingX = event.clientX; + active.moved ||= Math.abs(event.clientX - active.startX) > 2; + if (active.frame !== null) return; + active.frame = requestAnimationFrame(() => { + active.frame = null; + flush(); + }); + }, + onPointerUp: (event: PointerEvent) => end(event, true), + onPointerCancel: (event: PointerEvent) => end(event, false), + onLostPointerCapture: (event: PointerEvent) => end(event, false), + }; +} From 2ec59ca1fad6d9eb6ae36714b76b6130e63201e6 Mon Sep 17 00:00:00 2001 From: maria Date: Sun, 13 Sep 2026 02:44:51 -0300 Subject: [PATCH 07/15] fix(web): hide back button for single linked pull requests (#11520) --- apps/web/src/components/ChatView.tsx | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 4e8b370d382c..7994b3196f58 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -4228,10 +4228,11 @@ export default function ChatView(props: ChatViewProps) { }, [activeThreadRef]); const supportsThreadPullRequests = serverConfig?.environment.capabilities.threadPullRequests === true; + const visiblePullRequestCount = visibleThreadPullRequests( + (activeThreadShell ?? activeThread)?.pullRequests ?? [], + ).length; const pullRequestsSurfaceAvailable = - isServerThread && - supportsThreadPullRequests && - visibleThreadPullRequests((activeThreadShell ?? activeThread)?.pullRequests ?? []).length > 0; + isServerThread && supportsThreadPullRequests && visiblePullRequestCount > 0; const addPullRequestsSurface = useCallback(() => { if (!activeThreadRef || !pullRequestsSurfaceAvailable) return; useRightPanelStore.getState().open(activeThreadRef, "pull-requests"); @@ -8544,7 +8545,7 @@ export default function ChatView(props: ChatViewProps) { } composerDraftTarget={composerDraftTarget} onBack={ - activeThreadRef !== null && pullRequestsSurfaceAvailable + activeThreadRef !== null && pullRequestsSurfaceAvailable && visiblePullRequestCount > 1 ? addPullRequestsSurface : undefined } From 21d53ca2eecbf9f1e481454e8084459b422de714 Mon Sep 17 00:00:00 2001 From: maria Date: Sun, 13 Sep 2026 03:00:59 -0300 Subject: [PATCH 08/15] fix(files): browse ignored files and load folders on demand (#11527) --- .../src/features/files/FileTreeBrowser.tsx | 59 +++++-- .../features/files/ThreadFilesRouteScreen.tsx | 28 ++- apps/mobile/src/features/files/fileTree.ts | 4 + .../files/thread-file-navigator-pane.tsx | 23 +-- .../src/features/files/useFileTreeEntries.ts | 164 ++++++++++++++++++ .../src/workspace/WorkspaceEntries.test.ts | 96 ++++++++++ apps/server/src/workspace/WorkspaceEntries.ts | 77 ++++++++ apps/server/src/ws.ts | 6 + .../src/components/files/FileBreadcrumbs.tsx | 11 +- .../src/components/files/FileBrowserPanel.tsx | 158 ++++++++++++++--- .../files/projectFilesQueryState.ts | 14 +- .../components/files/useDirectoryEntries.ts | 136 +++++++++++++++ apps/web/src/state/queries.ts | 1 + packages/contracts/src/project.ts | 5 + 14 files changed, 706 insertions(+), 76 deletions(-) create mode 100644 apps/mobile/src/features/files/useFileTreeEntries.ts create mode 100644 apps/web/src/components/files/useDirectoryEntries.ts diff --git a/apps/mobile/src/features/files/FileTreeBrowser.tsx b/apps/mobile/src/features/files/FileTreeBrowser.tsx index bce58d838a7d..f2dfd3f15a97 100644 --- a/apps/mobile/src/features/files/FileTreeBrowser.tsx +++ b/apps/mobile/src/features/files/FileTreeBrowser.tsx @@ -11,7 +11,6 @@ import { IOS_NAV_BAR_HEIGHT } from "../../lib/layoutMetrics"; import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; import { buildFileTree, - defaultExpandedTreePaths, flattenFileTree, type FileTreeNode, type VisibleFileTreeNode, @@ -45,6 +44,7 @@ const FileTreeRow = memo(function FileTreeRow(props: { readonly item: VisibleFileTreeNode; readonly selected: boolean; readonly expanded: boolean; + readonly loaded: boolean; readonly onPressDirectory: (path: string) => void; readonly onPreviewFile?: (path: string) => void; readonly onPressFile: (path: string) => void; @@ -89,13 +89,15 @@ const FileTreeRow = memo(function FileTreeRow(props: { "min-w-0 flex-1 text-sm leading-normal", props.selected ? "font-t3-bold text-foreground" - : "font-t3-medium text-foreground-secondary", + : node.ignored + ? "font-t3-medium text-foreground-tertiary" + : "font-t3-medium text-foreground-secondary", )} numberOfLines={1} > {node.name} - {node.kind === "directory" ? ( + {node.kind === "directory" && props.loaded ? ( {node.children.length} @@ -109,7 +111,10 @@ export function FileTreeBrowser(props: { readonly error: string | null; readonly isPending: boolean; readonly searchQuery: string; + readonly searchTruncated: boolean; readonly selectedPath: string | null; + readonly loadedDirectories: ReadonlySet; + readonly onLoadDirectory: (path: string) => void; readonly onPreviewFile?: (path: string) => void; readonly onRefresh: () => void; readonly onSelectFile: (path: string) => void; @@ -123,7 +128,13 @@ export function FileTreeBrowser(props: { // Native transparent-header height ≈ safe-area top + nav bar (~44). Matches the // observed adjustedContentInset bottom (~102) seen in the native trace. const headerInset = NATIVE_LIQUID_GLASS_SUPPORTED ? insets.top + IOS_NAV_BAR_HEIGHT : 0; - const { onPreviewFile, onSelectFile, selectedPath: controlledSelectedPath } = props; + const { + onLoadDirectory, + onPreviewFile, + onSelectFile, + loadedDirectories, + selectedPath: controlledSelectedPath, + } = props; const controlledSelectedPathRef = useRef(controlledSelectedPath); const pendingSelectionTimeoutRef = useRef | null>(null); controlledSelectedPathRef.current = controlledSelectedPath; @@ -133,7 +144,6 @@ export function FileTreeBrowser(props: { ? pendingSelection.path : controlledSelectedPath; const tree = useMemo(() => cachedFileTree(props.entries), [props.entries]); - const defaultExpanded = useMemo(() => defaultExpandedTreePaths(tree), [tree]); const visibleNodes = useMemo( () => flattenFileTree({ @@ -144,15 +154,6 @@ export function FileTreeBrowser(props: { [expandedPaths, props.searchQuery, tree], ); - useEffect(() => { - setExpandedPaths((current) => { - if (current.size > 0 || defaultExpanded.size === 0) { - return current; - } - return new Set(defaultExpanded); - }); - }, [defaultExpanded]); - useEffect(() => { if (!controlledSelectedPath) { return; @@ -170,6 +171,10 @@ export function FileTreeBrowser(props: { }); }, [controlledSelectedPath]); + useEffect(() => { + for (const path of expandedPaths) onLoadDirectory(path); + }, [expandedPaths, onLoadDirectory]); + useEffect( () => () => { if (pendingSelectionTimeoutRef.current !== null) { @@ -213,12 +218,20 @@ export function FileTreeBrowser(props: { item={item} selected={item.node.kind === "file" && item.node.path === selectedPath} expanded={expandedPaths.has(item.node.path)} + loaded={loadedDirectories.has(item.node.path)} onPressDirectory={toggleDirectory} onPreviewFile={onPreviewFile} onPressFile={handleSelectFile} /> ), - [expandedPaths, handleSelectFile, onPreviewFile, selectedPath, toggleDirectory], + [ + expandedPaths, + handleSelectFile, + onPreviewFile, + loadedDirectories, + selectedPath, + toggleDirectory, + ], ); if (props.error && props.entries.length === 0) { @@ -255,6 +268,20 @@ export function FileTreeBrowser(props: { contentContainerStyle={{ paddingTop: 8, paddingBottom: 8 }} refreshControl={} renderItem={renderItem} + ListHeaderComponent={ + <> + {props.error ? ( + + {props.error} + + ) : null} + {props.searchTruncated ? ( + + More search results available. Refine your search to see them. + + ) : null} + + } ListEmptyComponent={ {props.isPending ? ( @@ -265,7 +292,7 @@ export function FileTreeBrowser(props: { {props.searchQuery.trim().length > 0 ? "Try a different search." - : "The workspace file index is empty."} + : "The workspace is empty."} )} diff --git a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx index a8a0f860cfa8..624a214e43e0 100644 --- a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx +++ b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx @@ -5,12 +5,7 @@ import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react" import { ActivityIndicator, Platform, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import Svg, { Defs, LinearGradient, Rect, Stop } from "react-native-svg"; -import { - EnvironmentId, - type ProjectListEntriesResult, - type ProjectReadFileResult, - ThreadId, -} from "@t3tools/contracts"; +import { EnvironmentId, type ProjectReadFileResult, ThreadId } from "@t3tools/contracts"; import { videoMimeType } from "@t3tools/shared/video"; import { isWorkspaceBrowserPreviewPath, @@ -53,6 +48,7 @@ import { useAppearancePreferences } from "../settings/appearance/AppearancePrefe import { ThreadRouteScreen } from "../threads/ThreadRouteScreen"; import { FileMarkdownPreview } from "./FileMarkdownPreview"; import { FileTreeBrowser } from "./FileTreeBrowser"; +import { useFileTreeEntries } from "./useFileTreeEntries"; import { preloadWorkspaceFileContents } from "./preload-workspace-file"; import { SourceFileSurface } from "./SourceFileSurface"; import { ThreadFileNavigatorPane } from "./thread-file-navigator-pane"; @@ -341,15 +337,11 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) { props.route.params, ); const revealedInspectorRef = useRef(false); - const entriesQuery = useEnvironmentQuery( - environmentId !== null && cwd !== null && !fileInspector.supported - ? projectEnvironment.listEntries({ - environmentId, - input: { cwd }, - }) - : null, - ); - const entriesData = entriesQuery.data as ProjectListEntriesResult | null; + const entriesQuery = useFileTreeEntries({ + environmentId, + cwd: fileInspector.supported ? null : cwd, + searchQuery, + }); const handleReturnToThread = useCallback(() => { if (navigation.canGoBack()) { navigation.goBack(); @@ -557,10 +549,14 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) { )} ; readonly searchSegments: ReadonlyArray; readonly searchWords: ReadonlyArray; @@ -19,6 +20,7 @@ interface MutableFileTreeNode { path: string; name: string; kind: ProjectEntry["kind"]; + ignored?: boolean; children: Map; } @@ -68,6 +70,7 @@ function freezeNode(node: MutableFileTreeNode): FileTreeNode { path: node.path, name: node.name, kind: node.kind, + ...(node.ignored ? { ignored: true } : {}), children: [...node.children.values()].sort(compareNodes).map(freezeNode), searchSegments: searchTerms.segments, searchWords: searchTerms.words, @@ -110,6 +113,7 @@ export function buildFileTree(entries: ReadonlyArray): ReadonlyArr } else if (isLeaf) { child.kind = entry.kind; } + if (isLeaf && entry.ignored) child.ignored = true; current = child; } } diff --git a/apps/mobile/src/features/files/thread-file-navigator-pane.tsx b/apps/mobile/src/features/files/thread-file-navigator-pane.tsx index 50c92ed1f590..4bb847dc546a 100644 --- a/apps/mobile/src/features/files/thread-file-navigator-pane.tsx +++ b/apps/mobile/src/features/files/thread-file-navigator-pane.tsx @@ -1,4 +1,4 @@ -import type { EnvironmentId, ProjectListEntriesResult } from "@t3tools/contracts"; +import type { EnvironmentId } from "@t3tools/contracts"; import { SymbolView } from "../../components/AppSymbol"; import { useCallback, useMemo, useState, type ComponentProps } from "react"; import { Platform, Pressable, View, type NativeSyntheticEvent } from "react-native"; @@ -13,10 +13,9 @@ import { import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; import { nativeHeaderScrollEdgeEffects } from "../../native/StackHeader"; import { useUniwindTheme } from "../../lib/useUniwindTheme"; -import { projectEnvironment } from "../../state/projects"; -import { useEnvironmentQuery } from "../../state/query"; import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; import { FileTreeBrowser } from "./FileTreeBrowser"; +import { useFileTreeEntries } from "./useFileTreeEntries"; import { preloadWorkspaceFileContents } from "./preload-workspace-file"; import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout"; @@ -35,13 +34,11 @@ export function ThreadFileNavigatorPane(props: { const foregroundColor = theme["--color-foreground"]; const sheetColor = theme["--color-sheet"]; const headerScrollEdgeEffects = nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version); - const entriesQuery = useEnvironmentQuery( - projectEnvironment.listEntries({ - environmentId: props.environmentId, - input: { cwd: props.cwd }, - }), - ); - const entriesData = entriesQuery.data as ProjectListEntriesResult | null; + const entriesQuery = useFileTreeEntries({ + environmentId: props.environmentId, + cwd: props.cwd, + searchQuery, + }); const handlePreviewFile = useCallback( (relativePath: string) => { preloadWorkspaceFileContents({ @@ -82,10 +79,14 @@ export function ThreadFileNavigatorPane(props: { const fileTree = ( 0; + const query = input.searchQuery.trim().slice(0, 256); + const debouncedQuery = useDebouncedValue(query, 200); + const root = useEnvironmentQuery( + cwd !== null && environmentId !== null + ? projectEnvironment.listEntries({ environmentId, input: { cwd, directoryPath: "" } }) + : null, + ); + const search = useEnvironmentQuery( + searching && debouncedQuery.length > 0 && cwd !== null && environmentId !== null + ? projectEnvironment.searchEntries({ + environmentId, + input: { cwd, query: debouncedQuery, limit: 200 }, + }) + : null, + ); + const [revision, render] = useReducer((value: number) => value + 1, 0); + const refreshVersion = useRef(0); + const directories = useMemo( + () => ({ + cwd, + environmentId, + entries: new Map>(), + requested: new Set(), + pending: new Map(), + errors: new Map(), + }), + [cwd, environmentId], + ); + useEffect( + () => () => { + refreshVersion.current++; + for (const controller of directories.pending.values()) controller.abort(); + directories.pending.clear(); + }, + [directories], + ); + const loadDirectory = useCallback( + (directoryPath: string, refresh = false) => { + if ( + cwd === null || + environmentId === null || + (!refresh && directories.entries.has(directoryPath)) || + directories.pending.has(directoryPath) + ) { + return; + } + const controller = new AbortController(); + directories.requested.add(directoryPath); + directories.pending.set(directoryPath, controller); + directories.errors.delete(directoryPath); + render(); + const atom = projectEnvironment.listEntries({ environmentId, input: { cwd, directoryPath } }); + appAtomRegistry.refresh(atom); + return executeAtomQuery(appAtomRegistry, atom, { + signal: controller.signal, + reportFailure: false, + reportDefect: false, + }).then((result) => { + if (controller.signal.aborted) return; + directories.pending.delete(directoryPath); + if (result._tag === "Success") { + directories.entries.set( + directoryPath, + result.value.entries.filter( + (entry) => + entry.path.slice(0, Math.max(0, entry.path.lastIndexOf("/"))) === directoryPath, + ), + ); + } else { + const error = Cause.squash(result.cause); + directories.errors.set( + directoryPath, + error instanceof Error ? error.message : "Files unavailable", + ); + } + render(); + }); + }, + [cwd, directories, environmentId], + ); + const { refresh: refreshRoot, data: rootData } = root; + const { refresh: refreshSearch, data: searchData } = search; + const snapshot = useMemo(() => { + const merged = new Map(); + if (searching) { + for (const entry of searchData?.entries ?? []) merged.set(entry.path, entry); + } + const reachableDirectories = new Set(); + const visit = (items: ReadonlyArray) => { + for (const entry of items) { + merged.set(entry.path, entry); + if (entry.kind === "directory") { + reachableDirectories.add(entry.path); + visit(directories.entries.get(entry.path) ?? []); + } + } + }; + visit((rootData?.entries ?? []).filter((entry) => !entry.path.includes("/"))); + return { revision, entries: [...merged.values()], reachableDirectories }; + }, [directories, revision, rootData, searchData, searching]); + + const refresh = useCallback(() => { + refreshRoot(); + if (searching) refreshSearch(); + const paths = new Set( + [...directories.requested].filter((path) => snapshot.reachableDirectories.has(path)), + ); + for (const controller of directories.pending.values()) controller.abort(); + directories.pending.clear(); + directories.errors.clear(); + const version = ++refreshVersion.current; + const remaining = paths.values(); + const worker = async () => { + while (version === refreshVersion.current) { + const next = remaining.next(); + if (next.done) return; + await loadDirectory(next.value, true); + } + }; + for (let index = 0; index < Math.min(4, paths.size); index++) void worker(); + render(); + }, [ + directories, + loadDirectory, + refreshRoot, + refreshSearch, + searching, + snapshot.reachableDirectories, + ]); + + return { + entries: snapshot.entries, + error: + root.error ?? + (searching ? search.error : null) ?? + [...directories.errors].find(([path]) => snapshot.reachableDirectories.has(path))?.[1] ?? + null, + isPending: + root.isPending || + directories.pending.size > 0 || + (searching && (query !== debouncedQuery || search.isPending)), + searchTruncated: searching && (search.data?.truncated ?? false), + loadedDirectories: new Set(directories.entries.keys()), + loadDirectory, + refresh, + }; +} diff --git a/apps/server/src/workspace/WorkspaceEntries.test.ts b/apps/server/src/workspace/WorkspaceEntries.test.ts index ff3343c37138..6013f978ed0c 100644 --- a/apps/server/src/workspace/WorkspaceEntries.test.ts +++ b/apps/server/src/workspace/WorkspaceEntries.test.ts @@ -96,6 +96,102 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceEntries", (it) => { }); describe("list", () => { + it.effect("lists immediate children including ignored and empty directories", () => + Effect.gen(function* () { + const cwd = yield* makeTempDir({ git: true }); + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* writeTextFile(cwd, "tracked.txt"); + yield* git(cwd, ["add", "tracked.txt"]); + yield* writeTextFile(cwd, ".gitignore", "node_modules/\n.env\ntracked.txt\n"); + yield* writeTextFile(cwd, ".env", "secret=value"); + yield* writeTextFile(cwd, "node_modules/pkg/index.js"); + yield* writeTextFile(cwd, "src/index.ts"); + yield* fileSystem.makeDirectory(path.join(cwd, "empty")); + + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; + const root = yield* workspaceEntries.list({ cwd, directoryPath: "" }); + expect(root.entries).toEqual( + expect.arrayContaining([ + { path: ".env", kind: "file", ignored: true }, + { path: "node_modules", kind: "directory", ignored: true }, + { path: "src", kind: "directory" }, + { path: "empty", kind: "directory" }, + { path: "tracked.txt", kind: "file" }, + ]), + ); + expect(root.entries.some((entry) => entry.path.includes("/"))).toBe(false); + expect(root.entries.some((entry) => entry.path === ".git")).toBe(false); + expect(root.truncated).toBe(false); + expect(yield* workspaceEntries.list({ cwd, directoryPath: "node_modules/pkg" })).toEqual({ + entries: [{ path: "node_modules/pkg/index.js", kind: "file", ignored: true }], + truncated: false, + }); + expect(yield* workspaceEntries.list({ cwd, directoryPath: "empty" })).toEqual({ + entries: [], + truncated: false, + }); + }), + ); + + it.effect( + "rejects directory traversal, git internals, and symlinks outside the workspace", + () => + Effect.gen(function* () { + const cwd = yield* makeTempDir(); + const outside = yield* makeTempDir(); + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* writeTextFile(cwd, ".git/HEAD"); + const platform = yield* HostProcessPlatform; + if (platform !== "win32") yield* fileSystem.symlink(outside, path.join(cwd, "external")); + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; + for (const directoryPath of [ + "../", + outside, + ".git", + "missing", + ...(platform !== "win32" ? ["external"] : []), + ]) { + const error = yield* workspaceEntries.list({ cwd, directoryPath }).pipe(Effect.flip); + expect(error._tag).toBe("WorkspaceEntriesReadDirectoryError"); + } + }), + ); + + it.effect( + "browses a workspace with more than 25,000 entries without truncation", + () => + Effect.gen(function* () { + const cwd = yield* makeTempDir(); + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + for (let directory = 0; directory < 26; directory++) { + const directoryPath = path.join(cwd, `folder-${directory}`); + yield* fileSystem.makeDirectory(directoryPath); + yield* Effect.forEach( + Array.from({ length: 1000 }, (_, i) => i), + (i) => fileSystem.writeFileString(path.join(directoryPath, `file-${i}.txt`), ""), + { concurrency: 32, discard: true }, + ); + } + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; + const root = yield* workspaceEntries.list({ cwd, directoryPath: "" }); + expect(root.entries).toHaveLength(26); + expect(root.truncated).toBe(false); + for (const directory of root.entries) { + const result = yield* workspaceEntries.list({ cwd, directoryPath: directory.path }); + expect(result.entries).toHaveLength(1000); + expect(result.truncated).toBe(false); + expect(result.entries).toContainEqual({ + path: `${directory.path}/file-999.txt`, + kind: "file", + }); + } + }), + 60_000, + ); + it.effect("returns the complete cached workspace index", () => Effect.gen(function* () { const cwd = yield* makeTempDir(); diff --git a/apps/server/src/workspace/WorkspaceEntries.ts b/apps/server/src/workspace/WorkspaceEntries.ts index 4bdf4d45a8c9..d575f944d885 100644 --- a/apps/server/src/workspace/WorkspaceEntries.ts +++ b/apps/server/src/workspace/WorkspaceEntries.ts @@ -11,6 +11,7 @@ import * as Schema from "effect/Schema"; import type { FilesystemBrowseInput, FilesystemBrowseResult, + ProjectEntry, ProjectListEntriesInput, ProjectListEntriesResult, ProjectSearchContentsInput, @@ -23,6 +24,7 @@ import { isExplicitRelativePath, isWindowsAbsolutePath } from "@t3tools/shared/p import { normalizeSearchQuery } from "@t3tools/shared/searchRanking"; import { expandHomePathWith } from "../pathExpansion.ts"; +import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as WorkspacePaths from "./WorkspacePaths.ts"; import * as WorkspaceSearchIndex from "./WorkspaceSearchIndex.ts"; @@ -74,6 +76,7 @@ export const WorkspaceEntriesBrowseError = Schema.Union([ export type WorkspaceEntriesBrowseError = typeof WorkspaceEntriesBrowseError.Type; export const WorkspaceEntriesError = Schema.Union([ + WorkspaceEntriesReadDirectoryError, WorkspacePaths.WorkspaceRootNotExistsError, WorkspacePaths.WorkspaceRootCreateFailedError, WorkspacePaths.WorkspaceRootStatFailedError, @@ -133,6 +136,7 @@ export const make = Effect.gen(function* () { const path = yield* Path.Path; const workspacePaths = yield* WorkspacePaths.WorkspacePaths; const workspaceSearchIndexes = yield* WorkspaceSearchIndex.WorkspaceSearchIndexMap; + const vcsProcess = yield* VcsProcess.VcsProcess; const normalizeWorkspaceRoot = Effect.fn("WorkspaceEntries.normalizeWorkspaceRoot")(function* ( cwd: string, @@ -266,6 +270,78 @@ export const make = Effect.gen(function* () { const list: WorkspaceEntries["Service"]["list"] = Effect.fn("WorkspaceEntries.list")( function* (input) { const normalizedCwd = yield* normalizeWorkspaceRoot(input.cwd); + if (input.directoryPath !== undefined) { + const directoryPath = input.directoryPath; + const toError = (cause: unknown) => + new WorkspaceEntriesReadDirectoryError({ + cwd: normalizedCwd, + partialPath: directoryPath, + parentPath: path.resolve(normalizedCwd, directoryPath), + cause, + }); + const target = + directoryPath === "" + ? { absolutePath: normalizedCwd, relativePath: "" } + : yield* workspacePaths + .resolveRelativePathWithinRoot({ + workspaceRoot: normalizedCwd, + relativePath: directoryPath, + }) + .pipe(Effect.mapError(toError)); + const entries = yield* Effect.tryPromise({ + try: async () => { + const root = await NodeFSP.realpath(normalizedCwd); + const directory = await NodeFSP.realpath(target.absolutePath); + const relative = path.relative(root, directory); + if ( + relative === ".." || + relative.startsWith(`..${path.sep}`) || + path.isAbsolute(relative) || + relative.split(path.sep).includes(".git") || + target.relativePath.split("/").includes(".git") + ) { + throw new Error("Directory must be inside the workspace and outside .git."); + } + const children = await NodeFSP.readdir(directory, { withFileTypes: true }); + return children.flatMap((child): ProjectEntry[] => { + if (child.name === ".git" || (!child.isDirectory() && !child.isFile())) return []; + return [ + { + path: target.relativePath ? `${target.relativePath}/${child.name}` : child.name, + kind: child.isDirectory() ? "directory" : "file", + }, + ]; + }); + }, + catch: toError, + }); + // Use stdin so large directories cannot exceed the command-line argument limit. + // Ignore classification is optional in non-git workspaces or when git is unavailable. + const ignored = new Set(); + for (let offset = 0; offset < entries.length; offset += 1000) { + const chunk = entries.slice(offset, offset + 1000); + const result = yield* vcsProcess + .run({ + operation: "WorkspaceEntries.list", + command: "git", + args: ["-c", "core.fsmonitor=false", "check-ignore", "-z", "--stdin"], + cwd: normalizedCwd, + stdin: `${chunk.map((entry) => entry.path).join("\0")}\0`, + allowNonZeroExit: true, + timeoutMs: 10_000, + maxOutputBytes: 16 * 1024 * 1024, + }) + .pipe(Effect.orElseSucceed(() => undefined)); + if (!result || (result.exitCode !== 0 && result.exitCode !== 1)) break; + for (const ignoredPath of result.stdout.split("\0")) ignored.add(ignoredPath); + } + return { + entries: entries.map((entry) => + ignored.has(entry.path) ? { ...entry, ignored: true } : entry, + ), + truncated: false, + }; + } return yield* Effect.gen(function* () { const searchIndex = yield* WorkspaceSearchIndex.WorkspaceSearchIndex; return yield* searchIndex.list(); @@ -284,4 +360,5 @@ export const make = Effect.gen(function* () { export const layer = Layer.effect(WorkspaceEntries, make).pipe( Layer.provide(WorkspaceSearchIndex.WorkspaceSearchIndexMap.layer), + Layer.provide(VcsProcess.layer), ); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 10d35e92040d..9ac919ea41bb 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -231,6 +231,12 @@ function projectEntriesFailureContext(error: WorkspaceEntries.WorkspaceEntriesEr failure: "workspace_root_not_directory", normalizedCwd: error.normalizedWorkspaceRoot, }; + case "WorkspaceEntriesReadDirectoryError": + return { + failure: "directory_list_failed", + ...(error.cwd !== undefined ? { normalizedCwd: error.cwd } : {}), + detail: error.message, + }; case "WorkspaceSearchIndexCreateFailed": return { failure: "search_index_create_failed", diff --git a/apps/web/src/components/files/FileBreadcrumbs.tsx b/apps/web/src/components/files/FileBreadcrumbs.tsx index 4896c9ce2df0..3021ff0fab6d 100644 --- a/apps/web/src/components/files/FileBreadcrumbs.tsx +++ b/apps/web/src/components/files/FileBreadcrumbs.tsx @@ -78,7 +78,7 @@ function BreadcrumbMenuContent(props: { readonly rootPath: string; readonly workspaceMutationId: string | null; }) { - const entriesQuery = useProjectEntriesQuery(props.environmentId, props.cwd); + const entriesQuery = useProjectEntriesQuery(props.environmentId, props.cwd, props.directoryPath); useWorkspaceMutationRefresh({ mutationId: props.workspaceMutationId, refresh: entriesQuery.refresh, @@ -91,9 +91,7 @@ function BreadcrumbMenuContent(props: { () => fileBreadcrumbChildren(entries, props.directoryPath), [entries, props.directoryPath], ); - const directoryAvailable = - props.directoryPath === "" || - entries.some((entry) => entry.kind === "directory" && entry.path === props.directoryPath); + const directoryAvailable = entriesQuery.data !== null; const parentPath = fileBreadcrumbParent(props.directoryPath); const canGoBack = props.directoryPath !== props.rootPath && @@ -150,7 +148,10 @@ function BreadcrumbMenuContent(props: { key={entry.path} closeOnClick={entry.kind === "file"} aria-current={isCurrentFile ? "page" : undefined} - className={cn(isCurrentFile && "bg-foreground/[0.08]")} + className={cn( + isCurrentFile && "bg-foreground/[0.08]", + entry.ignored && "text-muted-foreground", + )} onClick={() => { if (entry.kind === "directory") { props.onDirectoryChange(entry.path); diff --git a/apps/web/src/components/files/FileBrowserPanel.tsx b/apps/web/src/components/files/FileBrowserPanel.tsx index 49894db3c8cf..750fa3c7d191 100644 --- a/apps/web/src/components/files/FileBrowserPanel.tsx +++ b/apps/web/src/components/files/FileBrowserPanel.tsx @@ -7,7 +7,7 @@ import type { EnvironmentId, ProjectEntry } from "@t3tools/contracts"; import { FileTree, useFileTree, useFileTreeSearch, useFileTreeSelector } from "@pierre/trees/react"; import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger"; import { ChevronsDownUpIcon, ChevronsUpDownIcon } from "lucide-react"; -import { useEffect, useMemo, useRef } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { Button } from "~/components/ui/button"; import { InputGroup, InputGroupInput } from "~/components/ui/input-group"; @@ -24,7 +24,8 @@ import { PIERRE_TREE_UNSAFE_CSS, pierreTreeStyle } from "~/pierre-tree-theme"; import { createFileTreeDragMentionController } from "./fileTreeDragMention"; import { areAllDirectoriesExpanded, setAllDirectoriesExpanded } from "./fileTreeExpansion"; import { buildFileTreePathUpdates } from "./fileTreePathReconciliation"; -import { useProjectEntriesQuery } from "./projectFilesQueryState"; +import { useDirectoryEntries } from "./useDirectoryEntries"; +import { useProjectPathSearch } from "~/state/queries"; interface FileBrowserPanelProps { environmentId: EnvironmentId; @@ -104,8 +105,31 @@ export default function FileBrowserPanel({ }: FileBrowserPanelProps) { const { resolvedTheme } = useTheme(); const composerRef = useComposerHandleContext(); - const entriesQuery = useProjectEntriesQuery(environmentId, cwd); - const entries = entriesQuery.data?.entries ?? []; + const { + entries: directoryEntries, + load, + refresh, + ready, + error, + isPending, + } = useDirectoryEntries(environmentId, cwd); + const [query, setQuery] = useState(""); + const [expandAll, setExpandAll] = useState(false); + const pathSearch = useProjectPathSearch({ environmentId, cwd, query: query.slice(0, 256) }, 200); + const entries = useMemo(() => { + const result = new Map(directoryEntries.map((entry) => [entry.path, entry])); + if (query.trim() && !pathSearch.isPending) { + for (const entry of pathSearch.entries) { + if (!result.has(entry.path)) result.set(entry.path, entry); + const segments = entry.path.split("/"); + for (let index = 1; index < segments.length; index++) { + const path = segments.slice(0, index).join("/"); + if (!result.has(path)) result.set(path, { path, kind: "directory" }); + } + } + } + return [...result.values()]; + }, [directoryEntries, pathSearch.entries, pathSearch.isPending, query]); const entryKinds = useMemo( () => new Map(entries.map((entry) => [entry.path, entry.kind] as const)), [entries], @@ -222,7 +246,7 @@ export default function FileBrowserPanel({ density: "compact", fileTreeSearchMode: "hide-non-matches", flattenEmptyDirectories: true, - initialExpansion: 1, + initialExpansion: "closed", icons: T3_PIERRE_ICONS, onSelectionChange: (selectedPaths) => { // The drag controller's selection cache must track every change, @@ -244,6 +268,7 @@ export default function FileBrowserPanel({ }, paths: [], search: false, + onSearchChange: (value) => setQuery(value ?? ""), unsafeCSS: PIERRE_TREE_UNSAFE_CSS, }); const search = useFileTreeSearch(model); @@ -251,9 +276,63 @@ export default function FileBrowserPanel({ areAllDirectoriesExpanded(currentModel, directoryPaths), ); const toggleAllDirectories = () => { - setAllDirectoriesExpanded(model, directoryPaths, !allDirectoriesExpanded); + const expanded = !(expandAll || allDirectoriesExpanded); + setExpandAll(expanded); + setAllDirectoriesExpanded(model, directoryPaths, expanded); }; + const closeSearch = () => { + setQuery(""); + search.close(); + }; + const expandedPathsRef = useRef(new Set()); + useEffect(() => { + const currentPaths = new Set(directoryPaths); + for (const path of expandedPathsRef.current) { + if (!currentPaths.has(path)) expandedPathsRef.current.delete(path); + } + const loadExpanded = () => { + if (model.isSearchOpen()) return; + for (const path of directoryPaths) { + const item = model.getItem(path); + if (item?.isDirectory() && "isExpanded" in item && item.isExpanded()) { + if (!expandedPathsRef.current.has(path)) { + expandedPathsRef.current.add(path); + void load(path.replace(/\/$/, "")); + } + } else { + if (item?.isDirectory() && expandedPathsRef.current.has(path)) setExpandAll(false); + expandedPathsRef.current.delete(path); + } + } + }; + loadExpanded(); + return model.subscribe(loadExpanded); + }, [directoryPaths, load, model]); + useEffect(() => { + model.setGitStatus( + entries + .filter((entry) => entry.ignored) + .map((entry) => ({ + path: treePath(entry), + status: "ignored", + })), + ); + }, [entries, model]); + useEffect(() => { + if (!selectedPath) return; + const controller = new AbortController(); + void (async () => { + const segments = selectedPath.split("/"); + for (let index = 0; index < segments.length && !controller.signal.aborted; index++) { + await load(segments.slice(0, index).join("/")); + } + })(); + return () => { + controller.abort(); + }; + }, [load, selectedPath]); const handleSearchValueChange = (value: string) => { + setQuery(value); if (value.trim().length === 0) { search.close(); return; @@ -261,17 +340,21 @@ export default function FileBrowserPanel({ search.setValue(value); }; const handleRefresh = () => { - entriesQuery.refresh(); + refresh(); + if (query.trim()) pathSearch.refresh(); onRefreshSelectedFile?.(); }; useWorkspaceMutationRefresh({ mutationId: workspaceMutationId, - refresh: entriesQuery.refresh, + refresh: () => { + refresh(); + if (query.trim()) pathSearch.refresh(); + }, resourceKey: `files:${environmentId}:${cwd}`, }); useEffect(() => { - if (entriesQuery.data === null) return; + if (!ready) return; if (previousTreePathsRef.current === treePaths) return; entryKindsRef.current = entryKinds; const previousTreePaths = previousTreePathsRef.current; @@ -282,13 +365,21 @@ export default function FileBrowserPanel({ } const updates = buildFileTreePathUpdates(previousTreePaths, treePaths); if (updates.length > 0) model.batch(updates); - }, [entriesQuery.data, entryKinds, model, treePaths]); + }, [ready, entryKinds, model, treePaths]); + + useEffect(() => { + if (expandAll && !query.trim()) setAllDirectoriesExpanded(model, directoryPaths, true); + }, [directoryPaths, expandAll, model, query]); useEffect(() => { if (!selectedPath) { handledRevealRef.current = null; return; } + if (entryKinds.get(selectedPath) !== "file") { + handledRevealRef.current = null; + return; + } const revealRequest = { path: selectedPath, revealId: selectedPathRevealId }; const handledReveal = handledRevealRef.current; // Entry refreshes rebuild treePaths while the same preview stays open. @@ -299,7 +390,6 @@ export default function FileBrowserPanel({ ) { return; } - if (entryKinds.get(selectedPath) !== "file") return; const selectedItem = model.getItem(selectedPath); if (!selectedItem) return; @@ -319,6 +409,7 @@ export default function FileBrowserPanel({ handledRevealRef.current = revealRequest; syncingSelectionRef.current = true; + setQuery(""); model.closeSearch(); for (const path of model.getSelectedPaths()) { model.getItem(path)?.deselect(); @@ -339,7 +430,7 @@ export default function FileBrowserPanel({ queueMicrotask(() => { syncingSelectionRef.current = false; }); - }, [entryKinds, model, selectedPath, selectedPathRevealId, treePaths]); + }, [entryKinds, model, selectedPath, selectedPathRevealId]); // Tag tree drags with the composer mention payload. The row is read from // the composed event path (the tree's shadow root is open), so this does @@ -376,13 +467,13 @@ export default function FileBrowserPanel({ className="flex h-10 min-h-10 shrink-0 items-center gap-1 border-b border-border/60 bg-background px-2 in-data-[preview-panel-mode=inline]:mb-1 in-data-[preview-panel-mode=inline]:h-9 in-data-[preview-panel-mode=inline]:min-h-9 in-data-[preview-panel-mode=inline]:border-b-transparent" data-surface-subheader > - + {directoryPaths.length > 0 ? ( @@ -393,7 +484,9 @@ export default function FileBrowserPanel({ size="icon-xs" variant="ghost" aria-label={ - allDirectoriesExpanded ? "Collapse all folders" : "Expand all folders" + expandAll || allDirectoriesExpanded + ? "Collapse all folders" + : "Expand all folders" } onClick={toggleAllDirectories} /> @@ -406,21 +499,36 @@ export default function FileBrowserPanel({ )} - {allDirectoriesExpanded ? "Collapse all folders" : "Expand all folders"} + {expandAll || allDirectoriesExpanded ? "Collapse all folders" : "Expand all folders"} ) : null}
- {entriesQuery.error && entriesQuery.data === null ? ( -
{entriesQuery.error}
- ) : ( - + {error || pathSearch.error ? ( + + ) : null} + {query.trim() && pathSearch.truncated && !pathSearch.isPending ? ( +
+ More matches available. Refine your search. +
+ ) : null} + {(isPending || pathSearch.isPending) && ( +
+ Loading files… +
)} +
); } diff --git a/apps/web/src/components/files/projectFilesQueryState.ts b/apps/web/src/components/files/projectFilesQueryState.ts index a12772920956..4129ac038c35 100644 --- a/apps/web/src/components/files/projectFilesQueryState.ts +++ b/apps/web/src/components/files/projectFilesQueryState.ts @@ -33,8 +33,15 @@ interface ProjectQueryState
{ readonly refresh: () => void; } -function getProjectEntriesQueryAtom(environmentId: EnvironmentId, cwd: string) { - return projectEnvironment.listEntries({ environmentId, input: { cwd } }); +function getProjectEntriesQueryAtom( + environmentId: EnvironmentId, + cwd: string, + directoryPath?: string, +) { + return projectEnvironment.listEntries({ + environmentId, + input: { cwd, ...(directoryPath !== undefined ? { directoryPath } : {}) }, + }); } export function getProjectFileQueryAtom( @@ -128,8 +135,9 @@ function errorMessage(result: AsyncResult.AsyncResult): string | export function useProjectEntriesQuery( environmentId: EnvironmentId, cwd: string, + directoryPath?: string, ): ProjectQueryState { - const atom = getProjectEntriesQueryAtom(environmentId, cwd); + const atom = getProjectEntriesQueryAtom(environmentId, cwd, directoryPath); const result = useAtomValue(atom); const refreshAtom = useAtomRefresh(atom); const refresh = useCallback(() => refreshAtom(), [refreshAtom]); diff --git a/apps/web/src/components/files/useDirectoryEntries.ts b/apps/web/src/components/files/useDirectoryEntries.ts new file mode 100644 index 000000000000..c7a1db0207cb --- /dev/null +++ b/apps/web/src/components/files/useDirectoryEntries.ts @@ -0,0 +1,136 @@ +import type { EnvironmentId, ProjectEntry } from "@t3tools/contracts"; +import { executeAtomQuery } from "@t3tools/client-runtime/state/runtime"; +import * as Cause from "effect/Cause"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +import { appAtomRegistry } from "~/rpc/atomRegistry"; +import { projectEnvironment } from "~/state/projects"; + +/** Loads only requested directories; collapsing a folder keeps its children cached. */ +export function useDirectoryEntries(environmentId: EnvironmentId, cwd: string) { + const [directories, setDirectories] = useState(new Map()); + const [errors, setErrors] = useState(new Map()); + const [pending, setPending] = useState(0); + const requests = useRef(new Map>()); + const loaded = useRef(new Set()); + const requested = useRef(new Set()); + const active = useRef(true); + const running = useRef(0); + const waiting = useRef void>>([]); + + const load = useCallback( + function loadDirectory(directoryPath: string, refresh = false): Promise { + const existing = requests.current.get(directoryPath); + if (existing) + return refresh ? existing.then(() => loadDirectory(directoryPath, true)) : existing; + if (!refresh && loaded.current.has(directoryPath)) return Promise.resolve(); + loaded.current.add(directoryPath); + requested.current.add(directoryPath); + const atom = projectEnvironment.listEntries({ environmentId, input: { cwd, directoryPath } }); + setPending((count) => count + 1); + const request = (async () => { + if (running.current >= 4) + await new Promise((resolve) => waiting.current.push(resolve)); + else running.current++; + try { + if (!active.current) return undefined; + return await executeAtomQuery(appAtomRegistry, atom, { + refresh: true, + reportFailure: false, + reportDefect: false, + }); + } finally { + const next = waiting.current.shift(); + if (next) next(); + else running.current--; + } + })() + .then((result) => { + if (!active.current || !result) return; + if (result._tag === "Success") { + setDirectories((previous) => + new Map(previous).set( + directoryPath, + result.value.entries.filter( + (entry) => + entry.path.slice(0, Math.max(0, entry.path.lastIndexOf("/"))) === directoryPath, + ), + ), + ); + setErrors((previous) => { + const next = new Map(previous); + next.delete(directoryPath); + return next; + }); + } else { + loaded.current.delete(directoryPath); + const cause = Cause.squash(result.cause); + setErrors((previous) => + new Map(previous).set( + directoryPath, + cause instanceof Error ? cause.message : "Unable to load folder.", + ), + ); + } + }) + .finally(() => { + requests.current.delete(directoryPath); + if (active.current) setPending((count) => count - 1); + }); + requests.current.set(directoryPath, request); + return request; + }, + [cwd, environmentId], + ); + + useEffect(() => { + active.current = true; + void load(""); + return () => { + active.current = false; + }; + }, [load]); + + const entries = useMemo(() => { + const result: ProjectEntry[] = []; + const visit = (path: string) => { + for (const entry of directories.get(path) ?? []) { + result.push(entry); + if (entry.kind === "directory") visit(entry.path); + } + }; + visit(""); + return result; + }, [directories]); + + const reachableDirectories = useMemo( + () => + new Set([ + "", + ...entries.filter((entry) => entry.kind === "directory").map((entry) => entry.path), + ]), + [entries], + ); + + const refresh = useCallback(() => { + // Refresh folders already visited, preserving the current expansion state. + const paths = [...requested.current].filter((path) => reachableDirectories.has(path)); + let next = 0; + const worker = async () => { + while (next < paths.length && active.current) { + const path = paths[next++]; + if (path !== undefined) await load(path, true); + } + }; + for (let index = 0; index < Math.min(4, paths.length); index++) void worker(); + }, [load, reachableDirectories]); + + return { + entries, + load, + refresh, + isPending: pending > 0, + ready: directories.has(""), + error: [...errors].find(([path]) => reachableDirectories.has(path))?.[1] ?? null, + }; +} diff --git a/apps/web/src/state/queries.ts b/apps/web/src/state/queries.ts index 1792c5e9e599..a16c0921a7de 100644 --- a/apps/web/src/state/queries.ts +++ b/apps/web/src/state/queries.ts @@ -263,6 +263,7 @@ export function useProjectPathSearch( isPending: !areProjectPathSearchTargetsEqual(normalizedTarget, debouncedTarget) || result.isPending, searchedQuery: debouncedTarget.query ?? "", + truncated: result.data?.truncated ?? false, refresh: result.refresh, }; } diff --git a/packages/contracts/src/project.ts b/packages/contracts/src/project.ts index bee2e70d5746..3858c241c2d6 100644 --- a/packages/contracts/src/project.ts +++ b/packages/contracts/src/project.ts @@ -28,6 +28,7 @@ export type ProjectSearchEntriesInput = typeof ProjectSearchEntriesInput.Type; export const ProjectEntry = Schema.Struct({ path: TrimmedNonEmptyString, kind: ProjectEntryKind, + ignored: Schema.optional(Schema.Boolean), }); export type ProjectEntry = typeof ProjectEntry.Type; @@ -72,6 +73,9 @@ export type ProjectSearchContentsResult = typeof ProjectSearchContentsResult.Typ export const ProjectListEntriesInput = Schema.Struct({ cwd: TrimmedNonEmptyString, + // Present for immediate filesystem children, including ignored entries; empty means root. + // Omitted preserves the indexed recursive listing used by older clients. + directoryPath: Schema.optional(TrimmedString), }); export type ProjectListEntriesInput = typeof ProjectListEntriesInput.Type; @@ -89,6 +93,7 @@ export const ProjectEntriesFailure = Schema.Literals([ "search_index_create_failed", "search_index_scan_timed_out", "search_index_search_failed", + "directory_list_failed", ]); export type ProjectEntriesFailure = typeof ProjectEntriesFailure.Type; From d7c71f91d61e68ae35fcc0032847cb42324a8b51 Mon Sep 17 00:00:00 2001 From: maria Date: Sun, 13 Sep 2026 03:20:17 -0300 Subject: [PATCH 09/15] feat(web): float the pull request comment composer (#11531) Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com> --- .../pullRequest/PullRequestCodeTab.tsx | 18 +- .../PullRequestCommentComposer.tsx | 163 ++++++++++++++++++ .../pullRequest/PullRequestDetailPanel.tsx | 26 ++- .../pullRequest/PullRequestSummaryTab.tsx | 138 --------------- 4 files changed, 200 insertions(+), 145 deletions(-) create mode 100644 apps/web/src/components/pullRequest/PullRequestCommentComposer.tsx diff --git a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx index 42bf7ab5102e..59f40f955528 100644 --- a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx @@ -961,7 +961,14 @@ function PullRequestCodeTab({ review.verdicts.length === 0 ? null : (
{reviewOpen ? ( -
+
) : ( - // Bottom-right, clear of the vertical scrollbar the diff view keeps to its own right - // edge.