mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-21 18:13:26 +00:00
bf5ab5b366
* chore: clear cosmetic staticcheck findings (TASK-764)
Apply zero-behavior-change fixes for 8 staticcheck findings on main:
- SA4023 cmd/pad/main.go:431 — drop always-true `if eventBus != nil`
guard. eventBus is wrapped in metrics.NewInstrumentedBus a few lines
above, which returns a concrete *InstrumentedBus that is never nil.
- SA1019 cmd/pad/main.go:3926 — replace deprecated strings.Title with
golang.org/x/text/cases.Title(language.English).String. golang.org/x/text
was already an indirect dep; now promoted to direct.
- SA4031 internal/server/handlers_changes.go:130 — delete dead
`if updatedItems == nil { ... }` block. make([]T, n) always returns
non-nil; the JSON marshalling already produced [] not null.
- SA9003 cmd/pad/init.go:351 — delete empty if branch and fold its
intent into the surrounding comment.
- SA9003 internal/server/handlers_dashboard.go:125 — replace empty
`if err == nil { ... }` branch with `_ = json.Unmarshal(...)` to
match the sibling settings parse and document the best-effort intent.
- SA4006 internal/cli/format.go:153 — drop the dead initial
`titlePart := item.Title` (overwritten in both branches below);
declare titlePart with `var` instead.
- SA4006 internal/store/workspaces.go:70 — drop the dead first call
to s.uniqueSlug; only the workspace-specific uniqueWorkspaceSlug
is meaningful (workspace slugs are globally unique, not workspace-
scoped like collection/item slugs).
- SA4000 internal/store/store_test.go:99 — remove always-true outer
`if idx := len(connStr) - len(connStr); idx >= 0` and unindent the
inner '?' query-string split.
go.mod side effects from `go mod tidy` under Go 1.26: golang.org/x/text
moves to direct (used directly now); pquerna/otp, prometheus/client_*
and trustelem/zxcvbn move from indirect to direct (they were already
used directly — Go 1.26's tidy correctly classifies them).
Verified:
- `go build ./...` clean
- `go vet ./...` clean
- `go test ./...` all pass (including the replaceDBName test path)
- `staticcheck -checks "SA1019,SA4000,SA4006,SA4023,SA4031,SA9003"` clean
except for handlers_dashboard.go:221 (SA4006, dashboard visibility-
filter dead block — handled in TASK-765)
Parent: PLAN-644.
* fix: clear SA5011 nil-deref in buildReconcileFindings (TASK-764)
extractItemStatus(item.Fields) on the first line of the function would
have panicked on a nil item before the `if item != nil && item.CodeContext
== nil` guard could fire. Staticcheck SA5011 flagged the inconsistency.
Drop the (item != nil) half of the guard — the function now documents
its non-nil contract in the doc comment. All callers (reconcile.go:204
plus three sites in cmd/pad/reconcile_test.go) already pass non-nil,
so this is documentation, not behaviour change.
Verified:
- `go build ./...` clean
- `go test ./cmd/pad/...` passes (the existing reconcile tests cover the
contract)
- `staticcheck -checks SA5011 ./...` clean
Parent: PLAN-644.
* chore: silence SA4017 false positive in watchCmd SSE loop (TASK-764)
cmd/pad/main.go SSE keepalive branch:
if strings.HasPrefix(line, ":") {
continue
}
Staticcheck SA4017 reports "HasPrefix doesn't have side effects and
its return value is ignored" — but the return value IS used as the
if condition. Two sibling strings.HasPrefix calls earlier in the same
for-loop body (matching "event: " and "data: " prefixes) are not
flagged, which strongly suggests an SSA-analysis quirk specific to
this branch rather than a real defect.
Suppress the finding with a //lint:ignore directive that explains
the false positive in-place. Rewriting to a different form (extract
to a bool var, comma-OK on a synthetic value, etc.) would be uglier
than the suppression comment.
Verified:
- `staticcheck -checks SA4017 ./...` clean
- `go build ./...` clean
Parent: PLAN-644.
* chore: delete dead code flagged by U1000 (TASK-764)
Pre-launch (no external contributors yet) — no consumer fork can be
relying on these unreferenced symbols, so we delete them rather than
carry the maintenance burden into v1.
## Helpers (14 functions, 1 type)
cmd/pad/main.go
- progressBar — never called
internal/cli/format.go
- stripHTMLTags — never called
internal/server/handlers_dashboard_test.go
- updateItem (test helper) — never called from any test
internal/server/handlers_items.go
- publishItemEvent — wrapper over publishItemEventWithName; all 5 call
sites use the *WithName variant directly.
- resolveRelationFields — never called.
- resolveRelationFieldFiltersForWorkspace, resolveRelationFieldFilters,
relationFilterKeys, resolveRelationFilterValue — closed loop of dead
helpers (each one only called by another dead one in the family).
- extractStatus — never called (cmd/pad/reconcile.go has its own copy).
internal/server/handlers_versions.go
- handleGetDiff (HTTP handler) — never wired into setupRouter.
- diffsToChanges, diffChange (type) — only used by handleGetDiff above.
- Removes now-unused imports `strconv` and `dmp` (sergi/go-diff).
internal/server/middleware_ratelimit.go
- writeTooManyRequests — never called; the live ratelimit middleware
uses a dedicated 429 path with Retry-After-Bucket headers.
internal/server/server.go
- guestVisibleItemIDs — never called. handlers_events.go had a
comment cross-reference; updated to drop the reference.
## Constants
internal/events/redis_bus.go
- reconnectDelay — never read.
internal/store/api_tokens.go
- defaultTokenExpiryDays — never read.
## Out of scope
The 5 unwired handlers in internal/server/handlers_documents.go are
left alone: they are the subject of TASK-769 (a product decision —
wire up vs. delete — that may want different treatment per handler).
The two SA4006/SA4010 findings on internal/server/handlers_dashboard.go
visibility-filter block are similarly left for TASK-765.
## Verified
- `go build ./...` clean
- `go vet ./...` clean
- `go test ./...` all pass
- `staticcheck -checks "SA*,U1000" ./...` clean except the two TASK-
765 / TASK-769 follow-ups noted above.
Parent: PLAN-644.
* docs: correct caller name in buildReconcileFindings doc (TASK-764)
Codex round 1 caught: the doc comment named the caller `reconcileSingle`
but the actual function is `reconcileItem` (cmd/pad/reconcile.go:204).
Fix the contract comment so it doesn't go stale on the first git blame.
388 lines
9.8 KiB
Go
388 lines
9.8 KiB
Go
package cli
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"sort"
|
|
"strings"
|
|
"text/tabwriter"
|
|
"time"
|
|
|
|
"github.com/fatih/color"
|
|
"github.com/xarmian/pad/internal/models"
|
|
)
|
|
|
|
// Color definitions for reuse across the CLI.
|
|
var (
|
|
Bold = color.New(color.Bold)
|
|
Dim = color.New(color.Faint)
|
|
BoldCyan = color.New(color.Bold, color.FgCyan)
|
|
)
|
|
|
|
// StatusColor returns a *color.Color appropriate for the given status string.
|
|
func StatusColor(status string) *color.Color {
|
|
s := strings.ToLower(strings.ReplaceAll(status, "_", "-"))
|
|
switch s {
|
|
case "done", "completed", "fixed", "implemented", "resolved", "accepted":
|
|
return color.New(color.FgGreen)
|
|
case "in-progress", "in_progress", "exploring", "fixing", "building",
|
|
"researching", "planning", "triaged", "in-sprint", "in_sprint", "paused":
|
|
return color.New(color.FgYellow)
|
|
case "open", "new", "draft", "todo", "planned", "proposed", "raw", "ready":
|
|
return color.New(color.FgBlue)
|
|
case "cancelled", "rejected", "wontfix":
|
|
return color.New(color.FgRed)
|
|
case "active", "published":
|
|
return color.New(color.FgCyan)
|
|
case "archived", "disabled":
|
|
return color.New(color.Faint)
|
|
default:
|
|
return color.New(color.Reset)
|
|
}
|
|
}
|
|
|
|
// PriorityColor returns a *color.Color appropriate for the given priority string.
|
|
func PriorityColor(priority string) *color.Color {
|
|
switch strings.ToLower(priority) {
|
|
case "critical", "urgent":
|
|
return color.New(color.FgRed, color.Bold)
|
|
case "high":
|
|
return color.New(color.FgYellow)
|
|
case "medium":
|
|
return color.New(color.FgWhite)
|
|
case "low":
|
|
return color.New(color.Faint)
|
|
default:
|
|
return color.New(color.Reset)
|
|
}
|
|
}
|
|
|
|
// ColorizedStatus returns a status icon + status text with appropriate color.
|
|
func ColorizedStatus(status string) string {
|
|
icon := statusIconChar(status)
|
|
c := StatusColor(status)
|
|
return c.Sprintf("%s %s", icon, status)
|
|
}
|
|
|
|
// statusIconChar returns just the icon character for a status (no text, no color).
|
|
func statusIconChar(status string) string {
|
|
s := strings.ToLower(strings.ReplaceAll(status, "_", "-"))
|
|
switch s {
|
|
case "active", "open":
|
|
return "●"
|
|
case "draft", "raw", "new", "planned", "proposed", "ready":
|
|
return "○"
|
|
case "completed", "done", "fixed", "implemented", "resolved", "accepted":
|
|
return "✓"
|
|
case "archived", "disabled":
|
|
return "⊘"
|
|
case "in-progress", "exploring", "fixing", "building", "researching",
|
|
"planning", "triaged", "in-sprint", "paused":
|
|
return "◐"
|
|
case "cancelled", "rejected", "wontfix":
|
|
return "✗"
|
|
default:
|
|
return "·"
|
|
}
|
|
}
|
|
|
|
// ItemRef returns the reference string (e.g. "TASK-5") for an item, or empty string.
|
|
func ItemRef(item models.Item) string {
|
|
if item.CollectionPrefix != "" && item.ItemNumber != nil {
|
|
return fmt.Sprintf("%s-%d", item.CollectionPrefix, *item.ItemNumber)
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// StatusIcon returns a colorized status indicator.
|
|
func StatusIcon(status string) string {
|
|
return ColorizedStatus(status)
|
|
}
|
|
|
|
// RelativeTime returns a human-readable relative time string.
|
|
func RelativeTime(t time.Time) string {
|
|
d := time.Since(t)
|
|
switch {
|
|
case d < time.Minute:
|
|
return "just now"
|
|
case d < time.Hour:
|
|
m := int(d.Minutes())
|
|
if m == 1 {
|
|
return "1 min ago"
|
|
}
|
|
return fmt.Sprintf("%d mins ago", m)
|
|
case d < 24*time.Hour:
|
|
h := int(d.Hours())
|
|
if h == 1 {
|
|
return "1 hour ago"
|
|
}
|
|
return fmt.Sprintf("%d hours ago", h)
|
|
case d < 7*24*time.Hour:
|
|
days := int(d.Hours() / 24)
|
|
if days == 1 {
|
|
return "1 day ago"
|
|
}
|
|
return fmt.Sprintf("%d days ago", days)
|
|
default:
|
|
return t.Format("Jan 2, 2006")
|
|
}
|
|
}
|
|
|
|
// PrintItemTable prints items in a formatted table.
|
|
func PrintItemTable(items []models.Item) {
|
|
if len(items) == 0 {
|
|
fmt.Println("No items found.")
|
|
return
|
|
}
|
|
|
|
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
|
headerColor := color.New(color.Faint)
|
|
fmt.Fprintf(w, "%s\t%s\t%s\t%s\n",
|
|
headerColor.Sprint("TITLE"),
|
|
headerColor.Sprint("COLLECTION"),
|
|
headerColor.Sprint("UPDATED"),
|
|
headerColor.Sprint("BY"),
|
|
)
|
|
for _, item := range items {
|
|
pin := ""
|
|
if item.Pinned {
|
|
pin = color.YellowString("* ")
|
|
}
|
|
ref := ItemRef(item)
|
|
var titlePart string
|
|
if ref != "" {
|
|
titlePart = BoldCyan.Sprint(ref) + " " + Bold.Sprint(item.Title)
|
|
} else {
|
|
titlePart = Bold.Sprint(item.Title)
|
|
}
|
|
collLabel := item.CollectionName
|
|
if item.CollectionIcon != "" {
|
|
collLabel = item.CollectionIcon + " " + collLabel
|
|
}
|
|
fmt.Fprintf(w, "%s%s\t%s\t%s\t%s\n",
|
|
pin, titlePart,
|
|
collLabel,
|
|
Dim.Sprint(RelativeTime(item.UpdatedAt)),
|
|
Dim.Sprint(item.LastModifiedBy),
|
|
)
|
|
}
|
|
w.Flush()
|
|
}
|
|
|
|
// PrintItemTitles prints just item titles.
|
|
func PrintItemTitles(items []models.Item) {
|
|
for _, item := range items {
|
|
fmt.Println(item.Title)
|
|
}
|
|
}
|
|
|
|
// FormatFieldSummary returns a formatted summary of item fields.
|
|
// Example output: "status: open | priority: high | category: platform"
|
|
func FormatFieldSummary(fieldsJSON string) string {
|
|
if fieldsJSON == "" || fieldsJSON == "{}" || fieldsJSON == "null" {
|
|
return ""
|
|
}
|
|
|
|
var fields map[string]any
|
|
if err := json.Unmarshal([]byte(fieldsJSON), &fields); err != nil {
|
|
return ""
|
|
}
|
|
|
|
if len(fields) == 0 {
|
|
return ""
|
|
}
|
|
|
|
// Sort keys for consistent output
|
|
keys := make([]string, 0, len(fields))
|
|
for k := range fields {
|
|
keys = append(keys, k)
|
|
}
|
|
sort.Strings(keys)
|
|
|
|
var parts []string
|
|
for _, k := range keys {
|
|
v := fields[k]
|
|
str := fmt.Sprintf("%v", v)
|
|
if str == "" || str == "<nil>" {
|
|
continue
|
|
}
|
|
// Colorize well-known fields
|
|
switch k {
|
|
case "status":
|
|
parts = append(parts, fmt.Sprintf("%s: %s", k, StatusColor(str).Sprint(str)))
|
|
case "priority":
|
|
parts = append(parts, fmt.Sprintf("%s: %s", k, PriorityColor(str).Sprint(str)))
|
|
default:
|
|
parts = append(parts, fmt.Sprintf("%s: %s", k, str))
|
|
}
|
|
}
|
|
|
|
if len(parts) == 0 {
|
|
return ""
|
|
}
|
|
|
|
return strings.Join(parts, " | ")
|
|
}
|
|
|
|
// PrintJSON prints any value as formatted JSON.
|
|
func PrintJSON(v interface{}) error {
|
|
enc := json.NewEncoder(os.Stdout)
|
|
enc.SetIndent("", " ")
|
|
return enc.Encode(v)
|
|
}
|
|
|
|
// PrintItemMeta prints item metadata header with colors.
|
|
func PrintItemMeta(item *models.Item) {
|
|
label := color.New(color.Faint)
|
|
// Item ref + Title
|
|
ref := ItemRef(*item)
|
|
if ref != "" {
|
|
fmt.Printf("%s %s\n", BoldCyan.Sprint(ref), Bold.Sprint(item.Title))
|
|
} else {
|
|
fmt.Printf("%s\n", Bold.Sprint(item.Title))
|
|
}
|
|
|
|
if item.CollectionName != "" {
|
|
collLabel := item.CollectionName
|
|
if item.CollectionIcon != "" {
|
|
collLabel = item.CollectionIcon + " " + collLabel
|
|
}
|
|
fmt.Printf("%s %s\n", label.Sprint("Collection:"), collLabel)
|
|
}
|
|
// Parent link
|
|
if item.ParentRef != "" {
|
|
ref := item.ParentRef
|
|
title := item.ParentTitle
|
|
parentStr := ref
|
|
if title != "" {
|
|
parentStr = ref + " " + title
|
|
}
|
|
fmt.Printf("%s %s\n", label.Sprint("Parent: "), parentStr)
|
|
}
|
|
// Assignment: user + role
|
|
if item.AssignedUserName != "" || item.AgentRoleName != "" {
|
|
assignStr := ""
|
|
if item.AssignedUserName != "" && item.AgentRoleName != "" {
|
|
roleLabel := item.AgentRoleName
|
|
if item.AgentRoleIcon != "" {
|
|
roleLabel = item.AgentRoleIcon + " " + roleLabel
|
|
}
|
|
assignStr = fmt.Sprintf("%s (%s)", item.AssignedUserName, roleLabel)
|
|
} else if item.AssignedUserName != "" {
|
|
assignStr = item.AssignedUserName
|
|
} else {
|
|
roleLabel := item.AgentRoleName
|
|
if item.AgentRoleIcon != "" {
|
|
roleLabel = item.AgentRoleIcon + " " + roleLabel
|
|
}
|
|
assignStr = roleLabel
|
|
}
|
|
fmt.Printf("%s %s\n", label.Sprint("Assigned: "), assignStr)
|
|
}
|
|
|
|
tags := item.Tags
|
|
if tags == "[]" || tags == "" || tags == "null" {
|
|
tags = Dim.Sprint("(none)")
|
|
}
|
|
fmt.Printf("%s %s\n", label.Sprint("Tags: "), tags)
|
|
if item.Pinned {
|
|
fmt.Printf("%s %s\n", label.Sprint("Pinned: "), color.YellowString("★ yes"))
|
|
}
|
|
fmt.Printf("%s %s by %s via %s\n",
|
|
label.Sprint("Updated: "),
|
|
Dim.Sprint(RelativeTime(item.UpdatedAt)),
|
|
Dim.Sprint(item.LastModifiedBy),
|
|
Dim.Sprint(item.Source),
|
|
)
|
|
fmt.Println(Dim.Sprint("───────────────────────────────────────────"))
|
|
}
|
|
|
|
// PrintCollectionTable prints collections in a formatted table.
|
|
func PrintCollectionTable(collections []models.Collection) {
|
|
if len(collections) == 0 {
|
|
fmt.Println("No collections found.")
|
|
return
|
|
}
|
|
|
|
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
|
fmt.Fprintf(w, "NAME\tSLUG\tITEMS\tDEFAULT\n")
|
|
for _, col := range collections {
|
|
icon := col.Icon
|
|
if icon == "" {
|
|
icon = " "
|
|
}
|
|
def := ""
|
|
if col.IsDefault {
|
|
def = "yes"
|
|
}
|
|
fmt.Fprintf(w, "%s %s\t%s\t%d\t%s\n",
|
|
icon, col.Name,
|
|
col.Slug,
|
|
col.ItemCount,
|
|
def,
|
|
)
|
|
}
|
|
w.Flush()
|
|
}
|
|
|
|
// PrintLinkTable prints item links in a formatted table.
|
|
func PrintLinkTable(links []models.ItemLink) {
|
|
if len(links) == 0 {
|
|
fmt.Println("No links found.")
|
|
return
|
|
}
|
|
|
|
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
|
fmt.Fprintf(w, "TYPE\tSOURCE\tTARGET\tCREATED\n")
|
|
for _, link := range links {
|
|
fmt.Fprintf(w, "%s\t%s\t%s\t%s\n",
|
|
link.LinkType,
|
|
link.SourceTitle,
|
|
link.TargetTitle,
|
|
RelativeTime(link.CreatedAt),
|
|
)
|
|
}
|
|
w.Flush()
|
|
}
|
|
|
|
// PrintActivityTable prints activity entries in a table.
|
|
func PrintActivityTable(activities []models.Activity) {
|
|
if len(activities) == 0 {
|
|
fmt.Println("No recent activity.")
|
|
return
|
|
}
|
|
|
|
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
|
fmt.Fprintf(w, "ACTION\tACTOR\tSOURCE\tWHEN\n")
|
|
for _, a := range activities {
|
|
fmt.Fprintf(w, "%s\t%s\t%s\t%s\n",
|
|
a.Action,
|
|
a.Actor,
|
|
a.Source,
|
|
RelativeTime(a.CreatedAt),
|
|
)
|
|
}
|
|
w.Flush()
|
|
}
|
|
|
|
// PrintCommentTable prints comments in a formatted table.
|
|
func PrintCommentTable(comments []models.Comment) {
|
|
if len(comments) == 0 {
|
|
fmt.Println("No comments.")
|
|
return
|
|
}
|
|
|
|
for i, c := range comments {
|
|
badge := c.CreatedBy
|
|
if c.Author != "" && c.Author != c.CreatedBy {
|
|
badge = c.Author + " (" + c.CreatedBy + ")"
|
|
}
|
|
fmt.Printf("💬 %s • %s via %s\n", badge, RelativeTime(c.CreatedAt), c.Source)
|
|
fmt.Println(c.Body)
|
|
if i < len(comments)-1 {
|
|
fmt.Println()
|
|
}
|
|
}
|
|
}
|