Skip to content

Commit e3ba329

Browse files
authored
Merge pull request #1681 from entireio/merge-mirror-list-show-available
refactor: one repo mirror list for mirrored and mirrorable repos, per-cluster detail via mirror get
2 parents aa8c280 + 6d0c0de commit e3ba329

10 files changed

Lines changed: 2502 additions & 529 deletions

cmd/entire/cli/corecmd.go

Lines changed: 156 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
package cli
22

33
import (
4+
"bytes"
45
"context"
56
"encoding/json"
67
"errors"
78
"fmt"
89
"io"
10+
"strconv"
911
"strings"
1012

1113
"charm.land/huh/v2"
@@ -199,32 +201,142 @@ func renderCoreList[T any](cmd *cobra.Command, empty string, headers []string, r
199201
}
200202
}
201203

204+
// coreListFetchBudget bounds how many entries a bounded list command fetches
205+
// by default. The control plane pages but cannot filter or sort these lists,
206+
// so without a bound every call would walk the entire collection — thousands
207+
// of requests on a large org. Commands that stop at the budget must disclose
208+
// the partial window on stderr and offer --all.
209+
const coreListFetchBudget = 1000
210+
202211
// fetchAllPages drives a keyset-paginated list endpoint to completion: it
203212
// calls fetch with an empty cursor, then re-calls it with each returned
204213
// nextPageToken until the cursor comes back empty, concatenating every page.
205214
// The control plane caps the page size (and may cap it further than a caller
206215
// requests), so a single call only returns one page — list commands must loop
207-
// or they silently truncate. The next==cursor guard turns a misbehaving server
208-
// that fails to advance the cursor into an error instead of an infinite loop.
216+
// or they silently truncate.
209217
func fetchAllPages[T any](ctx context.Context, fetch func(ctx context.Context, cursor string) (items []T, next string, err error)) ([]T, error) {
218+
items, _, err := fetchPagesBounded(ctx, 0, fetch)
219+
return items, err
220+
}
221+
222+
// fetchPagesBounded is fetchAllPages with a fetch budget: the cursor walk
223+
// stops once at least budget entries have been fetched (a page is never split,
224+
// so the result can overshoot by up to one page). partial reports that the
225+
// walk stopped with a cursor remaining — entries exist beyond the returned
226+
// slice and the caller must disclose that. budget <= 0 means unbounded. The
227+
// next==cursor guard turns a misbehaving server that fails to advance the
228+
// cursor into an error instead of an infinite loop.
229+
func fetchPagesBounded[T any](ctx context.Context, budget int, fetch func(ctx context.Context, cursor string) (items []T, next string, err error)) (items []T, partial bool, err error) {
210230
var all []T
211231
cursor := ""
212232
for {
213-
items, next, err := fetch(ctx, cursor)
233+
page, next, err := fetch(ctx, cursor)
214234
if err != nil {
215-
return nil, err
235+
return nil, false, err
216236
}
217-
all = append(all, items...)
237+
all = append(all, page...)
218238
if next == "" {
219-
return all, nil
239+
return all, false, nil
240+
}
241+
if budget > 0 && len(all) >= budget {
242+
return all, true, nil
220243
}
221244
if next == cursor {
222-
return nil, fmt.Errorf("pagination did not advance (cursor %q repeated)", next)
245+
return nil, false, fmt.Errorf("pagination did not advance (cursor %q repeated)", next)
223246
}
224247
cursor = next
225248
}
226249
}
227250

251+
// listPage is the --json envelope for single-page (cursor passthrough) list
252+
// output: rows plus the cursor to resume from, omitted on the last page. Page
253+
// mode cannot emit the bare array the walk modes use — the caller needs the
254+
// cursor to continue, and stdout is the only machine-readable channel.
255+
type listPage[T any] struct {
256+
Items []T `json:"items"`
257+
NextPageToken string `json:"nextPageToken,omitempty"`
258+
}
259+
260+
// renderCoreListPage renders one fetched page of a list command: --json emits
261+
// the listPage envelope; the table view prints the usual table, preceded by a
262+
// stderr resume hint carrying the cursor when more entries exist.
263+
func renderCoreListPage[T any](cmd *cobra.Command, empty string, headers []string, row func(T) []string, items []T, next string) error {
264+
if jsonRequested(cmd) {
265+
if items == nil {
266+
items = []T{} // a nil slice encodes as null; scripts expect []
267+
}
268+
return printJSON(cmd.OutOrStdout(), listPage[T]{Items: items, NextPageToken: next})
269+
}
270+
if next != "" {
271+
fmt.Fprintf(cmd.ErrOrStderr(), "More entries available: resume with --page-token %s\n", next)
272+
}
273+
if len(items) == 0 {
274+
fmt.Fprintln(cmd.OutOrStdout(), empty)
275+
return nil
276+
}
277+
return printTable(cmd.OutOrStdout(), headers, items, row)
278+
}
279+
280+
// pageModeFlags wires the single-page cursor-passthrough flags onto a list
281+
// command and excludes them from the walk flags (--all, --limit): one call =
282+
// one request, so a walk bound makes no sense alongside them. Callers validate
283+
// pageSize positivity in PreRunE via validatePageSize.
284+
func pageModeFlags(cmd *cobra.Command, pageSize *int, pageToken *string) {
285+
cmd.Flags().IntVar(pageSize, "page-size", 0, "Fetch a single page of at most N entries (1-"+strconv.Itoa(coreListPageSizeMax)+"; the server may cap N further) and print the resume cursor")
286+
cmd.Flags().StringVar(pageToken, "page-token", "", "Fetch the single page at this cursor (from a previous run's nextPageToken)")
287+
cmd.MarkFlagsMutuallyExclusive("page-size", "all")
288+
cmd.MarkFlagsMutuallyExclusive("page-token", "all")
289+
cmd.MarkFlagsMutuallyExclusive("page-size", "limit")
290+
cmd.MarkFlagsMutuallyExclusive("page-token", "limit")
291+
}
292+
293+
// pageModeRequested reports whether the caller opted into single-page mode:
294+
// either page flag was explicitly set. Checked by Changed, not value — a
295+
// script's resume loop naturally passes --page-token "" for its first page,
296+
// and a value check would silently reroute that call to the multi-page walk,
297+
// flipping the --json shape from the {items, nextPageToken} envelope to a
298+
// bare array (and the request count from one to many).
299+
func pageModeRequested(cmd *cobra.Command) bool {
300+
return cmd.Flags().Changed("page-size") || cmd.Flags().Changed("page-token")
301+
}
302+
303+
// coreListPageSizeMax mirrors the OpenAPI `maximum: 500` on the list
304+
// endpoints' pageSize param (see internal/coreapi/spec). The generated client
305+
// does not validate params, so without this local bound an oversized
306+
// --page-size goes on the wire and comes back as a server 4xx naming the wire
307+
// param instead of the flag.
308+
const coreListPageSizeMax = 500
309+
310+
// validatePageSize rejects an explicitly set out-of-range --page-size; an
311+
// unset flag passes.
312+
func validatePageSize(cmd *cobra.Command, pageSize int) error {
313+
if cmd.Flags().Changed("page-size") && (pageSize <= 0 || pageSize > coreListPageSizeMax) {
314+
return fmt.Errorf("--page-size must be between 1 and %d, got %d", coreListPageSizeMax, pageSize)
315+
}
316+
return nil
317+
}
318+
319+
// flushThroughPager runs run with the command's stdout captured, then flushes
320+
// the captured output — through a pager when stdout is a real terminal and the
321+
// content is taller than the screen (see outputWithPager), directly otherwise.
322+
// --json output never pages: a machine consumer driving a PTY would hang
323+
// waiting on the pager's keyboard, and JSON is not for reading. Output is
324+
// flushed even when run errors, so partial renders are not swallowed.
325+
func flushThroughPager(cmd *cobra.Command, noPager bool, run func() error) error {
326+
finalOut := cmd.OutOrStdout()
327+
var buf bytes.Buffer
328+
cmd.SetOut(&buf)
329+
err := run()
330+
cmd.SetOut(finalOut)
331+
content := buf.String()
332+
if noPager || jsonRequested(cmd) {
333+
fmt.Fprint(finalOut, content)
334+
} else {
335+
outputWithPager(finalOut, content)
336+
}
337+
return err
338+
}
339+
228340
// runCoreObject fetches a single value via fn and renders it as a vertical
229341
// field/value list (default) or raw JSON (--json), reusing the same column
230342
// definition as the matching list view.
@@ -321,6 +433,43 @@ func printTable[T any](w io.Writer, headers []string, items []T, row func(T) []s
321433
return nil
322434
}
323435

