Skip to content
Merged
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
7 changes: 5 additions & 2 deletions docs/code/linear-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
---

Expand Down Expand Up @@ -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
```
Expand Down
30 changes: 4 additions & 26 deletions packages/code/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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";
Expand Down Expand Up @@ -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") {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -2205,7 +2183,7 @@ async function main(): Promise<void> {
// 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
Expand Down
39 changes: 39 additions & 0 deletions packages/code/src/lib/normalize-task-keys.ts
Original file line number Diff line number Diff line change
@@ -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;
}
35 changes: 35 additions & 0 deletions packages/code/tests/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});

Expand Down Expand Up @@ -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");
Expand Down
38 changes: 38 additions & 0 deletions packages/code/tests/normalize-task-keys.test.ts
Original file line number Diff line number Diff line change
@@ -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"]);
});
});
26 changes: 9 additions & 17 deletions packages/pm/backends.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" } },
Expand Down Expand Up @@ -393,9 +391,7 @@ describe("LinearBackend", () => {
teams: {
nodes: [{ id: "team-1", key: "ENG", name: "Engineering" }],
},
issues: {
nodes: [],
},
issue: null,
},
});

Expand All @@ -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" } },
Expand Down Expand Up @@ -532,10 +524,10 @@ describe("LinearBackend", () => {
variables?: Record<string, unknown>;
};
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" } },
);
Expand Down
48 changes: 46 additions & 2 deletions packages/task-trackers/linear-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -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", () => {
Expand Down
Loading
Loading