diff --git a/.github/workflows/pr-risk.yml b/.github/workflows/pr-risk.yml index 53c07c4..308ccf4 100644 --- a/.github/workflows/pr-risk.yml +++ b/.github/workflows/pr-risk.yml @@ -3,9 +3,10 @@ name: PR Risk Grade (reusable) # Reusable ADVISORY PR risk grader — the shadow-check rung of the PR risk-grading ladder. # Grades every PR event into a tier R0 (safest) .. R3 (riskiest) and syncs ONE label # (`risk:R0` .. `risk:R3`, or `risk:ungraded` when an input was unreadable). That label is -# the entire product: nothing is gated, nothing is blocked, nothing merges, no comment is -# posted. Humans look at the label and agree or disagree; disagreement is recorded by adding -# the `risk-dispute` label (which this workflow never touches) plus a comment saying why. +# the entire product: nothing is gated, nothing is blocked and nothing merges. Humans record a +# different assessment with `risk-dispute:R0` .. `risk-dispute:R3`, either as a label or with +# `/risk-dispute R2 [optional reason]`. The legacy `risk-dispute` label and `/risk-dispute +# [optional reason]` remain tier-unspecified disputes. Neither changes the computed `risk:R*`. # # grade = worst(path_floor, provenance, reversibility) — three deterministic axes; the worst # tier wins, so no axis can move a PR into a safer lane than another axis put it. No LLM, no @@ -63,8 +64,8 @@ name: PR Risk Grade (reusable) # # ON-DEMAND GRADING (`pr_number` / `pr_numbers`): supply a PR number and that PR is graded with # no `pull_request` event involved — which is how a repo enrolling mid-stream grades the open -# queue it already has, and how a PR is re-graded after a `.github/risk.json` change or a -# `risk-dispute`. Absent, the workflow reads the event exactly as before. Three things differ on +# queue it already has, and how a PR is re-graded after a `.github/risk.json` change. Absent, the +# workflow reads the event exactly as before. Three things differ on # the by-number path, all of them deliberate: # * BOT-AUTHORED PRs ARE GRADED. The `github.actor != 'dependabot[bot]'` clause callers put in # their `if:` is a TOKEN guard, not a policy one: a bot-triggered `pull_request` run gets a @@ -106,7 +107,7 @@ name: PR Risk Grade (reusable) # labels on a PR under one `label_map`; remapping `label_map` orphans the old names, which is a # one-time repo-side cleanup. What the shape does cost is a narrower residual: the PUT is built from # a snapshot read, so a NON-owned label added by someone else in the read→PUT window is dropped -# (`risk-dispute` included) and one removed in it is resurrected. That window opens only on a run +# (`risk-dispute` forms included) and one removed in it is resurrected. That window opens only on a run # that actually changes the grade and is roughly one API round-trip — about three on the first # grade in a repo, where the label pre-create sits inside it. The drop is recorded on the PR # timeline as an `unlabeled` event, so re-add a dispute that happens to land in that instant. @@ -116,8 +117,8 @@ name: PR Risk Grade (reusable) # quiet. # # SECRETS: none. This workflow declares no `secrets:` inputs and callers pass none — the only -# credential in the job is the automatic `GITHUB_TOKEN` (`github.token`), used for the PR read -# and the one label write. There is no `secrets: inherit` to add and nothing to rotate. +# credential in the job is the automatic `GITHUB_TOKEN` (`github.token`), used for PR reads, +# labels and dispute audit comments. There is no `secrets: inherit` to add and nothing to rotate. # # The label is applied with the plain GITHUB_TOKEN on purpose: GITHUB_TOKEN-applied labels # cannot fire `labeled` triggers, so the shadow check is structurally unable to start a @@ -136,7 +137,9 @@ name: PR Risk Grade (reusable) # name: CI - PR Risk Grade # on: # pull_request: -# types: [opened, synchronize, reopened, ready_for_review] +# types: [opened, synchronize, reopened, ready_for_review, labeled, unlabeled] +# issue_comment: +# types: [created] # workflow_dispatch: # inputs: # pr_number: @@ -154,7 +157,7 @@ name: PR Risk Grade (reusable) # # A `pr_numbers` LIST keys its own group, which serializes identical batches but cannot # # serialize a batch against a `pull_request` run for one of its members — see "A BATCH # # CANNOT SERIALIZE PER-PR" above for what that costs and how to avoid it. -# group: ${{ github.workflow }}-${{ inputs.pr_numbers || inputs.pr_number || github.event.pull_request.number }} +# group: ${{ github.workflow }}-${{ inputs.pr_numbers || inputs.pr_number || github.event.pull_request.number || github.event.issue.number }} # cancel-in-progress: true # permissions: # contents: read @@ -171,8 +174,12 @@ name: PR Risk Grade (reusable) # # on a dispatch the token is writable and the actor is a human, so there is nothing left # # for either clause to protect. # if: >- -# github.event_name != 'pull_request' || -# (github.actor != 'dependabot[bot]' && +# github.event_name != 'issue_comment' && +# (github.event_name != 'pull_request' || +# ((github.event.action != 'labeled' && github.event.action != 'unlabeled') || +# github.event.label.name == 'risk-dispute' || +# startsWith(github.event.label.name, 'risk-dispute:')) && +# github.actor != 'dependabot[bot]' && # github.event.pull_request.head.repo.full_name == github.repository) # permissions: # contents: read @@ -211,6 +218,24 @@ name: PR Risk Grade (reusable) # # For a backfill, dispatch with this lowered — see ON-DEMAND GRADING above for why a # # low wait is sound on the by-number path and why `0` still is not the right value. # # wait_for_checks_minutes: 1 +# risk-dispute-comment: +# if: >- +# github.event.issue.pull_request && +# (github.event.comment.body == '/risk-dispute' || +# startsWith(github.event.comment.body, '/risk-dispute ')) +# permissions: +# contents: read +# issues: write +# pull-requests: write +# checks: write +# actions: read +# statuses: read +# uses: Comfy-Org/github-workflows/.github/workflows/pr-risk.yml@ # v1 +# with: +# workflows_ref: +# enabled: true +# pr_number: ${{ github.event.issue.number }} +# wait_for_checks_minutes: 1 # # A SKIPPED CALLER JOB IS INVISIBLE FROM HERE. This workflow cannot detect, warn about or recover # from a caller whose `if:` excluded it — no run is created, so nothing of ours executes. The @@ -244,8 +269,8 @@ on: `pull_request` run: with no number supplied the target, the base ref and every emitted label are exactly what they were before this input existed. Supplying it is what makes grading possible without a `pull_request` event — the enrollment backfill of an - already-open queue, and the manual re-grade after a risk-map change or a - `risk-dispute`. Bot-authored and fork PRs ARE graded on this path (see the header). + already-open queue, and the manual re-grade after a risk-map change. + Bot-authored and fork PRs ARE graded on this path (see the header). Typed `string` rather than `number` because `workflow_dispatch` inputs arrive as strings, and because an empty string is what lets the fall-through to the event's own number stay a single expression. @@ -294,6 +319,14 @@ on: type: string required: false default: '' + allowed_dispute_associations: + description: >- + Comma-separated author associations allowed to use `/risk-dispute`. + Label-based disputes already require label permission; this gate applies + to the comment command. Use commas with no spaces. + type: string + required: false + default: 'OWNER,MEMBER,COLLABORATOR' wait_for_checks_minutes: description: >- How long to wait, PER TARGET, for the REST of the check rollup to settle before @@ -338,10 +371,9 @@ on: Ticking the checkbox applies `risk-grade-disputed` on the next grade, and a re-grade preserves the tick rather than resetting it. That label is DISTINCT from the - human-owned `risk-dispute` convention, which the grader still never touches: one is a - machine-maintained mirror of a checkbox, rewritten on every grade; the other is a - human's own label. Needs no permission beyond the `pull-requests: write` the grade job - already holds for the label. + human-owned `risk-dispute` and `risk-dispute:R*` labels: one mirrors a checkbox, while + the others record a disagreement with an optional human tier. Needs no permission beyond + the `pull-requests: write` the grade job already holds for the label. type: boolean required: false default: false @@ -728,8 +760,14 @@ jobs: # lockout. If a repo ever needs the harder guarantee, remove the caller — nothing here can # bind a maintainer who can edit the workflow file anyway. if: >- - needs.gate.outputs.enabled == 'true' || - github.event_name == 'workflow_dispatch' + (needs.gate.outputs.enabled == 'true' || + github.event_name == 'workflow_dispatch') && + (github.event_name != 'issue_comment' || + (github.event.issue.pull_request && + (github.event.comment.body == '/risk-dispute' || + startsWith(github.event.comment.body, '/risk-dispute ')) && + contains(format(',{0},', inputs.allowed_dispute_associations), + format(',{0},', github.event.comment.author_association)))) name: Grade PR risk runs-on: ubuntu-latest timeout-minutes: 30 @@ -958,6 +996,34 @@ jobs: set -uo pipefail bash _pr_risk_tool/scripts/pr-risk/grade-targets.sh + - name: Record risk dispute + if: >- + always() && !cancelled() && + (github.event_name == 'issue_comment' || + (github.event_name == 'pull_request' && + (github.event.action == 'opened' || + github.event.action == 'reopened' || + github.event.action == 'ready_for_review' || + github.event.action == 'labeled' || + github.event.action == 'unlabeled' || + github.event.action == 'synchronize'))) + env: + REPO: ${{ github.repository }} + PR_NUMBER: ${{ inputs.pr_number || github.event.pull_request.number || github.event.issue.number }} + RECORD: record-${{ inputs.pr_number || github.event.pull_request.number || github.event.issue.number }}.json + EVENT_NAME: ${{ github.event_name }} + EVENT_ACTION: ${{ github.event.action }} + EVENT_LABEL: ${{ github.event.label.name }} + COMMENT_BODY: ${{ github.event.comment.body }} + COMMENT_ID: ${{ github.event.comment.id }} + COMMENT_URL: ${{ github.event.comment.html_url }} + ACTOR: ${{ github.event.sender.login || github.actor }} + ACTOR_ASSOCIATION: ${{ github.event.comment.author_association }} + ALLOWED_ASSOCIATIONS: ${{ inputs.allowed_dispute_associations }} + RUN_ID: ${{ github.run_id }} + GH_TOKEN: ${{ github.token }} + run: bash _pr_risk_tool/scripts/pr-risk/handle-risk-dispute.sh + - name: Step summary if: always() env: @@ -1060,7 +1126,7 @@ jobs: jq -r '"| #\(.pr) | \(.tier // "—") | \(.label // "—") | `\(.base_ref // "—")` | \(.waited)s | \(.note // "") |"' "$RESULTS" fi echo - echo "This label routes nothing and gates nothing. Disagree with the grade? Add the \`risk-dispute\` label and say why in a comment — the grader never touches that label." + echo "This label routes nothing and gates nothing. Record a human assessment with \`risk-dispute:R0\`…\`R3\` or \`/risk-dispute R0\`…\`R3 [optional reason]\`. Legacy \`risk-dispute\` remains a tier-unspecified disagreement; neither form changes \`risk:R*\`." } >> "$GITHUB_STEP_SUMMARY" # THE ONE JOB THAT HOLDS `checks: write`, and it reads NOTHING from a pull request. Its whole diff --git a/.github/workflows/test-pr-risk.yml b/.github/workflows/test-pr-risk.yml index c70d045..cc8d7dd 100644 --- a/.github/workflows/test-pr-risk.yml +++ b/.github/workflows/test-pr-risk.yml @@ -48,7 +48,7 @@ jobs: persist-credentials: false - name: shellcheck - run: shellcheck -x grade-pr-risk.sh apply-risk-label.sh grade-targets.sh resolve-enabled.sh tests/test_grade_pr_risk.sh tests/test_apply_risk_label.sh tests/test_grade_targets.sh tests/test_resolve_enabled.sh tests/test_pin_contract.sh publish-risk-surfaces.sh tests/test_publish_risk_surfaces.sh + run: shellcheck -x grade-pr-risk.sh apply-risk-label.sh grade-targets.sh handle-risk-dispute.sh resolve-enabled.sh tests/test_grade_pr_risk.sh tests/test_apply_risk_label.sh tests/test_grade_targets.sh tests/test_handle_risk_dispute.sh tests/test_resolve_enabled.sh tests/test_pin_contract.sh publish-risk-surfaces.sh tests/test_publish_risk_surfaces.sh - name: default map + registry parse and validate # The shipped defaults must pass the grader's own structural validation: @@ -69,6 +69,9 @@ jobs: # every call it receives is logged, so the suite asserts on which requests were made. run: bash tests/test_grade_targets.sh + - name: risk-dispute suite + run: bash tests/test_handle_risk_dispute.sh + - name: pin-contract suite # The `workflows_ref` guard in pr-risk.yml itself — the trust boundary that decides which # revision of this repo's grader runs, and the one piece of logic that CANNOT live in a diff --git a/docs/callers/pr-risk.md b/docs/callers/pr-risk.md index bf95d1c..ee1d839 100644 --- a/docs/callers/pr-risk.md +++ b/docs/callers/pr-risk.md @@ -6,9 +6,10 @@ Read [the shared caller contract](README.md) first. Grades every PR into a tier `R0` (safest) .. `R3` (riskiest) and syncs **one** label (`risk:R0`..`risk:R3`, or `risk:ungraded` when an input was unreadable). -The label is the entire product: nothing is gated, routed, commented, or -merged — a human looks at the label and agrees or disagrees (recorded with a -`risk-dispute` label this workflow never touches). +Nothing is gated, routed, or merged. A human can record a different assessment +beside the computed label with `risk-dispute:R0` through `risk-dispute:R3`. +The legacy plain `risk-dispute` marker remains valid with no human tier; neither +form changes the computed `risk:R*`. Deterministic, no LLM: `grade = worst(path_floor, provenance, reversibility)` — a path-glob map, what process produced the diff (registered runbooks, forks @@ -37,10 +38,12 @@ on: # a fork run under plain `pull_request` cannot write the label. See the # fork gotcha below before you swap it. pull_request: - types: [opened, synchronize, reopened, ready_for_review] + types: [opened, synchronize, reopened, ready_for_review, labeled, unlabeled] + issue_comment: + types: [created] concurrency: - group: pr-risk-${{ github.event.pull_request.number }} + group: pr-risk-${{ github.event.pull_request.number || github.event.issue.number }} cancel-in-progress: true permissions: @@ -48,6 +51,14 @@ permissions: jobs: pr-risk: + if: >- + github.event_name != 'issue_comment' && + (github.event_name != 'pull_request' || + (((github.event.action != 'labeled' && github.event.action != 'unlabeled') || + github.event.label.name == 'risk-dispute' || + startsWith(github.event.label.name, 'risk-dispute:')) && + github.actor != 'dependabot[bot]' && + github.event.pull_request.head.repo.full_name == github.repository)) permissions: contents: read issues: write # create the risk:* labels repo-side on first use @@ -61,6 +72,26 @@ jobs: uses: Comfy-Org/github-workflows/.github/workflows/pr-risk.yml@ with: workflows_ref: + enabled: true + + risk-dispute-comment: + if: >- + github.event.issue.pull_request && + (github.event.comment.body == '/risk-dispute' || + startsWith(github.event.comment.body, '/risk-dispute ')) + permissions: + contents: read + issues: write + pull-requests: write + checks: write + actions: read + statuses: read + uses: Comfy-Org/github-workflows/.github/workflows/pr-risk.yml@ + with: + workflows_ref: + enabled: true + pr_number: ${{ github.event.issue.number }} + wait_for_checks_minutes: 1 ``` Enrolling is **two steps** — merging the caller above is only the first. Ask a @@ -118,10 +149,39 @@ fail the caller's next run at startup. | `fleet_logins` | `mattmillerai` | Logins whose PRs grade provenance `agent-supervised` alongside `agent-coded`. Both are read for **human** authors only: an author GitHub types as a `Bot` is a runbook candidate regardless, so listing a bot here (or labelling its PR) buys it nothing — only a registry entry that asserts can promote it. | | `bot_logins` | `github-actions,dependabot,renovate,coderabbitai,cursor,comfy-pr-bot,web-flow` | Extra logins treated as bots. Needed only for **machine USER accounts** — a real GitHub App is recognized from GitHub's own actor type, no list entry required. A bot with no runbook entry still grades as human — identity alone buys no trust. **This list is load-bearing, not a hint:** a listed login skips the first-time-contributor test, so it moves a non-fork `NONE`/`FIRST_TIME_CONTRIBUTOR` PR from `external` (R3) to `human` (R1). Nothing validates that a listed login is really a machine account, so add one only for an account you control, and remove it when it is retired. | | `label_map` | `''` | Rename the five grader-owned labels as `tier=label` pairs. Tier keys are fixed; only the label text is yours. | +| `allowed_dispute_associations` | `OWNER,MEMBER,COLLABORATOR` | Comment authors allowed to use `/risk-dispute`. Comma-separated with no spaces. Direct label changes already require repository label permission. | | `wait_for_checks_minutes` | `10` | How long to wait for the rest of the check rollup to settle before labeling (clamped to 25 — what a 30-minute job can spend waiting). `0` labels immediately, expect R2 floors from still-pending checks. | | `repo_map_path` | `.github/risk.json` | Consumer risk-map override, read from the PR **base ref**. | | `repo_runbooks_path` | `.github/risk-runbooks.json` | Consumer runbook-registry override, read from the PR **base ref**. | +## Risk disputes + +A human assessment sits beside the computed grade; it never replaces it: + +```text +risk:R1 +risk-dispute:R2 +``` + +Apply `risk-dispute:R0` through `risk-dispute:R3` directly, or comment: + +```text +/risk-dispute R2 Optional reason +``` + +The legacy forms remain supported as a disagreement with no human-assessed tier: + +```text +risk-dispute +/risk-dispute Optional reason +``` + +The reason may be empty or continue on later lines. A tiered dispute replaces +the legacy label and any previous tier. `/risk-dispute clear` clears both forms; +removing a label clears that form, and a new push expires both. Each change posts +a bot-authored audit record with the computed tier, nullable human tier, head +SHA, source, actor and optional reason. + ## Gotchas **Fork PRs need `pull_request_target`, not `pull_request`.** A fork PR under a diff --git a/scripts/pr-risk/README.md b/scripts/pr-risk/README.md index 1b8a4a6..32f8e4d 100644 --- a/scripts/pr-risk/README.md +++ b/scripts/pr-risk/README.md @@ -11,10 +11,12 @@ event is graded into a tier and gets ONE label: | R3 | `risk:R3` | elevated — auth, billing, migrations, IaC, CI, deps, secrets | owner + e2e | | — | `risk:ungraded` | an input could not be read; deliberately NOT a tier | human review | -**The label is the entire product.** Nothing is gated, blocked, routed, commented -on, or merged. Humans glance at the label and either agree or disagree. -Disagree by adding the `risk-dispute` label (never touched by the grader) plus a -comment saying why — disputes are the pilot's calibration data. +Nothing is gated, blocked, routed, or merged. Humans record a different +assessment beside the computed label with `risk-dispute:R0` through +`risk-dispute:R3`, or `/risk-dispute R2 [optional reason]`. The legacy +`risk-dispute` label and `/risk-dispute [optional reason]` remain valid with no +human tier. No dispute changes the computed `risk:R*`; both forms become +calibration data. ## How a grade is computed @@ -106,8 +108,8 @@ Two CI-specific mechanics worth knowing: Every grade above is triggered by a `pull_request` event. Two things need a grade with no event: a repo that **enrolls mid-stream** and wants the open queue it -already has labeled, and a **manual re-grade** after a `.github/risk.json` change -or on a PR carrying `risk-dispute`. Both are a `workflow_dispatch` on the +already has labeled, and a **manual re-grade** after a `.github/risk.json` change. +Both are a `workflow_dispatch` on the consumer's caller, forwarding a number: ```yaml @@ -177,7 +179,7 @@ Operational caveats for a backfill: re-dispatch on `pr_number` if a final grade looks wrong. The residual it costs instead is narrower, but it cuts both ways: the PUT is built from a snapshot read, so a **non-owned** label added in the read→PUT window is dropped - (`risk-dispute` included — re-add a dispute that lands in that instant) and one + (`risk-dispute` forms included — re-add a dispute that lands in that instant) and one **removed** in that window is resurrected. The window opens only on a run that actually changes the grade, and is about one API round-trip — three on the first grade in a repo, where the label pre-create sits inside it. A drop is not diff --git a/scripts/pr-risk/apply-risk-label.sh b/scripts/pr-risk/apply-risk-label.sh index b413a6d..68a4b8e 100755 --- a/scripts/pr-risk/apply-risk-label.sh +++ b/scripts/pr-risk/apply-risk-label.sh @@ -16,13 +16,13 @@ # up front if any tier maps to an empty name, so the array can never degrade to the empty-labels # request that strips a PR. Labels it does NOT own are carried through the PUT as the snapshot read # saw them (see RESIDUAL), so a human who disagrees with a grade records that with their -# OWN label (the pilot convention is `risk-dispute`) and the grader will never fight it. Editing the -# grader-owned label by hand is futile by design: the next push re-syncs it. +# OWN label (`risk-dispute` or `risk-dispute:R0` .. `risk-dispute:R3`) and the grader will never +# fight it. Editing the grader-owned label by hand is futile by design: the next push re-syncs it. # # RESIDUAL — the price of atomicity, worth knowing for the pilot: the PUT is built from a SNAPSHOT # read, so it is an unguarded read-modify-write (the labels endpoint offers no version precondition # that could make it otherwise), and it clobbers BOTH directions inside that window. A NON-owned -# label ADDED there is silently dropped — `risk-dispute` INCLUDED — and a non-owned label REMOVED +# label ADDED there is silently dropped — `risk-dispute` forms INCLUDED — and a non-owned label REMOVED # there is RESURRECTED, so a `do-not-merge` or review label a human or a sibling labeler cleared in # that instant comes back. The window opens ONLY on a run that actually changes the grade (an # in-sync PR writes nothing at all) and is normally ~one API round-trip; on the FIRST grade in a diff --git a/scripts/pr-risk/handle-risk-dispute.sh b/scripts/pr-risk/handle-risk-dispute.sh new file mode 100755 index 0000000..8e9ed7c --- /dev/null +++ b/scripts/pr-risk/handle-risk-dispute.sh @@ -0,0 +1,316 @@ +#!/usr/bin/env bash + +set -uo pipefail + +REPO="${REPO:-}" +PR_NUMBER="${PR_NUMBER:-}" +RECORD="${RECORD:-}" +EVENT_NAME="${EVENT_NAME:-}" +EVENT_ACTION="${EVENT_ACTION:-}" +EVENT_LABEL="${EVENT_LABEL:-}" +COMMENT_BODY="${COMMENT_BODY:-}" +COMMENT_ID="${COMMENT_ID:-}" +COMMENT_URL="${COMMENT_URL:-}" +ACTOR="${ACTOR:-}" +ACTOR_ASSOCIATION="${ACTOR_ASSOCIATION:-}" +ALLOWED_ASSOCIATIONS="${ALLOWED_ASSOCIATIONS:-OWNER,MEMBER,COLLABORATOR}" +RUN_ID="${RUN_ID:-}" +NOW="${NOW:-$(date -u +%Y-%m-%dT%H:%M:%SZ)}" +DRY_RUN="${DRY_RUN:-0}" + +DISPUTE_LABEL="risk-dispute" +DISPUTE_PREFIX="${DISPUTE_LABEL}:" +DISPUTE_MARKER="ci-pr-risk-dispute:v1" + +log() { printf '[risk-dispute] %s\n' "$*" >&2; } +die() { printf '[risk-dispute] ERROR %s\n' "$*" >&2; exit 2; } +fail() { printf '[risk-dispute] FAIL %s\n' "$*" >&2; exit 4; } + +[ -n "$REPO" ] || die "REPO is required" +[[ "$REPO" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]] || die "bad REPO '$REPO'" +[[ "$PR_NUMBER" =~ ^[0-9]+$ ]] || die "bad PR_NUMBER '$PR_NUMBER'" +command -v jq >/dev/null 2>&1 || die "jq not found on PATH" +[ "$DRY_RUN" = 1 ] || command -v gh >/dev/null 2>&1 || die "gh not found on PATH" + +trim() { + sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' <<<"$1" +} + +action="" +tier="" +reason="" +source="" +bootstrap=0 +clear_scope="all" + +case "$EVENT_NAME:$EVENT_ACTION" in + issue_comment:created) + [[ "$ALLOWED_ASSOCIATIONS" != *[[:space:]]* ]] \ + || die "ALLOWED_ASSOCIATIONS must be comma-separated with no spaces" + case ",$ALLOWED_ASSOCIATIONS," in + *",$ACTOR_ASSOCIATION,"*) ;; + *) log "ignoring /risk-dispute from association '${ACTOR_ASSOCIATION:-unknown}'"; exit 0 ;; + esac + + body="${COMMENT_BODY//$'\r'/}" + first_line="${body%%$'\n'*}" + remaining="" + [[ "$body" != *$'\n'* ]] || remaining="${body#*$'\n'}" + if [[ "$first_line" =~ ^/risk-dispute[[:space:]]+(R[0-3])([[:space:]]+(.*))?$ ]]; then + action="set" + tier="${BASH_REMATCH[1]}" + reason="$(trim "${BASH_REMATCH[3]:-}")" + if [ -n "$(trim "$remaining")" ]; then + [ -z "$reason" ] || reason+=$'\n' + reason+="$(trim "$remaining")" + fi + elif [[ "$first_line" =~ ^/risk-dispute[[:space:]]+clear[[:space:]]*$ ]]; then + action="clear" + elif [[ "$first_line" =~ ^/risk-dispute([[:space:]]+(.*))?$ ]]; then + reason="$(trim "${BASH_REMATCH[2]:-}")" + [[ "$reason" =~ ^R[0-9]+([[:space:]]|$) ]] \ + && die "bad tier; use R0, R1, R2 or R3" + if [ -n "$(trim "$remaining")" ]; then + [ -z "$reason" ] || reason+=$'\n' + reason+="$(trim "$remaining")" + fi + action="set" + else + die "bad command; use '/risk-dispute [optional reason]', '/risk-dispute R0..R3 [optional reason]' or '/risk-dispute clear'" + fi + source="comment" + ;; + pull_request:labeled) + if [[ "$EVENT_LABEL" =~ ^risk-dispute:(R[0-3])$ ]]; then + tier="${BASH_REMATCH[1]}" + elif [ "$EVENT_LABEL" != "$DISPUTE_LABEL" ]; then + exit 0 + fi + action="set" + source="label" + ;; + pull_request:unlabeled) + if [[ "$EVENT_LABEL" =~ ^risk-dispute:(R[0-3])$ ]]; then + tier="${BASH_REMATCH[1]}" + clear_scope="tiered" + elif [ "$EVENT_LABEL" = "$DISPUTE_LABEL" ]; then + clear_scope="legacy" + else + exit 0 + fi + action="clear" + source="label" + ;; + pull_request:opened|pull_request:reopened|pull_request:ready_for_review) + action="bootstrap" + source="workflow" + ;; + pull_request:synchronize) + action="expire" + source="push" + bootstrap=1 + ;; + *) exit 0 ;; +esac + +ERRF="$(mktemp "${TMPDIR:-/tmp}/risk-dispute-err.XXXXXX")" || die "mktemp failed" +trap 'rm -f "$ERRF"' EXIT +ghq() { gh "$@" 2>"$ERRF"; } +gherr() { tr '\n' ' ' <"$ERRF" | sed 's/[[:space:]]*$//'; } +enc() { jq -rn --arg s "$1" '$s | @uri'; } +color_for() { + case "$1" in + R0) echo 0e8a16 ;; R1) echo fbca04 ;; R2) echo d93f0b ;; R3) echo b60205 ;; + *) echo cfd3d7 ;; + esac +} + +description_for() { + if [ "$1" = legacy ]; then + echo "Human disagreement with PR risk grade; tier unspecified" + else + echo "Human PR risk assessment for grader calibration" + fi +} + +ensure_dispute_labels() { + local labels candidate candidate_tier + labels="$(ghq api --paginate "repos/$REPO/labels?per_page=100" --jq '[.[].name]' \ + | jq -sc 'add // []')" \ + || fail "could not read repository labels on $REPO: $(gherr)" + for candidate_tier in legacy R0 R1 R2 R3; do + if [ "$candidate_tier" = legacy ]; then + candidate="$DISPUTE_LABEL" + else + candidate="${DISPUTE_PREFIX}${candidate_tier}" + fi + if jq -e --arg candidate "$candidate" \ + 'map(ascii_downcase) | index($candidate | ascii_downcase) != null' \ + >/dev/null <<<"$labels"; then + continue + fi + [ "$DRY_RUN" = 1 ] && { log "DRY RUN — would create '$candidate'"; continue; } + ghq api -X POST "repos/$REPO/labels" -f name="$candidate" \ + -f color="$(color_for "$candidate_tier")" \ + -f description="$(description_for "$candidate_tier")" >/dev/null \ + || fail "could not create '$candidate': $(gherr)" + done +} + +if [ "$action" = bootstrap ] || [ "$bootstrap" = 1 ]; then + ensure_dispute_labels + [ "$action" != bootstrap ] || exit 0 +fi + +current="$(ghq api --paginate "repos/$REPO/issues/$PR_NUMBER/labels?per_page=100" --jq '[.[].name]' \ + | jq -sc 'add // []')" \ + || fail "could not read labels on $REPO#$PR_NUMBER: $(gherr)" + +disputes="$(jq -c '[.[] | select(test("^risk-dispute:R[0-3]$"; "i"))]' <<<"$current")" \ + || fail "could not inspect dispute labels" +dispute_count="$(jq 'length' <<<"$disputes")" || fail "could not count dispute labels" +legacy_count="$(jq '[.[] | select(ascii_downcase == "risk-dispute")] | length' <<<"$current")" \ + || fail "could not inspect the legacy dispute label" + +if [ "$action" = expire ] && [ "$dispute_count" -eq 0 ] && [ "$legacy_count" -eq 0 ]; then + exit 0 +fi + +if [ "$action" = set ]; then + if [ -n "$tier" ]; then + target="${DISPUTE_PREFIX}${tier}" + target_count="$(jq --arg target "$target" '[.[] | select(ascii_downcase == ($target | ascii_downcase))] | length' <<<"$disputes")" \ + || fail "could not inspect the target dispute label" + needs_sync=0 + [ "$dispute_count" -eq 1 ] && [ "$target_count" -eq 1 ] && [ "$legacy_count" -eq 0 ] \ + || needs_sync=1 + else + target="$DISPUTE_LABEL" + needs_sync=0 + [ "$legacy_count" -eq 1 ] || needs_sync=1 + fi + if [ "$needs_sync" = 1 ]; then + if [ "$DRY_RUN" != 1 ]; then + if ! probe_err="$(gh api "repos/$REPO/labels/$(enc "$target")" 2>&1 >/dev/null)"; then + case "$probe_err" in + *"HTTP 404"*|*"Not Found"*) + ghq api -X POST "repos/$REPO/labels" -f name="$target" \ + -f color="$(color_for "$tier")" \ + -f description="$(description_for "${tier:-legacy}")" >/dev/null \ + || fail "could not create '$target': $(gherr)" + ;; + *) fail "could not inspect '$target': $(tr '\n' ' ' <<<"$probe_err")" ;; + esac + fi + fi + if [ -n "$tier" ]; then + desired="$(jq -c --arg target "$target" \ + '[.[] | select(test("^risk-dispute(?::R[0-3])?$"; "i") | not)] + [$target]' <<<"$current")" \ + || fail "could not build the tiered dispute label set" + else + desired="$(jq -c --arg target "$target" '. + [$target]' <<<"$current")" \ + || fail "could not add the legacy dispute label" + fi + else + desired="$current" + fi +else + case "$clear_scope" in + legacy) + desired="$(jq -c '[.[] | select(ascii_downcase != "risk-dispute")]' <<<"$current")" ;; + tiered) + desired="$(jq -c '[.[] | select(test("^risk-dispute:R[0-3]$"; "i") | not)]' <<<"$current")" ;; + *) + desired="$(jq -c '[.[] | select(test("^risk-dispute(?::R[0-3])?$"; "i") | not)]' <<<"$current")" ;; + esac || fail "could not clear dispute labels" +fi + +if [ "$desired" != "$current" ]; then + if [ "$DRY_RUN" = 1 ]; then + log "DRY RUN — would sync dispute labels to $(jq -c . <<<"$desired")" + else + jq -n --argjson labels "$desired" '{labels:$labels}' \ + | ghq api -X PUT "repos/$REPO/issues/$PR_NUMBER/labels" --input - >/dev/null \ + || fail "could not sync dispute labels on $REPO#$PR_NUMBER: $(gherr)" + fi +fi + +computed_tier="" +map_version="" +head_sha="" +if [ -n "$RECORD" ] && [ -s "$RECORD" ] && jq -e . "$RECORD" >/dev/null 2>&1; then + computed_tier="$(jq -r '.risk.tier // ""' "$RECORD")" + map_version="$(jq -r '.risk.map_version // ""' "$RECORD")" + head_sha="$(jq -r '.head_sha // ""' "$RECORD")" +fi +[ -n "$computed_tier" ] || computed_tier="$(jq -r '[.[] | capture("^risk:(?R[0-3])$"; "i").tier | ascii_upcase][0] // ""' <<<"$current")" +if [ -z "$head_sha" ]; then + head_sha="$(ghq api "repos/$REPO/pulls/$PR_NUMBER" --jq '.head.sha')" \ + || fail "could not read the PR head: $(gherr)" +fi + +previous_tiers="$(jq -c '[.[] | capture("^risk-dispute:(?R[0-3])$"; "i").tier | ascii_upcase]' <<<"$disputes")" \ + || previous_tiers='[]' +if [ -n "$tier" ] && [ "$EVENT_NAME:$EVENT_ACTION" = pull_request:labeled ]; then + previous_tiers="$(jq -c --arg tier "$tier" 'map(select(. != $tier))' <<<"$previous_tiers")" \ + || fail "could not record the previous dispute tiers" +elif [ -n "$tier" ] && [ "$EVENT_NAME:$EVENT_ACTION" = pull_request:unlabeled ]; then + previous_tiers="$(jq -c --arg tier "$tier" 'if index($tier) then . else . + [$tier] end' <<<"$previous_tiers")" \ + || fail "could not record the previous dispute tiers" +fi +reason_json="null" +[ -z "$reason" ] || reason_json="$(jq -cn --arg reason "$reason" '$reason')" +human_json="null" +if [ "$action" = set ] && [ -n "$tier" ]; then + human_json="$(jq -cn --arg tier "$tier" '$tier')" +fi + +record="$(jq -cn \ + --arg action "$action" --arg repo "$REPO" --argjson pr "$PR_NUMBER" \ + --arg head_sha "$head_sha" --arg computed_tier "$computed_tier" \ + --argjson human_tier "$human_json" --argjson previous_tiers "$previous_tiers" \ + --argjson reason "$reason_json" --arg source "$source" --arg actor "$ACTOR" \ + --arg association "$ACTOR_ASSOCIATION" --arg comment_id "$COMMENT_ID" \ + --arg comment_url "$COMMENT_URL" --arg map_version "$map_version" \ + --arg run_id "$RUN_ID" --arg created_at "$NOW" \ + '{schema:1, action:$action, repo:$repo, pr:$pr, head_sha:$head_sha, + computed_tier:(if $computed_tier == "" then null else $computed_tier end), + human_tier:$human_tier, previous_tiers:$previous_tiers, reason:$reason, + source:$source, actor:(if $actor == "" then null else $actor end), + actor_association:(if $association == "" then null else $association end), + source_comment_id:(if $comment_id == "" then null else $comment_id end), + source_comment_url:(if $comment_url == "" then null else $comment_url end), + map_version:(if $map_version == "" then null else $map_version end), + run_id:(if $run_id == "" then null else $run_id end), created_at:$created_at}')" \ + || fail "could not build the audit record" + +encoded="$(jq -rn --arg record "$record" '$record | @base64')" || fail "could not encode the audit record" +case "$action" in + set) + if [ -n "$tier" ]; then + summary="Risk dispute recorded: grader \`${computed_tier:-unknown}\`, human \`${tier}\` on \`${head_sha:0:12}\`." + else + summary="Risk dispute recorded: grader \`${computed_tier:-unknown}\`, human tier unspecified on \`${head_sha:0:12}\`." + fi + ;; + clear) summary="Risk dispute cleared on \`${head_sha:0:12}\`." ;; + expire) summary="Risk dispute expired after a new push to \`${head_sha:0:12}\`." ;; +esac +if [ "$source" = comment ] && [ -n "$COMMENT_URL" ] && [ -n "$reason" ]; then + summary+=" Reason: [command comment]($COMMENT_URL)." +elif [ "$action" = set ]; then + summary+=" No reason supplied." +fi +body=" +${summary}" + +if [ "$DRY_RUN" = 1 ]; then + printf '%s\n' "$body" + exit 0 +fi + +jq -n --arg body "$body" '{body:$body}' \ + | ghq api -X POST "repos/$REPO/issues/$PR_NUMBER/comments" --input - >/dev/null \ + || fail "could not write the dispute audit record: $(gherr)" + +log "$action recorded for $REPO#$PR_NUMBER" diff --git a/scripts/pr-risk/publish-risk-surfaces.sh b/scripts/pr-risk/publish-risk-surfaces.sh index efb8d03..6dd1035 100644 --- a/scripts/pr-risk/publish-risk-surfaces.sh +++ b/scripts/pr-risk/publish-risk-surfaces.sh @@ -70,10 +70,9 @@ # overwritten, and the reviewer re-ticks — which is why the body is re-read by id IMMEDIATELY # before the write rather than reused from the paginated scan. # -# `risk-grade-disputed` (this script's) is DELIBERATELY DISTINCT from `risk-dispute` (the human -# convention the grader never touches). One is a machine-maintained mirror of a checkbox and is -# rewritten on every grade; the other is a human's own label and would be fought over if this -# script owned it. +# `risk-grade-disputed` (this script's) is DELIBERATELY DISTINCT from the human-owned +# `risk-dispute` and `risk-dispute:R*` assessment labels. This script mirrors a checkbox; it never +# assigns a human tier. # # Inputs (env): # REPO owner/name of the repo holding the PR (required) diff --git a/scripts/pr-risk/tests/test_apply_risk_label.sh b/scripts/pr-risk/tests/test_apply_risk_label.sh index 78ab30f..4ba82a0 100755 --- a/scripts/pr-risk/tests/test_apply_risk_label.sh +++ b/scripts/pr-risk/tests/test_apply_risk_label.sh @@ -136,11 +136,11 @@ if grep -q -- '-X POST repos/test/repo/issues/7/labels' "$GH_LOG"; then else ok "and never an additive POST (that is the interleaving)"; fi echo "— unowned labels are preserved verbatim, disputes included —" -: > "$GH_LOG"; printf 'risk:R1\nrisk-dispute\nbug\n' > "$CURRENT_LABELS" +: > "$GH_LOG"; printf 'risk:R1\nrisk-dispute\nrisk-dispute:R2\nbug\n' > "$CURRENT_LABELS" PATH="$SANDBOX/bin:$PATH" REPO=test/repo PR_NUMBER=7 TIER=R3 bash "$SCRIPT" >/dev/null 2>&1 put3="$(grep -- '-X PUT repos/test/repo/issues/7/labels ' "$GH_LOG")" eq "the PUT carries exactly the unowned labels plus the new target" \ - "api -X PUT repos/test/repo/issues/7/labels -f labels[]=risk-dispute -f labels[]=bug -f labels[]=risk:R3" \ + "api -X PUT repos/test/repo/issues/7/labels -f labels[]=risk-dispute -f labels[]=risk-dispute:R2 -f labels[]=bug -f labels[]=risk:R3" \ "$put3" echo "— first use: the label is pre-created before the sync —" @@ -233,7 +233,7 @@ echo "— a broken jq must fail the run, never degrade the PUT to target-only # The carry-through filter used to run inside a process substitution, where its exit status is # structurally unobservable: `pipefail` does not reach `< <(...)` and `set -e` is off. A jq that # failed there appended nothing, so this destructive full-set replace silently became -# `labels[]=$TARGET` alone — DELETING every unowned label on the PR (`risk-dispute` included) +# `labels[]=$TARGET` alone — DELETING every unowned label on the PR (`risk-dispute` forms included) # while logging "synced" and exiting 0. A total replace must refuse to run on an unverified set. mkdir -p "$SANDBOX/binjqfail" cp "$SANDBOX/bin/gh" "$SANDBOX/binjqfail/gh" @@ -244,7 +244,7 @@ for a in "$@"; do [ "$a" = --argjson ] && { echo 'jq: error: synthetic failure' exec "$REAL_JQ" "$@" STUB chmod +x "$SANDBOX/binjqfail/jq" -: > "$GH_LOG"; printf 'risk:R0\nrisk-dispute\nkeep-me\n' > "$CURRENT_LABELS" +: > "$GH_LOG"; printf 'risk:R0\nrisk-dispute\nrisk-dispute:R3\nkeep-me\n' > "$CURRENT_LABELS" REAL_JQ="$(command -v jq)" PATH="$SANDBOX/binjqfail:$PATH" \ REPO=test/repo PR_NUMBER=7 TIER=R2 bash "$SCRIPT" >/dev/null 2>&1 eq "a failing carry-through filter exits 4" 4 "$?" diff --git a/scripts/pr-risk/tests/test_handle_risk_dispute.sh b/scripts/pr-risk/tests/test_handle_risk_dispute.sh new file mode 100755 index 0000000..97fa40f --- /dev/null +++ b/scripts/pr-risk/tests/test_handle_risk_dispute.sh @@ -0,0 +1,179 @@ +#!/usr/bin/env bash + +set -uo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +SCRIPT="$ROOT/handle-risk-dispute.sh" +SANDBOX="$(mktemp -d)" +trap 'rm -rf "$SANDBOX"' EXIT +mkdir -p "$SANDBOX/bin" + +PASS=0 +FAIL=0 +ok() { PASS=$((PASS + 1)); printf 'ok - %s\n' "$1"; } +bad() { FAIL=$((FAIL + 1)); printf 'not ok - %s: %s\n' "$1" "$2"; } +eq() { + if [ "$2" = "$3" ]; then ok "$1"; else bad "$1" "want '$2', got '$3'"; fi +} + +export GH_LOG="$SANDBOX/gh.log" +export LABELS="$SANDBOX/labels.json" +export LAST_PUT="$SANDBOX/put.json" +export AUDIT="$SANDBOX/audit.json" +export CREATED_LABELS="$SANDBOX/created-labels.txt" +export HEAD_SHA="0123456789abcdef0123456789abcdef01234567" + +cat >"$SANDBOX/bin/gh" <<'STUB' +#!/usr/bin/env bash +printf '%s\n' "$*" >>"$GH_LOG" +case "$*" in + *"issues/7/labels?per_page=100"*) cat "$LABELS" ;; + *"repos/test/repo/labels?per_page=100"*) printf '%s\n' '["risk-dispute:R0"]' ;; + *"repos/test/repo/labels/risk-dispute"*) echo '{}' ;; + *"-X PUT repos/test/repo/issues/7/labels"*) cat >"$LAST_PUT" ;; + *"-X POST repos/test/repo/issues/7/comments"*) cat >"$AUDIT" ;; + *"repos/test/repo/pulls/7"*) printf '%s\n' "$HEAD_SHA" ;; + *"-X POST repos/test/repo/labels"*) printf '%s\n' "$*" >>"$CREATED_LABELS" ;; + *) echo "unexpected gh call: $*" >&2; exit 1 ;; +esac +STUB +chmod +x "$SANDBOX/bin/gh" + +cat >"$SANDBOX/record.json" <"$GH_LOG" + rm -f "$LAST_PUT" "$AUDIT" "$CREATED_LABELS" +} + +run_handler() { + PATH="$SANDBOX/bin:$PATH" REPO=test/repo PR_NUMBER=7 \ + RECORD="$SANDBOX/record.json" NOW=2026-08-17T22:00:00Z RUN_ID=99 \ + bash "$SCRIPT" +} + +audit_record() { + jq -r '.body' "$AUDIT" | sed -n '1s/^$/\1/p' \ + | jq -Rr '@base64d | fromjson' +} + +reset_case +printf '%s\n' '["risk:R1","risk-dispute"]' >"$LABELS" +EVENT_NAME=issue_comment EVENT_ACTION=created ACTOR=reviewer ACTOR_ASSOCIATION=MEMBER \ + COMMENT_ID=55 COMMENT_URL=https://github.com/test/repo/pull/7#issuecomment-55 \ + COMMENT_BODY=$'/risk-dispute R2 because tests only\nSecond line.' run_handler +eq "comment adds the requested dispute label" true \ + "$(jq -r '.labels | index("risk-dispute:R2") != null' "$LAST_PUT")" +eq "comment preserves the computed label" true \ + "$(jq -r '.labels | index("risk:R1") != null' "$LAST_PUT")" +eq "a tiered comment replaces the legacy label" false \ + "$(jq -r '.labels | index("risk-dispute") != null' "$LAST_PUT")" +eq "comment records the human tier" R2 "$(audit_record | jq -r '.human_tier')" +eq "comment records the optional reason" $'because tests only\nSecond line.' \ + "$(audit_record | jq -r '.reason')" + +reset_case +printf '%s\n' '["risk:R1"]' >"$LABELS" +EVENT_NAME=issue_comment EVENT_ACTION=created ACTOR=reviewer ACTOR_ASSOCIATION=MEMBER \ + COMMENT_BODY='/risk-dispute R3' run_handler +eq "an empty reason is accepted" null "$(audit_record | jq -r '.reason')" + +reset_case +printf '%s\n' '["risk:R1"]' >"$LABELS" +EVENT_NAME=issue_comment EVENT_ACTION=created ACTOR=reviewer ACTOR_ASSOCIATION=MEMBER \ + COMMENT_BODY=$'/risk-dispute Existing workflow\nMore context.' run_handler +eq "a legacy comment adds the plain dispute label" \ + '["risk:R1","risk-dispute"]' "$(jq -c '.labels' "$LAST_PUT")" +eq "a legacy comment records no human tier" null \ + "$(audit_record | jq -r '.human_tier')" +eq "a legacy comment preserves its reason" $'Existing workflow\nMore context.' \ + "$(audit_record | jq -r '.reason')" + +reset_case +printf '%s\n' '["risk:R1"]' >"$LABELS" +EVENT_NAME=issue_comment EVENT_ACTION=created ACTOR=reviewer ACTOR_ASSOCIATION=MEMBER \ + COMMENT_BODY='/risk-dispute' run_handler +eq "a bare legacy command is accepted" risk-dispute \ + "$(jq -r '.labels[-1]' "$LAST_PUT")" +eq "a bare legacy command needs no reason" null "$(audit_record | jq -r '.reason')" + +reset_case +printf '%s\n' '["risk:R1","risk-dispute"]' >"$LABELS" +EVENT_NAME=pull_request EVENT_ACTION=labeled EVENT_LABEL=risk-dispute ACTOR=reviewer \ + run_handler +rewrite=0 +[ ! -e "$LAST_PUT" ] || rewrite=1 +eq "the legacy label is accepted without rewriting labels" 0 "$rewrite" +eq "the legacy label records no human tier" null "$(audit_record | jq -r '.human_tier')" + +reset_case +printf '%s\n' '["risk:R1","risk-dispute","risk-dispute:R3","risk-dispute:R2"]' >"$LABELS" +EVENT_NAME=pull_request EVENT_ACTION=labeled EVENT_LABEL=risk-dispute:R2 ACTOR=reviewer \ + run_handler +eq "a label replaces the previous dispute tier" \ + '["risk:R1","risk-dispute:R2"]' "$(jq -c '.labels' "$LAST_PUT")" +eq "label entry records no reason" null "$(audit_record | jq -r '.reason')" +eq "label entry records its source" label "$(audit_record | jq -r '.source')" +eq "label entry records only the tier that preceded it" '["R3"]' \ + "$(audit_record | jq -c '.previous_tiers')" + +reset_case +printf '%s\n' '["risk:R1","risk-dispute:R3"]' >"$LABELS" +EVENT_NAME=pull_request EVENT_ACTION=unlabeled EVENT_LABEL=risk-dispute ACTOR=reviewer \ + run_handler +rewrite=0 +[ ! -e "$LAST_PUT" ] || rewrite=1 +eq "removing the legacy label preserves a tiered dispute" 0 "$rewrite" +eq "legacy removal retains the tier in history" '["R3"]' \ + "$(audit_record | jq -c '.previous_tiers')" + +reset_case +printf '%s\n' '["risk:R1"]' >"$LABELS" +EVENT_NAME=pull_request EVENT_ACTION=unlabeled EVENT_LABEL=risk-dispute:R2 ACTOR=reviewer \ + run_handler +rewrite=0 +[ ! -e "$LAST_PUT" ] || rewrite=1 +eq "removing an already-removed dispute needs no label rewrite" 0 "$rewrite" +eq "label removal records no current human tier" null \ + "$(audit_record | jq -r '.human_tier')" +eq "label removal records the removed tier" '["R2"]' \ + "$(audit_record | jq -c '.previous_tiers')" + +reset_case +printf '%s\n' '["risk:R1","risk-dispute","risk-dispute:R2"]' >"$LABELS" +EVENT_NAME=issue_comment EVENT_ACTION=created ACTOR=reviewer ACTOR_ASSOCIATION=MEMBER \ + COMMENT_BODY='/risk-dispute clear' run_handler +eq "clear removes the dispute without touching computed risk" \ + '["risk:R1"]' "$(jq -c '.labels' "$LAST_PUT")" +eq "clear is recorded" clear "$(audit_record | jq -r '.action')" + +reset_case +printf '%s\n' '["risk:R1","risk-dispute","risk-dispute:R2"]' >"$LABELS" +EVENT_NAME=pull_request EVENT_ACTION=synchronize ACTOR=author run_handler +eq "a new push expires the dispute" '["risk:R1"]' "$(jq -c '.labels' "$LAST_PUT")" +eq "expiry is recorded" expire "$(audit_record | jq -r '.action')" +eq "synchronize provisions the remaining dispute labels" 4 \ + "$(wc -l <"$CREATED_LABELS" | tr -d ' ')" + +reset_case +printf '%s\n' '["risk:R1"]' >"$LABELS" +EVENT_NAME=pull_request EVENT_ACTION=labeled EVENT_LABEL=area:testing ACTOR=reviewer \ + run_handler +eq "unrelated labels are ignored" 0 "$(wc -l <"$GH_LOG" | tr -d ' ')" + +reset_case +printf '%s\n' '["risk:R1"]' >"$LABELS" +EVENT_NAME=issue_comment EVENT_ACTION=created ACTOR=outsider ACTOR_ASSOCIATION=NONE \ + COMMENT_BODY='/risk-dispute R2' run_handler +eq "unauthorized comments are ignored" 0 "$(wc -l <"$GH_LOG" | tr -d ' ')" + +reset_case +printf '%s\n' '["risk:R1"]' >"$LABELS" +EVENT_NAME=issue_comment EVENT_ACTION=created ACTOR=reviewer ACTOR_ASSOCIATION=MEMBER \ + COMMENT_BODY='/risk-dispute R4 unsupported tier' run_handler >/dev/null 2>&1 +eq "invalid commands fail validation" 2 "$?" + +printf '%s passed, %s failed\n' "$PASS" "$FAIL" +[ "$FAIL" -eq 0 ] diff --git a/scripts/pr-risk/tests/test_publish_risk_surfaces.sh b/scripts/pr-risk/tests/test_publish_risk_surfaces.sh index e2b3f85..54d372d 100644 --- a/scripts/pr-risk/tests/test_publish_risk_surfaces.sh +++ b/scripts/pr-risk/tests/test_publish_risk_surfaces.sh @@ -94,7 +94,8 @@ else bad "CHECKED_RE matches the ticked line we render" "$(grep -F "$DISPUTE_TEX if grep -Eq "$CHECKED_RE" <<<"$body"; then bad "CHECKED_RE does NOT match an unticked line" "$body" else ok "CHECKED_RE does NOT match an unticked line"; fi has "$dbody" "$DISPUTE_LABEL" "the checkbox names the label it applies" -hasnt "$dbody" "risk-dispute —" "…and never claims the human-owned risk-dispute label" +hasnt "$dbody" "risk-dispute —" "…and never claims the legacy human dispute label" +hasnt "$dbody" "risk-dispute:R" "…and never claims a human tier label" echo "— ADVERSARIAL FILENAME: a crafted path cannot forge a dispute or break the table —" # git permits `|`, backticks and newlines in a filename. Rendered raw, such a path breaks out of