Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 79 additions & 0 deletions src/commands/graphql.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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 <path>", "read query from file")
.option("-v, --vars <json>", "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<string, unknown> | 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);
},
),
);
}
2 changes: 2 additions & 0 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -48,6 +49,7 @@ setupProjectsCommands(program);
setupCyclesCommands(program);
setupProjectMilestonesCommands(program);
setupEmbedsCommands(program);
setupGraphQLCommands(program);
setupTeamsCommands(program);
setupUsersCommands(program);
setupDocumentsCommands(program);
Expand Down
136 changes: 136 additions & 0 deletions tests/integration/graphql-cli.test.ts
Original file line number Diff line number Diff line change
@@ -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");
}
});
});
});