name: auto-fix lint issues & formatting

# On push to main (or manual trigger), run `npm run fix` on each workspace
# package and apply any changes via a PR.
#
# Fixable lint issues (import sorting, unused imports, curly braces, etc.) are
# auto-corrected on merge so PRs aren't blocked by them. The PR-time eslint
# check in typecheck.yml fails only when un-fixable errors remain.
#
# NOTE: App token pushes DO trigger further workflow runs (unlike
# secrets.GITHUB_TOKEN). The concurrency group (ts-autofix-${{ github.ref }})
# with cancel-in-progress: true prevents an infinite loop — a re-triggered
# run cancels the in-flight one, and since the second run finds no new fixes
# (the first run already applied them), it exits with an empty patch.
#
# ── Security model: two-job split ───────────────────────────────────────────
#
# The eslint process executes repo code (eslint.config.mjs, package.json
# scripts, installed plugins). To prevent a malicious PR from getting arbitrary
# code execution on a runner with push access, the work is split:
#
#   1. generate-patch (unprivileged, contents: read only)
#      Checks out, installs deps, runs eslint --fix, produces a .patch artifact.
#      Worst case: malicious code runs here on an ephemeral runner with zero
#      push permissions.
#
#   2. apply-patch (privileged, contents: write + pull-requests: write)
#      Checks out, downloads the patch artifact, applies it, pushes to the
#      bot/js-autofix branch, creates/updates a PR, and enables auto-merge.
#      This job never runs npm, never installs anything, never executes any
#      repo code. The only input it trusts is the patch artifact.
#      Skipped entirely when generate-patch reports no fixes (has-fixes != true).
#      The PR auto-merges (squash) once CI passes. If CI fails or main moves,
#      the PR is auto-closed and the branch deleted — the next run re-applies
#      on the current state.

on:
  push:
    branches: [main]
    paths:
      - '**/*.js'
      - '**/*.cjs'
      - '**/*.mjs'
      - '**/*.ts'
      - '**/*.tsx'
      - 'package.json'
      - 'package-lock.json'
  workflow_dispatch:

permissions:
  contents: read # default; apply-patch job overrides to write

concurrency:
  group: ts-autofix-${{ github.ref }}
  cancel-in-progress: true

