#!/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] [--jq ] # # 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 — 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 — 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 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 — {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 — 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 — ask 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 — 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 — 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 }