* fix(create): Use prefix from routes.jsonl when creating issues with --rig
When using `bd create --rig <name>`, the prefix from routes.jsonl was
being discarded. This caused issues to be created with the target
database's default prefix instead of the route's prefix.
This is particularly problematic when using the redirect mechanism to
share a single database across multiple rigs - the redirect correctly
routes to the shared database, but the prefix was not being applied.
The fix:
1. Capture the prefix from routing.ResolveBeadsDirForRig()
2. Temporarily override the target database's issue_prefix config
3. Restore the original prefix after issue creation
Example scenario that now works:
- routes.jsonl: {"prefix": "aops-", "path": "src/academicOps"}
- src/academicOps/.beads/redirect points to ~/writing/.beads
- `bd create --rig aops "Test"` now creates aops-xxx instead of ns-xxx
Co-Authored-By: Claude <noreply@anthropic.com>
* fix(create): pass prefix via struct field instead of mutating config
The previous approach temporarily mutated the database's issue_prefix
config during cross-rig issue creation, then restored it afterward.
This was fragile in multi-user scenarios where concurrent operations
could see the wrong prefix.
New approach:
- Add PrefixOverride field to types.Issue
- CreateIssue checks PrefixOverride first, uses it if set
- createInRig sets issue.PrefixOverride instead of mutating config
This passes state as a parameter rather than mutating shared state,
making it safe for concurrent multi-user access.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude <noreply@anthropic.com>
902 lines
31 KiB
Go
902 lines
31 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/spf13/cobra"
|
|
"github.com/steveyegge/beads/internal/config"
|
|
"github.com/steveyegge/beads/internal/debug"
|
|
"github.com/steveyegge/beads/internal/hooks"
|
|
"github.com/steveyegge/beads/internal/routing"
|
|
"github.com/steveyegge/beads/internal/rpc"
|
|
"github.com/steveyegge/beads/internal/storage/sqlite"
|
|
"github.com/steveyegge/beads/internal/timeparsing"
|
|
"github.com/steveyegge/beads/internal/types"
|
|
"github.com/steveyegge/beads/internal/ui"
|
|
"github.com/steveyegge/beads/internal/validation"
|
|
)
|
|
|
|
var createCmd = &cobra.Command{
|
|
Use: "create [title]",
|
|
GroupID: "issues",
|
|
Aliases: []string{"new"},
|
|
Short: "Create a new issue (or multiple issues from markdown file)",
|
|
Args: cobra.MinimumNArgs(0), // Changed to allow no args when using -f
|
|
Run: func(cmd *cobra.Command, args []string) {
|
|
CheckReadonly("create")
|
|
file, _ := cmd.Flags().GetString("file")
|
|
|
|
// If file flag is provided, parse markdown and create multiple issues
|
|
if file != "" {
|
|
if len(args) > 0 {
|
|
FatalError("cannot specify both title and --file flag")
|
|
}
|
|
// --dry-run not supported with --file (would need to parse and preview multiple issues)
|
|
dryRun, _ := cmd.Flags().GetBool("dry-run")
|
|
if dryRun {
|
|
FatalError("--dry-run is not supported with --file flag")
|
|
}
|
|
createIssuesFromMarkdown(cmd, file)
|
|
return
|
|
}
|
|
|
|
// Original single-issue creation logic
|
|
// Get title from flag or positional argument
|
|
titleFlag, _ := cmd.Flags().GetString("title")
|
|
var title string
|
|
|
|
if len(args) > 0 && titleFlag != "" {
|
|
// Both provided - check if they match
|
|
if args[0] != titleFlag {
|
|
FatalError("cannot specify different titles as both positional argument and --title flag\n Positional: %q\n --title: %q", args[0], titleFlag)
|
|
}
|
|
title = args[0] // They're the same, use either
|
|
} else if len(args) > 0 {
|
|
title = args[0]
|
|
} else if titleFlag != "" {
|
|
title = titleFlag
|
|
} else {
|
|
FatalError("title required (or use --file to create from markdown)")
|
|
}
|
|
|
|
// Get silent flag
|
|
silent, _ := cmd.Flags().GetBool("silent")
|
|
|
|
// Warn if creating a test issue in production database (unless silent mode)
|
|
if strings.HasPrefix(strings.ToLower(title), "test") && !silent && !debug.IsQuiet() {
|
|
fmt.Fprintf(os.Stderr, "%s Creating issue with 'Test' prefix in production database.\n", ui.RenderWarn("⚠"))
|
|
fmt.Fprintf(os.Stderr, " For testing, consider using: BEADS_DB=/tmp/test.db ./bd create \"Test issue\"\n")
|
|
}
|
|
|
|
// Get field values
|
|
description, _ := getDescriptionFlag(cmd)
|
|
|
|
// Check if description is required by config
|
|
if description == "" && !strings.Contains(strings.ToLower(title), "test") {
|
|
if config.GetBool("create.require-description") {
|
|
FatalError("description is required (set create.require-description: false in config.yaml to disable)")
|
|
}
|
|
// Warn if creating an issue without a description (unless silent mode)
|
|
if !silent && !debug.IsQuiet() {
|
|
fmt.Fprintf(os.Stderr, "%s Creating issue without description.\n", ui.RenderWarn("⚠"))
|
|
fmt.Fprintf(os.Stderr, " Issues without descriptions lack context for future work.\n")
|
|
fmt.Fprintf(os.Stderr, " Consider adding --description=\"Why this issue exists and what needs to be done\"\n")
|
|
}
|
|
}
|
|
|
|
design, _ := cmd.Flags().GetString("design")
|
|
acceptance, _ := cmd.Flags().GetString("acceptance")
|
|
notes, _ := cmd.Flags().GetString("notes")
|
|
|
|
// Parse priority (supports both "1" and "P1" formats)
|
|
priorityStr, _ := cmd.Flags().GetString("priority")
|
|
priority, err := validation.ValidatePriority(priorityStr)
|
|
if err != nil {
|
|
FatalError("%v", err)
|
|
}
|
|
|
|
issueType, _ := cmd.Flags().GetString("type")
|
|
assignee, _ := cmd.Flags().GetString("assignee")
|
|
|
|
labels, _ := cmd.Flags().GetStringSlice("labels")
|
|
labelAlias, _ := cmd.Flags().GetStringSlice("label")
|
|
if len(labelAlias) > 0 {
|
|
labels = append(labels, labelAlias...)
|
|
}
|
|
|
|
explicitID, _ := cmd.Flags().GetString("id")
|
|
parentID, _ := cmd.Flags().GetString("parent")
|
|
externalRef, _ := cmd.Flags().GetString("external-ref")
|
|
deps, _ := cmd.Flags().GetStringSlice("deps")
|
|
waitsFor, _ := cmd.Flags().GetString("waits-for")
|
|
waitsForGate, _ := cmd.Flags().GetString("waits-for-gate")
|
|
forceCreate, _ := cmd.Flags().GetBool("force")
|
|
repoOverride, _ := cmd.Flags().GetString("repo")
|
|
rigOverride, _ := cmd.Flags().GetString("rig")
|
|
prefixOverride, _ := cmd.Flags().GetString("prefix")
|
|
wisp, _ := cmd.Flags().GetBool("ephemeral")
|
|
molTypeStr, _ := cmd.Flags().GetString("mol-type")
|
|
var molType types.MolType
|
|
if molTypeStr != "" {
|
|
molType = types.MolType(molTypeStr)
|
|
if !molType.IsValid() {
|
|
FatalError("invalid mol-type %q (must be swarm, patrol, or work)", molTypeStr)
|
|
}
|
|
}
|
|
|
|
// Agent-specific flags
|
|
roleType, _ := cmd.Flags().GetString("role-type")
|
|
agentRig, _ := cmd.Flags().GetString("agent-rig")
|
|
|
|
// Validate agent-specific flags require --type=agent
|
|
if (roleType != "" || agentRig != "") && issueType != "agent" {
|
|
FatalError("--role-type and --agent-rig flags require --type=agent")
|
|
}
|
|
|
|
// Event-specific flags
|
|
eventCategory, _ := cmd.Flags().GetString("event-category")
|
|
eventActor, _ := cmd.Flags().GetString("event-actor")
|
|
eventTarget, _ := cmd.Flags().GetString("event-target")
|
|
eventPayload, _ := cmd.Flags().GetString("event-payload")
|
|
|
|
// Validate event-specific flags require --type=event
|
|
if (eventCategory != "" || eventActor != "" || eventTarget != "" || eventPayload != "") && issueType != "event" {
|
|
FatalError("--event-category, --event-actor, --event-target, and --event-payload flags require --type=event")
|
|
}
|
|
|
|
// Parse --due flag (GH#820)
|
|
// Uses layered parsing: compact duration → NLP → date-only → RFC3339
|
|
var dueAt *time.Time
|
|
dueStr, _ := cmd.Flags().GetString("due")
|
|
if dueStr != "" {
|
|
t, err := timeparsing.ParseRelativeTime(dueStr, time.Now())
|
|
if err != nil {
|
|
FatalError("invalid --due format %q. Examples: +6h, tomorrow, next monday, 2025-01-15", dueStr)
|
|
}
|
|
dueAt = &t
|
|
}
|
|
|
|
// Parse --defer flag (GH#820)
|
|
var deferUntil *time.Time
|
|
deferStr, _ := cmd.Flags().GetString("defer")
|
|
if deferStr != "" {
|
|
t, err := timeparsing.ParseRelativeTime(deferStr, time.Now())
|
|
if err != nil {
|
|
FatalError("invalid --defer format %q. Examples: +1h, tomorrow, next monday, 2025-01-15", deferStr)
|
|
}
|
|
// Warn if defer date is in the past (user probably meant future)
|
|
if t.Before(time.Now()) && !silent && !debug.IsQuiet() {
|
|
fmt.Fprintf(os.Stderr, "%s Defer date %q is in the past. Issue will appear in bd ready immediately.\n",
|
|
ui.RenderWarn("!"), t.Format("2006-01-02 15:04"))
|
|
fmt.Fprintf(os.Stderr, " Did you mean a future date? Use --defer=+1h or --defer=tomorrow\n")
|
|
}
|
|
deferUntil = &t
|
|
}
|
|
|
|
// Handle --dry-run flag (before --rig to ensure it works with cross-rig creation)
|
|
dryRun, _ := cmd.Flags().GetBool("dry-run")
|
|
if dryRun {
|
|
// Build preview issue
|
|
var externalRefPtr *string
|
|
if externalRef != "" {
|
|
externalRefPtr = &externalRef
|
|
}
|
|
previewIssue := &types.Issue{
|
|
Title: title,
|
|
Description: description,
|
|
Design: design,
|
|
AcceptanceCriteria: acceptance,
|
|
Notes: notes,
|
|
Status: types.StatusOpen,
|
|
Priority: priority,
|
|
IssueType: types.IssueType(issueType),
|
|
Assignee: assignee,
|
|
ExternalRef: externalRefPtr,
|
|
Ephemeral: wisp,
|
|
CreatedBy: getActorWithGit(),
|
|
Owner: getOwner(),
|
|
MolType: molType,
|
|
RoleType: roleType,
|
|
Rig: agentRig,
|
|
DueAt: dueAt,
|
|
DeferUntil: deferUntil,
|
|
// Event fields
|
|
EventKind: eventCategory,
|
|
Actor: eventActor,
|
|
Target: eventTarget,
|
|
Payload: eventPayload,
|
|
}
|
|
if explicitID != "" {
|
|
previewIssue.ID = explicitID
|
|
}
|
|
|
|
if jsonOutput {
|
|
outputJSON(previewIssue)
|
|
} else {
|
|
idDisplay := previewIssue.ID
|
|
if idDisplay == "" {
|
|
idDisplay = "(will be generated)"
|
|
}
|
|
fmt.Printf("%s [DRY RUN] Would create issue:\n", ui.RenderWarn("⚠"))
|
|
fmt.Printf(" ID: %s\n", idDisplay)
|
|
fmt.Printf(" Title: %s\n", previewIssue.Title)
|
|
fmt.Printf(" Type: %s\n", previewIssue.IssueType)
|
|
fmt.Printf(" Priority: P%d\n", previewIssue.Priority)
|
|
fmt.Printf(" Status: %s\n", previewIssue.Status)
|
|
if previewIssue.Assignee != "" {
|
|
fmt.Printf(" Assignee: %s\n", previewIssue.Assignee)
|
|
}
|
|
if previewIssue.Description != "" {
|
|
fmt.Printf(" Description: %s\n", previewIssue.Description)
|
|
}
|
|
if len(labels) > 0 {
|
|
fmt.Printf(" Labels: %s\n", strings.Join(labels, ", "))
|
|
}
|
|
if len(deps) > 0 {
|
|
fmt.Printf(" Dependencies: %s\n", strings.Join(deps, ", "))
|
|
}
|
|
if rigOverride != "" || prefixOverride != "" {
|
|
rig := rigOverride
|
|
if rig == "" {
|
|
rig = prefixOverride
|
|
}
|
|
fmt.Printf(" Target rig: %s\n", rig)
|
|
}
|
|
if eventCategory != "" {
|
|
fmt.Printf(" Event category: %s\n", eventCategory)
|
|
}
|
|
}
|
|
return
|
|
}
|
|
|
|
// Handle --rig or --prefix flag: create issue in a different rig
|
|
// Both flags use the same forgiving lookup (accepts rig names or prefixes)
|
|
targetRig := rigOverride
|
|
if prefixOverride != "" {
|
|
if targetRig != "" {
|
|
FatalError("cannot specify both --rig and --prefix flags")
|
|
}
|
|
targetRig = prefixOverride
|
|
}
|
|
if targetRig != "" {
|
|
createInRig(cmd, targetRig, title, description, issueType, priority, design, acceptance, notes, assignee, labels, externalRef, wisp)
|
|
return
|
|
}
|
|
|
|
// Get estimate if provided
|
|
var estimatedMinutes *int
|
|
if cmd.Flags().Changed("estimate") {
|
|
est, _ := cmd.Flags().GetInt("estimate")
|
|
if est < 0 {
|
|
FatalError("estimate must be a non-negative number of minutes")
|
|
}
|
|
estimatedMinutes = &est
|
|
}
|
|
|
|
// Validate template based on --validate flag or config
|
|
validateTemplate, _ := cmd.Flags().GetBool("validate")
|
|
if validateTemplate {
|
|
// Explicit --validate flag: fail on error
|
|
if err := validation.ValidateTemplate(types.IssueType(issueType), description); err != nil {
|
|
FatalError("%v", err)
|
|
}
|
|
} else {
|
|
// Check validation.on-create config (bd-t7jq)
|
|
validationMode := config.GetString("validation.on-create")
|
|
if validationMode == "error" || validationMode == "warn" {
|
|
if err := validation.ValidateTemplate(types.IssueType(issueType), description); err != nil {
|
|
if validationMode == "error" {
|
|
FatalError("%v", err)
|
|
} else {
|
|
// warn mode: print warning but proceed
|
|
fmt.Fprintf(os.Stderr, "%s %v\n", ui.RenderWarn("⚠"), err)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Use global jsonOutput set by PersistentPreRun
|
|
|
|
// Determine target repository using routing logic
|
|
repoPath := "." // default to current directory
|
|
if cmd.Flags().Changed("repo") {
|
|
// Explicit --repo flag overrides auto-routing
|
|
repoPath = repoOverride
|
|
} else {
|
|
// Auto-routing based on user role
|
|
userRole, err := routing.DetectUserRole(".")
|
|
if err != nil {
|
|
debug.Logf("Warning: failed to detect user role: %v\n", err)
|
|
}
|
|
|
|
routingConfig := &routing.RoutingConfig{
|
|
Mode: config.GetString("routing.mode"),
|
|
DefaultRepo: config.GetString("routing.default"),
|
|
MaintainerRepo: config.GetString("routing.maintainer"),
|
|
ContributorRepo: config.GetString("routing.contributor"),
|
|
ExplicitOverride: repoOverride,
|
|
}
|
|
|
|
repoPath = routing.DetermineTargetRepo(routingConfig, userRole, ".")
|
|
}
|
|
|
|
// TODO(bd-6x6g): Switch to target repo for multi-repo support
|
|
// For now, we just log the target repo in debug mode
|
|
if repoPath != "." {
|
|
debug.Logf("DEBUG: Target repo: %s\n", repoPath)
|
|
}
|
|
|
|
// Check for conflicting flags
|
|
if explicitID != "" && parentID != "" {
|
|
FatalError("cannot specify both --id and --parent flags")
|
|
}
|
|
|
|
// If parent is specified, generate child ID
|
|
// In daemon mode, the parent will be sent to the RPC handler
|
|
// In direct mode, we generate the child ID here
|
|
if parentID != "" && daemonClient == nil {
|
|
ctx := rootCtx
|
|
// Validate parent exists before generating child ID
|
|
parentIssue, err := store.GetIssue(ctx, parentID)
|
|
if err != nil {
|
|
FatalError("failed to check parent issue: %v", err)
|
|
}
|
|
if parentIssue == nil {
|
|
FatalError("parent issue %s not found", parentID)
|
|
}
|
|
childID, err := store.GetNextChildID(ctx, parentID)
|
|
if err != nil {
|
|
FatalError("%v", err)
|
|
}
|
|
explicitID = childID // Set as explicit ID for the rest of the flow
|
|
}
|
|
|
|
// Validate explicit ID format if provided
|
|
if explicitID != "" {
|
|
requestedPrefix, err := validation.ValidateIDFormat(explicitID)
|
|
if err != nil {
|
|
FatalError("%v", err)
|
|
}
|
|
|
|
// Validate prefix matches database prefix
|
|
ctx := rootCtx
|
|
|
|
// Get database prefix and allowed prefixes from config
|
|
var dbPrefix, allowedPrefixes string
|
|
if daemonClient != nil {
|
|
// Daemon mode - use RPC to get config
|
|
configResp, err := daemonClient.GetConfig(&rpc.GetConfigArgs{Key: "issue_prefix"})
|
|
if err == nil {
|
|
dbPrefix = configResp.Value
|
|
}
|
|
// Also get allowed_prefixes for multi-prefix support (e.g., Gas Town)
|
|
allowedResp, err := daemonClient.GetConfig(&rpc.GetConfigArgs{Key: "allowed_prefixes"})
|
|
if err == nil {
|
|
allowedPrefixes = allowedResp.Value
|
|
}
|
|
// If error, continue without validation (non-fatal)
|
|
} else {
|
|
// Direct mode - check config
|
|
dbPrefix, _ = store.GetConfig(ctx, "issue_prefix")
|
|
allowedPrefixes, _ = store.GetConfig(ctx, "allowed_prefixes")
|
|
}
|
|
|
|
if err := validation.ValidatePrefixWithAllowed(requestedPrefix, dbPrefix, allowedPrefixes, forceCreate); err != nil {
|
|
FatalError("%v", err)
|
|
}
|
|
|
|
// Validate agent ID pattern if type is agent
|
|
if issueType == "agent" {
|
|
if err := validation.ValidateAgentID(explicitID); err != nil {
|
|
FatalError("invalid agent ID: %v", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
var externalRefPtr *string
|
|
if externalRef != "" {
|
|
externalRefPtr = &externalRef
|
|
}
|
|
|
|
// If daemon is running, use RPC
|
|
if daemonClient != nil {
|
|
createArgs := &rpc.CreateArgs{
|
|
ID: explicitID,
|
|
Parent: parentID,
|
|
Title: title,
|
|
Description: description,
|
|
IssueType: issueType,
|
|
Priority: priority,
|
|
Design: design,
|
|
AcceptanceCriteria: acceptance,
|
|
Notes: notes,
|
|
Assignee: assignee,
|
|
ExternalRef: externalRef,
|
|
EstimatedMinutes: estimatedMinutes,
|
|
Labels: labels,
|
|
Dependencies: deps,
|
|
WaitsFor: waitsFor,
|
|
WaitsForGate: waitsForGate,
|
|
Ephemeral: wisp,
|
|
CreatedBy: getActorWithGit(),
|
|
Owner: getOwner(),
|
|
MolType: string(molType),
|
|
RoleType: roleType,
|
|
Rig: agentRig,
|
|
EventCategory: eventCategory,
|
|
EventActor: eventActor,
|
|
EventTarget: eventTarget,
|
|
EventPayload: eventPayload,
|
|
DueAt: formatTimeForRPC(dueAt),
|
|
DeferUntil: formatTimeForRPC(deferUntil),
|
|
}
|
|
|
|
resp, err := daemonClient.Create(createArgs)
|
|
if err != nil {
|
|
FatalError("%v", err)
|
|
}
|
|
|
|
// Parse response to get issue for hook
|
|
var issue types.Issue
|
|
if err := json.Unmarshal(resp.Data, &issue); err != nil {
|
|
FatalError("parsing response: %v", err)
|
|
}
|
|
|
|
// Run create hook
|
|
if hookRunner != nil {
|
|
hookRunner.Run(hooks.EventCreate, &issue)
|
|
}
|
|
|
|
if jsonOutput {
|
|
fmt.Println(string(resp.Data))
|
|
} else if silent {
|
|
fmt.Println(issue.ID)
|
|
} else {
|
|
fmt.Printf("%s Created issue: %s\n", ui.RenderPass("✓"), issue.ID)
|
|
fmt.Printf(" Title: %s\n", issue.Title)
|
|
fmt.Printf(" Priority: P%d\n", issue.Priority)
|
|
fmt.Printf(" Status: %s\n", issue.Status)
|
|
}
|
|
|
|
// Track as last touched issue
|
|
SetLastTouchedID(issue.ID)
|
|
return
|
|
}
|
|
|
|
// Direct mode
|
|
issue := &types.Issue{
|
|
ID: explicitID, // Set explicit ID if provided (empty string if not)
|
|
Title: title,
|
|
Description: description,
|
|
Design: design,
|
|
AcceptanceCriteria: acceptance,
|
|
Notes: notes,
|
|
Status: types.StatusOpen,
|
|
Priority: priority,
|
|
IssueType: types.IssueType(issueType),
|
|
Assignee: assignee,
|
|
ExternalRef: externalRefPtr,
|
|
EstimatedMinutes: estimatedMinutes,
|
|
Ephemeral: wisp,
|
|
CreatedBy: getActorWithGit(),
|
|
Owner: getOwner(),
|
|
MolType: molType,
|
|
RoleType: roleType,
|
|
Rig: agentRig,
|
|
EventKind: eventCategory,
|
|
Actor: eventActor,
|
|
Target: eventTarget,
|
|
Payload: eventPayload,
|
|
DueAt: dueAt,
|
|
DeferUntil: deferUntil,
|
|
}
|
|
|
|
ctx := rootCtx
|
|
|
|
// Check if any dependencies are discovered-from type
|
|
// If so, inherit source_repo from the parent issue
|
|
var discoveredFromParentID string
|
|
for _, depSpec := range deps {
|
|
depSpec = strings.TrimSpace(depSpec)
|
|
if depSpec == "" {
|
|
continue
|
|
}
|
|
|
|
var depType types.DependencyType
|
|
var dependsOnID string
|
|
|
|
if strings.Contains(depSpec, ":") {
|
|
parts := strings.SplitN(depSpec, ":", 2)
|
|
if len(parts) == 2 {
|
|
depType = types.DependencyType(strings.TrimSpace(parts[0]))
|
|
dependsOnID = strings.TrimSpace(parts[1])
|
|
|
|
if depType == types.DepDiscoveredFrom && dependsOnID != "" {
|
|
discoveredFromParentID = dependsOnID
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// If we found a discovered-from dependency, inherit source_repo from parent
|
|
if discoveredFromParentID != "" {
|
|
parentIssue, err := store.GetIssue(ctx, discoveredFromParentID)
|
|
if err == nil && parentIssue.SourceRepo != "" {
|
|
issue.SourceRepo = parentIssue.SourceRepo
|
|
}
|
|
// If error getting parent or parent has no source_repo, continue with default
|
|
}
|
|
|
|
if err := store.CreateIssue(ctx, issue, actor); err != nil {
|
|
FatalError("%v", err)
|
|
}
|
|
|
|
// If parent was specified, add parent-child dependency
|
|
if parentID != "" {
|
|
dep := &types.Dependency{
|
|
IssueID: issue.ID,
|
|
DependsOnID: parentID,
|
|
Type: types.DepParentChild,
|
|
}
|
|
if err := store.AddDependency(ctx, dep, actor); err != nil {
|
|
WarnError("failed to add parent-child dependency %s -> %s: %v", issue.ID, parentID, err)
|
|
}
|
|
}
|
|
|
|
// Add labels if specified
|
|
for _, label := range labels {
|
|
if err := store.AddLabel(ctx, issue.ID, label, actor); err != nil {
|
|
WarnError("failed to add label %s: %v", label, err)
|
|
}
|
|
}
|
|
|
|
// Auto-add role_type/rig labels for agent beads (enables filtering queries)
|
|
// Check for gt:agent label to identify agent beads (Gas Town separation)
|
|
hasAgentLabel := false
|
|
for _, l := range labels {
|
|
if l == "gt:agent" {
|
|
hasAgentLabel = true
|
|
break
|
|
}
|
|
}
|
|
if hasAgentLabel {
|
|
if issue.RoleType != "" {
|
|
agentLabel := "role_type:" + issue.RoleType
|
|
if err := store.AddLabel(ctx, issue.ID, agentLabel, actor); err != nil {
|
|
WarnError("failed to add role_type label: %v", err)
|
|
}
|
|
}
|
|
if issue.Rig != "" {
|
|
rigLabel := "rig:" + issue.Rig
|
|
if err := store.AddLabel(ctx, issue.ID, rigLabel, actor); err != nil {
|
|
WarnError("failed to add rig label: %v", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Add dependencies if specified (format: type:id or just id for default "blocks" type)
|
|
for _, depSpec := range deps {
|
|
// Skip empty specs (e.g., from trailing commas)
|
|
depSpec = strings.TrimSpace(depSpec)
|
|
if depSpec == "" {
|
|
continue
|
|
}
|
|
|
|
var depType types.DependencyType
|
|
var dependsOnID string
|
|
|
|
// Parse format: "type:id" or just "id" (defaults to "blocks")
|
|
if strings.Contains(depSpec, ":") {
|
|
parts := strings.SplitN(depSpec, ":", 2)
|
|
if len(parts) != 2 {
|
|
WarnError("invalid dependency format '%s', expected 'type:id' or 'id'", depSpec)
|
|
continue
|
|
}
|
|
depType = types.DependencyType(strings.TrimSpace(parts[0]))
|
|
dependsOnID = strings.TrimSpace(parts[1])
|
|
} else {
|
|
// Default to "blocks" if no type specified
|
|
depType = types.DepBlocks
|
|
dependsOnID = depSpec
|
|
}
|
|
|
|
// Validate dependency type
|
|
if !depType.IsValid() {
|
|
WarnError("invalid dependency type '%s' (valid: blocks, related, parent-child, discovered-from)", depType)
|
|
continue
|
|
}
|
|
|
|
// Add the dependency
|
|
dep := &types.Dependency{
|
|
IssueID: issue.ID,
|
|
DependsOnID: dependsOnID,
|
|
Type: depType,
|
|
}
|
|
if err := store.AddDependency(ctx, dep, actor); err != nil {
|
|
WarnError("failed to add dependency %s -> %s: %v", issue.ID, dependsOnID, err)
|
|
}
|
|
}
|
|
|
|
// Add waits-for dependency if specified
|
|
if waitsFor != "" {
|
|
// Validate gate type
|
|
gate := waitsForGate
|
|
if gate == "" {
|
|
gate = types.WaitsForAllChildren
|
|
}
|
|
if gate != types.WaitsForAllChildren && gate != types.WaitsForAnyChildren {
|
|
FatalError("invalid --waits-for-gate value '%s' (valid: all-children, any-children)", gate)
|
|
}
|
|
|
|
// Create metadata JSON
|
|
meta := types.WaitsForMeta{
|
|
Gate: gate,
|
|
}
|
|
metaJSON, err := json.Marshal(meta)
|
|
if err != nil {
|
|
FatalError("failed to serialize waits-for metadata: %v", err)
|
|
}
|
|
|
|
dep := &types.Dependency{
|
|
IssueID: issue.ID,
|
|
DependsOnID: waitsFor,
|
|
Type: types.DepWaitsFor,
|
|
Metadata: string(metaJSON),
|
|
}
|
|
if err := store.AddDependency(ctx, dep, actor); err != nil {
|
|
WarnError("failed to add waits-for dependency %s -> %s: %v", issue.ID, waitsFor, err)
|
|
}
|
|
}
|
|
|
|
// Schedule auto-flush
|
|
markDirtyAndScheduleFlush()
|
|
|
|
// Run create hook
|
|
if hookRunner != nil {
|
|
hookRunner.Run(hooks.EventCreate, issue)
|
|
}
|
|
|
|
if jsonOutput {
|
|
outputJSON(issue)
|
|
} else if silent {
|
|
fmt.Println(issue.ID)
|
|
} else {
|
|
fmt.Printf("%s Created issue: %s\n", ui.RenderPass("✓"), issue.ID)
|
|
fmt.Printf(" Title: %s\n", issue.Title)
|
|
fmt.Printf(" Priority: P%d\n", issue.Priority)
|
|
fmt.Printf(" Status: %s\n", issue.Status)
|
|
|
|
// Show tip after successful create (direct mode only)
|
|
maybeShowTip(store)
|
|
}
|
|
|
|
// Track as last touched issue
|
|
SetLastTouchedID(issue.ID)
|
|
},
|
|
}
|
|
|
|
func init() {
|
|
createCmd.Flags().StringP("file", "f", "", "Create multiple issues from markdown file")
|
|
createCmd.Flags().String("title", "", "Issue title (alternative to positional argument)")
|
|
createCmd.Flags().Bool("silent", false, "Output only the issue ID (for scripting)")
|
|
createCmd.Flags().Bool("dry-run", false, "Preview what would be created without actually creating")
|
|
registerPriorityFlag(createCmd, "2")
|
|
createCmd.Flags().StringP("type", "t", "task", "Issue type (bug|feature|task|epic|chore|merge-request|molecule|gate|agent|role|rig|convoy|event)")
|
|
registerCommonIssueFlags(createCmd)
|
|
createCmd.Flags().StringSliceP("labels", "l", []string{}, "Labels (comma-separated)")
|
|
createCmd.Flags().StringSlice("label", []string{}, "Alias for --labels")
|
|
_ = createCmd.Flags().MarkHidden("label") // Only fails if flag missing (caught in tests)
|
|
createCmd.Flags().String("id", "", "Explicit issue ID (e.g., 'bd-42' for partitioning)")
|
|
createCmd.Flags().String("parent", "", "Parent issue ID for hierarchical child (e.g., 'bd-a3f8e9')")
|
|
createCmd.Flags().StringSlice("deps", []string{}, "Dependencies in format 'type:id' or 'id' (e.g., 'discovered-from:bd-20,blocks:bd-15' or 'bd-20')")
|
|
createCmd.Flags().String("waits-for", "", "Spawner issue ID to wait for (creates waits-for dependency for fanout gate)")
|
|
createCmd.Flags().String("waits-for-gate", "all-children", "Gate type: all-children (wait for all) or any-children (wait for first)")
|
|
createCmd.Flags().Bool("force", false, "Force creation even if prefix doesn't match database prefix")
|
|
createCmd.Flags().String("repo", "", "Target repository for issue (overrides auto-routing)")
|
|
createCmd.Flags().String("rig", "", "Create issue in a different rig (e.g., --rig beads)")
|
|
createCmd.Flags().String("prefix", "", "Create issue in rig by prefix (e.g., --prefix bd- or --prefix bd or --prefix beads)")
|
|
createCmd.Flags().IntP("estimate", "e", 0, "Time estimate in minutes (e.g., 60 for 1 hour)")
|
|
createCmd.Flags().Bool("ephemeral", false, "Create as ephemeral (ephemeral, not exported to JSONL)")
|
|
createCmd.Flags().String("mol-type", "", "Molecule type: swarm (multi-polecat), patrol (recurring ops), work (default)")
|
|
createCmd.Flags().Bool("validate", false, "Validate description contains required sections for issue type")
|
|
// Agent-specific flags (only valid when --type=agent)
|
|
createCmd.Flags().String("role-type", "", "Agent role type: polecat|crew|witness|refinery|mayor|deacon (requires --type=agent)")
|
|
createCmd.Flags().String("agent-rig", "", "Agent's rig name (requires --type=agent)")
|
|
// Event-specific flags (only valid when --type=event)
|
|
createCmd.Flags().String("event-category", "", "Event category (e.g., patrol.muted, agent.started) (requires --type=event)")
|
|
createCmd.Flags().String("event-actor", "", "Entity URI who caused this event (requires --type=event)")
|
|
createCmd.Flags().String("event-target", "", "Entity URI or bead ID affected (requires --type=event)")
|
|
createCmd.Flags().String("event-payload", "", "Event-specific JSON data (requires --type=event)")
|
|
// Time-based scheduling flags (GH#820)
|
|
// Examples:
|
|
// --due=+6h Due in 6 hours
|
|
// --due=tomorrow Due tomorrow
|
|
// --due="next monday" Due next Monday
|
|
// --due=2025-01-15 Due on specific date
|
|
// --defer=+1h Hidden from bd ready for 1 hour
|
|
// --defer=tomorrow Hidden until tomorrow
|
|
createCmd.Flags().String("due", "", "Due date/time. Formats: +6h, +1d, +2w, tomorrow, next monday, 2025-01-15")
|
|
createCmd.Flags().String("defer", "", "Defer until date (issue hidden from bd ready until then). Same formats as --due")
|
|
// Note: --json flag is defined as a persistent flag in main.go, not here
|
|
rootCmd.AddCommand(createCmd)
|
|
}
|
|
|
|
// createInRig creates an issue in a different rig using --rig flag.
|
|
// This bypasses the normal daemon/direct flow and directly creates in the target rig.
|
|
func createInRig(cmd *cobra.Command, rigName, title, description, issueType string, priority int, design, acceptance, notes, assignee string, labels []string, externalRef string, wisp bool) {
|
|
ctx := rootCtx
|
|
|
|
// Find the town-level beads directory (where routes.jsonl lives)
|
|
townBeadsDir, err := findTownBeadsDir()
|
|
if err != nil {
|
|
FatalError("cannot use --rig: %v", err)
|
|
}
|
|
|
|
// Resolve the target rig's beads directory and prefix
|
|
targetBeadsDir, targetPrefix, err := routing.ResolveBeadsDirForRig(rigName, townBeadsDir)
|
|
if err != nil {
|
|
FatalError("%v", err)
|
|
}
|
|
|
|
// Open storage for the target rig
|
|
targetDBPath := filepath.Join(targetBeadsDir, "beads.db")
|
|
targetStore, err := sqlite.New(ctx, targetDBPath)
|
|
if err != nil {
|
|
FatalError("failed to open rig %q database: %v", rigName, err)
|
|
}
|
|
defer func() {
|
|
if err := targetStore.Close(); err != nil {
|
|
fmt.Fprintf(os.Stderr, "warning: failed to close rig database: %v\n", err)
|
|
}
|
|
}()
|
|
|
|
// Prepare prefix override from routes.jsonl for cross-rig creation
|
|
// Strip trailing hyphen - database stores prefix without it (e.g., "aops" not "aops-")
|
|
var prefixOverride string
|
|
if targetPrefix != "" {
|
|
prefixOverride = strings.TrimSuffix(targetPrefix, "-")
|
|
}
|
|
|
|
var externalRefPtr *string
|
|
if externalRef != "" {
|
|
externalRefPtr = &externalRef
|
|
}
|
|
|
|
// Extract event-specific flags (bd-xwvo fix)
|
|
eventCategory, _ := cmd.Flags().GetString("event-category")
|
|
eventActor, _ := cmd.Flags().GetString("event-actor")
|
|
eventTarget, _ := cmd.Flags().GetString("event-target")
|
|
eventPayload, _ := cmd.Flags().GetString("event-payload")
|
|
|
|
// Extract molecule/agent flags (bd-xwvo fix)
|
|
molTypeStr, _ := cmd.Flags().GetString("mol-type")
|
|
var molType types.MolType
|
|
if molTypeStr != "" {
|
|
molType = types.MolType(molTypeStr)
|
|
}
|
|
roleType, _ := cmd.Flags().GetString("role-type")
|
|
agentRig, _ := cmd.Flags().GetString("agent-rig")
|
|
|
|
// Extract time-based scheduling flags (bd-xwvo fix)
|
|
var dueAt *time.Time
|
|
dueStr, _ := cmd.Flags().GetString("due")
|
|
if dueStr != "" {
|
|
t, err := timeparsing.ParseRelativeTime(dueStr, time.Now())
|
|
if err != nil {
|
|
FatalError("invalid --due format %q", dueStr)
|
|
}
|
|
dueAt = &t
|
|
}
|
|
|
|
var deferUntil *time.Time
|
|
deferStr, _ := cmd.Flags().GetString("defer")
|
|
if deferStr != "" {
|
|
t, err := timeparsing.ParseRelativeTime(deferStr, time.Now())
|
|
if err != nil {
|
|
FatalError("invalid --defer format %q", deferStr)
|
|
}
|
|
deferUntil = &t
|
|
}
|
|
|
|
// Create issue without ID - CreateIssue will generate one with the correct prefix
|
|
issue := &types.Issue{
|
|
Title: title,
|
|
Description: description,
|
|
Design: design,
|
|
AcceptanceCriteria: acceptance,
|
|
Notes: notes,
|
|
Status: types.StatusOpen,
|
|
Priority: priority,
|
|
IssueType: types.IssueType(issueType),
|
|
Assignee: assignee,
|
|
ExternalRef: externalRefPtr,
|
|
Ephemeral: wisp,
|
|
CreatedBy: getActorWithGit(),
|
|
Owner: getOwner(),
|
|
// Event fields (bd-xwvo fix)
|
|
EventKind: eventCategory,
|
|
Actor: eventActor,
|
|
Target: eventTarget,
|
|
Payload: eventPayload,
|
|
// Molecule/agent fields (bd-xwvo fix)
|
|
MolType: molType,
|
|
RoleType: roleType,
|
|
Rig: agentRig,
|
|
// Time scheduling fields (bd-xwvo fix)
|
|
DueAt: dueAt,
|
|
DeferUntil: deferUntil,
|
|
// Cross-rig routing: use route prefix instead of database config
|
|
PrefixOverride: prefixOverride,
|
|
}
|
|
|
|
if err := targetStore.CreateIssue(ctx, issue, actor); err != nil {
|
|
FatalError("failed to create issue in rig %q: %v", rigName, err)
|
|
}
|
|
|
|
// Add labels if specified
|
|
for _, label := range labels {
|
|
if err := targetStore.AddLabel(ctx, issue.ID, label, actor); err != nil {
|
|
WarnError("failed to add label %s: %v", label, err)
|
|
}
|
|
}
|
|
|
|
// Get silent flag
|
|
silent, _ := cmd.Flags().GetBool("silent")
|
|
|
|
if jsonOutput {
|
|
outputJSON(issue)
|
|
} else if silent {
|
|
fmt.Println(issue.ID)
|
|
} else {
|
|
fmt.Printf("%s Created issue in rig %q: %s\n", ui.RenderPass("✓"), rigName, issue.ID)
|
|
fmt.Printf(" Title: %s\n", issue.Title)
|
|
fmt.Printf(" Priority: P%d\n", issue.Priority)
|
|
fmt.Printf(" Status: %s\n", issue.Status)
|
|
}
|
|
}
|
|
|
|
// findTownBeadsDir finds the town-level .beads directory (where routes.jsonl lives).
|
|
// It walks up from the current directory looking for a .beads directory with routes.jsonl.
|
|
func findTownBeadsDir() (string, error) {
|
|
// Start from current directory and walk up
|
|
dir, err := os.Getwd()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
for {
|
|
beadsDir := filepath.Join(dir, ".beads")
|
|
routesFile := filepath.Join(beadsDir, routing.RoutesFileName)
|
|
|
|
// Check if this .beads directory has routes.jsonl
|
|
if _, err := os.Stat(routesFile); err == nil {
|
|
return beadsDir, nil
|
|
}
|
|
|
|
// Move up one directory
|
|
parent := filepath.Dir(dir)
|
|
if parent == dir {
|
|
// Reached filesystem root
|
|
break
|
|
}
|
|
dir = parent
|
|
}
|
|
|
|
return "", fmt.Errorf("no routes.jsonl found in any parent .beads directory")
|
|
}
|
|
|
|
// formatTimeForRPC converts a *time.Time to RFC3339 string for daemon RPC calls.
|
|
// Returns empty string if t is nil, allowing the daemon to distinguish "not set" from "set to zero".
|
|
func formatTimeForRPC(t *time.Time) string {
|
|
if t == nil {
|
|
return ""
|
|
}
|
|
return t.Format(time.RFC3339)
|
|
}
|