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:
Steve Yegge
2025-12-21 23:11:48 -08:00
parent e7f09660c0
commit 9afefe73a7
2 changed files with 191 additions and 0 deletions

View File

@@ -98,6 +98,15 @@ var labelAddCmd = &cobra.Command{
resolvedIDs = append(resolvedIDs, fullID)
}
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,
func(issueID, lbl string) error {
_, err := daemonClient.AddLabel(&rpc.LabelAddArgs{ID: issueID, Label: lbl})

182
cmd/bd/ship.go Normal file
View 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)
}