mirror of
https://github.com/crewAIInc/crewAI.git
synced 2026-08-10 08:21:54 +00:00
387 lines
11 KiB
JavaScript
387 lines
11 KiB
JavaScript
import { readFile } from "node:fs/promises";
|
|
import { pathToFileURL } from "node:url";
|
|
|
|
const COMMENT_MARKER = "<!-- crewai-issue-gate -->";
|
|
const GATE_LABELS = [
|
|
"issue-gate:passed",
|
|
"issue-gate:exempt",
|
|
"needs-ready-issue",
|
|
];
|
|
const EXEMPT_LABELS = new Set(["issue-gate:override", "policy:legacy"]);
|
|
const EXEMPT_ACTORS = new Set([
|
|
"dependabot[bot]",
|
|
"github-actions[bot]",
|
|
"renovate[bot]",
|
|
]);
|
|
const VALID_MODES = new Set(["observe", "block", "close"]);
|
|
|
|
class ApiError extends Error {
|
|
constructor(status, message) {
|
|
super(message);
|
|
this.name = "ApiError";
|
|
this.status = status;
|
|
}
|
|
}
|
|
|
|
/** Return unique issue references declared with `Implements #123`. */
|
|
export function parseIssueReferences(body, defaultRepository) {
|
|
const references = [];
|
|
const seen = new Set();
|
|
const pattern =
|
|
/^[\t ]*Implements[\t ]+(?:(?<repository>[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+))?#(?<number>\d+)\b/gim;
|
|
|
|
for (const match of (body ?? "").matchAll(pattern)) {
|
|
const repository = match.groups.repository ?? defaultRepository;
|
|
const number = Number.parseInt(match.groups.number, 10);
|
|
const key = `${repository.toLowerCase()}#${number}`;
|
|
|
|
if (!seen.has(key)) {
|
|
references.push({ repository, number });
|
|
seen.add(key);
|
|
}
|
|
}
|
|
|
|
return references;
|
|
}
|
|
|
|
function labelNames(item) {
|
|
return new Set((item.labels ?? []).map((label) => label.name.toLowerCase()));
|
|
}
|
|
|
|
function exemptionReason(pullRequest, cutoff) {
|
|
const labels = labelNames(pullRequest);
|
|
for (const label of EXEMPT_LABELS) {
|
|
if (labels.has(label)) {
|
|
return {
|
|
kind: label === "policy:legacy" ? "legacy" : "override",
|
|
reason: `the pull request has the \`${label}\` exemption label`,
|
|
};
|
|
}
|
|
}
|
|
|
|
const actor = pullRequest.user?.login?.toLowerCase();
|
|
if (EXEMPT_ACTORS.has(actor)) {
|
|
return {
|
|
kind: "automation",
|
|
reason: `\`${pullRequest.user.login}\` is an exempt automation account`,
|
|
};
|
|
}
|
|
|
|
if (cutoff) {
|
|
const cutoffTime = Date.parse(cutoff);
|
|
if (Number.isNaN(cutoffTime)) {
|
|
throw new Error(`ISSUE_GATE_CUTOFF is not a valid date: ${cutoff}`);
|
|
}
|
|
|
|
const createdTime = Date.parse(pullRequest.created_at);
|
|
if (Number.isNaN(createdTime)) {
|
|
throw new Error(`Pull request has an invalid creation date: ${pullRequest.created_at}`);
|
|
}
|
|
|
|
if (createdTime < cutoffTime) {
|
|
return {
|
|
kind: "legacy",
|
|
reason: `the pull request predates the pilot cutoff (${cutoff})`,
|
|
};
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/** Evaluate a pull request without mutating GitHub state. */
|
|
export async function evaluatePullRequest({
|
|
pullRequest,
|
|
repository,
|
|
cutoff = "",
|
|
readyLabel = "state:ready",
|
|
getIssue,
|
|
}) {
|
|
const exemption = exemptionReason(pullRequest, cutoff);
|
|
if (exemption) {
|
|
return {
|
|
ok: true,
|
|
exempt: true,
|
|
exemption: exemption.kind,
|
|
reason: exemption.reason,
|
|
};
|
|
}
|
|
|
|
const references = parseIssueReferences(pullRequest.body, repository);
|
|
if (references.length === 0) {
|
|
return {
|
|
ok: false,
|
|
exempt: false,
|
|
reason: "the description does not contain `Implements #<issue-number>`",
|
|
};
|
|
}
|
|
|
|
if (references.length > 1) {
|
|
return {
|
|
ok: false,
|
|
exempt: false,
|
|
reason: "the description references more than one implementation issue",
|
|
};
|
|
}
|
|
|
|
const reference = references[0];
|
|
if (reference.repository.toLowerCase() !== repository.toLowerCase()) {
|
|
return {
|
|
ok: false,
|
|
exempt: false,
|
|
reason: `the implementation issue must belong to \`${repository}\``,
|
|
};
|
|
}
|
|
|
|
const issue = await getIssue(reference.number);
|
|
if (!issue || issue.pull_request) {
|
|
return {
|
|
ok: false,
|
|
exempt: false,
|
|
reason: `#${reference.number} is not an issue in \`${repository}\``,
|
|
};
|
|
}
|
|
|
|
if (issue.state !== "open") {
|
|
return {
|
|
ok: false,
|
|
exempt: false,
|
|
issueNumber: reference.number,
|
|
reason: `issue #${reference.number} is not open`,
|
|
};
|
|
}
|
|
|
|
if (!labelNames(issue).has(readyLabel.toLowerCase())) {
|
|
return {
|
|
ok: false,
|
|
exempt: false,
|
|
issueNumber: reference.number,
|
|
reason: `issue #${reference.number} does not have the \`${readyLabel}\` label`,
|
|
};
|
|
}
|
|
|
|
return {
|
|
ok: true,
|
|
exempt: false,
|
|
issueNumber: reference.number,
|
|
reason: `issue #${reference.number} is open and ready for implementation`,
|
|
};
|
|
}
|
|
|
|
async function githubApi(token, method, endpoint, body) {
|
|
const response = await fetch(`https://api.github.com${endpoint}`, {
|
|
method,
|
|
headers: {
|
|
Accept: "application/vnd.github+json",
|
|
Authorization: `Bearer ${token}`,
|
|
"User-Agent": "crewai-issue-gate",
|
|
"X-GitHub-Api-Version": "2022-11-28",
|
|
},
|
|
body: body === undefined ? undefined : JSON.stringify(body),
|
|
});
|
|
|
|
const responseText = await response.text();
|
|
const responseBody = responseText ? JSON.parse(responseText) : null;
|
|
if (!response.ok) {
|
|
throw new ApiError(
|
|
response.status,
|
|
`${method} ${endpoint} failed (${response.status}): ${responseBody?.message ?? responseText}`,
|
|
);
|
|
}
|
|
|
|
return responseBody;
|
|
}
|
|
|
|
function renderComment(result, mode) {
|
|
const modeExplanation =
|
|
mode === "observe"
|
|
? "The gate is in **observe mode**, so this result does not block or close the pull request."
|
|
: `The gate is in **${mode} mode**.`;
|
|
|
|
let outcome;
|
|
if (result.exempt) {
|
|
outcome = `This pull request is exempt because ${result.reason}.`;
|
|
} else if (result.ok) {
|
|
outcome = `This pull request passes: ${result.reason}.`;
|
|
} else {
|
|
outcome = `This pull request would be rejected because ${result.reason}.`;
|
|
}
|
|
|
|
return `${COMMENT_MARKER}\n### Issue gate\n\n${outcome}\n\n${modeExplanation}\n\nTo satisfy the pilot policy, describe exactly one ready issue using \`Implements #123\`. After the issue becomes ready, edit the pull request description or ask a maintainer to re-run the Issue Gate workflow.`;
|
|
}
|
|
|
|
async function updateGateComment({ token, repository, pullNumber, result, mode }) {
|
|
const [owner, repo] = repository.split("/");
|
|
const comments = await githubApi(
|
|
token,
|
|
"GET",
|
|
`/repos/${owner}/${repo}/issues/${pullNumber}/comments?per_page=100`,
|
|
);
|
|
const existing = comments.find(
|
|
(comment) => comment.user?.type === "Bot" && comment.body?.includes(COMMENT_MARKER),
|
|
);
|
|
if (!existing && result.ok) {
|
|
return;
|
|
}
|
|
|
|
const body = renderComment(result, mode);
|
|
|
|
if (existing) {
|
|
await githubApi(
|
|
token,
|
|
"PATCH",
|
|
`/repos/${owner}/${repo}/issues/comments/${existing.id}`,
|
|
{ body },
|
|
);
|
|
} else {
|
|
await githubApi(
|
|
token,
|
|
"POST",
|
|
`/repos/${owner}/${repo}/issues/${pullNumber}/comments`,
|
|
{ body },
|
|
);
|
|
}
|
|
}
|
|
|
|
async function updateGateLabel({ token, repository, pullRequest, result }) {
|
|
const [owner, repo] = repository.split("/");
|
|
const desiredLabel = result.exempt
|
|
? "issue-gate:exempt"
|
|
: result.ok
|
|
? "issue-gate:passed"
|
|
: "needs-ready-issue";
|
|
const currentLabels = labelNames(pullRequest);
|
|
|
|
for (const label of GATE_LABELS) {
|
|
if (label !== desiredLabel && currentLabels.has(label)) {
|
|
try {
|
|
await githubApi(
|
|
token,
|
|
"DELETE",
|
|
`/repos/${owner}/${repo}/issues/${pullRequest.number}/labels/${encodeURIComponent(label)}`,
|
|
);
|
|
} catch (error) {
|
|
if (!(error instanceof ApiError) || error.status !== 404) {
|
|
throw error;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!currentLabels.has(desiredLabel)) {
|
|
try {
|
|
await githubApi(
|
|
token,
|
|
"POST",
|
|
`/repos/${owner}/${repo}/issues/${pullRequest.number}/labels`,
|
|
{ labels: [desiredLabel] },
|
|
);
|
|
} catch (error) {
|
|
if (error instanceof ApiError && [404, 422].includes(error.status)) {
|
|
console.warn(`Could not apply missing label \`${desiredLabel}\`; create the pilot labels first.`);
|
|
return;
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
}
|
|
|
|
async function publishStatus({ token, repository, pullRequest, result, mode }) {
|
|
const [owner, repo] = repository.split("/");
|
|
const shouldPass = mode === "observe" || result.ok;
|
|
const prefix = mode === "observe" && !result.ok ? "Observe: would fail" : result.ok ? "Pass" : "Fail";
|
|
const description = `${prefix} — ${result.reason}`.slice(0, 140);
|
|
|
|
await githubApi(
|
|
token,
|
|
"POST",
|
|
`/repos/${owner}/${repo}/statuses/${pullRequest.head.sha}`,
|
|
{
|
|
state: shouldPass ? "success" : "failure",
|
|
context: "Issue gate",
|
|
description,
|
|
target_url: `https://github.com/${repository}/pull/${pullRequest.number}`,
|
|
},
|
|
);
|
|
}
|
|
|
|
async function run() {
|
|
const token = process.env.GITHUB_TOKEN;
|
|
const repository = process.env.GITHUB_REPOSITORY;
|
|
const eventPath = process.env.GITHUB_EVENT_PATH;
|
|
const pullNumber = Number.parseInt(process.env.PR_NUMBER, 10);
|
|
const mode = (process.env.ISSUE_GATE_MODE || "observe").toLowerCase();
|
|
const cutoff = process.env.ISSUE_GATE_CUTOFF || "";
|
|
const readyLabel = process.env.ISSUE_GATE_READY_LABEL || "state:ready";
|
|
|
|
if (!token || !repository || !eventPath || !Number.isInteger(pullNumber)) {
|
|
throw new Error("GITHUB_TOKEN, GITHUB_REPOSITORY, GITHUB_EVENT_PATH, and PR_NUMBER are required");
|
|
}
|
|
if (!VALID_MODES.has(mode)) {
|
|
throw new Error(`ISSUE_GATE_MODE must be one of: ${[...VALID_MODES].join(", ")}`);
|
|
}
|
|
if (mode !== "observe" && !cutoff) {
|
|
throw new Error("ISSUE_GATE_CUTOFF is required before enabling block or close mode");
|
|
}
|
|
|
|
const [owner, repo] = repository.split("/");
|
|
const event = JSON.parse(await readFile(eventPath, "utf8"));
|
|
const pullRequest =
|
|
event.pull_request ??
|
|
(await githubApi(token, "GET", `/repos/${owner}/${repo}/pulls/${pullNumber}`));
|
|
|
|
const result = await evaluatePullRequest({
|
|
pullRequest,
|
|
repository,
|
|
cutoff,
|
|
readyLabel,
|
|
getIssue: async (issueNumber) => {
|
|
try {
|
|
return await githubApi(
|
|
token,
|
|
"GET",
|
|
`/repos/${owner}/${repo}/issues/${issueNumber}`,
|
|
);
|
|
} catch (error) {
|
|
if (error instanceof ApiError && error.status === 404) {
|
|
return null;
|
|
}
|
|
throw error;
|
|
}
|
|
},
|
|
});
|
|
|
|
await publishStatus({ token, repository, pullRequest, result, mode });
|
|
if (result.exemption !== "legacy") {
|
|
await updateGateLabel({ token, repository, pullRequest, result });
|
|
await updateGateComment({
|
|
token,
|
|
repository,
|
|
pullNumber: pullRequest.number,
|
|
result,
|
|
mode,
|
|
});
|
|
}
|
|
|
|
if (mode === "close" && !result.ok) {
|
|
await githubApi(
|
|
token,
|
|
"PATCH",
|
|
`/repos/${owner}/${repo}/pulls/${pullRequest.number}`,
|
|
{ state: "closed" },
|
|
);
|
|
}
|
|
|
|
console.log(JSON.stringify({ mode, pullNumber: pullRequest.number, ...result }));
|
|
if (mode !== "observe" && !result.ok) {
|
|
process.exitCode = 1;
|
|
}
|
|
}
|
|
|
|
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
run().catch((error) => {
|
|
console.error(error);
|
|
process.exitCode = 1;
|
|
});
|
|
}
|