name: Docs Agent

on:
  workflow_run: # zizmor: ignore[dangerous-triggers] main-only docs repair after trusted CI; job gates repository, event, branch, actor, conclusion, exact current main SHA, and hourly cadence before using write token
    workflows:
      - CI
    types:
      - completed
  workflow_dispatch:

permissions:
  actions: read
  contents: write

concurrency:
  group: docs-agent-main
  cancel-in-progress: false

env:
  FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"

jobs:
  update-docs:
    if: >
      github.repository == 'openclaw/openclaw' &&
      github.actor != 'github-actions[bot]' &&
      (github.event_name != 'workflow_run' ||
        (github.event.workflow_run.conclusion == 'success' &&
          github.event.workflow_run.event == 'push' &&
          github.event.workflow_run.head_branch == 'main' &&
          github.event.workflow_run.actor.login != 'github-actions[bot]'))
    runs-on: ubuntu-24.04
    timeout-minutes: 30
    steps:
      - name: Checkout
        uses: actions/checkout@v6
        with:
          ref: main
          fetch-depth: 0
          persist-credentials: false
          submodules: false

      - name: Gate trusted main activity and hourly cadence
        id: gate
        env:
          EVENT_NAME: ${{ github.event_name }}
          GH_TOKEN: ${{ github.token }}
          WORKFLOW_HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
        run: |
          set -euo pipefail

          if [ "$EVENT_NAME" != "workflow_run" ]; then
            head_sha="$(git rev-parse HEAD)"
            review_base="$(git rev-parse "${head_sha}^" 2>/dev/null || printf '%s' "$head_sha")"
            {
              echo "run_agent=true"
              echo "base_sha=${head_sha}"
              echo "review_base_sha=${review_base}"
              echo "review_head_sha=${head_sha}"
            } >> "$GITHUB_OUTPUT"
            exit 0
          fi

          for attempt in 1 2 3 4 5; do
            if git fetch --no-tags origin main; then
              break
            fi
            if [ "$attempt" = "5" ]; then
              echo "Failed to fetch main after retries." >&2
              exit 1
            fi
            echo "Fetch attempt ${attempt} failed; retrying."
            sleep $((attempt * 2))
          done
          remote_main="$(git rev-parse origin/main)"
          if [ "$remote_main" != "$WORKFLOW_HEAD_SHA" ]; then
            echo "CI run is superseded by ${remote_main}; skipping docs agent for ${WORKFLOW_HEAD_SHA}."
            echo "run_agent=false" >> "$GITHUB_OUTPUT"
            exit 0
          fi

          runs_json="$RUNNER_TEMP/docs-agent-runs.json"
          gh api --method GET "repos/${GITHUB_REPOSITORY}/actions/workflows/docs-agent.yml/runs" \
            -f branch=main \
            -f event=workflow_run \
            -f per_page=100 > "$runs_json"

          one_hour_ago="$(date -u -d '1 hour ago' +%Y-%m-%dT%H:%M:%SZ)"
          recent_runs="$(
            jq -r \
              --argjson current_run_id "$GITHUB_RUN_ID" \
              --arg one_hour_ago "$one_hour_ago" \
              '.workflow_runs[]
                | select(.database_id != $current_run_id)
                | select(.created_at >= $one_hour_ago)
                | select(.status != "cancelled")
                | select((.conclusion // "") != "skipped")
                | [.database_id, .status, (.conclusion // ""), .created_at, .head_sha]
                | @tsv' "$runs_json"
          )"

          if [ -n "$recent_runs" ]; then
            echo "Docs agent already ran or is running within the last hour; skipping."
            printf '%s\n' "$recent_runs"
            echo "run_agent=false" >> "$GITHUB_OUTPUT"
            exit 0
          fi

          review_base="$(
            jq -r \
              --argjson current_run_id "$GITHUB_RUN_ID" \
              --arg remote_main "$remote_main" \
              '.workflow_runs[]
                | select(.database_id != $current_run_id)
                | select(.status != "cancelled")
                | select((.conclusion // "") != "skipped")
                | .head_sha
                | select(. != null and . != "")
                | select(. != $remote_main)
              ' "$runs_json" | head -n 1
          )"
          if [ -z "$review_base" ] || ! git cat-file -e "${review_base}^{commit}" 2>/dev/null; then
            review_base="$(git rev-parse "${remote_main}^" 2>/dev/null || printf '%s' "$remote_main")"
          fi

          {
            echo "run_agent=true"
            echo "base_sha=${remote_main}"
            echo "review_base_sha=${review_base}"
            echo "review_head_sha=${remote_main}"
          } >> "$GITHUB_OUTPUT"

      - name: Setup Node environment
        if: steps.gate.outputs.run_agent == 'true'
        uses: ./.github/actions/setup-node-env
        with:
          install-bun: "false"

      - name: Ensure docs agent key exists
        if: steps.gate.outputs.run_agent == 'true'
        env:
          OPENAI_API_KEY: ${{ secrets.OPENCLAW_DOCS_AGENT_OPENAI_API_KEY || secrets.OPENAI_API_KEY }}
        run: |
          set -euo pipefail
          if [ -z "${OPENAI_API_KEY:-}" ]; then
            echo "Missing OPENCLAW_DOCS_AGENT_OPENAI_API_KEY or OPENAI_API_KEY secret." >&2
            exit 1
          fi

      - name: Run Codex docs agent
        if: steps.gate.outputs.run_agent == 'true'
        uses: openai/codex-action@5c3f4ccdb2b8790f73d6b21751ac00e602aa0c02
        env:
          DOCS_AGENT_BASE_SHA: ${{ steps.gate.outputs.review_base_sha }}
          DOCS_AGENT_HEAD_SHA: ${{ steps.gate.outputs.review_head_sha }}
        with:
          openai-api-key: ${{ secrets.OPENCLAW_DOCS_AGENT_OPENAI_API_KEY || secrets.OPENAI_API_KEY }}
          prompt-file: .github/codex/prompts/docs-agent.md
          model: ${{ vars.OPENCLAW_CI_OPENAI_MODEL_BARE }}
          effort: medium
          sandbox: workspace-write
          safety-strategy: drop-sudo
          codex-args: '["--full-auto"]'

      - name: Enforce existing-docs-only patch
        if: steps.gate.outputs.run_agent == 'true'
        run: |
          set -euo pipefail

          untracked="$(git ls-files --others --exclude-standard)"
          if [ -n "$untracked" ]; then
            echo "Docs agent created untracked files; forbidden:"
            printf '%s\n' "$untracked"
            exit 1
          fi

          added_or_deleted="$(git diff --name-status --diff-filter=AD)"
          if [ -n "$added_or_deleted" ]; then
            echo "Docs agent added or deleted tracked files; forbidden:"
            printf '%s\n' "$added_or_deleted"
            exit 1
          fi

          bad_paths="$(
            git diff --name-only | while IFS= read -r path; do
              case "$path" in
                docs/*|README.md|CHANGELOG.md) ;;
                *) printf '%s\n' "$path" ;;
              esac
            done
          )"
          if [ -n "$bad_paths" ]; then
            echo "Docs agent touched non-doc paths; forbidden:"
            printf '%s\n' "$bad_paths"
            exit 1
          fi

      - name: Restore Node 24 path
        if: steps.gate.outputs.run_agent == 'true'
        run:
          | # zizmor: ignore[github-env] NODE_BIN is set by the trusted local setup-node-env action in this same job
          set -euo pipefail
          export PATH="${NODE_BIN}:${PATH}"
          echo "${NODE_BIN}" >> "$GITHUB_PATH"
          node -v
          corepack enable
          pnpm -v

      - name: Check docs
        if: steps.gate.outputs.run_agent == 'true'
        run: pnpm check:docs

      - name: Commit docs updates
        if: steps.gate.outputs.run_agent == 'true'
        env:
          BASE_SHA: ${{ steps.gate.outputs.base_sha }}
          GITHUB_TOKEN: ${{ github.token }}
          TARGET_BRANCH: main
        run: |
          set -euo pipefail

          if git diff --quiet; then
            echo "No docs changes."
            exit 0
          fi

          git config user.name "openclaw-docs-agent[bot]"
          git config user.email "openclaw-docs-agent[bot]@users.noreply.github.com"
          git add docs README.md CHANGELOG.md
          git commit --no-verify -m "docs: refresh documentation"

          for attempt in 1 2 3 4 5; do
            if ! git fetch --no-tags origin "${TARGET_BRANCH}"; then
              echo "Fetch attempt ${attempt} failed; retrying."
              sleep $((attempt * 2))
              continue
            fi
            if git push "https://x-access-token:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" HEAD:"${TARGET_BRANCH}"; then
              exit 0
            fi
            remote_main="$(git rev-parse "origin/${TARGET_BRANCH}")"
            if [ "$remote_main" != "$BASE_SHA" ]; then
              echo "main advanced from ${BASE_SHA} to ${remote_main}; skipping stale docs update."
              exit 0
            fi
            echo "Docs update attempt ${attempt} failed; retrying."
            sleep $((attempt * 2))
          done

          echo "Failed to push docs updates after retries." >&2
          exit 1
