diff --git a/README.md b/README.md index 3264097..a9bd475 100644 --- a/README.md +++ b/README.md @@ -113,7 +113,9 @@ linear issue list -a # open issue list in Linear.app linear issue start # create/switch to issue branch and mark as started linear issue create # create a new issue (interactive prompts) linear issue create -t "title" -d "description" # create with flags +linear issue create --project "My Project" --milestone "Phase 1" # create with milestone linear issue update # update an issue (interactive prompts) +linear issue update ENG-123 --milestone "Phase 2" # set milestone on existing issue linear issue delete # delete an issue linear issue comment list # list comments on current issue linear issue comment add # add a comment to current issue diff --git a/skills/linear-cli/references/issue.md b/skills/linear-cli/references/issue.md index 71b624f..2c495b8 100644 --- a/skills/linear-cli/references/issue.md +++ b/skills/linear-cli/references/issue.md @@ -279,6 +279,7 @@ Options: --team - Team associated with the issue (if not your default team) --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 --no-use-default-template - Do not use default template for the issue --no-interactive - Disable interactive prompts -t, --title - Title of the issue @@ -311,6 +312,7 @@ Options: --team <team> - Team associated with the issue (if not your default team) --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 -t, --title <title> - Title of the issue ``` diff --git a/src/commands/issue/issue-create.ts b/src/commands/issue/issue-create.ts index a12b94f..a0492aa 100644 --- a/src/commands/issue/issue-create.ts +++ b/src/commands/issue/issue-create.ts @@ -12,6 +12,7 @@ import { getIssueLabelIdByNameForTeam, getIssueLabelOptionsByNameForTeam, getLabelsForTeam, + getMilestoneIdByName, getProjectIdByName, getProjectOptionsByName, getTeamIdByKey, @@ -494,6 +495,10 @@ export const createCommand = new Command() "-s, --state <state:string>", "Workflow state for the issue (by name or type)", ) + .option( + "--milestone <milestone:string>", + "Name of the project milestone", + ) .option( "--no-use-default-template", "Do not use default template for the issue", @@ -516,6 +521,7 @@ export const createCommand = new Command() team, project, state, + milestone, interactive, title, }, @@ -550,7 +556,7 @@ export const createCommand = new Command() const noFlagsProvided = !title && !assignee && !dueDate && priority === undefined && estimate === undefined && !finalDescription && (!labels || labels.length === 0) && - !team && !project && !state && !start + !team && !project && !state && !milestone && !start if (noFlagsProvided && interactive) { try { @@ -738,6 +744,23 @@ export const createCommand = new Command() } } + let projectMilestoneId: string | undefined + if (milestone != null) { + if (projectId == null) { + throw new ValidationError( + "--milestone requires --project to be set", + { + suggestion: + "Use --project to specify which project the milestone belongs to.", + }, + ) + } + projectMilestoneId = await getMilestoneIdByName( + milestone, + projectId, + ) + } + // Date validation done at graphql level // Convert parent identifier if provided and fetch parent data @@ -775,6 +798,7 @@ export const createCommand = new Command() labelIds, teamId: teamId, projectId: projectId || parentData?.projectId, + projectMilestoneId, stateId, useDefaultTemplate, description: finalDescription, diff --git a/src/commands/issue/issue-update.ts b/src/commands/issue/issue-update.ts index 83b9fbd..e745ce0 100644 --- a/src/commands/issue/issue-update.ts +++ b/src/commands/issue/issue-update.ts @@ -5,6 +5,8 @@ import { getIssueId, getIssueIdentifier, getIssueLabelIdByNameForTeam, + getIssueProjectId, + getMilestoneIdByName, getProjectIdByName, getTeamIdByKey, getWorkflowStateByNameOrType, @@ -66,6 +68,10 @@ export const updateCommand = new Command() "-s, --state <state:string>", "Workflow state for the issue (by name or type)", ) + .option( + "--milestone <milestone:string>", + "Name of the project milestone", + ) .option("-t, --title <title:string>", "Title of the issue") .action( async ( @@ -81,6 +87,7 @@ export const updateCommand = new Command() team, project, state, + milestone, title, }, issueIdArg, @@ -187,6 +194,25 @@ export const updateCommand = new Command() } } + let projectMilestoneId: string | undefined + if (milestone != null) { + const milestoneProjectId = projectId ?? + await getIssueProjectId(issueId) + if (milestoneProjectId == null) { + throw new ValidationError( + "--milestone requires --project to be set (issue has no existing project)", + { + suggestion: + "Use --project to specify the project for the milestone.", + }, + ) + } + projectMilestoneId = await getMilestoneIdByName( + milestone, + milestoneProjectId, + ) + } + // Build the update input object, only including fields that were provided const input: Record<string, string | number | string[] | undefined> = {} @@ -212,6 +238,9 @@ export const updateCommand = new Command() if (labelIds.length > 0) input.labelIds = labelIds if (teamId !== undefined) input.teamId = teamId if (projectId !== undefined) input.projectId = projectId + if (projectMilestoneId !== undefined) { + input.projectMilestoneId = projectMilestoneId + } 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 69ad86d..0848fbd 100644 --- a/src/commands/issue/issue-view.ts +++ b/src/commands/issue/issue-view.ts @@ -117,7 +117,20 @@ export const viewCommand = new Command() } const { identifier } = issueData - let markdown = `# ${identifier}: ${title}${ + + // Build metadata line with project and milestone + const metaParts: string[] = [] + if (issueData.project) { + metaParts.push(`**Project:** ${issueData.project.name}`) + } + if (issueData.projectMilestone) { + metaParts.push(`**Milestone:** ${issueData.projectMilestone.name}`) + } + const metaLine = metaParts.length > 0 + ? "\n\n" + metaParts.join(" | ") + : "" + + let markdown = `# ${identifier}: ${title}${metaLine}${ description ? "\n\n" + description : "" }` diff --git a/src/utils/linear.ts b/src/utils/linear.ts index 87ba178..f7b25b3 100644 --- a/src/utils/linear.ts +++ b/src/utils/linear.ts @@ -167,6 +167,8 @@ export async function fetchIssueDetails( url: string branchName: string state: { name: string; color: string } + project?: { name: string } | null + projectMilestone?: { name: string } | null parent?: { identifier: string title: string @@ -212,6 +214,12 @@ export async function fetchIssueDetails( name color } + project { + name + } + projectMilestone { + name + } parent { identifier title @@ -275,6 +283,12 @@ export async function fetchIssueDetails( name color } + project { + name + } + projectMilestone { + name + } parent { identifier title @@ -879,6 +893,54 @@ export async function getTeamMembers(teamKey: string) { ) } +export async function getIssueProjectId( + issueIdentifier: string, +): Promise<string | undefined> { + const client = getGraphQLClient() + const query = gql(/* GraphQL */ ` + query GetIssueProjectId($id: String!) { + issue(id: $id) { + project { + id + } + } + } + `) + const data = await client.request(query, { id: issueIdentifier }) + return data.issue?.project?.id ?? undefined +} + +export async function getMilestoneIdByName( + milestoneName: string, + projectId: string, +): Promise<string> { + const client = getGraphQLClient() + const query = gql(/* GraphQL */ ` + query GetProjectMilestonesForLookup($projectId: String!) { + project(id: $projectId) { + projectMilestones { + nodes { + id + name + } + } + } + } + `) + const data = await client.request(query, { projectId }) + if (!data.project) { + throw new NotFoundError("Project", projectId) + } + const milestones = data.project.projectMilestones?.nodes || [] + const match = milestones.find( + (m) => m.name.toLowerCase() === milestoneName.toLowerCase(), + ) + if (!match) { + throw new NotFoundError("Milestone", milestoneName) + } + 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 e77aa61..3cc1658 100644 --- a/test/commands/issue/__snapshots__/issue-create.test.ts.snap +++ b/test/commands/issue/__snapshots__/issue-create.test.ts.snap @@ -24,6 +24,7 @@ Options: --team <team> - Team associated with the issue (if not your default team) --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 --no-use-default-template - Do not use default template for the issue --no-interactive - Disable interactive prompts -t, --title <title> - Title of the issue @@ -43,6 +44,16 @@ stderr: "" `; +snapshot[`Issue Create Command - With Milestone 1`] = ` +stdout: +"Creating issue in ENG + +https://linear.app/test-team/issue/ENG-789/test-milestone-feature +" +stderr: +"" +`; + snapshot[`Issue Create Command - Case Insensitive Label Matching 1`] = ` stdout: "Creating issue in ENG diff --git a/test/commands/issue/__snapshots__/issue-update.test.ts.snap b/test/commands/issue/__snapshots__/issue-update.test.ts.snap index 3db1e3a..8962dfa 100644 --- a/test/commands/issue/__snapshots__/issue-update.test.ts.snap +++ b/test/commands/issue/__snapshots__/issue-update.test.ts.snap @@ -23,6 +23,7 @@ Options: --team <team> - Team associated with the issue (if not your default team) --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 -t, --title <title> - Title of the issue " @@ -41,6 +42,17 @@ stderr: "" `; +snapshot[`Issue Update Command - With Milestone 1`] = ` +stdout: +"Updating issue ENG-123 + +✓ Updated issue ENG-123: Test Issue +https://linear.app/test-team/issue/ENG-123/test-issue +" +stderr: +"" +`; + snapshot[`Issue Update Command - Case Insensitive Label Matching 1`] = ` stdout: "Updating issue ENG-123 diff --git a/test/commands/issue/__snapshots__/issue-view.test.ts.snap b/test/commands/issue/__snapshots__/issue-view.test.ts.snap index 326f01e..3ed4cc2 100644 --- a/test/commands/issue/__snapshots__/issue-view.test.ts.snap +++ b/test/commands/issue/__snapshots__/issue-view.test.ts.snap @@ -127,6 +127,8 @@ stdout: "name": "In Progress", "color": "#f87462" }, + "project": null, + "projectMilestone": null, "parent": null, "children": [], "comments": [ @@ -183,3 +185,15 @@ Add user authentication to the application. stderr: "" `; + +snapshot[`Issue View Command - With Project And Milestone 1`] = ` +stdout: +"# TEST-789: Add monitoring dashboards + +**Project:** Platform Infrastructure Q1 | **Milestone:** Phase 2: Observability + +Set up Datadog dashboards for the new service. +" +stderr: +"" +`; diff --git a/test/commands/issue/issue-create.test.ts b/test/commands/issue/issue-create.test.ts index 33cd45c..5a8ddfc 100644 --- a/test/commands/issue/issue-create.test.ts +++ b/test/commands/issue/issue-create.test.ts @@ -94,6 +94,96 @@ await snapshotTest({ }, }) +// Test creating an issue with milestone +await snapshotTest({ + name: "Issue Create Command - With Milestone", + meta: import.meta, + colors: false, + args: [ + "--title", + "Test milestone feature", + "--team", + "ENG", + "--project", + "My Project", + "--milestone", + "Phase 1", + "--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 getProjectIdByName() + { + queryName: "GetProjectIdByName", + variables: { name: "My Project" }, + response: { + data: { + projects: { + nodes: [{ id: "project-123" }], + }, + }, + }, + }, + // Mock response for getMilestoneIdByName() + { + queryName: "GetProjectMilestonesForLookup", + variables: { projectId: "project-123" }, + response: { + data: { + project: { + projectMilestones: { + nodes: [ + { id: "milestone-1", name: "Phase 1" }, + { id: "milestone-2", name: "Phase 2" }, + ], + }, + }, + }, + }, + }, + // Mock response for the create issue mutation + { + queryName: "CreateIssue", + response: { + data: { + issueCreate: { + success: true, + issue: { + id: "issue-new-milestone", + identifier: "ENG-789", + url: + "https://linear.app/test-team/issue/ENG-789/test-milestone-feature", + team: { + key: "ENG", + }, + }, + }, + }, + }, + }, + ], { LINEAR_TEAM_ID: "ENG" }) + + try { + await createCommand.parse() + } finally { + await cleanup() + } + }, +}) + // Test creating an issue with case-insensitive label matching await snapshotTest({ name: "Issue Create Command - Case Insensitive Label Matching", diff --git a/test/commands/issue/issue-update.test.ts b/test/commands/issue/issue-update.test.ts index 5ef4b2e..0d1bf85 100644 --- a/test/commands/issue/issue-update.test.ts +++ b/test/commands/issue/issue-update.test.ts @@ -90,6 +90,89 @@ await snapshotTest({ }, }) +// Test updating an issue with milestone +await snapshotTest({ + name: "Issue Update Command - With Milestone", + meta: import.meta, + colors: false, + args: [ + "ENG-123", + "--project", + "My Project", + "--milestone", + "Phase 1", + ], + 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 getProjectIdByName() + { + queryName: "GetProjectIdByName", + variables: { name: "My Project" }, + response: { + data: { + projects: { + nodes: [{ id: "project-123" }], + }, + }, + }, + }, + // Mock response for getMilestoneIdByName() + { + queryName: "GetProjectMilestonesForLookup", + variables: { projectId: "project-123" }, + response: { + data: { + project: { + projectMilestones: { + nodes: [ + { id: "milestone-1", name: "Phase 1" }, + { id: "milestone-2", name: "Phase 2" }, + ], + }, + }, + }, + }, + }, + // 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() + } + }, +}) + // Test updating an issue with case-insensitive label matching await snapshotTest({ name: "Issue Update Command - Case Insensitive Label Matching", diff --git a/test/commands/issue/issue-view.test.ts b/test/commands/issue/issue-view.test.ts index 5c69b3d..e1a879f 100644 --- a/test/commands/issue/issue-view.test.ts +++ b/test/commands/issue/issue-view.test.ts @@ -48,6 +48,8 @@ await snapshotTest({ name: "In Progress", color: "#f87462", }, + project: null, + projectMilestone: null, parent: null, children: { nodes: [], @@ -104,6 +106,8 @@ await snapshotTest({ name: "In Progress", color: "#f87462", }, + project: null, + projectMilestone: null, parent: null, children: { nodes: [], @@ -157,6 +161,8 @@ await snapshotTest({ name: "In Progress", color: "#f87462", }, + project: null, + projectMilestone: null, parent: null, children: { nodes: [], @@ -355,6 +361,8 @@ await snapshotTest({ name: "In Progress", color: "#f87462", }, + project: null, + projectMilestone: null, parent: null, children: { nodes: [], @@ -437,6 +445,8 @@ await snapshotTest({ name: "In Progress", color: "#f87462", }, + project: null, + projectMilestone: null, parent: { identifier: "TEST-100", title: "Epic: Security Improvements", @@ -495,3 +505,61 @@ await snapshotTest({ } }, }) + +// Test with project and milestone +await snapshotTest({ + name: "Issue View Command - With Project And Milestone", + meta: import.meta, + colors: false, + args: ["TEST-789", "--no-comments"], + denoArgs, + async fn() { + const server = new MockLinearServer([ + { + queryName: "GetIssueDetails", + variables: { id: "TEST-789" }, + response: { + data: { + issue: { + identifier: "TEST-789", + title: "Add monitoring dashboards", + description: "Set up Datadog dashboards for the new service.", + url: + "https://linear.app/test-team/issue/TEST-789/add-monitoring-dashboards", + branchName: "feat/test-789-monitoring", + state: { + name: "In Progress", + color: "#f87462", + }, + project: { + name: "Platform Infrastructure Q1", + }, + projectMilestone: { + name: "Phase 2: Observability", + }, + 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") + } + }, +})