Some checks failed
CI / test (pull_request) Successful in 1m27s
CI / release-exercise (pull_request) Failing after 10s
CI / self-guards (pull_request) Failing after 5s
CI / action-exercise (pull_request) Successful in 5s
CI / docs-sync-exercise (pull_request) Successful in 5s
labels / labels (pull_request) Successful in 1m24s
The 0.4.1 drill measured both doors dead on Forgejo. lib/facts.sh gathered
`released` with `gh release view` and `labeled` with `gh api .../pulls`, and
release.yml tagged and published with `gh` — none of which exist on the
runner image. The merge door therefore read labeled=no for a correctly
labeled, correctly merged ceremony PR and refused it as "a bare push";
the tag door cleared every gate and died at `gh release create`.
Both are ported onto lib/forge.sh. Two asymmetries were measured against
the live instance and its swagger rather than assumed:
* GitHub serves an ARRAY of PRs at /commits/{sha}/pulls; Forgejo serves a
single OBJECT at /commits/{sha}/pull and 404s on the plural. Both verbs
emit the array shape, so facts.sh carries one jq expression.
* GitHub creates a tag by POSTing to /git/refs; Forgejo serves that path
GET-only and creates tags at /tags. A 1:1 port of the gh call would
have 404'd forever.
The behaviour change is the second half of the bug. Any failure used to
become a definite `no`, which is safe for row 4 and catastrophic for row 5:
it is how a missing binary became "this was not a release ceremony". Now a
completed read that finds nothing is still `no` and still fail-closed, and a
read that did not complete refuses and emits no fact at all.
Four new cases in test/facts.test.sh cover exactly that, and a mutation back
to the old fail-closed-on-error behaviour kills all four and nothing else.
1014 assertions, 22 suites, shellcheck and actionlint clean.
Refs #191
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
235 lines
9.6 KiB
Bash
235 lines
9.6 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
|
||
}
|
||
|
||
# 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_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[@]}"
|
||
}
|