Repository navigation
rc: 0.1 read and discovery capabilities - #51
Conversation
Implement --search <text> on the issues command via Linear's ranked searchIssues query (the current form of the deprecated issueSearch root field), composing the existing IssueFilter from fetchIssues and reusing ISSUE_LIST_FIELDS. Missing/blank --search values fail loud rather than being silently dropped, and an empty search result gains a search-specific hint. Establishes the stubbed-global-fetch pattern in test/issues-search.test.ts for testing command/transport code without network.
Honor every repeated --label via labels: { some: { name: { in: [...] } } }
(any-of semantics, documented in --help) and auto-paginate fetchIssues in
50-size batches using pageInfo { hasNextPage endCursor } until --limit is
satisfied. Raise the --limit cap to 500 (rejected loudly above it), keep the
truncation hint accurate via pageInfo.hasNextPage, and drop the old
first-label-only filter + hint.
Closes #18
List workspace projects via the root `projects` connection (name,
lifecycle state, progress, lead, target date), auto-paginated in 50-size
batches like fetchIssues. State is selected as `status { type }` —
Project.state is deprecated in favor of Project.status per @linear/sdk,
and status.type yields the same raw lowercase lifecycle values
(backlog, planned, started, paused, completed, canceled). Progress is
rendered as an integer percent (0-100).
Add `issues --project <name>`, composing project: { name: { eq: ... } }
into the shared IssueFilter builder so it works on both the issues and
searchIssues query paths; a missing/blank value is rejected loudly like
--search. Add resolveProjectId mirroring resolveTeamId (reserved for
v0.3 write-side project assignment).
Closes #20
parseFields (src/fields.ts) was built and unit-tested but wired into no command. Add the opt-in --fields <a,b,c> flag to `issues` and `issue view` (#23): per-command available field maps select extra GraphQL scalar keys that are appended to the selection set and the rendered TOON schema. - issues: url, estimate, dueDate, createdAt (extra columns) - issue view: dueDate, estimate, archivedAt, branchName (extra rows) - Field shapes verified against the generated Issue type in @linear/sdk v90.0.0; only fields absent from each command's default output are offered, so defaults stay byte-identical without the flag - fetchIssues/fetchIssue gain an optional extraFields param; a new withExtraFields helper appends keys, returning the base string unchanged when no extras are requested. Extras apply to the searchIssues document too - Unknown names fail loud (AxiError VALIDATION_ERROR) with the valid set listed, before any network request; blank values select nothing - Help (ISSUES_HELP/ISSUE_HELP), KNOWN_FLAGS/VIEW_FLAGS, and SKILL.md updated; new test/fields-wiring.test.ts (13 tests) covers document + output wiring, filter/search composition, unknown-field failure, and default-schema invariance
`comment list <ref>` reads an issue's comment thread through the root
`comments` connection, scoped via `filter: { issue: { id: { eq: } } }`
(the connection has no issueId argument in current @linear/sdk), ordered
`orderBy: createdAt` (Linear connections sort descending, so newest-first
with no client-side reversing). TOON list rows render author (user, falling
back to externalUser for integration comments), body truncated to a
200-char preview (`--full` disables), and relative creation time, with a
count line, definitive empty state, and reply hints. Bare `comment <ref>`
--body` keeps dispatching to create exactly as before.
Closes #19
`linear-axi labels` lists the workspace's issue labels through the root
`issueLabels` connection (the same connection resolveLabelIds already
queried workspace-wide), auto-paginated in 50-node batches with
orderBy: updatedAt (recent-first, matching issues/projects/cycles).
TOON rows render the label name and its color as the raw HEX string
("#EB5757"-style per the generated IssueLabel type in @linear/sdk —
rendered raw since terminal color support varies, no ANSI swatch), with
a count line, definitive empty state, and hints pointing at
`issue create --label` and `issues --label`.
resolveLabelIds now reuses fetchLabels, fixing a latent gap where label
resolution fetched a single unpaginated page and silently missed labels
beyond the first 50; matching stays case-insensitive in server order.
Closes #22
seraph-pixelperfect
left a comment
There was a problem hiding this comment.
Verified on rc/0.1 (534b3cc) in a clean worktree: pnpm run build, pnpm run lint, pnpm test (13 files / 134 tests), and pnpm run build:skill -- --check all pass — matching the evidence in the description.
Verdict: approve.
What holds up on read-through:
- The pagination pattern is applied consistently across all five list fetchers, including the loop guards for a non-advancing cursor and an empty page with
hasNextPage: true. The last-pagefirstshrinks to the remaining slots, so no over-fetch. - Loud-failure flag handling throughout: missing/blank
--search/--project/--cycle/--teamvalues are rejected,--limit 501fails instead of silently clamping to 500, and the #37 finding (--searchconsuming a following flag) is fixed with regression coverage intest/issues-search.test.ts. comment listdispatch is backward compatible — barecomment <ref> --bodystays the create path, and no real ref (TEAM-NUMBER or UUID) can collide with the wordlist.resolveLabelIdsnow reuses the paginatedfetchLabels, fixing a real silent miss for workspaces with more than one page of labels.- Tests assert on captured GraphQL documents and variables rather than snapshots, so they pin the actual wire contract (
searchIssues(term, filter, first, after),labels: { some: { name: { in: [...] } } },cycle: { isActive | number }) without touching the network.
Non-blocking notes, both fine to defer:
- A
--searchvalue that legitimately begins with--is rejected. Acceptable trade-off for the flag-eating fix, and it's tested — just worth remembering if someone ever reports it. - Filter values are interpolated into the query text via
JSON.stringify. Safe for quotes and backslashes and consistent with the pre-existing pattern, but moving them into GraphQL variables would be more robust — reasonable to fold into the transport-hardening work already reserved for rc/0.2.
| // literal first positional "list" selects the read subcommand, and no | ||
| // Linear issue reference (TEAM-NUMBER like LIN-123, or a UUID) can collide | ||
| // with that word, so existing invocations are unaffected. | ||
| const sub = getPositional(args, 0); |
There was a problem hiding this comment.
commentCommand dispatches on getPositional(args, 0), which returns the first token not starting with '--' regardless of whether it is actually a preceding flag's value, so a --body/--body-file value equal to the literal string "list" gets misrouted to the read-thread subcommand instead of creating the comment.
linear-axi comment --body "list" LIN-123 (flags-before-positional is normal CLI usage): args=['--body','list','LIN-123'] -> getPositional skips '--body' (starts with '--') and returns 'list' as sub. sub === 'list' routes to listComments(['list','LIN-123']), which then calls getPositional(args) again on that slice and gets 'list' as the ref, so it does fetchIssue(apiKey, 'list') -> Issue "list" not found. The comment is never created and the user gets a confusing NOT_FOUND error with no mention of --body or dispatch. Verified by reading args.ts:getPositional (line 97-105: if (!args[i].startsWith('--')) return args[i];) and tracing the exact token flow.
| ]); | ||
| } | ||
| const hasProject = args.some((a) => a === '--project' || a.startsWith('--project=')); | ||
| const project = takeFlag(args, '--project'); |
There was a problem hiding this comment.
The new --project and --cycle value guards only check for undefined/blank, unlike --search (line 102) which was also given a .startsWith('--') check in the same PR; because takeFlag() blindly consumes the next token as the flag's value, --project/--cycle can silently swallow a following flag as their value.
linear-axi issues --project --limit : takeFlag(args, '--project') grabs '--limit' as project's value and splices both tokens out of args. project is now the non-blank string "--limit" so no error fires (unlike --search, which has search.startsWith('--') at line 102 to catch exactly this). The user's real --limit value is silently gone (falls back to default 25) and the query filters by a project literally named "--limit", returning 0 issues with a misleading hint. Confirmed by reading takeFlag in args.ts (lines 24-40: const val = args[i + 1]; args.splice(i, 2); return val; — no check on what args[i+1] looks like) plus the asymmetric guards at issues.ts:100-123.
| ctx: LinearContext, | ||
| ): Promise<string> { | ||
| // `labels` takes no flags — reject any rather than silently dropping it. | ||
| assertKnownFlags(args, []); |
There was a problem hiding this comment.
labelsCommand (and the sibling new projects.ts) validates args with only assertKnownFlags(args, []), which per its implementation (args.ts:114-134) skips any token not starting with '--' — so stray positional arguments are silently ignored, contradicting the comment on this exact line ('reject any rather than silently dropping it') and the stricter behavior added in the same PR's cycles.ts (line 85-88) and issues.ts (line 171-175), which both explicitly reject stray positionals with 'Unexpected argument: ...'.
linear-axi labels foo or linear-axi projects bogus is silently accepted and the stray token is dropped with no error, while the near-identical new sibling command linear-axi cycles foo correctly fails loud with Unexpected argument: foo. This is an inconsistency introduced within this same PR between four structurally-identical new/updated command files, and it violates the project's own stated 'fail loud, never drop silently' principle for exactly the two commands (labels, projects) that lack the check.
| let cursor: string | undefined; | ||
| let hasMore = false; | ||
|
|
||
| while (projects.length < limit) { |
There was a problem hiding this comment.
The cursor-pagination while-loop (page-size capping, hasNextPage/endCursor handling, non-advancing-cursor and empty-page break guards) is duplicated near-verbatim five times: fetchProjects (line 247), fetchCycles (~366), fetchIssues (~524), fetchComments (~646), fetchLabels (~734) — only the query string and node type differ.
The loop-termination guards (if (!hasMore || nextCursor === undefined) break; / if (nodes.length === 0 || nextCursor === cursor) break;) are safety-critical (they prevent an infinite loop against a misbehaving API) but hand-copied five times. A future fix to one copy (e.g. a bug found in fetchIssues' pagination) is unlikely to be propagated to the other four, and the next new list command will just copy-paste a sixth time instead of calling a shared paginateConnection() helper.
| apiKey: string, | ||
| names: string[], | ||
| ): Promise<string[]> { | ||
| const { labels } = await fetchLabels(apiKey); |
There was a problem hiding this comment.
resolveLabelIds calls fetchLabels(apiKey) with no limit argument (defaulting to 500, auto-paginated in 50-row pages) just to resolve a handful of requested label names by client-side filtering, instead of a server-side name filter.
linear-axi issue create --team ENG --label bug calls resolveLabelIds, which in a workspace with a few hundred labels triggers up to 10 sequential awaited GraphQL round trips to fetch the entire label list, then filters client-side for the 1-3 wanted names — on every single issue-create/update call that uses --label. A server-side filter (e.g. issueLabels(filter: { name: { in: $names } })) would resolve this in one request regardless of workspace size.
| * assignment (e.g. issue create --project). ProjectFilter.name is a | ||
| * StringComparator with `eq` (verified against @linear/sdk). | ||
| */ | ||
| export async function resolveProjectId( |
There was a problem hiding this comment.
resolveProjectId is new exported production code whose own doc comment admits it is "currently exercised by tests and reserved for v0.3 write-side project assignment (e.g. issue create --project)" — it has no caller anywhere in src/, only in test/projects.test.ts, while this PR's stated scope (title: "rc: 0.1 read and discovery capabilities") is read/discovery only.
Dead exported code ships now for a not-yet-implemented v0.3 write feature, has to be read and reasoned about by every future maintainer of linear.ts, and its 'reserved for v0.3' comment can go stale (e.g. v0.3 ships using a different resolution strategy) with nothing forcing a revisit. Per ~/.claude/CLAUDE.md ('Prefer the smallest exact change that satisfies the request' / 'Stay within the requested scope'), this should be added in the PR that actually wires up issue create --project, not now.
| const stateType = takeFlag(args, '--state'); | ||
| const assignee = takeFlag(args, '--assignee'); | ||
| const labels = takeAllFlags(args, '--label'); | ||
| const hasSearch = args.some((a) => a === '--search' || a.startsWith('--search=')); |
There was a problem hiding this comment.
The 'flag present but blank/missing value must fail loud' check is hand-rolled three times in issues.ts (--search:98-108, --project:109-116, --cycle:117-123) and a fourth time in cycles.ts (--team:77-84) via a manual args.some(a => a === flag || a.startsWith(flag+'=')) scan, instead of a shared takeRequiredFlag()-style helper in args.ts.
The four copies have already drifted (see the --project/--cycle finding above: only --search got the extra .startsWith('--') guard). A shared helper fixed once would have applied the guard everywhere; as hand-rolled copies, each future filter flag added to issues.ts or a new command will either repeat the omission or need someone to remember to copy the more complete --search version instead of the incomplete --project/--cycle/--team versions.
|
|
||
| expect(out).toContain('past'); | ||
| expect(out).toContain('upcoming'); | ||
| expect(out).toContain('active'); |
There was a problem hiding this comment.
The test "derives past and upcoming statuses from the date range" asserts expect(out).toContain('active'), but cycles.ts always emits a hint containing the literal substring "active" ("...to list the active cycle's issues") whenever any cycles are returned, independent of cycleStatus()'s actual output — so this assertion passes even if cycleStatus() never produces 'active' at all or the past/upcoming labels are swapped.
If cycleStatus() (src/commands/cycles.ts) is regressed to always return 'past' (i.e. the 'active' status branch is deleted or broken), or if the past/upcoming branch labels are swapped, this test still passes because the always-present help-text hint contains "active" regardless of any row's real computed status — only the 'past' and 'upcoming' substring checks in this test do real verification; the 'active' check is vacuous.
| }; | ||
| }); | ||
|
|
||
| const { projects, hasMore } = await fetchProjects(FAKE_KEY); |
There was a problem hiding this comment.
hasMore is only unit-tested at the fetchProjects() level (line 172-180); no test in projects.test.ts or cycles.test.ts exercises the full projectsCommand()/cyclesCommand() path with hasMore=true to verify the truncation hint ("count: N (showing first N)") actually reaches the rendered output, unlike labels.test.ts:134 which does test this for labelsCommand.
Deleting the hasMore argument entirely from formatCountLine({ count: projects.length, hasMore }) in src/commands/projects.ts (and the equivalent in cycles.ts) leaves every test in both test/projects.test.ts and test/cycles.test.ts passing, because every fixture in both files sets hasNextPage: false (or omits pageInfo) — so a regression that drops or inverts the hasMore wiring between fetchProjects/fetchCycles and their command's output ships with zero CI signal for these two commands.
| */ | ||
| export async function fetchProjects( | ||
| apiKey: string, | ||
| limit = 100, |
There was a problem hiding this comment.
fetchProjects defaults to limit=100, fetchCycles to limit=10, and fetchLabels to limit=500 — three different hardcoded per-function ceilings — and none of cycles.ts, labels.ts, or projects.ts expose a --limit flag to override them, unlike issues.ts which got a fully generalized --limit/parseLimit/MAX_LIMIT=500 treatment in this same PR.
A team with more than 10 cycles has no way to see cycle #11+ from the CLI (no --limit flag exists on cycles), and a workspace with 51-100 projects always pays for 2 sequential paginated requests instead of 1 with no way to reduce that ceiling. This is the same generalization gap that produced the --search/--project/--cycle validation drift: per-command special-casing instead of one shared, configurable mechanism.
RC scope
Consolidates the read/discovery portion of the stacked PR series: #37 through #43. Write-side and transport-hardening changes (#44–#50) are intentionally reserved for
rc/0.2.Supported Linear features
searchIssuesroot field, composable with team, state, assignee, labels, project, and cycle filters--labelfilters use ANY-label semantics; issue lists auto-paginate up to 500 results--team)url,estimate,dueDate, andcreatedAt--fulloutputLinear API validation
Validated directly against Linear’s current public GraphQL introspection endpoint.
searchIssuesis non-deprecated and exposesterm,filter,first, andafter; the RC uses precisely those arguments. The current schema also reportsissues,projects,cycles,comments, andissueLabelsas non-deprecated. The earlier claim thatissueSearchis deprecated, and the unsupported claim that search ranking matches the Linear app, have been removed.Official references: GraphQL API, pagination, filtering, and deprecations.
Review finding fixed
The PR #37 review found that
issues --search --team LINcould consume--teamas the search value. This RC rejects a following flag as a missing search value and includes regression coverage.Evidence
pnpm run build— passedpnpm run lint— passedpnpm run build:skill -- --check— passedpnpm test— 13 files, 134 tests passednpm pack+ clean-prefix install +linear-axi --version— passed (0.1.2)issues,projects,cycles,labels, andcomment listgit diff --check origin/main...HEAD— passedpnpm run format:checkis non-gating and remains red for 18 repository files that pre-date this RC; it was not broadened into a formatting-only rewrite.