Skip to content

Commit 3252993

Browse files
MelsovCOZYclaude
andcommitted
feat: update storage layer and CLI for comment types
- Extends AddIssueComment with commentType parameter across all interfaces (Storage, Transaction, AnnotationStore) and implementations - Adds GetIssueCommentsByType for filtered comment retrieval - Updates all callers to pass empty string for backwards compatibility - Adds --type flag to `bd comments add` and `bd comments` list commands - Adds TestTypedComments test covering typed/untyped comments and filtered retrieval - Comment type shown as [type] prefix in human-readable output Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 22e231e commit 3252993

File tree

13 files changed

+169
-37
lines changed

13 files changed

+169
-37
lines changed

cmd/bd/comments.go

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,13 @@ Examples:
4444
}
4545
issueID = fullID
4646

47-
comments, err := store.GetIssueComments(ctx, issueID)
47+
commentType, _ := cmd.Flags().GetString("type")
48+
var comments []*types.Comment
49+
if commentType != "" {
50+
comments, err = store.GetIssueCommentsByType(ctx, issueID, commentType)
51+
} else {
52+
comments, err = store.GetIssueComments(ctx, issueID)
53+
}
4854
if err != nil {
4955
FatalErrorRespectJSON("getting comments: %v", err)
5056
}
@@ -74,8 +80,16 @@ Examples:
7480
fmt.Printf("[%s] at %s\n", comment.Author, ts.Format("2006-01-02 15:04"))
7581
rendered := ui.RenderMarkdown(comment.Text)
7682
// TrimRight removes trailing newlines that Glamour adds, preventing extra blank lines
77-
for _, line := range strings.Split(strings.TrimRight(rendered, "\n"), "\n") {
78-
fmt.Printf(" %s\n", line)
83+
lines := strings.Split(strings.TrimRight(rendered, "\n"), "\n")
84+
if comment.Type != "" {
85+
fmt.Printf(" [%s] %s\n", comment.Type, lines[0])
86+
for _, line := range lines[1:] {
87+
fmt.Printf(" %s\n", line)
88+
}
89+
} else {
90+
for _, line := range lines {
91+
fmt.Printf(" %s\n", line)
92+
}
7993
}
8094
fmt.Println()
8195
}
@@ -134,7 +148,8 @@ Examples:
134148
}
135149
issueID = fullID
136150

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

157174
// Issue ID completions
158175
commentsCmd.ValidArgsFunction = issueIDCompletion

cmd/bd/comments_test.go

Lines changed: 76 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"fmt"
88
"path/filepath"
99
"testing"
10+
"time"
1011

1112
"github.com/steveyegge/beads/internal/types"
1213
)
@@ -35,7 +36,7 @@ func TestCommentsSuite(t *testing.T) {
3536
}
3637

