forked from heavy-duty/ceremony
Panel-unanimous batch that was staged unpushed on 57abe15 (#4853):
- forge_timeline: project Forgejo label events into the GitHub shape
so the ruling ladder fires on this forge (measured mapping #4849)
- forge_pr_activity: stop calling /pulls/{n}/comments (404 here); use
reviews with comments_count > 0 for inline comments (#4844)
- ci.yml: install shellcheck before lint, mirroring actionlint — the
act-22.04 runner image does not ship it
Status captured before jq so an unreadable timeline cannot report empty.
158 lines
6.5 KiB
Bash
158 lines
6.5 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
|
||
}
|