Add comments feature (bd-162)

- Add comments table to SQLite schema
- Add Comment type to internal/types
- Implement AddIssueComment and GetIssueComments in storage layer
- Update JSONL export/import to include comments
- Add comments to 'bd show' output
- Create 'bd comments' CLI command structure
- Fix UpdateIssueID to update comments table and defer FK checks
- Add GetIssueComments/AddIssueComment to Storage interface

Note: CLI command needs daemon RPC support (tracked in bd-163)
Amp-Thread-ID: https://ampcode.com/threads/T-ece10dd1-cf64-48ff-9adb-dd304d0bcb25
Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
Steve Yegge
2025-10-19 18:28:41 -07:00
parent 34cf361b2b
commit a28d4fe4c7
11 changed files with 320 additions and 4 deletions

137
cmd/bd/comments.go Normal file
View File

@@ -0,0 +1,137 @@
package main
import (
"context"
"encoding/json"
"fmt"
"os"
"os/user"
"github.com/spf13/cobra"
)
var commentsCmd = &cobra.Command{
Use: "comments [issue-id]",
Short: "View or manage comments on an issue",
Long: `View or manage comments on an issue.
Examples:
# List all comments on an issue
bd comments bd-123
# List comments in JSON format
bd comments bd-123 --json
# Add a comment
bd comments add bd-123 "This is a comment"
# Add a comment from a file
bd comments add bd-123 -f notes.txt`,
Args: cobra.MinimumNArgs(1),
Run: func(cmd *cobra.Command, args []string) {
issueID := args[0]
ctx := context.Background()
// Get comments
comments, err := store.GetIssueComments(ctx, issueID)
if err != nil {
fmt.Fprintf(os.Stderr, "Error getting comments: %v\n", err)
os.Exit(1)
}
if jsonOutput {
data, err := json.MarshalIndent(comments, "", " ")
if err != nil {
fmt.Fprintf(os.Stderr, "Error encoding JSON: %v\n", err)
os.Exit(1)
}
fmt.Println(string(data))
return
}
// Human-readable output
if len(comments) == 0 {
fmt.Printf("No comments on %s\n", issueID)
return
}
fmt.Printf("\nComments on %s:\n\n", issueID)
for _, comment := range comments {
fmt.Printf("[%s] %s at %s\n", comment.Author, comment.Text, comment.CreatedAt.Format("2006-01-02 15:04"))
fmt.Println()
}
},
}
var commentsAddCmd = &cobra.Command{
Use: "add [issue-id] [text]",
Short: "Add a comment to an issue",
Long: `Add a comment to an issue.
Examples:
# Add a comment
bd comments add bd-123 "Working on this now"
# Add a comment from a file
bd comments add bd-123 -f notes.txt`,
Args: cobra.MinimumNArgs(1),
Run: func(cmd *cobra.Command, args []string) {
issueID := args[0]
// Get comment text from flag or argument
commentText, _ := cmd.Flags().GetString("file")
if commentText != "" {
// Read from file
data, err := os.ReadFile(commentText)
if err != nil {
fmt.Fprintf(os.Stderr, "Error reading file: %v\n", err)
os.Exit(1)
}
commentText = string(data)
} else if len(args) < 2 {
fmt.Fprintf(os.Stderr, "Error: comment text required (use -f to read from file)\n")
os.Exit(1)
} else {
commentText = args[1]
}
// Get author from environment or system
author := os.Getenv("BD_AUTHOR")
if author == "" {
author = os.Getenv("USER")
}
if author == "" {
if u, err := user.Current(); err == nil {
author = u.Username
} else {
author = "unknown"
}
}
ctx := context.Background()
comment, err := store.AddIssueComment(ctx, issueID, author, commentText)
if err != nil {
fmt.Fprintf(os.Stderr, "Error adding comment: %v\n", err)
os.Exit(1)
}
if jsonOutput {
data, err := json.MarshalIndent(comment, "", " ")
if err != nil {
fmt.Fprintf(os.Stderr, "Error encoding JSON: %v\n", err)
os.Exit(1)
}
fmt.Println(string(data))
return
}
fmt.Printf("Comment added to %s\n", issueID)
},
}
func init() {
commentsCmd.AddCommand(commentsAddCmd)
commentsAddCmd.Flags().StringP("file", "f", "", "Read comment text from file")
rootCmd.AddCommand(commentsCmd)
}

