diff --git a/src/commands/milestone/milestone-view.ts b/src/commands/milestone/milestone-view.ts index 8aa6e13c..5ff3fb8a 100644 --- a/src/commands/milestone/milestone-view.ts +++ b/src/commands/milestone/milestone-view.ts @@ -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 @@ -22,7 +25,7 @@ const GetMilestoneDetails = gql(` slugId url } - issues { + issues(first: $first, after: $after) { nodes { id identifier @@ -32,6 +35,10 @@ const GetMilestoneDetails = gql(` type } } + pageInfo { + hasNextPage + endCursor + } } } } @@ -39,10 +46,19 @@ const GetMilestoneDetails = gql(` 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("") - .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 @@ -50,24 +66,43 @@ export const viewCommand = new Command() 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}`) @@ -75,7 +110,6 @@ export const viewCommand = new Command() lines.push(`**Target Date:** Not set`) } - // Project info lines.push( `**Project:** ${milestone.project.name} (${milestone.project.slugId})`, ) @@ -85,7 +119,6 @@ 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") @@ -93,13 +126,12 @@ export const viewCommand = new Command() 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, issue) => { const stateType = issue.state.type if (!acc[stateType]) acc[stateType] = 0 @@ -109,7 +141,7 @@ export const viewCommand = new Command() {} as Record, ) - 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 @@ -117,7 +149,13 @@ export const viewCommand = new Command() 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}`) @@ -125,21 +163,29 @@ export const viewCommand = new Command() 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("") diff --git a/test/commands/milestone/__snapshots__/milestone-view.test.ts.snap b/test/commands/milestone/__snapshots__/milestone-view.test.ts.snap index 91d6a222..b5b45f7e 100644 --- a/test/commands/milestone/__snapshots__/milestone-view.test.ts.snap +++ b/test/commands/milestone/__snapshots__/milestone-view.test.ts.snap @@ -7,11 +7,12 @@ Usage: view 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: @@ -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 @@ -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: "" diff --git a/test/commands/milestone/milestone-view.test.ts b/test/commands/milestone/milestone-view.test.ts index ead71000..4347297b 100644 --- a/test/commands/milestone/milestone-view.test.ts +++ b/test/commands/milestone/milestone-view.test.ts @@ -48,30 +48,22 @@ await snapshotTest({ id: "issue-1", identifier: "ENG-123", title: "Implement authentication", - state: { - name: "In Progress", - type: "started", - }, + state: { name: "In Progress", type: "started" }, }, { id: "issue-2", identifier: "ENG-124", title: "Setup database", - state: { - name: "Done", - type: "completed", - }, + state: { name: "Done", type: "completed" }, }, { id: "issue-3", identifier: "ENG-125", title: "Create API endpoints", - state: { - name: "Todo", - type: "unstarted", - }, + state: { name: "Todo", type: "unstarted" }, }, ], + pageInfo: { hasNextPage: false, endCursor: null }, }, }, }, @@ -122,6 +114,7 @@ await snapshotTest({ }, issues: { nodes: [], + pageInfo: { hasNextPage: false, endCursor: null }, }, }, }, @@ -143,9 +136,9 @@ await snapshotTest({ }, }) -// Test with many issues (>10) +// Test default behavior with 15 fetched issues (single page, list slice to 10). await snapshotTest({ - name: "Milestone View Command - Many Issues", + name: "Milestone View Command - Many Issues (default lists 10)", meta: import.meta, colors: false, args: ["milestone-456"], @@ -184,6 +177,159 @@ await snapshotTest({ : "unstarted", }, })), + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }, + }, + }, + }, + ]) + + 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") + } + }, +}) + +// Test that hasNextPage = true triggers the explicit truncation footer +// (this is the core regression: silent capping when the API has more pages). +await snapshotTest({ + name: "Milestone View Command - Truncated (more pages available)", + meta: import.meta, + colors: false, + args: ["milestone-trunc"], + denoArgs: commonDenoArgs, + async fn() { + const server = new MockLinearServer([ + { + queryName: "GetMilestoneDetails", + variables: { id: "milestone-trunc", first: 50 }, + response: { + data: { + projectMilestone: { + id: "milestone-trunc", + name: "Huge Milestone", + description: null, + targetDate: null, + sortOrder: 1, + createdAt: "2020-01-01T00:00:00Z", + updatedAt: "2020-01-02T00:00:00Z", + project: { + id: "project-1", + name: "P", + slugId: "p", + url: "https://linear.app/test/project/p", + }, + issues: { + nodes: Array.from({ length: 50 }, (_, i) => ({ + id: `id-${i}`, + identifier: `BIG-${i + 1}`, + title: `Issue ${i + 1}`, + state: { name: "Todo", type: "unstarted" }, + })), + pageInfo: { hasNextPage: true, endCursor: "cursor-1" }, + }, + }, + }, + }, + }, + ]) + + 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") + } + }, +}) + +// Test --all paginates through subsequent pages. The more-specific (with `after`) +// mock is listed first so the mock matcher's subset semantics route page 2 correctly. +await snapshotTest({ + name: "Milestone View Command - --all paginates", + meta: import.meta, + colors: false, + args: ["milestone-trunc", "--all"], + denoArgs: commonDenoArgs, + async fn() { + const server = new MockLinearServer([ + { + queryName: "GetMilestoneDetails", + variables: { + id: "milestone-trunc", + first: 50, + after: "cursor-1", + }, + response: { + data: { + projectMilestone: { + id: "milestone-trunc", + name: "Huge Milestone", + description: null, + targetDate: null, + sortOrder: 1, + createdAt: "2020-01-01T00:00:00Z", + updatedAt: "2020-01-02T00:00:00Z", + project: { + id: "project-1", + name: "P", + slugId: "p", + url: "https://linear.app/test/project/p", + }, + issues: { + nodes: Array.from({ length: 2 }, (_, i) => ({ + id: `id-${50 + i}`, + identifier: `BIG-${51 + i}`, + title: `Issue ${51 + i}`, + state: { name: "Done", type: "completed" }, + })), + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }, + }, + }, + }, + { + queryName: "GetMilestoneDetails", + variables: { id: "milestone-trunc", first: 50 }, + response: { + data: { + projectMilestone: { + id: "milestone-trunc", + name: "Huge Milestone", + description: null, + targetDate: null, + sortOrder: 1, + createdAt: "2020-01-01T00:00:00Z", + updatedAt: "2020-01-02T00:00:00Z", + project: { + id: "project-1", + name: "P", + slugId: "p", + url: "https://linear.app/test/project/p", + }, + issues: { + nodes: Array.from({ length: 50 }, (_, i) => ({ + id: `id-${i}`, + identifier: `BIG-${i + 1}`, + title: `Issue ${i + 1}`, + state: { name: "Todo", type: "unstarted" }, + })), + pageInfo: { hasNextPage: true, endCursor: "cursor-1" }, }, }, },