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/mobile/app.config.ts b/apps/mobile/app.config.ts index f34f9c4b39d8..5daaa64a7985 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -130,6 +130,45 @@ const widgetsPlugin: NonNullable[number] = [ // frequent-updates entitlement iOS throttles the update budget sooner. frequentUpdates: true, widgets: [ + { + name: "SubscriptionUsage", + displayName: "Subscription usage", + description: "Subscription quotas from your connected T3 Code environments.", + configuration: { + title: "Subscription usage", + description: + "Both shows Session and Weekly when available. The Lock Screen shows the tightest selected limit.", + parameters: { + codexPeriod: { + title: "Codex limits", + type: "enum", + default: "auto", + values: [ + { name: "Both", value: "auto" }, + { name: "Session", value: "session" }, + { name: "Weekly", value: "weekly" }, + ], + }, + claudePeriod: { + title: "Claude limits", + type: "enum", + default: "auto", + values: [ + { name: "Both", value: "auto" }, + { name: "Session", value: "session" }, + { name: "Weekly", value: "weekly" }, + ], + }, + }, + }, + supportedFamilies: [ + "systemSmall", + "systemMedium", + "systemLarge", + "systemExtraLarge", + "accessoryRectangular", + ], + }, { name: "AgentActivity", displayName: "Agent Activity", diff --git a/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownSelectableText.tsx b/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownSelectableText.tsx index 1015a568ca33..50a381bae288 100644 --- a/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownSelectableText.tsx +++ b/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownSelectableText.tsx @@ -98,7 +98,7 @@ function resolveHeadingFontSize(textStyle: NativeMarkdownTextStyle, headingLevel } const scale = textStyle.fontSize / DEFAULT_BODY_FONT_SIZE; - return Math.max(12, Math.round(DEFAULT_HEADING_FONT_SIZES[index] * scale)); + return Math.max(12, Math.round((DEFAULT_HEADING_FONT_SIZES[index] ?? 15) * scale)); } function runStyle(run: NativeMarkdownTextRun, textStyle: NativeMarkdownTextStyle): TextStyle { diff --git a/apps/mobile/modules/t3-subscription-widget/android/build.gradle b/apps/mobile/modules/t3-subscription-widget/android/build.gradle new file mode 100644 index 000000000000..7de5052417c0 --- /dev/null +++ b/apps/mobile/modules/t3-subscription-widget/android/build.gradle @@ -0,0 +1,18 @@ +apply plugin: 'com.android.library' +apply plugin: 'org.jetbrains.kotlin.android' + +group = 'com.t3tools.subscriptionwidget' +version = '0.0.0' + +android { + namespace 'expo.modules.t3subscriptionwidget' + compileSdk rootProject.ext.compileSdkVersion + defaultConfig { + minSdkVersion rootProject.ext.minSdkVersion + targetSdkVersion rootProject.ext.targetSdkVersion + } +} + +dependencies { + implementation project(':expo-modules-core') +} diff --git a/apps/mobile/modules/t3-subscription-widget/android/src/main/AndroidManifest.xml b/apps/mobile/modules/t3-subscription-widget/android/src/main/AndroidManifest.xml new file mode 100644 index 000000000000..e66f03cffae5 --- /dev/null +++ b/apps/mobile/modules/t3-subscription-widget/android/src/main/AndroidManifest.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/apps/mobile/modules/t3-subscription-widget/android/src/main/java/expo/modules/t3subscriptionwidget/SubscriptionUsageWidget.kt b/apps/mobile/modules/t3-subscription-widget/android/src/main/java/expo/modules/t3subscriptionwidget/SubscriptionUsageWidget.kt new file mode 100644 index 000000000000..ef33a5d7c25d --- /dev/null +++ b/apps/mobile/modules/t3-subscription-widget/android/src/main/java/expo/modules/t3subscriptionwidget/SubscriptionUsageWidget.kt @@ -0,0 +1,163 @@ +package expo.modules.t3subscriptionwidget + +import android.app.AlarmManager +import android.app.PendingIntent +import android.appwidget.AppWidgetManager +import android.appwidget.AppWidgetProvider +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.view.View +import android.widget.RemoteViews +import org.json.JSONObject +import java.text.DateFormat +import java.util.Date + +class SubscriptionUsageWidget : AppWidgetProvider() { + override fun onUpdate(context: Context, manager: AppWidgetManager, ids: IntArray) { + ids.forEach { update(context, manager, it) } + } + + override fun onReceive(context: Context, intent: Intent) { + super.onReceive(context, intent) + if (intent.action == EXPIRE) updateAll(context) + } + + override fun onDisabled(context: Context) { + context.getSystemService(AlarmManager::class.java).cancel(expiryIntent(context)) + } + + override fun onAppWidgetOptionsChanged( + context: Context, + manager: AppWidgetManager, + id: Int, + options: Bundle + ) { + update(context, manager, id) + } + + companion object { + const val PREFERENCES = "t3_subscription_widget" + private const val EXPIRE = "expo.modules.t3subscriptionwidget.EXPIRE" + + private fun expiryIntent(context: Context): PendingIntent = PendingIntent.getBroadcast( + context, + 0, + Intent(context, SubscriptionUsageWidget::class.java).setAction(EXPIRE), + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + + fun updateAll(context: Context) { + val manager = AppWidgetManager.getInstance(context) + manager.getAppWidgetIds(ComponentName(context, SubscriptionUsageWidget::class.java)) + .forEach { update(context, manager, it) } + } + + private fun update(context: Context, manager: AppWidgetManager, id: Int) { + val saved = context.getSharedPreferences(PREFERENCES, 0).getString("snapshot", null) + val snapshot = runCatching { JSONObject(saved.orEmpty()) }.getOrNull() + val views = RemoteViews(context.packageName, R.layout.t3_subscription_widget) + openAppIntent(context, id, snapshot)?.let { + views.setOnClickPendingIntent(R.id.t3_widget_root, it) + } + val providers = snapshot?.optJSONArray("providers") + val now = System.currentTimeMillis() + var nextExpiry = Long.MAX_VALUE + var totalRows = 0 + val groups = (0 until (providers?.length() ?: 0)).mapNotNull { index -> + val provider = providers?.optJSONObject(index) ?: return@mapNotNull null + val windows = provider.optJSONArray("windows") + val expiresAt = provider.optLong("expiresAt") + if (expiresAt > now && windows != null && windows.length() > 0) { + nextExpiry = minOf(nextExpiry, expiresAt) + totalRows += provider.optInt("totalWindows", windows.length()) + (0 until windows.length()).map { provider to windows.optJSONObject(it) } + } else { + totalRows++ + listOf(provider to null) + } + } + // Show each provider before filling spare space with its other windows. + val rows = (0 until (groups.maxOfOrNull { it.size } ?: 0)).flatMap { index -> + groups.mapNotNull { it.getOrNull(index) } + } + if (rows.isNotEmpty()) { + views.removeAllViews(R.id.t3_widget_rows) + val options = manager.getAppWidgetOptions(id) + val height = options.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_HEIGHT, 180) + val count = ((height - 64) / 66).coerceIn(1, 12).coerceAtMost(rows.size) + for ((provider, window) in rows.take(count)) { + views.addView(R.id.t3_widget_rows, rowView(context, provider, window)) + } + val remaining = totalRows - count + val checkedAt = snapshot?.optLong("checkedAt") ?: 0 + val formatted = DateFormat.getDateTimeInstance( + DateFormat.SHORT, + DateFormat.SHORT + ).format(Date(checkedAt)) + val more = if (remaining > 0) { + context.getString(R.string.t3_subscription_widget_more, remaining) + } else { + "" + } + val checked = if (checkedAt > 0) { + context.getString(R.string.t3_subscription_widget_as_of, formatted) + } else { + context.getString(R.string.t3_subscription_widget_unknown_check) + } + views.setTextViewText(R.id.t3_widget_footer, checked + more) + } + val alarms = context.getSystemService(AlarmManager::class.java) + alarms.cancel(expiryIntent(context)) + // Inexact and non-wakeup: the timestamp remains visible if Android delays expiry. + if (nextExpiry != Long.MAX_VALUE) { + alarms.set(AlarmManager.RTC, nextExpiry, expiryIntent(context)) + } + manager.updateAppWidget(id, views) + } + + private fun openAppIntent(context: Context, id: Int, snapshot: JSONObject?): PendingIntent? { + // Target this variant's launcher so co-installed builds cannot steal the tap. + val intent = + context.packageManager.getLaunchIntentForPackage(context.packageName) ?: return null + intent.action = Intent.ACTION_VIEW + val deepLink = snapshot?.optString("url")?.takeIf { it.isNotBlank() } + ?: "t3code://settings/usage?tab=limits" + intent.data = Uri.parse(deepLink) + intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP + return PendingIntent.getActivity( + context, + id, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + } + + private fun rowView(context: Context, provider: JSONObject, window: JSONObject?): RemoteViews { + val child = RemoteViews(context.packageName, R.layout.t3_subscription_widget_row) + val remaining = window?.optInt("remaining")?.coerceIn(0, 100) + val detail = provider.optString("detail") + val label = provider.optString("name") + val windowLabel = window?.optString("label") ?: detail + child.setTextViewText(R.id.t3_widget_label, label) + child.setTextViewText(R.id.t3_widget_window, windowLabel) + val percent = remaining?.let { + context.getString(R.string.t3_subscription_widget_remaining, it) + } ?: "—" + child.setTextViewText(R.id.t3_widget_percent, percent) + val visibility = if (remaining == null) View.GONE else View.VISIBLE + child.setViewVisibility(R.id.t3_widget_progress, visibility) + if (remaining != null) child.setProgressBar(R.id.t3_widget_progress, 100, remaining, false) + val reset = window?.optString("reset") + ?: context.getString(R.string.t3_subscription_widget_refresh) + child.setTextViewText(R.id.t3_widget_reset, reset) + child.setContentDescription( + R.id.t3_widget_row, + "$label. $windowLabel. $percent. $reset. $detail" + ) + return child + } + } +} diff --git a/apps/mobile/modules/t3-subscription-widget/android/src/main/java/expo/modules/t3subscriptionwidget/T3SubscriptionWidgetModule.kt b/apps/mobile/modules/t3-subscription-widget/android/src/main/java/expo/modules/t3subscriptionwidget/T3SubscriptionWidgetModule.kt new file mode 100644 index 000000000000..6557b70958de --- /dev/null +++ b/apps/mobile/modules/t3-subscription-widget/android/src/main/java/expo/modules/t3subscriptionwidget/T3SubscriptionWidgetModule.kt @@ -0,0 +1,18 @@ +package expo.modules.t3subscriptionwidget + +import expo.modules.kotlin.modules.Module +import expo.modules.kotlin.modules.ModuleDefinition +import org.json.JSONObject + +class T3SubscriptionWidgetModule : Module() { + override fun definition() = ModuleDefinition { + Name("T3SubscriptionWidget") + Function("updateSnapshot") { snapshot: String -> + val context = appContext.reactContext ?: return@Function + JSONObject(snapshot) // Reject malformed writes before replacing the saved snapshot. + context.getSharedPreferences(SubscriptionUsageWidget.PREFERENCES, 0) + .edit().putString("snapshot", snapshot).apply() + SubscriptionUsageWidget.updateAll(context) + } + } +} diff --git a/apps/mobile/modules/t3-subscription-widget/android/src/main/res/drawable/t3_subscription_widget_background.xml b/apps/mobile/modules/t3-subscription-widget/android/src/main/res/drawable/t3_subscription_widget_background.xml new file mode 100644 index 000000000000..69ec028ee8af --- /dev/null +++ b/apps/mobile/modules/t3-subscription-widget/android/src/main/res/drawable/t3_subscription_widget_background.xml @@ -0,0 +1,4 @@ + + + + diff --git a/apps/mobile/modules/t3-subscription-widget/android/src/main/res/layout/t3_subscription_widget.xml b/apps/mobile/modules/t3-subscription-widget/android/src/main/res/layout/t3_subscription_widget.xml new file mode 100644 index 000000000000..58e55bc2c81e --- /dev/null +++ b/apps/mobile/modules/t3-subscription-widget/android/src/main/res/layout/t3_subscription_widget.xml @@ -0,0 +1,9 @@ + + + + + + + diff --git a/apps/mobile/modules/t3-subscription-widget/android/src/main/res/layout/t3_subscription_widget_row.xml b/apps/mobile/modules/t3-subscription-widget/android/src/main/res/layout/t3_subscription_widget_row.xml new file mode 100644 index 000000000000..941f27ef856a --- /dev/null +++ b/apps/mobile/modules/t3-subscription-widget/android/src/main/res/layout/t3_subscription_widget_row.xml @@ -0,0 +1,10 @@ + + + + + + + + + diff --git a/apps/mobile/modules/t3-subscription-widget/android/src/main/res/values-night/colors.xml b/apps/mobile/modules/t3-subscription-widget/android/src/main/res/values-night/colors.xml new file mode 100644 index 000000000000..0647eb1a8ea7 --- /dev/null +++ b/apps/mobile/modules/t3-subscription-widget/android/src/main/res/values-night/colors.xml @@ -0,0 +1,5 @@ + + #18181B + #FAFAFA + #A1A1AA + diff --git a/apps/mobile/modules/t3-subscription-widget/android/src/main/res/values/colors.xml b/apps/mobile/modules/t3-subscription-widget/android/src/main/res/values/colors.xml new file mode 100644 index 000000000000..bf4ca10ecfc4 --- /dev/null +++ b/apps/mobile/modules/t3-subscription-widget/android/src/main/res/values/colors.xml @@ -0,0 +1,5 @@ + + #FAFAFA + #18181B + #52525B + diff --git a/apps/mobile/modules/t3-subscription-widget/android/src/main/res/values/strings.xml b/apps/mobile/modules/t3-subscription-widget/android/src/main/res/values/strings.xml new file mode 100644 index 000000000000..7ef70aa44a46 --- /dev/null +++ b/apps/mobile/modules/t3-subscription-widget/android/src/main/res/values/strings.xml @@ -0,0 +1,11 @@ + + Last checked unavailable + Subscription usage + Saved subscription quotas from your T3 Code environments. Tap to refresh in the app. + Open T3 Code and connect an environment to see limits. + Tap to open Usage + Open app to refresh + %1$d%% remaining + As of %1$s + · +%1$d more + diff --git a/apps/mobile/modules/t3-subscription-widget/android/src/main/res/xml/t3_subscription_widget_info.xml b/apps/mobile/modules/t3-subscription-widget/android/src/main/res/xml/t3_subscription_widget_info.xml new file mode 100644 index 000000000000..4dfdad5b0fec --- /dev/null +++ b/apps/mobile/modules/t3-subscription-widget/android/src/main/res/xml/t3_subscription_widget_info.xml @@ -0,0 +1,9 @@ + diff --git a/apps/mobile/modules/t3-subscription-widget/expo-module.config.json b/apps/mobile/modules/t3-subscription-widget/expo-module.config.json new file mode 100644 index 000000000000..7ddc9c09e221 --- /dev/null +++ b/apps/mobile/modules/t3-subscription-widget/expo-module.config.json @@ -0,0 +1,4 @@ +{ + "platforms": ["android"], + "android": { "modules": ["expo.modules.t3subscriptionwidget.T3SubscriptionWidgetModule"] } +} diff --git a/apps/mobile/scripts/generate-uniwind-themes.mts b/apps/mobile/scripts/generate-uniwind-themes.mts index aa3d9b0bfb03..6878dc0ee785 100644 --- a/apps/mobile/scripts/generate-uniwind-themes.mts +++ b/apps/mobile/scripts/generate-uniwind-themes.mts @@ -53,7 +53,7 @@ const color = (family: TailwindColorFamily, shade?: TailwindColorShade, opacity // These replace the remaining dark:* utility pairs. A registered palette theme is // neither literally `light` nor `dark`, so appearance-sensitive values must also be // represented as semantic variables for custom themes. -const ADAPTIVE_COLORS = { +const ADAPTIVE_COLORS: Readonly> = { "--color-adaptive-amber-50-950-a40": [color("amber", 50), color("amber", 950, 0.4)], "--color-adaptive-amber-200-900-a60": [color("amber", 200), color("amber", 900, 0.6)], "--color-adaptive-amber-500-a12-a16": [color("amber", 500, 0.12), color("amber", 500, 0.16)], @@ -143,9 +143,9 @@ export const customThemeNames = BUILT_IN_THEME_IDS.flatMap((themeId) => const adaptiveVariablesFor = (appearance: MobileThemeAppearance) => Object.fromEntries( - Object.entries(ADAPTIVE_COLORS).map(([name, values]) => [ + Object.entries(ADAPTIVE_COLORS).map(([name, [light, dark]]) => [ name, - values[appearance === "light" ? 0 : 1], + appearance === "light" ? light : dark, ]), ); diff --git a/apps/mobile/src/App.tsx b/apps/mobile/src/App.tsx index b7f1db54e8c0..852b6c0560e3 100644 --- a/apps/mobile/src/App.tsx +++ b/apps/mobile/src/App.tsx @@ -24,6 +24,8 @@ import { OverlayPortalHost } from "./components/OverlayPortal"; import { appBlurTargetRef } from "./lib/appBlurTarget"; import { useMobileNavigationTheme } from "./lib/useMobileNavigationTheme"; +import { SubscriptionUsageCoordinator } from "./widgets/SubscriptionUsageCoordinator"; + import "../global.css"; if (process.env.EXPO_PUBLIC_SHOWCASE === "1") { @@ -77,6 +79,7 @@ function AppContent() { return ( <> + diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index ee8cae30d5ae..049cd2888962 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -53,6 +53,7 @@ import { NewTaskFlowProvider } from "./features/threads/new-task-flow-provider"; import { NewTaskRouteScreen } from "./features/threads/NewTaskRouteScreen"; import { SettingsAppearanceRouteScreen } from "./features/settings/SettingsAppearanceRouteScreen"; import { SettingsClientStorageRouteScreen } from "./features/settings/SettingsClientStorageRouteScreen"; +import { SettingsDiagnosticsRouteScreen } from "./features/diagnostics/SettingsDiagnosticsRouteScreen"; import { SettingsAuthRouteScreen } from "./features/settings/SettingsAuthRouteScreen"; import { SettingsEnvironmentsRouteScreen } from "./features/settings/SettingsEnvironmentsRouteScreen"; import { SettingsLegalRouteScreen } from "./features/settings/SettingsLegalRouteScreen"; @@ -198,6 +199,13 @@ const SettingsContentStack = createNativeStackNavigator({ title: "Client Storage", }, }), + SettingsDiagnostics: createNativeStackScreen({ + screen: SettingsDiagnosticsRouteScreen, + linking: "diagnostics", + options: { + title: "Diagnostics", + }, + }), SettingsUsageAccount: createNativeStackScreen({ screen: UsageLimitAccountScreen, options: { title: "Account" }, diff --git a/apps/mobile/src/components/AndroidAnchoredMenu.tsx b/apps/mobile/src/components/AndroidAnchoredMenu.tsx index 1a4f11b8c7e0..79ec95d0a014 100644 --- a/apps/mobile/src/components/AndroidAnchoredMenu.tsx +++ b/apps/mobile/src/components/AndroidAnchoredMenu.tsx @@ -125,7 +125,7 @@ export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) { return () => subscription.remove(); }, [anchor, close, submenuDepth]); - const parent = path.length > 0 ? path[path.length - 1] : null; + const parent = path[path.length - 1] ?? null; const levelActions = (parent?.subactions ?? props.actions).filter( (action) => !(action.attributes?.hidden ?? false), ); diff --git a/apps/mobile/src/components/AppSymbol.tsx b/apps/mobile/src/components/AppSymbol.tsx index 4da3a97c2bf6..e5d0137ee406 100644 --- a/apps/mobile/src/components/AppSymbol.tsx +++ b/apps/mobile/src/components/AppSymbol.tsx @@ -79,6 +79,7 @@ import IconServer from "@tabler/icons-react-native/IconServer"; import IconSettings from "@tabler/icons-react-native/IconSettings"; import IconSparkles from "@tabler/icons-react-native/IconSparkles"; import IconStack2 from "@tabler/icons-react-native/IconStack2"; +import IconStethoscope from "@tabler/icons-react-native/IconStethoscope"; import IconSun from "@tabler/icons-react-native/IconSun"; import IconTerminal2 from "@tabler/icons-react-native/IconTerminal2"; import IconTextDecrease from "@tabler/icons-react-native/IconTextDecrease"; @@ -164,6 +165,7 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial> = { "point.topleft.down.curvedto.point.bottomright.up": IconGitMerge, safari: IconExternalLink, "server.rack": IconServer, + stethoscope: IconStethoscope, "sidebar.left": IconLayoutSidebar, "sidebar.right": IconLayoutSidebarRight, "slider.horizontal.3": IconAdjustmentsHorizontal, 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 ( document.githubRoutingPermissions ?? [])), + write: (githubRoutingPermissions) => + catalog.update((document) => ({ ...document, githubRoutingPermissions })), + }); const targetStore = ConnectionTargetStore.of({ list: catalog.read.pipe( @@ -138,6 +145,7 @@ export const connectionStorageLayer = Layer.effectContext( })), }); return Context.make(ConnectionTargetStore, targetStore).pipe( + Context.add(GitHubRoutingPermissions, githubRoutingPermissions), Context.add(ConnectionRegistrationStore, registrationStore), Context.add(ProfileStore.ConnectionProfileStore, profileStore), Context.add(CredentialStore.ConnectionCredentialStore, credentialStore), diff --git a/apps/mobile/src/features/connection/ConnectionsRouteScreen.tsx b/apps/mobile/src/features/connection/ConnectionsRouteScreen.tsx index ab6207f186ef..bfab389a3a64 100644 --- a/apps/mobile/src/features/connection/ConnectionsRouteScreen.tsx +++ b/apps/mobile/src/features/connection/ConnectionsRouteScreen.tsx @@ -11,6 +11,7 @@ import { AppText as Text } from "../../components/AppText"; import { cn } from "../../lib/cn"; import { useRemoteConnections } from "../../state/use-remote-environment-registry"; import { ConnectionEnvironmentRow } from "./ConnectionEnvironmentRow"; +import { GitHubRoutingSettings } from "./GitHubRoutingSettings"; export function ConnectionsRouteScreen() { const { @@ -97,6 +98,7 @@ export function ConnectionsRouteScreen() { )} + ); diff --git a/apps/mobile/src/features/connection/GitHubRoutingSettings.tsx b/apps/mobile/src/features/connection/GitHubRoutingSettings.tsx new file mode 100644 index 000000000000..764a7d1a0d55 --- /dev/null +++ b/apps/mobile/src/features/connection/GitHubRoutingSettings.tsx @@ -0,0 +1,126 @@ +import { useAtomValue } from "@effect/atom-react"; +import { + connectionCatalogDisplayUrl, + gitHubRoutingConnectionKey, + gitHubRoutingPermissionFor, + type GitHubRoutingPermission, +} from "@t3tools/client-runtime/connection"; +import { useState } from "react"; +import { Alert, Pressable, View } from "react-native"; + +import { AppText as Text } from "../../components/AppText"; +import { SymbolView } from "../../components/AppSymbol"; +import { environmentCatalog } from "../../connection/catalog"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { SettingsSection } from "../settings/components/SettingsSection"; + +const options: ReadonlyArray<{ + value: GitHubRoutingPermission; + label: string; + description: string; +}> = [ + { value: "off", label: "Off", description: "Keep GitHub requests on this environment." }, + { + value: "read", + label: "Read PRs", + description: "Share PR data with other enabled environments.", + }, + { + value: "read-write", + label: "Read and act", + description: "Actions may use broader GitHub permissions than the original environment.", + }, +]; + +export function GitHubRoutingSettings() { + const catalog = useAtomValue(environmentCatalog.catalogValueAtom); + const permissions = useAtomValue(environmentCatalog.githubRoutingPermissionsValueAtom); + const update = useAtomCommand(environmentCatalog.setGitHubRoutingPermission); + const [expanded, setExpanded] = useState(null); + const [saving, setSaving] = useState(false); + if (catalog.entries.size === 0) return null; + + return ( + + + {[...catalog.entries.values()].map((entry, index) => { + const environmentId = entry.target.environmentId; + const selected = gitHubRoutingPermissionFor(entry, permissions); + const disabled = !catalog.isReady || saving || gitHubRoutingConnectionKey(entry) === null; + return ( + + setExpanded(expanded === environmentId ? null : environmentId)} + > + + + {entry.target.label} + + + {connectionCatalogDisplayUrl(entry) ?? "T3 Connect"} + + + + {options.find((option) => option.value === selected)?.label} + + + + {expanded === environmentId + ? options.map((option) => ( + { + setSaving(true); + void update({ environmentId, permission: option.value }).then((result) => { + setSaving(false); + if (result._tag === "Failure") + Alert.alert( + "Could not save GitHub routing permission", + "Try again before leaving this screen.", + ); + }); + }} + > + + {option.label} + + {option.description} + + + {selected === option.value ? ( + + ) : null} + + )) + : null} + + ); + })} + + + Choose environments you trust to share PR data and use each other's GitHub access. Enable + both environments. This applies only to this client. + + + ); +} diff --git a/apps/mobile/src/features/diagnostics/SettingsDiagnosticsRouteScreen.tsx b/apps/mobile/src/features/diagnostics/SettingsDiagnosticsRouteScreen.tsx new file mode 100644 index 000000000000..a55bb8f859e6 --- /dev/null +++ b/apps/mobile/src/features/diagnostics/SettingsDiagnosticsRouteScreen.tsx @@ -0,0 +1,175 @@ +import Constants from "expo-constants"; +import * as Updates from "expo-updates"; +import { useEffect, useState } from "react"; +import { ActivityIndicator, Platform, Pressable, ScrollView, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +import { SymbolView } from "../../components/AppSymbol"; +import { AppText as Text } from "../../components/AppText"; +import { tryCopyTextWithHaptic } from "../../lib/copyTextWithHaptic"; +import { SettingsSection } from "../settings/components/SettingsSection"; +import { + formatStartupCrashReport, + parseStartupCrashRecords, + type StartupCrashRecord, +} from "./crash-log-model"; + +// expo-updates keeps its persistent log this long. Reading any further back +// returns nothing, so this is the whole available window. +const LOG_WINDOW_MS = 7 * 24 * 60 * 60 * 1000; + +type CrashLogState = + | { readonly status: "loading" } + | { readonly status: "unavailable" } + | { readonly status: "ready"; readonly records: ReadonlyArray }; + +function appIdentity() { + return { + version: Constants.expoConfig?.version ?? "0.0.0", + build: + (Platform.OS === "ios" + ? Constants.platform?.ios?.buildNumber + : Constants.platform?.android?.versionCode?.toString()) ?? "dev", + }; +} + +/** + * Startup crashes that TestFlight and the stores strip from their reports. + * expo-updates' ErrorRecovery writes the JS error and component stack to its + * own log before aborting the process, so the next launch can show it here. + */ +export function SettingsDiagnosticsRouteScreen() { + const insets = useSafeAreaInsets(); + const [state, setState] = useState(() => + Updates.isEnabled ? { status: "loading" } : { status: "unavailable" }, + ); + const [copied, setCopied] = useState(false); + + useEffect(() => { + if (!Updates.isEnabled) return; + let cancelled = false; + Updates.readLogEntriesAsync(LOG_WINDOW_MS) + .then((entries) => { + if (cancelled) return; + setState({ status: "ready", records: parseStartupCrashRecords(entries) }); + }) + .catch((error: unknown) => { + console.warn("[diagnostics] could not read the expo-updates log", error); + if (!cancelled) setState({ status: "unavailable" }); + }); + return () => { + cancelled = true; + }; + }, []); + + const records = state.status === "ready" ? state.records : []; + const copyReport = async () => { + const ok = await tryCopyTextWithHaptic(formatStartupCrashReport(records, appIdentity()), { + target: "crash report", + }); + if (ok) setCopied(true); + }; + + return ( + + + + {state.status === "loading" ? ( + + + Reading crash log… + + ) : state.status === "unavailable" ? ( + + ) : records.length === 0 ? ( + + ) : ( + records.map((record, index) => ( + + )) + )} + + + + + void copyReport()} + className="flex-row items-center gap-4 p-4 disabled:opacity-40" + > + + + {copied ? "Copied" : "Copy crash report"} + + + + + Paste the report into a GitHub issue. It contains the app version, the JavaScript error + message, and the component stack. Error messages can quote values from the app, so read + it over before sharing. + + + + + ); +} + +function EmptyState(props: { + readonly icon: "exclamationmark.triangle" | "checkmark.circle"; + readonly title: string; + readonly detail: string; +}) { + return ( + + + {props.title} + {props.detail} + + ); +} + +function CrashRow(props: { readonly record: StartupCrashRecord; readonly first: boolean }) { + const { record } = props; + return ( + + + {new Date(record.timestamp).toLocaleString()} + + + {record.description} + + {record.frames.length > 0 ? ( + + {record.frames.slice(0, 4).join("\n")} + + ) : null} + + ); +} diff --git a/apps/mobile/src/features/diagnostics/crash-log-model.test.ts b/apps/mobile/src/features/diagnostics/crash-log-model.test.ts new file mode 100644 index 000000000000..761d9a49ed96 --- /dev/null +++ b/apps/mobile/src/features/diagnostics/crash-log-model.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { formatStartupCrashReport, parseStartupCrashRecords } from "./crash-log-model"; + +// Verbatim shape of the entry expo-updates wrote for the build 56 launch crash. +const BUNDLE = + "/Users/expo/workingdir/build/apps/mobile/ios/build/Build/Intermediates.noindex/ArchiveIntermediates/T3Code/BuildProductsPath/Release-iphoneos/main.jsbundle"; +const FATAL = { + timestamp: 1789277752000, + level: "error", + code: "JSRuntimeError", + message: [ + "ErrorRecovery fatal exception: Fatal error: Time: 1789277752033.127930", + "Domain: RCTErrorDomain", + "Code: 0", + "Description: Unhandled JS Exception: TypeError: Cannot read property 'defaultModelSelection' of null", + "", + "This error is located at:", + ` at NewTaskFlowProvider (${BUNDLE}:590585:51)`, + ` at NavigationProvider (${BUNDLE}:139067:3)`, + " at RNSSafeAreaView ()", + ].join("\n"), +}; + +describe("parseStartupCrashRecords", () => { + it("extracts the JS error and a compact component stack from a fatal entry", () => { + const [record] = parseStartupCrashRecords([FATAL]); + expect(record?.description).toBe( + "TypeError: Cannot read property 'defaultModelSelection' of null", + ); + expect(record?.frames).toEqual([ + "NewTaskFlowProvider (main.jsbundle:590585:51)", + "NavigationProvider (main.jsbundle:139067:3)", + "RNSSafeAreaView", + ]); + expect(record?.detail).toContain("Domain: RCTErrorDomain"); + }); + + it("ignores update-check noise and orders crashes newest first", () => { + const records = parseStartupCrashRecords([ + { timestamp: 1, level: "info", code: "NoUpdatesAvailable", message: "checked" }, + { ...FATAL, timestamp: 10 }, + { timestamp: 5, level: "error", code: "UpdateFailedToLoad", message: "nope" }, + { ...FATAL, timestamp: 20 }, + ]); + expect(records.map((record) => record.timestamp)).toEqual([20, 10]); + }); + + it("skips a runtime error entry without a description line", () => { + expect( + parseStartupCrashRecords([ + { ...FATAL, message: "ErrorRecovery fatal exception: Fatal exception: Name: x" }, + ]), + ).toEqual([]); + }); +}); + +describe("formatStartupCrashReport", () => { + it("writes the app version and each crash's full detail", () => { + const report = formatStartupCrashReport(parseStartupCrashRecords([FATAL]), { + version: "1.1.1", + build: "56", + }); + expect(report.startsWith("T3 Code 1.1.1 (56)\n")).toBe(true); + expect(report).toContain("2026-09-13T05:35:52.000Z"); + expect(report).toContain("at NewTaskFlowProvider"); + }); + + it("says so when nothing was recorded", () => { + expect(formatStartupCrashReport([], { version: "1.1.1", build: "56" })).toBe( + "T3 Code 1.1.1 (56)\nNo startup crashes recorded.", + ); + }); +}); diff --git a/apps/mobile/src/features/diagnostics/crash-log-model.ts b/apps/mobile/src/features/diagnostics/crash-log-model.ts new file mode 100644 index 000000000000..4c222a1f0be9 --- /dev/null +++ b/apps/mobile/src/features/diagnostics/crash-log-model.ts @@ -0,0 +1,83 @@ +/** + * The shape of an expo-updates log entry we care about. Mirrors + * `UpdatesLogEntry` structurally so the model needs no native module at test + * time. + */ +export interface UpdatesLogEntryLike { + readonly timestamp: number; + readonly message: string; + readonly code: string; + readonly level: string; +} + +/** One fatal JavaScript error that took the app down at startup. */ +export interface StartupCrashRecord { + readonly timestamp: number; + /** The `Description:` line, minus the "Unhandled JS Exception:" prefix. */ + readonly description: string; + /** Component stack (`at Name (bundle:line:col)`), one frame per entry. */ + readonly frames: ReadonlyArray; + /** Everything after the message header, verbatim, for copying. */ + readonly detail: string; +} + +const FATAL_PREFIX = "ErrorRecovery fatal exception: "; +const DESCRIPTION_PREFIX = "Description: "; +const UNHANDLED_PREFIX = "Unhandled JS Exception: "; + +/** + * Keep only the JS runtime fatals expo-updates' ErrorRecovery wrote while + * aborting the process. Everything else in that log (update checks, asset + * loads) is noise for a "why did the app crash" question. + */ +export function parseStartupCrashRecords( + entries: ReadonlyArray, +): ReadonlyArray { + const records: StartupCrashRecord[] = []; + for (const entry of entries) { + if (entry.code !== "JSRuntimeError" || !entry.message.startsWith(FATAL_PREFIX)) continue; + const body = entry.message.slice(FATAL_PREFIX.length); + const lines = body.split("\n"); + const descriptionLine = lines.find((line) => line.startsWith(DESCRIPTION_PREFIX)); + if (descriptionLine === undefined) continue; + let description = descriptionLine.slice(DESCRIPTION_PREFIX.length).trim(); + if (description.startsWith(UNHANDLED_PREFIX)) { + description = description.slice(UNHANDLED_PREFIX.length); + } + const frames = lines + .map((line) => line.trim()) + .filter((line) => line.startsWith("at ")) + .map(compactFrame); + records.push({ timestamp: entry.timestamp, description, frames, detail: body.trim() }); + } + // Newest first; the log appends in time order. + return records.sort((left, right) => right.timestamp - left.timestamp); +} + +/** + * `at NewTaskFlowProvider (/Users/expo/…/main.jsbundle:590585:51)` reads as + * `NewTaskFlowProvider (main.jsbundle:590585:51)`. The absolute build path is + * the same on every frame and says nothing. + */ +function compactFrame(line: string): string { + const match = /^at (.+?) \((.*)\)$/.exec(line); + if (!match) return line.slice("at ".length); + const [, name = "", location = ""] = match; + const file = location.slice(location.lastIndexOf("/") + 1); + return location === "" ? name : `${name} (${file})`; +} + +/** The report a user pastes into an issue: every recorded crash, newest first. */ +export function formatStartupCrashReport( + records: ReadonlyArray, + app: { readonly version: string; readonly build: string }, +): string { + const header = `T3 Code ${app.version} (${app.build})`; + if (records.length === 0) return `${header}\nNo startup crashes recorded.`; + return [ + header, + ...records.map( + (record) => `\n--- ${new Date(record.timestamp).toISOString()} ---\n${record.detail}`, + ), + ].join("\n"); +} 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/mobile/src/features/projects/AddProjectRepositoryRoute.tsx b/apps/mobile/src/features/projects/AddProjectRepositoryRoute.tsx index cdf52022a44a..17700e6599c9 100644 --- a/apps/mobile/src/features/projects/AddProjectRepositoryRoute.tsx +++ b/apps/mobile/src/features/projects/AddProjectRepositoryRoute.tsx @@ -17,6 +17,7 @@ export function AddProjectRepositoryRoute({ const title = source === "github" || source === "gitlab" || + source === "forgejo" || source === "bitbucket" || source === "azure-devops" ? addProjectRemoteSourceLabel(source) diff --git a/apps/mobile/src/features/projects/AddProjectScreen.tsx b/apps/mobile/src/features/projects/AddProjectScreen.tsx index cc1e8f4e5799..5231228829d3 100644 --- a/apps/mobile/src/features/projects/AddProjectScreen.tsx +++ b/apps/mobile/src/features/projects/AddProjectScreen.tsx @@ -110,6 +110,7 @@ function sourceFromParam(value: string | string[] | undefined): AddProjectRemote source === "url" || source === "github" || source === "gitlab" || + source === "forgejo" || source === "bitbucket" || source === "azure-devops" ) { diff --git a/apps/mobile/src/features/review/nativeReviewDiffAdapter.ts b/apps/mobile/src/features/review/nativeReviewDiffAdapter.ts index fd10f1e1509b..d783557f7b60 100644 --- a/apps/mobile/src/features/review/nativeReviewDiffAdapter.ts +++ b/apps/mobile/src/features/review/nativeReviewDiffAdapter.ts @@ -53,7 +53,7 @@ function opaqueNativeHexColor(color: string, background: string): string { const alpha = rgba[4] === undefined ? 1 : Math.min(1, Math.max(0, Number(rgba[4]))); const channels = [1, 2, 3].map((index) => { const foreground = Number(rgba[index]); - const behind = Number.parseInt(backgroundHex[index], 16); + const behind = Number.parseInt(backgroundHex[index] ?? "0", 16); return Math.round(foreground * alpha + behind * (1 - alpha)); }); return `#${channels.map((channel) => channel.toString(16).padStart(2, "0")).join("")}`; @@ -358,6 +358,9 @@ function addNativeWordDiffRanges( for (let pairIndex = 0; pairIndex < pairedCount; pairIndex += 1) { const deletedRowIndex = deletedRowIndexes[pairIndex]; const addedRowIndex = addedRowIndexes[pairIndex]; + if (deletedRowIndex === undefined || addedRowIndex === undefined) { + continue; + } const deletedRow = nextRows[deletedRowIndex]; const addedRow = nextRows[addedRowIndex]; if (!deletedRow?.content || !addedRow?.content) { diff --git a/apps/mobile/src/features/review/reviewModel.test.ts b/apps/mobile/src/features/review/reviewModel.test.ts index ee568085f7a2..8a9aadd6d26d 100644 --- a/apps/mobile/src/features/review/reviewModel.test.ts +++ b/apps/mobile/src/features/review/reviewModel.test.ts @@ -84,7 +84,7 @@ describe("buildReviewSectionItems", () => { }, ]; - const loadedTurnId = getReviewSectionIdForCheckpoint(checkpoints[0]); + const loadedTurnId = getReviewSectionIdForCheckpoint(checkpoints[0]!); const items = buildReviewSectionItems({ checkpoints, gitSections, @@ -92,7 +92,7 @@ describe("buildReviewSectionItems", () => { [loadedTurnId]: "diff --git a/loaded.ts b/loaded.ts", }, loadingTurnIds: { - [getReviewSectionIdForCheckpoint(checkpoints[1])]: true, + [getReviewSectionIdForCheckpoint(checkpoints[1]!)]: true, }, loadingGitSections: false, }); diff --git a/apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx index 9b8363c235db..653107d22bb3 100644 --- a/apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx @@ -10,6 +10,7 @@ import { AppText as Text } from "../../components/AppText"; import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; import { CloudEnvironmentRows } from "../connection/CloudEnvironmentRows"; import { ConnectionEnvironmentRow } from "../connection/ConnectionEnvironmentRow"; +import { GitHubRoutingSettings } from "../connection/GitHubRoutingSettings"; import { splitEnvironmentSections } from "../connection/environmentSections"; import { cn } from "../../lib/cn"; import { useUniwindTheme } from "../../lib/useUniwindTheme"; @@ -174,6 +175,7 @@ export function SettingsEnvironmentsRouteScreen() { } : {})} /> + ); diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx index b4614d1c071c..d343f2dc8830 100644 --- a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx @@ -821,6 +821,7 @@ function AppSettingsSection() { return ( + candidate.id === themeId) ?? BUILT_IN_THEMES[0]; + const theme = BUILT_IN_THEMES.find((candidate) => candidate.id === themeId) ?? T3_CHAT_THEME; const palette = getThemeColorsForAppearance(theme, scheme) ?? theme.colors; const colors = getMobileThemeVariables(themeId, scheme); const background = themeColorToNativeColor(palette.terminalBackground); diff --git a/apps/mobile/src/features/usage/UsageRouteScreen.tsx b/apps/mobile/src/features/usage/UsageRouteScreen.tsx index b3232acc6810..04d9a4bee6cf 100644 --- a/apps/mobile/src/features/usage/UsageRouteScreen.tsx +++ b/apps/mobile/src/features/usage/UsageRouteScreen.tsx @@ -1,5 +1,5 @@ import { EnvironmentId, USAGE_CONTRACT_VERSION } from "@t3tools/contracts"; -import { useNavigation } from "@react-navigation/native"; +import { type RouteProp, useNavigation, useRoute } from "@react-navigation/native"; import { isCompatibleUsageContractVersion, isModelCostUnknown, @@ -65,11 +65,22 @@ const CHART_HEIGHT = 180; * pull to refresh, each refreshing its own data. */ export function UsageRouteScreen() { + const route = useRoute>(); const navigation = useNavigation(); const insets = useSafeAreaInsets(); - // Limits first: remaining quota and reset time are what most people open - // the screen for. - const [tab, setTab] = useState("limits"); + // Preserve the Limits default while honoring explicit widget/navigation links. + const [selection, setSelection] = useState(() => ({ + params: route.params, + tab: (route.params?.tab === "usage" ? "usage" : "limits") as UsageTab, + })); + if (selection.params !== route.params) { + setSelection({ + params: route.params, + tab: route.params?.tab === "usage" ? "usage" : "limits", + }); + } + const { tab } = selection; + const setTab = (tab: UsageTab) => setSelection({ params: route.params, tab }); const [windowSelection, setWindowSelection] = useState(() => ({ days: 30, window: makeWindow(30), diff --git a/apps/mobile/src/lib/mobileTheme.test-support.ts b/apps/mobile/src/lib/mobileTheme.test-support.ts index a702bb9afb27..1e47b0f0ed41 100644 --- a/apps/mobile/src/lib/mobileTheme.test-support.ts +++ b/apps/mobile/src/lib/mobileTheme.test-support.ts @@ -14,7 +14,7 @@ export function readDefaultMobileThemeVariables( return Object.fromEntries( Array.from(variant.matchAll(/(--color-[a-z0-9-]+):\s*([^;]+);/gu), ([, name, value]) => [ name, - value.trim(), + (value ?? "").trim(), ]), ) as MobileThemeVariables; } diff --git a/apps/mobile/src/lib/mobileTheme.test.ts b/apps/mobile/src/lib/mobileTheme.test.ts index 0f621f8c01c5..64c27e7d870f 100644 --- a/apps/mobile/src/lib/mobileTheme.test.ts +++ b/apps/mobile/src/lib/mobileTheme.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vite-plus/test"; -import { BUILT_IN_THEME_IDS, BUILT_IN_THEMES } from "@t3tools/shared/themePalettes"; +import { BUILT_IN_THEME_IDS, BUILT_IN_THEMES, T3_CHAT_THEME } from "@t3tools/shared/themePalettes"; import { readDefaultMobileThemeVariables } from "./mobileTheme.test-support"; import { @@ -49,7 +49,7 @@ function compositeOver(overlay: string, background: string): string { describe("mobile themes", () => { it("declares every runtime theme variable in the static stylesheet", () => { - const generatedVariables = createMobileThemeVariables(BUILT_IN_THEMES[0].colors, "light"); + const generatedVariables = createMobileThemeVariables(T3_CHAT_THEME.colors, "light"); expect(Object.keys(readDefaultMobileThemeVariables("light")).sort()).toEqual( Object.keys(generatedVariables).sort(), ); @@ -152,16 +152,16 @@ describe("mobile themes", () => { }); it("maps semantic palette roles onto every mobile color variable", () => { - const variables = createMobileThemeVariables(BUILT_IN_THEMES[0].colors, "light"); + const variables = createMobileThemeVariables(T3_CHAT_THEME.colors, "light"); expect(Object.keys(variables)).toHaveLength(75); expect(variables["--color-sheet-solid"]).toBe( - themeColorToNativeColor(BUILT_IN_THEMES[0].colors.chrome), + themeColorToNativeColor(T3_CHAT_THEME.colors.chrome), ); expect(variables["--color-warning"]).toBe( - themeColorToNativeColor(BUILT_IN_THEMES[0].colors.warningSurface), + themeColorToNativeColor(T3_CHAT_THEME.colors.warningSurface), ); expect(variables["--color-warning-foreground"]).toBe( - themeColorToNativeColor(BUILT_IN_THEMES[0].colors.warningForeground), + themeColorToNativeColor(T3_CHAT_THEME.colors.warningForeground), ); expect(variables["--color-primary"]).not.toBe(variables["--color-screen"]); expect(variables["--color-primary-shadow"]).toBe("#000000"); diff --git a/apps/mobile/src/lib/mobileTheme.ts b/apps/mobile/src/lib/mobileTheme.ts index 715c6140146c..3b22ac8b5834 100644 --- a/apps/mobile/src/lib/mobileTheme.ts +++ b/apps/mobile/src/lib/mobileTheme.ts @@ -1,5 +1,6 @@ import { BUILT_IN_THEMES, + T3_CHAT_THEME, getThemeColorsForAppearance, MOBILE_DEFAULT_THEME_ID, MOBILE_THEME_IDS as SHARED_MOBILE_THEME_IDS, @@ -29,7 +30,9 @@ export const MOBILE_THEME_OPTIONS: ReadonlyArray<{ ...BUILT_IN_THEMES.map((theme) => ({ id: theme.id as MobileThemeId, label: theme.label })), ]; -export type MobileThemeVariable = `--color-${string}`; +// Closed set: every key `createMobileThemeVariables` writes. Reads of a +// misspelled variable then fail to compile instead of yielding undefined. +export type MobileThemeVariable = keyof ReturnType; export type MobileThemeVariables = Readonly>; export function normalizeMobileThemeId(value: unknown): MobileThemeId { @@ -136,9 +139,9 @@ function withAlpha(color: string, alpha: number): string { function rgbChannels(color: string): readonly [number, number, number] | null { const match = /^#([\da-f]{2})([\da-f]{2})([\da-f]{2})$/i.exec(color); - return match - ? [Number.parseInt(match[1], 16), Number.parseInt(match[2], 16), Number.parseInt(match[3], 16)] - : null; + if (!match) return null; + const [, red = "0", green = "0", blue = "0"] = match; + return [Number.parseInt(red, 16), Number.parseInt(green, 16), Number.parseInt(blue, 16)]; } /** @@ -216,18 +219,15 @@ function readableMessageAccent(accent: string, surface: string): string { } export function themeColorWithAlpha(color: string, alpha: number): string { - const hex = /^#([\da-f]{2})([\da-f]{2})([\da-f]{2})$/i.exec(color); - if (hex) { - return `rgba(${Number.parseInt(hex[1], 16)}, ${Number.parseInt(hex[2], 16)}, ${Number.parseInt(hex[3], 16)}, ${alpha})`; + const channels = rgbChannels(color); + if (channels) { + return `rgba(${channels[0]}, ${channels[1]}, ${channels[2]}, ${alpha})`; } const rgb = /^rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)/.exec(color); return rgb ? `rgba(${rgb[1]}, ${rgb[2]}, ${rgb[3]}, ${alpha})` : color; } -export function createMobileThemeVariables( - colors: ThemeColors, - appearance: MobileThemeAppearance, -): MobileThemeVariables { +export function createMobileThemeVariables(colors: ThemeColors, appearance: MobileThemeAppearance) { const c = nativeColors(colors); return { "--color-screen": c.canvas, @@ -315,7 +315,7 @@ export function createMobileThemeVariables( } export const MOBILE_THEME_VARIABLE_NAMES = Object.keys( - createMobileThemeVariables(BUILT_IN_THEMES[0].colors, "light"), + createMobileThemeVariables(T3_CHAT_THEME.colors, "light"), ) as ReadonlyArray; export function getMobileThemeVariables( @@ -323,7 +323,7 @@ export function getMobileThemeVariables( appearance: MobileThemeAppearance, overrides: Partial | null = null, ): MobileThemeVariables { - const theme = BUILT_IN_THEMES.find((candidate) => candidate.id === themeId) ?? BUILT_IN_THEMES[0]; + const theme = BUILT_IN_THEMES.find((candidate) => candidate.id === themeId) ?? T3_CHAT_THEME; const colors = getThemeColorsForAppearance(theme, appearance) ?? theme.colors; const baseVariables = createMobileThemeVariables(colors, appearance); @@ -337,7 +337,7 @@ export function getMobileThemePreviewColors( ): ThemePreviewColors { if (themeId === DEFAULT_MOBILE_THEME_ID || themeId === "material-you") return STANDARD_THEME_PREVIEW_COLORS[appearance]; - const theme = BUILT_IN_THEMES.find((candidate) => candidate.id === themeId) ?? BUILT_IN_THEMES[0]; + const theme = BUILT_IN_THEMES.find((candidate) => candidate.id === themeId) ?? T3_CHAT_THEME; const colors = getThemeColorsForAppearance(theme, appearance) ?? theme.colors; return { canvas: themeColorToNativeColor(colors.canvas), diff --git a/apps/mobile/src/lib/wideMarkdownBlocks.ts b/apps/mobile/src/lib/wideMarkdownBlocks.ts index 57588bab2a27..054c60be087e 100644 --- a/apps/mobile/src/lib/wideMarkdownBlocks.ts +++ b/apps/mobile/src/lib/wideMarkdownBlocks.ts @@ -81,7 +81,11 @@ function hasOrderedListItem(text: string): boolean { const nestedMatch = INDENTED_ORDERED_LIST_ITEM.exec(line); const parentMatch = previousNonEmptyLine === null ? null : ANY_LIST_ITEM.exec(previousNonEmptyLine); - if (nestedMatch && parentMatch && parentMatch[1].length < nestedMatch[1].length) { + if ( + nestedMatch?.[1] !== undefined && + parentMatch?.[1] !== undefined && + parentMatch[1].length < nestedMatch[1].length + ) { return true; } diff --git a/apps/mobile/src/widgets/SubscriptionUsage.tsx b/apps/mobile/src/widgets/SubscriptionUsage.tsx new file mode 100644 index 000000000000..600383765d59 --- /dev/null +++ b/apps/mobile/src/widgets/SubscriptionUsage.tsx @@ -0,0 +1,262 @@ +import { HStack, ProgressView, Spacer, Text, VStack } from "@expo/ui/swift-ui"; +import { + accessibilityElement, + accessibilityLabel, + font, + foregroundStyle, + frame, + layoutPriority, + lineLimit, + minimumScaleFactor, + progressViewStyle, + tint, + widgetURL, +} from "@expo/ui/swift-ui/modifiers"; +import { createWidget, type WidgetEnvironment } from "expo-widgets"; + +import type { SubscriptionUsageSnapshot as SubscriptionUsageProps } from "./subscriptionUsageSnapshot"; + +type UsageConfiguration = { + codexPeriod?: "auto" | "session" | "weekly"; + claudePeriod?: "auto" | "session" | "weekly"; +}; + +function SubscriptionUsage( + props: SubscriptionUsageProps, + environment: WidgetEnvironment, +) { + "widget"; + // The extension evaluates this function without the app's module scope. + const family = environment.widgetFamily; + // Gallery snapshots can render an old timeline entry after it has expired. + const now = Math.max(environment.date.getTime(), Date.now()); + const accessory = family === "accessoryRectangular"; + const compact = + family === "systemSmall" || accessory || environment.levelOfDetail === "simplified"; + const limit = family === "systemExtraLarge" ? 6 : family === "systemLarge" ? 4 : 2; + const monochrome = + environment.widgetRenderingMode !== "fullColor" || environment.isLuminanceReduced; + const providers = props.providers ?? [ + { name: "Codex", detail: "Open T3 to connect", windows: [], expiresAt: 0 }, + { name: "Claude", detail: "Open T3 to connect", windows: [], expiresAt: 0 }, + ]; + const columns = providers.map((provider) => { + const stale = provider.windows.length > 0 && now >= provider.expiresAt; + const period = + environment.configuration?.[provider.name === "Claude" ? "claudePeriod" : "codexPeriod"] ?? + "auto"; + const windows = stale + ? [] + : provider.windows.filter((window) => period === "auto" || window.kind === period); + // Lock Screen widgets surface the tightest selected limit. + const tightest = windows.reduce<(typeof windows)[number] | undefined>( + (result, window) => (!result || window.remaining < result.remaining ? window : result), + undefined, + ); + const compactWindows = [ + windows.find((window) => window.kind === "session"), + windows.find((window) => window.kind === "weekly"), + ].filter((window) => window !== undefined); + const shown = + accessory || environment.levelOfDetail === "simplified" + ? tightest + ? [tightest] + : [] + : family === "systemSmall" && compactWindows.length > 0 + ? compactWindows + : period === "auto" && compactWindows.length > 0 + ? [ + ...compactWindows, + ...windows.filter((window) => !compactWindows.includes(window)), + ].slice(0, limit) + : windows.slice(0, limit); + const detail = stale + ? "Open T3 to refresh" + : period !== "auto" && windows.length === 0 && provider.windows.length > 0 + ? `No ${period} limit reported` + : provider.detail; + const barModifiers = [ + progressViewStyle("linear"), + ...(monochrome ? [] : [tint(provider.name === "Claude" ? "#d97757" : "#8e8e93")]), + ]; + if (accessory) { + return ( + + + + {provider.name} + {tightest ? ` · ${tightest.label}` : ""} + + + + {tightest + ? `${tightest.remaining}% left` + : period !== "auto" && !stale && provider.windows.length > 0 + ? "N/A" + : "Open T3"} + + + {tightest ? ( + + ) : null} + + ); + } + return ( + + + {provider.name} + + {(!compact || shown.length === 0) && detail !== "Subscription remaining" ? ( + + {detail} + + ) : null} + {shown.map((window) => ( + + + + {window.label} + + + + {window.remaining}% left + + + + {!compact ? ( + + {window.reset} + + ) : null} + + ))} + {!compact && + !stale && + (period === "auto" ? (provider.totalWindows ?? windows.length) : windows.length) > limit ? ( + + {(period === "auto" ? (provider.totalWindows ?? windows.length) : windows.length) - + limit}{" "} + more in T3 + + ) : null} + + ); + }); + return ( + + {compact ? ( + + {columns} + + ) : ( + + {columns} + + )} + {!accessory ? : null} + {!accessory ? ( + + {props.checkedAt + ? `As of ${new Date(props.checkedAt).toLocaleString(undefined, { hour: "numeric", minute: "2-digit", month: "short", day: "numeric" })}` + : "Tap to connect in T3"} + + ) : null} + + ); +} + +export default createWidget("SubscriptionUsage", SubscriptionUsage); diff --git a/apps/mobile/src/widgets/SubscriptionUsageCoordinator.tsx b/apps/mobile/src/widgets/SubscriptionUsageCoordinator.tsx new file mode 100644 index 000000000000..84dce5d515ec --- /dev/null +++ b/apps/mobile/src/widgets/SubscriptionUsageCoordinator.tsx @@ -0,0 +1,32 @@ +import { useAtomValue } from "@effect/atom-react"; +import { Atom } from "effect/unstable/reactivity"; +import * as Linking from "expo-linking"; +import { useEffect } from "react"; +import { environmentCatalog } from "../connection/catalog"; +import { environmentPresentations } from "../state/presentation"; +import { publishSubscriptionUsage } from "./publishSubscriptionUsage"; +import { useSubscriptionUsage } from "./useSubscriptionUsage"; +import { buildSubscriptionUsageSnapshot } from "./subscriptionUsageSnapshot"; + +// Isolate quota changes from the much busier thread/config presentation stream. +const snapshotAtom = Atom.make((get) => + buildSubscriptionUsageSnapshot( + get(environmentPresentations.presentationsAtom), + Linking.createURL("settings/usage", { queryParams: { tab: "limits" } }), + ), +).pipe(Atom.withEquality((a, b) => JSON.stringify(a) === JSON.stringify(b))); + +export function SubscriptionUsageCoordinator() { + const catalog = useAtomValue(environmentCatalog.catalogValueAtom); + const snapshot = useAtomValue(snapshotAtom); + useSubscriptionUsage(catalog.isReady); + useEffect(() => { + if (!catalog.isReady) return; + void Promise.resolve() + .then(() => publishSubscriptionUsage(snapshot)) + .catch((error: unknown) => { + console.warn("Could not update subscription usage widget", error); + }); + }, [catalog.isReady, snapshot]); + return null; +} diff --git a/apps/mobile/src/widgets/publishSubscriptionUsage.android.ts b/apps/mobile/src/widgets/publishSubscriptionUsage.android.ts new file mode 100644 index 000000000000..a37b4f61eca3 --- /dev/null +++ b/apps/mobile/src/widgets/publishSubscriptionUsage.android.ts @@ -0,0 +1,8 @@ +import { requireOptionalNativeModule } from "expo"; +import type { SubscriptionUsageSnapshot } from "./subscriptionUsageSnapshot"; + +export function publishSubscriptionUsage(snapshot: SubscriptionUsageSnapshot) { + requireOptionalNativeModule<{ updateSnapshot: (snapshot: string) => void }>( + "T3SubscriptionWidget", + )?.updateSnapshot(JSON.stringify(snapshot)); +} diff --git a/apps/mobile/src/widgets/publishSubscriptionUsage.ios.ts b/apps/mobile/src/widgets/publishSubscriptionUsage.ios.ts new file mode 100644 index 000000000000..c386f927f942 --- /dev/null +++ b/apps/mobile/src/widgets/publishSubscriptionUsage.ios.ts @@ -0,0 +1,11 @@ +import { requireOptionalNativeModule } from "expo"; +import { + subscriptionUsageTimeline, + type SubscriptionUsageSnapshot, +} from "./subscriptionUsageSnapshot"; + +export async function publishSubscriptionUsage(snapshot: SubscriptionUsageSnapshot) { + if (!requireOptionalNativeModule("ExpoWidgets")) return; + const { default: widget } = await import("./SubscriptionUsage"); + widget.updateTimeline(subscriptionUsageTimeline(snapshot, Date.now())); +} diff --git a/apps/mobile/src/widgets/publishSubscriptionUsage.ts b/apps/mobile/src/widgets/publishSubscriptionUsage.ts new file mode 100644 index 000000000000..3a50eb20d820 --- /dev/null +++ b/apps/mobile/src/widgets/publishSubscriptionUsage.ts @@ -0,0 +1,3 @@ +import type { SubscriptionUsageSnapshot } from "./subscriptionUsageSnapshot"; + +export function publishSubscriptionUsage(_snapshot: SubscriptionUsageSnapshot) {} diff --git a/apps/mobile/src/widgets/subscriptionUsageSnapshot.test.ts b/apps/mobile/src/widgets/subscriptionUsageSnapshot.test.ts new file mode 100644 index 000000000000..65d4c9924f66 --- /dev/null +++ b/apps/mobile/src/widgets/subscriptionUsageSnapshot.test.ts @@ -0,0 +1,257 @@ +import { + EnvironmentId, + ProviderDriverKind, + ProviderInstanceId, + UsageLimitSourceId, + type ServerProvider, +} from "@t3tools/contracts"; +import { describe, expect, it, vi } from "vite-plus/test"; +import { + buildSubscriptionUsageSnapshot, + createWidgetRefresher, + WIDGET_REFRESH_INTERVAL, + subscriptionUsageTimeline, +} from "./subscriptionUsageSnapshot"; + +const checkedAt = "2026-09-05T12:00:00.000Z"; +const now = Date.parse(checkedAt); +const window = { + id: "session", + kind: "session", + label: "5 hours", + usedPercent: 40, + resetsAt: "2026-09-05T12:10:00.000Z", +} as const; +const limits = { checkedAt, windows: [window] }; +const deepLink = "t3code-dev://settings/usage?tab=limits"; +function provider(overrides: Partial = {}): ServerProvider { + return { + instanceId: ProviderInstanceId.make("codex"), + driver: ProviderDriverKind.make("codex"), + enabled: true, + installed: true, + version: null, + status: "ready", + auth: { status: "authenticated", email: "private@example.com" }, + checkedAt, + models: [], + slashCommands: [], + skills: [], + usageLimits: limits, + ...overrides, + }; +} +function presentations(providers: readonly ServerProvider[] = [provider()]) { + return new Map([ + [ + EnvironmentId.make("env"), + { entry: { target: { label: "Remote" } }, serverConfig: { providers } }, + ], + ]); +} + +describe("subscription widget snapshots", () => { + it("uses provider data and its observation time without exposing account emails", () => { + const snapshot = buildSubscriptionUsageSnapshot( + presentations([provider({ displayName: "private@example.com" })]), + deepLink, + ); + expect(snapshot.checkedAt).toBe(now); + expect(snapshot.providers[0]).toMatchObject({ + name: "Codex", + windows: [{ remaining: 60 }], + expiresAt: now + 10 * 60_000, + }); + expect(snapshot.url).toBe(deepLink); + expect(JSON.stringify(snapshot)).not.toContain("private@example.com"); + }); + it("clears data after removing environments and hides disabled providers", () => { + expect( + buildSubscriptionUsageSnapshot(new Map(), deepLink).providers.every( + (p) => p.windows.length === 0, + ), + ).toBe(true); + expect( + buildSubscriptionUsageSnapshot( + presentations([provider({ enabled: false })]), + deepLink, + ).providers.every((p) => p.windows.length === 0), + ).toBe(true); + }); + it("uses upstream deduplication for a native account also present in a proxy hub", () => { + const input = new Map([ + [ + EnvironmentId.make("env"), + { + entry: { target: { label: "Remote" } }, + serverConfig: { + providers: [provider()], + usageLimitSources: [ + { + id: UsageLimitSourceId.make("hub"), + kind: "cliproxy" as const, + label: "Hub", + checkedAt, + accounts: [ + { + id: "account", + driver: ProviderDriverKind.make("codex"), + email: " PRIVATE@example.com ", + usageLimits: limits, + }, + ], + }, + ], + }, + }, + ], + ]); + expect(buildSubscriptionUsageSnapshot(input, deepLink).providers[0]?.detail).toBe( + "Subscription remaining", + ); + input.get(EnvironmentId.make("env"))!.serverConfig.providers = []; + const snapshot = buildSubscriptionUsageSnapshot(input, deepLink); + expect(snapshot.providers[0]?.name).toBe("Codex"); + expect(JSON.stringify(snapshot)).not.toContain("example.com"); + }); + it("keeps unavailable quotas distinct from zero usage and omits provider error messages", () => { + const snapshot = buildSubscriptionUsageSnapshot( + presentations([ + provider({ + usageLimits: { + ...limits, + unavailable: { reason: "probeFailed", message: "token secret" }, + }, + }), + ]), + deepLink, + ); + expect(snapshot.providers[0]?.windows).toEqual([]); + expect(JSON.stringify(snapshot)).not.toContain("token secret"); + expect( + buildSubscriptionUsageSnapshot( + presentations([ + provider({ usageLimits: { checkedAt, windows: [{ ...window, usedPercent: 0 }] } }), + ]), + deepLink, + ).providers[0]?.windows[0]?.remaining, + ).toBe(100); + }); + it("bounds OS storage and puts the most constrained windows first", () => { + const windows = Array.from({ length: 20 }, (_, index) => ({ + ...window, + id: `${index}`, + usedPercent: index * 5, + })); + const snapshot = buildSubscriptionUsageSnapshot( + presentations([provider({ usageLimits: { checkedAt, windows } })]), + deepLink, + ); + expect(snapshot.providers[0]?.windows).toHaveLength(6); + expect(snapshot.providers[0]?.totalWindows).toBe(20); + expect(snapshot.providers[0]?.windows[0]?.remaining).toBe(5); + }); + it("marks unknown or distant reset times stale after fifteen minutes", () => { + const snapshot = buildSubscriptionUsageSnapshot( + presentations([ + provider({ usageLimits: { checkedAt, windows: [{ ...window, resetsAt: undefined }] } }), + ]), + deepLink, + ); + expect(snapshot.providers[0]?.expiresAt).toBe(now + 15 * 60_000); + expect(snapshot.providers[0]?.windows[0]?.reset).toBe("Reset time unavailable"); + }); + it("schedules a reset boundary without inventing a zero quota", () => { + const snapshot = buildSubscriptionUsageSnapshot(presentations(), deepLink); + const timeline = subscriptionUsageTimeline(snapshot, now); + expect(timeline.map((entry) => entry.date.getTime())).toEqual([now, now + 10 * 60_000]); + expect(timeline[1]?.props.providers[0]?.windows).toEqual([]); + expect(subscriptionUsageTimeline(snapshot, now + 60 * 60_000)).toHaveLength(1); + }); + it("expires providers independently without inventing a refill", () => { + const snapshot = buildSubscriptionUsageSnapshot( + presentations([ + provider(), + provider({ + instanceId: ProviderInstanceId.make("claude"), + driver: ProviderDriverKind.make("claudeAgent"), + usageLimits: { checkedAt, windows: [{ ...window, resetsAt: undefined }] }, + }), + ]), + deepLink, + ); + const timeline = subscriptionUsageTimeline(snapshot, now); + expect(timeline.map((entry) => entry.date.getTime())).toEqual([ + now, + now + 10 * 60_000, + now + 15 * 60_000, + ]); + expect(timeline[1]?.props.providers[0]?.windows).toEqual([]); + expect(timeline[1]?.props.providers[1]?.windows[0]?.remaining).toBe(60); + expect(timeline[2]?.props.providers.every((provider) => provider.windows.length === 0)).toBe( + true, + ); + }); + it("marks a malformed check time immediately stale without storing null", () => { + const snapshot = buildSubscriptionUsageSnapshot( + presentations([provider({ usageLimits: { ...limits, checkedAt: "invalid" } })]), + deepLink, + ); + expect(snapshot.checkedAt).toBe(0); + expect(snapshot.providers[0]).toMatchObject({ expiresAt: 0, windows: [] }); + expect(subscriptionUsageTimeline(snapshot, now)).toHaveLength(1); + expect(JSON.stringify(snapshot)).not.toContain("null"); + }); + it("uses the freshest copy of an account across environments before pooling", () => { + const input = presentations(); + input.set(EnvironmentId.make("other"), { + entry: { target: { label: "Other" } }, + serverConfig: { + providers: [ + provider({ + usageLimits: { + checkedAt: new Date(now + 60_000).toISOString(), + windows: [{ ...window, usedPercent: 80 }], + }, + }), + ], + }, + }); + const snapshot = buildSubscriptionUsageSnapshot(input, deepLink); + expect(snapshot.providers[0]?.detail).toBe("Subscription remaining"); + expect(snapshot.providers[0]?.windows[0]?.remaining).toBe(20); + }); +}); + +describe("widget refresh probes", () => { + it("throttles each connected environment independently and retries failures", async () => { + const probe = vi + .fn<(id: string) => Promise>() + .mockRejectedValueOnce(new Error("offline")) + .mockResolvedValue(undefined); + const refresh = createWidgetRefresher(probe); + await refresh([], now); + expect(probe).not.toHaveBeenCalled(); + await refresh(["first"], now); + await refresh(["first", "second"], now + 1); + expect(probe.mock.calls).toEqual([["first"], ["second"]]); + await refresh(["first"], now + WIDGET_REFRESH_INTERVAL); + expect(probe.mock.calls).toEqual([["first"], ["second"], ["first"]]); + }); + + it("does not overlap a slow probe even after the refresh interval", async () => { + let finish!: () => void; + const probe = vi.fn( + () => + new Promise((resolve) => { + finish = resolve; + }), + ); + const refresh = createWidgetRefresher(probe); + const first = refresh(["one", "one"], now); + await refresh(["one"], now + WIDGET_REFRESH_INTERVAL); + expect(probe).toHaveBeenCalledTimes(1); + finish(); + await first; + }); +}); diff --git a/apps/mobile/src/widgets/subscriptionUsageSnapshot.ts b/apps/mobile/src/widgets/subscriptionUsageSnapshot.ts new file mode 100644 index 000000000000..826124e1053f --- /dev/null +++ b/apps/mobile/src/widgets/subscriptionUsageSnapshot.ts @@ -0,0 +1,134 @@ +import { + collectLimitAccounts, + collectLimitPools, + type LimitAccount, + type LimitPresentations, +} from "@t3tools/shared/usageLimits"; + +export interface SubscriptionUsageSnapshot { + url?: string; + checkedAt: number; + providers: Array<{ + name: string; + detail: string; + windows: Array<{ kind?: string; label: string; remaining: number; reset: string }>; + expiresAt: number; + totalWindows: number; + }>; +} + +// Snapshots expire after 15 minutes; background refresh needs a +// separate authenticated transport while the mobile app is suspended. +const SNAPSHOT_MAX_AGE = 15 * 60_000; +export const WIDGET_REFRESH_INTERVAL = 5 * 60_000; + +/** Bound probes across config updates, reconnects, and foreground transitions. */ +export function createWidgetRefresher(refresh: (id: Id) => Promise) { + const attempted = new Map(); + const pending = new Set(); + return async (connected: readonly Id[], now: number) => { + await Promise.allSettled( + connected.map(async (id) => { + if (pending.has(id) || now - (attempted.get(id) ?? -Infinity) < WIDGET_REFRESH_INTERVAL) + return; + attempted.set(id, now); + pending.add(id); + try { + await refresh(id); + } finally { + pending.delete(id); + } + }), + ); + }; +} + +function subscriptionUsageProps( + accounts: readonly LimitAccount[], + now: number, +): SubscriptionUsageSnapshot { + const pools = collectLimitPools(accounts, now); + const checked = accounts + .filter((account) => account.driver === "codex" || account.driver === "claudeAgent") + .map((account) => Date.parse(account.limits.checkedAt)); + return { + checkedAt: checked.length > 0 && checked.every(Number.isFinite) ? Math.min(...checked) : 0, + providers: (["codex", "claudeAgent"] as const).map((driver) => { + const pool = pools.find((candidate) => candidate.driver === driver); + const name = driver === "codex" ? "Codex" : "Claude"; + if (!pool) + return { name, detail: "No limits available", windows: [], expiresAt: 0, totalWindows: 0 }; + const checkedAt = Math.min(...pool.accounts.map((a) => Date.parse(a.limits.checkedAt))); + const expiresAt = Math.min( + checkedAt + SNAPSHOT_MAX_AGE, + ...pool.windows.flatMap((window) => window.resets.map((reset) => reset.at)), + ); + const fresh = Number.isFinite(expiresAt) && expiresAt > now; + const sortedWindows = [...pool.windows].sort( + (a, b) => a.remainingPercent - b.remainingPercent, + ); + // Keep a session and weekly limit when scoped limits fill the storage budget. + const selectedWindows = [ + ...new Set([ + sortedWindows.find((window) => window.kind === "session"), + sortedWindows.find((window) => window.kind === "weekly"), + ...sortedWindows, + ]), + ] + .filter((window) => window !== undefined) + .slice(0, 6) + .sort((a, b) => a.remainingPercent - b.remainingPercent); + return { + name, + detail: !fresh + ? "Open T3 to refresh" + : pool.accounts.length > 1 + ? `${pool.accounts.length} accounts · pooled` + : "Subscription remaining", + expiresAt: fresh ? expiresAt : 0, + totalWindows: fresh ? pool.windows.length : 0, + windows: fresh + ? selectedWindows.map((window) => ({ + kind: window.kind, + label: window.label, + remaining: Math.round(window.remainingPercent), + reset: window.resets[0] + ? `Next reset ${new Date(window.resets[0].at).toLocaleString(undefined, { + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + })}` + : "Reset time unavailable", + })) + : [], + }; + }), + }; +} + +/** Deduplicate accounts before pooling, and only publish display data to the OS. */ +export function buildSubscriptionUsageSnapshot( + presentations: LimitPresentations, + url: string, +): SubscriptionUsageSnapshot { + // Freshness is evaluated at publication/render time, not on unrelated config emissions. + return { ...subscriptionUsageProps(collectLimitAccounts(presentations), 0), url }; +} + +export function subscriptionUsageTimeline(snapshot: SubscriptionUsageSnapshot, now: number) { + const deadlines = [...new Set(snapshot.providers.map((p) => p.expiresAt))] + .filter((deadline) => deadline > now) + .sort((a, b) => a - b); + return [now, ...deadlines].map((date) => ({ + date: new Date(date), + props: { + ...snapshot, + providers: snapshot.providers.map((provider) => + provider.windows.length > 0 && provider.expiresAt <= date + ? { ...provider, detail: "Open T3 to refresh", windows: [], totalWindows: 0 } + : provider, + ), + }, + })); +} diff --git a/apps/mobile/src/widgets/useSubscriptionUsage.ts b/apps/mobile/src/widgets/useSubscriptionUsage.ts new file mode 100644 index 000000000000..1f90a5ebbe7c --- /dev/null +++ b/apps/mobile/src/widgets/useSubscriptionUsage.ts @@ -0,0 +1,40 @@ +import { useAtomValue } from "@effect/atom-react"; +import { useEffect, useMemo } from "react"; +import { AppState } from "react-native"; + +import { environmentPresentations } from "../state/presentation"; +import { serverEnvironment } from "../state/server"; +import { useAtomCommand } from "../state/use-atom-command"; +import { createWidgetRefresher, WIDGET_REFRESH_INTERVAL } from "./subscriptionUsageSnapshot"; + +export function useSubscriptionUsage(enabled = true) { + const presentations = useAtomValue(environmentPresentations.presentationsAtom); + const refreshProviders = useAtomCommand(serverEnvironment.refreshProviders, { + reportFailure: false, + }); + const refresh = useMemo( + () => + createWidgetRefresher( + (environmentId: Parameters[0]["environmentId"]) => + refreshProviders({ environmentId, input: {} }), + ), + [refreshProviders], + ); + + useEffect(() => { + const update = () => { + if (!enabled || AppState.currentState !== "active") return; + const connected = [...presentations] + .filter(([, presentation]) => presentation.connection.phase === "connected") + .map(([id]) => id); + void refresh(connected, Date.now()); + }; + update(); + const subscription = AppState.addEventListener("change", update); + const timer = setInterval(update, WIDGET_REFRESH_INTERVAL); + return () => { + subscription.remove(); + clearInterval(timer); + }; + }, [enabled, presentations, refresh]); +} diff --git a/apps/mobile/tsconfig.json b/apps/mobile/tsconfig.json index f3763c56543d..f6634fbc4c97 100644 --- a/apps/mobile/tsconfig.json +++ b/apps/mobile/tsconfig.json @@ -2,6 +2,8 @@ "extends": "expo/tsconfig.base", "compilerOptions": { "allowImportingTsExtensions": true, - "strict": true + "strict": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true } } 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/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index d7d1be455fa1..2e2bdea73fc7 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -68,6 +68,8 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.pullRequestsList]: AuthOrchestrationReadScope, [WS_METHODS.pullRequestsListStats]: AuthOrchestrationReadScope, [WS_METHODS.pullRequestsSummary]: AuthOrchestrationReadScope, + [WS_METHODS.pullRequestsRouting]: AuthOrchestrationReadScope, + [WS_METHODS.pullRequestsRoutingIdentity]: AuthOrchestrationReadScope, [WS_METHODS.pullRequestsStack]: AuthOrchestrationReadScope, [WS_METHODS.pullRequestsLinkedThreads]: AuthOrchestrationReadScope, [WS_METHODS.pullRequestsDetail]: AuthOrchestrationReadScope, diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index 999a0c202216..cab5020d7423 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -37,6 +37,10 @@ import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as GitHubSourceControlProvider from "../sourceControl/GitHubSourceControlProvider.ts"; import * as GitLabSourceControlProvider from "../sourceControl/GitLabSourceControlProvider.ts"; +import { + ForgejoPullRequestSchema, + toForgejoChangeRequest, +} from "../sourceControl/forgejoPullRequests.ts"; import type { SourceControlProvider } from "../sourceControl/SourceControlProvider.ts"; import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts"; import * as ServerConfig from "../config.ts"; @@ -46,6 +50,7 @@ import * as ServerSettings from "../serverSettings.ts"; import * as GitManager from "./GitManager.ts"; const encodeCliJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); +const decodeForgejoPullRequest = Schema.decodeEffect(ForgejoPullRequestSchema); interface FakeGhScenario { prListSequence?: string[]; @@ -3851,6 +3856,69 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { 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/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/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/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; +} 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/GitHubPullRequestCli.test.ts b/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts index 6ef485627afb..a62d1ed21970 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts @@ -1,11 +1,14 @@ import { afterEach, assert, expect, it, vi } from "@effect/vitest"; import * as Effect from "effect/Effect"; +import * as Deferred from "effect/Deferred"; +import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; import * as TestClock from "effect/testing/TestClock"; import { ChildProcessSpawner } from "effect/unstable/process"; import * as GitHubCli from "../sourceControl/GitHubCli.ts"; +import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as GitHubGraphQlBudget from "../sourceControl/githubGraphQlBudget.ts"; import * as GitHubPullRequestCli from "./GitHubPullRequestCli.ts"; import { BASE_COMPARISON_GRAPHQL_QUERY } from "./gitHubPullRequestJson.ts"; @@ -192,7 +195,118 @@ afterEach(() => { mockedGetPullRequest.mockReset(); }); +it.effect( + "keeps a verified credential through an auth switch and separates token fingerprints", + () => + Effect.gen(function* () { + let activeToken = "broad-credential"; + const commands: VcsProcess.VcsProcessInput[] = []; + const github = yield* GitHubCli.make.pipe( + Effect.provideService(VcsProcess.VcsProcess, { + run: (input) => + Effect.sync(() => { + commands.push(input); + if (input.args[0] === "auth") return output(activeToken); + if (input.args[0] === "api") return output('{"id":123,"login":"same-account"}'); + return output(""); + }), + }), + ); + const cli = yield* GitHubPullRequestCli.make.pipe( + Effect.provideService(GitHubCli.GitHubCli, github), + Effect.provide(GitHubGraphQlBudget.layer), + ); + const input = { cwd: "/repo", host: "github.com" }; + const first = yield* cli.withVerifiedCredential(input, (identity) => + Effect.gen(function* () { + activeToken = "restricted-credential"; + expect(yield* cli.getViewerLogin(input)).toBe("same-account"); + yield* cli.commentOnPullRequest({ + ...input, + repository: "owner/repo", + number: 1, + body: "comment", + }); + return identity; + }), + ); + const second = yield* cli.withVerifiedCredential(input, Effect.succeed); + expect(first.accountId).toBe(second.accountId); + expect(first.credentialFingerprint).not.toBe(second.credentialFingerprint); + expect(encodeJson([first, second])).not.toContain("broad-credential"); + expect(encodeJson([first, second])).not.toContain("restricted-credential"); + expect(commands.find((command) => command.args[0] === "pr")?.env).toMatchObject({ + GH_TOKEN: "broad-credential", + GITHUB_TOKEN: "broad-credential", + GH_DEBUG: "", + }); + expect( + commands + .filter((command) => command.args[0] === "api") + .map((command) => command.env?.GH_TOKEN), + ).toEqual(["broad-credential", "restricted-credential"]); + expect(yield* cli.getRoutingIdentity(input)).toEqual({ + accountId: "123", + viewer: "same-account", + }); + }), +); + layer("GitHubPullRequestCli.layer", (it) => { + it.effect("coalesces concurrent identity verification for the same host and credential", () => + Effect.gen(function* () { + mockedExecute.mockImplementation((input) => + input.args[0] === "auth" + ? Effect.succeed(output("shared-credential")) + : Effect.yieldNow.pipe(Effect.as(output('{"id":123,"login":"viewer"}'))), + ); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const results = yield* Effect.all( + Array.from({ length: 4 }, () => + cli.getRoutingIdentity({ cwd: "/w", host: "github.identity-flight.test" }), + ), + { concurrency: 4 }, + ); + expect(results).toEqual( + Array.from({ length: 4 }, () => ({ accountId: "123", viewer: "viewer" })), + ); + expect(mockedExecute.mock.calls.filter(([input]) => input.args[0] === "api")).toHaveLength(1); + }), + ); + + it.effect( + "lets another identity reader continue when the first verification is interrupted", + () => + Effect.gen(function* () { + const firstStarted = yield* Deferred.make(); + const secondStarted = yield* Deferred.make(); + let tokens = 0; + let verifications = 0; + mockedExecute.mockImplementation((input) => + Effect.gen(function* () { + if (input.args[0] === "auth") { + if (++tokens === 2) yield* Deferred.succeed(secondStarted, undefined); + return output("cancel-credential"); + } + if (++verifications === 1) { + yield* Deferred.succeed(firstStarted, undefined); + return yield* Effect.never; + } + return output('{"id":123,"login":"viewer"}'); + }), + ); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const input = { cwd: "/w", host: "github.identity-cancel.test" }; + const first = yield* cli.getRoutingIdentity(input).pipe(Effect.forkChild); + yield* Deferred.await(firstStarted); + const second = yield* cli.getRoutingIdentity(input).pipe(Effect.forkChild); + yield* Deferred.await(secondStarted); + yield* Fiber.interrupt(first); + expect(yield* Fiber.join(second)).toEqual({ accountId: "123", viewer: "viewer" }); + expect(verifications).toBe(2); + }), + ); + it.effect("reads linked pull request status with the overview fields in one request", () => Effect.gen(function* () { mockedExecute.mockReturnValueOnce( @@ -2458,12 +2572,79 @@ layer("GitHubPullRequestCli.layer", (it) => { mockedExecute.mockReturnValueOnce(Effect.succeed(output(" "))); const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; - const error = yield* Effect.flip(cli.getViewerLogin({ cwd: "/w" })); + const error = yield* Effect.flip(cli.getViewerLogin({ cwd: "/w", host: "github.com" })); assert.strictEqual(error._tag, "GitHubViewerLoginUnavailableError"); }), ); + it.effect("looks up the authenticated account on the requested enterprise host", () => + Effect.gen(function* () { + mockedExecute + .mockReturnValueOnce(Effect.succeed(output("enterprise-test-credential"))) + .mockReturnValueOnce(Effect.succeed(output('{"id":456,"login":"enterprise-user"}'))); + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + + const login = yield* cli.getViewerLogin({ cwd: "/w", host: "github.acme.com" }); + + expect(login).toBe("enterprise-user"); + expect(callAt(0).args).toEqual(["auth", "token", "--hostname", "github.acme.com"]); + expect(callAt(1).args).toEqual(["api", "user", "--hostname", "github.acme.com"]); + }), + ); + + it.effect("reuses verified credentials offline and refuses an unverified replacement", () => + Effect.gen(function* () { + const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; + const input = { cwd: "/w", host: "github.identity-cache.test" }; + mockedExecute + .mockReturnValueOnce(Effect.succeed(output("test-credential-a"))) + .mockReturnValueOnce(Effect.succeed(output('{"id":123,"login":"maria-rcks"}'))); + expect(yield* cli.getRoutingIdentity(input)).toEqual({ + accountId: "123", + viewer: "maria-rcks", + }); + expect(callAt(1).env).toMatchObject({ + GH_ENTERPRISE_TOKEN: "test-credential-a", + GH_DEBUG: "", + }); + + mockedExecute.mockReturnValueOnce(Effect.succeed(output("test-credential-a"))); + expect(yield* cli.getRoutingIdentity(input)).toEqual({ + accountId: "123", + viewer: "maria-rcks", + }); + expect(mockedExecute).toHaveBeenCalledTimes(3); + + mockedExecute + .mockReturnValueOnce(Effect.succeed(output("test-credential-b"))) + .mockReturnValueOnce( + Effect.fail( + new GitHubCli.GitHubCliCommandError({ + command: "gh", + cwd: "/w", + cause: new Error("upstream failed with test-credential-b"), + }), + ), + ); + const failure = yield* cli.getRoutingIdentity(input).pipe(Effect.flip); + expect(failure._tag).toBe("GitHubViewerLoginUnavailableError"); + expect(String(failure)).not.toContain("test-credential-b"); + expect(callAt(4).env).toMatchObject({ + GH_ENTERPRISE_TOKEN: "test-credential-b", + GH_DEBUG: "", + }); + + mockedExecute + .mockReturnValueOnce(Effect.succeed(output("test-credential-b"))) + .mockReturnValueOnce(Effect.succeed(output('{"id":456,"login":"maria-rcks"}'))); + expect(yield* cli.getRoutingIdentity(input)).toEqual({ + accountId: "456", + viewer: "maria-rcks", + }); + }), + ); + it.effect("sends a whole review as one request body over stdin", () => Effect.gen(function* () { mockedExecute.mockReturnValue(Effect.succeed(output("{}"))); diff --git a/apps/server/src/pullRequest/GitHubPullRequestCli.ts b/apps/server/src/pullRequest/GitHubPullRequestCli.ts index 5e1950a6eeb0..2558f6695f1a 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestCli.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestCli.ts @@ -1,11 +1,17 @@ import { runGitHubStackAction, type GitHubStackActionError } from "./githubStackActions.ts"; import * as Context from "effect/Context"; +import * as Clock from "effect/Clock"; +import * as NodeCrypto from "node:crypto"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Redacted from "effect/Redacted"; import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; import { resolvePullRequestAuthorFilter, + PositiveInt, + TrimmedNonEmptyString, type PullRequestAction, type PullRequestStackHead, type PullRequestActor, @@ -413,8 +419,24 @@ export interface GitHubPullRequestDiffSlice { export class GitHubPullRequestCli extends Context.Service< GitHubPullRequestCli, { + readonly withVerifiedCredential: ( + input: { readonly cwd: string; readonly host: string }, + use: (identity: { + readonly accountId: string; + readonly viewer: string; + readonly credentialFingerprint: string; + }) => Effect.Effect, + ) => Effect.Effect; + readonly getRoutingIdentity: (input: { + readonly cwd: string; + readonly host: string; + }) => Effect.Effect< + { readonly accountId: string; readonly viewer: string }, + GitHubPullRequestCliError + >; readonly getViewerLogin: (input: { readonly cwd: string; + readonly host: string; }) => Effect.Effect; readonly listPullRequests: (input: { @@ -991,6 +1013,111 @@ function actionArgs( export const make = Effect.gen(function* () { const github = yield* GitHubCli.GitHubCli; const graphQlBudget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget; + const routingIdentities = new Map< + string, + { + at: number; + value: { accountId: string; viewer: string }; + } + >(); + const identityLocks = new Map(); + const decodeRoutingIdentity = Schema.decodeUnknownEffect( + Schema.fromJsonString( + Schema.Struct({ + id: PositiveInt, + login: TrimmedNonEmptyString, + }), + ), + ); + const captureVerifiedCredential = Effect.fn("GitHubPullRequestCli.captureVerifiedCredential")( + function* (input: { readonly cwd: string; readonly host: string }) { + const unavailable = () => + new GitHubViewerLoginUnavailableError({ command: "gh", cwd: input.cwd }); + const host = input.host.toLowerCase(); + const pinned = yield* GitHubCli.PinnedGitHubCredential; + if (pinned !== null && pinned.host !== host) return yield* unavailable(); + // Only the digest is retained. Never attach credential lookup output to an error. + const token = + pinned !== null + ? Redacted.value(pinned.token) + : (yield* github + .execute({ + cwd: input.cwd, + args: ["auth", "token", "--hostname", host], + env: { GH_DEBUG: "" }, + }) + .pipe(Effect.mapError(unavailable))).stdout.trim(); + if (!token) return yield* unavailable(); + const key = `${host}:${NodeCrypto.createHash("sha256").update(token).digest("hex")}`; + const credential = { host, token: Redacted.make(token), credentialFingerprint: key }; + // A cold page may ask several times. Wait per credential and check again after the + // first verification; cancellation releases the next waiter without losing its request. + return yield* Effect.acquireUseRelease( + Effect.sync(() => { + const lock = identityLocks.get(key) ?? { gate: Semaphore.makeUnsafe(1), users: 0 }; + lock.users++; + identityLocks.set(key, lock); + return lock; + }), + (lock) => + lock.gate.withPermit( + Effect.gen(function* () { + const now = yield* Clock.currentTimeMillis; + const cached = routingIdentities.get(key); + if (cached !== undefined && now - cached.at < 10 * 60_000) + return { ...credential, ...cached.value }; + // Pin this read so an auth switch cannot poison its cache entry. + const response = yield* github + .execute({ + cwd: input.cwd, + args: ["api", "user", "--hostname", host], + env: { + GH_HOST: host, + GH_TOKEN: token, + GITHUB_TOKEN: token, + GH_ENTERPRISE_TOKEN: token, + GITHUB_ENTERPRISE_TOKEN: token, + GH_DEBUG: "", + }, + }) + .pipe(Effect.mapError(unavailable)); + const identity = yield* decodeRoutingIdentity(response.stdout).pipe( + Effect.mapError(unavailable), + ); + const value = { accountId: String(identity.id), viewer: identity.login }; + if (routingIdentities.size >= 128) + routingIdentities.delete(routingIdentities.keys().next().value!); + routingIdentities.set(key, { at: now, value }); + return { ...credential, ...value }; + }), + ), + (lock) => + Effect.sync(() => { + lock.users--; + if (lock.users === 0) identityLocks.delete(key); + }), + ); + }, + ); + const withVerifiedCredential: GitHubPullRequestCli["Service"]["withVerifiedCredential"] = ( + input, + use, + ) => + captureVerifiedCredential(input).pipe( + Effect.flatMap(({ host, token, accountId, viewer, credentialFingerprint }) => + use({ accountId, viewer, credentialFingerprint }).pipe( + Effect.provideService(GitHubCli.PinnedGitHubCredential, { + host, + token, + credentialFingerprint, + }), + ), + ), + ); + const getRoutingIdentity: GitHubPullRequestCli["Service"]["getRoutingIdentity"] = (input) => + captureVerifiedCredential(input).pipe( + Effect.map(({ accountId, viewer }) => ({ accountId, viewer })), + ); /** * The pull request's own node id, which is what a mutation against the pull request itself is @@ -1468,15 +1595,10 @@ export const make = Effect.gen(function* () { ); return GitHubPullRequestCli.of({ + withVerifiedCredential, + getRoutingIdentity, getViewerLogin: (input) => - github.execute({ cwd: input.cwd, args: ["api", "user", "--jq", ".login"] }).pipe( - Effect.flatMap((result) => { - const login = result.stdout.trim(); - return login.length > 0 - ? Effect.succeed(login) - : Effect.fail(new GitHubViewerLoginUnavailableError({ command: "gh", cwd: input.cwd })); - }), - ), + getRoutingIdentity(input).pipe(Effect.map((identity) => identity.viewer)), listPullRequests: (input) => { const fallbackMaxRows = Math.max(input.limit + 1, PULL_REQUEST_FALLBACK_MAX_ROWS); diff --git a/apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts b/apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts index fe5032d70d5c..c2571d91d563 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Redacted from "effect/Redacted"; import type { PullRequestReaction } from "@t3tools/contracts"; import * as GitHubCli from "../sourceControl/GitHubCli.ts"; @@ -8,6 +9,44 @@ import * as GitHubPullRequestCli from "./GitHubPullRequestCli.ts"; import { gitHubViewerPermissions, loginAvatarUrl, make } from "./GitHubPullRequestProvider.ts"; import type { GitHubReviewThreadComments } from "./gitHubPullRequestJson.ts"; +it.effect("maps credential verification failures without relabeling operation failures", () => + Effect.gen(function* () { + let verificationFails = true; + let operations = 0; + const provider = yield* make.pipe( + Effect.provide( + Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({ + withVerifiedCredential: (_input, use) => + verificationFails + ? Effect.fail( + new GitHubPullRequestCli.GitHubViewerLoginUnavailableError({ + command: "gh", + cwd: "/w", + }), + ) + : use({ accountId: "123", viewer: "viewer", credentialFingerprint: "fingerprint" }), + }), + ), + ); + const verify = provider.withVerifiedCredential; + if (verify === undefined) + return yield* Effect.die("credential verification was not implemented"); + const input = { cwd: "/w", host: "github.com" }; + const operation = () => + Effect.sync(() => { + operations++; + }).pipe(Effect.andThen(Effect.fail("operation-failed"))); + expect(yield* verify(input, operation).pipe(Effect.flip)).toMatchObject({ + _tag: "PullRequestProviderError", + operation: "routeIdentity", + }); + expect(operations).toBe(0); + verificationFails = false; + expect(yield* verify(input, operation).pipe(Effect.flip)).toBe("operation-failed"); + expect(operations).toBe(1); + }), +); + it.effect("uses one narrow read for a linked pull request summary", () => Effect.gen(function* () { let summaryReads = 0; @@ -215,6 +254,23 @@ describe("gitHubViewerPermissions", () => { description: "GitHub could not determine whether workflows are awaiting approval.", url: null, }); + for (const fingerprint of ["broad", "restricted", "broad"]) { + const scoped = yield* provider + .getChangeRequest({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + }) + .pipe( + Effect.provideService(GitHubCli.PinnedGitHubCredential, { + host: "github.com", + token: Redacted.make("credential"), + credentialFingerprint: fingerprint, + }), + ); + expect(scoped.mergeCapabilities.squash).toBe(fingerprint !== "restricted"); + } }).pipe( Effect.provide( Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({ @@ -250,10 +306,16 @@ describe("gitHubViewerPermissions", () => { commits: [], }), getRepositoryAccess: () => - Effect.succeed({ - canWrite: false, - mergeCapabilities: { merge: true, squash: true, rebase: true }, - }), + GitHubCli.PinnedGitHubCredential.pipe( + Effect.map((credential) => ({ + canWrite: false, + mergeCapabilities: { + merge: true, + squash: credential?.credentialFingerprint !== "restricted", + rebase: true, + }, + })), + ), getViewerAccess: () => Effect.succeed({ canWrite: false, @@ -513,6 +575,36 @@ it.effect("propagates workflow discovery rate limits", () => ); describe("getViewerPermissions", () => { + it.effect("checks fresh access without reading branch details for unrelated operations", () => { + let accessReads = 0; + return Effect.gen(function* () { + const provider = yield* make; + const permissions = yield* provider.getViewerPermissions({ + cwd: "/w", + repository: "acme/web", + host: "github.com", + number: 7, + includeUpdateBranch: false, + }); + + expect(accessReads).toBe(1); + expect(permissions.actions).toContain("merge"); + expect(permissions.actions).not.toContain("update-branch"); + }).pipe( + Effect.provide( + Layer.mock(GitHubPullRequestCli.GitHubPullRequestCli)({ + getPullRequestDetail: () => Effect.die("Unexpected detail read"), + getPullRequestBaseComparison: () => Effect.die("Unexpected comparison read"), + getViewerAccess: () => + Effect.sync(() => { + accessReads++; + return { canWrite: true, canTriage: true, canUpdate: true, didAuthor: false }; + }), + }), + ), + ); + }); + const layerWithComparison = ( comparison: Effect.Effect<{ readonly behindBy: number | null; diff --git a/apps/server/src/pullRequest/GitHubPullRequestProvider.ts b/apps/server/src/pullRequest/GitHubPullRequestProvider.ts index 66a247efb90e..f8019ca36b4f 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestProvider.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestProvider.ts @@ -11,6 +11,7 @@ import type { } from "@t3tools/contracts"; import * as GitHubPullRequestCli from "./GitHubPullRequestCli.ts"; +import { PinnedGitHubCredential } from "../sourceControl/GitHubCli.ts"; import { PullRequestProviderError, type PullRequestProviderFailure, @@ -198,7 +199,20 @@ export const make = Effect.gen(function* () { readonly cwd: string; readonly repository: string; readonly host: string; - }) => Cache.get(repositoryAccessCache, JSON.stringify([input.cwd, input.repository, input.host])); + }) => + PinnedGitHubCredential.pipe( + Effect.flatMap((credential) => + Cache.get( + repositoryAccessCache, + JSON.stringify([ + input.cwd, + input.repository, + input.host, + credential?.credentialFingerprint ?? null, + ]), + ), + ), + ); const fail = (operation: string) => (error: GitHubPullRequestCli.GitHubPullRequestCliError) => new PullRequestProviderError({ @@ -212,9 +226,17 @@ export const make = Effect.gen(function* () { const provider: PullRequestProviderApi = { kind: "github", capabilities: CAPABILITIES, + getRoutingIdentity: (input) => + cli.getRoutingIdentity(input).pipe(Effect.mapError(fail("routeIdentity"))), + withVerifiedCredential: (input, use) => + cli + .withVerifiedCredential(input, (identity) => use(identity).pipe(Effect.result)) + .pipe(Effect.mapError(fail("routeIdentity")), Effect.flatMap(Effect.fromResult)), getViewer: (input) => - cli.getViewerLogin({ cwd: input.cwd }).pipe(Effect.mapError(fail("getViewer"))), + cli + .getViewerLogin({ cwd: input.cwd, host: input.host ?? "github.com" }) + .pipe(Effect.mapError(fail("getViewer"))), listChangeRequests: (input) => cli @@ -494,20 +516,22 @@ export const make = Effect.gen(function* () { // comparison only resolves through the head ref the detail carries. A failure here // withholds that one action rather than the whole answer, the way the detail path // leaves the banner unknown. - cli.getPullRequestDetail(input).pipe( - Effect.flatMap((pullRequest) => - pullRequest.state !== "open" || pullRequest.headRepositoryOwner === null - ? Effect.succeed(false) - : cli - .getPullRequestBaseComparison({ - ...input, - headRef: `${pullRequest.headRepositoryOwner}:${pullRequest.headBranch}`, - allowReserve: true, - }) - .pipe(Effect.map((comparison) => comparison.viewerCanUpdate === true)), - ), - Effect.orElseSucceed(() => false), - ), + input.includeUpdateBranch === false + ? Effect.succeed(false) + : cli.getPullRequestDetail(input).pipe( + Effect.flatMap((pullRequest) => + pullRequest.state !== "open" || pullRequest.headRepositoryOwner === null + ? Effect.succeed(false) + : cli + .getPullRequestBaseComparison({ + ...input, + headRef: `${pullRequest.headRepositoryOwner}:${pullRequest.headBranch}`, + allowReserve: true, + }) + .pipe(Effect.map((comparison) => comparison.viewerCanUpdate === true)), + ), + Effect.orElseSucceed(() => false), + ), ], { concurrency: 2 }, ).pipe( diff --git a/apps/server/src/pullRequest/PullRequestProvider.ts b/apps/server/src/pullRequest/PullRequestProvider.ts index 2309268bba68..405eb6ae9e6c 100644 --- a/apps/server/src/pullRequest/PullRequestProvider.ts +++ b/apps/server/src/pullRequest/PullRequestProvider.ts @@ -276,12 +276,28 @@ export interface ProviderRepositoryRef { * failing at call time. */ export interface PullRequestProviderApi { + readonly withVerifiedCredential?: ( + input: { readonly cwd: string; readonly host: string }, + use: (identity: { + readonly accountId: string; + readonly viewer: string; + readonly credentialFingerprint: string; + }) => Effect.Effect, + ) => Effect.Effect; + readonly getRoutingIdentity?: (input: { + readonly cwd: string; + readonly host: string; + }) => Effect.Effect< + { readonly accountId: string; readonly viewer: string }, + PullRequestProviderError + >; readonly kind: SourceControlProviderKind; readonly capabilities: PullRequestCapabilities; /** The signed-in account, which is what involvement filtering compares against. */ readonly getViewer: (input: { readonly cwd: string; + readonly host?: string; }) => Effect.Effect; readonly listChangeRequests: ( @@ -397,7 +413,11 @@ export interface PullRequestProviderApi { * is no request at all. */ readonly getViewerPermissions: ( - input: ProviderRepositoryRef & { readonly number: number }, + input: ProviderRepositoryRef & { + readonly number: number; + /** Skip branch comparison when checking permission for an unrelated operation. */ + readonly includeUpdateBranch?: boolean; + }, ) => Effect.Effect; /** 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..d354fd36d317 100644 --- a/apps/server/src/pullRequest/PullRequestService.test.ts +++ b/apps/server/src/pullRequest/PullRequestService.test.ts @@ -16,6 +16,7 @@ import type { PullRequestReviewerCapabilities, SourceControlProviderKind, } from "@t3tools/contracts"; +import { PullRequestOperationError } from "@t3tools/contracts"; import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts"; @@ -974,6 +975,51 @@ it.effect("tries another workspace on the same host for the viewer", () => }), ); +it.effect("routing verifies the current account on the requested host without caching it", () => + Effect.gen(function* () { + let viewer = "first-account"; + const service = yield* makeService({ + projects: [ + project({ + id: "p1", + title: "web", + workspaceRoot: "/a", + repository: "acme/web", + host: "github.example.test", + }), + ], + providers: [ + fakeProvider("github", { + getRoutingIdentity: (input) => { + assert.deepStrictEqual(input, { cwd: "/a", host: "github.example.test" }); + return Effect.succeed({ + viewer, + accountId: viewer === "first-account" ? "123" : "456", + }); + }, + }), + ], + }); + const ref = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; + assert.deepStrictEqual(yield* service.routing(ref), { + host: "github.example.test", + provider: "github", + viewer: "first-account", + accountId: "123", + projectTitle: "web", + workspaceRoot: "/a", + }); + viewer = "second-account"; + assert.strictEqual((yield* service.routing(ref)).viewer, "second-account"); + viewer = " "; + const failure = yield* service.routing(ref).pipe(Effect.flip); + assert.strictEqual(failure._tag, "PullRequestOperationError"); + if (failure._tag === "PullRequestOperationError") { + assert.strictEqual(failure.operation, "routeIdentity"); + } + }), +); + it.effect("refuses an action the host never claimed it could run", () => Effect.gen(function* () { let ran = false; @@ -1659,6 +1705,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 }> = []; @@ -3595,9 +3753,7 @@ it.effect("shares linked summaries and reuses them for display without asking th yield* TestClock.adjust("61 seconds"); failing = true; - const strict = yield* Effect.flip( - service.summary(reference, { recoverTransientFailure: false }), - ); + const strict = yield* Effect.flip(service.summary({ ...reference, allowStale: false })); assert.strictEqual(strict._tag, "PullRequestOperationError"); const stale = yield* service.summary(reference); @@ -3611,6 +3767,147 @@ it.effect("shares linked summaries and reuses them for display without asking th }), ); +it.effect("keeps routed summaries and details separate when the GitHub account changes", () => + Effect.gen(function* () { + for (const operation of ["summary", "detail"] as const) { + let failing = false; + let calls = 0; + const read = () => + Effect.suspend(() => { + calls += 1; + return failing + ? Effect.fail(requestFailed) + : Effect.succeed(hostedChangeRequest("account A content")); + }); + const service = yield* makeService({ + projects: [ + project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }), + ], + providers: [ + fakeProvider("github", { getChangeRequestSummary: read, getChangeRequest: read }), + ], + }); + const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; + yield* service[operation]({ ...reference, expectedAccountId: "101" }); + failing = true; + + for (const allowStale of [false, true]) { + const error = yield* Effect.flip( + service[operation]({ ...reference, expectedAccountId: "202", allowStale }), + ); + assert.strictEqual(error._tag, "PullRequestOperationError"); + } + assert.strictEqual(calls, 3); + } + }), +); + +it.effect("isolates routed caches for two credentials belonging to the same account", () => + Effect.gen(function* () { + for (const operation of ["summary", "detail"] as const) { + let credential = "broad"; + let calls = 0; + const read = () => + Effect.suspend(() => { + calls += 1; + return credential === "broad" + ? Effect.succeed(hostedChangeRequest("private content")) + : Effect.fail(requestFailed); + }); + const service = yield* makeService({ + projects: [ + project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }), + ], + providers: [ + fakeProvider("github", { + withVerifiedCredential: (_, use) => + Effect.suspend(() => + use({ + accountId: "101", + viewer: "octocat", + credentialFingerprint: credential, + }), + ), + getChangeRequest: read, + getChangeRequestSummary: read, + }), + ], + }); + const reference = { + projectId: "p1" as ProjectId, + repository: "acme/web", + number: 1, + host: "github.com", + expectedAccountId: "101", + }; + yield* service.withRoutingCredential(reference, service[operation](reference)); + credential = "restricted"; + for (const allowStale of [false, true]) { + const error = yield* Effect.flip( + service.withRoutingCredential( + reference, + service[operation]({ ...reference, allowStale }), + ), + ); + assert.strictEqual(error._tag, "PullRequestOperationError"); + } + assert.strictEqual(calls, 3); + } + }), +); + +it.effect("rejects mismatched routing credentials before use and preserves action errors", () => + Effect.gen(function* () { + let operations = 0; + const service = yield* makeService({ + projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], + providers: [ + fakeProvider("github", { + getRoutingIdentity: () => Effect.succeed({ accountId: "101", viewer: "octocat" }), + withVerifiedCredential: (_, use) => + use({ + accountId: "101", + viewer: "octocat", + credentialFingerprint: "credential-a", + }), + }), + ], + }); + const reference = { + projectId: "p1" as ProjectId, + repository: "acme/web", + number: 1, + host: "github.com", + expectedAccountId: "202", + }; + const actionError = new PullRequestOperationError({ + operation: "runAction", + detail: "ambiguous", + }); + const operation = Effect.sync(() => { + operations += 1; + }).pipe(Effect.andThen(Effect.fail(actionError))); + const rejected = yield* Effect.flip(service.withRoutingCredential(reference, operation)); + assert.strictEqual(rejected._tag, "PullRequestOperationError"); + if (rejected._tag === "PullRequestOperationError") + assert.strictEqual(rejected.operation, "routeIdentity"); + assert.strictEqual(operations, 0); + assert.strictEqual( + yield* Effect.flip( + service.withRoutingCredential({ ...reference, expectedAccountId: "101" }, operation), + ), + actionError, + ); + assert.strictEqual(operations, 1); + assert.deepStrictEqual(yield* service.routingIdentity({ host: "github.com" }), { + accountId: "101", + viewer: "octocat", + host: "github.com", + provider: "github", + }); + }), +); + it.effect("answers a known pull request immediately while the host refreshes", () => Effect.gen(function* () { const gate = yield* Deferred.make(); @@ -3783,6 +4080,13 @@ it.effect("does not let a stale detail reopen overwrite a fresher linked summary assert.strictEqual(display.title, "merged title"); assert.strictEqual(display.state, "merged"); assert.strictEqual(detailCalls, 2); + + summaryTitle = "updated after merge"; + yield* TestClock.adjust("61 seconds"); + assert.strictEqual((yield* service.summary(reference)).title, "merged title"); + const refreshed = yield* service.summary({ ...reference, allowStale: false }); + assert.strictEqual(refreshed.title, "updated after merge"); + assert.strictEqual(refreshed.state, "merged"); }), ); @@ -3866,6 +4170,8 @@ it.effect("keeps recent detail on a transient refresh failure but not after inva yield* service.detail(reference); yield* TestClock.adjust("16 seconds"); failing = true; + const strict = yield* Effect.flip(service.detail({ ...reference, allowStale: false })); + assert.strictEqual(strict._tag, "PullRequestOperationError"); const stale = yield* service.detail(reference); assert.strictEqual(stale.body, "last good body"); diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts index 241a9e8b1836..e4bd807cbefd 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"; @@ -45,6 +46,9 @@ import { type PullRequestProviderSummary, type PullRequestReactionInput, type PullRequestRef, + type PullRequestRoutingResult, + type PullRequestRoutingIdentityInput, + type PullRequestRoutingIdentityResult, type PullRequestReviewVerdict, type PullRequestReviewerCandidateList, type PullRequestReviewerRequestInput, @@ -137,6 +141,14 @@ const VIEWER_CACHE_CAPACITY = 32; export type PullRequestError = PullRequestUnavailableError | PullRequestOperationError; +const routingCredential = Context.Reference<{ + readonly credentialFingerprint: string; + readonly viewer: string; +} | null>("t3/PullRequestService/routingCredential", { defaultValue: () => null }); +// Internal only: the client cannot choose its cache's credential namespace. +const credentialNamespace = Symbol("pullRequestCredentialNamespace"); +type CredentialRef = PullRequestRef & { readonly [credentialNamespace]?: string }; + export class PullRequestService extends Context.Service< PullRequestService, { @@ -146,6 +158,16 @@ export class PullRequestService extends Context.Service< readonly listStats: ( input: PullRequestListStatsInput, ) => Effect.Effect; + readonly routing: ( + input: PullRequestRef, + ) => Effect.Effect; + readonly routingIdentity: ( + input: PullRequestRoutingIdentityInput, + ) => Effect.Effect; + readonly withRoutingCredential: ( + input: PullRequestRef, + operation: Effect.Effect, + ) => Effect.Effect; readonly summary: ( input: PullRequestRef, options?: { readonly recoverTransientFailure?: boolean }, @@ -472,6 +494,10 @@ function withRateLimitBackoff( kind: api.kind, capabilities: api.capabilities, getViewer: wrap("getViewer", api.getViewer), + ...(api.getRoutingIdentity === undefined ? {} : { getRoutingIdentity: api.getRoutingIdentity }), + ...(api.withVerifiedCredential === undefined + ? {} + : { withVerifiedCredential: api.withVerifiedCredential }), listChangeRequests: wrap("listChangeRequests", api.listChangeRequests), ...(api.listChangeRequestsAcross === undefined ? {} @@ -553,14 +579,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 +614,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 +658,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 +679,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. @@ -750,13 +801,19 @@ export const make = Effect.gen(function* () { * handed to a provider on the client's word. Read freshly for that reason, rather than taken * from whatever the detail said when the page loaded. */ - const viewerPermissionsOf = (project: SupportedProject, ref: PullRequestRef, operation: string) => + const viewerPermissionsOf = ( + project: SupportedProject, + ref: PullRequestRef, + operation: string, + includeUpdateBranch = false, + ) => project.api .getViewerPermissions({ cwd: project.project.workspaceRoot, repository: project.repository, host: project.host, number: ref.number, + includeUpdateBranch, }) .pipe(Effect.mapError(toPullRequestError(operation))); @@ -816,7 +873,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, @@ -1283,7 +1340,92 @@ export const make = Effect.gen(function* () { * and a host that cannot say leaves it null rather than failing the read it decorates. */ const viewerOf = (project: SupportedProject): Effect.Effect => - resolveViewers([project], new Map()).pipe(Effect.map(([resolved]) => resolved?.viewer ?? null)); + routingCredential.pipe( + Effect.flatMap((credential) => + credential !== null + ? Effect.succeed(credential.viewer) + : resolveViewers([project], new Map()).pipe( + Effect.map(([resolved]) => resolved?.viewer ?? null), + ), + ), + ); + + const routingIdentity: PullRequestService["Service"]["routingIdentity"] = Effect.fn( + "PullRequestService.routingIdentity", + )(function* (input) { + const host = input.host.toLowerCase(); + const { supported } = yield* listWorkspaceProjects({ host }); + const project = supported.find((candidate) => candidate.api.kind === "github"); + const api = registry.get("github"); + if (project === undefined || api?.getRoutingIdentity === undefined) { + return yield* new PullRequestUnavailableError({ reason: "provider-unsupported" }); + } + const identity = yield* api + .getRoutingIdentity({ + cwd: project.project.workspaceRoot, + host, + }) + .pipe(Effect.mapError(toPullRequestError("routeIdentity"))); + return { ...identity, host, provider: "github" as const }; + }); + + const withRoutingCredential: PullRequestService["Service"]["withRoutingCredential"] = ( + input, + operation, + ) => + Effect.gen(function* () { + if (input.expectedAccountId === undefined) return yield* operation; + const rejected = () => + new PullRequestOperationError({ + operation: "routeIdentity", + detail: "The GitHub account could not be verified before starting the operation.", + }); + const project = yield* requireProject(input).pipe(Effect.mapError(rejected)); + const api = project.api.kind === "github" ? registry.get("github") : null; + if ( + api?.withVerifiedCredential === undefined || + input.host?.toLowerCase() !== project.host.toLowerCase() + ) { + return yield* rejected(); + } + const result = yield* api + .withVerifiedCredential( + { cwd: project.project.workspaceRoot, host: project.host }, + (identity) => + identity.accountId === input.expectedAccountId + ? operation.pipe(Effect.provideService(routingCredential, identity), Effect.result) + : Effect.fail(rejected()), + ) + .pipe(Effect.catchTag("PullRequestProviderError", () => Effect.fail(rejected()))); + return yield* Effect.fromResult(result); + }); + + const routing = Effect.fn("PullRequestService.routing")(function* (input: PullRequestRef) { + const project = yield* requireProject(input); + const api = project.api.kind === "github" ? registry.get("github") : null; + if (api?.getRoutingIdentity === undefined) { + return yield* new PullRequestUnavailableError({ reason: "provider-unsupported" }); + } + const identity = yield* api + .getRoutingIdentity({ + cwd: project.project.workspaceRoot, + host: project.host, + }) + .pipe(Effect.mapError(toPullRequestError("routeIdentity"))); + if (!identity.viewer.trim() || !identity.accountId.trim()) { + return yield* new PullRequestOperationError({ + operation: "routeIdentity", + detail: "The signed-in account could not be verified.", + }); + } + return { + host: project.host, + provider: project.api.kind, + ...identity, + projectTitle: project.project.title, + workspaceRoot: project.project.workspaceRoot, + }; + }); const summaryUncached: PullRequestService["Service"]["summary"] = (input) => requireProject(input).pipe( @@ -1594,7 +1736,12 @@ export const make = Effect.gen(function* () { // What the host can do and what this account may ask of it are two questions, and both // have to say yes. The second is asked last, because it costs a request and the checks // above do not. - return viewerPermissionsOf(project, input, "runAction").pipe( + return viewerPermissionsOf( + project, + input, + "runAction", + input.action === "update-branch", + ).pipe( Effect.flatMap((viewer): Effect.Effect => { const stackRebase = input.stackNumber !== undefined && input.action === "update-branch"; if ( @@ -2167,6 +2314,12 @@ export const make = Effect.gen(function* () { const context = yield* Effect.context(); const runFork = Effect.runForkWith(context); + const revalidate = (read: Effect.Effect) => + Effect.context().pipe( + Effect.flatMap((caller) => + Effect.sync(() => runFork(Effect.ignore(read).pipe(Effect.provideContext(caller)))), + ), + ); /** * The diff is not live-polled and is expensive enough to keep its stale-while-revalidate path. @@ -2192,7 +2345,7 @@ export const make = Effect.gen(function* () { // Run as its own fiber rather than a child: the caller is answered and gone before the // refresh lands. The read still coalesces on the cache key, so ten stale reads in one // window cost one host request — and a failed refresh costs nothing but the retry. - return Effect.sync(() => runFork(Effect.ignore(recorded))).pipe(Effect.as(snapshot.value)); + return revalidate(recorded).pipe(Effect.as(snapshot.value)); }); }; })(); @@ -2250,9 +2403,7 @@ export const make = Effect.gen(function* () { const snapshot = held.get(key); if (snapshot === undefined) return read(key, effect); if (mode === "reuse") return Effect.succeed(snapshot.value); - return Effect.sync(() => runFork(Effect.ignore(read(key, effect)))).pipe( - Effect.as(snapshot.value), - ); + return revalidate(read(key, effect)).pipe(Effect.as(snapshot.value)); }; return { peek: (key: string) => held.get(key)?.value, read, record, serveHeld }; }; @@ -2274,25 +2425,25 @@ export const make = Effect.gen(function* () { Math.max(turnRefreshEpoch, refEpochs.get(refScope(ref)) ?? 0); // Keys carry the reference back out of the cache loader, so the slot layout is shared with // `refOfCacheKey` rather than read positionally at every loader. - const refCacheKey = (ref: PullRequestRef) => + const refCacheKey = (ref: CredentialRef) => JSON.stringify([ refEpoch(ref), ref.projectId, ref.host?.toLowerCase() ?? null, ref.repository.toLowerCase(), ref.number, + ref.expectedAccountId ?? null, + ref[credentialNamespace] ?? null, ]); const refOfCacheKey = (key: string): PullRequestRef => { - const [, projectId, host, repository, number] = JSON.parse(key) as [ - number, - string, - string | null, - string, - number, - ]; + const [, projectId, host, repository, number, expectedAccountId, fingerprint] = JSON.parse( + key, + ) as [number, string, string | null, string, number, string | null, string | null]; return { projectId, ...(host === null ? {} : { host }), + ...(expectedAccountId === null ? {} : { expectedAccountId }), + ...(fingerprint === null ? {} : { [credentialNamespace]: fingerprint }), repository, number, } as PullRequestRef; @@ -2339,7 +2490,7 @@ export const make = Effect.gen(function* () { }; const persistedRead = Effect.fn("PullRequestService.persistedRead")(function* ( - input: PullRequestRef, + input: CredentialRef, operation: string, codec: Schema.Codec, read: Effect.Effect, @@ -2353,6 +2504,8 @@ export const make = Effect.gen(function* () { project.project.id, project.project.workspaceRoot, String(input.number), + input.expectedAccountId ?? "", + input[credentialNamespace] ?? "", ] .map(encodeURIComponent) .join(":"); @@ -2383,6 +2536,7 @@ export const make = Effect.gen(function* () { const cached = persistedRead(input, "summary", summaryCodec, summaryUncached(input)); const held = lastGoodSummary.peek(key); return held !== undefined && + input.allowStale !== false && (options?.recoverTransientFailure !== false || held.state === "merged") ? Effect.succeed(held) : cached.pipe( @@ -2540,18 +2694,17 @@ export const make = Effect.gen(function* () { // `serveHeld` returns immediately. Skip the write when that read is older // than a later strict summary — display reuse would otherwise keep the // regression and never ask the host again. - return lastGoodDetail.serveHeld( - key, - Cache.get(detailCache, key).pipe( - Effect.tap((value) => { - const summary = summaryFromDetail(value, lastGoodSummary.peek(key)); - return shouldReplaceHeldSummary(key, summary) - ? lastGoodSummary.record(key, summary) - : Effect.void; - }), - ), - "revalidate", + const read = Cache.get(detailCache, key).pipe( + Effect.tap((value) => { + const summary = summaryFromDetail(value, lastGoodSummary.peek(key)); + return shouldReplaceHeldSummary(key, summary) + ? lastGoodSummary.record(key, summary) + : Effect.void; + }), ); + return input.allowStale === false + ? read.pipe(Effect.tap((value) => lastGoodDetail.record(key, value))) + : lastGoodDetail.serveHeld(key, read, "revalidate"); }; const activityCache = yield* Cache.makeWith( @@ -2732,11 +2885,33 @@ export const make = Effect.gen(function* () { } }); + const credentialCached = + , A, E>( + read: (input: I, ...args: Args) => Effect.Effect, + ) => + (input: I, ...args: Args) => + routingCredential.pipe( + Effect.flatMap((credential) => + read( + credential === null + ? input + : { + ...input, + [credentialNamespace]: credential.credentialFingerprint, + }, + ...args, + ), + ), + ); + return PullRequestService.of({ + routing, + routingIdentity, + withRoutingCredential, list, listStats, - summary, - stack, + summary: credentialCached(summary), + stack: credentialCached(stack), subscribeMerges: PubSub.subscribe(mergedPullRequests).pipe( Effect.map((subscription) => Stream.fromSubscription(subscription)), ), @@ -2744,8 +2919,8 @@ export const make = Effect.gen(function* () { Stream.filter((revision) => revision > 0), ), refreshAfterTurn, - detail, - activity, + detail: credentialCached(detail), + activity: credentialCached(activity), threadComments, diff, diffFileContents, 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/GitHubCli.test.ts b/apps/server/src/sourceControl/GitHubCli.test.ts index f72259b677eb..d3fe840fa64c 100644 --- a/apps/server/src/sourceControl/GitHubCli.test.ts +++ b/apps/server/src/sourceControl/GitHubCli.test.ts @@ -1,13 +1,18 @@ import { assert, it, afterEach, describe, expect, vi } from "@effect/vitest"; +import * as Cache from "effect/Cache"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as PlatformError from "effect/PlatformError"; +import * as Redacted from "effect/Redacted"; +import * as Schema from "effect/Schema"; import { ChildProcessSpawner } from "effect/unstable/process"; import { VcsProcessExitError, VcsProcessSpawnError } from "@t3tools/contracts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as GitHubCli from "./GitHubCli.ts"; +const encodeGitHubCliError = Schema.encodeEffect(Schema.fromJsonString(GitHubCli.GitHubCliError)); + const processOutput = (stdout: string): VcsProcess.VcsProcessOutput => ({ exitCode: ChildProcessSpawner.ExitCode(0), stdout, @@ -31,6 +36,111 @@ afterEach(() => { }); describe("GitHubCli.layer", () => { + it.effect("pins concurrent cached commands to their own verified credentials", () => + Effect.gen(function* () { + mockRun.mockImplementation((input) => + Effect.succeed(processOutput(input.env?.GH_TOKEN ?? "ambient")), + ); + const gh = yield* GitHubCli.GitHubCli; + // Constructed outside either request, like the PR service's read caches. + const cache = yield* Cache.make({ + lookup: (host: string) => + gh.execute({ + cwd: "/repo", + args: ["api", "user", "--hostname", host], + env: { GH_DEBUG: "api", GH_TOKEN: "changed-after-verification" }, + }), + capacity: 2, + timeToLive: "1 minute", + }); + const results = yield* Effect.all( + ["github.com", "github.example.test"].map((host, index) => + Cache.get(cache, host).pipe( + Effect.provideService(GitHubCli.PinnedGitHubCredential, { + host, + token: Redacted.make(`credential-${index}`), + credentialFingerprint: `fingerprint-${index}`, + }), + ), + ), + { concurrency: 2 }, + ); + expect(results.map((result) => result.stdout)).toEqual(["credential-0", "credential-1"]); + for (const [input] of mockRun.mock.calls) { + expect(input.env).toMatchObject({ + GH_HOST: input.args[3], + GH_DEBUG: "", + GH_TOKEN: input.env?.GITHUB_TOKEN, + GH_ENTERPRISE_TOKEN: input.env?.GH_TOKEN, + GITHUB_ENTERPRISE_TOKEN: input.env?.GH_TOKEN, + }); + } + expect((yield* gh.execute({ cwd: "/repo", args: ["api", "user"] })).stdout).toBe("ambient"); + }).pipe(Effect.provide(layer)), + ); + + it.effect("refuses other or implicit hosts before exposing a scoped credential to gh", () => + Effect.gen(function* () { + const gh = yield* GitHubCli.GitHubCli; + for (const args of [ + ["api", "user", "--hostname", "other.example.test"], + ["api", "user", "--hostname=other.example.test"], + ["pr", "view", "1", "--repo", "other.example.test/owner/repo"], + ["repo", "view", "other.example.test/owner/repo", "--json", "name"], + ["api", "https://other.example.test/user", "--hostname", "github.com"], + ["api", "user"], + ]) { + const failure = yield* gh.execute({ cwd: "/repo", args }).pipe( + Effect.provideService(GitHubCli.PinnedGitHubCredential, { + host: "github.com", + token: Redacted.make("secret-credential"), + credentialFingerprint: "fingerprint", + }), + Effect.flip, + ); + expect(failure._tag).toBe("GitHubCliCommandError"); + expect(yield* encodeGitHubCliError(failure)).not.toContain("secret-credential"); + } + expect(mockRun).not.toHaveBeenCalled(); + }).pipe(Effect.provide(layer)), + ); + + it.effect("pins repository-targeted writes on enterprise hosts", () => + Effect.gen(function* () { + mockRun.mockReturnValue(Effect.succeed(processOutput(""))); + const gh = yield* GitHubCli.GitHubCli; + yield* gh + .execute({ + cwd: "/repo", + args: ["pr", "merge", "1", "--repo", "github.example.test/owner/repo"], + }) + .pipe( + Effect.provideService(GitHubCli.PinnedGitHubCredential, { + host: "github.example.test", + token: Redacted.make("enterprise-credential"), + credentialFingerprint: "fingerprint", + }), + ); + yield* gh + .execute({ + cwd: "/repo", + args: ["repo", "view", "github.example.test/owner/repo", "--json", "name"], + }) + .pipe( + Effect.provideService(GitHubCli.PinnedGitHubCredential, { + host: "github.example.test", + token: Redacted.make("enterprise-credential"), + credentialFingerprint: "fingerprint", + }), + ); + expect(mockRun.mock.calls[0]?.[0].env).toMatchObject({ + GH_HOST: "github.example.test", + GH_ENTERPRISE_TOKEN: "enterprise-credential", + GH_DEBUG: "", + }); + }).pipe(Effect.provide(layer)), + ); + it("does not classify a missing cwd as an unavailable gh executable", () => { const context = { command: "gh", cwd: "/repo" } as const; const missingCwd = new VcsProcessSpawnError({ diff --git a/apps/server/src/sourceControl/GitHubCli.ts b/apps/server/src/sourceControl/GitHubCli.ts index cd8782c87d9e..30b0e4a09231 100644 --- a/apps/server/src/sourceControl/GitHubCli.ts +++ b/apps/server/src/sourceControl/GitHubCli.ts @@ -4,6 +4,7 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as PlatformError from "effect/PlatformError"; +import * as Redacted from "effect/Redacted"; import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; @@ -22,6 +23,40 @@ import { const DEFAULT_TIMEOUT_MS = 30_000; +/** Server-local credential scope; never put its value in RPC payloads or cache keys. */ +export const PinnedGitHubCredential = Context.Reference<{ + readonly host: string; + readonly token: Redacted.Redacted; + readonly credentialFingerprint: string; +} | null>("t3/sourceControl/PinnedGitHubCredential", { defaultValue: () => null }); + +function targetsVerifiedHost(args: ReadonlyArray, host: string): boolean { + const hosts: Array = []; + const repositoryHost = (repository: string | undefined) => { + if (repository === undefined) return null; + if (/^https?:\/\//i.test(repository)) { + try { + return new URL(repository).host.toLowerCase(); + } catch { + return null; + } + } + const parts = repository.split("/"); + return parts.length === 3 ? parts[0]!.toLowerCase() : null; + }; + if (args[0] === "repo" && args[1] === "view") hosts.push(repositoryHost(args[2])); + for (let index = 0; index < args.length; index++) { + const arg = args[index]!; + if (arg === "--hostname") hosts.push(args[++index]?.toLowerCase() ?? null); + else if (arg.startsWith("--hostname=")) hosts.push(arg.slice(11).toLowerCase()); + else if (arg === "--repo" || arg === "-R") hosts.push(repositoryHost(args[++index])); + else if (arg.startsWith("--repo=")) hosts.push(repositoryHost(arg.slice(7))); + else if (arg.startsWith("-R")) hosts.push(repositoryHost(arg.slice(2))); + else if (/^https?:\/\//i.test(arg)) hosts.push(repositoryHost(arg)); + } + return hosts.length > 0 && hosts.every((target) => target === host); +} + const gitHubCliFailureFields = { command: Schema.Literal("gh"), cwd: Schema.String, @@ -237,6 +272,7 @@ export class GitHubCli extends Context.Service< readonly timeoutMs?: number; /** Piped to the child's stdin, for payloads that must never appear in argv. */ readonly stdin?: string; + readonly env?: NodeJS.ProcessEnv; readonly maxOutputBytes?: number; }) => Effect.Effect; @@ -342,18 +378,43 @@ function deriveRepositoryCloneUrlsFromCreateOutput( export const make = Effect.gen(function* () { const process = yield* VcsProcess.VcsProcess; - const execute: GitHubCli["Service"]["execute"] = (input) => - process - .run({ - operation: "GitHubCli.execute", - command: "gh", - args: input.args, - cwd: input.cwd, - timeoutMs: input.timeoutMs ?? DEFAULT_TIMEOUT_MS, - ...(input.stdin !== undefined ? { stdin: input.stdin } : {}), - ...(input.maxOutputBytes !== undefined ? { maxOutputBytes: input.maxOutputBytes } : {}), - }) - .pipe(Effect.mapError((error) => fromVcsError({ command: "gh", cwd: input.cwd }, error))); + const execute: GitHubCli["Service"]["execute"] = Effect.fn("GitHubCli.execute")( + function* (input) { + const credential = yield* PinnedGitHubCredential; + if (credential !== null && !targetsVerifiedHost(input.args, credential.host)) { + return yield* new GitHubCliCommandError({ + command: "gh", + cwd: input.cwd, + cause: new Error("The GitHub command does not target the verified credential's host."), + }); + } + const token = credential === null ? undefined : Redacted.value(credential.token); + const env = + credential === null + ? input.env + : { + ...input.env, + GH_HOST: credential.host, + GH_TOKEN: token, + GITHUB_TOKEN: token, + GH_ENTERPRISE_TOKEN: token, + GITHUB_ENTERPRISE_TOKEN: token, + GH_DEBUG: "", + }; + return yield* process + .run({ + operation: "GitHubCli.execute", + command: "gh", + args: input.args, + cwd: input.cwd, + timeoutMs: input.timeoutMs ?? DEFAULT_TIMEOUT_MS, + ...(input.stdin !== undefined ? { stdin: input.stdin } : {}), + ...(env !== undefined ? { env } : {}), + ...(input.maxOutputBytes !== undefined ? { maxOutputBytes: input.maxOutputBytes } : {}), + }) + .pipe(Effect.mapError((error) => fromVcsError({ command: "gh", cwd: input.cwd }, error))); + }, + ); return GitHubCli.of({ execute, 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/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 e91fadd3155e..a4898ce5ef19 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"; @@ -230,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", @@ -623,6 +630,7 @@ const makeWsRpcLayer = ( const sourceControlRepositories = yield* SourceControlRepositoryService.SourceControlRepositoryService; const pullRequests = yield* PullRequestService.PullRequestService; + const withPullRequestViewer = pullRequests.withRoutingCredential; const pullRequestSync = yield* PullRequestSyncReactor.PullRequestSyncReactor; const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore; const sessions = yield* SessionStore.SessionStore; @@ -2149,14 +2157,34 @@ const makeWsRpcLayer = ( observeRpcEffect(WS_METHODS.pullRequestsListStats, pullRequests.listStats(input), { "rpc.aggregate": "pull-requests", }), - [WS_METHODS.pullRequestsSummary]: (input) => - observeRpcEffect(WS_METHODS.pullRequestsSummary, pullRequests.summary(input), { + [WS_METHODS.pullRequestsRoutingIdentity]: (input) => + observeRpcEffect( + WS_METHODS.pullRequestsRoutingIdentity, + pullRequests.routingIdentity(input), + { + "rpc.aggregate": "pull-requests", + }, + ), + [WS_METHODS.pullRequestsRouting]: (input) => + observeRpcEffect(WS_METHODS.pullRequestsRouting, pullRequests.routing(input), { "rpc.aggregate": "pull-requests", }), + [WS_METHODS.pullRequestsSummary]: (input) => + observeRpcEffect( + WS_METHODS.pullRequestsSummary, + withPullRequestViewer(input, pullRequests.summary(input)), + { + "rpc.aggregate": "pull-requests", + }, + ), [WS_METHODS.pullRequestsStack]: (input) => - observeRpcEffect(WS_METHODS.pullRequestsStack, pullRequests.stack(input), { - "rpc.aggregate": "pull-requests", - }), + observeRpcEffect( + WS_METHODS.pullRequestsStack, + withPullRequestViewer(input, pullRequests.stack(input)), + { + "rpc.aggregate": "pull-requests", + }, + ), [WS_METHODS.pullRequestsLinkedThreads]: (input) => observeRpcEffect( WS_METHODS.pullRequestsLinkedThreads, @@ -2172,17 +2200,25 @@ const makeWsRpcLayer = ( { "rpc.aggregate": "pull-requests" }, ), [WS_METHODS.pullRequestsDetail]: (input) => - observeRpcEffect(WS_METHODS.pullRequestsDetail, pullRequests.detail(input), { - "rpc.aggregate": "pull-requests", - }), + observeRpcEffect( + WS_METHODS.pullRequestsDetail, + withPullRequestViewer(input, pullRequests.detail(input)), + { + "rpc.aggregate": "pull-requests", + }, + ), [WS_METHODS.pullRequestsActivity]: (input) => - observeRpcEffect(WS_METHODS.pullRequestsActivity, pullRequests.activity(input), { - "rpc.aggregate": "pull-requests", - }), + observeRpcEffect( + WS_METHODS.pullRequestsActivity, + withPullRequestViewer(input, pullRequests.activity(input)), + { + "rpc.aggregate": "pull-requests", + }, + ), [WS_METHODS.pullRequestsThreadComments]: (input) => observeRpcEffect( WS_METHODS.pullRequestsThreadComments, - pullRequests.threadComments(input), + withPullRequestViewer(input, pullRequests.threadComments(input)), { "rpc.aggregate": "pull-requests", }, @@ -2190,61 +2226,75 @@ const makeWsRpcLayer = ( [WS_METHODS.pullRequestsDiffFileContents]: (input) => observeRpcEffect( WS_METHODS.pullRequestsDiffFileContents, - pullRequests.diffFileContents(input), + withPullRequestViewer(input, pullRequests.diffFileContents(input)), { "rpc.aggregate": "pull-requests" }, ), [WS_METHODS.pullRequestsRunAction]: (input) => observeRpcEffect( WS_METHODS.pullRequestsRunAction, - pullRequests - .runAction(input) - .pipe( - Effect.tap(() => - resolvePullRequestSyncKey(input).pipe( - Effect.flatMap((key) => - key === null ? Effect.void : pullRequestSync.requestSync(key), - ), + withPullRequestViewer(input, pullRequests.runAction(input)).pipe( + Effect.tap(() => + resolvePullRequestSyncKey(input).pipe( + Effect.flatMap((key) => + key === null ? Effect.void : pullRequestSync.requestSync(key), ), ), ), + ), { "rpc.aggregate": "pull-requests" }, ), [WS_METHODS.pullRequestsUpdate]: (input) => - observeRpcEffect(WS_METHODS.pullRequestsUpdate, pullRequests.update(input), { - "rpc.aggregate": "pull-requests", - }), + observeRpcEffect( + WS_METHODS.pullRequestsUpdate, + withPullRequestViewer(input, pullRequests.update(input)), + { + "rpc.aggregate": "pull-requests", + }, + ), [WS_METHODS.pullRequestsComment]: (input) => - observeRpcEffect(WS_METHODS.pullRequestsComment, pullRequests.comment(input), { - "rpc.aggregate": "pull-requests", - }), + observeRpcEffect( + WS_METHODS.pullRequestsComment, + withPullRequestViewer(input, pullRequests.comment(input)), + { + "rpc.aggregate": "pull-requests", + }, + ), [WS_METHODS.pullRequestsUpdateComment]: (input) => observeRpcEffect( WS_METHODS.pullRequestsUpdateComment, - pullRequests.updateComment(input), + withPullRequestViewer(input, pullRequests.updateComment(input)), { "rpc.aggregate": "pull-requests", }, ), [WS_METHODS.pullRequestsSubmitReview]: (input) => - observeRpcEffect(WS_METHODS.pullRequestsSubmitReview, pullRequests.submitReview(input), { - "rpc.aggregate": "pull-requests", - }), + observeRpcEffect( + WS_METHODS.pullRequestsSubmitReview, + withPullRequestViewer(input, pullRequests.submitReview(input)), + { + "rpc.aggregate": "pull-requests", + }, + ), [WS_METHODS.pullRequestsReplyToThread]: (input) => observeRpcEffect( WS_METHODS.pullRequestsReplyToThread, - pullRequests.replyToThread(input), + withPullRequestViewer(input, pullRequests.replyToThread(input)), { "rpc.aggregate": "pull-requests" }, ), [WS_METHODS.pullRequestsSetThreadResolution]: (input) => observeRpcEffect( WS_METHODS.pullRequestsSetThreadResolution, - pullRequests.setThreadResolution(input), + withPullRequestViewer(input, pullRequests.setThreadResolution(input)), { "rpc.aggregate": "pull-requests" }, ), [WS_METHODS.pullRequestsSetReaction]: (input) => - observeRpcEffect(WS_METHODS.pullRequestsSetReaction, pullRequests.setReaction(input), { - "rpc.aggregate": "pull-requests", - }), + observeRpcEffect( + WS_METHODS.pullRequestsSetReaction, + withPullRequestViewer(input, pullRequests.setReaction(input)), + { + "rpc.aggregate": "pull-requests", + }, + ), [WS_METHODS.pullRequestsInvalidate]: (input) => observeRpcEffect( WS_METHODS.pullRequestsInvalidate, @@ -2272,25 +2322,29 @@ const makeWsRpcLayer = ( [WS_METHODS.pullRequestsReviewerCandidates]: (input) => observeRpcEffect( WS_METHODS.pullRequestsReviewerCandidates, - pullRequests.reviewerCandidates(input), + withPullRequestViewer(input, pullRequests.reviewerCandidates(input)), { "rpc.aggregate": "pull-requests" }, ), [WS_METHODS.pullRequestsRequestReviewers]: (input) => observeRpcEffect( WS_METHODS.pullRequestsRequestReviewers, - pullRequests.requestReviewers(input), + withPullRequestViewer(input, pullRequests.requestReviewers(input)), { "rpc.aggregate": "pull-requests" }, ), [WS_METHODS.pullRequestsLabelCandidates]: (input) => observeRpcEffect( WS_METHODS.pullRequestsLabelCandidates, - pullRequests.labelCandidates(input), + withPullRequestViewer(input, pullRequests.labelCandidates(input)), { "rpc.aggregate": "pull-requests" }, ), [WS_METHODS.pullRequestsSetLabels]: (input) => - observeRpcEffect(WS_METHODS.pullRequestsSetLabels, pullRequests.setLabels(input), { - "rpc.aggregate": "pull-requests", - }), + observeRpcEffect( + WS_METHODS.pullRequestsSetLabels, + withPullRequestViewer(input, pullRequests.setLabels(input)), + { + "rpc.aggregate": "pull-requests", + }, + ), [WS_METHODS.sourceControlLookupRepository]: (input) => observeRpcEffect( WS_METHODS.sourceControlLookupRepository, @@ -3111,6 +3165,7 @@ export const websocketRpcRouteLayer = Layer.unwrap( BitbucketApi.layer, GitHubCli.layer, GitLabCli.layer, + ForgejoCli.layer, ), ), Layer.provideMerge(GitVcsDriver.layer), diff --git a/apps/web/src/assets/notification-completion.mp3 b/apps/web/src/assets/notification-completion.mp3 new file mode 100644 index 000000000000..1955f5eb05f1 Binary files /dev/null and b/apps/web/src/assets/notification-completion.mp3 differ diff --git a/apps/web/src/assets/notification-input.mp3 b/apps/web/src/assets/notification-input.mp3 new file mode 100644 index 000000000000..07e10da3bd57 Binary files /dev/null and b/apps/web/src/assets/notification-input.mp3 differ 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 } 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/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 ? ( 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/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/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/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..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.