Skip to content

feat(issues): close the issues domain coverage gaps - #293

Merged
iamfj merged 28 commits into
nextfrom
conductor/iamfj-docs/issues-domain-gap-audit
Aug 10, 2026
Merged

feat(issues): close the issues domain coverage gaps#293
iamfj merged 28 commits into
nextfrom
conductor/iamfj-docs/issues-domain-gap-audit

Conversation

@iamfj

@iamfj iamfj commented Aug 10, 2026

Copy link
Copy Markdown
Member

What does this PR do?

Closes the audited gaps in the issues domain 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), plus attachments disable-sync and 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, me assignee resolution, and relative date-offset overflow reporting.

Type of change

  • Bug fix
  • New feature
  • Refactor (no behavior change)
  • Documentation
  • Tests
  • Build / CI

Checklist

  • npm run check:ci passes (lint + format)
  • npx tsc --noEmit passes (type check)
  • npm test passes (unit tests)
  • New code has tests (happy path + primary error case)
  • Commit messages follow Conventional Commits

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 on next), 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 in issue-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.

iamfj added 26 commits August 10, 2026 12:22
`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.
@github-actions

Copy link
Copy Markdown

✅ knip — no dead code

No unused files, exports, types, or dependencies detected.

iamfj added 2 commits August 10, 2026 14:04
`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.
@iamfj
iamfj merged commit b8e4de8 into next Aug 10, 2026
12 checks passed
@iamfj
iamfj deleted the conductor/iamfj-docs/issues-domain-gap-audit branch August 10, 2026 12:30
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