Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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";
4 changes: 1 addition & 3 deletions packages/server/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -62,10 +62,8 @@ model WorkflowState {

model IssueLabel {
id String @id @default(uuid()) @db.Uuid
name String
name String @unique
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

There is a significant discrepancy between the implementation and the PR description. The description states that a unique constraint @@unique([name, teamId]) was added, but the code only adds @unique to the name field.

In a multi-tenant system like this (where Issue and WorkflowState are scoped to a teamId), labels are typically also scoped to a team. If labels are intended to be per-team, the IssueLabel model is missing a teamId field and the corresponding relation. If labels are truly global, then the PR description is incorrect and should be updated to reflect that uniqueness is enforced globally by name.

issues Issue[]

@@index([name])
}

model User {
Expand Down
7 changes: 3 additions & 4 deletions packages/server/src/import-pipeline.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand All @@ -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' },
Expand All @@ -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 () => {
Expand Down
32 changes: 23 additions & 9 deletions packages/server/src/import-pipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,13 +132,21 @@ async function createMapping(
newId: string,
entityType: string,
): Promise<void> {
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(
Expand Down Expand Up @@ -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 },
});
}
Comment on lines +293 to +301
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Instead of manually checking for existence and then creating, you can use Prisma's upsert method. This is more idiomatic, ensures atomicity within the transaction, and aligns with the PR description which mentions using upsert.

      const nextLabel = await transaction.issueLabel.upsert({
        where: { name: label.name },
        update: {},
        create: { name: label.name },
      });

Comment thread
coderabbitai[bot] marked this conversation as resolved.

await createMapping(transaction, label.id, nextLabel.id, 'label');
return nextLabel;
});
Expand Down
17 changes: 16 additions & 1 deletion packages/web/src/App.board-controls.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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' }));
Expand Down Expand Up @@ -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' },
});
Expand Down Expand Up @@ -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' });
Expand All @@ -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();

Expand Down Expand Up @@ -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'), {
Expand Down Expand Up @@ -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' }));
Expand Down
2 changes: 2 additions & 0 deletions packages/web/src/App.palette.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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' }));
Expand Down
2 changes: 1 addition & 1 deletion packages/web/src/components/IssueDetailDrawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(', '),
});
}

Expand Down
2 changes: 1 addition & 1 deletion packages/web/src/routes/BacklogPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -399,7 +399,7 @@ export function BacklogPage({
<td>{issue.state.name}</td>
<td>
{issue.labels.nodes.length > 0
? issue.labels.nodes.map((label) => label.name).join(', ')
? [...new Set(issue.labels.nodes.map((label) => label.name))].join(', ')
: '—'}
</td>
<td>{issue.assignee?.name ?? issue.assignee?.email ?? 'Unassigned'}</td>
Expand Down
18 changes: 11 additions & 7 deletions packages/web/src/routes/BoardPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<string, (typeof EMPTY_LABELS)[number]>();
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<IssueSummary[]>([]);
const [issueOverrides, setIssueOverrides] = useState<Record<string, IssueSummary>>({});
Expand Down Expand Up @@ -236,6 +240,7 @@ export function BoardPage() {
const [activeSavedBoardViewId, setActiveSavedBoardViewId] = useState('');
const boardSearchInputRef = useRef<HTMLInputElement | null>(null);
const [inlineCreateGroupId, setInlineCreateGroupId] = useState<string | null>(null);
const [filterBarVisible, setFilterBarVisible] = useState(false);
const [collapsedColumns, setCollapsedColumns] = useState<Record<string, boolean>>(() => {
try {
const stored = localStorage.getItem('involute:collapsed-columns');
Expand Down Expand Up @@ -1727,8 +1732,7 @@ export function BoardPage() {
))}
</select>
) : null}
<Btn variant="ghost" icon={<IcoFilter size={14} />} size="sm">Filter</Btn>
<Btn variant="ghost" icon={<IcoSort size={14} />} size="sm">Sort</Btn>
<Btn variant="ghost" icon={<IcoFilter size={14} />} size="sm" onClick={() => setFilterBarVisible((v) => !v)}>{filterBarVisible ? 'Hide filters' : 'Filter'}</Btn>
<div style={{ width: 1, height: 16, background: 'var(--border)' }} />
<Btn
variant="subtle"
Expand All @@ -1750,7 +1754,7 @@ export function BoardPage() {
</section>
) : null}

{!isBacklogView ? (
{!isBacklogView && filterBarVisible ? (
<>
<section style={{
display: 'flex', alignItems: 'center', gap: 6,
Expand Down
25 changes: 19 additions & 6 deletions packages/web/src/routes/IssuePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,7 @@ export function IssuePage() {
id: `${issueSnapshot.id}-labels`,
timestamp: issueSnapshot.updatedAt,
title: 'Labels updated',
body: issueSnapshot.labels.nodes.map((l) => l.name).join(', '),
body: [...new Set(issueSnapshot.labels.nodes.map((l) => l.name))].join(', '),
});
}

Expand Down Expand Up @@ -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<string>();
return raw.filter((l) => {
if (namesSeen.has(l.name)) return false;
namesSeen.add(l.name);
return true;
});
})();
const allUsers = data?.users.nodes ?? [];

// --- Render ---
Expand Down Expand Up @@ -968,9 +974,16 @@ export function IssuePage() {
<Kbd keys={['⌘', 'L']} />
</button>

<button type="button" className="issue-panel__action-btn">
<button
type="button"
className="issue-panel__action-btn"
onClick={() => {
const link = `[${activeIssue.identifier}](${window.location.href})`;
void navigator.clipboard.writeText(link);
}}
>
<span style={{ color: 'var(--fg-dim)', display: 'inline-flex' }}><IcoLink size={13} /></span>
<span>Link to issue</span>
<span>Copy markdown link</span>
</button>

<button
Expand Down
3 changes: 2 additions & 1 deletion packages/web/src/routes/SettingsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,7 @@ function PreferencesTab() {
}

function AccessTab() {
const navigate = useNavigate();
const teamKey = readStoredTeamKey();
const { data, loading } = useQuery<BoardPageQueryData, BoardPageQueryVariables>(BOARD_PAGE_QUERY, {
variables: {
Expand Down Expand Up @@ -388,7 +389,7 @@ function AccessTab() {
)}

<div style={{ marginTop: 16 }}>
<Btn variant="subtle" icon={<IcoPlus />} size="md">Invite members</Btn>
<Btn variant="subtle" icon={<IcoPlus />} size="md" onClick={() => navigate('/members')}>Invite members</Btn>
</div>
</>
);
Expand Down
Loading