mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-22 10:33:27 +00:00
be576d9e24
* feat: agent roles — role-based (user, role) assignment for items (#PHASE-9) Introduce agent roles as a first-class concept for human-agent work assignment. Roles describe capability specializations (Planner, Implementer, Reviewer, etc.) and items can be assigned to a (user, role) pair, enabling natural handoff workflows between different AI tools. Migration: - New `agent_roles` table (workspace-scoped, slug-unique) - `assigned_user_id` + `agent_role_id` columns on `items` with FKs - Removed legacy `assignee` text field from Tasks schema Backend: - AgentRole model + full CRUD store/API - All item queries updated with LEFT JOINs to resolve assignment - Item list filtering by assigned_user_id and agent_role_id - Role transitions tracked in activity feed metadata CLI: - `pad role list/create/delete` commands - `--role` and `--assign` flags on item create/update/list - Assignment displayed in `pad item show` output Web: - TypeScript types + API client for agent roles - Role badge on item cards in list/board views - Assignment display on item detail page * fix: enforce workspace-scoped assignments and fail fast on unresolved --assign filter Addresses code review feedback from PR #58: P1: Add validateAssignmentScope() to the store layer, called by both CreateItem and UpdateItem. Verifies that assigned_user_id belongs to the workspace (via IsWorkspaceMember) and agent_role_id exists in the workspace (via GetAgentRole) before writing. Prevents cross-workspace assignment leaks. P2: The CLI `pad item list --assign <name>` now errors instead of silently returning unfiltered results when the member lookup fails or no workspace member matches the provided name.
397 lines
9.8 KiB
Go
397 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)
|
|
titlePart := item.Title
|
|
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)
|
|
}
|
|
// 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()
|
|
}
|
|
}
|
|
}
|
|
|
|
func stripHTMLTags(s string) string {
|
|
var result strings.Builder
|
|
inTag := false
|
|
for _, r := range s {
|
|
if r == '<' {
|
|
inTag = true
|
|
continue
|
|
}
|
|
if r == '>' {
|
|
inTag = false
|
|
continue
|
|
}
|
|
if !inTag {
|
|
result.WriteRune(r)
|
|
}
|
|
}
|
|
return result.String()
|
|
}
|