Skip to content

Commit c42190e

Browse files
robzolkosjeremy
andauthored
Show comments by default in basecamp show (#389)
* Include comments in show output by default * Remove redundant show comments flag * Fix show comment count fallback * Fix show review follow-ups * test: assert show JSON output structurally * show: cap default embedded comments * Fix show attachment notice on comment fetch errors * Harden show comment flag handling * Extract reusable comment-embedding helpers; narrow renderer filter Move comment-fetching logic from show.go into comments_embed.go so todos, messages, cards, and files can reuse addCommentFlags / fetchRecordingComments in follow-up PRs. Narrow the renderer's _attachments suffix filter to exact matches on content_attachments and description_attachments — the two synthetic keys the CLI injects — so native API fields like previewable_attachments are no longer silently dropped by `basecamp api get` styled output. * Fix withComments to use UseNumber for ID precision Match the withAttachmentMeta pattern: decode with UseNumber so IDs above 2^53 survive the struct-to-map round-trip when future callers pass typed structs. * Fix import ordering in comments_embed.go --------- Co-authored-by: Jeremy Daer <jeremy@37signals.com>
1 parent fb8b093 commit c42190e

File tree

10 files changed

+1193
-44
lines changed

10 files changed

+1193
-44
lines changed

.surface

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11548,6 +11548,7 @@ FLAG basecamp setup claude --todolist type=string
1154811548
FLAG basecamp setup claude --verbose type=count
1154911549
FLAG basecamp show --account type=string
1155011550
FLAG basecamp show --agent type=bool
11551+
FLAG basecamp show --all-comments type=bool
1155111552
FLAG basecamp show --cache-dir type=string
1155211553
FLAG basecamp show --count type=bool
1155311554
FLAG basecamp show --download-attachments type=string
@@ -11559,6 +11560,7 @@ FLAG basecamp show --jq type=string
1155911560
FLAG basecamp show --json type=bool
1156011561
FLAG basecamp show --markdown type=bool
1156111562
FLAG basecamp show --md type=bool
11563+
FLAG basecamp show --no-comments type=bool
1156211564
FLAG basecamp show --no-hints type=bool
1156311565
FLAG basecamp show --no-stats type=bool
1156411566
FLAG basecamp show --profile type=string
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
package commands
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"encoding/json"
7+
"fmt"
8+
"strconv"
9+
10+
"github.com/spf13/cobra"
11+
12+
"github.com/basecamp/basecamp-sdk/go/pkg/basecamp"
13+
14+
"github.com/basecamp/basecamp-cli/internal/appctx"
15+
"github.com/basecamp/basecamp-cli/internal/output"
16+
)
17+
18+
// commentFlags holds the parsed state of --no-comments / --all-comments.
19+
type commentFlags struct {
20+
noComments bool
21+
allComments bool
22+
}
23+
24+
// shouldFetch returns true when the caller should attempt comment fetching.
25+
func (cf *commentFlags) shouldFetch() bool {
26+
return !cf.noComments
27+
}
28+
29+
// addCommentFlags registers --no-comments and --all-comments on cmd and
30+
// returns the parsed flag holder. Follows the addDownloadAttachmentsFlag
31+
// pattern.
32+
func addCommentFlags(cmd *cobra.Command) *commentFlags {
33+
cf := &commentFlags{}
34+
cmd.Flags().BoolVar(&cf.noComments, "no-comments", false, "Skip comment fetching")
35+
cmd.Flags().BoolVar(&cf.allComments, "all-comments", false,
36+
fmt.Sprintf("Fetch all comments instead of the default %d", basecamp.DefaultCommentLimit))
37+
cmd.MarkFlagsMutuallyExclusive("no-comments", "all-comments")
38+
return cf
39+
}
40+
41+
// commentEnrichment holds everything produced by fetchRecordingComments.
42+
type commentEnrichment struct {
43+
// Comments is the fetched comment slice (nil when skipped or failed).
44+
Comments []basecamp.Comment
45+
46+
// Notice is a user-facing truncation notice (empty when all comments
47+
// were fetched or when fetching was skipped).
48+
Notice string
49+
50+
// FetchNotice is a diagnostic notice when fetching failed (empty on success).
51+
FetchNotice string
52+
53+
// Breadcrumbs are comment-related breadcrumbs to append to the response.
54+
Breadcrumbs []output.Breadcrumb
55+
56+
// CountLabel is a parenthetical like "(3 comments)" for summary augmentation.
57+
// Empty when the recording has no comments_count field.
58+
CountLabel string
59+
}
60+
61+
// fetchRecordingComments fetches comments for a recording and returns an
62+
// enrichment bundle. Handles the full lifecycle: skip check, fetch with
63+
// limit, truncation notice, failure notice, and breadcrumb generation.
64+
func fetchRecordingComments(
65+
ctx context.Context,
66+
app *appctx.App,
67+
id string,
68+
data map[string]any,
69+
cf *commentFlags,
70+
) *commentEnrichment {
71+
commentsCount, hasCommentsCount := recordingCommentsCount(data)
72+
73+
result := &commentEnrichment{}
74+
75+
if hasCommentsCount && commentsCount > 0 {
76+
result.CountLabel = pluralizeComments(commentsCount)
77+
result.Breadcrumbs = append(result.Breadcrumbs, output.Breadcrumb{
78+
Action: "comments",
79+
Cmd: fmt.Sprintf("basecamp comments list --all %s", id),
80+
Description: "View all comments",
81+
})
82+
}
83+
84+
if !cf.shouldFetch() || !hasCommentsCount || commentsCount <= 0 {
85+
return result
86+
}
87+
88+
recordingID, parseErr := strconv.ParseInt(id, 10, 64)
89+
if parseErr != nil {
90+
return result
91+
}
92+
93+
commentOpts := &basecamp.CommentListOptions{
94+
Limit: basecamp.DefaultCommentLimit,
95+
}
96+
if cf.allComments {
97+
commentOpts.Limit = -1
98+
}
99+
100+
commentsResult, commentsErr := app.Account().Comments().List(
101+
ctx, recordingID, commentOpts,
102+
)
103+
if commentsErr != nil {
104+
result.FetchNotice = commentsFetchFailedNotice(commentsCount, id)
105+
return result
106+
}
107+
108+
result.Comments = commentsResult.Comments
109+
110+
if !cf.allComments {
111+
totalComments := commentsCount
112+
if commentsResult.Meta.TotalCount > totalComments {
113+
totalComments = commentsResult.Meta.TotalCount
114+
}
115+
notice := commentsTruncationNotice(len(commentsResult.Comments), totalComments)
116+
result.Notice = notice
117+
if notice != "" {
118+
result.Breadcrumbs = append(result.Breadcrumbs, output.Breadcrumb{
119+
Action: "all-comments",
120+
Cmd: fmt.Sprintf("basecamp show --all-comments %s", id),
121+
Description: "Fetch all comments",
122+
})
123+
}
124+
}
125+
126+
return result
127+
}
128+
129+
// withComments injects the "comments" key into data. If data is already a
130+
// map[string]any it is modified in place; otherwise it is marshaled to a map
131+
// first (same pattern as withAttachmentMeta). Returns data unchanged when
132+
// comments is nil.
133+
func withComments(data any, comments []basecamp.Comment) any {
134+
if comments == nil {
135+
return data
136+
}
137+
138+
if m, ok := data.(map[string]any); ok {
139+
m["comments"] = comments
140+
return m
141+
}
142+
143+
b, err := json.Marshal(data)
144+
if err != nil {
145+
return data
146+
}
147+
// Decode with UseNumber to preserve integer precision (IDs > 2^53).
148+
dec := json.NewDecoder(bytes.NewReader(b))
149+
dec.UseNumber()
150+
var m map[string]any
151+
if err := dec.Decode(&m); err != nil {
152+
return data
153+
}
154+
m["comments"] = comments
155+
return m
156+
}
157+
158+
// applyNotices merges comment and attachment notices into response options.
159+
// Routes fetch-failure diagnostics to WithDiagnostic; normal notices to
160+
// WithNotice. attachmentNotice is folded in so it is never lost.
161+
func (ce *commentEnrichment) applyNotices(attachmentNotice string) []output.ResponseOption {
162+
var opts []output.ResponseOption
163+
164+
if ce.FetchNotice != "" {
165+
diagnostic := joinShowNotices(ce.FetchNotice, attachmentNotice)
166+
opts = append(opts, output.WithDiagnostic(diagnostic))
167+
} else {
168+
notice := joinShowNotices(ce.Notice, attachmentNotice)
169+
if notice != "" {
170+
opts = append(opts, output.WithNotice(notice))
171+
}
172+
}
173+
174+
return opts
175+
}
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
package commands
2+
3+
import (
4+
"testing"
5+
6+
"github.com/basecamp/basecamp-sdk/go/pkg/basecamp"
7+
"github.com/stretchr/testify/assert"
8+
"github.com/stretchr/testify/require"
9+
)
10+
11+
func TestWithCommentsInjectsIntoMap(t *testing.T) {
12+
data := map[string]any{
13+
"id": float64(42),
14+
"title": "Buy milk",
15+
}
16+
comments := []basecamp.Comment{
17+
{ID: 1, Content: "first"},
18+
{ID: 2, Content: "second"},
19+
}
20+
21+
result := withComments(data, comments)
22+
m, ok := result.(map[string]any)
23+
require.True(t, ok)
24+
assert.Equal(t, float64(42), m["id"])
25+
assert.Equal(t, "Buy milk", m["title"])
26+
assert.Len(t, m["comments"], 2)
27+
}
28+
29+
func TestWithCommentsNilIsNoOp(t *testing.T) {
30+
data := map[string]any{"id": float64(1)}
31+
result := withComments(data, nil)
32+
m := result.(map[string]any)
33+
_, ok := m["comments"]
34+
assert.False(t, ok, "nil comments should not inject a key")
35+
}
36+
37+
func TestCommentFlagsShouldFetch(t *testing.T) {
38+
t.Run("default", func(t *testing.T) {
39+
cf := &commentFlags{}
40+
assert.True(t, cf.shouldFetch())
41+
})
42+
43+
t.Run("no-comments", func(t *testing.T) {
44+
cf := &commentFlags{noComments: true}
45+
assert.False(t, cf.shouldFetch())
46+
})
47+
48+
t.Run("all-comments", func(t *testing.T) {
49+
cf := &commentFlags{allComments: true}
50+
assert.True(t, cf.shouldFetch())
51+
})
52+
}
53+
54+
func TestCommentEnrichmentApplyNotices(t *testing.T) {
55+
t.Run("truncation notice only", func(t *testing.T) {
56+
ce := &commentEnrichment{Notice: "Showing 10 of 50 comments"}
57+
opts := ce.applyNotices("")
58+
assert.Len(t, opts, 1)
59+
})
60+
61+
t.Run("fetch failure routes to diagnostic", func(t *testing.T) {
62+
ce := &commentEnrichment{FetchNotice: "fetching failed"}
63+
opts := ce.applyNotices("1 attachment(s)")
64+
assert.Len(t, opts, 1)
65+
})
66+
67+
t.Run("no notices produces no opts", func(t *testing.T) {
68+
ce := &commentEnrichment{}
69+
opts := ce.applyNotices("")
70+
assert.Empty(t, opts)
71+
})
72+
73+
t.Run("attachment notice only", func(t *testing.T) {
74+
ce := &commentEnrichment{}
75+
opts := ce.applyNotices("1 attachment(s)")
76+
assert.Len(t, opts, 1)
77+
})
78+
}

0 commit comments

Comments
 (0)