diff --git a/docs/code/linear-integration.md b/docs/code/linear-integration.md index 2cad8fb..971f90f 100644 --- a/docs/code/linear-integration.md +++ b/docs/code/linear-integration.md @@ -4,7 +4,7 @@ sidebarLabel: "Linear Integration" description: "Fetch Linear issues, move workflow states, implement with your coding agent, and open PRs with summaries posted back." section: "Code" order: 5 -dateModified: 2026-07-23 +dateModified: 2026-08-17 tags: ["linear", "devintern/code", "integration"] --- @@ -64,12 +64,15 @@ See [Configuration](./configuration.md) for all settings fields. ## Running an issue -Pass an issue identifier or a full issue URL: +Pass one or more issue identifiers or full issue URLs. Identifiers are case-insensitive (`dan-6` is the same as `DAN-6`). Multiple keys are processed in order: ```bash # Identifier devintern ENG-42 --create-pr +# Several issues in one run +devintern dan-6 dan-7 dan-8 --create-pr + # Full issue URL devintern https://linear.app/acme/issue/ENG-42/fix-login-bug --create-pr ``` diff --git a/packages/code/src/index.ts b/packages/code/src/index.ts index 77b9f74..d24018e 100755 --- a/packages/code/src/index.ts +++ b/packages/code/src/index.ts @@ -39,7 +39,7 @@ import { } from "@devintern/agent-harness"; import type { AgentHarness, AgentRunOptions, ResolvedHarness } from "@devintern/agent-harness"; import { buildSandboxDoctorReport, getSandbox, setSandboxOverride } from "./lib/sandbox"; -import { isMarkdownFilePath, parseTrelloCardReference } from "@devintern/task-trackers"; +import { isMarkdownFilePath } from "@devintern/task-trackers"; import { findEnvFile, maybeOfferCliUpdate, resolveConfigDir } from "@devintern/utils"; import { ReadonlyAnalysisError, runAnalysisWithFallback } from "./lib/analysis-mode"; import { TaskFormatter } from "./lib/task-formatter"; @@ -61,10 +61,7 @@ import { trackersSupportingEstimate, trackersSupportingQuery, } from "./lib/tracker-capabilities"; -import { parseAsanaTaskReference } from "./lib/trackers/asana/asana-task-tracker-client"; -import { parseAzureDevOpsWorkItemReference } from "./lib/trackers/azure-devops/azure-devops-task-tracker-client"; -import { parseGitHubIssueReference } from "./lib/trackers/github/github-task-tracker-client"; -import { parseLinearIssueReference } from "./lib/trackers/linear/linear-task-tracker-client"; +import { normalizeTaskKeys } from "./lib/normalize-task-keys"; import { LockManager } from "./lib/lock-manager"; import { PRManager } from "./lib/pr-client"; import { RunStore, beginRun, endRun, recordRunPr, recordRunStage } from "./lib/run-recorder"; @@ -240,26 +237,6 @@ function getActiveTrackerType(): string { return (process.env.TASK_TRACKER || "jira").toLowerCase(); } -function normalizeTaskKeys(keys: string[]): string[] { - const trackerType = getActiveTrackerType(); - if (trackerType === "trello") { - return keys.map(parseTrelloCardReference); - } - if (trackerType === "linear") { - return keys.map((key) => parseLinearIssueReference(key) ?? key); - } - if (trackerType === "github") { - return keys.map((key) => parseGitHubIssueReference(key) ?? key); - } - if (trackerType === "azure-devops") { - return keys.map((key) => parseAzureDevOpsWorkItemReference(key) ?? key); - } - if (trackerType === "asana") { - return keys.map((key) => parseAsanaTaskReference(key) ?? key); - } - return keys; -} - function resolveProjectKey(taskKey: string, task?: { raw: unknown }): string { const trackerType = getActiveTrackerType(); if (trackerType === "trello") { @@ -1385,6 +1362,7 @@ Examples (Jira): Examples (Linear; set TASK_TRACKER=linear in .devintern-code/.env): devintern ENG-42 --create-pr + devintern ENG-42 ENG-43 ENG-44 --create-pr devintern https://linear.app/acme/issue/ENG-42/issue-slug --create-pr devintern --query '{"state":{"name":{"eq":"Todo"}}}' --create-pr devintern --query "login bug" --create-pr @@ -2205,7 +2183,7 @@ async function main(): Promise { // File-path arguments are kept as-is; PM task keys are normalised (e.g. Trello ref parsing). const pmArgs = taskKeys.filter((k) => !isMarkdownFilePath(k)); const fileArgs = taskKeys.filter(isMarkdownFilePath); - tasksToProcess = [...normalizeTaskKeys(pmArgs), ...fileArgs]; + tasksToProcess = [...normalizeTaskKeys(pmArgs, getActiveTrackerType()), ...fileArgs]; console.log(`📋 Processing ${tasksToProcess.length} task(s): ${tasksToProcess.join(", ")}`); } else { // No tasks specified diff --git a/packages/code/src/lib/normalize-task-keys.ts b/packages/code/src/lib/normalize-task-keys.ts new file mode 100644 index 0000000..6292d88 --- /dev/null +++ b/packages/code/src/lib/normalize-task-keys.ts @@ -0,0 +1,39 @@ +/** + * Normalize CLI task-key arguments for the active tracker. + * + * Linear / GitHub / Azure DevOps / Asana accept both bare ids and full URLs; + * Trello always rewrites the argument (short link, URL, or 24-char id). + */ + +import { parseTrelloCardReference } from "@devintern/task-trackers"; +import { parseAsanaTaskReference } from "./trackers/asana/asana-task-tracker-client"; +import { parseAzureDevOpsWorkItemReference } from "./trackers/azure-devops/azure-devops-task-tracker-client"; +import { parseGitHubIssueReference } from "./trackers/github/github-task-tracker-client"; +import { parseLinearIssueReference } from "./trackers/linear/linear-task-tracker-client"; + +/** + * Rewrite each CLI task argument into the tracker-native id used for fetch. + * + * @param keys - Raw positional arguments (markdown paths should already be filtered out). + * @param trackerType - Active `TASK_TRACKER` value (case-insensitive). + * @returns Keys in the same order, with Linear identifiers uppercased and URLs unwrapped. + */ +export function normalizeTaskKeys(keys: string[], trackerType: string): string[] { + const tracker = trackerType.toLowerCase(); + if (tracker === "trello") { + return keys.map(parseTrelloCardReference); + } + if (tracker === "linear") { + return keys.map((key) => parseLinearIssueReference(key) ?? key); + } + if (tracker === "github") { + return keys.map((key) => parseGitHubIssueReference(key) ?? key); + } + if (tracker === "azure-devops") { + return keys.map((key) => parseAzureDevOpsWorkItemReference(key) ?? key); + } + if (tracker === "asana") { + return keys.map((key) => parseAsanaTaskReference(key) ?? key); + } + return keys; +} diff --git a/packages/code/tests/cli.test.ts b/packages/code/tests/cli.test.ts index 7df3170..4b49e02 100644 --- a/packages/code/tests/cli.test.ts +++ b/packages/code/tests/cli.test.ts @@ -72,6 +72,7 @@ describe("CLI Argument Handling", () => { expect(result.stdout).not.toContain("--claude-path"); expect(result.stdout).not.toContain("--skip-jira-comments"); expect(result.stdout).toContain("devintern PROJ-123 PROJ-456 PROJ-789 --create-pr"); + expect(result.stdout).toContain("devintern ENG-42 ENG-43 ENG-44 --create-pr"); expect(result.exitCode).toBe(0); }); @@ -101,6 +102,40 @@ describe("CLI Argument Handling", () => { expect(result.stdout).toContain("TEST-456"); }); + test("should accept multiple Linear identifiers and uppercase them", () => { + const testDir = join( + tmpdir(), + `cli-linear-multi-${Date.now()}-${Math.random().toString(36).substring(7)}`, + ); + mkdirSync(testDir, { recursive: true }); + try { + const result = spawnSync("bun", [CLI_PATH, "dan-6", "dan-7", "dan-8", "--no-git"], { + encoding: "utf8", + timeout: CLI_SPAWN_TIMEOUT_MS, + cwd: testDir, + env: { + ...process.env, + TASK_TRACKER: "linear", + LINEAR_API_KEY: "lin_api_test", + DEVINTERN_SKIP_LICENSE_CHECK: "1", + DEVINTERN_NO_UPDATE: "1", + }, + }); + const output = (result.stdout || "") + (result.stderr || ""); + expect(output).not.toContain("Unsupported task tracker"); + expect(output).toContain("Processing 3 task(s): DAN-6, DAN-7, DAN-8"); + expect(output).toContain("[1/3] 🔍 Fetching task: DAN-6"); + expect(output).toContain("[2/3] 🔍 Fetching task: DAN-7"); + expect(output).toContain("[3/3] 🔍 Fetching task: DAN-8"); + } finally { + try { + rmSync(testDir, { recursive: true, force: true }); + } catch { + // ignore + } + } + }); + test("should handle --query option", () => { const result = runCLI(["--query", "project = TEST"]); expect(result.stdout).toContain("Searching task tracker with query"); diff --git a/packages/code/tests/normalize-task-keys.test.ts b/packages/code/tests/normalize-task-keys.test.ts new file mode 100644 index 0000000..226c7c1 --- /dev/null +++ b/packages/code/tests/normalize-task-keys.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, test } from "bun:test"; +import { normalizeTaskKeys } from "../src/lib/normalize-task-keys"; + +describe("normalizeTaskKeys", () => { + test("linear uppercases every identifier in a multi-task invocation", () => { + expect(normalizeTaskKeys(["dan-6", "dan-7", "dan-8"], "linear")).toEqual([ + "DAN-6", + "DAN-7", + "DAN-8", + ]); + }); + + test("linear unwraps issue URLs mixed with bare identifiers", () => { + expect( + normalizeTaskKeys( + ["dan-6", "https://linear.app/acme/issue/DAN-7/fix-login", "DAN-8"], + "linear", + ), + ).toEqual(["DAN-6", "DAN-7", "DAN-8"]); + }); + + test("linear leaves non-identifier arguments unchanged", () => { + expect(normalizeTaskKeys(["not-a-ticket", "dan-6"], "linear")).toEqual([ + "not-a-ticket", + "DAN-6", + ]); + }); + + test("jira keeps keys as-is", () => { + expect(normalizeTaskKeys(["PROJ-1", "PROJ-2"], "jira")).toEqual(["PROJ-1", "PROJ-2"]); + }); + + test("github unwraps issue numbers and URLs", () => { + expect( + normalizeTaskKeys(["12", "#13", "https://github.com/acme/web/issues/14"], "github"), + ).toEqual(["12", "13", "14"]); + }); +}); diff --git a/packages/pm/backends.test.ts b/packages/pm/backends.test.ts index 5f0b183..536e2d3 100644 --- a/packages/pm/backends.test.ts +++ b/packages/pm/backends.test.ts @@ -339,13 +339,11 @@ describe("LinearBackend", () => { (globalThis as any).fetch = async () => { callCount++; if (callCount === 1) { - // issues query for parent (getIssueIdByIdentifier) + // issue(id:) lookup for parent (getIssueIdByIdentifier) return new Response( JSON.stringify({ data: { - issues: { - nodes: [{ id: "parent-id", identifier: "ENG-1" }], - }, + issue: { id: "parent-id", identifier: "ENG-1" }, }, }), { status: 200, headers: { "Content-Type": "application/json" } }, @@ -393,9 +391,7 @@ describe("LinearBackend", () => { teams: { nodes: [{ id: "team-1", key: "ENG", name: "Engineering" }], }, - issues: { - nodes: [], - }, + issue: null, }, }); @@ -411,26 +407,22 @@ describe("LinearBackend", () => { (globalThis as any).fetch = async () => { callCount++; if (callCount === 1) { - // issues query for story + // issue(id:) lookup for story return new Response( JSON.stringify({ data: { - issues: { - nodes: [{ id: "story-id", identifier: "ENG-1" }], - }, + issue: { id: "story-id", identifier: "ENG-1" }, }, }), { status: 200, headers: { "Content-Type": "application/json" } }, ); } if (callCount === 2) { - // issues query for epic + // issue(id:) lookup for epic return new Response( JSON.stringify({ data: { - issues: { - nodes: [{ id: "epic-id", identifier: "ENG-0" }], - }, + issue: { id: "epic-id", identifier: "ENG-0" }, }, }), { status: 200, headers: { "Content-Type": "application/json" } }, @@ -532,10 +524,10 @@ describe("LinearBackend", () => { variables?: Record; }; calls.push(body); - if (body.query.includes("issues")) { + if (body.query.includes("issue(id:")) { return new Response( JSON.stringify({ - data: { issues: { nodes: [{ id: "issue-uuid", identifier: "ENG-42" }] } }, + data: { issue: { id: "issue-uuid", identifier: "ENG-42" } }, }), { status: 200, headers: { "Content-Type": "application/json" } }, ); diff --git a/packages/task-trackers/linear-client.test.ts b/packages/task-trackers/linear-client.test.ts index fc86fc1..eb5a92d 100644 --- a/packages/task-trackers/linear-client.test.ts +++ b/packages/task-trackers/linear-client.test.ts @@ -43,8 +43,19 @@ const issueNode = { }; describe("LinearClient.getIssueByIdentifier", () => { + test("looks up the issue by id instead of IssueFilter.identifier", async () => { + const calls = mockGraphQL(() => ({ issue: issueNode })); + + const client = new LinearClient({ apiKey: "key" }); + await client.getIssueByIdentifier("ENG-42"); + + expect(calls[0]?.query).toMatch(/issue\(id:\s*\$id\)/); + expect(calls[0]?.query).not.toMatch(/filter:\s*\{\s*identifier/); + expect(calls[0]?.variables).toEqual({ id: "ENG-42" }); + }); + test("returns normalized issue detail with flattened labels and attachments", async () => { - mockGraphQL(() => ({ issues: { nodes: [issueNode] } })); + mockGraphQL(() => ({ issue: issueNode })); const client = new LinearClient({ apiKey: "key" }); const issue = await client.getIssueByIdentifier("ENG-42"); @@ -57,11 +68,44 @@ describe("LinearClient.getIssueByIdentifier", () => { }); test("returns undefined when no issue matches", async () => { - mockGraphQL(() => ({ issues: { nodes: [] } })); + mockGraphQL(() => ({ issue: null })); const client = new LinearClient({ apiKey: "key" }); expect(await client.getIssueByIdentifier("ENG-999")).toBeUndefined(); }); + + test("returns undefined when Linear reports the issue is missing", async () => { + globalThis.fetch = (async () => + new Response(JSON.stringify({ errors: [{ message: "Entity not found: Issue" }] }), { + status: 200, + })) as typeof fetch; + + const client = new LinearClient({ apiKey: "key" }); + expect(await client.getIssueByIdentifier("ENG-999")).toBeUndefined(); + }); +}); + +describe("LinearClient.getIssueIdByIdentifier", () => { + test("resolves a UUID via issue(id:) and caches the identifier", async () => { + const calls = mockGraphQL(() => ({ + issue: { id: "uuid-1", identifier: "ENG-42" }, + })); + + const client = new LinearClient({ apiKey: "key" }); + expect(await client.getIssueIdByIdentifier("ENG-42")).toBe("uuid-1"); + expect(await client.getIssueIdByIdentifier("ENG-42")).toBe("uuid-1"); + + expect(calls).toHaveLength(1); + expect(calls[0]?.query).toMatch(/issue\(id:\s*\$id\)/); + expect(calls[0]?.variables).toEqual({ id: "ENG-42" }); + }); + + test("returns undefined when the issue does not exist", async () => { + mockGraphQL(() => ({ issue: null })); + + const client = new LinearClient({ apiKey: "key" }); + expect(await client.getIssueIdByIdentifier("ENG-999")).toBeUndefined(); + }); }); describe("LinearClient.searchIssues", () => { diff --git a/packages/task-trackers/src/clients/linear.ts b/packages/task-trackers/src/clients/linear.ts index 37c2697..ebfbf88 100644 --- a/packages/task-trackers/src/clients/linear.ts +++ b/packages/task-trackers/src/clients/linear.ts @@ -89,6 +89,11 @@ const ISSUE_DETAIL_FIELDS = ` attachments { nodes { id title url } } `; +/** Linear returns this when `issue(id:)` cannot resolve a UUID or identifier. */ +function isLinearNotFoundError(error: unknown): boolean { + return error instanceof Error && /entity not found/i.test(error.message); +} + export class LinearClient { private apiKey: string; private baseUrl = "https://api.linear.app/graphql"; @@ -173,10 +178,40 @@ export class LinearClient { this.issueIdCache.set(identifier, id); } + /** + * Fetch a Linear issue by UUID or human-readable identifier (e.g. `ENG-42`). + * + * Linear's `issue(id:)` accepts both forms. `IssueFilter` has no `identifier` + * field, so lookups must not use `issues(filter: { identifier: ... })`. + * + * @returns The selected fields, or `undefined` if the issue does not exist. + * @throws When the GraphQL request fails for a reason other than not found. + */ + private async fetchIssueById(id: string, fields: string): Promise { + try { + const data = await this.request<{ issue: T | null }>( + ` + query IssueById($id: String!) { + issue(id: $id) { + ${fields} + } + } + `, + { id }, + ); + return data?.issue ?? undefined; + } catch (error) { + if (isLinearNotFoundError(error)) { + return undefined; + } + throw error; + } + } + /** * Resolve a Linear issue UUID from its identifier string. * - * @param identifier - Human-readable issue ID (e.g. `ENG-42`). + * @param identifier - Human-readable issue ID (e.g. `ENG-42`) or UUID. * @returns Internal UUID, or `undefined` if not found. * @throws When the GraphQL request fails. */ @@ -186,29 +221,16 @@ export class LinearClient { return cached; } - const data = await this.request<{ - issues: { nodes: Array<{ id: string; identifier: string }> }; - }>( - ` - query IssuesByIdentifier($identifier: String!) { - issues(filter: { identifier: { eq: $identifier } }) { - nodes { - id - identifier - } - } - } - `, - { identifier }, + const issue = await this.fetchIssueById<{ id: string; identifier: string }>( + identifier, + "id identifier", ); - - const issue = data.issues.nodes[0]; - if (issue) { - this.cacheIssueId(issue.identifier, issue.id); - return issue.id; + if (!issue) { + return undefined; } - return undefined; + this.cacheIssueId(issue.identifier, issue.id); + return issue.id; } /** @@ -395,27 +417,15 @@ export class LinearClient { /** * Fetch a full issue by its human-readable identifier (e.g. `ENG-42`). * - * @param identifier - Human-readable issue ID. + * @param identifier - Human-readable issue ID or UUID. * @returns Full issue detail, or `undefined` if not found. * @throws When the GraphQL request fails. */ async getIssueByIdentifier(identifier: string): Promise { - const data = await this.request<{ - issues: { nodes: Array> }; - }>( - ` - query IssueByIdentifier($identifier: String!) { - issues(filter: { identifier: { eq: $identifier } }, first: 1) { - nodes { - ${ISSUE_DETAIL_FIELDS} - } - } - } - `, - { identifier }, + const node = await this.fetchIssueById>( + identifier, + ISSUE_DETAIL_FIELDS, ); - - const node = data.issues.nodes[0]; if (!node) return undefined; const issue = this.normalizeIssueDetail(node);