From 0c05d9185b0de3fd9b7a8572405c2acf6e3a6d4a Mon Sep 17 00:00:00 2001 From: Zeegaths Date: Wed, 17 Jun 2026 20:16:11 +0300 Subject: [PATCH 1/4] ci: add PR quality gate workflow and scoring engine --- .gitignore | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4413927 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +.env +.env.local +*.log +.DS_Store From df96cd07b18764bbeaccd24309dc66e85ad3715b Mon Sep 17 00:00:00 2001 From: Zeegaths Date: Thu, 18 Jun 2026 00:43:37 +0300 Subject: [PATCH 2/4] feat: enhanced scorer with risk profiling, buzzword detection, three-lane routing and live Bitcoin Core labels --- .github/workflows/pr-gate.yml | 10 +- package.json | 8 +- scripts/feedback.js | 207 ++++---- scripts/scorer.js | 870 +++++++++++++++++++--------------- 4 files changed, 595 insertions(+), 500 deletions(-) diff --git a/.github/workflows/pr-gate.yml b/.github/workflows/pr-gate.yml index 2a3e425..0fa15ca 100644 --- a/.github/workflows/pr-gate.yml +++ b/.github/workflows/pr-gate.yml @@ -2,8 +2,7 @@ name: PR Quality Gate on: pull_request: - types: [opened, synchronize, reopened, edited] - workflow_dispatch: + types: [opened, synchronize, reopened] permissions: pull-requests: write @@ -22,15 +21,14 @@ jobs: uses: actions/setup-node@v4 with: node-version: '20' - cache: npm - name: Install dependencies run: npm ci - - name: Fetch PR data and score + - name: Score PR env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + KIMI_API_KEY: ${{ secrets.KIMI_API_KEY }} PR_NUMBER: ${{ github.event.pull_request.number }} PR_TITLE: ${{ github.event.pull_request.title }} PR_BODY: ${{ github.event.pull_request.body }} @@ -38,4 +36,4 @@ jobs: REPO: ${{ github.repository }} BASE_SHA: ${{ github.event.pull_request.base.sha }} HEAD_SHA: ${{ github.event.pull_request.head.sha }} - run: npm run gate + run: node scripts/scorer.js \ No newline at end of file diff --git a/package.json b/package.json index 0255a6c..e76dfca 100644 --- a/package.json +++ b/package.json @@ -5,12 +5,12 @@ "type": "module", "main": "scripts/scorer.js", "scripts": { - "gate": "node scripts/scorer.js", - "mock:bad": "CORE_GATE_MOCK=bad node scripts/scorer.js", - "mock:good": "CORE_GATE_MOCK=good node scripts/scorer.js" + "start": "node scripts/scorer.js", + "mock:good": "CORE_GATE_MOCK=good node scripts/scorer.js", + "mock:bad": "CORE_GATE_MOCK=bad node scripts/scorer.js" }, "dependencies": { "@octokit/rest": "^20.1.1" }, "license": "MIT" -} +} \ No newline at end of file diff --git a/scripts/feedback.js b/scripts/feedback.js index 7a306ef..96f6209 100644 --- a/scripts/feedback.js +++ b/scripts/feedback.js @@ -1,122 +1,121 @@ -const DEFAULT_ANTHROPIC_MODEL = "claude-3-5-sonnet-latest"; +export async function generateFeedback({ result, risk, pr, files, commits, authorData, apiKey }) { + if (!apiKey) return "_AI feedback unavailable — API key not set._"; -export async function generateFeedback({ result, pr, files, anthropicApiKey }) { - const failedChecks = result.checks.filter((check) => !check.passed); + const isKimi = apiKey.startsWith("sk-") && !!process.env.KIMI_API_KEY; - if (failedChecks.length === 0) { - return "### Feedback\n\nNo blocking quality-gate issues found. Maintainers can continue with normal code review."; - } + const endpoint = isKimi + ? "https://api.moonshot.cn/v1/chat/completions" + : "https://api.anthropic.com/v1/messages"; - if (anthropicApiKey) { - try { - return await generateAiFeedback({ failedChecks, pr, files, anthropicApiKey }); - } catch (error) { - console.log(`AI feedback failed, using fallback feedback: ${error.message}`); - } - } + const failedChecks = result.checks + .filter(c => !c.passed) + .map(c => `- ${c.name} (${c.score}/${c.max}): ${c.detail}`) + .join("\n"); - return generateFallbackFeedback(failedChecks, files); -} - -async function generateAiFeedback({ failedChecks, pr, files, anthropicApiKey }) { - const response = await fetch("https://api.anthropic.com/v1/messages", { - method: "POST", - headers: { - "content-type": "application/json", - "x-api-key": anthropicApiKey, - "anthropic-version": "2023-06-01", - }, - body: JSON.stringify({ - model: process.env.ANTHROPIC_MODEL || DEFAULT_ANTHROPIC_MODEL, - max_tokens: 700, - messages: [ - { - role: "user", - content: buildPrompt({ failedChecks, pr, files }), - }, - ], - }), - }); + const commitMessages = commits + .slice(0, 10) + .map(c => `- ${c.commit.message.split("\n")[0]}`) + .join("\n"); - if (!response.ok) { - throw new Error(`Anthropic API returned ${response.status}`); - } + const changedFiles = files + .slice(0, 15) + .map(f => `- ${f.filename} (+${f.additions} -${f.deletions})`) + .join("\n"); - const data = await response.json(); - const text = data.content - ?.filter((part) => part.type === "text") - .map((part) => part.text) - .join("\n\n") - .trim(); + const accountAgeDays = Math.floor( + (Date.now() - new Date(authorData.created_at).getTime()) / (1000 * 60 * 60 * 24) + ); - if (!text) { - throw new Error("Anthropic API returned no text."); - } + const prompt = `You are a Bitcoin Core contributor mentor. A contributor opened a pull request that failed the automated quality gate. - return `### Specific Feedback\n\n${text}`; -} +PR Title: ${pr.title} -function buildPrompt({ failedChecks, pr, files }) { - const changedFiles = files.map((file) => file.filename).slice(0, 30).join("\n"); - const failedDetails = failedChecks - .map((check) => `- ${check.name}: ${check.detail}`) - .join("\n"); +PR Description: +${pr.body || "(no description provided)"} - return `You are Core-Gate, a concise PR quality assistant for Bitcoin Core. +Commit messages: +${commitMessages} -Write direct, actionable markdown feedback for the contributor. -Do not praise, do not be vague, and do not mention that you are an AI. -Focus only on the failed checks. +Changed files: +${changedFiles} -PR title: ${pr.title} -PR author: ${pr.user?.login ?? "unknown"} +Risk level: ${risk.level} — ${risk.message.replace(/[*_`]/g, "")} Failed checks: -${failedDetails} - -Changed files: -${changedFiles}`; -} - -function generateFallbackFeedback(failedChecks, files) { - const sections = failedChecks.map((check) => { - switch (check.id) { - case "title-prefix": - return `**Component prefix:** Rename the PR title so it starts with the area being changed, for example \`wallet:\`, \`rpc:\`, \`test:\`, \`doc:\`, \`build:\`, or \`consensus:\`.`; - case "description": - return "**PR description:** Add a short explanation of the problem, why the change is needed, and how your approach fixes it. Aim for enough detail that a maintainer understands the reason before reading the diff."; - case "commit-messages": - return "**Commit messages:** Rewrite vague commit subjects like `fix`, `update`, or `wip`. Use a specific subject under 50 characters, such as `wallet: fix fee rounding`."; - case "diff-size": - return "**Diff size:** This PR is large enough that it may be hard to review. Split unrelated changes into separate PRs, or reduce the diff to one focused behavior change."; - case "test-coverage": - return buildTestFeedback(files); - default: - return `**${check.name}:** ${check.detail}`; +${failedChecks} + +Contributor: ${authorData.login}, account ${accountAgeDays} days old. + +Write specific, actionable feedback referencing Bitcoin Core's CONTRIBUTING.md. + +Rules: +- Be direct but respectful — assume good intent +- Be specific to Bitcoin Core conventions, not generic advice +- For missing prefix: name the exact prefix that fits based on the files changed +- For missing tests: name the exact test file and directory they should add to +- For bad commit messages: show a concrete rewritten example +- For large diffs: suggest which files to split into a separate PR +- For consensus changes without BIP: explain why a BIP is required +- Reference CONTRIBUTING.md: https://github.com/bitcoin/bitcoin/blob/master/CONTRIBUTING.md +- Under 300 words, markdown bullet points +- End with one encouraging sentence`; + + try { + let responseText; + + if (isKimi) { + const res = await fetch(endpoint, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${apiKey}`, + }, + body: JSON.stringify({ + model: "moonshot-v1-8k", + temperature: 0.3, + messages: [ + { role: "system", content: "You are a Bitcoin Core contributor mentor. Give specific, actionable feedback." }, + { role: "user", content: prompt }, + ], + }), + }); + + if (!res.ok) { + console.error("Kimi API error:", await res.text()); + return "_AI feedback generation failed. Check KIMI_API_KEY secret._"; + } + + const data = await res.json(); + responseText = data.choices?.[0]?.message?.content; + + } else { + const res = await fetch(endpoint, { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-api-key": apiKey, + "anthropic-version": "2023-06-01", + }, + body: JSON.stringify({ + model: "claude-sonnet-4-6", + max_tokens: 1000, + messages: [{ role: "user", content: prompt }], + }), + }); + + if (!res.ok) { + console.error("Anthropic API error:", await res.text()); + return "_AI feedback generation failed. Check ANTHROPIC_API_KEY secret._"; + } + + const data = await res.json(); + responseText = data.content?.[0]?.text; } - }); - return `### Specific Feedback\n\n${sections.join("\n\n")}`; -} + return responseText || "_No feedback generated._"; -function buildTestFeedback(files) { - const sourceFile = files.find((file) => isSourceFile(file.filename)); - - if (!sourceFile) { - return "**Tests:** Add or update a relevant test for the behavior changed by this PR."; + } catch (err) { + console.error("Feedback error:", err); + return "_AI feedback generation failed due to a network error._"; } - - return `**Tests:** This PR changes \`${sourceFile.filename}\` without a matching test update. Add or update coverage in \`src/test/\`, \`test/\`, or \`src/test/fuzz/\` where appropriate.`; -} - -function isSourceFile(filename) { - if ( - filename.includes("/test/") || - filename.includes("/tests/") || - filename.includes("/fuzz/") - ) { - return false; - } - - return filename.startsWith("src/") && /\.(c|cc|cpp|h|hpp)$/i.test(filename); -} +} \ No newline at end of file diff --git a/scripts/scorer.js b/scripts/scorer.js index 9842229..3a97ea1 100644 --- a/scripts/scorer.js +++ b/scripts/scorer.js @@ -1,43 +1,57 @@ import { Octokit } from "@octokit/rest"; import { generateFeedback } from "./feedback.js"; -const THRESHOLD = 65; +// Scoring thresholds +const THRESHOLD_HIGH = 65; // ready-for-review +const THRESHOLD_MED = 40; // needs-attention + +// Comment marker so we can upsert instead of spam const BOT_COMMENT_MARKER = ""; -const READY_LABEL = "ready-for-review"; -const NEEDS_WORK_LABEL = "needs-work"; +// Labels — names match Bitcoin Core's existing labels where possible +const LABELS = { + READY: { name: "ready-for-review", color: "0e8a16", description: "Meets baseline quality gate" }, + ATTENTION: { name: "needs-attention", color: "e4e669", description: "Close but needs minor fixes" }, + WORK: { name: "needs-work", color: "d93f0b", description: "Needs contributor fixes before review" }, + HIGH_RISK: { name: "high-risk", color: "b60205", description: "Touches consensus or crypto code" }, + GUI: { name: "gui-change", color: "c5def5", description: "Touches Qt/GUI code" }, +}; + +// Exact prefixes from Bitcoin Core CONTRIBUTING.md const COMPONENT_PREFIXES = [ - "wallet", - "rpc", - "test", - "doc", - "docs", - "build", - "ci", - "net", - "p2p", - "consensus", - "script", - "crypto", - "qt", - "fuzz", - "bench", - "kernel", - "mempool", - "mining", - "fees", - "refactor", - "validation", - "index", - "gui", - "util", - "depends", - "i18n", + "consensus", "doc", "qt", "gui", "log", "mining", + "net", "p2p", "refactor", "rpc", "rest", "zmq", + "contrib", "cli", "test", "qa", "ci", "util", "lib", + "wallet", "build", "guix", "kernel", "mempool", "fees", + "fuzz", "bench", "depends", "validation", "index", + "addrman", "banman", "init", "node", "script", "crypto", + "interfaces", "txmempool", "flatpak", "snap", "cmake", +]; + +// Risk path mapping +const RISK_PATHS = { + high: ["src/consensus/", "src/crypto/", "src/secp256k1/"], + medium: ["src/wallet/", "src/rpc/", "src/validation/", "src/net/", "src/txmempool/"], + gui: ["src/qt/", "src/gui/"], + low: ["doc/", "contrib/", "test/", "src/test/"], +}; + +// AI slop buzzwords — high density = likely AI-generated +const BUZZWORDS = [ + "improves performance", "enhances security", "fixes various", + "general improvements", "refactors code", "better code", + "cleaner code", "improved readability", "minor fixes", + "small fixes", "various fixes", "made it better", + "optimized code", "code cleanup", "misc fixes", "miscellaneous", + "overall improvements", "several improvements", ]; +// ─── Entry point ──────────────────────────────────────────────────────────── + async function main() { console.log("Core-Gate PR quality gate starting..."); + // Local mock mode for testing without a real PR if (process.env.CORE_GATE_MOCK) { await runMock(process.env.CORE_GATE_MOCK); return; @@ -47,59 +61,113 @@ async function main() { if (!context) return; const octokit = new Octokit({ auth: context.token }); - const prData = await fetchPullRequestData(octokit, context); - const result = scorePullRequest(prData); - const feedback = await generateFeedback({ - result, - pr: prData.pr, - files: prData.files, - anthropicApiKey: process.env.ANTHROPIC_API_KEY, - }); - const body = buildComment(result, feedback); + // Fetch PR data and Bitcoin Core labels in parallel + const [prData, btcLabels] = await Promise.all([ + fetchPullRequestData(octokit, context), + fetchBitcoinCoreLabels(), + ]); + + const risk = getRiskProfile(prData.files); + const result = scorePullRequest(prData, risk); + const feedback = result.passed + ? null + : await generateFeedback({ + result, + risk, + pr: prData.pr, + files: prData.files, + commits: prData.commits, + authorData: prData.authorData, + apiKey: process.env.KIMI_API_KEY || process.env.ANTHROPIC_API_KEY, + }); + + const body = buildComment(result, risk, feedback); console.log(`PR #${context.prNumber}: ${prData.pr.title}`); - console.log(`Author: ${prData.pr.user?.login ?? "unknown"}`); - console.log(`Commits: ${prData.commits.length}`); - console.log(`Files changed: ${prData.files.length}`); - console.log(`Additions: ${prData.totals.additions}`); - console.log(`Deletions: ${prData.totals.deletions}`); - console.log(`Score: ${result.score}/100`); - - await ensureLabels(octokit, context); - await applyRoutingLabel(octokit, context, result.passed); + console.log(`Author: ${prData.pr.user?.login ?? "unknown"} | Prior merges: ${prData.authorPriorMerges}`); + console.log(`Commits: ${prData.commits.length} | Files: ${prData.files.length}`); + console.log(`Risk: ${risk.level} | Score: ${result.score}/100`); + + await ensureAllLabels(octokit, context, btcLabels); + await applyLabels(octokit, context, result, risk); await upsertComment(octokit, context, body); - console.log("Core-Gate PR quality gate complete."); + console.log("Core-Gate complete."); } +// ─── Mock mode ────────────────────────────────────────────────────────────── + async function runMock(kind) { - const prData = buildMockPullRequestData(kind); - const result = scorePullRequest(prData); - const feedback = await generateFeedback({ - result, - pr: prData.pr, - files: prData.files, - anthropicApiKey: "", - }); + const prData = buildMockData(kind); + const risk = getRiskProfile(prData.files); + const result = scorePullRequest(prData, risk); + const feedback = result.passed + ? null + : await generateFeedback({ + result, risk, + pr: prData.pr, + files: prData.files, + commits: prData.commits, + authorData: prData.authorData, + apiKey: "", + }); + + console.log(`Mock: ${kind} | Score: ${result.score}/100 | Risk: ${risk.level}`); + console.log(buildComment(result, risk, feedback)); +} - console.log(`Mock PR: ${kind}`); - console.log(`Score: ${result.score}/100`); - console.log(buildComment(result, feedback)); +function buildMockData(kind) { + if (kind === "good") { + const files = [ + mockFile("src/wallet/wallet.cpp", 28, 7), + mockFile("src/test/wallet_tests.cpp", 38, 2), + ]; + return { + pr: { + title: "wallet: fix fee rounding edge case", + body: "Fixes a fee rounding edge case in wallet transaction creation.\n\nThe previous behavior could round a small value inconsistently. This patch keeps the calculation explicit and adds a regression test.\n\nFixes #12345", + user: { login: "demo-contributor" }, + }, + commits: [mockCommit("wallet: fix fee rounding")], + files, + totals: calcTotals(files), + authorData: { login: "demo-contributor", created_at: "2020-01-01T00:00:00Z" }, + authorPriorMerges: 3, + }; + } + + const files = [mockFile("src/bitcoin-cli.cpp", 2, 0)]; + return { + pr: { title: "fixed stuff", body: "", user: { login: "newuser" } }, + commits: [mockCommit("fixed stuff")], + files, + totals: calcTotals(files), + authorData: { login: "newuser", created_at: new Date().toISOString() }, + authorPriorMerges: 0, + }; } +function mockCommit(subject) { return { commit: { message: subject } }; } +function mockFile(filename, additions, deletions) { + return { filename, additions, deletions, changes: additions + deletions }; +} +function calcTotals(files) { + return files.reduce( + (s, f) => ({ additions: s.additions + f.additions, deletions: s.deletions + f.deletions, changes: s.changes + f.changes }), + { additions: 0, deletions: 0, changes: 0 } + ); +} + +// ─── Context ───────────────────────────────────────────────────────────────── + function readContext() { const required = ["GITHUB_TOKEN", "REPO", "PR_NUMBER"]; - const missing = required.filter((name) => !process.env[name]); + const missing = required.filter(n => !process.env[n]); if (missing.length > 0) { - console.log(`Missing required env vars: ${missing.join(", ")}`); - console.log("Run this through GitHub Actions on a pull request to score real PR data."); - - if (process.env.GITHUB_ACTIONS === "true") { - process.exitCode = 1; - } - + console.log(`Missing env vars: ${missing.join(", ")}`); + if (process.env.GITHUB_ACTIONS === "true") process.exitCode = 1; return null; } @@ -107,283 +175,327 @@ function readContext() { const prNumber = Number(process.env.PR_NUMBER); if (!owner || !repo || Number.isNaN(prNumber)) { - throw new Error("Invalid REPO or PR_NUMBER environment variable."); + throw new Error("Invalid REPO or PR_NUMBER."); } - return { - token: process.env.GITHUB_TOKEN, - owner, - repo, - prNumber, - }; + return { token: process.env.GITHUB_TOKEN, owner, repo, prNumber }; } +// ─── Data fetching ─────────────────────────────────────────────────────────── + async function fetchPullRequestData(octokit, context) { + const { owner, repo, prNumber } = context; + const [prResponse, commits, files] = await Promise.all([ - octokit.pulls.get({ - owner: context.owner, - repo: context.repo, - pull_number: context.prNumber, - }), - octokit.paginate(octokit.pulls.listCommits, { - owner: context.owner, - repo: context.repo, - pull_number: context.prNumber, - per_page: 100, - }), - octokit.paginate(octokit.pulls.listFiles, { - owner: context.owner, - repo: context.repo, - pull_number: context.prNumber, - per_page: 100, - }), + octokit.pulls.get({ owner, repo, pull_number: prNumber }), + octokit.paginate(octokit.pulls.listCommits, { owner, repo, pull_number: prNumber, per_page: 100 }), + octokit.paginate(octokit.pulls.listFiles, { owner, repo, pull_number: prNumber, per_page: 100 }), ]); - const totals = files.reduce( - (sum, file) => ({ - additions: sum.additions + file.additions, - deletions: sum.deletions + file.deletions, - changes: sum.changes + file.changes, - }), - { additions: 0, deletions: 0, changes: 0 }, - ); + const totals = calcTotals(files); + const pr = prResponse.data; + const author = pr.user?.login ?? ""; - return { - pr: prResponse.data, - commits, - files, - totals, - }; + // Fetch author data and prior merged PRs + let authorData = { login: author, created_at: new Date().toISOString() }; + let authorPriorMerges = 0; + + try { + const [userData, searchData] = await Promise.all([ + octokit.users.getByUsername({ username: author }), + octokit.search.issuesAndPullRequests({ + q: `repo:${owner}/${repo} is:pr is:merged author:${author}` + }), + ]); + authorData = userData.data; + authorPriorMerges = searchData.data.total_count; + } catch (_) {} + + return { pr, commits, files, totals, authorData, authorPriorMerges }; } -function scorePullRequest({ pr, commits, files, totals }) { - const checks = [ - checkTitlePrefix(pr.title), - checkDescription(pr.body ?? ""), - checkCommitMessages(commits), - checkDiffSize(files, totals), - checkTestCoverage(files), - ]; +// Fetch Bitcoin Core's actual labels to reuse their names and colors +async function fetchBitcoinCoreLabels() { + try { + const octokit = new Octokit(); // no auth needed for public repo + const labels = await octokit.paginate(octokit.issues.listLabelsForRepo, { + owner: "bitcoin", + repo: "bitcoin", + per_page: 100, + }); + return labels; // [{ name, color, description }] + } catch (_) { + console.log("Could not fetch Bitcoin Core labels — using defaults."); + return []; + } +} - const score = checks.reduce((sum, check) => sum + check.score, 0); +// ─── Risk profiling ────────────────────────────────────────────────────────── - return { - score, - threshold: THRESHOLD, - passed: score >= THRESHOLD, - checks, - }; -} +function getRiskProfile(files) { + const names = files.map(f => f.filename); -function buildMockPullRequestData(kind) { - if (kind === "good") { - const files = [ - mockFile("src/wallet/wallet.cpp", 28, 7), - mockFile("src/wallet/test/wallet_tests.cpp", 38, 2), - ]; + if (RISK_PATHS.high.some(p => names.some(f => f.startsWith(p)))) { + return { + level: "HIGH", + label: LABELS.HIGH_RISK.name, + btcLabel: "Consensus", + message: "⚠️ **High Risk** — touches consensus-critical or cryptography code. Requires senior maintainer review.", + requiresBIP: true, + }; + } + if (RISK_PATHS.gui.some(p => names.some(f => f.startsWith(p)))) { + // Check if ONLY gui files — if so, should target bitcoin-core/gui repo + const nonGuiFiles = names.filter(f => + !RISK_PATHS.gui.some(p => f.startsWith(p)) + ); return { - pr: { - title: "wallet: fix fee rounding edge case", - body: - "This change fixes a fee rounding edge case in wallet transaction creation. The previous behavior could round a small value inconsistently, so this patch keeps the calculation explicit and adds a regression test for the affected path.", - user: { login: "demo-contributor" }, - }, - commits: [mockCommit("wallet: fix fee rounding")], - files, - totals: totalFiles(files), + level: "GUI", + label: LABELS.GUI.name, + btcLabel: "GUI", + message: nonGuiFiles.length === 0 + ? "🖥️ **GUI Only** — this PR only touches `src/qt/`. Consider targeting the [bitcoin-core/gui](https://github.com/bitcoin-core/gui) repository instead." + : "🖥️ **GUI Change** — touches Qt/GUI code. GUI PRs attract high comment volume; ensure you have concept ACKs before requesting review.", + requiresBIP: false, }; } - const files = [mockFile("src/bitcoin-cli.cpp", 2, 0)]; + if (RISK_PATHS.medium.some(p => names.some(f => f.startsWith(p)))) { + return { + level: "MEDIUM", + label: null, + btcLabel: names.some(f => f.startsWith("src/wallet/")) ? "Wallet" : null, + message: "🔶 **Medium Risk** — touches wallet, RPC, or validation code. Functional tests required.", + requiresBIP: false, + }; + } return { - pr: { - title: "fixed stuff", - body: "", - user: { login: "demo-contributor" }, - }, - commits: [mockCommit("fixed stuff")], - files, - totals: totalFiles(files), + level: "LOW", + label: null, + btcLabel: names.some(f => f.startsWith("src/test/") || f.includes("/test/")) ? "Tests" : null, + message: "🟢 **Low Risk** — touches documentation, tests, or non-consensus code.", + requiresBIP: false, }; } -function mockCommit(subject) { - return { - commit: { - message: subject, - }, - }; -} +// ─── Scoring ───────────────────────────────────────────────────────────────── + +function scorePullRequest({ pr, commits, files, totals, authorData, authorPriorMerges }, risk) { + const checks = [ + checkTitlePrefix(pr.title), + checkDescription(pr.body ?? "", pr.title, risk, authorPriorMerges), + checkCommitMessages(commits), + checkDiffSize(files, totals), + checkTestCoverage(files, risk), + ]; + + // Informational checks — no score impact + const info = [ + checkContributorHistory(authorData, authorPriorMerges), + ]; + + const score = checks.reduce((sum, c) => sum + c.score, 0); -function mockFile(filename, additions, deletions) { return { - filename, - additions, - deletions, - changes: additions + deletions, + score, + thresholdHigh: THRESHOLD_HIGH, + thresholdMed: THRESHOLD_MED, + passed: score >= THRESHOLD_HIGH, + lane: score >= THRESHOLD_HIGH ? "ready" : score >= THRESHOLD_MED ? "attention" : "work", + checks, + info, }; } -function totalFiles(files) { - return files.reduce( - (sum, file) => ({ - additions: sum.additions + file.additions, - deletions: sum.deletions + file.deletions, - changes: sum.changes + file.changes, - }), - { additions: 0, deletions: 0, changes: 0 }, - ); -} - +// Check 1 — Component prefix (20pts) function checkTitlePrefix(title) { - const lowerTitle = title.toLowerCase(); - const prefix = COMPONENT_PREFIXES.find((item) => lowerTitle.startsWith(`${item}:`)); + const lower = title.toLowerCase(); + const prefix = COMPONENT_PREFIXES.find(p => lower.startsWith(`${p}:`)); if (prefix) { - return pass("title-prefix", "Component prefix", `Found valid prefix: ${prefix}:`); + return pass("title-prefix", "Component prefix", `Found valid prefix: \`${prefix}:\``); } - return fail( - "title-prefix", - "Component prefix", - `Title is missing a component prefix like ${COMPONENT_PREFIXES.slice(0, 6) - .map((item) => `${item}:`) - .join(", ")}.`, + const examples = ["wallet", "rpc", "test", "doc", "consensus", "net"] + .map(p => `\`${p}:\``).join(", "); + + return fail("title-prefix", "Component prefix", + `Title \`"${title}"\` is missing a component prefix. Valid prefixes from CONTRIBUTING.md: ${examples} and more.` ); } -function checkDescription(body) { - const normalized = body.replace(//g, "").trim(); - const hasMention = /(^|\s)@\w+/.test(normalized); - const isWip = /\b(wip|work in progress)\b/i.test(normalized); - - if (normalized.length >= 100 && !hasMention && !isWip) { - return pass("description", "PR description", "Description explains the change with enough detail."); +// Check 2 — Description quality (20pts) +function checkDescription(body, title, risk, authorPriorMerges) { + const cleaned = body.replace(//g, "").trim(); + const hasMention = /(^|\s)@\w+/.test(cleaned); + const isWIP = /\[wip\]/i.test(title) || /\b(wip|work in progress)\b/i.test(cleaned); + const hasIssueRef = /(fixes|closes|resolves|refs?)\s+#\d+/i.test(cleaned) || /#\d+/.test(cleaned); + const buzzwordsFound = BUZZWORDS.filter(w => cleaned.toLowerCase().includes(w)); + const hasBuzzwords = buzzwordsFound.length >= 2; + + // Consensus changes need a BIP reference + const needsBIP = risk.requiresBIP; + const hasBIP = /BIP[-\s]?\d+/i.test(cleaned); + + let score = 0; + const notes = []; + + if (isWIP) { + score = 10; + notes.push("Marked [WIP] — partial credit"); + } else if (cleaned.length >= 100) { + score = 20; + notes.push("Description has sufficient detail"); + } else if (cleaned.length >= 50) { + score = 10; + notes.push("Description present but brief — explain *why*, not just *what*"); + } else { + score = 0; + notes.push("Description too short — explain the problem, approach, and impact"); } - if (normalized.length >= 50 && !isWip) { - return partial( - "description", - "PR description", - 10, - "Description has some detail, but should better explain why the change exists.", - ); - } + if (hasIssueRef) notes.push("✓ Issue reference found"); + else if (score > 0) { score -= 5; notes.push("No issue reference — add `Fixes #XXXX` or `Closes #XXXX`"); } - return fail( - "description", - "PR description", - "Description is too short or marked WIP. Explain the problem, approach, and impact.", - ); + if (needsBIP && !hasBIP) { score -= 5; notes.push("Consensus change requires a BIP reference in the description"); } + if (hasMention) { score -= 5; notes.push("Remove @mentions — they spam every fork's notification feed"); } + if (hasBuzzwords) { score -= 5; notes.push(`Buzzword language detected ("${buzzwordsFound.slice(0, 2).join('", "')}") — be specific`); } + + score = Math.max(0, Math.min(20, score)); + + return { + id: "description", name: "PR description", + score, max: 20, + passed: score >= 15, + detail: notes.join(". "), + meta: { hasIssueRef, hasBuzzwords, buzzwordsFound, hasBIP, needsBIP }, + }; } +// Check 3 — Commit messages (20pts) function checkCommitMessages(commits) { if (commits.length === 0) { return fail("commit-messages", "Commit message format", "No commits found on this PR."); } - const badSubjects = commits - .map((commit) => commit.commit.message.split("\n")[0].trim()) - .filter((subject) => !isGoodCommitSubject(subject)); + const subjects = commits.map(c => c.commit.message.split("\n")[0].trim()); - if (badSubjects.length === 0) { - return pass("commit-messages", "Commit message format", "Commit subjects are specific and concise."); + const badSubjects = subjects.filter(s => !isGoodSubject(s)); + const hasFixups = subjects.some(s => /^(fixup|squash)!/i.test(s)); + const hasBuzzwords = subjects.some(s => + BUZZWORDS.some(w => s.toLowerCase().includes(w)) + ); + + const notes = []; + let score = 20; + + if (badSubjects.length > 0) { + score -= badSubjects.length >= commits.length ? 20 : 10; + notes.push(`Vague or too-long subjects: ${badSubjects.slice(0, 2).map(s => `"${s}"`).join(", ")}`); } - if (badSubjects.length < commits.length) { - return partial( - "commit-messages", - "Commit message format", - 10, - `Some commit subjects need work: ${badSubjects.slice(0, 2).join("; ")}`, - ); + if (hasFixups) { + score -= 5; + notes.push("Fixup/squash commits found — squash these before requesting review (see CONTRIBUTING.md)"); } - return fail( - "commit-messages", - "Commit message format", - `Commit subjects are vague or too long: ${badSubjects.slice(0, 2).join("; ")}`, - ); + if (hasBuzzwords) { + score -= 5; + notes.push("Buzzword language in commit messages — be specific about what changed"); + } + + score = Math.max(0, score); + + if (score === 20) { + return pass("commit-messages", "Commit message format", "Commit subjects are specific and concise."); + } + + return { + id: "commit-messages", name: "Commit message format", + score, max: 20, + passed: score >= 15, + detail: notes.join(". "), + }; } +// Check 4 — Diff size (20pts) function checkDiffSize(files, totals) { - const changedLines = totals.additions + totals.deletions; + const changed = totals.additions + totals.deletions; - if (changedLines <= 200 && files.length <= 10) { - return pass( - "diff-size", - "Diff size and focus", - `Small focused diff: ${changedLines} changed lines across ${files.length} files.`, - ); + if (changed <= 200 && files.length <= 10) { + return pass("diff-size", "Diff size and focus", + `Small focused diff: ${changed} changed lines across ${files.length} files.`); } - if (changedLines <= 500 && files.length <= 20) { - return partial( - "diff-size", - "Diff size and focus", - 12, - `Medium diff: ${changedLines} changed lines across ${files.length} files.`, - ); + if (changed <= 500 && files.length <= 20) { + return partial("diff-size", "Diff size and focus", 12, + `Medium diff: ${changed} changed lines across ${files.length} files. Split if unrelated changes exist.`); } - return fail( - "diff-size", - "Diff size and focus", - `Large diff: ${changedLines} changed lines across ${files.length} files. Split if possible.`, - ); + return fail("diff-size", "Diff size and focus", + `Large diff: ${changed} changed lines across ${files.length} files. Bitcoin Core prefers small reviewable PRs — split this up.`); } -function checkTestCoverage(files) { - const sourceFiles = files.filter((file) => isSourceFile(file.filename)); - const testFiles = files.filter((file) => isTestFile(file.filename)); +// Check 5 — Test coverage (20pts) +function checkTestCoverage(files, risk) { + const srcFiles = files.filter(f => isSourceFile(f.filename)); + const testFiles = files.filter(f => isTestFile(f.filename)); - if (sourceFiles.length === 0) { + if (srcFiles.length === 0) { return pass("test-coverage", "Test coverage", "No source files changed that require tests."); } if (testFiles.length > 0) { - return pass( - "test-coverage", - "Test coverage", - `Found test coverage changes: ${testFiles.map((file) => file.filename).slice(0, 2).join(", ")}`, - ); + return pass("test-coverage", "Test coverage", + `Test coverage included: ${testFiles.slice(0, 2).map(f => f.filename).join(", ")}`); } - return fail( - "test-coverage", - "Test coverage", - `Source files changed without test updates: ${sourceFiles - .map((file) => file.filename) - .slice(0, 2) - .join(", ")}`, + // Find which src files are untested + const untested = srcFiles + .slice(0, 3) + .map(f => f.filename); + + const testDir = srcFiles.some(f => f.filename.startsWith("src/wallet/")) + ? "src/test/wallet_tests.cpp or test/functional/wallet_*.py" + : "src/test/ or test/functional/"; + + return fail("test-coverage", "Test coverage", + `No test files found. Files needing tests: ${untested.join(", ")}. Add tests in ${testDir}.`); +} + +// Informational — contributor history (no score impact) +function checkContributorHistory(authorData, authorPriorMerges) { + const ageDays = Math.floor( + (Date.now() - new Date(authorData.created_at).getTime()) / (1000 * 60 * 60 * 24) ); + const isNew = ageDays < 30 || authorPriorMerges === 0; + + return { + id: "contributor", name: "Contributor history", + isNew, ageDays, authorPriorMerges, + detail: isNew + ? `New contributor — account ${ageDays} days old, ${authorPriorMerges} prior merged PRs. Note: CONTRIBUTING.md advises against refactoring PRs from new contributors.` + : `${authorPriorMerges} prior merged PRs in this repo.`, + }; } -function isGoodCommitSubject(subject) { - if (!subject || subject.length > 50) return false; +// ─── Helpers ───────────────────────────────────────────────────────────────── - const vaguePatterns = [ - /^fix$/i, - /^fixes$/i, - /^fixed$/i, - /^fixed stuff$/i, - /^update$/i, - /^updates$/i, - /^changes$/i, - /^wip\b/i, - /^work in progress\b/i, - /^misc\b/i, +function isGoodSubject(subject) { + if (!subject || subject.length > 50) return false; + const vague = [ + /^fix(ed|es)?[\s:!]?$/i, /^fixed stuff$/i, + /^update[sd]?[\s:!]?$/i, /^change[sd]?[\s:!]?$/i, + /^wip\b/i, /^work in progress\b/i, /^misc\b/i, ]; - - return !vaguePatterns.some((pattern) => pattern.test(subject)); + return !vague.some(p => p.test(subject)); } function isSourceFile(filename) { if (isTestFile(filename)) return false; if (!filename.startsWith("src/")) return false; - return /\.(c|cc|cpp|h|hpp)$/i.test(filename); } @@ -392,159 +504,145 @@ function isTestFile(filename) { filename.includes("/test/") || filename.includes("/tests/") || filename.includes("/fuzz/") || - /(^|[/_-])test(s)?[._/-]/i.test(filename) || - /_tests?\.(c|cc|cpp|h|hpp|js|ts)$/i.test(filename) + filename.includes("/bench/") || + /_tests?\.(c|cc|cpp|h|hpp)$/i.test(filename) || + (filename.endsWith(".py") && filename.includes("functional")) ); } -function pass(id, name, detail) { - return { - id, - name, - score: 20, - max: 20, - passed: true, - detail, - }; -} +function pass(id, name, detail) { return { id, name, score: 20, max: 20, passed: true, detail }; } +function partial(id, name, score, detail) { return { id, name, score, max: 20, passed: false, detail }; } +function fail(id, name, detail) { return { id, name, score: 0, max: 20, passed: false, detail }; } -function partial(id, name, score, detail) { - return { - id, - name, - score, - max: 20, - passed: false, - detail, - }; -} +// ─── Comment builder ───────────────────────────────────────────────────────── -function fail(id, name, detail) { - return { - id, - name, - score: 0, - max: 20, - passed: false, - detail, - }; -} +function buildComment(result, risk, feedback) { + const { score, lane, checks, info, thresholdHigh } = result; -function buildComment(result, feedback) { - const statusIcon = result.passed ? "✅" : "⚠️"; - const statusText = result.passed ? "Passed" : "Needs Work"; - const routingText = result.passed - ? "This PR meets the baseline contribution quality checks and has been routed for review." - : "This PR did not meet the baseline quality threshold. Please address the feedback below."; - - const rows = result.checks - .map((check) => { - const icon = check.passed ? "✅" : check.score > 0 ? "⚠️" : "❌"; - return `| ${icon} | ${check.name} | ${check.score}/${check.max} | ${escapeTableCell(check.detail)} |`; - }) - .join("\n"); - - return `${BOT_COMMENT_MARKER} -## ${statusIcon} PR Quality Gate - ${statusText} (${result.score}/100) + const header = { + ready: `## ✅ PR Quality Gate — Passed (${score}/100)`, + attention: `## 🔶 PR Quality Gate — Needs Attention (${score}/100)`, + work: `## ⚠️ PR Quality Gate — Needs Work (${score}/100)`, + }[lane]; -${routingText} + const routing = { + ready: "This PR meets Bitcoin Core's contribution guidelines and has been routed to maintainers for review.", + attention: `This PR is close but needs a few fixes before entering the maintainer review queue (threshold: ${thresholdHigh}/100).`, + work: `This PR did not meet the baseline quality threshold (${thresholdHigh}/100). Please address the feedback below.`, + }[lane]; -Threshold: **${result.threshold}/100** + const rows = checks.map(c => { + const icon = c.passed ? "✅" : c.score > 0 ? "⚠️" : "❌"; + return `| ${icon} | ${c.name} | ${c.score}/${c.max} | ${escapeCell(c.detail)} |`; + }).join("\n"); -| | Check | Score | Detail | + const scoreTable = `| | Check | Score | Detail | |---|---|---:|---| ${rows} +| | **Total** | **${score}/100** | |`; -${feedback} + const contributorInfo = info[0]; + const newContributorNote = contributorInfo?.isNew + ? `\n> 👋 **First-time contributor** — welcome! Note that CONTRIBUTING.md advises against refactoring PRs from new contributors until you have more context on the codebase.\n` + : ""; ---- -Core-Gate runs automatically when this PR is opened or updated.`; + const feedbackSection = feedback + ? `\n---\n\n### What to Fix\n\n${feedback}\n\n---\n\n*Push a new commit to re-run this check automatically.*` + : ""; + + return `${BOT_COMMENT_MARKER} +${header} + +${routing} + +${risk.message} +${newContributorNote} +${scoreTable} +${feedbackSection} +*Scored against [Bitcoin Core's CONTRIBUTING.md](https://github.com/bitcoin/bitcoin/blob/master/CONTRIBUTING.md) · Powered by [Core-Gate](https://github.com/Zeegaths/core-gate)*`; } -function escapeTableCell(value) { +function escapeCell(value) { return String(value).replaceAll("|", "\\|").replace(/\n/g, " "); } -async function ensureLabels(octokit, context) { - await Promise.all([ - ensureLabel(octokit, context, READY_LABEL, "0e8a16", "PR meets the baseline quality gate."), - ensureLabel(octokit, context, NEEDS_WORK_LABEL, "d93f0b", "PR needs contributor fixes before review."), - ]); -} +// ─── Label management ──────────────────────────────────────────────────────── -async function ensureLabel(octokit, context, name, color, description) { - try { - await octokit.issues.getLabel({ - owner: context.owner, - repo: context.repo, - name, - }); - } catch (error) { - if (error.status !== 404) throw error; - - await octokit.issues.createLabel({ - owner: context.owner, - repo: context.repo, - name, - color, - description, - }); - } +async function ensureAllLabels(octokit, context, btcLabels) { + const btcLabelMap = Object.fromEntries(btcLabels.map(l => [l.name, l.color])); + + const allLabels = Object.values(LABELS); + + await Promise.all(allLabels.map(async label => { + // Use Bitcoin Core's color if the label exists there + const color = btcLabelMap[label.name] || label.color; + try { + await octokit.issues.getLabel({ owner: context.owner, repo: context.repo, name: label.name }); + } catch (err) { + if (err.status !== 404) return; + await octokit.issues.createLabel({ + owner: context.owner, repo: context.repo, + name: label.name, color, description: label.description, + }); + } + })); } -async function applyRoutingLabel(octokit, context, passed) { - const addLabel = passed ? READY_LABEL : NEEDS_WORK_LABEL; - const removeLabel = passed ? NEEDS_WORK_LABEL : READY_LABEL; +async function applyLabels(octokit, context, result, risk) { + const { owner, repo, prNumber } = context; - await octokit.issues.addLabels({ - owner: context.owner, - repo: context.repo, - issue_number: context.prNumber, - labels: [addLabel], - }); + // Routing label based on lane + const routingLabel = { + ready: LABELS.READY.name, + attention: LABELS.ATTENTION.name, + work: LABELS.WORK.name, + }[result.lane]; - try { - await octokit.issues.removeLabel({ - owner: context.owner, - repo: context.repo, - issue_number: context.prNumber, - name: removeLabel, - }); - } catch (error) { - if (error.status !== 404) throw error; + const removeLabels = [LABELS.READY.name, LABELS.ATTENTION.name, LABELS.WORK.name] + .filter(l => l !== routingLabel); + + // Add routing label + await octokit.issues.addLabels({ owner, repo, issue_number: prNumber, labels: [routingLabel] }); + + // Remove stale routing labels + await Promise.all(removeLabels.map(name => + octokit.issues.removeLabel({ owner, repo, issue_number: prNumber, name }).catch(() => {}) + )); + + // Add risk label if applicable + if (risk.label) { + await octokit.issues.addLabels({ owner, repo, issue_number: prNumber, labels: [risk.label] }).catch(() => {}); + } + + // Add Bitcoin Core component label if applicable + if (risk.btcLabel) { + try { + await octokit.issues.addLabels({ owner, repo, issue_number: prNumber, labels: [risk.btcLabel] }); + } catch (_) {} } } +// ─── Comment upsert ────────────────────────────────────────────────────────── + async function upsertComment(octokit, context, body) { + const { owner, repo, prNumber } = context; + const comments = await octokit.paginate(octokit.issues.listComments, { - owner: context.owner, - repo: context.repo, - issue_number: context.prNumber, - per_page: 100, + owner, repo, issue_number: prNumber, per_page: 100, }); - const existingComment = comments.find((comment) => comment.body?.includes(BOT_COMMENT_MARKER)); + const existing = comments.find(c => c.body?.includes(BOT_COMMENT_MARKER)); - if (existingComment) { - await octokit.issues.updateComment({ - owner: context.owner, - repo: context.repo, - comment_id: existingComment.id, - body, - }); - return; + if (existing) { + await octokit.issues.updateComment({ owner, repo, comment_id: existing.id, body }); + } else { + await octokit.issues.createComment({ owner, repo, issue_number: prNumber, body }); } - - await octokit.issues.createComment({ - owner: context.owner, - repo: context.repo, - issue_number: context.prNumber, - body, - }); } -main().catch((error) => { - console.error("Core-Gate failed:"); - console.error(error); +// ─── Run ───────────────────────────────────────────────────────────────────── + +main().catch(err => { + console.error("Core-Gate failed:", err); process.exitCode = 1; -}); +}); \ No newline at end of file From f4b03405a85154239a84471a10ea29c45cf0df27 Mon Sep 17 00:00:00 2001 From: Zeegaths Date: Thu, 18 Jun 2026 00:48:17 +0300 Subject: [PATCH 3/4] docs: update README with three-lane routing, risk profiling, and mock mode --- README.md | 308 +++++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 237 insertions(+), 71 deletions(-) diff --git a/README.md b/README.md index 5d04e14..2a3a4be 100644 --- a/README.md +++ b/README.md @@ -1,119 +1,285 @@ # Core-Gate -Core-Gate is a GitHub Actions PR quality gate for Bitcoin Core. It scores incoming pull requests against basic contribution-quality rules before maintainers spend time reviewing them. +**Core-Gate is an open-source PR quality gate for Bitcoin Core built as a GitHub Actions workflow. It automatically scores every incoming pull request against Bitcoin Core's official contribution guidelines and routes it before a single maintainer reads it — high-quality PRs go straight to the review queue, low-quality PRs get specific AI-generated feedback telling the contributor exactly what to fix.** -It does not replace Bitcoin Core CI. It does not compile code. It triages whether a PR is reviewable enough to enter the maintainer queue. +Bitcoin Core is the reference implementation powering the global Bitcoin network. It is one of the most security-critical open source projects in existence — a bug in consensus code can cost the ecosystem billions. Yet maintainers are increasingly buried under a flood of low-quality and AI-generated pull requests. As of March 2026, AI agents alone generate 17 million PRs per month across GitHub — a 325% increase in six months — and only 1 in 10 is legitimate. Bitcoin Core's existing CI pipeline takes over an hour per PR and only verifies that code compiles and tests pass. It does nothing to filter contribution quality. -## What It Does +Core-Gate fixes that. It runs as a second, parallel workflow alongside Bitcoin Core's existing CI — touching nothing they already have — and gives maintainers a labeled, scored queue instead of a raw pile of PRs. -On every PR open, update, reopen, or edit, Core-Gate: +--- -1. Fetches PR title, body, commits, and changed files. -2. Scores the PR from 0 to 100. -3. Posts or updates a score breakdown comment. -4. Applies `ready-for-review` or `needs-work`. -5. Generates contributor feedback for failed checks. +## The Problem -## Scoring +Bitcoin Core has 325+ open pull requests at any given time. Each one requires a maintainer to open it, read the description, scan the diff, check for tests, and decide if it deserves deeper review. That process takes 10-15 minutes minimum per PR even before any code review happens. -Each check is worth 20 points: +Bitcoin Core's existing CI checks whether the code is correct. It runs builds, fuzz tests, linting, and functional tests. What it does not check is whether the PR is worth reviewing in the first place. That gap is what Core-Gate fills. -| Check | Pass condition | -|---|---| -| Component prefix | Title starts with a known prefix like `wallet:`, `rpc:`, `test:`, `doc:`, `build:`, or `consensus:` | -| PR description | Description explains the change with enough detail | -| Commit messages | Commit subjects are specific and concise | -| Diff size/focus | Diff is small enough to review comfortably | -| Test coverage | Source changes include test updates | +Critically, even the `CI failed` label on Bitcoin Core is applied **manually** by maintainers today — a world-class maintainer spending time on something a bot should handle in 30 seconds. Core-Gate automates that entire triage layer. -Passing threshold: **65/100** +--- -## Local Smoke Tests +## What Core-Gate Does -Run these before installing into a Bitcoin fork: +Core-Gate runs as a GitHub Actions workflow on every PR opened or updated. It fetches the PR data via the GitHub API, scores it against Bitcoin Core's CONTRIBUTING.md, and routes it into one of three lanes: -```bash -npm run mock:bad -npm run mock:good +| Lane | Score | Label | What happens | +|---|---|---|---| +| Needs Work | < 40/100 | `needs-work` | AI posts specific fix instructions. Maintainer skips it. | +| Needs Attention | 40-64/100 | `needs-attention` | Close but incomplete. AI posts targeted feedback. | +| Ready for Review | 65+/100 | `ready-for-review` | Routed to maintainer queue with full score breakdown. | + +The workflow posts its result in **under 30 seconds**. Bitcoin Core's CI takes over an hour. Core-Gate fires first. + +--- + +## Scoring (100 points total) + +Five checks, 20 points each, derived directly from Bitcoin Core's [CONTRIBUTING.md](https://github.com/bitcoin/bitcoin/blob/master/CONTRIBUTING.md): + +| Check | Pass condition | Points | +|---|---|---| +| Component prefix in title | Title starts with a valid area prefix | 20 | +| PR description quality | 100+ chars, issue link, no @mentions, no buzzwords | 20 | +| Commit message format | Subject under 50 chars, specific, no fixup commits | 20 | +| Diff size and focus | Under 200 changed lines, under 10 files | 20 | +| Test coverage | Code changes include test file changes | 20 | + +Pass threshold: **65/100** + +### Valid Component Prefixes + +Taken directly from Bitcoin Core's CONTRIBUTING.md: + +`consensus` `doc` `qt` `gui` `log` `mining` `net` `p2p` `refactor` `rpc` `rest` `zmq` `contrib` `cli` `test` `qa` `ci` `util` `lib` `wallet` `build` `guix` `kernel` `mempool` `fees` `fuzz` `bench` `depends` `validation` `index` `script` `crypto` `interfaces` `node` `init` + +--- + +## Risk Profiling + +Core-Gate profiles every PR by the code it touches and applies additional labels accordingly: + +| Risk Level | Paths | Label | Notes | +|---|---|---|---| +| High | `src/consensus/` `src/crypto/` `src/secp256k1/` | `high-risk` + `Consensus` | Requires BIP reference in description | +| Medium | `src/wallet/` `src/rpc/` `src/validation/` `src/net/` | `Wallet` | Functional tests required | +| GUI | `src/qt/` `src/gui/` | `gui-change` | GUI-only PRs flagged to target bitcoin-core/gui repo | +| Low | `doc/` `contrib/` `test/` | — | Standard review | + +--- + +## AI Slop Detection + +Core-Gate detects AI-generated or low-effort contributions by scanning descriptions and commit messages for buzzword patterns: + +> "improves performance", "enhances security", "fixes various", "general improvements", "refactors code", "cleaner code", "misc fixes" and similar phrases + +High density of these phrases deducts points from the description and commit scores and is flagged in the AI feedback. + +--- + +## Additional Checks + +**Linked issue** — PRs should reference an existing issue with `Fixes #XXXX` or `Closes #XXXX`. Missing issue links deduct 5 points from the description score. + +**BIP requirement** — PRs touching consensus code (`src/consensus/`) must include a BIP reference in the description. CONTRIBUTING.md states consensus changes must be preceded by extensive mailing list discussion and have a numbered BIP. + +**Fixup commits** — CONTRIBUTING.md requires squashing fixup commits before requesting review. Core-Gate detects `fixup!` and `squash!` commit subjects and flags them. + +**New contributor refactoring** — CONTRIBUTING.md explicitly states refactoring PRs should not be made by new contributors. Core-Gate notes this in the comment when a new account opens a refactoring PR. + +**GUI repo routing** — PRs that only touch `src/qt/` should target the [bitcoin-core/gui](https://github.com/bitcoin-core/gui) repository. Core-Gate flags these automatically. + +--- + +## Live Bitcoin Core Labels + +Core-Gate fetches Bitcoin Core's actual labels from the GitHub API at runtime and reuses their exact names and colors when labeling PRs on your fork. This means Core-Gate's labels integrate cleanly into the existing maintainer workflow rather than introducing unfamiliar label names. + +--- + +## How It Works + +``` +PR opened or updated + ↓ +pr-gate.yml fires (GitHub Actions, ~30 seconds) + ↓ +Fetch PR data + Bitcoin Core labels in parallel + ↓ +getRiskProfile() — file path analysis + ↓ +scorePullRequest() — 5 deterministic checks + ↓ +Score >= 65 → ready-for-review, score breakdown posted +Score 40-64 → needs-attention, AI feedback posted +Score < 40 → needs-work, AI feedback posted + ↓ +Labels applied, comment upserted ``` -The bad mock should produce a low score and `needs-work` style feedback. +The AI never makes labeling decisions. Labels are applied by a deterministic rules engine. The AI only writes the human-readable feedback comment for low-scoring PRs. -The good mock should produce a passing score and `ready-for-review` style output. +--- + +## Example Output + +### Needs Work (20/100) + +``` +⚠️ PR Quality Gate — Needs Work (20/100) -## Install Into A Bitcoin Core Fork +This PR did not meet the baseline quality threshold (65/100). -Use this when testing against your fork: +🟢 Low Risk — touches documentation, tests, or non-consensus code. + +| | Check | Score | Detail | +|---|-----------------------|------:|---------------------------------------------------------------| +| ❌ | Component prefix | 0/20 | Title "fixed stuff" missing a prefix like "wallet:", "rpc:" | +| ❌ | PR description | 0/20 | Description too short — explain the problem, approach, impact | +| ❌ | Commit message format | 0/20 | Vague subjects: "fixed stuff" | +| ✅ | Diff size and focus | 20/20 | Small focused diff: 2 changed lines across 1 file | +| ❌ | Test coverage | 0/20 | No tests found. Add tests in src/test/ or test/functional/ | +| | Total | 20/100 | | + +### What to Fix +...specific AI-generated feedback here... +``` + +### Ready for Review (100/100) + +``` +✅ PR Quality Gate — Passed (100/100) + +This PR meets Bitcoin Core's contribution guidelines. + +🔶 Medium Risk — touches wallet code. Functional tests required. + +| | Check | Score | Detail | +|---|-----------------------|-------:|-------------------------------------------------| +| ✅ | Component prefix | 20/20 | Found valid prefix: wallet: | +| ✅ | PR description | 20/20 | Sufficient detail. ✓ Issue reference found | +| ✅ | Commit message format | 20/20 | Commit subjects are specific and concise | +| ✅ | Diff size and focus | 20/20 | Small focused diff: 75 lines across 2 files | +| ✅ | Test coverage | 20/20 | Test coverage: src/test/wallet_tests.cpp | +| | Total | 100/100 | | +``` + +--- + +## Stack + +| Layer | Choice | Why | +|---|---|---| +| CI runner | GitHub Actions | Free, runs in the repo, no infrastructure | +| GitHub API | `@octokit/rest` + `GITHUB_TOKEN` | Auto-injected, no auth setup | +| Scoring engine | Node.js rules engine | Deterministic, fully auditable, FOSS | +| AI feedback | Kimi API (Anthropic fallback) | Generates specific, convention-aware feedback | +| Bitcoin Core labels | Fetched live via GitHub API | Always current, integrates with existing workflow | +| Runtime | Node.js 20 | Available on all GitHub Actions runners | +| License | MIT | FOSS | + +--- + +## Setup + +### 1. Fork Bitcoin Core ```bash -git clone https://github.com/Aishagojo/bitcoin.git +# Fork https://github.com/bitcoin/bitcoin on GitHub +git clone https://github.com/YOUR_USERNAME/bitcoin cd bitcoin -git checkout -b core-gate-setup +``` +### 2. Add Core-Gate files to your fork + +```bash cp -r /path/to/core-gate/.github ./ cp -r /path/to/core-gate/scripts ./ cp /path/to/core-gate/package.json ./ -cp /path/to/core-gate/package-lock.json ./ - -git add .github/workflows/pr-gate.yml scripts package.json package-lock.json -git commit -m "ci: add Core-Gate PR quality workflow" -git push origin core-gate-setup +npm install ``` -Open a PR from `core-gate-setup` to `main` in the fork. After the workflow is on the base branch, create demo PRs against that branch or merge it into `main` for easier demos. +### 3. Add your AI API key as a repo secret + +Repo → Settings → Secrets and variables → Actions → New repository secret + +``` +Name: KIMI_API_KEY +Value: your key here +``` -## Demo PRs +`GITHUB_TOKEN` is injected automatically. No other secrets needed. -Bad PR example: +### 4. Push and open a test PR ```bash -git checkout main -git pull -git checkout -b demo-bad-pr -echo "// demo bad pr" >> src/bitcoin-cli.cpp -git add src/bitcoin-cli.cpp -git commit -m "fixed stuff" -git push origin demo-bad-pr +git checkout -b test/bad-pr +echo "// test" >> src/bitcoin-cli.cpp +git add . && git commit -m "fixed stuff" +git push origin test/bad-pr ``` -Open `demo-bad-pr -> main`. Expected result: `needs-work`. +Open the PR on GitHub. The `PR Quality Gate` workflow fires within seconds. -Good PR example: +--- + +## Local Testing + +Test without a real PR using mock mode: ```bash -git checkout main -git pull -git checkout -b demo-good-pr -echo "// demo good pr" >> src/wallet/wallet.cpp -echo "// demo wallet test" >> src/wallet/test/wallet_tests.cpp -git add src/wallet/wallet.cpp src/wallet/test/wallet_tests.cpp -git commit -m "wallet: add fee rounding test" -git push origin demo-good-pr +npm install + +# Simulate a bad PR +npm run mock:bad + +# Simulate a good PR +npm run mock:good ``` -Open `demo-good-pr -> main`. Use a PR title like: +No GitHub token or API key needed for mock mode. + +--- + +## Repo Structure -```text -wallet: add fee rounding test +``` +core-gate/ +├── .github/ +│ └── workflows/ +│ └── pr-gate.yml # Workflow trigger and job definition +├── scripts/ +│ ├── scorer.js # Rules engine — scores PRs 0-100 +│ └── feedback.js # AI layer — generates contributor feedback +├── package.json +└── README.md ``` -Add a description longer than 100 characters explaining the reason for the change. +--- -Expected result: `ready-for-review`. +## What Core-Gate Does Not Do -## Required Permissions +- Does not compile or run any code +- Does not replace Bitcoin Core's existing CI — runs in parallel +- Does not block PRs from being opened — labels and comments only +- Does not require external hosting, a server, or a database +- Does not make labeling decisions using AI — all labels are deterministic -The workflow uses: +--- -```yaml -permissions: - pull-requests: write - contents: read - issues: write -``` +## Why This Matters Beyond Bitcoin Core + +The PR spam problem is not unique to Bitcoin Core. Core-Gate is portable — the scoring rules and component prefixes are the only Bitcoin Core-specific parts. Adapting it to another project means updating the prefix list and pointing it at a different CONTRIBUTING.md. The architecture stays identical. + +--- + +## Built At + +Bitcoin++ Open Source Edition Hackathon +Nairobi, Kenya — June 2026 -`issues: write` is required because GitHub PR comments and labels use the Issues API. +--- -## Optional AI Feedback +## License -If `ANTHROPIC_API_KEY` is present as an Actions secret, Core-Gate asks Anthropic for specific feedback on failed checks. If the key is missing or the API call fails, Core-Gate uses deterministic fallback feedback. +MIT \ No newline at end of file From b296ef0c2343f8ab02e1d2dd59f7795d202c2642 Mon Sep 17 00:00:00 2001 From: Zeegaths Date: Thu, 18 Jun 2026 00:59:47 +0300 Subject: [PATCH 4/4] feat: replace length-based description scoring with signal-based quality checks --- scripts/scorer.js | 87 ++++++++++++++++++++++++++++++++++++----------- 1 file changed, 67 insertions(+), 20 deletions(-) diff --git a/scripts/scorer.js b/scripts/scorer.js index 3a97ea1..8c609b2 100644 --- a/scripts/scorer.js +++ b/scripts/scorer.js @@ -327,41 +327,88 @@ function checkTitlePrefix(title) { } // Check 2 — Description quality (20pts) +// Scored on quality signals, not just length. +// A short but technically specific description outscores a long buzzword-filled one. +// +// Signal breakdown (20pts total): +// 6pts — explains the problem (what is broken/missing) +// 6pts — technical specificity (file names, function names, Bitcoin terms) +// 4pts — issue reference (Fixes #XXXX) +// 2pts — no @mentions +// 2pts — no buzzword language +// +// Penalties: +// -5pts — missing issue link (if description exists) +// -5pts — consensus change without BIP reference +// -5pts — @mentions present +// -5pts — buzzword density >= 2 +// WIP — capped at 10pts regardless of signals function checkDescription(body, title, risk, authorPriorMerges) { const cleaned = body.replace(//g, "").trim(); + const lower = cleaned.toLowerCase(); const hasMention = /(^|\s)@\w+/.test(cleaned); const isWIP = /\[wip\]/i.test(title) || /\b(wip|work in progress)\b/i.test(cleaned); const hasIssueRef = /(fixes|closes|resolves|refs?)\s+#\d+/i.test(cleaned) || /#\d+/.test(cleaned); - const buzzwordsFound = BUZZWORDS.filter(w => cleaned.toLowerCase().includes(w)); + const buzzwordsFound = BUZZWORDS.filter(w => lower.includes(w)); const hasBuzzwords = buzzwordsFound.length >= 2; // Consensus changes need a BIP reference - const needsBIP = risk.requiresBIP; - const hasBIP = /BIP[-\s]?\d+/i.test(cleaned); + const needsBIP = risk.requiresBIP; + const hasBIP = /BIP[-\s]?\d+/i.test(cleaned); + + // Signal 1 — problem statement (6pts) + // Does the description explain what is broken, wrong, or missing? + const hasProblemStatement = /\b(fix(es|ed)?|bug|issue|problem|broken|fail(s|ed)?|error|wrong|incorrect|crash(es|ed)?|regression|edge case|undefined|unexpected|missing|lack)\b/i.test(cleaned); + const problemPts = hasProblemStatement ? 6 : 0; + + // Signal 2 — technical specificity (6pts) + // Bitcoin Core-specific terms, file paths, function names, or concrete values + // A description mentioning src/wallet/ or CWallet:: is almost certainly written by a human who read the code + const hasTechnicalDetail = ( + /src\/[a-z]/.test(cleaned) || // file path like src/wallet/ + /\b[A-Z][a-z]+::[A-Z][a-zA-Z]+/.test(cleaned) || // C++ method like CWallet::CreateTransaction + /\b(sat(oshi)?s?|btc|utxo|mempool|scriptpubkey|witness|segwit|taproot|schnorr|secp256k1|bip\d+)\b/i.test(cleaned) || // Bitcoin terms + /\b(assert|nullptr|overflow|underflow|integer|race condition|deadlock|mutex|lock|thread)\b/i.test(cleaned) || // technical CS terms + /#\d{4,}/.test(cleaned) || // issue/PR reference with 4+ digit number + /0x[0-9a-fA-F]+/.test(cleaned) // hex value + ); + const technicalPts = hasTechnicalDetail ? 6 : 0; + + // Signal 3 — issue reference (4pts) + const issuePts = hasIssueRef ? 4 : 0; + + // Signal 4 — no @mentions (2pts) + const mentionPts = hasMention ? 0 : 2; - let score = 0; + // Signal 5 — no buzzwords (2pts) + const buzzPts = hasBuzzwords ? 0 : 2; + + let score = problemPts + technicalPts + issuePts + mentionPts + buzzPts; const notes = []; - if (isWIP) { - score = 10; - notes.push("Marked [WIP] — partial credit"); - } else if (cleaned.length >= 100) { - score = 20; - notes.push("Description has sufficient detail"); - } else if (cleaned.length >= 50) { - score = 10; - notes.push("Description present but brief — explain *why*, not just *what*"); - } else { + // Empty description + if (cleaned.length === 0) { score = 0; - notes.push("Description too short — explain the problem, approach, and impact"); + notes.push("No description — explain the problem, approach, and impact"); + } else if (isWIP) { + score = Math.min(score, 10); + notes.push("Marked [WIP] — partial credit"); } - if (hasIssueRef) notes.push("✓ Issue reference found"); - else if (score > 0) { score -= 5; notes.push("No issue reference — add `Fixes #XXXX` or `Closes #XXXX`"); } + // Signal feedback + if (hasProblemStatement) notes.push("✓ Problem statement found"); + else notes.push("Missing problem statement — what is broken or wrong?"); + + if (hasTechnicalDetail) notes.push("✓ Technical specificity found"); + else notes.push("Missing technical detail — mention file paths, function names, or specific values"); + + if (hasIssueRef) notes.push("✓ Issue reference found"); + else if (cleaned.length > 0) notes.push("No issue reference — add `Fixes #XXXX` or `Closes #XXXX`"); - if (needsBIP && !hasBIP) { score -= 5; notes.push("Consensus change requires a BIP reference in the description"); } - if (hasMention) { score -= 5; notes.push("Remove @mentions — they spam every fork's notification feed"); } - if (hasBuzzwords) { score -= 5; notes.push(`Buzzword language detected ("${buzzwordsFound.slice(0, 2).join('", "')}") — be specific`); } + // Penalties + if (needsBIP && !hasBIP) { score -= 5; notes.push("Consensus change requires a BIP reference"); } + if (hasMention) { notes.push("Remove @mentions — they spam every fork's notification feed"); } + if (hasBuzzwords) { notes.push(`Buzzword language detected ("${buzzwordsFound.slice(0, 2).join('", "')}") — be specific`); } score = Math.max(0, Math.min(20, score));