diff --git a/.changeset/bright-lamps-peel.md b/.changeset/bright-lamps-peel.md new file mode 100644 index 0000000..b413bf8 --- /dev/null +++ b/.changeset/bright-lamps-peel.md @@ -0,0 +1,5 @@ +--- +"linear-cli": minor +--- + +Add a top-level `graphql` command to run arbitrary Linear GraphQL queries and mutations from a positional argument or stdin. diff --git a/README.md b/README.md index 5ee5fd2..99165c7 100644 --- a/README.md +++ b/README.md @@ -130,6 +130,7 @@ linear [flags] | `user` | `list`, `get`, `me` | User operations | | `state` | `list` | Workflow state operations | | `search` | `issues`, `documents`, `projects` | Search | +| `graphql` | - | Run arbitrary GraphQL queries and mutations | ### Examples @@ -169,6 +170,16 @@ linear project list --status started # JSON output linear issue list --json + +# Run an arbitrary GraphQL query +linear graphql 'query { viewer { id name email } }' + +# Run GraphQL with variables +linear graphql 'query Issue($id: String!) { issue(id: $id) { id title } }' \ + --variables '{"id":"ENG-123"}' + +# Or pipe a query via stdin +echo 'query { viewer { id name } }' | linear graphql ``` ### Global flags diff --git a/src/api.test.ts b/src/api.test.ts index 037e6ac..223bc02 100644 --- a/src/api.test.ts +++ b/src/api.test.ts @@ -341,3 +341,54 @@ describe("graphql() with plain string token", () => { } }); }); + +describe("graphql() error handling", () => { + test("truncates large non-JSON HTTP error bodies", async () => { + const fetchMock = mock( + async () => + new Response("x".repeat(400), { + status: 503, + statusText: "Service Unavailable", + }), + ); + const originalFetch = globalThis.fetch; + globalThis.fetch = fetchMock as unknown as typeof fetch; + + try { + await expect( + graphql("Bearer test-token", "query { viewer { id } }"), + ).rejects.toThrow( + /Linear API request failed with 503 Service Unavailable: x{50}/, + ); + await expect( + graphql("Bearer test-token", "query { viewer { id } }"), + ).rejects.not.toThrow(/x{250}/); + } finally { + globalThis.fetch = originalFetch; + } + }); + + test("surfaces generic entity-not-found hints using the provided id variable", async () => { + const fetchMock = mock(async () => + makeJsonResponse({ + errors: [{ message: "Entity not found: Project" }], + }), + ); + const originalFetch = globalThis.fetch; + globalThis.fetch = fetchMock as unknown as typeof fetch; + + try { + await expect( + graphql( + "Bearer test-token", + "query Project($id: String!) { project(id: $id) { id } }", + { + id: "project-123", + }, + ), + ).rejects.toThrow("Project not found: project-123"); + } finally { + globalThis.fetch = originalFetch; + } + }); +}); diff --git a/src/api.ts b/src/api.ts index 365c652..f3a7960 100644 --- a/src/api.ts +++ b/src/api.ts @@ -39,22 +39,27 @@ async function doGraphQL( }); } +function truncateDetail(value: string, maxLength = 200): string { + return value.length > maxLength + ? `${value.slice(0, maxLength - 3)}...` + : value; +} + /** - * Execute a GraphQL query against the Linear API. + * Execute a GraphQL request against the Linear API and return the raw GraphQL response. * * When passed a ResolvedAuth from config with a refreshToken, a 401 response * will trigger a single token-refresh attempt followed by a retry. * Tokens from environment variables are never refreshed automatically. */ -export async function graphql( +export async function graphqlRequest( auth: string | ResolvedAuth, query: string, variables?: Record, -): Promise { +): Promise> { let currentHeader = authHeader(auth); let response = await doGraphQL(currentHeader, query, variables); - // Attempt a token refresh on 401 for config-backed OAuth tokens with a refresh token if ( response.status === 401 && typeof auth !== "string" && @@ -74,7 +79,7 @@ export async function graphql( } const body = await response.text(); - const detail = body.trim(); + const detail = truncateDetail(body.trim()); const message = detail ? `Linear API request failed with ${response.status} ${response.statusText}: ${detail}` : `Linear API request failed with ${response.status} ${response.statusText}`; @@ -82,21 +87,34 @@ export async function graphql( throw new CliError(message); } - const result = (await response.json()) as GraphQLResponse; + return (await response.json()) as GraphQLResponse; +} + +/** + * Execute a GraphQL query against the Linear API. + */ +export async function graphql( + auth: string | ResolvedAuth, + query: string, + variables?: Record, +): Promise { + const result = await graphqlRequest(auth, query, variables); if (result.errors?.length) { const firstMessage = result.errors[0]?.message ?? "Unknown Linear API error"; - - if ( - firstMessage === "Entity not found: Issue" && - typeof variables?.id === "string" && - variables.id.trim() - ) { - throw new CliError(`Issue not found: ${variables.id}`, { - suggestion: - "Use a valid issue identifier like ENG-123 or a Linear issue UUID.", - }); + const entityNotFoundMatch = /^Entity not found: (.+)$/.exec(firstMessage); + + if (entityNotFoundMatch) { + const entityName = entityNotFoundMatch[1] ?? "Entity"; + const identifier = + typeof variables?.id === "string" ? variables.id.trim() : ""; + + if (identifier) { + throw new CliError(`${entityName} not found: ${identifier}`, { + suggestion: `Use a valid ${entityName.toLowerCase()} identifier or UUID.`, + }); + } } throw new CliError(result.errors.map((e) => e.message).join(", ")); diff --git a/src/args.ts b/src/args.ts index 3aab5cb..39b2cda 100644 --- a/src/args.ts +++ b/src/args.ts @@ -61,7 +61,7 @@ export function parseArgs( * Check if help flag is set. */ export function wantsHelp(parsed: ParsedArgs): boolean { - return Boolean(parsed.values.help || parsed.values.h); + return Boolean(parsed.values.help); } /** diff --git a/src/cli.ts b/src/cli.ts index 4e7cdc1..9c0cb51 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -93,6 +93,7 @@ ${bold("Commands:")} user User operations ${dim("(list, get, me)")} state Workflow state operations ${dim("(list)")} search Search ${dim("(issues, documents, projects)")} + graphql Run arbitrary GraphQL ${dim("(query or mutation)")} ${bold("Flags:")} -h, --help Show help @@ -106,6 +107,7 @@ ${bold("Examples:")} ${APP_NAME} issue create --team ENG --title "Fix bug" ${APP_NAME} team list ${APP_NAME} project list + ${APP_NAME} graphql 'query { viewer { id name email } }' ${bold("Configuration:")} Config file: ~/.config/linear-cli/config.json @@ -125,7 +127,7 @@ _${APP_NAME}_completions() { cur="\${COMP_WORDS[COMP_CWORD]}" prev="\${COMP_WORDS[COMP_CWORD-1]}" - local commands="auth issue team project cycle comment document label milestone initiative user state search" + local commands="auth issue team project cycle comment document label milestone initiative user state search graphql" local auth_cmds="login logout status" local issue_cmds="list get create update close" local team_cmds="list get" @@ -220,6 +222,7 @@ _${APP_NAME}() { 'user:User operations' 'state:Workflow state operations' 'search:Search' + 'graphql:Run arbitrary GraphQL' ) auth_cmds=('login:Authenticate with API token' 'logout:Remove stored credentials' 'status:Show auth status') @@ -300,6 +303,7 @@ complete -c ${APP_NAME} -n __fish_use_subcommand -a initiative -d 'Initiative op complete -c ${APP_NAME} -n __fish_use_subcommand -a user -d 'User operations' complete -c ${APP_NAME} -n __fish_use_subcommand -a state -d 'Workflow state operations' complete -c ${APP_NAME} -n __fish_use_subcommand -a search -d 'Search' +complete -c ${APP_NAME} -n __fish_use_subcommand -a graphql -d 'Run arbitrary GraphQL' # auth subcommands complete -c ${APP_NAME} -n '__fish_seen_subcommand_from auth' -a login -d 'Authenticate with API token' diff --git a/src/commands/graphql.test.ts b/src/commands/graphql.test.ts new file mode 100644 index 0000000..12cda88 --- /dev/null +++ b/src/commands/graphql.test.ts @@ -0,0 +1,94 @@ +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { saveConfig } from "../config.ts"; +import { CliError } from "../errors.ts"; +import { parseVariables, runGraphql } from "./graphql.ts"; + +let tempDir: string; +const originalXdgConfigHome = process.env.XDG_CONFIG_HOME; + +beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), "linear-cli-graphql-test-")); + process.env.XDG_CONFIG_HOME = tempDir; +}); + +afterEach(async () => { + if (originalXdgConfigHome === undefined) delete process.env.XDG_CONFIG_HOME; + else process.env.XDG_CONFIG_HOME = originalXdgConfigHome; + + await rm(tempDir, { recursive: true, force: true }); + mock.restore(); +}); + +describe("parseVariables", () => { + test("returns undefined when omitted", () => { + expect(parseVariables(undefined)).toBeUndefined(); + }); + + test("parses a JSON object", () => { + expect(parseVariables('{"id":"ENG-123"}')).toEqual({ id: "ENG-123" }); + }); + + test("rejects invalid JSON", () => { + expect(() => parseVariables("{")).toThrow(CliError); + expect(() => parseVariables("{")).toThrow( + "Invalid JSON passed to --variables.", + ); + }); + + test("rejects non-object JSON values", () => { + expect(() => parseVariables('["ENG-123"]')).toThrow(CliError); + expect(() => parseVariables('["ENG-123"]')).toThrow( + "GraphQL variables must be a JSON object.", + ); + }); +}); + +describe("runGraphql", () => { + test("sends parsed variables to the GraphQL API and prints the response", async () => { + await saveConfig({ apiToken: "test-api-token", outputFormat: "table" }); + + let requestBody: + | { query?: string; variables?: Record } + | undefined; + const fetchMock = mock(async (_url: string, init?: RequestInit) => { + requestBody = JSON.parse(String(init?.body)); + return new Response( + JSON.stringify({ data: { issue: { id: "issue-1", title: "Hello" } } }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + }, + ); + }); + + const originalFetch = globalThis.fetch; + const originalConsoleLog = console.log; + const logs: string[] = []; + globalThis.fetch = fetchMock as unknown as typeof fetch; + console.log = ((value: unknown) => { + logs.push(String(value)); + }) as typeof console.log; + + try { + await runGraphql([ + "query Issue($id: String!) { issue(id: $id) { id title } }", + "--variables", + '{"id":"ENG-123"}', + ]); + + expect(requestBody).toEqual({ + query: "query Issue($id: String!) { issue(id: $id) { id title } }", + variables: { id: "ENG-123" }, + }); + expect(logs).toEqual([ + JSON.stringify({ issue: { id: "issue-1", title: "Hello" } }, null, 2), + ]); + } finally { + globalThis.fetch = originalFetch; + console.log = originalConsoleLog; + } + }); +}); diff --git a/src/commands/graphql.ts b/src/commands/graphql.ts new file mode 100644 index 0000000..8262ae6 --- /dev/null +++ b/src/commands/graphql.ts @@ -0,0 +1,98 @@ +import { graphqlRequest } from "../api.ts"; +import { getString, parseArgs, wantsHelp } from "../args.ts"; +import { CliError } from "../errors.ts"; +import { requireToken } from "./shared.ts"; + +const GRAPHQL_OPTIONS = { + variables: { type: "string" as const }, +}; + +export async function runGraphql(args: string[]): Promise { + const parsed = parseArgs(args, GRAPHQL_OPTIONS); + + if (wantsHelp(parsed)) { + printGraphqlHelp(); + return; + } + + const query = await readQuery(parsed.positionals); + if (!query) { + throw new CliError("A GraphQL query or mutation is required.", { + suggestion: + "Pass it as a string argument, or pipe it to 'linear graphql' via stdin.", + }); + } + + const variables = parseVariables(getString(parsed, "variables")); + const token = await requireToken(); + const result = await graphqlRequest(token, query, variables); + + if (result.errors?.length) { + throw new CliError(result.errors.map((error) => error.message).join(", ")); + } + + console.log(JSON.stringify(result.data ?? null, null, 2)); +} + +function printGraphqlHelp(): void { + console.log(` +Usage: linear graphql [query] [--variables ] + +Run an arbitrary GraphQL query or mutation against Linear. + +Input: + query GraphQL query or mutation string + stdin If query is omitted, read the GraphQL document from stdin + +Options: + --variables Variables object as JSON + -h, --help Show this help + +Examples: + linear graphql 'query { viewer { id name email } }' + linear graphql 'query Issue($id: String!) { issue(id: $id) { id title } }' \\ + --variables '{"id":"ENG-123"}' + linear graphql 'mutation { issueUpdate(id: "...", input: {}) { success } }' + echo 'query { viewer { id name } }' | linear graphql +`); +} + +export function parseVariables( + raw: string | undefined, +): Record | undefined { + if (!raw) { + return undefined; + } + + try { + const parsed = JSON.parse(raw) as unknown; + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new CliError("GraphQL variables must be a JSON object.", { + suggestion: 'Pass an object like --variables \'{"id":"ENG-123"}\'.', + }); + } + return parsed as Record; + } catch (error) { + if (error instanceof CliError) { + throw error; + } + + throw new CliError("Invalid JSON passed to --variables.", { + suggestion: 'Pass an object like --variables \'{"id":"ENG-123"}\'.', + cause: error, + }); + } +} + +async function readQuery(positionals: string[]): Promise { + if (positionals.length > 0) { + return positionals.join(" ").trim() || undefined; + } + + if (process.stdin.isTTY) { + return undefined; + } + + const input = (await Bun.stdin.text()).trim(); + return input || undefined; +} diff --git a/src/errors.ts b/src/errors.ts index b854e1c..48b79c1 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -1,15 +1,13 @@ export class CliError extends Error { readonly suggestion?: string; - readonly cause?: unknown; constructor( message: string, options?: { suggestion?: string; cause?: unknown }, ) { - super(message); + super(message, { cause: options?.cause }); this.name = "CliError"; this.suggestion = options?.suggestion; - this.cause = options?.cause; } } diff --git a/src/index.ts b/src/index.ts index af99ba3..84417e8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,7 +7,13 @@ import { promptForTokenKind, readTokenFromStdin, } from "./auth"; -import { parseArgs, printCompletion, printHelp, printVersion } from "./cli"; +import { + type CliOptions, + parseArgs, + printCompletion, + printHelp, + printVersion, +} from "./cli"; import { createComment, deleteComment, @@ -22,6 +28,7 @@ import { listDocuments, updateDocument, } from "./commands/document"; +import { runGraphql } from "./commands/graphql"; import { getInitiative, listInitiatives } from "./commands/initiative"; import { closeIssue, @@ -121,6 +128,9 @@ async function main(): Promise { case "search": await handleSearch(options.subcommand, subcommandArgs); break; + case "graphql": + await handleGraphql(getGraphqlArgs(options)); + break; default: console.error(`Unknown command: ${options.command}`); printHelp(); @@ -593,6 +603,18 @@ async function handleSearch( } } +async function handleGraphql(args: string[]): Promise { + await runGraphql(args); +} + +function getGraphqlArgs(options: CliOptions): string[] { + return [ + ...(options.help ? ["--help"] : []), + ...(options.subcommand ? [options.subcommand] : []), + ...options.args, + ]; +} + main().catch((err) => { printCliError(err); process.exit(1);