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
31 changes: 31 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,37 @@ linearis projects list
linearis labels list --team Backend
```

### Cycles

You can list and read cycles (sprints) for teams. The CLI exposes simple helpers,
but the GraphQL API provides a few cycle-related fields you can use to
identify relatives (active, next, previous).

```bash
# List cycles (optionally scope to a team)
linearis cycles list --team Backend --limit 10

# Show only the active cycle(s) for a team
linearis cycles list --team Backend --active

# Read a cycle by ID or by name (optionally scope name lookup with --team)
linearis cycles read "Sprint 2025-10" --team Backend
```

Ordering and getting "active +/- 1"
- The cycles returned by the API include fields `isActive`, `isNext`, `isPrevious`
and a numerical `number` field. The CLI will prefer an active/next/previous
candidate when resolving ambiguous cycle names.
- To get the active and the next cycle programmatically, do two calls locally:
1) `linearis cycles list --team Backend --active --limit 1` to get the active
cycle and its `number`.
2) `linearis cycles list --team Backend --limit 10` and pick the cycle with
`number = (active.number + 1)` or check `isNext` on the returned nodes.
- If multiple cycles match a name and none is marked active/next/previous, the
CLI will return an error listing the candidates so you can use a precise ID
or scope with `--team`.


### Advanced Usage

```bash
Expand Down
99 changes: 99 additions & 0 deletions dist/commands/cycles.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { createGraphQLService } from "../utils/graphql-service.js";
import { handleAsyncCommand, outputSuccess } from "../utils/output.js";
import { GET_CYCLES_QUERY, GET_CYCLE_BY_ID_QUERY, FIND_CYCLE_BY_NAME_SCOPED, FIND_CYCLE_BY_NAME_GLOBAL, } from "../queries/cycles.js";
import { isUuid } from "../utils/uuid.js";
export function setupCyclesCommands(program) {
const cycles = program.command("cycles").description("Cycle operations");
cycles.action(() => cycles.help());
cycles.command("list")
.description("List cycles")
.option("--team <team>", "team key, name, or ID")
.option("-l, --limit <number>", "limit results", "25")
.option("--around-active <n>", "return active +/- n cycles (requires --team)")
.option("--active", "only active cycles")
.action(handleAsyncCommand(async (options, command) => {
const graphQLService = await createGraphQLService(command.parent.parent.opts());
if (options.aroundActive && !options.team) {
throw new Error("--around-active requires --team to be specified");
}
if (options.aroundActive) {
const n = parseInt(options.aroundActive);
if (isNaN(n) || n < 0)
throw new Error("--around-active requires a non-negative integer");
const activeRes = await graphQLService.rawRequest(GET_CYCLES_QUERY, {
first: 1,
teamKey: options.team,
isActive: true,
});
const active = activeRes.cycles?.nodes?.[0];
if (!active) {
throw new Error(`No active cycle found for team "${options.team}"`);
}
const activeNumber = Number(active.number || 0);
const min = activeNumber - n;
const max = activeNumber + n;
const fetchRes = await graphQLService.rawRequest(GET_CYCLES_QUERY, {
first: Math.max(parseInt(options.limit), 100),
teamKey: options.team,
});
const nodes = fetchRes.cycles?.nodes || [];
const filtered = nodes
.filter((c) => typeof c.number === "number" && c.number >= min && c.number <= max)
.sort((a, b) => a.number - b.number);
outputSuccess(filtered);
return;
}
const vars = { first: parseInt(options.limit) };
if (options.team)
vars.teamKey = options.team;
if (options.active)
vars.isActive = true;
const result = await graphQLService.rawRequest(GET_CYCLES_QUERY, vars);
outputSuccess(result.cycles?.nodes || []);
}));
cycles.command("read <cycleIdOrName>")
.description("Get cycle details including issues. Accepts UUID or cycle name (optionally scoped by --team)")
.option("--team <team>", "team key, name, or ID to scope name lookup")
.option("--issues-first <n>", "how many issues to fetch (default 50)", "50")
.action(handleAsyncCommand(async (cycleIdOrName, options, command) => {
const graphQLService = await createGraphQLService(command.parent.parent.opts());
let cycleId = cycleIdOrName;
if (!isUuid(cycleIdOrName)) {
let findRes;
let nodes = [];
if (options.team) {
findRes = await graphQLService.rawRequest(FIND_CYCLE_BY_NAME_SCOPED, {
name: cycleIdOrName,
teamKey: options.team,
});
nodes = findRes.cycles?.nodes || [];
}
if (!nodes.length) {
findRes = await graphQLService.rawRequest(FIND_CYCLE_BY_NAME_GLOBAL, {
name: cycleIdOrName,
});
nodes = findRes.cycles?.nodes || [];
}
if (!nodes.length) {
throw new Error(`Cycle with name "${cycleIdOrName}" not found`);
}
let chosen = nodes.find((n) => n.isActive);
if (!chosen)
chosen = nodes.find((n) => n.isNext);
if (!chosen)
chosen = nodes.find((n) => n.isPrevious);
if (!chosen && nodes.length === 1)
chosen = nodes[0];
if (!chosen) {
const list = nodes.map((n) => `${n.id} (${n.team?.key || "?"} / #${n.number} / ${n.startsAt})`).join("; ");
throw new Error(`Ambiguous cycle name "${cycleIdOrName}" — multiple matches found: ${list}. Please use an ID or scope with --team.`);
}
cycleId = chosen.id;
}
const result = await graphQLService.rawRequest(GET_CYCLE_BY_ID_QUERY, {
id: cycleId,
issuesFirst: parseInt(options.issuesFirst || "50"),
});
outputSuccess(result.cycle);
}));
}
17 changes: 17 additions & 0 deletions dist/commands/issues.js
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ export function setupIssuesCommands(program) {
.option("--team <team>", "team key, name, or ID (required if not specified)")
.option("--labels <labels>", "labels (comma-separated names or IDs)")
.option("--milestone <milestone>", "milestone name or ID (requires --project)")
.option("--cycle <cycle>", "cycle name or ID (requires --team)")
.option("--status <status>", "status name or ID")
.option("--parent-ticket <parentId>", "parent issue ID or identifier")
.action(handleAsyncCommand(async (title, options, command) => {
Expand All @@ -76,6 +77,7 @@ export function setupIssuesCommands(program) {
labelIds,
parentId: options.parentTicket,
milestoneId: options.milestone,
cycleId: options.cycle,
};
const result = await issuesService.createIssue(createArgs);
outputSuccess(result);
Expand Down Expand Up @@ -108,10 +110,22 @@ export function setupIssuesCommands(program) {
.optionsGroup("Parent ticket-related options:")
.option("--parent-ticket <parentId>", "set parent issue ID or identifier")
.option("--clear-parent-ticket", "clear existing parent relationship")
.optionsGroup("Milestone-related options:")
.option("--milestone <milestone>", "set milestone (can use name or ID, will try to resolve within project context first)")
.option("--clear-milestone", "clear existing milestone assignment")
.optionsGroup("Cycle-related options:")
.option("--cycle <cycle>", "set cycle (can use name or ID, will try to resolve within team context first)")
.option("--clear-cycle", "clear existing cycle assignment")
.action(handleAsyncCommand(async (issueId, options, command) => {
if (options.parentTicket && options.clearParentTicket) {
throw new Error("Cannot use --parent-ticket and --clear-parent-ticket together");
}
if (options.milestone && options.clearMilestone) {
throw new Error("Cannot use --milestone and --clear-milestone together");
}
if (options.cycle && options.clearCycle) {
throw new Error("Cannot use --cycle and --clear-cycle together");
}
if (options.labelBy && !options.labels) {
throw new Error("--label-by requires --labels to be specified");
}
Expand Down Expand Up @@ -149,6 +163,9 @@ export function setupIssuesCommands(program) {
labelIds,
parentId: options.parentTicket ||
(options.clearParentTicket ? null : undefined),
milestoneId: options.milestone ||
(options.clearMilestone ? null : undefined),
cycleId: options.cycle || (options.clearCycle ? null : undefined),
};
const labelMode = options.labelBy || "adding";
const result = await issuesService.updateIssue(updateArgs, labelMode);
Expand Down
2 changes: 2 additions & 0 deletions dist/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { setupCommentsCommands } from "./commands/comments.js";
import { setupIssuesCommands } from "./commands/issues.js";
import { setupLabelsCommands } from "./commands/labels.js";
import { setupProjectsCommands } from "./commands/projects.js";
import { setupCyclesCommands } from "./commands/cycles.js";
import { outputUsageInfo } from "./utils/usage.js";
program
.name("linearis")
Expand All @@ -17,6 +18,7 @@ setupIssuesCommands(program);
setupCommentsCommands(program);
setupLabelsCommands(program);
setupProjectsCommands(program);
setupCyclesCommands(program);
program.command("usage")
.description("show usage info for *all* tools")
.action(() => outputUsageInfo(program));
Expand Down
58 changes: 58 additions & 0 deletions dist/queries/cycles.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { COMPLETE_ISSUE_FRAGMENT } from "./common.js";
export const GET_CYCLES_QUERY = `
query GetCycles($first: Int!, $teamKey: String, $isActive: Boolean) {
cycles(first: $first, filter: { and: [
{ team: { key: { eq: $teamKey } } }
{ isActive: { eq: $isActive } }
] }) {
nodes {
id
name
number
startsAt
endsAt
isActive
progress
issueCountHistory
issues(first: 100) {
nodes {
${COMPLETE_ISSUE_FRAGMENT}
}
}
}
}
}
`;
export const GET_CYCLE_BY_ID_QUERY = `
query GetCycle($id: String!, $issuesFirst: Int) {
cycle(id: $id) {
id
name
number
startsAt
endsAt
isActive
progress
issueCountHistory
issues(first: $issuesFirst) {
nodes {
${COMPLETE_ISSUE_FRAGMENT}
}
}
}
}
`;
export const FIND_CYCLE_BY_NAME_SCOPED = `
query FindCycleByNameScoped($name: String!, $teamKey: String) {
cycles(filter: { and: [ { name: { eq: $name } }, { team: { key: { eq: $teamKey } } } ] }, first: 10) {
nodes { id name number startsAt isActive isNext isPrevious team { id key name } }
}
}
`;
export const FIND_CYCLE_BY_NAME_GLOBAL = `
query FindCycleByNameGlobal($name: String!) {
cycles(filter: { name: { eq: $name } }, first: 10) {
nodes { id name number startsAt isActive isNext isPrevious team { id key name } }
}
}
`;
28 changes: 28 additions & 0 deletions dist/queries/issues.js
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,9 @@ export const BATCH_RESOLVE_FOR_UPDATE_QUERY = `
$projectName: String
$teamKey: String
$issueNumber: Float
$milestoneName: String
$cycleName: String
$issueTeamId: String
) {
# Resolve labels if provided
labels: issueLabels(filter: { name: { in: $labelNames } }) {
Expand All @@ -133,6 +136,23 @@ export const BATCH_RESOLVE_FOR_UPDATE_QUERY = `

# Resolve project if provided
projects(filter: { name: { eq: $projectName } }, first: 1) {
nodes {
id
name
milestones {
nodes {
id
name
}
}
}
}

# Resolve milestone if provided (standalone query in case no project context)
milestones: projectMilestones(
filter: { name: { eq: $milestoneName } }
first: 1
) {
nodes {
id
name
Expand Down Expand Up @@ -187,6 +207,7 @@ export const BATCH_RESOLVE_FOR_CREATE_QUERY = `
$teamKey: String
$teamName: String
$projectName: String
$cycleName: String
$labelNames: [String!]
$parentTeamKey: String
$parentIssueNumber: Float
Expand All @@ -213,6 +234,10 @@ export const BATCH_RESOLVE_FOR_CREATE_QUERY = `
nodes {
id
name
milestones {
nodes { id name }
}
# Projects don't own cycles directly, but include teams for context if needed
}
}

Expand Down Expand Up @@ -250,5 +275,8 @@ export const BATCH_RESOLVE_FOR_CREATE_QUERY = `
identifier
}
}

# Resolve cycles by name (team-scoped lookup is preferred but we also provide global fallback)

}
`;
Loading