The per-read guards closed the reported class — a failed read never reaches a decision function — and left one layer standing. A pass could mutate and only THEN reach a guarded read, fail it, and report the issue as skipped: `stale` removed, or `needs-triage` minted, under a log line saying the sweep had touched nothing. That is the same false report #247 exists to close, told from the other end, and the panel reproduced it on four separate compositions. Fixed as the ordering invariant rather than per site. Inside reconcile_issue_pass's subshell, run() and log() stage their effects, and commit_staged_effects replays them in order once the pass has completed. skip_issue emits its own line directly and exits, so the buffer dies with the subshell. A skip therefore implies zero `gh issue edit`, zero `gh issue comment`, and no log line about a mutation that never landed — for compositions nobody has written yet, because reconcile_issue has no way to mutate directly. Reads stay where they are: they may happen anywhere, since nothing lands until the end. Stated per site it would hold until the next composition. Two consequences worth naming: reconcile_ruling is covered without touching lib/ruling.sh, because it posts through the sourcing script's run()/log() — the PR surface keeps its own and is unaffected; and a genuine crash mid-pass now also lands nothing, where before it left the earlier mutations applied. D4's handler string, D6's tail and D7's exit 0 are all unchanged, and the healthy path is byte-identical: every staged write commits under the same `>/dev/null` its call site already applied. Refs #247
62 lines
3 KiB
Bash
62 lines
3 KiB
Bash
#!/usr/bin/env bash
|
|
# lib/read.sh — the guarded read: an unreadable fact never invents a verdict.
|
|
#
|
|
# Both reconcilers source this file. The rule is the family's oldest one
|
|
# (#101, #95) and it has now been bought twice: the PR surface learned it
|
|
# when a permissions denial and a network hiccup left byte-identical
|
|
# evidence, and the ISSUE surface learned it when an HTTP 504 whose body is
|
|
# GitHub's JSON error object flowed straight into a decision function —
|
|
# `gh api` prints that body to stdout *and* exits non-zero, so the payload
|
|
# reaching the guards was valid JSON, `.labels[]` came back empty, and the
|
|
# sweep wrote `needs-triage` onto a healthy epic and called the pass a
|
|
# success (crew#329, #247).
|
|
#
|
|
# Two helpers:
|
|
# - guarded_read — run a read, keep its stderr, report its status
|
|
# - read_failure_reason — render that stderr into one bounded log line
|
|
#
|
|
# `read_failure_reason` is called from both surfaces. `guarded_read` is
|
|
# called from the issue surface only, and that is deliberate rather than
|
|
# unfinished: labels-reconcile's two capture sites are byte-identical to each
|
|
# other and predate this file, and converting them is a cleanup #247 does not
|
|
# own. Do not go looking for a labels-side caller — there is none yet.
|
|
#
|
|
# What the CALLER does with a failed read is the caller's: labels-reconcile
|
|
# leaves the PR alone for the pass, issueflow-reconcile skips the issue. The
|
|
# one thing neither may do is carry a degraded value into a decision.
|
|
|
|
guarded_read() { # $1 = variable to fill, rest = the read; sets READ_FAILURE_STDERR
|
|
# The status check and the captured stderr are one operation on purpose: a
|
|
# read whose failure is noticed but whose reason is thrown away is what
|
|
# #95 had to infer a cause from a control case for — wrongly, it turned
|
|
# out (#101 D2). Captured into a file rather than merged into stdout, so
|
|
# an unlucky error line can never be read back as the read's own payload.
|
|
local __var="$1" __err __out __rc=0
|
|
shift
|
|
__err="$(mktemp)" || return 1
|
|
__out="$("$@" 2>"$__err")" || __rc=$?
|
|
# shellcheck disable=SC2034 # the out-parameter: every caller reads it beside the status
|
|
READ_FAILURE_STDERR="$(cat "$__err")"
|
|
rm -f "$__err"
|
|
printf -v "$__var" '%s' "$__out"
|
|
return "$__rc"
|
|
}
|
|
|
|
read_failure_reason() { # $1 = captured stderr → one bounded line; pure (#101)
|
|
# Verbatim, collapsed, bounded (D3): gh emits multi-line errors and GraphQL
|
|
# blobs. Collapsed so the reason is exactly one log line — a raw newline
|
|
# inside the captured per-item output block could collide with a matched
|
|
# string — and truncated because an unbounded paste per item per sweep is
|
|
# noise, and annotations are capped anyway.
|
|
local reason
|
|
reason="$(printf '%s' "${1-}" | tr '\n' ' ')"
|
|
if [ -z "$reason" ]; then
|
|
# Empty stderr is itself a fact (D4): a read that failed silently is a
|
|
# different observation from a denial, and must not read as one.
|
|
echo "no error output"
|
|
elif [ "${#reason}" -gt 300 ]; then
|
|
printf '%s…\n' "${reason:0:300}"
|
|
else
|
|
printf '%s\n' "$reason"
|
|
fi
|
|
}
|