Skip to content

fix(issues): create --output json reports the labels it applied - #74

Open
FelixLisczyk wants to merge 3 commits into
joa23:mainfrom
FelixLisczyk:tl-572
Open

fix(issues): create --output json reports the labels it applied#74
FelixLisczyk wants to merge 3 commits into
joa23:mainfrom
FelixLisczyk:tl-572

Conversation

@FelixLisczyk

Copy link
Copy Markdown
Contributor

The problem

linear issues create --labels "Bug" --output json always reported "labels": null, even though the labels were applied correctly.

$ linear issues create "Example" --team ENG --labels "Bug" --priority 2 --output json
  "priority": null,
  "labels": null,

$ linear issues get ENG-123 --output json
  "priority": 2,
  "labels": [{"id": "5fce89ca-…", "name": "Bug"}]

The write succeeded; only the response was wrong. A scripted or agentic caller that reads the create response to confirm label application gets a false negative, and has to pay an extra issues get round-trip to discover the labels were there all along.

Root causes

Two independent defects, both fixed here.

1. The issueCreate selection set never asked for labels back. labelIds was sent correctly in the mutation input — which is why Linear applied the labels — but the issue { … } selection set requested neither labels nor four sibling fields the caller can also set: priority, estimate, dueDate, cycle. Each carried the identical "I set it, the response says null, did it work?" failure.

All five are now requested, copying the shapes from GetIssue verbatim so the two selection sets read as siblings. delegate and attachments are deliberately left out: issueCreate cannot set a delegate, and a new issue has no attachments, so requesting them could never surface caller-supplied data.

2. The JSON DTO layer rendered an empty collection as null. That is ambiguous between "this issue has none" and "this renderer does not report them" — the same confusion, one layer down. populateIssueBase now always allocates, so labels, children, and attachments render as [].

delegate deliberately keeps omitempty and still vanishes when nil: an absent object unambiguously means "no delegate", whereas an absent or null collection is ambiguous. A comment on populateIssueBase records this so the two are not later "harmonised" back together.

Behaviour change to read paths — please read

populateIssueBase is shared, so this also changes issues get, issues list, and search at --format detailed|full: empty labels, children, attachments, and comments now render as [] instead of null.

Consumers using length, select, or truthiness tests are unaffected. jq expressions along the lines of has("labels"), .labels == null, or .labels // "none" will behave differently.

Confining the fix to create would mean duplicating the DTO layer and making create's output structurally diverge from get's — undercutting the very parity this fix exists to establish. Text output is unchanged.

Tests

Layered, because no single layer covers this bug:

  • pkg/linear/issues/client_create_test.go (new) — an httptest-backed test that captures the outgoing mutation body. This is the only guard that fails if the selection-set fix is reverted; verified by removing the fields and watching it go red. Assertions are line-anchored, since a bare labels substring also matches labelIds in the mutation input and would pass against the unfixed code. Also covers response mapping (including a label with a parent), success: false, and metadata extraction.
  • internal/format/json_dtos_test.go (new) — raw-JSON assertions that empty collections marshal to [] and not null, that a nil connection and a present-but-empty one both render [], that LabelDTO still drops color/parent, and that a nil delegate stays omitted.
  • internal/service/issue_create_test.go — serialized-output assertions through the real formatter: one label, two labels, no labels, label resolution failure, CreateIssue failure, and text mode unchanged.

Service-level tests build their own core.Issue via the mock, so they prove the DTO path but would stay green on a selection-set regression — hence the client-layer test.

make test passes; go vet ./... is clean.

Verified live

Against a real Linear team, comparing the released binary with this branch:

Check Before After
--labels "A,B" null both reported with id+name
--priority 2 --estimate 3 null, null 2, 3
--due 2026-12-31 null "2026-12-31"
Label id/name vs issues get match (as a set)
Create with no labels null []
Unresolvable label name error, exit 1 unchanged
Text-mode output ABC-123: <title> + URL unchanged

