|
| 1 | += {my-title} |
| 2 | +Tj Vanderpoel (bougyman) <bougyman@users.noreply.github.com> |
| 3 | +:revdate: Sep 07, 2026 |
| 4 | +:my-title: Phase 17 plan: separate issue-list label display from label filtering |
| 5 | +:icons: font |
| 6 | +:env-github: |
| 7 | +ifdef::env-github[] |
| 8 | +:tip-caption: :bulb: |
| 9 | +:note-caption: :information_source: |
| 10 | +:important-caption: :heavy_exclamation_mark: |
| 11 | +:caution-caption: :fire: |
| 12 | +:warning-caption: :warning: |
| 13 | +endif::[] |
| 14 | +:toc: |
| 15 | + |
| 16 | +== Goal |
| 17 | + |
| 18 | +Give issue listing an explicit presentation flag. `-i/--include-labels` |
| 19 | +includes each issue's labels in the output without selecting a subset of |
| 20 | +issues. Keep the existing `-l/--labels NAME[,NAME...]` spelling for label-name |
| 21 | +filtering and `-s/--status STATUS` spelling for friendly-status filtering. |
| 22 | +Every existing option keeps its contract; this phase adds only the missing |
| 23 | +presentation control: |
| 24 | + |
| 25 | +[source,sh] |
| 26 | +---- |
| 27 | +# Include labels on the normal compact listing without filtering its issues. |
| 28 | +lcls --include-labels |
| 29 | +lcls -i |
| 30 | +
|
| 31 | +# Include every assignee and every lifecycle state, with labels in the output. |
| 32 | +lcls -N --include-labels --all |
| 33 | +lcls -N -i --all |
| 34 | +
|
| 35 | +# Return issues carrying Bug, and include their labels in the output. |
| 36 | +lcls --labels Bug |
| 37 | +lcls -l Bug |
| 38 | +
|
| 39 | +# Match Bug or Feature, case-insensitively. |
| 40 | +lcls --labels Bug,Feature |
| 41 | +
|
| 42 | +# Show labels for a specifically named issue. |
| 43 | +lcls CRY-123 --include-labels |
| 44 | +
|
| 45 | +# Existing status filtering composes without changing its short option. |
| 46 | +lcls -s "Human Review" --include-labels |
| 47 | +---- |
| 48 | + |
| 49 | +== Context and failure mode |
| 50 | + |
| 51 | +Versions 2.7.0 and 2.8.0 expose `-l/--labels LABELS` on `issue list` as a |
| 52 | +value-taking filter. Commit `f966f14` introduced the filter; commit `8073f9a` |
| 53 | +then made compact label rendering conditional on that same filter being |
| 54 | +present. That coupled three different concerns: |
| 55 | + |
| 56 | +* which issues the API selects; |
| 57 | +* whether the GraphQL selection retrieves each issue's labels; and |
| 58 | +* whether the CLI renders those labels. |
| 59 | + |
| 60 | +The coupling produces a silent and misleading failure for the motivating |
| 61 | +command: |
| 62 | + |
| 63 | +[source,sh] |
| 64 | +---- |
| 65 | +lcls -N --labels --all |
| 66 | +---- |
| 67 | + |
| 68 | +Optimus treats `--labels` as an option requiring a value and consumes `--all` |
| 69 | +as that value. `parse_labels/1` accepts it as the label name `"--all"`, so the |
| 70 | +real `--all` flag is never set. The CLI sends a case-insensitive filter for a |
| 71 | +label literally named `--all`; Linear returns no issues; formatting the empty |
| 72 | +list produces only a newline and exit status zero. The user's real labels are |
| 73 | +irrelevant because the request accidentally names a label that does not exist. |
| 74 | + |
| 75 | +The behavior was reproduced against the installed 2.8.0 binary. Supplying the |
| 76 | +intended value (`lcls -N --labels Bug --all`) returned the existing Bug-labeled |
| 77 | +issues, confirming that authentication, label data, and the Linear API were |
| 78 | +all working. |
| 79 | + |
| 80 | +Phase 17 does not reinterpret that malformed command as presentation. It gives |
| 81 | +presentation the explicit `--include-labels` name and makes the malformed |
| 82 | +`--labels --all` form fail clearly. The corrected presentation command is |
| 83 | +`lcls -N --include-labels --all`. |
| 84 | + |
| 85 | +== Decisions |
| 86 | + |
| 87 | +1. *`-i/--include-labels` is the boolean issue-list display flag.* It requests |
| 88 | + label data and includes every returned issue's labels in text and JSON |
| 89 | + output. It never adds a label filter. In compact text, labels remain the |
| 90 | + existing trailing bracketed list (`[Bug, Feature]`); an issue with no labels |
| 91 | + gets no empty bracket marker. `-i` is unused by `issue list` and by the |
| 92 | + inherited global options; the top-level bare `i` subcommand alias is a |
| 93 | + different token and does not conflict. |
| 94 | +2. *`-l/--labels` remains the issue-list label filter.* It takes one label name |
| 95 | + or a comma-separated list and preserves the existing case-insensitive OR |
| 96 | + semantics. A label filter continues to imply label display so a user can see |
| 97 | + all labels on every match without also spelling `--include-labels`. |
| 98 | +3. *Presentation and filtering compose but do not imply each other in the |
| 99 | + other direction.* `--include-labels` alone changes only fetched/rendered |
| 100 | + fields; `--labels Bug` filters and displays; using both is valid and |
| 101 | + equivalent to the filter alone for label retrieval and rendering. |
| 102 | +4. *`-s/--status` stays exactly as it is.* Reusing `-s` for presentation would |
| 103 | + break an established issue-list contract, so it is explicitly out of scope. |
| 104 | + `-l/--labels`, `-s/--status`, and every other existing short and long option |
| 105 | + retain their current spelling and meaning. |
| 106 | +5. *An option token must not silently become a label-filter value.* The |
| 107 | + `--labels` parser must reject a following recognized option such as `--all` |
| 108 | + with a clear missing-value/invalid-value error before any API call. Moving |
| 109 | + presentation to a new flag without closing the original parser hole would |
| 110 | + leave the motivating silent failure intact. |
| 111 | +6. *`issue create` is unchanged.* Its existing `-l/--labels Bug,Feature` |
| 112 | + option assigns labels to a new issue and remains value-taking. Optimus |
| 113 | + scopes options to their leaf subcommand; the create and list forms happen to |
| 114 | + retain the same value-taking labels spelling but have independent parsers. |
| 115 | +7. *Do not fetch label fields by default.* A normal list without |
| 116 | + `--include-labels` or `--labels VALUE` keeps the smaller GraphQL selection and |
| 117 | + existing compact output. The new behavior is opt-in and does not add |
| 118 | + avoidable response weight to every listing. |
| 119 | + |
| 120 | +== Verified implementation boundaries |
| 121 | + |
| 122 | +* `app/lib/linear_cli/cli.ex` defines `issue list`'s current |
| 123 | + `-l/--labels` entry under `options`, routed through `parse_labels/1`. The |
| 124 | + same list spec binds `-s` to `--status`; both existing entries stay intact. |
| 125 | + The separately scoped `issue create` labels option also does not change in |
| 126 | + this phase. |
| 127 | +* `LinearCli.CLI.Commands.issue_list/1` currently copies |
| 128 | + `options.labels` into the domain input and passes |
| 129 | + `labels: input.labels != []` to `Display.show/2`. This is the CLI-layer |
| 130 | + coupling point to split. |
| 131 | +* `LinearCli.Linear.Issue`'s `:list` action currently has one `labels` array |
| 132 | + argument, used as the server-side filter. `Issue.Read.List.read/4` also uses |
| 133 | + `args.labels != []` to choose between the base GraphQL document and the |
| 134 | + label-bearing document. The filter argument can stay intact; a separate |
| 135 | + `include_labels` boolean is needed for field selection. |
| 136 | +* `Issue.from_map/1` already parses `issue.labels.nodes` into `%Label{}` |
| 137 | + structs, and `Issue.list_fields_with_labels/0` already supplies the needed |
| 138 | + GraphQL selection. No new resource or response mapper is required. |
| 139 | +* `LinearCli.CLI.Display` already accepts a boolean `labels` display option and |
| 140 | + has tests for showing and hiding trailing label brackets. That internal |
| 141 | + contract and the current accessible, linear text format can remain intact. |
| 142 | +* ID-driven `issue list` calls use `Issue.full_fields/0`, which already fetches |
| 143 | + labels. `--include-labels` controls whether compact output prints them; the |
| 144 | + existing full-output `Labels:` line remains unchanged. |
| 145 | + |
| 146 | +== Implementation design |
| 147 | + |
| 148 | +=== CLI parsing |
| 149 | + |
| 150 | +In `app/lib/linear_cli/cli.ex`: |
| 151 | + |
| 152 | +* add `include_labels` to the `issue list` `flags` block with short name `-i`, |
| 153 | + long name `--include-labels`, and help text such as |
| 154 | + `Include labels in issue list output`; |
| 155 | +* retain the value-taking `labels` option as `-l/--labels LABELS` and preserve |
| 156 | + its comma-separated, case-insensitive OR parser; |
| 157 | +* retain the existing `-s/--status STATUS` option unchanged; and |
| 158 | +* make `parse_labels/1` reject a recognized flag token supplied as |
| 159 | + `--labels`' would-be value, so `lcls --labels --all` exits as a usage error |
| 160 | + and makes no GraphQL request. |
| 161 | + |
| 162 | +Do not change the separately scoped `issue create` labels option or its |
| 163 | +parser. |
| 164 | + |
| 165 | +=== Command and domain inputs |
| 166 | + |
| 167 | +In `LinearCli.CLI.Commands.issue_list/1`: |
| 168 | + |
| 169 | +* retain `label_filter = Map.get(options, :labels) || []`; |
| 170 | +* derive `include_labels` as |
| 171 | + `Map.get(flags, :include_labels, false) || label_filter != []`; |
| 172 | +* pass the existing domain filter as `labels: label_filter`; |
| 173 | +* pass the new domain selection switch as |
| 174 | + `include_labels: include_labels`; and |
| 175 | +* pass the existing `labels: include_labels` option to `Display.show/2`. |
| 176 | + |
| 177 | +In the `LinearCli.Linear.Issue` `:list` action, add |
| 178 | +`argument :include_labels, :boolean, default: false`. Keep `:labels` as the |
| 179 | +array of names used by `maybe_put_label_filter/2`; this avoids an unnecessary |
| 180 | +break in the domain code interface while giving each concern one explicit |
| 181 | +input. |
| 182 | + |
| 183 | +`Issue.Read.List` chooses `list_document_with_labels/0` from |
| 184 | +`args.include_labels`, not from whether `args.labels` is empty. Filtering still |
| 185 | +comes exclusively from `maybe_put_label_filter/2`. As a defensive domain |
| 186 | +invariant, a non-empty `args.labels` value should also force label field |
| 187 | +selection even if a future caller forgets to set `include_labels`; the CLI |
| 188 | +sets both deliberately, while the manual read remains safe for direct domain |
| 189 | +callers. |
| 190 | + |
| 191 | +=== Display and output formats |
| 192 | + |
| 193 | +Keep the internal boolean `labels` display option unchanged. Compact text |
| 194 | +continues to append all returned label names in brackets when enabled. It does |
| 195 | +not show only the name used to filter: an issue matching `Bug` but also carrying |
| 196 | +`Feature` renders both. |
| 197 | + |
| 198 | +JSON output must contain the populated `labels` array whenever |
| 199 | +`--include-labels` or `--labels VALUE` requested label data. Without either |
| 200 | +switch, its current unloaded empty/default value remains unchanged; this phase |
| 201 | +does not make every JSON listing pay for nested label data. |
| 202 | + |
| 203 | +== Tests |
| 204 | + |
| 205 | +Extend the existing consolidated test files rather than introducing a new |
| 206 | +suite. |
| 207 | + |
| 208 | +`app/test/linear_cli/cli/issue_commands_test.exs`: |
| 209 | + |
| 210 | +* the desired command, `issue list -N --include-labels --all` (and its `-i` |
| 211 | + spelling), parses both boolean flags, sends neither an assignee nor |
| 212 | + lifecycle-date filter, sends no label filter, requests label fields, and |
| 213 | + renders returned labels; |
| 214 | +* bare `--include-labels` requests label fields while leaving the API filter |
| 215 | + free of a `labels` key; |
| 216 | +* `--labels Bug` and `-l Bug` preserve single-name case-insensitive filtering |
| 217 | + and display labels; |
| 218 | +* `--labels Bug,Feature` preserves OR filtering and displays every label on a |
| 219 | + matching issue; |
| 220 | +* `--labels no-such-label` returns the normal empty result; |
| 221 | +* `--labels` followed by `--all` is a clear usage error and sends no request; |
| 222 | +* `-s "Human Review"` and `--status "Human Review"` retain friendly-status |
| 223 | + filtering and compose with `-i/--include-labels`; |
| 224 | +* a positional issue identifier composed with trailing `--include-labels` still |
| 225 | + uses the full issue lookup and renders its labels; |
| 226 | +* with neither label switch, labels stay out of the compact GraphQL selection |
| 227 | + and text; and |
| 228 | +* `--output json --include-labels` contains the fetched label objects without |
| 229 | + human text mixed into stdout. |
| 230 | + |
| 231 | +`app/test/linear_cli/linear/issue_test.exs`: |
| 232 | + |
| 233 | +* `include_labels: true, labels: []` selects and parses label fields without |
| 234 | + adding a label filter; |
| 235 | +* `include_labels: false, labels: []` retains the base selection; |
| 236 | +* non-empty `labels` retains the existing single/multiple filter shapes and |
| 237 | + defensively selects label fields; and |
| 238 | +* API errors and pagination behave identically for both document variants. |
| 239 | + |
| 240 | +`app/test/linear_cli/cli/display_test.exs` retains its existing boolean |
| 241 | +`labels` option coverage for multiple labels and an unlabeled issue; no display |
| 242 | +API rename is part of this phase. |
| 243 | + |
| 244 | +The focused verification command is: |
| 245 | + |
| 246 | +[source,sh] |
| 247 | +---- |
| 248 | +cd app |
| 249 | +mix test \ |
| 250 | + test/linear_cli/cli/issue_commands_test.exs \ |
| 251 | + test/linear_cli/linear/issue_test.exs \ |
| 252 | + test/linear_cli/cli/display_test.exs |
| 253 | +---- |
| 254 | + |
| 255 | +Finish with the repository's complete `mix precommit` gate from the root. |
| 256 | + |
| 257 | +== Documentation |
| 258 | + |
| 259 | +* `Readme.adoc`'s List Issues section gains the command contract shown in the |
| 260 | + Goal, including the explicit distinction between `--include-labels` |
| 261 | + (presentation) and `--labels` (filtering). No migration note is needed for |
| 262 | + existing valid commands because their contracts do not change. |
| 263 | +* Optimus-generated `issue list --help` must add `-i, --include-labels` under |
| 264 | + FLAGS while retaining `-l, --labels LABELS` and `-s, --status STATUS` under |
| 265 | + OPTIONS. |
| 266 | +* `documents/ash-domain-erd.adoc` must be updated in the implementation change |
| 267 | + because adding the `include_labels` Ash action argument and materially |
| 268 | + clarifying the `labels` argument changes the canonical domain inventory. |
| 269 | +* Release notes call out the additive `-i/--include-labels` flag. Do not edit |
| 270 | + historical changelog entries that accurately describe 2.7/2.8. |
| 271 | +* `AGENTS.md` indexes this Phase 17 plan. No repository-layout or module-tree |
| 272 | + update is needed because the phase adds no top-level directory or major |
| 273 | + architectural layer. |
| 274 | + |
| 275 | +== Sequencing |
| 276 | + |
| 277 | +This phase depends on the label-filter and compact-label work already released |
| 278 | +in 2.7.0 and 2.8.0 and is based on `origin/main` at v2.8.0. |
| 279 | + |
| 280 | +1. Change the `issue list` Optimus contract and add parser-level regression |
| 281 | + coverage, including `-N --include-labels --all`, `-i`, unchanged `-s` |
| 282 | + status filtering, and a clear failure for the motivating malformed |
| 283 | + `--labels --all` ordering. |
| 284 | +2. Split filter names from label-field selection through |
| 285 | + `Commands.issue_list/1` and the `Issue` read action; update domain tests and |
| 286 | + `documents/ash-domain-erd.adoc` in the same change. |
| 287 | +3. Route the new flag through the existing display switch, update text/JSON |
| 288 | + tests, and preserve the existing output format. |
| 289 | +4. Update the README, generated help, and release notes. |
| 290 | +5. Run focused tests, `mix precommit`, and manual dogfooding through |
| 291 | + `mix lc` (never an alternate Linear client). |
| 292 | + |
| 293 | +Implementation should use a feature branch from the then-current `main`; the |
| 294 | +plan branch contains documentation only. Conventional commit type: `feat`. |
| 295 | + |
| 296 | +== Acceptance criteria |
| 297 | + |
| 298 | +* `lcls -N --include-labels --all` and `lcls -N -i --all` list issues across |
| 299 | + assignees and lifecycle states and include their labels without adding a |
| 300 | + label filter. |
| 301 | +* `-i/--include-labels` never changes which issues match. |
| 302 | +* `-l/--labels` preserves case-insensitive, comma-separated OR filtering and |
| 303 | + makes matched issues' labels visible. |
| 304 | +* `-s/--status` retains its existing friendly-status filtering contract. |
| 305 | +* `lcls --labels --all` fails clearly before any API request rather than |
| 306 | + filtering for a label named `--all`. |
| 307 | +* `issue create -l/--labels VALUE` is unchanged. |
| 308 | +* Text and JSON output, paginated results, positional issue lookup, and the |
| 309 | + no-label fast path all have regression coverage. |
| 310 | +* Help, README, release guidance, tests, and the Ash domain ERD describe the |
| 311 | + same contract. |
0 commit comments