diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 37a6882..62827b9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,6 +15,18 @@ jobs: - uses: actions/checkout@v4 with: 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 run: bash .github/scripts/shellcheck-all.sh - name: Install actionlint diff --git a/actions/labels-reconcile/labels-reconcile.sh b/actions/labels-reconcile/labels-reconcile.sh index a8e2c26..252b885 100755 --- a/actions/labels-reconcile/labels-reconcile.sh +++ b/actions/labels-reconcile/labels-reconcile.sh @@ -702,13 +702,16 @@ reconcile_pr() { # $1 = PR number; relies on the globals set from its fetch fi # ---- 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="$( { jq -r '.created_at' <<<"$PR_JSON" - jq -r '.[].submitted_at' <<<"$REVIEWS_JSON" - forge_api --paginate "repos/$REPO/issues/$n/comments" --jq '.[].created_at' - forge_api --paginate "repos/$REPO/pulls/$n/comments" --jq '.[].created_at' - forge_api --paginate "repos/$REPO/pulls/$n/commits" --jq '.[].commit.committer.date' + jq -r '.[].submitted_at // empty' <<<"$REVIEWS_JSON" + forge_pr_activity "$n" 2>/dev/null || true } | sort | tail -n1 )" last_activity_epoch="$(date -d "$last_activity" +%s)" diff --git a/changelog.d/188.md b/changelog.d/188.md index 7c1dcec..f85084c 100644 --- a/changelog.d/188.md +++ b/changelog.d/188.md @@ -43,3 +43,14 @@ consumer having read zero facts — measured on `heavy-duty/rig`, where the sweep printed `reconciled.` over an empty PR list and scope reported "no 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). diff --git a/lib/forge-forgejo.sh b/lib/forge-forgejo.sh index 97b11c9..1aae741 100644 --- a/lib/forge-forgejo.sh +++ b/lib/forge-forgejo.sh @@ -438,3 +438,46 @@ forge_request_reviewer() { forgejo_write POST "repos/$REPO/pulls/$n/requested_reviewers" \ "$(jq -nc --arg u "$user" '{reviewers: [$u]}')" >/dev/null } + +# forge_timeline — 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 — 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") +} diff --git a/lib/forge-github.sh b/lib/forge-github.sh index ed180ff..c3e9351 100644 --- a/lib/forge-github.sh +++ b/lib/forge-github.sh @@ -134,3 +134,25 @@ 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 +} diff --git a/lib/ruling.sh b/lib/ruling.sh index afe23bd..c88b1ab 100644 --- a/lib/ruling.sh +++ b/lib/ruling.sh @@ -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 # comment, which only these facts identify, and half-verdicts on half-read # facts is the exact shape the reconciler's standing rule forbids. - local flags newest setter labeled_at labeled_epoch - if ! flags="$(forge_api --paginate "repos/$REPO/issues/$n/timeline" \ - --jq '.[] | select(.event == "labeled" and .label.name == "needs-ruling") - | [.actor.login, .created_at] | @tsv' 2>/dev/null)"; then + # forge_timeline projects both forges into the GitHub event shape + # (.event / .actor.login). Capture its status BEFORE jq: a pipeline's + # status is the last command's, so `forge_timeline | jq` would collapse + # 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" return 0 fi + flags="$(jq -r ' + .[] | select(.event == "labeled" and .label.name == "needs-ruling") + | [.actor.login, .created_at] | @tsv + ' <<<"$timeline")" if [ -z "$flags" ]; then # The label is on the item but no labeled event is visible (a timeline # hiccup, or an import). Same treatment as unreadable: do nothing. diff --git a/test/forge-backends.test.sh b/test/forge-backends.test.sh index 229cdd6..7bbbfc1 100644 --- a/test/forge-backends.test.sh +++ b/test/forge-backends.test.sh @@ -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" \ 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