Add tracer-bullet Kanban baseline with live issues read path

This commit is contained in:
zenchantlive 2026-02-11 17:55:26 -08:00
parent 7537ec27b0
commit c09420dc68
15 changed files with 748 additions and 8 deletions

99
src/lib/kanban.ts Normal file
View file

@ -0,0 +1,99 @@
import type { BeadIssue } from './types';
export const KANBAN_STATUSES = ['open', 'in_progress', 'blocked', 'deferred', 'closed'] as const;
export type KanbanStatus = (typeof KANBAN_STATUSES)[number];
export type KanbanColumns = Record<KanbanStatus, BeadIssue[]>;
export interface KanbanFilterOptions {
query?: string;
type?: string;
priority?: string;
showClosed?: boolean;
}
export interface KanbanStats {
total: number;
open: number;
active: number;
blocked: number;
done: number;
p0: number;
}
function isKanbanStatus(status: string): status is KanbanStatus {
return KANBAN_STATUSES.includes(status as KanbanStatus);
}
function issueSort(a: BeadIssue, b: BeadIssue): number {
const priorityDiff = a.priority - b.priority;
if (priorityDiff !== 0) {
return priorityDiff;
}
return b.updated_at.localeCompare(a.updated_at);
}
export function filterKanbanIssues(issues: BeadIssue[], filters: KanbanFilterOptions): BeadIssue[] {
const query = (filters.query ?? '').trim().toLowerCase();
const type = (filters.type ?? '').trim().toLowerCase();
const priority = (filters.priority ?? '').trim();
const showClosed = filters.showClosed ?? false;
return issues.filter((issue) => {
if (!showClosed && issue.status === 'closed') {
return false;
}
if (query) {
const haystack = [issue.id, issue.title, issue.description ?? '', issue.assignee ?? '', issue.labels.join(' ')].join(' ').toLowerCase();
if (!haystack.includes(query)) {
return false;
}
}
if (type && issue.issue_type.toLowerCase() !== type) {
return false;
}
if (priority && String(issue.priority) !== priority) {
return false;
}
return true;
});
}
export function buildKanbanColumns(issues: BeadIssue[]): KanbanColumns {
const columns = {
open: [],
in_progress: [],
blocked: [],
deferred: [],
closed: [],
} as KanbanColumns;
for (const issue of issues) {
if (isKanbanStatus(issue.status)) {
columns[issue.status].push(issue);
}
}
for (const status of KANBAN_STATUSES) {
columns[status].sort(issueSort);
}
return columns;
}
export function buildKanbanStats(issues: BeadIssue[]): KanbanStats {
return {
total: issues.length,
open: issues.filter((x) => x.status === 'open').length,
active: issues.filter((x) => x.status === 'in_progress').length,
blocked: issues.filter((x) => x.status === 'blocked').length,
done: issues.filter((x) => x.status === 'closed').length,
p0: issues.filter((x) => x.priority === 0).length,
};
}

33
src/lib/read-issues.ts Normal file
View file

@ -0,0 +1,33 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { parseIssuesJsonl } from './parser';
import { canonicalizeWindowsPath } from './pathing';
import type { BeadIssue } from './types';
export interface ReadIssuesOptions {
projectRoot?: string;
includeTombstones?: boolean;
}
export function resolveIssuesJsonlPath(projectRoot: string = process.cwd()): string {
const absolute = path.resolve(projectRoot, '.beads', 'issues.jsonl');
return canonicalizeWindowsPath(absolute);
}
export async function readIssuesFromDisk(options: ReadIssuesOptions = {}): Promise<BeadIssue[]> {
const issuesPath = resolveIssuesJsonlPath(options.projectRoot);
try {
const jsonl = await fs.readFile(issuesPath, 'utf8');
return parseIssuesJsonl(jsonl, {
includeTombstones: options.includeTombstones ?? false,
});
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return [];
}
throw error;
}
}