03e1e3abd2
Reverts paperclipai/paperclip#7423 Decided to keep this in place so we can automate issue reproduction in the future. We all make mistakes. Even me, if you can believe it.
146 lines
5.0 KiB
JavaScript
146 lines
5.0 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* run-quality-gates.mjs
|
|
* Orchestrates all quality gates. Fetches PR data once, runs all gates,
|
|
* posts or updates a single consolidated comment via commitperclip.
|
|
*
|
|
* Env: GH_TOKEN, GH_REPO, PR_NUMBER, PR_AUTHOR, PR_BRANCH
|
|
* Exit: 0 if all quality gates pass, 1 if any fail.
|
|
*/
|
|
import { fileURLToPath } from 'node:url';
|
|
import { ghFetch } from './get-bot-token.mjs';
|
|
import { fetchAllPullRequestFiles } from './fetch-pr-files.mjs';
|
|
import { checkTemplate } from './check-pr-template.mjs';
|
|
import { checkLinkedIssue } from './check-pr-linked-issue.mjs';
|
|
import { checkTestCoverage } from './check-pr-test-coverage.mjs';
|
|
import { checkLockfile } from './check-pr-lockfile.mjs';
|
|
import { checkDependencies } from './check-pr-dependencies.mjs';
|
|
|
|
const COMMENT_SIGNATURE = '— commitperclip';
|
|
|
|
function buildComment(author, failures, informational) {
|
|
if (failures.length === 0 && informational.length === 0) {
|
|
return `✅ All checks passing — ready for Greptile review and maintainer approval.\n\n${COMMENT_SIGNATURE}`;
|
|
}
|
|
|
|
const lines = [
|
|
`Hey @${author}! Before this PR can be reviewed, a few things need attention:\n`,
|
|
];
|
|
|
|
if (failures.length > 0) {
|
|
lines.push('**Missing or incomplete:**');
|
|
for (const f of failures) lines.push(`- [ ] ${f}`);
|
|
}
|
|
|
|
if (informational.length > 0) {
|
|
if (failures.length > 0) lines.push('');
|
|
lines.push('**Informational:**');
|
|
for (const i of informational) lines.push(`- ${i}`);
|
|
}
|
|
|
|
lines.push(
|
|
'\nOnce updated, push a new commit and these checks will re-run automatically.\n',
|
|
COMMENT_SIGNATURE
|
|
);
|
|
|
|
return lines.join('\n');
|
|
}
|
|
|
|
export async function findExistingComment(fetchFromGitHub, token, repo, prNumber) {
|
|
for (let page = 1; ; page += 1) {
|
|
const comments = await fetchFromGitHub(
|
|
`/repos/${repo}/issues/${prNumber}/comments?per_page=100&page=${page}`,
|
|
token
|
|
);
|
|
|
|
const existing = comments.find(
|
|
c => (c.user.login === 'commitperclip[bot]' || c.user.login === 'commitperclip') &&
|
|
c.body.includes(COMMENT_SIGNATURE)
|
|
);
|
|
if (existing) return existing;
|
|
|
|
if (comments.length < 100) return null;
|
|
}
|
|
}
|
|
|
|
async function upsertComment(token, repo, prNumber, body, existing) {
|
|
if (existing) {
|
|
await ghFetch(`/repos/${repo}/issues/comments/${existing.id}`, token, {
|
|
method: 'PATCH',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ body }),
|
|
});
|
|
} else {
|
|
await ghFetch(`/repos/${repo}/issues/${prNumber}/comments`, token, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ body }),
|
|
});
|
|
}
|
|
}
|
|
|
|
async function main() {
|
|
const { GH_TOKEN, GH_REPO, PR_NUMBER, PR_AUTHOR, PR_BRANCH } = process.env;
|
|
|
|
if (!GH_TOKEN || !GH_REPO || !PR_NUMBER) {
|
|
console.error('ERROR: GH_TOKEN, GH_REPO, PR_NUMBER env vars required');
|
|
process.exit(1);
|
|
}
|
|
|
|
// Sanitize inputs before use in URL construction (prevents SSRF)
|
|
const prNumber = parseInt(PR_NUMBER, 10);
|
|
if (!Number.isInteger(prNumber) || prNumber <= 0) {
|
|
console.error('ERROR: PR_NUMBER must be a positive integer');
|
|
process.exit(1);
|
|
}
|
|
if (!/^[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+$/.test(GH_REPO)) {
|
|
console.error('ERROR: GH_REPO must be in owner/repo format');
|
|
process.exit(1);
|
|
}
|
|
|
|
// Fetch PR data once — gates use this, no redundant API calls
|
|
const [pr, files] = await Promise.all([
|
|
ghFetch(`/repos/${GH_REPO}/pulls/${prNumber}`, GH_TOKEN),
|
|
fetchAllPullRequestFiles(ghFetch, GH_REPO, prNumber, GH_TOKEN),
|
|
]);
|
|
|
|
const prBody = pr.body ?? '';
|
|
const author = PR_AUTHOR ?? pr.user.login;
|
|
const branch = PR_BRANCH ?? pr.head.ref;
|
|
|
|
// Run all quality gates (pure functions run sync, deps check is async)
|
|
const prTitle = pr.title ?? '';
|
|
const [templateResult, issueResult, testResult, lockfileResult, depsResult] =
|
|
await Promise.all([
|
|
Promise.resolve(checkTemplate(prBody)),
|
|
Promise.resolve(checkLinkedIssue(prBody, prTitle)),
|
|
Promise.resolve(checkTestCoverage(files, prTitle)),
|
|
Promise.resolve(checkLockfile(files, author, branch)),
|
|
checkDependencies(files, GH_TOKEN, GH_REPO, prNumber, pr.base?.ref),
|
|
]);
|
|
|
|
const allFailures = [
|
|
...templateResult.failures,
|
|
...issueResult.failures,
|
|
...testResult.failures,
|
|
...lockfileResult.failures,
|
|
];
|
|
const informational = depsResult.informational ?? [];
|
|
const allPassed = allFailures.length === 0;
|
|
|
|
const commentBody = buildComment(author, allFailures, informational);
|
|
|
|
// Post comment if there are failures/informational, or update existing comment
|
|
const existing = await findExistingComment(ghFetch, GH_TOKEN, GH_REPO, prNumber);
|
|
if (allFailures.length > 0 || informational.length > 0 || existing) {
|
|
await upsertComment(GH_TOKEN, GH_REPO, prNumber, commentBody, existing);
|
|
}
|
|
|
|
console.log(JSON.stringify({ passed: allPassed, failures: allFailures, informational }));
|
|
process.exit(allPassed ? 0 : 1);
|
|
}
|
|
|
|
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
|
main().catch(e => { console.error(e.message); process.exit(1); });
|
|
}
|