ceremony/lib/closes_references.sh
cluade-reviewer-andresmgsl 5797b418b9 feat(forge): replace both gh api graphql sites with REST + a body parser
Term 3 of #188. Forgejo has no GraphQL API, so these two gathers could not
be translated — there is no endpoint to translate them to. A real
forgejo-runner job says so from the other side: GITHUB_GRAPHQL_URL arrives
set to the empty string (probe task 278).

MERGED_REF_PR_RECORDS was already a body parse; GraphQL was buying
pagination, nothing semantic. OPEN_PR_ISSUES used GitHub's own parse of the
closing keywords, so it becomes lib/closes_references.sh — a sibling of
refs_references, sharing its LOCAL/CROSS classifier so rig#112 can still
never be read as local #112 (#61).

Both gathers now read /pulls, which /api/v3 and /api/v1 return in the same
shape (measured on both). merged_at replaces GraphQL's states: MERGED.
Bodies travel base64: jq's @tsv escapes a newline to a literal backslash-n,
which a line parser reads as one line and loses every declaration after the
first.

The accepted delta, written down rather than rediscovered: GitHub also
records closing links attached through the PR development sidebar, which
live in no body. This family declares links in the body, so the delta is
zero here.

Refs #188
2026-08-02 18:41:03 +00:00

79 lines
4 KiB
Bash

#!/usr/bin/env bash
# lib/closes_references.sh — "which issues does this PR body close?", parsed
# here rather than asked of a forge (issue #188, term 3).
#
# Sourced, never executed: no set -e/-u — the sourcing script owns its shell
# options, as lib/version.sh and lib/forge.sh do.
#
# WHY THIS EXISTS. issueflow-reconcile asked GitHub's GraphQL API for
# `closingIssuesReferences` — GitHub's own parse of the closing keywords in
# a PR body. **Forgejo has no GraphQL API at all**, and the runner confirms
# it from the other side: a real forgejo-runner job arrives with
# GITHUB_GRAPHQL_URL set to the empty string (probe task 278, 2026-08-02).
# So that call site could not be translated to a Forgejo endpoint — there is
# nothing to translate it to. It had to be replaced by a parse this repo
# owns, over a field both forges already return:
# `GET /repos/{owner}/{repo}/pulls` carries `number` and `body` on
# /api/v3 and /api/v1 alike (measured on both).
#
# That the replacement is honest is the point. The sibling half of the same
# GraphQL query, MERGED_REF_PR_RECORDS, was ALREADY a body parse — it pulled
# `number` and `body` and ran them through refs_references. GraphQL was
# buying pagination convenience there, nothing semantic. This file makes the
# other half symmetric: one parser this repo controls and can test, for both
# link kinds, on both forges.
#
# THE ACCEPTED DELTA, stated so it is not rediscovered as a bug: GitHub also
# records closing links attached through the pull request's development
# sidebar, which live in no body and which no body parse can see. This
# family declares its links in the body — that is what BUILDER.md's PR
# template asks for — so the delta is zero in practice here. A consumer that
# links through the sidebar would see those issues go unclosed by the sweep;
# they would need to say so in the body instead.
#
# DEPENDENCY: issue_references, from issueflow-reconcile.sh — the LOCAL /
# CROSS classifier that keeps rig#112 from ever being read as local #112
# (#61). Bash resolves function calls at call time, so the order of sourcing
# does not matter; both must simply be defined before closes_references runs.
# refs_references depends on it exactly the same way.
# closes_references — PR body on stdin -> local issue numbers this body
# declares it CLOSES, sorted, unique.
#
# The keyword set is GitHub's documented one, all three verbs in all three
# tenses. Matching is case-insensitive because bodies are written by humans
# and agents both ("Closes", "closes", "CLOSES").
#
# Deliberately NOT matched: "Refs #N". That is the other relation entirely —
# refs_references owns it, and conflating them would make every referenced
# issue look closeable, which is the post-merge transition #151 had to be
# reopened by hand over.
closes_references() {
awk '
{
line = $0
lower = tolower(line)
# Every occurrence contributes, not just the first: a body that says
# "Closes #1. Closes #2." declares two, and binding to the first
# occurrence dropped the later ones — the same defect #184 fixed in
# blocked_reference_records, kept fixed here by construction.
while (match(lower, /(^|[^[:alnum:]_-])(close[sd]?|fix(e[sd])?|resolve[sd]?)[[:space:]:]+/)) {
# BOTH cursors advance together. Advancing only `lower` left the
# next match offset indexing the ORIGINAL line, so the second
# declaration on a line came back as garbage — caught by the
# "two closes on one line" case, which is why it is a case.
rest = substr(line, RSTART + RLENGTH)
line = rest
lower = tolower(rest)
if (rest ~ /^(#|([[:alnum:]_.-]+\/)?[[:alnum:]_.-]+#)[0-9]+/) {
token = rest
# Stop at the first thing that cannot be part of a reference, so
# "Closes #12, and more prose" yields #12 and not the sentence.
sub(/[^[:alnum:]_.\/#-].*/, "", token)
print token
}
}
}
' | issue_references \
| awk -F '\t' '$1 == "LOCAL" { print $2 }' | sort -nu
}