feat: add --unassigned flag to bd ready command
Adds the ability to filter ready work for issues with no assignee, which is useful for the SCAVENGE protocol in Gas Town where polecats need to query the "Salvage Yard" for unclaimed work. Changes: - Add Unassigned bool field to types.WorkFilter - Add --unassigned/-u flag to bd ready command - Update SQL query in GetReadyWork to filter for NULL/empty assignee - Add Unassigned field to RPC ReadyArgs for daemon support - Add tests for the new functionality Closes: gt-3rp 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -16,18 +16,20 @@ var readyCmd = &cobra.Command{
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
limit, _ := cmd.Flags().GetInt("limit")
|
||||
assignee, _ := cmd.Flags().GetString("assignee")
|
||||
unassigned, _ := cmd.Flags().GetBool("unassigned")
|
||||
sortPolicy, _ := cmd.Flags().GetString("sort")
|
||||
labels, _ := cmd.Flags().GetStringSlice("label")
|
||||
labelsAny, _ := cmd.Flags().GetStringSlice("label-any")
|
||||
// Use global jsonOutput set by PersistentPreRun (respects config.yaml + env vars)
|
||||
|
||||
|
||||
// Normalize labels: trim, dedupe, remove empty
|
||||
labels = util.NormalizeLabels(labels)
|
||||
labelsAny = util.NormalizeLabels(labelsAny)
|
||||
|
||||
|
||||
filter := types.WorkFilter{
|
||||
// Leave Status empty to get both 'open' and 'in_progress' (bd-165)
|
||||
Limit: limit,
|
||||
Unassigned: unassigned,
|
||||
SortPolicy: types.SortPolicy(sortPolicy),
|
||||
Labels: labels,
|
||||
LabelsAny: labelsAny,
|
||||
@@ -49,6 +51,7 @@ var readyCmd = &cobra.Command{
|
||||
if daemonClient != nil {
|
||||
readyArgs := &rpc.ReadyArgs{
|
||||
Assignee: assignee,
|
||||
Unassigned: unassigned,
|
||||
Limit: limit,
|
||||
SortPolicy: sortPolicy,
|
||||
Labels: labels,
|
||||
@@ -293,6 +296,7 @@ func init() {
|
||||
readyCmd.Flags().IntP("limit", "n", 10, "Maximum issues to show")
|
||||
readyCmd.Flags().IntP("priority", "p", 0, "Filter by priority")
|
||||
readyCmd.Flags().StringP("assignee", "a", "", "Filter by assignee")
|
||||
readyCmd.Flags().BoolP("unassigned", "u", false, "Show only unassigned issues")
|
||||
readyCmd.Flags().StringP("sort", "s", "hybrid", "Sort policy: hybrid (default), priority, oldest")
|
||||
readyCmd.Flags().StringSliceP("label", "l", []string{}, "Filter by labels (AND: must have ALL). Can combine with --label-any")
|
||||
readyCmd.Flags().StringSlice("label-any", []string{}, "Filter by labels (OR: must have AT LEAST ONE). Can combine with --label")
|
||||
|
||||
@@ -183,6 +183,35 @@ func TestReadySuite(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ReadyWorkUnassigned", func(t *testing.T) {
|
||||
// Test filtering for unassigned issues
|
||||
readyUnassigned, err := s.GetReadyWork(ctx, types.WorkFilter{
|
||||
Unassigned: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("GetReadyWork with unassigned filter failed: %v", err)
|
||||
}
|
||||
|
||||
// All returned issues should have no assignee
|
||||
for _, issue := range readyUnassigned {
|
||||
if issue.Assignee != "" {
|
||||
t.Errorf("Expected empty assignee, got %q for issue %s", issue.Assignee, issue.ID)
|
||||
}
|
||||
}
|
||||
|
||||
// Should include test-unassigned from previous test
|
||||
found := false
|
||||
for _, issue := range readyUnassigned {
|
||||
if issue.ID == "test-unassigned" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("Expected to find test-unassigned in unassigned results")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ReadyWorkInProgress", func(t *testing.T) {
|
||||
// Create in-progress issue (should be in ready work)
|
||||
issue := &types.Issue{
|
||||
|
||||
@@ -182,6 +182,7 @@ type ResolveIDArgs struct {
|
||||
// ReadyArgs represents arguments for the ready operation
|
||||
type ReadyArgs struct {
|
||||
Assignee string `json:"assignee,omitempty"`
|
||||
Unassigned bool `json:"unassigned,omitempty"`
|
||||
Priority *int `json:"priority,omitempty"`
|
||||
Limit int `json:"limit,omitempty"`
|
||||
SortPolicy string `json:"sort_policy,omitempty"`
|
||||
|
||||
@@ -955,6 +955,7 @@ func (s *Server) handleReady(req *Request) Response {
|
||||
wf := types.WorkFilter{
|
||||
Status: types.StatusOpen,
|
||||
Priority: readyArgs.Priority,
|
||||
Unassigned: readyArgs.Unassigned,
|
||||
Limit: readyArgs.Limit,
|
||||
SortPolicy: types.SortPolicy(readyArgs.SortPolicy),
|
||||
Labels: util.NormalizeLabels(readyArgs.Labels),
|
||||
|
||||
@@ -29,7 +29,9 @@ func (s *SQLiteStorage) GetReadyWork(ctx context.Context, filter types.WorkFilte
|
||||
args = append(args, *filter.Priority)
|
||||
}
|
||||
|
||||
if filter.Assignee != nil {
|
||||
if filter.Unassigned {
|
||||
whereClauses = append(whereClauses, "(i.assignee IS NULL OR i.assignee = '')")
|
||||
} else if filter.Assignee != nil {
|
||||
whereClauses = append(whereClauses, "i.assignee = ?")
|
||||
args = append(args, *filter.Assignee)
|
||||
}
|
||||
|
||||
@@ -168,6 +168,40 @@ func TestGetReadyWorkWithAssigneeFilter(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetReadyWorkWithUnassignedFilter(t *testing.T) {
|
||||
store, cleanup := setupTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Create issues with different assignees
|
||||
issueAlice := &types.Issue{Title: "Alice's task", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask, Assignee: "alice"}
|
||||
issueBob := &types.Issue{Title: "Bob's task", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask, Assignee: "bob"}
|
||||
issueUnassigned := &types.Issue{Title: "Unassigned", Status: types.StatusOpen, Priority: 1, IssueType: types.TypeTask}
|
||||
|
||||
store.CreateIssue(ctx, issueAlice, "test-user")
|
||||
store.CreateIssue(ctx, issueBob, "test-user")
|
||||
store.CreateIssue(ctx, issueUnassigned, "test-user")
|
||||
|
||||
// Filter for unassigned issues
|
||||
ready, err := store.GetReadyWork(ctx, types.WorkFilter{Status: types.StatusOpen, Unassigned: true})
|
||||
if err != nil {
|
||||
t.Fatalf("GetReadyWork with unassigned filter failed: %v", err)
|
||||
}
|
||||
|
||||
if len(ready) != 1 {
|
||||
t.Fatalf("Expected 1 unassigned issue, got %d", len(ready))
|
||||
}
|
||||
|
||||
if ready[0].Assignee != "" {
|
||||
t.Errorf("Expected unassigned issue, got assignee %q", ready[0].Assignee)
|
||||
}
|
||||
|
||||
if ready[0].ID != issueUnassigned.ID {
|
||||
t.Errorf("Expected issue %s, got %s", issueUnassigned.ID, ready[0].ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetReadyWorkWithLimit(t *testing.T) {
|
||||
store, cleanup := setupTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
@@ -351,6 +351,7 @@ type WorkFilter struct {
|
||||
Status Status
|
||||
Priority *int
|
||||
Assignee *string
|
||||
Unassigned bool // Filter for issues with no assignee
|
||||
Labels []string // AND semantics: issue must have ALL these labels
|
||||
LabelsAny []string // OR semantics: issue must have AT LEAST ONE of these labels
|
||||
Limit int
|
||||
|
||||
Reference in New Issue
Block a user