name: Query Counts — Apply

# Runs after the "Query Counts" workflow completes on a PR. This job has
# elevated permissions to push back to the PR branch and comment, but it
# never executes code from the PR — it only downloads the inert JSON +
# diff artifact produced by the measure job.
on:
  workflow_run:
    workflows: ["Query Counts"]
    types: [completed]

permissions:
  # actions:read is needed for listWorkflowRunArtifacts /
  # downloadArtifact. pull-requests:read is needed for pulls.get in the
  # Resolve PR step. contents:read is a safety default; the commit-back
  # step uses the app token, not GITHUB_TOKEN.
  actions: read
  contents: read
  pull-requests: read

jobs:
  apply:
    name: Apply
    if: github.event.workflow_run.conclusion == 'success'
    runs-on: ubuntu-latest
    timeout-minutes: 10
    steps:
      - name: Download snapshot artifact
        id: download
        uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
        with:
          script: |
            const fs = require('node:fs');
            const path = require('node:path');
            const { data } = await github.rest.actions.listWorkflowRunArtifacts({
              owner: context.repo.owner,
              repo: context.repo.repo,
              run_id: context.payload.workflow_run.id,
            });
            const artifact = data.artifacts.find((a) => a.name === 'query-counts-snapshots');
            if (!artifact) {
              core.setOutput('found', 'false');
              core.info('No snapshot artifact — nothing to apply.');
              return;
            }
            const download = await github.rest.actions.downloadArtifact({
              owner: context.repo.owner,
              repo: context.repo.repo,
              artifact_id: artifact.id,
              archive_format: 'zip',
            });
            // Stage the artifact under RUNNER_TEMP, not GITHUB_WORKSPACE.
            // GITHUB_WORKSPACE is wiped by the later actions/checkout step
            // (clean: true is the default), which would delete our payload
            // before the Apply step can copy it into scripts/. RUNNER_TEMP
            // sits outside the workspace and survives checkout.
            const outDir = path.join(process.env.RUNNER_TEMP, 'qc-artifact');
            fs.mkdirSync(outDir, { recursive: true });
            fs.writeFileSync(path.join(outDir, 'artifact.zip'), Buffer.from(download.data));
            core.setOutput('found', 'true');
            core.setOutput('dir', outDir);

      - name: Unpack artifact
        if: steps.download.outputs.found == 'true'
        run: |
          cd "$RUNNER_TEMP/qc-artifact"
          unzip -o artifact.zip
          ls -la

      - name: Resolve PR
        if: steps.download.outputs.found == 'true'
        id: pr
        uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
        with:
          script: |
            const fs = require('node:fs');
            const path = require('node:path');
            const prNumber = Number(
              fs.readFileSync(
                path.join(process.env.RUNNER_TEMP, 'qc-artifact', 'pr-number'),
                'utf8',
              ).trim(),
            );
            if (!Number.isInteger(prNumber) || prNumber <= 0) {
              core.setFailed(`Invalid PR number in artifact: ${prNumber}`);
              return;
            }
            // Cross-check: fetch the PR directly and verify its head SHA
            // matches the workflow_run's head SHA. This is more robust
            // than listPullRequestsAssociatedWithCommit, which doesn't
            // reliably surface fork PRs from the base repo's API view
            // and was rejecting valid fork artifacts as "not associated".
            const headSha = context.payload.workflow_run.head_sha;
            let pr;
            try {
              const { data } = await github.rest.pulls.get({
                owner: context.repo.owner,
                repo: context.repo.repo,
                pull_number: prNumber,
              });
              pr = data;
            } catch (err) {
              core.setFailed(`Failed to fetch PR #${prNumber}: ${err.message}`);
              return;
            }
            if (pr.head.sha !== headSha) {
              core.setFailed(
                `PR #${prNumber} head SHA (${pr.head.sha}) does not match workflow_run head SHA (${headSha}). The branch has likely moved on; the next PR event will trigger a fresh measure+apply.`,
              );
              return;
            }
            core.setOutput('number', String(pr.number));
            core.setOutput('ref', pr.head.ref);
            // Use the workflow_run's head_sha — the commit that was
            // actually measured — rather than the PR's current head.
            // If the branch has moved on since, we want to push onto
            // the measured commit (and let the push fail non-fast-
            // forward) rather than apply stale snapshots to a newer
            // tree.
            core.setOutput('sha', headSha);
            core.setOutput('full_name', pr.head.repo.full_name);
            const isFork = pr.head.repo.fork
              || pr.head.repo.full_name !== `${context.repo.owner}/${context.repo.repo}`;
            core.setOutput('is_fork', isFork.toString());

      - name: Generate app token
        if: steps.download.outputs.found == 'true'
        id: app-token
        uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
        with:
          client-id: ${{ secrets.APP_ID }}
          private-key: ${{ secrets.APP_PRIVATE_KEY }}
          # Push the updated snapshots back to the PR branch. Nothing else.
          permission-contents: write

      # --- Same-repo PRs: checkout the pinned SHA and push directly ---

      - name: Checkout (same-repo)
        if: steps.download.outputs.found == 'true' && steps.pr.outputs.is_fork == 'false'
        uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
        with:
          ref: ${{ steps.pr.outputs.sha }}
          token: ${{ steps.app-token.outputs.token }}
          # Intentional: the same-repo push step below pushes the
          # updated snapshots back to the PR branch using this credential.
          persist-credentials: true

      # --- Fork PRs: checkout the fork at the pinned SHA so we can push back ---

      - name: Checkout (fork)
        if: steps.download.outputs.found == 'true' && steps.pr.outputs.is_fork == 'true'
        uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
        with:
          repository: ${{ steps.pr.outputs.full_name }}
          ref: ${{ steps.pr.outputs.sha }}
          persist-credentials: false

      - name: Apply snapshots
        if: steps.download.outputs.found == 'true'
        id: apply
        run: |
          cp "$RUNNER_TEMP/qc-artifact/query-counts.snapshot.sqlite.json" scripts/
          cp "$RUNNER_TEMP/qc-artifact/query-counts.snapshot.d1.json" scripts/
          cp "$RUNNER_TEMP/qc-artifact/query-counts.queries.sqlite.json" scripts/
          cp "$RUNNER_TEMP/qc-artifact/query-counts.queries.d1.json" scripts/
          git add \
            scripts/query-counts.snapshot.sqlite.json \
            scripts/query-counts.snapshot.d1.json \
            scripts/query-counts.queries.sqlite.json \
            scripts/query-counts.queries.d1.json
          if git diff --staged --quiet; then
            echo "no_diff=true" >> "$GITHUB_OUTPUT"
            echo "Artifact matches the PR head — nothing to push (probably a race with a newer commit)."
          else
            echo "no_diff=false" >> "$GITHUB_OUTPUT"
          fi

      - name: Commit and push (same-repo)
        if: |
          steps.download.outputs.found == 'true'
          && steps.pr.outputs.is_fork == 'false'
          && steps.apply.outputs.no_diff == 'false'
        env:
          HEAD_REF: ${{ steps.pr.outputs.ref }}
          HEAD_SHA: ${{ steps.pr.outputs.sha }}
        run: |
          set -e
          git config user.name "emdashbot[bot]"
          git config user.email "emdashbot[bot]@users.noreply.github.com"
          git commit -m "ci: update query-count snapshots"
          # Detached-HEAD push onto the PR branch. If the branch has
          # advanced past the measured SHA, this fails with a non-fast-
          # forward — safe outcome, since applying stale counts to a
          # newer tree would hide a regression. The next PR event will
          # kick off a fresh measure+apply against the new head.
          if ! git push origin "HEAD:$HEAD_REF"; then
            echo "::error::Non-fast-forward push — the PR branch moved past the measured SHA $HEAD_SHA. Rerun the harness against the new head (the next PR event will do this automatically)." >&2
            exit 1
          fi

      - name: Commit and push (fork)
        if: |
          steps.download.outputs.found == 'true'
          && steps.pr.outputs.is_fork == 'true'
          && steps.apply.outputs.no_diff == 'false'
        env:
          APP_TOKEN: ${{ steps.app-token.outputs.token }}
          HEAD_REF: ${{ steps.pr.outputs.ref }}
          FULL_NAME: ${{ steps.pr.outputs.full_name }}
        run: |
          set -e
          git config user.name "emdashbot[bot]"
          git config user.email "emdashbot[bot]@users.noreply.github.com"
          git commit -m "ci: update query-count snapshots"
          export GIT_ASKPASS="$RUNNER_TEMP/git-askpass.sh"
          printf '#!/bin/sh\necho "%s"\n' "$APP_TOKEN" > "$GIT_ASKPASS"
          chmod +x "$GIT_ASKPASS"
          git remote add fork "https://x-access-token@github.com/$FULL_NAME.git"
          # Fail the workflow if we can't push — most likely cause is
          # "Allow edits by maintainers" being disabled on the fork PR.
          # The reviewer needs to know the snapshots couldn't be applied.
          if ! git push fork "HEAD:$HEAD_REF"; then
            rm -f "$GIT_ASKPASS"
            echo "::error::Failed to push snapshots to fork. The contributor may have 'Allow edits by maintainers' disabled. They need to run 'pnpm query-counts --target sqlite --update && pnpm query-counts --target d1 --update' locally and commit, or re-enable maintainer edits." >&2
            exit 1
          fi
          rm -f "$GIT_ASKPASS"
