Files
kanban_cpp/backend/issues_source.go
T
agent 0b93a985d6 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>
2026-05-18 18:56:22 +02:00

388 lines
11 KiB
Go

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