# VERY IMPORTANT:
# This workflow has a lot of permissions!
# It should ONLY run on trusted maintainers code (e.g. wwwillchen)
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]

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: ubuntu-latest
    permissions:
      actions: write
      contents: write
      pull-requests: write
    steps:
      - name: Get PR info and check labels
        id: pr-info
        uses: actions/github-script@v7
        with:
          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', 'wwwillchen-bot'];
              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 and wwwillchen-bot to use this workflow
            if (prAuthor !== 'wwwillchen' && prAuthor !== 'wwwillchen-bot') {
              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: 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 PR_CONTENTS_RW_GITHUB_TOKEN (a PAT) 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_CONTENTS_RW_GITHUB_TOKEN}@github.com/${{ steps.pr-info.outputs.head_repo }}.git"
          echo "Configured pushurl to ${{ steps.pr-info.outputs.head_repo }}"
        env:
          PR_CONTENTS_RW_GITHUB_TOKEN: ${{ secrets.PR_CONTENTS_RW_GITHUB_TOKEN }}

      - 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: ${{ secrets.GITHUB_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: ${{ secrets.GITHUB_TOKEN }}
          claude_args: --model claude-opus-4-6
          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()
        uses: anthropics/claude-code-action@v1
        with:
          claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
          github_token: ${{ secrets.GITHUB_TOKEN }}
          claude_args: --model claude-opus-4-6
          prompt: |
            The previous step may have made local commits but
            accidentally forgot to push them to the origin remote. Run the following skill to
            push any unpushed changes. If there are no unpushed commits, that's fine — just
            exit successfully.

            /dyad:fast-push

      - 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 PR_CONTENTS_RW_GITHUB_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: ${{ secrets.GITHUB_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: |
          REQUEST_COUNT="${{ steps.pr-info.outputs.request_count }}"
          NEXT_COUNT=$(( ${REQUEST_COUNT:-0} + 1 ))
          echo "Setting cc:request:${NEXT_COUNT} to auto-re-request review (retry ${NEXT_COUNT} of 3)"
          gh pr edit ${{ steps.pr-info.outputs.pr_number }} --repo ${{ github.repository }} --remove-label "cc:pending" --add-label "cc:request:${NEXT_COUNT}"
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_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: ${{ secrets.GITHUB_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: ${{ secrets.GITHUB_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:
          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 }}'
            });
