Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 21 additions & 4 deletions cmd/bd/comments.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,13 @@ Examples:
}
issueID = fullID

comments, err := store.GetIssueComments(ctx, issueID)
commentType, _ := cmd.Flags().GetString("type")
var comments []*types.Comment
if commentType != "" {
comments, err = store.GetIssueCommentsByType(ctx, issueID, commentType)
} else {
comments, err = store.GetIssueComments(ctx, issueID)
}
if err != nil {
FatalErrorRespectJSON("getting comments: %v", err)
}
Expand Down Expand Up @@ -74,8 +80,16 @@ Examples:
fmt.Printf("[%s] at %s\n", comment.Author, ts.Format("2006-01-02 15:04"))
rendered := ui.RenderMarkdown(comment.Text)
// TrimRight removes trailing newlines that Glamour adds, preventing extra blank lines
for _, line := range strings.Split(strings.TrimRight(rendered, "\n"), "\n") {
fmt.Printf(" %s\n", line)
lines := strings.Split(strings.TrimRight(rendered, "\n"), "\n")
if comment.Type != "" {
fmt.Printf(" [%s] %s\n", comment.Type, lines[0])
for _, line := range lines[1:] {
fmt.Printf(" %s\n", line)
}
} else {
for _, line := range lines {
fmt.Printf(" %s\n", line)
}
}
fmt.Println()
}
Expand Down Expand Up @@ -134,7 +148,8 @@ Examples:
}
issueID = fullID

comment, err := store.AddIssueComment(ctx, issueID, author, commentText)
commentType, _ := cmd.Flags().GetString("type")
comment, err := store.AddIssueComment(ctx, issueID, author, commentText, commentType)
if err != nil {
FatalErrorRespectJSON("adding comment: %v", err)
}
Expand All @@ -151,8 +166,10 @@ Examples:
func init() {
commentsCmd.AddCommand(commentsAddCmd)
commentsCmd.Flags().Bool("local-time", false, "Show timestamps in local time instead of UTC")
commentsCmd.Flags().String("type", "", "Filter by comment type (decision, handoff, note)")
commentsAddCmd.Flags().StringP("file", "f", "", "Read comment text from file")
commentsAddCmd.Flags().StringP("author", "a", "", "Add author to comment")
commentsAddCmd.Flags().String("type", "", "Comment type (decision, handoff, note)")

// Issue ID completions
commentsCmd.ValidArgsFunction = issueIDCompletion
Expand Down
79 changes: 76 additions & 3 deletions cmd/bd/comments_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"fmt"
"path/filepath"
"testing"
"time"

"github.com/steveyegge/beads/internal/types"
)
Expand Down Expand Up @@ -35,7 +36,7 @@ func TestCommentsSuite(t *testing.T) {
}

