ceremony-ci-probe/actions/issueflow-reconcile/issueflow-reconcile.sh
cluade-reviewer-andresmgsl baf4a20571 feat(forge): port every reconciler call site onto the shim
Term 1 completed. All 52 runtime gh call sites in the three reconcilers and
lib/ruling.sh now go through forge_* verbs; the three remaining matches in
labels-reconcile are prose in comments. lib/facts.sh is deliberately
untouched — it is the release door, and the ruling keeps release.yml out of
this issue.

The CEREMONY_FORGE_CLIENT:-gh wrappers die here, in the same commit as the
sites they described, so the tree is never in a state where the declaration
lies. main() now runs forge_preflight then forge_select "".

Two sites needed judgment rather than substitution:

  - labels-scope's write is forge_labels_add, a genuine additive POST on
    both backends, NOT forge_issue_edit --add-label. ceremony#128 turns on
    that write not being a read-modify-PUT: the labeler action computed
    (labels-at-job-start union derived) and PUT the whole set, silently
    dropping a label applied while the job ran. Routing it through a generic
    edit verb would have quietly reopened that.

  - the human-review request is forge_request_reviewer. Contrary to my
    earlier reading, POST /pulls/{n}/requested_reviewers DOES exist on
    Forgejo — 422 naming the reviewer's access without it, 201 with it. The
    earlier 404 was a GET, which the endpoint does not serve, plus a
    username that did not exist.

Test churn, all of it the term-5 boundary move:

  - the suites select the github backend, so their existing gh() stubs stay
    the boundary and keep intercepting;
  - stubs strip the paging the shim injects, so fixtures stay keyed on the
    logical endpoint (inlined in the PATH stub, which is a standalone
    executable and cannot see a shell function);
  - fixtures renamed off the per_page suffix for the same reason;
  - recorded-mutation assertions now match the verb, not the raw gh line;
  - gh() stubs carry SC2317: they are reached through the backend now, so
    shellcheck can no longer see the call path.

Refs #188
2026-08-02 19:43:58 +00:00

557 lines
23 KiB
Bash

