From 8707faf11ccb0b930df422dcecaaff467b9d02a8 Mon Sep 17 00:00:00 2001 From: prateek Date: Sat, 14 Feb 2026 15:45:51 +0530 Subject: [PATCH] feat: implement SCM and tracker plugins (github, linear) (#4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * ci: pass LINEAR_API_KEY and LINEAR_TEAM_ID to integration tests Co-Authored-By: Claude Opus 4.6 * 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 * 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 * 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 * 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 --------- Co-authored-by: Claude Opus 4.6 --- .github/workflows/integration-tests.yml | 6 + packages/core/package.json | 2 +- .../src/__tests__/plugin-integration.test.ts | 508 ++++++++++ packages/core/tsconfig.build.json | 4 + packages/core/tsconfig.json | 3 +- packages/core/vitest.config.ts | 23 + packages/integration-tests/package.json | 3 +- .../src/tracker-linear.integration.test.ts | 292 ++++++ packages/plugins/scm-github/package.json | 4 +- packages/plugins/scm-github/src/index.ts | 557 ++++++++++- .../plugins/scm-github/test/index.test.ts | 631 +++++++++++++ packages/plugins/tracker-github/package.json | 4 +- packages/plugins/tracker-github/src/index.ts | 322 ++++++- .../plugins/tracker-github/test/index.test.ts | 395 ++++++++ packages/plugins/tracker-linear/package.json | 4 +- .../tracker-linear/src/composio-core.d.ts | 23 + packages/plugins/tracker-linear/src/index.ts | 732 ++++++++++++++- .../test/composio-transport.test.ts | 350 +++++++ .../plugins/tracker-linear/test/index.test.ts | 866 ++++++++++++++++++ pnpm-lock.yaml | 12 + 20 files changed, 4732 insertions(+), 9 deletions(-) create mode 100644 packages/core/src/__tests__/plugin-integration.test.ts create mode 100644 packages/core/tsconfig.build.json create mode 100644 packages/core/vitest.config.ts create mode 100644 packages/integration-tests/src/tracker-linear.integration.test.ts create mode 100644 packages/plugins/scm-github/test/index.test.ts create mode 100644 packages/plugins/tracker-github/test/index.test.ts create mode 100644 packages/plugins/tracker-linear/src/composio-core.d.ts create mode 100644 packages/plugins/tracker-linear/test/composio-transport.test.ts create mode 100644 packages/plugins/tracker-linear/test/index.test.ts diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index 9b44b03a9..a4be852a0 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -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 diff --git a/packages/core/package.json b/packages/core/package.json index 2c686e7ae..f0a6abd1c 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -16,7 +16,7 @@ } }, "scripts": { - "build": "tsc", + "build": "tsc -p tsconfig.build.json", "typecheck": "tsc --noEmit", "test": "vitest run", "test:watch": "vitest", diff --git a/packages/core/src/__tests__/plugin-integration.test.ts b/packages/core/src/__tests__/plugin-integration.test.ts new file mode 100644 index 000000000..a79bc70cc --- /dev/null +++ b/packages/core/src/__tests__/plugin-integration.test.ts @@ -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 { + 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/" + 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 { + 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"); + }); + }); +}); diff --git a/packages/core/tsconfig.build.json b/packages/core/tsconfig.build.json new file mode 100644 index 000000000..4dc23fb9c --- /dev/null +++ b/packages/core/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["src/__tests__"] +} diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json index 5a24989cd..d782739b9 100644 --- a/packages/core/tsconfig.json +++ b/packages/core/tsconfig.json @@ -4,5 +4,6 @@ "outDir": "dist", "rootDir": "src" }, - "include": ["src"] + "include": ["src"], + "exclude": ["src/__tests__/plugin-integration.test.ts"] } diff --git a/packages/core/vitest.config.ts b/packages/core/vitest.config.ts new file mode 100644 index 000000000..b3bafc4a6 --- /dev/null +++ b/packages/core/vitest.config.ts @@ -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", + ), + }, + }, +}); diff --git a/packages/integration-tests/package.json b/packages/integration-tests/package.json index 4646879c9..ca15549a2 100644 --- a/packages/integration-tests/package.json +++ b/packages/integration-tests/package.json @@ -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", diff --git a/packages/integration-tests/src/tracker-linear.integration.test.ts b/packages/integration-tests/src/tracker-linear.integration.test.ts new file mode 100644 index 000000000..d24b01958 --- /dev/null +++ b/packages/integration-tests/src/tracker-linear.integration.test.ts @@ -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( + query: string, + variables: Record, +): Promise { + if (!LINEAR_API_KEY) { + throw new Error("linearGraphQL requires LINEAR_API_KEY"); + } + const body = JSON.stringify({ query, variables }); + + return new Promise((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"); + }); +}); diff --git a/packages/plugins/scm-github/package.json b/packages/plugins/scm-github/package.json index b12ea7669..00a010271 100644 --- a/packages/plugins/scm-github/package.json +++ b/packages/plugins/scm-github/package.json @@ -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" } } diff --git a/packages/plugins/scm-github/src/index.ts b/packages/plugins/scm-github/src/index.ts index 475ee4fcb..66170f31d 100644 --- a/packages/plugins/scm-github/src/index.ts +++ b/packages/plugins/scm-github/src/index.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + await gh([ + "pr", + "close", + String(pr.number), + "--repo", + repoFlag(pr), + ]); + }, + + async getCIChecks(pr: PRInfo): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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; diff --git a/packages/plugins/scm-github/test/index.test.ts b/packages/plugins/scm-github/test/index.test.ts new file mode 100644 index 000000000..e44842b68 --- /dev/null +++ b/packages/plugins/scm-github/test/index.test.ts @@ -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 { + 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; + + 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); + }); + }); +}); diff --git a/packages/plugins/tracker-github/package.json b/packages/plugins/tracker-github/package.json index 43d5a1003..520d33a33 100644 --- a/packages/plugins/tracker-github/package.json +++ b/packages/plugins/tracker-github/package.json @@ -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" } } diff --git a/packages/plugins/tracker-github/src/index.ts b/packages/plugins/tracker-github/src/index.ts index aaeb086d8..da19278fa 100644 --- a/packages/plugins/tracker-github/src/index.ts +++ b/packages/plugins/tracker-github/src/index.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + // 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 { + 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; diff --git a/packages/plugins/tracker-github/test/index.test.ts b/packages/plugins/tracker-github/test/index.test.ts new file mode 100644 index 000000000..0088b31e9 --- /dev/null +++ b/packages/plugins/tracker-github/test/index.test.ts @@ -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; + + 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"); + }); + }); +}); diff --git a/packages/plugins/tracker-linear/package.json b/packages/plugins/tracker-linear/package.json index 3b90a2cc0..66476b322 100644 --- a/packages/plugins/tracker-linear/package.json +++ b/packages/plugins/tracker-linear/package.json @@ -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" } } diff --git a/packages/plugins/tracker-linear/src/composio-core.d.ts b/packages/plugins/tracker-linear/src/composio-core.d.ts new file mode 100644 index 000000000..806876d8f --- /dev/null +++ b/packages/plugins/tracker-linear/src/composio-core.d.ts @@ -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; + error?: string; + successful?: boolean; + } + + interface ComposioTools { + execute( + action: string, + params: Record, + ): Promise; + } + + export class Composio { + constructor(opts: { apiKey: string }); + tools: ComposioTools; + } +} diff --git a/packages/plugins/tracker-linear/src/index.ts b/packages/plugins/tracker-linear/src/index.ts index 5cfd1bed4..3cbba0924 100644 --- a/packages/plugins/tracker-linear/src/index.ts +++ b/packages/plugins/tracker-linear/src/index.ts @@ -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 = ( + query: string, + variables?: Record, +) => Promise; + +// --------------------------------------------------------------------------- +// 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 { + data?: T; + errors?: Array<{ message: string }>; +} + +function createDirectTransport(): GraphQLTransport { + return (query: string, variables?: Record): Promise => { + const apiKey = getApiKey(); + const body = JSON.stringify({ query, variables }); + + return new Promise((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 = 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 | undefined; + + function getClient(): Promise { + 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 (query: string, variables?: Record): Promise => { + 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 | undefined; + const timeoutPromise = new Promise((_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 { + 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 { + 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 { + 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 = { + 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 { + // Build filter object using GraphQL variables to prevent injection + const filter: Record = {}; + const variables: Record = {}; + + 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 { + // 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 { + const teamId = project.tracker?.["teamId"]; + if (!teamId) { + throw new Error( + "Linear tracker requires 'teamId' in project tracker config", + ); + } + + const variables: Record = { + 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; diff --git a/packages/plugins/tracker-linear/test/composio-transport.test.ts b/packages/plugins/tracker-linear/test/composio-transport.test.ts new file mode 100644 index 000000000..77a473a57 --- /dev/null +++ b/packages/plugins/tracker-linear/test/composio-transport.test.ts @@ -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; + const args = params["arguments"] as Record; + 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; + const args = params["arguments"] as Record; + // 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(); + } + }); + }); +}); diff --git a/packages/plugins/tracker-linear/test/index.test.ts b/packages/plugins/tracker-linear/test/index.test.ts new file mode 100644 index 000000000..9a96ea573 --- /dev/null +++ b/packages/plugins/tracker-linear/test/index.test.ts @@ -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, 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, 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, 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; + 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 = { + 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, 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", + ); + }); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ac6f04cc7..25008db54 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: