name: Issue & PR Automation

on:
  issues:
    types: [opened]
  pull_request_target:
    types: [opened]
  issue_comment:
    types: [created]
  pull_request_review_comment:
    types: [created]

permissions:
  contents: read
  issues: write
  pull-requests: write
  models: read

env:
  # Single source of truth for the model used by both triage and composer.
  # GitHub Models (free, billed to the repo's plan) takes `publisher/name` model
  # ids. gpt-4.1 is the most capable model actually reachable on the free tier:
  # the gpt-5 family returns unavailable_model and o3/o3-mini return 403.
  TRIAGE_MODEL: openai/gpt-4.1
  COMPOSER_MODEL: openai/gpt-4.1

jobs:
  analyze-issue:
    if: github.repository == 'zhom/donutbrowser' && github.event_name == 'issues'
    runs-on: ubuntu-latest
    steps:
      - name: Checkout repository
        uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 #v7.0.1

      - name: Check if first-time contributor
        id: check-first-time
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          ISSUE_AUTHOR: ${{ github.event.issue.user.login }}
        run: |
          ISSUE_COUNT=$(gh api "/repos/${{ github.repository }}/issues?state=all&creator=$ISSUE_AUTHOR&per_page=100" \
            --jq "[.[] | select(.number != ${{ github.event.issue.number }}) ] | length" \
            || echo "0")

          if [ "$ISSUE_COUNT" = "0" ]; then
            echo "is_first_time=true" >> $GITHUB_OUTPUT
          else
            echo "is_first_time=false" >> $GITHUB_OUTPUT
          fi

      - name: Parse issue template fields
        env:
          ISSUE_BODY: ${{ github.event.issue.body }}
        run: |
          node --input-type=module <<'EOF'
          import fs from 'node:fs';
          import { redactIssueBody, redactSensitiveText } from './scripts/redact-sensitive-text.mjs';
          const body = process.env.ISSUE_BODY || '';
          // GitHub issue templates render fields as `### Heading\nValue` blocks.
          // Split on `###` at line start to recover them.
          const fields = {};
          const sections = body.split(/^###\s+/m);
          for (const section of sections.slice(1)) {
            const nl = section.indexOf('\n');
            if (nl < 0) continue;
            const heading = section.slice(0, nl).trim();
            const value = section.slice(nl + 1).trim();
            const normalized = value === '_No response_' ? '' : value;
            fields[heading] = heading === 'Error logs or screenshots'
              ? '[omitted from automated processing]'
              : redactSensitiveText(normalized);
          }
          fs.writeFileSync('/tmp/issue-fields.json', JSON.stringify(fields, null, 2));
          const get = (k) => fields[k] || '';
          fs.writeFileSync('/tmp/issue-os.txt', get('Operating System'));
          fs.writeFileSync('/tmp/issue-version.txt', get('Donut Browser version'));
          fs.writeFileSync('/tmp/issue-wayfern-version.txt', get('Wayfern version'));
          fs.writeFileSync('/tmp/issue-repro.txt', get('Steps to reproduce'));
          fs.writeFileSync('/tmp/issue-what.txt', get('What happened?') || get('What do you want?'));
          fs.writeFileSync('/tmp/issue-body.txt', redactIssueBody(body));
          EOF

      - name: Build repo context
        env:
          ISSUE_TITLE: ${{ github.event.issue.title }}
        run: |
          cp CLAUDE.md /tmp/repo-context.txt
          printf '%s' "$ISSUE_TITLE" | node scripts/redact-sensitive-text.mjs > /tmp/issue-title.txt

          # List all source files for the AI to choose from
          find . -type f \( -name "*.rs" -o -name "*.ts" -o -name "*.tsx" \) \
            ! -path "*/node_modules/*" ! -path "*/target/*" ! -path "*/.next/*" ! -path "*/dist/*" \
            ! -path "*/.git/*" ! -path "*/gen/*" ! -path "*/data/*" \
            | sed 's|^\./||' | sort > /tmp/all-source-files.txt

      - name: Write shared knowledge files (scope + pricing)
        run: |
          cat > /tmp/scope-and-pricing.md <<'EOF'
          # PROJECT SCOPE

          - Donut Browser: this repo. A Tauri desktop launcher (Rust + Next.js) that
            downloads, manages, and launches anti-detect browser profiles. In-scope for bug
            reports about profile management, downloads, sync, proxy, VPN, the launcher UI,
            its API, MCP server, and the bundled `donut-sync` self-hosted server.
          - Wayfern: a Chromium fork maintained by zhom (the same maintainer). Wayfern
            bugs are in-scope here unless they are obviously upstream Chromium issues.
          - Forks of Wayfern (e.g. CloverLabsAI, VulpineOS) are NOT
            supported. Feature requests asking for them are out of scope.

          # PAID vs FREE FEATURES

          Source: donutbrowser.com pricing tiers (verbatim from translations).

          ## Free (no account required)
          - Unlimited local profiles
          - Chromium (Wayfern) anti-detect browser engine
          - Proxy support (HTTP/SOCKS5)
          - VPN support (WireGuard)
          - Profile Management API & MCP (list / create / launch / kill / config)
          - Cookie & Extension Management
          - Set as default browser
          - Profile sync IS FREE if the user self-hosts the `donut-sync` server

          ## Pro ($16/mo) adds:
          - Browser Manipulation API & MCP (`type_text`, `click_element`,
            `evaluate_javascript`, `screenshot`, `navigate`, etc.)
          - Cross-OS fingerprinting (e.g. macOS user appearing as Windows)
          - Profile Synchronizer for Wayfern
          - 20 cloud profile backup (cloud sync via donutbrowser.com)
          - Commercial use license

          ## Team ($80/mo) adds:
          - 100 cloud profile sync
          - Team collaboration, profile sharing, unlimited seats

          # ANTI-PATTERNS

          - Regression: user explicitly mentions a previous version that worked
            differently ("worked in 0.21", "went from 2 to 8 false positives"). Do NOT
            dismiss as "known issue" / "expected" / "false positive in Tauri apps". Ask
            which exact version was the last working one and what changed.
          - Fork-support request: asks the maintainer to support an alternative
            Wayfern fork. Acknowledge in one neutral sentence. Do NOT call it
            "clear", "reasonable", "well-thought-out", etc.
          - AI-generated / template-violating report: report doesn't follow the
            template, may cite "official documentation" via context7, deepwiki, or any
            non-`donutbrowser.com` / non-`github.com/zhom` URL. The only authoritative
            sources are this GitHub repo and donutbrowser.com.
          - Speculation about internals: never write a "Possible cause" / "Likely
            cause" / "Root cause" section. Never cite internal file paths or line
            numbers. Never speculate about how subscription / paid-plan checks work.

          # OS-SPECIFIC LOG PATHS (use ONLY the one matching the user's OS)
          # Easiest path for the user: Donut → Settings → Advanced → Copy logs
          # (puts the latest rotated log on the clipboard). If they prefer to
          # attach files directly, the active log is `DonutBrowser.log`; older
          # rotated copies sit next to it (`DonutBrowser.log.YYYY-MM-DD-...`).

          - macOS:   `~/Library/Logs/com.donutbrowser/DonutBrowser.log`
          - Linux:   `~/.local/share/com.donutbrowser/logs/DonutBrowser.log`
          - Windows: `%LOCALAPPDATA%\com.donutbrowser\logs\DonutBrowser.log`

          # KNOWN ERROR SIGNATURES (truth, not guesses; match these
          # verbatim before suggesting anything else)

          - `CDP not ready after N attempts on port X: HTTP 5xx ...`
            An HTTP 5xx (503 / 502) response from a freshly-launched
            browser's `/json/version` endpoint always means *something on
            the loopback path is intercepting the connection*: a firewall,
            an antivirus web-shield (Kaspersky, Bitdefender, ESET, Avast /
            AVG, Yandex Protect on Windows; Little Snitch, LuLu on macOS),
            a VPN client that hijacks 127.0.0.1, or a corporate MDM /
            proxy (Zscaler, Cisco AnyConnect, Netskope). Chrome's
            DevTools endpoint never returns 5xx itself; only synthetic
            responses from interception layers do. Do NOT speculate
            about Gatekeeper, first-launch verification, code signing, or
            quarantine. None of those cause a 5xx response, and
            Gatekeeper never delays a launch long enough to surface as
            "120 attempts". Lead with: which AV / web-shield / firewall /
            VPN / MDM is installed, and ask the user to try with the AV's
            web-shield component temporarily disabled (not the whole AV).
          EOF

      - name: Build triage system prompt
        run: |
          # The static system prompt has apostrophes ("doesn't", "official docs"
          # etc.) that collide with shell single-quoting inside the jq filter.
          # Build it to a file instead and load it via --rawfile in the next step.
          {
            cat <<'TRIAGE_HEAD'
          You are a triage classifier for the Donut Browser GitHub repo. Classify the issue and pick at most 20 source files for a composer to read.

          TRIAGE_HEAD
            cat /tmp/scope-and-pricing.md
            printf '\n\n# REPO GUIDELINES\n'
            cat /tmp/repo-context.txt
            cat <<'TRIAGE_TAIL'

          # OUTPUT
          Return ONLY valid JSON. No preamble, no code fences. Schema:
          {
            "language": "en" or ISO 639-1 code,
            "classification": one of ["bug-in-scope", "bug-template-violation", "feature-request", "fork-request", "regression", "automated-content", "question", "other"],
            "operating_system": "macos" | "windows" | "linux" | "unknown",
            "is_paid_feature": true | false,
            "user_followed_template": true | false,
            "regression_signal": quoted user snippet or null,
            "user_cited_external_docs": URL string or null,
            "files_to_read": array of at most 20 file paths from the list,
            "notes": one short sentence describing what you observed
          }

          Classification guidance:
          - "bug-template-violation": missing or filled-in nonsense for required template fields.
          - "automated-content": cites fabricated "official docs" (context7, deepwiki, non-donutbrowser URLs) or has a highly structured automated-submission pattern with fabricated certainty.
          - "fork-request": asks for support of CloverLabsAI/VulpineOS/etc. forks.
          - "regression": user names a prior version that worked.

          File selection: pick files that an experienced reviewer would actually look at to act on this issue. For a fork request or automated-content classification, set files_to_read to []. Otherwise pick concrete files relevant to the symptoms.
          TRIAGE_TAIL
          } > /tmp/triage-system.txt
          wc -c /tmp/triage-system.txt

      - name: Stage 1 (triage and file selection)
        env:
          GH_MODELS_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: |
          # The triage call returns ONLY JSON. It classifies the issue and picks a
          # short list of source files for the composer to read.
          PAYLOAD=$(jq -n \
            --arg model "$TRIAGE_MODEL" \
            --rawfile system_prompt /tmp/triage-system.txt \
            --rawfile title /tmp/issue-title.txt \
            --rawfile body /tmp/issue-body.txt \
            --rawfile fields /tmp/issue-fields.json \
            --rawfile files /tmp/all-source-files.txt \
            '{
              model: $model,
              messages: [
                { role: "system", content: $system_prompt },
                { role: "user",
                  content: ("Issue title: " + $title + "\n\nSanitized body:\n" + $body + "\n\nSanitized template fields:\n" + $fields + "\n\nAll source files:\n" + $files) }
              ]
            }')

          # Never use curl -f here: a transport or quota error (402 once the repo's
          # GitHub Models allowance is spent) must not abort the job. Capture the
          # status and fall through to the safe classification below.
          STATUS=$(curl -sSL -o /tmp/triage-response.json -w '%{http_code}' \
            https://models.github.ai/inference/chat/completions \
            -H "Authorization: Bearer $GH_MODELS_TOKEN" \
            -H "Accept: application/vnd.github+json" \
            -H "X-GitHub-Api-Version: 2026-03-10" \
            -H "Content-Type: application/json" \
            -d "$PAYLOAD" || echo "000")

          if [ "$STATUS" = "200" ]; then
            jq -r '.choices[0].message.content // empty' /tmp/triage-response.json > /tmp/triage-raw.txt || : > /tmp/triage-raw.txt
          else
            echo "::error::GitHub Models returned HTTP $STATUS for triage"
            printf '%s\n' "triage inference call failed with HTTP $STATUS" >> /tmp/ai-degraded
            : > /tmp/triage-raw.txt
          fi

          # Normalize optional markdown fences before parsing.
          sed -E 's/^```(json)?$//; s/```$//' /tmp/triage-raw.txt > /tmp/triage.json

          # Fall back to a safe classification when the response is not JSON.
          if ! jq -e . /tmp/triage.json >/dev/null 2>&1; then
            echo "::error::Triage returned non-JSON; using fallback classification"
            printf '%s\n' "triage returned output that was not JSON" >> /tmp/ai-degraded
            jq -n '{
              language: "en",
              classification: "bug-in-scope",
              operating_system: "unknown",
              is_paid_feature: false,
              user_followed_template: true,
              regression_signal: null,
              user_cited_external_docs: null,
              files_to_read: [],
              notes: "triage call failed; defaulting"
            }' > /tmp/triage.json
          fi

          echo "Triage response validated"

      - name: Read files chosen by triage
        run: |
          : > /tmp/file-context.txt
          # An empty file list is valid for classifications that need no source context.
          jq -r '.files_to_read[]? // empty' /tmp/triage.json | while IFS= read -r filepath; do
            filepath=$(echo "$filepath" | xargs)
            [ -z "$filepath" ] && continue
            # Reject paths that escape the repository.
            case "$filepath" in
              /*|*..*|*$'\n'*) continue ;;
            esac
            if [ -f "$filepath" ] && file --mime "$filepath" | grep -q "text/"; then
              echo "=== $filepath ===" >> /tmp/file-context.txt
              cat "$filepath" >> /tmp/file-context.txt
              echo "" >> /tmp/file-context.txt
            fi
          done
          # Cap total context at 100 KB to keep token cost bounded.
          head -c 100000 /tmp/file-context.txt > /tmp/file-context.capped.txt
          mv /tmp/file-context.capped.txt /tmp/file-context.txt
          wc -c /tmp/file-context.txt

      - name: Build composer system prompt
        run: |
          # Same reason as the triage prompt: lots of apostrophes, no shell-quoting
          # gymnastics. Build it to a file, load via --rawfile.
          {
            cat <<'COMPOSER_HEAD'
          You are a triage assistant for Donut Browser. You compose ONE short GitHub comment in response to a freshly opened issue. The triage step has already classified the issue. Use the classification verbatim, do not re-litigate it.

          COMPOSER_HEAD
            cat /tmp/scope-and-pricing.md
            printf '\n\n# REPO GUIDELINES\n'
            cat /tmp/repo-context.txt
            cat <<'COMPOSER_TAIL'

          # STRICT RULES

          ## Output shape
          - One sentence acknowledging the report.
          - Then a line reading exactly `Missing information:`, only if something is actually missing. Skip this section if the user already provided OS, Donut Browser version, Wayfern version, repro steps, and any logs the situation calls for.
          - Maximum 15 lines.
          - No labels, no `Label:` line, no markdown headings, and no bold. `Missing information:` is a plain line, not a heading.
          - No closing pleasantries ("please let me know", "happy to help", etc.).
          - Write plainly: no em dashes, no emoji, no bold.

          ## Forbidden: never do these
          - NEVER include a `Possible cause` / `Likely cause` / `Root cause` / `Probably caused by` section. You do not have enough information; speculation is always wrong here.
          - NEVER cite internal file paths or line numbers in the comment. Internal references rot and confuse non-developers.
          - NEVER reference how subscription / paid-plan checks work internally. You do not know whether the user's claim is correct.
          - NEVER call a report "well-documented", "well-structured", "clear", "thorough", "reasonable", "well-thought-out", or any similar evaluation. You are triage, not peer review.
          - NEVER list more than one OS log path. Use ONLY the path matching the user's reported OS. If OS is unknown, ask for it instead of listing all three.
          - NEVER validate a feature request as "a clear enhancement" / "a reasonable request" / similar. Acknowledge neutrally and ask only the missing info (use case, urgency).
          - NEVER call a report "a known and expected behavior" or "a false positive" if the user mentions a regression. The triage tells you when this applies.

          ## Classification handling
          The triage classification (`triage.classification`) determines the response shape:

          - `bug-in-scope`: ask for what is missing using the user's reported OS log path. Be concrete about how to obtain logs.
          - `bug-template-violation` or `automated-content`: politely ask the user to refile using the bug-report template (the Operating System, Donut Browser version, Wayfern version, Steps to reproduce, Error logs sections). If they cited "documentation" from any non-`donutbrowser.com`/non-`github.com/zhom` URL (e.g. context7, deepwiki), gently note that those are AI-generated third-party summaries and the only authoritative sources are this repo and donutbrowser.com.
          - `feature-request`: one neutral sentence acknowledging, then ask only what is genuinely needed (concrete use case, whether a workaround would suffice). Do NOT validate.
          - `fork-request`: one neutral sentence acknowledging the request. Note that this would substantially increase support burden and the maintainer evaluates such requests on a case-by-case basis. Ask whether the alternative fork supports all platforms the user uses (macOS / Windows / Linux). No "clear enhancement" language.
          - `regression`: do NOT call known/expected. Ask which exact previous version was the last working one, what changed in the user's environment between then and now, and the specific delta in symptoms.
          - `question`: answer briefly if obvious from repo guidelines / pricing; otherwise ask for clarification.

          ## Paid-feature awareness
          If `triage.is_paid_feature` is true, factor the pricing tiers into your reply. For Pro-only features (browser manipulation API/MCP, cross-OS fingerprinting, Wayfern Profile Synchronizer, cloud sync), confirm the user is logged in with an active subscription before asking for logs. If the issue is about cloud sync, mention that self-hosting `donut-sync` makes sync free and is a viable alternative.

          ## Language
          If the issue body is not in English, write the comment in English (the maintainer reads English). The FIRST line must politely ask the user to communicate in English so the maintainer can help. Then continue with the normal triage response, in English.

          ## OS-specific log paths
          Recommend Settings → Advanced → Copy logs first. It puts the
          latest rotated log on the clipboard without the user hunting for
          a directory. If they want to attach files directly, point at the
          path that matches `triage.operating_system`. The active log is
          always `DonutBrowser.log`; rotated copies sit next to it.
            - macos:   `~/Library/Logs/com.donutbrowser/DonutBrowser.log`
            - linux:   `~/.local/share/com.donutbrowser/logs/DonutBrowser.log`
            - windows: `%LOCALAPPDATA%\com.donutbrowser\logs\DonutBrowser.log` (PowerShell: `Get-Content $env:LOCALAPPDATA\com.donutbrowser\logs\DonutBrowser.log -Tail 200`)
            - unknown: ask the user to share their OS first.

          ## Known error signatures (apply BEFORE asking generic questions)
          If the issue body contains any of these, lead with the matching
          response. Do NOT speculate about other causes:

          - `CDP not ready after N attempts on port X: HTTP 5xx ...`
            This is loopback interception by a firewall / antivirus
            web-shield / VPN / MDM. Lead with that question (specifically:
            Kaspersky, Bitdefender, ESET, Avast/AVG, Yandex Protect on
            Windows; Little Snitch, LuLu, corporate MDM on macOS; any
            VPN). Suggest temporarily disabling the AV's web-shield
            component (NOT the whole AV) and retrying. Do NOT mention
            Gatekeeper, first-launch verification, code signing, or
            quarantine. None of those cause an HTTP 5xx response, and
            Gatekeeper never delays a launch long enough to produce a
            "120 attempts" failure.
          COMPOSER_TAIL
          } > /tmp/composer-system.txt
          wc -c /tmp/composer-system.txt

      - name: Stage 2 (compose response)
        id: compose
        env:
          GH_MODELS_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          ISSUE_AUTHOR: ${{ github.event.issue.user.login }}
          IS_FIRST_TIME: ${{ steps.check-first-time.outputs.is_first_time }}
        run: |
          GREETING=""
          if [ "$IS_FIRST_TIME" = "true" ]; then
            # Use printf with %s so the apostrophe inside the string never has to
            # cross a shell single-quote boundary.
            printf '%s' 'This is the first issue from this user. Start the comment with "Thanks for opening your first issue!" on its own line.' > /tmp/greeting.txt
          else
            : > /tmp/greeting.txt
          fi
          printf '%s' "$ISSUE_AUTHOR" > /tmp/issue-author.txt

          PAYLOAD=$(jq -n \
            --arg model "$COMPOSER_MODEL" \
            --rawfile system_prompt /tmp/composer-system.txt \
            --rawfile title /tmp/issue-title.txt \
            --rawfile body /tmp/issue-body.txt \
            --rawfile author /tmp/issue-author.txt \
            --rawfile fields /tmp/issue-fields.json \
            --rawfile triage /tmp/triage.json \
            --rawfile greeting /tmp/greeting.txt \
            --rawfile files /tmp/file-context.txt \
            '{
              model: $model,
              messages: [
                { role: "system", content: $system_prompt },
                { role: "user",
                  content: ((if ($greeting | length) > 0 then $greeting + "\n\n" else "" end)
                    + "Title: " + $title
                    + "\nAuthor: " + $author
                    + "\n\n## Triage result\n" + $triage
                    + "\n\n## Sanitized template fields\n" + $fields
                    + "\n\n## Sanitized issue body\n" + $body
                    + "\n\n## Source files (selected by triage)\n" + $files) }
              ]
            }')

          # Same as triage: a quota or transport error must not fail the run. When
          # no comment can be composed the remaining steps are skipped instead.
          STATUS=$(curl -sSL -o /tmp/compose-response.json -w '%{http_code}' \
            https://models.github.ai/inference/chat/completions \
            -H "Authorization: Bearer $GH_MODELS_TOKEN" \
            -H "Accept: application/vnd.github+json" \
            -H "X-GitHub-Api-Version: 2026-03-10" \
            -H "Content-Type: application/json" \
            -d "$PAYLOAD" || echo "000")

          if [ "$STATUS" != "200" ]; then
            echo "::error::GitHub Models returned HTTP $STATUS; skipping the triage comment"
            printf '%s\n' "composer inference call failed with HTTP $STATUS" >> /tmp/ai-degraded
            echo "has_comment=false" >> "$GITHUB_OUTPUT"
            exit 0
          fi

          jq -r '.choices[0].message.content // empty' /tmp/compose-response.json > /tmp/ai-comment.txt || : > /tmp/ai-comment.txt

          if [ ! -s /tmp/ai-comment.txt ]; then
            echo "::error::Composer returned empty response; skipping the triage comment"
            printf '%s\n' "composer returned an empty response" >> /tmp/ai-degraded
            echo "has_comment=false" >> "$GITHUB_OUTPUT"
            exit 0
          fi

          echo "has_comment=true" >> "$GITHUB_OUTPUT"

      - name: Strip forbidden sections (defense in depth)
        if: steps.compose.outputs.has_comment == 'true'
        run: |
          # LLMs still emit "Possible cause" and friends despite the prompt rules.
          # Strip any such heading and its block, plus stray `Label:` lines left
          # over from earlier prompt iterations.
          python3 - <<'EOF'
          import re
          path = '/tmp/ai-comment.txt'
          text = open(path).read()
          # Drop forbidden section headers and everything until a blank line or another header.
          forbidden = re.compile(
              r'^\s*\**\s*(?:possible|likely|root|probable)\s+cause\b.*?(?=^\s*$|\n##|\n\*\*[A-Z]|\Z)',
              re.IGNORECASE | re.MULTILINE | re.DOTALL,
          )
          text = forbidden.sub('', text)
          # Drop stale Label: lines (we don't label anymore).
          text = re.sub(r'^\s*Label:\s*.*$', '', text, flags=re.MULTILINE)
          # Collapse 3+ blank lines.
          text = re.sub(r'\n{3,}', '\n\n', text).strip() + '\n'
          open(path, 'w').write(text)
          EOF

      - name: Post comment (no labeling)
        if: steps.compose.outputs.has_comment == 'true'
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          ISSUE_NUMBER: ${{ github.event.issue.number }}
        run: |
          gh issue comment "$ISSUE_NUMBER" --repo "$GITHUB_REPOSITORY" --body-file /tmp/ai-comment.txt


      # The steps above deliberately degrade rather than block: an inference
      # outage must never close a contributor's issue or flag their pull
      # request. But a run that skipped the check it exists to perform has not
      # succeeded, and reporting green hides that the automation is dead.
      - name: Fail if the AI check did not actually run
        if: always()
        run: |
          if [ -f /tmp/ai-degraded ]; then
            echo "::error::This check degraded to a no-op and its result was not verified:"
            sed 's/^/  - /' /tmp/ai-degraded
            exit 1
          fi

  analyze-pr:
    if: github.repository == 'zhom/donutbrowser' && github.event_name == 'pull_request_target' && github.actor != 'dependabot[bot]'
    runs-on: ubuntu-latest
    steps:
      - name: Checkout repository
        uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 #v7.0.1

      - name: Check if first-time contributor
        id: check-first-time
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          PR_AUTHOR: ${{ github.event.pull_request.user.login }}
        run: |
          PR_COUNT=$(gh api "/repos/${{ github.repository }}/pulls?state=all&per_page=100" \
            --jq "[.[] | select(.user.login == \"$PR_AUTHOR\" and .number != ${{ github.event.pull_request.number }})] | length" \
            || echo "0")

          if [ "$PR_COUNT" = "0" ]; then
            echo "is_first_time=true" >> $GITHUB_OUTPUT
          else
            echo "is_first_time=false" >> $GITHUB_OUTPUT
          fi

      - name: Gather PR context
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          PR_NUMBER: ${{ github.event.pull_request.number }}
          HEAD_REPOSITORY: ${{ github.event.pull_request.head.repo.full_name }}
        run: |
          gh api --paginate "/repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/files?per_page=100" \
            --jq '.[] | "- \(.filename) (\(.status)) +\(.additions)/-\(.deletions)"' \
            > /tmp/pr-files.txt

          gh api "/repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER" \
            --header "Accept: application/vnd.github.diff" \
            > /tmp/pr-diff-full.txt 2>/dev/null || true
          head -c 20000 /tmp/pr-diff-full.txt > /tmp/pr-diff.txt

          cat CONTRIBUTING.md > /tmp/contributing.txt 2>/dev/null || echo "Not found" > /tmp/contributing.txt
          head -50 README.md > /tmp/readme.txt 2>/dev/null || echo "Not found" > /tmp/readme.txt
          cp CLAUDE.md /tmp/repo-context.txt

          : > /tmp/related-file-contents.txt
          gh api --paginate "/repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/files?per_page=100" \
            --jq '.[] | select(.status != "removed") | [.filename, .sha] | @tsv' |
          while IFS=$'\t' read -r filepath blob_sha; do
            case "$filepath" in
              /*|*..*|*$'\n'*) continue ;;
            esac
            blob_file=$(mktemp)
            if gh api "/repos/$HEAD_REPOSITORY/git/blobs/$blob_sha" --jq .content \
              | tr -d '\n' | base64 --decode > "$blob_file" 2>/dev/null \
              && file --mime "$blob_file" | grep -q "text/"; then
                echo "=== $filepath (head revision) ===" >> /tmp/related-file-contents.txt
                cat "$blob_file" >> /tmp/related-file-contents.txt
                echo "" >> /tmp/related-file-contents.txt
            fi
            rm -f "$blob_file"
          done
          head -c 100000 /tmp/related-file-contents.txt > /tmp/pr-file-context.txt
          node scripts/redact-sensitive-text.mjs < /tmp/pr-diff.txt > /tmp/pr-diff.safe.txt
          mv /tmp/pr-diff.safe.txt /tmp/pr-diff.txt
          node scripts/redact-sensitive-text.mjs < /tmp/pr-file-context.txt > /tmp/pr-file-context.safe.txt
          mv /tmp/pr-file-context.safe.txt /tmp/pr-file-context.txt

      - name: Analyze PR with AI
        id: analyze
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          GH_MODELS_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          PR_NUMBER: ${{ github.event.pull_request.number }}
          PR_TITLE: ${{ github.event.pull_request.title }}
          PR_BODY: ${{ github.event.pull_request.body }}
          PR_AUTHOR: ${{ github.event.pull_request.user.login }}
          PR_BASE: ${{ github.event.pull_request.base.ref }}
          PR_HEAD: ${{ github.event.pull_request.head.ref }}
          IS_FIRST_TIME: ${{ steps.check-first-time.outputs.is_first_time }}
        run: |
          GREETING=""
          if [ "$IS_FIRST_TIME" = "true" ]; then
            GREETING='This is a first-time contributor. Start your comment with: "Thanks for your first PR!"'
          fi

          printf '%s' "$PR_TITLE" | node scripts/redact-sensitive-text.mjs > /tmp/pr-title.txt
          printf '%s' "${PR_BODY:-}" | node scripts/redact-sensitive-text.mjs > /tmp/pr-body.txt
          printf '%s' "$PR_AUTHOR" > /tmp/pr-author.txt
          printf '%s' "$PR_BASE" > /tmp/pr-base.txt
          printf '%s' "$PR_HEAD" > /tmp/pr-head.txt
          printf '%s' "$GREETING" > /tmp/greeting.txt

          PAYLOAD=$(jq -n \
            --arg model "$COMPOSER_MODEL" \
            --rawfile title /tmp/pr-title.txt \
            --rawfile body /tmp/pr-body.txt \
            --rawfile author /tmp/pr-author.txt \
            --rawfile base /tmp/pr-base.txt \
            --rawfile head /tmp/pr-head.txt \
            --rawfile files /tmp/pr-files.txt \
            --rawfile diff /tmp/pr-diff.txt \
            --rawfile greeting /tmp/greeting.txt \
            --rawfile repo_context /tmp/repo-context.txt \
            --rawfile contributing /tmp/contributing.txt \
            --rawfile file_context /tmp/pr-file-context.txt \
            '{
              model: $model,
              messages: [
                {
                  role: "system",
                  content: ("You are a code review bot for Donut Browser, an open-source anti-detect browser (Tauri desktop app: Rust backend + Next.js frontend).\n\nProject guidelines and structure:\n" + $repo_context + "\n\nContributing guidelines:\n" + $contributing + "\n\nYou have access to sanitized head-revision contents for changed files and a sanitized diff. Use them to give a substantive review.\n\nReview this PR and produce a single comment. Format:\n\n1. One sentence summarizing what this PR does and whether the approach is sound.\n2. Code review: specific observations about the actual code changes. Mention file names and what you see in the diff. Look for:\n   - Bugs or logic errors in the changed code\n   - Security issues (SQL injection, path traversal, XSS, command injection)\n   - Missing error handling or edge cases\n   - Breaking changes to existing APIs or behavior\n   - If UI text was added or changed, verify the key exists in every JSON file under src/i18n/locales/\n   - If Tauri commands were added or removed, verify e2e/coverage-map.mjs is updated exactly once per command\n3. Suggestions: concrete improvements if any. Skip if the PR looks good.\n\nRules:\n- Be substantive. Review the actual diff, not just the description.\n- Do NOT nitpick formatting or style; the project has automated linting (biome + clippy + rustfmt).\n- Do NOT just summarize the PR description back to the user. They wrote it, they know what it says.\n- If the PR is good, say so briefly.\n- Never exceed 20 lines.\n- Write plainly: no em dashes, no emoji, no bold.")
                },
                {
                  role: "user",
                  content: (
                    (if ($greeting | length) > 0 then $greeting + "\n\n" else "" end) +
                    "Review this PR:\n\nTitle: " + $title +
                    "\nAuthor: " + $author +
                    "\nBase: " + $base + " <- Head: " + $head +
                    "\n\nDescription:\n" + $body +
                    "\n\nChanged files:\n" + $files +
                    "\n\nDiff:\n" + $diff +
                    "\n\nFull file contents:\n" + $file_context
                  )
                }
              ]
            }')

          # A quota or transport error must not fail the run; skip the review
          # comment instead of red-crossing an otherwise healthy pull request.
          STATUS=$(curl -sSL -o /tmp/pr-response.json -w '%{http_code}' \
            https://models.github.ai/inference/chat/completions \
            -H "Authorization: Bearer $GH_MODELS_TOKEN" \
            -H "Accept: application/vnd.github+json" \
            -H "X-GitHub-Api-Version: 2026-03-10" \
            -H "Content-Type: application/json" \
            -d "$PAYLOAD" || echo "000")

          if [ "$STATUS" != "200" ]; then
            echo "::error::GitHub Models returned HTTP $STATUS; skipping the review comment"
            printf '%s\n' "PR review inference call failed with HTTP $STATUS" >> /tmp/ai-degraded
            echo "has_comment=false" >> "$GITHUB_OUTPUT"
            exit 0
          fi

          jq -r '.choices[0].message.content // empty' /tmp/pr-response.json > /tmp/ai-comment.txt || : > /tmp/ai-comment.txt

          if [ ! -s /tmp/ai-comment.txt ]; then
            echo "::error::AI response was empty; skipping the review comment"
            printf '%s\n' "PR review returned an empty response" >> /tmp/ai-degraded
            echo "has_comment=false" >> "$GITHUB_OUTPUT"
            exit 0
          fi

          echo "has_comment=true" >> "$GITHUB_OUTPUT"

      - name: Post comment
        if: steps.analyze.outputs.has_comment == 'true'
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          PR_NUMBER: ${{ github.event.pull_request.number }}
        run: |
          gh pr comment "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" --body-file /tmp/ai-comment.txt


      # The steps above deliberately degrade rather than block: an inference
      # outage must never close a contributor's issue or flag their pull
      # request. But a run that skipped the check it exists to perform has not
      # succeeded, and reporting green hides that the automation is dead.
      - name: Fail if the AI check did not actually run
        if: always()
        run: |
          if [ -f /tmp/ai-degraded ]; then
            echo "::error::This check degraded to a no-op and its result was not verified:"
            sed 's/^/  - /' /tmp/ai-degraded
            exit 1
          fi

  opencode-command:
    if: |
      github.repository == 'zhom/donutbrowser' &&
      (github.event_name == 'issue_comment' || github.event_name == 'pull_request_review_comment') &&
      (github.event.comment.author_association == 'OWNER' ||
       github.event.comment.author_association == 'MEMBER' ||
       github.event.comment.author_association == 'COLLABORATOR') &&
      (contains(github.event.comment.body, ' /oc') ||
       startsWith(github.event.comment.body, '/oc') ||
       contains(github.event.comment.body, ' /opencode') ||
       startsWith(github.event.comment.body, '/opencode'))
    runs-on: ubuntu-latest
    steps:
      - name: Checkout repository
        uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 #v7.0.1

      - name: Run opencode
        uses: anomalyco/opencode/github@31406ccc51b4bd2a4e1e086b2bcaa5f7f804f26d #v1.18.18
        env:
          ZHIPU_API_KEY: ${{ secrets.ZHIPU_API_KEY }}
          TOKEN: ${{ secrets.GITHUB_TOKEN }}
        with:
          model: zai-coding-plan/glm-4.7
