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:
137
cmd/bd/comments.go
Normal file
137
cmd/bd/comments.go
Normal 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)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user