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" }, +]; + +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; + 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 [templateDraft, setTemplateDraft] = useState( + settings?.ticketTitlePolicy.customTemplate ?? + DEFAULT_SERVER_SETTINGS.ticketTitlePolicy.customTemplate, + ); + const [templateError, setTemplateError] = useState(null); + const templateSaveGenerationRef = useRef(0); + const [isSaving, setIsSaving] = useState(false); + const [probeByInstanceId, setProbeByInstanceId] = useState< + Readonly> + >({}); + const instancesRef = useRef( + settings?.ticketProviderInstances ?? DEFAULT_SERVER_SETTINGS.ticketProviderInstances, + ); + const instancesRevisionRef = useRef( + 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( + settings?.ticketTitlePolicy ?? DEFAULT_SERVER_SETTINGS.ticketTitlePolicy, + ); + 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) syncProjectedInstances(); + }, [settings?.ticketProviderInstances, settings?.ticketProviderInstancesRevision]); + useEffect(() => { + if (settings && pendingPolicyMutationsRef.current === 0) { + policyRef.current = settings.ticketTitlePolicy; + templateSaveGenerationRef.current += 1; + setTemplateDraft(settings.ticketTitlePolicy.customTemplate); + setTemplateError(null); + } + }, [settings?.ticketTitlePolicy.customTemplate, settings?.ticketTitlePolicy.mode]); + + if (!settings) { + return ( + + + Connect this environment to configure ticket providers. + + + ); + } + + const savePatch = ( + patch: ServerSettingsPatch, + expectedTicketProviderInstancesRevision?: number, + ) => + runAtomCommand( + registry, + serverEnvironment.updateSettings, + { + environmentId, + input: { patch, expectedTicketProviderInstancesRevision }, + }, + { label: "mobile ticket provider settings" }, + ); + const updatePolicy = (update: (current: TicketTitlePolicy) => TicketTitlePolicy) => { + pendingPolicyMutationsRef.current += 1; + const operation = policyMutationQueueRef.current.then(async () => { + const previous = policyRef.current; + const next = update(previous); + policyRef.current = next; + const result = await savePatch({ ticketTitlePolicy: next }); + if (result._tag !== "Success") policyRef.current = previous; + return result; + }); + policyMutationQueueRef.current = operation.then( + () => { + pendingPolicyMutationsRef.current -= 1; + }, + () => { + pendingPolicyMutationsRef.current -= 1; + }, + ); + return operation; + }; + const saveTemplate = (customTemplate: string) => { + const generation = ++templateSaveGenerationRef.current; + setTemplateDraft(customTemplate); + if (customTemplate === policyRef.current.customTemplate) return; + const ticketTitlePolicy = { ...policyRef.current, customTemplate }; + if (!renderTicketThreadTitle(ticketTitlePolicy, TEMPLATE_PREVIEW_METADATA)) { + setTemplateError("The template contains an unsupported variable."); + return; + } + setTemplateError(null); + void updatePolicy((current) => ({ ...current, customTemplate })).then((result) => { + if (generation !== templateSaveGenerationRef.current) return; + if (result._tag !== "Success") { + setTemplateError("The template could not be saved. The previous template is still active."); + } + }); + }; + const updateInstances = ( + update: (current: TicketProviderInstanceConfigMap) => TicketProviderInstanceConfigMap, + ) => { + pendingInstanceMutationsRef.current += 1; + 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 }, + expectedTicketProviderInstancesRevision, + ); + if (result._tag !== "Success") { + instancesRef.current = previous; + 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; + }); + const finish = () => { + pendingInstanceMutationsRef.current -= 1; + if (pendingInstanceMutationsRef.current === 0) syncProjectedInstances(); + }; + instancesMutationQueueRef.current = operation.then(finish, finish); + return operation; + }; + 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, + serverEnvironment.probeTicketProvider, + { + environmentId, + input: { instanceId: TicketProviderInstanceId.make(instanceId) }, + }, + { label: "mobile ticket provider connection test" }, + ).then((result) => { + if (JSON.stringify(instancesRef.current[brandedInstanceId]) !== instanceSignature) return; + 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 = async () => { + 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 (parsed.username || parsed.password) { + 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; + } + const name = displayName.trim() || driver.label; + const baseId = `${driver.driver}_${slugify(`${name}_${parsed.host}`)}`.slice(0, 60); + let id = baseId; + let suffix = 2; + while (instancesRef.current[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 normalizedBaseUrl = parsed.toString().replace(/\/$/u, ""); + const normalizedBasePath = ticketProviderBasePath(normalizedBaseUrl); + const instance: TicketProviderInstanceConfig = { + driver: TicketProviderDriverKind.make(driver.driver), + displayName: name, + baseUrl: normalizedBaseUrl, + 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() && + ticketProviderBasePath(candidate.baseUrl) === normalizedBasePath && + candidate.isDefault === true, + ), + ...(identityConfig ? { config: identityConfig } : {}), + ...(driver.secret && secret.trim() + ? { + environment: [{ name: driver.secret, value: secret.trim(), sensitive: true }], + } + : {}), + }; + 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() && + ticketProviderBasePath(candidate.baseUrl) === normalizedBasePath + ) { + next[TicketProviderInstanceId.make(candidateId)] = { + ...candidate, + isDefault: false, + }; + } + } + } + next[addedInstanceId] = instance; + return next; + }); + setIsSaving(false); + if (result._tag !== "Success") { + setError("The provider could not be saved. Check the connection and try again."); + return; + } + setSecret(""); + setIdentity(""); + setDisplayName(""); + setShowAdd(false); + setError(null); + }; + + return ( + + + {MODE_OPTIONS.map((option, index) => ( + void updatePolicy((current) => ({ ...current, mode: option.mode }))} + > + {option.label} + {settings.ticketTitlePolicy.mode === option.mode ? ( + + ) : null} + + ))} + {settings.ticketTitlePolicy.mode === "custom" ? ( + + Template + saveTemplate(templateDraft)} + /> + {templateError ? ( + {templateError} + ) : null} + + ) : null} + + + + {instances.map(([instanceId, instance], index) => { + const probe = probeByInstanceId[instanceId]; + return ( + + + + + {instance.displayName ?? instanceId} + + + {instance.baseUrl} + + + { + 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" + > + + + + + + {probe && probe !== "testing" + ? 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"} + + + probeProvider(instanceId)} + className="rounded-full border border-border px-3 py-2 disabled:opacity-50" + > + + {probe === "testing" ? "Testing…" : "Test"} + + + { + const host = new URL(instance.baseUrl).host.toLowerCase(); + const basePath = ticketProviderBasePath(instance.baseUrl); + void updateInstances((current) => + Object.fromEntries( + Object.entries(current).map(([id, candidate]) => [ + id, + candidate.driver === instance.driver && + new URL(candidate.baseUrl).host.toLowerCase() === host && + ticketProviderBasePath(candidate.baseUrl) === basePath + ? { ...candidate, isDefault: id === instanceId } + : candidate, + ]), + ), + ); + }} + className="rounded-full border border-border px-3 py-2 disabled:opacity-50" + > + + {instance.isDefault ? "Default" : "Make default"} + + + + + ); + })} + 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} + void addProvider()} + className="items-center rounded-[14px] bg-foreground px-4 py-3 disabled:opacity-50" + > + + {isSaving ? "Saving…" : "Save provider"} + + + + ) : null} + {error ? ( + + {error} + + ) : 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 inheritedPolicy = settings?.ticketTitlePolicy ?? DEFAULT_SERVER_SETTINGS.ticketTitlePolicy; + const effectivePolicy = storedPolicy ?? inheritedPolicy; + const selectedMode = storedPolicy?.mode ?? "inherit"; + const [templateDraft, setTemplateDraft] = useState(effectivePolicy.customTemplate); + const [templateError, setTemplateError] = useState(null); + const templateSaveGenerationRef = useRef(0); + const policyRef = useRef(storedPolicy); + const policyMutationQueueRef = useRef>(Promise.resolve()); + const pendingPolicyMutationsRef = useRef(0); + useEffect(() => { + if (pendingPolicyMutationsRef.current === 0) policyRef.current = storedPolicy; + }, [storedPolicy?.customTemplate, storedPolicy?.mode]); + useEffect(() => { + if (pendingPolicyMutationsRef.current === 0) { + templateSaveGenerationRef.current += 1; + setTemplateDraft(effectivePolicy.customTemplate); + setTemplateError(null); + } + }, [effectivePolicy.customTemplate]); + const bindings = project.ticketProviderBindings ?? []; + const providerGroups = new Map< + string, + { + readonly driver: string; + readonly host: string; + readonly basePath: 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 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 { + // Invalid persisted URLs are ignored here; the server schema reports them on write. + } + } + for (const binding of bindings) { + const host = binding.host.toLowerCase(); + const basePath = binding.basePath ?? ""; + const key = `${binding.driver}:${host}:${basePath}`; + if (!providerGroups.has(key)) { + providerGroups.set(key, { driver: binding.driver, host, basePath, instances: [] }); + } + } + + const bindingsRef = useRef(bindings); + const confirmedBindingsRef = useRef(bindings); + const bindingMutationQueueRef = useRef>(Promise.resolve()); + const pendingBindingMutationsRef = useRef(0); + useEffect(() => { + if (pendingBindingMutationsRef.current === 0) { + bindingsRef.current = bindings; + confirmedBindingsRef.current = bindings; + } + }, [bindings]); + + const updateProject = (input: { + readonly ticketTitlePolicy?: EnvironmentProject["ticketTitlePolicy"]; + readonly ticketProviderBindings?: TicketProviderBindings; + }) => { + return runAtomCommand( + registry, + projectEnvironment.update, + { environmentId: project.environmentId, input: { projectId: project.id, ...input } }, + { label: "mobile project ticket title settings" }, + ); + }; + const updatePolicy = ( + update: (current: TicketTitlePolicy | null) => TicketTitlePolicy | null, + ) => { + pendingPolicyMutationsRef.current += 1; + const operation = policyMutationQueueRef.current.then(async () => { + const previous = policyRef.current; + const next = update(previous); + policyRef.current = next; + const result = await updateProject({ ticketTitlePolicy: next }); + if (result._tag !== "Success") policyRef.current = previous; + return result; + }); + policyMutationQueueRef.current = operation.then( + () => { + pendingPolicyMutationsRef.current -= 1; + }, + () => { + pendingPolicyMutationsRef.current -= 1; + }, + ); + return operation; + }; + const updateBinding = ( + driver: string, + host: string, + basePath: string, + instanceId: string | null, + ) => { + const remaining = bindingsRef.current.filter( + (binding) => + !( + binding.driver === driver && + binding.host.toLowerCase() === host && + (binding.basePath ?? "") === basePath + ), + ); + const next: TicketProviderBindings = instanceId + ? [ + ...remaining, + { + driver: TicketProviderDriverKind.make(driver), + host, + ...(basePath ? { basePath } : {}), + instanceId: TicketProviderInstanceId.make(instanceId), + }, + ] + : remaining; + bindingsRef.current = next; + pendingBindingMutationsRef.current += 1; + const operation = bindingMutationQueueRef.current.then(async () => { + const result = await updateProject({ ticketProviderBindings: next }); + if (result._tag === "Success") { + confirmedBindingsRef.current = next; + } else if (bindingsRef.current === next) { + bindingsRef.current = confirmedBindingsRef.current; + } + }); + bindingMutationQueueRef.current = operation.then( + () => { + pendingBindingMutationsRef.current -= 1; + }, + () => { + pendingBindingMutationsRef.current -= 1; + }, + ); + }; + + const saveProjectTemplate = (customTemplate: string) => { + const generation = ++templateSaveGenerationRef.current; + setTemplateDraft(customTemplate); + const currentPolicy = policyRef.current ?? inheritedPolicy; + if (currentPolicy.mode === "custom" && customTemplate === currentPolicy.customTemplate) return; + const ticketTitlePolicy = { ...currentPolicy, mode: "custom" as const, customTemplate }; + if (!renderTicketThreadTitle(ticketTitlePolicy, TEMPLATE_PREVIEW_METADATA)) { + setTemplateError("The template contains an unsupported variable."); + return; + } + setTemplateError(null); + void updatePolicy((current) => ({ + ...(current ?? inheritedPolicy), + mode: "custom", + customTemplate, + })).then((result) => { + if (generation !== templateSaveGenerationRef.current) return; + if (result._tag !== "Success") { + setTemplateError( + "The project template could not be saved. The previous template is still active.", + ); + } + }); + }; + + return ( + + {[ + { + mode: "inherit", + label: `Environment default (${MODE_OPTIONS.find((option) => option.mode === effectivePolicy.mode)?.label ?? "Identifier and title"})`, + }, + ...MODE_OPTIONS, + ].map((option, index) => ( + + void updatePolicy((current) => + option.mode === "inherit" + ? null + : { + ...(current ?? inheritedPolicy), + mode: option.mode as TicketTitleMode, + }, + ) + } + > + {option.label} + {selectedMode === option.mode ? ( + + ) : null} + + ))} + {storedPolicy?.mode === "custom" ? ( + + Template + 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.basePath ?? "") === group.basePath, + ); + const providerLocation = `${group.host}${group.basePath}`; + const choices = [ + { id: "automatic", label: `${providerLocation}: environment default` }, + ...group.instances.map(([instanceId, instance]) => ({ + id: instanceId, + label: `${providerLocation}: ${instance.displayName ?? instanceId}`, + })), + ]; + return choices.map((choice) => ( + + updateBinding( + group.driver, + group.host, + group.basePath, + 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..ce28d1e40465 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 } : {}), @@ -629,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, @@ -818,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 30892c760e77..03d4d0c2cd1c 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", @@ -298,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", @@ -405,6 +408,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..2fb72c921d14 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", @@ -419,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", @@ -457,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", @@ -497,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", @@ -861,6 +872,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 +898,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", @@ -941,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", @@ -1688,6 +1704,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, @@ -1699,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, @@ -1823,6 +1842,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, @@ -1910,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, @@ -2328,6 +2350,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, @@ -2627,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 a22a7acfb705..9294623ae20b 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,257 @@ 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 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-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(() => 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(); + return ( + readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"))?.title === + "acme/widgets#12 — Fix reconnect failures" + ); + }), + ); + + 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( + seededTitle, + ); + }), + ); + + 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..802bfd74a50d 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,12 @@ const make = Effect.gen(function* () { readonly messageText: string; readonly attachments?: ReadonlyArray; readonly titleSeed?: string; + readonly generatedTitle: Deferred.Deferred; + readonly expectedTitle: string; + readonly expectedTitleRevision: number; }) { const attachments = input.attachments ?? []; - yield* Effect.gen(function* () { + return yield* Effect.gen(function* () { const { textGenerationModelSelection: modelSelection } = yield* serverSettingsService.getSettings; @@ -909,30 +920,145 @@ const make = Effect.gen(function* () { ...(attachments.length > 0 ? { attachments } : {}), modelSelection, }); - if (!generated) return; - - const thread = yield* resolveThread(input.threadId); - if (!thread) return; - if (!canReplaceThreadTitle(thread.title, input.titleSeed)) { - return; + 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: input.expectedTitle, + expectedTitleRevision: input.expectedTitleRevision, }); + yield* Deferred.succeed(input.generatedTitle, commandId); + 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 maybeResolveTicketTitleForFirstTurn = Effect.fn("maybeResolveTicketTitleForFirstTurn")( + function* (input: { + readonly threadId: ThreadId; + readonly cwd: string; + readonly messageText: string; + readonly titleSeed?: string; + readonly generatedTitle: Deferred.Deferred; + readonly initialSequence: number; + }) { + 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 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 ( + 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( + 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 +1282,24 @@ const make = Effect.gen(function* () { }).pipe(Effect.forkScoped); if (canReplaceThreadTitle(thread.title, event.payload.titleSeed)) { + 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({ + threadId: event.payload.threadId, + cwd: generationCwd, + messageText: message.text, + ...(event.payload.titleSeed !== undefined ? { titleSeed: event.payload.titleSeed } : {}), + generatedTitle, + initialSequence, + }).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..e614c02eb660 100644 --- a/apps/server/src/orchestration/decider.titleRegeneration.test.ts +++ b/apps/server/src/orchestration/decider.titleRegeneration.test.ts @@ -69,4 +69,47 @@ 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(); + } + }), + ); + + 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 f3fdd462f437..87170d670213 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,13 @@ 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) || + (command.expectedTitleRevision !== undefined && + (thread.titleRevision ?? 0) !== command.expectedTitleRevision)) + ? undefined + : command.title; const occurredAt = yield* nowIso; return { ...(yield* withEventBase({ @@ -828,7 +841,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 +852,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.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 1c4cd65d5123..9bc5130a54c3 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 } : {}), @@ -292,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, @@ -459,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/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/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 8abbe87fce3e..5d9731152403 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -56,6 +56,8 @@ 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"; +import Migration0045 from "./Migrations/045_ProjectionThreadTitleRevision.ts"; /** * Migration loader with all migrations defined inline. @@ -111,6 +113,8 @@ export const migrationEntries = [ [41, "AuthSessionClientConnection", Migration0041], [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/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/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/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/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/server.ts b/apps/server/src/server.ts index d5bebe3d5000..c7de7338c29a 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 * 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"; @@ -244,10 +245,19 @@ const PlatformServicesLive = Layer.unwrap( }), ); +const TicketProviderRegistryLive = TicketProviderRegistry.layer.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..b40e954192c1 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,249 @@ 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())), + ); + + 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("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({ + 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 5a8650b7e405..3e239bf2fe27 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,9 +167,44 @@ 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 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 === undefined) return Effect.void; + 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, { @@ -177,6 +220,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. */ @@ -214,8 +258,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)), @@ -473,14 +520,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 +539,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,49 +557,97 @@ 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"], }; }); 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), ); - 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 +702,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,11 +724,113 @@ const make = Effect.gen(function* () { ); } } + return persisted; + }); - return { - ...next, - providerInstances: providerInstances as ServerSettings["providerInstances"], + const persistProviderEnvironmentSecrets = ( + current: ServerSettings, + next: ServerSettings, + ): Effect.Effect => + Effect.gen(function* () { + 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, + ); + 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, + }), + ), + ); + 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( @@ -735,10 +932,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/ticket/TicketProviderRegistry.test.ts b/apps/server/src/ticket/TicketProviderRegistry.test.ts new file mode 100644 index 000000000000..59c8082edb8a --- /dev/null +++ b/apps/server/src/ticket/TicketProviderRegistry.test.ts @@ -0,0 +1,578 @@ +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 * as TestClock from "effect/testing/TestClock"; +import { HttpClient, HttpClientResponse } 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"], + httpClient: HttpClient.HttpClient = { + execute: () => Effect.die("HTTP should not run"), + } as never, +) { + return make.pipe( + Effect.provideService(VcsProcess.VcsProcess, { run }), + Effect.provideService(HttpClient.HttpClient, httpClient), + ); +} + +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" }, + environment: [{ name: "GH_CONFIG_DIR", value: "/tmp/gh-work", sensitive: false }], + }, + }; + + 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"], + env: expect.objectContaining({ GH_CONFIG_DIR: "/tmp/gh-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("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("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(() => + 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("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(() => + 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("reports a missing ClickUp token as unauthenticated", () => + Effect.gen(function* () { + const run = vi.fn(() => + Effect.die("CLI should not run"), + ); + const registry = yield* makeRegistry(run); + const instanceId = TicketProviderInstanceId.make("clickup_work"); + + const result = yield* registry.probe({ + cwd: "/tmp/project", + instanceId, + instance: { + driver: TicketProviderDriverKind.make("clickup"), + baseUrl: "https://app.clickup.com", + }, + }); + + expect(result).toEqual({ + instanceId, + availability: "unauthenticated", + detail: "Add a ClickUp API token for this provider.", + }); + expect(run).not.toHaveBeenCalled(); + }), + ); + + 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("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("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(() => + 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(); + 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); + }), + ); + + 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); + }), + ); + + effectIt.effect("allows a stalled HTTP lookup to time out", () => + Effect.gen(function* () { + const run = vi.fn(() => + Effect.die("CLI should not run"), + ); + const httpClient = HttpClient.make(() => Effect.never); + const registry = yield* makeRegistry(run, httpClient); + const reference = extractUniqueTicketReference("https://acme.atlassian.net/browse/WEB-12")!; + const resolving = yield* registry + .resolve({ + cwd: "/tmp/project", + reference, + instances: { + [TicketProviderInstanceId.make("jira_work")]: { + driver: TicketProviderDriverKind.make("jira"), + baseUrl: "https://acme.atlassian.net", + }, + }, + bindings: [], + }) + .pipe(Effect.flip, Effect.forkChild); + + yield* Effect.yieldNow; + yield* TestClock.adjust("11 seconds"); + + expect((yield* Fiber.join(resolving)).reason).toBe("request-failed"); + }), + ); +}); diff --git a/apps/server/src/ticket/TicketProviderRegistry.ts b/apps/server/src/ticket/TicketProviderRegistry.ts new file mode 100644 index 000000000000..0f6c4f1d0d29 --- /dev/null +++ b/apps/server/src/ticket/TicketProviderRegistry.ts @@ -0,0 +1,721 @@ +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"; +import { collectUint8StreamText } from "../stream/collectUint8StreamText.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()), + }, +) { + override get message(): string { + const instance = this.instanceId === undefined ? "" : ` (instance '${this.instanceId}')`; + return `Ticket lookup for driver '${this.driver}'${instance} failed: ${this.reason}.`; + } +} + +class TicketProviderResponseTooLargeError 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; + 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 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] { + 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 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) + ? { _tag: "Selected", instanceId: binding.instanceId, instance } + : { _tag: "Missing" }; + } + + if (matching.length === 0) { + const [instanceId, instance] = implicitInstance(input.reference); + return { _tag: "Selected", instanceId, instance }; + } + 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 }; +} + +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 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); +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; + 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 executeText = Effect.fn("TicketProviderRegistry.executeText")(function* ( + request: HttpClientRequest.HttpClientRequest, + ) { + 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: { + 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(), + ], + env, + 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* decodeTicketResponse(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* decodeTicketResponse(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 issue = yield* decodeTicketResponse( + decodeBitbucketIssueJson(yield* executeText(request)), + ); + 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 issue = yield* decodeTicketResponse(decodeJiraIssueJson(yield* executeText(request))); + 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 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 { + 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: isTicketProviderInvalidResponseError(cause) + ? "invalid-response" + : "request-failed", + cause: isTicketProviderInvalidResponseError(cause) ? cause.cause : 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); + yield* resolveSelected(input, selected).pipe( + Effect.exit, + Effect.flatMap((exit) => Deferred.done(deferred, exit)), + Effect.ensuring(Effect.sync(() => inFlight.delete(key))), + Effect.forkDetach({ startImmediately: true, uninterruptible: false }), + ); + return yield* restore(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 clickUpToken = + driver === "clickup" ? environmentValue(input.instance, "CLICKUP_API_TOKEN") : undefined; + if (driver === "clickup" && !clickUpToken) { + return { + instanceId: input.instanceId, + availability: "unauthenticated", + detail: "Add a ClickUp API token for this provider.", + }; + } + + 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": { + const configuredUrl = new URL(baseUrl); + const detectOrganization = + configuredUrl.hostname === "dev.azure.com" && configuredUrl.pathname === "/"; + yield* runText({ + command: "az", + args: [ + "devops", + "project", + "list", + ...(detectOrganization ? [] : ["--organization", baseUrl]), + "--top", + "1", + "--output", + "none", + "--detect", + 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"); + 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* executeText(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* executeText(request); + break; + } + case "clickup": { + yield* executeText( + HttpClientRequest.get("https://api.clickup.com/api/v2/user").pipe( + HttpClientRequest.setHeader("Authorization", clickUpToken!), + ), + ); + 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..a17313924241 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* () { @@ -1670,16 +1672,42 @@ 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 + ? undefined + : { expectedTicketProviderInstancesRevision }, + ) .pipe(Effect.map(ServerSettings.redactServerSettingsForClient)), { "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 +2490,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 +2517,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..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"; @@ -53,6 +54,7 @@ import { SettingsSection, } from "./settingsLayout"; import { searchableSetting } from "./settingsSearch"; +import { TicketProviderSettings } from "./TicketProviderSettings"; const FILL_VALUE = "fill"; const RESPONSIVE_VALUE = "responsive"; @@ -454,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 = ( @@ -477,6 +480,7 @@ export function IntegrationsSettingsPanel() { previewDefaults )} + ); } diff --git a/apps/web/src/components/settings/ProjectSettingsPanel.tsx b/apps/web/src/components/settings/ProjectSettingsPanel.tsx index b462eaca883b..878a7a722875 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,11 @@ import { ProjectFaviconPickerDialog, } from "./ProjectFaviconPickerDialog"; 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", @@ -364,6 +374,7 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { title: string; defaultModelSelection: ModelSelection | null; defaultThreadEnvMode: ThreadEnvMode | null; + ticketTitlePolicy: TicketTitlePolicy | null; faviconPath: string | null; }>, failureTitle: string, @@ -447,6 +458,80 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { ), [updateAllMembers], ); + const storedTicketTitlePolicy = representative.ticketTitlePolicy ?? null; + const ticketTitlePolicyMutationQueueRef = useRef>(Promise.resolve()); + const pendingTicketTitlePolicyMutationsRef = useRef(0); + const ticketTitlePolicyByProjectRef = useRef( + new Map( + group.memberProjects.map((member) => [member.id, member.ticketTitlePolicy ?? null] as const), + ), + ); + useEffect(() => { + if (pendingTicketTitlePolicyMutationsRef.current === 0) { + ticketTitlePolicyByProjectRef.current = new Map( + group.memberProjects.map( + (member) => [member.id, member.ticketTitlePolicy ?? null] as const, + ), + ); + } + }, [group.memberProjects]); + const setTicketTitlePolicy = useCallback( + (ticketTitlePolicy: TicketTitlePolicy | null) => { + pendingTicketTitlePolicyMutationsRef.current += 1; + const operation = ticketTitlePolicyMutationQueueRef.current.then(async () => { + const previousPolicies = new Map(ticketTitlePolicyByProjectRef.current); + 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: previousPolicies.get(updatedMember.id) ?? 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; + } + ticketTitlePolicyByProjectRef.current = new Map( + group.memberProjects.map((member) => [member.id, ticketTitlePolicy] as const), + ); + }); + ticketTitlePolicyMutationQueueRef.current = operation.then( + () => { + pendingTicketTitlePolicyMutationsRef.current -= 1; + }, + () => { + pendingTicketTitlePolicyMutationsRef.current -= 1; + }, + ); + }, + [group.memberProjects, reportFailure, updateProject], + ); // ----- favicon ----- const [faviconPickerOpen, setFaviconPickerOpen] = useState(false); @@ -472,6 +557,139 @@ 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 basePath: string; + readonly instances: ReadonlyArray; + } + >(); + for (const [instanceId, instance] of Object.entries( + selectedCheckoutSettings.ticketProviderInstances, + )) { + if (instance.enabled === false) continue; + let host: string; + let basePath: string; + try { + const url = new URL(instance.baseUrl); + host = url.host.toLowerCase(); + basePath = ticketProviderBasePath(instance.baseUrl); + } catch { + continue; + } + 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 basePath = binding.basePath ?? ""; + const key = `${binding.driver}:${host}:${basePath}`; + if (!groups.has(key)) { + groups.set(key, { driver: binding.driver, host, basePath, instances: [] }); + } + } + return [...groups.values()]; + }, [selectedCheckout.ticketProviderBindings, selectedCheckoutSettings.ticketProviderInstances]); + const selectedTicketProviderBindings = selectedCheckout.ticketProviderBindings ?? []; + const ticketProviderBindingsRef = useRef({ + projectId: selectedCheckout.id, + bindings: selectedTicketProviderBindings, + }); + const confirmedTicketProviderBindingsRef = useRef( + new Map([[selectedCheckout.id, selectedTicketProviderBindings] as const]), + ); + const ticketProviderBindingMutationQueueRef = useRef>(Promise.resolve()); + const pendingTicketProviderBindingMutationsRef = useRef(0); + useEffect(() => { + if (pendingTicketProviderBindingMutationsRef.current === 0) { + const snapshot = { + projectId: selectedCheckout.id, + bindings: selectedTicketProviderBindings, + }; + ticketProviderBindingsRef.current = snapshot; + confirmedTicketProviderBindingsRef.current.set( + selectedCheckout.id, + selectedTicketProviderBindings, + ); + } + }, [selectedCheckout.id, selectedTicketProviderBindings]); + const setTicketProviderBinding = useCallback( + (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.basePath ?? "") === basePath + ), + ); + const next: TicketProviderBindings = instanceId + ? [ + ...withoutBinding, + { + driver: TicketProviderDriverKind.make(driver), + host, + ...(basePath ? { basePath } : {}), + instanceId: TicketProviderInstanceId.make(instanceId), + }, + ] + : withoutBinding; + ticketProviderBindingsRef.current = { projectId: selectedCheckout.id, bindings: next }; + pendingTicketProviderBindingMutationsRef.current += 1; + const operation = ticketProviderBindingMutationQueueRef.current.then(async () => { + const mapped = mapAtomCommandResult( + await updateProject({ + environmentId: selectedCheckout.environmentId, + input: { projectId: selectedCheckout.id, ticketProviderBindings: next }, + }), + () => undefined, + ); + if (mapped._tag === "Success") { + confirmedTicketProviderBindingsRef.current.set(selectedCheckout.id, next); + } else if ( + ticketProviderBindingsRef.current.projectId === selectedCheckout.id && + ticketProviderBindingsRef.current.bindings === next + ) { + ticketProviderBindingsRef.current = { + projectId: selectedCheckout.id, + bindings: + confirmedTicketProviderBindingsRef.current.get(selectedCheckout.id) ?? + selectedTicketProviderBindings, + }; + } + reportFailure("Failed to update ticket provider binding", mapped); + }); + ticketProviderBindingMutationQueueRef.current = operation.then( + () => { + pendingTicketProviderBindingMutationsRef.current -= 1; + }, + () => { + pendingTicketProviderBindingMutationsRef.current -= 1; + }, + ); + }, + [ + reportFailure, + selectedCheckout.environmentId, + selectedCheckout.id, + selectedTicketProviderBindings, + updateProject, + ], + ); const selectedServerConfig = useAtomValue( serverEnvironment.configValueAtom(selectedCheckout.environmentId), ); @@ -922,6 +1140,14 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { } /> + } /> + {ticketProviderGroups.map((providerGroup) => { + const binding = selectedTicketProviderBindings.find( + (candidate) => + candidate.driver === providerGroup.driver && + candidate.host.toLowerCase() === providerGroup.host && + (candidate.basePath ?? "") === providerGroup.basePath, + ); + const providerLocation = `${providerGroup.host}${providerGroup.basePath}`; + return ( + + setTicketProviderBinding( + providerGroup.driver, + providerGroup.host, + providerGroup.basePath, + 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, + inheritedLabel, + onChange, + allowInherit = false, +}: { + readonly policy: TicketTitlePolicy | null; + readonly inheritedPolicy?: TicketTitlePolicy; + readonly inheritedLabel?: string; + 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" + ? (inheritedLabel ?? + `Default (${POLICY_MODE_LABELS[effective.mode].toLowerCase()})`) + : POLICY_MODE_LABELS[effective.mode]} + + + + {allowInherit ? ( + {inheritedLabel ?? "Default"} + ) : null} + Identifier and title + Ticket title + Custom template + Off + + + } + /> + {effective.mode === "custom" && !(allowInherit && policy === null) ? ( + + The template contains an unknown variable, so it was not saved. + + ) + } + control={ + 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, + 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; + 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 handleOpenChange = (nextOpen: boolean) => { + if (!nextOpen) { + setSecret(""); + setError(null); + } + onOpenChange(nextOpen); + }; + + 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 = () => { + if (!canSave) { + setError("Connect a primary environment before adding a ticket provider."); + return; + } + 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 (parsed.username || parsed.password) { + 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" + ) { + setError("Replace the example URL with your Jira site URL."); + return; + } + + const displayName = label.trim() || option.label; + 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 } : {}), + }; + onAdd(instance); + toastManager.add({ type: "success", title: "Ticket provider added", description: displayName }); + handleOpenChange(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} + + + Default for this provider and host + setIsDefault(Boolean(checked))} + /> + + + Used when a project has no explicit account binding. + + + {error ?

{error}

: null} +
+ + + + +
+
+ ); +} + +export function TicketProviderSettings() { + const settings = usePrimarySettings(); + const updateSettings = useUpdatePrimarySettings(); + const environmentId = usePrimaryEnvironmentId(); + const persistProviderSettings = useAtomCommand( + serverEnvironment.updateSettings, + "ticket provider settings update", + ); + 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 instancesRef = useRef(settings.ticketProviderInstances); + const instancesRevisionRef = useRef(settings.ticketProviderInstancesRevision); + const projectedInstancesRef = useRef(settings.ticketProviderInstances); + const projectedInstancesRevisionRef = useRef(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; + instancesRevisionRef.current = projectedInstancesRevisionRef.current; + }; + useEffect(() => { + if (pendingInstanceMutationsRef.current === 0) syncProjectedInstances(); + }, [settings.ticketProviderInstances, settings.ticketProviderInstancesRevision]); + + const updateInstances = ( + update: ( + current: typeof settings.ticketProviderInstances, + ) => typeof settings.ticketProviderInstances, + ) => { + if (!environmentId) return; + pendingInstanceMutationsRef.current += 1; + 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 }, + 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; + } + }); + const finish = () => { + pendingInstanceMutationsRef.current -= 1; + if (pendingInstanceMutationsRef.current === 0) syncProjectedInstances(); + }; + instancesMutationQueueRef.current = operation.then(finish, finish); + }; + + const replaceInstance = (instanceId: string, instance: TicketProviderInstanceConfig) => { + updateInstances((current) => ({ + ...current, + [TicketProviderInstanceId.make(instanceId)]: instance, + })); + }; + + return ( + <> + setDialogOpen(true)}> + Add provider + + } + > + { + if (ticketTitlePolicy) updateSettings({ ticketTitlePolicy }); + }} + /> + + {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) }) + } + /> + + + } + /> + ); + })} + + { + 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/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..ec24855e6716 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), @@ -399,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( @@ -463,6 +467,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 +688,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 +804,8 @@ const ThreadMetaUpdateCommand = Schema.Struct({ commandId: CommandId, 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)), @@ -1142,6 +1152,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..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, @@ -80,6 +81,7 @@ import { ProviderUploadFeedbackResult, } from "./provider.ts"; import { ProviderInstanceId } from "./providerInstance.ts"; +import { TicketProviderInstanceId, TicketProviderProbeResult } from "./ticketProvider.ts"; import { PullRequestActionInput, PullRequestActivity, @@ -280,6 +282,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", @@ -400,11 +403,20 @@ 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]), }); +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 +1040,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..092851a8c74b 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,68 @@ 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({}); + expect(settings.ticketProviderInstancesRevision).toBe(0); + }); + + 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", + }); + }); + + it("rejects credentials embedded in ticket provider base URLs", () => { + expect(() => + decodeServerSettingsPatch({ + ticketProviderInstances: { + jira_work: { + driver: "jira", + baseUrl: "https://user:token@jira.example.com", + }, + }, + }), + ).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/settings.ts b/packages/contracts/src/settings.ts index 76bb032c866e..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, @@ -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,14 @@ 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({}))), + 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 @@ -753,10 +767,12 @@ export const resolveProviderInstanceEnabled = ( export const ServerSettingsOperation = Schema.Literals([ "normalize", + "compare-and-set", "check-exists", "read-file", "read-provider-history", "read-secret", + "read-secret-snapshot", "remove-secret", "remove-stale-secret", "write-secret", @@ -873,6 +889,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..12395d9e2e40 --- /dev/null +++ b/packages/contracts/src/ticketProvider.ts @@ -0,0 +1,102 @@ +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; + +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 && + 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."; + } + }), +); + +export const TicketProviderInstanceConfig = Schema.Struct({ + driver: TicketProviderDriverKind, + displayName: Schema.optional(TrimmedNonEmptyString), + baseUrl: TicketProviderBaseUrl, + 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, + basePath: Schema.optional(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.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 69fc9eaacbcc..4d0d688d3fc2 100644 --- a/packages/shared/src/serverSettings.ts +++ b/packages/shared/src/serverSettings.ts @@ -188,6 +188,12 @@ export function applyServerSettingsPatch( ...(patch.providerInstances !== undefined ? { providerInstances: patch.providerInstances } : {}), + ...(patch.ticketProviderInstances !== undefined + ? { + ticketProviderInstances: patch.ticketProviderInstances, + ticketProviderInstancesRevision: current.ticketProviderInstancesRevision + 1, + } + : {}), ...(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..91eb37ec97a5 --- /dev/null +++ b/packages/shared/src/ticketTitles.test.ts @@ -0,0 +1,174 @@ +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("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("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", [ + { 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..798eef6993d7 --- /dev/null +++ b/packages/shared/src/ticketTitles.ts @@ -0,0 +1,378 @@ +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 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 < value.length) { + if (value[cursor] !== "`") { + result += value[cursor]; + cursor += 1; + continue; + } + const markerLength = backtickRunLength(value, cursor); + const markerEnd = cursor + markerLength; + const closing = findClosingBacktickRun(value, markerEnd, markerLength); + if (closing === -1) { + result += value.slice(cursor, markerEnd); + cursor = markerEnd; + continue; + } + result += " "; + cursor = closing + markerLength; + } + return result; +} + +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 && + /^[ \t]*$/u.test(line.slice(fenceMatch[0].length)) + ) { + fence = undefined; + } + visible.push(" "); + previousLineWasBlank = false; + continue; + } + if (fenceMatch) { + fence = { + marker: fenceMatch[1]![0] as "`" | "~", + length: fenceMatch[1]!.length, + }; + visible.push(" "); + inIndentedCode = false; + previousLineWasBlank = false; + continue; + } + if (line.trim().length === 0) { + visible.push(" "); + previousLineWasBlank = true; + continue; + } + 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 stripInlineCode(visible.join("\n")); +} + +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; +}