chore(ci): add PR hygiene nudge automation (#278)
This commit is contained in:
parent
ce7f811c0f
commit
9428d3ab74
3 changed files with 188 additions and 0 deletions
184
.github/workflows/pr-hygiene.yml
vendored
Normal file
184
.github/workflows/pr-hygiene.yml
vendored
Normal 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}`);
|
||||
|
|
@ -32,6 +32,8 @@ Merge-blocking checks should stay small and deterministic. Optional checks are u
|
|||
- Purpose: first-time contributor onboarding messages
|
||||
- `.github/workflows/stale.yml` (`Stale`)
|
||||
- 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
|
||||
|
||||
|
|
@ -43,6 +45,7 @@ Merge-blocking checks should stay small and deterministic. Optional checks are u
|
|||
- `PR Labeler`: `pull_request_target` lifecycle events
|
||||
- `Auto Response`: issue opened, `pull_request_target` opened
|
||||
- `Stale`: daily schedule, manual dispatch
|
||||
- `PR Hygiene`: every 12 hours schedule, manual dispatch
|
||||
|
||||
## Fast Triage Guide
|
||||
|
||||
|
|
|
|||
|
|
@ -98,6 +98,7 @@ Review emphasis for AI-heavy PRs:
|
|||
- First maintainer triage target: within 48 hours.
|
||||
- If PR is blocked, maintainer leaves one actionable checklist.
|
||||
- `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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue