Skip to content
Open
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
50 changes: 50 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ A web dashboard - https://mybmad.hichem.cloud/ - to visualize and track [BMAD (B
- [Project Structure](#project-structure)
- [Available Scripts](#available-scripts)
- [Production Deployment](#production-deployment-docker)
- [Epic and Story Naming Conventions](#epic-and-story-naming-conventions)
- [Documentation](#documentation)
- [Contributing](#contributing)
- [License](#license)
Expand Down Expand Up @@ -168,6 +169,55 @@ The stack includes:

---

## Epic and Story Naming Conventions

MyBMAD supports both **numeric** and **alphanumeric** epic/story IDs:

### Epic IDs

| Format | Example heading | Parsed ID |
|--------|----------------|-----------|
| Numeric | `## Epic 1: Foundation` or `## 1: Foundation` | `1` |
| Single-word | `## Epic Housekeeping: Cleanup` | `housekeeping` |
| Multi-word | `## Epic DevOps/Infra: Pipeline` | `devops-infra` |

> **Note:** The `Epic` keyword is **required** for alphanumeric IDs to avoid false positives (e.g. `## Introduction:` is not parsed as an epic).

### Story IDs and Filenames

| Filename pattern | Parsed ID | Epic ID |
|-----------------|-----------|---------|
| `1-2-setup.md` | `1.2` | `1` |
| `di-1-pipeline.md` | `di.1` | `di` |
| `hk-3-cleanup.md` | `hk.3` | `hk` |
| `story-5.md` (legacy) | `5` | — |

Alphanumeric prefixes are normalized to **lowercase** (`DI`, `DevOps`, `Housekeeping` → `di`, `devops`, `housekeeping`). Slashes are converted to hyphens (`DevOps/Infra` → `devops-infra`).

### Sprint-Status Keys

```yaml
development_status:
epic-1: done # numeric epic
epic-devops-infra: done # alphanumeric epic
1-1-project-setup: done # numeric story (epicId: 1)
di-1-pipeline-setup: in-progress # alphanumeric story (epicId: di)
```

### Linking Alphanumeric Stories to Epics

When a story's filename prefix (e.g. `di`) doesn't directly match an epic ID (e.g. `devops-infra`), the dashboard uses story references declared in the epic body to bridge the gap:

```markdown
## Epic DevOps/Infra: Pipeline Quality
- Story DI.1 - First task
- Story DI.2 - Second task
```

Stories referenced via `Story DI.1` or `S DI.1` inside an epic section are automatically associated with that epic, regardless of filename prefix.

---

## Documentation

| Document | Description |
Expand Down
8 changes: 8 additions & 0 deletions pnpm-workspace.yaml
Original file line number Diff line number Diff line change
@@ -1,3 +1,11 @@
allowBuilds:
'@prisma/client': true
'@prisma/engines': true
esbuild: true
msw: true
prisma: true
sharp: true
unrs-resolver: true
ignoredBuiltDependencies:
- sharp
- unrs-resolver
10 changes: 6 additions & 4 deletions src/components/dashboard/epics-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import { cn } from "@/lib/utils";
import Link from "next/link";
import type { Epic } from "@/lib/bmad/types";

import { getEpicShortId } from "@/lib/bmad/utils";

interface EpicsListProps {
epics: Epic[];
owner: string;
Expand Down Expand Up @@ -34,8 +36,8 @@ export function EpicsList({ epics, owner, repo }: EpicsListProps) {
);
}

const sorted = [...epics].sort(
(a, b) => (parseInt(a.id, 10) || 0) - (parseInt(b.id, 10) || 0),
const sorted = [...epics].sort((a, b) =>
a.id.localeCompare(b.id, undefined, { numeric: true, sensitivity: "base" }),
);

return (
Expand All @@ -55,8 +57,8 @@ export function EpicsList({ epics, owner, repo }: EpicsListProps) {
)}
>
<div className="flex items-center gap-3 min-w-0">
<span className="text-xs font-mono text-muted-foreground shrink-0">
E{epic.id}
<span className="text-xs font-mono text-muted-foreground shrink-0" title={epic.id}>
{getEpicShortId(epic)}
</span>
<span className="font-medium truncate">
{epic.title}
Expand Down
9 changes: 3 additions & 6 deletions src/components/dashboard/sprint-summary-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,12 +53,9 @@ export function SprintSummaryCard({
byEpic.set(epicKey, entry);
}

// Sort epics numerically
const sortedEpics = [...byEpic.entries()].sort((a, b) => {
const numA = parseInt(a[0], 10) || 0;
const numB = parseInt(b[0], 10) || 0;
return numA - numB;
});
const sortedEpics = [...byEpic.entries()].sort((a, b) =>
a[0].localeCompare(b[0], undefined, { numeric: true, sensitivity: "base" }),
);

return (
<Card className="glass-card">
Expand Down
5 changes: 3 additions & 2 deletions src/components/epics/epic-timeline-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Card, CardContent } from "@/components/ui/card";
import { StatusBadge } from "@/components/shared/status-badge";
import { SegmentedProgressBar } from "@/components/shared/segmented-progress-bar";
import type { Epic } from "@/lib/bmad/types";
import { getEpicShortId } from "@/lib/bmad/utils";

function getProgressColor(percent: number) {
return percent >= 100 ? "bg-success" : "bg-warning";
Expand Down Expand Up @@ -32,8 +33,8 @@ export function EpicTimelineCard({ epic, onClick }: EpicTimelineCardProps) {
<div className="flex items-start justify-between gap-4">
<div className="flex-1 space-y-2">
<div className="flex items-center gap-3">
<span className="flex h-8 w-8 items-center justify-center rounded-full bg-primary/10 text-xs font-bold text-primary">
{epic.id}
<span className="flex h-8 w-8 items-center justify-center rounded-full bg-primary/10 text-xs font-bold text-primary" title={epic.id}>
{getEpicShortId(epic)}
</span>
<h3 className="font-semibold">{epic.title}</h3>
</div>
Expand Down
13 changes: 7 additions & 6 deletions src/components/epics/epics-browser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { StoryDetailView } from "./story-detail-view";
import { useBreadcrumb } from "@/contexts/breadcrumb-context";
import { ArrowLeft } from "lucide-react";
import type { Epic, StoryDetail } from "@/lib/bmad/types";
import { getStoryShortId, getEpicShortId } from "@/lib/bmad/utils";

type View = "epics" | "stories" | "story";

Expand Down Expand Up @@ -137,7 +138,7 @@ export function EpicsBrowser({
return (
<div>
<h1 className="text-3xl font-bold tracking-tight">
Epic {selectedEpic.id}: {selectedEpic.title}
Epic {getEpicShortId(selectedEpic)}: {selectedEpic.title}
</h1>
<p className="text-muted-foreground mt-1">
{epicStories.length} stories
Expand Down Expand Up @@ -184,8 +185,8 @@ export function EpicsBrowser({
<div className="flex items-start justify-between gap-4">
<div className="space-y-1">
<div className="flex items-center gap-3">
<span className="flex h-8 w-8 items-center justify-center rounded-full bg-primary/10 text-xs font-bold text-primary">
{selectedEpic.id}
<span className="flex h-8 w-8 items-center justify-center rounded-full bg-primary/10 text-xs font-bold text-primary" title={selectedEpic.id}>
{getEpicShortId(selectedEpic)}
</span>
<h3 className="text-lg font-semibold">
{selectedEpic.title}
Expand Down Expand Up @@ -229,7 +230,7 @@ export function EpicsBrowser({
</div>
) : (
<div className="space-y-3">
{epicStories.map((story) => (
{epicStories.map((story, index) => (
<Card
key={story.id}
className="glass-card cursor-pointer hover:shadow-md hover:border-primary/30 transition-all duration-200"
Expand All @@ -246,8 +247,8 @@ export function EpicsBrowser({
<CardContent className="p-4">
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-3 min-w-0">
<span className="flex h-7 w-7 items-center justify-center rounded-full bg-primary/10 text-xs font-bold text-primary shrink-0">
{story.id}
<span className="flex h-7 w-7 items-center justify-center rounded-full bg-primary/10 text-xs font-bold text-primary shrink-0" title={story.id}>
{getStoryShortId(story.id, index)}
</span>
<span className="font-medium truncate">
{story.title}
Expand Down
52 changes: 52 additions & 0 deletions src/lib/bmad/__tests__/correlate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,3 +138,55 @@ describe("correlate", () => {
expect(result.epics[0].status).toBe("done");
});
});

describe("correlate — alphanumeric IDs and backfill", () => {
it("backfills epicId for a story whose epicId doesn't match any epic.id", () => {
// Epic has id "devops-infra" and lists story "di.1"
// Story file produces epicId "di" (from filename di-1-task.md)
const stories = [makeStory({ id: "di.1", epicId: "di", status: "done" })];
const epics = [makeEpic({ id: "devops-infra", title: "Pipeline Quality", stories: ["di.1"] })];

const result = correlate(null, epics, stories);
expect(result.stories[0].epicId).toBe("devops-infra");
expect(result.stories[0].epicTitle).toBe("Pipeline Quality");
});

it("assigns correct progress to alphanumeric epic via backfill", () => {
const stories = [
makeStory({ id: "di.1", epicId: "di", status: "done" }),
makeStory({ id: "di.2", epicId: "di", status: "backlog" }),
];
const epics = [makeEpic({ id: "devops-infra", stories: ["di.1", "di.2"] })];

const result = correlate(null, epics, stories);
expect(result.epics[0].totalStories).toBe(2);
expect(result.epics[0].completedStories).toBe(1);
expect(result.epics[0].progressPercent).toBe(50);
});

it("mixes numeric and alphanumeric epics without interference", () => {
const stories = [
makeStory({ id: "1.1", epicId: "1", status: "done" }),
makeStory({ id: "di.1", epicId: "di", status: "in-progress" }),
];
const epics = [
makeEpic({ id: "1", title: "Foundation", stories: ["1.1"] }),
makeEpic({ id: "devops-infra", title: "DevOps", stories: ["di.1"] }),
];

const result = correlate(null, epics, stories);
expect(result.epics[0].id).toBe("1");
expect(result.epics[0].completedStories).toBe(1);
expect(result.epics[1].id).toBe("devops-infra");
expect(result.epics[1].status).toBe("in-progress");
});

it("does not backfill when epicId already matches an epic", () => {
const stories = [makeStory({ id: "1.1", epicId: "1", status: "backlog" })];
const epics = [makeEpic({ id: "1", stories: ["1.1"] })];

const result = correlate(null, epics, stories);
// epicId should remain "1" (the matching epic), not get replaced
expect(result.stories[0].epicId).toBe("1");
});
});
47 changes: 47 additions & 0 deletions src/lib/bmad/__tests__/parse-epic-file.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,3 +140,50 @@ title: Test
expect(epic!.stories).toEqual(["1.1", "1.2", "1.3"]);
});
});

describe("parseEpicFile — alphanumeric IDs", () => {
it("extracts id from alphanumeric filename", () => {
const epic = parseEpicFile("Some content", "epic-housekeeping.md");
expect(epic).not.toBeNull();
expect(epic!.id).toBe("housekeeping");
});

it("extracts id from hyphenated alphanumeric filename", () => {
const epic = parseEpicFile("Some content", "epic-devops-infra.md");
expect(epic).not.toBeNull();
expect(epic!.id).toBe("devops-infra");
});

it("extracts id from alphanumeric heading with Epic keyword", () => {
const content = `## Epic HK: Housekeeping\n\nResolve tech debt.`;
const epic = parseEpicFile(content, "unknown.md");
expect(epic).not.toBeNull();
expect(epic!.id).toBe("hk");
expect(epic!.title).toBe("Housekeeping");
});

it("extracts id from heading with slash (normalizes to hyphen)", () => {
const content = `## Epic DevOps/Infra: Pipeline Quality\n\nAutomation foundation.`;
const epic = parseEpicFile(content, "unknown.md");
expect(epic).not.toBeNull();
expect(epic!.id).toBe("devops-infra");
expect(epic!.title).toBe("Pipeline Quality");
});

it("captures alphanumeric story refs (e.g. DI.1, HK.2)", () => {
const content = `---
id: devops-infra
title: Pipeline
---
- Story DI.1 - First
- Story DI.2 - Second
`;
const epic = parseEpicFile(content, "epic-devops-infra.md");
expect(epic!.stories).toEqual(["di.1", "di.2"]);
});

it("regression: numeric epic-1.md still yields id '1'", () => {
const epic = parseEpicFile("Some content", "epic-1.md");
expect(epic!.id).toBe("1");
});
});
59 changes: 59 additions & 0 deletions src/lib/bmad/__tests__/parse-epics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,3 +74,62 @@ Description of second epic.
expect(result.epics[0].stories).toEqual(["1.1"]);
});
});

describe("parseEpics — alphanumeric IDs", () => {
it("parses a single-word alphanumeric epic", () => {
const content = `## Epic Housekeeping: Structural Stabilization\nCleanup and debt.`;
const result = parseEpics(content);
expect(result.epics).toHaveLength(1);
expect(result.epics[0].id).toBe("housekeeping");
expect(result.epics[0].title).toBe("Structural Stabilization");
});

it("parses an epic with slash in ID (normalizes to hyphen)", () => {
const content = `## Epic DevOps/Infra: Pipeline Quality\nFoundation automation.`;
const result = parseEpics(content);
expect(result.epics).toHaveLength(1);
expect(result.epics[0].id).toBe("devops-infra");
expect(result.epics[0].title).toBe("Pipeline Quality");
});

it("parses alphanumeric story refs from body", () => {
const content = `## Epic DevOps/Infra: Pipeline Quality
- Story DI.1 - First task
- Story DI.2 - Second task
### Story DI.3: Third task
`;
const result = parseEpics(content);
expect(result.epics[0].stories).toEqual(["di.1", "di.2", "di.3"]);
});

it("parses mixed numeric + alphanumeric epics in sequence", () => {
const content = `## Epic 1: Foundation
- Story 1.1 - Init

## Epic DevOps/Infra: Pipeline
- Story DI.1 - CI/CD

## Epic 2: Features
- Story 2.1 - Auth
`;
const result = parseEpics(content);
expect(result.epics).toHaveLength(3);
expect(result.epics[0].id).toBe("1");
expect(result.epics[1].id).toBe("devops-infra");
expect(result.epics[2].id).toBe("2");
expect(result.epics[1].stories).toEqual(["di.1"]);
});

it("does NOT parse headings without 'Epic' keyword as alphanumeric epics", () => {
const content = `## Introduction: Overview\n\nSome intro text.`;
const result = parseEpics(content);
expect(result.epics).toHaveLength(0);
});

it("regression: numeric ID still works unchanged after alphanumeric support", () => {
const content = `## Epic 3: Auth System\n- Story 3.1 - Login\n- Story 3.2 - Logout`;
const result = parseEpics(content);
expect(result.epics[0].id).toBe("3");
expect(result.epics[0].stories).toEqual(["3.1", "3.2"]);
});
});
Loading