Files
fn_registry/functions/infra/parse_issue_md.go
T
Egutierrez c468b24d2b feat(0130): kanban_cpp v2 — backend Go + 5 registry parser fns + epic/sub-issues
Registry (issue 0130a):
- 5 fns infra: parse_issue_md, write_issue_md, scan_issues_dir,
  scan_flows_dir, watch_dir_fsnotify
- 3 tipos: Issue, Flow, FsEvent
- Tests round-trip + scan reales + watcher fsnotify (all PASS)
- Capability group 'kanban' nuevo (docs/capabilities/kanban.md)

Apps:
- apps/kanban_cpp/ (sub-repo) — frontend ImGui: board drag-drop,
  flows, filters, detail con CSV editors
- apps/kanban_cpp/backend/ — Go service port 8487: REST + SSE +
  fsnotify watcher, parser bidireccional MD<->SQLite cache

Issues:
- dev/issues/0130-kanban-cpp-v2.md (epic)
- 0130a parser, 0130b backend, 0130c frontend

CMakeLists.txt: add_subdirectory apps/kanban_cpp (registrado por
init_cpp_app scaffolder).

End-to-end verde: backend devuelve 189 issues + 9 flows; PATCH a
/api/issues/{id} reescribe .md (solo frontmatter, body intacto);
frontend --self-test exit 0; tests Go infra 5/5 PASS.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 22:20:15 +02:00

88 lines
2.3 KiB
Go

package infra
import (
"bytes"
"fmt"
"os"
"strings"
"gopkg.in/yaml.v3"
)
// ParseIssueMd lee un archivo Markdown de issue, extrae y parsea el frontmatter YAML
// en un struct Issue, y devuelve el body (todo lo que va despues del segundo "---").
// FilePath e MtimeNs se rellenan con los valores del archivo en disco.
// Completed se deduce del path (contiene "/completed/").
func ParseIssueMd(path string) (Issue, []byte, error) {
data, err := os.ReadFile(path)
if err != nil {
return Issue{}, nil, fmt.Errorf("parse_issue_md: read %s: %w", path, err)
}
info, err := os.Stat(path)
if err != nil {
return Issue{}, nil, fmt.Errorf("parse_issue_md: stat %s: %w", path, err)
}
fm, body, err := splitFrontmatter(data)
if err != nil {
return Issue{}, nil, fmt.Errorf("parse_issue_md: %s: %w", path, err)
}
var iss Issue
if err := yaml.Unmarshal(fm, &iss); err != nil {
return Issue{}, nil, fmt.Errorf("parse_issue_md: yaml %s: %w", path, err)
}
iss.FilePath = path
iss.MtimeNs = info.ModTime().UnixNano()
iss.Completed = strings.Contains(path, "/completed/")
return iss, body, nil
}
// splitFrontmatter divide el contenido en bloque YAML y body.
// Espera formato: "---\n<yaml>\n---\n<body>".
// Devuelve el YAML (sin los delimitadores) y el body (incluye el \n posterior al segundo ---).
func splitFrontmatter(data []byte) ([]byte, []byte, error) {
sep := []byte("---")
newline := []byte("\n")
// El archivo debe empezar con "---\n"
if !bytes.HasPrefix(data, append(sep, '\n')) {
return nil, nil, fmt.Errorf("missing opening '---' delimiter")
}
// Buscar el segundo "---" (en su propia linea)
rest := data[len(sep)+1:] // avanza pasado el primer "---\n"
idx := -1
for i := 0; i <= len(rest)-len(sep); i++ {
// Debe estar al inicio de linea: posicion 0 o precedido por '\n'
atLineStart := i == 0 || rest[i-1] == '\n'
if atLineStart && bytes.Equal(rest[i:i+len(sep)], sep) {
// El separador debe ir seguido de '\n' o EOF
end := i + len(sep)
if end == len(rest) || rest[end] == '\n' {
idx = i
break
}
}
}
if idx == -1 {
return nil, nil, fmt.Errorf("missing closing '---' delimiter")
}
fm := rest[:idx]
// El body empieza despues del segundo "---\n"
bodyStart := idx + len(sep)
if bodyStart < len(rest) && rest[bodyStart] == '\n' {
bodyStart++
}
body := rest[bodyStart:]
_ = newline
return fm, body, nil
}