Files
kanban_cpp/backend/frontmatter_edit.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

164 lines
4.3 KiB
Go

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 "<key>:" 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 == "---"
}