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>
This commit is contained in:
parent
891deea532
commit
014e55e829
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ const BOT_AUTHORS = new Set([
|
|||
async function gh(args: string[]): Promise<string> {
|
||||
const { stdout } = await execFileAsync("gh", args, {
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
timeout: 30_000,
|
||||
});
|
||||
return stdout.trim();
|
||||
}
|
||||
|
|
@ -55,7 +56,9 @@ function repoFlag(pr: PRInfo): string {
|
|||
}
|
||||
|
||||
function parseDate(val: string | undefined | null): Date {
|
||||
return val ? new Date(val) : new Date();
|
||||
if (!val) return new Date(0);
|
||||
const d = new Date(val);
|
||||
return isNaN(d.getTime()) ? new Date(0) : d;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -72,7 +75,11 @@ function createGitHubSCM(): SCM {
|
|||
): Promise<PRInfo | null> {
|
||||
if (!session.branch) return null;
|
||||
|
||||
const [owner, repo] = project.repo.split("/");
|
||||
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",
|
||||
|
|
@ -226,6 +233,11 @@ function createGitHubSCM(): SCM {
|
|||
);
|
||||
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";
|
||||
},
|
||||
|
||||
|
|
@ -287,39 +299,81 @@ function createGitHubSCM(): SCM {
|
|||
|
||||
async getPendingComments(pr: PRInfo): Promise<ReviewComment[]> {
|
||||
try {
|
||||
// Use GraphQL to get review threads with actual isResolved status
|
||||
const raw = await gh([
|
||||
"api",
|
||||
`repos/${repoFlag(pr)}/pulls/${pr.number}/comments`,
|
||||
"graphql",
|
||||
"-f",
|
||||
`query=query {
|
||||
repository(owner: "${pr.owner}", name: "${pr.repo}") {
|
||||
pullRequest(number: ${pr.number}) {
|
||||
reviewThreads(first: 100) {
|
||||
nodes {
|
||||
isResolved
|
||||
comments(first: 1) {
|
||||
nodes {
|
||||
id
|
||||
author { login }
|
||||
body
|
||||
path
|
||||
line
|
||||
url
|
||||
createdAt
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`,
|
||||
]);
|
||||
|
||||
const comments: Array<{
|
||||
id: number;
|
||||
user: { login: string };
|
||||
body: string;
|
||||
path: string;
|
||||
line: number | null;
|
||||
original_line: number | null;
|
||||
in_reply_to_id?: number;
|
||||
created_at: string;
|
||||
html_url: string;
|
||||
}> = JSON.parse(raw);
|
||||
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);
|
||||
|
||||
// Top-level comments only (not replies) that aren't from bots
|
||||
return comments
|
||||
.filter(
|
||||
(c) =>
|
||||
!c.in_reply_to_id && !BOT_AUTHORS.has(c.user?.login ?? ""),
|
||||
)
|
||||
.map((c) => ({
|
||||
id: String(c.id),
|
||||
author: c.user?.login ?? "unknown",
|
||||
body: c.body,
|
||||
path: c.path || undefined,
|
||||
line: c.line ?? c.original_line ?? undefined,
|
||||
isResolved: false,
|
||||
createdAt: parseDate(c.created_at),
|
||||
url: c.html_url,
|
||||
}));
|
||||
const threads =
|
||||
data.data.repository.pullRequest.reviewThreads.nodes;
|
||||
|
||||
return threads
|
||||
.filter((t) => {
|
||||
const author = t.comments.nodes[0]?.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 [];
|
||||
}
|
||||
|
|
@ -327,7 +381,7 @@ function createGitHubSCM(): SCM {
|
|||
|
||||
async getAutomatedComments(pr: PRInfo): Promise<AutomatedComment[]> {
|
||||
try {
|
||||
// Get review comments from bots
|
||||
// Single API call gets all review comments (including bot review comments)
|
||||
const raw = await gh([
|
||||
"api",
|
||||
`repos/${repoFlag(pr)}/pulls/${pr.number}/comments`,
|
||||
|
|
@ -344,89 +398,38 @@ function createGitHubSCM(): SCM {
|
|||
html_url: string;
|
||||
}> = JSON.parse(raw);
|
||||
|
||||
// Also get reviews from bots (like cursor[bot] which leaves reviews)
|
||||
let botReviewComments: Array<{
|
||||
id: number;
|
||||
user: { login: string };
|
||||
body: string;
|
||||
path: string;
|
||||
line: number | null;
|
||||
original_line: number | null;
|
||||
created_at: string;
|
||||
html_url: string;
|
||||
}> = [];
|
||||
|
||||
try {
|
||||
const reviewsRaw = await gh([
|
||||
"api",
|
||||
`repos/${repoFlag(pr)}/pulls/${pr.number}/reviews`,
|
||||
]);
|
||||
const reviews: Array<{
|
||||
id: number;
|
||||
user: { login: string };
|
||||
}> = JSON.parse(reviewsRaw);
|
||||
|
||||
// For each bot review, get the review comments
|
||||
for (const review of reviews) {
|
||||
if (!BOT_AUTHORS.has(review.user?.login ?? "")) continue;
|
||||
try {
|
||||
const reviewCommentsRaw = await gh([
|
||||
"api",
|
||||
`repos/${repoFlag(pr)}/pulls/${pr.number}/reviews/${review.id}/comments`,
|
||||
]);
|
||||
const rc: typeof botReviewComments = JSON.parse(reviewCommentsRaw);
|
||||
botReviewComments = botReviewComments.concat(rc);
|
||||
} catch {
|
||||
// Skip if we can't fetch review comments
|
||||
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";
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Skip if we can't fetch reviews
|
||||
}
|
||||
|
||||
const allBotComments = [
|
||||
...comments.filter((c) => BOT_AUTHORS.has(c.user?.login ?? "")),
|
||||
...botReviewComments,
|
||||
];
|
||||
|
||||
// Deduplicate by id
|
||||
const seen = new Set<number>();
|
||||
const unique = allBotComments.filter((c) => {
|
||||
if (seen.has(c.id)) return false;
|
||||
seen.add(c.id);
|
||||
return true;
|
||||
});
|
||||
|
||||
return unique.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,
|
||||
};
|
||||
});
|
||||
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 [];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,610 @@
|
|||
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 mockGhRaw(stdout: string) {
|
||||
ghMock.mockResolvedValueOnce({ stdout });
|
||||
}
|
||||
|
||||
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: "" },
|
||||
]);
|
||||
|
||||
const checks = await scm.getCIChecks(pr);
|
||||
expect(checks).toHaveLength(8);
|
||||
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");
|
||||
});
|
||||
|
||||
it("returns empty array on error", async () => {
|
||||
mockGhError("no checks");
|
||||
expect(await scm.getCIChecks(pr)).toEqual([]);
|
||||
});
|
||||
|
||||
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 "none" on error (getCIChecks returns [])', async () => {
|
||||
mockGhError();
|
||||
expect(await scm.getCISummary(pr)).toBe("none");
|
||||
});
|
||||
|
||||
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 non-bot comments with isResolved 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(2);
|
||||
expect(comments[0]).toMatchObject({ id: "C1", author: "alice", isResolved: false });
|
||||
expect(comments[1]).toMatchObject({ id: "C2", author: "bob", isResolved: true });
|
||||
});
|
||||
|
||||
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");
|
||||
});
|
||||
|
||||
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 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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ const execFileAsync = promisify(execFile);
|
|||
async function gh(args: string[]): Promise<string> {
|
||||
const { stdout } = await execFileAsync("gh", args, {
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
timeout: 30_000,
|
||||
});
|
||||
return stdout.trim();
|
||||
}
|
||||
|
|
@ -270,8 +271,6 @@ function createGitHubTracker(): Tracker {
|
|||
input.title,
|
||||
"--body",
|
||||
input.description,
|
||||
"--json",
|
||||
"number,title,body,url,state,labels,assignees",
|
||||
];
|
||||
|
||||
if (input.labels && input.labels.length > 0) {
|
||||
|
|
@ -282,26 +281,17 @@ function createGitHubTracker(): Tracker {
|
|||
args.push("--assignee", input.assignee);
|
||||
}
|
||||
|
||||
const raw = await gh(args);
|
||||
const data: {
|
||||
number: number;
|
||||
title: string;
|
||||
body: string;
|
||||
url: string;
|
||||
state: string;
|
||||
labels: Array<{ name: string }>;
|
||||
assignees: Array<{ login: string }>;
|
||||
} = JSON.parse(raw);
|
||||
// gh issue create outputs the URL of the new issue
|
||||
const url = await gh(args);
|
||||
|
||||
return {
|
||||
id: String(data.number),
|
||||
title: data.title,
|
||||
description: data.body ?? "",
|
||||
url: data.url,
|
||||
state: "open",
|
||||
labels: (data.labels ?? []).map((l) => l.name),
|
||||
assignee: data.assignees?.[0]?.login,
|
||||
};
|
||||
// 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);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,6 +45,14 @@ async function linearQuery<T>(
|
|||
|
||||
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,
|
||||
|
|
@ -60,25 +68,35 @@ async function linearQuery<T>(
|
|||
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: LinearResponse<T> = JSON.parse(text);
|
||||
if (json.errors && json.errors.length > 0) {
|
||||
reject(new Error(`Linear API error: ${json.errors[0].message}`));
|
||||
return;
|
||||
settle(() => {
|
||||
try {
|
||||
const text = Buffer.concat(chunks).toString("utf-8");
|
||||
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);
|
||||
}
|
||||
if (!json.data) {
|
||||
reject(new Error("Linear API returned no data"));
|
||||
return;
|
||||
}
|
||||
resolve(json.data);
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
req.on("error", reject);
|
||||
|
||||
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();
|
||||
});
|
||||
|
|
@ -197,7 +215,13 @@ function createLinearTracker(): Tracker {
|
|||
return stateType === "completed" || stateType === "canceled";
|
||||
},
|
||||
|
||||
issueUrl(identifier: string, _project: ProjectConfig): string {
|
||||
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}`;
|
||||
},
|
||||
|
||||
|
|
@ -248,44 +272,44 @@ function createLinearTracker(): Tracker {
|
|||
filters: IssueFilters,
|
||||
project: ProjectConfig,
|
||||
): Promise<Issue[]> {
|
||||
// Build filter object for Linear's GraphQL API
|
||||
const filterParts: string[] = [];
|
||||
// Build filter object using GraphQL variables to prevent injection
|
||||
const filter: Record<string, unknown> = {};
|
||||
const variables: Record<string, unknown> = {};
|
||||
|
||||
if (filters.state === "open") {
|
||||
filterParts.push(`state: { type: { nin: ["completed", "canceled"] } }`);
|
||||
filter["state"] = { type: { nin: ["completed", "canceled"] } };
|
||||
} else if (filters.state === "closed") {
|
||||
filterParts.push(`state: { type: { in: ["completed", "canceled"] } }`);
|
||||
filter["state"] = { type: { in: ["completed", "canceled"] } };
|
||||
}
|
||||
|
||||
if (filters.assignee) {
|
||||
filterParts.push(`assignee: { displayName: { eq: "${filters.assignee}" } }`);
|
||||
filter["assignee"] = { displayName: { eq: filters.assignee } };
|
||||
}
|
||||
|
||||
if (filters.labels && filters.labels.length > 0) {
|
||||
filterParts.push(`labels: { name: { in: [${filters.labels.map((l) => `"${l}"`).join(", ")}] } }`);
|
||||
filter["labels"] = { name: { in: filters.labels } };
|
||||
}
|
||||
|
||||
// Add team filter if available from project config
|
||||
const teamId = (project.tracker as Record<string, unknown> | undefined)?.["teamId"];
|
||||
const teamId = project.tracker?.["teamId"];
|
||||
if (teamId) {
|
||||
filterParts.push(`team: { id: { eq: "${teamId}" } }`);
|
||||
filter["team"] = { id: { eq: teamId } };
|
||||
}
|
||||
|
||||
const filterStr =
|
||||
filterParts.length > 0 ? `filter: { ${filterParts.join(", ")} }` : "";
|
||||
|
||||
const limit = filters.limit ?? 30;
|
||||
variables["filter"] = Object.keys(filter).length > 0 ? filter : undefined;
|
||||
variables["first"] = filters.limit ?? 30;
|
||||
|
||||
const data = await linearQuery<{
|
||||
issues: { nodes: LinearIssueNode[] };
|
||||
}>(
|
||||
`query {
|
||||
issues(${filterStr} first: ${limit}) {
|
||||
`query($filter: IssueFilter, $first: Int!) {
|
||||
issues(filter: $filter, first: $first) {
|
||||
nodes {
|
||||
${ISSUE_FIELDS}
|
||||
}
|
||||
}
|
||||
}`,
|
||||
variables,
|
||||
);
|
||||
|
||||
return data.issues.nodes.map((node) => ({
|
||||
|
|
@ -375,7 +399,7 @@ function createLinearTracker(): Tracker {
|
|||
input: CreateIssueInput,
|
||||
project: ProjectConfig,
|
||||
): Promise<Issue> {
|
||||
const teamId = (project.tracker as Record<string, unknown> | undefined)?.["teamId"];
|
||||
const teamId = project.tracker?.["teamId"];
|
||||
if (!teamId) {
|
||||
throw new Error(
|
||||
"Linear tracker requires 'teamId' in project tracker config",
|
||||
|
|
@ -392,18 +416,36 @@ function createLinearTracker(): Tracker {
|
|||
variables["priority"] = input.priority;
|
||||
}
|
||||
|
||||
if (input.assignee) {
|
||||
variables["assigneeName"] = input.assignee;
|
||||
}
|
||||
|
||||
// Build label IDs variable parts
|
||||
const varDecls = [
|
||||
"$title: String!",
|
||||
"$description: String!",
|
||||
"$teamId: String!",
|
||||
"$priority: Int",
|
||||
...(input.assignee ? ["$assigneeName: String"] : []),
|
||||
];
|
||||
|
||||
const inputFields = [
|
||||
"title: $title",
|
||||
"description: $description",
|
||||
"teamId: $teamId",
|
||||
"priority: $priority",
|
||||
...(input.assignee ? ["assigneeDisplayName: $assigneeName"] : []),
|
||||
];
|
||||
|
||||
const data = await linearQuery<{
|
||||
issueCreate: {
|
||||
success: boolean;
|
||||
issue: LinearIssueNode;
|
||||
};
|
||||
}>(
|
||||
`mutation($title: String!, $description: String!, $teamId: String!, $priority: Int) {
|
||||
`mutation(${varDecls.join(", ")}) {
|
||||
issueCreate(input: {
|
||||
title: $title,
|
||||
description: $description,
|
||||
teamId: $teamId,
|
||||
priority: $priority
|
||||
${inputFields.join(",\n ")}
|
||||
}) {
|
||||
success
|
||||
issue {
|
||||
|
|
@ -415,7 +457,7 @@ function createLinearTracker(): Tracker {
|
|||
);
|
||||
|
||||
const node = data.issueCreate.issue;
|
||||
return {
|
||||
const issue: Issue = {
|
||||
id: node.identifier,
|
||||
title: node.title,
|
||||
description: node.description ?? "",
|
||||
|
|
@ -425,6 +467,45 @@ function createLinearTracker(): Tracker {
|
|||
assignee: node.assignee?.displayName ?? node.assignee?.name,
|
||||
priority: node.priority,
|
||||
};
|
||||
|
||||
// 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 linearQuery<{
|
||||
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 labelIds = input.labels
|
||||
.map((name) => labelMap.get(name))
|
||||
.filter((id): id is string => id !== undefined);
|
||||
|
||||
if (labelIds.length > 0) {
|
||||
await linearQuery(
|
||||
`mutation($id: String!, $labelIds: [String!]!) {
|
||||
issueUpdate(id: $id, input: { labelIds: $labelIds }) {
|
||||
success
|
||||
}
|
||||
}`,
|
||||
{ id: node.id, labelIds },
|
||||
);
|
||||
// Reflect the labels we added
|
||||
issue.labels = input.labels;
|
||||
}
|
||||
} catch {
|
||||
// Labels are best-effort; don't fail the whole creation
|
||||
}
|
||||
}
|
||||
|
||||
return issue;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -258,6 +258,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 +300,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 +316,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