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
7 changes: 4 additions & 3 deletions skills/linear-axi/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
name: linear-axi
description: "Operate Linear (linear.app) through the linear-axi CLI - list and view issues, create, update, and delete issues, post comments and read comment threads, list teams, list/filter projects, list cycles, and list labels. Use whenever a task touches Linear: triaging or filing issues, changing issue state, setting priority, commenting or reading an issue discussion via `comment list`, discovering team keys via `teams`, scoping work to a project via `projects` and `issues --project`, to a cycle via `cycles` and `issues --cycle`, or finding label names via `labels` for `issue create --label` and `issues --label`."
description: "Operate Linear (linear.app) through the linear-axi CLI - list and view issues, create, update, and delete issues, post comments and read comment threads, list teams, list/filter projects, list cycles, and list labels. Use whenever a task touches Linear: triaging or filing issues, changing issue state, setting priority, assigning issues or managing their labels, commenting on or reading an issue discussion via `comment list`, discovering team keys via `teams`, scoping work to a project via `projects` and `issues --project`, to a cycle via `cycles` and `issues --cycle`, or finding label names via `labels` for `issue create --label`, `issue update --label`, and `issues --label`."
user-invocable: false
author: ApexCloudWise
metadata:
Expand All @@ -20,15 +20,15 @@ linear-axi authenticates with a Linear personal API key (https://linear.app/sett

## When to use

Use linear-axi whenever a task touches Linear: listing, viewing, filing, editing, or deleting issues; changing issue state or priority; commenting on issues or reading their comment threads; discovering team keys; listing projects and scoping issues to one with `issues --project <name>`; listing a team's cycles and scoping issues to one with `issues --cycle current|<number> --team <KEY>`; or listing the workspace's labels and using their names with `issues --label <name>` or `issue create --label <name>`.
Use linear-axi whenever a task touches Linear: listing, viewing, filing, editing, or deleting issues; changing issue state, priority, assignee, or labels; commenting on issues or reading their comment threads; discovering team keys; listing projects and scoping issues to one with `issues --project <name>`; listing a team's cycles and scoping issues to one with `issues --cycle current|<number> --team <KEY>`; or listing the workspace's labels and using their names with `issues --label <name>`, `issue create --label <name>`, or `issue update --label <name>`.

## Workflow

1. Run `npx -y @apexcloudwise/linear-axi` with no arguments for a dashboard - the current viewer and your started, assigned issues.
2. Drill in command-first: `issues`, `issue view <IDENTIFIER>`, `teams`, `projects`, `cycles`, `labels`.
3. Identify issues by Linear identifier (e.g. `LIN-123`) or UUID.
4. Create an issue with `issue create --title "..." --team <KEY>`; team keys come from `teams`.
5. Change state with `issue update <IDENTIFIER> --state "<name>"`; pass the workflow state name exactly (e.g. `"In Progress"`).
5. Change state with `issue update <IDENTIFIER> --state "<name>"`; pass the workflow state name exactly (e.g. `"In Progress"`). Assign with `--assignee <name|me>` and manage labels with `--label <name>` / `--remove-label <name>` (repeatable) in the same command.
6. Comment with `comment <IDENTIFIER> --body "..."` or `--body-file <path>` for multi-line bodies; read an issue's thread with `comment list <IDENTIFIER>` (newest first, `--full` for untruncated bodies).
7. Every response ends with contextual next-step hints under `help:` - follow them.

Expand All @@ -50,6 +50,7 @@ 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 `--label` flags match issues carrying ANY of the given labels; a missing or blank value is rejected, never dropped. Label names come from `labels`.
- `issue update --label <name>` adds to an issue's existing labels and `--remove-label <name>` removes one; the last removal sends an explicit empty set, so it works too. Re-assigning the current assignee (or a label set that already matches) is a reported no-op.
- `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. `comment list` likewise previews each comment body; `--full` shows them whole.
Expand Down
124 changes: 121 additions & 3 deletions src/commands/issue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,12 @@ import type { LinearContext } from '../context.js';
import { requireKey } from '../config.js';
import {
fetchIssue,
fetchViewer,
createIssue,
updateIssue,
deleteIssue,
resolveUserId,
resolveLabelIds,
} from '../linear.js';
import {
assertKnownFlags,
Expand Down Expand Up @@ -35,23 +38,39 @@ subcommands:
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 "..."]
[--assignee <name|me>] [--label <name>] [--remove-label <name>]
delete <ref>

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

update flags:
--assignee <name|me> assign the issue; "me" is the authenticated viewer
--label <name> add a label (repeatable; names from \`linear-axi labels\`)
--remove-label <name> remove a label (repeatable); removing the last one clears labels

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 update LIN-123 --assignee me --label bug
linear-axi issue update LIN-123 --remove-label bug
linear-axi issue delete LIN-123
`;

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

const detailSchema: FieldDef[] = [
field('identifier'),
Expand Down Expand Up @@ -236,10 +255,21 @@ async function updateIssueCmd(
const title = takeFlag(args, '--title');
const priorityRaw = takeFlag(args, '--priority');
const description = takeFlag(args, '--description');
const assigneeRaw = takeFlag(args, '--assignee');
const addLabelNames = takeAllFlags(args, '--label');
const removeLabelNames = takeAllFlags(args, '--remove-label');

if (!stateName && !title && priorityRaw === undefined && description === undefined) {
if (
!stateName &&
!title &&
priorityRaw === undefined &&
description === undefined &&
assigneeRaw === undefined &&
addLabelNames.length === 0 &&
removeLabelNames.length === 0
) {
throw new AxiError(
'Nothing to update — pass at least one of --state, --title, --priority, --description',
'Nothing to update — pass at least one of --state, --title, --priority, --description, --assignee, --label, --remove-label',
'VALIDATION_ERROR',
);
}
Expand All @@ -249,6 +279,13 @@ async function updateIssueCmd(
priority = parsePriority(priorityRaw);
}

// Blank flag values fail loud before any network request (takeAllFlags
// already rejects blank --label/--remove-label the same way).
const assigneeName = assigneeRaw?.trim();
if (assigneeRaw !== undefined && !assigneeName) {
throw new AxiError('--assignee requires a value', 'VALIDATION_ERROR');
}

const existing = await fetchIssue(apiKey, refRaw);
if (!existing) {
throw new AxiError(`Issue "${refRaw}" not found`, 'NOT_FOUND');
Expand All @@ -264,11 +301,92 @@ async function updateIssueCmd(
]);
}

// Assignee (#24): "me" resolves to the viewer — fetchViewer already
// returns the id the mutation needs, so no users() round trip. Any other
// value resolves through users(filter: { name: { eq } }) and fails loud on
// no match or an ambiguous display name. Requesting the current assignee
// is a field-level no-op (skipped, like a matching state above).
let assigneeId: string | undefined;
if (assigneeName) {
let resolvedId: string;
let resolvedName: string;
if (assigneeName.toLowerCase() === 'me') {
const viewer = await fetchViewer(apiKey).catch(() => null);
if (!viewer?.id) {
throw new AxiError(
'Could not resolve your Linear user for --assignee me',
'UNKNOWN',
);
}
resolvedId = viewer.id;
resolvedName = viewer.name;
} else {
resolvedId = await resolveUserId(apiKey, assigneeName);
resolvedName = assigneeName;
}
const currentAssignee = existing.assignee?.name;
if (
!currentAssignee ||
resolvedName.toLowerCase() !== currentAssignee.toLowerCase()
) {
assigneeId = resolvedId;
}
}

// Labels (#24): Linear's labelIds REPLACES the whole set, so compute the
// final set here — the issue's current label ids (ISSUE_DETAIL_FIELDS
// selects labels { nodes { id name } }) minus names matched by
// --remove-label (case-insensitive, against the issue's own labels so an
// absent name is a harmless no-op), unioned with --label names resolved
// workspace-wide via resolveLabelIds. When the computed set equals the
// current one the field is skipped; when removal empties it, [] is sent
// explicitly (updateIssue) — removing the last label must work.
let labelIds: string[] | undefined;
if (addLabelNames.length > 0 || removeLabelNames.length > 0) {
const currentNodes = existing.labels?.nodes ?? [];
const currentIds = currentNodes
.map((n) => n.id)
.filter((id): id is string => id !== undefined);
const removeLower = removeLabelNames.map((n) => n.trim().toLowerCase());
const keptIds = currentNodes
.filter((n) => !removeLower.includes(n.name.toLowerCase()))
.map((n) => n.id)
.filter((id): id is string => id !== undefined);
const addIds = addLabelNames.length
? await resolveLabelIds(apiKey, addLabelNames)
: [];
const nextIds = [...new Set([...keptIds, ...addIds])];
const unchanged =
nextIds.length === currentIds.length &&
nextIds.every((id) => currentIds.includes(id));
if (!unchanged) {
labelIds = nextIds;
}
}

// Each field can individually be a no-op (a matching state short-circuits
// above; a matching assignee/label set is skipped). When NOTHING would be
// sent, report the no-op instead of mutating with an empty input.
const hasPatch =
title !== undefined ||
description !== undefined ||
stateName !== undefined ||
priority !== undefined ||
assigneeId !== undefined ||
labelIds !== undefined;
if (!hasPatch) {
return renderOutput([
`issue: ${existing.identifier} already up to date (no-op)`,
]);
}

const updated = await updateIssue(apiKey, existing.id, {
title,
description,
stateName,
priority,
assigneeId,
labelIds,
});

return renderOutput([
Expand Down
91 changes: 88 additions & 3 deletions src/linear.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,13 @@ export const ISSUE_LIST_FIELDS = `
updatedAt
`;

/**
* Detail selection for a single issue. Label nodes include `id` because
* `issue update --label/--remove-label` (#24) needs the issue's CURRENT label
* ids to compute the union/difference replacement set — names alone cannot be
* sent back to IssueUpdateInput.labelIds. Renderers read only `name`, so the
* extra key is invisible in output.
*/
export const ISSUE_DETAIL_FIELDS = `
id
identifier
Expand All @@ -75,7 +82,7 @@ export const ISSUE_DETAIL_FIELDS = `
priority
assignee { name }
team { key name id }
labels { nodes { name } }
labels { nodes { id name } }
url
createdAt
updatedAt
Expand Down Expand Up @@ -129,7 +136,9 @@ export interface LinearIssue {
priority?: number;
assignee?: { name: string } | null;
team?: { id?: string; key: string; name?: string } | null;
labels?: { nodes: Array<{ name: string }> } | null;
// `id` is present when the selection includes it (ISSUE_DETAIL_FIELDS does;
// ISSUE_LIST_FIELDS selects no labels at all), so it is optional here.
labels?: { nodes: Array<{ id?: string; name: string }> } | null;
url?: string;
createdAt?: string;
updatedAt?: string;
Expand Down Expand Up @@ -810,6 +819,21 @@ export interface IssueUpdate {
description?: string;
stateName?: string;
priority?: number;
/**
* Assign the issue to this user id. Undefined leaves the assignee
* untouched; Linear's IssueUpdateInput.assigneeId is a nullable String
* ("The identifier of the user to assign the issue to", @linear/sdk v90).
*/
assigneeId?: string;
/**
* FULL REPLACEMENT label-id set — Linear's IssueUpdateInput.labelIds
* replaces the issue's labels wholesale ("The identifiers of the issue
* labels associated with this ticket", @linear/sdk v90). Callers compute
* union/difference against the issue's current ids. An EMPTY ARRAY IS
* SENT EXPLICITLY (removing the last label must work); only undefined
* means "leave labels alone".
*/
labelIds?: string[];
}

/**
Expand Down Expand Up @@ -854,6 +878,20 @@ export async function updateIssue(
varDecls.push('$stateId: String');
variables['stateId'] = stateId;
}
// assigneeId/labelIds follow the same omit-null convention as above, with
// one deliberate exception: labelIds === [] IS included — an explicit
// removal of the last label must reach Linear as an empty replacement set,
// not be skipped like a null optional.
if (update.assigneeId !== undefined) {
inputFields.push('assigneeId: $assigneeId');
varDecls.push('$assigneeId: String');
variables['assigneeId'] = update.assigneeId;
}
if (update.labelIds !== undefined) {
inputFields.push('labelIds: $labelIds');
varDecls.push('$labelIds: [String!]');
variables['labelIds'] = update.labelIds;
}

const data = await linearRequest<{
issueUpdate: { issue: LinearIssue; success: boolean };
Expand Down Expand Up @@ -957,8 +995,9 @@ export async function resolveProjectId(
* Reuses fetchLabels (auto-paginated) so label resolution keeps working in
* workspaces with more than one page (50) of labels — the old inline query
* here fetched a single unpaginated page and silently missed the rest.
* Unknown names are silently skipped (same semantics as issue create).
*/
async function resolveLabelIds(
export async function resolveLabelIds(
apiKey: string,
names: string[],
): Promise<string[]> {
Expand All @@ -969,6 +1008,52 @@ async function resolveLabelIds(
.map((l) => l.id);
}

/**
* Resolve a user's display name (exact match) to their id for
* `issue update --assignee <name>` (#24). Query shape verified against the
* generated documents in @linear/sdk v90: Query.users takes
* `filter: UserFilter` where UserFilter.name is a StringComparator with `eq`,
* and the User type exposes id (ID), name, email — all selected here.
*
* Fails loud on both no-match and multi-match (display names are not unique
* in Linear), listing the matched candidates like resolveStateIdByName lists
* available states.
*/
export async function resolveUserId(
apiKey: string,
name: string,
): Promise<string> {
const data = await linearRequest<{
users: { nodes: Array<{ id: string; name: string; email: string }> };
}>(
apiKey,
`query UserByName($name: String!) {
users(filter: { name: { eq: $name } }) { nodes { id name email } }
}`,
{ name },
);

const nodes = data.users.nodes;
if (nodes.length === 0) {
throw new AxiError(
`User "${name}" not found in your workspace`,
'VALIDATION_ERROR',
['Use the exact display name, or "me" for yourself'],
);
}
if (nodes.length > 1) {
const candidates = nodes
.map((u) => `${u.name} <${u.email}>`)
.join(', ');
throw new AxiError(
`User "${name}" matches ${nodes.length} users`,
'VALIDATION_ERROR',
[`Candidates: ${candidates}`],
);
}
return nodes[0]!.id;
}

async function resolveStateIdByName(
apiKey: string,
issueRef: string,
Expand Down
Loading