Skip to content

Commit 37cb84b

Browse files
authored
Say a whole-board cards list was capped in every output mode (#933)
The 100-per-column cap on a whole-board `cards list` reported itself only through an informational notice, which --agent and --quiet drop, so an agent script got a short list in silence. Make the notice a diagnostic (stderr in quiet modes, stdout untouched) and name each cut column in meta.truncated_columns with what was shown and the total.
1 parent 7f6707d commit 37cb84b

2 files changed

Lines changed: 81 additions & 8 deletions

File tree

‎internal/commands/cards.go‎

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -97,8 +97,10 @@ func newCardsListCmd(project, cardTable *string) *cobra.Command {
9797
Use: "list",
9898
Short: "List cards",
9999
Long: "List the cards in a project's card table, or across every project with --all-projects.\n\n" +
100-
"Without --column the listing covers every column, up to 100 cards from each; --all lifts\n" +
101-
"that cap. --column lists one column in full unless --limit or --page narrows it.\n\n" +
100+
"Without --column the listing covers every column but stops at 100 cards from each, leaving\n" +
101+
"the rest out; --all lifts that cap. A capped listing says so in every output mode: a notice\n" +
102+
"(on stderr with --agent or --quiet) and meta.truncated_columns in the JSON envelope.\n" +
103+
"--column lists one column in full unless --limit or --page narrows it.\n\n" +
102104
"With --all-projects the listing comes from the account-wide card aggregates, grouped by\n" +
103105
"project. --status completed, --unassigned, --no-due-date, --not-now, and --overdue each\n" +
104106
"select a different aggregate and are account-wide only.\n\n" +
@@ -626,6 +628,7 @@ func runCardsListInProject(cmd *cobra.Command, app *appctx.App, opts cardsListOp
626628
perColumn = nil
627629
}
628630
var truncated []string
631+
var truncatedColumns []map[string]any
629632
for _, col := range cardTableData.Lists {
630633
cardsResult, err := app.Account().Cards().List(cmd.Context(), col.ID, perColumn)
631634
if err != nil {
@@ -634,17 +637,24 @@ func runCardsListInProject(cmd *cobra.Command, app *appctx.App, opts cardsListOp
634637
allCards = append(allCards, cardsResult.Cards...)
635638
if cardsResult.Meta.Truncated {
636639
truncated = append(truncated, truncatedColumnLabel(col, cardsResult.Meta.TotalCount))
640+
truncatedColumns = append(truncatedColumns, truncatedColumnMeta(col, len(cardsResult.Cards), cardsResult.Meta.TotalCount))
637641
}
638642
}
639643

640644
if opts.sortField != "" {
641645
sortCards(allCards, opts.sortField, opts.reverse)
642646
}
643647

648+
// The cap drops cards the caller didn't ask to drop, so it is a
649+
// diagnostic, not an informational notice: --agent and --quiet print
650+
// data alone and would otherwise hand back a short list in silence.
644651
if len(truncated) > 0 {
645-
respOpts = append(respOpts, output.WithNotice(fmt.Sprintf(
646-
"Showing the first %d cards of %s; use --column <id> --all for every card in one column, or --all for every column",
647-
cardsPerColumnLimit, strings.Join(truncated, ", "))))
652+
respOpts = append(respOpts,
653+
output.WithDiagnostic(fmt.Sprintf(
654+
"Showing the first %d cards of %s; use --column <id> --all for every card in one column, or --all for every column",
655+
cardsPerColumnLimit, strings.Join(truncated, ", "))),
656+
output.WithMeta("truncated_columns", truncatedColumns),
657+
)
648658
}
649659
}
650660

@@ -673,6 +683,16 @@ func truncatedColumnLabel(col basecamp.CardColumn, total int) string {
673683
return col.Title
674684
}
675685

686+
// truncatedColumnMeta describes a column the aggregate listing cut short for
687+
// meta.truncated_columns; total is omitted when the API didn't report one.
688+
func truncatedColumnMeta(col basecamp.CardColumn, shown, total int) map[string]any {
689+
m := map[string]any{"id": col.ID, "title": col.Title, "shown": shown}
690+
if total > 0 {
691+
m["total"] = total
692+
}
693+
return m
694+
}
695+
676696
// countAccountWideCards totals the cards inside the project groups.
677697
func countAccountWideCards(groups []basecamp.BucketCardsGroup) int {
678698
total := 0

‎internal/commands/cards_list_columns_test.go‎

Lines changed: 56 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -87,16 +87,22 @@ func cardsJSON(firstID, count int) string {
8787
}
8888

8989
func runCardTableList(t *testing.T, format output.Format, args ...string) (*cardTableTransport, *bytes.Buffer, error) {
90+
t.Helper()
91+
transport, buf, _, err := runCardTableListStderr(t, format, args...)
92+
return transport, buf, err
93+
}
94+
95+
func runCardTableListStderr(t *testing.T, format output.Format, args ...string) (*cardTableTransport, *bytes.Buffer, *bytes.Buffer, error) {
9096
t.Helper()
9197
transport := &cardTableTransport{}
9298
app, _ := newTestAppWithTransport(t, transport)
93-
buf := &bytes.Buffer{}
94-
app.Output = output.New(output.Options{Format: format, Writer: buf, ErrWriter: &bytes.Buffer{}})
99+
buf, errBuf := &bytes.Buffer{}, &bytes.Buffer{}
100+
app.Output = output.New(output.Options{Format: format, Writer: buf, ErrWriter: errBuf})
95101
if format == output.FormatCount {
96102
app.Flags.Count = true
97103
}
98104
err := executeCommand(NewCardsCmd(), app, append([]string{"list", "--in", "123", "--card-table", "777"}, args...)...)
99-
return transport, buf, err
105+
return transport, buf, errBuf, err
100106
}
101107

102108
// TestCardsListAcrossColumnsCapsEachColumn pins that listing a whole card table
@@ -119,6 +125,53 @@ func TestCardsListAcrossColumnsCapsEachColumn(t *testing.T) {
119125
assert.Contains(t, resp.Notice, "--all")
120126
}
121127

128+
// TestCardsListAcrossColumnsCapIsMachineReadable pins that the JSON envelope
129+
// names every column the cap cut short, with what it showed and the total, so
130+
// a script can tell a capped listing from a complete one without parsing prose.
131+
func TestCardsListAcrossColumnsCapIsMachineReadable(t *testing.T) {
132+
_, buf, err := runCardTableList(t, output.FormatJSON)
133+
require.NoError(t, err)
134+
135+
var resp struct {
136+
Meta struct {
137+
TruncatedColumns []map[string]any `json:"truncated_columns"`
138+
} `json:"meta"`
139+
}
140+
require.NoError(t, json.Unmarshal(buf.Bytes(), &resp))
141+
require.Len(t, resp.Meta.TruncatedColumns, 1, "only Done is cut short")
142+
done := resp.Meta.TruncatedColumns[0]
143+
assert.EqualValues(t, 2, done["id"])
144+
assert.Equal(t, "Done", done["title"])
145+
assert.EqualValues(t, cardsPerColumnLimit, done["shown"])
146+
assert.EqualValues(t, doneColumnCards, done["total"])
147+
}
148+
149+
// TestCardsListAcrossColumnsCapIsLoudInQuietModes pins that --agent and --quiet,
150+
// which print the data alone, still say on stderr that the list was cut short,
151+
// while stdout stays the bare card array.
152+
func TestCardsListAcrossColumnsCapIsLoudInQuietModes(t *testing.T) {
153+
_, buf, errBuf, err := runCardTableListStderr(t, output.FormatQuiet)
154+
require.NoError(t, err)
155+
156+
assert.Contains(t, errBuf.String(), "notice: Showing the first 100 cards of Done (140 total)")
157+
158+
var data []map[string]any
159+
require.NoError(t, json.Unmarshal(buf.Bytes(), &data), "stdout must stay data-only")
160+
assert.Len(t, data, 3+cardsPerColumnLimit)
161+
}
162+
163+
// TestCardsListAcrossColumnsUncappedIsQuiet pins that a listing nothing cut
164+
// short carries no notice and no truncated_columns, in either channel.
165+
func TestCardsListAcrossColumnsUncappedIsQuiet(t *testing.T) {
166+
_, _, errBuf, err := runCardTableListStderr(t, output.FormatQuiet, "--all")
167+
require.NoError(t, err)
168+
assert.Empty(t, errBuf.String())
169+
170+
_, buf, err := runCardTableList(t, output.FormatJSON, "--all")
171+
require.NoError(t, err)
172+
assert.NotContains(t, buf.String(), "truncated_columns")
173+
}
174+
122175
// TestCardsListAcrossColumnsAllWalksEveryPage pins that --all, now accepted
123176
// without --column, restores the full walk of every column.
124177
func TestCardsListAcrossColumnsAllWalksEveryPage(t *testing.T) {

0 commit comments

Comments
 (0)