From c09482e450b1f5a845339a26976aa8c901b3bc7f Mon Sep 17 00:00:00 2001 From: Peter Schilling Date: Tue, 21 Jul 2026 09:31:35 -0700 Subject: [PATCH] Expose cycles in issue query, mine, and view Issue tables gain a compact CYC column (only when a listed team has cycles enabled): 'now' for the active cycle, +1/-1 from the API's next/previous flags, +N/-N anchored on team.activeCycle, and #N when no anchor exists (cooldown or pre-first-cycle). issue view annotates its Cycle meta part with the same token, and issue JSON output now carries the cycle flags and team anchor in raw GraphQL shape. The shared cycle resolver understands now/next/previous and signed offsets, so --cycle on query/mine/create/update and cycle view all speak the relative vocabulary. issue update gains --clear-cycle (explicit cycleId: null, mirroring --unassign). Also fixes issue query --search silently dropping the --cycle filter. --- skills/linear-cli/references/api.md | 11 +- skills/linear-cli/references/document.md | 1 + skills/linear-cli/references/issue.md | 206 ++++--- skills/linear-cli/references/milestone.md | 10 +- skills/linear-cli/references/project.md | 60 +- src/commands/issue/issue-create.ts | 2 +- src/commands/issue/issue-mine.ts | 33 +- src/commands/issue/issue-query.ts | 30 +- src/commands/issue/issue-update.ts | 23 +- src/commands/issue/issue-view.ts | 20 +- src/utils/display.ts | 78 +++ src/utils/linear.ts | 175 +++++- test/commands/cycle/cycle-view.test.ts | 41 +- .../__snapshots__/issue-create.test.ts.snap | 37 +- .../__snapshots__/issue-list.test.ts.snap | 35 +- .../__snapshots__/issue-mine.test.ts.snap | 35 +- .../__snapshots__/issue-query.test.ts.snap | 11 +- .../__snapshots__/issue-update.test.ts.snap | 48 +- .../__snapshots__/issue-view.test.ts.snap | 14 +- test/commands/issue/issue-create.test.ts | 23 +- test/commands/issue/issue-list.test.ts | 7 + test/commands/issue/issue-mine.test.ts | 168 ++++++ test/commands/issue/issue-query.test.ts | 147 +++++ test/commands/issue/issue-update.test.ts | 538 +++++++++++++++++- test/commands/issue/issue-view.test.ts | 77 +++ test/utils/display.test.ts | 103 ++++ test/utils/linear.test.ts | 4 + 27 files changed, 1700 insertions(+), 237 deletions(-) create mode 100644 test/utils/display.test.ts diff --git a/skills/linear-cli/references/api.md b/skills/linear-cli/references/api.md index 2c492b69..3b308aff 100644 --- a/skills/linear-cli/references/api.md +++ b/skills/linear-cli/references/api.md @@ -13,10 +13,11 @@ Description: Options: - -h, --help - Show this help. - --workspace - Target workspace (uses credentials) - --variable - Variable in key=value format (coerces booleans, numbers, null; @file reads from path) - --variables-json - JSON object of variables (merged with --variable, which takes precedence) - --paginate - Auto-paginate a single connection field using cursor pagination + -h, --help - Show this help. + --workspace - Target workspace (uses credentials) + --variable - Variable in key=value format (coerces booleans, numbers, null; @file reads from + path) + --variables-json - JSON object of variables (merged with --variable, which takes precedence) + --paginate - Auto-paginate a single connection field using cursor pagination --silent - Suppress response output (exit code still reflects errors) ``` diff --git a/skills/linear-cli/references/document.md b/skills/linear-cli/references/document.md index 8cabc3f3..3d026a24 100644 --- a/skills/linear-cli/references/document.md +++ b/skills/linear-cli/references/document.md @@ -112,6 +112,7 @@ Options: -c, --content - New markdown content (inline) -f, --content-file - Read new content from file --icon - New icon (emoji) + --project - Attach to project (UUID, slug ID, or name) -e, --edit - Open current content in $EDITOR for editing --force - Update content even when document comments may lose inline anchors ``` diff --git a/skills/linear-cli/references/issue.md b/skills/linear-cli/references/issue.md index 0da0f7a5..67fb5cfa 100644 --- a/skills/linear-cli/references/issue.md +++ b/skills/linear-cli/references/issue.md @@ -75,10 +75,11 @@ Description: Options: - -h, --help - Show this help. - --workspace - Target workspace (uses credentials) - -j, --json - Output as JSON - --status - Filter by session status (Values: "pending", "active", "complete", "awaitingInput", "error", "stale") + -h, --help - Show this help. + --workspace - Target workspace (uses credentials) + -j, --json - Output as JSON + --status - Filter by session status (Values: "pending", "active", "complete", "awaitingInput", + "error", "stale") ``` ##### view @@ -110,11 +111,12 @@ Description: Options: - -h, --help - Show this help. - --workspace - Target workspace (uses credentials) - -t, --title - Custom title for the attachment - -c, --comment <body> - Add a comment body linked to the attachment - --public - Upload images to a public, unauthenticated URL (default: private, workspace-members only) + -h, --help - Show this help. + --workspace <slug> - Target workspace (uses credentials) + -t, --title <title> - Custom title for the attachment + -c, --comment <body> - Add a comment body linked to the attachment + --public - Upload images to a public, unauthenticated URL (default: private, + workspace-members only) ``` ### comment @@ -154,13 +156,14 @@ Description: Options: - -h, --help - Show this help. - --workspace <slug> - Target workspace (uses credentials) - -b, --body <text> - Comment body text - --body-file <path> - Read comment body from a file (preferred for markdown content) - -p, --parent <id> - Parent comment ID for replies - -a, --attach <filepath> - Attach a file to the comment (can be used multiple times) - --public - Upload attached images to a public, unauthenticated URL (default: private, workspace-members only) + -h, --help - Show this help. + --workspace <slug> - Target workspace (uses credentials) + -b, --body <text> - Comment body text + --body-file <path> - Read comment body from a file (preferred for markdown content) + -p, --parent <id> - Parent comment ID for replies + -a, --attach <filepath> - Attach a file to the comment (can be used multiple times) + --public - Upload attached images to a public, unauthenticated URL (default: private, + workspace-members only) ``` ##### delete @@ -241,24 +244,25 @@ Description: Options: - -h, --help - Show this help. - --workspace <slug> - Target workspace (uses credentials) - --start - Start the issue after creation - -a, --assignee <assignee> - Assign the issue to 'self' or someone (by username or name) - --due-date <dueDate> - Due date of the issue - --parent <parent> - Parent issue (if any) as a team_number code - -p, --priority <priority> - Priority of the issue (1-4, descending priority) - --estimate <estimate> - Points estimate of the issue - -d, --description <description> - Description of the issue - --description-file <path> - Read description from a file (preferred for markdown content) - -l, --label <label> - Issue label associated with the issue. May be repeated. - --team <team> - Team associated with the issue (if not your default team) - --project <project> - Project for the issue (UUID, slug ID, or name) - -s, --state <state> - Workflow state for the issue (by name or type) - --milestone <milestone> - Project milestone (UUID, or name when --project is set) - --cycle <cycle> - Cycle name, number, or 'active' - --no-use-default-template - Do not use default template for the issue - --no-interactive - Disable interactive prompts + -h, --help - Show this help. + --workspace <slug> - Target workspace (uses credentials) + --start - Start the issue after creation + -a, --assignee <assignee> - Assign the issue to 'self' or someone (by username or name) + --due-date <dueDate> - Due date of the issue + --parent <parent> - Parent issue (if any) as a team_number code + -p, --priority <priority> - Priority of the issue (1-4, descending priority) + --estimate <estimate> - Points estimate of the issue + -d, --description <description> - Description of the issue + --description-file <path> - Read description from a file (preferred for markdown content) + -l, --label <label> - Issue label associated with the issue. May be repeated. + --team <team> - Team associated with the issue (if not your default team) + --project <project> - Project for the issue (UUID, slug ID, or name) + -s, --state <state> - Workflow state for the issue (by name or type) + --milestone <milestone> - Project milestone (UUID, or name when --project is set) + --cycle <cycle> - Cycle name, number, 'active'/'now', 'next', 'previous', or a relative offset + like +1 (use --cycle=-1 for negatives) + --no-use-default-template - Do not use default template for the issue + --no-interactive - Disable interactive prompts -t, --title <title> - Title of the issue ``` @@ -355,31 +359,24 @@ Description: Options: - -h, --help - Show this help. - --workspace <slug> - Target workspace (uses credentials) - -s, --state <state> - Filter by issue state (can be repeated for (Default: [ "unstarted" ], Values: "triage", "backlog", - multiple states) "unstarted", "started", "completed", "canceled") - --all-states - Show issues from all states - --sort <sort> - Sort order (can also be set via (Values: "manual", "priority") - LINEAR_ISSUE_SORT) - --team <team> - Team to list issues for (if not your default - team) - --project <project> - Filter by project (UUID, slug ID, or name) - --project-label <projectLabel> - Filter by project label name (shows issues from - all projects with this label) - --cycle <cycle> - Filter by cycle name, number, or 'active' - --milestone <milestone> - Filter by project milestone (UUID, or name when - --project is set) - -l, --label <label> - Filter by label name (can be repeated for - multiple labels) - --limit <limit> - Maximum number of issues to fetch (default: 50, (Default: 50) - use 0 for unlimited) - --created-after <date> - Filter issues created after this date (ISO 8601 - or YYYY-MM-DD) - --updated-after <date> - Filter issues updated after this date (ISO 8601 - or YYYY-MM-DD) - -w, --web - Open in web browser - -a, --app - Open in Linear.app + -h, --help - Show this help. + --workspace <slug> - Target workspace (uses credentials) + -s, --state <state> - Filter by issue state (can be repeated for multiple states) (Default: [ "unstarted" ], Values: "triage", "backlog", + "unstarted", "started", "completed", "canceled") + --all-states - Show issues from all states + --sort <sort> - Sort order (can also be set via LINEAR_ISSUE_SORT) (Values: "manual", "priority") + --team <team> - Team to list issues for (if not your default team) + --project <project> - Filter by project (UUID, slug ID, or name) + --project-label <projectLabel> - Filter by project label name (shows issues from all projects with this label) + --cycle <cycle> - Filter by cycle name, number, 'active'/'now', 'next', 'previous', or a relative + offset like +1 + --milestone <milestone> - Filter by project milestone (UUID, or name when --project is set) + -l, --label <label> - Filter by label name (can be repeated for multiple labels) + --limit <limit> - Maximum number of issues to fetch (default: 50, use 0 for unlimited) (Default: 50) + --created-after <date> - Filter issues created after this date (ISO 8601 or YYYY-MM-DD) + --updated-after <date> - Filter issues updated after this date (ISO 8601 or YYYY-MM-DD) + -w, --web - Open in web browser + -a, --app - Open in Linear.app --no-pager - Disable automatic paging for long output ``` @@ -418,37 +415,30 @@ Description: Options: - -h, --help - Show this help. - --workspace <slug> - Target workspace (uses credentials) - --search <term> - Full-text search term - --search-comments - Also search inside issue comments (requires --search) - --team <team> - Filter by team key (can be repeated for multiple - teams) - --all-teams - Query across all teams - -s, --state <state> - Filter by issue state (can be repeated for multiple (Values: "triage", "backlog", "unstarted", "started", - states) "completed", "canceled") - --all-states - Show issues from all states (this is the default) - --assignee <assignee> - Filter by assignee (username) - -A, --all-assignees - Show issues for all assignees (this is the default) - -U, --unassigned - Show only unassigned issues - --sort <sort> - Sort order: manual or priority (default: priority, (Values: "manual", "priority") - not available with --search) - --project <project> - Filter by project (UUID, slug ID, or name) - --project-label <projectLabel> - Filter by project label name (shows issues from all - projects with this label) - --cycle <cycle> - Filter by cycle name, number, or 'active' - --milestone <milestone> - Filter by project milestone (UUID, or name when - --project is set) - -l, --label <label> - Filter by label name (can be repeated for multiple - labels) - --limit <limit> - Maximum number of issues to fetch (default: 50, use 0 (Default: 50) - for unlimited) - --created-after <date> - Filter issues created after this date (ISO 8601 or - YYYY-MM-DD) - --updated-after <date> - Filter issues updated after this date (ISO 8601 or - YYYY-MM-DD) - --include-archived - Include archived issues - -j, --json - Output results as JSON + -h, --help - Show this help. + --workspace <slug> - Target workspace (uses credentials) + --search <term> - Full-text search term + --search-comments - Also search inside issue comments (requires --search) + --team <team> - Filter by team key (can be repeated for multiple teams) + --all-teams - Query across all teams + -s, --state <state> - Filter by issue state (can be repeated for multiple states) (Values: "triage", "backlog", "unstarted", "started", + "completed", "canceled") + --all-states - Show issues from all states (this is the default) + --assignee <assignee> - Filter by assignee (username) + -A, --all-assignees - Show issues for all assignees (this is the default) + -U, --unassigned - Show only unassigned issues + --sort <sort> - Sort order: manual or priority (default: priority, not available with --search) (Values: "manual", "priority") + --project <project> - Filter by project (UUID, slug ID, or name) + --project-label <projectLabel> - Filter by project label name (shows issues from all projects with this label) + --cycle <cycle> - Filter by cycle name, number, 'active'/'now', 'next', 'previous', or a relative + offset like +1 + --milestone <milestone> - Filter by project milestone (UUID, or name when --project is set) + -l, --label <label> - Filter by label name (can be repeated for multiple labels) + --limit <limit> - Maximum number of issues to fetch (default: 50, use 0 for unlimited) (Default: 50) + --created-after <date> - Filter issues created after this date (ISO 8601 or YYYY-MM-DD) + --updated-after <date> - Filter issues updated after this date (ISO 8601 or YYYY-MM-DD) + --include-archived - Include archived issues + -j, --json - Output results as JSON --no-pager - Disable automatic paging for long output ``` @@ -580,22 +570,26 @@ Description: Options: - -h, --help - Show this help. - --workspace <slug> - Target workspace (uses credentials) - -a, --assignee <assignee> - Assign the issue to 'self' or someone (by username or name) - --unassign - Clear the issue's assignee (cannot be combined with --assignee) - --due-date <dueDate> - Due date of the issue - --parent <parent> - Parent issue (if any) as a team_number code - -p, --priority <priority> - Priority of the issue (1-4, descending priority) - --estimate <estimate> - Points estimate of the issue - -d, --description <description> - Description of the issue - --description-file <path> - Read description from a file (preferred for markdown content) - -l, --label <label> - Issue label associated with the issue. May be repeated. - --team <team> - Team associated with the issue (if not your default team) - --project <project> - Project to assign the issue to (UUID, slug ID, or name) - -s, --state <state> - Workflow state for the issue (by name or type) - --milestone <milestone> - Project milestone (UUID, or name when --project is set or the issue already has a project) - --cycle <cycle> - Cycle name, number, or 'active' + -h, --help - Show this help. + --workspace <slug> - Target workspace (uses credentials) + -a, --assignee <assignee> - Assign the issue to 'self' or someone (by username or name) + --unassign - Clear the issue's assignee (cannot be combined with --assignee) + --due-date <dueDate> - Due date of the issue + --parent <parent> - Parent issue (if any) as a team_number code + -p, --priority <priority> - Priority of the issue (1-4, descending priority) + --estimate <estimate> - Points estimate of the issue + -d, --description <description> - Description of the issue + --description-file <path> - Read description from a file (preferred for markdown content) + -l, --label <label> - Issue label associated with the issue. May be repeated. + --team <team> - Team associated with the issue (if not your default team) + --project <project> - Project to assign the issue to (UUID, slug ID, or name) + -s, --state <state> - Workflow state for the issue (by name or type) + --milestone <milestone> - Project milestone (UUID, or name when --project is set or the issue already has + a project) + --cycle <cycle> - Cycle name, number, 'active'/'now', 'next', 'previous', or a relative offset + like +1 (use --cycle=-1 for negatives). Use --clear-cycle to remove the issue + from its cycle + --clear-cycle - Remove the issue from its cycle -t, --title <title> - Title of the issue ``` diff --git a/skills/linear-cli/references/milestone.md b/skills/linear-cli/references/milestone.md index 1718b281..8ea79842 100644 --- a/skills/linear-cli/references/milestone.md +++ b/skills/linear-cli/references/milestone.md @@ -18,11 +18,11 @@ Options: Commands: - list - List milestones for a project - view, v <milestone> - View milestone details. By default lists the first 10 attached issues from the first page of 50; use --all to paginate the - full set. - create - Create a new project milestone - update <id> - Update an existing project milestone + list - List milestones for a project + view, v <milestone> - View milestone details. By default lists the first 10 attached issues from the + first page of 50; use --all to paginate the full set. + create - Create a new project milestone + update <id> - Update an existing project milestone delete <id> - Delete a project milestone ``` diff --git a/skills/linear-cli/references/project.md b/skills/linear-cli/references/project.md index 7ac54ec6..9644da7c 100644 --- a/skills/linear-cli/references/project.md +++ b/skills/linear-cli/references/project.md @@ -40,25 +40,26 @@ Description: Options: - -h, --help - Show this help. - --workspace <slug> - Target workspace (uses credentials) - -n, --name <name> - Project name (required) - -d, --description <description> - Project description (max 255 characters, enforced by Linear's API) - -f, --description-file <path> - Read project description from file (still subject to the 255-character API limit) - --content <markdown> - Project overview markdown - --content-file <path> - Read project overview markdown from a file - -t, --team <team> - Team key (required, can be repeated for multiple teams) - -l, --lead <lead> - Project lead (username, email, or @me) - -s, --status <status> - Project status (planned, started, paused, completed, canceled, backlog) - --start-date <startDate> - Start date (YYYY-MM-DD) - --target-date <targetDate> - Target completion date (YYYY-MM-DD) - --priority <priority> - Project priority (none, urgent, high, medium, low) - --label <label> - Project label associated with the project. May be repeated. - --member <user> - Project member (username, email, display name, or @me). May be repeated. - --icon <icon> - Project icon - --color <color> - Project color as a HEX string - --initiative <initiative> - Add to initiative immediately (ID, slug, or name) - -i, --interactive - Interactive mode (default if no flags provided) + -h, --help - Show this help. + --workspace <slug> - Target workspace (uses credentials) + -n, --name <name> - Project name (required) + -d, --description <description> - Project description (max 255 characters, enforced by Linear's API) + -f, --description-file <path> - Read project description from file (still subject to the 255-character API + limit) + --content <markdown> - Project overview markdown + --content-file <path> - Read project overview markdown from a file + -t, --team <team> - Team key (required, can be repeated for multiple teams) + -l, --lead <lead> - Project lead (username, email, or @me) + -s, --status <status> - Project status (planned, started, paused, completed, canceled, backlog) + --start-date <startDate> - Start date (YYYY-MM-DD) + --target-date <targetDate> - Target completion date (YYYY-MM-DD) + --priority <priority> - Project priority (none, urgent, high, medium, low) + --label <label> - Project label associated with the project. May be repeated. + --member <user> - Project member (username, email, display name, or @me). May be repeated. + --icon <icon> - Project icon + --color <color> - Project color as a HEX string + --initiative <initiative> - Add to initiative immediately (ID, slug, or name) + -i, --interactive - Interactive mode (default if no flags provided) -j, --json - Output created project as JSON ``` @@ -116,16 +117,17 @@ Description: Options: - -h, --help - Show this help. - --workspace <slug> - Target workspace (uses credentials) - -n, --name <name> - Project name - -d, --description <description> - Project description (max 255 characters, enforced by Linear's API) - -f, --description-file <path> - Read project description from file (still subject to the 255-character API limit) - -s, --status <status> - Status (planned, started, paused, completed, canceled, backlog) - -l, --lead <lead> - Project lead (username, email, or @me) - --start-date <startDate> - Start date (YYYY-MM-DD) - --target-date <targetDate> - Target date (YYYY-MM-DD) - -t, --team <team> - Team key (can be repeated for multiple teams) + -h, --help - Show this help. + --workspace <slug> - Target workspace (uses credentials) + -n, --name <name> - Project name + -d, --description <description> - Project description (max 255 characters, enforced by Linear's API) + -f, --description-file <path> - Read project description from file (still subject to the 255-character API + limit) + -s, --status <status> - Status (planned, started, paused, completed, canceled, backlog) + -l, --lead <lead> - Project lead (username, email, or @me) + --start-date <startDate> - Start date (YYYY-MM-DD) + --target-date <targetDate> - Target date (YYYY-MM-DD) + -t, --team <team> - Team key (can be repeated for multiple teams) --label <label> - Replace the project's labels. May be repeated to set multiple labels. ``` diff --git a/src/commands/issue/issue-create.ts b/src/commands/issue/issue-create.ts index 74292383..5b6ba028 100644 --- a/src/commands/issue/issue-create.ts +++ b/src/commands/issue/issue-create.ts @@ -648,7 +648,7 @@ export const createCommand = new Command() ) .option( "--cycle <cycle:string>", - "Cycle name, number, or 'active'", + "Cycle name, number, 'active'/'now', 'next', 'previous', or a relative offset like +1 (use --cycle=-1 for negatives)", ) .option( "--no-use-default-template", diff --git a/src/commands/issue/issue-mine.ts b/src/commands/issue/issue-mine.ts index 06a5dfeb..dcb64059 100644 --- a/src/commands/issue/issue-mine.ts +++ b/src/commands/issue/issue-mine.ts @@ -3,6 +3,8 @@ import { unicodeWidth } from "@std/cli" import { rgb24 } from "@std/fmt/colors" import { getOption } from "../../config.ts" import { + colorCycleShort, + formatCycleShort, getPriorityDisplay, getTimeAgo, padDisplay, @@ -78,7 +80,7 @@ export const mineCommand = new Command() ) .option( "--cycle <cycle:string>", - "Filter by cycle name, number, or 'active'", + "Filter by cycle name, number, 'active'/'now', 'next', 'previous', or a relative offset like +1", ) .option( "--milestone <milestone:string>", @@ -312,6 +314,15 @@ export const mineCommand = new Command() ), ) const ESTIMATE_WIDTH = 1 // fixed width for estimate + const showCycleColumn = issues.some((issue) => + issue.cycle != null || issue.team.cyclesEnabled + ) + const cycleShorts = issues.map((issue) => + formatCycleShort(issue.cycle, issue.team.activeCycle?.number) + ) + const CYCLE_WIDTH = showCycleColumn + ? Math.max(3, ...cycleShorts.map((c) => unicodeWidth(c.text))) + : 0 const STATE_WIDTH = Math.min( 20, // maximum width for state Math.max( @@ -334,12 +345,13 @@ export const mineCommand = new Command() identifier: string title: string labels: string + cycleCell: string state: string timeAgo: string estimate: number | null | undefined } - const tableData: Array<TableRow> = issues.map((issue) => { + const tableData: Array<TableRow> = issues.map((issue, index) => { let labels: string if (issue.labels.nodes.length === 0) { labels = " ".repeat(LABEL_WIDTH) @@ -404,12 +416,19 @@ export const mineCommand = new Command() const blockedStr = isIssueBlocked(issue) ? warning("⊘") : " " + const cycleShort = cycleShorts[index] + const cycleCell = colorCycleShort(cycleShort) + + " ".repeat( + Math.max(0, CYCLE_WIDTH - unicodeWidth(cycleShort.text)), + ) + return { priorityStr, blockedStr, identifier: issue.identifier, title: issue.title, labels, + cycleCell, state: statePadded, timeAgo, estimate: issue.estimate, @@ -418,7 +437,8 @@ export const mineCommand = new Command() const fixed = PRIORITY_WIDTH + BLOCKED_WIDTH + ID_WIDTH + UPDATED_WIDTH + SPACE_WIDTH + - LABEL_WIDTH + ESTIMATE_WIDTH + STATE_WIDTH + SPACE_WIDTH + LABEL_WIDTH + ESTIMATE_WIDTH + STATE_WIDTH + SPACE_WIDTH + + (showCycleColumn ? CYCLE_WIDTH + 1 : 0) const PADDING = 1 const maxTitleWidth = Math.max( ...tableData.map((row) => unicodeWidth(row.title)), @@ -432,6 +452,7 @@ export const mineCommand = new Command() padDisplay("LABELS", LABEL_WIDTH), padDisplay("B", BLOCKED_WIDTH), padDisplay("E", ESTIMATE_WIDTH), + ...(showCycleColumn ? [padDisplay("CYC", CYCLE_WIDTH)] : []), padDisplay("STATE", STATE_WIDTH), padDisplay(updatedHeader, UPDATED_WIDTH), ] @@ -449,6 +470,7 @@ export const mineCommand = new Command() identifier, title, labels, + cycleCell, state, timeAgo, estimate, @@ -458,11 +480,14 @@ export const mineCommand = new Command() titleWidth, ) + const cycleSegment = showCycleColumn ? `${cycleCell} ` : "" const issueLine = `${padDisplay(priorityStr, PRIORITY_WIDTH)} ${ padDisplay(identifier, ID_WIDTH) } ${truncTitle} ${labels} ${padDisplay(blockedStr, BLOCKED_WIDTH)} ${ padDisplay(estimate?.toString() || "-", ESTIMATE_WIDTH) - } ${state} ${muted(padDisplay(timeAgo, UPDATED_WIDTH))}` + } ${cycleSegment}${state} ${ + muted(padDisplay(timeAgo, UPDATED_WIDTH)) + }` outputLines.push(issueLine) } diff --git a/src/commands/issue/issue-query.ts b/src/commands/issue/issue-query.ts index 108164a9..eb5c6774 100644 --- a/src/commands/issue/issue-query.ts +++ b/src/commands/issue/issue-query.ts @@ -3,6 +3,9 @@ import { unicodeWidth } from "@std/cli" import { rgb24 } from "@std/fmt/colors" import { getOption } from "../../config.ts" import { + colorCycleShort, + type CycleDisplayInfo, + formatCycleShort, getPriorityDisplay, getTimeAgo, padDisplay, @@ -86,7 +89,7 @@ export const queryCommand = new Command() ) .option( "--cycle <cycle:string>", - "Filter by cycle name, number, or 'active'", + "Filter by cycle name, number, 'active'/'now', 'next', 'previous', or a relative offset like +1", ) .option( "--milestone <milestone:string>", @@ -333,6 +336,7 @@ export const queryCommand = new Command() limit: limit === 0 ? 0 : limit, projectId, projectLabel, + cycleId, labelNames, createdAfter, updatedAfter, @@ -425,7 +429,12 @@ interface DisplayableIssue { updatedAt: string state: { name: string; color: string } assignee?: { initials: string } | null - team?: { key: string } + team?: { + key: string + cyclesEnabled?: boolean + activeCycle?: { number: number } | null + } + cycle?: CycleDisplayInfo | null labels: { nodes: Array<{ name: string; color: string }> } inverseRelations?: { nodes: Array<{ @@ -463,6 +472,15 @@ function formatIssueTable( ), ) const estimateWidth = 1 + const showCycleColumn = issues.some((i) => + i.cycle != null || i.team?.cyclesEnabled === true + ) + const cycleShorts = issues.map((i) => + formatCycleShort(i.cycle, i.team?.activeCycle?.number) + ) + const cycleWidth = showCycleColumn + ? Math.max(3, ...cycleShorts.map((c) => unicodeWidth(c.text))) + : 0 const assigneeWidth = showAssigneeColumn ? 2 : 0 const stateWidth = Math.min( 20, @@ -481,6 +499,7 @@ function formatIssueTable( labelWidth, blockedWidth, estimateWidth, + ...(showCycleColumn ? [cycleWidth] : []), ...(showAssigneeColumn ? [assigneeWidth] : []), stateWidth, updatedWidth, @@ -499,6 +518,7 @@ function formatIssueTable( padDisplay("LABELS", labelWidth), padDisplay("B", blockedWidth), padDisplay("E", estimateWidth), + ...(showCycleColumn ? [padDisplay("CYC", cycleWidth)] : []), ...(showAssigneeColumn ? [padDisplay("A", assigneeWidth)] : []), padDisplay("STATE", stateWidth), padDisplay(updatedHeader, updatedWidth), @@ -506,7 +526,7 @@ function formatIssueTable( const outputLines = [header(headerCells.join(" "))] - for (const issue of issues) { + for (const [index, issue] of issues.entries()) { const title = padDisplay( truncateText(issue.title, titleWidth), titleWidth, @@ -522,6 +542,9 @@ function formatIssueTable( padDisplay(getTimeAgo(new Date(issue.updatedAt)), updatedWidth), ) const blockedCell = isIssueBlocked(issue) ? warning("⊘") : " " + const cycleShort = cycleShorts[index] + const cycleCell = colorCycleShort(cycleShort) + + " ".repeat(Math.max(0, cycleWidth - unicodeWidth(cycleShort.text))) const cells = [ padDisplay(getPriorityDisplay(issue.priority), priorityWidth), padDisplay(issue.identifier, idWidth), @@ -530,6 +553,7 @@ function formatIssueTable( formatLabels(issue.labels.nodes, labelWidth), padDisplay(blockedCell, blockedWidth), padDisplay(issue.estimate?.toString() || "-", estimateWidth), + ...(showCycleColumn ? [cycleCell] : []), ...(showAssigneeColumn ? [ padDisplay( diff --git a/src/commands/issue/issue-update.ts b/src/commands/issue/issue-update.ts index 5d543657..52de1384 100644 --- a/src/commands/issue/issue-update.ts +++ b/src/commands/issue/issue-update.ts @@ -84,7 +84,11 @@ export const updateCommand = new Command() ) .option( "--cycle <cycle:string>", - "Cycle name, number, or 'active'", + "Cycle name, number, 'active'/'now', 'next', 'previous', or a relative offset like +1 (use --cycle=-1 for negatives). Use --clear-cycle to remove the issue from its cycle", + ) + .option( + "--clear-cycle", + "Remove the issue from its cycle", ) .option("-t, --title <title:string>", "Title of the issue") .action( @@ -92,6 +96,7 @@ export const updateCommand = new Command() { assignee, unassign, + clearCycle, dueDate, parent, priority, @@ -119,6 +124,16 @@ export const updateCommand = new Command() ) } + if (clearCycle && cycle != null) { + throw new ValidationError( + "Cannot specify both --cycle and --clear-cycle", + { + suggestion: + "Use --cycle <cycle> to set a cycle, or --clear-cycle on its own to remove it.", + }, + ) + } + // Validate that description and descriptionFile are not both provided if (description && descriptionFile) { throw new ValidationError( @@ -280,7 +295,11 @@ export const updateCommand = new Command() if (projectMilestoneId !== undefined) { input.projectMilestoneId = projectMilestoneId } - if (cycleId !== undefined) input.cycleId = cycleId + if (clearCycle) { + input.cycleId = null + } else if (cycleId !== undefined) { + input.cycleId = cycleId + } if (stateId !== undefined) input.stateId = stateId spinner?.stop() diff --git a/src/commands/issue/issue-view.ts b/src/commands/issue/issue-view.ts index 06974cca..75436274 100644 --- a/src/commands/issue/issue-view.ts +++ b/src/commands/issue/issue-view.ts @@ -11,7 +11,11 @@ import type { FetchedIssueDetails, } from "../../utils/linear.ts" import { openIssuePage } from "../../utils/actions.ts" -import { formatRelativeTime, getPriorityDisplay } from "../../utils/display.ts" +import { + formatCycleShort, + formatRelativeTime, + getPriorityDisplay, +} from "../../utils/display.ts" import { pipeToUserPager, shouldUsePager } from "../../utils/pager.ts" import { bold, underline } from "@std/fmt/colors" import { ensureDir } from "@std/fs" @@ -157,9 +161,17 @@ export const viewCommand = new Command() metaParts.push(`**Milestone:** ${issueData.projectMilestone.name}`) } if (issueData.cycle) { - const cycleName = issueData.cycle.name ?? - `Cycle ${issueData.cycle.number}` - metaParts.push(`**Cycle:** ${cycleName}`) + const cycleShort = formatCycleShort( + issueData.cycle, + issueData.team?.activeCycle?.number, + ) + const cycleLabel = issueData.cycle.name != null + ? `#${issueData.cycle.number} ${issueData.cycle.name}` + : `#${issueData.cycle.number}` + const cycleDisplay = cycleShort.text.startsWith("#") + ? cycleLabel + : `${cycleLabel} (${cycleShort.text})` + metaParts.push(`**Cycle:** ${cycleDisplay}`) } const metaLine = metaParts.length > 0 ? "\n\n" + metaParts.join(" | ") diff --git a/src/utils/display.ts b/src/utils/display.ts index 3649f1b0..c633c11c 100644 --- a/src/utils/display.ts +++ b/src/utils/display.ts @@ -1,4 +1,6 @@ import { unicodeWidth } from "@std/cli" +import { green } from "@std/fmt/colors" +import { muted } from "./styling.ts" export function padDisplay(s: string, width: number): string { const w = unicodeWidth(s) @@ -71,6 +73,82 @@ export function getPriorityDisplay(priority: number): string { return priority.toString() } +export interface CycleDisplayInfo { + number: number + isActive: boolean + isNext: boolean + isPrevious: boolean + isPast: boolean +} + +export type CycleShortKind = "active" | "future" | "past" | "none" + +export interface CycleShort { + text: string + kind: CycleShortKind +} + +function assertCycleInteger(value: number, label: string): void { + if (!Number.isSafeInteger(value)) { + throw new Error( + `Expected ${label} to be a safe integer, got ${value}`, + ) + } +} + +/** + * Compact cycle token for table columns: "now" for the active cycle, signed + * offsets ("+1", "-2") relative to the team's active cycle, or an absolute + * "#N" when no anchor exists. The API's isNext/isPrevious flags take + * precedence over arithmetic so display always agrees with what + * `--cycle next`/`--cycle previous` would select. + */ +export function formatCycleShort( + cycle: CycleDisplayInfo | null | undefined, + activeCycleNumber: number | null | undefined, +): CycleShort { + if (cycle == null) { + return { text: "-", kind: "none" } + } + assertCycleInteger(cycle.number, "cycle number") + if (cycle.isActive) { + return { text: "now", kind: "active" } + } + if (cycle.isNext) { + return { text: "+1", kind: "future" } + } + if (cycle.isPrevious) { + return { text: "-1", kind: "past" } + } + if (activeCycleNumber != null) { + assertCycleInteger(activeCycleNumber, "active cycle number") + const offset = cycle.number - activeCycleNumber + if (offset === 0) { + return { text: "now", kind: "active" } + } + if (offset > 0) { + return { text: `+${offset}`, kind: "future" } + } + return { text: `${offset}`, kind: "past" } + } + return { + text: `#${cycle.number}`, + kind: cycle.isPast ? "past" : "future", + } +} + +export function colorCycleShort({ text, kind }: CycleShort): string { + switch (kind) { + case "active": + return green(text) + case "future": + return text + case "past": + case "none": + return muted(text) + } +} + export function formatRelativeTime(dateString: string): string { const now = new Date() const commentDate = new Date(dateString) diff --git a/src/utils/linear.ts b/src/utils/linear.ts index 35da850d..58cc3301 100644 --- a/src/utils/linear.ts +++ b/src/utils/linear.ts @@ -255,8 +255,19 @@ const issueDetailsWithCommentsQuery = gql(/* GraphQL */ ` name } cycle { - name + id number + name + isActive + isNext + isPrevious + isFuture + isPast + } + team { + activeCycle { + number + } } labels(first: 50) { nodes { @@ -357,8 +368,19 @@ const issueDetailsQuery = gql(/* GraphQL */ ` name } cycle { - name + id number + name + isActive + isNext + isPrevious + isFuture + isPast + } + team { + activeCycle { + number + } } labels(first: 50) { nodes { @@ -657,6 +679,24 @@ export async function fetchIssuesForState( color type } + cycle { + id + number + name + isActive + isNext + isPrevious + isFuture + isPast + } + team { + id + key + cyclesEnabled + activeCycle { + number + } + } labels { nodes { id @@ -784,6 +824,10 @@ const queryIssuesQuery = gql(/* GraphQL */ ` id key name + cyclesEnabled + activeCycle { + number + } } project { id @@ -797,6 +841,11 @@ const queryIssuesQuery = gql(/* GraphQL */ ` id number name + isActive + isNext + isPrevious + isFuture + isPast } labels { nodes { @@ -1036,6 +1085,10 @@ const searchIssuesQuery = gql(/* GraphQL */ ` id key name + cyclesEnabled + activeCycle { + number + } } project { id @@ -1049,6 +1102,11 @@ const searchIssuesQuery = gql(/* GraphQL */ ` id number name + isActive + isNext + isPrevious + isFuture + isPast } labels { nodes { @@ -1100,6 +1158,7 @@ export interface SearchIssuesByTermOptions { limit?: number projectId?: string projectLabel?: string + cycleId?: string labelNames?: string[] createdAfter?: string updatedAfter?: string @@ -1148,6 +1207,10 @@ export async function searchIssuesByTerm( } } + if (options.cycleId) { + filter.cycle = { id: { eq: options.cycleId } } + } + if (options.labelNames != null && options.labelNames.length > 0) { if (options.labelNames.length === 1) { filter.labels = { @@ -1856,13 +1919,22 @@ export async function getCycleIdByNameOrNumber( ): Promise<string> { const client = getGraphQLClient() const query = gql(/* GraphQL */ ` - query GetTeamCyclesForLookup($teamId: String!) { + query GetTeamCyclesForLookup($teamId: String!, $after: String) { team(id: $teamId) { - cycles { + key + cyclesEnabled + cycles(first: 250, after: $after) { nodes { id number name + startsAt + isNext + isPrevious + } + pageInfo { + hasNextPage + endCursor } } activeCycle { @@ -1873,23 +1945,106 @@ export async function getCycleIdByNameOrNumber( } } `) - const data = await client.request(query, { teamId }) + const data = await client.request(query, { teamId, after: null }) if (!data.team) { throw new NotFoundError("Team", teamId) } + if (!data.team.cyclesEnabled) { + throw new ValidationError( + `Cycles are not enabled for team ${data.team.key}`, + { + suggestion: + "Enable cycles for the team in Linear's settings before filtering or assigning by cycle.", + }, + ) + } + + const cycles = [...(data.team.cycles?.nodes || [])] + let pageInfo = data.team.cycles?.pageInfo + while (pageInfo?.hasNextPage) { + const page = await client.request(query, { + teamId, + after: pageInfo.endCursor, + }) + if (!page.team) { + throw new NotFoundError("Team", teamId) + } + cycles.push(...(page.team.cycles?.nodes || [])) + pageInfo = page.team.cycles?.pageInfo + } + const keyword = cycleNameOrNumber.toLowerCase() - if (cycleNameOrNumber.toLowerCase() === "active") { + // Reserved keywords take precedence over coincidental cycle names; use the + // cycle number to reach a cycle literally named "next"/"previous"/"active". + if (keyword === "active" || keyword === "now") { if (!data.team.activeCycle) { - throw new NotFoundError("Active cycle", teamId) + const next = cycles.find((c) => c.isNext) + throw new CliError( + `Team ${data.team.key} has no active cycle`, + { + suggestion: next != null + ? `The next cycle (#${next.number}) starts ${ + String(next.startsAt).slice(0, 10) + } — use --cycle next, a cycle number, or a name.` + : "Use a cycle number or name instead.", + }, + ) } return data.team.activeCycle.id } - const cycles = data.team.cycles?.nodes || [] + if (keyword === "next") { + const next = cycles.find((c) => c.isNext) + if (!next) { + throw new CliError( + `Team ${data.team.key} has no upcoming cycle`, + { suggestion: "Use a cycle number or name instead." }, + ) + } + return next.id + } + + if (keyword === "previous") { + const previous = cycles.find((c) => c.isPrevious) + if (!previous) { + throw new CliError( + `Team ${data.team.key} has no previous cycle`, + { suggestion: "Use a cycle number or name instead." }, + ) + } + return previous.id + } + + if (/^[+-]\d+$/.test(cycleNameOrNumber)) { + const offset = Number(cycleNameOrNumber) + if (!Number.isSafeInteger(offset)) { + throw new ValidationError( + `Cycle offset ${cycleNameOrNumber} is out of range`, + ) + } + if (!data.team.activeCycle) { + throw new ValidationError( + `Cannot resolve relative cycle ${cycleNameOrNumber}: the team has no active cycle`, + { + suggestion: + "Use 'next', a cycle number, or a cycle name while no cycle is active.", + }, + ) + } + const targetNumber = data.team.activeCycle.number + offset + const target = cycles.find((c) => c.number === targetNumber) + if (!target) { + throw new NotFoundError( + "Cycle", + `${cycleNameOrNumber} (cycle ${targetNumber})`, + ) + } + return target.id + } + const match = cycles.find( (c) => - (c.name != null && - c.name.toLowerCase() === cycleNameOrNumber.toLowerCase()) || + (c.name != null && c.name.toLowerCase() === keyword) || String(c.number) === cycleNameOrNumber, ) if (!match) { diff --git a/test/commands/cycle/cycle-view.test.ts b/test/commands/cycle/cycle-view.test.ts index 752d66e9..65581f68 100644 --- a/test/commands/cycle/cycle-view.test.ts +++ b/test/commands/cycle/cycle-view.test.ts @@ -37,13 +37,30 @@ await snapshotTest({ response: { data: { team: { + key: "ENG", + cyclesEnabled: true, cycles: { nodes: [ - { id: "cycle-1", number: 12, name: "Sprint 12" }, - { id: "cycle-2", number: 13, name: "Sprint 13" }, + { + id: "cycle-1", + number: 12, + startsAt: "2026-07-27T07:00:00.000Z", + name: "Sprint 12", + }, + { + id: "cycle-2", + number: 13, + startsAt: "2026-07-27T07:00:00.000Z", + name: "Sprint 13", + }, ], }, - activeCycle: { id: "cycle-2", number: 13, name: "Sprint 13" }, + activeCycle: { + id: "cycle-2", + number: 13, + startsAt: "2026-07-27T07:00:00.000Z", + name: "Sprint 13", + }, }, }, }, @@ -136,9 +153,16 @@ await snapshotTest({ response: { data: { team: { + key: "ENG", + cyclesEnabled: true, cycles: { nodes: [ - { id: "cycle-3", number: 14, name: "Sprint 14" }, + { + id: "cycle-3", + number: 14, + startsAt: "2026-07-27T07:00:00.000Z", + name: "Sprint 14", + }, ], }, activeCycle: null, @@ -215,9 +239,16 @@ await snapshotTest({ response: { data: { team: { + key: "ENG", + cyclesEnabled: true, cycles: { nodes: [ - { id: "cycle-1", number: 12, name: "Sprint 12" }, + { + id: "cycle-1", + number: 12, + startsAt: "2026-07-27T07:00:00.000Z", + name: "Sprint 12", + }, ], }, activeCycle: null, diff --git a/test/commands/issue/__snapshots__/issue-create.test.ts.snap b/test/commands/issue/__snapshots__/issue-create.test.ts.snap index d37eceee..b7a8ab74 100644 --- a/test/commands/issue/__snapshots__/issue-create.test.ts.snap +++ b/test/commands/issue/__snapshots__/issue-create.test.ts.snap @@ -11,24 +11,25 @@ Description: Options: - -h, --help - Show this help. - --start - Start the issue after creation - -a, --assignee <assignee> - Assign the issue to 'self' or someone (by username or name) - --due-date <dueDate> - Due date of the issue - --parent <parent> - Parent issue (if any) as a team_number code - -p, --priority <priority> - Priority of the issue (1-4, descending priority) - --estimate <estimate> - Points estimate of the issue - -d, --description <description> - Description of the issue - --description-file <path> - Read description from a file (preferred for markdown content) - -l, --label <label> - Issue label associated with the issue. May be repeated. - --team <team> - Team associated with the issue (if not your default team) - --project <project> - Project for the issue (UUID, slug ID, or name) - -s, --state <state> - Workflow state for the issue (by name or type) - --milestone <milestone> - Project milestone (UUID, or name when --project is set) - --cycle <cycle> - Cycle name, number, or 'active' - --no-use-default-template - Do not use default template for the issue - --no-interactive - Disable interactive prompts - -t, --title <title> - Title of the issue + -h, --help - Show this help. + --start - Start the issue after creation + -a, --assignee <assignee> - Assign the issue to 'self' or someone (by username or name) + --due-date <dueDate> - Due date of the issue + --parent <parent> - Parent issue (if any) as a team_number code + -p, --priority <priority> - Priority of the issue (1-4, descending priority) + --estimate <estimate> - Points estimate of the issue + -d, --description <description> - Description of the issue + --description-file <path> - Read description from a file (preferred for markdown content) + -l, --label <label> - Issue label associated with the issue. May be repeated. + --team <team> - Team associated with the issue (if not your default team) + --project <project> - Project for the issue (UUID, slug ID, or name) + -s, --state <state> - Workflow state for the issue (by name or type) + --milestone <milestone> - Project milestone (UUID, or name when --project is set) + --cycle <cycle> - Cycle name, number, 'active'/'now', 'next', 'previous', or a relative offset + like +1 (use --cycle=-1 for negatives) + --no-use-default-template - Do not use default template for the issue + --no-interactive - Disable interactive prompts + -t, --title <title> - Title of the issue " stderr: diff --git a/test/commands/issue/__snapshots__/issue-list.test.ts.snap b/test/commands/issue/__snapshots__/issue-list.test.ts.snap index 72684889..c32401ec 100644 --- a/test/commands/issue/__snapshots__/issue-list.test.ts.snap +++ b/test/commands/issue/__snapshots__/issue-list.test.ts.snap @@ -11,23 +11,24 @@ Description: Options: - -h, --help - Show this help. - -s, --state <state> - Filter by issue state (can be repeated for multiple states) (Default: [ \\x1b[32m"unstarted"\\x1b[39m ], Values: \\x1b[32m"triage"\\x1b[39m, \\x1b[32m"backlog"\\x1b[39m, - \\x1b[32m"unstarted"\\x1b[39m, \\x1b[32m"started"\\x1b[39m, \\x1b[32m"completed"\\x1b[39m, \\x1b[32m"canceled"\\x1b[39m) - --all-states - Show issues from all states - --sort <sort> - Sort order (can also be set via LINEAR_ISSUE_SORT) (Values: \\x1b[32m"manual"\\x1b[39m, \\x1b[32m"priority"\\x1b[39m) - --team <team> - Team to list issues for (if not your default team) - --project <project> - Filter by project (UUID, slug ID, or name) - --project-label <projectLabel> - Filter by project label name (shows issues from all projects with this label) - --cycle <cycle> - Filter by cycle name, number, or 'active' - --milestone <milestone> - Filter by project milestone (UUID, or name when --project is set) - -l, --label <label> - Filter by label name (can be repeated for multiple labels) - --limit <limit> - Maximum number of issues to fetch (default: 50, use 0 for unlimited) (Default: \\x1b[33m50\\x1b[39m) - --created-after <date> - Filter issues created after this date (ISO 8601 or YYYY-MM-DD) - --updated-after <date> - Filter issues updated after this date (ISO 8601 or YYYY-MM-DD) - -w, --web - Open in web browser - -a, --app - Open in Linear.app - --no-pager - Disable automatic paging for long output + -h, --help - Show this help. + -s, --state <state> - Filter by issue state (can be repeated for multiple states) (Default: [ \\x1b[32m"unstarted"\\x1b[39m ], Values: \\x1b[32m"triage"\\x1b[39m, \\x1b[32m"backlog"\\x1b[39m, + \\x1b[32m"unstarted"\\x1b[39m, \\x1b[32m"started"\\x1b[39m, \\x1b[32m"completed"\\x1b[39m, \\x1b[32m"canceled"\\x1b[39m) + --all-states - Show issues from all states + --sort <sort> - Sort order (can also be set via LINEAR_ISSUE_SORT) (Values: \\x1b[32m"manual"\\x1b[39m, \\x1b[32m"priority"\\x1b[39m) + --team <team> - Team to list issues for (if not your default team) + --project <project> - Filter by project (UUID, slug ID, or name) + --project-label <projectLabel> - Filter by project label name (shows issues from all projects with this label) + --cycle <cycle> - Filter by cycle name, number, 'active'/'now', 'next', 'previous', or a relative + offset like +1 + --milestone <milestone> - Filter by project milestone (UUID, or name when --project is set) + -l, --label <label> - Filter by label name (can be repeated for multiple labels) + --limit <limit> - Maximum number of issues to fetch (default: 50, use 0 for unlimited) (Default: \\x1b[33m50\\x1b[39m) + --created-after <date> - Filter issues created after this date (ISO 8601 or YYYY-MM-DD) + --updated-after <date> - Filter issues updated after this date (ISO 8601 or YYYY-MM-DD) + -w, --web - Open in web browser + -a, --app - Open in Linear.app + --no-pager - Disable automatic paging for long output \` stderr: diff --git a/test/commands/issue/__snapshots__/issue-mine.test.ts.snap b/test/commands/issue/__snapshots__/issue-mine.test.ts.snap index 7274fc0e..f4058724 100644 --- a/test/commands/issue/__snapshots__/issue-mine.test.ts.snap +++ b/test/commands/issue/__snapshots__/issue-mine.test.ts.snap @@ -11,23 +11,24 @@ Description: Options: - -h, --help - Show this help. - -s, --state <state> - Filter by issue state (can be repeated for multiple states) (Default: [ \\x1b[32m"unstarted"\\x1b[39m ], Values: \\x1b[32m"triage"\\x1b[39m, \\x1b[32m"backlog"\\x1b[39m, - \\x1b[32m"unstarted"\\x1b[39m, \\x1b[32m"started"\\x1b[39m, \\x1b[32m"completed"\\x1b[39m, \\x1b[32m"canceled"\\x1b[39m) - --all-states - Show issues from all states - --sort <sort> - Sort order (can also be set via LINEAR_ISSUE_SORT) (Values: \\x1b[32m"manual"\\x1b[39m, \\x1b[32m"priority"\\x1b[39m) - --team <team> - Team to list issues for (if not your default team) - --project <project> - Filter by project (UUID, slug ID, or name) - --project-label <projectLabel> - Filter by project label name (shows issues from all projects with this label) - --cycle <cycle> - Filter by cycle name, number, or 'active' - --milestone <milestone> - Filter by project milestone (UUID, or name when --project is set) - -l, --label <label> - Filter by label name (can be repeated for multiple labels) - --limit <limit> - Maximum number of issues to fetch (default: 50, use 0 for unlimited) (Default: \\x1b[33m50\\x1b[39m) - --created-after <date> - Filter issues created after this date (ISO 8601 or YYYY-MM-DD) - --updated-after <date> - Filter issues updated after this date (ISO 8601 or YYYY-MM-DD) - -w, --web - Open in web browser - -a, --app - Open in Linear.app - --no-pager - Disable automatic paging for long output + -h, --help - Show this help. + -s, --state <state> - Filter by issue state (can be repeated for multiple states) (Default: [ \\x1b[32m"unstarted"\\x1b[39m ], Values: \\x1b[32m"triage"\\x1b[39m, \\x1b[32m"backlog"\\x1b[39m, + \\x1b[32m"unstarted"\\x1b[39m, \\x1b[32m"started"\\x1b[39m, \\x1b[32m"completed"\\x1b[39m, \\x1b[32m"canceled"\\x1b[39m) + --all-states - Show issues from all states + --sort <sort> - Sort order (can also be set via LINEAR_ISSUE_SORT) (Values: \\x1b[32m"manual"\\x1b[39m, \\x1b[32m"priority"\\x1b[39m) + --team <team> - Team to list issues for (if not your default team) + --project <project> - Filter by project (UUID, slug ID, or name) + --project-label <projectLabel> - Filter by project label name (shows issues from all projects with this label) + --cycle <cycle> - Filter by cycle name, number, 'active'/'now', 'next', 'previous', or a relative + offset like +1 + --milestone <milestone> - Filter by project milestone (UUID, or name when --project is set) + -l, --label <label> - Filter by label name (can be repeated for multiple labels) + --limit <limit> - Maximum number of issues to fetch (default: 50, use 0 for unlimited) (Default: \\x1b[33m50\\x1b[39m) + --created-after <date> - Filter issues created after this date (ISO 8601 or YYYY-MM-DD) + --updated-after <date> - Filter issues updated after this date (ISO 8601 or YYYY-MM-DD) + -w, --web - Open in web browser + -a, --app - Open in Linear.app + --no-pager - Disable automatic paging for long output \` stderr: diff --git a/test/commands/issue/__snapshots__/issue-query.test.ts.snap b/test/commands/issue/__snapshots__/issue-query.test.ts.snap index 190c2932..1e19ff17 100644 --- a/test/commands/issue/__snapshots__/issue-query.test.ts.snap +++ b/test/commands/issue/__snapshots__/issue-query.test.ts.snap @@ -25,7 +25,8 @@ Options: --sort <sort> - Sort order: manual or priority (default: priority, not available with --search) (Values: \\x1b[32m"manual"\\x1b[39m, \\x1b[32m"priority"\\x1b[39m) --project <project> - Filter by project (UUID, slug ID, or name) --project-label <projectLabel> - Filter by project label name (shows issues from all projects with this label) - --cycle <cycle> - Filter by cycle name, number, or 'active' + --cycle <cycle> - Filter by cycle name, number, 'active'/'now', 'next', 'previous', or a relative + offset like +1 --milestone <milestone> - Filter by project milestone (UUID, or name when --project is set) -l, --label <label> - Filter by label name (can be repeated for multiple labels) --limit <limit> - Maximum number of issues to fetch (default: 50, use 0 for unlimited) (Default: \\x1b[33m50\\x1b[39m) @@ -69,7 +70,9 @@ stdout: "team": { "id": "team-1", "key": "ENG", - "name": "Engineering" + "name": "Engineering", + "cyclesEnabled": false, + "activeCycle": null }, "project": { "id": "project-1", @@ -130,7 +133,9 @@ stdout: "team": { "id": "team-1", "key": "ENG", - "name": "Engineering" + "name": "Engineering", + "cyclesEnabled": false, + "activeCycle": null }, "project": { "id": "project-1", diff --git a/test/commands/issue/__snapshots__/issue-update.test.ts.snap b/test/commands/issue/__snapshots__/issue-update.test.ts.snap index aad28b71..1633eda8 100644 --- a/test/commands/issue/__snapshots__/issue-update.test.ts.snap +++ b/test/commands/issue/__snapshots__/issue-update.test.ts.snap @@ -26,7 +26,10 @@ Options: -s, --state <state> - Workflow state for the issue (by name or type) --milestone <milestone> - Project milestone (UUID, or name when --project is set or the issue already has a project) - --cycle <cycle> - Cycle name, number, or 'active' + --cycle <cycle> - Cycle name, number, 'active'/'now', 'next', 'previous', or a relative offset + like +1 (use --cycle=-1 for negatives). Use --clear-cycle to remove the issue + from its cycle + --clear-cycle - Remove the issue from its cycle -t, --title <title> - Title of the issue " @@ -173,3 +176,46 @@ stderr: " `; +snapshot[`Issue Update Command - Clear Cycle Sends Null 1`] = ` +stdout: +"Updating issue ENG-123 + +✓ Updated issue ENG-123: Some issue +https://linear.app/test-team/issue/ENG-123/some-issue +" +stderr: +"" +`; + +snapshot[`Issue Update Command - Cycle Next Resolves Via Flag 1`] = ` +stdout: +"Updating issue ENG-123 + +✓ Updated issue ENG-123: Some issue +https://linear.app/test-team/issue/ENG-123/some-issue +" +stderr: +"" +`; + +snapshot[`Issue Update Command - Cycle Relative Offset 1`] = ` +stdout: +"Updating issue ENG-123 + +✓ Updated issue ENG-123: Some issue +https://linear.app/test-team/issue/ENG-123/some-issue +" +stderr: +"" +`; + +snapshot[`Issue Update Command - Cycle Lookup Paginates 1`] = ` +stdout: +"Updating issue ENG-123 + +✓ Updated issue ENG-123: Some issue +https://linear.app/test-team/issue/ENG-123/some-issue +" +stderr: +"" +`; diff --git a/test/commands/issue/__snapshots__/issue-view.test.ts.snap b/test/commands/issue/__snapshots__/issue-view.test.ts.snap index 99455e78..1c902505 100644 --- a/test/commands/issue/__snapshots__/issue-view.test.ts.snap +++ b/test/commands/issue/__snapshots__/issue-view.test.ts.snap @@ -354,7 +354,7 @@ snapshot[`Issue View Command - With Cycle 1`] = ` stdout: "# TEST-890: Implement rate limiting -**State:** Todo | **Priority:** ▄ | **Assignee:** Unassigned | **Project:** API Gateway v2 | **Cycle:** Sprint 7 +**State:** Todo | **Priority:** ▄ | **Assignee:** Unassigned | **Project:** API Gateway v2 | **Cycle:** #7 Sprint 7 (now) Add rate limiting to the API gateway. " @@ -362,6 +362,18 @@ stderr: "" `; +snapshot[`Issue View Command - With Upcoming Cycle And No Active Cycle 1`] = ` +stdout: +"# TEST-891: Tune rate limit thresholds + +**State:** Todo | **Priority:** ▄ | **Assignee:** Unassigned | **Cycle:** #8 (+1) + +Follow-up tuning work. +" +stderr: +"" +`; + snapshot[`Issue View Command - Hides Resolved Threads By Default 1`] = ` stdout: "# TEST-321: Audit resolved comment thread output diff --git a/test/commands/issue/issue-create.test.ts b/test/commands/issue/issue-create.test.ts index 6a7d4654..e6ff0a60 100644 --- a/test/commands/issue/issue-create.test.ts +++ b/test/commands/issue/issue-create.test.ts @@ -386,13 +386,30 @@ await snapshotTest({ response: { data: { team: { + key: "ENG", + cyclesEnabled: true, cycles: { nodes: [ - { id: "cycle-1", number: 7, name: "Sprint 7" }, - { id: "cycle-2", number: 8, name: "Sprint 8" }, + { + id: "cycle-1", + number: 7, + startsAt: "2026-07-27T07:00:00.000Z", + name: "Sprint 7", + }, + { + id: "cycle-2", + number: 8, + startsAt: "2026-07-27T07:00:00.000Z", + name: "Sprint 8", + }, ], }, - activeCycle: { id: "cycle-1", number: 7, name: "Sprint 7" }, + activeCycle: { + id: "cycle-1", + number: 7, + startsAt: "2026-07-27T07:00:00.000Z", + name: "Sprint 7", + }, }, }, }, diff --git a/test/commands/issue/issue-list.test.ts b/test/commands/issue/issue-list.test.ts index 8d42fcee..5c87e0f8 100644 --- a/test/commands/issue/issue-list.test.ts +++ b/test/commands/issue/issue-list.test.ts @@ -76,6 +76,13 @@ Deno.test("Issue List Command - Filter By Label", async () => { color: "#eb5757", }], }, + cycle: null, + team: { + id: "team-eng-id", + key: "ENG", + cyclesEnabled: false, + activeCycle: null, + }, inverseRelations: { nodes: [] }, updatedAt: "2026-03-13T10:00:00.000Z", }, diff --git a/test/commands/issue/issue-mine.test.ts b/test/commands/issue/issue-mine.test.ts index 574c9c4e..5a8b5379 100644 --- a/test/commands/issue/issue-mine.test.ts +++ b/test/commands/issue/issue-mine.test.ts @@ -74,6 +74,13 @@ Deno.test("Issue Mine Command - Filter By Label", async () => { color: "#eb5757", }], }, + cycle: null, + team: { + id: "team-eng-id", + key: "ENG", + cyclesEnabled: false, + activeCycle: null, + }, inverseRelations: { nodes: [] }, updatedAt: "2026-03-13T10:00:00.000Z", }, @@ -157,6 +164,13 @@ Deno.test("Issue Mine Command - Shows Blocked Indicator", async () => { assignee: { initials: "MC" }, state: baseState, labels: { nodes: [] }, + cycle: null, + team: { + id: "team-eng-id", + key: "ENG", + cyclesEnabled: false, + activeCycle: null, + }, inverseRelations: { nodes: [ { @@ -181,6 +195,13 @@ Deno.test("Issue Mine Command - Shows Blocked Indicator", async () => { assignee: { initials: "MC" }, state: baseState, labels: { nodes: [] }, + cycle: null, + team: { + id: "team-eng-id", + key: "ENG", + cyclesEnabled: false, + activeCycle: null, + }, inverseRelations: { nodes: [ { @@ -205,6 +226,13 @@ Deno.test("Issue Mine Command - Shows Blocked Indicator", async () => { assignee: { initials: "MC" }, state: baseState, labels: { nodes: [] }, + cycle: null, + team: { + id: "team-eng-id", + key: "ENG", + cyclesEnabled: false, + activeCycle: null, + }, inverseRelations: { nodes: [ { @@ -254,3 +282,143 @@ Deno.test("Issue Mine Command - Shows Blocked Indicator", async () => { await cleanup() } }) + +// Cycle column appears (between E and STATE) when the team has cycles +// enabled, using compact relative tokens. +Deno.test("Issue Mine Command - Shows Cycle Column", async () => { + const fixedNow = new Date("2026-03-30T10:00:00.000Z") + const RealDate = Date + const originalColorEnabled = getColorEnabled() + class MockDate extends RealDate { + constructor(value?: string | number | Date) { + super(value == null ? fixedNow.toISOString() : value) + } + + static override now(): number { + return fixedNow.getTime() + } + } + globalThis.Date = MockDate as DateConstructor + setColorEnabled(false) + + const cyclingTeam = { + id: "team-eng-id", + key: "ENG", + cyclesEnabled: true, + activeCycle: { number: 4 }, + } + + const { cleanup } = await setupMockLinearServer([ + { + queryName: "GetTeamIdByKey", + variables: { team: "ENG" }, + response: { + data: { + teams: { + nodes: [{ id: "team-eng-id" }], + }, + }, + }, + }, + { + queryName: "GetIssuesForState", + response: { + data: { + issues: { + nodes: [ + { + id: "issue-1", + identifier: "ENG-101", + title: "Fix login bug", + priority: 1, + estimate: 3, + assignee: { initials: "MC" }, + state: { + id: "state-1", + name: "In Progress", + color: "#f2c94c", + type: "started", + }, + labels: { + nodes: [{ + id: "label-1", + name: "Bug", + color: "#eb5757", + }], + }, + cycle: { + id: "cycle-4", + number: 4, + name: null, + isActive: true, + isNext: false, + isPrevious: false, + isFuture: false, + isPast: false, + }, + team: cyclingTeam, + inverseRelations: { nodes: [] }, + updatedAt: "2026-03-13T10:00:00.000Z", + }, + { + id: "issue-2", + identifier: "ENG-102", + title: "Plan ahead", + priority: 0, + estimate: null, + assignee: { initials: "MC" }, + state: { + id: "state-1", + name: "In Progress", + color: "#f2c94c", + type: "started", + }, + labels: { nodes: [] }, + cycle: { + id: "cycle-5", + number: 5, + name: null, + isActive: false, + isNext: true, + isPrevious: false, + isFuture: true, + isPast: false, + }, + team: cyclingTeam, + inverseRelations: { nodes: [] }, + updatedAt: "2026-03-13T10:00:00.000Z", + }, + ], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }, + }, + }, + ], { LINEAR_TEAM_ID: "ENG", LINEAR_ISSUE_SORT: "priority", NO_COLOR: "true" }) + + const logs: string[] = [] + const logStub = stub(console, "log", (...args: unknown[]) => { + logs.push(args.map(String).join(" ")) + }) + + try { + await mineCommand.parse([ + "--team", + "ENG", + "--sort", + "priority", + ]) + + assertEquals( + logs.join("\n") + "\n", + "◌ ID TITLE LABELS B E CYC STATE UPDATED \n" + + "⚠⚠⚠ ENG-101 Fix login bug Bug 3 now In Progress 17 days ago\n" + + "--- ENG-102 Plan ahead - +1 In Progress 17 days ago\n", + ) + } finally { + logStub.restore() + globalThis.Date = RealDate + setColorEnabled(originalColorEnabled) + await cleanup() + } +}) diff --git a/test/commands/issue/issue-query.test.ts b/test/commands/issue/issue-query.test.ts index 06ba61c0..73f5b530 100644 --- a/test/commands/issue/issue-query.test.ts +++ b/test/commands/issue/issue-query.test.ts @@ -47,6 +47,8 @@ const mockIssueNode = { id: "team-1", key: "ENG", name: "Engineering", + cyclesEnabled: false, + activeCycle: null, }, project: { id: "project-1", @@ -445,3 +447,148 @@ Deno.test("Issue Query Command - rejects --milestone without --project", async ( // getOption("team_id") reads from config files which can't be easily // overridden in tests. The validation logic is covered by the code path // and the other validation tests confirm handleError integration works. + +// Cycle column: shown when a team has cycles enabled, with relative tokens. +Deno.test("Issue Query Command - Shows Cycle Column", async () => { + const fixedNow = new Date("2026-04-03T10:00:00.000Z") + const RealDate = Date + const originalColorEnabled = getColorEnabled() + class MockDate extends RealDate { + constructor(value?: string | number | Date) { + super(value == null ? fixedNow.toISOString() : value) + } + static override now(): number { + return fixedNow.getTime() + } + } + globalThis.Date = MockDate as DateConstructor + setColorEnabled(false) + + const cyclingTeam = { + id: "team-1", + key: "ENG", + name: "Engineering", + cyclesEnabled: true, + activeCycle: { number: 3 }, + } + + const { cleanup } = await setupMockLinearServer([ + { + queryName: "GetIssuesForQuery", + response: { + data: { + issues: { + nodes: [ + { + ...mockIssueNode, + team: cyclingTeam, + cycle: { + id: "cycle-3", + number: 3, + name: null, + isActive: true, + isNext: false, + isPrevious: false, + isFuture: false, + isPast: false, + }, + }, + { + ...mockIssueNode, + id: "issue-2", + identifier: "ENG-102", + title: "Plan ahead", + team: cyclingTeam, + cycle: { + id: "cycle-5", + number: 5, + name: null, + isActive: false, + isNext: false, + isPrevious: false, + isFuture: true, + isPast: false, + }, + }, + { + ...mockIssueNode, + id: "issue-3", + identifier: "ENG-103", + title: "No cycle yet", + team: cyclingTeam, + cycle: null, + }, + ], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }, + }, + }, + ], { NO_COLOR: "true" }) + + const logs: string[] = [] + const logStub = stub(console, "log", (...args: unknown[]) => { + logs.push(args.map(String).join(" ")) + }) + + try { + await queryCommand.parse(["--team", "ENG"]) + + const [headerLine, ...rows] = logs + assertEquals(headerLine.includes("CYC"), true) + assertEquals(rows[0].includes(" now "), true) + assertEquals(rows[1].includes(" +2 "), true) + assertEquals(rows[2].includes(" - "), true) + } finally { + logStub.restore() + globalThis.Date = RealDate + setColorEnabled(originalColorEnabled) + await cleanup() + } +}) + +// Cycle column omitted entirely when no listed team has cycles enabled. +Deno.test("Issue Query Command - Hides Cycle Column When Cycles Disabled", async () => { + const fixedNow = new Date("2026-04-03T10:00:00.000Z") + const RealDate = Date + const originalColorEnabled = getColorEnabled() + class MockDate extends RealDate { + constructor(value?: string | number | Date) { + super(value == null ? fixedNow.toISOString() : value) + } + static override now(): number { + return fixedNow.getTime() + } + } + globalThis.Date = MockDate as DateConstructor + setColorEnabled(false) + + const { cleanup } = await setupMockLinearServer([ + { + queryName: "GetIssuesForQuery", + response: { + data: { + issues: { + nodes: [mockIssueNode], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }, + }, + }, + ], { NO_COLOR: "true" }) + + const logs: string[] = [] + const logStub = stub(console, "log", (...args: unknown[]) => { + logs.push(args.map(String).join(" ")) + }) + + try { + await queryCommand.parse(["--team", "ENG"]) + assertEquals(logs[0].includes("CYC"), false) + } finally { + logStub.restore() + globalThis.Date = RealDate + setColorEnabled(originalColorEnabled) + await cleanup() + } +}) diff --git a/test/commands/issue/issue-update.test.ts b/test/commands/issue/issue-update.test.ts index 3198cb3a..a7c1d5b5 100644 --- a/test/commands/issue/issue-update.test.ts +++ b/test/commands/issue/issue-update.test.ts @@ -390,13 +390,30 @@ await snapshotTest({ response: { data: { team: { + key: "ENG", + cyclesEnabled: true, cycles: { nodes: [ - { id: "cycle-1", number: 7, name: "Sprint 7" }, - { id: "cycle-2", number: 8, name: "Sprint 8" }, + { + id: "cycle-1", + number: 7, + startsAt: "2026-07-27T07:00:00.000Z", + name: "Sprint 7", + }, + { + id: "cycle-2", + number: 8, + startsAt: "2026-07-27T07:00:00.000Z", + name: "Sprint 8", + }, ], }, - activeCycle: { id: "cycle-1", number: 7, name: "Sprint 7" }, + activeCycle: { + id: "cycle-1", + number: 7, + startsAt: "2026-07-27T07:00:00.000Z", + name: "Sprint 7", + }, }, }, }, @@ -736,3 +753,518 @@ await snapshotTest({ await updateCommand.parse() }, }) + +// --clear-cycle must send `cycleId: null` on the wire. Like --unassign above, +// the exact-variables mock proves the key is present AND null. There is no +// GetTeamCyclesForLookup mock: clearing must not perform a cycle lookup. +await snapshotTest({ + name: "Issue Update Command - Clear Cycle Sends Null", + meta: import.meta, + colors: false, + args: ["ENG-123", "--clear-cycle"], + denoArgs: commonDenoArgs, + async fn() { + const { cleanup } = await setupMockLinearServer([ + { + queryName: "GetTeamIdByKey", + variables: { team: "ENG" }, + response: { data: { teams: { nodes: [{ id: "team-eng-id" }] } } }, + }, + { + queryName: "UpdateIssue", + variables: { + id: "ENG-123", + input: { cycleId: null, teamId: "team-eng-id" }, + }, + response: { + data: { + issueUpdate: { + success: true, + issue: { + id: "issue-existing-123", + identifier: "ENG-123", + url: "https://linear.app/test-team/issue/ENG-123/some-issue", + title: "Some issue", + }, + }, + }, + }, + }, + ], { LINEAR_TEAM_ID: "ENG" }) + + try { + await updateCommand.parse() + } finally { + await cleanup() + } + }, +}) + +// --cycle next resolves via the API's isNext flag, not name matching. +await snapshotTest({ + name: "Issue Update Command - Cycle Next Resolves Via Flag", + meta: import.meta, + colors: false, + args: ["ENG-123", "--cycle", "next"], + denoArgs: commonDenoArgs, + async fn() { + const { cleanup } = await setupMockLinearServer([ + { + queryName: "GetTeamIdByKey", + variables: { team: "ENG" }, + response: { data: { teams: { nodes: [{ id: "team-eng-id" }] } } }, + }, + { + queryName: "GetTeamCyclesForLookup", + response: { + data: { + team: { + key: "ENG", + cyclesEnabled: true, + cycles: { + nodes: [ + { + id: "cycle-5-id", + number: 5, + startsAt: "2026-07-27T07:00:00.000Z", + name: null, + isNext: false, + isPrevious: true, + }, + { + id: "cycle-6-id", + number: 6, + startsAt: "2026-07-27T07:00:00.000Z", + name: null, + isNext: false, + isPrevious: false, + }, + { + id: "cycle-7-id", + number: 7, + startsAt: "2026-07-27T07:00:00.000Z", + name: "next", + isNext: true, + isPrevious: false, + }, + ], + }, + activeCycle: { + id: "cycle-6-id", + number: 6, + startsAt: "2026-07-27T07:00:00.000Z", + name: null, + }, + }, + }, + }, + }, + { + queryName: "UpdateIssue", + variables: { + id: "ENG-123", + input: { cycleId: "cycle-7-id", teamId: "team-eng-id" }, + }, + response: { + data: { + issueUpdate: { + success: true, + issue: { + id: "issue-existing-123", + identifier: "ENG-123", + url: "https://linear.app/test-team/issue/ENG-123/some-issue", + title: "Some issue", + }, + }, + }, + }, + }, + ], { LINEAR_TEAM_ID: "ENG" }) + + try { + await updateCommand.parse() + } finally { + await cleanup() + } + }, +}) + +// Relative offsets resolve against the active cycle's number. +await snapshotTest({ + name: "Issue Update Command - Cycle Relative Offset", + meta: import.meta, + colors: false, + args: ["ENG-123", "--cycle", "+2"], + denoArgs: commonDenoArgs, + async fn() { + const { cleanup } = await setupMockLinearServer([ + { + queryName: "GetTeamIdByKey", + variables: { team: "ENG" }, + response: { data: { teams: { nodes: [{ id: "team-eng-id" }] } } }, + }, + { + queryName: "GetTeamCyclesForLookup", + response: { + data: { + team: { + key: "ENG", + cyclesEnabled: true, + cycles: { + nodes: [ + { + id: "cycle-5-id", + number: 5, + startsAt: "2026-07-27T07:00:00.000Z", + name: null, + isNext: false, + isPrevious: false, + }, + { + id: "cycle-7-id", + number: 7, + startsAt: "2026-07-27T07:00:00.000Z", + name: null, + isNext: false, + isPrevious: false, + }, + ], + }, + activeCycle: { + id: "cycle-5-id", + number: 5, + startsAt: "2026-07-27T07:00:00.000Z", + name: null, + }, + }, + }, + }, + }, + { + queryName: "UpdateIssue", + variables: { + id: "ENG-123", + input: { cycleId: "cycle-7-id", teamId: "team-eng-id" }, + }, + response: { + data: { + issueUpdate: { + success: true, + issue: { + id: "issue-existing-123", + identifier: "ENG-123", + url: "https://linear.app/test-team/issue/ENG-123/some-issue", + title: "Some issue", + }, + }, + }, + }, + }, + ], { LINEAR_TEAM_ID: "ENG" }) + + try { + await updateCommand.parse() + } finally { + await cleanup() + } + }, +}) + +Deno.test("Issue Update Command - rejects --cycle with --clear-cycle", async () => { + const { assertEquals } = await import("@std/assert") + const { stub } = await import("@std/testing/mock") + const errorLogs: string[] = [] + const errorStub = stub(console, "error", (...args: unknown[]) => { + errorLogs.push(args.map(String).join(" ")) + }) + const exitStub = stub(Deno, "exit", (_code?: number) => { + throw new Error("EXIT") + }) + + try { + await updateCommand.parse(["ENG-123", "--cycle", "next", "--clear-cycle"]) + } catch (e) { + if (!(e instanceof Error) || e.message !== "EXIT") throw e + } finally { + errorStub.restore() + exitStub.restore() + } + + assertEquals( + errorLogs.some((l) => + l.includes("Cannot specify both --cycle and --clear-cycle") + ), + true, + ) +}) + +Deno.test("Issue Update Command - relative cycle offset requires an active cycle", async () => { + const { assertEquals } = await import("@std/assert") + const { stub } = await import("@std/testing/mock") + const { cleanup } = await setupMockLinearServer([ + { + queryName: "GetTeamIdByKey", + variables: { team: "ENG" }, + response: { data: { teams: { nodes: [{ id: "team-eng-id" }] } } }, + }, + { + queryName: "GetTeamCyclesForLookup", + response: { + data: { + team: { + key: "ENG", + cyclesEnabled: true, + cycles: { + nodes: [ + { + id: "cycle-8-id", + number: 8, + startsAt: "2026-07-27T07:00:00.000Z", + name: null, + isNext: true, + isPrevious: false, + }, + ], + }, + activeCycle: null, + }, + }, + }, + }, + ], { LINEAR_TEAM_ID: "ENG" }) + + const errorLogs: string[] = [] + const errorStub = stub(console, "error", (...args: unknown[]) => { + errorLogs.push(args.map(String).join(" ")) + }) + const exitStub = stub(Deno, "exit", (_code?: number) => { + throw new Error("EXIT") + }) + + try { + await updateCommand.parse(["ENG-123", "--cycle", "+2"]) + } catch (e) { + if (!(e instanceof Error) || e.message !== "EXIT") throw e + } finally { + errorStub.restore() + exitStub.restore() + await cleanup() + } + + assertEquals( + errorLogs.some((l) => l.includes("the team has no active cycle")), + true, + ) +}) + +// Cycle lookup follows pagination: a cycle beyond the first page must still +// resolve. Page mocks are matched on the `after` cursor. +await snapshotTest({ + name: "Issue Update Command - Cycle Lookup Paginates", + meta: import.meta, + colors: false, + args: ["ENG-123", "--cycle", "1"], + denoArgs: commonDenoArgs, + async fn() { + const { cleanup } = await setupMockLinearServer([ + { + queryName: "GetTeamIdByKey", + variables: { team: "ENG" }, + response: { data: { teams: { nodes: [{ id: "team-eng-id" }] } } }, + }, + { + queryName: "GetTeamCyclesForLookup", + variables: { teamId: "team-eng-id", after: null }, + response: { + data: { + team: { + key: "ENG", + cyclesEnabled: true, + cycles: { + nodes: [ + { + id: "cycle-300-id", + number: 300, + startsAt: "2026-07-27T07:00:00.000Z", + name: null, + isNext: false, + isPrevious: false, + }, + ], + pageInfo: { hasNextPage: true, endCursor: "cursor-1" }, + }, + activeCycle: null, + }, + }, + }, + }, + { + queryName: "GetTeamCyclesForLookup", + variables: { teamId: "team-eng-id", after: "cursor-1" }, + response: { + data: { + team: { + key: "ENG", + cyclesEnabled: true, + cycles: { + nodes: [ + { + id: "cycle-1-id", + number: 1, + startsAt: "2026-07-27T07:00:00.000Z", + name: null, + isNext: false, + isPrevious: false, + }, + ], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + activeCycle: null, + }, + }, + }, + }, + { + queryName: "UpdateIssue", + variables: { + id: "ENG-123", + input: { cycleId: "cycle-1-id", teamId: "team-eng-id" }, + }, + response: { + data: { + issueUpdate: { + success: true, + issue: { + id: "issue-existing-123", + identifier: "ENG-123", + url: "https://linear.app/test-team/issue/ENG-123/some-issue", + title: "Some issue", + }, + }, + }, + }, + }, + ], { LINEAR_TEAM_ID: "ENG" }) + + try { + await updateCommand.parse() + } finally { + await cleanup() + } + }, +}) + +Deno.test("Issue Update Command - --cycle errors when team has cycles disabled", async () => { + const { assertEquals } = await import("@std/assert") + const { stub } = await import("@std/testing/mock") + const { cleanup } = await setupMockLinearServer([ + { + queryName: "GetTeamIdByKey", + variables: { team: "ENG" }, + response: { data: { teams: { nodes: [{ id: "team-eng-id" }] } } }, + }, + { + queryName: "GetTeamCyclesForLookup", + response: { + data: { + team: { + key: "ENG", + cyclesEnabled: false, + cycles: { + nodes: [], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + activeCycle: null, + }, + }, + }, + }, + ], { LINEAR_TEAM_ID: "ENG" }) + + const errorLogs: string[] = [] + const errorStub = stub(console, "error", (...args: unknown[]) => { + errorLogs.push(args.map(String).join(" ")) + }) + const exitStub = stub(Deno, "exit", (_code?: number) => { + throw new Error("EXIT") + }) + + try { + await updateCommand.parse(["ENG-123", "--cycle", "next"]) + } catch (e) { + if (!(e instanceof Error) || e.message !== "EXIT") throw e + } finally { + errorStub.restore() + exitStub.restore() + await cleanup() + } + + assertEquals( + errorLogs.some((l) => l.includes("Cycles are not enabled for team ENG")), + true, + ) +}) + +Deno.test("Issue Update Command - --cycle now errors helpfully when no cycle is active", async () => { + const { assertEquals } = await import("@std/assert") + const { stub } = await import("@std/testing/mock") + const { cleanup } = await setupMockLinearServer([ + { + queryName: "GetTeamIdByKey", + variables: { team: "ENG" }, + response: { data: { teams: { nodes: [{ id: "team-eng-id" }] } } }, + }, + { + queryName: "GetTeamCyclesForLookup", + response: { + data: { + team: { + key: "ENG", + cyclesEnabled: true, + cycles: { + nodes: [ + { + id: "cycle-8-id", + number: 8, + name: null, + startsAt: "2026-07-27T07:00:00.000Z", + isNext: true, + isPrevious: false, + }, + ], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + activeCycle: null, + }, + }, + }, + }, + ], { LINEAR_TEAM_ID: "ENG" }) + + const errorLogs: string[] = [] + const errorStub = stub(console, "error", (...args: unknown[]) => { + errorLogs.push(args.map(String).join(" ")) + }) + const exitStub = stub(Deno, "exit", (_code?: number) => { + throw new Error("EXIT") + }) + + try { + await updateCommand.parse(["ENG-123", "--cycle", "now"]) + } catch (e) { + if (!(e instanceof Error) || e.message !== "EXIT") throw e + } finally { + errorStub.restore() + exitStub.restore() + await cleanup() + } + + assertEquals( + errorLogs.some((l) => l.includes("Team ENG has no active cycle")), + true, + ) + assertEquals( + errorLogs.some((l) => l.includes("The next cycle (#8) starts 2026-07-27")), + true, + ) +}) diff --git a/test/commands/issue/issue-view.test.ts b/test/commands/issue/issue-view.test.ts index ea9fccf3..9c8af24a 100644 --- a/test/commands/issue/issue-view.test.ts +++ b/test/commands/issue/issue-view.test.ts @@ -890,8 +890,85 @@ await snapshotTest({ }, projectMilestone: null, cycle: { + id: "cycle-7", name: "Sprint 7", number: 7, + isActive: true, + isNext: false, + isPrevious: false, + isFuture: false, + isPast: false, + }, + team: { + activeCycle: { number: 7 }, + }, + parent: null, + children: { + nodes: [], + }, + attachments: { + nodes: [], + }, + }, + }, + }, + }, + ]) + + try { + await server.start() + Deno.env.set("LINEAR_GRAPHQL_ENDPOINT", server.getEndpoint()) + Deno.env.set("LINEAR_API_KEY", "Bearer test-token") + + await viewCommand.parse() + } finally { + await server.stop() + Deno.env.delete("LINEAR_GRAPHQL_ENDPOINT") + Deno.env.delete("LINEAR_API_KEY") + } + }, +}) + +await snapshotTest({ + name: "Issue View Command - With Upcoming Cycle And No Active Cycle", + meta: import.meta, + colors: false, + args: ["TEST-891", "--no-comments"], + denoArgs, + async fn() { + const server = new MockLinearServer([ + { + queryName: "GetIssueDetails", + variables: { id: "TEST-891" }, + response: { + data: { + issue: { + identifier: "TEST-891", + title: "Tune rate limit thresholds", + description: "Follow-up tuning work.", + url: + "https://linear.app/test-team/issue/TEST-891/tune-rate-limit-thresholds", + branchName: "feat/test-891-tune-thresholds", + state: { + name: "Todo", + color: "#e2e2e2", + }, + assignee: null, + priority: 4, + project: null, + projectMilestone: null, + cycle: { + id: "cycle-8", + name: null, + number: 8, + isActive: false, + isNext: true, + isPrevious: false, + isFuture: true, + isPast: false, + }, + team: { + activeCycle: null, }, parent: null, children: { diff --git a/test/utils/display.test.ts b/test/utils/display.test.ts new file mode 100644 index 00000000..6a00aab7 --- /dev/null +++ b/test/utils/display.test.ts @@ -0,0 +1,103 @@ +import { assertEquals, assertThrows } from "@std/assert" +import { formatCycleShort } from "../../src/utils/display.ts" + +function cycle(overrides: { + number: number + isActive?: boolean + isNext?: boolean + isPrevious?: boolean + isPast?: boolean +}) { + return { + isActive: false, + isNext: false, + isPrevious: false, + isPast: false, + ...overrides, + } +} + +Deno.test("formatCycleShort - no cycle", () => { + assertEquals(formatCycleShort(null, null), { text: "-", kind: "none" }) + assertEquals(formatCycleShort(undefined, 5), { text: "-", kind: "none" }) +}) + +Deno.test("formatCycleShort - active cycle wins regardless of anchor", () => { + assertEquals( + formatCycleShort(cycle({ number: 7, isActive: true }), 7), + { text: "now", kind: "active" }, + ) + assertEquals( + formatCycleShort(cycle({ number: 7, isActive: true }), null), + { text: "now", kind: "active" }, + ) +}) + +Deno.test("formatCycleShort - isNext/isPrevious flags win over arithmetic", () => { + // Even with an anchor that would compute a different offset, the API's + // next/previous flags take precedence so display matches --cycle next/previous. + assertEquals( + formatCycleShort(cycle({ number: 9, isNext: true }), 7), + { text: "+1", kind: "future" }, + ) + assertEquals( + formatCycleShort(cycle({ number: 5, isPrevious: true, isPast: true }), 7), + { text: "-1", kind: "past" }, + ) +}) + +Deno.test("formatCycleShort - flags without anchor (cooldown)", () => { + assertEquals( + formatCycleShort(cycle({ number: 1, isNext: true }), null), + { text: "+1", kind: "future" }, + ) + assertEquals( + formatCycleShort( + cycle({ number: 3, isPrevious: true, isPast: true }), + null, + ), + { text: "-1", kind: "past" }, + ) +}) + +Deno.test("formatCycleShort - anchored offsets", () => { + assertEquals( + formatCycleShort(cycle({ number: 9 }), 7), + { text: "+2", kind: "future" }, + ) + assertEquals( + formatCycleShort(cycle({ number: 4, isPast: true }), 7), + { text: "-3", kind: "past" }, + ) +}) + +Deno.test("formatCycleShort - anchored offset of zero renders as now", () => { + assertEquals( + formatCycleShort(cycle({ number: 7 }), 7), + { text: "now", kind: "active" }, + ) +}) + +Deno.test("formatCycleShort - no anchor and no flags falls back to absolute", () => { + assertEquals( + formatCycleShort(cycle({ number: 12 }), null), + { text: "#12", kind: "future" }, + ) + assertEquals( + formatCycleShort(cycle({ number: 3, isPast: true }), null), + { text: "#3", kind: "past" }, + ) +}) + +Deno.test("formatCycleShort - rejects non-integer cycle numbers", () => { + assertThrows( + () => formatCycleShort(cycle({ number: 1.5 }), 7), + Error, + "integer", + ) + assertThrows( + () => formatCycleShort(cycle({ number: 2 }), 1.5), + Error, + "integer", + ) +}) diff --git a/test/utils/linear.test.ts b/test/utils/linear.test.ts index d320ca48..e45cfa2c 100644 --- a/test/utils/linear.test.ts +++ b/test/utils/linear.test.ts @@ -99,6 +99,8 @@ Deno.test("searchIssuesByTerm - without limit fetches a single page", async () = id: "team-1", key: "CLI", name: "Linear CLI", + cyclesEnabled: false, + activeCycle: null, }, project: null, projectMilestone: null, @@ -147,6 +149,8 @@ Deno.test("searchIssuesByTerm - without limit fetches a single page", async () = id: "team-1", key: "CLI", name: "Linear CLI", + cyclesEnabled: false, + activeCycle: null, }, project: null, projectMilestone: null,