Skip to content

Commit f0c9421

Browse files
authored
Merge pull request #7 from gregario/feat/get-board-tool
feat: add get_board tool — kanban board view
2 parents 8fa9acb + df84e0c commit f0c9421

4 files changed

Lines changed: 210 additions & 4 deletions

File tree

src/db/queries.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type {
33
Team, Intent, IntentWithRelations, Claim, Signal,
44
ConflictWarning, ContextPackage, TeamStatus, Overview,
55
IntentStatus, IntentPriority, SignalType,
6+
BoardIntent, BoardView, BoardStatus,
67
} from '../types.js';
78

89
// ─── Teams ──────────────────────────────────────────────
@@ -703,3 +704,77 @@ export async function getOverview(): Promise<Overview> {
703704
blocked_intents: blockedRes.rows,
704705
};
705706
}
707+
708+
// ─── Board View ──────────────────────────────────────────
709+
710+
export async function getBoard(teamId?: string): Promise<BoardView> {
711+
// Main query: all non-draft intents LEFT JOIN active claims
712+
const teamFilter = teamId ? `AND i.team_id = $1` : '';
713+
const params: unknown[] = teamId ? [teamId] : [];
714+
715+
const intentsRes = await query<{
716+
id: string;
717+
title: string;
718+
priority: IntentPriority;
719+
team_id: string | null;
720+
status: IntentStatus;
721+
claimed_by: string | null;
722+
claim_id: string | null;
723+
}>(
724+
`SELECT i.id, i.title, i.priority, i.team_id, i.status,
725+
c.claimed_by, c.id as claim_id
726+
FROM intents i
727+
LEFT JOIN claims c ON c.intent_id = i.id AND c.status = 'active'
728+
WHERE i.status != 'draft' ${teamFilter}
729+
ORDER BY i.priority, i.created_at DESC`,
730+
params
731+
);
732+
733+
// Secondary query: blocked dependencies (intent_id -> depends_on where dep is not done)
734+
const blockedDepsRes = await query<{ intent_id: string; depends_on: string }>(
735+
`SELECT d.intent_id, d.depends_on
736+
FROM intent_dependencies d
737+
JOIN intents dep ON dep.id = d.depends_on
738+
JOIN intents i ON i.id = d.intent_id
739+
WHERE dep.status != 'done'
740+
AND i.status = 'blocked'`
741+
);
742+
743+
// Build blocked_by lookup: intent_id -> [dependency IDs]
744+
const blockedByMap = new Map<string, string[]>();
745+
for (const row of blockedDepsRes.rows) {
746+
const existing = blockedByMap.get(row.intent_id) ?? [];
747+
existing.push(row.depends_on);
748+
blockedByMap.set(row.intent_id, existing);
749+
}
750+
751+
// Group into columns
752+
const columns: Record<BoardStatus, BoardIntent[]> = {
753+
open: [], claimed: [], blocked: [], done: [], cancelled: [],
754+
};
755+
for (const row of intentsRes.rows) {
756+
const card: BoardIntent = {
757+
id: row.id,
758+
title: row.title,
759+
priority: row.priority,
760+
team_id: row.team_id,
761+
claimed_by: row.claimed_by ?? null,
762+
claim_id: row.claim_id ?? null,
763+
blocked_by: blockedByMap.get(row.id) ?? [],
764+
};
765+
if (row.status in columns) {
766+
columns[row.status as BoardStatus].push(card);
767+
}
768+
}
769+
770+
// Build summary counts
771+
const summary: Record<BoardStatus, number> = {
772+
open: columns.open.length,
773+
claimed: columns.claimed.length,
774+
blocked: columns.blocked.length,
775+
done: columns.done.length,
776+
cancelled: columns.cancelled.length,
777+
};
778+
779+
return { columns, summary };
780+
}

src/tools/overview.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,4 +48,16 @@ export function registerOverviewTools(server: McpServer): void {
4848
return { content: [{ type: 'text', text: JSON.stringify(overview, null, 2) }] };
4949
}
5050
);
51+
52+
server.tool(
53+
'get_board',
54+
'Get kanban board view — all intents grouped by status column with active claims and blocked dependencies. Excludes drafts.',
55+
{
56+
team_id: z.string().optional().describe('Filter to a single team. Omit for cross-team board.'),
57+
},
58+
async ({ team_id }) => {
59+
const board = await db.getBoard(team_id);
60+
return { content: [{ type: 'text', text: JSON.stringify(board, null, 2) }] };
61+
}
62+
);
5163
}

src/types.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,3 +98,20 @@ export interface Overview {
9898
recently_completed: Intent[];
9999
blocked_intents: Array<Intent & { blocked_by: string[] }>;
100100
}
101+
102+
export interface BoardIntent {
103+
id: string;
104+
title: string;
105+
priority: IntentPriority;
106+
team_id: string | null;
107+
claimed_by: string | null;
108+
claim_id: string | null;
109+
blocked_by: string[];
110+
}
111+
112+
export type BoardStatus = Exclude<IntentStatus, 'draft'>;
113+
114+
export interface BoardView {
115+
columns: Record<BoardStatus, BoardIntent[]>;
116+
summary: Record<BoardStatus, number>;
117+
}

tests/overview.test.ts

Lines changed: 106 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,6 @@ describe('Team Status & Overview', () => {
1212
await seedTeam();
1313
});
1414

15-
afterAll(async () => {
16-
await teardownTestDb();
17-
});
18-
1915
it('returns team status with intents grouped by status', async () => {
2016
const intent = await seedOpenIntent({ title: 'Open task' });
2117
const intent2 = await seedOpenIntent({ title: 'Claimed task' });
@@ -99,3 +95,109 @@ describe('Team Status & Overview', () => {
9995
expect(overview.recently_completed[0].title).toBe('Will complete');
10096
});
10197
});
98+
99+
describe('Board View', () => {
100+
beforeAll(async () => {
101+
await setupTestDb();
102+
});
103+
104+
beforeEach(async () => {
105+
await cleanTestDb();
106+
await seedTeam();
107+
});
108+
109+
afterAll(async () => {
110+
await teardownTestDb();
111+
});
112+
113+
it('returns empty columns with zero counts when no intents exist', async () => {
114+
const board = await db.getBoard();
115+
116+
expect(board.columns.open).toEqual([]);
117+
expect(board.columns.claimed).toEqual([]);
118+
expect(board.columns.blocked).toEqual([]);
119+
expect(board.columns.done).toEqual([]);
120+
expect(board.columns.cancelled).toEqual([]);
121+
expect(board.summary.open).toBe(0);
122+
expect(board.summary.claimed).toBe(0);
123+
expect(board.summary.blocked).toBe(0);
124+
expect(board.summary.done).toBe(0);
125+
expect(board.summary.cancelled).toBe(0);
126+
});
127+
128+
it('groups intents into correct status columns', async () => {
129+
const openIntent = await seedOpenIntent({ title: 'Open task' });
130+
const claimedIntent = await seedOpenIntent({ title: 'Claimed task' });
131+
await db.claimWork({ intent_id: claimedIntent.id as string, claimed_by: 'alice' });
132+
133+
const board = await db.getBoard();
134+
135+
expect(board.columns.open).toHaveLength(1);
136+
expect(board.columns.open[0].title).toBe('Open task');
137+
expect(board.columns.claimed).toHaveLength(1);
138+
expect(board.columns.claimed[0].title).toBe('Claimed task');
139+
});
140+
141+
it('includes claimed_by and claim_id for claimed intents', async () => {
142+
const intent = await seedOpenIntent({ title: 'In progress' });
143+
const { claim } = await db.claimWork({
144+
intent_id: intent.id as string,
145+
claimed_by: 'pawel',
146+
});
147+
148+
const board = await db.getBoard();
149+
150+
const claimedCard = board.columns.claimed[0];
151+
expect(claimedCard.claimed_by).toBe('pawel');
152+
expect(claimedCard.claim_id).toBe(claim.id);
153+
});
154+
155+
it('includes blocked_by for blocked intents', async () => {
156+
const blocker = await seedOpenIntent({ title: 'Blocker' });
157+
158+
await testQuery(
159+
`INSERT INTO intents (title, created_by, team_id, status, priority, acceptance_criteria)
160+
VALUES ('Blocked task', 'alice', 'backend', 'blocked', 'medium', '["Done"]') RETURNING *`
161+
);
162+
const blockedRes = await testQuery(
163+
`SELECT id FROM intents WHERE title = 'Blocked task'`
164+
);
165+
const blockedId = blockedRes.rows[0].id;
166+
await testQuery(
167+
'INSERT INTO intent_dependencies (intent_id, depends_on) VALUES ($1, $2)',
168+
[blockedId, blocker.id]
169+
);
170+
171+
const board = await db.getBoard();
172+
173+
expect(board.columns.blocked).toHaveLength(1);
174+
expect(board.columns.blocked[0].blocked_by).toContain(blocker.id);
175+
});
176+
177+
it('filters by team_id when provided', async () => {
178+
await seedTeam('frontend', 'Frontend Team');
179+
await seedOpenIntent({ title: 'Backend task', team_id: 'backend' });
180+
await seedOpenIntent({ title: 'Frontend task', team_id: 'frontend' });
181+
182+
const board = await db.getBoard('frontend');
183+
184+
expect(board.columns.open).toHaveLength(1);
185+
expect(board.columns.open[0].title).toBe('Frontend task');
186+
});
187+
188+
it('excludes drafts from all columns', async () => {
189+
await testQuery(
190+
`INSERT INTO intents (title, created_by, team_id, status, priority, acceptance_criteria)
191+
VALUES ('Draft task', 'alice', 'backend', 'draft', 'medium', '["Done"]')`
192+
);
193+
await seedOpenIntent({ title: 'Open task' });
194+
195+
const board = await db.getBoard();
196+
197+
const allCards = Object.values(board.columns).flat();
198+
expect(allCards.every(c => c.title !== 'Draft task')).toBe(true);
199+
expect(board.columns.open).toHaveLength(1);
200+
expect(board.columns).not.toHaveProperty('draft');
201+
expect(board.summary).not.toHaveProperty('draft');
202+
});
203+
});

0 commit comments

Comments
 (0)