t.Run("add comment", func(t *testing.T) {
comment, err := s.AddIssueComment(ctx, issue.ID, testUserAlice, "This is a test comment")
comment, err := s.AddIssueComment(ctx, issue.ID, testUserAlice, "This is a test comment", "")
if err != nil {
t.Fatalf("Failed to add comment: %v", err)
}
Expand Down Expand Up @@ -67,7 +68,7 @@ func TestCommentsSuite(t *testing.T) {
})

t.Run("multiple comments", func(t *testing.T) {
_, err := s.AddIssueComment(ctx, issue.ID, "bob", "Second comment")
_, err := s.AddIssueComment(ctx, issue.ID, "bob", "Second comment", "")
if err != nil {
t.Fatalf("Failed to add second comment: %v", err)
}
Expand Down Expand Up @@ -108,7 +109,7 @@ func TestCommentsSuite(t *testing.T) {
}

t.Run("comment added via storage API works", func(t *testing.T) {
comment, err := s.AddIssueComment(ctx, issue.ID, testUserAlice, "Test comment")
comment, err := s.AddIssueComment(ctx, issue.ID, testUserAlice, "Test comment", "")
if err != nil {
t.Fatalf("Failed to add comment: %v", err)
}
Expand All @@ -129,6 +130,78 @@ func TestCommentsSuite(t *testing.T) {
})
}

func TestTypedComments(t *testing.T) {
t.Parallel()
ctx := context.Background()
tmpDir := t.TempDir()
testDB := filepath.Join(tmpDir, ".beads", "beads.db")
s := newTestStore(t, testDB)

issue := &types.Issue{
Title: "Typed Comment Test",
Status: types.StatusOpen,
Priority: 2,
IssueType: types.TypeTask,
CreatedAt: time.Now(),
}
if err := s.CreateIssue(ctx, issue, "test"); err != nil {
t.Fatalf("Failed to create issue: %v", err)
}

t.Run("AddTypedComment", func(t *testing.T) {
comment, err := s.AddIssueComment(ctx, issue.ID, "alice", "Use JWT over sessions", types.CommentTypeDecision)
if err != nil {
t.Fatalf("AddIssueComment failed: %v", err)
}
if comment.Type != types.CommentTypeDecision {
t.Errorf("comment.Type = %q, want %q", comment.Type, types.CommentTypeDecision)
}
})

t.Run("AddUntypedComment", func(t *testing.T) {
comment, err := s.AddIssueComment(ctx, issue.ID, "bob", "General update", "")
if err != nil {
t.Fatalf("AddIssueComment failed: %v", err)
}
if comment.Type != "" {
t.Errorf("comment.Type = %q, want empty", comment.Type)
}
})

t.Run("GetCommentsByType", func(t *testing.T) {
comments, err := s.GetIssueCommentsByType(ctx, issue.ID, types.CommentTypeDecision)
if err != nil {
t.Fatalf("GetIssueCommentsByType failed: %v", err)
}
if len(comments) != 1 {
t.Errorf("got %d comments, want 1", len(comments))
}
if len(comments) > 0 && comments[0].Type != types.CommentTypeDecision {
t.Errorf("comment.Type = %q, want %q", comments[0].Type, types.CommentTypeDecision)
}
})

t.Run("GetAllCommentsReturnsType", func(t *testing.T) {
comments, err := s.GetIssueComments(ctx, issue.ID)
if err != nil {
t.Fatalf("GetIssueComments failed: %v", err)
}
if len(comments) < 2 {
t.Fatalf("got %d comments, want >= 2", len(comments))
}
found := false
for _, c := range comments {
if c.Type == types.CommentTypeDecision {
found = true
break
}
}
if !found {
t.Error("decision comment type not found in GetIssueComments results")
}
})
}

func TestIsUnknownOperationError(t *testing.T) {
t.Parallel()
tests := []struct {
Expand Down
2 changes: 1 addition & 1 deletion cmd/bd/human.go
Original file line number Diff line number Diff line change
Expand Up @@ -229,7 +229,7 @@ Examples:

// Add comment using AddIssueComment (issueID, author, text)
commentText := fmt.Sprintf("Response: %s", response)
_, err = targetStore.AddIssueComment(ctx, resolvedID, actor, commentText)
_, err = targetStore.AddIssueComment(ctx, resolvedID, actor, commentText, "")
if err != nil {
FatalErrorRespectJSON("adding comment: %v", err)
}
Expand Down
2 changes: 1 addition & 1 deletion examples/library-usage/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ func main() {

// Example 5: Add a comment
fmt.Println("\n=== Adding Comment ===")
comment, err := store.AddIssueComment(ctx, newIssue.ID, "library-example", "This is a programmatic comment")
comment, err := store.AddIssueComment(ctx, newIssue.ID, "library-example", "This is a programmatic comment", "")
if err != nil {
log.Fatalf("Failed to add comment: %v", err)
}
Expand Down
2 changes: 1 addition & 1 deletion internal/beads/beads_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ func (h *integrationTestHelper) addLabel(id, label string) {
}

func (h *integrationTestHelper) addComment(id, user, text string) *types.Comment {
comment, err := h.store.AddIssueComment(h.ctx, id, user, text)
comment, err := h.store.AddIssueComment(h.ctx, id, user, text, "")
if err != nil {
h.t.Fatalf("AddIssueComment failed: %v", err)
}
Expand Down
5 changes: 4 additions & 1 deletion internal/jira/tracker_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -581,12 +581,15 @@ func (s *configStore) GetBlockedIssues(_ context.Context, _ types.WorkFilter) ([
func (s *configStore) GetEpicsEligibleForClosure(_ context.Context) ([]*types.EpicStatus, error) {
return nil, nil
}
func (s *configStore) AddIssueComment(_ context.Context, _, _, _ string) (*types.Comment, error) {
func (s *configStore) AddIssueComment(_ context.Context, _, _, _, _ string) (*types.Comment, error) {
return nil, nil
}
func (s *configStore) GetIssueComments(_ context.Context, _ string) ([]*types.Comment, error) {
return nil, nil
}
func (s *configStore) GetIssueCommentsByType(_ context.Context, _, _ string) ([]*types.Comment, error) {
return nil, nil
}
func (s *configStore) GetEvents(_ context.Context, _ string, _ int) ([]*types.Event, error) {
return nil, nil
}
Expand Down
2 changes: 1 addition & 1 deletion internal/storage/annotation_queries.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import (
// AnnotationStore provides comment and label operations, including bulk queries.
type AnnotationStore interface {
AddComment(ctx context.Context, issueID, actor, comment string) error
ImportIssueComment(ctx context.Context, issueID, author, text string, createdAt time.Time) (*types.Comment, error)
ImportIssueComment(ctx context.Context, issueID, author, text, commentType string, createdAt time.Time) (*types.Comment, error)
GetCommentCounts(ctx context.Context, issueIDs []string) (map[string]int, error)
GetCommentsForIssues(ctx context.Context, issueIDs []string) (map[string][]*types.Comment, error)
GetLabelsForIssues(ctx context.Context, issueIDs []string) (map[string][]string, error)
Expand Down
4 changes: 2 additions & 2 deletions internal/storage/dolt/dolt_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -977,15 +977,15 @@ func TestDoltStoreComments(t *testing.T) {
}

// Add comments
comment1, err := store.AddIssueComment(ctx, issue.ID, "user1", "First comment")
comment1, err := store.AddIssueComment(ctx, issue.ID, "user1", "First comment", "")
if err != nil {
t.Fatalf("failed to add first comment: %v", err)
}
if comment1.ID == 0 {
t.Error("expected comment ID to be generated")
}

_, err = store.AddIssueComment(ctx, issue.ID, "user2", "Second comment")
_, err = store.AddIssueComment(ctx, issue.ID, "user2", "Second comment", "")
if err != nil {
t.Fatalf("failed to add second comment: %v", err)
}
Expand Down
43 changes: 33 additions & 10 deletions internal/storage/dolt/events.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,13 +80,13 @@ func (s *DoltStore) GetAllEventsSince(ctx context.Context, sinceID int64) ([]*ty
}

// AddIssueComment adds a comment to an issue (structured comment)
func (s *DoltStore) AddIssueComment(ctx context.Context, issueID, author, text string) (*types.Comment, error) {
return s.ImportIssueComment(ctx, issueID, author, text, time.Now().UTC())
func (s *DoltStore) AddIssueComment(ctx context.Context, issueID, author, text, commentType string) (*types.Comment, error) {
return s.ImportIssueComment(ctx, issueID, author, text, commentType, time.Now().UTC())
}

// ImportIssueComment adds a comment during import, preserving the original timestamp.
// This prevents comment timestamp drift across import/export cycles.
func (s *DoltStore) ImportIssueComment(ctx context.Context, issueID, author, text string, createdAt time.Time) (*types.Comment, error) {
func (s *DoltStore) ImportIssueComment(ctx context.Context, issueID, author, text, commentType string, createdAt time.Time) (*types.Comment, error) {
// Verify issue exists — route to wisps table for active wisps
issueTable := "issues"
commentTable := "comments"
Expand All @@ -110,9 +110,9 @@ func (s *DoltStore) ImportIssueComment(ctx context.Context, issueID, author, tex
createdAt = createdAt.UTC()
//nolint:gosec // G201: table is hardcoded
result, err := s.execContext(ctx, fmt.Sprintf(`
INSERT INTO %s (issue_id, author, text, created_at)
VALUES (?, ?, ?, ?)
`, commentTable), issueID, author, text, createdAt)
INSERT INTO %s (issue_id, author, text, type, created_at)
VALUES (?, ?, ?, ?, ?)
`, commentTable), issueID, author, text, commentType, createdAt)
if err != nil {
return nil, fmt.Errorf("failed to add comment: %w", err)
}
Expand All @@ -127,6 +127,7 @@ func (s *DoltStore) ImportIssueComment(ctx context.Context, issueID, author, tex
IssueID: issueID,
Author: author,
Text: text,
Type: commentType,
CreatedAt: createdAt,
}, nil
}
Expand All @@ -140,7 +141,7 @@ func (s *DoltStore) GetIssueComments(ctx context.Context, issueID string) ([]*ty

//nolint:gosec // G201: table is hardcoded
rows, err := s.queryContext(ctx, fmt.Sprintf(`
SELECT id, issue_id, author, text, created_at
SELECT id, issue_id, author, text, type, created_at
FROM %s
WHERE issue_id = ?
ORDER BY created_at ASC
Expand All @@ -153,6 +154,28 @@ func (s *DoltStore) GetIssueComments(ctx context.Context, issueID string) ([]*ty
return scanComments(rows)
}

// GetIssueCommentsByType retrieves comments for an issue filtered by type.
func (s *DoltStore) GetIssueCommentsByType(ctx context.Context, issueID, commentType string) ([]*types.Comment, error) {
table := "comments"
if s.isActiveWisp(ctx, issueID) {
table = "wisp_comments"
}

//nolint:gosec // G201: table is hardcoded
rows, err := s.queryContext(ctx, fmt.Sprintf(`
SELECT id, issue_id, author, text, type, created_at
FROM %s
WHERE issue_id = ? AND type = ?
ORDER BY created_at ASC
`, table), issueID, commentType)
if err != nil {
return nil, fmt.Errorf("failed to get comments by type: %w", err)
}
defer rows.Close()

return scanComments(rows)
}

// GetCommentsForIssues retrieves comments for multiple issues
func (s *DoltStore) GetCommentsForIssues(ctx context.Context, issueIDs []string) (map[string][]*types.Comment, error) {
if len(issueIDs) == 0 {
Expand Down Expand Up @@ -192,7 +215,7 @@ func (s *DoltStore) getCommentsForIDsInto(ctx context.Context, table string, ids

//nolint:gosec // G201: table is hardcoded, placeholders contains only ? markers
query := fmt.Sprintf(`
SELECT id, issue_id, author, text, created_at
SELECT id, issue_id, author, text, type, created_at
FROM %s
WHERE issue_id IN (%s)
ORDER BY issue_id, created_at ASC
Expand All @@ -205,7 +228,7 @@ func (s *DoltStore) getCommentsForIDsInto(ctx context.Context, table string, ids

for rows.Next() {
var c types.Comment
if err := rows.Scan(&c.ID, &c.IssueID, &c.Author, &c.Text, &c.CreatedAt); err != nil {
if err := rows.Scan(&c.ID, &c.IssueID, &c.Author, &c.Text, &c.Type, &c.CreatedAt); err != nil {
_ = rows.Close()
return fmt.Errorf("failed to scan comment: %w", err)
}
Expand Down Expand Up @@ -317,7 +340,7 @@ func scanComments(rows *sql.Rows) ([]*types.Comment, error) {
var comments []*types.Comment
for rows.Next() {
var c types.Comment
if err := rows.Scan(&c.ID, &c.IssueID, &c.Author, &c.Text, &c.CreatedAt); err != nil {
if err := rows.Scan(&c.ID, &c.IssueID, &c.Author, &c.Text, &c.Type, &c.CreatedAt); err != nil {
return nil, fmt.Errorf("failed to scan comment: %w", err)
}
comments = append(comments, &c)
Expand Down
1 change: 1 addition & 0 deletions internal/storage/dolt/migrations.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ var migrationsList = []Migration{
{"infra_to_wisps", migrations.MigrateInfraToWisps},
{"wisp_dep_type_index", migrations.MigrateWispDepTypeIndex},
{"cleanup_autopush_metadata", migrations.MigrateCleanupAutopushMetadata},
{"comment_type_column", migrations.MigrateCommentTypeColumn},
}

// RunMigrations executes all registered Dolt migrations in order.
Expand Down
38 changes: 38 additions & 0 deletions internal/storage/dolt/migrations/010_comment_type_column.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package migrations

import "database/sql"

// MigrateCommentTypeColumn adds a `type` column to comments and wisp_comments
// tables for categorizing comments (decision, handoff, note).
// Existing comments get empty string (untyped) — backwards compatible.
func MigrateCommentTypeColumn(db *sql.DB) error {
for _, table := range []string{"comments", "wisp_comments"} {
exists, err := columnExists(db, table, "type")
if err != nil {
// Table may not exist (e.g., wisp_comments on fresh installs
// before wisp migration runs). Skip gracefully.
if isTableNotFoundError(err) {
continue
}
return err
}
if exists {
continue
}

//nolint:gosec // G202: table name comes from hardcoded list above
if _, err := db.Exec("ALTER TABLE `" + table + "` ADD COLUMN `type` VARCHAR(32) NOT NULL DEFAULT ''"); err != nil {
return err
}

// Composite index for filtering comments by type within an issue
idxName := "idx_" + table + "_issue_type"
if !indexExists(db, table, idxName) {
//nolint:gosec // G202: table/index names from internal constants
if _, err := db.Exec("CREATE INDEX `" + idxName + "` ON `" + table + "` (issue_id, type)"); err != nil {
return err
}
}
}
return nil
}
Loading
Loading