Skip to content

✨ feat(cli): declarative [[labels]] + gwm labels push - #90

Merged
kbrdn1 merged 8 commits into
devfrom
feat/#81-labels-config-and-push
May 22, 2026
Merged

✨ feat(cli): declarative [[labels]] + gwm labels push#90
kbrdn1 merged 8 commits into
devfrom
feat/#81-labels-config-and-push

Conversation

@kbrdn1

@kbrdn1 kbrdn1 commented May 22, 2026

Copy link
Copy Markdown
Owner

Description

Implements #81: a [[labels]] table in .gwm.toml declaring the desired GitHub label set, plus a new gwm labels {list,push} subcommand that creates / updates them on the upstream origin remote via gh label create --force.

Closes #81

Type of change

  • ✨ Feature (new functionality)

Changes

  • [[labels]] schema — new top-level table in .gwm.toml (name required; description / color optional). Absent block ⇒ both subcommands are no-ops, no gh call.
  • labels module — pure (no I/O) FNV-1a-based deterministic pastel colours (same colour for the same label name across repos), hex normalisation (#D73A4Ad73a4a), and a diff_labels() engine producing to_create / to_update / matching / extra_on_remote buckets.
  • gh integrationfetch_remote_labels, push_label, delete_label shelling out to gh label list/create/delete. Argv builders + parse_labels_json are exposed publicly so tests pin the contract without a real gh binary.
  • CLI surfacegwm labels list prints the diff with + / ~ / = / - sigils; gwm labels push applies it with --dry-run, --prune (destructive opt-in), --random-colors. Validates config colours BEFORE looking up the slug so a typo surfaces with the offending label name.
  • Docsexamples/gwm.toml.example, docs/3.cli/1.reference.md, docs/4.configuration/1.gwm-toml.md, and CHANGELOG.md all carry the new feature.

Tests

  • cargo test passes locally (478 tests across 19 suites — 0 failed, 0 ignored)
  • cargo fmt --check passes
  • cargo clippy --all-targets -- -D warnings passes
  • New tests added under tests/ — 38 new tests covering:
    • config_tests.rs (+4) — [[labels]] parsing: default empty, full round-trip, minimal name-only, absent section.
    • labels_tests.rs (+21, new file) — colour stability, hex validation, declared > deterministic > random resolution, full diff matrix.
    • github_tests.rs (+7) — gh label list JSON contract + argv builders for create / delete.
    • cli_binary.rs (+6) — help canary update, help dispatch, no-op fast paths, not-in-git failure, invalid-colour error surfacing.
  • Pre-validated under stripped PATH per CONTRIBUTING.md to catch CI surprises:
    PATH="$(dirname "$(command -v cargo)"):/usr/bin:/bin" cargo test --test cli_binary --test labels_tests --test config_tests --test github_tests
    # → all 4 suites green
  • gwm doctor (local) green on this worktree

Checklist

  • Branch follows <type>/#<issue>-<description> (feat/#81-labels-config-and-push)
  • Commits follow Gitmoji + Conventional Commits — 5 atomic commits grouped by concern (config schema, labels module, gh integration, CLI wiring, docs)
  • CHANGELOG.md updated under ## [Unreleased] > ### Added
  • README left as-is (landing page now; reference + config docs under docs/3.cli/ and docs/4.configuration/ are updated)
  • examples/gwm.toml.example updated with the new [[labels]] block
  • No unwrap() on user-facing paths — all errors flow through GwmError::{Config, Other, CommandFailed}
  • No println! in TUI render code (this change doesn't touch the TUI)

Linked issues / docs

Notes for reviewers

  • Why FNV-1a and not DefaultHasher? DefaultHasher::new() is documented as "the same on all currently-existing platforms" but its key state isn't part of the stability contract. FNV-1a's 64-bit (offset, prime) constants are public and fixed — same colour across compilers, platforms, and Rust versions.
  • Why omit --description "" instead of passing it through? gh label create --force interprets --description "" as "wipe the existing description". A user who declares only name = "bug" (no description field) expects the remote description to stay put. Empty / absent both round-trip to None at parse time; the argv builder skips the flag entirely in that case.
  • Why two-phase validation in cmd_labels_{list,push}? Resolving labels (which validates colours) before looking up the GitHub slug means a typo in [[labels]] surfaces label 'bug' has invalid color: … rather than the unrelated no 'origin' remote configured. Caught by the labels_list_surfaces_invalid_color_with_label_name integration test.
  • --prune carries a destructive default of OFF. Pairs with the safety stance discussed in [Feature]: --dry-run on gwm remove and gwm prune #31; existing remote labels are listed under - extra-on-remote and require an explicit --prune to be deleted.

kbrdn1 added 5 commits May 22, 2026 11:04
Declare the desired GitHub label set under `[[labels]]` in
`.gwm.toml`. `name` is required; `description` and `color` are
optional (colour resolution happens at push time so a typo in one
entry doesn't break unrelated subcommands). Names with whitespace
("good first issue") round-trip verbatim.

The block defaults to an empty vec so configs predating issue #81
see zero behaviour change: `gwm labels {list,push}` becomes a no-op
on repos that never opt in.

refs #81
New `gwm::labels` module — pure (no I/O) so the gh-backed fetch
code can stay in `github.rs`. Three responsibilities:

- **Colour resolution.** FNV-1a 64-bit hash → low 3 bytes → average
  each channel with 255 to push the output into the pastel band
  (`#7f…` to `#ff…`). FNV-1a rather than `DefaultHasher` for
  cross-platform / cross-Rust-version stability — a `bug` label
  declared in two different repos must get the same colour.
  `--random-colors` swaps the hash for a SystemTime + counter
  source so back-to-back calls inside one nanosecond don't
  collide.
- **Hex validation.** `normalize_color` strips a leading `#` and
  lowercases the result before validating shape — `#D73A4A` ⇒
  `d73a4a`. Empty / wrong-length / non-hex inputs return
  `GwmError::Config` with the offending value in the message.
- **Diff engine.** `diff_labels(declared, remote)` partitions
  entries into `to_create / to_update / matching /
  extra_on_remote`. Colour comparison lowercases both sides;
  description treats `None` ≡ `Some("")` (GitHub stores them
  interchangeably). `LabelAction::{Create,Update}` reserves room
  for future `--prune` integration without reshuffling the diff
  shape.

21 unit tests in `tests/labels_tests.rs` cover the hash stability,
hex normalisation, resolution precedence (declared > deterministic
> random), and every diff bucket including the
"normalise-remote-uppercase" canary.

refs #81
Three new public functions wrap the corresponding `gh label`
subcommands; argv builders (`label_list_argv`, `label_create_argv`,
`label_delete_argv`) are extracted alongside so tests can pin the
contract without shelling out to a real `gh` binary.

- `fetch_remote_labels(slug)` → `gh label list --json
  name,color,description --limit 1000`. `parse_labels_json` is
  exposed publicly so unit tests cover the JSON shape (empty
  array, missing description field, malformed payload) without a
  network.
- `push_label(slug, spec)` → `gh label create <name> --color
  <hex> [--description <desc>] --force --repo <slug>`. The
  `--force` flag turns the create call into create-or-update,
  which is what `gwm labels push` needs (no separate "edit"
  step). `--description` is omitted when the spec carries `None`
  rather than passed as `""` — gh would otherwise wipe an
  existing description the user didn't intend to touch.
- `delete_label(slug, name)` → `gh label delete <name> --repo
  <slug> --yes`. `--yes` bypasses gh's interactive confirm so
  `gwm labels push --prune` doesn't hang on a TTY read.

Seven argv / JSON-parse unit tests in `tests/github_tests.rs`.

refs #81
New `gwm labels {list,push}` subcommand wiring the config schema
(commit 1), the diff engine (commit 2), and the gh-backed I/O
(commit 3) into a user-visible CLI surface.

- `gwm labels list` — print the resolved set + the diff against
  `origin` (`+ create`, `~ update (color #aaa → #bbb)`, `= match`,
  `- extra-on-remote`).
- `gwm labels push` — apply create + update via `gh label create
  --force`. `--dry-run` prints the plan with an explicit summary
  line ("would create 2, update 1, leave 3 untouched, prune 0,
  ignore 1 extra-on-remote") and exits without touching the
  remote. `--prune` opt-in deletes extras on the remote
  (destructive). `--random-colors` swaps deterministic colour
  derivation for random pastels on entries with no `color` field.

Two design choices worth flagging for review:

1. **Resolve before slug.** `cmd_labels_{list,push}` validate the
   config (colours, names) BEFORE looking up the `origin` slug.
   A typo in `[[labels]]` therefore surfaces `label 'bug' has
   invalid color: not-a-hex` instead of the unrelated `no 'origin'
   remote configured`.
2. **No-op fast path.** Both subcommands check
   `config.labels.is_empty()` first and exit with `0 labels
   declared in .gwm.toml — nothing to push.` without contacting
   `gh`. A user who hasn't opted into `[[labels]]` but ran the
   command exploratorily sees that message, not `gh: not found`.

`tests/cli_binary.rs::help_prints_subcommands` is updated as the
canary required by CONTRIBUTING.md. Six new tests cover help,
help dispatch, no-op fast paths (`list`, `push`, `push --dry-run`),
the not-in-git-repo error, and the bad-colour error surfacing the
label name.

refs #81
Surface the new feature in every documentation channel a user is
likely to hit:

- `examples/gwm.toml.example` — annotated `[[labels]]` block under
  `# --- declarative GitHub labels (issue #81) ---`, with the
  three-entry sample from the issue spec (`bug`, `enhancement`,
  `good first issue`) and the per-flag workflow cheatsheet
  inlined as a comment.
- `docs/3.cli/1.reference.md` — new `## gwm labels {list|push}`
  section between `gwm status` and `gwm doctor`. Flag table,
  sigil legend, and a cross-link to the config section.
- `docs/4.configuration/1.gwm-toml.md` — new `## [[labels]]
  (issue #81)` section between `[doctor]` and "defaults without
  `.gwm.toml`". Field table, colour resolution order (declared >
  deterministic > random), and the no-op fast-path contract.
  Defaults table also picks up the new row.
- `CHANGELOG.md` — `## [Unreleased] > ### Added` entry covering
  the subcommand, the flag surface, the colour resolution rules,
  and the gh dependency note.

closes #81
Copilot AI review requested due to automatic review settings May 22, 2026 09:09

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds declarative GitHub label management to gwm-cli by introducing a [[labels]] config section and new gwm labels subcommands that diff and (optionally) sync labels to the origin repo via the gh CLI.

Changes:

  • Introduces [[labels]] in .gwm.toml (LabelConfig) plus resolution/diff logic in a new I/O-free labels module.
  • Adds gwm labels list (diff display) and gwm labels push (create/update, optional --prune, --dry-run, --random-colors) CLI wiring and gh argv/JSON parsing helpers.
  • Updates docs, example config, changelog, and adds extensive unit/integration tests covering config parsing, color handling, diffing, and gh contracts.

Reviewed changes

Copilot reviewed 13 out of 13 changed files in this pull request and generated 7 comments.

Show a summary per file
File Description
tests/labels_tests.rs Adds unit coverage for color normalization/determinism, label resolution, and diff bucketing.
tests/github_tests.rs Pins gh label list JSON contract and gh label {create,delete} argv builders.
tests/config_tests.rs Verifies [[labels]] TOML parsing defaults, minimal entries, and round-tripping.
tests/cli_binary.rs Adds CLI integration checks for labels help, no-op fast paths, and invalid-color surfacing.
src/lib.rs Exposes the new labels module publicly.
src/labels.rs Implements label spec resolution, deterministic/random color generation, and diff engine.
src/github.rs Adds gh label JSON parsing and argv builders plus fetch/push/delete helpers.
src/config.rs Adds Config.labels: Vec<LabelConfig> and schema for [[labels]].
src/cli.rs Adds gwm labels {list,push} subcommands and orchestration/printing logic.
examples/gwm.toml.example Documents and exemplifies the new [[labels]] configuration block.
docs/4.configuration/1.gwm-toml.md Documents [[labels]] schema and workflow.
docs/3.cli/1.reference.md Documents `gwm labels {list
CHANGELOG.md Records the new feature under Unreleased/Added.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread docs/4.configuration/1.gwm-toml.md Outdated

- `gwm labels list` — print the resolved set plus the diff against the remote (`+ create`, `~ update`, `= match`, `- extra-on-remote`).
- `gwm labels push` — apply create + update.
- `gwm labels push --dry-run` — plan only, no remote contact.
Comment thread src/labels.rs
Comment on lines +28 to +29
/// remote. Colour is normalised to lowercase 6-hex on parse so the
/// diff doesn't surface a spurious "update" when GitHub renders it
Comment thread src/labels.rs Outdated
Comment on lines +189 to +190
/// labels currently on the upstream remote. Pure function — no I/O,
/// no allocation beyond the returned `LabelDiff`.
Comment thread docs/3.cli/1.reference.md
Comment on lines +197 to +201
gwm labels list # show the diff against the remote
gwm labels push # apply create + update
gwm labels push --dry-run # plan only, no remote contact
gwm labels push --prune # also delete labels not in config
gwm labels push --random-colors # random pastel for entries with no `color`
Comment thread src/github.rs
Comment on lines +388 to +392
#[derive(Deserialize)]
struct RawLabel2 {
name: String,
#[serde(default)]
color: String,
Comment thread src/cli.rs
Comment on lines +947 to +965
let slug = labels_slug()?;
let remote = github::fetch_remote_labels(&slug)?;
let diff = labels::diff_labels(&declared, &remote);
let (n_create, n_update, n_match, n_extra) = diff.counts();

if dry_run {
print_labels_diff(&slug, &declared, &diff);
let pruned = if prune { n_extra } else { 0 };
println!(
"would create {}, update {}, leave {} untouched, prune {}, ignore {} extra-on-remote",
n_create,
n_update,
n_match,
pruned,
n_extra.saturating_sub(pruned),
);
return Ok(());
}

Comment thread src/labels.rs Outdated
Comment on lines +84 to +88
/// Validate that `s` is a 6-character hex string (no leading `#`,
/// lowercase). Returns `Err` if the shape is wrong; the OK variant is
/// the input verbatim — use `normalize_color` if you also want the
/// canonical form.
pub fn validate_color(s: &str) -> Result<&str> {
kbrdn1 added 3 commits May 22, 2026 11:30
Two related changes addressing Copilot review on PR #90:

- Drop `#[serde(default)]` on `RawLabel2.color`. The `gh label list`
  schema always carries a `color` field; if a future gh contract
  change ever dropped it, the silent default would coerce to `""`
  and flag every remote label as a colour mismatch in the diff.
  Failing loud at parse time surfaces the contract drift instead.
- Lowercase `color` in `parse_labels_json` so callers get a
  uniformly-shaped `RemoteLabel`. GitHub serialises hex in either
  case; the diff engine relies on the lowercase form, and
  normalising at the parse boundary means downstream code never
  has to think about it. The `RemoteLabel` doc comment claimed
  this contract before — now the implementation matches.

Two new contract tests in `tests/github_tests.rs`:
- `parse_labels_json_normalises_uppercase_color` — `D73A4A` → `d73a4a`.
- `parse_labels_json_rejects_missing_color_field` — fails loud on
  the contract-drift case.

refs #81
Two doc-only corrections addressing Copilot review on PR #90:

- `validate_color`: previous wording claimed the function only
  accepts lowercase hex, but the implementation calls
  `is_ascii_hexdigit` which is case-insensitive. Re-cast the
  function as a "shape check" — length + character class — and
  point at `normalize_color` for canonicalisation. Behaviour
  unchanged.
- `diff_labels`: previous wording claimed "no allocation beyond
  the returned LabelDiff", but the implementation transiently
  allocates a `HashMap` and `HashSet` to index by name. Re-cast as
  "no I/O, no observable side effects" and call out the transient
  allocations explicitly so future readers don't optimise for a
  memory invariant the function never held.
  Also clarify that the two-sided lowercasing of `color` inside
  `diff_labels` is now defence-in-depth on top of the normalisation
  `parse_labels_json` already performs (commit 7351713) — a manually
  -constructed `RemoteLabel` (e.g. a test fixture) with uppercase
  hex still won't slip through as a spurious "update".

No production code change in this commit, no test diff — the doc
fixes have no observable behaviour. The pinned-against test
`diff_normalises_remote_color_case_before_compare` in
`tests/labels_tests.rs` continues to assert the documented
behaviour against the canonical fixture.

refs #81
…no contact"

`--dry-run` was advertised as "plan only, no remote contact" in
both doc files, the CLI help text, and the CHANGELOG entry. That
wording was wrong: `cmd_labels_push` still calls
`fetch_remote_labels` before the dry-run early-return so the diff
can be computed — only the `gh label create / delete` mutations
are skipped.

The right framing is "no remote mutations". Updated:

- `src/cli.rs` — `Push.dry_run` arg doc + the `Push` variant doc,
  which is what `gwm labels push --help` renders.
- `docs/3.cli/1.reference.md` — flag table and the cheatsheet
  block's inline comment.
- `docs/4.configuration/1.gwm-toml.md` — the workflow bullets
  under `[[labels]]`.
- `CHANGELOG.md` — the `[Unreleased] > Added` entry.

Documentation-only change, no production behaviour or test diff.
Caught by Copilot review on PR #90 (three separate inline
comments, same issue across the user-facing surfaces).

refs #81
@kbrdn1
kbrdn1 merged commit 5d72e77 into dev May 22, 2026
8 checks passed
kbrdn1 added a commit that referenced this pull request May 22, 2026
-#89)

New "Configurability" section grouping the 10 issues filed today into
3 sub-axes: Repo conventions (#80 branch_types, #85 gitmoji), GitHub
publish (#81 labels, #82 milestones, #83 issue_template, #84 pr_template)
and Lifecycle & control surface (#87 tui.keys, #88 hooks, #89 config CLI).
#86 (aliases) also listed under Quick wins as the lowest-cost entry.

#80 and #81 are already merged on dev (PR #90, #91) but kept under
Configurability until they ship in a tagged release — migrate to the
Shipped table on the next minor cut.
kbrdn1 added a commit that referenced this pull request May 22, 2026
Pre-release cut from dev. Delta against v0.6.0 stable bundles 10
merged PRs (#90 #91 #92 #109 #110 #111 #113 #115 #116 #118)
across three configurability features, three security hardening
passes, a TUI regression fix on R: review, and E2E coverage.

- Bump Cargo.toml + Cargo.lock to 0.7.0-rc.1.
- Add changelogs/pre-releases/0.7.0-rc.1.md with the per-RC delta
  (Added / Security / Fixed / Tests / Dependencies sections).
- Index the new file in CHANGELOG.md > Past releases > Pre-releases.

[Unreleased] stays as-is — it migrates into changelogs/0.7.0.md
only at the stable promotion, per CONTRIBUTING §Releases.
@kbrdn1
kbrdn1 deleted the feat/#81-labels-config-and-push branch July 26, 2026 19:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants