name: CI

on:
  push:
    branches:
      - main
  pull_request:
    types: [opened, synchronize, reopened, closed]

concurrency:
  group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
  cancel-in-progress: true

defaults:
  run:
    shell: bash

jobs:
  check-changes:
    # Skip closed PR events (we only listen to 'closed' to trigger cancellation of in-progress runs)
    if: github.event.action != 'closed'
    runs-on: ubuntu-latest
    outputs:
      should_run_tests: ${{ steps.check.outputs.should_run_tests }}
      build_os: ${{ steps.matrix.outputs.build_os }}
      e2e_os: ${{ steps.matrix.outputs.e2e_os }}
      e2e_shard: ${{ steps.matrix.outputs.e2e_shard }}
      e2e_shard_total: ${{ steps.matrix.outputs.e2e_shard_total }}
    steps:
      - name: Checkout code
        uses: actions/checkout@v4
        with:
          repository: ${{ github.event.pull_request.head.repo.full_name || github.repository }}
          ref: ${{ github.event.pull_request.head.ref || '' }}
          fetch-depth: 0
      - name: Check if only .claude files changed
        id: check
        run: |
          if [ "${{ github.event_name }}" = "push" ] && [ "${{ github.ref }}" = "refs/heads/main" ]; then
            # Always run tests on pushes to main
            echo "should_run_tests=true" >> $GITHUB_OUTPUT
            echo "Running tests: push to main branch"
            exit 0
          fi

          if [ "${{ github.event_name }}" = "pull_request" ]; then
            BASE_SHA="${{ github.event.pull_request.base.sha }}"
            HEAD_SHA="${{ github.event.pull_request.head.sha }}"
          else
            # For push events, compare with parent commit
            BASE_SHA="${{ github.event.before }}"
            HEAD_SHA="${{ github.sha }}"
          fi

          # Get list of changed files
          CHANGED_FILES=$(git diff --name-only "$BASE_SHA" "$HEAD_SHA" 2>/dev/null || echo "")

          if [ -z "$CHANGED_FILES" ]; then
            echo "should_run_tests=true" >> $GITHUB_OUTPUT
            echo "Running tests: could not determine changed files"
            exit 0
          fi

          echo "Changed files:"
          echo "$CHANGED_FILES"

          # Check if all changed files are in config-only directories (.claude, rules)
          NON_CONFIG_FILES=$(echo "$CHANGED_FILES" | grep -v "^\.claude/" | grep -v "^rules/" || true)

          if [ -z "$NON_CONFIG_FILES" ]; then
            echo "should_run_tests=false" >> $GITHUB_OUTPUT
            echo "Skipping tests: all changed files are in .claude/ or rules/"
          else
            echo "should_run_tests=true" >> $GITHUB_OUTPUT
            echo "Running tests: found files outside .claude/ and rules/:"
            echo "$NON_CONFIG_FILES"
          fi

      - name: Detect privileged author
        id: author-check
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: |
          AUTHOR=""
          if [ "${{ github.event_name }}" = "pull_request" ]; then
            AUTHOR="${{ github.event.pull_request.user.login }}"
          fi
          echo "Author: $AUTHOR"
          if [ "$AUTHOR" = "wwwillchen" ] || [ "$AUTHOR" = "wwwillchen-bot" ] || [ "$AUTHOR" = "dyadbot" ] || [ "$AUTHOR" = "azizmejri1" ]; then
            echo "is_privileged=true" >> $GITHUB_OUTPUT
          else
            echo "is_privileged=false" >> $GITHUB_OUTPUT
          fi

      # Sets the dynamic matrix for the build and e2e-tests jobs based on the PR author.
      # The "image" field is a JSON-encoded array string so that fromJSON() in runs-on
      # can produce the correct label(s) for both GitHub-hosted and self-hosted runners.
      #
      # Privileged authors (wwwillchen, wwwillchen-bot, dyadbot, azizmejri1):
      #   - Self-hosted macOS ARM64 runners, no Windows, no sharding.
      #
      #   build (macOS self-hosted) ──> e2e-tests (macOS self-hosted, shard 1/1)
      #                                          │
      #                                          v
      #                                    merge-reports
      #
      # All other authors:
      #   - GitHub-hosted runners for macOS + Windows, 4 shards each.
      #
      #   build (macOS) ──┐
      #   build (Windows) ┼──> e2e-tests (macOS shards 1-4)    ──┐
      #                   │    e2e-tests (Windows shards 1-4)  ──┼──> merge-reports
      #                   └──────────────────────────────────────┘
      - name: Set CI matrix
        id: matrix
        run: |
          if [ "${{ steps.author-check.outputs.is_privileged }}" = "true" ]; then
            echo 'build_os=[{"name":"macos","image":"[\"self-hosted\",\"macOS\",\"ARM64\"]"}]' >> $GITHUB_OUTPUT
            echo 'e2e_os=[{"name":"macos","image":"[\"self-hosted\",\"macOS\",\"ARM64\"]"}]' >> $GITHUB_OUTPUT
            echo 'e2e_shard=[1,2]' >> $GITHUB_OUTPUT
            echo 'e2e_shard_total=[2]' >> $GITHUB_OUTPUT
          else
            echo 'build_os=[{"name":"windows","image":"[\"windows-latest\"]"},{"name":"macos","image":"[\"macos-latest\"]"}]' >> $GITHUB_OUTPUT
            echo 'e2e_os=[{"name":"windows","image":"[\"windows-latest\"]"},{"name":"macos","image":"[\"macos-latest\"]"}]' >> $GITHUB_OUTPUT
            echo 'e2e_shard=[1,2,3,4]' >> $GITHUB_OUTPUT
            echo 'e2e_shard_total=[4]' >> $GITHUB_OUTPUT
          fi

  build:
    needs: [check-changes]
    if: needs.check-changes.outputs.should_run_tests == 'true'
    # Why Mac and Windows?
    # I can't run electron playwright on ubuntu-latest and
    # Linux support for Dyad is experimental so not as important
    # as Mac + Windows
    strategy:
      fail-fast: false
      matrix:
        os: ${{ fromJSON(needs.check-changes.outputs.build_os) }}
    runs-on: ${{ fromJSON(matrix.os.image) }}
    steps:
      - name: Checkout code
        uses: actions/checkout@v4
        with:
          repository: ${{ github.event.pull_request.head.repo.full_name || github.repository }}
          ref: ${{ github.event.pull_request.head.ref || '' }}
      - name: Initialize environment
        uses: actions/setup-node@v4
        with:
          node-version-file: package.json
          cache: npm
          cache-dependency-path: package-lock.json
      - name: Install npm 11.8.0
        run: npm install -g npm@11.8.0
      - name: Install node modules
        run: bash scripts/npm-ci-retry.sh
        env:
          # Required for @vscode/ripgrep to download binaries without hitting GitHub API rate limits
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
      - name: Presubmit check (e.g. lint, format)
        # do not run this on Windows (it fails and not necessary)
        if: contains(matrix.os.name, 'macos')
        run: npm run presubmit
      - name: Type-checking
        # do not run this on windows (it's redundant)
        if: contains(matrix.os.name, 'macos')
        run: npm run ts
      - name: Unit tests
        # do not run this on windows (it's redundant)
        if: contains(matrix.os.name, 'macos')
        run: npm run test
      - name: Build
        env:
          NODE_OPTIONS: "--max-old-space-size=4096"
        run: npm run pre:e2e
      - name: Prep test server
        run: cd testing/fake-llm-server && npm install && npm run build && cd -
      # macOS: Archive app bundle with tar to preserve metadata (code signatures, extended attributes)
      # This is necessary because GitHub artifact upload/download loses these attributes
      - name: Archive macOS app bundle
        if: contains(matrix.os.name, 'macos')
        run: tar -cvf out-macos.tar out/
      - name: Upload build artifacts (macOS)
        if: contains(matrix.os.name, 'macos')
        uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
        with:
          name: build-${{ matrix.os.name }}
          path: |
            out-macos.tar
            scaffold/
            testing/fake-llm-server/
          retention-days: 1
      - name: Upload build artifacts (Windows)
        if: contains(matrix.os.name, 'windows')
        uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
        with:
          name: build-${{ matrix.os.name }}
          path: |
            out/
            scaffold/
            testing/fake-llm-server/
          retention-days: 1
      - name: Cleanup (self-hosted macOS)
        if: always() && contains(matrix.os.image, 'self-hosted')
        run: bash scripts/ci-cleanup-macos.sh

  e2e-tests:
    needs: [check-changes, build]
    strategy:
      fail-fast: false
      matrix:
        os: ${{ fromJSON(needs.check-changes.outputs.e2e_os) }}
        shard: ${{ fromJSON(needs.check-changes.outputs.e2e_shard) }}
        shardTotal: ${{ fromJSON(needs.check-changes.outputs.e2e_shard_total) }}
    runs-on: ${{ fromJSON(matrix.os.image) }}
    steps:
      - name: Checkout code
        uses: actions/checkout@v4
        with:
          repository: ${{ github.event.pull_request.head.repo.full_name || github.repository }}
          ref: ${{ github.event.pull_request.head.ref || '' }}
      - name: Initialize environment
        uses: actions/setup-node@v4
        with:
          node-version-file: package.json
          cache: npm
          cache-dependency-path: package-lock.json
      - name: Install npm 11.8.0
        run: npm install -g npm@11.8.0
      - name: Install node modules
        run: bash scripts/npm-ci-retry.sh
        env:
          # Required for @vscode/ripgrep to download binaries without hitting GitHub API rate limits
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
      - name: Setup pnpm
        uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
        with:
          version: latest
      - name: Clone nextjs-template
        run: git clone --depth 1 https://github.com/dyad-sh/nextjs-template.git nextjs-template
      - name: Get pnpm store directory
        id: pnpm-cache
        shell: bash
        run: |
          echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT
      - name: Setup pnpm cache
        uses: actions/cache@v4
        with:
          path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
          key: ${{ matrix.os.name }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
          restore-keys: |
            ${{ matrix.os.name }}-pnpm-store-
      # Not strictly needed but makes the e2e tests faster (and less flaky)
      - name: Install scaffold dependencies
        run: cd scaffold && pnpm install
      - name: Install nextjs-template dependencies
        run: cd nextjs-template && pnpm install
      - name: Download build artifacts
        uses: actions/download-artifact@v4
        with:
          name: build-${{ matrix.os.name }}
      # macOS: Extract tar archive to restore app bundle with preserved metadata
      - name: Extract macOS app bundle
        if: contains(matrix.os.name, 'macos')
        run: tar -xvf out-macos.tar
      - name: Install Chromium browser for Playwright
        run: npx playwright install chromium --with-deps
      - name: E2E tests (Shard ${{ matrix.shard }}/${{ matrix.shardTotal }})
        env:
          FLAKINESS_ACCESS_TOKEN: ${{ secrets.FLAKINESS_ACCESS_TOKEN }}
          # Self-hosted macOS runners can handle more parallelism
          PLAYWRIGHT_PARALLELISM: ${{ contains(matrix.os.image, 'self-hosted') && '3' || '1' }}
          # Self-hosted macOS runners use fewer retries (1) vs GitHub-hosted CI (2)
          PLAYWRIGHT_RETRIES: ${{ contains(matrix.os.image, 'self-hosted') && '1' || '2' }}
        # You can add debug logging to make it easier to see what's failing
        # by adding "DEBUG=pw:browser" in front.
        # Use blob reporter for sharding and merge capabilities
        run: DEBUG=pw:browser npx playwright test --shard=${{ matrix.shard }}/${{ matrix.shardTotal }}
      - name: Record shard metadata
        if: ${{ !cancelled() }}
        run: |
          mkdir -p blob-report
          cat <<EOF > "blob-report/_playwright-shard-metadata-${{ matrix.os.name }}-shard-${{ matrix.shard }}-of-${{ matrix.shardTotal }}.json"
          {
            "os": "${{ matrix.os.name }}",
            "shard": ${{ matrix.shard }},
            "total": ${{ matrix.shardTotal }}
          }
          EOF
      - name: Upload shard results
        uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
        if: ${{ !cancelled() }}
        with:
          name: blob-report-${{ matrix.os.name }}-shard-${{ matrix.shard }}
          path: blob-report
          retention-days: 1
      - name: Upload flakiness report (for fork PRs)
        uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
        if: ${{ !cancelled() }}
        with:
          name: flakiness-report-${{ matrix.os.name }}-shard-${{ matrix.shard }}
          path: flakiness-report
          retention-days: 1
      - name: Cleanup (self-hosted macOS)
        if: always() && contains(matrix.os.image, 'self-hosted')
        run: bash scripts/ci-cleanup-macos.sh

  merge-reports:
    # Merge reports after playwright-tests, even if some shards have failed
    # Skip if tests were skipped (only .claude files changed)
    if: ${{ !cancelled() && needs.check-changes.outputs.should_run_tests == 'true' }}
    needs: [check-changes, e2e-tests]
    permissions:
      contents: read
      pull-requests: write
      actions: read

    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          repository: ${{ github.event.pull_request.head.repo.full_name || github.repository }}
          ref: ${{ github.event.pull_request.head.ref || '' }}
      - uses: actions/setup-node@v4
        with:
          node-version: lts/*
      - name: Install npm 11.8.0
        run: npm install -g npm@11.8.0
      - name: Install dependencies
        run: npm ci --no-audit --no-fund --progress=false
        env:
          # Required for @vscode/ripgrep to download binaries without hitting GitHub API rate limits
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

      - name: Download blob reports from GitHub Actions Artifacts
        uses: actions/download-artifact@v4
        with:
          path: all-blob-reports
          pattern: blob-report-*
          merge-multiple: true

      - name: Debug - List downloaded blob reports
        run: |
          echo "Contents of all-blob-reports directory:"
          ls -la all-blob-reports/
          echo "File sizes and details:"
          find all-blob-reports/ -type f -exec ls -lh {} \; || echo "No files found"

      - name: Merge into HTML Report
        run: PLAYWRIGHT_HTML_OUTPUT_DIR=playwright-report npx playwright merge-reports --config=merge.config.ts ./all-blob-reports

      - name: Debug - List playwright-report contents
        run: |
          echo "Contents of playwright-report directory:"
          ls -la playwright-report/ || echo "playwright-report directory does not exist"
          echo "Current directory contents:"
          ls -la

      - name: Upload HTML report
        uses: actions/upload-artifact@v4
        with:
          name: html-report
          path: playwright-report
          retention-days: 3
          overwrite: true

      - name: Generate Playwright summary
        uses: actions/github-script@v7
        env:
          PLAYWRIGHT_RUN_ID: ${{ github.run_id }}
        with:
          script: |
            const { run } = require('./scripts/generate-playwright-summary.js');
            await run({ github, context, core });