436+
// preStyleTable pre-colors table headers and a row function against w's color
437+
// capability, so a command that renders its table into a pager buffer keeps
438+
// its color. printTable/renderCoreListPage decide color from the writer they
439+
// render into; under flushThroughPager that writer is an in-memory buffer,
440+
// which never looks like a TTY, so a straight render there is always plain.
441+
// Pre-styling against the real output writer here and letting the buffered
442+
// render pass the ANSI through unchanged (its own color gate is off, so it
443+
// never re-styles) restores it — the same approach the mirror-list view takes.
444+
// Identity (no wrapping) when color is off, so pipes, tests, and NO_COLOR see
445+
// bare text byte for byte.
446+
func preStyleTable[T any](w io.Writer, headers []string, row func(T) []string) ([]string, func(T) []string) {
447+
return styleTableWith(newTableStyles(w), headers, row)
448+
}
449+
450+
// styleTableWith is the pure core of preStyleTable: it applies st's header and
451+
// per-column styles to the headers and row cells, matching how printTable
452+
// colors a direct render. Split out from the writer-facing wrapper so the
453+
// enabled path is unit-testable without a real terminal. Identity when st is
454+
// disabled, so plain output stays byte-for-byte unchanged.
455+
func styleTableWith[T any](st tableStyles, headers []string, row func(T) []string) ([]string, func(T) []string) {
456+
if !st.enabled {
457+
return headers, row
458+
}
459+
styledHeaders := make([]string, len(headers))
460+
for i, h := range headers {
461+
styledHeaders[i] = st.style(st.header, h)
462+
}
463+
styledRow := func(t T) []string {
464+
cells := row(t)
465+
for i := range cells {
466+
cells[i] = st.style(st.columnStyle(i), cells[i])
467+
}
468+
return cells
469+
}
470+
return styledHeaders, styledRow
471+
}
472+
324473
// printFields writes a single record as aligned "FIELD value" lines: the
325474
// label in header gray, the value in the same primary/secondary color the
326475
// list view would give that column.

cmd/entire/cli/corecmd_test.go

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ import (
77
"fmt"
88
"strings"
99
"testing"
10+
11+
"charm.land/lipgloss/v2"
12+
"github.com/spf13/cobra"
13+
"github.com/stretchr/testify/require"
1014
)
1115

1216
// TestConfirmControlPlaneDeletion covers the non-TTY decision paths of the
@@ -113,6 +117,133 @@ func TestFetchAllPages(t *testing.T) {
113117
})
114118
}
115119

