From 9428d3ab748b8420732f0eaa4d3f9e25c8be2f4b Mon Sep 17 00:00:00 2001 From: Chummy Date: Mon, 16 Feb 2026 14:57:45 +0800 Subject: [PATCH] chore(ci): add PR hygiene nudge automation (#278) --- .github/workflows/pr-hygiene.yml | 184 +++++++++++++++++++++++++++++++ docs/ci-map.md | 3 + docs/pr-workflow.md | 1 + 3 files changed, 188 insertions(+) create mode 100644 .github/workflows/pr-hygiene.yml diff --git a/.github/workflows/pr-hygiene.yml b/.github/workflows/pr-hygiene.yml new file mode 100644 index 0000000..0fa716d --- /dev/null +++ b/.github/workflows/pr-hygiene.yml @@ -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 = ""; + 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}`); diff --git a/docs/ci-map.md b/docs/ci-map.md index 375ffa6..520a4a0 100644 --- a/docs/ci-map.md +++ b/docs/ci-map.md @@ -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 diff --git a/docs/pr-workflow.md b/docs/pr-workflow.md index a766868..ee80725 100644 --- a/docs/pr-workflow.md +++ b/docs/pr-workflow.md @@ -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