name: Pi Auto Review

on:
  pull_request:
    types: [ready_for_review, opened, synchronize]
  workflow_call:
    inputs:
      pr_number:
        description: 'PR number to review'
        required: true
        type: number
      extra_prompt:
        description: 'Additional reviewer instructions appended to the standard review prompt'
        required: false
        type: string
        default: ''
      triggered_by:
        description: 'GitHub username that triggered this review (for audit only)'
        required: false
        type: string
        default: ''
    secrets:
      DEEPSEEK_API_KEY:
        required: false
      WINDMILL_EE_PRIVATE_ACCESS:
        required: false

concurrency:
  group: pi-review-${{ inputs.pr_number || github.event.pull_request.number }}
  cancel-in-progress: true

jobs:
  pi-review:
    runs-on: ubicloud-standard-2
    timeout-minutes: 30
    # A non-fork PR (head.repo.fork == false) can only be opened by someone with push
    # access to this repo, so fork==false already enforces write access. Do NOT re-add
    # an author_association gate: the pull_request webhook payload reports private org
    # members as CONTRIBUTOR/NONE (only public members show as MEMBER), which silently
    # skips auto-review for every private member.
    if: |
      github.event_name == 'workflow_call' ||
      (
        github.event.pull_request.draft == false &&
        github.event.pull_request.head.repo.fork == false
      )
    permissions:
      contents: read
      issues: write
      pull-requests: write
    steps:
      - name: Check Pi configuration
        id: pi_config
        env:
          DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }}
        run: |
          if [ -n "$DEEPSEEK_API_KEY" ]; then
            echo "enabled=true" >> "$GITHUB_OUTPUT"
          else
            echo "enabled=false" >> "$GITHUB_OUTPUT"
            echo "DEEPSEEK_API_KEY is not configured; skipping Pi review."
          fi

      - name: Resolve PR metadata
        if: steps.pi_config.outputs.enabled == 'true'
        id: pr
        env:
          GH_TOKEN: ${{ github.token }}
          INPUT_PR_NUMBER: ${{ inputs.pr_number }}
          EVENT_PR_NUMBER: ${{ github.event.pull_request.number }}
          EVENT_BASE_REF: ${{ github.event.pull_request.base.ref }}
          EVENT_BASE_SHA: ${{ github.event.pull_request.base.sha }}
          EVENT_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
          EVENT_TITLE: ${{ github.event.pull_request.title }}
          EVENT_BODY: ${{ github.event.pull_request.body }}
          EVENT_FORK: ${{ github.event.pull_request.head.repo.fork }}
          EVENT_AUTHOR: ${{ github.event.pull_request.user.login }}
          EVENT_ACTION: ${{ github.event.action }}
        run: |
          if [ -n "$INPUT_PR_NUMBER" ]; then
            PR_JSON=$(gh pr view "$INPUT_PR_NUMBER" --repo "${{ github.repository }}" \
              --json number,baseRefName,baseRefOid,headRefOid,title,body,isCrossRepository,author)
            PR_NUMBER=$(echo "$PR_JSON" | jq -r '.number')
            BASE_REF=$(echo "$PR_JSON" | jq -r '.baseRefName')
            BASE_SHA=$(echo "$PR_JSON" | jq -r '.baseRefOid')
            HEAD_SHA=$(echo "$PR_JSON" | jq -r '.headRefOid')
            PR_TITLE=$(echo "$PR_JSON" | jq -r '.title')
            PR_BODY=$(echo "$PR_JSON" | jq -r '.body // ""')
            IS_FORK=$(echo "$PR_JSON" | jq -r '.isCrossRepository')
            PR_AUTHOR=$(echo "$PR_JSON" | jq -r '.author.login // ""')
          else
            PR_NUMBER="$EVENT_PR_NUMBER"
            BASE_REF="$EVENT_BASE_REF"
            BASE_SHA="$EVENT_BASE_SHA"
            HEAD_SHA="$EVENT_HEAD_SHA"
            PR_TITLE="$EVENT_TITLE"
            PR_BODY="$EVENT_BODY"
            IS_FORK="$EVENT_FORK"
            PR_AUTHOR="$EVENT_AUTHOR"
          fi
          # Fork PRs run untrusted code with secrets present, so the automatic
          # pull_request trigger never reviews them. A non-empty INPUT_PR_NUMBER
          # means we arrived via workflow_call (a maintainer /pi comment gated by
          # check-write-access), so allow forks only on that path.
          if [ "$IS_FORK" = "true" ] && [ -z "$INPUT_PR_NUMBER" ]; then
            echo "Skipping Pi review for fork PR (automatic trigger)."
            echo "skip=true" >> "$GITHUB_OUTPUT"
            exit 0
          fi
          # An agent-driven PR flips to ready only after a clean /review round on
          # a draft, marked by an author comment naming the head SHA (pr skill,
          # "Review rounds"). Re-reviewing that same head on ready_for_review is
          # redundant. The marker alone is author attestation, so also require
          # reviewer evidence: a Codex review (posted by github-actions[bot], not
          # forgeable by the author) that predates the marker and carries a
          # non-blocking verdict. Comment-triggered and synchronize runs never
          # skip. Keep the three copies of this check in sync (pr-ready-review /
          # codex-pr-review / pi-pr-review); a shared local action would need the
          # repo checked out before the check, which the fork paths here
          # deliberately avoid.
          if [ "$EVENT_ACTION" = "ready_for_review" ] && [ -z "$INPUT_PR_NUMBER" ]; then
            # Fetch failures fail open (no skip): an API hiccup must run the
            # review, never skip it or fail the job.
            COMMENTS=$(gh api "repos/${{ github.repository }}/issues/$PR_NUMBER/comments?per_page=100" --paginate | jq -s '[.[][]]') || COMMENTS='[]'
            MARKER_TIME=$(jq -r --arg author "$PR_AUTHOR" --arg marker "✅ Review round clean @ $HEAD_SHA" \
              '[.[] | select(.user.login == $author) | select(.body | contains($marker)) | .created_at] | min // empty' <<<"$COMMENTS")
            CODEX_VERDICT=''
            if [ -n "$MARKER_TIME" ]; then
              # Only Codex evidence that predates the marker counts: the ready-
              # triggered Codex run itself posts after the flip and must not
              # vouch for a sibling reviewer's skip.
              CODEX_VERDICT=$(jq -r --arg mt "$MARKER_TIME" \
                '[.[] | select(.user.login == "github-actions[bot]") | select(.body | contains("## Codex Review")) | select(.created_at < $mt)] | last | .body // ""' <<<"$COMMENTS" \
                | grep -m1 -oE '(Good to merge|Mergeable, but should ideally address nits|Should address issues before merging)' || true)
            fi
            if [ -n "$MARKER_TIME" ] && [ -n "$CODEX_VERDICT" ] && [ "$CODEX_VERDICT" != "Should address issues before merging" ]; then
              echo "Clean review round marker found for $HEAD_SHA with pre-marker non-blocking Codex verdict; skipping redundant review."
              echo "skip=true" >> "$GITHUB_OUTPUT"
              exit 0
            fi
          fi
          # PR title/body are attacker-controlled free text. Use an unguessable
          # per-run delimiter so a fork can't embed a fixed heredoc terminator to
          # inject extra outputs — e.g. is_fork=false (last-write-wins), which
          # would re-enable the EE checkout and trusted-path settings for forks.
          RAND=$(head -c 16 /dev/urandom | od -An -tx1 | tr -d ' \n')
          TITLE_EOF="TITLE_EOF_${RAND}"
          BODY_EOF="BODY_EOF_${RAND}"
          {
            echo "skip=false"
            echo "is_fork=$IS_FORK"
            echo "pr_number=$PR_NUMBER"
            echo "base_ref=$BASE_REF"
            echo "base_sha=$BASE_SHA"
            echo "head_sha=$HEAD_SHA"
            echo "pr_author=$PR_AUTHOR"
            echo "title<<$TITLE_EOF"
            printf '%s\n' "$PR_TITLE"
            echo "$TITLE_EOF"
            echo "body<<$BODY_EOF"
            printf '%s\n' "$PR_BODY"
            echo "$BODY_EOF"
          } >> "$GITHUB_OUTPUT"

      - name: Checkout repository
        if: steps.pi_config.outputs.enabled == 'true' && steps.pr.outputs.skip != 'true'
        uses: actions/checkout@v5
        with:
          ref: refs/pull/${{ steps.pr.outputs.pr_number }}/merge
          fetch-depth: 1
          # Don't persist github.token in .git/config: the review agent can read
          # the checkout, and on the fork path that token (issue/PR write) would
          # otherwise be exfiltratable. All later git ops target the public origin
          # and need no auth; EE checkout and gh use their own explicit tokens.
          persist-credentials: false

      # Never expose the EE private-repo token to untrusted fork code. Skipping
      # this step leaves steps.ee.outputs.available empty, so the EE checkout and
      # substitution steps below are skipped too.
      - name: Check EE access
        if: steps.pi_config.outputs.enabled == 'true' && steps.pr.outputs.skip != 'true' && steps.pr.outputs.is_fork != 'true'
        id: ee
        env:
          EE_TOKEN: ${{ secrets.WINDMILL_EE_PRIVATE_ACCESS }}
        run: |
          if [ -n "$EE_TOKEN" ]; then
            echo "available=true" >> "$GITHUB_OUTPUT"
            echo "ee_repo_ref=$(cat ./backend/ee-repo-ref.txt)" >> "$GITHUB_OUTPUT"
          else
            echo "available=false" >> "$GITHUB_OUTPUT"
          fi

      - name: Checkout EE repository
        if: steps.ee.outputs.available == 'true'
        uses: actions/checkout@v5
        with:
          repository: windmill-labs/windmill-ee-private
          path: ./windmill-ee-private
          ref: ${{ steps.ee.outputs.ee_repo_ref }}
          token: ${{ secrets.WINDMILL_EE_PRIVATE_ACCESS }}
          fetch-depth: 1

      - name: Substitute EE code
        if: steps.ee.outputs.available == 'true'
        run: ./backend/substitute_ee_code.sh --copy --dir ./windmill-ee-private

      - name: Set up Node.js
        if: steps.pi_config.outputs.enabled == 'true' && steps.pr.outputs.skip != 'true'
        uses: actions/setup-node@v4
        with:
          node-version: 22

      - name: Install Pi CLI
        if: steps.pi_config.outputs.enabled == 'true' && steps.pr.outputs.skip != 'true'
        run: npm install --global @mariozechner/pi-coding-agent

      - name: Pre-fetch base and head refs for the PR
        if: steps.pi_config.outputs.enabled == 'true' && steps.pr.outputs.skip != 'true'
        env:
          PR_BASE_REF: ${{ steps.pr.outputs.base_ref }}
          PR_NUMBER: ${{ steps.pr.outputs.pr_number }}
        run: |
          git fetch --no-tags origin \
            "$PR_BASE_REF" \
            "+refs/pull/$PR_NUMBER/head"

      - name: Fetch prior PR discussion
        if: steps.pi_config.outputs.enabled == 'true' && steps.pr.outputs.skip != 'true'
        env:
          GH_TOKEN: ${{ github.token }}
          REPO: ${{ github.repository }}
          PR_NUMBER: ${{ steps.pr.outputs.pr_number }}
        run: |
          # Write outside the checkout: on the fork path the merge tree is
          # attacker-controlled, and a committed symlink at this path would
          # redirect the write.
          gh api "repos/$REPO/issues/$PR_NUMBER/comments?per_page=100" \
            --jq '[.[] | {user: .user.login, created_at: .created_at, body: (.body | .[:4000])}] | sort_by(.created_at) | .[-20:]' \
            > "$RUNNER_TEMP/prior-comments.json" || echo "[]" > "$RUNNER_TEMP/prior-comments.json"

      - name: Write Pi review context
        if: steps.pi_config.outputs.enabled == 'true' && steps.pr.outputs.skip != 'true'
        env:
          PR_REPOSITORY: ${{ github.repository }}
          PR_NUMBER: ${{ steps.pr.outputs.pr_number }}
          PR_BASE_SHA: ${{ steps.pr.outputs.base_sha }}
          PR_HEAD_SHA: ${{ steps.pr.outputs.head_sha }}
          PR_TITLE: ${{ steps.pr.outputs.title }}
          PR_BODY: ${{ steps.pr.outputs.body }}
          PR_AUTHOR: ${{ steps.pr.outputs.pr_author }}
          EXTRA_PROMPT: ${{ inputs.extra_prompt }}
        run: |
          node <<'NODE'
          const fs = require('fs');
          const tmp = process.env.RUNNER_TEMP;
          const lines = [
            `Repository: ${process.env.PR_REPOSITORY}`,
            `PR number: ${process.env.PR_NUMBER}`,
          ];
          if (process.env.PR_AUTHOR) {
            lines.push(`PR AUTHOR: ${process.env.PR_AUTHOR}`);
          }
          lines.push(
            `Base SHA: ${process.env.PR_BASE_SHA}`,
            `Head SHA: ${process.env.PR_HEAD_SHA}`,
            '',
            'PR title:',
            process.env.PR_TITLE || '(empty)',
            '',
            'PR body:',
            process.env.PR_BODY || '(empty)',
            '',
            'Changed commits command:',
            `git log --oneline ${process.env.PR_BASE_SHA}...${process.env.PR_HEAD_SHA}`,
            '',
            'Changed files command:',
            `git diff --stat ${process.env.PR_BASE_SHA}...${process.env.PR_HEAD_SHA}`,
            '',
            'Full review diff command:',
            `git diff --unified=0 ${process.env.PR_BASE_SHA}...${process.env.PR_HEAD_SHA}`
          );
          if (process.env.EXTRA_PROMPT && process.env.EXTRA_PROMPT.trim()) {
            lines.push('', 'Additional reviewer instructions:', process.env.EXTRA_PROMPT.trim());
          }
          if (fs.existsSync(`${tmp}/prior-comments.json`)) {
            try {
              const comments = JSON.parse(fs.readFileSync(`${tmp}/prior-comments.json`, 'utf8'));
              if (Array.isArray(comments) && comments.length > 0) {
                lines.push(
                  '',
                  'Prior PR discussion (most recent up to 20 comments):',
                  '',
                  'If you have already reviewed this PR (look for your own earlier "## Pi Review (DeepSeek V4)" comment), focus on what changed since then per the diff and respect any decisions the human made in replies. Do not re-flag findings the human already pushed back on.',
                  ''
                );
                for (const c of comments) {
                  lines.push(`### @${c.user} (${c.created_at})`, '', c.body, '', '---', '');
                }
              }
            } catch (_) {}
          }
          fs.writeFileSync(`${tmp}/pr-review-context.md`, `${lines.join('\n')}\n`);
          NODE

      - name: Run Pi review
        if: steps.pi_config.outputs.enabled == 'true' && steps.pr.outputs.skip != 'true'
        env:
          DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }}
          PI_SKIP_VERSION_CHECK: '1'
          PR_IS_FORK: ${{ steps.pr.outputs.is_fork }}
          PR_BASE_REF: ${{ steps.pr.outputs.base_ref }}
          PR_BASE_SHA: ${{ steps.pr.outputs.base_sha }}
          PR_HEAD_SHA: ${{ steps.pr.outputs.head_sha }}
        run: |
          set -o pipefail
          PI_HARDEN_FLAGS=()
          # Keep generated files (final message, events, context) outside the
          # checkout: on the fork path a committed symlink at any of these paths
          # would redirect our write and could overwrite an action's code that
          # then runs with our credentials. RUNNER_TEMP is outside the checkout.
          OUT_DIR="$RUNNER_TEMP"
          CTX="$RUNNER_TEMP/pr-review-context.md"
          if [ "$PR_IS_FORK" = "true" ]; then
            # Fork code is untrusted. Read the review policy/prompt from the base
            # ref (git show) rather than the attacker-controlled merge checkout,
            # so a malicious fork can't rewrite the reviewer's own instructions,
            # and drop the bash tool so the agent has no shell to exfiltrate with.
            git show "origin/$PR_BASE_REF:REVIEW.md" > /tmp/pi-prompt.md
            git show "origin/$PR_BASE_REF:.github/pi/pr-review.prompt.md" >> /tmp/pi-prompt.md
            PI_TOOLS=read,grep,find,ls

            # The agent has no shell, so pre-compute the diff (base...head SHAs are
            # trusted) into the context file it reads. It may still read fork files
            # by absolute path for extra context — reads are safe.
            {
              echo ""
              echo "## Pre-computed review diff (base...head)"
              echo "You have no shell. The full diff is below. The repository checkout"
              echo "is at $GITHUB_WORKSPACE — you may read files there by absolute path."
              echo '```diff'
              git -C "$GITHUB_WORKSPACE" diff --unified=0 "$PR_BASE_SHA...$PR_HEAD_SHA"
              echo '```'
            } >> "$CTX"

            # Pi resolves ALL project config from <cwd>/.pi (settings/packages,
            # extensions, skills, themes, prompts, SYSTEM.md); inside the fork
            # checkout a fork could inject any to run code or rewrite our system
            # prompt. Discovery is cwd-based, so run from a fresh empty dir.
            PI_WORKDIR=$(mktemp -d)
            cd "$PI_WORKDIR"

            # Belt-and-suspenders on top of the isolated cwd: refuse discovery of
            # extensions/skills/templates/themes/context-files, and PI_OFFLINE=1 to
            # block any startup network op or package install. PI_OFFLINE gates only
            # startup network ops, not the provider inference call.
            PI_HARDEN_FLAGS=(--no-extensions --no-skills --no-prompt-templates --no-themes --no-context-files)
            export PI_OFFLINE=1
          else
            cat REVIEW.md .github/pi/pr-review.prompt.md > /tmp/pi-prompt.md
            PI_TOOLS=read,grep,find,ls,bash
          fi
          # The context file lives in RUNNER_TEMP (outside the checkout); tell the
          # agent its absolute path.
          printf '\nReview context file (absolute path): %s\n' "$CTX" >> /tmp/pi-prompt.md
          pi -p \
            --provider deepseek \
            --model deepseek-v4-pro \
            --tools "$PI_TOOLS" \
            "${PI_HARDEN_FLAGS[@]}" \
            --mode json \
            < /tmp/pi-prompt.md \
            | tee "$OUT_DIR/pi-events.jsonl" \
            | jq -rc --unbuffered '
                if .type == "agent_start" then "🤖 pi agent started"
                elif .type == "turn_start" then "── turn ──"
                elif .type == "message_end" then
                  "[\(.message.role)] " + (
                    (.message.content // [])
                    | map(
                        if .type == "text" then "text(\(.text | length)c)"
                        elif .type == "tool_use" then "🔧 \(.name) \(.input | @json | .[:160])"
                        elif .type == "tool_result" then "✅ result"
                        else .type
                        end
                      )
                    | join(" | ")
                  )
                elif .type == "turn_end" then "── turn done (\((.toolResults // []) | length) tool result(s)) ──"
                elif .type == "agent_end" then "🏁 pi agent done"
                else empty
                end
              '

          jq -r '
            select(.type == "agent_end")
            | .messages
            | map(select(.role == "assistant"))
            | last
            | (.content[]? | select(.type == "text") | .text)
          ' "$OUT_DIR/pi-events.jsonl" > "$OUT_DIR/pi-final-message.md"

      - name: Post Pi review comment
        if: steps.pi_config.outputs.enabled == 'true' && steps.pr.outputs.skip != 'true'
        uses: actions/github-script@v7
        env:
          PR_NUMBER: ${{ steps.pr.outputs.pr_number }}
          DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }}
          GH_JOB_TOKEN: ${{ github.token }}
        with:
          github-token: ${{ github.token }}
          script: |
            const fs = require('fs');
            const path = `${process.env.RUNNER_TEMP}/pi-final-message.md`;
            if (!fs.existsSync(path)) {
              core.info('Pi did not produce a final message; skipping PR comment.');
              return;
            }
            let body = fs.readFileSync(path, 'utf8').trim();
            if (!body) {
              core.info('Pi final message was empty; skipping PR comment.');
              return;
            }
            // Defense-in-depth for fork reviews: the model call needs the provider
            // credential in the environment (readable via /proc/self/environ), and
            // the posted comment is an exfiltration channel that bypasses GitHub
            // Actions log masking. Strip the credential if it leaked into the text.
            for (const s of [process.env.DEEPSEEK_API_KEY, process.env.GH_JOB_TOKEN]) {
              if (typeof s === 'string' && s.length >= 8) {
                body = body.split(s).join('[REDACTED]');
              }
            }
            body = body.trim();
            if (!body) {
              core.info('Pi final message was empty after redaction; skipping PR comment.');
              return;
            }
            await github.rest.issues.createComment({
              owner: context.repo.owner,
              repo: context.repo.repo,
              issue_number: Number(process.env.PR_NUMBER),
              body,
            });
