Merge pull request #71 from michaellady/feature/convoy-dashboard
Add lint fixes and E2E tests for convoy dashboard
This commit is contained in:
@@ -76,6 +76,9 @@ func runDashboard(cmd *cobra.Command, args []string) error {
|
||||
Addr: fmt.Sprintf(":%d", dashboardPort),
|
||||
Handler: handler,
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
ReadTimeout: 30 * time.Second,
|
||||
WriteTimeout: 60 * time.Second,
|
||||
IdleTimeout: 120 * time.Second,
|
||||
}
|
||||
return server.ListenAndServe()
|
||||
}
|
||||
|
||||
@@ -17,7 +17,9 @@ const (
|
||||
ShellReadyTimeout = 5 * time.Second
|
||||
|
||||
// DefaultDebounceMs is the default debounce for SendKeys operations.
|
||||
DefaultDebounceMs = 100
|
||||
// 500ms is required for Claude Code to reliably process paste before Enter.
|
||||
// See NudgeSession comment: "Wait 500ms for paste to complete (tested, required)"
|
||||
DefaultDebounceMs = 500
|
||||
|
||||
// DefaultDisplayMs is the default duration for tmux display-message.
|
||||
DefaultDisplayMs = 5000
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
// FetchConvoys fetches all open convoys with their activity data.
|
||||
func (f *LiveConvoyFetcher) FetchConvoys() ([]ConvoyRow, error) {
|
||||
// List all open convoy-type issues
|
||||
@@ -68,6 +69,8 @@ func (f *LiveConvoyFetcher) FetchConvoys() ([]ConvoyRow, error) {
|
||||
row.Total = len(tracked)
|
||||
|
||||
var mostRecentActivity time.Time
|
||||
var mostRecentUpdated time.Time
|
||||
var hasAssignee bool
|
||||
for _, t := range tracked {
|
||||
if t.Status == "closed" {
|
||||
row.Completed++
|
||||
@@ -76,20 +79,50 @@ func (f *LiveConvoyFetcher) FetchConvoys() ([]ConvoyRow, error) {
|
||||
if t.LastActivity.After(mostRecentActivity) {
|
||||
mostRecentActivity = t.LastActivity
|
||||
}
|
||||
// Track most recent updated_at as fallback
|
||||
if t.UpdatedAt.After(mostRecentUpdated) {
|
||||
mostRecentUpdated = t.UpdatedAt
|
||||
}
|
||||
if t.Assignee != "" {
|
||||
hasAssignee = true
|
||||
}
|
||||
}
|
||||
|
||||
row.Progress = fmt.Sprintf("%d/%d", row.Completed, row.Total)
|
||||
|
||||
// Calculate activity info from most recent worker activity
|
||||
if !mostRecentActivity.IsZero() {
|
||||
// Have active tmux session activity from assigned workers
|
||||
row.LastActivity = activity.Calculate(mostRecentActivity)
|
||||
} else if !hasAssignee {
|
||||
// No assignees found in beads - try fallback to any running polecat activity
|
||||
// This handles cases where bd update --assignee didn't persist or wasn't returned
|
||||
if polecatActivity := f.getAllPolecatActivity(); polecatActivity != nil {
|
||||
info := activity.Calculate(*polecatActivity)
|
||||
info.FormattedAge = info.FormattedAge + " (polecat active)"
|
||||
row.LastActivity = info
|
||||
} else if !mostRecentUpdated.IsZero() {
|
||||
// Fall back to issue updated_at if no polecats running
|
||||
info := activity.Calculate(mostRecentUpdated)
|
||||
info.FormattedAge = info.FormattedAge + " (unassigned)"
|
||||
row.LastActivity = info
|
||||
} else {
|
||||
row.LastActivity = activity.Info{
|
||||
FormattedAge: "unassigned",
|
||||
ColorClass: activity.ColorUnknown,
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Has assignee but no active session
|
||||
row.LastActivity = activity.Info{
|
||||
FormattedAge: "no activity",
|
||||
FormattedAge: "idle",
|
||||
ColorClass: activity.ColorUnknown,
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate work status based on progress and activity
|
||||
row.WorkStatus = calculateWorkStatus(row.Completed, row.Total, row.LastActivity.ColorClass)
|
||||
|
||||
// Get tracked issues for expandable view
|
||||
row.TrackedIssues = make([]TrackedIssue, len(tracked))
|
||||
for i, t := range tracked {
|
||||
@@ -114,6 +147,7 @@ type trackedIssueInfo struct {
|
||||
Status string
|
||||
Assignee string
|
||||
LastActivity time.Time
|
||||
UpdatedAt time.Time // Fallback for activity when no assignee
|
||||
}
|
||||
|
||||
// getTrackedIssues fetches tracked issues for a convoy.
|
||||
@@ -122,6 +156,7 @@ func (f *LiveConvoyFetcher) getTrackedIssues(convoyID string) []trackedIssueInfo
|
||||
|
||||
// Query tracked dependencies from SQLite
|
||||
safeConvoyID := strings.ReplaceAll(convoyID, "'", "''")
|
||||
// #nosec G204 -- sqlite3 path is from trusted config, convoyID is escaped
|
||||
queryCmd := exec.Command("sqlite3", "-json", dbPath,
|
||||
fmt.Sprintf(`SELECT depends_on_id, type FROM dependencies WHERE issue_id = '%s' AND type = 'tracks'`, safeConvoyID))
|
||||
|
||||
@@ -155,8 +190,8 @@ func (f *LiveConvoyFetcher) getTrackedIssues(convoyID string) []trackedIssueInfo
|
||||
// Batch fetch issue details
|
||||
details := f.getIssueDetailsBatch(issueIDs)
|
||||
|
||||
// Get worker info for activity timestamps
|
||||
workers := f.getWorkersForIssues(issueIDs)
|
||||
// Get worker activity from tmux sessions based on assignees
|
||||
workers := f.getWorkersFromAssignees(details)
|
||||
|
||||
// Build result
|
||||
result := make([]trackedIssueInfo, 0, len(issueIDs))
|
||||
@@ -167,6 +202,7 @@ func (f *LiveConvoyFetcher) getTrackedIssues(convoyID string) []trackedIssueInfo
|
||||
info.Title = d.Title
|
||||
info.Status = d.Status
|
||||
info.Assignee = d.Assignee
|
||||
info.UpdatedAt = d.UpdatedAt
|
||||
} else {
|
||||
info.Title = "(external)"
|
||||
info.Status = "unknown"
|
||||
@@ -184,10 +220,11 @@ func (f *LiveConvoyFetcher) getTrackedIssues(convoyID string) []trackedIssueInfo
|
||||
|
||||
// issueDetail holds basic issue info.
|
||||
type issueDetail struct {
|
||||
ID string
|
||||
Title string
|
||||
Status string
|
||||
Assignee string
|
||||
ID string
|
||||
Title string
|
||||
Status string
|
||||
Assignee string
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// getIssueDetailsBatch fetches details for multiple issues.
|
||||
@@ -200,6 +237,7 @@ func (f *LiveConvoyFetcher) getIssueDetailsBatch(issueIDs []string) map[string]*
|
||||
args := append([]string{"show"}, issueIDs...)
|
||||
args = append(args, "--json")
|
||||
|
||||
// #nosec G204 -- bd is a trusted internal tool, args are issue IDs
|
||||
showCmd := exec.Command("bd", args...)
|
||||
var stdout bytes.Buffer
|
||||
showCmd.Stdout = &stdout
|
||||
@@ -209,22 +247,30 @@ func (f *LiveConvoyFetcher) getIssueDetailsBatch(issueIDs []string) map[string]*
|
||||
}
|
||||
|
||||
var issues []struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Status string `json:"status"`
|
||||
Assignee string `json:"assignee"`
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Status string `json:"status"`
|
||||
Assignee string `json:"assignee"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &issues); err != nil {
|
||||
return result
|
||||
}
|
||||
|
||||
for _, issue := range issues {
|
||||
result[issue.ID] = &issueDetail{
|
||||
detail := &issueDetail{
|
||||
ID: issue.ID,
|
||||
Title: issue.Title,
|
||||
Status: issue.Status,
|
||||
Assignee: issue.Assignee,
|
||||
}
|
||||
// Parse updated_at timestamp
|
||||
if issue.UpdatedAt != "" {
|
||||
if t, err := time.Parse(time.RFC3339, issue.UpdatedAt); err == nil {
|
||||
detail.UpdatedAt = t
|
||||
}
|
||||
}
|
||||
result[issue.ID] = detail
|
||||
}
|
||||
|
||||
return result
|
||||
@@ -236,62 +282,474 @@ type workerDetail struct {
|
||||
LastActivity *time.Time
|
||||
}
|
||||
|
||||
// getWorkersForIssues finds workers and their last activity for issues.
|
||||
func (f *LiveConvoyFetcher) getWorkersForIssues(issueIDs []string) map[string]*workerDetail {
|
||||
// getWorkersFromAssignees gets worker activity from tmux sessions based on issue assignees.
|
||||
// Assignees are in format "rigname/polecats/polecatname" which maps to tmux session "gt-rigname-polecatname".
|
||||
func (f *LiveConvoyFetcher) getWorkersFromAssignees(details map[string]*issueDetail) map[string]*workerDetail {
|
||||
result := make(map[string]*workerDetail)
|
||||
if len(issueIDs) == 0 {
|
||||
|
||||
// Collect unique assignees and map them to issue IDs
|
||||
assigneeToIssues := make(map[string][]string)
|
||||
for issueID, detail := range details {
|
||||
if detail == nil || detail.Assignee == "" {
|
||||
continue
|
||||
}
|
||||
assigneeToIssues[detail.Assignee] = append(assigneeToIssues[detail.Assignee], issueID)
|
||||
}
|
||||
|
||||
if len(assigneeToIssues) == 0 {
|
||||
return result
|
||||
}
|
||||
|
||||
townRoot, _ := workspace.FindFromCwd()
|
||||
if townRoot == "" {
|
||||
return result
|
||||
}
|
||||
// For each unique assignee, look up tmux session activity
|
||||
for assignee, issueIDs := range assigneeToIssues {
|
||||
activity := f.getSessionActivityForAssignee(assignee)
|
||||
if activity == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Find all rig beads databases
|
||||
rigDirs, _ := filepath.Glob(filepath.Join(townRoot, "*", "mayor", "rig", ".beads", "beads.db"))
|
||||
|
||||
for _, dbPath := range rigDirs {
|
||||
// Apply this activity to all issues assigned to this worker
|
||||
for _, issueID := range issueIDs {
|
||||
if _, ok := result[issueID]; ok {
|
||||
continue
|
||||
result[issueID] = &workerDetail{
|
||||
Worker: assignee,
|
||||
LastActivity: activity,
|
||||
}
|
||||
|
||||
safeID := strings.ReplaceAll(issueID, "'", "''")
|
||||
query := fmt.Sprintf(
|
||||
`SELECT id, hook_bead, last_activity FROM issues WHERE issue_type = 'agent' AND status = 'open' AND hook_bead = '%s' LIMIT 1`,
|
||||
safeID)
|
||||
|
||||
queryCmd := exec.Command("sqlite3", "-json", dbPath, query)
|
||||
var stdout bytes.Buffer
|
||||
queryCmd.Stdout = &stdout
|
||||
if err := queryCmd.Run(); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
var agents []struct {
|
||||
ID string `json:"id"`
|
||||
HookBead string `json:"hook_bead"`
|
||||
LastActivity string `json:"last_activity"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &agents); err != nil || len(agents) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
agent := agents[0]
|
||||
detail := &workerDetail{
|
||||
Worker: agent.ID,
|
||||
}
|
||||
|
||||
if agent.LastActivity != "" {
|
||||
if t, err := time.Parse(time.RFC3339, agent.LastActivity); err == nil {
|
||||
detail.LastActivity = &t
|
||||
}
|
||||
}
|
||||
|
||||
result[issueID] = detail
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// getSessionActivityForAssignee looks up tmux session activity for an assignee.
|
||||
// Assignee format: "rigname/polecats/polecatname" -> session "gt-rigname-polecatname"
|
||||
func (f *LiveConvoyFetcher) getSessionActivityForAssignee(assignee string) *time.Time {
|
||||
// Parse assignee: "roxas/polecats/dag" -> rig="roxas", polecat="dag"
|
||||
parts := strings.Split(assignee, "/")
|
||||
if len(parts) != 3 || parts[1] != "polecats" {
|
||||
return nil
|
||||
}
|
||||
rig := parts[0]
|
||||
polecat := parts[2]
|
||||
|
||||
// Construct session name
|
||||
sessionName := fmt.Sprintf("gt-%s-%s", rig, polecat)
|
||||
|
||||
// Query tmux for session activity
|
||||
// Format: session_activity returns unix timestamp
|
||||
cmd := exec.Command("tmux", "list-sessions", "-F", "#{session_name}|#{session_activity}",
|
||||
"-f", fmt.Sprintf("#{==:#{session_name},%s}", sessionName))
|
||||
var stdout bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
if err := cmd.Run(); err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
output := strings.TrimSpace(stdout.String())
|
||||
if output == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Parse output: "gt-roxas-dag|1704312345"
|
||||
outputParts := strings.Split(output, "|")
|
||||
if len(outputParts) < 2 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var activityUnix int64
|
||||
if _, err := fmt.Sscanf(outputParts[1], "%d", &activityUnix); err != nil || activityUnix == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
activity := time.Unix(activityUnix, 0)
|
||||
return &activity
|
||||
}
|
||||
|
||||
// getAllPolecatActivity returns the most recent activity from any running polecat session.
|
||||
// This is used as a fallback when no specific assignee activity can be determined.
|
||||
// Returns nil if no polecat sessions are running.
|
||||
func (f *LiveConvoyFetcher) getAllPolecatActivity() *time.Time {
|
||||
// List all tmux sessions matching gt-*-* pattern (polecat sessions)
|
||||
// Format: gt-{rig}-{polecat}
|
||||
cmd := exec.Command("tmux", "list-sessions", "-F", "#{session_name}|#{session_activity}")
|
||||
var stdout bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
if err := cmd.Run(); err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var mostRecent time.Time
|
||||
for _, line := range strings.Split(stdout.String(), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
parts := strings.Split(line, "|")
|
||||
if len(parts) < 2 {
|
||||
continue
|
||||
}
|
||||
|
||||
sessionName := parts[0]
|
||||
// Check if it's a polecat session (gt-{rig}-{polecat}, not gt-{rig}-witness/refinery)
|
||||
// Polecat sessions have exactly 3 parts when split by "-" and the middle part is the rig
|
||||
nameParts := strings.Split(sessionName, "-")
|
||||
if len(nameParts) < 3 || nameParts[0] != "gt" {
|
||||
continue
|
||||
}
|
||||
// Skip witness, refinery, mayor, deacon sessions
|
||||
lastPart := nameParts[len(nameParts)-1]
|
||||
if lastPart == "witness" || lastPart == "refinery" || lastPart == "mayor" || lastPart == "deacon" {
|
||||
continue
|
||||
}
|
||||
|
||||
var activityUnix int64
|
||||
if _, err := fmt.Sscanf(parts[1], "%d", &activityUnix); err != nil || activityUnix == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
activityTime := time.Unix(activityUnix, 0)
|
||||
if activityTime.After(mostRecent) {
|
||||
mostRecent = activityTime
|
||||
}
|
||||
}
|
||||
|
||||
if mostRecent.IsZero() {
|
||||
return nil
|
||||
}
|
||||
return &mostRecent
|
||||
}
|
||||
|
||||
// calculateWorkStatus determines the work status based on progress and activity.
|
||||
// Returns: "complete", "active", "stale", "stuck", or "waiting"
|
||||
func calculateWorkStatus(completed, total int, activityColor string) string {
|
||||
// Check if all work is done
|
||||
if total > 0 && completed == total {
|
||||
return "complete"
|
||||
}
|
||||
|
||||
// Determine status based on activity color
|
||||
switch activityColor {
|
||||
case activity.ColorGreen:
|
||||
return "active"
|
||||
case activity.ColorYellow:
|
||||
return "stale"
|
||||
case activity.ColorRed:
|
||||
return "stuck"
|
||||
default:
|
||||
return "waiting"
|
||||
}
|
||||
}
|
||||
|
||||
// FetchMergeQueue fetches open PRs from configured repos.
|
||||
func (f *LiveConvoyFetcher) FetchMergeQueue() ([]MergeQueueRow, error) {
|
||||
// Repos to query for PRs
|
||||
repos := []struct {
|
||||
Full string // Full repo path for gh CLI
|
||||
Short string // Short name for display
|
||||
}{
|
||||
{"michaellady/roxas", "roxas"},
|
||||
{"michaellady/gastown", "gastown"},
|
||||
}
|
||||
|
||||
var result []MergeQueueRow
|
||||
|
||||
for _, repo := range repos {
|
||||
prs, err := f.fetchPRsForRepo(repo.Full, repo.Short)
|
||||
if err != nil {
|
||||
// Non-fatal: continue with other repos
|
||||
continue
|
||||
}
|
||||
result = append(result, prs...)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// prResponse represents the JSON response from gh pr list.
|
||||
type prResponse struct {
|
||||
Number int `json:"number"`
|
||||
Title string `json:"title"`
|
||||
URL string `json:"url"`
|
||||
Mergeable string `json:"mergeable"`
|
||||
StatusCheckRollup []struct {
|
||||
State string `json:"state"`
|
||||
Status string `json:"status"`
|
||||
Conclusion string `json:"conclusion"`
|
||||
} `json:"statusCheckRollup"`
|
||||
}
|
||||
|
||||
// fetchPRsForRepo fetches open PRs for a single repo.
|
||||
func (f *LiveConvoyFetcher) fetchPRsForRepo(repoFull, repoShort string) ([]MergeQueueRow, error) {
|
||||
// #nosec G204 -- gh is a trusted CLI, repo is from hardcoded list
|
||||
cmd := exec.Command("gh", "pr", "list",
|
||||
"--repo", repoFull,
|
||||
"--state", "open",
|
||||
"--json", "number,title,url,mergeable,statusCheckRollup")
|
||||
|
||||
var stdout bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
return nil, fmt.Errorf("fetching PRs for %s: %w", repoFull, err)
|
||||
}
|
||||
|
||||
var prs []prResponse
|
||||
if err := json.Unmarshal(stdout.Bytes(), &prs); err != nil {
|
||||
return nil, fmt.Errorf("parsing PRs for %s: %w", repoFull, err)
|
||||
}
|
||||
|
||||
result := make([]MergeQueueRow, 0, len(prs))
|
||||
for _, pr := range prs {
|
||||
row := MergeQueueRow{
|
||||
Number: pr.Number,
|
||||
Repo: repoShort,
|
||||
Title: pr.Title,
|
||||
URL: pr.URL,
|
||||
}
|
||||
|
||||
// Determine CI status from statusCheckRollup
|
||||
row.CIStatus = determineCIStatus(pr.StatusCheckRollup)
|
||||
|
||||
// Determine mergeable status
|
||||
row.Mergeable = determineMergeableStatus(pr.Mergeable)
|
||||
|
||||
// Determine color class based on overall status
|
||||
row.ColorClass = determineColorClass(row.CIStatus, row.Mergeable)
|
||||
|
||||
result = append(result, row)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// determineCIStatus evaluates the overall CI status from status checks.
|
||||
func determineCIStatus(checks []struct {
|
||||
State string `json:"state"`
|
||||
Status string `json:"status"`
|
||||
Conclusion string `json:"conclusion"`
|
||||
}) string {
|
||||
if len(checks) == 0 {
|
||||
return "pending"
|
||||
}
|
||||
|
||||
hasFailure := false
|
||||
hasPending := false
|
||||
|
||||
for _, check := range checks {
|
||||
// Check conclusion first (for completed checks)
|
||||
switch check.Conclusion {
|
||||
case "failure", "cancelled", "timed_out", "action_required": //nolint:misspell // GitHub API returns "cancelled" (British spelling)
|
||||
hasFailure = true
|
||||
case "success", "skipped", "neutral":
|
||||
// Pass
|
||||
default:
|
||||
// Check status for in-progress checks
|
||||
switch check.Status {
|
||||
case "queued", "in_progress", "waiting", "pending", "requested":
|
||||
hasPending = true
|
||||
}
|
||||
// Also check state field
|
||||
switch check.State {
|
||||
case "FAILURE", "ERROR":
|
||||
hasFailure = true
|
||||
case "PENDING", "EXPECTED":
|
||||
hasPending = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if hasFailure {
|
||||
return "fail"
|
||||
}
|
||||
if hasPending {
|
||||
return "pending"
|
||||
}
|
||||
return "pass"
|
||||
}
|
||||
|
||||
// determineMergeableStatus converts GitHub's mergeable field to display value.
|
||||
func determineMergeableStatus(mergeable string) string {
|
||||
switch strings.ToUpper(mergeable) {
|
||||
case "MERGEABLE":
|
||||
return "ready"
|
||||
case "CONFLICTING":
|
||||
return "conflict"
|
||||
default:
|
||||
return "pending"
|
||||
}
|
||||
}
|
||||
|
||||
// determineColorClass determines the row color based on CI and merge status.
|
||||
func determineColorClass(ciStatus, mergeable string) string {
|
||||
if ciStatus == "fail" || mergeable == "conflict" {
|
||||
return "mq-red"
|
||||
}
|
||||
if ciStatus == "pending" || mergeable == "pending" {
|
||||
return "mq-yellow"
|
||||
}
|
||||
if ciStatus == "pass" && mergeable == "ready" {
|
||||
return "mq-green"
|
||||
}
|
||||
return "mq-yellow"
|
||||
}
|
||||
|
||||
// FetchPolecats fetches all running polecat and refinery sessions with activity data.
|
||||
func (f *LiveConvoyFetcher) FetchPolecats() ([]PolecatRow, error) {
|
||||
// Query all tmux sessions with window_activity for more accurate timing
|
||||
cmd := exec.Command("tmux", "list-sessions", "-F", "#{session_name}|#{window_activity}")
|
||||
var stdout bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
if err := cmd.Run(); err != nil {
|
||||
// tmux not running or no sessions
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Pre-fetch merge queue count to determine refinery idle status
|
||||
mergeQueueCount := f.getMergeQueueCount()
|
||||
|
||||
var polecats []PolecatRow
|
||||
lines := strings.Split(strings.TrimSpace(stdout.String()), "\n")
|
||||
|
||||
for _, line := range lines {
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
parts := strings.Split(line, "|")
|
||||
if len(parts) < 2 {
|
||||
continue
|
||||
}
|
||||
|
||||
sessionName := parts[0]
|
||||
|
||||
// Filter for gt-<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.
|
||||
type ConvoyFetcher interface {
|
||||
FetchConvoys() ([]ConvoyRow, error)
|
||||
FetchMergeQueue() ([]MergeQueueRow, error)
|
||||
FetchPolecats() ([]PolecatRow, error)
|
||||
}
|
||||
|
||||
// ConvoyHandler handles HTTP requests for the convoy dashboard.
|
||||
@@ -37,8 +39,22 @@ func (h *ConvoyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
mergeQueue, err := h.fetcher.FetchMergeQueue()
|
||||
if err != nil {
|
||||
// Non-fatal: show convoys even if merge queue fails
|
||||
mergeQueue = nil
|
||||
}
|
||||
|
||||
polecats, err := h.fetcher.FetchPolecats()
|
||||
if err != nil {
|
||||
// Non-fatal: show convoys even if polecats fail
|
||||
polecats = nil
|
||||
}
|
||||
|
||||
data := ConvoyData{
|
||||
Convoys: convoys,
|
||||
Convoys: convoys,
|
||||
MergeQueue: mergeQueue,
|
||||
Polecats: polecats,
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
@@ -10,16 +12,29 @@ import (
|
||||
"github.com/steveyegge/gastown/internal/activity"
|
||||
)
|
||||
|
||||
// Test error for simulating fetch failures
|
||||
var errFetchFailed = errors.New("fetch failed")
|
||||
|
||||
// MockConvoyFetcher is a mock implementation for testing.
|
||||
type MockConvoyFetcher struct {
|
||||
Convoys []ConvoyRow
|
||||
Error error
|
||||
Convoys []ConvoyRow
|
||||
MergeQueue []MergeQueueRow
|
||||
Polecats []PolecatRow
|
||||
Error error
|
||||
}
|
||||
|
||||
func (m *MockConvoyFetcher) FetchConvoys() ([]ConvoyRow, error) {
|
||||
return m.Convoys, m.Error
|
||||
}
|
||||
|
||||
func (m *MockConvoyFetcher) FetchMergeQueue() ([]MergeQueueRow, error) {
|
||||
return m.MergeQueue, nil
|
||||
}
|
||||
|
||||
func (m *MockConvoyFetcher) FetchPolecats() ([]PolecatRow, error) {
|
||||
return m.Polecats, nil
|
||||
}
|
||||
|
||||
func TestConvoyHandler_RendersTemplate(t *testing.T) {
|
||||
mock := &MockConvoyFetcher{
|
||||
Convoys: []ConvoyRow{
|
||||
@@ -179,3 +194,804 @@ func TestConvoyHandler_MultipleConvoys(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Integration tests for error handling
|
||||
|
||||
func TestConvoyHandler_FetchConvoysError(t *testing.T) {
|
||||
mock := &MockConvoyFetcher{
|
||||
Error: errFetchFailed,
|
||||
}
|
||||
|
||||
handler, err := NewConvoyHandler(mock)
|
||||
if err != nil {
|
||||
t.Fatalf("NewConvoyHandler() error = %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
t.Errorf("Status = %d, want %d", w.Code, http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
body := w.Body.String()
|
||||
if !strings.Contains(body, "Failed to fetch convoys") {
|
||||
t.Error("Response should contain error message")
|
||||
}
|
||||
}
|
||||
|
||||
// Integration tests for merge queue rendering
|
||||
|
||||
func TestConvoyHandler_MergeQueueRendering(t *testing.T) {
|
||||
mock := &MockConvoyFetcher{
|
||||
Convoys: []ConvoyRow{},
|
||||
MergeQueue: []MergeQueueRow{
|
||||
{
|
||||
Number: 123,
|
||||
Repo: "roxas",
|
||||
Title: "Fix authentication bug",
|
||||
URL: "https://github.com/test/repo/pull/123",
|
||||
CIStatus: "pass",
|
||||
Mergeable: "ready",
|
||||
ColorClass: "mq-green",
|
||||
},
|
||||
{
|
||||
Number: 456,
|
||||
Repo: "gastown",
|
||||
Title: "Add dashboard feature",
|
||||
URL: "https://github.com/test/repo/pull/456",
|
||||
CIStatus: "pending",
|
||||
Mergeable: "pending",
|
||||
ColorClass: "mq-yellow",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
handler, err := NewConvoyHandler(mock)
|
||||
if err != nil {
|
||||
t.Fatalf("NewConvoyHandler() error = %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("Status = %d, want %d", w.Code, http.StatusOK)
|
||||
}
|
||||
|
||||
body := w.Body.String()
|
||||
|
||||
// Check merge queue section header
|
||||
if !strings.Contains(body, "Refinery Merge Queue") {
|
||||
t.Error("Response should contain merge queue section header")
|
||||
}
|
||||
|
||||
// Check PR numbers are rendered
|
||||
if !strings.Contains(body, "#123") {
|
||||
t.Error("Response should contain PR #123")
|
||||
}
|
||||
if !strings.Contains(body, "#456") {
|
||||
t.Error("Response should contain PR #456")
|
||||
}
|
||||
|
||||
// Check repo names
|
||||
if !strings.Contains(body, "roxas") {
|
||||
t.Error("Response should contain repo 'roxas'")
|
||||
}
|
||||
|
||||
// Check CI status badges
|
||||
if !strings.Contains(body, "ci-pass") {
|
||||
t.Error("Response should contain ci-pass class for passing PR")
|
||||
}
|
||||
if !strings.Contains(body, "ci-pending") {
|
||||
t.Error("Response should contain ci-pending class for pending PR")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvoyHandler_EmptyMergeQueue(t *testing.T) {
|
||||
mock := &MockConvoyFetcher{
|
||||
Convoys: []ConvoyRow{},
|
||||
MergeQueue: []MergeQueueRow{},
|
||||
}
|
||||
|
||||
handler, err := NewConvoyHandler(mock)
|
||||
if err != nil {
|
||||
t.Fatalf("NewConvoyHandler() error = %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
body := w.Body.String()
|
||||
|
||||
// Should show empty state for merge queue
|
||||
if !strings.Contains(body, "No PRs in queue") {
|
||||
t.Error("Response should show empty merge queue message")
|
||||
}
|
||||
}
|
||||
|
||||
// Integration tests for polecat workers rendering
|
||||
|
||||
func TestConvoyHandler_PolecatWorkersRendering(t *testing.T) {
|
||||
mock := &MockConvoyFetcher{
|
||||
Convoys: []ConvoyRow{},
|
||||
Polecats: []PolecatRow{
|
||||
{
|
||||
Name: "dag",
|
||||
Rig: "roxas",
|
||||
SessionID: "gt-roxas-dag",
|
||||
LastActivity: activity.Calculate(time.Now().Add(-30 * time.Second)),
|
||||
StatusHint: "Running tests...",
|
||||
},
|
||||
{
|
||||
Name: "nux",
|
||||
Rig: "roxas",
|
||||
SessionID: "gt-roxas-nux",
|
||||
LastActivity: activity.Calculate(time.Now().Add(-5 * time.Minute)),
|
||||
StatusHint: "Waiting for input",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
handler, err := NewConvoyHandler(mock)
|
||||
if err != nil {
|
||||
t.Fatalf("NewConvoyHandler() error = %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("Status = %d, want %d", w.Code, http.StatusOK)
|
||||
}
|
||||
|
||||
body := w.Body.String()
|
||||
|
||||
// Check polecat section header
|
||||
if !strings.Contains(body, "Polecat Workers") {
|
||||
t.Error("Response should contain polecat workers section header")
|
||||
}
|
||||
|
||||
// Check polecat names
|
||||
if !strings.Contains(body, "dag") {
|
||||
t.Error("Response should contain polecat 'dag'")
|
||||
}
|
||||
if !strings.Contains(body, "nux") {
|
||||
t.Error("Response should contain polecat 'nux'")
|
||||
}
|
||||
|
||||
// Check rig names
|
||||
if !strings.Contains(body, "roxas") {
|
||||
t.Error("Response should contain rig 'roxas'")
|
||||
}
|
||||
|
||||
// Check status hints
|
||||
if !strings.Contains(body, "Running tests...") {
|
||||
t.Error("Response should contain status hint")
|
||||
}
|
||||
|
||||
// Check activity colors (dag should be green, nux should be yellow/red)
|
||||
if !strings.Contains(body, "activity-green") {
|
||||
t.Error("Response should contain activity-green for recent activity")
|
||||
}
|
||||
}
|
||||
|
||||
// Integration tests for work status rendering
|
||||
|
||||
func TestConvoyHandler_WorkStatusRendering(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
workStatus string
|
||||
wantClass string
|
||||
wantStatusText string
|
||||
}{
|
||||
{"complete status", "complete", "work-complete", "complete"},
|
||||
{"active status", "active", "work-active", "active"},
|
||||
{"stale status", "stale", "work-stale", "stale"},
|
||||
{"stuck status", "stuck", "work-stuck", "stuck"},
|
||||
{"waiting status", "waiting", "work-waiting", "waiting"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
mock := &MockConvoyFetcher{
|
||||
Convoys: []ConvoyRow{
|
||||
{
|
||||
ID: "hq-cv-test",
|
||||
Title: "Test Convoy",
|
||||
Status: "open",
|
||||
WorkStatus: tt.workStatus,
|
||||
Progress: "1/2",
|
||||
Completed: 1,
|
||||
Total: 2,
|
||||
LastActivity: activity.Calculate(time.Now()),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
handler, err := NewConvoyHandler(mock)
|
||||
if err != nil {
|
||||
t.Fatalf("NewConvoyHandler() error = %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
body := w.Body.String()
|
||||
|
||||
// Check work status class is applied
|
||||
if !strings.Contains(body, tt.wantClass) {
|
||||
t.Errorf("Response should contain class %q for work status %q", tt.wantClass, tt.workStatus)
|
||||
}
|
||||
|
||||
// Check work status text is displayed
|
||||
if !strings.Contains(body, tt.wantStatusText) {
|
||||
t.Errorf("Response should contain status text %q", tt.wantStatusText)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Integration tests for progress bar rendering
|
||||
|
||||
func TestConvoyHandler_ProgressBarRendering(t *testing.T) {
|
||||
mock := &MockConvoyFetcher{
|
||||
Convoys: []ConvoyRow{
|
||||
{
|
||||
ID: "hq-cv-progress",
|
||||
Title: "Progress Test",
|
||||
Status: "open",
|
||||
WorkStatus: "active",
|
||||
Progress: "3/4",
|
||||
Completed: 3,
|
||||
Total: 4,
|
||||
LastActivity: activity.Calculate(time.Now()),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
handler, err := NewConvoyHandler(mock)
|
||||
if err != nil {
|
||||
t.Fatalf("NewConvoyHandler() error = %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
body := w.Body.String()
|
||||
|
||||
// Check progress text
|
||||
if !strings.Contains(body, "3/4") {
|
||||
t.Error("Response should contain progress '3/4'")
|
||||
}
|
||||
|
||||
// Check progress bar element
|
||||
if !strings.Contains(body, "progress-bar") {
|
||||
t.Error("Response should contain progress-bar class")
|
||||
}
|
||||
|
||||
// Check progress fill with percentage (75%)
|
||||
if !strings.Contains(body, "progress-fill") {
|
||||
t.Error("Response should contain progress-fill class")
|
||||
}
|
||||
if !strings.Contains(body, "width: 75%") {
|
||||
t.Error("Response should contain 75% width for 3/4 progress")
|
||||
}
|
||||
}
|
||||
|
||||
// Integration test for HTMX auto-refresh
|
||||
|
||||
func TestConvoyHandler_HTMXAutoRefresh(t *testing.T) {
|
||||
mock := &MockConvoyFetcher{
|
||||
Convoys: []ConvoyRow{},
|
||||
}
|
||||
|
||||
handler, err := NewConvoyHandler(mock)
|
||||
if err != nil {
|
||||
t.Fatalf("NewConvoyHandler() error = %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
body := w.Body.String()
|
||||
|
||||
// Check htmx attributes for auto-refresh
|
||||
if !strings.Contains(body, "hx-get") {
|
||||
t.Error("Response should contain hx-get attribute for HTMX")
|
||||
}
|
||||
if !strings.Contains(body, "hx-trigger") {
|
||||
t.Error("Response should contain hx-trigger attribute for HTMX")
|
||||
}
|
||||
if !strings.Contains(body, "every 10s") {
|
||||
t.Error("Response should contain 'every 10s' trigger interval")
|
||||
}
|
||||
}
|
||||
|
||||
// Integration test for full dashboard with all sections
|
||||
|
||||
func TestConvoyHandler_FullDashboard(t *testing.T) {
|
||||
mock := &MockConvoyFetcher{
|
||||
Convoys: []ConvoyRow{
|
||||
{
|
||||
ID: "hq-cv-full",
|
||||
Title: "Full Test Convoy",
|
||||
Status: "open",
|
||||
WorkStatus: "active",
|
||||
Progress: "2/3",
|
||||
Completed: 2,
|
||||
Total: 3,
|
||||
LastActivity: activity.Calculate(time.Now().Add(-1 * time.Minute)),
|
||||
},
|
||||
},
|
||||
MergeQueue: []MergeQueueRow{
|
||||
{
|
||||
Number: 789,
|
||||
Repo: "testrig",
|
||||
Title: "Test PR",
|
||||
CIStatus: "pass",
|
||||
Mergeable: "ready",
|
||||
ColorClass: "mq-green",
|
||||
},
|
||||
},
|
||||
Polecats: []PolecatRow{
|
||||
{
|
||||
Name: "worker1",
|
||||
Rig: "testrig",
|
||||
SessionID: "gt-testrig-worker1",
|
||||
LastActivity: activity.Calculate(time.Now()),
|
||||
StatusHint: "Working...",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
handler, err := NewConvoyHandler(mock)
|
||||
if err != nil {
|
||||
t.Fatalf("NewConvoyHandler() error = %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("Status = %d, want %d", w.Code, http.StatusOK)
|
||||
}
|
||||
|
||||
body := w.Body.String()
|
||||
|
||||
// Verify all three sections are present
|
||||
if !strings.Contains(body, "Gas Town Convoys") {
|
||||
t.Error("Response should contain main header")
|
||||
}
|
||||
if !strings.Contains(body, "hq-cv-full") {
|
||||
t.Error("Response should contain convoy data")
|
||||
}
|
||||
if !strings.Contains(body, "Refinery Merge Queue") {
|
||||
t.Error("Response should contain merge queue section")
|
||||
}
|
||||
if !strings.Contains(body, "#789") {
|
||||
t.Error("Response should contain PR data")
|
||||
}
|
||||
if !strings.Contains(body, "Polecat Workers") {
|
||||
t.Error("Response should contain polecat section")
|
||||
}
|
||||
if !strings.Contains(body, "worker1") {
|
||||
t.Error("Response should contain polecat data")
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// End-to-End Tests with httptest.Server
|
||||
// =============================================================================
|
||||
|
||||
// TestE2E_Server_FullDashboard tests the full dashboard using a real HTTP server.
|
||||
func TestE2E_Server_FullDashboard(t *testing.T) {
|
||||
mock := &MockConvoyFetcher{
|
||||
Convoys: []ConvoyRow{
|
||||
{
|
||||
ID: "hq-cv-e2e",
|
||||
Title: "E2E Test Convoy",
|
||||
Status: "open",
|
||||
WorkStatus: "active",
|
||||
Progress: "2/4",
|
||||
Completed: 2,
|
||||
Total: 4,
|
||||
LastActivity: activity.Calculate(time.Now().Add(-45 * time.Second)),
|
||||
},
|
||||
},
|
||||
MergeQueue: []MergeQueueRow{
|
||||
{
|
||||
Number: 101,
|
||||
Repo: "roxas",
|
||||
Title: "E2E Test PR",
|
||||
URL: "https://github.com/test/roxas/pull/101",
|
||||
CIStatus: "pass",
|
||||
Mergeable: "ready",
|
||||
ColorClass: "mq-green",
|
||||
},
|
||||
},
|
||||
Polecats: []PolecatRow{
|
||||
{
|
||||
Name: "furiosa",
|
||||
Rig: "roxas",
|
||||
SessionID: "gt-roxas-furiosa",
|
||||
LastActivity: activity.Calculate(time.Now().Add(-30 * time.Second)),
|
||||
StatusHint: "Running E2E tests",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
handler, err := NewConvoyHandler(mock)
|
||||
if err != nil {
|
||||
t.Fatalf("NewConvoyHandler() error = %v", err)
|
||||
}
|
||||
|
||||
// Create a real HTTP server
|
||||
server := httptest.NewServer(handler)
|
||||
defer server.Close()
|
||||
|
||||
// Make HTTP request to the server
|
||||
resp, err := http.Get(server.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("HTTP GET failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Verify status code
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("Status = %d, want %d", resp.StatusCode, http.StatusOK)
|
||||
}
|
||||
|
||||
// Verify content type
|
||||
contentType := resp.Header.Get("Content-Type")
|
||||
if !strings.Contains(contentType, "text/html") {
|
||||
t.Errorf("Content-Type = %q, want text/html", contentType)
|
||||
}
|
||||
|
||||
// Read and verify body
|
||||
bodyBytes, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read response body: %v", err)
|
||||
}
|
||||
body := string(bodyBytes)
|
||||
|
||||
// Verify all three sections render
|
||||
checks := []struct {
|
||||
name string
|
||||
content string
|
||||
}{
|
||||
{"Convoy section header", "Gas Town Convoys"},
|
||||
{"Convoy ID", "hq-cv-e2e"},
|
||||
{"Convoy title", "E2E Test Convoy"},
|
||||
{"Convoy progress", "2/4"},
|
||||
{"Merge queue section", "Refinery Merge Queue"},
|
||||
{"PR number", "#101"},
|
||||
{"PR repo", "roxas"},
|
||||
{"Polecat section", "Polecat Workers"},
|
||||
{"Polecat name", "furiosa"},
|
||||
{"Polecat status", "Running E2E tests"},
|
||||
{"HTMX auto-refresh", `hx-trigger="every 10s"`},
|
||||
}
|
||||
|
||||
for _, check := range checks {
|
||||
if !strings.Contains(body, check.content) {
|
||||
t.Errorf("%s: should contain %q", check.name, check.content)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestE2E_Server_ActivityColors tests activity color rendering via HTTP server.
|
||||
func TestE2E_Server_ActivityColors(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
age time.Duration
|
||||
wantClass string
|
||||
}{
|
||||
{"green for recent", 20 * time.Second, "activity-green"},
|
||||
{"yellow for stale", 3 * time.Minute, "activity-yellow"},
|
||||
{"red for stuck", 8 * time.Minute, "activity-red"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
mock := &MockConvoyFetcher{
|
||||
Polecats: []PolecatRow{
|
||||
{
|
||||
Name: "test-worker",
|
||||
Rig: "test-rig",
|
||||
SessionID: "gt-test-rig-test-worker",
|
||||
LastActivity: activity.Calculate(time.Now().Add(-tt.age)),
|
||||
StatusHint: "Testing",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
handler, err := NewConvoyHandler(mock)
|
||||
if err != nil {
|
||||
t.Fatalf("NewConvoyHandler() error = %v", err)
|
||||
}
|
||||
|
||||
server := httptest.NewServer(handler)
|
||||
defer server.Close()
|
||||
|
||||
resp, err := http.Get(server.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("HTTP GET failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
body := string(bodyBytes)
|
||||
|
||||
if !strings.Contains(body, tt.wantClass) {
|
||||
t.Errorf("Should contain activity class %q for age %v", tt.wantClass, tt.age)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestE2E_Server_MergeQueueEmpty tests that empty merge queue shows message.
|
||||
func TestE2E_Server_MergeQueueEmpty(t *testing.T) {
|
||||
mock := &MockConvoyFetcher{
|
||||
Convoys: []ConvoyRow{},
|
||||
MergeQueue: []MergeQueueRow{},
|
||||
Polecats: []PolecatRow{},
|
||||
}
|
||||
|
||||
handler, err := NewConvoyHandler(mock)
|
||||
if err != nil {
|
||||
t.Fatalf("NewConvoyHandler() error = %v", err)
|
||||
}
|
||||
|
||||
server := httptest.NewServer(handler)
|
||||
defer server.Close()
|
||||
|
||||
resp, err := http.Get(server.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("HTTP GET failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
body := string(bodyBytes)
|
||||
|
||||
// Section header should always be visible
|
||||
if !strings.Contains(body, "Refinery Merge Queue") {
|
||||
t.Error("Merge queue section should always be visible")
|
||||
}
|
||||
|
||||
// Empty state message
|
||||
if !strings.Contains(body, "No PRs in queue") {
|
||||
t.Error("Should show 'No PRs in queue' when empty")
|
||||
}
|
||||
}
|
||||
|
||||
// TestE2E_Server_MergeQueueStatuses tests all PR status combinations.
|
||||
func TestE2E_Server_MergeQueueStatuses(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
ciStatus string
|
||||
mergeable string
|
||||
colorClass string
|
||||
wantCI string
|
||||
wantMerge string
|
||||
}{
|
||||
{"green when ready", "pass", "ready", "mq-green", "ci-pass", "merge-ready"},
|
||||
{"red when CI fails", "fail", "ready", "mq-red", "ci-fail", "merge-ready"},
|
||||
{"red when conflict", "pass", "conflict", "mq-red", "ci-pass", "merge-conflict"},
|
||||
{"yellow when pending", "pending", "pending", "mq-yellow", "ci-pending", "merge-pending"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
mock := &MockConvoyFetcher{
|
||||
MergeQueue: []MergeQueueRow{
|
||||
{
|
||||
Number: 42,
|
||||
Repo: "test",
|
||||
Title: "Test PR",
|
||||
URL: "https://github.com/test/test/pull/42",
|
||||
CIStatus: tt.ciStatus,
|
||||
Mergeable: tt.mergeable,
|
||||
ColorClass: tt.colorClass,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
handler, err := NewConvoyHandler(mock)
|
||||
if err != nil {
|
||||
t.Fatalf("NewConvoyHandler() error = %v", err)
|
||||
}
|
||||
|
||||
server := httptest.NewServer(handler)
|
||||
defer server.Close()
|
||||
|
||||
resp, err := http.Get(server.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("HTTP GET failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
body := string(bodyBytes)
|
||||
|
||||
if !strings.Contains(body, tt.colorClass) {
|
||||
t.Errorf("Should contain row class %q", tt.colorClass)
|
||||
}
|
||||
if !strings.Contains(body, tt.wantCI) {
|
||||
t.Errorf("Should contain CI class %q", tt.wantCI)
|
||||
}
|
||||
if !strings.Contains(body, tt.wantMerge) {
|
||||
t.Errorf("Should contain merge class %q", tt.wantMerge)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestE2E_Server_HTMLStructure validates HTML document structure.
|
||||
func TestE2E_Server_HTMLStructure(t *testing.T) {
|
||||
mock := &MockConvoyFetcher{Convoys: []ConvoyRow{}}
|
||||
|
||||
handler, err := NewConvoyHandler(mock)
|
||||
if err != nil {
|
||||
t.Fatalf("NewConvoyHandler() error = %v", err)
|
||||
}
|
||||
|
||||
server := httptest.NewServer(handler)
|
||||
defer server.Close()
|
||||
|
||||
resp, err := http.Get(server.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("HTTP GET failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
body := string(bodyBytes)
|
||||
|
||||
// Validate HTML structure
|
||||
elements := []string{
|
||||
"<!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.
|
||||
type ConvoyData struct {
|
||||
Convoys []ConvoyRow
|
||||
Convoys []ConvoyRow
|
||||
MergeQueue []MergeQueueRow
|
||||
Polecats []PolecatRow
|
||||
}
|
||||
|
||||
// PolecatRow represents a polecat worker in the dashboard.
|
||||
type PolecatRow struct {
|
||||
Name string // e.g., "dag", "nux"
|
||||
Rig string // e.g., "roxas", "gastown"
|
||||
SessionID string // e.g., "gt-roxas-dag"
|
||||
LastActivity activity.Info // Colored activity display
|
||||
StatusHint string // Last line from pane (optional)
|
||||
}
|
||||
|
||||
// MergeQueueRow represents a PR in the merge queue.
|
||||
type MergeQueueRow struct {
|
||||
Number int
|
||||
Repo string // Short repo name (e.g., "roxas", "gastown")
|
||||
Title string
|
||||
URL string
|
||||
CIStatus string // "pass", "fail", "pending"
|
||||
Mergeable string // "ready", "conflict", "pending"
|
||||
ColorClass string // "mq-green", "mq-yellow", "mq-red"
|
||||
}
|
||||
|
||||
// ConvoyRow represents a single convoy in the dashboard.
|
||||
type ConvoyRow struct {
|
||||
ID string
|
||||
Title string
|
||||
Status string // "open" or "closed"
|
||||
Status string // "open" or "closed" (raw beads status)
|
||||
WorkStatus string // Computed: "complete", "active", "stale", "stuck", "waiting"
|
||||
Progress string // e.g., "2/5"
|
||||
Completed int
|
||||
Total int
|
||||
@@ -43,6 +66,7 @@ func LoadTemplates() (*template.Template, error) {
|
||||
funcMap := template.FuncMap{
|
||||
"activityClass": activityClass,
|
||||
"statusClass": statusClass,
|
||||
"workStatusClass": workStatusClass,
|
||||
"progressPercent": progressPercent,
|
||||
}
|
||||
|
||||
@@ -87,6 +111,24 @@ func statusClass(status string) string {
|
||||
}
|
||||
}
|
||||
|
||||
// workStatusClass returns the CSS class for a computed work status.
|
||||
func workStatusClass(workStatus string) string {
|
||||
switch workStatus {
|
||||
case "complete":
|
||||
return "work-complete"
|
||||
case "active":
|
||||
return "work-active"
|
||||
case "stale":
|
||||
return "work-stale"
|
||||
case "stuck":
|
||||
return "work-stuck"
|
||||
case "waiting":
|
||||
return "work-waiting"
|
||||
default:
|
||||
return "work-unknown"
|
||||
}
|
||||
}
|
||||
|
||||
// progressPercent calculates percentage as an integer for progress bars.
|
||||
func progressPercent(completed, total int) int {
|
||||
if total == 0 {
|
||||
|
||||
@@ -104,6 +104,41 @@
|
||||
background: var(--green);
|
||||
}
|
||||
|
||||
/* Work status badges */
|
||||
.work-status {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.work-complete .work-status {
|
||||
background: var(--green);
|
||||
color: var(--bg-dark);
|
||||
}
|
||||
|
||||
.work-active .work-status {
|
||||
background: var(--green);
|
||||
color: var(--bg-dark);
|
||||
}
|
||||
|
||||
.work-stale .work-status {
|
||||
background: var(--yellow);
|
||||
color: var(--bg-dark);
|
||||
}
|
||||
|
||||
.work-stuck .work-status {
|
||||
background: var(--red);
|
||||
color: var(--bg-dark);
|
||||
}
|
||||
|
||||
.work-waiting .work-status {
|
||||
background: var(--text-secondary);
|
||||
color: var(--bg-dark);
|
||||
}
|
||||
|
||||
/* Activity colors */
|
||||
.activity-dot {
|
||||
display: inline-block;
|
||||
@@ -176,6 +211,106 @@
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.empty-state-inline {
|
||||
text-align: center;
|
||||
padding: 24px;
|
||||
color: var(--text-secondary);
|
||||
background: var(--bg-card);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.empty-state-inline p {
|
||||
font-size: 0.875rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.status-hint {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.875rem;
|
||||
max-width: 300px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
margin-top: 32px;
|
||||
margin-bottom: 16px;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Merge queue colors */
|
||||
.mq-green {
|
||||
background: rgba(74, 222, 128, 0.1);
|
||||
}
|
||||
|
||||
.mq-yellow {
|
||||
background: rgba(250, 204, 21, 0.1);
|
||||
}
|
||||
|
||||
.mq-red {
|
||||
background: rgba(248, 113, 113, 0.1);
|
||||
}
|
||||
|
||||
.ci-status, .merge-status {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.ci-pass {
|
||||
background: var(--green);
|
||||
color: var(--bg-dark);
|
||||
}
|
||||
|
||||
.ci-fail {
|
||||
background: var(--red);
|
||||
color: var(--bg-dark);
|
||||
}
|
||||
|
||||
.ci-pending {
|
||||
background: var(--yellow);
|
||||
color: var(--bg-dark);
|
||||
}
|
||||
|
||||
.merge-ready {
|
||||
background: var(--green);
|
||||
color: var(--bg-dark);
|
||||
}
|
||||
|
||||
.merge-conflict {
|
||||
background: var(--red);
|
||||
color: var(--bg-dark);
|
||||
}
|
||||
|
||||
.merge-pending {
|
||||
background: var(--yellow);
|
||||
color: var(--bg-dark);
|
||||
}
|
||||
|
||||
.pr-link {
|
||||
color: var(--text-primary);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.pr-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.pr-title {
|
||||
color: var(--text-secondary);
|
||||
margin-left: 8px;
|
||||
max-width: 400px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* htmx loading indicator */
|
||||
.htmx-request .htmx-indicator {
|
||||
opacity: 1;
|
||||
@@ -188,11 +323,11 @@
|
||||
</style>
|
||||
</head>
|
||||
<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>
|
||||
<h1>🚚 Gas Town Convoys</h1>
|
||||
<span class="refresh-info">
|
||||
Auto-refresh: every 30s
|
||||
Auto-refresh: every 10s
|
||||
<span class="htmx-indicator">⟳</span>
|
||||
</span>
|
||||
</header>
|
||||
@@ -209,10 +344,9 @@
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Convoys}}
|
||||
<tr class="{{statusClass .Status}}">
|
||||
<tr class="{{workStatusClass .WorkStatus}}">
|
||||
<td>
|
||||
<span class="status-indicator"></span>
|
||||
{{if eq .Status "open"}}●{{else}}✓{{end}}
|
||||
<span class="work-status">{{.WorkStatus}}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="convoy-id">{{.ID}}</span>
|
||||
@@ -240,6 +374,85 @@
|
||||
<p>Create a convoy with: gt convoy create <name> [issues...]</p>
|
||||
</div>
|
||||
{{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>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -137,8 +137,8 @@ func TestConvoyTemplate_HtmxAutoRefresh(t *testing.T) {
|
||||
if !strings.Contains(output, "hx-trigger") {
|
||||
t.Error("Template should contain hx-trigger for auto-refresh")
|
||||
}
|
||||
if !strings.Contains(output, "every 30s") {
|
||||
t.Error("Template should refresh every 30 seconds")
|
||||
if !strings.Contains(output, "every 10s") {
|
||||
t.Error("Template should refresh every 10 seconds")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user