diff --git a/.github/workflows/review-suspicious-stars.yml b/.github/workflows/review-suspicious-stars.yml new file mode 100644 index 00000000000..385c7ccad3c --- /dev/null +++ b/.github/workflows/review-suspicious-stars.yml @@ -0,0 +1,57 @@ +# Looks again at the auto-discovered pull requests that got the label +# suspicious-stars (many stars on a brand-new repository with few forks and +# commits, see code/discover-apps.sh): 30 days later a genuine project has forks +# in proportion to its stars (at least 10, no more than 30 stars per fork) or a +# real commit history (300 commits). Then code/review-suspicious-stars.sh +# removes suspicious-stars and manual-check-needed and comments once with the +# numbers; a repository that still looks hollow keeps its labels. Monthly, and +# by hand (a dry run by default). + +name: Review suspicious stars + +on: + schedule: + - cron: '23 5 12 * *' # monthly, a real run (the inputs below are for runs by hand) + workflow_dispatch: + inputs: + days: + description: 'Only pull requests opened at least this many days ago' + type: number + default: 30 + limit: + description: 'Clear at most this many pull requests in this run' + type: number + default: 50 + dry_run: + description: 'Dry run (only print what would be cleared)' + type: boolean + default: true + +jobs: + review: + if: github.repository == 'AppImage/appimage.github.io' + runs-on: ubuntu-latest + timeout-minutes: 30 + concurrency: + group: review-suspicious-stars + cancel-in-progress: false + permissions: + pull-requests: write + issues: write + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_REPOSITORY: ${{ github.repository }} + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Review the labeled pull requests + env: + DAYS: ${{ github.event.inputs.days || '30' }} + LIMIT: ${{ github.event.inputs.limit || '50' }} + # A scheduled run is a real run; a run by hand is a dry run unless dry_run is false + DRY_RUN: ${{ github.event_name == 'schedule' && 'false' || github.event.inputs.dry_run }} + run: | + ARGS=(--days "$DAYS" --limit "$LIMIT") + [ "$DRY_RUN" != "false" ] && ARGS+=(--dry-run) + bash code/review-suspicious-stars.sh "${ARGS[@]}" diff --git a/AGENTS.md b/AGENTS.md index 259730d35e3..9a351244f1a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -288,8 +288,20 @@ GitHub Pages (Jekyll) from this repository. a fork whose source publishes no AppImage gets the `not-upstream` label. A very high star count for a brand-new repository (more than ~15 stars per day since it was created, at least 300, under ~2 years old) can mean bought - or botted stars, so the PR gets the yellow `manual-check-needed` label and - a note (which also keeps it out of auto-merge). + or botted stars, but genuine projects take off like that too (in October + 2026, 61 of 73 PRs flagged by that rule alone were genuine): so the PR is + only flagged when the repository also looks hollow, with fewer than 10 + forks or more than 30 stars per fork AND fewer than 300 commits (one + request, the last page of `commits?per_page=1`). Then it gets the labels + `suspicious-stars` (the reason) and `manual-check-needed` (what auto-merge + and the health report look at) and a note with the numbers; a fast-growing + but established repository gets the numbers as a neutral note instead. + `.github/workflows/review-suspicious-stars.yml` (monthly, and + `workflow_dispatch` with `days` (30), `limit` (50) and `dry_run` (default + true); `code/review-suspicious-stars.sh`) looks at the labeled PRs again + once they are 30 days old and removes both labels with one comment (marker + ``) when forks or commits have caught up; + a repository that still looks hollow keeps them. The name (file in `data/`, branch and PR title) is the proper spelling picked by `code/pick-name.sh` from the repository name, the AppImage's name, the description and the README's diff --git a/code/discover-apps.sh b/code/discover-apps.sh index 3f947e8d030..945edf5de87 100755 --- a/code/discover-apps.sh +++ b/code/discover-apps.sh @@ -735,22 +735,42 @@ while read -r FULLNAME ; do fi done - # Very high star count for a brand-new project: a sign of bought or - # botted stars, so a maintainer should look (label manual-check-needed). - # Suspicious when the repository gathered stars implausibly fast: created - # not long ago yet already highly starred (age in days from .created_at). + # Very high star count for a brand-new project: can be a sign of bought or + # botted stars, so a maintainer should look (labels suspicious-stars and + # manual-check-needed). Suspicious when the repository gathered stars + # implausibly fast (created not long ago yet already highly starred) AND + # the rest of the repository does not look like a real project: real + # projects that take off (the press writes about them) have forks in + # proportion to their stars and a commit history, bought stars have + # neither. In October 2026, 61 of 73 PRs flagged by the velocity rule alone + # were genuine (fork ratio and commit count cleared them), so a flag now + # needs stars/forks above 30 (or fewer than 10 forks) and fewer than 300 + # commits (one request: the last page number of commits?per_page=1). SUSPICIOUS_STARS="" + STARS_FACTS="" CREATED=$(jq -r '.created_at // empty' <<<"$META") + FORKS=$(jq -r '.forks_count // 0' <<<"$META") + [[ "$FORKS" =~ ^[0-9]+$ ]] || FORKS=0 if [[ "$CREATED" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2} ]] && [[ "$STARS" =~ ^[0-9]+$ ]] ; then CREATED_S=$(date -u -d "${CREATED%%T*}" +%s 2>/dev/null || date -u -j -f %Y-%m-%d "${CREATED%%T*}" +%s 2>/dev/null || echo 0) if [ "$CREATED_S" -gt 0 ] ; then AGE_DAYS=$(( ( $(date -u +%s) - CREATED_S ) / 86400 )) [ "$AGE_DAYS" -lt 1 ] && AGE_DAYS=1 # More than 15 stars per day on average, and young enough that this is - # not just an old popular project (under ~2 years): flag it + # not just an old popular project (under ~2 years) if [ "$AGE_DAYS" -lt 730 ] && [ "$STARS" -ge $(( AGE_DAYS * 15 )) ] && [ "$STARS" -ge 300 ] ; then - SUSPICIOUS_STARS="$STARS stars for a repository created $AGE_DAYS day(s) ago ($(sanitize "${CREATED%%T*}"))" - echo " suspicious stars? $SUSPICIOUS_STARS" + COMMITS=$(api GET "repos/$OWNER/$RNAME/commits?per_page=1" -D - -o /dev/null 2>/dev/null \ + | grep -i '^link:' | grep -o 'page=[0-9]*>; rel="last"' | grep -o '[0-9]*' | head -n 1) + [[ "$COMMITS" =~ ^[0-9]+$ ]] || COMMITS=1 + STARS_FACTS="$STARS stars, $FORKS forks, $COMMITS commits, created $AGE_DAYS day(s) ago ($(sanitize "${CREATED%%T*}"))" + # Established after all: forks in proportion (at least 10, and no more + # than 30 stars per fork), or a real commit history + if { [ "$FORKS" -ge 10 ] && [ $(( STARS / FORKS )) -le 30 ] ; } || [ "$COMMITS" -ge 300 ] ; then + echo " fast-growing but established: $STARS_FACTS" + else + SUSPICIOUS_STARS="$STARS_FACTS" + echo " suspicious stars? $SUSPICIOUS_STARS" + fi fi fi fi @@ -769,7 +789,11 @@ while read -r FULLNAME ; do if [ -n "$SUSPICIOUS_STARS" ] ; then NOTE="$NOTE -**Unusual star count:** $SUSPICIOUS_STARS (label \`manual-check-needed\`). A very high star count on a brand-new project can mean bought or botted stars; please check that the project is genuine before merging." +**Unusual star count:** $SUSPICIOUS_STARS (labels \`suspicious-stars\` and \`manual-check-needed\`). Many stars on a brand-new repository with few forks and commits can mean bought or botted stars; please check that the project is genuine before merging. \`.github/workflows/review-suspicious-stars.yml\` looks at these numbers again after 30 days and removes the labels when they have become healthy." + elif [ -n "$STARS_FACTS" ] ; then + NOTE="$NOTE + +**Fast-growing project:** $STARS_FACTS. The forks and commits are in proportion to the stars, so it was not flagged." fi if [ -n "$VIA" ] ; then NOTE="$NOTE @@ -855,7 +879,11 @@ discover-apps.yml. Needs a maintainer's review." API_TOKEN="$PR_TOKEN" api POST "repos/$REPO/labels" -d "$(jq -n '{name:"repackaged", color:"d4c5f9", description:"Built by a third party from someone else'"'"'s application (an unofficial AppImage, repackaged)"}')" >/dev/null fi if [ -n "$SUSPICIOUS_STARS" ] ; then - LABELS+=(manual-check-needed) + # suspicious-stars says why; manual-check-needed is what the other + # workflows (auto-merge, health report) look at + LABELS+=(suspicious-stars manual-check-needed) + API_TOKEN="$PR_TOKEN" api GET "repos/$REPO/labels/suspicious-stars" >/dev/null 2>&1 || \ + API_TOKEN="$PR_TOKEN" api POST "repos/$REPO/labels" -d "$(jq -n '{name:"suspicious-stars", color:"fbca04", description:"Many stars on a brand-new repository with few forks and commits: possibly bought; review-suspicious-stars.yml re-checks after 30 days"}')" >/dev/null API_TOKEN="$PR_TOKEN" api GET "repos/$REPO/labels/manual-check-needed" >/dev/null 2>&1 || \ API_TOKEN="$PR_TOKEN" api POST "repos/$REPO/labels" -d "$(jq -n '{name:"manual-check-needed", color:"fbca04", description:"A maintainer should check this before merging"}')" >/dev/null fi diff --git a/code/review-suspicious-stars.sh b/code/review-suspicious-stars.sh new file mode 100755 index 00000000000..425ad6d681c --- /dev/null +++ b/code/review-suspicious-stars.sh @@ -0,0 +1,112 @@ +#!/bin/bash + +# Look again at the pull requests that discover-apps.sh labeled +# suspicious-stars (many stars on a brand-new repository with few forks and +# commits), once they are old enough that a genuine project has grown into its +# stars: forks in proportion (at least 10, no more than 30 stars per fork) or a +# real commit history (300 commits). Then the labels suspicious-stars and +# manual-check-needed are removed and one comment gives the numbers; otherwise +# the pull request is left as it is (the comment is never repeated). Used by +# .github/workflows/review-suspicious-stars.yml (monthly, and workflow_dispatch +# with dry_run); runnable by hand. +# +# Usage: review-suspicious-stars.sh [--dry-run] [--days D] [--limit N] +# --days D Only pull requests opened at least D days ago (default 30). +# --limit N Clear at most N pull requests per run (default 50). +# +# Needs GH_TOKEN (and GITHUB_REPOSITORY, default AppImage/appimage.github.io). +# API budget: one request per 100 labeled pull requests, then 2 per candidate +# (repository, commit count) and 3 per cleared one; stops when fewer than +# RESERVE (200) requests of the hour are left. + +set -uo pipefail + +REPO="${GITHUB_REPOSITORY:-AppImage/appimage.github.io}" +DRY_RUN=false +DAYS=30 +LIMIT=50 +while [ $# -gt 0 ] ; do + case "$1" in + --dry-run) DRY_RUN=true ;; + --days) DAYS="$2" ; shift ;; + --days=*) DAYS="${1#*=}" ;; + --limit) LIMIT="$2" ; shift ;; + --limit=*) LIMIT="${1#*=}" ;; + *) echo "Unknown argument: $1" >&2 ; exit 2 ;; + esac + shift +done +[[ "$DAYS" =~ ^[0-9]+$ ]] || DAYS=30 +[[ "$LIMIT" =~ ^[0-9]+$ ]] && [ "$LIMIT" -ge 1 ] || LIMIT=50 +MARKER='' +L_STARS=suspicious-stars +L_MANUAL=manual-check-needed +WORKDIR=$(mktemp -d) +trap 'rm -rf "$WORKDIR"' EXIT + +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 + gh api "$@" +} + +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) +# Open pull requests with the label, oldest first; the repository is in the +# body ("Repository: https://github.com/OWNER/REPO", written by discover-apps.sh) +api --paginate "repos/$REPO/issues?state=open&labels=$L_STARS&per_page=100&sort=created&direction=asc" \ + --jq '.[] | select(.pull_request) | [.number, .created_at, ((.body // "") | capture("Repository: https://github.com/(?[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+)").r // "")] | @tsv' \ + > "$WORKDIR/prs.tsv" 2>/dev/null || true +echo "$(wc -l < "$WORKDIR/prs.tsv") open pull requests labeled $L_STARS" >&2 + +N_CLEARED=0 ; N_KEPT=0 ; N_YOUNG=0 ; N_NOREPO=0 +: > "$WORKDIR/summary.md" +while IFS=$'\t' read -r N CREATED OR ; do + [ "$N_CLEARED" -lt "$LIMIT" ] || break + if [[ "$CREATED" > "$CUTOFF" ]] ; then N_YOUNG=$((N_YOUNG + 1)) ; continue ; fi + if [ -z "$OR" ] ; then N_NOREPO=$((N_NOREPO + 1)) ; echo "#$N: no repository in the body; left alone" ; continue ; fi + META=$(api "repos/$OR" 2>/dev/null) || { echo "#$N: $OR not readable; left alone" ; N_KEPT=$((N_KEPT + 1)) ; continue ; } + STARS=$(jq -r '.stargazers_count // 0' <<<"$META") ; FORKS=$(jq -r '.forks_count // 0' <<<"$META") + [[ "$STARS" =~ ^[0-9]+$ ]] || STARS=0 ; [[ "$FORKS" =~ ^[0-9]+$ ]] || FORKS=0 + COMMITS=$(api -i "repos/$OR/commits?per_page=1" 2>/dev/null | grep -i '^link:' | grep -o 'page=[0-9]*>; rel="last"' | grep -o '[0-9]*' | head -n 1) + [[ "$COMMITS" =~ ^[0-9]+$ ]] || COMMITS=1 + FACTS="$STARS stars, $FORKS forks, $COMMITS commits" + if { [ "$FORKS" -ge 10 ] && [ $(( STARS / FORKS )) -le 30 ] ; } || [ "$COMMITS" -ge 300 ] ; then + if [ "$DRY_RUN" = true ] ; then + echo "#$N ($OR): would clear ($FACTS)" + else + { + echo "$MARKER" + echo "The repository was flagged for an unusual star count when this pull request was opened. $DAYS days later it has $FACTS: forks and commits in proportion to the stars, as with a genuine project, so the labels \`$L_STARS\` and \`$L_MANUAL\` are removed. The test result above still decides whether the AppImage can be merged." + } > "$WORKDIR/c.md" + api -X POST "repos/$REPO/issues/$N/comments" -F body=@"$WORKDIR/c.md" >/dev/null + api -X DELETE "repos/$REPO/issues/$N/labels/$L_STARS" >/dev/null 2>&1 || true + api -X DELETE "repos/$REPO/issues/$N/labels/$L_MANUAL" >/dev/null 2>&1 || true + echo "#$N ($OR): cleared ($FACTS)" + fi + echo "- #$N \`$OR\`: cleared ($FACTS)" >> "$WORKDIR/summary.md" + N_CLEARED=$((N_CLEARED + 1)) + else + echo "#$N ($OR): still suspicious ($FACTS)" + echo "- #$N \`$OR\`: still suspicious ($FACTS)" >> "$WORKDIR/summary.md" + N_KEPT=$((N_KEPT + 1)) + fi +done < "$WORKDIR/prs.tsv" + +{ + echo "## Suspicious stars, reviewed" + echo + W=$([ "$DRY_RUN" = true ] && echo " (would be)") + echo "Cleared$W: $N_CLEARED; still suspicious: $N_KEPT; younger than $DAYS days: $N_YOUNG; no repository in the body: $N_NOREPO. API requests: $N_REQUESTS." + echo + cat "$WORKDIR/summary.md" + [ "$DRY_RUN" = true ] && echo && echo "(dry run: nothing was changed)" +} | tee -a "${GITHUB_STEP_SUMMARY:-/dev/null}" +exit 0