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
370 lines
8.3 KiB
Go
370 lines
8.3 KiB
Go
package convoy
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/charmbracelet/bubbles/help"
|
|
"github.com/charmbracelet/bubbles/key"
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
)
|
|
|
|
// convoyIDPattern validates convoy IDs to prevent SQL injection.
|
|
var convoyIDPattern = regexp.MustCompile(`^hq-[a-zA-Z0-9-]+$`)
|
|
|
|
// subprocessTimeout is the timeout for bd and sqlite3 calls.
|
|
const subprocessTimeout = 5 * time.Second
|
|
|
|
// IssueItem represents a tracked issue within a convoy.
|
|
type IssueItem struct {
|
|
ID string
|
|
Title string
|
|
Status string
|
|
}
|
|
|
|
// ConvoyItem represents a convoy with its tracked issues.
|
|
type ConvoyItem struct {
|
|
ID string
|
|
Title string
|
|
Status string
|
|
Issues []IssueItem
|
|
Progress string // e.g., "2/5"
|
|
Expanded bool
|
|
}
|
|
|
|
// Model is the bubbletea model for the convoy TUI.
|
|
type Model struct {
|
|
convoys []ConvoyItem
|
|
cursor int // Current selection index in flattened view
|
|
townBeads string // Path to town beads directory
|
|
err error
|
|
|
|
// UI state
|
|
keys KeyMap
|
|
help help.Model
|
|
showHelp bool
|
|
width int
|
|
height int
|
|
}
|
|
|
|
// New creates a new convoy TUI model.
|
|
func New(townBeads string) Model {
|
|
return Model{
|
|
townBeads: townBeads,
|
|
keys: DefaultKeyMap(),
|
|
help: help.New(),
|
|
convoys: make([]ConvoyItem, 0),
|
|
}
|
|
}
|
|
|
|
// Init initializes the model.
|
|
func (m Model) Init() tea.Cmd {
|
|
return m.fetchConvoys
|
|
}
|
|
|
|
// fetchConvoysMsg is the result of fetching convoys.
|
|
type fetchConvoysMsg struct {
|
|
convoys []ConvoyItem
|
|
err error
|
|
}
|
|
|
|
// fetchConvoys fetches convoy data from beads.
|
|
func (m Model) fetchConvoys() tea.Msg {
|
|
convoys, err := loadConvoys(m.townBeads)
|
|
return fetchConvoysMsg{convoys: convoys, err: err}
|
|
}
|
|
|
|
// loadConvoys loads convoy data from the beads directory.
|
|
func loadConvoys(townBeads string) ([]ConvoyItem, error) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), subprocessTimeout)
|
|
defer cancel()
|
|
|
|
// Get list of open convoys
|
|
listArgs := []string{"list", "--type=convoy", "--json"}
|
|
listCmd := exec.CommandContext(ctx, "bd", listArgs...)
|
|
listCmd.Dir = townBeads
|
|
var stdout bytes.Buffer
|
|
listCmd.Stdout = &stdout
|
|
|
|
if err := listCmd.Run(); err != nil {
|
|
return nil, fmt.Errorf("listing convoys: %w", err)
|
|
}
|
|
|
|
var rawConvoys []struct {
|
|
ID string `json:"id"`
|
|
Title string `json:"title"`
|
|
Status string `json:"status"`
|
|
}
|
|
if err := json.Unmarshal(stdout.Bytes(), &rawConvoys); err != nil {
|
|
return nil, fmt.Errorf("parsing convoy list: %w", err)
|
|
}
|
|
|
|
convoys := make([]ConvoyItem, 0, len(rawConvoys))
|
|
for _, rc := range rawConvoys {
|
|
issues, completed, total := loadTrackedIssues(townBeads, rc.ID)
|
|
convoys = append(convoys, ConvoyItem{
|
|
ID: rc.ID,
|
|
Title: rc.Title,
|
|
Status: rc.Status,
|
|
Issues: issues,
|
|
Progress: fmt.Sprintf("%d/%d", completed, total),
|
|
Expanded: false,
|
|
})
|
|
}
|
|
|
|
return convoys, nil
|
|
}
|
|
|
|
// loadTrackedIssues loads issues tracked by a convoy.
|
|
func loadTrackedIssues(townBeads, convoyID string) ([]IssueItem, int, int) {
|
|
// Validate convoy ID to prevent SQL injection
|
|
if !convoyIDPattern.MatchString(convoyID) {
|
|
return nil, 0, 0
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), subprocessTimeout)
|
|
defer cancel()
|
|
|
|
dbPath := filepath.Join(townBeads, "beads.db")
|
|
|
|
// Query tracked issues from SQLite (ID validated above)
|
|
query := fmt.Sprintf(`
|
|
SELECT d.depends_on_id
|
|
FROM dependencies d
|
|
WHERE d.issue_id = '%s' AND d.type = 'tracks'
|
|
`, convoyID)
|
|
|
|
cmd := exec.CommandContext(ctx, "sqlite3", "-json", dbPath, query) //nolint:gosec // G204: sqlite3 with controlled query
|
|
var stdout bytes.Buffer
|
|
cmd.Stdout = &stdout
|
|
|
|
if err := cmd.Run(); err != nil {
|
|
return nil, 0, 0
|
|
}
|
|
|
|
var deps []struct {
|
|
DependsOnID string `json:"depends_on_id"`
|
|
}
|
|
if err := json.Unmarshal(stdout.Bytes(), &deps); err != nil {
|
|
return nil, 0, 0
|
|
}
|
|
|
|
// Collect issue IDs, handling external references
|
|
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 all issue details in one call
|
|
detailsMap := getIssueDetailsBatch(townBeads, issueIDs)
|
|
|
|
issues := make([]IssueItem, 0, len(deps))
|
|
completed := 0
|
|
for _, id := range issueIDs {
|
|
if issue, ok := detailsMap[id]; ok {
|
|
issues = append(issues, issue)
|
|
if issue.Status == "closed" {
|
|
completed++
|
|
}
|
|
}
|
|
}
|
|
|
|
// Sort by status (open first, then closed)
|
|
sort.Slice(issues, func(i, j int) bool {
|
|
if issues[i].Status == issues[j].Status {
|
|
return issues[i].ID < issues[j].ID
|
|
}
|
|
return issues[i].Status != "closed" // open comes first
|
|
})
|
|
|
|
return issues, completed, len(issues)
|
|
}
|
|
|
|
// getIssueDetailsBatch fetches details for multiple issues in a single bd show call.
|
|
// Returns a map from issue ID to details.
|
|
func getIssueDetailsBatch(townBeads string, issueIDs []string) map[string]IssueItem {
|
|
result := make(map[string]IssueItem)
|
|
if len(issueIDs) == 0 {
|
|
return result
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), subprocessTimeout)
|
|
defer cancel()
|
|
|
|
// Build args: bd show id1 id2 id3 ... --json
|
|
args := append([]string{"show"}, issueIDs...)
|
|
args = append(args, "--json")
|
|
|
|
cmd := exec.CommandContext(ctx, "bd", args...) //nolint:gosec // G204: bd is a trusted internal tool
|
|
cmd.Dir = townBeads
|
|
var stdout bytes.Buffer
|
|
cmd.Stdout = &stdout
|
|
|
|
if err := cmd.Run(); err != nil {
|
|
return result // Return empty map on error
|
|
}
|
|
|
|
var issues []struct {
|
|
ID string `json:"id"`
|
|
Title string `json:"title"`
|
|
Status string `json:"status"`
|
|
}
|
|
if err := json.Unmarshal(stdout.Bytes(), &issues); err != nil {
|
|
return result
|
|
}
|
|
|
|
for _, issue := range issues {
|
|
result[issue.ID] = IssueItem{
|
|
ID: issue.ID,
|
|
Title: issue.Title,
|
|
Status: issue.Status,
|
|
}
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
// Update handles messages.
|
|
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
switch msg := msg.(type) {
|
|
case tea.WindowSizeMsg:
|
|
m.width = msg.Width
|
|
m.height = msg.Height
|
|
m.help.Width = msg.Width
|
|
return m, nil
|
|
|
|
case fetchConvoysMsg:
|
|
m.err = msg.err
|
|
m.convoys = msg.convoys
|
|
return m, nil
|
|
|
|
case tea.KeyMsg:
|
|
switch {
|
|
case key.Matches(msg, m.keys.Quit):
|
|
return m, tea.Quit
|
|
|
|
case key.Matches(msg, m.keys.Help):
|
|
m.showHelp = !m.showHelp
|
|
return m, nil
|
|
|
|
case key.Matches(msg, m.keys.Up):
|
|
if m.cursor > 0 {
|
|
m.cursor--
|
|
}
|
|
return m, nil
|
|
|
|
case key.Matches(msg, m.keys.Down):
|
|
max := m.maxCursor()
|
|
if m.cursor < max {
|
|
m.cursor++
|
|
}
|
|
return m, nil
|
|
|
|
case key.Matches(msg, m.keys.Top):
|
|
m.cursor = 0
|
|
return m, nil
|
|
|
|
case key.Matches(msg, m.keys.Bottom):
|
|
m.cursor = m.maxCursor()
|
|
return m, nil
|
|
|
|
case key.Matches(msg, m.keys.Toggle):
|
|
m.toggleExpand()
|
|
return m, nil
|
|
|
|
// Number keys for direct convoy access
|
|
case msg.String() >= "1" && msg.String() <= "9":
|
|
n := int(msg.String()[0] - '0')
|
|
if n <= len(m.convoys) {
|
|
m.jumpToConvoy(n - 1)
|
|
}
|
|
return m, nil
|
|
}
|
|
}
|
|
|
|
return m, nil
|
|
}
|
|
|
|
// maxCursor returns the maximum valid cursor position.
|
|
func (m Model) maxCursor() int {
|
|
count := 0
|
|
for _, c := range m.convoys {
|
|
count++ // convoy itself
|
|
if c.Expanded {
|
|
count += len(c.Issues)
|
|
}
|
|
}
|
|
if count == 0 {
|
|
return 0
|
|
}
|
|
return count - 1
|
|
}
|
|
|
|
// cursorToConvoyIndex returns the convoy index and issue index for the current cursor.
|
|
// Returns (convoyIdx, issueIdx) where issueIdx is -1 if on a convoy row.
|
|
func (m Model) cursorToConvoyIndex() (int, int) {
|
|
pos := 0
|
|
for ci, c := range m.convoys {
|
|
if pos == m.cursor {
|
|
return ci, -1
|
|
}
|
|
pos++
|
|
if c.Expanded {
|
|
for ii := range c.Issues {
|
|
if pos == m.cursor {
|
|
return ci, ii
|
|
}
|
|
pos++
|
|
}
|
|
}
|
|
}
|
|
return -1, -1
|
|
}
|
|
|
|
// toggleExpand toggles expansion of the convoy at the current cursor.
|
|
func (m *Model) toggleExpand() {
|
|
ci, ii := m.cursorToConvoyIndex()
|
|
if ci >= 0 && ii == -1 {
|
|
// On a convoy row, toggle it
|
|
m.convoys[ci].Expanded = !m.convoys[ci].Expanded
|
|
}
|
|
}
|
|
|
|
// jumpToConvoy moves the cursor to a specific convoy by index.
|
|
func (m *Model) jumpToConvoy(convoyIdx int) {
|
|
if convoyIdx < 0 || convoyIdx >= len(m.convoys) {
|
|
return
|
|
}
|
|
pos := 0
|
|
for ci, c := range m.convoys {
|
|
if ci == convoyIdx {
|
|
m.cursor = pos
|
|
return
|
|
}
|
|
pos++
|
|
if c.Expanded {
|
|
pos += len(c.Issues)
|
|
}
|
|
}
|
|
}
|
|
|
|
// View renders the model.
|
|
func (m Model) View() string {
|
|
return m.renderView()
|
|
}
|