chore(ci): externalize workflow scripts and relocate main flow doc (#722)
* feat: Add GitHub Actions workflows for security audits, CodeQL analysis, contributor updates, performance benchmarks, integration tests, fuzz testing, and reusable Rust build jobs - Implemented `sec-audit.yml` for Rust package security audits using `rustsec/audit-check` and `cargo-deny-action`. - Created `sec-codeql.yml` for CodeQL analysis scheduled twice daily. - Added `sync-contributors.yml` to update the NOTICE file with new contributors automatically. - Introduced `test-benchmarks.yml` for performance benchmarks using Criterion. - Established `test-e2e.yml` for running integration and end-to-end tests. - Developed `test-fuzz.yml` for fuzz testing with configurable runtime. - Created `test-rust-build.yml` as a reusable job for executing Rust commands with customizable parameters. - Documented main branch delivery flows in `main-branch-flow.md` for clarity on CI/CD processes. * ci(workflows): update workflow scripts and rename for clarity; remove obsolete lint feedback script * chore(ci): externalize workflow scripts and relocate main flow doc
This commit is contained in:
parent
41da46e2b2
commit
69a3b54968
34 changed files with 2090 additions and 1777 deletions
83
.github/workflows/scripts/ci_workflow_owner_approval.js
vendored
Normal file
83
.github/workflows/scripts/ci_workflow_owner_approval.js
vendored
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
// Extracted from ci-run.yml step: Require owner approval for workflow file changes
|
||||
|
||||
module.exports = async ({ github, context, core }) => {
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
const prNumber = context.payload.pull_request?.number;
|
||||
const prAuthor = context.payload.pull_request?.user?.login?.toLowerCase() || "";
|
||||
if (!prNumber) {
|
||||
core.setFailed("Missing pull_request context.");
|
||||
return;
|
||||
}
|
||||
|
||||
const baseOwners = ["theonlyhennygod", "willsarg"];
|
||||
const configuredOwners = (process.env.WORKFLOW_OWNER_LOGINS || "")
|
||||
.split(",")
|
||||
.map((login) => login.trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
const ownerAllowlist = [...new Set([...baseOwners, ...configuredOwners])];
|
||||
|
||||
if (ownerAllowlist.length === 0) {
|
||||
core.setFailed("Workflow owner allowlist is empty.");
|
||||
return;
|
||||
}
|
||||
|
||||
core.info(`Workflow owner allowlist: ${ownerAllowlist.join(", ")}`);
|
||||
|
||||
const files = await github.paginate(github.rest.pulls.listFiles, {
|
||||
owner,
|
||||
repo,
|
||||
pull_number: prNumber,
|
||||
per_page: 100,
|
||||
});
|
||||
|
||||
const workflowFiles = files
|
||||
.map((file) => file.filename)
|
||||
.filter((name) => name.startsWith(".github/workflows/"));
|
||||
|
||||
if (workflowFiles.length === 0) {
|
||||
core.info("No workflow files changed in this PR.");
|
||||
return;
|
||||
}
|
||||
|
||||
core.info(`Workflow files changed:\n- ${workflowFiles.join("\n- ")}`);
|
||||
|
||||
if (prAuthor && ownerAllowlist.includes(prAuthor)) {
|
||||
core.info(`Workflow PR authored by allowlisted owner: @${prAuthor}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const reviews = await github.paginate(github.rest.pulls.listReviews, {
|
||||
owner,
|
||||
repo,
|
||||
pull_number: prNumber,
|
||||
per_page: 100,
|
||||
});
|
||||
|
||||
const latestReviewByUser = new Map();
|
||||
for (const review of reviews) {
|
||||
const login = review.user?.login;
|
||||
if (!login) continue;
|
||||
latestReviewByUser.set(login.toLowerCase(), review.state);
|
||||
}
|
||||
|
||||
const approvedUsers = [...latestReviewByUser.entries()]
|
||||
.filter(([, state]) => state === "APPROVED")
|
||||
.map(([login]) => login);
|
||||
|
||||
if (approvedUsers.length === 0) {
|
||||
core.setFailed("Workflow files changed but no approving review is present.");
|
||||
return;
|
||||
}
|
||||
|
||||
const ownerApprover = approvedUsers.find((login) => ownerAllowlist.includes(login));
|
||||
if (!ownerApprover) {
|
||||
core.setFailed(
|
||||
`Workflow files changed. Approvals found (${approvedUsers.join(", ")}), but none match workflow owner allowlist.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
core.info(`Workflow owner approval present: @${ownerApprover}`);
|
||||
|
||||
};
|
||||
90
.github/workflows/scripts/lint_feedback.js
vendored
Normal file
90
.github/workflows/scripts/lint_feedback.js
vendored
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
// Post actionable lint failure summary as a PR comment.
|
||||
// Used by the lint-feedback CI job via actions/github-script.
|
||||
//
|
||||
// Required environment variables:
|
||||
// RUST_CHANGED — "true" if Rust files changed
|
||||
// DOCS_CHANGED — "true" if docs files changed
|
||||
// LINT_RESULT — result of the lint job
|
||||
// LINT_DELTA_RESULT — result of the strict delta lint job
|
||||
// DOCS_RESULT — result of the docs-quality job
|
||||
|
||||
module.exports = async ({ github, context, core }) => {
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
const issueNumber = context.payload.pull_request?.number;
|
||||
if (!issueNumber) return;
|
||||
|
||||
const marker = "<!-- ci-lint-feedback -->";
|
||||
const rustChanged = process.env.RUST_CHANGED === "true";
|
||||
const docsChanged = process.env.DOCS_CHANGED === "true";
|
||||
const lintResult = process.env.LINT_RESULT || "skipped";
|
||||
const lintDeltaResult = process.env.LINT_DELTA_RESULT || "skipped";
|
||||
const docsResult = process.env.DOCS_RESULT || "skipped";
|
||||
|
||||
const failures = [];
|
||||
if (rustChanged && !["success", "skipped"].includes(lintResult)) {
|
||||
failures.push("`Lint Gate (Format + Clippy)` failed.");
|
||||
}
|
||||
if (rustChanged && !["success", "skipped"].includes(lintDeltaResult)) {
|
||||
failures.push("`Lint Gate (Strict Delta)` failed.");
|
||||
}
|
||||
if (docsChanged && !["success", "skipped"].includes(docsResult)) {
|
||||
failures.push("`Docs Quality` failed.");
|
||||
}
|
||||
|
||||
const comments = await github.paginate(github.rest.issues.listComments, {
|
||||
owner,
|
||||
repo,
|
||||
issue_number: issueNumber,
|
||||
per_page: 100,
|
||||
});
|
||||
const existing = comments.find((comment) => (comment.body || "").includes(marker));
|
||||
|
||||
if (failures.length === 0) {
|
||||
if (existing) {
|
||||
await github.rest.issues.deleteComment({
|
||||
owner,
|
||||
repo,
|
||||
comment_id: existing.id,
|
||||
});
|
||||
}
|
||||
core.info("No lint/docs gate failures. No feedback comment required.");
|
||||
return;
|
||||
}
|
||||
|
||||
const runUrl = `${context.serverUrl}/${owner}/${repo}/actions/runs/${context.runId}`;
|
||||
const body = [
|
||||
marker,
|
||||
"### CI lint feedback",
|
||||
"",
|
||||
"This PR failed one or more fast lint/documentation gates:",
|
||||
"",
|
||||
...failures.map((item) => `- ${item}`),
|
||||
"",
|
||||
"Open the failing logs in this run:",
|
||||
`- ${runUrl}`,
|
||||
"",
|
||||
"Local fix commands:",
|
||||
"- `./scripts/ci/rust_quality_gate.sh`",
|
||||
"- `./scripts/ci/rust_strict_delta_gate.sh`",
|
||||
"- `./scripts/ci/docs_quality_gate.sh`",
|
||||
"",
|
||||
"After fixes, push a new commit and CI will re-run automatically.",
|
||||
].join("\n");
|
||||
|
||||
if (existing) {
|
||||
await github.rest.issues.updateComment({
|
||||
owner,
|
||||
repo,
|
||||
comment_id: existing.id,
|
||||
body,
|
||||
});
|
||||
} else {
|
||||
await github.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: issueNumber,
|
||||
body,
|
||||
});
|
||||
}
|
||||
};
|
||||
131
.github/workflows/scripts/pr_auto_response_contributor_tier.js
vendored
Normal file
131
.github/workflows/scripts/pr_auto_response_contributor_tier.js
vendored
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
// Extracted from pr-auto-response.yml step: Apply contributor tier label for issue author
|
||||
|
||||
module.exports = async ({ github, context, core }) => {
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
const issue = context.payload.issue;
|
||||
const pullRequest = context.payload.pull_request;
|
||||
const target = issue ?? pullRequest;
|
||||
async function loadContributorTierPolicy() {
|
||||
const fallback = {
|
||||
contributorTierColor: "2ED9FF",
|
||||
contributorTierRules: [
|
||||
{ label: "distinguished contributor", minMergedPRs: 50 },
|
||||
{ label: "principal contributor", minMergedPRs: 20 },
|
||||
{ label: "experienced contributor", minMergedPRs: 10 },
|
||||
{ label: "trusted contributor", minMergedPRs: 5 },
|
||||
],
|
||||
};
|
||||
try {
|
||||
const { data } = await github.rest.repos.getContent({
|
||||
owner,
|
||||
repo,
|
||||
path: ".github/label-policy.json",
|
||||
ref: context.payload.repository?.default_branch || "main",
|
||||
});
|
||||
const json = JSON.parse(Buffer.from(data.content, "base64").toString("utf8"));
|
||||
const contributorTierRules = (json.contributor_tiers || []).map((entry) => ({
|
||||
label: String(entry.label || "").trim(),
|
||||
minMergedPRs: Number(entry.min_merged_prs || 0),
|
||||
}));
|
||||
const contributorTierColor = String(json.contributor_tier_color || "").toUpperCase();
|
||||
if (!contributorTierColor || contributorTierRules.length === 0) {
|
||||
return fallback;
|
||||
}
|
||||
return { contributorTierColor, contributorTierRules };
|
||||
} catch (error) {
|
||||
core.warning(`failed to load .github/label-policy.json, using fallback policy: ${error.message}`);
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
const { contributorTierColor, contributorTierRules } = await loadContributorTierPolicy();
|
||||
const contributorTierLabels = contributorTierRules.map((rule) => rule.label);
|
||||
const managedContributorLabels = new Set(contributorTierLabels);
|
||||
const action = context.payload.action;
|
||||
const changedLabel = context.payload.label?.name;
|
||||
|
||||
if (!target) return;
|
||||
if ((action === "labeled" || action === "unlabeled") && !managedContributorLabels.has(changedLabel)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const author = target.user;
|
||||
if (!author || author.type === "Bot") return;
|
||||
|
||||
function contributorTierDescription(rule) {
|
||||
return `Contributor with ${rule.minMergedPRs}+ merged PRs.`;
|
||||
}
|
||||
|
||||
async function ensureContributorTierLabels() {
|
||||
for (const rule of contributorTierRules) {
|
||||
const label = rule.label;
|
||||
const expectedDescription = contributorTierDescription(rule);
|
||||
try {
|
||||
const { data: existing } = await github.rest.issues.getLabel({ owner, repo, name: label });
|
||||
const currentColor = (existing.color || "").toUpperCase();
|
||||
const currentDescription = (existing.description || "").trim();
|
||||
if (currentColor !== contributorTierColor || currentDescription !== expectedDescription) {
|
||||
await github.rest.issues.updateLabel({
|
||||
owner,
|
||||
repo,
|
||||
name: label,
|
||||
new_name: label,
|
||||
color: contributorTierColor,
|
||||
description: expectedDescription,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.status !== 404) throw error;
|
||||
await github.rest.issues.createLabel({
|
||||
owner,
|
||||
repo,
|
||||
name: label,
|
||||
color: contributorTierColor,
|
||||
description: expectedDescription,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function selectContributorTier(mergedCount) {
|
||||
const matchedTier = contributorTierRules.find((rule) => mergedCount >= rule.minMergedPRs);
|
||||
return matchedTier ? matchedTier.label : null;
|
||||
}
|
||||
|
||||
let contributorTierLabel = null;
|
||||
try {
|
||||
const { data: mergedSearch } = await github.rest.search.issuesAndPullRequests({
|
||||
q: `repo:${owner}/${repo} is:pr is:merged author:${author.login}`,
|
||||
per_page: 1,
|
||||
});
|
||||
const mergedCount = mergedSearch.total_count || 0;
|
||||
contributorTierLabel = selectContributorTier(mergedCount);
|
||||
} catch (error) {
|
||||
core.warning(`failed to evaluate contributor tier status: ${error.message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
await ensureContributorTierLabels();
|
||||
|
||||
const { data: currentLabels } = await github.rest.issues.listLabelsOnIssue({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: target.number,
|
||||
});
|
||||
const keepLabels = currentLabels
|
||||
.map((label) => label.name)
|
||||
.filter((label) => !contributorTierLabels.includes(label));
|
||||
|
||||
if (contributorTierLabel) {
|
||||
keepLabels.push(contributorTierLabel);
|
||||
}
|
||||
|
||||
await github.rest.issues.setLabels({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: target.number,
|
||||
labels: [...new Set(keepLabels)],
|
||||
});
|
||||
|
||||
};
|
||||
94
.github/workflows/scripts/pr_auto_response_labeled_routes.js
vendored
Normal file
94
.github/workflows/scripts/pr_auto_response_labeled_routes.js
vendored
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
// Extracted from pr-auto-response.yml step: Handle label-driven responses
|
||||
|
||||
module.exports = async ({ github, context, core }) => {
|
||||
const label = context.payload.label?.name;
|
||||
if (!label) return;
|
||||
|
||||
const issue = context.payload.issue;
|
||||
const pullRequest = context.payload.pull_request;
|
||||
const target = issue ?? pullRequest;
|
||||
if (!target) return;
|
||||
|
||||
const isIssue = Boolean(issue);
|
||||
const issueNumber = target.number;
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
|
||||
const rules = [
|
||||
{
|
||||
label: "r:support",
|
||||
close: true,
|
||||
closeIssuesOnly: true,
|
||||
closeReason: "not_planned",
|
||||
message:
|
||||
"This looks like a usage/support request. Please use README + docs first, then open a focused bug with repro details if behavior is incorrect.",
|
||||
},
|
||||
{
|
||||
label: "r:needs-repro",
|
||||
close: false,
|
||||
message:
|
||||
"Thanks for the report. Please add deterministic repro steps, exact environment, and redacted logs so maintainers can triage quickly.",
|
||||
},
|
||||
{
|
||||
label: "invalid",
|
||||
close: true,
|
||||
closeIssuesOnly: true,
|
||||
closeReason: "not_planned",
|
||||
message:
|
||||
"Closing as invalid based on current information. If this is still relevant, open a new issue with updated evidence and reproducible steps.",
|
||||
},
|
||||
{
|
||||
label: "duplicate",
|
||||
close: true,
|
||||
closeIssuesOnly: true,
|
||||
closeReason: "not_planned",
|
||||
message:
|
||||
"Closing as duplicate. Please continue discussion in the canonical linked issue/PR.",
|
||||
},
|
||||
];
|
||||
|
||||
const rule = rules.find((entry) => entry.label === label);
|
||||
if (!rule) return;
|
||||
|
||||
const marker = `<!-- auto-response:${rule.label} -->`;
|
||||
const comments = await github.paginate(github.rest.issues.listComments, {
|
||||
owner,
|
||||
repo,
|
||||
issue_number: issueNumber,
|
||||
per_page: 100,
|
||||
});
|
||||
|
||||
const alreadyCommented = comments.some((comment) =>
|
||||
(comment.body || "").includes(marker)
|
||||
);
|
||||
|
||||
if (!alreadyCommented) {
|
||||
await github.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: issueNumber,
|
||||
body: `${rule.message}\n\n${marker}`,
|
||||
});
|
||||
}
|
||||
|
||||
if (!rule.close) return;
|
||||
if (rule.closeIssuesOnly && !isIssue) return;
|
||||
if (target.state === "closed") return;
|
||||
|
||||
if (isIssue) {
|
||||
await github.rest.issues.update({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: issueNumber,
|
||||
state: "closed",
|
||||
state_reason: rule.closeReason || "not_planned",
|
||||
});
|
||||
} else {
|
||||
await github.rest.issues.update({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: issueNumber,
|
||||
state: "closed",
|
||||
});
|
||||
}
|
||||
};
|
||||
161
.github/workflows/scripts/pr_check_status_nudge.js
vendored
Normal file
161
.github/workflows/scripts/pr_check_status_nudge.js
vendored
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
// Extracted from pr-check-status.yml step: Nudge PRs that need rebase or CI refresh
|
||||
|
||||
module.exports = async ({ github, context, core }) => {
|
||||
const staleHours = Number(process.env.STALE_HOURS || "48");
|
||||
const ignoreLabels = new Set(["no-stale", "stale", "maintainer", "no-pr-hygiene"]);
|
||||
const marker = "<!-- pr-hygiene-nudge -->";
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
|
||||
const openPrs = await github.paginate(github.rest.pulls.list, {
|
||||
owner,
|
||||
repo,
|
||||
state: "open",
|
||||
per_page: 100,
|
||||
});
|
||||
|
||||
const activePrs = openPrs.filter((pr) => {
|
||||
if (pr.draft) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const labels = new Set((pr.labels || []).map((label) => label.name));
|
||||
return ![...ignoreLabels].some((label) => labels.has(label));
|
||||
});
|
||||
|
||||
core.info(`Scanning ${activePrs.length} open PR(s) for hygiene nudges.`);
|
||||
|
||||
let nudged = 0;
|
||||
let skipped = 0;
|
||||
|
||||
for (const pr of activePrs) {
|
||||
const { data: headCommit } = await github.rest.repos.getCommit({
|
||||
owner,
|
||||
repo,
|
||||
ref: pr.head.sha,
|
||||
});
|
||||
|
||||
const headCommitAt =
|
||||
headCommit.commit?.committer?.date || headCommit.commit?.author?.date;
|
||||
if (!headCommitAt) {
|
||||
skipped += 1;
|
||||
core.info(`#${pr.number}: missing head commit timestamp, skipping.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const ageHours = (Date.now() - new Date(headCommitAt).getTime()) / 3600000;
|
||||
if (ageHours < staleHours) {
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const { data: prDetail } = await github.rest.pulls.get({
|
||||
owner,
|
||||
repo,
|
||||
pull_number: pr.number,
|
||||
});
|
||||
|
||||
const isBehindBase = prDetail.mergeable_state === "behind";
|
||||
|
||||
const { data: checkRunsData } = await github.rest.checks.listForRef({
|
||||
owner,
|
||||
repo,
|
||||
ref: pr.head.sha,
|
||||
per_page: 100,
|
||||
});
|
||||
|
||||
const ciGateRuns = (checkRunsData.check_runs || [])
|
||||
.filter((run) => run.name === "CI Required Gate")
|
||||
.sort((a, b) => {
|
||||
const aTime = new Date(a.started_at || a.completed_at || a.created_at).getTime();
|
||||
const bTime = new Date(b.started_at || b.completed_at || b.created_at).getTime();
|
||||
return bTime - aTime;
|
||||
});
|
||||
|
||||
let ciState = "missing";
|
||||
if (ciGateRuns.length > 0) {
|
||||
const latest = ciGateRuns[0];
|
||||
if (latest.status !== "completed") {
|
||||
ciState = "in_progress";
|
||||
} else if (["success", "neutral", "skipped"].includes(latest.conclusion || "")) {
|
||||
ciState = "success";
|
||||
} else {
|
||||
ciState = String(latest.conclusion || "failure");
|
||||
}
|
||||
}
|
||||
|
||||
const ciMissing = ciState === "missing";
|
||||
const ciFailing = !["success", "in_progress", "missing"].includes(ciState);
|
||||
|
||||
if (!isBehindBase && !ciMissing && !ciFailing) {
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const reasons = [];
|
||||
if (isBehindBase) {
|
||||
reasons.push("- Branch is behind `main` (please rebase or merge the latest base branch).");
|
||||
}
|
||||
if (ciMissing) {
|
||||
reasons.push("- No `CI Required Gate` run was found for the current head commit.");
|
||||
}
|
||||
if (ciFailing) {
|
||||
reasons.push(`- Latest \`CI Required Gate\` result is \`${ciState}\`.`);
|
||||
}
|
||||
|
||||
const shortSha = pr.head.sha.slice(0, 12);
|
||||
const body = [
|
||||
marker,
|
||||
`Hi @${pr.user.login}, friendly automation nudge from PR hygiene.`,
|
||||
"",
|
||||
`This PR has had no new commits for **${Math.floor(ageHours)}h** and still needs an update before merge:`,
|
||||
"",
|
||||
...reasons,
|
||||
"",
|
||||
"### Recommended next steps",
|
||||
"1. Rebase your branch on `main`.",
|
||||
"2. Push the updated branch and re-run checks (or use **Re-run failed jobs**).",
|
||||
"3. Post fresh validation output in this PR thread.",
|
||||
"",
|
||||
"Maintainers: apply `no-stale` to opt out for accepted-but-blocked work.",
|
||||
`Head SHA: \`${shortSha}\``,
|
||||
].join("\n");
|
||||
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: pr.number,
|
||||
per_page: 100,
|
||||
});
|
||||
|
||||
const existing = comments.find(
|
||||
(comment) => comment.user?.type === "Bot" && comment.body?.includes(marker),
|
||||
);
|
||||
|
||||
if (existing) {
|
||||
if (existing.body === body) {
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
await github.rest.issues.updateComment({
|
||||
owner,
|
||||
repo,
|
||||
comment_id: existing.id,
|
||||
body,
|
||||
});
|
||||
} else {
|
||||
await github.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: pr.number,
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
nudged += 1;
|
||||
core.info(`#${pr.number}: hygiene nudge posted/updated.`);
|
||||
}
|
||||
|
||||
core.info(`Done. Nudged=${nudged}, skipped=${skipped}`);
|
||||
};
|
||||
190
.github/workflows/scripts/pr_intake_checks.js
vendored
Normal file
190
.github/workflows/scripts/pr_intake_checks.js
vendored
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
// Run safe intake checks for PR events and maintain a single sticky comment.
|
||||
// Used by .github/workflows/pr-intake-checks.yml via actions/github-script.
|
||||
|
||||
module.exports = async ({ github, context, core }) => {
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
const pr = context.payload.pull_request;
|
||||
if (!pr) return;
|
||||
|
||||
const marker = "<!-- pr-intake-checks -->";
|
||||
const legacyMarker = "<!-- pr-intake-sanity -->";
|
||||
const requiredSections = [
|
||||
"## Summary",
|
||||
"## Validation Evidence (required)",
|
||||
"## Security Impact (required)",
|
||||
"## Privacy and Data Hygiene (required)",
|
||||
"## Rollback Plan (required)",
|
||||
];
|
||||
const body = pr.body || "";
|
||||
|
||||
const missingSections = requiredSections.filter((section) => !body.includes(section));
|
||||
const missingFields = [];
|
||||
const requiredFieldChecks = [
|
||||
["summary problem", /- Problem:\s*\S+/m],
|
||||
["summary why it matters", /- Why it matters:\s*\S+/m],
|
||||
["summary what changed", /- What changed:\s*\S+/m],
|
||||
["validation commands", /Commands and result summary:\s*[\s\S]*```/m],
|
||||
["security risk/mitigation", /- New permissions\/capabilities\?\s*\(`Yes\/No`\):\s*\S+/m],
|
||||
["privacy status", /- Data-hygiene status\s*\(`pass\|needs-follow-up`\):\s*\S+/m],
|
||||
["rollback plan", /- Fast rollback command\/path:\s*\S+/m],
|
||||
];
|
||||
for (const [name, pattern] of requiredFieldChecks) {
|
||||
if (!pattern.test(body)) {
|
||||
missingFields.push(name);
|
||||
}
|
||||
}
|
||||
|
||||
const files = await github.paginate(github.rest.pulls.listFiles, {
|
||||
owner,
|
||||
repo,
|
||||
pull_number: pr.number,
|
||||
per_page: 100,
|
||||
});
|
||||
|
||||
const formatWarnings = [];
|
||||
const dangerousProblems = [];
|
||||
for (const file of files) {
|
||||
const patch = file.patch || "";
|
||||
if (!patch) continue;
|
||||
const lines = patch.split("\n");
|
||||
for (let idx = 0; idx < lines.length; idx += 1) {
|
||||
const line = lines[idx];
|
||||
if (!line.startsWith("+") || line.startsWith("+++")) continue;
|
||||
const added = line.slice(1);
|
||||
const lineNo = idx + 1;
|
||||
if (/\t/.test(added)) {
|
||||
formatWarnings.push(`${file.filename}:patch#${lineNo} contains tab characters`);
|
||||
}
|
||||
if (/[ \t]+$/.test(added)) {
|
||||
formatWarnings.push(`${file.filename}:patch#${lineNo} contains trailing whitespace`);
|
||||
}
|
||||
if (/^(<<<<<<<|=======|>>>>>>>)/.test(added)) {
|
||||
dangerousProblems.push(`${file.filename}:patch#${lineNo} contains merge conflict markers`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const workflowFilesChanged = files
|
||||
.map((file) => file.filename)
|
||||
.filter((name) => name.startsWith(".github/workflows/"));
|
||||
|
||||
const advisoryFindings = [];
|
||||
const blockingFindings = [];
|
||||
if (missingSections.length > 0) {
|
||||
advisoryFindings.push(`Missing required PR template sections: ${missingSections.join(", ")}`);
|
||||
}
|
||||
if (missingFields.length > 0) {
|
||||
advisoryFindings.push(`Incomplete required PR template fields: ${missingFields.join(", ")}`);
|
||||
}
|
||||
if (formatWarnings.length > 0) {
|
||||
advisoryFindings.push(`Formatting issues in added lines (${formatWarnings.length})`);
|
||||
}
|
||||
if (dangerousProblems.length > 0) {
|
||||
blockingFindings.push(`Dangerous patch markers found (${dangerousProblems.length})`);
|
||||
}
|
||||
|
||||
const comments = await github.paginate(github.rest.issues.listComments, {
|
||||
owner,
|
||||
repo,
|
||||
issue_number: pr.number,
|
||||
per_page: 100,
|
||||
});
|
||||
const existing = comments.find((comment) => {
|
||||
const body = comment.body || "";
|
||||
return body.includes(marker) || body.includes(legacyMarker);
|
||||
});
|
||||
|
||||
if (advisoryFindings.length === 0 && blockingFindings.length === 0) {
|
||||
if (existing) {
|
||||
await github.rest.issues.deleteComment({
|
||||
owner,
|
||||
repo,
|
||||
comment_id: existing.id,
|
||||
});
|
||||
}
|
||||
core.info("PR intake sanity checks passed.");
|
||||
return;
|
||||
}
|
||||
|
||||
const runUrl = `${context.serverUrl}/${owner}/${repo}/actions/runs/${context.runId}`;
|
||||
const advisoryDetails = [];
|
||||
if (formatWarnings.length > 0) {
|
||||
advisoryDetails.push(...formatWarnings.slice(0, 20).map((entry) => `- ${entry}`));
|
||||
if (formatWarnings.length > 20) {
|
||||
advisoryDetails.push(`- ...and ${formatWarnings.length - 20} more issue(s)`);
|
||||
}
|
||||
}
|
||||
const blockingDetails = [];
|
||||
if (dangerousProblems.length > 0) {
|
||||
blockingDetails.push(...dangerousProblems.slice(0, 20).map((entry) => `- ${entry}`));
|
||||
if (dangerousProblems.length > 20) {
|
||||
blockingDetails.push(`- ...and ${dangerousProblems.length - 20} more issue(s)`);
|
||||
}
|
||||
}
|
||||
|
||||
const isBlocking = blockingFindings.length > 0;
|
||||
|
||||
const ownerApprovalNote = workflowFilesChanged.length > 0
|
||||
? [
|
||||
"",
|
||||
"Workflow files changed in this PR:",
|
||||
...workflowFilesChanged.map((name) => `- \`${name}\``),
|
||||
"",
|
||||
"Reminder: workflow changes require owner approval via `CI Required Gate`.",
|
||||
].join("\n")
|
||||
: "";
|
||||
|
||||
const commentBody = [
|
||||
marker,
|
||||
isBlocking
|
||||
? "### PR intake checks failed (blocking)"
|
||||
: "### PR intake checks found warnings (non-blocking)",
|
||||
"",
|
||||
isBlocking
|
||||
? "Fast safe checks found blocking safety issues:"
|
||||
: "Fast safe checks found advisory issues. CI lint/test/build gates still enforce merge quality.",
|
||||
...(blockingFindings.length > 0 ? blockingFindings.map((entry) => `- ${entry}`) : []),
|
||||
...(advisoryFindings.length > 0 ? advisoryFindings.map((entry) => `- ${entry}`) : []),
|
||||
"",
|
||||
"Action items:",
|
||||
"1. Complete required PR template sections/fields.",
|
||||
"2. Remove tabs, trailing whitespace, and merge conflict markers from added lines.",
|
||||
"3. Re-run local checks before pushing:",
|
||||
" - `./scripts/ci/rust_quality_gate.sh`",
|
||||
" - `./scripts/ci/rust_strict_delta_gate.sh`",
|
||||
" - `./scripts/ci/docs_quality_gate.sh`",
|
||||
"",
|
||||
`Run logs: ${runUrl}`,
|
||||
"",
|
||||
"Detected blocking line issues (sample):",
|
||||
...(blockingDetails.length > 0 ? blockingDetails : ["- none"]),
|
||||
"",
|
||||
"Detected advisory line issues (sample):",
|
||||
...(advisoryDetails.length > 0 ? advisoryDetails : ["- none"]),
|
||||
ownerApprovalNote,
|
||||
].join("\n");
|
||||
|
||||
if (existing) {
|
||||
await github.rest.issues.updateComment({
|
||||
owner,
|
||||
repo,
|
||||
comment_id: existing.id,
|
||||
body: commentBody,
|
||||
});
|
||||
} else {
|
||||
await github.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: pr.number,
|
||||
body: commentBody,
|
||||
});
|
||||
}
|
||||
|
||||
if (isBlocking) {
|
||||
core.setFailed("PR intake sanity checks found blocking issues. See sticky comment for details.");
|
||||
return;
|
||||
}
|
||||
|
||||
core.info("PR intake sanity checks found advisory issues only.");
|
||||
};
|
||||
803
.github/workflows/scripts/pr_labeler.js
vendored
Normal file
803
.github/workflows/scripts/pr_labeler.js
vendored
Normal file
|
|
@ -0,0 +1,803 @@
|
|||
// Apply managed PR labels (size/risk/path/module/contributor tiers).
|
||||
// Extracted from pr-labeler workflow inline github-script for maintainability.
|
||||
|
||||
module.exports = async ({ github, context, core }) => {
|
||||
const pr = context.payload.pull_request;
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
const action = context.payload.action;
|
||||
const changedLabel = context.payload.label?.name;
|
||||
|
||||
const sizeLabels = ["size: XS", "size: S", "size: M", "size: L", "size: XL"];
|
||||
const computedRiskLabels = ["risk: low", "risk: medium", "risk: high"];
|
||||
const manualRiskOverrideLabel = "risk: manual";
|
||||
const managedEnforcedLabels = new Set([
|
||||
...sizeLabels,
|
||||
manualRiskOverrideLabel,
|
||||
...computedRiskLabels,
|
||||
]);
|
||||
if ((action === "labeled" || action === "unlabeled") && !managedEnforcedLabels.has(changedLabel)) {
|
||||
core.info(`skip non-size/risk label event: ${changedLabel || "unknown"}`);
|
||||
return;
|
||||
}
|
||||
|
||||
async function loadContributorTierPolicy() {
|
||||
const fallback = {
|
||||
contributorTierColor: "2ED9FF",
|
||||
contributorTierRules: [
|
||||
{ label: "distinguished contributor", minMergedPRs: 50 },
|
||||
{ label: "principal contributor", minMergedPRs: 20 },
|
||||
{ label: "experienced contributor", minMergedPRs: 10 },
|
||||
{ label: "trusted contributor", minMergedPRs: 5 },
|
||||
],
|
||||
};
|
||||
try {
|
||||
const { data } = await github.rest.repos.getContent({
|
||||
owner,
|
||||
repo,
|
||||
path: ".github/label-policy.json",
|
||||
ref: context.payload.repository?.default_branch || "main",
|
||||
});
|
||||
const json = JSON.parse(Buffer.from(data.content, "base64").toString("utf8"));
|
||||
const contributorTierRules = (json.contributor_tiers || []).map((entry) => ({
|
||||
label: String(entry.label || "").trim(),
|
||||
minMergedPRs: Number(entry.min_merged_prs || 0),
|
||||
}));
|
||||
const contributorTierColor = String(json.contributor_tier_color || "").toUpperCase();
|
||||
if (!contributorTierColor || contributorTierRules.length === 0) {
|
||||
return fallback;
|
||||
}
|
||||
return { contributorTierColor, contributorTierRules };
|
||||
} catch (error) {
|
||||
core.warning(`failed to load .github/label-policy.json, using fallback policy: ${error.message}`);
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
const { contributorTierColor, contributorTierRules } = await loadContributorTierPolicy();
|
||||
const contributorTierLabels = contributorTierRules.map((rule) => rule.label);
|
||||
|
||||
const managedPathLabels = [
|
||||
"docs",
|
||||
"dependencies",
|
||||
"ci",
|
||||
"core",
|
||||
"agent",
|
||||
"channel",
|
||||
"config",
|
||||
"cron",
|
||||
"daemon",
|
||||
"doctor",
|
||||
"gateway",
|
||||
"health",
|
||||
"heartbeat",
|
||||
"integration",
|
||||
"memory",
|
||||
"observability",
|
||||
"onboard",
|
||||
"provider",
|
||||
"runtime",
|
||||
"security",
|
||||
"service",
|
||||
"skillforge",
|
||||
"skills",
|
||||
"tool",
|
||||
"tunnel",
|
||||
"tests",
|
||||
"scripts",
|
||||
"dev",
|
||||
];
|
||||
const managedPathLabelSet = new Set(managedPathLabels);
|
||||
|
||||
const moduleNamespaceRules = [
|
||||
{ root: "src/agent/", prefix: "agent", coreEntries: new Set(["mod.rs"]) },
|
||||
{ root: "src/channels/", prefix: "channel", coreEntries: new Set(["mod.rs", "traits.rs"]) },
|
||||
{ root: "src/config/", prefix: "config", coreEntries: new Set(["mod.rs", "schema.rs"]) },
|
||||
{ root: "src/cron/", prefix: "cron", coreEntries: new Set(["mod.rs"]) },
|
||||
{ root: "src/daemon/", prefix: "daemon", coreEntries: new Set(["mod.rs"]) },
|
||||
{ root: "src/doctor/", prefix: "doctor", coreEntries: new Set(["mod.rs"]) },
|
||||
{ root: "src/gateway/", prefix: "gateway", coreEntries: new Set(["mod.rs"]) },
|
||||
{ root: "src/health/", prefix: "health", coreEntries: new Set(["mod.rs"]) },
|
||||
{ root: "src/heartbeat/", prefix: "heartbeat", coreEntries: new Set(["mod.rs"]) },
|
||||
{ root: "src/integrations/", prefix: "integration", coreEntries: new Set(["mod.rs", "registry.rs"]) },
|
||||
{ root: "src/memory/", prefix: "memory", coreEntries: new Set(["mod.rs", "traits.rs"]) },
|
||||
{ root: "src/observability/", prefix: "observability", coreEntries: new Set(["mod.rs", "traits.rs"]) },
|
||||
{ root: "src/onboard/", prefix: "onboard", coreEntries: new Set(["mod.rs"]) },
|
||||
{ root: "src/providers/", prefix: "provider", coreEntries: new Set(["mod.rs", "traits.rs"]) },
|
||||
{ root: "src/runtime/", prefix: "runtime", coreEntries: new Set(["mod.rs", "traits.rs"]) },
|
||||
{ root: "src/security/", prefix: "security", coreEntries: new Set(["mod.rs"]) },
|
||||
{ root: "src/service/", prefix: "service", coreEntries: new Set(["mod.rs"]) },
|
||||
{ root: "src/skillforge/", prefix: "skillforge", coreEntries: new Set(["mod.rs"]) },
|
||||
{ root: "src/skills/", prefix: "skills", coreEntries: new Set(["mod.rs"]) },
|
||||
{ root: "src/tools/", prefix: "tool", coreEntries: new Set(["mod.rs", "traits.rs"]) },
|
||||
{ root: "src/tunnel/", prefix: "tunnel", coreEntries: new Set(["mod.rs"]) },
|
||||
];
|
||||
const managedModulePrefixes = [...new Set(moduleNamespaceRules.map((rule) => `${rule.prefix}:`))];
|
||||
const orderedOtherLabelStyles = [
|
||||
{ label: "health", color: "8EC9B8" },
|
||||
{ label: "tool", color: "7FC4B6" },
|
||||
{ label: "agent", color: "86C4A2" },
|
||||
{ label: "memory", color: "8FCB99" },
|
||||
{ label: "channel", color: "7EB6F2" },
|
||||
{ label: "service", color: "95C7B6" },
|
||||
{ label: "integration", color: "8DC9AE" },
|
||||
{ label: "tunnel", color: "9FC8B3" },
|
||||
{ label: "config", color: "AABCD0" },
|
||||
{ label: "observability", color: "84C9D0" },
|
||||
{ label: "docs", color: "8FBBE0" },
|
||||
{ label: "dev", color: "B9C1CC" },
|
||||
{ label: "tests", color: "9DC8C7" },
|
||||
{ label: "skills", color: "BFC89B" },
|
||||
{ label: "skillforge", color: "C9C39B" },
|
||||
{ label: "provider", color: "958DF0" },
|
||||
{ label: "runtime", color: "A3ADD8" },
|
||||
{ label: "heartbeat", color: "C0C88D" },
|
||||
{ label: "daemon", color: "C8C498" },
|
||||
{ label: "doctor", color: "C1CF9D" },
|
||||
{ label: "onboard", color: "D2BF86" },
|
||||
{ label: "cron", color: "D2B490" },
|
||||
{ label: "ci", color: "AEB4CE" },
|
||||
{ label: "dependencies", color: "9FB1DE" },
|
||||
{ label: "gateway", color: "B5A8E5" },
|
||||
{ label: "security", color: "E58D85" },
|
||||
{ label: "core", color: "C8A99B" },
|
||||
{ label: "scripts", color: "C9B49F" },
|
||||
];
|
||||
const otherLabelDisplayOrder = orderedOtherLabelStyles.map((entry) => entry.label);
|
||||
const modulePrefixSet = new Set(moduleNamespaceRules.map((rule) => rule.prefix));
|
||||
const modulePrefixPriority = otherLabelDisplayOrder.filter((label) => modulePrefixSet.has(label));
|
||||
const pathLabelPriority = [...otherLabelDisplayOrder];
|
||||
const riskDisplayOrder = ["risk: high", "risk: medium", "risk: low", "risk: manual"];
|
||||
const sizeDisplayOrder = ["size: XS", "size: S", "size: M", "size: L", "size: XL"];
|
||||
const contributorDisplayOrder = [
|
||||
"distinguished contributor",
|
||||
"principal contributor",
|
||||
"experienced contributor",
|
||||
"trusted contributor",
|
||||
];
|
||||
const modulePrefixPriorityIndex = new Map(
|
||||
modulePrefixPriority.map((prefix, index) => [prefix, index])
|
||||
);
|
||||
const pathLabelPriorityIndex = new Map(
|
||||
pathLabelPriority.map((label, index) => [label, index])
|
||||
);
|
||||
const riskPriorityIndex = new Map(
|
||||
riskDisplayOrder.map((label, index) => [label, index])
|
||||
);
|
||||
const sizePriorityIndex = new Map(
|
||||
sizeDisplayOrder.map((label, index) => [label, index])
|
||||
);
|
||||
const contributorPriorityIndex = new Map(
|
||||
contributorDisplayOrder.map((label, index) => [label, index])
|
||||
);
|
||||
|
||||
const otherLabelColors = Object.fromEntries(
|
||||
orderedOtherLabelStyles.map((entry) => [entry.label, entry.color])
|
||||
);
|
||||
const staticLabelColors = {
|
||||
"size: XS": "E7CDD3",
|
||||
"size: S": "E1BEC7",
|
||||
"size: M": "DBB0BB",
|
||||
"size: L": "D4A2AF",
|
||||
"size: XL": "CE94A4",
|
||||
"risk: low": "97D3A6",
|
||||
"risk: medium": "E4C47B",
|
||||
"risk: high": "E98E88",
|
||||
"risk: manual": "B7A4E0",
|
||||
...otherLabelColors,
|
||||
};
|
||||
const staticLabelDescriptions = {
|
||||
"size: XS": "Auto size: <=80 non-doc changed lines.",
|
||||
"size: S": "Auto size: 81-250 non-doc changed lines.",
|
||||
"size: M": "Auto size: 251-500 non-doc changed lines.",
|
||||
"size: L": "Auto size: 501-1000 non-doc changed lines.",
|
||||
"size: XL": "Auto size: >1000 non-doc changed lines.",
|
||||
"risk: low": "Auto risk: docs/chore-only paths.",
|
||||
"risk: medium": "Auto risk: src/** or dependency/config changes.",
|
||||
"risk: high": "Auto risk: security/runtime/gateway/tools/workflows.",
|
||||
"risk: manual": "Maintainer override: keep selected risk label.",
|
||||
docs: "Auto scope: docs/markdown/template files changed.",
|
||||
dependencies: "Auto scope: dependency manifest/lock/policy changed.",
|
||||
ci: "Auto scope: CI/workflow/hook files changed.",
|
||||
core: "Auto scope: root src/*.rs files changed.",
|
||||
agent: "Auto scope: src/agent/** changed.",
|
||||
channel: "Auto scope: src/channels/** changed.",
|
||||
config: "Auto scope: src/config/** changed.",
|
||||
cron: "Auto scope: src/cron/** changed.",
|
||||
daemon: "Auto scope: src/daemon/** changed.",
|
||||
doctor: "Auto scope: src/doctor/** changed.",
|
||||
gateway: "Auto scope: src/gateway/** changed.",
|
||||
health: "Auto scope: src/health/** changed.",
|
||||
heartbeat: "Auto scope: src/heartbeat/** changed.",
|
||||
integration: "Auto scope: src/integrations/** changed.",
|
||||
memory: "Auto scope: src/memory/** changed.",
|
||||
observability: "Auto scope: src/observability/** changed.",
|
||||
onboard: "Auto scope: src/onboard/** changed.",
|
||||
provider: "Auto scope: src/providers/** changed.",
|
||||
runtime: "Auto scope: src/runtime/** changed.",
|
||||
security: "Auto scope: src/security/** changed.",
|
||||
service: "Auto scope: src/service/** changed.",
|
||||
skillforge: "Auto scope: src/skillforge/** changed.",
|
||||
skills: "Auto scope: src/skills/** changed.",
|
||||
tool: "Auto scope: src/tools/** changed.",
|
||||
tunnel: "Auto scope: src/tunnel/** changed.",
|
||||
tests: "Auto scope: tests/** changed.",
|
||||
scripts: "Auto scope: scripts/** changed.",
|
||||
dev: "Auto scope: dev/** changed.",
|
||||
};
|
||||
for (const label of contributorTierLabels) {
|
||||
staticLabelColors[label] = contributorTierColor;
|
||||
const rule = contributorTierRules.find((entry) => entry.label === label);
|
||||
if (rule) {
|
||||
staticLabelDescriptions[label] = `Contributor with ${rule.minMergedPRs}+ merged PRs.`;
|
||||
}
|
||||
}
|
||||
|
||||
const modulePrefixColors = Object.fromEntries(
|
||||
modulePrefixPriority.map((prefix) => [
|
||||
`${prefix}:`,
|
||||
otherLabelColors[prefix] || "BFDADC",
|
||||
])
|
||||
);
|
||||
|
||||
const providerKeywordHints = [
|
||||
"deepseek",
|
||||
"moonshot",
|
||||
"kimi",
|
||||
"qwen",
|
||||
"mistral",
|
||||
"doubao",
|
||||
"baichuan",
|
||||
"yi",
|
||||
"siliconflow",
|
||||
"vertex",
|
||||
"azure",
|
||||
"perplexity",
|
||||
"venice",
|
||||
"vercel",
|
||||
"cloudflare",
|
||||
"synthetic",
|
||||
"opencode",
|
||||
"zai",
|
||||
"glm",
|
||||
"minimax",
|
||||
"bedrock",
|
||||
"qianfan",
|
||||
"groq",
|
||||
"together",
|
||||
"fireworks",
|
||||
"cohere",
|
||||
"openai",
|
||||
"openrouter",
|
||||
"anthropic",
|
||||
"gemini",
|
||||
"ollama",
|
||||
];
|
||||
|
||||
const channelKeywordHints = [
|
||||
"telegram",
|
||||
"discord",
|
||||
"slack",
|
||||
"whatsapp",
|
||||
"matrix",
|
||||
"irc",
|
||||
"imessage",
|
||||
"email",
|
||||
"cli",
|
||||
];
|
||||
|
||||
function isDocsLike(path) {
|
||||
return (
|
||||
path.startsWith("docs/") ||
|
||||
path.endsWith(".md") ||
|
||||
path.endsWith(".mdx") ||
|
||||
path === "LICENSE" ||
|
||||
path === ".markdownlint-cli2.yaml" ||
|
||||
path === ".github/pull_request_template.md" ||
|
||||
path.startsWith(".github/ISSUE_TEMPLATE/")
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeLabelSegment(segment) {
|
||||
return (segment || "")
|
||||
.toLowerCase()
|
||||
.replace(/\.rs$/g, "")
|
||||
.replace(/[^a-z0-9_-]+/g, "-")
|
||||
.replace(/^[-_]+|[-_]+$/g, "")
|
||||
.slice(0, 40);
|
||||
}
|
||||
|
||||
function containsKeyword(text, keyword) {
|
||||
const escaped = keyword.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const pattern = new RegExp(`(^|[^a-z0-9_])${escaped}([^a-z0-9_]|$)`, "i");
|
||||
return pattern.test(text);
|
||||
}
|
||||
|
||||
function formatModuleLabel(prefix, segment) {
|
||||
return `${prefix}: ${segment}`;
|
||||
}
|
||||
|
||||
function parseModuleLabel(label) {
|
||||
if (typeof label !== "string") return null;
|
||||
const match = label.match(/^([^:]+):\s*(.+)$/);
|
||||
if (!match) return null;
|
||||
const prefix = match[1].trim().toLowerCase();
|
||||
const segment = (match[2] || "").trim().toLowerCase();
|
||||
if (!prefix || !segment) return null;
|
||||
return { prefix, segment };
|
||||
}
|
||||
|
||||
function sortByPriority(labels, priorityIndex) {
|
||||
return [...new Set(labels)].sort((left, right) => {
|
||||
const leftPriority = priorityIndex.has(left) ? priorityIndex.get(left) : Number.MAX_SAFE_INTEGER;
|
||||
const rightPriority = priorityIndex.has(right)
|
||||
? priorityIndex.get(right)
|
||||
: Number.MAX_SAFE_INTEGER;
|
||||
if (leftPriority !== rightPriority) return leftPriority - rightPriority;
|
||||
return left.localeCompare(right);
|
||||
});
|
||||
}
|
||||
|
||||
function sortModuleLabels(labels) {
|
||||
return [...new Set(labels)].sort((left, right) => {
|
||||
const leftParsed = parseModuleLabel(left);
|
||||
const rightParsed = parseModuleLabel(right);
|
||||
if (!leftParsed || !rightParsed) return left.localeCompare(right);
|
||||
|
||||
const leftPrefixPriority = modulePrefixPriorityIndex.has(leftParsed.prefix)
|
||||
? modulePrefixPriorityIndex.get(leftParsed.prefix)
|
||||
: Number.MAX_SAFE_INTEGER;
|
||||
const rightPrefixPriority = modulePrefixPriorityIndex.has(rightParsed.prefix)
|
||||
? modulePrefixPriorityIndex.get(rightParsed.prefix)
|
||||
: Number.MAX_SAFE_INTEGER;
|
||||
|
||||
if (leftPrefixPriority !== rightPrefixPriority) {
|
||||
return leftPrefixPriority - rightPrefixPriority;
|
||||
}
|
||||
if (leftParsed.prefix !== rightParsed.prefix) {
|
||||
return leftParsed.prefix.localeCompare(rightParsed.prefix);
|
||||
}
|
||||
|
||||
const leftIsCore = leftParsed.segment === "core";
|
||||
const rightIsCore = rightParsed.segment === "core";
|
||||
if (leftIsCore !== rightIsCore) return leftIsCore ? 1 : -1;
|
||||
|
||||
return leftParsed.segment.localeCompare(rightParsed.segment);
|
||||
});
|
||||
}
|
||||
|
||||
function refineModuleLabels(rawLabels) {
|
||||
const refined = new Set(rawLabels);
|
||||
const segmentsByPrefix = new Map();
|
||||
|
||||
for (const label of rawLabels) {
|
||||
const parsed = parseModuleLabel(label);
|
||||
if (!parsed) continue;
|
||||
if (!segmentsByPrefix.has(parsed.prefix)) {
|
||||
segmentsByPrefix.set(parsed.prefix, new Set());
|
||||
}
|
||||
segmentsByPrefix.get(parsed.prefix).add(parsed.segment);
|
||||
}
|
||||
|
||||
for (const [prefix, segments] of segmentsByPrefix) {
|
||||
const hasSpecificSegment = [...segments].some((segment) => segment !== "core");
|
||||
if (hasSpecificSegment) {
|
||||
refined.delete(formatModuleLabel(prefix, "core"));
|
||||
}
|
||||
}
|
||||
|
||||
return refined;
|
||||
}
|
||||
|
||||
function compactModuleLabels(labels) {
|
||||
const groupedSegments = new Map();
|
||||
const compactedModuleLabels = new Set();
|
||||
const forcePathPrefixes = new Set();
|
||||
|
||||
for (const label of labels) {
|
||||
const parsed = parseModuleLabel(label);
|
||||
if (!parsed) {
|
||||
compactedModuleLabels.add(label);
|
||||
continue;
|
||||
}
|
||||
if (!groupedSegments.has(parsed.prefix)) {
|
||||
groupedSegments.set(parsed.prefix, new Set());
|
||||
}
|
||||
groupedSegments.get(parsed.prefix).add(parsed.segment);
|
||||
}
|
||||
|
||||
for (const [prefix, segments] of groupedSegments) {
|
||||
const uniqueSegments = [...new Set([...segments].filter(Boolean))];
|
||||
if (uniqueSegments.length === 0) continue;
|
||||
|
||||
if (uniqueSegments.length === 1) {
|
||||
compactedModuleLabels.add(formatModuleLabel(prefix, uniqueSegments[0]));
|
||||
} else {
|
||||
forcePathPrefixes.add(prefix);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
moduleLabels: compactedModuleLabels,
|
||||
forcePathPrefixes,
|
||||
};
|
||||
}
|
||||
|
||||
function colorForLabel(label) {
|
||||
if (staticLabelColors[label]) return staticLabelColors[label];
|
||||
const matchedPrefix = Object.keys(modulePrefixColors).find((prefix) => label.startsWith(prefix));
|
||||
if (matchedPrefix) return modulePrefixColors[matchedPrefix];
|
||||
return "BFDADC";
|
||||
}
|
||||
|
||||
function descriptionForLabel(label) {
|
||||
if (staticLabelDescriptions[label]) return staticLabelDescriptions[label];
|
||||
|
||||
const parsed = parseModuleLabel(label);
|
||||
if (parsed) {
|
||||
if (parsed.segment === "core") {
|
||||
return `Auto module: ${parsed.prefix} core files changed.`;
|
||||
}
|
||||
return `Auto module: ${parsed.prefix}/${parsed.segment} changed.`;
|
||||
}
|
||||
|
||||
return "Auto-managed label.";
|
||||
}
|
||||
|
||||
async function ensureLabel(name, existing = null) {
|
||||
const expectedColor = colorForLabel(name);
|
||||
const expectedDescription = descriptionForLabel(name);
|
||||
try {
|
||||
const current = existing || (await github.rest.issues.getLabel({ owner, repo, name })).data;
|
||||
const currentColor = (current.color || "").toUpperCase();
|
||||
const currentDescription = (current.description || "").trim();
|
||||
if (currentColor !== expectedColor || currentDescription !== expectedDescription) {
|
||||
await github.rest.issues.updateLabel({
|
||||
owner,
|
||||
repo,
|
||||
name,
|
||||
new_name: name,
|
||||
color: expectedColor,
|
||||
description: expectedDescription,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.status !== 404) throw error;
|
||||
await github.rest.issues.createLabel({
|
||||
owner,
|
||||
repo,
|
||||
name,
|
||||
color: expectedColor,
|
||||
description: expectedDescription,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function isManagedLabel(label) {
|
||||
if (label === manualRiskOverrideLabel) return true;
|
||||
if (sizeLabels.includes(label) || computedRiskLabels.includes(label)) return true;
|
||||
if (managedPathLabelSet.has(label)) return true;
|
||||
if (contributorTierLabels.includes(label)) return true;
|
||||
if (managedModulePrefixes.some((prefix) => label.startsWith(prefix))) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
async function ensureManagedRepoLabelsMetadata() {
|
||||
const repoLabels = await github.paginate(github.rest.issues.listLabelsForRepo, {
|
||||
owner,
|
||||
repo,
|
||||
per_page: 100,
|
||||
});
|
||||
|
||||
for (const existingLabel of repoLabels) {
|
||||
const labelName = existingLabel.name || "";
|
||||
if (!isManagedLabel(labelName)) continue;
|
||||
await ensureLabel(labelName, existingLabel);
|
||||
}
|
||||
}
|
||||
|
||||
function selectContributorTier(mergedCount) {
|
||||
const matchedTier = contributorTierRules.find((rule) => mergedCount >= rule.minMergedPRs);
|
||||
return matchedTier ? matchedTier.label : null;
|
||||
}
|
||||
|
||||
if (context.eventName === "workflow_dispatch") {
|
||||
const mode = (context.payload.inputs?.mode || "audit").toLowerCase();
|
||||
const shouldRepair = mode === "repair";
|
||||
const repoLabels = await github.paginate(github.rest.issues.listLabelsForRepo, {
|
||||
owner,
|
||||
repo,
|
||||
per_page: 100,
|
||||
});
|
||||
|
||||
let managedScanned = 0;
|
||||
const drifts = [];
|
||||
|
||||
for (const existingLabel of repoLabels) {
|
||||
const labelName = existingLabel.name || "";
|
||||
if (!isManagedLabel(labelName)) continue;
|
||||
managedScanned += 1;
|
||||
|
||||
const expectedColor = colorForLabel(labelName);
|
||||
const expectedDescription = descriptionForLabel(labelName);
|
||||
const currentColor = (existingLabel.color || "").toUpperCase();
|
||||
const currentDescription = (existingLabel.description || "").trim();
|
||||
if (currentColor !== expectedColor || currentDescription !== expectedDescription) {
|
||||
drifts.push({
|
||||
name: labelName,
|
||||
currentColor,
|
||||
expectedColor,
|
||||
currentDescription,
|
||||
expectedDescription,
|
||||
});
|
||||
if (shouldRepair) {
|
||||
await ensureLabel(labelName, existingLabel);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
core.summary
|
||||
.addHeading("Managed Label Governance", 2)
|
||||
.addRaw(`Mode: ${shouldRepair ? "repair" : "audit"}`)
|
||||
.addEOL()
|
||||
.addRaw(`Managed labels scanned: ${managedScanned}`)
|
||||
.addEOL()
|
||||
.addRaw(`Drifts found: ${drifts.length}`)
|
||||
.addEOL();
|
||||
|
||||
if (drifts.length > 0) {
|
||||
const sample = drifts.slice(0, 30).map((entry) => [
|
||||
entry.name,
|
||||
`${entry.currentColor} -> ${entry.expectedColor}`,
|
||||
`${entry.currentDescription || "(blank)"} -> ${entry.expectedDescription}`,
|
||||
]);
|
||||
core.summary.addTable([
|
||||
[{ data: "Label", header: true }, { data: "Color", header: true }, { data: "Description", header: true }],
|
||||
...sample,
|
||||
]);
|
||||
if (drifts.length > sample.length) {
|
||||
core.summary
|
||||
.addRaw(`Additional drifts not shown: ${drifts.length - sample.length}`)
|
||||
.addEOL();
|
||||
}
|
||||
}
|
||||
|
||||
await core.summary.write();
|
||||
|
||||
if (!shouldRepair && drifts.length > 0) {
|
||||
core.info(`Managed-label metadata drifts detected: ${drifts.length}. Re-run with mode=repair to auto-fix.`);
|
||||
} else if (shouldRepair) {
|
||||
core.info(`Managed-label metadata repair applied to ${drifts.length} labels.`);
|
||||
} else {
|
||||
core.info("No managed-label metadata drift detected.");
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const files = await github.paginate(github.rest.pulls.listFiles, {
|
||||
owner,
|
||||
repo,
|
||||
pull_number: pr.number,
|
||||
per_page: 100,
|
||||
});
|
||||
|
||||
const detectedModuleLabels = new Set();
|
||||
for (const file of files) {
|
||||
const path = (file.filename || "").toLowerCase();
|
||||
for (const rule of moduleNamespaceRules) {
|
||||
if (!path.startsWith(rule.root)) continue;
|
||||
|
||||
const relative = path.slice(rule.root.length);
|
||||
if (!relative) continue;
|
||||
|
||||
const first = relative.split("/")[0];
|
||||
const firstStem = first.endsWith(".rs") ? first.slice(0, -3) : first;
|
||||
let segment = firstStem;
|
||||
|
||||
if (rule.coreEntries.has(first) || rule.coreEntries.has(firstStem)) {
|
||||
segment = "core";
|
||||
}
|
||||
|
||||
segment = normalizeLabelSegment(segment);
|
||||
if (!segment) continue;
|
||||
|
||||
detectedModuleLabels.add(formatModuleLabel(rule.prefix, segment));
|
||||
}
|
||||
}
|
||||
|
||||
const providerRelevantFiles = files.filter((file) => {
|
||||
const path = file.filename || "";
|
||||
return (
|
||||
path.startsWith("src/providers/") ||
|
||||
path.startsWith("src/integrations/") ||
|
||||
path.startsWith("src/onboard/") ||
|
||||
path.startsWith("src/config/")
|
||||
);
|
||||
});
|
||||
|
||||
if (providerRelevantFiles.length > 0) {
|
||||
const searchableText = [
|
||||
pr.title || "",
|
||||
pr.body || "",
|
||||
...providerRelevantFiles.map((file) => file.filename || ""),
|
||||
...providerRelevantFiles.map((file) => file.patch || ""),
|
||||
]
|
||||
.join("\n")
|
||||
.toLowerCase();
|
||||
|
||||
for (const keyword of providerKeywordHints) {
|
||||
if (containsKeyword(searchableText, keyword)) {
|
||||
detectedModuleLabels.add(formatModuleLabel("provider", keyword));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const channelRelevantFiles = files.filter((file) => {
|
||||
const path = file.filename || "";
|
||||
return (
|
||||
path.startsWith("src/channels/") ||
|
||||
path.startsWith("src/onboard/") ||
|
||||
path.startsWith("src/config/")
|
||||
);
|
||||
});
|
||||
|
||||
if (channelRelevantFiles.length > 0) {
|
||||
const searchableText = [
|
||||
pr.title || "",
|
||||
pr.body || "",
|
||||
...channelRelevantFiles.map((file) => file.filename || ""),
|
||||
...channelRelevantFiles.map((file) => file.patch || ""),
|
||||
]
|
||||
.join("\n")
|
||||
.toLowerCase();
|
||||
|
||||
for (const keyword of channelKeywordHints) {
|
||||
if (containsKeyword(searchableText, keyword)) {
|
||||
detectedModuleLabels.add(formatModuleLabel("channel", keyword));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const refinedModuleLabels = refineModuleLabels(detectedModuleLabels);
|
||||
const compactedModuleState = compactModuleLabels(refinedModuleLabels);
|
||||
const selectedModuleLabels = compactedModuleState.moduleLabels;
|
||||
const forcePathPrefixes = compactedModuleState.forcePathPrefixes;
|
||||
const modulePrefixesWithLabels = new Set(
|
||||
[...selectedModuleLabels]
|
||||
.map((label) => parseModuleLabel(label)?.prefix)
|
||||
.filter(Boolean)
|
||||
);
|
||||
|
||||
const { data: currentLabels } = await github.rest.issues.listLabelsOnIssue({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: pr.number,
|
||||
});
|
||||
const currentLabelNames = currentLabels.map((label) => label.name);
|
||||
const currentPathLabels = currentLabelNames.filter((label) => managedPathLabelSet.has(label));
|
||||
const candidatePathLabels = new Set([...currentPathLabels, ...forcePathPrefixes]);
|
||||
|
||||
const dedupedPathLabels = [...candidatePathLabels].filter((label) => {
|
||||
if (label === "core") return true;
|
||||
if (forcePathPrefixes.has(label)) return true;
|
||||
return !modulePrefixesWithLabels.has(label);
|
||||
});
|
||||
|
||||
const excludedLockfiles = new Set(["Cargo.lock"]);
|
||||
const changedLines = files.reduce((total, file) => {
|
||||
const path = file.filename || "";
|
||||
if (isDocsLike(path) || excludedLockfiles.has(path)) {
|
||||
return total;
|
||||
}
|
||||
return total + (file.additions || 0) + (file.deletions || 0);
|
||||
}, 0);
|
||||
|
||||
let sizeLabel = "size: XL";
|
||||
if (changedLines <= 80) sizeLabel = "size: XS";
|
||||
else if (changedLines <= 250) sizeLabel = "size: S";
|
||||
else if (changedLines <= 500) sizeLabel = "size: M";
|
||||
else if (changedLines <= 1000) sizeLabel = "size: L";
|
||||
|
||||
const hasHighRiskPath = files.some((file) => {
|
||||
const path = file.filename || "";
|
||||
return (
|
||||
path.startsWith("src/security/") ||
|
||||
path.startsWith("src/runtime/") ||
|
||||
path.startsWith("src/gateway/") ||
|
||||
path.startsWith("src/tools/") ||
|
||||
path.startsWith(".github/workflows/")
|
||||
);
|
||||
});
|
||||
|
||||
const hasMediumRiskPath = files.some((file) => {
|
||||
const path = file.filename || "";
|
||||
return (
|
||||
path.startsWith("src/") ||
|
||||
path === "Cargo.toml" ||
|
||||
path === "Cargo.lock" ||
|
||||
path === "deny.toml" ||
|
||||
path.startsWith(".githooks/")
|
||||
);
|
||||
});
|
||||
|
||||
let riskLabel = "risk: low";
|
||||
if (hasHighRiskPath) {
|
||||
riskLabel = "risk: high";
|
||||
} else if (hasMediumRiskPath) {
|
||||
riskLabel = "risk: medium";
|
||||
}
|
||||
|
||||
await ensureManagedRepoLabelsMetadata();
|
||||
|
||||
const labelsToEnsure = new Set([
|
||||
...sizeLabels,
|
||||
...computedRiskLabels,
|
||||
manualRiskOverrideLabel,
|
||||
...managedPathLabels,
|
||||
...contributorTierLabels,
|
||||
...selectedModuleLabels,
|
||||
]);
|
||||
|
||||
for (const label of labelsToEnsure) {
|
||||
await ensureLabel(label);
|
||||
}
|
||||
|
||||
let contributorTierLabel = null;
|
||||
const authorLogin = pr.user?.login;
|
||||
if (authorLogin && pr.user?.type !== "Bot") {
|
||||
try {
|
||||
const { data: mergedSearch } = await github.rest.search.issuesAndPullRequests({
|
||||
q: `repo:${owner}/${repo} is:pr is:merged author:${authorLogin}`,
|
||||
per_page: 1,
|
||||
});
|
||||
const mergedCount = mergedSearch.total_count || 0;
|
||||
contributorTierLabel = selectContributorTier(mergedCount);
|
||||
} catch (error) {
|
||||
core.warning(`failed to compute contributor tier label: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
const hasManualRiskOverride = currentLabelNames.includes(manualRiskOverrideLabel);
|
||||
const keepNonManagedLabels = currentLabelNames.filter((label) => {
|
||||
if (label === manualRiskOverrideLabel) return true;
|
||||
if (contributorTierLabels.includes(label)) return false;
|
||||
if (sizeLabels.includes(label) || computedRiskLabels.includes(label)) return false;
|
||||
if (managedPathLabelSet.has(label)) return false;
|
||||
if (managedModulePrefixes.some((prefix) => label.startsWith(prefix))) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
const manualRiskSelection =
|
||||
currentLabelNames.find((label) => computedRiskLabels.includes(label)) || riskLabel;
|
||||
|
||||
const moduleLabelList = sortModuleLabels([...selectedModuleLabels]);
|
||||
const contributorLabelList = contributorTierLabel ? [contributorTierLabel] : [];
|
||||
const selectedRiskLabels = hasManualRiskOverride
|
||||
? sortByPriority([manualRiskSelection, manualRiskOverrideLabel], riskPriorityIndex)
|
||||
: sortByPriority([riskLabel], riskPriorityIndex);
|
||||
const selectedSizeLabels = sortByPriority([sizeLabel], sizePriorityIndex);
|
||||
const sortedContributorLabels = sortByPriority(contributorLabelList, contributorPriorityIndex);
|
||||
const sortedPathLabels = sortByPriority(dedupedPathLabels, pathLabelPriorityIndex);
|
||||
const sortedKeepNonManagedLabels = [...new Set(keepNonManagedLabels)].sort((left, right) =>
|
||||
left.localeCompare(right)
|
||||
);
|
||||
|
||||
const nextLabels = [
|
||||
...new Set([
|
||||
...selectedRiskLabels,
|
||||
...selectedSizeLabels,
|
||||
...sortedContributorLabels,
|
||||
...moduleLabelList,
|
||||
...sortedPathLabels,
|
||||
...sortedKeepNonManagedLabels,
|
||||
]),
|
||||
];
|
||||
|
||||
await github.rest.issues.setLabels({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: pr.number,
|
||||
labels: nextLabels,
|
||||
});
|
||||
};
|
||||
57
.github/workflows/scripts/test_benchmarks_pr_comment.js
vendored
Normal file
57
.github/workflows/scripts/test_benchmarks_pr_comment.js
vendored
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
// Extracted from test-benchmarks.yml step: Post benchmark summary on PR
|
||||
|
||||
module.exports = async ({ github, context, core }) => {
|
||||
const fs = require('fs');
|
||||
const output = fs.readFileSync('benchmark_output.txt', 'utf8');
|
||||
|
||||
// Extract Criterion result lines
|
||||
const lines = output.split('\n').filter(l =>
|
||||
l.includes('time:') || l.includes('change:') || l.includes('Performance')
|
||||
);
|
||||
|
||||
if (lines.length === 0) {
|
||||
core.info('No benchmark results to post.');
|
||||
return;
|
||||
}
|
||||
|
||||
const body = [
|
||||
'## 📊 Benchmark Results',
|
||||
'',
|
||||
'```',
|
||||
lines.join('\n'),
|
||||
'```',
|
||||
'',
|
||||
'<details><summary>Full output</summary>',
|
||||
'',
|
||||
'```',
|
||||
output.substring(0, 60000),
|
||||
'```',
|
||||
'</details>',
|
||||
].join('\n');
|
||||
|
||||
// Find and update or create comment
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.payload.pull_request.number,
|
||||
});
|
||||
|
||||
const marker = '## 📊 Benchmark Results';
|
||||
const existing = comments.find(c => c.body && c.body.startsWith(marker));
|
||||
|
||||
if (existing) {
|
||||
await github.rest.issues.updateComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
comment_id: existing.id,
|
||||
body,
|
||||
});
|
||||
} else {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.payload.pull_request.number,
|
||||
body,
|
||||
});
|
||||
}
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue