Files
2026-05-17 02:44:02 +02:00

350 lines
8.4 KiB
Go

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
}