diff --git a/test/labels-triggers.test.sh b/test/labels-triggers.test.sh new file mode 100644 index 0000000..9367bb1 --- /dev/null +++ b/test/labels-triggers.test.sh @@ -0,0 +1,86 @@ +#!/usr/bin/env bash +set -u + +# The labels TRIGGER SURFACE is a cost lever (#199): a full-board sweep is +# billed a 1-minute minimum every time a trigger fires, so how OFTEN it fires +# is what exhausted the fleet's shared Actions allotment. These assertions +# pin the reductions #199 made and the guard it must not trade away — none of +# them touch the reconciler's LOGIC, which its own fixtures cover. + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +# shellcheck source=test/harness.sh +source "$ROOT/test/harness.sh" + +REUSABLE="$ROOT/.github/workflows/labels.yml" +SELF="$ROOT/.github/workflows/self-labels.yml" +STUB="$ROOT/docs/CONSUMERS.md" # the published caller stub, a fenced yaml block + +# The `cancel-in-progress:` value of a named top-level job, read from the first +# such line inside that job's block. Job keys sit at two-space indent. +job_cancel_in_progress() { # $1 = file, $2 = job name + awk -v job="^ $2:\$" ' + $0 ~ job { f = 1; next } + f && /^ [a-z]/ { exit } # next job — stop before leaking into it + f && /cancel-in-progress:/ { sub(/.*cancel-in-progress:[[:space:]]*/, ""); print; exit } + ' "$1" +} + +# The `types:` list of a trigger key (issues:, pull_request_target:), read from +# the first `types:` line after the bare key. The key is bare (nothing after +# the colon) so it never collides with `issues: write` in the permissions block. +trigger_types() { # $1 = file, $2 = trigger key + awk -v key="^ $2:\$" ' + $0 ~ key { f = 1; next } + f && /^ types:/ { sub(/^ types:[[:space:]]*/, ""); print; exit } + f && /^ [a-z]/ { exit } + ' "$1" +} + +# ---- the guard the cost fix must never trade away (#199 test plan must-fail) -- +# cancel-in-progress: true on reconcile kills a sweep mid-board, the exact race +# the shared concurrency group exists to prevent. It WOULD cut run count — by +# trading correctness for minutes — so it stays false, forever. +check "reconcile serializes, never cancels mid-board" 0 "false" \ + job_cancel_in_progress "$REUSABLE" reconcile +# shellcheck disable=SC2016 # the awk program runs in the nested bash, not here +check "reconcile is never cancel-in-progress: true" 1 "" \ + bash -c 'job_cancel_in_progress() { + awk -v job="^ reconcile:\$" "\$0 ~ job{f=1;next} f&&/^ [a-z]/{exit} f&&/cancel-in-progress:/{sub(/.*cancel-in-progress:[[:space:]]*/,\"\");print;exit}" "$1" + }; [ "$(job_cancel_in_progress "$1")" = true ]' _ "$REUSABLE" +# scope MAY cancel — it is per-PR and additive, so a superseded run is waste, +# not a lost sweep. This asserts the must-fail above is scoped to reconcile. +check "scope stays cancel-in-progress: true (per-PR, additive)" 0 "true" \ + job_cancel_in_progress "$REUSABLE" scope + +# ---- the cron is a backstop, relaxed to hourly (#199 candidate 1) ----------- +# Scope the */15 assertion to the cron LINE — the prose comments cite */15 by +# name to explain the change, and must not re-red their own documentation. +check "self caller cron is hourly" 0 '0 * * * *' grep -F 'cron:' "$SELF" +# shellcheck disable=SC2016 # $1 expands in the nested bash, not here +check "self caller cron line no longer fires */15" 1 "" \ + bash -c 'grep -F "cron:" "$1" | grep -qF "*/15"' _ "$SELF" +check "stub cron is hourly" 0 '0 * * * *' grep -F 'cron:' "$STUB" +# shellcheck disable=SC2016 # $1 expands in the nested bash, not here +check "stub cron line no longer fires */15" 1 "" \ + bash -c 'grep -F "cron:" "$1" | grep -qF "*/15"' _ "$STUB" + +# ---- issues: is narrowed to the two promptness-critical actions (#199) ------- +# opened → mint→needs-triage; closed → blocker-closes→ready self-heal. The +# churn actions must not reappear on the issues surface without a fresh why. +# (labels.test.sh owns the exact-list and caller<->stub parity assertions; here +# we name each dropped action so its return produces a #199-specific failure.) +for churn in labeled unlabeled assigned unassigned edited reopened; do + # shellcheck disable=SC2016 # the awk program runs in the nested bash, not here + check "self caller issues surface drops '$churn'" 1 "" \ + bash -c 'trigger_types() { + awk -v key="^ issues:\$" "\$0 ~ key{f=1;next} f&&/^ types:/{sub(/^ types:[[:space:]]*/,\"\");print;exit} f&&/^ [a-z]/{exit}" "$1" + }; trigger_types "$1" | grep -qw "$2"' _ "$SELF" "$churn" +done + +# ---- the PR handoff wake is NOT collateral of the issues narrowing ---------- +# The handoff (state:needs-human, confirmed by the caller's labeled event) rides +# pull_request_target, not issues. A future edit that strips it there re-reds. +check "pull_request_target keeps the labeled handoff wake" 0 "labeled" \ + trigger_types "$SELF" pull_request_target + +summary diff --git a/test/labels.test.sh b/test/labels.test.sh index 79f53fa..9abd180 100755 --- a/test/labels.test.sh +++ b/test/labels.test.sh @@ -69,15 +69,15 @@ check "LABELS.md enumerates no repo's scope labels" 1 "0" \ # same lists. review_requested/review_request_removed are the wake that # clears blocker:unrequested — the label sat false for as long as a quiet # repo stayed quiet because the one event that falsifies it was never -# listed (#137). edited/reopened are the wakes for the two events that -# falsify issue labels silently — an edited body rewrites the `Blocked by -# #N` declaration the reconcile sweep parses, and a reopened issue -# re-enters the queue wearing labels derived at close; PR #32 widened the -# caller by both and the stub never followed (#144). The stub is prose, so -# nothing but these rows keeps the lists from drifting: a type in one file -# only is a wake that fires at home and nowhere in the fleet, or the -# reverse. The NF guard keeps `issues: write` under permissions: from -# matching the issues: trigger key. +# listed (#137). The issues list narrowed to [opened, closed] (#199): those +# two carry reconcile behavior the hourly cron cannot wait one cadence for — +# opened drives mint→needs-triage, closed drives the blocker-closes→ready +# self-heal — while the churn actions (labeled/unlabeled/assigned/unassigned/ +# edited/reopened) came off. The stub is prose, so nothing but these rows +# keeps the lists from drifting: a type in one file only is a wake that fires +# at home and nowhere in the fleet, or the reverse — the drift #144 caught. +# The NF guard keeps `issues: write` under permissions: from matching the +# issues: trigger key. event_types() { # $1 = file, $2 = trigger key → that trigger's types line, unindented awk -v key="$2:" '$1 == key && NF == 1 {f=1; next} f && /types: /{sub(/^ */,""); print; exit}' "$1" } @@ -88,17 +88,20 @@ types_in_sync() { # $1 = trigger key, $2 = caller, $3 = stub → 0 when both lis } CALLER="$ROOT/.github/workflows/self-labels.yml" STUB="$ROOT/docs/CONSUMERS.md" +# event_types anchors on the bare trigger key (NF == 1), so it reads the real +# types line even though the #199 comments name pull_request_target: and +# issues: in prose above the keys — an inline /pull_request_target:/ scan would +# latch onto the first mention and read the wrong list. +pr_has_both_review_wakes() { + event_types "$CALLER" pull_request_target | grep -F review_requested | grep -qF review_request_removed +} check "caller and stub pull_request_target lists are identical" 0 "" \ types_in_sync pull_request_target "$CALLER" "$STUB" -# shellcheck disable=SC2016 # expansion belongs to the nested bash -check "the caller lists both review-request wakes" 0 "" bash -c \ - 'awk "/pull_request_target:/{f=1; next} f && /types: /{print; exit}" "$1" | - grep -F review_requested | grep -qF review_request_removed' _ "$CALLER" +check "the caller lists both review-request wakes" 0 "" pr_has_both_review_wakes check "caller and stub issues lists are identical" 0 "" \ types_in_sync issues "$CALLER" "$STUB" -check "the caller still lists all eight issue types" 0 \ - "types: [opened, edited, assigned, unassigned, labeled, unlabeled, closed, reopened]" \ - event_types "$CALLER" issues +check "the caller lists exactly the two promptness-critical issue types" 0 \ + "types: [opened, closed]" event_types "$CALLER" issues # the failing cases: drop a type from either file, or reorder one list only, # and the identity rows above go red — exercised here on mutated copies mut_caller="$TMP/mut-caller.yml" mut_stub="$TMP/mut-stub.md" @@ -112,13 +115,13 @@ sed 's/review_requested, review_request_removed/review_request_removed, review_r "$STUB" >"$mut_stub" check "a reorder in one list only goes red" 1 "" \ types_in_sync pull_request_target "$CALLER" "$mut_stub" -sed 's/, edited//' "$CALLER" >"$mut_caller" +sed 's/, closed//' "$CALLER" >"$mut_caller" check "an issue type dropped from the caller goes red" 1 "" \ types_in_sync issues "$mut_caller" "$STUB" -sed 's/, edited//' "$STUB" >"$mut_stub" +sed 's/, closed//' "$STUB" >"$mut_stub" check "an issue type dropped from the stub goes red" 1 "" \ types_in_sync issues "$CALLER" "$mut_stub" -sed 's/closed, reopened/reopened, closed/' "$STUB" >"$mut_stub" +sed 's/opened, closed/closed, opened/' "$STUB" >"$mut_stub" check "an issue-list reorder in one file only goes red" 1 "" \ types_in_sync issues "$CALLER" "$mut_stub"