#!/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 }