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: blacksmith-2vcpu-ubuntu-2404
    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@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
        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}`);
