From 70bd8bffad2a90c29a360e913314275f6fed4d6e Mon Sep 17 00:00:00 2001 From: Daniil Pokrovsky Date: Mon, 17 Aug 2026 18:01:22 +0700 Subject: [PATCH 1/2] fix(task-trackers): look up Linear issues by id, not IssueFilter.identifier Linear's IssueFilter has no identifier field, so fetching a task like DAN-6 failed with GRAPHQL_VALIDATION_FAILED before implementation started. Use the documented issue(id:) query, which accepts both identifiers and UUIDs. Signed-off-by: Daniil Pokrovsky --- packages/pm/backends.test.ts | 26 +++--- packages/task-trackers/linear-client.test.ts | 48 ++++++++++- packages/task-trackers/src/clients/linear.ts | 84 +++++++++++--------- 3 files changed, 102 insertions(+), 56 deletions(-) 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); From 847d90e1eb900c76f3d2cd092fe575a892d238e6 Mon Sep 17 00:00:00 2001 From: Daniil Pokrovsky Date: Mon, 17 Aug 2026 18:07:41 +0700 Subject: [PATCH 2/2] fix(code): process multiple Linear issue keys in one CLI run `devintern dan-6 dan-7 dan-8` already used the batch loop, but Linear identifiers were not covered by tests or help. Normalize each argument (uppercase, unwrap linear.app URLs) independently so all keys are fetched in order, and document the multi-issue form. Signed-off-by: Daniil Pokrovsky --- docs/code/linear-integration.md | 7 +++- packages/code/src/index.ts | 30 ++------------ packages/code/src/lib/normalize-task-keys.ts | 39 +++++++++++++++++++ packages/code/tests/cli.test.ts | 35 +++++++++++++++++ .../code/tests/normalize-task-keys.test.ts | 38 ++++++++++++++++++ 5 files changed, 121 insertions(+), 28 deletions(-) create mode 100644 packages/code/src/lib/normalize-task-keys.ts create mode 100644 packages/code/tests/normalize-task-keys.test.ts 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"]); + }); +});