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
8 changes: 8 additions & 0 deletions .changeset/refactor-board-beads-native.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
4 changes: 2 additions & 2 deletions packages/cli/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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.
115 changes: 65 additions & 50 deletions packages/cli/src/commands/board.ts
Original file line number Diff line number Diff line change
@@ -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;
}

Expand All @@ -46,41 +49,38 @@ const TIER_CONFIG: Record<PriorityTier, { emoji: string; color: (s: string) => 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 {
Expand All @@ -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<GhIssueRaw[]> {
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<BeadsIssue[]> {
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<BoardResult> {
const raw = await fetchIssues(cwd);
const raw = await loadBeadsIssues(cwd);
return parseBoard(raw);
}

Expand All @@ -126,9 +136,9 @@ export async function getBoard(cwd?: string): Promise<BoardResult> {
// ---------------------------------------------------------------------------

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 {
Expand Down Expand Up @@ -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);
Expand All @@ -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);
}
}
});
}
2 changes: 1 addition & 1 deletion packages/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading