Skip to content
Merged
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: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ pnpm verify

```bash
pnpm --filter @wiseiodev/linear-cli dev --help
pnpm --filter @wiseiodev/linear-cli dev issues list --json
pnpm --filter @wiseiodev/linear-cli dev issues list --limit 10 --json
```

## Help Discovery
Expand Down Expand Up @@ -67,7 +67,12 @@ linear issues list --limit 10
linear issues branch ANN-123 --json
linear issues browse
linear issues create --input '{"title":"Investigate bug","teamId":"<team-id>"}'
linear issues create --state "Todo" --input '{"title":"Investigate bug","teamId":"<team-id>"}'
linear issues update ANN-123 --state "In Progress" # set state by name, no stateId needed
linear issues bulk-update --ids ANN-123,ANN-124 --state "In Progress" --dry-run --json
linear comments list --issue ANN-123 --json
linear prep ANN-123 --json
linear pr-ready ANN-123 --pr https://github.com/org/repo/pull/123 --json
linear projects list
linear documents list

Expand Down
3 changes: 2 additions & 1 deletion assets/skills/cycle-planning/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,11 @@ Checklist:
2. Group candidate issues by objective.
3. Flag blocked work and missing dependencies.
4. Produce a recommended cycle scope with rationale.
5. When committing the plan, move issues by state name (no stateId lookup needed); the name resolves against each issue's team:
5. When committing the plan, move issues by state name (no stateId lookup needed); the name resolves against each issue's team. Use bulk-update when changing more than one issue:

```bash
linear issues update <id> --state "Todo" --json
linear issues bulk-update --ids ENG-123,ENG-124 --state "Todo" --dry-run --json
```

An unknown state name lists the team's valid states. Discover names with `linear states list --json`.
8 changes: 7 additions & 1 deletion assets/skills/issue-triage/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,13 @@ description: Agent playbook for Linear issue triage and prioritization
When invoked, review issue details, assign priority, suggest owner, and propose next action.

Checklist:
1. Confirm issue scope and acceptance criteria.
1. Confirm issue scope, acceptance criteria, and discussion:

```bash
linear issues get <id> --json
linear comments list --issue <id> --json
```

2. Validate priority against impact and urgency.
3. Suggest assignee and timeline.
4. Propose labels, cycle placement, and dependencies.
Expand Down
39 changes: 34 additions & 5 deletions assets/skills/linear-cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,14 @@ Read the branch from `.data.branchName`.
## Common Issue Commands

```bash
linear issues list --json
linear issues list --limit 25 --json
linear issues get <id-or-identifier> --json
linear issues create --input '{"teamId":"<team-id>","title":"Investigate issue"}' --json
linear issues update <id-or-identifier> --input '{"priority":2}' --json
linear issues branch <id-or-identifier> --json
linear comments list --issue <id-or-identifier> --json
linear prep <id-or-identifier> --json
linear pr-ready <id-or-identifier> --pr <url> --json
```

For list workflows, use filters instead of broad scans when possible:
Expand All @@ -45,7 +48,7 @@ linear issues list --query "search text" --json
linear issues list --updated-after 2026-05-01 --json
linear issues list --created-after -P7D --json
linear issues list --parent ENG-123 --json
linear issues list --no-parent --json
linear issues list --team ENG --no-parent --json
```

Use pagination flags for bounded queries:
Expand All @@ -62,6 +65,8 @@ Issue creation requires `teamId` plus either `title` or a template:

```bash
linear issues create --input '{"teamId":"<team-id>","title":"New issue title"}' --json
linear issues create --state "Todo" --input '{"teamId":"<team-id>","title":"New issue title"}' --json
linear issues create --input '{"teamId":"<team-id>","title":"New issue title","state":"Todo"}' --json
linear issues create --template "Bug Report" --input '{"teamId":"<team-id>"}' --json
```

Expand All @@ -88,6 +93,8 @@ You do not need a `stateId` UUID to change an issue's state. Pass the state name
linear issues update ENG-123 --state "In Progress" --json
linear issues update ENG-123 --input '{"state":"In Progress"}' --json
linear issues update ENG-123 --input '{"stateName":"In Progress"}' --json
linear issues create --state "Todo" --input '{"teamId":"<team-id>","title":"New issue"}' --json
linear issues bulk-update --ids ENG-123,ENG-124 --state "In Progress" --dry-run --json
```

All three are equivalent. A raw `stateId` UUID still works and is used as-is:
Expand All @@ -114,8 +121,12 @@ These are the patterns agents reach for that do not work, with the command that
| Goal | Do not use | Use instead |
| --- | --- | --- |
| Set an issue's state | Hand-crafting or guessing a `stateId` UUID | `linear issues update <id> --state "In Progress"` (or `--input '{"state":"In Progress"}'`; the name resolves automatically) |
| Discover workflow states | `linear statuses`, `linear workflow-states`, `linear list-states` | `linear states list --json` |
| Read one issue | `linear issues view <id>` / `linear issues show <id>` | `linear issues get <id> --json` |
| Discover workflow states | Guessing `list-states` or parsing team payloads | `linear states list --json` (`statuses` and `workflow-states` are accepted aliases) |
| Read one issue | Treating an alias as the canonical docs target | `linear issues get <id> --json` (`view` and `show` also work) |
| Read issue discussion | `linear comments list` with a broad scan | `linear comments list --issue <id-or-identifier> --json` |
| List issues | `linear issues list --all --json` with no filters | Add `--mine`, `--team`, `--state`, `--query`, or `--limit` |
| Start work | Multiple commands to fetch context, branch, and set state | `linear prep <id> --json` |
| Mark PR ready | Separate update plus hand-written PR comment | `linear pr-ready <id> --pr <url> --json` |

## Batch Workflows

Expand All @@ -124,10 +135,28 @@ Use `bulk-update` for multi-issue updates. Start with `--dry-run` and inspect th
```bash
linear issues bulk-update --ids ENG-123,ENG-124 --input '{"priority":2}' --dry-run --json
linear issues bulk-update --ids ENG-123,ENG-124 --input '{"priority":2}' --json
linear issues bulk-update --ids ENG-123,ENG-124 --state "In Progress" --dry-run --json
linear issues bulk-update --input-file updates.json --dry-run --json
```

For per-issue input files, use an array where each object includes the target issue id or identifier plus update fields.
For per-issue input files, use an array where each object includes the target issue id or identifier plus update fields. A shared `--state` applies to every item and resolves per issue team; per-item `state` or `stateName` also works.

## Prep And PR Ready

Use `prep` when starting work. It fetches the issue, parent or project context, branch name, and moves the issue to the team's in-progress state:

```bash
linear prep ENG-123 --json
linear prep ENG-123 --state "Doing" --json
```

Use `pr-ready` when the branch is ready for review. It moves the issue to `In Review` unless you override the state, and comments only when you ask for one:

```bash
linear pr-ready ENG-123 --json
linear pr-ready ENG-123 --pr https://github.com/org/repo/pull/123 --json
linear pr-ready ENG-123 --comment "Ready for review" --json
```

## Linear Linking In GitHub

Expand Down
86 changes: 86 additions & 0 deletions packages/cli/src/commands/issue-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,18 @@ async function resolveStateRef(
);
}

async function resolveStateRefForTeam(
gateway: IssueStateGateway,
teamId: string,
stateRef: string,
): Promise<string> {
if (isWorkflowStateId(stateRef)) {
return stateRef;
}

return resolveStateId(teamId, stateRef, (id) => gateway.listWorkflowStatesForTeam(id));
}

