agent-orchestrator/packages/integration-tests/src/tracker-linear.integration....

293 lines
9.3 KiB
TypeScript

/**
* 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");
});
});