feat: implement SCM and tracker plugins (github, linear) (#4)
* feat: implement SCM and tracker plugins (github, linear) Implement three plugin interfaces from packages/core/src/types.ts: - scm-github: Full GitHub PR lifecycle via `gh` CLI — PR detection by branch, state tracking, CI checks, reviews, review decision, pending comments, automated bot comment detection (cursor[bot], codecov, etc.), and merge readiness (CI + reviews + conflicts + draft). - tracker-github: GitHub Issues tracker via `gh` CLI — issue CRUD, completion check, branch naming (feat/issue-N), prompt generation, list/filter/update/create with label and assignee support. - tracker-linear: Linear issue tracker via GraphQL API (LINEAR_API_KEY) — issue fetch, completion check, branch naming (feat/IDENTIFIER), prompt generation, list with team/state/label filters, state transitions via workflow state resolution, issue creation with teamId config. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: address PR review — GraphQL injection, N+1 queries, timeouts, tests - tracker-linear: use GraphQL variables in listIssues to prevent injection - tracker-linear: add 30s HTTP timeout to linearQuery - tracker-linear: fix issueUrl to use workspace slug from project config - tracker-linear: add labels/assignee support to createIssue - scm-github: rewrite getPendingComments to use GraphQL reviewThreads with real isResolved status instead of hardcoding false - scm-github: simplify getAutomatedComments to single API call (N+1 fix) - scm-github: add 30s timeout to gh CLI calls - scm-github: fix parseDate to return epoch instead of fabricating dates - scm-github: add repo format validation in detectPR - scm-github: fix getCISummary to not count all-skipped as passing - tracker-github: add 30s timeout to gh CLI calls - tracker-github: fix createIssue to not use unsupported --json flag - Add comprehensive vitest tests for scm-github and tracker-github Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: address Codex review — assignee lookup, GraphQL vars, status checks Iteration 1 fixes (8 issues from Codex review): - tracker-linear: remove invalid assigneeDisplayName, resolve assignee by display name to ID via users query after creation - tracker-linear: add HTTP status code check in linearQuery - tracker-linear: throw on missing workflow state in updateIssue - scm-github: use GraphQL variables ($owner, $name, $number) in getPendingComments instead of string interpolation - scm-github: guard against empty comment nodes in review threads - scm-github: use mergeStateStatus in getMergeability (BEHIND, BLOCKED) - tracker-github: default description to "" in createIssue Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: address PR review + lint — error context, GraphQL vars, mergeability - scm-github/tracker-github: wrap gh() errors with command context + cause - scm-github: use GraphQL variables ($owner, $name, $number) in getPendingComments to prevent injection - scm-github: guard against empty review thread nodes - scm-github: incorporate mergeStateStatus (BEHIND/BLOCKED) in getMergeability - tracker-linear: add identifier vs UUID comment in updateIssue - Fix ESLint preserve-caught-error violations - Remove unused mockGhRaw in scm-github tests Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: add pagination to getAutomatedComments and increase thread limit - getAutomatedComments: add --paginate flag to REST API call to fetch all review comments beyond the default 30-item first page - getPendingComments: increase GraphQL reviewThreads limit from 100 to 250 (GitHub's max for connections) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: use per_page=100 instead of --paginate for JSON-safe pagination Replace --paginate with -F per_page=100 in getAutomatedComments to avoid concatenated JSON arrays that break JSON.parse on multi-page responses. 100 is GitHub's max per_page value. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: filter out resolved threads in getPendingComments The method name implies it should only return unresolved/pending review threads. The isResolved data was already fetched via GraphQL but was not being filtered on, causing resolved threads to be included in results. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * test: add comprehensive tracker-linear test suite (53 tests) Covers all Tracker interface methods: getIssue, isCompleted, issueUrl, branchName, generatePrompt, listIssues, updateIssue, createIssue. Mocks node:https request to simulate Linear GraphQL API responses. Tests state mapping, error handling, assignee/label resolution, and GraphQL variable injection. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: address 6 bugbot review comments on PR #4 - GraphQL reviewThreads(first: 250) → first: 100 (GitHub max) - noConflicts false when mergeable is UNKNOWN (not just CONFLICTING) - updateIssue now handles labels and assignee (not just state/comment) - Add res.on("error") handler in linearQuery to prevent crashes - createIssue returns only actually-applied labels, not all requested - Tests added/updated for all fixes (144 total across 3 plugins) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: make Linear updateIssue labels additive to match GitHub behavior Linear's issueUpdate replaces all labels, while GitHub's --add-label is additive. Now fetches existing label IDs first and merges with new ones before sending to issueUpdate, ensuring consistent behavior across both tracker implementations. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: default listIssues to open state in tracker-linear When no state filter is specified, tracker-github defaults to "open" but tracker-linear was returning all issues. Now defaults to excluding completed/canceled issues, matching tracker-github behavior. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add Composio SDK support to tracker-linear Allow users with a COMPOSIO_API_KEY to use the Linear tracker without a separate LINEAR_API_KEY. The plugin auto-detects which key is available and routes through either the direct Linear GraphQL API or Composio's LINEAR_RUN_QUERY_OR_MUTATION tool. All existing queries and response parsing are reused via a GraphQLTransport abstraction. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: address 2 bugbot review comments on PR #4 1. getCIChecks now throws on error instead of silently returning [], preventing a fail-open where CI appears healthy when we can't fetch check status. getCISummary catches the error and returns "failing". 2. Handle GitHub's UNSTABLE mergeStateStatus (required checks failing) as a merge blocker in getMergeability. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: prevent unhandled promise rejection in Composio timeout race When the timeout wins Promise.race in the Composio transport, the resultPromise is left without a rejection handler. If the SDK call later rejects, it becomes an unhandled promise rejection that crashes Node.js 20+ with --unhandled-rejections=throw. Attach no-op .catch() to both resultPromise and timeoutPromise before the race so whichever promise loses has its rejection silently handled. Also adds plugin integration tests (core -> real plugins -> mocked gh CLI). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: chain error cause in getCIChecks and fix core build - getCIChecks catch now preserves the original error via { cause: err } instead of discarding it, matching the pattern used by the gh() helper - Add tsconfig.build.json to core that excludes __tests__/ from build compilation, preventing TS5055 errors from circular workspace deps (integration tests import plugin packages that depend back on core) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: remove circular devDeps, address bugbot comments - Remove plugin devDependencies from core/package.json that created a circular dependency (core -> plugins -> core), breaking CI build order - Document in_progress state as intentional no-op in tracker-github updateIssue (GitHub Issues only supports open/closed) - Remove @composio/core optional peerDependency from tracker-linear; the dynamic import() already handles the missing package gracefully, and the peer dep was pulling composio + transitive deps into lockfile Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add real integration test for tracker-linear against Linear API Creates a test that exercises the full tracker-linear plugin lifecycle against the real Linear API — createIssue, getIssue, isCompleted, listIssues, updateIssue (comment, close, reopen), generatePrompt, branchName, issueUrl. Each run creates a throwaway test issue and trashes it in cleanup. Skipped when LINEAR_API_KEY is not set. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * ci: pass LINEAR_API_KEY and LINEAR_TEAM_ID to integration tests Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: support both LINEAR_API_KEY and COMPOSIO_API_KEY in integration test The tracker-linear integration test now runs with either credential: - LINEAR_API_KEY: direct Linear API (full cleanup via trash) - COMPOSIO_API_KEY: via Composio SDK (cleanup falls back to closing) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: don't pass COMPOSIO_API_KEY to CI integration tests When both LINEAR_API_KEY and COMPOSIO_API_KEY are set, the plugin prefers the Composio transport which requires @composio/core SDK. The SDK isn't installed in CI, so use the direct LINEAR_API_KEY transport which has no extra dependencies. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: use import.meta.url in vitest config, default createIssue description - Replace __dirname with import.meta.url-derived dirname in ESM config - Default createIssue description to "" for defensive safety Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: map CANCELLED and ACTION_REQUIRED CI conclusions to failed Terminal check conclusions (CANCELLED, ACTION_REQUIRED) were falling through to "pending", causing the lifecycle manager to wait forever. Also changed the default else branch to "failed" (fail-closed). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
925a7aae92
commit
8707faf11c
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@
|
|||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"build": "tsc -p tsconfig.build.json",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,508 @@
|
|||
/**
|
||||
* Plugin integration tests — core services calling real plugin instances.
|
||||
*
|
||||
* These tests verify the full path: core service → real plugin → mocked external API.
|
||||
* Both tracker-github and scm-github use `gh` CLI via `execFile`, so a single
|
||||
* `vi.mock("node:child_process")` covers both plugins.
|
||||
*
|
||||
* Runtime, Agent, and Workspace remain mock objects — we're testing the
|
||||
* tracker/SCM integration path, not those.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { mkdirSync, rmSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock node:child_process — must be hoisted before plugin imports
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const { ghMock } = vi.hoisted(() => ({ ghMock: vi.fn() }));
|
||||
|
||||
vi.mock("node:child_process", () => {
|
||||
const execFile = Object.assign(vi.fn(), {
|
||||
[Symbol.for("nodejs.util.promisify.custom")]: ghMock,
|
||||
});
|
||||
return { execFile };
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Imports — plugins resolve the mocked child_process at import time
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
import { createPluginRegistry } from "../plugin-registry.js";
|
||||
import { createSessionManager } from "../session-manager.js";
|
||||
import { createLifecycleManager } from "../lifecycle-manager.js";
|
||||
import { writeMetadata } from "../metadata.js";
|
||||
import trackerGithub from "@agent-orchestrator/plugin-tracker-github";
|
||||
import scmGithub from "@agent-orchestrator/plugin-scm-github";
|
||||
import type {
|
||||
OrchestratorConfig,
|
||||
PluginRegistry,
|
||||
Runtime,
|
||||
Agent,
|
||||
Workspace,
|
||||
RuntimeHandle,
|
||||
SessionManager,
|
||||
PRInfo,
|
||||
Session,
|
||||
} from "../types.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared fixtures + helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let dataDir: string;
|
||||
let mockRuntime: Runtime;
|
||||
let mockAgent: Agent;
|
||||
let mockWorkspace: Workspace;
|
||||
let config: OrchestratorConfig;
|
||||
|
||||
function makeHandle(id: string): RuntimeHandle {
|
||||
return { id, runtimeName: "mock", data: {} };
|
||||
}
|
||||
|
||||
function mockGh(result: unknown): void {
|
||||
ghMock.mockResolvedValueOnce({ stdout: JSON.stringify(result) });
|
||||
}
|
||||
|
||||
const project = {
|
||||
name: "Test App",
|
||||
repo: "acme/app",
|
||||
path: "/tmp/test-app",
|
||||
defaultBranch: "main",
|
||||
sessionPrefix: "app",
|
||||
tracker: { plugin: "github" },
|
||||
scm: { plugin: "github" },
|
||||
};
|
||||
|
||||
const pr: PRInfo = {
|
||||
number: 42,
|
||||
url: "https://github.com/acme/app/pull/42",
|
||||
title: "feat: add feature",
|
||||
owner: "acme",
|
||||
repo: "app",
|
||||
branch: "feat/issue-99",
|
||||
baseBranch: "main",
|
||||
isDraft: false,
|
||||
};
|
||||
|
||||
function makeSession(overrides: Partial<Session> = {}): Session {
|
||||
return {
|
||||
id: "app-1",
|
||||
projectId: "my-app",
|
||||
status: "working",
|
||||
activity: "active",
|
||||
branch: "feat/issue-99",
|
||||
issueId: null,
|
||||
pr: null,
|
||||
workspacePath: "/tmp/test-app",
|
||||
runtimeHandle: makeHandle("rt-1"),
|
||||
agentInfo: null,
|
||||
createdAt: new Date(),
|
||||
lastActivityAt: new Date(),
|
||||
metadata: {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Setup / teardown
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
dataDir = join(tmpdir(), `ao-test-plugin-int-${randomUUID()}`);
|
||||
mkdirSync(join(dataDir, "sessions"), { recursive: true });
|
||||
|
||||
mockRuntime = {
|
||||
name: "mock",
|
||||
create: vi.fn().mockResolvedValue(makeHandle("rt-1")),
|
||||
destroy: vi.fn().mockResolvedValue(undefined),
|
||||
sendMessage: vi.fn().mockResolvedValue(undefined),
|
||||
getOutput: vi.fn().mockResolvedValue(""),
|
||||
isAlive: vi.fn().mockResolvedValue(true),
|
||||
};
|
||||
|
||||
mockAgent = {
|
||||
name: "mock-agent",
|
||||
processName: "mock",
|
||||
getLaunchCommand: vi.fn().mockReturnValue("mock-agent --start"),
|
||||
getEnvironment: vi.fn().mockReturnValue({ AGENT_VAR: "1" }),
|
||||
detectActivity: vi.fn().mockResolvedValue("active"),
|
||||
isProcessRunning: vi.fn().mockResolvedValue(true),
|
||||
isProcessing: vi.fn().mockResolvedValue(false),
|
||||
getSessionInfo: vi.fn().mockResolvedValue(null),
|
||||
};
|
||||
|
||||
mockWorkspace = {
|
||||
name: "mock-ws",
|
||||
create: vi.fn().mockResolvedValue({
|
||||
path: "/tmp/mock-ws/app-1",
|
||||
branch: "feat/issue-99",
|
||||
sessionId: "app-1",
|
||||
projectId: "my-app",
|
||||
}),
|
||||
destroy: vi.fn().mockResolvedValue(undefined),
|
||||
list: vi.fn().mockResolvedValue([]),
|
||||
};
|
||||
|
||||
config = {
|
||||
dataDir,
|
||||
worktreeDir: "/tmp/worktrees",
|
||||
port: 3000,
|
||||
defaults: {
|
||||
runtime: "mock",
|
||||
agent: "mock-agent",
|
||||
workspace: "mock-ws",
|
||||
notifiers: [],
|
||||
},
|
||||
projects: {
|
||||
"my-app": project,
|
||||
},
|
||||
notifiers: {},
|
||||
notificationRouting: {
|
||||
urgent: [],
|
||||
action: [],
|
||||
warning: [],
|
||||
info: [],
|
||||
},
|
||||
reactions: {},
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(dataDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper: create a registry with real tracker-github and scm-github
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function createTestRegistry(): PluginRegistry {
|
||||
const registry = createPluginRegistry();
|
||||
|
||||
// Register mock plugins for runtime/agent/workspace
|
||||
registry.register({
|
||||
manifest: { name: "mock", slot: "runtime", description: "mock", version: "0.0.0" },
|
||||
create: () => mockRuntime,
|
||||
});
|
||||
registry.register({
|
||||
manifest: { name: "mock-agent", slot: "agent", description: "mock", version: "0.0.0" },
|
||||
create: () => mockAgent,
|
||||
});
|
||||
registry.register({
|
||||
manifest: { name: "mock-ws", slot: "workspace", description: "mock", version: "0.0.0" },
|
||||
create: () => mockWorkspace,
|
||||
});
|
||||
|
||||
// Register REAL plugins
|
||||
registry.register(trackerGithub);
|
||||
registry.register(scmGithub);
|
||||
|
||||
return registry;
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Tests
|
||||
// ===========================================================================
|
||||
|
||||
describe("plugin integration", () => {
|
||||
// -------------------------------------------------------------------------
|
||||
describe("registry + real plugins", () => {
|
||||
it("registers tracker-github and scm-github via real registry", () => {
|
||||
const registry = createTestRegistry();
|
||||
|
||||
const trackers = registry.list("tracker");
|
||||
const scms = registry.list("scm");
|
||||
|
||||
expect(trackers).toContainEqual(
|
||||
expect.objectContaining({ name: "github", slot: "tracker" }),
|
||||
);
|
||||
expect(scms).toContainEqual(
|
||||
expect.objectContaining({ name: "github", slot: "scm" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("registry.get returns correct plugin instances by slot+name", () => {
|
||||
const registry = createTestRegistry();
|
||||
|
||||
const tracker = registry.get("tracker", "github");
|
||||
const scm = registry.get("scm", "github");
|
||||
|
||||
expect(tracker).not.toBeNull();
|
||||
expect(scm).not.toBeNull();
|
||||
expect(tracker).toHaveProperty("name", "github");
|
||||
expect(scm).toHaveProperty("name", "github");
|
||||
// Verify they have the expected methods
|
||||
expect(tracker).toHaveProperty("branchName");
|
||||
expect(tracker).toHaveProperty("isCompleted");
|
||||
expect(scm).toHaveProperty("getPRState");
|
||||
expect(scm).toHaveProperty("getCISummary");
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
describe("SessionManager + Tracker", () => {
|
||||
it("spawn() uses tracker-github branchName() to derive branch", async () => {
|
||||
const registry = createTestRegistry();
|
||||
const sm = createSessionManager({ config, registry });
|
||||
|
||||
const session = await sm.spawn({
|
||||
projectId: "my-app",
|
||||
issueId: "99",
|
||||
});
|
||||
|
||||
// tracker-github.branchName("99", project) → "feat/issue-99"
|
||||
expect(session.branch).toBe("feat/issue-99");
|
||||
|
||||
// Workspace should have been called with the tracker-derived branch
|
||||
expect(mockWorkspace.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ branch: "feat/issue-99" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("spawn() falls back to generic branch when no tracker configured", async () => {
|
||||
// Remove tracker from project config
|
||||
const noTrackerConfig: OrchestratorConfig = {
|
||||
...config,
|
||||
projects: {
|
||||
"my-app": { ...project, tracker: undefined },
|
||||
},
|
||||
};
|
||||
const registry = createTestRegistry();
|
||||
const sm = createSessionManager({ config: noTrackerConfig, registry });
|
||||
|
||||
const session = await sm.spawn({
|
||||
projectId: "my-app",
|
||||
issueId: "99",
|
||||
});
|
||||
|
||||
// Without tracker, falls back to "feat/<issueId>"
|
||||
expect(session.branch).toBe("feat/99");
|
||||
});
|
||||
|
||||
it("cleanup() calls tracker-github isCompleted() and kills completed sessions", async () => {
|
||||
const registry = createTestRegistry();
|
||||
const sm = createSessionManager({ config, registry });
|
||||
|
||||
// Seed a session with an issueId but no PR
|
||||
writeMetadata(dataDir, "app-1", {
|
||||
worktree: "/tmp/mock-ws/app-1",
|
||||
branch: "feat/issue-99",
|
||||
status: "working",
|
||||
issue: "99",
|
||||
project: "my-app",
|
||||
runtimeHandle: JSON.stringify(makeHandle("rt-1")),
|
||||
});
|
||||
|
||||
// Mock gh: issue is closed
|
||||
mockGh({ state: "CLOSED" });
|
||||
|
||||
const result = await sm.cleanup("my-app");
|
||||
|
||||
expect(result.killed).toContain("app-1");
|
||||
// Verify the gh CLI was called with the right args
|
||||
expect(ghMock).toHaveBeenCalledWith(
|
||||
"gh",
|
||||
expect.arrayContaining(["issue", "view", "99", "--repo", "acme/app"]),
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("cleanup() skips sessions when issue is still open", async () => {
|
||||
const registry = createTestRegistry();
|
||||
const sm = createSessionManager({ config, registry });
|
||||
|
||||
writeMetadata(dataDir, "app-1", {
|
||||
worktree: "/tmp/mock-ws/app-1",
|
||||
branch: "feat/issue-99",
|
||||
status: "working",
|
||||
issue: "99",
|
||||
project: "my-app",
|
||||
runtimeHandle: JSON.stringify(makeHandle("rt-1")),
|
||||
});
|
||||
|
||||
// Mock gh: issue is still open — runtime also alive
|
||||
mockGh({ state: "OPEN" });
|
||||
|
||||
const result = await sm.cleanup("my-app");
|
||||
|
||||
expect(result.skipped).toContain("app-1");
|
||||
expect(result.killed).not.toContain("app-1");
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
describe("SessionManager + SCM", () => {
|
||||
it("cleanup() calls scm-github getPRState() and kills merged PR sessions", async () => {
|
||||
const registry = createTestRegistry();
|
||||
const sm = createSessionManager({ config, registry });
|
||||
|
||||
// metadataToSession extracts PR number from the URL tail (/42),
|
||||
// and owner/repo stay empty — scm-github receives exactly that.
|
||||
writeMetadata(dataDir, "app-1", {
|
||||
worktree: "/tmp/mock-ws/app-1",
|
||||
branch: "feat/issue-99",
|
||||
status: "working",
|
||||
pr: pr.url,
|
||||
project: "my-app",
|
||||
runtimeHandle: JSON.stringify(makeHandle("rt-1")),
|
||||
});
|
||||
|
||||
// Mock gh: PR is merged
|
||||
mockGh({ state: "MERGED" });
|
||||
|
||||
const result = await sm.cleanup("my-app");
|
||||
|
||||
expect(result.killed).toContain("app-1");
|
||||
// Verify gh CLI was called for PR state check
|
||||
expect(ghMock).toHaveBeenCalledWith(
|
||||
"gh",
|
||||
expect.arrayContaining(["pr", "view", "42"]),
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("cleanup() skips sessions when PR is still open", async () => {
|
||||
const registry = createTestRegistry();
|
||||
const sm = createSessionManager({ config, registry });
|
||||
|
||||
writeMetadata(dataDir, "app-1", {
|
||||
worktree: "/tmp/mock-ws/app-1",
|
||||
branch: "feat/issue-99",
|
||||
status: "working",
|
||||
pr: pr.url,
|
||||
project: "my-app",
|
||||
runtimeHandle: JSON.stringify(makeHandle("rt-1")),
|
||||
});
|
||||
|
||||
// Mock gh: PR is still open — runtime also alive
|
||||
mockGh({ state: "OPEN" });
|
||||
|
||||
const result = await sm.cleanup("my-app");
|
||||
|
||||
expect(result.skipped).toContain("app-1");
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
describe("LifecycleManager + SCM", () => {
|
||||
let registry: PluginRegistry;
|
||||
let sm: SessionManager;
|
||||
|
||||
beforeEach(() => {
|
||||
registry = createTestRegistry();
|
||||
sm = createSessionManager({ config, registry });
|
||||
});
|
||||
|
||||
function seedSession(overrides: Partial<Session> = {}): Session {
|
||||
const session = makeSession(overrides);
|
||||
|
||||
writeMetadata(dataDir, session.id, {
|
||||
worktree: session.workspacePath ?? "/tmp/test-app",
|
||||
branch: session.branch ?? "feat/issue-99",
|
||||
status: session.status,
|
||||
project: session.projectId,
|
||||
...(session.pr ? { pr: JSON.stringify(session.pr) } : {}),
|
||||
...(session.issueId ? { issue: session.issueId } : {}),
|
||||
runtimeHandle: JSON.stringify(session.runtimeHandle),
|
||||
});
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
it("check() detects ci_failed via scm-github getCISummary()", async () => {
|
||||
seedSession({ status: "pr_open", pr });
|
||||
|
||||
// Mock the sessionManager.list() to return our session
|
||||
const mockSM: SessionManager = {
|
||||
...sm,
|
||||
list: vi.fn().mockResolvedValue([makeSession({ status: "pr_open", pr })]),
|
||||
get: vi.fn().mockResolvedValue(makeSession({ status: "pr_open", pr })),
|
||||
kill: vi.fn().mockResolvedValue(undefined),
|
||||
send: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
const lm = createLifecycleManager({
|
||||
config,
|
||||
registry,
|
||||
sessionManager: mockSM,
|
||||
});
|
||||
|
||||
// gh calls for determineStatus:
|
||||
// 1. getPRState → open
|
||||
mockGh({ state: "OPEN" });
|
||||
// 2. getCISummary → failing (pr checks returns array of checks)
|
||||
mockGh([
|
||||
{ name: "lint", state: "COMPLETED", conclusion: "FAILURE", detailsUrl: "", startedAt: "", completedAt: "" },
|
||||
]);
|
||||
|
||||
await lm.check("app-1");
|
||||
|
||||
const states = lm.getStates();
|
||||
expect(states.get("app-1")).toBe("ci_failed");
|
||||
});
|
||||
|
||||
it("check() detects merged via scm-github getPRState()", async () => {
|
||||
seedSession({ status: "pr_open", pr });
|
||||
|
||||
const mockSM: SessionManager = {
|
||||
...sm,
|
||||
list: vi.fn().mockResolvedValue([makeSession({ status: "pr_open", pr })]),
|
||||
get: vi.fn().mockResolvedValue(makeSession({ status: "pr_open", pr })),
|
||||
kill: vi.fn().mockResolvedValue(undefined),
|
||||
send: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
const lm = createLifecycleManager({
|
||||
config,
|
||||
registry,
|
||||
sessionManager: mockSM,
|
||||
});
|
||||
|
||||
// getPRState → merged
|
||||
mockGh({ state: "MERGED" });
|
||||
|
||||
await lm.check("app-1");
|
||||
|
||||
const states = lm.getStates();
|
||||
expect(states.get("app-1")).toBe("merged");
|
||||
});
|
||||
|
||||
it("check() detects changes_requested via scm-github getReviewDecision()", async () => {
|
||||
seedSession({ status: "pr_open", pr });
|
||||
|
||||
const mockSM: SessionManager = {
|
||||
...sm,
|
||||
list: vi.fn().mockResolvedValue([makeSession({ status: "pr_open", pr })]),
|
||||
get: vi.fn().mockResolvedValue(makeSession({ status: "pr_open", pr })),
|
||||
kill: vi.fn().mockResolvedValue(undefined),
|
||||
send: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
const lm = createLifecycleManager({
|
||||
config,
|
||||
registry,
|
||||
sessionManager: mockSM,
|
||||
});
|
||||
|
||||
// 1. getPRState → open
|
||||
mockGh({ state: "OPEN" });
|
||||
// 2. getCISummary → passing
|
||||
mockGh([
|
||||
{ name: "lint", state: "COMPLETED", conclusion: "SUCCESS", detailsUrl: "", startedAt: "", completedAt: "" },
|
||||
]);
|
||||
// 3. getReviewDecision (gh pr view with reviewDecision)
|
||||
mockGh({ reviewDecision: "CHANGES_REQUESTED" });
|
||||
|
||||
await lm.check("app-1");
|
||||
|
||||
const states = lm.getStates();
|
||||
expect(states.get("app-1")).toBe("changes_requested");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"exclude": ["src/__tests__"]
|
||||
}
|
||||
|
|
@ -4,5 +4,6 @@
|
|||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src"]
|
||||
"include": ["src"],
|
||||
"exclude": ["src/__tests__/plugin-integration.test.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",
|
||||
),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,292 @@
|
|||
/**
|
||||
* Integration tests for the Linear tracker plugin.
|
||||
*
|
||||
* Requires one of:
|
||||
* - LINEAR_API_KEY (direct Linear API access), or
|
||||
* - COMPOSIO_API_KEY (via Composio SDK, optionally COMPOSIO_ENTITY_ID)
|
||||
* Plus:
|
||||
* - LINEAR_TEAM_ID (team to create test issues in)
|
||||
*
|
||||
* When using Composio, cleanup (issue deletion) still requires LINEAR_API_KEY
|
||||
* since that uses a direct GraphQL call outside the plugin.
|
||||
*
|
||||
* Skipped automatically when prerequisites are missing.
|
||||
*
|
||||
* Each test run creates a real Linear issue, exercises the plugin methods
|
||||
* against it, and deletes it in cleanup. This validates that our GraphQL
|
||||
* queries, state mapping, and data parsing work against the real API —
|
||||
* not just against mocked responses.
|
||||
*/
|
||||
|
||||
import { request } from "node:https";
|
||||
import type { ProjectConfig } from "@agent-orchestrator/core";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import trackerLinear from "@agent-orchestrator/plugin-tracker-linear";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Prerequisites
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const LINEAR_API_KEY = process.env["LINEAR_API_KEY"];
|
||||
const COMPOSIO_API_KEY = process.env["COMPOSIO_API_KEY"];
|
||||
const LINEAR_TEAM_ID = process.env["LINEAR_TEAM_ID"];
|
||||
const hasCredentials = Boolean(LINEAR_API_KEY || COMPOSIO_API_KEY);
|
||||
const canRun = hasCredentials && Boolean(LINEAR_TEAM_ID);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Direct GraphQL call for test setup/cleanup.
|
||||
* Only available when LINEAR_API_KEY is set.
|
||||
*/
|
||||
function linearGraphQL<T>(
|
||||
query: string,
|
||||
variables: Record<string, unknown>,
|
||||
): Promise<T> {
|
||||
if (!LINEAR_API_KEY) {
|
||||
throw new Error("linearGraphQL requires LINEAR_API_KEY");
|
||||
}
|
||||
const body = JSON.stringify({ query, variables });
|
||||
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const url = new URL("https://api.linear.app/graphql");
|
||||
const req = request(
|
||||
{
|
||||
hostname: url.hostname,
|
||||
path: url.pathname,
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: LINEAR_API_KEY,
|
||||
"Content-Length": Buffer.byteLength(body),
|
||||
},
|
||||
},
|
||||
(res) => {
|
||||
const chunks: Buffer[] = [];
|
||||
res.on("data", (chunk: Buffer) => chunks.push(chunk));
|
||||
res.on("end", () => {
|
||||
try {
|
||||
const text = Buffer.concat(chunks).toString("utf-8");
|
||||
const json = JSON.parse(text) as {
|
||||
data?: T;
|
||||
errors?: Array<{ message: string }>;
|
||||
};
|
||||
if (json.errors?.length) {
|
||||
reject(new Error(`Linear API error: ${json.errors[0].message}`));
|
||||
return;
|
||||
}
|
||||
resolve(json.data as T);
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
req.setTimeout(30_000, () => {
|
||||
req.destroy();
|
||||
reject(new Error("Linear API request timed out"));
|
||||
});
|
||||
|
||||
req.on("error", (err) => reject(err));
|
||||
req.write(body);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe.skipIf(!canRun)("tracker-linear (integration)", () => {
|
||||
const tracker = trackerLinear.create();
|
||||
|
||||
const project: ProjectConfig = {
|
||||
name: "test-project",
|
||||
repo: "test-org/test-repo",
|
||||
path: "/tmp/test",
|
||||
defaultBranch: "main",
|
||||
sessionPrefix: "test",
|
||||
tracker: {
|
||||
plugin: "linear",
|
||||
teamId: LINEAR_TEAM_ID!,
|
||||
},
|
||||
};
|
||||
|
||||
// Issue state tracked across tests (created in beforeAll, cleaned up in afterAll)
|
||||
let issueIdentifier: string; // e.g. "INT-1234"
|
||||
let issueUuid: string | undefined; // Linear internal UUID (needed for trash via direct API)
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Setup — create a test issue
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
beforeAll(async () => {
|
||||
const result = await tracker.createIssue!(
|
||||
{
|
||||
title: `[AO Integration Test] ${new Date().toISOString()}`,
|
||||
description:
|
||||
"Automated integration test issue. Safe to delete if found lingering.",
|
||||
priority: 4, // Low
|
||||
},
|
||||
project,
|
||||
);
|
||||
|
||||
issueIdentifier = result.id;
|
||||
|
||||
// Resolve the UUID for cleanup — only possible with direct API key
|
||||
if (LINEAR_API_KEY) {
|
||||
const data = await linearGraphQL<{ issue: { id: string } }>(
|
||||
`query($id: String!) { issue(id: $id) { id } }`,
|
||||
{ id: issueIdentifier },
|
||||
);
|
||||
issueUuid = data.issue.id;
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Cleanup — archive the test issue so it doesn't clutter the board.
|
||||
// With LINEAR_API_KEY we can trash it directly. With Composio-only we
|
||||
// close it via the plugin (can't trash through the plugin interface).
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
afterAll(async () => {
|
||||
if (!issueIdentifier) return;
|
||||
|
||||
try {
|
||||
if (issueUuid && LINEAR_API_KEY) {
|
||||
await linearGraphQL(
|
||||
`mutation($id: String!) {
|
||||
issueUpdate(id: $id, input: { trashed: true }) {
|
||||
success
|
||||
}
|
||||
}`,
|
||||
{ id: issueUuid },
|
||||
);
|
||||
} else {
|
||||
// Composio-only: best-effort close via plugin
|
||||
await tracker.updateIssue!(
|
||||
issueIdentifier,
|
||||
{ state: "closed" },
|
||||
project,
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// Best-effort cleanup
|
||||
}
|
||||
}, 15_000);
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Test cases
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it("createIssue returns a well-shaped Issue", () => {
|
||||
// Validating the result captured in beforeAll
|
||||
expect(issueIdentifier).toBeDefined();
|
||||
expect(issueIdentifier).toMatch(/^[A-Z]+-\d+$/);
|
||||
});
|
||||
|
||||
it("getIssue fetches the created issue with correct fields", async () => {
|
||||
const issue = await tracker.getIssue(issueIdentifier, project);
|
||||
|
||||
expect(issue.id).toBe(issueIdentifier);
|
||||
expect(issue.title).toContain("[AO Integration Test]");
|
||||
expect(issue.description).toContain("Automated integration test");
|
||||
expect(issue.url).toMatch(/^https:\/\/linear\.app\//);
|
||||
expect(issue.state).toBe("open");
|
||||
expect(Array.isArray(issue.labels)).toBe(true);
|
||||
expect(issue.priority).toBe(4);
|
||||
});
|
||||
|
||||
it("isCompleted returns false for an open issue", async () => {
|
||||
const completed = await tracker.isCompleted(issueIdentifier, project);
|
||||
expect(completed).toBe(false);
|
||||
});
|
||||
|
||||
it("issueUrl returns a valid Linear URL", () => {
|
||||
const url = tracker.issueUrl(issueIdentifier, project);
|
||||
expect(url).toContain(issueIdentifier);
|
||||
expect(url).toMatch(/^https:\/\/linear\.app\//);
|
||||
});
|
||||
|
||||
it("branchName returns conventional branch name", () => {
|
||||
const branch = tracker.branchName(issueIdentifier, project);
|
||||
expect(branch).toBe(`feat/${issueIdentifier}`);
|
||||
});
|
||||
|
||||
it("generatePrompt includes issue details", async () => {
|
||||
const prompt = await tracker.generatePrompt(issueIdentifier, project);
|
||||
|
||||
expect(prompt).toContain(issueIdentifier);
|
||||
expect(prompt).toContain("[AO Integration Test]");
|
||||
expect(prompt).toContain("Priority: Low");
|
||||
expect(prompt).toContain("implement the changes");
|
||||
});
|
||||
|
||||
it("listIssues includes the created issue", async () => {
|
||||
const issues = await tracker.listIssues!(
|
||||
{ state: "open", limit: 50 },
|
||||
project,
|
||||
);
|
||||
|
||||
const found = issues.find((i) => i.id === issueIdentifier);
|
||||
expect(found).toBeDefined();
|
||||
expect(found!.title).toContain("[AO Integration Test]");
|
||||
});
|
||||
|
||||
it("updateIssue adds a comment", async () => {
|
||||
await tracker.updateIssue!(
|
||||
issueIdentifier,
|
||||
{ comment: "Integration test comment" },
|
||||
project,
|
||||
);
|
||||
|
||||
// Verify the comment was added — use direct API if available,
|
||||
// otherwise trust the plugin didn't throw
|
||||
if (LINEAR_API_KEY) {
|
||||
const data = await linearGraphQL<{
|
||||
issue: { comments: { nodes: Array<{ body: string }> } };
|
||||
}>(
|
||||
`query($id: String!) {
|
||||
issue(id: $id) {
|
||||
comments { nodes { body } }
|
||||
}
|
||||
}`,
|
||||
{ id: issueIdentifier },
|
||||
);
|
||||
|
||||
const commentBodies = data.issue.comments.nodes.map((c) => c.body);
|
||||
expect(commentBodies).toContain("Integration test comment");
|
||||
}
|
||||
});
|
||||
|
||||
it("updateIssue closes the issue and isCompleted reflects it", async () => {
|
||||
await tracker.updateIssue!(
|
||||
issueIdentifier,
|
||||
{ state: "closed" },
|
||||
project,
|
||||
);
|
||||
|
||||
const completed = await tracker.isCompleted(issueIdentifier, project);
|
||||
expect(completed).toBe(true);
|
||||
|
||||
const issue = await tracker.getIssue(issueIdentifier, project);
|
||||
expect(issue.state).toBe("closed");
|
||||
});
|
||||
|
||||
it("updateIssue reopens the issue", async () => {
|
||||
await tracker.updateIssue!(
|
||||
issueIdentifier,
|
||||
{ state: "open" },
|
||||
project,
|
||||
);
|
||||
|
||||
const completed = await tracker.isCompleted(issueIdentifier, project);
|
||||
expect(completed).toBe(false);
|
||||
|
||||
const issue = await tracker.getIssue(issueIdentifier, project);
|
||||
expect(issue.state).toBe("open");
|
||||
});
|
||||
});
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1 +1,556 @@
|
|||
// scm-github plugin — to be implemented
|
||||
/**
|
||||
* scm-github plugin — GitHub PRs, CI checks, reviews, merge readiness.
|
||||
*
|
||||
* Uses the `gh` CLI for all GitHub API interactions.
|
||||
*/
|
||||
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import type {
|
||||
PluginModule,
|
||||
SCM,
|
||||
Session,
|
||||
ProjectConfig,
|
||||
PRInfo,
|
||||
PRState,
|
||||
MergeMethod,
|
||||
CICheck,
|
||||
CIStatus,
|
||||
Review,
|
||||
ReviewDecision,
|
||||
ReviewComment,
|
||||
AutomatedComment,
|
||||
MergeReadiness,
|
||||
} from "@agent-orchestrator/core";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
/** Known bot logins that produce automated review comments */
|
||||
const BOT_AUTHORS = new Set([
|
||||
"cursor[bot]",
|
||||
"github-actions[bot]",
|
||||
"codecov[bot]",
|
||||
"sonarcloud[bot]",
|
||||
"dependabot[bot]",
|
||||
"renovate[bot]",
|
||||
"codeclimate[bot]",
|
||||
"deepsource-autofix[bot]",
|
||||
"snyk-bot",
|
||||
"lgtm-com[bot]",
|
||||
]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function gh(args: string[]): Promise<string> {
|
||||
try {
|
||||
const { stdout } = await execFileAsync("gh", args, {
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
timeout: 30_000,
|
||||
});
|
||||
return stdout.trim();
|
||||
} catch (err) {
|
||||
throw new Error(`gh ${args.slice(0, 3).join(" ")} failed: ${(err as Error).message}`, {
|
||||
cause: err,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function repoFlag(pr: PRInfo): string {
|
||||
return `${pr.owner}/${pr.repo}`;
|
||||
}
|
||||
|
||||
function parseDate(val: string | undefined | null): Date {
|
||||
if (!val) return new Date(0);
|
||||
const d = new Date(val);
|
||||
return isNaN(d.getTime()) ? new Date(0) : d;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SCM implementation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function createGitHubSCM(): SCM {
|
||||
return {
|
||||
name: "github",
|
||||
|
||||
async detectPR(
|
||||
session: Session,
|
||||
project: ProjectConfig,
|
||||
): Promise<PRInfo | null> {
|
||||
if (!session.branch) return null;
|
||||
|
||||
const parts = project.repo.split("/");
|
||||
if (parts.length !== 2 || !parts[0] || !parts[1]) {
|
||||
throw new Error(`Invalid repo format "${project.repo}", expected "owner/repo"`);
|
||||
}
|
||||
const [owner, repo] = parts;
|
||||
try {
|
||||
const raw = await gh([
|
||||
"pr",
|
||||
"list",
|
||||
"--repo",
|
||||
project.repo,
|
||||
"--head",
|
||||
session.branch,
|
||||
"--json",
|
||||
"number,url,title,headRefName,baseRefName,isDraft",
|
||||
"--limit",
|
||||
"1",
|
||||
]);
|
||||
|
||||
const prs: Array<{
|
||||
number: number;
|
||||
url: string;
|
||||
title: string;
|
||||
headRefName: string;
|
||||
baseRefName: string;
|
||||
isDraft: boolean;
|
||||
}> = JSON.parse(raw);
|
||||
|
||||
if (prs.length === 0) return null;
|
||||
|
||||
const pr = prs[0];
|
||||
return {
|
||||
number: pr.number,
|
||||
url: pr.url,
|
||||
title: pr.title,
|
||||
owner,
|
||||
repo,
|
||||
branch: pr.headRefName,
|
||||
baseBranch: pr.baseRefName,
|
||||
isDraft: pr.isDraft,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
async getPRState(pr: PRInfo): Promise<PRState> {
|
||||
const raw = await gh([
|
||||
"pr",
|
||||
"view",
|
||||
String(pr.number),
|
||||
"--repo",
|
||||
repoFlag(pr),
|
||||
"--json",
|
||||
"state",
|
||||
]);
|
||||
const data: { state: string } = JSON.parse(raw);
|
||||
const s = data.state.toUpperCase();
|
||||
if (s === "MERGED") return "merged";
|
||||
if (s === "CLOSED") return "closed";
|
||||
return "open";
|
||||
},
|
||||
|
||||
async mergePR(pr: PRInfo, method: MergeMethod = "squash"): Promise<void> {
|
||||
const flag =
|
||||
method === "rebase"
|
||||
? "--rebase"
|
||||
: method === "merge"
|
||||
? "--merge"
|
||||
: "--squash";
|
||||
|
||||
await gh([
|
||||
"pr",
|
||||
"merge",
|
||||
String(pr.number),
|
||||
"--repo",
|
||||
repoFlag(pr),
|
||||
flag,
|
||||
"--delete-branch",
|
||||
]);
|
||||
},
|
||||
|
||||
async closePR(pr: PRInfo): Promise<void> {
|
||||
await gh([
|
||||
"pr",
|
||||
"close",
|
||||
String(pr.number),
|
||||
"--repo",
|
||||
repoFlag(pr),
|
||||
]);
|
||||
},
|
||||
|
||||
async getCIChecks(pr: PRInfo): Promise<CICheck[]> {
|
||||
try {
|
||||
const raw = await gh([
|
||||
"pr",
|
||||
"checks",
|
||||
String(pr.number),
|
||||
"--repo",
|
||||
repoFlag(pr),
|
||||
"--json",
|
||||
"name,state,conclusion,detailsUrl,startedAt,completedAt",
|
||||
]);
|
||||
|
||||
const checks: Array<{
|
||||
name: string;
|
||||
state: string;
|
||||
conclusion: string;
|
||||
detailsUrl: string;
|
||||
startedAt: string;
|
||||
completedAt: string;
|
||||
}> = JSON.parse(raw);
|
||||
|
||||
return checks.map((c) => {
|
||||
let status: CICheck["status"];
|
||||
const state = c.state?.toUpperCase();
|
||||
const conclusion = c.conclusion?.toUpperCase();
|
||||
|
||||
if (state === "PENDING" || state === "QUEUED") {
|
||||
status = "pending";
|
||||
} else if (state === "IN_PROGRESS") {
|
||||
status = "running";
|
||||
} else if (conclusion === "SUCCESS") {
|
||||
status = "passed";
|
||||
} else if (
|
||||
conclusion === "FAILURE" ||
|
||||
conclusion === "TIMED_OUT" ||
|
||||
conclusion === "CANCELLED" ||
|
||||
conclusion === "ACTION_REQUIRED"
|
||||
) {
|
||||
status = "failed";
|
||||
} else if (conclusion === "SKIPPED" || conclusion === "NEUTRAL") {
|
||||
status = "skipped";
|
||||
} else {
|
||||
// Unknown conclusion on a completed check — fail closed
|
||||
status = "failed";
|
||||
}
|
||||
|
||||
return {
|
||||
name: c.name,
|
||||
status,
|
||||
url: c.detailsUrl || undefined,
|
||||
conclusion: c.conclusion || undefined,
|
||||
startedAt: c.startedAt ? new Date(c.startedAt) : undefined,
|
||||
completedAt: c.completedAt ? new Date(c.completedAt) : undefined,
|
||||
};
|
||||
});
|
||||
} catch (err) {
|
||||
// Propagate so callers (getCISummary) can decide how to handle.
|
||||
// Do NOT silently return [] — that causes a fail-open where CI
|
||||
// appears healthy when we simply failed to fetch check status.
|
||||
throw new Error("Failed to fetch CI checks", { cause: err });
|
||||
}
|
||||
},
|
||||
|
||||
async getCISummary(pr: PRInfo): Promise<CIStatus> {
|
||||
let checks: CICheck[];
|
||||
try {
|
||||
checks = await this.getCIChecks(pr);
|
||||
} catch {
|
||||
// Fail closed: if we can't fetch CI status, report as failing
|
||||
// rather than "none" (which getMergeability treats as passing).
|
||||
return "failing";
|
||||
}
|
||||
if (checks.length === 0) return "none";
|
||||
|
||||
const hasFailing = checks.some((c) => c.status === "failed");
|
||||
if (hasFailing) return "failing";
|
||||
|
||||
const hasPending = checks.some(
|
||||
(c) => c.status === "pending" || c.status === "running",
|
||||
);
|
||||
if (hasPending) return "pending";
|
||||
|
||||
// Only report passing if at least one check actually passed
|
||||
// (not all skipped)
|
||||
const hasPassing = checks.some((c) => c.status === "passed");
|
||||
if (!hasPassing) return "none";
|
||||
|
||||
return "passing";
|
||||
},
|
||||
|
||||
async getReviews(pr: PRInfo): Promise<Review[]> {
|
||||
const raw = await gh([
|
||||
"pr",
|
||||
"view",
|
||||
String(pr.number),
|
||||
"--repo",
|
||||
repoFlag(pr),
|
||||
"--json",
|
||||
"reviews",
|
||||
]);
|
||||
const data: {
|
||||
reviews: Array<{
|
||||
author: { login: string };
|
||||
state: string;
|
||||
body: string;
|
||||
submittedAt: string;
|
||||
}>;
|
||||
} = JSON.parse(raw);
|
||||
|
||||
return data.reviews.map((r) => {
|
||||
let state: Review["state"];
|
||||
const s = r.state?.toUpperCase();
|
||||
if (s === "APPROVED") state = "approved";
|
||||
else if (s === "CHANGES_REQUESTED") state = "changes_requested";
|
||||
else if (s === "DISMISSED") state = "dismissed";
|
||||
else if (s === "PENDING") state = "pending";
|
||||
else state = "commented";
|
||||
|
||||
return {
|
||||
author: r.author?.login ?? "unknown",
|
||||
state,
|
||||
body: r.body || undefined,
|
||||
submittedAt: parseDate(r.submittedAt),
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
async getReviewDecision(pr: PRInfo): Promise<ReviewDecision> {
|
||||
const raw = await gh([
|
||||
"pr",
|
||||
"view",
|
||||
String(pr.number),
|
||||
"--repo",
|
||||
repoFlag(pr),
|
||||
"--json",
|
||||
"reviewDecision",
|
||||
]);
|
||||
const data: { reviewDecision: string } = JSON.parse(raw);
|
||||
|
||||
const d = (data.reviewDecision ?? "").toUpperCase();
|
||||
if (d === "APPROVED") return "approved";
|
||||
if (d === "CHANGES_REQUESTED") return "changes_requested";
|
||||
if (d === "REVIEW_REQUIRED") return "pending";
|
||||
return "none";
|
||||
},
|
||||
|
||||
async getPendingComments(pr: PRInfo): Promise<ReviewComment[]> {
|
||||
try {
|
||||
// Use GraphQL with variables to get review threads with actual isResolved status
|
||||
const raw = await gh([
|
||||
"api",
|
||||
"graphql",
|
||||
"-f",
|
||||
`owner=${pr.owner}`,
|
||||
"-f",
|
||||
`name=${pr.repo}`,
|
||||
"-F",
|
||||
`number=${pr.number}`,
|
||||
"-f",
|
||||
`query=query($owner: String!, $name: String!, $number: Int!) {
|
||||
repository(owner: $owner, name: $name) {
|
||||
pullRequest(number: $number) {
|
||||
reviewThreads(first: 100) {
|
||||
nodes {
|
||||
isResolved
|
||||
comments(first: 1) {
|
||||
nodes {
|
||||
id
|
||||
author { login }
|
||||
body
|
||||
path
|
||||
line
|
||||
url
|
||||
createdAt
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`,
|
||||
]);
|
||||
|
||||
const data: {
|
||||
data: {
|
||||
repository: {
|
||||
pullRequest: {
|
||||
reviewThreads: {
|
||||
nodes: Array<{
|
||||
isResolved: boolean;
|
||||
comments: {
|
||||
nodes: Array<{
|
||||
id: string;
|
||||
author: { login: string } | null;
|
||||
body: string;
|
||||
path: string | null;
|
||||
line: number | null;
|
||||
url: string;
|
||||
createdAt: string;
|
||||
}>;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
} = JSON.parse(raw);
|
||||
|
||||
const threads =
|
||||
data.data.repository.pullRequest.reviewThreads.nodes;
|
||||
|
||||
return threads
|
||||
.filter((t) => {
|
||||
if (t.isResolved) return false; // only pending (unresolved) threads
|
||||
const c = t.comments.nodes[0];
|
||||
if (!c) return false; // skip threads with no comments
|
||||
const author = c.author?.login ?? "";
|
||||
return !BOT_AUTHORS.has(author);
|
||||
})
|
||||
.map((t) => {
|
||||
const c = t.comments.nodes[0];
|
||||
return {
|
||||
id: c.id,
|
||||
author: c.author?.login ?? "unknown",
|
||||
body: c.body,
|
||||
path: c.path || undefined,
|
||||
line: c.line ?? undefined,
|
||||
isResolved: t.isResolved,
|
||||
createdAt: parseDate(c.createdAt),
|
||||
url: c.url,
|
||||
};
|
||||
});
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
},
|
||||
|
||||
async getAutomatedComments(pr: PRInfo): Promise<AutomatedComment[]> {
|
||||
try {
|
||||
// Fetch all review comments with max page size (100 is GitHub's limit)
|
||||
const raw = await gh([
|
||||
"api",
|
||||
"-F",
|
||||
"per_page=100",
|
||||
`repos/${repoFlag(pr)}/pulls/${pr.number}/comments`,
|
||||
]);
|
||||
|
||||
const comments: Array<{
|
||||
id: number;
|
||||
user: { login: string };
|
||||
body: string;
|
||||
path: string;
|
||||
line: number | null;
|
||||
original_line: number | null;
|
||||
created_at: string;
|
||||
html_url: string;
|
||||
}> = JSON.parse(raw);
|
||||
|
||||
return comments
|
||||
.filter((c) => BOT_AUTHORS.has(c.user?.login ?? ""))
|
||||
.map((c) => {
|
||||
// Determine severity from body content
|
||||
let severity: AutomatedComment["severity"] = "info";
|
||||
const bodyLower = c.body.toLowerCase();
|
||||
if (
|
||||
bodyLower.includes("error") ||
|
||||
bodyLower.includes("bug") ||
|
||||
bodyLower.includes("critical") ||
|
||||
bodyLower.includes("potential issue")
|
||||
) {
|
||||
severity = "error";
|
||||
} else if (
|
||||
bodyLower.includes("warning") ||
|
||||
bodyLower.includes("suggest") ||
|
||||
bodyLower.includes("consider")
|
||||
) {
|
||||
severity = "warning";
|
||||
}
|
||||
|
||||
return {
|
||||
id: String(c.id),
|
||||
botName: c.user?.login ?? "unknown",
|
||||
body: c.body,
|
||||
path: c.path || undefined,
|
||||
line: c.line ?? c.original_line ?? undefined,
|
||||
severity,
|
||||
createdAt: parseDate(c.created_at),
|
||||
url: c.html_url,
|
||||
};
|
||||
});
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
},
|
||||
|
||||
async getMergeability(pr: PRInfo): Promise<MergeReadiness> {
|
||||
const blockers: string[] = [];
|
||||
|
||||
// Fetch PR details with merge state
|
||||
const raw = await gh([
|
||||
"pr",
|
||||
"view",
|
||||
String(pr.number),
|
||||
"--repo",
|
||||
repoFlag(pr),
|
||||
"--json",
|
||||
"mergeable,reviewDecision,mergeStateStatus,isDraft",
|
||||
]);
|
||||
|
||||
const data: {
|
||||
mergeable: string;
|
||||
reviewDecision: string;
|
||||
mergeStateStatus: string;
|
||||
isDraft: boolean;
|
||||
} = JSON.parse(raw);
|
||||
|
||||
// CI
|
||||
const ciStatus = await this.getCISummary(pr);
|
||||
const ciPassing = ciStatus === "passing" || ciStatus === "none";
|
||||
if (!ciPassing) {
|
||||
blockers.push(`CI is ${ciStatus}`);
|
||||
}
|
||||
|
||||
// Reviews
|
||||
const reviewDecision = (data.reviewDecision ?? "").toUpperCase();
|
||||
const approved = reviewDecision === "APPROVED";
|
||||
if (reviewDecision === "CHANGES_REQUESTED") {
|
||||
blockers.push("Changes requested in review");
|
||||
} else if (reviewDecision === "REVIEW_REQUIRED") {
|
||||
blockers.push("Review required");
|
||||
}
|
||||
|
||||
// Conflicts / merge state
|
||||
const mergeable = (data.mergeable ?? "").toUpperCase();
|
||||
const mergeState = (data.mergeStateStatus ?? "").toUpperCase();
|
||||
const noConflicts = mergeable === "MERGEABLE";
|
||||
if (mergeable === "CONFLICTING") {
|
||||
blockers.push("Merge conflicts");
|
||||
} else if (mergeable === "UNKNOWN" || mergeable === "") {
|
||||
blockers.push("Merge status unknown (GitHub is computing)");
|
||||
}
|
||||
if (mergeState === "BEHIND") {
|
||||
blockers.push("Branch is behind base branch");
|
||||
} else if (mergeState === "BLOCKED") {
|
||||
blockers.push("Merge is blocked by branch protection");
|
||||
} else if (mergeState === "UNSTABLE") {
|
||||
blockers.push("Required checks are failing");
|
||||
}
|
||||
|
||||
// Draft
|
||||
if (data.isDraft) {
|
||||
blockers.push("PR is still a draft");
|
||||
}
|
||||
|
||||
return {
|
||||
mergeable: blockers.length === 0,
|
||||
ciPassing,
|
||||
approved,
|
||||
noConflicts,
|
||||
blockers,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Plugin module export
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const manifest = {
|
||||
name: "github",
|
||||
slot: "scm" as const,
|
||||
description: "SCM plugin: GitHub PRs, CI checks, reviews, merge readiness",
|
||||
version: "0.1.0",
|
||||
};
|
||||
|
||||
export function create(): SCM {
|
||||
return createGitHubSCM();
|
||||
}
|
||||
|
||||
export default { manifest, create } satisfies PluginModule<SCM>;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,631 @@
|
|||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock node:child_process — gh CLI calls go through execFileAsync = promisify(execFile)
|
||||
// vi.hoisted ensures the mock fn is available when vi.mock factory runs (hoisted above imports)
|
||||
// ---------------------------------------------------------------------------
|
||||
const { ghMock } = vi.hoisted(() => ({ ghMock: vi.fn() }));
|
||||
|
||||
vi.mock("node:child_process", () => {
|
||||
// Attach the custom promisify symbol so `promisify(execFile)` returns ghMock
|
||||
const execFile = Object.assign(vi.fn(), {
|
||||
[Symbol.for("nodejs.util.promisify.custom")]: ghMock,
|
||||
});
|
||||
return { execFile };
|
||||
});
|
||||
|
||||
import { create, manifest } from "../src/index.js";
|
||||
import type { PRInfo, Session, ProjectConfig } from "@agent-orchestrator/core";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fixtures
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const pr: PRInfo = {
|
||||
number: 42,
|
||||
url: "https://github.com/acme/repo/pull/42",
|
||||
title: "feat: add feature",
|
||||
owner: "acme",
|
||||
repo: "repo",
|
||||
branch: "feat/my-feature",
|
||||
baseBranch: "main",
|
||||
isDraft: false,
|
||||
};
|
||||
|
||||
const project: ProjectConfig = {
|
||||
name: "test",
|
||||
repo: "acme/repo",
|
||||
path: "/tmp/repo",
|
||||
defaultBranch: "main",
|
||||
sessionPrefix: "test",
|
||||
};
|
||||
|
||||
function makeSession(overrides: Partial<Session> = {}): Session {
|
||||
return {
|
||||
id: "test-1",
|
||||
projectId: "test",
|
||||
status: "working",
|
||||
activity: "active",
|
||||
branch: "feat/my-feature",
|
||||
issueId: null,
|
||||
pr: null,
|
||||
workspacePath: "/tmp/repo",
|
||||
runtimeHandle: null,
|
||||
agentInfo: null,
|
||||
createdAt: new Date(),
|
||||
lastActivityAt: new Date(),
|
||||
metadata: {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function mockGh(result: unknown) {
|
||||
ghMock.mockResolvedValueOnce({ stdout: JSON.stringify(result) });
|
||||
}
|
||||
|
||||
function mockGhError(msg = "Command failed") {
|
||||
ghMock.mockRejectedValueOnce(new Error(msg));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("scm-github plugin", () => {
|
||||
let scm: ReturnType<typeof create>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
scm = create();
|
||||
});
|
||||
|
||||
// ---- manifest ----------------------------------------------------------
|
||||
|
||||
describe("manifest", () => {
|
||||
it("has correct metadata", () => {
|
||||
expect(manifest.name).toBe("github");
|
||||
expect(manifest.slot).toBe("scm");
|
||||
expect(manifest.version).toBe("0.1.0");
|
||||
});
|
||||
});
|
||||
|
||||
// ---- create() ----------------------------------------------------------
|
||||
|
||||
describe("create()", () => {
|
||||
it("returns an SCM with correct name", () => {
|
||||
expect(scm.name).toBe("github");
|
||||
});
|
||||
});
|
||||
|
||||
// ---- detectPR ----------------------------------------------------------
|
||||
|
||||
describe("detectPR", () => {
|
||||
it("returns PRInfo when a PR exists", async () => {
|
||||
mockGh([
|
||||
{
|
||||
number: 42,
|
||||
url: "https://github.com/acme/repo/pull/42",
|
||||
title: "feat: add feature",
|
||||
headRefName: "feat/my-feature",
|
||||
baseRefName: "main",
|
||||
isDraft: false,
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await scm.detectPR(makeSession(), project);
|
||||
expect(result).toEqual({
|
||||
number: 42,
|
||||
url: "https://github.com/acme/repo/pull/42",
|
||||
title: "feat: add feature",
|
||||
owner: "acme",
|
||||
repo: "repo",
|
||||
branch: "feat/my-feature",
|
||||
baseBranch: "main",
|
||||
isDraft: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null when no PR found", async () => {
|
||||
mockGh([]);
|
||||
const result = await scm.detectPR(makeSession(), project);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when session has no branch", async () => {
|
||||
const result = await scm.detectPR(makeSession({ branch: null }), project);
|
||||
expect(result).toBeNull();
|
||||
expect(ghMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns null on gh CLI error", async () => {
|
||||
mockGhError("gh: not found");
|
||||
const result = await scm.detectPR(makeSession(), project);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("throws on invalid repo format", async () => {
|
||||
const badProject = { ...project, repo: "no-slash" };
|
||||
await expect(scm.detectPR(makeSession(), badProject)).rejects.toThrow("Invalid repo format");
|
||||
});
|
||||
|
||||
it("detects draft PRs", async () => {
|
||||
mockGh([
|
||||
{
|
||||
number: 99,
|
||||
url: "https://github.com/acme/repo/pull/99",
|
||||
title: "WIP: draft feature",
|
||||
headRefName: "feat/my-feature",
|
||||
baseRefName: "main",
|
||||
isDraft: true,
|
||||
},
|
||||
]);
|
||||
const result = await scm.detectPR(makeSession(), project);
|
||||
expect(result?.isDraft).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- getPRState --------------------------------------------------------
|
||||
|
||||
describe("getPRState", () => {
|
||||
it('returns "open" for open PR', async () => {
|
||||
mockGh({ state: "OPEN" });
|
||||
expect(await scm.getPRState(pr)).toBe("open");
|
||||
});
|
||||
|
||||
it('returns "merged" for merged PR', async () => {
|
||||
mockGh({ state: "MERGED" });
|
||||
expect(await scm.getPRState(pr)).toBe("merged");
|
||||
});
|
||||
|
||||
it('returns "closed" for closed PR', async () => {
|
||||
mockGh({ state: "CLOSED" });
|
||||
expect(await scm.getPRState(pr)).toBe("closed");
|
||||
});
|
||||
|
||||
it("handles lowercase state strings", async () => {
|
||||
mockGh({ state: "merged" });
|
||||
expect(await scm.getPRState(pr)).toBe("merged");
|
||||
});
|
||||
});
|
||||
|
||||
// ---- mergePR -----------------------------------------------------------
|
||||
|
||||
describe("mergePR", () => {
|
||||
it("uses --squash by default", async () => {
|
||||
ghMock.mockResolvedValueOnce({ stdout: "" });
|
||||
await scm.mergePR(pr);
|
||||
expect(ghMock).toHaveBeenCalledWith(
|
||||
"gh",
|
||||
["pr", "merge", "42", "--repo", "acme/repo", "--squash", "--delete-branch"],
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses --merge when specified", async () => {
|
||||
ghMock.mockResolvedValueOnce({ stdout: "" });
|
||||
await scm.mergePR(pr, "merge");
|
||||
expect(ghMock).toHaveBeenCalledWith(
|
||||
"gh",
|
||||
expect.arrayContaining(["--merge"]),
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses --rebase when specified", async () => {
|
||||
ghMock.mockResolvedValueOnce({ stdout: "" });
|
||||
await scm.mergePR(pr, "rebase");
|
||||
expect(ghMock).toHaveBeenCalledWith(
|
||||
"gh",
|
||||
expect.arrayContaining(["--rebase"]),
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- closePR -----------------------------------------------------------
|
||||
|
||||
describe("closePR", () => {
|
||||
it("calls gh pr close", async () => {
|
||||
ghMock.mockResolvedValueOnce({ stdout: "" });
|
||||
await scm.closePR(pr);
|
||||
expect(ghMock).toHaveBeenCalledWith(
|
||||
"gh",
|
||||
["pr", "close", "42", "--repo", "acme/repo"],
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- getCIChecks -------------------------------------------------------
|
||||
|
||||
describe("getCIChecks", () => {
|
||||
it("maps various check states correctly", async () => {
|
||||
mockGh([
|
||||
{ name: "build", state: "COMPLETED", conclusion: "SUCCESS", detailsUrl: "https://ci/1", startedAt: "2025-01-01T00:00:00Z", completedAt: "2025-01-01T00:05:00Z" },
|
||||
{ name: "lint", state: "COMPLETED", conclusion: "FAILURE", detailsUrl: "", startedAt: "", completedAt: "" },
|
||||
{ name: "deploy", state: "PENDING", conclusion: "", detailsUrl: "", startedAt: "", completedAt: "" },
|
||||
{ name: "e2e", state: "IN_PROGRESS", conclusion: "", detailsUrl: "", startedAt: "", completedAt: "" },
|
||||
{ name: "optional", state: "COMPLETED", conclusion: "SKIPPED", detailsUrl: "", startedAt: "", completedAt: "" },
|
||||
{ name: "neutral", state: "COMPLETED", conclusion: "NEUTRAL", detailsUrl: "", startedAt: "", completedAt: "" },
|
||||
{ name: "timeout", state: "COMPLETED", conclusion: "TIMED_OUT", detailsUrl: "", startedAt: "", completedAt: "" },
|
||||
{ name: "queued", state: "QUEUED", conclusion: "", detailsUrl: "", startedAt: "", completedAt: "" },
|
||||
{ name: "cancelled", state: "COMPLETED", conclusion: "CANCELLED", detailsUrl: "", startedAt: "", completedAt: "" },
|
||||
{ name: "action_req", state: "COMPLETED", conclusion: "ACTION_REQUIRED", detailsUrl: "", startedAt: "", completedAt: "" },
|
||||
]);
|
||||
|
||||
const checks = await scm.getCIChecks(pr);
|
||||
expect(checks).toHaveLength(10);
|
||||
expect(checks[0].status).toBe("passed");
|
||||
expect(checks[0].url).toBe("https://ci/1");
|
||||
expect(checks[1].status).toBe("failed");
|
||||
expect(checks[2].status).toBe("pending");
|
||||
expect(checks[3].status).toBe("running");
|
||||
expect(checks[4].status).toBe("skipped");
|
||||
expect(checks[5].status).toBe("skipped");
|
||||
expect(checks[6].status).toBe("failed");
|
||||
expect(checks[7].status).toBe("pending");
|
||||
expect(checks[8].status).toBe("failed"); // CANCELLED
|
||||
expect(checks[9].status).toBe("failed"); // ACTION_REQUIRED
|
||||
});
|
||||
|
||||
it("throws on error (fail-closed)", async () => {
|
||||
mockGhError("no checks");
|
||||
await expect(scm.getCIChecks(pr)).rejects.toThrow("Failed to fetch CI checks");
|
||||
});
|
||||
|
||||
it("returns empty array for PR with no checks", async () => {
|
||||
mockGh([]);
|
||||
expect(await scm.getCIChecks(pr)).toEqual([]);
|
||||
});
|
||||
|
||||
it("handles missing optional fields gracefully", async () => {
|
||||
mockGh([{ name: "test", state: "COMPLETED", conclusion: "SUCCESS" }]);
|
||||
const checks = await scm.getCIChecks(pr);
|
||||
expect(checks[0].url).toBeUndefined();
|
||||
expect(checks[0].startedAt).toBeUndefined();
|
||||
expect(checks[0].completedAt).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ---- getCISummary ------------------------------------------------------
|
||||
|
||||
describe("getCISummary", () => {
|
||||
it('returns "failing" when any check failed', async () => {
|
||||
mockGh([
|
||||
{ name: "a", state: "COMPLETED", conclusion: "SUCCESS" },
|
||||
{ name: "b", state: "COMPLETED", conclusion: "FAILURE" },
|
||||
]);
|
||||
expect(await scm.getCISummary(pr)).toBe("failing");
|
||||
});
|
||||
|
||||
it('returns "pending" when checks are running', async () => {
|
||||
mockGh([
|
||||
{ name: "a", state: "COMPLETED", conclusion: "SUCCESS" },
|
||||
{ name: "b", state: "IN_PROGRESS", conclusion: "" },
|
||||
]);
|
||||
expect(await scm.getCISummary(pr)).toBe("pending");
|
||||
});
|
||||
|
||||
it('returns "passing" when all checks passed', async () => {
|
||||
mockGh([
|
||||
{ name: "a", state: "COMPLETED", conclusion: "SUCCESS" },
|
||||
{ name: "b", state: "COMPLETED", conclusion: "SUCCESS" },
|
||||
]);
|
||||
expect(await scm.getCISummary(pr)).toBe("passing");
|
||||
});
|
||||
|
||||
it('returns "none" when no checks', async () => {
|
||||
mockGh([]);
|
||||
expect(await scm.getCISummary(pr)).toBe("none");
|
||||
});
|
||||
|
||||
it('returns "failing" on error (fail-closed)', async () => {
|
||||
mockGhError();
|
||||
expect(await scm.getCISummary(pr)).toBe("failing");
|
||||
});
|
||||
|
||||
it('returns "none" when all checks are skipped', async () => {
|
||||
mockGh([
|
||||
{ name: "a", state: "COMPLETED", conclusion: "SKIPPED" },
|
||||
{ name: "b", state: "COMPLETED", conclusion: "NEUTRAL" },
|
||||
]);
|
||||
expect(await scm.getCISummary(pr)).toBe("none");
|
||||
});
|
||||
});
|
||||
|
||||
// ---- getReviews --------------------------------------------------------
|
||||
|
||||
describe("getReviews", () => {
|
||||
it("maps review states correctly", async () => {
|
||||
mockGh({
|
||||
reviews: [
|
||||
{ author: { login: "alice" }, state: "APPROVED", body: "LGTM", submittedAt: "2025-01-01T00:00:00Z" },
|
||||
{ author: { login: "bob" }, state: "CHANGES_REQUESTED", body: "Fix this", submittedAt: "2025-01-02T00:00:00Z" },
|
||||
{ author: { login: "charlie" }, state: "COMMENTED", body: "", submittedAt: "2025-01-03T00:00:00Z" },
|
||||
{ author: { login: "eve" }, state: "DISMISSED", body: "", submittedAt: "2025-01-04T00:00:00Z" },
|
||||
{ author: { login: "frank" }, state: "PENDING", body: "", submittedAt: null },
|
||||
],
|
||||
});
|
||||
|
||||
const reviews = await scm.getReviews(pr);
|
||||
expect(reviews).toHaveLength(5);
|
||||
expect(reviews[0]).toMatchObject({ author: "alice", state: "approved" });
|
||||
expect(reviews[1]).toMatchObject({ author: "bob", state: "changes_requested" });
|
||||
expect(reviews[2]).toMatchObject({ author: "charlie", state: "commented" });
|
||||
expect(reviews[3]).toMatchObject({ author: "eve", state: "dismissed" });
|
||||
expect(reviews[4]).toMatchObject({ author: "frank", state: "pending" });
|
||||
});
|
||||
|
||||
it("handles empty reviews", async () => {
|
||||
mockGh({ reviews: [] });
|
||||
expect(await scm.getReviews(pr)).toEqual([]);
|
||||
});
|
||||
|
||||
it('defaults to "unknown" author when missing', async () => {
|
||||
mockGh({
|
||||
reviews: [{ author: null, state: "APPROVED", body: "", submittedAt: "2025-01-01T00:00:00Z" }],
|
||||
});
|
||||
const reviews = await scm.getReviews(pr);
|
||||
expect(reviews[0].author).toBe("unknown");
|
||||
});
|
||||
});
|
||||
|
||||
// ---- getReviewDecision -------------------------------------------------
|
||||
|
||||
describe("getReviewDecision", () => {
|
||||
it.each([
|
||||
["APPROVED", "approved"],
|
||||
["CHANGES_REQUESTED", "changes_requested"],
|
||||
["REVIEW_REQUIRED", "pending"],
|
||||
] as const)('maps %s to "%s"', async (input, expected) => {
|
||||
mockGh({ reviewDecision: input });
|
||||
expect(await scm.getReviewDecision(pr)).toBe(expected);
|
||||
});
|
||||
|
||||
it('returns "none" when reviewDecision is empty', async () => {
|
||||
mockGh({ reviewDecision: "" });
|
||||
expect(await scm.getReviewDecision(pr)).toBe("none");
|
||||
});
|
||||
|
||||
it('returns "none" when reviewDecision is null', async () => {
|
||||
mockGh({ reviewDecision: null });
|
||||
expect(await scm.getReviewDecision(pr)).toBe("none");
|
||||
});
|
||||
});
|
||||
|
||||
// ---- getPendingComments ------------------------------------------------
|
||||
|
||||
describe("getPendingComments", () => {
|
||||
function makeGraphQLThreads(
|
||||
threads: Array<{
|
||||
isResolved: boolean;
|
||||
id: string;
|
||||
author: string | null;
|
||||
body: string;
|
||||
path: string | null;
|
||||
line: number | null;
|
||||
url: string;
|
||||
createdAt: string;
|
||||
}>,
|
||||
) {
|
||||
return {
|
||||
data: {
|
||||
repository: {
|
||||
pullRequest: {
|
||||
reviewThreads: {
|
||||
nodes: threads.map((t) => ({
|
||||
isResolved: t.isResolved,
|
||||
comments: {
|
||||
nodes: [
|
||||
{
|
||||
id: t.id,
|
||||
author: t.author ? { login: t.author } : null,
|
||||
body: t.body,
|
||||
path: t.path,
|
||||
line: t.line,
|
||||
url: t.url,
|
||||
createdAt: t.createdAt,
|
||||
},
|
||||
],
|
||||
},
|
||||
})),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
it("returns only unresolved non-bot comments from GraphQL", async () => {
|
||||
mockGh(
|
||||
makeGraphQLThreads([
|
||||
{ isResolved: false, id: "C1", author: "alice", body: "Fix line 10", path: "src/foo.ts", line: 10, url: "https://github.com/c/1", createdAt: "2025-01-01T00:00:00Z" },
|
||||
{ isResolved: true, id: "C2", author: "bob", body: "Resolved one", path: "src/bar.ts", line: 20, url: "https://github.com/c/2", createdAt: "2025-01-02T00:00:00Z" },
|
||||
]),
|
||||
);
|
||||
|
||||
const comments = await scm.getPendingComments(pr);
|
||||
expect(comments).toHaveLength(1);
|
||||
expect(comments[0]).toMatchObject({ id: "C1", author: "alice", isResolved: false });
|
||||
});
|
||||
|
||||
it("filters out bot comments", async () => {
|
||||
mockGh(
|
||||
makeGraphQLThreads([
|
||||
{ isResolved: false, id: "C1", author: "alice", body: "Fix this", path: "a.ts", line: 1, url: "u", createdAt: "2025-01-01T00:00:00Z" },
|
||||
{ isResolved: false, id: "C2", author: "cursor[bot]", body: "Bot says", path: "a.ts", line: 2, url: "u", createdAt: "2025-01-01T00:00:00Z" },
|
||||
{ isResolved: false, id: "C3", author: "codecov[bot]", body: "Coverage", path: "a.ts", line: 3, url: "u", createdAt: "2025-01-01T00:00:00Z" },
|
||||
]),
|
||||
);
|
||||
|
||||
const comments = await scm.getPendingComments(pr);
|
||||
expect(comments).toHaveLength(1);
|
||||
expect(comments[0].author).toBe("alice");
|
||||
});
|
||||
|
||||
it("returns empty on error", async () => {
|
||||
mockGhError("API rate limit");
|
||||
expect(await scm.getPendingComments(pr)).toEqual([]);
|
||||
});
|
||||
|
||||
it("handles null path and line", async () => {
|
||||
mockGh(
|
||||
makeGraphQLThreads([
|
||||
{ isResolved: false, id: "C1", author: "alice", body: "General comment", path: null, line: null, url: "u", createdAt: "2025-01-01T00:00:00Z" },
|
||||
]),
|
||||
);
|
||||
const comments = await scm.getPendingComments(pr);
|
||||
expect(comments[0].path).toBeUndefined();
|
||||
expect(comments[0].line).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ---- getAutomatedComments ----------------------------------------------
|
||||
|
||||
describe("getAutomatedComments", () => {
|
||||
it("returns bot comments filtered from all PR comments", async () => {
|
||||
mockGh([
|
||||
{ id: 1, user: { login: "cursor[bot]" }, body: "Found a potential issue", path: "a.ts", line: 5, original_line: null, created_at: "2025-01-01T00:00:00Z", html_url: "u1" },
|
||||
{ id: 2, user: { login: "alice" }, body: "Human comment", path: "a.ts", line: 1, original_line: null, created_at: "2025-01-01T00:00:00Z", html_url: "u2" },
|
||||
]);
|
||||
|
||||
const comments = await scm.getAutomatedComments(pr);
|
||||
expect(comments).toHaveLength(1);
|
||||
expect(comments[0].botName).toBe("cursor[bot]");
|
||||
expect(comments[0].severity).toBe("error"); // "potential issue" → error
|
||||
});
|
||||
|
||||
it("classifies severity from body content", async () => {
|
||||
mockGh([
|
||||
{ id: 1, user: { login: "github-actions[bot]" }, body: "Error: build failed", path: "a.ts", line: 1, original_line: null, created_at: "2025-01-01T00:00:00Z", html_url: "u" },
|
||||
{ id: 2, user: { login: "github-actions[bot]" }, body: "Warning: deprecated API", path: "a.ts", line: 2, original_line: null, created_at: "2025-01-01T00:00:00Z", html_url: "u" },
|
||||
{ id: 3, user: { login: "github-actions[bot]" }, body: "Deployed to staging", path: "a.ts", line: 3, original_line: null, created_at: "2025-01-01T00:00:00Z", html_url: "u" },
|
||||
]);
|
||||
|
||||
const comments = await scm.getAutomatedComments(pr);
|
||||
expect(comments).toHaveLength(3);
|
||||
expect(comments[0].severity).toBe("error");
|
||||
expect(comments[1].severity).toBe("warning");
|
||||
expect(comments[2].severity).toBe("info");
|
||||
});
|
||||
|
||||
it("returns empty when no bot comments", async () => {
|
||||
mockGh([
|
||||
{ id: 1, user: { login: "alice" }, body: "Human comment", path: "a.ts", line: 1, original_line: null, created_at: "2025-01-01T00:00:00Z", html_url: "u" },
|
||||
]);
|
||||
|
||||
const comments = await scm.getAutomatedComments(pr);
|
||||
expect(comments).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns empty on error", async () => {
|
||||
mockGhError("network failure");
|
||||
expect(await scm.getAutomatedComments(pr)).toEqual([]);
|
||||
});
|
||||
|
||||
it("uses original_line as fallback", async () => {
|
||||
mockGh([
|
||||
{ id: 1, user: { login: "dependabot[bot]" }, body: "Suggest update", path: "a.ts", line: null, original_line: 15, created_at: "2025-01-01T00:00:00Z", html_url: "u" },
|
||||
]);
|
||||
|
||||
const comments = await scm.getAutomatedComments(pr);
|
||||
expect(comments[0].line).toBe(15);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- getMergeability ---------------------------------------------------
|
||||
|
||||
describe("getMergeability", () => {
|
||||
it("returns mergeable when everything is clear", async () => {
|
||||
// PR view
|
||||
mockGh({ mergeable: "MERGEABLE", reviewDecision: "APPROVED", mergeStateStatus: "CLEAN", isDraft: false });
|
||||
// CI checks (called by getCISummary)
|
||||
mockGh([{ name: "build", state: "COMPLETED", conclusion: "SUCCESS" }]);
|
||||
|
||||
const result = await scm.getMergeability(pr);
|
||||
expect(result).toEqual({
|
||||
mergeable: true,
|
||||
ciPassing: true,
|
||||
approved: true,
|
||||
noConflicts: true,
|
||||
blockers: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("reports CI failures as blockers", async () => {
|
||||
mockGh({ mergeable: "MERGEABLE", reviewDecision: "APPROVED", mergeStateStatus: "UNSTABLE", isDraft: false });
|
||||
mockGh([{ name: "build", state: "COMPLETED", conclusion: "FAILURE" }]);
|
||||
|
||||
const result = await scm.getMergeability(pr);
|
||||
expect(result.ciPassing).toBe(false);
|
||||
expect(result.mergeable).toBe(false);
|
||||
expect(result.blockers).toContain("CI is failing");
|
||||
expect(result.blockers).toContain("Required checks are failing");
|
||||
});
|
||||
|
||||
it("reports UNSTABLE merge state even when CI fetch fails", async () => {
|
||||
mockGh({ mergeable: "MERGEABLE", reviewDecision: "APPROVED", mergeStateStatus: "UNSTABLE", isDraft: false });
|
||||
mockGhError("rate limited");
|
||||
|
||||
const result = await scm.getMergeability(pr);
|
||||
expect(result.ciPassing).toBe(false);
|
||||
expect(result.mergeable).toBe(false);
|
||||
expect(result.blockers).toContain("CI is failing");
|
||||
expect(result.blockers).toContain("Required checks are failing");
|
||||
});
|
||||
|
||||
it("reports changes requested as blockers", async () => {
|
||||
mockGh({ mergeable: "MERGEABLE", reviewDecision: "CHANGES_REQUESTED", mergeStateStatus: "CLEAN", isDraft: false });
|
||||
mockGh([]); // no CI checks
|
||||
|
||||
const result = await scm.getMergeability(pr);
|
||||
expect(result.approved).toBe(false);
|
||||
expect(result.blockers).toContain("Changes requested in review");
|
||||
});
|
||||
|
||||
it("reports review required as blocker", async () => {
|
||||
mockGh({ mergeable: "MERGEABLE", reviewDecision: "REVIEW_REQUIRED", mergeStateStatus: "BLOCKED", isDraft: false });
|
||||
mockGh([]);
|
||||
|
||||
const result = await scm.getMergeability(pr);
|
||||
expect(result.blockers).toContain("Review required");
|
||||
});
|
||||
|
||||
it("reports merge conflicts as blockers", async () => {
|
||||
mockGh({ mergeable: "CONFLICTING", reviewDecision: "APPROVED", mergeStateStatus: "DIRTY", isDraft: false });
|
||||
mockGh([]);
|
||||
|
||||
const result = await scm.getMergeability(pr);
|
||||
expect(result.noConflicts).toBe(false);
|
||||
expect(result.blockers).toContain("Merge conflicts");
|
||||
});
|
||||
|
||||
it("reports UNKNOWN mergeable as noConflicts false", async () => {
|
||||
mockGh({ mergeable: "UNKNOWN", reviewDecision: "APPROVED", mergeStateStatus: "CLEAN", isDraft: false });
|
||||
mockGh([{ name: "build", state: "COMPLETED", conclusion: "SUCCESS" }]);
|
||||
|
||||
const result = await scm.getMergeability(pr);
|
||||
expect(result.noConflicts).toBe(false);
|
||||
expect(result.blockers).toContain("Merge status unknown (GitHub is computing)");
|
||||
expect(result.mergeable).toBe(false);
|
||||
});
|
||||
|
||||
it("reports draft status as blocker", async () => {
|
||||
mockGh({ mergeable: "MERGEABLE", reviewDecision: "APPROVED", mergeStateStatus: "DRAFT", isDraft: true });
|
||||
mockGh([{ name: "build", state: "COMPLETED", conclusion: "SUCCESS" }]);
|
||||
|
||||
const result = await scm.getMergeability(pr);
|
||||
expect(result.blockers).toContain("PR is still a draft");
|
||||
expect(result.mergeable).toBe(false);
|
||||
});
|
||||
|
||||
it("reports multiple blockers simultaneously", async () => {
|
||||
mockGh({ mergeable: "CONFLICTING", reviewDecision: "CHANGES_REQUESTED", mergeStateStatus: "DIRTY", isDraft: true });
|
||||
mockGh([{ name: "build", state: "COMPLETED", conclusion: "FAILURE" }]);
|
||||
|
||||
const result = await scm.getMergeability(pr);
|
||||
expect(result.blockers).toHaveLength(4);
|
||||
expect(result.mergeable).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1 +1,321 @@
|
|||
// tracker-github plugin — to be implemented
|
||||
/**
|
||||
* tracker-github plugin — GitHub Issues as an issue tracker.
|
||||
*
|
||||
* Uses the `gh` CLI for all GitHub API interactions.
|
||||
*/
|
||||
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import type {
|
||||
PluginModule,
|
||||
Tracker,
|
||||
Issue,
|
||||
IssueFilters,
|
||||
IssueUpdate,
|
||||
CreateIssueInput,
|
||||
ProjectConfig,
|
||||
} from "@agent-orchestrator/core";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function gh(args: string[]): Promise<string> {
|
||||
try {
|
||||
const { stdout } = await execFileAsync("gh", args, {
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
timeout: 30_000,
|
||||
});
|
||||
return stdout.trim();
|
||||
} catch (err) {
|
||||
throw new Error(`gh ${args.slice(0, 3).join(" ")} failed: ${(err as Error).message}`, {
|
||||
cause: err,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function mapState(
|
||||
ghState: string,
|
||||
stateReason?: string | null,
|
||||
): Issue["state"] {
|
||||
const s = ghState.toUpperCase();
|
||||
if (s === "CLOSED") {
|
||||
if (stateReason?.toUpperCase() === "NOT_PLANNED") return "cancelled";
|
||||
return "closed";
|
||||
}
|
||||
return "open";
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tracker implementation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function createGitHubTracker(): Tracker {
|
||||
return {
|
||||
name: "github",
|
||||
|
||||
async getIssue(
|
||||
identifier: string,
|
||||
project: ProjectConfig,
|
||||
): Promise<Issue> {
|
||||
const raw = await gh([
|
||||
"issue",
|
||||
"view",
|
||||
identifier,
|
||||
"--repo",
|
||||
project.repo,
|
||||
"--json",
|
||||
"number,title,body,url,state,stateReason,labels,assignees",
|
||||
]);
|
||||
|
||||
const data: {
|
||||
number: number;
|
||||
title: string;
|
||||
body: string;
|
||||
url: string;
|
||||
state: string;
|
||||
stateReason: string | null;
|
||||
labels: Array<{ name: string }>;
|
||||
assignees: Array<{ login: string }>;
|
||||
} = JSON.parse(raw);
|
||||
|
||||
return {
|
||||
id: String(data.number),
|
||||
title: data.title,
|
||||
description: data.body ?? "",
|
||||
url: data.url,
|
||||
state: mapState(data.state, data.stateReason),
|
||||
labels: data.labels.map((l) => l.name),
|
||||
assignee: data.assignees[0]?.login,
|
||||
};
|
||||
},
|
||||
|
||||
async isCompleted(
|
||||
identifier: string,
|
||||
project: ProjectConfig,
|
||||
): Promise<boolean> {
|
||||
const raw = await gh([
|
||||
"issue",
|
||||
"view",
|
||||
identifier,
|
||||
"--repo",
|
||||
project.repo,
|
||||
"--json",
|
||||
"state",
|
||||
]);
|
||||
const data: { state: string } = JSON.parse(raw);
|
||||
return data.state.toUpperCase() === "CLOSED";
|
||||
},
|
||||
|
||||
issueUrl(identifier: string, project: ProjectConfig): string {
|
||||
const num = identifier.replace(/^#/, "");
|
||||
return `https://github.com/${project.repo}/issues/${num}`;
|
||||
},
|
||||
|
||||
branchName(identifier: string, _project: ProjectConfig): string {
|
||||
const num = identifier.replace(/^#/, "");
|
||||
return `feat/issue-${num}`;
|
||||
},
|
||||
|
||||
async generatePrompt(
|
||||
identifier: string,
|
||||
project: ProjectConfig,
|
||||
): Promise<string> {
|
||||
const issue = await this.getIssue(identifier, project);
|
||||
const lines = [
|
||||
`You are working on GitHub issue #${issue.id}: ${issue.title}`,
|
||||
`Issue URL: ${issue.url}`,
|
||||
"",
|
||||
];
|
||||
|
||||
if (issue.labels.length > 0) {
|
||||
lines.push(`Labels: ${issue.labels.join(", ")}`);
|
||||
}
|
||||
|
||||
if (issue.description) {
|
||||
lines.push("## Description", "", issue.description);
|
||||
}
|
||||
|
||||
lines.push(
|
||||
"",
|
||||
"Please implement the changes described in this issue. When done, commit and push your changes.",
|
||||
);
|
||||
|
||||
return lines.join("\n");
|
||||
},
|
||||
|
||||
async listIssues(
|
||||
filters: IssueFilters,
|
||||
project: ProjectConfig,
|
||||
): Promise<Issue[]> {
|
||||
const args = [
|
||||
"issue",
|
||||
"list",
|
||||
"--repo",
|
||||
project.repo,
|
||||
"--json",
|
||||
"number,title,body,url,state,stateReason,labels,assignees",
|
||||
"--limit",
|
||||
String(filters.limit ?? 30),
|
||||
];
|
||||
|
||||
if (filters.state === "closed") {
|
||||
args.push("--state", "closed");
|
||||
} else if (filters.state === "all") {
|
||||
args.push("--state", "all");
|
||||
} else {
|
||||
args.push("--state", "open");
|
||||
}
|
||||
|
||||
if (filters.labels && filters.labels.length > 0) {
|
||||
args.push("--label", filters.labels.join(","));
|
||||
}
|
||||
|
||||
if (filters.assignee) {
|
||||
args.push("--assignee", filters.assignee);
|
||||
}
|
||||
|
||||
const raw = await gh(args);
|
||||
const issues: Array<{
|
||||
number: number;
|
||||
title: string;
|
||||
body: string;
|
||||
url: string;
|
||||
state: string;
|
||||
stateReason: string | null;
|
||||
labels: Array<{ name: string }>;
|
||||
assignees: Array<{ login: string }>;
|
||||
}> = JSON.parse(raw);
|
||||
|
||||
return issues.map((data) => ({
|
||||
id: String(data.number),
|
||||
title: data.title,
|
||||
description: data.body ?? "",
|
||||
url: data.url,
|
||||
state: mapState(data.state, data.stateReason),
|
||||
labels: data.labels.map((l) => l.name),
|
||||
assignee: data.assignees[0]?.login,
|
||||
}));
|
||||
},
|
||||
|
||||
async updateIssue(
|
||||
identifier: string,
|
||||
update: IssueUpdate,
|
||||
project: ProjectConfig,
|
||||
): Promise<void> {
|
||||
// Handle state change — GitHub Issues only supports open/closed.
|
||||
// "in_progress" is not a GitHub state, so it is intentionally a no-op.
|
||||
if (update.state === "closed") {
|
||||
await gh([
|
||||
"issue",
|
||||
"close",
|
||||
identifier,
|
||||
"--repo",
|
||||
project.repo,
|
||||
]);
|
||||
} else if (update.state === "open") {
|
||||
await gh([
|
||||
"issue",
|
||||
"reopen",
|
||||
identifier,
|
||||
"--repo",
|
||||
project.repo,
|
||||
]);
|
||||
}
|
||||
|
||||
// Handle label changes
|
||||
if (update.labels && update.labels.length > 0) {
|
||||
await gh([
|
||||
"issue",
|
||||
"edit",
|
||||
identifier,
|
||||
"--repo",
|
||||
project.repo,
|
||||
"--add-label",
|
||||
update.labels.join(","),
|
||||
]);
|
||||
}
|
||||
|
||||
// Handle assignee changes
|
||||
if (update.assignee) {
|
||||
await gh([
|
||||
"issue",
|
||||
"edit",
|
||||
identifier,
|
||||
"--repo",
|
||||
project.repo,
|
||||
"--add-assignee",
|
||||
update.assignee,
|
||||
]);
|
||||
}
|
||||
|
||||
// Handle comment
|
||||
if (update.comment) {
|
||||
await gh([
|
||||
"issue",
|
||||
"comment",
|
||||
identifier,
|
||||
"--repo",
|
||||
project.repo,
|
||||
"--body",
|
||||
update.comment,
|
||||
]);
|
||||
}
|
||||
},
|
||||
|
||||
async createIssue(
|
||||
input: CreateIssueInput,
|
||||
project: ProjectConfig,
|
||||
): Promise<Issue> {
|
||||
const args = [
|
||||
"issue",
|
||||
"create",
|
||||
"--repo",
|
||||
project.repo,
|
||||
"--title",
|
||||
input.title,
|
||||
"--body",
|
||||
input.description ?? "",
|
||||
];
|
||||
|
||||
if (input.labels && input.labels.length > 0) {
|
||||
args.push("--label", input.labels.join(","));
|
||||
}
|
||||
|
||||
if (input.assignee) {
|
||||
args.push("--assignee", input.assignee);
|
||||
}
|
||||
|
||||
// gh issue create outputs the URL of the new issue
|
||||
const url = await gh(args);
|
||||
|
||||
// Extract issue number from URL and fetch full details
|
||||
const match = url.match(/\/issues\/(\d+)/);
|
||||
if (!match) {
|
||||
throw new Error(`Failed to parse issue URL from gh output: ${url}`);
|
||||
}
|
||||
const number = match[1];
|
||||
|
||||
return this.getIssue(number, project);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Plugin module export
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const manifest = {
|
||||
name: "github",
|
||||
slot: "tracker" as const,
|
||||
description: "Tracker plugin: GitHub Issues",
|
||||
version: "0.1.0",
|
||||
};
|
||||
|
||||
export function create(): Tracker {
|
||||
return createGitHubTracker();
|
||||
}
|
||||
|
||||
export default { manifest, create } satisfies PluginModule<Tracker>;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,395 @@
|
|||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock node:child_process
|
||||
// ---------------------------------------------------------------------------
|
||||
const { ghMock } = vi.hoisted(() => ({ ghMock: vi.fn() }));
|
||||
|
||||
vi.mock("node:child_process", () => {
|
||||
const execFile = Object.assign(vi.fn(), {
|
||||
[Symbol.for("nodejs.util.promisify.custom")]: ghMock,
|
||||
});
|
||||
return { execFile };
|
||||
});
|
||||
|
||||
import { create, manifest } from "../src/index.js";
|
||||
import type { ProjectConfig } from "@agent-orchestrator/core";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fixtures
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const project: ProjectConfig = {
|
||||
name: "test",
|
||||
repo: "acme/repo",
|
||||
path: "/tmp/repo",
|
||||
defaultBranch: "main",
|
||||
sessionPrefix: "test",
|
||||
};
|
||||
|
||||
function mockGh(result: unknown) {
|
||||
ghMock.mockResolvedValueOnce({ stdout: JSON.stringify(result) });
|
||||
}
|
||||
|
||||
function mockGhRaw(stdout: string) {
|
||||
ghMock.mockResolvedValueOnce({ stdout });
|
||||
}
|
||||
|
||||
function mockGhError(msg = "Command failed") {
|
||||
ghMock.mockRejectedValueOnce(new Error(msg));
|
||||
}
|
||||
|
||||
const sampleIssue = {
|
||||
number: 123,
|
||||
title: "Fix login bug",
|
||||
body: "Users can't log in with SSO",
|
||||
url: "https://github.com/acme/repo/issues/123",
|
||||
state: "OPEN",
|
||||
stateReason: null as string | null,
|
||||
labels: [{ name: "bug" }, { name: "priority-high" }],
|
||||
assignees: [{ login: "alice" }],
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("tracker-github plugin", () => {
|
||||
let tracker: ReturnType<typeof create>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
tracker = create();
|
||||
});
|
||||
|
||||
// ---- manifest ----------------------------------------------------------
|
||||
|
||||
describe("manifest", () => {
|
||||
it("has correct metadata", () => {
|
||||
expect(manifest.name).toBe("github");
|
||||
expect(manifest.slot).toBe("tracker");
|
||||
expect(manifest.version).toBe("0.1.0");
|
||||
});
|
||||
});
|
||||
|
||||
describe("create()", () => {
|
||||
it("returns a Tracker with correct name", () => {
|
||||
expect(tracker.name).toBe("github");
|
||||
});
|
||||
});
|
||||
|
||||
// ---- getIssue ----------------------------------------------------------
|
||||
|
||||
describe("getIssue", () => {
|
||||
it("returns Issue with correct fields", async () => {
|
||||
mockGh(sampleIssue);
|
||||
const issue = await tracker.getIssue("123", project);
|
||||
expect(issue).toEqual({
|
||||
id: "123",
|
||||
title: "Fix login bug",
|
||||
description: "Users can't log in with SSO",
|
||||
url: "https://github.com/acme/repo/issues/123",
|
||||
state: "open",
|
||||
labels: ["bug", "priority-high"],
|
||||
assignee: "alice",
|
||||
});
|
||||
});
|
||||
|
||||
it("maps CLOSED state to closed", async () => {
|
||||
mockGh({ ...sampleIssue, state: "CLOSED", stateReason: "COMPLETED" });
|
||||
const issue = await tracker.getIssue("123", project);
|
||||
expect(issue.state).toBe("closed");
|
||||
});
|
||||
|
||||
it("maps NOT_PLANNED close reason to cancelled", async () => {
|
||||
mockGh({ ...sampleIssue, state: "CLOSED", stateReason: "NOT_PLANNED" });
|
||||
const issue = await tracker.getIssue("123", project);
|
||||
expect(issue.state).toBe("cancelled");
|
||||
});
|
||||
|
||||
it("handles missing body gracefully", async () => {
|
||||
mockGh({ ...sampleIssue, body: null });
|
||||
const issue = await tracker.getIssue("123", project);
|
||||
expect(issue.description).toBe("");
|
||||
});
|
||||
|
||||
it("handles empty assignees", async () => {
|
||||
mockGh({ ...sampleIssue, assignees: [] });
|
||||
const issue = await tracker.getIssue("123", project);
|
||||
expect(issue.assignee).toBeUndefined();
|
||||
});
|
||||
|
||||
it("propagates gh CLI errors", async () => {
|
||||
mockGhError("issue not found");
|
||||
await expect(tracker.getIssue("999", project)).rejects.toThrow("issue not found");
|
||||
});
|
||||
|
||||
it("throws on malformed JSON response", async () => {
|
||||
ghMock.mockResolvedValueOnce({ stdout: "not json{" });
|
||||
await expect(tracker.getIssue("123", project)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ---- isCompleted -------------------------------------------------------
|
||||
|
||||
describe("isCompleted", () => {
|
||||
it("returns true for CLOSED issues", async () => {
|
||||
mockGh({ state: "CLOSED" });
|
||||
expect(await tracker.isCompleted("123", project)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for OPEN issues", async () => {
|
||||
mockGh({ state: "OPEN" });
|
||||
expect(await tracker.isCompleted("123", project)).toBe(false);
|
||||
});
|
||||
|
||||
it("handles lowercase state", async () => {
|
||||
mockGh({ state: "closed" });
|
||||
expect(await tracker.isCompleted("123", project)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- issueUrl ----------------------------------------------------------
|
||||
|
||||
describe("issueUrl", () => {
|
||||
it("generates correct URL", () => {
|
||||
expect(tracker.issueUrl("42", project)).toBe("https://github.com/acme/repo/issues/42");
|
||||
});
|
||||
|
||||
it("strips # prefix from identifier", () => {
|
||||
expect(tracker.issueUrl("#42", project)).toBe("https://github.com/acme/repo/issues/42");
|
||||
});
|
||||
});
|
||||
|
||||
// ---- branchName --------------------------------------------------------
|
||||
|
||||
describe("branchName", () => {
|
||||
it("generates feat/issue-N format", () => {
|
||||
expect(tracker.branchName("42", project)).toBe("feat/issue-42");
|
||||
});
|
||||
|
||||
it("strips # prefix", () => {
|
||||
expect(tracker.branchName("#42", project)).toBe("feat/issue-42");
|
||||
});
|
||||
});
|
||||
|
||||
// ---- generatePrompt ----------------------------------------------------
|
||||
|
||||
describe("generatePrompt", () => {
|
||||
it("includes title and URL", async () => {
|
||||
mockGh(sampleIssue);
|
||||
const prompt = await tracker.generatePrompt("123", project);
|
||||
expect(prompt).toContain("Fix login bug");
|
||||
expect(prompt).toContain("https://github.com/acme/repo/issues/123");
|
||||
expect(prompt).toContain("GitHub issue #123");
|
||||
});
|
||||
|
||||
it("includes labels when present", async () => {
|
||||
mockGh(sampleIssue);
|
||||
const prompt = await tracker.generatePrompt("123", project);
|
||||
expect(prompt).toContain("bug, priority-high");
|
||||
});
|
||||
|
||||
it("includes description", async () => {
|
||||
mockGh(sampleIssue);
|
||||
const prompt = await tracker.generatePrompt("123", project);
|
||||
expect(prompt).toContain("Users can't log in with SSO");
|
||||
});
|
||||
|
||||
it("omits labels section when no labels", async () => {
|
||||
mockGh({ ...sampleIssue, labels: [] });
|
||||
const prompt = await tracker.generatePrompt("123", project);
|
||||
expect(prompt).not.toContain("Labels:");
|
||||
});
|
||||
|
||||
it("omits description section when body is empty", async () => {
|
||||
mockGh({ ...sampleIssue, body: null });
|
||||
const prompt = await tracker.generatePrompt("123", project);
|
||||
expect(prompt).not.toContain("## Description");
|
||||
});
|
||||
});
|
||||
|
||||
// ---- listIssues --------------------------------------------------------
|
||||
|
||||
describe("listIssues", () => {
|
||||
it("returns mapped issues", async () => {
|
||||
mockGh([sampleIssue, { ...sampleIssue, number: 456, title: "Another" }]);
|
||||
const issues = await tracker.listIssues!({}, project);
|
||||
expect(issues).toHaveLength(2);
|
||||
expect(issues[0].id).toBe("123");
|
||||
expect(issues[1].id).toBe("456");
|
||||
});
|
||||
|
||||
it("passes state filter for closed issues", async () => {
|
||||
mockGh([]);
|
||||
await tracker.listIssues!({ state: "closed" }, project);
|
||||
expect(ghMock).toHaveBeenCalledWith(
|
||||
"gh",
|
||||
expect.arrayContaining(["--state", "closed"]),
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("passes state filter for all issues", async () => {
|
||||
mockGh([]);
|
||||
await tracker.listIssues!({ state: "all" }, project);
|
||||
expect(ghMock).toHaveBeenCalledWith(
|
||||
"gh",
|
||||
expect.arrayContaining(["--state", "all"]),
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("defaults to open state", async () => {
|
||||
mockGh([]);
|
||||
await tracker.listIssues!({}, project);
|
||||
expect(ghMock).toHaveBeenCalledWith(
|
||||
"gh",
|
||||
expect.arrayContaining(["--state", "open"]),
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("passes label filter", async () => {
|
||||
mockGh([]);
|
||||
await tracker.listIssues!({ labels: ["bug", "urgent"] }, project);
|
||||
expect(ghMock).toHaveBeenCalledWith(
|
||||
"gh",
|
||||
expect.arrayContaining(["--label", "bug,urgent"]),
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("passes assignee filter", async () => {
|
||||
mockGh([]);
|
||||
await tracker.listIssues!({ assignee: "alice" }, project);
|
||||
expect(ghMock).toHaveBeenCalledWith(
|
||||
"gh",
|
||||
expect.arrayContaining(["--assignee", "alice"]),
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("respects custom limit", async () => {
|
||||
mockGh([]);
|
||||
await tracker.listIssues!({ limit: 5 }, project);
|
||||
expect(ghMock).toHaveBeenCalledWith(
|
||||
"gh",
|
||||
expect.arrayContaining(["--limit", "5"]),
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- updateIssue -------------------------------------------------------
|
||||
|
||||
describe("updateIssue", () => {
|
||||
it("closes an issue", async () => {
|
||||
ghMock.mockResolvedValueOnce({ stdout: "" });
|
||||
await tracker.updateIssue!("123", { state: "closed" }, project);
|
||||
expect(ghMock).toHaveBeenCalledWith(
|
||||
"gh",
|
||||
["issue", "close", "123", "--repo", "acme/repo"],
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("reopens an issue", async () => {
|
||||
ghMock.mockResolvedValueOnce({ stdout: "" });
|
||||
await tracker.updateIssue!("123", { state: "open" }, project);
|
||||
expect(ghMock).toHaveBeenCalledWith(
|
||||
"gh",
|
||||
["issue", "reopen", "123", "--repo", "acme/repo"],
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("adds labels", async () => {
|
||||
ghMock.mockResolvedValueOnce({ stdout: "" });
|
||||
await tracker.updateIssue!("123", { labels: ["bug", "urgent"] }, project);
|
||||
expect(ghMock).toHaveBeenCalledWith(
|
||||
"gh",
|
||||
["issue", "edit", "123", "--repo", "acme/repo", "--add-label", "bug,urgent"],
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("adds assignee", async () => {
|
||||
ghMock.mockResolvedValueOnce({ stdout: "" });
|
||||
await tracker.updateIssue!("123", { assignee: "bob" }, project);
|
||||
expect(ghMock).toHaveBeenCalledWith(
|
||||
"gh",
|
||||
["issue", "edit", "123", "--repo", "acme/repo", "--add-assignee", "bob"],
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("adds comment", async () => {
|
||||
ghMock.mockResolvedValueOnce({ stdout: "" });
|
||||
await tracker.updateIssue!("123", { comment: "Working on this" }, project);
|
||||
expect(ghMock).toHaveBeenCalledWith(
|
||||
"gh",
|
||||
["issue", "comment", "123", "--repo", "acme/repo", "--body", "Working on this"],
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("handles multiple updates in one call", async () => {
|
||||
ghMock.mockResolvedValue({ stdout: "" });
|
||||
await tracker.updateIssue!("123", { state: "closed", labels: ["done"], comment: "Done!" }, project);
|
||||
// Should have called gh 3 times: close + edit labels + comment
|
||||
expect(ghMock).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- createIssue -------------------------------------------------------
|
||||
|
||||
describe("createIssue", () => {
|
||||
it("creates an issue and fetches full details", async () => {
|
||||
// First call: gh issue create returns URL
|
||||
mockGhRaw("https://github.com/acme/repo/issues/999\n");
|
||||
// Second call: getIssue fetches the created issue
|
||||
mockGh({
|
||||
number: 999,
|
||||
title: "New issue",
|
||||
body: "Description",
|
||||
url: "https://github.com/acme/repo/issues/999",
|
||||
state: "OPEN",
|
||||
stateReason: null,
|
||||
labels: [],
|
||||
assignees: [],
|
||||
});
|
||||
|
||||
const issue = await tracker.createIssue!({ title: "New issue", description: "Description" }, project);
|
||||
expect(issue).toMatchObject({ id: "999", title: "New issue", state: "open" });
|
||||
});
|
||||
|
||||
it("passes labels and assignee to gh issue create", async () => {
|
||||
mockGhRaw("https://github.com/acme/repo/issues/1000\n");
|
||||
mockGh({
|
||||
number: 1000,
|
||||
title: "Bug",
|
||||
body: "Crash",
|
||||
url: "https://github.com/acme/repo/issues/1000",
|
||||
state: "OPEN",
|
||||
stateReason: null,
|
||||
labels: [{ name: "bug" }],
|
||||
assignees: [{ login: "alice" }],
|
||||
});
|
||||
|
||||
await tracker.createIssue!({ title: "Bug", description: "Crash", labels: ["bug"], assignee: "alice" }, project);
|
||||
expect(ghMock).toHaveBeenCalledWith(
|
||||
"gh",
|
||||
expect.arrayContaining(["issue", "create", "--label", "bug", "--assignee", "alice"]),
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("throws when URL cannot be parsed from gh output", async () => {
|
||||
mockGhRaw("unexpected output");
|
||||
await expect(
|
||||
tracker.createIssue!({ title: "Test", description: "" }, project),
|
||||
).rejects.toThrow("Failed to parse issue URL");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,23 @@
|
|||
/**
|
||||
* Minimal type declarations for @composio/core (optional peer dependency).
|
||||
* Only the subset used by the Composio transport is declared here.
|
||||
*/
|
||||
declare module "@composio/core" {
|
||||
interface ComposioExecuteResult {
|
||||
data?: Record<string, unknown>;
|
||||
error?: string;
|
||||
successful?: boolean;
|
||||
}
|
||||
|
||||
interface ComposioTools {
|
||||
execute(
|
||||
action: string,
|
||||
params: Record<string, unknown>,
|
||||
): Promise<ComposioExecuteResult>;
|
||||
}
|
||||
|
||||
export class Composio {
|
||||
constructor(opts: { apiKey: string });
|
||||
tools: ComposioTools;
|
||||
}
|
||||
}
|
||||
|
|
@ -1 +1,731 @@
|
|||
// tracker-linear plugin — to be implemented
|
||||
/**
|
||||
* tracker-linear plugin — Linear as an issue tracker.
|
||||
*
|
||||
* Uses the Linear GraphQL API with either:
|
||||
* - LINEAR_API_KEY (direct API access)
|
||||
* - COMPOSIO_API_KEY (via Composio SDK's LINEAR_RUN_QUERY_OR_MUTATION tool)
|
||||
*
|
||||
* Auto-detects which key is available and routes accordingly.
|
||||
*/
|
||||
|
||||
import { request } from "node:https";
|
||||
import type {
|
||||
PluginModule,
|
||||
Tracker,
|
||||
Issue,
|
||||
IssueFilters,
|
||||
IssueUpdate,
|
||||
CreateIssueInput,
|
||||
ProjectConfig,
|
||||
} from "@agent-orchestrator/core";
|
||||
import type { Composio } from "@composio/core";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Transport abstraction
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* A function that sends a GraphQL query/mutation and returns the parsed data.
|
||||
* Both the direct Linear API and Composio SDK transports implement this.
|
||||
*/
|
||||
type GraphQLTransport = <T>(
|
||||
query: string,
|
||||
variables?: Record<string, unknown>,
|
||||
) => Promise<T>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Direct Linear API transport
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const LINEAR_API_URL = "https://api.linear.app/graphql";
|
||||
|
||||
function getApiKey(): string {
|
||||
const key = process.env["LINEAR_API_KEY"];
|
||||
if (!key) {
|
||||
throw new Error(
|
||||
"LINEAR_API_KEY environment variable is required for the Linear tracker plugin",
|
||||
);
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
interface LinearResponse<T> {
|
||||
data?: T;
|
||||
errors?: Array<{ message: string }>;
|
||||
}
|
||||
|
||||
function createDirectTransport(): GraphQLTransport {
|
||||
return <T>(query: string, variables?: Record<string, unknown>): Promise<T> => {
|
||||
const apiKey = getApiKey();
|
||||
const body = JSON.stringify({ query, variables });
|
||||
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const url = new URL(LINEAR_API_URL);
|
||||
let settled = false;
|
||||
const settle = (fn: () => void) => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
fn();
|
||||
}
|
||||
};
|
||||
|
||||
const req = request(
|
||||
{
|
||||
hostname: url.hostname,
|
||||
path: url.pathname,
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: apiKey,
|
||||
"Content-Length": Buffer.byteLength(body),
|
||||
},
|
||||
},
|
||||
(res) => {
|
||||
const chunks: Buffer[] = [];
|
||||
res.on("error", (err: Error) => settle(() => reject(err)));
|
||||
res.on("data", (chunk: Buffer) => chunks.push(chunk));
|
||||
res.on("end", () => {
|
||||
settle(() => {
|
||||
try {
|
||||
const text = Buffer.concat(chunks).toString("utf-8");
|
||||
const status = res.statusCode ?? 0;
|
||||
if (status < 200 || status >= 300) {
|
||||
reject(new Error(`Linear API returned HTTP ${status}: ${text.slice(0, 200)}`));
|
||||
return;
|
||||
}
|
||||
const json: LinearResponse<T> = JSON.parse(text);
|
||||
if (json.errors && json.errors.length > 0) {
|
||||
reject(new Error(`Linear API error: ${json.errors[0].message}`));
|
||||
return;
|
||||
}
|
||||
if (!json.data) {
|
||||
reject(new Error("Linear API returned no data"));
|
||||
return;
|
||||
}
|
||||
resolve(json.data);
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
req.setTimeout(30_000, () => {
|
||||
settle(() => {
|
||||
req.destroy();
|
||||
reject(new Error("Linear API request timed out after 30s"));
|
||||
});
|
||||
});
|
||||
|
||||
req.on("error", (err) => settle(() => reject(err)));
|
||||
req.write(body);
|
||||
req.end();
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Composio SDK transport
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type ComposioTools = Composio["tools"];
|
||||
|
||||
function createComposioTransport(
|
||||
apiKey: string,
|
||||
entityId: string,
|
||||
): GraphQLTransport {
|
||||
// Lazy-load the Composio client — cached as a promise so the constructor
|
||||
// is called only once, even under concurrent requests.
|
||||
let clientPromise: Promise<ComposioTools> | undefined;
|
||||
|
||||
function getClient(): Promise<ComposioTools> {
|
||||
if (!clientPromise) {
|
||||
clientPromise = (async () => {
|
||||
try {
|
||||
const { Composio } = await import("@composio/core");
|
||||
const client = new Composio({ apiKey });
|
||||
return client.tools;
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
if (msg.includes("Cannot find module") || msg.includes("Cannot find package") || msg.includes("ERR_MODULE_NOT_FOUND")) {
|
||||
throw new Error(
|
||||
"Composio SDK (@composio/core) is not installed. " +
|
||||
"Install it with: pnpm add @composio/core",
|
||||
{ cause: err },
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
})();
|
||||
}
|
||||
return clientPromise;
|
||||
}
|
||||
|
||||
return async <T>(query: string, variables?: Record<string, unknown>): Promise<T> => {
|
||||
const tools = await getClient();
|
||||
|
||||
const resultPromise = tools.execute("LINEAR_RUN_QUERY_OR_MUTATION", {
|
||||
entityId,
|
||||
arguments: {
|
||||
query_or_mutation: query,
|
||||
variables: variables ? JSON.stringify(variables) : "{}",
|
||||
},
|
||||
});
|
||||
|
||||
// Apply 30s timeout for parity with the direct transport
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const timeoutPromise = new Promise<never>((_resolve, reject) => {
|
||||
timer = setTimeout(() => {
|
||||
reject(new Error("Composio Linear API request timed out after 30s"));
|
||||
}, 30_000);
|
||||
});
|
||||
|
||||
// Whichever promise loses the race is left without a handler.
|
||||
// Attach no-op .catch() to both so the loser doesn't trigger an
|
||||
// unhandled promise rejection. This does not affect Promise.race —
|
||||
// it still propagates the winning rejection normally.
|
||||
resultPromise.catch(() => {});
|
||||
timeoutPromise.catch(() => {});
|
||||
|
||||
try {
|
||||
const result = await Promise.race([resultPromise, timeoutPromise]);
|
||||
|
||||
if (!result.successful) {
|
||||
throw new Error(`Composio Linear API error: ${result.error ?? "unknown error"}`);
|
||||
}
|
||||
|
||||
if (!result.data) {
|
||||
throw new Error("Composio Linear API returned no data");
|
||||
}
|
||||
|
||||
return result.data as T;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types for Linear responses
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface LinearIssueNode {
|
||||
id: string;
|
||||
identifier: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
url: string;
|
||||
priority: number;
|
||||
state: {
|
||||
name: string;
|
||||
type: string; // "triage" | "backlog" | "unstarted" | "started" | "completed" | "canceled"
|
||||
};
|
||||
labels: {
|
||||
nodes: Array<{ name: string }>;
|
||||
};
|
||||
assignee: {
|
||||
name: string;
|
||||
displayName: string;
|
||||
} | null;
|
||||
team: {
|
||||
key: string;
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// State mapping
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function mapLinearState(stateType: string): Issue["state"] {
|
||||
switch (stateType) {
|
||||
case "completed":
|
||||
return "closed";
|
||||
case "canceled":
|
||||
return "cancelled";
|
||||
case "started":
|
||||
return "in_progress";
|
||||
default:
|
||||
// triage, backlog, unstarted
|
||||
return "open";
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Issue fields fragment
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const ISSUE_FIELDS = `
|
||||
id
|
||||
identifier
|
||||
title
|
||||
description
|
||||
url
|
||||
priority
|
||||
state { name type }
|
||||
labels { nodes { name } }
|
||||
assignee { name displayName }
|
||||
team { key }
|
||||
`;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tracker implementation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function createLinearTracker(query: GraphQLTransport): Tracker {
|
||||
return {
|
||||
name: "linear",
|
||||
|
||||
async getIssue(
|
||||
identifier: string,
|
||||
_project: ProjectConfig,
|
||||
): Promise<Issue> {
|
||||
const data = await query<{ issue: LinearIssueNode }>(
|
||||
`query($id: String!) {
|
||||
issue(id: $id) {
|
||||
${ISSUE_FIELDS}
|
||||
}
|
||||
}`,
|
||||
{ id: identifier },
|
||||
);
|
||||
|
||||
const node = data.issue;
|
||||
return {
|
||||
id: node.identifier,
|
||||
title: node.title,
|
||||
description: node.description ?? "",
|
||||
url: node.url,
|
||||
state: mapLinearState(node.state.type),
|
||||
labels: node.labels.nodes.map((l) => l.name),
|
||||
assignee: node.assignee?.displayName ?? node.assignee?.name,
|
||||
priority: node.priority,
|
||||
};
|
||||
},
|
||||
|
||||
async isCompleted(
|
||||
identifier: string,
|
||||
_project: ProjectConfig,
|
||||
): Promise<boolean> {
|
||||
const data = await query<{ issue: { state: { type: string } } }>(
|
||||
`query($id: String!) {
|
||||
issue(id: $id) {
|
||||
state { type }
|
||||
}
|
||||
}`,
|
||||
{ id: identifier },
|
||||
);
|
||||
|
||||
const stateType = data.issue.state.type;
|
||||
return stateType === "completed" || stateType === "canceled";
|
||||
},
|
||||
|
||||
issueUrl(identifier: string, project: ProjectConfig): string {
|
||||
const slug = project.tracker?.["workspaceSlug"] as string | undefined;
|
||||
if (slug) {
|
||||
return `https://linear.app/${slug}/issue/${identifier}`;
|
||||
}
|
||||
// Fallback: Linear also supports /issue/ URLs that redirect,
|
||||
// but they require authentication
|
||||
return `https://linear.app/issue/${identifier}`;
|
||||
},
|
||||
|
||||
branchName(identifier: string, _project: ProjectConfig): string {
|
||||
// Linear convention: feat/INT-1330
|
||||
return `feat/${identifier}`;
|
||||
},
|
||||
|
||||
async generatePrompt(
|
||||
identifier: string,
|
||||
project: ProjectConfig,
|
||||
): Promise<string> {
|
||||
const issue = await this.getIssue(identifier, project);
|
||||
const lines = [
|
||||
`You are working on Linear ticket ${issue.id}: ${issue.title}`,
|
||||
`Issue URL: ${issue.url}`,
|
||||
"",
|
||||
];
|
||||
|
||||
if (issue.labels.length > 0) {
|
||||
lines.push(`Labels: ${issue.labels.join(", ")}`);
|
||||
}
|
||||
|
||||
if (issue.priority !== undefined) {
|
||||
const priorityNames: Record<number, string> = {
|
||||
0: "No priority",
|
||||
1: "Urgent",
|
||||
2: "High",
|
||||
3: "Normal",
|
||||
4: "Low",
|
||||
};
|
||||
lines.push(`Priority: ${priorityNames[issue.priority] ?? String(issue.priority)}`);
|
||||
}
|
||||
|
||||
if (issue.description) {
|
||||
lines.push("## Description", "", issue.description);
|
||||
}
|
||||
|
||||
lines.push(
|
||||
"",
|
||||
"Please implement the changes described in this ticket. When done, commit and push your changes.",
|
||||
);
|
||||
|
||||
return lines.join("\n");
|
||||
},
|
||||
|
||||
async listIssues(
|
||||
filters: IssueFilters,
|
||||
project: ProjectConfig,
|
||||
): Promise<Issue[]> {
|
||||
// Build filter object using GraphQL variables to prevent injection
|
||||
const filter: Record<string, unknown> = {};
|
||||
const variables: Record<string, unknown> = {};
|
||||
|
||||
if (filters.state === "closed") {
|
||||
filter["state"] = { type: { in: ["completed", "canceled"] } };
|
||||
} else if (filters.state !== "all") {
|
||||
// Default to open (exclude completed/canceled) to match tracker-github
|
||||
filter["state"] = { type: { nin: ["completed", "canceled"] } };
|
||||
}
|
||||
|
||||
if (filters.assignee) {
|
||||
filter["assignee"] = { displayName: { eq: filters.assignee } };
|
||||
}
|
||||
|
||||
if (filters.labels && filters.labels.length > 0) {
|
||||
filter["labels"] = { name: { in: filters.labels } };
|
||||
}
|
||||
|
||||
// Add team filter if available from project config
|
||||
const teamId = project.tracker?.["teamId"];
|
||||
if (teamId) {
|
||||
filter["team"] = { id: { eq: teamId } };
|
||||
}
|
||||
|
||||
variables["filter"] = Object.keys(filter).length > 0 ? filter : undefined;
|
||||
variables["first"] = filters.limit ?? 30;
|
||||
|
||||
const data = await query<{
|
||||
issues: { nodes: LinearIssueNode[] };
|
||||
}>(
|
||||
`query($filter: IssueFilter, $first: Int!) {
|
||||
issues(filter: $filter, first: $first) {
|
||||
nodes {
|
||||
${ISSUE_FIELDS}
|
||||
}
|
||||
}
|
||||
}`,
|
||||
variables,
|
||||
);
|
||||
|
||||
return data.issues.nodes.map((node) => ({
|
||||
id: node.identifier,
|
||||
title: node.title,
|
||||
description: node.description ?? "",
|
||||
url: node.url,
|
||||
state: mapLinearState(node.state.type),
|
||||
labels: node.labels.nodes.map((l) => l.name),
|
||||
assignee: node.assignee?.displayName ?? node.assignee?.name,
|
||||
priority: node.priority,
|
||||
}));
|
||||
},
|
||||
|
||||
async updateIssue(
|
||||
identifier: string,
|
||||
update: IssueUpdate,
|
||||
_project: ProjectConfig,
|
||||
): Promise<void> {
|
||||
// Linear's issue() query accepts both UUID and short identifier (e.g. "INT-1330").
|
||||
// We resolve to UUID here for use in mutations.
|
||||
const issueData = await query<{
|
||||
issue: { id: string; team: { id: string } };
|
||||
}>(
|
||||
`query($id: String!) {
|
||||
issue(id: $id) {
|
||||
id
|
||||
team { id }
|
||||
}
|
||||
}`,
|
||||
{ id: identifier },
|
||||
);
|
||||
|
||||
const issueUuid = issueData.issue.id;
|
||||
const teamId = issueData.issue.team.id;
|
||||
|
||||
// Handle state change
|
||||
if (update.state) {
|
||||
// Need to find the correct workflow state ID
|
||||
const statesData = await query<{
|
||||
workflowStates: { nodes: Array<{ id: string; name: string; type: string }> };
|
||||
}>(
|
||||
`query($teamId: ID!) {
|
||||
workflowStates(filter: { team: { id: { eq: $teamId } } }) {
|
||||
nodes { id name type }
|
||||
}
|
||||
}`,
|
||||
{ teamId },
|
||||
);
|
||||
|
||||
const targetType =
|
||||
update.state === "closed"
|
||||
? "completed"
|
||||
: update.state === "open"
|
||||
? "unstarted"
|
||||
: "started";
|
||||
|
||||
const targetState = statesData.workflowStates.nodes.find(
|
||||
(s) => s.type === targetType,
|
||||
);
|
||||
|
||||
if (!targetState) {
|
||||
throw new Error(
|
||||
`No workflow state of type "${targetType}" found for team ${teamId}`,
|
||||
);
|
||||
}
|
||||
|
||||
await query(
|
||||
`mutation($id: String!, $stateId: String!) {
|
||||
issueUpdate(id: $id, input: { stateId: $stateId }) {
|
||||
success
|
||||
}
|
||||
}`,
|
||||
{ id: issueUuid, stateId: targetState.id },
|
||||
);
|
||||
}
|
||||
|
||||
// Handle assignee
|
||||
if (update.assignee) {
|
||||
const usersData = await query<{
|
||||
users: { nodes: Array<{ id: string; displayName: string; name: string }> };
|
||||
}>(
|
||||
`query($filter: UserFilter) {
|
||||
users(filter: $filter) {
|
||||
nodes { id displayName name }
|
||||
}
|
||||
}`,
|
||||
{ filter: { displayName: { eq: update.assignee } } },
|
||||
);
|
||||
|
||||
const user = usersData.users.nodes[0];
|
||||
if (user) {
|
||||
await query(
|
||||
`mutation($id: String!, $assigneeId: String!) {
|
||||
issueUpdate(id: $id, input: { assigneeId: $assigneeId }) {
|
||||
success
|
||||
}
|
||||
}`,
|
||||
{ id: issueUuid, assigneeId: user.id },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle labels (additive — merge with existing labels to match tracker-github behavior)
|
||||
if (update.labels && update.labels.length > 0) {
|
||||
// Fetch existing label IDs on the issue
|
||||
const existingData = await query<{
|
||||
issue: { labels: { nodes: Array<{ id: string }> } };
|
||||
}>(
|
||||
`query($id: String!) {
|
||||
issue(id: $id) {
|
||||
labels { nodes { id } }
|
||||
}
|
||||
}`,
|
||||
{ id: issueUuid },
|
||||
);
|
||||
const existingIds = new Set(existingData.issue.labels.nodes.map((l) => l.id));
|
||||
|
||||
// Resolve new label names to IDs
|
||||
const labelsData = await query<{
|
||||
issueLabels: { nodes: Array<{ id: string; name: string }> };
|
||||
}>(
|
||||
`query($teamId: ID) {
|
||||
issueLabels(filter: { team: { id: { eq: $teamId } } }) {
|
||||
nodes { id name }
|
||||
}
|
||||
}`,
|
||||
{ teamId },
|
||||
);
|
||||
|
||||
const labelMap = new Map(labelsData.issueLabels.nodes.map((l) => [l.name, l.id]));
|
||||
for (const name of update.labels) {
|
||||
const id = labelMap.get(name);
|
||||
if (id) existingIds.add(id);
|
||||
}
|
||||
|
||||
await query(
|
||||
`mutation($id: String!, $labelIds: [String!]!) {
|
||||
issueUpdate(id: $id, input: { labelIds: $labelIds }) {
|
||||
success
|
||||
}
|
||||
}`,
|
||||
{ id: issueUuid, labelIds: [...existingIds] },
|
||||
);
|
||||
}
|
||||
|
||||
// Handle comment
|
||||
if (update.comment) {
|
||||
await query(
|
||||
`mutation($issueId: String!, $body: String!) {
|
||||
commentCreate(input: { issueId: $issueId, body: $body }) {
|
||||
success
|
||||
}
|
||||
}`,
|
||||
{ issueId: issueUuid, body: update.comment },
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
async createIssue(
|
||||
input: CreateIssueInput,
|
||||
project: ProjectConfig,
|
||||
): Promise<Issue> {
|
||||
const teamId = project.tracker?.["teamId"];
|
||||
if (!teamId) {
|
||||
throw new Error(
|
||||
"Linear tracker requires 'teamId' in project tracker config",
|
||||
);
|
||||
}
|
||||
|
||||
const variables: Record<string, unknown> = {
|
||||
title: input.title,
|
||||
description: input.description ?? "",
|
||||
teamId,
|
||||
};
|
||||
|
||||
if (input.priority !== undefined) {
|
||||
variables["priority"] = input.priority;
|
||||
}
|
||||
|
||||
const data = await query<{
|
||||
issueCreate: {
|
||||
success: boolean;
|
||||
issue: LinearIssueNode;
|
||||
};
|
||||
}>(
|
||||
`mutation($title: String!, $description: String!, $teamId: String!, $priority: Int) {
|
||||
issueCreate(input: {
|
||||
title: $title,
|
||||
description: $description,
|
||||
teamId: $teamId,
|
||||
priority: $priority
|
||||
}) {
|
||||
success
|
||||
issue {
|
||||
${ISSUE_FIELDS}
|
||||
}
|
||||
}
|
||||
}`,
|
||||
variables,
|
||||
);
|
||||
|
||||
const node = data.issueCreate.issue;
|
||||
const issue: Issue = {
|
||||
id: node.identifier,
|
||||
title: node.title,
|
||||
description: node.description ?? "",
|
||||
url: node.url,
|
||||
state: mapLinearState(node.state.type),
|
||||
labels: node.labels.nodes.map((l) => l.name),
|
||||
assignee: node.assignee?.displayName ?? node.assignee?.name,
|
||||
priority: node.priority,
|
||||
};
|
||||
|
||||
// Assign after creation (Linear's issueCreate uses assigneeId, not display name)
|
||||
if (input.assignee) {
|
||||
try {
|
||||
const usersData = await query<{
|
||||
users: { nodes: Array<{ id: string; displayName: string; name: string }> };
|
||||
}>(
|
||||
`query($filter: UserFilter) {
|
||||
users(filter: $filter) {
|
||||
nodes { id displayName name }
|
||||
}
|
||||
}`,
|
||||
{ filter: { displayName: { eq: input.assignee } } },
|
||||
);
|
||||
|
||||
const user = usersData.users.nodes[0];
|
||||
if (user) {
|
||||
await query(
|
||||
`mutation($id: String!, $assigneeId: String!) {
|
||||
issueUpdate(id: $id, input: { assigneeId: $assigneeId }) {
|
||||
success
|
||||
}
|
||||
}`,
|
||||
{ id: node.id, assigneeId: user.id },
|
||||
);
|
||||
issue.assignee = input.assignee;
|
||||
}
|
||||
} catch {
|
||||
// Assignee is best-effort
|
||||
}
|
||||
}
|
||||
|
||||
// Add labels after creation (Linear's issueCreate doesn't accept label names directly)
|
||||
if (input.labels && input.labels.length > 0) {
|
||||
try {
|
||||
// Look up label IDs by name for the team
|
||||
const labelsData = await query<{
|
||||
issueLabels: { nodes: Array<{ id: string; name: string }> };
|
||||
}>(
|
||||
`query($teamId: ID) {
|
||||
issueLabels(filter: { team: { id: { eq: $teamId } } }) {
|
||||
nodes { id name }
|
||||
}
|
||||
}`,
|
||||
{ teamId },
|
||||
);
|
||||
|
||||
const labelMap = new Map(labelsData.issueLabels.nodes.map((l) => [l.name, l.id]));
|
||||
const appliedLabels: string[] = [];
|
||||
const labelIds: string[] = [];
|
||||
for (const name of input.labels) {
|
||||
const id = labelMap.get(name);
|
||||
if (id) {
|
||||
labelIds.push(id);
|
||||
appliedLabels.push(name);
|
||||
}
|
||||
}
|
||||
|
||||
if (labelIds.length > 0) {
|
||||
await query(
|
||||
`mutation($id: String!, $labelIds: [String!]!) {
|
||||
issueUpdate(id: $id, input: { labelIds: $labelIds }) {
|
||||
success
|
||||
}
|
||||
}`,
|
||||
{ id: node.id, labelIds },
|
||||
);
|
||||
// Reflect only the labels that actually exist in Linear
|
||||
issue.labels = appliedLabels;
|
||||
}
|
||||
} catch {
|
||||
// Labels are best-effort; don't fail the whole creation
|
||||
}
|
||||
}
|
||||
|
||||
return issue;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Plugin module export
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const manifest = {
|
||||
name: "linear",
|
||||
slot: "tracker" as const,
|
||||
description: "Tracker plugin: Linear issue tracker",
|
||||
version: "0.1.0",
|
||||
};
|
||||
|
||||
export function create(): Tracker {
|
||||
const composioKey = process.env["COMPOSIO_API_KEY"];
|
||||
if (composioKey) {
|
||||
const entityId = process.env["COMPOSIO_ENTITY_ID"] ?? "default";
|
||||
return createLinearTracker(createComposioTransport(composioKey, entityId));
|
||||
}
|
||||
return createLinearTracker(createDirectTransport());
|
||||
}
|
||||
|
||||
export default { manifest, create } satisfies PluginModule<Tracker>;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,350 @@
|
|||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock @composio/core
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const { mockExecute, MockComposio } = vi.hoisted(() => {
|
||||
const mockExecute = vi.fn();
|
||||
const MockComposio = vi.fn().mockImplementation(() => ({
|
||||
tools: { execute: mockExecute },
|
||||
}));
|
||||
return { mockExecute, MockComposio };
|
||||
});
|
||||
|
||||
vi.mock("@composio/core", () => ({
|
||||
Composio: MockComposio,
|
||||
}));
|
||||
|
||||
import { create } from "../src/index.js";
|
||||
import type { ProjectConfig } from "@agent-orchestrator/core";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fixtures
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const project: ProjectConfig = {
|
||||
name: "test",
|
||||
repo: "acme/integrator",
|
||||
path: "/tmp/repo",
|
||||
defaultBranch: "main",
|
||||
sessionPrefix: "test",
|
||||
tracker: {
|
||||
plugin: "linear",
|
||||
teamId: "team-uuid-1",
|
||||
workspaceSlug: "acme",
|
||||
},
|
||||
};
|
||||
|
||||
const sampleIssueNode = {
|
||||
id: "uuid-123",
|
||||
identifier: "INT-123",
|
||||
title: "Fix login bug",
|
||||
description: "Users can't log in with SSO",
|
||||
url: "https://linear.app/acme/issue/INT-123",
|
||||
priority: 2,
|
||||
state: { name: "In Progress", type: "started" },
|
||||
labels: { nodes: [{ name: "bug" }, { name: "high-priority" }] },
|
||||
assignee: { name: "Alice Smith", displayName: "Alice" },
|
||||
team: { key: "INT" },
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function mockComposioResponse(data: unknown) {
|
||||
mockExecute.mockResolvedValueOnce({
|
||||
data,
|
||||
successful: true,
|
||||
});
|
||||
}
|
||||
|
||||
function mockComposioError(error: string) {
|
||||
mockExecute.mockResolvedValueOnce({
|
||||
error,
|
||||
successful: false,
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Environment helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let savedComposioKey: string | undefined;
|
||||
let savedEntityId: string | undefined;
|
||||
let savedLinearKey: string | undefined;
|
||||
|
||||
function saveEnv() {
|
||||
savedComposioKey = process.env["COMPOSIO_API_KEY"];
|
||||
savedEntityId = process.env["COMPOSIO_ENTITY_ID"];
|
||||
savedLinearKey = process.env["LINEAR_API_KEY"];
|
||||
}
|
||||
|
||||
function restoreEnv() {
|
||||
if (savedComposioKey === undefined) {
|
||||
delete process.env["COMPOSIO_API_KEY"];
|
||||
} else {
|
||||
process.env["COMPOSIO_API_KEY"] = savedComposioKey;
|
||||
}
|
||||
if (savedEntityId === undefined) {
|
||||
delete process.env["COMPOSIO_ENTITY_ID"];
|
||||
} else {
|
||||
process.env["COMPOSIO_ENTITY_ID"] = savedEntityId;
|
||||
}
|
||||
if (savedLinearKey === undefined) {
|
||||
delete process.env["LINEAR_API_KEY"];
|
||||
} else {
|
||||
process.env["LINEAR_API_KEY"] = savedLinearKey;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("tracker-linear Composio transport", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
saveEnv();
|
||||
// Set Composio key, remove Linear key
|
||||
process.env["COMPOSIO_API_KEY"] = "composio_test_key";
|
||||
delete process.env["LINEAR_API_KEY"];
|
||||
delete process.env["COMPOSIO_ENTITY_ID"];
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
restoreEnv();
|
||||
});
|
||||
|
||||
// ---- Auto-detection ---------------------------------------------------
|
||||
|
||||
describe("transport auto-detection", () => {
|
||||
it("uses Composio transport when COMPOSIO_API_KEY is set", async () => {
|
||||
mockComposioResponse({ issue: sampleIssueNode });
|
||||
const tracker = create();
|
||||
const issue = await tracker.getIssue("INT-123", project);
|
||||
|
||||
expect(issue.id).toBe("INT-123");
|
||||
expect(MockComposio).toHaveBeenCalledWith({ apiKey: "composio_test_key" });
|
||||
expect(mockExecute).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("prefers COMPOSIO_API_KEY over LINEAR_API_KEY when both set", async () => {
|
||||
process.env["LINEAR_API_KEY"] = "lin_api_test_key";
|
||||
mockComposioResponse({ issue: sampleIssueNode });
|
||||
|
||||
const tracker = create();
|
||||
await tracker.getIssue("INT-123", project);
|
||||
|
||||
// Should use Composio, not direct
|
||||
expect(MockComposio).toHaveBeenCalled();
|
||||
expect(mockExecute).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ---- Entity ID --------------------------------------------------------
|
||||
|
||||
describe("entity ID", () => {
|
||||
it("defaults entity ID to 'default'", async () => {
|
||||
mockComposioResponse({ issue: sampleIssueNode });
|
||||
const tracker = create();
|
||||
await tracker.getIssue("INT-123", project);
|
||||
|
||||
expect(mockExecute).toHaveBeenCalledWith(
|
||||
"LINEAR_RUN_QUERY_OR_MUTATION",
|
||||
expect.objectContaining({ entityId: "default" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses COMPOSIO_ENTITY_ID env var when set", async () => {
|
||||
process.env["COMPOSIO_ENTITY_ID"] = "my-entity";
|
||||
mockComposioResponse({ issue: sampleIssueNode });
|
||||
|
||||
const tracker = create();
|
||||
await tracker.getIssue("INT-123", project);
|
||||
|
||||
expect(mockExecute).toHaveBeenCalledWith(
|
||||
"LINEAR_RUN_QUERY_OR_MUTATION",
|
||||
expect.objectContaining({ entityId: "my-entity" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- Successful queries -----------------------------------------------
|
||||
|
||||
describe("successful queries", () => {
|
||||
it("returns correct Issue from getIssue", async () => {
|
||||
mockComposioResponse({ issue: sampleIssueNode });
|
||||
const tracker = create();
|
||||
const issue = await tracker.getIssue("INT-123", project);
|
||||
|
||||
expect(issue).toEqual({
|
||||
id: "INT-123",
|
||||
title: "Fix login bug",
|
||||
description: "Users can't log in with SSO",
|
||||
url: "https://linear.app/acme/issue/INT-123",
|
||||
state: "in_progress",
|
||||
labels: ["bug", "high-priority"],
|
||||
assignee: "Alice",
|
||||
priority: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it("passes query and variables to Composio execute", async () => {
|
||||
mockComposioResponse({ issue: sampleIssueNode });
|
||||
const tracker = create();
|
||||
await tracker.getIssue("INT-123", project);
|
||||
|
||||
const call = mockExecute.mock.calls[0];
|
||||
expect(call[0]).toBe("LINEAR_RUN_QUERY_OR_MUTATION");
|
||||
const params = call[1] as Record<string, unknown>;
|
||||
const args = params["arguments"] as Record<string, string>;
|
||||
expect(args["query_or_mutation"]).toContain("query($id: String!)");
|
||||
expect(JSON.parse(args["variables"])).toEqual({ id: "INT-123" });
|
||||
});
|
||||
|
||||
it("serializes empty variables as '{}'", async () => {
|
||||
mockComposioResponse({ issue: { state: { type: "completed" } } });
|
||||
const tracker = create();
|
||||
|
||||
// isCompleted passes variables, but let's verify with listIssues which
|
||||
// also always passes variables. Instead, let's test a simpler case.
|
||||
await tracker.isCompleted("INT-123", project);
|
||||
|
||||
const call = mockExecute.mock.calls[0];
|
||||
const params = call[1] as Record<string, unknown>;
|
||||
const args = params["arguments"] as Record<string, string>;
|
||||
// Should be valid JSON
|
||||
expect(() => JSON.parse(args["variables"])).not.toThrow();
|
||||
});
|
||||
|
||||
it("works with listIssues", async () => {
|
||||
mockComposioResponse({
|
||||
issues: {
|
||||
nodes: [sampleIssueNode],
|
||||
},
|
||||
});
|
||||
const tracker = create();
|
||||
const issues = await tracker.listIssues!({}, project);
|
||||
|
||||
expect(issues).toHaveLength(1);
|
||||
expect(issues[0].id).toBe("INT-123");
|
||||
});
|
||||
|
||||
it("works with isCompleted", async () => {
|
||||
mockComposioResponse({ issue: { state: { type: "completed" } } });
|
||||
const tracker = create();
|
||||
const result = await tracker.isCompleted("INT-123", project);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- Error handling ---------------------------------------------------
|
||||
|
||||
describe("error handling", () => {
|
||||
it("throws on unsuccessful response", async () => {
|
||||
mockComposioError("Authentication failed");
|
||||
const tracker = create();
|
||||
|
||||
await expect(tracker.getIssue("INT-123", project)).rejects.toThrow(
|
||||
"Composio Linear API error: Authentication failed",
|
||||
);
|
||||
});
|
||||
|
||||
it("throws with 'unknown error' when error field is missing", async () => {
|
||||
mockExecute.mockResolvedValueOnce({
|
||||
successful: false,
|
||||
});
|
||||
const tracker = create();
|
||||
|
||||
await expect(tracker.getIssue("INT-123", project)).rejects.toThrow(
|
||||
"Composio Linear API error: unknown error",
|
||||
);
|
||||
});
|
||||
|
||||
it("throws when response has no data", async () => {
|
||||
mockExecute.mockResolvedValueOnce({
|
||||
successful: true,
|
||||
data: undefined,
|
||||
});
|
||||
const tracker = create();
|
||||
|
||||
await expect(tracker.getIssue("INT-123", project)).rejects.toThrow(
|
||||
"Composio Linear API returned no data",
|
||||
);
|
||||
});
|
||||
|
||||
it("propagates execute rejections", async () => {
|
||||
mockExecute.mockRejectedValueOnce(new Error("Network error"));
|
||||
const tracker = create();
|
||||
|
||||
await expect(tracker.getIssue("INT-123", project)).rejects.toThrow(
|
||||
"Network error",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- Client caching ---------------------------------------------------
|
||||
|
||||
describe("client caching", () => {
|
||||
it("creates Composio client only once across multiple queries", async () => {
|
||||
mockComposioResponse({ issue: sampleIssueNode });
|
||||
mockComposioResponse({ issue: { state: { type: "started" } } });
|
||||
mockComposioResponse({
|
||||
issues: { nodes: [sampleIssueNode] },
|
||||
});
|
||||
|
||||
const tracker = create();
|
||||
await tracker.getIssue("INT-123", project);
|
||||
await tracker.isCompleted("INT-123", project);
|
||||
await tracker.listIssues!({}, project);
|
||||
|
||||
// Composio constructor should be called exactly once
|
||||
expect(MockComposio).toHaveBeenCalledTimes(1);
|
||||
// But execute should be called 3 times
|
||||
expect(mockExecute).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- Timeout ----------------------------------------------------------
|
||||
|
||||
describe("timeout", () => {
|
||||
it("times out after 30s", async () => {
|
||||
// Pre-warm the client so import() resolves before we switch to fake timers.
|
||||
mockComposioResponse({ issue: sampleIssueNode });
|
||||
const tracker = create();
|
||||
await tracker.getIssue("INT-123", project);
|
||||
|
||||
// Now switch to fake timers
|
||||
vi.useFakeTimers();
|
||||
|
||||
// Vitest's fake timers fire the setTimeout callback synchronously
|
||||
// during advanceTimersByTimeAsync, before the microtask queue can
|
||||
// process the .catch() handler on timeoutPromise. Suppress the
|
||||
// transient unhandled rejection that vitest detects in that window.
|
||||
const suppressed: unknown[] = [];
|
||||
const handler = (reason: unknown) => { suppressed.push(reason); };
|
||||
process.on("unhandledRejection", handler);
|
||||
|
||||
try {
|
||||
// Make execute hang forever
|
||||
mockExecute.mockImplementationOnce(
|
||||
() => new Promise(() => {}), // never resolves
|
||||
);
|
||||
|
||||
const promise = tracker.getIssue("INT-123", project);
|
||||
|
||||
// Advance timers past the 30s timeout
|
||||
await vi.advanceTimersByTimeAsync(30_001);
|
||||
|
||||
await expect(promise).rejects.toThrow(
|
||||
"Composio Linear API request timed out after 30s",
|
||||
);
|
||||
} finally {
|
||||
process.removeListener("unhandledRejection", handler);
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,866 @@
|
|||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { EventEmitter } from "node:events";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock node:https
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const { requestMock } = vi.hoisted(() => ({ requestMock: vi.fn() }));
|
||||
|
||||
vi.mock("node:https", () => ({
|
||||
request: requestMock,
|
||||
}));
|
||||
|
||||
import { create, manifest } from "../src/index.js";
|
||||
import type { ProjectConfig } from "@agent-orchestrator/core";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fixtures
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const project: ProjectConfig = {
|
||||
name: "test",
|
||||
repo: "acme/integrator",
|
||||
path: "/tmp/repo",
|
||||
defaultBranch: "main",
|
||||
sessionPrefix: "test",
|
||||
tracker: {
|
||||
plugin: "linear",
|
||||
teamId: "team-uuid-1",
|
||||
workspaceSlug: "acme",
|
||||
},
|
||||
};
|
||||
|
||||
const projectNoSlug: ProjectConfig = {
|
||||
...project,
|
||||
tracker: { plugin: "linear", teamId: "team-uuid-1" },
|
||||
};
|
||||
|
||||
const sampleIssueNode = {
|
||||
id: "uuid-123",
|
||||
identifier: "INT-123",
|
||||
title: "Fix login bug",
|
||||
description: "Users can't log in with SSO",
|
||||
url: "https://linear.app/acme/issue/INT-123",
|
||||
priority: 2,
|
||||
state: { name: "In Progress", type: "started" },
|
||||
labels: { nodes: [{ name: "bug" }, { name: "high-priority" }] },
|
||||
assignee: { name: "Alice Smith", displayName: "Alice" },
|
||||
team: { key: "INT" },
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Queue a successful Linear API response.
|
||||
* Each call to linearQuery() will consume the next queued response.
|
||||
*/
|
||||
function mockLinearAPI(responseData: unknown, statusCode = 200) {
|
||||
const body = JSON.stringify({ data: responseData });
|
||||
|
||||
requestMock.mockImplementationOnce(
|
||||
(_opts: Record<string, unknown>, callback: (res: EventEmitter & { statusCode: number }) => void) => {
|
||||
const req = Object.assign(new EventEmitter(), {
|
||||
write: vi.fn(),
|
||||
end: vi.fn(() => {
|
||||
const res = Object.assign(new EventEmitter(), { statusCode });
|
||||
callback(res);
|
||||
process.nextTick(() => {
|
||||
res.emit("data", Buffer.from(body));
|
||||
res.emit("end");
|
||||
});
|
||||
}),
|
||||
destroy: vi.fn(),
|
||||
setTimeout: vi.fn(),
|
||||
});
|
||||
return req;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/** Queue a Linear API error response (GraphQL errors array). */
|
||||
function mockLinearError(message: string) {
|
||||
const body = JSON.stringify({ errors: [{ message }] });
|
||||
|
||||
requestMock.mockImplementationOnce(
|
||||
(_opts: Record<string, unknown>, callback: (res: EventEmitter & { statusCode: number }) => void) => {
|
||||
const req = Object.assign(new EventEmitter(), {
|
||||
write: vi.fn(),
|
||||
end: vi.fn(() => {
|
||||
const res = Object.assign(new EventEmitter(), { statusCode: 200 });
|
||||
callback(res);
|
||||
process.nextTick(() => {
|
||||
res.emit("data", Buffer.from(body));
|
||||
res.emit("end");
|
||||
});
|
||||
}),
|
||||
destroy: vi.fn(),
|
||||
setTimeout: vi.fn(),
|
||||
});
|
||||
return req;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/** Queue an HTTP-level error (non-200 status). */
|
||||
function mockHTTPError(statusCode: number, body: string) {
|
||||
requestMock.mockImplementationOnce(
|
||||
(_opts: Record<string, unknown>, callback: (res: EventEmitter & { statusCode: number }) => void) => {
|
||||
const req = Object.assign(new EventEmitter(), {
|
||||
write: vi.fn(),
|
||||
end: vi.fn(() => {
|
||||
const res = Object.assign(new EventEmitter(), { statusCode });
|
||||
callback(res);
|
||||
process.nextTick(() => {
|
||||
res.emit("data", Buffer.from(body));
|
||||
res.emit("end");
|
||||
});
|
||||
}),
|
||||
destroy: vi.fn(),
|
||||
setTimeout: vi.fn(),
|
||||
});
|
||||
return req;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("tracker-linear plugin", () => {
|
||||
let tracker: ReturnType<typeof create>;
|
||||
let savedApiKey: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
savedApiKey = process.env["LINEAR_API_KEY"];
|
||||
process.env["LINEAR_API_KEY"] = "lin_api_test_key";
|
||||
tracker = create();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (savedApiKey === undefined) {
|
||||
delete process.env["LINEAR_API_KEY"];
|
||||
} else {
|
||||
process.env["LINEAR_API_KEY"] = savedApiKey;
|
||||
}
|
||||
});
|
||||
|
||||
// ---- manifest ----------------------------------------------------------
|
||||
|
||||
describe("manifest", () => {
|
||||
it("has correct metadata", () => {
|
||||
expect(manifest.name).toBe("linear");
|
||||
expect(manifest.slot).toBe("tracker");
|
||||
expect(manifest.version).toBe("0.1.0");
|
||||
});
|
||||
});
|
||||
|
||||
describe("create()", () => {
|
||||
it("returns a Tracker with correct name", () => {
|
||||
expect(tracker.name).toBe("linear");
|
||||
});
|
||||
});
|
||||
|
||||
// ---- getIssue ----------------------------------------------------------
|
||||
|
||||
describe("getIssue", () => {
|
||||
it("returns Issue with correct fields", async () => {
|
||||
mockLinearAPI({ issue: sampleIssueNode });
|
||||
const issue = await tracker.getIssue("INT-123", project);
|
||||
expect(issue).toEqual({
|
||||
id: "INT-123",
|
||||
title: "Fix login bug",
|
||||
description: "Users can't log in with SSO",
|
||||
url: "https://linear.app/acme/issue/INT-123",
|
||||
state: "in_progress",
|
||||
labels: ["bug", "high-priority"],
|
||||
assignee: "Alice",
|
||||
priority: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it("maps completed state to closed", async () => {
|
||||
mockLinearAPI({
|
||||
issue: { ...sampleIssueNode, state: { name: "Done", type: "completed" } },
|
||||
});
|
||||
const issue = await tracker.getIssue("INT-123", project);
|
||||
expect(issue.state).toBe("closed");
|
||||
});
|
||||
|
||||
it("maps canceled state to cancelled", async () => {
|
||||
mockLinearAPI({
|
||||
issue: { ...sampleIssueNode, state: { name: "Canceled", type: "canceled" } },
|
||||
});
|
||||
const issue = await tracker.getIssue("INT-123", project);
|
||||
expect(issue.state).toBe("cancelled");
|
||||
});
|
||||
|
||||
it("maps backlog/triage/unstarted to open", async () => {
|
||||
for (const type of ["backlog", "triage", "unstarted"]) {
|
||||
mockLinearAPI({
|
||||
issue: { ...sampleIssueNode, state: { name: type, type } },
|
||||
});
|
||||
const issue = await tracker.getIssue("INT-123", project);
|
||||
expect(issue.state).toBe("open");
|
||||
}
|
||||
});
|
||||
|
||||
it("handles null description", async () => {
|
||||
mockLinearAPI({
|
||||
issue: { ...sampleIssueNode, description: null },
|
||||
});
|
||||
const issue = await tracker.getIssue("INT-123", project);
|
||||
expect(issue.description).toBe("");
|
||||
});
|
||||
|
||||
it("handles null assignee", async () => {
|
||||
mockLinearAPI({
|
||||
issue: { ...sampleIssueNode, assignee: null },
|
||||
});
|
||||
const issue = await tracker.getIssue("INT-123", project);
|
||||
expect(issue.assignee).toBeUndefined();
|
||||
});
|
||||
|
||||
it("uses assignee name as fallback when displayName is missing", async () => {
|
||||
mockLinearAPI({
|
||||
issue: {
|
||||
...sampleIssueNode,
|
||||
assignee: { name: "Alice Smith", displayName: undefined },
|
||||
},
|
||||
});
|
||||
const issue = await tracker.getIssue("INT-123", project);
|
||||
// undefined displayName falls through to name via ??
|
||||
expect(issue.assignee).toBe("Alice Smith");
|
||||
});
|
||||
|
||||
it("handles empty labels", async () => {
|
||||
mockLinearAPI({
|
||||
issue: { ...sampleIssueNode, labels: { nodes: [] } },
|
||||
});
|
||||
const issue = await tracker.getIssue("INT-123", project);
|
||||
expect(issue.labels).toEqual([]);
|
||||
});
|
||||
|
||||
it("propagates API errors", async () => {
|
||||
mockLinearError("Issue not found");
|
||||
await expect(tracker.getIssue("INT-999", project)).rejects.toThrow(
|
||||
"Linear API error: Issue not found",
|
||||
);
|
||||
});
|
||||
|
||||
it("throws when LINEAR_API_KEY is missing", async () => {
|
||||
delete process.env["LINEAR_API_KEY"];
|
||||
await expect(tracker.getIssue("INT-123", project)).rejects.toThrow(
|
||||
"LINEAR_API_KEY environment variable is required",
|
||||
);
|
||||
});
|
||||
|
||||
it("throws on HTTP errors", async () => {
|
||||
mockHTTPError(500, "Internal Server Error");
|
||||
await expect(tracker.getIssue("INT-123", project)).rejects.toThrow(
|
||||
"Linear API returned HTTP 500",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- isCompleted -------------------------------------------------------
|
||||
|
||||
describe("isCompleted", () => {
|
||||
it("returns true for completed state", async () => {
|
||||
mockLinearAPI({ issue: { state: { type: "completed" } } });
|
||||
expect(await tracker.isCompleted("INT-123", project)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for canceled state", async () => {
|
||||
mockLinearAPI({ issue: { state: { type: "canceled" } } });
|
||||
expect(await tracker.isCompleted("INT-123", project)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for started state", async () => {
|
||||
mockLinearAPI({ issue: { state: { type: "started" } } });
|
||||
expect(await tracker.isCompleted("INT-123", project)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for unstarted state", async () => {
|
||||
mockLinearAPI({ issue: { state: { type: "unstarted" } } });
|
||||
expect(await tracker.isCompleted("INT-123", project)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- issueUrl ----------------------------------------------------------
|
||||
|
||||
describe("issueUrl", () => {
|
||||
it("generates correct URL with workspace slug", () => {
|
||||
expect(tracker.issueUrl("INT-123", project)).toBe(
|
||||
"https://linear.app/acme/issue/INT-123",
|
||||
);
|
||||
});
|
||||
|
||||
it("generates fallback URL without workspace slug", () => {
|
||||
expect(tracker.issueUrl("INT-123", projectNoSlug)).toBe(
|
||||
"https://linear.app/issue/INT-123",
|
||||
);
|
||||
});
|
||||
|
||||
it("generates fallback URL when no tracker config", () => {
|
||||
const noTracker: ProjectConfig = { ...project, tracker: undefined };
|
||||
expect(tracker.issueUrl("INT-123", noTracker)).toBe(
|
||||
"https://linear.app/issue/INT-123",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- branchName --------------------------------------------------------
|
||||
|
||||
describe("branchName", () => {
|
||||
it("generates feat/ prefix branch name", () => {
|
||||
expect(tracker.branchName("INT-123", project)).toBe("feat/INT-123");
|
||||
});
|
||||
});
|
||||
|
||||
// ---- generatePrompt ----------------------------------------------------
|
||||
|
||||
describe("generatePrompt", () => {
|
||||
it("includes title, URL, and description", async () => {
|
||||
mockLinearAPI({ issue: sampleIssueNode });
|
||||
const prompt = await tracker.generatePrompt("INT-123", project);
|
||||
expect(prompt).toContain("INT-123");
|
||||
expect(prompt).toContain("Fix login bug");
|
||||
expect(prompt).toContain("https://linear.app/acme/issue/INT-123");
|
||||
expect(prompt).toContain("Users can't log in with SSO");
|
||||
});
|
||||
|
||||
it("includes labels when present", async () => {
|
||||
mockLinearAPI({ issue: sampleIssueNode });
|
||||
const prompt = await tracker.generatePrompt("INT-123", project);
|
||||
expect(prompt).toContain("bug, high-priority");
|
||||
});
|
||||
|
||||
it("includes priority", async () => {
|
||||
mockLinearAPI({ issue: sampleIssueNode });
|
||||
const prompt = await tracker.generatePrompt("INT-123", project);
|
||||
expect(prompt).toContain("High");
|
||||
});
|
||||
|
||||
it("maps priority numbers to names", async () => {
|
||||
const priorities: Record<number, string> = {
|
||||
0: "No priority",
|
||||
1: "Urgent",
|
||||
2: "High",
|
||||
3: "Normal",
|
||||
4: "Low",
|
||||
};
|
||||
for (const [num, name] of Object.entries(priorities)) {
|
||||
mockLinearAPI({
|
||||
issue: { ...sampleIssueNode, priority: Number(num) },
|
||||
});
|
||||
const prompt = await tracker.generatePrompt("INT-123", project);
|
||||
expect(prompt).toContain(name);
|
||||
}
|
||||
});
|
||||
|
||||
it("omits description section when empty", async () => {
|
||||
mockLinearAPI({
|
||||
issue: { ...sampleIssueNode, description: null },
|
||||
});
|
||||
const prompt = await tracker.generatePrompt("INT-123", project);
|
||||
expect(prompt).not.toContain("## Description");
|
||||
});
|
||||
|
||||
it("omits labels line when no labels", async () => {
|
||||
mockLinearAPI({
|
||||
issue: { ...sampleIssueNode, labels: { nodes: [] } },
|
||||
});
|
||||
const prompt = await tracker.generatePrompt("INT-123", project);
|
||||
expect(prompt).not.toContain("Labels:");
|
||||
});
|
||||
});
|
||||
|
||||
// ---- listIssues --------------------------------------------------------
|
||||
|
||||
describe("listIssues", () => {
|
||||
it("returns mapped issues", async () => {
|
||||
mockLinearAPI({
|
||||
issues: {
|
||||
nodes: [
|
||||
sampleIssueNode,
|
||||
{ ...sampleIssueNode, identifier: "INT-456", title: "Another" },
|
||||
],
|
||||
},
|
||||
});
|
||||
const issues = await tracker.listIssues!({}, project);
|
||||
expect(issues).toHaveLength(2);
|
||||
expect(issues[0].id).toBe("INT-123");
|
||||
expect(issues[1].id).toBe("INT-456");
|
||||
});
|
||||
|
||||
it("passes state filter for open issues", async () => {
|
||||
mockLinearAPI({ issues: { nodes: [] } });
|
||||
await tracker.listIssues!({ state: "open" }, project);
|
||||
|
||||
// Verify the request body contains the correct filter
|
||||
const writeCall = requestMock.mock.results[0].value.write.mock.calls[0][0];
|
||||
const body = JSON.parse(writeCall);
|
||||
expect(body.variables.filter.state).toEqual({
|
||||
type: { nin: ["completed", "canceled"] },
|
||||
});
|
||||
});
|
||||
|
||||
it("passes state filter for closed issues", async () => {
|
||||
mockLinearAPI({ issues: { nodes: [] } });
|
||||
await tracker.listIssues!({ state: "closed" }, project);
|
||||
|
||||
const writeCall = requestMock.mock.results[0].value.write.mock.calls[0][0];
|
||||
const body = JSON.parse(writeCall);
|
||||
expect(body.variables.filter.state).toEqual({
|
||||
type: { in: ["completed", "canceled"] },
|
||||
});
|
||||
});
|
||||
|
||||
it("defaults to open state when no state specified", async () => {
|
||||
mockLinearAPI({ issues: { nodes: [] } });
|
||||
await tracker.listIssues!({}, project);
|
||||
|
||||
const writeCall = requestMock.mock.results[0].value.write.mock.calls[0][0];
|
||||
const body = JSON.parse(writeCall);
|
||||
expect(body.variables.filter.state).toEqual({
|
||||
type: { nin: ["completed", "canceled"] },
|
||||
});
|
||||
});
|
||||
|
||||
it("passes assignee filter", async () => {
|
||||
mockLinearAPI({ issues: { nodes: [] } });
|
||||
await tracker.listIssues!({ assignee: "Alice" }, project);
|
||||
|
||||
const writeCall = requestMock.mock.results[0].value.write.mock.calls[0][0];
|
||||
const body = JSON.parse(writeCall);
|
||||
expect(body.variables.filter.assignee).toEqual({
|
||||
displayName: { eq: "Alice" },
|
||||
});
|
||||
});
|
||||
|
||||
it("passes labels filter", async () => {
|
||||
mockLinearAPI({ issues: { nodes: [] } });
|
||||
await tracker.listIssues!({ labels: ["bug", "urgent"] }, project);
|
||||
|
||||
const writeCall = requestMock.mock.results[0].value.write.mock.calls[0][0];
|
||||
const body = JSON.parse(writeCall);
|
||||
expect(body.variables.filter.labels).toEqual({
|
||||
name: { in: ["bug", "urgent"] },
|
||||
});
|
||||
});
|
||||
|
||||
it("passes team filter from project config", async () => {
|
||||
mockLinearAPI({ issues: { nodes: [] } });
|
||||
await tracker.listIssues!({}, project);
|
||||
|
||||
const writeCall = requestMock.mock.results[0].value.write.mock.calls[0][0];
|
||||
const body = JSON.parse(writeCall);
|
||||
expect(body.variables.filter.team).toEqual({
|
||||
id: { eq: "team-uuid-1" },
|
||||
});
|
||||
});
|
||||
|
||||
it("respects custom limit", async () => {
|
||||
mockLinearAPI({ issues: { nodes: [] } });
|
||||
await tracker.listIssues!({ limit: 5 }, project);
|
||||
|
||||
const writeCall = requestMock.mock.results[0].value.write.mock.calls[0][0];
|
||||
const body = JSON.parse(writeCall);
|
||||
expect(body.variables.first).toBe(5);
|
||||
});
|
||||
|
||||
it("defaults limit to 30", async () => {
|
||||
mockLinearAPI({ issues: { nodes: [] } });
|
||||
await tracker.listIssues!({}, project);
|
||||
|
||||
const writeCall = requestMock.mock.results[0].value.write.mock.calls[0][0];
|
||||
const body = JSON.parse(writeCall);
|
||||
expect(body.variables.first).toBe(30);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- updateIssue -------------------------------------------------------
|
||||
|
||||
describe("updateIssue", () => {
|
||||
const workflowStates = {
|
||||
workflowStates: {
|
||||
nodes: [
|
||||
{ id: "state-1", name: "Todo", type: "unstarted" },
|
||||
{ id: "state-2", name: "In Progress", type: "started" },
|
||||
{ id: "state-3", name: "Done", type: "completed" },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
it("changes state to closed (completed)", async () => {
|
||||
// 1st: resolve identifier to UUID
|
||||
mockLinearAPI({ issue: { id: "uuid-123", team: { id: "team-1" } } });
|
||||
// 2nd: fetch workflow states
|
||||
mockLinearAPI(workflowStates);
|
||||
// 3rd: issueUpdate mutation
|
||||
mockLinearAPI({ issueUpdate: { success: true } });
|
||||
|
||||
await tracker.updateIssue!("INT-123", { state: "closed" }, project);
|
||||
expect(requestMock).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("changes state to open (unstarted)", async () => {
|
||||
mockLinearAPI({ issue: { id: "uuid-123", team: { id: "team-1" } } });
|
||||
mockLinearAPI(workflowStates);
|
||||
mockLinearAPI({ issueUpdate: { success: true } });
|
||||
|
||||
await tracker.updateIssue!("INT-123", { state: "open" }, project);
|
||||
|
||||
// Verify the mutation uses the unstarted state ID
|
||||
const writeCall = requestMock.mock.results[2].value.write.mock.calls[0][0];
|
||||
const body = JSON.parse(writeCall);
|
||||
expect(body.variables.stateId).toBe("state-1");
|
||||
});
|
||||
|
||||
it("changes state to in_progress (started)", async () => {
|
||||
mockLinearAPI({ issue: { id: "uuid-123", team: { id: "team-1" } } });
|
||||
mockLinearAPI(workflowStates);
|
||||
mockLinearAPI({ issueUpdate: { success: true } });
|
||||
|
||||
await tracker.updateIssue!("INT-123", { state: "in_progress" }, project);
|
||||
|
||||
const writeCall = requestMock.mock.results[2].value.write.mock.calls[0][0];
|
||||
const body = JSON.parse(writeCall);
|
||||
expect(body.variables.stateId).toBe("state-2");
|
||||
});
|
||||
|
||||
it("throws when target workflow state is not found", async () => {
|
||||
mockLinearAPI({ issue: { id: "uuid-123", team: { id: "team-1" } } });
|
||||
// Return states without "completed"
|
||||
mockLinearAPI({
|
||||
workflowStates: {
|
||||
nodes: [{ id: "state-1", name: "Todo", type: "unstarted" }],
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
tracker.updateIssue!("INT-123", { state: "closed" }, project),
|
||||
).rejects.toThrow('No workflow state of type "completed"');
|
||||
});
|
||||
|
||||
it("adds a comment", async () => {
|
||||
mockLinearAPI({ issue: { id: "uuid-123", team: { id: "team-1" } } });
|
||||
mockLinearAPI({ commentCreate: { success: true } });
|
||||
|
||||
await tracker.updateIssue!(
|
||||
"INT-123",
|
||||
{ comment: "Working on this" },
|
||||
project,
|
||||
);
|
||||
expect(requestMock).toHaveBeenCalledTimes(2);
|
||||
|
||||
// Verify comment body
|
||||
const writeCall = requestMock.mock.results[1].value.write.mock.calls[0][0];
|
||||
const body = JSON.parse(writeCall);
|
||||
expect(body.variables.body).toBe("Working on this");
|
||||
});
|
||||
|
||||
it("handles state change + comment together", async () => {
|
||||
// 1: resolve identifier
|
||||
mockLinearAPI({ issue: { id: "uuid-123", team: { id: "team-1" } } });
|
||||
// 2: workflow states
|
||||
mockLinearAPI(workflowStates);
|
||||
// 3: issueUpdate (state)
|
||||
mockLinearAPI({ issueUpdate: { success: true } });
|
||||
// 4: commentCreate
|
||||
mockLinearAPI({ commentCreate: { success: true } });
|
||||
|
||||
await tracker.updateIssue!(
|
||||
"INT-123",
|
||||
{ state: "closed", comment: "Done!" },
|
||||
project,
|
||||
);
|
||||
expect(requestMock).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
|
||||
it("updates assignee by resolving display name to ID", async () => {
|
||||
// 1: resolve identifier
|
||||
mockLinearAPI({ issue: { id: "uuid-123", team: { id: "team-1" } } });
|
||||
// 2: user lookup
|
||||
mockLinearAPI({
|
||||
users: { nodes: [{ id: "user-1", displayName: "Alice", name: "Alice Smith" }] },
|
||||
});
|
||||
// 3: issueUpdate (assignee)
|
||||
mockLinearAPI({ issueUpdate: { success: true } });
|
||||
|
||||
await tracker.updateIssue!("INT-123", { assignee: "Alice" }, project);
|
||||
expect(requestMock).toHaveBeenCalledTimes(3);
|
||||
|
||||
const writeCall = requestMock.mock.results[2].value.write.mock.calls[0][0];
|
||||
const body = JSON.parse(writeCall);
|
||||
expect(body.variables.assigneeId).toBe("user-1");
|
||||
});
|
||||
|
||||
it("updates labels additively (merges with existing)", async () => {
|
||||
// 1: resolve identifier
|
||||
mockLinearAPI({ issue: { id: "uuid-123", team: { id: "team-1" } } });
|
||||
// 2: fetch existing labels on the issue
|
||||
mockLinearAPI({ issue: { labels: { nodes: [{ id: "label-existing" }] } } });
|
||||
// 3: team label lookup
|
||||
mockLinearAPI({
|
||||
issueLabels: {
|
||||
nodes: [
|
||||
{ id: "label-1", name: "bug" },
|
||||
{ id: "label-2", name: "urgent" },
|
||||
],
|
||||
},
|
||||
});
|
||||
// 4: issueUpdate (labels)
|
||||
mockLinearAPI({ issueUpdate: { success: true } });
|
||||
|
||||
await tracker.updateIssue!("INT-123", { labels: ["bug", "urgent"] }, project);
|
||||
expect(requestMock).toHaveBeenCalledTimes(4);
|
||||
|
||||
const writeCall = requestMock.mock.results[3].value.write.mock.calls[0][0];
|
||||
const body = JSON.parse(writeCall);
|
||||
// Should include existing + new labels
|
||||
expect(body.variables.labelIds).toEqual(
|
||||
expect.arrayContaining(["label-existing", "label-1", "label-2"]),
|
||||
);
|
||||
expect(body.variables.labelIds).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- createIssue -------------------------------------------------------
|
||||
|
||||
describe("createIssue", () => {
|
||||
it("creates a basic issue", async () => {
|
||||
mockLinearAPI({
|
||||
issueCreate: { success: true, issue: sampleIssueNode },
|
||||
});
|
||||
|
||||
const issue = await tracker.createIssue!(
|
||||
{ title: "Fix login bug", description: "Desc" },
|
||||
project,
|
||||
);
|
||||
expect(issue).toMatchObject({
|
||||
id: "INT-123",
|
||||
title: "Fix login bug",
|
||||
state: "in_progress",
|
||||
});
|
||||
});
|
||||
|
||||
it("passes priority to mutation", async () => {
|
||||
mockLinearAPI({
|
||||
issueCreate: { success: true, issue: sampleIssueNode },
|
||||
});
|
||||
|
||||
await tracker.createIssue!(
|
||||
{ title: "Bug", description: "", priority: 1 },
|
||||
project,
|
||||
);
|
||||
|
||||
const writeCall = requestMock.mock.results[0].value.write.mock.calls[0][0];
|
||||
const body = JSON.parse(writeCall);
|
||||
expect(body.variables.priority).toBe(1);
|
||||
});
|
||||
|
||||
it("resolves assignee by display name after creation", async () => {
|
||||
// 1: create issue
|
||||
mockLinearAPI({
|
||||
issueCreate: {
|
||||
success: true,
|
||||
issue: { ...sampleIssueNode, assignee: null },
|
||||
},
|
||||
});
|
||||
// 2: look up user by display name
|
||||
mockLinearAPI({
|
||||
users: { nodes: [{ id: "user-1", displayName: "Alice", name: "Alice Smith" }] },
|
||||
});
|
||||
// 3: issueUpdate to assign
|
||||
mockLinearAPI({ issueUpdate: { success: true } });
|
||||
|
||||
const issue = await tracker.createIssue!(
|
||||
{ title: "Bug", description: "", assignee: "Alice" },
|
||||
project,
|
||||
);
|
||||
expect(issue.assignee).toBe("Alice");
|
||||
expect(requestMock).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("skips assignee when user not found", async () => {
|
||||
mockLinearAPI({
|
||||
issueCreate: {
|
||||
success: true,
|
||||
issue: { ...sampleIssueNode, assignee: null },
|
||||
},
|
||||
});
|
||||
// User lookup returns empty
|
||||
mockLinearAPI({ users: { nodes: [] } });
|
||||
|
||||
const issue = await tracker.createIssue!(
|
||||
{ title: "Bug", description: "", assignee: "Unknown" },
|
||||
project,
|
||||
);
|
||||
expect(issue.assignee).toBeUndefined();
|
||||
// Only 2 calls: create + user lookup (no update since user not found)
|
||||
expect(requestMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("adds labels after creation", async () => {
|
||||
mockLinearAPI({
|
||||
issueCreate: {
|
||||
success: true,
|
||||
issue: { ...sampleIssueNode, labels: { nodes: [] } },
|
||||
},
|
||||
});
|
||||
// Label lookup
|
||||
mockLinearAPI({
|
||||
issueLabels: {
|
||||
nodes: [
|
||||
{ id: "label-1", name: "bug" },
|
||||
{ id: "label-2", name: "urgent" },
|
||||
{ id: "label-3", name: "other" },
|
||||
],
|
||||
},
|
||||
});
|
||||
// issueUpdate to set labels
|
||||
mockLinearAPI({ issueUpdate: { success: true } });
|
||||
|
||||
const issue = await tracker.createIssue!(
|
||||
{ title: "Bug", description: "", labels: ["bug", "urgent"] },
|
||||
project,
|
||||
);
|
||||
expect(issue.labels).toEqual(["bug", "urgent"]);
|
||||
|
||||
// Verify label IDs sent
|
||||
const writeCall = requestMock.mock.results[2].value.write.mock.calls[0][0];
|
||||
const body = JSON.parse(writeCall);
|
||||
expect(body.variables.labelIds).toEqual(["label-1", "label-2"]);
|
||||
});
|
||||
|
||||
it("only reflects actually-applied labels when some don't exist", async () => {
|
||||
mockLinearAPI({
|
||||
issueCreate: {
|
||||
success: true,
|
||||
issue: { ...sampleIssueNode, labels: { nodes: [] } },
|
||||
},
|
||||
});
|
||||
// Only "bug" exists in Linear; "nonexistent" does not
|
||||
mockLinearAPI({
|
||||
issueLabels: {
|
||||
nodes: [
|
||||
{ id: "label-1", name: "bug" },
|
||||
],
|
||||
},
|
||||
});
|
||||
mockLinearAPI({ issueUpdate: { success: true } });
|
||||
|
||||
const issue = await tracker.createIssue!(
|
||||
{ title: "Bug", description: "", labels: ["bug", "nonexistent"] },
|
||||
project,
|
||||
);
|
||||
// Should only include the label that was actually found and applied
|
||||
expect(issue.labels).toEqual(["bug"]);
|
||||
});
|
||||
|
||||
it("throws when teamId is missing from config", async () => {
|
||||
const noTeam: ProjectConfig = {
|
||||
...project,
|
||||
tracker: { plugin: "linear" },
|
||||
};
|
||||
await expect(
|
||||
tracker.createIssue!({ title: "Bug", description: "" }, noTeam),
|
||||
).rejects.toThrow("teamId");
|
||||
});
|
||||
|
||||
it("handles assignee error gracefully (best-effort)", async () => {
|
||||
mockLinearAPI({
|
||||
issueCreate: {
|
||||
success: true,
|
||||
issue: { ...sampleIssueNode, assignee: null },
|
||||
},
|
||||
});
|
||||
// User lookup fails
|
||||
mockLinearError("Internal error");
|
||||
|
||||
const issue = await tracker.createIssue!(
|
||||
{ title: "Bug", description: "", assignee: "Alice" },
|
||||
project,
|
||||
);
|
||||
// Should still return the issue without assignee
|
||||
expect(issue).toMatchObject({ id: "INT-123" });
|
||||
expect(issue.assignee).toBeUndefined();
|
||||
});
|
||||
|
||||
it("handles label error gracefully (best-effort)", async () => {
|
||||
mockLinearAPI({
|
||||
issueCreate: {
|
||||
success: true,
|
||||
issue: { ...sampleIssueNode, labels: { nodes: [] } },
|
||||
},
|
||||
});
|
||||
// Label lookup fails
|
||||
mockLinearError("Internal error");
|
||||
|
||||
const issue = await tracker.createIssue!(
|
||||
{ title: "Bug", description: "", labels: ["bug"] },
|
||||
project,
|
||||
);
|
||||
// Should still return the issue without labels
|
||||
expect(issue).toMatchObject({ id: "INT-123" });
|
||||
expect(issue.labels).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- linearQuery error handling ----------------------------------------
|
||||
|
||||
describe("linearQuery error handling", () => {
|
||||
it("throws on missing LINEAR_API_KEY", async () => {
|
||||
delete process.env["LINEAR_API_KEY"];
|
||||
await expect(tracker.getIssue("INT-123", project)).rejects.toThrow(
|
||||
"LINEAR_API_KEY",
|
||||
);
|
||||
});
|
||||
|
||||
it("throws on GraphQL errors", async () => {
|
||||
mockLinearError("You do not have access");
|
||||
await expect(tracker.getIssue("INT-123", project)).rejects.toThrow(
|
||||
"Linear API error: You do not have access",
|
||||
);
|
||||
});
|
||||
|
||||
it("throws on HTTP error status", async () => {
|
||||
mockHTTPError(401, "Unauthorized");
|
||||
await expect(tracker.getIssue("INT-123", project)).rejects.toThrow(
|
||||
"Linear API returned HTTP 401",
|
||||
);
|
||||
});
|
||||
|
||||
it("throws on empty data response", async () => {
|
||||
const body = JSON.stringify({ data: null });
|
||||
requestMock.mockImplementationOnce(
|
||||
(_opts: Record<string, unknown>, callback: (res: EventEmitter & { statusCode: number }) => void) => {
|
||||
const req = Object.assign(new EventEmitter(), {
|
||||
write: vi.fn(),
|
||||
end: vi.fn(() => {
|
||||
const res = Object.assign(new EventEmitter(), { statusCode: 200 });
|
||||
callback(res);
|
||||
process.nextTick(() => {
|
||||
res.emit("data", Buffer.from(body));
|
||||
res.emit("end");
|
||||
});
|
||||
}),
|
||||
destroy: vi.fn(),
|
||||
setTimeout: vi.fn(),
|
||||
});
|
||||
return req;
|
||||
},
|
||||
);
|
||||
|
||||
await expect(tracker.getIssue("INT-123", project)).rejects.toThrow(
|
||||
"Linear API returned no data",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Reference in New Issue