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") +})