Skip to content

rc: 0.2 write operations and transport hardening - #52

Merged
ghostinprod-pixelperfect merged 12 commits into
mainfrom
rc/0.2
Aug 20, 2026
Merged

ghostinprod-pixelperfect merged 12 commits into
mainfrom
rc/0.2

Conversation

@ghostinprod-pixelperfect

@ghostinprod-pixelperfect ghostinprod-pixelperfect commented Aug 20, 2026 •

Copy link
Copy Markdown
Collaborator

RC scope

rc/0.1 has merged as #51. This PR is rebased onto that merge and contains the next write-side and transport-hardening milestone (#44–#50), plus its review fixes.

Supported Linear features

  • Issue updates for assignee and labels
  • Project and cycle assignment on issue create/update
  • Parent/sub-issue creation and child display in full issue views
  • Long descriptions from a file or stdin
  • Comment update and idempotent delete
  • Bounded retries for documented Linear GraphQL rate limits
  • Transport-level GraphQL operation and error-path coverage

Linear API validation

Validated against Linear’s live public schema and official docs. The issue/comment write mutations and project/cycle/parent input fields are active and non-deprecated. The rate-limit implementation follows Linear’s documented HTTP 400 plus errors[].extensions.code = RATELIMITED response and X-RateLimit-*-Reset headers; HTTP 429 remains defensive compatibility handling.

Official references: GraphQL API, rate limiting, and deprecations.

Review fixes included

  • Reject invalid/non-JSON HTTP 200 responses as structured errors.
  • Preserve combined issue updates when a requested state already matches.
  • Require exact priority values 0–4.
  • Reject unknown flags on issue delete.
  • Retry and report Linear’s documented GraphQL rate-limit responses.

Evidence

  • pnpm run build — passed
  • pnpm run lint — passed
  • pnpm run build:skill -- --check — passed
  • pnpm test — 21 files, 272 tests passed
  • npm pack + clean-prefix install + linear-axi --version — passed (0.1.2)
  • git diff --check — passed

pnpm run format:check remains non-gating red only for the 18 pre-existing repository formatting warnings.

seraph-pixelperfect and others added 10 commits August 20, 2026 10:10
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.
- comment update <COMMENT-ID> --body|--body-file via commentUpdate (pre-fetch
  for a loud NOT_FOUND; body guards mirror the create path)
- comment delete <COMMENT-ID> via commentDelete, idempotent no-op when the
  comment is already gone (pre-fetch pattern, mirrors deleteIssueCmd)
- comment list rows now lead with the full comment id (the handle
  update/delete target); list hint advertises both subcommands
- COMMENT_HELP documents the new subcommands and the reserved words
- skill prose regenerated via pnpm run build:skill

Closes #27
- linearRequest retries HTTP 429 up to 2 times (3 attempts total) before
  surfacing mapLinearError's structured RATE_LIMITED error unchanged —
  transport-level, so every command benefits with no API/flag changes
- backoff honors Retry-After delay-seconds, clamped to [1s, 60s]; missing/
  invalid/non-positive header falls back to bounded exponential (1s, 2s)
- no retry on non-429 outcomes, GraphQL-level errors (200 + errors), or
  network failures; a non-429 after a 429 fails immediately with the last
  response's mapping
- test/rate-limit-retry.test.ts: 11 tests over the fetch-stub pattern with
  fake timers (only setTimeout faked, Date stays real to prove no actual
  sleeping; per-ms advance pins exact honored-delay boundaries)

Closes #30
…ayer

Gap-fill for #29 on top of the command-level suites: direct transport-level
tests for fetchViewer, fetchTeams, deleteIssue, and resolveStateIdByName
(query TeamStates) which had zero document assertions; createIssue's
labelNames->labelIds branch, team-not-found error, and the minimal omit-null
document; updateIssue's minimal and full omit-null combinations; all six
mutations' success:false payload branches; fetchIssues' anti-spin pagination
guards; and the mapLinearError matrix end to end through linearRequest
(HTTP 400/403/404/502 + GraphQL not-found/auth/validation, message text
formatting, extensions hint, non-JSON body fallback, LINEAR_AXI_DEBUG dump,
and the POST request-shape contract).

@seraph-pixelperfect seraph-pixelperfect left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Verified on rc/0.2 (d33c3c1) in a clean worktree: rebase confirmed (merge-base is #51's squash commit 694a564; exactly the 10 write-side commits on top, no overlap), and pnpm run build, pnpm run lint, pnpm test (21 files / 272 tests), and pnpm run build:skill -- --check all pass — matching the evidence in the description.

Verdict: approve.

What holds up on read-through:

  • Write-path discipline is consistent. Every resolvable flag (--project, --cycle, --parent, --assignee, --label) resolves loudly before its mutation, so no issue is ever created/updated minus a silently-missing association. Guards (mutual exclusion, blank values, unreadable paths, empty stdin) all fail before any network request, with the zero-request assertion in tests.
  • Field-level idempotency is the right refinement of the old whole-command no-op. A matching state/assignee/label-set/project/cycle skips just that field while other requested changes still apply — the review fix from the stacked series, with regression coverage. The labelIds: [] explicit-empty-set case (removing the last label) is correctly distinguished from undefined.
  • --cycle current write ambiguity is handled honestly: multiple active cycles fails loud listing candidates, a foreign team's only active cycle is rejected, and --team on update is constrained to the issue's own team. Mirrors the read path's per-team disambiguation.
  • Rate-limit retry matches Linear's documented contract: HTTP 400 + extensions.code = RATELIMITED (plus defensive 429), bounded at 3 attempts, delays from X-RateLimit-*-Reset epoch-ms headers with a Retry-After fallback and a [1s, 60s] clamp, exponential fallback for garbage values. The exhausted-retry error is byte-identical to mapLinearError's RATE_LIMITED. The fake-timer tests pin exact delay boundaries and prove non-rate-limit failures (401, 500, GraphQL 200+errors, network throw) are never retried.
  • The transport gap-fill suite is genuine coverage, not a re-run: fetchTeams/deleteIssue documents, resolveStateIdByName, every success: false payload branch, the omit-null builder's minimal/maximal forms, and the three pagination anti-spin guards were all previously untested.

Two non-blocking notes, fine for a follow-up:

  1. The doc comment on rateLimitRetryDelayMs (src/linear.ts) still says "mapLinearError derives RATE_LIMITED from the HTTP status alone… a GraphQL-level error is never retried." That's now stale twice over: mapLinearError maps gql RATELIMITED codes (the errors.ts change), and isRateLimitedResponse retries a body carrying that code regardless of status — including HTTP 200. The behavior is arguably better than the comment; the comment just needs to catch up.
  2. --assignee <name> resolves via exact-case name: { eq } while the no-op check compares case-insensitively — a lowercase variant of the current assignee's name fails with "not found" rather than no-oping. It fails loud, so it's safe; just an asymmetry worth knowing about.

@cassievale-pixelperfect

Copy link
Copy Markdown
Collaborator

Carried over from the #51 review

#51 merged as 694a564 at 04:34 UTC with the following findings still unaddressed. Re-verified each against rc/0.2 (this PR's branch point) just now — all nine are still live on main. One originally-flagged item (resolveProjectId as dead code) is no longer applicable: this PR wires it up for issue create/update --project, which is exactly the follow-up the original finding called for, so it's dropped from this list.

None of these are introduced by this PR, but since #51 is already merged, this is the first open PR where they can be tracked. Filing so they don't get lost — happy to defer any/all if you'd rather track them as follow-up issues instead.

  1. src/commands/comment.ts — getPositional(args, 0) dispatch can misroute a --body value into a subcommand. linear-axi comment --body "list" LIN-123 never creates the comment: getPositional returns the first token not starting with --, which is "list" (the value of --body, not a subcommand), so it routes to listComments instead. This PR raises the stakes — the reserved-word set grows from {list} to {list, update, delete} (see src/commands/comment.ts, commentCommand), tripling the collision surface, and the updated doc comment now asserts "no real ref can collide with those words" without addressing that a --body/--body-file value isn't a ref at all. Worth fixing as part of this PR's own dispatch changes rather than carrying forward again.
  2. src/commands/issues.ts:110 — --project/--cycle guards only check for undefined/blank, unlike --search (line 102) which also has a .startsWith('--') check. Since takeFlag() blindly consumes the next token, issues --project --limit silently swallows --limit as the project name instead of erroring.
  3. src/commands/labels.ts:28 — labelsCommand (and projects.ts) validate args with only assertKnownFlags(args, []), which silently drops stray positionals, unlike the sibling cycles.ts/issues.ts commands from the same rc: 0.1 read and discovery capabilities #51 PR, which reject them loudly.
  4. src/linear.ts — the cursor-pagination loop (page capping, hasNextPage/endCursor handling, non-advancing-cursor and empty-page guards) is duplicated near-verbatim across all five fetchers (fetchProjects, fetchCycles, fetchIssues, fetchComments, fetchLabels). A shared paginateConnection() helper would prevent future fixes from needing to be copied five times.
  5. src/linear.ts resolveLabelIds — fetches the entire label list (paginated, up to 500) just to resolve a handful of requested names by client-side filtering, on every --label use in issue create/update. A server-side name filter would scale better.
  6. src/commands/issues.ts:98 — the "flag present but blank must fail loud" check is hand-rolled 4 times (--search, --project, --cycle in issues.ts, --team in cycles.ts) instead of a shared takeRequiredFlag() helper — which is how Publish npm releases through trusted OIDC #2 above happened: only --search got the complete guard.
  7. test/cycles.test.ts:177 — asserts expect(out).toContain('active'), but cycles.ts always emits a hint containing the literal substring "active" regardless of any row's real computed status, so the assertion is vacuous.
  8. test/projects.test.ts:172 — hasMore is only unit-tested at the fetchProjects()/fetchCycles() level; no test exercises the full command path to confirm the truncation hint reaches rendered output (unlike labels.test.ts, which does).
  9. src/linear.ts — fetchProjects (limit 100), fetchCycles (limit 10), and fetchLabels (limit 500) each hardcode a different ceiling, and none of cycles/labels/projects expose a --limit override — unlike issues, which got the full --limit/parseLimit/MAX_LIMIT treatment in rc: 0.1 read and discovery capabilities #51. Still true on this branch.

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.

3 participants