feat(gitlab): filter issues by milestone (#3278)

Co-authored-by: nova28 <17953305+nova28@users.noreply.github.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
This commit is contained in:
Henry
2026-09-02 16:55:31 +00:00
committed by GitHub
parent c1161e0fbe
commit ffec4aade9
47 changed files with 3945 additions and 272 deletions
@@ -0,0 +1,91 @@
import { cleanup, render, screen, within } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";
import type { Issue } from "@/lib/types/gitlab";
import { IssueList } from "./issue-list";
afterEach(() => cleanup());
function fakeIssue(overrides: Partial<Issue> = {}): Issue {
return {
id: 1,
iid: 1,
project_id: 1,
title: "Fix the thing",
body: "",
url: "",
web_url: "",
state: "opened",
author_username: "alice",
project_namespace: "acme",
project_path: "acme/api",
labels: [],
assignees: [],
milestone: "",
created_at: new Date().toISOString(),
updated_at: "",
...overrides,
};
}
describe("IssueList milestone chip", () => {
it("renders no chip when the issue has no milestone, and leaves the label chips unmoved (Scenario 14)", () => {
render(
<IssueList
items={[fakeIssue({ labels: ["bug", "frontend"] })]}
loading={false}
error={null}
/>,
);
expect(screen.queryByTestId("gitlab-issue-milestone")).toBeNull();
expect(screen.getByText("bug")).not.toBeNull();
expect(screen.getByText("frontend")).not.toBeNull();
});
it("positions the milestone chip after the project#iid/author/time metadata and before the first label chip (Scenario 14)", () => {
render(
<IssueList
items={[fakeIssue({ milestone: "Next", labels: ["bug", "frontend"] })]}
loading={false}
error={null}
/>,
);
const row = screen.getByTestId("issue-row");
const metadata = within(row).getByText(/acme\/api#1/);
const chip = within(row).getByTestId("gitlab-issue-milestone");
const firstLabel = within(row).getByText("bug");
expect(chip.textContent).toBe("Next");
// DOM order: metadata, then the milestone chip, then the first label chip.
expect(
// eslint-disable-next-line no-bitwise -- Node.compareDocumentPosition bitmask, not a numeric flag
metadata.compareDocumentPosition(chip) & Node.DOCUMENT_POSITION_FOLLOWING,
).not.toBe(0);
expect(
// eslint-disable-next-line no-bitwise -- Node.compareDocumentPosition bitmask, not a numeric flag
chip.compareDocumentPosition(firstLabel) & Node.DOCUMENT_POSITION_FOLLOWING,
).not.toBe(0);
});
it("does not consume a label slot: a milestone plus six labels renders the chip, four labels, and a +2 overflow indicator (Scenario 26)", () => {
render(
<IssueList
items={[
fakeIssue({
milestone: "Next",
labels: ["l1", "l2", "l3", "l4", "l5", "l6"],
}),
]}
loading={false}
error={null}
/>,
);
const row = screen.getByTestId("issue-row");
expect(within(row).getByTestId("gitlab-issue-milestone").textContent).toBe("Next");
for (const label of ["l1", "l2", "l3", "l4"]) {
expect(within(row).getByText(label)).not.toBeNull();
}
expect(within(row).queryByText("l5")).toBeNull();
expect(within(row).queryByText("l6")).toBeNull();
expect(within(row).getByText("+2")).not.toBeNull();
});
});
@@ -21,6 +21,19 @@ type IssueListProps = {
host?: string;
};
function IssueMilestoneChip({ milestone }: { milestone: string }) {
if (!milestone) return null;
return (
<Badge
variant="outline"
className="text-[10px] px-1.5 py-0 h-4"
data-testid="gitlab-issue-milestone"
>
{milestone}
</Badge>
);
}
function IssueLabels({ labels }: { labels: string[] }) {
if (!labels?.length) return null;
return (
@@ -76,6 +89,7 @@ function IssueRow({
time: formatRelativeTime(issue.created_at),
})}
</span>
<IssueMilestoneChip milestone={issue.milestone ?? ""} />
<IssueLabels labels={issue.labels} />
</div>
</div>
@@ -0,0 +1,124 @@
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { TooltipProvider } from "@kandev/ui/tooltip";
import { ListToolbar } from "./list-toolbar";
afterEach(() => cleanup());
const MILESTONE_TEST_ID = "gitlab-milestone-filter";
function renderToolbar({
milestone = "",
committedMilestone = "",
}: { milestone?: string; committedMilestone?: string } = {}) {
const onMilestoneChange = vi.fn();
const onCommitMilestone = vi.fn();
render(
<TooltipProvider>
<ListToolbar
title="Assigned to me"
count={2}
loading={false}
lastFetchedAt={null}
customQuery=""
committedQuery=""
onCustomQueryChange={vi.fn()}
onCommitCustomQuery={vi.fn()}
projectFilter=""
onProjectFilterChange={vi.fn()}
projectOptions={[]}
onRefresh={vi.fn()}
showMilestoneFilter
milestone={milestone}
committedMilestone={committedMilestone}
onMilestoneChange={onMilestoneChange}
onCommitMilestone={onCommitMilestone}
/>
</TooltipProvider>,
);
return { onMilestoneChange, onCommitMilestone };
}
describe("MilestoneFilterInput", () => {
it("does not render when showMilestoneFilter is false", () => {
render(
<TooltipProvider>
<ListToolbar
title="Assigned to me"
count={2}
loading={false}
lastFetchedAt={null}
customQuery=""
committedQuery=""
onCustomQueryChange={vi.fn()}
onCommitCustomQuery={vi.fn()}
projectFilter=""
onProjectFilterChange={vi.fn()}
projectOptions={[]}
onRefresh={vi.fn()}
showMilestoneFilter={false}
milestone=""
committedMilestone=""
onMilestoneChange={vi.fn()}
onCommitMilestone={vi.fn()}
/>
</TooltipProvider>,
);
expect(screen.queryByTestId(MILESTONE_TEST_ID)).toBeNull();
});
it("commits on blur when the draft differs from the committed milestone", () => {
const { onCommitMilestone } = renderToolbar({ milestone: "Next", committedMilestone: "" });
fireEvent.blur(screen.getByTestId(MILESTONE_TEST_ID));
expect(onCommitMilestone).toHaveBeenCalledTimes(1);
});
it("does not commit on blur when the draft matches the committed milestone", () => {
const { onCommitMilestone } = renderToolbar({ milestone: "Next", committedMilestone: "Next" });
fireEvent.blur(screen.getByTestId(MILESTONE_TEST_ID));
expect(onCommitMilestone).not.toHaveBeenCalled();
});
it("commits on Enter and prevents the default form submission", () => {
const { onCommitMilestone } = renderToolbar({ milestone: "Next", committedMilestone: "" });
const input = screen.getByTestId(MILESTONE_TEST_ID);
const event = fireEvent.keyDown(input, { key: "Enter" });
expect(onCommitMilestone).toHaveBeenCalledTimes(1);
expect(event).toBe(false);
});
it("does not commit while an IME is composing a milestone candidate", () => {
const { onCommitMilestone } = renderToolbar({ milestone: "次", committedMilestone: "" });
fireEvent.keyDown(screen.getByTestId(MILESTONE_TEST_ID), {
key: "Enter",
isComposing: true,
});
expect(onCommitMilestone).not.toHaveBeenCalled();
});
it("does not commit on keys other than Enter", () => {
const { onCommitMilestone } = renderToolbar({ milestone: "Next", committedMilestone: "" });
fireEvent.keyDown(screen.getByTestId(MILESTONE_TEST_ID), { key: "Escape" });
expect(onCommitMilestone).not.toHaveBeenCalled();
});
it("reports every keystroke through onMilestoneChange without committing", () => {
const { onMilestoneChange, onCommitMilestone } = renderToolbar();
fireEvent.change(screen.getByTestId(MILESTONE_TEST_ID), { target: { value: "Sprint 42" } });
expect(onMilestoneChange).toHaveBeenCalledWith("Sprint 42");
expect(onCommitMilestone).not.toHaveBeenCalled();
});
});
@@ -1,6 +1,7 @@
"use client";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select";
import { Input } from "@kandev/ui/input";
import { useTranslation } from "react-i18next";
import { IntegrationListToolbar } from "@/components/integrations/integration-list-toolbar";
@@ -19,8 +20,76 @@ type ListToolbarProps = {
onProjectFilterChange: (value: string) => void;
projectOptions: string[];
onRefresh: () => void;
showMilestoneFilter: boolean;
milestone: string;
committedMilestone: string;
onMilestoneChange: (value: string) => void;
onCommitMilestone: () => void;
};
function ProjectFilterSelect({
projectFilter,
onProjectFilterChange,
projectOptions,
}: Pick<ListToolbarProps, "projectFilter" | "onProjectFilterChange" | "projectOptions">) {
const { t } = useTranslation();
const selectValue = projectFilter || ALL_PROJECTS;
return (
<Select
value={selectValue}
onValueChange={(value) => onProjectFilterChange(value === ALL_PROJECTS ? "" : value)}
>
<SelectTrigger
className="h-8 w-full cursor-pointer md:w-[220px]"
data-testid="gitlab-project-filter-trigger"
>
<SelectValue placeholder={t("gitlab:allProjects")} />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL_PROJECTS} className="cursor-pointer">
{t("gitlab:allProjects")}
</SelectItem>
{projectOptions.map((key) => (
<SelectItem key={key} value={key} className="cursor-pointer">
{key}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
function MilestoneFilterInput({
milestone,
committedMilestone,
onMilestoneChange,
onCommitMilestone,
}: Pick<
ListToolbarProps,
"milestone" | "committedMilestone" | "onMilestoneChange" | "onCommitMilestone"
>) {
const { t } = useTranslation();
return (
<Input
value={milestone}
onChange={(event) => onMilestoneChange(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter" && !event.nativeEvent.isComposing && event.keyCode !== 229) {
event.preventDefault();
onCommitMilestone();
}
}}
onBlur={() => {
if (milestone !== committedMilestone) onCommitMilestone();
}}
placeholder={t("gitlab:eGSprint42")}
aria-label={t("gitlab:milestoneFilterLabel")}
className="h-11 w-full md:h-8 md:w-[180px]"
data-testid="gitlab-milestone-filter"
/>
);
}
export function ListToolbar({
title,
count,
@@ -34,9 +103,13 @@ export function ListToolbar({
onProjectFilterChange,
projectOptions,
onRefresh,
showMilestoneFilter,
milestone,
committedMilestone,
onMilestoneChange,
onCommitMilestone,
}: ListToolbarProps) {
const { t } = useTranslation();
const selectValue = projectFilter || ALL_PROJECTS;
return (
<IntegrationListToolbar
title={title}
@@ -49,27 +122,21 @@ export function ListToolbar({
onCommitCustomQuery={onCommitCustomQuery}
onRefresh={onRefresh}
filter={
<Select
value={selectValue}
onValueChange={(value) => onProjectFilterChange(value === ALL_PROJECTS ? "" : value)}
>
<SelectTrigger
className="h-8 w-full cursor-pointer md:w-[220px]"
data-testid="gitlab-project-filter-trigger"
>
<SelectValue placeholder={t("gitlab:allProjects")} />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL_PROJECTS} className="cursor-pointer">
{t("gitlab:allProjects")}
</SelectItem>
{projectOptions.map((key) => (
<SelectItem key={key} value={key} className="cursor-pointer">
{key}
</SelectItem>
))}
</SelectContent>
</Select>
<>
<ProjectFilterSelect
projectFilter={projectFilter}
onProjectFilterChange={onProjectFilterChange}
projectOptions={projectOptions}
/>
{showMilestoneFilter ? (
<MilestoneFilterInput
milestone={milestone}
committedMilestone={committedMilestone}
onMilestoneChange={onMilestoneChange}
onCommitMilestone={onCommitMilestone}
/>
) : null}
</>
}
queryPlaceholder={t("gitlab:customQueryPressEnterEG")}
titleTestId="gitlab-list-toolbar-title"
@@ -167,6 +167,7 @@ describe("GitLab QuickTaskLauncher recovery", () => {
project_path: "group/project",
labels: [],
assignees: [],
milestone: "",
created_at: FIXED_DATE,
updated_at: FIXED_DATE,
},
@@ -110,6 +110,7 @@ function fakeIssue(overrides: Partial<Issue> = {}): Issue {
project_path: "acme/api",
labels: [],
assignees: [],
milestone: "",
created_at: "",
updated_at: "",
...overrides,
@@ -225,6 +226,73 @@ describe("useGitLabSearch — fetch wiring", () => {
});
});
describe("useGitLabSearch — milestone", () => {
beforeEach(resetMocks);
it("forwards the milestone to the issues API", async () => {
searchUserIssuesMock.mockResolvedValue({ issues: [], total_count: 0, page: 1, per_page: 25 });
renderHook(() =>
useGitLabSearch({
workspaceId: WORKSPACE_ID,
kind: "issue",
presets: PRESETS,
preset: PRESET_ASSIGNED,
customQuery: "",
milestone: "Next",
}),
);
await waitFor(() => expect(searchUserIssuesMock).toHaveBeenCalled());
const args = searchUserIssuesMock.mock.calls[0][0] as Record<string, unknown>;
expect(args.milestone).toBe("Next");
});
it("never forwards a milestone to the MR API", async () => {
searchUserMRsMock.mockResolvedValue(EMPTY_PAGE);
renderHook(() =>
useGitLabSearch({
workspaceId: WORKSPACE_ID,
kind: "mr",
presets: PRESETS,
preset: PRESET_ASSIGNED,
customQuery: "",
milestone: "Next",
}),
);
await waitFor(() => expect(searchUserMRsMock).toHaveBeenCalled());
const args = searchUserMRsMock.mock.calls[0][0] as Record<string, unknown>;
expect(args).not.toHaveProperty("milestone");
});
it("refetches when the milestone changes, without moving the current page", async () => {
searchUserIssuesMock.mockResolvedValue({ issues: [], total_count: 0, page: 1, per_page: 25 });
const { result, rerender } = renderHook(
({ milestone }: { milestone: string }) =>
useGitLabSearch({
workspaceId: WORKSPACE_ID,
kind: "issue",
presets: PRESETS,
preset: PRESET_ASSIGNED,
customQuery: "",
milestone,
}),
{ initialProps: { milestone: "" } },
);
await waitFor(() => expect(searchUserIssuesMock).toHaveBeenCalledTimes(1));
act(() => result.current.setPage(3));
await waitFor(() => expect(result.current.page).toBe(3));
await waitFor(() => expect(searchUserIssuesMock).toHaveBeenCalledTimes(2));
rerender({ milestone: "Next" });
await waitFor(() => expect(searchUserIssuesMock).toHaveBeenCalledTimes(3));
const args = searchUserIssuesMock.mock.calls[2][0] as Record<string, unknown>;
expect(args.milestone).toBe("Next");
// The hook itself does not reset the page on a milestone change — callers
// that commit a milestone are responsible for calling setPage(1) in the
// same event handler (see use-gitlab-page-state.ts).
expect(args.page).toBe(3);
});
});
describe("useGitLabSearch — state", () => {
beforeEach(resetMocks);
@@ -23,6 +23,7 @@ type SearchState = {
type FetchArgs = {
filter: string;
customQuery: string;
milestone: string;
page: number;
};
@@ -32,6 +33,7 @@ type UseGitLabSearchOptions = {
presets: PresetOption[];
preset: string;
customQuery: string;
milestone?: string;
projectFilter?: string;
// Gate the network fetch on the integration being connected. When GitLab is
// not configured the page renders the connect notice instead of the list, so
@@ -73,12 +75,42 @@ function filterByProject(items: Item[], project: string): Item[] {
return items.filter((it) => it.project_path === project);
}
async function fetchGitLabSearchPage(
kind: SearchKind,
workspaceId: string,
args: FetchArgs,
): Promise<Omit<SearchState, "workspaceId">> {
const params = {
workspaceId,
filter: args.filter,
customQuery: args.customQuery,
page: args.page,
perPage: SEARCH_PAGE_SIZE,
};
const response =
kind === "mr"
? await searchUserMRs(params)
: await searchUserIssues({ ...params, milestone: args.milestone });
const items: Item[] =
kind === "mr"
? ((response as { mrs: MR[] | null } | null)?.mrs ?? [])
: ((response as { issues: Issue[] | null } | null)?.issues ?? []);
return {
items,
loading: false,
error: null,
lastFetchedAt: new Date(),
total: response?.total_count ?? items.length,
};
}
export function useGitLabSearch({
workspaceId,
kind,
presets,
preset,
customQuery,
milestone = "",
projectFilter = "",
enabled = true,
}: UseGitLabSearchOptions) {
@@ -91,7 +123,7 @@ export function useGitLabSearch({
}, [preset, customQuery, kind]);
const fetchData = useCallback(
async ({ filter: ef, customQuery: ec, page: epage }: FetchArgs) => {
async (args: FetchArgs) => {
if (!enabled || !workspaceId) return;
const seq = ++requestSeq.current;
const requestedWorkspaceId = workspaceId;
@@ -108,28 +140,9 @@ export function useGitLabSearch({
},
);
try {
const params = {
workspaceId,
filter: ef,
customQuery: ec,
page: epage,
perPage: SEARCH_PAGE_SIZE,
};
const response =
kind === "mr" ? await searchUserMRs(params) : await searchUserIssues(params);
const next = await fetchGitLabSearchPage(kind, workspaceId, args);
if (seq !== requestSeq.current) return;
const items: Item[] =
kind === "mr"
? ((response as { mrs: MR[] | null } | null)?.mrs ?? [])
: ((response as { issues: Issue[] | null } | null)?.issues ?? []);
setState({
items,
loading: false,
error: null,
lastFetchedAt: new Date(),
total: response?.total_count ?? items.length,
workspaceId: requestedWorkspaceId,
});
setState({ ...next, workspaceId: requestedWorkspaceId });
} catch (err) {
if (seq !== requestSeq.current) return;
setState((s) => ({
@@ -155,12 +168,18 @@ export function useGitLabSearch({
// its run callback and its debounce effect) so a disabled integration never
// schedules a fetch. The callback keeps its own guard to also cover refresh().
if (!enabled || !workspaceId) return;
void fetchData({ filter: resolved.filter, customQuery: resolved.customQuery, page });
}, [fetchData, enabled, workspaceId, resolved.filter, resolved.customQuery, page]);
void fetchData({
filter: resolved.filter,
customQuery: resolved.customQuery,
milestone,
page,
});
}, [fetchData, enabled, workspaceId, resolved.filter, resolved.customQuery, milestone, page]);
const refresh = useCallback(
() => fetchData({ filter: resolved.filter, customQuery: resolved.customQuery, page }),
[fetchData, resolved.filter, resolved.customQuery, page],
() =>
fetchData({ filter: resolved.filter, customQuery: resolved.customQuery, milestone, page }),
[fetchData, resolved.filter, resolved.customQuery, milestone, page],
);
const isCurrentWorkspace = state.workspaceId === workspaceId;
@@ -21,6 +21,8 @@ const valid: SavedPreset = {
label: "My MRs",
customQuery: "scope=created_by_me",
projectFilter: "",
milestone: "",
preset: "",
createdAt: "2026-01-01T00:00:00Z",
};
@@ -45,6 +47,8 @@ describe("useSavedPresets (gitlab)", () => {
label: "My MRs",
customQuery: "scope=created_by_me",
projectFilter: "",
milestone: "",
preset: "",
});
});
expect(result.current.presets).toHaveLength(1);
@@ -64,6 +68,8 @@ describe("useSavedPresets (gitlab)", () => {
label: "Triage",
customQuery: "labels=bug",
projectFilter: "",
milestone: "",
preset: "",
});
});
expect(result.current.presets).toHaveLength(1);
@@ -80,6 +86,8 @@ describe("useSavedPresets (gitlab)", () => {
label: "Shared",
customQuery: "q",
projectFilter: "",
milestone: "",
preset: "",
});
});
expect(a.result.current.presets).toHaveLength(1);
@@ -97,3 +105,93 @@ describe("useSavedPresets (gitlab)", () => {
expect(updateUserSettings).not.toHaveBeenCalled();
});
});
const LEGACY_CREATED_AT = "2025-01-01T00:00:00Z";
describe("useSavedPresets (gitlab) — legacy preset field normalization", () => {
beforeEach(() => {
vi.clearAllMocks();
window.localStorage.clear();
__resetSnapshotForTests();
vi.mocked(updateUserSettings).mockResolvedValue({
settings: {},
} as Awaited<ReturnType<typeof updateUserSettings>>);
});
it("keeps a legacy preset with neither a milestone nor a preset key, defaulting both to empty", async () => {
const legacy = {
id: "g_legacy",
kind: "issue",
label: "Legacy triage",
customQuery: "labels=bug",
projectFilter: "",
createdAt: LEGACY_CREATED_AT,
};
vi.mocked(fetchUserSettings).mockResolvedValue({
settings: { gitlab_saved_presets: [legacy] },
} as Awaited<ReturnType<typeof fetchUserSettings>>);
const { result } = renderHook(() => useSavedPresets());
await waitFor(() => expect(result.current.presets).toHaveLength(1));
expect(result.current.presets[0]).toMatchObject({
id: "g_legacy",
milestone: "",
preset: "",
});
});
it("normalizes a non-string milestone/preset (number, null, object) to empty rather than dropping the entry", async () => {
const withBadTypes = [
{
id: "g_number",
kind: "issue",
label: "Number",
customQuery: "",
projectFilter: "",
createdAt: LEGACY_CREATED_AT,
milestone: 42,
preset: null,
},
{
id: "g_object",
kind: "issue",
label: "Object",
customQuery: "",
projectFilter: "",
createdAt: LEGACY_CREATED_AT,
milestone: { title: "Next" },
preset: 7,
},
];
vi.mocked(fetchUserSettings).mockResolvedValue({
settings: { gitlab_saved_presets: withBadTypes },
} as Awaited<ReturnType<typeof fetchUserSettings>>);
const { result } = renderHook(() => useSavedPresets());
await waitFor(() => expect(result.current.presets).toHaveLength(2));
expect(result.current.presets[0]).toMatchObject({ id: "g_number", milestone: "", preset: "" });
expect(result.current.presets[1]).toMatchObject({ id: "g_object", milestone: "", preset: "" });
});
it("still rejects an entry missing a required legacy field", async () => {
const missingLabel = [
{
id: "g_bad",
kind: "issue",
customQuery: "",
projectFilter: "",
createdAt: LEGACY_CREATED_AT,
},
];
vi.mocked(fetchUserSettings).mockResolvedValue({
settings: { gitlab_saved_presets: missingLabel },
} as Awaited<ReturnType<typeof fetchUserSettings>>);
const { result } = renderHook(() => useSavedPresets());
await waitFor(() => expect(fetchUserSettings).toHaveBeenCalled());
expect(result.current.presets).toEqual([]);
});
});
@@ -10,6 +10,12 @@ export type SavedPreset = {
label: string;
customQuery: string;
projectFilter: string;
// Milestone title in effect when the query was saved, and the sidebar
// preset value it was saved under. Both "" when none. Presets written
// before this feature carry neither key; isSavedPreset tolerates a missing
// or non-string value for either and normalizeSavedPreset reads it as "".
milestone: string;
preset: string;
createdAt: string;
};
@@ -39,7 +45,10 @@ function publish(next: SavedPreset[]) {
for (const l of listeners) l();
}
function isSavedPreset(p: unknown): p is SavedPreset {
// Checks the fields every SavedPreset has always had. milestone/preset are
// deliberately not checked here: a preset saved before this feature has
// neither key, and that SHALL NOT disqualify it (Scenario 13).
function isSavedPreset(p: unknown): p is Omit<SavedPreset, "milestone" | "preset"> {
return (
typeof p === "object" &&
p !== null &&
@@ -52,9 +61,29 @@ function isSavedPreset(p: unknown): p is SavedPreset {
);
}
// A missing value, or one present but not a string, reads as "" (Scenario 23).
function normalizeStoredString(value: unknown): string {
return typeof value === "string" ? value : "";
}
function normalizeSavedPreset(p: unknown): SavedPreset | null {
if (!isSavedPreset(p)) return null;
const raw = p as Record<string, unknown>;
return {
...p,
milestone: normalizeStoredString(raw.milestone),
preset: normalizeStoredString(raw.preset),
};
}
function readServerPresets(value: unknown): SavedPreset[] | null {
if (!Array.isArray(value)) return null;
return value.filter(isSavedPreset);
const normalized: SavedPreset[] = [];
for (const item of value) {
const preset = normalizeSavedPreset(item);
if (preset) normalized.push(preset);
}
return normalized;
}
const syncServer = createQueuedUserSettingsSync<SavedPreset[]>((next) => ({