mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 09:41:04 +01:00
Gate external objects behind experimental flag
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
71d08a6e05
commit
f0c5c1913a
18 files changed
+242
-59
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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!),
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -957,6 +957,7 @@ describe("IssueDetail", () => {
|
||||
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
|
||||
enableIssuePlanDecompositions: false,
|
||||
enableExperimentalFileViewer: false,
|
||||
enableExternalObjects: false,
|
||||
});
|
||||
mockIssuesApi.listAcceptedPlanDecompositions.mockResolvedValue([]);
|
||||
conferenceRoomChatFlag.enabled = true;
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user