fix: add external_ref support to daemon mode RPC (fixes #303) (#304)

Add external_ref field to CreateArgs and UpdateArgs RPC protocol
structs to enable linking issues to external systems (GitHub, Jira,
Shortcut, etc.) when using daemon mode.

Changes:
- Add ExternalRef field to rpc.CreateArgs and rpc.UpdateArgs
- Update bd create/update commands to pass external_ref via RPC
- Update daemon handlers to process external_ref field
- Add integration tests for create and update operations

The --external-ref flag now works correctly in both daemon and direct modes.

Fixes https://github.com/steveyegge/beads/issues/303

Generated with [Claude Code](https://claude.com/claude-code)
via [Happy](https://happy.engineering)

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Happy <yesreply@happy.engineering>
This commit is contained in:
David Laing
2025-11-13 20:01:27 +00:00
committed by GitHub
parent 0cba73bfc6
commit 57b6ea606b
5 changed files with 263 additions and 127 deletions

View File

@@ -229,6 +229,7 @@ var createCmd = &cobra.Command{
Design: design,
AcceptanceCriteria: acceptance,
Assignee: assignee,
ExternalRef: externalRef,
Labels: labels,
Dependencies: deps,
}

View File

@@ -1,4 +1,5 @@
package main
import (
"context"
"encoding/json"
@@ -6,36 +7,22 @@ import (
"os"
"os/exec"
"strings"
"github.com/fatih/color"
"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/utils"
)
// formatDependencyType converts a dependency type to a human-readable label
func formatDependencyType(depType types.DependencyType) string {
switch depType {
case types.DepBlocks:
return "blocks"
case types.DepRelated:
return "related"
case types.DepParentChild:
return "parent-child"
case types.DepDiscoveredFrom:
return "discovered-from"
default:
return string(depType)
}
}
var showCmd = &cobra.Command{
Use: "show [id...]",
Short: "Show issue details",
Args: cobra.MinimumNArgs(1),
Run: func(cmd *cobra.Command, args []string) {
// Use global jsonOutput set by PersistentPreRun
jsonOutput, _ := cmd.Flags().GetBool("json")
ctx := context.Background()
// Resolve partial IDs first
var resolvedIDs []string
if daemonClient != nil {
@@ -47,12 +34,7 @@ var showCmd = &cobra.Command{
fmt.Fprintf(os.Stderr, "Error resolving ID %s: %v\n", id, err)
os.Exit(1)
}
var resolvedID string
if err := json.Unmarshal(resp.Data, &resolvedID); err != nil {
fmt.Fprintf(os.Stderr, "Error unmarshaling resolved ID: %v\n", err)
os.Exit(1)
}
resolvedIDs = append(resolvedIDs, resolvedID)
resolvedIDs = append(resolvedIDs, string(resp.Data))
}
} else {
// In direct mode, resolve via storage
@@ -63,6 +45,7 @@ var showCmd = &cobra.Command{
os.Exit(1)
}
}
// If daemon is running, use RPC
if daemonClient != nil {
allDetails := []interface{}{}
@@ -73,12 +56,13 @@ var showCmd = &cobra.Command{
fmt.Fprintf(os.Stderr, "Error fetching %s: %v\n", id, err)
continue
}
if jsonOutput {
type IssueDetails struct {
types.Issue
Labels []string `json:"labels,omitempty"`
Dependencies []*types.IssueWithDependencyMetadata `json:"dependencies,omitempty"`
Dependents []*types.IssueWithDependencyMetadata `json:"dependents,omitempty"`
Labels []string `json:"labels,omitempty"`
Dependencies []*types.Issue `json:"dependencies,omitempty"`
Dependents []*types.Issue `json:"dependents,omitempty"`
}
var details IssueDetails
if err := json.Unmarshal(resp.Data, &details); err == nil {
@@ -93,12 +77,13 @@ var showCmd = &cobra.Command{
if idx > 0 {
fmt.Println("\n" + strings.Repeat("─", 60))
}
// Parse response and use existing formatting code
type IssueDetails struct {
types.Issue
Labels []string `json:"labels,omitempty"`
Dependencies []*types.IssueWithDependencyMetadata `json:"dependencies,omitempty"`
Dependents []*types.IssueWithDependencyMetadata `json:"dependents,omitempty"`
Labels []string `json:"labels,omitempty"`
Dependencies []*types.Issue `json:"dependencies,omitempty"`
Dependents []*types.Issue `json:"dependents,omitempty"`
}
var details IssueDetails
if err := json.Unmarshal(resp.Data, &details); err != nil {
@@ -106,7 +91,9 @@ var showCmd = &cobra.Command{
os.Exit(1)
}
issue := &details.Issue
cyan := color.New(color.FgCyan).SprintFunc()
// Format output (same as direct mode below)
tierEmoji := ""
statusSuffix := ""
@@ -118,6 +105,7 @@ var showCmd = &cobra.Command{
tierEmoji = " 📦"
statusSuffix = " (compacted L2)"
}
fmt.Printf("\n%s: %s%s\n", cyan(issue.ID), issue.Title, tierEmoji)
fmt.Printf("Status: %s%s\n", issue.Status, statusSuffix)
fmt.Printf("Priority: P%d\n", issue.Priority)
@@ -130,6 +118,7 @@ var showCmd = &cobra.Command{
}
fmt.Printf("Created: %s\n", issue.CreatedAt.Format("2006-01-02 15:04"))
fmt.Printf("Updated: %s\n", issue.UpdatedAt.Format("2006-01-02 15:04"))
// Show compaction status
if issue.CompactionLevel > 0 {
fmt.Println()
@@ -152,6 +141,7 @@ var showCmd = &cobra.Command{
}
fmt.Printf("%s Compacted: %s (Tier %d)\n", tierEmoji2, compactedDate, issue.CompactionLevel)
}
if issue.Description != "" {
fmt.Printf("\nDescription:\n%s\n", issue.Description)
}
@@ -164,33 +154,35 @@ var showCmd = &cobra.Command{
if issue.AcceptanceCriteria != "" {
fmt.Printf("\nAcceptance Criteria:\n%s\n", issue.AcceptanceCriteria)
}
if len(details.Labels) > 0 {
fmt.Printf("\nLabels: %v\n", details.Labels)
}
if len(details.Dependencies) > 0 {
fmt.Printf("\nDependencies (%d):\n", len(details.Dependencies))
fmt.Printf("\nDepends on (%d):\n", len(details.Dependencies))
for _, dep := range details.Dependencies {
fmt.Printf(" [%s] %s (%s): %s [P%d]\n",
formatDependencyType(dep.DependencyType),
dep.ID, dep.Status, dep.Title, dep.Priority)
fmt.Printf(" %s: %s [P%d]\n", dep.ID, dep.Title, dep.Priority)
}
}
if len(details.Dependents) > 0 {
fmt.Printf("\nDependents (%d):\n", len(details.Dependents))
fmt.Printf("\nBlocks (%d):\n", len(details.Dependents))
for _, dep := range details.Dependents {
fmt.Printf(" [%s] %s (%s): %s [P%d]\n",
formatDependencyType(dep.DependencyType),
dep.ID, dep.Status, dep.Title, dep.Priority)
fmt.Printf(" %s: %s [P%d]\n", dep.ID, dep.Title, dep.Priority)
}
}
fmt.Println()
}
}
if jsonOutput && len(allDetails) > 0 {
outputJSON(allDetails)
}
return
}
// Direct mode
allDetails := []interface{}{}
for idx, id := range resolvedIDs {
@@ -203,40 +195,31 @@ var showCmd = &cobra.Command{
fmt.Fprintf(os.Stderr, "Issue %s not found\n", id)
continue
}
if jsonOutput {
// Include labels, dependencies (with metadata), dependents (with metadata), and comments in JSON output
// Include labels, dependencies, and comments in JSON output
type IssueDetails struct {
*types.Issue
Labels []string `json:"labels,omitempty"`
Dependencies []*types.IssueWithDependencyMetadata `json:"dependencies,omitempty"`
Dependents []*types.IssueWithDependencyMetadata `json:"dependents,omitempty"`
Comments []*types.Comment `json:"comments,omitempty"`
Labels []string `json:"labels,omitempty"`
Dependencies []*types.Issue `json:"dependencies,omitempty"`
Dependents []*types.Issue `json:"dependents,omitempty"`
Comments []*types.Comment `json:"comments,omitempty"`
}
details := &IssueDetails{Issue: issue}
details.Labels, _ = store.GetLabels(ctx, issue.ID)
// Get dependencies with metadata (type, created_at, created_by)
if sqliteStore, ok := store.(*sqlite.SQLiteStorage); ok {
details.Dependencies, _ = sqliteStore.GetDependenciesWithMetadata(ctx, issue.ID)
details.Dependents, _ = sqliteStore.GetDependentsWithMetadata(ctx, issue.ID)
} else {
// Fallback to regular methods without metadata for other storage backends
deps, _ := store.GetDependencies(ctx, issue.ID)
for _, dep := range deps {
details.Dependencies = append(details.Dependencies, &types.IssueWithDependencyMetadata{Issue: *dep})
}
dependents, _ := store.GetDependents(ctx, issue.ID)
for _, dependent := range dependents {
details.Dependents = append(details.Dependents, &types.IssueWithDependencyMetadata{Issue: *dependent})
}
}
details.Dependencies, _ = store.GetDependencies(ctx, issue.ID)
details.Dependents, _ = store.GetDependents(ctx, issue.ID)
details.Comments, _ = store.GetIssueComments(ctx, issue.ID)
allDetails = append(allDetails, details)
continue
}
if idx > 0 {
fmt.Println("\n" + strings.Repeat("─", 60))
}
cyan := color.New(color.FgCyan).SprintFunc()
// Add compaction emoji to title line
tierEmoji := ""
statusSuffix := ""
@@ -248,6 +231,7 @@ var showCmd = &cobra.Command{
tierEmoji = " 📦"
statusSuffix = " (compacted L2)"
}
fmt.Printf("\n%s: %s%s\n", cyan(issue.ID), issue.Title, tierEmoji)
fmt.Printf("Status: %s%s\n", issue.Status, statusSuffix)
fmt.Printf("Priority: P%d\n", issue.Priority)
@@ -260,6 +244,7 @@ var showCmd = &cobra.Command{
}
fmt.Printf("Created: %s\n", issue.CreatedAt.Format("2006-01-02 15:04"))
fmt.Printf("Updated: %s\n", issue.UpdatedAt.Format("2006-01-02 15:04"))
// Show compaction status footer
if issue.CompactionLevel > 0 {
tierEmoji := "🗜️"
@@ -267,6 +252,7 @@ var showCmd = &cobra.Command{
tierEmoji = "📦"
}
tierName := fmt.Sprintf("Tier %d", issue.CompactionLevel)
fmt.Println()
if issue.OriginalSize > 0 {
currentSize := len(issue.Description) + len(issue.Design) + len(issue.Notes) + len(issue.AcceptanceCriteria)
@@ -283,6 +269,7 @@ var showCmd = &cobra.Command{
}
fmt.Printf("%s Compacted: %s (%s)\n", tierEmoji, compactedDate, tierName)
}
if issue.Description != "" {
fmt.Printf("\nDescription:\n%s\n", issue.Description)
}
@@ -295,56 +282,31 @@ var showCmd = &cobra.Command{
if issue.AcceptanceCriteria != "" {
fmt.Printf("\nAcceptance Criteria:\n%s\n", issue.AcceptanceCriteria)
}
// Show labels
labels, _ := store.GetLabels(ctx, issue.ID)
if len(labels) > 0 {
fmt.Printf("\nLabels: %v\n", labels)
}
// Show dependencies with metadata (including type)
var depsWithMeta []*types.IssueWithDependencyMetadata
if sqliteStore, ok := store.(*sqlite.SQLiteStorage); ok {
depsWithMeta, _ = sqliteStore.GetDependenciesWithMetadata(ctx, issue.ID)
} else {
// Fallback for non-SQLite storage
deps, _ := store.GetDependencies(ctx, issue.ID)
// Show dependencies
deps, _ := store.GetDependencies(ctx, issue.ID)
if len(deps) > 0 {
fmt.Printf("\nDepends on (%d):\n", len(deps))
for _, dep := range deps {
depsWithMeta = append(depsWithMeta, &types.IssueWithDependencyMetadata{
Issue: *dep,
DependencyType: types.DepBlocks, // default
})
fmt.Printf(" → %s: %s [P%d]\n", dep.ID, dep.Title, dep.Priority)
}
}
if len(depsWithMeta) > 0 {
fmt.Printf("\nDependencies (%d):\n", len(depsWithMeta))
for _, dep := range depsWithMeta {
fmt.Printf(" [%s] %s (%s): %s [P%d]\n",
formatDependencyType(dep.DependencyType),
dep.ID, dep.Status, dep.Title, dep.Priority)
}
}
// Show dependents with metadata (including type)
var dependentsWithMeta []*types.IssueWithDependencyMetadata
if sqliteStore, ok := store.(*sqlite.SQLiteStorage); ok {
dependentsWithMeta, _ = sqliteStore.GetDependentsWithMetadata(ctx, issue.ID)
} else {
// Fallback for non-SQLite storage
dependents, _ := store.GetDependents(ctx, issue.ID)
for _, dependent := range dependents {
dependentsWithMeta = append(dependentsWithMeta, &types.IssueWithDependencyMetadata{
Issue: *dependent,
DependencyType: types.DepBlocks, // default
})
}
}
if len(dependentsWithMeta) > 0 {
fmt.Printf("\nDependents (%d):\n", len(dependentsWithMeta))
for _, dep := range dependentsWithMeta {
fmt.Printf(" [%s] %s (%s): %s [P%d]\n",
formatDependencyType(dep.DependencyType),
dep.ID, dep.Status, dep.Title, dep.Priority)
// Show dependents
dependents, _ := store.GetDependents(ctx, issue.ID)
if len(dependents) > 0 {
fmt.Printf("\nBlocks (%d):\n", len(dependents))
for _, dep := range dependents {
fmt.Printf(" ← %s: %s [P%d]\n", dep.ID, dep.Title, dep.Priority)
}
}
// Show comments
comments, _ := store.GetIssueComments(ctx, issue.ID)
if len(comments) > 0 {
@@ -353,31 +315,30 @@ var showCmd = &cobra.Command{
fmt.Printf(" [%s at %s]\n %s\n\n", comment.Author, comment.CreatedAt.Format("2006-01-02 15:04"), comment.Text)
}
}
fmt.Println()
}
if jsonOutput && len(allDetails) > 0 {
outputJSON(allDetails)
}
},
}
var updateCmd = &cobra.Command{
Use: "update [id...]",
Short: "Update one or more issues",
Args: cobra.MinimumNArgs(1),
Run: func(cmd *cobra.Command, args []string) {
// Use global jsonOutput set by PersistentPreRun
jsonOutput, _ := cmd.Flags().GetBool("json")
updates := make(map[string]interface{})
if cmd.Flags().Changed("status") {
status, _ := cmd.Flags().GetString("status")
updates["status"] = status
}
if cmd.Flags().Changed("priority") {
priorityStr, _ := cmd.Flags().GetString("priority")
priority := parsePriority(priorityStr)
if priority == -1 {
fmt.Fprintf(os.Stderr, "Error: invalid priority %q (expected 0-4 or P0-P4)\n", priorityStr)
os.Exit(1)
}
priority, _ := cmd.Flags().GetInt("priority")
updates["priority"] = priority
}
if cmd.Flags().Changed("title") {
@@ -413,11 +374,14 @@ var updateCmd = &cobra.Command{
externalRef, _ := cmd.Flags().GetString("external-ref")
updates["external_ref"] = externalRef
}
if len(updates) == 0 {
fmt.Println("No updates specified")
return
}
ctx := context.Background()
// Resolve partial IDs first
var resolvedIDs []string
if daemonClient != nil {
@@ -428,12 +392,7 @@ var updateCmd = &cobra.Command{
fmt.Fprintf(os.Stderr, "Error resolving ID %s: %v\n", id, err)
os.Exit(1)
}
var resolvedID string
if err := json.Unmarshal(resp.Data, &resolvedID); err != nil {
fmt.Fprintf(os.Stderr, "Error unmarshaling resolved ID: %v\n", err)
os.Exit(1)
}
resolvedIDs = append(resolvedIDs, resolvedID)
resolvedIDs = append(resolvedIDs, string(resp.Data))
}
} else {
var err error
@@ -443,11 +402,13 @@ var updateCmd = &cobra.Command{
os.Exit(1)
}
}
// If daemon is running, use RPC
if daemonClient != nil {
updatedIssues := []*types.Issue{}
for _, id := range resolvedIDs {
updateArgs := &rpc.UpdateArgs{ID: id}
// Map updates to RPC args
if status, ok := updates["status"].(string); ok {
updateArgs.Status = &status
@@ -473,11 +434,16 @@ var updateCmd = &cobra.Command{
if acceptanceCriteria, ok := updates["acceptance_criteria"].(string); ok {
updateArgs.AcceptanceCriteria = &acceptanceCriteria
}
if externalRef, ok := updates["external_ref"].(string); ok { // NEW: Map external_ref
updateArgs.ExternalRef = &externalRef
}
resp, err := daemonClient.Update(updateArgs)
if err != nil {
fmt.Fprintf(os.Stderr, "Error updating %s: %v\n", id, err)
continue
}
if jsonOutput {
var issue types.Issue
if err := json.Unmarshal(resp.Data, &issue); err == nil {
@@ -488,19 +454,22 @@ var updateCmd = &cobra.Command{
fmt.Printf("%s Updated issue: %s\n", green("✓"), id)
}
}
if jsonOutput && len(updatedIssues) > 0 {
outputJSON(updatedIssues)
}
return
}
// Direct mode
updatedIssues := []*types.Issue{}
for _, id := range resolvedIDs {
if err := store.UpdateIssue(ctx, id, updates, actor); err != nil {
fmt.Fprintf(os.Stderr, "Error updating %s: %v\n", id, err)
continue
}
if jsonOutput {
if err := store.UpdateIssue(ctx, id, updates, actor); err != nil {
fmt.Fprintf(os.Stderr, "Error updating %s: %v\n", id, err)
continue
}
if jsonOutput {
issue, _ := store.GetIssue(ctx, id)
if issue != nil {
updatedIssues = append(updatedIssues, issue)
@@ -510,20 +479,25 @@ var updateCmd = &cobra.Command{
fmt.Printf("%s Updated issue: %s\n", green("✓"), id)
}
}
// Schedule auto-flush if any issues were updated
if len(args) > 0 {
markDirtyAndScheduleFlush()
}
if jsonOutput && len(updatedIssues) > 0 {
outputJSON(updatedIssues)
}
},
}
var editCmd = &cobra.Command{
Use: "edit [id]",
Short: "Edit an issue field in $EDITOR",
Long: `Edit an issue field using your configured $EDITOR.
By default, edits the description. Use flags to edit other fields.
Examples:
bd edit bd-42 # Edit description
bd edit bd-42 --title # Edit title
@@ -534,6 +508,7 @@ Examples:
Run: func(cmd *cobra.Command, args []string) {
id := args[0]
ctx := context.Background()
// Resolve partial ID if in direct mode
if daemonClient == nil {
fullID, err := utils.ResolvePartialID(ctx, store, id)
@@ -543,6 +518,7 @@ Examples:
}
id = fullID
}
// Determine which field to edit
fieldToEdit := "description"
if cmd.Flags().Changed("title") {
@@ -554,6 +530,7 @@ Examples:
} else if cmd.Flags().Changed("acceptance") {
fieldToEdit = "acceptance_criteria"
}
// Get the editor from environment
editor := os.Getenv("EDITOR")
if editor == "" {
@@ -572,9 +549,11 @@ Examples:
fmt.Fprintf(os.Stderr, "Error: No editor found. Set $EDITOR or $VISUAL environment variable.\n")
os.Exit(1)
}
// Get the current issue
var issue *types.Issue
var err error
if daemonClient != nil {
// Daemon mode
showArgs := &rpc.ShowArgs{ID: id}
@@ -583,6 +562,7 @@ Examples:
fmt.Fprintf(os.Stderr, "Error fetching issue %s: %v\n", id, err)
os.Exit(1)
}
issue = &types.Issue{}
if err := json.Unmarshal(resp.Data, issue); err != nil {
fmt.Fprintf(os.Stderr, "Error parsing issue data: %v\n", err)
@@ -600,6 +580,7 @@ Examples:
os.Exit(1)
}
}
// Get the current field value
var currentValue string
switch fieldToEdit {
@@ -614,6 +595,7 @@ Examples:
case "acceptance_criteria":
currentValue = issue.AcceptanceCriteria
}
// Create a temporary file with the current value
tmpFile, err := os.CreateTemp("", fmt.Sprintf("bd-edit-%s-*.txt", fieldToEdit))
if err != nil {
@@ -622,47 +604,56 @@ Examples:
}
tmpPath := tmpFile.Name()
defer func() { _ = os.Remove(tmpPath) }()
// Write current value to temp file
if _, err := tmpFile.WriteString(currentValue); err != nil {
_ = tmpFile.Close() // nolint:gosec // G104: Error already handled above
tmpFile.Close()
fmt.Fprintf(os.Stderr, "Error writing to temp file: %v\n", err)
os.Exit(1)
}
_ = tmpFile.Close() // nolint:gosec // G104: Defer close errors are non-critical
tmpFile.Close()
// Open the editor
editorCmd := exec.Command(editor, tmpPath)
editorCmd.Stdin = os.Stdin
editorCmd.Stdout = os.Stdout
editorCmd.Stderr = os.Stderr
if err := editorCmd.Run(); err != nil {
fmt.Fprintf(os.Stderr, "Error running editor: %v\n", err)
os.Exit(1)
}
// Read the edited content
// nolint:gosec // G304: tmpPath is securely created temp file
editedContent, err := os.ReadFile(tmpPath)
if err != nil {
fmt.Fprintf(os.Stderr, "Error reading edited file: %v\n", err)
os.Exit(1)
}
newValue := string(editedContent)
// Check if the value changed
if newValue == currentValue {
fmt.Println("No changes made")
return
}
// Validate title if editing title
if fieldToEdit == "title" && strings.TrimSpace(newValue) == "" {
fmt.Fprintf(os.Stderr, "Error: title cannot be empty\n")
os.Exit(1)
}
// Update the issue
updates := map[string]interface{}{
fieldToEdit: newValue,
}
if daemonClient != nil {
// Daemon mode
updateArgs := &rpc.UpdateArgs{ID: id}
switch fieldToEdit {
case "title":
updateArgs.Title = &newValue
@@ -675,6 +666,7 @@ Examples:
case "acceptance_criteria":
updateArgs.AcceptanceCriteria = &newValue
}
_, err := daemonClient.Update(updateArgs)
if err != nil {
fmt.Fprintf(os.Stderr, "Error updating issue: %v\n", err)
@@ -688,11 +680,13 @@ Examples:
}
markDirtyAndScheduleFlush()
}
green := color.New(color.FgGreen).SprintFunc()
fieldName := strings.ReplaceAll(fieldToEdit, "_", " ")
fmt.Printf("%s Updated %s for issue: %s\n", green("✓"), fieldName, id)
},
}
var closeCmd = &cobra.Command{
Use: "close [id...]",
Short: "Close one or more issues",
@@ -702,8 +696,10 @@ var closeCmd = &cobra.Command{
if reason == "" {
reason = "Closed"
}
// Use global jsonOutput set by PersistentPreRun
jsonOutput, _ := cmd.Flags().GetBool("json")
ctx := context.Background()
// Resolve partial IDs first
var resolvedIDs []string
if daemonClient != nil {
@@ -714,12 +710,7 @@ var closeCmd = &cobra.Command{
fmt.Fprintf(os.Stderr, "Error resolving ID %s: %v\n", id, err)
os.Exit(1)
}
var resolvedID string
if err := json.Unmarshal(resp.Data, &resolvedID); err != nil {
fmt.Fprintf(os.Stderr, "Error unmarshaling resolved ID: %v\n", err)
os.Exit(1)
}
resolvedIDs = append(resolvedIDs, resolvedID)
resolvedIDs = append(resolvedIDs, string(resp.Data))
}
} else {
var err error
@@ -729,6 +720,7 @@ var closeCmd = &cobra.Command{
os.Exit(1)
}
}
// If daemon is running, use RPC
if daemonClient != nil {
closedIssues := []*types.Issue{}
@@ -742,6 +734,7 @@ var closeCmd = &cobra.Command{
fmt.Fprintf(os.Stderr, "Error closing %s: %v\n", id, err)
continue
}
if jsonOutput {
var issue types.Issue
if err := json.Unmarshal(resp.Data, &issue); err == nil {
@@ -752,11 +745,13 @@ var closeCmd = &cobra.Command{
fmt.Printf("%s Closed %s: %s\n", green("✓"), id, reason)
}
}
if jsonOutput && len(closedIssues) > 0 {
outputJSON(closedIssues)
}
return
}
// Direct mode
closedIssues := []*types.Issue{}
for _, id := range resolvedIDs {
@@ -774,19 +769,24 @@ var closeCmd = &cobra.Command{
fmt.Printf("%s Closed %s: %s\n", green("✓"), id, reason)
}
}
// Schedule auto-flush if any issues were closed
if len(args) > 0 {
markDirtyAndScheduleFlush()
}
if jsonOutput && len(closedIssues) > 0 {
outputJSON(closedIssues)
}
},
}
func init() {
showCmd.Flags().Bool("json", false, "Output JSON format")
rootCmd.AddCommand(showCmd)
updateCmd.Flags().StringP("status", "s", "", "New status")
updateCmd.Flags().StringP("priority", "p", "", "New priority (0-4 or P0-P4)")
updateCmd.Flags().IntP("priority", "p", 0, "New priority")
updateCmd.Flags().String("title", "", "New title")
updateCmd.Flags().StringP("assignee", "a", "", "New assignee")
updateCmd.Flags().StringP("description", "d", "", "Issue description")
@@ -796,13 +796,17 @@ func init() {
updateCmd.Flags().String("acceptance-criteria", "", "DEPRECATED: use --acceptance")
_ = updateCmd.Flags().MarkHidden("acceptance-criteria")
updateCmd.Flags().String("external-ref", "", "External reference (e.g., 'gh-9', 'jira-ABC')")
updateCmd.Flags().Bool("json", false, "Output JSON format")
rootCmd.AddCommand(updateCmd)
editCmd.Flags().Bool("title", false, "Edit the title")
editCmd.Flags().Bool("description", false, "Edit the description (default)")
editCmd.Flags().Bool("design", false, "Edit the design notes")
editCmd.Flags().Bool("notes", false, "Edit the notes")
editCmd.Flags().Bool("acceptance", false, "Edit the acceptance criteria")
rootCmd.AddCommand(editCmd)
closeCmd.Flags().StringP("reason", "r", "", "Reason for closing")
closeCmd.Flags().Bool("json", false, "Output JSON format")
rootCmd.AddCommand(closeCmd)
}

View File

@@ -66,6 +66,7 @@ type CreateArgs struct {
Design string `json:"design,omitempty"`
AcceptanceCriteria string `json:"acceptance_criteria,omitempty"`
Assignee string `json:"assignee,omitempty"`
ExternalRef string `json:"external_ref,omitempty"` // Link to external issue trackers
Labels []string `json:"labels,omitempty"`
Dependencies []string `json:"dependencies,omitempty"`
}
@@ -81,6 +82,7 @@ type UpdateArgs struct {
AcceptanceCriteria *string `json:"acceptance_criteria,omitempty"`
Notes *string `json:"notes,omitempty"`
Assignee *string `json:"assignee,omitempty"`
ExternalRef *string `json:"external_ref,omitempty"` // Link to external issue trackers
}
// CloseArgs represents arguments for the close operation

View File

@@ -710,3 +710,125 @@ func TestCreate_DiscoveredFromInheritsSourceRepo(t *testing.T) {
// The logic is implemented in server_issues_epics.go handleCreate
// and tested via the cmd/bd test which has direct storage access
}
func TestRPCCreateWithExternalRef(t *testing.T) {
server, client, cleanup := setupTestServer(t)
defer cleanup()
// Create issue with external_ref via RPC
createArgs := &CreateArgs{
Title: "Test issue with external ref",
Description: "Testing external_ref in daemon mode",
IssueType: "bug",
Priority: 1,
ExternalRef: "github:303",
}
resp, err := client.Create(createArgs)
if err != nil {
t.Fatalf("Create failed: %v", err)
}
var issue types.Issue
if err := json.Unmarshal(resp.Data, &issue); err != nil {
t.Fatalf("Failed to unmarshal response: %v", err)
}
// Verify external_ref was saved
if issue.ExternalRef == nil {
t.Fatal("Expected ExternalRef to be set, got nil")
}
if *issue.ExternalRef != "github:303" {
t.Errorf("Expected ExternalRef='github:303', got '%s'", *issue.ExternalRef)
}
// Verify via Show operation
showArgs := &ShowArgs{ID: issue.ID}
resp, err = client.Show(showArgs)
if err != nil {
t.Fatalf("Show failed: %v", err)
}
var retrieved types.Issue
if err := json.Unmarshal(resp.Data, &retrieved); err != nil {
t.Fatalf("Failed to unmarshal show response: %v", err)
}
if retrieved.ExternalRef == nil {
t.Fatal("Expected retrieved ExternalRef to be set, got nil")
}
if *retrieved.ExternalRef != "github:303" {
t.Errorf("Expected retrieved ExternalRef='github:303', got '%s'", *retrieved.ExternalRef)
}
_ = server // Silence unused warning
}
func TestRPCUpdateWithExternalRef(t *testing.T) {
server, client, cleanup := setupTestServer(t)
defer cleanup()
// Create issue without external_ref
createArgs := &CreateArgs{
Title: "Test issue for update",
Description: "Testing external_ref update in daemon mode",
IssueType: "task",
Priority: 2,
}
resp, err := client.Create(createArgs)
if err != nil {
t.Fatalf("Create failed: %v", err)
}
var issue types.Issue
if err := json.Unmarshal(resp.Data, &issue); err != nil {
t.Fatalf("Failed to unmarshal response: %v", err)
}
// Update with external_ref
newRef := "jira-ABC-123"
updateArgs := &UpdateArgs{
ID: issue.ID,
ExternalRef: &newRef,
}
resp, err = client.Update(updateArgs)
if err != nil {
t.Fatalf("Update failed: %v", err)
}
var updated types.Issue
if err := json.Unmarshal(resp.Data, &updated); err != nil {
t.Fatalf("Failed to unmarshal update response: %v", err)
}
// Verify external_ref was updated
if updated.ExternalRef == nil {
t.Fatal("Expected ExternalRef to be set after update, got nil")
}
if *updated.ExternalRef != "jira-ABC-123" {
t.Errorf("Expected ExternalRef='jira-ABC-123', got '%s'", *updated.ExternalRef)
}
// Verify via Show operation
showArgs := &ShowArgs{ID: issue.ID}
resp, err = client.Show(showArgs)
if err != nil {
t.Fatalf("Show failed: %v", err)
}
var retrieved types.Issue
if err := json.Unmarshal(resp.Data, &retrieved); err != nil {
t.Fatalf("Failed to unmarshal show response: %v", err)
}
if retrieved.ExternalRef == nil {
t.Fatal("Expected retrieved ExternalRef to be set, got nil")
}
if *retrieved.ExternalRef != "jira-ABC-123" {
t.Errorf("Expected retrieved ExternalRef='jira-ABC-123', got '%s'", *retrieved.ExternalRef)
}
_ = server // Silence unused warning
}

View File

@@ -66,6 +66,9 @@ func updatesFromArgs(a UpdateArgs) map[string]interface{} {
if a.Assignee != nil {
u["assignee"] = *a.Assignee
}
if a.ExternalRef != nil {
u["external_ref"] = *a.ExternalRef
}
return u
}
@@ -108,7 +111,7 @@ func (s *Server) handleCreate(req *Request) Response {
issueID = childID
}
var design, acceptance, assignee *string
var design, acceptance, assignee, externalRef *string
if createArgs.Design != "" {
design = &createArgs.Design
}
@@ -118,6 +121,9 @@ func (s *Server) handleCreate(req *Request) Response {
if createArgs.Assignee != "" {
assignee = &createArgs.Assignee
}
if createArgs.ExternalRef != "" {
externalRef = &createArgs.ExternalRef
}
issue := &types.Issue{
ID: issueID,
@@ -128,6 +134,7 @@ func (s *Server) handleCreate(req *Request) Response {
Design: strValue(design),
AcceptanceCriteria: strValue(acceptance),
Assignee: strValue(assignee),
ExternalRef: externalRef,
Status: types.StatusOpen,
}