Skip to content

Commit 77aa22b

Browse files
authored
feat: add milestone management commands (#92)
## Summary Adds comprehensive milestone management functionality to linear-cli, enabling full CRUD operations for Linear project milestones. ## Motivation The Linear API supports project milestones, but the CLI didn't expose this functionality. This PR adds milestone management commands to complement the existing project and issue commands. ## Changes ### New Commands - `linear milestone list --project <id>` - List milestones for a project - `linear milestone create` - Create new project milestones - `linear milestone update <id>` - Update existing milestones - `linear milestone delete <id>` - Delete milestones with confirmation - Short alias: `linear m` for all milestone commands ### Features - Full CRUD operations for project milestones - Table-formatted output for list command - Interactive confirmation prompts for deletion (with `--force` option to skip) - Support for milestone name, description, and target date - Follows existing CLI patterns and conventions ### Implementation - Uses GraphQL Code Generator for type-safe queries/mutations - Built with Cliffy for command structure and prompts - Leverages existing graphql client utilities - Includes spinner for loading states - Updated README with usage examples ## Testing All commands have been tested with the Linear API: - ✅ List milestones for a project (empty and populated) - ✅ Create milestones with name, description, target date - ✅ Update milestone properties - ✅ Delete milestones with confirmation prompt - ✅ Delete with `--force` flag (skip confirmation) ## Documentation Updated README.md with: - Milestone commands section - Usage examples for all operations - Command aliases ## Example Usage ```bash # List milestones linear milestone list --project abc123 linear m list --project abc123 # alias # Create a milestone linear milestone create --project abc123 --name "Q1 Goals" --target-date "2026-03-31" linear m create --project abc123 # interactive mode # Update a milestone linear milestone update xyz789 --name "Q1 Objectives" linear m update xyz789 --target-date "2026-04-15" # Delete a milestone linear milestone delete xyz789 linear m delete xyz789 --force # skip confirmation ```
1 parent 0a732c3 commit 77aa22b

19 files changed

Lines changed: 1580 additions & 0 deletions

README.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,21 @@ linear project list # list projects
141141
linear project view # view project details
142142
```
143143

144+
### milestone commands
145+
146+
```bash
147+
linear milestone list --project <projectId> # list milestones for a project
148+
linear m list --project <projectId> # list milestones (alias)
149+
linear milestone view <milestoneId> # view milestone details
150+
linear m view <milestoneId> # view milestone (alias)
151+
linear milestone create --project <projectId> --name "Q1 Goals" --target-date "2026-03-31" # create a milestone
152+
linear m create --project <projectId> # create a milestone (interactive)
153+
linear milestone update <milestoneId> --name "New Name" # update milestone name
154+
linear m update <milestoneId> --target-date "2026-04-15" # update target date
155+
linear milestone delete <milestoneId> # delete a milestone
156+
linear m delete <milestoneId> --force # delete without confirmation
157+
```
158+
144159
### other commands
145160

146161
```bash
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { Command } from "@cliffy/command"
2+
import { gql } from "../../__codegen__/gql.ts"
3+
import { getGraphQLClient } from "../../utils/graphql.ts"
4+
import { resolveProjectId } from "../../utils/linear.ts"
5+
6+
const CreateProjectMilestone = gql(`
7+
mutation CreateProjectMilestone($input: ProjectMilestoneCreateInput!) {
8+
projectMilestoneCreate(input: $input) {
9+
success
10+
projectMilestone {
11+
id
12+
name
13+
targetDate
14+
project {
15+
id
16+
name
17+
}
18+
}
19+
}
20+
}
21+
`)
22+
23+
export const createCommand = new Command()
24+
.name("create")
25+
.description("Create a new project milestone")
26+
.option("--project <projectId:string>", "Project ID", { required: true })
27+
.option("--name <name:string>", "Milestone name", { required: true })
28+
.option("--description <description:string>", "Milestone description")
29+
.option("--target-date <date:string>", "Target date (YYYY-MM-DD)")
30+
.action(
31+
async ({ project: projectIdOrSlug, name, description, targetDate }) => {
32+
const { Spinner } = await import("@std/cli/unstable-spinner")
33+
const showSpinner = Deno.stdout.isTerminal()
34+
const spinner = showSpinner ? new Spinner() : null
35+
spinner?.start()
36+
37+
try {
38+
// Resolve project slug to full UUID
39+
const projectId = await resolveProjectId(projectIdOrSlug)
40+
41+
const client = getGraphQLClient()
42+
const result = await client.request(CreateProjectMilestone, {
43+
input: {
44+
projectId,
45+
name,
46+
description,
47+
targetDate,
48+
},
49+
})
50+
spinner?.stop()
51+
52+
if (result.projectMilestoneCreate.success) {
53+
const milestone = result.projectMilestoneCreate.projectMilestone
54+
if (milestone) {
55+
console.log(`✓ Created milestone: ${milestone.name}`)
56+
console.log(` ID: ${milestone.id}`)
57+
if (milestone.targetDate) {
58+
console.log(` Target Date: ${milestone.targetDate}`)
59+
}
60+
console.log(` Project: ${milestone.project.name}`)
61+
}
62+
} else {
63+
console.error("✗ Failed to create milestone")
64+
Deno.exit(1)
65+
}
66+
} catch (error) {
67+
spinner?.stop()
68+
console.error("Failed to create milestone:", error)
69+
Deno.exit(1)
70+
}
71+
},
72+
)
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { Command } from "@cliffy/command"
2+
import { Confirm } from "@cliffy/prompt"
3+
import { gql } from "../../__codegen__/gql.ts"
4+
import { getGraphQLClient } from "../../utils/graphql.ts"
5+
6+
const DeleteProjectMilestone = gql(`
7+
mutation DeleteProjectMilestone($id: String!) {
8+
projectMilestoneDelete(id: $id) {
9+
success
10+
}
11+
}
12+
`)
13+
14+
export const deleteCommand = new Command()
15+
.name("delete")
16+
.description("Delete a project milestone")
17+
.arguments("<id:string>")
18+
.option("-f, --force", "Skip confirmation prompt")
19+
.action(async ({ force }, id) => {
20+
// Confirmation prompt unless --force is used
21+
if (!force) {
22+
const confirmed = await Confirm.prompt({
23+
message: `Are you sure you want to delete milestone ${id}?`,
24+
default: false,
25+
})
26+
27+
if (!confirmed) {
28+
console.log("Deletion canceled")
29+
return
30+
}
31+
}
32+
33+
const { Spinner } = await import("@std/cli/unstable-spinner")
34+
const showSpinner = Deno.stdout.isTerminal()
35+
const spinner = showSpinner ? new Spinner() : null
36+
spinner?.start()
37+
38+
try {
39+
const client = getGraphQLClient()
40+
const result = await client.request(DeleteProjectMilestone, {
41+
id,
42+
})
43+
spinner?.stop()
44+
45+
if (result.projectMilestoneDelete.success) {
46+
console.log(`✓ Deleted milestone ${id}`)
47+
} else {
48+
console.error("✗ Failed to delete milestone")
49+
Deno.exit(1)
50+
}
51+
} catch (error) {
52+
spinner?.stop()
53+
console.error("Failed to delete milestone:", error)
54+
Deno.exit(1)
55+
}
56+
})
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import { Command } from "@cliffy/command"
2+
import { unicodeWidth } from "@std/cli"
3+
import { gql } from "../../__codegen__/gql.ts"
4+
import { getGraphQLClient } from "../../utils/graphql.ts"
5+
import { padDisplay } from "../../utils/display.ts"
6+
import { resolveProjectId } from "../../utils/linear.ts"
7+
8+
const GetProjectMilestones = gql(`
9+
query GetProjectMilestones($projectId: String!) {
10+
project(id: $projectId) {
11+
id
12+
name
13+
projectMilestones {
14+
nodes {
15+
id
16+
name
17+
targetDate
18+
sortOrder
19+
project {
20+
id
21+
name
22+
}
23+
}
24+
}
25+
}
26+
}
27+
`)
28+
29+
export const listCommand = new Command()
30+
.name("list")
31+
.description("List milestones for a project")
32+
.option("--project <projectId:string>", "Project ID", { required: true })
33+
.action(async ({ project: projectIdOrSlug }) => {
34+
const { Spinner } = await import("@std/cli/unstable-spinner")
35+
const showSpinner = Deno.stdout.isTerminal()
36+
const spinner = showSpinner ? new Spinner() : null
37+
spinner?.start()
38+
39+
try {
40+
// Resolve project slug to full UUID
41+
const projectId = await resolveProjectId(projectIdOrSlug)
42+
43+
const client = getGraphQLClient()
44+
const result = await client.request(GetProjectMilestones, {
45+
projectId,
46+
})
47+
spinner?.stop()
48+
49+
const milestones = result.project?.projectMilestones?.nodes || []
50+
51+
if (milestones.length === 0) {
52+
console.log("No milestones found for this project.")
53+
return
54+
}
55+
56+
// Sort milestones by targetDate (nulls last) then by name
57+
const sortedMilestones = milestones.sort((a, b) => {
58+
if (!a.targetDate && !b.targetDate) return a.name.localeCompare(b.name)
59+
if (!a.targetDate) return 1
60+
if (!b.targetDate) return -1
61+
const dateComparison = a.targetDate.localeCompare(b.targetDate)
62+
return dateComparison !== 0
63+
? dateComparison
64+
: a.name.localeCompare(b.name)
65+
})
66+
67+
// Calculate column widths
68+
const { columns } = Deno.stdout.isTerminal()
69+
? Deno.consoleSize()
70+
: { columns: 120 }
71+
72+
const ID_WIDTH = 36 // UUID format
73+
const TARGET_DATE_WIDTH = 12 // "YYYY-MM-DD" format or "No date"
74+
const PROJECT_WIDTH = Math.min(
75+
30,
76+
Math.max(
77+
7, // minimum width for "PROJECT" header
78+
...sortedMilestones.map((m) => unicodeWidth(m.project.name)),
79+
),
80+
)
81+
82+
const SPACE_WIDTH = 4
83+
const fixed = ID_WIDTH + TARGET_DATE_WIDTH + PROJECT_WIDTH + SPACE_WIDTH
84+
const PADDING = 1
85+
const maxNameWidth = Math.max(
86+
...sortedMilestones.map((m) => unicodeWidth(m.name)),
87+
)
88+
const availableWidth = Math.max(columns - PADDING - fixed, 0)
89+
const nameWidth = Math.min(maxNameWidth, availableWidth)
90+
91+
// Print header
92+
const headerCells = [
93+
padDisplay("NAME", nameWidth),
94+
padDisplay("ID", ID_WIDTH),
95+
padDisplay("TARGET DATE", TARGET_DATE_WIDTH),
96+
padDisplay("PROJECT", PROJECT_WIDTH),
97+
]
98+
99+
let headerMsg = ""
100+
const headerStyles: string[] = []
101+
headerCells.forEach((cell, index) => {
102+
headerMsg += `%c${cell}`
103+
headerStyles.push("text-decoration: underline")
104+
if (index < headerCells.length - 1) {
105+
headerMsg += "%c %c"
106+
headerStyles.push("text-decoration: none")
107+
headerStyles.push("text-decoration: underline")
108+
}
109+
})
110+
console.log(headerMsg, ...headerStyles)
111+
112+
// Print each milestone
113+
for (const milestone of sortedMilestones) {
114+
const targetDate = milestone.targetDate || "No date"
115+
const projectName = milestone.project.name.length > PROJECT_WIDTH
116+
? milestone.project.name.slice(0, PROJECT_WIDTH - 3) + "..."
117+
: padDisplay(milestone.project.name, PROJECT_WIDTH)
118+
119+
const truncName = milestone.name.length > nameWidth
120+
? milestone.name.slice(0, nameWidth - 3) + "..."
121+
: padDisplay(milestone.name, nameWidth)
122+
123+
console.log(
124+
`${truncName} ${padDisplay(milestone.id, ID_WIDTH)} ${
125+
padDisplay(targetDate, TARGET_DATE_WIDTH)
126+
} ${projectName}`,
127+
)
128+
}
129+
} catch (error) {
130+
spinner?.stop()
131+
console.error("Failed to fetch milestones:", error)
132+
Deno.exit(1)
133+
}
134+
})
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import { Command } from "@cliffy/command"
2+
import { gql } from "../../__codegen__/gql.ts"
3+
import { getGraphQLClient } from "../../utils/graphql.ts"
4+
import { resolveProjectId } from "../../utils/linear.ts"
5+
6+
const UpdateProjectMilestone = gql(`
7+
mutation UpdateProjectMilestone($id: String!, $input: ProjectMilestoneUpdateInput!) {
8+
projectMilestoneUpdate(id: $id, input: $input) {
9+
success
10+
projectMilestone {
11+
id
12+
name
13+
targetDate
14+
project {
15+
id
16+
name
17+
}
18+
}
19+
}
20+
}
21+
`)
22+
23+
export const updateCommand = new Command()
24+
.name("update")
25+
.description("Update an existing project milestone")
26+
.arguments("<id:string>")
27+
.option("--name <name:string>", "Milestone name")
28+
.option("--description <description:string>", "Milestone description")
29+
.option("--target-date <date:string>", "Target date (YYYY-MM-DD)")
30+
.option("--project <projectId:string>", "Move to a different project")
31+
.action(
32+
async ({ name, description, targetDate, project: projectIdOrSlug }, id) => {
33+
// Check if at least one update option is provided
34+
if (!name && !description && !targetDate && !projectIdOrSlug) {
35+
console.error("✗ At least one update option must be provided")
36+
console.error(
37+
" Use --name, --description, --target-date, or --project",
38+
)
39+
Deno.exit(1)
40+
}
41+
42+
const { Spinner } = await import("@std/cli/unstable-spinner")
43+
const showSpinner = Deno.stdout.isTerminal()
44+
const spinner = showSpinner ? new Spinner() : null
45+
spinner?.start()
46+
47+
try {
48+
const client = getGraphQLClient()
49+
const input: Record<string, unknown> = {}
50+
51+
if (name) input.name = name
52+
if (description) input.description = description
53+
if (targetDate) input.targetDate = targetDate
54+
if (projectIdOrSlug) {
55+
// Resolve project slug to full UUID
56+
input.projectId = await resolveProjectId(projectIdOrSlug)
57+
}
58+
59+
const result = await client.request(UpdateProjectMilestone, {
60+
id,
61+
input,
62+
})
63+
spinner?.stop()
64+
65+
if (result.projectMilestoneUpdate.success) {
66+
const milestone = result.projectMilestoneUpdate.projectMilestone
67+
if (milestone) {
68+
console.log(`✓ Updated milestone: ${milestone.name}`)
69+
console.log(` ID: ${milestone.id}`)
70+
if (milestone.targetDate) {
71+
console.log(` Target Date: ${milestone.targetDate}`)
72+
}
73+
console.log(` Project: ${milestone.project.name}`)
74+
}
75+
} else {
76+
console.error("✗ Failed to update milestone")
77+
Deno.exit(1)
78+
}
79+
} catch (error) {
80+
spinner?.stop()
81+
console.error("Failed to update milestone:", error)
82+
Deno.exit(1)
83+
}
84+
},
85+
)

0 commit comments

Comments
 (0)