/**
* Folds a state reference (from --state, or a `state`/`stateName` key) into a
* resolved `stateId`, scoped to the target issue's team. An explicit `stateId`
Expand Down Expand Up @@ -74,3 +86,77 @@ export async function normalizeIssueUpdateStatePayload(
const stateId = await resolveStateRef(gateway, issueId, stateRef);
return { ...rest, stateId };
}

export async function normalizeIssueCreateStatePayload(
gateway: IssueStateGateway,
payload: Record<string, unknown>,
stateFlag: string | undefined,
): Promise<Record<string, unknown>> {
const { state, stateName, ...rest } = payload;

if (typeof rest.stateId === "string" && rest.stateId.trim().length > 0) {
return rest;
}

const stateRef = pickStateRef(stateFlag, state, stateName);
if (stateRef === undefined) {
return rest;
}

if (typeof rest.teamId !== "string" || rest.teamId.trim().length === 0) {
throw new LinearCoreError(
"InvalidInput",
`Cannot resolve state "${stateRef}" for issue create because teamId is missing.`,
);
}

const stateId = await resolveStateRefForTeam(gateway, rest.teamId, stateRef);
return { ...rest, stateId };
}

export async function normalizeIssueBulkUpdateStatePayloads<T extends { id: string }>(
gateway: IssueStateGateway,
items: readonly (T & { payload: Record<string, unknown> })[],
stateFlag: string | undefined,
): Promise<Array<T & { payload: Record<string, unknown> }>> {
const stateCache = new Map<string, Promise<string>>();

const normalizeItem = async (
item: T & { payload: Record<string, unknown> },
): Promise<T & { payload: Record<string, unknown> }> => {
const { state, stateName, ...rest } = item.payload;

if (typeof rest.stateId === "string" && rest.stateId.trim().length > 0) {
return { ...item, payload: rest };
}

const stateRef = pickStateRef(stateFlag, state, stateName);
if (stateRef === undefined) {
return { ...item, payload: rest };
}

if (isWorkflowStateId(stateRef)) {
return { ...item, payload: { ...rest, stateId: stateRef } };
}

const issue = await gateway.getIssue(item.id);
if (!issue.teamId) {
throw new LinearCoreError(
"InvalidInput",
`Cannot resolve state "${stateRef}" because issue ${issue.identifier} has no team.`,
);
}

const cacheKey = `${issue.teamId}\n${stateRef.trim().toLowerCase()}`;
let stateIdPromise = stateCache.get(cacheKey);
if (!stateIdPromise) {
stateIdPromise = resolveStateRefForTeam(gateway, issue.teamId, stateRef);
stateCache.set(cacheKey, stateIdPromise);
}
const stateId = await stateIdPromise;

return { ...item, payload: { ...rest, stateId } };
};

return Promise.all(items.map((item) => normalizeItem(item)));
}
145 changes: 145 additions & 0 deletions packages/cli/src/commands/issue-workflow.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import type {
CommentRecord,
IssueRecord,
ProjectRecord,
ResolvableWorkflowState,
SdkCommentInput,
SdkIssueUpdateInput,
} from "@wiseiodev/linear-core";
import { LinearCoreError, resolveStateId } from "@wiseiodev/linear-core";

export interface IssueWorkflowGateway {
getIssue(id: string): Promise<IssueRecord>;
getProject(id: string): Promise<ProjectRecord>;
getIssueBranchName(id: string): Promise<{
readonly id: string;
readonly identifier: string;
readonly branchName: string;
readonly url: string;
}>;
listWorkflowStatesForTeam(teamId: string): Promise<readonly ResolvableWorkflowState[]>;
updateIssue(id: string, input: SdkIssueUpdateInput): Promise<IssueRecord>;
createComment(input: SdkCommentInput): Promise<CommentRecord>;
}

export interface WorkflowStateSummary {
readonly id: string;
readonly name: string;
readonly type: string;
}

export type IssueContext =
| { readonly type: "parent"; readonly issue: IssueRecord }
| { readonly type: "project"; readonly project: ProjectRecord }
| null;

export interface PrepResult {
readonly issue: IssueRecord;
readonly context: IssueContext;
readonly branchName: string;
readonly state: WorkflowStateSummary;
}

export interface PrReadyResult {
readonly issue: IssueRecord;
readonly state: WorkflowStateSummary;
readonly comment?: CommentRecord;
}

function requireTeam(issue: IssueRecord): string {
if (!issue.teamId) {
throw new LinearCoreError(
"InvalidInput",
`Cannot change state for ${issue.identifier} because it has no team.`,
);
}
return issue.teamId;
}

function findState(
states: readonly ResolvableWorkflowState[],
stateId: string,
): WorkflowStateSummary {
const state = states.find((candidate) => candidate.id === stateId);
if (!state) {
return { id: stateId, name: stateId, type: "unknown" };
}
return { id: state.id, name: state.name, type: state.type };
}

async function resolveStateSummary(
gateway: IssueWorkflowGateway,
teamId: string,
stateRef: string,
options: { readonly preferredType?: string } = {},
): Promise<WorkflowStateSummary> {
const states = await gateway.listWorkflowStatesForTeam(teamId);
const stateId = await resolveStateId(teamId, stateRef, async () => states, options);
return findState(states, stateId);
}

async function loadContext(
gateway: IssueWorkflowGateway,
issue: IssueRecord,
): Promise<IssueContext> {
if (issue.parentId) {
return { type: "parent", issue: await gateway.getIssue(issue.parentId) };
}
if (issue.projectId) {
return { type: "project", project: await gateway.getProject(issue.projectId) };
}
return null;
}

export async function runPrep(
gateway: IssueWorkflowGateway,
issueRef: string,
stateOverride?: string,
): Promise<PrepResult> {
const issue = await gateway.getIssue(issueRef);
const teamId = requireTeam(issue);
const state = await resolveStateSummary(gateway, teamId, stateOverride ?? "In Progress", {
...(stateOverride ? {} : { preferredType: "started" }),
});
const [context, branch] = await Promise.all([
loadContext(gateway, issue),
gateway.getIssueBranchName(issueRef),
]);
const updated = await gateway.updateIssue(issueRef, { stateId: state.id });

return {
issue: updated,
context,
branchName: branch.branchName,
state,
};
}

function buildPrComment(comment: string | undefined, pr: string | undefined): string | undefined {
if (!comment && !pr) {
return undefined;
}
if (comment && pr) {
return `${comment}\n\nPR: ${pr}`;
}
return comment ?? `PR: ${pr}`;
}

export async function runPrReady(
gateway: IssueWorkflowGateway,
issueRef: string,
options: { readonly state?: string; readonly comment?: string; readonly pr?: string },
): Promise<PrReadyResult> {
const issue = await gateway.getIssue(issueRef);
const teamId = requireTeam(issue);
const state = await resolveStateSummary(gateway, teamId, options.state ?? "In Review");
const updated = await gateway.updateIssue(issueRef, { stateId: state.id });
const body = buildPrComment(options.comment, options.pr);
const comment = body ? await gateway.createComment({ issueId: issue.id, body }) : undefined;

return {
issue: updated,
state,
...(comment ? { comment } : {}),
};
}
Loading
Loading