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>
388 lines
11 KiB
Go
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")
|
|
}
|