feat: add bd ship command for cross-project capabilities (bd-eijl)
- bd ship <capability> finds issue with export:<capability> label - Validates issue is closed (or --force to override) - Adds provides:<capability> label to mark capability as shipped - Protects provides:* namespace in bd label add - Supports --dry-run for preview External projects depend on capabilities via: bd dep add <issue> external:<project>:<capability> 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -98,6 +98,15 @@ var labelAddCmd = &cobra.Command{
|
|||||||
resolvedIDs = append(resolvedIDs, fullID)
|
resolvedIDs = append(resolvedIDs, fullID)
|
||||||
}
|
}
|
||||||
issueIDs = resolvedIDs
|
issueIDs = resolvedIDs
|
||||||
|
|
||||||
|
// Protect reserved label namespaces (bd-eijl)
|
||||||
|
// provides:* labels can only be added via 'bd ship' command
|
||||||
|
if strings.HasPrefix(label, "provides:") {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error: 'provides:' labels are reserved for cross-project capabilities\n")
|
||||||
|
fmt.Fprintf(os.Stderr, "Hint: use 'bd ship %s' instead\n", strings.TrimPrefix(label, "provides:"))
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
processBatchLabelOperation(issueIDs, label, "added", jsonOutput,
|
processBatchLabelOperation(issueIDs, label, "added", jsonOutput,
|
||||||
func(issueID, lbl string) error {
|
func(issueID, lbl string) error {
|
||||||
_, err := daemonClient.AddLabel(&rpc.LabelAddArgs{ID: issueID, Label: lbl})
|
_, err := daemonClient.AddLabel(&rpc.LabelAddArgs{ID: issueID, Label: lbl})
|
||||||
|
|||||||
182
cmd/bd/ship.go
Normal file
182
cmd/bd/ship.go
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
"github.com/steveyegge/beads/internal/rpc"
|
||||||
|
"github.com/steveyegge/beads/internal/storage/sqlite"
|
||||||
|
"github.com/steveyegge/beads/internal/types"
|
||||||
|
"github.com/steveyegge/beads/internal/ui"
|
||||||
|
)
|
||||||
|
|
||||||
|
var shipCmd = &cobra.Command{
|
||||||
|
Use: "ship <capability>",
|
||||||
|
Short: "Publish a capability for cross-project dependencies",
|
||||||
|
Long: `Ship a capability to satisfy cross-project dependencies.
|
||||||
|
|
||||||
|
This command:
|
||||||
|
1. Finds issue with export:<capability> label
|
||||||
|
2. Validates issue is closed (or --force to override)
|
||||||
|
3. Adds provides:<capability> label
|
||||||
|
|
||||||
|
External projects can depend on this capability using:
|
||||||
|
bd dep add <issue> external:<project>:<capability>
|
||||||
|
|
||||||
|
The capability is resolved when the external project has a closed issue
|
||||||
|
with the provides:<capability> label.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
bd ship mol-run-assignee # Ship the mol-run-assignee capability
|
||||||
|
bd ship mol-run-assignee --force # Ship even if issue is not closed
|
||||||
|
bd ship mol-run-assignee --dry-run # Preview without making changes`,
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
Run: runShip,
|
||||||
|
}
|
||||||
|
|
||||||
|
func runShip(cmd *cobra.Command, args []string) {
|
||||||
|
CheckReadonly("ship")
|
||||||
|
|
||||||
|
capability := args[0]
|
||||||
|
force, _ := cmd.Flags().GetBool("force")
|
||||||
|
dryRun, _ := cmd.Flags().GetBool("dry-run")
|
||||||
|
|
||||||
|
ctx := rootCtx
|
||||||
|
|
||||||
|
// Find issue with export:<capability> label
|
||||||
|
exportLabel := "export:" + capability
|
||||||
|
providesLabel := "provides:" + capability
|
||||||
|
|
||||||
|
var issues []*types.Issue
|
||||||
|
var err error
|
||||||
|
|
||||||
|
// Ship requires direct store access for label operations
|
||||||
|
if daemonClient != nil && store == nil {
|
||||||
|
store, err = sqlite.New(ctx, dbPath)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error: failed to open database: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
defer func() { _ = store.Close() }()
|
||||||
|
}
|
||||||
|
|
||||||
|
if daemonClient != nil {
|
||||||
|
// Use RPC to list issues with the export label
|
||||||
|
listArgs := &rpc.ListArgs{
|
||||||
|
LabelsAny: []string{exportLabel},
|
||||||
|
}
|
||||||
|
resp, err := daemonClient.List(listArgs)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error listing issues: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(resp.Data, &issues); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error unmarshaling issues: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
issues, err = store.GetIssuesByLabel(ctx, exportLabel)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error listing issues: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(issues) == 0 {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error: no issue found with label '%s'\n", exportLabel)
|
||||||
|
fmt.Fprintf(os.Stderr, "Hint: add the label first: bd label add <issue-id> %s\n", exportLabel)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(issues) > 1 {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error: multiple issues found with label '%s':\n", exportLabel)
|
||||||
|
for _, issue := range issues {
|
||||||
|
fmt.Fprintf(os.Stderr, " %s: %s (%s)\n", issue.ID, issue.Title, issue.Status)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(os.Stderr, "Hint: only one issue should have this label\n")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
issue := issues[0]
|
||||||
|
|
||||||
|
// Validate issue is closed (unless --force)
|
||||||
|
if issue.Status != types.StatusClosed && !force {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error: issue %s is not closed (status: %s)\n", issue.ID, issue.Status)
|
||||||
|
fmt.Fprintf(os.Stderr, "Hint: close the issue first, or use --force to override\n")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if already shipped (use direct store access)
|
||||||
|
hasProvides := false
|
||||||
|
labels, err := store.GetLabels(ctx, issue.ID)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error getting labels: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
for _, l := range labels {
|
||||||
|
if l == providesLabel {
|
||||||
|
hasProvides = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if hasProvides {
|
||||||
|
if jsonOutput {
|
||||||
|
outputJSON(map[string]interface{}{
|
||||||
|
"status": "already_shipped",
|
||||||
|
"capability": capability,
|
||||||
|
"issue_id": issue.ID,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
fmt.Printf("%s Capability '%s' already shipped (%s)\n",
|
||||||
|
ui.RenderPass("✓"), capability, issue.ID)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if dryRun {
|
||||||
|
if jsonOutput {
|
||||||
|
outputJSON(map[string]interface{}{
|
||||||
|
"status": "dry_run",
|
||||||
|
"capability": capability,
|
||||||
|
"issue_id": issue.ID,
|
||||||
|
"would_add": providesLabel,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
fmt.Printf("%s Would ship '%s' on %s (dry run)\n",
|
||||||
|
ui.RenderAccent("→"), capability, issue.ID)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add provides:<capability> label (use direct store access)
|
||||||
|
if err := store.AddLabel(ctx, issue.ID, providesLabel, actor); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error adding label: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
markDirtyAndScheduleFlush()
|
||||||
|
|
||||||
|
if jsonOutput {
|
||||||
|
outputJSON(map[string]interface{}{
|
||||||
|
"status": "shipped",
|
||||||
|
"capability": capability,
|
||||||
|
"issue_id": issue.ID,
|
||||||
|
"label": providesLabel,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
fmt.Printf("%s Shipped %s (%s)\n",
|
||||||
|
ui.RenderPass("✓"), capability, issue.ID)
|
||||||
|
fmt.Printf(" Added label: %s\n", providesLabel)
|
||||||
|
fmt.Printf("\nExternal projects can now depend on: external:%s:%s\n",
|
||||||
|
"<this-project>", capability)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
shipCmd.Flags().Bool("force", false, "Ship even if issue is not closed")
|
||||||
|
shipCmd.Flags().Bool("dry-run", false, "Preview without making changes")
|
||||||
|
|
||||||
|
rootCmd.AddCommand(shipCmd)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user