ceremony/lib/read.sh
cndgrr 13e8f54d60 fix(issueflow): a failed read never reaches a decision function
`gh api` prints a 5xx response body to stdout AND exits non-zero, and
GitHub's 5xx body is a JSON object. Inside the per-issue subshell that
payload passed `has("pull_request") | not`, emptied `.labels[]`, and
`queue_decision` — correct on the input it was handed — wrote
`needs-triage` onto a healthy epic. The run then logged `reconciled.`
and exited 0 (crew#329, #247).

errexit could not have caught it: a command whose status is tested by
`||` runs with errexit suppressed, and the suppression extends through
the whole subshell body, so the `|| log` handler is what disables the
errexit that would have aborted at the failed read. Removing the handler
revives errexit and loses #91's resilience, and an inline `set -e` does
not re-arm it. Explicit per-read checks are the mechanism.

Every read inside that subshell is now checked — the issue read on its
status AND on its payload shape (an HTTP 200 whose body is `null` exits
0 and empties the label set just the same), both reads in
`last_issue_activity`, and the comments read in
`issue_comment_has_marker`. On failure the issue is left exactly as it
is, the reason rides its own `#$n:` line, and the subshell exits with a
distinguished status the sweep counts, so a deliberate skip is not
reported as a crash and a genuine crash is still named byte-identically.

`read_failure_reason` moves to lib/read.sh beside a new `guarded_read`,
sourced by both reconcilers: labels-reconcile's copy was the only one,
and the issue surface needs the identical rule.

Refs #247
2026-08-03 18:01:58 +00:00

55 lines
2.6 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, both used on both surfaces:
# - guarded_read — run a read, keep its stderr, report its status
# - read_failure_reason — render that stderr into one bounded log line
#
# 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=$?
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
}