From de891be93c6411df908322ba912a89126e73ff49 Mon Sep 17 00:00:00 2001 From: Peter Schilling Date: Thu, 3 Sep 2026 13:48:49 -0700 Subject: [PATCH] Add --json to team list, cycle list/view, milestone list/view, project view Scripts need to map a team name to its key and id, and team list was the only way to see both, so they had to scrape its table. It was also the last list command without JSON. The maintainer asked to sweep the other commands in the same state; the survey found cycle list, cycle view, milestone list, milestone view, and project view, so all six get -j, --json here. issue mine stays human-only on purpose (issue query is its JSON surface). The reporter proposed a five-field subset for team list. The JSON instead carries every field the query already selects, per the repository rule to preserve GraphQL names and nesting rather than invent CLI shapes. Lists emit { nodes, pageInfo } after the same filtering and ordering as the table, so archived teams stay hidden and cycles stay newest-first. Views emit the object as fetched, including every issue rather than the ten-item preview, and milestone view --all --json includes every page. Two of these queries took Linear's default page with no cursor: cycle list and milestone list silently dropped everything past fifty. Adding JSON would have made that easier to consume without making it safer, so both now paginate and fail loudly if Linear advertises a page without a cursor. The view queries gain pageInfo on their issues connection so callers can see when a page was partial. A 2.0.0 changelog entry claimed cycle list --json; that merge only touched SVG files, so this is the first time it ships. Github-Issue: Fixes #276 Github-Issue-Url: https://github.com/schpet/linear-cli/issues/276 Claude-Session: https://claude.ai/code/session_01A9qEGri4p2HZMQSuYsBmub --- CHANGELOG.md | 8 + README.md | 13 ++ docs/usage.md | 2 + skills/linear-cli/SKILL.md | 2 +- skills/linear-cli/SKILL.template.md | 2 +- skills/linear-cli/references/cycle.md | 6 +- skills/linear-cli/references/milestone.md | 4 +- skills/linear-cli/references/project.md | 3 +- skills/linear-cli/references/team.md | 3 +- src/commands/cycle/cycle-list.ts | 62 +++++- src/commands/cycle/cycle-view.ts | 14 +- src/commands/milestone/milestone-list.ts | 69 ++++-- src/commands/milestone/milestone-view.ts | 18 +- src/commands/project/project-view.ts | 14 +- src/commands/team/team-list.ts | 44 ++-- .../__snapshots__/cycle-list.test.ts.snap | 89 ++++++++ .../__snapshots__/cycle-view.test.ts.snap | 143 +++++++++++++ test/commands/cycle/cycle-list.test.ts | 202 +++++++++++++++++- test/commands/cycle/cycle-view.test.ts | 88 ++++++++ .../__snapshots__/milestone-list.test.ts.snap | 105 +++++++++ .../__snapshots__/milestone-view.test.ts.snap | 133 ++++++++++++ .../commands/milestone/milestone-list.test.ts | 200 ++++++++++++++++- .../commands/milestone/milestone-view.test.ts | 115 ++++++++++ .../__snapshots__/project-view.test.ts.snap | 75 +++++++ test/commands/project/project-view.test.ts | 76 +++++++ .../team/__snapshots__/team-list.test.ts.snap | 109 ++++++++++ test/commands/team/team-list.test.ts | 181 ++++++++++++++++ 27 files changed, 1723 insertions(+), 57 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d05f3bb..06e59eca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ ## [Unreleased] +### Added + +- `--json` (`-j`) on `team list`, `cycle list`, `cycle view`, `milestone list`, `milestone view`, and `project view`, the last read commands without machine-readable output. List commands emit the same `{ nodes, pageInfo }` connection shape as the other list commands, after the same filtering and ordering as the table; view commands emit the GraphQL object as fetched, including every issue rather than the ten-item preview, and `milestone view --all --json` includes every page. (A 2.0.0 entry claimed `cycle list --json`; that change never actually landed.) ([#276](https://github.com/schpet/linear-cli/issues/276); thanks @lakardion) + +### Fixed + +- `cycle list` and `milestone list` now paginate instead of taking Linear's default page, so a team with more than 50 cycles or a project with more than 50 milestones is no longer silently truncated + ## [2.6.0] - 2026-09-02 ### Added diff --git a/README.md b/README.md index 5679c9b0..d785e9bd 100644 --- a/README.md +++ b/README.md @@ -175,6 +175,7 @@ linear issue comment add ENG-123 -a ./screenshot.png --public # public image U ```bash linear team list # list teams +linear team list --json # as JSON, e.g. to map a team name to its key or id in scripts linear team id # print out the team id (e.g. for scripts) linear team members # list team members linear team members --all --json # include inactive members, as JSON @@ -195,17 +196,29 @@ linear user list --json # machine-readable output ```bash linear project list # list projects linear project view # view project details +linear project view --json # project details as JSON linear project create --name "API v2" --team ENG --content-file overview.md linear project create --name "Mobile launch" --team APP --priority high --label Launch --member jane@example.com ``` +### cycle commands + +```bash +linear cycle list --team ENG # list a team's cycles +linear cycle list --team ENG --json # as JSON +linear cycle view 12 --team ENG # view a cycle by number or name +linear cycle view 12 --team ENG --json # cycle details and its issues, as JSON +``` + ### milestone commands ```bash linear milestone list --project # list milestones for a project linear m list --project # list milestones (alias) +linear milestone list --project --json # as JSON linear milestone view # view milestone details linear m view # view milestone (alias) +linear milestone view --all --json # every attached issue, as JSON linear milestone create --project --name "Q1 Goals" --target-date "2026-03-31" # create a milestone linear m create --project # create a milestone (interactive) linear milestone update --name "New Name" # update milestone name diff --git a/docs/usage.md b/docs/usage.md index 307905ff..fcc59751 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -214,6 +214,7 @@ linear issue delete TEAM-123 ```bash linear team list +linear team list --json # machine-readable, e.g. to map a team name to its key ``` #### get team id @@ -277,6 +278,7 @@ linear project list ```bash linear project view PROJECT-ID +linear project view PROJECT-ID --json ``` ### shell completions diff --git a/skills/linear-cli/SKILL.md b/skills/linear-cli/SKILL.md index c72c3d6e..eacbd763 100644 --- a/skills/linear-cli/SKILL.md +++ b/skills/linear-cli/SKILL.md @@ -308,7 +308,7 @@ Each command has detailed help output describing all available flags and options Some commands have required flags that aren't obvious. Notable examples: -- `issue list` sorts by priority by default — override via `--sort` (valid values: `manual`, `priority`), the `issue_sort` config option, or the `LINEAR_ISSUE_SORT` env var. Requires `--team ` unless the team can be inferred from the directory — if unknown, run `linear team list` first. +- `issue list` sorts by priority by default — override via `--sort` (valid values: `manual`, `priority`), the `issue_sort` config option, or the `LINEAR_ISSUE_SORT` env var. Requires `--team ` unless the team can be inferred from the directory — if unknown, run `linear team list` first (`linear team list --json` maps team names to their `key` and `id`). - `--no-pager` is only supported on `issue list` — passing it to other commands like `project list` will error. ## Using the Linear GraphQL API Directly diff --git a/skills/linear-cli/SKILL.template.md b/skills/linear-cli/SKILL.template.md index c3fc1f84..e5986fe1 100644 --- a/skills/linear-cli/SKILL.template.md +++ b/skills/linear-cli/SKILL.template.md @@ -188,7 +188,7 @@ Each command has detailed help output describing all available flags and options Some commands have required flags that aren't obvious. Notable examples: -- `issue list` sorts by priority by default — override via `--sort` (valid values: `manual`, `priority`), the `issue_sort` config option, or the `LINEAR_ISSUE_SORT` env var. Requires `--team ` unless the team can be inferred from the directory — if unknown, run `linear team list` first. +- `issue list` sorts by priority by default — override via `--sort` (valid values: `manual`, `priority`), the `issue_sort` config option, or the `LINEAR_ISSUE_SORT` env var. Requires `--team ` unless the team can be inferred from the directory — if unknown, run `linear team list` first (`linear team list --json` maps team names to their `key` and `id`). - `--no-pager` is only supported on `issue list` — passing it to other commands like `project list` will error. ## Using the Linear GraphQL API Directly diff --git a/skills/linear-cli/references/cycle.md b/skills/linear-cli/references/cycle.md index 61e2a1de..762b4f37 100644 --- a/skills/linear-cli/references/cycle.md +++ b/skills/linear-cli/references/cycle.md @@ -39,7 +39,8 @@ Options: -h, --help - Show this help. --workspace - Target workspace (uses credentials) - --team - Team key (defaults to current team) + --team - Team key (defaults to current team) + -j, --json - Output as JSON ``` ### view @@ -57,5 +58,6 @@ Options: -h, --help - Show this help. --workspace - Target workspace (uses credentials) - --team - Team key (defaults to current team) + --team - Team key (defaults to current team) + -j, --json - Output as JSON ``` diff --git a/skills/linear-cli/references/milestone.md b/skills/linear-cli/references/milestone.md index 8ea79842..01eb5fed 100644 --- a/skills/linear-cli/references/milestone.md +++ b/skills/linear-cli/references/milestone.md @@ -83,6 +83,7 @@ Options: -h, --help - Show this help. --workspace - Target workspace (uses credentials) --project - Project (UUID, slug ID, or name) (required) + -j, --json - Output as JSON ``` ### update @@ -123,5 +124,6 @@ Options: -h, --help - Show this help. --workspace - Target workspace (uses credentials) --all - Fetch and list every issue attached to the milestone (paginates the Linear API). - --project - Project for resolving a milestone name (UUID, slug ID, or name) + --project - Project for resolving a milestone name (UUID, slug ID, or name) + -j, --json - Output as JSON ``` diff --git a/skills/linear-cli/references/project.md b/skills/linear-cli/references/project.md index af22f89a..1dc7a1e5 100644 --- a/skills/linear-cli/references/project.md +++ b/skills/linear-cli/references/project.md @@ -157,5 +157,6 @@ Options: -h, --help - Show this help. --workspace - Target workspace (uses credentials) -w, --web - Open in web browser - -a, --app - Open in Linear.app + -a, --app - Open in Linear.app + -j, --json - Output as JSON ``` diff --git a/skills/linear-cli/references/team.md b/skills/linear-cli/references/team.md index edd70797..ebc73cfd 100644 --- a/skills/linear-cli/references/team.md +++ b/skills/linear-cli/references/team.md @@ -120,7 +120,8 @@ Options: -h, --help - Show this help. --workspace - Target workspace (uses credentials) -w, --web - Open in web browser - -a, --app - Open in Linear.app + -a, --app - Open in Linear.app + -j, --json - Output as JSON ``` ### members diff --git a/src/commands/cycle/cycle-list.ts b/src/commands/cycle/cycle-list.ts index fc99cedf..6866bd6e 100644 --- a/src/commands/cycle/cycle-list.ts +++ b/src/commands/cycle/cycle-list.ts @@ -2,23 +2,25 @@ import { Command } from "@cliffy/command" import { unicodeWidth } from "@std/cli" import { green } from "@std/fmt/colors" import { gql } from "../../__codegen__/gql.ts" +import type { GetTeamCyclesQuery } from "../../__codegen__/graphql.ts" import { getGraphQLClient } from "../../utils/graphql.ts" import { padDisplay } from "../../utils/display.ts" import { getTeamIdByKey, getTeamKey } from "../../utils/linear.ts" import { shouldShowSpinner } from "../../utils/hyperlink.ts" import { header, muted } from "../../utils/styling.ts" import { + CliError, handleError, NotFoundError, ValidationError, } from "../../utils/errors.ts" const GetTeamCycles = gql(` - query GetTeamCycles($teamId: String!) { + query GetTeamCycles($teamId: String!, $first: Int, $after: String) { team(id: $teamId) { id name - cycles { + cycles(first: $first, after: $after) { nodes { id number @@ -30,6 +32,10 @@ const GetTeamCycles = gql(` isFuture isPast } + pageInfo { + hasNextPage + endCursor + } } } } @@ -56,7 +62,8 @@ export const listCommand = new Command() .name("list") .description("List cycles for a team") .option("--team ", "Team key (defaults to current team)") - .action(async ({ team }) => { + .option("-j, --json", "Output as JSON") + .action(async ({ team, json }) => { try { const teamKey = team || getTeamKey() if (!teamKey) { @@ -71,25 +78,58 @@ export const listCommand = new Command() } const { Spinner } = await import("@std/cli/unstable-spinner") - const showSpinner = shouldShowSpinner() + const showSpinner = !json && shouldShowSpinner() const spinner = showSpinner ? new Spinner() : null spinner?.start() const client = getGraphQLClient() - const result = await client.request(GetTeamCycles, { teamId }) - spinner?.stop() - - const cycles = result.team?.cycles?.nodes || [] - if (cycles.length === 0) { - console.log("No cycles found for this team.") - return + type CyclesConnection = NonNullable["cycles"] + const cycles: CyclesConnection["nodes"] = [] + let pageInfo: CyclesConnection["pageInfo"] = { + hasNextPage: false, + endCursor: null, } + let after: string | null | undefined = undefined + + do { + const result = await client.request(GetTeamCycles, { + teamId, + first: 100, + after, + }) + if (!result.team) { + throw new NotFoundError("Team", teamKey) + } + + cycles.push(...result.team.cycles.nodes) + pageInfo = result.team.cycles.pageInfo + + if (pageInfo.hasNextPage && !pageInfo.endCursor) { + throw new CliError( + "Linear reported more cycles but returned no pagination cursor", + { suggestion: "Retry the command." }, + ) + } + after = pageInfo.endCursor + } while (pageInfo.hasNextPage) + + spinner?.stop() const sortedCycles = [...cycles].sort((a, b) => b.startsAt.localeCompare(a.startsAt) ) + if (json) { + console.log(JSON.stringify({ nodes: sortedCycles, pageInfo }, null, 2)) + return + } + + if (sortedCycles.length === 0) { + console.log("No cycles found for this team.") + return + } + const { columns } = Deno.stdout.isTerminal() ? Deno.consoleSize() : { columns: 120 } diff --git a/src/commands/cycle/cycle-view.ts b/src/commands/cycle/cycle-view.ts index 2f18cba1..d309c37e 100644 --- a/src/commands/cycle/cycle-view.ts +++ b/src/commands/cycle/cycle-view.ts @@ -45,6 +45,10 @@ const GetCycleDetails = gql(` type } } + pageInfo { + hasNextPage + endCursor + } } } } @@ -56,7 +60,8 @@ export const viewCommand = new Command() .alias("v") .arguments("") .option("--team ", "Team key (defaults to current team)") - .action(async ({ team }, cycleRef) => { + .option("-j, --json", "Output as JSON") + .action(async ({ team, json }, cycleRef) => { try { const teamKey = team || getTeamKey() if (!teamKey) { @@ -73,7 +78,7 @@ export const viewCommand = new Command() const cycleId = await getCycleIdByNameOrNumber(cycleRef, teamId) const { Spinner } = await import("@std/cli/unstable-spinner") - const showSpinner = shouldShowSpinner() + const showSpinner = !json && shouldShowSpinner() const spinner = showSpinner ? new Spinner() : null spinner?.start() @@ -86,6 +91,11 @@ export const viewCommand = new Command() throw new NotFoundError("Cycle", cycleRef) } + if (json) { + console.log(JSON.stringify(cycle, null, 2)) + return + } + const lines: string[] = [] const title = cycle.name || `Cycle ${cycle.number}` diff --git a/src/commands/milestone/milestone-list.ts b/src/commands/milestone/milestone-list.ts index ef6f3009..b7700b47 100644 --- a/src/commands/milestone/milestone-list.ts +++ b/src/commands/milestone/milestone-list.ts @@ -1,18 +1,19 @@ import { Command } from "@cliffy/command" import { unicodeWidth } from "@std/cli" import { gql } from "../../__codegen__/gql.ts" +import type { GetProjectMilestonesQuery } from "../../__codegen__/graphql.ts" import { getGraphQLClient } from "../../utils/graphql.ts" import { padDisplay } from "../../utils/display.ts" import { resolveProjectId } from "../../utils/linear.ts" import { shouldShowSpinner } from "../../utils/hyperlink.ts" -import { handleError } from "../../utils/errors.ts" +import { CliError, handleError, NotFoundError } from "../../utils/errors.ts" const GetProjectMilestones = gql(` - query GetProjectMilestones($projectId: String!) { + query GetProjectMilestones($projectId: String!, $first: Int, $after: String) { project(id: $projectId) { id name - projectMilestones { + projectMilestones(first: $first, after: $after) { nodes { id name @@ -23,6 +24,10 @@ const GetProjectMilestones = gql(` name } } + pageInfo { + hasNextPage + endCursor + } } } } @@ -36,9 +41,10 @@ export const listCommand = new Command() "Project (UUID, slug ID, or name)", { required: true }, ) - .action(async ({ project: projectIdOrSlug }) => { + .option("-j, --json", "Output as JSON") + .action(async ({ project: projectIdOrSlug, json }) => { const { Spinner } = await import("@std/cli/unstable-spinner") - const showSpinner = shouldShowSpinner() + const showSpinner = !json && shouldShowSpinner() const spinner = showSpinner ? new Spinner() : null spinner?.start() @@ -47,17 +53,40 @@ export const listCommand = new Command() const projectId = await resolveProjectId(projectIdOrSlug) const client = getGraphQLClient() - const result = await client.request(GetProjectMilestones, { - projectId, - }) - spinner?.stop() - const milestones = result.project?.projectMilestones?.nodes || [] - - if (milestones.length === 0) { - console.log("No milestones found for this project.") - return + type MilestonesConnection = NonNullable< + GetProjectMilestonesQuery["project"] + >["projectMilestones"] + const milestones: MilestonesConnection["nodes"] = [] + let pageInfo: MilestonesConnection["pageInfo"] = { + hasNextPage: false, + endCursor: null, } + let after: string | null | undefined = undefined + + do { + const result = await client.request(GetProjectMilestones, { + projectId, + first: 100, + after, + }) + if (!result.project) { + throw new NotFoundError("Project", projectIdOrSlug) + } + + milestones.push(...result.project.projectMilestones.nodes) + pageInfo = result.project.projectMilestones.pageInfo + + if (pageInfo.hasNextPage && !pageInfo.endCursor) { + throw new CliError( + "Linear reported more milestones but returned no pagination cursor", + { suggestion: "Retry the command." }, + ) + } + after = pageInfo.endCursor + } while (pageInfo.hasNextPage) + + spinner?.stop() // Sort milestones by targetDate (nulls last) then by name const sortedMilestones = milestones.sort((a, b) => { @@ -70,6 +99,18 @@ export const listCommand = new Command() : a.name.localeCompare(b.name) }) + if (json) { + console.log( + JSON.stringify({ nodes: sortedMilestones, pageInfo }, null, 2), + ) + return + } + + if (sortedMilestones.length === 0) { + console.log("No milestones found for this project.") + return + } + // Calculate column widths const { columns } = Deno.stdout.isTerminal() ? Deno.consoleSize() diff --git a/src/commands/milestone/milestone-view.ts b/src/commands/milestone/milestone-view.ts index 47cc1dde..d7d8f101 100644 --- a/src/commands/milestone/milestone-view.ts +++ b/src/commands/milestone/milestone-view.ts @@ -63,9 +63,10 @@ export const viewCommand = new Command() "--project ", "Project for resolving a milestone name (UUID, slug ID, or name)", ) - .action(async ({ all, project }, milestoneInput) => { + .option("-j, --json", "Output as JSON") + .action(async ({ all, project, json }, milestoneInput) => { const { Spinner } = await import("@std/cli/unstable-spinner") - const showSpinner = shouldShowSpinner() + const showSpinner = !json && shouldShowSpinner() const spinner = showSpinner ? new Spinner() : null spinner?.start() @@ -125,6 +126,19 @@ export const viewCommand = new Command() spinner?.stop() + if (json) { + // Same connection the human output works from: the first page, or + // every page under --all. The 10-item preview is presentation only. + console.log( + JSON.stringify( + { ...milestone, issues: { nodes: issues, pageInfo } }, + null, + 2, + ), + ) + return + } + const truncated = !all && pageInfo.hasNextPage const lines: string[] = [] diff --git a/src/commands/project/project-view.ts b/src/commands/project/project-view.ts index c8ee75fc..a1011065 100644 --- a/src/commands/project/project-view.ts +++ b/src/commands/project/project-view.ts @@ -56,6 +56,10 @@ const GetProjectDetails = gql(` type } } + pageInfo { + hasNextPage + endCursor + } } lastUpdate { id @@ -78,8 +82,9 @@ export const viewCommand = new Command() .arguments("") .option("-w, --web", "Open in web browser") .option("-a, --app", "Open in Linear.app") + .option("-j, --json", "Output as JSON") .action(async (options, projectId) => { - const { web, app } = options + const { web, app, json } = options if (web || app) { await openProjectPage(projectId, { app, web: !app }) @@ -87,7 +92,7 @@ export const viewCommand = new Command() } const { Spinner } = await import("@std/cli/unstable-spinner") - const showSpinner = shouldShowSpinner() + const showSpinner = !json && shouldShowSpinner() const spinner = showSpinner ? new Spinner() : null spinner?.start() @@ -101,6 +106,11 @@ export const viewCommand = new Command() throw new NotFoundError("Project", projectId) } + if (json) { + console.log(JSON.stringify(project, null, 2)) + return + } + // Build the display const lines: string[] = [] diff --git a/src/commands/team/team-list.ts b/src/commands/team/team-list.ts index 00fe580f..c9710b78 100644 --- a/src/commands/team/team-list.ts +++ b/src/commands/team/team-list.ts @@ -7,7 +7,7 @@ import { getGraphQLClient } from "../../utils/graphql.ts" import { getTimeAgo, padDisplay } from "../../utils/display.ts" import { getOption } from "../../config.ts" import { shouldShowSpinner } from "../../utils/hyperlink.ts" -import { handleError, ValidationError } from "../../utils/errors.ts" +import { CliError, handleError, ValidationError } from "../../utils/errors.ts" import { LINEAR_WEB_BASE_URL } from "../../const.ts" const GetTeams = gql(` @@ -42,9 +42,10 @@ export const listCommand = new Command() .description("List teams") .option("-w, --web", "Open in web browser") .option("-a, --app", "Open in Linear.app") - .action(async ({ web, app }) => { + .option("-j, --json", "Output as JSON") + .action(async ({ web, app, json }) => { const { Spinner } = await import("@std/cli/unstable-spinner") - const showSpinner = shouldShowSpinner() + const showSpinner = !json && shouldShowSpinner() const spinner = showSpinner ? new Spinner() : null try { @@ -69,36 +70,49 @@ export const listCommand = new Command() // Fetch all teams with pagination const allTeams: GetTeamsQuery["teams"]["nodes"] = [] - let hasNextPage = true + let pageInfo: GetTeamsQuery["teams"]["pageInfo"] = { + hasNextPage: false, + endCursor: null, + } let after: string | null | undefined = undefined - while (hasNextPage) { + do { const result: GetTeamsQuery = await client.request(GetTeams, { filter: undefined, first: 100, after, }) - const teams = result.teams?.nodes || [] - allTeams.push(...teams) + allTeams.push(...result.teams.nodes) + pageInfo = result.teams.pageInfo - hasNextPage = result.teams?.pageInfo?.hasNextPage || false - after = result.teams?.pageInfo?.endCursor - } + if (pageInfo.hasNextPage && !pageInfo.endCursor) { + throw new CliError( + "Linear reported more teams but returned no pagination cursor", + { suggestion: "Retry the command." }, + ) + } + after = pageInfo.endCursor + } while (pageInfo.hasNextPage) spinner?.stop() - // Filter out archived teams - let teams = allTeams.filter((team) => !team.archivedAt) + // Filter out archived teams and sort alphabetically by name. --json is an + // output format, not a raw dump: it gets the same nodes the table shows. + const teams = allTeams + .filter((team) => !team.archivedAt) + .sort((a, b) => a.name.localeCompare(b.name)) + + if (json) { + console.log(JSON.stringify({ nodes: teams, pageInfo }, null, 2)) + return + } if (teams.length === 0) { console.log("No teams found.") return } - // Sort teams alphabetically by name - teams = teams.sort((a, b) => a.name.localeCompare(b.name)) - // Define column widths based on actual data const { columns } = Deno.stdout.isTerminal() ? Deno.consoleSize() diff --git a/test/commands/cycle/__snapshots__/cycle-list.test.ts.snap b/test/commands/cycle/__snapshots__/cycle-list.test.ts.snap index 6c5b44d2..33a2e093 100644 --- a/test/commands/cycle/__snapshots__/cycle-list.test.ts.snap +++ b/test/commands/cycle/__snapshots__/cycle-list.test.ts.snap @@ -13,6 +13,7 @@ Options: -h, --help - Show this help. --team - Team key (defaults to current team) + -j, --json - Output as JSON " stderr: @@ -37,3 +38,91 @@ stdout: stderr: "" `; + +snapshot[`Cycle List Command - JSON Output 1`] = ` +stdout: +'{ + "nodes": [ + { + "name": null, + "startsAt": "2026-02-24T00:00:00.000Z", + "endsAt": "2026-03-10T00:00:00.000Z", + "completedAt": null, + "isActive": true, + "isFuture": false, + "isPast": false, + "id": "cycle-2", + "number": 13 + }, + { + "name": "Sprint 12", + "startsAt": "2026-02-10T00:00:00.000Z", + "endsAt": "2026-02-24T00:00:00.000Z", + "completedAt": "2026-02-24T00:00:00.000Z", + "isActive": false, + "isFuture": false, + "isPast": true, + "id": "cycle-1", + "number": 12 + } + ], + "pageInfo": { + "hasNextPage": false, + "endCursor": null + } +} +' +stderr: +"" +`; + +snapshot[`Cycle List Command - Empty JSON 1`] = ` +stdout: +'{ + "nodes": [], + "pageInfo": { + "hasNextPage": false, + "endCursor": null + } +} +' +stderr: +"" +`; + +snapshot[`Cycle List Command - JSON Output With Pagination 1`] = ` +stdout: +'{ + "nodes": [ + { + "name": null, + "startsAt": "2026-06-01T00:00:00.000Z", + "endsAt": "2026-06-15T00:00:00.000Z", + "completedAt": null, + "isActive": false, + "isFuture": true, + "isPast": false, + "id": "cycle-2", + "number": 2 + }, + { + "name": null, + "startsAt": "2026-01-01T00:00:00.000Z", + "endsAt": "2026-01-15T00:00:00.000Z", + "completedAt": null, + "isActive": false, + "isFuture": false, + "isPast": true, + "id": "cycle-1", + "number": 1 + } + ], + "pageInfo": { + "hasNextPage": false, + "endCursor": null + } +} +' +stderr: +"" +`; diff --git a/test/commands/cycle/__snapshots__/cycle-view.test.ts.snap b/test/commands/cycle/__snapshots__/cycle-view.test.ts.snap index 0f0be238..7ad07038 100644 --- a/test/commands/cycle/__snapshots__/cycle-view.test.ts.snap +++ b/test/commands/cycle/__snapshots__/cycle-view.test.ts.snap @@ -13,6 +13,7 @@ Options: -h, --help - Show this help. --team - Team key (defaults to current team) + -j, --json - Output as JSON " stderr: @@ -116,3 +117,145 @@ _...and 5 more issues_ stderr: "" `; + +snapshot[`Cycle View Command - JSON Output Includes All Issues 1`] = ` +stdout: +'{ + "id": "cycle-1", + "number": 12, + "name": "Sprint 12", + "description": null, + "startsAt": "2026-02-10T00:00:00.000Z", + "endsAt": "2026-02-24T00:00:00.000Z", + "completedAt": "2026-02-24T00:00:00.000Z", + "isActive": false, + "isFuture": false, + "isPast": true, + "createdAt": "2026-02-01T00:00:00.000Z", + "updatedAt": "2026-02-24T00:00:00.000Z", + "team": { + "id": "team-eng-id", + "key": "ENG", + "name": "Engineering" + }, + "issues": { + "nodes": [ + { + "id": "issue-1", + "identifier": "ENG-1", + "title": "Issue 1", + "state": { + "name": "Done", + "type": "completed" + } + }, + { + "id": "issue-2", + "identifier": "ENG-2", + "title": "Issue 2", + "state": { + "name": "Done", + "type": "completed" + } + }, + { + "id": "issue-3", + "identifier": "ENG-3", + "title": "Issue 3", + "state": { + "name": "Done", + "type": "completed" + } + }, + { + "id": "issue-4", + "identifier": "ENG-4", + "title": "Issue 4", + "state": { + "name": "Done", + "type": "completed" + } + }, + { + "id": "issue-5", + "identifier": "ENG-5", + "title": "Issue 5", + "state": { + "name": "Done", + "type": "completed" + } + }, + { + "id": "issue-6", + "identifier": "ENG-6", + "title": "Issue 6", + "state": { + "name": "Done", + "type": "completed" + } + }, + { + "id": "issue-7", + "identifier": "ENG-7", + "title": "Issue 7", + "state": { + "name": "Done", + "type": "completed" + } + }, + { + "id": "issue-8", + "identifier": "ENG-8", + "title": "Issue 8", + "state": { + "name": "Done", + "type": "completed" + } + }, + { + "id": "issue-9", + "identifier": "ENG-9", + "title": "Issue 9", + "state": { + "name": "Done", + "type": "completed" + } + }, + { + "id": "issue-10", + "identifier": "ENG-10", + "title": "Issue 10", + "state": { + "name": "Done", + "type": "completed" + } + }, + { + "id": "issue-11", + "identifier": "ENG-11", + "title": "Issue 11", + "state": { + "name": "Done", + "type": "completed" + } + }, + { + "id": "issue-12", + "identifier": "ENG-12", + "title": "Issue 12", + "state": { + "name": "Done", + "type": "completed" + } + } + ], + "pageInfo": { + "hasNextPage": true, + "endCursor": "issues-cursor-1" + } + } +} +' +stderr: +"" +`; diff --git a/test/commands/cycle/cycle-list.test.ts b/test/commands/cycle/cycle-list.test.ts index 87163e80..5729367f 100644 --- a/test/commands/cycle/cycle-list.test.ts +++ b/test/commands/cycle/cycle-list.test.ts @@ -1,4 +1,6 @@ import { snapshotTest as cliffySnapshotTest } from "@cliffy/testing" +import { assertEquals, assertStringIncludes } from "@std/assert" +import { stub } from "@std/testing/mock" import { listCommand } from "../../../src/commands/cycle/cycle-list.ts" import { commonDenoArgs } from "../../utils/test-helpers.ts" import { MockLinearServer } from "../../utils/mock_linear_server.ts" @@ -34,7 +36,7 @@ await cliffySnapshotTest({ }, { queryName: "GetTeamCycles", - variables: { teamId: "team-eng-id" }, + variables: { teamId: "team-eng-id", first: 100, after: undefined }, response: { data: { team: { @@ -76,6 +78,7 @@ await cliffySnapshotTest({ isPast: false, }, ], + pageInfo: { hasNextPage: false, endCursor: null }, }, }, }, @@ -117,7 +120,7 @@ await cliffySnapshotTest({ }, { queryName: "GetTeamCycles", - variables: { teamId: "team-eng-id" }, + variables: { teamId: "team-eng-id", first: 100, after: undefined }, response: { data: { team: { @@ -125,6 +128,7 @@ await cliffySnapshotTest({ name: "Engineering", cycles: { nodes: [], + pageInfo: { hasNextPage: false, endCursor: null }, }, }, }, @@ -145,3 +149,197 @@ await cliffySnapshotTest({ } }, }) + +const TEAM_LOOKUP = { + queryName: "GetTeamIdByKey", + response: { data: { teams: { nodes: [{ id: "team-eng-id" }] } } }, +} + +function cycle( + overrides: Record & { id: string; number: number }, +) { + return { + name: null, + startsAt: "2026-01-01T00:00:00.000Z", + endsAt: "2026-01-15T00:00:00.000Z", + completedAt: null, + isActive: false, + isFuture: false, + isPast: true, + ...overrides, + } +} + +function cyclesPage( + nodes: unknown[], + pageInfo: { hasNextPage: boolean; endCursor: string | null }, +) { + return { + data: { + team: { + id: "team-eng-id", + name: "Engineering", + cycles: { nodes, pageInfo }, + }, + }, + } +} + +async function runWithServer(server: MockLinearServer, args?: string[]) { + try { + await server.start() + Deno.env.set("LINEAR_GRAPHQL_ENDPOINT", server.getEndpoint()) + Deno.env.set("LINEAR_API_KEY", "Bearer test-token") + await listCommand.parse(args) + } finally { + await server.stop() + Deno.env.delete("LINEAR_GRAPHQL_ENDPOINT") + Deno.env.delete("LINEAR_API_KEY") + } +} + +// JSON keeps GraphQL field names (a null name stays null, no "Cycle N" +// fallback, no derived status) and the table's newest-first ordering. +await cliffySnapshotTest({ + name: "Cycle List Command - JSON Output", + meta: import.meta, + colors: false, + args: ["--team", "ENG", "--json"], + denoArgs: commonDenoArgs, + async fn() { + await runWithServer( + new MockLinearServer([ + TEAM_LOOKUP, + { + queryName: "GetTeamCycles", + variables: { teamId: "team-eng-id", first: 100, after: undefined }, + response: cyclesPage( + [ + cycle({ + id: "cycle-1", + number: 12, + name: "Sprint 12", + startsAt: "2026-02-10T00:00:00.000Z", + endsAt: "2026-02-24T00:00:00.000Z", + completedAt: "2026-02-24T00:00:00.000Z", + }), + cycle({ + id: "cycle-2", + number: 13, + startsAt: "2026-02-24T00:00:00.000Z", + endsAt: "2026-03-10T00:00:00.000Z", + isActive: true, + isPast: false, + }), + ], + { hasNextPage: false, endCursor: null }, + ), + }, + ]), + ) + }, +}) + +await cliffySnapshotTest({ + name: "Cycle List Command - Empty JSON", + meta: import.meta, + colors: false, + args: ["--team", "ENG", "--json"], + denoArgs: commonDenoArgs, + async fn() { + await runWithServer( + new MockLinearServer([ + TEAM_LOOKUP, + { + queryName: "GetTeamCycles", + variables: { teamId: "team-eng-id", first: 100, after: undefined }, + response: cyclesPage([], { hasNextPage: false, endCursor: null }), + }, + ]), + ) + }, +}) + +// Regression guard: the query previously took Linear's default page, so a team +// with more cycles than that was silently truncated. The newest cycle lives on +// page two here, so a first-page-only implementation would both drop it and +// mis-order the list. +await cliffySnapshotTest({ + name: "Cycle List Command - JSON Output With Pagination", + meta: import.meta, + colors: false, + args: ["--team", "ENG", "--json"], + denoArgs: commonDenoArgs, + async fn() { + await runWithServer( + new MockLinearServer([ + TEAM_LOOKUP, + { + queryName: "GetTeamCycles", + variables: { teamId: "team-eng-id", first: 100, after: undefined }, + response: cyclesPage( + [cycle({ id: "cycle-1", number: 1 })], + { hasNextPage: true, endCursor: "cycles-cursor-1" }, + ), + }, + { + queryName: "GetTeamCycles", + variables: { + teamId: "team-eng-id", + first: 100, + after: "cycles-cursor-1", + }, + response: cyclesPage( + [ + cycle({ + id: "cycle-2", + number: 2, + startsAt: "2026-06-01T00:00:00.000Z", + endsAt: "2026-06-15T00:00:00.000Z", + isFuture: true, + isPast: false, + }), + ], + { hasNextPage: false, endCursor: null }, + ), + }, + ]), + ) + }, +}) + +Deno.test("Cycle List Command - errors on inconsistent pagination", async () => { + const server = new MockLinearServer([ + TEAM_LOOKUP, + { + queryName: "GetTeamCycles", + variables: { teamId: "team-eng-id", first: 100, after: undefined }, + response: cyclesPage( + [cycle({ id: "cycle-1", number: 1 })], + { hasNextPage: true, endCursor: null }, + ), + }, + ]) + + const errorLogs: string[] = [] + const errorStub = stub(console, "error", (...args: unknown[]) => { + errorLogs.push(args.map(String).join(" ")) + }) + const exitStub = stub(Deno, "exit", (_code?: number) => { + throw new Error("EXIT") + }) + + let exited = false + try { + await runWithServer(server, ["--team", "ENG", "--json"]) + } catch (e) { + if (!(e instanceof Error) || e.message !== "EXIT") throw e + exited = true + } finally { + errorStub.restore() + exitStub.restore() + } + + assertEquals(exited, true) + assertStringIncludes(errorLogs.join("\n"), "no pagination cursor") +}) diff --git a/test/commands/cycle/cycle-view.test.ts b/test/commands/cycle/cycle-view.test.ts index 65581f68..c9fe0268 100644 --- a/test/commands/cycle/cycle-view.test.ts +++ b/test/commands/cycle/cycle-view.test.ts @@ -313,3 +313,91 @@ await snapshotTest({ } }, }) + +// JSON is the GraphQL cycle object as selected: every returned issue (the human +// view previews ten), no derived status or progress, plus the issues +// connection's pageInfo so callers can tell when Linear's page was partial. +await snapshotTest({ + name: "Cycle View Command - JSON Output Includes All Issues", + meta: import.meta, + colors: false, + args: ["12", "--team", "ENG", "--json"], + denoArgs: commonDenoArgs, + async fn() { + const server = new MockLinearServer([ + { + queryName: "GetTeamIdByKey", + response: { data: { teams: { nodes: [{ id: "team-eng-id" }] } } }, + }, + { + queryName: "GetTeamCyclesForLookup", + variables: { teamId: "team-eng-id", after: null }, + response: { + data: { + team: { + key: "ENG", + cyclesEnabled: true, + cycles: { + nodes: [ + { + id: "cycle-1", + number: 12, + name: "Sprint 12", + startsAt: "2026-02-10T00:00:00.000Z", + endsAt: "2026-02-24T00:00:00.000Z", + isActive: false, + }, + ], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + activeCycle: null, + }, + }, + }, + }, + { + queryName: "GetCycleDetails", + variables: { id: "cycle-1" }, + response: { + data: { + cycle: { + id: "cycle-1", + number: 12, + name: "Sprint 12", + description: null, + startsAt: "2026-02-10T00:00:00.000Z", + endsAt: "2026-02-24T00:00:00.000Z", + completedAt: "2026-02-24T00:00:00.000Z", + isActive: false, + isFuture: false, + isPast: true, + createdAt: "2026-02-01T00:00:00.000Z", + updatedAt: "2026-02-24T00:00:00.000Z", + team: { id: "team-eng-id", key: "ENG", name: "Engineering" }, + issues: { + nodes: Array.from({ length: 12 }, (_, i) => ({ + id: `issue-${i + 1}`, + identifier: `ENG-${i + 1}`, + title: `Issue ${i + 1}`, + state: { name: "Done", type: "completed" }, + })), + pageInfo: { hasNextPage: true, endCursor: "issues-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") + } + }, +}) diff --git a/test/commands/milestone/__snapshots__/milestone-list.test.ts.snap b/test/commands/milestone/__snapshots__/milestone-list.test.ts.snap index 11aeead4..7ee116d1 100644 --- a/test/commands/milestone/__snapshots__/milestone-list.test.ts.snap +++ b/test/commands/milestone/__snapshots__/milestone-list.test.ts.snap @@ -13,6 +13,7 @@ Options: -h, --help - Show this help. --project - Project (UUID, slug ID, or name) (required) + -j, --json - Output as JSON " stderr: @@ -37,3 +38,107 @@ stdout: stderr: "" `; + +snapshot[`Milestone List Command - JSON Output 1`] = ` +stdout: +'{ + "nodes": [ + { + "targetDate": "2026-01-31", + "sortOrder": 1, + "project": { + "id": "project-123", + "name": "Test Project" + }, + "id": "m-first", + "name": "Kickoff" + }, + { + "targetDate": "2026-03-31", + "sortOrder": 2, + "project": { + "id": "project-123", + "name": "Test Project" + }, + "id": "m-alpha", + "name": "Alpha" + }, + { + "targetDate": "2026-03-31", + "sortOrder": 3, + "project": { + "id": "project-123", + "name": "Test Project" + }, + "id": "m-beta", + "name": "Beta" + }, + { + "targetDate": null, + "sortOrder": 4, + "project": { + "id": "project-123", + "name": "Test Project" + }, + "id": "m-undated", + "name": "Someday" + } + ], + "pageInfo": { + "hasNextPage": false, + "endCursor": null + } +} +' +stderr: +"" +`; + +snapshot[`Milestone List Command - Empty JSON 1`] = ` +stdout: +'{ + "nodes": [], + "pageInfo": { + "hasNextPage": false, + "endCursor": null + } +} +' +stderr: +"" +`; + +snapshot[`Milestone List Command - JSON Output With Pagination 1`] = ` +stdout: +'{ + "nodes": [ + { + "targetDate": "2026-01-31", + "sortOrder": 1, + "project": { + "id": "project-123", + "name": "Test Project" + }, + "id": "m-1", + "name": "Sooner" + }, + { + "targetDate": "2026-06-30", + "sortOrder": 1, + "project": { + "id": "project-123", + "name": "Test Project" + }, + "id": "m-2", + "name": "Later" + } + ], + "pageInfo": { + "hasNextPage": false, + "endCursor": null + } +} +' +stderr: +"" +`; diff --git a/test/commands/milestone/__snapshots__/milestone-view.test.ts.snap b/test/commands/milestone/__snapshots__/milestone-view.test.ts.snap index 24a409d6..6a38025b 100644 --- a/test/commands/milestone/__snapshots__/milestone-view.test.ts.snap +++ b/test/commands/milestone/__snapshots__/milestone-view.test.ts.snap @@ -14,6 +14,7 @@ Options: -h, --help - Show this help. --all - Fetch and list every issue attached to the milestone (paginates the Linear API). --project - Project for resolving a milestone name (UUID, slug ID, or name) + -j, --json - Output as JSON " stderr: @@ -225,3 +226,135 @@ stdout: stderr: "" `; + +snapshot[`Milestone View Command - JSON Output Preserves First Page 1`] = ` +stdout: +'{ + "id": "milestone-json", + "name": "JSON Milestone", + "description": "Machine readable", + "targetDate": "2026-06-30", + "sortOrder": 1, + "createdAt": "2020-01-01T12:00:00Z", + "updatedAt": "2020-01-02T12:00:00Z", + "project": { + "id": "project-1", + "name": "P", + "slugId": "p", + "url": "https://linear.app/test/project/p" + }, + "issues": { + "nodes": [ + { + "id": "id-0", + "identifier": "JSN-1", + "title": "Issue 1", + "state": { + "name": "Todo", + "type": "unstarted" + } + }, + { + "id": "id-1", + "identifier": "JSN-2", + "title": "Issue 2", + "state": { + "name": "Todo", + "type": "unstarted" + } + }, + { + "id": "id-2", + "identifier": "JSN-3", + "title": "Issue 3", + "state": { + "name": "Todo", + "type": "unstarted" + } + } + ], + "pageInfo": { + "hasNextPage": true, + "endCursor": "issues-cursor-1" + } + } +} +' +stderr: +"" +`; + +snapshot[`Milestone View Command - --all JSON Paginates 1`] = ` +stdout: +'{ + "id": "milestone-json", + "name": "JSON Milestone", + "description": "Machine readable", + "targetDate": "2026-06-30", + "sortOrder": 1, + "createdAt": "2020-01-01T12:00:00Z", + "updatedAt": "2020-01-02T12:00:00Z", + "project": { + "id": "project-1", + "name": "P", + "slugId": "p", + "url": "https://linear.app/test/project/p" + }, + "issues": { + "nodes": [ + { + "id": "id-0", + "identifier": "JSN-1", + "title": "Issue 1", + "state": { + "name": "Todo", + "type": "unstarted" + } + }, + { + "id": "id-1", + "identifier": "JSN-2", + "title": "Issue 2", + "state": { + "name": "Todo", + "type": "unstarted" + } + }, + { + "id": "id-2", + "identifier": "JSN-3", + "title": "Issue 3", + "state": { + "name": "Todo", + "type": "unstarted" + } + }, + { + "id": "id-3", + "identifier": "JSN-4", + "title": "Issue 4", + "state": { + "name": "Todo", + "type": "unstarted" + } + }, + { + "id": "id-4", + "identifier": "JSN-5", + "title": "Issue 5", + "state": { + "name": "Todo", + "type": "unstarted" + } + } + ], + "pageInfo": { + "hasNextPage": false, + "endCursor": null + } + } +} +' +stderr: +"" +`; diff --git a/test/commands/milestone/milestone-list.test.ts b/test/commands/milestone/milestone-list.test.ts index 4b5084d0..2ae23a94 100644 --- a/test/commands/milestone/milestone-list.test.ts +++ b/test/commands/milestone/milestone-list.test.ts @@ -1,4 +1,6 @@ import { snapshotTest as cliffySnapshotTest } from "@cliffy/testing" +import { assertEquals, assertStringIncludes } from "@std/assert" +import { stub } from "@std/testing/mock" import { listCommand } from "../../../src/commands/milestone/milestone-list.ts" import { commonDenoArgs } from "../../utils/test-helpers.ts" import { MockLinearServer } from "../../utils/mock_linear_server.ts" @@ -38,7 +40,7 @@ await cliffySnapshotTest({ }, { queryName: "GetProjectMilestones", - variables: { projectId: "project-123" }, + variables: { projectId: "project-123", first: 100, after: undefined }, response: { data: { project: { @@ -77,6 +79,7 @@ await cliffySnapshotTest({ }, }, ], + pageInfo: { hasNextPage: false, endCursor: null }, }, }, }, @@ -121,7 +124,7 @@ await cliffySnapshotTest({ }, { queryName: "GetProjectMilestones", - variables: { projectId: "project-456" }, + variables: { projectId: "project-456", first: 100, after: undefined }, response: { data: { project: { @@ -129,6 +132,7 @@ await cliffySnapshotTest({ name: "Empty Project", projectMilestones: { nodes: [], + pageInfo: { hasNextPage: false, endCursor: null }, }, }, }, @@ -149,3 +153,195 @@ await cliffySnapshotTest({ } }, }) + +const PROJECT_LOOKUP = [ + { + queryName: "GetProjectIdByName", + response: { data: { projects: { nodes: [] } } }, + }, + { + queryName: "GetProjectIdBySlugId", + response: { data: { projects: { nodes: [{ id: "project-123" }] } } }, + }, +] + +function milestone( + overrides: Record & { id: string; name: string }, +) { + return { + targetDate: null, + sortOrder: 1, + project: { id: "project-123", name: "Test Project" }, + ...overrides, + } +} + +function milestonesPage( + nodes: unknown[], + pageInfo: { hasNextPage: boolean; endCursor: string | null }, +) { + return { + data: { + project: { + id: "project-123", + name: "Test Project", + projectMilestones: { nodes, pageInfo }, + }, + }, + } +} + +async function runWithServer(server: MockLinearServer, args?: string[]) { + try { + await server.start() + Deno.env.set("LINEAR_GRAPHQL_ENDPOINT", server.getEndpoint()) + Deno.env.set("LINEAR_API_KEY", "Bearer test-token") + await listCommand.parse(args) + } finally { + await server.stop() + Deno.env.delete("LINEAR_GRAPHQL_ENDPOINT") + Deno.env.delete("LINEAR_API_KEY") + } +} + +// Same ordering as the table: target date ascending, ties by name, undated +// milestones last (and a null targetDate stays null rather than "No date"). +await cliffySnapshotTest({ + name: "Milestone List Command - JSON Output", + meta: import.meta, + colors: false, + args: ["--project", "project-123", "--json"], + denoArgs: commonDenoArgs, + async fn() { + await runWithServer( + new MockLinearServer([ + ...PROJECT_LOOKUP, + { + queryName: "GetProjectMilestones", + variables: { projectId: "project-123", first: 100, after: undefined }, + response: milestonesPage( + [ + milestone({ id: "m-undated", name: "Someday", sortOrder: 4 }), + milestone({ + id: "m-beta", + name: "Beta", + targetDate: "2026-03-31", + sortOrder: 3, + }), + milestone({ + id: "m-alpha", + name: "Alpha", + targetDate: "2026-03-31", + sortOrder: 2, + }), + milestone({ + id: "m-first", + name: "Kickoff", + targetDate: "2026-01-31", + sortOrder: 1, + }), + ], + { hasNextPage: false, endCursor: null }, + ), + }, + ]), + ) + }, +}) + +await cliffySnapshotTest({ + name: "Milestone List Command - Empty JSON", + meta: import.meta, + colors: false, + args: ["--project", "project-123", "--json"], + denoArgs: commonDenoArgs, + async fn() { + await runWithServer( + new MockLinearServer([ + ...PROJECT_LOOKUP, + { + queryName: "GetProjectMilestones", + variables: { projectId: "project-123", first: 100, after: undefined }, + response: milestonesPage([], { hasNextPage: false, endCursor: null }), + }, + ]), + ) + }, +}) + +// Regression guard for the default-page truncation: the earliest milestone is +// on page two, so a first-page-only implementation would drop it. +await cliffySnapshotTest({ + name: "Milestone List Command - JSON Output With Pagination", + meta: import.meta, + colors: false, + args: ["--project", "project-123", "--json"], + denoArgs: commonDenoArgs, + async fn() { + await runWithServer( + new MockLinearServer([ + ...PROJECT_LOOKUP, + { + queryName: "GetProjectMilestones", + variables: { projectId: "project-123", first: 100, after: undefined }, + response: milestonesPage( + [milestone({ id: "m-2", name: "Later", targetDate: "2026-06-30" })], + { hasNextPage: true, endCursor: "milestones-cursor-1" }, + ), + }, + { + queryName: "GetProjectMilestones", + variables: { + projectId: "project-123", + first: 100, + after: "milestones-cursor-1", + }, + response: milestonesPage( + [milestone({ + id: "m-1", + name: "Sooner", + targetDate: "2026-01-31", + })], + { hasNextPage: false, endCursor: null }, + ), + }, + ]), + ) + }, +}) + +Deno.test("Milestone List Command - errors on inconsistent pagination", async () => { + const server = new MockLinearServer([ + ...PROJECT_LOOKUP, + { + queryName: "GetProjectMilestones", + variables: { projectId: "project-123", first: 100, after: undefined }, + response: milestonesPage( + [milestone({ id: "m-1", name: "Only" })], + { hasNextPage: true, endCursor: null }, + ), + }, + ]) + + const errorLogs: string[] = [] + const errorStub = stub(console, "error", (...args: unknown[]) => { + errorLogs.push(args.map(String).join(" ")) + }) + const exitStub = stub(Deno, "exit", (_code?: number) => { + throw new Error("EXIT") + }) + + let exited = false + try { + await runWithServer(server, ["--project", "project-123", "--json"]) + } catch (e) { + if (!(e instanceof Error) || e.message !== "EXIT") throw e + exited = true + } finally { + errorStub.restore() + exitStub.restore() + } + + assertEquals(exited, true) + assertStringIncludes(errorLogs.join("\n"), "no pagination cursor") +}) diff --git a/test/commands/milestone/milestone-view.test.ts b/test/commands/milestone/milestone-view.test.ts index 4b6cb901..da1c0d33 100644 --- a/test/commands/milestone/milestone-view.test.ts +++ b/test/commands/milestone/milestone-view.test.ts @@ -426,3 +426,118 @@ Deno.test("Milestone View Command - --all errors on inconsistent pagination", as true, ) }) + +function milestoneJsonPage( + issues: unknown[], + pageInfo: { hasNextPage: boolean; endCursor: string | null }, +) { + return { + data: { + projectMilestone: { + id: "milestone-json", + name: "JSON Milestone", + description: "Machine readable", + targetDate: "2026-06-30", + sortOrder: 1, + createdAt: "2020-01-01T12:00:00Z", + updatedAt: "2020-01-02T12:00:00Z", + project: { + id: "project-1", + name: "P", + slugId: "p", + url: "https://linear.app/test/project/p", + }, + issues: { nodes: issues, pageInfo }, + }, + }, + } +} + +function jsonIssues(count: number, offset = 0) { + return Array.from({ length: count }, (_, i) => ({ + id: `id-${offset + i}`, + identifier: `JSN-${offset + i + 1}`, + title: `Issue ${offset + i + 1}`, + state: { name: "Todo", type: "unstarted" }, + })) +} + +// Without --all, JSON is the first page exactly as fetched and pageInfo +// reports that more exist. (Small fixtures: the mock pins `first: 50` but +// does not need 50 nodes to prove the shape.) +await snapshotTest({ + name: "Milestone View Command - JSON Output Preserves First Page", + meta: import.meta, + colors: false, + args: ["milestone-json", "--json"], + denoArgs: commonDenoArgs, + async fn() { + const server = new MockLinearServer([ + { + queryName: "GetMilestoneDetails", + variables: { id: "milestone-json", first: 50 }, + response: milestoneJsonPage(jsonIssues(3), { + hasNextPage: true, + endCursor: "issues-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") + } + }, +}) + +// --all --json concatenates every page into issues.nodes and reports the last +// page's pageInfo. The cursor-specific mock is listed first because the mock +// matcher treats pinned variables as a subset and takes the first hit. +await snapshotTest({ + name: "Milestone View Command - --all JSON Paginates", + meta: import.meta, + colors: false, + args: ["milestone-json", "--all", "--json"], + denoArgs: commonDenoArgs, + async fn() { + const server = new MockLinearServer([ + { + queryName: "GetMilestoneDetails", + variables: { + id: "milestone-json", + first: 50, + after: "issues-cursor-1", + }, + response: milestoneJsonPage(jsonIssues(2, 3), { + hasNextPage: false, + endCursor: null, + }), + }, + { + queryName: "GetMilestoneDetails", + variables: { id: "milestone-json", first: 50 }, + response: milestoneJsonPage(jsonIssues(3), { + hasNextPage: true, + endCursor: "issues-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") + } + }, +}) diff --git a/test/commands/project/__snapshots__/project-view.test.ts.snap b/test/commands/project/__snapshots__/project-view.test.ts.snap index d04956fe..23f40a7c 100644 --- a/test/commands/project/__snapshots__/project-view.test.ts.snap +++ b/test/commands/project/__snapshots__/project-view.test.ts.snap @@ -14,6 +14,7 @@ Options: -h, --help - Show this help. -w, --web - Open in web browser -a, --app - Open in Linear.app + -j, --json - Output as JSON " stderr: @@ -100,3 +101,77 @@ stdout: stderr: "" `; + +snapshot[`Project View Command - JSON Output 1`] = ` +stdout: +'{ + "id": "project-123", + "name": "Authentication System Redesign", + "description": "Overhaul auth.", + "slugId": "auth-redesign-2024", + "icon": "🔐", + "color": "#3b82f6", + "status": { + "id": "status-1", + "name": "In Progress", + "color": "#f59e0b" + }, + "creator": { + "name": "john.admin", + "displayName": "John Admin" + }, + "lead": { + "name": "jane.lead", + "displayName": "Jane Lead" + }, + "priority": 2, + "health": "onTrack", + "startDate": "2024-01-15", + "targetDate": "2024-03-31", + "startedAt": "2024-01-15T09:00:00Z", + "completedAt": null, + "canceledAt": null, + "updatedAt": "2024-01-25T14:30:00Z", + "createdAt": "2024-01-10T10:00:00Z", + "url": "https://linear.app/acme/project/auth-redesign-2024", + "teams": { + "nodes": [ + { + "id": "team-1", + "key": "BACKEND", + "name": "Backend Team" + } + ] + }, + "issues": { + "nodes": [ + { + "id": "issue-1", + "identifier": "AUTH-101", + "title": "Implement OAuth 2.0 flow", + "state": { + "name": "In Progress", + "type": "started" + } + } + ], + "pageInfo": { + "hasNextPage": true, + "endCursor": "issues-cursor-1" + } + }, + "lastUpdate": { + "id": "update-1", + "body": "On track.", + "health": "onTrack", + "createdAt": "2024-01-25T14:30:00Z", + "user": { + "name": "jane.lead", + "displayName": "Jane Lead" + } + } +} +' +stderr: +"" +`; diff --git a/test/commands/project/project-view.test.ts b/test/commands/project/project-view.test.ts index 2e0c0260..bce60167 100644 --- a/test/commands/project/project-view.test.ts +++ b/test/commands/project/project-view.test.ts @@ -211,3 +211,79 @@ await snapshotTest({ } }, }) + +// JSON is the GraphQL project object as selected: numeric priority (no label), +// nested status/lead/teams/issues/lastUpdate, and the issues connection's +// pageInfo so callers can tell when Linear's default page was partial. +await snapshotTest({ + name: "Project View Command - JSON Output", + meta: import.meta, + colors: false, + args: ["project-123", "--json"], + denoArgs, + async fn() { + const server = new MockLinearServer([ + { + queryName: "GetProjectDetails", + variables: { id: "project-123" }, + response: { + data: { + project: { + id: "project-123", + name: "Authentication System Redesign", + description: "Overhaul auth.", + slugId: "auth-redesign-2024", + icon: "🔐", + color: "#3b82f6", + status: { id: "status-1", name: "In Progress", color: "#f59e0b" }, + creator: { name: "john.admin", displayName: "John Admin" }, + lead: { name: "jane.lead", displayName: "Jane Lead" }, + priority: 2, + health: "onTrack", + startDate: "2024-01-15", + targetDate: "2024-03-31", + startedAt: "2024-01-15T09:00:00Z", + completedAt: null, + canceledAt: null, + updatedAt: "2024-01-25T14:30:00Z", + createdAt: "2024-01-10T10:00:00Z", + url: "https://linear.app/acme/project/auth-redesign-2024", + teams: { + nodes: [{ id: "team-1", key: "BACKEND", name: "Backend Team" }], + }, + issues: { + nodes: [ + { + id: "issue-1", + identifier: "AUTH-101", + title: "Implement OAuth 2.0 flow", + state: { name: "In Progress", type: "started" }, + }, + ], + pageInfo: { hasNextPage: true, endCursor: "issues-cursor-1" }, + }, + lastUpdate: { + id: "update-1", + body: "On track.", + health: "onTrack", + createdAt: "2024-01-25T14:30:00Z", + user: { name: "jane.lead", displayName: "Jane Lead" }, + }, + }, + }, + }, + }, + ]) + + 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") + } + }, +}) diff --git a/test/commands/team/__snapshots__/team-list.test.ts.snap b/test/commands/team/__snapshots__/team-list.test.ts.snap index 91b894fd..ee648b86 100644 --- a/test/commands/team/__snapshots__/team-list.test.ts.snap +++ b/test/commands/team/__snapshots__/team-list.test.ts.snap @@ -14,6 +14,7 @@ Options: -h, --help - Show this help. -w, --web - Open in web browser -a, --app - Open in Linear.app + -j, --json - Output as JSON " stderr: @@ -27,3 +28,111 @@ stdout: stderr: "" `; + +snapshot[`Team List Command - JSON Output 1`] = ` +stdout: +'{ + "nodes": [ + { + "description": null, + "icon": null, + "color": "#3b82f6", + "cyclesEnabled": true, + "createdAt": "2024-01-01T10:00:00.000Z", + "updatedAt": "2024-06-01T10:00:00.000Z", + "archivedAt": null, + "organization": { + "id": "org-1", + "name": "Acme Corp" + }, + "id": "team-eng", + "key": "ENG", + "name": "Engineering" + }, + { + "description": "Tier 1", + "icon": "🎧", + "color": "#3b82f6", + "cyclesEnabled": true, + "createdAt": "2024-01-01T10:00:00.000Z", + "updatedAt": "2024-06-01T10:00:00.000Z", + "archivedAt": null, + "organization": { + "id": "org-1", + "name": "Acme Corp" + }, + "id": "team-sup", + "key": "SUP", + "name": "Support - Front Line - Team" + } + ], + "pageInfo": { + "hasNextPage": false, + "endCursor": null + } +} +' +stderr: +"" +`; + +snapshot[`Team List Command - Empty JSON 1`] = ` +stdout: +'{ + "nodes": [], + "pageInfo": { + "hasNextPage": false, + "endCursor": null + } +} +' +stderr: +"" +`; + +snapshot[`Team List Command - JSON Output With Pagination 1`] = ` +stdout: +'{ + "nodes": [ + { + "description": null, + "icon": null, + "color": "#3b82f6", + "cyclesEnabled": false, + "createdAt": "2024-01-01T10:00:00.000Z", + "updatedAt": "2024-06-01T10:00:00.000Z", + "archivedAt": null, + "organization": { + "id": "org-1", + "name": "Acme Corp" + }, + "id": "team-a", + "key": "ALPHA", + "name": "Alpha Team" + }, + { + "description": null, + "icon": null, + "color": "#3b82f6", + "cyclesEnabled": false, + "createdAt": "2024-01-01T10:00:00.000Z", + "updatedAt": "2024-06-01T10:00:00.000Z", + "archivedAt": null, + "organization": { + "id": "org-1", + "name": "Acme Corp" + }, + "id": "team-b", + "key": "BETA", + "name": "Beta Team" + } + ], + "pageInfo": { + "hasNextPage": false, + "endCursor": null + } +} +' +stderr: +"" +`; diff --git a/test/commands/team/team-list.test.ts b/test/commands/team/team-list.test.ts index e1bb9044..b0b6b545 100644 --- a/test/commands/team/team-list.test.ts +++ b/test/commands/team/team-list.test.ts @@ -1,4 +1,6 @@ import { snapshotTest as cliffySnapshotTest } from "@cliffy/testing" +import { assertEquals, assertStringIncludes } from "@std/assert" +import { stub } from "@std/testing/mock" import { snapshotTest } from "../../utils/snapshot_with_fake_time.ts" import { listCommand } from "../../../src/commands/team/team-list.ts" import { MockLinearServer } from "../../utils/mock_linear_server.ts" @@ -293,3 +295,182 @@ await snapshotTest({ } }, }) + +// JSON fixtures carry raw ISO timestamps, so these tests need no fake clock and +// can use the plain cliffy snapshot runner (the fake-time runner hangs the mock +// server because FakeTime replaces the setTimeout its start() awaits). +function team( + overrides: Record & { + id: string + key: string + name: string + }, +) { + return { + description: null, + icon: null, + color: "#3b82f6", + cyclesEnabled: false, + createdAt: "2024-01-01T10:00:00.000Z", + updatedAt: "2024-06-01T10:00:00.000Z", + archivedAt: null, + organization: { id: "org-1", name: "Acme Corp" }, + ...overrides, + } +} + +function teamsPage( + nodes: unknown[], + pageInfo: { hasNextPage: boolean; endCursor: string | null }, +) { + return { data: { teams: { nodes, pageInfo } } } +} + +const FIRST_PAGE_VARS = { filter: undefined, first: 100, after: undefined } + +async function runWithServer(server: MockLinearServer, args?: string[]) { + try { + await server.start() + Deno.env.set("LINEAR_GRAPHQL_ENDPOINT", server.getEndpoint()) + Deno.env.set("LINEAR_API_KEY", "Bearer test-token") + await listCommand.parse(args) + } finally { + await server.stop() + Deno.env.delete("LINEAR_GRAPHQL_ENDPOINT") + Deno.env.delete("LINEAR_API_KEY") + } +} + +// JSON is an output format for the same list the table shows: every selected +// GraphQL field, archived teams removed, sorted by name, connection shape kept. +await cliffySnapshotTest({ + name: "Team List Command - JSON Output", + meta: import.meta, + colors: false, + args: ["--json"], + denoArgs, + async fn() { + await runWithServer( + new MockLinearServer([ + { + queryName: "GetTeams", + variables: FIRST_PAGE_VARS, + response: teamsPage( + [ + team({ + id: "team-sup", + key: "SUP", + name: "Support - Front Line - Team", + cyclesEnabled: true, + description: "Tier 1", + icon: "🎧", + }), + team({ + id: "team-old", + key: "OLD", + name: "Archived Team", + archivedAt: "2023-12-01T10:00:00.000Z", + }), + team({ + id: "team-eng", + key: "ENG", + name: "Engineering", + cyclesEnabled: true, + }), + ], + { hasNextPage: false, endCursor: null }, + ), + }, + ]), + ) + }, +}) + +// An empty workspace must still emit a connection, not prose. +await cliffySnapshotTest({ + name: "Team List Command - Empty JSON", + meta: import.meta, + colors: false, + args: ["--json"], + denoArgs, + async fn() { + await runWithServer( + new MockLinearServer([ + { + queryName: "GetTeams", + variables: FIRST_PAGE_VARS, + response: teamsPage([], { hasNextPage: false, endCursor: null }), + }, + ]), + ) + }, +}) + +// Pages are concatenated before sorting (the lexically-first team is on page +// two) and the emitted pageInfo is the last page's. +await cliffySnapshotTest({ + name: "Team List Command - JSON Output With Pagination", + meta: import.meta, + colors: false, + args: ["--json"], + denoArgs, + async fn() { + await runWithServer( + new MockLinearServer([ + { + queryName: "GetTeams", + variables: FIRST_PAGE_VARS, + response: teamsPage( + [team({ id: "team-b", key: "BETA", name: "Beta Team" })], + { hasNextPage: true, endCursor: "teams-cursor-1" }, + ), + }, + { + queryName: "GetTeams", + variables: { filter: undefined, first: 100, after: "teams-cursor-1" }, + response: teamsPage( + [team({ id: "team-a", key: "ALPHA", name: "Alpha Team" })], + { hasNextPage: false, endCursor: null }, + ), + }, + ]), + ) + }, +}) + +// Linear claiming another page without a cursor must fail loudly rather than +// loop or silently return a partial list. +Deno.test("Team List Command - errors on inconsistent pagination", async () => { + const server = new MockLinearServer([ + { + queryName: "GetTeams", + variables: FIRST_PAGE_VARS, + response: teamsPage( + [team({ id: "team-a", key: "ALPHA", name: "Alpha Team" })], + { hasNextPage: true, endCursor: null }, + ), + }, + ]) + + const errorLogs: string[] = [] + const errorStub = stub(console, "error", (...args: unknown[]) => { + errorLogs.push(args.map(String).join(" ")) + }) + const exitStub = stub(Deno, "exit", (_code?: number) => { + throw new Error("EXIT") + }) + + let exited = false + try { + await runWithServer(server, ["--json"]) + } catch (e) { + if (!(e instanceof Error) || e.message !== "EXIT") throw e + exited = true + } finally { + errorStub.restore() + exitStub.restore() + } + + assertEquals(exited, true) + assertStringIncludes(errorLogs.join("\n"), "no pagination cursor") +})