forked from heavy-duty/ceremony
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:
parent
57abe15a77
commit
5c8e4f5b84
7 changed files with 203 additions and 8 deletions
12
.github/workflows/ci.yml
vendored
12
.github/workflows/ci.yml
vendored
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)"
|
||||||
|
|
|
||||||
|
|
@ -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).
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue