Skip to content

feat: support assignee and label changes in issue update - #44

Closed
seraph-pixelperfect wants to merge 1 commit into
feat/labels-commandfrom
feat/issue-update-assignee-labels
Closed

seraph-pixelperfect wants to merge 1 commit into
feat/labels-commandfrom
feat/issue-update-assignee-labels

Conversation

@seraph-pixelperfect

Copy link
Copy Markdown
Collaborator

Closes #24.
Depends on #43.

What

issue update learns three new flags (all composable with the existing --state/--title/--priority/--description):

  • --assignee <name|me> — "me" resolves to the authenticated viewer's id (via fetchViewer, which already returns id name email — no users() round trip needed for a write); any other name resolves through users(filter: { name: { eq } }) in the new resolveUserId (src/linear.ts), which fails loud on no match and on ambiguous display names (candidates listed with emails, mirroring resolveStateIdByName's convention).
  • --label <name> (repeatable) — added to the issue's current labels; names resolved workspace-wide via the now-exported resolveLabelIds (delegates to fetchLabels).
  • --remove-label <name> (repeatable) — removed from the issue's current labels, matched case-insensitively against the issue's own labels (no extra network round trip; removing a label the issue doesn't carry is a no-op).

Because Linear's IssueUpdateInput.labelIds replaces the whole set, the command layer computes the final set (current ids − removed ∪ added) and sends it wholesale. Two deliberate details:

  • Removing the last label works: when the computed set is empty, labelIds: [] is sent explicitly — the omit-null input builder would otherwise skip the field and silently keep the label.
  • Field-level idempotency: a computed label set identical to the current one is skipped, and re-assigning the current assignee is skipped; when every requested field is a no-op, the command reports issue: LIN-1 already up to date (no-op) and sends no mutation at all.

The issue's current label ids come from extending ISSUE_DETAIL_FIELDS to labels { nodes { id name } } — the update flow already fetches the issue, so this adds zero round trips; renderers read only name, so output is unchanged.

Schema verification (source: @linear/sdk v90.0.0 generated documents, via npm pack)

  • QueryUsersArgs (dist/index-zxW4m1xd.d.mts): users takes filter?: UserFilter (plus first/after/orderBy/…); UserFilter.name is a StringComparator exposing eq — so users(filter: { name: { eq: $name } }) is schema-valid.
  • User type: id: ID, name: String, email: String — all three selected in resolveUserId (email is what makes the ambiguity error useful).
  • IssueUpdateInput: assigneeId?: InputMaybe<String> ("The identifier of the user to assign the issue to") and labelIds?: InputMaybe<Array<String>> ("The identifiers of the issue labels associated with this ticket" — replacement semantics).

Before / after (stubbed variables, real code path)

issue update LIN-1 --assignee me --label bug — issue unassigned, no labels; viewer user-viewer; workspace label bug → lb-bug:

-- query Issue                       variables: {"id":"LIN-1"}
-- query { viewer { id name email } } variables: {}
-- query Labels                       variables: {"first":50}
-- MUTATION
mutation UpdateIssue($id: String!, $assigneeId: String, $labelIds: [String!]) {
  issueUpdate(id: $id, input: { assigneeId: $assigneeId, labelIds: $labelIds }) { ... } }
   variables: {"id":"ir-1","assigneeId":"user-viewer","labelIds":["lb-bug"]}
--- output ---
issue:
  identifier: LIN-1
  title: Ship the thing
updated: LIN-1
help[1]:
  Run `linear-axi issue view LIN-1` to confirm

Before this PR both flags were rejected: unknown flag --assignee / unknown flag --label (exit 2).

issue update LIN-1 --remove-label bug — issue carries exactly one label (bug → lb-bug):

-- query Issue                       variables: {"id":"LIN-1"}
-- MUTATION
mutation UpdateIssue($id: String!, $labelIds: [String!]) {
  issueUpdate(id: $id, input: { labelIds: $labelIds }) { ... } }
   variables: {"id":"ir-1","labelIds":[]}          <-- explicit empty set
--- output ---
updated: LIN-1

Also verified: named assignee (UserByName → {"name":"Ada Lovelace"} → assigneeId:"user-ada"), and re-assigning the current assignee reports issue: LIN-1 already up to date (no-op) with no mutation sent.

Local gates (real output)

$ pnpm build            # tsc — clean
$ pnpm lint             # eslint . — clean
$ pnpm run build:skill -- --check
skills/linear-axi/SKILL.md is up to date.
$ pnpm test
 Test Files  14 passed (14)
      Tests  149 passed (149)
$ pnpm exec prettier --check test/issue-update-assignee-labels.test.ts
All matched files use Prettier code style!

Baseline on the parent branch was 134 tests / 13 files; this adds test/issue-update-assignee-labels.test.ts with 15 cases (stubbed fetch, no network): --assignee me and named resolution, not-found and ambiguous user errors, label union with existing labels, one-of-two and last-label removal, the combined acceptance case, both no-op paths, the nothing-to-update guard (now listing all seven flags, fires pre-network), blank flag values, omit-null mutation documents, and help coverage.

pnpm run format:check remains red on the same pre-existing 18 files (identical set to the parent branch; CI does not run it). No newly-flagged files; the new test file passes prettier.

Stacked PR note

Stacked on #43 (stack: #37 → #38 → #39 → #40 → #41 → #42 → #43). This branch is based on the tip of feat/labels-command (8cca1bc); parent commits are untouched. No CI checks appear on this PR — ci.yml only triggers on PRs targeting main. CI runs when this PR is retargeted to main after the stack merges; the local gates above are the evidence in the meantime. Not retargeting.

CI quota caveat: if checks do run later and appear as failures, verify before rerunning — GitHub Actions billing/spending-limit rejections look like check failures; check gh run view <run-id> or the jobs API for a billing annotation, or an empty steps list with a ~3-4s duration. If quota-blocked, treat the local output above as the evidence and don't retry.

Ambiguities / judgment calls (flagged for review)

  1. Label-id sourcing: current label ids come from extending ISSUE_DETAIL_FIELDS (labels { nodes { id name } }) rather than re-fetching ids by name — zero extra round trips, exact ids instead of name matching. Side effect: issue view's query also selects label ids now (a few extra bytes; output unchanged).
  2. Idempotency granularity: assignee/label no-ops are field-level (the field is skipped, other flags still apply). The pre-existing --state no-op short-circuits the whole command even when other flags are present — left untouched as out of scope for Support assignee and label changes in issue update #24, but worth a follow-up issue if the inconsistency bothers you.
  3. Ambiguous user names: display names are not unique in Linear; resolveUserId fails loud listing Name <email> candidates rather than guessing. --assignee me bypasses the problem entirely.
  4. Unknown --label names are silently skipped (the issue says "reuse resolveLabelIds", whose existing issue create semantics skip unknown names) — flagged in case reviewers want update to be stricter.
  5. addedLabelIds/removedLabelIds also exist on IssueUpdateInput (verified in the SDK types) and would avoid the full-set computation; I followed the issue spec's union/difference + labelIds design, which also makes the remove-last-label case explicit.
  6. Same name in both --label and --remove-label: add wins (removal applies to the current set, then additions are unioned in).

Identity note

Opened by an automated agent dispatch authenticated as seraph-pixelperfect, submitted for human review — not self-approved. Merging (and retargeting) is a human decision.

Add --assignee <name|me>, --label <name> (repeatable), and
--remove-label <name> (repeatable) to `issue update` (#24).

- --assignee: "me" uses the viewer id from fetchViewer directly (no
  users() round trip for a write); any other name resolves via
  users(filter: { name: { eq } }) in the new resolveUserId, which fails
  loud on no match and on ambiguous display names (candidates listed).
  Re-assigning the current assignee is a field-level no-op.
- --label/--remove-label: Linear's IssueUpdateInput.labelIds REPLACES the
  whole set, so the command computes the final set — current label ids
  (ISSUE_DETAIL_FIELDS now selects labels { nodes { id name } }) minus
  names matched by --remove-label (case-insensitive against the issue's
  own labels), unioned with --label names resolved via the exported
  resolveLabelIds. An empty computed set sends labelIds: [] explicitly
  (removing the last label works); a set identical to the current one is
  skipped, and an update where every field is a no-op reports "(no-op)"
  without mutating.
- updateIssue (src/linear.ts) extends the omit-null input builder with
  assigneeId/labelIds; labelIds === [] is deliberately included.
- Schema verified against @linear/sdk v90 generated documents:
  Query.users takes filter: UserFilter (UserFilter.name is a
  StringComparator with eq), User exposes id/name/email, and
  IssueUpdateInput has assigneeId?: String ("The identifier of the user
  to assign the issue to") and labelIds?: [String] ("The identifiers of
  the issue labels associated with this ticket").
- ISSUE_HELP documents the flags; SKILL.md regenerated (build:skill).

Tests: test/issue-update-assignee-labels.test.ts (15 cases, stubbed
fetch, no network) — "me" and named resolution, not-found/ambiguous
users, label union, one-of-two and last-label removal (labelIds: []),
the combined acceptance case, no-op paths, the nothing-to-update guard,
and omit-null mutation documents.
@ghostinprod-pixelperfect

Copy link
Copy Markdown
Collaborator

Superseded by merged rc/0.2 PR #52 (commit ad6b141), which consolidated this write/transport stack with validation and review fixes.

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