350 lines
8.4 KiB
Go
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
|
|
}
|