The five-field selection set is valid against Linear's live schema.

Out of scope

  • issues update has the same gap in its client-layer selection set, but no --output/-o flag at all, so it is unreachable from the CLI. Adding JSON output there is a feature addition and needs to cover all three label modes (--labels, --add-labels, --remove-labels).
  • Extending LabelDTO with color/parent. The mutation selects them (to stay identical to the read paths), but the DTO still drops them, so the output surface is unchanged.
  • A shared GraphQL fragment. This bug is one instance of a class — the label selection is copy-pasted across seven query strings. A fragment IssueFields on Issue would structurally prevent the drift, but rewriting seven live queries is disproportionate risk for a bug fix.

FelixLisczyk and others added 3 commits August 28, 2026 14:51
The issueCreate mutation sent labelIds correctly, so Linear applied the
labels, but the mutation's selection set never asked for `labels` back.
The response therefore always carried `"labels": null`, even on a fully
successful create. A scripted or agentic caller reading the create
response to confirm label application got a false negative and had to
pay an extra `issues get` round-trip to find out the labels were there
all along.

Two defects were in play, and both are fixed here:

1. The selection set omitted `labels`. It also omitted `priority`,
   `estimate`, `dueDate`, and `cycle` — every one of them a value the
   caller passes *into* create, so every one of them carried the
   identical "I set it, the response says null, did it work?" failure.
   All five are now requested, copying the shapes from GetIssue verbatim
   so the two selection sets read as siblings. `delegate` and
   `attachments` are deliberately left out: issueCreate cannot set a
   delegate, and a new issue has no attachments, so requesting them
   could never surface caller-supplied data.

2. The DTO layer rendered an empty collection as `null`, which is
   ambiguous between "this issue has none" and "this renderer does not
   report them" — exactly the confusion above. populateIssueBase now
   always allocates, so `labels`, `children`, and `attachments` render
   as `[]`. Delegate deliberately keeps `omitempty` and still vanishes
   when nil; an absent object is unambiguous in a way an absent
   collection is not, and the comment on populateIssueBase records that
   so the two are not later "harmonised" back together.

This changes read-path JSON as well: `issues get|list` and `search` at
detailed|full report `[]` where they reported `null`. Confining it to
create would mean duplicating the DTO layer and making create's output
structurally diverge from get's, which undercuts the parity this fix is
for. It is a real if small break for consumers using has(), == null, or
// defaults, rather than a cosmetic one.

Tests are layered because no single layer covers this. The service-level
tests build their own core.Issue via the mock, so they prove the
DTO/formatter path but would stay green if the selection set regressed.
The new httptest-backed client test captures the outgoing mutation body
and is the only guard that fails on that regression — verified by
removing the fields and watching it go red. Its assertions are anchored
to whole lines, since a bare `labels` substring also matches `labelIds`
in the mutation input and would pass against the unfixed code.
The previous commit made labels, children, and attachments render as an
empty array rather than null. Comments were left rendering as null,
which would emit `"labels": []` next to `"comments": null` in the same
JSON object — reproducing, one field over, the exact ambiguity that
commit set out to remove.

Kept deliberately separate so it can be reverted on its own without
disturbing the labels fix, since it touches a field the create defect
never involved.

One acknowledged asymmetry: create does not request comments at all, so
a newly created issue now reports `"comments": []` for something the
server was never asked about. That is the accepted cost of a uniform
contract — the DTO describes the shape it renders, not the wire
response it was built from — and the test records it.
The end-to-end create test claimed to cover the GraphQL selection set, but
its fake server ignored the request and answered with a canned body that
carried labels regardless — so deleting the selection set left it green.
Its handler now asserts the captured mutation actually asks for labels.

Also cover parent in the field-survival test, and anchor the LabelDTO and
delegate leak assertions to their own fields instead of substring-scanning
the whole document, so they cannot start failing on unrelated content.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBkx8wtrq2Mtb8N7jNerwZ
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.

1 participant