View File

@@ -664,6 +664,15 @@ func exportToJSONLWithStore(ctx context.Context, store storage.Storage, jsonlPat
issue.Labels = labels
}
// Populate comments for all issues
for _, issue := range issues {
comments, err := store.GetIssueComments(ctx, issue.ID)
if err != nil {
return fmt.Errorf("failed to get comments for %s: %w", issue.ID, err)
}
issue.Comments = comments
}
// Create temp file for atomic write
dir := filepath.Dir(jsonlPath)
base := filepath.Base(jsonlPath)

View File

@@ -3,6 +3,7 @@ package main
import (
"context"
"fmt"
"time"
"github.com/steveyegge/beads/internal/storage"
"github.com/steveyegge/beads/internal/storage/sqlite"
@@ -275,5 +276,39 @@ func importIssuesCore(ctx context.Context, dbPath string, store storage.Storage,
}
}
// Phase 7: Import comments
for _, issue := range issues {
if len(issue.Comments) == 0 {
continue
}
// Get current comments to avoid duplicates
currentComments, err := sqliteStore.GetIssueComments(ctx, issue.ID)
if err != nil {
return nil, fmt.Errorf("error getting comments for %s: %w", issue.ID, err)
}
// Build a set of existing comments (by author+text+timestamp)
existingComments := make(map[string]bool)
for _, c := range currentComments {
key := fmt.Sprintf("%s:%s:%s", c.Author, c.Text, c.CreatedAt.Format(time.RFC3339))
existingComments[key] = true
}
// Add missing comments
for _, comment := range issue.Comments {
key := fmt.Sprintf("%s:%s:%s", comment.Author, comment.Text, comment.CreatedAt.Format(time.RFC3339))
if !existingComments[key] {
if _, err := sqliteStore.AddIssueComment(ctx, issue.ID, comment.Author, comment.Text); err != nil {
if opts.Strict {
return nil, fmt.Errorf("error adding comment to %s: %w", issue.ID, err)
}
// Non-strict mode: skip this comment
continue
}
}
}
}
return result, nil
}

View File

@@ -1731,17 +1731,19 @@ var showCmd = &cobra.Command{
}
if jsonOutput {
// Include labels and dependencies in JSON output
// Include labels, dependencies, and comments in JSON output
type IssueDetails struct {
*types.Issue
Labels []string `json:"labels,omitempty"`
Dependencies []*types.Issue `json:"dependencies,omitempty"`
Dependents []*types.Issue `json:"dependents,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)
details.Dependencies, _ = store.GetDependencies(ctx, issue.ID)
details.Dependents, _ = store.GetDependents(ctx, issue.ID)
details.Comments, _ = store.GetIssueComments(ctx, issue.ID)
outputJSON(details)
return
}
@@ -1835,6 +1837,15 @@ var showCmd = &cobra.Command{
}
}
// Show comments
comments, _ := store.GetIssueComments(ctx, issue.ID)
if len(comments) > 0 {
fmt.Printf("\nComments (%d):\n", len(comments))
for _, comment := range comments {
fmt.Printf(" [%s at %s]\n %s\n\n", comment.Author, comment.CreatedAt.Format("2006-01-02 15:04"), comment.Text)
}
}
fmt.Println()
},
}

View File

@@ -270,6 +270,15 @@ func exportToJSONL(ctx context.Context, jsonlPath string) error {
issue.Labels = labels
}
// Populate comments for all issues
for _, issue := range issues {
comments, err := store.GetIssueComments(ctx, issue.ID)
if err != nil {
return fmt.Errorf("failed to get comments for %s: %w", issue.ID, err)
}
issue.Comments = comments
}
// Create temp file for atomic write
dir := filepath.Dir(jsonlPath)
base := filepath.Base(jsonlPath)