diff --git a/.github/workflows/pr-require-checks.yml b/.github/workflows/pr-require-checks.yml index 71f054316e..d6a877741a 100644 --- a/.github/workflows/pr-require-checks.yml +++ b/.github/workflows/pr-require-checks.yml @@ -3110,18 +3110,25 @@ jobs: # of own-gate retries, 7 s of status-write retries and the 90 s # `rerun-wait-seconds` wait, about four and a half minutes, plus up to # 15 s of retry sleeps for each of the hold-lift step's three reads and - # the review-lane step's four, under two minutes. - timeout-minutes: 7 + # the review-lane step's four, under two minutes, plus the cost-hold step's + # reads: three for most pull requests, which stop at the label events; for a + # lane-labeled one whose body cites a decision, about ten, and two more per + # review run since the window opened. + timeout-minutes: 9 permissions: # `yield-to-full-run` lists this workflow's runs on the head SHA, and # `rerun-contract-only-siblings` re-runs the red contract-only ones once # the full run records `success`. Under an explicit permissions block every # unlisted scope defaults to none; write covers the read. actions: write + # The cost-hold step reads the review lanes' hold notices from their + # check-run annotations. + checks: read contents: read # check-contract upserts the advisory linkage comment and moves the linkage # label; ci-status records the `ci-lanes` commit status the carry-forward - # branch reads back on the next contract-only event. + # branch reads back on the next contract-only event. The cost-hold step + # comments and removes the human-gated label. pull-requests: write statuses: write steps: @@ -3249,6 +3256,191 @@ jobs: msg="draft: lanes not run; the ready_for_review run lints and tests this SHA" echo "::notice::$msg" echo "$msg" >>"$GITHUB_STEP_SUMMARY" + # A COST HOLD THE OWNER ALREADY DECIDED COMES OFF. A review lane re-holds + # every new head, so its `cost` hold went back on after the owner had + # recorded the cost decision. This step clears a hold only when all + # of these hold, else it changes nothing and the two steps below judge + # the label as before: + # - The window opens at the newest removal of the human-gated label by a + # `human` of the base commit's trusted-actor list (ever, when none). + # - The label was added in it, and only by github-actions[bot] or + # claude[bot], each time followed within ten minutes by a held review. + # - Every hold a review lane applied in it has criteria `cost` alone. The + # holds are read from the `claude-lane-hold ... criteria=` notice the + # lane's status job records (any attempt, on this branch), never from + # the hold comment, which anyone with write access can edit or delete. + # A pull_request run takes its workflow from the pull request, so a + # run whose head changes either review-lane workflow from the base (or + # whose comparison lists 300 files, the API's cap) could forge that + # notice, and keeps the hold. + # - No lanes App comment was written and no comment was deleted in it: a + # lanes App escalation adds no label event to a label already on. + # - The pull request body, outside HTML comments, links a decision comment + # as babysit_merge_policy.py's cost_decision reads one: by a User on the + # trusted-actor list, not through an App, never edited, first line + # `**Decision recorded.** Cost:`, on the pull request itself or (written + # after the pull request) on the one issue in this repository that + # GitHub records it as closing. A body `Refs:` line or a second closing + # issue counts for nothing, so an owner decision on an unrelated issue + # cannot clear this hold. + # It then outputs cleared=true and the decision link, and, with the label + # on, posts one comment citing the decision and removes the label. A + # GITHUB_TOKEN removal starts no run, so the steps below read the label + # off in this run, and the hold-lift step skips on cleared=true. + # Unreadable input clears nothing; the step itself never fails the job. + # Pinned by scripts/ci-hold-cost-decision.test.sh, which runs this body. + - name: Clear a cost-only review hold the owner decided + id: cost_hold + if: ${{ !cancelled() && github.event_name == 'pull_request' }} + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.pull_request.number }} + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + LANES_APP_ID: ${{ vars.AUTOMATION_LANES_APP_SENDER_ID }} + run: | + keep() { + echo "The cost hold stays for a person: $1." + exit 0 + } + read_api() { + local attempt out + for attempt in 1 2 3; do + if out=$(gh api "$@" 2>"$RUNNER_TEMP/cost-hold.err"); then + printf '%s\n' "$out" + return 0 + fi + ! grep -q 'HTTP 404' "$RUNNER_TEMP/cost-hold.err" || return 2 + [ "$attempt" -lt 3 ] || return 1 + sleep $((attempt * 5)) + done + } + [[ $BASE_SHA =~ ^[0-9a-f]{40}$ && $HEAD_SHA =~ ^[0-9a-f]{40}$ && $PR_NUMBER =~ ^[1-9][0-9]*$ ]] || keep "the event names no commit or pull request" + [[ $LANES_APP_ID =~ ^[1-9][0-9]*$ ]] || keep "AUTOMATION_LANES_APP_SENDER_ID is not set" + repo=$GITHUB_REPOSITORY + tracker=$(read_api -H 'Accept: application/vnd.github.raw+json' "repos/$repo/contents/.work-item-tracker.json?ref=$BASE_SHA") && rc=0 || rc=$? + [ "$rc" -ne 1 ] || keep "the tracker config is unreadable" + label=needs-human + if [ "$rc" -eq 0 ] && [ -n "$tracker" ]; then + label=$(jq -er '[objects | .config | objects | .role_labels | objects + | .["human-gated"] | strings | gsub("^\\s+|\\s+$"; "")][0] // "" + | if . == "" then "needs-human" else . end' <<<"$tracker") || keep "the tracker config is invalid" + fi + actors=$(read_api -H 'Accept: application/vnd.github.raw+json' "repos/$repo/contents/.github/standards/trusted-actors/trusted-actors.json?ref=$BASE_SHA") || keep "the trusted-actor list is unreadable" + humans=$(jq -ec '[.actors[] | select(.kind == "human") | .id | numbers] | select(length > 0)' <<<"$actors") || keep "the trusted-actor list names no person" + events=$(read_api "repos/$repo/issues/$PR_NUMBER/events?per_page=100" --paginate) || keep "the events are unreadable" + # The window opens at the newest removal by a trusted person. + since=$(jq -n --arg label "$label" --argjson humans "$humans" '[inputs[] | select(.label.name == $label + and .event == "unlabeled" and (.actor.id as $id | $humans | index($id))) | .created_at | fromdateiso8601] | max // -1' \ + <<<"$events") || keep "the events do not parse" + # Cheap reads first: most pull requests stop here. + added=$(jq -nr --arg label "$label" --argjson since "$since" '[inputs[] | select(.label.name == $label + and .event == "labeled" and (.created_at | fromdateiso8601) > $since)] + | if length == 0 then "no label was added since a trusted person last removed it" + elif any(.[]; .actor.login != "github-actions[bot]" and .actor.login != "claude[bot]") then "someone other than a review lane added the label" + else "lane" end' <<<"$events") || keep "the events do not parse" + [ "$added" = lane ] || keep "$added" + pr=$(read_api "repos/$repo/pulls/$PR_NUMBER") || keep "the pull request is unreadable" + ref=$(jq -r '.head.ref | @uri' <<<"$pr") || keep "the pull request has no head branch" + links=$(jq -r '.body // "" | gsub("|\\z)"; "") + | match("https://github\\.com/([A-Za-z0-9-]+)/([A-Za-z0-9._-]+)/(?:issues|pull)/([0-9]+)#issuecomment-([0-9]+)"; "g") + | [.string, (.captures[] | .string)] | @tsv' <<<"$pr") || links= + [ -n "$links" ] || keep "the body links no comment" + # shellcheck disable=SC2016 # GraphQL variables, not shell + closing=$(read_api graphql -f query='query($owner:String!,$name:String!,$number:Int!){repository(owner:$owner,name:$name){pullRequest(number:$number){closingIssuesReferences(first:2){totalCount nodes{number repository{nameWithOwner}}}}}}' \ + -F owner="${repo%%/*}" -F name="${repo#*/}" -F number="$PR_NUMBER") || keep "the closing issues are unreadable" + # The one issue in this repository the pull request closes, as babysit's + # cost_decision reads it: none when it closes more than one. + issues=" $(jq -r --arg repo "$repo" '.data.repository.pullRequest.closingIssuesReferences + | select(.totalCount == 1) | .nodes[] + | select((.repository.nameWithOwner | ascii_downcase) == ($repo | ascii_downcase)) | .number | tostring' <<<"$closing" 2>/dev/null) " + opened=$(jq -r '.created_at | fromdateiso8601' <<<"$pr") || keep "the pull request has no creation time" + decision='' decision_id='' + while IFS=$'\t' read -r link owner name number id; do + [ -n "$link" ] || continue + [ "$(tr '[:upper:]' '[:lower:]' <<<"$owner/$name")" = "$(tr '[:upper:]' '[:lower:]' <<<"$repo")" ] || continue + [ "$number" = "$PR_NUMBER" ] || [[ $issues == *" $number "* ]] || continue + comment=$(read_api "repos/$repo/issues/comments/$id") || continue + jq -e --arg number "$number" --arg pr "$PR_NUMBER" --argjson humans "$humans" --argjson opened "$opened" ' + (.issue_url // "" | endswith("/issues/\($number)")) + and .user.type == "User" and .performed_via_github_app == null + and (.user.id as $id | $humans | index($id) != null) + and (.body // "" | sub("\\A\\s+"; "") | test("\\A\\*\\*Decision recorded\\.\\*\\*[ \\t]+Cost:")) + and ($number == $pr or (.created_at | fromdateiso8601) > $opened)' <<<"$comment" >/dev/null 2>&1 || continue + node=$(jq -r .node_id <<<"$comment") + # shellcheck disable=SC2016 # GraphQL variables, not shell + edited=$(read_api graphql -f query='query($id:ID!){node(id:$id){... on IssueComment{lastEditedAt}}}' -F id="$node") || continue + jq -e '.data.node | objects | has("lastEditedAt") and .lastEditedAt == null' <<<"$edited" >/dev/null 2>&1 || continue + decision=$link decision_id=$id + break + done <<<"$links" + [ -n "$decision" ] || keep "the body cites no recorded owner cost decision covering it" + # Every hold a review lane recorded in the window, as {at, criteria}. + holds='[]' vetted='' + while read -r file check; do + runs=$(read_api "repos/$repo/actions/workflows/$file/runs?event=pull_request&branch=$ref&per_page=100" --paginate) || keep "the $file runs are unreadable" + while read -r id sha; do + [ -n "$id" ] || continue + [[ $sha =~ ^[0-9a-f]{40}$ ]] || keep "run $id names no head commit" + if [[ " $vetted " != *" $sha "* ]]; then + diff=$(read_api "repos/$repo/compare/$BASE_SHA...$sha") || keep "the changes of $sha are unreadable" + changed=$(jq -er '[.files[].filename] as $f + | if ($f | length) >= 300 then "too many files" + elif any($f[]; . == ".github/workflows/pr-review-hosted.yml" or . == ".github/workflows/pr-review-security-hosted.yml") then "a review-lane workflow" + else "" end' <<<"$diff") || keep "the changes of $sha do not parse" + [ -z "$changed" ] || keep "lane run $id ran on $sha, which changes $changed" + vetted+=" $sha" + fi + jobs=$(read_api "repos/$repo/actions/runs/$id/jobs?filter=all&per_page=100" --paginate) || keep "the jobs of run $id are unreadable" + for job in $(jq -nr --arg check "$check" --argjson since "$since" '[inputs.jobs[] | select(.name == $check + and .completed_at != null and (.completed_at | fromdateiso8601) > $since) | "\(.id)/\(.completed_at)"] | unique[]' <<<"$jobs"); do + notes=$(read_api "repos/$repo/check-runs/${job%%/*}/annotations?per_page=100" --paginate) || keep "the annotations of job ${job%%/*} are unreadable" + holds=$(jq -nc --argjson holds "$holds" --arg at "${job#*/}" '$holds + [inputs[] | select(.annotation_level == "notice") + | .message | select(startswith("claude-lane-hold ")) + | {at: ($at | fromdateiso8601), criteria: (capture("^claude-lane-hold lane=[a-z-]+ sha=[0-9a-f]{40} criteria=(?[a-z,-]+) label=").c // "unparsed")}]' \ + <<<"$notes") || keep "the annotations of job ${job%%/*} do not parse" + done + done < <(jq -nr --argjson since "$since" '[inputs.workflow_runs[] | select((.updated_at | fromdateiso8601) > $since) | "\(.id) \(.head_sha)"] | unique[]' <<<"$runs") + done <<<'pr-review-hosted.yml pr-review / claude-review-status + pr-review-security-hosted.yml pr-review-security / security-review' + comments=$(read_api "repos/$repo/issues/$PR_NUMBER/comments?per_page=100" --paginate) || keep "the comments are unreadable" + # shellcheck disable=SC2016 # GraphQL variables, not shell + deleted=$(read_api graphql -f query='query($owner:String!,$name:String!,$number:Int!){repository(owner:$owner,name:$name){pullRequest(number:$number){timelineItems(itemTypes:[COMMENT_DELETED_EVENT],last:100){filteredCount nodes{... on CommentDeletedEvent{createdAt}}}}}}' \ + -F owner="${repo%%/*}" -F name="${repo#*/}" -F number="$PR_NUMBER") || keep "the deleted comments are unreadable" + verdict=$(jq -nr --arg label "$label" --argjson since "$since" --argjson holds "$holds" --argjson app "$LANES_APP_ID" \ + --slurpfile events <(printf '%s\n' "$events") --slurpfile comments <(printf '%s\n' "$comments") \ + --slurpfile deleted <(printf '%s\n' "$deleted") ' + def t: fromdateiso8601; + ([$events[][] | select(.label.name == $label and .event == "labeled" and (.created_at | t) > $since)]) as $added + | ($deleted[0].data.repository.pullRequest.timelineItems) as $gone + | if ($holds | length) == 0 then "no review lane recorded a hold since a trusted person last removed the label" + elif any($holds[]; .criteria != "cost") then "a review-lane hold since then is not cost-only" + elif any($added[]; (.created_at | t) as $at | any($holds[]; .at >= $at and .at - $at <= 600) | not) then "a label was added with no held review after it" + elif any($comments[][]; .user.id == $app and ([.created_at, .updated_at] | map(t) | max) > $since) then "the lanes App commented since then" + elif ($gone | type) != "object" or $gone.filteredCount > ($gone.nodes | length) + or any($gone.nodes[]; (.createdAt | t) > $since) then "a comment was deleted since then" + else "cost" end' 2>/dev/null) || keep "the events, holds or comments do not parse" + [ "$verdict" = cost ] || keep "$verdict" + { + echo "cleared=true" + echo "decision=$decision" + } >>"$GITHUB_OUTPUT" + echo "::notice::every review-lane hold on #$PR_NUMBER since a trusted person last removed '$label' is cost-only, and $decision records the owner's cost decision." + labels=$(read_api "repos/$repo/issues/$PR_NUMBER/labels?per_page=100" --paginate --jq '.[].name') || { + echo "::warning::could not read the labels of #$PR_NUMBER, so '$label' was not removed." + exit 0 + } + grep -qxF -- "$label" <<<"$labels" || exit 0 + marker="" + if ! jq -se --arg marker "$marker" 'any(.[][]; .user.login == "github-actions[bot]" and (.body | startswith($marker)))' \ + <<<"$comments" >/dev/null; then + gh api -X POST "repos/$repo/issues/$PR_NUMBER/comments" -f body="$marker + ci-status removed \`$label\`: every review-lane hold since a trusted person last removed it is cost-only, and the owner recorded the cost decision in $decision. A person adding \`$label\` back holds it again." >/dev/null || + echo "::warning::could not post the removal comment on #$PR_NUMBER." + fi + # shellcheck disable=SC2016 # $l is a jq variable + gh api -X DELETE "repos/$repo/issues/$PR_NUMBER/labels/$(jq -rn --arg l "$label" '$l | @uri')" >/dev/null || + echo "::warning::could not remove '$label' from #$PR_NUMBER; the step below holds it." # A PULL REQUEST CARRYING THE HUMAN-GATED LABEL IS HELD. A review lane or # an escalation puts the label on so a person decides; removing it is the # approval, and the `unlabeled` event re-runs this job. The label's name @@ -3335,10 +3527,11 @@ jobs: # later: an event cannot be deleted or edited, so removing the comment # does not remove the hold, and a review re-run that labels the same head # again starts a new hold. Unreadable comments, events or actor list fail - # closed. + # closed. It skips when the cost_hold step cleared a cost-only hold, whose + # label that step (or anyone) may have removed. # Pinned by scripts/ci-hold-lifted-by.test.sh, which runs this body. - name: Fail a review-lane hold an untrusted account lifted - if: ${{ !cancelled() && github.event_name == 'pull_request' && steps.human_gated.outputs.held == 'false' }} + if: ${{ !cancelled() && github.event_name == 'pull_request' && steps.human_gated.outputs.held == 'false' && steps.cost_hold.outputs.cleared != 'true' }} env: GH_TOKEN: ${{ github.token }} PR_NUMBER: ${{ github.event.pull_request.number }} diff --git a/scripts/ci-hold-cost-decision.test.sh b/scripts/ci-hold-cost-decision.test.sh new file mode 100755 index 0000000000..57f3effc32 --- /dev/null +++ b/scripts/ci-hold-cost-decision.test.sh @@ -0,0 +1,356 @@ +#!/usr/bin/env bash +# Runs the `Clear a cost-only review hold the owner decided` step body of +# .github/workflows/pr-require-checks.yml's ci-status under the shell Actions +# gives a step with no `shell:` (bash -e), against stub `gh` and `sleep`. +# +# A review lane's `cost` hold is cleared when every lane hold since a trusted +# person last removed the human-gated label is cost-only, and the pull request +# body cites the owner's unedited `**Decision recorded.** Cost:` comment on the +# pull request itself, or (newer than the pull request) on the one issue GitHub +# records it as closing; a `Refs:` line or a second closing issue counts for +# nothing. The holds are read from the lanes' check-run notices. +# The step then outputs cleared=true and the decision, and takes the label off +# with one comment citing it. Anything else, an unreadable read included, +# clears nothing and leaves the hold to the steps after it. +# test-scope: .github/workflows/pr-require-checks.yml +set -uo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +WORKFLOW="$ROOT/.github/workflows/pr-require-checks.yml" + +# shellcheck source=lib/test-harness.sh +. "$ROOT/scripts/lib/test-harness.sh" + +TMP_ROOT="$(mktemp -d)" +trap 'rm -rf "$TMP_ROOT"' EXIT + +NAME='Clear a cost-only review hold the owner decided' +step="$(awk -v name=" - name: $NAME" '$0 == name { on = 1; print; next } + on && /^ - / { exit } + on { print }' "$WORKFLOW")" +awk '/^ run: \|$/ { on = 1; next } + on && /^ / { print substr($0, 11); next } + on && /^[[:blank:]]*$/ { print ""; next } + on { exit }' <<<"$step" >"$TMP_ROOT/body.sh" + +if [[ ! -s "$TMP_ROOT/body.sh" ]]; then + fail "no '$NAME' step with a run: | body in $WORKFLOW" + test_harness::report + exit 1 +fi +if [[ "$step" == *"if: \${{ !cancelled() && github.event_name == 'pull_request' }}"* ]]; then + ok "step runs on every pull_request run that is not cancelled" +else + fail "step lost its pull_request condition" +fi +if [[ "$step" == *$'\n id: cost_hold\n'* ]]; then + ok "step carries the id the hold-lift step reads its outputs by" +else + fail "step lost its cost_hold id" +fi +# shellcheck disable=SC2016 # the ${{ }} text is workflow source, not shell +for env in 'BASE_SHA: ${{ github.event.pull_request.base.sha }}' 'HEAD_SHA: ${{ github.event.pull_request.head.sha }}'; do + if [[ "$step" == *"$env"* ]]; then ok "step env carries $env"; else fail "step env lacks $env"; fi +done +order="$(grep -nE "^ ci-status:$|^ - name: ($NAME|Fail a pull request carrying the human-gated label|Fail a review-lane hold an untrusted account lifted)$" "$WORKFLOW" | cut -d: -f1 | paste -sd' ')" +read -r job_line cost_line gated_line lift_line <<<"$order" +if [[ -n "${lift_line:-}" && "$job_line" -lt "$cost_line" && "$cost_line" -lt "$gated_line" && "$gated_line" -lt "$lift_line" ]]; then + ok "step sits in ci-status before the human-gated and hold-lift steps" +else + fail "step is not inside ci-status before the human-gated step (lines: $order)" +fi +lift="$(awk '$0 == " - name: Fail a review-lane hold an untrusted account lifted" { on = 1; next } on && /^ if: / { print; exit }' "$WORKFLOW")" +if [[ "$lift" == *"steps.cost_hold.outputs.cleared != 'true'"* ]]; then + ok "the hold-lift step does not run once a recorded decision clears the hold" +else + fail "the hold-lift step does not read cost_hold's cleared output: '$lift'" +fi + +# The stub logs its arguments and answers from STUB_* variables; a read whose +# name is in STUB_FAIL fails. Runs, jobs and annotations come from JSON maps +# keyed by workflow file, run id and check-run id. +mkdir -p "$TMP_ROOT/bin" +cat >"$TMP_ROOT/bin/gh" <<'EOF' +#!/usr/bin/env bash +printf '%s\n' "$*" >>"$STUB_LOG" +failing() { [[ " ${STUB_FAIL:-} " == *" $1 "* ]] && exit 1; return 0; } +from() { jq -ce --arg k "$2" '.[$k] // error' <<<"$1" || { echo 'gh: Not Found (HTTP 404)' >&2; exit 1; }; } +# The path segment after /<1>/ in the api path argument. +key() { local a; for a in "${@:2}"; do [[ "$a" == */"$1"/* ]] && { a="${a##*/"$1"/}"; printf '%s' "${a%%[/?]*}"; return; }; done; } +case "$*" in +*/contents/.work-item-tracker.json*) + failing tracker + [[ -n "${STUB_TRACKER+set}" ]] || { echo 'gh: Not Found (HTTP 404)' >&2; exit 1; } + printf '%s\n' "$STUB_TRACKER" + ;; +*/contents/.github/standards/trusted-actors/*) failing actors; printf '%s\n' "$STUB_ACTORS" ;; +*'-X DELETE'*/labels/*) failing delete ;; +*/issues/6419/labels*) failing labels; [[ -z "$STUB_LABELS" ]] || tr ',' '\n' <<<"$STUB_LABELS" ;; +*/issues/6419/events*) failing events; printf '%s\n' "$STUB_EVENTS" ;; +*'-X POST'*/issues/6419/comments*) failing post ;; +*/issues/6419/comments*) failing comments; printf '%s\n' "$STUB_COMMENTS" ;; +*/issues/comments/*) failing decision; from "$STUB_DECISIONS" "$(key comments "$@")" ;; +*/actions/workflows/*/runs*) + failing runs + [[ "$*" == *'branch=fix%2Fcost&'* ]] || exit 1 + from "$STUB_RUNS" "$(key workflows "$@")" + ;; +*/compare/"$BASE_SHA..."*) failing compare; args="$*"; from "$STUB_COMPARE" "${args##*...}" ;; +*/actions/runs/*/jobs*) failing jobs; from "$STUB_JOBS" "$(key runs "$@")" ;; +*/check-runs/*/annotations*) failing notes; from "$STUB_NOTES" "$(key check-runs "$@")" ;; +*/pulls/6419*) failing pull; printf '%s\n' "$STUB_PR" ;; +*graphql*COMMENT_DELETED_EVENT*) failing deleted; printf '%s\n' "$STUB_DELETED" ;; +*graphql*closingIssuesReferences*) failing closing; printf '%s\n' "$STUB_CLOSING" ;; +*graphql*lastEditedAt*) failing edited; printf '%s\n' "$STUB_EDITED" ;; +*) exit 1 ;; +esac +EOF +printf '#!/usr/bin/env bash\n' >"$TMP_ROOT/bin/sleep" +chmod +x "$TMP_ROOT/bin/gh" "$TMP_ROOT/bin/sleep" + +BASE=89abcdef0123456789abcdef0123456789abcdef +HEAD=0123456789abcdef0123456789abcdef01234567 +OLD=fedcba9876543210fedcba9876543210fedcba98 +OWNER_ID=153232337 +APP_ID=777 +LINK='https://github.com/o/r/issues/9306#issuecomment-111' +LANE='{"login":"github-actions[bot]","id":41898282,"type":"Bot"}' +APP="{\"login\":\"melodic-automation-lanes[bot]\",\"id\":$APP_ID,\"type\":\"Bot\"}" +OWNER="{\"login\":\"owner\",\"id\":$OWNER_ID,\"type\":\"User\"}" +STRANGER='{"login":"someone","id":42,"type":"User"}' +CODE_CHECK='pr-review / claude-review-status' +SEC_CHECK='pr-review-security / security-review' + +event() { jq -cn --arg e "$1" --argjson actor "$2" --arg at "$3" '{event: $e, actor: $actor, created_at: $at, label: {name: "needs-human"}}'; } +comment() { jq -cn --argjson user "$1" --arg at "$2" --arg body "$3" '{user: $user, created_at: $at, updated_at: $at, body: $body}'; } +decision() { + jq -cn --arg url "https://api.github.com/repos/o/r/issues/$1" --argjson user "$2" --arg at "$3" --arg body "$4" \ + '{issue_url: $url, user: $user, performed_via_github_app: null, created_at: $at, body: $body, node_id: "IC_1"}' +} +note() { jq -cn --arg m "$1" '{annotation_level: "notice", message: $m}'; } +hold_note() { note "claude-lane-hold lane=${2:-claude-review} sha=$HEAD criteria=$1 label=needs-human"; } +# job +job() { jq -cn --argjson id "$1" --arg name "$2" --arg at "$3" '{id: $id, name: $name, completed_at: $at}'; } +# lanes : one run per lane, its status job done at 13:20:30 and 13:21:00. +lanes() { + STUB_RUNS="$(jq -c . <<<'{"pr-review-hosted.yml":{"workflow_runs":[{"id":1,"head_sha":"0123456789abcdef0123456789abcdef01234567","updated_at":"2026-10-23T13:20:40Z"}]},"pr-review-security-hosted.yml":{"workflow_runs":[{"id":2,"head_sha":"0123456789abcdef0123456789abcdef01234567","updated_at":"2026-10-23T13:21:10Z"}]}}')" + STUB_JOBS="$(jq -cn --argjson a "$(job 11 "$CODE_CHECK" 2026-10-23T13:20:30Z)" --argjson b "$(job 21 "$SEC_CHECK" 2026-10-23T13:21:00Z)" \ + '{"1": {jobs: [{id: 10, name: "kill-switch", completed_at: "2026-10-23T13:19:00Z"}, $a]}, "2": {jobs: [$b]}}')" + STUB_NOTES="$(jq -cn --argjson a "$1" --argjson b "$2" '{"11": $a, "21": $b, "10": []}')" + STUB_COMPARE="$(jq -cn --arg h "$HEAD" --arg o "$OLD" '{($h): {files: [{filename: "README.md"}]}, ($o): {files: []}}')" + export STUB_RUNS STUB_JOBS STUB_NOTES STUB_COMPARE +} + +reset() { + export STUB_FAIL='' STUB_LABELS='needs-human' LANES_APP_ID="$APP_ID" + export STUB_ACTORS="{\"actors\":[{\"kind\":\"human\",\"id\":$OWNER_ID},{\"kind\":\"bot\",\"id\":5}]}" + export STUB_PR="{\"number\":6419,\"created_at\":\"2026-10-23T05:00:00Z\",\"head\":{\"ref\":\"fix/cost\"},\"body\":\"Closes #9306\\n\\n- Cost decision: [Q50]($LINK)\"}" + export STUB_CLOSING='{"data":{"repository":{"pullRequest":{"closingIssuesReferences":{"totalCount":1,"nodes":[{"number":9306,"repository":{"nameWithOwner":"o/r"}}]}}}}}' + STUB_DECISIONS="$(jq -cn --argjson c "$(decision 9306 "$OWNER" 2026-10-23T13:14:57Z '**Decision recorded.** Cost: accepted.')" '{"111": $c}')" + export STUB_DECISIONS + export STUB_EDITED='{"data":{"node":{"lastEditedAt":null}}}' + export STUB_DELETED='{"data":{"repository":{"pullRequest":{"timelineItems":{"filteredCount":0,"nodes":[]}}}}}' + STUB_EVENTS="[$(event labeled "$LANE" 2026-10-23T13:20:20Z)]" + STUB_COMMENTS="[$(comment "$OWNER" 2026-10-23T05:40:00Z 'A comment.')]" + export STUB_EVENTS STUB_COMMENTS + lanes "[$(hold_note cost),$(note "claude-lane-review-complete lane=claude-review sha=$HEAD findings=0")]" '[]' + unset STUB_TRACKER +} +# The owner lifted an earlier hold at 13:15, so the 05:39 escalation is outside the window. +lifted() { + STUB_EVENTS="[$(event labeled "$APP" 2026-10-23T05:39:30Z),$(event unlabeled "$OWNER" 2026-10-23T13:15:00Z),$(event labeled "$LANE" 2026-10-23T13:20:20Z)]" + STUB_COMMENTS="[$(comment "$APP" 2026-10-23T05:39:35Z '')]" + export STUB_EVENTS STUB_COMMENTS +} + +# expect