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 ¶
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 ¶
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 ExecuteContext ¶
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 ¶
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 ¶
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.