name: PR Sweep

on:
  schedule:
    # Every 6 hours
    - cron: "0 */6 * * *"
  workflow_dispatch:

permissions:
  contents: read
  pull-requests: write

jobs:
  sweep:
    name: Sweep Open PRs
    runs-on: ubuntu-latest
    timeout-minutes: 10
    steps:
      - name: Sweep PRs
        uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
        with:
          script: |
            const owner = context.repo.owner;
            const repo = context.repo.repo;
            const now = new Date();
            const DAY = 24 * 60 * 60 * 1000;

            // Label color map for auto-creation
            const labelColors = {
              'needs-approval': 'fbca04',
              'needs-rebase': 'e11d48',
              'stale': 'ededed',
              'overlap': 'c5def5',
            };

            // Ensure labels exist
            const existingLabels = new Set();
            for await (const response of github.paginate.iterator(
              github.rest.issues.listLabelsForRepo,
              { owner, repo, per_page: 100 }
            )) {
              for (const label of response.data) {
                existingLabels.add(label.name);
              }
            }
            for (const [name, color] of Object.entries(labelColors)) {
              if (!existingLabels.has(name)) {
                await github.rest.issues.createLabel({ owner, repo, name, color });
              }
            }

            // Get all open PRs
            const prs = await github.paginate(github.rest.pulls.list, {
              owner,
              repo,
              state: 'open',
              per_page: 100,
            });

            core.info(`Sweeping ${prs.length} open PRs`);

            // Build a file->PR map for overlap detection
            const fileToPRs = new Map();

            for (const pr of prs) {
              const prLabels = new Set(pr.labels.map(l => l.name));
              const toAdd = [];
              const toRemove = [];

              // --- needs-approval: PR has no CI check runs ---
              // (CLA runs via pull_request_target so it always runs)
              try {
                const { data: checkRuns } = await github.rest.checks.listForRef({
                  owner,
                  repo,
                  ref: pr.head.sha,
                  per_page: 100,
                });
                // Filter to only CI-related checks (not CLA, not PR Triage)
                const ciChecks = checkRuns.check_runs.filter(c =>
                  !c.name.includes('CLA') &&
                  !c.name.includes('Label') &&
                  !c.name.includes('Triage') &&
                  !c.name.includes('Validate PR')
                );
                const prAge = now - new Date(pr.created_at);

                if (ciChecks.length === 0 && prAge > 5 * 60 * 1000) {
                  // No CI checks after 5 minutes -- likely needs approval
                  if (!prLabels.has('needs-approval')) toAdd.push('needs-approval');
                } else {
                  if (prLabels.has('needs-approval')) toRemove.push('needs-approval');
                }
              } catch {
                // Ignore check run lookup failures
              }

              // --- needs-rebase: PR has merge conflicts ---
              // mergeable is not included in the list endpoint; fetch individually
              try {
                const { data: fullPr } = await github.rest.pulls.get({
                  owner,
                  repo,
                  pull_number: pr.number,
                });
                if (fullPr.mergeable === false) {
                  if (!prLabels.has('needs-rebase')) toAdd.push('needs-rebase');
                } else if (fullPr.mergeable === true) {
                  if (prLabels.has('needs-rebase')) toRemove.push('needs-rebase');
                }
                // mergeable===null means GitHub is still computing; skip
              } catch {
                // Ignore merge status lookup failures
              }

              // --- Stale detection ---
              const lastActivity = new Date(pr.updated_at);
              const daysSinceActivity = (now - lastActivity) / DAY;

              if (daysSinceActivity > 21) {
                // 21 days with no activity -- close it
                if (!prLabels.has('stale')) {
                  // Should already be labeled stale from 14-day mark, but just in case
                  toAdd.push('stale');
                }

                const marker = '<!-- stale-close -->';
                await github.rest.issues.createComment({
                  owner,
                  repo,
                  issue_number: pr.number,
                  body: `${marker}\nThis PR has been inactive for 21 days and is being closed automatically. If you'd like to continue working on it, feel free to reopen it.`,
                });

                await github.rest.pulls.update({
                  owner,
                  repo,
                  pull_number: pr.number,
                  state: 'closed',
                });

                core.info(`#${pr.number}: closed (stale, ${Math.floor(daysSinceActivity)} days inactive)`);
                continue; // Skip further processing for closed PRs
              } else if (daysSinceActivity > 14) {
                // 14 days -- label as stale and warn
                if (!prLabels.has('stale')) {
                  toAdd.push('stale');

                  const marker = '<!-- stale-warning -->';
                  // Check if we already left a stale warning
                  const { data: comments } = await github.rest.issues.listComments({
                    owner,
                    repo,
                    issue_number: pr.number,
                    per_page: 10,
                  });
                  const hasWarning = comments.some(c =>
                    c.user?.login === 'github-actions[bot]' && c.body?.includes(marker)
                  );

                  if (!hasWarning) {
                    await github.rest.issues.createComment({
                      owner,
                      repo,
                      issue_number: pr.number,
                      body: `${marker}\nThis PR has been inactive for 14 days. It will be closed automatically in 7 days if there is no further activity.\n\nIf you're still working on this, please push an update or leave a comment.`,
                    });
                  }
                }
              } else {
                // Active -- remove stale label if present
                if (prLabels.has('stale')) toRemove.push('stale');
              }

              // --- Collect files for overlap detection ---
              try {
                const { data: files } = await github.rest.pulls.listFiles({
                  owner,
                  repo,
                  pull_number: pr.number,
                  per_page: 100,
                });
                for (const file of files) {
                  if (!fileToPRs.has(file.filename)) {
                    fileToPRs.set(file.filename, []);
                  }
                  fileToPRs.get(file.filename).push(pr.number);
                }
              } catch {
                // Ignore file listing failures
              }

              // --- Apply label changes ---
              if (toAdd.length > 0) {
                await github.rest.issues.addLabels({
                  owner,
                  repo,
                  issue_number: pr.number,
                  labels: toAdd,
                });
              }
              for (const label of toRemove) {
                try {
                  await github.rest.issues.removeLabel({
                    owner,
                    repo,
                    issue_number: pr.number,
                    name: label,
                  });
                } catch {
                  // Label might not exist on the PR
                }
              }

              if (toAdd.length > 0 || toRemove.length > 0) {
                core.info(`#${pr.number}: +[${toAdd.join(',')}] -[${toRemove.join(',')}]`);
              }
            }

            // --- Overlap detection ---
            // Find files changed by multiple PRs
            const overlaps = new Map(); // pr number -> set of overlapping PR numbers
            for (const [file, prNumbers] of fileToPRs) {
              if (prNumbers.length > 1) {
                for (const prNum of prNumbers) {
                  if (!overlaps.has(prNum)) overlaps.set(prNum, new Set());
                  for (const other of prNumbers) {
                    if (other !== prNum) overlaps.get(prNum).add(other);
                  }
                }
              }
            }

            // Label PRs with significant overlap (3+ shared files with another PR)
            for (const [prNum, otherPRs] of overlaps) {
              // Count shared files per overlapping PR
              const sharedFileCounts = new Map();
              for (const [file, prNumbers] of fileToPRs) {
                if (prNumbers.includes(prNum)) {
                  for (const other of prNumbers) {
                    if (other !== prNum) {
                      sharedFileCounts.set(other, (sharedFileCounts.get(other) || 0) + 1);
                    }
                  }
                }
              }

              const significantOverlaps = [...sharedFileCounts.entries()]
                .filter(([_, count]) => count >= 3)
                .map(([otherPR, count]) => `#${otherPR} (${count} shared files)`);

              if (significantOverlaps.length > 0) {
                const pr = prs.find(p => p.number === prNum);
                const prLabels = new Set(pr?.labels.map(l => l.name) || []);

                if (!prLabels.has('overlap')) {
                  await github.rest.issues.addLabels({
                    owner,
                    repo,
                    issue_number: prNum,
                    labels: ['overlap'],
                  });

                  // Leave a comment about the overlap (only once)
                  const marker = '<!-- overlap-notice -->';
                  const { data: comments } = await github.rest.issues.listComments({
                    owner,
                    repo,
                    issue_number: prNum,
                    per_page: 20,
                  });
                  const hasNotice = comments.some(c =>
                    c.user?.login === 'github-actions[bot]' && c.body?.includes(marker)
                  );

                  if (!hasNotice) {
                    await github.rest.issues.createComment({
                      owner,
                      repo,
                      issue_number: prNum,
                      body: `${marker}\n## Overlapping PRs\n\nThis PR modifies files that are also changed by other open PRs:\n\n${significantOverlaps.map(s => `- ${s}`).join('\n')}\n\nThis may cause merge conflicts or duplicated work. A maintainer will coordinate.`,
                    });
                  }

                  core.info(`#${prNum}: overlap with ${significantOverlaps.join(', ')}`);
                }
              }
            }
