Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 71 additions & 25 deletions src/commands/milestone/milestone-view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,11 @@ import { formatRelativeTime } from "../../utils/display.ts"
import { shouldShowSpinner } from "../../utils/hyperlink.ts"
import { handleError, NotFoundError } from "../../utils/errors.ts"

const PAGE_SIZE = 50
const LIST_PREVIEW = 10

const GetMilestoneDetails = gql(`
query GetMilestoneDetails($id: String!) {
query GetMilestoneDetails($id: String!, $first: Int!, $after: String) {
projectMilestone(id: $id) {
id
name
Expand All @@ -22,7 +25,7 @@ const GetMilestoneDetails = gql(`
slugId
url
}
issues {
issues(first: $first, after: $after) {
nodes {
id
identifier
Expand All @@ -32,50 +35,81 @@ const GetMilestoneDetails = gql(`
type
}
}
pageInfo {
hasNextPage
endCursor
}
}
}
}
`)

export const viewCommand = new Command()
.name("view")
.description("View milestone details")
.description(
"View milestone details. By default lists the first " +
LIST_PREVIEW +
" attached issues from the first page of " + PAGE_SIZE +
"; use --all to paginate the full set.",
)
.alias("v")
.arguments("<milestoneId:string>")
.action(async (_options, milestoneId) => {
.option(
"--all",
"Fetch and list every issue attached to the milestone (paginates the Linear API).",
)
.action(async ({ all }, milestoneId) => {
const { Spinner } = await import("@std/cli/unstable-spinner")
const showSpinner = shouldShowSpinner()
const spinner = showSpinner ? new Spinner() : null
spinner?.start()

try {
const client = getGraphQLClient()
const result = await client.request(GetMilestoneDetails, {
const firstPage = await client.request(GetMilestoneDetails, {
id: milestoneId,
first: PAGE_SIZE,
})
spinner?.stop()

const milestone = result.projectMilestone
const milestone = firstPage.projectMilestone
if (!milestone) {
spinner?.stop()
throw new NotFoundError("Milestone", milestoneId)
}

// Build the display
const issues = [...milestone.issues.nodes]
let pageInfo = milestone.issues.pageInfo

if (all) {
while (pageInfo.hasNextPage && pageInfo.endCursor) {
const nextPage = await client.request(GetMilestoneDetails, {
id: milestoneId,
first: PAGE_SIZE,
after: pageInfo.endCursor,
})
const next = nextPage.projectMilestone
if (next == null) break
issues.push(...next.issues.nodes)
pageInfo = next.issues.pageInfo
}
}

spinner?.stop()

const truncated = !all && pageInfo.hasNextPage

const lines: string[] = []

// Title
lines.push(`# ${milestone.name}`)
lines.push("")

// Basic info
lines.push(`**ID:** ${milestone.id}`)
if (milestone.targetDate) {
lines.push(`**Target Date:** ${milestone.targetDate}`)
} else {
lines.push(`**Target Date:** Not set`)
}

// Project info
lines.push(
`**Project:** ${milestone.project.name} (${milestone.project.slugId})`,
)
Expand All @@ -85,21 +119,19 @@ export const viewCommand = new Command()
lines.push(`**Created:** ${formatRelativeTime(milestone.createdAt)}`)
lines.push(`**Updated:** ${formatRelativeTime(milestone.updatedAt)}`)

// Description
if (milestone.description) {
lines.push("")
lines.push("## Description")
lines.push("")
lines.push(milestone.description)
}

// Issue summary
if (milestone.issues.nodes.length > 0) {
if (issues.length > 0) {
lines.push("")
lines.push("## Issues")
lines.push("")

const issuesByState = milestone.issues.nodes.reduce(
const issuesByState = issues.reduce(
(acc: Record<string, number>, issue) => {
const stateType = issue.state.type
if (!acc[stateType]) acc[stateType] = 0
Expand All @@ -109,37 +141,51 @@ export const viewCommand = new Command()
{} as Record<string, number>,
)

const total = milestone.issues.nodes.length
const fetched = issues.length
const completed = issuesByState.completed || 0
const started = issuesByState.started || 0
const unstarted = issuesByState.unstarted || 0
const canceled = issuesByState.canceled || 0
const backlog = issuesByState.backlog || 0
const triage = issuesByState.triage || 0

lines.push(`**Total Issues:** ${total}`)
if (truncated) {
lines.push(
`**Issues fetched:** ${fetched} (milestone has more — use \`--all\` for full counts)`,
)
} else {
lines.push(`**Total Issues:** ${fetched}`)
}
if (completed > 0) lines.push(`**Completed:** ${completed}`)
if (started > 0) lines.push(`**In Progress:** ${started}`)
if (unstarted > 0) lines.push(`**To Do:** ${unstarted}`)
if (backlog > 0) lines.push(`**Backlog:** ${backlog}`)
if (triage > 0) lines.push(`**Triage:** ${triage}`)
if (canceled > 0) lines.push(`**Canceled:** ${canceled}`)

// List first 10 issues
const listed = all ? issues : issues.slice(0, LIST_PREVIEW)
lines.push("")
lines.push("**Recent Issues:**")
lines.push(all ? "**All Issues:**" : "**Recent Issues:**")
lines.push("")
milestone.issues.nodes.slice(0, 10).forEach((issue) => {
listed.forEach((issue) => {
lines.push(
`- ${issue.identifier}: ${issue.title} (${issue.state.name})`,
)
})

if (milestone.issues.nodes.length > 10) {
lines.push("")
lines.push(
`_...and ${milestone.issues.nodes.length - 10} more issues_`,
)
if (!all) {
const hiddenLoaded = Math.max(fetched - LIST_PREVIEW, 0)
if (truncated) {
lines.push("")
lines.push(
`_Showing ${Math.min(LIST_PREVIEW, fetched)} of ${fetched}+ issues — the milestone contains more than ${PAGE_SIZE}. Re-run with \`--all\` or use \`linear issue query --milestone ${milestone.id} --json\` for the full list._`,
)
} else if (hiddenLoaded > 0) {
lines.push("")
lines.push(
`_...and ${hiddenLoaded} more issue${hiddenLoaded === 1 ? "" : "s"}. Re-run with \`--all\` or use \`linear issue query --milestone ${milestone.id} --json\` to see them all._`,
)
}
}
} else {
lines.push("")
Expand Down
122 changes: 118 additions & 4 deletions test/commands/milestone/__snapshots__/milestone-view.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,12 @@ Usage: view <milestoneId>

Description:

View milestone details
View milestone details. By default lists the first 10 attached issues from the first page of 50; use --all to paginate the full set.

Options:

-h, --help - Show this help.
-h, --help - Show this help.
--all - Fetch and list every issue attached to the milestone (paginates the Linear API).

"
stderr:
Expand Down Expand Up @@ -69,7 +70,7 @@ stderr:
""
`;

snapshot[`Milestone View Command - Many Issues 1`] = `
snapshot[`Milestone View Command - Many Issues (default lists 10) 1`] = `
stdout:
"# Big Release

Expand Down Expand Up @@ -105,7 +106,120 @@ Major product release with many features
- PROD-9: Feature 9 (In Progress)
- PROD-10: Feature 10 (In Progress)

_...and 5 more issues_
_...and 5 more issues. Re-run with \`--all\` or use \`linear issue query --milestone milestone-456 --json\` to see them all._
"
stderr:
""
`;

snapshot[`Milestone View Command - Truncated (more pages available) 1`] = `
stdout:
"# Huge Milestone

**ID:** milestone-trunc
**Target Date:** Not set
**Project:** P (p)
**Project URL:** https://linear.app/test/project/p

**Created:** 12/31/2019
**Updated:** 1/1/2020

## Issues

**Issues fetched:** 50 (milestone has more — use \`--all\` for full counts)
**To Do:** 50

**Recent Issues:**

- BIG-1: Issue 1 (Todo)
- BIG-2: Issue 2 (Todo)
- BIG-3: Issue 3 (Todo)
- BIG-4: Issue 4 (Todo)
- BIG-5: Issue 5 (Todo)
- BIG-6: Issue 6 (Todo)
- BIG-7: Issue 7 (Todo)
- BIG-8: Issue 8 (Todo)
- BIG-9: Issue 9 (Todo)
- BIG-10: Issue 10 (Todo)

_Showing 10 of 50+ issues — the milestone contains more than 50. Re-run with \`--all\` or use \`linear issue query --milestone milestone-trunc --json\` for the full list._
"
stderr:
""
`;

snapshot[`Milestone View Command - --all paginates 1`] = `
stdout:
"# Huge Milestone

**ID:** milestone-trunc
**Target Date:** Not set
**Project:** P (p)
**Project URL:** https://linear.app/test/project/p

**Created:** 12/31/2019
**Updated:** 1/1/2020

## Issues

**Total Issues:** 52
**Completed:** 2
**To Do:** 50

**All Issues:**

- BIG-1: Issue 1 (Todo)
- BIG-2: Issue 2 (Todo)
- BIG-3: Issue 3 (Todo)
- BIG-4: Issue 4 (Todo)
- BIG-5: Issue 5 (Todo)
- BIG-6: Issue 6 (Todo)
- BIG-7: Issue 7 (Todo)
- BIG-8: Issue 8 (Todo)
- BIG-9: Issue 9 (Todo)
- BIG-10: Issue 10 (Todo)
- BIG-11: Issue 11 (Todo)
- BIG-12: Issue 12 (Todo)
- BIG-13: Issue 13 (Todo)
- BIG-14: Issue 14 (Todo)
- BIG-15: Issue 15 (Todo)
- BIG-16: Issue 16 (Todo)
- BIG-17: Issue 17 (Todo)
- BIG-18: Issue 18 (Todo)
- BIG-19: Issue 19 (Todo)
- BIG-20: Issue 20 (Todo)
- BIG-21: Issue 21 (Todo)
- BIG-22: Issue 22 (Todo)
- BIG-23: Issue 23 (Todo)
- BIG-24: Issue 24 (Todo)
- BIG-25: Issue 25 (Todo)
- BIG-26: Issue 26 (Todo)
- BIG-27: Issue 27 (Todo)
- BIG-28: Issue 28 (Todo)
- BIG-29: Issue 29 (Todo)
- BIG-30: Issue 30 (Todo)
- BIG-31: Issue 31 (Todo)
- BIG-32: Issue 32 (Todo)
- BIG-33: Issue 33 (Todo)
- BIG-34: Issue 34 (Todo)
- BIG-35: Issue 35 (Todo)
- BIG-36: Issue 36 (Todo)
- BIG-37: Issue 37 (Todo)
- BIG-38: Issue 38 (Todo)
- BIG-39: Issue 39 (Todo)
- BIG-40: Issue 40 (Todo)
- BIG-41: Issue 41 (Todo)
- BIG-42: Issue 42 (Todo)
- BIG-43: Issue 43 (Todo)
- BIG-44: Issue 44 (Todo)
- BIG-45: Issue 45 (Todo)
- BIG-46: Issue 46 (Todo)
- BIG-47: Issue 47 (Todo)
- BIG-48: Issue 48 (Todo)
- BIG-49: Issue 49 (Todo)
- BIG-50: Issue 50 (Todo)
- BIG-51: Issue 51 (Done)
- BIG-52: Issue 52 (Done)
"
stderr:
""
Expand Down
Loading