chore(ci): add PR hygiene nudge automation (#278)

This commit is contained in:
Chummy 2026-02-16 14:57:45 +08:00 committed by GitHub
parent ce7f811c0f
commit 9428d3ab74
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 188 additions and 0 deletions

184
.github/workflows/pr-hygiene.yml vendored Normal file
View file

@ -0,0 +1,184 @@
name: PR Hygiene
on:
schedule:
- cron: "15 */12 * * *"
workflow_dispatch:
permissions: {}
concurrency:
group: pr-hygiene
cancel-in-progress: true
jobs:
nudge-stale-prs:
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
issues: write
env:
STALE_HOURS: "48"
steps:
- name: Nudge PRs that need rebase or CI refresh
uses: actions/github-script@v7
with:
script: |
const staleHours = Number(process.env.STALE_HOURS || "48");
const ignoreLabels = new Set(["no-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}`);

View file

@ -32,6 +32,8 @@ Merge-blocking checks should stay small and deterministic. Optional checks are u
- Purpose: first-time contributor onboarding messages - Purpose: first-time contributor onboarding messages
- `.github/workflows/stale.yml` (`Stale`) - `.github/workflows/stale.yml` (`Stale`)
- Purpose: stale issue/PR lifecycle automation - Purpose: stale issue/PR lifecycle automation
- `.github/workflows/pr-hygiene.yml` (`PR Hygiene`)
- Purpose: nudge stale-but-active PRs to rebase/re-run required checks before queue starvation
## Trigger Map ## Trigger Map
@ -43,6 +45,7 @@ Merge-blocking checks should stay small and deterministic. Optional checks are u
- `PR Labeler`: `pull_request_target` lifecycle events - `PR Labeler`: `pull_request_target` lifecycle events
- `Auto Response`: issue opened, `pull_request_target` opened - `Auto Response`: issue opened, `pull_request_target` opened
- `Stale`: daily schedule, manual dispatch - `Stale`: daily schedule, manual dispatch
- `PR Hygiene`: every 12 hours schedule, manual dispatch
## Fast Triage Guide ## Fast Triage Guide

View file

@ -98,6 +98,7 @@ Review emphasis for AI-heavy PRs:
- First maintainer triage target: within 48 hours. - First maintainer triage target: within 48 hours.
- If PR is blocked, maintainer leaves one actionable checklist. - If PR is blocked, maintainer leaves one actionable checklist.
- `stale` automation is used to keep queue healthy; maintainers can apply `no-stale` when needed. - `stale` automation is used to keep queue healthy; maintainers can apply `no-stale` when needed.
- `pr-hygiene` automation checks open PRs every 12 hours and posts a nudge when a PR has no new commits for 48+ hours and is either behind `main` or missing/failing `CI Required Gate` on the head commit.
## 7) Security and Stability Rules ## 7) Security and Stability Rules