Files
gastown/internal/web/fetcher.go
Mike Lady f512598783 fix(dashboard): Handle unassigned convoys and show fallback activity
Improvements to convoy dashboard last_activity column:

1. When issues have no assignee:
   - Fall back to issue's updated_at timestamp
   - Show age with "(unassigned)" suffix, e.g., "2m (unassigned)"

2. When issues have assignee but no active tmux session:
   - Show "idle" instead of "no activity"

3. Added UpdatedAt field to track issue timestamps

This provides better context for convoys that haven't been assigned yet.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 17:12:26 -08:00

353 lines
9.1 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/workspace"
)
// LiveConvoyFetcher fetches convoy data from beads.
type LiveConvoyFetcher struct {
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{
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
row.LastActivity = activity.Calculate(mostRecentActivity)
} else if !hasAssignee {
// No assignees - fall back to issue updated_at
if !mostRecentUpdated.IsZero() {
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,
}
}
// 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, "'", "''")
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")
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
}