From 8e8de21536e1f53c6c0532e81e032a4d047282f4 Mon Sep 17 00:00:00 2001 From: Matt Aitchison Date: Sun, 25 Jan 2026 09:33:26 -0600 Subject: [PATCH] feat: Add raw GraphQL query command Adds `linearis graphql` command for executing custom GraphQL queries against the Linear API. Supports inline queries, file input (--file), stdin piping, and variables (--vars). Closes #31 --- src/commands/graphql.ts | 79 +++++++++++++++ src/main.ts | 2 + tests/integration/graphql-cli.test.ts | 136 ++++++++++++++++++++++++++ 3 files changed, 217 insertions(+) create mode 100644 src/commands/graphql.ts create mode 100644 tests/integration/graphql-cli.test.ts diff --git a/src/commands/graphql.ts b/src/commands/graphql.ts new file mode 100644 index 0000000..ad6b53d --- /dev/null +++ b/src/commands/graphql.ts @@ -0,0 +1,79 @@ +import { Command } from "commander"; +import fs from "fs"; +import { createGraphQLService } from "../utils/graphql-service.js"; +import { handleAsyncCommand, outputSuccess } from "../utils/output.js"; + +/** + * Read all data from stdin + * + * Collects chunks from stdin stream and returns as UTF-8 string. + * Used when query is piped to the command. + * + * @returns Promise resolving to stdin content as string + */ +async function readStdin(): Promise { + const chunks: Buffer[] = []; + for await (const chunk of process.stdin) { + chunks.push(chunk); + } + return Buffer.concat(chunks).toString("utf8"); +} + +/** + * Setup graphql command on the program + * + * Registers the `graphql` command for executing raw GraphQL queries + * against the Linear API. Supports inline queries, file input, and stdin. + * + * @param program - Commander.js program instance to register commands on + * + * @example + * ```typescript + * // In main.ts + * setupGraphQLCommands(program); + * // Enables: linearis graphql '{ viewer { id } }' + * ``` + */ +export function setupGraphQLCommands(program: Command): void { + program + .command("graphql [query]") + .description("Execute a raw GraphQL query against the Linear API") + .option("-f, --file ", "read query from file") + .option("-v, --vars ", "JSON variables for the query") + .action( + handleAsyncCommand( + async (query: string | undefined, options: any, command: Command) => { + // Get query from: 1) --file, 2) positional arg, 3) stdin + let finalQuery = query; + + if (options.file) { + finalQuery = fs.readFileSync(options.file, "utf8"); + } else if (!finalQuery && !process.stdin.isTTY) { + // Read from stdin when piped + finalQuery = await readStdin(); + } + + if (!finalQuery) { + throw new Error( + "No query provided. Use inline query, --file, or pipe to stdin.", + ); + } + + // Parse variables if provided + let variables: Record | undefined; + if (options.vars) { + try { + variables = JSON.parse(options.vars); + } catch { + throw new Error(`Invalid JSON in --vars: ${options.vars}`); + } + } + + // Execute query + const service = await createGraphQLService(command.parent!.opts()); + const result = await service.rawRequest(finalQuery, variables); + outputSuccess(result); + }, + ), + ); +} diff --git a/src/main.ts b/src/main.ts index 538dbdc..570c766 100644 --- a/src/main.ts +++ b/src/main.ts @@ -18,6 +18,7 @@ import { program } from "commander"; import pkg from "../package.json" with { type: "json" }; import { setupCommentsCommands } from "./commands/comments.js"; import { setupEmbedsCommands } from "./commands/embeds.js"; +import { setupGraphQLCommands } from "./commands/graphql.js"; import { setupIssuesCommands } from "./commands/issues.js"; import { setupLabelsCommands } from "./commands/labels.js"; import { setupProjectsCommands } from "./commands/projects.js"; @@ -48,6 +49,7 @@ setupProjectsCommands(program); setupCyclesCommands(program); setupProjectMilestonesCommands(program); setupEmbedsCommands(program); +setupGraphQLCommands(program); setupTeamsCommands(program); setupUsersCommands(program); setupDocumentsCommands(program); diff --git a/tests/integration/graphql-cli.test.ts b/tests/integration/graphql-cli.test.ts new file mode 100644 index 0000000..7fc7fb9 --- /dev/null +++ b/tests/integration/graphql-cli.test.ts @@ -0,0 +1,136 @@ +import { beforeAll, describe, expect, it } from "vitest"; +import { exec } from "child_process"; +import { promisify } from "util"; +import { writeFileSync, unlinkSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; + +const execAsync = promisify(exec); + +/** + * Integration tests for graphql CLI command + * + * These tests verify the graphql command works end-to-end with the compiled CLI. + * + * Note: Some tests require LINEAR_API_TOKEN to be set in environment. + * If not set, those tests will be skipped. + */ + +const CLI_PATH = "./dist/main.js"; +const hasApiToken = !!process.env.LINEAR_API_TOKEN; + +describe("GraphQL CLI Command", () => { + beforeAll(async () => { + if (!hasApiToken) { + console.warn( + "\n⚠️ LINEAR_API_TOKEN not set - skipping integration tests\n" + + " To run these tests, set LINEAR_API_TOKEN in your environment\n", + ); + } + }); + + describe("graphql --help", () => { + it("should display help text", async () => { + const { stdout } = await execAsync(`node ${CLI_PATH} graphql --help`); + + expect(stdout).toContain("Usage: linearis graphql"); + expect(stdout).toContain("Execute a raw GraphQL query"); + expect(stdout).toContain("--file"); + expect(stdout).toContain("--vars"); + }); + }); + + describe("graphql inline query", () => { + it.skipIf(!hasApiToken)( + "should execute inline query and return JSON", + async () => { + const { stdout } = await execAsync( + `node ${CLI_PATH} graphql '{ viewer { id } }'`, + ); + + const result = JSON.parse(stdout); + expect(result).toHaveProperty("viewer"); + expect(result.viewer).toHaveProperty("id"); + }, + ); + + it.skipIf(!hasApiToken)( + "should return viewer details with multiple fields", + async () => { + const { stdout } = await execAsync( + `node ${CLI_PATH} graphql '{ viewer { id name email } }'`, + ); + + const result = JSON.parse(stdout); + expect(result.viewer).toHaveProperty("id"); + expect(result.viewer).toHaveProperty("name"); + expect(result.viewer).toHaveProperty("email"); + }, + ); + }); + + describe("graphql --file", () => { + it.skipIf(!hasApiToken)("should read query from file", async () => { + const tmpFile = join(tmpdir(), `linearis-test-${Date.now()}.graphql`); + writeFileSync(tmpFile, "{ viewer { id } }"); + + try { + const { stdout } = await execAsync( + `node ${CLI_PATH} graphql --file ${tmpFile}`, + ); + + const result = JSON.parse(stdout); + expect(result).toHaveProperty("viewer"); + expect(result.viewer).toHaveProperty("id"); + } finally { + unlinkSync(tmpFile); + } + }); + }); + + describe("graphql --vars", () => { + it.skipIf(!hasApiToken)( + "should accept variables for parameterized queries", + async () => { + // Use a query that accepts variables but doesn't require a specific ID + const { stdout } = await execAsync( + `node ${CLI_PATH} graphql 'query($first: Int) { teams(first: $first) { nodes { id } } }' --vars '{"first": 1}'`, + ); + + const result = JSON.parse(stdout); + expect(result).toHaveProperty("teams"); + expect(result.teams).toHaveProperty("nodes"); + }, + ); + }); + + describe("error handling", () => { + it.skipIf(!hasApiToken)( + "should return error for invalid query", + async () => { + try { + await execAsync(`node ${CLI_PATH} graphql '{ invalidField }'`); + expect.fail("Should have thrown an error"); + } catch (error: any) { + const result = JSON.parse(error.stderr); + expect(result).toHaveProperty("error"); + expect(result.error).toContain("Cannot query field"); + } + }, + ); + + it("should return error for invalid --vars JSON", async () => { + // This test doesn't need API token since it fails before API call + try { + await execAsync( + `node ${CLI_PATH} graphql '{ viewer { id } }' --vars 'not valid json'`, + ); + expect.fail("Should have thrown an error"); + } catch (error: any) { + const result = JSON.parse(error.stderr); + expect(result).toHaveProperty("error"); + expect(result.error).toContain("Invalid JSON in --vars"); + } + }); + }); +});