Files
pad/internal/cli/format.go
T
xarmian 0fd5d0cdfb fix: green up Go (PostgreSQL) CI (BUG-842) (#275)
* fix(store): swap plainto_tsquery → websearch_to_tsquery for PG FTS (BUG-842)

`TestListItems_FTS_HyphenatedSearchTerm/task-five` has been failing on
every Go (PostgreSQL) CI run because `plainto_tsquery('english',
'task-five')` doesn't match the asciihword lexeme(s) the english parser
produces for an indexed `task-five-distinctive`. The result is that
every PG full-text search for hyphenated terms returns zero rows.

`websearch_to_tsquery` (Postgres 11+) is purpose-built for arbitrary
user input and tokenizes hyphenated terms the same way `to_tsvector`
does for the indexed document, so the query intersects the index
correctly. Swapped in three spots in the postgres dialect — FTSMatch,
FTSSnippet, FTSRank — and updated the caller-side comments that
referenced plainto_tsquery. SQLite path is unchanged: it goes through
items_fts MATCH with sanitizeFTSQuery, never through these methods.

* fix(server): drain background goroutines on Stop() (BUG-842)

`TestAdminBillingStats_SidecarSidecarError_DegradesToLocalOnly` (and
other server tests) have been flaking on the Go (PostgreSQL) CI runner
with `TempDir RemoveAll cleanup: directory not empty`. Root cause:
several request handlers spawned bare `go func() { ... }()` goroutines
that touched the SQLite WAL DB after the test function returned.
testServer's t.Cleanup closed the store but had no way to drain those
goroutines first, so a fire-and-forget WAL write could re-create the
`-wal`/`-shm` files between Close() and t.TempDir's RemoveAll.

Add a Server.bg sync.WaitGroup, a Server.goAsync helper that wraps a
WaitGroup-tracked goroutine, and a Server.Stop() that blocks until
every goAsync closure has finished. Convert the four known
fire-and-forget sites to goAsync:

- middleware_auth.go (TouchUserActivity)
- handlers_auth.go   (password reset email)
- handlers_cloud.go  (stripe_processed_events pruning)
- handlers_members.go (workspace invitation email)

Wire `srv.Stop()` into both testServer (server_test.go) and
newMetricsTestServer (metrics_auth_test.go) so cleanup order is
Stop → Close → TempDir RemoveAll. Add
TestServer_Stop_DrainsBackgroundGoroutines to pin the contract: a
goAsync goroutine must block Stop until it returns.

* fix(store): correct PG FTS hyphenation via OR-combined plainto_tsquery (BUG-842)

The previous attempt swapped plainto_tsquery → websearch_to_tsquery,
which was wrong: websearch_to_tsquery treats `-` as a NEGATION operator
(Google-style), so `task-five` becomes `task & !five` and the search
returns 0 rows for the same reason as before. This commit reverts the
swap and applies the actual fix.

PG's english parser indexes `task-five-distinctive` as
`{task-five-distinct, task, five, distinct}` — the asciihword AND its
parts. plainto_tsquery applied to the partial query `task-five`
produces `task-fiv & task & five`: the stemmed asciihword for the
PARTIAL query (`task-fiv`) is NOT in the vector, so the AND fails.

Replacing the hyphen with a space makes plainto emit `task & five`,
which DOES match — but doing that unconditionally breaks `BUG-842`-
style queries: PG indexes the `-842` suffix as a negative-number
lexeme, so `plainto_tsquery('BUG-842')` matches via `-842`, while
`plainto_tsquery('BUG 842')` searches for `842` and misses.

The fix ORs the two query variants together so the search vector is
matched against either the raw user query OR its hyphen-as-space form.
Both `task-five` (against `task-five-distinctive`) and `BUG-842`
(against `BUG-842 fix the cleanup race`) hit. Verified locally against
postgres:17-alpine via PAD_TEST_POSTGRES_URL — both 10x stress and
race-detector runs are green.

Surfaces:
  - dialect.go: FTSMatch / FTSSnippet / FTSRank now consume TWO
    placeholders each in the PG dialect.
  - items.go: listItemsFTS PG branch + SearchItems PG branch update
    args to pass (raw, sanitized) for every PG `?` placeholder.
  - search.go: SearchItems main / count / facets PG branches updated
    likewise. New sanitizePGFTSQuery helper alongside sanitizeFTSQuery.
  - documents.go: ListDocuments PG branch updated.

Tests:
  - TestListItems_FTS_HyphenatedSearchTerm extended with a `BUG-842`
    case to pin the OR-combined logic — naive hyphen-stripping would
    silently regress this.
  - New TestSanitizePGFTSQuery unit test.

* chore: gofmt 11 files with import-order issues (BUG-842 PR cleanup)

The Go (SQLite) CI job has been failing on `main` (and every PR built
against it) because golangci-lint flags 11 files whose third-party
imports are intermixed with internal imports — the import-grouping
rule that gofmt enforces. None of these were introduced by the
BUG-842 PR; they're pre-existing on main. The PR can't go green
without this cleanup, though, so it's bundled here.

Pure mechanical change — `gofmt -w <files>` only re-orders import
groups; no logic changes. Files touched:

  cmd/pad/configure.go
  cmd/pad/main.go
  internal/cli/format.go
  internal/server/handlers_admin_invitations.go
  internal/server/handlers_admin_users.go
  internal/server/handlers_grants.go
  internal/server/handlers_share_links.go
  internal/server/handlers_stars.go
  internal/server/middleware_auth.go
  internal/store/store.go
  internal/store/store_test.go

After this commit `gofmt -l ./cmd ./internal` returns clean.
2026-04-28 16:21:43 -04:00

388 lines
9.8 KiB
Go

package cli
import (
"encoding/json"
"fmt"
"os"
"sort"
"strings"
"text/tabwriter"
"time"
"github.com/PerpetualSoftware/pad/internal/models"
"github.com/fatih/color"
)
// 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()
}
}
}