diff --git a/cmd/bd/label.go b/cmd/bd/label.go index 62227908..8b19192f 100644 --- a/cmd/bd/label.go +++ b/cmd/bd/label.go @@ -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}) diff --git a/cmd/bd/ship.go b/cmd/bd/ship.go new file mode 100644 index 00000000..04b14724 --- /dev/null +++ b/cmd/bd/ship.go @@ -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 ", + Short: "Publish a capability for cross-project dependencies", + Long: `Ship a capability to satisfy cross-project dependencies. + +This command: + 1. Finds issue with export: label + 2. Validates issue is closed (or --force to override) + 3. Adds provides: label + +External projects can depend on this capability using: + bd dep add external:: + +The capability is resolved when the external project has a closed issue +with the provides: 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: 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 %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: 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", + "", 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) +}