diff --git a/deno.lock b/deno.lock index a4c571e..5e750bd 100644 --- a/deno.lock +++ b/deno.lock @@ -4,6 +4,7 @@ "jsr:@cliffy/ansi@1.0.0-rc.8": "1.0.0-rc.8", "jsr:@cliffy/ansi@^1.0.0-rc.8": "1.0.0-rc.8", "jsr:@cliffy/command@1.0.0-rc.7": "1.0.0-rc.7", + "jsr:@cliffy/command@1.0.0-rc.8": "1.0.0-rc.8", "jsr:@cliffy/command@^1.0.0-rc.8": "1.0.0-rc.8", "jsr:@cliffy/flags@1.0.0-rc.7": "1.0.0-rc.7", "jsr:@cliffy/flags@1.0.0-rc.8": "1.0.0-rc.8", diff --git a/skills/linear-cli/SKILL.md b/skills/linear-cli/SKILL.md index b0368e6..368fc3d 100644 --- a/skills/linear-cli/SKILL.md +++ b/skills/linear-cli/SKILL.md @@ -8,7 +8,7 @@ allowed-tools: Bash(linear:*), Bash(curl:*) A CLI to manage Linear issues from the command line, with git and jj integration. -Generated from linear CLI v1.8.1 +Generated from linear CLI v1.9.1 ## Prerequisites @@ -36,6 +36,7 @@ linear label # Manage Linear issue labels linear document # Manage Linear documents linear config # Interactively generate .linear.toml configuration linear schema # Print the GraphQL schema to stdout +linear api # Make a raw GraphQL API request ``` ## Reference Documentation @@ -52,6 +53,7 @@ linear schema # Print the GraphQL schema to stdout - [document](references/document.md) - Manage Linear documents - [config](references/config.md) - Interactively generate .linear.toml configuration - [schema](references/schema.md) - Print the GraphQL schema to stdout +- [api](references/api.md) - Make a raw GraphQL API request For curated examples of organization features (initiatives, labels, projects, bulk operations), see [organization-features](references/organization-features.md). @@ -70,47 +72,48 @@ Each command has detailed help output describing all available flags and options ## Using the Linear GraphQL API Directly -**Prefer the CLI for all supported operations.** Direct API calls via curl are slower and should only be used as a fallback for advanced queries not covered by the CLI. For complex queries involving multiple calls, write and execute a script. +**Prefer the CLI for all supported operations.** The `api` command should only be used as a fallback for queries not covered by the CLI. -To make direct API calls, use `linear schema` and `linear auth token`: - -### 1. Check the schema for available types and fields +### Check the schema for available types and fields Write the schema to a tempfile, then search it: ```bash -# Write schema to a tempfile (cross-platform) linear schema -o "${TMPDIR:-/tmp}/linear-schema.graphql" - -# Search for specific types or fields grep -i "cycle" "${TMPDIR:-/tmp}/linear-schema.graphql" grep -A 30 "^type Issue " "${TMPDIR:-/tmp}/linear-schema.graphql" - -# View filter options -grep -A 50 "^input IssueFilter" "${TMPDIR:-/tmp}/linear-schema.graphql" ``` -### 2. Get the auth token +### Make a GraphQL request ```bash -linear auth token -``` +# Simple query +linear api '{ viewer { id name email } }' -### 3. Make a curl request +# Query with variables (coerces types: booleans, numbers, null) +linear api 'query($teamId: String!) { team(id: $teamId) { name } }' --variable teamId=abc123 -```bash -curl -s -X POST https://api.linear.app/graphql \ - -H "Content-Type: application/json" \ - -H "Authorization: $(linear auth token)" \ - -d '{"query": "{ issues(filter: { team: { key: { eq: \"CLI\" } } }, first: 5) { nodes { identifier title state { name } } } }"}' +# Numeric and boolean variables +linear api 'query($first: Int!) { issues(first: $first) { nodes { title } } }' --variable first=5 + +# Complex variables via JSON +linear api 'query($filter: IssueFilter!) { issues(filter: $filter) { nodes { title } } }' \ + --variables-json '{"filter": {"state": {"name": {"eq": "In Progress"}}}}' + +# Read query from stdin +echo '{ viewer { id } }' | linear api + +# Pipe to jq for filtering +linear api '{ issues(first: 5) { nodes { identifier title } } }' | jq '.data.issues.nodes[].title' ``` -### Example queries +### Advanced: Using curl directly + +For cases where you need full HTTP control, use `linear auth token`: ```bash -# Get issues assigned to current user curl -s -X POST https://api.linear.app/graphql \ -H "Content-Type: application/json" \ -H "Authorization: $(linear auth token)" \ - -d '{"query": "{ viewer { assignedIssues(first: 10) { nodes { identifier title state { name } } } } }"}' + -d '{"query": "{ viewer { id } }"}' ``` diff --git a/skills/linear-cli/SKILL.template.md b/skills/linear-cli/SKILL.template.md index f17497e..0bb0823 100644 --- a/skills/linear-cli/SKILL.template.md +++ b/skills/linear-cli/SKILL.template.md @@ -46,47 +46,48 @@ Each command has detailed help output describing all available flags and options ## Using the Linear GraphQL API Directly -**Prefer the CLI for all supported operations.** Direct API calls via curl are slower and should only be used as a fallback for advanced queries not covered by the CLI. For complex queries involving multiple calls, write and execute a script. +**Prefer the CLI for all supported operations.** The `api` command should only be used as a fallback for queries not covered by the CLI. -To make direct API calls, use `linear schema` and `linear auth token`: - -### 1. Check the schema for available types and fields +### Check the schema for available types and fields Write the schema to a tempfile, then search it: ```bash -# Write schema to a tempfile (cross-platform) linear schema -o "${TMPDIR:-/tmp}/linear-schema.graphql" - -# Search for specific types or fields grep -i "cycle" "${TMPDIR:-/tmp}/linear-schema.graphql" grep -A 30 "^type Issue " "${TMPDIR:-/tmp}/linear-schema.graphql" - -# View filter options -grep -A 50 "^input IssueFilter" "${TMPDIR:-/tmp}/linear-schema.graphql" ``` -### 2. Get the auth token +### Make a GraphQL request ```bash -linear auth token -``` +# Simple query +linear api '{ viewer { id name email } }' -### 3. Make a curl request +# Query with variables (coerces types: booleans, numbers, null) +linear api 'query($teamId: String!) { team(id: $teamId) { name } }' --variable teamId=abc123 -```bash -curl -s -X POST https://api.linear.app/graphql \ - -H "Content-Type: application/json" \ - -H "Authorization: $(linear auth token)" \ - -d '{"query": "{ issues(filter: { team: { key: { eq: \"CLI\" } } }, first: 5) { nodes { identifier title state { name } } } }"}' +# Numeric and boolean variables +linear api 'query($first: Int!) { issues(first: $first) { nodes { title } } }' --variable first=5 + +# Complex variables via JSON +linear api 'query($filter: IssueFilter!) { issues(filter: $filter) { nodes { title } } }' \ + --variables-json '{"filter": {"state": {"name": {"eq": "In Progress"}}}}' + +# Read query from stdin +echo '{ viewer { id } }' | linear api + +# Pipe to jq for filtering +linear api '{ issues(first: 5) { nodes { identifier title } } }' | jq '.data.issues.nodes[].title' ``` -### Example queries +### Advanced: Using curl directly + +For cases where you need full HTTP control, use `linear auth token`: ```bash -# Get issues assigned to current user curl -s -X POST https://api.linear.app/graphql \ -H "Content-Type: application/json" \ -H "Authorization: $(linear auth token)" \ - -d '{"query": "{ viewer { assignedIssues(first: 10) { nodes { identifier title state { name } } } } }"}' + -d '{"query": "{ viewer { id } }"}' ``` diff --git a/skills/linear-cli/references/api.md b/skills/linear-cli/references/api.md new file mode 100644 index 0000000..919885e --- /dev/null +++ b/skills/linear-cli/references/api.md @@ -0,0 +1,23 @@ +# api + +> Make a raw GraphQL API request + +## Usage + +``` +Usage: linear api [query] +Version: 1.9.1 + +Description: + + Make a raw GraphQL API request + +Options: + + -h, --help - Show this help. + -w, --workspace - Target workspace (uses credentials) + --variable - Variable in key=value format (coerces booleans, numbers, null; @file reads from path) + --variables-json - JSON object of variables (merged with --variable, which takes precedence) + --paginate - Automatically fetch all pages using cursor pagination + --silent - Suppress response output (exit code still reflects errors) +``` diff --git a/skills/linear-cli/references/commands.md b/skills/linear-cli/references/commands.md index 5e89873..e9fc570 100644 --- a/skills/linear-cli/references/commands.md +++ b/skills/linear-cli/references/commands.md @@ -1,6 +1,6 @@ # Linear CLI Command Reference -Generated from linear CLI v1.8.1 +Generated from linear CLI v1.9.1 ## Commands @@ -16,6 +16,7 @@ Generated from linear CLI v1.8.1 - [document](./document.md) - Manage Linear documents - [config](./config.md) - Interactively generate .linear.toml configuration - [schema](./schema.md) - Print the GraphQL schema to stdout +- [api](./api.md) - Make a raw GraphQL API request ## Quick Reference diff --git a/src/commands/api.ts b/src/commands/api.ts new file mode 100644 index 0000000..3d6f2ea --- /dev/null +++ b/src/commands/api.ts @@ -0,0 +1,436 @@ +import { + type ArgumentValue, + Command, + Type, + ValidationError, +} from "@cliffy/command" +import denoConfig from "../../deno.json" with { type: "json" } +import { getGraphQLEndpoint, getResolvedApiKey } from "../utils/graphql.ts" +import { + CliError, + handleError, + ValidationError as AppValidationError, +} from "../utils/errors.ts" + +class VariableType extends Type<[string, string]> { + parse({ value }: ArgumentValue): [string, string] { + const [key, ...rest] = value.split("=") + if (rest.length === 0) { + throw new ValidationError( + `Invalid variable format: ${value}. Variables must be in key=value format, e.g. --variable teamId=abc`, + ) + } + return [key, rest.join("=")] + } +} + +export const apiCommand = new Command() + .name("api") + .description("Make a raw GraphQL API request") + .type("variable", new VariableType()) + .arguments("[query:string]") + .option( + "--variable ", + "Variable in key=value format (coerces booleans, numbers, null; @file reads from path)", + { collect: true }, + ) + .option( + "--variables-json ", + "JSON object of variables (merged with --variable, which takes precedence)", + ) + .option( + "--paginate", + "Auto-paginate a single connection field using cursor pagination", + ) + .option( + "--silent", + "Suppress response output (exit code still reflects errors)", + ) + .action(async (options, query?: string) => { + try { + const resolvedQuery = await resolveQuery(query) + const variables = await buildVariables( + options.variable, + options.variablesJson, + ) + + const apiKey = getResolvedApiKey() + if (!apiKey) { + throw new AppValidationError( + "No API key configured", + { + suggestion: + "Set LINEAR_API_KEY, add api_key to .linear.toml, or run `linear auth login`.", + }, + ) + } + + const headers = { + "Content-Type": "application/json", + Authorization: apiKey, + "User-Agent": `schpet-linear-cli/${denoConfig.version}`, + } + + if (options.paginate) { + await executePaginated( + resolvedQuery, + variables, + headers, + options.silent ?? false, + ) + } else { + await executeSingle( + resolvedQuery, + variables, + headers, + options.silent ?? false, + ) + } + } catch (error) { + handleError(error, "API request failed") + } + }) + +async function executeSingle( + query: string, + variables: Record, + headers: Record, + silent: boolean, +): Promise { + const body: Record = { query } + if (Object.keys(variables).length > 0) { + body.variables = variables + } + + const response = await fetch(getGraphQLEndpoint(), { + method: "POST", + headers, + body: JSON.stringify(body), + }) + + const text = await response.text() + + if (response.status >= 400) { + if (!silent) { + console.error(text) + } + Deno.exit(1) + } + + let hasGraphQLErrors = false + try { + const parsed = JSON.parse(text) + hasGraphQLErrors = Array.isArray(parsed.errors) && parsed.errors.length > 0 + if (!silent) { + outputJSON(parsed, text) + } + } catch { + if (!silent) { + console.log(text) + } + } + + if (hasGraphQLErrors) { + Deno.exit(1) + } +} + +async function executePaginated( + query: string, + variables: Record, + headers: Record, + silent: boolean, +): Promise { + const allNodes: unknown[] = [] + let cursor: string | undefined + + for (;;) { + const vars = { ...variables, after: cursor ?? null } + + const body: Record = { query } + if (Object.keys(vars).length > 0) { + body.variables = vars + } + + const response = await fetch(getGraphQLEndpoint(), { + method: "POST", + headers, + body: JSON.stringify(body), + }) + + const text = await response.text() + + if (response.status >= 400) { + if (!silent) { + console.error(text) + } + Deno.exit(1) + } + + let parsed: Record + try { + parsed = JSON.parse(text) + } catch { + if (!silent) { + console.log(text) + } + Deno.exit(1) + } + + if (Array.isArray(parsed.errors) && parsed.errors.length > 0) { + if (!silent) { + outputJSON(parsed, text) + } + Deno.exit(1) + } + + if (allNodes.length === 0 && countConnections(parsed.data) > 1) { + throw new AppValidationError( + "--paginate does not support queries with multiple paginated connections", + { + suggestion: + "Use cursor-based pagination manually with $after and pageInfo { hasNextPage endCursor }.", + }, + ) + } + + const pageResult = extractPageInfo(parsed) + + if (!pageResult) { + if (!silent) { + outputJSON(parsed, text) + } + return + } + + allNodes.push(...pageResult.nodes) + + if (!pageResult.hasNextPage || !pageResult.endCursor) { + break + } + + cursor = pageResult.endCursor + } + + if (!silent) { + outputJSON(allNodes, JSON.stringify(allNodes)) + } +} + +interface PageResult { + nodes: unknown[] + hasNextPage: boolean + endCursor: string | null + connectionPath: string[] +} + +function extractPageInfo( + data: Record, +): PageResult | null { + return findPageInfo(data, []) +} + +function findPageInfo( + obj: unknown, + path: string[], +): PageResult | null { + if (obj == null || typeof obj !== "object") return null + + const record = obj as Record + + if ( + "pageInfo" in record && + "nodes" in record && + record.pageInfo != null && + typeof record.pageInfo === "object" + ) { + const pageInfo = record.pageInfo as Record + return { + nodes: Array.isArray(record.nodes) ? record.nodes : [], + hasNextPage: Boolean(pageInfo.hasNextPage), + endCursor: (pageInfo.endCursor as string) ?? null, + connectionPath: path, + } + } + + for (const [key, value] of Object.entries(record)) { + const result = findPageInfo(value, [...path, key]) + if (result) return result + } + + return null +} + +function countConnections(obj: unknown): number { + if (obj == null || typeof obj !== "object") return 0 + + const record = obj as Record + + if ("pageInfo" in record && "nodes" in record) { + return 1 + } + + let count = 0 + for (const value of Object.values(record)) { + count += countConnections(value) + } + + return count +} + +function outputJSON(parsed: unknown, rawText: string): void { + if (Deno.stdout.isTerminal()) { + try { + console.log(JSON.stringify(parsed, null, 2)) + } catch { + console.log(rawText) + } + } else { + Deno.stdout.writeSync(new TextEncoder().encode( + typeof parsed === "string" ? rawText : JSON.stringify(parsed), + )) + } +} + +async function resolveQuery(positionalArg?: string): Promise { + if (positionalArg && positionalArg !== "-") { + return positionalArg + } + + const explicit = positionalArg === "-" + + if (explicit || !Deno.stdin.isTerminal()) { + const content = explicit + ? await readAllStdin() + : await readStdinWithTimeout() + if (content) { + return content + } + } + + throw new AppValidationError("No query provided", { + suggestion: + "Provide a query as an argument: linear api '{ viewer { id } }'\n Or pipe from stdin: echo '{ viewer { id } }' | linear api", + }) +} + +async function readAllStdin(): Promise { + const chunks: Uint8Array[] = [] + for await (const chunk of Deno.stdin.readable) { + chunks.push(chunk) + } + const text = new TextDecoder().decode(concatChunks(chunks)).trim() + return text.length > 0 ? text : undefined +} + +async function readStdinWithTimeout(): Promise { + try { + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => reject(new Error("stdin timeout")), 100) + }) + const result = await Promise.race([readAllStdin(), timeoutPromise]) + return result + } catch { + return undefined + } +} + +function concatChunks(chunks: Uint8Array[]): Uint8Array { + const totalLength = chunks.reduce((sum, c) => sum + c.length, 0) + const combined = new Uint8Array(totalLength) + let offset = 0 + for (const chunk of chunks) { + combined.set(chunk, offset) + offset += chunk.length + } + return combined +} + +async function buildVariables( + variableEntries?: [string, string][], + variablesJson?: string, +): Promise> { + const variables: Record = {} + + if (variablesJson) { + let parsed: unknown + try { + parsed = JSON.parse(variablesJson) + } catch { + throw new AppValidationError( + `Invalid JSON for --variables-json: ${variablesJson}`, + { + suggestion: + 'Provide a valid JSON object, e.g. --variables-json \'{"key": "value"}\'', + }, + ) + } + if (parsed == null || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new AppValidationError( + `--variables-json must be a JSON object, got ${ + Array.isArray(parsed) ? "array" : typeof parsed + }`, + { + suggestion: + 'Provide a JSON object, e.g. --variables-json \'{"key": "value"}\'', + }, + ) + } + Object.assign(variables, parsed) + } + + if (variableEntries) { + for (const [key, rawValue] of variableEntries) { + variables[key] = await resolveTypedValue(rawValue) + } + } + + return variables +} + +async function resolveTypedValue(value: string): Promise { + if (value === "@-") { + const content = await readAllStdin() + if (content == null) { + throw new AppValidationError("No data on stdin for @- value") + } + return parseJSONOrString(content) + } + + if (value.startsWith("@")) { + const filePath = value.slice(1) + try { + const content = await Deno.readTextFile(filePath) + return parseJSONOrString(content.trim()) + } catch (error) { + if (error instanceof Deno.errors.NotFound) { + throw new AppValidationError(`File not found: ${filePath}`) + } + throw new CliError( + `Failed to read file: ${filePath}`, + { cause: error }, + ) + } + } + + return coerceValue(value) +} + +function parseJSONOrString(content: string): unknown { + try { + return JSON.parse(content) + } catch { + return content + } +} + +function coerceValue(value: string): unknown { + if (value === "true") return true + if (value === "false") return false + if (value === "null") return null + + const num = Number(value) + if (value !== "" && !isNaN(num) && String(num) === value) return num + + return value +} diff --git a/src/main.ts b/src/main.ts index 3d33feb..bcc9663 100644 --- a/src/main.ts +++ b/src/main.ts @@ -13,6 +13,7 @@ import { labelCommand } from "./commands/label/label.ts" import { documentCommand } from "./commands/document/document.ts" import { configCommand } from "./commands/config.ts" import { schemaCommand } from "./commands/schema.ts" +import { apiCommand } from "./commands/api.ts" import { setCliWorkspace } from "./config.ts" // Import config and credentials setup @@ -59,4 +60,5 @@ Environment Variables: .command("completions", new CompletionsCommand()) .command("config", configCommand) .command("schema", schemaCommand) + .command("api", apiCommand) .parse(Deno.args) diff --git a/test/commands/__snapshots__/api.test.ts.snap b/test/commands/__snapshots__/api.test.ts.snap new file mode 100644 index 0000000..82213c2 --- /dev/null +++ b/test/commands/__snapshots__/api.test.ts.snap @@ -0,0 +1,222 @@ +export const snapshot = {}; + +snapshot[`API Command - Help Text 1`] = ` +stdout: +" +Usage: api [query] + +Description: + + Make a raw GraphQL API request + +Options: + + -h, --help - Show this help. + --variable - Variable in key=value format (coerces booleans, numbers, null; @file reads from + path) + --variables-json - JSON object of variables (merged with --variable, which takes precedence) + --paginate - Auto-paginate a single connection field using cursor pagination + --silent - Suppress response output (exit code still reflects errors) + +" +stderr: +"" +`; + +snapshot[`API Command - Basic Query 1`] = ` +stdout: +'{"data":{"viewer":{"id":"user-1","name":"Test User"}}}' +stderr: +"" +`; + +snapshot[`API Command - Variable Flag 1`] = ` +stdout: +'{"data":{"team":{"name":"Backend Team"}}}' +stderr: +"" +`; + +snapshot[`API Command - Variable Type Coercion 1`] = ` +stdout: +'{"data":{"issues":{"nodes":[{"title":"Issue One"},{"title":"Issue Two"}]}}}' +stderr: +"" +`; + +snapshot[`API Command - No Query Error 1`] = ` +stdout: +"" +stderr: +"✗ API request failed: No query provided + Provide a query as an argument: linear api '{ viewer { id } }' + Or pipe from stdin: echo '{ viewer { id } }' | linear api +" +`; + +snapshot[`API Command - Invalid Variable Format 1`] = ` +stdout: +" +Usage: api [query] + +Description: + + Make a raw GraphQL API request + +Options: + + -h, --help - Show this help. + --variable - Variable in key=value format (coerces booleans, numbers, null; @file reads from + path) + --variables-json - JSON object of variables (merged with --variable, which takes precedence) + --paginate - Auto-paginate a single connection field using cursor pagination + --silent - Suppress response output (exit code still reflects errors) + +" +stderr: +" error: Invalid variable format: badformat. Variables must be in key=value format, e.g. --variable teamId=abc + +" +`; + +snapshot[`API Command - GraphQL Errors Exit Non-Zero 1`] = ` +stdout: +\`{"data":null,"errors":[{"message":"Cannot query field 'nonexistent' on type 'Query'"}]}\` +stderr: +"" +`; + +snapshot[`API Command - Silent Flag 1`] = ` +stdout: +"" +stderr: +"" +`; + +snapshot[`API Command - Variable From File 1`] = ` +stdout: +'{"data":{"teams":{"nodes":[{"name":"Backend"}]}}}' +stderr: +"" +`; + +snapshot[`API Command - Paginate 1`] = ` +stdout: +'[{"title":"Issue 1"},{"title":"Issue 2"},{"title":"Issue 3"}]' +stderr: +"" +`; + +snapshot[`API Command - No API Key 1`] = ` +stdout: +"" +stderr: +"✗ API request failed: No API key configured + Set LINEAR_API_KEY, add api_key to .linear.toml, or run \`linear auth login\`. +" +`; + +snapshot[`API Command - Variable Coercion Null And False 1`] = ` +stdout: +'{"data":{"issues":{"nodes":[]}}}' +stderr: +"" +`; + +snapshot[`API Command - Value Containing Equals Sign 1`] = ` +stdout: +'{"data":{"issues":{"nodes":[{"title":"Test"}]}}}' +stderr: +"" +`; + +snapshot[`API Command - Paginate Single Page 1`] = ` +stdout: +'[{"title":"Only Issue"}]' +stderr: +"" +`; + +snapshot[`API Command - Paginate Non-Connection Query 1`] = ` +stdout: +'{"data":{"viewer":{"id":"user-1","name":"Test"}}}' +stderr: +"" +`; + +snapshot[`API Command - File Not Found For Variable 1`] = ` +stdout: +"" +stderr: +"✗ API request failed: File not found: /nonexistent/path.json +" +`; + +snapshot[`API Command - Variables JSON 1`] = ` +stdout: +'{"data":{"issues":{"nodes":[{"title":"Issue One"},{"title":"Issue Two"}]}}}' +stderr: +"" +`; + +snapshot[`API Command - Variables JSON Malformed 1`] = ` +stdout: +"" +stderr: +\`✗ API request failed: Invalid JSON for --variables-json: {bad json + Provide a valid JSON object, e.g. --variables-json '{"key": "value"}' +\` +`; + +snapshot[`API Command - Variables JSON Non-Object 1`] = ` +stdout: +"" +stderr: +\`✗ API request failed: --variables-json must be a JSON object, got array + Provide a JSON object, e.g. --variables-json '{"key": "value"}' +\` +`; + +snapshot[`API Command - Silent Flag With HTTP Error 1`] = ` +stdout: +"" +stderr: +"" +`; + +snapshot[`API Command - Variable Coercion Preserves Leading Zeros 1`] = ` +stdout: +'{"data":{"issue":{"title":"Issue 007"}}}' +stderr: +"" +`; + +snapshot[`API Command - Variable Coercion Preserves Scientific Notation 1`] = ` +stdout: +'{"data":{"issue":{"title":"Issue 1e5"}}}' +stderr: +"" +`; + +snapshot[`API Command - Paginate Multiple Connections Error 1`] = ` +stdout: +"" +stderr: +"✗ API request failed: --paginate does not support queries with multiple paginated connections + Use cursor-based pagination manually with \$after and pageInfo { hasNextPage endCursor }. +" +`; + +snapshot[`API Command - Paginate With Nested Connections 1`] = ` +stdout: +'[{"title":"Parent Issue","subIssues":{"nodes":[{"title":"Child Issue"}],"pageInfo":{"hasNextPage":false,"endCursor":null}}}]' +stderr: +"" +`; + +snapshot[`API Command - Variable Overrides Variables JSON 1`] = ` +stdout: +'{"data":{"issues":{"nodes":[{"title":"Issue One"}]}}}' +stderr: +"" +`; diff --git a/test/commands/api.test.ts b/test/commands/api.test.ts new file mode 100644 index 0000000..17f4e86 --- /dev/null +++ b/test/commands/api.test.ts @@ -0,0 +1,858 @@ +import { snapshotTest as cliffySnapshotTest } from "@cliffy/testing" +import { apiCommand } from "../../src/commands/api.ts" +import { MockLinearServer } from "../utils/mock_linear_server.ts" + +const denoArgs = ["--allow-all", "--quiet"] + +await cliffySnapshotTest({ + name: "API Command - Help Text", + meta: import.meta, + colors: false, + args: ["--help"], + denoArgs, + async fn() { + await apiCommand.parse() + }, +}) + +await cliffySnapshotTest({ + name: "API Command - Basic Query", + meta: import.meta, + colors: false, + args: ["query GetViewer { viewer { id name } }"], + denoArgs, + async fn() { + const server = new MockLinearServer([ + { + queryName: "GetViewer", + response: { + data: { + viewer: { + id: "user-1", + name: "Test User", + }, + }, + }, + }, + ]) + + try { + await server.start() + Deno.env.set("LINEAR_GRAPHQL_ENDPOINT", server.getEndpoint()) + Deno.env.set("LINEAR_API_KEY", "Bearer test-token") + + await apiCommand.parse() + } finally { + await server.stop() + Deno.env.delete("LINEAR_GRAPHQL_ENDPOINT") + Deno.env.delete("LINEAR_API_KEY") + } + }, +}) + +await cliffySnapshotTest({ + name: "API Command - Variable Flag", + meta: import.meta, + colors: false, + args: [ + "query GetTeam($teamId: String!) { team(id: $teamId) { name } }", + "--variable", + "teamId=abc123", + ], + denoArgs, + async fn() { + const server = new MockLinearServer([ + { + queryName: "GetTeam", + variables: { teamId: "abc123" }, + response: { + data: { + team: { + name: "Backend Team", + }, + }, + }, + }, + ]) + + try { + await server.start() + Deno.env.set("LINEAR_GRAPHQL_ENDPOINT", server.getEndpoint()) + Deno.env.set("LINEAR_API_KEY", "Bearer test-token") + + await apiCommand.parse() + } finally { + await server.stop() + Deno.env.delete("LINEAR_GRAPHQL_ENDPOINT") + Deno.env.delete("LINEAR_API_KEY") + } + }, +}) + +await cliffySnapshotTest({ + name: "API Command - Variable Type Coercion", + meta: import.meta, + colors: false, + args: [ + "query GetIssues($first: Int!, $active: Boolean!) { issues(first: $first, filter: { active: $active }) { nodes { title } } }", + "--variable", + "first=5", + "--variable", + "active=true", + ], + denoArgs, + async fn() { + const server = new MockLinearServer([ + { + queryName: "GetIssues", + variables: { first: 5, active: true }, + response: { + data: { + issues: { + nodes: [ + { title: "Issue One" }, + { title: "Issue Two" }, + ], + }, + }, + }, + }, + ]) + + try { + await server.start() + Deno.env.set("LINEAR_GRAPHQL_ENDPOINT", server.getEndpoint()) + Deno.env.set("LINEAR_API_KEY", "Bearer test-token") + + await apiCommand.parse() + } finally { + await server.stop() + Deno.env.delete("LINEAR_GRAPHQL_ENDPOINT") + Deno.env.delete("LINEAR_API_KEY") + } + }, +}) + +await cliffySnapshotTest({ + name: "API Command - No Query Error", + meta: import.meta, + colors: false, + args: [], + denoArgs, + canFail: true, + async fn() { + Deno.env.set("LINEAR_API_KEY", "Bearer test-token") + try { + await apiCommand.parse() + } finally { + Deno.env.delete("LINEAR_API_KEY") + } + }, +}) + +await cliffySnapshotTest({ + name: "API Command - Invalid Variable Format", + meta: import.meta, + colors: false, + args: ["query GetViewer { viewer { id } }", "--variable", "badformat"], + denoArgs, + canFail: true, + async fn() { + Deno.env.set("LINEAR_API_KEY", "Bearer test-token") + try { + await apiCommand.parse() + } finally { + Deno.env.delete("LINEAR_API_KEY") + } + }, +}) + +await cliffySnapshotTest({ + name: "API Command - GraphQL Errors Exit Non-Zero", + meta: import.meta, + colors: false, + args: ["query BadQuery { nonexistent { id } }"], + denoArgs, + canFail: true, + async fn() { + const server = new MockLinearServer([ + { + queryName: "BadQuery", + response: { + data: null, + errors: [ + { + message: "Cannot query field 'nonexistent' on type 'Query'", + }, + ], + }, + }, + ]) + + try { + await server.start() + Deno.env.set("LINEAR_GRAPHQL_ENDPOINT", server.getEndpoint()) + Deno.env.set("LINEAR_API_KEY", "Bearer test-token") + + await apiCommand.parse() + } finally { + await server.stop() + Deno.env.delete("LINEAR_GRAPHQL_ENDPOINT") + Deno.env.delete("LINEAR_API_KEY") + } + }, +}) + +await cliffySnapshotTest({ + name: "API Command - Silent Flag", + meta: import.meta, + colors: false, + args: [ + "query GetViewer { viewer { id } }", + "--silent", + ], + denoArgs, + async fn() { + const server = new MockLinearServer([ + { + queryName: "GetViewer", + response: { + data: { + viewer: { id: "user-1" }, + }, + }, + }, + ]) + + try { + await server.start() + Deno.env.set("LINEAR_GRAPHQL_ENDPOINT", server.getEndpoint()) + Deno.env.set("LINEAR_API_KEY", "Bearer test-token") + + await apiCommand.parse() + } finally { + await server.stop() + Deno.env.delete("LINEAR_GRAPHQL_ENDPOINT") + Deno.env.delete("LINEAR_API_KEY") + } + }, +}) + +await cliffySnapshotTest({ + name: "API Command - Variable From File", + meta: import.meta, + colors: false, + args: [ + "query GetTeam($filter: TeamFilter!) { teams(filter: $filter) { nodes { name } } }", + "--variable", + `filter=@${Deno.cwd()}/test/commands/fixtures/api-filter.json`, + ], + denoArgs, + async fn() { + const server = new MockLinearServer([ + { + queryName: "GetTeam", + response: { + data: { + teams: { + nodes: [{ name: "Backend" }], + }, + }, + }, + }, + ]) + + try { + await server.start() + Deno.env.set("LINEAR_GRAPHQL_ENDPOINT", server.getEndpoint()) + Deno.env.set("LINEAR_API_KEY", "Bearer test-token") + + await apiCommand.parse() + } finally { + await server.stop() + Deno.env.delete("LINEAR_GRAPHQL_ENDPOINT") + Deno.env.delete("LINEAR_API_KEY") + } + }, +}) + +await cliffySnapshotTest({ + name: "API Command - Paginate", + meta: import.meta, + colors: false, + args: [ + "query GetIssues($after: String) { issues(first: 2, after: $after) { nodes { title } pageInfo { hasNextPage endCursor } } }", + "--paginate", + ], + denoArgs, + async fn() { + const server = new MockLinearServer([ + { + queryName: "GetIssues", + variables: { after: null }, + response: { + data: { + issues: { + nodes: [ + { title: "Issue 1" }, + { title: "Issue 2" }, + ], + pageInfo: { + hasNextPage: true, + endCursor: "cursor-1", + }, + }, + }, + }, + }, + { + queryName: "GetIssues", + variables: { after: "cursor-1" }, + response: { + data: { + issues: { + nodes: [ + { title: "Issue 3" }, + ], + 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 apiCommand.parse() + } finally { + await server.stop() + Deno.env.delete("LINEAR_GRAPHQL_ENDPOINT") + Deno.env.delete("LINEAR_API_KEY") + } + }, +}) + +await cliffySnapshotTest({ + name: "API Command - No API Key", + meta: import.meta, + colors: false, + args: ["query GetViewer { viewer { id } }"], + denoArgs, + canFail: true, + async fn() { + Deno.env.delete("LINEAR_API_KEY") + await apiCommand.parse() + }, +}) + +await cliffySnapshotTest({ + name: "API Command - Variable Coercion Null And False", + meta: import.meta, + colors: false, + args: [ + "query GetIssues($active: Boolean, $label: String) { issues(filter: { active: $active, label: $label }) { nodes { title } } }", + "--variable", + "active=false", + "--variable", + "label=null", + ], + denoArgs, + async fn() { + const server = new MockLinearServer([ + { + queryName: "GetIssues", + variables: { active: false, label: null }, + response: { + data: { + issues: { nodes: [] }, + }, + }, + }, + ]) + + try { + await server.start() + Deno.env.set("LINEAR_GRAPHQL_ENDPOINT", server.getEndpoint()) + Deno.env.set("LINEAR_API_KEY", "Bearer test-token") + + await apiCommand.parse() + } finally { + await server.stop() + Deno.env.delete("LINEAR_GRAPHQL_ENDPOINT") + Deno.env.delete("LINEAR_API_KEY") + } + }, +}) + +await cliffySnapshotTest({ + name: "API Command - Value Containing Equals Sign", + meta: import.meta, + colors: false, + args: [ + "query GetIssues($filter: String!) { issues(filter: $filter) { nodes { title } } }", + "--variable", + "filter=name eq backend", + ], + denoArgs, + async fn() { + const server = new MockLinearServer([ + { + queryName: "GetIssues", + variables: { filter: "name eq backend" }, + response: { + data: { + issues: { nodes: [{ title: "Test" }] }, + }, + }, + }, + ]) + + try { + await server.start() + Deno.env.set("LINEAR_GRAPHQL_ENDPOINT", server.getEndpoint()) + Deno.env.set("LINEAR_API_KEY", "Bearer test-token") + + await apiCommand.parse() + } finally { + await server.stop() + Deno.env.delete("LINEAR_GRAPHQL_ENDPOINT") + Deno.env.delete("LINEAR_API_KEY") + } + }, +}) + +await cliffySnapshotTest({ + name: "API Command - Paginate Single Page", + meta: import.meta, + colors: false, + args: [ + "query GetIssues($after: String) { issues(first: 10, after: $after) { nodes { title } pageInfo { hasNextPage endCursor } } }", + "--paginate", + ], + denoArgs, + async fn() { + const server = new MockLinearServer([ + { + queryName: "GetIssues", + variables: { after: null }, + response: { + data: { + issues: { + nodes: [ + { title: "Only Issue" }, + ], + 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 apiCommand.parse() + } finally { + await server.stop() + Deno.env.delete("LINEAR_GRAPHQL_ENDPOINT") + Deno.env.delete("LINEAR_API_KEY") + } + }, +}) + +await cliffySnapshotTest({ + name: "API Command - Paginate Non-Connection Query", + meta: import.meta, + colors: false, + args: [ + "query GetViewer($after: String) { viewer { id name } }", + "--paginate", + ], + denoArgs, + async fn() { + const server = new MockLinearServer([ + { + queryName: "GetViewer", + response: { + data: { + viewer: { id: "user-1", name: "Test" }, + }, + }, + }, + ]) + + try { + await server.start() + Deno.env.set("LINEAR_GRAPHQL_ENDPOINT", server.getEndpoint()) + Deno.env.set("LINEAR_API_KEY", "Bearer test-token") + + await apiCommand.parse() + } finally { + await server.stop() + Deno.env.delete("LINEAR_GRAPHQL_ENDPOINT") + Deno.env.delete("LINEAR_API_KEY") + } + }, +}) + +await cliffySnapshotTest({ + name: "API Command - File Not Found For Variable", + meta: import.meta, + colors: false, + args: [ + "query GetTeam { team { name } }", + "--variable", + "filter=@/nonexistent/path.json", + ], + denoArgs, + canFail: true, + async fn() { + Deno.env.set("LINEAR_API_KEY", "Bearer test-token") + try { + await apiCommand.parse() + } finally { + Deno.env.delete("LINEAR_API_KEY") + } + }, +}) + +await cliffySnapshotTest({ + name: "API Command - Variables JSON", + meta: import.meta, + colors: false, + args: [ + "query GetIssues($first: Int!, $active: Boolean!) { issues(first: $first, filter: { active: $active }) { nodes { title } } }", + "--variables-json", + '{"first": 5, "active": true}', + ], + denoArgs, + async fn() { + const server = new MockLinearServer([ + { + queryName: "GetIssues", + variables: { first: 5, active: true }, + response: { + data: { + issues: { + nodes: [ + { title: "Issue One" }, + { title: "Issue Two" }, + ], + }, + }, + }, + }, + ]) + + try { + await server.start() + Deno.env.set("LINEAR_GRAPHQL_ENDPOINT", server.getEndpoint()) + Deno.env.set("LINEAR_API_KEY", "Bearer test-token") + + await apiCommand.parse() + } finally { + await server.stop() + Deno.env.delete("LINEAR_GRAPHQL_ENDPOINT") + Deno.env.delete("LINEAR_API_KEY") + } + }, +}) + +await cliffySnapshotTest({ + name: "API Command - Variables JSON Malformed", + meta: import.meta, + colors: false, + args: [ + "query GetViewer { viewer { id } }", + "--variables-json", + "{bad json", + ], + denoArgs, + canFail: true, + async fn() { + Deno.env.set("LINEAR_API_KEY", "Bearer test-token") + try { + await apiCommand.parse() + } finally { + Deno.env.delete("LINEAR_API_KEY") + } + }, +}) + +await cliffySnapshotTest({ + name: "API Command - Variables JSON Non-Object", + meta: import.meta, + colors: false, + args: [ + "query GetViewer { viewer { id } }", + "--variables-json", + "[1, 2, 3]", + ], + denoArgs, + canFail: true, + async fn() { + Deno.env.set("LINEAR_API_KEY", "Bearer test-token") + try { + await apiCommand.parse() + } finally { + Deno.env.delete("LINEAR_API_KEY") + } + }, +}) + +await cliffySnapshotTest({ + name: "API Command - Silent Flag With HTTP Error", + meta: import.meta, + colors: false, + args: [ + "query BadQuery { nonexistent { id } }", + "--silent", + ], + denoArgs, + canFail: true, + async fn() { + const server = new MockLinearServer([ + { + queryName: "BadQuery", + status: 400, + response: { + errors: [{ + message: "Cannot query field 'nonexistent' on type 'Query'", + extensions: { code: "GRAPHQL_VALIDATION_FAILED" }, + }], + }, + }, + ]) + + try { + await server.start() + Deno.env.set("LINEAR_GRAPHQL_ENDPOINT", server.getEndpoint()) + Deno.env.set("LINEAR_API_KEY", "Bearer test-token") + + await apiCommand.parse() + } finally { + await server.stop() + Deno.env.delete("LINEAR_GRAPHQL_ENDPOINT") + Deno.env.delete("LINEAR_API_KEY") + } + }, +}) + +await cliffySnapshotTest({ + name: "API Command - Variable Coercion Preserves Leading Zeros", + meta: import.meta, + colors: false, + args: [ + "query GetIssue($id: String!) { issue(id: $id) { title } }", + "--variable", + "id=007", + ], + denoArgs, + async fn() { + const server = new MockLinearServer([ + { + queryName: "GetIssue", + variables: { id: "007" }, + response: { + data: { + issue: { title: "Issue 007" }, + }, + }, + }, + ]) + + try { + await server.start() + Deno.env.set("LINEAR_GRAPHQL_ENDPOINT", server.getEndpoint()) + Deno.env.set("LINEAR_API_KEY", "Bearer test-token") + + await apiCommand.parse() + } finally { + await server.stop() + Deno.env.delete("LINEAR_GRAPHQL_ENDPOINT") + Deno.env.delete("LINEAR_API_KEY") + } + }, +}) + +await cliffySnapshotTest({ + name: "API Command - Variable Coercion Preserves Scientific Notation", + meta: import.meta, + colors: false, + args: [ + "query GetIssue($id: String!) { issue(id: $id) { title } }", + "--variable", + "id=1e5", + ], + denoArgs, + async fn() { + const server = new MockLinearServer([ + { + queryName: "GetIssue", + variables: { id: "1e5" }, + response: { + data: { + issue: { title: "Issue 1e5" }, + }, + }, + }, + ]) + + try { + await server.start() + Deno.env.set("LINEAR_GRAPHQL_ENDPOINT", server.getEndpoint()) + Deno.env.set("LINEAR_API_KEY", "Bearer test-token") + + await apiCommand.parse() + } finally { + await server.stop() + Deno.env.delete("LINEAR_GRAPHQL_ENDPOINT") + Deno.env.delete("LINEAR_API_KEY") + } + }, +}) + +await cliffySnapshotTest({ + name: "API Command - Paginate Multiple Connections Error", + meta: import.meta, + colors: false, + args: [ + "query GetAll($after: String) { issues(first: 10, after: $after) { nodes { title } pageInfo { hasNextPage endCursor } } projects(first: 10, after: $after) { nodes { name } pageInfo { hasNextPage endCursor } } }", + "--paginate", + ], + denoArgs, + canFail: true, + async fn() { + const server = new MockLinearServer([ + { + queryName: "GetAll", + variables: { after: null }, + response: { + data: { + issues: { + nodes: [{ title: "Issue 1" }], + pageInfo: { hasNextPage: true, endCursor: "cursor-1" }, + }, + projects: { + nodes: [{ name: "Project 1" }], + pageInfo: { hasNextPage: true, endCursor: "cursor-2" }, + }, + }, + }, + }, + ]) + + try { + await server.start() + Deno.env.set("LINEAR_GRAPHQL_ENDPOINT", server.getEndpoint()) + Deno.env.set("LINEAR_API_KEY", "Bearer test-token") + + await apiCommand.parse() + } finally { + await server.stop() + Deno.env.delete("LINEAR_GRAPHQL_ENDPOINT") + Deno.env.delete("LINEAR_API_KEY") + } + }, +}) + +await cliffySnapshotTest({ + name: "API Command - Paginate With Nested Connections", + meta: import.meta, + colors: false, + args: [ + "query GetIssues($after: String) { issues(first: 2, after: $after) { nodes { title subIssues { nodes { title } pageInfo { hasNextPage endCursor } } } pageInfo { hasNextPage endCursor } } }", + "--paginate", + ], + denoArgs, + async fn() { + const server = new MockLinearServer([ + { + queryName: "GetIssues", + variables: { after: null }, + response: { + data: { + issues: { + nodes: [ + { + title: "Parent Issue", + subIssues: { + nodes: [{ title: "Child Issue" }], + pageInfo: { hasNextPage: false, endCursor: 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 apiCommand.parse() + } finally { + await server.stop() + Deno.env.delete("LINEAR_GRAPHQL_ENDPOINT") + Deno.env.delete("LINEAR_API_KEY") + } + }, +}) + +await cliffySnapshotTest({ + name: "API Command - Variable Overrides Variables JSON", + meta: import.meta, + colors: false, + args: [ + "query GetIssues($first: Int!, $active: Boolean!) { issues(first: $first, filter: { active: $active }) { nodes { title } } }", + "--variables-json", + '{"first": 10, "active": false}', + "--variable", + "first=5", + ], + denoArgs, + async fn() { + const server = new MockLinearServer([ + { + queryName: "GetIssues", + variables: { first: 5, active: false }, + response: { + data: { + issues: { + nodes: [ + { title: "Issue One" }, + ], + }, + }, + }, + }, + ]) + + try { + await server.start() + Deno.env.set("LINEAR_GRAPHQL_ENDPOINT", server.getEndpoint()) + Deno.env.set("LINEAR_API_KEY", "Bearer test-token") + + await apiCommand.parse() + } finally { + await server.stop() + Deno.env.delete("LINEAR_GRAPHQL_ENDPOINT") + Deno.env.delete("LINEAR_API_KEY") + } + }, +}) diff --git a/test/commands/fixtures/api-filter.json b/test/commands/fixtures/api-filter.json new file mode 100644 index 0000000..0425a9d --- /dev/null +++ b/test/commands/fixtures/api-filter.json @@ -0,0 +1 @@ +{ "name": { "eq": "Backend" } } diff --git a/test/utils/mock_linear_server.ts b/test/utils/mock_linear_server.ts index 2a12e1c..025036b 100644 --- a/test/utils/mock_linear_server.ts +++ b/test/utils/mock_linear_server.ts @@ -15,6 +15,7 @@ interface MockResponse { queryName: string variables?: Record response: Record + status?: number } export class MockLinearServer { @@ -84,7 +85,7 @@ export class MockLinearServer { if (mockResponse) { return new Response( JSON.stringify(mockResponse.response), - { status: 200, headers }, + { status: mockResponse.status ?? 200, headers }, ) }