diff --git a/skills/linear-cli/references/issue.md b/skills/linear-cli/references/issue.md index 2c495b8..4fce63c 100644 --- a/skills/linear-cli/references/issue.md +++ b/skills/linear-cli/references/issue.md @@ -280,6 +280,7 @@ Options: --project - Name of the project with the issue -s, --state - Workflow state for the issue (by name or type) --milestone - Name of the project milestone + --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 of the issue @@ -313,6 +314,7 @@ Options: --project <project> - Name of the project with the issue -s, --state <state> - Workflow state for the issue (by name or type) --milestone <milestone> - Name of the project milestone + --cycle <cycle> - Cycle name, number, or 'active' -t, --title <title> - Title of the issue ``` diff --git a/src/commands/issue/issue-create.ts b/src/commands/issue/issue-create.ts index a0492aa..129cdba 100644 --- a/src/commands/issue/issue-create.ts +++ b/src/commands/issue/issue-create.ts @@ -7,6 +7,7 @@ import { getPriorityDisplay } from "../../utils/display.ts" import { fetchParentIssueData, getAllTeams, + getCycleIdByNameOrNumber, getIssueId, getIssueIdentifier, getIssueLabelIdByNameForTeam, @@ -499,6 +500,10 @@ export const createCommand = new Command() "--milestone <milestone:string>", "Name of the project milestone", ) + .option( + "--cycle <cycle:string>", + "Cycle name, number, or 'active'", + ) .option( "--no-use-default-template", "Do not use default template for the issue", @@ -522,6 +527,7 @@ export const createCommand = new Command() project, state, milestone, + cycle, interactive, title, }, @@ -556,7 +562,7 @@ export const createCommand = new Command() const noFlagsProvided = !title && !assignee && !dueDate && priority === undefined && estimate === undefined && !finalDescription && (!labels || labels.length === 0) && - !team && !project && !state && !milestone && !start + !team && !project && !state && !milestone && !cycle && !start if (noFlagsProvided && interactive) { try { @@ -761,6 +767,11 @@ export const createCommand = new Command() ) } + let cycleId: string | undefined + if (cycle != null) { + cycleId = await getCycleIdByNameOrNumber(cycle, teamId) + } + // Date validation done at graphql level // Convert parent identifier if provided and fetch parent data @@ -799,6 +810,7 @@ export const createCommand = new Command() teamId: teamId, projectId: projectId || parentData?.projectId, projectMilestoneId, + cycleId, stateId, useDefaultTemplate, description: finalDescription, diff --git a/src/commands/issue/issue-update.ts b/src/commands/issue/issue-update.ts index e745ce0..1a981cc 100644 --- a/src/commands/issue/issue-update.ts +++ b/src/commands/issue/issue-update.ts @@ -2,6 +2,7 @@ import { Command } from "@cliffy/command" import { gql } from "../../__codegen__/gql.ts" import { getGraphQLClient } from "../../utils/graphql.ts" import { + getCycleIdByNameOrNumber, getIssueId, getIssueIdentifier, getIssueLabelIdByNameForTeam, @@ -72,6 +73,10 @@ export const updateCommand = new Command() "--milestone <milestone:string>", "Name of the project milestone", ) + .option( + "--cycle <cycle:string>", + "Cycle name, number, or 'active'", + ) .option("-t, --title <title:string>", "Title of the issue") .action( async ( @@ -88,6 +93,7 @@ export const updateCommand = new Command() project, state, milestone, + cycle, title, }, issueIdArg, @@ -213,6 +219,11 @@ export const updateCommand = new Command() ) } + let cycleId: string | undefined + if (cycle != null) { + cycleId = await getCycleIdByNameOrNumber(cycle, teamId) + } + // Build the update input object, only including fields that were provided const input: Record<string, string | number | string[] | undefined> = {} @@ -241,6 +252,7 @@ export const updateCommand = new Command() if (projectMilestoneId !== undefined) { input.projectMilestoneId = projectMilestoneId } + 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 0848fbd..711972a 100644 --- a/src/commands/issue/issue-view.ts +++ b/src/commands/issue/issue-view.ts @@ -126,6 +126,11 @@ export const viewCommand = new Command() if (issueData.projectMilestone) { metaParts.push(`**Milestone:** ${issueData.projectMilestone.name}`) } + if (issueData.cycle) { + const cycleName = issueData.cycle.name ?? + `Cycle ${issueData.cycle.number}` + metaParts.push(`**Cycle:** ${cycleName}`) + } const metaLine = metaParts.length > 0 ? "\n\n" + metaParts.join(" | ") : "" diff --git a/src/utils/linear.ts b/src/utils/linear.ts index f7b25b3..a625ab8 100644 --- a/src/utils/linear.ts +++ b/src/utils/linear.ts @@ -169,6 +169,7 @@ export async function fetchIssueDetails( state: { name: string; color: string } project?: { name: string } | null projectMilestone?: { name: string } | null + cycle?: { name?: string | null; number: number } | null parent?: { identifier: string title: string @@ -220,6 +221,10 @@ export async function fetchIssueDetails( projectMilestone { name } + cycle { + name + number + } parent { identifier title @@ -289,6 +294,10 @@ export async function fetchIssueDetails( projectMilestone { name } + cycle { + name + number + } parent { identifier title @@ -941,6 +950,54 @@ export async function getMilestoneIdByName( return match.id } +export async function getCycleIdByNameOrNumber( + cycleNameOrNumber: string, + teamId: string, +): Promise<string> { + const client = getGraphQLClient() + const query = gql(/* GraphQL */ ` + query GetTeamCyclesForLookup($teamId: String!) { + team(id: $teamId) { + cycles { + nodes { + id + number + name + } + } + activeCycle { + id + number + name + } + } + } + `) + const data = await client.request(query, { teamId }) + if (!data.team) { + throw new NotFoundError("Team", teamId) + } + + if (cycleNameOrNumber.toLowerCase() === "active") { + if (!data.team.activeCycle) { + throw new NotFoundError("Active cycle", teamId) + } + return data.team.activeCycle.id + } + + const cycles = data.team.cycles?.nodes || [] + const match = cycles.find( + (c) => + (c.name != null && + c.name.toLowerCase() === cycleNameOrNumber.toLowerCase()) || + String(c.number) === cycleNameOrNumber, + ) + if (!match) { + throw new NotFoundError("Cycle", cycleNameOrNumber) + } + return match.id +} + export async function selectOption( dataName: string, originalValue: string, diff --git a/test/commands/issue/__snapshots__/issue-create.test.ts.snap b/test/commands/issue/__snapshots__/issue-create.test.ts.snap index 3cc1658..8d4daf0 100644 --- a/test/commands/issue/__snapshots__/issue-create.test.ts.snap +++ b/test/commands/issue/__snapshots__/issue-create.test.ts.snap @@ -25,6 +25,7 @@ Options: --project <project> - Name of the project with the issue -s, --state <state> - Workflow state for the issue (by name or type) --milestone <milestone> - Name of the project milestone + --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 @@ -63,3 +64,13 @@ https://linear.app/test-team/issue/ENG-456/test-case-insensitive-labels stderr: "" `; + +snapshot[`Issue Create Command - With Cycle 1`] = ` +stdout: +"Creating issue in ENG + +https://linear.app/test-team/issue/ENG-890/test-cycle-feature +" +stderr: +"" +`; diff --git a/test/commands/issue/__snapshots__/issue-update.test.ts.snap b/test/commands/issue/__snapshots__/issue-update.test.ts.snap index 8962dfa..c6bd112 100644 --- a/test/commands/issue/__snapshots__/issue-update.test.ts.snap +++ b/test/commands/issue/__snapshots__/issue-update.test.ts.snap @@ -24,6 +24,7 @@ Options: --project <project> - Name of the project with the issue -s, --state <state> - Workflow state for the issue (by name or type) --milestone <milestone> - Name of the project milestone + --cycle <cycle> - Cycle name, number, or 'active' -t, --title <title> - Title of the issue " @@ -63,3 +64,14 @@ https://linear.app/test-team/issue/ENG-123/test-issue stderr: "" `; + +snapshot[`Issue Update Command - With Cycle 1`] = ` +stdout: +"Updating issue ENG-123 + +✓ Updated issue ENG-123: Test Issue +https://linear.app/test-team/issue/ENG-123/test-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 3ed4cc2..b8e1162 100644 --- a/test/commands/issue/__snapshots__/issue-view.test.ts.snap +++ b/test/commands/issue/__snapshots__/issue-view.test.ts.snap @@ -197,3 +197,15 @@ Set up Datadog dashboards for the new service. stderr: "" `; + +snapshot[`Issue View Command - With Cycle 1`] = ` +stdout: +"# TEST-890: Implement rate limiting + +**Project:** API Gateway v2 | **Cycle:** Sprint 7 + +Add rate limiting to the API gateway. +" +stderr: +"" +`; diff --git a/test/commands/issue/issue-create.test.ts b/test/commands/issue/issue-create.test.ts index 5a8ddfc..e448f2e 100644 --- a/test/commands/issue/issue-create.test.ts +++ b/test/commands/issue/issue-create.test.ts @@ -259,3 +259,80 @@ await snapshotTest({ } }, }) + +// Test creating an issue with cycle +await snapshotTest({ + name: "Issue Create Command - With Cycle", + meta: import.meta, + colors: false, + args: [ + "--title", + "Test cycle feature", + "--team", + "ENG", + "--cycle", + "active", + "--no-interactive", + ], + denoArgs: commonDenoArgs, + async fn() { + const { cleanup } = await setupMockLinearServer([ + // Mock response for getTeamIdByKey() + { + queryName: "GetTeamIdByKey", + variables: { team: "ENG" }, + response: { + data: { + teams: { + nodes: [{ id: "team-eng-id" }], + }, + }, + }, + }, + // Mock response for getCycleIdByNameOrNumber("active") + { + queryName: "GetTeamCyclesForLookup", + variables: { teamId: "team-eng-id" }, + response: { + data: { + team: { + cycles: { + nodes: [ + { id: "cycle-1", number: 7, name: "Sprint 7" }, + { id: "cycle-2", number: 8, name: "Sprint 8" }, + ], + }, + activeCycle: { id: "cycle-1", number: 7, name: "Sprint 7" }, + }, + }, + }, + }, + // Mock response for the create issue mutation + { + queryName: "CreateIssue", + response: { + data: { + issueCreate: { + success: true, + issue: { + id: "issue-new-cycle", + identifier: "ENG-890", + url: + "https://linear.app/test-team/issue/ENG-890/test-cycle-feature", + team: { + key: "ENG", + }, + }, + }, + }, + }, + }, + ], { LINEAR_TEAM_ID: "ENG" }) + + try { + await createCommand.parse() + } finally { + await cleanup() + } + }, +}) diff --git a/test/commands/issue/issue-update.test.ts b/test/commands/issue/issue-update.test.ts index 0d1bf85..5f9f701 100644 --- a/test/commands/issue/issue-update.test.ts +++ b/test/commands/issue/issue-update.test.ts @@ -239,3 +239,73 @@ await snapshotTest({ } }, }) + +// Test updating an issue with cycle +await snapshotTest({ + name: "Issue Update Command - With Cycle", + meta: import.meta, + colors: false, + args: [ + "ENG-123", + "--cycle", + "Sprint 7", + ], + denoArgs: commonDenoArgs, + async fn() { + const { cleanup } = await setupMockLinearServer([ + // Mock response for getTeamIdByKey() + { + queryName: "GetTeamIdByKey", + variables: { team: "ENG" }, + response: { + data: { + teams: { + nodes: [{ id: "team-eng-id" }], + }, + }, + }, + }, + // Mock response for getCycleIdByNameOrNumber() + { + queryName: "GetTeamCyclesForLookup", + variables: { teamId: "team-eng-id" }, + response: { + data: { + team: { + cycles: { + nodes: [ + { id: "cycle-1", number: 7, name: "Sprint 7" }, + { id: "cycle-2", number: 8, name: "Sprint 8" }, + ], + }, + activeCycle: { id: "cycle-1", number: 7, name: "Sprint 7" }, + }, + }, + }, + }, + // Mock response for the update issue mutation + { + queryName: "UpdateIssue", + response: { + data: { + issueUpdate: { + success: true, + issue: { + id: "issue-existing-123", + identifier: "ENG-123", + url: "https://linear.app/test-team/issue/ENG-123/test-issue", + title: "Test Issue", + }, + }, + }, + }, + }, + ], { LINEAR_TEAM_ID: "ENG" }) + + try { + await updateCommand.parse() + } finally { + await cleanup() + } + }, +}) diff --git a/test/commands/issue/issue-view.test.ts b/test/commands/issue/issue-view.test.ts index e1a879f..b4e04f1 100644 --- a/test/commands/issue/issue-view.test.ts +++ b/test/commands/issue/issue-view.test.ts @@ -563,3 +563,63 @@ await snapshotTest({ } }, }) + +// Test with cycle +await snapshotTest({ + name: "Issue View Command - With Cycle", + meta: import.meta, + colors: false, + args: ["TEST-890", "--no-comments"], + denoArgs, + async fn() { + const server = new MockLinearServer([ + { + queryName: "GetIssueDetails", + variables: { id: "TEST-890" }, + response: { + data: { + issue: { + identifier: "TEST-890", + title: "Implement rate limiting", + description: "Add rate limiting to the API gateway.", + url: + "https://linear.app/test-team/issue/TEST-890/implement-rate-limiting", + branchName: "feat/test-890-rate-limiting", + state: { + name: "Todo", + color: "#e2e2e2", + }, + project: { + name: "API Gateway v2", + }, + projectMilestone: null, + cycle: { + name: "Sprint 7", + 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") + } + }, +})