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:
@@ -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")
|
||||
}
|
||||
Reference in New Issue
Block a user