From 4a3df422fbe364e74f2607bc55a9fd816174602b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Timb=C3=B3=20Pereira?= Date: Wed, 26 Aug 2026 22:31:13 -0300 Subject: [PATCH 01/19] feat: name new threads from linked tickets --- apps/mobile/src/Stack.tsx | 8 + .../features/settings/SettingsRouteScreen.tsx | 2 + .../SettingsTicketProvidersRouteScreen.tsx | 637 ++++++++++++++++++ .../components/settings-sheet-targets.ts | 1 + .../OrchestrationEngineHarness.integration.ts | 17 + apps/server/src/auth/RpcAuthorization.ts | 1 + .../Layers/ProjectionPipeline.ts | 8 + .../Layers/ProjectionSnapshotQuery.test.ts | 4 + .../Layers/ProjectionSnapshotQuery.ts | 18 + .../Layers/ProviderCommandReactor.test.ts | 133 ++++ .../Layers/ProviderCommandReactor.ts | 154 ++++- .../decider.projectThreadEnvMode.test.ts | 48 +- .../decider.titleRegeneration.test.ts | 21 + apps/server/src/orchestration/decider.ts | 16 +- apps/server/src/orchestration/projector.ts | 8 + .../persistence/Layers/ProjectionProjects.ts | 19 +- .../Layers/ProjectionRepositories.test.ts | 2 + apps/server/src/persistence/Migrations.ts | 2 + .../044_ProjectionProjectTicketTitles.test.ts | 34 + .../044_ProjectionProjectTicketTitles.ts | 23 + .../Services/ProjectionProjects.ts | 4 + apps/server/src/server.ts | 13 +- apps/server/src/serverSettings.test.ts | 52 ++ apps/server/src/serverSettings.ts | 123 +++- .../src/ticket/TicketProviderRegistry.test.ts | 263 ++++++++ .../src/ticket/TicketProviderRegistry.ts | 629 +++++++++++++++++ apps/server/src/ws.ts | 31 +- .../settings/IntegrationsSettings.tsx | 4 + .../settings/ProjectSettingsPanel.tsx | 140 ++++ .../settings/TicketProviderSettings.tsx | 543 +++++++++++++++ .../src/components/settings/settingsSearch.ts | 11 + docs/internals/glossary.md | 8 + docs/user/thread-sidebar.md | 15 + packages/client-runtime/src/state/server.ts | 8 + packages/contracts/src/index.ts | 1 + packages/contracts/src/orchestration.ts | 10 + packages/contracts/src/rpc.ts | 9 + packages/contracts/src/settings.test.ts | 35 + packages/contracts/src/settings.ts | 20 + packages/contracts/src/ticketProvider.ts | 81 +++ packages/shared/package.json | 4 + packages/shared/src/serverSettings.ts | 3 + packages/shared/src/ticketTitles.test.ts | 120 ++++ packages/shared/src/ticketTitles.ts | 292 ++++++++ 44 files changed, 3530 insertions(+), 45 deletions(-) create mode 100644 apps/mobile/src/features/settings/SettingsTicketProvidersRouteScreen.tsx create mode 100644 apps/server/src/persistence/Migrations/044_ProjectionProjectTicketTitles.test.ts create mode 100644 apps/server/src/persistence/Migrations/044_ProjectionProjectTicketTitles.ts create mode 100644 apps/server/src/ticket/TicketProviderRegistry.test.ts create mode 100644 apps/server/src/ticket/TicketProviderRegistry.ts create mode 100644 apps/web/src/components/settings/TicketProviderSettings.tsx create mode 100644 packages/contracts/src/ticketProvider.ts create mode 100644 packages/shared/src/ticketTitles.test.ts create mode 100644 packages/shared/src/ticketTitles.ts diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index 7cffbf62b0d7..182d06981903 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -56,6 +56,7 @@ import { SettingsAuthRouteScreen } from "./features/settings/SettingsAuthRouteSc import { SettingsEnvironmentsRouteScreen } from "./features/settings/SettingsEnvironmentsRouteScreen"; import { SettingsLegalRouteScreen } from "./features/settings/SettingsLegalRouteScreen"; import { SettingsProjectGroupingRouteScreen } from "./features/settings/SettingsProjectGroupingRouteScreen"; +import { SettingsTicketProvidersRouteScreen } from "./features/settings/SettingsTicketProvidersRouteScreen"; import { UsageRouteScreen } from "./features/usage/UsageRouteScreen"; import { SettingsRouteScreen } from "./features/settings/SettingsRouteScreen"; import { ShowcaseCaptureCoordinator } from "./features/showcase/ShowcaseCaptureCoordinator"; @@ -184,6 +185,13 @@ const SettingsContentStack = createNativeStackNavigator({ title: "Project Grouping", }, }), + SettingsTicketProviders: createNativeStackScreen({ + screen: SettingsTicketProvidersRouteScreen, + linking: "ticket-providers", + options: { + title: "Ticket Providers", + }, + }), SettingsClientStorage: createNativeStackScreen({ screen: SettingsClientStorageRouteScreen, linking: "client-storage", diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx index b0e851b59d88..40a5a6eca5ac 100644 --- a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx @@ -123,6 +123,7 @@ function LocalSettingsRouteScreen() { value={`${environmentCount}`} target="SettingsEnvironments" /> + @@ -471,6 +472,7 @@ function ConfiguredSettingsRouteScreen() { value={`${environmentCount}`} target="SettingsEnvironments" /> + = [ + { mode: "identifier_title", label: "Identifier and title" }, + { mode: "title", label: "Ticket title" }, + { mode: "custom", label: "Custom template" }, + { mode: "disabled", label: "Off" }, +]; + +interface DriverOption { + readonly driver: string; + readonly label: string; + readonly baseUrl: string; + readonly identity?: string; + readonly secret?: string; +} + +const DRIVER_OPTIONS: ReadonlyArray = [ + { driver: "github", label: "GitHub", baseUrl: "https://github.com", identity: "Account login" }, + { driver: "gitlab", label: "GitLab", baseUrl: "https://gitlab.com", secret: "GITLAB_TOKEN" }, + { + driver: "azure-devops", + label: "Azure", + baseUrl: "https://dev.azure.com", + secret: "AZURE_DEVOPS_EXT_PAT", + }, + { + driver: "bitbucket", + label: "Bitbucket", + baseUrl: "https://bitbucket.org", + secret: "T3CODE_BITBUCKET_ACCESS_TOKEN", + }, + { + driver: "jira", + label: "Jira", + baseUrl: "https://example.atlassian.net", + identity: "Account email", + secret: "JIRA_API_TOKEN", + }, + { + driver: "clickup", + label: "ClickUp", + baseUrl: "https://app.clickup.com", + identity: "Workspace ID", + secret: "CLICKUP_API_TOKEN", + }, +]; + +function inputClassName() { + return "rounded-[14px] border border-input-border bg-input px-4 py-3 text-base text-foreground"; +} + +function slugify(value: string): string { + return value + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/gu, "_") + .replace(/^_+|_+$/gu, "") + .slice(0, 48); +} + +function EnvironmentTicketProviders({ + environmentId, + label, +}: { + readonly environmentId: EnvironmentId; + readonly label: string; +}) { + const settings = useAtomValue(serverEnvironment.settingsValueAtom(environmentId)); + const registry = useContext(RegistryContext); + const checkmarkColor = useThemeColor("--color-icon"); + const destructiveColor = useThemeColor("--color-destructive"); + const [showAdd, setShowAdd] = useState(false); + const [driver, setDriver] = useState(DRIVER_OPTIONS[0]); + const [displayName, setDisplayName] = useState(""); + const [baseUrl, setBaseUrl] = useState(driver.baseUrl); + const [identity, setIdentity] = useState(""); + const [secret, setSecret] = useState(""); + const [error, setError] = useState(null); + const [probeByInstanceId, setProbeByInstanceId] = useState< + Readonly> + >({}); + + if (!settings) { + return ( + + + Connect this environment to configure ticket providers. + + + ); + } + + const savePatch = (patch: ServerSettingsPatch) => { + void runAtomCommand( + registry, + serverEnvironment.updateSettings, + { environmentId, input: { patch } }, + { label: "mobile ticket provider settings" }, + ); + }; + const instances = Object.entries(settings.ticketProviderInstances); + const probeProvider = (instanceId: string) => { + setProbeByInstanceId((current) => ({ ...current, [instanceId]: "testing" })); + void runAtomCommand( + registry, + serverEnvironment.probeTicketProvider, + { + environmentId, + input: { instanceId: TicketProviderInstanceId.make(instanceId) }, + }, + { label: "mobile ticket provider connection test" }, + ).then((result) => { + setProbeByInstanceId((current) => ({ + ...current, + [instanceId]: + result._tag === "Success" + ? result.value + : { + instanceId: TicketProviderInstanceId.make(instanceId), + availability: "unavailable", + detail: "Connection test could not reach this environment.", + }, + })); + }); + }; + const chooseDriver = (option: DriverOption) => { + setDriver(option); + setBaseUrl(option.baseUrl); + setIdentity(""); + setSecret(""); + setError(null); + }; + const addProvider = () => { + let parsed: URL; + try { + parsed = new URL(baseUrl.trim()); + } catch { + setError("Enter a valid base URL."); + return; + } + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + setError("The base URL must use HTTP or HTTPS."); + return; + } + if (driver.driver === "jira" && parsed.hostname === "example.atlassian.net") { + setError("Replace the example URL with your Jira site URL."); + return; + } + const name = displayName.trim() || driver.label; + const baseId = `${driver.driver}_${slugify(`${name}_${parsed.host}`)}`.slice(0, 60); + let id = baseId; + let suffix = 2; + while (settings.ticketProviderInstances[TicketProviderInstanceId.make(id)]) { + id = `${baseId.slice(0, 60 - String(suffix).length)}_${suffix}`; + suffix += 1; + } + const identityConfig = + identity.trim().length === 0 + ? undefined + : driver.driver === "github" + ? { accountLogin: identity.trim() } + : driver.driver === "jira" + ? { email: identity.trim() } + : driver.driver === "clickup" + ? { workspaceId: identity.trim() } + : undefined; + const instance: TicketProviderInstanceConfig = { + driver: TicketProviderDriverKind.make(driver.driver), + displayName: name, + baseUrl: parsed.toString().replace(/\/$/u, ""), + enabled: true, + ...(identityConfig ? { config: identityConfig } : {}), + ...(driver.secret && secret.trim() + ? { + environment: [{ name: driver.secret, value: secret.trim(), sensitive: true }], + } + : {}), + }; + savePatch({ + ticketProviderInstances: { + ...settings.ticketProviderInstances, + [TicketProviderInstanceId.make(id)]: instance, + }, + }); + // Credentials are write-only on mobile: discard the draft immediately. + setSecret(""); + setIdentity(""); + setDisplayName(""); + setShowAdd(false); + setError(null); + }; + + return ( + + + {MODE_OPTIONS.map((option, index) => ( + + savePatch({ + ticketTitlePolicy: { ...settings.ticketTitlePolicy, mode: option.mode }, + }) + } + > + {option.label} + {settings.ticketTitlePolicy.mode === option.mode ? ( + + ) : null} + + ))} + {settings.ticketTitlePolicy.mode === "custom" ? ( + + Template + + savePatch({ + ticketTitlePolicy: { + ...settings.ticketTitlePolicy, + customTemplate: event.nativeEvent.text, + }, + }) + } + /> + + ) : null} + + + + {instances.map(([instanceId, instance], index) => { + const probe = probeByInstanceId[instanceId]; + return ( + + + + + {instance.displayName ?? instanceId} + + + {instance.baseUrl} + + + { + const next = { ...settings.ticketProviderInstances }; + delete next[TicketProviderInstanceId.make(instanceId)]; + savePatch({ ticketProviderInstances: next }); + }} + className="p-2" + > + + + + + + {probe && probe !== "testing" + ? probe.detail + : "Uses this environment's local tools."} + + probeProvider(instanceId)} + className="rounded-full border border-border px-3 py-2 disabled:opacity-50" + > + + {probe === "testing" ? "Testing…" : "Test"} + + + + + ); + })} + setShowAdd((value) => !value)} + > + + {showAdd ? "Cancel" : "Add provider"} + + {showAdd ? ( + + + {DRIVER_OPTIONS.map((option) => ( + chooseDriver(option)} + className={ + option.driver === driver.driver + ? "rounded-full bg-foreground px-3 py-2" + : "rounded-full bg-subtle px-3 py-2" + } + > + + {option.label} + + + ))} + + + + {driver.identity ? ( + + ) : null} + {driver.secret ? ( + + ) : null} + {error ? {error} : null} + + Save provider + + + ) : null} + + + ); +} + +function ProjectTicketTitles({ project }: { readonly project: EnvironmentProject }) { + const settings = useAtomValue(serverEnvironment.settingsValueAtom(project.environmentId)); + const registry = useContext(RegistryContext); + const checkmarkColor = useThemeColor("--color-icon"); + const storedPolicy = project.ticketTitlePolicy ?? null; + const effectivePolicy = + storedPolicy ?? settings?.ticketTitlePolicy ?? DEFAULT_SERVER_SETTINGS.ticketTitlePolicy; + const selectedMode = storedPolicy?.mode ?? "inherit"; + const bindings = project.ticketProviderBindings ?? []; + const providerGroups = new Map< + string, + { + readonly driver: string; + readonly host: string; + readonly instances: ReadonlyArray; + } + >(); + for (const [instanceId, instance] of Object.entries(settings?.ticketProviderInstances ?? {})) { + if (instance.enabled === false) continue; + try { + const host = new URL(instance.baseUrl).host.toLowerCase(); + const key = `${instance.driver}:${host}`; + const current = providerGroups.get(key); + providerGroups.set(key, { + driver: instance.driver, + host, + instances: [...(current?.instances ?? []), [instanceId, instance]], + }); + } catch { + // Invalid persisted URLs are ignored here; the server schema reports them on write. + } + } + + const updateProject = (input: { + readonly ticketTitlePolicy?: EnvironmentProject["ticketTitlePolicy"]; + readonly ticketProviderBindings?: TicketProviderBindings; + }) => { + void runAtomCommand( + registry, + projectEnvironment.update, + { environmentId: project.environmentId, input: { projectId: project.id, ...input } }, + { label: "mobile project ticket title settings" }, + ); + }; + const updateBinding = (driver: string, host: string, instanceId: string | null) => { + const remaining = bindings.filter( + (binding) => !(binding.driver === driver && binding.host.toLowerCase() === host), + ); + updateProject({ + ticketProviderBindings: instanceId + ? [ + ...remaining, + { + driver: TicketProviderDriverKind.make(driver), + host, + instanceId: TicketProviderInstanceId.make(instanceId), + }, + ] + : remaining, + }); + }; + + return ( + + {[ + { + mode: "inherit", + label: `Environment default (${MODE_OPTIONS.find((option) => option.mode === effectivePolicy.mode)?.label ?? "Identifier and title"})`, + }, + ...MODE_OPTIONS, + ].map((option, index) => ( + + updateProject({ + ticketTitlePolicy: + option.mode === "inherit" + ? null + : { ...effectivePolicy, mode: option.mode as TicketTitleMode }, + }) + } + > + {option.label} + {selectedMode === option.mode ? ( + + ) : null} + + ))} + {storedPolicy?.mode === "custom" ? ( + + Template + + updateProject({ + ticketTitlePolicy: { + ...storedPolicy, + customTemplate: event.nativeEvent.text, + }, + }) + } + /> + + ) : null} + {[...providerGroups.values()].map((group) => { + const binding = bindings.find( + (candidate) => + candidate.driver === group.driver && candidate.host.toLowerCase() === group.host, + ); + const choices = [ + { id: "automatic", label: `${group.host}: environment default` }, + ...group.instances.map(([instanceId, instance]) => ({ + id: instanceId, + label: `${group.host}: ${instance.displayName ?? instanceId}`, + })), + ]; + return choices.map((choice) => ( + + updateBinding(group.driver, group.host, choice.id === "automatic" ? null : choice.id) + } + > + {choice.label} + {(binding?.instanceId ?? "automatic") === choice.id ? ( + + ) : null} + + )); + })} + + ); +} + +export function SettingsTicketProvidersRouteScreen() { + const navigation = useNavigation(); + const insets = useSafeAreaInsets(); + const { savedConnectionsById } = useSavedRemoteConnections(); + const environments = Object.values(savedConnectionsById); + const projects = useProjects(); + + return ( + + {Platform.OS === "android" ? ( + <> + + navigation.goBack()} /> + + ) : null} + + {environments.length === 0 ? ( + + Connect an environment first. + + ) : ( + environments.map((environment) => ( + + )) + )} + {projects.length > 0 ? ( + + + Project overrides and account bindings + + {projects.map((project) => ( + + ))} + + ) : null} + + + ); +} diff --git a/apps/mobile/src/features/settings/components/settings-sheet-targets.ts b/apps/mobile/src/features/settings/components/settings-sheet-targets.ts index 7189fdc2ebe5..5398a66f534c 100644 --- a/apps/mobile/src/features/settings/components/settings-sheet-targets.ts +++ b/apps/mobile/src/features/settings/components/settings-sheet-targets.ts @@ -2,6 +2,7 @@ export type SettingsSheetTarget = | "SettingsEnvironments" | "SettingsArchive" | "SettingsAppearance" + | "SettingsTicketProviders" | "SettingsProjectGrouping" | "SettingsClientStorage" | "SettingsUsage"; diff --git a/apps/server/integration/OrchestrationEngineHarness.integration.ts b/apps/server/integration/OrchestrationEngineHarness.integration.ts index f332b080ceea..c1440d6badf0 100644 --- a/apps/server/integration/OrchestrationEngineHarness.integration.ts +++ b/apps/server/integration/OrchestrationEngineHarness.integration.ts @@ -83,6 +83,10 @@ import { VcsStatusBroadcaster } from "../src/vcs/VcsStatusBroadcaster.ts"; import { GitWorkflowService } from "../src/git/GitWorkflowService.ts"; import * as VcsProcess from "../src/vcs/VcsProcess.ts"; import * as AgentAwarenessRelay from "../src/relay/AgentAwarenessRelay.ts"; +import { + TicketProviderRegistry, + TicketProviderResolveError, +} from "../src/ticket/TicketProviderRegistry.ts"; const decodeCodexSettings = Schema.decodeEffect(CodexSettings); @@ -337,6 +341,19 @@ export const makeOrchestrationIntegrationHarness = ( Layer.provideMerge(gitWorkflowLayer), Layer.provideMerge(textGenerationLayer), Layer.provideMerge(serverSettingsLayer), + Layer.provideMerge( + Layer.succeed(TicketProviderRegistry, { + resolve: (input) => + Effect.fail( + new TicketProviderResolveError({ + driver: input.reference.driver, + reason: "no-instance", + }), + ), + probe: (input) => + Effect.succeed({ instanceId: input.instanceId, availability: "unavailable" }), + }), + ), ); const checkpointReactorLayer = CheckpointReactorLive.pipe( Layer.provideMerge(runtimeServicesLayer), diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 28ceac4cec99..2396f7acc5ee 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -39,6 +39,7 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.serverRemoveKeybinding]: AuthOrchestrationOperateScope, [WS_METHODS.serverGetSettings]: AuthOrchestrationReadScope, [WS_METHODS.serverUpdateSettings]: AuthOrchestrationOperateScope, + [WS_METHODS.serverProbeTicketProvider]: AuthOrchestrationOperateScope, [WS_METHODS.serverDiscoverSourceControl]: AuthOrchestrationReadScope, [WS_METHODS.serverGetTraceDiagnostics]: AuthOrchestrationReadScope, [WS_METHODS.serverGetProcessDiagnostics]: AuthOrchestrationReadScope, diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index e048826b9cce..1822bc5d6088 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -519,6 +519,8 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti workspaceRoot: event.payload.workspaceRoot, defaultModelSelection: event.payload.defaultModelSelection, defaultThreadEnvMode: null, + ticketTitlePolicy: null, + ticketProviderBindings: [], faviconPath: event.payload.faviconPath ?? null, scripts: event.payload.scripts, createdAt: event.payload.createdAt, @@ -546,6 +548,12 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti ...(event.payload.defaultThreadEnvMode !== undefined ? { defaultThreadEnvMode: event.payload.defaultThreadEnvMode } : {}), + ...(event.payload.ticketTitlePolicy !== undefined + ? { ticketTitlePolicy: event.payload.ticketTitlePolicy } + : {}), + ...(event.payload.ticketProviderBindings !== undefined + ? { ticketProviderBindings: event.payload.ticketProviderBindings } + : {}), ...(event.payload.faviconPath !== undefined ? { faviconPath: event.payload.faviconPath } : {}), diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index 30892c760e77..ea236eaf7ec7 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -278,6 +278,8 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { model: "gpt-5-codex", }, faviconPath: null, + ticketTitlePolicy: null, + ticketProviderBindings: [], scripts: [ { id: "script-1", @@ -405,6 +407,8 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { model: "gpt-5-codex", }, faviconPath: null, + ticketTitlePolicy: null, + ticketProviderBindings: [], scripts: [ { id: "script-1", diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index 0b9698eaf9cf..953edea2cfdb 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -12,6 +12,8 @@ import { OrchestrationThread, OrchestrationThreadDetailSnapshot, ProjectScript, + TicketProviderBindings, + TicketTitlePolicy, TurnId, type OrchestrationCheckpointSummary, type OrchestrationLatestTurn, @@ -77,6 +79,8 @@ const THREAD_DETAIL_ACTIVITY_LIMIT = 500; const ProjectionProjectDbRowSchema = ProjectionProject.mapFields( Struct.assign({ defaultModelSelection: Schema.NullOr(Schema.fromJsonString(ModelSelection)), + ticketTitlePolicy: Schema.NullOr(Schema.fromJsonString(TicketTitlePolicy)), + ticketProviderBindings: Schema.fromJsonString(TicketProviderBindings), scripts: Schema.fromJsonString(Schema.Array(ProjectScript)), }), ); @@ -323,6 +327,8 @@ function mapProjectShellRow( repositoryIdentity, defaultModelSelection: row.defaultModelSelection, defaultThreadEnvMode: row.defaultThreadEnvMode, + ticketTitlePolicy: row.ticketTitlePolicy, + ticketProviderBindings: row.ticketProviderBindings, faviconPath: row.faviconPath ?? null, scripts: row.scripts, createdAt: row.createdAt, @@ -400,6 +406,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { workspace_root AS "workspaceRoot", default_model_selection_json AS "defaultModelSelection", default_thread_env_mode AS "defaultThreadEnvMode", + ticket_title_policy_json AS "ticketTitlePolicy", + ticket_provider_bindings_json AS "ticketProviderBindings", favicon_path AS "faviconPath", scripts_json AS "scripts", created_at AS "createdAt", @@ -861,6 +869,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { workspace_root AS "workspaceRoot", default_model_selection_json AS "defaultModelSelection", default_thread_env_mode AS "defaultThreadEnvMode", + ticket_title_policy_json AS "ticketTitlePolicy", + ticket_provider_bindings_json AS "ticketProviderBindings", favicon_path AS "faviconPath", scripts_json AS "scripts", created_at AS "createdAt", @@ -885,6 +895,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { workspace_root AS "workspaceRoot", default_model_selection_json AS "defaultModelSelection", default_thread_env_mode AS "defaultThreadEnvMode", + ticket_title_policy_json AS "ticketTitlePolicy", + ticket_provider_bindings_json AS "ticketProviderBindings", favicon_path AS "faviconPath", scripts_json AS "scripts", created_at AS "createdAt", @@ -1688,6 +1700,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { repositoryIdentity: repositoryIdentities.get(row.projectId) ?? null, defaultModelSelection: row.defaultModelSelection, defaultThreadEnvMode: row.defaultThreadEnvMode, + ticketTitlePolicy: row.ticketTitlePolicy, + ticketProviderBindings: row.ticketProviderBindings, faviconPath: row.faviconPath ?? null, scripts: row.scripts, createdAt: row.createdAt, @@ -1823,6 +1837,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { workspaceRoot: row.workspaceRoot, defaultModelSelection: row.defaultModelSelection, defaultThreadEnvMode: row.defaultThreadEnvMode, + ticketTitlePolicy: row.ticketTitlePolicy, + ticketProviderBindings: row.ticketProviderBindings, faviconPath: row.faviconPath ?? null, scripts: row.scripts, createdAt: row.createdAt, @@ -2328,6 +2344,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { repositoryIdentity, defaultModelSelection: option.value.defaultModelSelection, defaultThreadEnvMode: option.value.defaultThreadEnvMode, + ticketTitlePolicy: option.value.ticketTitlePolicy, + ticketProviderBindings: option.value.ticketProviderBindings, faviconPath: option.value.faviconPath ?? null, scripts: option.value.scripts, createdAt: option.value.createdAt, diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index a22a7acfb705..d5934b3b308a 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -63,6 +63,10 @@ import * as Clock from "effect/Clock"; import { ServerSettingsService } from "../../serverSettings.ts"; import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts"; import * as GitWorkflowService from "../../git/GitWorkflowService.ts"; +import { + TicketProviderRegistry, + TicketProviderResolveError, +} from "../../ticket/TicketProviderRegistry.ts"; const asProjectId = (value: string): ProjectId => ProjectId.make(value); const asApprovalRequestId = (value: string): ApprovalRequestId => ApprovalRequestId.make(value); @@ -309,6 +313,14 @@ describe("ProviderCommandReactor", () => { }), ), ); + const resolveTicket = vi.fn((input) => + Effect.fail( + new TicketProviderResolveError({ + driver: input.reference.driver, + reason: "no-instance", + }), + ), + ); const providerSnapshots = [ { instanceId: modelSelection.instanceId, @@ -427,6 +439,13 @@ describe("ProviderCommandReactor", () => { }), ), Layer.provideMerge(ServerSettingsService.layerTest()), + Layer.provideMerge( + Layer.succeed(TicketProviderRegistry, { + resolve: resolveTicket, + probe: (input) => + Effect.succeed({ instanceId: input.instanceId, availability: "unavailable" }), + }), + ), Layer.provideMerge(ServerConfig.layerTest(process.cwd(), baseDir)), Layer.provideMerge(NodeServices.layer), ); @@ -518,6 +537,7 @@ describe("ProviderCommandReactor", () => { refreshStatus, generateBranchName, generateThreadTitle, + resolveTicket, runtimeSessions, stateDir, drain, @@ -728,6 +748,119 @@ describe("ProviderCommandReactor", () => { expect(thread?.title).toBe("Generated title"); }); + effectIt.effect("replaces the generated first-turn title with the linked ticket title", () => + Effect.gen(function* () { + const harness = yield* Effect.promise(() => createHarness()); + const generatedTitle = yield* Deferred.make<{ readonly title: string }>(); + const seededTitle = "Please investigate https://github.com/acme/widgets/..."; + harness.generateThreadTitle.mockReturnValue(Deferred.await(generatedTitle)); + harness.resolveTicket.mockReturnValue( + Effect.succeed({ + title: "Fix reconnect failures", + identifier: "acme/widgets#12", + provider: "GitHub", + project: "acme/widgets", + }), + ); + + yield* harness.engine.dispatch({ + type: "thread.meta.update", + commandId: CommandId.make("cmd-thread-ticket-title-seed"), + threadId: ThreadId.make("thread-1"), + title: seededTitle, + }); + yield* harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start-ticket-title"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("user-message-ticket-title"), + role: "user", + text: "Please investigate https://github.com/acme/widgets/issues/12", + attachments: [], + }, + titleSeed: seededTitle, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: "2026-01-01T00:00:00.000Z", + }); + + yield* Effect.promise(() => + waitFor(async () => { + const readModel = await harness.readModel(); + return ( + readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"))?.title === + "acme/widgets#12 — Fix reconnect failures" + ); + }), + ); + yield* Deferred.succeed(generatedTitle, { title: "Generated title" }); + yield* Effect.promise(() => harness.drain()); + + const readModel = yield* Effect.promise(() => harness.readModel()); + expect(readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"))?.title).toBe( + "acme/widgets#12 — Fix reconnect failures", + ); + }), + ); + + effectIt.effect("does not replace a manual rename while the ticket lookup is running", () => + Effect.gen(function* () { + const harness = yield* Effect.promise(() => createHarness()); + const ticket = yield* Deferred.make<{ + readonly title: string; + readonly identifier: string; + readonly provider: string; + readonly project: string; + }>(); + const seededTitle = "Please investigate https://github.com/acme/widgets/..."; + harness.generateThreadTitle.mockReturnValue(Effect.succeed({ title: "Generated title" })); + harness.resolveTicket.mockReturnValue(Deferred.await(ticket)); + + yield* harness.engine.dispatch({ + type: "thread.meta.update", + commandId: CommandId.make("cmd-thread-ticket-race-seed"), + threadId: ThreadId.make("thread-1"), + title: seededTitle, + }); + yield* harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start-ticket-race"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("user-message-ticket-race"), + role: "user", + text: "Please investigate https://github.com/acme/widgets/issues/12", + attachments: [], + }, + titleSeed: seededTitle, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: "2026-01-01T00:00:00.000Z", + }); + + yield* Effect.promise(() => waitFor(() => harness.resolveTicket.mock.calls.length === 1)); + yield* harness.engine.dispatch({ + type: "thread.meta.update", + commandId: CommandId.make("cmd-thread-ticket-race-manual"), + threadId: ThreadId.make("thread-1"), + title: "Keep manual rename", + }); + yield* Deferred.succeed(ticket, { + title: "Fix reconnect failures", + identifier: "acme/widgets#12", + provider: "GitHub", + project: "acme/widgets", + }); + yield* Effect.promise(() => harness.drain()); + + const readModel = yield* Effect.promise(() => harness.readModel()); + expect(readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"))?.title).toBe( + "Keep manual rename", + ); + }), + ); + it("regenerates a thread title from the current conversation", async () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 1c0091028add..861a34f990c5 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -10,13 +10,19 @@ import { ThreadId, type ProviderSession, type RuntimeMode, + type TicketTitlePolicy, type TurnId, } from "@t3tools/contracts"; import { isTemporaryWorktreeBranch, WORKTREE_BRANCH_PREFIX } from "@t3tools/shared/git"; +import { + extractUniqueTicketReference, + renderTicketThreadTitle, +} from "@t3tools/shared/ticketTitles"; import * as Cache from "effect/Cache"; import * as Cause from "effect/Cause"; import * as Crypto from "effect/Crypto"; import * as Duration from "effect/Duration"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Equal from "effect/Equal"; import * as FileSystem from "effect/FileSystem"; @@ -47,6 +53,7 @@ import { } from "../../serverSettings.ts"; import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts"; import { GitWorkflowService } from "../../git/GitWorkflowService.ts"; +import { TicketProviderRegistry } from "../../ticket/TicketProviderRegistry.ts"; const isProviderAdapterRequestError = Schema.is(ProviderAdapterRequestError); const isProviderDriverKind = Schema.is(ProviderDriverKind); @@ -310,6 +317,7 @@ const make = Effect.gen(function* () { const vcsStatusBroadcaster = yield* VcsStatusBroadcaster; const textGeneration = yield* TextGeneration; const serverSettingsService = yield* ServerSettingsService; + const ticketProviderRegistry = yield* TicketProviderRegistry; const serverCommandId = (tag: string) => crypto.randomUUIDv4.pipe(Effect.map((uuid) => CommandId.make(`server:${tag}:${uuid}`))); const serverEventId = () => crypto.randomUUIDv4.pipe(Effect.map(EventId.make)); @@ -897,9 +905,10 @@ const make = Effect.gen(function* () { readonly messageText: string; readonly attachments?: ReadonlyArray; readonly titleSeed?: string; + readonly generatedTitle: Deferred.Deferred; }) { const attachments = input.attachments ?? []; - yield* Effect.gen(function* () { + return yield* Effect.gen(function* () { const { textGenerationModelSelection: modelSelection } = yield* serverSettingsService.getSettings; @@ -909,12 +918,13 @@ const make = Effect.gen(function* () { ...(attachments.length > 0 ? { attachments } : {}), modelSelection, }); - if (!generated) return; + yield* Deferred.succeed(input.generatedTitle, generated?.title); + if (!generated) return undefined; const thread = yield* resolveThread(input.threadId); - if (!thread) return; + if (!thread) return undefined; if (!canReplaceThreadTitle(thread.title, input.titleSeed)) { - return; + return undefined; } yield* orchestrationEngine.dispatch({ @@ -922,17 +932,136 @@ const make = Effect.gen(function* () { commandId: yield* serverCommandId("thread-title-rename"), threadId: input.threadId, title: generated.title, + expectedTitle: thread.title, }); + return generated.title; }).pipe( Effect.catchCause((cause) => - Effect.logWarning("provider command reactor failed to generate or rename thread title", { + Deferred.succeed(input.generatedTitle, undefined).pipe( + Effect.andThen( + Effect.logWarning( + "provider command reactor failed to generate or rename thread title", + { + threadId: input.threadId, + cwd: input.cwd, + cause: Cause.pretty(cause), + }, + ), + ), + Effect.as(undefined), + ), + ), + ); + }, + ); + + const effectiveTicketTitlePolicy = ( + projectPolicy: TicketTitlePolicy | null | undefined, + globalPolicy: TicketTitlePolicy, + ): TicketTitlePolicy => projectPolicy ?? globalPolicy; + + const generatedTitleFromDeferred = ( + deferred: Deferred.Deferred, + ): Effect.Effect => + Deferred.poll(deferred).pipe( + Effect.flatMap( + Option.match({ + onNone: () => Effect.succeed(undefined), + onSome: (completed) => completed, + }), + ), + ); + + const maybeResolveTicketTitleForFirstTurn = Effect.fn("maybeResolveTicketTitleForFirstTurn")( + function* (input: { + readonly threadId: ThreadId; + readonly cwd: string; + readonly messageText: string; + readonly titleSeed?: string; + readonly generatedTitle: Deferred.Deferred; + }) { + const initialThread = yield* resolveThread(input.threadId); + if (!initialThread) return; + const initialProject = yield* resolveProject(initialThread.projectId); + const initialSettings = yield* serverSettingsService.getSettings; + const initialPolicy = effectiveTicketTitlePolicy( + initialProject?.ticketTitlePolicy, + initialSettings.ticketTitlePolicy, + ); + if (initialPolicy.mode === "disabled") return; + + const reference = extractUniqueTicketReference( + input.messageText, + Object.values(initialSettings.ticketProviderInstances).map((instance) => ({ + driver: instance.driver, + baseUrl: instance.baseUrl, + })), + ); + if (!reference) return; + + const metadata = yield* ticketProviderRegistry.resolve({ + cwd: input.cwd, + reference, + instances: initialSettings.ticketProviderInstances, + bindings: initialProject?.ticketProviderBindings ?? [], + }); + const ticketTitle = renderTicketThreadTitle(initialPolicy, metadata); + if (!ticketTitle) return; + + const currentSettings = yield* serverSettingsService.getSettings; + const currentProject = yield* resolveProject(initialThread.projectId); + const currentPolicy = effectiveTicketTitlePolicy( + currentProject?.ticketTitlePolicy, + currentSettings.ticketTitlePolicy, + ); + if ( + !Equal.equals(initialPolicy, currentPolicy) || + !Equal.equals( + initialSettings.ticketProviderInstances, + currentSettings.ticketProviderInstances, + ) || + !Equal.equals( + initialProject?.ticketProviderBindings ?? [], + currentProject?.ticketProviderBindings ?? [], + ) + ) { + return; + } + + const tryApply = Effect.fn("tryApplyTicketTitle")(function* () { + const thread = yield* resolveThread(input.threadId); + if (!thread || thread.title === ticketTitle) return; + const generatedTitle = yield* generatedTitleFromDeferred(input.generatedTitle); + const replaceableTitles = new Set( + [initialThread.title, input.titleSeed, generatedTitle].filter( + (title): title is string => title !== undefined, + ), + ); + if (!replaceableTitles.has(thread.title)) return; + yield* orchestrationEngine.dispatch({ + type: "thread.meta.update", + commandId: yield* serverCommandId("thread-ticket-title"), + threadId: input.threadId, + title: ticketTitle, + expectedTitle: thread.title, + }); + }); + + yield* tryApply(); + const afterFirstApply = yield* resolveThread(input.threadId); + if (afterFirstApply && afterFirstApply.title !== ticketTitle) { + yield* tryApply(); + } + }, + (effect, input) => + effect.pipe( + Effect.catchCause((cause) => + Effect.logWarning("ticket title lookup failed; keeping generated thread title", { threadId: input.threadId, - cwd: input.cwd, cause: Cause.pretty(cause), }), ), - ); - }, + ), ); const regenerateThreadTitle = Effect.fn("regenerateThreadTitle")(function* ( @@ -1156,11 +1285,20 @@ const make = Effect.gen(function* () { }).pipe(Effect.forkScoped); if (canReplaceThreadTitle(thread.title, event.payload.titleSeed)) { + const generatedTitle = yield* Deferred.make(); yield* maybeGenerateThreadTitleForFirstTurn({ threadId: event.payload.threadId, cwd: generationCwd, + generatedTitle, ...generationInput, }).pipe(Effect.forkScoped); + yield* maybeResolveTicketTitleForFirstTurn({ + threadId: event.payload.threadId, + cwd: generationCwd, + messageText: message.text, + ...(event.payload.titleSeed !== undefined ? { titleSeed: event.payload.titleSeed } : {}), + generatedTitle, + }).pipe(Effect.forkScoped); } } diff --git a/apps/server/src/orchestration/decider.projectThreadEnvMode.test.ts b/apps/server/src/orchestration/decider.projectThreadEnvMode.test.ts index afee13343ad1..352a344f6d5f 100644 --- a/apps/server/src/orchestration/decider.projectThreadEnvMode.test.ts +++ b/apps/server/src/orchestration/decider.projectThreadEnvMode.test.ts @@ -1,4 +1,11 @@ -import { CommandId, EventId, ProjectId, type OrchestrationEvent } from "@t3tools/contracts"; +import { + CommandId, + EventId, + ProjectId, + TicketProviderDriverKind, + TicketProviderInstanceId, + type OrchestrationEvent, +} from "@t3tools/contracts"; import { expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as NodeServices from "@effect/platform-node/NodeServices"; @@ -100,4 +107,43 @@ it.layer(NodeServices.layer)("decider project defaultThreadEnvMode", (it) => { expect(afterClear.projects[0]?.defaultThreadEnvMode).toBeNull(); }), ); + + it.effect("propagates ticket title policy and account bindings atomically", () => + Effect.gen(function* () { + const readModel = yield* projectEvent(createEmptyReadModel(now), seedProjectCreated(1)); + const result = yield* decideOrchestrationCommand({ + command: { + type: "project.meta.update", + commandId: CommandId.make("cmd-project-ticket-titles"), + projectId, + ticketTitlePolicy: { + mode: "custom", + customTemplate: "[{identifier}] {title}", + }, + ticketProviderBindings: [ + { + driver: TicketProviderDriverKind.make("github"), + host: "github.com", + instanceId: TicketProviderInstanceId.make("github_work"), + }, + ], + }, + readModel, + }); + const event = Array.isArray(result) ? result[0] : result; + const updated = yield* projectEvent(readModel, { ...event, sequence: 2 }); + + expect(updated.projects[0]?.ticketTitlePolicy).toEqual({ + mode: "custom", + customTemplate: "[{identifier}] {title}", + }); + expect(updated.projects[0]?.ticketProviderBindings).toEqual([ + { + driver: "github", + host: "github.com", + instanceId: "github_work", + }, + ]); + }), + ); }); diff --git a/apps/server/src/orchestration/decider.titleRegeneration.test.ts b/apps/server/src/orchestration/decider.titleRegeneration.test.ts index b29c8ffda676..bacc3b69df75 100644 --- a/apps/server/src/orchestration/decider.titleRegeneration.test.ts +++ b/apps/server/src/orchestration/decider.titleRegeneration.test.ts @@ -69,4 +69,25 @@ it.layer(NodeServices.layer)("title regeneration decider", (it) => { } }), ); + + it.effect("turns a title update into a no-op when the observed title is stale", () => + Effect.gen(function* () { + const result = yield* decideOrchestrationCommand({ + command: { + type: "thread.meta.update", + commandId: CommandId.make("cmd-stale-title-update"), + threadId: ThreadId.make("thread-1"), + title: "Ticket title", + expectedTitle: "Generated title", + }, + readModel, + }); + const event = Array.isArray(result) ? result[0] : result; + + expect(event.type).toBe("thread.meta-updated"); + if (event.type === "thread.meta-updated") { + expect(event.payload.title).toBeUndefined(); + } + }), + ); }); diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index f3fdd462f437..e8194e1b801f 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -291,6 +291,12 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" ...(command.defaultThreadEnvMode !== undefined ? { defaultThreadEnvMode: command.defaultThreadEnvMode } : {}), + ...(command.ticketTitlePolicy !== undefined + ? { ticketTitlePolicy: command.ticketTitlePolicy } + : {}), + ...(command.ticketProviderBindings !== undefined + ? { ticketProviderBindings: command.ticketProviderBindings } + : {}), ...(command.faviconPath !== undefined ? { faviconPath: command.faviconPath } : {}), ...(command.scripts !== undefined ? { scripts: command.scripts } : {}), updatedAt: occurredAt, @@ -817,6 +823,12 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" thread.branch !== command.expectedBranch ? thread.branch : command.branch; + const title = + command.title !== undefined && + command.expectedTitle !== undefined && + thread.title !== command.expectedTitle + ? undefined + : command.title; const occurredAt = yield* nowIso; return { ...(yield* withEventBase({ @@ -828,7 +840,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" type: "thread.meta-updated", payload: { threadId: command.threadId, - ...(command.title !== undefined ? { title: command.title } : {}), + ...(title !== undefined ? { title } : {}), ...(command.regenerateTitle === true ? { regenerateTitle: true as const, @@ -839,7 +851,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" }, } : {}), - ...(command.title !== undefined && thread.titleRegeneration != null + ...(title !== undefined && title !== thread.title && thread.titleRegeneration != null ? { titleRegeneration: null } : {}), ...(command.modelSelection !== undefined diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index 1c4cd65d5123..49e0322a5577 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -215,6 +215,8 @@ export function projectEvent( workspaceRoot: payload.workspaceRoot, defaultModelSelection: payload.defaultModelSelection, defaultThreadEnvMode: null, + ticketTitlePolicy: null, + ticketProviderBindings: [], faviconPath: payload.faviconPath ?? null, scripts: payload.scripts, createdAt: payload.createdAt, @@ -251,6 +253,12 @@ export function projectEvent( ...(payload.defaultThreadEnvMode !== undefined ? { defaultThreadEnvMode: payload.defaultThreadEnvMode } : {}), + ...(payload.ticketTitlePolicy !== undefined + ? { ticketTitlePolicy: payload.ticketTitlePolicy } + : {}), + ...(payload.ticketProviderBindings !== undefined + ? { ticketProviderBindings: payload.ticketProviderBindings } + : {}), ...(payload.faviconPath !== undefined ? { faviconPath: payload.faviconPath } : {}), diff --git a/apps/server/src/persistence/Layers/ProjectionProjects.ts b/apps/server/src/persistence/Layers/ProjectionProjects.ts index ba133bb24a47..d139f7bd7307 100644 --- a/apps/server/src/persistence/Layers/ProjectionProjects.ts +++ b/apps/server/src/persistence/Layers/ProjectionProjects.ts @@ -5,7 +5,12 @@ import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; import * as Struct from "effect/Struct"; -import { ModelSelection, ProjectScript } from "@t3tools/contracts"; +import { + ModelSelection, + ProjectScript, + TicketProviderBindings, + TicketTitlePolicy, +} from "@t3tools/contracts"; import { toPersistenceSqlError } from "../Errors.ts"; import { DeleteProjectionProjectInput, @@ -18,6 +23,8 @@ import { const ProjectionProjectDbRow = ProjectionProject.mapFields( Struct.assign({ defaultModelSelection: Schema.NullOr(Schema.fromJsonString(ModelSelection)), + ticketTitlePolicy: Schema.NullOr(Schema.fromJsonString(TicketTitlePolicy)), + ticketProviderBindings: Schema.fromJsonString(TicketProviderBindings), scripts: Schema.fromJsonString(Schema.Array(ProjectScript)), }), ); @@ -36,6 +43,8 @@ const makeProjectionProjectRepository = Effect.gen(function* () { workspace_root, default_model_selection_json, default_thread_env_mode, + ticket_title_policy_json, + ticket_provider_bindings_json, favicon_path, scripts_json, created_at, @@ -48,6 +57,8 @@ const makeProjectionProjectRepository = Effect.gen(function* () { ${row.workspaceRoot}, ${row.defaultModelSelection !== null ? JSON.stringify(row.defaultModelSelection) : null}, ${row.defaultThreadEnvMode}, + ${row.ticketTitlePolicy !== null ? JSON.stringify(row.ticketTitlePolicy) : null}, + ${JSON.stringify(row.ticketProviderBindings)}, ${row.faviconPath ?? null}, ${JSON.stringify(row.scripts)}, ${row.createdAt}, @@ -60,6 +71,8 @@ const makeProjectionProjectRepository = Effect.gen(function* () { workspace_root = excluded.workspace_root, default_model_selection_json = excluded.default_model_selection_json, default_thread_env_mode = excluded.default_thread_env_mode, + ticket_title_policy_json = excluded.ticket_title_policy_json, + ticket_provider_bindings_json = excluded.ticket_provider_bindings_json, favicon_path = excluded.favicon_path, scripts_json = excluded.scripts_json, created_at = excluded.created_at, @@ -79,6 +92,8 @@ const makeProjectionProjectRepository = Effect.gen(function* () { workspace_root AS "workspaceRoot", default_model_selection_json AS "defaultModelSelection", default_thread_env_mode AS "defaultThreadEnvMode", + ticket_title_policy_json AS "ticketTitlePolicy", + ticket_provider_bindings_json AS "ticketProviderBindings", favicon_path AS "faviconPath", scripts_json AS "scripts", created_at AS "createdAt", @@ -100,6 +115,8 @@ const makeProjectionProjectRepository = Effect.gen(function* () { workspace_root AS "workspaceRoot", default_model_selection_json AS "defaultModelSelection", default_thread_env_mode AS "defaultThreadEnvMode", + ticket_title_policy_json AS "ticketTitlePolicy", + ticket_provider_bindings_json AS "ticketProviderBindings", favicon_path AS "faviconPath", scripts_json AS "scripts", created_at AS "createdAt", diff --git a/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts b/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts index 70a034932089..65a412c226bd 100644 --- a/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts +++ b/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts @@ -34,6 +34,8 @@ projectionRepositoriesLayer("Projection repositories", (it) => { model: "gpt-5.4", }, defaultThreadEnvMode: null, + ticketTitlePolicy: null, + ticketProviderBindings: [], scripts: [], createdAt: "2026-03-24T00:00:00.000Z", updatedAt: "2026-03-24T00:00:00.000Z", diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index 8abbe87fce3e..a159754cc210 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -56,6 +56,7 @@ import Migration0040 from "./Migrations/040_ProjectionProjectFaviconPath.ts"; import Migration0041 from "./Migrations/041_AuthSessionClientConnection.ts"; import Migration0042 from "./Migrations/042_ProjectionThreadLinkedPullRequest.ts"; import Migration0043 from "./Migrations/043_ProjectionThreadsUnsettledAt.ts"; +import Migration0044 from "./Migrations/044_ProjectionProjectTicketTitles.ts"; /** * Migration loader with all migrations defined inline. @@ -111,6 +112,7 @@ export const migrationEntries = [ [41, "AuthSessionClientConnection", Migration0041], [42, "ProjectionThreadLinkedPullRequest", Migration0042], [43, "ProjectionThreadsUnsettledAt", Migration0043], + [44, "ProjectionProjectTicketTitles", Migration0044], ] as const; export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); diff --git a/apps/server/src/persistence/Migrations/044_ProjectionProjectTicketTitles.test.ts b/apps/server/src/persistence/Migrations/044_ProjectionProjectTicketTitles.test.ts new file mode 100644 index 000000000000..04b00f2c687a --- /dev/null +++ b/apps/server/src/persistence/Migrations/044_ProjectionProjectTicketTitles.test.ts @@ -0,0 +1,34 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { runMigrations } from "../Migrations.ts"; +import * as NodeSqliteClient from "../NodeSqliteClient.ts"; + +const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory())); + +layer("044_ProjectionProjectTicketTitles", (it) => { + it.effect("adds ticket policy and account bindings to project projections", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* runMigrations({ toMigrationInclusive: 43 }); + yield* runMigrations({ toMigrationInclusive: 44 }); + + const columns = yield* sql<{ + readonly name: string; + readonly notnull: number; + readonly dflt_value: string | null; + }>` + PRAGMA table_info(projection_projects) + `; + const policy = columns.find((column) => column.name === "ticket_title_policy_json"); + const bindings = columns.find((column) => column.name === "ticket_provider_bindings_json"); + + assert.equal(policy?.notnull, 0); + assert.equal(bindings?.notnull, 1); + assert.equal(bindings?.dflt_value, "'[]'"); + }), + ); +}); diff --git a/apps/server/src/persistence/Migrations/044_ProjectionProjectTicketTitles.ts b/apps/server/src/persistence/Migrations/044_ProjectionProjectTicketTitles.ts new file mode 100644 index 000000000000..319454b9bae5 --- /dev/null +++ b/apps/server/src/persistence/Migrations/044_ProjectionProjectTicketTitles.ts @@ -0,0 +1,23 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const columns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(projection_projects) + `; + + if (!columns.some((column) => column.name === "ticket_title_policy_json")) { + yield* sql` + ALTER TABLE projection_projects + ADD COLUMN ticket_title_policy_json TEXT + `; + } + + if (!columns.some((column) => column.name === "ticket_provider_bindings_json")) { + yield* sql` + ALTER TABLE projection_projects + ADD COLUMN ticket_provider_bindings_json TEXT NOT NULL DEFAULT '[]' + `; + } +}); diff --git a/apps/server/src/persistence/Services/ProjectionProjects.ts b/apps/server/src/persistence/Services/ProjectionProjects.ts index 339439fdfcb8..eca0c6a0db94 100644 --- a/apps/server/src/persistence/Services/ProjectionProjects.ts +++ b/apps/server/src/persistence/Services/ProjectionProjects.ts @@ -11,6 +11,8 @@ import { ModelSelection, ProjectId, ProjectScript, + TicketProviderBindings, + TicketTitlePolicy, ThreadEnvMode, } from "@t3tools/contracts"; import * as Option from "effect/Option"; @@ -26,6 +28,8 @@ export const ProjectionProject = Schema.Struct({ workspaceRoot: Schema.String, defaultModelSelection: Schema.NullOr(ModelSelection), defaultThreadEnvMode: Schema.NullOr(ThreadEnvMode), + ticketTitlePolicy: Schema.NullOr(TicketTitlePolicy), + ticketProviderBindings: TicketProviderBindings, faviconPath: Schema.optional(Schema.NullOr(Schema.String)), scripts: Schema.Array(ProjectScript), createdAt: IsoDateTime, diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index d5bebe3d5000..8b295c622c5d 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -59,6 +59,7 @@ import { OrchestrationReactorLive } from "./orchestration/Layers/OrchestrationRe import { RuntimeReceiptBusLive } from "./orchestration/Layers/RuntimeReceiptBus.ts"; import { ProviderRuntimeIngestionLive } from "./orchestration/Layers/ProviderRuntimeIngestion.ts"; import { ProviderCommandReactorLive } from "./orchestration/Layers/ProviderCommandReactor.ts"; +import { layer as TicketProviderRegistryBaseLive } from "./ticket/TicketProviderRegistry.ts"; import { CheckpointReactorLive } from "./orchestration/Layers/CheckpointReactor.ts"; import { ThreadDeletionReactorLive } from "./orchestration/Layers/ThreadDeletionReactor.ts"; import * as AgentAwarenessRelay from "./relay/AgentAwarenessRelay.ts"; @@ -244,10 +245,19 @@ const PlatformServicesLive = Layer.unwrap( }), ); +const TicketProviderRegistryLive = TicketProviderRegistryBaseLive.pipe( + Layer.provide(VcsProcess.layer), + Layer.provide(FetchHttpClient.layer), +); + +const ProviderCommandReactorResolvedLive = ProviderCommandReactorLive.pipe( + Layer.provide(TicketProviderRegistryLive), +); + const ReactorLayerLive = Layer.empty.pipe( Layer.provideMerge(OrchestrationReactorLive), Layer.provideMerge(ProviderRuntimeIngestionLive), - Layer.provideMerge(ProviderCommandReactorLive), + Layer.provideMerge(ProviderCommandReactorResolvedLive), Layer.provideMerge(CheckpointReactorLive), Layer.provideMerge(ThreadDeletionReactorLive), Layer.provideMerge(AgentAwarenessRelay.layer.pipe(Layer.provide(ServerSecretStore.layer))), @@ -473,6 +483,7 @@ export const makeRoutesLayer = Layer.mergeAll( // Both transports consume the same service instance, so caches single-flight across clients // and mutations observed on WebSocket invalidate patches subsequently read over HTTP. Layer.provide(PullRequestServiceLive), + Layer.provide(TicketProviderRegistryLive), Layer.provide(PreviewAutomationBroker.layer), Layer.provide(ServerSelfUpdate.layer), Layer.provide(commandReadinessLayer), diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index 521865839bde..ad1e3a1a4477 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -6,6 +6,8 @@ import { resolveProviderInstanceEnabled, ServerSettings, ServerSettingsPatch, + TicketProviderDriverKind, + TicketProviderInstanceId, } from "@t3tools/contracts"; import { createModelSelection } from "@t3tools/shared/model"; import { assert, it } from "@effect/vitest"; @@ -1048,4 +1050,54 @@ it.layer(NodeServices.layer)("server settings", (it) => { ); }).pipe(Effect.provide(makeServerSettingsLayer())), ); + + it.effect("stores ticket provider credentials outside settings.json", () => + Effect.gen(function* () { + const serverSettings = yield* ServerSettingsModule.ServerSettingsService; + const serverConfig = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const instanceId = TicketProviderInstanceId.make("jira_work"); + + const next = yield* serverSettings.updateSettings({ + ticketProviderInstances: { + [instanceId]: { + driver: TicketProviderDriverKind.make("jira"), + displayName: "Work Jira", + baseUrl: "https://work.atlassian.net", + environment: [{ name: "JIRA_API_TOKEN", value: "jira-secret", sensitive: true }], + config: { email: "dev@example.com" }, + }, + }, + }); + + assert.deepEqual(next.ticketProviderInstances[instanceId]?.environment, [ + { + name: "JIRA_API_TOKEN", + value: "jira-secret", + sensitive: true, + valueRedacted: true, + }, + ]); + const raw = yield* fileSystem.readFileString(serverConfig.settingsPath); + assert.notInclude(raw, "jira-secret"); + + const roundTripped = yield* serverSettings.updateSettings({ + ticketProviderInstances: { + [instanceId]: { + driver: TicketProviderDriverKind.make("jira"), + displayName: "Work Jira", + baseUrl: "https://work.atlassian.net", + environment: [ + { name: "JIRA_API_TOKEN", value: "", sensitive: true, valueRedacted: true }, + ], + config: { email: "dev@example.com" }, + }, + }, + }); + assert.equal( + roundTripped.ticketProviderInstances[instanceId]?.environment?.[0]?.value, + "jira-secret", + ); + }).pipe(Effect.provide(makeServerSettingsLayer())), + ); }); diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index 5a8650b7e405..65c9f89cb7ac 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -23,6 +23,7 @@ import { ServerSettings, ServerSettingsError, type ServerSettingsPatch, + type TicketProviderInstanceConfig, } from "@t3tools/contracts"; import * as Cache from "effect/Cache"; import * as Cause from "effect/Cause"; @@ -133,6 +134,13 @@ function providerEnvironmentSecretName(input: { return `provider-env-${Buffer.from(input.instanceId, "utf8").toString("base64url")}-${Buffer.from(input.name, "utf8").toString("base64url")}`; } +function ticketProviderEnvironmentSecretName(input: { + readonly instanceId: string; + readonly name: string; +}): string { + return `ticket-provider-env-${Buffer.from(input.instanceId, "utf8").toString("base64url")}-${Buffer.from(input.name, "utf8").toString("base64url")}`; +} + function redactProviderEnvironmentVariable( variable: ProviderInstanceEnvironmentVariable, ): ProviderInstanceEnvironmentVariable { @@ -159,7 +167,18 @@ export function redactServerSettingsForClient(settings: ServerSettings): ServerS : instance, ]), ); - return { ...settings, providerInstances }; + const ticketProviderInstances = Object.fromEntries( + Object.entries(settings.ticketProviderInstances).map(([instanceId, instance]) => [ + instanceId, + instance.environment + ? { + ...instance, + environment: instance.environment.map(redactProviderEnvironmentVariable), + } + : instance, + ]), + ); + return { ...settings, providerInstances, ticketProviderInstances }; } export class ServerSettingsService extends Context.Service< @@ -473,14 +492,17 @@ const make = Effect.gen(function* () { const getSettingsFromCache = Cache.get(settingsCache, cacheKey); - const materializeProviderEnvironmentSecrets = ( - settings: ServerSettings, - ): Effect.Effect => + const materializeInstanceEnvironmentSecrets = < + Instance extends { + readonly environment?: ReadonlyArray; + }, + >( + instances: Readonly>, + secretNameFor: (input: { readonly instanceId: string; readonly name: string }) => string, + ): Effect.Effect, ServerSettingsError> => Effect.gen(function* () { - const providerInstances: Record = { - ...settings.providerInstances, - }; - for (const [instanceId, instance] of Object.entries(settings.providerInstances)) { + const materialized: Record = { ...instances }; + for (const [instanceId, instance] of Object.entries(instances)) { if (!instance.environment) continue; const environment: ProviderInstanceEnvironmentVariable[] = []; for (const variable of instance.environment) { @@ -489,7 +511,7 @@ const make = Effect.gen(function* () { continue; } const secret = yield* secretStore - .get(providerEnvironmentSecretName({ instanceId, name: variable.name })) + .get(secretNameFor({ instanceId, name: variable.name })) .pipe( Effect.mapError( (cause) => @@ -507,14 +529,32 @@ const make = Effect.gen(function* () { value: Option.isSome(secret) ? textDecoder.decode(secret.value) : "", }); } - providerInstances[instanceId] = { - ...instance, - environment, - } satisfies ProviderInstanceConfig; + materialized[instanceId] = { ...instance, environment } as Instance; } + return materialized; + }); + + const materializeProviderEnvironmentSecrets = ( + settings: ServerSettings, + ): Effect.Effect => + Effect.gen(function* () { + const providerInstances = + yield* materializeInstanceEnvironmentSecrets( + settings.providerInstances as unknown as Readonly>, + providerEnvironmentSecretName, + ); + const ticketProviderInstances = + yield* materializeInstanceEnvironmentSecrets( + settings.ticketProviderInstances as unknown as Readonly< + Record + >, + ticketProviderEnvironmentSecretName, + ); return { ...settings, - providerInstances: providerInstances as ServerSettings["providerInstances"], + providerInstances: providerInstances as unknown as ServerSettings["providerInstances"], + ticketProviderInstances: + ticketProviderInstances as unknown as ServerSettings["ticketProviderInstances"], }; }); @@ -535,21 +575,21 @@ const make = Effect.gen(function* () { Stream.map(resolveTextGenerationProvider), ); - const persistProviderEnvironmentSecrets = ( - current: ServerSettings, - next: ServerSettings, - ): Effect.Effect => + const persistInstanceEnvironmentSecrets = < + Instance extends { readonly environment?: ReadonlyArray }, + >( + current: Readonly>, + next: Readonly>, + secretNameFor: (input: { readonly instanceId: string; readonly name: string }) => string, + ): Effect.Effect, ServerSettingsError> => Effect.gen(function* () { - const providerInstances: Record = { - ...next.providerInstances, - }; - + const persisted: Record = { ...next }; const nextSecretKeys = new Set(); - for (const [instanceId, instance] of Object.entries(next.providerInstances)) { + for (const [instanceId, instance] of Object.entries(next)) { if (!instance.environment) continue; const environment: ProviderInstanceEnvironmentVariable[] = []; for (const variable of instance.environment) { - const secretName = providerEnvironmentSecretName({ instanceId, name: variable.name }); + const secretName = secretNameFor({ instanceId, name: variable.name }); if (!variable.sensitive) { yield* secretStore.remove(secretName).pipe( Effect.mapError( @@ -604,16 +644,13 @@ const make = Effect.gen(function* () { environment.push(redactProviderEnvironmentVariable(variable)); } - providerInstances[instanceId] = { - ...instance, - environment, - } satisfies ProviderInstanceConfig; + persisted[instanceId] = { ...instance, environment } as Instance; } - for (const [instanceId, instance] of Object.entries(current.providerInstances)) { + for (const [instanceId, instance] of Object.entries(current)) { for (const variable of instance.environment ?? []) { if (!variable.sensitive) continue; - const secretName = providerEnvironmentSecretName({ instanceId, name: variable.name }); + const secretName = secretNameFor({ instanceId, name: variable.name }); if (nextSecretKeys.has(secretName)) continue; yield* secretStore.remove(secretName).pipe( Effect.mapError( @@ -629,10 +666,34 @@ const make = Effect.gen(function* () { ); } } + return persisted; + }); + const persistProviderEnvironmentSecrets = ( + current: ServerSettings, + next: ServerSettings, + ): Effect.Effect => + Effect.gen(function* () { + const providerInstances = yield* persistInstanceEnvironmentSecrets( + current.providerInstances as unknown as Readonly>, + next.providerInstances as unknown as Readonly>, + providerEnvironmentSecretName, + ); + const ticketProviderInstances = + yield* persistInstanceEnvironmentSecrets( + current.ticketProviderInstances as unknown as Readonly< + Record + >, + next.ticketProviderInstances as unknown as Readonly< + Record + >, + ticketProviderEnvironmentSecretName, + ); return { ...next, - providerInstances: providerInstances as ServerSettings["providerInstances"], + providerInstances: providerInstances as unknown as ServerSettings["providerInstances"], + ticketProviderInstances: + ticketProviderInstances as unknown as ServerSettings["ticketProviderInstances"], }; }); diff --git a/apps/server/src/ticket/TicketProviderRegistry.test.ts b/apps/server/src/ticket/TicketProviderRegistry.test.ts new file mode 100644 index 000000000000..e32006610a1b --- /dev/null +++ b/apps/server/src/ticket/TicketProviderRegistry.test.ts @@ -0,0 +1,263 @@ +import { + TicketProviderDriverKind, + TicketProviderInstanceId, + type TicketProviderInstanceConfigMap, +} from "@t3tools/contracts"; +import { extractUniqueTicketReference } from "@t3tools/shared/ticketTitles"; +import * as Effect from "effect/Effect"; +import * as Deferred from "effect/Deferred"; +import * as Fiber from "effect/Fiber"; +import { HttpClient } from "effect/unstable/http"; +import { it as effectIt } from "@effect/vitest"; +import { describe, expect, vi } from "vite-plus/test"; + +import * as VcsProcess from "../vcs/VcsProcess.ts"; +import { make, TicketProviderResolveError } from "./TicketProviderRegistry.ts"; + +const commandOutput = (stdout: string): VcsProcess.VcsProcessOutput => ({ + exitCode: 0 as never, + stdout, + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, +}); + +const githubReference = extractUniqueTicketReference("https://github.com/acme/widgets/issues/12")!; + +function makeRegistry(run: VcsProcess.VcsProcess["Service"]["run"]) { + return make.pipe( + Effect.provideService(VcsProcess.VcsProcess, { run }), + Effect.provideService(HttpClient.HttpClient, { + execute: () => Effect.die("HTTP should not run"), + } as never), + ); +} + +describe("TicketProviderRegistry", () => { + effectIt.effect("uses the project-bound GitHub account without changing global gh auth", () => + Effect.gen(function* () { + const run = vi.fn((input) => + Effect.succeed( + commandOutput(input.args[0] === "auth" ? "work-token\n" : '{"title":"Fix reconnect"}'), + ), + ); + const registry = yield* makeRegistry(run); + const instances: TicketProviderInstanceConfigMap = { + [TicketProviderInstanceId.make("github_personal")]: { + driver: TicketProviderDriverKind.make("github"), + baseUrl: "https://github.com", + config: { accountLogin: "personal" }, + }, + [TicketProviderInstanceId.make("github_work")]: { + driver: TicketProviderDriverKind.make("github"), + baseUrl: "https://github.com", + config: { accountLogin: "work" }, + }, + }; + + const resolved = yield* registry.resolve({ + cwd: "/tmp/project", + reference: githubReference, + instances, + bindings: [ + { + driver: TicketProviderDriverKind.make("github"), + host: "github.com", + instanceId: TicketProviderInstanceId.make("github_work"), + }, + ], + }); + + expect(resolved).toMatchObject({ + title: "Fix reconnect", + identifier: "acme/widgets#12", + provider: "GitHub", + }); + expect(run).toHaveBeenCalledTimes(2); + expect(run.mock.calls[0]?.[0]).toMatchObject({ + command: "gh", + args: ["auth", "token", "--hostname", "github.com", "--user", "work"], + }); + expect(run.mock.calls[1]?.[0]).toMatchObject({ + command: "gh", + args: ["issue", "view", "https://github.com/acme/widgets/issues/12", "--json", "title"], + env: expect.objectContaining({ GH_TOKEN: "work-token", GH_HOST: "github.com" }), + }); + }), + ); + + effectIt.effect( + "does not fall through to another account when an explicit binding is unavailable", + () => + Effect.gen(function* () { + const run = vi.fn(() => + Effect.succeed(commandOutput('{"title":"should not resolve"}')), + ); + const registry = yield* makeRegistry(run); + const failure = yield* Effect.flip( + registry.resolve({ + cwd: "/tmp/project", + reference: githubReference, + instances: { + [TicketProviderInstanceId.make("github_personal")]: { + driver: TicketProviderDriverKind.make("github"), + baseUrl: "https://github.com", + }, + }, + bindings: [ + { + driver: TicketProviderDriverKind.make("github"), + host: "github.com", + instanceId: TicketProviderInstanceId.make("github_removed"), + }, + ], + }), + ); + + expect(failure).toBeInstanceOf(TicketProviderResolveError); + expect(failure.reason).toBe("no-instance"); + expect(run).not.toHaveBeenCalled(); + }), + ); + + effectIt.effect("uses the single compatible local CLI when no instance is configured", () => + Effect.gen(function* () { + const run = vi.fn(() => + Effect.succeed(commandOutput('{"title":"Public issue"}')), + ); + const registry = yield* makeRegistry(run); + + const resolved = yield* registry.resolve({ + cwd: "/tmp/project", + reference: githubReference, + instances: {}, + bindings: [], + }); + + expect(resolved.title).toBe("Public issue"); + expect(run.mock.calls[0]?.[0]).toMatchObject({ command: "gh" }); + }), + ); + + effectIt.effect("tests the configured GitHub account without changing active gh auth", () => + Effect.gen(function* () { + const run = vi.fn(() => + Effect.succeed(commandOutput("token\n")), + ); + const registry = yield* makeRegistry(run); + const instanceId = TicketProviderInstanceId.make("github_work"); + + const result = yield* registry.probe({ + cwd: "/tmp/project", + instanceId, + instance: { + driver: TicketProviderDriverKind.make("github"), + baseUrl: "https://github.com", + config: { accountLogin: "work" }, + }, + }); + + expect(result).toEqual({ + instanceId, + availability: "available", + detail: "Connection test succeeded.", + }); + expect(run).toHaveBeenCalledWith( + expect.objectContaining({ + command: "gh", + args: ["auth", "token", "--hostname", "github.com", "--user", "work"], + }), + ); + }), + ); + + effectIt.effect("reports probe failures without exposing command output", () => + Effect.gen(function* () { + const run = vi.fn(() => + Effect.die("sensitive stderr"), + ); + const registry = yield* makeRegistry(run); + const instanceId = TicketProviderInstanceId.make("github_work"); + + const result = yield* registry.probe({ + cwd: "/tmp/project", + instanceId, + instance: { + driver: TicketProviderDriverKind.make("github"), + baseUrl: "https://github.com", + }, + }); + + expect(result).toEqual({ + instanceId, + availability: "unavailable", + detail: "Connection test failed. Check the local CLI login or configured credential.", + }); + expect(result.detail).not.toContain("sensitive stderr"); + }), + ); + + effectIt.effect("passes a configured Azure DevOps Server collection to the CLI", () => + Effect.gen(function* () { + const run = vi.fn(() => + Effect.succeed(commandOutput("Fix reconnect\n")), + ); + const registry = yield* makeRegistry(run); + const baseUrl = "https://devops.internal/tfs/DefaultCollection"; + const reference = extractUniqueTicketReference(`${baseUrl}/Widgets/_workitems/edit/77`, [ + { driver: TicketProviderDriverKind.make("azure-devops"), baseUrl }, + ])!; + + const result = yield* registry.resolve({ + cwd: "/tmp/project", + reference, + instances: { + [TicketProviderInstanceId.make("azure_work")]: { + driver: TicketProviderDriverKind.make("azure-devops"), + baseUrl, + }, + }, + bindings: [], + }); + + expect(result.title).toBe("Fix reconnect"); + expect(run).toHaveBeenCalledWith( + expect.objectContaining({ + command: "az", + args: expect.arrayContaining(["--organization", baseUrl]), + }), + ); + }), + ); + + effectIt.effect("deduplicates only simultaneous lookups for the same ticket and account", () => + Effect.gen(function* () { + const started = yield* Deferred.make(); + const release = yield* Deferred.make(); + const run = vi.fn(() => + Deferred.succeed(started, undefined).pipe( + Effect.andThen(Deferred.await(release)), + Effect.as(commandOutput('{"title":"Shared result"}')), + ), + ); + const registry = yield* makeRegistry(run); + const input = { + cwd: "/tmp/project", + reference: githubReference, + instances: {}, + bindings: [], + } as const; + + const resolving = yield* Effect.all([registry.resolve(input), registry.resolve(input)], { + concurrency: "unbounded", + }).pipe(Effect.forkChild); + yield* Deferred.await(started); + yield* Effect.yieldNow; + yield* Deferred.succeed(release, undefined); + const resolved = yield* Fiber.join(resolving); + + expect(resolved.map((ticket) => ticket.title)).toEqual(["Shared result", "Shared result"]); + expect(run).toHaveBeenCalledTimes(1); + }), + ); +}); diff --git a/apps/server/src/ticket/TicketProviderRegistry.ts b/apps/server/src/ticket/TicketProviderRegistry.ts new file mode 100644 index 000000000000..f245096b1bbb --- /dev/null +++ b/apps/server/src/ticket/TicketProviderRegistry.ts @@ -0,0 +1,629 @@ +import { + type TicketProviderBindings, + type TicketProviderInstanceConfig, + type TicketProviderInstanceConfigMap, + type TicketProviderInstanceId, + type TicketProviderProbeResult, +} from "@t3tools/contracts"; +import type { TicketReference, TicketTitleMetadata } from "@t3tools/shared/ticketTitles"; +import * as Context from "effect/Context"; +import * as Deferred from "effect/Deferred"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import { + FetchHttpClient, + HttpClient, + HttpClientRequest, + HttpClientResponse, +} from "effect/unstable/http"; + +import * as VcsProcess from "../vcs/VcsProcess.ts"; + +const LOOKUP_TIMEOUT_MS = 10_000; +const MAX_RESPONSE_BYTES = 128 * 1024; + +const PROVIDER_LABELS: Readonly> = { + github: "GitHub", + gitlab: "GitLab", + "azure-devops": "Azure DevOps", + bitbucket: "Bitbucket", + jira: "Jira", + clickup: "ClickUp", +}; + +export class TicketProviderResolveError extends Schema.TaggedErrorClass()( + "TicketProviderResolveError", + { + driver: Schema.String, + instanceId: Schema.optional(Schema.String), + reason: Schema.Literals([ + "no-instance", + "ambiguous-instance", + "unsupported-driver", + "unauthenticated", + "request-failed", + "invalid-response", + ]), + cause: Schema.optional(Schema.Defect()), + }, +) {} + +export interface TicketProviderResolveInput { + readonly cwd: string; + readonly reference: TicketReference; + readonly instances: TicketProviderInstanceConfigMap; + readonly bindings: TicketProviderBindings; +} + +export interface TicketProviderProbeInput { + readonly cwd: string; + readonly instanceId: TicketProviderInstanceId; + readonly instance: TicketProviderInstanceConfig; +} + +interface ResolvedTicketFields { + readonly title: string; + readonly identifier?: string; + readonly project?: string; +} + +export class TicketProviderRegistry extends Context.Service< + TicketProviderRegistry, + { + readonly resolve: ( + input: TicketProviderResolveInput, + ) => Effect.Effect; + readonly probe: (input: TicketProviderProbeInput) => Effect.Effect; + } +>()("t3/ticket/TicketProviderRegistry") {} + +function environmentForInstance(instance: TicketProviderInstanceConfig): NodeJS.ProcessEnv { + return { + ...process.env, + ...Object.fromEntries((instance.environment ?? []).map((entry) => [entry.name, entry.value])), + }; +} + +function environmentValue( + instance: TicketProviderInstanceConfig, + name: string, +): string | undefined { + const value = instance.environment?.find((entry) => entry.name === name)?.value.trim(); + return value ? value : undefined; +} + +function normalizedBaseUrl(value: string): URL | undefined { + try { + const url = new URL(value); + url.hash = ""; + url.search = ""; + url.pathname = url.pathname.replace(/\/$/u, ""); + return url; + } catch { + return undefined; + } +} + +function instanceMatchesReference( + instance: TicketProviderInstanceConfig, + reference: TicketReference, +): boolean { + if (instance.enabled === false || instance.driver !== reference.driver) return false; + const baseUrl = normalizedBaseUrl(instance.baseUrl); + const referenceUrl = normalizedBaseUrl(reference.url); + if (!baseUrl || !referenceUrl || baseUrl.host !== referenceUrl.host) return false; + const basePath = baseUrl.pathname.replace(/\/$/u, ""); + return ( + basePath.length === 0 || + referenceUrl.pathname === basePath || + referenceUrl.pathname.startsWith(`${basePath}/`) + ); +} + +function implicitInstance( + reference: TicketReference, +): readonly [string, TicketProviderInstanceConfig] { + const url = new URL(reference.url); + const baseUrl = + reference.driver === "jira" + ? `${url.protocol}//${url.host}${url.pathname.slice(0, url.pathname.lastIndexOf("/browse/"))}` + : `${url.protocol}//${url.host}`; + return [ + String(reference.driver), + { + driver: reference.driver, + baseUrl, + displayName: PROVIDER_LABELS[reference.driver] ?? String(reference.driver), + enabled: true, + isDefault: true, + }, + ]; +} + +function selectInstance(input: TicketProviderResolveInput): + | { + readonly _tag: "Selected"; + readonly instanceId: string; + readonly instance: TicketProviderInstanceConfig; + } + | { readonly _tag: "Missing" | "Ambiguous" } { + const matching = Object.entries(input.instances).filter(([, instance]) => + instanceMatchesReference(instance, input.reference), + ); + const binding = input.bindings.find( + (entry) => + entry.driver === input.reference.driver && + entry.host.toLowerCase() === input.reference.host.toLowerCase(), + ); + if (binding) { + const instance = input.instances[binding.instanceId]; + return instance && instanceMatchesReference(instance, input.reference) + ? { _tag: "Selected", instanceId: binding.instanceId, instance } + : { _tag: "Missing" }; + } + + if (matching.length === 0) { + const [instanceId, instance] = implicitInstance(input.reference); + return { _tag: "Selected", instanceId, instance }; + } + const defaults = matching.filter(([, instance]) => instance.isDefault === true); + const candidates = defaults.length > 0 ? defaults : matching; + if (candidates.length !== 1) return { _tag: "Ambiguous" }; + const [instanceId, instance] = candidates[0]!; + return { _tag: "Selected", instanceId, instance }; +} + +const GitHubConfig = Schema.Struct({ accountLogin: Schema.optional(Schema.String) }); +const JiraConfig = Schema.Struct({ + email: Schema.optional(Schema.String), + apiVersion: Schema.optional(Schema.Literals(["2", "3"])), +}); +const ClickUpConfig = Schema.Struct({ workspaceId: Schema.optional(Schema.String) }); + +const GitHubIssueResponse = Schema.Struct({ title: Schema.String }); +const GitLabIssueResponse = Schema.Struct({ title: Schema.String }); +const BitbucketIssueResponse = Schema.Struct({ title: Schema.String }); +const JiraIssueResponse = Schema.Struct({ fields: Schema.Struct({ summary: Schema.String }) }); +const ClickUpTaskResponse = Schema.Struct({ + name: Schema.String, + custom_id: Schema.optional(Schema.NullOr(Schema.String)), + list: Schema.optional(Schema.Struct({ name: Schema.optional(Schema.String) })), +}); +const decodeGitHubConfig = Schema.decodeUnknownEffect(GitHubConfig); +const decodeJiraConfig = Schema.decodeUnknownEffect(JiraConfig); +const decodeClickUpConfig = Schema.decodeUnknownEffect(ClickUpConfig); +const decodeGitHubIssueJson = Schema.decodeUnknownEffect( + Schema.fromJsonString(GitHubIssueResponse), +); +const decodeGitLabIssueJson = Schema.decodeUnknownEffect( + Schema.fromJsonString(GitLabIssueResponse), +); +const isTicketProviderResolveError = Schema.is(TicketProviderResolveError); + +export const make = Effect.gen(function* () { + const vcsProcess = yield* VcsProcess.VcsProcess; + const httpClient = yield* HttpClient.HttpClient; + + const runText = Effect.fn("TicketProviderRegistry.runText")(function* (input: { + readonly command: string; + readonly args: ReadonlyArray; + readonly cwd: string; + readonly env: NodeJS.ProcessEnv; + }) { + const output = yield* vcsProcess.run({ + operation: "resolveTicket", + command: input.command, + args: input.args, + cwd: input.cwd, + env: input.env, + timeoutMs: LOOKUP_TIMEOUT_MS, + maxOutputBytes: MAX_RESPONSE_BYTES, + }); + return output.stdout; + }); + + const execute = Effect.fn("TicketProviderRegistry.execute")(function* ( + request: HttpClientRequest.HttpClientRequest, + ) { + const response = yield* httpClient + .execute(request.pipe(HttpClientRequest.acceptJson)) + .pipe( + Effect.provideService(FetchHttpClient.RequestInit, { redirect: "manual" }), + Effect.timeout(Duration.millis(LOOKUP_TIMEOUT_MS)), + ); + return yield* HttpClientResponse.filterStatusOk(response); + }); + + const resolveGitHub = Effect.fn("TicketProviderRegistry.resolveGitHub")(function* (input: { + readonly cwd: string; + readonly reference: TicketReference; + readonly instance: TicketProviderInstanceConfig; + }) { + const config = yield* decodeGitHubConfig(input.instance.config ?? {}); + const env = environmentForInstance(input.instance); + if (config.accountLogin?.trim()) { + const token = yield* vcsProcess.run({ + operation: "resolveTicketAccount", + command: "gh", + args: [ + "auth", + "token", + "--hostname", + input.reference.host, + "--user", + config.accountLogin.trim(), + ], + cwd: input.cwd, + timeoutMs: LOOKUP_TIMEOUT_MS, + maxOutputBytes: 16 * 1024, + }); + const value = token.stdout.trim(); + if (!value) { + return yield* new TicketProviderResolveError({ + driver: "github", + reason: "unauthenticated", + }); + } + env[input.reference.host === "github.com" ? "GH_TOKEN" : "GH_ENTERPRISE_TOKEN"] = value; + env.GH_HOST = input.reference.host; + } + const stdout = yield* runText({ + command: "gh", + args: ["issue", "view", input.reference.url, "--json", "title"], + cwd: input.cwd, + env, + }); + const issue = yield* decodeGitHubIssueJson(stdout); + return { title: issue.title }; + }); + + const resolveGitLab = Effect.fn("TicketProviderRegistry.resolveGitLab")(function* (input: { + readonly cwd: string; + readonly reference: TicketReference; + readonly instance: TicketProviderInstanceConfig; + }) { + const stdout = yield* runText({ + command: "glab", + args: [ + "api", + "--hostname", + input.reference.host, + `projects/${encodeURIComponent(input.reference.project)}/issues/${input.reference.resourceId}`, + ], + cwd: input.cwd, + env: environmentForInstance(input.instance), + }); + const issue = yield* decodeGitLabIssueJson(stdout); + return { title: issue.title }; + }); + + const resolveAzureDevOps = Effect.fn("TicketProviderRegistry.resolveAzureDevOps")( + function* (input: { + readonly cwd: string; + readonly reference: TicketReference; + readonly instance: TicketProviderInstanceConfig; + }) { + const organization = input.reference.project.split("/")[0]!; + const configuredBaseUrl = input.instance.baseUrl.replace(/\/$/u, ""); + const configuredUrl = new URL(configuredBaseUrl); + const organizationUrl = + input.reference.host === "dev.azure.com" && configuredUrl.pathname === "/" + ? `${configuredBaseUrl}/${organization}` + : configuredBaseUrl; + const output = yield* vcsProcess.run({ + operation: "resolveTicket", + command: "az", + args: [ + "boards", + "work-item", + "show", + "--id", + input.reference.resourceId, + "--organization", + organizationUrl, + "--detect", + "false", + "--fields", + "System.Title", + "--query", + 'fields."System.Title"', + "-o", + "tsv", + ], + cwd: input.cwd, + env: environmentForInstance(input.instance), + timeoutMs: LOOKUP_TIMEOUT_MS, + maxOutputBytes: MAX_RESPONSE_BYTES, + }); + return { title: output.stdout.trim() }; + }, + ); + + const resolveBitbucket = Effect.fn("TicketProviderRegistry.resolveBitbucket")(function* (input: { + readonly reference: TicketReference; + readonly instance: TicketProviderInstanceConfig; + }) { + const [workspace, repository] = input.reference.project.split("/"); + if (!workspace || !repository) { + return yield* new TicketProviderResolveError({ + driver: "bitbucket", + reason: "request-failed", + }); + } + let request = HttpClientRequest.get( + `https://api.bitbucket.org/2.0/repositories/${encodeURIComponent(workspace)}/${encodeURIComponent(repository)}/issues/${encodeURIComponent(input.reference.resourceId)}`, + ); + const accessToken = environmentValue(input.instance, "T3CODE_BITBUCKET_ACCESS_TOKEN"); + const email = environmentValue(input.instance, "T3CODE_BITBUCKET_EMAIL"); + const apiToken = environmentValue(input.instance, "T3CODE_BITBUCKET_API_TOKEN"); + if (accessToken) request = request.pipe(HttpClientRequest.bearerToken(accessToken)); + else if (email && apiToken) + request = request.pipe(HttpClientRequest.basicAuth(email, apiToken)); + const response = yield* execute(request); + const issue = yield* HttpClientResponse.schemaBodyJson(BitbucketIssueResponse)(response); + return { title: issue.title }; + }); + + const resolveJira = Effect.fn("TicketProviderRegistry.resolveJira")(function* (input: { + readonly reference: TicketReference; + readonly instance: TicketProviderInstanceConfig; + }) { + const config = yield* decodeJiraConfig(input.instance.config ?? {}); + const baseUrl = input.instance.baseUrl.replace(/\/$/u, ""); + const apiVersion = + config.apiVersion ?? (input.reference.host.endsWith(".atlassian.net") ? "3" : "2"); + let request = HttpClientRequest.get( + `${baseUrl}/rest/api/${apiVersion}/issue/${encodeURIComponent(input.reference.resourceId)}?fields=summary`, + ); + const token = environmentValue(input.instance, "JIRA_API_TOKEN"); + const pat = environmentValue(input.instance, "JIRA_PAT"); + if (config.email?.trim() && token) { + request = request.pipe(HttpClientRequest.basicAuth(config.email.trim(), token)); + } else if (pat ?? token) { + request = request.pipe(HttpClientRequest.bearerToken((pat ?? token)!)); + } + const response = yield* execute(request); + const issue = yield* HttpClientResponse.schemaBodyJson(JiraIssueResponse)(response); + return { title: issue.fields.summary }; + }); + + const resolveClickUp = Effect.fn("TicketProviderRegistry.resolveClickUp")(function* (input: { + readonly reference: TicketReference; + readonly instance: TicketProviderInstanceConfig; + }) { + const config = yield* decodeClickUpConfig(input.instance.config ?? {}); + const token = environmentValue(input.instance, "CLICKUP_API_TOKEN"); + if (!token) { + return yield* new TicketProviderResolveError({ + driver: "clickup", + reason: "unauthenticated", + }); + } + const custom = input.reference.resourceId.includes("-") && config.workspaceId?.trim(); + const query = custom + ? `?custom_task_ids=true&team_id=${encodeURIComponent(config.workspaceId!.trim())}` + : ""; + const response = yield* execute( + HttpClientRequest.get( + `https://api.clickup.com/api/v2/task/${encodeURIComponent(input.reference.resourceId)}${query}`, + ).pipe(HttpClientRequest.setHeader("Authorization", token)), + ); + const task = yield* HttpClientResponse.schemaBodyJson(ClickUpTaskResponse)(response); + return { + title: task.name, + ...(task.custom_id ? { identifier: task.custom_id } : {}), + ...(task.list?.name ? { project: task.list.name } : {}), + }; + }); + + const inFlight = new Map< + string, + Deferred.Deferred + >(); + + const resolveSelected = Effect.fn("TicketProviderRegistry.resolveSelected")(function* ( + input: TicketProviderResolveInput, + selected: Extract, { readonly _tag: "Selected" }>, + ) { + const mapResolveError = ( + effect: Effect.Effect, + ): Effect.Effect => + effect.pipe( + Effect.mapError((cause) => + isTicketProviderResolveError(cause) + ? cause + : new TicketProviderResolveError({ + driver: selected.instance.driver, + instanceId: selected.instanceId, + reason: "request-failed", + cause, + }), + ), + ); + + let resolved: ResolvedTicketFields; + switch (String(selected.instance.driver)) { + case "github": + resolved = yield* mapResolveError(resolveGitHub({ ...input, instance: selected.instance })); + break; + case "gitlab": + resolved = yield* mapResolveError(resolveGitLab({ ...input, instance: selected.instance })); + break; + case "azure-devops": + resolved = yield* mapResolveError( + resolveAzureDevOps({ ...input, instance: selected.instance }), + ); + break; + case "bitbucket": + resolved = yield* mapResolveError( + resolveBitbucket({ ...input, instance: selected.instance }), + ); + break; + case "jira": + resolved = yield* mapResolveError(resolveJira({ ...input, instance: selected.instance })); + break; + case "clickup": + resolved = yield* mapResolveError( + resolveClickUp({ ...input, instance: selected.instance }), + ); + break; + default: + return yield* new TicketProviderResolveError({ + driver: selected.instance.driver, + instanceId: selected.instanceId, + reason: "unsupported-driver", + }); + } + + return { + title: resolved.title, + identifier: resolved.identifier ?? input.reference.identifier, + provider: PROVIDER_LABELS[selected.instance.driver] ?? String(selected.instance.driver), + project: resolved.project ?? input.reference.project, + }; + }); + + const resolve: TicketProviderRegistry["Service"]["resolve"] = Effect.fn( + "TicketProviderRegistry.resolve", + )(function* (input) { + const selected = selectInstance(input); + if (selected._tag !== "Selected") { + return yield* new TicketProviderResolveError({ + driver: input.reference.driver, + reason: selected._tag === "Ambiguous" ? "ambiguous-instance" : "no-instance", + }); + } + + return yield* Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { + const key = `${selected.instanceId}:${input.reference.driver}:${input.reference.url}`; + const pending = inFlight.get(key); + if (pending) { + return yield* restore(Deferred.await(pending)); + } + const deferred = Deferred.makeUnsafe(); + inFlight.set(key, deferred); + const exit = yield* Effect.exit(restore(resolveSelected(input, selected))); + yield* Deferred.done(deferred, exit); + inFlight.delete(key); + return yield* Deferred.await(deferred); + }), + ); + }); + + const probe: TicketProviderRegistry["Service"]["probe"] = Effect.fn( + "TicketProviderRegistry.probe", + )(function* (input) { + const driver = String(input.instance.driver); + if (!Object.hasOwn(PROVIDER_LABELS, driver)) { + return { + instanceId: input.instanceId, + availability: "unknown_driver", + detail: `No ${driver} ticket driver is registered in this build.`, + }; + } + + const probeEffect = Effect.gen(function* () { + const env = environmentForInstance(input.instance); + const baseUrl = input.instance.baseUrl.replace(/\/$/u, ""); + const host = new URL(baseUrl).host; + switch (driver) { + case "github": { + const config = yield* decodeGitHubConfig(input.instance.config ?? {}); + yield* vcsProcess.run({ + operation: "probeTicketProvider", + command: "gh", + args: config.accountLogin?.trim() + ? ["auth", "token", "--hostname", host, "--user", config.accountLogin.trim()] + : ["auth", "status", "--hostname", host], + cwd: input.cwd, + env, + timeoutMs: LOOKUP_TIMEOUT_MS, + maxOutputBytes: 16 * 1024, + }); + break; + } + case "gitlab": + yield* runText({ + command: "glab", + args: ["auth", "status", "--hostname", host], + cwd: input.cwd, + env, + }); + break; + case "azure-devops": + if (!environmentValue(input.instance, "AZURE_DEVOPS_EXT_PAT")) { + yield* runText({ + command: "az", + args: ["account", "show", "--output", "none"], + cwd: input.cwd, + env, + }); + } + break; + case "bitbucket": { + let request = HttpClientRequest.get("https://api.bitbucket.org/2.0/user"); + const accessToken = environmentValue(input.instance, "T3CODE_BITBUCKET_ACCESS_TOKEN"); + const email = environmentValue(input.instance, "T3CODE_BITBUCKET_EMAIL"); + const apiToken = environmentValue(input.instance, "T3CODE_BITBUCKET_API_TOKEN"); + if (accessToken) request = request.pipe(HttpClientRequest.bearerToken(accessToken)); + else if (email && apiToken) { + request = request.pipe(HttpClientRequest.basicAuth(email, apiToken)); + } + yield* execute(request); + break; + } + case "jira": { + const config = yield* decodeJiraConfig(input.instance.config ?? {}); + let request = HttpClientRequest.get(`${baseUrl}/rest/api/2/myself`); + const token = environmentValue(input.instance, "JIRA_API_TOKEN"); + const pat = environmentValue(input.instance, "JIRA_PAT"); + if (config.email?.trim() && token) { + request = request.pipe(HttpClientRequest.basicAuth(config.email.trim(), token)); + } else if (pat ?? token) { + request = request.pipe(HttpClientRequest.bearerToken((pat ?? token)!)); + } + yield* execute(request); + break; + } + case "clickup": { + const token = environmentValue(input.instance, "CLICKUP_API_TOKEN"); + if (!token) { + return yield* new TicketProviderResolveError({ + driver, + instanceId: input.instanceId, + reason: "unauthenticated", + }); + } + yield* execute( + HttpClientRequest.get("https://api.clickup.com/api/v2/user").pipe( + HttpClientRequest.setHeader("Authorization", token), + ), + ); + break; + } + } + }); + + return yield* probeEffect.pipe( + Effect.matchCause({ + onFailure: () => ({ + instanceId: input.instanceId, + availability: "unavailable" as const, + detail: "Connection test failed. Check the local CLI login or configured credential.", + }), + onSuccess: () => ({ + instanceId: input.instanceId, + availability: "available" as const, + detail: "Connection test succeeded.", + }), + }), + ); + }); + + return TicketProviderRegistry.of({ resolve, probe }); +}); + +export const layer = Layer.effect(TicketProviderRegistry, make); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 226c82cdb1ac..8c66a8e1da7a 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -118,6 +118,7 @@ import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts"; import * as AnalyticsService from "./telemetry/AnalyticsService.ts"; import * as UsageService from "./usage/UsageService.ts"; +import * as TicketProviderRegistry from "./ticket/TicketProviderRegistry.ts"; import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; import * as PullRequestService from "./pullRequest/PullRequestService.ts"; import * as SourceControlDiscovery from "./sourceControl/SourceControlDiscovery.ts"; @@ -424,6 +425,7 @@ const makeWsRpcLayer = ( currentSession: EnvironmentAuth.AuthenticatedSession, clientOrigin: OrchestrationClientOrigin, previewAutomationBroker: PreviewAutomationBroker.PreviewAutomationBroker["Service"], + ticketProviderRegistry: TicketProviderRegistry.TicketProviderRegistry["Service"], ) => WsRpcGroup.toLayer( Effect.gen(function* () { @@ -1680,6 +1682,27 @@ const makeWsRpcLayer = ( "rpc.aggregate": "server", }, ), + [WS_METHODS.serverProbeTicketProvider]: ({ instanceId }) => + observeRpcEffect( + WS_METHODS.serverProbeTicketProvider, + Effect.gen(function* () { + const settings = yield* serverSettings.getSettings; + const instance = settings.ticketProviderInstances[instanceId]; + if (!instance) { + return { + instanceId, + availability: "unavailable" as const, + detail: "This ticket provider instance no longer exists.", + }; + } + return yield* ticketProviderRegistry.probe({ + cwd: config.cwd, + instanceId, + instance, + }); + }), + { "rpc.aggregate": "server" }, + ), [WS_METHODS.serverDiscoverSourceControl]: (_input) => observeRpcEffect( WS_METHODS.serverDiscoverSourceControl, @@ -2462,6 +2485,7 @@ export const websocketRpcRouteLayer = Layer.unwrap( const previewAutomationBroker = yield* PreviewAutomationBroker.PreviewAutomationBroker; const serverSelfUpdate = yield* ServerSelfUpdate.ServerSelfUpdate; const pullRequests = yield* PullRequestService.PullRequestService; + const ticketProviderRegistry = yield* TicketProviderRegistry.TicketProviderRegistry; return HttpRouter.add( "GET", "/ws", @@ -2488,7 +2512,12 @@ export const websocketRpcRouteLayer = Layer.unwrap( disableTracing: true, }).pipe( Effect.provide( - makeWsRpcLayer(session, clientOrigin, previewAutomationBroker).pipe( + makeWsRpcLayer( + session, + clientOrigin, + previewAutomationBroker, + ticketProviderRegistry, + ).pipe( Layer.provideMerge(RpcSerialization.layerJson), Layer.provide(ProviderMaintenanceRunner.layer), Layer.provide(Layer.succeed(ServerSelfUpdate.ServerSelfUpdate, serverSelfUpdate)), diff --git a/apps/web/src/components/settings/IntegrationsSettings.tsx b/apps/web/src/components/settings/IntegrationsSettings.tsx index f3b773f7bc80..01fadd85d14f 100644 --- a/apps/web/src/components/settings/IntegrationsSettings.tsx +++ b/apps/web/src/components/settings/IntegrationsSettings.tsx @@ -53,6 +53,7 @@ import { SettingsSection, } from "./settingsLayout"; import { searchableSetting } from "./settingsSearch"; +import { TicketProviderSettings } from "./TicketProviderSettings"; const FILL_VALUE = "fill"; const RESPONSIVE_VALUE = "responsive"; @@ -477,6 +478,9 @@ export function IntegrationsSettingsPanel() { previewDefaults )} + + + ); } diff --git a/apps/web/src/components/settings/ProjectSettingsPanel.tsx b/apps/web/src/components/settings/ProjectSettingsPanel.tsx index b462eaca883b..39cf9bbd536d 100644 --- a/apps/web/src/components/settings/ProjectSettingsPanel.tsx +++ b/apps/web/src/components/settings/ProjectSettingsPanel.tsx @@ -12,12 +12,16 @@ import { deriveProjectGroupingOverrideKey, selectProjectGroupingSettings, } from "../../logicalProject"; +import { TicketProviderDriverKind, TicketProviderInstanceId } from "@t3tools/contracts"; import type { ContextMenuItem, ModelSelection, ProviderDriverKind, SidebarProjectGroupingMode, T3ProjectFileScript, + TicketProviderBindings, + TicketProviderInstanceConfig, + TicketTitlePolicy, ThreadEnvMode, } from "@t3tools/contracts"; import { resolveEnvModeLabel } from "../BranchToolbar.logic"; @@ -39,6 +43,7 @@ import { useComposerDraftStore } from "../../composerDraftStore"; import { isElectron } from "../../env"; import { useClientSettings, + useEnvironmentSettings, useUpdateClientSettings, usePrimarySettings, } from "../../hooks/useSettings"; @@ -114,6 +119,7 @@ import { ProjectFaviconPickerDialog, } from "./ProjectFaviconPickerDialog"; import { projectGroupTitleNeedsUpdate } from "./ProjectSettingsPanel.logic"; +import { TicketTitlePolicySettings } from "./TicketProviderSettings"; export const PROJECT_GROUPING_MODE_LABELS: Record = { repository: "Group by repository", @@ -364,6 +370,7 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { title: string; defaultModelSelection: ModelSelection | null; defaultThreadEnvMode: ThreadEnvMode | null; + ticketTitlePolicy: TicketTitlePolicy | null; faviconPath: string | null; }>, failureTitle: string, @@ -447,6 +454,12 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { ), [updateAllMembers], ); + const storedTicketTitlePolicy = representative.ticketTitlePolicy ?? null; + const setTicketTitlePolicy = useCallback( + (ticketTitlePolicy: TicketTitlePolicy | null) => + void updateAllMembers({ ticketTitlePolicy }, "Failed to update ticket title settings"), + [updateAllMembers], + ); // ----- favicon ----- const [faviconPickerOpen, setFaviconPickerOpen] = useState(false); @@ -472,6 +485,70 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { const selectedCheckout = group.memberProjects.find((member) => member.physicalProjectKey === selectedCheckoutKey) ?? representative; + const selectedCheckoutSettings = useEnvironmentSettings(selectedCheckout.environmentId); + const ticketProviderGroups = useMemo(() => { + const groups = new Map< + string, + { + readonly driver: string; + readonly host: string; + readonly instances: ReadonlyArray; + } + >(); + for (const [instanceId, instance] of Object.entries( + selectedCheckoutSettings.ticketProviderInstances, + )) { + if (instance.enabled === false) continue; + let host: string; + try { + host = new URL(instance.baseUrl).host.toLowerCase(); + } catch { + continue; + } + const key = `${instance.driver}:${host}`; + const existing = groups.get(key); + groups.set(key, { + driver: instance.driver, + host, + instances: [...(existing?.instances ?? []), [instanceId, instance]], + }); + } + return [...groups.values()]; + }, [selectedCheckoutSettings.ticketProviderInstances]); + const selectedTicketProviderBindings = selectedCheckout.ticketProviderBindings ?? []; + const setTicketProviderBinding = useCallback( + (driver: string, host: string, instanceId: string | null) => { + const withoutBinding = selectedTicketProviderBindings.filter( + (binding) => !(binding.driver === driver && binding.host.toLowerCase() === host), + ); + const next: TicketProviderBindings = instanceId + ? [ + ...withoutBinding, + { + driver: TicketProviderDriverKind.make(driver), + host, + instanceId: TicketProviderInstanceId.make(instanceId), + }, + ] + : withoutBinding; + void updateProject({ + environmentId: selectedCheckout.environmentId, + input: { projectId: selectedCheckout.id, ticketProviderBindings: next }, + }).then((result) => + reportFailure( + "Failed to update ticket provider binding", + mapAtomCommandResult(result, () => undefined), + ), + ); + }, + [ + reportFailure, + selectedCheckout.environmentId, + selectedCheckout.id, + selectedTicketProviderBindings, + updateProject, + ], + ); const selectedServerConfig = useAtomValue( serverEnvironment.configValueAtom(selectedCheckout.environmentId), ); @@ -922,6 +999,12 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { } /> + } /> + {ticketProviderGroups.map((providerGroup) => { + const binding = selectedTicketProviderBindings.find( + (candidate) => + candidate.driver === providerGroup.driver && + candidate.host.toLowerCase() === providerGroup.host, + ); + return ( + + setTicketProviderBinding(providerGroup.driver, providerGroup.host, null) + } + /> + ) : null + } + control={ + + } + /> + ); + })} {group.memberProjects.length > 1 ? ( = [ + { + driver: TicketProviderDriverKind.make("github"), + label: "GitHub Issues", + defaultBaseUrl: "https://github.com", + identityLabel: "GitHub account", + identityDescription: + "Optional gh account login. T3 reads its token for this command without changing gh's active account.", + identityConfigKey: "accountLogin", + }, + { + driver: TicketProviderDriverKind.make("gitlab"), + label: "GitLab Issues", + defaultBaseUrl: "https://gitlab.com", + secretLabel: "GitLab token (optional)", + secretEnvironmentName: "GITLAB_TOKEN", + }, + { + driver: TicketProviderDriverKind.make("azure-devops"), + label: "Azure DevOps Work Items", + defaultBaseUrl: "https://dev.azure.com", + secretLabel: "Azure DevOps PAT (optional)", + secretEnvironmentName: "AZURE_DEVOPS_EXT_PAT", + }, + { + driver: TicketProviderDriverKind.make("bitbucket"), + label: "Bitbucket Cloud Issues", + defaultBaseUrl: "https://bitbucket.org", + secretLabel: "Access token (optional for public issues)", + secretEnvironmentName: "T3CODE_BITBUCKET_ACCESS_TOKEN", + }, + { + driver: TicketProviderDriverKind.make("jira"), + label: "Jira", + defaultBaseUrl: "https://example.atlassian.net", + identityLabel: "Account email (Jira Cloud)", + identityConfigKey: "email", + secretLabel: "API token or Data Center PAT", + secretEnvironmentName: "JIRA_API_TOKEN", + }, + { + driver: TicketProviderDriverKind.make("clickup"), + label: "ClickUp Tasks", + defaultBaseUrl: "https://app.clickup.com", + identityLabel: "Workspace ID (for custom task IDs)", + identityConfigKey: "workspaceId", + secretLabel: "ClickUp API token", + secretEnvironmentName: "CLICKUP_API_TOKEN", + }, +]; + +const DRIVER_BY_KIND = new Map(TICKET_DRIVER_OPTIONS.map((option) => [option.driver, option])); +const DEFAULT_DRIVER = TICKET_DRIVER_OPTIONS[0]!; +const POLICY_MODE_LABELS: Readonly> = { + disabled: "Off", + title: "Ticket title", + identifier_title: "Identifier and title", + custom: "Custom template", +}; +const TEMPLATE_PREVIEW_METADATA = { + title: "Fix reconnect failures", + identifier: "acme/widgets#12", + provider: "GitHub", + project: "acme/widgets", +}; + +function slugify(value: string): string { + return value + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/gu, "_") + .replace(/^_+|_+$/gu, "") + .slice(0, 48); +} + +function hostOf(value: string): string | null { + try { + return new URL(value).host; + } catch { + return null; + } +} + +function nextInstanceId( + driver: TicketProviderDriverKind, + label: string, + existing: ReadonlySet, +): TicketProviderInstanceId { + const base = `${driver}_${slugify(label) || "default"}`.slice(0, 60); + let candidate = base; + let suffix = 2; + while (existing.has(candidate)) { + candidate = `${base.slice(0, 60 - String(suffix).length)}_${suffix}`; + suffix += 1; + } + return TicketProviderInstanceId.make(candidate); +} + +export function TicketTitlePolicySettings({ + policy, + inheritedPolicy, + onChange, + allowInherit = false, +}: { + readonly policy: TicketTitlePolicy | null; + readonly inheritedPolicy?: TicketTitlePolicy; + readonly onChange: (policy: TicketTitlePolicy | null) => void; + readonly allowInherit?: boolean; +}) { + const effective = policy ?? + inheritedPolicy ?? { + mode: "identifier_title" as const, + customTemplate: DEFAULT_TICKET_TITLE_TEMPLATE, + }; + const [templateDraft, setTemplateDraft] = useState(effective.customTemplate); + useEffect(() => setTemplateDraft(effective.customTemplate), [effective.customTemplate]); + const preview = renderTicketThreadTitle( + { ...effective, customTemplate: templateDraft }, + TEMPLATE_PREVIEW_METADATA, + ); + const selectValue = policy === null && allowInherit ? "inherit" : effective.mode; + + const updateMode = (value: string | null) => { + if (allowInherit && value === "inherit") { + onChange(null); + return; + } + if ( + value === "disabled" || + value === "title" || + value === "identifier_title" || + value === "custom" + ) { + onChange({ ...effective, mode: value }); + } + }; + + return ( + <> + + + + {selectValue === "inherit" + ? `Default (${POLICY_MODE_LABELS[effective.mode].toLowerCase()})` + : POLICY_MODE_LABELS[effective.mode]} + + + + {allowInherit ? Default : null} + Identifier and title + Ticket title + Custom template + Off + + + } + /> + {effective.mode === "custom" && !(allowInherit && policy === null) ? ( + setTemplateDraft(event.currentTarget.value)} + onBlur={() => { + if (preview) onChange({ ...effective, customTemplate: templateDraft }); + }} + onKeyDown={(event) => { + if (event.key === "Enter") event.currentTarget.blur(); + }} + /> + } + /> + ) : null} + + ); +} + +function AddTicketProviderDialog({ + open, + onOpenChange, +}: { + readonly open: boolean; + readonly onOpenChange: (open: boolean) => void; +}) { + const settings = usePrimarySettings(); + const updateSettings = useUpdatePrimarySettings(); + const [driver, setDriver] = useState(DEFAULT_DRIVER.driver); + const option = DRIVER_BY_KIND.get(driver) ?? DEFAULT_DRIVER; + const [label, setLabel] = useState(""); + const [baseUrl, setBaseUrl] = useState(DEFAULT_DRIVER.defaultBaseUrl); + const [identity, setIdentity] = useState(""); + const [secret, setSecret] = useState(""); + const [isDefault, setIsDefault] = useState(false); + const [error, setError] = useState(null); + + const selectDriver = (value: string | null) => { + const next = TICKET_DRIVER_OPTIONS.find((candidate) => candidate.driver === value); + if (!next) return; + setDriver(next.driver); + setBaseUrl(next.defaultBaseUrl); + setIdentity(""); + setSecret(""); + setError(null); + }; + + const save = () => { + let parsed: URL; + try { + parsed = new URL(baseUrl.trim()); + } catch { + setError("Enter a valid base URL."); + return; + } + if (parsed.protocol !== "https:" && parsed.protocol !== "http:") { + setError("The base URL must use HTTP or HTTPS."); + return; + } + if ( + option.driver === TicketProviderDriverKind.make("jira") && + parsed.hostname === "example.atlassian.net" + ) { + setError("Replace the example URL with your Jira site URL."); + return; + } + + const displayName = label.trim() || option.label; + const instanceId = nextInstanceId( + option.driver, + `${displayName}_${parsed.host}`, + new Set(Object.keys(settings.ticketProviderInstances)), + ); + const config = + option.identityConfigKey && identity.trim() + ? { [option.identityConfigKey]: identity.trim() } + : undefined; + const environment = + option.secretEnvironmentName && secret.trim() + ? [ + { + name: option.secretEnvironmentName, + value: secret.trim(), + sensitive: true, + }, + ] + : undefined; + const instance: TicketProviderInstanceConfig = { + driver: option.driver, + displayName, + baseUrl: parsed.toString().replace(/\/$/u, ""), + enabled: true, + isDefault, + ...(config ? { config } : {}), + ...(environment ? { environment } : {}), + }; + const nextInstances = { ...settings.ticketProviderInstances }; + if (isDefault) { + for (const [id, candidate] of Object.entries(nextInstances)) { + if (candidate.driver === option.driver && hostOf(candidate.baseUrl) === parsed.host) { + nextInstances[TicketProviderInstanceId.make(id)] = { ...candidate, isDefault: false }; + } + } + } + nextInstances[instanceId] = instance; + updateSettings({ ticketProviderInstances: nextInstances }); + toastManager.add({ type: "success", title: "Ticket provider added", description: displayName }); + onOpenChange(false); + }; + + return ( + + + + Add ticket provider + + T3 prefers the provider's local CLI where one is available. Credentials entered here are + stored by this environment and never returned to clients. + + + + + Provider + + + + Name + setLabel(event.currentTarget.value)} + placeholder={option.label} + /> + + + Base URL + setBaseUrl(event.currentTarget.value)} /> + + Use the web URL where ticket links for this account open. + + + {option.identityConfigKey ? ( + + {option.identityLabel} + setIdentity(event.currentTarget.value)} + /> + {option.identityDescription ? ( + {option.identityDescription} + ) : null} + + ) : null} + {option.secretEnvironmentName ? ( + + {option.secretLabel} + setSecret(event.currentTarget.value)} + autoComplete="off" + /> + + ) : null} + + {error ?

