From 46b0826a9f80ca1971bd5fcc7bb6155bfa9261b2 Mon Sep 17 00:00:00 2001 From: fn-registry agent Date: Sun, 17 May 2026 02:44:02 +0200 Subject: [PATCH] chore: sync from fn-registry agent --- .gitignore | 1 + README.md | 75 +++++ app.md | 25 ++ flow.go | 193 +++++++++++++ format.go | 93 +++++++ go.mod | 7 + go.sum | 7 + issue.go | 349 +++++++++++++++++++++++ main.go | 210 ++++++++++++++ parser.go | 418 ++++++++++++++++++++++++++++ parser_test.go | 259 +++++++++++++++++ testdata/flows/0001-sample.md | 40 +++ testdata/issues/0001-completado.md | 27 ++ testdata/issues/0050-blocked.md | 23 ++ testdata/issues/0051-missing-dep.md | 18 ++ testdata/issues/0099-sample.md | 39 +++ work.go | 306 ++++++++++++++++++++ 17 files changed, 2090 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 app.md create mode 100644 flow.go create mode 100644 format.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 issue.go create mode 100644 main.go create mode 100644 parser.go create mode 100644 parser_test.go create mode 100644 testdata/flows/0001-sample.md create mode 100644 testdata/issues/0001-completado.md create mode 100644 testdata/issues/0050-blocked.md create mode 100644 testdata/issues/0051-missing-dep.md create mode 100644 testdata/issues/0099-sample.md create mode 100644 work.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..094ccf4 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +dev_console diff --git a/README.md b/README.md new file mode 100644 index 0000000..5a7c5b9 --- /dev/null +++ b/README.md @@ -0,0 +1,75 @@ +# dev_console + +CLI unificado para listar, inspeccionar y gestionar issues y flows del registry. + +## Build + +```bash +cd apps/dev_console +CGO_ENABLED=0 go build -o dev_console . +``` + +## Uso + +```bash +# Listar issues con filtros +./dev_console issue list +./dev_console issue list --status pendiente +./dev_console issue list --domain trading --prio alta +./dev_console issue list --type epic + +# Ver un issue concreto +./dev_console issue show 0099 +./dev_console issue status 0099 + +# Board Kanban en terminal +./dev_console issue board + +# Flows +./dev_console flow list +./dev_console flow list --risk high +./dev_console flow show 0001 +./dev_console flow status 0001 + +# Work: que hacer hoy +./dev_console work today +./dev_console work dashboard # JSON para dashboards + +# Salida JSON (todos los subcomandos) +./dev_console issue list --json | jq '.[] | .id' +``` + +## Entorno + +`FN_REGISTRY_ROOT` — directorio raiz del registry. Si no se setea, se +auto-detecta subiendo desde el cwd hasta encontrar `registry.db`. + +## Auto-deteccion de raiz + +El binario puede lanzarse desde cualquier directorio dentro del registry: + +```bash +# Desde la raiz +./apps/dev_console/dev_console issue list + +# Con env var explicita +FN_REGISTRY_ROOT=/home/lucas/fn_registry ./dev_console issue list +``` + +## Subcomandos v2 (stub) + +Los siguientes subcomandos imprimen "TODO v2" y salen con exit code 2: + +- `issue dep|roadmap|tag|done|stale|create` +- `flow create|dod|trace|user-test|run|chain|done` +- `work weekly|search` + +## Source + +- `main.go` — entrypoint + dispatch + flag parsing +- `parser.go` — ParseIssue / ParseFlow / LoadAllIssues / LoadAllFlows +- `issue.go` — subcomandos de issue +- `flow.go` — subcomandos de flow +- `work.go` — subcomandos de work +- `format.go` — tabwriter helpers + JSON renderer +- `parser_test.go` — unit tests con fixtures en testdata/ diff --git a/app.md b/app.md new file mode 100644 index 0000000..3f94106 --- /dev/null +++ b/app.md @@ -0,0 +1,25 @@ +--- +id: dev_console +name: dev_console +lang: go +domain: tools +description: "CLI unificado para listar/inspeccionar/gestionar issues + flows del registry. Reemplaza grep ad-hoc sobre dev/issues + dev/flows. Issue 0101." +tags: [cli, registry, issues, flows, work] +uses_functions: [] +uses_types: [] +framework: "" +entry_point: "main.go" +dir_path: "apps/dev_console" +repo_url: "" +e2e_checks: + - id: build + cmd: "cd apps/dev_console && CGO_ENABLED=0 go build -o dev_console ." + - id: tests + cmd: "cd apps/dev_console && go test -count=1 ./..." + - id: list_smoke + cmd: "apps/dev_console/dev_console issue list --status pendiente | head -5" + - id: flow_smoke + cmd: "apps/dev_console/dev_console flow list | head -5" + - id: json_smoke + cmd: "apps/dev_console/dev_console issue list --json | python3 -c \"import json,sys; d=json.load(sys.stdin); assert len(d) > 100, f'expected >100 issues, got {len(d)}'\"" +--- diff --git a/flow.go b/flow.go new file mode 100644 index 0000000..8ca1927 --- /dev/null +++ b/flow.go @@ -0,0 +1,193 @@ +package main + +import ( + "fmt" + "os" + "sort" + "strings" +) + +// cmdFlow dispatches flow subcommands. +func cmdFlow(args []string, flags Flags) { + if len(args) == 0 { + fmt.Fprintln(os.Stderr, "usage: dev_console flow [args]") + os.Exit(1) + } + sub := args[0] + rest := args[1:] + + switch sub { + case "list": + flowList(rest, flags) + case "show": + flowShow(rest, flags) + case "status": + flowStatus(rest, flags) + // v2 stubs + case "create", "dod", "trace", "user-test", "run", "chain", "done": + fmt.Fprintf(os.Stderr, "TODO v2: flow %s not yet implemented\n", sub) + os.Exit(2) + default: + fmt.Fprintf(os.Stderr, "unknown flow subcommand: %s\n", sub) + os.Exit(1) + } +} + +// flowList lists flows with optional filters. +func flowList(args []string, flags Flags) { + root := mustRegistryRoot() + flows, err := LoadAllFlows(root) + if err != nil { + fatalf("load flows: %v", err) + } + + // Apply filters + var filtered []Flow + for _, fl := range flows { + if flags.App != "" && !matchApp(fl.Apps, flags.App) { + continue + } + if flags.Pattern != "" && !matchStr(fl.Pattern, flags.Pattern) { + continue + } + if flags.Risk != "" && !matchStr(fl.Risk, flags.Risk) { + continue + } + filtered = append(filtered, fl) + } + + sort.Slice(filtered, func(i, j int) bool { + return filtered[i].ID < filtered[j].ID + }) + + if flags.JSON { + printJSON(filtered) + return + } + + headers := []string{"ID", "NAME", "STATUS", "PRIO", "RISK", "APPS", "ACCEPTANCE", "DOD", "USER-FACING"} + var rows [][]string + for _, fl := range filtered { + rows = append(rows, []string{ + fl.ID, + truncate(fl.Name, 30), + statusShort(fl.Status), + fl.Priority, + fl.Risk, + truncate(joinStrings(fl.Apps), 30), + pctBar(fl.AcceptancePct), + pctBar(fl.DoDPct), + pctBar(fl.UserFacingPct), + }) + } + printTable(os.Stdout, headers, rows) + fmt.Printf("\nTotal: %d flows\n", len(filtered)) +} + +// flowShow prints the full content of a flow. +func flowShow(args []string, flags Flags) { + if len(args) == 0 { + fatalf("usage: dev_console flow show NNNN") + } + id := normalizeID(args[0]) + root := mustRegistryRoot() + fl, err := findFlowByID(root, id) + if err != nil { + fatalf("flow %s: %v", id, err) + } + + if flags.JSON { + printJSON(fl) + return + } + + fmt.Printf("# Flow %s — %s\n\n", fl.ID, fl.Name) + fmt.Printf("Status: %s\n", statusShort(fl.Status)) + fmt.Printf("Priority: %s\n", fl.Priority) + fmt.Printf("Risk: %s\n", fl.Risk) + fmt.Printf("Apps: %s\n", joinStrings(fl.Apps)) + fmt.Printf("Trigger: %s\n", fl.Trigger) + fmt.Printf("Schedule: %s\n", fl.Schedule) + fmt.Printf("ExpectedRuntime: %ds\n", fl.ExpectedRuntimeS) + fmt.Printf("Tags: %s\n", joinStrings(fl.Tags)) + fmt.Printf("Created: %s\n", fl.Created) + fmt.Printf("Updated: %s\n", fl.Updated) + fmt.Printf("Path: %s\n", fl.Path) + fmt.Printf("\nAcceptance: %s\n", pctBar(fl.AcceptancePct)) + fmt.Printf("DoD: %s\n", pctBar(fl.DoDPct)) + fmt.Printf("User-facing: %s\n", pctBar(fl.UserFacingPct)) + fmt.Printf("\n---\n%s\n", fl.Body) +} + +// flowStatus prints acceptance + DoD + user-facing % for a flow. +func flowStatus(args []string, flags Flags) { + if len(args) == 0 { + fatalf("usage: dev_console flow status NNNN") + } + id := normalizeID(args[0]) + root := mustRegistryRoot() + fl, err := findFlowByID(root, id) + if err != nil { + fatalf("flow %s: %v", id, err) + } + + type statusOut struct { + ID string `json:"id"` + Name string `json:"name"` + Status string `json:"status"` + AcceptancePct int `json:"acceptance_pct"` + DoDPct int `json:"dod_pct"` + UserFacingPct int `json:"user_facing_pct"` + DoDComplete bool `json:"dod_complete"` + } + out := statusOut{ + ID: fl.ID, + Name: fl.Name, + Status: fl.Status, + AcceptancePct: fl.AcceptancePct, + DoDPct: fl.DoDPct, + UserFacingPct: fl.UserFacingPct, + DoDComplete: fl.DoDPct == 100, + } + + if flags.JSON { + printJSON(out) + return + } + + fmt.Printf("Flow %s — %s\n", fl.ID, fl.Name) + fmt.Printf("Status: %s\n", statusShort(fl.Status)) + fmt.Printf("Acceptance: %s\n", pctBar(fl.AcceptancePct)) + fmt.Printf("DoD: %s\n", pctBar(fl.DoDPct)) + fmt.Printf("User-facing: %s\n", pctBar(fl.UserFacingPct)) + if fl.DoDPct == 100 { + fmt.Println("DoD: COMPLETE (100%)") + } else { + fmt.Printf("DoD: INCOMPLETE (%d%%)\n", fl.DoDPct) + } +} + +// findFlowByID searches for a flow by ID. +func findFlowByID(root, id string) (Flow, error) { + flows, err := LoadAllFlows(root) + if err != nil { + return Flow{}, err + } + for _, fl := range flows { + if fl.ID == id { + return fl, nil + } + } + return Flow{}, fmt.Errorf("not found") +} + +// matchApp returns true if any of the flow's apps matches the filter. +func matchApp(apps []string, filter string) bool { + filter = strings.ToLower(filter) + for _, a := range apps { + if strings.ToLower(a) == filter { + return true + } + } + return false +} diff --git a/format.go b/format.go new file mode 100644 index 0000000..921560c --- /dev/null +++ b/format.go @@ -0,0 +1,93 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "os" + "strings" + "text/tabwriter" +) + +// newTabWriter returns a tabwriter for stdout. +func newTabWriter() *tabwriter.Writer { + return tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) +} + +// printTable writes a header + rows to a tabwriter. +// headers: e.g. []string{"ID", "TITLE", "TYPE"} +// rows: [][]string +func printTable(w io.Writer, headers []string, rows [][]string) { + tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0) + fmt.Fprintln(tw, strings.Join(headers, "\t")) + for _, row := range rows { + fmt.Fprintln(tw, strings.Join(row, "\t")) + } + tw.Flush() +} + +// printJSON marshals v to JSON and writes to stdout. +func printJSON(v any) { + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + if err := enc.Encode(v); err != nil { + fmt.Fprintln(os.Stderr, "json encode error:", err) + os.Exit(1) + } +} + +// truncate returns s truncated to maxLen chars, with "..." if truncated. +func truncate(s string, maxLen int) string { + if len(s) <= maxLen { + return s + } + if maxLen <= 3 { + return s[:maxLen] + } + return s[:maxLen-3] + "..." +} + +// depsStatus returns "OK" or "blocked: X,Y" for display. +func depsStatus(issue Issue) string { + if len(issue.Depends) == 0 { + return "OK" + } + if issue.DepsResolved { + return "OK" + } + return "blocked: " + strings.Join(issue.Depends, ",") +} + +// joinStrings joins a slice with commas, or returns "-" if empty. +func joinStrings(ss []string) string { + if len(ss) == 0 { + return "-" + } + return strings.Join(ss, ",") +} + +// pctBar returns a compact string like "75%" or "0%". +func pctBar(p int) string { + return fmt.Sprintf("%d%%", p) +} + +// statusShort normalizes status for display. +func statusShort(s string) string { + switch strings.ToLower(s) { + case "in-progress": + return "in-progress" + case "completado", "done", "completed": + return "completado" + case "pendiente", "pending": + return "pendiente" + case "bloqueado", "blocked": + return "bloqueado" + case "deferred": + return "deferred" + default: + if s == "" { + return "-" + } + return s + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..be53d71 --- /dev/null +++ b/go.mod @@ -0,0 +1,7 @@ +module dev_console + +go 1.21 + +require gopkg.in/yaml.v3 v3.0.1 + +require gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..5a2cc77 --- /dev/null +++ b/go.sum @@ -0,0 +1,7 @@ +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/issue.go b/issue.go new file mode 100644 index 0000000..985b794 --- /dev/null +++ b/issue.go @@ -0,0 +1,349 @@ +package main + +import ( + "fmt" + "os" + "sort" + "strings" + "time" +) + +// cmdIssue dispatches issue subcommands. +func cmdIssue(args []string, flags Flags) { + if len(args) == 0 { + fmt.Fprintln(os.Stderr, "usage: dev_console issue [args]") + os.Exit(1) + } + sub := args[0] + rest := args[1:] + + switch sub { + case "list": + issueList(rest, flags) + case "show": + issueShow(rest, flags) + case "status": + issueStatus(rest, flags) + case "board": + issueBoard(flags) + // v2 stubs + case "dep", "roadmap", "tag", "done", "stale", "create": + fmt.Fprintf(os.Stderr, "TODO v2: issue %s not yet implemented\n", sub) + os.Exit(2) + default: + fmt.Fprintf(os.Stderr, "unknown issue subcommand: %s\n", sub) + os.Exit(1) + } +} + +// issueList lists issues with optional filters. +func issueList(args []string, flags Flags) { + root := mustRegistryRoot() + issues, err := LoadAllIssues(root) + if err != nil { + fatalf("load issues: %v", err) + } + ComputeDepsResolved(issues) + + // Apply filters + var filtered []Issue + for _, iss := range issues { + if flags.Status != "" && !matchStatus(iss.Status, flags.Status) { + continue + } + if flags.Domain != "" && !matchDomain(iss.Domain, flags.Domain) { + continue + } + if flags.IssueType != "" && !matchStr(iss.Type, flags.IssueType) { + continue + } + if flags.Prio != "" && !matchStr(iss.Priority, flags.Prio) { + continue + } + filtered = append(filtered, iss) + } + + // Sort by ID + sort.Slice(filtered, func(i, j int) bool { + return filtered[i].ID < filtered[j].ID + }) + + if flags.JSON { + printJSON(filtered) + return + } + + // Table output + headers := []string{"ID", "TITLE", "TYPE", "DOMAIN", "PRIO", "STATUS", "DEPS"} + var rows [][]string + for _, iss := range filtered { + rows = append(rows, []string{ + iss.ID, + truncate(iss.Title, 50), + iss.Type, + truncate(joinStrings(iss.Domain), 20), + iss.Priority, + statusShort(iss.Status), + depsStatus(iss), + }) + } + printTable(os.Stdout, headers, rows) + fmt.Printf("\nTotal: %d issues\n", len(filtered)) +} + +// issueShow prints the full content of an issue. +func issueShow(args []string, flags Flags) { + if len(args) == 0 { + fatalf("usage: dev_console issue show NNNN") + } + id := normalizeID(args[0]) + root := mustRegistryRoot() + iss, err := findIssueByID(root, id) + if err != nil { + fatalf("issue %s: %v", id, err) + } + + if flags.JSON { + printJSON(iss) + return + } + + fmt.Printf("# %s — %s\n\n", iss.ID, iss.Title) + fmt.Printf("Status: %s\n", statusShort(iss.Status)) + fmt.Printf("Type: %s\n", iss.Type) + fmt.Printf("Domain: %s\n", joinStrings(iss.Domain)) + fmt.Printf("Scope: %s\n", iss.Scope) + fmt.Printf("Priority: %s\n", iss.Priority) + fmt.Printf("Depends: %s\n", joinStrings(iss.Depends)) + fmt.Printf("Blocks: %s\n", joinStrings(iss.Blocks)) + fmt.Printf("Related: %s\n", joinStrings(iss.Related)) + fmt.Printf("Tags: %s\n", joinStrings(iss.Tags)) + fmt.Printf("Created: %s\n", iss.Created) + fmt.Printf("Updated: %s\n", iss.Updated) + fmt.Printf("Path: %s\n", iss.Path) + fmt.Printf("\nAcceptance: %s\n", pctBar(iss.AcceptancePct)) + fmt.Printf("DoD: %s\n", pctBar(iss.DoDPct)) + fmt.Printf("\n---\n%s\n", iss.Body) +} + +// issueStatus prints acceptance % and dep status. +func issueStatus(args []string, flags Flags) { + if len(args) == 0 { + fatalf("usage: dev_console issue status NNNN") + } + id := normalizeID(args[0]) + root := mustRegistryRoot() + issues, err := LoadAllIssues(root) + if err != nil { + fatalf("load issues: %v", err) + } + ComputeDepsResolved(issues) + + var iss *Issue + for i := range issues { + if issues[i].ID == id { + iss = &issues[i] + break + } + } + if iss == nil { + fatalf("issue %s not found", id) + } + + type statusOut struct { + ID string `json:"id"` + Title string `json:"title"` + Status string `json:"status"` + AcceptancePct int `json:"acceptance_pct"` + DoDPct int `json:"dod_pct"` + Depends []string `json:"depends"` + DepsResolved bool `json:"deps_resolved"` + } + out := statusOut{ + ID: iss.ID, + Title: iss.Title, + Status: iss.Status, + AcceptancePct: iss.AcceptancePct, + DoDPct: iss.DoDPct, + Depends: iss.Depends, + DepsResolved: iss.DepsResolved, + } + + if flags.JSON { + printJSON(out) + return + } + + fmt.Printf("Issue %s — %s\n", iss.ID, iss.Title) + fmt.Printf("Status: %s\n", statusShort(iss.Status)) + fmt.Printf("Acceptance: %s\n", pctBar(iss.AcceptancePct)) + fmt.Printf("DoD: %s\n", pctBar(iss.DoDPct)) + fmt.Printf("Depends: %s\n", joinStrings(iss.Depends)) + if iss.DepsResolved { + fmt.Println("Deps: resolved") + } else { + fmt.Printf("Deps: BLOCKED on: %s\n", joinStrings(iss.Depends)) + } +} + +// issueBoard prints a 4-column view: pendiente / in-progress / bloqueado / completado (recent). +func issueBoard(flags Flags) { + root := mustRegistryRoot() + issues, err := LoadAllIssues(root) + if err != nil { + fatalf("load issues: %v", err) + } + ComputeDepsResolved(issues) + + type col struct { + name string + issues []Issue + } + cols := []*col{ + {name: "PENDIENTE"}, + {name: "IN-PROGRESS"}, + {name: "BLOQUEADO"}, + {name: "COMPLETADO-24H"}, + } + + cutoff := time.Now().UTC().Add(-24 * time.Hour) + + for _, iss := range issues { + st := strings.ToLower(iss.Status) + switch { + case st == "in-progress": + cols[1].issues = append(cols[1].issues, iss) + case st == "bloqueado" || st == "blocked": + cols[2].issues = append(cols[2].issues, iss) + case st == "completado" || st == "done" || st == "completed": + // Only show completado if updated within 24h + t, err := time.Parse("2006-01-02", iss.Updated) + if err == nil && t.After(cutoff) { + cols[3].issues = append(cols[3].issues, iss) + } + default: + // pendiente, deferred, pending, etc. + cols[0].issues = append(cols[0].issues, iss) + } + } + + type boardOut struct { + Pendiente []Issue `json:"pendiente"` + InProgress []Issue `json:"in_progress"` + Bloqueado []Issue `json:"bloqueado"` + Completado []Issue `json:"completado_24h"` + } + + if flags.JSON { + out := boardOut{ + Pendiente: cols[0].issues, + InProgress: cols[1].issues, + Bloqueado: cols[2].issues, + Completado: cols[3].issues, + } + printJSON(out) + return + } + + // Find max rows + maxRows := 0 + for _, c := range cols { + if len(c.issues) > maxRows { + maxRows = len(c.issues) + } + } + + // Print header + headerRow := make([]string, len(cols)) + for i, c := range cols { + headerRow[i] = fmt.Sprintf("%s (%d)", c.name, len(c.issues)) + } + fmt.Println(strings.Join(headerRow, " | ")) + fmt.Println(strings.Repeat("-", 100)) + + // Print rows + maxDisplay := maxRows + if maxDisplay > 20 { + maxDisplay = 20 + } + for r := 0; r < maxDisplay; r++ { + rowParts := make([]string, len(cols)) + for c, col := range cols { + if r < len(col.issues) { + iss := col.issues[r] + rowParts[c] = fmt.Sprintf("%-6s %s", iss.ID, truncate(iss.Title, 28)) + } else { + rowParts[c] = "" + } + } + fmt.Println(strings.Join(rowParts, " | ")) + } +} + +// findIssueByID searches for an issue by ID in all issue directories. +func findIssueByID(root, id string) (Issue, error) { + issues, err := LoadAllIssues(root) + if err != nil { + return Issue{}, err + } + ComputeDepsResolved(issues) + + for _, iss := range issues { + if iss.ID == id { + return iss, nil + } + } + return Issue{}, fmt.Errorf("not found") +} + +// matchStatus returns true if the issue status matches the filter. +// Handles normalization: pending=pendiente, done=completado, etc. +func matchStatus(issueStatus, filter string) bool { + norm := func(s string) string { + s = strings.ToLower(s) + switch s { + case "pending": + return "pendiente" + case "done", "completed": + return "completado" + case "blocked": + return "bloqueado" + } + return s + } + return norm(issueStatus) == norm(filter) +} + +// matchDomain returns true if any of the issue domains contains the filter. +func matchDomain(domains []string, filter string) bool { + filter = strings.ToLower(filter) + for _, d := range domains { + if strings.ToLower(d) == filter { + return true + } + } + return false +} + +// matchStr returns true if s == filter (case-insensitive). +func matchStr(s, filter string) bool { + return strings.ToLower(s) == strings.ToLower(filter) +} + +// normalizeID trims leading zeros? No — keep original. But accept "99" -> "0099". +func normalizeID(s string) string { + // If it's purely numeric and < 4 chars, pad to 4 + s = strings.TrimSpace(s) + // Check if it's purely numeric + allNum := true + for _, c := range s { + if c < '0' || c > '9' { + allNum = false + break + } + } + if allNum && len(s) < 4 { + return fmt.Sprintf("%04s", s) + } + return s +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..f3772c8 --- /dev/null +++ b/main.go @@ -0,0 +1,210 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +// Flags holds all parsed CLI flags. +type Flags struct { + JSON bool + Domain string + IssueType string // --type + Status string + Prio string + App string + Pattern string + Risk string +} + +func main() { + args := os.Args[1:] + + if len(args) == 0 { + printUsage() + os.Exit(0) + } + + // Parse global flags and collect remaining args + var noun, verb string + var rest []string + flags := Flags{} + + // First arg is noun, second is verb, rest are flags/args + pos := 0 + for pos < len(args) { + arg := args[pos] + if strings.HasPrefix(arg, "--") { + flag, val, ok := parseFlag(args, pos) + pos += flag.consumed + if !ok { + fmt.Fprintf(os.Stderr, "unknown flag: %s\n", arg) + os.Exit(1) + } + applyFlag(&flags, flag.name, val) + } else { + // Positional + if noun == "" { + noun = arg + } else if verb == "" { + verb = arg + } else { + rest = append(rest, arg) + } + pos++ + } + } + + // Dispatch + switch noun { + case "issue": + if verb == "" { + fmt.Fprintln(os.Stderr, "usage: dev_console issue ") + os.Exit(1) + } + cmdIssue(append([]string{verb}, rest...), flags) + case "flow": + if verb == "" { + fmt.Fprintln(os.Stderr, "usage: dev_console flow ") + os.Exit(1) + } + cmdFlow(append([]string{verb}, rest...), flags) + case "work": + if verb == "" { + fmt.Fprintln(os.Stderr, "usage: dev_console work ") + os.Exit(1) + } + cmdWork(append([]string{verb}, rest...), flags) + case "help", "--help", "-h": + printUsage() + os.Exit(0) + default: + fmt.Fprintf(os.Stderr, "unknown command: %s\n", noun) + printUsage() + os.Exit(1) + } +} + +type parsedFlag struct { + name string + consumed int +} + +func parseFlag(args []string, pos int) (parsedFlag, string, bool) { + arg := args[pos] + // Strip leading -- + name := strings.TrimPrefix(arg, "--") + + // Check for --key=value form + if idx := strings.Index(name, "="); idx != -1 { + key := name[:idx] + val := name[idx+1:] + return parsedFlag{name: key, consumed: 1}, val, true + } + + // Boolean flags (no value) + switch name { + case "json": + return parsedFlag{name: name, consumed: 1}, "true", true + } + + // Flags that take a value + switch name { + case "domain", "type", "status", "prio", "app", "pattern", "risk", "epic", "days": + if pos+1 < len(args) && !strings.HasPrefix(args[pos+1], "--") { + return parsedFlag{name: name, consumed: 2}, args[pos+1], true + } + return parsedFlag{name: name, consumed: 1}, "", true + } + + return parsedFlag{consumed: 1}, "", false +} + +func applyFlag(flags *Flags, name, val string) { + switch name { + case "json": + flags.JSON = true + case "domain": + flags.Domain = val + case "type": + flags.IssueType = val + case "status": + flags.Status = val + case "prio": + flags.Prio = val + case "app": + flags.App = val + case "pattern": + flags.Pattern = val + case "risk": + flags.Risk = val + } +} + +func printUsage() { + fmt.Print(`dev_console — CLI unificado para issues + flows del registry + +Usage: + dev_console issue list [--domain X] [--type Y] [--status Z] [--prio P] [--json] + dev_console issue show NNNN [--json] + dev_console issue status NNNN [--json] + dev_console issue board [--json] + + dev_console flow list [--app X] [--pattern P] [--risk R] [--json] + dev_console flow show NNNN [--json] + dev_console flow status NNNN [--json] + + dev_console work today [--json] + dev_console work dashboard + +Stubs (v2): + issue dep|roadmap|tag|done|stale|create + flow create|dod|trace|user-test|run|chain|done + work weekly|search + +Env: + FN_REGISTRY_ROOT Root of the fn_registry repo (auto-detected if not set) +`) +} + +// mustRegistryRoot returns the registry root or exits. +func mustRegistryRoot() string { + root, err := findRegistryRoot() + if err != nil { + fatalf("cannot find registry root: %v\nSet FN_REGISTRY_ROOT env var or run from within fn_registry.", err) + } + return root +} + +// findRegistryRoot tries FN_REGISTRY_ROOT, then walks up from cwd looking for registry.db. +func findRegistryRoot() (string, error) { + if r := os.Getenv("FN_REGISTRY_ROOT"); r != "" { + return r, nil + } + + cwd, err := os.Getwd() + if err != nil { + return "", err + } + + dir := cwd + for { + if _, err := os.Stat(filepath.Join(dir, "registry.db")); err == nil { + return dir, nil + } + parent := filepath.Dir(dir) + if parent == dir { + break + } + dir = parent + } + return "", fmt.Errorf("registry.db not found walking up from %s", cwd) +} + +// fatalf prints an error to stderr and exits with code 1. +func fatalf(format string, args ...any) { + fmt.Fprintf(os.Stderr, "error: "+format+"\n", args...) + os.Exit(1) +} diff --git a/parser.go b/parser.go new file mode 100644 index 0000000..220d361 --- /dev/null +++ b/parser.go @@ -0,0 +1,418 @@ +package main + +import ( + "bufio" + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + + "gopkg.in/yaml.v3" +) + +// Issue represents a parsed dev/issues/*.md file. +type Issue struct { + ID string `yaml:"id" json:"id"` + Title string `yaml:"title" json:"title"` + Status string `yaml:"status" json:"status"` + Type string `yaml:"type" json:"type"` + Domain []string `yaml:"domain" json:"domain"` + Scope string `yaml:"scope" json:"scope"` + Priority string `yaml:"priority" json:"priority"` + Depends []string `yaml:"depends" json:"depends"` + Blocks []string `yaml:"blocks" json:"blocks"` + Related []string `yaml:"related" json:"related"` + Created string `yaml:"created" json:"created"` + Updated string `yaml:"updated" json:"updated"` + Tags []string `yaml:"tags" json:"tags"` + + // Computed fields (not in YAML) + Path string `json:"path"` + Body string `json:"body,omitempty"` + AcceptancePct int `json:"acceptance_pct"` + DoDPct int `json:"dod_pct"` + DepsResolved bool `json:"deps_resolved"` +} + +// Flow represents a parsed dev/flows/*.md file. +type Flow struct { + Name string `yaml:"name" json:"name"` + ID string `yaml:"id" json:"id"` + Status string `yaml:"status" json:"status"` + Created string `yaml:"created" json:"created"` + Updated string `yaml:"updated" json:"updated"` + Priority string `yaml:"priority" json:"priority"` + Risk string `yaml:"risk" json:"risk"` + RelatedIssues []string `yaml:"related_issues" json:"related_issues"` + Apps []string `yaml:"apps" json:"apps"` + Trigger string `yaml:"trigger" json:"trigger"` + Schedule string `yaml:"schedule" json:"schedule"` + ExpectedRuntimeS int `yaml:"expected_runtime_s" json:"expected_runtime_s"` + Tags []string `yaml:"tags" json:"tags"` + Pattern string `yaml:"pattern" json:"pattern"` + + // Computed fields + Path string `json:"path"` + Body string `json:"body,omitempty"` + AcceptancePct int `json:"acceptance_pct"` + DoDPct int `json:"dod_pct"` + UserFacingPct int `json:"user_facing_pct"` +} + +// splitFrontmatter splits a markdown file into YAML frontmatter and body. +// Returns (yamlStr, body, err). +func splitFrontmatter(path string) (string, string, error) { + f, err := os.Open(path) + if err != nil { + return "", "", fmt.Errorf("open %s: %w", path, err) + } + defer f.Close() + + scanner := bufio.NewScanner(f) + var lines []string + for scanner.Scan() { + lines = append(lines, scanner.Text()) + } + if err := scanner.Err(); err != nil { + return "", "", fmt.Errorf("scan %s: %w", path, err) + } + + if len(lines) == 0 { + return "", "", nil + } + + // Find frontmatter delimiters + if lines[0] != "---" { + // No frontmatter — entire file is body + return "", strings.Join(lines, "\n"), nil + } + + end := -1 + for i := 1; i < len(lines); i++ { + if lines[i] == "---" { + end = i + break + } + } + if end == -1 { + return "", strings.Join(lines, "\n"), nil + } + + yamlStr := strings.Join(lines[1:end], "\n") + body := "" + if end+1 < len(lines) { + body = strings.Join(lines[end+1:], "\n") + } + return yamlStr, body, nil +} + +// countCheckboxes counts checked and total checkboxes in a section of markdown. +// section is the content of the section (after the heading line). +func countCheckboxes(body string, sectionHeading string) (checked, total int) { + lines := strings.Split(body, "\n") + inSection := false + // Match headings of same or greater depth to detect section end + headingRe := regexp.MustCompile(`^#{1,6}\s`) + var sectionLines []string + + for _, line := range lines { + trimmed := strings.TrimSpace(line) + // Detect section start - flexible: "## Acceptance", "## Definition of Done" + if !inSection { + // case-insensitive heading match + lower := strings.ToLower(trimmed) + headingLower := strings.ToLower(sectionHeading) + if strings.HasPrefix(lower, "##") && strings.Contains(lower, headingLower) { + inSection = true + continue + } + } else { + // Stop at any heading of ## or higher + if headingRe.MatchString(trimmed) && (strings.HasPrefix(trimmed, "## ") || strings.HasPrefix(trimmed, "# ")) { + break + } + // Also stop at "### " sub-headings only if they are a different section + // Actually: stop at any heading that's the same level or higher + if strings.HasPrefix(trimmed, "## ") { + break + } + sectionLines = append(sectionLines, line) + } + } + + for _, l := range sectionLines { + t := strings.TrimSpace(l) + if strings.HasPrefix(t, "- [x]") || strings.HasPrefix(t, "- [X]") { + checked++ + total++ + } else if strings.HasPrefix(t, "- [ ]") { + total++ + } + } + return checked, total +} + +// pct computes integer percentage (0-100). +func pct(checked, total int) int { + if total == 0 { + return 0 + } + return (checked * 100) / total +} + +// ParseIssue parses a single issue markdown file. +func ParseIssue(path string) (Issue, error) { + yamlStr, body, err := splitFrontmatter(path) + if err != nil { + return Issue{}, err + } + + var issue Issue + if yamlStr != "" { + if err := yaml.Unmarshal([]byte(yamlStr), &issue); err != nil { + return Issue{}, fmt.Errorf("yaml parse %s: %w", path, err) + } + } + issue.Path = path + issue.Body = body + + // Compute AcceptancePct + acceptChecked, acceptTotal := countCheckboxes(body, "Acceptance") + issue.AcceptancePct = pct(acceptChecked, acceptTotal) + + // Compute DoDPct + dodChecked, dodTotal := countCheckboxes(body, "Definition of Done") + issue.DoDPct = pct(dodChecked, dodTotal) + + return issue, nil +} + +// ParseFlow parses a single flow markdown file. +func ParseFlow(path string) (Flow, error) { + yamlStr, body, err := splitFrontmatter(path) + if err != nil { + return Flow{}, err + } + + var flow Flow + if yamlStr != "" { + if err := yaml.Unmarshal([]byte(yamlStr), &flow); err != nil { + return Flow{}, fmt.Errorf("yaml parse %s: %w", path, err) + } + } + flow.Path = path + flow.Body = body + + // Compute AcceptancePct + acceptChecked, acceptTotal := countCheckboxes(body, "Acceptance") + flow.AcceptancePct = pct(acceptChecked, acceptTotal) + + // Compute DoDPct from full DoD section (all checkboxes in that section) + dodChecked, dodTotal := countCheckboxesDeep(body, "Definition of Done") + flow.DoDPct = pct(dodChecked, dodTotal) + + // UserFacingPct from ### User-facing sub-block + ufChecked, ufTotal := countCheckboxesSubsection(body, "User-facing") + flow.UserFacingPct = pct(ufChecked, ufTotal) + + return flow, nil +} + +// countCheckboxesDeep counts all checkboxes under a section heading (## or ###), including sub-sections. +func countCheckboxesDeep(body, sectionHeading string) (checked, total int) { + lines := strings.Split(body, "\n") + inSection := false + sectionDepth := 0 + + for _, line := range lines { + trimmed := strings.TrimSpace(line) + lower := strings.ToLower(trimmed) + headingLower := strings.ToLower(sectionHeading) + + if !inSection { + if isHeading(trimmed) && strings.Contains(lower, headingLower) { + inSection = true + sectionDepth = headingDepth(trimmed) + continue + } + } else { + // Stop if we hit a heading at same or lesser depth + if isHeading(trimmed) && headingDepth(trimmed) <= sectionDepth { + break + } + t := strings.TrimSpace(line) + if strings.HasPrefix(t, "- [x]") || strings.HasPrefix(t, "- [X]") { + checked++ + total++ + } else if strings.HasPrefix(t, "- [ ]") { + total++ + } + } + } + return checked, total +} + +// countCheckboxesSubsection counts checkboxes under a ### sub-heading. +func countCheckboxesSubsection(body, subHeading string) (checked, total int) { + lines := strings.Split(body, "\n") + inSection := false + + for _, line := range lines { + trimmed := strings.TrimSpace(line) + lower := strings.ToLower(trimmed) + headingLower := strings.ToLower(subHeading) + + if !inSection { + if isHeading(trimmed) && strings.Contains(lower, headingLower) { + inSection = true + continue + } + } else { + if isHeading(trimmed) { + break + } + t := strings.TrimSpace(line) + if strings.HasPrefix(t, "- [x]") || strings.HasPrefix(t, "- [X]") { + checked++ + total++ + } else if strings.HasPrefix(t, "- [ ]") { + total++ + } + } + } + return checked, total +} + +func isHeading(line string) bool { + return regexp.MustCompile(`^#{1,6}\s`).MatchString(line) +} + +func headingDepth(line string) int { + for i, c := range line { + if c != '#' { + return i + } + } + return 0 +} + +// isSkippable returns true for files that should be skipped when loading issues. +func isSkippable(name string) bool { + lower := strings.ToLower(name) + skip := []string{"readme", "template", "index", "agent_guide"} + for _, s := range skip { + if strings.Contains(lower, s) { + return true + } + } + return false +} + +// LoadAllIssues loads all issues from dev/issues/ (open) and dev/issues/completed/ (completed). +// If onlyOpen is true, skips completed/. +func LoadAllIssues(root string) ([]Issue, error) { + return loadIssuesFromDirs(root, false) +} + +func loadIssuesFromDirs(root string, onlyOpen bool) ([]Issue, error) { + dirs := []string{ + filepath.Join(root, "dev", "issues"), + } + if !onlyOpen { + dirs = append(dirs, filepath.Join(root, "dev", "issues", "completed")) + } + + // Deduplicate by ID: first occurrence wins (dev/issues/ takes precedence over completed/). + seen := make(map[string]bool) + var issues []Issue + for _, dir := range dirs { + entries, err := filepath.Glob(filepath.Join(dir, "*.md")) + if err != nil { + return nil, err + } + for _, path := range entries { + name := filepath.Base(path) + if isSkippable(name) { + continue + } + issue, err := ParseIssue(path) + if err != nil { + // Skip malformed files with a warning + fmt.Fprintf(os.Stderr, "warn: skip %s: %v\n", path, err) + continue + } + // Deduplicate by ID; if same file name appears in both dirs, keep first seen. + key := issue.ID + if key == "" { + key = name // fallback to filename + } + if seen[key] { + continue + } + seen[key] = true + issues = append(issues, issue) + } + } + return issues, nil +} + +// LoadOpenIssues loads only non-completed issues (from dev/issues/, not completed/). +func LoadOpenIssues(root string) ([]Issue, error) { + return loadIssuesFromDirs(root, true) +} + +// LoadAllFlows loads all flows from dev/flows/*.md. +func LoadAllFlows(root string) ([]Flow, error) { + dir := filepath.Join(root, "dev", "flows") + entries, err := filepath.Glob(filepath.Join(dir, "*.md")) + if err != nil { + return nil, err + } + + var flows []Flow + for _, path := range entries { + name := filepath.Base(path) + if isSkippable(name) { + continue + } + flow, err := ParseFlow(path) + if err != nil { + fmt.Fprintf(os.Stderr, "warn: skip %s: %v\n", path, err) + continue + } + flows = append(flows, flow) + } + return flows, nil +} + +// ComputeDepsResolved sets DepsResolved on each issue based on the full list. +func ComputeDepsResolved(issues []Issue) { + // Build a map of id -> status + statusMap := make(map[string]string) + for _, iss := range issues { + if iss.ID != "" { + statusMap[iss.ID] = iss.Status + } + } + + for i := range issues { + if len(issues[i].Depends) == 0 { + issues[i].DepsResolved = true + continue + } + allResolved := true + for _, dep := range issues[i].Depends { + dep = strings.TrimSpace(dep) + if dep == "" { + continue + } + st, found := statusMap[dep] + if !found || st != "completado" { + allResolved = false + break + } + } + issues[i].DepsResolved = allResolved + } +} diff --git a/parser_test.go b/parser_test.go new file mode 100644 index 0000000..1324f58 --- /dev/null +++ b/parser_test.go @@ -0,0 +1,259 @@ +package main + +import ( + "path/filepath" + "strings" + "testing" +) + +// testdataDir returns the path to the testdata directory. +func testdataDir() string { + return "testdata" +} + +// TestParseIssue_BasicFields verifies that ParseIssue correctly reads frontmatter. +func TestParseIssue_BasicFields(t *testing.T) { + path := filepath.Join(testdataDir(), "issues", "0099-sample.md") + iss, err := ParseIssue(path) + if err != nil { + t.Fatalf("ParseIssue: %v", err) + } + + if iss.ID != "0099" { + t.Errorf("ID: got %q, want %q", iss.ID, "0099") + } + if iss.Title != "datahub app (launcher central para todas las apps)" { + t.Errorf("Title: got %q", iss.Title) + } + if iss.Status != "pendiente" { + t.Errorf("Status: got %q, want %q", iss.Status, "pendiente") + } + if iss.Priority != "alta" { + t.Errorf("Priority: got %q, want %q", iss.Priority, "alta") + } + if iss.Type != "feature" { + t.Errorf("Type: got %q, want %q", iss.Type, "feature") + } + if len(iss.Domain) != 1 || iss.Domain[0] != "apps-infra" { + t.Errorf("Domain: got %v, want [apps-infra]", iss.Domain) + } + if iss.Path != path { + t.Errorf("Path: got %q, want %q", iss.Path, path) + } + if iss.Body == "" { + t.Error("Body should not be empty") + } +} + +// TestParseIssue_AcceptancePct verifies checkbox counting in ## Acceptance. +func TestParseIssue_AcceptancePct(t *testing.T) { + path := filepath.Join(testdataDir(), "issues", "0099-sample.md") + iss, err := ParseIssue(path) + if err != nil { + t.Fatalf("ParseIssue: %v", err) + } + // In 0099-sample.md: 2 checked, 2 unchecked = 50% + if iss.AcceptancePct != 50 { + t.Errorf("AcceptancePct: got %d, want 50", iss.AcceptancePct) + } +} + +// TestParseIssue_DoDPct verifies checkbox counting in ## Definition of Done. +func TestParseIssue_DoDPct(t *testing.T) { + path := filepath.Join(testdataDir(), "issues", "0099-sample.md") + iss, err := ParseIssue(path) + if err != nil { + t.Fatalf("ParseIssue: %v", err) + } + // In 0099-sample.md DoD: 1 checked, 3 unchecked = 25% + if iss.DoDPct != 25 { + t.Errorf("DoDPct: got %d, want 25", iss.DoDPct) + } +} + +// TestParseIssue_EmptyBody handles a file with no body. +func TestParseIssue_EmptyBody(t *testing.T) { + path := filepath.Join(testdataDir(), "issues", "0051-missing-dep.md") + iss, err := ParseIssue(path) + if err != nil { + t.Fatalf("ParseIssue: %v", err) + } + if iss.ID != "0051" { + t.Errorf("ID: got %q, want %q", iss.ID, "0051") + } + // No checkboxes — both pcts should be 0 + if iss.AcceptancePct != 0 { + t.Errorf("AcceptancePct: got %d, want 0", iss.AcceptancePct) + } +} + +// TestDepsResolved_AllCompletado verifies DepsResolved=true when all deps are completado. +func TestDepsResolved_AllCompletado(t *testing.T) { + issues := []Issue{ + {ID: "0001", Status: "completado"}, + {ID: "0050", Status: "pendiente", Depends: []string{"0001"}}, + } + ComputeDepsResolved(issues) + + if !issues[1].DepsResolved { + t.Error("DepsResolved should be true when all deps are completado") + } +} + +// TestDepsResolved_DepNotCompletado verifies DepsResolved=false when dep not completado. +func TestDepsResolved_DepNotCompletado(t *testing.T) { + issues := []Issue{ + {ID: "0001", Status: "pendiente"}, // not completado + {ID: "0050", Status: "pendiente", Depends: []string{"0001"}}, + } + ComputeDepsResolved(issues) + + if issues[1].DepsResolved { + t.Error("DepsResolved should be false when dep is not completado") + } +} + +// TestDepsResolved_DepMissing verifies DepsResolved=false when dep is not in list. +func TestDepsResolved_DepMissing(t *testing.T) { + issues := []Issue{ + {ID: "0051", Status: "pendiente", Depends: []string{"9999"}}, + } + ComputeDepsResolved(issues) + + if issues[0].DepsResolved { + t.Error("DepsResolved should be false when dep is missing from issue list") + } +} + +// TestDepsResolved_NoDeps verifies DepsResolved=true when there are no deps. +func TestDepsResolved_NoDeps(t *testing.T) { + issues := []Issue{ + {ID: "0099", Status: "pendiente", Depends: []string{}}, + } + ComputeDepsResolved(issues) + + if !issues[0].DepsResolved { + t.Error("DepsResolved should be true when there are no deps") + } +} + +// TestLoadAllIssues_SkipsSkippable verifies that README.md and template.md are skipped. +func TestLoadAllIssues_SkipsSkippable(t *testing.T) { + issues, err := loadIssuesFromTestdata() + if err != nil { + t.Fatalf("load: %v", err) + } + for _, iss := range issues { + base := filepath.Base(iss.Path) + lower := strings.ToLower(base) + if strings.Contains(lower, "readme") || strings.Contains(lower, "template") { + t.Errorf("should have skipped %s", base) + } + } +} + +// TestLoadAllIssues_CountsCorrect verifies we load the expected number of fixtures. +func TestLoadAllIssues_CountsCorrect(t *testing.T) { + issues, err := loadIssuesFromTestdata() + if err != nil { + t.Fatalf("load: %v", err) + } + // We have 4 fixture issues: 0099, 0001, 0050, 0051 + if len(issues) != 4 { + t.Errorf("expected 4 issues, got %d", len(issues)) + } +} + +// TestParseFlow_BasicFields verifies that ParseFlow reads frontmatter correctly. +func TestParseFlow_BasicFields(t *testing.T) { + path := filepath.Join(testdataDir(), "flows", "0001-sample.md") + fl, err := ParseFlow(path) + if err != nil { + t.Fatalf("ParseFlow: %v", err) + } + + if fl.ID != "0001" { + t.Errorf("ID: got %q, want %q", fl.ID, "0001") + } + if fl.Name != "hn-top-stories" { + t.Errorf("Name: got %q, want %q", fl.Name, "hn-top-stories") + } + if fl.Status != "pending" { + t.Errorf("Status: got %q, want %q", fl.Status, "pending") + } + if fl.Risk != "low" { + t.Errorf("Risk: got %q, want %q", fl.Risk, "low") + } + if len(fl.Apps) != 2 { + t.Errorf("Apps: got %d apps, want 2", len(fl.Apps)) + } +} + +// TestParseFlow_AcceptancePct verifies checkbox counting in flow Acceptance. +func TestParseFlow_AcceptancePct(t *testing.T) { + path := filepath.Join(testdataDir(), "flows", "0001-sample.md") + fl, err := ParseFlow(path) + if err != nil { + t.Fatalf("ParseFlow: %v", err) + } + // 1 checked, 2 unchecked = 33% + if fl.AcceptancePct != 33 { + t.Errorf("AcceptancePct: got %d, want 33", fl.AcceptancePct) + } +} + +// TestParseFlow_UserFacingPct verifies user-facing checkbox counting. +func TestParseFlow_UserFacingPct(t *testing.T) { + path := filepath.Join(testdataDir(), "flows", "0001-sample.md") + fl, err := ParseFlow(path) + if err != nil { + t.Fatalf("ParseFlow: %v", err) + } + // 0 checked, 2 unchecked = 0% + if fl.UserFacingPct != 0 { + t.Errorf("UserFacingPct: got %d, want 0", fl.UserFacingPct) + } +} + +// TestNormalizeID pads short numeric IDs. +func TestNormalizeID(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"99", "0099"}, + {"0099", "0099"}, + {"1", "0001"}, + {"0001", "0001"}, + {"0088a", "0088a"}, // non-numeric, no pad + {"101", "0101"}, + } + for _, tt := range tests { + got := normalizeID(tt.input) + if got != tt.want { + t.Errorf("normalizeID(%q) = %q, want %q", tt.input, got, tt.want) + } + } +} + +// loadIssuesFromTestdata is a helper that loads from testdata/ instead of dev/issues/. +func loadIssuesFromTestdata() ([]Issue, error) { + dir := filepath.Join(testdataDir(), "issues") + entries, err := filepath.Glob(filepath.Join(dir, "*.md")) + if err != nil { + return nil, err + } + var issues []Issue + for _, path := range entries { + name := filepath.Base(path) + if isSkippable(name) { + continue + } + iss, err := ParseIssue(path) + if err != nil { + return nil, err + } + issues = append(issues, iss) + } + return issues, nil +} diff --git a/testdata/flows/0001-sample.md b/testdata/flows/0001-sample.md new file mode 100644 index 0000000..d02f20e --- /dev/null +++ b/testdata/flows/0001-sample.md @@ -0,0 +1,40 @@ +--- +name: hn-top-stories +id: 0001 +status: pending +created: 2026-05-16 +updated: 2026-05-16 +priority: high +risk: low +related_issues: [0097, 0098] +apps: + - navegator_dashboard + - dag_engine +trigger: cron +schedule: "*/30 * * * *" +expected_runtime_s: 30 +tags: [scraping, news, smoke-test] +--- + +## Goal + +Probar end-to-end el stack: navegator -> dag_engine -> data_factory. + +## Acceptance + +- [x] Recipe creada y validada. +- [ ] DAG corre OK 2 veces consecutivas. +- [ ] data_factory.runs tiene >=2 entries. + +## Definition of Done + +### Generico + +- [x] **Repetibilidad**: corre 3 veces consecutivas via cron sin intervencion. +- [ ] **Observabilidad**: call_monitor.calls registra la funcion. +- [ ] **Error-path**: si Chrome cae, el step falla con mensaje claro. + +### User-facing + +- [ ] **User-facing**: usuario abre data_factory.exe y ve >=30 filas. +- [ ] **User-facing repeat**: datos frescos cada 30 min. diff --git a/testdata/issues/0001-completado.md b/testdata/issues/0001-completado.md new file mode 100644 index 0000000..5209001 --- /dev/null +++ b/testdata/issues/0001-completado.md @@ -0,0 +1,27 @@ +--- +id: "0001" +title: "setup inicial del registry" +status: completado +type: chore +domain: + - meta +scope: registry-only +priority: alta +depends: [] +blocks: [] +related: [] +created: 2026-01-01 +updated: 2026-01-15 +tags: [setup] +--- +# 0001 — setup inicial del registry + +## Acceptance + +- [x] Registry indexable con fn index. +- [x] CLI fn disponible. + +## Definition of Done + +- [x] **Repetibilidad**: fn index corre 3x sin errores. +- [x] **Docs**: CLAUDE.md actualizado. diff --git a/testdata/issues/0050-blocked.md b/testdata/issues/0050-blocked.md new file mode 100644 index 0000000..afc30d1 --- /dev/null +++ b/testdata/issues/0050-blocked.md @@ -0,0 +1,23 @@ +--- +id: "0050" +title: "feature que depende de 0001" +status: pendiente +type: feature +domain: + - meta +scope: registry-only +priority: media +depends: + - "0001" +blocks: [] +related: [] +created: 2026-02-01 +updated: 2026-02-01 +tags: [] +--- +# 0050 — feature que depende de 0001 + +## Acceptance + +- [ ] Feature implementada. +- [ ] Tests verdes. diff --git a/testdata/issues/0051-missing-dep.md b/testdata/issues/0051-missing-dep.md new file mode 100644 index 0000000..b873ea0 --- /dev/null +++ b/testdata/issues/0051-missing-dep.md @@ -0,0 +1,18 @@ +--- +id: "0051" +title: "feature con dep no existente" +status: pendiente +type: feature +domain: + - meta +scope: registry-only +priority: baja +depends: + - "9999" +blocks: [] +related: [] +created: 2026-02-01 +updated: 2026-02-01 +tags: [] +--- +# 0051 — feature con dep no existente diff --git a/testdata/issues/0099-sample.md b/testdata/issues/0099-sample.md new file mode 100644 index 0000000..43dce2e --- /dev/null +++ b/testdata/issues/0099-sample.md @@ -0,0 +1,39 @@ +--- +id: "0099" +title: "datahub app (launcher central para todas las apps)" +status: pendiente +type: feature +domain: + - apps-infra +scope: app-scoped +priority: alta +depends: [] +blocks: [] +related: [] +created: 2026-05-17 +updated: 2026-05-17 +tags: [] +--- +# 0099 — datahub app (launcher central para todas las apps) + +**Status:** pendiente + +## Problema + +App C++ ImGui standalone que actua como launcher central. + +## Acceptance + +- [x] Catalogo de apps visible con icono, nombre, descripcion. +- [x] Filtro por texto funcional. +- [ ] Launch / Stop / Redeploy funcionales. +- [ ] Tail log de una app corriendo actualizado en vivo. + +## Definition of Done + +### Generico + +- [x] **Repetibilidad**: tests verdes 3x. +- [ ] **Observabilidad**: cada invocacion registrada. +- [ ] **Error-path**: archivo malformado -> mensaje claro. +- [ ] **Docs**: app.md + README con ejemplos. diff --git a/work.go b/work.go new file mode 100644 index 0000000..7977e35 --- /dev/null +++ b/work.go @@ -0,0 +1,306 @@ +package main + +import ( + "fmt" + "os" + "sort" + "strings" +) + +// cmdWork dispatches work subcommands. +func cmdWork(args []string, flags Flags) { + if len(args) == 0 { + fmt.Fprintln(os.Stderr, "usage: dev_console work [args]") + os.Exit(1) + } + sub := args[0] + + switch sub { + case "today": + workToday(flags) + case "dashboard": + workDashboard(flags) + // v2 stubs + case "weekly", "search": + fmt.Fprintf(os.Stderr, "TODO v2: work %s not yet implemented\n", sub) + os.Exit(2) + default: + fmt.Fprintf(os.Stderr, "unknown work subcommand: %s\n", sub) + os.Exit(1) + } +} + +type workItem struct { + Kind string `json:"kind"` + ID string `json:"id"` + Title string `json:"title"` + Prio string `json:"prio"` + Status string `json:"status"` + Next string `json:"next"` +} + +// workToday shows a prioritized list of up to 10 items to work on today. +func workToday(flags Flags) { + root := mustRegistryRoot() + + // Load issues + issues, err := LoadOpenIssues(root) + if err != nil { + fatalf("load issues: %v", err) + } + ComputeDepsResolved(issues) + + // Load flows + flows, err := LoadAllFlows(root) + if err != nil { + fatalf("load flows: %v", err) + } + + var items []workItem + + // Priority order: alta > media > baja + prioPriority := map[string]int{ + "alta": 0, + "media": 1, + "baja": 2, + "high": 0, + "medium": 1, + "low": 2, + "": 3, + } + + statusPriority := map[string]int{ + "in-progress": 0, + "pendiente": 1, + "pending": 1, + "bloqueado": 2, + "blocked": 2, + "deferred": 3, + } + + // Filter issues: only pendiente or in-progress + for _, iss := range issues { + st := strings.ToLower(iss.Status) + if st == "completado" || st == "done" || st == "completed" || st == "deferred" { + continue + } + next := issueNext(iss) + items = append(items, workItem{ + Kind: "issue", + ID: iss.ID, + Title: iss.Title, + Prio: iss.Priority, + Status: st, + Next: next, + }) + } + + // Add flows that are not completed + for _, fl := range flows { + st := strings.ToLower(fl.Status) + if st == "completado" || st == "done" || st == "completed" { + continue + } + next := flowNext(fl) + items = append(items, workItem{ + Kind: "flow", + ID: fl.ID, + Title: fl.Name, + Prio: fl.Priority, + Status: st, + Next: next, + }) + } + + // Sort: first by status priority, then by prio priority, then by ID + sort.Slice(items, func(i, j int) bool { + si := statusPriority[items[i].Status] + sj := statusPriority[items[j].Status] + if si != sj { + return si < sj + } + pi := prioPriority[strings.ToLower(items[i].Prio)] + pj := prioPriority[strings.ToLower(items[j].Prio)] + if pi != pj { + return pi < pj + } + return items[i].ID < items[j].ID + }) + + // Take top 10 + if len(items) > 10 { + items = items[:10] + } + + if flags.JSON { + printJSON(items) + return + } + + headers := []string{"KIND", "ID", "TITLE", "PRIO", "STATUS", "NEXT"} + var rows [][]string + for _, item := range items { + rows = append(rows, []string{ + item.Kind, + item.ID, + truncate(item.Title, 40), + item.Prio, + item.Status, + truncate(item.Next, 30), + }) + } + printTable(os.Stdout, headers, rows) +} + +// issueNext returns a human-readable "next action" for an issue. +func issueNext(iss Issue) string { + st := strings.ToLower(iss.Status) + if st == "in-progress" { + return "iterate" + } + if !iss.DepsResolved { + return "blocked: resolve deps" + } + if iss.AcceptancePct == 0 { + return "implement (deps ok)" + } + if iss.AcceptancePct < 100 { + return fmt.Sprintf("implement (%d%% done)", iss.AcceptancePct) + } + return "review DoD" +} + +// flowNext returns a human-readable "next action" for a flow. +func flowNext(fl Flow) string { + st := strings.ToLower(fl.Status) + if st == "in-progress" { + return "iterate" + } + if fl.DoDPct == 100 { + return "mark done" + } + if fl.UserFacingPct < 100 && fl.UserFacingPct > 0 { + return fmt.Sprintf("DoD user-facing %d%%", fl.UserFacingPct) + } + if fl.AcceptancePct < 100 { + return fmt.Sprintf("acceptance %d%%", fl.AcceptancePct) + } + return "check DoD" +} + +// workDashboard prints a JSON dashboard for the work tab. +func workDashboard(flags Flags) { + root := mustRegistryRoot() + + issues, err := LoadAllIssues(root) + if err != nil { + fatalf("load issues: %v", err) + } + ComputeDepsResolved(issues) + + flows, err := LoadAllFlows(root) + if err != nil { + fatalf("load flows: %v", err) + } + + // Build stats + type stats struct { + Total int `json:"total"` + Pendiente int `json:"pendiente"` + InProgress int `json:"in_progress"` + Bloqueado int `json:"bloqueado"` + Completado int `json:"completado"` + } + + var ist stats + for _, iss := range issues { + ist.Total++ + switch strings.ToLower(iss.Status) { + case "pendiente", "pending": + ist.Pendiente++ + case "in-progress": + ist.InProgress++ + case "bloqueado", "blocked": + ist.Bloqueado++ + case "completado", "done", "completed": + ist.Completado++ + } + } + + var fst stats + for _, fl := range flows { + fst.Total++ + switch strings.ToLower(fl.Status) { + case "pendiente", "pending": + fst.Pendiente++ + case "in-progress": + fst.InProgress++ + case "completado", "done", "completed": + fst.Completado++ + } + } + + // Top priority issues + var topIssues []Issue + for _, iss := range issues { + if iss.Priority == "alta" { + st := strings.ToLower(iss.Status) + if st != "completado" && st != "done" && st != "completed" && st != "deferred" { + topIssues = append(topIssues, iss) + } + } + } + sort.Slice(topIssues, func(i, j int) bool { + return topIssues[i].ID < topIssues[j].ID + }) + if len(topIssues) > 10 { + topIssues = topIssues[:10] + } + + type issueSlim struct { + ID string `json:"id"` + Title string `json:"title"` + Status string `json:"status"` + Type string `json:"type"` + Domain []string `json:"domain"` + Priority string `json:"priority"` + Depends []string `json:"depends"` + DepsResolved bool `json:"deps_resolved"` + AcceptancePct int `json:"acceptance_pct"` + } + type flowSlim struct { + ID string `json:"id"` + Name string `json:"name"` + Status string `json:"status"` + Pattern string `json:"pattern"` + Risk string `json:"risk"` + Priority string `json:"priority"` + Apps []string `json:"apps"` + AcceptancePct int `json:"acceptance_pct"` + DoDPct int `json:"dod_pct"` + UserFacingPct int `json:"user_facing_pct"` + } + slimIssues := make([]issueSlim, 0, len(topIssues)) + for _, iss := range topIssues { + slimIssues = append(slimIssues, issueSlim{ + ID: iss.ID, Title: iss.Title, Status: iss.Status, Type: iss.Type, + Domain: iss.Domain, Priority: iss.Priority, Depends: iss.Depends, + DepsResolved: iss.DepsResolved, AcceptancePct: iss.AcceptancePct, + }) + } + slimFlows := make([]flowSlim, 0, len(flows)) + for _, fl := range flows { + slimFlows = append(slimFlows, flowSlim{ + ID: fl.ID, Name: fl.Name, Status: fl.Status, Pattern: fl.Pattern, + Risk: fl.Risk, Priority: fl.Priority, Apps: fl.Apps, + AcceptancePct: fl.AcceptancePct, DoDPct: fl.DoDPct, UserFacingPct: fl.UserFacingPct, + }) + } + out := map[string]any{ + "issue_stats": ist, + "flow_stats": fst, + "top_issues": slimIssues, + "flows": slimFlows, + } + printJSON(out) +}