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 (
+
+ );
+}
+
+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;
+}