feat(backend): issues/flows sync layer (issue 0119)

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>
This commit is contained in:
agent
2026-05-18 18:56:22 +02:00
parent a76ec74338
commit 0b93a985d6
9 changed files with 1126 additions and 2 deletions
+35
View File
@@ -0,0 +1,35 @@
package main
import (
"path/filepath"
"strings"
"time"
)
// flowsCache mirrors issuesCache but for dev/flows/*.md.
var flowsCache = &cardsCache{ttl: 30 * time.Second}
// mapFlowStatusToColumn maps flow frontmatter status -> kanban column id.
// Flows use a different vocabulary than issues.
func mapFlowStatusToColumn(status string) string {
switch strings.ToLower(strings.TrimSpace(status)) {
case "pending", "":
return "Pending"
case "running":
return "Running"
case "done":
return "Done"
case "deferred":
return "Deferred"
default:
return "Pending"
}
}
func loadFlowCards(dir string) ([]IssueCard, error) {
return loadCardsFromDir(dir, mapFlowStatusToColumn, "flow")
}
func flowsDir() string {
return filepath.Join(registryRoot(), "dev", "flows")
}
+60
View File
@@ -0,0 +1,60 @@
package main
import (
"testing"
)
func TestLoadFlowCards_MapsStatuses(t *testing.T) {
dir := t.TempDir()
writeFixture(t, dir, "INDEX.md", "skip")
writeFixture(t, dir, "0001-foo.md", "---\nid: 0001\nname: foo\nstatus: pending\n---\nbody\n")
writeFixture(t, dir, "0002-bar.md", "---\nid: 0002\nname: bar\nstatus: running\n---\nbody\n")
writeFixture(t, dir, "0003-baz.md", "---\nid: 0003\nname: baz\nstatus: done\n---\nbody\n")
writeFixture(t, dir, "0004-bop.md", "---\nid: 0004\nname: bop\nstatus: deferred\n---\nbody\n")
cards, err := loadFlowCards(dir)
if err != nil {
t.Fatalf("load: %v", err)
}
if len(cards) != 4 {
t.Fatalf("expected 4 cards, got %d", len(cards))
}
want := map[string]string{
"0001": "Pending",
"0002": "Running",
"0003": "Done",
"0004": "Deferred",
}
for _, c := range cards {
if want[c.ID] != c.ColumnID {
t.Fatalf("%s: expected column %s, got %s", c.ID, want[c.ID], c.ColumnID)
}
}
}
func TestLoadFlowCards_MissingStatusDefaultsPending(t *testing.T) {
dir := t.TempDir()
writeFixture(t, dir, "0010-nostatus.md", "---\nid: 0010\nname: empty\n---\nbody\n")
cards, err := loadFlowCards(dir)
if err != nil {
t.Fatalf("load: %v", err)
}
if len(cards) != 1 {
t.Fatalf("expected 1 card")
}
if cards[0].ColumnID != "Pending" {
t.Fatalf("expected Pending column, got %q", cards[0].ColumnID)
}
}
func TestLoadFlowCards_MalformedDoesNotCrash(t *testing.T) {
dir := t.TempDir()
writeFixture(t, dir, "0011-bad.md", "---\nid: 0011\nstatus: pending\n : malformed yaml\n---\nbody\n")
cards, err := loadFlowCards(dir)
if err != nil {
t.Fatalf("load: %v", err)
}
if len(cards) != 1 || cards[0].ParseError == "" {
t.Fatalf("expected 1 card with ParseError, got %#v", cards)
}
}
+163
View File
@@ -0,0 +1,163 @@
package main
import (
"bytes"
"fmt"
"os"
"path/filepath"
"strings"
)
// PatchFrontmatterField rewrites the value of a single YAML frontmatter key
// in the file at filePath, preserving everything else byte-for-byte.
//
// The file MUST begin with a "---" frontmatter delimiter (issue 0100 canonical).
// The function:
// - locates the frontmatter block delimited by leading "---" and closing "---"
// - finds a line matching "<key>:" at the root level (no indent)
// - replaces the value portion (keeping the key) with newValue
// - writes back atomically via temp file + rename
//
// If the key does not exist, it is inserted just before the closing "---" line.
// The function does NOT validate that newValue is YAML-safe; callers should
// pass plain scalars (no embedded newlines).
func PatchFrontmatterField(filePath, key, newValue string) error {
if key == "" {
return fmt.Errorf("PatchFrontmatterField: empty key")
}
if strings.ContainsAny(newValue, "\n\r") {
return fmt.Errorf("PatchFrontmatterField: newValue must not contain newlines")
}
raw, err := os.ReadFile(filePath)
if err != nil {
return fmt.Errorf("read %s: %w", filePath, err)
}
out, err := patchFrontmatterBytes(raw, key, newValue)
if err != nil {
return err
}
dir := filepath.Dir(filePath)
tmp, err := os.CreateTemp(dir, ".fm.*.tmp")
if err != nil {
return fmt.Errorf("tmp: %w", err)
}
tmpName := tmp.Name()
if _, err := tmp.Write(out); err != nil {
tmp.Close()
os.Remove(tmpName)
return fmt.Errorf("write tmp: %w", err)
}
if err := tmp.Close(); err != nil {
os.Remove(tmpName)
return fmt.Errorf("close tmp: %w", err)
}
// Preserve original file mode if possible.
if info, err := os.Stat(filePath); err == nil {
_ = os.Chmod(tmpName, info.Mode())
}
if err := os.Rename(tmpName, filePath); err != nil {
os.Remove(tmpName)
return fmt.Errorf("rename: %w", err)
}
return nil
}
func patchFrontmatterBytes(raw []byte, key, newValue string) ([]byte, error) {
// Detect line ending convention (default to LF).
eol := []byte("\n")
if bytes.Contains(raw, []byte("\r\n")) {
eol = []byte("\r\n")
}
// Find frontmatter boundaries. First line must be "---".
lines := splitKeepEOL(raw)
if len(lines) == 0 {
return nil, fmt.Errorf("empty file")
}
if !isDashDashDash(lines[0]) {
return nil, fmt.Errorf("no frontmatter (file does not start with '---')")
}
closeIdx := -1
for i := 1; i < len(lines); i++ {
if isDashDashDash(lines[i]) {
closeIdx = i
break
}
}
if closeIdx < 0 {
return nil, fmt.Errorf("no frontmatter close delimiter")
}
keyPrefix := key + ":"
found := -1
for i := 1; i < closeIdx; i++ {
trimmed := strings.TrimRight(strings.TrimRight(string(lines[i]), "\n"), "\r")
// Only match top-level keys (no leading whitespace).
if !strings.HasPrefix(trimmed, keyPrefix) {
continue
}
// Ensure next char after key is ':' followed by space or EOL (avoid prefix collisions).
afterKey := trimmed[len(keyPrefix):]
if afterKey != "" && afterKey[0] != ' ' && afterKey[0] != '\t' {
continue
}
found = i
break
}
var buf bytes.Buffer
if found >= 0 {
// Replace just the value on that line, preserving EOL.
original := lines[found]
// Compute EOL preserved at end.
lineEOL := []byte{}
if bytes.HasSuffix(original, []byte("\r\n")) {
lineEOL = []byte("\r\n")
} else if bytes.HasSuffix(original, []byte("\n")) {
lineEOL = []byte("\n")
}
newLine := []byte(key + ": " + newValue)
newLine = append(newLine, lineEOL...)
for i, l := range lines {
if i == found {
buf.Write(newLine)
} else {
buf.Write(l)
}
}
} else {
// Insert just before closeIdx.
insertion := []byte(key + ": " + newValue)
insertion = append(insertion, eol...)
for i, l := range lines {
if i == closeIdx {
buf.Write(insertion)
}
buf.Write(l)
}
}
return buf.Bytes(), nil
}
// splitKeepEOL splits raw into lines, preserving the trailing EOL on each line.
func splitKeepEOL(raw []byte) [][]byte {
var lines [][]byte
start := 0
for i := 0; i < len(raw); i++ {
if raw[i] == '\n' {
lines = append(lines, raw[start:i+1])
start = i + 1
}
}
if start < len(raw) {
lines = append(lines, raw[start:])
}
return lines
}
func isDashDashDash(line []byte) bool {
s := strings.TrimRight(strings.TrimRight(string(line), "\n"), "\r")
return s == "---"
}
+104
View File
@@ -0,0 +1,104 @@
package main
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestPatchFrontmatterField_UpdateExistingKey(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "0119-x.md")
original := "---\n" +
"id: \"0119\"\n" +
"title: \"Test\"\n" +
"status: pendiente\n" +
"priority: alta\n" +
"tags: [a, b]\n" +
"---\n" +
"\n" +
"# Body heading\n" +
"\n" +
"Some body.\n"
if err := os.WriteFile(path, []byte(original), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
if err := PatchFrontmatterField(path, "status", "en-curso"); err != nil {
t.Fatalf("patch: %v", err)
}
got, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read: %v", err)
}
gotStr := string(got)
if !strings.Contains(gotStr, "status: en-curso") {
t.Fatalf("expected status: en-curso, got:\n%s", gotStr)
}
// Preserve everything else.
for _, line := range []string{
`id: "0119"`,
`title: "Test"`,
`priority: alta`,
`tags: [a, b]`,
`# Body heading`,
`Some body.`,
} {
if !strings.Contains(gotStr, line) {
t.Fatalf("line %q lost after patch, got:\n%s", line, gotStr)
}
}
// Ensure original status line is gone (no duplicate).
if strings.Count(gotStr, "status:") != 1 {
t.Fatalf("expected exactly one status: line, got %d:\n%s", strings.Count(gotStr, "status:"), gotStr)
}
}
func TestPatchFrontmatterField_InsertMissingKey(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "0119-x.md")
original := "---\n" +
"id: \"0119\"\n" +
"title: \"Test\"\n" +
"---\n" +
"body\n"
if err := os.WriteFile(path, []byte(original), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
if err := PatchFrontmatterField(path, "status", "done"); err != nil {
t.Fatalf("patch: %v", err)
}
got, _ := os.ReadFile(path)
gotStr := string(got)
if !strings.Contains(gotStr, "status: done") {
t.Fatalf("missing inserted status line:\n%s", gotStr)
}
if !strings.Contains(gotStr, "body") {
t.Fatalf("body lost:\n%s", gotStr)
}
}
func TestPatchFrontmatterField_NoFrontmatter(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "plain.md")
_ = os.WriteFile(path, []byte("just a body\n"), 0o644)
err := PatchFrontmatterField(path, "status", "done")
if err == nil {
t.Fatalf("expected error for missing frontmatter")
}
}
func TestPatchFrontmatterField_AtomicNoLeftovers(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "0119.md")
_ = os.WriteFile(path, []byte("---\nid: \"0119\"\nstatus: pendiente\n---\n"), 0o644)
if err := PatchFrontmatterField(path, "status", "en-curso"); err != nil {
t.Fatalf("patch: %v", err)
}
entries, _ := os.ReadDir(dir)
for _, e := range entries {
if strings.HasPrefix(e.Name(), ".fm.") || strings.HasSuffix(e.Name(), ".tmp") {
t.Fatalf("leftover tmp file: %s", e.Name())
}
}
}
+3 -1
View File
@@ -416,7 +416,7 @@ func handlePurgeCard(db *DB) http.HandlerFunc {
}
func apiRoutes(db *DB, chatWorkdir string, logger *ChatLogger, internalToken string, flags *FeatureFlags) []infra.Route {
return []infra.Route{
routes := []infra.Route{
{Method: "GET", Path: "/api/flags", Handler: handleListFlags(flags)},
{Method: "POST", Path: "/api/auth/register", Handler: handleRegister(db, flags)},
{Method: "POST", Path: "/api/auth/login", Handler: handleLogin(db)},
@@ -449,6 +449,8 @@ func apiRoutes(db *DB, chatWorkdir string, logger *ChatLogger, internalToken str
{Method: "GET", Path: "/api/tags", Handler: handleListTags(db)},
{Method: "GET", Path: "/api/requesters", Handler: handleListRequesters(db)},
}
routes = append(routes, boardRoutes()...)
return routes
}
func handleListTags(db *DB) http.HandlerFunc {
+254
View File
@@ -0,0 +1,254 @@
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"fn-registry/functions/infra"
)
// agentRunnerEndpoint returns the agent_runner_api base URL.
// Override with KANBAN_AGENT_RUNNER_API env var.
func agentRunnerEndpoint() string {
if v := strings.TrimSpace(os.Getenv("KANBAN_AGENT_RUNNER_API")); v != "" {
return v
}
return "http://127.0.0.1:8486"
}
// allowedStatusForBoard returns the canonical statuses a PATCH can set on a
// given board. Anything else returns 400 (taxonomy issue 0103).
func allowedStatusForBoard(board string) []string {
switch board {
case "issues":
return []string{"pendiente", "en-curso", "en-revisión", "en-revision", "done", "deferred"}
case "flows":
return []string{"pending", "running", "done", "deferred"}
default:
return nil
}
}
func isAllowedStatus(board, status string) bool {
allowed := allowedStatusForBoard(board)
for _, a := range allowed {
if a == status {
return true
}
}
return false
}
// dirAndCacheForBoard returns the filesystem directory + cache for a board
// name. Unknown boards yield ("", nil).
func dirAndCacheForBoard(board string) (string, *cardsCache, func(string) string) {
switch board {
case "issues":
return issuesDir(), issuesCache, mapIssueStatusToColumn
case "flows":
return flowsDir(), flowsCache, mapFlowStatusToColumn
default:
return "", nil, nil
}
}
// findCardFile locates the .md file in dir whose leading numeric id matches
// the given card id. Returns "" if not found.
func findCardFile(dir, id string) (string, error) {
id = strings.TrimSpace(id)
if id == "" {
return "", nil
}
entries, err := os.ReadDir(dir)
if err != nil {
return "", err
}
for _, e := range entries {
if e.IsDir() {
continue
}
name := e.Name()
if !strings.HasSuffix(strings.ToLower(name), ".md") {
continue
}
if isSkippedMarkdown(name) {
continue
}
if deriveIDFromFilename(name) == id {
return filepath.Join(dir, name), nil
}
}
return "", nil
}
// GET /api/boards/{board}/cards
func handleListBoardCards() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
board := r.PathValue("board")
dir, cache, _ := dirAndCacheForBoard(board)
if dir == "" {
notFound(w, "unknown board: "+board)
return
}
if cached, ok := cache.get(); ok {
infra.HTTPJSONResponse(w, http.StatusOK, cached)
return
}
var (
cards []IssueCard
err error
)
switch board {
case "issues":
cards, err = loadIssueCards(dir)
case "flows":
cards, err = loadFlowCards(dir)
}
if err != nil {
serverError(w, err)
return
}
cache.set(cards)
infra.HTTPJSONResponse(w, http.StatusOK, cards)
}
}
// PATCH /api/boards/{board}/cards/{id} body: { status: "..." }
func handlePatchBoardCard() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
board := r.PathValue("board")
id := r.PathValue("id")
dir, cache, _ := dirAndCacheForBoard(board)
if dir == "" {
notFound(w, "unknown board: "+board)
return
}
var body struct {
Status string `json:"status"`
}
if err := infra.HTTPParseBody(r, &body, maxBodyBytes); err != nil {
badRequest(w, err.Error())
return
}
status := strings.TrimSpace(body.Status)
if status == "" {
badRequest(w, "status required")
return
}
if !isAllowedStatus(board, status) {
badRequest(w, fmt.Sprintf("invalid status for board %q: %q (allowed: %s)",
board, status, strings.Join(allowedStatusForBoard(board), ", ")))
return
}
file, err := findCardFile(dir, id)
if err != nil {
serverError(w, err)
return
}
if file == "" {
notFound(w, fmt.Sprintf("card %q not found on board %q", id, board))
return
}
// Patch status; also bump updated to today (YYYY-MM-DD).
if err := PatchFrontmatterField(file, "status", status); err != nil {
serverError(w, err)
return
}
_ = PatchFrontmatterField(file, "updated", time.Now().UTC().Format("2006-01-02"))
cache.invalidate()
infra.HTTPJSONResponse(w, http.StatusOK, map[string]any{
"ok": true,
"id": id,
"board": board,
"status": status,
"file": file,
})
}
}
// POST /api/boards/{board}/cards/{id}/launch
// Proxies to agent_runner_api at /api/runs with payload including the issue id
// and the DoD items pulled from the .md frontmatter. If the runner is
// unreachable, returns 502 with a suggestion.
func handleLaunchBoardCard() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
board := r.PathValue("board")
id := r.PathValue("id")
dir, _, statusMapper := dirAndCacheForBoard(board)
if dir == "" {
notFound(w, "unknown board: "+board)
return
}
file, err := findCardFile(dir, id)
if err != nil {
serverError(w, err)
return
}
if file == "" {
notFound(w, fmt.Sprintf("card %q not found on board %q", id, board))
return
}
card, err := parseCardFile(file, statusMapper)
if err != nil {
serverError(w, err)
return
}
// Drain incoming body (optional overrides from client). We do not
// forward it as-is to avoid trust issues; we build a clean payload.
_, _ = io.Copy(io.Discard, r.Body)
payload := map[string]any{
"board": board,
"issue_id": card.ExternalID,
"title": card.Title,
"priority": card.Priority,
"type": card.Type,
"flow_id": card.FlowID,
"dod_items": card.DoDItems,
"file_path": card.FilePath,
"launched_at": time.Now().UTC().Format(time.RFC3339),
}
buf, _ := json.Marshal(payload)
url := strings.TrimRight(agentRunnerEndpoint(), "/") + "/api/runs"
client := &http.Client{Timeout: 5 * time.Second}
req, err := http.NewRequestWithContext(r.Context(), http.MethodPost, url, bytes.NewReader(buf))
if err != nil {
serverError(w, err)
return
}
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
infra.HTTPErrorResponse(w, infra.HTTPError{
Status: http.StatusBadGateway,
Code: "agent_runner_unreachable",
Message: fmt.Sprintf("could not reach agent_runner_api at %s: %v (suggestion: start agent_runner_api service)", url, err),
})
return
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
// Forward status + body verbatim so the UI can show backend errors.
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(resp.StatusCode)
_, _ = w.Write(body)
}
}
// boardRoutes returns the additional routes for issues/flows boards. Called
// from apiRoutes() in handlers.go.
func boardRoutes() []infra.Route {
return []infra.Route{
{Method: "GET", Path: "/api/boards/{board}/cards", Handler: handleListBoardCards()},
{Method: "PATCH", Path: "/api/boards/{board}/cards/{id}", Handler: handlePatchBoardCard()},
{Method: "POST", Path: "/api/boards/{board}/cards/{id}/launch", Handler: handleLaunchBoardCard()},
}
}
+387
View File
@@ -0,0 +1,387 @@
package main
import (
"bytes"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
"gopkg.in/yaml.v3"
)
// IssueCard is the on-the-wire card representation for issues/flows boards.
// It mirrors enough of the regular Card shape so the kanban UI can render it,
// but it is built from a .md frontmatter file (NOT from operations.db).
type IssueCard struct {
ID string `json:"id"` // canonical "external_id" (e.g. "0119")
ExternalID string `json:"external_id"` // same as ID, for clarity
Title string `json:"title"`
Description string `json:"description"` // first ~5 lines of body
Status string `json:"status"` // raw frontmatter status (pendiente/en-curso/done/deferred)
ColumnID string `json:"column_id"` // mapped column ("Backlog"/"Doing"/"Review"/"Done"/"Deferred")
Priority string `json:"priority"` // alta/media/baja
Type string `json:"type"` // feature/bug/chore/...
Tag string `json:"tag"` // same as type, for card.tag convenience
Tags []string `json:"tags"`
FlowID string `json:"flow_id"` // frontmatter flow
DoDItems []string `json:"dod_items"` // from dod_evidence_schema if present
UpdatedAt string `json:"updated_at"`
CreatedAt string `json:"created_at"`
FilePath string `json:"file_path"` // relative path under registry root
ParseError string `json:"parse_error,omitempty"`
}
type issueFrontmatter struct {
ID string `yaml:"id"`
Title string `yaml:"title"`
Status string `yaml:"status"`
Type string `yaml:"type"`
Priority string `yaml:"priority"`
Tags []string `yaml:"tags"`
Flow string `yaml:"flow"`
Created string `yaml:"created"`
Updated string `yaml:"updated"`
DoDEvidenceSchema []any `yaml:"dod_evidence_schema"`
Extra map[string]interface{} `yaml:",inline"`
}
// --- in-memory cache ---------------------------------------------------------
type cardsCache struct {
mu sync.Mutex
at time.Time
cards []IssueCard
dir string
ttl time.Duration
}
func (c *cardsCache) get() ([]IssueCard, bool) {
c.mu.Lock()
defer c.mu.Unlock()
if time.Since(c.at) < c.ttl && c.cards != nil {
out := make([]IssueCard, len(c.cards))
copy(out, c.cards)
return out, true
}
return nil, false
}
func (c *cardsCache) set(cards []IssueCard) {
c.mu.Lock()
defer c.mu.Unlock()
c.cards = make([]IssueCard, len(cards))
copy(c.cards, cards)
c.at = time.Now()
}
func (c *cardsCache) invalidate() {
c.mu.Lock()
defer c.mu.Unlock()
c.at = time.Time{}
c.cards = nil
}
var (
issuesCache = &cardsCache{ttl: 30 * time.Second}
)
// mapIssueStatusToColumn maps canonical issue frontmatter statuses to kanban
// column ids. Falls back to "Backlog" for unknown / empty values.
func mapIssueStatusToColumn(status string) string {
switch strings.ToLower(strings.TrimSpace(status)) {
case "pendiente", "":
return "Backlog"
case "en-curso":
return "Doing"
case "en-revisión", "en-revision":
return "Review"
case "done":
return "Done"
case "deferred":
return "Deferred"
default:
return "Backlog"
}
}
// loadIssueCards scans issuesDir for *.md issue files, parses each frontmatter
// and returns the resulting cards. README/INDEX/AGENT_GUIDE and files inside
// completed/ are skipped. Parse errors do NOT abort the scan — they yield a
// card with ParseError set so the UI can surface them.
//
// Results are sorted by updated_at desc, then id asc.
func loadIssueCards(issuesDir string) ([]IssueCard, error) {
return loadCardsFromDir(issuesDir, mapIssueStatusToColumn, "issue")
}
// loadCardsFromDir is the shared implementation for issues and flows.
// statusMapper translates frontmatter status -> column id for the given board.
func loadCardsFromDir(dir string, statusMapper func(string) string, kind string) ([]IssueCard, error) {
entries, err := os.ReadDir(dir)
if err != nil {
return nil, fmt.Errorf("read %s: %w", dir, err)
}
out := make([]IssueCard, 0, len(entries))
for _, e := range entries {
if e.IsDir() {
continue
}
name := e.Name()
if !strings.HasSuffix(strings.ToLower(name), ".md") {
continue
}
if isSkippedMarkdown(name) {
continue
}
full := filepath.Join(dir, name)
c, err := parseCardFile(full, statusMapper)
if err != nil {
// Surface as a parse-error card so the UI still shows it.
out = append(out, IssueCard{
ID: deriveIDFromFilename(name),
ExternalID: deriveIDFromFilename(name),
Title: name,
Status: "pendiente",
ColumnID: statusMapper(""),
FilePath: full,
ParseError: err.Error(),
})
continue
}
out = append(out, c)
}
sort.SliceStable(out, func(i, j int) bool {
if out[i].UpdatedAt != out[j].UpdatedAt {
return out[i].UpdatedAt > out[j].UpdatedAt
}
return out[i].ID < out[j].ID
})
_ = kind // reserved for telemetry/log labels
return out, nil
}
func isSkippedMarkdown(name string) bool {
upper := strings.ToUpper(name)
switch upper {
case "README.MD", "INDEX.MD", "AGENT_GUIDE.MD":
return true
}
return false
}
// deriveIDFromFilename pulls the leading numeric id segment from a filename
// like "0119-foo-bar.md" -> "0119". If no leading digits, returns the
// stem without extension.
func deriveIDFromFilename(name string) string {
stem := strings.TrimSuffix(name, filepath.Ext(name))
for i := 0; i < len(stem); i++ {
if stem[i] < '0' || stem[i] > '9' {
if i == 0 {
return stem
}
return stem[:i]
}
}
return stem
}
// parseCardFile reads filePath, splits frontmatter from body and returns a
// populated IssueCard. The body's first ~5 non-empty lines (after the first
// markdown heading) become the description.
func parseCardFile(filePath string, statusMapper func(string) string) (IssueCard, error) {
raw, err := os.ReadFile(filePath)
if err != nil {
return IssueCard{}, fmt.Errorf("read: %w", err)
}
fmText, body, err := splitFrontmatter(raw)
if err != nil {
return IssueCard{}, err
}
var fm issueFrontmatter
if err := yaml.Unmarshal(fmText, &fm); err != nil {
return IssueCard{}, fmt.Errorf("yaml: %w", err)
}
id := strings.TrimSpace(fm.ID)
if id == "" {
id = deriveIDFromFilename(filepath.Base(filePath))
}
status := strings.TrimSpace(fm.Status)
if status == "" {
status = "pendiente"
}
card := IssueCard{
ID: id,
ExternalID: id,
Title: strings.TrimSpace(fm.Title),
Status: status,
ColumnID: statusMapper(status),
Priority: strings.TrimSpace(fm.Priority),
Type: strings.TrimSpace(fm.Type),
Tag: strings.TrimSpace(fm.Type),
Tags: fm.Tags,
FlowID: strings.TrimSpace(fm.Flow),
UpdatedAt: strings.TrimSpace(fm.Updated),
CreatedAt: strings.TrimSpace(fm.Created),
FilePath: filePath,
DoDItems: dodItemsFromSchema(fm.DoDEvidenceSchema),
}
if card.Title == "" {
card.Title = filepath.Base(filePath)
}
card.Description = firstBodyLines(body, 5)
return card, nil
}
// splitFrontmatter expects raw to start with "---\n". Returns frontmatter
// bytes (without the delimiters) and body bytes.
func splitFrontmatter(raw []byte) ([]byte, []byte, error) {
// Tolerate optional BOM.
if bytes.HasPrefix(raw, []byte{0xEF, 0xBB, 0xBF}) {
raw = raw[3:]
}
if !bytes.HasPrefix(raw, []byte("---")) {
return nil, nil, fmt.Errorf("no frontmatter")
}
// Find end of first delimiter line.
firstNL := bytes.IndexByte(raw, '\n')
if firstNL < 0 {
return nil, nil, fmt.Errorf("malformed frontmatter (no newline after opening ---)")
}
rest := raw[firstNL+1:]
// Find closing delimiter at start of a line.
closeIdx := -1
searchFrom := 0
for {
idx := bytes.Index(rest[searchFrom:], []byte("\n---"))
if idx < 0 {
// also accept frontmatter that starts immediately with "---" then directly "---" on next line
if bytes.HasPrefix(rest, []byte("---")) {
closeIdx = 0
}
break
}
absolute := searchFrom + idx + 1 // skip the \n
// confirm it's on its own line (followed by \n or EOF or \r\n)
after := absolute + 3
if after == len(rest) || rest[after] == '\n' || rest[after] == '\r' {
closeIdx = absolute
break
}
searchFrom = absolute + 3
}
if closeIdx < 0 {
return nil, nil, fmt.Errorf("malformed frontmatter (no closing ---)")
}
fm := rest[:closeIdx]
// Trim trailing newline from fm if present.
fm = bytes.TrimRight(fm, "\r\n")
body := []byte{}
bodyStart := closeIdx + 3
if bodyStart < len(rest) {
// skip leading EOL after closing ---
if rest[bodyStart] == '\r' && bodyStart+1 < len(rest) && rest[bodyStart+1] == '\n' {
bodyStart += 2
} else if rest[bodyStart] == '\n' {
bodyStart++
}
body = rest[bodyStart:]
}
return fm, body, nil
}
// firstBodyLines returns up to n meaningful lines from body (skipping the
// initial H1/H2 heading and blank lines) joined with spaces.
func firstBodyLines(body []byte, n int) string {
if n <= 0 {
return ""
}
lines := strings.Split(string(body), "\n")
out := make([]string, 0, n)
skippedHeading := false
for _, l := range lines {
t := strings.TrimRight(strings.TrimRight(l, "\n"), "\r")
ts := strings.TrimSpace(t)
if ts == "" {
continue
}
if !skippedHeading && strings.HasPrefix(ts, "#") {
skippedHeading = true
continue
}
// Skip pure markdown decorations like horizontal rules.
if ts == "---" {
continue
}
out = append(out, ts)
if len(out) >= n {
break
}
}
return strings.Join(out, " ")
}
// dodItemsFromSchema extracts a flat list of titles/keys from the
// `dod_evidence_schema` frontmatter field, which can be either a list of
// strings or a list of objects with a "title" or "key" field.
func dodItemsFromSchema(items []any) []string {
if len(items) == 0 {
return nil
}
out := make([]string, 0, len(items))
for _, it := range items {
switch v := it.(type) {
case string:
s := strings.TrimSpace(v)
if s != "" {
out = append(out, s)
}
case map[string]interface{}:
for _, k := range []string{"title", "name", "key", "id"} {
if val, ok := v[k]; ok {
if s, ok2 := val.(string); ok2 && strings.TrimSpace(s) != "" {
out = append(out, strings.TrimSpace(s))
break
}
}
}
}
}
return out
}
// --- registry root resolution ------------------------------------------------
// registryRoot returns FN_REGISTRY_ROOT if set, otherwise walks upward from
// cwd looking for a "dev/issues" directory. Falls back to cwd.
func registryRoot() string {
if v := strings.TrimSpace(os.Getenv("FN_REGISTRY_ROOT")); v != "" {
return v
}
cwd, err := os.Getwd()
if err != nil {
return "."
}
dir := cwd
for i := 0; i < 8; i++ {
if st, err := os.Stat(filepath.Join(dir, "dev", "issues")); err == nil && st.IsDir() {
return dir
}
parent := filepath.Dir(dir)
if parent == dir {
break
}
dir = parent
}
return cwd
}
func issuesDir() string {
return filepath.Join(registryRoot(), "dev", "issues")
}
+119
View File
@@ -0,0 +1,119 @@
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")
}
}
+1 -1
View File
@@ -76,7 +76,7 @@ func main() {
authMW := infra.HTTPSessionCookieMiddleware(infra.SessionCookieConfig{
DB: db.conn,
CookieName: cookieName,
SkipPaths: []string{"/api/auth/", "/api/tool/", "/api/flags", "/health", "/assets/", "/index.html"},
SkipPaths: []string{"/api/auth/", "/api/tool/", "/api/flags", "/api/boards/", "/health", "/assets/", "/index.html"},
UserCtxKey: userCtxKey,
})