name: PR Severity Classification

on:
  # Use pull_request_target so the workflow runs on fork PRs with the base
  # repository's workflow definition. The classify job below reads PR metadata
  # with a read-only token and never checks out or executes PR code; the write
  # scope needed to apply the label lives in a separate, model-free job.
  pull_request_target:
    types: [opened, synchronize, labeled]

# Default the whole workflow to read-only. Each job opts into exactly the scope
# it needs: classify stays read-only (untrusted PR metadata reaches the model,
# so it must not hold a write token), apply takes pull-requests: write but runs
# no model.
permissions:
  contents: read

concurrency:
  group: pr-severity-${{ github.event.pull_request.number }}
  cancel-in-progress: true

jobs:
  classify:
    name: Classify PR Severity
    runs-on: ubuntu-latest
    # Cap the model run: it fires on every pull_request_target synchronize with
    # attacker-controllable input, so bound its runner-minute/token cost rather
    # than inheriting GitHub's 6h default. Mirrors the dedupe workflow.
    timeout-minutes: 10
    # Read-only: the classifier only inspects PR metadata via the GitHub API.
    permissions:
      contents: read
      pull-requests: read
    # Skip if PR has skip-severity-check label.
    # For labeled events, only run if 'reclassify' label was added.
    if: |
      !contains(github.event.pull_request.labels.*.name, 'skip-severity-check') &&
      (github.event.action != 'labeled' || github.event.label.name == 'reclassify')
    steps:
      - name: Checkout repository
        uses: actions/checkout@v4
        with:
          fetch-depth: 1
          # Don't leave the job token in .git/config: nothing here needs a
          # persisted git credential, and the classifier runs on untrusted
          # fork-PR input.
          persist-credentials: false

      - name: Classify PR with Claude
        # Pinned to a full commit SHA rather than the mutable @v1 tag: this step
        # runs on pull_request_target with CLAUDE_CODE_OAUTH_TOKEN and
        # GITHUB_TOKEN in-process and is reachable by any fork author, so a
        # repointed tag would run attacker-controlled action code with those
        # secrets present. Bump deliberately when updating.
        uses: anthropics/claude-code-action@ba0aafd4308cbba7165f9f2cdb0cfbed5a3c99ce # v1
        env:
          # gh (invoked by the Bash(gh pr view) tool) authenticates from
          # GH_TOKEN; set it explicitly so classification doesn't depend on the
          # action propagating its github_token input into the tool environment,
          # matching the dedupe find-duplicates step.
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        with:
          claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
          github_token: ${{ secrets.GITHUB_TOKEN }}

          # Accept any PR author: this job holds only a read-only token, reads
          # PR metadata via the API, and writes its verdict to a file. The
          # privileged label/comment step runs separately without the model.
          # "*" is safe ONLY while this job stays read-only. Before granting it
          # a write token or a mutating tool (a write-capable gh subcommand, a
          # Bash mutation), replace "*" with an explicit allowlist — otherwise
          # any fork author's PR text would steer a privileged model.
          allowed_non_write_users: "*"

          # Pin the model so the severity decision (which gates the label the
          # apply job applies) is reproducible from the workflow file and does
          # not drift on an action-default change.
          model: claude-sonnet-5

          # The classifier only needs to read PR data and record its verdict to
          # the workspace. It has no write-capable gh tools.
          #
          # Security note: because this runs on pull_request_target, the OAuth
          # token above is present in-process while the model reads untrusted
          # fork-PR text. The read-only github_token bounds what the model can
          # WRITE via the API, but the in-process token's protection rests on
          # this allowlist staying minimal — read-only `gh pr view` plus
          # `Write`, with no network- or shell-mutating tool a prompt injection
          # could use to exfiltrate it. Keep it that way: do not add `Bash`
          # verbs beyond `gh pr view`, and never add a tool that can make
          # outbound requests. (A stricter design would drop Bash entirely and
          # pre-fetch PR metadata via the API in a separate step.)
          claude_args: --allowedTools "Bash(gh pr view:*)" "Write"

          prompt: |
            You are a PR severity classifier for the lnd (Lightning Network Daemon) repository.

            ## Tool Constraints

            You ONLY have access to:
            - `gh pr view` - to read PR metadata
            - `Write` - to record your verdict to files

            You do NOT have access to `gh api`, `gh label`, `gh pr edit`,
            `gh pr comment`, or any other command. Do not attempt to use them.
            You do NOT apply labels or post comments yourself. A separate,
            deterministic step reads the files you write and applies the label
            and comment. Your job is only to classify and record the result.

            ## Your Task

            Analyze PR #${{ github.event.pull_request.number }} and:
            1. Determine its severity level based on the files changed
            2. Record the severity, whether a comment should be posted, and the
               comment body, to files (see "Output" below).

            ## Severity Levels

            **CRITICAL** (severity-critical) - Requires expert review:
            - lnwallet/* - Wallet operations, channel funding, signing, commitment transactions
            - htlcswitch/* - HTLC forwarding, payment routing state machine
            - contractcourt/* - On-chain dispute resolution, breach handling
            - sweep/* - Output sweeping, fund recovery, fee bumping
            - peer/*, brontide/* - Encrypted peer connections, Noise protocol
            - keychain/* - Private key derivation and management
            - input/* - Script signing, witness generation, MuSig2
            - channeldb/* - Channel state persistence, database migrations
            - funding/* - Channel funding workflow coordination
            - lnwire/* - Lightning wire protocol messages
            - server.go, rpcserver.go - Core server coordination

            **HIGH** (severity-high) - Requires knowledgeable engineer:
            - routing/* - Payment pathfinding algorithms
            - invoices/* - Invoice management and settlement
            - discovery/* - Gossip protocol
            - graph/* - Network graph maintenance
            - watchtower/* - Breach remediation
            - feature/* - Feature bit management
            - lnrpc/* - RPC/API definitions
            - macaroons/*, walletunlocker/*, cert/* - Auth/security
            - chainntnfs/*, chanacceptor/*, protofsm/*, sqldb/*

            **MEDIUM** (severity-medium) - Focused review:
            - cmd/* - CLI client commands (do NOT inherit severity from server-side packages with similar names)
            - payments/*, autopilot/*, lncfg/*, chanfitness/*
            - netann/*, kvdb/*, chanbackup/*, aezeed/*, tor/*
            - zpay32/*, tlv/*, fn/*, record/*, amp/*
            - *.proto files (API changes)
            - Other Go files not categorized above

            **LOW** (severity-low) - Best-effort review:
            - docs/*, release-notes/*, *.md files
            - scripts/*, tools/*, contrib/*, make/*, docker/*
            - itest/*, lntest/*, *_test.go (test-only changes)
            - .github/* (CI/CD configuration)

            ## Classification Rules

            1. The HIGHEST severity file determines the PR severity
            2. Classify files by their actual package path, NOT by filename keywords.
               Files under cmd/* are CLI client code and should always be MEDIUM,
               even if the filename contains a server-side package name (e.g.
               cmd/commands/cmd_walletunlocker.go is MEDIUM, not HIGH).
            3. Bump severity UP one level if:
               - PR touches >20 files (excluding tests and auto-generated files)
               - PR has >500 lines changed (excluding tests and auto-generated files)
               - PR touches multiple distinct critical packages
            4. Check for override labels first (severity-override-*). If present, respect the override.
            5. Database migrations (channeldb/migration*, sqldb/*, wtdb/*) are always CRITICAL

            ## Files to Exclude from Line/File Counting
            When calculating file count and lines changed for severity bumps, exclude:
            - Test files: *_test.go, itest/*, lntest/*
            - Auto-generated files: *.pb.go, *.pb.gw.go, *.pb.json.go, *.sql.go, *_generated.go
            - Mock files: mock_*.go, *_mock.go

            ## Steps

            1. Read the current labels AND comments to detect overrides and prior
               bot activity:
               ```
               gh pr view ${{ github.event.pull_request.number }} --json labels,comments
               ```
               Note which `severity-*` label (if any) is currently applied. This
               is the "previous severity". Look for the HTML marker
               `<!-- pr-severity-bot -->` in comment bodies to tell whether the
               bot has commented before.

            2. If an override label exists (severity-override-*), use that level
               and skip classification.

            3. Get the list of changed files:
               ```
               gh pr view ${{ github.event.pull_request.number }} --json files,additions,deletions
               ```

            4. Classify each file and determine the new overall severity.

            5. **Decide whether a comment should be posted.** Set should_comment
               to "true" only if EITHER:
               - The bot has NOT commented before (no existing comment with
                 `<!-- pr-severity-bot -->`), OR
               - The newly determined severity is DIFFERENT from the previous
                 severity label.

               Otherwise set should_comment to "false" (the label may still be
               updated by the apply step, but no new comment is posted).

            ## Output

            Record your verdict by writing these files in the current working
            directory (the repository root). Do NOT apply labels or comment
            yourself.

            1. `severity.txt` - exactly one lowercase word, one of:
               `critical`, `high`, `medium`, `low`. Nothing else.

            2. `should_comment.txt` - exactly `true` or `false`.

            3. `comment.md` - only if should_comment is `true`. The full comment
               markdown, in this format:

               If this is a severity CHANGE (previous label existed but differs),
               prepend: `> ⚠️ Severity changed: **<OLD>** → **<NEW>** (files changed since last classification)`

               ```markdown
               ## <emoji> PR Severity: **<LEVEL>**

               > <source> | <N> files | <M> lines changed

               <details>
               <summary>🔴 <strong>Critical</strong> (N files)</summary>

               - `path/to/file1.go` - reason
               - `path/to/file2.go` - reason

               </details>

               [repeat for other tiers if applicable]

               ### Analysis

               <Your explanation of why this severity was chosen, any concerns, etc.>

               ---
               <sub>To override, add a `severity-override-{critical,high,medium,low}` label.</sub>
               <!-- pr-severity-bot -->
               ```

               If should_comment is `false`, do not create `comment.md`.

               Keep the comment concise and factual: it is posted verbatim under
               the bot's identity, and the apply step defangs any `@`-mentions
               and links, so do not rely on them.

            ## Emoji Mapping
            - critical: 🔴
            - high: 🟠
            - medium: 🟡
            - low: 🟢

      - name: Upload classification result
        uses: actions/upload-artifact@v4
        with:
          name: pr-severity-result
          path: |
            severity.txt
            should_comment.txt
            comment.md
          if-no-files-found: warn
          retention-days: 1

  apply:
    name: Apply Severity Label
    runs-on: ubuntu-latest
    needs: classify
    timeout-minutes: 5
    # Write scope lives here, in a job that runs no model. The only inputs are
    # the PR number from the trusted event payload and the classifier's files,
    # which are strictly validated before use.
    permissions:
      contents: read
      pull-requests: write
    steps:
      - name: Checkout repository
        uses: actions/checkout@v4
        with:
          # Needed for scripts/apply-pr-severity.sh; no persisted git credential
          # is required here.
          persist-credentials: false

      - name: Download classification result
        uses: actions/download-artifact@v4
        # classify uploads with if-no-files-found: warn, so if the model writes
        # no verdict at all (timeout, refusal) no artifact exists and
        # download-artifact would otherwise hard-fail the job. Tolerate a
        # missing artifact so the no-op guard in the next step is reachable.
        continue-on-error: true
        with:
          name: pr-severity-result
          path: result

      - name: Apply label and comment
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          PR_NUMBER: ${{ github.event.pull_request.number }}
          REPO: ${{ github.repository }}
        # Validate the severity, reconcile the label, and sanitize + post the
        # model-authored comment. The logic lives in a checked-in script so the
        # untrusted-comment sanitizer is unit-tested
        # (scripts/apply-pr-severity_test.sh), mirroring how the dedupe workflow
        # delegates to scripts/comment-on-duplicates.sh.
        run: ./scripts/apply-pr-severity.sh result
