name: ci / PR checks

on:
  # pull_request_target runs in the base-repo context (has secrets) so the check
  # works on fork PRs. Safe here: the checkout pins to the base branch (no fork
  # code runs) and the scripts only read context.payload and call the GitHub API.
  pull_request_target:  # zizmor: ignore[dangerous-triggers]
    types: [opened, edited, synchronize, reopened, ready_for_review]

# Default-deny at the workflow level; each job opts into only the scopes it needs.
# Note: modifying a PR's labels/comments needs pull-requests:write even though the
# REST path is under /issues/{n}/...; issues:write alone returns 403 on PRs.
permissions: {}

jobs:
  check-description:
    name: Check PR description
    runs-on: ubuntu-latest
    permissions:
      contents: read
      pull-requests: write
      issues: write
    # Skip bots: they open PRs programmatically and have their own process.
    if: github.event.pull_request.user.type != 'Bot'
    steps:
      - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0  # v7.0.0
        with:
          ref: ${{ github.base_ref }}
          sparse-checkout: .github/scripts
          persist-credentials: false

      - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3  # v9.0.0
        with:
          script: return require('./.github/scripts/check-pr-description.js')({github, context, core})

  check-title:
    name: Check PR title (Conventional Commits)
    runs-on: ubuntu-latest
    permissions: {}
    # Skip bots: they open PRs programmatically and have their own process.
    if: github.event.pull_request.user.type != 'Bot'
    steps:
      - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3  # v9.0.0
        with:
          script: |
            const title = context.payload.pull_request.title || "";
            // Conventional Commits: type(optional-scope)(optional !): summary
            const re = /^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\([\w .\/-]+\))?!?: .+/;
            if (!re.test(title)) {
              core.setFailed(
                `PR title is not in Conventional Commits format:\n  "${title}"\n\n` +
                `Expected: type(scope): summary\n` +
                `Example:  fix(search): handle empty query\n` +
                `Types: feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert.`
              );
            } else {
              core.info(`PR title OK: ${title}`);
            }

  check-mergeable:
    name: Flag unmergeable PRs
    runs-on: ubuntu-latest
    permissions:
      pull-requests: write
      issues: write
    # Skip bots: they open PRs programmatically and have their own process.
    if: github.event.pull_request.user.type != 'Bot'
    steps:
      - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3  # v9.0.0
        with:
          script: |
            const repo = { owner: context.repo.owner, repo: context.repo.repo };
            const number = context.payload.pull_request.number;
            const READY = "ready for review";
            const CONFLICT = "merge conflict";

            // Ensure the conflict label exists (red). Ignore if already present.
            try {
              await github.rest.issues.getLabel({ ...repo, name: CONFLICT });
            } catch {
              await github.rest.issues.createLabel({
                ...repo, name: CONFLICT, color: "B60205",
                description: "Conflicts with the base branch; needs a rebase before review.",
              }).catch(() => {});
            }

            // mergeable is computed asynchronously and is often null right after
            // an event, so poll a few times until GitHub has resolved it.
            let pr = null;
            for (let i = 0; i < 5; i++) {
              const { data } = await github.rest.pulls.get({ ...repo, pull_number: number });
              if (data.mergeable !== null) { pr = data; break; }
              await new Promise(r => setTimeout(r, 3000));
            }
            if (!pr || pr.draft) return;
            const labels = pr.labels.map(l => l.name);

            if (pr.mergeable === false) {
              if (labels.includes(READY)) {
                await github.rest.issues.removeLabel({ ...repo, issue_number: number, name: READY }).catch(() => {});
              }
              if (!labels.includes(CONFLICT)) {
                await github.rest.issues.addLabels({ ...repo, issue_number: number, labels: [CONFLICT] });
              }
            } else if (pr.mergeable === true) {
              if (labels.includes(CONFLICT)) {
                await github.rest.issues.removeLabel({ ...repo, issue_number: number, name: CONFLICT }).catch(() => {});
              }
            }
