feat: Add bd repos multi-repo commands and fix bd ready for in_progress issues
- Add 'bd repos' command for multi-repository management (bd-123)
- bd repos list: show all cached repositories
- bd repos ready: aggregate ready work across repos
- bd repos stats: combined statistics across repos
- bd repos clear-cache: clear repository cache
- Requires global daemon (bd daemon --global)
- Fix bd ready to show in_progress issues (bd-165)
- bd ready now shows both 'open' and 'in_progress' issues with no blockers
- Allows epics/tasks ready to close to appear in ready work
- Critical P0 bug fix for workflow
- Apply code review improvements to repos implementation
- Use strongly typed RPC responses (remove interface{})
- Fix clear-cache lock handling (close connections outside lock)
- Add error collection for per-repo failures
- Add context timeouts (1-2s) to prevent hangs
- Add lock strategy comments
- Update documentation (README.md, AGENTS.md)
- Add comprehensive tests for both features
Amp-Thread-ID: https://ampcode.com/threads/T-1de989a1-1890-492c-9847-a34144259e0f
Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
@@ -15,13 +15,13 @@ import (
|
||||
|
||||
var readyCmd = &cobra.Command{
|
||||
Use: "ready",
|
||||
Short: "Show ready work (no blockers)",
|
||||
Short: "Show ready work (no blockers, open or in-progress)",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
limit, _ := cmd.Flags().GetInt("limit")
|
||||
assignee, _ := cmd.Flags().GetString("assignee")
|
||||
|
||||
filter := types.WorkFilter{
|
||||
Status: types.StatusOpen,
|
||||
// Leave Status empty to get both 'open' and 'in_progress' (bd-165)
|
||||
Limit: limit,
|
||||
}
|
||||
// Use Changed() to properly handle P0 (priority=0)
|
||||
|
||||
293
cmd/bd/repos.go
Normal file
293
cmd/bd/repos.go
Normal file
@@ -0,0 +1,293 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/fatih/color"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/steveyegge/beads/internal/rpc"
|
||||
)
|
||||
|
||||
var reposCmd = &cobra.Command{
|
||||
Use: "repos",
|
||||
Short: "Multi-repository management (requires global daemon)",
|
||||
Long: `Manage work across multiple repositories when using a global daemon.
|
||||
|
||||
This command requires a running global daemon (bd daemon --global).
|
||||
It allows you to view and aggregate work across all cached repositories.`,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
cmd.Help()
|
||||
},
|
||||
}
|
||||
|
||||
var reposListCmd = &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List all cached repositories",
|
||||
Long: `Show all repositories that the daemon has cached.
|
||||
|
||||
The daemon caches a repository after any command is run from that directory.
|
||||
This command shows all active caches with their paths, prefixes, and issue counts.`,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
if daemonClient == nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: This command requires a running daemon\n")
|
||||
fmt.Fprintf(os.Stderr, "Start one with: bd daemon --global\n")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
resp, err := daemonClient.ReposList()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
var repos []rpc.RepoInfo
|
||||
if err := json.Unmarshal(resp.Data, &repos); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error parsing response: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if jsonOutput {
|
||||
outputJSON(repos)
|
||||
return
|
||||
}
|
||||
|
||||
if len(repos) == 0 {
|
||||
yellow := color.New(color.FgYellow).SprintFunc()
|
||||
fmt.Printf("\n%s No repositories cached yet\n", yellow("📁"))
|
||||
fmt.Printf("Repositories are cached when you run commands from their directories.\n\n")
|
||||
return
|
||||
}
|
||||
|
||||
cyan := color.New(color.FgCyan).SprintFunc()
|
||||
green := color.New(color.FgGreen).SprintFunc()
|
||||
fmt.Printf("\n%s Cached Repositories (%d):\n\n", cyan("📁"), len(repos))
|
||||
|
||||
for _, repo := range repos {
|
||||
prefix := repo.Prefix
|
||||
if prefix == "" {
|
||||
prefix = "(no prefix)"
|
||||
}
|
||||
fmt.Printf("%s\n", repo.Path)
|
||||
fmt.Printf(" Prefix: %s\n", prefix)
|
||||
fmt.Printf(" Issue Count: %s\n", green(fmt.Sprintf("%d", repo.IssueCount)))
|
||||
fmt.Printf(" Status: %s\n", repo.LastAccess)
|
||||
fmt.Println()
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
var reposReadyCmd = &cobra.Command{
|
||||
Use: "ready",
|
||||
Short: "Show ready work across all repositories",
|
||||
Long: `Display ready work (issues with no blockers) from all cached repositories.
|
||||
|
||||
By default, shows a flat list of all ready work. Use --group to organize by repository.`,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
if daemonClient == nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: This command requires a running daemon\n")
|
||||
fmt.Fprintf(os.Stderr, "Start one with: bd daemon --global\n")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
limit, _ := cmd.Flags().GetInt("limit")
|
||||
assignee, _ := cmd.Flags().GetString("assignee")
|
||||
groupByRepo, _ := cmd.Flags().GetBool("group")
|
||||
|
||||
readyArgs := &rpc.ReposReadyArgs{
|
||||
Assignee: assignee,
|
||||
Limit: limit,
|
||||
GroupByRepo: groupByRepo,
|
||||
}
|
||||
|
||||
if cmd.Flags().Changed("priority") {
|
||||
priority, _ := cmd.Flags().GetInt("priority")
|
||||
readyArgs.Priority = &priority
|
||||
}
|
||||
|
||||
resp, err := daemonClient.ReposReady(readyArgs)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if groupByRepo {
|
||||
var grouped []rpc.RepoReadyWork
|
||||
if err := json.Unmarshal(resp.Data, &grouped); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error parsing response: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if jsonOutput {
|
||||
outputJSON(grouped)
|
||||
return
|
||||
}
|
||||
|
||||
if len(grouped) == 0 {
|
||||
yellow := color.New(color.FgYellow).SprintFunc()
|
||||
fmt.Printf("\n%s No ready work found across any repositories\n\n", yellow("✨"))
|
||||
return
|
||||
}
|
||||
|
||||
cyan := color.New(color.FgCyan).SprintFunc()
|
||||
fmt.Printf("\n%s Ready work across %d repositories:\n\n", cyan("📋"), len(grouped))
|
||||
|
||||
for _, repo := range grouped {
|
||||
fmt.Printf("%s (%d issues):\n", repo.RepoPath, len(repo.Issues))
|
||||
for i, issue := range repo.Issues {
|
||||
fmt.Printf(" %d. [P%d] %s: %s\n", i+1, issue.Priority, issue.ID, issue.Title)
|
||||
if issue.EstimatedMinutes != nil {
|
||||
fmt.Printf(" Estimate: %d min\n", *issue.EstimatedMinutes)
|
||||
}
|
||||
if issue.Assignee != "" {
|
||||
fmt.Printf(" Assignee: %s\n", issue.Assignee)
|
||||
}
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
} else {
|
||||
var issues []rpc.ReposReadyIssue
|
||||
if err := json.Unmarshal(resp.Data, &issues); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error parsing response: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if jsonOutput {
|
||||
outputJSON(issues)
|
||||
return
|
||||
}
|
||||
|
||||
if len(issues) == 0 {
|
||||
yellow := color.New(color.FgYellow).SprintFunc()
|
||||
fmt.Printf("\n%s No ready work found across any repositories\n\n", yellow("✨"))
|
||||
return
|
||||
}
|
||||
|
||||
cyan := color.New(color.FgCyan).SprintFunc()
|
||||
fmt.Printf("\n%s Ready work across all repositories (%d issues):\n\n", cyan("📋"), len(issues))
|
||||
|
||||
for i, item := range issues {
|
||||
issue := item.Issue
|
||||
fmt.Printf("%d. [P%d] %s: %s\n", i+1, issue.Priority, issue.ID, issue.Title)
|
||||
fmt.Printf(" Repo: %s\n", item.RepoPath)
|
||||
|
||||
if issue.EstimatedMinutes != nil {
|
||||
fmt.Printf(" Estimate: %d min\n", *issue.EstimatedMinutes)
|
||||
}
|
||||
if issue.Assignee != "" {
|
||||
fmt.Printf(" Assignee: %s\n", issue.Assignee)
|
||||
}
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
var reposStatsCmd = &cobra.Command{
|
||||
Use: "stats",
|
||||
Short: "Show combined statistics across all repositories",
|
||||
Long: `Display aggregated statistics from all cached repositories.
|
||||
|
||||
Shows both total combined statistics and per-repository breakdowns.`,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
if daemonClient == nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: This command requires a running daemon\n")
|
||||
fmt.Fprintf(os.Stderr, "Start one with: bd daemon --global\n")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
resp, err := daemonClient.ReposStats()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
var statsResp rpc.ReposStatsResponse
|
||||
if err := json.Unmarshal(resp.Data, &statsResp); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error parsing response: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if jsonOutput {
|
||||
outputJSON(statsResp)
|
||||
return
|
||||
}
|
||||
|
||||
cyan := color.New(color.FgCyan).SprintFunc()
|
||||
green := color.New(color.FgGreen).SprintFunc()
|
||||
yellow := color.New(color.FgYellow).SprintFunc()
|
||||
|
||||
fmt.Printf("\n%s Combined Statistics Across All Repositories:\n\n", cyan("📊"))
|
||||
fmt.Printf("Total Issues: %d\n", statsResp.Total.TotalIssues)
|
||||
fmt.Printf("Open: %s\n", green(fmt.Sprintf("%d", statsResp.Total.OpenIssues)))
|
||||
fmt.Printf("In Progress: %s\n", yellow(fmt.Sprintf("%d", statsResp.Total.InProgressIssues)))
|
||||
fmt.Printf("Closed: %d\n", statsResp.Total.ClosedIssues)
|
||||
fmt.Printf("Blocked: %d\n", statsResp.Total.BlockedIssues)
|
||||
fmt.Printf("Ready: %s\n", green(fmt.Sprintf("%d", statsResp.Total.ReadyIssues)))
|
||||
fmt.Println()
|
||||
|
||||
if len(statsResp.PerRepo) > 0 {
|
||||
fmt.Printf("%s Per-Repository Breakdown:\n\n", cyan("📁"))
|
||||
for path, stats := range statsResp.PerRepo {
|
||||
fmt.Printf("%s:\n", path)
|
||||
fmt.Printf(" Total: %d Ready: %s Blocked: %d\n",
|
||||
stats.TotalIssues, green(fmt.Sprintf("%d", stats.ReadyIssues)), stats.BlockedIssues)
|
||||
fmt.Println()
|
||||
}
|
||||
}
|
||||
|
||||
if len(statsResp.Errors) > 0 {
|
||||
red := color.New(color.FgRed).SprintFunc()
|
||||
fmt.Printf("%s Errors (%d repositories):\n", red("⚠"), len(statsResp.Errors))
|
||||
for path, errMsg := range statsResp.Errors {
|
||||
fmt.Printf(" %s: %s\n", path, errMsg)
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
var reposClearCacheCmd = &cobra.Command{
|
||||
Use: "clear-cache",
|
||||
Short: "Clear all cached repository connections",
|
||||
Long: `Close all cached storage connections and clear the daemon's repository cache.
|
||||
|
||||
Useful for freeing resources or forcing the daemon to reload repository databases.
|
||||
The cache will be rebuilt automatically as commands are run from different directories.`,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
if daemonClient == nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: This command requires a running daemon\n")
|
||||
fmt.Fprintf(os.Stderr, "Start one with: bd daemon --global\n")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
resp, err := daemonClient.ReposClearCache()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if jsonOutput {
|
||||
fmt.Println(string(resp.Data))
|
||||
return
|
||||
}
|
||||
|
||||
green := color.New(color.FgGreen).SprintFunc()
|
||||
fmt.Printf("\n%s Repository cache cleared successfully\n\n", green("✅"))
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
reposReadyCmd.Flags().IntP("limit", "n", 10, "Maximum issues to show per repository")
|
||||
reposReadyCmd.Flags().IntP("priority", "p", -1, "Filter by priority (0-4)")
|
||||
reposReadyCmd.Flags().StringP("assignee", "a", "", "Filter by assignee")
|
||||
reposReadyCmd.Flags().BoolP("group", "g", false, "Group issues by repository")
|
||||
|
||||
reposCmd.AddCommand(reposListCmd)
|
||||
reposCmd.AddCommand(reposReadyCmd)
|
||||
reposCmd.AddCommand(reposStatsCmd)
|
||||
reposCmd.AddCommand(reposClearCacheCmd)
|
||||
|
||||
rootCmd.AddCommand(reposCmd)
|
||||
}
|
||||
Reference in New Issue
Block a user