ceremony/actions/labels-scope/labels-scope.sh
cluade-reviewer-andresmgsl baf4a20571
Some checks failed
CI / test (pull_request) Has been cancelled
CI / release-exercise (pull_request) Has been cancelled
CI / self-guards (pull_request) Has been cancelled
CI / action-exercise (pull_request) Has been cancelled
CI / docs-sync-exercise (pull_request) Has been cancelled
labels / labels (pull_request) Has been cancelled
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

177 lines
7.3 KiB
Bash
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env bash
if [ "${BASH_SOURCE[0]}" = "$0" ]; then
set -euo pipefail
else
# Fixture tests source the pure functions and deliberately inspect failures.
set -u
fi
# shellcheck source=lib/forge.sh
. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../../lib/forge.sh"
# labels-scope.sh — the additive half of the labels automation: derive
# scope:* labels from a PR's changed paths and ADD them, touching nothing
# else. This seat belonged to actions/labeler@v5 until #130: even under
# `sync-labels: false`, labeler computes (labels-fetched-at-job-start
# derived) and writes it back with `PUT /issues/{n}/labels`
# (src/labeler.ts: api.setLabels — a full replace), so any label applied
# between its read and its write is silently removed. On ceremony#128 the
# builder's `release` — the merge door's declared-intent read — landed in
# that window and vanished two seconds later; v6 and v7 write the same
# way, so the fix is this replacement, not a newer pin.
#
# The only write here is `POST /issues/{n}/labels`: GitHub adds the named
# labels, ignores ones already present, and removes nothing. A label
# applied while this runs survives by construction.
#
# The path mapping stays in the consumer's .github/labeler.yml, read via
# the API at CONFIG_REF — the base branch, never the PR head, so a PR
# cannot label itself by editing the mapping. The accepted shape is the
# one every governed repo uses:
#
# scope:name:
# - changed-files:
# - any-glob-to-any-file: ["glob", ...]
#
# in any YAML spelling (block or flow; a glob list may be a single
# string). Anything else — all-globs-to-all-files, branch matchers,
# negations, backslash escapes — is refused loudly rather than
# half-honoured: this parser exists to make one write additive, not to
# reimplement minimatch. Globs support `**` (crosses `/`), `*` and `?`
# (do not); a leading dot is not special; the whole path must match
# (`README` matches README, never docs/README).
log() { printf 'labels-scope: %s\n' "$*"; }
run() { # every mutation goes through here — DRY_RUN=1 logs instead of doing
if [ -n "${DRY_RUN:-}" ]; then log "DRY_RUN: $*"; else "$@"; fi
}
glob_to_regex() { # $1 = glob (the subset above) → anchored ERE, one line
local glob="$1" out="" c i=0 n
n="${#glob}"
while [ "$i" -lt "$n" ]; do
c="${glob:i:1}"
case "$c" in
\*)
if [ "${glob:i:2}" = '**' ]; then
out="$out.*"
i=$((i + 2))
continue
fi
out="${out}[^/]*"
;;
\?) out="${out}[^/]" ;;
[a-zA-Z0-9_/-]) out="$out$c" ;;
*) out="$out\\$c" ;; # every other byte is literal — ., +, {, (, …
esac
i=$((i + 1))
done
printf '^%s$\n' "$out"
}
parse_labeler_config() { # labeler.yml on stdin → "label<TAB>glob" lines
# yq only normalizes YAML to JSON; the shape contract is enforced in jq,
# where an unsupported key is a loud error naming the label it sits under.
yq -o=json '.' - | jq -r '
if type != "object" then
error("labeler config: top level must be a map of label -> rules")
else . end
| to_entries[]
| .key as $label
| (if (.value | type) != "array" then
error("labeler config: \($label): rules must be a list")
else .value end)[]
| (if type != "object" then
error("labeler config: \($label): each rule must be a map")
else . end)
| ((keys - ["changed-files"]) as $extra
| if ($extra | length) > 0 then
error("labeler config: \($label): unsupported key(s) \($extra | join(", ")) — the scope job accepts changed-files/any-glob-to-any-file only (#130)")
else . end)
| .["changed-files"]
| (if type == "object" then [.]
elif type == "array" then .
else error("labeler config: \($label): changed-files must be a list") end)[]
| (if type != "object" then
error("labeler config: \($label): each changed-files entry must be a map")
else . end)
| ((keys - ["any-glob-to-any-file"]) as $extra
| if ($extra | length) > 0 then
error("labeler config: \($label): unsupported matcher(s) \($extra | join(", ")) — the scope job accepts any-glob-to-any-file only (#130)")
else . end)
| .["any-glob-to-any-file"]
| (if type == "string" then [.]
elif type == "array" then .
else error("labeler config: \($label): any-glob-to-any-file must be a glob or a list of globs") end)[]
| (if type != "string" then
error("labeler config: \($label): globs must be strings")
elif contains("\\") then
error("labeler config: \($label): backslash in glob \(.) — escapes are not supported (#130)")
else . end)
| [$label, .] | @tsv
'
}
derive_labels() { # $1 = "label<TAB>glob" lines, $2 = changed files (one per
# line) → matched labels, one per line, config order, deduped
local tsv="$1" files="$2" label glob matched=$'\n'
[ -n "$files" ] || return 0
while IFS=$'\t' read -r label glob; do
[ -n "$label" ] || continue
case "$matched" in *$'\n'"$label"$'\n'*) continue ;; esac
if printf '%s\n' "$files" | grep -qE -- "$(glob_to_regex "$glob")"; then
matched="$matched$label"$'\n'
printf '%s\n' "$label"
fi
done <<<"$tsv"
}
main() {
# See labels-reconcile's twin (#188). This action's degraded read was the
# quietest of the three: an unreadable mapping and an absent one produced
# the same "nothing to derive" no-op, so on Forgejo a PR simply got no
# scope labels and nothing said why.
# 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}"
PR_NUMBER="${PR_NUMBER:?set PR_NUMBER to the pull request number}"
CONFIG_REF="${CONFIG_REF:?set CONFIG_REF to the base commit the mapping is read at}"
CONFIG_PATH="${CONFIG_PATH:-.github/labeler.yml}"
local config tsv files labels
# No mapping is a consumer that has not adopted scope labels — an
# advisory no-op, not a red run (scopes locate, they do not alert). A
# mapping that EXISTS but does not parse still fails loudly below.
if ! config="$(forge_api "repos/$REPO/contents/$CONFIG_PATH?ref=$CONFIG_REF" \
--jq '.content' 2>/dev/null | base64 -d)" || [ -z "$config" ]; then
log "no $CONFIG_PATH at $CONFIG_REF — nothing to derive"
return 0
fi
tsv="$(parse_labeler_config <<<"$config")"
files="$(forge_api --paginate "repos/$REPO/pulls/$PR_NUMBER/files" --jq '.[].filename')"
labels="$(derive_labels "$tsv" "$files")"
if [ -z "$labels" ]; then
log "#$PR_NUMBER: no scope labels derived"
return 0
fi
local args=()
while IFS= read -r label; do args+=("$label"); done <<<"$labels"
run forge_labels_add "$PR_NUMBER" "${args[@]}"
log "#$PR_NUMBER: scopes -> $(paste -sd, <<<"$labels") (additive POST; already-present names are no-ops)"
}
# sourced by test/labels-scope.test.sh for the fixture tests; executed in CI
if [ "${BASH_SOURCE[0]}" = "$0" ]; then
main "$@"
fi