From 7acb1baf1da9e3d207528107bff74b13e5b97148 Mon Sep 17 00:00:00 2001 From: probonopd Date: Sun, 11 Oct 2026 09:29:18 +0000 Subject: [PATCH 1/3] Close stale auto-discovered pull requests after a last re-test; discovery re-proposes them on a new release Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01DZvaN9tm5taf5avUvVtmyq --- .github/workflows/close-stale-discovered.yml | 55 ++++++++ AGENTS.md | 11 ++ code/close-stale-discovered.sh | 136 +++++++++++++++++++ code/discover-apps.sh | 22 ++- 4 files changed, 223 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/close-stale-discovered.yml create mode 100755 code/close-stale-discovered.sh diff --git a/.github/workflows/close-stale-discovered.yml b/.github/workflows/close-stale-discovered.yml new file mode 100644 index 00000000000..10db34f2527 --- /dev/null +++ b/.github/workflows/close-stale-discovered.yml @@ -0,0 +1,55 @@ +# Closes the auto-discovered pull requests whose test failed more than 90 days +# ago and that nobody commented on, after one last re-test (two runs: the first +# re-tests and labels retest-before-closing, the next closes those that still +# fail, label auto-closed). discover-apps.yml proposes a closed repository again +# when it publishes a new release. code/close-stale-discovered.sh; manual only, +# a dry run by default. Nothing from any pull request is run. + +name: Close stale auto-discovered pull requests + +on: + workflow_dispatch: + inputs: + limit: + description: 'Maximum number of pull requests to re-test or close in this run (oldest first)' + type: number + default: 20 + days: + description: 'A test result older than this many days counts as stale' + type: number + default: 90 + dry_run: + description: 'Dry run (only list what would be re-tested or closed)' + type: boolean + default: true + +jobs: + close-stale: + if: github.repository == 'AppImage/appimage.github.io' + runs-on: ubuntu-latest + timeout-minutes: 60 + concurrency: + group: close-stale-discovered + cancel-in-progress: false + permissions: + issues: write + pull-requests: write + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PAT: ${{ secrets.SCREENSHOT_UPLOAD_TOKEN }} + GITHUB_REPOSITORY: ${{ github.repository }} + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + sparse-checkout: code + + - name: Re-test, then close what still fails + env: + LIMIT: ${{ github.event.inputs.limit || '20' }} + DAYS: ${{ github.event.inputs.days || '90' }} + DRY_RUN: ${{ github.event.inputs.dry_run }} + run: | + ARGS=(--limit "$LIMIT" --days "$DAYS") + [ "$DRY_RUN" != "false" ] && ARGS+=(--dry-run) + bash code/close-stale-discovered.sh "${ARGS[@]}" diff --git a/AGENTS.md b/AGENTS.md index 32536da3e1c..20aca56f230 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -166,6 +166,17 @@ GitHub Pages (Jekyll) from this repository. ``). A maintainer who reopens it on purpose is respected. Only the list of changed files is read, via the API. + `.github/workflows/close-stale-discovered.yml` (`workflow_dispatch` only; inputs + `limit` (default 20), `days` (default 90), `dry_run` (default true); + `code/close-stale-discovered.sh`) closes auto-discovered PRs whose test failed + more than `days` ago and that no human commented on, in two runs: the first + re-tests them (`retest-pr.sh`, with `SCREENSHOT_UPLOAD_TOKEN`) and labels them + `retest-before-closing`; the next closes those whose new result still failed + (comment, label `auto-closed`). A PR that passes the re-test keeps + `screenshot-ok` and waits for a maintainer. `discover-apps.sh` treats an + `auto-closed` PR like a repository checked without success on its closing day + (proposed again when pushed to later), not like one rejected by a maintainer. + 4. `.github/workflows/ping-authors.yml` (monthly `schedule`, a real run, and `workflow_dispatch` with `dry_run` (default true), `max_issues` (default 5) and `full_scan`) runs `code/check-entry.sh` over the files in diff --git a/code/close-stale-discovered.sh b/code/close-stale-discovered.sh new file mode 100755 index 00000000000..74b7183f18a --- /dev/null +++ b/code/close-stale-discovered.sh @@ -0,0 +1,136 @@ +#!/bin/bash + +# Close the auto-discovered pull requests whose test failed long ago and that +# nobody looked at, after one last re-test. Used by +# .github/workflows/close-stale-discovered.yml (workflow_dispatch only; +# a dry run by default); runnable by hand. +# +# Usage: close-stale-discovered.sh [--dry-run] [--limit N] [--days D] +# --dry-run Only print what would be done. +# --limit N Handle at most N pull requests (default 20). +# --days D A test result older than D days counts as old (default 90). +# +# Two steps, one per run, so that a pull request is never closed on a stale +# result: +# 1. An open pull request labeled auto-discovered with an error-* label, no +# comment by a human (only github-actions), no retest-before-closing +# label, and a last test result (the bot's test result comment) older +# than D days: it is tested once more (closed and reopened with PAT, as +# /retest does, so that the test runs with the current workflow files +# and the newest release) and labeled retest-before-closing. +# 2. One labeled retest-before-closing whose latest test result is newer +# than that label and still failed (an error-* label, no screenshot-ok): +# it is closed with a comment and labeled auto-closed; the +# retest-before-closing label is removed. A pull request that passed +# the re-test keeps the screenshot-ok label and waits for a maintainer +# (the health report lists it). +# A pull request with a comment by anyone but github-actions is never +# touched: a human is looking at it. +# +# discover-apps.sh treats an auto-closed pull request like a repository checked +# without success (not like one rejected by a maintainer): the repository is +# proposed again when it is pushed to later, i.e. when it may publish a fixed +# AppImage. Needs GH_TOKEN; PAT (SCREENSHOT_UPLOAD_TOKEN) to re-test (step 1); +# GITHUB_REPOSITORY names the repository. + +set -uo pipefail + +REPO="${GITHUB_REPOSITORY:-AppImage/appimage.github.io}" +DRY_RUN=false +LIMIT=20 +DAYS=90 +while [ $# -gt 0 ] ; do + case "$1" in + --dry-run) DRY_RUN=true ;; + --limit) LIMIT="$2" ; shift ;; + --limit=*) LIMIT="${1#*=}" ;; + --days) DAYS="$2" ; shift ;; + --days=*) DAYS="${1#*=}" ;; + *) echo "Unknown argument: $1" >&2 ; exit 2 ;; + esac + shift +done +[[ "$LIMIT" =~ ^[0-9]+$ ]] && [ "$LIMIT" -ge 1 ] || LIMIT=20 +[[ "$DAYS" =~ ^[0-9]+$ ]] || DAYS=90 +SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd) +WORKDIR=$(mktemp -d) +trap 'rm -rf "$WORKDIR"' EXIT +CUTOFF=$(date -u -d "$DAYS days ago" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date -u -v-"$DAYS"d +%Y-%m-%dT%H:%M:%SZ) +MARKER='' +RETEST_LABEL=retest-before-closing +CLOSED_LABEL=auto-closed + +ensure_label() { gh api "repos/$REPO/labels/$1" >/dev/null 2>&1 || gh api -X POST "repos/$REPO/labels" -f name="$1" -f color="$2" -f description="$3" >/dev/null ; } + +# Open auto-discovered pull requests with an error label, oldest first +: > "$WORKDIR/prs.jsonl" +PAGE=1 +while [ "$PAGE" -le 100 ] ; do + gh api "repos/$REPO/pulls?state=open&per_page=100&page=$PAGE&sort=created&direction=asc" > "$WORKDIR/page.json" 2>/dev/null || break + [ "$(jq 'length' "$WORKDIR/page.json" 2>/dev/null || echo 0)" -gt 0 ] || break + jq -c '.[] | select([.labels[].name] | index("auto-discovered")) | select([.labels[].name | select(startswith("error-"))] | length > 0) + | select([.labels[].name] | index("screenshot-ok") | not) | select([.labels[].name] | index("manual-check-needed") | not) + | {n:.number, labels:[.labels[].name]}' "$WORKDIR/page.json" >> "$WORKDIR/prs.jsonl" + PAGE=$((PAGE + 1)) +done +echo "$(wc -l < "$WORKDIR/prs.jsonl") open auto-discovered pull requests with a failed test" >&2 + +N_RETESTED=0 ; N_CLOSED=0 ; N_HUMAN=0 ; N_FRESH=0 ; N_WAIT=0 +while IFS= read -r ROW ; do + [ $((N_RETESTED + N_CLOSED)) -lt "$LIMIT" ] || break + N=$(jq -r .n <<<"$ROW") + # The comments: who wrote them, and when the last test result and the label were + gh api --paginate "repos/$REPO/issues/$N/comments?per_page=100" --jq '.[] | [.user.login, .created_at, (.body | contains("'"$MARKER"'"))] | @tsv' > "$WORKDIR/c.tsv" 2>/dev/null || continue + if awk -F'\t' '$1 != "github-actions[bot]" { f = 1 } END { exit !f }' "$WORKDIR/c.tsv" ; then + N_HUMAN=$((N_HUMAN + 1)) ; continue + fi + LAST_RESULT=$(awk -F'\t' '$3 == "true" { t = $2 } END { print t }' "$WORKDIR/c.tsv") + if jq -e '.labels | index("'"$RETEST_LABEL"'")' <<<"$ROW" >/dev/null ; then + # Step 2: closed and reopened before; is the re-test's result in? + LABELED_AT=$(gh api --paginate "repos/$REPO/issues/$N/events?per_page=100" --jq '.[] | select(.event == "labeled" and .label.name == "'"$RETEST_LABEL"'") | .created_at' 2>/dev/null | tail -n 1) + if [ -z "$LAST_RESULT" ] || [ -z "$LABELED_AT" ] || [[ "$LAST_RESULT" < "$LABELED_AT" ]] ; then + N_WAIT=$((N_WAIT + 1)) ; continue + fi + if [ "$DRY_RUN" = true ] ; then + echo "#$N: would close (re-tested on ${LAST_RESULT%%T*}, still failing)" + else + ensure_label "$CLOSED_LABEL" ededed "Auto-discovered, failed its test for months and nobody looked: closed; proposed again when the repository publishes a new release" + { + echo "" + echo "This pull request was opened automatically, its test has failed for more than $DAYS days (also after a re-test today, see the result above), and nobody has commented, so it is closed to keep the queue manageable. Nothing is lost: the application is proposed again automatically once its repository publishes a new release. If you are the application's author and the AppImage is fixed, comment \`/retest\` here or open a pull request that adds \`data/\` yourself; thank you!" + } > "$WORKDIR/c.md" + gh api -X POST "repos/$REPO/issues/$N/comments" -F body=@"$WORKDIR/c.md" >/dev/null + gh api -X POST "repos/$REPO/issues/$N/labels" -f "labels[]=$CLOSED_LABEL" >/dev/null + gh api -X DELETE "repos/$REPO/issues/$N/labels/$RETEST_LABEL" >/dev/null 2>&1 || true + gh api -X PATCH "repos/$REPO/pulls/$N" -f state=closed >/dev/null + echo "#$N: closed" + fi + N_CLOSED=$((N_CLOSED + 1)) + else + # Step 1: old enough? then re-test + if [ -z "$LAST_RESULT" ] || [[ "$LAST_RESULT" > "$CUTOFF" ]] ; then N_FRESH=$((N_FRESH + 1)) ; continue ; fi + if [ "$DRY_RUN" = true ] ; then + echo "#$N: would re-test (last result ${LAST_RESULT%%T*}) and label $RETEST_LABEL" + else + [ -n "${PAT:-}" ] || { echo "PAT is needed to re-test; stopping" >&2 ; break ; } + ensure_label "$RETEST_LABEL" fef2c0 "Tested once more before it is closed as stale (close-stale-discovered.yml)" + if PAT="$PAT" REPO="$REPO" bash "$SCRIPT_DIR/retest-pr.sh" "$N" >/dev/null 2>&1 ; then + gh api -X POST "repos/$REPO/issues/$N/labels" -f "labels[]=$RETEST_LABEL" >/dev/null + echo "#$N: re-tested (closed and reopened), labeled $RETEST_LABEL" + else + echo "#$N: could not re-test; skipped" >&2 ; continue + fi + sleep 3 + fi + N_RETESTED=$((N_RETESTED + 1)) + fi +done < "$WORKDIR/prs.jsonl" + +{ + echo "## Close stale auto-discovered pull requests" + echo + echo "Re-tested$([ "$DRY_RUN" = true ] && echo " (would)"): $N_RETESTED; closed$([ "$DRY_RUN" = true ] && echo " (would)"): $N_CLOSED (limit $LIMIT, results older than $DAYS days)." + echo "Left alone: $N_HUMAN with a comment by a human, $N_FRESH with a result newer than $DAYS days, $N_WAIT re-tested and waiting for the result." + [ "$DRY_RUN" = true ] && echo && echo "(dry run: nothing was changed)" +} | tee -a "${GITHUB_STEP_SUMMARY:-/dev/null}" +exit 0 diff --git a/code/discover-apps.sh b/code/discover-apps.sh index 4a22df1c0ac..3f947e8d030 100755 --- a/code/discover-apps.sh +++ b/code/discover-apps.sh @@ -224,13 +224,24 @@ while : ; do PAGE_JSON=$(api GET "repos/$REPO/issues?labels=$LABEL&state=all&per_page=100&page=$LABELED_PAGE") N_THIS_PAGE=$(jq 'if type == "array" then length else 0 end' <<<"$PAGE_JSON" 2>/dev/null || echo 0) [ "${N_THIS_PAGE:-0}" -gt 0 ] || break - jq -r '.[] | select(.pull_request) | .body // ""' <<<"$PAGE_JSON" 2>/dev/null \ + # A pull request closed by close-stale-discovered.yml (label auto-closed) was + # not rejected by a maintainer: its repository counts as checked without + # success on the day it was closed (proposed again when it is pushed to later), + # not as proposed before + jq -r '.[] | select(.pull_request) | select([.labels[]?.name] | index("auto-closed") | not) | .body // ""' <<<"$PAGE_JSON" 2>/dev/null \ | sed -n 's/^Repository: *\(https:\/\/github\.com\/[^ ]*\).*/\1/p' | tr -d '\r' \ | while read -r LINE ; do parse_owner_repo "$LINE" ; done >> "$WORKDIR/labeled.txt" + jq -r '.[] | select(.pull_request) | select([.labels[]?.name] | index("auto-closed")) | [(.closed_at // .updated_at // "")[0:10], (.body // "")] | @tsv' <<<"$PAGE_JSON" 2>/dev/null \ + | while IFS=$'\t' read -r DAY BODY ; do + OR=$(sed -n 's/^Repository: *\(https:\/\/github\.com\/[^ ]*\).*/\1/p' <<<"$BODY" | head -n 1 | tr -d '\r') + OR=$(parse_owner_repo "$OR") && printf '%s\t%s\n' "$OR" "$DAY" + done >> "$WORKDIR/autoclosed.tsv" [ "$N_THIS_PAGE" -lt 100 ] && break LABELED_PAGE=$((LABELED_PAGE + 1)) done [ -f "$WORKDIR/labeled.txt" ] && while read -r OR ; do [ -n "$OR" ] && SKIP_LABELED["$OR"]=1 ; done < "$WORKDIR/labeled.txt" +# (the auto-closed ones join the recently-checked below, with their closing day) +[ -f "$WORKDIR/autoclosed.tsv" ] && echo "$(grep -c . "$WORKDIR/autoclosed.tsv") repositories were auto-closed before (proposed again when pushed to)" >&2 echo "$(sort -u "$WORKDIR/labeled.txt" 2>/dev/null | grep -c .) repositories were proposed before (label $LABEL)" >&2 # --- 3b. Apps a maintainer opted out (label opt-out on any PR) ------------- @@ -323,6 +334,15 @@ done > "$WORKDIR/recent.txt" # that, at least a week later, is checked again (it may publish an AppImage now) declare -A SKIP_RECENT_ON=() while IFS=$'\t' read -r OR DATE ; do [ -n "$OR" ] && { SKIP_RECENT["$OR"]=1 ; SKIP_RECENT_ON["$OR"]="$DATE" ; } ; done < "$WORKDIR/recent.txt" +# Auto-closed pull requests: like checked without success on the closing day +# (skipped for 90 days, unless the repository is pushed to in the meantime) +if [ -f "$WORKDIR/autoclosed.tsv" ] ; then + while IFS=$'\t' read -r OR DATE ; do + [ -n "$OR" ] || continue + [ -n "${SKIP_KNOWN[$OR]:-}${SKIP_LABELED[$OR]:-}" ] && continue + if [[ "$DATE" > "$CUTOFF" ]] ; then SKIP_RECENT["$OR"]=1 ; SKIP_RECENT_ON["$OR"]="$DATE" ; fi + done < "$WORKDIR/autoclosed.tsv" +fi # --- 9. Persist the state file ------------------------------------------- # Called after every opened PR, every 10 slices and at the end, so that a From da04e2e1ce4b701ff7b3b3cb5fe4fe7d3c0455d6 Mon Sep 17 00:00:00 2001 From: probonopd Date: Sun, 11 Oct 2026 10:06:10 +0000 Subject: [PATCH 2/3] Stale auto-discovered PRs: re-test, ping the author, close only after an unanswered ping Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01DZvaN9tm5taf5avUvVtmyq --- .github/workflows/close-stale-discovered.yml | 55 ------ .github/workflows/stale-discovered.yml | 62 +++++++ AGENTS.md | 25 +-- code/close-stale-discovered.sh | 136 -------------- code/stale-discovered.sh | 178 +++++++++++++++++++ 5 files changed, 255 insertions(+), 201 deletions(-) delete mode 100644 .github/workflows/close-stale-discovered.yml create mode 100644 .github/workflows/stale-discovered.yml delete mode 100755 code/close-stale-discovered.sh create mode 100755 code/stale-discovered.sh diff --git a/.github/workflows/close-stale-discovered.yml b/.github/workflows/close-stale-discovered.yml deleted file mode 100644 index 10db34f2527..00000000000 --- a/.github/workflows/close-stale-discovered.yml +++ /dev/null @@ -1,55 +0,0 @@ -# Closes the auto-discovered pull requests whose test failed more than 90 days -# ago and that nobody commented on, after one last re-test (two runs: the first -# re-tests and labels retest-before-closing, the next closes those that still -# fail, label auto-closed). discover-apps.yml proposes a closed repository again -# when it publishes a new release. code/close-stale-discovered.sh; manual only, -# a dry run by default. Nothing from any pull request is run. - -name: Close stale auto-discovered pull requests - -on: - workflow_dispatch: - inputs: - limit: - description: 'Maximum number of pull requests to re-test or close in this run (oldest first)' - type: number - default: 20 - days: - description: 'A test result older than this many days counts as stale' - type: number - default: 90 - dry_run: - description: 'Dry run (only list what would be re-tested or closed)' - type: boolean - default: true - -jobs: - close-stale: - if: github.repository == 'AppImage/appimage.github.io' - runs-on: ubuntu-latest - timeout-minutes: 60 - concurrency: - group: close-stale-discovered - cancel-in-progress: false - permissions: - issues: write - pull-requests: write - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - PAT: ${{ secrets.SCREENSHOT_UPLOAD_TOKEN }} - GITHUB_REPOSITORY: ${{ github.repository }} - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - sparse-checkout: code - - - name: Re-test, then close what still fails - env: - LIMIT: ${{ github.event.inputs.limit || '20' }} - DAYS: ${{ github.event.inputs.days || '90' }} - DRY_RUN: ${{ github.event.inputs.dry_run }} - run: | - ARGS=(--limit "$LIMIT" --days "$DAYS") - [ "$DRY_RUN" != "false" ] && ARGS+=(--dry-run) - bash code/close-stale-discovered.sh "${ARGS[@]}" diff --git a/.github/workflows/stale-discovered.yml b/.github/workflows/stale-discovered.yml new file mode 100644 index 00000000000..cbfae8a5f02 --- /dev/null +++ b/.github/workflows/stale-discovered.yml @@ -0,0 +1,62 @@ +# Auto-discovered pull requests whose test failed long ago and that nobody looked +# at, in three steps, one per pull request and run (code/stale-discovered.sh): +# re-test (labeled retested-stale); if it still fails, ping the application's +# author once (labeled pinged-author); if nobody answers in 60 days, close +# (labeled auto-closed; discover-apps.yml proposes the repository again when it +# publishes a new release). A pull request with a human comment is never +# touched. Manual only, a dry run by default. Nothing from any pull request is run. + +name: Stale auto-discovered pull requests + +on: + workflow_dispatch: + inputs: + limit: + description: 'Maximum number of pull requests to re-test, ping or close in this run (oldest first)' + type: number + default: 20 + days: + description: 'A test result older than this many days counts as stale (step 1, re-test)' + type: number + default: 90 + close_days: + description: 'Close this many days after the ping went unanswered (step 3)' + type: number + default: 60 + dry_run: + description: 'Dry run (only list what would be re-tested, pinged or closed)' + type: boolean + default: true + +jobs: + stale: + if: github.repository == 'AppImage/appimage.github.io' + runs-on: ubuntu-latest + timeout-minutes: 90 + concurrency: + group: stale-discovered + cancel-in-progress: false + permissions: + contents: read # the entry's link, to find the author to mention + issues: write + pull-requests: write + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PAT: ${{ secrets.SCREENSHOT_UPLOAD_TOKEN }} + GITHUB_REPOSITORY: ${{ github.repository }} + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + sparse-checkout: code + + - name: Re-test, ping, close + env: + LIMIT: ${{ github.event.inputs.limit || '20' }} + DAYS: ${{ github.event.inputs.days || '90' }} + CLOSE_DAYS: ${{ github.event.inputs.close_days || '60' }} + DRY_RUN: ${{ github.event.inputs.dry_run }} + run: | + ARGS=(--limit "$LIMIT" --days "$DAYS" --close-days "$CLOSE_DAYS") + [ "$DRY_RUN" != "false" ] && ARGS+=(--dry-run) + bash code/stale-discovered.sh "${ARGS[@]}" diff --git a/AGENTS.md b/AGENTS.md index 20aca56f230..71674f9b57b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -166,16 +166,21 @@ GitHub Pages (Jekyll) from this repository. ``). A maintainer who reopens it on purpose is respected. Only the list of changed files is read, via the API. - `.github/workflows/close-stale-discovered.yml` (`workflow_dispatch` only; inputs - `limit` (default 20), `days` (default 90), `dry_run` (default true); - `code/close-stale-discovered.sh`) closes auto-discovered PRs whose test failed - more than `days` ago and that no human commented on, in two runs: the first - re-tests them (`retest-pr.sh`, with `SCREENSHOT_UPLOAD_TOKEN`) and labels them - `retest-before-closing`; the next closes those whose new result still failed - (comment, label `auto-closed`). A PR that passes the re-test keeps - `screenshot-ok` and waits for a maintainer. `discover-apps.sh` treats an - `auto-closed` PR like a repository checked without success on its closing day - (proposed again when pushed to later), not like one rejected by a maintainer. + `.github/workflows/stale-discovered.yml` (`workflow_dispatch` only; inputs + `limit` (default 20), `days` (default 90), `close_days` (default 60), `dry_run` + (default true); `code/stale-discovered.sh`) walks auto-discovered PRs whose test + failed and that no human commented on up a ladder, one step per PR and run: + (1) a last result older than `days` → re-test (`retest-pr.sh`, with + `SCREENSHOT_UPLOAD_TOKEN`), label `retested-stale`; (2) the re-test's result is + in and still failed → one comment that @mentions the GitHub account the AppImage + comes from (`upstream-owner.sh`; never a maintainer or bot), asks for `/retest` + when fixed and announces the closing, label `pinged-author` (marker + ``); (3) `close_days` after the ping with no human + comment → closed with a comment, label `auto-closed` (the other two removed). + A PR that passes the re-test keeps `screenshot-ok` and waits for a maintainer. + `discover-apps.sh` treats an `auto-closed` PR like a repository checked without + success on its closing day (proposed again when pushed to later), not like one + rejected by a maintainer. 4. `.github/workflows/ping-authors.yml` (monthly `schedule`, a real run, and `workflow_dispatch` with `dry_run` (default true), `max_issues` diff --git a/code/close-stale-discovered.sh b/code/close-stale-discovered.sh deleted file mode 100755 index 74b7183f18a..00000000000 --- a/code/close-stale-discovered.sh +++ /dev/null @@ -1,136 +0,0 @@ -#!/bin/bash - -# Close the auto-discovered pull requests whose test failed long ago and that -# nobody looked at, after one last re-test. Used by -# .github/workflows/close-stale-discovered.yml (workflow_dispatch only; -# a dry run by default); runnable by hand. -# -# Usage: close-stale-discovered.sh [--dry-run] [--limit N] [--days D] -# --dry-run Only print what would be done. -# --limit N Handle at most N pull requests (default 20). -# --days D A test result older than D days counts as old (default 90). -# -# Two steps, one per run, so that a pull request is never closed on a stale -# result: -# 1. An open pull request labeled auto-discovered with an error-* label, no -# comment by a human (only github-actions), no retest-before-closing -# label, and a last test result (the bot's test result comment) older -# than D days: it is tested once more (closed and reopened with PAT, as -# /retest does, so that the test runs with the current workflow files -# and the newest release) and labeled retest-before-closing. -# 2. One labeled retest-before-closing whose latest test result is newer -# than that label and still failed (an error-* label, no screenshot-ok): -# it is closed with a comment and labeled auto-closed; the -# retest-before-closing label is removed. A pull request that passed -# the re-test keeps the screenshot-ok label and waits for a maintainer -# (the health report lists it). -# A pull request with a comment by anyone but github-actions is never -# touched: a human is looking at it. -# -# discover-apps.sh treats an auto-closed pull request like a repository checked -# without success (not like one rejected by a maintainer): the repository is -# proposed again when it is pushed to later, i.e. when it may publish a fixed -# AppImage. Needs GH_TOKEN; PAT (SCREENSHOT_UPLOAD_TOKEN) to re-test (step 1); -# GITHUB_REPOSITORY names the repository. - -set -uo pipefail - -REPO="${GITHUB_REPOSITORY:-AppImage/appimage.github.io}" -DRY_RUN=false -LIMIT=20 -DAYS=90 -while [ $# -gt 0 ] ; do - case "$1" in - --dry-run) DRY_RUN=true ;; - --limit) LIMIT="$2" ; shift ;; - --limit=*) LIMIT="${1#*=}" ;; - --days) DAYS="$2" ; shift ;; - --days=*) DAYS="${1#*=}" ;; - *) echo "Unknown argument: $1" >&2 ; exit 2 ;; - esac - shift -done -[[ "$LIMIT" =~ ^[0-9]+$ ]] && [ "$LIMIT" -ge 1 ] || LIMIT=20 -[[ "$DAYS" =~ ^[0-9]+$ ]] || DAYS=90 -SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd) -WORKDIR=$(mktemp -d) -trap 'rm -rf "$WORKDIR"' EXIT -CUTOFF=$(date -u -d "$DAYS days ago" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date -u -v-"$DAYS"d +%Y-%m-%dT%H:%M:%SZ) -MARKER='' -RETEST_LABEL=retest-before-closing -CLOSED_LABEL=auto-closed - -ensure_label() { gh api "repos/$REPO/labels/$1" >/dev/null 2>&1 || gh api -X POST "repos/$REPO/labels" -f name="$1" -f color="$2" -f description="$3" >/dev/null ; } - -# Open auto-discovered pull requests with an error label, oldest first -: > "$WORKDIR/prs.jsonl" -PAGE=1 -while [ "$PAGE" -le 100 ] ; do - gh api "repos/$REPO/pulls?state=open&per_page=100&page=$PAGE&sort=created&direction=asc" > "$WORKDIR/page.json" 2>/dev/null || break - [ "$(jq 'length' "$WORKDIR/page.json" 2>/dev/null || echo 0)" -gt 0 ] || break - jq -c '.[] | select([.labels[].name] | index("auto-discovered")) | select([.labels[].name | select(startswith("error-"))] | length > 0) - | select([.labels[].name] | index("screenshot-ok") | not) | select([.labels[].name] | index("manual-check-needed") | not) - | {n:.number, labels:[.labels[].name]}' "$WORKDIR/page.json" >> "$WORKDIR/prs.jsonl" - PAGE=$((PAGE + 1)) -done -echo "$(wc -l < "$WORKDIR/prs.jsonl") open auto-discovered pull requests with a failed test" >&2 - -N_RETESTED=0 ; N_CLOSED=0 ; N_HUMAN=0 ; N_FRESH=0 ; N_WAIT=0 -while IFS= read -r ROW ; do - [ $((N_RETESTED + N_CLOSED)) -lt "$LIMIT" ] || break - N=$(jq -r .n <<<"$ROW") - # The comments: who wrote them, and when the last test result and the label were - gh api --paginate "repos/$REPO/issues/$N/comments?per_page=100" --jq '.[] | [.user.login, .created_at, (.body | contains("'"$MARKER"'"))] | @tsv' > "$WORKDIR/c.tsv" 2>/dev/null || continue - if awk -F'\t' '$1 != "github-actions[bot]" { f = 1 } END { exit !f }' "$WORKDIR/c.tsv" ; then - N_HUMAN=$((N_HUMAN + 1)) ; continue - fi - LAST_RESULT=$(awk -F'\t' '$3 == "true" { t = $2 } END { print t }' "$WORKDIR/c.tsv") - if jq -e '.labels | index("'"$RETEST_LABEL"'")' <<<"$ROW" >/dev/null ; then - # Step 2: closed and reopened before; is the re-test's result in? - LABELED_AT=$(gh api --paginate "repos/$REPO/issues/$N/events?per_page=100" --jq '.[] | select(.event == "labeled" and .label.name == "'"$RETEST_LABEL"'") | .created_at' 2>/dev/null | tail -n 1) - if [ -z "$LAST_RESULT" ] || [ -z "$LABELED_AT" ] || [[ "$LAST_RESULT" < "$LABELED_AT" ]] ; then - N_WAIT=$((N_WAIT + 1)) ; continue - fi - if [ "$DRY_RUN" = true ] ; then - echo "#$N: would close (re-tested on ${LAST_RESULT%%T*}, still failing)" - else - ensure_label "$CLOSED_LABEL" ededed "Auto-discovered, failed its test for months and nobody looked: closed; proposed again when the repository publishes a new release" - { - echo "" - echo "This pull request was opened automatically, its test has failed for more than $DAYS days (also after a re-test today, see the result above), and nobody has commented, so it is closed to keep the queue manageable. Nothing is lost: the application is proposed again automatically once its repository publishes a new release. If you are the application's author and the AppImage is fixed, comment \`/retest\` here or open a pull request that adds \`data/\` yourself; thank you!" - } > "$WORKDIR/c.md" - gh api -X POST "repos/$REPO/issues/$N/comments" -F body=@"$WORKDIR/c.md" >/dev/null - gh api -X POST "repos/$REPO/issues/$N/labels" -f "labels[]=$CLOSED_LABEL" >/dev/null - gh api -X DELETE "repos/$REPO/issues/$N/labels/$RETEST_LABEL" >/dev/null 2>&1 || true - gh api -X PATCH "repos/$REPO/pulls/$N" -f state=closed >/dev/null - echo "#$N: closed" - fi - N_CLOSED=$((N_CLOSED + 1)) - else - # Step 1: old enough? then re-test - if [ -z "$LAST_RESULT" ] || [[ "$LAST_RESULT" > "$CUTOFF" ]] ; then N_FRESH=$((N_FRESH + 1)) ; continue ; fi - if [ "$DRY_RUN" = true ] ; then - echo "#$N: would re-test (last result ${LAST_RESULT%%T*}) and label $RETEST_LABEL" - else - [ -n "${PAT:-}" ] || { echo "PAT is needed to re-test; stopping" >&2 ; break ; } - ensure_label "$RETEST_LABEL" fef2c0 "Tested once more before it is closed as stale (close-stale-discovered.yml)" - if PAT="$PAT" REPO="$REPO" bash "$SCRIPT_DIR/retest-pr.sh" "$N" >/dev/null 2>&1 ; then - gh api -X POST "repos/$REPO/issues/$N/labels" -f "labels[]=$RETEST_LABEL" >/dev/null - echo "#$N: re-tested (closed and reopened), labeled $RETEST_LABEL" - else - echo "#$N: could not re-test; skipped" >&2 ; continue - fi - sleep 3 - fi - N_RETESTED=$((N_RETESTED + 1)) - fi -done < "$WORKDIR/prs.jsonl" - -{ - echo "## Close stale auto-discovered pull requests" - echo - echo "Re-tested$([ "$DRY_RUN" = true ] && echo " (would)"): $N_RETESTED; closed$([ "$DRY_RUN" = true ] && echo " (would)"): $N_CLOSED (limit $LIMIT, results older than $DAYS days)." - echo "Left alone: $N_HUMAN with a comment by a human, $N_FRESH with a result newer than $DAYS days, $N_WAIT re-tested and waiting for the result." - [ "$DRY_RUN" = true ] && echo && echo "(dry run: nothing was changed)" -} | tee -a "${GITHUB_STEP_SUMMARY:-/dev/null}" -exit 0 diff --git a/code/stale-discovered.sh b/code/stale-discovered.sh new file mode 100755 index 00000000000..c22d056a8d4 --- /dev/null +++ b/code/stale-discovered.sh @@ -0,0 +1,178 @@ +#!/bin/bash + +# Handle the auto-discovered pull requests whose test failed long ago and that +# nobody looked at: re-test, then ping the application's author, then (much +# later) close. Used by .github/workflows/stale-discovered.yml (workflow_dispatch +# only; a dry run by default); runnable by hand. +# +# Usage: stale-discovered.sh [--dry-run] [--limit N] [--days D] [--close-days C] +# --dry-run Only print what would be done. +# --limit N Handle at most N pull requests per run (default 20; a +# re-test costs a Test run, a ping is a mention). +# --days D A test result older than D days counts as old (default 90). +# --close-days C Close C days after the ping went unanswered (default 60). +# +# The ladder, one step per pull request and run, each step on a fresh result: +# 1. Re-test: an open pull request labeled auto-discovered with an error-* +# label, no comment by anyone but github-actions, none of the labels +# below, and a last test result older than D days: tested once more +# (closed and reopened with PAT, as /retest does: current workflow files, +# newest release), labeled retested-stale. +# 2. Ping: labeled retested-stale, the re-test's result is in and still +# failed: one comment that @mentions the GitHub account the AppImage +# comes from (code/upstream-owner.sh on the entry's link; the first test +# result mentioned them once already, months ago), says what fails (the +# result above), asks for /retest when fixed, and says that the pull +# request is closed in C days otherwise; labeled pinged-author. +# 3. Close: labeled pinged-author, the ping is C days old, still no comment +# by a human: closed with a comment, labeled auto-closed (the other two +# labels removed). discover-apps.sh proposes the repository again when it +# is pushed to later. +# A pull request with a comment by anyone but github-actions is never touched +# (the author or a maintainer is looking at it); one that passes the re-test +# keeps screenshot-ok and waits for a maintainer (the health report lists it). +# +# Needs GH_TOKEN; PAT (SCREENSHOT_UPLOAD_TOKEN) for step 1; GITHUB_REPOSITORY. + +set -uo pipefail + +REPO="${GITHUB_REPOSITORY:-AppImage/appimage.github.io}" +DRY_RUN=false +LIMIT=20 +DAYS=90 +CLOSE_DAYS=60 +while [ $# -gt 0 ] ; do + case "$1" in + --dry-run) DRY_RUN=true ;; + --limit) LIMIT="$2" ; shift ;; + --limit=*) LIMIT="${1#*=}" ;; + --days) DAYS="$2" ; shift ;; + --days=*) DAYS="${1#*=}" ;; + --close-days) CLOSE_DAYS="$2" ; shift ;; + --close-days=*) CLOSE_DAYS="${1#*=}" ;; + *) echo "Unknown argument: $1" >&2 ; exit 2 ;; + esac + shift +done +[[ "$LIMIT" =~ ^[0-9]+$ ]] && [ "$LIMIT" -ge 1 ] || LIMIT=20 +[[ "$DAYS" =~ ^[0-9]+$ ]] || DAYS=90 +[[ "$CLOSE_DAYS" =~ ^[0-9]+$ ]] || CLOSE_DAYS=60 +SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd) +WORKDIR=$(mktemp -d) +trap 'rm -rf "$WORKDIR"' EXIT +iso_ago() { date -u -d "$1 days ago" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date -u -v-"$1"d +%Y-%m-%dT%H:%M:%SZ ; } +CUTOFF=$(iso_ago "$DAYS") +CLOSE_CUTOFF=$(iso_ago "$CLOSE_DAYS") +MARKER='' +PING_MARKER='' +L_RETESTED=retested-stale +L_PINGED=pinged-author +L_CLOSED=auto-closed + +ensure_label() { gh api "repos/$REPO/labels/$1" >/dev/null 2>&1 || gh api -X POST "repos/$REPO/labels" -f name="$1" -f color="$2" -f description="$3" >/dev/null ; } +lower() { tr 'A-Z' 'a-z' <<<"$1" ; } + +# Open auto-discovered pull requests with an error label, oldest first +: > "$WORKDIR/prs.jsonl" +PAGE=1 +while [ "$PAGE" -le 100 ] ; do + gh api "repos/$REPO/pulls?state=open&per_page=100&page=$PAGE&sort=created&direction=asc" > "$WORKDIR/page.json" 2>/dev/null || break + [ "$(jq 'length' "$WORKDIR/page.json" 2>/dev/null || echo 0)" -gt 0 ] || break + jq -c '.[] | select([.labels[].name] | index("auto-discovered")) | select([.labels[].name | select(startswith("error-"))] | length > 0) + | select([.labels[].name] | index("screenshot-ok") | not) | select([.labels[].name] | index("manual-check-needed") | not) + | {n:.number, sha:.head.sha, user:.user.login, labels:[.labels[].name]}' "$WORKDIR/page.json" >> "$WORKDIR/prs.jsonl" + PAGE=$((PAGE + 1)) +done +echo "$(wc -l < "$WORKDIR/prs.jsonl") open auto-discovered pull requests with a failed test" >&2 + +N_RETESTED=0 ; N_PINGED=0 ; N_CLOSED=0 ; N_HUMAN=0 ; N_FRESH=0 ; N_WAIT=0 +label_time() { # label_time PR LABEL -> when it was last applied + gh api --paginate "repos/$REPO/issues/$1/events?per_page=100" --jq '.[] | select(.event == "labeled" and .label.name == "'"$2"'") | .created_at' 2>/dev/null | tail -n 1 +} +while IFS= read -r ROW ; do + [ $((N_RETESTED + N_PINGED + N_CLOSED)) -lt "$LIMIT" ] || break + N=$(jq -r .n <<<"$ROW") + gh api --paginate "repos/$REPO/issues/$N/comments?per_page=100" --jq '.[] | [.user.login, .created_at, (.body | contains("'"$MARKER"'")), (.body | contains("'"$PING_MARKER"'"))] | @tsv' > "$WORKDIR/c.tsv" 2>/dev/null || continue + if awk -F'\t' '$1 != "github-actions[bot]" { f = 1 } END { exit !f }' "$WORKDIR/c.tsv" ; then + N_HUMAN=$((N_HUMAN + 1)) ; continue + fi + LAST_RESULT=$(awk -F'\t' '$3 == "true" { t = $2 } END { print t }' "$WORKDIR/c.tsv") + PING_AT=$(awk -F'\t' '$4 == "true" { t = $2 } END { print t }' "$WORKDIR/c.tsv") + has_label() { jq -e --arg l "$1" '.labels | index($l)' <<<"$ROW" >/dev/null ; } + + if has_label "$L_PINGED" ; then + # Step 3: close, once the ping is old enough and still unanswered + [ -n "$PING_AT" ] || PING_AT=$(label_time "$N" "$L_PINGED") + if [ -z "$PING_AT" ] || [[ "$PING_AT" > "$CLOSE_CUTOFF" ]] ; then N_WAIT=$((N_WAIT + 1)) ; continue ; fi + if [ "$DRY_RUN" = true ] ; then + echo "#$N: would close (pinged on ${PING_AT%%T*}, no answer)" + else + ensure_label "$L_CLOSED" ededed "Auto-discovered, failed its test for months, author pinged, no answer: closed; proposed again when the repository publishes a new release" + { + echo "" + echo "No answer in $CLOSE_DAYS days, so this automatically opened pull request is closed to keep the queue manageable. Nothing is lost: the application is proposed again automatically once its repository publishes a new release, and a pull request that adds \`data/\` is welcome at any time. Thank you!" + } > "$WORKDIR/c.md" + gh api -X POST "repos/$REPO/issues/$N/comments" -F body=@"$WORKDIR/c.md" >/dev/null + gh api -X POST "repos/$REPO/issues/$N/labels" -f "labels[]=$L_CLOSED" >/dev/null + gh api -X DELETE "repos/$REPO/issues/$N/labels/$L_PINGED" >/dev/null 2>&1 || true + gh api -X DELETE "repos/$REPO/issues/$N/labels/$L_RETESTED" >/dev/null 2>&1 || true + gh api -X PATCH "repos/$REPO/pulls/$N" -f state=closed >/dev/null + echo "#$N: closed" + fi + N_CLOSED=$((N_CLOSED + 1)) + elif has_label "$L_RETESTED" ; then + # Step 2: ping, once the re-test's result is in and still failed + LABELED_AT=$(label_time "$N" "$L_RETESTED") + if [ -z "$LAST_RESULT" ] || [ -z "$LABELED_AT" ] || [[ "$LAST_RESULT" < "$LABELED_AT" ]] ; then N_WAIT=$((N_WAIT + 1)) ; continue ; fi + # The GitHub account the AppImage comes from (never the bot, never a maintainer) + SHA=$(jq -r .sha <<<"$ROW") + FILE=$(gh api --paginate "repos/$REPO/pulls/$N/files" --jq '.[] | select(.filename | startswith("data/")) | .filename' 2>/dev/null | head -n 1) + OWNER="" + if [ -n "$FILE" ] ; then + URL=$(gh api -H "Accept: application/vnd.github.raw" "repos/$REPO/contents/$FILE?ref=$SHA" 2>/dev/null | head -n 1 | tr -d '\r') + OWNER=$(bash "$SCRIPT_DIR/upstream-owner.sh" "$URL" 2>/dev/null | tr -cd 'A-Za-z0-9-') + fi + case "$(lower "$OWNER")" in ""|*"[bot]"|probonopd|github-actions) OWNER="" ;; esac + if [ "$DRY_RUN" = true ] ; then + echo "#$N: would ping ${OWNER:+@$OWNER }(re-tested on ${LAST_RESULT%%T*}, still failing)" + else + ensure_label "$L_PINGED" fef2c0 "Auto-discovered, still failing after a re-test; the application's author was asked (stale-discovered.yml); closed if unanswered" + { + echo "$PING_MARKER" + echo "${OWNER:+@$OWNER: }This pull request was opened automatically to add the application to the [AppImage catalog](https://appimage.github.io), and we would like to list it, but its AppImage does not pass the test (the result above says what fails and how to fix it; the test was just run again on the newest release). Could you have a look? Once a fixed release is out, comment \`/retest\` here and it is tested again. If we do not hear anything in $CLOSE_DAYS days, this pull request is closed to keep the queue manageable; the application is proposed again automatically when the repository publishes a new release. Thank you!" + } > "$WORKDIR/c.md" + gh api -X POST "repos/$REPO/issues/$N/comments" -F body=@"$WORKDIR/c.md" >/dev/null + gh api -X POST "repos/$REPO/issues/$N/labels" -f "labels[]=$L_PINGED" >/dev/null + echo "#$N: pinged ${OWNER:+@$OWNER}" + sleep 3 + fi + N_PINGED=$((N_PINGED + 1)) + else + # Step 1: re-test, if the last result is old + if [ -z "$LAST_RESULT" ] || [[ "$LAST_RESULT" > "$CUTOFF" ]] ; then N_FRESH=$((N_FRESH + 1)) ; continue ; fi + if [ "$DRY_RUN" = true ] ; then + echo "#$N: would re-test (last result ${LAST_RESULT%%T*}) and label $L_RETESTED" + else + [ -n "${PAT:-}" ] || { echo "PAT is needed to re-test; stopping" >&2 ; break ; } + ensure_label "$L_RETESTED" fef2c0 "Auto-discovered, failed months ago, tested once more (stale-discovered.yml); the author is pinged if it still fails" + if PAT="$PAT" REPO="$REPO" bash "$SCRIPT_DIR/retest-pr.sh" "$N" >/dev/null 2>&1 ; then + gh api -X POST "repos/$REPO/issues/$N/labels" -f "labels[]=$L_RETESTED" >/dev/null + echo "#$N: re-tested (closed and reopened), labeled $L_RETESTED" + else + echo "#$N: could not re-test; skipped" >&2 ; continue + fi + sleep 3 + fi + N_RETESTED=$((N_RETESTED + 1)) + fi +done < "$WORKDIR/prs.jsonl" + +{ + echo "## Stale auto-discovered pull requests" + echo + W=$([ "$DRY_RUN" = true ] && echo " (would)") + echo "Re-tested$W: $N_RETESTED; pinged$W: $N_PINGED; closed$W: $N_CLOSED (limit $LIMIT; results older than $DAYS days; closed $CLOSE_DAYS days after an unanswered ping)." + echo "Left alone: $N_HUMAN with a comment by a human, $N_FRESH with a result newer than $DAYS days, $N_WAIT waiting (for a re-test result, or for the $CLOSE_DAYS days after the ping)." + [ "$DRY_RUN" = true ] && echo && echo "(dry run: nothing was changed)" +} | tee -a "${GITHUB_STEP_SUMMARY:-/dev/null}" +exit 0 From 8a89464a485a2df0281328f43b97591b0c0c5fbc Mon Sep 17 00:00:00 2001 From: probonopd Date: Sun, 11 Oct 2026 10:39:56 +0000 Subject: [PATCH 3/3] stale-discovered.sh: fetch comments only for candidates, stop when the API quota runs low Walking the comments of all 3400 failed auto-discovered pull requests cost one request each, more than the 1000 an hour the workflow token has. The list already carries updated_at: a pull request with any activity newer than --days cannot have an older result, so its comments are not fetched. Every request goes through api(), which counts them and stops the run (exit 4) when fewer than RESERVE requests are left; the summary prints the count. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01DZvaN9tm5taf5avUvVtmyq --- AGENTS.md | 5 +++- code/stale-discovered.sh | 65 +++++++++++++++++++++++++++++----------- 2 files changed, 52 insertions(+), 18 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 71674f9b57b..618283e08d5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -180,7 +180,10 @@ GitHub Pages (Jekyll) from this repository. A PR that passes the re-test keeps `screenshot-ok` and waits for a maintainer. `discover-apps.sh` treats an `auto-closed` PR like a repository checked without success on its closing day (proposed again when pushed to later), not like one - rejected by a maintainer. + rejected by a maintainer. API budget: the comments of a PR are only fetched + when it is on the ladder or had no activity (`updated_at`) for `days`; the run + stops with exit 4 when fewer than `RESERVE` (200) requests of the hour are left, + and prints how many it used (see "GitHub API budget" below). 4. `.github/workflows/ping-authors.yml` (monthly `schedule`, a real run, and `workflow_dispatch` with `dry_run` (default true), `max_issues` diff --git a/code/stale-discovered.sh b/code/stale-discovered.sh index c22d056a8d4..40144e168bb 100755 --- a/code/stale-discovered.sh +++ b/code/stale-discovered.sh @@ -33,6 +33,12 @@ # keeps screenshot-ok and waits for a maintainer (the health report lists it). # # Needs GH_TOKEN; PAT (SCREENSHOT_UPLOAD_TOKEN) for step 1; GITHUB_REPOSITORY. +# +# API budget: the list of open pull requests costs one request per 100; the +# comments are fetched only for pull requests on the ladder or without any +# activity in D days (updated_at from the list), one request each; every +# action costs 2-5. The run stops (exit 4) when fewer than RESERVE (default +# 200) requests of the hourly quota are left, and prints how many it used. set -uo pipefail @@ -69,36 +75,60 @@ L_RETESTED=retested-stale L_PINGED=pinged-author L_CLOSED=auto-closed -ensure_label() { gh api "repos/$REPO/labels/$1" >/dev/null 2>&1 || gh api -X POST "repos/$REPO/labels" -f name="$1" -f color="$2" -f description="$3" >/dev/null ; } +# Every request goes through api(): it counts them and, every 50 requests, +# stops the run when fewer than RESERVE requests of the hourly quota are left +# (GITHUB_TOKEN has 1000 an hour per repository, a personal token 5000). +RESERVE=${RESERVE:-200} +N_REQUESTS=0 +api() { + N_REQUESTS=$((N_REQUESTS + 1)) + if [ $((N_REQUESTS % 50)) -eq 0 ] ; then + LEFT=$(gh api rate_limit --jq .resources.core.remaining 2>/dev/null || echo 0) + if [ "${LEFT:-0}" -lt "$RESERVE" ] ; then + echo "Only $LEFT API requests left this hour (reserve $RESERVE); stopping after $N_REQUESTS requests" >&2 + exit 4 + fi + fi + api "$@" +} +ensure_label() { api "repos/$REPO/labels/$1" >/dev/null 2>&1 || api -X POST "repos/$REPO/labels" -f name="$1" -f color="$2" -f description="$3" >/dev/null ; } lower() { tr 'A-Z' 'a-z' <<<"$1" ; } # Open auto-discovered pull requests with an error label, oldest first : > "$WORKDIR/prs.jsonl" PAGE=1 while [ "$PAGE" -le 100 ] ; do - gh api "repos/$REPO/pulls?state=open&per_page=100&page=$PAGE&sort=created&direction=asc" > "$WORKDIR/page.json" 2>/dev/null || break + api "repos/$REPO/pulls?state=open&per_page=100&page=$PAGE&sort=created&direction=asc" > "$WORKDIR/page.json" 2>/dev/null || break [ "$(jq 'length' "$WORKDIR/page.json" 2>/dev/null || echo 0)" -gt 0 ] || break jq -c '.[] | select([.labels[].name] | index("auto-discovered")) | select([.labels[].name | select(startswith("error-"))] | length > 0) | select([.labels[].name] | index("screenshot-ok") | not) | select([.labels[].name] | index("manual-check-needed") | not) - | {n:.number, sha:.head.sha, user:.user.login, labels:[.labels[].name]}' "$WORKDIR/page.json" >> "$WORKDIR/prs.jsonl" + | {n:.number, sha:.head.sha, user:.user.login, updated:.updated_at, labels:[.labels[].name]}' "$WORKDIR/page.json" >> "$WORKDIR/prs.jsonl" PAGE=$((PAGE + 1)) done echo "$(wc -l < "$WORKDIR/prs.jsonl") open auto-discovered pull requests with a failed test" >&2 N_RETESTED=0 ; N_PINGED=0 ; N_CLOSED=0 ; N_HUMAN=0 ; N_FRESH=0 ; N_WAIT=0 label_time() { # label_time PR LABEL -> when it was last applied - gh api --paginate "repos/$REPO/issues/$1/events?per_page=100" --jq '.[] | select(.event == "labeled" and .label.name == "'"$2"'") | .created_at' 2>/dev/null | tail -n 1 + api --paginate "repos/$REPO/issues/$1/events?per_page=100" --jq '.[] | select(.event == "labeled" and .label.name == "'"$2"'") | .created_at' 2>/dev/null | tail -n 1 } while IFS= read -r ROW ; do [ $((N_RETESTED + N_PINGED + N_CLOSED)) -lt "$LIMIT" ] || break N=$(jq -r .n <<<"$ROW") - gh api --paginate "repos/$REPO/issues/$N/comments?per_page=100" --jq '.[] | [.user.login, .created_at, (.body | contains("'"$MARKER"'")), (.body | contains("'"$PING_MARKER"'"))] | @tsv' > "$WORKDIR/c.tsv" 2>/dev/null || continue + has_label() { jq -e --arg l "$1" '.labels | index($l)' <<<"$ROW" >/dev/null ; } + # Cheap first: a pull request not yet on the ladder whose last activity (a + # comment, a label, a push) is newer than D days cannot have a result older + # than that, so its comments are not fetched at all. Thousands of pull + # requests fail this test; the comments of the few hundred that pass it are + # one request each. + if ! has_label "$L_PINGED" && ! has_label "$L_RETESTED" && [[ "$(jq -r .updated <<<"$ROW")" > "$CUTOFF" ]] ; then + N_FRESH=$((N_FRESH + 1)) ; continue + fi + api --paginate "repos/$REPO/issues/$N/comments?per_page=100" --jq '.[] | [.user.login, .created_at, (.body | contains("'"$MARKER"'")), (.body | contains("'"$PING_MARKER"'"))] | @tsv' > "$WORKDIR/c.tsv" 2>/dev/null || continue if awk -F'\t' '$1 != "github-actions[bot]" { f = 1 } END { exit !f }' "$WORKDIR/c.tsv" ; then N_HUMAN=$((N_HUMAN + 1)) ; continue fi LAST_RESULT=$(awk -F'\t' '$3 == "true" { t = $2 } END { print t }' "$WORKDIR/c.tsv") PING_AT=$(awk -F'\t' '$4 == "true" { t = $2 } END { print t }' "$WORKDIR/c.tsv") - has_label() { jq -e --arg l "$1" '.labels | index($l)' <<<"$ROW" >/dev/null ; } if has_label "$L_PINGED" ; then # Step 3: close, once the ping is old enough and still unanswered @@ -112,11 +142,11 @@ while IFS= read -r ROW ; do echo "" echo "No answer in $CLOSE_DAYS days, so this automatically opened pull request is closed to keep the queue manageable. Nothing is lost: the application is proposed again automatically once its repository publishes a new release, and a pull request that adds \`data/\` is welcome at any time. Thank you!" } > "$WORKDIR/c.md" - gh api -X POST "repos/$REPO/issues/$N/comments" -F body=@"$WORKDIR/c.md" >/dev/null - gh api -X POST "repos/$REPO/issues/$N/labels" -f "labels[]=$L_CLOSED" >/dev/null - gh api -X DELETE "repos/$REPO/issues/$N/labels/$L_PINGED" >/dev/null 2>&1 || true - gh api -X DELETE "repos/$REPO/issues/$N/labels/$L_RETESTED" >/dev/null 2>&1 || true - gh api -X PATCH "repos/$REPO/pulls/$N" -f state=closed >/dev/null + api -X POST "repos/$REPO/issues/$N/comments" -F body=@"$WORKDIR/c.md" >/dev/null + api -X POST "repos/$REPO/issues/$N/labels" -f "labels[]=$L_CLOSED" >/dev/null + api -X DELETE "repos/$REPO/issues/$N/labels/$L_PINGED" >/dev/null 2>&1 || true + api -X DELETE "repos/$REPO/issues/$N/labels/$L_RETESTED" >/dev/null 2>&1 || true + api -X PATCH "repos/$REPO/pulls/$N" -f state=closed >/dev/null echo "#$N: closed" fi N_CLOSED=$((N_CLOSED + 1)) @@ -126,10 +156,10 @@ while IFS= read -r ROW ; do if [ -z "$LAST_RESULT" ] || [ -z "$LABELED_AT" ] || [[ "$LAST_RESULT" < "$LABELED_AT" ]] ; then N_WAIT=$((N_WAIT + 1)) ; continue ; fi # The GitHub account the AppImage comes from (never the bot, never a maintainer) SHA=$(jq -r .sha <<<"$ROW") - FILE=$(gh api --paginate "repos/$REPO/pulls/$N/files" --jq '.[] | select(.filename | startswith("data/")) | .filename' 2>/dev/null | head -n 1) + FILE=$(api --paginate "repos/$REPO/pulls/$N/files" --jq '.[] | select(.filename | startswith("data/")) | .filename' 2>/dev/null | head -n 1) OWNER="" if [ -n "$FILE" ] ; then - URL=$(gh api -H "Accept: application/vnd.github.raw" "repos/$REPO/contents/$FILE?ref=$SHA" 2>/dev/null | head -n 1 | tr -d '\r') + URL=$(api -H "Accept: application/vnd.github.raw" "repos/$REPO/contents/$FILE?ref=$SHA" 2>/dev/null | head -n 1 | tr -d '\r') OWNER=$(bash "$SCRIPT_DIR/upstream-owner.sh" "$URL" 2>/dev/null | tr -cd 'A-Za-z0-9-') fi case "$(lower "$OWNER")" in ""|*"[bot]"|probonopd|github-actions) OWNER="" ;; esac @@ -141,8 +171,8 @@ while IFS= read -r ROW ; do echo "$PING_MARKER" echo "${OWNER:+@$OWNER: }This pull request was opened automatically to add the application to the [AppImage catalog](https://appimage.github.io), and we would like to list it, but its AppImage does not pass the test (the result above says what fails and how to fix it; the test was just run again on the newest release). Could you have a look? Once a fixed release is out, comment \`/retest\` here and it is tested again. If we do not hear anything in $CLOSE_DAYS days, this pull request is closed to keep the queue manageable; the application is proposed again automatically when the repository publishes a new release. Thank you!" } > "$WORKDIR/c.md" - gh api -X POST "repos/$REPO/issues/$N/comments" -F body=@"$WORKDIR/c.md" >/dev/null - gh api -X POST "repos/$REPO/issues/$N/labels" -f "labels[]=$L_PINGED" >/dev/null + api -X POST "repos/$REPO/issues/$N/comments" -F body=@"$WORKDIR/c.md" >/dev/null + api -X POST "repos/$REPO/issues/$N/labels" -f "labels[]=$L_PINGED" >/dev/null echo "#$N: pinged ${OWNER:+@$OWNER}" sleep 3 fi @@ -156,7 +186,7 @@ while IFS= read -r ROW ; do [ -n "${PAT:-}" ] || { echo "PAT is needed to re-test; stopping" >&2 ; break ; } ensure_label "$L_RETESTED" fef2c0 "Auto-discovered, failed months ago, tested once more (stale-discovered.yml); the author is pinged if it still fails" if PAT="$PAT" REPO="$REPO" bash "$SCRIPT_DIR/retest-pr.sh" "$N" >/dev/null 2>&1 ; then - gh api -X POST "repos/$REPO/issues/$N/labels" -f "labels[]=$L_RETESTED" >/dev/null + api -X POST "repos/$REPO/issues/$N/labels" -f "labels[]=$L_RETESTED" >/dev/null echo "#$N: re-tested (closed and reopened), labeled $L_RETESTED" else echo "#$N: could not re-test; skipped" >&2 ; continue @@ -172,7 +202,8 @@ done < "$WORKDIR/prs.jsonl" echo W=$([ "$DRY_RUN" = true ] && echo " (would)") echo "Re-tested$W: $N_RETESTED; pinged$W: $N_PINGED; closed$W: $N_CLOSED (limit $LIMIT; results older than $DAYS days; closed $CLOSE_DAYS days after an unanswered ping)." - echo "Left alone: $N_HUMAN with a comment by a human, $N_FRESH with a result newer than $DAYS days, $N_WAIT waiting (for a re-test result, or for the $CLOSE_DAYS days after the ping)." + echo "Left alone: $N_HUMAN with a comment by a human, $N_FRESH with a result (or other activity) newer than $DAYS days, $N_WAIT waiting (for a re-test result, or for the $CLOSE_DAYS days after the ping)." + echo "API requests: $N_REQUESTS." [ "$DRY_RUN" = true ] && echo && echo "(dry run: nothing was changed)" } | tee -a "${GITHUB_STEP_SUMMARY:-/dev/null}" exit 0