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,163 @@
|
||||
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 == "---"
|
||||
}
|
||||
Reference in New Issue
Block a user