feat(issues): close the issues domain coverage gaps - #293
Merged
Conversation
`resolveIssueId` and every `GetIssueByIdentifier*` read went through `issues(filter: …)` without `includeArchived`, which the Linear API defaults to `false`. An archived issue therefore could not be found by its `ENG-42` identifier at all, so `issues unarchive ENG-42` failed with "Issue not found" and only worked when the caller already knew the UUID — the one case where they least need the command. The identifier lookups now pin `includeArchived: true`. That is the correct fixed value rather than a flag: these queries exist only to turn a human identifier into a UUID (or to read one specific issue the caller named), and an archived issue still has both. It also aligns the identifier path with the UUID path, where `issue(id:)` already returned archived issues — the two spellings of "read ENG-42" no longer disagree. Collection reads are a different question, so they stay opt-in: `list` and `search` gain `--include-archived`, threaded through a new `IssueReadOptions` in the service layer and defaulting to `false` so the default result set is unchanged. `SearchIssues`/`FilteredSearchIssues` previously hardcoded `includeArchived: false` with no way to override.
Every issue payload identified the issue only by `identifier`, so a consumer that wanted to link to it had to reconstruct the Linear URL from the team key and number — the workspace slug is not even derivable from the CLI output, so that reconstruction was guesswork. `url` is a non-null field on both `Issue` and `IssueSearchResult`; selecting it is what "share links" in the README coverage cell actually needed. Alongside it the read fragments now carry the rest of the issue's shape that was being dropped: `creator` and `delegate` (the assignee's two siblings), the lifecycle timestamps `startedAt`/`completedAt`/ `canceledAt`, and the state markers `archivedAt`, `snoozedUntilAt` and `trashed`. Without those last three a caller cannot tell an archived or trashed issue from a live one in any CLI output. `subscribers` and `sharedAccess` are deliberately *not* on the shared `CompleteIssueFields`: a subscriber roster is worth fetching for one issue but would multiply across all 50 rows of a default `issues list` for no benefit, and this CLI is optimized for token cost. They live in a new `IssueDetailOnlyFields` fragment spread into the single-issue read fragments only. The alternative — a `--with-subscribers` flag — was rejected as a third axis on top of the existing `--with-*` flags for data that is a handful of bytes on a single read.
Five issue-scoped root mutations had no CLI surface: `issueSubscribe`, `issueUnsubscribe`, `issueShare`, `issueUnshare` and `issueReminder`. Naming needed care on two of them. `issueShare` does not mint a URL — it grants a named user access to an issue they otherwise cannot see — so the command takes `--with <user>` rather than a positional argument, which keeps `issues share ENG-1` from reading like "print me a link". (The link is the `url` field added to the read payload.) Subscribe and unsubscribe default `--user` to the authenticated viewer, since subscribing yourself is the common case; `me`/`@me` are accepted as the explicit spelling and now resolve in `resolveUserId` for every flag that takes a user, not just these. `resolveViewerId` lands in `user-resolver.ts` as the single owner of "who am I". It queries `viewer` directly, which is an architectural exception to the lean-lookup rule documented at the call site: no filter can select the caller. `reaction-service.ts` keeps its own private copy rather than importing the resolver, which the layer rules forbid. `--at` accepts either an ISO-8601 instant or a `+2h`/`+3d` offset, parsed by a new pure `parseDateTimeOption` helper. It sits in `common/datetime.ts` rather than next to `parseDueDate`, which covers Linear's timeless `TimelessDate` scalar and cannot grow a clock time. The helper takes `now` as a parameter so it stays testable, normalizes everything to UTC, and reads a zoneless value as UTC so the same command means the same instant wherever it runs.
`issueBatchCreate` and `issueBatchUpdate` had no CLI surface, so bulk work meant N invocations and N transactions. `batch create` takes a JSON array whose keys are the `issues create` flag names with the dashes dropped, so there is no second schema to learn. Unknown keys are rejected rather than ignored: a typo like `assingee` would otherwise create the entire batch with the field silently missing, and unpicking that costs far more than a rejected command. Input arrives via `--file`, `--file -` (stdin) or `--json`. `batch update` applies one patch to an explicit `--issues` list, mirroring `issueBatchUpdate(ids, input)`. Selection is deliberately not driven by a filter, because a filter-selected mass mutation is a footgun with no dry-run story. If that is wanted later it should arrive as `issues list … | issues batch update --issues -`, which keeps the selection visible in the pipeline. Two constraints fall out of the API shape and are enforced rather than papered over. The mutation takes a single `stateId`/`cycleId`, so a status or cycle named by word is only meaningful when every target shares one team — the mixed-team case now errors instead of resolving against an arbitrary target's workflow. And labels are overwrite-only, because add/remove would need each target's current label set, which one shared patch cannot express. Resolution goes through a new `resolveBatchCreateIssueIds`, which routes every entry through the existing single-issue resolver so disambiguation and not-found behavior are identical, while collapsing entries that name the same references onto one in-flight request. A batch sharing a team and project therefore costs one lookup, not one per row. Field-level memoization would collapse more but cannot be keyed correctly, because status, cycle and milestone lookups are scoped by the entry's own team and project. `resolveIssueRefs` joins the issue resolver to resolve the whole `--issues` list in one request and return each issue's team, which is what the single-team check needs. UUID references are looked up rather than passed through, since a UUID does not carry a team. The batch subgroup lives in its own `issues-batch.ts` because it has a distinct input format and its own constraints, and `issues.ts` was already the largest command file in the project.
`issueVcsBranchSearch` was the highest-leverage unwired issue query for a CLI: the caller is almost always sitting in a checkout of the very branch Linear generated for the issue they want to act on, and had no way to get from there to the identifier without opening the web app. The branch argument is optional; with none, the current checkout's branch is read via `git rev-parse --abbrev-ref HEAD`. That runs through `execFileSync`, not a shell, so no branch name or repository path can be interpreted as shell syntax. A missing git, a non-repository directory and a detached HEAD all produce the same actionable error, because from the caller's side they are the same situation: there is no branch to infer, so name one. The lookup returns the same single-issue payload as `issues read`, so `issues from-branch | jq .identifier` is enough to chain into any other issue command.
Three writable fields on `IssueUpdateInput` had no CLI surface. `teamId` is the largest gap of the three: moving an issue between teams was simply impossible from the CLI, despite being a routine correction when a ticket is filed in the wrong place. A team move rescopes the other lookups, so `--team` is resolved before the batch resolve request rather than alongside it: a status or cycle named in the same invocation belongs to the destination team's workflow, not the workflow of the team the issue is leaving. Resolving it against the old team would either fail or, worse, find a same-named state and move the issue into a state its new team does not own. `--subscribers` and `--delegate` land on both `create` and `update`, with the `--subscriber-mode add|remove|overwrite` and `--clear-*` counterparts the file already uses for labels. Because the mutation replaces `subscriberIds` wholesale, add and remove are computed against the issue's current roster, which the read payload now carries. Two pieces of shared machinery fall out. `parseLabelMode` becomes a thin wrapper over `parseSetMode(flag, value)` so `--subscriber-mode` reuses the parser without inheriting `--label-mode`'s name in its error message, and the label add/remove/overwrite arithmetic in the update command becomes `applySetMode`, now used by both flags. Subscriber and delegate references resolve through `resolveUserId` in parallel with the batch request rather than inside it. The batch query has a single `$assigneeQuery` variable and no case-insensitive list comparator, so a list of users and a second single user cannot be expressed there; going through the shared resolver also means `me` and name/email disambiguation behave the same as on every other user flag.
`issues delete` maps to `issueDelete`, which trashes rather than destroys, but there was no way back: `issues unarchive` covers a different state, and `issueUpdate(trashed: false)` was unreachable. A mistaken `delete` therefore had to be undone in the web app. `issues restore` closes that loop, and `issues snooze --until <when>` exposes `snoozedUntilAt`, with `--clear` to wake an issue again. Both are named service functions rather than raw `issues update` flags because both are lifecycle transitions with a single meaning, and `--trashed false` would read as an odd way to spell "undelete". `--until` shares the `parseDateTimeOption` parser with `remind --at`, so `+3d` works for both. `snoozedById` is left to the API, which attributes the snooze to the authenticated user.
…ters `IssueFilter` exposes several dimensions the CLI could not reach. Three of them come up constantly in triage: issues with nobody on them, issues in a state *category* rather than a specific named state, and issues a particular person follows. Sort order was hardcoded to `updatedAt` with no way to ask for creation order. `--state-type` takes the documented `WorkflowState.type` categories, which is what makes it worth having over `--status`: state names are per-team, so "everything in progress across four teams" was previously unaskable without enumerating each team's workflow. `duplicate` is excluded from the accepted values — it is not a category anyone filters a work list by, and Linear models duplicates as a relation. `--unassigned` rejects being combined with `--assignee`. They describe the same field in contradictory ways, and the resulting filter would silently match nothing rather than fail. `--order-by` lives on `list` alone rather than in the shared filter options. `search` and `list --query` go through `searchIssues`, whose results are relevance-ordered by the API, so the flag would have nothing to act on there; passing it alongside `--query` is an error rather than a silent no-op. The implicit "exclude completed" clause on an unfiltered `list` survives the orderBy parameterization, and is now covered by a regression test — it is the one piece of default behavior these knobs could plausibly have knocked out.
`issueExternalSyncDisable` is the last unwired mutation in the attachment surface. Its name puts it in the issue domain, but its only argument is `attachmentId`: one issue can carry several synced attachments — a GitHub PR and a Sentry issue, say — and they are disabled one at a time. It therefore belongs on `attachments`, and the README's issue row was miscounting it as an issue-domain gap. The command is `attachments disable-sync <id>` and returns the affected issue, which is what the mutation's payload carries.
The `issues` row named five gaps — batch create/update, subscribe/ unsubscribe, share links, reminders, external sync toggles — all of which are now wired, along with several the row did not mention: archived reachability, team moves, restore, snooze, `from-branch`, the issue `url`, and the delegate/subscriber fields. Two of the five were also miscategorized, and the row is corrected rather than just ticked. "Share links" described `issueShare`, which grants a user access rather than minting a URL; the actual link need was the `url` field, and both are now covered but as separate things. "External sync toggles" is `issueExternalSyncDisable`, whose only argument is an `attachmentId` — it moves to the `attachments` row, where it now ships as `disable-sync`. What remains unwired in the issue domain is listed as a deliberate exclusion rather than a gap: the AI-assist and integration-suggestion queries belong to the Integrations row, and `issuePriorityValues` is a static list already present in the help text. Naming them keeps the ✅ honest — the legend's bar is "complete for practical purposes", not "every root field". The headline counts are unchanged: 537/164/373 is still correct when deprecated root fields are counted, which is the figure the sentence has always used. The wired count moves from "about 75" to an exact 83. `ISSUES_META` gains the three things a caller now has to distinguish and cannot infer from flag names: that archive, trash and snooze are three separate states rather than synonyms; that assignee, delegate, subscribers and shared access are four different relationships; and that `share` does not produce a link.
`issues batch update` refuses to resolve a status or cycle when the targets span several teams, because `issueBatchUpdate` applies one stateId/cycleId to every issue and resolving a word against an arbitrary one of the teams would silently move issues into a foreign workflow state. The error told the caller to pass a UUID instead — but the guard fired on the flag being present at all, so the advised escape hatch hit the very same error. Check the value rather than its presence: a UUID needs no team to resolve against, and `resolveUpdateIssueIds` already hands UUIDs through untouched, so letting it past the guard is safe and makes the message true. The message now says "by name" to match what is actually rejected. `buildBatchUpdateContext` is exported so the escape hatch can be covered without driving a full command through Commander.
The subscriber/delegate lookups were started eagerly and only awaited on the
success path, so any earlier throw — an unknown --project, --status or --team,
or the batch request itself failing — returned from the resolver with that
promise still pending. When it then rejected, nothing was listening: Node 22
terminates on an unhandled rejection, so the user got a raw stack trace on
stderr instead of the `{"error": ...}` envelope every other failure produces.
For a CLI whose entire contract is "stdout is JSON", that is a broken contract,
not just noisy output.
Await the lookups in the same expression that awaits the work they run
alongside — the batch request for create, the destination-team resolution for
update — so both rejections are handled whichever loses the race. The
concurrency the eager start bought is preserved; only the awaiting changes.
Both resolvers get a regression test that fails every lookup and asserts no
unhandled rejection escapes, since a plain rejects.toThrow() passes either way.
`issues list` has always hidden completed work by default. On the filtered
path that default lives in the service (buildListIssuesFilter, skipped when the
caller names a state), but the unfiltered path used a separate GetIssues query
with `state: { type: { neq: "completed" } }` baked into the document, where no
flag could reach it. Archived issues are nearly always completed or canceled,
so `issues list --include-archived` on its own hid exactly the issues it was
passed to surface, and the two paths disagreed about what the flag means.
Treat --include-archived as the caller saying something about state, the same
way an explicit state filter already does, and drop the implicit clause on both
paths. Someone who asks for archived issues wants to see them; narrowing state
from there is what the filter flags are for.
With the default expressed only in the service, the two queries were the same
query, so GetIssues is deleted and FilteredSearchIssues backs both paths. It
selects the identical CompleteIssueFields fragment, so IssueListItem and the
shared PageInfo type just repoint at it — no payload change.
`issues batch create` is the only command whose input is a JSON document rather than flags, and until now that document's shape existed only as a TypeScript interface and a one-line example in the help text. Callers — especially agents generating a batch programmatically — had no way to check a document before spending an API call on it, and the failure mode is expensive: the parser rejects unknown keys, so a single typo throws away the whole batch. Publish the contract as JSON Schema (draft 2020-12) in `schemas/issues-batch-create.schema.json`, ship it in the npm package, and point at it from the three places a caller actually looks: the `batch create` help text (with a check-jsonschema invocation), the `--file` option description, and the `issues usage` context block that `USAGE.md` is generated from — help text after-blocks do not make it into USAGE.md, so the context block is what agents reading the generated reference will see. README gains a section covering file/stdin/inline input, validation, and editor wiring. The schema is hand-written rather than generated: it encodes constraints the TypeScript interface cannot (non-empty strings, the 1-4 priority range, the YYYY-MM-DD due-date shape, projectMilestone requiring project) and generating it from the interface would lose exactly those. The cost of hand-writing is drift, so `KNOWN_ENTRY_KEYS` is exported and a test asserts the schema's property set, required fields, dependency and additionalProperties setting still match the parser, and that every documented example parses. The URL is pinned to the raw file on `next` rather than a release tag: someone validating a document wants the contract of the CLI they are about to run.
`issues batch create` documented its keys as "the single-issue flag names with the leading dashes dropped", but `--subscribers` and `--delegate` were added to `issues create` on this branch without reaching the batch parser. Since unknown keys are hard-rejected rather than ignored, a document carrying either field failed outright — the strictness that makes typos safe also made this omission a wall. The resolver side already worked: `ResolveCreateIssueIdsInput` carries both fields and `resolveIssueUserRefs` resolves them, so this only wires them through the parser, `toResolverInput`, `toCreateInput`, and the published JSON Schema. `parseLabels` becomes `parseStringList`, taking the key name so the subscribers error names `subscribers` rather than `labels`. Subscribers accept the same array-or-comma-separated shapes as labels, matching what `--subscribers` takes on the command line.
`resolveBatchCreateIssueIds` collapsed entries naming the same reference set onto one request, which covers the common batch that shares a team and project and differs only in title. It did not bound anything else: every *distinct* set started concurrently, so a heterogeneous import — varying assignee, project and status per row, which is the case this command exists for — issued N simultaneous `BatchResolveForCreate` requests plus their user lookups. That is how a large import earns a rate-limit rejection instead of a result. Distinct sets now resolve in waves of five. Deduplication moved ahead of the fan-out so the window counts real requests: a hundred rows sharing one reference set still costs a single wave, unchanged from before. Five is a deliberate compromise — high enough that the small mixed batch sees no practical slowdown, low enough that a hundred-row import stays well inside Linear's request budget. An adaptive window keyed off observed rate-limit headers would be better, but the client does not surface them today.
`--include-archived` does two things: it includes archived issues, and it drops the implicit "hide completed" narrowing that `list`/`search` apply by default. The second half is deliberate — archived issues are nearly always completed, so keeping the default clause would hide exactly what the flag was passed to surface — but neither the flag description nor the `issues usage` context said so, and that text is what an agent reads before choosing a flag. A caller reaching for archived issues got every completed non-archived issue as well, with nothing to explain it. The domain context now also states the default narrowing itself, which was undocumented in either place, and points at --state-type completed for the "completed but not archived" case.
`issues update` and `issues batch create` both check `--estimate` against the team's estimation scale before sending anything. `batch update` did not, so `--estimate 7` on a fibonacci team came back as a raw Linear API error rather than the JSON envelope naming the allowed values — the same input, rejected three different ways depending on which command you reached for. Every distinct team the batch spans is checked, not just the single-team one. A single patch applies the same estimate to all targets, so it has to be valid on each of their scales. An estimate of 8 across a fibonacci team and a linear team is legal on the first and not the second, and sending it would half-apply. That costs one lookup per distinct team, and a batch spanning teams is already the rare shape. The targets are resolved before the check runs, so their team UUIDs are in hand; `resolveTeamEstimateContext` takes it from there.
`issues batch update` could set --cycle and --project-milestone but had no way to unset them, so detaching twenty issues from a cycle meant twenty single-issue `issues update --clear-cycle` calls. Every other set-valued field in the batch flag list already had a --clear-* counterpart; these two were the gap. Both flags mirror the single-issue semantics exactly: they send a null cycleId and a null projectMilestoneId respectively, and each is mutually exclusive with its setter through the same exclusion table the other pairs use. The milestone branch is kept separate from the one --clear-project already runs, because clearing the milestone alone is valid for a batch that stays in its project. buildBatchUpdateInput is now exported so the clear-versus-set branches can be asserted directly — a null and an absent field mean different things to the API, and only the built patch shows which one a flag produced.
`issues update --team OPS --estimate 4` read the estimation config from the issue's current team, but with --team the estimate lands on the destination. Moving a fibonacci-scale issue to a linear-scale team therefore rejected 4 while naming the team the issue was leaving, and the reverse waved an off-scale value through to come back as a raw API error from Linear. The scale to check is whichever team owns the issue afterwards, so --team now resolves the destination's estimation config and validates against that; the issue's own context is only looked up when the issue is staying put. Neither path costs an extra round trip, because the two are exclusive: resolving the destination also means skipping resolveIssueEstimateContext, and the issue id then comes from resolveIssueId instead. This is the same rule `issues batch update` already applies per target team.
The default "hide completed" narrowing lives in buildListIssuesFilter, which only `listIssues` calls. `issues search` — and `issues list --query`, which runs the same full-text query — never applied it, so the usage context and the --include-archived flag description both described a behaviour the full-text path does not have. The claim is now scoped to `list`, with a separate paragraph saying what the search path actually does: it returns completed issues either way, and --include-archived there only adds archived ones. This text is what an agent reads before picking a flag, so a wrong default is worse than a missing one. The behaviour itself is left alone. Narrowing full-text results by state would silently drop matches a caller searched for by name, which is a bigger surprise than the inconsistency.
`issues remind --at +99999999w` computed an instant outside the range a JavaScript `Date` can hold, and `toISOString()` answered that with a bare `RangeError: Invalid time value`. It still reached the caller as JSON — `handleCommand` catches everything — but it was the one error in this parser that named neither the flag nor the value it choked on, which is exactly what an agent needs to correct the call. Check the computed instant before formatting it and report it like every other rejected value here: `Invalid --at: "+99999999w" is too far in the future to represent`. The check sits after the arithmetic rather than bounding the accepted digits up front: the limit depends on `now` and the unit, so a digit cap would either reject valid input or let some invalid input through, while `Number.isNaN` on the result is exact.
`labels` and `subscribers` accept the comma-separated flag form as well
as a JSON array, and the flag form is parsed by `parseCommaSeparated`,
which throws its own message. So `{"labels": "a,,b"}` came back as
`Invalid comma-separated list: contains empty segments` — no key, and no
`batch document entry N:` prefix, which is the only thing that says
which of a hundred entries to fix. Every other rejection in this parser,
including the array branch of the same field, carries that locator.
Restate the failure with the entry's own locator and the key that
carried it. The message is rewritten rather than wrapped: the underlying
error adds nothing the new one does not say, and prefixing it would read
as two stacked "Invalid ..." clauses.
`parseCommaSeparated` itself is left alone — it is shared with the flag
paths, where "comma-separated list" is exactly the right noun.
`buildBatchUpdateContext` rejects a named `--status` or `--cycle` when the targets span teams, because `issueBatchUpdate` applies one `stateId` to all of them and there is no single team to resolve the name against. `--labels` has exactly that property and was not covered: Linear labels can be team-scoped, so two teams may each own a "bug", the lookup filter matches on name alone and `mapLabels` takes the first hit. A cross-team `--labels bug` therefore tagged half the batch with the other team's label — silently, since both names read the same in the output. Extend the guard to `--labels`, checking the list entry by entry so a UUID still passes: the error tells callers to pass a UUID instead, and `--labels` is the one flag here whose value can mix the two forms. The three checks now share a `crossTeam` helper rather than repeating the message; the wording is unchanged so existing callers (and the tests) still see the same guidance.
`me`/`@me` is documented as a valid user reference everywhere a user is named — the JSON Schema's `assignee` description, `ISSUES_META`'s `user` argument, the `--assignee` help — but only `--subscribers` and `--delegate` actually honoured it, because those are the two flags that go through `resolveUserId`. Everything else hands the reference to the batch queries as `$assigneeQuery`/`$creatorQuery`, which match display name and email; `me` is neither, so `issues create --assignee me`, `update`, `batch create` and the `--assignee`/`--creator` filters all answered `User "me" not found`. Divert the alias before the query is built: `buildUserQuery` yields null for a UUID and for a viewer alias alike (both resolve elsewhere), and the alias takes a `viewer` lookup issued alongside the batch request. The mutation resolvers already run one concurrent user-lookup phase for subscribers and delegate, so the assignee case joins it and costs no extra round trip in the common case; the filter resolver gains one, and resolves `--assignee me --creator me` with a single lookup for both. Both call sites await the lookup in the same expression as the batch request. Starting it without awaiting it there would leave a rejection unhandled when the other side throws first, which kills the process with a stack trace instead of the JSON error envelope — the same reasoning already recorded for the subscriber lookups. `isViewerAlias` is exported rather than duplicating the alias set, so `me` and `@me` keep one definition.
The block describing `resolveBatchCreateIssueIds` sat directly above `BATCH_CREATE_RESOLVE_CONCURRENCY`, so every editor attributed twenty lines about batching semantics to the number 5 and showed the exported function itself with no hover doc at all. Move it onto the function and leave the constant a one-line comment. The text also still described a "promise cache" collapsing entries onto one in-flight request — the shape before the concurrency bound landed. The implementation now deduplicates reference sets into a Map up front and resolves them in waves, so say that instead.
✅ knip — no dead codeNo unused files, exports, types, or dependencies detected. |
`batch create` accepted a JSON document and had a published schema to
write it against; `batch update` was flag-only, so the one command that
edits many issues at once had no contract a caller could validate before
firing a mass mutation. Generating a long `--clear-*`-laden command line
is also the awkward half of the pair for an agent driving the CLI.
`batch update` now accepts `--file`/`--json` carrying
`{"issues": [...], "patch": {...}}`, published as
`schemas/issues-batch-update.schema.json`. The patch keys mirror the
update flags with the dashes dropped, and `null` clears a field the way
`--clear-*` does — cleaner than a document with both `assignee` and
`clearAssignee` keys and a mutual-exclusion rule to state twice.
The document is one patch over a list of targets rather than an array of
per-issue patches, because that is what `issueBatchUpdate` can do. Per
-issue patches would fan out into N mutations and lose the all-or-
nothing guarantee that is the reason to batch.
Both input paths normalise into one `BatchUpdatePatch` before anything
else touches them, so the team-scope guard, the estimate validation and
the built mutation input stay single implementations. Mixing a document
with flags is refused by name instead of silently ignored — a dropped
`--status` would apply a different patch to every issue in the batch.
The cross-team error now names `status`/`cycle`/`labels` without dashes,
since the rule covers both forms.
The README spelled out the batch-create schema at length: the raw URL twice, a check-jsonschema invocation, and a VS Code settings block. That is validator and editor documentation rather than linearis documentation, and it buried the thing a reader actually needs — what the document looks like. With a second schema now published for batch update, repeating that treatment would have doubled it. Instead the section covers both commands with one example document each, the commands that consume them, and a pointer to schemas/ for anyone who wants to wire up validation.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What does this PR do?
Closes the audited gaps in the
issuesdomain against the Linear API: new commands (from-branch,restore,snooze,subscribe,share,remind,batch create,batch update), richer filters (order-by,unassigned,state-type, subscriber, working--include-archived), team moves with subscribers/delegates, extra output fields (url,creator,delegate, lifecycle timestamps), plusattachments disable-syncand a published JSON Schema for batch-create documents. It also fixes a batch of correctness bugs surfaced by the audit — archived issues unreachable by identifier, un-awaited user lookups breaking JSON output, estimates not validated against the target team's scale, mixed-team label/UUID guards,meassignee resolution, and relative date-offset overflow reporting.Type of change
Checklist
npm run check:cipasses (lint + format)npx tsc --noEmitpasses (type check)npm testpasses (unit tests)Testing
Full AGENTS.md checklist on the rebased branch:
npm run generate,npm run check:ci(clean — only the pre-existing biome 2.5.6/2.5.7 schema-version info already onnext),npx tsc --noEmit,npm test(71 files / 1043 tests passing),npm run build,npm run knip(no findings).Notes for reviewers
Risk is concentrated in the batch commands (
src/commands/issues-batch.ts, ~760 new lines) and inissue-mutation-resolver.ts, where cross-team validation now runs against the destination team — worth a close read. Batch create bounds its ID-resolution fan-out rather than resolving every row concurrently, trading a little latency for not hammering the API. The commits are split one-concern-each and ordered so the feature commits land before the fixes that harden them; reviewing commit-by-commit is easier than the squashed diff.