feat: implement SCM and tracker plugins (github, linear) (#4)

* feat: implement SCM and tracker plugins (github, linear)

Implement three plugin interfaces from packages/core/src/types.ts:

- scm-github: Full GitHub PR lifecycle via `gh` CLI — PR detection by
  branch, state tracking, CI checks, reviews, review decision, pending
  comments, automated bot comment detection (cursor[bot], codecov, etc.),
  and merge readiness (CI + reviews + conflicts + draft).

- tracker-github: GitHub Issues tracker via `gh` CLI — issue CRUD,
  completion check, branch naming (feat/issue-N), prompt generation,
  list/filter/update/create with label and assignee support.

- tracker-linear: Linear issue tracker via GraphQL API (LINEAR_API_KEY) —
  issue fetch, completion check, branch naming (feat/IDENTIFIER), prompt
  generation, list with team/state/label filters, state transitions via
  workflow state resolution, issue creation with teamId config.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: address PR review — GraphQL injection, N+1 queries, timeouts, tests

- tracker-linear: use GraphQL variables in listIssues to prevent injection
- tracker-linear: add 30s HTTP timeout to linearQuery
- tracker-linear: fix issueUrl to use workspace slug from project config
- tracker-linear: add labels/assignee support to createIssue
- scm-github: rewrite getPendingComments to use GraphQL reviewThreads
  with real isResolved status instead of hardcoding false
- scm-github: simplify getAutomatedComments to single API call (N+1 fix)
- scm-github: add 30s timeout to gh CLI calls
- scm-github: fix parseDate to return epoch instead of fabricating dates
- scm-github: add repo format validation in detectPR
- scm-github: fix getCISummary to not count all-skipped as passing
- tracker-github: add 30s timeout to gh CLI calls
- tracker-github: fix createIssue to not use unsupported --json flag
- Add comprehensive vitest tests for scm-github and tracker-github

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: address Codex review — assignee lookup, GraphQL vars, status checks

Iteration 1 fixes (8 issues from Codex review):
- tracker-linear: remove invalid assigneeDisplayName, resolve assignee
  by display name to ID via users query after creation
- tracker-linear: add HTTP status code check in linearQuery
- tracker-linear: throw on missing workflow state in updateIssue
- scm-github: use GraphQL variables ($owner, $name, $number) in
  getPendingComments instead of string interpolation
- scm-github: guard against empty comment nodes in review threads
- scm-github: use mergeStateStatus in getMergeability (BEHIND, BLOCKED)
- tracker-github: default description to "" in createIssue

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: address PR review + lint — error context, GraphQL vars, mergeability

- scm-github/tracker-github: wrap gh() errors with command context + cause
- scm-github: use GraphQL variables ($owner, $name, $number) in
  getPendingComments to prevent injection
- scm-github: guard against empty review thread nodes
- scm-github: incorporate mergeStateStatus (BEHIND/BLOCKED) in getMergeability
- tracker-linear: add identifier vs UUID comment in updateIssue
- Fix ESLint preserve-caught-error violations
- Remove unused mockGhRaw in scm-github tests

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: add pagination to getAutomatedComments and increase thread limit

- getAutomatedComments: add --paginate flag to REST API call to fetch
  all review comments beyond the default 30-item first page
- getPendingComments: increase GraphQL reviewThreads limit from 100 to
  250 (GitHub's max for connections)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: use per_page=100 instead of --paginate for JSON-safe pagination

Replace --paginate with -F per_page=100 in getAutomatedComments to
avoid concatenated JSON arrays that break JSON.parse on multi-page
responses. 100 is GitHub's max per_page value.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: filter out resolved threads in getPendingComments

The method name implies it should only return unresolved/pending review
threads. The isResolved data was already fetched via GraphQL but was not
being filtered on, causing resolved threads to be included in results.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* test: add comprehensive tracker-linear test suite (53 tests)

Covers all Tracker interface methods: getIssue, isCompleted, issueUrl,
branchName, generatePrompt, listIssues, updateIssue, createIssue.
Mocks node:https request to simulate Linear GraphQL API responses.
Tests state mapping, error handling, assignee/label resolution, and
GraphQL variable injection.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: address 6 bugbot review comments on PR #4

- GraphQL reviewThreads(first: 250) → first: 100 (GitHub max)
- noConflicts false when mergeable is UNKNOWN (not just CONFLICTING)
- updateIssue now handles labels and assignee (not just state/comment)
- Add res.on("error") handler in linearQuery to prevent crashes
- createIssue returns only actually-applied labels, not all requested
- Tests added/updated for all fixes (144 total across 3 plugins)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: make Linear updateIssue labels additive to match GitHub behavior

Linear's issueUpdate replaces all labels, while GitHub's --add-label is
additive. Now fetches existing label IDs first and merges with new ones
before sending to issueUpdate, ensuring consistent behavior across both
tracker implementations.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: default listIssues to open state in tracker-linear

When no state filter is specified, tracker-github defaults to "open"
but tracker-linear was returning all issues. Now defaults to excluding
completed/canceled issues, matching tracker-github behavior.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add Composio SDK support to tracker-linear

Allow users with a COMPOSIO_API_KEY to use the Linear tracker without
a separate LINEAR_API_KEY. The plugin auto-detects which key is available
and routes through either the direct Linear GraphQL API or Composio's
LINEAR_RUN_QUERY_OR_MUTATION tool. All existing queries and response
parsing are reused via a GraphQLTransport abstraction.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: address 2 bugbot review comments on PR #4

1. getCIChecks now throws on error instead of silently returning [],
   preventing a fail-open where CI appears healthy when we can't fetch
   check status. getCISummary catches the error and returns "failing".

2. Handle GitHub's UNSTABLE mergeStateStatus (required checks failing)
   as a merge blocker in getMergeability.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: prevent unhandled promise rejection in Composio timeout race

When the timeout wins Promise.race in the Composio transport, the
resultPromise is left without a rejection handler. If the SDK call
later rejects, it becomes an unhandled promise rejection that crashes
Node.js 20+ with --unhandled-rejections=throw.

Attach no-op .catch() to both resultPromise and timeoutPromise before
the race so whichever promise loses has its rejection silently handled.

Also adds plugin integration tests (core -> real plugins -> mocked gh CLI).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: chain error cause in getCIChecks and fix core build

- getCIChecks catch now preserves the original error via { cause: err }
  instead of discarding it, matching the pattern used by the gh() helper
- Add tsconfig.build.json to core that excludes __tests__/ from build
  compilation, preventing TS5055 errors from circular workspace deps
  (integration tests import plugin packages that depend back on core)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: remove circular devDeps, address bugbot comments

- Remove plugin devDependencies from core/package.json that created a
  circular dependency (core -> plugins -> core), breaking CI build order
- Document in_progress state as intentional no-op in tracker-github
  updateIssue (GitHub Issues only supports open/closed)
- Remove @composio/core optional peerDependency from tracker-linear;
  the dynamic import() already handles the missing package gracefully,
  and the peer dep was pulling composio + transitive deps into lockfile

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add real integration test for tracker-linear against Linear API

Creates a test that exercises the full tracker-linear plugin lifecycle
against the real Linear API — createIssue, getIssue, isCompleted,
listIssues, updateIssue (comment, close, reopen), generatePrompt,
branchName, issueUrl. Each run creates a throwaway test issue and
trashes it in cleanup. Skipped when LINEAR_API_KEY is not set.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* ci: pass LINEAR_API_KEY and LINEAR_TEAM_ID to integration tests

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: support both LINEAR_API_KEY and COMPOSIO_API_KEY in integration test

The tracker-linear integration test now runs with either credential:
- LINEAR_API_KEY: direct Linear API (full cleanup via trash)
- COMPOSIO_API_KEY: via Composio SDK (cleanup falls back to closing)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: don't pass COMPOSIO_API_KEY to CI integration tests

When both LINEAR_API_KEY and COMPOSIO_API_KEY are set, the plugin
prefers the Composio transport which requires @composio/core SDK.
The SDK isn't installed in CI, so use the direct LINEAR_API_KEY
transport which has no extra dependencies.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: use import.meta.url in vitest config, default createIssue description

- Replace __dirname with import.meta.url-derived dirname in ESM config
- Default createIssue description to "" for defensive safety

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: map CANCELLED and ACTION_REQUIRED CI conclusions to failed

Terminal check conclusions (CANCELLED, ACTION_REQUIRED) were falling
through to "pending", causing the lifecycle manager to wait forever.
Also changed the default else branch to "failed" (fail-closed).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
prateek 2026-02-14 15:45:51 +05:30 committed by GitHub
parent 925a7aae92
commit 8707faf11c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 4732 additions and 9 deletions

View File

@ -55,4 +55,10 @@ jobs:
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
LINEAR_API_KEY: ${{ secrets.LINEAR_API_KEY }}
LINEAR_TEAM_ID: ${{ secrets.LINEAR_TEAM_ID }}
# Note: COMPOSIO_API_KEY is intentionally not passed here.
# When both keys are set, the plugin prefers the Composio transport
# which requires @composio/core SDK installed. The direct LINEAR_API_KEY
# transport needs no extra dependencies.
run: pnpm test:integration

View File

@ -16,7 +16,7 @@
}
},
"scripts": {
"build": "tsc",
"build": "tsc -p tsconfig.build.json",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest",

View File

@ -0,0 +1,508 @@
/**
* Plugin integration tests core services calling real plugin instances.
*
* These tests verify the full path: core service real plugin mocked external API.
* Both tracker-github and scm-github use `gh` CLI via `execFile`, so a single
* `vi.mock("node:child_process")` covers both plugins.
*
* Runtime, Agent, and Workspace remain mock objects we're testing the
* tracker/SCM integration path, not those.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { mkdirSync, rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { randomUUID } from "node:crypto";
// ---------------------------------------------------------------------------
// Mock node:child_process — must be hoisted before plugin imports
// ---------------------------------------------------------------------------
const { ghMock } = vi.hoisted(() => ({ ghMock: vi.fn() }));
vi.mock("node:child_process", () => {
const execFile = Object.assign(vi.fn(), {
[Symbol.for("nodejs.util.promisify.custom")]: ghMock,
});
return { execFile };
});
// ---------------------------------------------------------------------------
// Imports — plugins resolve the mocked child_process at import time
// ---------------------------------------------------------------------------
import { createPluginRegistry } from "../plugin-registry.js";
import { createSessionManager } from "../session-manager.js";
import { createLifecycleManager } from "../lifecycle-manager.js";
import { writeMetadata } from "../metadata.js";
import trackerGithub from "@agent-orchestrator/plugin-tracker-github";
import scmGithub from "@agent-orchestrator/plugin-scm-github";
import type {
OrchestratorConfig,
PluginRegistry,
Runtime,
Agent,
Workspace,
RuntimeHandle,
SessionManager,
PRInfo,
Session,
} from "../types.js";
// ---------------------------------------------------------------------------
// Shared fixtures + helpers
// ---------------------------------------------------------------------------
let dataDir: string;
let mockRuntime: Runtime;
let mockAgent: Agent;
let mockWorkspace: Workspace;
let config: OrchestratorConfig;
function makeHandle(id: string): RuntimeHandle {
return { id, runtimeName: "mock", data: {} };
}
function mockGh(result: unknown): void {
ghMock.mockResolvedValueOnce({ stdout: JSON.stringify(result) });
}
const project = {
name: "Test App",
repo: "acme/app",
path: "/tmp/test-app",
defaultBranch: "main",
sessionPrefix: "app",
tracker: { plugin: "github" },
scm: { plugin: "github" },
};
const pr: PRInfo = {
number: 42,
url: "https://github.com/acme/app/pull/42",
title: "feat: add feature",
owner: "acme",
repo: "app",
branch: "feat/issue-99",
baseBranch: "main",
isDraft: false,
};
function makeSession(overrides: Partial<Session> = {}): Session {
return {
id: "app-1",
projectId: "my-app",
status: "working",
activity: "active",
branch: "feat/issue-99",
issueId: null,
pr: null,
workspacePath: "/tmp/test-app",
runtimeHandle: makeHandle("rt-1"),
agentInfo: null,
createdAt: new Date(),
lastActivityAt: new Date(),
metadata: {},
...overrides,
};
}
// ---------------------------------------------------------------------------
// Setup / teardown
// ---------------------------------------------------------------------------
beforeEach(() => {
vi.clearAllMocks();
dataDir = join(tmpdir(), `ao-test-plugin-int-${randomUUID()}`);
mkdirSync(join(dataDir, "sessions"), { recursive: true });
mockRuntime = {
name: "mock",
create: vi.fn().mockResolvedValue(makeHandle("rt-1")),
destroy: vi.fn().mockResolvedValue(undefined),
sendMessage: vi.fn().mockResolvedValue(undefined),
getOutput: vi.fn().mockResolvedValue(""),
isAlive: vi.fn().mockResolvedValue(true),
};
mockAgent = {
name: "mock-agent",
processName: "mock",
getLaunchCommand: vi.fn().mockReturnValue("mock-agent --start"),
getEnvironment: vi.fn().mockReturnValue({ AGENT_VAR: "1" }),
detectActivity: vi.fn().mockResolvedValue("active"),
isProcessRunning: vi.fn().mockResolvedValue(true),
isProcessing: vi.fn().mockResolvedValue(false),
getSessionInfo: vi.fn().mockResolvedValue(null),
};
mockWorkspace = {
name: "mock-ws",
create: vi.fn().mockResolvedValue({
path: "/tmp/mock-ws/app-1",
branch: "feat/issue-99",
sessionId: "app-1",
projectId: "my-app",
}),
destroy: vi.fn().mockResolvedValue(undefined),
list: vi.fn().mockResolvedValue([]),
};
config = {
dataDir,
worktreeDir: "/tmp/worktrees",
port: 3000,
defaults: {
runtime: "mock",
agent: "mock-agent",
workspace: "mock-ws",
notifiers: [],
},
projects: {
"my-app": project,
},
notifiers: {},
notificationRouting: {
urgent: [],
action: [],
warning: [],
info: [],
},
reactions: {},
};
});
afterEach(() => {
rmSync(dataDir, { recursive: true, force: true });
});
// ---------------------------------------------------------------------------
// Helper: create a registry with real tracker-github and scm-github
// ---------------------------------------------------------------------------
function createTestRegistry(): PluginRegistry {
const registry = createPluginRegistry();
// Register mock plugins for runtime/agent/workspace
registry.register({
manifest: { name: "mock", slot: "runtime", description: "mock", version: "0.0.0" },
create: () => mockRuntime,
});
registry.register({
manifest: { name: "mock-agent", slot: "agent", description: "mock", version: "0.0.0" },
create: () => mockAgent,
});
registry.register({
manifest: { name: "mock-ws", slot: "workspace", description: "mock", version: "0.0.0" },
create: () => mockWorkspace,
});
// Register REAL plugins
registry.register(trackerGithub);
registry.register(scmGithub);
return registry;
}
// ===========================================================================
// Tests
// ===========================================================================
describe("plugin integration", () => {
// -------------------------------------------------------------------------
describe("registry + real plugins", () => {
it("registers tracker-github and scm-github via real registry", () => {
const registry = createTestRegistry();
const trackers = registry.list("tracker");
const scms = registry.list("scm");
expect(trackers).toContainEqual(
expect.objectContaining({ name: "github", slot: "tracker" }),
);
expect(scms).toContainEqual(
expect.objectContaining({ name: "github", slot: "scm" }),
);
});
it("registry.get returns correct plugin instances by slot+name", () => {
const registry = createTestRegistry();
const tracker = registry.get("tracker", "github");
const scm = registry.get("scm", "github");
expect(tracker).not.toBeNull();
expect(scm).not.toBeNull();
expect(tracker).toHaveProperty("name", "github");
expect(scm).toHaveProperty("name", "github");
// Verify they have the expected methods
expect(tracker).toHaveProperty("branchName");
expect(tracker).toHaveProperty("isCompleted");
expect(scm).toHaveProperty("getPRState");
expect(scm).toHaveProperty("getCISummary");
});
});
// -------------------------------------------------------------------------
describe("SessionManager + Tracker", () => {
it("spawn() uses tracker-github branchName() to derive branch", async () => {
const registry = createTestRegistry();
const sm = createSessionManager({ config, registry });
const session = await sm.spawn({
projectId: "my-app",
issueId: "99",
});
// tracker-github.branchName("99", project) → "feat/issue-99"
expect(session.branch).toBe("feat/issue-99");
// Workspace should have been called with the tracker-derived branch
expect(mockWorkspace.create).toHaveBeenCalledWith(
expect.objectContaining({ branch: "feat/issue-99" }),
);
});
it("spawn() falls back to generic branch when no tracker configured", async () => {
// Remove tracker from project config
const noTrackerConfig: OrchestratorConfig = {
...config,
projects: {
"my-app": { ...project, tracker: undefined },
},
};
const registry = createTestRegistry();
const sm = createSessionManager({ config: noTrackerConfig, registry });
const session = await sm.spawn({
projectId: "my-app",
issueId: "99",
});
// Without tracker, falls back to "feat/<issueId>"
expect(session.branch).toBe("feat/99");
});
it("cleanup() calls tracker-github isCompleted() and kills completed sessions", async () => {
const registry = createTestRegistry();
const sm = createSessionManager({ config, registry });
// Seed a session with an issueId but no PR
writeMetadata(dataDir, "app-1", {
worktree: "/tmp/mock-ws/app-1",
branch: "feat/issue-99",
status: "working",
issue: "99",
project: "my-app",
runtimeHandle: JSON.stringify(makeHandle("rt-1")),
});
// Mock gh: issue is closed
mockGh({ state: "CLOSED" });
const result = await sm.cleanup("my-app");
expect(result.killed).toContain("app-1");
// Verify the gh CLI was called with the right args
expect(ghMock).toHaveBeenCalledWith(
"gh",
expect.arrayContaining(["issue", "view", "99", "--repo", "acme/app"]),
expect.any(Object),
);
});
it("cleanup() skips sessions when issue is still open", async () => {
const registry = createTestRegistry();
const sm = createSessionManager({ config, registry });
writeMetadata(dataDir, "app-1", {
worktree: "/tmp/mock-ws/app-1",
branch: "feat/issue-99",
status: "working",
issue: "99",
project: "my-app",
runtimeHandle: JSON.stringify(makeHandle("rt-1")),
});
// Mock gh: issue is still open — runtime also alive
mockGh({ state: "OPEN" });
const result = await sm.cleanup("my-app");
expect(result.skipped).toContain("app-1");
expect(result.killed).not.toContain("app-1");
});
});
// -------------------------------------------------------------------------
describe("SessionManager + SCM", () => {
it("cleanup() calls scm-github getPRState() and kills merged PR sessions", async () => {
const registry = createTestRegistry();
const sm = createSessionManager({ config, registry });
// metadataToSession extracts PR number from the URL tail (/42),
// and owner/repo stay empty — scm-github receives exactly that.
writeMetadata(dataDir, "app-1", {
worktree: "/tmp/mock-ws/app-1",
branch: "feat/issue-99",
status: "working",
pr: pr.url,
project: "my-app",
runtimeHandle: JSON.stringify(makeHandle("rt-1")),
});
// Mock gh: PR is merged
mockGh({ state: "MERGED" });
const result = await sm.cleanup("my-app");
expect(result.killed).toContain("app-1");
// Verify gh CLI was called for PR state check
expect(ghMock).toHaveBeenCalledWith(
"gh",
expect.arrayContaining(["pr", "view", "42"]),
expect.any(Object),
);
});
it("cleanup() skips sessions when PR is still open", async () => {
const registry = createTestRegistry();
const sm = createSessionManager({ config, registry });
writeMetadata(dataDir, "app-1", {
worktree: "/tmp/mock-ws/app-1",
branch: "feat/issue-99",
status: "working",
pr: pr.url,
project: "my-app",
runtimeHandle: JSON.stringify(makeHandle("rt-1")),
});
// Mock gh: PR is still open — runtime also alive
mockGh({ state: "OPEN" });
const result = await sm.cleanup("my-app");
expect(result.skipped).toContain("app-1");
});
});
// -------------------------------------------------------------------------
describe("LifecycleManager + SCM", () => {
let registry: PluginRegistry;
let sm: SessionManager;
beforeEach(() => {
registry = createTestRegistry();
sm = createSessionManager({ config, registry });
});
function seedSession(overrides: Partial<Session> = {}): Session {
const session = makeSession(overrides);
writeMetadata(dataDir, session.id, {
worktree: session.workspacePath ?? "/tmp/test-app",
branch: session.branch ?? "feat/issue-99",
status: session.status,
project: session.projectId,
...(session.pr ? { pr: JSON.stringify(session.pr) } : {}),
...(session.issueId ? { issue: session.issueId } : {}),
runtimeHandle: JSON.stringify(session.runtimeHandle),
});
return session;
}
it("check() detects ci_failed via scm-github getCISummary()", async () => {
seedSession({ status: "pr_open", pr });
// Mock the sessionManager.list() to return our session
const mockSM: SessionManager = {
...sm,
list: vi.fn().mockResolvedValue([makeSession({ status: "pr_open", pr })]),
get: vi.fn().mockResolvedValue(makeSession({ status: "pr_open", pr })),
kill: vi.fn().mockResolvedValue(undefined),
send: vi.fn().mockResolvedValue(undefined),
};
const lm = createLifecycleManager({
config,
registry,
sessionManager: mockSM,
});
// gh calls for determineStatus:
// 1. getPRState → open
mockGh({ state: "OPEN" });
// 2. getCISummary → failing (pr checks returns array of checks)
mockGh([
{ name: "lint", state: "COMPLETED", conclusion: "FAILURE", detailsUrl: "", startedAt: "", completedAt: "" },
]);
await lm.check("app-1");
const states = lm.getStates();
expect(states.get("app-1")).toBe("ci_failed");
});
it("check() detects merged via scm-github getPRState()", async () => {
seedSession({ status: "pr_open", pr });
const mockSM: SessionManager = {
...sm,
list: vi.fn().mockResolvedValue([makeSession({ status: "pr_open", pr })]),
get: vi.fn().mockResolvedValue(makeSession({ status: "pr_open", pr })),
kill: vi.fn().mockResolvedValue(undefined),
send: vi.fn().mockResolvedValue(undefined),
};
const lm = createLifecycleManager({
config,
registry,
sessionManager: mockSM,
});
// getPRState → merged
mockGh({ state: "MERGED" });
await lm.check("app-1");
const states = lm.getStates();
expect(states.get("app-1")).toBe("merged");
});
it("check() detects changes_requested via scm-github getReviewDecision()", async () => {
seedSession({ status: "pr_open", pr });
const mockSM: SessionManager = {
...sm,
list: vi.fn().mockResolvedValue([makeSession({ status: "pr_open", pr })]),
get: vi.fn().mockResolvedValue(makeSession({ status: "pr_open", pr })),
kill: vi.fn().mockResolvedValue(undefined),
send: vi.fn().mockResolvedValue(undefined),
};
const lm = createLifecycleManager({
config,
registry,
sessionManager: mockSM,
});
// 1. getPRState → open
mockGh({ state: "OPEN" });
// 2. getCISummary → passing
mockGh([
{ name: "lint", state: "COMPLETED", conclusion: "SUCCESS", detailsUrl: "", startedAt: "", completedAt: "" },
]);
// 3. getReviewDecision (gh pr view with reviewDecision)
mockGh({ reviewDecision: "CHANGES_REQUESTED" });
await lm.check("app-1");
const states = lm.getStates();
expect(states.get("app-1")).toBe("changes_requested");
});
});
});

View File

@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["src/__tests__"]
}

View File

@ -4,5 +4,6 @@
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"]
"include": ["src"],
"exclude": ["src/__tests__/plugin-integration.test.ts"]
}

View File

@ -0,0 +1,23 @@
import { defineConfig } from "vitest/config";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = dirname(fileURLToPath(import.meta.url));
export default defineConfig({
test: {
alias: {
// Integration tests import real plugins. These aliases resolve
// package names to source files so we don't need circular devDeps
// (plugins depend on core, core can't depend on plugins).
"@agent-orchestrator/plugin-tracker-github": resolve(
__dirname,
"../plugins/tracker-github/src/index.ts",
),
"@agent-orchestrator/plugin-scm-github": resolve(
__dirname,
"../plugins/scm-github/src/index.ts",
),
},
},
});

View File

@ -18,7 +18,8 @@
"@agent-orchestrator/plugin-runtime-tmux": "workspace:*",
"@agent-orchestrator/plugin-runtime-process": "workspace:*",
"@agent-orchestrator/plugin-workspace-worktree": "workspace:*",
"@agent-orchestrator/plugin-workspace-clone": "workspace:*"
"@agent-orchestrator/plugin-workspace-clone": "workspace:*",
"@agent-orchestrator/plugin-tracker-linear": "workspace:*"
},
"devDependencies": {
"@types/node": "^25.2.3",

View File

@ -0,0 +1,292 @@
/**
* Integration tests for the Linear tracker plugin.
*
* Requires one of:
* - LINEAR_API_KEY (direct Linear API access), or
* - COMPOSIO_API_KEY (via Composio SDK, optionally COMPOSIO_ENTITY_ID)
* Plus:
* - LINEAR_TEAM_ID (team to create test issues in)
*
* When using Composio, cleanup (issue deletion) still requires LINEAR_API_KEY
* since that uses a direct GraphQL call outside the plugin.
*
* Skipped automatically when prerequisites are missing.
*
* Each test run creates a real Linear issue, exercises the plugin methods
* against it, and deletes it in cleanup. This validates that our GraphQL
* queries, state mapping, and data parsing work against the real API
* not just against mocked responses.
*/
import { request } from "node:https";
import type { ProjectConfig } from "@agent-orchestrator/core";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import trackerLinear from "@agent-orchestrator/plugin-tracker-linear";
// ---------------------------------------------------------------------------
// Prerequisites
// ---------------------------------------------------------------------------
const LINEAR_API_KEY = process.env["LINEAR_API_KEY"];
const COMPOSIO_API_KEY = process.env["COMPOSIO_API_KEY"];
const LINEAR_TEAM_ID = process.env["LINEAR_TEAM_ID"];
const hasCredentials = Boolean(LINEAR_API_KEY || COMPOSIO_API_KEY);
const canRun = hasCredentials && Boolean(LINEAR_TEAM_ID);
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/**
* Direct GraphQL call for test setup/cleanup.
* Only available when LINEAR_API_KEY is set.
*/
function linearGraphQL<T>(
query: string,
variables: Record<string, unknown>,
): Promise<T> {
if (!LINEAR_API_KEY) {
throw new Error("linearGraphQL requires LINEAR_API_KEY");
}
const body = JSON.stringify({ query, variables });
return new Promise<T>((resolve, reject) => {
const url = new URL("https://api.linear.app/graphql");
const req = request(
{
hostname: url.hostname,
path: url.pathname,
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: LINEAR_API_KEY,
"Content-Length": Buffer.byteLength(body),
},
},
(res) => {
const chunks: Buffer[] = [];
res.on("data", (chunk: Buffer) => chunks.push(chunk));
res.on("end", () => {
try {
const text = Buffer.concat(chunks).toString("utf-8");
const json = JSON.parse(text) as {
data?: T;
errors?: Array<{ message: string }>;
};
if (json.errors?.length) {
reject(new Error(`Linear API error: ${json.errors[0].message}`));
return;
}
resolve(json.data as T);
} catch (err) {
reject(err);
}
});
},
);
req.setTimeout(30_000, () => {
req.destroy();
reject(new Error("Linear API request timed out"));
});
req.on("error", (err) => reject(err));
req.write(body);
req.end();
});
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe.skipIf(!canRun)("tracker-linear (integration)", () => {
const tracker = trackerLinear.create();
const project: ProjectConfig = {
name: "test-project",
repo: "test-org/test-repo",
path: "/tmp/test",
defaultBranch: "main",
sessionPrefix: "test",
tracker: {
plugin: "linear",
teamId: LINEAR_TEAM_ID!,
},
};
// Issue state tracked across tests (created in beforeAll, cleaned up in afterAll)
let issueIdentifier: string; // e.g. "INT-1234"
let issueUuid: string | undefined; // Linear internal UUID (needed for trash via direct API)
// -------------------------------------------------------------------------
// Setup — create a test issue
// -------------------------------------------------------------------------
beforeAll(async () => {
const result = await tracker.createIssue!(
{
title: `[AO Integration Test] ${new Date().toISOString()}`,
description:
"Automated integration test issue. Safe to delete if found lingering.",
priority: 4, // Low
},
project,
);
issueIdentifier = result.id;
// Resolve the UUID for cleanup — only possible with direct API key
if (LINEAR_API_KEY) {
const data = await linearGraphQL<{ issue: { id: string } }>(
`query($id: String!) { issue(id: $id) { id } }`,
{ id: issueIdentifier },
);
issueUuid = data.issue.id;
}
}, 30_000);
// -------------------------------------------------------------------------
// Cleanup — archive the test issue so it doesn't clutter the board.
// With LINEAR_API_KEY we can trash it directly. With Composio-only we
// close it via the plugin (can't trash through the plugin interface).
// -------------------------------------------------------------------------
afterAll(async () => {
if (!issueIdentifier) return;
try {
if (issueUuid && LINEAR_API_KEY) {
await linearGraphQL(
`mutation($id: String!) {
issueUpdate(id: $id, input: { trashed: true }) {
success
}
}`,
{ id: issueUuid },
);
} else {
// Composio-only: best-effort close via plugin
await tracker.updateIssue!(
issueIdentifier,
{ state: "closed" },
project,
);
}
} catch {
// Best-effort cleanup
}
}, 15_000);
// -------------------------------------------------------------------------
// Test cases
// -------------------------------------------------------------------------
it("createIssue returns a well-shaped Issue", () => {
// Validating the result captured in beforeAll
expect(issueIdentifier).toBeDefined();
expect(issueIdentifier).toMatch(/^[A-Z]+-\d+$/);
});
it("getIssue fetches the created issue with correct fields", async () => {
const issue = await tracker.getIssue(issueIdentifier, project);
expect(issue.id).toBe(issueIdentifier);
expect(issue.title).toContain("[AO Integration Test]");
expect(issue.description).toContain("Automated integration test");
expect(issue.url).toMatch(/^https:\/\/linear\.app\//);
expect(issue.state).toBe("open");
expect(Array.isArray(issue.labels)).toBe(true);
expect(issue.priority).toBe(4);
});
it("isCompleted returns false for an open issue", async () => {
const completed = await tracker.isCompleted(issueIdentifier, project);
expect(completed).toBe(false);
});
it("issueUrl returns a valid Linear URL", () => {
const url = tracker.issueUrl(issueIdentifier, project);
expect(url).toContain(issueIdentifier);
expect(url).toMatch(/^https:\/\/linear\.app\//);
});
it("branchName returns conventional branch name", () => {
const branch = tracker.branchName(issueIdentifier, project);
expect(branch).toBe(`feat/${issueIdentifier}`);
});
it("generatePrompt includes issue details", async () => {
const prompt = await tracker.generatePrompt(issueIdentifier, project);
expect(prompt).toContain(issueIdentifier);
expect(prompt).toContain("[AO Integration Test]");
expect(prompt).toContain("Priority: Low");
expect(prompt).toContain("implement the changes");
});
it("listIssues includes the created issue", async () => {
const issues = await tracker.listIssues!(
{ state: "open", limit: 50 },
project,
);
const found = issues.find((i) => i.id === issueIdentifier);
expect(found).toBeDefined();
expect(found!.title).toContain("[AO Integration Test]");
});
it("updateIssue adds a comment", async () => {
await tracker.updateIssue!(
issueIdentifier,
{ comment: "Integration test comment" },
project,
);
// Verify the comment was added — use direct API if available,
// otherwise trust the plugin didn't throw
if (LINEAR_API_KEY) {
const data = await linearGraphQL<{
issue: { comments: { nodes: Array<{ body: string }> } };
}>(
`query($id: String!) {
issue(id: $id) {
comments { nodes { body } }
}
}`,
{ id: issueIdentifier },
);
const commentBodies = data.issue.comments.nodes.map((c) => c.body);
expect(commentBodies).toContain("Integration test comment");
}
});
it("updateIssue closes the issue and isCompleted reflects it", async () => {
await tracker.updateIssue!(
issueIdentifier,
{ state: "closed" },
project,
);
const completed = await tracker.isCompleted(issueIdentifier, project);
expect(completed).toBe(true);
const issue = await tracker.getIssue(issueIdentifier, project);
expect(issue.state).toBe("closed");
});
it("updateIssue reopens the issue", async () => {
await tracker.updateIssue!(
issueIdentifier,
{ state: "open" },
project,
);
const completed = await tracker.isCompleted(issueIdentifier, project);
expect(completed).toBe(false);
const issue = await tracker.getIssue(issueIdentifier, project);
expect(issue.state).toBe("open");
});
});

View File

@ -14,6 +14,7 @@
"scripts": {
"build": "tsc",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"clean": "rm -rf dist"
},
"dependencies": {
@ -21,6 +22,7 @@
},
"devDependencies": {
"@types/node": "^25.2.3",
"typescript": "^5.7.0"
"typescript": "^5.7.0",
"vitest": "^3.0.0"
}
}

View File

@ -1 +1,556 @@
// scm-github plugin — to be implemented
/**
* scm-github plugin GitHub PRs, CI checks, reviews, merge readiness.
*
* Uses the `gh` CLI for all GitHub API interactions.
*/
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import type {
PluginModule,
SCM,
Session,
ProjectConfig,
PRInfo,
PRState,
MergeMethod,
CICheck,
CIStatus,
Review,
ReviewDecision,
ReviewComment,
AutomatedComment,
MergeReadiness,
} from "@agent-orchestrator/core";
const execFileAsync = promisify(execFile);
/** Known bot logins that produce automated review comments */
const BOT_AUTHORS = new Set([
"cursor[bot]",
"github-actions[bot]",
"codecov[bot]",
"sonarcloud[bot]",
"dependabot[bot]",
"renovate[bot]",
"codeclimate[bot]",
"deepsource-autofix[bot]",
"snyk-bot",
"lgtm-com[bot]",
]);
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
async function gh(args: string[]): Promise<string> {
try {
const { stdout } = await execFileAsync("gh", args, {
maxBuffer: 10 * 1024 * 1024,
timeout: 30_000,
});
return stdout.trim();
} catch (err) {
throw new Error(`gh ${args.slice(0, 3).join(" ")} failed: ${(err as Error).message}`, {
cause: err,
});
}
}
function repoFlag(pr: PRInfo): string {
return `${pr.owner}/${pr.repo}`;
}
function parseDate(val: string | undefined | null): Date {
if (!val) return new Date(0);
const d = new Date(val);
return isNaN(d.getTime()) ? new Date(0) : d;
}
// ---------------------------------------------------------------------------
// SCM implementation
// ---------------------------------------------------------------------------
function createGitHubSCM(): SCM {
return {
name: "github",
async detectPR(
session: Session,
project: ProjectConfig,
): Promise<PRInfo | null> {
if (!session.branch) return null;
const parts = project.repo.split("/");
if (parts.length !== 2 || !parts[0] || !parts[1]) {
throw new Error(`Invalid repo format "${project.repo}", expected "owner/repo"`);
}
const [owner, repo] = parts;
try {
const raw = await gh([
"pr",
"list",
"--repo",
project.repo,
"--head",
session.branch,
"--json",
"number,url,title,headRefName,baseRefName,isDraft",
"--limit",
"1",
]);
const prs: Array<{
number: number;
url: string;
title: string;
headRefName: string;
baseRefName: string;
isDraft: boolean;
}> = JSON.parse(raw);
if (prs.length === 0) return null;
const pr = prs[0];
return {
number: pr.number,
url: pr.url,
title: pr.title,
owner,
repo,
branch: pr.headRefName,
baseBranch: pr.baseRefName,
isDraft: pr.isDraft,
};
} catch {
return null;
}
},
async getPRState(pr: PRInfo): Promise<PRState> {
const raw = await gh([
"pr",
"view",
String(pr.number),
"--repo",
repoFlag(pr),
"--json",
"state",
]);
const data: { state: string } = JSON.parse(raw);
const s = data.state.toUpperCase();
if (s === "MERGED") return "merged";
if (s === "CLOSED") return "closed";
return "open";
},
async mergePR(pr: PRInfo, method: MergeMethod = "squash"): Promise<void> {
const flag =
method === "rebase"
? "--rebase"
: method === "merge"
? "--merge"
: "--squash";
await gh([
"pr",
"merge",
String(pr.number),
"--repo",
repoFlag(pr),
flag,
"--delete-branch",
]);
},
async closePR(pr: PRInfo): Promise<void> {
await gh([
"pr",
"close",
String(pr.number),
"--repo",
repoFlag(pr),
]);
},
async getCIChecks(pr: PRInfo): Promise<CICheck[]> {
try {
const raw = await gh([
"pr",
"checks",
String(pr.number),
"--repo",
repoFlag(pr),
"--json",
"name,state,conclusion,detailsUrl,startedAt,completedAt",
]);
const checks: Array<{
name: string;
state: string;
conclusion: string;
detailsUrl: string;
startedAt: string;
completedAt: string;
}> = JSON.parse(raw);
return checks.map((c) => {
let status: CICheck["status"];
const state = c.state?.toUpperCase();
const conclusion = c.conclusion?.toUpperCase();
if (state === "PENDING" || state === "QUEUED") {
status = "pending";
} else if (state === "IN_PROGRESS") {
status = "running";
} else if (conclusion === "SUCCESS") {
status = "passed";
} else if (
conclusion === "FAILURE" ||
conclusion === "TIMED_OUT" ||
conclusion === "CANCELLED" ||
conclusion === "ACTION_REQUIRED"
) {
status = "failed";
} else if (conclusion === "SKIPPED" || conclusion === "NEUTRAL") {
status = "skipped";
} else {
// Unknown conclusion on a completed check — fail closed
status = "failed";
}
return {
name: c.name,
status,
url: c.detailsUrl || undefined,
conclusion: c.conclusion || undefined,
startedAt: c.startedAt ? new Date(c.startedAt) : undefined,
completedAt: c.completedAt ? new Date(c.completedAt) : undefined,
};
});
} catch (err) {
// Propagate so callers (getCISummary) can decide how to handle.
// Do NOT silently return [] — that causes a fail-open where CI
// appears healthy when we simply failed to fetch check status.
throw new Error("Failed to fetch CI checks", { cause: err });
}
},
async getCISummary(pr: PRInfo): Promise<CIStatus> {
let checks: CICheck[];
try {
checks = await this.getCIChecks(pr);
} catch {
// Fail closed: if we can't fetch CI status, report as failing
// rather than "none" (which getMergeability treats as passing).
return "failing";
}
if (checks.length === 0) return "none";
const hasFailing = checks.some((c) => c.status === "failed");
if (hasFailing) return "failing";
const hasPending = checks.some(
(c) => c.status === "pending" || c.status === "running",
);
if (hasPending) return "pending";
// Only report passing if at least one check actually passed
// (not all skipped)
const hasPassing = checks.some((c) => c.status === "passed");
if (!hasPassing) return "none";
return "passing";
},
async getReviews(pr: PRInfo): Promise<Review[]> {
const raw = await gh([
"pr",
"view",
String(pr.number),
"--repo",
repoFlag(pr),
"--json",
"reviews",
]);
const data: {
reviews: Array<{
author: { login: string };
state: string;
body: string;
submittedAt: string;
}>;
} = JSON.parse(raw);
return data.reviews.map((r) => {
let state: Review["state"];
const s = r.state?.toUpperCase();
if (s === "APPROVED") state = "approved";
else if (s === "CHANGES_REQUESTED") state = "changes_requested";
else if (s === "DISMISSED") state = "dismissed";
else if (s === "PENDING") state = "pending";
else state = "commented";
return {
author: r.author?.login ?? "unknown",
state,
body: r.body || undefined,
submittedAt: parseDate(r.submittedAt),
};
});
},
async getReviewDecision(pr: PRInfo): Promise<ReviewDecision> {
const raw = await gh([
"pr",
"view",
String(pr.number),
"--repo",
repoFlag(pr),
"--json",
"reviewDecision",
]);
const data: { reviewDecision: string } = JSON.parse(raw);
const d = (data.reviewDecision ?? "").toUpperCase();
if (d === "APPROVED") return "approved";
if (d === "CHANGES_REQUESTED") return "changes_requested";
if (d === "REVIEW_REQUIRED") return "pending";
return "none";
},
async getPendingComments(pr: PRInfo): Promise<ReviewComment[]> {
try {
// Use GraphQL with variables to get review threads with actual isResolved status
const raw = await gh([
"api",
"graphql",
"-f",
`owner=${pr.owner}`,
"-f",
`name=${pr.repo}`,
"-F",
`number=${pr.number}`,
"-f",
`query=query($owner: String!, $name: String!, $number: Int!) {
repository(owner: $owner, name: $name) {
pullRequest(number: $number) {
reviewThreads(first: 100) {
nodes {
isResolved
comments(first: 1) {
nodes {
id
author { login }
body
path
line
url
createdAt
}
}
}
}
}
}
}`,
]);
const data: {
data: {
repository: {
pullRequest: {
reviewThreads: {
nodes: Array<{
isResolved: boolean;
comments: {
nodes: Array<{
id: string;
author: { login: string } | null;
body: string;
path: string | null;
line: number | null;
url: string;
createdAt: string;
}>;
};
}>;
};
};
};
};
} = JSON.parse(raw);
const threads =
data.data.repository.pullRequest.reviewThreads.nodes;
return threads
.filter((t) => {
if (t.isResolved) return false; // only pending (unresolved) threads
const c = t.comments.nodes[0];
if (!c) return false; // skip threads with no comments
const author = c.author?.login ?? "";
return !BOT_AUTHORS.has(author);
})
.map((t) => {
const c = t.comments.nodes[0];
return {
id: c.id,
author: c.author?.login ?? "unknown",
body: c.body,
path: c.path || undefined,
line: c.line ?? undefined,
isResolved: t.isResolved,
createdAt: parseDate(c.createdAt),
url: c.url,
};
});
} catch {
return [];
}
},
async getAutomatedComments(pr: PRInfo): Promise<AutomatedComment[]> {
try {
// Fetch all review comments with max page size (100 is GitHub's limit)
const raw = await gh([
"api",
"-F",
"per_page=100",
`repos/${repoFlag(pr)}/pulls/${pr.number}/comments`,
]);
const comments: Array<{
id: number;
user: { login: string };
body: string;
path: string;
line: number | null;
original_line: number | null;
created_at: string;
html_url: string;
}> = JSON.parse(raw);
return comments
.filter((c) => BOT_AUTHORS.has(c.user?.login ?? ""))
.map((c) => {
// Determine severity from body content
let severity: AutomatedComment["severity"] = "info";
const bodyLower = c.body.toLowerCase();
if (
bodyLower.includes("error") ||
bodyLower.includes("bug") ||
bodyLower.includes("critical") ||
bodyLower.includes("potential issue")
) {
severity = "error";
} else if (
bodyLower.includes("warning") ||
bodyLower.includes("suggest") ||
bodyLower.includes("consider")
) {
severity = "warning";
}
return {
id: String(c.id),
botName: c.user?.login ?? "unknown",
body: c.body,
path: c.path || undefined,
line: c.line ?? c.original_line ?? undefined,
severity,
createdAt: parseDate(c.created_at),
url: c.html_url,
};
});
} catch {
return [];
}
},
async getMergeability(pr: PRInfo): Promise<MergeReadiness> {
const blockers: string[] = [];
// Fetch PR details with merge state
const raw = await gh([
"pr",
"view",
String(pr.number),
"--repo",
repoFlag(pr),
"--json",
"mergeable,reviewDecision,mergeStateStatus,isDraft",
]);
const data: {
mergeable: string;
reviewDecision: string;
mergeStateStatus: string;
isDraft: boolean;
} = JSON.parse(raw);
// CI
const ciStatus = await this.getCISummary(pr);
const ciPassing = ciStatus === "passing" || ciStatus === "none";
if (!ciPassing) {
blockers.push(`CI is ${ciStatus}`);
}
// Reviews
const reviewDecision = (data.reviewDecision ?? "").toUpperCase();
const approved = reviewDecision === "APPROVED";
if (reviewDecision === "CHANGES_REQUESTED") {
blockers.push("Changes requested in review");
} else if (reviewDecision === "REVIEW_REQUIRED") {
blockers.push("Review required");
}
// Conflicts / merge state
const mergeable = (data.mergeable ?? "").toUpperCase();
const mergeState = (data.mergeStateStatus ?? "").toUpperCase();
const noConflicts = mergeable === "MERGEABLE";
if (mergeable === "CONFLICTING") {
blockers.push("Merge conflicts");
} else if (mergeable === "UNKNOWN" || mergeable === "") {
blockers.push("Merge status unknown (GitHub is computing)");
}
if (mergeState === "BEHIND") {
blockers.push("Branch is behind base branch");
} else if (mergeState === "BLOCKED") {
blockers.push("Merge is blocked by branch protection");
} else if (mergeState === "UNSTABLE") {
blockers.push("Required checks are failing");
}
// Draft
if (data.isDraft) {
blockers.push("PR is still a draft");
}
return {
mergeable: blockers.length === 0,
ciPassing,
approved,
noConflicts,
blockers,
};
},
};
}
// ---------------------------------------------------------------------------
// Plugin module export
// ---------------------------------------------------------------------------
export const manifest = {
name: "github",
slot: "scm" as const,
description: "SCM plugin: GitHub PRs, CI checks, reviews, merge readiness",
version: "0.1.0",
};
export function create(): SCM {
return createGitHubSCM();
}
export default { manifest, create } satisfies PluginModule<SCM>;

View File

@ -0,0 +1,631 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
// ---------------------------------------------------------------------------
// Mock node:child_process — gh CLI calls go through execFileAsync = promisify(execFile)
// vi.hoisted ensures the mock fn is available when vi.mock factory runs (hoisted above imports)
// ---------------------------------------------------------------------------
const { ghMock } = vi.hoisted(() => ({ ghMock: vi.fn() }));
vi.mock("node:child_process", () => {
// Attach the custom promisify symbol so `promisify(execFile)` returns ghMock
const execFile = Object.assign(vi.fn(), {
[Symbol.for("nodejs.util.promisify.custom")]: ghMock,
});
return { execFile };
});
import { create, manifest } from "../src/index.js";
import type { PRInfo, Session, ProjectConfig } from "@agent-orchestrator/core";
// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------
const pr: PRInfo = {
number: 42,
url: "https://github.com/acme/repo/pull/42",
title: "feat: add feature",
owner: "acme",
repo: "repo",
branch: "feat/my-feature",
baseBranch: "main",
isDraft: false,
};
const project: ProjectConfig = {
name: "test",
repo: "acme/repo",
path: "/tmp/repo",
defaultBranch: "main",
sessionPrefix: "test",
};
function makeSession(overrides: Partial<Session> = {}): Session {
return {
id: "test-1",
projectId: "test",
status: "working",
activity: "active",
branch: "feat/my-feature",
issueId: null,
pr: null,
workspacePath: "/tmp/repo",
runtimeHandle: null,
agentInfo: null,
createdAt: new Date(),
lastActivityAt: new Date(),
metadata: {},
...overrides,
};
}
function mockGh(result: unknown) {
ghMock.mockResolvedValueOnce({ stdout: JSON.stringify(result) });
}
function mockGhError(msg = "Command failed") {
ghMock.mockRejectedValueOnce(new Error(msg));
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("scm-github plugin", () => {
let scm: ReturnType<typeof create>;
beforeEach(() => {
vi.clearAllMocks();
scm = create();
});
// ---- manifest ----------------------------------------------------------
describe("manifest", () => {
it("has correct metadata", () => {
expect(manifest.name).toBe("github");
expect(manifest.slot).toBe("scm");
expect(manifest.version).toBe("0.1.0");
});
});
// ---- create() ----------------------------------------------------------
describe("create()", () => {
it("returns an SCM with correct name", () => {
expect(scm.name).toBe("github");
});
});
// ---- detectPR ----------------------------------------------------------
describe("detectPR", () => {
it("returns PRInfo when a PR exists", async () => {
mockGh([
{
number: 42,
url: "https://github.com/acme/repo/pull/42",
title: "feat: add feature",
headRefName: "feat/my-feature",
baseRefName: "main",
isDraft: false,
},
]);
const result = await scm.detectPR(makeSession(), project);
expect(result).toEqual({
number: 42,
url: "https://github.com/acme/repo/pull/42",
title: "feat: add feature",
owner: "acme",
repo: "repo",
branch: "feat/my-feature",
baseBranch: "main",
isDraft: false,
});
});
it("returns null when no PR found", async () => {
mockGh([]);
const result = await scm.detectPR(makeSession(), project);
expect(result).toBeNull();
});
it("returns null when session has no branch", async () => {
const result = await scm.detectPR(makeSession({ branch: null }), project);
expect(result).toBeNull();
expect(ghMock).not.toHaveBeenCalled();
});
it("returns null on gh CLI error", async () => {
mockGhError("gh: not found");
const result = await scm.detectPR(makeSession(), project);
expect(result).toBeNull();
});
it("throws on invalid repo format", async () => {
const badProject = { ...project, repo: "no-slash" };
await expect(scm.detectPR(makeSession(), badProject)).rejects.toThrow("Invalid repo format");
});
it("detects draft PRs", async () => {
mockGh([
{
number: 99,
url: "https://github.com/acme/repo/pull/99",
title: "WIP: draft feature",
headRefName: "feat/my-feature",
baseRefName: "main",
isDraft: true,
},
]);
const result = await scm.detectPR(makeSession(), project);
expect(result?.isDraft).toBe(true);
});
});
// ---- getPRState --------------------------------------------------------
describe("getPRState", () => {
it('returns "open" for open PR', async () => {
mockGh({ state: "OPEN" });
expect(await scm.getPRState(pr)).toBe("open");
});
it('returns "merged" for merged PR', async () => {
mockGh({ state: "MERGED" });
expect(await scm.getPRState(pr)).toBe("merged");
});
it('returns "closed" for closed PR', async () => {
mockGh({ state: "CLOSED" });
expect(await scm.getPRState(pr)).toBe("closed");
});
it("handles lowercase state strings", async () => {
mockGh({ state: "merged" });
expect(await scm.getPRState(pr)).toBe("merged");
});
});
// ---- mergePR -----------------------------------------------------------
describe("mergePR", () => {
it("uses --squash by default", async () => {
ghMock.mockResolvedValueOnce({ stdout: "" });
await scm.mergePR(pr);
expect(ghMock).toHaveBeenCalledWith(
"gh",
["pr", "merge", "42", "--repo", "acme/repo", "--squash", "--delete-branch"],
expect.any(Object),
);
});
it("uses --merge when specified", async () => {
ghMock.mockResolvedValueOnce({ stdout: "" });
await scm.mergePR(pr, "merge");
expect(ghMock).toHaveBeenCalledWith(
"gh",
expect.arrayContaining(["--merge"]),
expect.any(Object),
);
});
it("uses --rebase when specified", async () => {
ghMock.mockResolvedValueOnce({ stdout: "" });
await scm.mergePR(pr, "rebase");
expect(ghMock).toHaveBeenCalledWith(
"gh",
expect.arrayContaining(["--rebase"]),
expect.any(Object),
);
});
});
// ---- closePR -----------------------------------------------------------
describe("closePR", () => {
it("calls gh pr close", async () => {
ghMock.mockResolvedValueOnce({ stdout: "" });
await scm.closePR(pr);
expect(ghMock).toHaveBeenCalledWith(
"gh",
["pr", "close", "42", "--repo", "acme/repo"],
expect.any(Object),
);
});
});
// ---- getCIChecks -------------------------------------------------------
describe("getCIChecks", () => {
it("maps various check states correctly", async () => {
mockGh([
{ name: "build", state: "COMPLETED", conclusion: "SUCCESS", detailsUrl: "https://ci/1", startedAt: "2025-01-01T00:00:00Z", completedAt: "2025-01-01T00:05:00Z" },
{ name: "lint", state: "COMPLETED", conclusion: "FAILURE", detailsUrl: "", startedAt: "", completedAt: "" },
{ name: "deploy", state: "PENDING", conclusion: "", detailsUrl: "", startedAt: "", completedAt: "" },
{ name: "e2e", state: "IN_PROGRESS", conclusion: "", detailsUrl: "", startedAt: "", completedAt: "" },
{ name: "optional", state: "COMPLETED", conclusion: "SKIPPED", detailsUrl: "", startedAt: "", completedAt: "" },
{ name: "neutral", state: "COMPLETED", conclusion: "NEUTRAL", detailsUrl: "", startedAt: "", completedAt: "" },
{ name: "timeout", state: "COMPLETED", conclusion: "TIMED_OUT", detailsUrl: "", startedAt: "", completedAt: "" },
{ name: "queued", state: "QUEUED", conclusion: "", detailsUrl: "", startedAt: "", completedAt: "" },
{ name: "cancelled", state: "COMPLETED", conclusion: "CANCELLED", detailsUrl: "", startedAt: "", completedAt: "" },
{ name: "action_req", state: "COMPLETED", conclusion: "ACTION_REQUIRED", detailsUrl: "", startedAt: "", completedAt: "" },
]);
const checks = await scm.getCIChecks(pr);
expect(checks).toHaveLength(10);
expect(checks[0].status).toBe("passed");
expect(checks[0].url).toBe("https://ci/1");
expect(checks[1].status).toBe("failed");
expect(checks[2].status).toBe("pending");
expect(checks[3].status).toBe("running");
expect(checks[4].status).toBe("skipped");
expect(checks[5].status).toBe("skipped");
expect(checks[6].status).toBe("failed");
expect(checks[7].status).toBe("pending");
expect(checks[8].status).toBe("failed"); // CANCELLED
expect(checks[9].status).toBe("failed"); // ACTION_REQUIRED
});
it("throws on error (fail-closed)", async () => {
mockGhError("no checks");
await expect(scm.getCIChecks(pr)).rejects.toThrow("Failed to fetch CI checks");
});
it("returns empty array for PR with no checks", async () => {
mockGh([]);
expect(await scm.getCIChecks(pr)).toEqual([]);
});
it("handles missing optional fields gracefully", async () => {
mockGh([{ name: "test", state: "COMPLETED", conclusion: "SUCCESS" }]);
const checks = await scm.getCIChecks(pr);
expect(checks[0].url).toBeUndefined();
expect(checks[0].startedAt).toBeUndefined();
expect(checks[0].completedAt).toBeUndefined();
});
});
// ---- getCISummary ------------------------------------------------------
describe("getCISummary", () => {
it('returns "failing" when any check failed', async () => {
mockGh([
{ name: "a", state: "COMPLETED", conclusion: "SUCCESS" },
{ name: "b", state: "COMPLETED", conclusion: "FAILURE" },
]);
expect(await scm.getCISummary(pr)).toBe("failing");
});
it('returns "pending" when checks are running', async () => {
mockGh([
{ name: "a", state: "COMPLETED", conclusion: "SUCCESS" },
{ name: "b", state: "IN_PROGRESS", conclusion: "" },
]);
expect(await scm.getCISummary(pr)).toBe("pending");
});
it('returns "passing" when all checks passed', async () => {
mockGh([
{ name: "a", state: "COMPLETED", conclusion: "SUCCESS" },
{ name: "b", state: "COMPLETED", conclusion: "SUCCESS" },
]);
expect(await scm.getCISummary(pr)).toBe("passing");
});
it('returns "none" when no checks', async () => {
mockGh([]);
expect(await scm.getCISummary(pr)).toBe("none");
});
it('returns "failing" on error (fail-closed)', async () => {
mockGhError();
expect(await scm.getCISummary(pr)).toBe("failing");
});
it('returns "none" when all checks are skipped', async () => {
mockGh([
{ name: "a", state: "COMPLETED", conclusion: "SKIPPED" },
{ name: "b", state: "COMPLETED", conclusion: "NEUTRAL" },
]);
expect(await scm.getCISummary(pr)).toBe("none");
});
});
// ---- getReviews --------------------------------------------------------
describe("getReviews", () => {
it("maps review states correctly", async () => {
mockGh({
reviews: [
{ author: { login: "alice" }, state: "APPROVED", body: "LGTM", submittedAt: "2025-01-01T00:00:00Z" },
{ author: { login: "bob" }, state: "CHANGES_REQUESTED", body: "Fix this", submittedAt: "2025-01-02T00:00:00Z" },
{ author: { login: "charlie" }, state: "COMMENTED", body: "", submittedAt: "2025-01-03T00:00:00Z" },
{ author: { login: "eve" }, state: "DISMISSED", body: "", submittedAt: "2025-01-04T00:00:00Z" },
{ author: { login: "frank" }, state: "PENDING", body: "", submittedAt: null },
],
});
const reviews = await scm.getReviews(pr);
expect(reviews).toHaveLength(5);
expect(reviews[0]).toMatchObject({ author: "alice", state: "approved" });
expect(reviews[1]).toMatchObject({ author: "bob", state: "changes_requested" });
expect(reviews[2]).toMatchObject({ author: "charlie", state: "commented" });
expect(reviews[3]).toMatchObject({ author: "eve", state: "dismissed" });
expect(reviews[4]).toMatchObject({ author: "frank", state: "pending" });
});
it("handles empty reviews", async () => {
mockGh({ reviews: [] });
expect(await scm.getReviews(pr)).toEqual([]);
});
it('defaults to "unknown" author when missing', async () => {
mockGh({
reviews: [{ author: null, state: "APPROVED", body: "", submittedAt: "2025-01-01T00:00:00Z" }],
});
const reviews = await scm.getReviews(pr);
expect(reviews[0].author).toBe("unknown");
});
});
// ---- getReviewDecision -------------------------------------------------
describe("getReviewDecision", () => {
it.each([
["APPROVED", "approved"],
["CHANGES_REQUESTED", "changes_requested"],
["REVIEW_REQUIRED", "pending"],
] as const)('maps %s to "%s"', async (input, expected) => {
mockGh({ reviewDecision: input });
expect(await scm.getReviewDecision(pr)).toBe(expected);
});
it('returns "none" when reviewDecision is empty', async () => {
mockGh({ reviewDecision: "" });
expect(await scm.getReviewDecision(pr)).toBe("none");
});
it('returns "none" when reviewDecision is null', async () => {
mockGh({ reviewDecision: null });
expect(await scm.getReviewDecision(pr)).toBe("none");
});
});
// ---- getPendingComments ------------------------------------------------
describe("getPendingComments", () => {
function makeGraphQLThreads(
threads: Array<{
isResolved: boolean;
id: string;
author: string | null;
body: string;
path: string | null;
line: number | null;
url: string;
createdAt: string;
}>,
) {
return {
data: {
repository: {
pullRequest: {
reviewThreads: {
nodes: threads.map((t) => ({
isResolved: t.isResolved,
comments: {
nodes: [
{
id: t.id,
author: t.author ? { login: t.author } : null,
body: t.body,
path: t.path,
line: t.line,
url: t.url,
createdAt: t.createdAt,
},
],
},
})),
},
},
},
},
};
}
it("returns only unresolved non-bot comments from GraphQL", async () => {
mockGh(
makeGraphQLThreads([
{ isResolved: false, id: "C1", author: "alice", body: "Fix line 10", path: "src/foo.ts", line: 10, url: "https://github.com/c/1", createdAt: "2025-01-01T00:00:00Z" },
{ isResolved: true, id: "C2", author: "bob", body: "Resolved one", path: "src/bar.ts", line: 20, url: "https://github.com/c/2", createdAt: "2025-01-02T00:00:00Z" },
]),
);
const comments = await scm.getPendingComments(pr);
expect(comments).toHaveLength(1);
expect(comments[0]).toMatchObject({ id: "C1", author: "alice", isResolved: false });
});
it("filters out bot comments", async () => {
mockGh(
makeGraphQLThreads([
{ isResolved: false, id: "C1", author: "alice", body: "Fix this", path: "a.ts", line: 1, url: "u", createdAt: "2025-01-01T00:00:00Z" },
{ isResolved: false, id: "C2", author: "cursor[bot]", body: "Bot says", path: "a.ts", line: 2, url: "u", createdAt: "2025-01-01T00:00:00Z" },
{ isResolved: false, id: "C3", author: "codecov[bot]", body: "Coverage", path: "a.ts", line: 3, url: "u", createdAt: "2025-01-01T00:00:00Z" },
]),
);
const comments = await scm.getPendingComments(pr);
expect(comments).toHaveLength(1);
expect(comments[0].author).toBe("alice");
});
it("returns empty on error", async () => {
mockGhError("API rate limit");
expect(await scm.getPendingComments(pr)).toEqual([]);
});
it("handles null path and line", async () => {
mockGh(
makeGraphQLThreads([
{ isResolved: false, id: "C1", author: "alice", body: "General comment", path: null, line: null, url: "u", createdAt: "2025-01-01T00:00:00Z" },
]),
);
const comments = await scm.getPendingComments(pr);
expect(comments[0].path).toBeUndefined();
expect(comments[0].line).toBeUndefined();
});
});
// ---- getAutomatedComments ----------------------------------------------
describe("getAutomatedComments", () => {
it("returns bot comments filtered from all PR comments", async () => {
mockGh([
{ id: 1, user: { login: "cursor[bot]" }, body: "Found a potential issue", path: "a.ts", line: 5, original_line: null, created_at: "2025-01-01T00:00:00Z", html_url: "u1" },
{ id: 2, user: { login: "alice" }, body: "Human comment", path: "a.ts", line: 1, original_line: null, created_at: "2025-01-01T00:00:00Z", html_url: "u2" },
]);
const comments = await scm.getAutomatedComments(pr);
expect(comments).toHaveLength(1);
expect(comments[0].botName).toBe("cursor[bot]");
expect(comments[0].severity).toBe("error"); // "potential issue" → error
});
it("classifies severity from body content", async () => {
mockGh([
{ id: 1, user: { login: "github-actions[bot]" }, body: "Error: build failed", path: "a.ts", line: 1, original_line: null, created_at: "2025-01-01T00:00:00Z", html_url: "u" },
{ id: 2, user: { login: "github-actions[bot]" }, body: "Warning: deprecated API", path: "a.ts", line: 2, original_line: null, created_at: "2025-01-01T00:00:00Z", html_url: "u" },
{ id: 3, user: { login: "github-actions[bot]" }, body: "Deployed to staging", path: "a.ts", line: 3, original_line: null, created_at: "2025-01-01T00:00:00Z", html_url: "u" },
]);
const comments = await scm.getAutomatedComments(pr);
expect(comments).toHaveLength(3);
expect(comments[0].severity).toBe("error");
expect(comments[1].severity).toBe("warning");
expect(comments[2].severity).toBe("info");
});
it("returns empty when no bot comments", async () => {
mockGh([
{ id: 1, user: { login: "alice" }, body: "Human comment", path: "a.ts", line: 1, original_line: null, created_at: "2025-01-01T00:00:00Z", html_url: "u" },
]);
const comments = await scm.getAutomatedComments(pr);
expect(comments).toEqual([]);
});
it("returns empty on error", async () => {
mockGhError("network failure");
expect(await scm.getAutomatedComments(pr)).toEqual([]);
});
it("uses original_line as fallback", async () => {
mockGh([
{ id: 1, user: { login: "dependabot[bot]" }, body: "Suggest update", path: "a.ts", line: null, original_line: 15, created_at: "2025-01-01T00:00:00Z", html_url: "u" },
]);
const comments = await scm.getAutomatedComments(pr);
expect(comments[0].line).toBe(15);
});
});
// ---- getMergeability ---------------------------------------------------
describe("getMergeability", () => {
it("returns mergeable when everything is clear", async () => {
// PR view
mockGh({ mergeable: "MERGEABLE", reviewDecision: "APPROVED", mergeStateStatus: "CLEAN", isDraft: false });
// CI checks (called by getCISummary)
mockGh([{ name: "build", state: "COMPLETED", conclusion: "SUCCESS" }]);
const result = await scm.getMergeability(pr);
expect(result).toEqual({
mergeable: true,
ciPassing: true,
approved: true,
noConflicts: true,
blockers: [],
});
});
it("reports CI failures as blockers", async () => {
mockGh({ mergeable: "MERGEABLE", reviewDecision: "APPROVED", mergeStateStatus: "UNSTABLE", isDraft: false });
mockGh([{ name: "build", state: "COMPLETED", conclusion: "FAILURE" }]);
const result = await scm.getMergeability(pr);
expect(result.ciPassing).toBe(false);
expect(result.mergeable).toBe(false);
expect(result.blockers).toContain("CI is failing");
expect(result.blockers).toContain("Required checks are failing");
});
it("reports UNSTABLE merge state even when CI fetch fails", async () => {
mockGh({ mergeable: "MERGEABLE", reviewDecision: "APPROVED", mergeStateStatus: "UNSTABLE", isDraft: false });
mockGhError("rate limited");
const result = await scm.getMergeability(pr);
expect(result.ciPassing).toBe(false);
expect(result.mergeable).toBe(false);
expect(result.blockers).toContain("CI is failing");
expect(result.blockers).toContain("Required checks are failing");
});
it("reports changes requested as blockers", async () => {
mockGh({ mergeable: "MERGEABLE", reviewDecision: "CHANGES_REQUESTED", mergeStateStatus: "CLEAN", isDraft: false });
mockGh([]); // no CI checks
const result = await scm.getMergeability(pr);
expect(result.approved).toBe(false);
expect(result.blockers).toContain("Changes requested in review");
});
it("reports review required as blocker", async () => {
mockGh({ mergeable: "MERGEABLE", reviewDecision: "REVIEW_REQUIRED", mergeStateStatus: "BLOCKED", isDraft: false });
mockGh([]);
const result = await scm.getMergeability(pr);
expect(result.blockers).toContain("Review required");
});
it("reports merge conflicts as blockers", async () => {
mockGh({ mergeable: "CONFLICTING", reviewDecision: "APPROVED", mergeStateStatus: "DIRTY", isDraft: false });
mockGh([]);
const result = await scm.getMergeability(pr);
expect(result.noConflicts).toBe(false);
expect(result.blockers).toContain("Merge conflicts");
});
it("reports UNKNOWN mergeable as noConflicts false", async () => {
mockGh({ mergeable: "UNKNOWN", reviewDecision: "APPROVED", mergeStateStatus: "CLEAN", isDraft: false });
mockGh([{ name: "build", state: "COMPLETED", conclusion: "SUCCESS" }]);
const result = await scm.getMergeability(pr);
expect(result.noConflicts).toBe(false);
expect(result.blockers).toContain("Merge status unknown (GitHub is computing)");
expect(result.mergeable).toBe(false);
});
it("reports draft status as blocker", async () => {
mockGh({ mergeable: "MERGEABLE", reviewDecision: "APPROVED", mergeStateStatus: "DRAFT", isDraft: true });
mockGh([{ name: "build", state: "COMPLETED", conclusion: "SUCCESS" }]);
const result = await scm.getMergeability(pr);
expect(result.blockers).toContain("PR is still a draft");
expect(result.mergeable).toBe(false);
});
it("reports multiple blockers simultaneously", async () => {
mockGh({ mergeable: "CONFLICTING", reviewDecision: "CHANGES_REQUESTED", mergeStateStatus: "DIRTY", isDraft: true });
mockGh([{ name: "build", state: "COMPLETED", conclusion: "FAILURE" }]);
const result = await scm.getMergeability(pr);
expect(result.blockers).toHaveLength(4);
expect(result.mergeable).toBe(false);
});
});
});

View File

@ -14,6 +14,7 @@
"scripts": {
"build": "tsc",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"clean": "rm -rf dist"
},
"dependencies": {
@ -21,6 +22,7 @@
},
"devDependencies": {
"@types/node": "^25.2.3",
"typescript": "^5.7.0"
"typescript": "^5.7.0",
"vitest": "^3.0.0"
}
}

View File

@ -1 +1,321 @@
// tracker-github plugin — to be implemented
/**
* tracker-github plugin GitHub Issues as an issue tracker.
*
* Uses the `gh` CLI for all GitHub API interactions.
*/
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import type {
PluginModule,
Tracker,
Issue,
IssueFilters,
IssueUpdate,
CreateIssueInput,
ProjectConfig,
} from "@agent-orchestrator/core";
const execFileAsync = promisify(execFile);
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
async function gh(args: string[]): Promise<string> {
try {
const { stdout } = await execFileAsync("gh", args, {
maxBuffer: 10 * 1024 * 1024,
timeout: 30_000,
});
return stdout.trim();
} catch (err) {
throw new Error(`gh ${args.slice(0, 3).join(" ")} failed: ${(err as Error).message}`, {
cause: err,
});
}
}
function mapState(
ghState: string,
stateReason?: string | null,
): Issue["state"] {
const s = ghState.toUpperCase();
if (s === "CLOSED") {
if (stateReason?.toUpperCase() === "NOT_PLANNED") return "cancelled";
return "closed";
}
return "open";
}
// ---------------------------------------------------------------------------
// Tracker implementation
// ---------------------------------------------------------------------------
function createGitHubTracker(): Tracker {
return {
name: "github",
async getIssue(
identifier: string,
project: ProjectConfig,
): Promise<Issue> {
const raw = await gh([
"issue",
"view",
identifier,
"--repo",
project.repo,
"--json",
"number,title,body,url,state,stateReason,labels,assignees",
]);
const data: {
number: number;
title: string;
body: string;
url: string;
state: string;
stateReason: string | null;
labels: Array<{ name: string }>;
assignees: Array<{ login: string }>;
} = JSON.parse(raw);
return {
id: String(data.number),
title: data.title,
description: data.body ?? "",
url: data.url,
state: mapState(data.state, data.stateReason),
labels: data.labels.map((l) => l.name),
assignee: data.assignees[0]?.login,
};
},
async isCompleted(
identifier: string,
project: ProjectConfig,
): Promise<boolean> {
const raw = await gh([
"issue",
"view",
identifier,
"--repo",
project.repo,
"--json",
"state",
]);
const data: { state: string } = JSON.parse(raw);
return data.state.toUpperCase() === "CLOSED";
},
issueUrl(identifier: string, project: ProjectConfig): string {
const num = identifier.replace(/^#/, "");
return `https://github.com/${project.repo}/issues/${num}`;
},
branchName(identifier: string, _project: ProjectConfig): string {
const num = identifier.replace(/^#/, "");
return `feat/issue-${num}`;
},
async generatePrompt(
identifier: string,
project: ProjectConfig,
): Promise<string> {
const issue = await this.getIssue(identifier, project);
const lines = [
`You are working on GitHub issue #${issue.id}: ${issue.title}`,
`Issue URL: ${issue.url}`,
"",
];
if (issue.labels.length > 0) {
lines.push(`Labels: ${issue.labels.join(", ")}`);
}
if (issue.description) {
lines.push("## Description", "", issue.description);
}
lines.push(
"",
"Please implement the changes described in this issue. When done, commit and push your changes.",
);
return lines.join("\n");
},
async listIssues(
filters: IssueFilters,
project: ProjectConfig,
): Promise<Issue[]> {
const args = [
"issue",
"list",
"--repo",
project.repo,
"--json",
"number,title,body,url,state,stateReason,labels,assignees",
"--limit",
String(filters.limit ?? 30),
];
if (filters.state === "closed") {
args.push("--state", "closed");
} else if (filters.state === "all") {
args.push("--state", "all");
} else {
args.push("--state", "open");
}
if (filters.labels && filters.labels.length > 0) {
args.push("--label", filters.labels.join(","));
}
if (filters.assignee) {
args.push("--assignee", filters.assignee);
}
const raw = await gh(args);
const issues: Array<{
number: number;
title: string;
body: string;
url: string;
state: string;
stateReason: string | null;
labels: Array<{ name: string }>;
assignees: Array<{ login: string }>;
}> = JSON.parse(raw);
return issues.map((data) => ({
id: String(data.number),
title: data.title,
description: data.body ?? "",
url: data.url,
state: mapState(data.state, data.stateReason),
labels: data.labels.map((l) => l.name),
assignee: data.assignees[0]?.login,
}));
},
async updateIssue(
identifier: string,
update: IssueUpdate,
project: ProjectConfig,
): Promise<void> {
// Handle state change — GitHub Issues only supports open/closed.
// "in_progress" is not a GitHub state, so it is intentionally a no-op.
if (update.state === "closed") {
await gh([
"issue",
"close",
identifier,
"--repo",
project.repo,
]);
} else if (update.state === "open") {
await gh([
"issue",
"reopen",
identifier,
"--repo",
project.repo,
]);
}
// Handle label changes
if (update.labels && update.labels.length > 0) {
await gh([
"issue",
"edit",
identifier,
"--repo",
project.repo,
"--add-label",
update.labels.join(","),
]);
}
// Handle assignee changes
if (update.assignee) {
await gh([
"issue",
"edit",
identifier,
"--repo",
project.repo,
"--add-assignee",
update.assignee,
]);
}
// Handle comment
if (update.comment) {
await gh([
"issue",
"comment",
identifier,
"--repo",
project.repo,
"--body",
update.comment,
]);
}
},
async createIssue(
input: CreateIssueInput,
project: ProjectConfig,
): Promise<Issue> {
const args = [
"issue",
"create",
"--repo",
project.repo,
"--title",
input.title,
"--body",
input.description ?? "",
];
if (input.labels && input.labels.length > 0) {
args.push("--label", input.labels.join(","));
}
if (input.assignee) {
args.push("--assignee", input.assignee);
}
// gh issue create outputs the URL of the new issue
const url = await gh(args);
// Extract issue number from URL and fetch full details
const match = url.match(/\/issues\/(\d+)/);
if (!match) {
throw new Error(`Failed to parse issue URL from gh output: ${url}`);
}
const number = match[1];
return this.getIssue(number, project);
},
};
}
// ---------------------------------------------------------------------------
// Plugin module export
// ---------------------------------------------------------------------------
export const manifest = {
name: "github",
slot: "tracker" as const,
description: "Tracker plugin: GitHub Issues",
version: "0.1.0",
};
export function create(): Tracker {
return createGitHubTracker();
}
export default { manifest, create } satisfies PluginModule<Tracker>;

View File

@ -0,0 +1,395 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
// ---------------------------------------------------------------------------
// Mock node:child_process
// ---------------------------------------------------------------------------
const { ghMock } = vi.hoisted(() => ({ ghMock: vi.fn() }));
vi.mock("node:child_process", () => {
const execFile = Object.assign(vi.fn(), {
[Symbol.for("nodejs.util.promisify.custom")]: ghMock,
});
return { execFile };
});
import { create, manifest } from "../src/index.js";
import type { ProjectConfig } from "@agent-orchestrator/core";
// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------
const project: ProjectConfig = {
name: "test",
repo: "acme/repo",
path: "/tmp/repo",
defaultBranch: "main",
sessionPrefix: "test",
};
function mockGh(result: unknown) {
ghMock.mockResolvedValueOnce({ stdout: JSON.stringify(result) });
}
function mockGhRaw(stdout: string) {
ghMock.mockResolvedValueOnce({ stdout });
}
function mockGhError(msg = "Command failed") {
ghMock.mockRejectedValueOnce(new Error(msg));
}
const sampleIssue = {
number: 123,
title: "Fix login bug",
body: "Users can't log in with SSO",
url: "https://github.com/acme/repo/issues/123",
state: "OPEN",
stateReason: null as string | null,
labels: [{ name: "bug" }, { name: "priority-high" }],
assignees: [{ login: "alice" }],
};
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("tracker-github plugin", () => {
let tracker: ReturnType<typeof create>;
beforeEach(() => {
vi.clearAllMocks();
tracker = create();
});
// ---- manifest ----------------------------------------------------------
describe("manifest", () => {
it("has correct metadata", () => {
expect(manifest.name).toBe("github");
expect(manifest.slot).toBe("tracker");
expect(manifest.version).toBe("0.1.0");
});
});
describe("create()", () => {
it("returns a Tracker with correct name", () => {
expect(tracker.name).toBe("github");
});
});
// ---- getIssue ----------------------------------------------------------
describe("getIssue", () => {
it("returns Issue with correct fields", async () => {
mockGh(sampleIssue);
const issue = await tracker.getIssue("123", project);
expect(issue).toEqual({
id: "123",
title: "Fix login bug",
description: "Users can't log in with SSO",
url: "https://github.com/acme/repo/issues/123",
state: "open",
labels: ["bug", "priority-high"],
assignee: "alice",
});
});
it("maps CLOSED state to closed", async () => {
mockGh({ ...sampleIssue, state: "CLOSED", stateReason: "COMPLETED" });
const issue = await tracker.getIssue("123", project);
expect(issue.state).toBe("closed");
});
it("maps NOT_PLANNED close reason to cancelled", async () => {
mockGh({ ...sampleIssue, state: "CLOSED", stateReason: "NOT_PLANNED" });
const issue = await tracker.getIssue("123", project);
expect(issue.state).toBe("cancelled");
});
it("handles missing body gracefully", async () => {
mockGh({ ...sampleIssue, body: null });
const issue = await tracker.getIssue("123", project);
expect(issue.description).toBe("");
});
it("handles empty assignees", async () => {
mockGh({ ...sampleIssue, assignees: [] });
const issue = await tracker.getIssue("123", project);
expect(issue.assignee).toBeUndefined();
});
it("propagates gh CLI errors", async () => {
mockGhError("issue not found");
await expect(tracker.getIssue("999", project)).rejects.toThrow("issue not found");
});
it("throws on malformed JSON response", async () => {
ghMock.mockResolvedValueOnce({ stdout: "not json{" });
await expect(tracker.getIssue("123", project)).rejects.toThrow();
});
});
// ---- isCompleted -------------------------------------------------------
describe("isCompleted", () => {
it("returns true for CLOSED issues", async () => {
mockGh({ state: "CLOSED" });
expect(await tracker.isCompleted("123", project)).toBe(true);
});
it("returns false for OPEN issues", async () => {
mockGh({ state: "OPEN" });
expect(await tracker.isCompleted("123", project)).toBe(false);
});
it("handles lowercase state", async () => {
mockGh({ state: "closed" });
expect(await tracker.isCompleted("123", project)).toBe(true);
});
});
// ---- issueUrl ----------------------------------------------------------
describe("issueUrl", () => {
it("generates correct URL", () => {
expect(tracker.issueUrl("42", project)).toBe("https://github.com/acme/repo/issues/42");
});
it("strips # prefix from identifier", () => {
expect(tracker.issueUrl("#42", project)).toBe("https://github.com/acme/repo/issues/42");
});
});
// ---- branchName --------------------------------------------------------
describe("branchName", () => {
it("generates feat/issue-N format", () => {
expect(tracker.branchName("42", project)).toBe("feat/issue-42");
});
it("strips # prefix", () => {
expect(tracker.branchName("#42", project)).toBe("feat/issue-42");
});
});
// ---- generatePrompt ----------------------------------------------------
describe("generatePrompt", () => {
it("includes title and URL", async () => {
mockGh(sampleIssue);
const prompt = await tracker.generatePrompt("123", project);
expect(prompt).toContain("Fix login bug");
expect(prompt).toContain("https://github.com/acme/repo/issues/123");
expect(prompt).toContain("GitHub issue #123");
});
it("includes labels when present", async () => {
mockGh(sampleIssue);
const prompt = await tracker.generatePrompt("123", project);
expect(prompt).toContain("bug, priority-high");
});
it("includes description", async () => {
mockGh(sampleIssue);
const prompt = await tracker.generatePrompt("123", project);
expect(prompt).toContain("Users can't log in with SSO");
});
it("omits labels section when no labels", async () => {
mockGh({ ...sampleIssue, labels: [] });
const prompt = await tracker.generatePrompt("123", project);
expect(prompt).not.toContain("Labels:");
});
it("omits description section when body is empty", async () => {
mockGh({ ...sampleIssue, body: null });
const prompt = await tracker.generatePrompt("123", project);
expect(prompt).not.toContain("## Description");
});
});
// ---- listIssues --------------------------------------------------------
describe("listIssues", () => {
it("returns mapped issues", async () => {
mockGh([sampleIssue, { ...sampleIssue, number: 456, title: "Another" }]);
const issues = await tracker.listIssues!({}, project);
expect(issues).toHaveLength(2);
expect(issues[0].id).toBe("123");
expect(issues[1].id).toBe("456");
});
it("passes state filter for closed issues", async () => {
mockGh([]);
await tracker.listIssues!({ state: "closed" }, project);
expect(ghMock).toHaveBeenCalledWith(
"gh",
expect.arrayContaining(["--state", "closed"]),
expect.any(Object),
);
});
it("passes state filter for all issues", async () => {
mockGh([]);
await tracker.listIssues!({ state: "all" }, project);
expect(ghMock).toHaveBeenCalledWith(
"gh",
expect.arrayContaining(["--state", "all"]),
expect.any(Object),
);
});
it("defaults to open state", async () => {
mockGh([]);
await tracker.listIssues!({}, project);
expect(ghMock).toHaveBeenCalledWith(
"gh",
expect.arrayContaining(["--state", "open"]),
expect.any(Object),
);
});
it("passes label filter", async () => {
mockGh([]);
await tracker.listIssues!({ labels: ["bug", "urgent"] }, project);
expect(ghMock).toHaveBeenCalledWith(
"gh",
expect.arrayContaining(["--label", "bug,urgent"]),
expect.any(Object),
);
});
it("passes assignee filter", async () => {
mockGh([]);
await tracker.listIssues!({ assignee: "alice" }, project);
expect(ghMock).toHaveBeenCalledWith(
"gh",
expect.arrayContaining(["--assignee", "alice"]),
expect.any(Object),
);
});
it("respects custom limit", async () => {
mockGh([]);
await tracker.listIssues!({ limit: 5 }, project);
expect(ghMock).toHaveBeenCalledWith(
"gh",
expect.arrayContaining(["--limit", "5"]),
expect.any(Object),
);
});
});
// ---- updateIssue -------------------------------------------------------
describe("updateIssue", () => {
it("closes an issue", async () => {
ghMock.mockResolvedValueOnce({ stdout: "" });
await tracker.updateIssue!("123", { state: "closed" }, project);
expect(ghMock).toHaveBeenCalledWith(
"gh",
["issue", "close", "123", "--repo", "acme/repo"],
expect.any(Object),
);
});
it("reopens an issue", async () => {
ghMock.mockResolvedValueOnce({ stdout: "" });
await tracker.updateIssue!("123", { state: "open" }, project);
expect(ghMock).toHaveBeenCalledWith(
"gh",
["issue", "reopen", "123", "--repo", "acme/repo"],
expect.any(Object),
);
});
it("adds labels", async () => {
ghMock.mockResolvedValueOnce({ stdout: "" });
await tracker.updateIssue!("123", { labels: ["bug", "urgent"] }, project);
expect(ghMock).toHaveBeenCalledWith(
"gh",
["issue", "edit", "123", "--repo", "acme/repo", "--add-label", "bug,urgent"],
expect.any(Object),
);
});
it("adds assignee", async () => {
ghMock.mockResolvedValueOnce({ stdout: "" });
await tracker.updateIssue!("123", { assignee: "bob" }, project);
expect(ghMock).toHaveBeenCalledWith(
"gh",
["issue", "edit", "123", "--repo", "acme/repo", "--add-assignee", "bob"],
expect.any(Object),
);
});
it("adds comment", async () => {
ghMock.mockResolvedValueOnce({ stdout: "" });
await tracker.updateIssue!("123", { comment: "Working on this" }, project);
expect(ghMock).toHaveBeenCalledWith(
"gh",
["issue", "comment", "123", "--repo", "acme/repo", "--body", "Working on this"],
expect.any(Object),
);
});
it("handles multiple updates in one call", async () => {
ghMock.mockResolvedValue({ stdout: "" });
await tracker.updateIssue!("123", { state: "closed", labels: ["done"], comment: "Done!" }, project);
// Should have called gh 3 times: close + edit labels + comment
expect(ghMock).toHaveBeenCalledTimes(3);
});
});
// ---- createIssue -------------------------------------------------------
describe("createIssue", () => {
it("creates an issue and fetches full details", async () => {
// First call: gh issue create returns URL
mockGhRaw("https://github.com/acme/repo/issues/999\n");
// Second call: getIssue fetches the created issue
mockGh({
number: 999,
title: "New issue",
body: "Description",
url: "https://github.com/acme/repo/issues/999",
state: "OPEN",
stateReason: null,
labels: [],
assignees: [],
});
const issue = await tracker.createIssue!({ title: "New issue", description: "Description" }, project);
expect(issue).toMatchObject({ id: "999", title: "New issue", state: "open" });
});
it("passes labels and assignee to gh issue create", async () => {
mockGhRaw("https://github.com/acme/repo/issues/1000\n");
mockGh({
number: 1000,
title: "Bug",
body: "Crash",
url: "https://github.com/acme/repo/issues/1000",
state: "OPEN",
stateReason: null,
labels: [{ name: "bug" }],
assignees: [{ login: "alice" }],
});
await tracker.createIssue!({ title: "Bug", description: "Crash", labels: ["bug"], assignee: "alice" }, project);
expect(ghMock).toHaveBeenCalledWith(
"gh",
expect.arrayContaining(["issue", "create", "--label", "bug", "--assignee", "alice"]),
expect.any(Object),
);
});
it("throws when URL cannot be parsed from gh output", async () => {
mockGhRaw("unexpected output");
await expect(
tracker.createIssue!({ title: "Test", description: "" }, project),
).rejects.toThrow("Failed to parse issue URL");
});
});
});

View File

@ -14,6 +14,7 @@
"scripts": {
"build": "tsc",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"clean": "rm -rf dist"
},
"dependencies": {
@ -21,6 +22,7 @@
},
"devDependencies": {
"@types/node": "^25.2.3",
"typescript": "^5.7.0"
"typescript": "^5.7.0",
"vitest": "^3.0.0"
}
}

View File

@ -0,0 +1,23 @@
/**
* Minimal type declarations for @composio/core (optional peer dependency).
* Only the subset used by the Composio transport is declared here.
*/
declare module "@composio/core" {
interface ComposioExecuteResult {
data?: Record<string, unknown>;
error?: string;
successful?: boolean;
}
interface ComposioTools {
execute(
action: string,
params: Record<string, unknown>,
): Promise<ComposioExecuteResult>;
}
export class Composio {
constructor(opts: { apiKey: string });
tools: ComposioTools;
}
}

View File

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

View File

@ -0,0 +1,350 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
// ---------------------------------------------------------------------------
// Mock @composio/core
// ---------------------------------------------------------------------------
const { mockExecute, MockComposio } = vi.hoisted(() => {
const mockExecute = vi.fn();
const MockComposio = vi.fn().mockImplementation(() => ({
tools: { execute: mockExecute },
}));
return { mockExecute, MockComposio };
});
vi.mock("@composio/core", () => ({
Composio: MockComposio,
}));
import { create } from "../src/index.js";
import type { ProjectConfig } from "@agent-orchestrator/core";
// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------
const project: ProjectConfig = {
name: "test",
repo: "acme/integrator",
path: "/tmp/repo",
defaultBranch: "main",
sessionPrefix: "test",
tracker: {
plugin: "linear",
teamId: "team-uuid-1",
workspaceSlug: "acme",
},
};
const sampleIssueNode = {
id: "uuid-123",
identifier: "INT-123",
title: "Fix login bug",
description: "Users can't log in with SSO",
url: "https://linear.app/acme/issue/INT-123",
priority: 2,
state: { name: "In Progress", type: "started" },
labels: { nodes: [{ name: "bug" }, { name: "high-priority" }] },
assignee: { name: "Alice Smith", displayName: "Alice" },
team: { key: "INT" },
};
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function mockComposioResponse(data: unknown) {
mockExecute.mockResolvedValueOnce({
data,
successful: true,
});
}
function mockComposioError(error: string) {
mockExecute.mockResolvedValueOnce({
error,
successful: false,
});
}
// ---------------------------------------------------------------------------
// Environment helpers
// ---------------------------------------------------------------------------
let savedComposioKey: string | undefined;
let savedEntityId: string | undefined;
let savedLinearKey: string | undefined;
function saveEnv() {
savedComposioKey = process.env["COMPOSIO_API_KEY"];
savedEntityId = process.env["COMPOSIO_ENTITY_ID"];
savedLinearKey = process.env["LINEAR_API_KEY"];
}
function restoreEnv() {
if (savedComposioKey === undefined) {
delete process.env["COMPOSIO_API_KEY"];
} else {
process.env["COMPOSIO_API_KEY"] = savedComposioKey;
}
if (savedEntityId === undefined) {
delete process.env["COMPOSIO_ENTITY_ID"];
} else {
process.env["COMPOSIO_ENTITY_ID"] = savedEntityId;
}
if (savedLinearKey === undefined) {
delete process.env["LINEAR_API_KEY"];
} else {
process.env["LINEAR_API_KEY"] = savedLinearKey;
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("tracker-linear Composio transport", () => {
beforeEach(() => {
vi.clearAllMocks();
saveEnv();
// Set Composio key, remove Linear key
process.env["COMPOSIO_API_KEY"] = "composio_test_key";
delete process.env["LINEAR_API_KEY"];
delete process.env["COMPOSIO_ENTITY_ID"];
});
afterEach(() => {
restoreEnv();
});
// ---- Auto-detection ---------------------------------------------------
describe("transport auto-detection", () => {
it("uses Composio transport when COMPOSIO_API_KEY is set", async () => {
mockComposioResponse({ issue: sampleIssueNode });
const tracker = create();
const issue = await tracker.getIssue("INT-123", project);
expect(issue.id).toBe("INT-123");
expect(MockComposio).toHaveBeenCalledWith({ apiKey: "composio_test_key" });
expect(mockExecute).toHaveBeenCalledTimes(1);
});
it("prefers COMPOSIO_API_KEY over LINEAR_API_KEY when both set", async () => {
process.env["LINEAR_API_KEY"] = "lin_api_test_key";
mockComposioResponse({ issue: sampleIssueNode });
const tracker = create();
await tracker.getIssue("INT-123", project);
// Should use Composio, not direct
expect(MockComposio).toHaveBeenCalled();
expect(mockExecute).toHaveBeenCalled();
});
});
// ---- Entity ID --------------------------------------------------------
describe("entity ID", () => {
it("defaults entity ID to 'default'", async () => {
mockComposioResponse({ issue: sampleIssueNode });
const tracker = create();
await tracker.getIssue("INT-123", project);
expect(mockExecute).toHaveBeenCalledWith(
"LINEAR_RUN_QUERY_OR_MUTATION",
expect.objectContaining({ entityId: "default" }),
);
});
it("uses COMPOSIO_ENTITY_ID env var when set", async () => {
process.env["COMPOSIO_ENTITY_ID"] = "my-entity";
mockComposioResponse({ issue: sampleIssueNode });
const tracker = create();
await tracker.getIssue("INT-123", project);
expect(mockExecute).toHaveBeenCalledWith(
"LINEAR_RUN_QUERY_OR_MUTATION",
expect.objectContaining({ entityId: "my-entity" }),
);
});
});
// ---- Successful queries -----------------------------------------------
describe("successful queries", () => {
it("returns correct Issue from getIssue", async () => {
mockComposioResponse({ issue: sampleIssueNode });
const tracker = create();
const issue = await tracker.getIssue("INT-123", project);
expect(issue).toEqual({
id: "INT-123",
title: "Fix login bug",
description: "Users can't log in with SSO",
url: "https://linear.app/acme/issue/INT-123",
state: "in_progress",
labels: ["bug", "high-priority"],
assignee: "Alice",
priority: 2,
});
});
it("passes query and variables to Composio execute", async () => {
mockComposioResponse({ issue: sampleIssueNode });
const tracker = create();
await tracker.getIssue("INT-123", project);
const call = mockExecute.mock.calls[0];
expect(call[0]).toBe("LINEAR_RUN_QUERY_OR_MUTATION");
const params = call[1] as Record<string, unknown>;
const args = params["arguments"] as Record<string, string>;
expect(args["query_or_mutation"]).toContain("query($id: String!)");
expect(JSON.parse(args["variables"])).toEqual({ id: "INT-123" });
});
it("serializes empty variables as '{}'", async () => {
mockComposioResponse({ issue: { state: { type: "completed" } } });
const tracker = create();
// isCompleted passes variables, but let's verify with listIssues which
// also always passes variables. Instead, let's test a simpler case.
await tracker.isCompleted("INT-123", project);
const call = mockExecute.mock.calls[0];
const params = call[1] as Record<string, unknown>;
const args = params["arguments"] as Record<string, string>;
// Should be valid JSON
expect(() => JSON.parse(args["variables"])).not.toThrow();
});
it("works with listIssues", async () => {
mockComposioResponse({
issues: {
nodes: [sampleIssueNode],
},
});
const tracker = create();
const issues = await tracker.listIssues!({}, project);
expect(issues).toHaveLength(1);
expect(issues[0].id).toBe("INT-123");
});
it("works with isCompleted", async () => {
mockComposioResponse({ issue: { state: { type: "completed" } } });
const tracker = create();
const result = await tracker.isCompleted("INT-123", project);
expect(result).toBe(true);
});
});
// ---- Error handling ---------------------------------------------------
describe("error handling", () => {
it("throws on unsuccessful response", async () => {
mockComposioError("Authentication failed");
const tracker = create();
await expect(tracker.getIssue("INT-123", project)).rejects.toThrow(
"Composio Linear API error: Authentication failed",
);
});
it("throws with 'unknown error' when error field is missing", async () => {
mockExecute.mockResolvedValueOnce({
successful: false,
});
const tracker = create();
await expect(tracker.getIssue("INT-123", project)).rejects.toThrow(
"Composio Linear API error: unknown error",
);
});
it("throws when response has no data", async () => {
mockExecute.mockResolvedValueOnce({
successful: true,
data: undefined,
});
const tracker = create();
await expect(tracker.getIssue("INT-123", project)).rejects.toThrow(
"Composio Linear API returned no data",
);
});
it("propagates execute rejections", async () => {
mockExecute.mockRejectedValueOnce(new Error("Network error"));
const tracker = create();
await expect(tracker.getIssue("INT-123", project)).rejects.toThrow(
"Network error",
);
});
});
// ---- Client caching ---------------------------------------------------
describe("client caching", () => {
it("creates Composio client only once across multiple queries", async () => {
mockComposioResponse({ issue: sampleIssueNode });
mockComposioResponse({ issue: { state: { type: "started" } } });
mockComposioResponse({
issues: { nodes: [sampleIssueNode] },
});
const tracker = create();
await tracker.getIssue("INT-123", project);
await tracker.isCompleted("INT-123", project);
await tracker.listIssues!({}, project);
// Composio constructor should be called exactly once
expect(MockComposio).toHaveBeenCalledTimes(1);
// But execute should be called 3 times
expect(mockExecute).toHaveBeenCalledTimes(3);
});
});
// ---- Timeout ----------------------------------------------------------
describe("timeout", () => {
it("times out after 30s", async () => {
// Pre-warm the client so import() resolves before we switch to fake timers.
mockComposioResponse({ issue: sampleIssueNode });
const tracker = create();
await tracker.getIssue("INT-123", project);
// Now switch to fake timers
vi.useFakeTimers();
// Vitest's fake timers fire the setTimeout callback synchronously
// during advanceTimersByTimeAsync, before the microtask queue can
// process the .catch() handler on timeoutPromise. Suppress the
// transient unhandled rejection that vitest detects in that window.
const suppressed: unknown[] = [];
const handler = (reason: unknown) => { suppressed.push(reason); };
process.on("unhandledRejection", handler);
try {
// Make execute hang forever
mockExecute.mockImplementationOnce(
() => new Promise(() => {}), // never resolves
);
const promise = tracker.getIssue("INT-123", project);
// Advance timers past the 30s timeout
await vi.advanceTimersByTimeAsync(30_001);
await expect(promise).rejects.toThrow(
"Composio Linear API request timed out after 30s",
);
} finally {
process.removeListener("unhandledRejection", handler);
vi.useRealTimers();
}
});
});
});

View File

@ -0,0 +1,866 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { EventEmitter } from "node:events";
// ---------------------------------------------------------------------------
// Mock node:https
// ---------------------------------------------------------------------------
const { requestMock } = vi.hoisted(() => ({ requestMock: vi.fn() }));
vi.mock("node:https", () => ({
request: requestMock,
}));
import { create, manifest } from "../src/index.js";
import type { ProjectConfig } from "@agent-orchestrator/core";
// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------
const project: ProjectConfig = {
name: "test",
repo: "acme/integrator",
path: "/tmp/repo",
defaultBranch: "main",
sessionPrefix: "test",
tracker: {
plugin: "linear",
teamId: "team-uuid-1",
workspaceSlug: "acme",
},
};
const projectNoSlug: ProjectConfig = {
...project,
tracker: { plugin: "linear", teamId: "team-uuid-1" },
};
const sampleIssueNode = {
id: "uuid-123",
identifier: "INT-123",
title: "Fix login bug",
description: "Users can't log in with SSO",
url: "https://linear.app/acme/issue/INT-123",
priority: 2,
state: { name: "In Progress", type: "started" },
labels: { nodes: [{ name: "bug" }, { name: "high-priority" }] },
assignee: { name: "Alice Smith", displayName: "Alice" },
team: { key: "INT" },
};
// ---------------------------------------------------------------------------
// Mock helpers
// ---------------------------------------------------------------------------
/**
* Queue a successful Linear API response.
* Each call to linearQuery() will consume the next queued response.
*/
function mockLinearAPI(responseData: unknown, statusCode = 200) {
const body = JSON.stringify({ data: responseData });
requestMock.mockImplementationOnce(
(_opts: Record<string, unknown>, callback: (res: EventEmitter & { statusCode: number }) => void) => {
const req = Object.assign(new EventEmitter(), {
write: vi.fn(),
end: vi.fn(() => {
const res = Object.assign(new EventEmitter(), { statusCode });
callback(res);
process.nextTick(() => {
res.emit("data", Buffer.from(body));
res.emit("end");
});
}),
destroy: vi.fn(),
setTimeout: vi.fn(),
});
return req;
},
);
}
/** Queue a Linear API error response (GraphQL errors array). */
function mockLinearError(message: string) {
const body = JSON.stringify({ errors: [{ message }] });
requestMock.mockImplementationOnce(
(_opts: Record<string, unknown>, callback: (res: EventEmitter & { statusCode: number }) => void) => {
const req = Object.assign(new EventEmitter(), {
write: vi.fn(),
end: vi.fn(() => {
const res = Object.assign(new EventEmitter(), { statusCode: 200 });
callback(res);
process.nextTick(() => {
res.emit("data", Buffer.from(body));
res.emit("end");
});
}),
destroy: vi.fn(),
setTimeout: vi.fn(),
});
return req;
},
);
}
/** Queue an HTTP-level error (non-200 status). */
function mockHTTPError(statusCode: number, body: string) {
requestMock.mockImplementationOnce(
(_opts: Record<string, unknown>, callback: (res: EventEmitter & { statusCode: number }) => void) => {
const req = Object.assign(new EventEmitter(), {
write: vi.fn(),
end: vi.fn(() => {
const res = Object.assign(new EventEmitter(), { statusCode });
callback(res);
process.nextTick(() => {
res.emit("data", Buffer.from(body));
res.emit("end");
});
}),
destroy: vi.fn(),
setTimeout: vi.fn(),
});
return req;
},
);
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("tracker-linear plugin", () => {
let tracker: ReturnType<typeof create>;
let savedApiKey: string | undefined;
beforeEach(() => {
vi.clearAllMocks();
savedApiKey = process.env["LINEAR_API_KEY"];
process.env["LINEAR_API_KEY"] = "lin_api_test_key";
tracker = create();
});
afterEach(() => {
if (savedApiKey === undefined) {
delete process.env["LINEAR_API_KEY"];
} else {
process.env["LINEAR_API_KEY"] = savedApiKey;
}
});
// ---- manifest ----------------------------------------------------------
describe("manifest", () => {
it("has correct metadata", () => {
expect(manifest.name).toBe("linear");
expect(manifest.slot).toBe("tracker");
expect(manifest.version).toBe("0.1.0");
});
});
describe("create()", () => {
it("returns a Tracker with correct name", () => {
expect(tracker.name).toBe("linear");
});
});
// ---- getIssue ----------------------------------------------------------
describe("getIssue", () => {
it("returns Issue with correct fields", async () => {
mockLinearAPI({ issue: sampleIssueNode });
const issue = await tracker.getIssue("INT-123", project);
expect(issue).toEqual({
id: "INT-123",
title: "Fix login bug",
description: "Users can't log in with SSO",
url: "https://linear.app/acme/issue/INT-123",
state: "in_progress",
labels: ["bug", "high-priority"],
assignee: "Alice",
priority: 2,
});
});
it("maps completed state to closed", async () => {
mockLinearAPI({
issue: { ...sampleIssueNode, state: { name: "Done", type: "completed" } },
});
const issue = await tracker.getIssue("INT-123", project);
expect(issue.state).toBe("closed");
});
it("maps canceled state to cancelled", async () => {
mockLinearAPI({
issue: { ...sampleIssueNode, state: { name: "Canceled", type: "canceled" } },
});
const issue = await tracker.getIssue("INT-123", project);
expect(issue.state).toBe("cancelled");
});
it("maps backlog/triage/unstarted to open", async () => {
for (const type of ["backlog", "triage", "unstarted"]) {
mockLinearAPI({
issue: { ...sampleIssueNode, state: { name: type, type } },
});
const issue = await tracker.getIssue("INT-123", project);
expect(issue.state).toBe("open");
}
});
it("handles null description", async () => {
mockLinearAPI({
issue: { ...sampleIssueNode, description: null },
});
const issue = await tracker.getIssue("INT-123", project);
expect(issue.description).toBe("");
});
it("handles null assignee", async () => {
mockLinearAPI({
issue: { ...sampleIssueNode, assignee: null },
});
const issue = await tracker.getIssue("INT-123", project);
expect(issue.assignee).toBeUndefined();
});
it("uses assignee name as fallback when displayName is missing", async () => {
mockLinearAPI({
issue: {
...sampleIssueNode,
assignee: { name: "Alice Smith", displayName: undefined },
},
});
const issue = await tracker.getIssue("INT-123", project);
// undefined displayName falls through to name via ??
expect(issue.assignee).toBe("Alice Smith");
});
it("handles empty labels", async () => {
mockLinearAPI({
issue: { ...sampleIssueNode, labels: { nodes: [] } },
});
const issue = await tracker.getIssue("INT-123", project);
expect(issue.labels).toEqual([]);
});
it("propagates API errors", async () => {
mockLinearError("Issue not found");
await expect(tracker.getIssue("INT-999", project)).rejects.toThrow(
"Linear API error: Issue not found",
);
});
it("throws when LINEAR_API_KEY is missing", async () => {
delete process.env["LINEAR_API_KEY"];
await expect(tracker.getIssue("INT-123", project)).rejects.toThrow(
"LINEAR_API_KEY environment variable is required",
);
});
it("throws on HTTP errors", async () => {
mockHTTPError(500, "Internal Server Error");
await expect(tracker.getIssue("INT-123", project)).rejects.toThrow(
"Linear API returned HTTP 500",
);
});
});
// ---- isCompleted -------------------------------------------------------
describe("isCompleted", () => {
it("returns true for completed state", async () => {
mockLinearAPI({ issue: { state: { type: "completed" } } });
expect(await tracker.isCompleted("INT-123", project)).toBe(true);
});
it("returns true for canceled state", async () => {
mockLinearAPI({ issue: { state: { type: "canceled" } } });
expect(await tracker.isCompleted("INT-123", project)).toBe(true);
});
it("returns false for started state", async () => {
mockLinearAPI({ issue: { state: { type: "started" } } });
expect(await tracker.isCompleted("INT-123", project)).toBe(false);
});
it("returns false for unstarted state", async () => {
mockLinearAPI({ issue: { state: { type: "unstarted" } } });
expect(await tracker.isCompleted("INT-123", project)).toBe(false);
});
});
// ---- issueUrl ----------------------------------------------------------
describe("issueUrl", () => {
it("generates correct URL with workspace slug", () => {
expect(tracker.issueUrl("INT-123", project)).toBe(
"https://linear.app/acme/issue/INT-123",
);
});
it("generates fallback URL without workspace slug", () => {
expect(tracker.issueUrl("INT-123", projectNoSlug)).toBe(
"https://linear.app/issue/INT-123",
);
});
it("generates fallback URL when no tracker config", () => {
const noTracker: ProjectConfig = { ...project, tracker: undefined };
expect(tracker.issueUrl("INT-123", noTracker)).toBe(
"https://linear.app/issue/INT-123",
);
});
});
// ---- branchName --------------------------------------------------------
describe("branchName", () => {
it("generates feat/ prefix branch name", () => {
expect(tracker.branchName("INT-123", project)).toBe("feat/INT-123");
});
});
// ---- generatePrompt ----------------------------------------------------
describe("generatePrompt", () => {
it("includes title, URL, and description", async () => {
mockLinearAPI({ issue: sampleIssueNode });
const prompt = await tracker.generatePrompt("INT-123", project);
expect(prompt).toContain("INT-123");
expect(prompt).toContain("Fix login bug");
expect(prompt).toContain("https://linear.app/acme/issue/INT-123");
expect(prompt).toContain("Users can't log in with SSO");
});
it("includes labels when present", async () => {
mockLinearAPI({ issue: sampleIssueNode });
const prompt = await tracker.generatePrompt("INT-123", project);
expect(prompt).toContain("bug, high-priority");
});
it("includes priority", async () => {
mockLinearAPI({ issue: sampleIssueNode });
const prompt = await tracker.generatePrompt("INT-123", project);
expect(prompt).toContain("High");
});
it("maps priority numbers to names", async () => {
const priorities: Record<number, string> = {
0: "No priority",
1: "Urgent",
2: "High",
3: "Normal",
4: "Low",
};
for (const [num, name] of Object.entries(priorities)) {
mockLinearAPI({
issue: { ...sampleIssueNode, priority: Number(num) },
});
const prompt = await tracker.generatePrompt("INT-123", project);
expect(prompt).toContain(name);
}
});
it("omits description section when empty", async () => {
mockLinearAPI({
issue: { ...sampleIssueNode, description: null },
});
const prompt = await tracker.generatePrompt("INT-123", project);
expect(prompt).not.toContain("## Description");
});
it("omits labels line when no labels", async () => {
mockLinearAPI({
issue: { ...sampleIssueNode, labels: { nodes: [] } },
});
const prompt = await tracker.generatePrompt("INT-123", project);
expect(prompt).not.toContain("Labels:");
});
});
// ---- listIssues --------------------------------------------------------
describe("listIssues", () => {
it("returns mapped issues", async () => {
mockLinearAPI({
issues: {
nodes: [
sampleIssueNode,
{ ...sampleIssueNode, identifier: "INT-456", title: "Another" },
],
},
});
const issues = await tracker.listIssues!({}, project);
expect(issues).toHaveLength(2);
expect(issues[0].id).toBe("INT-123");
expect(issues[1].id).toBe("INT-456");
});
it("passes state filter for open issues", async () => {
mockLinearAPI({ issues: { nodes: [] } });
await tracker.listIssues!({ state: "open" }, project);
// Verify the request body contains the correct filter
const writeCall = requestMock.mock.results[0].value.write.mock.calls[0][0];
const body = JSON.parse(writeCall);
expect(body.variables.filter.state).toEqual({
type: { nin: ["completed", "canceled"] },
});
});
it("passes state filter for closed issues", async () => {
mockLinearAPI({ issues: { nodes: [] } });
await tracker.listIssues!({ state: "closed" }, project);
const writeCall = requestMock.mock.results[0].value.write.mock.calls[0][0];
const body = JSON.parse(writeCall);
expect(body.variables.filter.state).toEqual({
type: { in: ["completed", "canceled"] },
});
});
it("defaults to open state when no state specified", async () => {
mockLinearAPI({ issues: { nodes: [] } });
await tracker.listIssues!({}, project);
const writeCall = requestMock.mock.results[0].value.write.mock.calls[0][0];
const body = JSON.parse(writeCall);
expect(body.variables.filter.state).toEqual({
type: { nin: ["completed", "canceled"] },
});
});
it("passes assignee filter", async () => {
mockLinearAPI({ issues: { nodes: [] } });
await tracker.listIssues!({ assignee: "Alice" }, project);
const writeCall = requestMock.mock.results[0].value.write.mock.calls[0][0];
const body = JSON.parse(writeCall);
expect(body.variables.filter.assignee).toEqual({
displayName: { eq: "Alice" },
});
});
it("passes labels filter", async () => {
mockLinearAPI({ issues: { nodes: [] } });
await tracker.listIssues!({ labels: ["bug", "urgent"] }, project);
const writeCall = requestMock.mock.results[0].value.write.mock.calls[0][0];
const body = JSON.parse(writeCall);
expect(body.variables.filter.labels).toEqual({
name: { in: ["bug", "urgent"] },
});
});
it("passes team filter from project config", async () => {
mockLinearAPI({ issues: { nodes: [] } });
await tracker.listIssues!({}, project);
const writeCall = requestMock.mock.results[0].value.write.mock.calls[0][0];
const body = JSON.parse(writeCall);
expect(body.variables.filter.team).toEqual({
id: { eq: "team-uuid-1" },
});
});
it("respects custom limit", async () => {
mockLinearAPI({ issues: { nodes: [] } });
await tracker.listIssues!({ limit: 5 }, project);
const writeCall = requestMock.mock.results[0].value.write.mock.calls[0][0];
const body = JSON.parse(writeCall);
expect(body.variables.first).toBe(5);
});
it("defaults limit to 30", async () => {
mockLinearAPI({ issues: { nodes: [] } });
await tracker.listIssues!({}, project);
const writeCall = requestMock.mock.results[0].value.write.mock.calls[0][0];
const body = JSON.parse(writeCall);
expect(body.variables.first).toBe(30);
});
});
// ---- updateIssue -------------------------------------------------------
describe("updateIssue", () => {
const workflowStates = {
workflowStates: {
nodes: [
{ id: "state-1", name: "Todo", type: "unstarted" },
{ id: "state-2", name: "In Progress", type: "started" },
{ id: "state-3", name: "Done", type: "completed" },
],
},
};
it("changes state to closed (completed)", async () => {
// 1st: resolve identifier to UUID
mockLinearAPI({ issue: { id: "uuid-123", team: { id: "team-1" } } });
// 2nd: fetch workflow states
mockLinearAPI(workflowStates);
// 3rd: issueUpdate mutation
mockLinearAPI({ issueUpdate: { success: true } });
await tracker.updateIssue!("INT-123", { state: "closed" }, project);
expect(requestMock).toHaveBeenCalledTimes(3);
});
it("changes state to open (unstarted)", async () => {
mockLinearAPI({ issue: { id: "uuid-123", team: { id: "team-1" } } });
mockLinearAPI(workflowStates);
mockLinearAPI({ issueUpdate: { success: true } });
await tracker.updateIssue!("INT-123", { state: "open" }, project);
// Verify the mutation uses the unstarted state ID
const writeCall = requestMock.mock.results[2].value.write.mock.calls[0][0];
const body = JSON.parse(writeCall);
expect(body.variables.stateId).toBe("state-1");
});
it("changes state to in_progress (started)", async () => {
mockLinearAPI({ issue: { id: "uuid-123", team: { id: "team-1" } } });
mockLinearAPI(workflowStates);
mockLinearAPI({ issueUpdate: { success: true } });
await tracker.updateIssue!("INT-123", { state: "in_progress" }, project);
const writeCall = requestMock.mock.results[2].value.write.mock.calls[0][0];
const body = JSON.parse(writeCall);
expect(body.variables.stateId).toBe("state-2");
});
it("throws when target workflow state is not found", async () => {
mockLinearAPI({ issue: { id: "uuid-123", team: { id: "team-1" } } });
// Return states without "completed"
mockLinearAPI({
workflowStates: {
nodes: [{ id: "state-1", name: "Todo", type: "unstarted" }],
},
});
await expect(
tracker.updateIssue!("INT-123", { state: "closed" }, project),
).rejects.toThrow('No workflow state of type "completed"');
});
it("adds a comment", async () => {
mockLinearAPI({ issue: { id: "uuid-123", team: { id: "team-1" } } });
mockLinearAPI({ commentCreate: { success: true } });
await tracker.updateIssue!(
"INT-123",
{ comment: "Working on this" },
project,
);
expect(requestMock).toHaveBeenCalledTimes(2);
// Verify comment body
const writeCall = requestMock.mock.results[1].value.write.mock.calls[0][0];
const body = JSON.parse(writeCall);
expect(body.variables.body).toBe("Working on this");
});
it("handles state change + comment together", async () => {
// 1: resolve identifier
mockLinearAPI({ issue: { id: "uuid-123", team: { id: "team-1" } } });
// 2: workflow states
mockLinearAPI(workflowStates);
// 3: issueUpdate (state)
mockLinearAPI({ issueUpdate: { success: true } });
// 4: commentCreate
mockLinearAPI({ commentCreate: { success: true } });
await tracker.updateIssue!(
"INT-123",
{ state: "closed", comment: "Done!" },
project,
);
expect(requestMock).toHaveBeenCalledTimes(4);
});
it("updates assignee by resolving display name to ID", async () => {
// 1: resolve identifier
mockLinearAPI({ issue: { id: "uuid-123", team: { id: "team-1" } } });
// 2: user lookup
mockLinearAPI({
users: { nodes: [{ id: "user-1", displayName: "Alice", name: "Alice Smith" }] },
});
// 3: issueUpdate (assignee)
mockLinearAPI({ issueUpdate: { success: true } });
await tracker.updateIssue!("INT-123", { assignee: "Alice" }, project);
expect(requestMock).toHaveBeenCalledTimes(3);
const writeCall = requestMock.mock.results[2].value.write.mock.calls[0][0];
const body = JSON.parse(writeCall);
expect(body.variables.assigneeId).toBe("user-1");
});
it("updates labels additively (merges with existing)", async () => {
// 1: resolve identifier
mockLinearAPI({ issue: { id: "uuid-123", team: { id: "team-1" } } });
// 2: fetch existing labels on the issue
mockLinearAPI({ issue: { labels: { nodes: [{ id: "label-existing" }] } } });
// 3: team label lookup
mockLinearAPI({
issueLabels: {
nodes: [
{ id: "label-1", name: "bug" },
{ id: "label-2", name: "urgent" },
],
},
});
// 4: issueUpdate (labels)
mockLinearAPI({ issueUpdate: { success: true } });
await tracker.updateIssue!("INT-123", { labels: ["bug", "urgent"] }, project);
expect(requestMock).toHaveBeenCalledTimes(4);
const writeCall = requestMock.mock.results[3].value.write.mock.calls[0][0];
const body = JSON.parse(writeCall);
// Should include existing + new labels
expect(body.variables.labelIds).toEqual(
expect.arrayContaining(["label-existing", "label-1", "label-2"]),
);
expect(body.variables.labelIds).toHaveLength(3);
});
});
// ---- createIssue -------------------------------------------------------
describe("createIssue", () => {
it("creates a basic issue", async () => {
mockLinearAPI({
issueCreate: { success: true, issue: sampleIssueNode },
});
const issue = await tracker.createIssue!(
{ title: "Fix login bug", description: "Desc" },
project,
);
expect(issue).toMatchObject({
id: "INT-123",
title: "Fix login bug",
state: "in_progress",
});
});
it("passes priority to mutation", async () => {
mockLinearAPI({
issueCreate: { success: true, issue: sampleIssueNode },
});
await tracker.createIssue!(
{ title: "Bug", description: "", priority: 1 },
project,
);
const writeCall = requestMock.mock.results[0].value.write.mock.calls[0][0];
const body = JSON.parse(writeCall);
expect(body.variables.priority).toBe(1);
});
it("resolves assignee by display name after creation", async () => {
// 1: create issue
mockLinearAPI({
issueCreate: {
success: true,
issue: { ...sampleIssueNode, assignee: null },
},
});
// 2: look up user by display name
mockLinearAPI({
users: { nodes: [{ id: "user-1", displayName: "Alice", name: "Alice Smith" }] },
});
// 3: issueUpdate to assign
mockLinearAPI({ issueUpdate: { success: true } });
const issue = await tracker.createIssue!(
{ title: "Bug", description: "", assignee: "Alice" },
project,
);
expect(issue.assignee).toBe("Alice");
expect(requestMock).toHaveBeenCalledTimes(3);
});
it("skips assignee when user not found", async () => {
mockLinearAPI({
issueCreate: {
success: true,
issue: { ...sampleIssueNode, assignee: null },
},
});
// User lookup returns empty
mockLinearAPI({ users: { nodes: [] } });
const issue = await tracker.createIssue!(
{ title: "Bug", description: "", assignee: "Unknown" },
project,
);
expect(issue.assignee).toBeUndefined();
// Only 2 calls: create + user lookup (no update since user not found)
expect(requestMock).toHaveBeenCalledTimes(2);
});
it("adds labels after creation", async () => {
mockLinearAPI({
issueCreate: {
success: true,
issue: { ...sampleIssueNode, labels: { nodes: [] } },
},
});
// Label lookup
mockLinearAPI({
issueLabels: {
nodes: [
{ id: "label-1", name: "bug" },
{ id: "label-2", name: "urgent" },
{ id: "label-3", name: "other" },
],
},
});
// issueUpdate to set labels
mockLinearAPI({ issueUpdate: { success: true } });
const issue = await tracker.createIssue!(
{ title: "Bug", description: "", labels: ["bug", "urgent"] },
project,
);
expect(issue.labels).toEqual(["bug", "urgent"]);
// Verify label IDs sent
const writeCall = requestMock.mock.results[2].value.write.mock.calls[0][0];
const body = JSON.parse(writeCall);
expect(body.variables.labelIds).toEqual(["label-1", "label-2"]);
});
it("only reflects actually-applied labels when some don't exist", async () => {
mockLinearAPI({
issueCreate: {
success: true,
issue: { ...sampleIssueNode, labels: { nodes: [] } },
},
});
// Only "bug" exists in Linear; "nonexistent" does not
mockLinearAPI({
issueLabels: {
nodes: [
{ id: "label-1", name: "bug" },
],
},
});
mockLinearAPI({ issueUpdate: { success: true } });
const issue = await tracker.createIssue!(
{ title: "Bug", description: "", labels: ["bug", "nonexistent"] },
project,
);
// Should only include the label that was actually found and applied
expect(issue.labels).toEqual(["bug"]);
});
it("throws when teamId is missing from config", async () => {
const noTeam: ProjectConfig = {
...project,
tracker: { plugin: "linear" },
};
await expect(
tracker.createIssue!({ title: "Bug", description: "" }, noTeam),
).rejects.toThrow("teamId");
});
it("handles assignee error gracefully (best-effort)", async () => {
mockLinearAPI({
issueCreate: {
success: true,
issue: { ...sampleIssueNode, assignee: null },
},
});
// User lookup fails
mockLinearError("Internal error");
const issue = await tracker.createIssue!(
{ title: "Bug", description: "", assignee: "Alice" },
project,
);
// Should still return the issue without assignee
expect(issue).toMatchObject({ id: "INT-123" });
expect(issue.assignee).toBeUndefined();
});
it("handles label error gracefully (best-effort)", async () => {
mockLinearAPI({
issueCreate: {
success: true,
issue: { ...sampleIssueNode, labels: { nodes: [] } },
},
});
// Label lookup fails
mockLinearError("Internal error");
const issue = await tracker.createIssue!(
{ title: "Bug", description: "", labels: ["bug"] },
project,
);
// Should still return the issue without labels
expect(issue).toMatchObject({ id: "INT-123" });
expect(issue.labels).toEqual([]);
});
});
// ---- linearQuery error handling ----------------------------------------
describe("linearQuery error handling", () => {
it("throws on missing LINEAR_API_KEY", async () => {
delete process.env["LINEAR_API_KEY"];
await expect(tracker.getIssue("INT-123", project)).rejects.toThrow(
"LINEAR_API_KEY",
);
});
it("throws on GraphQL errors", async () => {
mockLinearError("You do not have access");
await expect(tracker.getIssue("INT-123", project)).rejects.toThrow(
"Linear API error: You do not have access",
);
});
it("throws on HTTP error status", async () => {
mockHTTPError(401, "Unauthorized");
await expect(tracker.getIssue("INT-123", project)).rejects.toThrow(
"Linear API returned HTTP 401",
);
});
it("throws on empty data response", async () => {
const body = JSON.stringify({ data: null });
requestMock.mockImplementationOnce(
(_opts: Record<string, unknown>, callback: (res: EventEmitter & { statusCode: number }) => void) => {
const req = Object.assign(new EventEmitter(), {
write: vi.fn(),
end: vi.fn(() => {
const res = Object.assign(new EventEmitter(), { statusCode: 200 });
callback(res);
process.nextTick(() => {
res.emit("data", Buffer.from(body));
res.emit("end");
});
}),
destroy: vi.fn(),
setTimeout: vi.fn(),
});
return req;
},
);
await expect(tracker.getIssue("INT-123", project)).rejects.toThrow(
"Linear API returned no data",
);
});
});
});

View File

@ -94,6 +94,9 @@ importers:
'@agent-orchestrator/plugin-runtime-tmux':
specifier: workspace:*
version: link:../plugins/runtime-tmux
'@agent-orchestrator/plugin-tracker-linear':
specifier: workspace:*
version: link:../plugins/tracker-linear
'@agent-orchestrator/plugin-workspace-clone':
specifier: workspace:*
version: link:../plugins/workspace-clone
@ -258,6 +261,9 @@ importers:
typescript:
specifier: ^5.7.0
version: 5.9.3
vitest:
specifier: ^3.0.0
version: 3.2.4(@types/node@25.2.3)(jiti@2.6.1)(jsdom@25.0.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2)
packages/plugins/terminal-iterm2:
dependencies:
@ -297,6 +303,9 @@ importers:
typescript:
specifier: ^5.7.0
version: 5.9.3
vitest:
specifier: ^3.0.0
version: 3.2.4(@types/node@25.2.3)(jiti@2.6.1)(jsdom@25.0.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2)
packages/plugins/tracker-linear:
dependencies:
@ -310,6 +319,9 @@ importers:
typescript:
specifier: ^5.7.0
version: 5.9.3
vitest:
specifier: ^3.0.0
version: 3.2.4(@types/node@25.2.3)(jiti@2.6.1)(jsdom@25.0.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2)
packages/plugins/workspace-clone:
dependencies: