feat(FN-3426): add plugin AI security scan gate and GraphTaskNode wrapper t
Adds a plugin AI security scan gate that enforces security scanning before plugin installation, including new `plugin-security-scan.ts` infrastructure, CLI integration (`fn plugin install`), plugin loader hooks, and a changeset. Implements branch filter persistence across sessions in the dashboard ( Fusion-Task-Id: FN-3426
This commit is contained in:
@@ -18,6 +18,7 @@ Features:
|
||||
|
||||
- Drag-and-drop between lifecycle columns
|
||||
- Search/filter tasks (including working-branch and base-branch dropdown filters with explicit **No working branch** / **No base branch** options)
|
||||
- Working-branch and base-branch filter selections are persisted per project and restored across refresh/navigation
|
||||
- Column visibility controls
|
||||
- Inline quick entry creation
|
||||
- PR/issue badges with live updates
|
||||
|
||||
@@ -116,6 +116,8 @@ function prefetchLazyViews() {
|
||||
|
||||
const SETUP_WARNING_DISMISSED_KEY = "kb-setup-warning-dismissed";
|
||||
const ACTIVE_CHAT_SESSION_STORAGE_KEY = "kb-chat-active-session";
|
||||
const WORKING_BRANCH_FILTER_STORAGE_KEY = "kb-dashboard-working-branch-filter";
|
||||
const BASE_BRANCH_FILTER_STORAGE_KEY = "kb-dashboard-base-branch-filter";
|
||||
const NO_BRANCH_FILTER_VALUE = "__fusion:no-branch__";
|
||||
|
||||
function buildRemoteDashboardUrl(serverUrl: string, authToken?: string | null): string {
|
||||
@@ -209,6 +211,21 @@ function AppInner() {
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [branchFilter, setBranchFilter] = useState("");
|
||||
const [baseBranchFilter, setBaseBranchFilter] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
setBranchFilter(getScopedItem(WORKING_BRANCH_FILTER_STORAGE_KEY, currentProject?.id) ?? "");
|
||||
setBaseBranchFilter(getScopedItem(BASE_BRANCH_FILTER_STORAGE_KEY, currentProject?.id) ?? "");
|
||||
}, [currentProject?.id]);
|
||||
|
||||
const handleBranchFilterChange = useCallback((value: string) => {
|
||||
setBranchFilter(value);
|
||||
setScopedItem(WORKING_BRANCH_FILTER_STORAGE_KEY, value, currentProject?.id);
|
||||
}, [currentProject?.id]);
|
||||
|
||||
const handleBaseBranchFilterChange = useCallback((value: string) => {
|
||||
setBaseBranchFilter(value);
|
||||
setScopedItem(BASE_BRANCH_FILTER_STORAGE_KEY, value, currentProject?.id);
|
||||
}, [currentProject?.id]);
|
||||
|
||||
// Remote node data and events when in remote mode (pass searchQuery for server-side filtering)
|
||||
const remoteData = useRemoteNodeData(currentNodeId, { projectId: currentProject?.id, searchQuery: searchQuery || undefined });
|
||||
@@ -1316,8 +1333,8 @@ function AppInner() {
|
||||
baseBranchFilter={baseBranchFilter}
|
||||
branchOptions={branchOptions}
|
||||
baseBranchOptions={baseBranchOptions}
|
||||
onBranchFilterChange={setBranchFilter}
|
||||
onBaseBranchFilterChange={setBaseBranchFilter}
|
||||
onBranchFilterChange={handleBranchFilterChange}
|
||||
onBaseBranchFilterChange={handleBaseBranchFilterChange}
|
||||
projects={effectiveProjects}
|
||||
currentProject={currentProject}
|
||||
onSelectProject={handleSelectProject}
|
||||
|
||||
@@ -505,6 +505,7 @@ async function waitForAppShell(): Promise<void> {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
mockSubscribeSse.mockReset();
|
||||
mockSubscribeSse.mockReturnValue(vi.fn());
|
||||
mockCreateTask.mockReset();
|
||||
@@ -3260,6 +3261,13 @@ describe("FN-3290: modal keyboard isolation for mobile dashboard layout", () =>
|
||||
});
|
||||
|
||||
describe("App board branch filters", () => {
|
||||
const WORKING_BRANCH_FILTER_STORAGE_KEY = "kb-dashboard-working-branch-filter";
|
||||
const BASE_BRANCH_FILTER_STORAGE_KEY = "kb-dashboard-base-branch-filter";
|
||||
|
||||
function scopedProjectKey(baseKey: string, projectId: string) {
|
||||
return `kb:${projectId}:${baseKey}`;
|
||||
}
|
||||
|
||||
function makeTask(id: string, title: string, branch?: string, baseBranch?: string) {
|
||||
return {
|
||||
id,
|
||||
@@ -3390,6 +3398,109 @@ describe("App board branch filters", () => {
|
||||
remoteSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("restores saved branch filter selections per project", async () => {
|
||||
const projectId = "project-restore";
|
||||
mockCurrentProjectState.currentProject = {
|
||||
id: projectId,
|
||||
name: "Restore Project",
|
||||
path: "/restore",
|
||||
status: "active",
|
||||
isolationMode: "in-process",
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
};
|
||||
localStorage.setItem(scopedProjectKey(WORKING_BRANCH_FILTER_STORAGE_KEY, projectId), "feature/a");
|
||||
localStorage.setItem(scopedProjectKey(BASE_BRANCH_FILTER_STORAGE_KEY, projectId), "__fusion:no-branch__");
|
||||
|
||||
mockUseTasks.mockImplementation(() => ({
|
||||
tasks: [
|
||||
makeTask("FN-1", "Restore Candidate", "feature/a"),
|
||||
makeTask("FN-2", "Filtered Out", "feature/b", "main"),
|
||||
],
|
||||
createTask: mockCreateTask,
|
||||
moveTask: vi.fn(),
|
||||
deleteTask: vi.fn(),
|
||||
mergeTask: vi.fn(),
|
||||
retryTask: vi.fn(),
|
||||
updateTask: vi.fn(),
|
||||
duplicateTask: vi.fn(),
|
||||
archiveTask: vi.fn(),
|
||||
unarchiveTask: vi.fn(),
|
||||
archiveAllDone: vi.fn(),
|
||||
}));
|
||||
|
||||
render(<App />);
|
||||
await waitForAppShell();
|
||||
|
||||
fireEvent.click(screen.getByTestId("desktop-header-search-btn"));
|
||||
|
||||
expect((screen.getByTestId("working-branch-filter") as HTMLSelectElement).value).toBe("feature/a");
|
||||
expect((screen.getByTestId("target-branch-filter") as HTMLSelectElement).value).toBe("__fusion:no-branch__");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Restore Candidate")).toBeTruthy();
|
||||
expect(screen.queryByText("Filtered Out")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("writes updated filter values to project-scoped storage and isolates between projects", async () => {
|
||||
const projectOneId = "project-one";
|
||||
const projectTwoId = "project-two";
|
||||
mockCurrentProjectState.currentProject = {
|
||||
id: projectOneId,
|
||||
name: "Project One",
|
||||
path: "/one",
|
||||
status: "active",
|
||||
isolationMode: "in-process",
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
};
|
||||
|
||||
mockUseTasks.mockImplementation(() => ({
|
||||
tasks: [makeTask("FN-1", "Alpha Search", "feature/a", "main")],
|
||||
createTask: mockCreateTask,
|
||||
moveTask: vi.fn(),
|
||||
deleteTask: vi.fn(),
|
||||
mergeTask: vi.fn(),
|
||||
retryTask: vi.fn(),
|
||||
updateTask: vi.fn(),
|
||||
duplicateTask: vi.fn(),
|
||||
archiveTask: vi.fn(),
|
||||
unarchiveTask: vi.fn(),
|
||||
archiveAllDone: vi.fn(),
|
||||
}));
|
||||
|
||||
const { rerender } = render(<App />);
|
||||
await waitForAppShell();
|
||||
|
||||
fireEvent.click(screen.getByTestId("desktop-header-search-btn"));
|
||||
fireEvent.change(screen.getByTestId("working-branch-filter"), { target: { value: "feature/a" } });
|
||||
fireEvent.change(screen.getByTestId("target-branch-filter"), { target: { value: "main" } });
|
||||
|
||||
expect(localStorage.getItem(scopedProjectKey(WORKING_BRANCH_FILTER_STORAGE_KEY, projectOneId))).toBe("feature/a");
|
||||
expect(localStorage.getItem(scopedProjectKey(BASE_BRANCH_FILTER_STORAGE_KEY, projectOneId))).toBe("main");
|
||||
|
||||
mockCurrentProjectState.currentProject = {
|
||||
id: projectTwoId,
|
||||
name: "Project Two",
|
||||
path: "/two",
|
||||
status: "active",
|
||||
isolationMode: "in-process",
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
};
|
||||
|
||||
rerender(<App />);
|
||||
await waitForAppShell();
|
||||
|
||||
fireEvent.click(screen.getByTestId("desktop-header-search-btn"));
|
||||
|
||||
expect((screen.getByTestId("working-branch-filter") as HTMLSelectElement).value).toBe("");
|
||||
expect((screen.getByTestId("target-branch-filter") as HTMLSelectElement).value).toBe("");
|
||||
expect(localStorage.getItem(scopedProjectKey(WORKING_BRANCH_FILTER_STORAGE_KEY, projectTwoId))).toBeNull();
|
||||
expect(localStorage.getItem(scopedProjectKey(BASE_BRANCH_FILTER_STORAGE_KEY, projectTwoId))).toBeNull();
|
||||
});
|
||||
|
||||
it("composes with search and does not affect list view tasks", async () => {
|
||||
mockUseTasks.mockImplementation(() => ({
|
||||
tasks: [
|
||||
|
||||
@@ -95,11 +95,24 @@ describe("projectStorage", () => {
|
||||
"kb-usage-modal-size",
|
||||
"kb-usage-provider-order",
|
||||
"kb-chat-active-session",
|
||||
"kb-dashboard-working-branch-filter",
|
||||
"kb-dashboard-base-branch-filter",
|
||||
"kb-files-line-numbers",
|
||||
"fusion-plugin-dependency-graph:positions",
|
||||
]),
|
||||
);
|
||||
expect(PROJECT_STORAGE_KEYS).toHaveLength(21);
|
||||
expect(PROJECT_STORAGE_KEYS).toHaveLength(23);
|
||||
});
|
||||
|
||||
it("stores branch filter values as scoped strings per project", () => {
|
||||
setScopedItem("kb-dashboard-working-branch-filter", "feature/a", "proj-1");
|
||||
setScopedItem("kb-dashboard-base-branch-filter", "__fusion:no-branch__", "proj-1");
|
||||
setScopedItem("kb-dashboard-working-branch-filter", "feature/b", "proj-2");
|
||||
|
||||
expect(getScopedItem("kb-dashboard-working-branch-filter", "proj-1")).toBe("feature/a");
|
||||
expect(getScopedItem("kb-dashboard-base-branch-filter", "proj-1")).toBe("__fusion:no-branch__");
|
||||
expect(getScopedItem("kb-dashboard-working-branch-filter", "proj-2")).toBe("feature/b");
|
||||
expect(getScopedItem("kb-dashboard-working-branch-filter", "proj-3")).toBeNull();
|
||||
});
|
||||
|
||||
it("has no overlap between global and project-scoped keys", () => {
|
||||
|
||||
@@ -28,6 +28,8 @@ export const PROJECT_STORAGE_KEYS: string[] = [
|
||||
"kb-usage-modal-size",
|
||||
"kb-usage-provider-order",
|
||||
"kb-chat-active-session",
|
||||
"kb-dashboard-working-branch-filter",
|
||||
"kb-dashboard-base-branch-filter",
|
||||
"kb-files-line-numbers",
|
||||
"fusion-plugin-dependency-graph:positions",
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user