From f0c5c1913a2d373c69ac3e9e183b642832b92b17 Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 27 Apr 2026 15:29:15 -0500 Subject: [PATCH] Gate external objects behind experimental flag Co-Authored-By: Paperclip --- packages/shared/src/types/instance.ts | 1 + packages/shared/src/validators/instance.ts | 1 + .../__tests__/external-object-routes.test.ts | 11 +++- .../external-objects-service.test.ts | 22 +++++++ .../instance-settings-routes.test.ts | 21 +++++++ server/src/routes/issues.ts | 1 + server/src/routes/projects.ts | 6 +- server/src/services/external-objects.ts | 19 ++++++ server/src/services/instance-settings.ts | 2 + ui/src/components/IssueFiltersPopover.tsx | 62 ++++++++++--------- ui/src/components/IssueRelatedWorkPanel.tsx | 16 +++-- ui/src/components/IssuesList.test.tsx | 36 ++++++++++- ui/src/components/IssuesList.tsx | 8 +++ ui/src/hooks/useIssueExternalObjects.ts | 29 +++++++-- ui/src/pages/Inbox.tsx | 12 ++++ ui/src/pages/InstanceExperimentalSettings.tsx | 19 ++++++ ui/src/pages/IssueDetail.test.tsx | 1 + ui/src/pages/IssueDetail.tsx | 34 +++++----- 18 files changed, 242 insertions(+), 59 deletions(-) diff --git a/packages/shared/src/types/instance.ts b/packages/shared/src/types/instance.ts index e4a2db9172c..e308e0e2ab6 100644 --- a/packages/shared/src/types/instance.ts +++ b/packages/shared/src/types/instance.ts @@ -53,6 +53,7 @@ export interface InstanceExperimentalSettings { enableIssuePlanDecompositions: boolean; enableExperimentalFileViewer: boolean; enableCloudSync: boolean; + enableExternalObjects: boolean; autoRestartDevServerWhenIdle: boolean; enableIssueGraphLivenessAutoRecovery: boolean; issueGraphLivenessAutoRecoveryLookbackHours: number; diff --git a/packages/shared/src/validators/instance.ts b/packages/shared/src/validators/instance.ts index 66119debf65..986d0a3525b 100644 --- a/packages/shared/src/validators/instance.ts +++ b/packages/shared/src/validators/instance.ts @@ -47,6 +47,7 @@ export const instanceExperimentalSettingsSchema = z.object({ enableIssuePlanDecompositions: z.boolean().default(false), enableExperimentalFileViewer: z.boolean().default(false), enableCloudSync: z.boolean().default(false), + enableExternalObjects: z.boolean().default(false), autoRestartDevServerWhenIdle: z.boolean().default(false), enableIssueGraphLivenessAutoRecovery: z.boolean().default(false), issueGraphLivenessAutoRecoveryLookbackHours: z diff --git a/server/src/__tests__/external-object-routes.test.ts b/server/src/__tests__/external-object-routes.test.ts index 6566ae7891e..eabefa6d95e 100644 --- a/server/src/__tests__/external-object-routes.test.ts +++ b/server/src/__tests__/external-object-routes.test.ts @@ -27,12 +27,19 @@ const mockExternalObjectsService = vi.hoisted(() => ({ listForIssue: vi.fn(), refreshIssueObjects: vi.fn(), })); +const mockInstanceSettingsService = vi.hoisted(() => ({ + getExperimental: vi.fn(), +})); function registerRouteMocks() { vi.doMock("../services/external-objects.js", () => ({ externalObjectService: () => mockExternalObjectsService, })); + vi.doMock("../services/instance-settings.js", () => ({ + instanceSettingsService: () => mockInstanceSettingsService, + })); + vi.doMock("../services/index.js", () => ({ accessService: () => mockAccessService, agentService: () => mockAgentService, @@ -47,7 +54,6 @@ function registerRouteMocks() { getActiveRunForAgent: vi.fn(async () => null), cancelRun: vi.fn(async () => null), }), - instanceSettingsService: () => ({}), issueApprovalService: () => ({}), issueReferenceService: () => ({ listIssueReferenceSummary: async () => ({ outbound: [], inbound: [] }), @@ -153,6 +159,9 @@ describe("external object routes", () => { mockExternalObjectsService.refreshIssueObjects.mockResolvedValue([ { object: { id: "77777777-7777-4777-8777-777777777777" }, refreshed: false, reason: "no_resolver" }, ]); + mockInstanceSettingsService.getExperimental.mockResolvedValue({ + enableExternalObjects: true, + }); }); it("enforces company access on read routes", async () => { diff --git a/server/src/__tests__/external-objects-service.test.ts b/server/src/__tests__/external-objects-service.test.ts index 729f03b4a50..8d589352bfe 100644 --- a/server/src/__tests__/external-objects-service.test.ts +++ b/server/src/__tests__/external-objects-service.test.ts @@ -415,6 +415,28 @@ describeEmbeddedPostgres("externalObjectService", () => { }); }); + it("no-ops detection and summaries when external objects are disabled", async () => { + const { issueId } = await createIssue(); + const svc = externalObjectService(db, { enabled: false }); + + await svc.syncIssue(issueId); + + const [objectRows, mentionRows, summary] = await Promise.all([ + db.select().from(externalObjects), + db.select().from(externalObjectMentions), + svc.getIssueSummary(issueId), + ]); + expect(objectRows).toHaveLength(0); + expect(mentionRows).toHaveLength(0); + expect(summary).toMatchObject({ + total: 0, + byStatusCategory: {}, + byLiveness: {}, + highestSeverity: "neutral", + objects: [], + }); + }); + it("preserves last-known status when resolver reports auth and unreachable failures", async () => { const { companyId, issueId } = await createIssue(); const resolver: ExternalObjectResolver = { diff --git a/server/src/__tests__/instance-settings-routes.test.ts b/server/src/__tests__/instance-settings-routes.test.ts index bd6b63a9c4b..3965dacb975 100644 --- a/server/src/__tests__/instance-settings-routes.test.ts +++ b/server/src/__tests__/instance-settings-routes.test.ts @@ -100,6 +100,7 @@ describe("instance settings routes", () => { enableExperimentalFileViewer: false, enableTaskWatchdogs: false, enableCloudSync: false, + enableExternalObjects: false, autoRestartDevServerWhenIdle: false, enableIssueGraphLivenessAutoRecovery: true, issueGraphLivenessAutoRecoveryLookbackHours: 24, @@ -142,6 +143,7 @@ describe("instance settings routes", () => { enableExperimentalFileViewer: true, enableTaskWatchdogs: true, enableCloudSync: true, + enableExternalObjects: false, autoRestartDevServerWhenIdle: false, enableIssueGraphLivenessAutoRecovery: true, issueGraphLivenessAutoRecoveryLookbackHours: 24, @@ -194,6 +196,7 @@ describe("instance settings routes", () => { enableExperimentalFileViewer: false, enableTaskWatchdogs: false, enableCloudSync: false, + enableExternalObjects: false, autoRestartDevServerWhenIdle: false, enableIssueGraphLivenessAutoRecovery: true, issueGraphLivenessAutoRecoveryLookbackHours: 24, @@ -271,6 +274,24 @@ describe("instance settings routes", () => { ).toBe(true); }); + it("allows local board users to update external object detection", async () => { + const app = await createApp({ + type: "board", + userId: "local-board", + source: "local_implicit", + isInstanceAdmin: true, + }); + + await request(app) + .patch("/api/instance/settings/experimental") + .send({ enableExternalObjects: true }) + .expect(200); + + expect(mockInstanceSettingsService.updateExperimental).toHaveBeenCalledWith({ + enableExternalObjects: true, + }); + }); + it("allows local board users to update issue graph liveness auto-recovery", async () => { const app = await createApp({ type: "board", diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index bbcaaf4b878..ac5fc95e2ef 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -1113,6 +1113,7 @@ export function issueRoutes( }) ?? noopTaskWatchdogService(); const externalObjectsSvc = externalObjectService(db, { pluginWorkerManager: opts.pluginWorkerManager, + enabled: async () => (await instanceSettings.getExperimental()).enableExternalObjects === true, }); const routinesSvc = routineService(db, { pluginWorkerManager: opts.pluginWorkerManager, diff --git a/server/src/routes/projects.ts b/server/src/routes/projects.ts index 1f75e250489..c7e123fc0da 100644 --- a/server/src/routes/projects.ts +++ b/server/src/routes/projects.ts @@ -16,6 +16,7 @@ import { validate } from "../middleware/validate.js"; import { accessService, projectService, logActivity, workspaceOperationService } from "../services/index.js"; import { conflict, forbidden } from "../errors.js"; import { externalObjectService } from "../services/external-objects.js"; +import { instanceSettingsService } from "../services/instance-settings.js"; import { assertCompanyAccess, getActorInfo } from "./authz.js"; import { buildWorkspaceRuntimeDesiredStatePatch, @@ -45,7 +46,10 @@ export function projectRoutes(db: Db) { const access = accessService(db); const secretsSvc = secretService(db); const workspaceOperations = workspaceOperationService(db); - const externalObjectsSvc = externalObjectService(db); + const instanceSettings = instanceSettingsService(db); + const externalObjectsSvc = externalObjectService(db, { + enabled: async () => (await instanceSettings.getExperimental()).enableExternalObjects === true, + }); const strictSecretsMode = process.env.PAPERCLIP_SECRETS_STRICT_MODE === "true"; const environmentsSvc = environmentService(db); diff --git a/server/src/services/external-objects.ts b/server/src/services/external-objects.ts index 83df3816d71..f9ac6f3931a 100644 --- a/server/src/services/external-objects.ts +++ b/server/src/services/external-objects.ts @@ -345,6 +345,7 @@ export function externalObjectService( resolvers?: ExternalObjectResolver[]; pluginWorkerManager?: PluginWorkerManager; github?: GitHubExternalObjectProviderOptions | false; + enabled?: boolean | (() => boolean | Promise); } = {}, ) { const githubProvider = opts.github === false ? null : createGitHubExternalObjectProvider(db, opts.github); @@ -361,6 +362,15 @@ export function externalObjectService( ...(githubProvider?.resolvers ?? []), ]); + async function isEnabled() { + if (typeof opts.enabled === "function") return await opts.enabled(); + return opts.enabled ?? true; + } + + function emptySummary() { + return summarizeObjectPayloads([]); + } + async function issueById(issueId: string, dbOrTx: any = db) { return dbOrTx .select({ @@ -461,6 +471,7 @@ export function externalObjectService( } async function syncIssue(issueId: string, dbOrTx: any = db) { + if (!(await isEnabled())) return; const runSync = async (tx: any) => { const issue = await issueById(issueId, tx); if (!issue) throw notFound("Issue not found"); @@ -487,6 +498,7 @@ export function externalObjectService( } async function syncComment(commentId: string, dbOrTx: any = db) { + if (!(await isEnabled())) return; const comment = await dbOrTx .select({ id: issueComments.id, @@ -510,6 +522,7 @@ export function externalObjectService( } async function syncDocument(documentId: string, dbOrTx: any = db) { + if (!(await isEnabled())) return; const document = await dbOrTx .select({ documentId: documents.id, @@ -567,6 +580,7 @@ export function externalObjectService( } async function listForIssue(issueId: string) { + if (!(await isEnabled())) return []; const issue = await issueById(issueId); if (!issue) throw notFound("Issue not found"); const rows = await db @@ -659,12 +673,14 @@ export function externalObjectService( } async function getIssueSummary(issueId: string) { + if (!(await isEnabled())) return emptySummary(); const groups = await listForIssue(issueId); const objects = groups.flatMap((group) => (group.object ? [group.object] : [])); return summarizeObjectPayloads(objects); } async function getIssueSummaries(companyId: string, issueIds: string[]) { + if (!(await isEnabled())) return new Map>(); const uniqueIssueIds = [...new Set(issueIds)].filter((id) => id.length > 0); const summaries = new Map>(); if (uniqueIssueIds.length === 0) return summaries; @@ -697,6 +713,7 @@ export function externalObjectService( } async function getProjectSummary(projectId: string) { + if (!(await isEnabled())) return emptySummary(); const projectIssues = await db .select({ id: issues.id, companyId: issues.companyId }) .from(issues) @@ -840,6 +857,7 @@ export function externalObjectService( objectIds?: string[]; actor?: Pick; }) { + if (!(await isEnabled())) return []; const groups = await listForIssue(issueId); const objectIds = groups .flatMap((group) => (group.object ? [group.object.id] : [])) @@ -852,6 +870,7 @@ export function externalObjectService( } async function refreshDueObjects(companyId: string, limit = 50, now = new Date()) { + if (!(await isEnabled())) return []; const due = await db .select({ id: externalObjects.id }) .from(externalObjects) diff --git a/server/src/services/instance-settings.ts b/server/src/services/instance-settings.ts index 03bf2c02e57..88656ec14d1 100644 --- a/server/src/services/instance-settings.ts +++ b/server/src/services/instance-settings.ts @@ -52,6 +52,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta enableExperimentalFileViewer: parsed.data.enableExperimentalFileViewer ?? false, enableTaskWatchdogs: parsed.data.enableTaskWatchdogs ?? false, enableCloudSync: parsed.data.enableCloudSync ?? false, + enableExternalObjects: parsed.data.enableExternalObjects ?? false, autoRestartDevServerWhenIdle: parsed.data.autoRestartDevServerWhenIdle ?? false, enableIssueGraphLivenessAutoRecovery: parsed.data.enableIssueGraphLivenessAutoRecovery ?? false, issueGraphLivenessAutoRecoveryLookbackHours: @@ -68,6 +69,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta enableIssuePlanDecompositions: false, enableExperimentalFileViewer: false, enableCloudSync: false, + enableExternalObjects: false, autoRestartDevServerWhenIdle: false, enableIssueGraphLivenessAutoRecovery: false, issueGraphLivenessAutoRecoveryLookbackHours: diff --git a/ui/src/components/IssueFiltersPopover.tsx b/ui/src/components/IssueFiltersPopover.tsx index 473689fb6d5..40de5417308 100644 --- a/ui/src/components/IssueFiltersPopover.tsx +++ b/ui/src/components/IssueFiltersPopover.tsx @@ -59,6 +59,7 @@ export function IssueFiltersPopover({ projects, labels, currentUserId, + enableExternalObjectFilters = true, enableRoutineVisibilityFilter = false, buttonVariant = "ghost", iconOnly = false, @@ -72,6 +73,7 @@ export function IssueFiltersPopover({ projects?: ProjectOption[]; labels?: LabelOption[]; currentUserId?: string | null; + enableExternalObjectFilters?: boolean; enableRoutineVisibilityFilter?: boolean; buttonVariant?: "ghost" | "outline"; iconOnly?: boolean; @@ -348,36 +350,38 @@ export function IssueFiltersPopover({ ) : null} -
- External object status -
- {externalObjectFilterOrder.map((value) => { - const iconCategory = value === "failed" ? "failed" - : value === "waiting" ? "waiting" - : value === "running" ? "running" - : value === "auth_required" ? "auth_required" - : value === "unreachable" ? "unreachable" - : value === "stale" ? "unknown" - : "closed"; - const Icon = externalObjectIconForCategory(iconCategory); - const tone = externalObjectStatusIcon[iconCategory] ?? ""; - const textTone = tone.split(" ").filter((c) => c.startsWith("text-")).join(" "); - return ( - - ); - })} + {enableExternalObjectFilters ? ( +
+ External object status +
+ {externalObjectFilterOrder.map((value) => { + const iconCategory = value === "failed" ? "failed" + : value === "waiting" ? "waiting" + : value === "running" ? "running" + : value === "auth_required" ? "auth_required" + : value === "unreachable" ? "unreachable" + : value === "stale" ? "unknown" + : "closed"; + const Icon = externalObjectIconForCategory(iconCategory); + const tone = externalObjectStatusIcon[iconCategory] ?? ""; + const textTone = tone.split(" ").filter((c) => c.startsWith("text-")).join(" "); + return ( + + ); + })} +
-
+ ) : null}
Visibility diff --git a/ui/src/components/IssueRelatedWorkPanel.tsx b/ui/src/components/IssueRelatedWorkPanel.tsx index dced991906c..9d5fad9df7d 100644 --- a/ui/src/components/IssueRelatedWorkPanel.tsx +++ b/ui/src/components/IssueRelatedWorkPanel.tsx @@ -171,12 +171,14 @@ function ExternalObjectsSection({ export function IssueRelatedWorkPanel({ relatedWork, + externalObjectsEnabled = true, externalObjects, externalObjectsLoading, externalObjectsError, onRetryExternalObjects, }: { relatedWork?: IssueRelatedWorkSummary | null; + externalObjectsEnabled?: boolean; externalObjects?: IssueExternalObjectGroup[]; externalObjectsLoading?: boolean; externalObjectsError?: boolean; @@ -193,12 +195,14 @@ export function IssueRelatedWorkPanel({ items={outbound} emptyLabel="This task does not reference any other tasks yet." /> - + {externalObjectsEnabled ? ( + + ) : null}
{ mockAccessApi.listUserDirectory.mockResolvedValue({ users: [] }); mockExecutionWorkspacesApi.list.mockResolvedValue([]); mockExecutionWorkspacesApi.listSummaries.mockResolvedValue([]); - mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: false }); + mockInstanceSettingsApi.getExperimental.mockResolvedValue({ + enableIsolatedWorkspaces: false, + enableExternalObjects: false, + }); setDocumentScrollMetrics({ innerHeight: 600, scrollY: 0, scrollHeight: 2400 }); mockExternalObjectsApi.getIssueSummaries.mockResolvedValue({ summaries: {} }); localStorage.clear(); @@ -319,6 +322,10 @@ describe("IssuesList", () => { }); it("forwards external-object summaries into issue rows", async () => { + mockInstanceSettingsApi.getExperimental.mockResolvedValue({ + enableIsolatedWorkspaces: false, + enableExternalObjects: true, + }); mockExternalObjectsApi.getIssueSummaries.mockResolvedValue({ summaries: { "issue-1": { @@ -355,7 +362,34 @@ describe("IssuesList", () => { }); }); + it("does not load external-object summaries when the experimental flag is disabled", async () => { + const { root } = renderWithQueryClient( + undefined} + />, + container, + ); + + await waitForAssertion(() => { + expect(mockInstanceSettingsApi.getExperimental).toHaveBeenCalled(); + expect(container.querySelector("[data-testid='issue-row']")).not.toBeNull(); + }); + expect(mockExternalObjectsApi.getIssueSummaries).not.toHaveBeenCalled(); + + act(() => { + root.unmount(); + }); + }); + it("filters issue rows by external-object status summaries", async () => { + mockInstanceSettingsApi.getExperimental.mockResolvedValue({ + enableIsolatedWorkspaces: false, + enableExternalObjects: true, + }); const failedIssue = createIssue({ id: "issue-failed", identifier: "PAP-10", title: "Failed external object" }); const freshIssue = createIssue({ id: "issue-fresh", identifier: "PAP-11", title: "Fresh external object" }); const noObjectIssue = createIssue({ id: "issue-none", identifier: "PAP-12", title: "No external object" }); diff --git a/ui/src/components/IssuesList.tsx b/ui/src/components/IssuesList.tsx index c66868f4bcb..bded52262d8 100644 --- a/ui/src/components/IssuesList.tsx +++ b/ui/src/components/IssuesList.tsx @@ -643,7 +643,9 @@ export function IssuesList({ retry: false, }); const currentUserId = session?.user?.id ?? session?.session?.userId ?? null; + const experimentalSettingsLoaded = experimentalSettings !== undefined; const isolatedWorkspacesEnabled = experimentalSettings?.enableIsolatedWorkspaces === true; + const externalObjectsEnabled = experimentalSettings?.enableExternalObjects === true; // Scope the storage key per company so folding/view state is independent across companies. const scopedKey = selectedCompanyId ? `${viewStateKey}:${selectedCompanyId}` : viewStateKey; @@ -693,6 +695,11 @@ export function IssuesList({ }); }, [scopedKey]); + useEffect(() => { + if (!experimentalSettingsLoaded || externalObjectsEnabled || viewState.externalObjectStatuses.length === 0) return; + updateView({ externalObjectStatuses: [] }); + }, [experimentalSettingsLoaded, externalObjectsEnabled, updateView, viewState.externalObjectStatuses.length]); + // Prune stale IDs from collapsedParents whenever the issue list changes. // Deleted or reassigned issues leave orphan IDs in localStorage; this keeps // the stored array bounded to only current parent IDs. @@ -1503,6 +1510,7 @@ export function IssuesList({ projects={projects?.map((project) => ({ id: project.id, name: project.name }))} labels={labels?.map((label) => ({ id: label.id, name: label.name, color: label.color }))} currentUserId={currentUserId} + enableExternalObjectFilters={externalObjectsEnabled} enableRoutineVisibilityFilter={enableRoutineVisibilityFilter} iconOnly workspaces={isolatedWorkspacesEnabled ? workspaceOptions : undefined} diff --git a/ui/src/hooks/useIssueExternalObjects.ts b/ui/src/hooks/useIssueExternalObjects.ts index 09201579e40..563ddbb309c 100644 --- a/ui/src/hooks/useIssueExternalObjects.ts +++ b/ui/src/hooks/useIssueExternalObjects.ts @@ -10,6 +10,7 @@ import { queryKeys } from "../lib/queryKeys"; import { normalizeExternalObjectHref } from "../lib/external-object-href"; import type { MarkdownExternalReferenceMap } from "../components/MarkdownBody"; import type { ExternalObjectPillData } from "../components/ExternalObjectPill"; +import { instanceSettingsApi } from "../api/instanceSettings"; /** * Browser-side mention-source label. Keep in sync with the shared formatter @@ -42,6 +43,7 @@ export interface IssueExternalObjectGroup { } export interface IssueExternalObjectsResult { + isEnabled: boolean; groups: IssueExternalObjectGroup[]; /** Lookup map for `MarkdownBody`'s `externalReferences` prop. */ markdownReferences: MarkdownExternalReferenceMap; @@ -50,6 +52,18 @@ export interface IssueExternalObjectsResult { refetch: () => void; } +function useExternalObjectsFeature() { + const query = useQuery({ + queryKey: queryKeys.instance.experimentalSettings, + queryFn: () => instanceSettingsApi.getExperimental(), + retry: false, + }); + return { + isEnabled: query.data?.enableExternalObjects === true, + isLoaded: query.data !== undefined || query.isError, + }; +} + /** * Loads `external_objects` for an issue and produces both the per-group rows * (used by the property panel and related-work section) and the markdown URL @@ -57,7 +71,8 @@ export interface IssueExternalObjectsResult { * surface reads from the same query result. */ export function useIssueExternalObjects(issueId: string | null | undefined): IssueExternalObjectsResult { - const enabled = Boolean(issueId); + const externalObjectsFeature = useExternalObjectsFeature(); + const enabled = externalObjectsFeature.isEnabled && Boolean(issueId); const query = useQuery({ queryKey: queryKeys.externalObjects.byIssue(issueId ?? "__none__"), queryFn: () => externalObjectsApi.listForIssue(issueId!), @@ -134,6 +149,7 @@ export function useIssueExternalObjects(issueId: string | null | undefined): Iss }, [query.refetch]); return { + isEnabled: externalObjectsFeature.isEnabled, groups, markdownReferences, isLoading: enabled && query.isLoading, @@ -146,7 +162,8 @@ export function useIssueExternalObjectSummary(issueId: string | null | undefined summary: ExternalObjectSummary | null; isLoading: boolean; } { - const enabled = Boolean(issueId); + const externalObjectsFeature = useExternalObjectsFeature(); + const enabled = externalObjectsFeature.isEnabled && Boolean(issueId); const query = useQuery({ queryKey: queryKeys.externalObjects.issueSummary(issueId ?? "__none__"), queryFn: () => externalObjectsApi.getIssueSummary(issueId!), @@ -167,11 +184,12 @@ export function useIssueExternalObjectSummaries( isLoading: boolean; isReady: boolean; } { + const externalObjectsFeature = useExternalObjectsFeature(); const normalizedIssueIds = useMemo( () => [...new Set(issueIds.filter((issueId) => issueId.length > 0))].sort(), [issueIds], ); - const enabled = Boolean(companyId) && normalizedIssueIds.length > 0; + const enabled = externalObjectsFeature.isEnabled && Boolean(companyId) && normalizedIssueIds.length > 0; const query = useQuery({ queryKey: queryKeys.externalObjects.issueSummaries(companyId ?? "__none__", normalizedIssueIds), queryFn: () => externalObjectsApi.getIssueSummaries(companyId!, normalizedIssueIds), @@ -185,7 +203,7 @@ export function useIssueExternalObjectSummaries( return { summaries, isLoading: enabled && query.isLoading, - isReady: !enabled || query.isSuccess, + isReady: externalObjectsFeature.isLoaded && (!enabled || query.isSuccess), }; } @@ -193,7 +211,8 @@ export function useProjectExternalObjectSummary(projectId: string | null | undef summary: ExternalObjectSummary | null; isLoading: boolean; } { - const enabled = Boolean(projectId); + const externalObjectsFeature = useExternalObjectsFeature(); + const enabled = externalObjectsFeature.isEnabled && Boolean(projectId); const query = useQuery({ queryKey: queryKeys.externalObjects.projectSummary(projectId ?? "__none__"), queryFn: () => externalObjectsApi.getProjectSummary(projectId!), diff --git a/ui/src/pages/Inbox.tsx b/ui/src/pages/Inbox.tsx index 3931740b5d7..5a29c986d4b 100644 --- a/ui/src/pages/Inbox.tsx +++ b/ui/src/pages/Inbox.tsx @@ -733,6 +733,7 @@ export function Inbox() { enabled: !!selectedCompanyId, }); const isolatedWorkspacesEnabled = experimentalSettings?.enableIsolatedWorkspaces === true; + const externalObjectsEnabled = experimentalSettings?.enableExternalObjects === true; const { data: executionWorkspaces = [] } = useQuery({ queryKey: selectedCompanyId ? queryKeys.executionWorkspaces.summaryList(selectedCompanyId) @@ -1389,6 +1390,15 @@ export function Inbox() { issueFilters: { ...previous.issueFilters, ...patch }, })); }, [updateFilterPreferences]); + useEffect(() => { + if (!experimentalSettingsLoaded || externalObjectsEnabled || issueFilters.externalObjectStatuses.length === 0) return; + updateIssueFilters({ externalObjectStatuses: [] }); + }, [ + experimentalSettingsLoaded, + externalObjectsEnabled, + issueFilters.externalObjectStatuses.length, + updateIssueFilters, + ]); const updateAllCategoryFilter = useCallback((value: InboxCategoryFilter) => { updateFilterPreferences((previous) => ({ ...previous, allCategoryFilter: value })); }, [updateFilterPreferences]); @@ -2049,6 +2059,7 @@ export function Inbox() { projects={projects?.map((project) => ({ id: project.id, name: project.name }))} labels={labels?.map((label) => ({ id: label.id, name: label.name, color: label.color }))} currentUserId={currentUserId} + enableExternalObjectFilters={externalObjectsEnabled} enableRoutineVisibilityFilter buttonVariant="outline" iconOnly @@ -2146,6 +2157,7 @@ export function Inbox() { projects={projects?.map((project) => ({ id: project.id, name: project.name }))} labels={labels?.map((label) => ({ id: label.id, name: label.name, color: label.color }))} currentUserId={currentUserId} + enableExternalObjectFilters={externalObjectsEnabled} enableRoutineVisibilityFilter buttonVariant="outline" iconOnly diff --git a/ui/src/pages/InstanceExperimentalSettings.tsx b/ui/src/pages/InstanceExperimentalSettings.tsx index e16ab3501ab..9c769817f27 100644 --- a/ui/src/pages/InstanceExperimentalSettings.tsx +++ b/ui/src/pages/InstanceExperimentalSettings.tsx @@ -243,6 +243,7 @@ export function InstanceExperimentalSettings() { experimentalQuery.data?.enableExperimentalFileViewer === true; const enableTaskWatchdogs = experimentalQuery.data?.enableTaskWatchdogs === true; const enableCloudSync = experimentalQuery.data?.enableCloudSync === true; + const enableExternalObjects = experimentalQuery.data?.enableExternalObjects === true; const autoRestartDevServerWhenIdle = experimentalQuery.data?.autoRestartDevServerWhenIdle === true; const enableIssueGraphLivenessAutoRecovery = experimentalQuery.data?.enableIssueGraphLivenessAutoRecovery === true; @@ -355,6 +356,24 @@ export function InstanceExperimentalSettings() {
+
+
+
+

Enable External Objects

+

+ Detect external URLs in issues and show resolved status for pull requests, tickets, and other referenced + work objects. +

+
+ toggleMutation.mutate({ enableExternalObjects: !enableExternalObjects })} + disabled={toggleMutation.isPending} + aria-label="Toggle external objects experimental setting" + /> +
+
+
diff --git a/ui/src/pages/IssueDetail.test.tsx b/ui/src/pages/IssueDetail.test.tsx index 961fba00c07..43c894c9a87 100644 --- a/ui/src/pages/IssueDetail.test.tsx +++ b/ui/src/pages/IssueDetail.test.tsx @@ -957,6 +957,7 @@ describe("IssueDetail", () => { mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIssuePlanDecompositions: false, enableExperimentalFileViewer: false, + enableExternalObjects: false, }); mockIssuesApi.listAcceptedPlanDecompositions.mockResolvedValue([]); conferenceRoomChatFlag.enabled = true; diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index 54042b1c22f..8ada9110366 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -2871,10 +2871,10 @@ export function IssueDetail() { onAddSubIssue={openNewSubIssue} onUpdate={handleIssuePropertiesUpdate} hasActiveRun={resolvedHasActiveRun} - externalObjects={externalObjectsState.groups} - externalObjectsLoading={externalObjectsState.isLoading} - externalObjectsError={externalObjectsState.isError} - onRetryExternalObjects={externalObjectsState.refetch} + externalObjects={externalObjectsState.isEnabled ? externalObjectsState.groups : undefined} + externalObjectsLoading={externalObjectsState.isEnabled ? externalObjectsState.isLoading : undefined} + externalObjectsError={externalObjectsState.isEnabled ? externalObjectsState.isError : undefined} + onRetryExternalObjects={externalObjectsState.isEnabled ? externalObjectsState.refetch : undefined} /> ); return () => closePanel(); @@ -2887,6 +2887,7 @@ export function IssueDetail() { panelChildIssues, panelIssue, resolvedHasActiveRun, + externalObjectsState.isEnabled, externalObjectsState.groups, externalObjectsState.isLoading, externalObjectsState.isError, @@ -3967,7 +3968,7 @@ export function IssueDetail() { multiline foldable mentions={mentionOptions} - externalReferences={externalObjectsState.markdownReferences} + externalReferences={externalObjectsState.isEnabled ? externalObjectsState.markdownReferences : undefined} imageUploadHandler={async (file) => { const attachment = await uploadAttachment.mutateAsync(file); return attachment.contentPath; @@ -4070,7 +4071,7 @@ export function IssueDetail() { feedbackDataSharingPreference={feedbackDataSharingPreference} feedbackTermsUrl={FEEDBACK_TERMS_URL} mentions={mentionOptions} - externalReferences={externalObjectsState.markdownReferences} + externalReferences={externalObjectsState.isEnabled ? externalObjectsState.markdownReferences : undefined} imageUploadHandler={async (file) => { const attachment = await uploadAttachment.mutateAsync(file); return attachment.contentPath; @@ -4262,7 +4263,7 @@ export function IssueDetail() { resumeFromBacklogPending={ updateIssue.isPending && updateIssue.variables?.status === "todo" } - externalReferences={externalObjectsState.markdownReferences} + externalReferences={externalObjectsState.isEnabled ? externalObjectsState.markdownReferences : undefined} /> ) : null} @@ -4286,7 +4287,7 @@ export function IssueDetail() { }} onCheckMonitorNow={() => checkIssueMonitorNow.mutate()} checkingMonitorNow={checkIssueMonitorNow.isPending} - externalReferences={externalObjectsState.markdownReferences} + externalReferences={externalObjectsState.isEnabled ? externalObjectsState.markdownReferences : undefined} /> ) : null} @@ -4294,10 +4295,11 @@ export function IssueDetail() { @@ -4486,10 +4488,10 @@ export function IssueDetail() { onUpdate={(data) => updateIssue.mutate(data)} inline hasActiveRun={resolvedHasActiveRun} - externalObjects={externalObjectsState.groups} - externalObjectsLoading={externalObjectsState.isLoading} - externalObjectsError={externalObjectsState.isError} - onRetryExternalObjects={externalObjectsState.refetch} + externalObjects={externalObjectsState.isEnabled ? externalObjectsState.groups : undefined} + externalObjectsLoading={externalObjectsState.isEnabled ? externalObjectsState.isLoading : undefined} + externalObjectsError={externalObjectsState.isEnabled ? externalObjectsState.isError : undefined} + onRetryExternalObjects={externalObjectsState.isEnabled ? externalObjectsState.refetch : undefined} />