Files
gastown/internal/tui/feed/convoy.go
max 1b69576573 fix: Address golangci-lint errors (errcheck, gosec) (#76)
Apply PR #76 from dannomayernotabot:

- Add golangci exclusions for internal package false positives
- Tighten file permissions (0644 -> 0600) for sensitive files
- Add ReadHeaderTimeout to HTTP server (slowloris prevention)
- Explicit error ignoring with _ = for intentional cases
- Add //nolint comments with justifications
- Spelling: cancelled -> canceled (US locale)

Co-Authored-By: dannomayernotabot <noreply@github.com>

🤖 Generated with Claude Code
2026-01-03 16:11:55 -08:00

354 lines
9.2 KiB
Go

package feed
import (
"bytes"
"context"
"encoding/json"
"fmt"
"os/exec"
"path/filepath"
"regexp"
"sort"
"strings"
"time"
"github.com/charmbracelet/lipgloss"
)
// convoyIDPattern validates convoy IDs to prevent SQL injection
var convoyIDPattern = regexp.MustCompile(`^hq-[a-zA-Z0-9-]+$`)
// convoySubprocessTimeout is the timeout for bd and sqlite3 calls in the convoy panel.
// Prevents TUI freezing if these commands hang.
const convoySubprocessTimeout = 5 * time.Second
// Convoy represents a convoy's status for the dashboard
type Convoy struct {
ID string `json:"id"`
Title string `json:"title"`
Status string `json:"status"`
Completed int `json:"completed"`
Total int `json:"total"`
CreatedAt time.Time `json:"created_at"`
ClosedAt time.Time `json:"closed_at,omitempty"`
}
// ConvoyState holds all convoy data for the panel
type ConvoyState struct {
InProgress []Convoy
Landed []Convoy
LastUpdate time.Time
}
// FetchConvoys retrieves convoy status from town-level beads
func FetchConvoys(townRoot string) (*ConvoyState, error) {
townBeads := filepath.Join(townRoot, ".beads")
state := &ConvoyState{
InProgress: make([]Convoy, 0),
Landed: make([]Convoy, 0),
LastUpdate: time.Now(),
}
// Fetch open convoys
openConvoys, err := listConvoys(townBeads, "open")
if err != nil {
// Not a fatal error - just return empty state
return state, nil
}
for _, c := range openConvoys {
// Get detailed status for each convoy
convoy := enrichConvoy(townBeads, c)
state.InProgress = append(state.InProgress, convoy)
}
// Fetch recently closed convoys (landed in last 24h)
closedConvoys, err := listConvoys(townBeads, "closed")
if err == nil {
cutoff := time.Now().Add(-24 * time.Hour)
for _, c := range closedConvoys {
convoy := enrichConvoy(townBeads, c)
if !convoy.ClosedAt.IsZero() && convoy.ClosedAt.After(cutoff) {
state.Landed = append(state.Landed, convoy)
}
}
}
// Sort: in-progress by created (oldest first), landed by closed (newest first)
sort.Slice(state.InProgress, func(i, j int) bool {
return state.InProgress[i].CreatedAt.Before(state.InProgress[j].CreatedAt)
})
sort.Slice(state.Landed, func(i, j int) bool {
return state.Landed[i].ClosedAt.After(state.Landed[j].ClosedAt)
})
return state, nil
}
// listConvoys returns convoys with the given status
func listConvoys(beadsDir, status string) ([]convoyListItem, error) {
listArgs := []string{"list", "--type=convoy", "--status=" + status, "--json"}
ctx, cancel := context.WithTimeout(context.Background(), convoySubprocessTimeout)
defer cancel()
cmd := exec.CommandContext(ctx, "bd", listArgs...) //nolint:gosec // G204: args are constructed internally
cmd.Dir = beadsDir
var stdout bytes.Buffer
cmd.Stdout = &stdout
if err := cmd.Run(); err != nil {
return nil, err
}
var items []convoyListItem
if err := json.Unmarshal(stdout.Bytes(), &items); err != nil {
return nil, err
}
return items, nil
}
type convoyListItem struct {
ID string `json:"id"`
Title string `json:"title"`
Status string `json:"status"`
CreatedAt string `json:"created_at"`
ClosedAt string `json:"closed_at,omitempty"`
}
// enrichConvoy adds tracked issue counts to a convoy
func enrichConvoy(beadsDir string, item convoyListItem) Convoy {
convoy := Convoy{
ID: item.ID,
Title: item.Title,
Status: item.Status,
}
// Parse timestamps
if t, err := time.Parse(time.RFC3339, item.CreatedAt); err == nil {
convoy.CreatedAt = t
} else if t, err := time.Parse("2006-01-02 15:04", item.CreatedAt); err == nil {
convoy.CreatedAt = t
}
if t, err := time.Parse(time.RFC3339, item.ClosedAt); err == nil {
convoy.ClosedAt = t
} else if t, err := time.Parse("2006-01-02 15:04", item.ClosedAt); err == nil {
convoy.ClosedAt = t
}
// Get tracked issues and their status
tracked := getTrackedIssueStatus(beadsDir, item.ID)
convoy.Total = len(tracked)
for _, t := range tracked {
if t.Status == "closed" {
convoy.Completed++
}
}
return convoy
}
type trackedStatus struct {
ID string
Status string
}
// getTrackedIssueStatus queries tracked issues and their status
func getTrackedIssueStatus(beadsDir, convoyID string) []trackedStatus {
// Validate convoyID to prevent SQL injection
if !convoyIDPattern.MatchString(convoyID) {
return nil
}
dbPath := filepath.Join(beadsDir, "beads.db")
ctx, cancel := context.WithTimeout(context.Background(), convoySubprocessTimeout)
defer cancel()
// Query tracked dependencies from SQLite
// convoyID is validated above to match ^hq-[a-zA-Z0-9-]+$
cmd := exec.CommandContext(ctx, "sqlite3", "-json", dbPath, //nolint:gosec // G204: convoyID is validated against strict pattern
fmt.Sprintf(`SELECT depends_on_id FROM dependencies WHERE issue_id = '%s' AND type = 'tracks'`, convoyID))
var stdout bytes.Buffer
cmd.Stdout = &stdout
if err := cmd.Run(); err != nil {
return nil
}
var deps []struct {
DependsOnID string `json:"depends_on_id"`
}
if err := json.Unmarshal(stdout.Bytes(), &deps); err != nil {
return nil
}
var tracked []trackedStatus
for _, dep := range deps {
issueID := dep.DependsOnID
// Handle external reference format: external:rig:issue-id
if strings.HasPrefix(issueID, "external:") {
parts := strings.SplitN(issueID, ":", 3)
if len(parts) == 3 {
issueID = parts[2]
}
}
// Get issue status
status := getIssueStatus(issueID)
tracked = append(tracked, trackedStatus{ID: issueID, Status: status})
}
return tracked
}
// getIssueStatus fetches just the status of an issue
func getIssueStatus(issueID string) string {
ctx, cancel := context.WithTimeout(context.Background(), convoySubprocessTimeout)
defer cancel()
cmd := exec.CommandContext(ctx, "bd", "show", issueID, "--json")
var stdout bytes.Buffer
cmd.Stdout = &stdout
if err := cmd.Run(); err != nil {
return "unknown"
}
var issues []struct {
Status string `json:"status"`
}
if err := json.Unmarshal(stdout.Bytes(), &issues); err != nil || len(issues) == 0 {
return "unknown"
}
return issues[0].Status
}
// Convoy panel styles
var (
ConvoyPanelStyle = lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(colorDim).
Padding(0, 1)
ConvoyTitleStyle = lipgloss.NewStyle().
Bold(true).
Foreground(colorPrimary)
ConvoySectionStyle = lipgloss.NewStyle().
Foreground(colorDim).
Bold(true)
ConvoyIDStyle = lipgloss.NewStyle().
Foreground(colorHighlight)
ConvoyNameStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("15"))
ConvoyProgressStyle = lipgloss.NewStyle().
Foreground(colorSuccess)
ConvoyLandedStyle = lipgloss.NewStyle().
Foreground(colorSuccess).
Bold(true)
ConvoyAgeStyle = lipgloss.NewStyle().
Foreground(colorDim)
)
// renderConvoyPanel renders the convoy status panel
func (m *Model) renderConvoyPanel() string {
style := ConvoyPanelStyle
if m.focusedPanel == PanelConvoy {
style = FocusedBorderStyle
}
// Add title before content
title := ConvoyTitleStyle.Render("🚚 Convoys")
content := title + "\n" + m.convoyViewport.View()
return style.Width(m.width - 2).Render(content)
}
// renderConvoys renders the convoy panel content
func (m *Model) renderConvoys() string {
if m.convoyState == nil {
return AgentIdleStyle.Render("Loading convoys...")
}
var lines []string
// In Progress section
lines = append(lines, ConvoySectionStyle.Render("IN PROGRESS"))
if len(m.convoyState.InProgress) == 0 {
lines = append(lines, " "+AgentIdleStyle.Render("No active convoys"))
} else {
for _, c := range m.convoyState.InProgress {
lines = append(lines, renderConvoyLine(c, false))
}
}
lines = append(lines, "")
// Recently Landed section
lines = append(lines, ConvoySectionStyle.Render("RECENTLY LANDED (24h)"))
if len(m.convoyState.Landed) == 0 {
lines = append(lines, " "+AgentIdleStyle.Render("No recent landings"))
} else {
for _, c := range m.convoyState.Landed {
lines = append(lines, renderConvoyLine(c, true))
}
}
return strings.Join(lines, "\n")
}
// renderConvoyLine renders a single convoy status line
func renderConvoyLine(c Convoy, landed bool) string {
// Format: " hq-xyz Title 2/4 ●●○○" or " hq-xyz Title ✓ 2h ago"
id := ConvoyIDStyle.Render(c.ID)
// Truncate title if too long
title := c.Title
if len(title) > 20 {
title = title[:17] + "..."
}
title = ConvoyNameStyle.Render(title)
if landed {
// Show checkmark and time since landing
age := formatAge(time.Since(c.ClosedAt))
status := ConvoyLandedStyle.Render("✓") + " " + ConvoyAgeStyle.Render(age+" ago")
return fmt.Sprintf(" %s %-20s %s", id, title, status)
}
// Show progress bar
progress := renderProgressBar(c.Completed, c.Total)
count := ConvoyProgressStyle.Render(fmt.Sprintf("%d/%d", c.Completed, c.Total))
return fmt.Sprintf(" %s %-20s %s %s", id, title, count, progress)
}
// renderProgressBar creates a simple progress bar: ●●○○
func renderProgressBar(completed, total int) string {
if total == 0 {
return ""
}
// Cap at 5 dots for display
displayTotal := total
if displayTotal > 5 {
displayTotal = 5
}
filled := (completed * displayTotal) / total
if filled > displayTotal {
filled = displayTotal
}
bar := strings.Repeat("●", filled) + strings.Repeat("○", displayTotal-filled)
return ConvoyProgressStyle.Render(bar)
}