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,