plan

package
v0.1.0-rc.1 Latest Latest
Warning

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

Go to latest
Published: Aug 11, 2026 License: MIT Imports: 9 Imported by: 0

Documentation

Overview

Package plan turns "these labels are configured" and "these labels exist" into an ordered list of changes. It is pure: nothing here touches the network, and the package never imports internal/github.

action.go holds the vocabulary — Kind, Action, and Plan. All three are plain serialisable structs with no behaviour and no client reference, which is what keeps a future `plan -o file` / `apply file` split a thin serialisation shell rather than a restructuring exercise: writing a plan out is json.Marshal, and reading one back is json.Unmarshal.

compute.go holds the reconciler itself: the pure function that turns one repository's configured labels and its current labels into the ordered list of actions that converges the second onto the first.

Nothing here does I/O. The remote half of the input is Label, a plain struct this package declares itself rather than the client's label type, which is what keeps internal/plan free of internal/github — the interesting logic is testable with two slices and no HTTP mock.

Index

Constants

View Source
const RepositoryKind = "repository"

RepositoryKind is the "kind" of an NDJSON record that says something about a repository rather than about a label.

Like SummaryKind it is not an action Kind and is never sent to the API. A consumer applying a stream filters on the action kinds it knows; a record carrying this one is a note about the repository the actions belong to. The string is a wire contract: added to, never renamed.

View Source
const SummaryKind = "summary"

SummaryKind is the "kind" of the final NDJSON object.

It is not an action Kind and never appears on an Action. It shares the "kind" key so that a consumer reading the stream has one discriminator rather than two: `jq 'select(.kind == "summary")'` picks the totals out, and `select(.kind != "summary")` leaves a stream of actions. Like the action kinds, the string is a wire contract — it may be added to, never renamed.

Variables

This section is empty.

Functions

func Diff

func Diff(p Plan) output.DiffData

Diff prepares both renderings of p without writing them. Render is the call site to reach for; this one exists so a test can assert on either projection, and so a future `plan -o file` can take the records without a writer.

func Render

func Render(w output.Writer, p Plan)

Render writes p to w: the pretty diff for a human, the NDJSON stream for a machine. Which one lands is the writer's business, not the caller's.

Types

type Action

type Action struct {
	Kind Kind   `json:"kind"`
	Repo string `json:"repo"` // owner/repo

	// Name is the label's current name, and the lookup key against the
	// repository's existing labels. A rename changes the name the API sees;
	// Name stays the name to find it under.
	Name string `json:"name"`

	// NewName is set only by a rename, which is a PATCH so that the label's
	// issue and PR associations survive.
	NewName *string `json:"new_name,omitempty"`

	// Color is bare six-digit lowercase hex, no leading #, as GitHub stores it.
	Color *string `json:"color,omitempty"`

	// Description is authoritative when set. A pointer to the empty string
	// clears the label's description; nil leaves it alone.
	Description *string `json:"description,omitempty"`

	// Reason carries reporting context — for example `displaced by "type: bug"`
	// on a recoloured squatter, or the palette's exhaustion warning. A recolour
	// that looks arbitrary in a diff becomes obvious when annotated with what
	// displaced it. It never affects what is sent to the API.
	Reason string `json:"reason,omitempty"`
}

Action is one change to one label in one repository.

Why the optional fields are pointers

An update carries only the fields it changes, and a nil field means "unchanged". That distinction cannot be carried by plain strings, because the design makes descriptions authoritative: an omitted description in the config means "clear it", so an empty description is a value an update legitimately sets. With a plain string, "leave the description alone" and "set the description to empty" are the same zero value, and clearing a description would be indistinguishable from not touching one.

The pointers survive a round trip for the same reason: omitempty drops a nil field, and a *string pointing at "" marshals as an explicit "" rather than disappearing.

Why Repo is on the action

Plan already groups actions by repository, so Repo is redundant inside a plan. It is here anyway so that a single action is self-describing — a log line, an NDJSON record, or an error about one failed write carries its repository without its surrounding group having to be threaded along.

