ceremony/lib/forge-github.sh
cluade-reviewer-andresmgsl a55fbaef15 fix(forge): forge_commit_at — Forgejo serves a single commit at /git/commits/{sha} (#209)
Found by the first post-merge sweep after the 0.6.0 merge — #198's own
acceptance probe — not by review. Three PRs in one run:

  labels: #208: could not read the head commit's date:
    forge_api: HTTP 404 from 'GET repos/heavy-duty/ceremony/commits/f3a1336…'
    — blocker:unrequested not judged this pass

Measured against this instance:

  forgejo  repos/{o}/{r}/commits/{sha}       -> 404
  forgejo  repos/{o}/{r}/git/commits/{sha}   -> 200, date under `.created`
  github   repos/{o}/{r}/commits/{sha}       -> 200, date nested

A fourth asymmetry, alongside the three lib/forge-forgejo.sh's header already
records. #198 ported this call site onto the shim with GitHub's path unchanged
— correct against GitHub, and the block it lives in (#236 D2) arrived WITH the
merge, so nothing here had ever executed it.

So it becomes a verb rather than a path at the call site: the caller wants one
timestamp and should not have to know either shape.

Cost while it stood was bounded and loud rather than silent — guarded_read
refused and the sweep said so — but blocker:unrequested could never be judged
on this forge.

The tests pin each backend's PATH and FIELD, because a stubbed forge_api cannot
catch a wrong path; that is exactly how this shipped and why it took a live
sweep to find. Swapping the paths reds the forgejo pair; swapping the fields
reds the github one.

test/run.sh 28/28 under jq 1.7 and jq 1.6; forge-backends 124/124; shellcheck
0.10.0 and actionlint clean.

Refs #209
2026-08-05 15:01:32 +00:00

247 lines
10 KiB
Bash
Raw Permalink 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
# 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
}
# forge_timeline <n> — JSON array of timeline events in the GitHub shape
# (.event, .actor.login, .label.name, .created_at). The GitHub path is a
# pass-through: that shape is what the forge already returns (#188 batch).
# Callers must capture the status of THIS function before piping into jq —
# a pipeline's status is the last command's, so `forge_timeline | jq`
# collapses an unreadable timeline into an empty one (#4853).
forge_timeline() {
local n="${1:?forge_timeline: number required}"
forge_api --paginate "repos/$REPO/issues/$n/timeline"
}
# forge_pr_activity <n> — one ISO timestamp per line of real PR activity
# (issue comments, inline review comments, commits). GitHub serves the
# flat /pulls/{n}/comments collection; the forgejo twin re-derives it from
# reviews with comments_count > 0 because that endpoint 404s there (#4844).
forge_pr_activity() {
local n="${1:?forge_pr_activity: number required}"
forge_api --paginate "repos/$REPO/issues/$n/comments" --jq '.[].created_at' || return 1
forge_api --paginate "repos/$REPO/pulls/$n/comments" --jq '.[].created_at' || return 1
forge_api --paginate "repos/$REPO/pulls/$n/commits" --jq '.[].commit.committer.date' || return 1
}
# --- the release door's facts (#191) --------------------------------------
# The github twins of the forgejo backend's two release-door reads. Term 5
# discipline applies: these are the `gh` calls lib/facts.sh carried before
# the port, with one behaviour added — a read that did not complete is
# reported as such instead of collapsing into a definite `no`.
# forge_release_exists <tag> — prints `yes` or `no`; non-zero exit means the
# read did not complete and the answer is UNKNOWN (#191).
forge_release_exists() {
local tag="${1:?forge_release_exists: tag required}" errf err rc
errf="$(mktemp)"
if gh api "repos/$GITHUB_REPOSITORY/releases/tags/$tag" --jq .tag_name >/dev/null 2>"$errf"; then
rm -f "$errf"
echo yes
return 0
fi
rc=$?
err="$(cat "$errf")"; rm -f "$errf"
# gh's 404 text is stable and is the only failure that is an ANSWER.
case "$err" in
*"HTTP 404"*) echo no; return 0 ;;
esac
echo "forge_release_exists: gh exited $rc reading release '$tag' — the answer is unknown, not 'no': $err" >&2
return 1
}
# forge_commit_pulls <sha> — the pull requests whose merge produced <sha>, as
# a JSON array. GitHub serves the array directly; the forgejo twin builds
# one from its single-object endpoint so this call site is identical.
# forge_commit_at <sha> — the commit's committer date, ISO-8601, or empty.
#
# A VERB rather than a path at the call site, because the two forges do not
# agree on where a single commit lives: GitHub serves it at /commits/{sha},
# Forgejo 404s there and serves it at /git/commits/{sha} with the timestamp
# under a different field (#209). The caller wants one timestamp; it should not
# have to know either shape.
forge_commit_at() {
local sha="${1:?forge_commit_at: sha required}"
forge_api "repos/$REPO/commits/$sha" --jq '.commit.committer.date'
}
forge_commit_pulls() {
local sha="${1:?forge_commit_pulls: sha required}" errf out rc err
errf="$(mktemp)"
if out="$(gh api "repos/$GITHUB_REPOSITORY/commits/$sha/pulls" 2>"$errf")"; then
rm -f "$errf"
printf '%s\n' "$out"
return 0
fi
rc=$?
err="$(cat "$errf")"; rm -f "$errf"
case "$err" in
*"HTTP 404"*) printf '[]\n'; return 0 ;;
esac
echo "forge_commit_pulls: gh exited $rc reading the PRs for '$sha' — the answer is unknown, not 'none': $err" >&2
return 1
}
# --- the release door's writes (#191) -------------------------------------
# The gh calls the workflow carried before the port, moved behind the shim
# so the call sites stop naming a client. Term 5: same flags, same order.
# forge_tag_create <tag> <sha>
forge_tag_create() {
local tag="${1:?forge_tag_create: tag required}" sha="${2:?forge_tag_create: sha required}"
gh api "repos/$GITHUB_REPOSITORY/git/refs" -f "ref=refs/tags/$tag" -f "sha=$sha" >/dev/null
}
# forge_release_create <tag> <title> <notes-file> [asset…]
forge_release_create() {
local tag="${1:?forge_release_create: tag required}" title="${2:?forge_release_create: title required}"
local notes="${3:?forge_release_create: notes file required}"
shift 3
gh release create "$tag" --verify-tag --title "$title" \
--notes-file "$notes" -R "$GITHUB_REPOSITORY" "$@"
}
# forge_pr_create <head> <base> <title> <body> <label…> — the release's
# bump-fallback PR (#191). gh takes repeated --label flags.
forge_pr_create() {
local head="${1:?forge_pr_create: head required}" base="${2:?forge_pr_create: base required}"
local title="${3:?forge_pr_create: title required}" body="${4:?forge_pr_create: body required}"
shift 4
local args=() l
for l in "$@"; do args+=(--label "$l"); done
gh pr create -R "$GITHUB_REPOSITORY" --head "$head" --base "$base" \
--title "$title" --body "$body" "${args[@]}"
}