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 }