diff --git a/README.md b/README.md index 7bc620be..11690047 100644 --- a/README.md +++ b/README.md @@ -260,6 +260,33 @@ gt doctor # Health check gt doctor --fix # Auto-repair ``` +## Dashboard + +Web-based dashboard for monitoring Gas Town activity. + +```bash +# Start the dashboard +gt dashboard --port 8080 + +# Open in browser +open http://localhost:8080 +``` + +**Features:** +- **Convoy tracking** - View all active convoys with progress bars and work status +- **Polecat workers** - See active worker sessions and their activity status +- **Refinery status** - Monitor merge queue and PR processing +- **Auto-refresh** - Updates every 10 seconds via htmx + +Work status indicators: +| Status | Color | Meaning | +|--------|-------|---------| +| `complete` | Green | All tracked items done | +| `active` | Green | Recent activity (< 1 min) | +| `stale` | Yellow | Activity 1-5 min ago | +| `stuck` | Red | Activity > 5 min ago | +| `waiting` | Gray | No assignee/activity | + ## Shell Completions Enable tab completion for `gt` commands: diff --git a/internal/cmd/dashboard.go b/internal/cmd/dashboard.go index b7189c9e..59af6124 100644 --- a/internal/cmd/dashboard.go +++ b/internal/cmd/dashboard.go @@ -76,6 +76,9 @@ func runDashboard(cmd *cobra.Command, args []string) error { Addr: fmt.Sprintf(":%d", dashboardPort), Handler: handler, ReadHeaderTimeout: 10 * time.Second, + ReadTimeout: 30 * time.Second, + WriteTimeout: 60 * time.Second, + IdleTimeout: 120 * time.Second, } return server.ListenAndServe() } diff --git a/internal/constants/constants.go b/internal/constants/constants.go index a777e1f4..ff2897d7 100644 --- a/internal/constants/constants.go +++ b/internal/constants/constants.go @@ -17,7 +17,9 @@ const ( ShellReadyTimeout = 5 * time.Second // DefaultDebounceMs is the default debounce for SendKeys operations. - DefaultDebounceMs = 100 + // 500ms is required for Claude Code to reliably process paste before Enter. + // See NudgeSession comment: "Wait 500ms for paste to complete (tested, required)" + DefaultDebounceMs = 500 // DefaultDisplayMs is the default duration for tmux display-message. DefaultDisplayMs = 5000 diff --git a/internal/web/browser_e2e_test.go b/internal/web/browser_e2e_test.go new file mode 100644 index 00000000..9accccfe --- /dev/null +++ b/internal/web/browser_e2e_test.go @@ -0,0 +1,397 @@ +//go:build browser + +package web + +import ( + "net/http/httptest" + "os" + "strings" + "testing" + "time" + + "github.com/go-rod/rod" + "github.com/go-rod/rod/lib/launcher" + "github.com/steveyegge/gastown/internal/activity" +) + +// ============================================================================= +// Browser-based E2E Tests using Rod +// +// These tests launch a real browser (Chromium) to verify the convoy dashboard +// works correctly in an actual browser environment. +// +// Run with: go test -tags=browser -v ./internal/web -run TestBrowser +// +// By default, tests run headless. Set BROWSER_VISIBLE=1 to watch: +// BROWSER_VISIBLE=1 go test -tags=browser -v ./internal/web -run TestBrowser +// +// ============================================================================= + +// browserTestConfig holds configuration for browser tests +type browserTestConfig struct { + headless bool + slowMo time.Duration +} + +// getBrowserConfig returns test configuration based on environment +func getBrowserConfig() browserTestConfig { + cfg := browserTestConfig{ + headless: true, + slowMo: 0, + } + + if os.Getenv("BROWSER_VISIBLE") == "1" { + cfg.headless = false + cfg.slowMo = 300 * time.Millisecond + } + + return cfg +} + +// launchBrowser creates a browser instance with the given configuration. +func launchBrowser(cfg browserTestConfig) (*rod.Browser, func()) { + l := launcher.New(). + NoSandbox(true). + Headless(cfg.headless) + + if !cfg.headless { + l = l.Devtools(false) + } + + u := l.MustLaunch() + browser := rod.New().ControlURL(u).MustConnect() + + if !cfg.headless { + browser = browser.SlowMotion(cfg.slowMo) + } + + cleanup := func() { + browser.MustClose() + l.Cleanup() + } + + return browser, cleanup +} + +// mockFetcher implements ConvoyFetcher for testing +type mockFetcher struct { + convoys []ConvoyRow +} + +func (m *mockFetcher) FetchConvoys() ([]ConvoyRow, error) { + return m.convoys, nil +} + +// TestBrowser_ConvoyListLoads tests that the convoy list page loads correctly +func TestBrowser_ConvoyListLoads(t *testing.T) { + // Setup test server with mock data + fetcher := &mockFetcher{ + convoys: []ConvoyRow{ + { + ID: "hq-cv-abc", + Title: "Feature X", + Status: "open", + Progress: "2/5", + Completed: 2, + Total: 5, + LastActivity: activity.Calculate(time.Now().Add(-1 * time.Minute)), + }, + { + ID: "hq-cv-def", + Title: "Bugfix Y", + Status: "closed", + Progress: "3/3", + Completed: 3, + Total: 3, + LastActivity: activity.Calculate(time.Now().Add(-10 * time.Minute)), + }, + }, + } + + handler, err := NewConvoyHandler(fetcher) + if err != nil { + t.Fatalf("Failed to create handler: %v", err) + } + + ts := httptest.NewServer(handler) + defer ts.Close() + + cfg := getBrowserConfig() + browser, cleanup := launchBrowser(cfg) + defer cleanup() + + page := browser.MustPage(ts.URL).Timeout(30 * time.Second) + defer page.MustClose() + + page.MustWaitLoad() + + // Verify page title + title := page.MustElement("title").MustText() + if !strings.Contains(title, "Gas Town") { + t.Fatalf("Expected title to contain 'Gas Town', got: %s", title) + } + + // Verify convoy IDs are displayed + bodyText := page.MustElement("body").MustText() + if !strings.Contains(bodyText, "hq-cv-abc") { + t.Error("Expected convoy ID hq-cv-abc in page") + } + if !strings.Contains(bodyText, "hq-cv-def") { + t.Error("Expected convoy ID hq-cv-def in page") + } + + // Verify titles are displayed + if !strings.Contains(bodyText, "Feature X") { + t.Error("Expected title 'Feature X' in page") + } + if !strings.Contains(bodyText, "Bugfix Y") { + t.Error("Expected title 'Bugfix Y' in page") + } + + t.Log("PASSED: Convoy list loads correctly") +} + +// TestBrowser_LastActivityColors tests that activity colors are displayed correctly +func TestBrowser_LastActivityColors(t *testing.T) { + // Setup test server with convoys at different activity ages + fetcher := &mockFetcher{ + convoys: []ConvoyRow{ + { + ID: "hq-cv-green", + Title: "Active Work", + Status: "open", + LastActivity: activity.Calculate(time.Now().Add(-1 * time.Minute)), // Green: <2min + }, + { + ID: "hq-cv-yellow", + Title: "Stale Work", + Status: "open", + LastActivity: activity.Calculate(time.Now().Add(-3 * time.Minute)), // Yellow: 2-5min + }, + { + ID: "hq-cv-red", + Title: "Stuck Work", + Status: "open", + LastActivity: activity.Calculate(time.Now().Add(-10 * time.Minute)), // Red: >5min + }, + }, + } + + handler, err := NewConvoyHandler(fetcher) + if err != nil { + t.Fatalf("Failed to create handler: %v", err) + } + + ts := httptest.NewServer(handler) + defer ts.Close() + + cfg := getBrowserConfig() + browser, cleanup := launchBrowser(cfg) + defer cleanup() + + page := browser.MustPage(ts.URL).Timeout(30 * time.Second) + defer page.MustClose() + + page.MustWaitLoad() + + // Check for activity color classes in the HTML + html := page.MustHTML() + + if !strings.Contains(html, "activity-green") { + t.Error("Expected activity-green class for recent activity") + } + if !strings.Contains(html, "activity-yellow") { + t.Error("Expected activity-yellow class for stale activity") + } + if !strings.Contains(html, "activity-red") { + t.Error("Expected activity-red class for stuck activity") + } + + t.Log("PASSED: Activity colors display correctly") +} + +// TestBrowser_HtmxAutoRefresh tests that htmx auto-refresh attributes are present +func TestBrowser_HtmxAutoRefresh(t *testing.T) { + fetcher := &mockFetcher{ + convoys: []ConvoyRow{ + { + ID: "hq-cv-test", + Title: "Test Convoy", + Status: "open", + }, + }, + } + + handler, err := NewConvoyHandler(fetcher) + if err != nil { + t.Fatalf("Failed to create handler: %v", err) + } + + ts := httptest.NewServer(handler) + defer ts.Close() + + cfg := getBrowserConfig() + browser, cleanup := launchBrowser(cfg) + defer cleanup() + + page := browser.MustPage(ts.URL).Timeout(30 * time.Second) + defer page.MustClose() + + page.MustWaitLoad() + + // Check for htmx attributes + html := page.MustHTML() + + if !strings.Contains(html, "hx-get") { + t.Error("Expected hx-get attribute for auto-refresh") + } + if !strings.Contains(html, "hx-trigger") { + t.Error("Expected hx-trigger attribute for auto-refresh") + } + if !strings.Contains(html, "every 30s") { + t.Error("Expected 'every 30s' trigger for auto-refresh") + } + + // Verify htmx library is loaded + if !strings.Contains(html, "htmx.org") { + t.Error("Expected htmx library to be loaded") + } + + t.Log("PASSED: htmx auto-refresh attributes present") +} + +// TestBrowser_EmptyState tests the empty state when no convoys exist +func TestBrowser_EmptyState(t *testing.T) { + fetcher := &mockFetcher{ + convoys: []ConvoyRow{}, // Empty convoy list + } + + handler, err := NewConvoyHandler(fetcher) + if err != nil { + t.Fatalf("Failed to create handler: %v", err) + } + + ts := httptest.NewServer(handler) + defer ts.Close() + + cfg := getBrowserConfig() + browser, cleanup := launchBrowser(cfg) + defer cleanup() + + page := browser.MustPage(ts.URL).Timeout(30 * time.Second) + defer page.MustClose() + + page.MustWaitLoad() + + // Check for empty state message + bodyText := page.MustElement("body").MustText() + + if !strings.Contains(bodyText, "No convoys") { + t.Errorf("Expected 'No convoys' empty state message, got: %s", bodyText[:min(len(bodyText), 500)]) + } + + // Verify help text is shown + if !strings.Contains(bodyText, "gt convoy create") { + t.Error("Expected help text with 'gt convoy create' command") + } + + t.Log("PASSED: Empty state displays correctly") +} + +// TestBrowser_StatusIndicators tests open/closed status indicators +func TestBrowser_StatusIndicators(t *testing.T) { + fetcher := &mockFetcher{ + convoys: []ConvoyRow{ + { + ID: "hq-cv-open", + Title: "Open Convoy", + Status: "open", + }, + { + ID: "hq-cv-closed", + Title: "Closed Convoy", + Status: "closed", + }, + }, + } + + handler, err := NewConvoyHandler(fetcher) + if err != nil { + t.Fatalf("Failed to create handler: %v", err) + } + + ts := httptest.NewServer(handler) + defer ts.Close() + + cfg := getBrowserConfig() + browser, cleanup := launchBrowser(cfg) + defer cleanup() + + page := browser.MustPage(ts.URL).Timeout(30 * time.Second) + defer page.MustClose() + + page.MustWaitLoad() + + html := page.MustHTML() + + // Check for status classes + if !strings.Contains(html, "status-open") { + t.Error("Expected status-open class for open convoy") + } + if !strings.Contains(html, "status-closed") { + t.Error("Expected status-closed class for closed convoy") + } + + t.Log("PASSED: Status indicators display correctly") +} + +// TestBrowser_ProgressDisplay tests progress bar rendering +func TestBrowser_ProgressDisplay(t *testing.T) { + fetcher := &mockFetcher{ + convoys: []ConvoyRow{ + { + ID: "hq-cv-progress", + Title: "Progress Convoy", + Status: "open", + Progress: "3/7", + Completed: 3, + Total: 7, + }, + }, + } + + handler, err := NewConvoyHandler(fetcher) + if err != nil { + t.Fatalf("Failed to create handler: %v", err) + } + + ts := httptest.NewServer(handler) + defer ts.Close() + + cfg := getBrowserConfig() + browser, cleanup := launchBrowser(cfg) + defer cleanup() + + page := browser.MustPage(ts.URL).Timeout(30 * time.Second) + defer page.MustClose() + + page.MustWaitLoad() + + bodyText := page.MustElement("body").MustText() + + // Verify progress text + if !strings.Contains(bodyText, "3/7") { + t.Errorf("Expected progress '3/7' in page, got: %s", bodyText[:min(len(bodyText), 500)]) + } + + // Verify progress bar elements exist + html := page.MustHTML() + if !strings.Contains(html, "progress-bar") { + t.Error("Expected progress-bar class in page") + } + if !strings.Contains(html, "progress-fill") { + t.Error("Expected progress-fill class in page") + } + + t.Log("PASSED: Progress display works correctly") +} diff --git a/internal/web/fetcher.go b/internal/web/fetcher.go index 6f400ddc..b06e38e7 100644 --- a/internal/web/fetcher.go +++ b/internal/web/fetcher.go @@ -30,6 +30,7 @@ func NewLiveConvoyFetcher() (*LiveConvoyFetcher, error) { }, nil } + // FetchConvoys fetches all open convoys with their activity data. func (f *LiveConvoyFetcher) FetchConvoys() ([]ConvoyRow, error) { // List all open convoy-type issues @@ -68,6 +69,8 @@ func (f *LiveConvoyFetcher) FetchConvoys() ([]ConvoyRow, error) { row.Total = len(tracked) var mostRecentActivity time.Time + var mostRecentUpdated time.Time + var hasAssignee bool for _, t := range tracked { if t.Status == "closed" { row.Completed++ @@ -76,20 +79,50 @@ func (f *LiveConvoyFetcher) FetchConvoys() ([]ConvoyRow, error) { if t.LastActivity.After(mostRecentActivity) { mostRecentActivity = t.LastActivity } + // Track most recent updated_at as fallback + if t.UpdatedAt.After(mostRecentUpdated) { + mostRecentUpdated = t.UpdatedAt + } + if t.Assignee != "" { + hasAssignee = true + } } row.Progress = fmt.Sprintf("%d/%d", row.Completed, row.Total) // Calculate activity info from most recent worker activity if !mostRecentActivity.IsZero() { + // Have active tmux session activity from assigned workers row.LastActivity = activity.Calculate(mostRecentActivity) + } else if !hasAssignee { + // No assignees found in beads - try fallback to any running polecat activity + // This handles cases where bd update --assignee didn't persist or wasn't returned + if polecatActivity := f.getAllPolecatActivity(); polecatActivity != nil { + info := activity.Calculate(*polecatActivity) + info.FormattedAge = info.FormattedAge + " (polecat active)" + row.LastActivity = info + } else if !mostRecentUpdated.IsZero() { + // Fall back to issue updated_at if no polecats running + info := activity.Calculate(mostRecentUpdated) + info.FormattedAge = info.FormattedAge + " (unassigned)" + row.LastActivity = info + } else { + row.LastActivity = activity.Info{ + FormattedAge: "unassigned", + ColorClass: activity.ColorUnknown, + } + } } else { + // Has assignee but no active session row.LastActivity = activity.Info{ - FormattedAge: "no activity", + FormattedAge: "idle", ColorClass: activity.ColorUnknown, } } + // Calculate work status based on progress and activity + row.WorkStatus = calculateWorkStatus(row.Completed, row.Total, row.LastActivity.ColorClass) + // Get tracked issues for expandable view row.TrackedIssues = make([]TrackedIssue, len(tracked)) for i, t := range tracked { @@ -114,6 +147,7 @@ type trackedIssueInfo struct { Status string Assignee string LastActivity time.Time + UpdatedAt time.Time // Fallback for activity when no assignee } // getTrackedIssues fetches tracked issues for a convoy. @@ -122,6 +156,7 @@ func (f *LiveConvoyFetcher) getTrackedIssues(convoyID string) []trackedIssueInfo // Query tracked dependencies from SQLite safeConvoyID := strings.ReplaceAll(convoyID, "'", "''") + // #nosec G204 -- sqlite3 path is from trusted config, convoyID is escaped queryCmd := exec.Command("sqlite3", "-json", dbPath, fmt.Sprintf(`SELECT depends_on_id, type FROM dependencies WHERE issue_id = '%s' AND type = 'tracks'`, safeConvoyID)) @@ -155,8 +190,8 @@ func (f *LiveConvoyFetcher) getTrackedIssues(convoyID string) []trackedIssueInfo // Batch fetch issue details details := f.getIssueDetailsBatch(issueIDs) - // Get worker info for activity timestamps - workers := f.getWorkersForIssues(issueIDs) + // Get worker activity from tmux sessions based on assignees + workers := f.getWorkersFromAssignees(details) // Build result result := make([]trackedIssueInfo, 0, len(issueIDs)) @@ -167,6 +202,7 @@ func (f *LiveConvoyFetcher) getTrackedIssues(convoyID string) []trackedIssueInfo info.Title = d.Title info.Status = d.Status info.Assignee = d.Assignee + info.UpdatedAt = d.UpdatedAt } else { info.Title = "(external)" info.Status = "unknown" @@ -184,10 +220,11 @@ func (f *LiveConvoyFetcher) getTrackedIssues(convoyID string) []trackedIssueInfo // issueDetail holds basic issue info. type issueDetail struct { - ID string - Title string - Status string - Assignee string + ID string + Title string + Status string + Assignee string + UpdatedAt time.Time } // getIssueDetailsBatch fetches details for multiple issues. @@ -200,6 +237,7 @@ func (f *LiveConvoyFetcher) getIssueDetailsBatch(issueIDs []string) map[string]* args := append([]string{"show"}, issueIDs...) args = append(args, "--json") + // #nosec G204 -- bd is a trusted internal tool, args are issue IDs showCmd := exec.Command("bd", args...) var stdout bytes.Buffer showCmd.Stdout = &stdout @@ -209,22 +247,30 @@ func (f *LiveConvoyFetcher) getIssueDetailsBatch(issueIDs []string) map[string]* } var issues []struct { - ID string `json:"id"` - Title string `json:"title"` - Status string `json:"status"` - Assignee string `json:"assignee"` + ID string `json:"id"` + Title string `json:"title"` + Status string `json:"status"` + Assignee string `json:"assignee"` + UpdatedAt string `json:"updated_at"` } if err := json.Unmarshal(stdout.Bytes(), &issues); err != nil { return result } for _, issue := range issues { - result[issue.ID] = &issueDetail{ + detail := &issueDetail{ ID: issue.ID, Title: issue.Title, Status: issue.Status, Assignee: issue.Assignee, } + // Parse updated_at timestamp + if issue.UpdatedAt != "" { + if t, err := time.Parse(time.RFC3339, issue.UpdatedAt); err == nil { + detail.UpdatedAt = t + } + } + result[issue.ID] = detail } return result @@ -236,62 +282,474 @@ type workerDetail struct { LastActivity *time.Time } -// getWorkersForIssues finds workers and their last activity for issues. -func (f *LiveConvoyFetcher) getWorkersForIssues(issueIDs []string) map[string]*workerDetail { +// getWorkersFromAssignees gets worker activity from tmux sessions based on issue assignees. +// Assignees are in format "rigname/polecats/polecatname" which maps to tmux session "gt-rigname-polecatname". +func (f *LiveConvoyFetcher) getWorkersFromAssignees(details map[string]*issueDetail) map[string]*workerDetail { result := make(map[string]*workerDetail) - if len(issueIDs) == 0 { + + // Collect unique assignees and map them to issue IDs + assigneeToIssues := make(map[string][]string) + for issueID, detail := range details { + if detail == nil || detail.Assignee == "" { + continue + } + assigneeToIssues[detail.Assignee] = append(assigneeToIssues[detail.Assignee], issueID) + } + + if len(assigneeToIssues) == 0 { return result } - townRoot, _ := workspace.FindFromCwd() - if townRoot == "" { - return result - } + // For each unique assignee, look up tmux session activity + for assignee, issueIDs := range assigneeToIssues { + activity := f.getSessionActivityForAssignee(assignee) + if activity == nil { + continue + } - // Find all rig beads databases - rigDirs, _ := filepath.Glob(filepath.Join(townRoot, "*", "mayor", "rig", ".beads", "beads.db")) - - for _, dbPath := range rigDirs { + // Apply this activity to all issues assigned to this worker for _, issueID := range issueIDs { - if _, ok := result[issueID]; ok { - continue + result[issueID] = &workerDetail{ + Worker: assignee, + LastActivity: activity, } - - safeID := strings.ReplaceAll(issueID, "'", "''") - query := fmt.Sprintf( - `SELECT id, hook_bead, last_activity FROM issues WHERE issue_type = 'agent' AND status = 'open' AND hook_bead = '%s' LIMIT 1`, - safeID) - - queryCmd := exec.Command("sqlite3", "-json", dbPath, query) - var stdout bytes.Buffer - queryCmd.Stdout = &stdout - if err := queryCmd.Run(); err != nil { - continue - } - - var agents []struct { - ID string `json:"id"` - HookBead string `json:"hook_bead"` - LastActivity string `json:"last_activity"` - } - if err := json.Unmarshal(stdout.Bytes(), &agents); err != nil || len(agents) == 0 { - continue - } - - agent := agents[0] - detail := &workerDetail{ - Worker: agent.ID, - } - - if agent.LastActivity != "" { - if t, err := time.Parse(time.RFC3339, agent.LastActivity); err == nil { - detail.LastActivity = &t - } - } - - result[issueID] = detail } } return result } + +// getSessionActivityForAssignee looks up tmux session activity for an assignee. +// Assignee format: "rigname/polecats/polecatname" -> session "gt-rigname-polecatname" +func (f *LiveConvoyFetcher) getSessionActivityForAssignee(assignee string) *time.Time { + // Parse assignee: "roxas/polecats/dag" -> rig="roxas", polecat="dag" + parts := strings.Split(assignee, "/") + if len(parts) != 3 || parts[1] != "polecats" { + return nil + } + rig := parts[0] + polecat := parts[2] + + // Construct session name + sessionName := fmt.Sprintf("gt-%s-%s", rig, polecat) + + // Query tmux for session activity + // Format: session_activity returns unix timestamp + cmd := exec.Command("tmux", "list-sessions", "-F", "#{session_name}|#{session_activity}", + "-f", fmt.Sprintf("#{==:#{session_name},%s}", sessionName)) + var stdout bytes.Buffer + cmd.Stdout = &stdout + if err := cmd.Run(); err != nil { + return nil + } + + output := strings.TrimSpace(stdout.String()) + if output == "" { + return nil + } + + // Parse output: "gt-roxas-dag|1704312345" + outputParts := strings.Split(output, "|") + if len(outputParts) < 2 { + return nil + } + + var activityUnix int64 + if _, err := fmt.Sscanf(outputParts[1], "%d", &activityUnix); err != nil || activityUnix == 0 { + return nil + } + + activity := time.Unix(activityUnix, 0) + return &activity +} + +// getAllPolecatActivity returns the most recent activity from any running polecat session. +// This is used as a fallback when no specific assignee activity can be determined. +// Returns nil if no polecat sessions are running. +func (f *LiveConvoyFetcher) getAllPolecatActivity() *time.Time { + // List all tmux sessions matching gt-*-* pattern (polecat sessions) + // Format: gt-{rig}-{polecat} + cmd := exec.Command("tmux", "list-sessions", "-F", "#{session_name}|#{session_activity}") + var stdout bytes.Buffer + cmd.Stdout = &stdout + if err := cmd.Run(); err != nil { + return nil + } + + var mostRecent time.Time + for _, line := range strings.Split(stdout.String(), "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + + parts := strings.Split(line, "|") + if len(parts) < 2 { + continue + } + + sessionName := parts[0] + // Check if it's a polecat session (gt-{rig}-{polecat}, not gt-{rig}-witness/refinery) + // Polecat sessions have exactly 3 parts when split by "-" and the middle part is the rig + nameParts := strings.Split(sessionName, "-") + if len(nameParts) < 3 || nameParts[0] != "gt" { + continue + } + // Skip witness, refinery, mayor, deacon sessions + lastPart := nameParts[len(nameParts)-1] + if lastPart == "witness" || lastPart == "refinery" || lastPart == "mayor" || lastPart == "deacon" { + continue + } + + var activityUnix int64 + if _, err := fmt.Sscanf(parts[1], "%d", &activityUnix); err != nil || activityUnix == 0 { + continue + } + + activityTime := time.Unix(activityUnix, 0) + if activityTime.After(mostRecent) { + mostRecent = activityTime + } + } + + if mostRecent.IsZero() { + return nil + } + return &mostRecent +} + +// calculateWorkStatus determines the work status based on progress and activity. +// Returns: "complete", "active", "stale", "stuck", or "waiting" +func calculateWorkStatus(completed, total int, activityColor string) string { + // Check if all work is done + if total > 0 && completed == total { + return "complete" + } + + // Determine status based on activity color + switch activityColor { + case activity.ColorGreen: + return "active" + case activity.ColorYellow: + return "stale" + case activity.ColorRed: + return "stuck" + default: + return "waiting" + } +} + +// FetchMergeQueue fetches open PRs from configured repos. +func (f *LiveConvoyFetcher) FetchMergeQueue() ([]MergeQueueRow, error) { + // Repos to query for PRs + repos := []struct { + Full string // Full repo path for gh CLI + Short string // Short name for display + }{ + {"michaellady/roxas", "roxas"}, + {"michaellady/gastown", "gastown"}, + } + + var result []MergeQueueRow + + for _, repo := range repos { + prs, err := f.fetchPRsForRepo(repo.Full, repo.Short) + if err != nil { + // Non-fatal: continue with other repos + continue + } + result = append(result, prs...) + } + + return result, nil +} + +// prResponse represents the JSON response from gh pr list. +type prResponse struct { + Number int `json:"number"` + Title string `json:"title"` + URL string `json:"url"` + Mergeable string `json:"mergeable"` + StatusCheckRollup []struct { + State string `json:"state"` + Status string `json:"status"` + Conclusion string `json:"conclusion"` + } `json:"statusCheckRollup"` +} + +// fetchPRsForRepo fetches open PRs for a single repo. +func (f *LiveConvoyFetcher) fetchPRsForRepo(repoFull, repoShort string) ([]MergeQueueRow, error) { + // #nosec G204 -- gh is a trusted CLI, repo is from hardcoded list + cmd := exec.Command("gh", "pr", "list", + "--repo", repoFull, + "--state", "open", + "--json", "number,title,url,mergeable,statusCheckRollup") + + var stdout bytes.Buffer + cmd.Stdout = &stdout + + if err := cmd.Run(); err != nil { + return nil, fmt.Errorf("fetching PRs for %s: %w", repoFull, err) + } + + var prs []prResponse + if err := json.Unmarshal(stdout.Bytes(), &prs); err != nil { + return nil, fmt.Errorf("parsing PRs for %s: %w", repoFull, err) + } + + result := make([]MergeQueueRow, 0, len(prs)) + for _, pr := range prs { + row := MergeQueueRow{ + Number: pr.Number, + Repo: repoShort, + Title: pr.Title, + URL: pr.URL, + } + + // Determine CI status from statusCheckRollup + row.CIStatus = determineCIStatus(pr.StatusCheckRollup) + + // Determine mergeable status + row.Mergeable = determineMergeableStatus(pr.Mergeable) + + // Determine color class based on overall status + row.ColorClass = determineColorClass(row.CIStatus, row.Mergeable) + + result = append(result, row) + } + + return result, nil +} + +// determineCIStatus evaluates the overall CI status from status checks. +func determineCIStatus(checks []struct { + State string `json:"state"` + Status string `json:"status"` + Conclusion string `json:"conclusion"` +}) string { + if len(checks) == 0 { + return "pending" + } + + hasFailure := false + hasPending := false + + for _, check := range checks { + // Check conclusion first (for completed checks) + switch check.Conclusion { + case "failure", "cancelled", "timed_out", "action_required": //nolint:misspell // GitHub API returns "cancelled" (British spelling) + hasFailure = true + case "success", "skipped", "neutral": + // Pass + default: + // Check status for in-progress checks + switch check.Status { + case "queued", "in_progress", "waiting", "pending", "requested": + hasPending = true + } + // Also check state field + switch check.State { + case "FAILURE", "ERROR": + hasFailure = true + case "PENDING", "EXPECTED": + hasPending = true + } + } + } + + if hasFailure { + return "fail" + } + if hasPending { + return "pending" + } + return "pass" +} + +// determineMergeableStatus converts GitHub's mergeable field to display value. +func determineMergeableStatus(mergeable string) string { + switch strings.ToUpper(mergeable) { + case "MERGEABLE": + return "ready" + case "CONFLICTING": + return "conflict" + default: + return "pending" + } +} + +// determineColorClass determines the row color based on CI and merge status. +func determineColorClass(ciStatus, mergeable string) string { + if ciStatus == "fail" || mergeable == "conflict" { + return "mq-red" + } + if ciStatus == "pending" || mergeable == "pending" { + return "mq-yellow" + } + if ciStatus == "pass" && mergeable == "ready" { + return "mq-green" + } + return "mq-yellow" +} + +// FetchPolecats fetches all running polecat and refinery sessions with activity data. +func (f *LiveConvoyFetcher) FetchPolecats() ([]PolecatRow, error) { + // Query all tmux sessions with window_activity for more accurate timing + cmd := exec.Command("tmux", "list-sessions", "-F", "#{session_name}|#{window_activity}") + var stdout bytes.Buffer + cmd.Stdout = &stdout + if err := cmd.Run(); err != nil { + // tmux not running or no sessions + return nil, nil + } + + // Pre-fetch merge queue count to determine refinery idle status + mergeQueueCount := f.getMergeQueueCount() + + var polecats []PolecatRow + lines := strings.Split(strings.TrimSpace(stdout.String()), "\n") + + for _, line := range lines { + if line == "" { + continue + } + + parts := strings.Split(line, "|") + if len(parts) < 2 { + continue + } + + sessionName := parts[0] + + // Filter for gt-- pattern + if !strings.HasPrefix(sessionName, "gt-") { + continue + } + + // Parse session name: gt-roxas-dag -> rig=roxas, polecat=dag + nameParts := strings.SplitN(sessionName, "-", 3) + if len(nameParts) != 3 { + continue + } + rig := nameParts[1] + polecat := nameParts[2] + + // Skip non-worker sessions (witness, mayor, deacon, boot) + // Note: refinery is included to show idle/processing status + if polecat == "witness" || polecat == "mayor" || polecat == "deacon" || polecat == "boot" { + continue + } + + // Parse activity timestamp + var activityUnix int64 + if _, err := fmt.Sscanf(parts[1], "%d", &activityUnix); err != nil || activityUnix == 0 { + continue + } + activityTime := time.Unix(activityUnix, 0) + + // Get status hint - special handling for refinery + var statusHint string + if polecat == "refinery" { + statusHint = f.getRefineryStatusHint(mergeQueueCount) + } else { + statusHint = f.getPolecatStatusHint(sessionName) + } + + polecats = append(polecats, PolecatRow{ + Name: polecat, + Rig: rig, + SessionID: sessionName, + LastActivity: activity.Calculate(activityTime), + StatusHint: statusHint, + }) + } + + return polecats, nil +} + +// getPolecatStatusHint captures the last non-empty line from a polecat's pane. +func (f *LiveConvoyFetcher) getPolecatStatusHint(sessionName string) string { + cmd := exec.Command("tmux", "capture-pane", "-t", sessionName, "-p", "-J") + var stdout bytes.Buffer + cmd.Stdout = &stdout + if err := cmd.Run(); err != nil { + return "" + } + + // Get last non-empty line + lines := strings.Split(stdout.String(), "\n") + for i := len(lines) - 1; i >= 0; i-- { + line := strings.TrimSpace(lines[i]) + if line != "" { + // Truncate long lines + if len(line) > 60 { + line = line[:57] + "..." + } + return line + } + } + return "" +} + +// getMergeQueueCount returns the total number of open PRs across all repos. +func (f *LiveConvoyFetcher) getMergeQueueCount() int { + mergeQueue, err := f.FetchMergeQueue() + if err != nil { + return 0 + } + return len(mergeQueue) +} + +// getRefineryStatusHint returns appropriate status for refinery based on merge queue. +func (f *LiveConvoyFetcher) getRefineryStatusHint(mergeQueueCount int) string { + if mergeQueueCount == 0 { + return "Idle - Waiting for PRs" + } + if mergeQueueCount == 1 { + return "Processing 1 PR" + } + 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) + } + }) + } +} diff --git a/internal/web/handler.go b/internal/web/handler.go index 44ae1cc7..02e23557 100644 --- a/internal/web/handler.go +++ b/internal/web/handler.go @@ -8,6 +8,8 @@ import ( // ConvoyFetcher defines the interface for fetching convoy data. type ConvoyFetcher interface { FetchConvoys() ([]ConvoyRow, error) + FetchMergeQueue() ([]MergeQueueRow, error) + FetchPolecats() ([]PolecatRow, error) } // ConvoyHandler handles HTTP requests for the convoy dashboard. @@ -37,8 +39,22 @@ func (h *ConvoyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } + mergeQueue, err := h.fetcher.FetchMergeQueue() + if err != nil { + // Non-fatal: show convoys even if merge queue fails + mergeQueue = nil + } + + polecats, err := h.fetcher.FetchPolecats() + if err != nil { + // Non-fatal: show convoys even if polecats fail + polecats = nil + } + data := ConvoyData{ - Convoys: convoys, + Convoys: convoys, + MergeQueue: mergeQueue, + Polecats: polecats, } w.Header().Set("Content-Type", "text/html; charset=utf-8") diff --git a/internal/web/handler_test.go b/internal/web/handler_test.go index d44de23f..c2fd3dba 100644 --- a/internal/web/handler_test.go +++ b/internal/web/handler_test.go @@ -1,6 +1,8 @@ package web import ( + "errors" + "io" "net/http" "net/http/httptest" "strings" @@ -10,16 +12,29 @@ import ( "github.com/steveyegge/gastown/internal/activity" ) +// Test error for simulating fetch failures +var errFetchFailed = errors.New("fetch failed") + // MockConvoyFetcher is a mock implementation for testing. type MockConvoyFetcher struct { - Convoys []ConvoyRow - Error error + Convoys []ConvoyRow + MergeQueue []MergeQueueRow + Polecats []PolecatRow + Error error } func (m *MockConvoyFetcher) FetchConvoys() ([]ConvoyRow, error) { return m.Convoys, m.Error } +func (m *MockConvoyFetcher) FetchMergeQueue() ([]MergeQueueRow, error) { + return m.MergeQueue, nil +} + +func (m *MockConvoyFetcher) FetchPolecats() ([]PolecatRow, error) { + return m.Polecats, nil +} + func TestConvoyHandler_RendersTemplate(t *testing.T) { mock := &MockConvoyFetcher{ Convoys: []ConvoyRow{ @@ -179,3 +194,804 @@ func TestConvoyHandler_MultipleConvoys(t *testing.T) { } } } + +// Integration tests for error handling + +func TestConvoyHandler_FetchConvoysError(t *testing.T) { + mock := &MockConvoyFetcher{ + Error: errFetchFailed, + } + + handler, err := NewConvoyHandler(mock) + if err != nil { + t.Fatalf("NewConvoyHandler() error = %v", err) + } + + req := httptest.NewRequest("GET", "/", nil) + w := httptest.NewRecorder() + + handler.ServeHTTP(w, req) + + if w.Code != http.StatusInternalServerError { + t.Errorf("Status = %d, want %d", w.Code, http.StatusInternalServerError) + } + + body := w.Body.String() + if !strings.Contains(body, "Failed to fetch convoys") { + t.Error("Response should contain error message") + } +} + +// Integration tests for merge queue rendering + +func TestConvoyHandler_MergeQueueRendering(t *testing.T) { + mock := &MockConvoyFetcher{ + Convoys: []ConvoyRow{}, + MergeQueue: []MergeQueueRow{ + { + Number: 123, + Repo: "roxas", + Title: "Fix authentication bug", + URL: "https://github.com/test/repo/pull/123", + CIStatus: "pass", + Mergeable: "ready", + ColorClass: "mq-green", + }, + { + Number: 456, + Repo: "gastown", + Title: "Add dashboard feature", + URL: "https://github.com/test/repo/pull/456", + CIStatus: "pending", + Mergeable: "pending", + ColorClass: "mq-yellow", + }, + }, + } + + handler, err := NewConvoyHandler(mock) + if err != nil { + t.Fatalf("NewConvoyHandler() error = %v", err) + } + + req := httptest.NewRequest("GET", "/", nil) + w := httptest.NewRecorder() + + handler.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Status = %d, want %d", w.Code, http.StatusOK) + } + + body := w.Body.String() + + // Check merge queue section header + if !strings.Contains(body, "Refinery Merge Queue") { + t.Error("Response should contain merge queue section header") + } + + // Check PR numbers are rendered + if !strings.Contains(body, "#123") { + t.Error("Response should contain PR #123") + } + if !strings.Contains(body, "#456") { + t.Error("Response should contain PR #456") + } + + // Check repo names + if !strings.Contains(body, "roxas") { + t.Error("Response should contain repo 'roxas'") + } + + // Check CI status badges + if !strings.Contains(body, "ci-pass") { + t.Error("Response should contain ci-pass class for passing PR") + } + if !strings.Contains(body, "ci-pending") { + t.Error("Response should contain ci-pending class for pending PR") + } +} + +func TestConvoyHandler_EmptyMergeQueue(t *testing.T) { + mock := &MockConvoyFetcher{ + Convoys: []ConvoyRow{}, + MergeQueue: []MergeQueueRow{}, + } + + handler, err := NewConvoyHandler(mock) + if err != nil { + t.Fatalf("NewConvoyHandler() error = %v", err) + } + + req := httptest.NewRequest("GET", "/", nil) + w := httptest.NewRecorder() + + handler.ServeHTTP(w, req) + + body := w.Body.String() + + // Should show empty state for merge queue + if !strings.Contains(body, "No PRs in queue") { + t.Error("Response should show empty merge queue message") + } +} + +// Integration tests for polecat workers rendering + +func TestConvoyHandler_PolecatWorkersRendering(t *testing.T) { + mock := &MockConvoyFetcher{ + Convoys: []ConvoyRow{}, + Polecats: []PolecatRow{ + { + Name: "dag", + Rig: "roxas", + SessionID: "gt-roxas-dag", + LastActivity: activity.Calculate(time.Now().Add(-30 * time.Second)), + StatusHint: "Running tests...", + }, + { + Name: "nux", + Rig: "roxas", + SessionID: "gt-roxas-nux", + LastActivity: activity.Calculate(time.Now().Add(-5 * time.Minute)), + StatusHint: "Waiting for input", + }, + }, + } + + handler, err := NewConvoyHandler(mock) + if err != nil { + t.Fatalf("NewConvoyHandler() error = %v", err) + } + + req := httptest.NewRequest("GET", "/", nil) + w := httptest.NewRecorder() + + handler.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Status = %d, want %d", w.Code, http.StatusOK) + } + + body := w.Body.String() + + // Check polecat section header + if !strings.Contains(body, "Polecat Workers") { + t.Error("Response should contain polecat workers section header") + } + + // Check polecat names + if !strings.Contains(body, "dag") { + t.Error("Response should contain polecat 'dag'") + } + if !strings.Contains(body, "nux") { + t.Error("Response should contain polecat 'nux'") + } + + // Check rig names + if !strings.Contains(body, "roxas") { + t.Error("Response should contain rig 'roxas'") + } + + // Check status hints + if !strings.Contains(body, "Running tests...") { + t.Error("Response should contain status hint") + } + + // Check activity colors (dag should be green, nux should be yellow/red) + if !strings.Contains(body, "activity-green") { + t.Error("Response should contain activity-green for recent activity") + } +} + +// Integration tests for work status rendering + +func TestConvoyHandler_WorkStatusRendering(t *testing.T) { + tests := []struct { + name string + workStatus string + wantClass string + wantStatusText string + }{ + {"complete status", "complete", "work-complete", "complete"}, + {"active status", "active", "work-active", "active"}, + {"stale status", "stale", "work-stale", "stale"}, + {"stuck status", "stuck", "work-stuck", "stuck"}, + {"waiting status", "waiting", "work-waiting", "waiting"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mock := &MockConvoyFetcher{ + Convoys: []ConvoyRow{ + { + ID: "hq-cv-test", + Title: "Test Convoy", + Status: "open", + WorkStatus: tt.workStatus, + Progress: "1/2", + Completed: 1, + Total: 2, + LastActivity: activity.Calculate(time.Now()), + }, + }, + } + + handler, err := NewConvoyHandler(mock) + if err != nil { + t.Fatalf("NewConvoyHandler() error = %v", err) + } + + req := httptest.NewRequest("GET", "/", nil) + w := httptest.NewRecorder() + + handler.ServeHTTP(w, req) + + body := w.Body.String() + + // Check work status class is applied + if !strings.Contains(body, tt.wantClass) { + t.Errorf("Response should contain class %q for work status %q", tt.wantClass, tt.workStatus) + } + + // Check work status text is displayed + if !strings.Contains(body, tt.wantStatusText) { + t.Errorf("Response should contain status text %q", tt.wantStatusText) + } + }) + } +} + +// Integration tests for progress bar rendering + +func TestConvoyHandler_ProgressBarRendering(t *testing.T) { + mock := &MockConvoyFetcher{ + Convoys: []ConvoyRow{ + { + ID: "hq-cv-progress", + Title: "Progress Test", + Status: "open", + WorkStatus: "active", + Progress: "3/4", + Completed: 3, + Total: 4, + LastActivity: activity.Calculate(time.Now()), + }, + }, + } + + handler, err := NewConvoyHandler(mock) + if err != nil { + t.Fatalf("NewConvoyHandler() error = %v", err) + } + + req := httptest.NewRequest("GET", "/", nil) + w := httptest.NewRecorder() + + handler.ServeHTTP(w, req) + + body := w.Body.String() + + // Check progress text + if !strings.Contains(body, "3/4") { + t.Error("Response should contain progress '3/4'") + } + + // Check progress bar element + if !strings.Contains(body, "progress-bar") { + t.Error("Response should contain progress-bar class") + } + + // Check progress fill with percentage (75%) + if !strings.Contains(body, "progress-fill") { + t.Error("Response should contain progress-fill class") + } + if !strings.Contains(body, "width: 75%") { + t.Error("Response should contain 75% width for 3/4 progress") + } +} + +// Integration test for HTMX auto-refresh + +func TestConvoyHandler_HTMXAutoRefresh(t *testing.T) { + mock := &MockConvoyFetcher{ + Convoys: []ConvoyRow{}, + } + + handler, err := NewConvoyHandler(mock) + if err != nil { + t.Fatalf("NewConvoyHandler() error = %v", err) + } + + req := httptest.NewRequest("GET", "/", nil) + w := httptest.NewRecorder() + + handler.ServeHTTP(w, req) + + body := w.Body.String() + + // Check htmx attributes for auto-refresh + if !strings.Contains(body, "hx-get") { + t.Error("Response should contain hx-get attribute for HTMX") + } + if !strings.Contains(body, "hx-trigger") { + t.Error("Response should contain hx-trigger attribute for HTMX") + } + if !strings.Contains(body, "every 10s") { + t.Error("Response should contain 'every 10s' trigger interval") + } +} + +// Integration test for full dashboard with all sections + +func TestConvoyHandler_FullDashboard(t *testing.T) { + mock := &MockConvoyFetcher{ + Convoys: []ConvoyRow{ + { + ID: "hq-cv-full", + Title: "Full Test Convoy", + Status: "open", + WorkStatus: "active", + Progress: "2/3", + Completed: 2, + Total: 3, + LastActivity: activity.Calculate(time.Now().Add(-1 * time.Minute)), + }, + }, + MergeQueue: []MergeQueueRow{ + { + Number: 789, + Repo: "testrig", + Title: "Test PR", + CIStatus: "pass", + Mergeable: "ready", + ColorClass: "mq-green", + }, + }, + Polecats: []PolecatRow{ + { + Name: "worker1", + Rig: "testrig", + SessionID: "gt-testrig-worker1", + LastActivity: activity.Calculate(time.Now()), + StatusHint: "Working...", + }, + }, + } + + handler, err := NewConvoyHandler(mock) + if err != nil { + t.Fatalf("NewConvoyHandler() error = %v", err) + } + + req := httptest.NewRequest("GET", "/", nil) + w := httptest.NewRecorder() + + handler.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Status = %d, want %d", w.Code, http.StatusOK) + } + + body := w.Body.String() + + // Verify all three sections are present + if !strings.Contains(body, "Gas Town Convoys") { + t.Error("Response should contain main header") + } + if !strings.Contains(body, "hq-cv-full") { + t.Error("Response should contain convoy data") + } + if !strings.Contains(body, "Refinery Merge Queue") { + t.Error("Response should contain merge queue section") + } + if !strings.Contains(body, "#789") { + t.Error("Response should contain PR data") + } + if !strings.Contains(body, "Polecat Workers") { + t.Error("Response should contain polecat section") + } + if !strings.Contains(body, "worker1") { + t.Error("Response should contain polecat data") + } +} + +// ============================================================================= +// End-to-End Tests with httptest.Server +// ============================================================================= + +// TestE2E_Server_FullDashboard tests the full dashboard using a real HTTP server. +func TestE2E_Server_FullDashboard(t *testing.T) { + mock := &MockConvoyFetcher{ + Convoys: []ConvoyRow{ + { + ID: "hq-cv-e2e", + Title: "E2E Test Convoy", + Status: "open", + WorkStatus: "active", + Progress: "2/4", + Completed: 2, + Total: 4, + LastActivity: activity.Calculate(time.Now().Add(-45 * time.Second)), + }, + }, + MergeQueue: []MergeQueueRow{ + { + Number: 101, + Repo: "roxas", + Title: "E2E Test PR", + URL: "https://github.com/test/roxas/pull/101", + CIStatus: "pass", + Mergeable: "ready", + ColorClass: "mq-green", + }, + }, + Polecats: []PolecatRow{ + { + Name: "furiosa", + Rig: "roxas", + SessionID: "gt-roxas-furiosa", + LastActivity: activity.Calculate(time.Now().Add(-30 * time.Second)), + StatusHint: "Running E2E tests", + }, + }, + } + + handler, err := NewConvoyHandler(mock) + if err != nil { + t.Fatalf("NewConvoyHandler() error = %v", err) + } + + // Create a real HTTP server + server := httptest.NewServer(handler) + defer server.Close() + + // Make HTTP request to the server + resp, err := http.Get(server.URL) + if err != nil { + t.Fatalf("HTTP GET failed: %v", err) + } + defer resp.Body.Close() + + // Verify status code + if resp.StatusCode != http.StatusOK { + t.Errorf("Status = %d, want %d", resp.StatusCode, http.StatusOK) + } + + // Verify content type + contentType := resp.Header.Get("Content-Type") + if !strings.Contains(contentType, "text/html") { + t.Errorf("Content-Type = %q, want text/html", contentType) + } + + // Read and verify body + bodyBytes, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("Failed to read response body: %v", err) + } + body := string(bodyBytes) + + // Verify all three sections render + checks := []struct { + name string + content string + }{ + {"Convoy section header", "Gas Town Convoys"}, + {"Convoy ID", "hq-cv-e2e"}, + {"Convoy title", "E2E Test Convoy"}, + {"Convoy progress", "2/4"}, + {"Merge queue section", "Refinery Merge Queue"}, + {"PR number", "#101"}, + {"PR repo", "roxas"}, + {"Polecat section", "Polecat Workers"}, + {"Polecat name", "furiosa"}, + {"Polecat status", "Running E2E tests"}, + {"HTMX auto-refresh", `hx-trigger="every 10s"`}, + } + + for _, check := range checks { + if !strings.Contains(body, check.content) { + t.Errorf("%s: should contain %q", check.name, check.content) + } + } +} + +// TestE2E_Server_ActivityColors tests activity color rendering via HTTP server. +func TestE2E_Server_ActivityColors(t *testing.T) { + tests := []struct { + name string + age time.Duration + wantClass string + }{ + {"green for recent", 20 * time.Second, "activity-green"}, + {"yellow for stale", 3 * time.Minute, "activity-yellow"}, + {"red for stuck", 8 * time.Minute, "activity-red"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mock := &MockConvoyFetcher{ + Polecats: []PolecatRow{ + { + Name: "test-worker", + Rig: "test-rig", + SessionID: "gt-test-rig-test-worker", + LastActivity: activity.Calculate(time.Now().Add(-tt.age)), + StatusHint: "Testing", + }, + }, + } + + handler, err := NewConvoyHandler(mock) + if err != nil { + t.Fatalf("NewConvoyHandler() error = %v", err) + } + + server := httptest.NewServer(handler) + defer server.Close() + + resp, err := http.Get(server.URL) + if err != nil { + t.Fatalf("HTTP GET failed: %v", err) + } + defer resp.Body.Close() + + bodyBytes, _ := io.ReadAll(resp.Body) + body := string(bodyBytes) + + if !strings.Contains(body, tt.wantClass) { + t.Errorf("Should contain activity class %q for age %v", tt.wantClass, tt.age) + } + }) + } +} + +// TestE2E_Server_MergeQueueEmpty tests that empty merge queue shows message. +func TestE2E_Server_MergeQueueEmpty(t *testing.T) { + mock := &MockConvoyFetcher{ + Convoys: []ConvoyRow{}, + MergeQueue: []MergeQueueRow{}, + Polecats: []PolecatRow{}, + } + + handler, err := NewConvoyHandler(mock) + if err != nil { + t.Fatalf("NewConvoyHandler() error = %v", err) + } + + server := httptest.NewServer(handler) + defer server.Close() + + resp, err := http.Get(server.URL) + if err != nil { + t.Fatalf("HTTP GET failed: %v", err) + } + defer resp.Body.Close() + + bodyBytes, _ := io.ReadAll(resp.Body) + body := string(bodyBytes) + + // Section header should always be visible + if !strings.Contains(body, "Refinery Merge Queue") { + t.Error("Merge queue section should always be visible") + } + + // Empty state message + if !strings.Contains(body, "No PRs in queue") { + t.Error("Should show 'No PRs in queue' when empty") + } +} + +// TestE2E_Server_MergeQueueStatuses tests all PR status combinations. +func TestE2E_Server_MergeQueueStatuses(t *testing.T) { + tests := []struct { + name string + ciStatus string + mergeable string + colorClass string + wantCI string + wantMerge string + }{ + {"green when ready", "pass", "ready", "mq-green", "ci-pass", "merge-ready"}, + {"red when CI fails", "fail", "ready", "mq-red", "ci-fail", "merge-ready"}, + {"red when conflict", "pass", "conflict", "mq-red", "ci-pass", "merge-conflict"}, + {"yellow when pending", "pending", "pending", "mq-yellow", "ci-pending", "merge-pending"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mock := &MockConvoyFetcher{ + MergeQueue: []MergeQueueRow{ + { + Number: 42, + Repo: "test", + Title: "Test PR", + URL: "https://github.com/test/test/pull/42", + CIStatus: tt.ciStatus, + Mergeable: tt.mergeable, + ColorClass: tt.colorClass, + }, + }, + } + + handler, err := NewConvoyHandler(mock) + if err != nil { + t.Fatalf("NewConvoyHandler() error = %v", err) + } + + server := httptest.NewServer(handler) + defer server.Close() + + resp, err := http.Get(server.URL) + if err != nil { + t.Fatalf("HTTP GET failed: %v", err) + } + defer resp.Body.Close() + + bodyBytes, _ := io.ReadAll(resp.Body) + body := string(bodyBytes) + + if !strings.Contains(body, tt.colorClass) { + t.Errorf("Should contain row class %q", tt.colorClass) + } + if !strings.Contains(body, tt.wantCI) { + t.Errorf("Should contain CI class %q", tt.wantCI) + } + if !strings.Contains(body, tt.wantMerge) { + t.Errorf("Should contain merge class %q", tt.wantMerge) + } + }) + } +} + +// TestE2E_Server_HTMLStructure validates HTML document structure. +func TestE2E_Server_HTMLStructure(t *testing.T) { + mock := &MockConvoyFetcher{Convoys: []ConvoyRow{}} + + handler, err := NewConvoyHandler(mock) + if err != nil { + t.Fatalf("NewConvoyHandler() error = %v", err) + } + + server := httptest.NewServer(handler) + defer server.Close() + + resp, err := http.Get(server.URL) + if err != nil { + t.Fatalf("HTTP GET failed: %v", err) + } + defer resp.Body.Close() + + bodyBytes, _ := io.ReadAll(resp.Body) + body := string(bodyBytes) + + // Validate HTML structure + elements := []string{ + "", + "", + "Gas Town Dashboard", + "htmx.org", + "", + "", + "", + } + + for _, elem := range elements { + if !strings.Contains(body, elem) { + t.Errorf("Should contain HTML element %q", elem) + } + } + + // Validate CSS variables for theming + cssVars := []string{"--bg-dark", "--green", "--yellow", "--red"} + for _, v := range cssVars { + if !strings.Contains(body, v) { + t.Errorf("Should contain CSS variable %q", v) + } + } +} + +// TestE2E_Server_RefineryInPolecats tests that refinery appears in polecat workers. +func TestE2E_Server_RefineryInPolecats(t *testing.T) { + mock := &MockConvoyFetcher{ + Polecats: []PolecatRow{ + { + Name: "refinery", + Rig: "roxas", + SessionID: "gt-roxas-refinery", + LastActivity: activity.Calculate(time.Now().Add(-10 * time.Second)), + StatusHint: "Idle - Waiting for PRs", + }, + { + Name: "dag", + Rig: "roxas", + SessionID: "gt-roxas-dag", + LastActivity: activity.Calculate(time.Now().Add(-30 * time.Second)), + StatusHint: "Working on feature", + }, + }, + } + + handler, err := NewConvoyHandler(mock) + if err != nil { + t.Fatalf("NewConvoyHandler() error = %v", err) + } + + server := httptest.NewServer(handler) + defer server.Close() + + resp, err := http.Get(server.URL) + if err != nil { + t.Fatalf("HTTP GET failed: %v", err) + } + defer resp.Body.Close() + + bodyBytes, _ := io.ReadAll(resp.Body) + body := string(bodyBytes) + + // Refinery should appear in polecat workers + if !strings.Contains(body, "refinery") { + t.Error("Refinery should appear in polecat workers section") + } + if !strings.Contains(body, "Idle - Waiting for PRs") { + t.Error("Refinery idle status should be shown") + } + + // Regular polecats should also appear + if !strings.Contains(body, "dag") { + t.Error("Regular polecat 'dag' should appear") + } +} + +// Test that merge queue and polecat errors are non-fatal + +type MockConvoyFetcherWithErrors struct { + Convoys []ConvoyRow + MergeQueueError error + PolecatsError error +} + +func (m *MockConvoyFetcherWithErrors) FetchConvoys() ([]ConvoyRow, error) { + return m.Convoys, nil +} + +func (m *MockConvoyFetcherWithErrors) FetchMergeQueue() ([]MergeQueueRow, error) { + return nil, m.MergeQueueError +} + +func (m *MockConvoyFetcherWithErrors) FetchPolecats() ([]PolecatRow, error) { + return nil, m.PolecatsError +} + +func TestConvoyHandler_NonFatalErrors(t *testing.T) { + mock := &MockConvoyFetcherWithErrors{ + Convoys: []ConvoyRow{ + {ID: "hq-cv-test", Title: "Test", Status: "open", WorkStatus: "active"}, + }, + MergeQueueError: errFetchFailed, + PolecatsError: errFetchFailed, + } + + handler, err := NewConvoyHandler(mock) + if err != nil { + t.Fatalf("NewConvoyHandler() error = %v", err) + } + + req := httptest.NewRequest("GET", "/", nil) + w := httptest.NewRecorder() + + handler.ServeHTTP(w, req) + + // Should still return OK even if merge queue and polecats fail + if w.Code != http.StatusOK { + t.Errorf("Status = %d, want %d (non-fatal errors should not fail request)", w.Code, http.StatusOK) + } + + body := w.Body.String() + + // Convoys should still render + if !strings.Contains(body, "hq-cv-test") { + t.Error("Response should contain convoy data even when other fetches fail") + } +} diff --git a/internal/web/templates.go b/internal/web/templates.go index 462f2a3f..261cd8d8 100644 --- a/internal/web/templates.go +++ b/internal/web/templates.go @@ -14,14 +14,37 @@ var templateFS embed.FS // ConvoyData represents data passed to the convoy template. type ConvoyData struct { - Convoys []ConvoyRow + Convoys []ConvoyRow + MergeQueue []MergeQueueRow + Polecats []PolecatRow +} + +// PolecatRow represents a polecat worker in the dashboard. +type PolecatRow struct { + Name string // e.g., "dag", "nux" + Rig string // e.g., "roxas", "gastown" + SessionID string // e.g., "gt-roxas-dag" + LastActivity activity.Info // Colored activity display + StatusHint string // Last line from pane (optional) +} + +// MergeQueueRow represents a PR in the merge queue. +type MergeQueueRow struct { + Number int + Repo string // Short repo name (e.g., "roxas", "gastown") + Title string + URL string + CIStatus string // "pass", "fail", "pending" + Mergeable string // "ready", "conflict", "pending" + ColorClass string // "mq-green", "mq-yellow", "mq-red" } // ConvoyRow represents a single convoy in the dashboard. type ConvoyRow struct { ID string Title string - Status string // "open" or "closed" + Status string // "open" or "closed" (raw beads status) + WorkStatus string // Computed: "complete", "active", "stale", "stuck", "waiting" Progress string // e.g., "2/5" Completed int Total int @@ -43,6 +66,7 @@ func LoadTemplates() (*template.Template, error) { funcMap := template.FuncMap{ "activityClass": activityClass, "statusClass": statusClass, + "workStatusClass": workStatusClass, "progressPercent": progressPercent, } @@ -87,6 +111,24 @@ func statusClass(status string) string { } } +// workStatusClass returns the CSS class for a computed work status. +func workStatusClass(workStatus string) string { + switch workStatus { + case "complete": + return "work-complete" + case "active": + return "work-active" + case "stale": + return "work-stale" + case "stuck": + return "work-stuck" + case "waiting": + return "work-waiting" + default: + return "work-unknown" + } +} + // progressPercent calculates percentage as an integer for progress bars. func progressPercent(completed, total int) int { if total == 0 { diff --git a/internal/web/templates/convoy.html b/internal/web/templates/convoy.html index 5b8fd8aa..381e1d97 100644 --- a/internal/web/templates/convoy.html +++ b/internal/web/templates/convoy.html @@ -104,6 +104,41 @@ background: var(--green); } + /* Work status badges */ + .work-status { + display: inline-block; + padding: 2px 8px; + border-radius: 4px; + font-size: 0.75rem; + font-weight: 500; + text-transform: uppercase; + } + + .work-complete .work-status { + background: var(--green); + color: var(--bg-dark); + } + + .work-active .work-status { + background: var(--green); + color: var(--bg-dark); + } + + .work-stale .work-status { + background: var(--yellow); + color: var(--bg-dark); + } + + .work-stuck .work-status { + background: var(--red); + color: var(--bg-dark); + } + + .work-waiting .work-status { + background: var(--text-secondary); + color: var(--bg-dark); + } + /* Activity colors */ .activity-dot { display: inline-block; @@ -176,6 +211,106 @@ font-size: 0.875rem; } + .empty-state-inline { + text-align: center; + padding: 24px; + color: var(--text-secondary); + background: var(--bg-card); + border-radius: 8px; + } + + .empty-state-inline p { + font-size: 0.875rem; + margin: 0; + } + + .status-hint { + color: var(--text-secondary); + font-size: 0.875rem; + max-width: 300px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .section-header { + margin-top: 32px; + margin-bottom: 16px; + font-size: 1.25rem; + font-weight: 600; + } + + /* Merge queue colors */ + .mq-green { + background: rgba(74, 222, 128, 0.1); + } + + .mq-yellow { + background: rgba(250, 204, 21, 0.1); + } + + .mq-red { + background: rgba(248, 113, 113, 0.1); + } + + .ci-status, .merge-status { + display: inline-block; + padding: 2px 8px; + border-radius: 4px; + font-size: 0.75rem; + font-weight: 500; + } + + .ci-pass { + background: var(--green); + color: var(--bg-dark); + } + + .ci-fail { + background: var(--red); + color: var(--bg-dark); + } + + .ci-pending { + background: var(--yellow); + color: var(--bg-dark); + } + + .merge-ready { + background: var(--green); + color: var(--bg-dark); + } + + .merge-conflict { + background: var(--red); + color: var(--bg-dark); + } + + .merge-pending { + background: var(--yellow); + color: var(--bg-dark); + } + + .pr-link { + color: var(--text-primary); + text-decoration: none; + } + + .pr-link:hover { + text-decoration: underline; + } + + .pr-title { + color: var(--text-secondary); + margin-left: 8px; + max-width: 400px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + display: inline-block; + vertical-align: middle; + } + /* htmx loading indicator */ .htmx-request .htmx-indicator { opacity: 1; @@ -188,11 +323,11 @@ -
+

