FetchPolecats() was showing all tmux sessions system-wide without filtering by the workspace's registered rigs. This caused unrelated refineries (like roxas) to appear in the dashboard. Now loads rigs.json and only displays sessions for registered rigs, matching the filtering behavior already used in FetchMergeQueue(). Fixes gh-868 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
803 lines
22 KiB
Go
803 lines
22 KiB
Go
package web
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/steveyegge/gastown/internal/activity"
|
|
"github.com/steveyegge/gastown/internal/config"
|
|
"github.com/steveyegge/gastown/internal/workspace"
|
|
)
|
|
|
|
// LiveConvoyFetcher fetches convoy data from beads.
|
|
type LiveConvoyFetcher struct {
|
|
townRoot string
|
|
townBeads string
|
|
}
|
|
|
|
// NewLiveConvoyFetcher creates a fetcher for the current workspace.
|
|
func NewLiveConvoyFetcher() (*LiveConvoyFetcher, error) {
|
|
townRoot, err := workspace.FindFromCwdOrError()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("not in a Gas Town workspace: %w", err)
|
|
}
|
|
|
|
return &LiveConvoyFetcher{
|
|
townRoot: townRoot,
|
|
townBeads: filepath.Join(townRoot, ".beads"),
|
|
}, nil
|
|
}
|
|
|
|
|
|
// FetchConvoys fetches all open convoys with their activity data.
|
|
func (f *LiveConvoyFetcher) FetchConvoys() ([]ConvoyRow, error) {
|
|
// List all open convoy-type issues
|
|
listArgs := []string{"list", "--type=convoy", "--status=open", "--json"}
|
|
listCmd := exec.Command("bd", listArgs...)
|
|
listCmd.Dir = f.townBeads
|
|
|
|
var stdout bytes.Buffer
|
|
listCmd.Stdout = &stdout
|
|
|
|
if err := listCmd.Run(); err != nil {
|
|
return nil, fmt.Errorf("listing convoys: %w", err)
|
|
}
|
|
|
|
var convoys []struct {
|
|
ID string `json:"id"`
|
|
Title string `json:"title"`
|
|
Status string `json:"status"`
|
|
CreatedAt string `json:"created_at"`
|
|
}
|
|
if err := json.Unmarshal(stdout.Bytes(), &convoys); err != nil {
|
|
return nil, fmt.Errorf("parsing convoy list: %w", err)
|
|
}
|
|
|
|
// Build convoy rows with activity data
|
|
rows := make([]ConvoyRow, 0, len(convoys))
|
|
for _, c := range convoys {
|
|
row := ConvoyRow{
|
|
ID: c.ID,
|
|
Title: c.Title,
|
|
Status: c.Status,
|
|
}
|
|
|
|
// Get tracked issues for progress and activity calculation
|
|
tracked := f.getTrackedIssues(c.ID)
|
|
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++
|
|
}
|
|
// Track most recent activity from workers
|
|
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: "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 {
|
|
row.TrackedIssues[i] = TrackedIssue{
|
|
ID: t.ID,
|
|
Title: t.Title,
|
|
Status: t.Status,
|
|
Assignee: t.Assignee,
|
|
}
|
|
}
|
|
|
|
rows = append(rows, row)
|
|
}
|
|
|
|
return rows, nil
|
|
}
|
|
|
|
// trackedIssueInfo holds info about an issue being tracked by a convoy.
|
|
type trackedIssueInfo struct {
|
|
ID string
|
|
Title string
|
|
Status string
|
|
Assignee string
|
|
LastActivity time.Time
|
|
UpdatedAt time.Time // Fallback for activity when no assignee
|
|
}
|
|
|
|
// getTrackedIssues fetches tracked issues for a convoy.
|
|
func (f *LiveConvoyFetcher) getTrackedIssues(convoyID string) []trackedIssueInfo {
|
|
dbPath := filepath.Join(f.townBeads, "beads.db")
|
|
|
|
// 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))
|
|
|
|
var stdout bytes.Buffer
|
|
queryCmd.Stdout = &stdout
|
|
if err := queryCmd.Run(); err != nil {
|
|
return nil
|
|
}
|
|
|
|
var deps []struct {
|
|
DependsOnID string `json:"depends_on_id"`
|
|
Type string `json:"type"`
|
|
}
|
|
if err := json.Unmarshal(stdout.Bytes(), &deps); err != nil {
|
|
return nil
|
|
}
|
|
|
|
// Collect issue IDs (normalize external refs)
|
|
issueIDs := make([]string, 0, len(deps))
|
|
for _, dep := range deps {
|
|
issueID := dep.DependsOnID
|
|
if strings.HasPrefix(issueID, "external:") {
|
|
parts := strings.SplitN(issueID, ":", 3)
|
|
if len(parts) == 3 {
|
|
issueID = parts[2]
|
|
}
|
|
}
|
|
issueIDs = append(issueIDs, issueID)
|
|
}
|
|
|
|
// Batch fetch issue details
|
|
details := f.getIssueDetailsBatch(issueIDs)
|
|
|
|
// Get worker activity from tmux sessions based on assignees
|
|
workers := f.getWorkersFromAssignees(details)
|
|
|
|
// Build result
|
|
result := make([]trackedIssueInfo, 0, len(issueIDs))
|
|
for _, id := range issueIDs {
|
|
info := trackedIssueInfo{ID: id}
|
|
|
|
if d, ok := details[id]; ok {
|
|
info.Title = d.Title
|
|
info.Status = d.Status
|
|
info.Assignee = d.Assignee
|
|
info.UpdatedAt = d.UpdatedAt
|
|
} else {
|
|
info.Title = "(external)"
|
|
info.Status = "unknown"
|
|
}
|
|
|
|
if w, ok := workers[id]; ok && w.LastActivity != nil {
|
|
info.LastActivity = *w.LastActivity
|
|
}
|
|
|
|
result = append(result, info)
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
// issueDetail holds basic issue info.
|
|
type issueDetail struct {
|
|
ID string
|
|
Title string
|
|
Status string
|
|
Assignee string
|
|
UpdatedAt time.Time
|
|
}
|
|
|
|
// getIssueDetailsBatch fetches details for multiple issues.
|
|
func (f *LiveConvoyFetcher) getIssueDetailsBatch(issueIDs []string) map[string]*issueDetail {
|
|
result := make(map[string]*issueDetail)
|
|
if len(issueIDs) == 0 {
|
|
return result
|
|
}
|
|
|
|
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
|
|
|
|
if err := showCmd.Run(); err != nil {
|
|
return result
|
|
}
|
|
|
|
var issues []struct {
|
|
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 {
|
|
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
|
|
}
|
|
|
|
// workerDetail holds worker info including last activity.
|
|
type workerDetail struct {
|
|
Worker string
|
|
LastActivity *time.Time
|
|
}
|
|
|
|
// 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)
|
|
|
|
// 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
|
|
}
|
|
|
|
// For each unique assignee, look up tmux session activity
|
|
for assignee, issueIDs := range assigneeToIssues {
|
|
activity := f.getSessionActivityForAssignee(assignee)
|
|
if activity == nil {
|
|
continue
|
|
}
|
|
|
|
// Apply this activity to all issues assigned to this worker
|
|
for _, issueID := range issueIDs {
|
|
result[issueID] = &workerDetail{
|
|
Worker: assignee,
|
|
LastActivity: activity,
|
|
}
|
|
}
|
|
}
|
|
|
|
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 registered rigs.
|
|
func (f *LiveConvoyFetcher) FetchMergeQueue() ([]MergeQueueRow, error) {
|
|
// Load registered rigs from config
|
|
rigsConfigPath := filepath.Join(f.townRoot, "mayor", "rigs.json")
|
|
rigsConfig, err := config.LoadRigsConfig(rigsConfigPath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("loading rigs config: %w", err)
|
|
}
|
|
|
|
var result []MergeQueueRow
|
|
|
|
for rigName, entry := range rigsConfig.Rigs {
|
|
// Convert git URL to owner/repo format for gh CLI
|
|
repoPath := gitURLToRepoPath(entry.GitURL)
|
|
if repoPath == "" {
|
|
continue
|
|
}
|
|
|
|
prs, err := f.fetchPRsForRepo(repoPath, rigName)
|
|
if err != nil {
|
|
// Non-fatal: continue with other repos
|
|
continue
|
|
}
|
|
result = append(result, prs...)
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
// gitURLToRepoPath converts a git URL to owner/repo format.
|
|
// Supports HTTPS (https://github.com/owner/repo.git) and
|
|
// SSH (git@github.com:owner/repo.git) formats.
|
|
func gitURLToRepoPath(gitURL string) string {
|
|
// Handle HTTPS format: https://github.com/owner/repo.git
|
|
if strings.HasPrefix(gitURL, "https://github.com/") {
|
|
path := strings.TrimPrefix(gitURL, "https://github.com/")
|
|
path = strings.TrimSuffix(path, ".git")
|
|
return path
|
|
}
|
|
|
|
// Handle SSH format: git@github.com:owner/repo.git
|
|
if strings.HasPrefix(gitURL, "git@github.com:") {
|
|
path := strings.TrimPrefix(gitURL, "git@github.com:")
|
|
path = strings.TrimSuffix(path, ".git")
|
|
return path
|
|
}
|
|
|
|
// Unsupported format
|
|
return ""
|
|
}
|
|
|
|
// 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 registered rigs config
|
|
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) {
|
|
// Load registered rigs to filter sessions
|
|
rigsConfigPath := filepath.Join(f.townRoot, "mayor", "rigs.json")
|
|
rigsConfig, err := config.LoadRigsConfig(rigsConfigPath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("loading rigs config: %w", err)
|
|
}
|
|
|
|
// Build set of registered rig names
|
|
registeredRigs := make(map[string]bool)
|
|
for rigName := range rigsConfig.Rigs {
|
|
registeredRigs[rigName] = true
|
|
}
|
|
|
|
// 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 rigs not registered in this workspace
|
|
if !registeredRigs[rig] {
|
|
continue
|
|
}
|
|
|
|
// 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
|
|
}
|