feat(graph): Implement Graph View with Dagre Layout and Epic Scope (bb-18e)
This commit is contained in:
parent
7ab23448f0
commit
8490cb1d8c
33 changed files with 4936 additions and 38 deletions
87
tests/lib/aggregate-read.test.ts
Normal file
87
tests/lib/aggregate-read.test.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import { readIssuesForScope } from '../../src/lib/aggregate-read';
|
||||
import type { ProjectScopeOption } from '../../src/lib/project-scope';
|
||||
|
||||
async function writeIssues(root: string, lines: unknown[]): Promise<void> {
|
||||
const beadsDir = path.join(root, '.beads');
|
||||
await fs.mkdir(beadsDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(beadsDir, 'issues.jsonl'),
|
||||
lines.map((line) => JSON.stringify(line)).join('\n'),
|
||||
'utf8',
|
||||
);
|
||||
}
|
||||
|
||||
test('readIssuesForScope reads selected project in single mode', async () => {
|
||||
const localRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'beadboard-scope-single-'));
|
||||
await writeIssues(localRoot, [{ id: 'bb-1', title: 'Local issue', issue_type: 'task', status: 'open', priority: 1 }]);
|
||||
|
||||
const local: ProjectScopeOption = {
|
||||
key: 'local',
|
||||
root: localRoot,
|
||||
displayPath: localRoot.replaceAll('\\', '/'),
|
||||
source: 'local',
|
||||
};
|
||||
|
||||
const issues = await readIssuesForScope({
|
||||
mode: 'single',
|
||||
selected: local,
|
||||
scopeOptions: [local],
|
||||
});
|
||||
|
||||
assert.equal(issues.length, 1);
|
||||
assert.equal(issues[0].id, 'bb-1');
|
||||
});
|
||||
|
||||
test('readIssuesForScope scopes ids and dependencies in aggregate mode', async () => {
|
||||
const localRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'beadboard-scope-agg-local-'));
|
||||
const registryRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'beadboard-scope-agg-reg-'));
|
||||
|
||||
await writeIssues(localRoot, [
|
||||
{ id: 'bb-epic', title: 'Local epic', issue_type: 'epic', status: 'open', priority: 1 },
|
||||
{
|
||||
id: 'bb-epic.1',
|
||||
title: 'Local task',
|
||||
issue_type: 'task',
|
||||
status: 'open',
|
||||
priority: 1,
|
||||
dependencies: [{ type: 'parent-child', depends_on_id: 'bb-epic' }],
|
||||
},
|
||||
]);
|
||||
|
||||
await writeIssues(registryRoot, [
|
||||
{ id: 'bb-epic', title: 'Registry epic', issue_type: 'epic', status: 'open', priority: 1 },
|
||||
]);
|
||||
|
||||
const local: ProjectScopeOption = {
|
||||
key: 'local',
|
||||
root: localRoot,
|
||||
displayPath: localRoot.replaceAll('\\', '/'),
|
||||
source: 'local',
|
||||
};
|
||||
const registry: ProjectScopeOption = {
|
||||
key: 'd:\\repos\\alpha',
|
||||
root: registryRoot,
|
||||
displayPath: registryRoot.replaceAll('\\', '/'),
|
||||
source: 'registry',
|
||||
};
|
||||
|
||||
const issues = await readIssuesForScope({
|
||||
mode: 'aggregate',
|
||||
selected: local,
|
||||
scopeOptions: [local, registry],
|
||||
});
|
||||
|
||||
assert.equal(issues.length, 3);
|
||||
assert.equal(issues.some((issue) => issue.id === 'local::bb-epic'), true);
|
||||
assert.equal(issues.some((issue) => issue.id === 'd:\\repos\\alpha::bb-epic'), true);
|
||||
const localTask = issues.find((issue) => issue.id === 'local::bb-epic.1');
|
||||
assert.ok(localTask);
|
||||
assert.equal(localTask.dependencies.some((dependency) => dependency.target === 'local::bb-epic'), true);
|
||||
assert.equal(localTask.metadata.original_id, 'bb-epic.1');
|
||||
});
|
||||
174
tests/lib/graph-view.test.ts
Normal file
174
tests/lib/graph-view.test.ts
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { buildGraphModel } from '../../src/lib/graph';
|
||||
import { analyzeBlockedChain, buildGraphViewModel, buildPathWorkspace, detectDependencyCycles } from '../../src/lib/graph-view';
|
||||
import type { BeadIssue } from '../../src/lib/types';
|
||||
|
||||
function issue(overrides: Partial<BeadIssue>): BeadIssue {
|
||||
return {
|
||||
id: overrides.id ?? 'bb-x',
|
||||
title: overrides.title ?? 'Issue',
|
||||
description: overrides.description ?? null,
|
||||
status: overrides.status ?? 'open',
|
||||
priority: overrides.priority ?? 2,
|
||||
issue_type: overrides.issue_type ?? 'task',
|
||||
assignee: overrides.assignee ?? null,
|
||||
owner: overrides.owner ?? null,
|
||||
labels: overrides.labels ?? [],
|
||||
dependencies: overrides.dependencies ?? [],
|
||||
created_at: overrides.created_at ?? '2026-02-12T00:00:00Z',
|
||||
updated_at: overrides.updated_at ?? '2026-02-12T00:00:00Z',
|
||||
closed_at: overrides.closed_at ?? null,
|
||||
close_reason: overrides.close_reason ?? null,
|
||||
closed_by_session: overrides.closed_by_session ?? null,
|
||||
created_by: overrides.created_by ?? null,
|
||||
due_at: overrides.due_at ?? null,
|
||||
estimated_minutes: overrides.estimated_minutes ?? null,
|
||||
external_ref: overrides.external_ref ?? null,
|
||||
metadata: overrides.metadata ?? {},
|
||||
};
|
||||
}
|
||||
|
||||
test('buildGraphViewModel limits visible nodes by hop depth around focus', () => {
|
||||
const model = buildGraphModel([
|
||||
issue({ id: 'bb-1', dependencies: [{ type: 'blocks', target: 'bb-2' }] }),
|
||||
issue({ id: 'bb-2', dependencies: [{ type: 'blocks', target: 'bb-3' }] }),
|
||||
issue({ id: 'bb-3', dependencies: [{ type: 'blocks', target: 'bb-4' }] }),
|
||||
issue({ id: 'bb-4' }),
|
||||
]);
|
||||
|
||||
const depth1 = buildGraphViewModel(model, { focusId: 'bb-2', depth: 1, hideClosed: false });
|
||||
const depth2 = buildGraphViewModel(model, { focusId: 'bb-2', depth: 2, hideClosed: false });
|
||||
|
||||
assert.deepEqual(
|
||||
depth1.nodes.map((x) => x.id),
|
||||
['bb-2', 'bb-1', 'bb-3'],
|
||||
);
|
||||
assert.deepEqual(
|
||||
depth2.nodes.map((x) => x.id),
|
||||
['bb-2', 'bb-1', 'bb-3', 'bb-4'],
|
||||
);
|
||||
});
|
||||
|
||||
test('buildGraphViewModel can hide closed nodes while preserving focused node', () => {
|
||||
const model = buildGraphModel([
|
||||
issue({ id: 'bb-1', status: 'closed', dependencies: [{ type: 'blocks', target: 'bb-2' }] }),
|
||||
issue({ id: 'bb-2', status: 'open' }),
|
||||
]);
|
||||
|
||||
const hidden = buildGraphViewModel(model, { focusId: null, depth: 'full', hideClosed: true });
|
||||
const focused = buildGraphViewModel(model, { focusId: 'bb-1', depth: 'full', hideClosed: true });
|
||||
|
||||
assert.deepEqual(hidden.nodes.map((x) => x.id), ['bb-2']);
|
||||
assert.deepEqual(focused.nodes.map((x) => x.id), ['bb-1', 'bb-2']);
|
||||
});
|
||||
|
||||
test('buildGraphViewModel keeps deterministic edge ordering', () => {
|
||||
const model = buildGraphModel([
|
||||
issue({
|
||||
id: 'bb-2',
|
||||
dependencies: [
|
||||
{ type: 'parent', target: 'bb-1' },
|
||||
{ type: 'blocks', target: 'bb-3' },
|
||||
],
|
||||
}),
|
||||
issue({ id: 'bb-1' }),
|
||||
issue({ id: 'bb-3' }),
|
||||
]);
|
||||
|
||||
const view = buildGraphViewModel(model, { focusId: null, depth: 'full', hideClosed: false });
|
||||
|
||||
assert.deepEqual(
|
||||
view.edges.map((x) => `${x.source}|${x.type}|${x.target}`),
|
||||
['bb-2|blocks|bb-3', 'bb-2|parent|bb-1'],
|
||||
);
|
||||
assert.equal(view.nodes.every((x) => Number.isFinite(x.position.x) && Number.isFinite(x.position.y)), true);
|
||||
});
|
||||
|
||||
test('buildPathWorkspace returns upstream/downstream levels around focus', () => {
|
||||
const model = buildGraphModel([
|
||||
issue({ id: 'bb-1', dependencies: [{ type: 'blocks', target: 'bb-2' }] }),
|
||||
issue({ id: 'bb-2', dependencies: [{ type: 'blocks', target: 'bb-3' }] }),
|
||||
issue({ id: 'bb-3' }),
|
||||
]);
|
||||
|
||||
const workspace = buildPathWorkspace(model, { focusId: 'bb-2', depth: 2, hideClosed: false });
|
||||
|
||||
assert.equal(workspace.focus?.id, 'bb-2');
|
||||
assert.deepEqual(workspace.blockers.map((level) => level.map((node) => node.id)), [['bb-1']]);
|
||||
assert.deepEqual(workspace.dependents.map((level) => level.map((node) => node.id)), [['bb-3']]);
|
||||
});
|
||||
|
||||
test('buildPathWorkspace hides closed nodes when requested', () => {
|
||||
const model = buildGraphModel([
|
||||
issue({ id: 'bb-1', status: 'closed', dependencies: [{ type: 'blocks', target: 'bb-2' }] }),
|
||||
issue({ id: 'bb-2' }),
|
||||
]);
|
||||
|
||||
const workspace = buildPathWorkspace(model, { focusId: 'bb-2', depth: 2, hideClosed: true });
|
||||
assert.equal(workspace.blockers.length, 0);
|
||||
assert.equal(workspace.focus?.id, 'bb-2');
|
||||
});
|
||||
|
||||
test('buildPathWorkspace full depth keeps deterministic blocker and dependent levels', () => {
|
||||
const model = buildGraphModel([
|
||||
issue({ id: 'bb-1', dependencies: [{ type: 'blocks', target: 'bb-2' }] }),
|
||||
issue({ id: 'bb-2', dependencies: [{ type: 'blocks', target: 'bb-3' }] }),
|
||||
issue({ id: 'bb-3', dependencies: [{ type: 'blocks', target: 'bb-4' }] }),
|
||||
issue({ id: 'bb-4', dependencies: [{ type: 'blocks', target: 'bb-5' }] }),
|
||||
issue({ id: 'bb-5' }),
|
||||
]);
|
||||
|
||||
const workspace = buildPathWorkspace(model, { focusId: 'bb-3', depth: 'full', hideClosed: false });
|
||||
|
||||
assert.deepEqual(workspace.blockers.map((level) => level.map((node) => node.id)), [['bb-2'], ['bb-1']]);
|
||||
assert.deepEqual(workspace.dependents.map((level) => level.map((node) => node.id)), [['bb-4'], ['bb-5']]);
|
||||
});
|
||||
|
||||
test('analyzeBlockedChain returns blocker counts, first actionable blocker, and chain edges', () => {
|
||||
const model = buildGraphModel([
|
||||
issue({ id: 'bb-1', status: 'open', dependencies: [{ type: 'blocks', target: 'bb-2' }] }),
|
||||
issue({ id: 'bb-2', status: 'in_progress', dependencies: [{ type: 'blocks', target: 'bb-3' }] }),
|
||||
issue({ id: 'bb-3', status: 'blocked' }),
|
||||
]);
|
||||
|
||||
const summary = analyzeBlockedChain(model, { focusId: 'bb-3' });
|
||||
|
||||
assert.equal(summary.blockerNodeIds.length, 2);
|
||||
assert.equal(summary.openBlockerCount, 1);
|
||||
assert.equal(summary.inProgressBlockerCount, 1);
|
||||
assert.equal(summary.firstActionableBlockerId, 'bb-1');
|
||||
assert.deepEqual(summary.chainEdgeIds, ['bb-1:blocks:bb-2', 'bb-2:blocks:bb-3']);
|
||||
});
|
||||
|
||||
test('detectDependencyCycles reports cycle nodes and edges for blocks relations', () => {
|
||||
const model = buildGraphModel([
|
||||
issue({ id: 'bb-1', dependencies: [{ type: 'blocks', target: 'bb-2' }] }),
|
||||
issue({ id: 'bb-2', dependencies: [{ type: 'blocks', target: 'bb-3' }] }),
|
||||
issue({ id: 'bb-3', dependencies: [{ type: 'blocks', target: 'bb-1' }] }),
|
||||
issue({ id: 'bb-4', dependencies: [{ type: 'blocks', target: 'bb-5' }] }),
|
||||
issue({ id: 'bb-5' }),
|
||||
]);
|
||||
|
||||
const anomaly = detectDependencyCycles(model);
|
||||
|
||||
assert.equal(anomaly.cycles.length, 1);
|
||||
assert.deepEqual(anomaly.cycleNodeIds, ['bb-1', 'bb-2', 'bb-3']);
|
||||
assert.deepEqual(anomaly.cycleEdgeIds, ['bb-1:blocks:bb-2', 'bb-2:blocks:bb-3', 'bb-3:blocks:bb-1']);
|
||||
});
|
||||
|
||||
test('detectDependencyCycles does not mark non-cycle predecessor as cyclic', () => {
|
||||
const model = buildGraphModel([
|
||||
issue({ id: 'bb-a', dependencies: [{ type: 'blocks', target: 'bb-b' }] }),
|
||||
issue({ id: 'bb-b', dependencies: [{ type: 'blocks', target: 'bb-c' }] }),
|
||||
issue({ id: 'bb-c', dependencies: [{ type: 'blocks', target: 'bb-a' }] }),
|
||||
issue({ id: 'bb-x', dependencies: [{ type: 'blocks', target: 'bb-a' }] }),
|
||||
]);
|
||||
|
||||
const anomaly = detectDependencyCycles(model);
|
||||
|
||||
assert.deepEqual(anomaly.cycleNodeIds, ['bb-a', 'bb-b', 'bb-c']);
|
||||
assert.equal(anomaly.cycleNodeIds.includes('bb-x'), false);
|
||||
assert.equal(anomaly.cycleEdgeIds.includes('bb-x:blocks:bb-a'), false);
|
||||
});
|
||||
127
tests/lib/graph.test.ts
Normal file
127
tests/lib/graph.test.ts
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import type { BeadDependency, BeadIssue } from '../../src/lib/types';
|
||||
import { buildGraphModel } from '../../src/lib/graph';
|
||||
|
||||
function issue(overrides: Partial<BeadIssue>): BeadIssue {
|
||||
return {
|
||||
id: overrides.id ?? 'bb-x',
|
||||
title: overrides.title ?? 'Issue',
|
||||
description: overrides.description ?? null,
|
||||
status: overrides.status ?? 'open',
|
||||
priority: overrides.priority ?? 2,
|
||||
issue_type: overrides.issue_type ?? 'task',
|
||||
assignee: overrides.assignee ?? null,
|
||||
owner: overrides.owner ?? null,
|
||||
labels: overrides.labels ?? [],
|
||||
dependencies: overrides.dependencies ?? [],
|
||||
created_at: overrides.created_at ?? '2026-02-12T00:00:00Z',
|
||||
updated_at: overrides.updated_at ?? '2026-02-12T00:00:00Z',
|
||||
closed_at: overrides.closed_at ?? null,
|
||||
close_reason: overrides.close_reason ?? null,
|
||||
closed_by_session: overrides.closed_by_session ?? null,
|
||||
created_by: overrides.created_by ?? null,
|
||||
due_at: overrides.due_at ?? null,
|
||||
estimated_minutes: overrides.estimated_minutes ?? null,
|
||||
external_ref: overrides.external_ref ?? null,
|
||||
metadata: overrides.metadata ?? {},
|
||||
};
|
||||
}
|
||||
|
||||
function dep(type: BeadDependency['type'], target: string): BeadDependency {
|
||||
return { type, target };
|
||||
}
|
||||
|
||||
test('buildGraphModel extracts supported dependency types with deterministic ordering', () => {
|
||||
const issues = [
|
||||
issue({
|
||||
id: 'bb-2',
|
||||
dependencies: [
|
||||
dep('parent', 'bb-1'),
|
||||
dep('blocks', 'bb-3'),
|
||||
],
|
||||
}),
|
||||
issue({
|
||||
id: 'bb-1',
|
||||
dependencies: [dep('supersedes', 'bb-3')],
|
||||
}),
|
||||
issue({
|
||||
id: 'bb-3',
|
||||
dependencies: [
|
||||
dep('duplicates', 'bb-1'),
|
||||
dep('relates_to', 'bb-2'),
|
||||
],
|
||||
}),
|
||||
];
|
||||
|
||||
const model = buildGraphModel(issues, { projectKey: 'demo' });
|
||||
|
||||
assert.deepEqual(model.nodes.map((x) => x.id), ['bb-1', 'bb-2', 'bb-3']);
|
||||
assert.deepEqual(
|
||||
model.edges.map((x) => `${x.source}|${x.type}|${x.target}`),
|
||||
[
|
||||
'bb-1|supersedes|bb-3',
|
||||
'bb-2|blocks|bb-3',
|
||||
'bb-2|parent|bb-1',
|
||||
'bb-3|duplicates|bb-1',
|
||||
'bb-3|relates_to|bb-2',
|
||||
],
|
||||
);
|
||||
assert.equal(model.projectKey, 'demo');
|
||||
});
|
||||
|
||||
test('buildGraphModel deduplicates duplicate edges and tracks diagnostics', () => {
|
||||
const issues = [
|
||||
issue({
|
||||
id: 'bb-1',
|
||||
dependencies: [
|
||||
dep('blocks', 'bb-2'),
|
||||
dep('blocks', 'bb-2'),
|
||||
dep('blocks', 'bb-2'),
|
||||
],
|
||||
}),
|
||||
issue({ id: 'bb-2' }),
|
||||
];
|
||||
|
||||
const model = buildGraphModel(issues);
|
||||
|
||||
assert.equal(model.edges.length, 1);
|
||||
assert.equal(model.diagnostics.droppedDuplicates, 2);
|
||||
assert.equal(model.diagnostics.missingTargets, 0);
|
||||
});
|
||||
|
||||
test('buildGraphModel ignores missing-target edges and unsupported types', () => {
|
||||
const issues = [
|
||||
issue({
|
||||
id: 'bb-1',
|
||||
dependencies: [
|
||||
dep('blocks', 'bb-missing'),
|
||||
dep('replies_to', 'bb-2'),
|
||||
],
|
||||
}),
|
||||
issue({ id: 'bb-2' }),
|
||||
];
|
||||
|
||||
const model = buildGraphModel(issues);
|
||||
|
||||
assert.equal(model.edges.length, 0);
|
||||
assert.equal(model.diagnostics.missingTargets, 1);
|
||||
assert.equal(model.diagnostics.unsupportedTypes, 1);
|
||||
});
|
||||
|
||||
test('buildGraphModel builds incoming/outgoing adjacency maps', () => {
|
||||
const issues = [
|
||||
issue({ id: 'bb-1', dependencies: [dep('blocks', 'bb-2')] }),
|
||||
issue({ id: 'bb-2', dependencies: [dep('parent', 'bb-3')] }),
|
||||
issue({ id: 'bb-3' }),
|
||||
];
|
||||
|
||||
const model = buildGraphModel(issues);
|
||||
|
||||
assert.deepEqual(model.adjacency['bb-1'].outgoing.map((x) => x.target), ['bb-2']);
|
||||
assert.deepEqual(model.adjacency['bb-1'].incoming.map((x) => x.source), []);
|
||||
assert.deepEqual(model.adjacency['bb-2'].incoming.map((x) => x.source), ['bb-1']);
|
||||
assert.deepEqual(model.adjacency['bb-2'].outgoing.map((x) => x.target), ['bb-3']);
|
||||
assert.deepEqual(model.adjacency['bb-3'].incoming.map((x) => x.source), ['bb-2']);
|
||||
});
|
||||
142
tests/lib/issue-editor.test.ts
Normal file
142
tests/lib/issue-editor.test.ts
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
buildEditableIssueDraft,
|
||||
buildIssueUpdatePayload,
|
||||
classifyEditState,
|
||||
parseLabelsInput,
|
||||
validateEditableIssueDraft,
|
||||
type EditableIssueDraft,
|
||||
} from '../../src/lib/issue-editor';
|
||||
import type { BeadIssue } from '../../src/lib/types';
|
||||
|
||||
function makeIssue(overrides: Partial<BeadIssue> = {}): BeadIssue {
|
||||
const has = (key: keyof BeadIssue) => Object.prototype.hasOwnProperty.call(overrides, key);
|
||||
return {
|
||||
id: overrides.id ?? 'bb-101',
|
||||
title: overrides.title ?? 'Implement shared edit surface',
|
||||
description: has('description') ? (overrides.description as string | null) : 'First line',
|
||||
status: overrides.status ?? 'open',
|
||||
priority: overrides.priority ?? 2,
|
||||
issue_type: overrides.issue_type ?? 'task',
|
||||
assignee: has('assignee') ? (overrides.assignee as string | null) : null,
|
||||
owner: has('owner') ? (overrides.owner as string | null) : null,
|
||||
labels: overrides.labels ?? ['ux', 'graph'],
|
||||
dependencies: overrides.dependencies ?? [],
|
||||
created_at: overrides.created_at ?? '2026-02-13T00:00:00.000Z',
|
||||
updated_at: overrides.updated_at ?? '2026-02-13T00:00:00.000Z',
|
||||
closed_at: overrides.closed_at ?? null,
|
||||
close_reason: overrides.close_reason ?? null,
|
||||
closed_by_session: overrides.closed_by_session ?? null,
|
||||
created_by: overrides.created_by ?? 'zenchantlive',
|
||||
due_at: overrides.due_at ?? null,
|
||||
estimated_minutes: overrides.estimated_minutes ?? null,
|
||||
external_ref: overrides.external_ref ?? null,
|
||||
metadata: overrides.metadata ?? {},
|
||||
};
|
||||
}
|
||||
|
||||
test('buildEditableIssueDraft normalizes nullable issue fields', () => {
|
||||
const issue = makeIssue({
|
||||
description: null,
|
||||
assignee: null,
|
||||
owner: null,
|
||||
labels: ['graph', ' ux ', ''],
|
||||
});
|
||||
|
||||
const draft = buildEditableIssueDraft(issue);
|
||||
|
||||
assert.equal(draft.title, issue.title);
|
||||
assert.equal(draft.description, '');
|
||||
assert.equal(draft.status, issue.status);
|
||||
assert.equal(draft.assignee, '');
|
||||
assert.equal(draft.owner, '');
|
||||
assert.equal(draft.labelsInput, 'graph, ux');
|
||||
});
|
||||
|
||||
test('validateEditableIssueDraft returns validation errors for invalid input', () => {
|
||||
const draft = {
|
||||
title: ' ',
|
||||
description: 'desc',
|
||||
status: 'tombstone',
|
||||
priority: 7,
|
||||
issueType: '',
|
||||
assignee: 'dev-a',
|
||||
owner: '',
|
||||
labelsInput: 'ok, , invalid',
|
||||
} as unknown as EditableIssueDraft;
|
||||
|
||||
const result = validateEditableIssueDraft(draft);
|
||||
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.errors.title, 'Title is required.');
|
||||
assert.equal(result.errors.status, 'Status must be open, in progress, blocked, deferred, or closed.');
|
||||
assert.equal(result.errors.priority, 'Priority must be between 0 and 4.');
|
||||
assert.equal(result.errors.issueType, 'Issue type is required.');
|
||||
assert.equal(result.errors.labelsInput, 'Labels must be comma-separated non-empty values.');
|
||||
});
|
||||
|
||||
test('buildIssueUpdatePayload includes only changed mutable fields', () => {
|
||||
const issue = makeIssue({
|
||||
title: 'Old title',
|
||||
description: 'Old description',
|
||||
priority: 2,
|
||||
issue_type: 'task',
|
||||
assignee: 'old-assignee',
|
||||
labels: ['legacy'],
|
||||
owner: 'owner-a',
|
||||
});
|
||||
const draft: EditableIssueDraft = {
|
||||
title: 'New title',
|
||||
description: 'Old description',
|
||||
status: 'in_progress',
|
||||
priority: 1,
|
||||
issueType: 'feature',
|
||||
assignee: 'new-assignee',
|
||||
owner: 'owner-b',
|
||||
labelsInput: 'legacy,ui',
|
||||
};
|
||||
|
||||
const payload = buildIssueUpdatePayload(issue, draft, 'C:/repo');
|
||||
|
||||
assert.deepEqual(payload, {
|
||||
projectRoot: 'C:/repo',
|
||||
id: issue.id,
|
||||
title: 'New title',
|
||||
status: 'in_progress',
|
||||
priority: 1,
|
||||
issueType: 'feature',
|
||||
assignee: 'new-assignee',
|
||||
labels: ['legacy', 'ui'],
|
||||
});
|
||||
});
|
||||
|
||||
test('buildIssueUpdatePayload returns null when no mutable fields changed', () => {
|
||||
const issue = makeIssue({
|
||||
title: 'Same title',
|
||||
description: 'Same description',
|
||||
status: 'open',
|
||||
priority: 2,
|
||||
issue_type: 'task',
|
||||
assignee: null,
|
||||
owner: 'owner-a',
|
||||
labels: ['a', 'b'],
|
||||
});
|
||||
const draft = buildEditableIssueDraft(issue);
|
||||
|
||||
const payload = buildIssueUpdatePayload(issue, draft, 'C:/repo');
|
||||
|
||||
assert.equal(payload, null);
|
||||
});
|
||||
|
||||
test('parseLabelsInput deduplicates, trims, and preserves order', () => {
|
||||
assert.deepEqual(parseLabelsInput(' alpha, beta,alpha, gamma , ,beta'), ['alpha', 'beta', 'gamma']);
|
||||
});
|
||||
|
||||
test('classifyEditState derives ui state from dirty/saving/error flags', () => {
|
||||
assert.equal(classifyEditState({ dirty: false, saving: false, error: null }), 'pristine');
|
||||
assert.equal(classifyEditState({ dirty: true, saving: false, error: null }), 'dirty');
|
||||
assert.equal(classifyEditState({ dirty: true, saving: true, error: null }), 'saving');
|
||||
assert.equal(classifyEditState({ dirty: true, saving: false, error: 'boom' }), 'error');
|
||||
});
|
||||
|
|
@ -4,9 +4,14 @@ import assert from 'node:assert/strict';
|
|||
import type { BeadIssue } from '../../src/lib/types';
|
||||
import {
|
||||
KANBAN_STATUSES,
|
||||
buildExecutionChecklist,
|
||||
buildBlockedByTree,
|
||||
buildKanbanColumns,
|
||||
buildKanbanStats,
|
||||
buildUnblocksCountByIssue,
|
||||
findIssueLane,
|
||||
filterKanbanIssues,
|
||||
pickNextActionableIssue,
|
||||
} from '../../src/lib/kanban';
|
||||
|
||||
function issue(overrides: Partial<BeadIssue>): BeadIssue {
|
||||
|
|
@ -56,19 +61,24 @@ test('buildKanbanColumns groups by core statuses and sorts by priority ascending
|
|||
const issues = [
|
||||
issue({ id: 'bb-1', status: 'open', priority: 2 }),
|
||||
issue({ id: 'bb-2', status: 'open', priority: 0 }),
|
||||
issue({ id: 'bb-3', status: 'blocked', priority: 1 }),
|
||||
issue({ id: 'bb-4', status: 'pinned', priority: 1 }),
|
||||
issue({ id: 'bb-epic', status: 'open', priority: 0, issue_type: 'epic' }),
|
||||
issue({ id: 'bb-3', status: 'in_progress', priority: 1 }),
|
||||
issue({ id: 'bb-4', status: 'deferred', priority: 1 }),
|
||||
issue({ id: 'bb-5', status: 'open', priority: 3, dependencies: [{ type: 'blocks', target: 'bb-1' }] }),
|
||||
issue({ id: 'bb-6', status: 'open', priority: 4, dependencies: [{ type: 'blocks', target: 'bb-2' }] }),
|
||||
];
|
||||
|
||||
const columns = buildKanbanColumns(issues);
|
||||
|
||||
assert.deepEqual(Object.keys(columns), KANBAN_STATUSES);
|
||||
assert.deepEqual(columns.open.map((x) => x.id), ['bb-2', 'bb-1']);
|
||||
assert.deepEqual(columns.blocked.map((x) => x.id), ['bb-3']);
|
||||
assert.deepEqual(columns.ready.map((x) => x.id), ['bb-4', 'bb-5', 'bb-6']);
|
||||
assert.equal(columns.ready.some((x) => x.issue_type === 'epic'), false);
|
||||
assert.deepEqual(columns.in_progress.map((x) => x.id), ['bb-3']);
|
||||
assert.deepEqual(columns.blocked.map((x) => x.id), ['bb-2', 'bb-1']);
|
||||
assert.equal(columns.closed.length, 0);
|
||||
});
|
||||
|
||||
test('buildKanbanStats reports total/open/active/blocked/done/p0', () => {
|
||||
test('buildKanbanStats reports total/ready/active/blocked/done/p0', () => {
|
||||
const issues = [
|
||||
issue({ status: 'open', priority: 0 }),
|
||||
issue({ status: 'open', priority: 2 }),
|
||||
|
|
@ -80,9 +90,121 @@ test('buildKanbanStats reports total/open/active/blocked/done/p0', () => {
|
|||
const stats = buildKanbanStats(issues);
|
||||
|
||||
assert.equal(stats.total, 5);
|
||||
assert.equal(stats.open, 2);
|
||||
assert.equal(stats.ready, 2);
|
||||
assert.equal(stats.active, 1);
|
||||
assert.equal(stats.blocked, 1);
|
||||
assert.equal(stats.done, 1);
|
||||
assert.equal(stats.p0, 1);
|
||||
});
|
||||
|
||||
test('buildBlockedByTree returns compact blocker tree with depth and total', () => {
|
||||
const issues = [
|
||||
issue({ id: 'bb-1', title: 'Target issue' }),
|
||||
issue({ id: 'bb-2', title: 'Direct blocker A', dependencies: [{ type: 'blocks', target: 'bb-1' }] }),
|
||||
issue({ id: 'bb-3', title: 'Direct blocker B', dependencies: [{ type: 'blocks', target: 'bb-1' }] }),
|
||||
issue({ id: 'bb-4', title: 'Nested blocker', dependencies: [{ type: 'blocks', target: 'bb-2' }] }),
|
||||
];
|
||||
|
||||
const tree = buildBlockedByTree(issues, 'bb-1');
|
||||
|
||||
assert.equal(tree.total, 3);
|
||||
assert.deepEqual(
|
||||
tree.nodes.map((node) => `${node.id}:${node.level}`),
|
||||
['bb-2:1', 'bb-3:1', 'bb-4:2'],
|
||||
);
|
||||
});
|
||||
|
||||
test('findIssueLane resolves ready lane for unblocked linked issues', () => {
|
||||
const issues = [
|
||||
issue({ id: 'bb-1', status: 'open' }),
|
||||
issue({ id: 'bb-2', status: 'blocked' }),
|
||||
issue({ id: 'bb-3', status: 'closed' }),
|
||||
];
|
||||
|
||||
const columns = buildKanbanColumns(issues);
|
||||
|
||||
assert.equal(findIssueLane(columns, 'bb-1'), 'ready');
|
||||
assert.equal(findIssueLane(columns, 'bb-2'), 'blocked');
|
||||
assert.equal(findIssueLane(columns, 'bb-3'), 'closed');
|
||||
assert.equal(findIssueLane(columns, 'bb-404'), null);
|
||||
});
|
||||
|
||||
test('pickNextActionableIssue is deterministic by priority asc, unblocks desc, updated desc, id asc', () => {
|
||||
const issues = [
|
||||
issue({
|
||||
id: 'bb-1',
|
||||
status: 'open',
|
||||
priority: 1,
|
||||
updated_at: '2026-02-10T01:00:00Z',
|
||||
dependencies: [{ type: 'blocks', target: 'bb-10' }],
|
||||
}),
|
||||
issue({
|
||||
id: 'bb-2',
|
||||
status: 'open',
|
||||
priority: 1,
|
||||
updated_at: '2026-02-10T02:00:00Z',
|
||||
dependencies: [{ type: 'blocks', target: 'bb-11' }, { type: 'blocks', target: 'bb-12' }],
|
||||
}),
|
||||
issue({
|
||||
id: 'bb-3',
|
||||
status: 'open',
|
||||
priority: 1,
|
||||
updated_at: '2026-02-10T02:00:00Z',
|
||||
dependencies: [{ type: 'blocks', target: 'bb-13' }, { type: 'blocks', target: 'bb-14' }],
|
||||
}),
|
||||
issue({ id: 'bb-10', status: 'blocked' }),
|
||||
issue({ id: 'bb-11', status: 'blocked' }),
|
||||
issue({ id: 'bb-12', status: 'blocked' }),
|
||||
issue({ id: 'bb-13', status: 'blocked' }),
|
||||
issue({ id: 'bb-14', status: 'blocked' }),
|
||||
];
|
||||
|
||||
const columns = buildKanbanColumns(issues);
|
||||
const next = pickNextActionableIssue(columns, issues);
|
||||
|
||||
assert.equal(next?.id, 'bb-2');
|
||||
});
|
||||
|
||||
test('pickNextActionableIssue returns null when no ready issue exists', () => {
|
||||
const issues = [issue({ id: 'bb-1', status: 'in_progress' }), issue({ id: 'bb-2', status: 'closed' })];
|
||||
const columns = buildKanbanColumns(issues);
|
||||
|
||||
assert.equal(pickNextActionableIssue(columns, issues), null);
|
||||
});
|
||||
|
||||
test('buildUnblocksCountByIssue counts unique blocks dependencies per issue', () => {
|
||||
const issues = [
|
||||
issue({
|
||||
id: 'bb-1',
|
||||
dependencies: [
|
||||
{ type: 'blocks', target: 'bb-2' },
|
||||
{ type: 'blocks', target: 'bb-2' },
|
||||
{ type: 'blocks', target: 'bb-3' },
|
||||
{ type: 'relates_to', target: 'bb-4' },
|
||||
],
|
||||
}),
|
||||
];
|
||||
|
||||
const map = buildUnblocksCountByIssue(issues);
|
||||
|
||||
assert.equal(map.get('bb-1'), 2);
|
||||
});
|
||||
|
||||
test('buildExecutionChecklist evaluates owner, blockers, quality signal, and execution-compatible lane', () => {
|
||||
const issues = [
|
||||
issue({
|
||||
id: 'bb-1',
|
||||
status: 'open',
|
||||
owner: 'dev-a',
|
||||
description: 'Implements acceptance criteria with rollback notes',
|
||||
}),
|
||||
issue({ id: 'bb-2', status: 'open', dependencies: [{ type: 'blocks', target: 'bb-1' }] }),
|
||||
];
|
||||
|
||||
const checklist = buildExecutionChecklist(issues[0], issues);
|
||||
|
||||
assert.deepEqual(
|
||||
checklist.map((item) => item.passed),
|
||||
[true, false, true, false],
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -29,6 +29,17 @@ test('buildBdMutationArgs maps reopen correctly', () => {
|
|||
assert.deepEqual(args, ['reopen', 'bb-123', '-r', 'retry work', '--json']);
|
||||
});
|
||||
|
||||
test('buildBdMutationArgs maps update issue type correctly', () => {
|
||||
const payload = validateMutationPayload('update', {
|
||||
projectRoot: root,
|
||||
id: 'bb-123',
|
||||
issueType: 'feature',
|
||||
});
|
||||
|
||||
const args = buildBdMutationArgs('update', payload);
|
||||
assert.deepEqual(args, ['update', 'bb-123', '-t', 'feature', '--json']);
|
||||
});
|
||||
|
||||
test('buildBdMutationArgs maps comment correctly', () => {
|
||||
const payload = validateMutationPayload('comment', {
|
||||
projectRoot: root,
|
||||
|
|
|
|||
|
|
@ -49,3 +49,24 @@ test('parseIssuesJsonl can include tombstones when requested', () => {
|
|||
|
||||
assert.equal(result.length, 2);
|
||||
});
|
||||
|
||||
test('parseIssuesJsonl supports beads dependency schema with depends_on_id and parent-child', () => {
|
||||
const input = JSON.stringify({
|
||||
id: 'bb-6',
|
||||
title: 'Dependency test',
|
||||
dependencies: [
|
||||
{ type: 'blocks', depends_on_id: 'bb-1' },
|
||||
{ type: 'parent-child', depends_on_id: 'bb-epic' },
|
||||
{ type: 'relates_to', target: 'bb-2' },
|
||||
],
|
||||
});
|
||||
|
||||
const result = parseIssuesJsonl(input);
|
||||
|
||||
assert.equal(result.length, 1);
|
||||
assert.deepEqual(result[0].dependencies, [
|
||||
{ type: 'blocks', target: 'bb-1' },
|
||||
{ type: 'parent', target: 'bb-epic' },
|
||||
{ type: 'relates_to', target: 'bb-2' },
|
||||
]);
|
||||
});
|
||||
|
|
|
|||
87
tests/lib/project-scope.test.ts
Normal file
87
tests/lib/project-scope.test.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { resolveProjectScope, type ProjectScopeRegistryEntry } from '../../src/lib/project-scope';
|
||||
|
||||
const REGISTRY: ProjectScopeRegistryEntry[] = [
|
||||
{ path: 'D:/Repos/Alpha' },
|
||||
{ path: 'D:/Repos/Beta' },
|
||||
];
|
||||
|
||||
test('resolveProjectScope defaults to local when query key is missing', () => {
|
||||
const scope = resolveProjectScope({
|
||||
currentProjectRoot: 'C:/Users/Zenchant/codex/beadboard',
|
||||
registryProjects: REGISTRY,
|
||||
});
|
||||
|
||||
assert.equal(scope.mode, 'single');
|
||||
assert.equal(scope.selected.source, 'local');
|
||||
assert.equal(scope.selected.root, 'C:\\Users\\Zenchant\\codex\\beadboard');
|
||||
assert.equal(scope.selected.key, 'local');
|
||||
assert.deepEqual(scope.readRoots, ['C:\\Users\\Zenchant\\codex\\beadboard']);
|
||||
assert.equal(scope.options[0].key, 'local');
|
||||
assert.equal(scope.options.length, 3);
|
||||
});
|
||||
|
||||
test('resolveProjectScope selects registry project when key matches', () => {
|
||||
const scope = resolveProjectScope({
|
||||
currentProjectRoot: 'C:/Users/Zenchant/codex/beadboard',
|
||||
registryProjects: REGISTRY,
|
||||
requestedProjectKey: 'd:\\repos\\beta',
|
||||
});
|
||||
|
||||
assert.equal(scope.selected.source, 'registry');
|
||||
assert.equal(scope.selected.root, 'D:\\Repos\\Beta');
|
||||
assert.equal(scope.selected.key, 'd:\\repos\\beta');
|
||||
assert.deepEqual(scope.readRoots, ['D:\\Repos\\Beta']);
|
||||
});
|
||||
|
||||
test('resolveProjectScope falls back to local when query key is unknown', () => {
|
||||
const scope = resolveProjectScope({
|
||||
currentProjectRoot: 'C:/Users/Zenchant/codex/beadboard',
|
||||
registryProjects: REGISTRY,
|
||||
requestedProjectKey: 'd:\\repos\\missing',
|
||||
});
|
||||
|
||||
assert.equal(scope.selected.source, 'local');
|
||||
assert.equal(scope.selected.key, 'local');
|
||||
assert.deepEqual(scope.readRoots, ['C:\\Users\\Zenchant\\codex\\beadboard']);
|
||||
});
|
||||
|
||||
test('resolveProjectScope deduplicates registry entries by normalized key', () => {
|
||||
const scope = resolveProjectScope({
|
||||
currentProjectRoot: 'C:/Users/Zenchant/codex/beadboard',
|
||||
registryProjects: [{ path: 'D:/Repos/Alpha/' }, { path: 'd:\\repos\\alpha' }],
|
||||
});
|
||||
|
||||
assert.equal(scope.options.length, 2);
|
||||
assert.equal(scope.options.filter((option) => option.source === 'registry').length, 1);
|
||||
});
|
||||
|
||||
test('resolveProjectScope supports aggregate mode and reads all roots', () => {
|
||||
const scope = resolveProjectScope({
|
||||
currentProjectRoot: 'C:/Users/Zenchant/codex/beadboard',
|
||||
registryProjects: REGISTRY,
|
||||
requestedProjectKey: 'd:\\repos\\alpha',
|
||||
requestedMode: 'aggregate',
|
||||
});
|
||||
|
||||
assert.equal(scope.mode, 'aggregate');
|
||||
assert.equal(scope.selected.key, 'd:\\repos\\alpha');
|
||||
assert.deepEqual(scope.readRoots, [
|
||||
'C:\\Users\\Zenchant\\codex\\beadboard',
|
||||
'D:\\Repos\\Alpha',
|
||||
'D:\\Repos\\Beta',
|
||||
]);
|
||||
});
|
||||
|
||||
test('resolveProjectScope falls back to single mode for unknown mode values', () => {
|
||||
const scope = resolveProjectScope({
|
||||
currentProjectRoot: 'C:/Users/Zenchant/codex/beadboard',
|
||||
registryProjects: REGISTRY,
|
||||
requestedMode: 'invalid-mode',
|
||||
});
|
||||
|
||||
assert.equal(scope.mode, 'single');
|
||||
assert.deepEqual(scope.readRoots, ['C:\\Users\\Zenchant\\codex\\beadboard']);
|
||||
});
|
||||
|
|
@ -51,12 +51,27 @@ test('scanForProjects respects depth limits and ignore list', async () => {
|
|||
await withTempUserProfile(async (userProfile) => {
|
||||
const projectRoot = path.join(userProfile, 'ProjectA');
|
||||
await fs.mkdir(path.join(projectRoot, '.beads'), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(projectRoot, '.beads', 'issues.jsonl'),
|
||||
JSON.stringify({ id: 'bb-a', title: 'A', issue_type: 'task', status: 'open', priority: 1 }),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
const ignoredRoot = path.join(userProfile, 'node_modules', 'Ignored');
|
||||
await fs.mkdir(path.join(ignoredRoot, '.beads'), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(ignoredRoot, '.beads', 'issues.jsonl'),
|
||||
JSON.stringify({ id: 'bb-ignored', title: 'Ignored', issue_type: 'task', status: 'open', priority: 1 }),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
const deepRoot = path.join(userProfile, 'Deep', 'Level1', 'Level2', 'ProjectDeep');
|
||||
await fs.mkdir(path.join(deepRoot, '.beads'), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(deepRoot, '.beads', 'issues.jsonl'),
|
||||
JSON.stringify({ id: 'bb-deep', title: 'Deep', issue_type: 'task', status: 'open', priority: 1 }),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
const result = await scanForProjects({ maxDepth: 1 });
|
||||
const keys = result.projects.map((project) => project.key);
|
||||
|
|
@ -66,3 +81,59 @@ test('scanForProjects respects depth limits and ignore list', async () => {
|
|||
assert.equal(keys.includes(windowsPathKey(canonicalizeWindowsPath(deepRoot))), false);
|
||||
});
|
||||
});
|
||||
|
||||
test('scanForProjects ignores directories that have .beads but no issues JSONL files', async () => {
|
||||
await withTempUserProfile(async (userProfile) => {
|
||||
const falsePositiveRoot = path.join(userProfile, 'LooksLikeBeadsProject');
|
||||
await fs.mkdir(path.join(falsePositiveRoot, '.beads'), { recursive: true });
|
||||
|
||||
const validRoot = path.join(userProfile, 'ValidProject');
|
||||
await fs.mkdir(path.join(validRoot, '.beads'), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(validRoot, '.beads', 'issues.jsonl'),
|
||||
JSON.stringify({ id: 'bb-1', title: 'valid', issue_type: 'task', status: 'open', priority: 1 }),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
const result = await scanForProjects();
|
||||
const keys = result.projects.map((project) => project.key);
|
||||
|
||||
assert.equal(keys.includes(windowsPathKey(canonicalizeWindowsPath(falsePositiveRoot))), false);
|
||||
assert.equal(keys.includes(windowsPathKey(canonicalizeWindowsPath(validRoot))), true);
|
||||
});
|
||||
});
|
||||
|
||||
test('scanForProjects ignores tool/cache paths even if they contain issues JSONL', async () => {
|
||||
await withTempUserProfile(async (userProfile) => {
|
||||
const tempBeads = path.join(userProfile, 'AppData', 'Local', 'Temp', 'beadboard-read-X');
|
||||
await fs.mkdir(path.join(tempBeads, '.beads'), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(tempBeads, '.beads', 'issues.jsonl'),
|
||||
JSON.stringify({ id: 'bb-temp', title: 'temp', issue_type: 'task', status: 'open', priority: 1 }),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
const skillsBeads = path.join(userProfile, '.agents', 'skills', 'create-beads-orchestration');
|
||||
await fs.mkdir(path.join(skillsBeads, '.beads'), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(skillsBeads, '.beads', 'issues.jsonl'),
|
||||
JSON.stringify({ id: 'bb-skill', title: 'skill', issue_type: 'task', status: 'open', priority: 1 }),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
const realProject = path.join(userProfile, 'RealProject');
|
||||
await fs.mkdir(path.join(realProject, '.beads'), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(realProject, '.beads', 'issues.jsonl'),
|
||||
JSON.stringify({ id: 'bb-real', title: 'real', issue_type: 'task', status: 'open', priority: 1 }),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
const result = await scanForProjects();
|
||||
const keys = result.projects.map((project) => project.key);
|
||||
|
||||
assert.equal(keys.includes(windowsPathKey(canonicalizeWindowsPath(tempBeads))), false);
|
||||
assert.equal(keys.includes(windowsPathKey(canonicalizeWindowsPath(skillsBeads))), false);
|
||||
assert.equal(keys.includes(windowsPathKey(canonicalizeWindowsPath(realProject))), true);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue