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
1 change: 1 addition & 0 deletions skills/linear-axi/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,5 +51,6 @@ Run `npx -y @apexcloudwise/linear-axi --help` for global flags, or `npx -y @apex
- Mutations are idempotent: updating an issue to its current state, or deleting an already-deleted issue, is a reported no-op (exit 0).
- Repeated `--label` flags match issues carrying ANY of the given labels; a missing or blank value is rejected, never dropped.
- `issues --cycle current` lists every team's active-cycle issues (add `--team <KEY>` to narrow); `--cycle <number>` also requires `--team` because cycle numbers restart per team - find them with `cycles [--team <KEY>]`.
- `issues --fields <a,b,c>` and `issue view <IDENTIFIER> --fields <a,b,c>` add opt-in extra fields (per-command sets - see each command's --help) when the default columns lack something you need.
- `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`.
36 changes: 32 additions & 4 deletions src/commands/issue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
getPositional,
} from '../args.js';
import { AxiError } from '../errors.js';
import { parseFields, type ExtraFieldSpec } from '../fields.js';
import {
field,
custom,
Expand All @@ -31,20 +32,24 @@ export const ISSUE_HELP = `usage: linear-axi issue <subcommand> [args]
Manage Linear issues.

subcommands:
view <IDENTIFIER|UUID> show issue details (--full disables description truncation)
view <IDENTIFIER|UUID> show issue details (--full disables description truncation; --fields <a,b,c> adds extra fields)
create --title "..." --team <KEY> [--description "..."] [--label <name>...]
update <ref> [--state <name>] [--title "..."] [--priority <0-4>] [--description "..."]
delete <ref>

view --fields names (opt-in, comma-separated): dueDate, estimate, archivedAt, branchName
default detail fields are untouched without the flag

examples:
linear-axi issue view LIN-123
linear-axi issue view LIN-123 --full
linear-axi issue view LIN-123 --fields dueDate,estimate
linear-axi issue create --title "Fix login" --team ENG --label bug
linear-axi issue update LIN-123 --state "In Progress"
linear-axi issue delete LIN-123
`;

const VIEW_FLAGS = ['--full'];
const VIEW_FLAGS = ['--full', '--fields'];
const CREATE_FLAGS = ['--title', '--team', '--description', '--label'];
const UPDATE_FLAGS = ['--state', '--title', '--priority', '--description'];

Expand All @@ -62,6 +67,21 @@ const detailSchema: FieldDef[] = [
relativeTime('updatedAt', 'updated'),
];

/**
* Opt-in extra fields for `issue view --fields` (#23). Only fields NOT already
* in the default detail output are offered; each linearKey is a scalar on
* Linear's Issue type (verified against the generated types in @linear/sdk
* v90.0.0): dueDate (TimelessDate serialized YYYY-MM-DD, null when unset),
* estimate (Float, null when unset), archivedAt (DateTime, null when not
* archived), branchName (String).
*/
const VIEW_EXTRA_FIELDS: Record<string, ExtraFieldSpec> = {
dueDate: { linearKey: 'dueDate', def: field('dueDate') },
estimate: { linearKey: 'estimate', def: field('estimate') },
archivedAt: { linearKey: 'archivedAt', def: relativeTime('archivedAt', 'archived') },
branchName: { linearKey: 'branchName', def: field('branchName') },
};

export async function issueCommand(
args: string[],
ctx: LinearContext,
Expand Down Expand Up @@ -98,14 +118,20 @@ async function viewIssue(
assertKnownFlags(args, VIEW_FLAGS);
const apiKey = requireKey(ctx.apiKey);
const full = takeBoolFlag(args, '--full');
// Opt-in extra fields (--fields): unknown names fail loud (parseFields)
// before any network request; a blank value selects nothing.
const { extraDefs, extraLinearKeys } = parseFields(
takeFlag(args, '--fields'),
VIEW_EXTRA_FIELDS,
);
const refRaw = getPositional(args);
if (!refRaw) {
throw new AxiError('Missing issue reference', 'VALIDATION_ERROR', [
'Run `linear-axi issue view <IDENTIFIER>` (e.g. LIN-123)',
]);
}

const issue = await fetchIssue(apiKey, refRaw);
const issue = await fetchIssue(apiKey, refRaw, extraLinearKeys);
if (!issue) {
throw new AxiError(
`Issue "${refRaw}" not found`,
Expand All @@ -114,7 +140,9 @@ async function viewIssue(
);
}

const blocks: string[] = [renderDetail('issue', issue, detailSchema)];
const blocks: string[] = [
renderDetail('issue', issue, [...detailSchema, ...extraDefs]),
];

// Description: truncated by default, full on --full (principle 3).
const { preview, total, truncated } = truncateForDisplay(
Expand Down
36 changes: 33 additions & 3 deletions src/commands/issues.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { requireKey } from '../config.js';
import { fetchIssues, fetchViewer, type IssueListFilter } from '../linear.js';
import { assertKnownFlags, takeFlag, takeAllFlags } from '../args.js';
import { AxiError } from '../errors.js';
import { parseFields, type ExtraFieldSpec } from '../fields.js';
import {
field,
custom,
Expand All @@ -15,7 +16,7 @@ import {
} from '../toon.js';
import { formatCountLine } from '../format.js';

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

flags:
Expand All @@ -26,6 +27,7 @@ flags:
--project <name> filter by project name (exact match; run \`linear-axi projects\` for names)
--cycle <current|number> filter by cycle: "current" matches issues in any team's active cycle (with --team, just that team's); a cycle number requires --team because numbers restart per team (run \`linear-axi cycles\` for numbers)
--search <text> full-text search (same ranking as Linear's app search); composable with the filters above
--fields <a,b,c> opt-in extra columns (comma-separated): url, estimate, dueDate, createdAt; default columns are untouched without this flag
--limit <n> max issues to return (default 25, max 500; fetched in pages of 50)

examples:
Expand All @@ -36,6 +38,7 @@ examples:
linear-axi issues --project "Mobile app" --state started
linear-axi issues --cycle current --team LIN
linear-axi issues --search "onboarding" --team LIN --state started
linear-axi issues --fields url,estimate
`;

const KNOWN_FLAGS = [
Expand All @@ -46,6 +49,7 @@ const KNOWN_FLAGS = [
'--project',
'--cycle',
'--search',
'--fields',
'--limit',
];

Expand All @@ -57,6 +61,20 @@ const listSchema: FieldDef[] = [
relativeTime('updatedAt', 'updated'),
];

/**
* Opt-in extra columns for `issues --fields` (#23). Only fields NOT already in
* the default list output are offered; each linearKey is a scalar on Linear's
* Issue type (verified against the generated types in @linear/sdk v90.0.0):
* url (String), estimate (Float, null when unset), dueDate (TimelessDate
* serialized YYYY-MM-DD, null when unset), createdAt (DateTime).
*/
const ISSUE_LIST_EXTRA_FIELDS: Record<string, ExtraFieldSpec> = {
url: { linearKey: 'url', def: field('url') },
estimate: { linearKey: 'estimate', def: field('estimate') },
dueDate: { linearKey: 'dueDate', def: field('dueDate') },
createdAt: { linearKey: 'createdAt', def: relativeTime('createdAt', 'created') },
};

const STATE_TYPES = new Set([
'backlog',
'unstarted',
Expand Down Expand Up @@ -103,6 +121,13 @@ export async function issuesCommand(
const limitRaw = takeFlag(args, '--limit');
const limit = limitRaw ? parseLimit(limitRaw) : 25;

// Opt-in extra columns (--fields): unknown names fail loud (parseFields)
// before any network request; a blank value selects nothing.
const { extraDefs, extraLinearKeys } = parseFields(
takeFlag(args, '--fields'),
ISSUE_LIST_EXTRA_FIELDS,
);

if (stateType && !STATE_TYPES.has(stateType.toLowerCase())) {
throw new AxiError(
`Unknown state type: "${stateType}"`,
Expand Down Expand Up @@ -177,13 +202,18 @@ export async function issuesCommand(
if (cycleFilter !== undefined) filter.cycle = cycleFilter;
if (search !== undefined) filter.search = search;

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

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

if (issues.length) {
blocks.push(renderList('issues', issues, listSchema));
blocks.push(renderList('issues', issues, [...listSchema, ...extraDefs]));
} else {
blocks.push('issues: 0 found');
}
Expand Down
35 changes: 32 additions & 3 deletions src/linear.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,16 @@ export const ISSUE_DETAIL_FIELDS = `
updatedAt
`;

/**
* Append opt-in extra scalar keys (from `--fields`) to a base issue selection
* set. Returns the base string unchanged when no extras are requested, so the
* default GraphQL documents stay byte-identical without the flag (opt-in only).
*/
export function withExtraFields(base: string, extraKeys: string[]): string {
if (extraKeys.length === 0) return base;
return `${base}${extraKeys.map((k) => `\n ${k}`).join('')}`;
}

/**
* Selection for the `projects` connection. We select `status { type }` rather
* than the deprecated `state` field (Project.state: "[DEPRECATED] Use
Expand Down Expand Up @@ -123,6 +133,14 @@ export interface LinearIssue {
url?: string;
createdAt?: string;
updatedAt?: string;
// Opt-in extra fields, present only when selected via --fields. Shapes
// verified against the generated Issue type in @linear/sdk v90.0.0:
// estimate is Float|null, dueDate a TimelessDate serialized as YYYY-MM-DD
// (null when unset), archivedAt an ISO DateTime, branchName a String.
estimate?: number | null;
dueDate?: string | null;
archivedAt?: string | null;
branchName?: string;
}

export interface LinearTeam {
Expand Down Expand Up @@ -413,11 +431,17 @@ interface IssueListPage {
* 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).
*
* `extraFields` is a list of opt-in extra scalar Issue keys (from `--fields`)
* appended to the node selection; the default document is used when empty.
* It applies to both the `issues` and the `searchIssues` (filter.search)
* documents — both return Issue nodes.
*/
export async function fetchIssues(
apiKey: string,
filter: IssueListFilter,
limit = 50,
extraFields: string[] = [],
): Promise<IssueListResult> {
const where: string[] = [];

Expand Down Expand Up @@ -453,17 +477,18 @@ export async function fetchIssues(

const filterPart = where.length ? `filter: { ${where.join(', ')} }, ` : '';
const isSearch = filter.search !== undefined;
const selection = withExtraFields(ISSUE_LIST_FIELDS, extraFields);

const query = isSearch
? `query SearchIssues($term: String!, $first: Int!, $after: String) {
searchIssues(term: $term, ${filterPart}first: $first, after: $after) {
nodes { ${ISSUE_LIST_FIELDS} }
nodes { ${selection} }
pageInfo { hasNextPage endCursor }
}
}`
: `query Issues($first: Int!, $after: String) {
issues(${filterPart}first: $first, after: $after, orderBy: updatedAt) {
nodes { ${ISSUE_LIST_FIELDS} }
nodes { ${selection} }
pageInfo { hasNextPage endCursor }
}
}`;
Expand Down Expand Up @@ -508,14 +533,18 @@ export async function fetchIssues(
/**
* Fetch a single issue. Linear's `issue(id:)` accepts EITHER a UUID or the
* human-readable identifier (e.g. "CTZ-311"), so no resolution step is needed.
*
* `extraFields` is a list of opt-in extra scalar Issue keys (from `--fields`)
* appended to the selection; the default document is used when empty.
*/
export async function fetchIssue(
apiKey: string,
ref: string,
extraFields: string[] = [],
): Promise<LinearIssue | undefined> {
const data = await linearRequest<{ issue: LinearIssue | null }>(
apiKey,
`query Issue($id: String!) { issue(id: $id) { ${ISSUE_DETAIL_FIELDS} } }`,
`query Issue($id: String!) { issue(id: $id) { ${withExtraFields(ISSUE_DETAIL_FIELDS, extraFields)} } }`,
{ id: ref },
);
return data.issue ?? undefined;
Expand Down
1 change: 1 addition & 0 deletions src/skill.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ Run \`npx -y @apexcloudwise/linear-axi --help\` for global flags, or \`npx -y @a
- Mutations are idempotent: updating an issue to its current state, or deleting an already-deleted issue, is a reported no-op (exit 0).
- Repeated \`--label\` flags match issues carrying ANY of the given labels; a missing or blank value is rejected, never dropped.
- \`issues --cycle current\` lists every team's active-cycle issues (add \`--team <KEY>\` to narrow); \`--cycle <number>\` also requires \`--team\` because cycle numbers restart per team - find them with \`cycles [--team <KEY>]\`.
- \`issues --fields <a,b,c>\` and \`issue view <IDENTIFIER> --fields <a,b,c>\` add opt-in extra fields (per-command sets - see each command's --help) when the default columns lack something you need.
- \`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
Loading