name: quality-gates

# 三层环境的 PR 门禁，按目标分支分流：
# - PR → develop (L2): build / migration-count / contract / backend-integration 四项硬阻断
# - PR → staging (L3): 不重跑 L2 已过的检查（仅 AI Review + 人工 approve，由 ai-review.yml 处理）
# - PR → production (L4): 同上 + Migration 风险审查
#
# 本文件由原 quality-gates.yml + agent-assets-check.yml 合并而成（资产同步校验内联为
# 第一个 job verify-agent-assets，重型 job 用 if: 限定只在 PR → develop 跑）。

permissions:
  contents: read

on:
  pull_request:
    branches:
      # 重型 job (build / migration / contract / backend-integration) 只在 PR → develop 跑（L2 门禁）。
      # PR → staging / production 走 ai-review.yml + 人工 approve，不重跑 L2 已过的检查。
      # 但 verify-agent-assets 在三层 PR 都要跑（防止 .agents 漂移），因此 trigger 列出全部分支，
      # 重型 job 用 if: 条件限定只在 develop 跑。
      - develop
      - staging
      - production
  push:
    # push: develop 仅给 verify-agent-assets 用（合并后兜一道 .agents 同步漂移检测）。
    # 其它重型 job 不跑——合并后由 deploy-test.yml 自带 build 当门禁，原 push 重跑跟 deploy
    # 并行等于事后告警，已废弃。
    branches:
      - develop

