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>
164 lines
4.3 KiB
Go
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 == "---"
|
|
}
|