# yamllint disable rule:line-length
# yamllint disable rule:truthy
# Rationale: GitHub requires `status: {}` to subscribe broadly, and the embedded GraphQL/script blocks exceed 80 columns.
# Auto-merge fires for all open, non-draft, same-repo PRs unless
# labeled 'no-automerge'. On PR open, waits 10 minutes to allow
# automated code reviews to post first.
---
name: PR Auto-merge

on:
  pull_request:
    types: [opened, labeled, unlabeled, synchronize, reopened, ready_for_review]
  check_suite:
    types: [completed]
  status: {}

permissions:
  pull-requests: write
  contents: write

jobs:
  auto_merge:
    runs-on: ubuntu-latest
    timeout-minutes: 20
    env:
      # derived requirement (user rollout, 2026-08-21): AUTOMERGE_TOKEN_NONADMIN_NO_BYPASS is a
      # fine-grained PAT scoped to "Pull requests: Read and write" only -- unlike AUTOMERGE_TOKEN
      # (an older, admin-capable token; see the PR #447 incident documented on the step below),
      # it should NOT be able to bypass required-status-check branch protection even if this
      # workflow's own logic ever regressed back toward a direct-merge fallback. Tried first;
      # AUTOMERGE_TOKEN stays wired as a fallback until the new token is confirmed working across
      # several real PRs, then AUTOMERGE_TOKEN's own step should be removed.
      AUTOMERGE_TOKEN_NONADMIN_NO_BYPASS: ${{ secrets.AUTOMERGE_TOKEN_NONADMIN_NO_BYPASS }}
      AUTOMERGE_TOKEN: ${{ secrets.AUTOMERGE_TOKEN }}
    steps:
      - name: Wait for automated reviews (PR opened only)
        if: ${{ github.event_name == 'pull_request' && github.event.action == 'opened' }}
        shell: bash
        run: |
          echo "Waiting 10 minutes for automated code reviews to post..."
          sleep 600
          echo "Done waiting, proceeding with auto-merge enable."

      - name: Resolve candidate PR numbers for this event
        id: prs
        uses: actions/github-script@v8
        with:
          github-token: ${{ github.token }}
          script: |
            const owner = context.repo.owner;
            const repo  = context.repo.repo;

            async function numbersFromEvent() {
              if (context.eventName === 'pull_request') {
                return [context.payload.pull_request.number];
              }
              // Map status/check_suite SHA -> PRs using stable REST API
              const sha =
                context.eventName === 'status'
                  ? context.payload.sha
                  : context.payload.check_suite?.head_sha;

              if (!sha) return [];
              const { data: prs } = await github.rest.repos.listPullRequestsAssociatedWithCommit({
                owner, repo, commit_sha: sha
              });
              return prs.filter(pr => pr.state === 'open').map(pr => pr.number);
            }

            const nums = await numbersFromEvent();
            core.setOutput('numbers', JSON.stringify(nums));

      - name: Enable Auto-merge via AUTOMERGE_TOKEN_NONADMIN_NO_BYPASS (preferred)
        if: ${{ steps.prs.outputs.numbers != '[]' && env.AUTOMERGE_TOKEN_NONADMIN_NO_BYPASS }}
        # derived requirement: this step must never block the AUTOMERGE_TOKEN legacy-fallback
        # step below, even if the new token turns out to be malformed enough that the
        # actions/github-script action itself fails before the script body ever runs (e.g. at
        # Octokit client construction). continue-on-error keeps the job's overall status healthy
        # for the fallback step's own (unconditioned-on-this-step) `if:` to still evaluate and
        # run, while this step's own outcome/log still faithfully shows failure for troubleshooting.
        continue-on-error: true
        uses: actions/github-script@v8
        with:
          github-token: ${{ secrets.AUTOMERGE_TOKEN_NONADMIN_NO_BYPASS }}
          script: |
            const owner = context.repo.owner;
            const repo  = context.repo.repo;
            const nums = JSON.parse(process.env.numbers || '[]');
            core.info('Auth path: PAT (AUTOMERGE_TOKEN_NONADMIN_NO_BYPASS, preferred non-admin token)');

            const ql = `
              query($owner:String!,$repo:String!,$num:Int!){
                repository(owner:$owner,name:$repo){
                  pullRequest(number:$num){
                    id number isDraft mergeable reviewDecision viewerCanEnableAutoMerge
                    autoMergeRequest { enabledAt }
                    headRepository { nameWithOwner } baseRepository { nameWithOwner }
                  }
                }
              }
            `;
            const enable = `
              mutation($id:ID!){
                enablePullRequestAutoMerge(input:{pullRequestId:$id,mergeMethod:SQUASH}){ clientMutationId }
              }
            `;

            // derived requirement: NEVER merge directly from this workflow -- only ever queue
            // via enablePullRequestAutoMerge, which GitHub itself will not complete until
            // required checks/reviews genuinely pass. A prior version of this script fell back
            // to github.rest.pulls.merge() here whenever viewerCanEnableAutoMerge read false,
            // gated only on pr.mergeable === 'MERGEABLE' -- which means "no git conflicts with
            // base," NOT "required checks passed." That fallback merged a real PR (#447) within
            // ~20 seconds of it opening, before any CI check had even started, because an
            // admin-capable AUTOMERGE_TOKEN made viewerCanEnableAutoMerge read false immediately
            // (nothing to queue -- the actor can already bypass). Do not reintroduce a
            // direct-merge fallback of any kind here, in this step or any of its siblings.
            //
            // derived requirement (CodeRabbit review on PR #448): attempt the enable call
            // regardless of viewerCanEnableAutoMerge rather than skipping outright -- that
            // field is a client-side hint, not authoritative, and can read false for a
            // genuinely still-pending PR too. The GraphQL mutation itself is the real source of
            // truth: if it succeeds, the PR is genuinely queued; if it fails, it's just logged --
            // never a license to merge directly.
            for (const number of nums) {
              try {
                const { data: prRest } = await github.rest.pulls.get({ owner, repo, pull_number: number });
                const labels = (prRest.labels || []).map(l => (l.name||'').toLowerCase());
                const isSameRepo = prRest.head?.repo?.full_name === prRest.base?.repo?.full_name;

                if (prRest.state !== 'open' || prRest.draft) { core.info(`#${number}: skip (closed/draft)`); continue; }
                if (!isSameRepo) { core.info(`#${number}: skip (fork PR)`); continue; }
                if (labels.includes('no-automerge')) { core.info(`#${number}: skip (has 'no-automerge')`); continue; }
                if (prRest.auto_merge) { core.info(`#${number}: already armed (REST)`); continue; }

                const pre = await github.graphql(ql, { owner, repo, num: number });
                const pr = pre.repository.pullRequest;
                if (!pr.viewerCanEnableAutoMerge) {
                  core.info(`#${number}: viewerCanEnable=false; attempting enable anyway (queue-only, never merges directly)`);
                }

                await github.graphql(enable, { id: pr.id });
                const { data: after } = await github.rest.pulls.get({ owner, repo, pull_number: number });
                if (after.auto_merge) {
                  core.info(`#${number}: Auto-merge enabled (Squash) via AUTOMERGE_TOKEN_NONADMIN_NO_BYPASS [OK]`);
                } else {
                  core.info(`#${number}: enable via AUTOMERGE_TOKEN_NONADMIN_NO_BYPASS reported success, but REST auto_merge is null (will rely on diagnostics/legacy fallback).`);
                }
              } catch (e) {
                // core.warning (not core.info) so an outright failure of the new token --
                // including an auth/permission failure on the very first REST call, not just the
                // enable mutation -- is visually distinct in the Actions log and shows up as its
                // own check-run annotation, making it easy to confirm from the logs whether
                // AUTOMERGE_TOKEN_NONADMIN_NO_BYPASS's scopes are sufficient.
                const msg = e?.errors ? JSON.stringify(e.errors[0]) : String(e.message||e);
                core.warning(`#${number}: AUTOMERGE_TOKEN_NONADMIN_NO_BYPASS FAILED (${msg}) -- falling back to AUTOMERGE_TOKEN (legacy) below.`);
              }
            }
        env:
          numbers: ${{ steps.prs.outputs.numbers }}

      - name: Enable Auto-merge via AUTOMERGE_TOKEN (legacy, admin-capable -- remove once AUTOMERGE_TOKEN_NONADMIN_NO_BYPASS is confirmed working)
        if: ${{ steps.prs.outputs.numbers != '[]' && env.AUTOMERGE_TOKEN }}
        uses: actions/github-script@v8
        with:
          github-token: ${{ secrets.AUTOMERGE_TOKEN }}
          script: |
            const owner = context.repo.owner;
            const repo  = context.repo.repo;
            const nums = JSON.parse(process.env.numbers || '[]');
            core.info('Auth path: PAT (AUTOMERGE_TOKEN, legacy admin-capable fallback)');

            const ql = `
              query($owner:String!,$repo:String!,$num:Int!){
                repository(owner:$owner,name:$repo){
                  pullRequest(number:$num){
                    id number isDraft mergeable reviewDecision viewerCanEnableAutoMerge
                    autoMergeRequest { enabledAt }
                    headRepository { nameWithOwner } baseRepository { nameWithOwner }
                  }
                }
              }
            `;
            const enable = `
              mutation($id:ID!){
                enablePullRequestAutoMerge(input:{pullRequestId:$id,mergeMethod:SQUASH}){ clientMutationId }
              }
            `;

            // derived requirement: no direct-merge fallback here either -- see the matching
            // comment in the AUTOMERGE_TOKEN_NONADMIN_NO_BYPASS step above for the PR #447
            // incident this protects against, and for why the enable call is always attempted
            // regardless of viewerCanEnableAutoMerge. This step is a fallback for any PR the
            // preferred token's step above could not arm (its own `already armed (REST)` check
            // just above makes it a safe no-op for anything already armed by that step).
            for (const number of nums) {
              const { data: prRest } = await github.rest.pulls.get({ owner, repo, pull_number: number });
              const labels = (prRest.labels || []).map(l => (l.name||'').toLowerCase());
              const isSameRepo = prRest.head?.repo?.full_name === prRest.base?.repo?.full_name;

              if (prRest.state !== 'open' || prRest.draft) { core.info(`#${number}: skip (closed/draft)`); continue; }
              if (!isSameRepo) { core.info(`#${number}: skip (fork PR)`); continue; }
              if (labels.includes('no-automerge')) { core.info(`#${number}: skip (has 'no-automerge')`); continue; }
              if (prRest.auto_merge) { core.info(`#${number}: already armed (REST)`); continue; }

              const pre = await github.graphql(ql, { owner, repo, num: number });
              const pr = pre.repository.pullRequest;
              if (!pr.viewerCanEnableAutoMerge) {
                core.info(`#${number}: viewerCanEnable=false; attempting enable anyway (queue-only, never merges directly)`);
              }

              try {
                await github.graphql(enable, { id: pr.id });
                const { data: after } = await github.rest.pulls.get({ owner, repo, pull_number: number });
                if (after.auto_merge) {
                  core.info(`#${number}: Auto-merge enabled (Squash) via AUTOMERGE_TOKEN (legacy) [OK]`);
                } else {
                  core.info(`#${number}: enable via AUTOMERGE_TOKEN (legacy) reported success, but REST auto_merge is null (will rely on diagnostics).`);
                }
              } catch (e) {
                const msg = e?.errors ? JSON.stringify(e.errors[0]) : String(e.message||e);
                core.info(`#${number}: enable failed via AUTOMERGE_TOKEN (legacy) (GraphQL): ${msg}`);
              }
            }
        env:
          numbers: ${{ steps.prs.outputs.numbers }}

      - name: Enable Auto-merge via GITHUB_TOKEN
        if: ${{ steps.prs.outputs.numbers != '[]' && !env.AUTOMERGE_TOKEN_NONADMIN_NO_BYPASS && !env.AUTOMERGE_TOKEN }}
        uses: actions/github-script@v8
        with:
          github-token: ${{ github.token }}
          script: |
            const owner = context.repo.owner;
            const repo  = context.repo.repo;
            const nums = JSON.parse(process.env.numbers || '[]');
            core.info('Auth path: GITHUB_TOKEN');

            const enable = `
              mutation($id:ID!){
                enablePullRequestAutoMerge(input:{pullRequestId:$id,mergeMethod:SQUASH}){ clientMutationId }
              }
            `;

            for (const number of nums) {
              const { data: prRest } = await github.rest.pulls.get({ owner, repo, pull_number: number });
              const labels = (prRest.labels || []).map(l => (l.name||'').toLowerCase());
              const isSameRepo = prRest.head?.repo?.full_name === prRest.base?.repo?.full_name;

              if (prRest.state !== 'open' || prRest.draft) { core.info(`#${number}: skip (closed/draft)`); continue; }
              if (!isSameRepo) { core.info(`#${number}: skip (fork PR)`); continue; }
              if (labels.includes('no-automerge')) { core.info(`#${number}: skip (has 'no-automerge')`); continue; }
              if (prRest.auto_merge) { core.info(`#${number}: already armed (REST)`); continue; }

              try {
                await github.graphql(enable, { id: prRest.node_id || prRest.id /* node_id expected */ });
                const { data: after } = await github.rest.pulls.get({ owner, repo, pull_number: number });
                if (after.auto_merge) {
                  core.info(`#${number}: Auto-merge enabled (Squash) via GITHUB_TOKEN [OK]`);
                } else {
                  core.info(`#${number}: enable via GITHUB_TOKEN reported success, but REST auto_merge is null (permissions likely).`);
                }
              } catch (e) {
                // derived requirement: no direct-merge fallback here either -- see the matching
                // comment in the AUTOMERGE_TOKEN_NONADMIN_NO_BYPASS step above for why (PR #447).
                const msg = e?.errors ? JSON.stringify(e.errors[0]) : String(e.message||e);
                core.info(`#${number}: enable failed via GITHUB_TOKEN (GraphQL): ${msg}`);
              }
            }
        env:
          numbers: ${{ steps.prs.outputs.numbers }}

      - name: Auto-merge diagnostics (state + reason)
        if: always()
        uses: actions/github-script@v8
        with:
          github-token: ${{ github.token }}
          script: |
            const owner = context.repo.owner;
            const repo  = context.repo.repo;

            async function resolvePRNumbers(){
              if (context.eventName === 'pull_request') return [context.payload.pull_request.number];
              const sha = context.eventName === 'status' ? context.payload.sha : context.payload.check_suite?.head_sha;
              if (!sha) return [];
              const { data: prs } = await github.rest.repos.listPullRequestsAssociatedWithCommit({ owner, repo, commit_sha: sha });
              return prs.filter(pr => pr.state === 'open').map(pr => pr.number);
            }

            const repoQl = `
              query($owner:String!,$repo:String!){
                repository(owner:$owner,name:$repo){
                  autoMergeAllowed
                }
              }
            `;
            const prQl = `
              query($owner:String!,$repo:String!,$num:Int!){
                repository(owner:$owner,name:$repo){
                  pullRequest(number:$num){
                    id number isDraft mergeable reviewDecision
                    viewerCanEnableAutoMerge
                    autoMergeRequest { enabledAt }
                  }
                }
              }
            `;

            const nums = await resolvePRNumbers();
            const lines = [];
            const repoData = await github.graphql(repoQl, { owner, repo });
            const repoAuto = repoData.repository.autoMergeAllowed;

            if (!nums.length) {
              const msg = `Auto-merge diagnostics: no open PRs found for event=${context.eventName}; repo.autoMergeAllowed=${repoAuto}`;
              core.info(msg); await core.summary.addRaw(msg).write(); return;
            }

            for (const number of nums) {
              const { data: prRest } = await github.rest.pulls.get({ owner, repo, pull_number: number });
              const armed = !!prRest.auto_merge;
              const data = await github.graphql(prQl, { owner, repo, num: number });
              const pr = data.repository.pullRequest;

              let reason = '';
              if (!armed) {
                if (!repoAuto) reason ||= 'repo auto-merge disabled';
                if (pr.isDraft) reason ||= 'draft';
                if (pr.mergeable === 'CONFLICTING') reason ||= 'merge conflicts';
                if (pr.reviewDecision === 'REVIEW_REQUIRED') reason ||= 'required review not approved';
                if (!pr.viewerCanEnableAutoMerge) reason ||= 'actor cannot enable auto-merge (permissions)';
                if (!reason) reason = 'not enabled by API (see logs above)';
              }

              const state = armed ? 'armed' : 'not armed';
              const msg = `PR #${number}: Auto-merge is ${state}; repo.autoMergeAllowed=${repoAuto}; viewerCanEnable=${pr.viewerCanEnableAutoMerge}; mergeable=${pr.mergeable}; draft=${pr.isDraft}; reviewDecision=${pr.reviewDecision || 'n/a'}; ${reason || 'ok'}`;
              core.info(msg);
              lines.push(msg);
            }

            await core.summary.addHeading('Auto-merge diagnostics', 3).addList(lines).write();

      - name: Note auto-merge state in Summary
        if: always() && github.event_name == 'pull_request'
        uses: actions/github-script@v8
        with:
          github-token: ${{ github.token }}
          script: |
            const pr = context.payload.pull_request;
            const labels = (pr.labels || []).map(l => (l.name||'').toLowerCase());
            const armed = !labels.includes('no-automerge') && !pr.draft && pr.head.repo?.full_name === pr.base.repo?.full_name;
            const line = armed ? `Auto-merge: **armed** for #${pr.number} (Squash)` : `Auto-merge: **not armed** for #${pr.number}`;
            await core.summary.addRaw(line).write()
