chore: sync from fn-registry agent
This commit is contained in:
@@ -0,0 +1 @@
|
||||
dev_console
|
||||
@@ -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/
|
||||
@@ -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)}'\""
|
||||
---
|
||||
@@ -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 <list|show|status> [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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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=
|
||||
@@ -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 <list|show|status|board> [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
|
||||
}
|
||||
@@ -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 <list|show|status|board>")
|
||||
os.Exit(1)
|
||||
}
|
||||
cmdIssue(append([]string{verb}, rest...), flags)
|
||||
case "flow":
|
||||
if verb == "" {
|
||||
fmt.Fprintln(os.Stderr, "usage: dev_console flow <list|show|status>")
|
||||
os.Exit(1)
|
||||
}
|
||||
cmdFlow(append([]string{verb}, rest...), flags)
|
||||
case "work":
|
||||
if verb == "" {
|
||||
fmt.Fprintln(os.Stderr, "usage: dev_console work <today|dashboard>")
|
||||
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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
+259
@@ -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
|
||||
}
|
||||
Vendored
+40
@@ -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.
|
||||
Vendored
+27
@@ -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.
|
||||
Vendored
+23
@@ -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.
|
||||
Vendored
+18
@@ -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
|
||||
Vendored
+39
@@ -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.
|
||||
@@ -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 <today|dashboard> [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)
|
||||
}
|
||||
Reference in New Issue
Block a user