diff --git a/internal/web/fetcher.go b/internal/web/fetcher.go index b06e38e7..357e113c 100644 --- a/internal/web/fetcher.go +++ b/internal/web/fetcher.go @@ -15,7 +15,9 @@ import ( // LiveConvoyFetcher fetches convoy data from beads. type LiveConvoyFetcher struct { + townRoot string townBeads string + routes map[string]string // prefix -> rig path (e.g., "sc-" -> "scout") } // NewLiveConvoyFetcher creates a fetcher for the current workspace. @@ -25,11 +27,60 @@ func NewLiveConvoyFetcher() (*LiveConvoyFetcher, error) { return nil, fmt.Errorf("not in a Gas Town workspace: %w", err) } - return &LiveConvoyFetcher{ + fetcher := &LiveConvoyFetcher{ + townRoot: townRoot, townBeads: filepath.Join(townRoot, ".beads"), - }, nil + routes: make(map[string]string), + } + + // Load routes from routes.jsonl + fetcher.loadRoutes() + + return fetcher, nil } +// loadRoutes loads prefix routing from routes.jsonl. +func (f *LiveConvoyFetcher) loadRoutes() { + routesFile := filepath.Join(f.townBeads, "routes.jsonl") + data, err := exec.Command("cat", routesFile).Output() + if err != nil { + return + } + + for _, line := range strings.Split(string(data), "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + var route struct { + Prefix string `json:"prefix"` + Path string `json:"path"` + } + if err := json.Unmarshal([]byte(line), &route); err == nil && route.Prefix != "" { + f.routes[route.Prefix] = route.Path + } + } +} + +// getBeadsDir returns the appropriate beads directory for an issue ID. +// Routes issues to the correct rig's beads based on prefix. +func (f *LiveConvoyFetcher) getBeadsDir(issueID string) string { + // Find longest matching prefix + var bestMatch string + var bestPath string + for prefix, path := range f.routes { + if strings.HasPrefix(issueID, prefix) && len(prefix) > len(bestMatch) { + bestMatch = prefix + bestPath = path + } + } + + if bestPath == "" || bestPath == "." { + return f.townBeads + } + + return filepath.Join(f.townRoot, bestPath, ".beads") +} // FetchConvoys fetches all open convoys with their activity data. func (f *LiveConvoyFetcher) FetchConvoys() ([]ConvoyRow, error) { @@ -37,6 +88,8 @@ func (f *LiveConvoyFetcher) FetchConvoys() ([]ConvoyRow, error) { listArgs := []string{"list", "--type=convoy", "--status=open", "--json"} listCmd := exec.Command("bd", listArgs...) listCmd.Dir = f.townBeads + // Bypass daemon for faster execution (daemon adds ~5s latency) + listCmd.Env = append(listCmd.Environ(), "BEADS_NO_DAEMON=1") var stdout bytes.Buffer listCmd.Stdout = &stdout @@ -228,49 +281,63 @@ type issueDetail struct { } // getIssueDetailsBatch fetches details for multiple issues. +// Groups issues by their rig prefix and fetches from the correct beads directory. 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 + // Group issues by their beads directory + byDir := make(map[string][]string) + for _, id := range issueIDs { + dir := f.getBeadsDir(id) + byDir[dir] = append(byDir[dir], id) } - 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 - } + // Fetch from each directory + for dir, ids := range byDir { + args := append([]string{"show"}, ids...) + args = append(args, "--json") - for _, issue := range issues { - detail := &issueDetail{ - ID: issue.ID, - Title: issue.Title, - Status: issue.Status, - Assignee: issue.Assignee, + // #nosec G204 -- bd is a trusted internal tool, args are issue IDs + showCmd := exec.Command("bd", args...) + showCmd.Dir = dir + // Bypass daemon for faster execution (daemon adds ~5s latency) + showCmd.Env = append(showCmd.Environ(), "BEADS_NO_DAEMON=1") + var stdout bytes.Buffer + showCmd.Stdout = &stdout + + if err := showCmd.Run(); err != nil { + continue } - // Parse updated_at timestamp - if issue.UpdatedAt != "" { - if t, err := time.Parse(time.RFC3339, issue.UpdatedAt); err == nil { - detail.UpdatedAt = t + + 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 { + continue + } + + 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 } - result[issue.ID] = detail } return result