chore: import upstream snapshot with attribution
Voice Workbench / headless workbench (mocked backends) (push) Has been cancelled
Voice Workbench / real acoustic lane (nightly, provisioned only) (push) Has been cancelled
ci / test (push) Has been cancelled
ci / lint-and-format (push) Has been cancelled
ci / build (push) Has been cancelled
ci / dev-startup (push) Has been cancelled
gitleaks / gitleaks (push) Has been cancelled
Markdown Links / Relative Markdown Links (push) Has been cancelled
Quality (Extended) / Homepage Build (PR smoke) (push) Has been cancelled
Quality (Extended) / Comment-only diff guard (push) Has been cancelled
Quality (Extended) / Format + Type Safety Ratchet (push) Has been cancelled
Quality (Extended) / Develop Gate (secret scan + UI determinism) (push) Has been cancelled
Quality (Extended) / Develop Gate (lint) (push) Has been cancelled
Chat shell gestures / Chat shell gesture + parity e2e (push) Has been cancelled
Cloud Gateway Discord / Test (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx @biomejs/biome check packages/lifeops-bench/src, benchmark-lint) (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx vitest run --config packages/lifeops-bench/vitest.config.ts --root packages/lifeops-bench --passWithNoTests, benchmark-tests) (push) Has been cancelled
Build Agent Image / build-and-push (push) Has been cancelled
Dev Smoke / bun run dev onboarding chat (push) Has been cancelled
Dev Smoke / Vite HMR dependency-level smoke (push) Has been cancelled
Electrobun Submodule Guard / electrobun gitlink is fetchable (push) Has been cancelled
Publish @elizaos/example-code / check_npm (push) Has been cancelled
Publish @elizaos/example-code / publish_npm (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / verify_version (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / publish_npm (push) Has been cancelled
Sandbox Live Smoke / Sandbox live smoke (push) Has been cancelled
Snap Build & Test / Build Snap (amd64) (push) Has been cancelled
Snap Build & Test / Build Snap (arm64) (push) Has been cancelled
Test Packaging / elizaos CLI global-install smoke (node + bun) (push) Has been cancelled
Cloud Gateway Webhook / Test (push) Has been cancelled
Cloud Tests / lint-and-types (push) Has been cancelled
Cloud Tests / unit-tests (push) Has been cancelled
Cloud Tests / integration-tests (push) Has been cancelled
Cloud Tests / e2e-tests (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Apps Worker (Product 2) / Determine environment (push) Has been cancelled
Deploy Apps Worker (Product 2) / Deploy apps worker to apps-control host (${{ needs.determine-env.outputs.environment }}) (push) Has been cancelled
Deploy Eliza Provisioning Worker / Determine environment (push) Has been cancelled
Deploy Eliza Provisioning Worker / Deploy worker to Hetzner host (${{ needs.determine-env.outputs.environment }} @ ${{ needs.determine-env.outputs.deployment_sha }}) (push) Has been cancelled
Dev Smoke / Classify changed paths (push) Has been cancelled
supply-chain / sbom (push) Has been cancelled
supply-chain / vulnerability-scan (push) Has been cancelled
Build, Push & Deploy to Phala Cloud / build-and-push (push) Has been cancelled
Test Packaging / Validate Packaging Configs (push) Has been cancelled
Test Packaging / Build & Test PyPI Package (push) Has been cancelled
Test Packaging / PyPI on Python ${{ matrix.python }} (push) Has been cancelled
Test Packaging / Pack & Test JS Tarballs (push) Has been cancelled
UI Fixture E2E / ui-fixture-e2e (push) Has been cancelled
UI Fixture E2E / fixture-e2e (push) Has been cancelled
UI Story Gate / story-gate (push) Has been cancelled
vault-ci / test (macos-latest) (push) Has been cancelled
vault-ci / test (ubuntu-latest) (push) Has been cancelled
vault-ci / test (windows-latest) (push) Has been cancelled
vault-ci / app-core wiring tests (push) Has been cancelled
verify-patches / verify patches/CHECKSUMS.sha256 (push) Has been cancelled
Voice Benchmark Smoke / voice-emotion fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voiceagentbench fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench-quality unit smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench TypeScript unit (no audio) (push) Has been cancelled
Voice Benchmark Smoke / voice bench smoke summary (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/app-core test bun run --cwd packages/elizaos test bun run --cwd packages/cloud/shared test], app-and-cli) (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/scenario-runner test bun run --cwd packages/vault test bun run --cwd packages/security test bun run --cwd plugins/plugin-coding-tools test], framework-packages) (push) Has been cancelled
Windows CI / windows ([bun run --cwd plugins/plugin-elizacloud test bun run --cwd plugins/plugin-discord test bun run --cwd plugins/plugin-anthropic test bun run --cwd plugins/plugin-openai test bun run --cwd plugins/plugin-app-control test bun run --cwd plugins/pl… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run build --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/agent --concurrency=4 node packages/scripts/run-bash-linux-only.mjs scripts/verify-riscv64-buildpaths.sh node packages/scripts/run… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run typecheck --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/cloud-shared --concurrency=4 bun run --cwd packages/core test bun run --cwd packages/shared test], core-runtime, 75) (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:43:05 +08:00
commit 426e9eeabd
41828 changed files with 9656266 additions and 0 deletions
+103
View File
@@ -0,0 +1,103 @@
/** Unit tests for Linear account resolution: legacy-key default, multi-account precedence, and malformed-record filtering (deterministic, no live API). */
import type { IAgentRuntime } from "@elizaos/core";
import { describe, expect, it, vi } from "vitest";
import {
hasLinearAccountConfig,
readLinearAccounts,
resolveLinearAccount,
resolveLinearAccountId,
} from "./accounts";
function runtime(settings: Record<string, unknown>): IAgentRuntime {
return {
character: {},
getSetting: vi.fn((key: string) => settings[key]),
} as IAgentRuntime;
}
describe("Linear account resolution", () => {
it("keeps the legacy LINEAR_API_KEY as the default account", () => {
const rt = runtime({
LINEAR_API_KEY: "linear-key",
LINEAR_WORKSPACE_ID: "workspace",
});
expect(readLinearAccounts(rt)).toEqual([
expect.objectContaining({
accountId: "default",
role: "OWNER",
apiKey: "linear-key",
workspaceId: "workspace",
}),
]);
expect(resolveLinearAccountId(rt)).toBe("default");
});
it("resolves a configured accountId before falling back to default", () => {
const rt = runtime({
LINEAR_ACCOUNTS: JSON.stringify({
personal: { apiKey: "personal-key" },
work: { apiKey: "work-key", workspaceId: "workspace" },
}),
});
const accounts = readLinearAccounts(rt);
expect(resolveLinearAccountId(rt, { accountId: "work" })).toBe("work");
expect(resolveLinearAccount(accounts, "work")).toMatchObject({
accountId: "work",
role: "OWNER",
apiKey: "work-key",
});
});
it("ignores malformed account records and nested non-string credentials", () => {
const rt = {
character: {
settings: {
linear: {
accounts: {
empty: { apiKey: " " },
nested: {
credentials: { accessToken: " nested-key " },
settings: { defaultTeamKey: " ENG " },
metadata: { workspaceId: " workspace " },
},
invalid: null,
},
},
},
},
getSetting: vi.fn((key: string) =>
key === "LINEAR_ACCOUNTS"
? JSON.stringify([{ id: "json-account", credentials: { token: 123 } }, false])
: undefined
),
} as unknown as IAgentRuntime;
expect(readLinearAccounts(rt)).toEqual([
{
accountId: "nested",
role: "OWNER",
apiKey: "nested-key",
workspaceId: "workspace",
defaultTeamKey: "ENG",
label: undefined,
},
]);
expect(hasLinearAccountConfig(rt, { accountId: "empty" })).toBe(false);
expect(hasLinearAccountConfig(rt, { accountId: "nested" })).toBe(true);
});
it("defaults to the first configured account only when no accountId is requested", () => {
const rt = runtime({
LINEAR_ACCOUNTS: JSON.stringify({
work: { apiKey: "work-key", workspaceId: "workspace" },
}),
});
const accounts = readLinearAccounts(rt);
expect(resolveLinearAccountId(rt)).toBe("work");
expect(resolveLinearAccount(accounts, "missing")).toBeNull();
expect(resolveLinearAccountId(rt, { accountId: "missing" })).toBe("missing");
});
});
+200
View File
@@ -0,0 +1,200 @@
/**
* Resolves Linear account configs from runtime settings and the character file.
* Merges the legacy single LINEAR_API_KEY account, the LINEAR_ACCOUNTS JSON
* multi-account list, and character.settings.linear.accounts into a deduped map,
* and resolves which account id a request targets (explicit id → configured
* default → first registered). Consumed by LinearService and every action to
* pick the client for a call.
*/
import type { IAgentRuntime } from "@elizaos/core";
export const DEFAULT_LINEAR_ACCOUNT_ID = "default";
export const DEFAULT_LINEAR_ACCOUNT_ROLE = "OWNER";
export interface LinearAccountConfig {
accountId: string;
role: typeof DEFAULT_LINEAR_ACCOUNT_ROLE;
apiKey: string;
workspaceId?: string;
defaultTeamKey?: string;
label?: string;
}
type RawAccountRecord = Record<string, unknown>;
function nonEmptyString(value: unknown): string | undefined {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
}
function readSetting(runtime: IAgentRuntime, key: string): string | undefined {
return nonEmptyString(runtime.getSetting(key));
}
export function normalizeLinearAccountId(value: unknown): string {
return nonEmptyString(value) ?? DEFAULT_LINEAR_ACCOUNT_ID;
}
export function resolveLinearAccountId(
runtime: IAgentRuntime,
options?: Record<string, unknown>
): string {
const requested = nonEmptyString(options?.accountId) ?? nonEmptyString(options?.linearAccountId);
if (requested) return requested;
const configuredDefault =
readSetting(runtime, "LINEAR_DEFAULT_ACCOUNT_ID") ?? readSetting(runtime, "LINEAR_ACCOUNT_ID");
const accounts = readLinearAccounts(runtime);
const defaultAccount = resolveLinearDefaultAccount(accounts, configuredDefault);
return defaultAccount?.accountId ?? normalizeLinearAccountId(configuredDefault);
}
function parseAccountsJson(raw: string | undefined): RawAccountRecord[] {
if (!raw) return [];
try {
const parsed = JSON.parse(raw) as unknown;
if (Array.isArray(parsed)) {
return parsed.filter(
(item): item is RawAccountRecord =>
Boolean(item) && typeof item === "object" && !Array.isArray(item)
);
}
if (parsed && typeof parsed === "object") {
return Object.entries(parsed as Record<string, unknown>)
.filter(([, value]) => value && typeof value === "object")
.map(([id, value]) => ({
...(value as RawAccountRecord),
accountId: (value as RawAccountRecord).accountId ?? id,
}));
}
} catch {
return [];
}
return [];
}
function readRawField(record: RawAccountRecord, keys: readonly string[]): string | undefined {
const credentials =
record.credentials && typeof record.credentials === "object"
? (record.credentials as RawAccountRecord)
: {};
const metadata =
record.metadata && typeof record.metadata === "object"
? (record.metadata as RawAccountRecord)
: {};
const settings =
record.settings && typeof record.settings === "object"
? (record.settings as RawAccountRecord)
: {};
for (const source of [record, credentials, metadata, settings]) {
for (const key of keys) {
const value = nonEmptyString(source[key]);
if (value) return value;
}
}
return undefined;
}
function accountFromRecord(record: RawAccountRecord): LinearAccountConfig | null {
const accountId = normalizeLinearAccountId(record.accountId ?? record.id ?? record.name);
const apiKey = readRawField(record, [
"LINEAR_API_KEY",
"apiKey",
"token",
"accessToken",
"access",
]);
if (!apiKey) return null;
return {
accountId,
role: DEFAULT_LINEAR_ACCOUNT_ROLE,
apiKey,
workspaceId: readRawField(record, ["LINEAR_WORKSPACE_ID", "workspaceId"]),
defaultTeamKey: readRawField(record, ["LINEAR_DEFAULT_TEAM_KEY", "defaultTeamKey"]),
label: nonEmptyString(record.label ?? record.displayName),
};
}
function addAccount(
accounts: Map<string, LinearAccountConfig>,
account: LinearAccountConfig | null
): void {
if (account) {
accounts.set(account.accountId, account);
}
}
export function readLinearAccounts(runtime: IAgentRuntime): LinearAccountConfig[] {
const accounts = new Map<string, LinearAccountConfig>();
const characterConfig = runtime.character?.settings?.linear as { accounts?: unknown } | undefined;
const characterAccounts = characterConfig?.accounts;
if (Array.isArray(characterAccounts)) {
for (const item of characterAccounts) {
if (item && typeof item === "object") {
addAccount(accounts, accountFromRecord(item as RawAccountRecord));
}
}
} else if (characterAccounts && typeof characterAccounts === "object") {
for (const [id, value] of Object.entries(characterAccounts as Record<string, unknown>)) {
if (value && typeof value === "object") {
addAccount(
accounts,
accountFromRecord({
...(value as RawAccountRecord),
accountId: (value as RawAccountRecord).accountId ?? id,
})
);
}
}
}
for (const record of parseAccountsJson(readSetting(runtime, "LINEAR_ACCOUNTS"))) {
addAccount(accounts, accountFromRecord(record));
}
const apiKey = readSetting(runtime, "LINEAR_API_KEY");
if (apiKey) {
addAccount(accounts, {
accountId: normalizeLinearAccountId(
readSetting(runtime, "LINEAR_ACCOUNT_ID") ??
readSetting(runtime, "LINEAR_DEFAULT_ACCOUNT_ID")
),
role: DEFAULT_LINEAR_ACCOUNT_ROLE,
apiKey,
workspaceId: readSetting(runtime, "LINEAR_WORKSPACE_ID"),
defaultTeamKey: readSetting(runtime, "LINEAR_DEFAULT_TEAM_KEY"),
});
}
return Array.from(accounts.values());
}
export function resolveLinearAccount(
accounts: readonly LinearAccountConfig[],
accountId: string
): LinearAccountConfig | null {
return accounts.find((account) => account.accountId === accountId) ?? null;
}
export function resolveLinearDefaultAccount(
accounts: readonly LinearAccountConfig[],
accountId?: string
): LinearAccountConfig | null {
const normalized = normalizeLinearAccountId(accountId);
return (
resolveLinearAccount(accounts, normalized) ??
resolveLinearAccount(accounts, DEFAULT_LINEAR_ACCOUNT_ID) ??
accounts.find((account) => account.role === DEFAULT_LINEAR_ACCOUNT_ROLE) ??
accounts[0] ??
null
);
}
export function hasLinearAccountConfig(
runtime: IAgentRuntime,
options?: Record<string, unknown>
): boolean {
const accountId = resolveLinearAccountId(runtime, options);
return Boolean(resolveLinearAccount(readLinearAccounts(runtime), accountId));
}
@@ -0,0 +1,33 @@
/**
* Shared account-id plumbing for Linear actions: flattens handler options
* (merging the nested `parameters`), resolves the target account id, and exposes
* the reusable `accountId` action parameter definition every Linear action lists.
*/
import type { HandlerOptions, IAgentRuntime } from "@elizaos/core";
import { resolveLinearAccountId } from "../accounts";
export function getLinearActionOptions(
options?: HandlerOptions | Record<string, unknown>
): Record<string, unknown> {
const direct = (options ?? {}) as Record<string, unknown>;
const parameters =
direct.parameters && typeof direct.parameters === "object"
? (direct.parameters as Record<string, unknown>)
: {};
return { ...direct, ...parameters };
}
export function getLinearAccountId(
runtime: IAgentRuntime,
options?: HandlerOptions | Record<string, unknown>
): string {
return resolveLinearAccountId(runtime, getLinearActionOptions(options));
}
export const linearAccountIdParameter = {
name: "accountId",
description:
"Linear account id from LINEAR_ACCOUNTS. Default LINEAR_DEFAULT_ACCOUNT_ID or legacy single API key.",
required: false,
schema: { type: "string" as const },
};
@@ -0,0 +1,145 @@
/**
* Handles the clear_activity Linear op: wipes the in-memory activity log
* (optionally scoped to an account) via LinearService.clearActivityLog. Requires
* a two-phase user confirmation first, since the clear is not undoable.
*/
import {
type Action,
type ActionResult,
type HandlerCallback,
type HandlerOptions,
type IAgentRuntime,
logger,
type Memory,
requireConfirmation,
type State,
} from "@elizaos/core";
import type { LinearService } from "../services/linear";
import { getLinearAccountId, linearAccountIdParameter } from "./account-options";
import { formatUnknownError, getMessageSource } from "./message-source";
import { validateLinearActionIntent } from "./validate-linear-intent";
const CLEAR_ACTIVITY_TIMEOUT_MS = 10_000;
export const clearActivityAction: Action = {
name: "CLEAR_LINEAR_ACTIVITY",
contexts: ["tasks", "connectors", "automation"],
contextGate: { anyOf: ["tasks", "connectors", "automation"] },
roleGate: { minRole: "USER" },
description:
"Clear cached Linear activity log for connected account. Use for reset/wipe/refresh activity before fresh issue/comment events.",
descriptionCompressed: "clear Linear activity log",
similes: ["clear-linear-activity", "reset-linear-activity", "delete-linear-activity"],
parameters: [linearAccountIdParameter],
examples: [
[
{
name: "User",
content: {
text: "Clear the Linear activity log",
},
},
{
name: "Assistant",
content: {
text: "I'll clear the Linear activity log for you.",
actions: ["CLEAR_LINEAR_ACTIVITY"],
},
},
],
[
{
name: "User",
content: {
text: "Reset Linear activity",
},
},
{
name: "Assistant",
content: {
text: "I'll reset the Linear activity log now.",
actions: ["CLEAR_LINEAR_ACTIVITY"],
},
},
],
],
validate: async (runtime: IAgentRuntime, message: Memory, state?: State): Promise<boolean> =>
validateLinearActionIntent(runtime, message, state),
async handler(
runtime: IAgentRuntime,
message: Memory,
_state?: State,
_options?: HandlerOptions,
callback?: HandlerCallback
): Promise<ActionResult> {
try {
const linearService = runtime.getService<LinearService>("linear");
if (!linearService) {
throw new Error("Linear service not available");
}
const accountId = getLinearAccountId(runtime, _options);
// Two-phase confirmation: clearing the activity log is destructive
// and not undoable from the agent's side.
const decision = await requireConfirmation({
runtime,
message,
actionName: "CLEAR_LINEAR_ACTIVITY",
pendingKey: `clear_log:${accountId}`,
prompt: 'Clear the Linear activity log? Reply "yes" to confirm.',
callback,
});
if (decision.status === "pending") {
return {
text: "Awaiting confirmation to clear Linear activity.",
success: true,
data: { awaitingUserInput: true },
};
}
if (decision.status === "cancelled") {
const cancelMessage = "Clear of Linear activity cancelled.";
await callback?.({ text: cancelMessage, source: getMessageSource(message) });
return {
text: cancelMessage,
success: true,
data: { cancelled: true },
};
}
await Promise.race([
linearService.clearActivityLog(accountId),
new Promise<never>((_, reject) =>
setTimeout(
() => reject(new Error("Linear clear activity timeout")),
CLEAR_ACTIVITY_TIMEOUT_MS
)
),
]);
const successMessage = "✅ Linear activity log has been cleared.";
await callback?.({
text: successMessage,
source: getMessageSource(message),
});
return {
text: successMessage,
success: true,
};
} catch (error) {
logger.error("Failed to clear Linear activity:", formatUnknownError(error));
const errorMessage = `❌ Failed to clear Linear activity: ${error instanceof Error ? error.message : "Unknown error"}`;
await callback?.({
text: errorMessage,
source: getMessageSource(message),
});
return {
text: errorMessage,
success: false,
};
}
},
};
@@ -0,0 +1,318 @@
/**
* Handles the create_comment Linear op. Resolves the target issue from an
* explicit id, a prompt-extracted id/description, or regex, disambiguating when a
* description matches multiple issues, then posts the comment body via
* LinearService.createComment (prefixing a [TYPE] tag for non-note comments).
*/
import {
type Action,
type ActionResult,
type HandlerCallback,
type HandlerOptions,
type IAgentRuntime,
logger,
type Memory,
ModelType,
type State,
} from "@elizaos/core";
import { createCommentTemplate } from "../prompts.js";
import type { LinearService } from "../services/linear";
import type { CreateCommentParameters } from "../types/index.js";
import { getLinearAccountId, linearAccountIdParameter } from "./account-options";
import { formatUnknownError, getMessageSource } from "./message-source";
import { getStringValue, parseLinearPromptResponse } from "./parseLinearPrompt.js";
import { validateLinearActionIntent } from "./validate-linear-intent";
export const createCommentAction: Action = {
name: "CREATE_LINEAR_COMMENT",
contexts: ["tasks", "connectors", "automation"],
contextGate: { anyOf: ["tasks", "connectors", "automation"] },
roleGate: { minRole: "USER" },
description: "Add a comment to a Linear issue",
descriptionCompressed: "add comment Linear issue",
parameters: [
{
name: "issueId",
description: "Linear issue id or identifier to comment on.",
required: false,
schema: { type: "string" },
},
{
name: "body",
description: "Comment body to add to the issue.",
required: false,
schema: { type: "string" },
},
linearAccountIdParameter,
],
similes: [
"create-linear-comment",
"add-linear-comment",
"comment-on-linear-issue",
"reply-to-linear-issue",
],
examples: [
[
{
name: "User",
content: {
text: "Comment on ENG-123: This looks good to me",
},
},
{
name: "Assistant",
content: {
text: "I'll add your comment to issue ENG-123.",
actions: ["CREATE_LINEAR_COMMENT"],
},
},
],
[
{
name: "User",
content: {
text: "Tell the login bug that we need more information from QA",
},
},
{
name: "Assistant",
content: {
text: "I'll add that comment to the login bug issue.",
actions: ["CREATE_LINEAR_COMMENT"],
},
},
],
[
{
name: "User",
content: {
text: "Reply to COM2-7: Thanks for the update, I'll look into it",
},
},
{
name: "Assistant",
content: {
text: "I'll add your reply to issue COM2-7.",
actions: ["CREATE_LINEAR_COMMENT"],
},
},
],
],
validate: async (runtime: IAgentRuntime, message: Memory, state?: State): Promise<boolean> =>
validateLinearActionIntent(runtime, message, state),
async handler(
runtime: IAgentRuntime,
message: Memory,
_state?: State,
_options?: HandlerOptions,
callback?: HandlerCallback
): Promise<ActionResult> {
try {
const linearService = runtime.getService<LinearService>("linear");
if (!linearService) {
throw new Error("Linear service not available");
}
const accountId = getLinearAccountId(runtime, _options);
const content = message.content.text;
if (!content) {
const errorMessage = "Please provide a message with the issue and comment content.";
await callback?.({
text: errorMessage,
source: getMessageSource(message),
});
return {
text: errorMessage,
success: false,
};
}
let issueId: string;
let commentBody: string;
const params = _options?.parameters as CreateCommentParameters | undefined;
if (params?.issueId && params?.body) {
issueId = params.issueId;
commentBody = params.body;
} else {
const prompt = createCommentTemplate.replace("{{userMessage}}", content);
const response = await runtime.useModel(ModelType.TEXT_LARGE, {
prompt: prompt,
});
if (!response) {
const issueMatch = content.match(
/(?:comment on|add.*comment.*to|reply to|tell)\s+(\w+-\d+):?\s*(.*)/i
);
if (issueMatch) {
issueId = issueMatch[1];
commentBody = issueMatch[2].trim();
} else {
throw new Error("Could not understand comment request");
}
} else {
try {
const parsed = parseLinearPromptResponse(response);
if (Object.keys(parsed).length === 0) {
throw new Error("No fields found in model response");
}
const parsedIssueId = getStringValue(parsed.issueId);
const issueDescription = getStringValue(parsed.issueDescription);
const parsedCommentBody = getStringValue(parsed.commentBody) ?? "";
if (parsedIssueId) {
issueId = parsedIssueId;
commentBody = parsedCommentBody;
} else if (issueDescription) {
const filters: { query: string; limit: number; team?: string } = {
query: issueDescription,
limit: 5,
};
const defaultTeamKey =
linearService.getDefaultTeamKey(accountId) ??
(runtime.getSetting("LINEAR_DEFAULT_TEAM_KEY") as string);
if (defaultTeamKey) {
filters.team = defaultTeamKey;
}
const issues = await linearService.searchIssues(filters, accountId);
if (issues.length === 0) {
const errorMessage = `No issues found matching "${issueDescription}". Please provide a specific issue ID.`;
await callback?.({
text: errorMessage,
source: getMessageSource(message),
});
return {
text: errorMessage,
success: false,
};
}
if (issues.length === 1) {
issueId = issues[0].identifier;
commentBody = parsedCommentBody;
} else {
const issueList = await Promise.all(
issues.map(async (issue, index) => {
const state = await issue.state;
return `${index + 1}. ${issue.identifier}: ${issue.title} (${state?.name || "No state"})`;
})
);
const clarifyMessage = `Found multiple issues matching "${issueDescription}":\n${issueList.join("\n")}\n\nPlease specify which issue to comment on by its ID.`;
await callback?.({
text: clarifyMessage,
source: getMessageSource(message),
});
return {
text: clarifyMessage,
success: false,
data: {
multipleMatches: true,
issues: issues.map((i) => ({
id: i.id,
identifier: i.identifier,
title: i.title,
})),
pendingComment: parsedCommentBody,
},
};
}
} else {
throw new Error("No issue identifier or description found");
}
const commentType = getStringValue(parsed.commentType)?.toLowerCase();
if (commentType && commentType !== "note") {
commentBody = `[${commentType.toUpperCase()}] ${commentBody}`;
}
} catch (parseError) {
logger.warn(
"Failed to parse LLM response, falling back to regex:",
formatUnknownError(parseError)
);
const issueMatch = content.match(
/(?:comment on|add.*comment.*to|reply to|tell)\s+(\w+-\d+):?\s*(.*)/i
);
if (!issueMatch) {
const errorMessage =
'Please specify the issue ID and comment content. Example: "Comment on ENG-123: This looks good"';
await callback?.({
text: errorMessage,
source: getMessageSource(message),
});
return {
text: errorMessage,
success: false,
};
}
issueId = issueMatch[1];
commentBody = issueMatch[2].trim();
}
}
}
if (!commentBody || commentBody.length === 0) {
const errorMessage = "Please provide the comment content.";
await callback?.({
text: errorMessage,
source: getMessageSource(message),
});
return {
text: errorMessage,
success: false,
};
}
const issue = await linearService.getIssue(issueId, accountId);
const comment = await linearService.createComment(
{
issueId: issue.id,
body: commentBody,
},
accountId
);
const successMessage = `✅ Comment added to issue ${issue.identifier}: "${commentBody}"`;
await callback?.({
text: successMessage,
source: getMessageSource(message),
});
return {
text: `Added comment to issue ${issue.identifier}`,
success: true,
data: {
commentId: comment.id,
issueId: issue.id,
issueIdentifier: issue.identifier,
commentBody: commentBody,
createdAt:
comment.createdAt instanceof Date ? comment.createdAt.toISOString() : comment.createdAt,
accountId,
},
};
} catch (error) {
logger.error("Failed to create comment:", formatUnknownError(error));
const errorMessage = `❌ Failed to create comment: ${error instanceof Error ? error.message : "Unknown error"}`;
await callback?.({
text: errorMessage,
source: getMessageSource(message),
});
return {
text: errorMessage,
success: false,
};
}
},
};
@@ -0,0 +1,179 @@
/**
* Unit tests for the create_issue action handler: missing-input validation and
* issue creation through a mocked LinearService and mocked model. Deterministic,
* no live API.
*/
import type { IAgentRuntime, Memory } from "@elizaos/core";
import { ModelType } from "@elizaos/core";
import { describe, expect, it, vi } from "vitest";
import { createIssueAction } from "./createIssue";
function message(text?: string): Memory {
return {
id: "msg",
agentId: "agent",
entityId: "entity",
roomId: "room",
content: { text, source: "test" },
} as Memory;
}
function runtime(service: Record<string, unknown>, modelResponse?: string): IAgentRuntime {
return {
getService: vi.fn((name: string) => (name === "linear" ? service : undefined)),
getSetting: vi.fn((key: string) => (key === "LINEAR_DEFAULT_TEAM_KEY" ? "ENG" : undefined)),
useModel: vi.fn(async (modelType) => {
expect(modelType).toBe(ModelType.TEXT_LARGE);
return modelResponse ?? "{}";
}),
} as unknown as IAgentRuntime;
}
describe("createIssueAction", () => {
it("returns a useful validation message when issue text is missing", async () => {
const callback = vi.fn();
const result = await createIssueAction.handler(
runtime({}),
message(undefined),
"default",
"default",
callback
);
expect(result).toEqual({
text: "Please provide a description for the issue.",
success: false,
});
expect(callback).toHaveBeenCalledWith({
text: "Please provide a description for the issue.",
source: "test",
});
});
it("creates an issue from structured parameters without calling the LLM", async () => {
const service = {
createIssue: vi.fn(async (input) => ({
id: "issue-id",
title: input.title,
identifier: "ENG-42",
url: "https://linear.app/acme/issue/ENG-42",
})),
getDefaultTeamKey: vi.fn(() => "ENG"),
getTeams: vi.fn(async () => [{ id: "team-id", key: "ENG", name: "Engineering" }]),
};
const rt = runtime(service);
const callback = vi.fn();
const result = await createIssueAction.handler(
rt,
message("Create a bug"),
"default",
{
parameters: {
issueData: {
title: "Fix login",
description: "Login fails on mobile",
teamId: "team-id",
priority: 2,
},
},
},
callback
);
expect(rt.useModel).not.toHaveBeenCalled();
expect(service.createIssue).toHaveBeenCalledWith(
{
title: "Fix login",
description: "Login fails on mobile",
teamId: "team-id",
priority: 2,
},
"default"
);
expect(result).toMatchObject({
success: true,
text: "Created issue: Fix login (ENG-42)",
data: {
issueId: "issue-id",
identifier: "ENG-42",
url: "https://linear.app/acme/issue/ENG-42",
},
});
expect(callback).toHaveBeenCalledWith({
text: expect.stringContaining("Created Linear issue: Fix login (ENG-42)"),
source: "test",
});
});
it("falls back to user text and default team when model JSON is malformed", async () => {
const service = {
createIssue: vi.fn(async (input) => ({
id: "issue-id",
title: input.title,
identifier: "ENG-7",
url: "https://linear.app/acme/issue/ENG-7",
})),
getDefaultTeamKey: vi.fn(() => "ENG"),
getTeams: vi.fn(async () => [{ id: "team-id", key: "ENG", name: "Engineering" }]),
};
const result = await createIssueAction.handler(
runtime(service, "not json at all"),
message("Investigate a flaky deploy failure"),
"default",
undefined,
vi.fn()
);
expect(service.createIssue).toHaveBeenCalledWith(
{
title: "Investigate a flaky deploy failure",
description: "Investigate a flaky deploy failure",
teamId: "team-id",
},
"default"
);
expect(result.success).toBe(true);
});
it("reports no-team and create failures through callback and result text", async () => {
const noTeamService = {
getDefaultTeamKey: vi.fn(() => undefined),
getTeams: vi.fn(async () => []),
createIssue: vi.fn(),
};
const noTeamCallback = vi.fn();
const noTeam = await createIssueAction.handler(
runtime(noTeamService, '{"title":"No team"}'),
message("Create no-team issue"),
undefined,
undefined,
noTeamCallback
);
expect(noTeam).toMatchObject({
success: false,
text: "No Linear teams found. Please ensure at least one team exists in your Linear workspace.",
});
expect(noTeamService.createIssue).not.toHaveBeenCalled();
const failingService = {
getDefaultTeamKey: vi.fn(() => undefined),
getTeams: vi.fn(async () => [{ id: "team-id", key: "ENG", name: "Engineering" }]),
createIssue: vi.fn(async () => {
throw new Error("Linear API rate limited");
}),
};
const failed = await createIssueAction.handler(
runtime(failingService, '{"title":"Rate limit"}'),
message("Create rate-limit issue"),
undefined,
undefined,
vi.fn()
);
expect(failed).toMatchObject({
success: false,
text: "❌ Failed to create issue: Linear API rate limited",
});
});
});
@@ -0,0 +1,310 @@
/**
* Handles the create_issue Linear op. Uses structured issueData when the caller
* supplies it, otherwise extracts title/description/priority/team/assignee/labels
* from the message via the createIssue prompt, resolving team, assignee, and
* label names to Linear ids before calling LinearService.createIssue. Falls back
* to a configured or first-available team when none is named.
*/
import {
type Action,
type ActionResult,
type HandlerCallback,
type HandlerOptions,
type IAgentRuntime,
logger,
type Memory,
ModelType,
type State,
} from "@elizaos/core";
import { createIssueTemplate } from "../prompts.js";
import type { LinearService } from "../services/linear";
import type { CreateIssueParameters, LinearIssueInput } from "../types/index.js";
import { getLinearAccountId, linearAccountIdParameter } from "./account-options";
import { formatUnknownError, getMessageSource } from "./message-source";
import {
getPriorityNumberValue,
getStringArrayValue,
getStringValue,
parseLinearPromptResponse,
} from "./parseLinearPrompt.js";
import { validateLinearActionIntent } from "./validate-linear-intent";
export const createIssueAction: Action = {
name: "CREATE_LINEAR_ISSUE",
contexts: ["tasks", "connectors", "automation"],
contextGate: { anyOf: ["tasks", "connectors", "automation"] },
roleGate: { minRole: "USER" },
description:
"Create Linear issue: title, description, priority, team, assignee, labels. Use for new ticket/bug/story/task.",
descriptionCompressed: "create new issue Linear",
parameters: [
{
name: "issueData",
description: "Structured Linear issue fields.",
required: false,
schema: {
type: "object",
properties: {
title: { type: "string" },
description: { type: "string" },
priority: { type: "number" },
teamId: { type: "string" },
assigneeId: { type: "string" },
labelIds: { type: "array", items: { type: "string" } },
},
},
},
linearAccountIdParameter,
],
similes: ["create-linear-issue", "new-linear-issue", "add-linear-issue"],
examples: [
[
{
name: "User",
content: {
text: "Create a new issue: Fix login button not working on mobile devices",
},
},
{
name: "Assistant",
content: {
text: "I'll create that issue for you in Linear.",
actions: ["CREATE_LINEAR_ISSUE"],
},
},
],
[
{
name: "User",
content: {
text: "Create a bug report for the ENG team: API returns 500 error when updating user profile",
},
},
{
name: "Assistant",
content: {
text: "I'll create a bug report for the engineering team right away.",
actions: ["CREATE_LINEAR_ISSUE"],
},
},
],
],
validate: async (runtime: IAgentRuntime, message: Memory, state?: State): Promise<boolean> =>
validateLinearActionIntent(runtime, message, state),
async handler(
runtime: IAgentRuntime,
message: Memory,
_state?: State,
_options?: HandlerOptions,
callback?: HandlerCallback
): Promise<ActionResult> {
try {
const linearService = runtime.getService<LinearService>("linear");
if (!linearService) {
throw new Error("Linear service not available");
}
const accountId = getLinearAccountId(runtime, _options);
const content = message.content.text;
if (!content) {
const errorMessage = "Please provide a description for the issue.";
await callback?.({
text: errorMessage,
source: getMessageSource(message),
});
return {
text: errorMessage,
success: false,
};
}
const params = _options?.parameters as CreateIssueParameters | undefined;
const structuredData = params?.issueData;
let issueData: Partial<LinearIssueInput>;
if (structuredData) {
issueData = structuredData;
} else {
const prompt = createIssueTemplate.replace("{{userMessage}}", content);
const response = await runtime.useModel(ModelType.TEXT_LARGE, {
prompt: prompt,
});
if (!response) {
throw new Error("Failed to extract issue information");
}
try {
const parsed = parseLinearPromptResponse(response);
if (Object.keys(parsed).length === 0) {
throw new Error("No fields found in model response");
}
issueData = {
title: getStringValue(parsed.title),
description: getStringValue(parsed.description),
priority: getPriorityNumberValue(parsed.priority),
};
const teamKey = getStringValue(parsed.teamKey);
if (teamKey) {
const teams = await linearService.getTeams(accountId);
const team = teams.find((t) => t.key.toLowerCase() === teamKey.toLowerCase());
if (team) {
issueData.teamId = team.id;
}
}
const assignee = getStringValue(parsed.assignee);
if (assignee) {
const cleanAssignee = assignee.replace(/^@/, "");
const users = await linearService.getUsers(accountId);
const user = users.find(
(u) =>
u.email === cleanAssignee ||
u.name.toLowerCase().includes(cleanAssignee.toLowerCase())
);
if (user) {
issueData.assigneeId = user.id;
}
}
const parsedLabels = getStringArrayValue(parsed.labels);
if (parsedLabels && parsedLabels.length > 0) {
const labels = await linearService.getLabels(issueData.teamId, accountId);
const labelIds: string[] = [];
for (const labelName of parsedLabels) {
const label = labels.find((l) => l.name.toLowerCase() === labelName.toLowerCase());
if (label) {
labelIds.push(label.id);
}
}
if (labelIds.length > 0) {
issueData.labelIds = labelIds;
}
}
if (!issueData.teamId) {
const defaultTeamKey =
linearService.getDefaultTeamKey(accountId) ??
(runtime.getSetting("LINEAR_DEFAULT_TEAM_KEY") as string);
if (defaultTeamKey) {
const teams = await linearService.getTeams(accountId);
const defaultTeam = teams.find(
(t) => t.key.toLowerCase() === defaultTeamKey.toLowerCase()
);
if (defaultTeam) {
issueData.teamId = defaultTeam.id;
logger.info(
`Using configured default team: ${defaultTeam.name} (${defaultTeam.key})`
);
} else {
logger.warn(`Default team key ${defaultTeamKey} not found`);
}
}
if (!issueData.teamId) {
const teams = await linearService.getTeams(accountId);
if (teams.length > 0) {
issueData.teamId = teams[0].id;
logger.warn(`No team specified, using first available team: ${teams[0].name}`);
}
}
}
} catch (parseError) {
logger.error("Failed to parse LLM response:", formatUnknownError(parseError));
issueData = {
title: content.length > 100 ? `${content.substring(0, 100)}...` : content,
description: content,
};
const defaultTeamKey =
linearService.getDefaultTeamKey(accountId) ??
(runtime.getSetting("LINEAR_DEFAULT_TEAM_KEY") as string);
const teams = await linearService.getTeams(accountId);
if (defaultTeamKey) {
const defaultTeam = teams.find(
(t) => t.key.toLowerCase() === defaultTeamKey.toLowerCase()
);
if (defaultTeam) {
issueData.teamId = defaultTeam.id;
logger.info(
`Using configured default team for fallback: ${defaultTeam.name} (${defaultTeam.key})`
);
}
}
if (!issueData.teamId && teams.length > 0) {
issueData.teamId = teams[0].id;
logger.warn(`Using first available team for fallback: ${teams[0].name}`);
}
}
}
if (!issueData.title) {
const errorMessage = "Could not determine issue title. Please provide more details.";
await callback?.({
text: errorMessage,
source: getMessageSource(message),
});
return {
text: errorMessage,
success: false,
};
}
if (!issueData.teamId) {
const errorMessage =
"No Linear teams found. Please ensure at least one team exists in your Linear workspace.";
await callback?.({
text: errorMessage,
source: getMessageSource(message),
});
return {
text: errorMessage,
success: false,
};
}
const issue = await linearService.createIssue(issueData as LinearIssueInput, accountId);
const successMessage = `✅ Created Linear issue: ${issue.title} (${issue.identifier})\n\nView it at: ${issue.url}`;
await callback?.({
text: successMessage,
source: getMessageSource(message),
});
return {
text: `Created issue: ${issue.title} (${issue.identifier})`,
success: true,
data: {
issueId: issue.id,
identifier: issue.identifier,
url: issue.url,
accountId,
},
};
} catch (error) {
logger.error("Failed to create issue:", formatUnknownError(error));
const errorMessage = `❌ Failed to create issue: ${error instanceof Error ? error.message : "Unknown error"}`;
await callback?.({
text: errorMessage,
source: getMessageSource(message),
});
return {
text: errorMessage,
success: false,
};
}
},
};
@@ -0,0 +1,95 @@
/** Handles the delete_comment Linear op: deletes a comment by id via LinearService.deleteComment. */
import {
type Action,
type ActionResult,
type HandlerCallback,
type HandlerOptions,
type IAgentRuntime,
logger,
type Memory,
type State,
} from "@elizaos/core";
import type { LinearService } from "../services/linear";
import type { DeleteCommentParameters } from "../types/index.js";
import { getLinearAccountId, linearAccountIdParameter } from "./account-options";
import { formatUnknownError, getMessageSource } from "./message-source";
import { validateLinearActionIntent } from "./validate-linear-intent";
export const deleteCommentAction: Action = {
name: "DELETE_LINEAR_COMMENT",
contexts: ["tasks", "connectors", "automation"],
contextGate: { anyOf: ["tasks", "connectors", "automation"] },
roleGate: { minRole: "USER" },
description:
"Delete Linear comment by comment id. Use for remove/retract/erase comment on Linear issue.",
descriptionCompressed: "delete Linear comment id",
parameters: [
{
name: "commentId",
description: "Linear comment id to delete.",
required: false,
schema: { type: "string" },
},
linearAccountIdParameter,
],
similes: ["remove-linear-comment", "erase-linear-comment"],
examples: [
[
{
name: "User",
content: { text: "Delete comment abc-123 from ENG-456." },
},
{
name: "Assistant",
content: {
text: "I'll delete that comment.",
actions: ["DELETE_LINEAR_COMMENT"],
},
},
],
],
validate: async (runtime: IAgentRuntime, message: Memory, state?: State): Promise<boolean> =>
validateLinearActionIntent(runtime, message, state),
async handler(
runtime: IAgentRuntime,
message: Memory,
_state?: State,
_options?: HandlerOptions,
callback?: HandlerCallback
): Promise<ActionResult> {
try {
const linearService = runtime.getService<LinearService>("linear");
if (!linearService) {
throw new Error("Linear service not available");
}
const accountId = getLinearAccountId(runtime, _options);
const params = _options?.parameters as DeleteCommentParameters | undefined;
const commentId = params?.commentId?.trim() ?? "";
if (!commentId) {
const errorMessage = "Please provide a commentId to delete.";
await callback?.({ text: errorMessage, source: getMessageSource(message) });
return { text: errorMessage, success: false };
}
await linearService.deleteComment(commentId, accountId);
const successMessage = `Deleted comment ${commentId}.`;
await callback?.({ text: successMessage, source: getMessageSource(message) });
return {
text: successMessage,
success: true,
data: { commentId, accountId },
};
} catch (error) {
logger.error("Failed to delete comment:", formatUnknownError(error));
const errorMessage = `Failed to delete comment: ${error instanceof Error ? error.message : "Unknown error"}`;
await callback?.({ text: errorMessage, source: getMessageSource(message) });
return { text: errorMessage, success: false };
}
},
};
@@ -0,0 +1,253 @@
/**
* Handles the delete_issue Linear op, which archives (not hard-deletes) an issue.
* Resolves the issue id from a parameter, prompt extraction, or regex fallback,
* then requires a two-phase user confirmation before calling
* LinearService.deleteIssue, since archiving is irreversible from the agent side.
*/
import {
type Action,
type ActionResult,
type HandlerCallback,
type HandlerOptions,
type IAgentRuntime,
logger,
type Memory,
ModelType,
requireConfirmation,
type State,
} from "@elizaos/core";
import { deleteIssueTemplate } from "../prompts.js";
import type { LinearService } from "../services/linear";
import type { DeleteIssueParameters } from "../types/index.js";
import { getLinearAccountId, linearAccountIdParameter } from "./account-options";
import { formatUnknownError, getMessageSource } from "./message-source";
import { getStringValue, parseLinearPromptResponse } from "./parseLinearPrompt.js";
import { validateLinearActionIntent } from "./validate-linear-intent";
const LINEAR_MODEL_TIMEOUT_MS = 15_000;
const LINEAR_ISSUE_TITLE_MAX_CHARS = 300;
export const deleteIssueAction: Action = {
name: "DELETE_LINEAR_ISSUE",
contexts: ["tasks", "connectors", "automation"],
contextGate: { anyOf: ["tasks", "connectors", "automation"] },
roleGate: { minRole: "USER" },
description: "Delete (archive) an issue in Linear",
descriptionCompressed: "delete (archive) issue Linear",
parameters: [
{
name: "issueId",
description: "Linear issue id or identifier to archive.",
required: false,
schema: { type: "string" },
},
linearAccountIdParameter,
],
similes: [
"delete-linear-issue",
"archive-linear-issue",
"remove-linear-issue",
"close-linear-issue",
],
examples: [
[
{
name: "User",
content: {
text: "Delete issue ENG-123",
},
},
{
name: "Assistant",
content: {
text: "I'll archive issue ENG-123 for you.",
actions: ["DELETE_LINEAR_ISSUE"],
},
},
],
[
{
name: "User",
content: {
text: "Remove COM2-7 from Linear",
},
},
{
name: "Assistant",
content: {
text: "I'll archive issue COM2-7 in Linear.",
actions: ["DELETE_LINEAR_ISSUE"],
},
},
],
[
{
name: "User",
content: {
text: "Archive the bug report BUG-456",
},
},
{
name: "Assistant",
content: {
text: "I'll archive issue BUG-456 for you.",
actions: ["DELETE_LINEAR_ISSUE"],
},
},
],
],
validate: async (runtime: IAgentRuntime, message: Memory, state?: State): Promise<boolean> =>
validateLinearActionIntent(runtime, message, state),
async handler(
runtime: IAgentRuntime,
message: Memory,
_state?: State,
_options?: HandlerOptions,
callback?: HandlerCallback
): Promise<ActionResult> {
try {
const linearService = runtime.getService<LinearService>("linear");
if (!linearService) {
throw new Error("Linear service not available");
}
const accountId = getLinearAccountId(runtime, _options);
const content = message.content.text;
if (!content) {
const errorMessage = "Please specify which issue to delete.";
await callback?.({
text: errorMessage,
source: getMessageSource(message),
});
return {
text: errorMessage,
success: false,
};
}
let issueId: string;
const params = _options?.parameters as DeleteIssueParameters | undefined;
if (params?.issueId) {
issueId = params.issueId;
} else {
const prompt = deleteIssueTemplate.replace("{{userMessage}}", content);
const response = await Promise.race([
runtime.useModel(ModelType.TEXT_LARGE, {
prompt: prompt,
}),
new Promise<never>((_, reject) =>
setTimeout(
() => reject(new Error("Linear issue extraction timeout")),
LINEAR_MODEL_TIMEOUT_MS
)
),
]);
if (!response) {
throw new Error("Failed to extract issue identifier");
}
try {
const parsed = parseLinearPromptResponse(response);
if (Object.keys(parsed).length === 0) {
throw new Error("No fields found in model response");
}
issueId = getStringValue(parsed.issueId) ?? "";
if (!issueId) {
throw new Error("Issue ID not found in parsed response");
}
} catch (parseError) {
logger.warn(
"Failed to parse LLM response, falling back to regex parsing:",
formatUnknownError(parseError)
);
const issueMatch = content.match(/(\w+-\d+)/);
if (!issueMatch) {
const errorMessage = "Please specify an issue ID (e.g., ENG-123) to delete.";
await callback?.({
text: errorMessage,
source: getMessageSource(message),
});
return {
text: errorMessage,
success: false,
};
}
issueId = issueMatch[1];
}
}
const issue = await linearService.getIssue(issueId, accountId);
const issueTitle = issue.title.slice(0, LINEAR_ISSUE_TITLE_MAX_CHARS);
const issueIdentifier = issue.identifier;
// Two-phase confirmation: archiving a Linear issue is irreversible
// from the agent's side, so always ask the user to confirm.
const decision = await requireConfirmation({
runtime,
message,
actionName: "DELETE_LINEAR_ISSUE",
pendingKey: `archive:${issue.id}`,
prompt: `Archive issue ${issueIdentifier}: "${issueTitle}"? This moves it out of active views. Reply "yes" to confirm.`,
callback,
});
if (decision.status === "pending") {
return {
text: `Awaiting confirmation to archive ${issueIdentifier}.`,
success: true,
data: { awaitingUserInput: true, issueId: issue.id, identifier: issueIdentifier },
};
}
if (decision.status === "cancelled") {
const cancelMessage = `Archive of ${issueIdentifier} cancelled.`;
await callback?.({ text: cancelMessage, source: getMessageSource(message) });
return {
text: cancelMessage,
success: true,
data: { cancelled: true, issueId: issue.id, identifier: issueIdentifier },
};
}
logger.info(`Archiving issue ${issueIdentifier}: ${issueTitle}`);
await linearService.deleteIssue(issueId, accountId);
const successMessage = `✅ Successfully archived issue ${issueIdentifier}: "${issueTitle}"\n\nThe issue has been moved to the archived state and will no longer appear in active views.`;
await callback?.({
text: successMessage,
source: getMessageSource(message),
});
return {
text: `Archived issue ${issueIdentifier}: "${issueTitle}"`,
success: true,
data: {
issueId: issue.id,
identifier: issueIdentifier,
title: issueTitle,
archived: true,
accountId,
},
};
} catch (error) {
logger.error("Failed to delete issue:", formatUnknownError(error));
const errorMessage = `❌ Failed to delete issue: ${error instanceof Error ? error.message : "Unknown error"}`;
await callback?.({
text: errorMessage,
source: getMessageSource(message),
});
return {
text: errorMessage,
success: false,
};
}
},
};
@@ -0,0 +1,333 @@
/**
* Handles the get_activity Linear op. Extracts time-range, action, resource, and
* success filters from the message via the getActivity prompt, reads the matching
* slice of LinearService's in-memory activity log, and formats it into the reply.
*/
import {
type Action,
type ActionResult,
type HandlerCallback,
type HandlerOptions,
type IAgentRuntime,
logger,
type Memory,
ModelType,
type State,
} from "@elizaos/core";
import { getActivityTemplate } from "../prompts.js";
import type { LinearService } from "../services/linear";
import { getLinearAccountId, linearAccountIdParameter } from "./account-options";
import { formatUnknownError, getMessageSource } from "./message-source";
import {
getNumberValue,
getRecordValue,
getStringArrayValue,
getStringValue,
parseLinearPromptResponse,
} from "./parseLinearPrompt.js";
import { validateLinearActionIntent } from "./validate-linear-intent";
function formatActivityDetail(value: unknown): string {
if (value === null || value === undefined) {
return "none";
}
if (Array.isArray(value)) {
return value.map(formatActivityDetail).join(", ");
}
if (typeof value === "object") {
return Object.entries(value as Record<string, unknown>)
.map(([key, nestedValue]) => `${key}=${formatActivityDetail(nestedValue)}`)
.join("; ");
}
return String(value);
}
type ActivityParams = {
filters?: Record<string, unknown>;
limit?: number;
};
export const getActivityAction: Action = {
name: "GET_LINEAR_ACTIVITY",
contexts: ["tasks", "connectors", "automation"],
contextGate: { anyOf: ["tasks", "connectors", "automation"] },
roleGate: { minRole: "USER" },
description: "Get recent Linear activity log with filters.",
descriptionCompressed: "get recent Linear activity log w/ optional filter",
similes: [
"get-linear-activity",
"show-linear-activity",
"view-linear-activity",
"check-linear-activity",
],
parameters: [
{
name: "filters",
description:
"Activity filters: fromDate ISO timestamp, action, resource_type, resource_id, success.",
required: false,
schema: { type: "object" as const },
},
{
name: "limit",
description: "Max activity entries.",
required: false,
schema: { type: "number" as const },
},
linearAccountIdParameter,
],
examples: [
[
{
name: "User",
content: {
text: "Show me recent Linear activity",
},
},
{
name: "Assistant",
content: {
text: "I'll show you the recent Linear activity.",
actions: ["GET_LINEAR_ACTIVITY"],
},
},
],
[
{
name: "User",
content: {
text: "What happened in Linear today?",
},
},
{
name: "Assistant",
content: {
text: "Let me check today's Linear activity for you.",
actions: ["GET_LINEAR_ACTIVITY"],
},
},
],
[
{
name: "User",
content: {
text: "Show me what issues John created this week",
},
},
{
name: "Assistant",
content: {
text: "I'll find the issues John created this week.",
actions: ["GET_LINEAR_ACTIVITY"],
},
},
],
],
validate: async (runtime: IAgentRuntime, message: Memory, state?: State): Promise<boolean> =>
validateLinearActionIntent(runtime, message, state),
async handler(
runtime: IAgentRuntime,
message: Memory,
_state?: State,
_options?: HandlerOptions,
callback?: HandlerCallback
): Promise<ActionResult> {
try {
const linearService = runtime.getService<LinearService>("linear");
if (!linearService) {
throw new Error("Linear service not available");
}
const accountId = getLinearAccountId(runtime, _options);
const content = message.content.text || "";
const params = (_options?.parameters ?? {}) as ActivityParams;
const filters: Record<string, unknown> = { ...(params.filters ?? {}) };
let limit =
typeof params.limit === "number" && Number.isFinite(params.limit)
? Math.max(1, Math.floor(params.limit))
: 10;
if (content) {
const prompt = getActivityTemplate.replace("{{userMessage}}", content);
const response = await runtime.useModel(ModelType.TEXT_LARGE, {
prompt: prompt,
});
if (response) {
try {
const parsed = parseLinearPromptResponse(response);
if (Object.keys(parsed).length === 0) {
throw new Error("No fields found in model response");
}
const timeRange = getRecordValue(parsed.timeRange);
if (timeRange) {
const now = new Date();
let fromDate: Date | undefined;
const from = getStringValue(timeRange.from);
const period = getStringValue(timeRange.period);
if (from) {
fromDate = new Date(from);
} else if (period) {
switch (period) {
case "today":
fromDate = new Date(now.setHours(0, 0, 0, 0));
break;
case "yesterday":
fromDate = new Date(now.setDate(now.getDate() - 1));
fromDate.setHours(0, 0, 0, 0);
break;
case "this-week":
fromDate = new Date(now.setDate(now.getDate() - now.getDay()));
fromDate.setHours(0, 0, 0, 0);
break;
case "last-week":
fromDate = new Date(now.setDate(now.getDate() - now.getDay() - 7));
fromDate.setHours(0, 0, 0, 0);
break;
case "this-month":
fromDate = new Date(now.getFullYear(), now.getMonth(), 1);
break;
}
}
if (fromDate) {
filters.fromDate = fromDate.toISOString();
}
}
const actionTypes = getStringArrayValue(parsed.actionTypes);
if (actionTypes && actionTypes.length > 0) {
filters.action = actionTypes[0];
}
const resourceTypes = getStringArrayValue(parsed.resourceTypes);
if (resourceTypes && resourceTypes.length > 0) {
filters.resource_type = resourceTypes[0];
}
const resourceId = getStringValue(parsed.resourceId);
if (resourceId) {
filters.resource_id = resourceId;
}
const successFilter = getStringValue(parsed.successFilter);
if (successFilter && successFilter !== "all") {
filters.success = successFilter === "success";
}
limit = getNumberValue(parsed.limit) || 10;
} catch (parseError) {
logger.warn("Failed to parse activity filters:", formatUnknownError(parseError));
}
}
}
let activity = linearService.getActivityLog(limit * 2, filters, accountId);
if (filters.fromDate) {
const fromDateValue = filters.fromDate;
const fromDate =
typeof fromDateValue === "string"
? fromDateValue
: fromDateValue instanceof Date
? fromDateValue.toISOString()
: String(fromDateValue);
const fromTime = new Date(fromDate).getTime();
if (!Number.isNaN(fromTime)) {
activity = activity.filter((item) => new Date(item.timestamp).getTime() >= fromTime);
}
}
activity = activity.slice(0, limit);
if (activity.length === 0) {
const noActivityMessage = filters.fromDate
? `No Linear activity found for the specified filters.`
: "No recent Linear activity found.";
await callback?.({
text: noActivityMessage,
source: getMessageSource(message),
});
return {
text: noActivityMessage,
success: true,
data: {
activity: [],
accountId,
},
};
}
const activityText = activity
.map((item, index) => {
const time = new Date(item.timestamp).toLocaleString();
const status = item.success ? "✅" : "❌";
const details = Object.entries(item.details)
.filter(([key]) => key !== "filters")
.map(([key, value]) => `${key}: ${formatActivityDetail(value)}`)
.join(", ");
return `${index + 1}. ${status} ${item.action} on ${item.resource_type} ${item.resource_id}\n Time: ${time}\n ${details ? `Details: ${details}` : ""}${item.error ? `\n Error: ${item.error}` : ""}`;
})
.join("\n\n");
const headerText = filters.fromDate
? `📊 Linear activity ${content}:`
: "📊 Recent Linear activity:";
const resultMessage = `${headerText}\n\n${activityText}`;
await callback?.({
text: resultMessage,
source: getMessageSource(message),
});
return {
text: `Found ${activity.length} activity item${activity.length === 1 ? "" : "s"}`,
success: true,
data: {
activity: activity.map((item) => ({
id: item.id,
action: item.action,
resource_type: item.resource_type,
resource_id: item.resource_id,
success: item.success,
error: item.error,
details: formatActivityDetail(item.details),
timestamp:
typeof item.timestamp === "string"
? item.timestamp
: new Date(item.timestamp).toISOString(),
})) as Array<Record<string, string | boolean | undefined>>,
filters: filters
? {
...filters,
fromDate: filters.fromDate
? typeof filters.fromDate === "string"
? filters.fromDate
: String(filters.fromDate)
: undefined,
}
: undefined,
count: activity.length,
accountId,
},
};
} catch (error) {
logger.error("Failed to get activity:", formatUnknownError(error));
const errorMessage = `❌ Failed to get activity: ${error instanceof Error ? error.message : "Unknown error"}`;
await callback?.({
text: errorMessage,
source: getMessageSource(message),
});
return {
text: errorMessage,
success: false,
};
}
},
};
@@ -0,0 +1,425 @@
/**
* Handles the get_issue Linear op. Fetches a single issue by identifier when one
* is given, otherwise uses the getIssue prompt to extract a direct id or
* search-by fields, resolves those to issues via LinearService, and formats a
* single hit — or a shortlist for the user to disambiguate — into the reply.
*/
import {
type Action,
type ActionResult,
type HandlerCallback,
type HandlerOptions,
type IAgentRuntime,
logger,
type Memory,
ModelType,
type State,
} from "@elizaos/core";
import type { Issue, IssueLabel } from "@linear/sdk";
import { getIssueTemplate } from "../prompts.js";
import type { LinearService } from "../services/linear";
import { getLinearAccountId, linearAccountIdParameter } from "./account-options";
import { formatUnknownError, getMessageSource } from "./message-source";
import { getRecordValue, getStringValue, parseLinearPromptResponse } from "./parseLinearPrompt.js";
import { validateLinearActionIntent } from "./validate-linear-intent";
type GetIssueParams = {
issueId?: string;
query?: string;
};
export const getIssueAction: Action = {
name: "GET_LINEAR_ISSUE",
contexts: ["tasks", "connectors", "knowledge"],
contextGate: { anyOf: ["tasks", "connectors", "knowledge"] },
roleGate: { minRole: "USER" },
description: "Get details of a specific Linear issue",
descriptionCompressed: "get detail specific Linear issue",
similes: [
"get-linear-issue",
"show-linear-issue",
"view-linear-issue",
"check-linear-issue",
"find-linear-issue",
],
parameters: [
{
name: "issueId",
description: "Linear issue identifier or id, e.g. ENG-123.",
required: false,
schema: { type: "string" as const },
},
{
name: "query",
description: "Search text when the exact Linear issue identifier is unknown.",
required: false,
schema: { type: "string" as const },
},
linearAccountIdParameter,
],
examples: [
[
{
name: "User",
content: {
text: "Show me issue ENG-123",
},
},
{
name: "Assistant",
content: {
text: "I'll get the details for issue ENG-123.",
actions: ["GET_LINEAR_ISSUE"],
},
},
],
[
{
name: "User",
content: {
text: "What's the status of the login bug?",
},
},
{
name: "Assistant",
content: {
text: "Let me find the login bug issue for you.",
actions: ["GET_LINEAR_ISSUE"],
},
},
],
[
{
name: "User",
content: {
text: "Show me the latest high priority issue assigned to Sarah",
},
},
{
name: "Assistant",
content: {
text: "I'll find the latest high priority issue assigned to Sarah.",
actions: ["GET_LINEAR_ISSUE"],
},
},
],
],
validate: async (runtime: IAgentRuntime, message: Memory, state?: State): Promise<boolean> =>
validateLinearActionIntent(runtime, message, state),
async handler(
runtime: IAgentRuntime,
message: Memory,
_state?: State,
_options?: HandlerOptions,
callback?: HandlerCallback
): Promise<ActionResult> {
try {
const linearService = runtime.getService<LinearService>("linear");
if (!linearService) {
throw new Error("Linear service not available");
}
const accountId = getLinearAccountId(runtime, _options);
const params = (_options?.parameters ?? {}) as GetIssueParams;
const content = params.query ?? params.issueId ?? message.content.text;
if (!content) {
const errorMessage = "Please specify which issue you want to see.";
await callback?.({
text: errorMessage,
source: getMessageSource(message),
});
return {
text: errorMessage,
success: false,
};
}
if (params.issueId) {
const issue = await linearService.getIssue(params.issueId, accountId);
return await formatIssueResponse(issue, callback, message);
}
const prompt = getIssueTemplate.replace("{{userMessage}}", content);
const response = await runtime.useModel(ModelType.TEXT_LARGE, {
prompt: prompt,
});
if (!response) {
const issueMatch = content.match(/(\w+-\d+)/);
if (issueMatch) {
const issue = await linearService.getIssue(issueMatch[1], accountId);
return await formatIssueResponse(issue, callback, message);
}
throw new Error("Could not understand issue reference");
}
try {
const parsed = parseLinearPromptResponse(response);
if (Object.keys(parsed).length === 0) {
throw new Error("No fields found in model response");
}
const directId = getStringValue(parsed.directId);
if (directId) {
const issue = await linearService.getIssue(directId, accountId);
return await formatIssueResponse(issue, callback, message);
}
const searchBy = getRecordValue(parsed.searchBy);
if (searchBy && Object.keys(searchBy).length > 0) {
const filters: Record<string, unknown> = {};
const title = getStringValue(searchBy.title);
if (title) {
filters.query = title;
}
const assignee = getStringValue(searchBy.assignee);
if (assignee) {
filters.assignee = [assignee];
}
const priorityValue = getStringValue(searchBy.priority);
if (priorityValue) {
const priorityMap: Record<string, number> = {
urgent: 1,
high: 2,
normal: 3,
low: 4,
"1": 1,
"2": 2,
"3": 3,
"4": 4,
};
const priority = priorityMap[priorityValue.toLowerCase()];
if (priority) {
filters.priority = [priority];
}
}
const team = getStringValue(searchBy.team);
if (team) {
filters.team = team;
}
const state = getStringValue(searchBy.state);
if (state) {
filters.state = [state];
}
const defaultTeamKey =
linearService.getDefaultTeamKey(accountId) ??
(runtime.getSetting("LINEAR_DEFAULT_TEAM_KEY") as string);
if (defaultTeamKey && !filters.team) {
filters.team = defaultTeamKey;
}
const issues = await linearService.searchIssues(
{
...filters,
limit: getStringValue(searchBy.recency) ? 10 : 5,
},
accountId
);
if (issues.length === 0) {
const noResultsMessage = "No issues found matching your criteria.";
await callback?.({
text: noResultsMessage,
source: getMessageSource(message),
});
return {
text: noResultsMessage,
success: false,
};
}
if (getStringValue(searchBy.recency)) {
issues.sort(
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
);
}
if (getStringValue(searchBy.recency) && issues.length > 0) {
return await formatIssueResponse(issues[0], callback, message);
}
if (issues.length === 1) {
return await formatIssueResponse(issues[0], callback, message);
}
const issueList = await Promise.all(
issues.slice(0, 5).map(async (issue, index) => {
const state = await issue.state;
return `${index + 1}. ${issue.identifier}: ${issue.title} (${state?.name || "No state"})`;
})
);
const clarifyMessage = `Found ${issues.length} issues matching your criteria:\n${issueList.join("\n")}\n\nPlease specify which one you want to see by its ID.`;
await callback?.({
text: clarifyMessage,
source: getMessageSource(message),
});
return {
text: clarifyMessage,
success: true,
data: {
multipleResults: true,
issues: issues.slice(0, 5).map((i) => ({
id: i.id,
identifier: i.identifier,
title: i.title,
})),
},
};
}
} catch (parseError) {
logger.warn(
"Failed to parse LLM response, falling back to regex:",
formatUnknownError(parseError)
);
const issueMatch = content.match(/(\w+-\d+)/);
if (issueMatch) {
const issue = await linearService.getIssue(issueMatch[1], accountId);
return await formatIssueResponse(issue, callback, message);
}
}
const errorMessage =
"Could not understand which issue you want to see. Please provide an issue ID (e.g., ENG-123) or describe it more specifically.";
await callback?.({
text: errorMessage,
source: getMessageSource(message),
});
return {
text: errorMessage,
success: false,
};
} catch (error) {
logger.error("Failed to get issue:", formatUnknownError(error));
const errorMessage = `❌ Failed to get issue: ${error instanceof Error ? error.message : "Unknown error"}`;
await callback?.({
text: errorMessage,
source: getMessageSource(message),
});
return {
text: errorMessage,
success: false,
};
}
},
};
async function formatIssueResponse(
issue: Issue,
callback: HandlerCallback | undefined,
message: Memory
): Promise<ActionResult> {
const assignee = await issue.assignee;
const state = await issue.state;
const team = await issue.team;
const labels = await issue.labels();
const project = await issue.project;
const issueDetails = {
id: issue.id,
identifier: issue.identifier,
title: issue.title,
description: issue.description,
priority: issue.priority,
priorityLabel: issue.priorityLabel,
url: issue.url,
createdAt: issue.createdAt,
updatedAt: issue.updatedAt,
dueDate: issue.dueDate,
estimate: issue.estimate,
assignee: assignee
? {
id: assignee.id,
name: assignee.name,
email: assignee.email,
}
: null,
state: state
? {
id: state.id,
name: state.name,
type: state.type,
color: state.color,
}
: null,
team: team
? {
id: team.id,
name: team.name,
key: team.key,
}
: null,
labels: labels.nodes.map((label: IssueLabel) => ({
id: label.id,
name: label.name,
color: label.color,
})),
project: project
? {
id: project.id,
name: project.name,
description: project.description,
}
: null,
};
const priorityLabels = ["", "Urgent", "High", "Normal", "Low"];
const priority = priorityLabels[issue.priority || 0] || "No priority";
const labelText =
issueDetails.labels.length > 0
? `Labels: ${issueDetails.labels.map((l) => l.name).join(", ")}`
: "";
const issueMessage = `📋 **${issue.identifier}: ${issue.title}**
Status: ${state?.name || "No status"}
Priority: ${priority}
Team: ${team?.name || "No team"}
Assignee: ${assignee?.name || "Unassigned"}
${issue.dueDate ? `Due: ${new Date(issue.dueDate).toLocaleDateString()}` : ""}
${labelText}
${project ? `Project: ${project.name}` : ""}
${issue.description || "No description"}
View in Linear: ${issue.url}`;
await callback?.({
text: issueMessage,
source: getMessageSource(message),
});
const serializedIssue = {
...issueDetails,
createdAt:
issueDetails.createdAt instanceof Date
? issueDetails.createdAt.toISOString()
: issueDetails.createdAt,
updatedAt:
issueDetails.updatedAt instanceof Date
? issueDetails.updatedAt.toISOString()
: issueDetails.updatedAt,
dueDate: issueDetails.dueDate
? issueDetails.dueDate instanceof Date
? issueDetails.dueDate.toISOString()
: issueDetails.dueDate
: null,
};
return {
text: `Retrieved issue ${issue.identifier}: ${issue.title}`,
success: true,
data: { issue: serializedIssue },
};
}
@@ -0,0 +1,9 @@
/** Re-exports the Linear sub-action handlers and router helpers. */
export * from "./clearActivity";
export * from "./createComment";
export * from "./createIssue";
export * from "./deleteIssue";
export * from "./getActivity";
export * from "./getIssue";
export * from "./routers";
export * from "./searchIssues";
+297
View File
@@ -0,0 +1,297 @@
/**
* The single composite LINEAR action fronting every Linear sub-operation.
* Selects a route from an explicit `action` op or a regex match on the message
* text, dispatches to the matching sub-action handler, and stamps the result
* `data` with the resolved op and routed action name. Promoted to top-level
* actions in the plugin entry so each op is also directly callable.
*/
import type {
Action,
ActionResult,
HandlerCallback,
HandlerOptions,
IAgentRuntime,
Memory,
State,
} from "@elizaos/core";
import { hasLinearAccountConfig } from "../accounts";
import { linearAccountIdParameter } from "./account-options";
import { clearActivityAction } from "./clearActivity";
import { createCommentAction } from "./createComment";
import { createIssueAction } from "./createIssue";
import { deleteCommentAction } from "./deleteComment";
import { deleteIssueAction } from "./deleteIssue";
import { getActivityAction } from "./getActivity";
import { getIssueAction } from "./getIssue";
import { listCommentsAction } from "./listComments";
import { getMessageSource } from "./message-source";
import { searchIssuesAction } from "./searchIssues";
import { handleUpdateComment } from "./updateComment";
import { handleUpdateIssue } from "./updateIssue";
export const LINEAR_CONTEXT = "linear";
type LinearOp =
| "create_issue"
| "get_issue"
| "update_issue"
| "delete_issue"
| "create_comment"
| "update_comment"
| "delete_comment"
| "list_comments"
| "get_activity"
| "clear_activity"
| "search_issues";
const ALL_OPS: readonly LinearOp[] = [
"create_issue",
"get_issue",
"update_issue",
"delete_issue",
"create_comment",
"update_comment",
"delete_comment",
"list_comments",
"get_activity",
"clear_activity",
"search_issues",
] as const;
type LinearHandlerFn = (
runtime: IAgentRuntime,
message: Memory,
state?: State,
options?: HandlerOptions,
callback?: HandlerCallback
) => Promise<ActionResult>;
interface LinearRoute {
op: LinearOp;
action?: Action;
run?: LinearHandlerFn;
match: RegExp;
}
const ROUTES: LinearRoute[] = [
{
op: "delete_issue",
action: deleteIssueAction,
match: /\b(delete|archive|remove|close)\b.*\b(issue|bug|task|ticket|[a-z]+-\d+)\b/i,
},
{
op: "update_issue",
run: handleUpdateIssue,
match:
/\b(update|edit|modify|move|change|assign|reassign|priority|status|label)\b.*\b(issue|bug|task|ticket|[a-z]+-\d+)\b/i,
},
{
op: "create_issue",
action: createIssueAction,
match:
/\b(create|new|add|file|open)\b.*\b(issue|bug|task|ticket|linear)\b|\b(issue|bug|task|ticket)\b.*\b(create|new|add|file|open)\b/i,
},
{
op: "create_comment",
action: createCommentAction,
match: /\b(comment|reply|note|tell)\b.*\b(issue|bug|task|ticket|[a-z]+-\d+)\b/i,
},
{
op: "update_comment",
run: handleUpdateComment,
match: /\b(update|edit|modify|change)\b.*\bcomment\b/i,
},
{
op: "delete_comment",
action: deleteCommentAction,
match: /\b(delete|remove|erase)\b.*\bcomment\b/i,
},
{
op: "list_comments",
action: listCommentsAction,
match:
/\b(list|show|get|fetch|view)\b.*\bcomments?\b|\bcomments?\b.*\b(list|show|get|fetch)\b/i,
},
{
op: "clear_activity",
action: clearActivityAction,
match: /\b(clear|reset|delete)\b.*\b(activity|activity log)\b/i,
},
{
op: "get_activity",
action: getActivityAction,
match: /\b(activity|activity log|what happened|recent changes|audit)\b/i,
},
{
op: "search_issues",
action: searchIssuesAction,
match:
/\b(search|find|query|list|show)\b.*\b(issues?|bugs?|tasks?|tickets?)\b|\b(open|closed|unassigned|assigned|high priority|blockers?)\b.*\b(issues?|bugs?|tasks?|tickets?)\b/i,
},
{
op: "get_issue",
action: getIssueAction,
match:
/\b(show|get|view|check|details?|status|what'?s|find)\b.*\b(issue|bug|task|ticket|[a-z]+-\d+)\b|[a-z]+-\d+/i,
},
];
function textOf(message: Memory): string {
return typeof message.content?.text === "string" ? message.content.text : "";
}
function readOptions(options?: HandlerOptions | Record<string, unknown>): Record<string, unknown> {
const direct = (options ?? {}) as Record<string, unknown>;
const parameters =
direct.parameters && typeof direct.parameters === "object"
? (direct.parameters as Record<string, unknown>)
: {};
return { ...direct, ...parameters };
}
function normalizeOp(value: unknown): LinearOp | null {
if (typeof value !== "string") return null;
const trimmed = value
.trim()
.toLowerCase()
.replace(/[\s-]+/g, "_");
return (ALL_OPS as readonly string[]).includes(trimmed) ? (trimmed as LinearOp) : null;
}
function selectRoute(
message: Memory,
options?: HandlerOptions | Record<string, unknown>
): LinearRoute | null {
const opts = readOptions(options);
const requested = normalizeOp(opts.action ?? opts.subaction ?? opts.op);
if (requested) {
const route = ROUTES.find((candidate) => candidate.op === requested);
if (route) return route;
}
const text = textOf(message);
return ROUTES.find((route) => route.match.test(text)) ?? null;
}
function hasLinearAccess(runtime: IAgentRuntime): boolean {
return hasLinearAccountConfig(runtime);
}
export const linearAction: Action = {
name: "LINEAR",
description:
"Manage Linear issues/comments/activity. Ops: create_issue, get_issue, update_issue, delete_issue, create_comment, update_comment, delete_comment, list_comments, get_activity, clear_activity, search_issues. Infer op if omitted.",
descriptionCompressed: "Linear: issue CRUD, comment CRUD/list, search issues, get/clear activity",
similes: [
// Group/router-style names
"LINEAR_ISSUE",
"LINEAR_ISSUES",
"LINEAR_COMMENT",
"LINEAR_COMMENTS",
"LINEAR_WORKFLOW",
"LINEAR_ACTIVITY",
"LINEAR_SEARCH",
// Issue ops
"CREATE_LINEAR_ISSUE",
"GET_LINEAR_ISSUE",
"UPDATE_LINEAR_ISSUE",
"DELETE_LINEAR_ISSUE",
"MANAGE_LINEAR_ISSUE",
"MANAGE_LINEAR_ISSUES",
// Comment ops
"CREATE_LINEAR_COMMENT",
"COMMENT_LINEAR_ISSUE",
"UPDATE_LINEAR_COMMENT",
"DELETE_LINEAR_COMMENT",
"LIST_LINEAR_COMMENTS",
// Workflow / activity / search ops
"GET_LINEAR_ACTIVITY",
"CLEAR_LINEAR_ACTIVITY",
"SEARCH_LINEAR_ISSUES",
"LINEAR_WORKFLOW_SEARCH",
],
contexts: ["general", "automation", "knowledge", LINEAR_CONTEXT],
contextGate: { anyOf: ["general", "automation", "knowledge", LINEAR_CONTEXT] },
roleGate: { minRole: "USER" },
parameters: [
{
name: "action",
description:
"Operation: create_issue, get_issue, update_issue, delete_issue, create_comment, update_comment, delete_comment, list_comments, get_activity, clear_activity, search_issues. Infer if omitted.",
required: false,
schema: { type: "string", enum: [...ALL_OPS] },
},
linearAccountIdParameter,
],
validate: async (runtime: IAgentRuntime) => {
if (!hasLinearAccess(runtime)) return false;
return true;
},
handler: async (
runtime: IAgentRuntime,
message: Memory,
state: State | undefined,
options?: HandlerOptions,
callback?: HandlerCallback
): Promise<ActionResult> => {
const route = selectRoute(message, options);
if (!route) {
const ops = ALL_OPS.join(", ");
const text = `LINEAR could not determine the operation. Specify one of: ${ops}.`;
await callback?.({ text, source: getMessageSource(message) });
return {
success: false,
text,
values: { error: "MISSING" },
data: { actionName: "LINEAR", availableOps: ops },
};
}
const dispatch = route.run ?? route.action?.handler?.bind(route.action);
const result = dispatch
? ((await dispatch(runtime, message, state, options, callback)) ??
({ success: true } as ActionResult))
: ({ success: true } as ActionResult);
return {
...result,
data: {
...(typeof result.data === "object" && result.data ? result.data : {}),
actionName: "LINEAR",
routedActionName: route.action?.name ?? route.op,
op: route.op,
},
};
},
examples: [
[
{ name: "{{user1}}", content: { text: "Create a Linear issue for the mobile login bug" } },
{
name: "{{agentName}}",
content: {
text: "I'll create that Linear issue.",
actions: ["LINEAR"],
},
},
],
[
{ name: "{{user1}}", content: { text: "Comment on ENG-123 that QA can retest it" } },
{
name: "{{agentName}}",
content: { text: "I'll add that comment to ENG-123.", actions: ["LINEAR"] },
},
],
[
{ name: "{{user1}}", content: { text: "Search open Linear bugs for the backend team" } },
{
name: "{{agentName}}",
content: { text: "I'll search Linear issues.", actions: ["LINEAR"] },
},
],
[
{ name: "{{user1}}", content: { text: "What's the status of ENG-456?" } },
{
name: "{{agentName}}",
content: { text: "Looking up ENG-456.", actions: ["LINEAR"] },
},
],
],
};
@@ -0,0 +1,144 @@
/**
* Handles the list_comments Linear op. Reads the issue id from parameters or a
* regex match on the message, fetches comments (default 25, capped at 100) via
* LinearService.listComments, and formats author/date/body lines into the reply.
*/
import {
type Action,
type ActionResult,
type HandlerCallback,
type HandlerOptions,
type IAgentRuntime,
logger,
type Memory,
type State,
} from "@elizaos/core";
import type { LinearService } from "../services/linear";
import type { ListCommentsParameters } from "../types/index.js";
import { getLinearAccountId, linearAccountIdParameter } from "./account-options";
import { formatUnknownError, getMessageSource } from "./message-source";
import { validateLinearActionIntent } from "./validate-linear-intent";
export const listCommentsAction: Action = {
name: "LIST_LINEAR_COMMENTS",
contexts: ["tasks", "connectors", "automation"],
contextGate: { anyOf: ["tasks", "connectors", "automation"] },
roleGate: { minRole: "USER" },
description: "List comments on a Linear issue",
descriptionCompressed: "list comment Linear issue",
parameters: [
{
name: "issueId",
description: "Linear issue id or identifier to list comments for.",
required: false,
schema: { type: "string" },
},
{
name: "limit",
description: "Max comments. Default 25, max 100.",
required: false,
schema: { type: "number" },
},
linearAccountIdParameter,
],
similes: ["get-linear-comments", "show-linear-comments", "fetch-linear-comments"],
examples: [
[
{
name: "User",
content: { text: "Show the comments on ENG-123." },
},
{
name: "Assistant",
content: {
text: "Here are the comments on ENG-123.",
actions: ["LIST_LINEAR_COMMENTS"],
},
},
],
],
validate: async (runtime: IAgentRuntime, message: Memory, state?: State): Promise<boolean> =>
validateLinearActionIntent(runtime, message, state),
async handler(
runtime: IAgentRuntime,
message: Memory,
_state?: State,
_options?: HandlerOptions,
callback?: HandlerCallback
): Promise<ActionResult> {
try {
const linearService = runtime.getService<LinearService>("linear");
if (!linearService) {
throw new Error("Linear service not available");
}
const accountId = getLinearAccountId(runtime, _options);
const params = _options?.parameters as ListCommentsParameters | undefined;
const issueId = params?.issueId?.trim() ?? "";
const limit =
typeof params?.limit === "number" ? Math.min(Math.max(1, params.limit), 100) : 25;
if (!issueId) {
// Try to extract issue id from message text
const match = message.content.text?.match(/([A-Z]+-\d+)/);
if (!match) {
const errorMessage = "Please provide an issueId to list comments for.";
await callback?.({
text: errorMessage,
source: getMessageSource(message),
});
return { text: errorMessage, success: false };
}
const extractedId = match[1];
const comments = await linearService.listComments(extractedId, limit, accountId);
return formatCommentResult(extractedId, comments, message, callback);
}
const comments = await linearService.listComments(issueId, limit, accountId);
return formatCommentResult(issueId, comments, message, callback);
} catch (error) {
logger.error("Failed to list comments:", formatUnknownError(error));
const errorMessage = `Failed to list comments: ${error instanceof Error ? error.message : "Unknown error"}`;
await callback?.({ text: errorMessage, source: getMessageSource(message) });
return { text: errorMessage, success: false };
}
},
};
async function formatCommentResult(
issueId: string,
comments: Awaited<ReturnType<LinearService["listComments"]>>,
message: Memory,
callback?: HandlerCallback
): Promise<ActionResult> {
if (comments.length === 0) {
const text = `No comments on issue ${issueId}.`;
await callback?.({ text, source: getMessageSource(message) });
return { text, success: true, data: { issueId, comments: [] } };
}
const lines = await Promise.all(
comments.map(async (c) => {
const user = await c.user;
const name = user?.name ?? "unknown";
const created = c.createdAt ? new Date(c.createdAt).toISOString().slice(0, 10) : "?";
const body = (c.body ?? "").slice(0, 200);
return `- [${c.id}] ${name} (${created}): ${body}`;
})
);
const text = `${comments.length} comment(s) on ${issueId}:\n${lines.join("\n")}`;
await callback?.({ text, source: getMessageSource(message) });
return {
text,
success: true,
data: {
issueId,
count: comments.length,
comments: comments.map((c) => ({ id: c.id })),
},
};
}
@@ -0,0 +1,21 @@
/**
* Small helpers shared by Linear action handlers: reads the originating
* message `source` to echo back on callbacks, and renders an unknown thrown
* value into a loggable string.
*/
import type { Memory } from "@elizaos/core";
export function getMessageSource(message: Memory): string | undefined {
const source = (message.content as { source?: unknown }).source;
return typeof source === "string" ? source : undefined;
}
export function formatUnknownError(error: unknown): string {
if (error instanceof Error) return error.stack ?? error.message;
if (typeof error === "string") return error;
try {
return JSON.stringify(error) ?? String(error);
} catch {
return String(error);
}
}
@@ -0,0 +1,79 @@
/**
* Unit and property tests (fast-check) for the tolerant LLM-response parsing
* helpers: JSON extraction from fenced/prose output and per-field
* scalar/array/boolean/number/priority coercion. Deterministic, no live model.
*/
import * as fc from "fast-check";
import { describe, expect, it } from "vitest";
import {
getBooleanValue,
getNumberValue,
getPriorityNumberValue,
getRecordValue,
getStringArrayValue,
getStringValue,
parseLinearPromptResponse,
} from "./parseLinearPrompt";
describe("parseLinearPromptResponse", () => {
it("extracts JSON from fenced or prose-wrapped model responses", () => {
expect(
parseLinearPromptResponse(
'Sure:\n```json\n{"title":"Fix login","priority":"high","labels":["bug"]}\n```'
)
).toEqual({
title: "Fix login",
priority: "high",
labels: ["bug"],
});
expect(parseLinearPromptResponse('Result: {"title":"Fix API"} thanks')).toEqual({
title: "Fix API",
});
});
it("normalizes empty scalar/list sentinels and priority names", () => {
expect(getStringValue(" n/a ")).toBeUndefined();
expect(getStringValue('"ENG"')).toBe("ENG");
expect(getStringArrayValue("bug, regression\nfrontend")).toEqual([
"bug",
"regression",
"frontend",
]);
expect(getStringArrayValue("clear all")).toEqual([]);
expect(getPriorityNumberValue("urgent")).toBe(1);
expect(getPriorityNumberValue("low")).toBe(4);
});
it("rejects hostile or malformed model values without leaking bogus fields", () => {
expect(parseLinearPromptResponse('["not", "an", "object"]')).toEqual({});
expect(parseLinearPromptResponse('{"title":"first"} trailing {"title":"second"}')).toEqual({});
expect(getRecordValue('{"updates":{"title":"Fix it"}}')).toEqual({
updates: { title: "Fix it" },
});
expect(getRecordValue('{"updates":')).toBeUndefined();
expect(getStringArrayValue(["bug, regression", null, 4, false])).toEqual([
"bug",
"regression",
"4",
"false",
]);
expect(getStringArrayValue('"clear"')).toEqual([]);
expect(getBooleanValue("YES")).toBe(true);
expect(getBooleanValue("0")).toBeUndefined();
expect(getNumberValue("Infinity")).toBeUndefined();
expect(getPriorityNumberValue(0)).toBeUndefined();
});
it("fuzzes arbitrary model text as non-throwing object output", () => {
fc.assert(
fc.property(fc.string({ maxLength: 2_000 }), (response) => {
const parsed = parseLinearPromptResponse(response);
expect(parsed).not.toBeNull();
expect(typeof parsed).toBe("object");
expect(Array.isArray(parsed)).toBe(false);
}),
{ numRuns: 500 }
);
});
});
@@ -0,0 +1,163 @@
/**
* Tolerant parsing helpers for LLM responses in the Linear action handlers.
* `parseLinearPromptResponse` pulls a JSON object out of fenced or prose-wrapped
* model output; the `get*Value` helpers coerce individual fields to
* string/array/boolean/number/priority, treating sentinels like "none"/"n/a" as
* empty and mapping priority names (urgent/high/normal/low) to Linear's numbers.
*/
const EMPTY_SCALAR_VALUES = new Set(["", "none", "null", "undefined", "n/a", "not provided"]);
const EMPTY_LIST_VALUES = new Set([...EMPTY_SCALAR_VALUES, "clear", "clear all", "no labels"]);
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function stripWrappingQuotes(value: string): string {
const trimmed = value.trim();
if (
(trimmed.startsWith('"') && trimmed.endsWith('"')) ||
(trimmed.startsWith("'") && trimmed.endsWith("'"))
) {
return trimmed.slice(1, -1).trim();
}
return trimmed;
}
function normalizeListEntry(value: string): string {
return stripWrappingQuotes(value.replace(/^\s*[-*]\s*/, "").trim());
}
function splitListString(value: string): string[] {
let trimmed = value.trim();
if (trimmed.startsWith("[") && trimmed.endsWith("]")) {
trimmed = trimmed.slice(1, -1);
}
return trimmed.split(/[,\n]/).map(normalizeListEntry).filter(Boolean);
}
export function parseLinearPromptResponse(response: string): Record<string, unknown> {
try {
const trimmed = response.trim();
const fenced = trimmed.match(/```(?:json)?\s*([\s\S]*?)\s*```/i);
const candidate = (fenced?.[1] ?? trimmed).trim();
const firstBrace = candidate.indexOf("{");
const lastBrace = candidate.lastIndexOf("}");
if (firstBrace < 0 || lastBrace <= firstBrace) return {};
const parsed = JSON.parse(candidate.slice(firstBrace, lastBrace + 1));
return isRecord(parsed) ? parsed : {};
} catch {
return {};
}
}
export function getRecordValue(value: unknown): Record<string, unknown> | undefined {
if (isRecord(value)) {
return value;
}
if (typeof value === "string" && value.trim().startsWith("{")) {
const parsed = parseLinearPromptResponse(value);
return Object.keys(parsed).length > 0 ? parsed : undefined;
}
return undefined;
}
export function getStringValue(value: unknown): string | undefined {
if (typeof value === "string") {
const normalized = stripWrappingQuotes(value);
return EMPTY_SCALAR_VALUES.has(normalized.toLowerCase()) ? undefined : normalized;
}
if (typeof value === "number" || typeof value === "boolean") {
return String(value);
}
return undefined;
}
export function getStringArrayValue(value: unknown): string[] | undefined {
if (Array.isArray(value)) {
return value
.flatMap((entry) => {
if (entry == null) {
return [];
}
if (typeof entry === "string") {
return splitListString(entry);
}
return [String(entry)];
})
.filter(Boolean);
}
if (typeof value === "string") {
const normalized = stripWrappingQuotes(value);
if (EMPTY_LIST_VALUES.has(normalized.toLowerCase())) {
return [];
}
return splitListString(normalized);
}
return undefined;
}
export function getBooleanValue(value: unknown): boolean | undefined {
if (typeof value === "boolean") {
return value;
}
if (typeof value === "string") {
const normalized = value.trim().toLowerCase();
if (["true", "yes", "y"].includes(normalized)) {
return true;
}
if (["false", "no", "n"].includes(normalized)) {
return false;
}
}
return undefined;
}
export function getNumberValue(value: unknown): number | undefined {
if (typeof value === "number" && Number.isFinite(value)) {
return value;
}
if (typeof value === "string") {
const normalized = value.trim();
if (!normalized) {
return undefined;
}
const parsed = Number(normalized);
return Number.isFinite(parsed) ? parsed : undefined;
}
return undefined;
}
export function getPriorityNumberValue(value: unknown): number | undefined {
const numeric = getNumberValue(value);
if (numeric) {
return numeric;
}
const priority = getStringValue(value)?.toLowerCase();
if (!priority) {
return undefined;
}
const priorityMap: Record<string, number> = {
urgent: 1,
high: 2,
normal: 3,
medium: 3,
low: 4,
};
return priorityMap[priority];
}
@@ -0,0 +1,225 @@
/**
* Unit tests for the grouped Linear router actions: verifies route selection and
* dispatch to the child sub-actions. Deterministic, mocked runtime/service, no
* live API.
*/
import type { IAgentRuntime, Memory } from "@elizaos/core";
import { describe, expect, it, vi } from "vitest";
import { createCommentAction } from "./createComment";
import { createIssueAction } from "./createIssue";
import {
getLinearRouteForTest,
linearCommentRouterAction,
linearIssueRouterAction,
linearWorkflowRouterAction,
} from "./routers";
function createRuntime(): IAgentRuntime {
return {
agentId: "agent-id",
getSetting: vi.fn((key: string) => (key === "LINEAR_API_KEY" ? "linear-key" : undefined)),
getService: vi.fn(),
} as IAgentRuntime;
}
function createMessage(text: string): Memory {
return {
id: "message-id",
agentId: "agent-id",
entityId: "entity-id",
roomId: "room-id",
content: { text, source: "test" },
} as Memory;
}
describe("Linear router actions", () => {
it("selects issue and workflow subactions from message text", () => {
expect(getLinearRouteForTest("issue", createMessage("Archive ENG-123"))).toBe("delete");
expect(getLinearRouteForTest("workflow", createMessage("Search open Linear bugs"))).toBe(
"search_issues"
);
});
it("honors an explicit issue subaction and annotates delegated results", async () => {
const handler = vi.spyOn(createIssueAction, "handler").mockResolvedValue({
success: true,
text: "created",
data: { identifier: "ENG-1" },
});
const result = await linearIssueRouterAction.handler(
createRuntime(),
createMessage("Add a Linear task"),
undefined,
{ parameters: { subaction: "create" } }
);
expect(handler).toHaveBeenCalledTimes(1);
expect(result).toMatchObject({
success: true,
text: "created",
data: {
actionName: "LINEAR_ISSUE",
router: "LINEAR_ISSUE",
routedActionName: "CREATE_LINEAR_ISSUE",
op: "create",
subaction: "create",
result: { identifier: "ENG-1" },
identifier: "ENG-1",
},
});
handler.mockRestore();
});
it("honors an explicit issue action discriminator (canonical name)", async () => {
const handler = vi.spyOn(createIssueAction, "handler").mockResolvedValue({
success: true,
text: "created",
data: { identifier: "ENG-2" },
});
const result = await linearIssueRouterAction.handler(
createRuntime(),
createMessage("Add a Linear task"),
undefined,
{ parameters: { action: "create" } }
);
expect(handler).toHaveBeenCalledTimes(1);
expect(result).toMatchObject({
success: true,
data: {
actionName: "LINEAR_ISSUE",
op: "create",
subaction: "create",
},
});
handler.mockRestore();
});
it("normalizes explicit subactions and preserves callback action names", async () => {
const handler = vi.spyOn(createIssueAction, "handler").mockResolvedValue({
success: true,
text: "updated",
data: { identifier: "ENG-3" },
});
const callback = vi.fn();
const result = await linearIssueRouterAction.handler(
createRuntime(),
createMessage("Do the Linear thing"),
undefined,
{ parameters: { subaction: " create " } },
callback
);
expect(handler).toHaveBeenCalledTimes(1);
const routedCallback = handler.mock.calls[0]?.[4];
await routedCallback?.({ text: "child callback", source: "test" });
expect(callback).toHaveBeenCalledWith(
{ text: "child callback", source: "test" },
"CREATE_LINEAR_ISSUE"
);
expect(result).toMatchObject({
success: true,
data: {
op: "create",
subaction: "create",
result: { identifier: "ENG-3" },
},
});
handler.mockRestore();
});
it("annotates delegated comment failures with structured error data", async () => {
const handler = vi.spyOn(createCommentAction, "handler").mockResolvedValue({
success: false,
text: "Please provide the comment content.",
values: { error: "MISSING_COMMENT_BODY" },
});
const result = await linearCommentRouterAction.handler(
createRuntime(),
createMessage("Comment on ENG-1"),
undefined,
{ parameters: { subaction: "create" } }
);
expect(handler).toHaveBeenCalledTimes(1);
expect(result).toMatchObject({
success: false,
text: "Please provide the comment content.",
values: { error: "MISSING_COMMENT_BODY" },
data: {
actionName: "LINEAR_COMMENT",
router: "LINEAR_COMMENT",
routedActionName: "CREATE_LINEAR_COMMENT",
op: "create",
subaction: "create",
error: {
code: "MISSING_COMMENT_BODY",
message: "Please provide the comment content.",
},
},
});
handler.mockRestore();
});
it("returns structured workflow errors when no route matches", async () => {
const result = await linearWorkflowRouterAction.handler(
createRuntime(),
createMessage("Linear please do the thing"),
undefined,
{ parameters: { subaction: "unknown" } }
);
expect(result).toMatchObject({
success: false,
text: "LINEAR_WORKFLOW requires one of these subactions: clear_activity, get_activity, search_issues.",
values: { error: "MISSING_SUBACTION" },
data: {
actionName: "LINEAR_WORKFLOW",
router: "LINEAR_WORKFLOW",
routedActionName: null,
op: null,
subaction: null,
availableSubactions: "clear_activity, get_activity, search_issues",
error: {
code: "MISSING_SUBACTION",
message:
"LINEAR_WORKFLOW requires one of these subactions: clear_activity, get_activity, search_issues.",
},
},
});
});
it("validates router groups when Linear is configured", async () => {
const runtime = createRuntime();
await expect(
linearIssueRouterAction.validate(runtime, createMessage("Update issue ENG-2"))
).resolves.toBe(true);
await expect(
linearWorkflowRouterAction.validate(runtime, createMessage("Show Linear activity"))
).resolves.toBe(true);
});
it("denies router validation when Linear account config is absent", async () => {
const runtime = {
agentId: "agent-id",
character: {},
getSetting: vi.fn(() => undefined),
} as unknown as IAgentRuntime;
await expect(
linearIssueRouterAction.validate(runtime, createMessage("Update issue ENG-2"))
).resolves.toBe(false);
await expect(
linearCommentRouterAction.validate(runtime, createMessage("Comment on ENG-2"))
).resolves.toBe(false);
});
});
@@ -0,0 +1,537 @@
/**
* Grouped Linear router actions (LINEAR_ISSUE / LINEAR_COMMENT /
* LINEAR_WORKFLOW) and the typed result envelopes they emit. Each router selects
* a child sub-action by explicit op or regex match, dispatches it, and wraps the
* child's `data` in a discriminated `Linear*RouterResultData` envelope carrying
* the routed action name, resolved subaction, and success/error result.
* `getLinearRouteForTest` exposes route selection for tests.
*/
import type {
Action,
ActionResult,
HandlerCallback,
HandlerOptions,
IAgentRuntime,
Memory,
ProviderValue,
State,
} from "@elizaos/core";
import { hasLinearAccountConfig } from "../accounts";
import { linearAccountIdParameter } from "./account-options";
import { clearActivityAction } from "./clearActivity";
import { createCommentAction } from "./createComment";
import { createIssueAction } from "./createIssue";
import { deleteIssueAction } from "./deleteIssue";
import { getActivityAction } from "./getActivity";
import { getIssueAction } from "./getIssue";
import { getMessageSource } from "./message-source";
import { searchIssuesAction } from "./searchIssues";
import { updateIssueAction } from "./updateIssue";
export const LINEAR_ISSUE_CONTEXT = "linear_issue";
export const LINEAR_COMMENT_CONTEXT = "linear_comment";
export const LINEAR_WORKFLOW_CONTEXT = "linear_workflow";
/**
* Common envelope every Linear router result carries. `actionName` and
* `router` identify the public router action that produced the result,
* `routedActionName` names the child action that handled the request, and
* `op`/`subaction` are the resolved verb (or `null` when no route matched).
* `result` carries child action data on success and `error` carries structured
* failure details. The index signature is required so the result is assignable
* to `ProviderDataRecord`.
*/
interface LinearRouterEnvelope {
router: string;
routedActionName: string | null;
op: string | null;
subaction: string | null;
result?: ProviderValue;
error?: ProviderValue;
/** List of valid subactions, populated only on missing-subaction errors. */
availableSubactions?: string;
[key: string]: ProviderValue;
}
interface LinearIssueSummary {
id: string;
identifier: string;
title: string;
}
/**
* `LINEAR_ISSUE` router result. Fields below are the union of what
* createIssue/getIssue/updateIssue/deleteIssue place in `data`. Each is
* optional because only the routed child populates its subset.
*/
export interface LinearIssueRouterResultData extends LinearRouterEnvelope {
actionName: "LINEAR_ISSUE";
/** createIssue, updateIssue, deleteIssue (also prompt branches). */
issueId?: string;
/** createIssue, updateIssue, deleteIssue. */
identifier?: string;
/** createIssue, updateIssue. */
url?: string;
/** updateIssue: snapshot of fields that were changed. */
updates?: Record<string, ProviderValue>;
/** deleteIssue. */
title?: string;
archived?: boolean;
/** deleteIssue (pending confirmation) and any future awaiting branches. */
awaitingUserInput?: boolean;
/** deleteIssue (user declined). */
cancelled?: boolean;
/** getIssue (single hit): full serialized issue details. */
issue?: Record<string, ProviderValue>;
/** getIssue (multi-result clarify branch). */
multipleResults?: boolean;
/** getIssue clarify branch: shortlist for the user to disambiguate. */
issues?: LinearIssueSummary[];
}
/**
* `LINEAR_COMMENT` router result. Sourced from createComment.
*/
export interface LinearCommentRouterResultData extends LinearRouterEnvelope {
actionName: "LINEAR_COMMENT";
/** createComment success. */
commentId?: string;
issueId?: string;
issueIdentifier?: string;
commentBody?: string;
createdAt?: string;
/** createComment clarify branch when multiple issues match. */
multipleMatches?: boolean;
issues?: LinearIssueSummary[];
/** Comment text held while clarifying which issue to attach it to. */
pendingComment?: string;
}
interface LinearActivityItem {
id: string;
action: string;
resource_type: string;
resource_id: string;
success: boolean;
error?: string;
details: ProviderValue;
timestamp: string;
}
interface LinearSearchIssue {
id: string;
identifier: string;
title: string;
url: string;
priority: number | null;
state: { name: string; type: string } | null;
assignee: { name: string; email: string } | null;
team: { name: string; key: string } | null;
createdAt: string | Date;
updatedAt: string | Date;
}
/**
* `LINEAR_WORKFLOW` router result. Sourced from
* getActivity/clearActivity/searchIssues.
*/
export interface LinearWorkflowRouterResultData extends LinearRouterEnvelope {
actionName: "LINEAR_WORKFLOW";
/** getActivity. */
activity?: LinearActivityItem[];
/** getActivity, searchIssues. */
filters?: Record<string, ProviderValue>;
count?: number;
/** searchIssues. */
issues?: LinearSearchIssue[];
/** clearActivity (pending confirmation). */
awaitingUserInput?: boolean;
/** clearActivity (user declined). */
cancelled?: boolean;
}
type LinearRouterResultData =
| LinearIssueRouterResultData
| LinearCommentRouterResultData
| LinearWorkflowRouterResultData;
type RouterAction = Action & {
actionGroup?: {
contexts?: string[];
};
};
type LinearRoute = {
subaction: string;
action: Action;
match: RegExp;
};
const issueRoutes: LinearRoute[] = [
{
subaction: "delete",
action: deleteIssueAction,
match: /\b(delete|archive|remove|close)\b.*\b(issue|bug|task|ticket|[a-z]+-\d+)\b/i,
},
{
subaction: "update",
action: updateIssueAction,
match:
/\b(update|edit|modify|move|change|assign|reassign|priority|status|label)\b.*\b(issue|bug|task|ticket|[a-z]+-\d+)\b/i,
},
{
subaction: "create",
action: createIssueAction,
match:
/\b(create|new|add|file|open)\b.*\b(issue|bug|task|ticket|linear)\b|\b(issue|bug|task|ticket)\b.*\b(create|new|add|file|open)\b/i,
},
{
subaction: "get",
action: getIssueAction,
match:
/\b(show|get|view|check|details?|status|what'?s|find)\b.*\b(issue|bug|task|ticket|[a-z]+-\d+)\b|[a-z]+-\d+/i,
},
];
const commentRoutes: LinearRoute[] = [
{
subaction: "create",
action: createCommentAction,
match: /\b(comment|reply|note|tell)\b.*\b(issue|bug|task|ticket|[a-z]+-\d+)\b/i,
},
];
const workflowRoutes: LinearRoute[] = [
{
subaction: "clear_activity",
action: clearActivityAction,
match: /\b(clear|reset|delete)\b.*\b(activity|activity log)\b/i,
},
{
subaction: "get_activity",
action: getActivityAction,
match: /\b(activity|activity log|what happened|recent changes|audit)\b/i,
},
{
subaction: "search_issues",
action: searchIssuesAction,
match:
/\b(search|find|query|list|show)\b.*\b(issues?|bugs?|tasks?|tickets?)\b|\b(open|closed|unassigned|assigned|high priority|blockers?)\b.*\b(issues?|bugs?|tasks?|tickets?)\b/i,
},
];
function textOf(message: Memory): string {
return typeof message.content?.text === "string" ? message.content.text : "";
}
function readOptions(options?: HandlerOptions | Record<string, unknown>): Record<string, unknown> {
const direct = (options ?? {}) as Record<string, unknown>;
const parameters =
direct.parameters && typeof direct.parameters === "object"
? (direct.parameters as Record<string, unknown>)
: {};
return { ...direct, ...parameters };
}
function normalizeSubaction(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0
? value
.trim()
.toLowerCase()
.replace(/[\s-]+/g, "_")
: null;
}
function selectRoute(
routes: readonly LinearRoute[],
message: Memory,
options?: HandlerOptions | Record<string, unknown>
): LinearRoute | null {
const opts = readOptions(options);
const requested = normalizeSubaction(opts.action ?? opts.subaction);
if (requested) {
const route = routes.find((candidate) => candidate.subaction === requested);
if (route) return route;
}
const text = textOf(message);
return routes.find((route) => route.match.test(text)) ?? null;
}
function hasLinearAccess(runtime: IAgentRuntime): boolean {
return hasLinearAccountConfig(runtime);
}
function readErrorCode(result: ActionResult): string {
const valuesError = result.values?.error;
if (typeof valuesError === "string" && valuesError.length > 0) return valuesError;
const dataError =
typeof result.data === "object" && result.data !== null ? result.data.error : undefined;
if (typeof dataError === "string" && dataError.length > 0) return dataError;
return "ACTION_FAILED";
}
function readErrorMessage(result: ActionResult, fallbackText: string): string {
if (result.error instanceof Error) return result.error.message;
if (typeof result.error === "string" && result.error.length > 0) return result.error;
return fallbackText;
}
async function validateRouter(
runtime: IAgentRuntime,
_message: Memory,
_routes: readonly LinearRoute[],
_fallback: RegExp
): Promise<boolean> {
return hasLinearAccess(runtime);
}
async function dispatchRoute<T extends LinearRouterResultData>(
routerName: T["actionName"],
routes: readonly LinearRoute[],
runtime: IAgentRuntime,
message: Memory,
state: State | undefined,
options: HandlerOptions | Record<string, unknown> | undefined,
callback: HandlerCallback | undefined
): Promise<ActionResult> {
const route = selectRoute(routes, message, options);
if (!route) {
const subactions = routes.map((candidate) => candidate.subaction).join(", ");
const text = `${routerName} requires one of these subactions: ${subactions}.`;
await callback?.({ text, source: getMessageSource(message) });
const data: T = {
actionName: routerName,
router: routerName,
routedActionName: null,
op: null,
subaction: null,
error: {
code: "MISSING_SUBACTION",
message: text,
},
availableSubactions: subactions,
} as T;
return {
success: false,
text,
values: { error: "MISSING_SUBACTION" },
data,
};
}
const routedCallback: HandlerCallback | undefined = callback
? (response, actionName) => callback(response, actionName ?? route.action.name)
: undefined;
const result =
(await route.action.handler(
runtime,
message,
state,
options as HandlerOptions,
routedCallback
)) ??
({
success: true,
text: `${routerName} routed to ${route.action.name}.`,
data: {},
} as ActionResult);
const text =
typeof result.text === "string" && result.text.length > 0
? result.text
: `${routerName} routed to ${route.action.name}.`;
const success = result.success ?? true;
const childData = typeof result.data === "object" && result.data !== null ? result.data : {};
const data: T = {
...childData,
actionName: routerName,
router: routerName,
routedActionName: route.action.name,
op: route.subaction,
subaction: route.subaction,
...(success
? { result: Object.keys(childData).length > 0 ? childData : { ok: true } }
: {
result: Object.keys(childData).length > 0 ? childData : undefined,
error: {
code: readErrorCode(result),
message: readErrorMessage(result, text),
},
}),
} as T;
return {
...result,
success,
text,
data,
};
}
export function getLinearRouteForTest(
group: "issue" | "comment" | "workflow",
message: Memory,
options?: HandlerOptions | Record<string, unknown>
): string | null {
const routes =
group === "issue" ? issueRoutes : group === "comment" ? commentRoutes : workflowRoutes;
return selectRoute(routes, message, options)?.subaction ?? null;
}
/**
* Routes natural-language issue intents to CREATE/GET/UPDATE/DELETE Linear
* issue actions. `data` is `LinearIssueRouterResultData`: always carries
* `actionName: "LINEAR_ISSUE"`, `routedActionName`, and `subaction`. Result
* fields like `issueId`, `identifier`, and `url` are populated by the routed
* child; absent fields just mean that child didn't supply them.
*/
export const linearIssueRouterAction: RouterAction = {
name: "LINEAR_ISSUE",
description: "Route Linear issue ops: create, get, update, delete.",
descriptionCompressed: "route Linear issue create get update delete",
similes: [],
contexts: ["general", "automation", "knowledge", LINEAR_ISSUE_CONTEXT],
actionGroup: { contexts: [LINEAR_ISSUE_CONTEXT] },
roleGate: { minRole: "USER" },
validate: (runtime, message) =>
validateRouter(runtime, message, issueRoutes, /\b(linear|issue|bug|task|ticket|[a-z]+-\d+)\b/i),
handler: (runtime, message, state, options, callback) =>
dispatchRoute<LinearIssueRouterResultData>(
"LINEAR_ISSUE",
issueRoutes,
runtime,
message,
state,
options,
callback
),
parameters: [
{
name: "action",
description: "Issue operation to run.",
required: false,
schema: { type: "string", enum: ["create", "get", "update", "delete"] },
},
linearAccountIdParameter,
],
examples: [
[
{
name: "{{user1}}",
content: { text: "Create a Linear issue for the mobile login bug" },
},
{
name: "{{agentName}}",
content: {
text: "I'll create that Linear issue.",
actions: ["LINEAR_ISSUE"],
},
},
],
],
};
/**
* Routes Linear comment intents to the comment child actions. `data` is
* `LinearCommentRouterResultData` with the standard envelope plus
* `commentId`, `issueId`, `issueIdentifier`, `commentBody`, and `createdAt`
* on success, or a `multipleMatches`/`pendingComment` clarify branch.
*/
export const linearCommentRouterAction: RouterAction = {
name: "LINEAR_COMMENT",
description: "Route Linear issue comment ops.",
descriptionCompressed: "route Linear issue comment create reply note",
similes: [],
contexts: ["general", "automation", LINEAR_COMMENT_CONTEXT],
actionGroup: { contexts: [LINEAR_COMMENT_CONTEXT] },
roleGate: { minRole: "USER" },
validate: (runtime, message) =>
validateRouter(runtime, message, commentRoutes, /\b(comment|reply|note|tell)\b/i),
handler: (runtime, message, state, options, callback) =>
dispatchRoute<LinearCommentRouterResultData>(
"LINEAR_COMMENT",
commentRoutes,
runtime,
message,
state,
options,
callback
),
parameters: [
{
name: "action",
description: "Comment operation to run.",
required: false,
schema: { type: "string", enum: ["create"] },
},
linearAccountIdParameter,
],
examples: [
[
{
name: "{{user1}}",
content: { text: "Comment on ENG-123 that QA can retest it" },
},
{
name: "{{agentName}}",
content: {
text: "I'll add that comment to ENG-123.",
actions: ["LINEAR_COMMENT"],
},
},
],
],
};
/**
* Routes Linear workflow intents to getActivity/clearActivity/searchIssues.
* `data` is `LinearWorkflowRouterResultData`: standard envelope plus
* `activity`/`filters`/`count` (activity), `issues`/`filters`/`count`
* (search), or `awaitingUserInput`/`cancelled` (clearActivity).
*/
export const linearWorkflowRouterAction: RouterAction = {
name: "LINEAR_WORKFLOW",
description: "Route Linear workflow/activity/search ops.",
descriptionCompressed: "route Linear workflow activity search issue category",
similes: [],
contexts: ["general", "automation", "knowledge", LINEAR_WORKFLOW_CONTEXT],
actionGroup: { contexts: [LINEAR_WORKFLOW_CONTEXT] },
roleGate: { minRole: "USER" },
validate: (runtime, message) =>
validateRouter(runtime, message, workflowRoutes, /\b(linear|activity|search|issues?|bugs?)\b/i),
handler: (runtime, message, state, options, callback) =>
dispatchRoute<LinearWorkflowRouterResultData>(
"LINEAR_WORKFLOW",
workflowRoutes,
runtime,
message,
state,
options,
callback
),
parameters: [
{
name: "action",
description: "Workflow operation to run.",
required: false,
schema: { type: "string", enum: ["get_activity", "clear_activity", "search_issues"] },
},
linearAccountIdParameter,
],
examples: [
[
{
name: "{{user1}}",
content: { text: "Search open Linear bugs for the backend team" },
},
{
name: "{{agentName}}",
content: {
text: "I'll search Linear issues with those filters.",
actions: ["LINEAR_WORKFLOW"],
},
},
],
],
};
@@ -0,0 +1,335 @@
/**
* Handles the search_issues Linear op. Builds LinearSearchFilters from caller
* parameters or by extracting query/state/assignee/priority/team/label fields
* from the message via the searchIssues prompt (resolving "me" to the current
* user), scopes to the default team unless allTeams is set, and formats the
* matched issues into the reply.
*/
import {
type Action,
type ActionResult,
type HandlerCallback,
type HandlerOptions,
type IAgentRuntime,
logger,
type Memory,
ModelType,
type State,
} from "@elizaos/core";
import { searchIssuesTemplate } from "../prompts.js";
import type { LinearService } from "../services/linear";
import type { LinearSearchFilters, SearchIssuesParameters } from "../types/index.js";
import { getLinearAccountId, linearAccountIdParameter } from "./account-options";
import { formatUnknownError, getMessageSource } from "./message-source";
import {
getBooleanValue,
getNumberValue,
getStringArrayValue,
getStringValue,
parseLinearPromptResponse,
} from "./parseLinearPrompt.js";
import { validateLinearActionIntent } from "./validate-linear-intent";
const searchTemplate = searchIssuesTemplate;
export const searchIssuesAction: Action = {
name: "SEARCH_LINEAR_ISSUES",
contexts: ["tasks", "connectors", "knowledge"],
contextGate: { anyOf: ["tasks", "connectors", "knowledge"] },
roleGate: { minRole: "USER" },
description: "Search Linear issues with filters.",
descriptionCompressed: "search issue Linear w/ various filter",
similes: [
"search-linear-issues",
"find-linear-issues",
"query-linear-issues",
"list-linear-issues",
],
parameters: [
{
name: "filters",
description: "Linear issue filters: query, state, assignee, priority, team, label, limit.",
required: false,
schema: { type: "object" as const },
},
{
name: "limit",
description: "Max issues.",
required: false,
schema: { type: "number" as const },
},
linearAccountIdParameter,
],
examples: [
[
{
name: "User",
content: {
text: "Show me all open bugs",
},
},
{
name: "Assistant",
content: {
text: "I'll search for all open bug issues in Linear.",
actions: ["SEARCH_LINEAR_ISSUES"],
},
},
],
[
{
name: "User",
content: {
text: "What is John working on?",
},
},
{
name: "Assistant",
content: {
text: "I'll find the issues assigned to John.",
actions: ["SEARCH_LINEAR_ISSUES"],
},
},
],
[
{
name: "User",
content: {
text: "Show me high priority issues created this week",
},
},
{
name: "Assistant",
content: {
text: "I'll search for high priority issues created this week.",
actions: ["SEARCH_LINEAR_ISSUES"],
},
},
],
],
validate: async (runtime: IAgentRuntime, message: Memory, state?: State): Promise<boolean> =>
validateLinearActionIntent(runtime, message, state),
async handler(
runtime: IAgentRuntime,
message: Memory,
_state?: State,
_options?: HandlerOptions,
callback?: HandlerCallback
): Promise<ActionResult> {
try {
const linearService = runtime.getService<LinearService>("linear");
if (!linearService) {
throw new Error("Linear service not available");
}
const accountId = getLinearAccountId(runtime, _options);
const content = message.content.text;
if (!content) {
const errorMessage = "Please provide search criteria for issues.";
await callback?.({
text: errorMessage,
source: getMessageSource(message),
});
return {
text: errorMessage,
success: false,
};
}
let filters: LinearSearchFilters = {};
const params = _options?.parameters as SearchIssuesParameters | undefined;
if (params?.filters) {
filters = params.filters;
} else {
const prompt = searchTemplate.replace("{{userMessage}}", content);
const response = await runtime.useModel(ModelType.TEXT_LARGE, {
prompt: prompt,
});
if (!response) {
filters = { query: content };
} else {
try {
const parsed = parseLinearPromptResponse(response);
if (Object.keys(parsed).length === 0) {
throw new Error("No fields found in model response");
}
filters = {
query: getStringValue(parsed.query),
limit: getNumberValue(parsed.limit) || 10,
};
const states = getStringArrayValue(parsed.states);
if (states && states.length > 0) {
filters.state = states;
}
const assignees = getStringArrayValue(parsed.assignees);
if (assignees && assignees.length > 0) {
const processedAssignees: string[] = [];
for (const assignee of assignees) {
if (assignee.toLowerCase() === "me") {
try {
const currentUser = await linearService.getCurrentUser(accountId);
processedAssignees.push(currentUser.email);
} catch {
logger.warn('Could not resolve "me" to current user');
}
} else {
processedAssignees.push(assignee);
}
}
if (processedAssignees.length > 0) {
filters.assignee = processedAssignees;
}
}
if (getBooleanValue(parsed.hasAssignee) === false) {
filters.query = filters.query ? `${filters.query} unassigned` : "unassigned";
}
const parsedPriorities = getStringArrayValue(parsed.priorities);
if (parsedPriorities && parsedPriorities.length > 0) {
const priorityMap: Record<string, number> = {
urgent: 1,
high: 2,
normal: 3,
low: 4,
"1": 1,
"2": 2,
"3": 3,
"4": 4,
};
const priorities = parsedPriorities
.map((p: string) => priorityMap[p.toLowerCase()])
.filter(Boolean);
if (priorities.length > 0) {
filters.priority = priorities;
}
}
const teams = getStringArrayValue(parsed.teams);
if (teams && teams.length > 0) {
filters.team = teams[0];
}
const labels = getStringArrayValue(parsed.labels);
if (labels && labels.length > 0) {
filters.label = labels;
}
Object.keys(filters).forEach((key) => {
if (filters[key as keyof LinearSearchFilters] === undefined) {
delete filters[key as keyof LinearSearchFilters];
}
});
} catch (parseError) {
logger.error("Failed to parse search filters:", formatUnknownError(parseError));
filters = { query: content };
}
}
}
// Scope to the default team unless the caller explicitly asked for all
// teams via the structured `allTeams` filter the model sets (#10470).
if (!filters.team && filters.allTeams !== true) {
const defaultTeamKey =
linearService.getDefaultTeamKey(accountId) ??
(runtime.getSetting("LINEAR_DEFAULT_TEAM_KEY") as string);
if (defaultTeamKey) {
filters.team = defaultTeamKey;
logger.info(`Applying default team filter: ${defaultTeamKey}`);
}
}
filters.limit = params?.limit ?? filters.limit ?? 10;
const issues = await linearService.searchIssues(filters, accountId);
if (issues.length === 0) {
const noResultsMessage = "No issues found matching your search criteria.";
await callback?.({
text: noResultsMessage,
source: getMessageSource(message),
});
return {
text: noResultsMessage,
success: true,
data: {
issues: [],
filters: filters ? { ...filters } : undefined,
count: 0,
accountId,
},
};
}
const issueList = await Promise.all(
issues.map(async (issue, index) => {
const state = await issue.state;
const assignee = await issue.assignee;
const priorityLabels = ["", "Urgent", "High", "Normal", "Low"];
const priority = priorityLabels[issue.priority || 0] || "No priority";
return `${index + 1}. ${issue.identifier}: ${issue.title}\n Status: ${state?.name || "No state"} | Priority: ${priority} | Assignee: ${assignee?.name || "Unassigned"}`;
})
);
const issueText = issueList.join("\n\n");
const resultMessage = `📋 Found ${issues.length} issue${issues.length === 1 ? "" : "s"}:\n\n${issueText}`;
await callback?.({
text: resultMessage,
source: getMessageSource(message),
});
return {
text: `Found ${issues.length} issue${issues.length === 1 ? "" : "s"}`,
success: true,
data: {
issues: await Promise.all(
issues.map(async (issue) => {
const state = await issue.state;
const assignee = await issue.assignee;
const team = await issue.team;
return {
id: issue.id,
identifier: issue.identifier,
title: issue.title,
url: issue.url,
priority: issue.priority,
state: state ? { name: state.name, type: state.type } : null,
assignee: assignee ? { name: assignee.name, email: assignee.email } : null,
team: team ? { name: team.name, key: team.key } : null,
createdAt:
issue.createdAt instanceof Date ? issue.createdAt.toISOString() : issue.createdAt,
updatedAt:
issue.updatedAt instanceof Date ? issue.updatedAt.toISOString() : issue.updatedAt,
};
})
),
filters: filters ? { ...filters } : undefined,
count: issues.length,
accountId,
},
};
} catch (error) {
logger.error("Failed to search issues:", formatUnknownError(error));
const errorMessage = `❌ Failed to search issues: ${error instanceof Error ? error.message : "Unknown error"}`;
await callback?.({
text: errorMessage,
source: getMessageSource(message),
});
return {
text: errorMessage,
success: false,
};
}
},
};
@@ -0,0 +1,59 @@
/**
* Handles the update_comment Linear op: updates a comment body by comment id via
* LinearService.updateComment. `handleUpdateComment` is the router entry and
* requires both commentId and body in the action parameters.
*/
import {
type ActionResult,
type HandlerCallback,
type HandlerOptions,
type IAgentRuntime,
logger,
type Memory,
type State,
} from "@elizaos/core";
import type { LinearService } from "../services/linear";
import type { UpdateCommentParameters } from "../types/index.js";
import { getLinearAccountId } from "./account-options";
import { formatUnknownError, getMessageSource } from "./message-source";
export async function handleUpdateComment(
runtime: IAgentRuntime,
message: Memory,
_state?: State,
_options?: HandlerOptions,
callback?: HandlerCallback
): Promise<ActionResult> {
try {
const linearService = runtime.getService<LinearService>("linear");
if (!linearService) {
throw new Error("Linear service not available");
}
const accountId = getLinearAccountId(runtime, _options);
const params = _options?.parameters as UpdateCommentParameters | undefined;
const commentId = params?.commentId?.trim() ?? "";
const body = params?.body?.trim() ?? "";
if (!commentId || !body) {
const errorMessage = "Please provide both commentId and body to update a comment.";
await callback?.({ text: errorMessage, source: getMessageSource(message) });
return { text: errorMessage, success: false };
}
const comment = await linearService.updateComment(commentId, body, accountId);
const successMessage = `Updated comment ${commentId}.`;
await callback?.({ text: successMessage, source: getMessageSource(message) });
return {
text: successMessage,
success: true,
data: { commentId: comment.id, accountId },
};
} catch (error) {
logger.error("Failed to update comment:", formatUnknownError(error));
const errorMessage = `Failed to update comment: ${error instanceof Error ? error.message : "Unknown error"}`;
await callback?.({ text: errorMessage, source: getMessageSource(message) });
return { text: errorMessage, success: false };
}
}
@@ -0,0 +1,332 @@
/**
* Handles the update_issue Linear op. Extracts the issue id and a partial update
* (title, description, priority, team, assignee, status, labels) from the message
* via the updateIssue prompt, resolving team/assignee/state/label names to Linear
* ids, then applies it through LinearService.updateIssue; falls back to regex
* extraction of the issue id when model parsing fails. `handleUpdateIssue` is the
* router entry, `updateIssueAction` the standalone action.
*/
import {
type Action,
type ActionResult,
type HandlerCallback,
type HandlerOptions,
type IAgentRuntime,
logger,
type Memory,
ModelType,
type State,
} from "@elizaos/core";
import { updateIssueTemplate } from "../prompts.js";
import type { LinearService } from "../services/linear";
import type { LinearIssueInput } from "../types";
import { getLinearAccountId, linearAccountIdParameter } from "./account-options";
import { formatUnknownError, getMessageSource } from "./message-source";
import {
getPriorityNumberValue,
getRecordValue,
getStringArrayValue,
getStringValue,
parseLinearPromptResponse,
} from "./parseLinearPrompt.js";
import { validateLinearActionIntent } from "./validate-linear-intent";
const LINEAR_MODEL_TIMEOUT_MS = 15_000;
const LINEAR_LOOKUP_LIMIT = 100;
export async function handleUpdateIssue(
runtime: IAgentRuntime,
message: Memory,
_state?: State,
_options?: HandlerOptions,
callback?: HandlerCallback
): Promise<ActionResult> {
try {
const linearService = runtime.getService<LinearService>("linear");
if (!linearService) {
throw new Error("Linear service not available");
}
const accountId = getLinearAccountId(runtime, _options);
const content = message.content.text;
if (!content) {
const errorMessage = "Please provide update instructions for the issue.";
await callback?.({
text: errorMessage,
source: getMessageSource(message),
});
return {
text: errorMessage,
success: false,
};
}
const prompt = updateIssueTemplate.replace("{{userMessage}}", content);
const response = await Promise.race([
runtime.useModel(ModelType.TEXT_LARGE, {
prompt: prompt,
}),
new Promise<never>((_, reject) =>
setTimeout(
() => reject(new Error("Linear update extraction timeout")),
LINEAR_MODEL_TIMEOUT_MS
)
),
]);
if (!response) {
throw new Error("Failed to extract update information");
}
let issueId: string;
const updates: Partial<LinearIssueInput> = {};
try {
const parsed = parseLinearPromptResponse(response);
if (Object.keys(parsed).length === 0) {
throw new Error("No fields found in model response");
}
issueId = getStringValue(parsed.issueId) ?? "";
if (!issueId) {
throw new Error("Issue ID not found in parsed response");
}
const parsedUpdates = getRecordValue(parsed.updates) ?? {};
const title = getStringValue(parsedUpdates.title);
if (title) {
updates.title = title;
}
const description = getStringValue(parsedUpdates.description);
if (description) {
updates.description = description;
}
const priority = getPriorityNumberValue(parsedUpdates.priority);
if (priority) {
updates.priority = priority;
}
const teamKey = getStringValue(parsedUpdates.teamKey);
if (teamKey) {
const teams = await linearService.getTeams(accountId);
const team = teams
.slice(0, LINEAR_LOOKUP_LIMIT)
.find((t) => t.key.toLowerCase() === teamKey.toLowerCase());
if (team) {
updates.teamId = team.id;
logger.info(`Moving issue to team: ${team.name} (${team.key})`);
} else {
logger.warn(`Team with key ${teamKey} not found`);
}
}
const assignee = getStringValue(parsedUpdates.assignee);
if (assignee) {
const cleanAssignee = assignee.replace(/^@/, "");
const users = await linearService.getUsers(accountId);
const user = users
.slice(0, LINEAR_LOOKUP_LIMIT)
.find(
(u) =>
u.email === cleanAssignee ||
u.name.toLowerCase().includes(cleanAssignee.toLowerCase())
);
if (user) {
updates.assigneeId = user.id;
} else {
logger.warn(`User ${cleanAssignee} not found`);
}
}
const status = getStringValue(parsedUpdates.status);
if (status) {
const issue = await linearService.getIssue(issueId, accountId);
const issueTeam = await issue.team;
const teamId = updates.teamId || issueTeam?.id;
if (!teamId) {
logger.warn("Could not determine team for status update");
} else {
const states = await linearService.getWorkflowStates(teamId, accountId);
const state = states
.slice(0, LINEAR_LOOKUP_LIMIT)
.find(
(s) =>
s.name.toLowerCase() === status.toLowerCase() ||
s.type.toLowerCase() === status.toLowerCase()
);
if (state) {
updates.stateId = state.id;
logger.info(`Changing status to: ${state.name}`);
} else {
logger.warn(`Status ${status} not found for team`);
}
}
}
const parsedLabels = getStringArrayValue(parsedUpdates.labels);
if (parsedLabels !== undefined) {
const teamId = updates.teamId;
const labels = await linearService.getLabels(teamId, accountId);
const labelIds: string[] = [];
for (const labelName of parsedLabels.slice(0, LINEAR_LOOKUP_LIMIT)) {
const label = labels
.slice(0, LINEAR_LOOKUP_LIMIT)
.find((l) => l.name.toLowerCase() === labelName.toLowerCase());
if (label) {
labelIds.push(label.id);
}
}
updates.labelIds = labelIds;
}
} catch (parseError) {
logger.warn(
"Failed to parse LLM response, falling back to regex parsing:",
formatUnknownError(parseError)
);
const issueMatch = content.match(/(\w+-\d+)/);
if (!issueMatch) {
const errorMessage = "Please specify an issue ID (e.g., ENG-123) to update.";
await callback?.({
text: errorMessage,
source: getMessageSource(message),
});
return {
text: errorMessage,
success: false,
};
}
issueId = issueMatch[1];
const titleMatch = content.match(/title to ["'](.+?)["']/i);
if (titleMatch) {
updates.title = titleMatch[1];
}
const priorityMatch = content.match(/priority (?:to |as )?(\w+)/i);
if (priorityMatch) {
const priorityMap: Record<string, number> = {
urgent: 1,
high: 2,
normal: 3,
medium: 3,
low: 4,
};
const priority = priorityMap[priorityMatch[1].toLowerCase()];
if (priority) {
updates.priority = priority;
}
}
}
if (Object.keys(updates).length === 0) {
const errorMessage =
"No valid updates found. Please specify what to update (e.g., \"Update issue ENG-123 title to 'New Title'\")";
await callback?.({
text: errorMessage,
source: getMessageSource(message),
});
return {
text: errorMessage,
success: false,
};
}
const updatedIssue = await linearService.updateIssue(issueId, updates, accountId);
const updateSummary: string[] = [];
if (updates.title) updateSummary.push(`title: "${updates.title}"`);
if (updates.priority)
updateSummary.push(`priority: ${["", "urgent", "high", "normal", "low"][updates.priority]}`);
if (updates.teamId) updateSummary.push(`moved to team`);
if (updates.assigneeId) updateSummary.push(`assigned to user`);
if (updates.stateId) updateSummary.push(`status changed`);
if (updates.labelIds) updateSummary.push(`labels updated`);
const successMessage = `✅ Updated issue ${updatedIssue.identifier}: ${updateSummary.join(", ")}\n\nView it at: ${updatedIssue.url}`;
await callback?.({
text: successMessage,
source: getMessageSource(message),
});
return {
text: `Updated issue ${updatedIssue.identifier}: ${updateSummary.join(", ")}`,
success: true,
data: {
issueId: updatedIssue.id,
identifier: updatedIssue.identifier,
updates: updates
? Object.fromEntries(
Object.entries(updates).map(([key, value]) => [
key,
value instanceof Date ? value.toISOString() : value,
])
)
: undefined,
url: updatedIssue.url,
accountId,
},
};
} catch (error) {
logger.error("Failed to update issue:", formatUnknownError(error));
const errorMessage = `❌ Failed to update issue: ${error instanceof Error ? error.message : "Unknown error"}`;
await callback?.({
text: errorMessage,
source: getMessageSource(message),
});
return {
text: errorMessage,
success: false,
};
}
}
export const updateIssueAction: Action = {
name: "UPDATE_LINEAR_ISSUE",
contexts: ["tasks", "connectors", "automation"],
contextGate: { anyOf: ["tasks", "connectors", "automation"] },
roleGate: { minRole: "USER" },
description: "Update an existing Linear issue (title, priority, assignee, status, labels, team)",
descriptionCompressed: "update Linear issue",
parameters: [
{
name: "issueId",
description: "Linear issue id or identifier to update.",
required: false,
schema: { type: "string" },
},
linearAccountIdParameter,
],
similes: ["update-linear-issue", "edit-linear-issue", "modify-linear-issue"],
examples: [
[
{
name: "User",
content: {
text: "Update ENG-123 priority to high",
},
},
{
name: "Assistant",
content: {
text: "I'll update issue ENG-123 in Linear.",
actions: ["UPDATE_LINEAR_ISSUE"],
},
},
],
],
validate: async (runtime: IAgentRuntime, message: Memory, state?: State): Promise<boolean> =>
validateLinearActionIntent(runtime, message, state),
handler: handleUpdateIssue,
};
@@ -0,0 +1,18 @@
import type { IAgentRuntime, Memory, State } from "@elizaos/core";
import { hasLinearAccountConfig } from "../accounts";
/**
* Shared action validator: hard availability only. Intent/keyword routing is
* handled by action retrieval, while this confirms Linear is configured.
*/
export async function validateLinearActionIntent(
runtime: IAgentRuntime,
_message: Memory,
_state: State | undefined
): Promise<boolean> {
try {
return hasLinearAccountConfig(runtime);
} catch {
return false;
}
}
@@ -0,0 +1,315 @@
/**
* Linear ConnectorAccountManager provider.
*
* Bridges plugin-linear to the @elizaos/core ConnectorAccountManager so the
* generic HTTP CRUD + OAuth surface can list, create, patch, delete, and run
* the OAuth flow for Linear workspaces.
*
* Account model:
* - role "OWNER" — workspace admin (workspace API key or OAuth)
* - accountKey — workspace id (or workspace handle if id unavailable)
* - purpose — ["admin"]
*/
import {
type ConnectorAccount,
type ConnectorAccountManager,
type ConnectorAccountPatch,
type ConnectorAccountProvider,
type ConnectorAccountPurpose,
type ConnectorOAuthCallbackRequest,
type ConnectorOAuthCallbackResult,
type ConnectorOAuthStartRequest,
type ConnectorOAuthStartResult,
type IAgentRuntime,
logger,
readRequestedConnectorRole,
} from "@elizaos/core";
import { readLinearAccounts } from "./accounts";
export const LINEAR_PROVIDER_NAME = "linear";
const LINEAR_AUTHORIZATION_ENDPOINT = "https://linear.app/oauth/authorize";
const LINEAR_TOKEN_ENDPOINT = "https://api.linear.app/oauth/token";
const LINEAR_GRAPHQL_ENDPOINT = "https://api.linear.app/graphql";
const DEFAULT_PURPOSES: ConnectorAccountPurpose[] = ["admin" as ConnectorAccountPurpose];
interface LinearTokenResponse {
access_token?: string;
token_type?: string;
scope?: string;
refresh_token?: string;
expires_in?: number;
error?: string;
error_description?: string;
}
interface LinearViewerPayload {
data?: {
viewer?: {
id?: string;
name?: string;
email?: string;
organization?: {
id?: string;
name?: string;
urlKey?: string;
};
};
};
}
function nonEmptyString(value: unknown): string | undefined {
if (typeof value !== "string") return undefined;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
function readSetting(runtime: IAgentRuntime, key: string): string | undefined {
return nonEmptyString(runtime.getSetting?.(key));
}
function readClientConfig(runtime: IAgentRuntime): {
clientId: string;
clientSecret: string;
redirectUri: string;
} {
const clientId = readSetting(runtime, "LINEAR_OAUTH_CLIENT_ID");
const clientSecret = readSetting(runtime, "LINEAR_OAUTH_CLIENT_SECRET");
const redirectUri = readSetting(runtime, "LINEAR_OAUTH_REDIRECT_URI");
if (!clientId || !clientSecret || !redirectUri) {
throw new Error(
"Linear OAuth requires LINEAR_OAUTH_CLIENT_ID, LINEAR_OAUTH_CLIENT_SECRET, and LINEAR_OAUTH_REDIRECT_URI to be configured."
);
}
return { clientId, clientSecret, redirectUri };
}
function parseScopes(value: string | undefined): string[] {
if (!value) return [];
return value
.split(/[,\s]+/)
.map((scope) => scope.trim())
.filter(Boolean);
}
async function exchangeCodeForToken(args: {
clientId: string;
clientSecret: string;
redirectUri: string;
code: string;
}): Promise<LinearTokenResponse> {
const response = await fetch(LINEAR_TOKEN_ENDPOINT, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
},
body: new URLSearchParams({
client_id: args.clientId,
client_secret: args.clientSecret,
code: args.code,
redirect_uri: args.redirectUri,
grant_type: "authorization_code",
}).toString(),
});
if (!response.ok) {
const body = await response.text();
throw new Error(`Linear token exchange failed with ${response.status}: ${body}`);
}
const parsed = (await response.json()) as LinearTokenResponse;
if (parsed.error) {
throw new Error(
`Linear token exchange returned error ${parsed.error}: ${parsed.error_description ?? "no description"}`
);
}
if (!parsed.access_token) {
throw new Error("Linear token exchange returned no access_token.");
}
return parsed;
}
async function fetchLinearViewer(accessToken: string): Promise<LinearViewerPayload> {
const response = await fetch(LINEAR_GRAPHQL_ENDPOINT, {
method: "POST",
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
query: "{ viewer { id name email organization { id name urlKey } } }",
}),
});
if (!response.ok) {
throw new Error(`Linear viewer query failed with ${response.status}`);
}
return (await response.json()) as LinearViewerPayload;
}
function synthesizeEnvAccounts(runtime: IAgentRuntime): ConnectorAccount[] {
const now = Date.now();
return readLinearAccounts(runtime).map((account) => ({
id: account.accountId,
provider: LINEAR_PROVIDER_NAME,
label: account.label ?? `Linear (${account.accountId})`,
role: "OWNER" as const,
purpose: DEFAULT_PURPOSES,
accessGate: "open" as const,
status: "connected" as const,
externalId: account.workspaceId,
displayHandle: account.workspaceId ?? account.accountId,
createdAt: now,
updatedAt: now,
metadata: {
authMethod: "api_key",
source: "env",
defaultTeamKey: account.defaultTeamKey ?? null,
},
}));
}
/**
* Build the Linear ConnectorAccountManager provider.
*/
export function createLinearConnectorAccountProvider(
runtime: IAgentRuntime
): ConnectorAccountProvider {
return {
provider: LINEAR_PROVIDER_NAME,
label: "Linear",
listAccounts: async (manager: ConnectorAccountManager): Promise<ConnectorAccount[]> => {
const stored = await manager.getStorage().listAccounts(LINEAR_PROVIDER_NAME);
if (stored.length > 0) return stored;
return synthesizeEnvAccounts(runtime);
},
createAccount: async (input: ConnectorAccountPatch, _manager: ConnectorAccountManager) => {
return {
...input,
provider: LINEAR_PROVIDER_NAME,
role: input.role ?? "OWNER",
purpose: input.purpose ?? DEFAULT_PURPOSES,
accessGate: input.accessGate ?? "open",
status: input.status ?? "pending",
};
},
patchAccount: async (
_accountId: string,
patch: ConnectorAccountPatch,
_manager: ConnectorAccountManager
) => {
return { ...patch, provider: LINEAR_PROVIDER_NAME };
},
deleteAccount: async (_accountId: string, _manager: ConnectorAccountManager): Promise<void> => {
// Credential cleanup is the credential store's responsibility.
},
startOAuth: async (
request: ConnectorOAuthStartRequest,
_manager: ConnectorAccountManager
): Promise<ConnectorOAuthStartResult> => {
const config = readClientConfig(runtime);
const redirectUri = request.redirectUri ?? config.redirectUri;
const scopes =
request.scopes && request.scopes.length > 0
? request.scopes
: ["read", "write", "issues:create", "comments:create"];
const params = new URLSearchParams({
client_id: config.clientId,
redirect_uri: redirectUri,
response_type: "code",
scope: scopes.join(","),
state: request.flow.state,
prompt: "consent",
});
return {
authUrl: `${LINEAR_AUTHORIZATION_ENDPOINT}?${params.toString()}`,
metadata: {
...request.metadata,
requestedScopes: scopes,
redirectUri,
},
};
},
completeOAuth: async (
request: ConnectorOAuthCallbackRequest,
_manager: ConnectorAccountManager
): Promise<ConnectorOAuthCallbackResult> => {
const code = nonEmptyString(request.code);
if (!code) {
throw new Error("Linear OAuth callback is missing an authorization code.");
}
const config = readClientConfig(runtime);
const redirectUri = nonEmptyString(request.flow.redirectUri) ?? config.redirectUri;
const tokens = await exchangeCodeForToken({
clientId: config.clientId,
clientSecret: config.clientSecret,
redirectUri,
code,
});
if (!tokens.access_token) {
throw new Error("Linear token exchange returned no access_token.");
}
const viewerPayload = await fetchLinearViewer(tokens.access_token);
const viewer = viewerPayload.data?.viewer;
const organization = viewer?.organization;
const workspaceId = nonEmptyString(organization?.id);
const workspaceHandle = nonEmptyString(organization?.urlKey);
const externalId = workspaceId ?? workspaceHandle;
if (!externalId) {
throw new Error("Linear viewer payload did not include an organization id or urlKey.");
}
const flowMetadata = (request.flow.metadata as Record<string, unknown> | undefined) ?? {};
const role = readRequestedConnectorRole(flowMetadata, "plugin:linear:connector");
const accountPatch: ConnectorAccountPatch & { provider: string } = {
provider: LINEAR_PROVIDER_NAME,
role,
purpose: DEFAULT_PURPOSES,
accessGate: "open",
status: "connected",
externalId,
displayHandle: workspaceHandle ?? externalId,
label: nonEmptyString(organization?.name) ?? nonEmptyString(workspaceHandle) ?? "Linear",
metadata: {
authMethod: "oauth",
workspaceId: workspaceId ?? null,
workspaceHandle: workspaceHandle ?? null,
workspaceName: nonEmptyString(organization?.name) ?? null,
viewerId: nonEmptyString(viewer?.id) ?? null,
viewerEmail: nonEmptyString(viewer?.email) ?? null,
viewerName: nonEmptyString(viewer?.name) ?? null,
tokenType: nonEmptyString(tokens.token_type) ?? "bearer",
grantedScopes: parseScopes(tokens.scope),
hasRefreshToken: Boolean(tokens.refresh_token),
},
};
logger.info(
{
src: "plugin:linear:connector",
workspaceId: workspaceId ?? null,
workspaceHandle: workspaceHandle ?? null,
},
"Linear OAuth completed"
);
return {
account: accountPatch,
flow: { status: "completed" },
};
},
};
}
+52
View File
@@ -0,0 +1,52 @@
/**
* Plugin entry for @elizaos/plugin-linear. Registers the LinearService
* singleton, the promoted LINEAR router sub-actions, and the four Linear context
* providers; on init it registers the linear_issues search category and the
* ConnectorAccountManager provider that drives OAuth/API-key account lifecycle.
*/
import type { IAgentRuntime, Plugin } from "@elizaos/core";
import { getConnectorAccountManager, logger, promoteSubactionsToActions } from "@elizaos/core";
import { linearAction } from "./actions/linear";
import { createLinearConnectorAccountProvider } from "./connector-account-provider";
import { linearActivityProvider } from "./providers/activity";
import { linearIssuesProvider } from "./providers/issues";
import { linearProjectsProvider } from "./providers/projects";
import { linearTeamsProvider } from "./providers/teams";
import { registerLinearSearchCategory } from "./search-category";
import { LinearService } from "./services/linear";
export const linearPlugin: Plugin = {
name: "@elizaos/plugin-linear-ts",
description: "Plugin for integrating with Linear issue tracking system",
services: [LinearService],
actions: [...promoteSubactionsToActions(linearAction)],
providers: [
linearIssuesProvider,
linearTeamsProvider,
linearProjectsProvider,
linearActivityProvider,
],
async dispose(runtime: IAgentRuntime) {
const svc = runtime.getService<LinearService>(LinearService.serviceType);
await svc?.stop();
},
init: async (_config: Record<string, string>, runtime: IAgentRuntime) => {
registerLinearSearchCategory(runtime);
try {
const manager = getConnectorAccountManager(runtime);
manager.registerProvider(createLinearConnectorAccountProvider(runtime));
} catch (err) {
logger.warn(
{
src: "plugin:linear",
err: err instanceof Error ? err.message : String(err),
},
"Failed to register Linear provider with ConnectorAccountManager"
);
}
},
};
export * from "./accounts";
export { createLinearConnectorAccountProvider } from "./connector-account-provider";
export { LinearService } from "./services/linear";
+247
View File
@@ -0,0 +1,247 @@
/**
* Prompt templates for Linear plugin actions.
*
* Handlebars-style template syntax:
* - {{variableName}} for simple substitution
* - {{#each items}}...{{/each}} for iteration
* - {{#if condition}}...{{/if}} for conditionals
*/
export const createCommentTemplate = `Extract comment details from the user's request to add a comment to a Linear issue.
User request: "{{userMessage}}"
The user might express this in various ways:
- "Comment on ENG-123: This looks good"
- "Tell ENG-123 that the fix is ready for testing"
- "Add a note to the login bug saying we need more info"
- "Reply to COM2-7: Thanks for the update"
- "Let the payment issue know that it's blocked by API changes"
Respond with JSON only. Use this shape:
{
"issueId": "Direct issue ID if explicitly mentioned, for example ENG-123",
"issueDescription": "Description or keywords of the issue if no ID was provided",
"commentBody": "The actual comment content to add",
"commentType": "note|reply|update|question|feedback"
}
Extract the core message the user wants to convey as the comment body.
Omit unknown fields. Output only the JSON object, with no prose before or after it.`;
export const CREATE_COMMENT_TEMPLATE = createCommentTemplate;
export const createIssueTemplate = `Given the user's request, extract the information needed to create a Linear issue.
User request: "{{userMessage}}"
Respond with JSON only. Use this shape:
{
"title": "Brief, clear issue title",
"description": "Detailed description of the issue",
"teamKey": "Team key if mentioned, such as ENG or PROD",
"priority": 3,
"labels": ["label"],
"assignee": "Assignee username or email if mentioned"
}
Omit optional fields when they are not provided. Output only the JSON object, with no prose before or after it.`;
export const CREATE_ISSUE_TEMPLATE = createIssueTemplate;
export const deleteIssueTemplate = `Given the user's request to delete/archive a Linear issue, extract the issue identifier.
User request: "{{userMessage}}"
Respond with JSON only:
{
"issueId": "The issue identifier, such as ENG-123 or COM2-7"
}
Output only the JSON object, with no prose before or after it.`;
export const DELETE_ISSUE_TEMPLATE = deleteIssueTemplate;
export const getActivityTemplate = `Extract activity filter criteria from the user's request.
User request: "{{userMessage}}"
The user might ask for activity in various ways:
- "Show me today's activity" → time range filter
- "What issues were created?" → action type filter
- "What did John do yesterday?" → user filter + time range
- "Activity on ENG-123" → resource filter
- "Recent comment activity" → action type + recency
- "Failed operations this week" → success filter + time range
Respond with JSON only. Use this shape:
{
"timeRange": {
"period": "today|yesterday|this-week|last-week|this-month",
"from": "ISO datetime if a specific start is mentioned",
"to": "ISO datetime if a specific end is mentioned"
},
"actionTypes": ["create_issue"],
"resourceTypes": ["issue"],
"resourceId": "Specific resource ID if mentioned, such as ENG-123",
"user": "User name, or me for current user",
"successFilter": "success|failed|all",
"limit": 10
}
Only include fields that are clearly mentioned. Output only the JSON object, with no prose before or after it.`;
export const GET_ACTIVITY_TEMPLATE = getActivityTemplate;
export const getIssueTemplate = `Extract issue identification from the user's request.
User request: "{{userMessage}}"
The user might reference an issue by:
- Direct ID (e.g., "ENG-123", "COM2-7")
- Title keywords (e.g., "the login bug", "that payment issue")
- Assignee (e.g., "John's high priority task")
- Recency (e.g., "the latest bug", "most recent issue")
- Team context (e.g., "newest issue in ELIZA team")
Respond with JSON only. Use directId when an issue ID is explicitly mentioned:
{
"directId": "Issue ID such as ENG-123"
}
When no issue ID is provided, use searchBy fields:
{
"searchBy": {
"title": "Keywords from issue title if mentioned",
"assignee": "Name or email of assignee if mentioned",
"priority": "urgent|high|normal|low|1|2|3|4",
"team": "Team name or key if mentioned",
"state": "Issue state if mentioned, such as to-do, in-progress, or done",
"recency": "latest|newest|recent|last",
"type": "bug|feature|task"
}
}
Only include fields that are clearly mentioned or implied. Output only the JSON object, with no prose before or after it.`;
export const GET_ISSUE_TEMPLATE = getIssueTemplate;
export const listProjectsTemplate = `Extract project filter criteria from the user's request.
User request: "{{userMessage}}"
The user might ask for projects in various ways:
- "Show me all projects" → list all projects
- "Active projects" → filter by state (active/planned/completed)
- "Projects due this quarter" → filter by target date
- "Which projects is Sarah managing?" → filter by lead/owner
- "Projects with high priority issues" → filter by contained issue priority
- "Projects for the engineering team" → filter by team
- "Completed projects" → filter by state
- "Projects starting next month" → filter by start date
Respond with JSON only. Use this shape:
{
"teamFilter": "Team name or key if mentioned",
"stateFilter": "active|planned|completed|all",
"dateFilter": {
"type": "due|starting",
"period": "this-week|this-month|this-quarter|next-month|next-quarter",
"from": "ISO date if a specific start is mentioned",
"to": "ISO date if a specific end is mentioned"
},
"leadFilter": "Project lead name if mentioned",
"showAll": true
}
Only include fields that are clearly mentioned. Output only the JSON object, with no prose before or after it.`;
export const LIST_PROJECTS_TEMPLATE = listProjectsTemplate;
export const listTeamsTemplate = `Extract team filter criteria from the user's request.
User request: "{{userMessage}}"
The user might ask for teams in various ways:
- "Show me all teams" → list all teams
- "Engineering teams" → filter by teams with engineering in name/description
- "List teams I'm part of" → filter by membership
- "Which teams work on the mobile app?" → filter by description/focus
- "Show me the ELIZA team details" → specific team lookup
- "Active teams" → teams with recent activity
- "Frontend and backend teams" → multiple team types
Respond with JSON only. Use this shape:
{
"nameFilter": "Keywords to search in team names",
"specificTeam": "Specific team name or key if looking for one team",
"myTeams": true,
"showAll": true,
"includeDetails": true
}
Only include fields that are clearly mentioned. Output only the JSON object, with no prose before or after it.`;
export const LIST_TEAMS_TEMPLATE = listTeamsTemplate;
export const searchIssuesTemplate = `Extract search criteria from the user's request for Linear issues.
User request: "{{userMessage}}"
The user might express searches in various ways:
- "Show me what John is working on" → assignee filter
- "Any blockers for the next release?" → priority/label filters
- "Issues created this week" → date range filter
- "My high priority bugs" → assignee (current user) + priority + label
- "Unassigned tasks in the backend team" → no assignee + team filter
- "What did Sarah close yesterday?" → assignee + state + date
- "Bugs that are almost done" → label + state filter
- "Show me the oldest open issues" → state + sort order
Respond with JSON only. Use this shape:
{
"query": "General search text for title or description",
"states": ["In Progress"],
"assignees": ["me"],
"priorities": ["high"],
"teams": ["ENG"],
"labels": ["bug"],
"hasAssignee": true,
"dateRange": {
"field": "created|updated|completed",
"period": "today|yesterday|this-week|last-week|this-month|last-month",
"from": "ISO date if a specific start is mentioned",
"to": "ISO date if a specific end is mentioned"
},
"sort": {
"field": "created|updated|priority",
"order": "asc|desc"
},
"limit": 10
}
Only include fields that are clearly mentioned or implied. For "my" issues, set assignees to ["me"]. Output only the JSON object, with no prose before or after it.`;
export const SEARCH_ISSUES_TEMPLATE = searchIssuesTemplate;
export const updateIssueTemplate = `Given the user's request to update a Linear issue, extract the information needed.
User request: "{{userMessage}}"
Respond with JSON only. Use this shape:
{
"issueId": "Issue identifier such as ENG-123 or COM2-7",
"updates": {
"title": "New title if changing the title",
"description": "New description if changing the description",
"priority": 3,
"teamKey": "New team key if moving to another team, such as ENG, ELIZA, or COM2",
"assignee": "New assignee username or email if changing",
"status": "to-do|in-progress|done|canceled",
"labels": ["label"]
}
}
Only include fields that are being updated. Use an empty labels array to clear all labels. Output only the JSON object, with no prose before or after it.`;
export const UPDATE_ISSUE_TEMPLATE = updateIssueTemplate;
@@ -0,0 +1,89 @@
/**
* LINEAR_ACTIVITY context provider: injects the last 10 entries of
* LinearService's in-memory activity log into the prompt. Gated to the
* automation/connectors contexts and ADMIN role, cached per turn.
*/
import type { IAgentRuntime, Memory, Provider, State } from "@elizaos/core";
import type { LinearService } from "../services/linear";
import type { LinearActivityItem } from "../types";
function formatDetails(details: unknown): string {
if (details === null || details === undefined) {
return "none";
}
if (Array.isArray(details)) {
return details.map(formatDetails).join(", ");
}
if (typeof details !== "object") {
return String(details);
}
return Object.entries(details as Record<string, unknown>)
.map(([key, value]) => `${key}: ${formatDetails(value)}`)
.join("; ");
}
export const linearActivityProvider: Provider = {
name: "LINEAR_ACTIVITY",
description: "Provides context about recent Linear activity",
descriptionCompressed: "provide context recent Linear activity",
dynamic: true,
contexts: ["automation", "connectors"],
contextGate: { anyOf: ["automation", "connectors"] },
cacheScope: "turn",
roleGate: { minRole: "ADMIN" },
get: async (runtime: IAgentRuntime, _message: Memory, _state: State) => {
try {
const linearService = runtime.getService<LinearService>("linear");
if (!linearService) {
return {
text: "Linear service is not available",
};
}
const activity = linearService.getActivityLog(10);
if (activity.length === 0) {
return {
text: "No recent Linear activity",
};
}
const activityList = activity.map((item: LinearActivityItem) => {
const status = item.success ? "✓" : "✗";
const time = new Date(item.timestamp).toLocaleTimeString();
return `${status} ${time}: ${item.action} ${item.resource_type} ${item.resource_id}`;
});
const text = `Recent Linear Activity:\n${activityList.join("\n")}`;
return {
text,
data: {
activity: activity.slice(0, 10).map((item) => ({
id: item.id,
action: item.action,
resource_type: item.resource_type,
resource_id: item.resource_id,
success: item.success,
error: item.error,
details: formatDetails(item.details),
timestamp:
typeof item.timestamp === "string"
? item.timestamp
: new Date(item.timestamp).toISOString(),
})) as Array<Record<string, string | boolean | undefined>>,
},
};
} catch (error) {
// error-policy:J4 explicit user-facing degrade — a failure reading the
// in-memory activity log renders the distinguishable "error" prompt state
// (never a fabricated "no recent activity"), and reportError makes the
// underlying failure observable in RECENT_ERRORS + owner-escalation instead
// of being silently swallowed.
runtime.reportError?.("LINEAR_ACTIVITY.provider", error);
return {
text: "Error retrieving Linear activity",
};
}
},
};
@@ -0,0 +1,5 @@
/** Re-exports the four Linear context providers (activity, issues, projects, teams). */
export * from "./activity";
export * from "./issues";
export * from "./projects";
export * from "./teams";
@@ -0,0 +1,68 @@
/**
* LINEAR_ISSUES context provider: injects up to 10 recent issues (identifier,
* title, state, assignee) from LinearService into the prompt. Gated to the
* automation/connectors contexts and ADMIN role, cached per turn.
*/
import type { IAgentRuntime, Memory, Provider, State } from "@elizaos/core";
import type { Issue } from "@linear/sdk";
import type { LinearService } from "../services/linear";
export const linearIssuesProvider: Provider = {
name: "LINEAR_ISSUES",
description: "Provides context about recent Linear issues",
descriptionCompressed: "provide context recent Linear issue",
dynamic: true,
contexts: ["automation", "connectors"],
contextGate: { anyOf: ["automation", "connectors"] },
cacheScope: "turn",
roleGate: { minRole: "ADMIN" },
get: async (runtime: IAgentRuntime, _message: Memory, _state: State) => {
try {
const linearService = runtime.getService<LinearService>("linear");
if (!linearService) {
return {
text: "Linear service is not available",
};
}
const issues = await linearService.searchIssues({ limit: 10 });
if (issues.length === 0) {
return {
text: "No recent Linear issues found",
};
}
const issuesList = await Promise.all(
issues.map(async (issue: Issue) => {
const [assignee, state] = await Promise.all([issue.assignee, issue.state]);
return `- ${issue.identifier}: ${issue.title} (${state?.name || "Unknown"}, ${assignee?.name || "Unassigned"})`;
})
);
const text = `Recent Linear Issues:\n${issuesList.join("\n")}`;
return {
text,
data: {
issues: issues.map((issue: Issue) => ({
id: issue.id,
identifier: issue.identifier,
title: issue.title,
})),
},
};
} catch (error) {
// error-policy:J4 explicit user-facing degrade — a Linear API/auth/network
// failure renders the distinguishable "error" prompt state (never a
// fabricated "no issues found"), and reportError makes the underlying
// failure observable in RECENT_ERRORS + owner-escalation instead of being
// silently swallowed.
runtime.reportError?.("LINEAR_ISSUES.provider", error);
return {
text: "Error retrieving Linear issues",
};
}
},
};
@@ -0,0 +1,71 @@
/**
* LINEAR_PROJECTS context provider: injects up to 10 active (started/planned)
* Linear projects from LinearService into the prompt. Gated to the
* automation/connectors contexts and ADMIN role, cached per agent.
*/
import type { IAgentRuntime, Memory, Provider, State } from "@elizaos/core";
import type { Project } from "@linear/sdk";
import type { LinearService } from "../services/linear";
export const linearProjectsProvider: Provider = {
name: "LINEAR_PROJECTS",
description: "Provides context about active Linear projects",
descriptionCompressed: "provide context active Linear project",
dynamic: true,
contexts: ["automation", "connectors"],
contextGate: { anyOf: ["automation", "connectors"] },
cacheScope: "agent",
roleGate: { minRole: "ADMIN" },
get: async (runtime: IAgentRuntime, _message: Memory, _state: State) => {
try {
const linearService = runtime.getService<LinearService>("linear");
if (!linearService) {
return {
text: "Linear service is not available",
};
}
const projects = await linearService.getProjects();
if (projects.length === 0) {
return {
text: "No Linear projects found",
};
}
const activeProjects = projects.filter(
(project: Project) => project.state === "started" || project.state === "planned"
);
const projectsList = activeProjects
.slice(0, 10)
.map(
(project: Project) =>
`- ${project.name}: ${project.state} (${project.startDate || "No start date"} - ${project.targetDate || "No target date"})`
);
const text = `Active Linear Projects:\n${projectsList.join("\n")}`;
return {
text,
data: {
projects: activeProjects.slice(0, 10).map((project: Project) => ({
id: project.id,
name: project.name,
state: project.state,
})),
},
};
} catch (error) {
// error-policy:J4 explicit user-facing degrade — a Linear API/auth/network
// failure renders the distinguishable "error" prompt state (never a
// fabricated "no projects found"), and reportError makes the underlying
// failure observable in RECENT_ERRORS + owner-escalation instead of being
// silently swallowed.
runtime.reportError?.("LINEAR_PROJECTS.provider", error);
return {
text: "Error retrieving Linear projects",
};
}
},
};
@@ -0,0 +1,115 @@
/**
* Failure-path tests for the four Linear context providers.
*
* Guards #12744 (#12275-G fallback-slop sweep): a Linear API/auth/network
* failure inside a provider `get()` must (a) render the designed J4
* user-facing "Error retrieving …" degrade state — never a fabricated
* "no X found" success shape — and (b) surface the underlying error through
* `runtime.reportError` so the failure is observable in RECENT_ERRORS /
* owner-escalation instead of being silently swallowed.
*/
import type { IAgentRuntime, Memory, State } from "@elizaos/core";
import { describe, expect, it, vi } from "vitest";
import { linearActivityProvider } from "./activity";
import { linearIssuesProvider } from "./issues";
import { linearProjectsProvider } from "./projects";
import { linearTeamsProvider } from "./teams";
const message = { id: "m", content: { text: "" } } as unknown as Memory;
const state = {} as State;
function runtimeWith(
service: Record<string, unknown> | undefined,
reportError = vi.fn()
): IAgentRuntime {
return {
getService: vi.fn((name: string) => (name === "linear" ? service : undefined)),
reportError,
} as unknown as IAgentRuntime;
}
const cases = [
{
label: "LINEAR_ISSUES",
provider: linearIssuesProvider,
method: "searchIssues",
scope: "LINEAR_ISSUES.provider",
degradeText: "Error retrieving Linear issues",
emptyText: "No recent Linear issues found",
successService: () => ({ searchIssues: vi.fn(async () => []) }),
},
{
label: "LINEAR_TEAMS",
provider: linearTeamsProvider,
method: "getTeams",
scope: "LINEAR_TEAMS.provider",
degradeText: "Error retrieving Linear teams",
emptyText: "No Linear teams found",
successService: () => ({ getTeams: vi.fn(async () => []) }),
},
{
label: "LINEAR_PROJECTS",
provider: linearProjectsProvider,
method: "getProjects",
scope: "LINEAR_PROJECTS.provider",
degradeText: "Error retrieving Linear projects",
emptyText: "No Linear projects found",
successService: () => ({ getProjects: vi.fn(async () => []) }),
},
{
label: "LINEAR_ACTIVITY",
provider: linearActivityProvider,
method: "getActivityLog",
scope: "LINEAR_ACTIVITY.provider",
degradeText: "Error retrieving Linear activity",
emptyText: "No recent Linear activity",
successService: () => ({ getActivityLog: vi.fn(() => []) }),
},
] as const;
describe("Linear providers — failure-path observability", () => {
for (const c of cases) {
describe(c.label, () => {
it("reports the error and renders the J4 degrade state on service failure", async () => {
const boom = new Error("Linear API 503");
const reportError = vi.fn();
const service = {
[c.method]: vi.fn(() => {
throw boom;
}),
};
const runtime = runtimeWith(service, reportError);
const result = await c.provider.get(runtime, message, state);
// J4: distinguishable error state, NOT a fabricated "no X found".
expect(result.text).toBe(c.degradeText);
expect(result.text).not.toBe(c.emptyText);
// Observability: the real error is surfaced with the provider scope.
expect(reportError).toHaveBeenCalledTimes(1);
expect(reportError).toHaveBeenCalledWith(c.scope, boom);
});
it("does not call reportError on the empty (designed-absence) path", async () => {
const reportError = vi.fn();
const runtime = runtimeWith(c.successService(), reportError);
const result = await c.provider.get(runtime, message, state);
expect(result.text).toBe(c.emptyText);
expect(reportError).not.toHaveBeenCalled();
});
it("returns the service-unavailable state without reporting when the service is missing", async () => {
const reportError = vi.fn();
const runtime = runtimeWith(undefined, reportError);
const result = await c.provider.get(runtime, message, state);
expect(result.text).toBe("Linear service is not available");
expect(reportError).not.toHaveBeenCalled();
});
});
}
});
@@ -0,0 +1,70 @@
/**
* LINEAR_TEAMS context provider: injects up to 20 Linear teams (key, name,
* truncated description) from LinearService into the prompt. Gated to the
* automation/connectors contexts and ADMIN role, cached per agent.
*/
import type { IAgentRuntime, Memory, Provider, State } from "@elizaos/core";
import type { Team } from "@linear/sdk";
import type { LinearService } from "../services/linear";
const MAX_LINEAR_TEAMS = 20;
const MAX_DESCRIPTION_CHARS = 180;
export const linearTeamsProvider: Provider = {
name: "LINEAR_TEAMS",
description: "Provides context about Linear teams",
descriptionCompressed: "provide context Linear team",
dynamic: true,
contexts: ["automation", "connectors"],
contextGate: { anyOf: ["automation", "connectors"] },
cacheScope: "agent",
roleGate: { minRole: "ADMIN" },
get: async (runtime: IAgentRuntime, _message: Memory, _state: State) => {
try {
const linearService = runtime.getService<LinearService>("linear");
if (!linearService) {
return {
text: "Linear service is not available",
};
}
const teams = await linearService.getTeams();
const listedTeams = teams.slice(0, MAX_LINEAR_TEAMS);
if (teams.length === 0) {
return {
text: "No Linear teams found",
};
}
const teamsList = listedTeams.map(
(team: Team) =>
`- ${team.name} (${team.key}): ${(team.description || "No description").slice(0, MAX_DESCRIPTION_CHARS)}`
);
const text = `Linear Teams:\n${teamsList.join("\n")}`;
return {
text,
data: {
teams: listedTeams.map((team: Team) => ({
id: team.id,
name: team.name,
key: team.key,
})),
truncated: teams.length > listedTeams.length,
},
};
} catch (error) {
// error-policy:J4 explicit user-facing degrade — a Linear API/auth/network
// failure renders the distinguishable "error" prompt state (never a
// fabricated "no teams found"), and reportError makes the underlying
// failure observable in RECENT_ERRORS + owner-escalation instead of being
// silently swallowed.
runtime.reportError?.("LINEAR_TEAMS.provider", error);
return {
text: "Error retrieving Linear teams",
};
}
},
};
@@ -0,0 +1,56 @@
/**
* Unit tests for the linear_issues search category: asserts the registration
* metadata and that registerLinearSearchCategory is idempotent. Deterministic,
* mocked runtime, no live API.
*/
import type { IAgentRuntime, SearchCategoryRegistration } from "@elizaos/core";
import { describe, expect, it, vi } from "vitest";
import { LINEAR_ISSUES_SEARCH_CATEGORY, registerLinearSearchCategory } from "./search-category";
function createRuntime() {
const categories = new Map<string, SearchCategoryRegistration>();
const registerSearchCategory = vi.fn((registration: SearchCategoryRegistration) => {
categories.set(registration.category, registration);
});
const getSearchCategory = vi.fn((category: string) => {
const registration = categories.get(category);
if (!registration) throw new Error(`Missing category ${category}`);
return registration;
});
return {
categories,
registerSearchCategory,
runtime: { getSearchCategory, registerSearchCategory } as IAgentRuntime,
};
}
describe("Linear search category", () => {
it("registers Linear issue search metadata once", () => {
const { categories, registerSearchCategory, runtime } = createRuntime();
registerLinearSearchCategory(runtime);
registerLinearSearchCategory(runtime);
expect(registerSearchCategory).toHaveBeenCalledTimes(1);
expect(categories.get("linear_issues")).toMatchObject({
category: "linear_issues",
serviceType: "linear",
source: "plugin:linear",
});
});
it("does not overwrite an existing category registration", () => {
const { categories, registerSearchCategory, runtime } = createRuntime();
const existing = {
...LINEAR_ISSUES_SEARCH_CATEGORY,
label: "Existing Linear issues",
};
categories.set("linear_issues", existing);
registerLinearSearchCategory(runtime);
expect(registerSearchCategory).not.toHaveBeenCalled();
expect(categories.get("linear_issues")).toBe(existing);
});
});
@@ -0,0 +1,87 @@
/**
* Defines the `linear_issues` search category — its filter schema, result-shape
* summary, and capabilities — and registers it idempotently with the runtime's
* search registry. The plugin entry calls registerLinearSearchCategory on init
* so agents can query Linear issues through the generic search surface.
*/
import type { IAgentRuntime, SearchCategoryRegistration } from "@elizaos/core";
export const LINEAR_ISSUES_SEARCH_CATEGORY: SearchCategoryRegistration = {
category: "linear_issues",
label: "Linear issues",
description: "Search Linear issues by text and issue metadata filters.",
contexts: ["automation", "system"],
filters: [
{ name: "query", label: "Query", type: "string", required: true },
{
name: "state",
label: "States",
description: "Issue workflow state names.",
type: "string[]",
},
{
name: "assignee",
label: "Assignees",
description: "Assignee names or emails.",
type: "string[]",
},
{
name: "label",
label: "Labels",
description: "Linear issue label names.",
type: "string[]",
},
{
name: "project",
label: "Project",
description: "Linear project name or identifier.",
type: "string",
},
{
name: "team",
label: "Team",
description: "Linear team key, name, or identifier.",
type: "string",
},
{
name: "priority",
label: "Priorities",
description: "Linear priorities: 1 urgent, 2 high, 3 normal, 4 low.",
type: "number[]",
},
{
name: "limit",
label: "Limit",
description: "Maximum issues to return.",
type: "number",
default: 10,
},
{
name: "accountId",
label: "Account",
description:
"Optional Linear account id. Defaults to LINEAR_DEFAULT_ACCOUNT_ID or the legacy single API key.",
type: "string",
},
],
resultSchemaSummary:
"LinearIssue[] with id, identifier, title, description, state, assignee, labels, priority, team, project, url, and updatedAt.",
capabilities: ["issues", "filters", "workflow", "team"],
source: "plugin:linear",
serviceType: "linear",
};
function hasSearchCategory(runtime: IAgentRuntime, category: string): boolean {
try {
runtime.getSearchCategory(category, { includeDisabled: true });
return true;
} catch {
return false;
}
}
export function registerLinearSearchCategory(runtime: IAgentRuntime): void {
if (!hasSearchCategory(runtime, LINEAR_ISSUES_SEARCH_CATEGORY.category)) {
runtime.registerSearchCategory(LINEAR_ISSUES_SEARCH_CATEGORY);
}
}
@@ -0,0 +1,626 @@
/**
* The plugin's singleton wrapper around the @linear/sdk LinearClient. Holds a
* per-account client map keyed by account id (accounts resolved from runtime
* settings and character config via ../accounts) and exposes typed CRUD methods
* for issues, comments, projects, teams, labels, and users that the LINEAR
* sub-action handlers and context providers call.
*
* It also maintains an in-memory activity log (capped at 1000 entries) that the
* LINEAR_ACTIVITY provider and the get/clear_activity ops read. Constructing
* without a resolvable default account throws LinearAuthenticationError, which
* is how the plugin declines to enable when no API key is configured.
*/
import { type IAgentRuntime, logger, Service } from "@elizaos/core";
import {
type Comment,
type Issue,
type IssueLabel,
LinearClient,
type Project,
type Team,
type User,
type WorkflowState,
} from "@linear/sdk";
import {
DEFAULT_LINEAR_ACCOUNT_ID,
type LinearAccountConfig,
normalizeLinearAccountId,
readLinearAccounts,
resolveLinearDefaultAccount,
} from "../accounts";
import type {
ActivityDetailObject,
ActivityDetailValue,
LinearActivityItem,
LinearCommentInput,
LinearIssueInput,
LinearSearchFilters,
} from "../types";
import { LinearAuthenticationError } from "../types";
interface LinearClientState {
accountId: string;
config: LinearAccountConfig;
client: LinearClient;
}
export class LinearService extends Service {
static serviceType = "linear";
capabilityDescription =
"Linear API integration for issue tracking, project management, and team collaboration";
private clients = new Map<string, LinearClientState>();
private activityLog: LinearActivityItem[] = [];
private defaultAccountId = DEFAULT_LINEAR_ACCOUNT_ID;
public workspaceId?: string;
constructor(runtime?: IAgentRuntime) {
super(runtime);
const accounts = runtime ? readLinearAccounts(runtime) : [];
const requestedDefault = runtime
? normalizeLinearAccountId(
runtime.getSetting("LINEAR_DEFAULT_ACCOUNT_ID") ?? runtime.getSetting("LINEAR_ACCOUNT_ID")
)
: DEFAULT_LINEAR_ACCOUNT_ID;
const defaultAccount = resolveLinearDefaultAccount(accounts, requestedDefault);
if (!defaultAccount) {
throw new LinearAuthenticationError("Linear API key is required");
}
this.defaultAccountId = defaultAccount.accountId;
this.workspaceId = defaultAccount.workspaceId;
for (const account of accounts) {
this.clients.set(account.accountId, {
accountId: account.accountId,
config: account,
client: new LinearClient({ apiKey: account.apiKey }),
});
}
}
static async start(runtime: IAgentRuntime): Promise<LinearService> {
const service = new LinearService(runtime);
await service.validateConnection();
logger.info("Linear service started successfully");
return service;
}
async stop(): Promise<void> {
this.activityLog = [];
logger.info("Linear service stopped");
}
private async validateConnection(accountId?: string): Promise<void> {
try {
const state = this.getAccountState(accountId);
const viewer = await state.client.viewer;
logger.info(`Linear connected as user: ${viewer.email} (accountId=${state.accountId})`);
} catch (_error) {
throw new LinearAuthenticationError("Failed to authenticate with Linear API");
}
}
hasAccount(accountId?: string): boolean {
return Boolean(this.getAccountState(accountId, false));
}
getDefaultTeamKey(accountId?: string): string | undefined {
return this.getAccountState(accountId).config.defaultTeamKey;
}
private getAccountState(accountId?: string): LinearClientState;
private getAccountState(
accountId: string | undefined,
throwOnMissing: false
): LinearClientState | null;
private getAccountState(accountId?: string, throwOnMissing = true): LinearClientState | null {
const normalized = normalizeLinearAccountId(accountId);
const state = accountId
? (this.clients.get(normalized) ?? null)
: (this.clients.get(this.defaultAccountId) ?? Array.from(this.clients.values())[0] ?? null);
if (!state && throwOnMissing) {
throw new LinearAuthenticationError("Linear API key is required");
}
return state;
}
private getClient(accountId?: string): LinearClient {
return this.getAccountState(accountId)?.client as LinearClient;
}
private logActivity(
action: string,
resourceType: LinearActivityItem["resource_type"],
resourceId: string,
details: Record<string, ActivityDetailValue>,
success: boolean,
error?: string
): void {
const activity: LinearActivityItem = {
id: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
timestamp: new Date().toISOString(),
action,
resource_type: resourceType,
resource_id: resourceId,
details,
success,
error,
};
this.activityLog.push(activity);
if (this.activityLog.length > 1000) {
this.activityLog = this.activityLog.slice(-1000);
}
}
getActivityLog(
limit?: number,
filter?: Partial<LinearActivityItem>,
accountId?: string
): LinearActivityItem[] {
let filtered = [...this.activityLog];
if (filter) {
filtered = filtered.filter((item) => {
return Object.entries(filter).every(([key, value]) => {
return item[key as keyof LinearActivityItem] === value;
});
});
}
if (accountId) {
filtered = filtered.filter((item) => item.details.accountId === accountId);
}
return filtered.slice(-(limit || 100));
}
clearActivityLog(accountId?: string): void {
if (accountId) {
this.activityLog = this.activityLog.filter((item) => item.details.accountId !== accountId);
logger.info(`Linear activity log cleared for accountId=${accountId}`);
return;
}
this.activityLog = [];
logger.info("Linear activity log cleared");
}
async getTeams(accountId?: string): Promise<Team[]> {
const state = this.getAccountState(accountId);
const teams = await state.client.teams();
const teamList = await teams.nodes;
this.logActivity(
"list_teams",
"team",
"all",
{ count: teamList.length, accountId: state.accountId },
true
);
return teamList;
}
async getTeam(teamId: string, accountId?: string): Promise<Team> {
const state = this.getAccountState(accountId);
const team = await state.client.team(teamId);
this.logActivity(
"get_team",
"team",
teamId,
{ name: team.name, accountId: state.accountId },
true
);
return team;
}
async createIssue(input: LinearIssueInput, accountId?: string): Promise<Issue> {
const state = this.getAccountState(accountId);
const issuePayload = await state.client.createIssue({
title: input.title,
description: input.description,
teamId: input.teamId,
priority: input.priority,
assigneeId: input.assigneeId,
labelIds: input.labelIds,
projectId: input.projectId,
stateId: input.stateId,
estimate: input.estimate,
dueDate: input.dueDate,
});
const issue = await issuePayload.issue;
if (!issue) {
throw new Error("Failed to create issue");
}
this.logActivity(
"create_issue",
"issue",
issue.id,
{
title: input.title,
teamId: input.teamId,
accountId: state.accountId,
},
true
);
return issue;
}
async getIssue(issueId: string, accountId?: string): Promise<Issue> {
const state = this.getAccountState(accountId);
const issue = await state.client.issue(issueId);
this.logActivity(
"get_issue",
"issue",
issueId,
{
title: issue.title,
identifier: issue.identifier,
accountId: state.accountId,
},
true
);
return issue;
}
async updateIssue(
issueId: string,
updates: Partial<LinearIssueInput>,
accountId?: string
): Promise<Issue> {
const state = this.getAccountState(accountId);
const updatePayload = await state.client.updateIssue(issueId, {
title: updates.title,
description: updates.description,
priority: updates.priority,
assigneeId: updates.assigneeId,
labelIds: updates.labelIds,
projectId: updates.projectId,
stateId: updates.stateId,
estimate: updates.estimate,
dueDate: updates.dueDate,
});
const issue = await updatePayload.issue;
if (!issue) {
throw new Error("Failed to update issue");
}
this.logActivity(
"update_issue",
"issue",
issueId,
{ ...updates, accountId: state.accountId },
true
);
return issue;
}
async deleteIssue(issueId: string, accountId?: string): Promise<void> {
const state = this.getAccountState(accountId);
const archivePayload = await state.client.archiveIssue(issueId);
const success = await archivePayload.success;
if (!success) {
throw new Error("Failed to archive issue");
}
this.logActivity(
"delete_issue",
"issue",
issueId,
{ action: "archived", accountId: state.accountId },
true
);
}
async searchIssues(filters: LinearSearchFilters, accountId?: string): Promise<Issue[]> {
const state = this.getAccountState(accountId);
const filterObject: Record<string, string | number | boolean | object | null | undefined> = {};
if (filters.query) {
filterObject.or = [
{ title: { containsIgnoreCase: filters.query } },
{ description: { containsIgnoreCase: filters.query } },
];
}
if (filters.team) {
const teams = await this.getTeams(state.accountId);
const team = teams.find(
(t) =>
t.key.toLowerCase() === filters.team?.toLowerCase() ||
t.name.toLowerCase() === filters.team?.toLowerCase()
);
if (team) {
filterObject.team = { id: { eq: team.id } };
}
}
if (filters.assignee && filters.assignee.length > 0) {
const users = await this.getUsers(state.accountId);
const assigneeIds = filters.assignee
.map((assigneeName) => {
const user = users.find(
(u) =>
u.email === assigneeName || u.name.toLowerCase().includes(assigneeName.toLowerCase())
);
return user?.id;
})
.filter(Boolean);
if (assigneeIds.length > 0) {
filterObject.assignee = { id: { in: assigneeIds } };
}
}
if (filters.priority && filters.priority.length > 0) {
filterObject.priority = { number: { in: filters.priority } };
}
if (filters.state && filters.state.length > 0) {
filterObject.state = {
name: { in: filters.state },
};
}
if (filters.label && filters.label.length > 0) {
filterObject.labels = {
some: {
name: { in: filters.label },
},
};
}
const query = state.client.issues({
first: filters.limit || 50,
filter: Object.keys(filterObject).length > 0 ? filterObject : undefined,
});
const issues = await query;
const issueList = await issues.nodes;
this.logActivity(
"search_issues",
"issue",
"search",
{
filters: { ...filters, accountId: state.accountId } as ActivityDetailObject,
count: issueList.length,
},
true
);
return issueList;
}
async createComment(input: LinearCommentInput, accountId?: string): Promise<Comment> {
const state = this.getAccountState(accountId);
const commentPayload = await state.client.createComment({
body: input.body,
issueId: input.issueId,
});
const comment = await commentPayload.comment;
if (!comment) {
throw new Error("Failed to create comment");
}
this.logActivity(
"create_comment",
"comment",
comment.id,
{
issueId: input.issueId,
bodyLength: input.body.length,
accountId: state.accountId,
},
true
);
return comment;
}
async updateComment(commentId: string, body: string, accountId?: string): Promise<Comment> {
const state = this.getAccountState(accountId);
const commentPayload = await state.client.updateComment(commentId, {
body,
});
const comment = await commentPayload.comment;
if (!comment) {
throw new Error("Failed to update comment");
}
this.logActivity(
"update_comment",
"comment",
commentId,
{ bodyLength: body.length, accountId: state.accountId },
true
);
return comment;
}
async deleteComment(commentId: string, accountId?: string): Promise<void> {
const state = this.getAccountState(accountId);
const payload = await state.client.deleteComment(commentId);
if (!payload.success) {
throw new Error("Failed to delete comment");
}
this.logActivity("delete_comment", "comment", commentId, { accountId: state.accountId }, true);
}
async listComments(issueId: string, limit = 25, accountId?: string): Promise<Comment[]> {
const issue = await this.getClient(accountId).issue(issueId);
const connection = await issue.comments({ first: Math.min(limit, 100) });
return connection.nodes;
}
async getProjects(teamId?: string, accountId?: string): Promise<Project[]> {
const state = this.getAccountState(accountId);
// Linear SDK v51 requires manual team filtering on projects
const query = state.client.projects({
first: 100,
});
const projects = await query;
let projectList = await projects.nodes;
if (teamId) {
const filteredProjects = await Promise.all(
projectList.map(async (project) => {
const projectTeams = await project.teams();
const teamsList = await projectTeams.nodes;
const hasTeam = teamsList.some((team: Team) => team.id === teamId);
return hasTeam ? project : null;
})
);
projectList = filteredProjects.filter(Boolean) as Project[];
}
this.logActivity(
"list_projects",
"project",
"all",
{
count: projectList.length,
...(teamId ? { teamId } : {}),
accountId: state.accountId,
},
true
);
return projectList;
}
async getProject(projectId: string, accountId?: string): Promise<Project> {
const state = this.getAccountState(accountId);
const project = await state.client.project(projectId);
this.logActivity(
"get_project",
"project",
projectId,
{
name: project.name,
accountId: state.accountId,
},
true
);
return project;
}
async getUsers(accountId?: string): Promise<User[]> {
const state = this.getAccountState(accountId);
const users = await state.client.users();
const userList = await users.nodes;
this.logActivity(
"list_users",
"user",
"all",
{
count: userList.length,
accountId: state.accountId,
},
true
);
return userList;
}
async getCurrentUser(accountId?: string): Promise<User> {
const state = this.getAccountState(accountId);
const user = await state.client.viewer;
this.logActivity(
"get_current_user",
"user",
user.id,
{
email: user.email,
name: user.name,
accountId: state.accountId,
},
true
);
return user;
}
async getUserTeams(accountId?: string): Promise<Team[]> {
const state = this.getAccountState(accountId);
const viewer = await state.client.viewer;
const teams = await viewer.teams();
const teamList = await teams.nodes;
this.logActivity(
"list_user_teams",
"team",
viewer.id,
{
count: teamList.length,
accountId: state.accountId,
},
true
);
return teamList;
}
async getLabels(teamId?: string, accountId?: string): Promise<IssueLabel[]> {
const state = this.getAccountState(accountId);
const query = state.client.issueLabels({
first: 100,
filter: teamId
? {
team: { id: { eq: teamId } },
}
: undefined,
});
const labels = await query;
const labelList = await labels.nodes;
this.logActivity(
"list_labels",
"label",
"all",
{
count: labelList.length,
...(teamId ? { teamId } : {}),
accountId: state.accountId,
},
true
);
return labelList;
}
async getWorkflowStates(teamId: string, accountId?: string): Promise<WorkflowState[]> {
const state = this.getAccountState(accountId);
const states = await state.client.workflowStates({
filter: {
team: { id: { eq: teamId } },
},
});
const stateList = await states.nodes;
this.logActivity(
"list_workflow_states",
"team",
teamId,
{
count: stateList.length,
accountId: state.accountId,
},
true
);
return stateList;
}
}
+134
View File
@@ -0,0 +1,134 @@
/**
* Shared types for the Linear plugin: the config/env shape, the activity-log
* item and its detail-value union, per-action parameter shapes, issue/comment/
* search inputs, and the Linear API error classes.
*/
export interface LinearConfig {
LINEAR_API_KEY: string;
LINEAR_WORKSPACE_ID?: string;
LINEAR_ACCOUNT_ID?: string;
LINEAR_DEFAULT_ACCOUNT_ID?: string;
LINEAR_ACCOUNTS?: string;
}
/** Primitive types allowed in activity details */
export type ActivityDetailPrimitive = string | number | boolean | null;
/** Array types allowed in activity details */
export type ActivityDetailArray = ActivityDetailPrimitive[];
/** Nested object allowed in activity details (one level deep) */
export type ActivityDetailObject = Record<string, ActivityDetailPrimitive | ActivityDetailArray>;
/** Valid values for activity detail fields */
export type ActivityDetailValue =
| ActivityDetailPrimitive
| ActivityDetailArray
| ActivityDetailObject
| Date;
export interface LinearActivityItem {
id: string;
timestamp: string;
action: string;
resource_type: "issue" | "project" | "comment" | "label" | "user" | "team";
resource_id: string;
details: Record<string, ActivityDetailValue>;
success: boolean;
error?: string;
}
export interface LinearIssueInput {
title: string;
description?: string;
teamId: string;
priority?: number; // 0 = No priority, 1 = Urgent, 2 = High, 3 = Normal, 4 = Low
assigneeId?: string;
labelIds?: string[];
projectId?: string;
stateId?: string;
estimate?: number;
dueDate?: Date;
}
export interface LinearCommentInput {
body: string;
issueId: string;
}
export interface LinearSearchFilters {
state?: string[];
assignee?: string[];
label?: string[];
project?: string;
team?: string;
/** Search across all teams instead of scoping to the default team (#10470). */
allTeams?: boolean;
priority?: number[];
query?: string;
limit?: number;
}
export interface CreateCommentParameters {
issueId?: string;
body?: string;
}
export interface UpdateCommentParameters {
commentId?: string;
body?: string;
}
export interface DeleteCommentParameters {
commentId?: string;
}
export interface ListCommentsParameters {
issueId?: string;
limit?: number;
}
export interface CreateIssueParameters {
issueData?: Partial<LinearIssueInput>;
}
export interface DeleteIssueParameters {
issueId?: string;
}
export interface SearchIssuesParameters {
filters?: LinearSearchFilters;
limit?: number;
}
export interface LinearErrorResponse {
message?: string;
errors?: Array<{ message: string; path?: string[] }>;
}
export class LinearAPIError extends Error {
constructor(
message: string,
public status?: number,
public response?: LinearErrorResponse
) {
super(message);
this.name = "LinearAPIError";
}
}
export class LinearAuthenticationError extends LinearAPIError {
constructor(message: string) {
super(message, 401);
this.name = "LinearAuthenticationError";
}
}
export class LinearRateLimitError extends LinearAPIError {
constructor(
message: string,
public resetTime: number
) {
super(message, 429);
this.name = "LinearRateLimitError";
}
}