diff --git a/.github/workflows/dev-lead-retry.yml b/.github/workflows/dev-lead-retry.yml index ab19e0fca..ba665836f 100644 --- a/.github/workflows/dev-lead-retry.yml +++ b/.github/workflows/dev-lead-retry.yml @@ -31,6 +31,10 @@ name: Dev-Lead Retry (rate-limited) # whose registered reviewer-bot issue comment has no covering disposition (and no # info_status_pattern match) gets one deduplicated fix-bot-comment retry per scan, # addressed by the comment's node id (scan_pr_for_undispositioned_bot_comments). +# +# Each target repo parses a payload at its OWN dev-lead.yml pin, so the sweep +# holds back (with a ::warning::) any payload carrying a client_payload field the +# target's pinned parser does not read (#2086, scripts/lib/dispatch-channel-gate.sh). on: schedule: @@ -72,8 +76,8 @@ jobs: # `agent_ref`; running the sweep from main let it send payload fields that # channel could not read yet (#2017). Read the pin from the default branch # and check scripts/ out at it. Never hard-code the major here. - # One pin covers the whole sweep: target repos whose dev-lead.yml pins an - # older channel than this repo can still see the skew (#2086). + # Target repos pinned to another channel are covered per payload by the + # gate the run step turns on with SWEEP_AGENT_REF (#2086). - name: Fetch dev-lead caller stub uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -106,4 +110,7 @@ jobs: persist-credentials: false - name: Scan and retry rate-limited PRs + env: + # Turns on the per-target payload-field gate (#2086). + SWEEP_AGENT_REF: ${{ steps.channel.outputs.agent_ref }} run: bash scripts/dev-lead-retry.sh diff --git a/scripts/dev-lead-retry.sh b/scripts/dev-lead-retry.sh index 7171c7aeb..da68cf9ab 100755 --- a/scripts/dev-lead-retry.sh +++ b/scripts/dev-lead-retry.sh @@ -38,6 +38,9 @@ set -euo pipefail # DISPATCH_DELAY_SEC — seconds between repo dispatches (default: 30) to # prevent cascading org-wide rate-limit hits # DRY_RUN — if "true", log what would be dispatched but don't send +# SWEEP_AGENT_REF — the dev-lead channel the sweep's scripts/ run at; +# turns on the #2086 per-target payload-field gate +# (dev-lead-retry.yml passes it; unset = gate off) # NOW_ISO — override current time for testing (ISO-8601 UTC) # BOT_COMMENT_RETRY_CLAIM_SETTLE_SEC — seconds to wait after posting a # bot-comment retry marker before re-listing markers @@ -78,6 +81,13 @@ set -euo pipefail # threads with no reply from our automation, and dispatches one deduplicated # fix-reviews pass per PR (lib/bot-thread-retry.sh). Threads that exhaust their # attempts get a single visible notice, so the stall is never silent. +# +# Each payload is gated on the target repo's own dev-lead.yml pin (#2086). The +# sweep runs one release of scripts/ (this repo's pinned channel), but every +# target parses a payload with dev-lead-intent.sh at its own pin. A payload +# carrying a client_payload field that parser does not read is held back, with +# a ::warning:: naming the field (lib/dispatch-channel-gate.sh); payloads every +# release reads still reach older-pinned repos. SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # Escalation gate (#946): pr_has_escalation_label / NEEDS_HUMAN_REVIEW_LABEL. @@ -96,6 +106,9 @@ source "$SCRIPT_DIR/lib/bot-comment-retry.sh" # Unreplied bot review-thread retry decision + thread fetch (#2046). # shellcheck source=lib/bot-thread-retry.sh source "$SCRIPT_DIR/lib/bot-thread-retry.sh" +# Per-target payload-field gate (#2086): dcg_init / dcg_payload_allowed / dcg_target_reads. +# shellcheck source=lib/dispatch-channel-gate.sh +source "$SCRIPT_DIR/lib/dispatch-channel-gate.sh" TARGET_ORG="${TARGET_ORG:-petry-projects}" DELEGATION_ORGS="${DELEGATION_ORGS:-}" @@ -231,6 +244,8 @@ dispatch_ci_retry() { app_slug: "github-actions", id: $check_run_id}] } }') + # The target's pinned parser must read every field sent (#2086). + dcg_payload_allowed "$repo" "$payload" || return 1 if ! echo "$payload" | gh api --method POST "repos/${repo}/dispatches" --input - >/dev/null 2>&1; then echo " [warn] dispatch failed for PR ${pr_number} in ${repo}" >&2 return 1 @@ -261,6 +276,8 @@ dispatch_reviews_retry() { intent_type: $intent_type } }') + # The target's pinned parser must read every field sent (#2086). + dcg_payload_allowed "$repo" "$payload" || return 1 if ! echo "$payload" | gh api --method POST "repos/${repo}/dispatches" --input - >/dev/null 2>&1; then echo " [warn] dispatch failed for PR ${pr_number} in ${repo}" >&2 return 1 @@ -292,6 +309,8 @@ dispatch_issue_retry() { attempt: $attempt } }') + # The target's pinned parser must read every field sent (#2086). + dcg_payload_allowed "$repo" "$payload" || return 1 if ! echo "$payload" | gh api --method POST "repos/${repo}/dispatches" --input - >/dev/null 2>&1; then echo " [warn] dispatch failed for issue ${issue_number} in ${repo}" >&2 fi @@ -589,6 +608,11 @@ withdraw_bot_comment_retry_marker() { fi } +# The client_payload keys dispatch_bot_comment_retry sends, so the scan can check +# the target reads them (#2086) BEFORE it posts a retry marker. Keep in step with +# the payload below (tests/dev-lead/unit/test_dispatch_channel_gate.bats checks). +BOT_COMMENT_RETRY_FIELDS=(pr_number head_sha repo intent_type comment_node_id) + # dispatch_bot_comment_retry # Re-dispatches a fix-bot-comment pass for ONE bot comment (#2017). Reuses the # dev-lead-reviews-retry type every caller stub already subscribes to, so no stub @@ -618,6 +642,8 @@ dispatch_bot_comment_retry() { comment_node_id: $comment_node_id } }') + # The target's pinned parser must read every field sent (#2086). + dcg_payload_allowed "$repo" "$payload" || return 1 if ! echo "$payload" | gh api --method POST "repos/${repo}/dispatches" --input - >/dev/null 2>&1; then echo " [warn] dispatch failed for PR ${pr_number} in ${repo}" >&2 return 1 @@ -763,6 +789,13 @@ scan_pr_for_undispositioned_bot_comments() { now_iso="${NOW_ISO:-$(date -u +%Y-%m-%dT%H:%M:%SZ)}" echo " [retry] bot-comment ${cid} ($(jq -r '.login' <<< "$pick")) on PR ${pr_number}: undispositioned → fix-bot-comment attempt ${attempt}" >&2 + # A target pinned to a parser that drops comment_node_id would fail the run + # (#2050); hold before the marker, which would block the comment for the whole + # pending window (#2086). + if ! dcg_target_reads "$repo" "${BOT_COMMENT_RETRY_FIELDS[@]}"; then + echo "0"; return 0 + fi + # Record the attempt BEFORE dispatching so a concurrent caller (the cron and # pr-review's gate verdict) sees it as pending and does not duplicate it. local marker_id="" @@ -1257,6 +1290,15 @@ main() { local repo_count="${#all_repos[@]}" echo "[retry] scanning ${repo_count} repo(s) across org(s)" + # Hold each retry payload back from a target whose pinned parser does not + # read all its fields (#2086). Off unless the workflow passes SWEEP_AGENT_REF. + dcg_init || exit 1 + if [ -n "$DCG_SWEEP_REF" ]; then + echo "[retry] sweep channel: ${DCG_SWEEP_REF} (a payload goes only to repos whose pinned parser reads all its fields)" + else + echo "[retry] SWEEP_AGENT_REF unset: per-target payload-field gate off (#2086)" + fi + local repo_index=0 for repo in "${all_repos[@]}"; do if [ "$repo_index" -gt 0 ] && [ "$DISPATCH_DELAY_SEC" -gt 0 ]; then diff --git a/scripts/lib/dispatch-channel-gate.sh b/scripts/lib/dispatch-channel-gate.sh new file mode 100644 index 000000000..437f744f9 --- /dev/null +++ b/scripts/lib/dispatch-channel-gate.sh @@ -0,0 +1,231 @@ +#!/usr/bin/env bash +# dispatch-channel-gate.sh — hold a retry payload back from a target repo whose +# pinned dev-lead harness does not read every field in it (#2086). +# +# WHY THIS EXISTS +# dev-lead-retry.yml runs scripts/ at ONE release: the channel this repo's +# dev-lead.yml pins via `agent_ref` (#2050). The sweep then sends +# repository_dispatch events to every repo in the org, and each target parses +# them with scripts/dev-lead-intent.sh at ITS OWN dev-lead.yml pin (the stub +# contract allows a per-repo ring/channel pin). A target pinned older than the +# sweep can be sent a client_payload field its parser drops — the #2017 / #2050 +# skew again, across repos instead of across time. +# +# WHAT THIS LIBRARY DECIDES +# Per payload, not per repo: most retry payloads carry only fields every +# release reads, so a target on an older channel (the fleet sits on stable) +# keeps its retries; only a payload with a field the target's parser lacks is +# held back. For each target: +# pin its dev-lead.yml `agent_ref` (the reusable's default, main, when +# the stub passes none) — the ref its harness checks scripts/ out at +# fields the `.client_payload.` reads in dev-lead-intent.sh and the +# lib/*.sh files it names, at that pin (comments stripped; the +# same reading scripts/check-retry-payload-fields.sh does in CI) +# A payload goes out only when the target's parser reads all its fields +# (INFORMATIONAL ones aside). Pin == the sweep's channel needs no lookup: the +# sweep and that parser ship together, and CI checks them against each other. +# no dev-lead.yml nothing in that repo receives it → hold, no warning +# (a 404 counts only when the repo itself is readable) +# pin or parser unreadable compatibility unproven → hold, warn (fail closed) +# a field not read hold, warn once per repo and field set +# A held payload leaves its markers in place, so the retry resumes once the +# target is repinned to a channel that reads the field. +# +# The gate is OFF (every payload allowed, no API reads) until dcg_init runs with +# SWEEP_AGENT_REF set — dev-lead-retry.yml passes its resolved channel. Unit +# tests and scripts/check-retry-payload-fields.sh source the sweep without it. +# +# Env: +# SWEEP_AGENT_REF the channel tag the sweep's scripts/ run at; enables the gate +# DCG_HOST_REPO repo hosting the dev-lead scripts and channel tags (default: +# $GITHUB_REPOSITORY, else petry-projects/.github-private) + +DCG_HOST_REPO="${DCG_HOST_REPO:-${GITHUB_REPOSITORY:-petry-projects/.github-private}}" +# Same channel-tag shape dev-lead-retry.yml accepts for the sweep's own pin. +DCG_CHANNEL_RE='^dev-lead/v[0-9]+-(next|ring0|ring1|stable)$' +DCG_PARSER="scripts/dev-lead-intent.sh" +# Sent for logs and audit only; no harness code reads them. Mirrors +# INFORMATIONAL_FIELDS in scripts/check-retry-payload-fields.sh. +DCG_INFORMATIONAL_FIELDS=(repo attempt) +DCG_SWEEP_REF="" +# Per-sweep cache. The sweep runs its scans and these lookups inside command +# substitutions (subshells), where a shell variable written is lost on return, so +# the cache lives in files: pin. = ref | "!", fields. = keys | "!", +# warned.. dcg_init makes it; without it nothing is cached. +DCG_CACHE_DIR="${DCG_CACHE_DIR:-}" + +_dcg_cache_file() { + printf '%s/%s.%s' "$DCG_CACHE_DIR" "$1" "$(printf '%s' "$2" | sha256sum | cut -c1-32)" +} +# _dcg_cache_get : prints the cached value; returns 1 on a miss. +_dcg_cache_get() { + [ -n "$DCG_CACHE_DIR" ] || return 1 + local f + f="$(_dcg_cache_file "$1" "$2")" + [ -f "$f" ] || return 1 + cat -- "$f" +} +# _dcg_cache_put +_dcg_cache_put() { + [ -n "$DCG_CACHE_DIR" ] || return 0 + printf '%s' "$3" >"$(_dcg_cache_file "$1" "$2")" 2>/dev/null || true +} + +# dcg_init: turn the gate on for the sweep's channel. Returns 1 when +# SWEEP_AGENT_REF is set but is not a channel tag (a configuration error). +dcg_init() { + DCG_SWEEP_REF="" + [ -n "${SWEEP_AGENT_REF:-}" ] || return 0 + if [[ ! "$SWEEP_AGENT_REF" =~ $DCG_CHANNEL_RE ]]; then + echo "::error::[retry] SWEEP_AGENT_REF '${SWEEP_AGENT_REF}' is not a dev-lead channel tag (dev-lead/v-next|ring0|ring1|stable)" >&2 + return 1 + fi + DCG_SWEEP_REF="$SWEEP_AGENT_REF" + if [ -z "$DCG_CACHE_DIR" ]; then + DCG_CACHE_DIR="$(mktemp -d "${RUNNER_TEMP:-${TMPDIR:-/tmp}}/dcg.XXXXXX")" || return 1 + fi +} + +# dcg_parse_agent_ref: stdin = dev-lead.yml text; prints the ref its harness +# runs at — `agent_ref`, else the reusable's default `main`. Returns 1 when the +# value is not a plain ref (an expression, say), so it cannot be resolved here. +dcg_parse_agent_ref() { + local text ref + text="$(cat)" + ref="$(sed -nE "s/^[[:space:]]*agent_ref:[[:space:]]*[\"']?([^\"'[:space:]#]*).*/\1/p" <<<"$text" | head -n 1)" + if ! grep -qE "^[[:space:]]*agent_ref:" <<<"$text"; then + ref="main" + fi + [[ "$ref" =~ ^[A-Za-z0-9][A-Za-z0-9._/-]*$ ]] || return 1 + printf '%s\n' "$ref" +} + +# dcg_target_pin : prints the ref 's dev-lead harness runs at. +# Returns 2 when the (readable) repo has no dev-lead.yml, 1 when the pin is +# unreadable — the repo itself included. +# Cached per sweep. +dcg_target_pin() { + local repo="$1" out rc=0 + if out="$(_dcg_cache_get pin "$repo")"; then + if [[ "$out" == "!"* ]]; then return "${out#!}"; fi + printf '%s\n' "$out"; return 0 + fi + out="$(gh api "repos/${repo}/contents/.github/workflows/dev-lead.yml" \ + -H "Accept: application/vnd.github.raw" 2>&1)" || rc=$? + if [ "$rc" -ne 0 ]; then + rc=1 + # GitHub also answers 404 for a repo the token cannot read; only a readable + # repo's 404 means the stub is absent. + if [[ "$out" == *"HTTP 404"* ]] \ + && gh api "repos/${repo}" --jq '.full_name' >/dev/null 2>&1; then + rc=2 + fi + elif ! out="$(dcg_parse_agent_ref <<<"$out")"; then + rc=1 + fi + if [ "$rc" -ne 0 ]; then + _dcg_cache_put pin "$repo" "!${rc}" + return "$rc" + fi + _dcg_cache_put pin "$repo" "$out" + printf '%s\n' "$out" +} + +# _dcg_fetch_at : raw file content from the host repo at . +_dcg_fetch_at() { + local path="$1" ref="$2" enc + enc="$(jq -rn --arg r "$ref" '$r | @uri')" || return 1 + gh api "repos/${DCG_HOST_REPO}/contents/${path}?ref=${enc}" \ + -H "Accept: application/vnd.github.raw" 2>/dev/null +} + +# dcg_fields_read_at : the top-level client_payload keys the intent parser +# (and the lib/*.sh files it names, transitively) reads at , one per line. +# Returns 1 when any of those files cannot be read. Cached per sweep. +dcg_fields_read_at() { + local ref="$1" src all="" lib + if all="$(_dcg_cache_get fields "$ref")"; then + [ "$all" != "!" ] || return 1 + [ -z "$all" ] || printf '%s\n' "$all" + return 0 + fi + all="" + local -a queue=("$DCG_PARSER") seen=() + while [ "${#queue[@]}" -gt 0 ]; do + lib="${queue[0]}"; queue=("${queue[@]:1}") + case " ${seen[*]} " in *" $lib "*) continue ;; esac + seen+=("$lib") + if ! src="$(_dcg_fetch_at "$lib" "$ref")" || [ -z "$src" ]; then + _dcg_cache_put fields "$ref" "!" + return 1 + fi + # Drop shell comments (not `$#`), so prose naming a field is not a read. + src="$(sed -E 's/(^|[^$])#.*$/\1/' <<<"$src")" + all+="$src"$'\n' + mapfile -t -O "${#queue[@]}" queue < <(grep -oE '/lib/[A-Za-z0-9_.-]+\.sh' <<<"$src" | sed 's|^|scripts|' | sort -u) + done + all="$(grep -oE '\.client_payload\.[A-Za-z_][A-Za-z0-9_]*' <<<"$all" \ + | sed 's/^\.client_payload\.//' | sort -u || true)" + [ -z "$all" ] || all+=$'\n' + _dcg_cache_put fields "$ref" "$all" + printf '%s' "$all" +} + +# _dcg_warn_once : a ::warning:: per key per sweep, so a repo with +# many held PRs logs its reason once. +_dcg_warn_once() { + ! _dcg_cache_get warned "$1" >/dev/null || return 0 + _dcg_cache_put warned "$1" 1 + echo "::warning::$2" >&2 +} + +# dcg_target_reads ...: 0 when 's pinned parser reads every +# field (informational ones aside), or the gate is off; 1 to hold the payload. +dcg_target_reads() { + local repo="$1"; shift + [ -n "$DCG_SWEEP_REF" ] || return 0 + local pin rc=0 + pin="$(dcg_target_pin "$repo")" || rc=$? + case "$rc" in + 0) ;; + 2) + echo " [hold] ${repo}: no dev-lead.yml, so nothing there receives a retry (#2086)" >&2 + return 1 ;; + *) + _dcg_warn_once "pin:${repo}" "[retry] holding retries for ${repo}: cannot read the ref its dev-lead.yml runs the harness at (agent_ref), so the fields its parser reads are unproven (#2086)" + return 1 ;; + esac + [ "$pin" != "$DCG_SWEEP_REF" ] || return 0 + + local fields + if ! fields="$(dcg_fields_read_at "$pin")"; then + _dcg_warn_once "fields:${pin}" "[retry] holding retries for repos pinned to ${pin}: cannot read ${DCG_PARSER} and its libs there, so the fields it reads are unproven (#2086)" + return 1 + fi + local f info missing=() + for f in "$@"; do + [ -n "$f" ] || continue + for info in "${DCG_INFORMATIONAL_FIELDS[@]}"; do + [ "$f" != "$info" ] || continue 2 + done + grep -qxF -- "$f" <<<"$fields" || missing+=("$f") + done + [ "${#missing[@]}" -eq 0 ] && return 0 + _dcg_warn_once "miss:${repo}:${missing[*]}" "[retry] holding retries for ${repo}: it pins ${pin}, whose ${DCG_PARSER} does not read client_payload field(s) ${missing[*]} that the sweep at ${DCG_SWEEP_REF} sends (#2086). Repin it to a channel that reads them." + echo " [hold] ${repo} (${pin}) does not read: ${missing[*]}" >&2 + return 1 +} + +# dcg_payload_allowed : dcg_target_reads over the top-level +# client_payload keys of a repository_dispatch body. +dcg_payload_allowed() { + local repo="$1" body="$2" keys + [ -n "$DCG_SWEEP_REF" ] || return 0 + if ! keys="$(jq -r '.client_payload | keys[]' <<<"$body" 2>/dev/null)"; then + echo " [hold] ${repo}: unreadable retry payload (#2086)" >&2 + return 1 + fi + local -a fields=() + mapfile -t fields <<<"$keys" + dcg_target_reads "$repo" "${fields[@]}" +} diff --git a/tests/dev-lead/unit/test_bot_comment_retry.bats b/tests/dev-lead/unit/test_bot_comment_retry.bats index eeaa23fbb..45e57adea 100644 --- a/tests/dev-lead/unit/test_bot_comment_retry.bats +++ b/tests/dev-lead/unit/test_bot_comment_retry.bats @@ -477,6 +477,31 @@ _graphql_page() { [ "$marker_line" -lt "$dispatch_line" ] } +@test "sweep(#2086): a target whose pinned parser drops comment_node_id gets no retry marker and no dispatch" { + _setup_sweep + export TRUSTED_BOTS="$TRUSTED" + export GRAPHQL_RESPONSE + GRAPHQL_RESPONSE="$(_graphql_page "$(_bot IC_cr coderabbitai 'Walkthrough')")" + # Gate on; the target pins an older channel whose parser reads every retry + # field except comment_node_id (the #2050 skew). + DCG_SWEEP_REF=dev-lead/v139-ring0 + dcg_target_pin() { echo dev-lead/v1-stable; } + dcg_fields_read_at() { printf '%s\n' checks head_sha intent_type issue_number pr_number; } + + run scan_pr_for_undispositioned_bot_comments "petry-projects/.github-private" 2009 + [ "$status" -eq 0 ] + [ "${lines[-1]}" = "0" ] + [[ "$output" == *"::warning::"*"comment_node_id"* ]] + if grep -q '/dispatches' "$GH_LOG"; then cat "$GH_LOG"; return 1; fi + if grep -q 'dev-lead-bot-comment-retry' "$GH_LOG"; then cat "$GH_LOG"; return 1; fi + + # Once that channel reads the field, the same scan dispatches. + dcg_fields_read_at() { printf '%s\n' checks comment_node_id head_sha intent_type issue_number pr_number; } + run scan_pr_for_undispositioned_bot_comments "petry-projects/.github-private" 2009 + [ "${lines[-1]}" = "1" ] + [ "$(grep -c '/dispatches' "$GH_LOG")" -eq 1 ] +} + @test "sweep(#2089): a dev-lead:hands-off PR gets no retry marker and no dispatch" { export PR_JSON='{"state":"open","head":{"sha":"abc","ref":"dev-lead/issue-2008-x","repo":{"full_name":"petry-projects/.github-private"}},"user":{"login":"don-petry"},"labels":[{"name":"dev-lead:hands-off"}]}' _setup_sweep diff --git a/tests/dev-lead/unit/test_dispatch_channel_gate.bats b/tests/dev-lead/unit/test_dispatch_channel_gate.bats new file mode 100644 index 000000000..7822115a9 --- /dev/null +++ b/tests/dev-lead/unit/test_dispatch_channel_gate.bats @@ -0,0 +1,394 @@ +#!/usr/bin/env bats +# Per-target payload-field gate for the dev-lead retry sweep (#2086). +# +# The sweep runs one release of scripts/ (the channel this repo's dev-lead.yml +# pins, #2050) but sends repository_dispatch events to every repo in the org, and +# each target parses them with dev-lead-intent.sh at ITS OWN dev-lead.yml pin. A +# payload carrying a client_payload field that parser does not read must be held +# back; a payload of fields every release reads must still reach older-pinned +# repos (the fleet sits on stable). These tests pin: +# • agent_ref parsing (any plain ref; the reusable's default `main` when absent); +# • pin reads: found / no stub (404 on a readable repo) / unreadable repo / +# read error / unresolvable, cached; +# • the fields a ref's parser reads: parser + libs it names, comments ignored, +# an unreadable file fails closed, cached, ref URL-encoded; +# • the decision: gate off without SWEEP_AGENT_REF, same pin skips the lookup, +# a missing field holds with one warning naming it, informational fields ignored; +# • every dispatcher is gated, and BOT_COMMENT_RETRY_FIELDS matches its payload; +# • main(): every repo is still scanned; an invalid SWEEP_AGENT_REF is an error. +# +# Run: bats tests/dev-lead/unit/test_dispatch_channel_gate.bats + +REPO_ROOT="$(cd "$(dirname "$BATS_TEST_FILENAME")/../../.." && pwd)" +RETRY_SCRIPT="$REPO_ROOT/scripts/dev-lead-retry.sh" + +# The v1 parser reads every field except comment_node_id (the #2050 skew). +V1_FIELDS=(pr_number issue_number head_sha checks intent_type) + +setup() { + MOCK_BIN="$(mktemp -d)" + export MOCK_BIN + export PATH="$MOCK_BIN:$PATH" + mkdir -p "$MOCK_BIN/stubs" "$MOCK_BIN/src" "$MOCK_BIN/unreadable" + : >"$MOCK_BIN/calls" + export DRY_RUN="false" + export DISPATCH_DELAY_SEC="0" + export TARGET_ORG="petry-projects" + export DCG_HOST_REPO="petry-projects/.github-private" + unset SWEEP_AGENT_REF DELEGATION_ORGS GITHUB_REPOSITORY + # The per-sweep cache dcg_init would make. + export DCG_CACHE_DIR="$BATS_TEST_TMPDIR/dcg" + mkdir -p "$DCG_CACHE_DIR" + + # gh stub. Every call is logged to $MOCK_BIN/calls; an unexpected call fails + # loudly (exit 97) so a test never passes on a silent default. + # repos//contents/?ref= + # -> $MOCK_BIN/src//; missing -> HTTP 404 + # repos///contents/.github/workflows/dev-lead.yml + # -> $MOCK_BIN/stubs/__; missing -> HTTP 404; + # a file containing exactly ERROR -> HTTP 502 + # repos// --jq .full_name -> readable, unless + # $MOCK_BIN/unreadable/__ exists (HTTP 404) + # repos//dispatches -> body appended to $MOCK_BIN/dispatched + # repo list -> $REPO_LIST_JSON + cat >"$MOCK_BIN/gh" <<'GHEOF' +#!/usr/bin/env bash +printf '%s\n' "$*" >>"$MOCK_BIN/calls" +args="$*" +case "$args" in + "repo list"*) printf '%s' "${REPO_LIST_JSON:-[]}"; exit 0 ;; + *"/dispatches"*) cat >>"$MOCK_BIN/dispatched"; echo >>"$MOCK_BIN/dispatched"; exit 0 ;; + *"contents/"*"?ref="*) + p="${args#*/contents/}"; p="${p%% *}" + ref="${p#*\?ref=}"; path="${p%%\?ref=*}" + f="$MOCK_BIN/src/${ref}/${path//\//__}" + if [ ! -f "$f" ]; then echo "gh: Not Found (HTTP 404)" >&2; exit 1; fi + cat "$f"; exit 0 ;; + *contents/.github/workflows/dev-lead.yml*) + repo="${args#*repos/}"; repo="${repo%%/contents/*}" + f="$MOCK_BIN/stubs/${repo//\//__}" + if [ ! -f "$f" ]; then + printf '{"message":"Not Found","status":"404"}' + echo "gh: Not Found (HTTP 404)" >&2 + exit 1 + fi + if [ "$(cat "$f")" = "ERROR" ]; then + echo "gh: Bad Gateway (HTTP 502)" >&2 + exit 1 + fi + cat "$f"; exit 0 ;; + "api repos/"*" --jq .full_name") + repo="${args#api repos/}"; repo="${repo%% *}" + if [ -f "$MOCK_BIN/unreadable/${repo//\//__}" ]; then echo "gh: Not Found (HTTP 404)" >&2; exit 1; fi + echo "$repo"; exit 0 ;; + *) echo "mock gh: unexpected call: $args" >&2; exit 97 ;; +esac +GHEOF + chmod +x "$MOCK_BIN/gh" + + # shellcheck disable=SC1090 + source "$RETRY_SCRIPT" +} + +teardown() { + rm -rf "$MOCK_BIN" +} + +# _pin : give a repo a dev-lead.yml caller stub. +_pin() { + cat >"$MOCK_BIN/stubs/${1//\//__}" < ...: the host repo's dev-lead-intent.sh at reads +# s — the last one through a lib it sources. +_parser() { + local ref="$1" enc; shift + enc="$(jq -rn --arg r "$ref" '$r | @uri')" + mkdir -p "$MOCK_BIN/src/$enc" + local f last="" body="" + for f in "$@"; do last="$f"; done + for f in "$@"; do + [ "$f" = "$last" ] && continue + body+=" ${f}=\$(jq -r '.client_payload.${f} // empty' \"\$EVENT_PATH\")"$'\n' + done + { + echo '#!/usr/bin/env bash' + echo '# .client_payload.comment_node_id is named only in this comment' + echo 'source "$(dirname "$0")/lib/helper.sh"' + echo 'parse() {' + printf '%s' "$body" + echo '}' + } >"$MOCK_BIN/src/$enc/scripts__dev-lead-intent.sh" + printf 'helper() { jq -r %s; }\n' "'.client_payload.${last} // empty'" \ + >"$MOCK_BIN/src/$enc/scripts__lib__helper.sh" +} + +_calls() { grep -c -- "$1" "$MOCK_BIN/calls" || true; } + +# ── dcg_parse_agent_ref ────────────────────────────────────────────────────── + +@test "parse: returns the agent_ref of a caller stub" { + local ch out + for ch in dev-lead/v139-stable dev-lead/v1-next dev-lead/v7-ring0 main 4fd4eac; do + _pin o/r "$ch" + out="$(dcg_parse_agent_ref <"$MOCK_BIN/stubs/o__r")" + [ "$out" = "$ch" ] + done +} + +@test "parse: accepts a quoted agent_ref with a trailing comment" { + out="$(printf ' with:\n agent_ref: "dev-lead/v139-ring1" # pin\n' | dcg_parse_agent_ref)" + [ "$out" = "dev-lead/v139-ring1" ] +} + +@test "parse: no agent_ref → the reusable's default, main" { + out="$(printf 'jobs:\n dev-lead:\n uses: o/r/.github/workflows/dev-lead-reusable.yml@dev-lead/v1-stable\n' | dcg_parse_agent_ref)" + [ "$out" = "main" ] +} + +@test "parse: reads this repo's live dev-lead.yml stub" { + run dcg_parse_agent_ref <"$REPO_ROOT/.github/workflows/dev-lead.yml" + [ "$status" -eq 0 ] + [[ "$output" =~ ^dev-lead/v[0-9]+-(next|ring0|ring1|stable)$ ]] +} + +@test "parse: rejects an empty or non-literal agent_ref" { + local bad + for bad in '' '${{ inputs.ref }}' '../../x' '-x'; do + run dcg_parse_agent_ref <<<" agent_ref: $bad" + [ "$status" -ne 0 ] + [ -z "$output" ] + done +} + +# ── dcg_target_pin ─────────────────────────────────────────────────────────── + +@test "pin: returns the repo's agent_ref, read once per sweep" { + _pin petry-projects/markets dev-lead/v139-ring1 + run dcg_target_pin petry-projects/markets + [ "$status" -eq 0 ] + [ "$output" = "dev-lead/v139-ring1" ] + dcg_target_pin petry-projects/markets >/dev/null + [ "$(dcg_target_pin petry-projects/markets)" = "dev-lead/v139-ring1" ] + [ "$(_calls 'repos/petry-projects/markets/contents')" -eq 1 ] +} + +@test "pin: a 404 from a repo the token cannot read is unreadable (1), not absent" { + touch "$MOCK_BIN/unreadable/petry-projects__private" + run dcg_target_pin petry-projects/private + [ "$status" -eq 1 ] + DCG_SWEEP_REF=dev-lead/v139-ring0 + run dcg_target_reads petry-projects/private pr_number + [ "$status" -eq 1 ] + [[ "$output" == *"::warning::"* ]] +} + +@test "pin: no dev-lead.yml → 2; read error or unresolvable pin → 1" { + run dcg_target_pin petry-projects/no-stub + [ "$status" -eq 2 ] + echo ERROR >"$MOCK_BIN/stubs/petry-projects__flaky" + run dcg_target_pin petry-projects/flaky + [ "$status" -eq 1 ] + _pin petry-projects/expr '${{ inputs.ref }}' + run dcg_target_pin petry-projects/expr + [ "$status" -eq 1 ] +} + +# ── dcg_fields_read_at ─────────────────────────────────────────────────────── + +@test "fields: the parser's reads plus its libs', not names in comments" { + _parser dev-lead/v1-stable "${V1_FIELDS[@]}" + run dcg_fields_read_at dev-lead/v1-stable + [ "$status" -eq 0 ] + [ "$output" = "$(printf '%s\n' "${V1_FIELDS[@]}" | sort -u)" ] + # The ref is URL-encoded and read from the host repo. + grep -q 'repos/petry-projects/.github-private/contents/scripts/lib/helper.sh?ref=dev-lead%2Fv1-stable' "$MOCK_BIN/calls" +} + +@test "fields: a lib that cannot be read fails closed" { + _parser dev-lead/v1-stable "${V1_FIELDS[@]}" + rm "$MOCK_BIN/src/dev-lead%2Fv1-stable/scripts__lib__helper.sh" + run dcg_fields_read_at dev-lead/v1-stable + [ "$status" -eq 1 ] +} + +@test "fields: cached per ref within a sweep" { + _parser dev-lead/v1-stable "${V1_FIELDS[@]}" + dcg_fields_read_at dev-lead/v1-stable >/dev/null + dcg_fields_read_at dev-lead/v1-stable >/dev/null + [ "$(_calls 'dev-lead-intent.sh?ref=')" -eq 1 ] +} + +# ── dcg_init / dcg_target_reads ────────────────────────────────────────────── + +@test "init: unset → gate off; a channel tag → on with a cache dir; anything else is an error" { + dcg_init + [ -z "$DCG_SWEEP_REF" ] + DCG_CACHE_DIR="" + SWEEP_AGENT_REF=dev-lead/v139-ring0 RUNNER_TEMP="$BATS_TEST_TMPDIR" dcg_init + [ "$DCG_SWEEP_REF" = "dev-lead/v139-ring0" ] + [ -d "$DCG_CACHE_DIR" ] + [[ "$DCG_CACHE_DIR" == "$BATS_TEST_TMPDIR"/dcg.* ]] + SWEEP_AGENT_REF=main run dcg_init + [ "$status" -eq 1 ] + [[ "$output" == *"::error::"* ]] +} + +@test "reads: gate off → allowed with no API call" { + run dcg_target_reads petry-projects/broodly comment_node_id + [ "$status" -eq 0 ] + [ ! -s "$MOCK_BIN/calls" ] +} + +@test "reads: pinned to the sweep's channel → allowed without reading its parser" { + DCG_SWEEP_REF=dev-lead/v139-ring0 + _pin petry-projects/x dev-lead/v139-ring0 + run dcg_target_reads petry-projects/x comment_node_id + [ "$status" -eq 0 ] + [ "$(_calls 'dev-lead-intent.sh')" -eq 0 ] +} + +@test "reads: an older pin whose parser reads every field → allowed" { + DCG_SWEEP_REF=dev-lead/v139-ring0 + _pin petry-projects/broodly dev-lead/v1-stable + _parser dev-lead/v1-stable "${V1_FIELDS[@]}" + run dcg_target_reads petry-projects/broodly pr_number head_sha repo intent_type + [ "$status" -eq 0 ] + [[ "$output" != *"::warning::"* ]] +} + +@test "reads: informational fields (repo, attempt) need no reader" { + DCG_SWEEP_REF=dev-lead/v139-ring0 + _pin petry-projects/broodly dev-lead/v1-stable + _parser dev-lead/v1-stable issue_number + run dcg_target_reads petry-projects/broodly issue_number repo attempt + [ "$status" -eq 0 ] +} + +@test "reads: a field the pinned parser does not read → held, one warning naming it" { + DCG_SWEEP_REF=dev-lead/v139-ring0 + _pin petry-projects/broodly dev-lead/v1-stable + _parser dev-lead/v1-stable "${V1_FIELDS[@]}" + local err="$BATS_TEST_TMPDIR/err" rc=0 + dcg_target_reads petry-projects/broodly pr_number comment_node_id 2>"$err" || rc=$? + [ "$rc" -eq 1 ] + dcg_target_reads petry-projects/broodly pr_number comment_node_id 2>>"$err" || rc=$? + [ "$(grep -c '::warning::' "$err")" -eq 1 ] + grep -q '::warning::.*petry-projects/broodly.*dev-lead/v1-stable.*comment_node_id' "$err" +} + +@test "reads: the cache survives the subshells the sweep calls it in" { + DCG_SWEEP_REF=dev-lead/v139-ring0 + _pin petry-projects/broodly dev-lead/v1-stable + _parser dev-lead/v1-stable "${V1_FIELDS[@]}" + local i out + for i in 1 2 3; do + out="$(dcg_target_reads petry-projects/broodly pr_number comment_node_id 2>&1 || true)" + done + [ "$(_calls 'repos/petry-projects/broodly/contents')" -eq 1 ] + [ "$(_calls 'dev-lead-intent.sh?ref=')" -eq 1 ] + [ "$(_calls 'helper.sh?ref=')" -eq 1 ] + # ...and so does warn-once: the later calls only log the hold. + [[ "$out" != *"::warning::"* ]] + [[ "$out" == *"[hold] petry-projects/broodly"* ]] +} + +@test "reads: no dev-lead.yml → held without a warning" { + DCG_SWEEP_REF=dev-lead/v139-ring0 + run dcg_target_reads petry-projects/no-stub pr_number + [ "$status" -eq 1 ] + [[ "$output" != *"::warning::"* ]] +} + +@test "reads: unreadable pin or parser → held with a warning (fail closed)" { + DCG_SWEEP_REF=dev-lead/v139-ring0 + echo ERROR >"$MOCK_BIN/stubs/petry-projects__flaky" + run dcg_target_reads petry-projects/flaky pr_number + [ "$status" -eq 1 ] + [[ "$output" == *"::warning::"* ]] + _pin petry-projects/old dev-lead/v1-ring1 # no parser source at that ref + run dcg_target_reads petry-projects/old pr_number + [ "$status" -eq 1 ] + [[ "$output" == *"::warning::"* ]] +} + +# ── dispatchers ────────────────────────────────────────────────────────────── + +_v1_target() { + DCG_SWEEP_REF=dev-lead/v139-ring0 + _pin petry-projects/broodly dev-lead/v1-stable + _parser dev-lead/v1-stable "${V1_FIELDS[@]}" +} + +@test "dispatch: a bot-comment retry is held from a target that drops comment_node_id" { + _v1_target + run dispatch_bot_comment_retry petry-projects/broodly 7 abc IC_x + [ "$status" -eq 1 ] + [ ! -s "$MOCK_BIN/dispatched" ] +} + +@test "dispatch: reviews, CI and issue retries still reach a v1-pinned target" { + _v1_target + lookup_check_run_details() { echo '{"id":"","details_url":""}'; } + dispatch_reviews_retry petry-projects/broodly 7 abc fix-reviews + dispatch_ci_retry petry-projects/broodly 7 abc "CI" + dispatch_issue_retry petry-projects/broodly 9 1 + [ "$(grep -c 'client_payload' "$MOCK_BIN/dispatched")" -eq 3 ] +} + +@test "dispatch: every dispatcher in the sweep consults the gate" { + local fn n=0 + for fn in $(declare -F | awk '{print $3}'); do + # A sweep dispatcher POSTs its payload to the target's /dispatches. + declare -f "$fn" | grep -qF 'POST "repos/${repo}/dispatches"' || continue + n=$((n + 1)) + declare -f "$fn" | grep -q 'dcg_payload_allowed' || { echo "ungated: $fn"; return 1; } + done + [ "$n" -ge 4 ] +} + +@test "BOT_COMMENT_RETRY_FIELDS matches dispatch_bot_comment_retry's payload" { + dispatch_bot_comment_retry petry-projects/x 7 abc IC_x + [ "$(jq -r '.client_payload | keys[]' "$MOCK_BIN/dispatched" | sort)" \ + = "$(printf '%s\n' "${BOT_COMMENT_RETRY_FIELDS[@]}" | sort)" ] +} + +# ── main() wiring ──────────────────────────────────────────────────────────── + +@test "main: with the gate on, every repo is still scanned" { + export REPO_LIST_JSON='["petry-projects/.github-private","petry-projects/broodly"]' + scan_repo() { echo "SCANNED $1"; } + SWEEP_AGENT_REF=dev-lead/v139-ring0 run main + [ "$status" -eq 0 ] + [[ "$output" == *"sweep channel: dev-lead/v139-ring0"* ]] + [[ "$output" == *"SCANNED petry-projects/.github-private"* ]] + [[ "$output" == *"SCANNED petry-projects/broodly"* ]] +} + +@test "main: SWEEP_AGENT_REF unset → gate off, logged" { + export REPO_LIST_JSON='["petry-projects/a"]' + scan_repo() { echo "SCANNED $1"; } + run main + [ "$status" -eq 0 ] + [[ "$output" == *"gate off"* ]] + [[ "$output" == *"SCANNED petry-projects/a"* ]] +} + +@test "main: an invalid SWEEP_AGENT_REF fails the sweep before any scan" { + export REPO_LIST_JSON='["petry-projects/a"]' + scan_repo() { echo "SCANNED $1"; } + SWEEP_AGENT_REF='dev-lead/v139-canary' run main + [ "$status" -ne 0 ] + [[ "$output" != *"SCANNED"* ]] +} + +@test "workflow: the run step passes the resolved channel as SWEEP_AGENT_REF" { + grep -q 'SWEEP_AGENT_REF: \${{ steps.channel.outputs.agent_ref }}' \ + "$REPO_ROOT/.github/workflows/dev-lead-retry.yml" +}