0b93a985d6
Read dev/issues/*.md and dev/flows/*.md as kanban cards via new
/api/boards/{issues|flows}/cards endpoints. PATCH writes status back
to the frontmatter atomically (tmp + rename), POST .../launch proxies
to agent_runner_api.
- issues_source.go: scan + parse frontmatter (yaml.v3) into IssueCard.
Skips README/INDEX/AGENT_GUIDE. Malformed YAML yields parse-error
cards (no crash). Description = first 5 body lines (no full body).
- flows_source.go: same shape, distinct status->column mapping
(pending/running/done/deferred -> Pending/Running/Done/Deferred).
- frontmatter_edit.go: PatchFrontmatterField — atomic, preserves the
rest of the file byte-for-byte, inserts key if missing.
- handlers_boards.go: list + patch + launch endpoints, taxonomy 0103
enforced. Cache 30s in memory, thread-safe (mutex), invalidated on
PATCH. Launch returns 502 with suggestion when runner is down.
- main.go: SkipPaths += "/api/boards/" so the C++ frontend hits the
read endpoints without a kanban_web session.
Smoke (FN_REGISTRY_ROOT pointed at the worktree, 87 issues + 9 flows
on disk):
GET /api/boards/issues/cards -> 200, 87 cards
GET /api/boards/flows/cards -> 200, 9 cards
PATCH /api/boards/issues/cards/0119 {status:en-curso} -> 200,
file mtime changes, frontmatter rewritten, rest preserved
POST /api/boards/issues/cards/0119/launch -> 502
agent_runner_unreachable (expected, runner not yet implemented)
Tests: issues_source_test (3 cases incl. malformed + missing status),
flows_source_test (3 cases), frontmatter_edit_test (4 cases incl.
atomic rename + no tmp leftovers). Pre-existing tools_test failure
on TestExecuteTool_MoveCard_BetweenColumns_OpensHistory is unrelated
(CardHistoryResponse type assert, not touched here).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
120 lines
3.3 KiB
Go
120 lines
3.3 KiB
Go
package main
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
)
|
|
|
|
func writeFixture(t *testing.T, dir, name, content string) {
|
|
t.Helper()
|
|
if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644); err != nil {
|
|
t.Fatalf("write %s: %v", name, err)
|
|
}
|
|
}
|
|
|
|
func TestLoadIssueCards_MapsStatusesAndSkipsNonIssues(t *testing.T) {
|
|
dir := t.TempDir()
|
|
writeFixture(t, dir, "README.md", "skip me")
|
|
writeFixture(t, dir, "AGENT_GUIDE.md", "skip me too")
|
|
writeFixture(t, dir, "0001-foo.md", "---\n"+
|
|
"id: \"0001\"\n"+
|
|
"title: \"Foo\"\n"+
|
|
"status: pendiente\n"+
|
|
"priority: alta\n"+
|
|
"type: feature\n"+
|
|
"tags: [x, y]\n"+
|
|
"flow: \"0008\"\n"+
|
|
"created: 2026-05-18\n"+
|
|
"updated: 2026-05-18\n"+
|
|
"---\n# Foo\n\nDescription body line 1.\nLine 2.\n")
|
|
writeFixture(t, dir, "0002-bar.md", "---\n"+
|
|
"id: \"0002\"\n"+
|
|
"title: \"Bar\"\n"+
|
|
"status: en-curso\n"+
|
|
"---\nBody\n")
|
|
writeFixture(t, dir, "0003-baz.md", "---\n"+
|
|
"id: \"0003\"\n"+
|
|
"title: \"Baz\"\n"+
|
|
"status: done\n"+
|
|
"---\nBody\n")
|
|
|
|
cards, err := loadIssueCards(dir)
|
|
if err != nil {
|
|
t.Fatalf("load: %v", err)
|
|
}
|
|
if len(cards) != 3 {
|
|
t.Fatalf("expected 3 cards, got %d: %#v", len(cards), cards)
|
|
}
|
|
byID := map[string]IssueCard{}
|
|
for _, c := range cards {
|
|
byID[c.ID] = c
|
|
}
|
|
if c := byID["0001"]; c.ColumnID != "Backlog" || c.Priority != "alta" || c.FlowID != "0008" || c.Type != "feature" {
|
|
t.Fatalf("0001 mismapped: %#v", c)
|
|
}
|
|
if c := byID["0002"]; c.ColumnID != "Doing" {
|
|
t.Fatalf("0002 expected Doing, got %s", c.ColumnID)
|
|
}
|
|
if c := byID["0003"]; c.ColumnID != "Done" {
|
|
t.Fatalf("0003 expected Done, got %s", c.ColumnID)
|
|
}
|
|
// Description must contain body content but NOT the title heading.
|
|
if c := byID["0001"]; c.Description == "" {
|
|
t.Fatalf("0001 missing description")
|
|
}
|
|
}
|
|
|
|
func TestLoadIssueCards_MalformedYAMLDoesNotCrash(t *testing.T) {
|
|
dir := t.TempDir()
|
|
writeFixture(t, dir, "0010-bad.md", "---\nid: \"0010\"\ntitle: \"Bad\"\nstatus: pendiente\n : malformed\n---\nbody\n")
|
|
|
|
cards, err := loadIssueCards(dir)
|
|
if err != nil {
|
|
t.Fatalf("expected no top-level error, got: %v", err)
|
|
}
|
|
if len(cards) != 1 {
|
|
t.Fatalf("expected 1 card (with parse error), got %d", len(cards))
|
|
}
|
|
if cards[0].ParseError == "" {
|
|
t.Fatalf("expected ParseError to be set on malformed card")
|
|
}
|
|
if cards[0].ID != "0010" {
|
|
t.Fatalf("expected id 0010 derived from filename, got %q", cards[0].ID)
|
|
}
|
|
}
|
|
|
|
func TestLoadIssueCards_MissingStatusDefaultsPendiente(t *testing.T) {
|
|
dir := t.TempDir()
|
|
writeFixture(t, dir, "0011-nostatus.md", "---\nid: \"0011\"\ntitle: \"NoStatus\"\n---\nbody\n")
|
|
cards, err := loadIssueCards(dir)
|
|
if err != nil {
|
|
t.Fatalf("load: %v", err)
|
|
}
|
|
if len(cards) != 1 {
|
|
t.Fatalf("expected 1 card, got %d", len(cards))
|
|
}
|
|
if cards[0].Status != "pendiente" {
|
|
t.Fatalf("expected default status pendiente, got %q", cards[0].Status)
|
|
}
|
|
if cards[0].ColumnID != "Backlog" {
|
|
t.Fatalf("expected column Backlog, got %q", cards[0].ColumnID)
|
|
}
|
|
}
|
|
|
|
func TestIssuesCacheTTL(t *testing.T) {
|
|
// Sanity: a fresh cache misses.
|
|
cache := &cardsCache{ttl: 30 * 1_000_000_000} // 30s in ns
|
|
if _, ok := cache.get(); ok {
|
|
t.Fatalf("expected miss on empty cache")
|
|
}
|
|
cache.set([]IssueCard{{ID: "0001"}})
|
|
if c, ok := cache.get(); !ok || len(c) != 1 {
|
|
t.Fatalf("expected cache hit")
|
|
}
|
|
cache.invalidate()
|
|
if _, ok := cache.get(); ok {
|
|
t.Fatalf("expected miss after invalidate")
|
|
}
|
|
}
|