feat(polecat): add identity show command with CV summary
Add `gt polecat identity show <rig> <polecat>` command that displays: - Identity bead ID and creation date - Session count - Completion statistics (completed, failed, abandoned) - Language breakdown from file extensions in git history - Work type breakdown (feat, fix, refactor, etc.) - Recent work list with relative timestamps - First-pass success rate Supports --json flag for programmatic output. Closes: hq-d17es.4 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -4,7 +4,14 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"github.com/steveyegge/gastown/internal/beads"
|
"github.com/steveyegge/gastown/internal/beads"
|
||||||
"github.com/steveyegge/gastown/internal/git"
|
"github.com/steveyegge/gastown/internal/git"
|
||||||
@@ -72,15 +79,18 @@ Example:
|
|||||||
|
|
||||||
var polecatIdentityShowCmd = &cobra.Command{
|
var polecatIdentityShowCmd = &cobra.Command{
|
||||||
Use: "show <rig> <name>",
|
Use: "show <rig> <name>",
|
||||||
Short: "Show identity bead details and CV summary",
|
Short: "Show polecat identity with CV summary",
|
||||||
Long: `Show detailed identity bead information for a polecat.
|
Long: `Show detailed identity information for a polecat including work history.
|
||||||
|
|
||||||
Displays:
|
Displays:
|
||||||
- Identity bead fields
|
- Identity bead ID and creation date
|
||||||
- CV history (past work)
|
- Session count
|
||||||
- Current hook bead details
|
- Completion statistics (issues completed, failed, abandoned)
|
||||||
|
- Language breakdown from file extensions
|
||||||
|
- Work type breakdown (feat, fix, refactor, etc.)
|
||||||
|
- Recent work list with relative timestamps
|
||||||
|
|
||||||
Example:
|
Examples:
|
||||||
gt polecat identity show gastown Toast
|
gt polecat identity show gastown Toast
|
||||||
gt polecat identity show gastown Toast --json`,
|
gt polecat identity show gastown Toast --json`,
|
||||||
Args: cobra.ExactArgs(2),
|
Args: cobra.ExactArgs(2),
|
||||||
@@ -160,6 +170,40 @@ type IdentityInfo struct {
|
|||||||
SessionRunning bool `json:"session_running"`
|
SessionRunning bool `json:"session_running"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IdentityDetails holds detailed identity information for show command.
|
||||||
|
type IdentityDetails struct {
|
||||||
|
IdentityInfo
|
||||||
|
Title string `json:"title"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
CreatedAt string `json:"created_at,omitempty"`
|
||||||
|
UpdatedAt string `json:"updated_at,omitempty"`
|
||||||
|
CVBeads []string `json:"cv_beads,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CVSummary represents the CV/work history summary for a polecat.
|
||||||
|
type CVSummary struct {
|
||||||
|
Identity string `json:"identity"`
|
||||||
|
Created string `json:"created,omitempty"`
|
||||||
|
Sessions int `json:"sessions"`
|
||||||
|
IssuesCompleted int `json:"issues_completed"`
|
||||||
|
IssuesFailed int `json:"issues_failed"`
|
||||||
|
IssuesAbandoned int `json:"issues_abandoned"`
|
||||||
|
Languages map[string]int `json:"languages,omitempty"`
|
||||||
|
WorkTypes map[string]int `json:"work_types,omitempty"`
|
||||||
|
AvgCompletionMin int `json:"avg_completion_minutes,omitempty"`
|
||||||
|
FirstPassRate float64 `json:"first_pass_rate,omitempty"`
|
||||||
|
RecentWork []RecentWorkItem `json:"recent_work,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecentWorkItem represents a recent work item in the CV.
|
||||||
|
type RecentWorkItem struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Type string `json:"type,omitempty"`
|
||||||
|
Completed string `json:"completed"`
|
||||||
|
Ago string `json:"ago"`
|
||||||
|
}
|
||||||
|
|
||||||
func runPolecatIdentityAdd(cmd *cobra.Command, args []string) error {
|
func runPolecatIdentityAdd(cmd *cobra.Command, args []string) error {
|
||||||
rigName := args[0]
|
rigName := args[0]
|
||||||
var polecatName string
|
var polecatName string
|
||||||
@@ -328,16 +372,6 @@ func runPolecatIdentityList(cmd *cobra.Command, args []string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// IdentityDetails holds detailed identity information for show command.
|
|
||||||
type IdentityDetails struct {
|
|
||||||
IdentityInfo
|
|
||||||
Title string `json:"title"`
|
|
||||||
Description string `json:"description,omitempty"`
|
|
||||||
CreatedAt string `json:"created_at,omitempty"`
|
|
||||||
UpdatedAt string `json:"updated_at,omitempty"`
|
|
||||||
CVBeads []string `json:"cv_beads,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func runPolecatIdentityShow(cmd *cobra.Command, args []string) error {
|
func runPolecatIdentityShow(cmd *cobra.Command, args []string) error {
|
||||||
rigName := args[0]
|
rigName := args[0]
|
||||||
polecatName := args[1]
|
polecatName := args[1]
|
||||||
@@ -365,13 +399,29 @@ func runPolecatIdentityShow(cmd *cobra.Command, args []string) error {
|
|||||||
mgr := polecat.NewManager(r, nil)
|
mgr := polecat.NewManager(r, nil)
|
||||||
|
|
||||||
worktreeExists := false
|
worktreeExists := false
|
||||||
|
var clonePath string
|
||||||
if p, err := mgr.Get(polecatName); err == nil && p != nil {
|
if p, err := mgr.Get(polecatName); err == nil && p != nil {
|
||||||
worktreeExists = true
|
worktreeExists = true
|
||||||
|
clonePath = p.ClonePath
|
||||||
}
|
}
|
||||||
sessionRunning, _ := polecatMgr.IsRunning(polecatName)
|
sessionRunning, _ := polecatMgr.IsRunning(polecatName)
|
||||||
|
|
||||||
// Build details
|
// Build CV summary with enhanced analytics
|
||||||
details := IdentityDetails{
|
cv, err := buildCVSummary(r.Path, rigName, polecatName, beadID, clonePath)
|
||||||
|
if err != nil {
|
||||||
|
// Continue without CV if there's an error
|
||||||
|
cv = &CVSummary{Identity: beadID}
|
||||||
|
}
|
||||||
|
|
||||||
|
// JSON output - include both identity details and CV
|
||||||
|
if polecatIdentityShowJSON {
|
||||||
|
output := struct {
|
||||||
|
IdentityInfo
|
||||||
|
Title string `json:"title"`
|
||||||
|
CreatedAt string `json:"created_at,omitempty"`
|
||||||
|
UpdatedAt string `json:"updated_at,omitempty"`
|
||||||
|
CV *CVSummary `json:"cv,omitempty"`
|
||||||
|
}{
|
||||||
IdentityInfo: IdentityInfo{
|
IdentityInfo: IdentityInfo{
|
||||||
Rig: rigName,
|
Rig: rigName,
|
||||||
Name: polecatName,
|
Name: polecatName,
|
||||||
@@ -385,49 +435,36 @@ func runPolecatIdentityShow(cmd *cobra.Command, args []string) error {
|
|||||||
Title: issue.Title,
|
Title: issue.Title,
|
||||||
CreatedAt: issue.CreatedAt,
|
CreatedAt: issue.CreatedAt,
|
||||||
UpdatedAt: issue.UpdatedAt,
|
UpdatedAt: issue.UpdatedAt,
|
||||||
|
CV: cv,
|
||||||
}
|
}
|
||||||
if details.HookBead == "" {
|
if output.HookBead == "" {
|
||||||
details.HookBead = fields.HookBead
|
output.HookBead = fields.HookBead
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get CV beads (work history) - beads that were assigned to this polecat
|
|
||||||
// Assignee format is "rig/name" (e.g., "gastown/Toast")
|
|
||||||
assignee := fmt.Sprintf("%s/%s", rigName, polecatName)
|
|
||||||
cvBeads, _ := bd.ListByAssignee(assignee)
|
|
||||||
for _, cv := range cvBeads {
|
|
||||||
if cv.ID != beadID && cv.Status == "closed" {
|
|
||||||
details.CVBeads = append(details.CVBeads, cv.ID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// JSON output
|
|
||||||
if polecatIdentityShowJSON {
|
|
||||||
enc := json.NewEncoder(os.Stdout)
|
enc := json.NewEncoder(os.Stdout)
|
||||||
enc.SetIndent("", " ")
|
enc.SetIndent("", " ")
|
||||||
return enc.Encode(details)
|
return enc.Encode(output)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Human-readable output
|
// Human-readable output
|
||||||
fmt.Printf("%s\n\n", style.Bold.Render(fmt.Sprintf("Identity: %s/%s", rigName, polecatName)))
|
fmt.Printf("\n%s %s/%s\n", style.Bold.Render("Identity:"), rigName, polecatName)
|
||||||
|
fmt.Printf(" Bead ID: %s\n", beadID)
|
||||||
fmt.Printf(" Bead ID: %s\n", details.BeadID)
|
fmt.Printf(" Title: %s\n", issue.Title)
|
||||||
fmt.Printf(" Title: %s\n", details.Title)
|
|
||||||
|
|
||||||
// Status
|
// Status
|
||||||
sessionStr := style.Dim.Render("stopped")
|
sessionStr := style.Dim.Render("stopped")
|
||||||
if details.SessionRunning {
|
if sessionRunning {
|
||||||
sessionStr = style.Success.Render("running")
|
sessionStr = style.Success.Render("running")
|
||||||
}
|
}
|
||||||
fmt.Printf(" Session: %s\n", sessionStr)
|
fmt.Printf(" Session: %s\n", sessionStr)
|
||||||
|
|
||||||
worktreeStr := style.Dim.Render("no")
|
worktreeStr := style.Dim.Render("no")
|
||||||
if details.WorktreeExists {
|
if worktreeExists {
|
||||||
worktreeStr = style.Success.Render("yes")
|
worktreeStr = style.Success.Render("yes")
|
||||||
}
|
}
|
||||||
fmt.Printf(" Worktree: %s\n", worktreeStr)
|
fmt.Printf(" Worktree: %s\n", worktreeStr)
|
||||||
|
|
||||||
// Agent state
|
// Agent state
|
||||||
stateStr := details.AgentState
|
stateStr := fields.AgentState
|
||||||
if stateStr == "" {
|
if stateStr == "" {
|
||||||
stateStr = "unknown"
|
stateStr = "unknown"
|
||||||
}
|
}
|
||||||
@@ -444,36 +481,71 @@ func runPolecatIdentityShow(cmd *cobra.Command, args []string) error {
|
|||||||
fmt.Printf(" Agent State: %s\n", stateStr)
|
fmt.Printf(" Agent State: %s\n", stateStr)
|
||||||
|
|
||||||
// Hook
|
// Hook
|
||||||
if details.HookBead != "" {
|
hookBead := issue.HookBead
|
||||||
fmt.Printf(" Hook: %s\n", details.HookBead)
|
if hookBead == "" {
|
||||||
|
hookBead = fields.HookBead
|
||||||
|
}
|
||||||
|
if hookBead != "" {
|
||||||
|
fmt.Printf(" Hook: %s\n", hookBead)
|
||||||
} else {
|
} else {
|
||||||
fmt.Printf(" Hook: %s\n", style.Dim.Render("(empty)"))
|
fmt.Printf(" Hook: %s\n", style.Dim.Render("(empty)"))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cleanup status
|
// Cleanup status
|
||||||
if details.CleanupStatus != "" {
|
if fields.CleanupStatus != "" {
|
||||||
fmt.Printf(" Cleanup: %s\n", details.CleanupStatus)
|
fmt.Printf(" Cleanup: %s\n", fields.CleanupStatus)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Timestamps
|
// Timestamps
|
||||||
if details.CreatedAt != "" {
|
if issue.CreatedAt != "" {
|
||||||
fmt.Printf(" Created: %s\n", style.Dim.Render(details.CreatedAt))
|
fmt.Printf(" Created: %s\n", style.Dim.Render(issue.CreatedAt))
|
||||||
|
}
|
||||||
|
if issue.UpdatedAt != "" {
|
||||||
|
fmt.Printf(" Updated: %s\n", style.Dim.Render(issue.UpdatedAt))
|
||||||
|
}
|
||||||
|
|
||||||
|
// CV Summary section with enhanced analytics
|
||||||
|
fmt.Printf("\n%s\n", style.Bold.Render("CV Summary:"))
|
||||||
|
fmt.Printf(" Sessions: %d\n", cv.Sessions)
|
||||||
|
fmt.Printf(" Issues completed: %s\n", style.Success.Render(fmt.Sprintf("%d", cv.IssuesCompleted)))
|
||||||
|
fmt.Printf(" Issues failed: %s\n", formatCountStyled(cv.IssuesFailed, style.Error))
|
||||||
|
fmt.Printf(" Issues abandoned: %s\n", formatCountStyled(cv.IssuesAbandoned, style.Warning))
|
||||||
|
|
||||||
|
// Language stats
|
||||||
|
if len(cv.Languages) > 0 {
|
||||||
|
fmt.Printf("\n %s %s\n", style.Bold.Render("Languages:"), formatLanguageStats(cv.Languages))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Work type stats
|
||||||
|
if len(cv.WorkTypes) > 0 {
|
||||||
|
fmt.Printf(" %s %s\n", style.Bold.Render("Types:"), formatWorkTypeStats(cv.WorkTypes))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Performance metrics
|
||||||
|
if cv.AvgCompletionMin > 0 {
|
||||||
|
fmt.Printf("\n Avg completion time: %d minutes\n", cv.AvgCompletionMin)
|
||||||
|
}
|
||||||
|
if cv.FirstPassRate > 0 {
|
||||||
|
fmt.Printf(" First-pass success: %.0f%%\n", cv.FirstPassRate*100)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recent work
|
||||||
|
if len(cv.RecentWork) > 0 {
|
||||||
|
fmt.Printf("\n%s\n", style.Bold.Render("Recent work:"))
|
||||||
|
for _, work := range cv.RecentWork {
|
||||||
|
typeStr := ""
|
||||||
|
if work.Type != "" {
|
||||||
|
typeStr = work.Type + ": "
|
||||||
|
}
|
||||||
|
title := work.Title
|
||||||
|
if len(title) > 40 {
|
||||||
|
title = title[:37] + "..."
|
||||||
|
}
|
||||||
|
fmt.Printf(" %-10s %s%s %s\n", work.ID, typeStr, title, style.Dim.Render(work.Ago))
|
||||||
}
|
}
|
||||||
if details.UpdatedAt != "" {
|
|
||||||
fmt.Printf(" Updated: %s\n", style.Dim.Render(details.UpdatedAt))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// CV summary
|
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
fmt.Printf("%s\n", style.Bold.Render("CV (Work History)"))
|
|
||||||
if len(details.CVBeads) == 0 {
|
|
||||||
fmt.Printf(" %s\n", style.Dim.Render("(no completed work)"))
|
|
||||||
} else {
|
|
||||||
for _, cv := range details.CVBeads {
|
|
||||||
fmt.Printf(" - %s\n", cv)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -633,3 +705,373 @@ func runPolecatIdentityRemove(cmd *cobra.Command, args []string) error {
|
|||||||
fmt.Printf("%s Removed identity bead: %s\n", style.SuccessPrefix, beadID)
|
fmt.Printf("%s Removed identity bead: %s\n", style.SuccessPrefix, beadID)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// buildCVSummary constructs the CV summary for a polecat.
|
||||||
|
func buildCVSummary(rigPath, rigName, polecatName, identityBeadID, clonePath string) (*CVSummary, error) {
|
||||||
|
cv := &CVSummary{
|
||||||
|
Identity: identityBeadID,
|
||||||
|
Languages: make(map[string]int),
|
||||||
|
WorkTypes: make(map[string]int),
|
||||||
|
RecentWork: []RecentWorkItem{},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use clonePath for beads queries (has proper redirect setup)
|
||||||
|
// Fall back to rigPath if clonePath is empty
|
||||||
|
beadsQueryPath := clonePath
|
||||||
|
if beadsQueryPath == "" {
|
||||||
|
beadsQueryPath = rigPath
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get agent bead info for creation date
|
||||||
|
bd := beads.New(beadsQueryPath)
|
||||||
|
agentBead, _, err := bd.GetAgentBead(identityBeadID)
|
||||||
|
if err == nil && agentBead != nil {
|
||||||
|
if agentBead.CreatedAt != "" && len(agentBead.CreatedAt) >= 10 {
|
||||||
|
cv.Created = agentBead.CreatedAt[:10] // Just the date part
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count sessions from checkpoint files (session history)
|
||||||
|
cv.Sessions = countPolecatSessions(rigPath, polecatName)
|
||||||
|
|
||||||
|
// Query completed issues assigned to this polecat
|
||||||
|
assignee := fmt.Sprintf("%s/polecats/%s", rigName, polecatName)
|
||||||
|
completedIssues, err := queryAssignedIssues(beadsQueryPath, assignee, "closed")
|
||||||
|
if err == nil {
|
||||||
|
cv.IssuesCompleted = len(completedIssues)
|
||||||
|
|
||||||
|
// Extract work types from issue titles/types
|
||||||
|
for _, issue := range completedIssues {
|
||||||
|
workType := extractWorkType(issue.Title, issue.Type)
|
||||||
|
if workType != "" {
|
||||||
|
cv.WorkTypes[workType]++
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add to recent work (limit to 5)
|
||||||
|
if len(cv.RecentWork) < 5 {
|
||||||
|
ago := formatRelativeTimeCV(issue.Updated)
|
||||||
|
cv.RecentWork = append(cv.RecentWork, RecentWorkItem{
|
||||||
|
ID: issue.ID,
|
||||||
|
Title: issue.Title,
|
||||||
|
Type: workType,
|
||||||
|
Completed: issue.Updated,
|
||||||
|
Ago: ago,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Query failed/escalated issues
|
||||||
|
escalatedIssues, err := queryAssignedIssues(beadsQueryPath, assignee, "escalated")
|
||||||
|
if err == nil {
|
||||||
|
cv.IssuesFailed = len(escalatedIssues)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Query abandoned issues (deferred)
|
||||||
|
deferredIssues, err := queryAssignedIssues(beadsQueryPath, assignee, "deferred")
|
||||||
|
if err == nil {
|
||||||
|
cv.IssuesAbandoned = len(deferredIssues)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get language stats from git commits
|
||||||
|
if clonePath != "" {
|
||||||
|
langStats := getLanguageStats(clonePath)
|
||||||
|
if len(langStats) > 0 {
|
||||||
|
cv.Languages = langStats
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate first-pass success rate
|
||||||
|
total := cv.IssuesCompleted + cv.IssuesFailed + cv.IssuesAbandoned
|
||||||
|
if total > 0 {
|
||||||
|
cv.FirstPassRate = float64(cv.IssuesCompleted) / float64(total)
|
||||||
|
}
|
||||||
|
|
||||||
|
return cv, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IssueInfo holds basic issue information for CV queries.
|
||||||
|
type IssueInfo struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Type string `json:"issue_type"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Updated string `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// queryAssignedIssues queries beads for issues assigned to a specific agent.
|
||||||
|
func queryAssignedIssues(rigPath, assignee, status string) ([]IssueInfo, error) {
|
||||||
|
// Use bd list with filters
|
||||||
|
args := []string{"list", "--assignee=" + assignee, "--json"}
|
||||||
|
if status != "" {
|
||||||
|
args = append(args, "--status="+status)
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.Command("bd", args...)
|
||||||
|
cmd.Dir = rigPath
|
||||||
|
out, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(out) == 0 {
|
||||||
|
return []IssueInfo{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var issues []IssueInfo
|
||||||
|
if err := json.Unmarshal(out, &issues); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by updated date (most recent first)
|
||||||
|
sort.Slice(issues, func(i, j int) bool {
|
||||||
|
return issues[i].Updated > issues[j].Updated
|
||||||
|
})
|
||||||
|
|
||||||
|
return issues, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractWorkType extracts the work type from issue title or type.
|
||||||
|
func extractWorkType(title, issueType string) string {
|
||||||
|
// Check explicit issue type first
|
||||||
|
switch issueType {
|
||||||
|
case "bug":
|
||||||
|
return "fix"
|
||||||
|
case "task", "feature":
|
||||||
|
return "feat"
|
||||||
|
case "epic":
|
||||||
|
return "epic"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to extract from conventional commit-style title
|
||||||
|
title = strings.ToLower(title)
|
||||||
|
prefixes := []string{"feat:", "fix:", "refactor:", "docs:", "test:", "chore:", "style:", "perf:"}
|
||||||
|
for _, prefix := range prefixes {
|
||||||
|
if strings.HasPrefix(title, prefix) {
|
||||||
|
return strings.TrimSuffix(prefix, ":")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to infer from keywords
|
||||||
|
if strings.Contains(title, "fix") || strings.Contains(title, "bug") {
|
||||||
|
return "fix"
|
||||||
|
}
|
||||||
|
if strings.Contains(title, "add") || strings.Contains(title, "implement") || strings.Contains(title, "create") {
|
||||||
|
return "feat"
|
||||||
|
}
|
||||||
|
if strings.Contains(title, "refactor") || strings.Contains(title, "cleanup") {
|
||||||
|
return "refactor"
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// getLanguageStats analyzes git history to determine language distribution.
|
||||||
|
func getLanguageStats(clonePath string) map[string]int {
|
||||||
|
stats := make(map[string]int)
|
||||||
|
|
||||||
|
// Get list of files changed in commits by this author
|
||||||
|
// We use git log with --name-only to get file names
|
||||||
|
cmd := exec.Command("git", "log", "--name-only", "--pretty=format:", "--diff-filter=ACMR", "-100")
|
||||||
|
cmd.Dir = clonePath
|
||||||
|
out, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
return stats
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count file extensions
|
||||||
|
extCount := make(map[string]int)
|
||||||
|
for _, line := range strings.Split(string(out), "\n") {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ext := filepath.Ext(line)
|
||||||
|
if ext != "" {
|
||||||
|
extCount[ext]++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map extensions to languages
|
||||||
|
extToLang := map[string]string{
|
||||||
|
".go": "Go",
|
||||||
|
".ts": "TypeScript",
|
||||||
|
".tsx": "TypeScript",
|
||||||
|
".js": "JavaScript",
|
||||||
|
".jsx": "JavaScript",
|
||||||
|
".py": "Python",
|
||||||
|
".rs": "Rust",
|
||||||
|
".java": "Java",
|
||||||
|
".rb": "Ruby",
|
||||||
|
".c": "C",
|
||||||
|
".cpp": "C++",
|
||||||
|
".h": "C",
|
||||||
|
".hpp": "C++",
|
||||||
|
".cs": "C#",
|
||||||
|
".swift": "Swift",
|
||||||
|
".kt": "Kotlin",
|
||||||
|
".scala": "Scala",
|
||||||
|
".php": "PHP",
|
||||||
|
".sh": "Shell",
|
||||||
|
".bash": "Shell",
|
||||||
|
".zsh": "Shell",
|
||||||
|
".md": "Markdown",
|
||||||
|
".yaml": "YAML",
|
||||||
|
".yml": "YAML",
|
||||||
|
".json": "JSON",
|
||||||
|
".toml": "TOML",
|
||||||
|
".sql": "SQL",
|
||||||
|
".html": "HTML",
|
||||||
|
".css": "CSS",
|
||||||
|
".scss": "SCSS",
|
||||||
|
}
|
||||||
|
|
||||||
|
for ext, count := range extCount {
|
||||||
|
if lang, ok := extToLang[ext]; ok {
|
||||||
|
stats[lang] += count
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return stats
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatRelativeTimeCV returns a human-readable relative time string for CV display.
|
||||||
|
func formatRelativeTimeCV(timestamp string) string {
|
||||||
|
// Try RFC3339 format with timezone (ISO 8601)
|
||||||
|
t, err := time.Parse(time.RFC3339, timestamp)
|
||||||
|
if err != nil {
|
||||||
|
// Try RFC3339Nano
|
||||||
|
t, err = time.Parse(time.RFC3339Nano, timestamp)
|
||||||
|
if err != nil {
|
||||||
|
// Try without timezone
|
||||||
|
t, err = time.Parse("2006-01-02T15:04:05", timestamp)
|
||||||
|
if err != nil {
|
||||||
|
// Try alternative format
|
||||||
|
t, err = time.Parse("2006-01-02 15:04:05", timestamp)
|
||||||
|
if err != nil {
|
||||||
|
// Try date only
|
||||||
|
t, err = time.Parse("2006-01-02", timestamp)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
d := time.Since(t)
|
||||||
|
switch {
|
||||||
|
case d < time.Minute:
|
||||||
|
return "just now"
|
||||||
|
case d < time.Hour:
|
||||||
|
mins := int(d.Minutes())
|
||||||
|
if mins == 1 {
|
||||||
|
return "1m ago"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%dm ago", mins)
|
||||||
|
case d < 24*time.Hour:
|
||||||
|
hours := int(d.Hours())
|
||||||
|
if hours == 1 {
|
||||||
|
return "1h ago"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%dh ago", hours)
|
||||||
|
case d < 7*24*time.Hour:
|
||||||
|
days := int(d.Hours() / 24)
|
||||||
|
if days == 1 {
|
||||||
|
return "1d ago"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%dd ago", days)
|
||||||
|
default:
|
||||||
|
weeks := int(d.Hours() / 24 / 7)
|
||||||
|
if weeks == 1 {
|
||||||
|
return "1w ago"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%dw ago", weeks)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatCountStyled formats a count with appropriate styling using lipgloss.Style.
|
||||||
|
func formatCountStyled(count int, s lipgloss.Style) string {
|
||||||
|
if count == 0 {
|
||||||
|
return style.Dim.Render("0")
|
||||||
|
}
|
||||||
|
return s.Render(strconv.Itoa(count))
|
||||||
|
}
|
||||||
|
|
||||||
|
// countPolecatSessions counts the number of sessions from checkpoint files.
|
||||||
|
func countPolecatSessions(rigPath, polecatName string) int {
|
||||||
|
// Look for checkpoint files in the polecat's directory
|
||||||
|
checkpointDir := filepath.Join(rigPath, "polecats", polecatName, ".checkpoints")
|
||||||
|
entries, err := os.ReadDir(checkpointDir)
|
||||||
|
if err != nil {
|
||||||
|
// Also check at rig level
|
||||||
|
checkpointDir = filepath.Join(rigPath, ".checkpoints")
|
||||||
|
entries, err = os.ReadDir(checkpointDir)
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count checkpoint files that contain this polecat's name
|
||||||
|
count := 0
|
||||||
|
for _, entry := range entries {
|
||||||
|
if !entry.IsDir() && strings.Contains(entry.Name(), polecatName) {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no checkpoint files found, return at least 1 if polecat exists
|
||||||
|
if count == 0 {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatLanguageStats formats language statistics for display.
|
||||||
|
func formatLanguageStats(langs map[string]int) string {
|
||||||
|
// Sort by count descending
|
||||||
|
type langCount struct {
|
||||||
|
lang string
|
||||||
|
count int
|
||||||
|
}
|
||||||
|
var sorted []langCount
|
||||||
|
for lang, count := range langs {
|
||||||
|
sorted = append(sorted, langCount{lang, count})
|
||||||
|
}
|
||||||
|
sort.Slice(sorted, func(i, j int) bool {
|
||||||
|
return sorted[i].count > sorted[j].count
|
||||||
|
})
|
||||||
|
|
||||||
|
// Format top languages
|
||||||
|
var parts []string
|
||||||
|
for i, lc := range sorted {
|
||||||
|
if i >= 3 { // Show top 3
|
||||||
|
break
|
||||||
|
}
|
||||||
|
parts = append(parts, fmt.Sprintf("%s (%d)", lc.lang, lc.count))
|
||||||
|
}
|
||||||
|
return strings.Join(parts, ", ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatWorkTypeStats formats work type statistics for display.
|
||||||
|
func formatWorkTypeStats(types map[string]int) string {
|
||||||
|
// Sort by count descending
|
||||||
|
type typeCount struct {
|
||||||
|
typ string
|
||||||
|
count int
|
||||||
|
}
|
||||||
|
var sorted []typeCount
|
||||||
|
for typ, count := range types {
|
||||||
|
sorted = append(sorted, typeCount{typ, count})
|
||||||
|
}
|
||||||
|
sort.Slice(sorted, func(i, j int) bool {
|
||||||
|
return sorted[i].count > sorted[j].count
|
||||||
|
})
|
||||||
|
|
||||||
|
// Format all types
|
||||||
|
var parts []string
|
||||||
|
for _, tc := range sorted {
|
||||||
|
parts = append(parts, fmt.Sprintf("%s (%d)", tc.typ, tc.count))
|
||||||
|
}
|
||||||
|
return strings.Join(parts, ", ")
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user