3738
t.Run("add comment", func(t *testing.T) {
38-
comment, err := s.AddIssueComment(ctx, issue.ID, testUserAlice, "This is a test comment")
39+
comment, err := s.AddIssueComment(ctx, issue.ID, testUserAlice, "This is a test comment", "")
3940
if err != nil {
4041
t.Fatalf("Failed to add comment: %v", err)
4142
}
@@ -67,7 +68,7 @@ func TestCommentsSuite(t *testing.T) {
6768
})
6869

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

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

133+
func TestTypedComments(t *testing.T) {
134+
t.Parallel()
135+
ctx := context.Background()
136+
tmpDir := t.TempDir()
137+
testDB := filepath.Join(tmpDir, ".beads", "beads.db")
138+
s := newTestStore(t, testDB)
139+
140+
issue := &types.Issue{
141+
Title: "Typed Comment Test",
142+
Status: types.StatusOpen,
143+
Priority: 2,
144+
IssueType: types.TypeTask,
145+
CreatedAt: time.Now(),
146+
}
147+
if err := s.CreateIssue(ctx, issue, "test"); err != nil {
148+
t.Fatalf("Failed to create issue: %v", err)
149+
}
150+
151+
t.Run("AddTypedComment", func(t *testing.T) {
152+
comment, err := s.AddIssueComment(ctx, issue.ID, "alice", "Use JWT over sessions", types.CommentTypeDecision)
153+
if err != nil {
154+
t.Fatalf("AddIssueComment failed: %v", err)
155+
}
156+
if comment.Type != types.CommentTypeDecision {
157+
t.Errorf("comment.Type = %q, want %q", comment.Type, types.CommentTypeDecision)
158+
}
159+
})
160+
161+
t.Run("AddUntypedComment", func(t *testing.T) {
162+
comment, err := s.AddIssueComment(ctx, issue.ID, "bob", "General update", "")
163+
if err != nil {
164+
t.Fatalf("AddIssueComment failed: %v", err)
165+
}
166+
if comment.Type != "" {
167+
t.Errorf("comment.Type = %q, want empty", comment.Type)
168+
}
169+
})
170+
171+
t.Run("GetCommentsByType", func(t *testing.T) {
172+
comments, err := s.GetIssueCommentsByType(ctx, issue.ID, types.CommentTypeDecision)
173+
if err != nil {
174+
t.Fatalf("GetIssueCommentsByType failed: %v", err)
175+
}
176+
if len(comments) != 1 {
177+
t.Errorf("got %d comments, want 1", len(comments))
178+
}
179+
if len(comments) > 0 && comments[0].Type != types.CommentTypeDecision {
180+
t.Errorf("comment.Type = %q, want %q", comments[0].Type, types.CommentTypeDecision)
181+
}
182+
})
183+
184+
t.Run("GetAllCommentsReturnsType", func(t *testing.T) {
185+
comments, err := s.GetIssueComments(ctx, issue.ID)
186+
if err != nil {
187+
t.Fatalf("GetIssueComments failed: %v", err)
188+
}
189+
if len(comments) < 2 {
190+
t.Fatalf("got %d comments, want >= 2", len(comments))
191+
}
192+
found := false
193+
for _, c := range comments {
194+
if c.Type == types.CommentTypeDecision {
195+
found = true
196+
break
197+
}
198+
}
199+
if !found {
200+
t.Error("decision comment type not found in GetIssueComments results")
201+
}
202+
})
203+
}
204+
132205
func TestIsUnknownOperationError(t *testing.T) {
133206
t.Parallel()
134207
tests := []struct {

cmd/bd/human.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -229,7 +229,7 @@ Examples:
229229

230230
// Add comment using AddIssueComment (issueID, author, text)
231231
commentText := fmt.Sprintf("Response: %s", response)
232-
_, err = targetStore.AddIssueComment(ctx, resolvedID, actor, commentText)
232+
_, err = targetStore.AddIssueComment(ctx, resolvedID, actor, commentText, "")
233233
if err != nil {
234234
FatalErrorRespectJSON("adding comment: %v", err)
235235
}

examples/library-usage/main.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ func main() {
9090

9191
// Example 5: Add a comment
9292
fmt.Println("\n=== Adding Comment ===")
93-
comment, err := store.AddIssueComment(ctx, newIssue.ID, "library-example", "This is a programmatic comment")
93+
comment, err := store.AddIssueComment(ctx, newIssue.ID, "library-example", "This is a programmatic comment", "")
9494
if err != nil {
9595
log.Fatalf("Failed to add comment: %v", err)
9696
}

internal/beads/beads_integration_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ func (h *integrationTestHelper) addLabel(id, label string) {
9494
}
9595

9696
func (h *integrationTestHelper) addComment(id, user, text string) *types.Comment {
97-
comment, err := h.store.AddIssueComment(h.ctx, id, user, text)
97+
comment, err := h.store.AddIssueComment(h.ctx, id, user, text, "")
9898
if err != nil {
9999
h.t.Fatalf("AddIssueComment failed: %v", err)
100100
}

internal/jira/tracker_test.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -581,12 +581,15 @@ func (s *configStore) GetBlockedIssues(_ context.Context, _ types.WorkFilter) ([
581581
func (s *configStore) GetEpicsEligibleForClosure(_ context.Context) ([]*types.EpicStatus, error) {
582582
return nil, nil
583583
}
584-
func (s *configStore) AddIssueComment(_ context.Context, _, _, _ string) (*types.Comment, error) {
584+
func (s *configStore) AddIssueComment(_ context.Context, _, _, _, _ string) (*types.Comment, error) {
585585
return nil, nil
586586
}
587587
func (s *configStore) GetIssueComments(_ context.Context, _ string) ([]*types.Comment, error) {
588588
return nil, nil
589589
}
590+
func (s *configStore) GetIssueCommentsByType(_ context.Context, _, _ string) ([]*types.Comment, error) {
591+
return nil, nil
592+
}
590593
func (s *configStore) GetEvents(_ context.Context, _ string, _ int) ([]*types.Event, error) {
591594
return nil, nil
592595
}

internal/storage/annotation_queries.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import (
1010
// AnnotationStore provides comment and label operations, including bulk queries.
1111
type AnnotationStore interface {
1212
AddComment(ctx context.Context, issueID, actor, comment string) error
13-
ImportIssueComment(ctx context.Context, issueID, author, text string, createdAt time.Time) (*types.Comment, error)
13+
ImportIssueComment(ctx context.Context, issueID, author, text, commentType string, createdAt time.Time) (*types.Comment, error)
1414
GetCommentCounts(ctx context.Context, issueIDs []string) (map[string]int, error)
1515
GetCommentsForIssues(ctx context.Context, issueIDs []string) (map[string][]*types.Comment, error)
1616
GetLabelsForIssues(ctx context.Context, issueIDs []string) (map[string][]string, error)

internal/storage/dolt/dolt_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -977,15 +977,15 @@ func TestDoltStoreComments(t *testing.T) {
977977
}
978978

979979
// Add comments
980-
comment1, err := store.AddIssueComment(ctx, issue.ID, "user1", "First comment")
980+
comment1, err := store.AddIssueComment(ctx, issue.ID, "user1", "First comment", "")
981981
if err != nil {
982982
t.Fatalf("failed to add first comment: %v", err)
983983
}
984984
if comment1.ID == 0 {
985985
t.Error("expected comment ID to be generated")
986986
}
987987

988-
_, err = store.AddIssueComment(ctx, issue.ID, "user2", "Second comment")
988+
_, err = store.AddIssueComment(ctx, issue.ID, "user2", "Second comment", "")
989989
if err != nil {
990990
t.Fatalf("failed to add second comment: %v", err)
991991
}

internal/storage/dolt/events.go

Lines changed: 33 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -80,13 +80,13 @@ func (s *DoltStore) GetAllEventsSince(ctx context.Context, sinceID int64) ([]*ty
8080
}
8181

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

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

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

157+
// GetIssueCommentsByType retrieves comments for an issue filtered by type.
158+
func (s *DoltStore) GetIssueCommentsByType(ctx context.Context, issueID, commentType string) ([]*types.Comment, error) {
159+
table := "comments"
160+
if s.isActiveWisp(ctx, issueID) {
161+
table = "wisp_comments"
162+
}
163+
164+
//nolint:gosec // G201: table is hardcoded
165+
rows, err := s.queryContext(ctx, fmt.Sprintf(`
166+
SELECT id, issue_id, author, text, type, created_at
167+
FROM %s
168+
WHERE issue_id = ? AND type = ?
169+
ORDER BY created_at ASC
170+
`, table), issueID, commentType)
171+
if err != nil {
172+
return nil, fmt.Errorf("failed to get comments by type: %w", err)
173+
}
174+
defer rows.Close()
175+
176+
return scanComments(rows)
177+
}
178+
156179
// GetCommentsForIssues retrieves comments for multiple issues
157180
func (s *DoltStore) GetCommentsForIssues(ctx context.Context, issueIDs []string) (map[string][]*types.Comment, error) {
158181
if len(issueIDs) == 0 {
@@ -192,7 +215,7 @@ func (s *DoltStore) getCommentsForIDsInto(ctx context.Context, table string, ids
192215

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

206229
for rows.Next() {
207230
var c types.Comment
208-
if err := rows.Scan(&c.ID, &c.IssueID, &c.Author, &c.Text, &c.CreatedAt); err != nil {
231+
if err := rows.Scan(&c.ID, &c.IssueID, &c.Author, &c.Text, &c.Type, &c.CreatedAt); err != nil {
209232
_ = rows.Close()
210233
return fmt.Errorf("failed to scan comment: %w", err)
211234
}
@@ -317,7 +340,7 @@ func scanComments(rows *sql.Rows) ([]*types.Comment, error) {
317340
var comments []*types.Comment
318341
for rows.Next() {
319342
var c types.Comment
320-
if err := rows.Scan(&c.ID, &c.IssueID, &c.Author, &c.Text, &c.CreatedAt); err != nil {
343+
if err := rows.Scan(&c.ID, &c.IssueID, &c.Author, &c.Text, &c.Type, &c.CreatedAt); err != nil {
321344
return nil, fmt.Errorf("failed to scan comment: %w", err)
322345
}
323346
comments = append(comments, &c)

internal/storage/dolt/transaction.go

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -768,7 +768,7 @@ func (t *doltTransaction) GetMetadata(ctx context.Context, key string) (string,
768768
return value, wrapQueryError("get metadata in tx", err)
769769
}
770770

771-
func (t *doltTransaction) ImportIssueComment(ctx context.Context, issueID, author, text string, createdAt time.Time) (*types.Comment, error) {
771+
func (t *doltTransaction) ImportIssueComment(ctx context.Context, issueID, author, text, commentType string, createdAt time.Time) (*types.Comment, error) {
772772
_, err := t.GetIssue(ctx, issueID)
773773
if err != nil {
774774
return nil, err
@@ -782,9 +782,9 @@ func (t *doltTransaction) ImportIssueComment(ctx context.Context, issueID, autho
782782
createdAt = createdAt.UTC()
783783
//nolint:gosec // G201: table is hardcoded
784784
res, err := t.tx.ExecContext(ctx, fmt.Sprintf(`
785-
INSERT INTO %s (issue_id, author, text, created_at)
786-
VALUES (?, ?, ?, ?)
787-
`, table), issueID, author, text, createdAt)
785+
INSERT INTO %s (issue_id, author, text, type, created_at)
786+
VALUES (?, ?, ?, ?, ?)
787+
`, table), issueID, author, text, commentType, createdAt)
788788
if err != nil {
789789
return nil, fmt.Errorf("failed to add comment: %w", err)
790790
}
@@ -793,7 +793,7 @@ func (t *doltTransaction) ImportIssueComment(ctx context.Context, issueID, autho
793793
return nil, fmt.Errorf("failed to get comment id: %w", err)
794794
}
795795

796-
return &types.Comment{ID: id, IssueID: issueID, Author: author, Text: text, CreatedAt: createdAt}, nil
796+
return &types.Comment{ID: id, IssueID: issueID, Author: author, Text: text, Type: commentType, CreatedAt: createdAt}, nil
797797
}
798798

799799
func (t *doltTransaction) GetIssueComments(ctx context.Context, issueID string) ([]*types.Comment, error) {
@@ -804,7 +804,7 @@ func (t *doltTransaction) GetIssueComments(ctx context.Context, issueID string)
804804

805805
//nolint:gosec // G201: table is hardcoded
806806
rows, err := t.tx.QueryContext(ctx, fmt.Sprintf(`
807-
SELECT id, issue_id, author, text, created_at
807+
SELECT id, issue_id, author, text, type, created_at
808808
FROM %s
809809
WHERE issue_id = ?
810810
ORDER BY created_at ASC
@@ -816,7 +816,7 @@ func (t *doltTransaction) GetIssueComments(ctx context.Context, issueID string)
816816
var comments []*types.Comment
817817
for rows.Next() {
818818
var c types.Comment
819-
if err := rows.Scan(&c.ID, &c.IssueID, &c.Author, &c.Text, &c.CreatedAt); err != nil {
819+
if err := rows.Scan(&c.ID, &c.IssueID, &c.Author, &c.Text, &c.Type, &c.CreatedAt); err != nil {
820820
return nil, wrapScanError("get comments in tx", err)
821821
}
822822
comments = append(comments, &c)

0 commit comments

Comments
 (0)