ci: split PR severity workflow into classify and apply jobs
What changed, and why it matters
This commit is a hardening and cleanup of a GitHub Actions workflow that automatically labels pull requests by severity. It does not change any LND node code, wallet logic, or network protocol. Instead, it splits the workflow into two jobs: a read-only 'classify' job that runs an AI model to decide the severity, and a separate 'apply' job that actually sets the label and posts the comment. The change reduces security risk by keeping write permissions out of the job that processes untrusted pull-request text, pins the external AI action to a fixed commit hash, disables unnecessary git credentials, and adds input sanitization for the model-generated comment. It is a defensive improvement, not a vulnerability fix.
No urgent action is required; this is a defensive CI hardening change. Reviewers should verify that the apply job's artifact download cannot be influenced by fork-PR authors (it uses the internal artifact name and the trusted event payload), and that the unit tests are run in CI. Consider extending the sanitizer if new notification/spam vectors are identified.
Security signals we found
Principle of least privilege: write token moved out of the model-bearing job
Untrusted input (model-generated comment) sanitized before privileged API use
External action pinned to immutable commit SHA instead of mutable tag
Workflow default permissions lowered to read-only
Git credential persistence disabled
Job timeout added to bound resource consumption on attacker-triggered events
Single atomic gh pr edit prevents inconsistent multi-label state
Severity validated against an explicit allowlist before label mutation
Evidence from the diff
The PR severity workflow is refactored from a single job into ‘classify’ and ‘apply’ jobs. The classify job now has only contents:read and pull-requests:read permissions, a 10-minute timeout, persist-credentials:false, a pinned anthropics/claude-code-action SHA, an explicit model pin, and a restricted tool allowlist (Bash(gh pr view:*) and Write only). It writes severity.txt, should_comment.txt, and comment.md as artifacts. The new apply job has pull-requests:write, downloads the artifacts, and runs scripts/apply-pr-severity.sh. The script validates severity against {critical,high,medium,low}, reconciles labels in a single gh pr edit call, and sanitizes comment.md via sanitize_comment() to defang @-mentions, issue references, URLs, and Markdown brackets before posting with –body-file. A unit-test file is added covering the sanitizer and control-flow edge cases.
Changed components
.github/workflows/pr-severity.ymlscripts/apply-pr-severity.shscripts/apply-pr-severity_test.shInspect captured patch +424 / −59
diff --git a/.github/workflows/pr-severity.yml b/.github/workflows/pr-severity.yml
index 6b48977..e4c14ff 100644
--- a/.github/workflows/pr-severity.yml
+++ b/.github/workflows/pr-severity.yml
@@ -1,16 +1,19 @@
name: PR Severity Classification
on:
- # Use pull_request_target to allow running on fork PRs with access to secrets.
- # This is safe because we don't checkout or execute any code from the PR -
- # we only read PR metadata (changed files, labels) via the GitHub API.
+ # 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
- pull-requests: write
- issues: write
concurrency:
group: pr-severity-${{ github.event.pull_request.number }}
@@ -20,6 +23,14 @@ 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: |
@@ -30,43 +41,78 @@ jobs:
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
- uses: anthropics/claude-code-action@v1
+ # 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 }}
- # Allow any user since this workflow only reads PR metadata via API
- # and doesn't execute any code from the PR. Tool permissions are
- # restricted to gh pr commands only.
+ # 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: "*"
- # Allow Claude to manage labels and post comments.
- # Keep permissions minimal to limit prompt injection risk.
- claude_args: --allowedTools "Bash(gh pr view:*)" "Bash(gh pr edit:*)" "Bash(gh pr comment:*)"
+ # 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 these commands:
+ You ONLY have access to:
- `gh pr view` - to read PR metadata
- - `gh pr edit` - to add/remove labels
- - `gh pr comment` - to post comments
+ - `Write` - to record your verdict to files
- You do NOT have access to `gh api`, `gh label`, or any other
- `gh` subcommand. Do not attempt to use them. For ALL label
- operations, use `gh pr edit` with `--add-label` or
- `--remove-label`.
+ 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. Apply the appropriate severity label
- 3. Post a detailed comment explaining your determination
+ 2. Record the severity, whether a comment should be posted, and the
+ comment body, to files (see "Output" below).
## Severity Levels
@@ -130,51 +176,49 @@ jobs:
## Steps
- 1. First, check for existing override labels AND existing severity labels:
+ 1. Read the current labels AND comments to detect overrides and prior
+ bot activity:
```
- gh pr view ${{ github.event.pull_request.number }} --json labels --jq '.labels[].name'
+ 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".
+ 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.
+ 2. If an override label exists (severity-override-*), use that level
+ and skip classification.
- 3. Check for existing bot comments. Look for the HTML marker `<!-- pr-severity-bot -->`:
- ```
- gh pr view ${{ github.event.pull_request.number }} --json comments --jq '.comments[].body' | grep -c 'pr-severity-bot' || true
- ```
- This tells you whether the bot has commented before.
-
- 4. Get the list of changed files:
+ 3. Get the list of changed files:
```
gh pr view ${{ github.event.pull_request.number }} --json files,additions,deletions
```
- 5. Classify each file and determine the new overall severity.
+ 4. Classify each file and determine the new overall severity.
- 6. **Decide whether to comment.** Only post a comment 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.
+ 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.
- If the bot already commented AND the severity has NOT changed, just
- stop here — do NOT post another comment. Still update the label if
- needed (step 7-8), but skip the comment.
+ Otherwise set should_comment to "false" (the label may still be
+ updated by the apply step, but no new comment is posted).
- 7. Remove any existing severity-* labels (not override labels):
- ```
- gh pr edit ${{ github.event.pull_request.number }} --remove-label "severity-critical" 2>/dev/null || true
- gh pr edit ${{ github.event.pull_request.number }} --remove-label "severity-high" 2>/dev/null || true
- gh pr edit ${{ github.event.pull_request.number }} --remove-label "severity-medium" 2>/dev/null || true
- gh pr edit ${{ github.event.pull_request.number }} --remove-label "severity-low" 2>/dev/null || true
- ```
+ ## Output
- 8. Apply the new severity label:
- ```
- gh pr edit ${{ github.event.pull_request.number }} --add-label "severity-<level>"
- ```
+ 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.
- 9. If you determined in step 6 that a comment should be posted, post it
- with your analysis. Use this format:
+ 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)`
@@ -203,16 +247,67 @@ jobs:
<!-- pr-severity-bot -->
```
- 10. Post the comment using `gh pr comment`:
- ```
- gh pr comment ${{ github.event.pull_request.number }} --body "YOUR_COMMENT_HERE"
- ```
+ If should_comment is `false`, do not create `comment.md`.
- 11. If you decided in step 6 to SKIP commenting, do NOT post any comment.
- Just ensure the label is correct and exit.
+ 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
diff --git a/scripts/apply-pr-severity.sh b/scripts/apply-pr-severity.sh
new file mode 100755
index 0000000..311aac8
--- /dev/null
+++ b/scripts/apply-pr-severity.sh
@@ -0,0 +1,145 @@
+#!/usr/bin/env bash
+#
+# Applies the PR severity label and posts the classifier's comment.
+#
+# Reads the classifier's verdict from a result directory (arg $1, default
+# "result") and, in this order:
+# - validates the severity against the known set,
+# - reconciles the severity-* label in a single gh edit, and
+# - sanitizes and posts the model-authored comment.
+#
+# The classify job runs a model on untrusted PR text, so the comment body is
+# treated as tainted and passed through sanitize_comment() before posting.
+#
+# Env: GH_TOKEN (gh auth), PR_NUMBER, REPO.
+# Usage: ./apply-pr-severity.sh [RESULT_DIR]
+
+set -euo pipefail
+
+# sanitize_comment reads an untrusted comment body on stdin and writes a safe
+# version to stdout. GitHub already strips scripts and unsafe HTML from rendered
+# comment bodies, so this targets the notification/spam class: @-mentions that
+# ping arbitrary users, `#N` cross-references that notify other issues/PRs,
+# auto-linked URLs used for phishing, and Markdown links. It deliberately leaves
+# the model's <details>/<sub> markup intact so the comment still renders.
+#
+# The zero-width non-joiner is spliced in as a literal byte (not a `\xNN` sed
+# escape) so the rules work identically under both GNU and BSD sed. Rule order
+# matters: the `#`-before-digit defang runs before the bracket rules, which
+# themselves emit `[`/`]` — running it after would corrupt those
+# entities. Matching only `#` followed by a digit leaves `##`/`###` Markdown
+# headings untouched.
+sanitize_comment() {
+ local z
+ z="$(printf '\xe2\x80\x8c')"
+ sed -E \
+ -e "s/@/@${z}/g" \
+ -e "s/www\./www${z}./g" \
+ -e 's,[hH][tT][tT][pP][sS]?://,hxxp://,g' \
+ -e "s/#([0-9])/#${z}\1/g" \
+ -e 's/\[/\[/g' \
+ -e 's/\]/\]/g'
+}
+
+main() {
+ local result_dir="${1:-result}"
+ : "${GH_TOKEN:?GH_TOKEN is required}"
+ : "${PR_NUMBER:?PR_NUMBER is required}"
+ : "${REPO:?REPO is required}"
+
+ # Treat a missing severity.txt and an empty/whitespace-only one the same way:
+ # both mean the classify job produced no usable verdict (model crash or
+ # timeout, a missing artifact tolerated by the download step, or a truncated
+ # write). Warn and degrade rather than laundering a broken run into a silent
+ # green or failing the check red, since severity classification is advisory.
+ # A non-empty but unrecognized value still fails below.
+ #
+ # Lowercase on read so a stray `Low`/`HIGH` from the model still matches.
+ local severity=""
+ if [[ -f "$result_dir/severity.txt" ]]; then
+ severity="$(tr -d '[:space:]' < "$result_dir/severity.txt" | tr '[:upper:]' '[:lower:]')"
+ fi
+ if [[ -z "$severity" ]]; then
+ echo "::warning::PR severity classifier produced no verdict; PR left unlabeled."
+ return 0
+ fi
+
+ # Strictly validate the severity against the known set — it is the only
+ # privileged, semantically meaningful output.
+ case "$severity" in
+ critical|high|medium|low) ;;
+ *)
+ echo "Invalid severity '$severity'; refusing to apply." >&2
+ return 1
+ ;;
+ esac
+
+ # Read the PR's current severity-* labels so the reconciliation below removes
+ # exactly the ones present. (The severity-change banner is authored by the
+ # model in comment.md, so no previous severity is needed here.)
+ #
+ # Fail closed on a read error (rate limit, 5xx): swallowing it would yield
+ # empty labels, so the loop below would remove nothing yet still add the new
+ # label — the exact two-label state this single-edit design prevents. With
+ # set -e, a failed read aborts before any edit. (local is declared separately
+ # so the assignment's own exit status, not local's, drives set -e.)
+ local cur_labels
+ cur_labels="$(gh pr view "$PR_NUMBER" --repo "$REPO" \
+ --json labels --jq '.labels[].name')"
+
+ # Reconcile the severity label in a SINGLE gh edit: add the target and remove
+ # exactly the other severity-* labels currently present. Doing it in one call
+ # means:
+ # - if the add fails (label missing from the repo, transient API error), the
+ # whole edit fails and the PR keeps its prior label rather than being left
+ # unlabeled; and
+ # - a run cancelled by `concurrency.cancel-in-progress` can't stop between an
+ # add and a separate remove, so the PR is never left carrying two labels.
+ # Only present labels are removed, so gh never errors on a missing one.
+ local edit_args level
+ edit_args=(--add-label "severity-$severity")
+ for level in critical high medium low; do
+ [[ "$level" == "$severity" ]] && continue
+ if grep -qx "severity-$level" <<< "$cur_labels"; then
+ edit_args+=(--remove-label "severity-$level")
+ fi
+ done
+ gh pr edit "$PR_NUMBER" --repo "$REPO" "${edit_args[@]}"
+
+ # Lowercase on read (as with severity) so a `True`/`TRUE` slip from the model
+ # is honored rather than silently dropping the comment.
+ local should_comment="false"
+ if [[ -f "$result_dir/should_comment.txt" ]]; then
+ should_comment="$(tr -d '[:space:]' < "$result_dir/should_comment.txt" | tr '[:upper:]' '[:lower:]')"
+ fi
+
+ # Use -s (exists and non-empty): a 0-byte comment.md would otherwise reach
+ # `gh pr comment --body-file`, which rejects an empty body and would abort
+ # after the label was already applied.
+ if [[ "$should_comment" != "true" || ! -s "$result_dir/comment.md" ]]; then
+ echo "No comment requested; done."
+ return 0
+ fi
+
+ # Sanitize first, then enforce the size cap on the SANITIZED body. Every
+ # sanitizer rule only grows the byte count (e.g. `[` -> `[`), so a body
+ # that fits before sanitizing can exceed GitHub's comment limit afterward;
+ # measuring the post-sanitization size is what actually bounds the request.
+ sanitize_comment < "$result_dir/comment.md" > "$result_dir/comment.sanitized.md"
+
+ local max_bytes=16384
+ if [[ "$(wc -c < "$result_dir/comment.sanitized.md")" -gt "$max_bytes" ]]; then
+ echo "::warning::classifier comment exceeds ${max_bytes} bytes after sanitization; skipping comment."
+ return 0
+ fi
+
+ # Post from a file (never interpolated into the shell) so the body is handled
+ # purely as data.
+ gh pr comment "$PR_NUMBER" --repo "$REPO" \
+ --body-file "$result_dir/comment.sanitized.md"
+}
+
+# Allow sourcing (e.g. from the test) without executing main.
+if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
+ main "$@"
+fi
diff --git a/scripts/apply-pr-severity_test.sh b/scripts/apply-pr-severity_test.sh
new file mode 100755
index 0000000..b2fdc1e
--- /dev/null
+++ b/scripts/apply-pr-severity_test.sh
@@ -0,0 +1,125 @@
+#!/usr/bin/env bash
+#
+# Tests for scripts/apply-pr-severity.sh: the sanitize_comment() filter and the
+# main() control flow (label reconciliation and comment gating), the latter with
+# gh stubbed via a PATH shim that records its argument lists.
+#
+# Run: bash scripts/apply-pr-severity_test.sh
+
+set -euo pipefail
+
+DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+# shellcheck source=scripts/apply-pr-severity.sh
+source "$DIR/apply-pr-severity.sh"
+
+ZWNJ="$(printf '\xe2\x80\x8c')"
+fail=0
+
+want() { grep -qF -- "$2" <<< "$OUT" && echo "ok: $1" || { echo "FAIL: $1"; fail=1; }; }
+absent() { grep -qF -- "$2" <<< "$OUT" && { echo "FAIL: $1"; fail=1; } || echo "ok: $1"; }
+eq() { [[ "$2" == "$3" ]] && echo "ok: $1" || { echo "FAIL: $1 (got '$2' want '$3')"; fail=1; }; }
+has() { grep -qF -- "$2" "$GH_CALLS" && echo "ok: $1" || { echo "FAIL: $1 (no gh call: $2)"; fail=1; }; }
+lacks() { grep -qF -- "$2" "$GH_CALLS" && { echo "FAIL: $1 (unexpected gh call: $2)"; fail=1; } || echo "ok: $1"; }
+in_file(){ grep -qF -- "$3" "$2" && echo "ok: $1" || { echo "FAIL: $1"; fail=1; }; }
+
+echo "# sanitize_comment"
+INPUT='## 🟢 PR Severity: **LOW**
+### Analysis
+Ping @user, see #123 and org/repo#4567, [x](https://evil), www.evil.net, http://Bad.io.
+<!-- pr-severity-bot -->'
+OUT="$(printf '%s' "$INPUT" | sanitize_comment)"
+want "## heading preserved" '## 🟢 PR Severity'
+want "### heading preserved" '### Analysis'
+want "@-mention defanged" "@${ZWNJ}user"
+want "#123 defanged" "#${ZWNJ}123"
+want "repo#4567 defanged" "#${ZWNJ}4567"
+want "https scheme defanged" 'hxxp://evil'
+want "http scheme (case-insensitive)" 'hxxp://Bad.io'
+want "www. auto-link defanged" "www${ZWNJ}.evil.net"
+want "markdown brackets escaped" '[x]'
+want "bot marker preserved" '<!-- pr-severity-bot -->'
+absent "no live http(s):// scheme" 'https://'
+absent "bracket entity not corrupted by #" "&#${ZWNJ}"
+
+echo "# main()"
+WORK="$(mktemp -d)"
+trap 'rm -rf "$WORK"' EXIT
+mkdir -p "$WORK/bin"
+# Fake gh: record the full argument list, and for `pr view` emit the fixture
+# labels so main()'s reconciliation has something to diff against.
+cat > "$WORK/bin/gh" <<'SH'
+#!/usr/bin/env bash
+echo "$*" >> "$GH_CALLS"
+[[ "$1 $2" == "pr view" ]] && printf '%s' "${GH_FAKE_LABELS:-}"
+exit 0
+SH
+chmod +x "$WORK/bin/gh"
+export PATH="$WORK/bin:$PATH"
+export GH_TOKEN=x PR_NUMBER=42 REPO=owner/repo
+
+# run_main RESULT_DIR: run main() capturing its return code in RC, with a fresh
+# GH_CALLS log for the case.
+run_main() { export GH_CALLS="$WORK/calls"; : > "$GH_CALLS"; RC=0; main "$1" >/dev/null 2>&1 || RC=$?; }
+
+# Invalid severity: reject before any gh mutation.
+r="$WORK/invalid"; mkdir -p "$r"; printf 'bogus\n' > "$r/severity.txt"
+GH_FAKE_LABELS='' run_main "$r"
+eq "invalid severity returns 1" "$RC" "1"
+lacks "invalid severity: no label edit" "pr edit"
+
+# No verdict at all: warn and no-op, no gh mutation.
+r="$WORK/noverdict"; mkdir -p "$r"
+GH_FAKE_LABELS='' run_main "$r"
+eq "missing severity.txt returns 0" "$RC" "0"
+lacks "missing verdict: no label edit" "pr edit"
+
+# Empty/whitespace-only severity.txt is handled like a missing one (F30).
+r="$WORK/emptysev"; mkdir -p "$r"; printf ' \n' > "$r/severity.txt"
+GH_FAKE_LABELS='' run_main "$r"
+eq "empty severity.txt returns 0" "$RC" "0"
+lacks "empty severity: no label edit" "pr edit"
+
+# Valid severity, no comment: reconcile labels in one edit, remove only present.
+r="$WORK/label"; mkdir -p "$r"; printf 'high\n' > "$r/severity.txt"; printf 'false\n' > "$r/should_comment.txt"
+GH_FAKE_LABELS=$'severity-low\nkeep-me\nseverity-medium' run_main "$r"
+eq "label-only returns 0" "$RC" "0"
+has "adds target label" "--add-label severity-high"
+has "removes present severity-low" "--remove-label severity-low"
+has "removes present severity-medium" "--remove-label severity-medium"
+lacks "does not touch absent critical" "severity-critical"
+lacks "no comment when should_comment false" "pr comment"
+
+# should_comment true with a real body: sanitize and post it.
+r="$WORK/comment"; mkdir -p "$r"; printf 'low\n' > "$r/severity.txt"; printf 'true\n' > "$r/should_comment.txt"
+printf '## x\nhi @bob\n' > "$r/comment.md"
+GH_FAKE_LABELS='' run_main "$r"
+eq "comment path returns 0" "$RC" "0"
+has "posts sanitized comment" "pr comment 42 --repo owner/repo --body-file"
+in_file "posted body defangs @-mention" "$r/comment.sanitized.md" "@${ZWNJ}bob"
+
+# Capitalized should_comment is honored, not silently dropped (F31).
+r="$WORK/truecase"; mkdir -p "$r"; printf 'low\n' > "$r/severity.txt"; printf 'TRUE\n' > "$r/should_comment.txt"
+printf '## x\nhi\n' > "$r/comment.md"
+GH_FAKE_LABELS='' run_main "$r"
+eq "should_comment TRUE returns 0" "$RC" "0"
+has "TRUE still posts the comment" "pr comment 42 --repo owner/repo --body-file"
+
+# should_comment true but empty body (F15): no post.
+r="$WORK/empty"; mkdir -p "$r"; printf 'low\n' > "$r/severity.txt"; printf 'true\n' > "$r/should_comment.txt"
+: > "$r/comment.md"
+GH_FAKE_LABELS='' run_main "$r"
+eq "empty comment returns 0" "$RC" "0"
+lacks "empty comment: no post" "pr comment"
+
+# Oversized after sanitization (F22): brackets expand ~5x past the cap; no post.
+r="$WORK/big"; mkdir -p "$r"; printf 'low\n' > "$r/severity.txt"; printf 'true\n' > "$r/should_comment.txt"
+{ printf '## x\n'; head -c 20000 /dev/zero | tr '\0' '['; } > "$r/comment.md"
+GH_FAKE_LABELS='' run_main "$r"
+eq "oversized comment returns 0" "$RC" "0"
+lacks "oversized comment: no post" "pr comment"
+
+if [[ "$fail" -ne 0 ]]; then
+ echo "TESTS FAILED"
+ exit 1
+fi
+echo "ALL TESTS PASSED"
Why this scored 15/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.