jobs:
  # ============================================================
  # 资产同步校验（合自原 agent-assets-check.yml，廉价，全 trigger 都跑）
  # ============================================================

  verify-agent-assets:
    runs-on: ubuntu-latest
    timeout-minutes: 5
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Verify source/distribution sync
        run: |
          bash scripts/sync-skills.sh
          bash scripts/check-agent-assets.sh
          bash scripts/check-entry-docs.sh
          git diff --exit-code -- .agents .claude

  # ============================================================
  # 静态检查（最便宜，最早跑）
  # ============================================================
  # 注：原 lint-and-type-check job 已合并进 build-check——nest build / next build
  # 自身就跑 tsc，单独再起一个 tsc --noEmit 是重复劳动；lint 当时挂 `|| true` 不阻断，
  # 留着也是装饰。如需把 lint 改硬阻断再单独加回。

  build-check:
    if: github.event_name == 'pull_request' && github.base_ref == 'develop'
    runs-on: ubuntu-latest
    timeout-minutes: 25
    env:
      # nest build 在 CI runner 上吃内存重，给 4GB 防 OOM (默认 ~1.7GB)
      # frontend tsc + next build 同样吃内存（Next 项目类型推导很重）
      NODE_OPTIONS: '--max-old-space-size=4096'
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: 22
          # 不启用 cache: 'npm'：dedicated-runner 是 host 模式 + persistent home，
          # ~/.npm/_cacache 28 GB 天然持续，setup-node 内置 cache 的 restore/save
          # 反而是纯冗余（每次 tar 3.4 GB 备份占 ~10 min/job）。
          # 详见 issue #456 + .learnings/ERRORS/ERR-20260519-007-setup-node-cache-redundant-on-host-runner.md。

      - name: Install dependencies
        run: |
          (cd backend && npm install)
          (cd frontend && npm install)

      - name: Generate Prisma Client
        run: cd backend && DATABASE_URL='postgresql://dummy:dummy@localhost:5432/dummy' npx prisma generate

      # Next.js build cache（issue #173）：纯文档 PR build 之前要 7m30s
      # collecting page data，加 .next/cache 后无关 PR 命中缓存可降到 ~1-2min。
      # key 维度：lockfile + next.config + tsconfig（依赖/配置变化才彻底失效），
      # 加上 src/public 内容 hash 做最优命中；restore-keys 兜底命中部分缓存。
      - name: Restore Next.js build cache
        uses: actions/cache@v4
        with:
          path: frontend/.next/cache
          key: nextjs-${{ hashFiles('frontend/package-lock.json', 'frontend/next.config.*', 'frontend/tsconfig.json') }}-${{ hashFiles('frontend/src/**', 'frontend/public/**') }}
          restore-keys: |
            nextjs-${{ hashFiles('frontend/package-lock.json', 'frontend/next.config.*', 'frontend/tsconfig.json') }}-

      - name: Backend build
        run: cd backend && npm run build

      - name: Frontend build
        run: cd frontend && npm run build

      # PR11.4: HostCapability 三处镜像（protocol / desktop main / backend types）+
      # dispatcher↔dispatchKey 双向 parity 字面校验。--ignore-scripts 跳 electron
      # postinstall（~80MB 下载）—— check:caps 只用 typescript，不需要 electron 二进制。
      - name: Desktop capability mirror check
        run: |
          cd desktop
          npm ci --ignore-scripts
          npm run check:caps

  migration-file-count:
    if: github.event_name == 'pull_request' && github.base_ref == 'develop'
    runs-on: ubuntu-latest
    timeout-minutes: 5
    steps:
      - name: Checkout
        uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: Check at most one new migration per commit (not PR-total)
        # CLAUDE.md 规则原意：「每次提交最多包含一个迁移文件」—— 是 per-commit
        # 约束。PR 累积多 migration 是合规的（epic PR 跨多 commit / merge 带进
        # source branch 的 migration），每个 migration 在它原始 commit 里 ≤ 1。
        # 早期版本错用 PR-total diff，会把合规的 epic PR / merge PR 误拦。
        #
        # Gitea Actions 默认 pipefail——grep 在无匹配时返回 1。用 || true 兜底。
        # ⚠️ 不要再加 `git fetch ... --depth=1`——checkout v4 已经 fetch-depth=0。
        run: |
          BASE_REF="${GITHUB_BASE_REF:-develop}"

          # 显式校验 origin/$BASE_REF 可达；fail-fast 而不是 fail-open
          if ! git rev-parse --verify "origin/${BASE_REF}" >/dev/null 2>&1; then
            echo "❌ origin/${BASE_REF} not found locally; checkout step needs fetch-depth: 0"
            exit 1
          fi

          VIOLATIONS=0
          for sha in $(git rev-list "origin/${BASE_REF}..HEAD"); do
            # Merge commit (>1 parent) 跳过：它合并多个 source commit，
            # 每个 source commit 的 migration 已经在原 commit 校验过
            parents=$(git rev-list --parents -n 1 "$sha" | awk '{print NF-1}')
            if [ "$parents" -gt 1 ]; then
              continue
            fi
            count=$(git show --name-only --pretty=format: "$sha" \
              | grep -cE '^backend/prisma/migrations/[^/]+/migration\.sql$' || true)
            if [ "$count" -gt 1 ]; then
              echo "❌ Commit $sha 新增 $count 个 migration（最多 1 个）："
              git show --name-only --pretty=format: "$sha" \
                | grep -E '^backend/prisma/migrations/[^/]+/migration\.sql$'
              VIOLATIONS=$((VIOLATIONS + 1))
            fi
          done

          if [ "$VIOLATIONS" -gt 0 ]; then
            echo "❌ 共 $VIOLATIONS 个 commit 违反 'CLAUDE.md: 每次提交最多一个迁移文件'"
            exit 1
          fi
          echo "✅ migration count check passed (per-commit basis)"

  partial-unique-guard:
    # 守护 *_active_key partial unique 索引不被误删（防 prisma migrate dev 自动 drift 修复
    # 退回普通 unique，详见 scripts/ops/check-partial-unique-guard.sh 头部注释）
    if: github.event_name == 'pull_request' && github.base_ref == 'develop'
    runs-on: ubuntu-latest
    timeout-minutes: 5
    steps:
      - name: Checkout
        uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: Check no DROP INDEX *_active_key in new migrations
        run: |
          BASE_REF="${GITHUB_BASE_REF:-develop}"
          if ! git rev-parse --verify "origin/${BASE_REF}" >/dev/null 2>&1; then
            echo "❌ origin/${BASE_REF} not found locally; checkout step needs fetch-depth: 0"
            exit 1
          fi

          # 取 PR 新增的 migration.sql 文件清单
          CHANGED=$(git diff --name-only --diff-filter=A "origin/${BASE_REF}...HEAD" \
            | grep -E '^backend/prisma/migrations/[^/]+/migration\.sql$' || true)

          if [ -z "$CHANGED" ]; then
            echo "✅ no new migrations to check"
            exit 0
          fi

          # 转成数组传给脚本
          # shellcheck disable=SC2086
          bash scripts/ops/check-partial-unique-guard.sh --files $CHANGED

  # ============================================================
  # 代码层（契约 + 集成）
  # ============================================================

  contract-check:
    if: github.event_name == 'pull_request' && github.base_ref == 'develop'
    runs-on: ubuntu-latest
    timeout-minutes: 10
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: 22
          # 不启用 cache: 'npm'：原因同 build-check job —— dedicated-runner host
          # 模式下 setup-node 内置 cache 纯冗余。
          # 详见 issue #456 + .learnings/ERRORS/ERR-20260519-007-setup-node-cache-redundant-on-host-runner.md。

      - name: Install dependencies
        # contract-check.ts 把前后端文件当文本读再正则解析，不 import 任何前端代码，
        # 因此不装 frontend；root 也无相关依赖。只保留 backend（Prisma 类型）+ testing。
        run: |
          (cd backend && npm install)
          (cd testing && npm install)

      - name: Run L0a/L0b contract check
        run: |
          cd testing
          npm run test:contract

  env-coverage-check:
    if: github.event_name == 'pull_request' && github.base_ref == 'develop'
    runs-on: ubuntu-latest
    timeout-minutes: 5
    steps:
      - name: Checkout
        uses: actions/checkout@v4
      - name: Verify .env.example covers all process.env.* references
        # 防漂移：新引入 env 必须同步加进 .env.example。
        # 历史遗留 49 项已 grandfather 到 scripts/ops/env-coverage-baseline.txt。
        run: bash scripts/ops/check-env-coverage.sh

  backend-integration:
    if: github.event_name == 'pull_request' && github.base_ref == 'develop'
    runs-on: uat-with-docker
    timeout-minutes: 45
    # 跟 deploy-uat::post-deploy-regression 共用全局测试容器 ffoa-test-postgres / ffoa-test-redis，
    # 必须串行：另一个 job 一上来 `docker rm -f` 会把正在跑的容器干掉。
    # 历史的 `concurrency: shared-test-containers` block 在 Gitea 1.25.x 静默失效（1.26.0+ 才支持），
    # 改用 flock 在 yaml 层做真串行——见下方 "Run integration + L0c" step。
    # 关联：.learnings/ERRORS/ERR-20260506-001.md / 工单 #264
    steps:
      - name: Checkout
        uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: Install dependencies
        run: |
          export NVM_DIR="$HOME/.nvm"
          [ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh"
          npm install
          (cd backend && npm install)
          (cd testing && npm install)

      - name: Generate Prisma Client
        run: |
          export NVM_DIR="$HOME/.nvm"
          [ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh"
          cd backend && DATABASE_URL='postgresql://dummy:dummy@localhost:5432/dummy' npx prisma generate

      # 共享测试容器整段使用周期（reset → 跑 jest → L0c）必须互斥；
      # flock 在 fd 200 上拿排他锁，fd 随 step shell 退出自动释放，runner 重启 /tmp 自清。
      # 1800s 上限保护意外卡死，远高于实测 jest 全量 ~5-10min。
      # 合并原 Reset / Detect+run / L0c 三 step 是为了让锁覆盖整个容器使用窗口——
      # 拆开则空隙仍可能被对方 docker rm 干掉容器。
      - name: Run integration + L0c (under flock on shared test containers)
        run: |
          export NVM_DIR="$HOME/.nvm"
          [ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh"

          exec 200>/tmp/ffoa-test-containers.lock
          if ! flock -x -w 1800 200; then
            echo "❌ 1800s 内未取到 ffoa-test 容器锁，疑似前序 job 卡死"
            echo "--- 诊断 ---"
            lsof /tmp/ffoa-test-containers.lock 2>&1 | head -20 || true
            docker ps -a --filter 'name=ffoa-test' --format 'table {{.Names}}\t{{.Status}}' 2>&1 || true
            echo "如需手动恢复（runner 上）：rm -f /tmp/ffoa-test-containers.lock && docker rm -f ffoa-test-postgres ffoa-test-redis"
            exit 1
          fi
          echo "✅ 已取得 ffoa-test 容器锁"

          # --- Phase 1: reset shared test containers ---
          # self-hosted runner 跨 PR 复用 docker daemon，上轮 PR 留下的孤儿连接会让
          # reset_test_db_schema 的 DROP DATABASE 卡死 → 强制重建容器（issue #211）。
          P_START=$SECONDS
          echo "▶ Phase 1: reset containers"
          docker rm -f ffoa-test-postgres ffoa-test-redis 2>/dev/null || true
          echo "✅ test containers removed; lib-test-db.sh will recreate"
          echo "✓ Phase 1 done in $((SECONDS - P_START))s"

          # --- Phase 2: detect modules + run jest ---
          # 显式 fail-fast：origin/$BASE_REF 必须可达；diff 失败必须直接红，
          # 不允许 `2>/dev/null || true` 把 CHANGED 吞成空 → 假装"无变更"跳过测试。
          P_START=$SECONDS
          echo "▶ Phase 2: detect modules + jest"
          BASE_REF="${GITHUB_BASE_REF:-develop}"
          if ! git rev-parse --verify "origin/${BASE_REF}" >/dev/null 2>&1; then
            echo "❌ origin/${BASE_REF} not found locally; checkout step needs fetch-depth: 0"
            exit 1
          fi

          CHANGED=$(git diff --name-only "origin/${BASE_REF}...HEAD")

          echo "=== Changed files ==="
          echo "$CHANGED"
          echo "===================="

          MODULES=$(echo "$CHANGED" | sed -n 's|^backend/src/modules/\([^/]*\)/.*|\1|p' | sort -u || true)
          TEST_MODULES=$(echo "$CHANGED" | sed -n 's|^testing/backend/integration/\([^/]*\)/.*|\1|p' | sort -u || true)
          ALL_MODULES=$(printf '%s\n%s\n' "$MODULES" "$TEST_MODULES" | sort -u | grep -v '^$' || true)

          echo "Detected modules: ${ALL_MODULES:-none}"

          IS_COMMON=$(echo "$CHANGED" | grep -cE 'backend/prisma/schema/base\.prisma|backend/src/common/|backend/src/core/|backend/src/app\.module' || true)
          if [ "$IS_COMMON" -gt 0 ]; then
            # 公共代码变更：跑全部集成测试，走 batch by module 路径（issue #260）。
            # 不传任何路径参数 → 脚本自动按 12 个 module 目录依次起 jest，
            # 每个模块独立 Node 进程，heap 不累积。脚本内部对每次 jest 显式 --runInBand
            # 保护单模块内 cleanup race（test DB 共享）。
            echo "公共代码变更，跑全部集成测试（batch by module）"
            (cd testing && bash scripts/run-backend-integration.sh --force-reset)
          else
            TEST_PATHS=""
            for mod in $ALL_MODULES; do
              if [ -d "testing/backend/integration/$mod" ]; then
                TEST_PATHS="$TEST_PATHS backend/integration/$mod"
              fi
            done

            if [ -z "$TEST_PATHS" ]; then
              echo "✅ 无相关模块变更，跳过集成测试"
            else
              # 模块路径变更：单次 jest 透传具体路径（脚本内部已加 --runInBand）。
              echo "受影响模块测试: $TEST_PATHS"
              (cd testing && bash scripts/run-backend-integration.sh --force-reset $TEST_PATHS)
            fi
          fi
          echo "✓ Phase 2 done in $((SECONDS - P_START))s"

          # --- Phase 3: L0c response snapshot ---
          # backend 此时仍在 localhost:3001（被上一步的集成测试拉起且保留）。
          # Gitea Actions 的 needs 只管依赖顺序，不共享 runner/container——L0c 必须跟 L1 同 step。
          P_START=$SECONDS
          echo "▶ Phase 3: L0c"
          (
            cd testing
            npx ts-node --transpile-only scripts/response-snapshot-check.ts || {
              echo "⚠️  L0c 失败（当前作为 warning；待 backend container 持久化稳定后改硬阻断）"
            }
          )
          echo "✓ Phase 3 done in $((SECONDS - P_START))s"
          # 锁随 step 退出由 fd 200 关闭自动释放
