582 lines
17 KiB
TypeScript
582 lines
17 KiB
TypeScript
/**
|
|
* 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 {
|
|
CI_STATUS,
|
|
type PluginModule,
|
|
type SCM,
|
|
type Session,
|
|
type ProjectConfig,
|
|
type PRInfo,
|
|
type PRState,
|
|
type MergeMethod,
|
|
type CICheck,
|
|
type CIStatus,
|
|
type Review,
|
|
type ReviewDecision,
|
|
type ReviewComment,
|
|
type AutomatedComment,
|
|
type MergeReadiness,
|
|
} from "@composio/ao-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 getPRSummary(pr: PRInfo) {
|
|
const raw = await gh([
|
|
"pr",
|
|
"view",
|
|
String(pr.number),
|
|
"--repo",
|
|
repoFlag(pr),
|
|
"--json",
|
|
"state,title,additions,deletions",
|
|
]);
|
|
const data: {
|
|
state: string;
|
|
title: string;
|
|
additions: number;
|
|
deletions: number;
|
|
} = JSON.parse(raw);
|
|
const s = data.state.toUpperCase();
|
|
const state: PRState = s === "MERGED" ? "merged" : s === "CLOSED" ? "closed" : "open";
|
|
return {
|
|
state,
|
|
title: data.title ?? "",
|
|
additions: data.additions ?? 0,
|
|
deletions: data.deletions ?? 0,
|
|
};
|
|
},
|
|
|
|
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,link,startedAt,completedAt",
|
|
]);
|
|
|
|
const checks: Array<{
|
|
name: string;
|
|
state: string;
|
|
link: string;
|
|
startedAt: string;
|
|
completedAt: string;
|
|
}> = JSON.parse(raw);
|
|
|
|
return checks.map((c) => {
|
|
let status: CICheck["status"];
|
|
const state = c.state?.toUpperCase();
|
|
|
|
// gh pr checks returns state directly: SUCCESS, FAILURE, PENDING, QUEUED, etc.
|
|
if (state === "PENDING" || state === "QUEUED") {
|
|
status = "pending";
|
|
} else if (state === "IN_PROGRESS") {
|
|
status = "running";
|
|
} else if (state === "SUCCESS") {
|
|
status = "passed";
|
|
} else if (
|
|
state === "FAILURE" ||
|
|
state === "TIMED_OUT" ||
|
|
state === "CANCELLED" ||
|
|
state === "ACTION_REQUIRED"
|
|
) {
|
|
status = "failed";
|
|
} else if (state === "SKIPPED" || state === "NEUTRAL") {
|
|
status = "skipped";
|
|
} else {
|
|
// Unknown state on a check — fail closed for safety
|
|
status = "failed";
|
|
}
|
|
|
|
return {
|
|
name: c.name,
|
|
status,
|
|
url: c.link || undefined,
|
|
conclusion: state || undefined, // Store original state for debugging
|
|
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 {
|
|
// Before fail-closing, check if the PR is merged/closed —
|
|
// GitHub may not return check data for those, and reporting
|
|
// "failing" for a merged PR is wrong.
|
|
try {
|
|
const state = await this.getPRState(pr);
|
|
if (state === "merged" || state === "closed") return "none";
|
|
} catch {
|
|
// Can't determine state either; fall through to fail-closed.
|
|
}
|
|
// Fail closed for open PRs: 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[] = [];
|
|
|
|
// First, check if the PR is merged
|
|
// GitHub returns mergeable=null for merged PRs, which is not useful
|
|
// Note: We only skip checks for merged PRs. Closed PRs still need accurate status.
|
|
const state = await this.getPRState(pr);
|
|
if (state === "merged") {
|
|
// For merged PRs, return a clean result without querying mergeable status
|
|
return {
|
|
mergeable: true,
|
|
ciPassing: true,
|
|
approved: true,
|
|
noConflicts: true,
|
|
blockers: [],
|
|
};
|
|
}
|
|
|
|
// 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 === CI_STATUS.PASSING || ciStatus === CI_STATUS.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>;
|