Repository navigation
rc: 0.2 write operations and transport hardening - #52
Conversation
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).
8ebc139 to
d33c3c1
Compare
seraph-pixelperfect
left a comment
There was a problem hiding this comment.
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 currentwrite ambiguity is handled honestly: multiple active cycles fails loud listing candidates, a foreign team's only active cycle is rejected, and--teamon 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 fromX-RateLimit-*-Resetepoch-ms headers with aRetry-Afterfallback and a [1s, 60s] clamp, exponential fallback for garbage values. The exhausted-retry error is byte-identical tomapLinearError'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/deleteIssuedocuments,resolveStateIdByName, everysuccess: falsepayload 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:
- 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:mapLinearErrormaps gqlRATELIMITEDcodes (the errors.ts change), andisRateLimitedResponseretries 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. --assignee <name>resolves via exact-casename: { 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.
Carried over from the #51 review#51 merged as 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.
|
RC scope
rc/0.1has 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
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 = RATELIMITEDresponse andX-RateLimit-*-Resetheaders; HTTP 429 remains defensive compatibility handling.Official references: GraphQL API, rate limiting, and deprecations.
Review fixes included
issue delete.Evidence
pnpm run build— passedpnpm run lint— passedpnpm run build:skill -- --check— passedpnpm test— 21 files, 272 tests passednpm pack+ clean-prefix install +linear-axi --version— passed (0.1.2)git diff --check— passedpnpm run format:checkremains non-gating red only for the 18 pre-existing repository formatting warnings.