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>
This commit is contained in:
@@ -0,0 +1,87 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user