diff --git a/backend/flows_source.go b/backend/flows_source.go new file mode 100644 index 0000000..21f78d9 --- /dev/null +++ b/backend/flows_source.go @@ -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") +} diff --git a/backend/flows_source_test.go b/backend/flows_source_test.go new file mode 100644 index 0000000..e133112 --- /dev/null +++ b/backend/flows_source_test.go @@ -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) + } +} diff --git a/backend/frontmatter_edit.go b/backend/frontmatter_edit.go new file mode 100644 index 0000000..4b30ffd --- /dev/null +++ b/backend/frontmatter_edit.go @@ -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 ":" 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 == "---" +} diff --git a/backend/frontmatter_edit_test.go b/backend/frontmatter_edit_test.go new file mode 100644 index 0000000..3c64f3b --- /dev/null +++ b/backend/frontmatter_edit_test.go @@ -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()) + } + } +} diff --git a/backend/handlers.go b/backend/handlers.go index e4c2e84..3f5a74c 100644 --- a/backend/handlers.go +++ b/backend/handlers.go @@ -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 { diff --git a/backend/handlers_boards.go b/backend/handlers_boards.go new file mode 100644 index 0000000..9939e9f --- /dev/null +++ b/backend/handlers_boards.go @@ -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()}, + } +} diff --git a/backend/issues_source.go b/backend/issues_source.go new file mode 100644 index 0000000..06d29d4 --- /dev/null +++ b/backend/issues_source.go @@ -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") +} diff --git a/backend/issues_source_test.go b/backend/issues_source_test.go new file mode 100644 index 0000000..2cd80a0 --- /dev/null +++ b/backend/issues_source_test.go @@ -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") + } +} diff --git a/backend/main.go b/backend/main.go index 4465dc9..d2f8a16 100644 --- a/backend/main.go +++ b/backend/main.go @@ -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, })