#!/usr/bin/env bash
# shellcheck disable=SC2016 # backticks in comment bodies are Markdown literals
if [ "${BASH_SOURCE[0]}" = "$0" ]; then
set -euo pipefail
else
set -u
fi
# The issue-flow half of the labels state machine. Decisions are pure strings;
# API calls live below the divider so fixture tests can exercise every branch.
ISSUEFLOW_NOW="${ISSUEFLOW_NOW:-$(date -u +%s)}"
ISSUEFLOW_STALE_HOURS="${ISSUEFLOW_STALE_HOURS:-48}"
[[ "$ISSUEFLOW_NOW" =~ ^[0-9]+$ ]] || {
echo "issueflow: ISSUEFLOW_NOW must be UTC epoch seconds" >&2
if [ "${BASH_SOURCE[0]}" = "$0" ]; then exit 1; else return 1; fi
}
[[ "$ISSUEFLOW_STALE_HOURS" =~ ^[0-9]+$ ]] || {
echo "issueflow: ISSUEFLOW_STALE_HOURS must be a non-negative integer" >&2
if [ "${BASH_SOURCE[0]}" = "$0" ]; then exit 1; else return 1; fi
}
NOW="$ISSUEFLOW_NOW"
STALE_AFTER=$((ISSUEFLOW_STALE_HOURS * 3600))
QUEUE_LABELS=(ready claimed blocked post-merge)
TRIAGE_ACTORS=()
# The needs-ruling invariants (#52) — one implementation for both surfaces.
# shellcheck source=lib/ruling.sh
. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../../lib/ruling.sh"
# shellcheck source=lib/forge.sh
. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../../lib/forge.sh"
# shellcheck source=lib/closes_references.sh
. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../../lib/closes_references.sh"
log() { printf 'issueflow: %s\n' "$*"; }
run() { if [ -n "${DRY_RUN:-}" ]; then log "DRY_RUN: $*"; else "$@"; fi; }
load_issueflow_config() { # $1 = labels.conf
local conf="$1" line seen=false
[ -f "$conf" ] || { echo "issueflow: missing config: $conf" >&2; return 1; }
TRIAGE_ACTORS=()
while IFS= read -r line || [ -n "$line" ]; do
case "$line" in
triage-actors=*)
[ "$seen" = false ] || {
echo "issueflow: duplicate triage-actors line in $conf" >&2
return 1
}
seen=true
read -r -a TRIAGE_ACTORS <<<"${line#triage-actors=}"
[ "${#TRIAGE_ACTORS[@]}" -gt 0 ] || {
echo "issueflow: triage-actors must name at least one actor in $conf" >&2
return 1
}
;;
esac
done <"$conf"
[ "$seen" = true ] || {
echo "issueflow: missing triage-actors= line in $conf" >&2
return 1
}
}
is_triage_actor() {
local actor
for actor in "${TRIAGE_ACTORS[@]}"; do
[ "$actor" = "$1" ] && return 0
done
return 1
}
has_issue_label() { grep -qxF "$1" <<<"$ISSUE_LABELS"; }
queue_decision() { # labels on stdin -> KEEP | ADD_NEEDS_TRIAGE | FLAG_CONFLICT
local labels count=0 label categories=0
labels="$(cat)"
grep -qxF needs-triage <<<"$labels" && categories=$((categories + 1))
grep -qxF epic <<<"$labels" && categories=$((categories + 1))
for label in "${QUEUE_LABELS[@]}"; do
if grep -qxF "$label" <<<"$labels"; then count=$((count + 1)); fi
done
[ "$count" -gt 0 ] && categories=$((categories + 1))
if [ "$categories" -eq 0 ]; then echo ADD_NEEDS_TRIAGE
elif [ "$categories" -gt 1 ] || [ "$count" -gt 1 ]; then echo FLAG_CONFLICT
else echo KEEP
fi
}
author_decision() { # $1 = true when author is triage; labels on stdin
local triage="$1" labels
labels="$(cat)"
if [ "$triage" = false ] && ! grep -qxF needs-triage <<<"$labels"; then
echo ADD_NEEDS_TRIAGE
else echo KEEP
fi
}
claim_decision() { # $1 assignee count, $2 linked open PR, $3 age seconds
local assignees="$1" open_pr="$2" age="$3"
# Staleness wins over missing ownership: a stale unassigned claim is
# derivably reclaimable, while a recent unassigned claim needs triage.
if [ "$open_pr" = false ] && [ "$age" -gt "$STALE_AFTER" ]; then echo RECLAIM
elif [ "$assignees" -eq 0 ]; then echo FLAG_UNASSIGNED
else echo KEEP
fi
}
claim_clock_exempt() { # labels on stdin -> EXEMPT | SWEEP
local labels
labels="$(cat)"
# Cross-repo work has no local closing PR by construction (#68), so its
# deliberate silence must share the one claim-clock gate with rulings.
if grep -qxF offsite <<<"$labels" \
|| [ "$(ruling_stale_exempt <<<"$labels")" = EXEMPT ]; then
echo EXEMPT
else
echo SWEEP
fi
}
claim_decision_at() { # $1 assignee count, $2 linked open PR, $3 last activity epoch
claim_decision "$1" "$2" "$((NOW - $3))"
}
claim_reclaim_marker() { # $1 = last activity epoch
printf 'claim-reclaimed-%s\n' "$1"
}
refs_references() { # PR body on stdin -> local issue numbers named by Refs
awk '
{
line = $0
lower = tolower(line)
if (match(lower, /(^|[^[:alnum:]_-])refs[[:space:]:]+/)) {
line = substr(line, RSTART + RLENGTH)
if (line ~ /^(#|([[:alnum:]_.-]+\/)?[[:alnum:]_.-]+#)[0-9]+/) {
sub(/[.(;].*/, "", line)
print line
}
}
}
' | issue_references \
| awk -F '\t' '$1 == "LOCAL" { print $2 }' | sort -nu
}
unchecked_criteria() { # issue body on stdin -> unchecked task-list lines verbatim
awk '
/^[[:space:]]*([-*]|[0-9]+\.)[[:space:]]+\[[[:space:]]\]/ {
sub(/\r$/, "")
print
}
'
}
post_merge_decision() { # $1 merged Refs PR, $2 linked open PR, $3 already handled
local merged="$1" open_pr="$2" handled="$3" unchecked
unchecked="$(cat)"
if [ -n "$merged" ] && [ "$open_pr" = false ] && [ "$handled" = false ] \
&& [ -n "$unchecked" ]; then echo TRANSITION
else echo KEEP
fi
}
post_merge_pr_for_issue() { # $1 issue; records are ISSUE<TAB>PR
awk -F '\t' -v issue="$1" '$1 == issue { print $2 }' \
<<<"${MERGED_REF_PR_RECORDS:-}" | sort -n | tail -n1
}
post_merge_transition_marker() { # $1 merged PR number
printf 'post-merge-transition-pr-%s\n' "$1"
}
issue_references() { # text on stdin -> LOCAL/CROSS<TAB>reference
# A qualified reference belongs to another repository. Classify the whole
# token before extracting numbers so rig#112 can never become local #112.
{ grep -Eo '([[:alnum:]_.-]+/)?[[:alnum:]_.-]+#[0-9]+|#[0-9]+' || true; } \
| awk '
index($0, "#") == 1 { print "LOCAL\t" substr($0, 2); next }
{ print "CROSS\t" $0 }
'
}
blocked_reference_records() { # body on stdin -> classified reference records
# Every occurrence of the marker contributes a clause. Binding to the first
# occurrence alone dropped the later sentences of a repeated declaration
# ("Blocked by #152. Blocked by #153. Blocked by #148 — …") and let earlier
# prose that merely mentioned being blocked hijack the parse — the false
# `ready` promotion on rig#154 (#184). Each clause runs to its own first
# sentence terminator; declarations sometimes soft-wrap after a comma, so
# an open clause continues across lines, and if prose omits the terminator
# it retains to end of input. Unioning can over-retain — prose like "this
# was blocked by #9 before the split" now contributes #9 — and that is the
# correct direction of error: a stale `blocked` is a triage comment away,
# a false `ready` sends a builder into work that cannot merge (#184).
awk '
BEGIN { marker = "blocked by" }
{
line = $0
while (1) {
if (!active) {
start = index(tolower(line), marker)
if (!start) next
line = substr(line, start + length(marker))
active = 1
}
if (match(line, /[.;]/)) {
print substr(line, 1, RSTART - 1)
line = substr(line, RSTART + 1)
active = 0
} else {
print line
next
}
}
}
' | issue_references
}
blocked_references() { # body on stdin -> local issue numbers, one per line
blocked_reference_records | awk -F '\t' '$1 == "LOCAL" { print $2 }' | sort -nu
}
blocked_cross_references() { # body on stdin -> qualified refs, one per line
blocked_reference_records | awk -F '\t' '$1 == "CROSS" { print $2 }' | sort -u
}
blocked_decision() { # $1 local refs, $2 OPEN/CLOSED states, $3 cross-repo refs
local refs="$1" states="$2" cross_refs="${3:-}"
if [ -n "$cross_refs" ]; then echo FLAG_CROSS_REPO
elif [ -z "$refs" ]; then echo FLAG_UNPARSEABLE
elif grep -qxF OPEN <<<"$states"; then echo KEEP
elif grep -qxF UNKNOWN <<<"$states"; then echo FLAG_UNPARSEABLE
else echo READY
fi
}
epic_references() { # markdown task-list issue references from body on stdin
awk '
tolower($0) ~ /^##[[:space:]]+task list[[:space:]]*$/ { in_list = 1; next }
in_list && /^#/ { exit }
in_list && /^[[:space:]]*[-*][[:space:]]+\[[ xX]\]/ { print }
' | issue_references \
| awk -F '\t' '$1 == "LOCAL" { print $2 }' | sort -nu
}
epic_decision() { # $1 refs, $2 states
local refs="$1" states="$2"
if [ -n "$refs" ] && ! grep -Eq '^(OPEN|UNKNOWN)$' <<<"$states"; then echo NUDGE
else echo KEEP
fi
}
offsite_cross_referenced_prs() { # timeline JSON on stdin -> owner/repo#N
jq -r '
.[]
| select(.event == "cross-referenced")
| .source.issue
| select(.pull_request != null)
| select(.repository.full_name != null and .number != null)
| "\(.repository.full_name)#\(.number)"
' | sort -u
}
offsite_resolved_decision() { # PR states on stdin -> NUDGE | QUIET
local states
states="$(cat)"
if [ -n "$states" ] && ! grep -Eq '^(OPEN|UNKNOWN)$' <<<"$states"; then
echo NUDGE
else
echo QUIET
fi
}
# API edge. Marker comments make warnings and nudges idempotent across sweeps.
ensure_comment() { # $1 issue, $2 marker, $3 message
local n="$1" marker="$2" message="$3"
if issue_comment_has_marker "$n" "$marker"; then return; fi
run forge_issue_comment "$n" "<!-- issueflow:$marker -->
$message" >/dev/null
}
issue_comment_has_marker() { # $1 issue, $2 marker
forge_api --paginate "repos/$REPO/issues/$1/comments" --jq '.[].body' \
| grep -qF "<!-- issueflow:$2 -->"
}
reference_states() {
local ref state
while IFS= read -r ref; do
[ -n "$ref" ] || continue
state="$(forge_api "repos/$REPO/issues/$ref" --jq '.state' 2>/dev/null || echo UNKNOWN)"
case "$state" in open) echo OPEN ;; closed) echo CLOSED ;; *) echo UNKNOWN ;; esac
done
}
offsite_pr_states() {
local ref repo number state
while IFS= read -r ref; do
[ -n "$ref" ] || continue
repo="${ref%#*}"
number="${ref##*#}"
state="$(forge_api "repos/$repo/pulls/$number" --jq '.state' 2>/dev/null || echo UNKNOWN)"
case "$state" in open) echo OPEN ;; closed) echo CLOSED ;; *) echo UNKNOWN ;; esac
done
}
offsite_timeline() { # unreadable timelines are deliberately silent
forge_api --paginate "repos/$REPO/issues/$1/timeline" 2>/dev/null || return 1
}
last_issue_activity() {
local n="$1" created="$2" latest
latest="$({
printf '%s\n' "$created"
forge_api --paginate "repos/$REPO/issues/$n/comments" --jq '.[].created_at'
# Assignment is the claim itself. Ignoring it would let an old issue be
# reclaimed in the seconds between assignment and its required draft PR.
forge_api --paginate "repos/$REPO/issues/$n/timeline" \
--jq '.[] | select(.event == "assigned") | .created_at'
} \
| sort | tail -n1)"
date -d "$latest" +%s
}
reconcile_issue() {
local n="$1" decision refs cross_refs states age assignees open_pr=false label owners
local merged_ref_pr="" transition_marker="" transition_handled=false
local unchecked="" remove_claimed=claimed
decision="$(queue_decision <<<"$ISSUE_LABELS")"
case "$decision" in
ADD_NEEDS_TRIAGE)
run forge_issue_edit "$n" --add-label needs-triage >/dev/null
log "#$n: needs-triage (no queue state)" ;;
FLAG_CONFLICT)
ensure_comment "$n" queue-conflict \
'The issue-flow sweep found conflicting queue labels. It cannot infer intent safely; triage must leave exactly one of `needs-triage`, `epic`, `ready`, `claimed`, `blocked`, or `post-merge`.'
log "#$n: conflicting queue labels; flagged"
return ;;
esac
if has_issue_label claimed; then
assignees="$(jq '.assignees | length' <<<"$ISSUE_JSON")"
grep -qxF "$n" <<<"${OPEN_PR_ISSUES:-}" && open_pr=true
merged_ref_pr="$(post_merge_pr_for_issue "$n")"
if [ -n "$merged_ref_pr" ]; then
transition_marker="$(post_merge_transition_marker "$merged_ref_pr")"
issue_comment_has_marker "$n" "$transition_marker" \
&& transition_handled=true
fi
unchecked="$(unchecked_criteria <<<"$(jq -r '.body // ""' <<<"$ISSUE_JSON")")"
if [ "$(post_merge_decision "$merged_ref_pr" "$open_pr" "$transition_handled" \
<<<"$unchecked")" = TRANSITION ]; then
ensure_comment "$n" "$transition_marker" \
"The Refs-linked PR merged with these acceptance criteria still unchecked:
$unchecked
The merge releases the claim; no builder owes a draft. Triage owes completion in a follow-up comment that names the owner and wake condition."
owners="$(jq -r '[.assignees[].login] | join(",")' <<<"$ISSUE_JSON")"
# `attention` is a demand for the assigned builder. The derived
# transition releases that builder, so carrying the demand forward
# would create an impossible parked-for state (#175 D4).
has_issue_label attention && remove_claimed=claimed,attention
if [ -n "$owners" ]; then
run forge_issue_edit "$n" --remove-assignee "$owners" \
--remove-label "$remove_claimed" --add-label post-merge >/dev/null
else
run forge_issue_edit "$n" \
--remove-label "$remove_claimed" --add-label post-merge >/dev/null
fi
log "#$n: merged Refs PR -> post-merge; claim released"
else
age="$(last_issue_activity "$n" "$(jq -r '.created_at' <<<"$ISSUE_JSON")")"
if [ "$(claim_clock_exempt <<<"$ISSUE_LABELS")" = EXEMPT ]; then
# Legitimately quiet work does not run the reclaim clock. Only the
# clock stops: an unassigned claim is still a repair the decision must
# see, so it runs on a zero age rather than being skipped.
decision="$(claim_decision "$assignees" "$open_pr" 0)"
else
decision="$(claim_decision_at "$assignees" "$open_pr" "$age")"
fi
case "$decision" in
FLAG_UNASSIGNED)
ensure_comment "$n" claimed-unassigned \
'This issue is `claimed` but has no assignee. The sweep cannot infer an owner; triage must repair the claim.' ;;
RECLAIM)
# The last-activity epoch identifies a claim episode. A fixed marker
# hid the required comment when the same issue was later claimed and
# reclaimed again.
ensure_comment "$n" "$(claim_reclaim_marker "$age")" \
'This claim has no linked open PR and no activity for 48 hours. The sweep is reclaiming it for the ready queue.'
owners="$(jq -r '[.assignees[].login] | join(",")' <<<"$ISSUE_JSON")"
if [ -n "$owners" ]; then
run forge_issue_edit "$n" --remove-assignee "$owners" \
--remove-label claimed --add-label ready >/dev/null
else
run forge_issue_edit "$n" --remove-label claimed --add-label ready >/dev/null
fi
log "#$n: stale claim reclaimed -> ready" ;;
esac
if has_issue_label offsite; then
local timeline
if timeline="$(offsite_timeline "$n")"; then
refs="$(offsite_cross_referenced_prs <<<"$timeline")"
states="$(offsite_pr_states <<<"$refs")"
if [ "$(offsite_resolved_decision <<<"$states")" = NUDGE ]; then
ensure_comment "$n" offsite-resolved \
"$(tr '\n' ' ' <<<"$refs" | sed 's/[[:space:]]*$//') is closed; this issue's \`offsite\` flag is still up. Clear it and close the issue, or say what is still outstanding. @$(jq -r '.assignees[0].login' <<<"$ISSUE_JSON")"
log "#$n: resolved offsite PRs nudged"
fi
fi
fi
fi
elif has_issue_label post-merge; then
assignees="$(jq '.assignees | length' <<<"$ISSUE_JSON")"
if [ "$assignees" -gt 0 ] || has_issue_label attention; then
ensure_comment "$n" post-merge-assigned \
'This `post-merge` issue has an assignee or `attention`. The sweep will not undo hand-set intent; triage must clear the invalid composition or move the issue back into buildable queue state.'
log "#$n: assigned or attention-bearing post-merge issue flagged"
fi
elif has_issue_label blocked; then
refs="$(blocked_references <<<"$(jq -r '.body // ""' <<<"$ISSUE_JSON")")"
cross_refs="$(blocked_cross_references <<<"$(jq -r '.body // ""' <<<"$ISSUE_JSON")")"
states="$(reference_states <<<"$refs")"
decision="$(blocked_decision "$refs" "$states" "$cross_refs")"
case "$decision" in
FLAG_CROSS_REPO)
ensure_comment "$n" blocked-cross-repo \
"This issue's \`Blocked by\` declaration names cross-repo dependencies that the sweep cannot resolve: $(tr '\n' ' ' <<<"$cross_refs" | sed 's/[[:space:]]*$//'). Triage must verify those dependencies and flip this issue to \`ready\` by hand." ;;
FLAG_UNPARSEABLE)
ensure_comment "$n" blocked-unparseable \
'This issue is `blocked`, but its body has no parseable `Blocked by #N` declaration. The sweep will not guess the dependency.' ;;
READY)
ensure_comment "$n" blockers-cleared \
'Every issue named by `Blocked by` is closed. The sweep is moving this issue to `ready`.'
run forge_issue_edit "$n" --remove-label blocked --add-label ready >/dev/null
log "#$n: blockers closed -> ready" ;;
esac
elif has_issue_label epic; then
refs="$(epic_references <<<"$(jq -r '.body // ""' <<<"$ISSUE_JSON")")"
states="$(reference_states <<<"$refs")"
if [ "$(epic_decision "$refs" "$states")" = NUDGE ]; then
ensure_comment "$n" epic-complete \
"Every issue referenced by this epic's task list is closed. Please close the epic or extend its task list."
log "#$n: completed epic nudged"
fi
fi
# ---- the ruling invariants (#52), on any queue state ----
# The flag composes with the queue labels (#50 D8), so this runs after the
# queue branches rather than inside one of them. The FLAG_CONFLICT return
# above still short-circuits it on purpose: a board lying about its queue
# state is repaired by triage before anything else is derived from it.
if has_issue_label needs-ruling; then
# An already-applied stale comes off: waiting on a human is legitimately
# quiet (#50 D10), and nothing on the issue side ever puts stale back.
if has_issue_label stale; then
run forge_issue_edit "$n" --remove-label stale >/dev/null
log "#$n: unstale (a ruling is pending)"
fi
[ -n "${age:-}" ] \
|| age="$(last_issue_activity "$n" "$(jq -r '.created_at' <<<"$ISSUE_JSON")")"
reconcile_ruling "$n" "$age" "$NOW"
fi
}
reconcile_opened_issue() {
local n="$1" author triage=false labels remove="" label
ISSUE_JSON="$(forge_api "repos/$REPO/issues/$n")"
# The stand-downs return 0 explicitly: a bare return carries the failed
# test's status, which under execution is live `set -e` — and it killed the
# run on every triage-authored mint, before one issue was reconciled (#91).
jq -e 'has("pull_request") | not' <<<"$ISSUE_JSON" >/dev/null || return 0
author="$(jq -r '.user.login' <<<"$ISSUE_JSON")"
is_triage_actor "$author" && triage=true
labels="$(jq -r '.labels[].name' <<<"$ISSUE_JSON")"
[ "$(author_decision "$triage" <<<"$labels")" = ADD_NEEDS_TRIAGE ] || return 0
for label in epic "${QUEUE_LABELS[@]}"; do
grep -qxF "$label" <<<"$labels" && remove="$remove,$label"
done
remove="${remove#,}"
if [ -n "$remove" ]; then
run forge_issue_edit "$n" --add-label needs-triage --remove-label "$remove" >/dev/null
else
run forge_issue_edit "$n" --add-label needs-triage >/dev/null
fi
log "#$n: needs-triage (opened by $author)"
}
main() {
# See labels-reconcile's twin (#188). This one already failed loudly on
# Forgejo — but with `line 408: gh: command not found`, which names the
# symptom and not the cause, and only after the sibling step had already
# reported a green blind sweep.
# The forge is decided once, here, before anything reads the board, and
# the backend that can speak it is loaded (#188). The CEREMONY_FORGE_CLIENT
# wrapper that stood here died with the call-site port: it declared "this
# code uses gh", which stopped being true the moment every site went
# through the shim, and leaving it would have defaulted the forgejo path
# into the very client its own preflight refuses.
forge_preflight || return 1
# "" means decide from the environment; forge_select takes an explicit
# forge only in tests.
forge_select "" || return 1
REPO="${REPO:?set REPO to owner/name}"
LABELS_CONF="${LABELS_CONF:-.github/labels.conf}"
load_issueflow_config "$LABELS_CONF"
if [ "${EVENT_NAME:-}" = issues ] && [ "${EVENT_ACTION:-}" = opened ]; then
reconcile_opened_issue "${EVENT_ISSUE:?set EVENT_ISSUE for issues:opened}"
fi
# owner/name split out here until #188 — the GraphQL query took them as
# separate variables. REST takes the owner/name path whole, so it is gone.
# Both gathers were `forge_api graphql` until #188. Forgejo has NO GraphQL
# API — a real forgejo-runner job even arrives with GITHUB_GRAPHQL_URL set
# to the empty string (probe task 278) — so these could not be translated
# to a Forgejo endpoint; there is none. They are REST + a parser this repo
# owns, over `number` and `body`, which /api/v3 and /api/v1 both return in
# the same shape (measured on both, 2026-08-02).
#
# Bodies travel base64 because they contain newlines: jq's @tsv escapes a
# newline to a literal backslash-n, which a line-oriented parser reads as
# one line and silently loses every declaration after the first. The old
# GraphQL gather sidestepped that with `split("\n")[]`; base64 is the same
# protection without needing the split to be correct.
OPEN_PR_ISSUES="$(forge_api --paginate "repos/$REPO/pulls?state=open&per_page=100" \
--jq '.[] | .body // "" | @base64' \
| while IFS= read -r b64; do
[ -n "$b64" ] && printf '%s' "$b64" | base64 -d | closes_references
done | sort -nu)"
# closes_references, not refs_references: GitHub's closingIssuesReferences
# meant the CLOSING relation specifically, and reading Refs as closing
# would make every referenced issue look closeable — the distinction #151
# was reopened by hand over.
MERGED_REF_PR_RECORDS="$(forge_api --paginate "repos/$REPO/pulls?state=closed&per_page=100" \
--jq '.[] | select(.merged_at != null) | "\(.number)\t\(.body // "" | @base64)"' \
| while IFS=$'\t' read -r pr b64; do
[ -n "$b64" ] || continue
while IFS= read -r issue; do
[ -n "$issue" ] && printf '%s\t%s\n' "$issue" "$pr"
done < <(printf '%s' "$b64" | base64 -d | refs_references)
done)"
local n
for n in $(forge_api --paginate "repos/$REPO/issues?state=open&per_page=100" \
--jq '.[] | select(has("pull_request") | not) | .number'); do
(
ISSUE_JSON="$(forge_api "repos/$REPO/issues/$n")"
jq -e 'has("pull_request") | not' <<<"$ISSUE_JSON" >/dev/null || exit 0
ISSUE_LABELS="$(jq -r '.labels[].name' <<<"$ISSUE_JSON")"
reconcile_issue "$n"
) || log "#$n: reconcile failed — continuing with the remaining issues"
done
log "reconciled."
}
if [ "${BASH_SOURCE[0]}" = "$0" ]; then main "$@"; fi