diff --git a/AGENTS.md b/AGENTS.md index 7bbe9ace..80c48737 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,6 +8,8 @@ - prefer `foo == null` and `foo != null` over `foo === undefined` and `foo !== undefined` - import: use dynamic import only when necessary, the static form is preferable - avoid the typescript `any` type - prefer strict typing, if you can't find a good way to fix a type issue (particularly with graphql data or documents) explain the problem instead of working around it +- for `--json` output, preserve GraphQL field names and nesting instead of inventing CLI-specific JSON shapes +- for paginated `--json` output, preserve connection shape and concatenate `nodes` rather than flattening or renaming fields ## permissions diff --git a/src/commands/document/document-list.ts b/src/commands/document/document-list.ts index ba73c55a..071cc202 100644 --- a/src/commands/document/document-list.ts +++ b/src/commands/document/document-list.ts @@ -74,10 +74,17 @@ export const listCommand = new Command() }) spinner?.stop() - const documents = result.documents?.nodes || [] + const documentsConnection = result.documents ?? { + nodes: [], + pageInfo: { + hasNextPage: false, + endCursor: null, + }, + } + const documents = documentsConnection.nodes if (json) { - console.log(JSON.stringify(documents, null, 2)) + console.log(JSON.stringify(documentsConnection, null, 2)) return } diff --git a/src/commands/initiative-update/initiative-update-list.ts b/src/commands/initiative-update/initiative-update-list.ts index 2636bce0..9233a9e3 100644 --- a/src/commands/initiative-update/initiative-update-list.ts +++ b/src/commands/initiative-update/initiative-update-list.ts @@ -142,23 +142,8 @@ export const listCommand = new Command() const updates = initiative.initiativeUpdates?.nodes || [] - // JSON output if (json) { - const jsonOutput = { - initiative: { - name: initiative.name, - slugId: initiative.slugId, - }, - updates: updates.map((update) => ({ - id: update.id, - body: update.body, - health: update.health, - url: update.url, - createdAt: update.createdAt, - author: update.user?.name || null, - })), - } - console.log(JSON.stringify(jsonOutput, null, 2)) + console.log(JSON.stringify(initiative, null, 2)) return } diff --git a/src/commands/initiative/initiative-list.ts b/src/commands/initiative/initiative-list.ts index 391707d8..d5184925 100644 --- a/src/commands/initiative/initiative-list.ts +++ b/src/commands/initiative/initiative-list.ts @@ -43,6 +43,10 @@ const GetInitiatives = gql(` } } } + pageInfo { + hasNextPage + endCursor + } } } `) @@ -154,11 +158,19 @@ export const listCommand = new Command() }) spinner?.stop() - let initiatives = result.initiatives?.nodes || [] + const initiativesConnection = result.initiatives ?? { + nodes: [], + pageInfo: { + hasNextPage: false, + endCursor: null, + }, + } + + let initiatives = initiativesConnection.nodes if (initiatives.length === 0) { if (json) { - console.log("[]") + console.log(JSON.stringify(initiativesConnection, null, 2)) } else { console.log("No initiatives found.") } @@ -177,27 +189,15 @@ export const listCommand = new Command() return a.name.localeCompare(b.name) }) - // JSON output if (json) { - const jsonOutput = initiatives.map((init) => ({ - id: init.id, - slugId: init.slugId, - name: init.name, - description: init.description, - status: init.status, - health: init.health, - targetDate: init.targetDate, - owner: init.owner - ? { - id: init.owner.id, - displayName: init.owner.displayName, - } - : null, - projectCount: init.projects?.nodes?.length || 0, - url: init.url, - archivedAt: init.archivedAt, - })) - console.log(JSON.stringify(jsonOutput, null, 2)) + console.log(JSON.stringify( + { + ...initiativesConnection, + nodes: initiatives, + }, + null, + 2, + )) return } diff --git a/src/commands/initiative/initiative-view.ts b/src/commands/initiative/initiative-view.ts index 4505974b..9d44b0e8 100644 --- a/src/commands/initiative/initiative-view.ts +++ b/src/commands/initiative/initiative-view.ts @@ -113,37 +113,8 @@ export const viewCommand = new Command() throw new NotFoundError("Initiative", initiativeId) } - // JSON output if (json) { - const jsonOutput = { - id: initiative.id, - slugId: initiative.slugId, - name: initiative.name, - description: initiative.description, - status: initiative.status, - health: initiative.health, - targetDate: initiative.targetDate, - color: initiative.color, - icon: initiative.icon, - url: initiative.url, - archivedAt: initiative.archivedAt, - createdAt: initiative.createdAt, - updatedAt: initiative.updatedAt, - owner: initiative.owner - ? { - id: initiative.owner.id, - name: initiative.owner.name, - displayName: initiative.owner.displayName, - } - : null, - projects: (initiative.projects?.nodes || []).map((p) => ({ - id: p.id, - slugId: p.slugId, - name: p.name, - status: p.status?.name, - })), - } - console.log(JSON.stringify(jsonOutput, null, 2)) + console.log(JSON.stringify(initiative, null, 2)) return } diff --git a/src/commands/issue/issue-agent-session-list.ts b/src/commands/issue/issue-agent-session-list.ts index 62186077..7db0748c 100644 --- a/src/commands/issue/issue-agent-session-list.ts +++ b/src/commands/issue/issue-agent-session-list.ts @@ -30,6 +30,10 @@ const GetIssueAgentSessions = gql(` } } } + pageInfo { + hasNextPage + endCursor + } } } } @@ -88,7 +92,7 @@ export const agentSessionListCommand = new Command() } const { Spinner } = await import("@std/cli/unstable-spinner") - const showSpinner = shouldShowSpinner() + const showSpinner = shouldShowSpinner() && !json const spinner = showSpinner ? new Spinner() : null spinner?.start() @@ -98,16 +102,33 @@ export const agentSessionListCommand = new Command() }) spinner?.stop() - let sessions = (result.issue?.comments?.nodes || []) + const comments = result.issue?.comments ?? { + nodes: [], + pageInfo: { + hasNextPage: false, + endCursor: null, + }, + } + + let sessions = comments.nodes .map((c) => c.agentSession) .filter((s): s is NonNullable => s != null) + const jsonComments = status + ? { + ...comments, + nodes: comments.nodes.filter((comment) => + comment.agentSession?.status === status + ), + } + : comments + if (status) { sessions = sessions.filter((s) => s.status === status) } if (json) { - console.log(JSON.stringify(sessions, null, 2)) + console.log(JSON.stringify(jsonComments, null, 2)) return } diff --git a/src/commands/issue/issue-agent-session-view.ts b/src/commands/issue/issue-agent-session-view.ts index cb08784d..db54e3de 100644 --- a/src/commands/issue/issue-agent-session-view.ts +++ b/src/commands/issue/issue-agent-session-view.ts @@ -80,7 +80,7 @@ export const agentSessionViewCommand = new Command() .action(async ({ json }, sessionId) => { try { const { Spinner } = await import("@std/cli/unstable-spinner") - const showSpinner = shouldShowSpinner() + const showSpinner = shouldShowSpinner() && !json const spinner = showSpinner ? new Spinner() : null spinner?.start() diff --git a/src/commands/issue/issue-comment-list.ts b/src/commands/issue/issue-comment-list.ts index b4d379cb..2a9c4e63 100644 --- a/src/commands/issue/issue-comment-list.ts +++ b/src/commands/issue/issue-comment-list.ts @@ -45,6 +45,10 @@ export const commentListCommand = new Command() id } } + pageInfo { + hasNextPage + endCursor + } } } } @@ -53,10 +57,17 @@ export const commentListCommand = new Command() const client = getGraphQLClient() const data = await client.request(query, { id: resolvedIdentifier }) - const comments = data.issue?.comments?.nodes || [] + const commentsConnection = data.issue?.comments ?? { + nodes: [], + pageInfo: { + hasNextPage: false, + endCursor: null, + }, + } + const comments = commentsConnection.nodes if (json) { - console.log(JSON.stringify(comments, null, 2)) + console.log(JSON.stringify(commentsConnection, null, 2)) return } diff --git a/src/commands/label/label-list.ts b/src/commands/label/label-list.ts index ed5f62f4..6681d36d 100644 --- a/src/commands/label/label-list.ts +++ b/src/commands/label/label-list.ts @@ -29,13 +29,7 @@ const GetIssueLabels = gql(` } `) -interface Label { - id: string - name: string - description?: string | null - color: string - team?: { key: string; name: string } | null -} +type Label = NonNullable["nodes"][number] export const listCommand = new Command() .name("list") @@ -95,6 +89,12 @@ export const listCommand = new Command() const allLabels: Label[] = [] let hasNextPage = true let after: string | null | undefined = undefined + let pageInfo: NonNullable< + GetIssueLabelsQuery["issueLabels"] + >["pageInfo"] = { + hasNextPage: false, + endCursor: null, + } while (hasNextPage) { const result: GetIssueLabelsQuery = await client.request( @@ -106,17 +106,33 @@ export const listCommand = new Command() }, ) - const labels = result.issueLabels?.nodes || [] - allLabels.push(...(labels as Label[])) + const labelsConnection = result.issueLabels + const labels = labelsConnection?.nodes || [] + allLabels.push(...labels) - hasNextPage = result.issueLabels?.pageInfo?.hasNextPage || false - after = result.issueLabels?.pageInfo?.endCursor + pageInfo = labelsConnection?.pageInfo ?? { + hasNextPage: false, + endCursor: null, + } + hasNextPage = pageInfo.hasNextPage + after = pageInfo.endCursor } spinner?.stop() if (allLabels.length === 0) { - console.log("No labels found.") + if (json) { + console.log(JSON.stringify( + { + nodes: allLabels, + pageInfo, + }, + null, + 2, + )) + } else { + console.log("No labels found.") + } return } @@ -125,9 +141,15 @@ export const listCommand = new Command() a.name.toLowerCase().localeCompare(b.name.toLowerCase()) ) - // JSON output if (json) { - console.log(JSON.stringify(sortedLabels, null, 2)) + console.log(JSON.stringify( + { + nodes: sortedLabels, + pageInfo, + }, + null, + 2, + )) return } diff --git a/src/commands/project-update/project-update-list.ts b/src/commands/project-update/project-update-list.ts index 515c6233..6829b856 100644 --- a/src/commands/project-update/project-update-list.ts +++ b/src/commands/project-update/project-update-list.ts @@ -1,33 +1,12 @@ import { Command } from "@cliffy/command" +import { gql } from "../../__codegen__/gql.ts" import { getGraphQLClient } from "../../utils/graphql.ts" import { getTimeAgo, padDisplay, truncateText } from "../../utils/display.ts" import { resolveProjectId } from "../../utils/linear.ts" import { shouldShowSpinner } from "../../utils/hyperlink.ts" import { handleError, NotFoundError } from "../../utils/errors.ts" -interface ProjectUpdateNode { - id: string - body: string | null - health: string | null - url: string - createdAt: string - user: { - name: string - displayName: string - } | null -} - -interface ListProjectUpdatesQueryResult { - project: { - name: string - slugId: string - projectUpdates: { - nodes: ProjectUpdateNode[] - } | null - } | null -} - -const ListProjectUpdatesQuery = /* GraphQL */ ` +const ListProjectUpdatesQuery = gql(` query ListProjectUpdates($id: String!, $first: Int) { project(id: $id) { name @@ -44,10 +23,14 @@ const ListProjectUpdatesQuery = /* GraphQL */ ` displayName } } + pageInfo { + hasNextPage + endCursor + } } } } -` +`) export const listCommand = new Command() .name("list") @@ -67,13 +50,10 @@ export const listCommand = new Command() const resolvedProjectId = await resolveProjectId(projectId) const client = getGraphQLClient() - const result = await client.request( - ListProjectUpdatesQuery, - { - id: resolvedProjectId, - first: limit, - }, - ) + const result = await client.request(ListProjectUpdatesQuery, { + id: resolvedProjectId, + first: limit, + }) spinner?.stop() const project = result.project @@ -84,17 +64,7 @@ export const listCommand = new Command() const updates = project.projectUpdates?.nodes || [] if (json) { - console.log(JSON.stringify( - { - project: { - name: project.name, - slugId: project.slugId, - }, - updates, - }, - null, - 2, - )) + console.log(JSON.stringify(project, null, 2)) return } diff --git a/src/commands/project/project-create.ts b/src/commands/project/project-create.ts index 82262695..5d326f23 100644 --- a/src/commands/project/project-create.ts +++ b/src/commands/project/project-create.ts @@ -357,7 +357,7 @@ export const createCommand = new Command() } const { Spinner } = await import("@std/cli/unstable-spinner") - const showSpinner = shouldShowSpinner() + const showSpinner = shouldShowSpinner() && !jsonOutput const spinner = showSpinner ? new Spinner() : null spinner?.start() @@ -408,18 +408,7 @@ export const createCommand = new Command() } if (jsonOutput) { - console.log( - JSON.stringify( - { - id: project.id, - slugId: project.slugId, - name: project.name, - url: project.url, - }, - null, - 2, - ), - ) + console.log(JSON.stringify(result.projectCreate, null, 2)) } else { console.log(`✓ Created project: ${project.name}`) console.log(` Slug: ${project.slugId}`) diff --git a/src/commands/project/project-list.ts b/src/commands/project/project-list.ts index 7d915404..64b5228f 100644 --- a/src/commands/project/project-list.ts +++ b/src/commands/project/project-list.ts @@ -130,6 +130,10 @@ export const listCommand = new Command() const allProjects: GetProjectsQuery["projects"]["nodes"] = [] let hasNextPage = true let after: string | null | undefined = undefined + let pageInfo: NonNullable["pageInfo"] = { + hasNextPage: false, + endCursor: null, + } while (hasNextPage) { const result: GetProjectsQuery = await client.request(GetProjects, { @@ -138,11 +142,16 @@ export const listCommand = new Command() after, }) - const projects = result.projects?.nodes || [] + const projectsConnection = result.projects + const projects = projectsConnection?.nodes || [] allProjects.push(...projects) - hasNextPage = result.projects?.pageInfo?.hasNextPage || false - after = result.projects?.pageInfo?.endCursor + pageInfo = projectsConnection?.pageInfo ?? { + hasNextPage: false, + endCursor: null, + } + hasNextPage = pageInfo.hasNextPage + after = pageInfo.endCursor } spinner?.stop() @@ -152,7 +161,14 @@ export const listCommand = new Command() if (projects.length === 0) { if (json) { - console.log("[]") + console.log(JSON.stringify( + { + nodes: allProjects, + pageInfo, + }, + null, + 2, + )) } else { console.log("No projects found.") } @@ -184,35 +200,15 @@ export const listCommand = new Command() return a.name.localeCompare(b.name) }) - // JSON output if (json) { - const jsonOutput = projects.map((project) => ({ - id: project.id, - slugId: project.slugId, - name: project.name, - description: project.description, - status: { - id: project.status.id, - name: project.status.name, - type: project.status.type, + console.log(JSON.stringify( + { + nodes: projects, + pageInfo, }, - lead: project.lead - ? { - name: project.lead.name, - displayName: project.lead.displayName, - initials: project.lead.initials, - } - : null, - teams: project.teams.nodes.map((t) => t.key), - priority: project.priority, - health: project.health, - startDate: project.startDate, - targetDate: project.targetDate, - url: project.url, - createdAt: project.createdAt, - updatedAt: project.updatedAt, - })) - console.log(JSON.stringify(jsonOutput, null, 2)) + null, + 2, + )) return } diff --git a/test/commands/document/__snapshots__/document-list.test.ts.snap b/test/commands/document/__snapshots__/document-list.test.ts.snap index 399bc579..8c15110d 100644 --- a/test/commands/document/__snapshots__/document-list.test.ts.snap +++ b/test/commands/document/__snapshots__/document-list.test.ts.snap @@ -24,23 +24,29 @@ stderr: snapshot[`Document List Command - JSON Output 1`] = ` stdout: -'[ - { - "id": "doc-1", - "title": "Delegation System Spec", - "slugId": "d4b93e3b2695", - "url": "https://linear.app/test/document/delegation-system-spec-d4b93e3b2695", - "updatedAt": "2026-01-18T10:30:00Z", - "project": { - "name": "TinyCloud SDK", - "slugId": "tinycloud-sdk" - }, - "issue": null, - "creator": { - "name": "John Doe" +'{ + "nodes": [ + { + "id": "doc-1", + "title": "Delegation System Spec", + "slugId": "d4b93e3b2695", + "url": "https://linear.app/test/document/delegation-system-spec-d4b93e3b2695", + "updatedAt": "2026-01-18T10:30:00Z", + "project": { + "name": "TinyCloud SDK", + "slugId": "tinycloud-sdk" + }, + "issue": null, + "creator": { + "name": "John Doe" + } } + ], + "pageInfo": { + "hasNextPage": false, + "endCursor": null } -] +} ' stderr: "" diff --git a/test/commands/initiative-update/__snapshots__/initiative-update-list.test.ts.snap b/test/commands/initiative-update/__snapshots__/initiative-update-list.test.ts.snap new file mode 100644 index 00000000..fed4026d --- /dev/null +++ b/test/commands/initiative-update/__snapshots__/initiative-update-list.test.ts.snap @@ -0,0 +1,26 @@ +export const snapshot = {}; + +snapshot[`Initiative Update List Command - JSON Output 1`] = ` +stdout: +'{ + "name": "Alpha Initiative", + "slugId": "alpha", + "initiativeUpdates": { + "nodes": [ + { + "id": "update-1", + "body": "Everything is on track.", + "health": "onTrack", + "url": "https://linear.app/test/update-1", + "createdAt": "2026-02-15T10:00:00Z", + "user": { + "name": "alex.active" + } + } + ] + } +} +' +stderr: +"" +`; diff --git a/test/commands/initiative-update/initiative-update-list.test.ts b/test/commands/initiative-update/initiative-update-list.test.ts new file mode 100644 index 00000000..9a526af8 --- /dev/null +++ b/test/commands/initiative-update/initiative-update-list.test.ts @@ -0,0 +1,57 @@ +import { snapshotTest as cliffySnapshotTest } from "@cliffy/testing" +import { listCommand } from "../../../src/commands/initiative-update/initiative-update-list.ts" +import { commonDenoArgs } from "../../utils/test-helpers.ts" +import { MockLinearServer } from "../../utils/mock_linear_server.ts" + +await cliffySnapshotTest({ + name: "Initiative Update List Command - JSON Output", + meta: import.meta, + colors: false, + args: ["550e8400-e29b-41d4-a716-446655440000", "--json"], + denoArgs: commonDenoArgs, + async fn() { + const server = new MockLinearServer([ + { + queryName: "ListInitiativeUpdates", + variables: { + id: "550e8400-e29b-41d4-a716-446655440000", + first: 10, + }, + response: { + data: { + initiative: { + name: "Alpha Initiative", + slugId: "alpha", + initiativeUpdates: { + nodes: [ + { + id: "update-1", + body: "Everything is on track.", + health: "onTrack", + url: "https://linear.app/test/update-1", + createdAt: "2026-02-15T10:00:00Z", + user: { + name: "alex.active", + }, + }, + ], + }, + }, + }, + }, + }, + ]) + + try { + await server.start() + Deno.env.set("LINEAR_GRAPHQL_ENDPOINT", server.getEndpoint()) + Deno.env.set("LINEAR_API_KEY", "Bearer test-token") + + await listCommand.parse() + } finally { + await server.stop() + Deno.env.delete("LINEAR_GRAPHQL_ENDPOINT") + Deno.env.delete("LINEAR_API_KEY") + } + }, +}) diff --git a/test/commands/initiative/__snapshots__/initiative-list.test.ts.snap b/test/commands/initiative/__snapshots__/initiative-list.test.ts.snap new file mode 100644 index 00000000..25cda311 --- /dev/null +++ b/test/commands/initiative/__snapshots__/initiative-list.test.ts.snap @@ -0,0 +1,74 @@ +export const snapshot = {}; + +snapshot[`Initiative List Command - JSON Output 1`] = ` +stdout: +'{ + "nodes": [ + { + "id": "initiative-1", + "slugId": "alpha", + "name": "Alpha", + "description": "First initiative", + "status": "Active", + "targetDate": "2026-05-01", + "health": "onTrack", + "color": "#10b981", + "icon": "🟢", + "url": "https://linear.app/test/initiative/alpha", + "archivedAt": null, + "owner": { + "id": "owner-1", + "displayName": "Alex Active", + "initials": "AA" + }, + "projects": { + "nodes": [ + { + "id": "project-1", + "name": "Project A", + "status": { + "name": "In Progress" + } + } + ] + } + }, + { + "id": "initiative-2", + "slugId": "plan-b", + "name": "Plan B", + "description": "Second initiative", + "status": "Planned", + "targetDate": "2026-06-01", + "health": "atRisk", + "color": "#f59e0b", + "icon": "🟡", + "url": "https://linear.app/test/initiative/plan-b", + "archivedAt": null, + "owner": { + "id": "owner-2", + "displayName": "Pat Planner", + "initials": "PP" + }, + "projects": { + "nodes": [ + { + "id": "project-2", + "name": "Project B", + "status": { + "name": "Planned" + } + } + ] + } + } + ], + "pageInfo": { + "hasNextPage": false, + "endCursor": null + } +} +' +stderr: +"" +`; diff --git a/test/commands/initiative/__snapshots__/initiative-view.test.ts.snap b/test/commands/initiative/__snapshots__/initiative-view.test.ts.snap new file mode 100644 index 00000000..5b4da6e1 --- /dev/null +++ b/test/commands/initiative/__snapshots__/initiative-view.test.ts.snap @@ -0,0 +1,41 @@ +export const snapshot = {}; + +snapshot[`Initiative View Command - JSON Output 1`] = ` +stdout: +'{ + "id": "550e8400-e29b-41d4-a716-446655440000", + "slugId": "alpha", + "name": "Alpha Initiative", + "description": "Top-level initiative description.", + "status": "active", + "targetDate": "2026-05-01", + "health": "onTrack", + "color": "#10b981", + "icon": "🟢", + "url": "https://linear.app/test/initiative/alpha", + "archivedAt": null, + "createdAt": "2026-01-01T10:00:00Z", + "updatedAt": "2026-02-01T10:00:00Z", + "owner": { + "id": "owner-1", + "name": "alex.active", + "displayName": "Alex Active" + }, + "projects": { + "nodes": [ + { + "id": "project-1", + "slugId": "project-a", + "name": "Project A", + "status": { + "name": "In Progress", + "type": "started" + } + } + ] + } +} +' +stderr: +"" +`; diff --git a/test/commands/initiative/initiative-list.test.ts b/test/commands/initiative/initiative-list.test.ts new file mode 100644 index 00000000..9865c0d8 --- /dev/null +++ b/test/commands/initiative/initiative-list.test.ts @@ -0,0 +1,98 @@ +import { snapshotTest as cliffySnapshotTest } from "@cliffy/testing" +import { listCommand } from "../../../src/commands/initiative/initiative-list.ts" +import { commonDenoArgs } from "../../utils/test-helpers.ts" +import { MockLinearServer } from "../../utils/mock_linear_server.ts" + +await cliffySnapshotTest({ + name: "Initiative List Command - JSON Output", + meta: import.meta, + colors: false, + args: ["--all-statuses", "--json"], + denoArgs: commonDenoArgs, + async fn() { + const server = new MockLinearServer([ + { + queryName: "GetInitiatives", + variables: { filter: undefined, includeArchived: false }, + response: { + data: { + initiatives: { + nodes: [ + { + id: "initiative-2", + slugId: "plan-b", + name: "Plan B", + description: "Second initiative", + status: "Planned", + targetDate: "2026-06-01", + health: "atRisk", + color: "#f59e0b", + icon: "🟡", + url: "https://linear.app/test/initiative/plan-b", + archivedAt: null, + owner: { + id: "owner-2", + displayName: "Pat Planner", + initials: "PP", + }, + projects: { + nodes: [ + { + id: "project-2", + name: "Project B", + status: { name: "Planned" }, + }, + ], + }, + }, + { + id: "initiative-1", + slugId: "alpha", + name: "Alpha", + description: "First initiative", + status: "Active", + targetDate: "2026-05-01", + health: "onTrack", + color: "#10b981", + icon: "🟢", + url: "https://linear.app/test/initiative/alpha", + archivedAt: null, + owner: { + id: "owner-1", + displayName: "Alex Active", + initials: "AA", + }, + projects: { + nodes: [ + { + id: "project-1", + name: "Project A", + status: { name: "In Progress" }, + }, + ], + }, + }, + ], + pageInfo: { + hasNextPage: false, + endCursor: null, + }, + }, + }, + }, + }, + ]) + + try { + await server.start() + Deno.env.set("LINEAR_GRAPHQL_ENDPOINT", server.getEndpoint()) + Deno.env.set("LINEAR_API_KEY", "Bearer test-token") + + await listCommand.parse() + } finally { + await server.stop() + Deno.env.delete("LINEAR_GRAPHQL_ENDPOINT") + Deno.env.delete("LINEAR_API_KEY") + } + }, +}) diff --git a/test/commands/initiative/initiative-view.test.ts b/test/commands/initiative/initiative-view.test.ts new file mode 100644 index 00000000..e08421c9 --- /dev/null +++ b/test/commands/initiative/initiative-view.test.ts @@ -0,0 +1,69 @@ +import { snapshotTest as cliffySnapshotTest } from "@cliffy/testing" +import { viewCommand } from "../../../src/commands/initiative/initiative-view.ts" +import { commonDenoArgs } from "../../utils/test-helpers.ts" +import { MockLinearServer } from "../../utils/mock_linear_server.ts" + +await cliffySnapshotTest({ + name: "Initiative View Command - JSON Output", + meta: import.meta, + colors: false, + args: ["550e8400-e29b-41d4-a716-446655440000", "--json"], + denoArgs: commonDenoArgs, + async fn() { + const server = new MockLinearServer([ + { + queryName: "GetInitiativeDetails", + variables: { id: "550e8400-e29b-41d4-a716-446655440000" }, + response: { + data: { + initiative: { + id: "550e8400-e29b-41d4-a716-446655440000", + slugId: "alpha", + name: "Alpha Initiative", + description: "Top-level initiative description.", + status: "active", + targetDate: "2026-05-01", + health: "onTrack", + color: "#10b981", + icon: "🟢", + url: "https://linear.app/test/initiative/alpha", + archivedAt: null, + createdAt: "2026-01-01T10:00:00Z", + updatedAt: "2026-02-01T10:00:00Z", + owner: { + id: "owner-1", + name: "alex.active", + displayName: "Alex Active", + }, + projects: { + nodes: [ + { + id: "project-1", + slugId: "project-a", + name: "Project A", + status: { + name: "In Progress", + type: "started", + }, + }, + ], + }, + }, + }, + }, + }, + ]) + + 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/issue/__snapshots__/issue-agent-session-list.test.ts.snap b/test/commands/issue/__snapshots__/issue-agent-session-list.test.ts.snap index 3fb52c44..29bbae60 100644 --- a/test/commands/issue/__snapshots__/issue-agent-session-list.test.ts.snap +++ b/test/commands/issue/__snapshots__/issue-agent-session-list.test.ts.snap @@ -38,3 +38,35 @@ stdout: stderr: "" `; + +snapshot[`Issue Agent Session List Command - JSON Output 1`] = ` +stdout: +'{ + "nodes": [ + { + "agentSession": { + "id": "session-1", + "status": "active", + "type": "commentThread", + "createdAt": "2026-03-20T10:00:00.000Z", + "startedAt": "2026-03-20T10:00:05.000Z", + "endedAt": null, + "summary": "Investigating auth token refresh bug", + "creator": { + "name": "Alice" + }, + "appUser": { + "name": "Linear Assistant" + } + } + } + ], + "pageInfo": { + "hasNextPage": false, + "endCursor": null + } +} +' +stderr: +"" +`; diff --git a/test/commands/issue/__snapshots__/issue-agent-session-view.test.ts.snap b/test/commands/issue/__snapshots__/issue-agent-session-view.test.ts.snap index 3f68e965..d7437d23 100644 --- a/test/commands/issue/__snapshots__/issue-agent-session-view.test.ts.snap +++ b/test/commands/issue/__snapshots__/issue-agent-session-view.test.ts.snap @@ -71,3 +71,37 @@ Added dark mode toggle to settings page stderr: "" `; + +snapshot[`Issue Agent Session View Command - JSON Output 1`] = ` +stdout: +'{ + "id": "session-3", + "status": "stale", + "type": "commentThread", + "createdAt": "2020-01-02T10:00:00Z", + "updatedAt": "2020-01-02T10:30:00Z", + "startedAt": "2020-01-02T10:00:05Z", + "endedAt": null, + "dismissedAt": null, + "summary": "Session JSON output", + "externalLink": null, + "creator": { + "name": "Casey" + }, + "appUser": { + "name": "Linear Assistant" + }, + "dismissedBy": null, + "issue": { + "identifier": "ENG-500", + "title": "JSON issue", + "url": "https://linear.app/eng/issue/ENG-500" + }, + "activities": { + "nodes": [] + } +} +' +stderr: +"" +`; diff --git a/test/commands/issue/__snapshots__/issue-comment-list.test.ts.snap b/test/commands/issue/__snapshots__/issue-comment-list.test.ts.snap index 4777645c..fd08c384 100644 --- a/test/commands/issue/__snapshots__/issue-comment-list.test.ts.snap +++ b/test/commands/issue/__snapshots__/issue-comment-list.test.ts.snap @@ -18,21 +18,27 @@ stderr: snapshot[`Issue Comment List Command - JSON Output 1`] = ` stdout: -'[ - { - "id": "comment-uuid-456", - "body": "This is a comment", - "createdAt": "2024-01-15T10:30:00Z", - "updatedAt": "2024-01-15T10:30:00Z", - "url": "https://linear.app/issue/TEST-123#comment-uuid-456", - "user": { - "name": "testuser", - "displayName": "Test User" - }, - "externalUser": null, - "parent": null +'{ + "nodes": [ + { + "id": "comment-uuid-456", + "body": "This is a comment", + "createdAt": "2024-01-15T10:30:00Z", + "updatedAt": "2024-01-15T10:30:00Z", + "url": "https://linear.app/issue/TEST-123#comment-uuid-456", + "user": { + "name": "testuser", + "displayName": "Test User" + }, + "externalUser": null, + "parent": null + } + ], + "pageInfo": { + "hasNextPage": false, + "endCursor": null } -] +} ' stderr: "" diff --git a/test/commands/issue/issue-agent-session-list.test.ts b/test/commands/issue/issue-agent-session-list.test.ts index be76c63a..a5279fb5 100644 --- a/test/commands/issue/issue-agent-session-list.test.ts +++ b/test/commands/issue/issue-agent-session-list.test.ts @@ -120,3 +120,74 @@ await cliffySnapshotTest({ } }, }) + +await cliffySnapshotTest({ + name: "Issue Agent Session List Command - JSON Output", + meta: import.meta, + colors: false, + args: ["ENG-412", "--json", "--status", "active"], + denoArgs: commonDenoArgs, + async fn() { + const server = new MockLinearServer([ + { + queryName: "GetIssueAgentSessions", + variables: { issueId: "ENG-412" }, + response: { + data: { + issue: { + comments: { + nodes: [ + { + agentSession: { + id: "session-1", + status: "active", + type: "commentThread", + createdAt: "2026-03-20T10:00:00.000Z", + startedAt: "2026-03-20T10:00:05.000Z", + endedAt: null, + summary: "Investigating auth token refresh bug", + creator: { name: "Alice" }, + appUser: { name: "Linear Assistant" }, + }, + }, + { + agentSession: { + id: "session-2", + status: "complete", + type: "commentThread", + createdAt: "2026-03-19T15:30:00.000Z", + startedAt: "2026-03-19T15:30:05.000Z", + endedAt: "2026-03-19T16:00:00.000Z", + summary: "Added dark mode toggle to settings page", + creator: { name: "Bob" }, + appUser: { name: "Linear Assistant" }, + }, + }, + { + agentSession: null, + }, + ], + pageInfo: { + hasNextPage: false, + endCursor: null, + }, + }, + }, + }, + }, + }, + ]) + + try { + await server.start() + Deno.env.set("LINEAR_GRAPHQL_ENDPOINT", server.getEndpoint()) + Deno.env.set("LINEAR_API_KEY", "Bearer test-token") + + await agentSessionListCommand.parse() + } finally { + await server.stop() + Deno.env.delete("LINEAR_GRAPHQL_ENDPOINT") + Deno.env.delete("LINEAR_API_KEY") + } + }, +}) diff --git a/test/commands/issue/issue-agent-session-view.test.ts b/test/commands/issue/issue-agent-session-view.test.ts index d6b4c2f1..06349a66 100644 --- a/test/commands/issue/issue-agent-session-view.test.ts +++ b/test/commands/issue/issue-agent-session-view.test.ts @@ -155,3 +155,58 @@ await snapshotTest({ } }, }) + +await snapshotTest({ + name: "Issue Agent Session View Command - JSON Output", + meta: import.meta, + colors: false, + args: ["session-3", "--json"], + denoArgs: commonDenoArgs, + async fn() { + const server = new MockLinearServer([ + { + queryName: "GetAgentSessionDetails", + variables: { id: "session-3" }, + response: { + data: { + agentSession: { + id: "session-3", + status: "stale", + type: "commentThread", + createdAt: "2020-01-02T10:00:00Z", + updatedAt: "2020-01-02T10:30:00Z", + startedAt: "2020-01-02T10:00:05Z", + endedAt: null, + dismissedAt: null, + summary: "Session JSON output", + externalLink: null, + creator: { name: "Casey" }, + appUser: { name: "Linear Assistant" }, + dismissedBy: null, + issue: { + identifier: "ENG-500", + title: "JSON issue", + url: "https://linear.app/eng/issue/ENG-500", + }, + activities: { + nodes: [], + }, + }, + }, + }, + }, + ]) + + try { + await server.start() + Deno.env.set("LINEAR_GRAPHQL_ENDPOINT", server.getEndpoint()) + Deno.env.set("LINEAR_API_KEY", "Bearer test-token") + + await agentSessionViewCommand.parse() + } finally { + await server.stop() + Deno.env.delete("LINEAR_GRAPHQL_ENDPOINT") + Deno.env.delete("LINEAR_API_KEY") + } + }, +}) diff --git a/test/commands/issue/issue-comment-list.test.ts b/test/commands/issue/issue-comment-list.test.ts index 3add4e49..9cdd0fa5 100644 --- a/test/commands/issue/issue-comment-list.test.ts +++ b/test/commands/issue/issue-comment-list.test.ts @@ -74,6 +74,10 @@ await snapshotTest({ parent: null, }, ], + pageInfo: { + hasNextPage: false, + endCursor: null, + }, }, }, }, @@ -130,6 +134,10 @@ await snapshotTest({ parent: null, }, ], + pageInfo: { + hasNextPage: false, + endCursor: null, + }, }, }, }, @@ -172,6 +180,10 @@ await snapshotTest({ issue: { comments: { nodes: [], + pageInfo: { + hasNextPage: false, + endCursor: null, + }, }, }, }, diff --git a/test/commands/label/__snapshots__/label-list.test.ts.snap b/test/commands/label/__snapshots__/label-list.test.ts.snap new file mode 100644 index 00000000..474c3887 --- /dev/null +++ b/test/commands/label/__snapshots__/label-list.test.ts.snap @@ -0,0 +1,47 @@ +export const snapshot = {}; + +snapshot[`Label List Command - JSON Output 1`] = ` +stdout: +'{ + "nodes": [ + { + "id": "label-2", + "name": "backend", + "description": "Backend label", + "color": "#00ff00", + "team": { + "key": "ENG", + "name": "Engineering" + } + }, + { + "id": "label-1", + "name": "bug", + "description": "Bug label", + "color": "#ff0000", + "team": null + } + ], + "pageInfo": { + "hasNextPage": false, + "endCursor": null + } +} +' +stderr: +"" +`; + +snapshot[`Label List Command - Empty JSON Output 1`] = ` +stdout: +'{ + "nodes": [], + "pageInfo": { + "hasNextPage": false, + "endCursor": null + } +} +' +stderr: +"" +`; diff --git a/test/commands/label/label-list.test.ts b/test/commands/label/label-list.test.ts new file mode 100644 index 00000000..56038548 --- /dev/null +++ b/test/commands/label/label-list.test.ts @@ -0,0 +1,100 @@ +import { snapshotTest } from "@cliffy/testing" +import { listCommand } from "../../../src/commands/label/label-list.ts" +import { MockLinearServer } from "../../utils/mock_linear_server.ts" +import { commonDenoArgs } from "../../utils/test-helpers.ts" + +await snapshotTest({ + name: "Label List Command - JSON Output", + meta: import.meta, + colors: false, + args: ["--all", "--json"], + denoArgs: commonDenoArgs, + async fn() { + const server = new MockLinearServer([ + { + queryName: "GetIssueLabels", + variables: { filter: undefined, first: 100, after: undefined }, + response: { + data: { + issueLabels: { + nodes: [ + { + id: "label-2", + name: "backend", + description: "Backend label", + color: "#00ff00", + team: { + key: "ENG", + name: "Engineering", + }, + }, + { + id: "label-1", + name: "bug", + description: "Bug label", + color: "#ff0000", + team: null, + }, + ], + pageInfo: { + hasNextPage: false, + endCursor: null, + }, + }, + }, + }, + }, + ]) + + try { + await server.start() + Deno.env.set("LINEAR_GRAPHQL_ENDPOINT", server.getEndpoint()) + Deno.env.set("LINEAR_API_KEY", "Bearer test-token") + + await listCommand.parse() + } finally { + await server.stop() + Deno.env.delete("LINEAR_GRAPHQL_ENDPOINT") + Deno.env.delete("LINEAR_API_KEY") + } + }, +}) + +await snapshotTest({ + name: "Label List Command - Empty JSON Output", + meta: import.meta, + colors: false, + args: ["--all", "--json"], + denoArgs: commonDenoArgs, + async fn() { + const server = new MockLinearServer([ + { + queryName: "GetIssueLabels", + variables: { filter: undefined, first: 100, after: undefined }, + response: { + data: { + issueLabels: { + nodes: [], + pageInfo: { + hasNextPage: false, + endCursor: null, + }, + }, + }, + }, + }, + ]) + + try { + await server.start() + Deno.env.set("LINEAR_GRAPHQL_ENDPOINT", server.getEndpoint()) + Deno.env.set("LINEAR_API_KEY", "Bearer test-token") + + await listCommand.parse() + } finally { + await server.stop() + Deno.env.delete("LINEAR_GRAPHQL_ENDPOINT") + Deno.env.delete("LINEAR_API_KEY") + } + }, +}) diff --git a/test/commands/project-update/__snapshots__/project-update-list.test.ts.snap b/test/commands/project-update/__snapshots__/project-update-list.test.ts.snap new file mode 100644 index 00000000..6cab22d2 --- /dev/null +++ b/test/commands/project-update/__snapshots__/project-update-list.test.ts.snap @@ -0,0 +1,31 @@ +export const snapshot = {}; + +snapshot[`Project Update List Command - JSON Output 1`] = ` +stdout: +'{ + "name": "JSON Project", + "slugId": "json-project", + "projectUpdates": { + "nodes": [ + { + "id": "project-update-1", + "body": "Project is healthy.", + "health": "onTrack", + "url": "https://linear.app/test/project-update-1", + "createdAt": "2026-02-10T09:00:00Z", + "user": { + "name": "alex.active", + "displayName": "Alex Active" + } + } + ], + "pageInfo": { + "hasNextPage": false, + "endCursor": null + } + } +} +' +stderr: +"" +`; diff --git a/test/commands/project-update/project-update-list.test.ts b/test/commands/project-update/project-update-list.test.ts new file mode 100644 index 00000000..f970da38 --- /dev/null +++ b/test/commands/project-update/project-update-list.test.ts @@ -0,0 +1,62 @@ +import { snapshotTest as cliffySnapshotTest } from "@cliffy/testing" +import { listCommand } from "../../../src/commands/project-update/project-update-list.ts" +import { commonDenoArgs } from "../../utils/test-helpers.ts" +import { MockLinearServer } from "../../utils/mock_linear_server.ts" + +await cliffySnapshotTest({ + name: "Project Update List Command - JSON Output", + meta: import.meta, + colors: false, + args: ["550e8400-e29b-41d4-a716-446655440000", "--json"], + denoArgs: commonDenoArgs, + async fn() { + const server = new MockLinearServer([ + { + queryName: "ListProjectUpdates", + variables: { + id: "550e8400-e29b-41d4-a716-446655440000", + first: 10, + }, + response: { + data: { + project: { + name: "JSON Project", + slugId: "json-project", + projectUpdates: { + nodes: [ + { + id: "project-update-1", + body: "Project is healthy.", + health: "onTrack", + url: "https://linear.app/test/project-update-1", + createdAt: "2026-02-10T09:00:00Z", + user: { + name: "alex.active", + displayName: "Alex Active", + }, + }, + ], + pageInfo: { + hasNextPage: false, + endCursor: null, + }, + }, + }, + }, + }, + }, + ]) + + try { + await server.start() + Deno.env.set("LINEAR_GRAPHQL_ENDPOINT", server.getEndpoint()) + Deno.env.set("LINEAR_API_KEY", "Bearer test-token") + + await listCommand.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-create.test.ts.snap b/test/commands/project/__snapshots__/project-create.test.ts.snap index c652b7a9..59b0c524 100644 --- a/test/commands/project/__snapshots__/project-create.test.ts.snap +++ b/test/commands/project/__snapshots__/project-create.test.ts.snap @@ -31,10 +31,13 @@ stderr: snapshot[`Project Create Command - With JSON Output 1`] = ` stdout: '{ - "id": "550e8400-e29b-41d4-a716-446655440000", - "slugId": "json-test-project", - "name": "JSON Test Project", - "url": "https://linear.app/test/project/json-test-project" + "success": true, + "project": { + "id": "550e8400-e29b-41d4-a716-446655440000", + "slugId": "json-test-project", + "name": "JSON Test Project", + "url": "https://linear.app/test/project/json-test-project" + } } ' stderr: diff --git a/test/commands/project/__snapshots__/project-list.test.ts.snap b/test/commands/project/__snapshots__/project-list.test.ts.snap index ad7fdd27..43ed6901 100644 --- a/test/commands/project/__snapshots__/project-list.test.ts.snap +++ b/test/commands/project/__snapshots__/project-list.test.ts.snap @@ -34,42 +34,147 @@ stderr: snapshot[`Project List Command - No Projects Found JSON 1`] = ` stdout: -"[] -" +'{ + "nodes": [], + "pageInfo": { + "hasNextPage": false, + "endCursor": null + } +} +' stderr: "" `; snapshot[`Project List Command - With JSON Output 1`] = ` stdout: -'[ - { - "id": "project-json-1", - "slugId": "json-proj", - "name": "JSON Test Project", - "description": "A project for JSON output", - "status": { - "id": "status-1", - "name": "In Progress", - "type": "started" - }, - "lead": { - "name": "test.user", - "displayName": "Test User", - "initials": "TU" +'{ + "nodes": [ + { + "id": "project-json-1", + "name": "JSON Test Project", + "description": "A project for JSON output", + "slugId": "json-proj", + "icon": null, + "color": "#3b82f6", + "status": { + "id": "status-1", + "name": "In Progress", + "color": "#f59e0b", + "type": "started" + }, + "lead": { + "name": "test.user", + "displayName": "Test User", + "initials": "TU" + }, + "priority": 2, + "health": "onTrack", + "startDate": "2024-01-15", + "targetDate": "2024-03-30", + "startedAt": "2024-01-16T09:00:00Z", + "completedAt": null, + "canceledAt": null, + "createdAt": "2024-01-10T10:00:00Z", + "updatedAt": "2024-01-20T15:30:00Z", + "url": "https://linear.app/test/project/json-proj", + "teams": { + "nodes": [ + { + "key": "ENG" + } + ] + } + } + ], + "pageInfo": { + "hasNextPage": false, + "endCursor": null + } +} +' +stderr: +"" +`; + +snapshot[`Project List Command - JSON Output With Pagination 1`] = ` +stdout: +'{ + "nodes": [ + { + "id": "project-page1-1", + "name": "Alpha Project", + "description": "First page project", + "slugId": "alpha-proj", + "icon": null, + "color": "#3b82f6", + "status": { + "id": "status-1", + "name": "In Progress", + "color": "#f59e0b", + "type": "started" + }, + "lead": null, + "priority": 2, + "health": "onTrack", + "startDate": null, + "targetDate": null, + "startedAt": null, + "completedAt": null, + "canceledAt": null, + "createdAt": "2024-01-10T10:00:00Z", + "updatedAt": "2024-01-20T15:30:00Z", + "url": "https://linear.app/test/project/alpha-proj", + "teams": { + "nodes": [ + { + "key": "ENG" + } + ] + } }, - "teams": [ - "ENG" - ], - "priority": 2, - "health": "onTrack", - "startDate": "2024-01-15", - "targetDate": "2024-03-30", - "url": "https://linear.app/test/project/json-proj", - "createdAt": "2024-01-10T10:00:00Z", - "updatedAt": "2024-01-20T15:30:00Z" + { + "id": "project-page2-1", + "name": "Beta Project", + "description": "Second page project", + "slugId": "beta-proj", + "icon": null, + "color": "#10b981", + "status": { + "id": "status-2", + "name": "Planned", + "color": "#6366f1", + "type": "planned" + }, + "lead": { + "name": "pat.planner", + "displayName": "Pat Planner", + "initials": "PP" + }, + "priority": 3, + "health": null, + "startDate": null, + "targetDate": null, + "startedAt": null, + "completedAt": null, + "canceledAt": null, + "createdAt": "2024-01-11T10:00:00Z", + "updatedAt": "2024-01-21T15:30:00Z", + "url": "https://linear.app/test/project/beta-proj", + "teams": { + "nodes": [ + { + "key": "OPS" + } + ] + } + } + ], + "pageInfo": { + "hasNextPage": false, + "endCursor": null } -] +} ' stderr: "" diff --git a/test/commands/project/project-list.test.ts b/test/commands/project/project-list.test.ts index 41fabb65..025fa6c5 100644 --- a/test/commands/project/project-list.test.ts +++ b/test/commands/project/project-list.test.ts @@ -501,3 +501,119 @@ await snapshotTest({ } }, }) + +await cliffySnapshotTest({ + name: "Project List Command - JSON Output With Pagination", + meta: import.meta, + colors: false, + args: ["--all-teams", "--json"], + denoArgs: commonDenoArgs, + async fn() { + const server = new MockLinearServer([ + { + queryName: "GetProjects", + variables: { filter: undefined, first: 100, after: undefined }, + response: { + data: { + projects: { + nodes: [ + { + id: "project-page1-1", + name: "Alpha Project", + description: "First page project", + slugId: "alpha-proj", + icon: null, + color: "#3b82f6", + status: { + id: "status-1", + name: "In Progress", + color: "#f59e0b", + type: "started", + }, + lead: null, + priority: 2, + health: "onTrack", + startDate: null, + targetDate: null, + startedAt: null, + completedAt: null, + canceledAt: null, + createdAt: "2024-01-10T10:00:00Z", + updatedAt: "2024-01-20T15:30:00Z", + url: "https://linear.app/test/project/alpha-proj", + teams: { + nodes: [{ key: "ENG" }], + }, + }, + ], + pageInfo: { + hasNextPage: true, + endCursor: "cursor-1", + }, + }, + }, + }, + }, + { + queryName: "GetProjects", + variables: { filter: undefined, first: 100, after: "cursor-1" }, + response: { + data: { + projects: { + nodes: [ + { + id: "project-page2-1", + name: "Beta Project", + description: "Second page project", + slugId: "beta-proj", + icon: null, + color: "#10b981", + status: { + id: "status-2", + name: "Planned", + color: "#6366f1", + type: "planned", + }, + lead: { + name: "pat.planner", + displayName: "Pat Planner", + initials: "PP", + }, + priority: 3, + health: null, + startDate: null, + targetDate: null, + startedAt: null, + completedAt: null, + canceledAt: null, + createdAt: "2024-01-11T10:00:00Z", + updatedAt: "2024-01-21T15:30:00Z", + url: "https://linear.app/test/project/beta-proj", + teams: { + nodes: [{ key: "OPS" }], + }, + }, + ], + pageInfo: { + hasNextPage: false, + endCursor: null, + }, + }, + }, + }, + }, + ]) + + try { + await server.start() + Deno.env.set("LINEAR_GRAPHQL_ENDPOINT", server.getEndpoint()) + Deno.env.set("LINEAR_API_KEY", "Bearer test-token") + + await listCommand.parse() + } finally { + await server.stop() + Deno.env.delete("LINEAR_GRAPHQL_ENDPOINT") + Deno.env.delete("LINEAR_API_KEY") + } + }, +})