jobs:
  generate-patch:
    name: Generate eslint --fix patch
    runs-on: ubuntu-latest
    timeout-minutes: 5
    outputs:
      has-fixes: ${{ steps.produce-patch.outputs.has-fixes }}
    # No permissions override → inherits workflow-level contents: read.
    steps:
      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

      - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
        with:
          node-version: 26
          cache: npm

      - name: grab npm 12
        run: |
          npm i -g npm@12

      # --ignore-scripts: eslint only needs TS sources + eslint packages.
      - uses: ./.github/actions/retry
        with:
          command: npm ci --ignore-scripts

      - name: npm run fix in all workspaces
        # continue-on-error: if un-fixable errors exist on main, we still want
        # to commit whatever fixes were applied. The PR-time check in
        # typecheck.yml is what blocks un-fixable errors from landing.
        continue-on-error: true
        run: npm run fix

      - name: Produce patch
        id: produce-patch
        run: |
          # Exclude every file that the dep-version-gate ruleset guards
          # (package manifests, eslint configs, workflow files). A patch
          # that contains one of these files makes the bot PR wait for a
          # team review, and auto-merge then stops. The check step in
          # typecheck.yml still reports their lint errors.
          # The (glob) magic makes "**/" also match files at the repo
          # root, which plain pathspec wildcards do not.
          EXCLUDES=(
            ':(exclude,glob)**/package.json'
            ':(exclude,glob)**/package-lock.json'
            ':(exclude,glob)**/eslint.config.*'
            ':(exclude,glob).github/**'
          )
          if git diff --quiet -- . "${EXCLUDES[@]}"; then
            echo "No fixes needed."
            echo "has-fixes=false" >> "$GITHUB_OUTPUT"
            # Empty patch signals "nothing to do" to apply-patch.
            : > js-fix.patch
          else
            git diff -- . "${EXCLUDES[@]}" > js-fix.patch
            echo "has-fixes=true" >> "$GITHUB_OUTPUT"
            echo "Patch size: $(wc -c < js-fix.patch) bytes"

            # Reject patches that touch anything outside JS/TS/JSON sources.
            # `npm run fix` should only ever modify those; anything else means
            # eslint/prettier or a plugin went rogue and we refuse to ship it.
            BAD=$(git diff --name-only -- . "${EXCLUDES[@]}" | grep -vE '\.(js|cjs|mjs|ts|tsx|json)$' || true)
            if [ -n "$BAD" ]; then
              echo "::error::Refusing to upload patch — touches disallowed files:"
              echo "$BAD"
              exit 1
            fi
          fi

      - name: Upload patch artifact
        uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
        with:
          name: js-fix-patch
          path: js-fix.patch
          retention-days: 1
          include-hidden-files: true

  apply-patch:
    name: Apply patch
    needs: generate-patch
    # Skip entirely when generate-patch found no fixes — saves a runner,
    # avoids a redundant checkout/download, and keeps the job graph honest.
    if: needs.generate-patch.outputs.has-fixes == 'true'
    runs-on: ubuntu-latest
    timeout-minutes: 15
    environment: trusted-automation
    permissions:
      contents: write # needed to push to bot/js-autofix
      pull-requests: write # needed for PR creation + auto-merge
    steps:
      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

      - name: Get GitHub App token
        id: app-token
        uses: ./.github/actions/get-app-token
        with:
          client-id: ${{ vars.APP_CLIENT_ID }}
          private-key: ${{ secrets.APP_PRIVATE_KEY }}

      - name: Download patch
        uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
        with:
          name: js-fix-patch
          # ${{ runner.temp }} expands in with: params (shell-style $VAR does not).
          # download-artifact's path is a *directory* — the artifact's js-fix.patch
          # file lands inside it, so $RUNNER_TEMP/js-fix.patch resolves correctly
          # in the run step below.
          path: ${{ runner.temp }}

      - name: Apply patch and push to bot branch
        env:
          BOT_BRANCH: bot/js-autofix
        run: |
          set -euo pipefail

          # Empty patch = nothing to do.
          if [ ! -s "$RUNNER_TEMP/js-fix.patch" ]; then
            echo "Patch is empty. No fixes to apply."
            exit 0
          fi

          # Apply the patch produced by the unprivileged job.
          git apply --check "$RUNNER_TEMP/js-fix.patch" || {
            echo "::error::Patch does not apply cleanly. Branch may have moved."
            exit 1
          }
          git apply "$RUNNER_TEMP/js-fix.patch"

          git config user.name "github-actions[bot]"
          git config user.email "github-actions[bot]@users.noreply.github.com"
          git add -A
          git commit -m "fmt(js): \`npm run fix\` on merge"

          # Push to the dedicated bot branch. Force-push is safe here:
          # bot/js-autofix is a bot-only branch that gets rewritten each run.
          # If the branch was deleted after a previous PR merge, this
          # recreates it.
          git push --force origin HEAD:"$BOT_BRANCH"

      - name: Create/update PR and enable auto-merge
        env:
          GH_TOKEN: ${{ steps.app-token.outputs.token }}
          BOT_BRANCH: bot/js-autofix
        run: |
          set -euo pipefail

          # Create PR if one doesn't exist. If it already exists, the
          # force-push above already updated it with the latest fixes.
          PR_NUM=$(gh pr list --head "$BOT_BRANCH" --state open --json number --jq '.[0].number' 2>/dev/null || true)
          if [ -z "$PR_NUM" ]; then
            # gh pr create prints the PR URL. Extract the number from it
            # (https://github.com/<org>/<repo>/pull/<number>).
            PR_URL=$(gh pr create \
              --head "$BOT_BRANCH" --base main \
              --title 'fmt(js): `npm run fix` auto-fix' \
              --body 'Auto-generated by the `auto-fix lint issues & formatting` workflow. Auto-merges (squash) once CI passes. If CI fails or `main` moves, the PR is auto-closed and the branch deleted — the next run re-applies on the current state.')
            PR_NUM=$(echo "$PR_URL" | grep -oE '[0-9]+$')
          fi

          # Enable auto-merge (squash). If already enabled, this is a no-op.
          gh pr merge "$PR_NUM" --auto --squash || true

      - name: Wait for merge, auto-close on failure or stale
        env:
          GH_TOKEN: ${{ steps.app-token.outputs.token }}
          START_SHA: ${{ github.sha }}
        run: |
          set -euo pipefail

          PR_NUM=$(gh pr list --head bot/js-autofix --state open --json number --jq '.[0].number' 2>/dev/null || true)
          if [ -z "$PR_NUM" ]; then
            echo "No open PR. Nothing to wait for."
            exit 0
          fi

          echo "Waiting for PR #$PR_NUM to merge..."

          # Poll every 15s for up to ~10 minutes. Auto-merge will handle the
          # PR even if this job times out — the polling is for cleanup only
          # (auto-close on CI failure, conflicts, or main moving).
          for i in $(seq 1 40); do
            sleep 15

            STATE=$(gh pr view "$PR_NUM" --json state --jq '.state')
            if [ "$STATE" = "MERGED" ] || [ "$STATE" = "CLOSED" ]; then
              echo "PR #$PR_NUM is $STATE."
              exit 0
            fi

            # If main moved, the PR may have already merged (which moves
            # main) or another commit landed. Re-check state first.
            CURRENT_SHA=$(gh api "repos/${{ github.repository }}/branches/main" --jq '.commit.sha')
            if [ "$CURRENT_SHA" != "$START_SHA" ]; then
              STATE=$(gh pr view "$PR_NUM" --json state --jq '.state')
              if [ "$STATE" = "MERGED" ]; then
                echo "PR #$PR_NUM merged (main moved to $CURRENT_SHA)."
                exit 0
              fi
              echo "Main moved ($START_SHA → $CURRENT_SHA). Closing stale PR."
              gh pr close "$PR_NUM" --delete-branch || true
              exit 0
            fi

            # If CI checks failed, close + delete the branch.
            if gh pr checks "$PR_NUM" 2>/dev/null | grep -qi "fail"; then
              echo "CI failed on PR #$PR_NUM. Closing + deleting branch."
              gh pr close "$PR_NUM" --delete-branch
              exit 0
            fi

            # If PR is conflicted, close + delete the branch.
            MERGEABLE=$(gh pr view "$PR_NUM" --json mergeable --jq '.mergeable')
            if [ "$MERGEABLE" = "CONFLICTING" ]; then
              echo "PR #$PR_NUM is conflicted. Closing + deleting branch."
              gh pr close "$PR_NUM" --delete-branch
              exit 0
            fi
          done

          echo "Timeout reached. Auto-merge will handle PR #$PR_NUM if CI passes."
