Gate external objects behind experimental flag

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-06-23 00:35:05 +00:00
1 parent 71d08a6e05
commit f0c5c1913a
18 files changed
+242 -59

No files matched your search

+1
View File
@@ -53,6 +53,7 @@ export interface InstanceExperimentalSettings {
enableIssuePlanDecompositions: boolean;
enableExperimentalFileViewer: boolean;
enableCloudSync: boolean;
enableExternalObjects: boolean;
autoRestartDevServerWhenIdle: boolean;
enableIssueGraphLivenessAutoRecovery: boolean;
issueGraphLivenessAutoRecoveryLookbackHours: number;
@@ -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
@@ -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 () => {
@@ -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 = {
@@ -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",
+1
View File
@@ -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,
+5 -1
View File
@@ -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);
+19
View File
@@ -345,6 +345,7 @@ export function externalObjectService(
resolvers?: ExternalObjectResolver[];
pluginWorkerManager?: PluginWorkerManager;
github?: GitHubExternalObjectProviderOptions | false;
enabled?: boolean | (() => boolean | Promise<boolean>);
} = {},
) {
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<string, ReturnType<typeof summarizeObjectPayloads>>();
const uniqueIssueIds = [...new Set(issueIds)].filter((id) => id.length > 0);
const summaries = new Map<string, ReturnType<typeof summarizeObjectPayloads>>();
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<LogActivityInput, "actorType" | "actorId" | "agentId" | "runId">;
}) {
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)
+2
View File
@@ -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:
+33 -29
View File
@@ -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({
</div>
) : null}
<div className="space-y-1">
<span className="text-xs text-muted-foreground">External object status</span>
<div className="space-y-0.5">
{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 (
<label
key={value}
className="flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1 hover:bg-accent/50"
>
<Checkbox
checked={state.externalObjectStatuses.includes(value)}
onCheckedChange={() => onChange({ externalObjectStatuses: toggleIssueFilterValue(state.externalObjectStatuses, value) })}
/>
<Icon className={`h-3.5 w-3.5 shrink-0 ${textTone}`} aria-hidden="true" />
<span className="text-sm">{externalObjectFilterLabel(value)}</span>
</label>
);
})}
{enableExternalObjectFilters ? (
<div className="space-y-1">
<span className="text-xs text-muted-foreground">External object status</span>
<div className="space-y-0.5">
{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 (
<label
key={value}
className="flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1 hover:bg-accent/50"
>
<Checkbox
checked={state.externalObjectStatuses.includes(value)}
onCheckedChange={() => onChange({ externalObjectStatuses: toggleIssueFilterValue(state.externalObjectStatuses, value) })}
/>
<Icon className={`h-3.5 w-3.5 shrink-0 ${textTone}`} aria-hidden="true" />
<span className="text-sm">{externalObjectFilterLabel(value)}</span>
</label>
);
})}
</div>
</div>
</div>
) : null}
<div className="space-y-1">
<span className="text-xs text-muted-foreground">Visibility</span>
+10 -6
View File
@@ -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."
/>
<ExternalObjectsSection
groups={externalObjects ?? []}
isLoading={Boolean(externalObjectsLoading)}
isError={Boolean(externalObjectsError)}
onRetry={onRetryExternalObjects}
/>
{externalObjectsEnabled ? (
<ExternalObjectsSection
groups={externalObjects ?? []}
isLoading={Boolean(externalObjectsLoading)}
isError={Boolean(externalObjectsError)}
onRetry={onRetryExternalObjects}
/>
) : null}
<Section
title="Referenced by"
description="Other tasks that currently point at this task."
+35 -1
View File
@@ -307,7 +307,10 @@ describe("IssuesList", () => {
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(
<IssuesList
issues={[createIssue()]}
agents={[]}
projects={[]}
viewStateKey="paperclip:test-issues"
onUpdateIssue={() => 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" });
+8
View File
@@ -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}
+24 -5
View File
@@ -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!),
+12
View File
@@ -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
@@ -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() {
</div>
</section>
<section className="rounded-xl border border-border bg-card p-5">
<div className="flex items-start justify-between gap-4">
<div className="space-y-1.5">
<h2 className="text-sm font-semibold">Enable External Objects</h2>
<p className="max-w-2xl text-sm text-muted-foreground">
Detect external URLs in issues and show resolved status for pull requests, tickets, and other referenced
work objects.
</p>
</div>
<ToggleSwitch
checked={enableExternalObjects}
onCheckedChange={() => toggleMutation.mutate({ enableExternalObjects: !enableExternalObjects })}
disabled={toggleMutation.isPending}
aria-label="Toggle external objects experimental setting"
/>
</div>
</section>
<section className="rounded-xl border border-border bg-card p-5">
<div className="flex items-start justify-between gap-4">
<div className="space-y-1.5">
+1
View File
@@ -957,6 +957,7 @@ describe("IssueDetail", () => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
enableIssuePlanDecompositions: false,
enableExperimentalFileViewer: false,
enableExternalObjects: false,
});
mockIssuesApi.listAcceptedPlanDecompositions.mockResolvedValue([]);
conferenceRoomChatFlag.enabled = true;
+18 -16
View File
@@ -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}
</TabsContent>
@@ -4286,7 +4287,7 @@ export function IssueDetail() {
}}
onCheckMonitorNow={() => checkIssueMonitorNow.mutate()}
checkingMonitorNow={checkIssueMonitorNow.isPending}
externalReferences={externalObjectsState.markdownReferences}
externalReferences={externalObjectsState.isEnabled ? externalObjectsState.markdownReferences : undefined}
/>
) : null}
</TabsContent>
@@ -4294,10 +4295,11 @@ export function IssueDetail() {
<TabsContent value="related-work">
<IssueRelatedWorkPanel
relatedWork={issue.relatedWork}
externalObjects={externalObjectsState.groups}
externalObjectsLoading={externalObjectsState.isLoading}
externalObjectsError={externalObjectsState.isError}
onRetryExternalObjects={externalObjectsState.refetch}
externalObjectsEnabled={externalObjectsState.isEnabled}
externalObjects={externalObjectsState.isEnabled ? externalObjectsState.groups : undefined}
externalObjectsLoading={externalObjectsState.isEnabled ? externalObjectsState.isLoading : undefined}
externalObjectsError={externalObjectsState.isEnabled ? externalObjectsState.isError : undefined}
onRetryExternalObjects={externalObjectsState.isEnabled ? externalObjectsState.refetch : undefined}
/>
</TabsContent>
@@ -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}
/>
</div>
</ScrollArea>