723 lines
22 KiB
TypeScript
723 lines
22 KiB
TypeScript
/**
|
|
* tracker-linear plugin — Linear as an issue tracker.
|
|
*
|
|
* Uses the Linear GraphQL API with either:
|
|
* - LINEAR_API_KEY (direct API access)
|
|
* - COMPOSIO_API_KEY (via Composio SDK's LINEAR_RUN_QUERY_OR_MUTATION tool)
|
|
*
|
|
* Auto-detects which key is available and routes accordingly.
|
|
*/
|
|
|
|
import { request } from "node:https";
|
|
import type {
|
|
PluginModule,
|
|
Tracker,
|
|
Issue,
|
|
IssueFilters,
|
|
IssueUpdate,
|
|
CreateIssueInput,
|
|
ProjectConfig,
|
|
} from "@composio/ao-core";
|
|
import type { Composio } from "@composio/core";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Transport abstraction
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* A function that sends a GraphQL query/mutation and returns the parsed data.
|
|
* Both the direct Linear API and Composio SDK transports implement this.
|
|
*/
|
|
type GraphQLTransport = <T>(query: string, variables?: Record<string, unknown>) => Promise<T>;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Direct Linear API transport
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const LINEAR_API_URL = "https://api.linear.app/graphql";
|
|
|
|
function getApiKey(): string {
|
|
const key = process.env["LINEAR_API_KEY"];
|
|
if (!key) {
|
|
throw new Error(
|
|
"LINEAR_API_KEY environment variable is required for the Linear tracker plugin",
|
|
);
|
|
}
|
|
return key;
|
|
}
|
|
|
|
interface LinearResponse<T> {
|
|
data?: T;
|
|
errors?: Array<{ message: string }>;
|
|
}
|
|
|
|
function createDirectTransport(): GraphQLTransport {
|
|
return <T>(query: string, variables?: Record<string, unknown>): Promise<T> => {
|
|
const apiKey = getApiKey();
|
|
const body = JSON.stringify({ query, variables });
|
|
|
|
return new Promise<T>((resolve, reject) => {
|
|
const url = new URL(LINEAR_API_URL);
|
|
let settled = false;
|
|
const settle = (fn: () => void) => {
|
|
if (!settled) {
|
|
settled = true;
|
|
fn();
|
|
}
|
|
};
|
|
|
|
const req = request(
|
|
{
|
|
hostname: url.hostname,
|
|
path: url.pathname,
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
Authorization: apiKey,
|
|
"Content-Length": Buffer.byteLength(body),
|
|
},
|
|
},
|
|
(res) => {
|
|
const chunks: Buffer[] = [];
|
|
res.on("error", (err: Error) => settle(() => reject(err)));
|
|
res.on("data", (chunk: Buffer) => chunks.push(chunk));
|
|
res.on("end", () => {
|
|
settle(() => {
|
|
try {
|
|
const text = Buffer.concat(chunks).toString("utf-8");
|
|
const status = res.statusCode ?? 0;
|
|
if (status < 200 || status >= 300) {
|
|
reject(new Error(`Linear API returned HTTP ${status}: ${text.slice(0, 200)}`));
|
|
return;
|
|
}
|
|
const json: LinearResponse<T> = JSON.parse(text);
|
|
if (json.errors && json.errors.length > 0) {
|
|
reject(new Error(`Linear API error: ${json.errors[0].message}`));
|
|
return;
|
|
}
|
|
if (!json.data) {
|
|
reject(new Error("Linear API returned no data"));
|
|
return;
|
|
}
|
|
resolve(json.data);
|
|
} catch (err) {
|
|
reject(err);
|
|
}
|
|
});
|
|
});
|
|
},
|
|
);
|
|
|
|
req.setTimeout(30_000, () => {
|
|
settle(() => {
|
|
req.destroy();
|
|
reject(new Error("Linear API request timed out after 30s"));
|
|
});
|
|
});
|
|
|
|
req.on("error", (err) => settle(() => reject(err)));
|
|
req.write(body);
|
|
req.end();
|
|
});
|
|
};
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Composio SDK transport
|
|
// ---------------------------------------------------------------------------
|
|
|
|
type ComposioTools = Composio["tools"];
|
|
|
|
function createComposioTransport(apiKey: string, entityId: string): GraphQLTransport {
|
|
// Lazy-load the Composio client — cached as a promise so the constructor
|
|
// is called only once, even under concurrent requests.
|
|
let clientPromise: Promise<ComposioTools> | undefined;
|
|
|
|
function getClient(): Promise<ComposioTools> {
|
|
if (!clientPromise) {
|
|
clientPromise = (async () => {
|
|
try {
|
|
const { Composio } = await import("@composio/core");
|
|
const client = new Composio({ apiKey });
|
|
return client.tools;
|
|
} catch (err: unknown) {
|
|
const msg = err instanceof Error ? err.message : String(err);
|
|
if (
|
|
msg.includes("Cannot find module") ||
|
|
msg.includes("Cannot find package") ||
|
|
msg.includes("ERR_MODULE_NOT_FOUND")
|
|
) {
|
|
throw new Error(
|
|
"Composio SDK (@composio/core) is not installed. " +
|
|
"Install it with: pnpm add @composio/core",
|
|
{ cause: err },
|
|
);
|
|
}
|
|
throw err;
|
|
}
|
|
})();
|
|
}
|
|
return clientPromise;
|
|
}
|
|
|
|
return async <T>(query: string, variables?: Record<string, unknown>): Promise<T> => {
|
|
const tools = await getClient();
|
|
|
|
const resultPromise = tools.execute("LINEAR_RUN_QUERY_OR_MUTATION", {
|
|
entityId,
|
|
arguments: {
|
|
query_or_mutation: query,
|
|
variables: variables ? JSON.stringify(variables) : "{}",
|
|
},
|
|
});
|
|
|
|
// Apply 30s timeout for parity with the direct transport
|
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
const timeoutPromise = new Promise<never>((_resolve, reject) => {
|
|
timer = setTimeout(() => {
|
|
reject(new Error("Composio Linear API request timed out after 30s"));
|
|
}, 30_000);
|
|
});
|
|
|
|
// Whichever promise loses the race is left without a handler.
|
|
// Attach no-op .catch() to both so the loser doesn't trigger an
|
|
// unhandled promise rejection. This does not affect Promise.race —
|
|
// it still propagates the winning rejection normally.
|
|
resultPromise.catch(() => {});
|
|
timeoutPromise.catch(() => {});
|
|
|
|
try {
|
|
const result = await Promise.race([resultPromise, timeoutPromise]);
|
|
|
|
if (!result.successful) {
|
|
throw new Error(`Composio Linear API error: ${result.error ?? "unknown error"}`);
|
|
}
|
|
|
|
if (!result.data) {
|
|
throw new Error("Composio Linear API returned no data");
|
|
}
|
|
|
|
return result.data as T;
|
|
} finally {
|
|
clearTimeout(timer);
|
|
}
|
|
};
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Types for Linear responses
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface LinearIssueNode {
|
|
id: string;
|
|
identifier: string;
|
|
title: string;
|
|
description: string | null;
|
|
url: string;
|
|
priority: number;
|
|
state: {
|
|
name: string;
|
|
type: string; // "triage" | "backlog" | "unstarted" | "started" | "completed" | "canceled"
|
|
};
|
|
labels: {
|
|
nodes: Array<{ name: string }>;
|
|
};
|
|
assignee: {
|
|
name: string;
|
|
displayName: string;
|
|
} | null;
|
|
team: {
|
|
key: string;
|
|
};
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// State mapping
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function mapLinearState(stateType: string): Issue["state"] {
|
|
switch (stateType) {
|
|
case "completed":
|
|
return "closed";
|
|
case "canceled":
|
|
return "cancelled";
|
|
case "started":
|
|
return "in_progress";
|
|
default:
|
|
// triage, backlog, unstarted
|
|
return "open";
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Issue fields fragment
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const ISSUE_FIELDS = `
|
|
id
|
|
identifier
|
|
title
|
|
description
|
|
url
|
|
priority
|
|
state { name type }
|
|
labels { nodes { name } }
|
|
assignee { name displayName }
|
|
team { key }
|
|
`;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tracker implementation
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function createLinearTracker(query: GraphQLTransport): Tracker {
|
|
return {
|
|
name: "linear",
|
|
|
|
async getIssue(identifier: string, _project: ProjectConfig): Promise<Issue> {
|
|
const data = await query<{ issue: LinearIssueNode }>(
|
|
`query($id: String!) {
|
|
issue(id: $id) {
|
|
${ISSUE_FIELDS}
|
|
}
|
|
}`,
|
|
{ id: identifier },
|
|
);
|
|
|
|
const node = data.issue;
|
|
return {
|
|
id: node.identifier,
|
|
title: node.title,
|
|
description: node.description ?? "",
|
|
url: node.url,
|
|
state: mapLinearState(node.state.type),
|
|
labels: node.labels.nodes.map((l) => l.name),
|
|
assignee: node.assignee?.displayName ?? node.assignee?.name,
|
|
priority: node.priority,
|
|
};
|
|
},
|
|
|
|
async isCompleted(identifier: string, _project: ProjectConfig): Promise<boolean> {
|
|
const data = await query<{ issue: { state: { type: string } } }>(
|
|
`query($id: String!) {
|
|
issue(id: $id) {
|
|
state { type }
|
|
}
|
|
}`,
|
|
{ id: identifier },
|
|
);
|
|
|
|
const stateType = data.issue.state.type;
|
|
return stateType === "completed" || stateType === "canceled";
|
|
},
|
|
|
|
issueUrl(identifier: string, project: ProjectConfig): string {
|
|
const slug = project.tracker?.["workspaceSlug"] as string | undefined;
|
|
if (slug) {
|
|
return `https://linear.app/${slug}/issue/${identifier}`;
|
|
}
|
|
// Fallback: Linear also supports /issue/ URLs that redirect,
|
|
// but they require authentication
|
|
return `https://linear.app/issue/${identifier}`;
|
|
},
|
|
|
|
issueLabel(url: string, _project: ProjectConfig): string {
|
|
// Extract identifier from Linear URL
|
|
// Examples:
|
|
// https://linear.app/composio/issue/INT-1327
|
|
// https://linear.app/issue/INT-1327
|
|
const match = url.match(/\/issue\/([A-Z]+-\d+)/);
|
|
if (match) {
|
|
return match[1];
|
|
}
|
|
// Fallback: return the last segment of the URL
|
|
const parts = url.split("/");
|
|
return parts[parts.length - 1] || url;
|
|
},
|
|
|
|
branchName(identifier: string, _project: ProjectConfig): string {
|
|
// Linear convention: feat/INT-1330
|
|
return `feat/${identifier}`;
|
|
},
|
|
|
|
async generatePrompt(identifier: string, project: ProjectConfig): Promise<string> {
|
|
const issue = await this.getIssue(identifier, project);
|
|
const lines = [
|
|
`You are working on Linear ticket ${issue.id}: ${issue.title}`,
|
|
`Issue URL: ${issue.url}`,
|
|
"",
|
|
];
|
|
|
|
if (issue.labels.length > 0) {
|
|
lines.push(`Labels: ${issue.labels.join(", ")}`);
|
|
}
|
|
|
|
if (issue.priority !== undefined) {
|
|
const priorityNames: Record<number, string> = {
|
|
0: "No priority",
|
|
1: "Urgent",
|
|
2: "High",
|
|
3: "Normal",
|
|
4: "Low",
|
|
};
|
|
lines.push(`Priority: ${priorityNames[issue.priority] ?? String(issue.priority)}`);
|
|
}
|
|
|
|
if (issue.description) {
|
|
lines.push("## Description", "", issue.description);
|
|
}
|
|
|
|
lines.push(
|
|
"",
|
|
"Please implement the changes described in this ticket. When done, commit and push your changes.",
|
|
);
|
|
|
|
return lines.join("\n");
|
|
},
|
|
|
|
async listIssues(filters: IssueFilters, project: ProjectConfig): Promise<Issue[]> {
|
|
// Build filter object using GraphQL variables to prevent injection
|
|
const filter: Record<string, unknown> = {};
|
|
const variables: Record<string, unknown> = {};
|
|
|
|
if (filters.state === "closed") {
|
|
filter["state"] = { type: { in: ["completed", "canceled"] } };
|
|
} else if (filters.state !== "all") {
|
|
// Default to open (exclude completed/canceled) to match tracker-github
|
|
filter["state"] = { type: { nin: ["completed", "canceled"] } };
|
|
}
|
|
|
|
if (filters.assignee) {
|
|
filter["assignee"] = { displayName: { eq: filters.assignee } };
|
|
}
|
|
|
|
if (filters.labels && filters.labels.length > 0) {
|
|
filter["labels"] = { name: { in: filters.labels } };
|
|
}
|
|
|
|
// Add team filter if available from project config
|
|
const teamId = project.tracker?.["teamId"];
|
|
if (teamId) {
|
|
filter["team"] = { id: { eq: teamId } };
|
|
}
|
|
|
|
variables["filter"] = Object.keys(filter).length > 0 ? filter : undefined;
|
|
variables["first"] = filters.limit ?? 30;
|
|
|
|
const data = await query<{
|
|
issues: { nodes: LinearIssueNode[] };
|
|
}>(
|
|
`query($filter: IssueFilter, $first: Int!) {
|
|
issues(filter: $filter, first: $first) {
|
|
nodes {
|
|
${ISSUE_FIELDS}
|
|
}
|
|
}
|
|
}`,
|
|
variables,
|
|
);
|
|
|
|
return data.issues.nodes.map((node) => ({
|
|
id: node.identifier,
|
|
title: node.title,
|
|
description: node.description ?? "",
|
|
url: node.url,
|
|
state: mapLinearState(node.state.type),
|
|
labels: node.labels.nodes.map((l) => l.name),
|
|
assignee: node.assignee?.displayName ?? node.assignee?.name,
|
|
priority: node.priority,
|
|
}));
|
|
},
|
|
|
|
async updateIssue(
|
|
identifier: string,
|
|
update: IssueUpdate,
|
|
_project: ProjectConfig,
|
|
): Promise<void> {
|
|
// Linear's issue() query accepts both UUID and short identifier (e.g. "INT-1330").
|
|
// We resolve to UUID here for use in mutations.
|
|
const issueData = await query<{
|
|
issue: { id: string; team: { id: string } };
|
|
}>(
|
|
`query($id: String!) {
|
|
issue(id: $id) {
|
|
id
|
|
team { id }
|
|
}
|
|
}`,
|
|
{ id: identifier },
|
|
);
|
|
|
|
const issueUuid = issueData.issue.id;
|
|
const teamId = issueData.issue.team.id;
|
|
|
|
// Handle state change
|
|
if (update.state) {
|
|
// Need to find the correct workflow state ID
|
|
const statesData = await query<{
|
|
workflowStates: { nodes: Array<{ id: string; name: string; type: string }> };
|
|
}>(
|
|
`query($teamId: ID!) {
|
|
workflowStates(filter: { team: { id: { eq: $teamId } } }) {
|
|
nodes { id name type }
|
|
}
|
|
}`,
|
|
{ teamId },
|
|
);
|
|
|
|
const targetType =
|
|
update.state === "closed"
|
|
? "completed"
|
|
: update.state === "open"
|
|
? "unstarted"
|
|
: "started";
|
|
|
|
const targetState = statesData.workflowStates.nodes.find((s) => s.type === targetType);
|
|
|
|
if (!targetState) {
|
|
throw new Error(`No workflow state of type "${targetType}" found for team ${teamId}`);
|
|
}
|
|
|
|
await query(
|
|
`mutation($id: String!, $stateId: String!) {
|
|
issueUpdate(id: $id, input: { stateId: $stateId }) {
|
|
success
|
|
}
|
|
}`,
|
|
{ id: issueUuid, stateId: targetState.id },
|
|
);
|
|
}
|
|
|
|
// Handle assignee
|
|
if (update.assignee) {
|
|
const usersData = await query<{
|
|
users: { nodes: Array<{ id: string; displayName: string; name: string }> };
|
|
}>(
|
|
`query($filter: UserFilter) {
|
|
users(filter: $filter) {
|
|
nodes { id displayName name }
|
|
}
|
|
}`,
|
|
{ filter: { displayName: { eq: update.assignee } } },
|
|
);
|
|
|
|
const user = usersData.users.nodes[0];
|
|
if (user) {
|
|
await query(
|
|
`mutation($id: String!, $assigneeId: String!) {
|
|
issueUpdate(id: $id, input: { assigneeId: $assigneeId }) {
|
|
success
|
|
}
|
|
}`,
|
|
{ id: issueUuid, assigneeId: user.id },
|
|
);
|
|
}
|
|
}
|
|
|
|
// Handle labels (additive — merge with existing labels to match tracker-github behavior)
|
|
if (update.labels && update.labels.length > 0) {
|
|
// Fetch existing label IDs on the issue
|
|
const existingData = await query<{
|
|
issue: { labels: { nodes: Array<{ id: string }> } };
|
|
}>(
|
|
`query($id: String!) {
|
|
issue(id: $id) {
|
|
labels { nodes { id } }
|
|
}
|
|
}`,
|
|
{ id: issueUuid },
|
|
);
|
|
const existingIds = new Set(existingData.issue.labels.nodes.map((l) => l.id));
|
|
|
|
// Resolve new label names to IDs
|
|
const labelsData = await query<{
|
|
issueLabels: { nodes: Array<{ id: string; name: string }> };
|
|
}>(
|
|
`query($teamId: ID) {
|
|
issueLabels(filter: { team: { id: { eq: $teamId } } }) {
|
|
nodes { id name }
|
|
}
|
|
}`,
|
|
{ teamId },
|
|
);
|
|
|
|
const labelMap = new Map(labelsData.issueLabels.nodes.map((l) => [l.name, l.id]));
|
|
for (const name of update.labels) {
|
|
const id = labelMap.get(name);
|
|
if (id) existingIds.add(id);
|
|
}
|
|
|
|
await query(
|
|
`mutation($id: String!, $labelIds: [String!]!) {
|
|
issueUpdate(id: $id, input: { labelIds: $labelIds }) {
|
|
success
|
|
}
|
|
}`,
|
|
{ id: issueUuid, labelIds: [...existingIds] },
|
|
);
|
|
}
|
|
|
|
// Handle comment
|
|
if (update.comment) {
|
|
await query(
|
|
`mutation($issueId: String!, $body: String!) {
|
|
commentCreate(input: { issueId: $issueId, body: $body }) {
|
|
success
|
|
}
|
|
}`,
|
|
{ issueId: issueUuid, body: update.comment },
|
|
);
|
|
}
|
|
},
|
|
|
|
async createIssue(input: CreateIssueInput, project: ProjectConfig): Promise<Issue> {
|
|
const teamId = project.tracker?.["teamId"];
|
|
if (!teamId) {
|
|
throw new Error("Linear tracker requires 'teamId' in project tracker config");
|
|
}
|
|
|
|
const variables: Record<string, unknown> = {
|
|
title: input.title,
|
|
description: input.description ?? "",
|
|
teamId,
|
|
};
|
|
|
|
if (input.priority !== undefined) {
|
|
variables["priority"] = input.priority;
|
|
}
|
|
|
|
const data = await query<{
|
|
issueCreate: {
|
|
success: boolean;
|
|
issue: LinearIssueNode;
|
|
};
|
|
}>(
|
|
`mutation($title: String!, $description: String!, $teamId: String!, $priority: Int) {
|
|
issueCreate(input: {
|
|
title: $title,
|
|
description: $description,
|
|
teamId: $teamId,
|
|
priority: $priority
|
|
}) {
|
|
success
|
|
issue {
|
|
${ISSUE_FIELDS}
|
|
}
|
|
}
|
|
}`,
|
|
variables,
|
|
);
|
|
|
|
const node = data.issueCreate.issue;
|
|
const issue: Issue = {
|
|
id: node.identifier,
|
|
title: node.title,
|
|
description: node.description ?? "",
|
|
url: node.url,
|
|
state: mapLinearState(node.state.type),
|
|
labels: node.labels.nodes.map((l) => l.name),
|
|
assignee: node.assignee?.displayName ?? node.assignee?.name,
|
|
priority: node.priority,
|
|
};
|
|
|
|
// Assign after creation (Linear's issueCreate uses assigneeId, not display name)
|
|
if (input.assignee) {
|
|
try {
|
|
const usersData = await query<{
|
|
users: { nodes: Array<{ id: string; displayName: string; name: string }> };
|
|
}>(
|
|
`query($filter: UserFilter) {
|
|
users(filter: $filter) {
|
|
nodes { id displayName name }
|
|
}
|
|
}`,
|
|
{ filter: { displayName: { eq: input.assignee } } },
|
|
);
|
|
|
|
const user = usersData.users.nodes[0];
|
|
if (user) {
|
|
await query(
|
|
`mutation($id: String!, $assigneeId: String!) {
|
|
issueUpdate(id: $id, input: { assigneeId: $assigneeId }) {
|
|
success
|
|
}
|
|
}`,
|
|
{ id: node.id, assigneeId: user.id },
|
|
);
|
|
issue.assignee = input.assignee;
|
|
}
|
|
} catch {
|
|
// Assignee is best-effort
|
|
}
|
|
}
|
|
|
|
// Add labels after creation (Linear's issueCreate doesn't accept label names directly)
|
|
if (input.labels && input.labels.length > 0) {
|
|
try {
|
|
// Look up label IDs by name for the team
|
|
const labelsData = await query<{
|
|
issueLabels: { nodes: Array<{ id: string; name: string }> };
|
|
}>(
|
|
`query($teamId: ID) {
|
|
issueLabels(filter: { team: { id: { eq: $teamId } } }) {
|
|
nodes { id name }
|
|
}
|
|
}`,
|
|
{ teamId },
|
|
);
|
|
|
|
const labelMap = new Map(labelsData.issueLabels.nodes.map((l) => [l.name, l.id]));
|
|
const appliedLabels: string[] = [];
|
|
const labelIds: string[] = [];
|
|
for (const name of input.labels) {
|
|
const id = labelMap.get(name);
|
|
if (id) {
|
|
labelIds.push(id);
|
|
appliedLabels.push(name);
|
|
}
|
|
}
|
|
|
|
if (labelIds.length > 0) {
|
|
await query(
|
|
`mutation($id: String!, $labelIds: [String!]!) {
|
|
issueUpdate(id: $id, input: { labelIds: $labelIds }) {
|
|
success
|
|
}
|
|
}`,
|
|
{ id: node.id, labelIds },
|
|
);
|
|
// Reflect only the labels that actually exist in Linear
|
|
issue.labels = appliedLabels;
|
|
}
|
|
} catch {
|
|
// Labels are best-effort; don't fail the whole creation
|
|
}
|
|
}
|
|
|
|
return issue;
|
|
},
|
|
};
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Plugin module export
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export const manifest = {
|
|
name: "linear",
|
|
slot: "tracker" as const,
|
|
description: "Tracker plugin: Linear issue tracker",
|
|
version: "0.1.0",
|
|
};
|
|
|
|
export function create(): Tracker {
|
|
const composioKey = process.env["COMPOSIO_API_KEY"];
|
|
if (composioKey) {
|
|
const entityId = process.env["COMPOSIO_ENTITY_ID"] ?? "default";
|
|
return createLinearTracker(createComposioTransport(composioKey, entityId));
|
|
}
|
|
return createLinearTracker(createDirectTransport());
|
|
}
|
|
|
|
export default { manifest, create } satisfies PluginModule<Tracker>;
|