forked from heavy-duty/ceremony
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
136 lines
5.3 KiB
Bash
136 lines
5.3 KiB
Bash
#!/usr/bin/env bash
|
||
# lib/forge-github.sh — the GitHub backend (issue #188, term 1). Sourced by
|
||
# lib/forge.sh when forge_detect says github; never at the same time as the
|
||
# forgejo backend — they define the same verbs on purpose.
|
||
#
|
||
# This file is the CURRENT call set, extracted 1:1 and nothing more. Term 5
|
||
# of the frozen Spec is "GitHub consumers are unchanged", and the cheapest
|
||
# way to keep that true is for every verb here to be a thin pass-through to
|
||
# the `gh` invocation the call site used before the port. No behaviour is
|
||
# added, fixed or tidied on this path; anything that looks like an
|
||
# improvement here is a regression risk against a forge nobody is currently
|
||
# reporting bugs on.
|
||
|
||
# forge_api [--paginate] <endpoint> [--jq <expr>]
|
||
#
|
||
# The one deliberate difference from a pure pass-through: the caller no
|
||
# longer names a page size, because the page-size parameter is not portable
|
||
# and is therefore the backend's to own (#188).
|
||
#
|
||
# ?per_page=100 GitHub: 100 items Forgejo: 30 items (IGNORED)
|
||
# ?limit=100 GitHub: 30 items Forgejo: 50 items (capped)
|
||
#
|
||
# Both answer HTTP 200 either way, so a call site that names one is a silent
|
||
# truncation waiting for the other forge. per_page=100 is injected here —
|
||
# exactly what the call sites said before — so the GitHub path is unchanged
|
||
# in behaviour while the parameter stops being a call-site concern.
|
||
forge_api() {
|
||
local paginate=false endpoint="" jqexpr="" have_jq=false
|
||
while [ $# -gt 0 ]; do
|
||
case "$1" in
|
||
--paginate) paginate=true ;;
|
||
--jq) jqexpr="$2"; have_jq=true; shift ;;
|
||
-*) ;;
|
||
*) [ -n "$endpoint" ] || endpoint="$1" ;;
|
||
esac
|
||
shift
|
||
done
|
||
[ -n "$endpoint" ] || { echo "forge_api: endpoint required" >&2; return 1; }
|
||
|
||
if [ "$paginate" = true ]; then
|
||
endpoint="$(github_page_url "$endpoint")"
|
||
if [ "$have_jq" = true ]; then
|
||
gh api --paginate "$endpoint" --jq "$jqexpr"
|
||
else
|
||
gh api --paginate "$endpoint"
|
||
fi
|
||
else
|
||
if [ "$have_jq" = true ]; then
|
||
gh api "$endpoint" --jq "$jqexpr"
|
||
else
|
||
gh api "$endpoint"
|
||
fi
|
||
fi
|
||
}
|
||
|
||
# github_page_url <endpoint> — pure, so the page-size contract is testable
|
||
# without a network. Strips any page-size parameter a caller left behind in
|
||
# either dialect, then applies GitHub's own.
|
||
github_page_url() {
|
||
local endpoint="${1:?github_page_url: endpoint required}" clean
|
||
clean="$(printf '%s' "$endpoint" | sed -E 's/([?&])(per_page|limit|page)=[0-9]+/\1/g; s/[?&]+$//; s/([?&])&+/\1/g')"
|
||
case "$clean" in
|
||
*\?) printf '%sper_page=100\n' "$clean" ;;
|
||
*\?*) printf '%s&per_page=100\n' "$clean" ;;
|
||
*) printf '%s?per_page=100\n' "$clean" ;;
|
||
esac
|
||
}
|
||
|
||
# --- the verbs the reconcilers use, extracted 1:1 -------------------------
|
||
# Every one of these is the exact `gh` invocation the call site carried
|
||
# before the port. Term 5 is kept by making this file boring.
|
||
|
||
# forge_issue_edit <n> <gh-style flags…> — labels and assignees on an issue
|
||
# or a PR (gh treats them interchangeably, and so do the call sites).
|
||
forge_issue_edit() {
|
||
local n="${1:?forge_issue_edit: number required}"
|
||
shift
|
||
gh issue edit "$n" -R "$REPO" "$@"
|
||
}
|
||
|
||
# forge_issue_comment <n> <body>
|
||
forge_issue_comment() {
|
||
local n="${1:?forge_issue_comment: number required}" body="${2?forge_issue_comment: body required}"
|
||
gh issue comment "$n" -R "$REPO" --body "$body"
|
||
}
|
||
|
||
# forge_pr_list — open PR numbers, one per line. Note this used
|
||
# `gh pr list --limit 100`: a page size in gh's OWN flag namespace, which no
|
||
# URL-parameter strip could have caught, so it moves behind the shim with
|
||
# the rest (#188).
|
||
forge_pr_list() {
|
||
gh pr list -R "$REPO" --state open --limit 100 --json number --jq '.[].number'
|
||
}
|
||
|
||
# forge_pr_view <n> — {mergeable, statusCheckRollup} as JSON, or non-zero
|
||
# with the reason on stderr. `gh pr view` rather than the REST PR object:
|
||
# the API's `mergeable` is a tri-state boolean GitHub computes lazily, while
|
||
# this returns the MERGEABLE/CONFLICTING/UNKNOWN string the UI shows.
|
||
forge_pr_view() {
|
||
local n="${1:?forge_pr_view: number required}"
|
||
gh pr view "$n" -R "$REPO" --json mergeable,statusCheckRollup
|
||
}
|
||
|
||
# forge_label_list — every label name in the repo.
|
||
forge_label_list() {
|
||
gh label list -R "$REPO" --limit 200 --json name --jq '.[].name'
|
||
}
|
||
|
||
forge_label_create() {
|
||
local name="${1:?}" color="${2:?}" desc="${3:-}"
|
||
gh label create "$name" -R "$REPO" --color "$color" --description "$desc" --force
|
||
}
|
||
|
||
forge_label_delete() {
|
||
local name="${1:?}"
|
||
gh label delete "$name" -R "$REPO" --yes
|
||
}
|
||
|
||
# forge_labels_add <n> <label…> — an ADDITIVE label write, and deliberately
|
||
# not forge_issue_edit --add-label. The distinction is ceremony#128: the
|
||
# labeler action computed (labels-at-job-start ∪ derived) and PUT the whole
|
||
# set, so a label applied while the job ran was silently removed. This is the
|
||
# raw POST, which adds the named labels, ignores ones already present, and
|
||
# removes nothing — a concurrent label survives by construction.
|
||
forge_labels_add() {
|
||
local n="${1:?forge_labels_add: number required}" args=() label
|
||
shift
|
||
for label in "$@"; do args+=(-f "labels[]=$label"); done
|
||
gh api "repos/$REPO/issues/$n/labels" "${args[@]}" --silent
|
||
}
|
||
|
||
# forge_request_reviewer <n> <user> — ask <user> for a verdict.
|
||
forge_request_reviewer() {
|
||
local n="${1:?}" user="${2:?}"
|
||
gh api "repos/$REPO/pulls/$n/requested_reviewers" -f "reviewers[]=$user" --silent
|
||
}
|