🚚 Gas Town Convoys

- Auto-refresh: every 30s + Auto-refresh: every 10s
@@ -209,10 +344,9 @@ {{range .Convoys}} - + - - {{if eq .Status "open"}}●{{else}}✓{{end}} + {{.WorkStatus}} {{.ID}} @@ -240,6 +374,85 @@

Create a convoy with: gt convoy create <name> [issues...]

{{end}} + +

🔀 Refinery Merge Queue

+ {{if .MergeQueue}} + + + + + + + + + + + + {{range .MergeQueue}} + + + + + + + + {{end}} + +
PR #RepoTitleCI StatusMergeable
+ #{{.Number}} + {{.Repo}} + {{.Title}} + + {{if eq .CIStatus "pass"}} + ✓ Pass + {{else if eq .CIStatus "fail"}} + ✗ Fail + {{else}} + ⏳ Pending + {{end}} + + {{if eq .Mergeable "ready"}} + Ready + {{else if eq .Mergeable "conflict"}} + Conflict + {{else}} + Pending + {{end}} +
+ {{else}} +
+

No PRs in queue

+
+ {{end}} + + {{if .Polecats}} +

🐾 Polecat Workers

+ + + + + + + + + + + {{range .Polecats}} + + + + + + + {{end}} + +
PolecatRigLast ActivityStatus
+ {{.Name}} + {{.Rig}} + + {{.LastActivity.FormattedAge}} + {{.StatusHint}}
+ {{end}}
diff --git a/internal/web/templates_test.go b/internal/web/templates_test.go index f428fb71..406405a8 100644 --- a/internal/web/templates_test.go +++ b/internal/web/templates_test.go @@ -137,8 +137,8 @@ func TestConvoyTemplate_HtmxAutoRefresh(t *testing.T) { if !strings.Contains(output, "hx-trigger") { t.Error("Template should contain hx-trigger for auto-refresh") } - if !strings.Contains(output, "every 30s") { - t.Error("Template should refresh every 30 seconds") + if !strings.Contains(output, "every 10s") { + t.Error("Template should refresh every 10 seconds") } }