feat(forge): timeline normalizer, portable PR activity, shellcheck install (#188)

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.
This commit is contained in:
grok-reviewer-andresmgsl 2026-08-03 15:13:30 +00:00
parent 57abe15a77
commit 5c8e4f5b84
7 changed files with 203 additions and 8 deletions

View file

@ -15,6 +15,18 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
# GitHub-hosted ubuntu-latest ships shellcheck; the Forgejo runner image
# this instance uses (ghcr.io/catthehacker/ubuntu:act-22.04) does not.
# actionlint already self-installs below — the same for shellcheck, so a
# green head is reachable once a ceremony runner is online (#188).
- name: Install shellcheck
env:
SHELLCHECK_VERSION: 0.10.0
run: |
curl -fsSLo shellcheck.tar.xz \
"https://github.com/koalaman/shellcheck/releases/download/v${SHELLCHECK_VERSION}/shellcheck-v${SHELLCHECK_VERSION}.linux.x86_64.tar.xz"
tar -xJf shellcheck.tar.xz "shellcheck-v${SHELLCHECK_VERSION}/shellcheck"
sudo install "shellcheck-v${SHELLCHECK_VERSION}/shellcheck" /usr/local/bin/shellcheck
- name: Shellcheck - name: Shellcheck
run: bash .github/scripts/shellcheck-all.sh run: bash .github/scripts/shellcheck-all.sh
- name: Install actionlint - name: Install actionlint

View file

@ -702,13 +702,16 @@ reconcile_pr() { # $1 = PR number; relies on the globals set from its fetch
fi fi
# ---- stale: real activity only, and blocked is legitimately quiet ---- # ---- stale: real activity only, and blocked is legitimately quiet ----
# forge_pr_activity owns the portable half: issue comments + commits +
# inline review comments. The flat /pulls/{n}/comments endpoint 404s on
# Forgejo; the forgejo backend re-derives it from reviews with
# comments_count > 0 (#188 / #4844). PR created_at and review submitted_at
# stay here — they are already in hand and need no second fetch.
last_activity="$( last_activity="$(
{ {
jq -r '.created_at' <<<"$PR_JSON" jq -r '.created_at' <<<"$PR_JSON"
jq -r '.[].submitted_at' <<<"$REVIEWS_JSON" jq -r '.[].submitted_at // empty' <<<"$REVIEWS_JSON"
forge_api --paginate "repos/$REPO/issues/$n/comments" --jq '.[].created_at' forge_pr_activity "$n" 2>/dev/null || true
forge_api --paginate "repos/$REPO/pulls/$n/comments" --jq '.[].created_at'
forge_api --paginate "repos/$REPO/pulls/$n/commits" --jq '.[].commit.committer.date'
} | sort | tail -n1 } | sort | tail -n1
)" )"
last_activity_epoch="$(date -d "$last_activity" +%s)" last_activity_epoch="$(date -d "$last_activity" +%s)"

View file

@ -43,3 +43,14 @@
consumer having read zero facts — measured on `heavy-duty/rig`, where the consumer having read zero facts — measured on `heavy-duty/rig`, where the
sweep printed `reconciled.` over an empty PR list and scope reported "no sweep printed `reconciled.` over an empty PR list and scope reported "no
labeler.yml" for a file that exists (#188). labeler.yml" for a file that exists (#188).
- `forge_timeline` projects Forgejo's label events (`.type` / `.body` /
`.user.login`) into the GitHub shape (`.event` / `.actor.login`) so the
ruling ladder reads the same board on both forges (#188).
- `forge_pr_activity` no longer calls `/pulls/{n}/comments` on Forgejo
(HTTP 404); inline review comments come from reviews with
`comments_count > 0` (#188).
- CI installs shellcheck before linting, matching actionlint — the Forgejo
runner image does not ship it (#188).

View file

@ -438,3 +438,46 @@ forge_request_reviewer() {
forgejo_write POST "repos/$REPO/pulls/$n/requested_reviewers" \ forgejo_write POST "repos/$REPO/pulls/$n/requested_reviewers" \
"$(jq -nc --arg u "$user" '{reviewers: [$u]}')" >/dev/null "$(jq -nc --arg u "$user" '{reviewers: [$u]}')" >/dev/null
} }
# forge_timeline <n> — JSON array of timeline events projected into the
# GitHub shape the reconcilers already select on. Measured mapping (#4849):
#
# | | GitHub | Forgejo |
# | event kind | .event == "labeled"/"unlabeled"| .type == "label" |
# | add vs remove | the two event names | .body "1" / "" |
# | actor | .actor.login (no .user) | .user.login (no .actor) |
#
# Status is captured BEFORE jq so an unreadable read cannot report as an
# empty timeline — the two states the ruling ladder must tell apart (#4853).
forge_timeline() {
local n="${1:?forge_timeline: number required}" raw
raw="$(forge_api --paginate "repos/$REPO/issues/$n/timeline")" || return 1
jq '
[.[]
| select(.type == "label")
| {
event: (if .body == "1" then "labeled" else "unlabeled" end),
actor: {login: (.user.login // "")},
label: {name: (.label.name // "")},
created_at: .created_at
}
]
' <<<"$raw"
}
# forge_pr_activity <n> — one ISO timestamp per line of real PR activity.
# Forgejo has no flat /pulls/{n}/comments (HTTP 404, measured #4844); inline
# review comments live under /pulls/{n}/reviews/{id}/comments. Only reviews
# with comments_count > 0 are fetched, so a board with none costs zero
# extra requests.
forge_pr_activity() {
local n="${1:?forge_pr_activity: number required}" reviews rid
forge_api --paginate "repos/$REPO/issues/$n/comments" --jq '.[].created_at' || return 1
forge_api --paginate "repos/$REPO/pulls/$n/commits" --jq '.[].commit.committer.date' || return 1
reviews="$(forge_api --paginate "repos/$REPO/pulls/$n/reviews")" || return 1
while IFS= read -r rid; do
[ -n "$rid" ] || continue
forge_api --paginate "repos/$REPO/pulls/$n/reviews/$rid/comments" \
--jq '.[].created_at' || return 1
done < <(jq -r '.[] | select((.comments_count // 0) > 0) | .id' <<<"$reviews")
}

View file

@ -134,3 +134,25 @@ forge_request_reviewer() {
local n="${1:?}" user="${2:?}" local n="${1:?}" user="${2:?}"
gh api "repos/$REPO/pulls/$n/requested_reviewers" -f "reviewers[]=$user" --silent 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
}

View file

@ -208,13 +208,20 @@ reconcile_ruling() { # $1 item number, $2 last real-activity epoch, $3 now
# skips BOTH checks — the nudge's specified content links the escalation # skips BOTH checks — the nudge's specified content links the escalation
# comment, which only these facts identify, and half-verdicts on half-read # comment, which only these facts identify, and half-verdicts on half-read
# facts is the exact shape the reconciler's standing rule forbids. # facts is the exact shape the reconciler's standing rule forbids.
local flags newest setter labeled_at labeled_epoch # forge_timeline projects both forges into the GitHub event shape
if ! flags="$(forge_api --paginate "repos/$REPO/issues/$n/timeline" \ # (.event / .actor.login). Capture its status BEFORE jq: a pipeline's
--jq '.[] | select(.event == "labeled" and .label.name == "needs-ruling") # status is the last command's, so `forge_timeline | jq` would collapse
| [.actor.login, .created_at] | @tsv' 2>/dev/null)"; then # an unreadable timeline into an empty one — the two states this function
# exists to tell apart (#188 / #4853).
local flags newest setter labeled_at labeled_epoch timeline
if ! timeline="$(forge_timeline "$n" 2>/dev/null)"; then
log "#$n: ruling timeline unreadable — no verdict invented this pass" log "#$n: ruling timeline unreadable — no verdict invented this pass"
return 0 return 0
fi fi
flags="$(jq -r '
.[] | select(.event == "labeled" and .label.name == "needs-ruling")
| [.actor.login, .created_at] | @tsv
' <<<"$timeline")"
if [ -z "$flags" ]; then if [ -z "$flags" ]; then
# The label is on the item but no labeled event is visible (a timeline # The label is on the item but no labeled event is visible (a timeline
# hiccup, or an import). Same treatment as unreadable: do nothing. # hiccup, or an import). Same treatment as unreadable: do nothing.

View file

@ -436,4 +436,101 @@ check "the newest verdict per context wins, not the array order" 0 "" \
check "no api base refuses" 1 "cannot reach the forge" \ check "no api base refuses" 1 "cannot reach the forge" \
bash -c 'unset CEREMONY_FORGE_API GITHUB_API_URL; . '"$ROOT"'/lib/forge-forgejo.sh; forgejo_api_base' bash -c 'unset CEREMONY_FORGE_API GITHUB_API_URL; . '"$ROOT"'/lib/forge-forgejo.sh; forgejo_api_base'
# --- forge_timeline: project Forgejo labels into the GitHub event shape -
# Mapping measured #4849: .type=="label", .body "1"/"" -> labeled/unlabeled,
# .user.login -> .actor.login. Mutation-verified: collapsing add/remove or
# emitting .user instead of .actor each reds its own case (#4853).
timeline_stub() {
# shellcheck disable=SC2317 # invoked indirectly, by forge_api
curl() {
local hdr="" out="" url=""
while [ $# -gt 0 ]; do
case "$1" in -D) hdr="$2"; shift ;; -o) out="$2"; shift ;; -H) shift ;; *) url="$1" ;; esac
shift
done
printf 'HTTP/1.1 200 OK\r\nX-Total-Count: %s\r\n\r\n' "${FAKE_TL_N:-2}" >"$hdr"
case "$url" in
*timeline*) printf '%s' "$FAKE_TIMELINE" >"$out" ;;
*) printf '[]' >"$out" ;;
esac
return 0
}
}
FAKE_TIMELINE='[
{"type":"label","body":"1","user":{"login":"setter"},"label":{"name":"needs-ruling"},"created_at":"2026-08-02T14:58:13Z"},
{"type":"label","body":"","user":{"login":"setter"},"label":{"name":"needs-ruling"},"created_at":"2026-08-02T15:22:22Z"},
{"type":"comment","body":"noise","user":{"login":"other"},"created_at":"2026-08-02T15:00:00Z"}
]'
FAKE_TL_N=3
timeline_stub
tl="$(REPO=o/r forge_timeline 188)"
check "forge_timeline projects body=1 to labeled" 0 "" \
jq -e '.[] | select(.event == "labeled" and .label.name == "needs-ruling" and .actor.login == "setter")' <<<"$tl" >/dev/null
check "forge_timeline projects body=\"\" to unlabeled" 0 "" \
jq -e '.[] | select(.event == "unlabeled" and .label.name == "needs-ruling")' <<<"$tl" >/dev/null
check "forge_timeline drops non-label events" 0 "" \
test "$(jq '[.[] | select(.event == null or .event == "")] | length' <<<"$tl")" = 0
check "forge_timeline uses .actor.login, not a bare .user" 0 "" \
jq -e 'all(.[]; has("actor") and (.user|not))' <<<"$tl" >/dev/null
# Unreadable: curl fails. Status must surface through forge_timeline itself
# (not a later jq), or the ruling ladder invents a verdict on a half-read.
# shellcheck disable=SC2317
curl() { return 22; }
tl_unreadable() { REPO=o/r forge_timeline 188; }
check "forge_timeline fails when the gather fails" 1 "" tl_unreadable
# --- forge_pr_activity: no flat /pulls/{n}/comments on Forgejo -----------
# Only reviews with comments_count > 0 are fetched (#4844).
activity_calls="$TMP/activity_calls"
: >"$activity_calls"
activity_stub() {
# shellcheck disable=SC2317
curl() {
local hdr="" out="" url="" total=1 body='[]'
while [ $# -gt 0 ]; do
case "$1" in -D) hdr="$2"; shift ;; -o) out="$2"; shift ;; -H) shift ;; *) url="$1" ;; esac
shift
done
printf '%s\n' "$url" >>"$activity_calls"
case "$url" in
*'/issues/'*'/comments'*)
total=1
body='[{"created_at":"2026-08-01T10:00:00Z"}]'
;;
*'/pulls/'*'/commits'*)
total=1
body='[{"commit":{"committer":{"date":"2026-08-01T11:00:00Z"}}}]'
;;
*'/reviews/'*'/comments'*)
total=1
body='[{"created_at":"2026-08-01T12:00:00Z"}]'
;;
*'/pulls/'*'/reviews'*)
total=2
body="$FAKE_REVIEWS"
;;
*) total=0; body='[]' ;;
esac
printf 'HTTP/1.1 200 OK\r\nX-Total-Count: %s\r\n\r\n' "$total" >"$hdr"
printf '%s' "$body" >"$out"
return 0
}
}
FAKE_REVIEWS='[{"id":7,"comments_count":1},{"id":8,"comments_count":0}]'
activity_stub
: >"$activity_calls"
act="$(REPO=o/r forge_pr_activity 9 | sort)"
check "forge_pr_activity emits issue-comment timestamps" 0 "" \
grep -qx '2026-08-01T10:00:00Z' <<<"$act"
check "forge_pr_activity emits commit timestamps" 0 "" \
grep -qx '2026-08-01T11:00:00Z' <<<"$act"
check "forge_pr_activity emits inline review-comment timestamps" 0 "" \
grep -qx '2026-08-01T12:00:00Z' <<<"$act"
check "forge_pr_activity fetches only reviews with comments_count>0" 0 "" \
grep -q '/reviews/7/comments' "$activity_calls"
check "...and never fetches a zero-comment review" 1 "" \
grep -q '/reviews/8/comments' "$activity_calls"
check "...and never hits the flat /pulls/{n}/comments endpoint" 1 "" \
grep -E '/pulls/[0-9]+/comments(\?|$)' "$activity_calls"
summary summary