trail list: push filters, limit, and pagination to the server · Entire
trail list: push filters, limit, and pagination to the server
9f453fc→main·
matthiaswenz·1mo ago·3 files·+137 added/-261 removed
The list endpoint paginates (default 50 rows, max 200), but the CLI fetched it bare and filtered client-side, so only trails among the 50 most recently updated were ever visible and the shown/total counts were computed against that page instead of the real match count.
Send status, author, and limit as query params, read the server's total for the shown/total display, and drop the client-side filter/sort/limit machinery. --limit above 200 is clamped with a note when matches exceed the page. The grouped-by-status view is replaced by a flat list with a STATUS column whenever more than one status can appear, so unknown server statuses stay visible without aggregation.
findTrail (trail show/update lookups) had the same first-page blindness; as a stopgap it now requests the 200-row server max.
Co-Authored-By: Claude Fable 5 noreply@anthropic.com
Sessions
6b684484518fView transcript
[?
Align Trail Status with Server and Refactor ListClaude Code·Fable 5·3 steps](/content/gh/entireio/cli/session/e489d20f-3db8-41fd-9446-9ab6aa550405#timeline-6b684484518f/index.html)
Changes
3
cmd/entire/cli
api
Mtrail_types.go+5
Mtrail_cmd.go+92/-185
Mtrail_cmd_test.go+40/-76
6 unmodified lines
7
8
9
10
11
12
13
14
15
16
17
18
19
6 unmodified lines
)
// TrailListResponse is the response from GET /api/v1/trails/:org/:repo.
// The endpoint paginates: Trails holds one page (server max 200 rows) and
// Total is the full match count for the requested filters.
type TrailListResponse struct {
Trails []TrailResource `json:"trails"`
Total int `json:"total"`
Limit int `json:"limit"`
Offset int `json:"offset"`
RepoFullName string `json:"repo_full_name"`
DefaultBranch string `json:"default_branch"`
UpdatedAt time.Time `json:"updated_at"`
}
Mcmd/entire/cli/api/trail_types.go+5
6 unmodified lines
7
8
9
10
11
12
13
14
16 unmodified lines
31
32
33
34
35
36
37
38
39
137 unmodified lines
177
178
179
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
180
181
182
6 unmodified lines
189
190
191
218
219
192
193
194
195
196
222
223
197
198
199
200
201
226
227
228
229
230
231
232
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
14 unmodified lines
247
248
249
253
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
20 unmodified lines
305
306
307
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
308
309
310
38 unmodified lines
349
350
351
373
374
375
352
353
354
355
377
378
379
356
357
358
359
384
385
386
360
361
362
363
364
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
365
366
367
368
27 unmodified lines
396
397
398
463
399
400
401
402
403
404
405
406
407
468
469
470
408
409
410
411
412
413
414
3 unmodified lines
418
419
420
421
422
423
424
425
482
483
426
427
485
428
429
430
431
432
433
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
434
435
436
14 unmodified lines
451
452
453
534
535
536
537
538
539
540
541
454
455
456
3 unmodified lines
460
461
462
551
552
553
554
555
556
557
558
559
463
464
465
417 unmodified lines
883
884
885
983
886
887
888
889
890
891
892
893
6 unmodified lines
"fmt"
"io"
"net/http"
"net/url"
"os/exec"
"sort"
"strconv"
"strings"
"text/tabwriter"
16 unmodified lines
defaultTrailListStatus = string(trail.StatusOpen)
// trailListStatusAny disables the status filter; user-facing value for --status.
trailListStatusAny = "any"
// trailListServerMaxLimit is the most trails the server returns per
// request (the list endpoint clamps limit to 200).
trailListServerMaxLimit = 200
)
func newTrailCmd() *cobra.Command {
137 unmodified lines
if err != nil {
return err
}
client, err := NewAuthenticatedAPIClient(ctx, opts.InsecureHTTP)
if err != nil {
return fmt.Errorf("authentication required: %w", err)
}
forge, owner, repo, err := resolveTrailRemote(ctx)
if err != nil {
return err
}
resp, err := client.Get(ctx, trailsBasePath(forge, owner, repo))
if err != nil {
return fmt.Errorf("failed to list trails: %w", err)
}
defer resp.Body.Close()
if err := checkTrailResponse(resp); err != nil {
return err
}
var listResp api.TrailListResponse
if err := api.DecodeJSON(resp, &listResp); err != nil {
return fmt.Errorf("failed to decode trail list: %w", err)
}
// Convert to metadata for display
trails := make([]*trail.Metadata, 0, len(listResp.Trails))
for i := range listResp.Trails {
trails = append(trails, listResp.Trails[i].ToMetadata())
}
authorFilter := opts.Author
currentUserLogin := ""
6 unmodified lines
authorFilter = login
}
if authorFilter != "" {
trails = filterTrailsByAuthor(trails, authorFilter)
client, err := NewAuthenticatedAPIClient(ctx, opts.InsecureHTTP)
if err != nil {
return fmt.Errorf("authentication required: %w", err)
}
if len(statusFilters) > 0 {
trails = filterTrailsByStatuses(trails, statusFilters)
forge, owner, repo, err := resolveTrailRemote(ctx)
if err != nil {
return err
}
// Sort by updated_at descending, then keep only the most recent rows.
sort.Slice(trails, func(i, j int) bool {
return trails[i].UpdatedAt.After(trails[j].UpdatedAt)
})
totalMatched := len(trails)
statusTotals := trailStatusCounts(trails)
trails = limitTrails(trails, opts.Limit)
// Filtering, sorting (updated_at desc), and truncation all happen
// server-side; the response carries the total match count so a capped
// page never reads as the total number of matches.
resp, err := client.Get(ctx, trailsBasePath(forge, owner, repo)+trailListQuery(statusFilters, authorFilter, opts.Limit))
if err != nil {
return fmt.Errorf("failed to list trails: %w", err)
}
defer resp.Body.Close()
if err := checkTrailResponse(resp); err != nil {
return err
}
var listResp api.TrailListResponse
if err := api.DecodeJSON(resp, &listResp); err != nil {
return fmt.Errorf("failed to decode trail list: %w", err)
}
totalMatched := listResp.Total
if totalMatched < len(trails) {
// Older servers don't report a total; fall back to the page size.
totalMatched = len(trails)
}
if opts.JSON {
e := json.NewEncoder(w)
14 unmodified lines
CurrentUser: currentUserLogin,
StatusFilters: statusFilters,
TotalMatched: totalMatched,
StatusTotals: statusTotals,
})
if opts.Limit > trailListServerMaxLimit && totalMatched > len(trails) {
fmt.Fprintln(w)
fmt.Fprintf(w, "Note: --limit %d exceeds the server maximum of %d trails per request.\n", opts.Limit, trailListServerMaxLimit)
}
return nil
}
// trailListQuery builds the server-side filter query for the trail list
// endpoint. Empty statusFilters (--status any) omits the status param so the
// server returns all statuses; the limit is capped at the server maximum.
func trailListQuery(statusFilters []trail.Status, author string, limit int) string {
q := url.Values{}
if len(statusFilters) > 0 {
parts := make([]string, len(statusFilters))
for i, status := range statusFilters {
parts[i] = string(status)
}
q.Set("status", strings.Join(parts, ","))
}
if author != "" {
q.Set("author", author)
}
if limit > trailListServerMaxLimit {
limit = trailListServerMaxLimit
}
q.Set("limit", strconv.Itoa(limit))
return "?" + q.Encode()
}
// printTrailListEmpty renders the empty-state message. It names the active
// status filter so a bare `entire trail list` (which defaults to open)
// doesn't read as "this repo has no trails" when trails exist in other
20 unmodified lines
fmt.Fprintln(w, " entire trail update Update trail metadata")
}
func limitTrails(trails []*trail.Metadata, limit int) []*trail.Metadata {
if len(trails) <= limit {
return trails
}
return trails[:limit]
}
// filterTrailsByAuthor matches case-insensitively because GitHub logins are
// case-insensitive (e.g. "Alice" and "alice" identify the same user).
func filterTrailsByAuthor(trails []*trail.Metadata, login string) []*trail.Metadata {
var filtered []*trail.Metadata
for _, t := range trails {
if strings.EqualFold(t.AuthorLogin(), login) {
filtered = append(filtered, t)
}
}
return filtered
}
func filterTrailsByStatus(trails []*trail.Metadata, status trail.Status) []*trail.Metadata {
var filtered []*trail.Metadata
for _, t := range trails {
if t.Status == status {
filtered = append(filtered, t)
}
}
return filtered
}
func filterTrailsByStatuses(trails []*trail.Metadata, statuses []trail.Status) []*trail.Metadata {
statusSet := make(map[trail.Status]bool, len(statuses))
for _, status := range statuses {
statusSet[status] = true
}
var filtered []*trail.Metadata
for _, t := range trails {
if statusSet[t.Status] {
filtered = append(filtered, t)
}
}
return filtered
}
func parseTrailStatusFilter(filter string) ([]trail.Status, error) {
if filter == "" || filter == trailListStatusAny {
return nil, nil
}
38 unmodified lines
RequestedAuthor string
CurrentUser string
StatusFilters []trail.Status
// TotalMatched is the number of trails matching the filters before
// --limit truncation. Counts render as "shown/total" when they differ so
// a full page doesn't read as the total number of matches.
// TotalMatched is the number of trails matching the filters server-side,
// before --limit truncation. Counts render as "shown/total" when they
// differ so a capped page doesn't read as the total number of matches.
TotalMatched int
// StatusTotals are the pre-truncation per-status counts backing the
// group headers in the grouped view.
StatusTotals map[trail.Status]int
}
func printTrailList(w io.Writer, trails []*trail.Metadata, opts trailListDisplayOptions) {
showAuthor := opts.RequestedAuthor == ""
// Group by status when the user filtered for 0 or 2+ statuses. A single
// status is already named in the header, so flat rows read more cleanly.
grouped := len(opts.StatusFilters) != 1
// Show the status column unless exactly one status is filtered — that
// status is already named in the header.
showStatus := len(opts.StatusFilters) != 1
printTrailListHeader(w, opts, len(trails))
fmt.Fprintln(w)
if !grouped {
printTrailRows(w, trails, showAuthor)
return
}
rendered := make(map[*trail.Metadata]bool, len(trails))
for _, status := range trailListStatusOrder(opts.StatusFilters) {
group := filterTrailsByStatus(trails, status)
if len(group) == 0 {
continue
}
for _, t := range group {
rendered[t] = true
}
fmt.Fprintf(w, " %s · %s\n", trailStatusTitle(status), trailCountDisplay(len(group), opts.StatusTotals[status]))
fmt.Fprintln(w)
printTrailRows(w, group, showAuthor)
fmt.Fprintln(w)
}
// When no explicit status filter is set, surface trails with unknown
// statuses in an "Other" bucket so they don't silently disappear if the
// server adds a status the CLI hasn't learned about yet.
if len(opts.StatusFilters) == 0 {
var other []*trail.Metadata
for _, t := range trails {
if !rendered[t] {
other = append(other, t)
}
}
if len(other) > 0 {
otherTotal := opts.TotalMatched
for _, status := range trailListStatusOrder(nil) {
otherTotal -= opts.StatusTotals[status]
}
fmt.Fprintf(w, " Other · %s\n", trailCountDisplay(len(other), otherTotal))
fmt.Fprintln(w)
printTrailRows(w, other, showAuthor)
fmt.Fprintln(w)
}
}
printTrailRows(w, trails, showAuthor, showStatus)
}
func printTrailListHeader(w io.Writer, opts trailListDisplayOptions, count int) {
27 unmodified lines
fmt.Fprintf(w, " %s · %s %s\n", label, countStr, trailStatusListDisplay(opts.StatusFilters))
}
func printTrailRows(w io.Writer, trails []*trail.Metadata, showAuthor bool) {
func printTrailRows(w io.Writer, trails []*trail.Metadata, showAuthor, showStatus bool) {
// tabwriter aligns by display columns instead of bytes, so multi-byte
// branch names or logins don't throw off the table.
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
columns := []string{"NUM", "BRANCH", "TITLE"}
if showStatus {
columns = append(columns, "STATUS")
}
if showAuthor {
fmt.Fprintln(tw, " NUM\tBRANCH\tTITLE\tAUTHOR\tUPDATED")
} else {
fmt.Fprintln(tw, " NUM\tBRANCH\tTITLE\tUPDATED")
columns = append(columns, "AUTHOR")
}
columns = append(columns, "UPDATED")
fmt.Fprintln(tw, " "+strings.Join(columns, "\t"))
for _, t := range trails {
number := "-"
if t.Number > 0 {
3 unmodified lines
if title == "" {
title = "(untitled)"
}
fields := []string{number, t.Branch, title}
if showStatus {
fields = append(fields, trailStatusDisplay(t.Status))
}
if showAuthor {
fmt.Fprintf(tw, " %s\t%s\t%s\t%s\t%s\n", number, t.Branch, title, t.AuthorLogin(), timeAgo(t.UpdatedAt))
continue
fields = append(fields, t.AuthorLogin())
}
fmt.Fprintf(tw, " %s\t%s\t%s\t%s\n", number, t.Branch, title, timeAgo(t.UpdatedAt))
fields = append(fields, timeAgo(t.UpdatedAt))
fmt.Fprintln(tw, " "+strings.Join(fields, "\t"))
}
_ = tw.Flush()
}
func trailListStatusOrder(filter []trail.Status) []trail.Status {
order := []trail.Status{
trail.StatusOpen,
trail.StatusDraft,
trail.StatusMerged,
trail.StatusClosed,
}
if len(filter) == 0 {
return order
}
allowed := make(map[trail.Status]bool, len(filter))
for _, status := range filter {
allowed[status] = true
}
var filtered []trail.Status
for _, status := range order {
if allowed[status] {
filtered = append(filtered, status)
}
}
return filtered
}
func trailStatusListDisplay(statuses []trail.Status) string {
parts := make([]string, len(statuses))
for i, status := range statuses {
14 unmodified lines
return strings.ReplaceAll(string(status), "_", " ")
}
func trailStatusTitle(status trail.Status) string {
display := trailStatusDisplay(status)
if display == "" {
return ""
}
return strings.ToUpper(display[:1]) + display[1:]
}
// trailCountDisplay renders a count as "shown/total" when --limit truncated
// the list, so a capped page doesn't read as the total number of matches.
func trailCountDisplay(shown, total int) string {
3 unmodified lines
return strconv.Itoa(shown)
}
// trailStatusCounts tallies trails per status before --limit truncation.
func trailStatusCounts(trails []*trail.Metadata) map[trail.Status]int {
counts := make(map[trail.Status]int, len(trails))
for _, t := range trails {
counts[t.Status]++
}
return counts
}
func pluralize(s string, count int) string {
if count == 1 {
return s
417 unmodified lines
}
}
func findTrail(ctx context.Context, client *api.Client, forge, owner, repo string, match func(api.TrailResource) bool) (*api.TrailResource, error) {
resp, err := client.Get(ctx, trailsBasePath(forge, owner, repo))
// The list endpoint paginates (default 50 rows); request the server max
// so lookups don't miss less recently updated trails. Trails beyond the
// first 200 are still invisible here — fixing that needs a server-side
// branch filter or the by-number detail endpoint.
resp, err := client.Get(ctx, trailsBasePath(forge, owner, repo)+trailListQuery(nil, "", trailListServerMaxLimit))
if err != nil {
return nil, fmt.Errorf("list trails: %w", err)
}