From ee8d7d0a6933efc645c996f6b2935da4d2e6ab3a Mon Sep 17 00:00:00 2001 From: Matthias Wenz Date: Fri, 5 Jun 2026 22:53:23 +0200 Subject: [PATCH 1/4] trail list: name active status filter in empty state `entire trail list` defaults to the in_progress status filter, so a repo with trails in other statuses (open, merged, etc.) rendered a bare "No trails found." that read as "this repo has no trails." Extract the empty-state into printTrailListEmpty, which names the active status filter and the author when set, and hints at `--status any` whenever a status filter narrowed the results. Co-Authored-By: Claude Opus 4.8 (1M context) --- cmd/entire/cli/trail_cmd.go | 33 ++++++++++++++++++++----- cmd/entire/cli/trail_cmd_test.go | 42 ++++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 6 deletions(-) diff --git a/cmd/entire/cli/trail_cmd.go b/cmd/entire/cli/trail_cmd.go index 0ba6f4a92a..dbe39bc8c0 100644 --- a/cmd/entire/cli/trail_cmd.go +++ b/cmd/entire/cli/trail_cmd.go @@ -236,12 +236,7 @@ func runTrailListAll(ctx context.Context, w io.Writer, opts trailListOptions) er } if len(trails) == 0 { - fmt.Fprintln(w, "No trails found.") - fmt.Fprintln(w) - fmt.Fprintln(w, "Commands:") - fmt.Fprintln(w, " entire trail create Create a trail for the current branch") - fmt.Fprintln(w, " entire trail list List recent trails") - fmt.Fprintln(w, " entire trail update Update trail metadata") + printTrailListEmpty(w, authorFilter, statusFilters) return nil } @@ -254,6 +249,32 @@ func runTrailListAll(ctx context.Context, w io.Writer, opts trailListOptions) er return nil } +// printTrailListEmpty renders the empty-state message. It names the active +// status filter so a bare `entire trail list` (which defaults to in_progress) +// doesn't read as "this repo has no trails" when trails exist in other +// statuses. statusFilters is empty when the user passed --status any. +func printTrailListEmpty(w io.Writer, authorFilter string, statusFilters []trail.Status) { + desc := "No trails found" + if len(statusFilters) > 0 { + desc = fmt.Sprintf("No %s trails found", trailStatusListDisplay(statusFilters)) + } + if authorFilter != "" { + desc += " for " + authorFilter + } + fmt.Fprintf(w, "%s.\n", desc) + + if len(statusFilters) > 0 { + fmt.Fprintln(w) + fmt.Fprintln(w, "Use --status any to see trails in other statuses.") + } + + fmt.Fprintln(w) + fmt.Fprintln(w, "Commands:") + fmt.Fprintln(w, " entire trail create Create a trail for the current branch") + fmt.Fprintln(w, " entire trail list List recent trails") + fmt.Fprintln(w, " entire trail update Update trail metadata") +} + func limitTrails(trails []*trail.Metadata, limit int) []*trail.Metadata { if len(trails) <= limit { return trails diff --git a/cmd/entire/cli/trail_cmd_test.go b/cmd/entire/cli/trail_cmd_test.go index 1007df2a48..64db91f8c7 100644 --- a/cmd/entire/cli/trail_cmd_test.go +++ b/cmd/entire/cli/trail_cmd_test.go @@ -313,6 +313,48 @@ func TestPrintTrailListUnknownStatusGroupedInOtherBucket(t *testing.T) { } } +func TestPrintTrailListEmptyDefaultStatusNamesFilterAndHints(t *testing.T) { + t.Parallel() + var out bytes.Buffer + printTrailListEmpty(&out, "", []trail.Status{trail.StatusInProgress}) + + text := out.String() + for _, want := range []string{ + "No in progress trails found.", + "Use --status any to see trails in other statuses.", + "entire trail create", + } { + if !strings.Contains(text, want) { + t.Fatalf("output missing %q, got:\n%s", want, text) + } + } +} + +func TestPrintTrailListEmptyAnyStatusOmitsHint(t *testing.T) { + t.Parallel() + var out bytes.Buffer + printTrailListEmpty(&out, "", nil) + + text := out.String() + if !strings.Contains(text, "No trails found.") { + t.Fatalf("expected generic empty message, got:\n%s", text) + } + if strings.Contains(text, "--status any") { + t.Fatalf("should not hint --status any when no status filter is active, got:\n%s", text) + } +} + +func TestPrintTrailListEmptyIncludesAuthor(t *testing.T) { + t.Parallel() + var out bytes.Buffer + printTrailListEmpty(&out, trailListTestAuthorAlice, []trail.Status{trail.StatusInProgress}) + + text := out.String() + if !strings.Contains(text, "No in progress trails found for alice.") { + t.Fatalf("expected author in empty message, got:\n%s", text) + } +} + func TestFetchCurrentUserLoginReturnsLogin(t *testing.T) { t.Parallel() r := newFakeRunner() From 4767f3dc4187d39fe18d851b70f31acd34baa9cc Mon Sep 17 00:00:00 2001 From: Matthias Wenz Date: Fri, 12 Jun 2026 23:56:30 +0900 Subject: [PATCH 2/4] trail: align status values with the server's simplified set The server folded in_progress and in_review into open (migration 20260603120300_simplify_repo_trail_status); the API now accepts and emits exactly draft, open, merged, closed. Drop the two retired statuses from the trail package and default `trail list` to open instead of in_progress, which the server can no longer return. Co-Authored-By: Claude Fable 5 Entire-Checkpoint: 0a6a3860cd0c --- cmd/entire/cli/trail/store_test.go | 12 +++--- cmd/entire/cli/trail/trail.go | 15 ++++--- cmd/entire/cli/trail/trail_test.go | 11 +++--- cmd/entire/cli/trail_cmd.go | 6 +-- cmd/entire/cli/trail_cmd_test.go | 52 +++++++++++++------------ cmd/entire/cli/trail_review_cmd_test.go | 4 +- 6 files changed, 51 insertions(+), 49 deletions(-) diff --git a/cmd/entire/cli/trail/store_test.go b/cmd/entire/cli/trail/store_test.go index 1d6227637d..e08d785516 100644 --- a/cmd/entire/cli/trail/store_test.go +++ b/cmd/entire/cli/trail/store_test.go @@ -256,7 +256,7 @@ func TestStore_Update(t *testing.T) { // Update if err := store.Update(context.Background(), id, func(m *Metadata) { m.Title = "Updated" - m.Status = StatusInProgress + m.Status = StatusOpen m.Labels = []string{"urgent"} }); err != nil { t.Fatalf("Update() error = %v", err) @@ -270,8 +270,8 @@ func TestStore_Update(t *testing.T) { if updated.Title != "Updated" { t.Errorf("Read() title = %q, want %q", updated.Title, "Updated") } - if updated.Status != StatusInProgress { - t.Errorf("Read() status = %q, want %q", updated.Status, StatusInProgress) + if updated.Status != StatusOpen { + t.Errorf("Read() status = %q, want %q", updated.Status, StatusOpen) } if len(updated.Labels) != 1 || updated.Labels[0] != "urgent" { t.Errorf("Read() labels = %v, want [urgent]", updated.Labels) @@ -387,7 +387,7 @@ func TestStore_AddCheckpointPreservesOtherFields(t *testing.T) { Base: "main", Title: "Preservation test", Body: "Verify AddCheckpoint doesn't corrupt other fields", - Status: StatusInProgress, + Status: StatusOpen, Author: &Author{ID: "1", Login: strPtr("tester")}, Assignees: []string{"alice"}, Labels: []string{"important"}, @@ -427,8 +427,8 @@ func TestStore_AddCheckpointPreservesOtherFields(t *testing.T) { if gotMeta.Body != "Verify AddCheckpoint doesn't corrupt other fields" { t.Errorf("metadata body changed: got %q", gotMeta.Body) } - if gotMeta.Status != StatusInProgress { - t.Errorf("metadata status changed: got %q, want %q", gotMeta.Status, StatusInProgress) + if gotMeta.Status != StatusOpen { + t.Errorf("metadata status changed: got %q, want %q", gotMeta.Status, StatusOpen) } if len(gotMeta.Assignees) != 1 || gotMeta.Assignees[0] != "alice" { t.Errorf("metadata assignees changed: got %v", gotMeta.Assignees) diff --git a/cmd/entire/cli/trail/trail.go b/cmd/entire/cli/trail/trail.go index 3e970e8ede..19a2e1bb17 100644 --- a/cmd/entire/cli/trail/trail.go +++ b/cmd/entire/cli/trail/trail.go @@ -73,13 +73,14 @@ func (id ID) ShardParts() (shard, suffix string) { // Status represents the lifecycle status of a trail. type Status string +// The status set mirrors the server's repo_trails check constraint +// ('draft', 'open', 'merged', 'closed'). The former in_progress and +// in_review statuses were folded into open server-side. const ( - StatusDraft Status = "draft" - StatusOpen Status = "open" - StatusInProgress Status = "in_progress" - StatusInReview Status = "in_review" - StatusMerged Status = "merged" - StatusClosed Status = "closed" + StatusDraft Status = "draft" + StatusOpen Status = "open" + StatusMerged Status = "merged" + StatusClosed Status = "closed" ) // ValidStatuses returns all valid trail statuses in lifecycle order. @@ -87,8 +88,6 @@ func ValidStatuses() []Status { return []Status{ StatusDraft, StatusOpen, - StatusInProgress, - StatusInReview, StatusMerged, StatusClosed, } diff --git a/cmd/entire/cli/trail/trail_test.go b/cmd/entire/cli/trail/trail_test.go index 7174a17cc3..7686b7fb6c 100644 --- a/cmd/entire/cli/trail/trail_test.go +++ b/cmd/entire/cli/trail/trail_test.go @@ -106,10 +106,11 @@ func TestStatus_IsValid(t *testing.T) { }{ {StatusDraft, true}, {StatusOpen, true}, - {StatusInProgress, true}, - {StatusInReview, true}, {StatusMerged, true}, {StatusClosed, true}, + // Retired server-side (folded into open); no longer accepted. + {"in_progress", false}, + {"in_review", false}, {"invalid", false}, {"", false}, } @@ -128,11 +129,11 @@ func TestValidStatuses(t *testing.T) { t.Parallel() statuses := ValidStatuses() - if len(statuses) != 6 { - t.Errorf("expected 6 statuses, got %d", len(statuses)) + if len(statuses) != 4 { + t.Errorf("expected 4 statuses, got %d", len(statuses)) } // Verify lifecycle order - expected := []Status{StatusDraft, StatusOpen, StatusInProgress, StatusInReview, StatusMerged, StatusClosed} + expected := []Status{StatusDraft, StatusOpen, StatusMerged, StatusClosed} for i, s := range expected { if statuses[i] != s { t.Errorf("status[%d] = %q, want %q", i, statuses[i], s) diff --git a/cmd/entire/cli/trail_cmd.go b/cmd/entire/cli/trail_cmd.go index 84ab4f0d5c..dd0c387f03 100644 --- a/cmd/entire/cli/trail_cmd.go +++ b/cmd/entire/cli/trail_cmd.go @@ -28,7 +28,7 @@ import ( const ( defaultTrailListLimit = 10 trailListAuthorMe = "me" - defaultTrailListStatus = string(trail.StatusInProgress) + defaultTrailListStatus = string(trail.StatusOpen) // trailListStatusAny disables the status filter; user-facing value for --status. trailListStatusAny = "any" ) @@ -253,7 +253,7 @@ func runTrailListAll(ctx context.Context, w io.Writer, opts trailListOptions) er } // printTrailListEmpty renders the empty-state message. It names the active -// status filter so a bare `entire trail list` (which defaults to in_progress) +// 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 // statuses. statusFilters is empty when the user passed --status any. func printTrailListEmpty(w io.Writer, authorFilter string, statusFilters []trail.Status) { @@ -467,9 +467,7 @@ func printTrailRows(w io.Writer, trails []*trail.Metadata, showAuthor bool) { func trailListStatusOrder(filter []trail.Status) []trail.Status { order := []trail.Status{ - trail.StatusInProgress, trail.StatusOpen, - trail.StatusInReview, trail.StatusDraft, trail.StatusMerged, trail.StatusClosed, diff --git a/cmd/entire/cli/trail_cmd_test.go b/cmd/entire/cli/trail_cmd_test.go index 64db91f8c7..523684f977 100644 --- a/cmd/entire/cli/trail_cmd_test.go +++ b/cmd/entire/cli/trail_cmd_test.go @@ -138,11 +138,11 @@ func TestFilterTrailsByAuthorIsCaseInsensitive(t *testing.T) { func TestParseTrailStatusFilterAcceptsCommaSeparatedStatuses(t *testing.T) { t.Parallel() - got, err := parseTrailStatusFilter("in_progress, open,closed") + got, err := parseTrailStatusFilter("draft, open,closed") if err != nil { t.Fatalf("parseTrailStatusFilter: %v", err) } - want := []trail.Status{trail.StatusInProgress, trail.StatusOpen, trail.StatusClosed} + want := []trail.Status{trail.StatusDraft, trail.StatusOpen, trail.StatusClosed} if len(got) != len(want) { t.Fatalf("len = %d, want %d", len(got), len(want)) } @@ -155,9 +155,13 @@ func TestParseTrailStatusFilterAcceptsCommaSeparatedStatuses(t *testing.T) { func TestParseTrailStatusFilterRejectsInvalidStatus(t *testing.T) { t.Parallel() - if _, err := parseTrailStatusFilter("in_progress,nope"); err == nil { + if _, err := parseTrailStatusFilter("open,nope"); err == nil { t.Fatal("expected invalid status error") } + // in_progress was retired server-side and must no longer parse. + if _, err := parseTrailStatusFilter("in_progress"); err == nil { + t.Fatal("expected invalid status error for retired in_progress") + } } func TestParseTrailStatusFilterAnySentinelMeansNoFilter(t *testing.T) { @@ -178,17 +182,17 @@ func TestPrintTrailListDefaultRepoShapeShowsAuthor(t *testing.T) { printTrailList(&out, []*trail.Metadata{ { Branch: "feat/repo-wide", - Status: trail.StatusInProgress, + Status: trail.StatusOpen, Author: &trail.Author{Login: &alice}, UpdatedAt: time.Now(), }, }, trailListDisplayOptions{ RequestedAuthor: "", - StatusFilters: []trail.Status{trail.StatusInProgress}, + StatusFilters: []trail.Status{trail.StatusOpen}, }) text := out.String() - for _, want := range []string{"In progress · 1 trail", "feat/repo-wide", trailListTestAuthorAlice} { + for _, want := range []string{"Open · 1 trail", "feat/repo-wide", trailListTestAuthorAlice} { if !strings.Contains(text, want) { t.Fatalf("output missing %q, got:\n%s", want, text) } @@ -204,17 +208,17 @@ func TestPrintTrailListAuthorFilteredShapeHidesAuthor(t *testing.T) { printTrailList(&out, []*trail.Metadata{ { Branch: longBranch, - Status: trail.StatusInProgress, + Status: trail.StatusOpen, Author: &trail.Author{Login: &alice}, UpdatedAt: time.Now().Add(-24 * time.Hour), }, }, trailListDisplayOptions{ RequestedAuthor: trailListTestAuthorAlice, - StatusFilters: []trail.Status{trail.StatusInProgress}, + StatusFilters: []trail.Status{trail.StatusOpen}, }) text := out.String() - if !strings.Contains(text, "alice · 1 in progress") { + if !strings.Contains(text, "alice · 1 open") { t.Fatalf("output should contain author/status header, got:\n%s", text) } if !strings.Contains(text, longBranch) { @@ -232,18 +236,18 @@ func TestPrintTrailListYourTrailsRelabelsAndSurfacesGhLogin(t *testing.T) { printTrailList(&out, []*trail.Metadata{ { Branch: "feat/x", - Status: trail.StatusInProgress, + Status: trail.StatusOpen, Author: &trail.Author{Login: &mixedCase}, UpdatedAt: time.Now(), }, }, trailListDisplayOptions{ RequestedAuthor: "alice", CurrentUser: "alice", - StatusFilters: []trail.Status{trail.StatusInProgress}, + StatusFilters: []trail.Status{trail.StatusOpen}, }) text := out.String() - if !strings.Contains(text, "Your trails (alice) · 1 in progress") { + if !strings.Contains(text, "Your trails (alice) · 1 open") { t.Fatalf("expected 'Your trails (alice)' header, got:\n%s", text) } } @@ -254,18 +258,18 @@ func TestPrintTrailListAnyAuthorAnyStatusGroupsByStatus(t *testing.T) { bob := trailListTestAuthorBob var out bytes.Buffer printTrailList(&out, []*trail.Metadata{ - {Branch: "feat/a", Status: trail.StatusInProgress, Author: &trail.Author{Login: &alice}, UpdatedAt: time.Now()}, - {Branch: "fix/b", Status: trail.StatusOpen, Author: &trail.Author{Login: &bob}, UpdatedAt: time.Now()}, + {Branch: "feat/a", Status: trail.StatusOpen, Author: &trail.Author{Login: &alice}, UpdatedAt: time.Now()}, + {Branch: "fix/b", Status: trail.StatusDraft, Author: &trail.Author{Login: &bob}, UpdatedAt: time.Now()}, }, trailListDisplayOptions{ RequestedAuthor: "", StatusFilters: nil, }) text := out.String() - if strings.Index(text, "In progress · 1") > strings.Index(text, "Open · 1") { - t.Fatalf("expected in-progress group before open group, got:\n%s", text) + if strings.Index(text, "Open · 1") > strings.Index(text, "Draft · 1") { + t.Fatalf("expected open group before draft group, got:\n%s", text) } - for _, want := range []string{"Recent trails · 2", "In progress · 1", "Open · 1", "feat/a", trailListTestAuthorAlice, "fix/b", trailListTestAuthorBob} { + for _, want := range []string{"Recent trails · 2", "Open · 1", "Draft · 1", "feat/a", trailListTestAuthorAlice, "fix/b", trailListTestAuthorBob} { if !strings.Contains(text, want) { t.Fatalf("output missing %q, got:\n%s", want, text) } @@ -277,7 +281,7 @@ func TestPrintTrailListSingularRecentTrailWhenOne(t *testing.T) { alice := trailListTestAuthorAlice var out bytes.Buffer printTrailList(&out, []*trail.Metadata{ - {Branch: "feat/a", Status: trail.StatusInProgress, Author: &trail.Author{Login: &alice}, UpdatedAt: time.Now()}, + {Branch: "feat/a", Status: trail.StatusOpen, Author: &trail.Author{Login: &alice}, UpdatedAt: time.Now()}, }, trailListDisplayOptions{ RequestedAuthor: "", StatusFilters: nil, @@ -298,7 +302,7 @@ func TestPrintTrailListUnknownStatusGroupedInOtherBucket(t *testing.T) { unknownStatus := trail.Status("experimental_review") var out bytes.Buffer printTrailList(&out, []*trail.Metadata{ - {Branch: "feat/known", Status: trail.StatusInProgress, Author: &trail.Author{Login: &alice}, UpdatedAt: time.Now()}, + {Branch: "feat/known", Status: trail.StatusOpen, Author: &trail.Author{Login: &alice}, UpdatedAt: time.Now()}, {Branch: "feat/odd", Status: unknownStatus, Author: &trail.Author{Login: &alice}, UpdatedAt: time.Now()}, }, trailListDisplayOptions{ RequestedAuthor: "", @@ -306,7 +310,7 @@ func TestPrintTrailListUnknownStatusGroupedInOtherBucket(t *testing.T) { }) text := out.String() - for _, want := range []string{"Recent trails · 2", "In progress · 1", "Other · 1", "feat/odd"} { + for _, want := range []string{"Recent trails · 2", "Open · 1", "Other · 1", "feat/odd"} { if !strings.Contains(text, want) { t.Fatalf("output missing %q, got:\n%s", want, text) } @@ -316,11 +320,11 @@ func TestPrintTrailListUnknownStatusGroupedInOtherBucket(t *testing.T) { func TestPrintTrailListEmptyDefaultStatusNamesFilterAndHints(t *testing.T) { t.Parallel() var out bytes.Buffer - printTrailListEmpty(&out, "", []trail.Status{trail.StatusInProgress}) + printTrailListEmpty(&out, "", []trail.Status{trail.StatusOpen}) text := out.String() for _, want := range []string{ - "No in progress trails found.", + "No open trails found.", "Use --status any to see trails in other statuses.", "entire trail create", } { @@ -347,10 +351,10 @@ func TestPrintTrailListEmptyAnyStatusOmitsHint(t *testing.T) { func TestPrintTrailListEmptyIncludesAuthor(t *testing.T) { t.Parallel() var out bytes.Buffer - printTrailListEmpty(&out, trailListTestAuthorAlice, []trail.Status{trail.StatusInProgress}) + printTrailListEmpty(&out, trailListTestAuthorAlice, []trail.Status{trail.StatusOpen}) text := out.String() - if !strings.Contains(text, "No in progress trails found for alice.") { + if !strings.Contains(text, "No open trails found for alice.") { t.Fatalf("expected author in empty message, got:\n%s", text) } } diff --git a/cmd/entire/cli/trail_review_cmd_test.go b/cmd/entire/cli/trail_review_cmd_test.go index 52b1718d15..b45379670d 100644 --- a/cmd/entire/cli/trail_review_cmd_test.go +++ b/cmd/entire/cli/trail_review_cmd_test.go @@ -309,7 +309,7 @@ func TestPrintTrailReviewDashboard(t *testing.T) { ID: "trl_1", Number: 42, Title: "Add token refresh", - Status: "in_review", + Status: "open", Branch: "feat/token-refresh", Base: "main", }}, comments, false, defaultTrailReviewListOptions(), countTrailReviewComments(comments)) @@ -340,7 +340,7 @@ func TestPrintTrailReviewDashboard_UsesSeparateCountsWhenFilteredCommentsEmpty(t ID: "trl_1", Number: 42, Title: "Add token refresh", - Status: "in_review", + Status: "open", Branch: "feat/token-refresh", Base: "main", }}, nil, false, defaultTrailReviewListOptions(), counts) From adfe5a07b25bf734d5c160a53101fb5f13910928 Mon Sep 17 00:00:00 2001 From: Matthias Wenz Date: Sat, 13 Jun 2026 00:23:23 +0900 Subject: [PATCH 3/4] trail list: show shown/total counts when --limit truncates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A capped page rendered "Recent trails · 10", reading as the total number of matches rather than the page size. Header, per-status group counts, and the Other bucket now render "shown/total" when --limit dropped rows, and the header noun pluralizes by the total. Also fix the --json flag help, which claimed only --status is respected. Co-Authored-By: Claude Fable 5 Entire-Checkpoint: 72bbfd94df45 --- cmd/entire/cli/trail_cmd.go | 54 +++++++++++++++++++--- cmd/entire/cli/trail_cmd_test.go | 78 ++++++++++++++++++++++++++++++++ 2 files changed, 125 insertions(+), 7 deletions(-) diff --git a/cmd/entire/cli/trail_cmd.go b/cmd/entire/cli/trail_cmd.go index dd0c387f03..93a99dcdb3 100644 --- a/cmd/entire/cli/trail_cmd.go +++ b/cmd/entire/cli/trail_cmd.go @@ -160,7 +160,7 @@ func newTrailListCmd() *cobra.Command { "Filter by author login (case-insensitive); use '"+trailListAuthorMe+"' for yourself (requires gh CLI); omit for any author") cmd.Flags().StringVar(&opts.Status, "status", defaultTrailListStatus, "Filter by comma-separated status(es): "+formatValidStatuses()+"; use '"+trailListStatusAny+"' for all statuses") - cmd.Flags().BoolVar(&opts.JSON, "json", false, "Output as JSON (respects --status filter)") + cmd.Flags().BoolVar(&opts.JSON, "json", false, "Output as JSON (respects --author, --status, and --limit)") cmd.Flags().IntVarP(&opts.Limit, "limit", "n", defaultTrailListLimit, "Maximum number of trails to show") return cmd @@ -227,6 +227,8 @@ func runTrailListAll(ctx context.Context, w io.Writer, opts trailListOptions) er 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) if opts.JSON { @@ -247,6 +249,8 @@ func runTrailListAll(ctx context.Context, w io.Writer, opts trailListOptions) er RequestedAuthor: authorFilter, CurrentUser: currentUserLogin, StatusFilters: statusFilters, + TotalMatched: totalMatched, + StatusTotals: statusTotals, }) return nil @@ -366,6 +370,13 @@ type trailListDisplayOptions struct { 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 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) { @@ -389,7 +400,7 @@ func printTrailList(w io.Writer, trails []*trail.Metadata, opts trailListDisplay for _, t := range group { rendered[t] = true } - fmt.Fprintf(w, " %s · %d\n", trailStatusTitle(status), len(group)) + fmt.Fprintf(w, " %s · %s\n", trailStatusTitle(status), trailCountDisplay(len(group), opts.StatusTotals[status])) fmt.Fprintln(w) printTrailRows(w, group, showAuthor) fmt.Fprintln(w) @@ -406,7 +417,11 @@ func printTrailList(w io.Writer, trails []*trail.Metadata, opts trailListDisplay } } if len(other) > 0 { - fmt.Fprintf(w, " Other · %d\n", len(other)) + 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) @@ -415,12 +430,19 @@ func printTrailList(w io.Writer, trails []*trail.Metadata, opts trailListDisplay } func printTrailListHeader(w io.Writer, opts trailListDisplayOptions, count int) { + countStr := trailCountDisplay(count, opts.TotalMatched) + // The noun refers to the full match set, so pluralize by the total when + // the page is truncated ("1/2 trails", not "1/2 trail"). + nounCount := count + if opts.TotalMatched > count { + nounCount = opts.TotalMatched + } if opts.RequestedAuthor == "" { if len(opts.StatusFilters) == 0 { - fmt.Fprintf(w, " Recent %s · %d\n", pluralize("trail", count), count) + fmt.Fprintf(w, " Recent %s · %s\n", pluralize("trail", nounCount), countStr) return } - fmt.Fprintf(w, " %s · %d %s\n", trailStatusListTitle(opts.StatusFilters), count, pluralize("trail", count)) + fmt.Fprintf(w, " %s · %s %s\n", trailStatusListTitle(opts.StatusFilters), countStr, pluralize("trail", nounCount)) return } @@ -432,10 +454,10 @@ func printTrailListHeader(w io.Writer, opts trailListDisplayOptions, count int) label = fmt.Sprintf("Your trails (%s)", opts.CurrentUser) } if len(opts.StatusFilters) == 0 { - fmt.Fprintf(w, " %s · %d\n", label, count) + fmt.Fprintf(w, " %s · %s\n", label, countStr) return } - fmt.Fprintf(w, " %s · %d %s\n", label, count, trailStatusListDisplay(opts.StatusFilters)) + fmt.Fprintf(w, " %s · %s %s\n", label, countStr, trailStatusListDisplay(opts.StatusFilters)) } func printTrailRows(w io.Writer, trails []*trail.Metadata, showAuthor bool) { @@ -517,6 +539,24 @@ func trailStatusTitle(status trail.Status) string { 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 { + if total > shown { + return fmt.Sprintf("%d/%d", shown, total) + } + 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 diff --git a/cmd/entire/cli/trail_cmd_test.go b/cmd/entire/cli/trail_cmd_test.go index 523684f977..d1957671de 100644 --- a/cmd/entire/cli/trail_cmd_test.go +++ b/cmd/entire/cli/trail_cmd_test.go @@ -317,6 +317,84 @@ func TestPrintTrailListUnknownStatusGroupedInOtherBucket(t *testing.T) { } } +func TestPrintTrailListTruncatedShowsShownOfTotal(t *testing.T) { + t.Parallel() + alice := trailListTestAuthorAlice + var out bytes.Buffer + printTrailList(&out, []*trail.Metadata{ + {Branch: "feat/a", Status: trail.StatusOpen, Author: &trail.Author{Login: &alice}, UpdatedAt: time.Now()}, + }, trailListDisplayOptions{ + RequestedAuthor: "", + StatusFilters: nil, + TotalMatched: 5, + StatusTotals: map[trail.Status]int{trail.StatusOpen: 4, trail.StatusDraft: 1}, + }) + + text := out.String() + for _, want := range []string{"Recent trails · 1/5", "Open · 1/4"} { + if !strings.Contains(text, want) { + t.Fatalf("output missing %q, got:\n%s", want, text) + } + } +} + +func TestPrintTrailListTruncatedSingleStatusHeaderShowsShownOfTotal(t *testing.T) { + t.Parallel() + alice := trailListTestAuthorAlice + var out bytes.Buffer + printTrailList(&out, []*trail.Metadata{ + {Branch: "feat/a", Status: trail.StatusOpen, Author: &trail.Author{Login: &alice}, UpdatedAt: time.Now()}, + }, trailListDisplayOptions{ + RequestedAuthor: "", + StatusFilters: []trail.Status{trail.StatusOpen}, + TotalMatched: 3, + StatusTotals: map[trail.Status]int{trail.StatusOpen: 3}, + }) + + // Pluralized by the total match count, not the truncated page size. + if text := out.String(); !strings.Contains(text, "Open · 1/3 trails") { + t.Fatalf("expected truncated header 'Open · 1/3 trails', got:\n%s", text) + } +} + +func TestPrintTrailListTruncatedOtherBucketShowsShownOfTotal(t *testing.T) { + t.Parallel() + alice := trailListTestAuthorAlice + unknownStatus := trail.Status("experimental_review") + var out bytes.Buffer + printTrailList(&out, []*trail.Metadata{ + {Branch: "feat/odd", Status: unknownStatus, Author: &trail.Author{Login: &alice}, UpdatedAt: time.Now()}, + }, trailListDisplayOptions{ + RequestedAuthor: "", + StatusFilters: nil, + TotalMatched: 3, + StatusTotals: map[trail.Status]int{unknownStatus: 2, trail.StatusOpen: 1}, + }) + + if text := out.String(); !strings.Contains(text, "Other · 1/2") { + t.Fatalf("expected truncated 'Other · 1/2' group, got:\n%s", text) + } +} + +func TestPrintTrailListFullPageKeepsPlainCounts(t *testing.T) { + t.Parallel() + alice := trailListTestAuthorAlice + var out bytes.Buffer + printTrailList(&out, []*trail.Metadata{ + {Branch: "feat/a", Status: trail.StatusOpen, Author: &trail.Author{Login: &alice}, UpdatedAt: time.Now()}, + }, trailListDisplayOptions{ + RequestedAuthor: "", + StatusFilters: nil, + TotalMatched: 1, + StatusTotals: map[trail.Status]int{trail.StatusOpen: 1}, + }) + + text := out.String() + if !strings.Contains(text, "Recent trail · 1") || strings.Contains(text, "1/1") { + t.Fatalf("expected plain counts without slash when nothing was truncated, got:\n%s", text) + } +} + func TestPrintTrailListEmptyDefaultStatusNamesFilterAndHints(t *testing.T) { t.Parallel() var out bytes.Buffer From 9f453fc105713bd38f4f8a46eeb31c815413eaa8 Mon Sep 17 00:00:00 2001 From: Matthias Wenz Date: Sat, 13 Jun 2026 01:02:45 +0900 Subject: [PATCH 4/4] trail list: push filters, limit, and pagination to the server 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 Entire-Checkpoint: 6b684484518f --- cmd/entire/cli/api/trail_types.go | 5 + cmd/entire/cli/trail_cmd.go | 249 ++++++++++-------------------- cmd/entire/cli/trail_cmd_test.go | 116 +++++--------- 3 files changed, 123 insertions(+), 247 deletions(-) diff --git a/cmd/entire/cli/api/trail_types.go b/cmd/entire/cli/api/trail_types.go index 11b9900f88..dd7748b7a2 100644 --- a/cmd/entire/cli/api/trail_types.go +++ b/cmd/entire/cli/api/trail_types.go @@ -7,8 +7,13 @@ import ( ) // 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"` diff --git a/cmd/entire/cli/trail_cmd.go b/cmd/entire/cli/trail_cmd.go index 93a99dcdb3..840ec9968f 100644 --- a/cmd/entire/cli/trail_cmd.go +++ b/cmd/entire/cli/trail_cmd.go @@ -7,8 +7,8 @@ import ( "fmt" "io" "net/http" + "net/url" "os/exec" - "sort" "strconv" "strings" "text/tabwriter" @@ -31,6 +31,9 @@ const ( 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 { @@ -174,6 +177,18 @@ func runTrailListAll(ctx context.Context, w io.Writer, opts trailListOptions) er if err != nil { return err } + + authorFilter := opts.Author + currentUserLogin := "" + if authorFilter == trailListAuthorMe { + login, err := fetchCurrentUserLogin(ctx, execRunner{}) + if err != nil { + return err + } + currentUserLogin = login + authorFilter = login + } + client, err := NewAuthenticatedAPIClient(ctx, opts.InsecureHTTP) if err != nil { return fmt.Errorf("authentication required: %w", err) @@ -184,7 +199,10 @@ func runTrailListAll(ctx context.Context, w io.Writer, opts trailListOptions) er return err } - resp, err := client.Get(ctx, trailsBasePath(forge, owner, repo)) + // 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) } @@ -204,33 +222,12 @@ func runTrailListAll(ctx context.Context, w io.Writer, opts trailListOptions) er trails = append(trails, listResp.Trails[i].ToMetadata()) } - authorFilter := opts.Author - currentUserLogin := "" - if authorFilter == trailListAuthorMe { - login, err := fetchCurrentUserLogin(ctx, execRunner{}) - if err != nil { - return err - } - currentUserLogin = login - authorFilter = login - } - - if authorFilter != "" { - trails = filterTrailsByAuthor(trails, authorFilter) + totalMatched := listResp.Total + if totalMatched < len(trails) { + // Older servers don't report a total; fall back to the page size. + totalMatched = len(trails) } - if len(statusFilters) > 0 { - trails = filterTrailsByStatuses(trails, statusFilters) - } - - // 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) - if opts.JSON { enc := json.NewEncoder(w) enc.SetIndent("", " ") @@ -250,12 +247,38 @@ func runTrailListAll(ctx context.Context, w io.Writer, opts trailListOptions) er 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 @@ -282,50 +305,6 @@ func printTrailListEmpty(w io.Writer, authorFilter string, statusFilters []trail 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 @@ -370,63 +349,20 @@ type trailListDisplayOptions struct { 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) { @@ -460,15 +396,19 @@ func printTrailListHeader(w io.Writer, opts trailListDisplayOptions, count int) 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 { @@ -478,39 +418,19 @@ func printTrailRows(w io.Writer, trails []*trail.Metadata, showAuthor bool) { 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 { @@ -531,14 +451,6 @@ func trailStatusDisplay(status trail.Status) string { 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 { @@ -548,15 +460,6 @@ func trailCountDisplay(shown, total int) string { 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 @@ -980,7 +883,11 @@ func findTrailByNumber(ctx context.Context, client *api.Client, forge, owner, re } 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) } diff --git a/cmd/entire/cli/trail_cmd_test.go b/cmd/entire/cli/trail_cmd_test.go index d1957671de..a3831e3aad 100644 --- a/cmd/entire/cli/trail_cmd_test.go +++ b/cmd/entire/cli/trail_cmd_test.go @@ -82,57 +82,28 @@ func TestTrailWatchDescription(t *testing.T) { } } -func TestLimitTrailsKeepsMostRecentPrefix(t *testing.T) { +func TestTrailListQueryEncodesFiltersAndLimit(t *testing.T) { t.Parallel() - trails := []*trail.Metadata{ - {Branch: "newest"}, - {Branch: "middle"}, - {Branch: "oldest"}, - } - - got := limitTrails(trails, 2) - if len(got) != 2 { - t.Fatalf("len = %d, want 2", len(got)) - } - if got[0].Branch != "newest" || got[1].Branch != "middle" { - t.Fatalf("got branches %q, %q; want newest, middle", got[0].Branch, got[1].Branch) - } - - if all := limitTrails(trails, 3); len(all) != len(trails) { - t.Fatalf("limit 3 len = %d, want %d", len(all), len(trails)) + got := trailListQuery([]trail.Status{trail.StatusOpen, trail.StatusDraft}, "alice", 10) + want := "?author=alice&limit=10&status=open%2Cdraft" + if got != want { + t.Fatalf("trailListQuery = %q, want %q", got, want) } } -func TestFilterTrailsByAuthor(t *testing.T) { +func TestTrailListQueryAnyStatusOmitsStatusParam(t *testing.T) { t.Parallel() - alice := trailListTestAuthorAlice - bob := trailListTestAuthorBob - trails := []*trail.Metadata{ - {Branch: "mine-1", Author: &trail.Author{Login: &alice}}, - {Branch: "theirs", Author: &trail.Author{Login: &bob}}, - {Branch: "unknown"}, - {Branch: "mine-2", Author: &trail.Author{Login: &alice}}, - } - - got := filterTrailsByAuthor(trails, trailListTestAuthorAlice) - if len(got) != 2 { - t.Fatalf("len = %d, want 2", len(got)) - } - if got[0].Branch != "mine-1" || got[1].Branch != "mine-2" { - t.Fatalf("got branches %q, %q; want mine-1, mine-2", got[0].Branch, got[1].Branch) + got := trailListQuery(nil, "", 10) + if got != "?limit=10" { + t.Fatalf("trailListQuery = %q, want %q", got, "?limit=10") } } -func TestFilterTrailsByAuthorIsCaseInsensitive(t *testing.T) { +func TestTrailListQueryCapsLimitAtServerMax(t *testing.T) { t.Parallel() - mixed := "Alice" - trails := []*trail.Metadata{ - {Branch: "mine", Author: &trail.Author{Login: &mixed}}, - } - - got := filterTrailsByAuthor(trails, "alice") - if len(got) != 1 { - t.Fatalf("len = %d, want 1 (case-insensitive)", len(got)) + got := trailListQuery(nil, "", 5000) + if !strings.Contains(got, "limit=200") { + t.Fatalf("expected limit capped at 200, got %q", got) } } @@ -252,7 +223,7 @@ func TestPrintTrailListYourTrailsRelabelsAndSurfacesGhLogin(t *testing.T) { } } -func TestPrintTrailListAnyAuthorAnyStatusGroupsByStatus(t *testing.T) { +func TestPrintTrailListAnyStatusShowsStatusColumn(t *testing.T) { t.Parallel() alice := trailListTestAuthorAlice bob := trailListTestAuthorBob @@ -263,19 +234,34 @@ func TestPrintTrailListAnyAuthorAnyStatusGroupsByStatus(t *testing.T) { }, trailListDisplayOptions{ RequestedAuthor: "", StatusFilters: nil, + TotalMatched: 2, }) text := out.String() - if strings.Index(text, "Open · 1") > strings.Index(text, "Draft · 1") { - t.Fatalf("expected open group before draft group, got:\n%s", text) - } - for _, want := range []string{"Recent trails · 2", "Open · 1", "Draft · 1", "feat/a", trailListTestAuthorAlice, "fix/b", trailListTestAuthorBob} { + for _, want := range []string{"Recent trails · 2", "STATUS", "open", "draft", "feat/a", trailListTestAuthorAlice, "fix/b", trailListTestAuthorBob} { if !strings.Contains(text, want) { t.Fatalf("output missing %q, got:\n%s", want, text) } } } +func TestPrintTrailListSingleStatusFilterOmitsStatusColumn(t *testing.T) { + t.Parallel() + alice := trailListTestAuthorAlice + var out bytes.Buffer + printTrailList(&out, []*trail.Metadata{ + {Branch: "feat/a", Status: trail.StatusOpen, Author: &trail.Author{Login: &alice}, UpdatedAt: time.Now()}, + }, trailListDisplayOptions{ + RequestedAuthor: "", + StatusFilters: []trail.Status{trail.StatusOpen}, + TotalMatched: 1, + }) + + if text := out.String(); strings.Contains(text, "STATUS") { + t.Fatalf("single-status list should not repeat the status as a column, got:\n%s", text) + } +} + func TestPrintTrailListSingularRecentTrailWhenOne(t *testing.T) { t.Parallel() alice := trailListTestAuthorAlice @@ -296,7 +282,7 @@ func TestPrintTrailListSingularRecentTrailWhenOne(t *testing.T) { } } -func TestPrintTrailListUnknownStatusGroupedInOtherBucket(t *testing.T) { +func TestPrintTrailListUnknownStatusRendersInStatusColumn(t *testing.T) { t.Parallel() alice := trailListTestAuthorAlice unknownStatus := trail.Status("experimental_review") @@ -307,10 +293,13 @@ func TestPrintTrailListUnknownStatusGroupedInOtherBucket(t *testing.T) { }, trailListDisplayOptions{ RequestedAuthor: "", StatusFilters: nil, + TotalMatched: 2, }) + // A status the CLI doesn't know yet must not disappear; it renders + // verbatim (underscores humanized) in the status column. text := out.String() - for _, want := range []string{"Recent trails · 2", "Open · 1", "Other · 1", "feat/odd"} { + for _, want := range []string{"Recent trails · 2", "experimental review", "feat/odd"} { if !strings.Contains(text, want) { t.Fatalf("output missing %q, got:\n%s", want, text) } @@ -327,14 +316,10 @@ func TestPrintTrailListTruncatedShowsShownOfTotal(t *testing.T) { RequestedAuthor: "", StatusFilters: nil, TotalMatched: 5, - StatusTotals: map[trail.Status]int{trail.StatusOpen: 4, trail.StatusDraft: 1}, }) - text := out.String() - for _, want := range []string{"Recent trails · 1/5", "Open · 1/4"} { - if !strings.Contains(text, want) { - t.Fatalf("output missing %q, got:\n%s", want, text) - } + if text := out.String(); !strings.Contains(text, "Recent trails · 1/5") { + t.Fatalf("expected truncated header 'Recent trails · 1/5', got:\n%s", text) } } @@ -348,7 +333,6 @@ func TestPrintTrailListTruncatedSingleStatusHeaderShowsShownOfTotal(t *testing.T RequestedAuthor: "", StatusFilters: []trail.Status{trail.StatusOpen}, TotalMatched: 3, - StatusTotals: map[trail.Status]int{trail.StatusOpen: 3}, }) // Pluralized by the total match count, not the truncated page size. @@ -357,25 +341,6 @@ func TestPrintTrailListTruncatedSingleStatusHeaderShowsShownOfTotal(t *testing.T } } -func TestPrintTrailListTruncatedOtherBucketShowsShownOfTotal(t *testing.T) { - t.Parallel() - alice := trailListTestAuthorAlice - unknownStatus := trail.Status("experimental_review") - var out bytes.Buffer - printTrailList(&out, []*trail.Metadata{ - {Branch: "feat/odd", Status: unknownStatus, Author: &trail.Author{Login: &alice}, UpdatedAt: time.Now()}, - }, trailListDisplayOptions{ - RequestedAuthor: "", - StatusFilters: nil, - TotalMatched: 3, - StatusTotals: map[trail.Status]int{unknownStatus: 2, trail.StatusOpen: 1}, - }) - - if text := out.String(); !strings.Contains(text, "Other · 1/2") { - t.Fatalf("expected truncated 'Other · 1/2' group, got:\n%s", text) - } -} - func TestPrintTrailListFullPageKeepsPlainCounts(t *testing.T) { t.Parallel() alice := trailListTestAuthorAlice @@ -386,7 +351,6 @@ func TestPrintTrailListFullPageKeepsPlainCounts(t *testing.T) { RequestedAuthor: "", StatusFilters: nil, TotalMatched: 1, - StatusTotals: map[trail.Status]int{trail.StatusOpen: 1}, }) text := out.String()