Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion skills/linear-axi/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,6 @@ Run `npx -y @apexcloudwise/linear-axi --help` for global flags, or `npx -y @apex

- Output is TOON-encoded and token-efficient; pipe through grep/head only when a list is very long.
- Mutations are idempotent: updating an issue to its current state, or deleting an already-deleted issue, is a reported no-op (exit 0).
- Repeated flags (`--label`) apply once per value; a missing or blank value is rejected, never dropped.
- Repeated `--label` flags match issues carrying ANY of the given labels; a missing or blank value is rejected, never dropped.
- `issue view` truncates the description by default; pass `--full` to see it entirely.
- Issue references accept either a UUID or a `TEAM-NUMBER` identifier like `LIN-123`.
34 changes: 24 additions & 10 deletions src/commands/issues.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,20 +16,21 @@ import {
import { formatCountLine } from '../format.js';

export const ISSUES_HELP = `usage: linear-axi issues [--team <KEY>] [--state <type>] [--assignee <name|me>] [--label <name>] [--search <text>] [--limit <n>]
List Linear issues, most recently updated first (relevance-ranked when --search is given).
List Linear issues, most recently updated first (relevance-ranked when --search is given).

flags:
--team <KEY> filter by team key (e.g. LIN)
--state <type> workflow state type: backlog, unstarted, started, completed, canceled, triage
--assignee <name> "me" for yourself, or a user name
--label <name> filter by label name (repeatable)
--label <name> filter by label name; repeat to match ANY of the given labels (issues carrying at least one)
--search <text> full-text search (same ranking as Linear's app search); composable with the filters above
--limit <n> max issues to return (default 25, capped at 50)
--limit <n> max issues to return (default 25, max 500; fetched in pages of 50)

examples:
linear-axi issues
linear-axi issues --assignee me --state started
linear-axi issues --team LIN --label bug
linear-axi issues --label bug --label regression
linear-axi issues --search "onboarding" --team LIN --state started
`;

Expand Down Expand Up @@ -121,14 +122,15 @@ export async function issuesCommand(
}
}

// The API matches "at least one label"; pass the first label only for v1.
if (labels.length) filter.label = labels[0];
// Every repeated --label is applied: Linear matches issues carrying at
// least one of the names ("any of" semantics, documented in --help).
if (labels.length) filter.labels = labels;
if (search !== undefined) filter.search = search;

const { issues } = await fetchIssues(apiKey, filter, limit);
const { issues, hasMore } = await fetchIssues(apiKey, filter, limit);

const blocks: string[] = [];
blocks.push(formatCountLine({ count: issues.length, limit }));
blocks.push(formatCountLine({ count: issues.length, limit, hasMore }));

if (issues.length) {
blocks.push(renderList('issues', issues, listSchema));
Expand All @@ -147,9 +149,9 @@ export async function issuesCommand(
'Run `linear-axi issue create --title "..." --team <KEY>` to create one',
);
}
if (labels.length > 1) {
if (labels.length > 1 && issues.length === 0) {
hints.push(
`Only the first label ("${labels[0]}") was applied — multi-label filter lands post-v1`,
`No issues carry any of: ${labels.join(', ')} — try fewer labels`,
);
}
if (search !== undefined && issues.length === 0) {
Expand All @@ -160,10 +162,22 @@ export async function issuesCommand(
return renderOutput(blocks);
}

/** Hard ceiling for --limit. Issues are auto-paginated in 50-size batches up to this. */
const MAX_LIMIT = 500;

function parseLimit(raw: string): number {
const n = parseInt(raw, 10);
if (isNaN(n) || n < 1) {
throw new AxiError(`Invalid --limit: ${raw}`, 'VALIDATION_ERROR');
}
return Math.min(n, 50);
// Loud failure over silent clamping (AXI principle 6): a caller asking for
// 501 should learn the real ceiling, not get 500 back unawares.
if (n > MAX_LIMIT) {
throw new AxiError(
`Invalid --limit: ${raw} (max ${MAX_LIMIT})`,
'VALIDATION_ERROR',
['Results are fetched in pages of 50; the total is capped for token safety.'],
);
}
return n;
}
16 changes: 13 additions & 3 deletions src/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,17 +12,27 @@ export interface CountLineOptions {
limit?: number;
/** True total count from an API connection. */
totalCount?: number;
/**
* Whether more results exist beyond the returned slice, when the caller
* knows it from the connection's pageInfo.hasNextPage. Takes precedence
* over the count === limit heuristic (which guesses).
*/
hasMore?: boolean;
}

export function formatCountLine(opts: CountLineOptions): string {
const { count, limit, totalCount } = opts;
const { count, limit, totalCount, hasMore } = opts;

if (totalCount !== undefined && totalCount !== null && totalCount >= count) {
return `count: ${count} of ${totalCount} total`;
}

// Hit the request limit — results may be truncated
if (limit !== undefined && count === limit && count > 0) {
// More results existed — results are truncated. Prefer the explicit
// pageInfo signal when present; fall back to the limit heuristic.
const truncated =
hasMore ?? (limit !== undefined && count === limit && count > 0);

if (truncated && count > 0) {
return `count: ${count} (showing first ${count})`;
}

Expand Down
114 changes: 80 additions & 34 deletions src/linear.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,32 +133,57 @@ export interface IssueListFilter {
stateType?: string; // backlog | unstarted | started | completed | canceled | triage
assigneeEmail?: string; // exact email (use viewer email for "me")
assigneeName?: string; // exact display name
label?: string; // label name (at least one match)
labels?: string[]; // label names — matches issues carrying ANY of them
search?: string; // ranked full-text search term (Linear app search ranking)
}

export interface IssueListResult {
issues: LinearIssue[];
/**
* True when the last fetched page reported `hasNextPage` — more results
* exist beyond the returned slice (e.g. the `--limit` was hit mid-cursor).
*/
hasMore: boolean;
}

/** Linear caps connection pages at 50; fetchIssues auto-paginates in this size. */
const PAGE_SIZE = 50;

interface IssueListPage {
nodes: LinearIssue[];
pageInfo?: { hasNextPage?: boolean; endCursor?: string | null } | null;
}

/**
* List issues with an optional filter, most recently updated first.
*
* Filter syntax follows Linear's documented comparators, e.g.
* assignee: { email: { eq: "x" } }
* labels: { name: { eq: "Bug" } }
* labels: { some: { name: { in: ["Bug", "Regression"] } } }
* state: { type: { eq: "started" } }
* team: { key: { eq: "LIN" } }
*
* `labels` uses `some: { name: { in: [...] } }` — an issue matches when at
* least one of its labels is in the list ("any of" semantics). Shape verified
* against the generated schema types in @linear/sdk: IssueFilter.labels is an
* IssueLabelCollectionFilter whose `some` takes an IssueLabelFilter with a
* `name` StringComparator exposing `in`.
*
* Results are auto-paginated: each request fetches up to PAGE_SIZE (50) nodes
* via `pageInfo { hasNextPage endCursor }` and follows the cursor until
* `limit` is satisfied or the server reports no more pages.
*
* When `filter.search` is set, Linear's ranked full-text search is used
* instead (same ranking as the Linear app's search). We query `searchIssues`
* rather than the `issueSearch` root field: both accept the same
* `filter: IssueFilter` + `first: Int`, but Linear marks `issueSearch` as
* deprecated ("will be removed in the future — use searchIssues instead"),
* and its nodes carry the same fields as `Issue` for our selection set.
* `filter: IssueFilter` + `first`/`after` paging, but Linear marks
* `issueSearch` as deprecated ("will be removed in the future — use
* searchIssues instead"), and its nodes carry the same fields as `Issue` for
* our selection set.
*
* There is no `totalCount` on Linear connections, so callers compute the count
* line from the returned slice (with a truncation hint when `limit` is hit).
* There is no usable `totalCount` on the `issues` connection, so callers
* compute the count line from the returned slice (`hasMore` in the result
* says whether a truncation hint is warranted).
*/
export async function fetchIssues(
apiKey: string,
Expand All @@ -179,41 +204,62 @@ export async function fetchIssues(
if (filter.stateType) {
where.push(`state: { type: { eq: ${jsonStr(filter.stateType)} } }`);
}
if (filter.label) {
where.push(`labels: { name: { eq: ${jsonStr(filter.label)} } }`);
if (filter.labels?.length) {
where.push(`labels: { some: { name: { in: ${jsonStr(filter.labels)} } } }`);
}

const filterPart = where.length ? `filter: { ${where.join(', ')} }, ` : '';
const first = Math.min(limit, 50);
const isSearch = filter.search !== undefined;

if (filter.search !== undefined) {
const data = await linearRequest<{
searchIssues: { nodes: LinearIssue[] };
}>(
apiKey,
`query SearchIssues($term: String!, $first: Int!) {
searchIssues(term: $term, ${filterPart}first: $first) {
const query = isSearch
? `query SearchIssues($term: String!, $first: Int!, $after: String) {
searchIssues(term: $term, ${filterPart}first: $first, after: $after) {
nodes { ${ISSUE_LIST_FIELDS} }
pageInfo { hasNextPage endCursor }
}
}`,
{ term: filter.search, first },
);
return { issues: data.searchIssues.nodes };
}

const query = `query Issues($first: Int!) {
issues(${filterPart}first: $first, orderBy: updatedAt) {
nodes { ${ISSUE_LIST_FIELDS} }
}`
: `query Issues($first: Int!, $after: String) {
issues(${filterPart}first: $first, after: $after, orderBy: updatedAt) {
nodes { ${ISSUE_LIST_FIELDS} }
pageInfo { hasNextPage endCursor }
}
}`;

const issues: LinearIssue[] = [];
let cursor: string | undefined;
let hasMore = false;

// Auto-paginate until `limit` is satisfied. Each page asks for only the
// remaining slots (capped at PAGE_SIZE) so we never over-fetch.
while (issues.length < limit) {
const first = Math.min(limit - issues.length, PAGE_SIZE);
const variables: Record<string, unknown> = isSearch
? { term: filter.search, first }
: { first };
if (cursor !== undefined) {
variables['after'] = cursor;
}
}`;

const data = await linearRequest<{ issues: { nodes: LinearIssue[] } }>(
apiKey,
query,
{ first },
);
const data = await linearRequest<{
issues?: IssueListPage;
searchIssues?: IssueListPage;
}>(apiKey, query, variables);

const conn = (isSearch ? data.searchIssues : data.issues) as IssueListPage;
issues.push(...conn.nodes);

hasMore = conn.pageInfo?.hasNextPage ?? false;
const nextCursor = conn.pageInfo?.endCursor ?? undefined;

// Stop when the server says we are done, and guard against a server that
// keeps hasNextPage true without a usable (present, advancing) cursor or
// without nodes — both would loop forever.
if (!hasMore || nextCursor === undefined) break;
if (conn.nodes.length === 0 || nextCursor === cursor) break;
cursor = nextCursor;
}

return { issues: data.issues.nodes };
return { issues: issues.slice(0, limit), hasMore };
}

/**
Expand Down Expand Up @@ -471,6 +517,6 @@ async function resolveStateIdByName(
return match.id;
}

function jsonStr(value: string): string {
function jsonStr(value: string | string[]): string {
return JSON.stringify(value);
}
2 changes: 1 addition & 1 deletion src/skill.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ Run \`npx -y @apexcloudwise/linear-axi --help\` for global flags, or \`npx -y @a

- Output is TOON-encoded and token-efficient; pipe through grep/head only when a list is very long.
- Mutations are idempotent: updating an issue to its current state, or deleting an already-deleted issue, is a reported no-op (exit 0).
- Repeated flags (\`--label\`) apply once per value; a missing or blank value is rejected, never dropped.
- Repeated \`--label\` flags match issues carrying ANY of the given labels; a missing or blank value is rejected, never dropped.
- \`issue view\` truncates the description by default; pass \`--full\` to see it entirely.
- Issue references accept either a UUID or a \`TEAM-NUMBER\` identifier like \`LIN-123\`.
`;
Expand Down
14 changes: 14 additions & 0 deletions test/format.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,20 @@ describe('formatCountLine', () => {
);
});

it('hasMore=true marks truncation even below the limit', () => {
expect(formatCountLine({ count: 3, limit: 25, hasMore: true })).toBe(
'count: 3 (showing first 3)',
);
});

it('hasMore=false beats the count === limit heuristic', () => {
// Exactly `limit` results and the connection said no more pages exist:
// nothing was truncated.
expect(formatCountLine({ count: 25, limit: 25, hasMore: false })).toBe(
'count: 25',
);
});

it('zero is a simple count', () => {
expect(formatCountLine({ count: 0 })).toBe('count: 0');
});
Expand Down
Loading