type Candidate

type Candidate struct {
	Repo string `json:"repo"` // owner/repo
	Name string `json:"name"`
}

Candidate identifies one removal candidate: a repository, and the name of the label in it that the config does not mention.

It is a comparable value rather than an Action so a selection can be carried as a set, and so that the thing a user is shown and the thing they chose are the same type. The name is the one the repository will hold *after* the rename pass, because that is what Compute emitted and a delete is applied last.

func Candidates

func Candidates(p Plan) []Candidate

Candidates lists every removal candidate in p, in plan order: repositories in the order the plan holds them, and within each one the ascending name order Compute emitted.

Order is the whole reason this exists rather than a caller walking the plan itself. What a user is offered has to read the same way twice over the same input, because the alternative is a destructive prompt whose rows move between runs.

type Kind

type Kind string

Kind is what an action does to a label.

const (
	KindCreate Kind = "create"
	KindUpdate Kind = "update"
	KindDelete Kind = "delete"
	KindNoOp   Kind = "noop"
)

The four kinds of action. KindNoOp is emitted for a configured label that already matches: it is never sent to the API, and exists so reporting can show a label as checked rather than silently omitting it.

type Label

type Label struct {
	// Name is the label's name exactly as the repository stores it, casing
	// included. Matching against the config is case-insensitive, but the
	// stored casing is what an update has to correct.
	Name string

	// Color is six-digit hex. A leading # and upper case are tolerated —
	// Compute normalises before comparing — but GitHub stores neither.
	Color string

	// Description is the label's description, empty when it has none. GitHub
	// does not distinguish an absent description from an empty one, and
	// neither does this.
	Description string
}

Label is a label as it exists in a repository today.

It is deliberately not the GitHub client's type: the planner takes plain structs and returns plain structs, so translating an API response into this is the caller's job and this package imports no client.

type Mode

type Mode string

Mode selects how far reconciliation goes. Append is additive and never deletes; prune makes the repository match the config exactly.

const (
	ModeAppend Mode = "append"
	ModePrune  Mode = "prune"
)

The two reconciliation modes.

type Plan

type Plan struct {
	Repos []RepoPlan `json:"repos"`
}

Plan is a whole run's worth of actions, grouped per repository.

Repos is a slice rather than a map keyed by repository: ordering is part of what a plan is. Actions within a repository are emitted in the order they have to be applied, and the repositories themselves are reported in a stable order, so that two runs over the same input render identically.

func RetainDeletes

func RetainDeletes(p Plan, keep []Candidate) Plan

RetainDeletes returns p with every delete that keep does not name removed. Every other action survives untouched, and so does every repository: one whose candidates were all deselected is still a repository the run visited and still applies its creates and updates.

Filtering rather than adding is deliberate. The plan the user was shown is the plan that gets applied, minus what they declined — so a candidate can only ever be dropped between the report and the writes, never introduced. Passing every candidate back, which is what --prune=all does, returns p unchanged.

The repository a candidate belongs to is RepoPlan.Repo rather than Action.Repo: grouping is what a plan *is*, and an action carrying a disagreeing repository is a plan that was read back rather than computed.

type RepoPlan

type RepoPlan struct {
	Repo    string   `json:"repo"` // owner/repo
	Actions []Action `json:"actions"`

	// IssuesDisabled reports that the repository has issues turned off. It is a
	// **note**, not a warning and not a skip: it changes no action, no exit
	// code, and nothing that is sent to the API. Label endpoints are ungated on
	// the flag, so the repository syncs normally and its labels are used by pull
	// requests.
	//
	// It sits on the repository rather than being a synthetic action because it
	// is not something to apply. omitempty keeps it out of the stream for the
	// ordinary case, and out of the goldens of every test that predates it.
	//
	// False also covers "not known": an explicit repos entry is never
	// enumerated, and a note about a repository nothing looked at would be worse
	// than no note at all.
	IssuesDisabled bool `json:"issues_disabled,omitempty"`
}

RepoPlan is one repository's actions, in the order they must be applied: renames, then squatter recolours, then creates, then updates, then deletes.

