name: Visual Regression — Apply

# Commits accepted visual baselines when a maintainer comments
# `/accept-baselines` on a PR. The command is the human gate: only users with
# write access (author association OWNER/MEMBER/COLLABORATOR) can trigger it.
#
# It downloads the inert candidate-baseline artifact produced by the measure
# job for the PR's current head and commits those PNGs to the PR branch. It
# never checks out or executes PR-authored code.
#
# Like all issue_comment workflows, this runs from the default branch, so it
# only takes effect once merged to main.

on:
  issue_comment:
    types: [created]

permissions:
  actions: read # find + download the measure artifact
  contents: read # app token handles the write/push
  pull-requests: write # comment back with the result
  issues: write # react to the triggering comment (issues-scoped API)

jobs:
  apply:
    name: Apply
    # Only on PR comments, from a non-bot with write access. Exact match (as in
    # format-command.yml) so quoted or suffixed text can't trigger a write.
    if: >-
      github.event.issue.pull_request != null
      && github.event.sender.type != 'Bot'
      && github.event.comment.body == '/accept-baselines'
      && contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association)
    concurrency:
      group: visual-apply-${{ github.event.issue.number }}
      cancel-in-progress: false
    runs-on: ubuntu-latest
    timeout-minutes: 10
    steps:
      - name: Acknowledge
        continue-on-error: true # a missing 👀 must not block the apply
        uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
        with:
          script: |
            await github.rest.reactions.createForIssueComment({
              owner: context.repo.owner, repo: context.repo.repo,
              comment_id: context.payload.comment.id, content: 'eyes',
            });

      - name: Resolve PR and measure run
        id: resolve
        uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
        with:
          script: |
            const { owner, repo } = context.repo;
            const number = context.issue.number;
            const { data: pr } = await github.rest.pulls.get({
              owner, repo, pull_number: number,
            });

            // Find the measure run (with the candidate artifact) for this head.
            // The run "fails" red on drift, so match on completion, not
            // conclusion. listWorkflowRuns returns newest first.
            const sha7 = pr.head.sha.slice(0, 7);
            const runs = await github.rest.actions.listWorkflowRuns({
              owner, repo, workflow_id: 'visual.yml', head_sha: pr.head.sha, per_page: 20,
            });
            const all = runs.data.workflow_runs ?? [];
            const run = all.find((r) => r.status === 'completed');
            if (!run) {
              await github.rest.issues.createComment({
                owner, repo, issue_number: number,
                body: all.length > 0
                  ? `The \`Visual Regression\` run for the current head (\`${sha7}\`) is still in progress. Wait for it to finish, then comment \`/accept-baselines\` again.`
                  : `No \`Visual Regression\` run found for the current head (\`${sha7}\`). Push a commit to trigger it (or wait for the measure job), then comment \`/accept-baselines\` again.`,
              });
              core.setOutput('eligible', 'false');
              return;
            }

            core.setOutput('eligible', 'true');
            core.setOutput('number', String(number));
            core.setOutput('head_sha', pr.head.sha);
            core.setOutput('ref', pr.head.ref);
            core.setOutput('full_name', pr.head.repo.full_name);
            core.setOutput('is_fork', String(
              pr.head.repo.fork || pr.head.repo.full_name !== `${owner}/${repo}`,
            ));
            core.setOutput('run_id', String(run.id));

      - name: Download candidate baselines
        id: download
        if: steps.resolve.outputs.eligible == 'true'
        uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
        env:
          RUN_ID: ${{ steps.resolve.outputs.run_id }}
          PR_NUMBER: ${{ steps.resolve.outputs.number }}
        with:
          script: |
            const fs = require('node:fs');
            const path = require('node:path');
            const runId = Number(process.env.RUN_ID);
            const number = Number(process.env.PR_NUMBER);
            const { data } = await github.rest.actions.listWorkflowRunArtifacts({
              owner: context.repo.owner, repo: context.repo.repo, run_id: runId,
            });
            const artifact = data.artifacts.find((a) => a.name === 'visual-regression');
            if (!artifact) {
              await github.rest.issues.createComment({
                owner: context.repo.owner, repo: context.repo.repo, issue_number: number,
                body: "Couldn't find the candidate-baseline artifact for the completed measure run (it may have expired after 7 days, or the run failed before uploading). Push a commit to regenerate it, then comment `/accept-baselines` again.",
              });
              core.setOutput('found', 'false');
              return;
            }
            const dl = await github.rest.actions.downloadArtifact({
              owner: context.repo.owner, repo: context.repo.repo,
              artifact_id: artifact.id, archive_format: 'zip',
            });
            const outDir = path.join(process.env.RUNNER_TEMP, 'va');
            fs.mkdirSync(outDir, { recursive: true });
            fs.writeFileSync(path.join(outDir, 'artifact.zip'), Buffer.from(dl.data));
            core.setOutput('found', 'true');

      - name: Unpack and validate
        id: validate
        if: steps.download.outputs.found == 'true'
        env:
          HEAD_SHA: ${{ steps.resolve.outputs.head_sha }}
        run: |
          cd "$RUNNER_TEMP/va"
          unzip -o artifact.zip
          drift=$(cat has-drift 2>/dev/null || echo false)
          measured=$(cat head-sha 2>/dev/null || echo "")
          if [ "$drift" != "true" ]; then
            echo "reason=no_drift" >> "$GITHUB_OUTPUT"
            echo "ok=false" >> "$GITHUB_OUTPUT"
          elif [ "$measured" != "$HEAD_SHA" ]; then
            echo "reason=head_mismatch" >> "$GITHUB_OUTPUT"
            echo "ok=false" >> "$GITHUB_OUTPUT"
          elif [ -z "$(ls -A candidates 2>/dev/null)" ]; then
            echo "reason=no_candidates" >> "$GITHUB_OUTPUT"
            echo "ok=false" >> "$GITHUB_OUTPUT"
          else
            echo "ok=true" >> "$GITHUB_OUTPUT"
          fi

      - name: Report nothing to apply
        if: steps.validate.outputs.ok == 'false'
        uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
        env:
          PR_NUMBER: ${{ steps.resolve.outputs.number }}
          REASON: ${{ steps.validate.outputs.reason }}
        with:
          script: |
            const messages = {
              no_drift: 'There is no visual drift on the current head, so there are no baselines to accept.',
              head_mismatch: 'The measured baselines are for an older commit. Wait for the `Visual Regression` run on the current head to finish, then comment `/accept-baselines` again.',
              no_candidates: 'The measure run produced no candidate baselines (likely an infrastructure failure, not a UI change). Check the `Visual Regression` logs.',
            };
            await github.rest.issues.createComment({
              owner: context.repo.owner, repo: context.repo.repo,
              issue_number: Number(process.env.PR_NUMBER),
              body: messages[process.env.REASON] || 'Nothing to apply.',
            });

      - name: Generate app token
        id: app-token
        if: steps.validate.outputs.ok == 'true'
        uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
        with:
          client-id: ${{ secrets.APP_ID }}
          private-key: ${{ secrets.APP_PRIVATE_KEY }}
          permission-contents: write

      - name: Checkout (same-repo)
        if: steps.validate.outputs.ok == 'true' && steps.resolve.outputs.is_fork == 'false'
        uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
        with:
          ref: ${{ steps.resolve.outputs.head_sha }}
          token: ${{ steps.app-token.outputs.token }}
          persist-credentials: true

      - name: Checkout (fork)
        if: steps.validate.outputs.ok == 'true' && steps.resolve.outputs.is_fork == 'true'
        uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
        with:
          repository: ${{ steps.resolve.outputs.full_name }}
          ref: ${{ steps.resolve.outputs.head_sha }}
          persist-credentials: false
          # Reviewed opt-in: no code from the fork tree is executed. The steps
          # below only copy inert PNGs from the trusted measure artifact and run
          # git add/commit/push -- no scripts, hooks, or tooling from the tree.
          allow-unsafe-pr-checkout: true

      - name: Stage baselines
        id: stage
        if: steps.validate.outputs.ok == 'true'
        run: |
          set -e
          dest="e2e/tests/visual-regression.spec.ts-snapshots"
          mkdir -p "$dest"
          cp -r "$RUNNER_TEMP/va/candidates/." "$dest/"
          git add "$dest"
          if git diff --staged --quiet; then
            echo "changed=false" >> "$GITHUB_OUTPUT"
          else
            echo "changed=true" >> "$GITHUB_OUTPUT"
          fi

      - name: Commit and push (same-repo)
        if: steps.stage.outputs.changed == 'true' && steps.resolve.outputs.is_fork == 'false'
        env:
          HEAD_REF: ${{ steps.resolve.outputs.ref }}
          ACCEPTER: ${{ github.event.comment.user.login }}
        run: |
          set -e
          git config user.name "emdashbot[bot]"
          git config user.email "emdashbot[bot]@users.noreply.github.com"
          git commit -m "ci: accept visual baselines (requested by @$ACCEPTER)"
          if ! git push origin "HEAD:$HEAD_REF"; then
            echo "::error::Non-fast-forward push — the PR branch moved. Wait for the new measure run, then comment /accept-baselines again." >&2
            exit 1
          fi

      - name: Commit and push (fork)
        if: steps.stage.outputs.changed == 'true' && steps.resolve.outputs.is_fork == 'true'
        env:
          APP_TOKEN: ${{ steps.app-token.outputs.token }}
          HEAD_REF: ${{ steps.resolve.outputs.ref }}
          FULL_NAME: ${{ steps.resolve.outputs.full_name }}
          ACCEPTER: ${{ github.event.comment.user.login }}
        run: |
          set -e
          git config user.name "emdashbot[bot]"
          git config user.email "emdashbot[bot]@users.noreply.github.com"
          git commit -m "ci: accept visual baselines (requested by @$ACCEPTER)"
          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"
          if ! git push fork "HEAD:$HEAD_REF"; then
            rm -f "$GIT_ASKPASS"
            echo "::error::Failed to push to fork. The contributor likely has 'Allow edits by maintainers' disabled; they must regenerate baselines in the pinned Playwright Docker image and commit." >&2
            exit 1
          fi
          rm -f "$GIT_ASKPASS"

      - name: Report result
        if: steps.validate.outputs.ok == 'true'
        uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
        env:
          PR_NUMBER: ${{ steps.resolve.outputs.number }}
          ACCEPTER: ${{ github.event.comment.user.login }}
          CHANGED: ${{ steps.stage.outputs.changed }}
        with:
          script: |
            const changed = process.env.CHANGED === 'true';
            const body = changed
              ? `✅ Baselines accepted by @${process.env.ACCEPTER} and committed to this PR.`
              : 'ℹ️ The committed baselines already match the candidates; nothing to commit.';
            await github.rest.issues.createComment({
              owner: context.repo.owner, repo: context.repo.repo,
              issue_number: Number(process.env.PR_NUMBER), body,
            });

      - name: Report failure
        if: failure()
        uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
        env:
          RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
        with:
          script: |
            await github.rest.issues.createComment({
              owner: context.repo.owner, repo: context.repo.repo,
              issue_number: context.issue.number,
              body: `❌ \`/accept-baselines\` failed. See the [run log](${process.env.RUN_URL}). If this is a fork PR, the contributor may have "Allow edits by maintainers" disabled.`,
            });
