diff --git a/.changeset/refactor-board-beads-native.md b/.changeset/refactor-board-beads-native.md new file mode 100644 index 00000000..d54da486 --- /dev/null +++ b/.changeset/refactor-board-beads-native.md @@ -0,0 +1,8 @@ +--- +"@stackwright/cli": minor +"@stackwright/mcp": minor +--- + +Replace GitHub Issues board with beads-native implementation. The `stackwright board` CLI command and `stackwright_get_board` MCP tool now read from `.beads/issues.jsonl` instead of calling the `gh` CLI. No GitHub authentication or `gh` CLI required. + +**Breaking change in `@stackwright/cli` public types**: `GhIssueRaw` is removed (replaced by `BeadsIssue`); `BoardIssue.number` is now `BoardIssue.id: string`; `BoardIssue.labels` and `BoardIssue.assignees` are removed; `BoardIssue.issueType` is added. diff --git a/CLAUDE.md b/CLAUDE.md index f9334731..4babf193 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -86,7 +86,7 @@ pnpm dev:hellostackwright pnpm stackwright -- --help pnpm stackwright -- types pnpm stackwright -- info -# View the priority-tiered product board (queries GitHub Issues) +# View the priority-tiered product board (reads .beads/issues.jsonl) pnpm stackwright -- board # Preview a page as a screenshot (requires a running dev server + Playwright) diff --git a/packages/cli/AGENTS.md b/packages/cli/AGENTS.md index 49fcb75b..f280979b 100644 --- a/packages/cli/AGENTS.md +++ b/packages/cli/AGENTS.md @@ -27,7 +27,7 @@ pnpm stackwright -- page list | Command | File | Purpose | |---------|------|---------| -| `board` | `src/commands/board.ts` | Display the priority-tiered product board (queries GitHub Issues) | +| `board` | `src/commands/board.ts` | Display the priority-tiered product board (reads `.beads/issues.jsonl`) | | `generate-agent-docs` | `src/commands/generate-agent-docs.ts` | Regenerate content type tables in all AGENTS.md files from live Zod schemas | | `git-ops` | `src/commands/git-ops.ts` | Git workflow helpers | | `info` | `src/commands/info.ts` | Display project and environment info | @@ -69,6 +69,6 @@ src/ ## Key Notes -- The `board` command queries **GitHub Issues** via the GitHub API and displays them sorted by priority labels (`priority:now`, `priority:next`, `priority:later`, `priority:vision`). +- The `board` command reads `.beads/issues.jsonl` (walking up from cwd) and displays open issues sorted by numeric priority (`1`=now, `2`=next, `3`=later, `4`=vision). Requires beads to be initialized (`bd init`). - The `generate-agent-docs` command introspects live Zod schemas and writes the content type reference tables into AGENTS.md files. **Do not edit those tables manually.** - All commands use `commander` for consistent structure and `--help` output. diff --git a/packages/cli/src/commands/board.ts b/packages/cli/src/commands/board.ts index 319f57e5..f025b87f 100644 --- a/packages/cli/src/commands/board.ts +++ b/packages/cli/src/commands/board.ts @@ -1,26 +1,29 @@ import { Command } from 'commander'; import chalk from 'chalk'; -import { gh } from '../utils/git'; +import path from 'path'; +import fs from 'fs-extra'; import { outputResult, outputError, formatError } from '../utils/json-output'; // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- -/** Shape returned by `gh issue list --json ...` for each issue. */ -export interface GhIssueRaw { - number: number; +/** Shape of a single record in .beads/issues.jsonl */ +export interface BeadsIssue { + _type: string; + id: string; title: string; - labels: { name: string }[]; - assignees: { login: string }[]; - updatedAt: string; + description?: string; + status: 'open' | 'closed'; + priority: number; + issue_type?: 'task' | 'feature' | 'bug'; + updated_at: string; } export interface BoardIssue { - number: number; + id: string; title: string; - labels: string[]; - assignees: string[]; + issueType?: string; updatedAt: string; } @@ -46,41 +49,38 @@ const TIER_CONFIG: Record s }; // --------------------------------------------------------------------------- -// Pure functions (testable without GitHub) +// Pure functions (testable without filesystem) // --------------------------------------------------------------------------- -function toBoard(raw: GhIssueRaw): BoardIssue { +function toBoard(raw: BeadsIssue): BoardIssue { return { - number: raw.number, + id: raw.id, title: raw.title, - labels: raw.labels.map((l) => l.name), - assignees: raw.assignees.map((a) => a.login), - updatedAt: raw.updatedAt, + issueType: raw.issue_type, + updatedAt: raw.updated_at, }; } -function getTier(issue: BoardIssue): PriorityTier | null { - for (const label of issue.labels) { - if (label.startsWith('priority:')) { - const tier = label.slice('priority:'.length); - if (tier === 'now' || tier === 'next' || tier === 'later' || tier === 'vision') { - return tier; - } - } - } +function getTier(issue: BeadsIssue): PriorityTier | null { + if (issue.priority === 1) return 'now'; + if (issue.priority === 2) return 'next'; + if (issue.priority === 3) return 'later'; + if (issue.priority === 4) return 'vision'; return null; } /** - * Parse raw GitHub issue data into a priority-tiered board. + * Parse raw beads issue data into a priority-tiered board. * Pure function — no I/O, fully testable. + * Only includes open issues; closed issues are silently skipped. */ -export function parseBoard(rawIssues: GhIssueRaw[]): BoardResult { +export function parseBoard(rawIssues: BeadsIssue[]): BoardResult { const result: BoardResult = { now: [], next: [], later: [], vision: [], unlabeled: [] }; for (const raw of rawIssues) { + if (raw._type !== 'issue' || raw.status !== 'open') continue; const issue = toBoard(raw); - const tier = getTier(issue); + const tier = getTier(raw); if (tier) { result[tier].push(issue); } else { @@ -92,32 +92,42 @@ export function parseBoard(rawIssues: GhIssueRaw[]): BoardResult { } // --------------------------------------------------------------------------- -// I/O: fetch issues via gh CLI +// I/O: locate and parse .beads/issues.jsonl // --------------------------------------------------------------------------- -async function fetchIssues(cwd?: string): Promise { - const args = [ - 'issue', - 'list', - '--state', - 'open', - '--json', - 'number,title,labels,assignees,updatedAt', - '--limit', - '100', - ]; +/** + * Walk up from startDir looking for .beads/issues.jsonl. + * Throws BEADS_NOT_FOUND if not found. + */ +function findBeadsFile(startDir: string): string { + let dir = startDir; + while (true) { + const candidate = path.join(dir, '.beads', 'issues.jsonl'); + if (fs.existsSync(candidate)) return candidate; + const parent = path.dirname(dir); + if (parent === dir) break; + dir = parent; + } + const err = new Error('No .beads/issues.jsonl found. Run `bd init` to set up issue tracking.'); + (err as NodeJS.ErrnoException).code = 'BEADS_NOT_FOUND'; + throw err; +} +async function loadBeadsIssues(cwd?: string): Promise { const effectiveCwd = cwd ?? process.cwd(); - const { stdout } = await gh(args, effectiveCwd); - return JSON.parse(stdout) as GhIssueRaw[]; + const jsonlPath = findBeadsFile(effectiveCwd); + const content = await fs.readFile(jsonlPath, 'utf8'); + return content + .split('\n') + .filter(Boolean) + .map((line) => JSON.parse(line) as BeadsIssue); } /** - * Fetch open GitHub issues and organize them into a priority board. - * Requires `gh` CLI to be installed and authenticated. + * Load issues from .beads/issues.jsonl and organize into a priority board. */ export async function getBoard(cwd?: string): Promise { - const raw = await fetchIssues(cwd); + const raw = await loadBeadsIssues(cwd); return parseBoard(raw); } @@ -126,9 +136,9 @@ export async function getBoard(cwd?: string): Promise { // --------------------------------------------------------------------------- function formatIssue(issue: BoardIssue): string { - const num = chalk.dim(`#${issue.number}`); - const assignee = issue.assignees.length > 0 ? chalk.dim(` (${issue.assignees.join(', ')})`) : ''; - return ` ${num.padEnd(16)}${issue.title}${assignee}`; + const id = chalk.dim(issue.id); + const badge = issue.issueType ? chalk.dim(` [${issue.issueType.slice(0, 4)}]`) : ''; + return ` ${id.padEnd(28)}${badge.padEnd(8)}${issue.title}`; } function formatTier(tier: PriorityTier, issues: BoardIssue[]): string { @@ -174,7 +184,7 @@ function formatBoard(board: BoardResult): string { export function registerBoard(program: Command): void { program .command('board') - .description('Show the priority-tiered product board from GitHub Issues') + .description('Show the priority-tiered product board from .beads/issues.jsonl') .option('--json', 'Output machine-readable JSON') .action(async (opts: { json?: boolean }) => { const json = Boolean(opts.json); @@ -184,7 +194,12 @@ export function registerBoard(program: Command): void { process.stdout.write(formatBoard(board)); }); } catch (err: unknown) { - outputError(formatError(err), 'BOARD_FAILED', { json }, 2); + const code = (err as NodeJS.ErrnoException).code; + if (code === 'BEADS_NOT_FOUND') { + outputError(formatError(err), 'BEADS_NOT_FOUND', { json }); + } else { + outputError(formatError(err), 'BOARD_FAILED', { json }, 2); + } } }); } diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index e07dfcf4..0ca4b0f2 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -50,7 +50,7 @@ export type { OpenPrResult, OpenPrOptions, } from './commands/git-ops'; -export type { BoardResult, BoardIssue, GhIssueRaw } from './commands/board'; +export type { BoardResult, BoardIssue, BeadsIssue } from './commands/board'; export type { CollectionSummary, CollectionListResult, diff --git a/packages/cli/test/commands/board.test.ts b/packages/cli/test/commands/board.test.ts index 6880317c..c80eeebf 100644 --- a/packages/cli/test/commands/board.test.ts +++ b/packages/cli/test/commands/board.test.ts @@ -1,29 +1,22 @@ import { describe, it, expect } from 'vitest'; -import { parseBoard, GhIssueRaw } from '../../src/commands/board'; +import { parseBoard, BeadsIssue } from '../../src/commands/board'; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- -function makeIssue(overrides: Partial = {}): GhIssueRaw { +function makeIssue(overrides: Partial = {}): BeadsIssue { return { - number: 1, + _type: 'issue', + id: 'stackwright-abc', title: 'Test issue', - labels: [], - assignees: [], - updatedAt: '2026-03-12T00:00:00Z', + status: 'open', + priority: 1, + updated_at: '2026-03-12T00:00:00Z', ...overrides, }; } -function label(name: string): { name: string } { - return { name }; -} - -function assignee(login: string): { login: string } { - return { login }; -} - // --------------------------------------------------------------------------- // parseBoard // --------------------------------------------------------------------------- @@ -39,96 +32,87 @@ describe('parseBoard', () => { }); it('sorts issues into the correct priority tier', () => { - const issues: GhIssueRaw[] = [ - makeIssue({ number: 1, title: 'Urgent fix', labels: [label('priority:now')] }), - makeIssue({ number: 2, title: 'Next up', labels: [label('priority:next')] }), - makeIssue({ number: 3, title: 'Someday', labels: [label('priority:later')] }), - makeIssue({ number: 4, title: 'Dream big', labels: [label('priority:vision')] }), + const issues: BeadsIssue[] = [ + makeIssue({ id: 'stackwright-001', title: 'Urgent fix', priority: 1 }), + makeIssue({ id: 'stackwright-002', title: 'Next up', priority: 2 }), + makeIssue({ id: 'stackwright-003', title: 'Someday', priority: 3 }), + makeIssue({ id: 'stackwright-004', title: 'Dream big', priority: 4 }), ]; const result = parseBoard(issues); expect(result.now).toHaveLength(1); - expect(result.now[0].number).toBe(1); + expect(result.now[0].id).toBe('stackwright-001'); expect(result.next).toHaveLength(1); - expect(result.next[0].number).toBe(2); + expect(result.next[0].id).toBe('stackwright-002'); expect(result.later).toHaveLength(1); - expect(result.later[0].number).toBe(3); + expect(result.later[0].id).toBe('stackwright-003'); expect(result.vision).toHaveLength(1); - expect(result.vision[0].number).toBe(4); + expect(result.vision[0].id).toBe('stackwright-004'); expect(result.unlabeled).toHaveLength(0); }); - it('puts issues without a priority label into unlabeled', () => { - const issues: GhIssueRaw[] = [ - makeIssue({ number: 10, title: 'No label', labels: [] }), - makeIssue({ number: 11, title: 'Other label', labels: [label('enhancement')] }), + it('puts issues with unknown priority into unlabeled', () => { + const issues: BeadsIssue[] = [ + makeIssue({ id: 'stackwright-005', title: 'Unknown priority', priority: 99 }), ]; const result = parseBoard(issues); - expect(result.unlabeled).toHaveLength(2); - expect(result.unlabeled[0].number).toBe(10); - expect(result.unlabeled[1].number).toBe(11); + expect(result.unlabeled).toHaveLength(1); + expect(result.unlabeled[0].id).toBe('stackwright-005'); + expect(result.now).toHaveLength(0); }); - it('handles issues with multiple labels including a priority label', () => { - const issues: GhIssueRaw[] = [ - makeIssue({ - number: 42, - title: 'Labeled both ways', - labels: [label('enhancement'), label('priority:now'), label('bug')], - }), + it('excludes closed issues from all tiers', () => { + const issues: BeadsIssue[] = [ + makeIssue({ id: 'stackwright-006', priority: 1, status: 'closed' }), + makeIssue({ id: 'stackwright-007', priority: 2, status: 'closed' }), + makeIssue({ id: 'stackwright-008', priority: 1, status: 'open' }), ]; const result = parseBoard(issues); expect(result.now).toHaveLength(1); - expect(result.now[0].labels).toEqual(['enhancement', 'priority:now', 'bug']); + expect(result.now[0].id).toBe('stackwright-008'); + expect(result.next).toHaveLength(0); }); - it('flattens assignee objects to login strings', () => { - const issues: GhIssueRaw[] = [ - makeIssue({ - number: 7, - labels: [label('priority:next')], - assignees: [assignee('alice'), assignee('bob')], - }), + it('ignores entries with _type other than "issue"', () => { + const issues: BeadsIssue[] = [ + makeIssue({ id: 'stackwright-009', _type: 'comment', priority: 1 }), + makeIssue({ id: 'stackwright-010', _type: 'issue', priority: 1 }), ]; const result = parseBoard(issues); - expect(result.next[0].assignees).toEqual(['alice', 'bob']); + expect(result.now).toHaveLength(1); + expect(result.now[0].id).toBe('stackwright-010'); }); - it('preserves updatedAt timestamp', () => { - const ts = '2026-06-15T10:30:00Z'; - const issues: GhIssueRaw[] = [ - makeIssue({ number: 99, labels: [label('priority:later')], updatedAt: ts }), + it('maps issue_type onto the BoardIssue.issueType field', () => { + const issues: BeadsIssue[] = [ + makeIssue({ id: 'stackwright-011', priority: 2, issue_type: 'feature' }), ]; const result = parseBoard(issues); - expect(result.later[0].updatedAt).toBe(ts); + expect(result.next[0].issueType).toBe('feature'); }); - it('ignores unknown priority: prefixed labels', () => { - const issues: GhIssueRaw[] = [ - makeIssue({ - number: 50, - title: 'Unknown priority', - labels: [label('priority:critical')], - }), + it('preserves updatedAt from updated_at timestamp', () => { + const ts = '2026-06-15T10:30:00Z'; + const issues: BeadsIssue[] = [ + makeIssue({ id: 'stackwright-012', priority: 3, updated_at: ts }), ]; const result = parseBoard(issues); - expect(result.unlabeled).toHaveLength(1); - expect(result.now).toHaveLength(0); + expect(result.later[0].updatedAt).toBe(ts); }); it('handles a mix of everything', () => { - const issues: GhIssueRaw[] = [ - makeIssue({ number: 1, labels: [label('priority:now')] }), - makeIssue({ number: 2, labels: [label('priority:now')] }), - makeIssue({ number: 3, labels: [label('priority:next')] }), - makeIssue({ number: 4, labels: [] }), - makeIssue({ number: 5, labels: [label('priority:vision')] }), - makeIssue({ number: 6, labels: [label('bug')] }), + const issues: BeadsIssue[] = [ + makeIssue({ id: 'stackwright-013', priority: 1 }), + makeIssue({ id: 'stackwright-014', priority: 1 }), + makeIssue({ id: 'stackwright-015', priority: 2 }), + makeIssue({ id: 'stackwright-016', priority: 1, status: 'closed' }), + makeIssue({ id: 'stackwright-017', priority: 4 }), + makeIssue({ id: 'stackwright-018', priority: 99 }), ]; const result = parseBoard(issues); @@ -136,6 +120,6 @@ describe('parseBoard', () => { expect(result.next).toHaveLength(1); expect(result.later).toHaveLength(0); expect(result.vision).toHaveLength(1); - expect(result.unlabeled).toHaveLength(2); + expect(result.unlabeled).toHaveLength(1); }); }); diff --git a/packages/mcp/AGENTS.md b/packages/mcp/AGENTS.md index f3177bb2..bc3c9d12 100644 --- a/packages/mcp/AGENTS.md +++ b/packages/mcp/AGENTS.md @@ -22,7 +22,7 @@ The server communicates via stdio and implements the MCP SDK protocol. | `tools/content-types.ts` | Introspect content type schemas, list registered types, get field definitions | | `tools/pages.ts` | List pages, read page content, create/update pages | | `tools/site.ts` | Read/update site configuration | -| `tools/board.ts` | `stackwright_get_board` — query the GitHub Issues product board | +| `tools/board.ts` | `stackwright_get_board` — read the beads product board from `.beads/issues.jsonl` | | `tools/git-ops.ts` | Git workflow helpers (branch, commit, status) | | `tools/project.ts` | Project info, package versions, build status | | `tools/render.ts` | Visual rendering — screenshot pages, check dev server, capture before/after diffs | diff --git a/packages/mcp/src/tools/board.ts b/packages/mcp/src/tools/board.ts index c10dfe9e..27261f3a 100644 --- a/packages/mcp/src/tools/board.ts +++ b/packages/mcp/src/tools/board.ts @@ -5,13 +5,13 @@ import { getBoard } from '@stackwright/cli'; export function registerBoardTools(server: McpServer): void { server.tool( 'stackwright_get_board', - 'Get the priority-tiered product board from GitHub Issues. Returns open issues organized by priority:now / priority:next / priority:later / priority:vision labels.', + 'Get the priority-tiered product board from .beads/issues.jsonl. Returns open issues organized by priority: now (p1) / next (p2) / later (p3) / vision (p4).', { cwd: z .string() .optional() .describe( - 'Working directory (must be inside a git repo with a GitHub remote). Defaults to process.cwd().' + 'Working directory to search for .beads/issues.jsonl (walks up from cwd). Defaults to process.cwd().' ), }, async ({ cwd }) => { @@ -27,14 +27,13 @@ export function registerBoardTools(server: McpServer): void { const sections = tiers.map(({ label, emoji, issues }) => { if (issues.length === 0) return `${emoji} ${label}\n (none)`; const lines = issues.map( - (i) => - ` #${i.number} ${i.title}${i.assignees.length > 0 ? ` (${i.assignees.join(', ')})` : ''}` + (i) => ` ${i.id}${i.issueType ? ` [${i.issueType}]` : ''} ${i.title}` ); return `${emoji} ${label}\n${lines.join('\n')}`; }); if (board.unlabeled.length > 0) { - const lines = board.unlabeled.map((i) => ` #${i.number} ${i.title}`); + const lines = board.unlabeled.map((i) => ` ${i.id} ${i.title}`); sections.push(`⚪ UNLABELED\n${lines.join('\n')}`); }