func Compute

func Compute(repo config.Repo, desired []config.Label, current []Label, mode Mode, renames []config.Rename) RepoPlan

Compute reconciles one repository. It is pure — no network, no clock, no randomness — so the same input always produces byte-identical output.

repo is the repository the actions belong to. Its owner/repo fills Action.Repo and RepoPlan.Repo; its HasIssues fills RepoPlan.IssuesDisabled and nothing else. The flag arrives as input and is never asked of GitHub here — that is what keeps this function pure. desired is the label set resolved for this repository, current is what the repository holds today.

Issues being disabled changes nothing

A repository with issues off gets exactly the actions it would otherwise get, byte for byte. Label endpoints are ungated on the flag, so such a repository syncs normally and its labels are used by pull requests; the note exists only because label changes on one are surprising enough that a reader would otherwise suspect the config or the group filter.

Order

Actions come back in the order they have to be applied:

  1. Renames, so later matching sees the new names and a rename plus a recolour of the same label collapses into coherent steps rather than a delete and a create.
  2. Squatter recolours, before the configured label claims the colour, so that a run aborting mid-repository leaves no configured label sharing a colour with a label that was supposed to have moved off it. GitHub permits duplicate colours, so this is crash-consistency, not validity.
  3. Creates, for configured labels the repository does not have.
  4. Existing configured labels in ascending name order: an update when colour, description, or casing differs, a no-op when nothing does.
  5. Deletes, prune mode only, in ascending name order.

Modes

ModeAppend never emits a delete: whatever the repository holds beyond the configured set is left where it is. ModePrune additionally records every unconfigured label as a removal candidate.

A candidate is exactly that. Which candidates are actually deleted is chosen by the caller — an interactive selection, or --prune=all — and the planner takes no part in it, which is what makes prune semantics testable without a terminal.

Repositories the config does not cover

An empty desired set means no group resolved to this repository, and such a repository is never touched: Compute returns no actions at all rather than treating every label it holds as unconfigured. This is the tool's primary safety property, and the guard is deliberately on the desired set rather than on how it came to be empty — a repository listed in a group no label opts into is just as uncovered as one no group names.

Renames

A rename is emitted only when from exists and to does not, both compared case-insensitively, and the local view of the repository is rewritten before anything else looks at it. Everything downstream therefore reasons about the names the repository will hold, not the ones it holds now.

Colour ownership

Every configured colour is reserved. An unconfigured label sitting on a reserved colour is a squatter and is recoloured, in ascending name order, each allocation being fed back into the used set so no two squatters are handed the same colour.

Descriptions

Descriptions are authoritative. A configured label with no description means the description is "", and converging on that clears whatever the repository has.

type Repository

type Repository struct {
	Kind           string `json:"kind"` // always RepositoryKind
	Repo           string `json:"repo"` // owner/repo
	IssuesDisabled bool   `json:"issues_disabled,omitempty"`
}

Repository is an NDJSON record about a repository rather than about one of its labels. It is emitted only when there is something to say — today, that issues are disabled — and it carries no counts and nothing to apply.

It is a record and not a field on an action because it is a property of the repository: putting it on every action would repeat it, and putting it on one action would make a reader wonder what was special about that one.

type Summary

type Summary struct {
	Kind         string `json:"kind"` // always SummaryKind
	Repositories int    `json:"repositories"`
	Created      int    `json:"created"`
	Updated      int    `json:"updated"`
	Deleted      int    `json:"deleted"`
	Unchanged    int    `json:"unchanged"`
}

Summary is the closing count of a rendered plan: the last line of the pretty diff, and the last object of the NDJSON stream.

Unchanged counts KindNoOp actions — labels that were checked and already matched. They are reported precisely so a clean run says "I looked" rather than saying nothing at all.

func Summarise

func Summarise(p Plan) Summary

Summarise counts a plan. It is exported because the counts are also what a caller decides an exit code from — a dry run with anything but no-ops has found drift.

Jump to

Keyboard shortcuts

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