✨ feat(cli): declarative [[labels]] + gwm labels push - #90
Merged
Conversation
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
There was a problem hiding this comment.
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-freelabelsmodule. - Adds
gwm labels list(diff display) andgwm labels push(create/update, optional--prune,--dry-run,--random-colors) CLI wiring andghargv/JSON parsing helpers. - Updates docs, example config, changelog, and adds extensive unit/integration tests covering config parsing, color handling, diffing, and
ghcontracts.
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.
|
|
||
| - `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 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 on lines
+189
to
+190
| /// labels currently on the upstream remote. Pure function — no I/O, | ||
| /// no allocation beyond the returned `LabelDiff`. |
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 on lines
+388
to
+392
| #[derive(Deserialize)] | ||
| struct RawLabel2 { | ||
| name: String, | ||
| #[serde(default)] | ||
| color: String, |
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 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> { |
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
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.
This was referenced May 22, 2026
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
Implements #81: a
[[labels]]table in.gwm.tomldeclaring the desired GitHub label set, plus a newgwm labels {list,push}subcommand that creates / updates them on the upstreamoriginremote viagh label create --force.Closes #81
Type of change
Changes
[[labels]]schema — new top-level table in.gwm.toml(namerequired;description/coloroptional). Absent block ⇒ both subcommands are no-ops, noghcall.labelsmodule — pure (no I/O) FNV-1a-based deterministic pastel colours (same colour for the same label name across repos), hex normalisation (#D73A4A⇒d73a4a), and adiff_labels()engine producingto_create/to_update/matching/extra_on_remotebuckets.fetch_remote_labels,push_label,delete_labelshelling out togh label list/create/delete. Argv builders +parse_labels_jsonare exposed publicly so tests pin the contract without a realghbinary.gwm labels listprints the diff with+ / ~ / = / -sigils;gwm labels pushapplies 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.examples/gwm.toml.example,docs/3.cli/1.reference.md,docs/4.configuration/1.gwm-toml.md, andCHANGELOG.mdall carry the new feature.Tests
cargo testpasses locally (478 tests across 19 suites — 0 failed, 0 ignored)cargo fmt --checkpassescargo clippy --all-targets -- -D warningspassestests/— 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 listJSON 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.PATHper CONTRIBUTING.md to catch CI surprises:gwm doctor(local) green on this worktreeChecklist
<type>/#<issue>-<description>(feat/#81-labels-config-and-push)## [Unreleased] > ### Addeddocs/3.cli/anddocs/4.configuration/are updated)examples/gwm.toml.exampleupdated with the new[[labels]]blockunwrap()on user-facing paths — all errors flow throughGwmError::{Config, Other, CommandFailed}println!in TUI render code (this change doesn't touch the TUI)Linked issues / docs
docs/4.configuration/1.gwm-toml.md—[[labels]]sectiondocs/3.cli/1.reference.md—gwm labels {list|push}sectionNotes for reviewers
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.--description ""instead of passing it through?gh label create --forceinterprets--description ""as "wipe the existing description". A user who declares onlyname = "bug"(no description field) expects the remote description to stay put. Empty / absent both round-trip toNoneat parse time; the argv builder skips the flag entirely in that case.cmd_labels_{list,push}? Resolving labels (which validates colours) before looking up the GitHub slug means a typo in[[labels]]surfaceslabel 'bug' has invalid color: …rather than the unrelatedno 'origin' remote configured. Caught by thelabels_list_surfaces_invalid_color_with_label_nameintegration test.--prunecarries 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-remoteand require an explicit--pruneto be deleted.