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,19 @@
|
||||
package infra
|
||||
|
||||
// Flow representa el frontmatter de un archivo Markdown de flow en dev/flows/.
|
||||
// Los campos de runtime (FilePath, MtimeNs) no se serializaran en YAML.
|
||||
type Flow struct {
|
||||
ID string `yaml:"id"`
|
||||
Title string `yaml:"title,omitempty"`
|
||||
Status string `yaml:"status,omitempty"`
|
||||
Kind string `yaml:"kind,omitempty"`
|
||||
Tags []string `yaml:"tags,omitempty"`
|
||||
|
||||
// Para flows con formato name/status por separado (ej. hn-top-stories).
|
||||
Name string `yaml:"name,omitempty"`
|
||||
Priority string `yaml:"priority,omitempty"`
|
||||
|
||||
// Campos de runtime — NO se serializan en YAML.
|
||||
FilePath string `yaml:"-"`
|
||||
MtimeNs int64 `yaml:"-"`
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package infra
|
||||
|
||||
// FsEvent representa un evento del watcher de sistema de archivos.
|
||||
// Op es uno de: "create", "write", "remove", "rename".
|
||||
type FsEvent struct {
|
||||
Path string // ruta absoluta del archivo afectado
|
||||
Op string // "create" | "write" | "remove" | "rename"
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package infra
|
||||
|
||||
// Issue representa el frontmatter de un archivo Markdown de issue en dev/issues/.
|
||||
// Los campos de runtime (FilePath, MtimeNs, Completed) no se serialiaran en YAML.
|
||||
type Issue struct {
|
||||
ID string `yaml:"id"`
|
||||
Title string `yaml:"title"`
|
||||
Status string `yaml:"status"`
|
||||
Type string `yaml:"type"`
|
||||
Domain []string `yaml:"domain"`
|
||||
Scope string `yaml:"scope"`
|
||||
Priority string `yaml:"priority"`
|
||||
Depends []string `yaml:"depends"`
|
||||
Blocks []string `yaml:"blocks"`
|
||||
Related []string `yaml:"related"`
|
||||
Tags []string `yaml:"tags"`
|
||||
Flow string `yaml:"flow,omitempty"`
|
||||
Created string `yaml:"created"`
|
||||
Updated string `yaml:"updated"`
|
||||
|
||||
// Campos de runtime — NO se serializan en YAML.
|
||||
FilePath string `yaml:"-"`
|
||||
MtimeNs int64 `yaml:"-"`
|
||||
Completed bool `yaml:"-"` // true si el archivo vive en dev/issues/completed/
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
---
|
||||
name: parse_issue_md
|
||||
kind: function
|
||||
lang: go
|
||||
domain: infra
|
||||
version: "0.1.0"
|
||||
purity: impure
|
||||
signature: "func ParseIssueMd(path string) (Issue, []byte, error)"
|
||||
description: "Lee un archivo Markdown de issue (dev/issues/*.md), extrae el frontmatter YAML en un struct Issue y devuelve el body tal como esta en disco. Rellena FilePath, MtimeNs y Completed (deduce de si el path contiene /completed/)."
|
||||
tags: [issue, parser, frontmatter, yaml, kanban, dev-ux, kanban]
|
||||
uses_functions: []
|
||||
uses_types: [issue_go_infra]
|
||||
returns: [issue_go_infra]
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: ["bytes", "fmt", "os", "strings", "gopkg.in/yaml.v3"]
|
||||
params:
|
||||
- name: path
|
||||
desc: "Ruta absoluta o relativa al archivo .md del issue (ej: dev/issues/0130-kanban-cpp-v2.md)"
|
||||
output: "Struct Issue con todos los campos del frontmatter, byte slice con el body MD, y error si el archivo no existe o el YAML es invalido"
|
||||
tested: true
|
||||
tests:
|
||||
- "parsea 0130-kanban-cpp-v2 correctamente"
|
||||
- "completed flag se deduce del path"
|
||||
- "error en archivo inexistente"
|
||||
- "fixture preserva campos"
|
||||
test_file_path: "functions/infra/parse_issue_md_test.go"
|
||||
file_path: "functions/infra/parse_issue_md.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
iss, body, err := infra.ParseIssueMd("dev/issues/0130-kanban-cpp-v2.md")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
fmt.Printf("ID=%s Status=%s Domain=%v\n", iss.ID, iss.Status, iss.Domain)
|
||||
// body contiene el Markdown despues del segundo ---
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando necesites leer el frontmatter de un issue del registry para mostrarlo, modificarlo o indexarlo. Usar como base de `scan_issues_dir_go_infra` (que la llama por cada archivo) o cuando necesites acceso al body MD ademas del struct.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- El body devuelto incluye el `\n` inmediatamente posterior al segundo `---`. No se normaliza.
|
||||
- Si el archivo tiene un solo `---` (sin segundo delimitador), retorna error. Issues sin frontmatter no son validos.
|
||||
- `Completed` se infiere del path, no del campo `status` del YAML — un issue con `status: completado` que vive en `dev/issues/` (no en `completed/`) tendra `Completed=false`.
|
||||
- Los campos `Depends`, `Blocks`, `Related`, `Tags`, `Domain` son `[]string` — si el YAML los omite quedan como `nil`, no slice vacio.
|
||||
@@ -0,0 +1,101 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func registryRoot() string {
|
||||
_, thisFile, _, _ := runtime.Caller(0)
|
||||
return filepath.Join(filepath.Dir(thisFile), "..", "..")
|
||||
}
|
||||
|
||||
func TestParseIssueMd(t *testing.T) {
|
||||
root := registryRoot()
|
||||
|
||||
t.Run("parsea 0130-kanban-cpp-v2 correctamente", func(t *testing.T) {
|
||||
path := filepath.Join(root, "dev", "issues", "0130-kanban-cpp-v2.md")
|
||||
iss, body, err := ParseIssueMd(path)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseIssueMd error: %v", err)
|
||||
}
|
||||
if iss.ID != "0130" {
|
||||
t.Errorf("ID: got %q, want %q", iss.ID, "0130")
|
||||
}
|
||||
if !strings.Contains(iss.Title, "Kanban C++ v2") {
|
||||
t.Errorf("Title %q does not contain 'Kanban C++ v2'", iss.Title)
|
||||
}
|
||||
if iss.Status != "pendiente" {
|
||||
t.Errorf("Status: got %q, want %q", iss.Status, "pendiente")
|
||||
}
|
||||
if len(iss.Domain) < 3 {
|
||||
t.Errorf("Domain: got %d items, want >=3: %v", len(iss.Domain), iss.Domain)
|
||||
}
|
||||
if iss.FilePath != path {
|
||||
t.Errorf("FilePath: got %q, want %q", iss.FilePath, path)
|
||||
}
|
||||
if iss.MtimeNs == 0 {
|
||||
t.Error("MtimeNs should be non-zero")
|
||||
}
|
||||
if iss.Completed {
|
||||
t.Error("Completed should be false for non-completed issue")
|
||||
}
|
||||
if len(body) == 0 {
|
||||
t.Error("body should not be empty")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("completed flag se deduce del path", func(t *testing.T) {
|
||||
fixturePath := filepath.Join(root, "functions", "infra", "testdata", "issue_fixture.fixture")
|
||||
data, err := os.ReadFile(fixturePath)
|
||||
if err != nil {
|
||||
t.Fatalf("read fixture: %v", err)
|
||||
}
|
||||
completedDir := filepath.Join(t.TempDir(), "completed")
|
||||
if err := os.MkdirAll(completedDir, 0755); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
completedPath := filepath.Join(completedDir, "9999-fixture.md")
|
||||
if err := os.WriteFile(completedPath, data, 0644); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
|
||||
iss, _, err := ParseIssueMd(completedPath)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseIssueMd error: %v", err)
|
||||
}
|
||||
if !iss.Completed {
|
||||
t.Error("Completed should be true for path with /completed/")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("error en archivo inexistente", func(t *testing.T) {
|
||||
_, _, err := ParseIssueMd("/nonexistent/path/issue.md")
|
||||
if err == nil {
|
||||
t.Error("expected error for nonexistent file")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("fixture preserva campos", func(t *testing.T) {
|
||||
fixturePath := filepath.Join(root, "functions", "infra", "testdata", "issue_fixture.fixture")
|
||||
iss, body, err := ParseIssueMd(fixturePath)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseIssueMd error: %v", err)
|
||||
}
|
||||
if iss.ID != "9999" {
|
||||
t.Errorf("ID: got %q, want %q", iss.ID, "9999")
|
||||
}
|
||||
if iss.Flow != "0001" {
|
||||
t.Errorf("Flow: got %q, want %q", iss.Flow, "0001")
|
||||
}
|
||||
if len(iss.Depends) != 1 || iss.Depends[0] != "0001" {
|
||||
t.Errorf("Depends: got %v, want [0001]", iss.Depends)
|
||||
}
|
||||
if !strings.Contains(string(body), "Este es el body") {
|
||||
t.Errorf("body should contain fixture text, got: %s", string(body))
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// ScanFlowsDir escanea el directorio root (dev/flows/) y devuelve todos los Flows
|
||||
// encontrados en *.md directos.
|
||||
// Si un archivo falla al parsearse, se emite un warning al log y se continua.
|
||||
// Los flows se devuelven ordenados por ID ascendente.
|
||||
func ScanFlowsDir(root string) ([]Flow, error) {
|
||||
matches, err := filepath.Glob(filepath.Join(root, "*.md"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan_flows_dir: glob: %w", err)
|
||||
}
|
||||
|
||||
var flows []Flow
|
||||
for _, path := range matches {
|
||||
base := filepath.Base(path)
|
||||
if strings.EqualFold(base, "INDEX.md") || strings.EqualFold(base, "README.md") || strings.EqualFold(base, "AGENT_GUIDE.md") {
|
||||
continue
|
||||
}
|
||||
|
||||
info, err := os.Stat(path)
|
||||
if err != nil || !info.Mode().IsRegular() {
|
||||
continue
|
||||
}
|
||||
|
||||
f, err := parseFlowMd(path)
|
||||
if err != nil {
|
||||
log.Printf("scan_flows_dir: warning: skip %s: %v", path, err)
|
||||
continue
|
||||
}
|
||||
flows = append(flows, f)
|
||||
}
|
||||
|
||||
sort.Slice(flows, func(i, j int) bool {
|
||||
return flows[i].ID < flows[j].ID
|
||||
})
|
||||
|
||||
return flows, nil
|
||||
}
|
||||
|
||||
// parseFlowMd parsea el frontmatter de un archivo dev/flows/*.md en un struct Flow.
|
||||
func parseFlowMd(path string) (Flow, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return Flow{}, fmt.Errorf("read %s: %w", path, err)
|
||||
}
|
||||
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return Flow{}, fmt.Errorf("stat %s: %w", path, err)
|
||||
}
|
||||
|
||||
fm, _, err := splitFrontmatter(data)
|
||||
if err != nil {
|
||||
return Flow{}, fmt.Errorf("frontmatter %s: %w", path, err)
|
||||
}
|
||||
|
||||
var f Flow
|
||||
if err := yaml.Unmarshal(fm, &f); err != nil {
|
||||
return Flow{}, fmt.Errorf("yaml %s: %w", path, err)
|
||||
}
|
||||
|
||||
// Algunos flows usan "name" y no "title" — normalizar
|
||||
if f.Title == "" && f.Name != "" {
|
||||
f.Title = f.Name
|
||||
}
|
||||
// Algunos flows usan entero como ID en el YAML — yaml.v3 lo convierte a string OK
|
||||
|
||||
f.FilePath = path
|
||||
f.MtimeNs = info.ModTime().UnixNano()
|
||||
|
||||
return f, nil
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
---
|
||||
name: scan_flows_dir
|
||||
kind: function
|
||||
lang: go
|
||||
domain: infra
|
||||
version: "0.1.0"
|
||||
purity: impure
|
||||
signature: "func ScanFlowsDir(root string) ([]Flow, error)"
|
||||
description: "Escanea el directorio dev/flows/ (root) y devuelve todos los Flows encontrados en *.md directos. Skippea INDEX.md, README.md y AGENT_GUIDE.md. Si un archivo falla al parsearse emite warning y continua. Resultado ordenado por ID ascendente."
|
||||
tags: [flow, scanner, frontmatter, yaml, dev-ux, kanban]
|
||||
uses_functions: []
|
||||
uses_types: [flow_go_infra]
|
||||
returns: [flow_go_infra]
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: ["fmt", "log", "os", "path/filepath", "sort", "strings", "gopkg.in/yaml.v3"]
|
||||
params:
|
||||
- name: root
|
||||
desc: "Ruta al directorio dev/flows/ (absoluta o relativa)."
|
||||
output: "Slice de Flow ordenado por ID asc con FilePath y MtimeNs rellenados. Flows con YAML malformado se omiten con warning."
|
||||
tested: true
|
||||
tests:
|
||||
- "scan devuelve al menos 5 flows"
|
||||
- "flow 0001 esta presente"
|
||||
- "flows tienen FilePath y MtimeNs"
|
||||
- "flows ordenados por ID asc"
|
||||
test_file_path: "functions/infra/scan_flows_dir_test.go"
|
||||
file_path: "functions/infra/scan_flows_dir.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
flows, err := infra.ScanFlowsDir("/home/lucas/fn_registry/dev/flows")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
fmt.Printf("Total flows: %d\n", len(flows))
|
||||
for _, f := range flows {
|
||||
fmt.Printf(" %s [%s] %s\n", f.ID, f.Status, f.Title)
|
||||
}
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Al arrancar el backend de kanban_cpp para cargar el panel Flows. Tambien util para dashboards de estado del proyecto que necesiten listar flujos activos/pendientes.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- El struct `Flow` tiene campos `Name` y `Title` porque algunos flows del registry usan `name:` y otros `title:` en el frontmatter. `parseFlowMd` normaliza: si `Title` esta vacio pero `Name` no, copia `Name` a `Title`.
|
||||
- No tiene subdirectorio `completed/` equivalente — todos los flows activos e historicos viven en el mismo directorio raiz.
|
||||
- La funcion `parseFlowMd` es interna (no exportada). Si necesitas parsear un flow individual, usa directamente `yaml.Unmarshal` o expone una funcion separada.
|
||||
@@ -0,0 +1,66 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestScanFlowsDir(t *testing.T) {
|
||||
root := registryRoot()
|
||||
flowsDir := filepath.Join(root, "dev", "flows")
|
||||
|
||||
t.Run("scan devuelve al menos 5 flows", func(t *testing.T) {
|
||||
flows, err := ScanFlowsDir(flowsDir)
|
||||
if err != nil {
|
||||
t.Fatalf("ScanFlowsDir: %v", err)
|
||||
}
|
||||
if len(flows) < 5 {
|
||||
t.Errorf("expected >= 5 flows, got %d", len(flows))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("flow 0001 esta presente", func(t *testing.T) {
|
||||
flows, err := ScanFlowsDir(flowsDir)
|
||||
if err != nil {
|
||||
t.Fatalf("ScanFlowsDir: %v", err)
|
||||
}
|
||||
found := false
|
||||
for _, f := range flows {
|
||||
if f.ID == "0001" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("flow 0001 not found in scan results")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("flows tienen FilePath y MtimeNs", func(t *testing.T) {
|
||||
flows, err := ScanFlowsDir(flowsDir)
|
||||
if err != nil {
|
||||
t.Fatalf("ScanFlowsDir: %v", err)
|
||||
}
|
||||
for _, f := range flows {
|
||||
if f.FilePath == "" {
|
||||
t.Errorf("flow %q has empty FilePath", f.ID)
|
||||
}
|
||||
if f.MtimeNs == 0 {
|
||||
t.Errorf("flow %q has zero MtimeNs", f.ID)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("flows ordenados por ID asc", func(t *testing.T) {
|
||||
flows, err := ScanFlowsDir(flowsDir)
|
||||
if err != nil {
|
||||
t.Fatalf("ScanFlowsDir: %v", err)
|
||||
}
|
||||
for i := 1; i < len(flows); i++ {
|
||||
if flows[i].ID < flows[i-1].ID {
|
||||
t.Errorf("not sorted at index %d: %q < %q", i, flows[i].ID, flows[i-1].ID)
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ScanIssuesDir escanea el directorio root (dev/issues/) y devuelve todos los Issues
|
||||
// encontrados en *.md directos y en completed/*.md.
|
||||
// Si un archivo falla al parsearse, se emite un warning al log y se continua.
|
||||
// Los issues se devuelven ordenados por ID ascendente.
|
||||
func ScanIssuesDir(root string) ([]Issue, error) {
|
||||
// Verificar que el directorio raiz existe.
|
||||
if _, err := os.Stat(root); err != nil {
|
||||
return nil, fmt.Errorf("scan_issues_dir: root dir %s: %w", root, err)
|
||||
}
|
||||
|
||||
var issues []Issue
|
||||
|
||||
// Patterns a escanear: archivos directos y completed/
|
||||
patterns := []string{
|
||||
filepath.Join(root, "*.md"),
|
||||
filepath.Join(root, "completed", "*.md"),
|
||||
}
|
||||
|
||||
for _, pattern := range patterns {
|
||||
matches, err := filepath.Glob(pattern)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan_issues_dir: glob %s: %w", pattern, err)
|
||||
}
|
||||
|
||||
for _, path := range matches {
|
||||
// Saltar INDEX.md y README.md
|
||||
base := filepath.Base(path)
|
||||
if strings.EqualFold(base, "INDEX.md") || strings.EqualFold(base, "README.md") {
|
||||
continue
|
||||
}
|
||||
// Verificar que es un archivo regular
|
||||
info, err := os.Stat(path)
|
||||
if err != nil || !info.Mode().IsRegular() {
|
||||
continue
|
||||
}
|
||||
|
||||
iss, _, err := ParseIssueMd(path)
|
||||
if err != nil {
|
||||
log.Printf("scan_issues_dir: warning: skip %s: %v", path, err)
|
||||
continue
|
||||
}
|
||||
issues = append(issues, iss)
|
||||
}
|
||||
}
|
||||
|
||||
sort.Slice(issues, func(i, j int) bool {
|
||||
return issues[i].ID < issues[j].ID
|
||||
})
|
||||
|
||||
return issues, nil
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
---
|
||||
name: scan_issues_dir
|
||||
kind: function
|
||||
lang: go
|
||||
domain: infra
|
||||
version: "0.1.0"
|
||||
purity: impure
|
||||
signature: "func ScanIssuesDir(root string) ([]Issue, error)"
|
||||
description: "Escanea el directorio dev/issues/ (root) y devuelve todos los Issues encontrados en *.md directos y en completed/*.md. Si un archivo falla al parsearse emite un warning al log y continua. Resultado ordenado por ID ascendente."
|
||||
tags: [issue, scanner, frontmatter, yaml, dev-ux, kanban]
|
||||
uses_functions: [parse_issue_md_go_infra]
|
||||
uses_types: [issue_go_infra]
|
||||
returns: [issue_go_infra]
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: ["fmt", "log", "os", "path/filepath", "sort", "strings"]
|
||||
params:
|
||||
- name: root
|
||||
desc: "Ruta al directorio dev/issues/ (absoluta o relativa). Debe existir o retorna error."
|
||||
output: "Slice de Issue ordenado por ID asc. Incluye issues de completed/ con Completed=true. Issues con YAML malformado se omiten con warning."
|
||||
tested: true
|
||||
tests:
|
||||
- "scan devuelve al menos 90 issues"
|
||||
- "issue 0130 esta presente"
|
||||
- "issues ordenados por ID asc"
|
||||
- "completed issues tienen Completed=true"
|
||||
- "directorio inexistente retorna error"
|
||||
test_file_path: "functions/infra/scan_issues_dir_test.go"
|
||||
file_path: "functions/infra/scan_issues_dir.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
issues, err := infra.ScanIssuesDir("/home/lucas/fn_registry/dev/issues")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
fmt.Printf("Total issues: %d\n", len(issues))
|
||||
for _, iss := range issues {
|
||||
fmt.Printf(" %s [%s] %s\n", iss.ID, iss.Status, iss.Title)
|
||||
}
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Al arrancar el backend de kanban_cpp para poblar la cache SQLite inicial. Tambien util para cualquier herramienta que necesite un snapshot completo de todos los issues del proyecto (stats, dashboards, fn doctor).
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Skippea automaticamente `INDEX.md` y `README.md` — no son issues.
|
||||
- Si `completed/` no existe (no hay issues completados), no retorna error — devuelve los issues directos.
|
||||
- La ordenacion es lexicografica por ID string, no numerica. `"0099" < "0100"` funciona bien con el formato de 4 digitos del registry.
|
||||
- Un issue con YAML invalido no aborta el scan entero — solo ese archivo se omite con un `log.Printf` warning. Si necesitas comportamiento strict (abort en primer error), parsea manualmente con `ParseIssueMd`.
|
||||
@@ -0,0 +1,74 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestScanIssuesDir(t *testing.T) {
|
||||
root := registryRoot()
|
||||
issuesDir := filepath.Join(root, "dev", "issues")
|
||||
|
||||
t.Run("scan devuelve al menos 90 issues", func(t *testing.T) {
|
||||
issues, err := ScanIssuesDir(issuesDir)
|
||||
if err != nil {
|
||||
t.Fatalf("ScanIssuesDir: %v", err)
|
||||
}
|
||||
if len(issues) < 90 {
|
||||
t.Errorf("expected >= 90 issues, got %d", len(issues))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("issue 0130 esta presente", func(t *testing.T) {
|
||||
issues, err := ScanIssuesDir(issuesDir)
|
||||
if err != nil {
|
||||
t.Fatalf("ScanIssuesDir: %v", err)
|
||||
}
|
||||
found := false
|
||||
for _, iss := range issues {
|
||||
if iss.ID == "0130" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("issue 0130 not found in scan results")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("issues ordenados por ID asc", func(t *testing.T) {
|
||||
issues, err := ScanIssuesDir(issuesDir)
|
||||
if err != nil {
|
||||
t.Fatalf("ScanIssuesDir: %v", err)
|
||||
}
|
||||
for i := 1; i < len(issues); i++ {
|
||||
if issues[i].ID < issues[i-1].ID {
|
||||
t.Errorf("not sorted at index %d: %q < %q", i, issues[i].ID, issues[i-1].ID)
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("completed issues tienen Completed=true", func(t *testing.T) {
|
||||
issues, err := ScanIssuesDir(issuesDir)
|
||||
if err != nil {
|
||||
t.Fatalf("ScanIssuesDir: %v", err)
|
||||
}
|
||||
completedCount := 0
|
||||
for _, iss := range issues {
|
||||
if iss.Completed {
|
||||
completedCount++
|
||||
}
|
||||
}
|
||||
if completedCount == 0 {
|
||||
t.Error("expected at least some completed issues")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("directorio inexistente retorna error", func(t *testing.T) {
|
||||
_, err := ScanIssuesDir("/nonexistent/dev/issues")
|
||||
if err == nil {
|
||||
t.Error("expected error for nonexistent directory")
|
||||
}
|
||||
})
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
---
|
||||
id: "9999"
|
||||
title: "Fixture issue con caracteres especiales: áéíóú & <test>"
|
||||
status: pendiente
|
||||
type: app
|
||||
domain:
|
||||
- core
|
||||
- infra
|
||||
scope: registry-only
|
||||
priority: alta
|
||||
depends:
|
||||
- "0001"
|
||||
blocks: []
|
||||
related:
|
||||
- "0100"
|
||||
tags: [test, fixture, round-trip]
|
||||
flow: "0001"
|
||||
created: 2026-01-01
|
||||
updated: 2026-05-22
|
||||
---
|
||||
|
||||
# Fixture issue
|
||||
|
||||
Este es el body del issue. Contiene caracteres especiales: áéíóú & <test>.
|
||||
|
||||
## Sección
|
||||
|
||||
Linea con **negrita** y _cursiva_.
|
||||
|
||||
Final del body.
|
||||
@@ -0,0 +1,135 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/fsnotify/fsnotify"
|
||||
)
|
||||
|
||||
// WatchDirFsnotify crea un watcher recursivo sobre root y todos sus subdirectorios.
|
||||
// Emite FsEvent al canal devuelto con debounce de 200ms por path (si llegan multiples
|
||||
// eventos del mismo archivo en la ventana, se emite solo el ultimo).
|
||||
// Cierra el canal cuando ctx.Done() se dispara.
|
||||
func WatchDirFsnotify(ctx context.Context, root string) (<-chan FsEvent, error) {
|
||||
watcher, err := fsnotify.NewWatcher()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("watch_dir_fsnotify: new watcher: %w", err)
|
||||
}
|
||||
|
||||
// Anadir root y todos los subdirectorios recursivamente.
|
||||
if err := addDirsRecursive(watcher, root); err != nil {
|
||||
watcher.Close()
|
||||
return nil, fmt.Errorf("watch_dir_fsnotify: add dirs: %w", err)
|
||||
}
|
||||
|
||||
ch := make(chan FsEvent, 64)
|
||||
|
||||
go func() {
|
||||
defer watcher.Close()
|
||||
defer close(ch)
|
||||
|
||||
// Mapa de debounce: path -> (timer, ultimo op)
|
||||
type pending struct {
|
||||
timer *time.Timer
|
||||
op string
|
||||
}
|
||||
debounce := make(map[string]*pending)
|
||||
const debounceDelay = 200 * time.Millisecond
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
// Cancelar todos los timers pendientes antes de salir.
|
||||
for _, p := range debounce {
|
||||
p.timer.Stop()
|
||||
}
|
||||
return
|
||||
|
||||
case event, ok := <-watcher.Events:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
op := fsnotifyOpToString(event.Op)
|
||||
if op == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
path := event.Name
|
||||
|
||||
// Si el directorio nuevo fue creado, anadirlo al watcher.
|
||||
if event.Op&fsnotify.Create != 0 {
|
||||
if info, err := os.Stat(path); err == nil && info.IsDir() {
|
||||
if err := watcher.Add(path); err != nil {
|
||||
log.Printf("watch_dir_fsnotify: add new dir %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Debounce: resetear el timer si ya habia uno para este path.
|
||||
if p, exists := debounce[path]; exists {
|
||||
p.timer.Stop()
|
||||
p.op = op
|
||||
p.timer.Reset(debounceDelay)
|
||||
} else {
|
||||
p = &pending{op: op}
|
||||
p.timer = time.AfterFunc(debounceDelay, func() {
|
||||
select {
|
||||
case ch <- FsEvent{Path: path, Op: p.op}:
|
||||
case <-ctx.Done():
|
||||
}
|
||||
delete(debounce, path)
|
||||
})
|
||||
debounce[path] = p
|
||||
}
|
||||
|
||||
case err, ok := <-watcher.Errors:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
log.Printf("watch_dir_fsnotify: watcher error: %v", err)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
// addDirsRecursive anade root y todos sus subdirectorios al watcher.
|
||||
// Retorna error si root no existe o no es accesible.
|
||||
func addDirsRecursive(watcher *fsnotify.Watcher, root string) error {
|
||||
if _, err := os.Stat(root); err != nil {
|
||||
return fmt.Errorf("root dir %s: %w", root, err)
|
||||
}
|
||||
return filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return nil // ignora errores de acceso en subdirs
|
||||
}
|
||||
if info.IsDir() {
|
||||
return watcher.Add(path)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// fsnotifyOpToString convierte fsnotify.Op al string canonico del registry.
|
||||
// Retorna "" para operaciones no mapeadas (CHMOD, etc.).
|
||||
func fsnotifyOpToString(op fsnotify.Op) string {
|
||||
switch {
|
||||
case op&fsnotify.Create != 0:
|
||||
return "create"
|
||||
case op&fsnotify.Write != 0:
|
||||
return "write"
|
||||
case op&fsnotify.Remove != 0:
|
||||
return "remove"
|
||||
case op&fsnotify.Rename != 0:
|
||||
return "rename"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
---
|
||||
name: watch_dir_fsnotify
|
||||
kind: function
|
||||
lang: go
|
||||
domain: infra
|
||||
version: "0.1.0"
|
||||
purity: impure
|
||||
signature: "func WatchDirFsnotify(ctx context.Context, root string) (<-chan FsEvent, error)"
|
||||
description: "Crea un watcher recursivo sobre root y todos sus subdirectorios usando fsnotify. Emite FsEvent al canal con debounce de 200ms por path (multiples eventos del mismo archivo en la ventana = un solo evento con la ultima op). Cierra el canal cuando ctx.Done(). Anade automaticamente nuevos subdirectorios creados en runtime."
|
||||
tags: [watcher, fsnotify, filesystem, dev-ux, async, kanban]
|
||||
uses_functions: []
|
||||
uses_types: [fs_event_go_infra]
|
||||
returns: [fs_event_go_infra]
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: ["context", "fmt", "log", "os", "path/filepath", "time", "github.com/fsnotify/fsnotify"]
|
||||
params:
|
||||
- name: ctx
|
||||
desc: "Context para cancelar el watcher. Al cancelar, el canal se cierra limpiamente."
|
||||
- name: root
|
||||
desc: "Directorio raiz a vigilar recursivamente. Debe existir o retorna error."
|
||||
output: "Canal de solo lectura que emite FsEvent por cada cambio detectado (tras debounce). El canal se cierra cuando ctx se cancela o el watcher interno falla."
|
||||
tested: true
|
||||
tests:
|
||||
- "detecta escritura de archivo"
|
||||
- "canal se cierra cuando ctx cancela"
|
||||
- "error en directorio inexistente"
|
||||
- "debounce agrupa multiples escrituras"
|
||||
test_file_path: "functions/infra/watch_dir_fsnotify_test.go"
|
||||
file_path: "functions/infra/watch_dir_fsnotify.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
ch, err := infra.WatchDirFsnotify(ctx, "/home/lucas/fn_registry/dev/issues")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
for ev := range ch {
|
||||
fmt.Printf("event: op=%s path=%s\n", ev.Op, ev.Path)
|
||||
// recargar el issue afectado en cache
|
||||
}
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
En el backend de kanban_cpp para detectar cambios externos en `dev/issues/` y `dev/flows/` (ediciones en el editor de texto del usuario) y propagar via SSE al frontend ImGui. Tambien util para cualquier daemon que necesite invalidar cache ante cambios en disco.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Debounce por path**: si guardas el mismo archivo 5 veces en 200ms (ej. autoguardado del editor), recibes 1 evento, no 5. El `Op` del evento es el de la ultima operacion en la ventana.
|
||||
- **Subdirectorios dinamicos**: si se crea un subdirectorio nuevo mientras el watcher esta activo, se anade automaticamente al watcher. Los archivos creados dentro del nuevo subdir se detectan.
|
||||
- **Eventos CHMOD ignorados**: solo se emiten `create`, `write`, `remove`, `rename`. Cambios de permisos no disparan eventos.
|
||||
- **Canal con buffer 64**: si el consumidor es lento y el buffer se llena, eventos adicionales se bloquean en la goroutine interna. Con debounce 200ms es poco probable en uso normal.
|
||||
- **No filtra por extension**: emite eventos para cualquier archivo en el arbol, no solo `.md`. El consumidor debe filtrar si solo le interesan ciertos tipos.
|
||||
- **Linux inotify limit**: en sistemas con muchos subdirectorios, puede alcanzar el limite de `fs.inotify.max_user_watches` (default 8192). Aumentar con `sysctl fs.inotify.max_user_watches=65536` si se observan errores en el log.
|
||||
@@ -0,0 +1,129 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestWatchDirFsnotify(t *testing.T) {
|
||||
t.Run("detecta escritura de archivo", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
ch, err := WatchDirFsnotify(ctx, tmpDir)
|
||||
if err != nil {
|
||||
t.Fatalf("WatchDirFsnotify: %v", err)
|
||||
}
|
||||
|
||||
// Dar tiempo al watcher para arrancar
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
// Escribir un archivo
|
||||
testFile := filepath.Join(tmpDir, "test.md")
|
||||
if err := os.WriteFile(testFile, []byte("hello"), 0644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
|
||||
// Esperar evento (debounce 200ms + margen)
|
||||
select {
|
||||
case ev, ok := <-ch:
|
||||
if !ok {
|
||||
t.Fatal("channel closed unexpectedly")
|
||||
}
|
||||
if ev.Path != testFile {
|
||||
t.Errorf("Path: got %q, want %q", ev.Path, testFile)
|
||||
}
|
||||
if ev.Op != "create" && ev.Op != "write" {
|
||||
t.Errorf("Op: got %q, want 'create' or 'write'", ev.Op)
|
||||
}
|
||||
case <-ctx.Done():
|
||||
t.Fatal("timeout waiting for fs event")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("canal se cierra cuando ctx cancela", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
ch, err := WatchDirFsnotify(ctx, tmpDir)
|
||||
if err != nil {
|
||||
t.Fatalf("WatchDirFsnotify: %v", err)
|
||||
}
|
||||
|
||||
// Cancelar inmediatamente
|
||||
cancel()
|
||||
|
||||
// El canal debe cerrarse
|
||||
timeout := time.After(2 * time.Second)
|
||||
// Drenar cualquier evento pendiente hasta que el canal se cierre
|
||||
for {
|
||||
select {
|
||||
case _, ok := <-ch:
|
||||
if !ok {
|
||||
return // canal cerrado correctamente
|
||||
}
|
||||
case <-timeout:
|
||||
t.Fatal("channel not closed after ctx cancel within 2s")
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("error en directorio inexistente", func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
_, err := WatchDirFsnotify(ctx, "/nonexistent/dir/that/does/not/exist")
|
||||
if err == nil {
|
||||
t.Error("expected error for nonexistent directory")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("debounce agrupa multiples escrituras", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
ch, err := WatchDirFsnotify(ctx, tmpDir)
|
||||
if err != nil {
|
||||
t.Fatalf("WatchDirFsnotify: %v", err)
|
||||
}
|
||||
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
testFile := filepath.Join(tmpDir, "debounce.md")
|
||||
// Escribir 5 veces rapidamente
|
||||
for i := 0; i < 5; i++ {
|
||||
_ = os.WriteFile(testFile, []byte("content"), 0644)
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
|
||||
// Esperar debounce + margen
|
||||
time.Sleep(400 * time.Millisecond)
|
||||
|
||||
// Debe haber llegado al menos un evento pero no 5
|
||||
eventCount := 0
|
||||
drain:
|
||||
for {
|
||||
select {
|
||||
case _, ok := <-ch:
|
||||
if !ok {
|
||||
break drain
|
||||
}
|
||||
eventCount++
|
||||
default:
|
||||
break drain
|
||||
}
|
||||
}
|
||||
if eventCount == 0 {
|
||||
t.Error("expected at least one debounced event")
|
||||
}
|
||||
if eventCount >= 5 {
|
||||
t.Errorf("debounce failed: got %d events, expected fewer than 5", eventCount)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// WriteIssueMd serializa el frontmatter del Issue a YAML y lo escribe en path junto al body.
|
||||
// El archivo resultante tiene formato: "---\n<yaml>---\n<body>".
|
||||
// El body se preserva exactamente tal como fue recibido (sin normalizar trailing newlines).
|
||||
// Los campos de runtime (FilePath, MtimeNs, Completed) se omiten del YAML via yaml:"-".
|
||||
func WriteIssueMd(path string, iss Issue, body []byte) error {
|
||||
var buf bytes.Buffer
|
||||
|
||||
yamlBytes, err := yaml.Marshal(iss)
|
||||
if err != nil {
|
||||
return fmt.Errorf("write_issue_md: marshal %s: %w", path, err)
|
||||
}
|
||||
|
||||
buf.WriteString("---\n")
|
||||
buf.Write(yamlBytes)
|
||||
buf.WriteString("---\n")
|
||||
buf.Write(body)
|
||||
|
||||
if err := os.WriteFile(path, buf.Bytes(), 0644); err != nil {
|
||||
return fmt.Errorf("write_issue_md: write %s: %w", path, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
---
|
||||
name: write_issue_md
|
||||
kind: function
|
||||
lang: go
|
||||
domain: infra
|
||||
version: "0.1.0"
|
||||
purity: impure
|
||||
signature: "func WriteIssueMd(path string, iss Issue, body []byte) error"
|
||||
description: "Serializa el frontmatter de un struct Issue a YAML y escribe el archivo Markdown en disco con formato ---\\nyaml---\\nbody. Preserva el body exactamente sin normalizar trailing newlines ni reordenar. Los campos de runtime (FilePath, MtimeNs, Completed) se omiten del YAML via yaml:\"-\"."
|
||||
tags: [issue, writer, frontmatter, yaml, dev-ux, kanban]
|
||||
uses_functions: []
|
||||
uses_types: [issue_go_infra]
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: ["bytes", "fmt", "os", "gopkg.in/yaml.v3"]
|
||||
params:
|
||||
- name: path
|
||||
desc: "Ruta de destino del archivo .md (puede ser la misma de la que se leyo para un update in-place)"
|
||||
- name: iss
|
||||
desc: "Struct Issue con el frontmatter a serializar. FilePath/MtimeNs/Completed se ignoran en el YAML de salida"
|
||||
- name: body
|
||||
desc: "Body MD tal como fue devuelto por ParseIssueMd — se escribe byte a byte sin modificar"
|
||||
output: "nil en exito, error si el marshal YAML falla o el archivo no se puede escribir"
|
||||
tested: true
|
||||
tests:
|
||||
- "round-trip parse-write-parse preserva struct"
|
||||
- "archivo resultante empieza con ---"
|
||||
- "error en path inexistente"
|
||||
test_file_path: "functions/infra/write_issue_md_test.go"
|
||||
file_path: "functions/infra/write_issue_md.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
// Actualizar status de un issue in-place
|
||||
iss, body, err := infra.ParseIssueMd("dev/issues/0130-kanban-cpp-v2.md")
|
||||
if err != nil { log.Fatal(err) }
|
||||
|
||||
iss.Status = "in-progress"
|
||||
iss.Updated = "2026-05-22"
|
||||
|
||||
if err := infra.WriteIssueMd("dev/issues/0130-kanban-cpp-v2.md", iss, body); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando el backend de kanban_cpp necesite actualizar el frontmatter de un issue (cambio de status, priority, tags, etc.) sin tocar el body. Siempre usar en par con `parse_issue_md_go_infra`: parse → modificar struct → write.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- `yaml.Marshal` de v3 puede reordenar campos respecto al original — el orden del YAML de salida sera el orden de declaracion del struct `Issue`, no el del archivo original. Si el orden importa para diff legibilidad, documentarlo.
|
||||
- El body se escribe byte a byte. Si lo modificas antes de pasar, lo que escribes es lo que queda.
|
||||
- No hace backup previo. En sistemas con watcher activo, el write dispara un evento `write` en `watch_dir_fsnotify_go_infra` — el backend debe ignorar sus propios writes para no entrar en loop.
|
||||
@@ -0,0 +1,92 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestWriteIssueMd(t *testing.T) {
|
||||
root := registryRoot()
|
||||
|
||||
t.Run("round-trip parse-write-parse preserva struct", func(t *testing.T) {
|
||||
fixturePath := filepath.Join(root, "functions", "infra", "testdata", "issue_fixture.fixture")
|
||||
|
||||
// Parse original
|
||||
iss1, body1, err := ParseIssueMd(fixturePath)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseIssueMd: %v", err)
|
||||
}
|
||||
|
||||
// Write a TempDir
|
||||
tmpPath := filepath.Join(t.TempDir(), "issue_roundtrip.md")
|
||||
if err := WriteIssueMd(tmpPath, iss1, body1); err != nil {
|
||||
t.Fatalf("WriteIssueMd: %v", err)
|
||||
}
|
||||
|
||||
// Parse de nuevo
|
||||
iss2, body2, err := ParseIssueMd(tmpPath)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseIssueMd after write: %v", err)
|
||||
}
|
||||
|
||||
// Comparar campos (ignorar FilePath y MtimeNs que son runtime)
|
||||
if iss1.ID != iss2.ID {
|
||||
t.Errorf("ID: %q != %q", iss1.ID, iss2.ID)
|
||||
}
|
||||
if iss1.Title != iss2.Title {
|
||||
t.Errorf("Title: %q != %q", iss1.Title, iss2.Title)
|
||||
}
|
||||
if iss1.Status != iss2.Status {
|
||||
t.Errorf("Status: %q != %q", iss1.Status, iss2.Status)
|
||||
}
|
||||
if iss1.Flow != iss2.Flow {
|
||||
t.Errorf("Flow: %q != %q", iss1.Flow, iss2.Flow)
|
||||
}
|
||||
if len(iss1.Domain) != len(iss2.Domain) {
|
||||
t.Errorf("Domain len: %d != %d", len(iss1.Domain), len(iss2.Domain))
|
||||
}
|
||||
if len(iss1.Depends) != len(iss2.Depends) {
|
||||
t.Errorf("Depends len: %d != %d", len(iss1.Depends), len(iss2.Depends))
|
||||
}
|
||||
if len(iss1.Tags) != len(iss2.Tags) {
|
||||
t.Errorf("Tags len: %d != %d", len(iss1.Tags), len(iss2.Tags))
|
||||
}
|
||||
|
||||
// El body debe preservarse exactamente
|
||||
if string(body1) != string(body2) {
|
||||
t.Errorf("body mismatch:\ngot: %q\nwant: %q", string(body2), string(body1))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("archivo resultante empieza con ---", func(t *testing.T) {
|
||||
iss := Issue{
|
||||
ID: "0001",
|
||||
Title: "Test issue",
|
||||
Status: "pendiente",
|
||||
}
|
||||
tmpPath := filepath.Join(t.TempDir(), "test.md")
|
||||
if err := WriteIssueMd(tmpPath, iss, []byte("# Body\n")); err != nil {
|
||||
t.Fatalf("WriteIssueMd: %v", err)
|
||||
}
|
||||
data, _ := os.ReadFile(tmpPath)
|
||||
if len(data) < 4 || string(data[:4]) != "---\n" {
|
||||
t.Errorf("file should start with '---\\n', got: %q", string(data[:min(10, len(data))]))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("error en path inexistente", func(t *testing.T) {
|
||||
iss := Issue{ID: "0001", Title: "x", Status: "pendiente"}
|
||||
err := WriteIssueMd("/nonexistent/dir/issue.md", iss, []byte("body"))
|
||||
if err == nil {
|
||||
t.Error("expected error writing to nonexistent dir")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
Reference in New Issue
Block a user