# VERY IMPORTANT:
# This workflow has elevated permissions (contents:write, pull-requests:write).
# It should ONLY run on trusted maintainers code (e.g. wwwillchen).
# Author allowlists are enforced in the job steps below.
name: PR Review Responder

on:
  pull_request_target:
    types: [labeled]
  workflow_run:
    workflows: ["CI"]
    # Regardless of success or fail, we want to run this workflow.
    # Why?
    # If it's failure, we want to fix the errors.
    # If it's success, we want to address the PR review comments from the AI code reviewers.
    #
    # The CI workflow is almost always the last workflow to finish, so that's why we wait for it.
    types: [completed]

# Restrict default permissions; the job below declares only what it needs.
permissions: {}

jobs:
  respond-to-pr:
    if: >-
      github.event_name == 'workflow_run' ||
      (github.event_name == 'pull_request_target' && github.event.label.name == 'cc:request:now')
    environment: ai-bots
    runs-on:
      - self-hosted
      - macOS
      - ARM64
    permissions:
      contents: write # push commits to PR branches
      pull-requests: write # edit labels, post comments
    steps:
      - name: Create GitHub App token for base repo
        id: base-app-token
        uses: actions/create-github-app-token@v2
        with:
          app-id: ${{ vars.DYAD_GITHUB_APP_ID }}
          private-key: ${{ secrets.DYAD_GITHUB_APP_PRIVATE_KEY }}
          permission-actions: write
          permission-contents: write
          permission-pull-requests: write
          permission-workflows: write

      - name: Get PR info and check labels
        id: pr-info
        uses: actions/github-script@v7
        with:
          github-token: ${{ steps.base-app-token.outputs.token }}
          script: |
            const eventName = context.eventName;
            let prNumber, headRepo, headBranch, prLabels, prAuthor;

            if (eventName === 'pull_request_target') {
              // Triggered by adding cc:request:now label
              const pr = context.payload.pull_request;
              prNumber = pr.number;
              headRepo = pr.head.repo ? pr.head.repo.full_name : null;
              headBranch = pr.head.ref;
              prLabels = pr.labels;
              prAuthor = pr.user.login;

              // Check that the person who applied the label is a trusted actor
              const actor = context.actor;
              const allowedActors = ['wwwillchen', 'keppo-bot', 'dyad-assistant'];
              if (!allowedActors.includes(actor)) {
                console.log(`Label applied by ${actor} who is not in the allowed actors list`);
                core.setOutput('should_continue', 'false');
                return;
              }

              if (!headRepo) {
                console.log('Head repository not available');
                core.setOutput('should_continue', 'false');
                return;
              }
            } else {
              // workflow_run event
              const run = context.payload.workflow_run;

              // Get PR associated with this workflow run
              // Note: run.pull_requests can be empty for cross-repo PRs (forks)
              // So we also search by head SHA as a fallback
              if (run.pull_requests && run.pull_requests.length > 0) {
                prNumber = run.pull_requests[0].number;
              } else {
                // Search for PRs with matching head branch
                // Note: head_repository can be null if the fork was deleted
                if (!run.head_repository) {
                  console.log('Head repository not available');
                  core.setOutput('should_continue', 'false');
                  return;
                }
                const { data: prs } = await github.rest.pulls.list({
                  owner: context.repo.owner,
                  repo: context.repo.repo,
                  state: 'open',
                  head: `${run.head_repository.owner.login}:${run.head_branch}`
                });

                if (prs.length === 0) {
                  console.log('No pull requests found for this workflow run');
                  core.setOutput('should_continue', 'false');
                  return;
                }
                prNumber = prs[0].number;
              }

              if (!run.head_repository) {
                console.log('Head repository not available (fork may have been deleted)');
                core.setOutput('should_continue', 'false');
                return;
              }

              headRepo = run.head_repository.full_name;
              headBranch = run.head_branch;

              // Fetch full PR details to get labels and author
              const { data: pr } = await github.rest.pulls.get({
                owner: context.repo.owner,
                repo: context.repo.repo,
                pull_number: prNumber
              });
              prLabels = pr.labels;
              prAuthor = pr.user.login;
            }

            // Only allow wwwillchen, keppo-bot, dyad-assistant, and princeaden1 to use this workflow
            if (prAuthor !== 'wwwillchen' && prAuthor !== 'keppo-bot' && prAuthor !== 'dyad-assistant' && prAuthor !== 'princeaden1') {
              console.log(`PR #${prNumber} author ${prAuthor} is not allowed to use this workflow`);
              core.setOutput('should_continue', 'false');
              return;
            }

            // Check for cc:request, cc:request:now (initial) or cc:request:N (re-request) labels
            // If multiple labels exist, use the highest count to avoid resetting the retry counter
            let requestCount = -1;
            let currentLabel = '';

            for (const label of prLabels) {
              if (label.name === 'cc:request' || (label.name === 'cc:request:now' && eventName === 'pull_request_target')) {
                if (requestCount < 0) {
                  requestCount = 0;
                  currentLabel = label.name;
                }
              }
              const match = label.name.match(/^cc:request:(\d+)$/);
              if (match) {
                const count = parseInt(match[1], 10);
                if (count > requestCount) {
                  requestCount = count;
                  currentLabel = label.name;
                }
              }
            }

            if (requestCount === -1) {
              console.log(`PR #${prNumber} does not have a cc:request, cc:request:now, or cc:request:N label`);
              core.setOutput('should_continue', 'false');
              return;
            }

            // Guard: do not loop more than 3 retries (4 total runs: initial + 3 retries)
            // cc:request (0) -> cc:request:1 (1) -> cc:request:2 (2) -> cc:request:3 (3) -> blocked
            if (requestCount >= 4) {
              console.log(`PR #${prNumber} has reached max retry count (${requestCount}), adding needs-human:review-issue`);

              // Remove current label, cc:pending
              await github.rest.issues.removeLabel({
                owner: context.repo.owner,
                repo: context.repo.repo,
                issue_number: prNumber,
                name: currentLabel
              }).catch(() => {});

              await github.rest.issues.removeLabel({
                owner: context.repo.owner,
                repo: context.repo.repo,
                issue_number: prNumber,
                name: 'cc:pending'
              }).catch(() => {});

              // Also add needs-human:review-issue (retries exhausted = needs human attention).
              await github.rest.issues.addLabels({
                owner: context.repo.owner,
                repo: context.repo.repo,
                issue_number: prNumber,
                labels: ['needs-human:review-issue']
              });

              await github.rest.issues.removeLabel({
                owner: context.repo.owner,
                repo: context.repo.repo,
                issue_number: prNumber,
                name: 'needs-human:final-check'
              }).catch(() => {});

              core.setOutput('pr_number', prNumber);
              core.setOutput('should_continue', 'false');
              return;
            }

            console.log(`PR #${prNumber} has ${currentLabel} label (count=${requestCount}), proceeding with pr-fix`);
            core.setOutput('pr_number', prNumber);
            core.setOutput('should_continue', 'true');
            core.setOutput('request_count', requestCount);
            core.setOutput('current_label', currentLabel);
            core.setOutput('head_repo', headRepo);
            core.setOutput('head_branch', headBranch);
            // ci_conclusion is only available for workflow_run events
            core.setOutput('ci_conclusion', eventName === 'workflow_run'
              ? context.payload.workflow_run.conclusion
              : '');

      - name: Create GitHub App token for PR head repo
        if: steps.pr-info.outputs.should_continue == 'true'
        id: head-app-token
        uses: actions/create-github-app-token@v2
        with:
          app-id: ${{ vars.DYAD_GITHUB_APP_ID }}
          private-key: ${{ secrets.DYAD_GITHUB_APP_PRIVATE_KEY }}
          owner: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.head.repo.owner.login || github.event.workflow_run.head_repository.owner.login }}
          repositories: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.head.repo.name || github.event.workflow_run.head_repository.name }}
          permission-contents: write
          permission-workflows: write

      - name: Checkout repository
        if: steps.pr-info.outputs.should_continue == 'true'
        uses: actions/checkout@v5
        with:
          # Security note: The pr.user.login check verifies who opened the PR, but this
          # checks out code from whoever last pushed. If "Allow edits from maintainers" is
          # enabled, other maintainers could push to the branch. This is acceptable because
          # maintainers already have write access to the repo and are trusted.
          repository: ${{ steps.pr-info.outputs.head_repo }}
          ref: ${{ steps.pr-info.outputs.head_branch }}
          fetch-depth: 0

      - name: Record HEAD before Claude Code
        if: steps.pr-info.outputs.should_continue == 'true'
        id: before-claude
        run: echo "sha=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT

      - name: Configure push remote for fork PRs
        if: steps.pr-info.outputs.should_continue == 'true'
        run: |
          # claude-code-action overwrites origin's fetch URL to point to dyad-sh/dyad
          # (using GITHUB_REPOSITORY which is always the base repo in workflow_run events).
          # Setting pushurl separately ensures git push still targets the fork,
          # because git uses pushurl over url when both are configured.
          #
          # We use a GitHub App token instead of GITHUB_TOKEN so that
          # the push creates real PR events (synchronize) that trigger downstream workflows
          # like CI. Pushes made with GITHUB_TOKEN are silently ignored by GitHub to prevent
          # infinite loops.
          git remote set-url --push origin "https://x-access-token:${PR_HEAD_APP_TOKEN}@github.com/${{ steps.pr-info.outputs.head_repo }}.git"
          echo "Configured pushurl to ${{ steps.pr-info.outputs.head_repo }}"
        env:
          PR_HEAD_APP_TOKEN: ${{ steps.head-app-token.outputs.token }}

      - name: Ensure Homebrew paths are available
        if: steps.pr-info.outputs.should_continue == 'true'
        run: |
          # Self-hosted macOS runners often have gh installed via Homebrew, but
          # non-interactive shells may not include Homebrew directories in PATH.
          echo "/opt/homebrew/bin" >> "$GITHUB_PATH"
          echo "/usr/local/bin" >> "$GITHUB_PATH"

      - name: Update labels to pending
        if: steps.pr-info.outputs.should_continue == 'true'
        run: |
          gh pr edit ${{ steps.pr-info.outputs.pr_number }} --repo ${{ github.repository }} --remove-label "${{ steps.pr-info.outputs.current_label }}" --add-label "cc:pending"
        env:
          GITHUB_TOKEN: ${{ steps.base-app-token.outputs.token }}

      - name: Run PR Fix
        if: steps.pr-info.outputs.should_continue == 'true'
        uses: anthropics/claude-code-action@v1
        with:
          claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
          github_token: ${{ steps.base-app-token.outputs.token }}
          claude_args: --model claude-opus-4-6
          display_report: true
          prompt: |
            Run the following skill end-to-end. Execute every step sequentially. Do not skip, summarize, or stop early.

            /dyad:pr-fix ${{ steps.pr-info.outputs.pr_number }}

      - name: Push any unpushed changes
        # In case the previous step accidentally forgot to push its changes
        if: steps.pr-info.outputs.should_continue == 'true' && always()
        run: |
          if [ "$(git rev-list --count @{u}..HEAD 2>/dev/null || echo '0')" -gt 0 ]; then
            echo "Local branch is ahead of upstream, pushing..."
            git push --force-with-lease
          else
            echo "No unpushed commits"
          fi

      - name: Check if commits were pushed
        # Use always() to ensure we detect commits even if Claude Code fails partway through.
        # The push itself (made with the GitHub App token) triggers downstream workflows
        # like CI, BugBot, and Claude PR Review naturally via pull_request synchronize events.
        if: steps.pr-info.outputs.should_continue == 'true' && always()
        id: retrigger
        env:
          GITHUB_TOKEN: ${{ steps.base-app-token.outputs.token }}
        run: |
          # Use the GitHub API to get the current head SHA, avoiding race conditions with git fetch
          PR_HEAD_SHA=$(gh pr view ${{ steps.pr-info.outputs.pr_number }} --repo ${{ github.repository }} --json headRefOid --jq '.headRefOid')
          if [ -z "$PR_HEAD_SHA" ]; then
            echo "Failed to get PR head SHA, falling back to git fetch"
            git fetch origin ${{ steps.pr-info.outputs.head_branch }} || git fetch origin
            PR_HEAD_SHA=$(git rev-parse FETCH_HEAD 2>/dev/null || git rev-parse origin/${{ steps.pr-info.outputs.head_branch }})
          fi

          if [ "${{ steps.before-claude.outputs.sha }}" != "$PR_HEAD_SHA" ]; then
            echo "Claude pushed new commits (before: ${{ steps.before-claude.outputs.sha }}, after: $PR_HEAD_SHA)"
            echo "commits_pushed=true" >> $GITHUB_OUTPUT
          else
            echo "No new commits pushed"
            echo "commits_pushed=false" >> $GITHUB_OUTPUT
          fi

      - name: Update labels - re-request review (commits pushed)
        # Use always() so retry loop continues even if Claude Code fails after pushing commits
        if: steps.pr-info.outputs.should_continue == 'true' && always() && steps.retrigger.outputs.commits_pushed == 'true'
        run: |
          MAX_RETRIES=3
          REQUEST_COUNT="${{ steps.pr-info.outputs.request_count }}"
          if [ "${REQUEST_COUNT:-0}" -ge "$MAX_RETRIES" ]; then
            echo "Max retries reached (${REQUEST_COUNT}/${MAX_RETRIES}), marking PR as failed for human review"
            gh pr edit ${{ steps.pr-info.outputs.pr_number }} --repo ${{ github.repository }} --remove-label "cc:pending" --add-label "cc:failed" --add-label "needs-human:review-issue"
          else
            NEXT_COUNT=$(( ${REQUEST_COUNT:-0} + 1 ))
            echo "Setting cc:request:${NEXT_COUNT} to auto-re-request review (retry ${NEXT_COUNT} of ${MAX_RETRIES})"
            gh pr edit ${{ steps.pr-info.outputs.pr_number }} --repo ${{ github.repository }} --remove-label "cc:pending" --add-label "cc:request:${NEXT_COUNT}"
          fi
        env:
          GITHUB_TOKEN: ${{ steps.base-app-token.outputs.token }}

      - name: Update labels to done (no new commits)
        if: steps.pr-info.outputs.should_continue == 'true' && success() && steps.retrigger.outputs.commits_pushed == 'false'
        run: |
          gh pr edit ${{ steps.pr-info.outputs.pr_number }} --repo ${{ github.repository }} --remove-label "cc:pending" --add-label "cc:done"
        env:
          GITHUB_TOKEN: ${{ steps.base-app-token.outputs.token }}

      - name: Update labels to failed
        if: steps.pr-info.outputs.should_continue == 'true' && failure()
        run: |
          gh pr edit ${{ steps.pr-info.outputs.pr_number }} --repo ${{ github.repository }} --remove-label "cc:pending" --add-label "cc:failed"
        env:
          GITHUB_TOKEN: ${{ steps.base-app-token.outputs.token }}

      - name: Checkout base repo for labeler script
        # The earlier checkout checks out the PR head repo, which for
        # fork PRs may not contain scripts/pr-status-labeler.js. Checkout the
        # base repo's default branch to a separate path so the script is always available.
        # Only runs when ci_conclusion is available (workflow_run events).
        if: >-
          steps.pr-info.outputs.should_continue == 'true' &&
          steps.pr-info.outputs.ci_conclusion != '' &&
          always() &&
          steps.retrigger.outputs.commits_pushed != 'true'
        uses: actions/checkout@v5
        with:
          path: __base
          sparse-checkout: scripts/pr-status-labeler.js

      - name: Apply needs-human status label
        # Run when PR reaches a terminal state (cc:done or cc:failed) but NOT when
        # commits were pushed (retry loop continues). Uses always() to run even if
        # the Claude Code step failed. Only runs when ci_conclusion is available
        # (workflow_run events); cc:request:now triggers skip this since CI status is unknown.
        if: >-
          steps.pr-info.outputs.should_continue == 'true' &&
          steps.pr-info.outputs.ci_conclusion != '' &&
          always() &&
          steps.retrigger.outputs.commits_pushed != 'true'
        uses: actions/github-script@v7
        with:
          github-token: ${{ steps.base-app-token.outputs.token }}
          script: |
            const { run } = require('./__base/scripts/pr-status-labeler.js');
            await run({
              github,
              context,
              core,
              prNumber: parseInt('${{ steps.pr-info.outputs.pr_number }}', 10),
              ciConclusion: '${{ steps.pr-info.outputs.ci_conclusion }}'
            });