120+
// TestFetchPagesBounded covers the budget branch fetchAllPages delegates to:
121+
// the walk stops once the budget is reached (never splitting a page, so it can
122+
// overshoot) and reports that entries remain; budget<=0 walks to the end.
123+
func TestFetchPagesBounded(t *testing.T) {
124+
t.Parallel()
125+
126+
// Three pages keyed by the cursor the previous page returned.
127+
pages := map[string][]string{"": {"a", "b"}, "c1": {"c", "d"}, "c2": {"e"}}
128+
nexts := map[string]string{"": "c1", "c1": "c2", "c2": ""}
129+
130+
t.Run("stops at the budget and reports the partial walk", func(t *testing.T) {
131+
t.Parallel()
132+
got, partial, err := fetchPagesBounded(context.Background(), 3, func(_ context.Context, cursor string) ([]string, string, error) {
133+
return pages[cursor], nexts[cursor], nil
134+
})
135+
require.NoError(t, err)
136+
// Budget 3 is reached after the second page (4 items), which is not
137+
// split — so the result overshoots to 4 and the walk stops there.
138+
require.Equal(t, []string{"a", "b", "c", "d"}, got)
139+
require.True(t, partial, "a cursor still remained, so entries are unseen")
140+
})
141+
142+
t.Run("a zero budget walks to the empty cursor", func(t *testing.T) {
143+
t.Parallel()
144+
got, partial, err := fetchPagesBounded(context.Background(), 0, func(_ context.Context, cursor string) ([]string, string, error) {
145+
return pages[cursor], nexts[cursor], nil
146+
})
147+
require.NoError(t, err)
148+
require.Equal(t, []string{"a", "b", "c", "d", "e"}, got)
149+
require.False(t, partial, "the chain ended, nothing left unseen")
150+
})
151+
}
152+
153+
// newPageModeTestCmd wires the walk (--all/--limit) and single-page
154+
// (--page-size/--page-token) flags the way the real list commands do, so the
155+
// shared flag helpers can be unit-tested without a command surface.
156+
func newPageModeTestCmd() *cobra.Command {
157+
var pageSize, limit int
158+
var pageToken string
159+
var all bool
160+
cmd := &cobra.Command{Use: "x", RunE: func(*cobra.Command, []string) error { return nil }}
161+
cmd.Flags().IntVar(&limit, "limit", 0, "")
162+
cmd.Flags().BoolVar(&all, "all", false, "")
163+
pageModeFlags(cmd, &pageSize, &pageToken)
164+
return cmd
165+
}
166+
167+
// TestValidatePageSize covers the local bound the list commands enforce in
168+
// PreRunE: an unset flag passes, and an explicit value outside 1..max fails
169+
// naming the flag (and the max), turning a would-be server 4xx into a
170+
// flag-named error.
171+
func TestValidatePageSize(t *testing.T) {
172+
t.Parallel()
173+
check := func(args ...string) error {
174+
cmd := newPageModeTestCmd()
175+
require.NoError(t, cmd.Flags().Parse(args))
176+
ps, err := cmd.Flags().GetInt("page-size")
177+
require.NoError(t, err)
178+
return validatePageSize(cmd, ps)
179+
}
180+
require.NoError(t, check(), "unset --page-size passes")
181+
require.NoError(t, check("--page-size", "1"))
182+
require.NoError(t, check("--page-size", "500"))
183+
184+
err := check("--page-size", "0")
185+
require.Error(t, err)
186+
require.Contains(t, err.Error(), "--page-size")
187+
188+
err = check("--page-size", "501")
189+
require.Error(t, err)
190+
require.Contains(t, err.Error(), "500")
191+
}
192+
193+
// TestPageModeRequested pins that page mode is opted into by SETTING either
194+
// page flag, not by its value: an explicitly empty --page-token (a resume
195+
// loop's natural first call) still selects page mode, so the output shape does
196+
// not flip to the walk's bare array on an empty cursor.
197+
func TestPageModeRequested(t *testing.T) {
198+
t.Parallel()
199+
mode := func(args ...string) bool {
200+
cmd := newPageModeTestCmd()
201+
require.NoError(t, cmd.Flags().Parse(args))
202+
return pageModeRequested(cmd)
203+
}
204+
require.False(t, mode(), "no page flag → walk mode")
205+
require.True(t, mode("--page-size", "5"))
206+
require.True(t, mode("--page-token", "p2"))
207+
require.True(t, mode("--page-token", ""), "an explicit empty cursor still selects page mode")
208+
}
209+
210+
// TestStyleTableWith covers the pre-styling that keeps a paged list command's
211+
// table colored: the render inside flushThroughPager targets a buffer that
212+
// never looks like a TTY, so color is decided against the real writer up front
213+
// and applied here. The enabled path must color the header row and route each
214+
// data cell through its column style (first column primary, rest secondary);
215+
// the disabled path must be an exact identity so pipes, tests, and NO_COLOR
216+
// see bare text byte for byte.
217+
func TestStyleTableWith(t *testing.T) {
218+
t.Parallel()
219+
220+
headers := []string{"ID", "NAME"}
221+
row := func(r []string) []string { return r }
222+
item := []string{"a", "b"}
223+
224+
t.Run("enabled path colors headers and routes cells by column", func(t *testing.T) {
225+
t.Parallel()
226+
st := tableStyles{
227+
enabled: true,
228+
header: lipgloss.NewStyle().Bold(true),
229+
primary: lipgloss.NewStyle().Underline(true),
230+
cell: lipgloss.NewStyle().Faint(true),
231+
}
232+
gotHeaders, gotRow := styleTableWith(st, headers, row)
233+
require.Equal(t, []string{st.header.Render("ID"), st.header.Render("NAME")}, gotHeaders)
234+
// Column 0 is the primary identifier, the rest secondary — the same
235+
// split printTable applies when it colors a direct render.
236+
require.Equal(t, []string{st.primary.Render("a"), st.cell.Render("b")}, gotRow(item))
237+
})
238+
239+
t.Run("disabled path is an exact identity", func(t *testing.T) {
240+
t.Parallel()
241+
gotHeaders, gotRow := styleTableWith(tableStyles{}, headers, row)
242+
require.Equal(t, headers, gotHeaders)
243+
require.Equal(t, item, gotRow(item))
244+
})
245+
}
246+
116247
// printTable/printFields render plain (no color/escape) when the writer
117248
// isn't a TTY — which a bytes.Buffer never is — so these assert the plain
118249
// layout directly.

0 commit comments

Comments
 (0)