commit 46b0826a9f80ca1971bd5fcc7bb6155bfa9261b2 Author: fn-registry agent Date: Sun May 17 02:44:02 2026 +0200 chore: sync from fn-registry agent 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) +}