Merge pull request #71 from michaellady/feature/convoy-dashboard
Add lint fixes and E2E tests for convoy dashboard
This commit is contained in:
27
README.md
27
README.md
@@ -260,6 +260,33 @@ gt doctor # Health check
|
|||||||
gt doctor --fix # Auto-repair
|
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
|
## Shell Completions
|
||||||
|
|
||||||
Enable tab completion for `gt` commands:
|
Enable tab completion for `gt` commands:
|
||||||
|
|||||||
@@ -76,6 +76,9 @@ func runDashboard(cmd *cobra.Command, args []string) error {
|
|||||||
Addr: fmt.Sprintf(":%d", dashboardPort),
|
Addr: fmt.Sprintf(":%d", dashboardPort),
|
||||||
Handler: handler,
|
Handler: handler,
|
||||||
ReadHeaderTimeout: 10 * time.Second,
|
ReadHeaderTimeout: 10 * time.Second,
|
||||||
|
ReadTimeout: 30 * time.Second,
|
||||||
|
WriteTimeout: 60 * time.Second,
|
||||||
|
IdleTimeout: 120 * time.Second,
|
||||||
}
|
}
|
||||||
return server.ListenAndServe()
|
return server.ListenAndServe()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,9 @@ const (
|
|||||||
ShellReadyTimeout = 5 * time.Second
|
ShellReadyTimeout = 5 * time.Second
|
||||||
|
|
||||||
// DefaultDebounceMs is the default debounce for SendKeys operations.
|
// 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 is the default duration for tmux display-message.
|
||||||
DefaultDisplayMs = 5000
|
DefaultDisplayMs = 5000
|
||||||
|
|||||||
397
internal/web/browser_e2e_test.go
Normal file
397
internal/web/browser_e2e_test.go
Normal file
@@ -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")
|
||||||
|
}
|
||||||
@@ -30,6 +30,7 @@ func NewLiveConvoyFetcher() (*LiveConvoyFetcher, error) {
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// FetchConvoys fetches all open convoys with their activity data.
|
// FetchConvoys fetches all open convoys with their activity data.
|
||||||
func (f *LiveConvoyFetcher) FetchConvoys() ([]ConvoyRow, error) {
|
func (f *LiveConvoyFetcher) FetchConvoys() ([]ConvoyRow, error) {
|
||||||
// List all open convoy-type issues
|
// List all open convoy-type issues
|
||||||
@@ -68,6 +69,8 @@ func (f *LiveConvoyFetcher) FetchConvoys() ([]ConvoyRow, error) {
|
|||||||
row.Total = len(tracked)
|
row.Total = len(tracked)
|
||||||
|
|
||||||
var mostRecentActivity time.Time
|
var mostRecentActivity time.Time
|
||||||
|
var mostRecentUpdated time.Time
|
||||||
|
var hasAssignee bool
|
||||||
for _, t := range tracked {
|
for _, t := range tracked {
|
||||||
if t.Status == "closed" {
|
if t.Status == "closed" {
|
||||||
row.Completed++
|
row.Completed++
|
||||||
@@ -76,20 +79,50 @@ func (f *LiveConvoyFetcher) FetchConvoys() ([]ConvoyRow, error) {
|
|||||||
if t.LastActivity.After(mostRecentActivity) {
|
if t.LastActivity.After(mostRecentActivity) {
|
||||||
mostRecentActivity = t.LastActivity
|
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)
|
row.Progress = fmt.Sprintf("%d/%d", row.Completed, row.Total)
|
||||||
|
|
||||||
// Calculate activity info from most recent worker activity
|
// Calculate activity info from most recent worker activity
|
||||||
if !mostRecentActivity.IsZero() {
|
if !mostRecentActivity.IsZero() {
|
||||||
|
// Have active tmux session activity from assigned workers
|
||||||
row.LastActivity = activity.Calculate(mostRecentActivity)
|
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 {
|
} else {
|
||||||
|
// Has assignee but no active session
|
||||||
row.LastActivity = activity.Info{
|
row.LastActivity = activity.Info{
|
||||||
FormattedAge: "no activity",
|
FormattedAge: "idle",
|
||||||
ColorClass: activity.ColorUnknown,
|
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
|
// Get tracked issues for expandable view
|
||||||
row.TrackedIssues = make([]TrackedIssue, len(tracked))
|
row.TrackedIssues = make([]TrackedIssue, len(tracked))
|
||||||
for i, t := range tracked {
|
for i, t := range tracked {
|
||||||
@@ -114,6 +147,7 @@ type trackedIssueInfo struct {
|
|||||||
Status string
|
Status string
|
||||||
Assignee string
|
Assignee string
|
||||||
LastActivity time.Time
|
LastActivity time.Time
|
||||||
|
UpdatedAt time.Time // Fallback for activity when no assignee
|
||||||
}
|
}
|
||||||
|
|
||||||
// getTrackedIssues fetches tracked issues for a convoy.
|
// getTrackedIssues fetches tracked issues for a convoy.
|
||||||
@@ -122,6 +156,7 @@ func (f *LiveConvoyFetcher) getTrackedIssues(convoyID string) []trackedIssueInfo
|
|||||||
|
|
||||||
// Query tracked dependencies from SQLite
|
// Query tracked dependencies from SQLite
|
||||||
safeConvoyID := strings.ReplaceAll(convoyID, "'", "''")
|
safeConvoyID := strings.ReplaceAll(convoyID, "'", "''")
|
||||||
|
// #nosec G204 -- sqlite3 path is from trusted config, convoyID is escaped
|
||||||
queryCmd := exec.Command("sqlite3", "-json", dbPath,
|
queryCmd := exec.Command("sqlite3", "-json", dbPath,
|
||||||
fmt.Sprintf(`SELECT depends_on_id, type FROM dependencies WHERE issue_id = '%s' AND type = 'tracks'`, safeConvoyID))
|
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
|
// Batch fetch issue details
|
||||||
details := f.getIssueDetailsBatch(issueIDs)
|
details := f.getIssueDetailsBatch(issueIDs)
|
||||||
|
|
||||||
// Get worker info for activity timestamps
|
// Get worker activity from tmux sessions based on assignees
|
||||||
workers := f.getWorkersForIssues(issueIDs)
|
workers := f.getWorkersFromAssignees(details)
|
||||||
|
|
||||||
// Build result
|
// Build result
|
||||||
result := make([]trackedIssueInfo, 0, len(issueIDs))
|
result := make([]trackedIssueInfo, 0, len(issueIDs))
|
||||||
@@ -167,6 +202,7 @@ func (f *LiveConvoyFetcher) getTrackedIssues(convoyID string) []trackedIssueInfo
|
|||||||
info.Title = d.Title
|
info.Title = d.Title
|
||||||
info.Status = d.Status
|
info.Status = d.Status
|
||||||
info.Assignee = d.Assignee
|
info.Assignee = d.Assignee
|
||||||
|
info.UpdatedAt = d.UpdatedAt
|
||||||
} else {
|
} else {
|
||||||
info.Title = "(external)"
|
info.Title = "(external)"
|
||||||
info.Status = "unknown"
|
info.Status = "unknown"
|
||||||
@@ -184,10 +220,11 @@ func (f *LiveConvoyFetcher) getTrackedIssues(convoyID string) []trackedIssueInfo
|
|||||||
|
|
||||||
// issueDetail holds basic issue info.
|
// issueDetail holds basic issue info.
|
||||||
type issueDetail struct {
|
type issueDetail struct {
|
||||||
ID string
|
ID string
|
||||||
Title string
|
Title string
|
||||||
Status string
|
Status string
|
||||||
Assignee string
|
Assignee string
|
||||||
|
UpdatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// getIssueDetailsBatch fetches details for multiple issues.
|
// 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([]string{"show"}, issueIDs...)
|
||||||
args = append(args, "--json")
|
args = append(args, "--json")
|
||||||
|
|
||||||
|
// #nosec G204 -- bd is a trusted internal tool, args are issue IDs
|
||||||
showCmd := exec.Command("bd", args...)
|
showCmd := exec.Command("bd", args...)
|
||||||
var stdout bytes.Buffer
|
var stdout bytes.Buffer
|
||||||
showCmd.Stdout = &stdout
|
showCmd.Stdout = &stdout
|
||||||
@@ -209,22 +247,30 @@ func (f *LiveConvoyFetcher) getIssueDetailsBatch(issueIDs []string) map[string]*
|
|||||||
}
|
}
|
||||||
|
|
||||||
var issues []struct {
|
var issues []struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
Assignee string `json:"assignee"`
|
Assignee string `json:"assignee"`
|
||||||
|
UpdatedAt string `json:"updated_at"`
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(stdout.Bytes(), &issues); err != nil {
|
if err := json.Unmarshal(stdout.Bytes(), &issues); err != nil {
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, issue := range issues {
|
for _, issue := range issues {
|
||||||
result[issue.ID] = &issueDetail{
|
detail := &issueDetail{
|
||||||
ID: issue.ID,
|
ID: issue.ID,
|
||||||
Title: issue.Title,
|
Title: issue.Title,
|
||||||
Status: issue.Status,
|
Status: issue.Status,
|
||||||
Assignee: issue.Assignee,
|
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
|
return result
|
||||||
@@ -236,62 +282,474 @@ type workerDetail struct {
|
|||||||
LastActivity *time.Time
|
LastActivity *time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// getWorkersForIssues finds workers and their last activity for issues.
|
// getWorkersFromAssignees gets worker activity from tmux sessions based on issue assignees.
|
||||||
func (f *LiveConvoyFetcher) getWorkersForIssues(issueIDs []string) map[string]*workerDetail {
|
// 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)
|
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
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
townRoot, _ := workspace.FindFromCwd()
|
// For each unique assignee, look up tmux session activity
|
||||||
if townRoot == "" {
|
for assignee, issueIDs := range assigneeToIssues {
|
||||||
return result
|
activity := f.getSessionActivityForAssignee(assignee)
|
||||||
}
|
if activity == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
// Find all rig beads databases
|
// Apply this activity to all issues assigned to this worker
|
||||||
rigDirs, _ := filepath.Glob(filepath.Join(townRoot, "*", "mayor", "rig", ".beads", "beads.db"))
|
|
||||||
|
|
||||||
for _, dbPath := range rigDirs {
|
|
||||||
for _, issueID := range issueIDs {
|
for _, issueID := range issueIDs {
|
||||||
if _, ok := result[issueID]; ok {
|
result[issueID] = &workerDetail{
|
||||||
continue
|
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
|
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-<rig>-<polecat> 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> -> (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
|
||||||
|
}
|
||||||
|
|||||||
417
internal/web/fetcher_test.go
Normal file
417
internal/web/fetcher_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,8 @@ import (
|
|||||||
// ConvoyFetcher defines the interface for fetching convoy data.
|
// ConvoyFetcher defines the interface for fetching convoy data.
|
||||||
type ConvoyFetcher interface {
|
type ConvoyFetcher interface {
|
||||||
FetchConvoys() ([]ConvoyRow, error)
|
FetchConvoys() ([]ConvoyRow, error)
|
||||||
|
FetchMergeQueue() ([]MergeQueueRow, error)
|
||||||
|
FetchPolecats() ([]PolecatRow, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ConvoyHandler handles HTTP requests for the convoy dashboard.
|
// ConvoyHandler handles HTTP requests for the convoy dashboard.
|
||||||
@@ -37,8 +39,22 @@ func (h *ConvoyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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{
|
data := ConvoyData{
|
||||||
Convoys: convoys,
|
Convoys: convoys,
|
||||||
|
MergeQueue: mergeQueue,
|
||||||
|
Polecats: polecats,
|
||||||
}
|
}
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package web
|
package web
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -10,16 +12,29 @@ import (
|
|||||||
"github.com/steveyegge/gastown/internal/activity"
|
"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.
|
// MockConvoyFetcher is a mock implementation for testing.
|
||||||
type MockConvoyFetcher struct {
|
type MockConvoyFetcher struct {
|
||||||
Convoys []ConvoyRow
|
Convoys []ConvoyRow
|
||||||
Error error
|
MergeQueue []MergeQueueRow
|
||||||
|
Polecats []PolecatRow
|
||||||
|
Error error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MockConvoyFetcher) FetchConvoys() ([]ConvoyRow, error) {
|
func (m *MockConvoyFetcher) FetchConvoys() ([]ConvoyRow, error) {
|
||||||
return m.Convoys, m.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) {
|
func TestConvoyHandler_RendersTemplate(t *testing.T) {
|
||||||
mock := &MockConvoyFetcher{
|
mock := &MockConvoyFetcher{
|
||||||
Convoys: []ConvoyRow{
|
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{
|
||||||
|
"<!DOCTYPE html>",
|
||||||
|
"<html",
|
||||||
|
"<head>",
|
||||||
|
"<title>Gas Town Dashboard</title>",
|
||||||
|
"htmx.org",
|
||||||
|
"<body>",
|
||||||
|
"</body>",
|
||||||
|
"</html>",
|
||||||
|
}
|
||||||
|
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,14 +14,37 @@ var templateFS embed.FS
|
|||||||
|
|
||||||
// ConvoyData represents data passed to the convoy template.
|
// ConvoyData represents data passed to the convoy template.
|
||||||
type ConvoyData struct {
|
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.
|
// ConvoyRow represents a single convoy in the dashboard.
|
||||||
type ConvoyRow struct {
|
type ConvoyRow struct {
|
||||||
ID string
|
ID string
|
||||||
Title 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"
|
Progress string // e.g., "2/5"
|
||||||
Completed int
|
Completed int
|
||||||
Total int
|
Total int
|
||||||
@@ -43,6 +66,7 @@ func LoadTemplates() (*template.Template, error) {
|
|||||||
funcMap := template.FuncMap{
|
funcMap := template.FuncMap{
|
||||||
"activityClass": activityClass,
|
"activityClass": activityClass,
|
||||||
"statusClass": statusClass,
|
"statusClass": statusClass,
|
||||||
|
"workStatusClass": workStatusClass,
|
||||||
"progressPercent": progressPercent,
|
"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.
|
// progressPercent calculates percentage as an integer for progress bars.
|
||||||
func progressPercent(completed, total int) int {
|
func progressPercent(completed, total int) int {
|
||||||
if total == 0 {
|
if total == 0 {
|
||||||
|
|||||||
@@ -104,6 +104,41 @@
|
|||||||
background: var(--green);
|
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 colors */
|
||||||
.activity-dot {
|
.activity-dot {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
@@ -176,6 +211,106 @@
|
|||||||
font-size: 0.875rem;
|
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 loading indicator */
|
||||||
.htmx-request .htmx-indicator {
|
.htmx-request .htmx-indicator {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
@@ -188,11 +323,11 @@
|
|||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="dashboard" hx-get="/" hx-trigger="every 30s" hx-swap="outerHTML">
|
<div class="dashboard" hx-get="/" hx-trigger="every 10s" hx-swap="outerHTML">
|
||||||
<header>
|
<header>
|
||||||
<h1>🚚 Gas Town Convoys</h1>
|
<h1>🚚 Gas Town Convoys</h1>
|
||||||
<span class="refresh-info">
|
<span class="refresh-info">
|
||||||
Auto-refresh: every 30s
|
Auto-refresh: every 10s
|
||||||
<span class="htmx-indicator">⟳</span>
|
<span class="htmx-indicator">⟳</span>
|
||||||
</span>
|
</span>
|
||||||
</header>
|
</header>
|
||||||
@@ -209,10 +344,9 @@
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{{range .Convoys}}
|
{{range .Convoys}}
|
||||||
<tr class="{{statusClass .Status}}">
|
<tr class="{{workStatusClass .WorkStatus}}">
|
||||||
<td>
|
<td>
|
||||||
<span class="status-indicator"></span>
|
<span class="work-status">{{.WorkStatus}}</span>
|
||||||
{{if eq .Status "open"}}●{{else}}✓{{end}}
|
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<span class="convoy-id">{{.ID}}</span>
|
<span class="convoy-id">{{.ID}}</span>
|
||||||
@@ -240,6 +374,85 @@
|
|||||||
<p>Create a convoy with: gt convoy create <name> [issues...]</p>
|
<p>Create a convoy with: gt convoy create <name> [issues...]</p>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
|
<h2 class="section-header">🔀 Refinery Merge Queue</h2>
|
||||||
|
{{if .MergeQueue}}
|
||||||
|
<table class="convoy-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>PR #</th>
|
||||||
|
<th>Repo</th>
|
||||||
|
<th>Title</th>
|
||||||
|
<th>CI Status</th>
|
||||||
|
<th>Mergeable</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .MergeQueue}}
|
||||||
|
<tr class="{{.ColorClass}}">
|
||||||
|
<td>
|
||||||
|
<a href="{{.URL}}" target="_blank" class="pr-link">#{{.Number}}</a>
|
||||||
|
</td>
|
||||||
|
<td>{{.Repo}}</td>
|
||||||
|
<td>
|
||||||
|
<span class="pr-title">{{.Title}}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{{if eq .CIStatus "pass"}}
|
||||||
|
<span class="ci-status ci-pass">✓ Pass</span>
|
||||||
|
{{else if eq .CIStatus "fail"}}
|
||||||
|
<span class="ci-status ci-fail">✗ Fail</span>
|
||||||
|
{{else}}
|
||||||
|
<span class="ci-status ci-pending">⏳ Pending</span>
|
||||||
|
{{end}}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{{if eq .Mergeable "ready"}}
|
||||||
|
<span class="merge-status merge-ready">Ready</span>
|
||||||
|
{{else if eq .Mergeable "conflict"}}
|
||||||
|
<span class="merge-status merge-conflict">Conflict</span>
|
||||||
|
{{else}}
|
||||||
|
<span class="merge-status merge-pending">Pending</span>
|
||||||
|
{{end}}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{{else}}
|
||||||
|
<div class="empty-state-inline">
|
||||||
|
<p>No PRs in queue</p>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{if .Polecats}}
|
||||||
|
<h2 class="section-header">🐾 Polecat Workers</h2>
|
||||||
|
<table class="convoy-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Polecat</th>
|
||||||
|
<th>Rig</th>
|
||||||
|
<th>Last Activity</th>
|
||||||
|
<th>Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .Polecats}}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<span class="convoy-id">{{.Name}}</span>
|
||||||
|
</td>
|
||||||
|
<td>{{.Rig}}</td>
|
||||||
|
<td class="{{activityClass .LastActivity}}">
|
||||||
|
<span class="activity-dot"></span>
|
||||||
|
{{.LastActivity.FormattedAge}}
|
||||||
|
</td>
|
||||||
|
<td class="status-hint">{{.StatusHint}}</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -137,8 +137,8 @@ func TestConvoyTemplate_HtmxAutoRefresh(t *testing.T) {
|
|||||||
if !strings.Contains(output, "hx-trigger") {
|
if !strings.Contains(output, "hx-trigger") {
|
||||||
t.Error("Template should contain hx-trigger for auto-refresh")
|
t.Error("Template should contain hx-trigger for auto-refresh")
|
||||||
}
|
}
|
||||||
if !strings.Contains(output, "every 30s") {
|
if !strings.Contains(output, "every 10s") {
|
||||||
t.Error("Template should refresh every 30 seconds")
|
t.Error("Template should refresh every 10 seconds")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user