ceremony/actions/refs-not-closing/refs-not-closing.sh
2026-08-03 20:58:56 +00:00

124 lines
3.8 KiB
Bash
Executable file

#!/usr/bin/env bash
set -euo pipefail
# refs-not-closing.sh <body-file> [<closing-issue-number> ...] — compare the
# issues a PR promises merely to reference with GitHub's closing-issue graph
# (#218). The graph is authoritative because it includes both closing
# keywords and sidebar links. The body still matters: only an issue named by
# `Ref #N` or `Refs #N` is protected, so an ordinary `Closes #N` PR remains
# untouched.
#
# This decision stays network-free so test/refs-not-closing.test.sh can drive
# the incident matrix offline. The composite action gathers both facts in one
# GraphQL read and passes them here. A failed or partial read never reaches
# this script: action.yml refuses it before asking for a verdict.
body_file="${1:-}"
shift || true
if [ -z "$body_file" ] || [ ! -f "$body_file" ]; then
echo "refs-not-closing: body file is missing or unreadable: ${body_file:-<none>}" >&2
exit 1
fi
declare -A closing=()
for issue in "$@"; do
case "$issue" in
''|*[!0-9]*)
echo "refs-not-closing: invalid closing issue number: '$issue'" >&2
exit 1
;;
esac
closing["$issue"]=1
done
mapfile -t refs_targets < <(
awk '
{
rest = tolower($0)
while (match(rest, /(^|[^[:alnum:]_])refs?[[:space:]]*:?[[:space:]]*[[]?#[0-9]+/)) {
token = substr(rest, RSTART, RLENGTH)
sub(/^.*#/, "", token)
print token + 0
rest = substr(rest, RSTART + RLENGTH)
}
}
' "$body_file" | sort -nu
)
intersections=()
for issue in "${refs_targets[@]}"; do
if [ -n "${closing[$issue]:-}" ]; then
intersections+=("$issue")
fi
done
if [ "${#intersections[@]}" -eq 0 ]; then
echo "refs-not-closing: no Refs target appears in GitHub's closing-issue graph"
exit 0
fi
sentence_for_issue() {
local issue="$1" mode="$2"
awk -v issue="$issue" -v mode="$mode" '
/^[[:space:]]*$/ {
if (paragraph != "") {
text = text paragraph "\n\n"
paragraph = ""
}
next
}
{
if (paragraph != "") paragraph = paragraph " "
paragraph = paragraph $0
}
END {
text = text paragraph
count = split(text, sentence, /[.!?][[:space:]]+|\n\n+/)
if (mode == "closing") {
needle = "(^|[^[:alnum:]_])(close|closes|closed|fix|fixes|fixed|resolve|resolves|resolved)[[:space:]]+#[[:space:]]*" issue "([^0-9]|$)"
} else {
needle = "(^|[^[:alnum:]_])refs?[[:space:]]*:?[[:space:]]*\\[?#[[:space:]]*" issue "([^0-9]|$)"
}
for (i = 1; i <= count; i++) {
lower = tolower(sentence[i])
if (match(lower, needle)) {
matched = substr(sentence[i], RSTART, RLENGTH)
sub(/^[^[:alnum:]_]*/, "", matched)
sub(/[^0-9]*$/, "", matched)
gsub(/^[[:space:]]+|[[:space:]]+$/, "", sentence[i])
printf "%s\t%s\n", matched, sentence[i]
exit
}
}
}
' "$body_file"
}
{
printf 'refs-not-closing: Refs target(s) also scheduled to close:'
printf ' #%s' "${intersections[@]}"
printf '\n'
for issue in "${intersections[@]}"; do
detail="$(sentence_for_issue "$issue" closing)"
if [ -z "$detail" ]; then
detail="$(sentence_for_issue "$issue" refs)"
printf " #%s: GitHub reports a closing reference; no adjacent closing keyword was found, so inspect the Development sidebar link.\n" "$issue"
fi
if [ -n "$detail" ]; then
matched="${detail%%$'\t'*}"
sentence="${detail#*$'\t'}"
printf ' matched: %s\n' "$matched"
printf ' sentence: %s\n' "$sentence"
fi
done
cat <<'EOF'
A `Refs #N` PR must not close N. Remove the sidebar closing link or rewrite
an adjacent closing-keyword sentence so the number comes first (`#N is
closed by hand`) or the number is omitted (`triage closes the issue by
hand`). Backticks do not protect a closing keyword from GitHub's parser.
EOF
} >&2
exit 1