Documentation
¶
Overview ¶
Package config finds the labelsync config file, parses it, normalises what it parsed, and validates the result. Nothing here touches the network: the rules in the design's validation table live in validate.go, which LoadFile runs once normalisation is done, and turning groups into repository selectors lives in resolve.go.
Parse is the one entry point that stops short of validating, so that a test — or anything else that wants the decoder without the rules — can hand it a fragment.
Normalisation is the part every later stage depends on. Once Parse returns, colours are bare lowercase hex, label names are trimmed, every label carries the groups it belongs to — its own, or the ones defaults.groups supplies — and every group's skip_archived, skip_forks, and visibility hold a real value rather than a Go zero value that happens to mean "unset".
Index ¶
- Constants
- func DuplicateColors(labels []Label) map[string][]string
- func Export(repo string, labels []Label) ([]byte, error)
- func Find(explicit string) (string, error)
- func Scaffold() []byte
- type Config
- type Defaults
- type Group
- type Groups
- type Label
- type Rename
- type Repo
- type Resolution
- func (r *Resolution) Desired(repo Repo) []Label
- func (r *Resolution) Groups(repo Repo) []string
- func (r *Resolution) Matches(group string, repo Repo) bool
- func (r *Resolution) Names() []string
- func (r *Resolution) Selectors() []Selector
- func (r *Resolution) SelectorsFor(group string) []Selector
- func (r *Resolution) Warnings() []Warning
- type Selector
- type SourceKind
- type Visibility
- type Warning
Constants ¶
const ( MaxNameRunes = 50 MaxDescriptionRunes = 100 )
The label bounds GitHub enforces. Both count Unicode code points, not bytes and not grapheme clusters — measured against the live API in #18, and recorded in docs/design.md. Overflow is a 422 rather than a truncation, so these mirror the API rather than being stricter than it.
const ExportGroup = "exported"
ExportGroup is the name of the group an exported config declares. A config with no groups at all would parse and validate and then select nothing, so the export names the repository it came from and points defaults.groups at it — the file is usable as it lands rather than after an edit nothing told the user to make.
const SchemaVersion = 1
SchemaVersion is the config schema this binary understands. A file that does not name it is rejected rather than assumed, so a future version 2 cannot be read as though it were a version 1 with unfamiliar keys.
Variables ¶
This section is empty.
Functions ¶
func DuplicateColors ¶
DuplicateColors returns the colours more than one of labels uses, each with the names that share it, sorted.
It is exported because the command warns about them on stderr as well as commenting them in the file: a redirected export is a file nobody reads until the next run rejects it.
func Export ¶
Export renders labels as a config file for repo.
Labels are sorted by name so that two exports of the same repository produce the same bytes, whatever order the API listed them in, and colours go through the same normalisation the loader applies — so export, load, export is a fixed point rather than a diff.
The result validates clean, with one exception it flags rather than hides: a repository holding two labels of the same colour is exported as it is, with a comment on both saying so. See [duplicateColorNote].
func Find ¶
Find returns the path of the config file to use, in this order:
- explicit, when --config was given
- labels.yml or labels.yaml in the working directory
- labels.yml or labels.yaml under the XDG config directory
An explicit path that names a directory is searched the same way as the other two. A directory holding both spellings is ErrAmbiguousConfigFile; finding nothing anywhere is ErrConfigNotFound.
Types ¶
type Config ¶
type Config struct {
Version int `yaml:"version"`
Groups Groups `yaml:"groups,omitempty"`
Defaults Defaults `yaml:"defaults,omitempty"`
Renames []Rename `yaml:"renames,omitempty"`
Labels []Label `yaml:"labels,omitempty"`
// Path is the file this Config was read from, for error messages. It is
// not a config field: Parse leaves it empty, LoadFile fills it in.
Path string `yaml:"-"`
}
Config is a parsed labels.yml.
func Load ¶
Load resolves the config file and parses it. The explicit path is the --config flag; empty means "search". See Find for the resolution order.
func LoadFile ¶
LoadFile reads, parses, normalises, and validates one config file, and records where it came from on the returned Config. A Config it returns has passed every rule in validate.go, so no later stage re-checks one.
func Parse ¶
Parse decodes config YAML and normalises the result. It is the whole of the load path that a test can drive without a file, and the entry point the golden normalisation fixtures go through.
func (*Config) Resolve ¶
func (c *Config) Resolve(authenticatedUser string) (*Resolution, error)
Resolve turns the groups section into selectors: one source per group, include_groups flattened into the selectors of the groups it names, and every filter carried along.
authenticatedUser is the login the run's token belongs to, and may be empty when it is not known yet. It decides nothing about membership; it only picks which of the two user endpoints a user selector has to use, and whether asking for private repositories of that user is going to come back empty.
Resolve checks the group graph and nothing else. A label naming a group that does not exist is ErrUnknownGroup from validate.go, not from here — a label is not part of the graph, and its group simply matches no repository.
func (*Config) Validate ¶
Validate applies every rule in the design's validation table to an already normalised Config, and returns the first one broken, wrapped in its sentinel.
It runs entirely offline and is the whole of the tool's input checking: once it returns nil, no later stage re-asks whether a colour is hex or whether a group a label names exists. LoadFile calls it, so a config that reaches the planner has passed. Parse deliberately does not, which is what lets the normalisation tests drive fragments that were never meant to be whole files.
The first broken rule wins rather than a collected list. A config file is edited by hand and re-run in a second; a wall of errors, most of them knock-on effects of the first, reads worse than one that names the line to fix.
type Defaults ¶
type Defaults struct {
// Groups is applied to every label that declares no groups of its own.
Groups []string `yaml:"groups,omitempty"`
}
Defaults holds the fallbacks applied to labels that do not speak for themselves.
type Group ¶
type Group struct {
Org string `yaml:"org,omitempty"`
User string `yaml:"user,omitempty"`
Repos []string `yaml:"repos,omitempty"`
IncludeGroups []string `yaml:"include_groups,omitempty"`
// Include and Exclude are globs over the repository name only, not
// owner/repo. Exclude is applied after Include.
Include []string `yaml:"include,omitempty"`
Exclude []string `yaml:"exclude,omitempty"`
// SkipArchived, SkipForks, and Visibility apply to org and user sources.
// All three are defaulted while decoding, not afterwards, because an
// explicit "skip_archived: false" and an omitted key are both false by the
// time a plain struct has been filled in.
SkipArchived bool `yaml:"skip_archived"`
SkipForks bool `yaml:"skip_forks"`
Visibility Visibility `yaml:"visibility"`
}
Group is one repository selector. Exactly one of Org, User, Repos, or IncludeGroups is the group's source — validate.go enforces that; parsing accepts whatever the file says.
func (*Group) UnmarshalYAML ¶
UnmarshalYAML decodes a group over the defaults rather than over a zero value, which is the only way "skip_archived: false" can be distinguished from an omitted skip_archived once the result is a plain bool.
The alias type is what stops this from recursing: it has the same fields and tags, and no UnmarshalYAML method.
type Groups ¶
Groups is the config's groups section, keyed by group name.
func (*Groups) UnmarshalYAML ¶
UnmarshalYAML decodes the groups section entry by entry, which is what a group written as a bare key ("self:") needs: yaml.v3 resolves a null node to a zero value without ever consulting the value's own UnmarshalYAML, so such a group would silently come out with skip_archived and skip_forks false and no visibility at all.
type Label ¶
type Label struct {
Name string `yaml:"name"`
Color string `yaml:"color"`
Description string `yaml:"description,omitempty"`
Groups []string `yaml:"groups,omitempty"`
}
Label is one desired label.
Description is authoritative and deliberately a plain string: the design defines an omitted description as "clear it", which is exactly what an empty string means downstream, so an omitted and an explicitly empty description need not be told apart.
type Rename ¶
Rename maps an existing label name onto a configured one. Renames are applied before matching, so the issue and PR associations survive.
type Repo ¶
type Repo struct {
Owner string
Name string
// Archived, Fork, and Private are only consulted for org and user
// selectors. An explicit repos entry names a repository outright, and a
// filter that silently dropped a repository the config asked for by name
// would be a surprise rather than a safety net.
Archived bool
Fork bool
Private bool
// HasIssues is whether the repository has issues enabled, and nil when that
// is not known. It is **not** a filter and nothing skips on it: the GH-17
// spike confirmed that repository-scoped label endpoints are ungated on it,
// so such a repository syncs normally and its labels are genuinely used by
// pull requests.
//
// It is carried because the diff notes it — label changes on a repository
// with issues off are surprising enough that a reader would otherwise
// suspect the config or the group filter. Filtering those repositories out
// stays the user's choice, through the group filters.
//
// The pointer is what keeps "not known" from rendering as "disabled". An
// explicit repos entry is never enumerated, so nothing ever saw the flag for
// one, and a plain bool would put an untrue note on every repository the
// config names outright.
HasIssues *bool
}
Repo is one repository, in as much detail as a selector looks at. It is a plain struct on purpose: the enumerator in internal/github fills one in per repository it sees, and every membership question is answered here, offline.
func ParseRepoRef ¶
ParseRepoRef splits an owner/repo reference. Anything else — a bare name, a URL, an empty half, a space in either half — is ErrInvalidRepoRef.
This is the only place a reference is judged, so a repos entry in the config and a --repo on the command line are held to one rule.
Whitespace around either half is trimmed rather than rejected: a human typed this into YAML, and `specsnl / labelsync` says which repository it means. A space *inside* a half is a different thing and is rejected, because GitHub has no such name and the reference cannot be what was meant.
type Resolution ¶
type Resolution struct {
// contains filtered or unexported fields
}
Resolution is the network-free answer to "which repositories does each group select, and which labels does a given repository want".
func (*Resolution) Desired ¶
func (r *Resolution) Desired(repo Repo) []Label
Desired returns the labels repo should have, in config order: every label whose groups contain a group that resolves to repo.
A repository no group resolves to yields no labels. That is not the same as an empty config, and callers must keep telling the two apart — an empty desired set for an unselected repository means "leave it alone", never "delete everything it has".
func (*Resolution) Groups ¶
func (r *Resolution) Groups(repo Repo) []string
Groups returns the names of the groups that select repo, sorted. An empty result is the safety property: no group resolves to this repository, so nothing about it is ours to touch.
func (*Resolution) Matches ¶
func (r *Resolution) Matches(group string, repo Repo) bool
Matches reports whether group selects repo. A group that does not exist matches nothing.
func (*Resolution) Names ¶
func (r *Resolution) Names() []string
Names returns every group name, sorted.
func (*Resolution) Selectors ¶
func (r *Resolution) Selectors() []Selector
Selectors returns every distinct selector in the config, sorted by the group that defined it. This is the enumeration work list: one walk per selector covers every group, however many composed groups point at it.
func (*Resolution) SelectorsFor ¶
func (r *Resolution) SelectorsFor(group string) []Selector
SelectorsFor returns the selectors a single group resolves to. A composed group returns the selectors of every group it reaches, deduplicated; an undefined group returns nothing.
func (*Resolution) Warnings ¶
func (r *Resolution) Warnings() []Warning
Warnings returns what the resolution wants said out loud, in group order.
type Selector ¶
type Selector struct {
// Group is the group this selector came from. A composed group borrows the
// selectors of the groups it includes, so this can differ from the group
// the caller asked about.
Group string
Kind SourceKind
Owner string // the org or user login; empty for SourceRepos
Repos []Repo // the parsed owner/repo list; empty for the other kinds
// Include and Exclude are globs over the repository name only, never
// owner/repo. Exclude is applied after Include.
Include []string
Exclude []string
SkipArchived bool
SkipForks bool
Visibility Visibility
// AuthenticatedUser records which of the two user endpoints enumeration has
// to call. GET /user/repos?affiliation=owner sees private repositories but
// only for the token's own user; GET /users/{user}/repos works for anyone
// and returns public repositories only. The decision needs the
// authenticated login, which this package must not go and fetch, so it is
// made once here from the login the caller passes to Resolve.
AuthenticatedUser bool
}
Selector is one group's source, flattened and defaulted: everything the enumerator needs to list repositories, and everything Matches needs to decide whether a repository belongs. It is deliberately not a repository list — producing one needs the network, which is what keeps this package testable without an HTTP mock.
func (Selector) Matches ¶
Matches reports whether repo belongs to this selector. It answers the same question the enumerator's filters answer, so a repository listed by the API and a repository handed straight to --repo are judged by one rule.
func (Selector) Reject ¶
Reject is Selector.Matches with its reasoning: "" when repo belongs, and otherwise why it does not.
The two are one function rather than two so that the reason a repository was filtered out cannot disagree with whether it was. `labelsync groups` prints these; enumeration ignores them.
A repos selector rejects with no reason at all — a repository is either named or it is not, and "the config does not list it" is not an explanation anyone needs.
type SourceKind ¶
type SourceKind string
SourceKind names where a selector's repositories come from. There is no kind for include_groups: composition is flattened away during resolution, so what a composed group leaves behind is the selectors of the groups it includes.
const ( SourceOrg SourceKind = "org" SourceUser SourceKind = "user" SourceRepos SourceKind = "repos" )
The three selector kinds an enumerator has to know how to walk.
type Visibility ¶
type Visibility string
Visibility selects which repositories an org or user group enumerates.
const ( VisibilityAll Visibility = "all" VisibilityPublic Visibility = "public" VisibilityPrivate Visibility = "private" )
The accepted visibility values. VisibilityAll is the default a group gets when it does not say.