diff --git a/packages/server/prisma/migrations/20260515094021_merge_duplicate_labels/migration.sql b/packages/server/prisma/migrations/20260515094021_merge_duplicate_labels/migration.sql new file mode 100644 index 0000000..7085f57 --- /dev/null +++ b/packages/server/prisma/migrations/20260515094021_merge_duplicate_labels/migration.sql @@ -0,0 +1,47 @@ +-- Step 1: Move issue associations from duplicate labels to the keeper (earliest id per name) +INSERT INTO "_IssueToIssueLabel" ("A", "B") +SELECT DISTINCT dup_assoc."A", keepers.keeper_id +FROM "_IssueToIssueLabel" dup_assoc +JOIN "IssueLabel" dup ON dup.id = dup_assoc."B" +JOIN ( + SELECT name, (MIN(id::text))::uuid AS keeper_id + FROM "IssueLabel" + GROUP BY name + HAVING COUNT(*) > 1 +) keepers ON keepers.name = dup.name AND dup.id != keepers.keeper_id +WHERE NOT EXISTS ( + SELECT 1 FROM "_IssueToIssueLabel" existing + WHERE existing."A" = dup_assoc."A" AND existing."B" = keepers.keeper_id +); + +-- Step 2: Remove associations pointing to duplicate (non-keeper) labels +DELETE FROM "_IssueToIssueLabel" +WHERE "B" IN ( + SELECT dup.id + FROM "IssueLabel" dup + JOIN ( + SELECT name, (MIN(id::text))::uuid AS keeper_id + FROM "IssueLabel" + GROUP BY name + HAVING COUNT(*) > 1 + ) keepers ON keepers.name = dup.name AND dup.id != keepers.keeper_id +); + +-- Step 3: Delete duplicate labels (keep the earliest id per name) +DELETE FROM "IssueLabel" +WHERE id IN ( + SELECT dup.id + FROM "IssueLabel" dup + JOIN ( + SELECT name, (MIN(id::text))::uuid AS keeper_id + FROM "IssueLabel" + GROUP BY name + HAVING COUNT(*) > 1 + ) keepers ON keepers.name = dup.name AND dup.id != keepers.keeper_id +); + +-- Step 4: Add unique constraint on name +ALTER TABLE "IssueLabel" ADD CONSTRAINT "IssueLabel_name_key" UNIQUE ("name"); + +-- Step 5: Drop the now-redundant index (unique constraint already creates one) +DROP INDEX IF EXISTS "IssueLabel_name_idx"; diff --git a/packages/server/prisma/schema.prisma b/packages/server/prisma/schema.prisma index 87af183..2eda543 100644 --- a/packages/server/prisma/schema.prisma +++ b/packages/server/prisma/schema.prisma @@ -62,10 +62,8 @@ model WorkflowState { model IssueLabel { id String @id @default(uuid()) @db.Uuid - name String + name String @unique issues Issue[] - - @@index([name]) } model User { diff --git a/packages/server/src/import-pipeline.test.ts b/packages/server/src/import-pipeline.test.ts index 95a0eb8..b7902a0 100644 --- a/packages/server/src/import-pipeline.test.ts +++ b/packages/server/src/import-pipeline.test.ts @@ -208,7 +208,7 @@ describe('import pipeline', () => { expect(labels.map((l) => l.name)).toEqual(['bug', 'feature', 'urgent']); }); - it('allows multiple imported labels to share the same name without collapsing them', async () => { + it('deduplicates imported labels that share the same name', async () => { const duplicateNameLabels = [ ...FIXTURE_LABELS, { id: 'linear-label-4', name: 'bug', color: '#0000ff' }, @@ -233,7 +233,7 @@ describe('import pipeline', () => { where: { name: 'bug' }, orderBy: { id: 'asc' }, }); - expect(labels).toHaveLength(2); + expect(labels).toHaveLength(1); const importedIssue = await prisma.issue.findUnique({ where: { identifier: 'SON-43' }, @@ -250,8 +250,7 @@ describe('import pipeline', () => { }, orderBy: { oldId: 'asc' }, }); - expect(labelMappings).toHaveLength(2); - expect(new Set(labelMappings.map((mapping) => mapping.newId)).size).toBe(2); + expect(labelMappings).toHaveLength(1); }); it('imports users with name and email preserved', async () => { diff --git a/packages/server/src/import-pipeline.ts b/packages/server/src/import-pipeline.ts index df0d160..0909a6f 100644 --- a/packages/server/src/import-pipeline.ts +++ b/packages/server/src/import-pipeline.ts @@ -132,13 +132,21 @@ async function createMapping( newId: string, entityType: string, ): Promise { - await prisma.legacyLinearMapping.upsert({ - where: { - oldId_entityType: { oldId, entityType }, - }, - create: { oldId, newId, entityType }, - update: {}, - }); + try { + await prisma.legacyLinearMapping.upsert({ + where: { + oldId_entityType: { oldId, entityType }, + }, + create: { oldId, newId, entityType }, + update: {}, + }); + } catch (error: unknown) { + const prismaError = error as { code?: string }; + if (prismaError.code === 'P2002') { + return; + } + throw error; + } } function recordSkippedImport( @@ -282,10 +290,16 @@ async function importLabels( } const created = await prisma.$transaction(async (transaction) => { - const nextLabel = await transaction.issueLabel.create({ - data: { name: label.name }, + let nextLabel = await transaction.issueLabel.findFirst({ + where: { name: label.name }, }); + if (!nextLabel) { + nextLabel = await transaction.issueLabel.create({ + data: { name: label.name }, + }); + } + await createMapping(transaction, label.id, nextLabel.id, 'label'); return nextLabel; }); diff --git a/packages/web/src/App.board-controls.test.tsx b/packages/web/src/App.board-controls.test.tsx index 3cfbe36..ce7d105 100644 --- a/packages/web/src/App.board-controls.test.tsx +++ b/packages/web/src/App.board-controls.test.tsx @@ -16,6 +16,8 @@ describe('App board controls', () => { expect(await screen.findByRole('heading', { name: 'All issues' })).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: /Filter/ })); + const filters = screen.getByLabelText('Board filters'); fireEvent.click(within(filters).getByText('Labels')); fireEvent.click(screen.getByRole('checkbox', { name: 'Bug' })); @@ -84,6 +86,8 @@ describe('App board controls', () => { expect(await screen.findByRole('heading', { name: 'All issues' })).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: /Filter/ })); + fireEvent.change(screen.getByLabelText('Sort board by'), { target: { value: 'title' }, }); @@ -129,6 +133,9 @@ describe('App board controls', () => { renderApp({ data: boardQueryResult, loading: false }, ['/']); expect(await screen.findByRole('heading', { name: 'All issues' })).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: /Filter/ })); + expect(screen.getByTestId('issue-card-issue-1')).toHaveAttribute('data-focused', 'true'); fireEvent.keyDown(window, { key: 'x' }); @@ -154,7 +161,11 @@ describe('App board controls', () => { it('focuses board search with slash and clears it with Escape', async () => { renderApp({ data: boardQueryResult, loading: false }, ['/']); - const searchInput = await screen.findByLabelText('Search board issues'); + expect(await screen.findByRole('heading', { name: 'All issues' })).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: /Filter/ })); + + const searchInput = screen.getByLabelText('Search board issues'); fireEvent.keyDown(window, { key: '/' }); expect(searchInput).toHaveFocus(); @@ -232,6 +243,8 @@ describe('App board controls', () => { expect(await screen.findByRole('heading', { name: 'All issues' })).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: /Filter/ })); + fireEvent.keyDown(window, { key: 'x' }); fireEvent.change(screen.getByLabelText('Bulk assign selected issues'), { @@ -288,6 +301,8 @@ describe('App board controls', () => { expect(await screen.findByRole('heading', { name: 'All issues' })).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: /Filter/ })); + const filters = screen.getByLabelText('Board filters'); fireEvent.click(within(filters).getByText('Labels')); fireEvent.click(screen.getByRole('checkbox', { name: 'Bug' })); diff --git a/packages/web/src/App.palette.test.tsx b/packages/web/src/App.palette.test.tsx index 0372425..0cd610b 100644 --- a/packages/web/src/App.palette.test.tsx +++ b/packages/web/src/App.palette.test.tsx @@ -68,6 +68,8 @@ describe('App command palette', () => { expect(await screen.findByRole('heading', { name: 'All issues' })).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: /Filter/ })); + const filters = screen.getByLabelText('Board filters'); fireEvent.click(within(filters).getByText('Labels')); fireEvent.click(screen.getByRole('checkbox', { name: 'Bug' })); diff --git a/packages/web/src/components/IssueDetailDrawer.tsx b/packages/web/src/components/IssueDetailDrawer.tsx index 401355c..aec0ea3 100644 --- a/packages/web/src/components/IssueDetailDrawer.tsx +++ b/packages/web/src/components/IssueDetailDrawer.tsx @@ -151,7 +151,7 @@ export function IssueDetailDrawer({ id: `${issue.id}-labels`, meta: issue.updatedAt, title: 'Labels updated', - body: issue.labels.nodes.map((label) => label.name).join(', '), + body: [...new Set(issue.labels.nodes.map((label) => label.name))].join(', '), }); } diff --git a/packages/web/src/routes/BacklogPage.tsx b/packages/web/src/routes/BacklogPage.tsx index 826cfb3..3bfecdd 100644 --- a/packages/web/src/routes/BacklogPage.tsx +++ b/packages/web/src/routes/BacklogPage.tsx @@ -399,7 +399,7 @@ export function BacklogPage({ {issue.state.name} {issue.labels.nodes.length > 0 - ? issue.labels.nodes.map((label) => label.name).join(', ') + ? [...new Set(issue.labels.nodes.map((label) => label.name))].join(', ') : '—'} {issue.assignee?.name ?? issue.assignee?.email ?? 'Unassigned'} diff --git a/packages/web/src/routes/BoardPage.tsx b/packages/web/src/routes/BoardPage.tsx index 81e6cf7..2b3de06 100644 --- a/packages/web/src/routes/BoardPage.tsx +++ b/packages/web/src/routes/BoardPage.tsx @@ -87,7 +87,7 @@ import { IssueCard } from '../components/IssueCard'; import { IssueDetailDrawer } from '../components/IssueDetailDrawer'; import { KanbanView } from '../components/KanbanView'; import { BacklogPage } from './BacklogPage'; -import { IcoFilter, IcoSort, IcoPlus, IcoList, IcoBoard, IcoClose, IcoChevR } from '../components/Icons'; +import { IcoFilter, IcoPlus, IcoList, IcoBoard, IcoClose, IcoChevR } from '../components/Icons'; import { Btn, PriorityIcon } from '../components/Primitives'; const ISSUE_PAGE_SIZE = 200; @@ -205,9 +205,13 @@ export function BoardPage() { ); const teams = queryData?.teams.nodes ?? EMPTY_TEAMS; const users = queryData?.users.nodes ?? EMPTY_USERS; - const labels = useMemo(() => Array.from( - new Map((queryData?.issueLabels.nodes ?? EMPTY_LABELS).map(l => [l.id, l])).values() - ), [queryData?.issueLabels.nodes]); + const labels = useMemo(() => { + const seen = new Map(); + for (const l of queryData?.issueLabels.nodes ?? EMPTY_LABELS) { + if (!seen.has(l.name)) seen.set(l.name, l); + } + return Array.from(seen.values()); + }, [queryData?.issueLabels.nodes]); const baseIssues = queryData?.issues.nodes ?? EMPTY_ISSUES; const [createdIssues, setCreatedIssues] = useState([]); const [issueOverrides, setIssueOverrides] = useState>({}); @@ -236,6 +240,7 @@ export function BoardPage() { const [activeSavedBoardViewId, setActiveSavedBoardViewId] = useState(''); const boardSearchInputRef = useRef(null); const [inlineCreateGroupId, setInlineCreateGroupId] = useState(null); + const [filterBarVisible, setFilterBarVisible] = useState(false); const [collapsedColumns, setCollapsedColumns] = useState>(() => { try { const stored = localStorage.getItem('involute:collapsed-columns'); @@ -1727,8 +1732,7 @@ export function BoardPage() { ))} ) : null} - } size="sm">Filter - } size="sm">Sort + } size="sm" onClick={() => setFilterBarVisible((v) => !v)}>{filterBarVisible ? 'Hide filters' : 'Filter'}
) : null} - {!isBacklogView ? ( + {!isBacklogView && filterBarVisible ? ( <>
l.name).join(', '), + body: [...new Set(issueSnapshot.labels.nodes.map((l) => l.name))].join(', '), }); } @@ -525,9 +525,15 @@ export function IssuePage() { } const activeIssue = issueSnapshot; - const allLabels = Array.from( - new Map((data?.issueLabels.nodes ?? []).map(l => [l.id, l])).values() - ); + const allLabels = (() => { + const raw = data?.issueLabels.nodes ?? []; + const namesSeen = new Set(); + return raw.filter((l) => { + if (namesSeen.has(l.name)) return false; + namesSeen.add(l.name); + return true; + }); + })(); const allUsers = data?.users.nodes ?? []; // --- Render --- @@ -968,9 +974,16 @@ export function IssuePage() { -