cmd

package
v0.1.0-rc.4 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 23, 2026 License: MIT Imports: 24 Imported by: 0

Documentation

Overview

Package cmd is the labelsync command tree.

One file per command, all of them leaves on the root built in Execute. The wiring that every command inherits — the output writer, the debug logger, and the persistent flags — is resolved once in the root's PersistentPreRunE and reaches the leaves through the App they were built with.

No os.Exit here

Commands return errors; main turns them into exit codes with exit.Of. os.Exit skips deferred cleanup, so calling it inside a command would leak temp files, unreleased locks, and unflushed writers. A non-zero code that is not a failure travels on an *exit.Err with a nil Err field, which main prints nothing for.

Index

Constants

View Source
const (
	// DefaultConcurrency bounds the parallel per-repository reads. Eight is
	// enough to hide the round-trip latency of a mid-sized repository set without
	// tripping GitHub's abuse detection on the read side.
	DefaultConcurrency = 8

	// DefaultWriteRate is the ceiling on label writes per minute. GitHub's
	// secondary rate limits are undocumented and content-based; 70/min is the
	// rate that has proven not to trip them.
	DefaultWriteRate = 70

	// DefaultMaxWait is how long a run may sleep for a rate-limit reset before it
	// gives up with ErrMaxWaitExceeded. A CI job should fail with a clear reason
	// rather than sit on a runner for an hour.
	DefaultMaxWait = 15 * time.Minute
)

Default values for the persistent flags. Named because the tests assert on them and the help text renders them.

Variables

View Source
var Version = "dev"

Version is the binary version, injected at build time:

-ldflags "-X github.com/specsnl/labelsync/internal/cmd.Version=1.2.3"

Both .goreleaser.yml and the Dockerfile name this variable by that exact path, so it has to stay in this package under this name — rename it and every build silently ships as "dev".

Functions

func Execute

func Execute(app *App) error

Execute builds the command tree and runs it with a background context.

func ExecuteContext

func ExecuteContext(ctx context.Context, app *App) error

ExecuteContext builds the command tree and runs it with the given context. The context reaches the handlers as cmd.Context(), which is how a cancelled run stops mid-flight instead of finishing its writes.

func NewRootCmd

func NewRootCmd(app *App) *cobra.Command

NewRootCmd builds the labelsync root command, with every subcommand attached and every persistent flag defined.

Exported because a test drives the tree the way main does — build it, point cmd.SetOut/SetErr at buffers, execute — and that is the only way to assert that the output really is captured.

Types

type App

type App struct {
	// Out is the single channel for user-facing output. It is replaced in the
	// root's PersistentPreRunE with a writer over the command's own streams, once
	// --output has been parsed.
	Out output.Writer

	// Stdout is the raw stdout stream, for the one product that is a file rather
	// than a record.
	//
	// `labelsync export owner/repo > labels.yml` has to produce a config file,
	// and every method on Out would either wrap the YAML in a JSON object or
	// interleave prose with it. Nothing else in the tree may reach for this:
	// output that is a record goes through Out, so that --output means something
	// everywhere it can.
	//
	// Like Out, it is replaced in PersistentPreRunE with the command's own
	// stream, so a test captures it.
	Stdout io.Writer

	// Stderr is the raw stderr stream, for the one thing no Writer method can
	// express: the rate-limit countdown's in-place line, which is a carriage
	// return with no newline after it.
	//
	// It is also the stream the TTY question is asked about, because it is the
	// stream being drawn to. Everything else that narrates a run goes through Out.
	Stderr io.Writer

	// Stdin is the raw stdin stream, for the one thing that reads: the prune
	// prompt, which both draws its form over it and asks whether it is a terminal
	// at all.
	//
	// It is taken from the command like the other two, so that a test drives the
	// prompt through a stream it controls rather than through the developer's
	// actual keyboard.
	Stdin io.Reader

	// LogLevel gates the debug logger. Cobra parses flags after the tree is
	// built, so the level is held here and raised once --debug is known.
	LogLevel *slog.LevelVar

	// ConfigPath is --config: an explicit config file path, or "" to search the
	// working directory and then the XDG config directory.
	ConfigPath string

	// Token is --token: an explicit GitHub credential, or "" to resolve one from
	// the environment, the gh config, or gh itself. It is the first step of the
	// chain in internal/github, and the discouraged one — a token on the command
	// line is in the shell history and in every process list on the machine.
	//
	// Never log this value. Only whether it was set.
	Token string

	// Format is --output, already validated against the known formats.
	Format output.Format

	// Debug is --debug: raises the slog level to Debug on stderr.
	Debug bool

	// NoCache is --no-cache: ignore the ETag cache for this run, both on read
	// and on write.
	NoCache bool

	// Concurrency is --concurrency: the bound on parallel per-repository reads.
	Concurrency int

	// WriteRate is --write-rate: the ceiling on label writes per minute.
	WriteRate int

	// MaxWait is --max-wait: the longest a rate-limit backoff may sleep before
	// the run fails with ErrMaxWaitExceeded.
	MaxWait time.Duration

	// CacheDir and CacheRoot are where the cache commands look, and the
	// directory they are bounded by. Empty means the resolved XDG paths, which
	// is what production wants.
	//
	// They are a test seam, and a narrow one on purpose: `cache clear` deletes
	// what it finds, so a suite that ran against the developer's real cache
	// would empty it. Only the cache commands read them — the ETag cache the
	// client writes is pointed elsewhere through GitHub instead.
	CacheDir  string
	CacheRoot string

	// Prompt is the prune mode removal selection. Nil means the real
	// huh.MultiSelect over Stdin.
	//
	// It is a test seam, and a wider one than it looks: supplying it also declares
	// that this run *can* be asked a question, which in production is
	// output.IsTTY(Stdin). A terminal file descriptor is not something a test can
	// portably fake, so the two are one seam rather than two — see
	// [App.canPrompt].
	Prompt Selector

	// Now is the clock, for the one command that renders an age. Nil means
	// time.Now.
	//
	// Injected rather than read, because "3 days ago" is not something a test
	// can assert against a real clock.
	Now func() time.Time

	// GitHub are extra options applied last whenever a command builds a client,
	// after everything the persistent flags decided.
	//
	// It is the seam an end-to-end test drives the tree through: a base URL
	// pointing at net/http/httptest, and a cache directory under t.TempDir() so
	// that running the suite cannot touch the developer's real cache. Production
	// leaves it nil.
	GitHub []github.Option
}

App holds everything a command needs that is not its own flags: the output writer, the handle on the debug log level, and the resolved values of the persistent flags.

Every command is built with the App and closes over it, so a test constructs one App, points it at buffers, and drives the tree. The zero-argument NewApp is what main uses.

func NewApp

func NewApp() *App

NewApp creates an App with the flag defaults and a silent logger.

Out is a pretty writer over os.Stdout and os.Stderr, and LogLevel is silent. Both are placeholders for the window before flags are parsed — a failure during parsing still has somewhere to go — and both are replaced in PersistentPreRunE by equivalents over the command's own streams. A command that reads app.Out is therefore always reading the test's buffers under test.

type Selector

type Selector func(ctx context.Context, candidates []plan.Candidate) ([]plan.Candidate, error)

Selector answers "which of these candidates should be removed?".

It is the seam the interactive prompt is replaced through. A test that supplies one is also declaring that a selection can be answered at all — see [App.canPrompt] — because a terminal is not a thing a test can portably fake, and everything above the prompt is what there is to test.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL