Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Taskfile.dist.yml
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ tasks:
cmds:
- task: dc:run:go-builder
vars:
SUB_CMD: "go test -tags=integration ./..."
SUB_CMD: "go test -race -tags=integration ./..."

release:dry-run:
desc: Build a snapshot release locally without publishing
Expand Down
15 changes: 15 additions & 0 deletions docs/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -667,6 +667,16 @@ surfaced as a stable `error_kind` string in JSON output — exactly the specs-cl
func KindOf(err error) string // stable kind string, or "" when no known sentinel is wrapped
```

**The wrapping rule is the package rule.** A call site with context to add never returns a
sentinel bare, and never renders one with `%v` or into a freshly constructed error — either
breaks both `errors.Is` matching and `KindOf`:

```go
return fmt.Errorf("%w: %s", labelsync.ErrInvalidColor, raw)
```

The kind strings are a public contract. They may be added to, never renamed.

| Sentinel | Kind string |
|-------------------------------|------------------------------|
| `ErrConfigNotFound` | `config_not_found` |
Expand All @@ -688,6 +698,11 @@ func KindOf(err error) string // stable kind string, or "" when no known sentine
| `ErrRepoInaccessible` | `repo_inaccessible` |
| `ErrMaxWaitExceeded` | `max_wait_exceeded` |

**Adding a sentinel means adding a row here, a `KindOf` case, and an entry in the `allSentinels`
test table.** The test derives its expected set by parsing the package source for exported `Err*`
variables, so a sentinel that is declared but not tabled — or tabled after being removed — fails
the build rather than silently escaping `KindOf` and rendering an empty `error_kind`.

---

## Output
Expand Down
4 changes: 4 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
module github.com/specsnl/labelsync

go 1.26.5

require github.com/adrg/xdg v0.5.3

require golang.org/x/sys v0.26.0 // indirect
12 changes: 12 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78=
github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo=
golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
34 changes: 34 additions & 0 deletions internal/labelsync/configuration.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package labelsync

import (
"path/filepath"

"github.com/adrg/xdg"
)

const (
// AppName is the binary name, and the directory name used under both the XDG
// config and cache homes.
AppName = "labelsync"

// Config file names. Both spellings are accepted; having both present in one
// directory is ErrAmbiguousConfigFile.
ConfigYMLFile = "labels.yml"
ConfigYAMLFile = "labels.yaml"
)

// ConfigFileNames lists the accepted config file names, in the order a directory
// is searched. A directory containing more than one of them is ambiguous.
var ConfigFileNames = []string{ConfigYMLFile, ConfigYAMLFile}

// ConfigDir returns the labelsync configuration directory.
// Defaults to $XDG_CONFIG_HOME/labelsync (~/.config/labelsync).
func ConfigDir() string {
return filepath.Join(xdg.ConfigHome, AppName)
}

// CacheDir returns the directory holding the label/ETag cache.
// Defaults to $XDG_CACHE_HOME/labelsync (~/.cache/labelsync).
func CacheDir() string {
return filepath.Join(xdg.CacheHome, AppName)
}
46 changes: 46 additions & 0 deletions internal/labelsync/configuration_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package labelsync_test

import (
"path/filepath"
"slices"
"testing"

"github.com/adrg/xdg"
"github.com/specsnl/labelsync/internal/labelsync"
)

func TestConfigDir_XDGOverride(t *testing.T) {
tmp := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", tmp)
xdg.Reload()
t.Cleanup(func() { xdg.Reload() })

got := labelsync.ConfigDir()
want := filepath.Join(tmp, "labelsync")

if got != want {
t.Errorf("ConfigDir() = %q, want %q", got, want)
}
}

func TestCacheDir_XDGOverride(t *testing.T) {
tmp := t.TempDir()
t.Setenv("XDG_CACHE_HOME", tmp)
xdg.Reload()
t.Cleanup(func() { xdg.Reload() })

got := labelsync.CacheDir()
want := filepath.Join(tmp, "labelsync")

if got != want {
t.Errorf("CacheDir() = %q, want %q", got, want)
}
}

func TestConfigFileNames(t *testing.T) {
want := []string{"labels.yml", "labels.yaml"}

if !slices.Equal(labelsync.ConfigFileNames, want) {
t.Errorf("ConfigFileNames = %q, want %q", labelsync.ConfigFileNames, want)
}
}
144 changes: 144 additions & 0 deletions internal/labelsync/errors.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
// Package labelsync holds the values every other labelsync package depends on:
// the sentinel errors that describe how a run can fail, and the XDG paths and
// file names that describe where its files live. It has no behaviour of its own
// and imports nothing from the rest of the tree, so any package may import it.
//
// # Wrapping rule
//
// Sentinels are never returned bare from a call site that has context to add.
// Always wrap with %w so the caller keeps both a readable message and a
// machine-comparable identity:
//
// return fmt.Errorf("%w: %s", labelsync.ErrInvalidColor, raw)
//
// Callers match with errors.Is; the JSON output layer calls KindOf to render the
// stable error_kind field. Returning a sentinel through %v or a freshly
// constructed error breaks both.
package labelsync

import "errors"

// Sentinel errors for every way a labelsync run can fail. Each one maps to a
// stable kind string in KindOf — see the error table in docs/design.md.
var (
// ErrConfigNotFound is returned when no config file is found at the --config
// path, in the working directory, or under the XDG config directory.
ErrConfigNotFound = errors.New("no config file found")

// ErrAmbiguousConfigFile is returned when both labels.yml and labels.yaml
// exist in the same directory. Only one is allowed.
ErrAmbiguousConfigFile = errors.New("ambiguous config file: both labels.yml and labels.yaml exist — remove one")

// ErrUnsupportedConfigVersion is returned when the config's version field is
// missing, or names a schema version this binary does not understand.
ErrUnsupportedConfigVersion = errors.New("unsupported config version")

// ErrEmptyConfig is returned when the config parses but declares no labels,
// leaving nothing to reconcile.
ErrEmptyConfig = errors.New("config declares no labels")

// ErrDuplicateLabelName is returned when two label entries share a name.
// Comparison is case-insensitive, because GitHub treats label names that way.
ErrDuplicateLabelName = errors.New("duplicate label name")

// ErrDuplicateLabelColor is returned when two label entries share a colour.
// Distinct labels need distinct colours for the diff to stay readable.
ErrDuplicateLabelColor = errors.New("duplicate label colour")

// ErrInvalidColor is returned when a colour is not a 6-digit hex value, with
// or without a leading #.
ErrInvalidColor = errors.New("invalid colour: want a 6-digit hex value")

// ErrInvalidLabelName is returned when a label name is empty or longer than
// the 50 characters GitHub accepts.
ErrInvalidLabelName = errors.New("invalid label name")

// ErrDescriptionTooLong is returned when a label description exceeds the 100
// characters GitHub accepts.
ErrDescriptionTooLong = errors.New("label description is too long")

// ErrUnknownGroup is returned when a label, or defaults.groups, references a
// group name that the groups section does not define.
ErrUnknownGroup = errors.New("unknown group")

// ErrAmbiguousGroupSource is returned when a group mixes sources. Exactly one
// of org, user, repos, or include_groups must be set.
ErrAmbiguousGroupSource = errors.New("ambiguous group source: set exactly one of org, user, repos, or include_groups")

// ErrCyclicGroup is returned when include_groups forms a cycle, so the group
// cannot be resolved to a repository set.
ErrCyclicGroup = errors.New("cyclic group composition")

// ErrInvalidRepoRef is returned when a repository reference is not in
// owner/repo form.
ErrInvalidRepoRef = errors.New("invalid repository reference: want owner/repo")

// ErrInvalidRename is returned when a rename entry is malformed: an empty
// from or to, a rename to a name no label declares, or two renames targeting
// the same name.
ErrInvalidRename = errors.New("invalid rename")

// ErrNoToken is returned when the token resolution chain finds no GitHub
// credential to authenticate with.
ErrNoToken = errors.New("no GitHub token found")

// ErrInteractiveRequired is returned when an operation needs a prompt but
// stdin is not a TTY — prune without --prune=all in CI, for example.
ErrInteractiveRequired = errors.New("operation requires an interactive terminal")

// ErrRepoInaccessible is returned when a repository cannot be reached with
// the current token: missing, archived, or outside the token's scopes. It is
// reported per repository and does not abort the run.
ErrRepoInaccessible = errors.New("repository is inaccessible")

// ErrMaxWaitExceeded is returned when a rate-limit backoff would sleep for
// longer than the --max-wait ceiling allows.
ErrMaxWaitExceeded = errors.New("rate limit wait exceeds --max-wait")
)

// KindOf returns a stable, machine-readable string identifier for the sentinel
// wrapped in err, or "" when err wraps no known sentinel.
// The returned strings are a public contract: they are embedded in JSON output
// as the error_kind field, so they may be added to but never renamed.
func KindOf(err error) string {
switch {
case errors.Is(err, ErrConfigNotFound):
return "config_not_found"
case errors.Is(err, ErrAmbiguousConfigFile):
return "ambiguous_config_file"
case errors.Is(err, ErrUnsupportedConfigVersion):
return "unsupported_config_version"
case errors.Is(err, ErrEmptyConfig):
return "empty_config"
case errors.Is(err, ErrDuplicateLabelName):
return "duplicate_label_name"
case errors.Is(err, ErrDuplicateLabelColor):
return "duplicate_label_color"
case errors.Is(err, ErrInvalidColor):
return "invalid_color"
case errors.Is(err, ErrInvalidLabelName):
return "invalid_label_name"
case errors.Is(err, ErrDescriptionTooLong):
return "description_too_long"
case errors.Is(err, ErrUnknownGroup):
return "unknown_group"
case errors.Is(err, ErrAmbiguousGroupSource):
return "ambiguous_group_source"
case errors.Is(err, ErrCyclicGroup):
return "cyclic_group"
case errors.Is(err, ErrInvalidRepoRef):
return "invalid_repo_ref"
case errors.Is(err, ErrInvalidRename):
return "invalid_rename"
case errors.Is(err, ErrNoToken):
return "no_token"
case errors.Is(err, ErrInteractiveRequired):
return "interactive_required"
case errors.Is(err, ErrRepoInaccessible):
return "repo_inaccessible"
case errors.Is(err, ErrMaxWaitExceeded):
return "max_wait_exceeded"
default:
return ""
}
}
Loading