# ==============================================================================
# GitHub Actions — Continuous Integration & Quality Gate
# ==============================================================================
# Runs on every push to any branch (including stacked PR branches, whose base
# is not main) and for pull requests into main from forks. Same-repository
# pull requests are already covered by their push run, so the pull_request
# run skips every job for them instead of running CI twice.
#
# Jobs run only when files they check change (or this workflow changes).
# "CI status" always reports, so it can be the one required status check.
# ==============================================================================
name: Continuous Integration & Quality Gate

on:
  push:
    branches: ['**']
  pull_request:
    branches: [main]

permissions:
  contents: read

concurrency:
  group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
  cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}

env:
  # Build records are uploaded as artifacts by default; they count against the
  # storage quota of this private repository.
  DOCKER_BUILD_RECORD_UPLOAD: "false"

jobs:
  # ----------------------------------------------------------------------------
  # 0. Detect which areas changed
  # ----------------------------------------------------------------------------
  changes:
    name: Detect Changed Areas
    if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name != github.repository
    runs-on: ubuntu-24.04
    permissions:
      contents: read
      pull-requests: read
    outputs:
      go: ${{ steps.filter.outputs.go }}
      python: ${{ steps.filter.outputs.python }}
      frontend: ${{ steps.filter.outputs.frontend }}
      docker: ${{ steps.filter.outputs.docker }}
      scripts: ${{ steps.filter.outputs.scripts }}
      sites: ${{ steps.filter.outputs.sites }}
    steps:
      # Full history lets paths-filter find the merge base with main locally,
      # so it never needs credentials to fetch from this private repository.
      - name: Checkout Code
        uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          fetch-depth: 0
          persist-credentials: false

      # Pushes to a branch compare with its merge base with main; pushes to
      # main compare with the previous head; fork PRs use the PR's file list.
      - name: Filter Changed Paths
        id: filter
        uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3
        with:
          filters: |
            go:
              - '.github/workflows/ci.yml'
              - 'go.mod'
              - 'go.sum'
              - '**/*.go'
              - 'src/backend/**/*.sql'
              - 'src/services/github-backup-worker/**/*.sql'
            python:
              - '.github/workflows/ci.yml'
              - 'pyrightconfig.json'
              - 'src/services/github-backup-observatory/**'
            frontend:
              - '.github/workflows/ci.yml'
              - 'src/apps/github-backup-dashboard/**'
            sites:
              - '.github/workflows/ci.yml'
              - 'src/apps/github-backup-design/**'
              - 'src/apps/github-backup-docs/**'
              - 'docs/**'
            docker:
              - '.github/workflows/ci.yml'
              - '**/Dockerfile'
              - '.dockerignore'
              - 'docker-compose.yml'
              - 'scripts/smoke-test-stack.sh'
              - 'go.mod'
              - 'go.sum'
              - '**/*.go'
              - 'src/backend/**/*.sql'
              - 'src/services/github-backup-worker/**/*.sql'
              - 'src/services/github-backup-observatory/**'
              - 'src/apps/github-backup-dashboard/**'
            scripts:
              - '.github/workflows/ci.yml'
              - '.githooks/**'
              - 'scripts/**'
              - 'tests/**'
              - '.agents/skills/**'
              - 'Makefile'
              # scripts/setup.sh builds the worker and reads its flags.
              - 'src/services/github-backup-worker/cmd/**'
              - 'src/services/github-backup-worker/internal/app/**'

  # ----------------------------------------------------------------------------
  # 1. Go API, worker and MCP server
  # ----------------------------------------------------------------------------
  go:
    name: Go (gofmt, Tidy, Vet, Race Tests, Builds)
    needs: changes
    if: needs.changes.outputs.go == 'true'
    runs-on: ubuntu-24.04
    timeout-minutes: 20
    steps:
      - name: Checkout Code
        uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          persist-credentials: false

      - name: Set up Go
        uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
        with:
          go-version-file: go.mod
          cache-dependency-path: go.sum

      - name: Check Formatting (gofmt)
        run: |
          unformatted="$(git ls-files -z '*.go' | xargs -0 gofmt -l)"
          if [ -n "${unformatted}" ]; then
            echo "::error::These files are not gofmt-formatted (run 'make format'):"
            echo "${unformatted}"
            exit 1
          fi

      - name: Check go.mod and go.sum Are Tidy
        run: go mod tidy -diff

      - name: Run Go Vet
        run: go vet ./...

      - name: Run Go Unit Tests with Race Detector
        run: go test -race -count=1 ./...

      - name: Build Static API, Worker and MCP Binaries
        env:
          CGO_ENABLED: "0"
        run: |
          go build ./...
          go build -o "${RUNNER_TEMP}/bin/" \
            ./src/backend/github-backup-api/cmd/github-backup-api \
            ./src/services/github-backup-worker/cmd/github-backup-worker \
            ./src/backend/github-backup-mcp/cmd/github-backup-mcp
          ls -l "${RUNNER_TEMP}/bin"

  go-integration:
    name: Go Integration Tests (PostgreSQL)
    needs: changes
    if: needs.changes.outputs.go == 'true'
    runs-on: ubuntu-24.04
    timeout-minutes: 20
    # The integration tests create a throwaway database per test from
    # TEST_DATABASE_URL and apply every migration; the S3 tests skip without
    # TEST_S3_* settings.
    services:
      postgres:
        image: pgvector/pgvector:pg16
        env:
          POSTGRES_PASSWORD: postgres
        ports:
          - 5432:5432
        options: >-
          --health-cmd "pg_isready -U postgres"
          --health-interval 5s
          --health-timeout 5s
          --health-retries 10
    steps:
      - name: Checkout Code
        uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          persist-credentials: false

      - name: Set up Go
        uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
        with:
          go-version-file: go.mod
          cache-dependency-path: go.sum

      - name: Run Go Integration Tests Against PostgreSQL
        env:
          TEST_DATABASE_URL: postgres://postgres:postgres@localhost:5432/postgres?sslmode=disable
        run: go test -count=1 -p 2 ./...

  # ----------------------------------------------------------------------------
  # 2. Python Observatory
  # ----------------------------------------------------------------------------
  python:
    name: Python Observatory (Pyright & AI Agent Suite)
    needs: changes
    if: needs.changes.outputs.python == 'true'
    runs-on: ubuntu-24.04
    timeout-minutes: 15
    defaults:
      run:
        working-directory: src/services/github-backup-observatory
    # The integration tests build a throwaway database per test from the Go
    # migration files; they skip when TEST_DATABASE_URL is unset.
    services:
      postgres:
        image: pgvector/pgvector:pg16
        env:
          POSTGRES_PASSWORD: postgres
        ports:
          - 5432:5432
        options: >-
          --health-cmd "pg_isready -U postgres"
          --health-interval 5s
          --health-timeout 5s
          --health-retries 10
    steps:
      - name: Checkout Code
        uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          persist-credentials: false

      # uv reads Python 3.12 from .python-version.
      - name: Install uv
        uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
        with:
          version: "0.12.18"
          working-directory: src/services/github-backup-observatory

      - name: Install Locked Dependencies
        run: uv sync --locked

      - name: Run Pyright Static Type Check
        run: uv run --with pyright pyright

      - name: Run the Observatory Test Suite (unit and integration)
        env:
          TEST_DATABASE_URL: postgres://postgres:postgres@localhost:5432/postgres?sslmode=disable
        run: uv run pytest

  # ----------------------------------------------------------------------------
  # 3. Next.js dashboard
  # ----------------------------------------------------------------------------
  frontend:
    name: Frontend (Biome, tsc, Vitest & Next.js Build)
    needs: changes
    if: needs.changes.outputs.frontend == 'true'
    runs-on: ubuntu-24.04
    timeout-minutes: 15
    defaults:
      run:
        working-directory: src/apps/github-backup-dashboard
    env:
      NEXT_TELEMETRY_DISABLED: "1"
    steps:
      - name: Checkout Code
        uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          persist-credentials: false

      # Installs the pnpm release pinned in package.json "packageManager".
      - name: Install pnpm
        uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
        with:
          package_json_file: src/apps/github-backup-dashboard/package.json

      - name: Set up Node.js
        uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
        with:
          node-version: '22'
          cache: pnpm
          cache-dependency-path: src/apps/github-backup-dashboard/pnpm-lock.yaml

      - name: Install Dependencies
        run: pnpm install --frozen-lockfile

      - name: Run Biome Linter & Formatter Check
        run: pnpm run lint

      - name: Run TypeScript Type Check
        run: pnpm exec tsc --noEmit

      - name: Run Vitest Unit Tests
        run: pnpm test

      - name: Run Next.js Turbopack Production Build
        run: pnpm run build

  # ----------------------------------------------------------------------------
  # 4. Build every image and smoke-test the compose stack
  # ----------------------------------------------------------------------------
  # 4b. The two static sites: the design system website and the docs site
  # ----------------------------------------------------------------------------
  sites:
    name: Sites (design & docs)
    needs: changes
    if: needs.changes.outputs.sites == 'true'
    runs-on: ubuntu-latest
    timeout-minutes: 15
    strategy:
      fail-fast: false
      matrix:
        app: [github-backup-design, github-backup-docs]
    defaults:
      run:
        working-directory: src/apps/${{ matrix.app }}
    env:
      NEXT_TELEMETRY_DISABLED: 1
    steps:
      - name: Checkout Code
        uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          persist-credentials: false
      - name: Set up pnpm
        uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
        with:
          package_json_file: src/apps/${{ matrix.app }}/package.json
      - name: Set up Node.js
        uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
        with:
          node-version: 22
          cache: pnpm
          cache-dependency-path: src/apps/${{ matrix.app }}/pnpm-lock.yaml
      - name: Install
        run: pnpm install --frozen-lockfile
      - name: Lint (design site)
        if: matrix.app == 'github-backup-design'
        run: pnpm run lint
      - name: Type check
        run: pnpm run typecheck
      - name: Build
        run: pnpm run build

  # ----------------------------------------------------------------------------
  smoke:
    name: Docker Images & Compose Smoke Test
    needs: changes
    if: needs.changes.outputs.docker == 'true'
    runs-on: ubuntu-24.04
    timeout-minutes: 30
    steps:
      - name: Checkout Code
        uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          persist-credentials: false

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1

      # Builds the five compose images (github-backup-*:local) with a cache
      # scope per image. Compose refuses to parse the file without these
      # variables; the images do not contain them and the smoke test
      # generates its own random values.
      - name: Build Images
        uses: docker/bake-action@018cb6412ab401ebaa809aa5f85966b74628600f # v7.4.0
        env:
          INTERNAL_SECRET: unused-at-build-time
          JWT_SECRET: unused-at-build-time
          MCP_AUTH_TOKEN: unused-at-build-time
        with:
          source: .
          files: docker-compose.yml
          load: true
          set: |
            github-backup-api.cache-from=type=gha,scope=smoke-github-backup-api
            github-backup-api.cache-to=type=gha,scope=smoke-github-backup-api,mode=max
            github-backup-worker.cache-from=type=gha,scope=smoke-github-backup-worker
            github-backup-worker.cache-to=type=gha,scope=smoke-github-backup-worker,mode=max
            github-backup-mcp.cache-from=type=gha,scope=smoke-github-backup-mcp
            github-backup-mcp.cache-to=type=gha,scope=smoke-github-backup-mcp,mode=max
            github-backup-observatory.cache-from=type=gha,scope=smoke-github-backup-observatory
            github-backup-observatory.cache-to=type=gha,scope=smoke-github-backup-observatory,mode=max
            github-backup-dashboard.cache-from=type=gha,scope=smoke-github-backup-dashboard
            github-backup-dashboard.cache-to=type=gha,scope=smoke-github-backup-dashboard,mode=max

      - name: Smoke-Test the Stack
        env:
          SMOKE_SKIP_BUILD: "1"
        run: ./scripts/smoke-test-stack.sh

  # ----------------------------------------------------------------------------
  # 5. Scripts, hooks and skills
  # ----------------------------------------------------------------------------
  scripts:
    name: Scripts, Hooks & Skills
    needs: changes
    if: needs.changes.outputs.scripts == 'true'
    runs-on: ubuntu-24.04
    timeout-minutes: 15
    steps:
      - name: Checkout Code
        uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          persist-credentials: false

      # The setup script test builds and installs the worker into a fake HOME.
      - name: Set up Go
        uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
        with:
          go-version-file: go.mod
          cache-dependency-path: go.sum

      # The shell suites validate YAML with PyYAML.
      - name: Install uv
        uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
        with:
          version: "0.12.18"

      - name: Check Shell Syntax
        run: |
          for script in .githooks/pre-commit .githooks/pre-push .githooks/post-commit scripts/*.sh tests/*.sh; do
            bash -n "${script}"
          done

      # macOS ships bash 3.2; with this image the setup script test also runs
      # its whole suite under bash 3.2.
      - name: Pull bash 3.2 for the macOS Compatibility Check
        run: |
          docker pull bash@sha256:0fd7cb8499c63a3c9345e7088a9cd83bb69f6e895e83833859aff838a0312091
          docker tag bash@sha256:0fd7cb8499c63a3c9345e7088a9cd83bb69f6e895e83833859aff838a0312091 bash:3.2

      - name: Run Script, Hook and Skill Tests
        run: |
          uv run --no-project --python 3.12 --with pyyaml -- bash tests/jules_loop_test.sh
          uv run --no-project --python 3.12 --with pyyaml -- bash tests/github_automation_test.sh
          bash tests/githooks_test.sh
          bash tests/setup_script_test.sh
          bash tests/skills_sync_test.sh
          python3 -m unittest discover -s tests -p 'test_*.py'
          python3 scripts/validate-skills.py

  # ----------------------------------------------------------------------------
  # 5b. Windows setup script
  # ----------------------------------------------------------------------------
  # Parses scripts/setup.ps1 and dry-runs it on a Windows runner, under
  # PowerShell 7 and Windows PowerShell 5.1. Real installs (Task Scheduler
  # registration) are not exercised.
  setup-windows:
    name: Windows Setup Script (dry run)
    needs: changes
    if: needs.changes.outputs.scripts == 'true'
    runs-on: windows-2025
    timeout-minutes: 15
    steps:
      - name: Checkout Code
        uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          persist-credentials: false

      # The dry run still checks that Go is new enough for go.mod.
      - name: Set up Go
        uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
        with:
          go-version-file: go.mod
          cache-dependency-path: go.sum

      - name: Parse scripts/setup.ps1
        shell: pwsh
        run: |
          $tokens = $null
          $errors = $null
          [System.Management.Automation.Language.Parser]::ParseFile("$PWD/scripts/setup.ps1", [ref]$tokens, [ref]$errors) | Out-Null
          if ($errors) {
            $errors | ForEach-Object { Write-Host "::error file=scripts/setup.ps1,line=$($_.Extent.StartLineNumber)::$($_.Message)" }
            exit 1
          }

      # -DryRun prints every change instead of making it; status only reads.
      - name: Dry-Run Install, Status and Uninstall (PowerShell 7)
        shell: pwsh
        run: |
          ./scripts/setup.ps1 install -DryRun -NoLogin
          ./scripts/setup.ps1 status
          ./scripts/setup.ps1 uninstall -DryRun

      # The script's own usage example runs it with Windows PowerShell 5.1.
      - name: Dry-Run Install (Windows PowerShell 5.1)
        shell: powershell
        run: ./scripts/setup.ps1 install -DryRun -NoLogin

  # ----------------------------------------------------------------------------
  # 6. Aggregate status (the single check to require in branch protection)
  # ----------------------------------------------------------------------------
  ci-status:
    name: CI Status
    needs: [changes, go, go-integration, python, frontend, sites, smoke, scripts, setup-windows]
    if: always() && (github.event_name == 'push' || github.event.pull_request.head.repo.full_name != github.repository)
    runs-on: ubuntu-24.04
    steps:
      - name: Report Job Results
        env:
          RESULTS: ${{ toJSON(needs) }}
        run: |
          {
            echo "## CI status"
            echo ""
            echo "| Job | Result |"
            echo "|---|---|"
            echo "${RESULTS}" | jq -r 'to_entries[] | "| \(.key) | \(.value.result) |"'
            echo ""
            echo "Skipped jobs had no relevant file changes."
          } >> "${GITHUB_STEP_SUMMARY}"
          if echo "${RESULTS}" | jq -e 'any(.[]; .result == "failure" or .result == "cancelled")' >/dev/null; then
            echo "::error::At least one CI job failed or was cancelled."
            exit 1
          fi
