diff --git a/internal/web/fetcher.go b/internal/web/fetcher.go index 99c8a8f7..b06e38e7 100644 --- a/internal/web/fetcher.go +++ b/internal/web/fetcher.go @@ -710,3 +710,46 @@ func (f *LiveConvoyFetcher) getRefineryStatusHint(mergeQueueCount int) string { } return fmt.Sprintf("Processing %d PRs", mergeQueueCount) } + +// truncateStatusHint truncates a status hint to 60 characters with ellipsis. +func truncateStatusHint(line string) string { + if len(line) > 60 { + return line[:57] + "..." + } + return line +} + +// parsePolecatSessionName parses a tmux session name into rig and polecat components. +// Format: gt-- -> (rig, polecat, true) +// Returns ("", "", false) if the format is invalid. +func parsePolecatSessionName(sessionName string) (rig, polecat string, ok bool) { + if !strings.HasPrefix(sessionName, "gt-") { + return "", "", false + } + parts := strings.SplitN(sessionName, "-", 3) + if len(parts) != 3 { + return "", "", false + } + return parts[1], parts[2], true +} + +// isWorkerSession returns true if the polecat name represents a worker session. +// Non-worker sessions: witness, mayor, deacon, boot +func isWorkerSession(polecat string) bool { + switch polecat { + case "witness", "mayor", "deacon", "boot": + return false + default: + return true + } +} + +// parseActivityTimestamp parses a Unix timestamp string from tmux. +// Returns (0, false) for invalid or zero timestamps. +func parseActivityTimestamp(s string) (int64, bool) { + var unix int64 + if _, err := fmt.Sscanf(s, "%d", &unix); err != nil || unix <= 0 { + return 0, false + } + return unix, true +} diff --git a/internal/web/fetcher_test.go b/internal/web/fetcher_test.go new file mode 100644 index 00000000..07976e25 --- /dev/null +++ b/internal/web/fetcher_test.go @@ -0,0 +1,417 @@ +package web + +import ( + "testing" + + "github.com/steveyegge/gastown/internal/activity" +) + +func TestCalculateWorkStatus(t *testing.T) { + tests := []struct { + name string + completed int + total int + activityColor string + want string + }{ + { + name: "complete when all done", + completed: 5, + total: 5, + activityColor: activity.ColorGreen, + want: "complete", + }, + { + name: "complete overrides activity color", + completed: 3, + total: 3, + activityColor: activity.ColorRed, + want: "complete", + }, + { + name: "active when green", + completed: 2, + total: 5, + activityColor: activity.ColorGreen, + want: "active", + }, + { + name: "stale when yellow", + completed: 2, + total: 5, + activityColor: activity.ColorYellow, + want: "stale", + }, + { + name: "stuck when red", + completed: 2, + total: 5, + activityColor: activity.ColorRed, + want: "stuck", + }, + { + name: "waiting when unknown color", + completed: 2, + total: 5, + activityColor: activity.ColorUnknown, + want: "waiting", + }, + { + name: "waiting when empty color", + completed: 0, + total: 5, + activityColor: "", + want: "waiting", + }, + { + name: "waiting when no work yet", + completed: 0, + total: 0, + activityColor: activity.ColorUnknown, + want: "waiting", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := calculateWorkStatus(tt.completed, tt.total, tt.activityColor) + if got != tt.want { + t.Errorf("calculateWorkStatus(%d, %d, %q) = %q, want %q", + tt.completed, tt.total, tt.activityColor, got, tt.want) + } + }) + } +} + +func TestDetermineCIStatus(t *testing.T) { + tests := []struct { + name string + checks []struct { + State string `json:"state"` + Status string `json:"status"` + Conclusion string `json:"conclusion"` + } + want string + }{ + { + name: "pending when no checks", + checks: nil, + want: "pending", + }, + { + name: "pass when all success", + checks: []struct { + State string `json:"state"` + Status string `json:"status"` + Conclusion string `json:"conclusion"` + }{ + {Conclusion: "success"}, + {Conclusion: "success"}, + }, + want: "pass", + }, + { + name: "pass with skipped checks", + checks: []struct { + State string `json:"state"` + Status string `json:"status"` + Conclusion string `json:"conclusion"` + }{ + {Conclusion: "success"}, + {Conclusion: "skipped"}, + }, + want: "pass", + }, + { + name: "fail when any failure", + checks: []struct { + State string `json:"state"` + Status string `json:"status"` + Conclusion string `json:"conclusion"` + }{ + {Conclusion: "success"}, + {Conclusion: "failure"}, + }, + want: "fail", + }, + { + name: "fail when cancelled", + checks: []struct { + State string `json:"state"` + Status string `json:"status"` + Conclusion string `json:"conclusion"` + }{ + {Conclusion: "cancelled"}, + }, + want: "fail", + }, + { + name: "fail when timed_out", + checks: []struct { + State string `json:"state"` + Status string `json:"status"` + Conclusion string `json:"conclusion"` + }{ + {Conclusion: "timed_out"}, + }, + want: "fail", + }, + { + name: "pending when in_progress", + checks: []struct { + State string `json:"state"` + Status string `json:"status"` + Conclusion string `json:"conclusion"` + }{ + {Conclusion: "success"}, + {Status: "in_progress"}, + }, + want: "pending", + }, + { + name: "pending when queued", + checks: []struct { + State string `json:"state"` + Status string `json:"status"` + Conclusion string `json:"conclusion"` + }{ + {Status: "queued"}, + }, + want: "pending", + }, + { + name: "fail from state FAILURE", + checks: []struct { + State string `json:"state"` + Status string `json:"status"` + Conclusion string `json:"conclusion"` + }{ + {State: "FAILURE"}, + }, + want: "fail", + }, + { + name: "pending from state PENDING", + checks: []struct { + State string `json:"state"` + Status string `json:"status"` + Conclusion string `json:"conclusion"` + }{ + {State: "PENDING"}, + }, + want: "pending", + }, + { + name: "failure takes precedence over pending", + checks: []struct { + State string `json:"state"` + Status string `json:"status"` + Conclusion string `json:"conclusion"` + }{ + {Conclusion: "failure"}, + {Status: "in_progress"}, + }, + want: "fail", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := determineCIStatus(tt.checks) + if got != tt.want { + t.Errorf("determineCIStatus() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestDetermineMergeableStatus(t *testing.T) { + tests := []struct { + name string + mergeable string + want string + }{ + {"ready when MERGEABLE", "MERGEABLE", "ready"}, + {"ready when lowercase mergeable", "mergeable", "ready"}, + {"conflict when CONFLICTING", "CONFLICTING", "conflict"}, + {"conflict when lowercase conflicting", "conflicting", "conflict"}, + {"pending when UNKNOWN", "UNKNOWN", "pending"}, + {"pending when empty", "", "pending"}, + {"pending when other value", "something_else", "pending"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := determineMergeableStatus(tt.mergeable) + if got != tt.want { + t.Errorf("determineMergeableStatus(%q) = %q, want %q", + tt.mergeable, got, tt.want) + } + }) + } +} + +func TestDetermineColorClass(t *testing.T) { + tests := []struct { + name string + ciStatus string + mergeable string + want string + }{ + {"green when pass and ready", "pass", "ready", "mq-green"}, + {"red when CI fails", "fail", "ready", "mq-red"}, + {"red when conflict", "pass", "conflict", "mq-red"}, + {"red when both fail and conflict", "fail", "conflict", "mq-red"}, + {"yellow when CI pending", "pending", "ready", "mq-yellow"}, + {"yellow when merge pending", "pass", "pending", "mq-yellow"}, + {"yellow when both pending", "pending", "pending", "mq-yellow"}, + {"yellow for unknown states", "unknown", "unknown", "mq-yellow"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := determineColorClass(tt.ciStatus, tt.mergeable) + if got != tt.want { + t.Errorf("determineColorClass(%q, %q) = %q, want %q", + tt.ciStatus, tt.mergeable, got, tt.want) + } + }) + } +} + +func TestGetRefineryStatusHint(t *testing.T) { + // Create a minimal fetcher for testing + f := &LiveConvoyFetcher{} + + tests := []struct { + name string + mergeQueueCount int + want string + }{ + {"idle when no PRs", 0, "Idle - Waiting for PRs"}, + {"singular PR", 1, "Processing 1 PR"}, + {"multiple PRs", 2, "Processing 2 PRs"}, + {"many PRs", 10, "Processing 10 PRs"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := f.getRefineryStatusHint(tt.mergeQueueCount) + if got != tt.want { + t.Errorf("getRefineryStatusHint(%d) = %q, want %q", + tt.mergeQueueCount, got, tt.want) + } + }) + } +} + +func TestTruncateStatusHint(t *testing.T) { + tests := []struct { + name string + input string + want string + }{ + {"short line unchanged", "Hello world", "Hello world"}, + {"exactly 60 chars unchanged", "123456789012345678901234567890123456789012345678901234567890", "123456789012345678901234567890123456789012345678901234567890"}, + {"61 chars truncated", "1234567890123456789012345678901234567890123456789012345678901", "123456789012345678901234567890123456789012345678901234567..."}, + {"long line truncated", "This is a very long line that should be truncated because it exceeds sixty characters", "This is a very long line that should be truncated because..."}, + {"empty string", "", ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := truncateStatusHint(tt.input) + if got != tt.want { + t.Errorf("truncateStatusHint(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} + +func TestParsePolecatSessionName(t *testing.T) { + tests := []struct { + name string + sessionName string + wantRig string + wantPolecat string + wantOk bool + }{ + {"valid polecat session", "gt-roxas-dag", "roxas", "dag", true}, + {"valid polecat with hyphen", "gt-gas-town-nux", "gas", "town-nux", true}, + {"refinery session", "gt-roxas-refinery", "roxas", "refinery", true}, + {"witness session", "gt-gastown-witness", "gastown", "witness", true}, + {"not gt prefix", "other-roxas-dag", "", "", false}, + {"too few parts", "gt-roxas", "", "", false}, + {"empty string", "", "", "", false}, + {"single gt", "gt", "", "", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rig, polecat, ok := parsePolecatSessionName(tt.sessionName) + if ok != tt.wantOk { + t.Errorf("parsePolecatSessionName(%q) ok = %v, want %v", + tt.sessionName, ok, tt.wantOk) + } + if ok && (rig != tt.wantRig || polecat != tt.wantPolecat) { + t.Errorf("parsePolecatSessionName(%q) = (%q, %q), want (%q, %q)", + tt.sessionName, rig, polecat, tt.wantRig, tt.wantPolecat) + } + }) + } +} + +func TestIsWorkerSession(t *testing.T) { + tests := []struct { + name string + polecat string + wantWork bool + }{ + {"polecat dag is worker", "dag", true}, + {"polecat nux is worker", "nux", true}, + {"refinery is worker", "refinery", true}, + {"witness is not worker", "witness", false}, + {"mayor is not worker", "mayor", false}, + {"deacon is not worker", "deacon", false}, + {"boot is not worker", "boot", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := isWorkerSession(tt.polecat) + if got != tt.wantWork { + t.Errorf("isWorkerSession(%q) = %v, want %v", + tt.polecat, got, tt.wantWork) + } + }) + } +} + +func TestParseActivityTimestamp(t *testing.T) { + tests := []struct { + name string + input string + wantUnix int64 + wantValid bool + }{ + {"valid timestamp", "1704312345", 1704312345, true}, + {"zero timestamp", "0", 0, false}, + {"empty string", "", 0, false}, + {"invalid string", "abc", 0, false}, + {"negative", "-123", 0, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + unix, valid := parseActivityTimestamp(tt.input) + if valid != tt.wantValid { + t.Errorf("parseActivityTimestamp(%q) valid = %v, want %v", + tt.input, valid, tt.wantValid) + } + if valid && unix != tt.wantUnix { + t.Errorf("parseActivityTimestamp(%q) = %d, want %d", + tt.input, unix, tt.wantUnix) + } + }) + } +}