From 99c39f2425eda12937006f3406abc799c9ea7dca Mon Sep 17 00:00:00 2001 From: Roberto Cano <3525807+robercano@users.noreply.github.com> Date: Tue, 21 Jul 2026 11:23:17 +0200 Subject: [PATCH] =?UTF-8?q?fix(needs-human):=20REST=20label=20ops=20+=20co?= =?UTF-8?q?nfirmed-episode=20guard=20=E2=80=94=20stop=20per-tick=20comment?= =?UTF-8?q?=20spam=20(issue=20#169)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit needs-human.sh's once-per-episode comment guard derived "fresh episode" from the needs-human label's presence on the target, but every label write silently no-op'd on this environment: `gh label create` (gh 2.4.0 has no `gh label` subcommand) and `gh pr|issue edit --add-label`/`--remove-label` (GraphQL scope error with the bot token), each swallowed by `|| true`. The label never actually stuck, so every tick read "no label" as a fresh episode and re-posted the comment (99 times on PR #168, ~128 on PR #161). Replace every label read/write with `gh api` REST calls (proven to work with this token/gh version): POST .../labels to idempotently ensure the label exists, POST .../issues/N/labels to add it, DELETE .../issues/N/labels/needs-human to remove it, and a plain GET .../issues/N to read the current set — PRs are issues in the REST API, so this also unifies what used to be separate pr/issue code paths. Critical invariant: the episode comment now posts ONLY once the label add is CONFIRMED by re-reading the target's labels after the add, never from the add's exit code alone (old gh can exit non-zero on a call that actually succeeded). An unconfirmed add fails loudly via log-event.sh instead of posting a comment for an unverifiable episode — that silent-failure-plus- post-anyway combination is exactly the mechanism that produced the spam. Updates the gh stubs in needs-human.test.sh (rewritten with 10 scenarios covering the new REST shapes, the confirmed/unconfirmed comment gate, and 404-as-success on remove) and every caller test that exercises the seam: pr-feedback.test.sh, pr-comment-fix.test.sh, merge-ready.test.sh, loop-ceilings.test.sh, loop-tick.test.sh, pr-rebase.test.sh. Existing gh-call-count assertions are preserved (shapes adjusted, not the counting discipline). The #168/#161 spam comments are left in place, per scope. Co-Authored-By: Claude Fable 5 --- .claude/scripts/loop-ceilings.test.sh | 39 +++- .claude/scripts/loop-tick.test.sh | 29 ++- .claude/scripts/merge-ready.test.sh | 78 +++---- .claude/scripts/needs-human.sh | 184 +++++++++++----- .claude/scripts/needs-human.test.sh | 284 ++++++++++++++++++++----- .claude/scripts/pr-comment-fix.test.sh | 45 ++-- .claude/scripts/pr-feedback.test.sh | 63 +++--- .claude/scripts/pr-rebase.test.sh | 38 +++- 8 files changed, 551 insertions(+), 209 deletions(-) diff --git a/.claude/scripts/loop-ceilings.test.sh b/.claude/scripts/loop-ceilings.test.sh index 5e543aa..e301b22 100644 --- a/.claude/scripts/loop-ceilings.test.sh +++ b/.claude/scripts/loop-ceilings.test.sh @@ -59,6 +59,7 @@ new_fixture() { # gh calls still landing only in this fixture's own logging bot-gh.sh. cp "$script_dir/needs-human.sh" "$dir/needs-human.sh" cp "$script_dir/notify.sh" "$dir/notify.sh" + cp "$script_dir/log-event.sh" "$dir/log-event.sh" cat > "$dir/loop-census.sh" < "$dir/bot-gh.sh" <<'EOF' #!/usr/bin/env bash log="$(dirname "$0")/../state/gh-calls.log" -mkdir -p "$(dirname "$log")" +labels_dir="$(dirname "$0")/../state/gh-labels" +mkdir -p "$(dirname "$log")" "$labels_dir" printf '%s\n' "$*" >> "$log" case "$1 $2" in "issue create") echo "https://github.com/acme/repo/issues/777" ;; "issue view") echo "${FAKE_ISSUE_STATE:-OPEN}" ;; esac +if [ "$1" = "api" ]; then + case "$*" in + *"-X POST"*"/labels --input -"*) + num="$(printf '%s\n' "$*" | sed -E 's#.*/issues/([0-9]+)/labels --input -.*#\1#')" + touch "$labels_dir/$num" + ;; + *"-X DELETE"*"/labels/needs-human"*) + num="$(printf '%s\n' "$*" | sed -E 's#.*/issues/([0-9]+)/labels/needs-human.*#\1#')" + rm -f "$labels_dir/$num" + ;; + *"-q .labels[].name"*) + num="$(printf '%s\n' "$*" | sed -E 's#.*/issues/([0-9]+) .*#\1#')" + [ -f "$labels_dir/$num" ] && echo "needs-human" + ;; + *) : ;; + esac +fi exit 0 EOF chmod +x "$dir/bot-gh.sh" @@ -250,8 +277,8 @@ node -e ' out5="$(run_tick "$dir5")" check "scenario 5 (attempts 5 >= budget 5): verdict is action=none" bash -c '[ "$(verdict_of "$1")" = "action=none" ]' _ "$out5" check "scenario 5: diagnostic cites the attempt budget" bash -c 'printf "%s\n" "$1" | grep -q "attempt budget exceeded for issue=42"' _ "$out5" -check "scenario 5: exactly 4 gh calls (label-presence read, label create, issue edit, issue comment)" bash -c '[ "$(gh_calls "$1" | wc -l | tr -d " ")" -eq 4 ]' _ "$dir5" -check "scenario 5: the issue itself (not a PR) was labeled needs-human" bash -c 'gh_calls "$1" | grep -q "^issue edit 42 --add-label needs-human"' _ "$dir5" +check "scenario 5: exactly 5 gh calls (ensure-label create, label-presence read, label add, confirm read, issue comment -- issue #169 REST)" bash -c '[ "$(gh_calls "$1" | wc -l | tr -d " ")" -eq 5 ]' _ "$dir5" +check "scenario 5: the issue itself (not a PR) was labeled needs-human via REST" bash -c 'gh_calls "$1" | grep -qF -- "-X POST repos/acme/repo/issues/42/labels --input -"' _ "$dir5" check "scenario 5: escalated is now persisted true, attempts unchanged at 5" node -e ' const fs = require("fs"); const j = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); @@ -259,7 +286,7 @@ check "scenario 5: escalated is now persisted true, attempts unchanged at 5" nod ' "$dir5/../state/loop-issue-attempts.json" out5b="$(run_tick "$dir5")" check "scenario 5b (still over budget, second tick): verdict is still action=none" bash -c '[ "$(verdict_of "$1")" = "action=none" ]' _ "$out5b" -check "scenario 5b: no additional gh calls (escalated guard held) -- still exactly 4" bash -c '[ "$(gh_calls "$1" | wc -l | tr -d " ")" -eq 4 ]' _ "$dir5" +check "scenario 5b: no additional gh calls (escalated guard held) -- still exactly 5" bash -c '[ "$(gh_calls "$1" | wc -l | tr -d " ")" -eq 5 ]' _ "$dir5" # --------------------------------------------------------------------------- # 6. Per-issue attempt budget applies across advance AND feedback phases of @@ -275,7 +302,7 @@ node -e ' ' "$dir6" out6="$(run_tick "$dir6")" check "scenario 6 (feedback for issue 42's PR, budget already exhausted): verdict is action=none" bash -c '[ "$(verdict_of "$1")" = "action=none" ]' _ "$out6" -check "scenario 6: the PR (17), not the issue, was labeled/commented needs-human" bash -c 'gh_calls "$1" | grep -q "^pr edit 17 --add-label needs-human" && gh_calls "$1" | grep -q "^pr comment 17"' _ "$dir6" +check "scenario 6: the PR (17), not the issue, was labeled/commented needs-human" bash -c 'gh_calls "$1" | grep -qF -- "-X POST repos/acme/repo/issues/17/labels --input -" && gh_calls "$1" | grep -q "^pr comment 17"' _ "$dir6" # --------------------------------------------------------------------------- # 7. Daily action ceiling: UNDER the ceiling (default 50) -> advance @@ -437,7 +464,7 @@ node -e ' out11="$(run_tick "$dir11")" check "scenario 11 (ci-fix for issue 42's PR, budget already exhausted): verdict is action=none" bash -c '[ "$(verdict_of "$1")" = "action=none" ]' _ "$out11" check "scenario 11: diagnostic cites the attempt budget for issue=42, not PR 23" bash -c 'printf "%s\n" "$1" | grep -q "attempt budget exceeded for issue=42"' _ "$out11" -check "scenario 11: the PR (23), not the issue, was labeled/commented needs-human" bash -c 'gh_calls "$1" | grep -q "^pr edit 23 --add-label needs-human" && gh_calls "$1" | grep -q "^pr comment 23"' _ "$dir11" +check "scenario 11: the PR (23), not the issue, was labeled/commented needs-human" bash -c 'gh_calls "$1" | grep -qF -- "-X POST repos/acme/repo/issues/23/labels --input -" && gh_calls "$1" | grep -q "^pr comment 23"' _ "$dir11" # --------------------------------------------------------------------------- # 12. Precedence (issue #96): a ci-fix candidate wins over an ALSO-ready diff --git a/.claude/scripts/loop-tick.test.sh b/.claude/scripts/loop-tick.test.sh index 3f3b37d..dfe9619 100644 --- a/.claude/scripts/loop-tick.test.sh +++ b/.claude/scripts/loop-tick.test.sh @@ -647,13 +647,34 @@ check "scenario 16: resume-attempts file now records count=2" node -e ' ' "$resume15" # Scenario 17: 3rd stall (count already at 2) -> escalate to needs-human -# instead of resuming again: label create + issue edit + issue comment (all +# instead of resuming again: label create + label add + issue comment (all # via bot-gh.sh, captured here into a plain log file), escalated:true # persisted, and an escalated-to-needs-human event logged. +# +# Issue #169: needs-human.sh's label reads/writes now go through `gh api` +# (REST). This stub simulates GitHub's own label state via a marker FILE the +# REST add touches, so the post-add CONFIRM read (issue #169's +# comment-gating invariant) sees the label actually "stuck" and the episode +# comment posts. gh_calls17="$work/scenario17-gh-calls.log" +label_marker17="$work/scenario17-label.marker" cat > "$dir15/bot-gh.sh" <> "$gh_calls17" +case "\$1" in + api) + case "\$*" in + *"-X POST"*"/issues/42/labels --input -") + touch "$label_marker17" + ;; + *"-q .labels[].name"*) + [ -f "$label_marker17" ] && printf 'needs-human\n' + ;; + *) : ;; + esac + ;; + *) : ;; +esac exit 0 EOF chmod +x "$dir15/bot-gh.sh" @@ -664,9 +685,9 @@ check "scenario 17: resume-attempts file now escalated=true (count stays 2)" nod const j = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); if (!j["42"] || j["42"].count !== 2 || j["42"].escalated !== true) throw new Error("got " + JSON.stringify(j)); ' "$resume15" -check "scenario 17: needs-human label create + issue edit + issue comment all dispatched via bot-gh.sh" bash -c ' - grep -q "^label create needs-human" "$1" && - grep -q "^issue edit 42 --add-label needs-human" "$1" && +check "scenario 17: needs-human label create + label add (REST) + issue comment all dispatched via bot-gh.sh" bash -c ' + grep -qF -- "-X POST repos/acme/repo/labels " "$1" && + grep -qF -- "-X POST repos/acme/repo/issues/42/labels --input -" "$1" && grep -q "^issue comment 42 " "$1" ' _ "$gh_calls17" check "scenario 17: escalated-to-needs-human event logged" bash -c 'grep -q "\"phase\":\"escalated-to-needs-human\"" "$1"' _ "$ticks15_events" diff --git a/.claude/scripts/merge-ready.test.sh b/.claude/scripts/merge-ready.test.sh index 5c4e2b6..a5d5fac 100755 --- a/.claude/scripts/merge-ready.test.sh +++ b/.claude/scripts/merge-ready.test.sh @@ -8,6 +8,12 @@ # comment, `pr merge`) that flows through the real needs-human.sh seam this # script sources. # +# Issue #169: needs-human.sh's label reads/writes now go through `gh api` +# (REST) instead of `gh pr edit --*-label`/`gh pr view --json labels`. The +# fake bot-gh.sh below simulates GitHub's own label state via a marker FILE +# the REST add touches, so the post-add CONFIRM read (issue #169's +# comment-gating invariant) sees the label actually "stuck". +# # Exit 0 on success, non-zero if any assertion fails. Runnable bare: # bash .claude/scripts/merge-ready.test.sh set -uo pipefail @@ -52,6 +58,7 @@ new_fixture() { cp "$script_dir/resolve-roots.sh" "$scripts/resolve-roots.sh" cp "$script_dir/needs-human.sh" "$scripts/needs-human.sh" cp "$script_dir/notify.sh" "$scripts/notify.sh" + cp "$script_dir/log-event.sh" "$scripts/log-event.sh" chmod +x "$scripts"/*.sh cat > "$dir/.claude/gates.json" <> $work/$name-notify-fired.txt" } @@ -90,26 +97,26 @@ case "\$1" in fi ;; view) - if printf '%s\n' "\$*" | grep -q -- '--json labels'; then - [ -f "$labeled_markerA" ] && printf 'needs-human\n' - else - cat <<'JSON' + cat <<'JSON' {"number":10,"title":"Add widget","isDraft":false,"baseRefName":"main","headRefName":"feat/issue-10-widget","mergeable":"MERGEABLE","reviews":[],"statusCheckRollup":[],"commits":[{"committedDate":"2026-01-01T00:00:00Z"}]} JSON - fi - ;; - edit) - if printf '%s\n' "\$*" | grep -q -- '--add-label needs-human'; then - touch "$labeled_markerA" - fi ;; comment) : ;; merge) exit 1 ;; *) : ;; esac ;; - label) : ;; - issue) : ;; + api) + case "\$*" in + *"-X POST"*"/issues/10/labels --input -") + touch "$labeled_markerA" + ;; + *"-q .labels[].name"*) + [ -f "$labeled_markerA" ] && printf 'needs-human\n' + ;; + *) : ;; + esac + ;; *) echo "unhandled: \$*" >&2; exit 1 ;; esac EOF @@ -120,7 +127,7 @@ outA2="$(env -u GATES_FILE bash "$dirA/.claude/scripts/merge-ready.sh" "acme/rep check "A: first run's verdict is skip:no-owner-review" bash -c 'printf "%s\n" "$1" | grep -q "\"reason\":\"no-owner-review\""' _ "$outA1" check "A: second run's verdict is ALSO skip:no-owner-review (still unreviewed)" bash -c 'printf "%s\n" "$1" | grep -q "\"reason\":\"no-owner-review\""' _ "$outA2" -check "A: needs-human label add attempted on BOTH runs" bash -c '[ "$(grep -c "pr edit 10 --add-label needs-human" "$1")" -eq 2 ]' _ "$gh_logA" +check "A: needs-human label add attempted (REST) on BOTH runs" bash -c '[ "$(grep -c -- "-X POST repos/acme/repo/issues/10/labels --input -" "$1")" -eq 2 ]' _ "$gh_logA" check "A: exactly ONE comment across BOTH runs (episode-gated, finding #1)" bash -c '[ "$(grep -c "pr comment 10 --body" "$1")" -eq 1 ]' _ "$gh_logA" check "A: no merge was attempted (skip path)" bash -c '! grep -q "^pr merge" "$1"' _ "$gh_logA" @@ -132,6 +139,7 @@ check "A: no merge was attempted (skip path)" bash -c '! grep -q "^pr merge" "$1 # --------------------------------------------------------------------------- dirB="$(new_fixture scenarioB)" gh_logB="$work/scenarioB-gh.log" +labeled_markerB="$work/scenarioB-labeled.marker" cat > "$dirB/.claude/scripts/bot-gh.sh" <> "$gh_logB" @@ -144,22 +152,26 @@ case "\$1" in fi ;; view) - if printf '%s\n' "\$*" | grep -q -- '--json labels'; then - : # never labeled yet -- fresh episode - else - cat <<'JSON' + cat <<'JSON' {"number":11,"title":"Fix bug","isDraft":false,"baseRefName":"main","headRefName":"fix/issue-11-bug","mergeable":"MERGEABLE","reviews":[{"author":{"login":"acme"},"state":"APPROVED","submittedAt":"2026-01-01T00:00:00Z"}],"statusCheckRollup":[],"commits":[{"committedDate":"2026-01-02T00:00:00Z"}]} JSON - fi ;; - edit) : ;; comment) : ;; merge) exit 1 ;; *) : ;; esac ;; - label) : ;; - issue) : ;; + api) + case "\$*" in + *"-X POST"*"/issues/11/labels --input -") + touch "$labeled_markerB" + ;; + *"-q .labels[].name"*) + [ -f "$labeled_markerB" ] && printf 'needs-human\n' + ;; + *) : ;; + esac + ;; *) echo "unhandled: \$*" >&2; exit 1 ;; esac EOF @@ -167,8 +179,8 @@ chmod +x "$dirB/.claude/scripts/bot-gh.sh" outB="$(env -u GATES_FILE bash "$dirB/.claude/scripts/merge-ready.sh" "acme/repo" 2>&1)" check "B: verdict is skip:approval-stale" bash -c 'printf "%s\n" "$1" | grep -q "approval-stale"' _ "$outB" -check "B: label add attempted (fresh episode)" grep -q "pr edit 11 --add-label needs-human" "$gh_logB" -check "B: comment posted (fresh episode, ready-for-review body)" bash -c 'grep -q "pr comment 11 --body" "$1"' _ "$gh_logB" +check "B: label add attempted via REST (fresh episode)" grep -qF -- "-X POST repos/acme/repo/issues/11/labels --input -" "$gh_logB" +check "B: comment posted (fresh, CONFIRMED episode, ready-for-review body)" bash -c 'grep -q "pr comment 11 --body" "$1"' _ "$gh_logB" # --------------------------------------------------------------------------- # C. Successful MERGE: owner-approved, CI-green, head_branch matches @@ -190,22 +202,16 @@ case "\$1" in fi ;; view) - if printf '%s\n' "\$*" | grep -q -- '--json labels'; then - : # not relevant on the merge path - else - cat <<'JSON' + cat <<'JSON' {"number":12,"title":"Ship feature","isDraft":false,"baseRefName":"main","headRefName":"feat/issue-77-thing","mergeable":"MERGEABLE","reviews":[{"author":{"login":"acme"},"state":"APPROVED","submittedAt":"2026-01-02T00:00:00Z"}],"statusCheckRollup":[],"commits":[{"committedDate":"2026-01-01T00:00:00Z"}]} JSON - fi ;; merge) exit 0 ;; - edit) : ;; comment) : ;; *) : ;; esac ;; - label) : ;; - issue) : ;; + api) : ;; # every REST call here is a needs_human_clear DELETE no-op *) echo "unhandled: \$*" >&2; exit 1 ;; esac EOF @@ -215,11 +221,11 @@ outC="$(env -u GATES_FILE bash "$dirC/.claude/scripts/merge-ready.sh" "acme/repo check "C: PR merged" bash -c 'printf "%s\n" "$1" | grep -q "\"action\":\"merged\""' _ "$outC" check "C: gh pr merge invoked with --merge --delete-branch" grep -q "pr merge 12 -R acme/repo --merge --delete-branch" "$gh_logC" check "C: no comment posted on the merge path (verdict never hits the flag case)" bash -c '! grep -q "^pr comment" "$1"' _ "$gh_logC" -check "C: pr:12 pr-review/changes-requested cleared -- 'pr edit 12 --remove-label needs-human' appears 3x (case-default once, success block twice)" \ - bash -c '[ "$(grep -c "pr edit 12 --remove-label needs-human" "$1")" -eq 3 ]' _ "$gh_logC" -check "C: head_branch->issue-number regex clears issue 77's attempt-budget AND stall (2x 'issue edit 77 --remove-label needs-human')" \ - bash -c '[ "$(grep -c "issue edit 77 --remove-label needs-human" "$1")" -eq 2 ]' _ "$gh_logC" -check "C: no needs-human label/comment ever added on the merge path" bash -c '! grep -q "add-label" "$1"' _ "$gh_logC" +check "C: pr:12 pr-review/changes-requested cleared via REST DELETE -- appears 3x (case-default once, success block twice)" \ + bash -c '[ "$(grep -c -- "-X DELETE repos/acme/repo/issues/12/labels/needs-human" "$1")" -eq 3 ]' _ "$gh_logC" +check "C: head_branch->issue-number regex clears issue 77's attempt-budget AND stall (2x REST DELETE .../issues/77/labels/needs-human)" \ + bash -c '[ "$(grep -c -- "-X DELETE repos/acme/repo/issues/77/labels/needs-human" "$1")" -eq 2 ]' _ "$gh_logC" +check "C: no needs-human label ever ADDED (REST POST) on the merge path" bash -c '! grep -q -- "-X POST" "$1"' _ "$gh_logC" echo "" if [ "$fail" -eq 0 ]; then diff --git a/.claude/scripts/needs-human.sh b/.claude/scripts/needs-human.sh index 2b8d8e3..592fcd9 100644 --- a/.claude/scripts/needs-human.sh +++ b/.claude/scripts/needs-human.sh @@ -8,6 +8,9 @@ # plain (probably missing) `gh` binary — fine in a fixture that expects zero # gh side effects, but callers that want real label/comment behavior must # define `gh` first, exactly like they already do for their own gh calls. +# If the caller has already computed a `$repo` ("owner/name") in its own +# top-level scope (every current caller does, right before sourcing this +# file), this file reuses it; otherwise it falls back to `gh repo view`. # # This file intentionally does NOT `set -e`/`set -u`/etc — it is SOURCED into # the caller's shell, and changing the caller's shell options out from under @@ -17,6 +20,21 @@ # caller running under `set -e` (pr-feedback.sh, merge-ready.sh) AND one that # isn't (loop-tick.sh). # +# REST, not `gh label`/`gh pr|issue edit --*-label` (issue #169): on this +# environment EVERY one of those used to silently no-op -- +# - `gh label create` -- gh 2.4.0 has no `gh label` subcommand at all. +# - `gh pr|issue edit --add-label`/`--remove-label` -- a GraphQL scope error +# with the bot token on gh 2.4.0. +# each one wrapped in `|| true`, so the label NEVER actually stuck, every tick +# read "no label" as a fresh episode, and the comment below posted on EVERY +# tick (99 identical comments on PR #168 overnight, ~128 on PR #161). All +# label reads/writes below go through `gh api` instead (proven to work with +# this token/gh version): POST .../labels to (idempotently) create the label, +# POST .../issues/N/labels to add it, DELETE .../issues/N/labels/needs-human +# to remove it, and a plain GET .../issues/N to read the current set. PRs ARE +# issues in the REST API, so this also collapses what used to be separate +# pr/issue branches into one shared code path. +# # needs_human_flag TARGET KIND SEVERITY TITLE BODY # TARGET: "issue:" or "pr:" — which GitHub object to label/comment on. # KIND: a short escalation key, e.g. "attempt-budget", "stall", @@ -27,9 +45,8 @@ # stay throttled to notify.sh's window. # SEVERITY/TITLE/BODY: passed straight through to notify.sh; BODY is also # posted as a comment on TARGET -- but ONLY on a FRESH escalation -# episode (see below); always skipped when BODY is empty. -# Idempotent: `gh label create --force` never fails if the label already -# exists; adding an already-present label is a no-op on GitHub's side. +# episode that ALSO has its label add CONFIRMED (see below); always +# skipped when BODY is empty. # # Comment is first-transition-only (issue #99 re-review finding #1): BEFORE # (re-)adding the label, this reads TARGET's CURRENT labels from GitHub. If @@ -39,25 +56,36 @@ # block-on-owner condition doesn't spam a fresh GitHub comment every tick # (loop-tick.sh/loop-census.sh invoke the callers of this seam every few # minutes for as long as the condition holds). If `needs-human` is ABSENT, -# this is a fresh episode (first flag ever, or a prior episode was cleared) -# and the comment posts. Deriving "fresh episode" from GitHub's own label -# state (rather than a local state file) makes this self-heal across -# restarts/redeploys with no extra state to keep in sync. A failed label -# read (offline/unauthenticated) is treated as "not already labeled" so the -# comment still fires -- fail toward the OLD (safe, if noisier) behavior, -# never toward silently swallowing an escalation. +# this is a fresh episode (first flag ever, or a prior episode was cleared). +# +# CRITICAL INVARIANT (issue #169): a fresh episode is NOT enough on its own +# -- the comment posts ONLY once the label add is CONFIRMED by re-reading +# TARGET's labels AFTER the add (never from the add's exit code alone: old +# gh (2.4.0) is known to exit non-zero on some calls that actually +# succeeded, and swallowing every exit code with `|| true` throughout this +# file means a genuinely FAILED add must never be mistaken for success +# either). If the add is not confirmed, the comment is skipped -- an +# unverifiable episode must never comment, since "the label never really +# stuck" plus "post anyway" is exactly the mechanism that produced the +# #168/#161 spam -- and the failure is logged LOUDLY to events.jsonl (via +# log-event.sh, best-effort, mirroring loop-tick.sh's own log_loop_event +# convention) so it is visible in the cockpit instead of silently +# swallowed like every other step in this file. # # needs_human_clear TARGET KIND # Removes the needs-human label from TARGET (best-effort — a target that -# was never labeled just no-ops) and clears notify.sh's throttle entry for -# (KIND,TARGET), so a FUTURE flag of the same kind on the same target -# notifies immediately instead of staying throttled from the episode that -# just cleared. Call this from the success point where the corresponding -# block-on-owner condition resolves (PR merged, approval given, issue -# advanced, budget/attempts counter manually reset, etc). +# was never labeled just no-ops; a 404 from the DELETE is the expected +# steady state and is not distinguished from success) and clears notify.sh's +# throttle entry for (KIND,TARGET), so a FUTURE flag of the same kind on the +# same target notifies immediately instead of staying throttled from the +# episode that just cleared. Call this from the success point where the +# corresponding block-on-owner condition resolves (PR merged, approval +# given, issue advanced, budget/attempts counter manually reset, etc). # # Both functions are best-effort throughout: a gh/notify failure here must -# NEVER break the calling script. +# NEVER break the calling script -- with the one deliberate exception of the +# confirmed-before-comment gate above, which fails CLOSED (no comment) instead +# of open, and instead surfaces loudly via events.jsonl. needs_human_script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # $1 = "issue:42" or "pr:17" -> sets NH_TYPE ("issue"|"pr") and NH_NUM. @@ -67,19 +95,38 @@ _needs_human_split_target() { NH_NUM="${t#*:}" } -# _needs_human_already_labeled: prints "yes" if TARGET (NH_TYPE/NH_NUM, must -# already be split) currently carries the needs-human label on GitHub, "no" -# otherwise -- including on any read failure (offline/unauthenticated/missing -# gh), so callers fail toward still posting the comment (the old behavior) -# rather than toward silently swallowing a fresh escalation. Read-only: never -# mutates anything. +# _needs_human_repo: prints "owner/name" -- reuses the caller's own `$repo` +# (every current caller computes this in its own top-level scope, right +# before sourcing this file) so this doesn't burn a second `gh repo view` +# round-trip per call; falls back to computing it directly (e.g. when this +# file is sourced standalone, as needs-human.test.sh does) if `$repo` is +# unset/empty. A failure here (offline/unauthenticated) just yields an empty +# string -- every REST call built from it below will then fail too, which is +# fine: this whole file is best-effort. +_needs_human_repo() { + if [ -n "${repo:-}" ]; then + printf '%s\n' "$repo" + else + gh repo view --json nameWithOwner -q .nameWithOwner 2>/dev/null + fi +} + +# _needs_human_read_labels: prints TARGET's (NH_TYPE/NH_NUM, must already be +# split) current label names, one per line, via a plain REST GET -- PRs ARE +# issues in the REST API, so this is the SAME call for both kinds (issue #169 +# unifies what used to be separate `gh pr view`/`gh issue view` branches). +# $1 = repo ("owner/name"). Read-only: never mutates anything. +_needs_human_read_labels() { + local nh_repo="$1" + gh api "repos/$nh_repo/issues/$NH_NUM" -q '.labels[].name' 2>/dev/null +} + +# _needs_human_already_labeled: prints "yes" if TARGET currently carries the +# needs-human label on GitHub, "no" otherwise -- including on any read +# failure (offline/unauthenticated/missing gh/empty repo). $1 = repo. _needs_human_already_labeled() { - local out="" - case "$NH_TYPE" in - pr) out="$(gh pr view "$NH_NUM" --json labels -q '.labels[].name' 2>/dev/null)" || out="" ;; - issue) out="$(gh issue view "$NH_NUM" --json labels -q '.labels[].name' 2>/dev/null)" || out="" ;; - *) out="" ;; - esac + local nh_repo="$1" out="" + out="$(_needs_human_read_labels "$nh_repo")" || out="" if printf '%s\n' "$out" | grep -qx "needs-human"; then printf 'yes\n' else @@ -87,35 +134,73 @@ _needs_human_already_labeled() { fi } +# _needs_human_log_error: best-effort append to events.jsonl via log-event.sh +# (issue #169) so a label-add that never got confirmed surfaces in the +# cockpit instead of silently vanishing behind this file's `|| true`s. +# Mirrors loop-tick.sh's own log_loop_event helper: role=orchestrator, and a +# missing log-event.sh (a fixture that doesn't ship it) is a silent no-op. +_needs_human_log_error() { + local target="$1" detail="$2" + [ -f "$needs_human_script_dir/log-event.sh" ] || return 0 + bash "$needs_human_script_dir/log-event.sh" --role orchestrator --task "$target" \ + --phase error --detail "$detail" >/dev/null 2>&1 || true +} + needs_human_flag() { local target="$1" kind="$2" severity="$3" title="$4" body="$5" _needs_human_split_target "$target" + local nh_repo + nh_repo="$(_needs_human_repo)" || nh_repo="" + + # Ensure the label exists repo-wide (idempotent create via REST -- issue + # #169: gh 2.4.0 has no `gh label` subcommand, so this MUST go through + # `gh api`). A 422 "already_exists" is the expected steady state; like every + # other statement in this file it is swallowed by `|| true` -- this step is + # NOT part of the confirmed-before-comment gate below (that gate only cares + # about the per-target add a few lines down). + gh api -X POST "repos/$nh_repo/labels" \ + -f name=needs-human -f color=b60205 \ + -f description="Loop is blocked on owner judgment -- see the issue/PR body/comments" \ + >/dev/null 2>&1 || true # Read BEFORE mutating: this call's own label add below must not make # itself look like a "repeat" episode. local fresh_episode="yes" - { [ "$(_needs_human_already_labeled)" = "yes" ] && fresh_episode="no"; } || true + { [ "$(_needs_human_already_labeled "$nh_repo")" = "yes" ] && fresh_episode="no"; } || true - gh label create "needs-human" --color b60205 \ - --description "Loop is blocked on owner judgment -- see the issue/PR body/comments" \ - --force >/dev/null 2>&1 || true + # Add the label via REST (issue #169): the one call shape proven to work + # with this token/gh version. NOTE: exit code intentionally ignored here -- + # old gh (2.4.0) can exit non-zero parsing an otherwise-successful response; + # the re-read immediately below is the actual ground truth, in EITHER + # direction (see the CRITICAL INVARIANT note at the top of this file). + printf '{"labels":["needs-human"]}' \ + | gh api -X POST "repos/$nh_repo/issues/$NH_NUM/labels" --input - >/dev/null 2>&1 || true + + local confirmed="no" + { [ "$(_needs_human_already_labeled "$nh_repo")" = "yes" ] && confirmed="yes"; } || true # NOTE: every conditional below ends in `|| true` on the OUTSIDE of the # `[ ... ] && { ... }` too (not just inside the braces) -- under a caller # running `set -e` (pr-feedback.sh, merge-ready.sh), a bare # `[ -n "$body" ] && { ...; }` statement whose test is FALSE evaluates the # whole statement to non-zero and would trip errexit right here. - case "$NH_TYPE" in - pr) - gh pr edit "$NH_NUM" --add-label needs-human >/dev/null 2>&1 || true - { [ "$fresh_episode" = "yes" ] && [ -n "$body" ] && gh pr comment "$NH_NUM" --body "$body" >/dev/null 2>&1; } || true - ;; - issue) - gh issue edit "$NH_NUM" --add-label needs-human >/dev/null 2>&1 || true - { [ "$fresh_episode" = "yes" ] && [ -n "$body" ] && gh issue comment "$NH_NUM" --body "$body" >/dev/null 2>&1; } || true - ;; - *) ;; - esac + if [ "$confirmed" = "yes" ]; then + case "$NH_TYPE" in + pr) + { [ "$fresh_episode" = "yes" ] && [ -n "$body" ] && gh pr comment "$NH_NUM" --body "$body" >/dev/null 2>&1; } || true + ;; + issue) + { [ "$fresh_episode" = "yes" ] && [ -n "$body" ] && gh issue comment "$NH_NUM" --body "$body" >/dev/null 2>&1; } || true + ;; + *) ;; + esac + else + # The label add could not be confirmed -- fail LOUDLY instead of quietly + # posting a comment for an escalation episode that GitHub itself does not + # show as labeled (issue #169: this is precisely the mechanism that let + # the #168/#161 spam happen). + _needs_human_log_error "$target" "needs-human label add unconfirmed (kind=$kind) -- comment suppressed" + fi local body_line body_line="$(printf '%s\n' "$body" | head -1)" @@ -126,12 +211,13 @@ needs_human_flag() { needs_human_clear() { local target="$1" kind="$2" _needs_human_split_target "$target" + local nh_repo + nh_repo="$(_needs_human_repo)" || nh_repo="" - case "$NH_TYPE" in - pr) gh pr edit "$NH_NUM" --remove-label needs-human >/dev/null 2>&1 || true ;; - issue) gh issue edit "$NH_NUM" --remove-label needs-human >/dev/null 2>&1 || true ;; - *) ;; - esac + # REST DELETE (issue #169) -- a 404 (never labeled, or already cleared) is + # the expected steady state and is not distinguished from success; like + # every other statement in this file, best-effort only. + gh api -X DELETE "repos/$nh_repo/issues/$NH_NUM/labels/needs-human" >/dev/null 2>&1 || true bash "$needs_human_script_dir/notify.sh" --clear --kind "$kind" --target "$target" >/dev/null 2>&1 || true } diff --git a/.claude/scripts/needs-human.test.sh b/.claude/scripts/needs-human.test.sh index 261845f..9532eff 100755 --- a/.claude/scripts/needs-human.test.sh +++ b/.claude/scripts/needs-human.test.sh @@ -1,12 +1,25 @@ #!/usr/bin/env bash -# needs-human.test.sh — offline smoke test for needs-human.sh (issue #99), -# the ONE shared label+notify seam for the loop's block-on-owner points. +# needs-human.test.sh — offline smoke test for needs-human.sh (issue #99, +# rewritten for issue #169's REST label ops + confirmed-episode guard). # -# Sources the REAL needs-human.sh (+ notify.sh, next to it) with a stubbed -# `gh` shell FUNCTION (never a network call) that logs every invocation, so -# needs_human_flag/needs_human_clear exercise their real gh call sequence -# with zero network/tokens. notify.sh's own configured command is also a -# local file write, never gh/network. +# Sources the REAL needs-human.sh (+ notify.sh/log-event.sh/resolve-roots.sh, +# next to it) with a stubbed `gh` shell FUNCTION (never a network call) that +# logs every invocation, so needs_human_flag/needs_human_clear exercise their +# real gh call sequence with zero network/tokens. notify.sh's own configured +# command is also a local file write, never gh/network. +# +# Issue #169: every label read/write now goes through `gh api` (REST) instead +# of `gh label create`/`gh pr|issue edit --*-label` (gh 2.4.0 has no `gh +# label` subcommand, and the edit --*-label GraphQL calls hit a scope error +# with the bot token on this environment -- both silently no-op'd behind +# `|| true`, so the label never actually stuck and the episode guard below +# reset on every tick, spamming a fresh GitHub comment every ~5 min). PRs are +# issues in the REST API, so `gh api repos/OWNER/REPO/issues/N` is now the ONE +# shared read for both pr:/issue: targets. The CRITICAL invariant added by +# #169: the episode comment posts ONLY once the label add is CONFIRMED by +# re-reading the target's labels after the add -- never from the add's exit +# code alone -- and an unconfirmed add fails LOUDLY via log-event.sh instead +# of silently posting an unverifiable-episode comment. # # Exit 0 on success, non-zero if any assertion fails. Runnable bare: # bash .claude/scripts/needs-human.test.sh @@ -39,7 +52,10 @@ check() { } # $1 = fixture name, $2 = notify command (may be empty). Prints the fixture -# root dir (a throwaway /.claude/{scripts,state} tree). +# root dir (a throwaway /.claude/{scripts,state} tree). Copies +# log-event.sh too (issue #169) so the confirmed-episode failure path can +# actually append to a real events.jsonl instead of silently no-op'ing on a +# missing sibling script. new_fixture() { local name="$1" notify_cmd="$2" local dir="$work/$name" @@ -48,6 +64,7 @@ new_fixture() { cp "$script_dir/needs-human.sh" "$scripts/needs-human.sh" cp "$script_dir/notify.sh" "$scripts/notify.sh" cp "$script_dir/resolve-roots.sh" "$scripts/resolve-roots.sh" + cp "$script_dir/log-event.sh" "$scripts/log-event.sh" chmod +x "$scripts"/*.sh CLAUDE_NOTIFY_CMD="$notify_cmd" node -e ' const fs = require("fs"); @@ -57,53 +74,111 @@ new_fixture() { } # --------------------------------------------------------------------------- -# 1. needs_human_flag on an ISSUE target, FRESH episode (stub `gh` returns no -# labels, so the pre-flight presence check reports "not already labeled"): -# label-presence read + label create + issue edit --add-label + issue -# comment (in that order), all via the stubbed `gh`. +# 1. needs_human_flag on an ISSUE target, FRESH episode, label add CONFIRMED +# (stub `gh` simulates GitHub's own label state via a marker FILE the +# add-label POST touches, so the pre-add read reports "absent" and the +# post-add confirm read reports "present"): repo view + label-create + +# label-presence read (before) + label add + label-presence read (confirm) +# + issue comment (in that order), all via the stubbed `gh`. # --------------------------------------------------------------------------- dir1="$(new_fixture scenario1 "")" gh_log1="$work/scenario1-gh.log" +label_marker1="$work/scenario1-label.marker" out1="$(bash -c ' - gh() { printf "%s\n" "$*" >> "'"$gh_log1"'"; } + gh() { + printf "%s\n" "$*" >> "'"$gh_log1"'" + case "$1" in + repo) echo "acme/repo" ;; + api) + shift + case "$*" in + "repos/acme/repo/issues/42 -q .labels[].name") + [ -f "'"$label_marker1"'" ] && echo "needs-human" + ;; + "-X POST repos/acme/repo/issues/42/labels --input -") + touch "'"$label_marker1"'" + ;; + *) : ;; + esac + ;; + *) : ;; + esac + } . "'"$dir1"'/.claude/scripts/needs-human.sh" needs_human_flag "issue:42" "attempt-budget" "high" "Attempt budget exhausted" "Please look at issue 42" ' 2>&1)" rc1=$? check "scenario 1: needs_human_flag exits 0" [ "$rc1" -eq 0 ] -check "scenario 1: label-presence check is the FIRST gh call" bash -c 'head -1 "$1" | grep -q "^issue view 42 --json labels"' _ "$gh_log1" -check "scenario 1: label create is the SECOND gh call" bash -c 'sed -n 2p "$1" | grep -q "^label create needs-human"' _ "$gh_log1" -check "scenario 1: issue edit --add-label needs-human is the THIRD gh call" bash -c 'sed -n 3p "$1" | grep -qF "issue edit 42 --add-label needs-human"' _ "$gh_log1" -check "scenario 1: issue comment with the body is the FOURTH gh call" bash -c 'sed -n 4p "$1" | grep -qF "issue comment 42 --body Please look at issue 42"' _ "$gh_log1" -check "scenario 1: exactly 4 gh calls (no extra side effects)" bash -c '[ "$(wc -l < "$1" | tr -d " ")" -eq 4 ]' _ "$gh_log1" +check "scenario 1: repo view is the FIRST gh call" bash -c 'sed -n 1p "$1" | grep -q "^repo view --json nameWithOwner"' _ "$gh_log1" +check "scenario 1: ensure-label REST create is the SECOND gh call" bash -c 'sed -n 2p "$1" | grep -qF -- "-X POST repos/acme/repo/labels"' _ "$gh_log1" +check "scenario 1: label-presence read (BEFORE add) is the THIRD gh call" bash -c 'sed -n 3p "$1" | grep -qF "api repos/acme/repo/issues/42 -q .labels[].name"' _ "$gh_log1" +check "scenario 1: label add via REST POST .../issues/42/labels is the FOURTH gh call" bash -c 'sed -n 4p "$1" | grep -qF -- "-X POST repos/acme/repo/issues/42/labels --input -"' _ "$gh_log1" +check "scenario 1: label-presence read (CONFIRM after add) is the FIFTH gh call" bash -c 'sed -n 5p "$1" | grep -qF "api repos/acme/repo/issues/42 -q .labels[].name"' _ "$gh_log1" +check "scenario 1: issue comment with the body is the SIXTH gh call (add was CONFIRMED)" bash -c 'sed -n 6p "$1" | grep -qF "issue comment 42 --body Please look at issue 42"' _ "$gh_log1" +check "scenario 1: exactly 6 gh calls (no extra side effects)" bash -c '[ "$(wc -l < "$1" | tr -d " ")" -eq 6 ]' _ "$gh_log1" # --------------------------------------------------------------------------- -# 2. needs_human_flag on a PR target: `pr edit`/`pr comment`, not `issue *`. +# 2. needs_human_flag on a PR target: reads/writes the SAME REST +# repos/OWNER/REPO/issues/N endpoint (PRs ARE issues in the REST API -- +# issue #169 unifies what used to be separate pr view/issue view +# branches), but still posts via `gh pr comment`, not `gh issue comment`. # --------------------------------------------------------------------------- dir2="$(new_fixture scenario2 "")" gh_log2="$work/scenario2-gh.log" +label_marker2="$work/scenario2-label.marker" bash -c ' - gh() { printf "%s\n" "$*" >> "'"$gh_log2"'"; } + gh() { + printf "%s\n" "$*" >> "'"$gh_log2"'" + case "$1" in + repo) echo "acme/repo" ;; + api) + shift + case "$*" in + "repos/acme/repo/issues/17 -q .labels[].name") + [ -f "'"$label_marker2"'" ] && echo "needs-human" + ;; + "-X POST repos/acme/repo/issues/17/labels --input -") + touch "'"$label_marker2"'" + ;; + *) : ;; + esac + ;; + *) : ;; + esac + } . "'"$dir2"'/.claude/scripts/needs-human.sh" needs_human_flag "pr:17" "pr-review" "low" "PR ready" "please review PR 17" ' >/dev/null 2>&1 -check "scenario 2: PR target uses 'pr edit', not 'issue edit'" bash -c 'grep -q "^pr edit 17 --add-label needs-human" "$1" && ! grep -q "^issue edit" "$1"' _ "$gh_log2" -check "scenario 2: PR target posts via 'pr comment'" grep -qF "pr comment 17 --body please review PR 17" "$gh_log2" +check "scenario 2: PR target reads/writes the shared issues/17 REST endpoint" bash -c ' + grep -qF "api repos/acme/repo/issues/17 -q .labels[].name" "$1" && + grep -qF -- "-X POST repos/acme/repo/issues/17/labels --input -" "$1" +' _ "$gh_log2" +check "scenario 2: PR target posts via 'pr comment', never 'issue comment'" bash -c ' + grep -qF "pr comment 17 --body please review PR 17" "$1" && ! grep -q "^issue comment" "$1" +' _ "$gh_log2" # --------------------------------------------------------------------------- -# 3. needs_human_clear removes the label (the OPPOSITE gh call from flag) and -# never posts a comment. +# 3. needs_human_clear removes the label via REST DELETE (the OPPOSITE gh +# call from flag's add) and never posts a comment or (re-)creates the +# label. # --------------------------------------------------------------------------- dir3="$(new_fixture scenario3 "")" gh_log3="$work/scenario3-gh.log" bash -c ' - gh() { printf "%s\n" "$*" >> "'"$gh_log3"'"; } + gh() { + printf "%s\n" "$*" >> "'"$gh_log3"'" + case "$1" in + repo) echo "acme/repo" ;; + *) : ;; + esac + } . "'"$dir3"'/.claude/scripts/needs-human.sh" needs_human_clear "issue:99" "stall" ' >/dev/null 2>&1 -check "scenario 3: clear removes the label via 'issue edit --remove-label'" grep -qF "issue edit 99 --remove-label needs-human" "$gh_log3" -check "scenario 3: clear never posts a comment" bash -c '! grep -q "^issue comment" "$1"' _ "$gh_log3" -check "scenario 3: clear never (re-)creates the label" bash -c '! grep -q "^label create" "$1"' _ "$gh_log3" +check "scenario 3: clear removes the label via REST DELETE .../issues/99/labels/needs-human" grep -qF -- "-X DELETE repos/acme/repo/issues/99/labels/needs-human" "$gh_log3" +check "scenario 3: clear never posts a comment" bash -c '! grep -qE "^(issue|pr) comment" "$1"' _ "$gh_log3" +check "scenario 3: clear never (re-)creates the label" bash -c '! grep -q -- "-X POST repos/acme/repo/labels " "$1"' _ "$gh_log3" +check "scenario 3: exactly 2 gh calls (repo view + the DELETE)" bash -c '[ "$(wc -l < "$1" | tr -d " ")" -eq 2 ]' _ "$gh_log3" # --------------------------------------------------------------------------- # 4. Idempotent add/remove round-trip driven through notify.sh's real @@ -111,26 +186,55 @@ check "scenario 3: clear never (re-)creates the label" bash -c '! grep -q "^labe # notification TWICE (clear resets the throttle so the second flag isn't # silently swallowed) — proves flag/clear are a genuine round trip, not # just gh label bookkeeping with a notify path that's accidentally inert. +# The marker file the stub `gh` uses to simulate GitHub's label state is +# touched by the add and removed by the DELETE, so the SECOND flag also +# sees a fresh (label-absent) episode and posts its own comment. # --------------------------------------------------------------------------- fired4="$work/scenario4-fired.txt" dir4="$(new_fixture scenario4 "printf 'fired\n' >> $fired4")" gh_log4="$work/scenario4-gh.log" +label_marker4="$work/scenario4-label.marker" bash -c ' - gh() { printf "%s\n" "$*" >> "'"$gh_log4"'"; } + gh() { + printf "%s\n" "$*" >> "'"$gh_log4"'" + case "$1" in + repo) echo "acme/repo" ;; + api) + shift + case "$*" in + "repos/acme/repo/issues/7 -q .labels[].name") + [ -f "'"$label_marker4"'" ] && echo "needs-human" + ;; + "-X POST repos/acme/repo/issues/7/labels --input -") + touch "'"$label_marker4"'" + ;; + "-X DELETE repos/acme/repo/issues/7/labels/needs-human") + rm -f "'"$label_marker4"'" + ;; + *) : ;; + esac + ;; + *) : ;; + esac + } . "'"$dir4"'/.claude/scripts/needs-human.sh" needs_human_flag "issue:7" "attempt-budget" "high" "T" "B" needs_human_clear "issue:7" "attempt-budget" needs_human_flag "issue:7" "attempt-budget" "high" "T2" "B2" ' >/dev/null 2>&1 -check "scenario 4: label add appears TWICE (flag, clear, flag again)" bash -c '[ "$(grep -c "add-label needs-human" "$1")" -eq 2 ]' _ "$gh_log4" -check "scenario 4: label remove appears ONCE (the clear in between)" bash -c '[ "$(grep -c "remove-label needs-human" "$1")" -eq 1 ]' _ "$gh_log4" +check "scenario 4: label add appears TWICE (flag, clear, flag again)" bash -c '[ "$(grep -c -- "-X POST repos/acme/repo/issues/7/labels --input -" "$1")" -eq 2 ]' _ "$gh_log4" +check "scenario 4: label remove appears ONCE (the clear in between)" bash -c '[ "$(grep -c -- "-X DELETE repos/acme/repo/issues/7/labels/needs-human" "$1")" -eq 1 ]' _ "$gh_log4" +check "scenario 4: both flags post a comment (each is its own fresh, CONFIRMED episode)" bash -c '[ "$(grep -c "^issue comment 7" "$1")" -eq 2 ]' _ "$gh_log4" check "scenario 4: notify fired TWICE (clear reset the throttle between the two flags)" bash -c '[ "$(wc -l < "$1" | tr -d " ")" -eq 2 ]' _ "$fired4" # --------------------------------------------------------------------------- # 5. Without an intervening clear, a SECOND flag for the SAME (kind,target) -# is throttled by notify.sh (still re-applies the gh label, but does not -# re-notify) -- the "one notification per (kind,target) per window" -# contract this issue's acceptance criteria calls for. +# is throttled by notify.sh -- the "one notification per (kind,target) per +# window" contract this issue's acceptance criteria calls for. `gh` here +# is a total no-op (stands in for "every REST call fails/does nothing"), +# which per the #169 invariant also means neither call's comment posts -- +# but notify.sh's throttle is independent of the label/comment path, so it +# still only fires once. # --------------------------------------------------------------------------- fired5="$work/scenario5-fired.txt" dir5="$(new_fixture scenario5 "printf 'fired\n' >> $fired5")" @@ -159,66 +263,136 @@ out6="$(bash -c ' check "scenario 6: caller survives (prints SURVIVED) even when every gh call fails" bash -c 'printf "%s\n" "$1" | grep -q "SURVIVED"' _ "$out6" # --------------------------------------------------------------------------- -# 7. Comment episode-gating (issue #99 re-review finding #1): a REPEAT flag -# call on a target that ALREADY carries the needs-human label (per the -# stubbed `gh issue view --json labels` read) still (re-)applies the label -# but SKIPS the comment -- this is the fix for the "fresh GitHub comment -# every tick" bug the reviewer reproduced. +# 7. Comment episode-gating (issue #99 re-review finding #1, re-verified +# under #169's REST rewrite): a REPEAT flag call on a target that ALREADY +# carries the needs-human label (per the stubbed REST read, present on +# BOTH the before- and after-add reads) still (re-)applies the label but +# SKIPS the comment -- task acceptance criterion (a): "second flag with +# label present -> no comment". # --------------------------------------------------------------------------- dir7="$(new_fixture scenario7 "")" gh_log7="$work/scenario7-gh.log" bash -c ' gh() { printf "%s\n" "$*" >> "'"$gh_log7"'" - case "$*" in - "issue view 50 --json labels -q .labels[].name") printf "needs-human\n" ;; + case "$1" in + repo) echo "acme/repo" ;; + api) + shift + case "$*" in + "repos/acme/repo/issues/50 -q .labels[].name") echo "needs-human" ;; + *) : ;; + esac + ;; *) : ;; esac } . "'"$dir7"'/.claude/scripts/needs-human.sh" needs_human_flag "issue:50" "attempt-budget" "high" "T" "already labeled body" ' >/dev/null 2>&1 -check "scenario 7: label is still (re-)applied when already present" grep -qF "issue edit 50 --add-label needs-human" "$gh_log7" -check "scenario 7: comment is SKIPPED because the label was already present" bash -c '! grep -q "^issue comment" "$1"' _ "$gh_log7" +check "scenario 7: label is still (re-)applied when already present" grep -qF -- "-X POST repos/acme/repo/issues/50/labels --input -" "$gh_log7" +check "scenario 7: comment is SKIPPED because the label was already present (repeat episode)" bash -c '! grep -qE "^(issue|pr) comment" "$1"' _ "$gh_log7" # --------------------------------------------------------------------------- -# 8. Same target shape, label ABSENT (a fresh episode -- e.g. right after a -# needs_human_clear, or the very first flag ever) -- comment posts. +# 8. Fresh episode with an UNRELATED label already present (needs-human +# itself absent before the add, confirmed present after) -- comment posts. +# Task acceptance criterion (a): "label-add confirmed -> comment posts +# once". # --------------------------------------------------------------------------- dir8="$(new_fixture scenario8 "")" gh_log8="$work/scenario8-gh.log" +label_marker8="$work/scenario8-label.marker" bash -c ' gh() { printf "%s\n" "$*" >> "'"$gh_log8"'" - case "$*" in - "issue view 51 --json labels -q .labels[].name") printf "some-other-label\n" ;; + case "$1" in + repo) echo "acme/repo" ;; + api) + shift + case "$*" in + "repos/acme/repo/issues/51 -q .labels[].name") + if [ -f "'"$label_marker8"'" ]; then printf "some-other-label\nneeds-human\n"; else printf "some-other-label\n"; fi + ;; + "-X POST repos/acme/repo/issues/51/labels --input -") + touch "'"$label_marker8"'" + ;; + *) : ;; + esac + ;; *) : ;; esac } . "'"$dir8"'/.claude/scripts/needs-human.sh" needs_human_flag "issue:51" "attempt-budget" "high" "T" "fresh episode body" ' >/dev/null 2>&1 -check "scenario 8: comment posts on a fresh episode (needs-human label absent)" grep -qF "issue comment 51 --body fresh episode body" "$gh_log8" +check "scenario 8: comment posts on a fresh, CONFIRMED episode (needs-human absent before, present after add)" grep -qF "issue comment 51 --body fresh episode body" "$gh_log8" # --------------------------------------------------------------------------- -# 9. A failing label READ (offline/unauthenticated `gh issue view`) fails -# OPEN toward posting the comment -- the old, safe (if noisier) behavior -# -- never toward silently swallowing a genuinely fresh escalation. +# 9. CRITICAL INVARIANT (issue #169): a label add that can never be CONFIRMED +# (every REST label read fails -- offline/unauthenticated/permission +# error, standing in for the exact #168/#161 failure mode) must NOT post +# the episode comment, and must fail LOUDLY by appending an error line to +# events.jsonl via log-event.sh -- task acceptance criterion (b): +# "label-add FAILS -> NO comment posted + an events.jsonl error line". +# This deliberately INVERTS this suite's old "fails OPEN toward the +# comment" assertion: that old fail-open behavior is exactly how an +# unconfirmable episode used to spam a fresh comment every tick. # --------------------------------------------------------------------------- dir9="$(new_fixture scenario9 "")" gh_log9="$work/scenario9-gh.log" +events9="$work/scenario9-events.jsonl" bash -c ' gh() { printf "%s\n" "$*" >> "'"$gh_log9"'" - case "$*" in - "issue view 52 --json labels -q .labels[].name") return 1 ;; + case "$1" in + repo) echo "acme/repo" ;; + api) + shift + case "$*" in + "repos/acme/repo/issues/52 -q .labels[].name") return 1 ;; + *) : ;; + esac + ;; *) : ;; esac } . "'"$dir9"'/.claude/scripts/needs-human.sh" - needs_human_flag "issue:52" "attempt-budget" "high" "T" "fail-open body" + CLAUDE_EVENTS_FILE="'"$events9"'" needs_human_flag "issue:52" "attempt-budget" "high" "T" "unconfirmed body" ' >/dev/null 2>&1 -check "scenario 9: a failing label read fails OPEN -- comment still posts" grep -qF "issue comment 52 --body fail-open body" "$gh_log9" +check "scenario 9: an unconfirmable label add does NOT post a comment" bash -c '! grep -qE "^(issue|pr) comment" "$1"' _ "$gh_log9" +check "scenario 9: the label add was still ATTEMPTED (best-effort, just unconfirmed)" grep -qF -- "-X POST repos/acme/repo/issues/52/labels --input -" "$gh_log9" +check "scenario 9: the failure is logged LOUDLY to events.jsonl (phase=error)" bash -c '[ -f "$1" ] && grep -q "\"phase\":\"error\"" "$1"' _ "$events9" +check "scenario 9: the events.jsonl line names the target (issue:52)" bash -c 'grep -q "\"task\":\"issue:52\"" "$1"' _ "$events9" + +# --------------------------------------------------------------------------- +# 10. Task acceptance criterion (c): "remove treats 404 as success" -- a +# failing (non-zero-exit) REST DELETE, standing in for a 404 on a target +# that was never labeled (or already cleared), must be treated exactly +# like success: no crash, no special-casing, no retried/duplicated calls. +# --------------------------------------------------------------------------- +dir10="$(new_fixture scenario10 "")" +gh_log10="$work/scenario10-gh.log" +out10="$(bash -c ' + gh() { + printf "%s\n" "$*" >> "'"$gh_log10"'" + case "$1" in + repo) echo "acme/repo" ;; + api) + shift + case "$*" in + "-X DELETE repos/acme/repo/issues/60/labels/needs-human") return 1 ;; # simulated 404 + *) : ;; + esac + ;; + *) : ;; + esac + } + . "'"$dir10"'/.claude/scripts/needs-human.sh" + needs_human_clear "issue:60" "stall" + echo "CLEAR_SURVIVED" +' 2>&1)" +check "scenario 10: a 404-like DELETE failure never crashes needs_human_clear" bash -c 'printf "%s\n" "$1" | grep -q "CLEAR_SURVIVED"' _ "$out10" +check "scenario 10: exactly ONE DELETE attempt (no retry loop on the 'failure')" bash -c '[ "$(grep -c -- "-X DELETE repos/acme/repo/issues/60/labels/needs-human" "$1")" -eq 1 ]' _ "$gh_log10" echo "" if [ "$fail" -eq 0 ]; then diff --git a/.claude/scripts/pr-comment-fix.test.sh b/.claude/scripts/pr-comment-fix.test.sh index db98232..8fbce5e 100644 --- a/.claude/scripts/pr-comment-fix.test.sh +++ b/.claude/scripts/pr-comment-fix.test.sh @@ -9,6 +9,12 @@ # the precedence exclusion can be exercised without any real # feedback-detection logic). No network, no real `gh` CLI required. # +# Issue #169: needs-human.sh's label reads/writes now go through `gh api` +# (REST) instead of `gh pr edit --*-label`/`gh pr view --json labels`. The +# fake bot-gh.sh below simulates GitHub's own label state via a marker FILE +# the REST add touches, so the post-add CONFIRM read (issue #169's +# comment-gating invariant) sees the label actually "stuck". +# # Exit 0 on success, non-zero if any assertion fails. Runnable bare: # bash .claude/scripts/pr-comment-fix.test.sh set -uo pipefail @@ -40,6 +46,7 @@ cp "$src" "$scripts_dir/pr-comment-fix.sh" cp "$resolve_roots_src" "$scripts_dir/resolve-roots.sh" cp "$script_dir/needs-human.sh" "$scripts_dir/needs-human.sh" cp "$script_dir/notify.sh" "$scripts_dir/notify.sh" +cp "$script_dir/log-event.sh" "$scripts_dir/log-event.sh" cat > "$fixture/.claude/gates.json" <<'EOF' { @@ -71,19 +78,21 @@ EOF cat > "$scripts_dir/bot-gh.sh" <<'BOTGH' #!/usr/bin/env bash # Log every invocation (mirrors pr-feedback.test.sh's gh-call-log convention) -# so escalation side effects (needs_human_flag's `gh pr edit ... --add-label -# needs-human`) can be asserted on directly, not just inferred from "the PR -# wasn't emitted" (which can't distinguish correct escalation from a silent -# drop). +# so escalation side effects (needs_human_flag's REST label add, issue #169) +# can be asserted on directly, not just inferred from "the PR wasn't emitted" +# (which can't distinguish correct escalation from a silent drop). The +# needs-human label marker FILE below simulates GitHub's own label state (the +# REST add touches it; the REST read reports it) so needs_human_flag's +# post-add CONFIRM read sees the label actually "stuck" for PR 17's +# escalation. log_dir="$(cd "$(dirname "$0")" && pwd)" +label_marker17="$log_dir/labeled-17.marker" printf '%s\n' "$*" >> "$log_dir/gh-calls.log" case "$1" in repo) echo "acme/repo" ;; - label) exit 0 ;; # needs_human_flag's `gh label create needs-human ...` pr) case "$2" in - edit|comment) exit 0 ;; # needs_human_flag's `gh pr edit`/`gh pr comment` - view) echo ""; exit 0 ;; # _needs_human_already_labeled's `gh pr view --json labels` + comment) exit 0 ;; # needs_human_flag's `gh pr comment` esac if printf '%s\n' "$*" | grep -q 'headRefOid'; then cat <<'JSON' @@ -140,6 +149,15 @@ JSON *"graphql"*) echo '[]' ;; + *"-X POST"*"/issues/17/labels --input -") + touch "$label_marker17" + ;; + *"-X POST"*"repos/acme/repo/labels "*) + : # ensure-label repo-level create (idempotent, needs_human_flag) + ;; + *"-q .labels[].name"*) + [ -f "$label_marker17" ] && echo "needs-human" + ;; *"issues/15/comments"*) echo '[{"user":{"login":"testbot"},"body":"","created_at":"2026-02-01T00:00:00Z"}]' ;; @@ -196,10 +214,10 @@ check "exactly 3 PRs emitted total (10, 16, 19)" bash -c '[ "$(printf "%s\n" "$1 # effect, not just "PR 17 wasn't emitted" (which can't tell correct # escalation apart from a silent bug that just drops the thread). gh_log1="$scripts_dir/gh-calls.log" -check "PR 17 (budget exhausted): needs-human label ACTUALLY applied (gh pr edit 17 --add-label needs-human)" \ - grep -q "pr edit 17 --add-label needs-human" "$gh_log1" +check "PR 17 (budget exhausted): needs-human label ACTUALLY applied via REST (issue #169)" \ + grep -qF -- "-X POST repos/acme/repo/issues/17/labels --input -" "$gh_log1" check "PR 16 (attempt 2, budget not yet exhausted): needs-human label NOT applied" \ - bash -c '! grep -q "pr edit 16 --add-label needs-human" "$1"' _ "$gh_log1" + bash -c '! grep -qF -- "-X POST repos/acme/repo/issues/16/labels --input -" "$1"' _ "$gh_log1" # --- fixture2: the SHIPPED DEFAULT (commentFix.botAllowlist: []) must # actually be exercised -- fixture1 above hardcodes a non-empty allowlist for @@ -215,6 +233,7 @@ cp "$src" "$scripts_dir2/pr-comment-fix.sh" cp "$resolve_roots_src" "$scripts_dir2/resolve-roots.sh" cp "$script_dir/needs-human.sh" "$scripts_dir2/needs-human.sh" cp "$script_dir/notify.sh" "$scripts_dir2/notify.sh" +cp "$script_dir/log-event.sh" "$scripts_dir2/log-event.sh" cat > "$fixture2/.claude/gates.json" <<'EOF' { @@ -226,13 +245,13 @@ EOF cat > "$scripts_dir2/bot-gh.sh" <<'BOTGH' #!/usr/bin/env bash +# Neither PR here reaches the anti-livelock escalation (needs_human_flag), +# so no label/comment REST calls are ever expected in this fixture. case "$1" in repo) echo "acme/repo" ;; - label) exit 0 ;; pr) case "$2" in - edit|comment) exit 0 ;; - view) echo ""; exit 0 ;; + comment) exit 0 ;; esac if printf '%s\n' "$*" | grep -q 'headRefOid'; then cat <<'JSON' diff --git a/.claude/scripts/pr-feedback.test.sh b/.claude/scripts/pr-feedback.test.sh index b72daa9..e2ae19d 100755 --- a/.claude/scripts/pr-feedback.test.sh +++ b/.claude/scripts/pr-feedback.test.sh @@ -21,6 +21,13 @@ # loop-census.sh now uses so a read-only census never mutates GitHub # state. # +# Issue #169: needs-human.sh's label reads/writes now go through `gh api` +# (REST) instead of `gh pr edit --*-label`/`gh pr view --json labels` (the +# latter silently no-op'd/GraphQL-scope-errored on this environment). The +# fake bot-gh.sh below simulates GitHub's own label state via a marker FILE +# the REST add touches, so the post-add CONFIRM read (issue #169's +# comment-gating invariant) sees the label actually "stuck". +# # Exit 0 on success, non-zero if any assertion fails. Runnable bare: # bash .claude/scripts/pr-feedback.test.sh set -uo pipefail @@ -57,6 +64,7 @@ new_fixture() { cp "$script_dir/resolve-roots.sh" "$scripts/resolve-roots.sh" cp "$script_dir/needs-human.sh" "$scripts/needs-human.sh" cp "$script_dir/notify.sh" "$scripts/notify.sh" + cp "$script_dir/log-event.sh" "$scripts/log-event.sh" chmod +x "$scripts"/*.sh cat > "$dir/.claude/gates.json" <> $work/$name-notify-fired.txt" } @@ -78,21 +86,18 @@ case "\$1" in pr) case "\$2" in list) printf '20\tfeat/issue-20-x\t\n' ;; - view) : ;; # not queried on this path before the clear - edit) : ;; comment) : ;; *) : ;; esac ;; api) - case "\$2" in - repos/*/pulls/*/reviews) printf '2026-02-01T00:00:00Z\treviewer1\n' ;; - repos/*/issues/*/comments) : ;; # no addressed-marker comment at all + case "\$*" in + *"pulls/"*"/reviews"*) printf '2026-02-01T00:00:00Z\treviewer1\n' ;; + *"issues/"*"/comments"*) : ;; # no addressed-marker comment at all + *"-X DELETE"*"/labels/needs-human") : ;; # the clear this scenario exercises *) : ;; esac ;; - label) : ;; - issue) : ;; *) echo "unhandled: \$*" >&2; exit 1 ;; esac EOF @@ -100,7 +105,7 @@ chmod +x "$dirA/.claude/scripts/bot-gh.sh" outA="$(env -u GATES_FILE bash "$dirA/.claude/scripts/pr-feedback.sh" "acme/repo" 2>&1)" check "A: unaddressed PR 20 IS listed in the TSV" bash -c 'printf "%s\n" "$1" | grep -qF "20 feat/issue-20-x reviewer1 2026-02-01T00:00:00Z"' _ "$outA" -check "A: earlier 'awaiting re-review' flag is CLEARED (ball back in bot's court)" grep -q "pr edit 20 --remove-label needs-human" "$gh_logA" +check "A: earlier 'awaiting re-review' flag is CLEARED (ball back in bot's court)" grep -qF -- "-X DELETE repos/acme/repo/issues/20/labels/needs-human" "$gh_logA" check "A: no comment posted on the clear path" bash -c '! grep -q "^pr comment" "$1"' _ "$gh_logA" # --------------------------------------------------------------------------- @@ -120,14 +125,12 @@ case "\$1" in esac ;; api) - case "\$2" in - repos/*/pulls/*/reviews) printf '2026-02-01T00:00:00Z\treviewer1\n' ;; - repos/*/issues/*/comments) : ;; + case "\$*" in + *"pulls/"*"/reviews"*) printf '2026-02-01T00:00:00Z\treviewer1\n' ;; + *"issues/"*"/comments"*) : ;; *) : ;; esac ;; - label) : ;; - issue) : ;; *) echo "unhandled: \$*" >&2; exit 1 ;; esac EOF @@ -136,7 +139,7 @@ outAc="$(env -u GATES_FILE PR_FEEDBACK_COUNT_ONLY=1 bash "$dirA/.claude/scripts/ check "A-count-only: TSV output identical to the real (non-count-only) run" \ bash -c '[ "$1" = "$2" ]' _ "$outA" "$outAc" -check "A-count-only: NO gh label mutation at all (pure counter, finding #2)" bash -c '! grep -q "pr edit" "$1"' _ "$gh_logAc" +check "A-count-only: NO gh label mutation at all (pure counter, finding #2)" bash -c '! grep -qE -- "-X (POST|DELETE) repos/.*labels" "$1"' _ "$gh_logAc" # --------------------------------------------------------------------------- # B. ADDRESSED PR (marker comment newer than the last CHANGES_REQUESTED @@ -157,29 +160,19 @@ case "\$1" in pr) case "\$2" in list) printf '21\tfeat/issue-21-y\t\n' ;; - view) - if printf '%s\n' "\$*" | grep -q -- '--json labels'; then - [ -f "$labeled_markerB" ] && printf 'needs-human\n' - fi - ;; - edit) - if printf '%s\n' "\$*" | grep -q -- '--add-label needs-human'; then - touch "$labeled_markerB" - fi - ;; comment) : ;; *) : ;; esac ;; api) - case "\$2" in - repos/*/pulls/*/reviews) printf '2026-02-01T00:00:00Z\treviewer2\n' ;; - repos/*/issues/*/comments) printf '2026-02-02T00:00:00Z\n' ;; # marker AFTER the CR + case "\$*" in + *"pulls/"*"/reviews"*) printf '2026-02-01T00:00:00Z\treviewer2\n' ;; + *"issues/"*"/comments"*) printf '2026-02-02T00:00:00Z\n' ;; # marker AFTER the CR + *"-X POST"*"/issues/21/labels --input -") touch "$labeled_markerB" ;; + *"-q .labels[].name"*) [ -f "$labeled_markerB" ] && printf 'needs-human\n' ;; *) : ;; esac ;; - label) : ;; - issue) : ;; *) echo "unhandled: \$*" >&2; exit 1 ;; esac EOF @@ -189,7 +182,7 @@ outB2="$(env -u GATES_FILE bash "$dirB/.claude/scripts/pr-feedback.sh" "acme/rep check "B: addressed PR 21 is NOT listed on run 1" bash -c '[ -z "$1" ]' _ "$outB1" check "B: addressed PR 21 is NOT listed on run 2 either" bash -c '[ -z "$1" ]' _ "$outB2" -check "B: label add attempted on BOTH runs" bash -c '[ "$(grep -c "pr edit 21 --add-label needs-human" "$1")" -eq 2 ]' _ "$gh_logB" +check "B: label add attempted on BOTH runs" bash -c '[ "$(grep -c -- "-X POST repos/acme/repo/issues/21/labels --input -" "$1")" -eq 2 ]' _ "$gh_logB" check "B: exactly ONE comment across BOTH runs (episode-gated, finding #1)" bash -c '[ "$(grep -c "pr comment 21 --body" "$1")" -eq 1 ]' _ "$gh_logB" # --------------------------------------------------------------------------- @@ -208,14 +201,12 @@ case "\$1" in esac ;; api) - case "\$2" in - repos/*/pulls/*/reviews) printf '2026-02-01T00:00:00Z\treviewer2\n' ;; - repos/*/issues/*/comments) printf '2026-02-02T00:00:00Z\n' ;; + case "\$*" in + *"pulls/"*"/reviews"*) printf '2026-02-01T00:00:00Z\treviewer2\n' ;; + *"issues/"*"/comments"*) printf '2026-02-02T00:00:00Z\n' ;; *) : ;; esac ;; - label) : ;; - issue) : ;; *) echo "unhandled: \$*" >&2; exit 1 ;; esac EOF @@ -223,7 +214,7 @@ chmod +x "$dirB/.claude/scripts/bot-gh.sh" outBc="$(env -u GATES_FILE PR_FEEDBACK_COUNT_ONLY=1 bash "$dirB/.claude/scripts/pr-feedback.sh" "acme/repo" 2>&1)" check "B-count-only: still not listed (same as non-count-only)" bash -c '[ -z "$1" ]' _ "$outBc" -check "B-count-only: no label add, no comment at all" bash -c '! grep -qE "add-label|^pr comment" "$1"' _ "$gh_logBc" +check "B-count-only: no label add, no comment at all" bash -c '! grep -qE -- "-X (POST|DELETE) repos/.*labels|^pr comment" "$1"' _ "$gh_logBc" # --------------------------------------------------------------------------- # C. A PR already labeled `claude-addressing` is skipped entirely -- not even diff --git a/.claude/scripts/pr-rebase.test.sh b/.claude/scripts/pr-rebase.test.sh index 8ef4fb9..8814363 100644 --- a/.claude/scripts/pr-rebase.test.sh +++ b/.claude/scripts/pr-rebase.test.sh @@ -10,6 +10,12 @@ # can be exercised without any real detection logic in those siblings). No # network, no real `gh` CLI required. # +# Issue #169: needs-human.sh's label reads/writes now go through `gh api` +# (REST) instead of `gh pr edit --*-label`/`gh pr view --json labels`. The +# fake bot-gh.sh below simulates GitHub's own label state via a marker FILE +# the REST add touches, so the post-add CONFIRM read (issue #169's +# comment-gating invariant) sees the label actually "stuck". +# # Exit 0 on success, non-zero if any assertion fails. Runnable bare: # bash .claude/scripts/pr-rebase.test.sh set -uo pipefail @@ -41,6 +47,7 @@ cp "$src" "$scripts_dir/pr-rebase.sh" cp "$resolve_roots_src" "$scripts_dir/resolve-roots.sh" cp "$script_dir/needs-human.sh" "$scripts_dir/needs-human.sh" cp "$script_dir/notify.sh" "$scripts_dir/notify.sh" +cp "$script_dir/log-event.sh" "$scripts_dir/log-event.sh" cat > "$fixture/.claude/gates.json" <<'EOF' { @@ -65,18 +72,20 @@ EOF cat > "$scripts_dir/bot-gh.sh" <<'BOTGH' #!/usr/bin/env bash # Log every invocation (mirrors pr-comment-fix.test.sh's gh-call-log -# convention) so escalation side effects (needs_human_flag's `gh pr edit ... -# --add-label needs-human`) can be asserted on directly, not just inferred -# from "the PR wasn't emitted". +# convention) so escalation side effects (needs_human_flag's REST label add, +# issue #169) can be asserted on directly, not just inferred from "the PR +# wasn't emitted". The needs-human label marker FILE below simulates +# GitHub's own label state (the REST add touches it; the REST read reports +# it) so needs_human_flag's post-add CONFIRM read sees the label actually +# "stuck" for PR 17's escalation. log_dir="$(cd "$(dirname "$0")" && pwd)" +label_marker17="$log_dir/labeled-17.marker" printf '%s\n' "$*" >> "$log_dir/gh-calls.log" case "$1" in repo) echo "acme/repo" ;; - label) exit 0 ;; # needs_human_flag's `gh label create needs-human ...` pr) case "$2" in - edit|comment) exit 0 ;; # needs_human_flag's `gh pr edit`/`gh pr comment` - view) echo ""; exit 0 ;; # _needs_human_already_labeled's `gh pr view --json labels` + comment) exit 0 ;; # needs_human_flag's `gh pr comment` esac if printf '%s\n' "$*" | grep -q 'headRefOid'; then cat <<'JSON' @@ -99,6 +108,15 @@ JSON ;; api) case "$*" in + *"-X POST"*"/issues/17/labels --input -") + touch "$label_marker17" + ;; + *"-X POST"*"repos/acme/repo/labels "*) + : # ensure-label repo-level create (idempotent, needs_human_flag) + ;; + *"-q .labels[].name"*) + [ -f "$label_marker17" ] && echo "needs-human" + ;; *"issues/16/comments"*) echo '[{"user":{"login":"testbot"},"body":""}]' ;; @@ -160,12 +178,12 @@ check "exactly 3 PRs emitted total (10, 16, 19)" bash -c '[ "$(printf "%s\n" "$1 # effect, not just "PR 17 wasn't emitted" (which can't tell correct # escalation apart from a silent bug that just drops the PR). gh_log1="$scripts_dir/gh-calls.log" -check "PR 17 (budget exhausted): needs-human label ACTUALLY applied (gh pr edit 17 --add-label needs-human)" \ - grep -q "pr edit 17 --add-label needs-human" "$gh_log1" +check "PR 17 (budget exhausted): needs-human label ACTUALLY applied via REST (issue #169)" \ + grep -qF -- "-X POST repos/acme/repo/issues/17/labels --input -" "$gh_log1" check "PR 16 (attempt 2, budget not yet exhausted): needs-human label NOT applied" \ - bash -c '! grep -q "pr edit 16 --add-label needs-human" "$1"' _ "$gh_log1" + bash -c '! grep -qF -- "-X POST repos/acme/repo/issues/16/labels --input -" "$1"' _ "$gh_log1" check "PR 19 (reset after base change): needs-human label NOT applied" \ - bash -c '! grep -q "pr edit 19 --add-label needs-human" "$1"' _ "$gh_log1" + bash -c '! grep -qF -- "-X POST repos/acme/repo/issues/19/labels --input -" "$1"' _ "$gh_log1" echo "" if [ "$fail" -eq 0 ]; then