{error}

: null} +
+ + + + +
+
+ ); +} + +export function TicketProviderSettings() { + const settings = usePrimarySettings(); + const updateSettings = useUpdatePrimarySettings(); + const environmentId = usePrimaryEnvironmentId(); + const probeProvider = useAtomCommand(serverEnvironment.probeTicketProvider, { + reportFailure: false, + }); + const [dialogOpen, setDialogOpen] = useState(false); + const [probeByInstanceId, setProbeByInstanceId] = useState< + Readonly> + >({}); + const entries = useMemo( + () => Object.entries(settings.ticketProviderInstances), + [settings.ticketProviderInstances], + ); + + const replaceInstance = (instanceId: string, instance: TicketProviderInstanceConfig) => { + updateSettings({ + ticketProviderInstances: { + ...settings.ticketProviderInstances, + [TicketProviderInstanceId.make(instanceId)]: instance, + }, + }); + }; + + return ( + <> + { + if (ticketTitlePolicy) updateSettings({ ticketTitlePolicy }); + }} + /> + 0 ? ( + updateSettings({ ticketProviderInstances: {} })} + /> + ) : null + } + control={ + + } + /> + {entries.map(([instanceId, instance]) => { + const option = DRIVER_BY_KIND.get(instance.driver); + const probe = probeByInstanceId[instanceId]; + return ( +
+
+

+ {instance.displayName ?? option?.label ?? instance.driver} +

+

+ {instance.baseUrl} · {instanceId} +

+ {probe && probe !== "testing" ? ( +

+ {probe.detail} +

+ ) : null} +
+ + + replaceInstance(instanceId, { ...instance, enabled: Boolean(checked) }) + } + /> + +
+ ); + })} + + + ); +} diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index 5213cb55a503..100992b7ac11 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -238,6 +238,17 @@ export const SETTINGS_SEARCH_ITEMS = [ to: "/settings/integrations", targetId: "browser", }, + { + id: "ticket-thread-titles", + title: "Ticket thread titles", + to: "/settings/integrations", + targetId: "ticket-providers", + }, + { + id: "ticket-providers", + title: "Ticket providers", + to: "/settings/integrations", + }, { id: "source-control", title: "Source control", diff --git a/docs/internals/glossary.md b/docs/internals/glossary.md index 8f5791175c13..e3e389697b87 100644 --- a/docs/internals/glossary.md +++ b/docs/internals/glossary.md @@ -96,6 +96,14 @@ The live backend agent implementation and its event stream. The main service is The backend agent runtime that actually performs work. Five drivers ship built in: Codex, Claude, Cursor, Grok, and OpenCode. See [ProviderService.ts][14], [ProviderAdapter.ts][15], and [CodexAdapter.ts][17] as a representative adapter. +#### Ticket provider + +An external issue tracker used only to resolve metadata for a linked ticket, distinct from an agent +provider. Ticket provider instances are environment settings; project bindings select an instance +for a driver and host. Resolution runs as a side effect in +[ProviderCommandReactor.ts][12], while title replacement remains a compare-and-set orchestration +command so a manual rename always wins. + #### Session The live provider-backed runtime attached to a thread. Session shape is in [the orchestration contracts][1], and lifecycle is managed in [ProviderService.ts][14]. diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index 51eca1e73207..1e38a9948cd8 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -32,3 +32,18 @@ pill** fallback because their colors are not controlled by T3 Code. To generate a fresh title from the conversation, open a thread's context menu and choose **Regenerate title**. While T3 Code is generating it, the action reads **Regenerating…** and cannot be selected again. The option is hidden when the connected environment needs a server update. + +## Titles from linked tickets + +When the first message in a new thread contains one supported ticket link, T3 Code can replace the +generated thread title with the ticket's title. The generated title appears immediately while the +ticket is read in the background. If you rename the thread before that finishes, your title is kept. + +Configure the title format and ticket accounts in **Settings → Integrations → Ticket providers**. +You can use the ticket title, its identifier and title, or a custom template with `{title}`, +`{identifier}`, `{provider}`, and `{project}`. Project settings can override the global format and +bind a provider and host to a particular configured account. + +GitHub Issues, GitLab Issues, Azure DevOps work items, Bitbucket Cloud issues, Jira issues, and +ClickUp tasks are supported. T3 Code uses local provider CLIs where available. A failed or ambiguous +lookup is silent and leaves the generated title unchanged. diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts index 2fef689a9bbb..3ab973a7dbc9 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -770,6 +770,14 @@ export function createServerEnvironmentAtoms( scheduler: configScheduler, concurrency: configConcurrency, }), + probeTicketProvider: createEnvironmentRpcCommand(runtime, { + label: "environment-data:server:probe-ticket-provider", + tag: WS_METHODS.serverProbeTicketProvider, + concurrency: { + mode: "singleFlight", + key: ({ environmentId, input }) => JSON.stringify([environmentId, input.instanceId]), + }, + }), signalProcess: createEnvironmentRpcCommand(runtime, { label: "environment-data:server:signal-process", tag: WS_METHODS.serverSignalProcess, diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index c6daef8687ba..2cc2e94fda7a 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -10,6 +10,7 @@ export * from "./ipc.ts"; export * from "./terminal.ts"; export * from "./provider.ts"; export * from "./providerInstance.ts"; +export * from "./ticketProvider.ts"; export * from "./providerRuntime.ts"; export * from "./model.ts"; export * from "./keybindings.ts"; diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 682d65fda8ac..b559343c0713 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -23,6 +23,7 @@ import { TurnId, } from "./baseSchemas.ts"; import { ProviderInstanceId } from "./providerInstance.ts"; +import { TicketProviderBindings, TicketTitlePolicy } from "./ticketProvider.ts"; export const ORCHESTRATION_WS_METHODS = { dispatchCommand: "orchestration.dispatchCommand", @@ -252,6 +253,8 @@ export const OrchestrationProject = Schema.Struct({ // Per-project override for where new threads start. Null/absent means // "no override": clients fall back to t3.json, then the global setting. defaultThreadEnvMode: Schema.optional(Schema.NullOr(ThreadEnvMode)), + ticketTitlePolicy: Schema.optional(Schema.NullOr(TicketTitlePolicy)), + ticketProviderBindings: Schema.optional(TicketProviderBindings), // Optional on the wire so cached snapshots from older servers still decode. faviconPath: Schema.optional(Schema.NullOr(ProjectFaviconPath)), scripts: Schema.Array(ProjectScript), @@ -463,6 +466,8 @@ export const OrchestrationProjectShell = Schema.Struct({ repositoryIdentity: Schema.optional(Schema.NullOr(RepositoryIdentity)), defaultModelSelection: Schema.NullOr(ModelSelection), defaultThreadEnvMode: Schema.optional(Schema.NullOr(ThreadEnvMode)), + ticketTitlePolicy: Schema.optional(Schema.NullOr(TicketTitlePolicy)), + ticketProviderBindings: Schema.optional(TicketProviderBindings), // Optional on the wire so cached snapshots from older servers still decode. faviconPath: Schema.optional(Schema.NullOr(ProjectFaviconPath)), scripts: Schema.Array(ProjectScript), @@ -682,6 +687,8 @@ const ProjectMetaUpdateCommand = Schema.Struct({ defaultModelSelection: Schema.optional(Schema.NullOr(ModelSelection)), // Absent = leave unchanged; null = clear the override. defaultThreadEnvMode: Schema.optional(Schema.NullOr(ThreadEnvMode)), + ticketTitlePolicy: Schema.optional(Schema.NullOr(TicketTitlePolicy)), + ticketProviderBindings: Schema.optional(TicketProviderBindings), faviconPath: Schema.optional(Schema.NullOr(ProjectFaviconPath)), scripts: Schema.optional(Schema.Array(ProjectScript)), }); @@ -796,6 +803,7 @@ const ThreadMetaUpdateCommand = Schema.Struct({ commandId: CommandId, threadId: ThreadId, title: Schema.optional(TrimmedNonEmptyString), + expectedTitle: Schema.optional(TrimmedNonEmptyString), regenerateTitle: Schema.optional(Schema.Literal(true)), modelSelection: Schema.optional(ModelSelection), branch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), @@ -1142,6 +1150,8 @@ export const ProjectMetaUpdatedPayload = Schema.Struct({ repositoryIdentity: Schema.optional(Schema.NullOr(RepositoryIdentity)), defaultModelSelection: Schema.optional(Schema.NullOr(ModelSelection)), defaultThreadEnvMode: Schema.optional(Schema.NullOr(ThreadEnvMode)), + ticketTitlePolicy: Schema.optional(Schema.NullOr(TicketTitlePolicy)), + ticketProviderBindings: Schema.optional(TicketProviderBindings), faviconPath: Schema.optional(Schema.NullOr(ProjectFaviconPath)), scripts: Schema.optional(Schema.Array(ProjectScript)), updatedAt: IsoDateTime, diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 14363cfedff9..04feca0a0b6d 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -80,6 +80,7 @@ import { ProviderUploadFeedbackResult, } from "./provider.ts"; import { ProviderInstanceId } from "./providerInstance.ts"; +import { TicketProviderInstanceId, TicketProviderProbeResult } from "./ticketProvider.ts"; import { PullRequestActionInput, PullRequestActivity, @@ -280,6 +281,7 @@ export const WS_METHODS = { serverRemoveKeybinding: "server.removeKeybinding", serverGetSettings: "server.getSettings", serverUpdateSettings: "server.updateSettings", + serverProbeTicketProvider: "server.probeTicketProvider", serverDiscoverSourceControl: "server.discoverSourceControl", serverGetTraceDiagnostics: "server.getTraceDiagnostics", serverGetProcessDiagnostics: "server.getProcessDiagnostics", @@ -405,6 +407,12 @@ export const WsServerUpdateSettingsRpc = Rpc.make(WS_METHODS.serverUpdateSetting error: Schema.Union([ServerSettingsError, EnvironmentAuthorizationError]), }); +export const WsServerProbeTicketProviderRpc = Rpc.make(WS_METHODS.serverProbeTicketProvider, { + payload: Schema.Struct({ instanceId: TicketProviderInstanceId }), + success: TicketProviderProbeResult, + error: Schema.Union([ServerSettingsError, EnvironmentAuthorizationError]), +}); + export const WsServerDiscoverSourceControlRpc = Rpc.make(WS_METHODS.serverDiscoverSourceControl, { payload: Schema.Struct({}), success: SourceControlDiscoveryResult, @@ -1028,6 +1036,7 @@ export const WsRpcGroup = RpcGroup.make( WsServerRemoveKeybindingRpc, WsServerGetSettingsRpc, WsServerUpdateSettingsRpc, + WsServerProbeTicketProviderRpc, WsServerDiscoverSourceControlRpc, WsServerGetTraceDiagnosticsRpc, WsServerGetProcessDiagnosticsRpc, diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index d75b0b4ae798..ceaaf1585be4 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vite-plus/test"; import * as Schema from "effect/Schema"; import { ProviderDriverKind, ProviderInstanceId } from "./providerInstance.ts"; +import { TicketProviderInstanceId } from "./ticketProvider.ts"; import { ClientSettingsSchema, ClientSettingsPatch, @@ -411,3 +412,37 @@ describe("ServerSettingsPatch string normalization", () => { expect(encoded.providers?.codex?.launchArgs).toBe("--strict-config"); }); }); + +describe("ServerSettings ticket title configuration", () => { + it("enables identifier-and-title naming by default", () => { + const settings = decodeServerSettings({}); + expect(settings.ticketTitlePolicy).toEqual({ + mode: "identifier_title", + customTemplate: "{identifier} — {title}", + }); + expect(settings.ticketProviderInstances).toEqual({}); + }); + + it("preserves open ticket driver slugs and whole-map instance patches", () => { + const patch = decodeServerSettingsPatch({ + ticketTitlePolicy: { mode: "custom", customTemplate: " [{provider}] {title} " }, + ticketProviderInstances: { + linear_work: { + driver: "linear", + displayName: " Linear Work ", + baseUrl: " https://linear.app/acme ", + }, + }, + }); + const instance = patch.ticketProviderInstances?.[TicketProviderInstanceId.make("linear_work")]; + expect(patch.ticketTitlePolicy).toEqual({ + mode: "custom", + customTemplate: "[{provider}] {title}", + }); + expect(instance).toMatchObject({ + driver: "linear", + displayName: "Linear Work", + baseUrl: "https://linear.app/acme", + }); + }); +}); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 76bb032c866e..570878827308 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -23,6 +23,12 @@ import { ProviderInstanceId, type ProviderDriverKind, } from "./providerInstance.ts"; +import { + TicketProviderInstanceConfig, + TicketProviderInstanceId, + TicketTitleMode, + TicketTitlePolicy, +} from "./ticketProvider.ts"; // ── Client Settings (local-only) ─────────────────────────────── @@ -679,6 +685,11 @@ export const ServerSettings = Schema.Struct({ sourceControlWriterModelSelection: Schema.NullOr(ModelSelection).pipe( Schema.withDecodingDefault(Effect.succeed(null)), ), + ticketTitlePolicy: TicketTitlePolicy.pipe(Schema.withDecodingDefault(Effect.succeed({}))), + ticketProviderInstances: Schema.Record( + TicketProviderInstanceId, + TicketProviderInstanceConfig, + ).pipe(Schema.withDecodingDefault(Effect.succeed({}))), // Legacy single-instance-per-driver settings. Continues to be the source // of truth until `providerInstances` (below) lands per-driver migration @@ -873,6 +884,15 @@ export const ServerSettingsPatch = Schema.Struct({ }), ), sourceControlWriterModelSelection: Schema.optionalKey(Schema.NullOr(ModelSelection)), + ticketTitlePolicy: Schema.optionalKey( + Schema.Struct({ + mode: Schema.optionalKey(TicketTitleMode), + customTemplate: Schema.optionalKey(TrimmedString), + }), + ), + ticketProviderInstances: Schema.optionalKey( + Schema.Record(TicketProviderInstanceId, TicketProviderInstanceConfig), + ), observability: Schema.optionalKey( Schema.Struct({ otlpTracesUrl: Schema.optionalKey(TrimmedString), diff --git a/packages/contracts/src/ticketProvider.ts b/packages/contracts/src/ticketProvider.ts new file mode 100644 index 000000000000..2bf1be21a698 --- /dev/null +++ b/packages/contracts/src/ticketProvider.ts @@ -0,0 +1,81 @@ +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +import { TrimmedNonEmptyString, TrimmedString } from "./baseSchemas.ts"; +import { ProviderInstanceEnvironment } from "./providerInstance.ts"; + +const TICKET_PROVIDER_SLUG_MAX_CHARS = 64; +const TICKET_PROVIDER_SLUG_PATTERN = /^[a-zA-Z][a-zA-Z0-9_-]*$/; + +const ticketProviderSlug = TrimmedNonEmptyString.check( + Schema.isMaxLength(TICKET_PROVIDER_SLUG_MAX_CHARS), + Schema.isPattern(TICKET_PROVIDER_SLUG_PATTERN), +); + +export const TicketProviderDriverKind = ticketProviderSlug.pipe( + Schema.brand("TicketProviderDriverKind"), +); +export type TicketProviderDriverKind = typeof TicketProviderDriverKind.Type; + +export const TicketProviderInstanceId = ticketProviderSlug.pipe( + Schema.brand("TicketProviderInstanceId"), +); +export type TicketProviderInstanceId = typeof TicketProviderInstanceId.Type; + +export const TicketTitleMode = Schema.Literals(["disabled", "title", "identifier_title", "custom"]); +export type TicketTitleMode = typeof TicketTitleMode.Type; + +export const DEFAULT_TICKET_TITLE_TEMPLATE = "{identifier} — {title}"; +export const MAX_TICKET_THREAD_TITLE_CHARS = 512; + +export const TicketTitlePolicy = Schema.Struct({ + mode: TicketTitleMode.pipe( + Schema.withDecodingDefault(Effect.succeed("identifier_title" as const)), + ), + customTemplate: TrimmedString.pipe( + Schema.withDecodingDefault(Effect.succeed(DEFAULT_TICKET_TITLE_TEMPLATE)), + ), +}); +export type TicketTitlePolicy = typeof TicketTitlePolicy.Type; + +export const TicketProviderInstanceConfig = Schema.Struct({ + driver: TicketProviderDriverKind, + displayName: Schema.optional(TrimmedNonEmptyString), + baseUrl: TrimmedNonEmptyString, + enabled: Schema.optionalKey(Schema.Boolean), + isDefault: Schema.optionalKey(Schema.Boolean), + environment: Schema.optionalKey(ProviderInstanceEnvironment), + config: Schema.optionalKey(Schema.Unknown), +}); +export type TicketProviderInstanceConfig = typeof TicketProviderInstanceConfig.Type; + +export const TicketProviderInstanceConfigMap = Schema.Record( + TicketProviderInstanceId, + TicketProviderInstanceConfig, +); +export type TicketProviderInstanceConfigMap = typeof TicketProviderInstanceConfigMap.Type; + +export const TicketProviderBinding = Schema.Struct({ + driver: TicketProviderDriverKind, + host: TrimmedNonEmptyString, + instanceId: TicketProviderInstanceId, +}); +export type TicketProviderBinding = typeof TicketProviderBinding.Type; + +export const TicketProviderBindings = Schema.Array(TicketProviderBinding); +export type TicketProviderBindings = typeof TicketProviderBindings.Type; + +export const TicketProviderAvailability = Schema.Literals([ + "available", + "unavailable", + "unauthenticated", + "unknown_driver", +]); +export type TicketProviderAvailability = typeof TicketProviderAvailability.Type; + +export const TicketProviderProbeResult = Schema.Struct({ + instanceId: TicketProviderInstanceId, + availability: TicketProviderAvailability, + detail: Schema.optional(TrimmedNonEmptyString), +}); +export type TicketProviderProbeResult = typeof TicketProviderProbeResult.Type; diff --git a/packages/shared/package.json b/packages/shared/package.json index eeaa5f59e087..119509973bf5 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -103,6 +103,10 @@ "types": "./src/threadEnvMode.ts", "import": "./src/threadEnvMode.ts" }, + "./ticketTitles": { + "types": "./src/ticketTitles.ts", + "import": "./src/ticketTitles.ts" + }, "./t3ProjectFile": { "types": "./src/t3ProjectFile.ts", "import": "./src/t3ProjectFile.ts" diff --git a/packages/shared/src/serverSettings.ts b/packages/shared/src/serverSettings.ts index 69fc9eaacbcc..6b0e99c8f8aa 100644 --- a/packages/shared/src/serverSettings.ts +++ b/packages/shared/src/serverSettings.ts @@ -188,6 +188,9 @@ export function applyServerSettingsPatch( ...(patch.providerInstances !== undefined ? { providerInstances: patch.providerInstances } : {}), + ...(patch.ticketProviderInstances !== undefined + ? { ticketProviderInstances: patch.ticketProviderInstances } + : {}), ...(patch.sourceControlWriterModelSelection !== undefined ? { sourceControlWriterModelSelection: patch.sourceControlWriterModelSelection } : {}), diff --git a/packages/shared/src/ticketTitles.test.ts b/packages/shared/src/ticketTitles.test.ts new file mode 100644 index 000000000000..f958449dea2b --- /dev/null +++ b/packages/shared/src/ticketTitles.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + extractUniqueTicketReference, + normalizeTicketThreadTitle, + renderTicketThreadTitle, +} from "./ticketTitles.ts"; + +describe("extractUniqueTicketReference", () => { + it.each([ + ["https://github.com/t3tools/t3code/issues/123", "github", "t3tools/t3code#123"], + ["https://gitlab.com/acme/widgets/-/issues/42", "gitlab", "acme/widgets#42"], + ["https://dev.azure.com/acme/widgets/_workitems/edit/77", "azure-devops", "acme/widgets#77"], + ["https://bitbucket.org/acme/widgets/issues/8", "bitbucket", "acme/widgets#8"], + ["https://acme.atlassian.net/browse/WEB-12", "jira", "WEB-12"], + ["https://app.clickup.com/t/901/abc123", "clickup", "abc123"], + ])("parses %s", (url, driver, identifier) => { + expect(extractUniqueTicketReference(`Please fix ${url}.`)).toMatchObject({ + driver, + identifier, + }); + }); + + it("deduplicates canonical references after removing query and fragments", () => { + const reference = extractUniqueTicketReference( + "https://github.com/acme/widgets/issues/12/?notification=1 https://github.com/acme/widgets/issues/12#issuecomment-1", + ); + expect(reference?.identifier).toBe("acme/widgets#12"); + }); + + it("returns no reference when distinct tickets are present", () => { + expect( + extractUniqueTicketReference( + "https://github.com/acme/widgets/issues/12 https://acme.atlassian.net/browse/WEB-2", + ), + ).toBeUndefined(); + }); + + it("ignores links in code and blockquotes", () => { + expect( + extractUniqueTicketReference( + "`https://github.com/acme/widgets/issues/1`\n> https://github.com/acme/widgets/issues/2\nhttps://github.com/acme/widgets/issues/3", + )?.identifier, + ).toBe("acme/widgets#3"); + }); + + it("uses configured hosts for self-hosted providers", () => { + expect( + extractUniqueTicketReference("http://git.internal/acme/widgets/-/issues/9", [ + { driver: "gitlab" as never, baseUrl: "http://git.internal" }, + ]), + ).toMatchObject({ driver: "gitlab", identifier: "acme/widgets#9" }); + }); + + it("removes a configured GitLab base path from the project identifier", () => { + expect( + extractUniqueTicketReference("https://tools.internal/gitlab/acme/widgets/-/issues/9", [ + { driver: "gitlab" as never, baseUrl: "https://tools.internal/gitlab" }, + ]), + ).toMatchObject({ driver: "gitlab", identifier: "acme/widgets#9" }); + }); + + it("parses Azure DevOps Server links relative to their collection URL", () => { + expect( + extractUniqueTicketReference( + "https://devops.internal/tfs/DefaultCollection/Widgets/_workitems/edit/77", + [ + { + driver: "azure-devops" as never, + baseUrl: "https://devops.internal/tfs/DefaultCollection", + }, + ], + ), + ).toMatchObject({ + driver: "azure-devops", + identifier: "DefaultCollection/Widgets#77", + project: "DefaultCollection/Widgets", + }); + }); +}); + +describe("renderTicketThreadTitle", () => { + const metadata = { + title: "Fix reconnect failures", + identifier: "acme/widgets#12", + provider: "GitHub", + project: "acme/widgets", + }; + + it("renders built-in and custom policies", () => { + expect(renderTicketThreadTitle({ mode: "title", customTemplate: "" }, metadata)).toBe( + "Fix reconnect failures", + ); + expect( + renderTicketThreadTitle({ mode: "identifier_title", customTemplate: "" }, metadata), + ).toBe("acme/widgets#12 — Fix reconnect failures"); + expect( + renderTicketThreadTitle( + { mode: "custom", customTemplate: "[{provider}] {project}: {title}" }, + metadata, + ), + ).toBe("[GitHub] acme/widgets: Fix reconnect failures"); + }); + + it("supports literal braces and rejects unknown variables", () => { + expect( + renderTicketThreadTitle( + { mode: "custom", customTemplate: "{{{identifier}}} {title}" }, + metadata, + ), + ).toBe("{acme/widgets#12} Fix reconnect failures"); + expect( + renderTicketThreadTitle({ mode: "custom", customTemplate: "{unknown}" }, metadata), + ).toBeUndefined(); + }); + + it("normalizes control characters and bounds output", () => { + expect(normalizeTicketThreadTitle(` First\n\tSecond ${"x".repeat(600)}`)).toHaveLength(512); + }); +}); diff --git a/packages/shared/src/ticketTitles.ts b/packages/shared/src/ticketTitles.ts new file mode 100644 index 000000000000..e1de16d8394e --- /dev/null +++ b/packages/shared/src/ticketTitles.ts @@ -0,0 +1,292 @@ +import { + DEFAULT_TICKET_TITLE_TEMPLATE, + MAX_TICKET_THREAD_TITLE_CHARS, + type TicketProviderDriverKind, + type TicketTitlePolicy, +} from "@t3tools/contracts"; + +export interface TicketReference { + readonly driver: TicketProviderDriverKind; + readonly host: string; + readonly url: string; + readonly identifier: string; + readonly project: string; + readonly resourceId: string; +} + +export interface TicketTitleMetadata { + readonly title: string; + readonly identifier: string; + readonly provider: string; + readonly project: string; +} + +const BUILT_IN_HOST_DRIVERS = new Map([ + ["github.com", "github"], + ["gitlab.com", "gitlab"], + ["dev.azure.com", "azure-devops"], + ["bitbucket.org", "bitbucket"], + ["app.clickup.com", "clickup"], +]); + +const URL_PATTERN = /https?:\/\/[^\s<>"']+/giu; +const TRAILING_URL_PUNCTUATION = /[),.;!?\]}]+$/u; + +function stripIgnoredMarkdown(message: string): string { + return message + .replace(/```[\s\S]*?```/gu, " ") + .replace(/~~~[\s\S]*?~~~/gu, " ") + .replace(/^\s*>.*$/gmu, " ") + .replace(/`[^`\n]*`/gu, " "); +} + +function normalizedUrl(raw: string): URL | undefined { + try { + const url = new URL(raw.replace(TRAILING_URL_PUNCTUATION, "")); + url.hash = ""; + url.search = ""; + if (url.pathname !== "/") { + url.pathname = url.pathname.replace(/\/+$/u, ""); + } + return url; + } catch { + return undefined; + } +} + +function configuredDriverForUrl( + url: URL, + configuredBaseUrls: ReadonlyArray<{ + readonly driver: TicketProviderDriverKind; + readonly baseUrl: string; + }>, +): { readonly driver: TicketProviderDriverKind; readonly basePath: string } | undefined { + const matching = configuredBaseUrls + .flatMap((entry) => { + const base = normalizedUrl(entry.baseUrl); + if (!base || base.host !== url.host) return []; + const basePath = base.pathname.replace(/\/$/u, ""); + if (basePath && !url.pathname.startsWith(`${basePath}/`) && url.pathname !== basePath) { + return []; + } + return [{ ...entry, basePath }]; + }) + .sort((left, right) => right.basePath.length - left.basePath.length); + return matching[0]; +} + +function driverForUrl( + url: URL, + configuredBaseUrls: ReadonlyArray<{ + readonly driver: TicketProviderDriverKind; + readonly baseUrl: string; + }>, +): { readonly driver: TicketProviderDriverKind; readonly basePath: string } | undefined { + const configured = configuredDriverForUrl(url, configuredBaseUrls); + if (configured) return configured; + if (url.hostname.endsWith(".atlassian.net")) { + return { driver: "jira" as TicketProviderDriverKind, basePath: "" }; + } + if (url.hostname.endsWith(".visualstudio.com")) { + return { driver: "azure-devops" as TicketProviderDriverKind, basePath: "" }; + } + const driver = BUILT_IN_HOST_DRIVERS.get(url.hostname) as TicketProviderDriverKind | undefined; + return driver ? { driver, basePath: "" } : undefined; +} + +function pathnameRelativeToBase(url: URL, basePath: string): string { + if (!basePath) return url.pathname; + const relative = url.pathname.slice(basePath.length); + return relative.startsWith("/") ? relative : `/${relative}`; +} + +function parseGitHub(url: URL, basePath: string): TicketReference | undefined { + const match = /^\/([^/]+)\/([^/]+)\/issues\/(\d+)\/?$/u.exec( + pathnameRelativeToBase(url, basePath), + ); + if (!match) return undefined; + const project = `${match[1]}/${match[2]}`; + return { + driver: "github" as TicketProviderDriverKind, + host: url.host, + url: url.toString(), + identifier: `${project}#${match[3]}`, + project, + resourceId: match[3]!, + }; +} + +function parseGitLab(url: URL, basePath: string): TicketReference | undefined { + const match = /^\/(.+)\/-\/issues\/(\d+)\/?$/u.exec(pathnameRelativeToBase(url, basePath)); + if (!match) return undefined; + const project = match[1]!; + return { + driver: "gitlab" as TicketProviderDriverKind, + host: url.host, + url: url.toString(), + identifier: `${project}#${match[2]}`, + project, + resourceId: match[2]!, + }; +} + +function parseAzureDevOps(url: URL, basePath: string): TicketReference | undefined { + const cloudMatch = /^\/([^/]+)\/([^/]+)\/_workitems\/edit\/(\d+)\/?$/u.exec(url.pathname); + const legacyMatch = /^\/([^/]+)\/_workitems\/edit\/(\d+)\/?$/u.exec(url.pathname); + const serverMatch = /^\/([^/]+)\/_workitems\/edit\/(\d+)\/?$/u.exec( + pathnameRelativeToBase(url, basePath), + ); + const baseOrganization = /([^/]+)\/?$/u.exec(basePath)?.[1]; + const organization = url.hostname.endsWith(".visualstudio.com") + ? url.hostname.slice(0, -".visualstudio.com".length) + : (cloudMatch?.[1] ?? baseOrganization); + const project = url.hostname.endsWith(".visualstudio.com") + ? legacyMatch?.[1] + : (cloudMatch?.[2] ?? serverMatch?.[1]); + const resourceId = url.hostname.endsWith(".visualstudio.com") + ? legacyMatch?.[2] + : (cloudMatch?.[3] ?? serverMatch?.[2]); + if (!organization || !project || !resourceId) return undefined; + return { + driver: "azure-devops" as TicketProviderDriverKind, + host: url.host, + url: url.toString(), + identifier: `${organization}/${project}#${resourceId}`, + project: `${organization}/${project}`, + resourceId, + }; +} + +function parseBitbucket(url: URL): TicketReference | undefined { + const match = /^\/([^/]+)\/([^/]+)\/issues\/(\d+)\/?$/u.exec(url.pathname); + if (!match) return undefined; + const project = `${match[1]}/${match[2]}`; + return { + driver: "bitbucket" as TicketProviderDriverKind, + host: url.host, + url: url.toString(), + identifier: `${project}#${match[3]}`, + project, + resourceId: match[3]!, + }; +} + +function parseJira(url: URL, basePath: string): TicketReference | undefined { + const match = /^(.*?)\/browse\/([A-Za-z][A-Za-z0-9_]*-\d+)\/?$/u.exec( + pathnameRelativeToBase(url, basePath), + ); + if (!match) return undefined; + const identifier = match[2]!.toUpperCase(); + return { + driver: "jira" as TicketProviderDriverKind, + host: url.host, + url: url.toString(), + identifier, + project: identifier.slice(0, identifier.lastIndexOf("-")), + resourceId: identifier, + }; +} + +function parseClickUp(url: URL): TicketReference | undefined { + const match = /^\/t\/(?:([^/]+)\/)?([^/]+)\/?$/u.exec(url.pathname); + if (!match) return undefined; + const workspace = match[1] ?? "ClickUp"; + const resourceId = match[2]!; + return { + driver: "clickup" as TicketProviderDriverKind, + host: url.host, + url: url.toString(), + identifier: resourceId, + project: workspace, + resourceId, + }; +} + +function parseByDriver( + driver: TicketProviderDriverKind, + url: URL, + basePath: string, +): TicketReference | undefined { + switch (driver) { + case "github": + return parseGitHub(url, basePath); + case "gitlab": + return parseGitLab(url, basePath); + case "azure-devops": + return parseAzureDevOps(url, basePath); + case "bitbucket": + return parseBitbucket(url); + case "jira": + return parseJira(url, basePath); + case "clickup": + return parseClickUp(url); + default: + return undefined; + } +} + +export function extractUniqueTicketReference( + message: string, + configuredBaseUrls: ReadonlyArray<{ + readonly driver: TicketProviderDriverKind; + readonly baseUrl: string; + }> = [], +): TicketReference | undefined { + const references = new Map(); + for (const rawUrl of stripIgnoredMarkdown(message).match(URL_PATTERN) ?? []) { + const url = normalizedUrl(rawUrl); + if (!url) continue; + const match = driverForUrl(url, configuredBaseUrls); + if (!match) continue; + const reference = parseByDriver(match.driver, url, match.basePath); + if (!reference) continue; + references.set(`${reference.driver}:${reference.url}`, reference); + } + return references.size === 1 ? [...references.values()][0] : undefined; +} + +export function normalizeTicketThreadTitle(value: string): string { + let withoutControlCharacters = ""; + for (const character of value) { + const codePoint = character.codePointAt(0) ?? 0; + withoutControlCharacters += codePoint <= 0x1f || codePoint === 0x7f ? " " : character; + } + return withoutControlCharacters + .replace(/\s+/gu, " ") + .trim() + .slice(0, MAX_TICKET_THREAD_TITLE_CHARS); +} + +const TEMPLATE_TOKEN_PATTERN = /\{(title|identifier|provider|project)\}/gu; +const UNKNOWN_TEMPLATE_TOKEN_PATTERN = /\{[^{}]+\}/u; + +export function renderTicketThreadTitle( + policy: TicketTitlePolicy, + metadata: TicketTitleMetadata, +): string | undefined { + if (policy.mode === "disabled") return undefined; + if (policy.mode === "title") return normalizeTicketThreadTitle(metadata.title) || undefined; + if (policy.mode === "identifier_title") { + return normalizeTicketThreadTitle(`${metadata.identifier} — ${metadata.title}`) || undefined; + } + + const openSentinel = "\u0000T3_OPEN_BRACE\u0000"; + const closeSentinel = "\u0000T3_CLOSE_BRACE\u0000"; + const template = (policy.customTemplate || DEFAULT_TICKET_TITLE_TEMPLATE) + .replace( + /\{\{\{(title|identifier|provider|project)\}\}\}/gu, + `${openSentinel}{$1}${closeSentinel}`, + ) + .replace(/\{\{/gu, openSentinel) + .replace(/\}\}/gu, closeSentinel); + if (UNKNOWN_TEMPLATE_TOKEN_PATTERN.test(template.replace(TEMPLATE_TOKEN_PATTERN, ""))) { + return undefined; + } + + const values = metadata as Record<"title" | "identifier" | "provider" | "project", string>; + const rendered = template + .replace(TEMPLATE_TOKEN_PATTERN, (_whole, token: keyof typeof values) => values[token]) + .replaceAll(openSentinel, "{") + .replaceAll(closeSentinel, "}"); + return normalizeTicketThreadTitle(rendered) || undefined; +} From f40bdc727234b6b1af15f078593eba052907125b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Timb=C3=B3=20Pereira?= Date: Wed, 26 Aug 2026 23:02:15 -0300 Subject: [PATCH 02/19] refactor(server): preserve ticket provider module namespace --- apps/server/src/server.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 8b295c622c5d..c7de7338c29a 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -59,7 +59,7 @@ import { OrchestrationReactorLive } from "./orchestration/Layers/OrchestrationRe import { RuntimeReceiptBusLive } from "./orchestration/Layers/RuntimeReceiptBus.ts"; import { ProviderRuntimeIngestionLive } from "./orchestration/Layers/ProviderRuntimeIngestion.ts"; import { ProviderCommandReactorLive } from "./orchestration/Layers/ProviderCommandReactor.ts"; -import { layer as TicketProviderRegistryBaseLive } from "./ticket/TicketProviderRegistry.ts"; +import * as TicketProviderRegistry from "./ticket/TicketProviderRegistry.ts"; import { CheckpointReactorLive } from "./orchestration/Layers/CheckpointReactor.ts"; import { ThreadDeletionReactorLive } from "./orchestration/Layers/ThreadDeletionReactor.ts"; import * as AgentAwarenessRelay from "./relay/AgentAwarenessRelay.ts"; @@ -245,7 +245,7 @@ const PlatformServicesLive = Layer.unwrap( }), ); -const TicketProviderRegistryLive = TicketProviderRegistryBaseLive.pipe( +const TicketProviderRegistryLive = TicketProviderRegistry.layer.pipe( Layer.provide(VcsProcess.layer), Layer.provide(FetchHttpClient.layer), ); From 3e3ed89b15edba54254ef16bfb1b2e6f5b305f54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Timb=C3=B3=20Pereira?= Date: Wed, 26 Aug 2026 23:56:51 -0300 Subject: [PATCH 03/19] fix: harden linked ticket thread titles --- .../SettingsTicketProvidersRouteScreen.tsx | 161 ++++++++++--- .../Layers/ProjectionPipeline.ts | 8 +- .../Layers/ProjectionSnapshotQuery.test.ts | 1 + .../Layers/ProjectionSnapshotQuery.ts | 7 + .../Layers/ProviderCommandReactor.test.ts | 156 ++++++++++++- .../Layers/ProviderCommandReactor.ts | 89 ++++---- .../decider.titleRegeneration.test.ts | 22 ++ apps/server/src/orchestration/decider.ts | 5 +- .../src/orchestration/projector.test.ts | 1 + apps/server/src/orchestration/projector.ts | 10 +- .../persistence/Layers/ProjectionThreads.ts | 5 + apps/server/src/persistence/Migrations.ts | 2 + .../045_ProjectionThreadTitleRevision.test.ts | 25 ++ .../045_ProjectionThreadTitleRevision.ts | 21 ++ .../persistence/Services/ProjectionThreads.ts | 1 + apps/server/src/serverSettings.test.ts | 143 ++++++++++++ apps/server/src/serverSettings.ts | 160 ++++++++++--- .../src/ticket/TicketProviderRegistry.test.ts | 87 ++++++- .../src/ticket/TicketProviderRegistry.ts | 86 ++++--- .../settings/ProjectSettingsPanel.tsx | 30 ++- .../settings/TicketProviderSettings.tsx | 215 ++++++++++-------- packages/contracts/src/orchestration.ts | 2 + packages/contracts/src/settings.test.ts | 13 ++ packages/contracts/src/settings.ts | 1 + packages/contracts/src/ticketProvider.ts | 18 +- packages/shared/src/ticketTitles.test.ts | 15 ++ packages/shared/src/ticketTitles.ts | 59 ++++- 27 files changed, 1094 insertions(+), 249 deletions(-) create mode 100644 apps/server/src/persistence/Migrations/045_ProjectionThreadTitleRevision.test.ts create mode 100644 apps/server/src/persistence/Migrations/045_ProjectionThreadTitleRevision.ts diff --git a/apps/mobile/src/features/settings/SettingsTicketProvidersRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsTicketProvidersRouteScreen.tsx index 60bc5f07f2b8..57ef8d879e36 100644 --- a/apps/mobile/src/features/settings/SettingsTicketProvidersRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsTicketProvidersRouteScreen.tsx @@ -9,13 +9,14 @@ import { type EnvironmentId, type ServerSettingsPatch, type TicketProviderInstanceConfig, + type TicketProviderInstanceConfigMap, type TicketProviderProbeResult, type TicketProviderBindings, type TicketTitleMode, } from "@t3tools/contracts"; import { Platform, Pressable, ScrollView, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { useContext, useState } from "react"; +import { useContext, useEffect, useRef, useState } from "react"; import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; @@ -108,9 +109,16 @@ function EnvironmentTicketProviders({ const [identity, setIdentity] = useState(""); const [secret, setSecret] = useState(""); const [error, setError] = useState(null); + const [isSaving, setIsSaving] = useState(false); const [probeByInstanceId, setProbeByInstanceId] = useState< Readonly> >({}); + const instancesRef = useRef( + settings?.ticketProviderInstances ?? DEFAULT_SERVER_SETTINGS.ticketProviderInstances, + ); + useEffect(() => { + if (settings) instancesRef.current = settings.ticketProviderInstances; + }, [settings]); if (!settings) { return ( @@ -122,16 +130,24 @@ function EnvironmentTicketProviders({ ); } - const savePatch = (patch: ServerSettingsPatch) => { - void runAtomCommand( + const savePatch = (patch: ServerSettingsPatch) => + runAtomCommand( registry, serverEnvironment.updateSettings, { environmentId, input: { patch } }, { label: "mobile ticket provider settings" }, ); + const updateInstances = ( + update: (current: TicketProviderInstanceConfigMap) => TicketProviderInstanceConfigMap, + ) => { + const next = update(instancesRef.current); + instancesRef.current = next; + return savePatch({ ticketProviderInstances: next }); }; const instances = Object.entries(settings.ticketProviderInstances); const probeProvider = (instanceId: string) => { + const brandedInstanceId = TicketProviderInstanceId.make(instanceId); + const instanceSignature = JSON.stringify(instancesRef.current[brandedInstanceId]); setProbeByInstanceId((current) => ({ ...current, [instanceId]: "testing" })); void runAtomCommand( registry, @@ -142,6 +158,7 @@ function EnvironmentTicketProviders({ }, { label: "mobile ticket provider connection test" }, ).then((result) => { + if (JSON.stringify(instancesRef.current[brandedInstanceId]) !== instanceSignature) return; setProbeByInstanceId((current) => ({ ...current, [instanceId]: @@ -162,7 +179,7 @@ function EnvironmentTicketProviders({ setSecret(""); setError(null); }; - const addProvider = () => { + const addProvider = async () => { let parsed: URL; try { parsed = new URL(baseUrl.trim()); @@ -174,6 +191,10 @@ function EnvironmentTicketProviders({ setError("The base URL must use HTTP or HTTPS."); return; } + if (parsed.username || parsed.password) { + setError("The base URL must not contain credentials."); + return; + } if (driver.driver === "jira" && parsed.hostname === "example.atlassian.net") { setError("Replace the example URL with your Jira site URL."); return; @@ -182,7 +203,7 @@ function EnvironmentTicketProviders({ const baseId = `${driver.driver}_${slugify(`${name}_${parsed.host}`)}`.slice(0, 60); let id = baseId; let suffix = 2; - while (settings.ticketProviderInstances[TicketProviderInstanceId.make(id)]) { + while (instancesRef.current[TicketProviderInstanceId.make(id)]) { id = `${baseId.slice(0, 60 - String(suffix).length)}_${suffix}`; suffix += 1; } @@ -201,6 +222,13 @@ function EnvironmentTicketProviders({ displayName: name, baseUrl: parsed.toString().replace(/\/$/u, ""), enabled: true, + isDefault: !Object.values(instancesRef.current).some( + (candidate) => + candidate.enabled !== false && + candidate.driver === driver.driver && + new URL(candidate.baseUrl).host.toLowerCase() === parsed.host.toLowerCase() && + candidate.isDefault === true, + ), ...(identityConfig ? { config: identityConfig } : {}), ...(driver.secret && secret.trim() ? { @@ -208,13 +236,36 @@ function EnvironmentTicketProviders({ } : {}), }; - savePatch({ - ticketProviderInstances: { - ...settings.ticketProviderInstances, - [TicketProviderInstanceId.make(id)]: instance, - }, + setIsSaving(true); + const addedInstanceId = TicketProviderInstanceId.make(id); + const result = await updateInstances((current) => { + const next = { ...current }; + if (instance.isDefault) { + for (const [candidateId, candidate] of Object.entries(next)) { + if ( + candidate.driver === instance.driver && + new URL(candidate.baseUrl).host.toLowerCase() === parsed.host.toLowerCase() + ) { + next[TicketProviderInstanceId.make(candidateId)] = { + ...candidate, + isDefault: false, + }; + } + } + } + next[addedInstanceId] = instance; + return next; }); - // Credentials are write-only on mobile: discard the draft immediately. + setIsSaving(false); + if (result._tag !== "Success") { + if (instancesRef.current[addedInstanceId] === instance) { + const next = { ...instancesRef.current }; + delete next[addedInstanceId]; + instancesRef.current = next; + } + setError("The provider could not be saved. Check the connection and try again."); + return; + } setSecret(""); setIdentity(""); setDisplayName(""); @@ -236,7 +287,7 @@ function EnvironmentTicketProviders({ : "flex-row items-center gap-4 border-t border-border-subtle p-4" } onPress={() => - savePatch({ + void savePatch({ ticketTitlePolicy: { ...settings.ticketTitlePolicy, mode: option.mode }, }) } @@ -263,7 +314,7 @@ function EnvironmentTicketProviders({ placeholder="{identifier} — {title}" className={inputClassName()} onSubmitEditing={(event) => - savePatch({ + void savePatch({ ticketTitlePolicy: { ...settings.ticketTitlePolicy, customTemplate: event.nativeEvent.text, @@ -296,9 +347,16 @@ function EnvironmentTicketProviders({ accessibilityRole="button" accessibilityLabel={`Remove ${instance.displayName ?? instanceId}`} onPress={() => { - const next = { ...settings.ticketProviderInstances }; - delete next[TicketProviderInstanceId.make(instanceId)]; - savePatch({ ticketProviderInstances: next }); + setProbeByInstanceId((current) => { + const next = { ...current }; + delete next[instanceId]; + return next; + }); + void updateInstances((current) => { + const next = { ...current }; + delete next[TicketProviderInstanceId.make(instanceId)]; + return next; + }); }} className="p-2" > @@ -327,6 +385,30 @@ function EnvironmentTicketProviders({ {probe === "testing" ? "Testing…" : "Test"} + { + const host = new URL(instance.baseUrl).host.toLowerCase(); + void updateInstances((current) => + Object.fromEntries( + Object.entries(current).map(([id, candidate]) => [ + id, + candidate.driver === instance.driver && + new URL(candidate.baseUrl).host.toLowerCase() === host + ? { ...candidate, isDefault: id === instanceId } + : candidate, + ]), + ), + ); + }} + className="rounded-full border border-border px-3 py-2 disabled:opacity-50" + > + + {instance.isDefault ? "Default" : "Make default"} + + ); @@ -412,10 +494,13 @@ function EnvironmentTicketProviders({ {error ? {error} : null} void addProvider()} + className="items-center rounded-[14px] bg-foreground px-4 py-3 disabled:opacity-50" > - Save provider + + {isSaving ? "Saving…" : "Save provider"} + ) : null} @@ -456,6 +541,18 @@ function ProjectTicketTitles({ project }: { readonly project: EnvironmentProject // Invalid persisted URLs are ignored here; the server schema reports them on write. } } + for (const binding of bindings) { + const host = binding.host.toLowerCase(); + const key = `${binding.driver}:${host}`; + if (!providerGroups.has(key)) { + providerGroups.set(key, { driver: binding.driver, host, instances: [] }); + } + } + + const bindingsRef = useRef(bindings); + useEffect(() => { + bindingsRef.current = bindings; + }, [bindings]); const updateProject = (input: { readonly ticketTitlePolicy?: EnvironmentProject["ticketTitlePolicy"]; @@ -469,21 +566,21 @@ function ProjectTicketTitles({ project }: { readonly project: EnvironmentProject ); }; const updateBinding = (driver: string, host: string, instanceId: string | null) => { - const remaining = bindings.filter( + const remaining = bindingsRef.current.filter( (binding) => !(binding.driver === driver && binding.host.toLowerCase() === host), ); - updateProject({ - ticketProviderBindings: instanceId - ? [ - ...remaining, - { - driver: TicketProviderDriverKind.make(driver), - host, - instanceId: TicketProviderInstanceId.make(instanceId), - }, - ] - : remaining, - }); + const next: TicketProviderBindings = instanceId + ? [ + ...remaining, + { + driver: TicketProviderDriverKind.make(driver), + host, + instanceId: TicketProviderInstanceId.make(instanceId), + }, + ] + : remaining; + bindingsRef.current = next; + updateProject({ ticketProviderBindings: next }); }; return ( diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index 1822bc5d6088..ce28d1e40465 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -637,6 +637,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti threadId: event.payload.threadId, projectId: event.payload.projectId, title: event.payload.title, + titleRevision: 0, modelSelection: event.payload.modelSelection, runtimeMode: event.payload.runtimeMode, interactionMode: event.payload.interactionMode, @@ -826,7 +827,12 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti } yield* projectionThreadRepository.upsert({ ...existingRow.value, - ...(event.payload.title !== undefined ? { title: event.payload.title } : {}), + ...(event.payload.title !== undefined + ? { + title: event.payload.title, + titleRevision: (existingRow.value.titleRevision ?? 0) + 1, + } + : {}), ...(event.payload.titleRegeneration !== undefined ? { titleRegenerationRequestId: event.payload.titleRegeneration?.requestId ?? null, diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index ea236eaf7ec7..03d4d0c2cd1c 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -300,6 +300,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { id: ThreadId.make("thread-1"), projectId: asProjectId("project-1"), title: "Thread 1", + titleRevision: 0, modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex", diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index 953edea2cfdb..2fb72c921d14 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -427,6 +427,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { thread_id AS "threadId", project_id AS "projectId", title, + title_revision AS "titleRevision", model_selection_json AS "modelSelection", runtime_mode AS "runtimeMode", interaction_mode AS "interactionMode", @@ -465,6 +466,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { thread_id AS "threadId", project_id AS "projectId", title, + title_revision AS "titleRevision", model_selection_json AS "modelSelection", runtime_mode AS "runtimeMode", interaction_mode AS "interactionMode", @@ -505,6 +507,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { thread_id AS "threadId", project_id AS "projectId", title, + title_revision AS "titleRevision", model_selection_json AS "modelSelection", runtime_mode AS "runtimeMode", interaction_mode AS "interactionMode", @@ -953,6 +956,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { thread_id AS "threadId", project_id AS "projectId", title, + title_revision AS "titleRevision", model_selection_json AS "modelSelection", runtime_mode AS "runtimeMode", interaction_mode AS "interactionMode", @@ -1713,6 +1717,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { id: row.threadId, projectId: row.projectId, title: row.title, + titleRevision: row.titleRevision ?? 0, modelSelection: row.modelSelection, runtimeMode: row.runtimeMode, interactionMode: row.interactionMode, @@ -1926,6 +1931,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { id: row.threadId, projectId: row.projectId, title: row.title, + titleRevision: row.titleRevision ?? 0, modelSelection: row.modelSelection, runtimeMode: row.runtimeMode, interactionMode: row.interactionMode, @@ -2645,6 +2651,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { id: threadRow.value.threadId, projectId: threadRow.value.projectId, title: threadRow.value.title, + titleRevision: threadRow.value.titleRevision ?? 0, modelSelection: threadRow.value.modelSelection, runtimeMode: threadRow.value.runtimeMode, interactionMode: threadRow.value.interactionMode, diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index d5934b3b308a..9294623ae20b 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -752,16 +752,15 @@ describe("ProviderCommandReactor", () => { Effect.gen(function* () { const harness = yield* Effect.promise(() => createHarness()); const generatedTitle = yield* Deferred.make<{ readonly title: string }>(); + const ticket = yield* Deferred.make<{ + readonly title: string; + readonly identifier: string; + readonly provider: string; + readonly project: string; + }>(); const seededTitle = "Please investigate https://github.com/acme/widgets/..."; harness.generateThreadTitle.mockReturnValue(Deferred.await(generatedTitle)); - harness.resolveTicket.mockReturnValue( - Effect.succeed({ - title: "Fix reconnect failures", - identifier: "acme/widgets#12", - provider: "GitHub", - project: "acme/widgets", - }), - ); + harness.resolveTicket.mockReturnValue(Deferred.await(ticket)); yield* harness.engine.dispatch({ type: "thread.meta.update", @@ -785,6 +784,23 @@ describe("ProviderCommandReactor", () => { createdAt: "2026-01-01T00:00:00.000Z", }); + yield* Effect.promise(() => waitFor(() => harness.resolveTicket.mock.calls.length === 1)); + yield* Deferred.succeed(generatedTitle, { title: "Generated title" }); + yield* Effect.promise(() => + waitFor(async () => { + const readModel = await harness.readModel(); + return ( + readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"))?.title === + "Generated title" + ); + }), + ); + yield* Deferred.succeed(ticket, { + title: "Fix reconnect failures", + identifier: "acme/widgets#12", + provider: "GitHub", + project: "acme/widgets", + }); yield* Effect.promise(() => waitFor(async () => { const readModel = await harness.readModel(); @@ -794,12 +810,134 @@ describe("ProviderCommandReactor", () => { ); }), ); + + const readModel = yield* Effect.promise(() => harness.readModel()); + expect(readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"))?.title).toBe( + "acme/widgets#12 — Fix reconnect failures", + ); + }), + ); + + effectIt.effect("preserves a manual rename even when it repeats the generated title", () => + Effect.gen(function* () { + const harness = yield* Effect.promise(() => createHarness()); + const ticket = yield* Deferred.make<{ + readonly title: string; + readonly identifier: string; + readonly provider: string; + readonly project: string; + }>(); + const seededTitle = "Please investigate https://github.com/acme/widgets/..."; + harness.generateThreadTitle.mockReturnValue(Effect.succeed({ title: "Generated title" })); + harness.resolveTicket.mockReturnValue(Deferred.await(ticket)); + + yield* harness.engine.dispatch({ + type: "thread.meta.update", + commandId: CommandId.make("cmd-thread-ticket-same-title-seed"), + threadId: ThreadId.make("thread-1"), + title: seededTitle, + }); + yield* harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start-ticket-same-title"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("user-message-ticket-same-title"), + role: "user", + text: "Please investigate https://github.com/acme/widgets/issues/12", + attachments: [], + }, + titleSeed: seededTitle, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: "2026-01-01T00:00:00.000Z", + }); + + yield* Effect.promise(() => + waitFor(async () => { + const readModel = await harness.readModel(); + return ( + readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"))?.title === + "Generated title" + ); + }), + ); + yield* harness.engine.dispatch({ + type: "thread.meta.update", + commandId: CommandId.make("cmd-thread-ticket-same-title-manual"), + threadId: ThreadId.make("thread-1"), + title: "Generated title", + }); + yield* Deferred.succeed(ticket, { + title: "Fix reconnect failures", + identifier: "acme/widgets#12", + provider: "GitHub", + project: "acme/widgets", + }); + yield* Effect.promise(() => harness.drain()); + + const readModel = yield* Effect.promise(() => harness.readModel()); + expect(readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"))?.title).toBe( + "Generated title", + ); + }), + ); + + effectIt.effect("preserves a manual rename while generated and ticket titles are pending", () => + Effect.gen(function* () { + const harness = yield* Effect.promise(() => createHarness()); + const generatedTitle = yield* Deferred.make<{ readonly title: string }>(); + const ticket = yield* Deferred.make<{ + readonly title: string; + readonly identifier: string; + readonly provider: string; + readonly project: string; + }>(); + const seededTitle = "Please investigate https://github.com/acme/widgets/..."; + harness.generateThreadTitle.mockReturnValue(Deferred.await(generatedTitle)); + harness.resolveTicket.mockReturnValue(Deferred.await(ticket)); + + yield* harness.engine.dispatch({ + type: "thread.meta.update", + commandId: CommandId.make("cmd-thread-ticket-pending-seed"), + threadId: ThreadId.make("thread-1"), + title: seededTitle, + }); + yield* harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start-ticket-pending"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("user-message-ticket-pending"), + role: "user", + text: "Please investigate https://github.com/acme/widgets/issues/12", + attachments: [], + }, + titleSeed: seededTitle, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: "2026-01-01T00:00:00.000Z", + }); + + yield* Effect.promise(() => waitFor(() => harness.resolveTicket.mock.calls.length === 1)); + yield* harness.engine.dispatch({ + type: "thread.meta.update", + commandId: CommandId.make("cmd-thread-ticket-pending-manual"), + threadId: ThreadId.make("thread-1"), + title: seededTitle, + }); yield* Deferred.succeed(generatedTitle, { title: "Generated title" }); + yield* Deferred.succeed(ticket, { + title: "Fix reconnect failures", + identifier: "acme/widgets#12", + provider: "GitHub", + project: "acme/widgets", + }); yield* Effect.promise(() => harness.drain()); const readModel = yield* Effect.promise(() => harness.readModel()); expect(readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"))?.title).toBe( - "acme/widgets#12 — Fix reconnect failures", + seededTitle, ); }), ); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 861a34f990c5..802bfd74a50d 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -905,7 +905,9 @@ const make = Effect.gen(function* () { readonly messageText: string; readonly attachments?: ReadonlyArray; readonly titleSeed?: string; - readonly generatedTitle: Deferred.Deferred; + readonly generatedTitle: Deferred.Deferred; + readonly expectedTitle: string; + readonly expectedTitleRevision: number; }) { const attachments = input.attachments ?? []; return yield* Effect.gen(function* () { @@ -918,22 +920,21 @@ const make = Effect.gen(function* () { ...(attachments.length > 0 ? { attachments } : {}), modelSelection, }); - yield* Deferred.succeed(input.generatedTitle, generated?.title); - if (!generated) return undefined; - - const thread = yield* resolveThread(input.threadId); - if (!thread) return undefined; - if (!canReplaceThreadTitle(thread.title, input.titleSeed)) { + if (!generated) { + yield* Deferred.succeed(input.generatedTitle, undefined); return undefined; } + const commandId = yield* serverCommandId("thread-title-rename"); yield* orchestrationEngine.dispatch({ type: "thread.meta.update", - commandId: yield* serverCommandId("thread-title-rename"), + commandId, threadId: input.threadId, title: generated.title, - expectedTitle: thread.title, + expectedTitle: input.expectedTitle, + expectedTitleRevision: input.expectedTitleRevision, }); + yield* Deferred.succeed(input.generatedTitle, commandId); return generated.title; }).pipe( Effect.catchCause((cause) => @@ -960,25 +961,14 @@ const make = Effect.gen(function* () { globalPolicy: TicketTitlePolicy, ): TicketTitlePolicy => projectPolicy ?? globalPolicy; - const generatedTitleFromDeferred = ( - deferred: Deferred.Deferred, - ): Effect.Effect => - Deferred.poll(deferred).pipe( - Effect.flatMap( - Option.match({ - onNone: () => Effect.succeed(undefined), - onSome: (completed) => completed, - }), - ), - ); - const maybeResolveTicketTitleForFirstTurn = Effect.fn("maybeResolveTicketTitleForFirstTurn")( function* (input: { readonly threadId: ThreadId; readonly cwd: string; readonly messageText: string; readonly titleSeed?: string; - readonly generatedTitle: Deferred.Deferred; + readonly generatedTitle: Deferred.Deferred; + readonly initialSequence: number; }) { const initialThread = yield* resolveThread(input.threadId); if (!initialThread) return; @@ -1028,30 +1018,37 @@ const make = Effect.gen(function* () { return; } - const tryApply = Effect.fn("tryApplyTicketTitle")(function* () { - const thread = yield* resolveThread(input.threadId); - if (!thread || thread.title === ticketTitle) return; - const generatedTitle = yield* generatedTitleFromDeferred(input.generatedTitle); - const replaceableTitles = new Set( - [initialThread.title, input.titleSeed, generatedTitle].filter( - (title): title is string => title !== undefined, + const generatedTitleCommandId = yield* Deferred.await(input.generatedTitle); + const thread = yield* resolveThread(input.threadId); + if (!thread || thread.title === ticketTitle) return; + const auditedThrough = yield* orchestrationEngine.latestSequence; + const titleEvents = yield* orchestrationEngine + .readEvents(input.initialSequence, auditedThrough - input.initialSequence) + .pipe( + Stream.filter( + (event) => + event.type === "thread.meta-updated" && + event.payload.threadId === input.threadId && + event.payload.title !== undefined, ), + Stream.runCollect, ); - if (!replaceableTitles.has(thread.title)) return; - yield* orchestrationEngine.dispatch({ - type: "thread.meta.update", - commandId: yield* serverCommandId("thread-ticket-title"), - threadId: input.threadId, - title: ticketTitle, - expectedTitle: thread.title, - }); - }); - - yield* tryApply(); - const afterFirstApply = yield* resolveThread(input.threadId); - if (afterFirstApply && afterFirstApply.title !== ticketTitle) { - yield* tryApply(); + if ( + Array.from(titleEvents).some( + (event) => + generatedTitleCommandId === undefined || event.commandId !== generatedTitleCommandId, + ) + ) { + return; } + yield* orchestrationEngine.dispatch({ + type: "thread.meta.update", + commandId: yield* serverCommandId("thread-ticket-title"), + threadId: input.threadId, + title: ticketTitle, + expectedTitle: thread.title, + expectedTitleRevision: thread.titleRevision ?? 0, + }); }, (effect, input) => effect.pipe( @@ -1285,11 +1282,14 @@ const make = Effect.gen(function* () { }).pipe(Effect.forkScoped); if (canReplaceThreadTitle(thread.title, event.payload.titleSeed)) { - const generatedTitle = yield* Deferred.make(); + const initialSequence = yield* orchestrationEngine.latestSequence; + const generatedTitle = yield* Deferred.make(); yield* maybeGenerateThreadTitleForFirstTurn({ threadId: event.payload.threadId, cwd: generationCwd, generatedTitle, + expectedTitle: thread.title, + expectedTitleRevision: thread.titleRevision ?? 0, ...generationInput, }).pipe(Effect.forkScoped); yield* maybeResolveTicketTitleForFirstTurn({ @@ -1298,6 +1298,7 @@ const make = Effect.gen(function* () { messageText: message.text, ...(event.payload.titleSeed !== undefined ? { titleSeed: event.payload.titleSeed } : {}), generatedTitle, + initialSequence, }).pipe(Effect.forkScoped); } } diff --git a/apps/server/src/orchestration/decider.titleRegeneration.test.ts b/apps/server/src/orchestration/decider.titleRegeneration.test.ts index bacc3b69df75..e614c02eb660 100644 --- a/apps/server/src/orchestration/decider.titleRegeneration.test.ts +++ b/apps/server/src/orchestration/decider.titleRegeneration.test.ts @@ -90,4 +90,26 @@ it.layer(NodeServices.layer)("title regeneration decider", (it) => { } }), ); + + it.effect("rejects a same-value title update when the observed revision is stale", () => + Effect.gen(function* () { + const result = yield* decideOrchestrationCommand({ + command: { + type: "thread.meta.update", + commandId: CommandId.make("cmd-stale-title-revision"), + threadId: ThreadId.make("thread-1"), + title: "Ticket title", + expectedTitle: "Manual title", + expectedTitleRevision: 1, + }, + readModel, + }); + const event = Array.isArray(result) ? result[0] : result; + + expect(event.type).toBe("thread.meta-updated"); + if (event.type === "thread.meta-updated") { + expect(event.payload.title).toBeUndefined(); + } + }), + ); }); diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index e8194e1b801f..87170d670213 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -825,8 +825,9 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" : command.branch; const title = command.title !== undefined && - command.expectedTitle !== undefined && - thread.title !== command.expectedTitle + ((command.expectedTitle !== undefined && thread.title !== command.expectedTitle) || + (command.expectedTitleRevision !== undefined && + (thread.titleRevision ?? 0) !== command.expectedTitleRevision)) ? undefined : command.title; const occurredAt = yield* nowIso; diff --git a/apps/server/src/orchestration/projector.test.ts b/apps/server/src/orchestration/projector.test.ts index dad3d07370f9..34aac31da8a5 100644 --- a/apps/server/src/orchestration/projector.test.ts +++ b/apps/server/src/orchestration/projector.test.ts @@ -77,6 +77,7 @@ describe("orchestration projector", () => { id: "thread-1", projectId: "project-1", title: "demo", + titleRevision: 0, modelSelection: { instanceId: "codex", model: "gpt-5-codex", diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index 49e0322a5577..9bc5130a54c3 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -300,6 +300,7 @@ export function projectEvent( id: payload.threadId, projectId: payload.projectId, title: payload.title, + titleRevision: 0, modelSelection: payload.modelSelection, runtimeMode: payload.runtimeMode, interactionMode: payload.interactionMode, @@ -467,7 +468,14 @@ export function projectEvent( Effect.map((payload) => ({ ...nextBase, threads: updateThread(nextBase.threads, payload.threadId, { - ...(payload.title !== undefined ? { title: payload.title } : {}), + ...(payload.title !== undefined + ? { + title: payload.title, + titleRevision: + (nextBase.threads.find((thread) => thread.id === payload.threadId) + ?.titleRevision ?? 0) + 1, + } + : {}), ...(payload.titleRegeneration !== undefined ? { titleRegeneration: payload.titleRegeneration } : {}), diff --git a/apps/server/src/persistence/Layers/ProjectionThreads.ts b/apps/server/src/persistence/Layers/ProjectionThreads.ts index d5653a2c8b42..d6796c6969b3 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreads.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreads.ts @@ -35,6 +35,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { thread_id, project_id, title, + title_revision, model_selection_json, runtime_mode, interaction_mode, @@ -64,6 +65,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { ${row.threadId}, ${row.projectId}, ${row.title}, + ${row.titleRevision ?? 0}, ${JSON.stringify(row.modelSelection)}, ${row.runtimeMode}, ${row.interactionMode}, @@ -93,6 +95,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { DO UPDATE SET project_id = excluded.project_id, title = excluded.title, + title_revision = excluded.title_revision, model_selection_json = excluded.model_selection_json, runtime_mode = excluded.runtime_mode, interaction_mode = excluded.interaction_mode, @@ -129,6 +132,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { thread_id AS "threadId", project_id AS "projectId", title, + title_revision AS "titleRevision", model_selection_json AS "modelSelection", runtime_mode AS "runtimeMode", interaction_mode AS "interactionMode", @@ -167,6 +171,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { thread_id AS "threadId", project_id AS "projectId", title, + title_revision AS "titleRevision", model_selection_json AS "modelSelection", runtime_mode AS "runtimeMode", interaction_mode AS "interactionMode", diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index a159754cc210..5d9731152403 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -57,6 +57,7 @@ import Migration0041 from "./Migrations/041_AuthSessionClientConnection.ts"; import Migration0042 from "./Migrations/042_ProjectionThreadLinkedPullRequest.ts"; import Migration0043 from "./Migrations/043_ProjectionThreadsUnsettledAt.ts"; import Migration0044 from "./Migrations/044_ProjectionProjectTicketTitles.ts"; +import Migration0045 from "./Migrations/045_ProjectionThreadTitleRevision.ts"; /** * Migration loader with all migrations defined inline. @@ -113,6 +114,7 @@ export const migrationEntries = [ [42, "ProjectionThreadLinkedPullRequest", Migration0042], [43, "ProjectionThreadsUnsettledAt", Migration0043], [44, "ProjectionProjectTicketTitles", Migration0044], + [45, "ProjectionThreadTitleRevision", Migration0045], ] as const; export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); diff --git a/apps/server/src/persistence/Migrations/045_ProjectionThreadTitleRevision.test.ts b/apps/server/src/persistence/Migrations/045_ProjectionThreadTitleRevision.test.ts new file mode 100644 index 000000000000..f4291d5609ad --- /dev/null +++ b/apps/server/src/persistence/Migrations/045_ProjectionThreadTitleRevision.test.ts @@ -0,0 +1,25 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { runMigrations } from "../Migrations.ts"; +import * as NodeSqliteClient from "../NodeSqliteClient.ts"; + +const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory())); + +layer("045_ProjectionThreadTitleRevision", (it) => { + it.effect("adds the title revision column", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* runMigrations({ toMigrationInclusive: 44 }); + yield* runMigrations({ toMigrationInclusive: 45 }); + + const columns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(projection_threads) + `; + assert.ok(columns.some((column) => column.name === "title_revision")); + }), + ); +}); diff --git a/apps/server/src/persistence/Migrations/045_ProjectionThreadTitleRevision.ts b/apps/server/src/persistence/Migrations/045_ProjectionThreadTitleRevision.ts new file mode 100644 index 000000000000..91f1c023fe08 --- /dev/null +++ b/apps/server/src/persistence/Migrations/045_ProjectionThreadTitleRevision.ts @@ -0,0 +1,21 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const columns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(projection_threads) + `; + + if (!columns.some((column) => column.name === "title_revision")) { + yield* sql` + ALTER TABLE projection_threads + ADD COLUMN title_revision INTEGER + `; + } + yield* sql` + UPDATE projection_threads + SET title_revision = COALESCE(title_revision, 0) + WHERE title_revision IS NULL + `; +}); diff --git a/apps/server/src/persistence/Services/ProjectionThreads.ts b/apps/server/src/persistence/Services/ProjectionThreads.ts index a70548bc110c..76d3495cbe35 100644 --- a/apps/server/src/persistence/Services/ProjectionThreads.ts +++ b/apps/server/src/persistence/Services/ProjectionThreads.ts @@ -29,6 +29,7 @@ export const ProjectionThread = Schema.Struct({ threadId: ThreadId, projectId: ProjectId, title: Schema.String, + titleRevision: Schema.optional(Schema.NullOr(NonNegativeInt)), modelSelection: ModelSelection, runtimeMode: RuntimeMode, interactionMode: ProviderInteractionMode, diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index ad1e3a1a4477..9656c7bfe037 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -1100,4 +1100,147 @@ it.layer(NodeServices.layer)("server settings", (it) => { ); }).pipe(Effect.provide(makeServerSettingsLayer())), ); + + it.effect("rolls back ordinary provider secrets when a ticket secret write fails", () => { + const secrets = new Map(); + const failingStore = ServerSecretStore.ServerSecretStore.of({ + get: (name) => Effect.succeed(Option.fromNullishOr(secrets.get(name))), + set: (name, value) => + name.startsWith("ticket-provider-env-") + ? Effect.fail( + new ServerSecretStore.SecretStorePersistError({ + resource: `secret ${name}`, + cause: "simulated ticket secret failure", + }), + ) + : Effect.sync(() => { + secrets.set(name, Uint8Array.from(value)); + }), + create: (name, value) => + Effect.sync(() => { + secrets.set(name, Uint8Array.from(value)); + }), + getOrCreateRandom: () => Effect.succeed(new Uint8Array()), + remove: (name) => + Effect.sync(() => { + secrets.delete(name); + }), + }); + const settingsLayer = ServerSettingsModule.layer.pipe( + Layer.provide(Layer.succeed(ServerSecretStore.ServerSecretStore, failingStore)), + Layer.provideMerge(Layer.fresh(SqlitePersistenceMemory)), + Layer.provideMerge( + Layer.fresh( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3code-server-settings-secret-rollback-test-", + }), + ), + ), + ); + + return Effect.gen(function* () { + const serverSettings = yield* ServerSettingsModule.ServerSettingsService; + const failure = yield* Effect.flip( + serverSettings.updateSettings({ + providerInstances: { + [ProviderInstanceId.make("codex_personal")]: { + driver: ProviderDriverKind.make("codex"), + environment: [ + { name: "OPENROUTER_API_KEY", value: "ordinary-secret", sensitive: true }, + ], + config: {}, + }, + }, + ticketProviderInstances: { + [TicketProviderInstanceId.make("jira_work")]: { + driver: TicketProviderDriverKind.make("jira"), + baseUrl: "https://work.atlassian.net", + environment: [{ name: "JIRA_API_TOKEN", value: "ticket-secret", sensitive: true }], + }, + }, + }), + ); + + assert.equal(failure.operation, "write-secret"); + assert.equal(secrets.size, 0); + }).pipe(Effect.provide(settingsLayer)); + }); + + it.effect("keeps ordinary credentials materialized when a ticket secret read fails", () => { + const secrets = new Map(); + let failTicketReads = false; + const store = ServerSecretStore.ServerSecretStore.of({ + get: (name) => + failTicketReads && name.startsWith("ticket-provider-env-") + ? Effect.fail( + new ServerSecretStore.SecretStoreReadError({ + resource: `secret ${name}`, + cause: "simulated ticket secret read failure", + }), + ) + : Effect.succeed(Option.fromNullishOr(secrets.get(name))), + set: (name, value) => + Effect.sync(() => { + secrets.set(name, Uint8Array.from(value)); + }), + create: (name, value) => + Effect.sync(() => { + secrets.set(name, Uint8Array.from(value)); + }), + getOrCreateRandom: () => Effect.succeed(new Uint8Array()), + remove: (name) => + Effect.sync(() => { + secrets.delete(name); + }), + }); + const settingsLayer = ServerSettingsModule.layer.pipe( + Layer.provide(Layer.succeed(ServerSecretStore.ServerSecretStore, store)), + Layer.provideMerge(Layer.fresh(SqlitePersistenceMemory)), + Layer.provideMerge( + Layer.fresh( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3code-server-settings-secret-materialize-test-", + }), + ), + ), + ); + + return Effect.scoped( + Effect.gen(function* () { + const serverSettings = yield* ServerSettingsModule.ServerSettingsService; + const changes = yield* serverSettings.subscribeChanges; + yield* serverSettings.updateSettings({ + providerInstances: { + [ProviderInstanceId.make("codex_personal")]: { + driver: ProviderDriverKind.make("codex"), + environment: [ + { name: "OPENROUTER_API_KEY", value: "ordinary-secret", sensitive: true }, + ], + config: {}, + }, + }, + ticketProviderInstances: { + [TicketProviderInstanceId.make("jira_work")]: { + driver: TicketProviderDriverKind.make("jira"), + baseUrl: "https://work.atlassian.net", + environment: [{ name: "JIRA_API_TOKEN", value: "ticket-secret", sensitive: true }], + }, + }, + }); + failTicketReads = true; + const changed = Option.getOrThrow(yield* changes.pipe(Stream.runHead)); + + assert.equal( + changed.providerInstances[ProviderInstanceId.make("codex_personal")]?.environment?.[0] + ?.value, + "ordinary-secret", + ); + assert.equal( + changed.ticketProviderInstances[TicketProviderInstanceId.make("jira_work")] + ?.environment?.[0]?.value, + "", + ); + }), + ).pipe(Effect.provide(settingsLayer)); + }); }); diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index 65c9f89cb7ac..9f643b2b38b7 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -561,16 +561,46 @@ const make = Effect.gen(function* () { const materializeChanges = (changes: Stream.Stream) => changes.pipe( Stream.mapEffect((settings) => - materializeProviderEnvironmentSecrets(settings).pipe( - Effect.catch((error: ServerSettingsError) => - Effect.logWarning("failed to materialize provider environment secrets", { - operation: error.operation, - providerInstanceId: error.providerInstanceId, - environmentVariable: error.environmentVariable, - cause: error.cause, - }).pipe(Effect.as(settings)), - ), - ), + Effect.gen(function* () { + const providerInstances = + yield* materializeInstanceEnvironmentSecrets( + settings.providerInstances as unknown as Readonly< + Record + >, + providerEnvironmentSecretName, + ).pipe( + Effect.catch((error: ServerSettingsError) => + Effect.logWarning("failed to materialize provider environment secrets", { + operation: error.operation, + providerInstanceId: error.providerInstanceId, + environmentVariable: error.environmentVariable, + cause: error.cause, + }).pipe(Effect.as(settings.providerInstances)), + ), + ); + const ticketProviderInstances = + yield* materializeInstanceEnvironmentSecrets( + settings.ticketProviderInstances as unknown as Readonly< + Record + >, + ticketProviderEnvironmentSecretName, + ).pipe( + Effect.catch((error: ServerSettingsError) => + Effect.logWarning("failed to materialize ticket provider environment secrets", { + operation: error.operation, + providerInstanceId: error.providerInstanceId, + environmentVariable: error.environmentVariable, + cause: error.cause, + }).pipe(Effect.as(settings.ticketProviderInstances)), + ), + ); + return { + ...settings, + providerInstances: providerInstances as ServerSettings["providerInstances"], + ticketProviderInstances: + ticketProviderInstances as ServerSettings["ticketProviderInstances"], + }; + }), ), Stream.map(resolveTextGenerationProvider), ); @@ -674,27 +704,105 @@ const make = Effect.gen(function* () { next: ServerSettings, ): Effect.Effect => Effect.gen(function* () { - const providerInstances = yield* persistInstanceEnvironmentSecrets( + const secretEntries = new Map< + string, + { readonly instanceId: string; readonly environmentVariable: string } + >(); + const collectSecretEntries = < + Instance extends { + readonly environment?: ReadonlyArray; + }, + >( + instances: Readonly>, + secretNameFor: (input: { readonly instanceId: string; readonly name: string }) => string, + ) => { + for (const [instanceId, instance] of Object.entries(instances)) { + for (const variable of instance.environment ?? []) { + secretEntries.set(secretNameFor({ instanceId, name: variable.name }), { + instanceId, + environmentVariable: variable.name, + }); + } + } + }; + collectSecretEntries( current.providerInstances as unknown as Readonly>, + providerEnvironmentSecretName, + ); + collectSecretEntries( next.providerInstances as unknown as Readonly>, providerEnvironmentSecretName, ); - const ticketProviderInstances = - yield* persistInstanceEnvironmentSecrets( - current.ticketProviderInstances as unknown as Readonly< - Record - >, - next.ticketProviderInstances as unknown as Readonly< - Record - >, - ticketProviderEnvironmentSecretName, + collectSecretEntries( + current.ticketProviderInstances as unknown as Readonly< + Record + >, + ticketProviderEnvironmentSecretName, + ); + collectSecretEntries( + next.ticketProviderInstances as unknown as Readonly< + Record + >, + ticketProviderEnvironmentSecretName, + ); + + const snapshots = new Map>(); + for (const [secretName, context] of secretEntries) { + const value = yield* secretStore.get(secretName).pipe( + Effect.mapError( + (cause) => + new ServerSettingsError({ + settingsPath, + operation: "read-secret-snapshot", + providerInstanceId: context.instanceId, + environmentVariable: context.environmentVariable, + cause, + }), + ), ); - return { - ...next, - providerInstances: providerInstances as unknown as ServerSettings["providerInstances"], - ticketProviderInstances: - ticketProviderInstances as unknown as ServerSettings["ticketProviderInstances"], - }; + snapshots.set(secretName, value); + } + + const persistAll = Effect.gen(function* () { + const providerInstances = yield* persistInstanceEnvironmentSecrets( + current.providerInstances as unknown as Readonly>, + next.providerInstances as unknown as Readonly>, + providerEnvironmentSecretName, + ); + const ticketProviderInstances = + yield* persistInstanceEnvironmentSecrets( + current.ticketProviderInstances as unknown as Readonly< + Record + >, + next.ticketProviderInstances as unknown as Readonly< + Record + >, + ticketProviderEnvironmentSecretName, + ); + return { + ...next, + providerInstances: providerInstances as unknown as ServerSettings["providerInstances"], + ticketProviderInstances: + ticketProviderInstances as unknown as ServerSettings["ticketProviderInstances"], + }; + }); + + return yield* persistAll.pipe( + Effect.tapError(() => + Effect.gen(function* () { + for (const [secretName, snapshot] of snapshots) { + yield* Option.match(snapshot, { + onNone: () => secretStore.remove(secretName), + onSome: (value) => secretStore.set(secretName, value), + }).pipe(Effect.ignore); + } + }).pipe( + Effect.andThen( + Effect.logWarning("rolled back provider secret mutations after settings failure"), + ), + ), + ), + ); }); const writeSettingsAtomically = Effect.fnUntraced( diff --git a/apps/server/src/ticket/TicketProviderRegistry.test.ts b/apps/server/src/ticket/TicketProviderRegistry.test.ts index e32006610a1b..4c1118e5cb6f 100644 --- a/apps/server/src/ticket/TicketProviderRegistry.test.ts +++ b/apps/server/src/ticket/TicketProviderRegistry.test.ts @@ -7,7 +7,7 @@ import { extractUniqueTicketReference } from "@t3tools/shared/ticketTitles"; import * as Effect from "effect/Effect"; import * as Deferred from "effect/Deferred"; import * as Fiber from "effect/Fiber"; -import { HttpClient } from "effect/unstable/http"; +import { HttpClient, HttpClientResponse } from "effect/unstable/http"; import { it as effectIt } from "@effect/vitest"; import { describe, expect, vi } from "vite-plus/test"; @@ -24,12 +24,15 @@ const commandOutput = (stdout: string): VcsProcess.VcsProcessOutput => ({ const githubReference = extractUniqueTicketReference("https://github.com/acme/widgets/issues/12")!; -function makeRegistry(run: VcsProcess.VcsProcess["Service"]["run"]) { +function makeRegistry( + run: VcsProcess.VcsProcess["Service"]["run"], + httpClient: HttpClient.HttpClient = { + execute: () => Effect.die("HTTP should not run"), + } as never, +) { return make.pipe( Effect.provideService(VcsProcess.VcsProcess, { run }), - Effect.provideService(HttpClient.HttpClient, { - execute: () => Effect.die("HTTP should not run"), - } as never), + Effect.provideService(HttpClient.HttpClient, httpClient), ); } @@ -52,6 +55,7 @@ describe("TicketProviderRegistry", () => { driver: TicketProviderDriverKind.make("github"), baseUrl: "https://github.com", config: { accountLogin: "work" }, + environment: [{ name: "GH_CONFIG_DIR", value: "/tmp/gh-work", sensitive: false }], }, }; @@ -77,6 +81,7 @@ describe("TicketProviderRegistry", () => { expect(run.mock.calls[0]?.[0]).toMatchObject({ command: "gh", args: ["auth", "token", "--hostname", "github.com", "--user", "work"], + env: expect.objectContaining({ GH_CONFIG_DIR: "/tmp/gh-work" }), }); expect(run.mock.calls[1]?.[0]).toMatchObject({ command: "gh", @@ -230,6 +235,78 @@ describe("TicketProviderRegistry", () => { }), ); + effectIt.effect("probes Azure DevOps with an authenticated organization request", () => + Effect.gen(function* () { + const run = vi.fn(() => + Effect.succeed(commandOutput("")), + ); + const registry = yield* makeRegistry(run); + const instanceId = TicketProviderInstanceId.make("azure_work"); + const result = yield* registry.probe({ + cwd: "/tmp/project", + instanceId, + instance: { + driver: TicketProviderDriverKind.make("azure-devops"), + baseUrl: "https://dev.azure.com/acme", + environment: [ + { name: "AZURE_DEVOPS_EXT_PAT", value: "expired-or-valid", sensitive: true }, + ], + }, + }); + + expect(result.availability).toBe("available"); + expect(run).toHaveBeenCalledWith( + expect.objectContaining({ + command: "az", + args: expect.arrayContaining([ + "devops", + "project", + "list", + "--organization", + "https://dev.azure.com/acme", + ]), + env: expect.objectContaining({ AZURE_DEVOPS_EXT_PAT: "expired-or-valid" }), + }), + ); + }), + ); + + effectIt.effect("rejects oversized HTTP ticket responses before JSON decoding", () => + Effect.gen(function* () { + const run = vi.fn(() => + Effect.die("CLI should not run"), + ); + const httpClient = HttpClient.make((request) => + Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response(JSON.stringify({ fields: { summary: "x".repeat(140 * 1024) } }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ), + ), + ); + const registry = yield* makeRegistry(run, httpClient); + const reference = extractUniqueTicketReference("https://acme.atlassian.net/browse/WEB-12")!; + const failure = yield* Effect.flip( + registry.resolve({ + cwd: "/tmp/project", + reference, + instances: { + [TicketProviderInstanceId.make("jira_work")]: { + driver: TicketProviderDriverKind.make("jira"), + baseUrl: "https://acme.atlassian.net", + }, + }, + bindings: [], + }), + ); + + expect(failure.reason).toBe("request-failed"); + }), + ); + effectIt.effect("deduplicates only simultaneous lookups for the same ticket and account", () => Effect.gen(function* () { const started = yield* Deferred.make(); diff --git a/apps/server/src/ticket/TicketProviderRegistry.ts b/apps/server/src/ticket/TicketProviderRegistry.ts index f245096b1bbb..b4c4bc40ae91 100644 --- a/apps/server/src/ticket/TicketProviderRegistry.ts +++ b/apps/server/src/ticket/TicketProviderRegistry.ts @@ -20,6 +20,7 @@ import { } from "effect/unstable/http"; import * as VcsProcess from "../vcs/VcsProcess.ts"; +import { collectUint8StreamText } from "../stream/collectUint8StreamText.ts"; const LOOKUP_TIMEOUT_MS = 10_000; const MAX_RESPONSE_BYTES = 128 * 1024; @@ -50,6 +51,11 @@ export class TicketProviderResolveError extends Schema.TaggedErrorClass()( + "TicketProviderResponseTooLargeError", + {}, +) {} + export interface TicketProviderResolveInput { readonly cwd: string; readonly reference: TicketReference; @@ -200,6 +206,13 @@ const decodeGitHubIssueJson = Schema.decodeUnknownEffect( const decodeGitLabIssueJson = Schema.decodeUnknownEffect( Schema.fromJsonString(GitLabIssueResponse), ); +const decodeBitbucketIssueJson = Schema.decodeUnknownEffect( + Schema.fromJsonString(BitbucketIssueResponse), +); +const decodeJiraIssueJson = Schema.decodeUnknownEffect(Schema.fromJsonString(JiraIssueResponse)); +const decodeClickUpTaskJson = Schema.decodeUnknownEffect( + Schema.fromJsonString(ClickUpTaskResponse), +); const isTicketProviderResolveError = Schema.is(TicketProviderResolveError); export const make = Effect.gen(function* () { @@ -224,16 +237,23 @@ export const make = Effect.gen(function* () { return output.stdout; }); - const execute = Effect.fn("TicketProviderRegistry.execute")(function* ( + const executeText = Effect.fn("TicketProviderRegistry.executeText")(function* ( request: HttpClientRequest.HttpClientRequest, ) { - const response = yield* httpClient - .execute(request.pipe(HttpClientRequest.acceptJson)) - .pipe( - Effect.provideService(FetchHttpClient.RequestInit, { redirect: "manual" }), - Effect.timeout(Duration.millis(LOOKUP_TIMEOUT_MS)), - ); - return yield* HttpClientResponse.filterStatusOk(response); + return yield* Effect.gen(function* () { + const response = yield* httpClient + .execute(request.pipe(HttpClientRequest.acceptJson)) + .pipe(Effect.provideService(FetchHttpClient.RequestInit, { redirect: "manual" })); + const success = yield* HttpClientResponse.filterStatusOk(response); + const collected = yield* collectUint8StreamText({ + stream: success.stream, + maxBytes: MAX_RESPONSE_BYTES, + }); + if (collected.truncated) { + return yield* new TicketProviderResponseTooLargeError(); + } + return collected.text; + }).pipe(Effect.timeout(Duration.millis(LOOKUP_TIMEOUT_MS))); }); const resolveGitHub = Effect.fn("TicketProviderRegistry.resolveGitHub")(function* (input: { @@ -255,6 +275,7 @@ export const make = Effect.gen(function* () { "--user", config.accountLogin.trim(), ], + env, cwd: input.cwd, timeoutMs: LOOKUP_TIMEOUT_MS, maxOutputBytes: 16 * 1024, @@ -361,8 +382,7 @@ export const make = Effect.gen(function* () { if (accessToken) request = request.pipe(HttpClientRequest.bearerToken(accessToken)); else if (email && apiToken) request = request.pipe(HttpClientRequest.basicAuth(email, apiToken)); - const response = yield* execute(request); - const issue = yield* HttpClientResponse.schemaBodyJson(BitbucketIssueResponse)(response); + const issue = yield* decodeBitbucketIssueJson(yield* executeText(request)); return { title: issue.title }; }); @@ -384,8 +404,7 @@ export const make = Effect.gen(function* () { } else if (pat ?? token) { request = request.pipe(HttpClientRequest.bearerToken((pat ?? token)!)); } - const response = yield* execute(request); - const issue = yield* HttpClientResponse.schemaBodyJson(JiraIssueResponse)(response); + const issue = yield* decodeJiraIssueJson(yield* executeText(request)); return { title: issue.fields.summary }; }); @@ -405,12 +424,13 @@ export const make = Effect.gen(function* () { const query = custom ? `?custom_task_ids=true&team_id=${encodeURIComponent(config.workspaceId!.trim())}` : ""; - const response = yield* execute( - HttpClientRequest.get( - `https://api.clickup.com/api/v2/task/${encodeURIComponent(input.reference.resourceId)}${query}`, - ).pipe(HttpClientRequest.setHeader("Authorization", token)), + const task = yield* decodeClickUpTaskJson( + yield* executeText( + HttpClientRequest.get( + `https://api.clickup.com/api/v2/task/${encodeURIComponent(input.reference.resourceId)}${query}`, + ).pipe(HttpClientRequest.setHeader("Authorization", token)), + ), ); - const task = yield* HttpClientResponse.schemaBodyJson(ClickUpTaskResponse)(response); return { title: task.name, ...(task.custom_id ? { identifier: task.custom_id } : {}), @@ -554,14 +574,24 @@ export const make = Effect.gen(function* () { }); break; case "azure-devops": - if (!environmentValue(input.instance, "AZURE_DEVOPS_EXT_PAT")) { - yield* runText({ - command: "az", - args: ["account", "show", "--output", "none"], - cwd: input.cwd, - env, - }); - } + yield* runText({ + command: "az", + args: [ + "devops", + "project", + "list", + "--organization", + baseUrl, + "--top", + "1", + "--output", + "none", + "--detect", + "false", + ], + cwd: input.cwd, + env, + }); break; case "bitbucket": { let request = HttpClientRequest.get("https://api.bitbucket.org/2.0/user"); @@ -572,7 +602,7 @@ export const make = Effect.gen(function* () { else if (email && apiToken) { request = request.pipe(HttpClientRequest.basicAuth(email, apiToken)); } - yield* execute(request); + yield* executeText(request); break; } case "jira": { @@ -585,7 +615,7 @@ export const make = Effect.gen(function* () { } else if (pat ?? token) { request = request.pipe(HttpClientRequest.bearerToken((pat ?? token)!)); } - yield* execute(request); + yield* executeText(request); break; } case "clickup": { @@ -597,7 +627,7 @@ export const make = Effect.gen(function* () { reason: "unauthenticated", }); } - yield* execute( + yield* executeText( HttpClientRequest.get("https://api.clickup.com/api/v2/user").pipe( HttpClientRequest.setHeader("Authorization", token), ), diff --git a/apps/web/src/components/settings/ProjectSettingsPanel.tsx b/apps/web/src/components/settings/ProjectSettingsPanel.tsx index 39cf9bbd536d..22343b0f11b9 100644 --- a/apps/web/src/components/settings/ProjectSettingsPanel.tsx +++ b/apps/web/src/components/settings/ProjectSettingsPanel.tsx @@ -513,12 +513,33 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { instances: [...(existing?.instances ?? []), [instanceId, instance]], }); } + for (const binding of selectedCheckout.ticketProviderBindings ?? []) { + const host = binding.host.toLowerCase(); + const key = `${binding.driver}:${host}`; + if (!groups.has(key)) { + groups.set(key, { driver: binding.driver, host, instances: [] }); + } + } return [...groups.values()]; - }, [selectedCheckoutSettings.ticketProviderInstances]); + }, [selectedCheckout.ticketProviderBindings, selectedCheckoutSettings.ticketProviderInstances]); const selectedTicketProviderBindings = selectedCheckout.ticketProviderBindings ?? []; + const ticketProviderBindingsRef = useRef({ + projectId: selectedCheckout.id, + bindings: selectedTicketProviderBindings, + }); + useEffect(() => { + ticketProviderBindingsRef.current = { + projectId: selectedCheckout.id, + bindings: selectedTicketProviderBindings, + }; + }, [selectedCheckout.id, selectedTicketProviderBindings]); const setTicketProviderBinding = useCallback( (driver: string, host: string, instanceId: string | null) => { - const withoutBinding = selectedTicketProviderBindings.filter( + const current = + ticketProviderBindingsRef.current.projectId === selectedCheckout.id + ? ticketProviderBindingsRef.current.bindings + : selectedTicketProviderBindings; + const withoutBinding = current.filter( (binding) => !(binding.driver === driver && binding.host.toLowerCase() === host), ); const next: TicketProviderBindings = instanceId @@ -531,6 +552,7 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { }, ] : withoutBinding; + ticketProviderBindingsRef.current = { projectId: selectedCheckout.id, bindings: next }; void updateProject({ environmentId: selectedCheckout.environmentId, input: { projectId: selectedCheckout.id, ticketProviderBindings: next }, @@ -1001,7 +1023,9 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { /> diff --git a/apps/web/src/components/settings/TicketProviderSettings.tsx b/apps/web/src/components/settings/TicketProviderSettings.tsx index 30384cd49309..3534771e762b 100644 --- a/apps/web/src/components/settings/TicketProviderSettings.tsx +++ b/apps/web/src/components/settings/TicketProviderSettings.tsx @@ -9,7 +9,7 @@ import { } from "@t3tools/contracts"; import { renderTicketThreadTitle } from "@t3tools/shared/ticketTitles"; import { PlusIcon, Trash2Icon } from "lucide-react"; -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { usePrimarySettings, useUpdatePrimarySettings } from "../../hooks/useSettings"; import { usePrimaryEnvironmentId } from "../../state/environments"; @@ -237,12 +237,12 @@ export function TicketTitlePolicySettings({ function AddTicketProviderDialog({ open, onOpenChange, + onAdd, }: { readonly open: boolean; readonly onOpenChange: (open: boolean) => void; + readonly onAdd: (instance: TicketProviderInstanceConfig) => void; }) { - const settings = usePrimarySettings(); - const updateSettings = useUpdatePrimarySettings(); const [driver, setDriver] = useState(DEFAULT_DRIVER.driver); const option = DRIVER_BY_KIND.get(driver) ?? DEFAULT_DRIVER; const [label, setLabel] = useState(""); @@ -274,6 +274,10 @@ function AddTicketProviderDialog({ setError("The base URL must use HTTP or HTTPS."); return; } + if (parsed.username || parsed.password) { + setError("The base URL must not contain credentials."); + return; + } if ( option.driver === TicketProviderDriverKind.make("jira") && parsed.hostname === "example.atlassian.net" @@ -283,11 +287,6 @@ function AddTicketProviderDialog({ } const displayName = label.trim() || option.label; - const instanceId = nextInstanceId( - option.driver, - `${displayName}_${parsed.host}`, - new Set(Object.keys(settings.ticketProviderInstances)), - ); const config = option.identityConfigKey && identity.trim() ? { [option.identityConfigKey]: identity.trim() } @@ -311,16 +310,7 @@ function AddTicketProviderDialog({ ...(config ? { config } : {}), ...(environment ? { environment } : {}), }; - const nextInstances = { ...settings.ticketProviderInstances }; - if (isDefault) { - for (const [id, candidate] of Object.entries(nextInstances)) { - if (candidate.driver === option.driver && hostOf(candidate.baseUrl) === parsed.host) { - nextInstances[TicketProviderInstanceId.make(id)] = { ...candidate, isDefault: false }; - } - } - } - nextInstances[instanceId] = instance; - updateSettings({ ticketProviderInstances: nextInstances }); + onAdd(instance); toastManager.add({ type: "success", title: "Ticket provider added", description: displayName }); onOpenChange(false); }; @@ -429,14 +419,26 @@ export function TicketProviderSettings() { () => Object.entries(settings.ticketProviderInstances), [settings.ticketProviderInstances], ); + const instancesRef = useRef(settings.ticketProviderInstances); + useEffect(() => { + instancesRef.current = settings.ticketProviderInstances; + }, [settings.ticketProviderInstances]); + + const updateInstances = ( + update: ( + current: typeof settings.ticketProviderInstances, + ) => typeof settings.ticketProviderInstances, + ) => { + const next = update(instancesRef.current); + instancesRef.current = next; + updateSettings({ ticketProviderInstances: next }); + }; const replaceInstance = (instanceId: string, instance: TicketProviderInstanceConfig) => { - updateSettings({ - ticketProviderInstances: { - ...settings.ticketProviderInstances, - [TicketProviderInstanceId.make(instanceId)]: instance, - }, - }); + updateInstances((current) => ({ + ...current, + [TicketProviderInstanceId.make(instanceId)]: instance, + })); }; return ( @@ -448,13 +450,13 @@ export function TicketProviderSettings() { }} /> 0 ? ( updateSettings({ ticketProviderInstances: {} })} + onClick={() => updateInstances(() => ({}))} /> ) : null } @@ -468,76 +470,105 @@ export function TicketProviderSettings() { const option = DRIVER_BY_KIND.get(instance.driver); const probe = probeByInstanceId[instanceId]; return ( -
-
-

- {instance.displayName ?? option?.label ?? instance.driver} -

-

- {instance.baseUrl} · {instanceId} -

- {probe && probe !== "testing" ? ( -

+ + + replaceInstance(instanceId, { ...instance, enabled: Boolean(checked) }) } + /> +

- - - replaceInstance(instanceId, { ...instance, enabled: Boolean(checked) }) - } - /> - -
+ + + + } + /> ); })} - + { + updateInstances((current) => { + const host = hostOf(instance.baseUrl); + const instanceId = nextInstanceId( + instance.driver, + `${instance.displayName ?? instance.driver}_${host ?? "provider"}`, + new Set(Object.keys(current)), + ); + const next = { ...current }; + if (instance.isDefault) { + for (const [id, candidate] of Object.entries(next)) { + if (candidate.driver === instance.driver && hostOf(candidate.baseUrl) === host) { + next[TicketProviderInstanceId.make(id)] = { ...candidate, isDefault: false }; + } + } + } + next[instanceId] = instance; + return next; + }); + }} + /> ); } diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index b559343c0713..ec24855e6716 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -402,6 +402,7 @@ export const OrchestrationThread = Schema.Struct({ id: ThreadId, projectId: ProjectId, title: TrimmedNonEmptyString, + titleRevision: Schema.optional(NonNegativeInt), modelSelection: ModelSelection, runtimeMode: RuntimeMode, interactionMode: ProviderInteractionMode.pipe( @@ -804,6 +805,7 @@ const ThreadMetaUpdateCommand = Schema.Struct({ threadId: ThreadId, title: Schema.optional(TrimmedNonEmptyString), expectedTitle: Schema.optional(TrimmedNonEmptyString), + expectedTitleRevision: Schema.optional(NonNegativeInt), regenerateTitle: Schema.optional(Schema.Literal(true)), modelSelection: Schema.optional(ModelSelection), branch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index ceaaf1585be4..1eb38649adcb 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -445,4 +445,17 @@ describe("ServerSettings ticket title configuration", () => { baseUrl: "https://linear.app/acme", }); }); + + it("rejects credentials embedded in ticket provider base URLs", () => { + expect(() => + decodeServerSettingsPatch({ + ticketProviderInstances: { + jira_work: { + driver: "jira", + baseUrl: "https://user:token@jira.example.com", + }, + }, + }), + ).toThrow(); + }); }); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 570878827308..e4ca463187dc 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -768,6 +768,7 @@ export const ServerSettingsOperation = Schema.Literals([ "read-file", "read-provider-history", "read-secret", + "read-secret-snapshot", "remove-secret", "remove-stale-secret", "write-secret", diff --git a/packages/contracts/src/ticketProvider.ts b/packages/contracts/src/ticketProvider.ts index 2bf1be21a698..d6e65df5cb4f 100644 --- a/packages/contracts/src/ticketProvider.ts +++ b/packages/contracts/src/ticketProvider.ts @@ -38,10 +38,26 @@ export const TicketTitlePolicy = Schema.Struct({ }); export type TicketTitlePolicy = typeof TicketTitlePolicy.Type; +const TicketProviderBaseUrl = TrimmedNonEmptyString.check( + Schema.makeFilter((value) => { + try { + const url = new URL(value); + return ( + ((url.protocol === "https:" || url.protocol === "http:") && + url.username.length === 0 && + url.password.length === 0) || + "Ticket provider base URL must use HTTP or HTTPS and must not contain credentials." + ); + } catch { + return "Ticket provider base URL must be a valid URL."; + } + }), +); + export const TicketProviderInstanceConfig = Schema.Struct({ driver: TicketProviderDriverKind, displayName: Schema.optional(TrimmedNonEmptyString), - baseUrl: TrimmedNonEmptyString, + baseUrl: TicketProviderBaseUrl, enabled: Schema.optionalKey(Schema.Boolean), isDefault: Schema.optionalKey(Schema.Boolean), environment: Schema.optionalKey(ProviderInstanceEnvironment), diff --git a/packages/shared/src/ticketTitles.test.ts b/packages/shared/src/ticketTitles.test.ts index f958449dea2b..f3e6a651f470 100644 --- a/packages/shared/src/ticketTitles.test.ts +++ b/packages/shared/src/ticketTitles.test.ts @@ -44,6 +44,21 @@ describe("extractUniqueTicketReference", () => { ).toBe("acme/widgets#3"); }); + it("ignores indented code and variable-length fenced or inline code", () => { + expect( + extractUniqueTicketReference( + [ + " https://github.com/acme/widgets/issues/1", + "````", + "https://github.com/acme/widgets/issues/2", + "````", + "``https://github.com/acme/widgets/issues/3``", + "https://github.com/acme/widgets/issues/4", + ].join("\n"), + )?.identifier, + ).toBe("acme/widgets#4"); + }); + it("uses configured hosts for self-hosted providers", () => { expect( extractUniqueTicketReference("http://git.internal/acme/widgets/-/issues/9", [ diff --git a/packages/shared/src/ticketTitles.ts b/packages/shared/src/ticketTitles.ts index e1de16d8394e..e66fa6bbf70a 100644 --- a/packages/shared/src/ticketTitles.ts +++ b/packages/shared/src/ticketTitles.ts @@ -32,12 +32,61 @@ const BUILT_IN_HOST_DRIVERS = new Map([ const URL_PATTERN = /https?:\/\/[^\s<>"']+/giu; const TRAILING_URL_PUNCTUATION = /[),.;!?\]}]+$/u; +function stripInlineCode(line: string): string { + let result = ""; + let cursor = 0; + while (cursor < line.length) { + if (line[cursor] !== "`") { + result += line[cursor]; + cursor += 1; + continue; + } + let markerEnd = cursor + 1; + while (line[markerEnd] === "`") markerEnd += 1; + const marker = line.slice(cursor, markerEnd); + const closing = line.indexOf(marker, markerEnd); + if (closing === -1) { + result += marker; + cursor = markerEnd; + continue; + } + result += " "; + cursor = closing + marker.length; + } + return result; +} + function stripIgnoredMarkdown(message: string): string { - return message - .replace(/```[\s\S]*?```/gu, " ") - .replace(/~~~[\s\S]*?~~~/gu, " ") - .replace(/^\s*>.*$/gmu, " ") - .replace(/`[^`\n]*`/gu, " "); + const visible: string[] = []; + let fence: { readonly marker: "`" | "~"; readonly length: number } | undefined; + for (const line of message.split(/\r?\n/u)) { + const fenceMatch = /^ {0,3}(`{3,}|~{3,})/u.exec(line); + if (fence) { + if ( + fenceMatch && + fenceMatch[1]![0] === fence.marker && + fenceMatch[1]!.length >= fence.length + ) { + fence = undefined; + } + visible.push(" "); + continue; + } + if (fenceMatch) { + fence = { + marker: fenceMatch[1]![0] as "`" | "~", + length: fenceMatch[1]!.length, + }; + visible.push(" "); + continue; + } + if (/^(?: {4}|\t)/u.test(line) || /^\s{0,3}>/u.test(line)) { + visible.push(" "); + continue; + } + visible.push(stripInlineCode(line)); + } + return visible.join("\n"); } function normalizedUrl(raw: string): URL | undefined { From 7b4d0fb9a1564793d8170f0de613b569011c77e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Timb=C3=B3=20Pereira?= Date: Thu, 27 Aug 2026 00:09:01 -0300 Subject: [PATCH 04/19] fix: address linked ticket title edge cases --- .../SettingsTicketProvidersRouteScreen.tsx | 36 +++++++--- .../src/ticket/TicketProviderRegistry.test.ts | 31 +++++++++ .../src/ticket/TicketProviderRegistry.ts | 2 +- .../settings/ProjectSettingsPanel.tsx | 47 ++++++++++++-- .../settings/TicketProviderSettings.tsx | 24 ++++++- packages/contracts/src/settings.test.ts | 17 +++++ packages/contracts/src/ticketProvider.ts | 8 ++- packages/shared/src/ticketTitles.test.ts | 39 +++++++++++ packages/shared/src/ticketTitles.ts | 65 +++++++++++++++---- 9 files changed, 235 insertions(+), 34 deletions(-) diff --git a/apps/mobile/src/features/settings/SettingsTicketProvidersRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsTicketProvidersRouteScreen.tsx index 57ef8d879e36..9545802d9246 100644 --- a/apps/mobile/src/features/settings/SettingsTicketProvidersRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsTicketProvidersRouteScreen.tsx @@ -116,6 +116,7 @@ function EnvironmentTicketProviders({ const instancesRef = useRef( settings?.ticketProviderInstances ?? DEFAULT_SERVER_SETTINGS.ticketProviderInstances, ); + const instancesMutationQueueRef = useRef>(Promise.resolve()); useEffect(() => { if (settings) instancesRef.current = settings.ticketProviderInstances; }, [settings]); @@ -140,9 +141,21 @@ function EnvironmentTicketProviders({ const updateInstances = ( update: (current: TicketProviderInstanceConfigMap) => TicketProviderInstanceConfigMap, ) => { - const next = update(instancesRef.current); - instancesRef.current = next; - return savePatch({ ticketProviderInstances: next }); + const operation = instancesMutationQueueRef.current.then(async () => { + const previous = instancesRef.current; + const next = update(previous); + instancesRef.current = next; + const result = await savePatch({ ticketProviderInstances: next }); + if (result._tag !== "Success") { + instancesRef.current = previous; + setError("The provider change could not be saved. Check the connection and try again."); + } else { + setError(null); + } + return result; + }); + instancesMutationQueueRef.current = operation.then(() => undefined); + return operation; }; const instances = Object.entries(settings.ticketProviderInstances); const probeProvider = (instanceId: string) => { @@ -195,6 +208,10 @@ function EnvironmentTicketProviders({ setError("The base URL must not contain credentials."); return; } + if (baseUrl.includes("?") || baseUrl.includes("#")) { + setError("The base URL must not contain a query or fragment."); + return; + } if (driver.driver === "jira" && parsed.hostname === "example.atlassian.net") { setError("Replace the example URL with your Jira site URL."); return; @@ -258,11 +275,6 @@ function EnvironmentTicketProviders({ }); setIsSaving(false); if (result._tag !== "Success") { - if (instancesRef.current[addedInstanceId] === instance) { - const next = { ...instancesRef.current }; - delete next[addedInstanceId]; - instancesRef.current = next; - } setError("The provider could not be saved. Check the connection and try again."); return; } @@ -388,7 +400,7 @@ function EnvironmentTicketProviders({ { const host = new URL(instance.baseUrl).host.toLowerCase(); void updateInstances((current) => @@ -491,7 +503,6 @@ function EnvironmentTicketProviders({ className={inputClassName()} /> ) : null} - {error ? {error} : null} ) : null} + {error ? ( + + {error} + + ) : null}
); diff --git a/apps/server/src/ticket/TicketProviderRegistry.test.ts b/apps/server/src/ticket/TicketProviderRegistry.test.ts index 4c1118e5cb6f..3e38acb7c044 100644 --- a/apps/server/src/ticket/TicketProviderRegistry.test.ts +++ b/apps/server/src/ticket/TicketProviderRegistry.test.ts @@ -337,4 +337,35 @@ describe("TicketProviderRegistry", () => { expect(run).toHaveBeenCalledTimes(1); }), ); + + effectIt.effect("keeps a shared lookup alive when its first caller is interrupted", () => + Effect.gen(function* () { + const started = yield* Deferred.make(); + const release = yield* Deferred.make(); + const run = vi.fn(() => + Deferred.succeed(started, undefined).pipe( + Effect.andThen(Deferred.await(release)), + Effect.as(commandOutput('{"title":"Shared after interruption"}')), + ), + ); + const registry = yield* makeRegistry(run); + const input = { + cwd: "/tmp/project", + reference: githubReference, + instances: {}, + bindings: [], + } as const; + + const owner = yield* registry.resolve(input).pipe(Effect.forkChild); + yield* Deferred.await(started); + const waiter = yield* registry.resolve(input).pipe(Effect.forkChild); + yield* Effect.yieldNow; + const interruptingOwner = yield* Fiber.interrupt(owner).pipe(Effect.forkChild); + yield* Deferred.succeed(release, undefined); + + expect((yield* Fiber.join(waiter)).title).toBe("Shared after interruption"); + yield* Fiber.await(interruptingOwner); + expect(run).toHaveBeenCalledTimes(1); + }), + ); }); diff --git a/apps/server/src/ticket/TicketProviderRegistry.ts b/apps/server/src/ticket/TicketProviderRegistry.ts index b4c4bc40ae91..b5d76b0659de 100644 --- a/apps/server/src/ticket/TicketProviderRegistry.ts +++ b/apps/server/src/ticket/TicketProviderRegistry.ts @@ -525,7 +525,7 @@ export const make = Effect.gen(function* () { } const deferred = Deferred.makeUnsafe(); inFlight.set(key, deferred); - const exit = yield* Effect.exit(restore(resolveSelected(input, selected))); + const exit = yield* Effect.exit(resolveSelected(input, selected)); yield* Deferred.done(deferred, exit); inFlight.delete(key); return yield* Deferred.await(deferred); diff --git a/apps/web/src/components/settings/ProjectSettingsPanel.tsx b/apps/web/src/components/settings/ProjectSettingsPanel.tsx index 22343b0f11b9..e551aef9580d 100644 --- a/apps/web/src/components/settings/ProjectSettingsPanel.tsx +++ b/apps/web/src/components/settings/ProjectSettingsPanel.tsx @@ -456,9 +456,48 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { ); const storedTicketTitlePolicy = representative.ticketTitlePolicy ?? null; const setTicketTitlePolicy = useCallback( - (ticketTitlePolicy: TicketTitlePolicy | null) => - void updateAllMembers({ ticketTitlePolicy }, "Failed to update ticket title settings"), - [updateAllMembers], + (ticketTitlePolicy: TicketTitlePolicy | null) => { + void (async () => { + const updatedMembers: Array<(typeof group.memberProjects)[number]> = []; + for (const member of group.memberProjects) { + const result = mapAtomCommandResult( + await updateProject({ + environmentId: member.environmentId, + input: { projectId: member.id, ticketTitlePolicy }, + }), + () => undefined, + ); + if (result._tag === "Success") { + updatedMembers.push(member); + continue; + } + + for (const updatedMember of updatedMembers.toReversed()) { + const rollback = mapAtomCommandResult( + await updateProject({ + environmentId: updatedMember.environmentId, + input: { + projectId: updatedMember.id, + ticketTitlePolicy: updatedMember.ticketTitlePolicy ?? null, + }, + }), + () => undefined, + ); + if (rollback._tag === "Failure") { + reportFailure("Failed to roll back ticket title settings", rollback); + } + } + reportFailure( + group.memberProjects.length > 1 + ? `Failed to update ticket title settings on ${member.environmentLabel ?? "the current environment"}` + : "Failed to update ticket title settings", + result, + ); + return; + } + })(); + }, + [group.memberProjects, reportFailure, updateProject], ); // ----- favicon ----- @@ -1025,7 +1064,7 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { policy={storedTicketTitlePolicy} {...(group.memberProjects.length === 1 ? { inheritedPolicy: selectedCheckoutSettings.ticketTitlePolicy } - : {})} + : { inheritedLabel: "Default (per checkout)" })} allowInherit onChange={setTicketTitlePolicy} /> diff --git a/apps/web/src/components/settings/TicketProviderSettings.tsx b/apps/web/src/components/settings/TicketProviderSettings.tsx index 3534771e762b..22318d9a2745 100644 --- a/apps/web/src/components/settings/TicketProviderSettings.tsx +++ b/apps/web/src/components/settings/TicketProviderSettings.tsx @@ -145,11 +145,13 @@ function nextInstanceId( export function TicketTitlePolicySettings({ policy, inheritedPolicy, + inheritedLabel, onChange, allowInherit = false, }: { readonly policy: TicketTitlePolicy | null; readonly inheritedPolicy?: TicketTitlePolicy; + readonly inheritedLabel?: string; readonly onChange: (policy: TicketTitlePolicy | null) => void; readonly allowInherit?: boolean; }) { @@ -195,12 +197,15 @@ export function TicketTitlePolicySettings({ {selectValue === "inherit" - ? `Default (${POLICY_MODE_LABELS[effective.mode].toLowerCase()})` + ? (inheritedLabel ?? + `Default (${POLICY_MODE_LABELS[effective.mode].toLowerCase()})`) : POLICY_MODE_LABELS[effective.mode]} - {allowInherit ? Default : null} + {allowInherit ? ( + {inheritedLabel ?? "Default"} + ) : null} Identifier and title Ticket title Custom template @@ -238,10 +243,12 @@ function AddTicketProviderDialog({ open, onOpenChange, onAdd, + canSave, }: { readonly open: boolean; readonly onOpenChange: (open: boolean) => void; readonly onAdd: (instance: TicketProviderInstanceConfig) => void; + readonly canSave: boolean; }) { const [driver, setDriver] = useState(DEFAULT_DRIVER.driver); const option = DRIVER_BY_KIND.get(driver) ?? DEFAULT_DRIVER; @@ -263,6 +270,10 @@ function AddTicketProviderDialog({ }; const save = () => { + if (!canSave) { + setError("Connect a primary environment before adding a ticket provider."); + return; + } let parsed: URL; try { parsed = new URL(baseUrl.trim()); @@ -278,6 +289,10 @@ function AddTicketProviderDialog({ setError("The base URL must not contain credentials."); return; } + if (baseUrl.includes("?") || baseUrl.includes("#")) { + setError("The base URL must not contain a query or fragment."); + return; + } if ( option.driver === TicketProviderDriverKind.make("jira") && parsed.hostname === "example.atlassian.net" @@ -397,7 +412,9 @@ function AddTicketProviderDialog({ - + @@ -548,6 +565,7 @@ export function TicketProviderSettings() { { updateInstances((current) => { const host = hostOf(instance.baseUrl); diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 1eb38649adcb..16c15e18fe2e 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -458,4 +458,21 @@ describe("ServerSettings ticket title configuration", () => { }), ).toThrow(); }); + + it("rejects query strings and fragments in ticket provider base URLs", () => { + for (const baseUrl of [ + "https://jira.example.com/?tenant=acme", + "https://jira.example.com/#tickets", + "https://jira.example.com/?", + "https://jira.example.com/#", + ]) { + expect(() => + decodeServerSettingsPatch({ + ticketProviderInstances: { + jira_work: { driver: "jira", baseUrl }, + }, + }), + ).toThrow(); + } + }); }); diff --git a/packages/contracts/src/ticketProvider.ts b/packages/contracts/src/ticketProvider.ts index d6e65df5cb4f..dcb56305d79d 100644 --- a/packages/contracts/src/ticketProvider.ts +++ b/packages/contracts/src/ticketProvider.ts @@ -45,8 +45,12 @@ const TicketProviderBaseUrl = TrimmedNonEmptyString.check( return ( ((url.protocol === "https:" || url.protocol === "http:") && url.username.length === 0 && - url.password.length === 0) || - "Ticket provider base URL must use HTTP or HTTPS and must not contain credentials." + url.password.length === 0 && + url.search.length === 0 && + url.hash.length === 0 && + !value.includes("?") && + !value.includes("#")) || + "Ticket provider base URL must use HTTP or HTTPS and must not contain credentials, a query, or a fragment." ); } catch { return "Ticket provider base URL must be a valid URL."; diff --git a/packages/shared/src/ticketTitles.test.ts b/packages/shared/src/ticketTitles.test.ts index f3e6a651f470..91eb37ec97a5 100644 --- a/packages/shared/src/ticketTitles.test.ts +++ b/packages/shared/src/ticketTitles.test.ts @@ -59,6 +59,45 @@ describe("extractUniqueTicketReference", () => { ).toBe("acme/widgets#4"); }); + it("keeps an indented paragraph continuation visible", () => { + expect( + extractUniqueTicketReference("See the ticket:\n https://github.com/acme/widgets/issues/5") + ?.identifier, + ).toBe("acme/widgets#5"); + }); + + it("ignores multiline code spans and requires exact closing backtick runs", () => { + expect( + extractUniqueTicketReference( + [ + "`ticket:", + "https://github.com/acme/widgets/issues/6", + "`", + "``not code https://github.com/acme/widgets/issues/7 ```", + "https://github.com/acme/widgets/issues/8", + ].join("\n"), + )?.identifier, + ).toBeUndefined(); + expect( + extractUniqueTicketReference("``not code https://github.com/acme/widgets/issues/9 ```") + ?.identifier, + ).toBe("acme/widgets#9"); + }); + + it("does not close a fenced block with a marker that has trailing content", () => { + expect( + extractUniqueTicketReference( + [ + "````", + "````js", + "https://github.com/acme/widgets/issues/10", + "````", + "https://github.com/acme/widgets/issues/11", + ].join("\n"), + )?.identifier, + ).toBe("acme/widgets#11"); + }); + it("uses configured hosts for self-hosted providers", () => { expect( extractUniqueTicketReference("http://git.internal/acme/widgets/-/issues/9", [ diff --git a/packages/shared/src/ticketTitles.ts b/packages/shared/src/ticketTitles.ts index e66fa6bbf70a..798eef6993d7 100644 --- a/packages/shared/src/ticketTitles.ts +++ b/packages/shared/src/ticketTitles.ts @@ -32,26 +32,43 @@ const BUILT_IN_HOST_DRIVERS = new Map([ const URL_PATTERN = /https?:\/\/[^\s<>"']+/giu; const TRAILING_URL_PUNCTUATION = /[),.;!?\]}]+$/u; -function stripInlineCode(line: string): string { +function backtickRunLength(value: string, start: number): number { + let end = start; + while (value[end] === "`") end += 1; + return end - start; +} + +function findClosingBacktickRun(value: string, start: number, markerLength: number): number { + let cursor = start; + while (cursor < value.length) { + const candidate = value.indexOf("`", cursor); + if (candidate === -1) return -1; + const candidateLength = backtickRunLength(value, candidate); + if (candidateLength === markerLength) return candidate; + cursor = candidate + candidateLength; + } + return -1; +} + +function stripInlineCode(value: string): string { let result = ""; let cursor = 0; - while (cursor < line.length) { - if (line[cursor] !== "`") { - result += line[cursor]; + while (cursor < value.length) { + if (value[cursor] !== "`") { + result += value[cursor]; cursor += 1; continue; } - let markerEnd = cursor + 1; - while (line[markerEnd] === "`") markerEnd += 1; - const marker = line.slice(cursor, markerEnd); - const closing = line.indexOf(marker, markerEnd); + const markerLength = backtickRunLength(value, cursor); + const markerEnd = cursor + markerLength; + const closing = findClosingBacktickRun(value, markerEnd, markerLength); if (closing === -1) { - result += marker; + result += value.slice(cursor, markerEnd); cursor = markerEnd; continue; } result += " "; - cursor = closing + marker.length; + cursor = closing + markerLength; } return result; } @@ -59,17 +76,21 @@ function stripInlineCode(line: string): string { function stripIgnoredMarkdown(message: string): string { const visible: string[] = []; let fence: { readonly marker: "`" | "~"; readonly length: number } | undefined; + let inIndentedCode = false; + let previousLineWasBlank = true; for (const line of message.split(/\r?\n/u)) { const fenceMatch = /^ {0,3}(`{3,}|~{3,})/u.exec(line); if (fence) { if ( fenceMatch && fenceMatch[1]![0] === fence.marker && - fenceMatch[1]!.length >= fence.length + fenceMatch[1]!.length >= fence.length && + /^[ \t]*$/u.test(line.slice(fenceMatch[0].length)) ) { fence = undefined; } visible.push(" "); + previousLineWasBlank = false; continue; } if (fenceMatch) { @@ -78,15 +99,31 @@ function stripIgnoredMarkdown(message: string): string { length: fenceMatch[1]!.length, }; visible.push(" "); + inIndentedCode = false; + previousLineWasBlank = false; continue; } - if (/^(?: {4}|\t)/u.test(line) || /^\s{0,3}>/u.test(line)) { + if (line.trim().length === 0) { visible.push(" "); + previousLineWasBlank = true; continue; } - visible.push(stripInlineCode(line)); + const isIndented = /^(?: {4}|\t)/u.test(line); + if (isIndented && (inIndentedCode || previousLineWasBlank)) { + visible.push(" "); + inIndentedCode = true; + previousLineWasBlank = false; + continue; + } + inIndentedCode = false; + if (/^\s{0,3}>/u.test(line)) { + visible.push(" "); + } else { + visible.push(line); + } + previousLineWasBlank = false; } - return visible.join("\n"); + return stripInlineCode(visible.join("\n")); } function normalizedUrl(raw: string): URL | undefined { From 4e5976e2fd45d110d3db4088912119e0ad14a09d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Timb=C3=B3=20Pereira?= Date: Thu, 27 Aug 2026 00:10:53 -0300 Subject: [PATCH 05/19] fix(server): detect Azure organization during probes --- .../src/ticket/TicketProviderRegistry.test.ts | 27 +++++++++++++++++++ .../src/ticket/TicketProviderRegistry.ts | 11 +++++--- 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/apps/server/src/ticket/TicketProviderRegistry.test.ts b/apps/server/src/ticket/TicketProviderRegistry.test.ts index 3e38acb7c044..3fb5b8a4c7e4 100644 --- a/apps/server/src/ticket/TicketProviderRegistry.test.ts +++ b/apps/server/src/ticket/TicketProviderRegistry.test.ts @@ -271,6 +271,33 @@ describe("TicketProviderRegistry", () => { }), ); + effectIt.effect("lets the Azure CLI detect the organization for the root cloud URL", () => + Effect.gen(function* () { + const run = vi.fn(() => + Effect.succeed(commandOutput("")), + ); + const registry = yield* makeRegistry(run); + + const result = yield* registry.probe({ + cwd: "/tmp/project", + instanceId: TicketProviderInstanceId.make("azure_detected"), + instance: { + driver: TicketProviderDriverKind.make("azure-devops"), + baseUrl: "https://dev.azure.com", + environment: [{ name: "AZURE_DEVOPS_EXT_PAT", value: "pat", sensitive: true }], + }, + }); + + expect(result.availability).toBe("available"); + expect(run).toHaveBeenCalledWith( + expect.objectContaining({ + command: "az", + args: ["devops", "project", "list", "--top", "1", "--output", "none", "--detect", "true"], + }), + ); + }), + ); + effectIt.effect("rejects oversized HTTP ticket responses before JSON decoding", () => Effect.gen(function* () { const run = vi.fn(() => diff --git a/apps/server/src/ticket/TicketProviderRegistry.ts b/apps/server/src/ticket/TicketProviderRegistry.ts index b5d76b0659de..4fe077595648 100644 --- a/apps/server/src/ticket/TicketProviderRegistry.ts +++ b/apps/server/src/ticket/TicketProviderRegistry.ts @@ -573,26 +573,29 @@ export const make = Effect.gen(function* () { env, }); break; - case "azure-devops": + case "azure-devops": { + const configuredUrl = new URL(baseUrl); + const detectOrganization = + configuredUrl.hostname === "dev.azure.com" && configuredUrl.pathname === "/"; yield* runText({ command: "az", args: [ "devops", "project", "list", - "--organization", - baseUrl, + ...(detectOrganization ? [] : ["--organization", baseUrl]), "--top", "1", "--output", "none", "--detect", - "false", + detectOrganization ? "true" : "false", ], cwd: input.cwd, env, }); break; + } case "bitbucket": { let request = HttpClientRequest.get("https://api.bitbucket.org/2.0/user"); const accessToken = environmentValue(input.instance, "T3CODE_BITBUCKET_ACCESS_TOKEN"); From d58f0df09eeab0b0e68d2857a9354d49163f0410 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Timb=C3=B3=20Pereira?= Date: Thu, 27 Aug 2026 00:13:41 -0300 Subject: [PATCH 06/19] fix(server): classify invalid ticket responses --- .../src/ticket/TicketProviderRegistry.test.ts | 22 +++++++ .../src/ticket/TicketProviderRegistry.ts | 63 +++++++++++++++---- 2 files changed, 72 insertions(+), 13 deletions(-) diff --git a/apps/server/src/ticket/TicketProviderRegistry.test.ts b/apps/server/src/ticket/TicketProviderRegistry.test.ts index 3fb5b8a4c7e4..7fa1b7ee81fe 100644 --- a/apps/server/src/ticket/TicketProviderRegistry.test.ts +++ b/apps/server/src/ticket/TicketProviderRegistry.test.ts @@ -144,6 +144,28 @@ describe("TicketProviderRegistry", () => { }), ); + effectIt.effect("classifies malformed provider payloads as invalid responses", () => + Effect.gen(function* () { + const run = vi.fn(() => + Effect.succeed(commandOutput('{"unexpected":"shape"}')), + ); + const registry = yield* makeRegistry(run); + + const failure = yield* Effect.flip( + registry.resolve({ + cwd: "/tmp/project", + reference: githubReference, + instances: {}, + bindings: [], + }), + ); + + expect(failure.reason).toBe("invalid-response"); + expect(failure.message).toContain("Ticket lookup for driver 'github'"); + expect(failure.message).not.toContain("unexpected"); + }), + ); + effectIt.effect("tests the configured GitHub account without changing active gh auth", () => Effect.gen(function* () { const run = vi.fn(() => diff --git a/apps/server/src/ticket/TicketProviderRegistry.ts b/apps/server/src/ticket/TicketProviderRegistry.ts index 4fe077595648..dccc2764e865 100644 --- a/apps/server/src/ticket/TicketProviderRegistry.ts +++ b/apps/server/src/ticket/TicketProviderRegistry.ts @@ -49,12 +49,30 @@ export class TicketProviderResolveError extends Schema.TaggedErrorClass()( "TicketProviderResponseTooLargeError", {}, -) {} +) { + override get message(): string { + return `Ticket provider response exceeded ${MAX_RESPONSE_BYTES} bytes.`; + } +} + +class TicketProviderInvalidResponseError extends Schema.TaggedErrorClass()( + "TicketProviderInvalidResponseError", + { cause: Schema.Defect() }, +) { + override get message(): string { + return "Ticket provider returned an invalid response."; + } +} export interface TicketProviderResolveInput { readonly cwd: string; @@ -214,6 +232,19 @@ const decodeClickUpTaskJson = Schema.decodeUnknownEffect( Schema.fromJsonString(ClickUpTaskResponse), ); const isTicketProviderResolveError = Schema.is(TicketProviderResolveError); +const isTicketProviderInvalidResponseError = Schema.is(TicketProviderInvalidResponseError); + +const decodeTicketResponse = ( + effect: Effect.Effect, +): Effect.Effect => + effect.pipe( + Effect.mapError( + (cause) => + new TicketProviderInvalidResponseError({ + cause, + }), + ), + ); export const make = Effect.gen(function* () { const vcsProcess = yield* VcsProcess.VcsProcess; @@ -296,7 +327,7 @@ export const make = Effect.gen(function* () { cwd: input.cwd, env, }); - const issue = yield* decodeGitHubIssueJson(stdout); + const issue = yield* decodeTicketResponse(decodeGitHubIssueJson(stdout)); return { title: issue.title }; }); @@ -316,7 +347,7 @@ export const make = Effect.gen(function* () { cwd: input.cwd, env: environmentForInstance(input.instance), }); - const issue = yield* decodeGitLabIssueJson(stdout); + const issue = yield* decodeTicketResponse(decodeGitLabIssueJson(stdout)); return { title: issue.title }; }); @@ -382,7 +413,9 @@ export const make = Effect.gen(function* () { if (accessToken) request = request.pipe(HttpClientRequest.bearerToken(accessToken)); else if (email && apiToken) request = request.pipe(HttpClientRequest.basicAuth(email, apiToken)); - const issue = yield* decodeBitbucketIssueJson(yield* executeText(request)); + const issue = yield* decodeTicketResponse( + decodeBitbucketIssueJson(yield* executeText(request)), + ); return { title: issue.title }; }); @@ -404,7 +437,7 @@ export const make = Effect.gen(function* () { } else if (pat ?? token) { request = request.pipe(HttpClientRequest.bearerToken((pat ?? token)!)); } - const issue = yield* decodeJiraIssueJson(yield* executeText(request)); + const issue = yield* decodeTicketResponse(decodeJiraIssueJson(yield* executeText(request))); return { title: issue.fields.summary }; }); @@ -424,11 +457,13 @@ export const make = Effect.gen(function* () { const query = custom ? `?custom_task_ids=true&team_id=${encodeURIComponent(config.workspaceId!.trim())}` : ""; - const task = yield* decodeClickUpTaskJson( - yield* executeText( - HttpClientRequest.get( - `https://api.clickup.com/api/v2/task/${encodeURIComponent(input.reference.resourceId)}${query}`, - ).pipe(HttpClientRequest.setHeader("Authorization", token)), + const task = yield* decodeTicketResponse( + decodeClickUpTaskJson( + yield* executeText( + HttpClientRequest.get( + `https://api.clickup.com/api/v2/task/${encodeURIComponent(input.reference.resourceId)}${query}`, + ).pipe(HttpClientRequest.setHeader("Authorization", token)), + ), ), ); return { @@ -457,8 +492,10 @@ export const make = Effect.gen(function* () { : new TicketProviderResolveError({ driver: selected.instance.driver, instanceId: selected.instanceId, - reason: "request-failed", - cause, + reason: isTicketProviderInvalidResponseError(cause) + ? "invalid-response" + : "request-failed", + cause: isTicketProviderInvalidResponseError(cause) ? cause.cause : cause, }), ), ); From c95fa7b60393a1c33a60f642d4bf9fcc69bde777 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Timb=C3=B3=20Pereira?= Date: Thu, 27 Aug 2026 00:19:58 -0300 Subject: [PATCH 07/19] fix: scope ticket bindings by provider path --- .../SettingsTicketProvidersRouteScreen.tsx | 159 ++++++++++++++---- .../src/ticket/TicketProviderRegistry.test.ts | 57 +++++++ .../src/ticket/TicketProviderRegistry.ts | 20 ++- .../settings/ProjectSettingsPanel.tsx | 66 ++++++-- packages/contracts/src/ticketProvider.ts | 1 + 5 files changed, 248 insertions(+), 55 deletions(-) diff --git a/apps/mobile/src/features/settings/SettingsTicketProvidersRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsTicketProvidersRouteScreen.tsx index 9545802d9246..906037de3be7 100644 --- a/apps/mobile/src/features/settings/SettingsTicketProvidersRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsTicketProvidersRouteScreen.tsx @@ -14,6 +14,7 @@ import { type TicketProviderBindings, type TicketTitleMode, } from "@t3tools/contracts"; +import { renderTicketThreadTitle } from "@t3tools/shared/ticketTitles"; import { Platform, Pressable, ScrollView, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useContext, useEffect, useRef, useState } from "react"; @@ -39,6 +40,17 @@ const MODE_OPTIONS: ReadonlyArray<{ { mode: "disabled", label: "Off" }, ]; +const TEMPLATE_PREVIEW_METADATA = { + title: "Fix reconnect failures", + identifier: "acme/widgets#12", + provider: "GitHub", + project: "acme/widgets", +}; + +function ticketProviderBasePath(value: string): string { + return new URL(value).pathname.replace(/\/+$/u, ""); +} + interface DriverOption { readonly driver: string; readonly label: string; @@ -109,6 +121,11 @@ function EnvironmentTicketProviders({ const [identity, setIdentity] = useState(""); const [secret, setSecret] = useState(""); const [error, setError] = useState(null); + const [templateDraft, setTemplateDraft] = useState( + settings?.ticketTitlePolicy.customTemplate ?? + DEFAULT_SERVER_SETTINGS.ticketTitlePolicy.customTemplate, + ); + const [templateError, setTemplateError] = useState(null); const [isSaving, setIsSaving] = useState(false); const [probeByInstanceId, setProbeByInstanceId] = useState< Readonly> @@ -120,6 +137,12 @@ function EnvironmentTicketProviders({ useEffect(() => { if (settings) instancesRef.current = settings.ticketProviderInstances; }, [settings]); + useEffect(() => { + if (settings) { + setTemplateDraft(settings.ticketTitlePolicy.customTemplate); + setTemplateError(null); + } + }, [settings]); if (!settings) { return ( @@ -138,6 +161,17 @@ function EnvironmentTicketProviders({ { environmentId, input: { patch } }, { label: "mobile ticket provider settings" }, ); + const saveTemplate = (customTemplate: string) => { + setTemplateDraft(customTemplate); + if (customTemplate === settings.ticketTitlePolicy.customTemplate) return; + const ticketTitlePolicy = { ...settings.ticketTitlePolicy, customTemplate }; + if (!renderTicketThreadTitle(ticketTitlePolicy, TEMPLATE_PREVIEW_METADATA)) { + setTemplateError("The template contains an unsupported variable."); + return; + } + setTemplateError(null); + void savePatch({ ticketTitlePolicy }); + }; const updateInstances = ( update: (current: TicketProviderInstanceConfigMap) => TicketProviderInstanceConfigMap, ) => { @@ -322,18 +356,15 @@ function EnvironmentTicketProviders({ - void savePatch({ - ticketTitlePolicy: { - ...settings.ticketTitlePolicy, - customTemplate: event.nativeEvent.text, - }, - }) - } + onBlur={() => saveTemplate(templateDraft)} /> + {templateError ? ( + {templateError} + ) : null} ) : null} @@ -386,6 +417,29 @@ function EnvironmentTicketProviders({ ? probe.detail : "Uses this environment's local tools."} + { + void updateInstances((current) => { + const brandedInstanceId = TicketProviderInstanceId.make(instanceId); + const candidate = current[brandedInstanceId]; + if (!candidate) return current; + return { + ...current, + [brandedInstanceId]: { + ...candidate, + enabled: candidate.enabled === false, + }, + }; + }); + }} + className="rounded-full border border-border px-3 py-2" + > + + {instance.enabled === false ? "Enable" : "Disable"} + + (null); + useEffect( + () => setTemplateDraft(effectivePolicy.customTemplate), + [effectivePolicy.customTemplate], + ); const bindings = project.ticketProviderBindings ?? []; const providerGroups = new Map< string, { readonly driver: string; readonly host: string; + readonly basePath: string; readonly instances: ReadonlyArray; } >(); @@ -546,11 +607,13 @@ function ProjectTicketTitles({ project }: { readonly project: EnvironmentProject if (instance.enabled === false) continue; try { const host = new URL(instance.baseUrl).host.toLowerCase(); - const key = `${instance.driver}:${host}`; + const basePath = ticketProviderBasePath(instance.baseUrl); + const key = `${instance.driver}:${host}:${basePath}`; const current = providerGroups.get(key); providerGroups.set(key, { driver: instance.driver, host, + basePath, instances: [...(current?.instances ?? []), [instanceId, instance]], }); } catch { @@ -559,9 +622,10 @@ function ProjectTicketTitles({ project }: { readonly project: EnvironmentProject } for (const binding of bindings) { const host = binding.host.toLowerCase(); - const key = `${binding.driver}:${host}`; + const basePath = binding.basePath ?? ""; + const key = `${binding.driver}:${host}:${basePath}`; if (!providerGroups.has(key)) { - providerGroups.set(key, { driver: binding.driver, host, instances: [] }); + providerGroups.set(key, { driver: binding.driver, host, basePath, instances: [] }); } } @@ -574,16 +638,27 @@ function ProjectTicketTitles({ project }: { readonly project: EnvironmentProject readonly ticketTitlePolicy?: EnvironmentProject["ticketTitlePolicy"]; readonly ticketProviderBindings?: TicketProviderBindings; }) => { - void runAtomCommand( + return runAtomCommand( registry, projectEnvironment.update, { environmentId: project.environmentId, input: { projectId: project.id, ...input } }, { label: "mobile project ticket title settings" }, ); }; - const updateBinding = (driver: string, host: string, instanceId: string | null) => { - const remaining = bindingsRef.current.filter( - (binding) => !(binding.driver === driver && binding.host.toLowerCase() === host), + const updateBinding = ( + driver: string, + host: string, + basePath: string, + instanceId: string | null, + ) => { + const previous = bindingsRef.current; + const remaining = previous.filter( + (binding) => + !( + binding.driver === driver && + binding.host.toLowerCase() === host && + (binding.basePath ?? "") === basePath + ), ); const next: TicketProviderBindings = instanceId ? [ @@ -591,12 +666,29 @@ function ProjectTicketTitles({ project }: { readonly project: EnvironmentProject { driver: TicketProviderDriverKind.make(driver), host, + ...(basePath ? { basePath } : {}), instanceId: TicketProviderInstanceId.make(instanceId), }, ] : remaining; bindingsRef.current = next; - updateProject({ ticketProviderBindings: next }); + void updateProject({ ticketProviderBindings: next }).then((result) => { + if (result._tag !== "Success" && bindingsRef.current === next) { + bindingsRef.current = previous; + } + }); + }; + + const saveProjectTemplate = (customTemplate: string) => { + setTemplateDraft(customTemplate); + if (customTemplate === effectivePolicy.customTemplate) return; + const ticketTitlePolicy = { ...effectivePolicy, mode: "custom" as const, customTemplate }; + if (!renderTicketThreadTitle(ticketTitlePolicy, TEMPLATE_PREVIEW_METADATA)) { + setTemplateError("The template contains an unsupported variable."); + return; + } + setTemplateError(null); + void updateProject({ ticketTitlePolicy }); }; return ( @@ -618,7 +710,7 @@ function ProjectTicketTitles({ project }: { readonly project: EnvironmentProject : "flex-row items-center gap-4 border-t border-border-subtle p-4" } onPress={() => - updateProject({ + void updateProject({ ticketTitlePolicy: option.mode === "inherit" ? null @@ -644,39 +736,42 @@ function ProjectTicketTitles({ project }: { readonly project: EnvironmentProject - updateProject({ - ticketTitlePolicy: { - ...storedPolicy, - customTemplate: event.nativeEvent.text, - }, - }) - } + onBlur={() => saveProjectTemplate(templateDraft)} /> + {templateError ? {templateError} : null} ) : null} {[...providerGroups.values()].map((group) => { const binding = bindings.find( (candidate) => - candidate.driver === group.driver && candidate.host.toLowerCase() === group.host, + candidate.driver === group.driver && + candidate.host.toLowerCase() === group.host && + (candidate.basePath ?? "") === group.basePath, ); + const providerLocation = `${group.host}${group.basePath}`; const choices = [ - { id: "automatic", label: `${group.host}: environment default` }, + { id: "automatic", label: `${providerLocation}: environment default` }, ...group.instances.map(([instanceId, instance]) => ({ id: instanceId, - label: `${group.host}: ${instance.displayName ?? instanceId}`, + label: `${providerLocation}: ${instance.displayName ?? instanceId}`, })), ]; return choices.map((choice) => ( - updateBinding(group.driver, group.host, choice.id === "automatic" ? null : choice.id) + updateBinding( + group.driver, + group.host, + group.basePath, + choice.id === "automatic" ? null : choice.id, + ) } > {choice.label} diff --git a/apps/server/src/ticket/TicketProviderRegistry.test.ts b/apps/server/src/ticket/TicketProviderRegistry.test.ts index 7fa1b7ee81fe..a469bc96eadd 100644 --- a/apps/server/src/ticket/TicketProviderRegistry.test.ts +++ b/apps/server/src/ticket/TicketProviderRegistry.test.ts @@ -125,6 +125,63 @@ describe("TicketProviderRegistry", () => { }), ); + effectIt.effect("selects same-host bindings by their configured base path", () => + Effect.gen(function* () { + const run = vi.fn((input) => + Effect.succeed( + commandOutput( + JSON.stringify({ + title: + input.env?.GITLAB_TOKEN === "tenant-b-token" ? "Tenant B issue" : "Wrong tenant", + }), + ), + ), + ); + const registry = yield* makeRegistry(run); + const reference = { + driver: TicketProviderDriverKind.make("gitlab"), + host: "git.example.com", + url: "https://git.example.com/tenant-b/acme/widgets/-/issues/12", + identifier: "acme/widgets#12", + project: "acme/widgets", + resourceId: "12", + } as const; + + const result = yield* registry.resolve({ + cwd: "/tmp/project", + reference, + instances: { + [TicketProviderInstanceId.make("gitlab_a")]: { + driver: TicketProviderDriverKind.make("gitlab"), + baseUrl: "https://git.example.com/tenant-a", + environment: [{ name: "GITLAB_TOKEN", value: "tenant-a-token", sensitive: true }], + }, + [TicketProviderInstanceId.make("gitlab_b")]: { + driver: TicketProviderDriverKind.make("gitlab"), + baseUrl: "https://git.example.com/tenant-b", + environment: [{ name: "GITLAB_TOKEN", value: "tenant-b-token", sensitive: true }], + }, + }, + bindings: [ + { + driver: TicketProviderDriverKind.make("gitlab"), + host: "git.example.com", + basePath: "/tenant-a", + instanceId: TicketProviderInstanceId.make("gitlab_a"), + }, + { + driver: TicketProviderDriverKind.make("gitlab"), + host: "git.example.com", + basePath: "/tenant-b", + instanceId: TicketProviderInstanceId.make("gitlab_b"), + }, + ], + }); + + expect(result.title).toBe("Tenant B issue"); + }), + ); + effectIt.effect("uses the single compatible local CLI when no instance is configured", () => Effect.gen(function* () { const run = vi.fn(() => diff --git a/apps/server/src/ticket/TicketProviderRegistry.ts b/apps/server/src/ticket/TicketProviderRegistry.ts index dccc2764e865..bcfea7881a27 100644 --- a/apps/server/src/ticket/TicketProviderRegistry.ts +++ b/apps/server/src/ticket/TicketProviderRegistry.ts @@ -146,6 +146,12 @@ function instanceMatchesReference( ); } +function bindingBasePathMatchesReference(basePath: string | undefined, reference: URL): boolean { + if (!basePath) return true; + const normalized = `/${basePath}`.replace(/\/{2,}/gu, "/").replace(/\/$/u, ""); + return reference.pathname === normalized || reference.pathname.startsWith(`${normalized}/`); +} + function implicitInstance( reference: TicketReference, ): readonly [string, TicketProviderInstanceConfig] { @@ -176,11 +182,15 @@ function selectInstance(input: TicketProviderResolveInput): const matching = Object.entries(input.instances).filter(([, instance]) => instanceMatchesReference(instance, input.reference), ); - const binding = input.bindings.find( - (entry) => - entry.driver === input.reference.driver && - entry.host.toLowerCase() === input.reference.host.toLowerCase(), - ); + const referenceUrl = new URL(input.reference.url); + const binding = input.bindings + .filter( + (entry) => + entry.driver === input.reference.driver && + entry.host.toLowerCase() === input.reference.host.toLowerCase() && + bindingBasePathMatchesReference(entry.basePath, referenceUrl), + ) + .toSorted((left, right) => (right.basePath?.length ?? 0) - (left.basePath?.length ?? 0))[0]; if (binding) { const instance = input.instances[binding.instanceId]; return instance && instanceMatchesReference(instance, input.reference) diff --git a/apps/web/src/components/settings/ProjectSettingsPanel.tsx b/apps/web/src/components/settings/ProjectSettingsPanel.tsx index e551aef9580d..3fa452187063 100644 --- a/apps/web/src/components/settings/ProjectSettingsPanel.tsx +++ b/apps/web/src/components/settings/ProjectSettingsPanel.tsx @@ -121,6 +121,10 @@ import { import { projectGroupTitleNeedsUpdate } from "./ProjectSettingsPanel.logic"; import { TicketTitlePolicySettings } from "./TicketProviderSettings"; +function ticketProviderBasePath(value: string): string { + return new URL(value).pathname.replace(/\/+$/u, ""); +} + export const PROJECT_GROUPING_MODE_LABELS: Record = { repository: "Group by repository", repository_path: "Group by repository path", @@ -531,6 +535,7 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { { readonly driver: string; readonly host: string; + readonly basePath: string; readonly instances: ReadonlyArray; } >(); @@ -539,24 +544,29 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { )) { if (instance.enabled === false) continue; let host: string; + let basePath: string; try { - host = new URL(instance.baseUrl).host.toLowerCase(); + const url = new URL(instance.baseUrl); + host = url.host.toLowerCase(); + basePath = ticketProviderBasePath(instance.baseUrl); } catch { continue; } - const key = `${instance.driver}:${host}`; + const key = `${instance.driver}:${host}:${basePath}`; const existing = groups.get(key); groups.set(key, { driver: instance.driver, host, + basePath, instances: [...(existing?.instances ?? []), [instanceId, instance]], }); } for (const binding of selectedCheckout.ticketProviderBindings ?? []) { const host = binding.host.toLowerCase(); - const key = `${binding.driver}:${host}`; + const basePath = binding.basePath ?? ""; + const key = `${binding.driver}:${host}:${basePath}`; if (!groups.has(key)) { - groups.set(key, { driver: binding.driver, host, instances: [] }); + groups.set(key, { driver: binding.driver, host, basePath, instances: [] }); } } return [...groups.values()]; @@ -573,13 +583,18 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { }; }, [selectedCheckout.id, selectedTicketProviderBindings]); const setTicketProviderBinding = useCallback( - (driver: string, host: string, instanceId: string | null) => { + (driver: string, host: string, basePath: string, instanceId: string | null) => { const current = ticketProviderBindingsRef.current.projectId === selectedCheckout.id ? ticketProviderBindingsRef.current.bindings : selectedTicketProviderBindings; const withoutBinding = current.filter( - (binding) => !(binding.driver === driver && binding.host.toLowerCase() === host), + (binding) => + !( + binding.driver === driver && + binding.host.toLowerCase() === host && + (binding.basePath ?? "") === basePath + ), ); const next: TicketProviderBindings = instanceId ? [ @@ -587,20 +602,27 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { { driver: TicketProviderDriverKind.make(driver), host, + ...(basePath ? { basePath } : {}), instanceId: TicketProviderInstanceId.make(instanceId), }, ] : withoutBinding; + const previous = { projectId: selectedCheckout.id, bindings: current }; ticketProviderBindingsRef.current = { projectId: selectedCheckout.id, bindings: next }; void updateProject({ environmentId: selectedCheckout.environmentId, input: { projectId: selectedCheckout.id, ticketProviderBindings: next }, - }).then((result) => - reportFailure( - "Failed to update ticket provider binding", - mapAtomCommandResult(result, () => undefined), - ), - ); + }).then((result) => { + const mapped = mapAtomCommandResult(result, () => undefined); + if ( + mapped._tag === "Failure" && + ticketProviderBindingsRef.current.projectId === selectedCheckout.id && + ticketProviderBindingsRef.current.bindings === next + ) { + ticketProviderBindingsRef.current = previous; + } + reportFailure("Failed to update ticket provider binding", mapped); + }); }, [ reportFailure, @@ -1170,19 +1192,26 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { const binding = selectedTicketProviderBindings.find( (candidate) => candidate.driver === providerGroup.driver && - candidate.host.toLowerCase() === providerGroup.host, + candidate.host.toLowerCase() === providerGroup.host && + (candidate.basePath ?? "") === providerGroup.basePath, ); + const providerLocation = `${providerGroup.host}${providerGroup.basePath}`; return ( - setTicketProviderBinding(providerGroup.driver, providerGroup.host, null) + setTicketProviderBinding( + providerGroup.driver, + providerGroup.host, + providerGroup.basePath, + null, + ) } /> ) : null @@ -1194,13 +1223,14 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { setTicketProviderBinding( providerGroup.driver, providerGroup.host, + providerGroup.basePath, value === "automatic" ? null : String(value), ) } > {binding diff --git a/packages/contracts/src/ticketProvider.ts b/packages/contracts/src/ticketProvider.ts index dcb56305d79d..12395d9e2e40 100644 --- a/packages/contracts/src/ticketProvider.ts +++ b/packages/contracts/src/ticketProvider.ts @@ -78,6 +78,7 @@ export type TicketProviderInstanceConfigMap = typeof TicketProviderInstanceConfi export const TicketProviderBinding = Schema.Struct({ driver: TicketProviderDriverKind, host: TrimmedNonEmptyString, + basePath: Schema.optional(TrimmedNonEmptyString), instanceId: TicketProviderInstanceId, }); export type TicketProviderBinding = typeof TicketProviderBinding.Type; From a9767c8a25bcb6bbf645018a86a129057e025903 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Timb=C3=B3=20Pereira?= Date: Thu, 27 Aug 2026 00:21:09 -0300 Subject: [PATCH 08/19] fix(web): clarify ticket provider controls --- .../components/settings/TicketProviderSettings.tsx | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/settings/TicketProviderSettings.tsx b/apps/web/src/components/settings/TicketProviderSettings.tsx index 22318d9a2745..13f932b8e43d 100644 --- a/apps/web/src/components/settings/TicketProviderSettings.tsx +++ b/apps/web/src/components/settings/TicketProviderSettings.tsx @@ -403,6 +403,7 @@ function AddTicketProviderDialog({ setIsDefault(Boolean(checked))} /> @@ -492,7 +493,17 @@ export function TicketProviderSettings() { className="border-t" title={instance.displayName ?? option?.label ?? instance.driver} description={`${instance.baseUrl} · ${instanceId}`} - status={probe && probe !== "testing" ? probe.detail : undefined} + status={ + probe && probe !== "testing" && probe.detail ? ( + + {probe.detail} + + ) : undefined + } control={ <> } - /> - {entries.map(([instanceId, instance]) => { - const option = DRIVER_BY_KIND.get(instance.driver); - const probe = probeByInstanceId[instanceId]; - return ( - - {probe.detail} - - ) : undefined - } - control={ - <> - - - replaceInstance(instanceId, { ...instance, enabled: Boolean(checked) }) - } - /> - - - } - /> - ); - })} + > + { + if (ticketTitlePolicy) updateSettings({ ticketTitlePolicy }); + }} + /> + 0 ? ( + updateInstances(() => ({}))} + /> + ) : null + } + /> + {entries.map(([instanceId, instance]) => { + const option = DRIVER_BY_KIND.get(instance.driver); + const probe = probeByInstanceId[instanceId]; + return ( + + {probe.detail} + + ) : undefined + } + control={ + <> + + + replaceInstance(instanceId, { ...instance, enabled: Boolean(checked) }) + } + /> + + + } + /> + ); + })} + Date: Thu, 27 Aug 2026 01:20:47 -0300 Subject: [PATCH 16/19] fix: reject stale ticket provider updates --- .../SettingsTicketProvidersRouteScreen.tsx | 28 +++++++++--- apps/server/src/serverSettings.test.ts | 43 +++++++++++++++++++ apps/server/src/serverSettings.ts | 32 +++++++++++++- apps/server/src/ws.ts | 9 +++- .../settings/TicketProviderSettings.tsx | 17 +++++++- packages/contracts/src/rpc.ts | 6 ++- packages/contracts/src/settings.test.ts | 1 + packages/contracts/src/settings.ts | 6 ++- packages/shared/src/serverSettings.test.ts | 10 +++++ packages/shared/src/serverSettings.ts | 5 ++- 10 files changed, 143 insertions(+), 14 deletions(-) diff --git a/apps/mobile/src/features/settings/SettingsTicketProvidersRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsTicketProvidersRouteScreen.tsx index af0a3c6eab85..4a798fcd8966 100644 --- a/apps/mobile/src/features/settings/SettingsTicketProvidersRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsTicketProvidersRouteScreen.tsx @@ -135,6 +135,10 @@ function EnvironmentTicketProviders({ const instancesRef = useRef( settings?.ticketProviderInstances ?? DEFAULT_SERVER_SETTINGS.ticketProviderInstances, ); + const instancesRevisionRef = useRef( + settings?.ticketProviderInstancesRevision ?? + DEFAULT_SERVER_SETTINGS.ticketProviderInstancesRevision, + ); const instancesMutationQueueRef = useRef>(Promise.resolve()); const pendingInstanceMutationsRef = useRef(0); const policyRef = useRef( @@ -145,8 +149,9 @@ function EnvironmentTicketProviders({ useEffect(() => { if (settings && pendingInstanceMutationsRef.current === 0) { instancesRef.current = settings.ticketProviderInstances; + instancesRevisionRef.current = settings.ticketProviderInstancesRevision; } - }, [settings?.ticketProviderInstances]); + }, [settings?.ticketProviderInstances, settings?.ticketProviderInstancesRevision]); useEffect(() => { if (settings && pendingPolicyMutationsRef.current === 0) { policyRef.current = settings.ticketTitlePolicy; @@ -166,11 +171,17 @@ function EnvironmentTicketProviders({ ); } - const savePatch = (patch: ServerSettingsPatch) => + const savePatch = ( + patch: ServerSettingsPatch, + expectedTicketProviderInstancesRevision?: number, + ) => runAtomCommand( registry, serverEnvironment.updateSettings, - { environmentId, input: { patch } }, + { + environmentId, + input: { patch, expectedTicketProviderInstancesRevision }, + }, { label: "mobile ticket provider settings" }, ); const updatePolicy = (update: (current: TicketTitlePolicy) => TicketTitlePolicy) => { @@ -217,12 +228,19 @@ function EnvironmentTicketProviders({ const operation = instancesMutationQueueRef.current.then(async () => { const previous = instancesRef.current; const next = update(previous); + const expectedTicketProviderInstancesRevision = instancesRevisionRef.current; instancesRef.current = next; - const result = await savePatch({ ticketProviderInstances: next }); + const result = await savePatch( + { ticketProviderInstances: next }, + expectedTicketProviderInstancesRevision, + ); if (result._tag !== "Success") { instancesRef.current = previous; - setError("The provider change could not be saved. Check the connection and try again."); + setError( + "The provider change could not be saved. Review the latest accounts and try again.", + ); } else { + instancesRevisionRef.current = result.value.ticketProviderInstancesRevision; setError(null); } return result; diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index 9656c7bfe037..8053756a1901 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -1101,6 +1101,49 @@ it.layer(NodeServices.layer)("server settings", (it) => { }).pipe(Effect.provide(makeServerSettingsLayer())), ); + it.effect("rejects a stale ticket provider instance revision", () => + Effect.gen(function* () { + const serverSettings = yield* ServerSettingsModule.ServerSettingsService; + const initial = yield* serverSettings.getSettings; + const githubId = TicketProviderInstanceId.make("github_work"); + const jiraId = TicketProviderInstanceId.make("jira_work"); + + const updated = yield* serverSettings.updateSettings( + { + ticketProviderInstances: { + [githubId]: { + driver: TicketProviderDriverKind.make("github"), + baseUrl: "https://github.com", + }, + }, + }, + { expectedTicketProviderInstancesRevision: initial.ticketProviderInstancesRevision }, + ); + const conflict = yield* Effect.flip( + serverSettings.updateSettings( + { + ticketProviderInstances: { + [jiraId]: { + driver: TicketProviderDriverKind.make("jira"), + baseUrl: "https://work.atlassian.net", + }, + }, + }, + { expectedTicketProviderInstancesRevision: initial.ticketProviderInstancesRevision }, + ), + ); + + assert.equal( + updated.ticketProviderInstancesRevision, + initial.ticketProviderInstancesRevision + 1, + ); + assert.equal(conflict.operation, "compare-and-set"); + assert.deepEqual((yield* serverSettings.getSettings).ticketProviderInstances, { + [githubId]: updated.ticketProviderInstances[githubId], + }); + }).pipe(Effect.provide(makeServerSettingsLayer())), + ); + it.effect("rolls back ordinary provider secrets when a ticket secret write fails", () => { const secrets = new Map(); const failingStore = ServerSecretStore.ServerSecretStore.of({ diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index 9f643b2b38b7..d978adcdb611 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -181,6 +181,29 @@ export function redactServerSettingsForClient(settings: ServerSettings): ServerS return { ...settings, providerInstances, ticketProviderInstances }; } +export interface ServerSettingsUpdateOptions { + readonly expectedTicketProviderInstancesRevision?: number; +} + +const checkTicketProviderInstancesRevision = ( + current: ServerSettings, + patch: ServerSettingsPatch, + options: ServerSettingsUpdateOptions | undefined, + settingsPath: string, +): Effect.Effect => { + if (patch.ticketProviderInstances === undefined || options === undefined) return Effect.void; + const expected = options?.expectedTicketProviderInstancesRevision; + if (expected === current.ticketProviderInstancesRevision) return Effect.void; + return new ServerSettingsError({ + settingsPath, + operation: "compare-and-set", + cause: { + expectedTicketProviderInstancesRevision: expected, + actualTicketProviderInstancesRevision: current.ticketProviderInstancesRevision, + }, + }); +}; + export class ServerSettingsService extends Context.Service< ServerSettingsService, { @@ -196,6 +219,7 @@ export class ServerSettingsService extends Context.Service< /** Patch settings and persist. Returns the new full settings object. */ readonly updateSettings: ( patch: ServerSettingsPatch, + options?: ServerSettingsUpdateOptions, ) => Effect.Effect; /** Stream of settings change events. */ @@ -233,8 +257,11 @@ const makeTest = (overrides: DeepPartial = {}) => start: Effect.void, ready: Effect.void, getSettings: Ref.get(currentSettingsRef).pipe(Effect.map(resolveTextGenerationProvider)), - updateSettings: (patch) => + updateSettings: (patch, options) => Ref.get(currentSettingsRef).pipe( + Effect.tap((currentSettings) => + checkTicketProviderInstancesRevision(currentSettings, patch, options, ""), + ), Effect.map((currentSettings) => applyServerSettingsPatch(currentSettings, patch)), Effect.flatMap(normalizeServerSettings), Effect.tap((nextSettings) => Ref.set(currentSettingsRef, nextSettings)), @@ -904,10 +931,11 @@ const make = Effect.gen(function* () { Effect.flatMap(materializeProviderEnvironmentSecrets), Effect.map(resolveTextGenerationProvider), ), - updateSettings: (patch) => + updateSettings: (patch, options) => writeSemaphore.withPermits(1)( Effect.gen(function* () { const current = yield* getSettingsFromCache; + yield* checkTicketProviderInstancesRevision(current, patch, options, settingsPath); const nextPersisted = yield* persistProviderEnvironmentSecrets( current, applyServerSettingsPatch(current, patch), diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 8c66a8e1da7a..f856d69c80b4 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1672,11 +1672,16 @@ const makeWsRpcLayer = ( "rpc.aggregate": "server", }, ), - [WS_METHODS.serverUpdateSettings]: ({ patch }) => + [WS_METHODS.serverUpdateSettings]: ({ patch, expectedTicketProviderInstancesRevision }) => observeRpcEffect( WS_METHODS.serverUpdateSettings, serverSettings - .updateSettings(patch) + .updateSettings( + patch, + expectedTicketProviderInstancesRevision === undefined + ? {} + : { expectedTicketProviderInstancesRevision }, + ) .pipe(Effect.map(ServerSettings.redactServerSettingsForClient)), { "rpc.aggregate": "server", diff --git a/apps/web/src/components/settings/TicketProviderSettings.tsx b/apps/web/src/components/settings/TicketProviderSettings.tsx index 5b7c635060c2..6cfc1fe1e43a 100644 --- a/apps/web/src/components/settings/TicketProviderSettings.tsx +++ b/apps/web/src/components/settings/TicketProviderSettings.tsx @@ -459,13 +459,15 @@ export function TicketProviderSettings() { [settings.ticketProviderInstances], ); const instancesRef = useRef(settings.ticketProviderInstances); + const instancesRevisionRef = useRef(settings.ticketProviderInstancesRevision); const instancesMutationQueueRef = useRef>(Promise.resolve()); const pendingInstanceMutationsRef = useRef(0); useEffect(() => { if (pendingInstanceMutationsRef.current === 0) { instancesRef.current = settings.ticketProviderInstances; + instancesRevisionRef.current = settings.ticketProviderInstancesRevision; } - }, [settings.ticketProviderInstances]); + }, [settings.ticketProviderInstances, settings.ticketProviderInstancesRevision]); const updateInstances = ( update: ( @@ -477,13 +479,24 @@ export function TicketProviderSettings() { const operation = instancesMutationQueueRef.current.then(async () => { const previous = instancesRef.current; const next = update(previous); + const expectedTicketProviderInstancesRevision = instancesRevisionRef.current; instancesRef.current = next; const result = await persistProviderSettings({ environmentId, - input: { patch: { ticketProviderInstances: next } }, + input: { + patch: { ticketProviderInstances: next }, + expectedTicketProviderInstancesRevision, + }, }); if (result._tag === "Failure") { instancesRef.current = previous; + toastManager.add({ + type: "error", + title: "Ticket provider change not saved", + description: "Review the latest provider accounts and try again.", + }); + } else { + instancesRevisionRef.current = result.value.ticketProviderInstancesRevision; } }); instancesMutationQueueRef.current = operation.then( diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 04feca0a0b6d..76d56d55d4c9 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -2,6 +2,7 @@ import * as Schema from "effect/Schema"; import * as Rpc from "effect/unstable/rpc/Rpc"; import * as RpcGroup from "effect/unstable/rpc/RpcGroup"; +import { NonNegativeInt } from "./baseSchemas.ts"; import { ExternalLauncherError, LaunchEditorInput } from "./editor.ts"; import { AuthAccessStreamError, @@ -402,7 +403,10 @@ export const WsServerGetSettingsRpc = Rpc.make(WS_METHODS.serverGetSettings, { }); export const WsServerUpdateSettingsRpc = Rpc.make(WS_METHODS.serverUpdateSettings, { - payload: Schema.Struct({ patch: ServerSettingsPatch }), + payload: Schema.Struct({ + patch: ServerSettingsPatch, + expectedTicketProviderInstancesRevision: Schema.optionalKey(NonNegativeInt), + }), success: ServerSettings, error: Schema.Union([ServerSettingsError, EnvironmentAuthorizationError]), }); diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 16c15e18fe2e..092851a8c74b 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -421,6 +421,7 @@ describe("ServerSettings ticket title configuration", () => { customTemplate: "{identifier} — {title}", }); expect(settings.ticketProviderInstances).toEqual({}); + expect(settings.ticketProviderInstancesRevision).toBe(0); }); it("preserves open ticket driver slugs and whole-map instance patches", () => { diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index e4ca463187dc..2bbe88b8e861 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -2,7 +2,7 @@ import * as Effect from "effect/Effect"; import * as Duration from "effect/Duration"; import * as Schema from "effect/Schema"; import * as SchemaTransformation from "effect/SchemaTransformation"; -import { TrimmedNonEmptyString, TrimmedString } from "./baseSchemas.ts"; +import { NonNegativeInt, TrimmedNonEmptyString, TrimmedString } from "./baseSchemas.ts"; import { ThreadEnvMode } from "./environment.ts"; import { DEFAULT_TEXT_GENERATION_MODEL, @@ -690,6 +690,9 @@ export const ServerSettings = Schema.Struct({ TicketProviderInstanceId, TicketProviderInstanceConfig, ).pipe(Schema.withDecodingDefault(Effect.succeed({}))), + ticketProviderInstancesRevision: NonNegativeInt.pipe( + Schema.withDecodingDefault(Effect.succeed(0)), + ), // Legacy single-instance-per-driver settings. Continues to be the source // of truth until `providerInstances` (below) lands per-driver migration @@ -764,6 +767,7 @@ export const resolveProviderInstanceEnabled = ( export const ServerSettingsOperation = Schema.Literals([ "normalize", + "compare-and-set", "check-exists", "read-file", "read-provider-history", diff --git a/packages/shared/src/serverSettings.test.ts b/packages/shared/src/serverSettings.test.ts index baa84a4e1aa8..3fcdefdbfa95 100644 --- a/packages/shared/src/serverSettings.test.ts +++ b/packages/shared/src/serverSettings.test.ts @@ -298,6 +298,16 @@ describe("serverSettings helpers", () => { }); }); + it("increments the ticket provider instance revision on whole-map replacement", () => { + const next = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { + ticketProviderInstances: {}, + }); + + expect(next.ticketProviderInstancesRevision).toBe( + DEFAULT_SERVER_SETTINGS.ticketProviderInstancesRevision + 1, + ); + }); + it("stores background activity profiles as a versioned object and syncs legacy aliases", () => { const next = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { backgroundActivity: { diff --git a/packages/shared/src/serverSettings.ts b/packages/shared/src/serverSettings.ts index 6b0e99c8f8aa..4d0d688d3fc2 100644 --- a/packages/shared/src/serverSettings.ts +++ b/packages/shared/src/serverSettings.ts @@ -189,7 +189,10 @@ export function applyServerSettingsPatch( ? { providerInstances: patch.providerInstances } : {}), ...(patch.ticketProviderInstances !== undefined - ? { ticketProviderInstances: patch.ticketProviderInstances } + ? { + ticketProviderInstances: patch.ticketProviderInstances, + ticketProviderInstancesRevision: current.ticketProviderInstancesRevision + 1, + } : {}), ...(patch.sourceControlWriterModelSelection !== undefined ? { sourceControlWriterModelSelection: patch.sourceControlWriterModelSelection } From a92d5fe7cddfc10b6c33797f44c23b0564fda989 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Timb=C3=B3=20Pereira?= Date: Thu, 27 Aug 2026 01:28:43 -0300 Subject: [PATCH 17/19] fix: reconcile ticket provider conflicts --- .../SettingsTicketProvidersRouteScreen.tsx | 34 +++++++++----- apps/server/src/serverSettings.test.ts | 9 ++++ apps/server/src/serverSettings.ts | 1 + .../src/ticket/TicketProviderRegistry.test.ts | 45 +++++++++++++++++++ .../src/ticket/TicketProviderRegistry.ts | 11 ++++- apps/server/src/ws.ts | 2 +- .../settings/TicketProviderSettings.tsx | 27 ++++++----- 7 files changed, 102 insertions(+), 27 deletions(-) diff --git a/apps/mobile/src/features/settings/SettingsTicketProvidersRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsTicketProvidersRouteScreen.tsx index 4a798fcd8966..b27466c06843 100644 --- a/apps/mobile/src/features/settings/SettingsTicketProvidersRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsTicketProvidersRouteScreen.tsx @@ -139,6 +139,17 @@ function EnvironmentTicketProviders({ settings?.ticketProviderInstancesRevision ?? DEFAULT_SERVER_SETTINGS.ticketProviderInstancesRevision, ); + const projectedInstancesRef = useRef( + settings?.ticketProviderInstances ?? DEFAULT_SERVER_SETTINGS.ticketProviderInstances, + ); + const projectedInstancesRevisionRef = useRef( + settings?.ticketProviderInstancesRevision ?? + DEFAULT_SERVER_SETTINGS.ticketProviderInstancesRevision, + ); + if (settings) { + projectedInstancesRef.current = settings.ticketProviderInstances; + projectedInstancesRevisionRef.current = settings.ticketProviderInstancesRevision; + } const instancesMutationQueueRef = useRef>(Promise.resolve()); const pendingInstanceMutationsRef = useRef(0); const policyRef = useRef( @@ -146,11 +157,13 @@ function EnvironmentTicketProviders({ ); const policyMutationQueueRef = useRef>(Promise.resolve()); const pendingPolicyMutationsRef = useRef(0); + const syncProjectedInstances = () => { + if (projectedInstancesRevisionRef.current < instancesRevisionRef.current) return; + instancesRef.current = projectedInstancesRef.current; + instancesRevisionRef.current = projectedInstancesRevisionRef.current; + }; useEffect(() => { - if (settings && pendingInstanceMutationsRef.current === 0) { - instancesRef.current = settings.ticketProviderInstances; - instancesRevisionRef.current = settings.ticketProviderInstancesRevision; - } + if (settings && pendingInstanceMutationsRef.current === 0) syncProjectedInstances(); }, [settings?.ticketProviderInstances, settings?.ticketProviderInstancesRevision]); useEffect(() => { if (settings && pendingPolicyMutationsRef.current === 0) { @@ -245,14 +258,11 @@ function EnvironmentTicketProviders({ } return result; }); - instancesMutationQueueRef.current = operation.then( - () => { - pendingInstanceMutationsRef.current -= 1; - }, - () => { - pendingInstanceMutationsRef.current -= 1; - }, - ); + const finish = () => { + pendingInstanceMutationsRef.current -= 1; + if (pendingInstanceMutationsRef.current === 0) syncProjectedInstances(); + }; + instancesMutationQueueRef.current = operation.then(finish, finish); return operation; }; const instances = Object.entries(settings.ticketProviderInstances); diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index 8053756a1901..b40e954192c1 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -1144,6 +1144,15 @@ it.layer(NodeServices.layer)("server settings", (it) => { }).pipe(Effect.provide(makeServerSettingsLayer())), ); + it.effect("allows an unguarded ticket provider update when the revision is omitted", () => + Effect.gen(function* () { + const serverSettings = yield* ServerSettingsModule.ServerSettingsService; + const next = yield* serverSettings.updateSettings({ ticketProviderInstances: {} }, {}); + + assert.equal(next.ticketProviderInstancesRevision, 1); + }).pipe(Effect.provide(makeServerSettingsLayer())), + ); + it.effect("rolls back ordinary provider secrets when a ticket secret write fails", () => { const secrets = new Map(); const failingStore = ServerSecretStore.ServerSecretStore.of({ diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index d978adcdb611..3e239bf2fe27 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -193,6 +193,7 @@ const checkTicketProviderInstancesRevision = ( ): Effect.Effect => { if (patch.ticketProviderInstances === undefined || options === undefined) return Effect.void; const expected = options?.expectedTicketProviderInstancesRevision; + if (expected === undefined) return Effect.void; if (expected === current.ticketProviderInstancesRevision) return Effect.void; return new ServerSettingsError({ settingsPath, diff --git a/apps/server/src/ticket/TicketProviderRegistry.test.ts b/apps/server/src/ticket/TicketProviderRegistry.test.ts index 8d39e170235a..59c8082edb8a 100644 --- a/apps/server/src/ticket/TicketProviderRegistry.test.ts +++ b/apps/server/src/ticket/TicketProviderRegistry.test.ts @@ -183,6 +183,51 @@ describe("TicketProviderRegistry", () => { }), ); + effectIt.effect("selects the most specific default for overlapping base paths", () => + Effect.gen(function* () { + const run = vi.fn((input) => + Effect.succeed( + commandOutput( + JSON.stringify({ + title: input.env?.GITLAB_TOKEN === "tenant-token" ? "Tenant issue" : "Root issue", + }), + ), + ), + ); + const registry = yield* makeRegistry(run); + const reference = { + driver: TicketProviderDriverKind.make("gitlab"), + host: "git.example.com", + url: "https://git.example.com/tenant/acme/widgets/-/issues/12", + identifier: "acme/widgets#12", + project: "acme/widgets", + resourceId: "12", + } as const; + + const result = yield* registry.resolve({ + cwd: "/tmp/project", + reference, + instances: { + [TicketProviderInstanceId.make("gitlab_root")]: { + driver: TicketProviderDriverKind.make("gitlab"), + baseUrl: "https://git.example.com", + isDefault: true, + environment: [{ name: "GITLAB_TOKEN", value: "root-token", sensitive: true }], + }, + [TicketProviderInstanceId.make("gitlab_tenant")]: { + driver: TicketProviderDriverKind.make("gitlab"), + baseUrl: "https://git.example.com/tenant", + isDefault: true, + environment: [{ name: "GITLAB_TOKEN", value: "tenant-token", sensitive: true }], + }, + }, + bindings: [], + }); + + expect(result.title).toBe("Tenant issue"); + }), + ); + effectIt.effect("uses the single compatible local CLI when no instance is configured", () => Effect.gen(function* () { const run = vi.fn(() => diff --git a/apps/server/src/ticket/TicketProviderRegistry.ts b/apps/server/src/ticket/TicketProviderRegistry.ts index 68d7614ee333..0f6c4f1d0d29 100644 --- a/apps/server/src/ticket/TicketProviderRegistry.ts +++ b/apps/server/src/ticket/TicketProviderRegistry.ts @@ -202,8 +202,15 @@ function selectInstance(input: TicketProviderResolveInput): const [instanceId, instance] = implicitInstance(input.reference); return { _tag: "Selected", instanceId, instance }; } - const defaults = matching.filter(([, instance]) => instance.isDefault === true); - const candidates = defaults.length > 0 ? defaults : matching; + const maxBasePathLength = Math.max( + ...matching.map(([, instance]) => normalizedBaseUrl(instance.baseUrl)?.pathname.length ?? 0), + ); + const mostSpecific = matching.filter( + ([, instance]) => + (normalizedBaseUrl(instance.baseUrl)?.pathname.length ?? 0) === maxBasePathLength, + ); + const defaults = mostSpecific.filter(([, instance]) => instance.isDefault === true); + const candidates = defaults.length > 0 ? defaults : mostSpecific; if (candidates.length !== 1) return { _tag: "Ambiguous" }; const [instanceId, instance] = candidates[0]!; return { _tag: "Selected", instanceId, instance }; diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index f856d69c80b4..a17313924241 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1679,7 +1679,7 @@ const makeWsRpcLayer = ( .updateSettings( patch, expectedTicketProviderInstancesRevision === undefined - ? {} + ? undefined : { expectedTicketProviderInstancesRevision }, ) .pipe(Effect.map(ServerSettings.redactServerSettingsForClient)), diff --git a/apps/web/src/components/settings/TicketProviderSettings.tsx b/apps/web/src/components/settings/TicketProviderSettings.tsx index 6cfc1fe1e43a..f7a376d4a375 100644 --- a/apps/web/src/components/settings/TicketProviderSettings.tsx +++ b/apps/web/src/components/settings/TicketProviderSettings.tsx @@ -460,13 +460,19 @@ export function TicketProviderSettings() { ); const instancesRef = useRef(settings.ticketProviderInstances); const instancesRevisionRef = useRef(settings.ticketProviderInstancesRevision); + const projectedInstancesRef = useRef(settings.ticketProviderInstances); + const projectedInstancesRevisionRef = useRef(settings.ticketProviderInstancesRevision); + projectedInstancesRef.current = settings.ticketProviderInstances; + projectedInstancesRevisionRef.current = settings.ticketProviderInstancesRevision; const instancesMutationQueueRef = useRef>(Promise.resolve()); const pendingInstanceMutationsRef = useRef(0); + const syncProjectedInstances = () => { + if (projectedInstancesRevisionRef.current < instancesRevisionRef.current) return; + instancesRef.current = projectedInstancesRef.current; + instancesRevisionRef.current = projectedInstancesRevisionRef.current; + }; useEffect(() => { - if (pendingInstanceMutationsRef.current === 0) { - instancesRef.current = settings.ticketProviderInstances; - instancesRevisionRef.current = settings.ticketProviderInstancesRevision; - } + if (pendingInstanceMutationsRef.current === 0) syncProjectedInstances(); }, [settings.ticketProviderInstances, settings.ticketProviderInstancesRevision]); const updateInstances = ( @@ -499,14 +505,11 @@ export function TicketProviderSettings() { instancesRevisionRef.current = result.value.ticketProviderInstancesRevision; } }); - instancesMutationQueueRef.current = operation.then( - () => { - pendingInstanceMutationsRef.current -= 1; - }, - () => { - pendingInstanceMutationsRef.current -= 1; - }, - ); + const finish = () => { + pendingInstanceMutationsRef.current -= 1; + if (pendingInstanceMutationsRef.current === 0) syncProjectedInstances(); + }; + instancesMutationQueueRef.current = operation.then(finish, finish); }; const replaceInstance = (instanceId: string, instance: TicketProviderInstanceConfig) => { From 628c7b3076ac6770d73c2205d7ea04a22881aaf8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Timb=C3=B3=20Pereira?= Date: Thu, 27 Aug 2026 01:43:16 -0300 Subject: [PATCH 18/19] fix(web): harden ticket provider settings --- .../settings/IntegrationsSettings.tsx | 4 +- .../settings/TicketProviderSettings.tsx | 38 ++++++++----------- 2 files changed, 18 insertions(+), 24 deletions(-) diff --git a/apps/web/src/components/settings/IntegrationsSettings.tsx b/apps/web/src/components/settings/IntegrationsSettings.tsx index c51de38af552..f238c638fc01 100644 --- a/apps/web/src/components/settings/IntegrationsSettings.tsx +++ b/apps/web/src/components/settings/IntegrationsSettings.tsx @@ -26,6 +26,7 @@ import type { ReactNode } from "react"; import { ScreenRotationIcon } from "~/browser/ScreenRotationIcon"; import { isElectron } from "../../env"; +import { usePrimaryEnvironmentId } from "../../state/environments"; import { Button } from "../ui/button"; import { NumberField, NumberFieldGroup, NumberFieldInput } from "../ui/number-field"; @@ -455,6 +456,7 @@ function DesktopOnlyBrowserDefaults({ children }: { readonly children: ReactNode } export function IntegrationsSettingsPanel() { + const primaryEnvironmentId = usePrimaryEnvironmentId(); // Client-local preview defaults are editable only where the preview exists. const previewDefaultsDisabled = !isElectron; const previewDefaults = ( @@ -478,7 +480,7 @@ export function IntegrationsSettingsPanel() { previewDefaults )} - + ); } diff --git a/apps/web/src/components/settings/TicketProviderSettings.tsx b/apps/web/src/components/settings/TicketProviderSettings.tsx index f7a376d4a375..6f4424083749 100644 --- a/apps/web/src/components/settings/TicketProviderSettings.tsx +++ b/apps/web/src/components/settings/TicketProviderSettings.tsx @@ -30,7 +30,7 @@ import { Input } from "../ui/input"; import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; import { Switch } from "../ui/switch"; import { toastManager } from "../ui/toast"; -import { SettingResetButton, SettingsRow, SettingsSection } from "./settingsLayout"; +import { SettingsRow, SettingsSection } from "./settingsLayout"; import { searchableSetting } from "./settingsSearch"; interface TicketDriverOption { @@ -411,19 +411,19 @@ function AddTicketProviderDialog({ /> ) : null} - + setIsDefault(Boolean(checked))} + /> + + + Used when a project has no explicit account binding. + + {error ?

{error}

: null} @@ -462,10 +462,10 @@ export function TicketProviderSettings() { const instancesRevisionRef = useRef(settings.ticketProviderInstancesRevision); const projectedInstancesRef = useRef(settings.ticketProviderInstances); const projectedInstancesRevisionRef = useRef(settings.ticketProviderInstancesRevision); - projectedInstancesRef.current = settings.ticketProviderInstances; - projectedInstancesRevisionRef.current = settings.ticketProviderInstancesRevision; const instancesMutationQueueRef = useRef>(Promise.resolve()); const pendingInstanceMutationsRef = useRef(0); + projectedInstancesRef.current = settings.ticketProviderInstances; + projectedInstancesRevisionRef.current = settings.ticketProviderInstancesRevision; const syncProjectedInstances = () => { if (projectedInstancesRevisionRef.current < instancesRevisionRef.current) return; instancesRef.current = projectedInstancesRef.current; @@ -538,14 +538,6 @@ export function TicketProviderSettings() { 0 ? ( - updateInstances(() => ({}))} - /> - ) : null - } /> {entries.map(([instanceId, instance]) => { const option = DRIVER_BY_KIND.get(instance.driver); From 1ea3f7afd9eb1bdd3e67e01723f4be4c716b3ba6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Timb=C3=B3=20Pereira?= Date: Thu, 27 Aug 2026 01:58:34 -0300 Subject: [PATCH 19/19] fix(web): soften ticket account dividers --- apps/web/src/components/settings/TicketProviderSettings.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/settings/TicketProviderSettings.tsx b/apps/web/src/components/settings/TicketProviderSettings.tsx index 6f4424083749..1c398729a20d 100644 --- a/apps/web/src/components/settings/TicketProviderSettings.tsx +++ b/apps/web/src/components/settings/TicketProviderSettings.tsx @@ -545,7 +545,7 @@ export function TicketProviderSettings() { return (