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") }