From d8b5d532c6ff9e41a6e8585ff1260dff627799d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=98=93=E8=89=AF?= <1204183885@qq.com> Date: Mon, 17 Aug 2026 05:12:04 +0000 Subject: [PATCH] fix(ci): minimize new spam comments on creation (#9266) * fix(ci): run spam cleanup every five minutes * fix(ci): minimize spam comments on creation * fix(ci): tolerate deleted spam comments * fix(ci): preserve live spam comments * test(ci): evaluate spam minimizer jq filter * fix(ci): handle deleted spam comments * test(ci): pin spam minimizer guards * test(ci): pin spam guard parentheses --- .github/scripts/auto-minimize-spam.test.mjs | 99 ++++++++++++++ .github/workflows/auto-minimize-spam.yml | 139 +++++++++++++------- 2 files changed, 188 insertions(+), 50 deletions(-) diff --git a/.github/scripts/auto-minimize-spam.test.mjs b/.github/scripts/auto-minimize-spam.test.mjs index d1a5040ec9..6c0b63edf3 100644 --- a/.github/scripts/auto-minimize-spam.test.mjs +++ b/.github/scripts/auto-minimize-spam.test.mjs @@ -4,6 +4,7 @@ // guard, widens permissions, moves GH_TOKEN to job-level env, or drops // persist-credentials would ship without any other test to catch it. import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; import { readFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -22,6 +23,18 @@ const steps = minimizeJob.steps; const checkoutStep = steps.find((s) => s.uses?.startsWith('actions/checkout')); const minimizeStep = steps.find((s) => s.name?.includes('Minimize comments')); +function runMinimizableStateFilter(payload) { + assert.ok(minimizeStep, 'minimize step must exist'); + const filter = [...minimizeStep.run.matchAll(/--jq '([^']+)'/g)] + .map((match) => match[1]) + .find((candidate) => candidate.includes('.data.node')); + assert.ok(filter, 'minimizable state jq filter must exist'); + return execFileSync('jq', ['-r', filter], { + input: JSON.stringify(payload), + encoding: 'utf8', + }).trim(); +} + describe('auto-minimize-spam: repository guard', () => { it('gates the job on the canonical repository', () => { assert.match( @@ -71,6 +84,92 @@ describe('auto-minimize-spam: credential scoping', () => { }); }); +describe('auto-minimize-spam: event fast path', () => { + it('handles new comments and blocklist changes with an hourly fallback', () => { + assert.equal(doc.on.schedule[0].cron, '30 * * * *'); + assert.deepEqual(doc.on.issue_comment.types, ['created']); + assert.deepEqual(doc.on.pull_request_review_comment.types, ['created']); + assert.deepEqual(doc.on.push, { + branches: ['main'], + paths: ['.github/spam-blocklist.txt'], + }); + assert.match(String(minimizeJob.if), /github\.event_name == 'push'/); + }); + + it('processes the triggering comment without dropping bursts', () => { + const jobGuard = String(minimizeJob.if); + const flatJobGuard = jobGuard.replace(/\s+/g, ' '); + assert.match( + jobGuard, + /comment\.user\.type != 'Bot'[\s\S]*!contains\([\s\S]*OWNER[\s\S]*MEMBER[\s\S]*COLLABORATOR[\s\S]*github\.event\.comment\.author_association/, + ); + assert.match( + flatJobGuard, + /author_association \) && \( github\.event_name != 'pull_request_review_comment' \|\| github\.event\.pull_request\.head\.repo\.full_name == github\.repository \)/, + ); + assert.match(jobGuard, /head\.repo\.full_name == github\.repository/); + assert.match( + jobGuard, + /github\.event_name != 'pull_request_review_comment' \|\|/, + ); + assert.equal( + String(doc.concurrency.group), + "auto-minimize-spam-${{ github.event.comment.node_id || 'scan' }}", + ); + assert.equal( + checkoutStep.with.ref, + '${{ github.event.repository.default_branch }}', + ); + assert.equal( + minimizeStep.env?.EVENT_COMMENT_LOGIN, + '${{ github.event.comment.user.login }}', + ); + assert.equal( + minimizeStep.env?.EVENT_COMMENT_NODE_ID, + '${{ github.event.comment.node_id }}', + ); + assert.doesNotMatch(minimizeStep.run, /\$\{\{\s*github\.event\./); + assert.equal( + minimizeStep.run.match(/\[ -n "\$EVENT_COMMENT_NODE_ID" \]/g)?.length, + 2, + ); + assert.match( + minimizeStep.run, + /ALL_CANDIDATES="\$\{EVENT_COMMENT_LOGIN\}"\$'\\t'"\$\{EVENT_COMMENT_NODE_ID\}"/, + ); + assert.equal( + runMinimizableStateFilter({ + data: { node: { isMinimized: false } }, + }), + 'false', + ); + assert.equal( + runMinimizableStateFilter({ + data: { node: { isMinimized: true } }, + }), + 'true', + ); + assert.equal( + minimizeStep.env?.LOOKBACK_HOURS, + "${{ inputs.hours || (github.event_name == 'push' && '72') || '2' }}", + ); + assert.equal( + runMinimizableStateFilter({ + data: { node: null }, + }), + 'missing', + ); + assert.match(minimizeStep.run, /if ! is_minimized="\$\(/); + assert.match(minimizeStep.run, /then\n\s+is_minimized="missing"\n\s+fi/); + assert.doesNotMatch(minimizeStep.run, /\|\| printf 'missing'/); + assert.doesNotMatch(minimizeStep.run, /2>\/dev\/null/); + assert.match( + minimizeStep.run, + /\[ "\$is_minimized" = "missing" \] && continue/, + ); + }); +}); + describe('auto-minimize-spam: comment coverage', () => { it('scans inline PR review comments without re-minimizing them', () => { assert.ok(minimizeStep, 'minimize step must exist'); diff --git a/.github/workflows/auto-minimize-spam.yml b/.github/workflows/auto-minimize-spam.yml index 26ccbb8383..3238db23c2 100644 --- a/.github/workflows/auto-minimize-spam.yml +++ b/.github/workflows/auto-minimize-spam.yml @@ -1,10 +1,9 @@ name: 'Auto-minimize spam comments' -# Periodically scan recent issue/PR comments and minimize any from -# users listed in .github/spam-blocklist.txt. This cleans up spam -# comments that were posted before a block was applied, and catches -# any that slip through during the window between a spam comment -# and the manual block action. +# Minimize new issue/PR comments from users listed in +# .github/spam-blocklist.txt. Comment events check only the triggering +# comment; blocklist updates and the hourly scan clean up older comments +# and cover events that run with a read-only token. # # The blocklist is a plain-text file in the repo — one username per # line, case-insensitive, # for comments. No special API scopes @@ -21,6 +20,13 @@ name: 'Auto-minimize spam comments' on: schedule: - cron: '30 * * * *' # Every hour at :30 + issue_comment: + types: ['created'] + pull_request_review_comment: + types: ['created'] + push: + branches: ['main'] + paths: ['.github/spam-blocklist.txt'] workflow_dispatch: inputs: hours: @@ -35,12 +41,31 @@ permissions: pull-requests: 'write' concurrency: - group: 'auto-minimize-spam' + group: "auto-minimize-spam-${{ github.event.comment.node_id || 'scan' }}" cancel-in-progress: false jobs: minimize: - if: "${{ github.repository == 'QwenLM/qwen-code' }}" + if: >- + ${{ + github.repository == 'QwenLM/qwen-code' && + ( + github.event_name == 'schedule' || + github.event_name == 'workflow_dispatch' || + github.event_name == 'push' || + ( + github.event.comment.user.type != 'Bot' && + !contains( + fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), + github.event.comment.author_association + ) && + ( + github.event_name != 'pull_request_review_comment' || + github.event.pull_request.head.repo.full_name == github.repository + ) + ) + ) + }} runs-on: 'ubuntu-latest' timeout-minutes: 10 steps: @@ -48,12 +73,15 @@ jobs: uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3 with: sparse-checkout: '.github/spam-blocklist.txt' + ref: '${{ github.event.repository.default_branch }}' persist-credentials: false - name: 'Minimize comments from blocklisted users' env: GH_TOKEN: '${{ github.token }}' - LOOKBACK_HOURS: "${{ inputs.hours || '2' }}" + LOOKBACK_HOURS: "${{ inputs.hours || (github.event_name == 'push' && '72') || '2' }}" + EVENT_COMMENT_LOGIN: '${{ github.event.comment.user.login }}' + EVENT_COMMENT_NODE_ID: '${{ github.event.comment.node_id }}' run: |- set -euo pipefail @@ -64,7 +92,11 @@ jobs: write_summary() { { echo "## Summary" - echo "- Scanned comments since: ${SINCE}" + if [ -n "$EVENT_COMMENT_NODE_ID" ]; then + echo "- Scanned comment: ${EVENT_COMMENT_NODE_ID}" + else + echo "- Scanned comments since: ${SINCE}" + fi echo "- Blocklisted users: ${BLOCKED_COUNT}" echo "- Comments minimized: $1" } >> "$GITHUB_STEP_SUMMARY" @@ -95,51 +127,55 @@ jobs: exit 0 fi - # ── 2. Fetch recent unminimized comments via GraphQL ────────── - ALL_UNMINIMIZED="$( - gh api graphql -f query=" - query { - repository(owner: \"${REPO%%/*}\", name: \"${REPO##*/}\") { - issues(first: 100, orderBy: {field: UPDATED_AT, direction: DESC}, filterBy: {since: \"${SINCE}\"}) { - nodes { - number - comments(last: 30) { - nodes { id author { login } isMinimized } + # ── 2. Select the triggering comment or scan recent comments ──── + if [ -n "$EVENT_COMMENT_NODE_ID" ]; then + ALL_CANDIDATES="${EVENT_COMMENT_LOGIN}"$'\t'"${EVENT_COMMENT_NODE_ID}" + else + ALL_UNMINIMIZED="$( + gh api graphql -f query=" + query { + repository(owner: \"${REPO%%/*}\", name: \"${REPO##*/}\") { + issues(first: 100, orderBy: {field: UPDATED_AT, direction: DESC}, filterBy: {since: \"${SINCE}\"}) { + nodes { + number + comments(last: 30) { + nodes { id author { login } isMinimized } + } } } - } - pullRequests(first: 100, orderBy: {field: UPDATED_AT, direction: DESC}) { - nodes { - number - comments(last: 30) { - nodes { id author { login } isMinimized } + pullRequests(first: 100, orderBy: {field: UPDATED_AT, direction: DESC}) { + nodes { + number + comments(last: 30) { + nodes { id author { login } isMinimized } + } } } } } - } - " --jq ' - [ - .data.repository.issues.nodes[].comments.nodes[], - .data.repository.pullRequests.nodes[].comments.nodes[] - ] - | map(select(.isMinimized == false and .author != null)) - | .[] | "\(.author.login)\t\(.id)" - ' - )" - - REVIEW_CANDIDATES="$( - gh api --method GET --paginate \ - "repos/${REPO}/pulls/comments" \ - -f since="$SINCE" \ - -F per_page=100 \ - --jq ' - .[] - | select(.user != null and .node_id != null) - | "\(.user.login)\t\(.node_id)" + " --jq ' + [ + .data.repository.issues.nodes[].comments.nodes[], + .data.repository.pullRequests.nodes[].comments.nodes[] + ] + | map(select(.isMinimized == false and .author != null)) + | .[] | "\(.author.login)\t\(.id)" ' - )" - ALL_CANDIDATES="${ALL_UNMINIMIZED}"$'\n'"${REVIEW_CANDIDATES}" + )" + + REVIEW_CANDIDATES="$( + gh api --method GET --paginate \ + "repos/${REPO}/pulls/comments" \ + -f since="$SINCE" \ + -F per_page=100 \ + --jq ' + .[] + | select(.user != null and .node_id != null) + | "\(.user.login)\t\(.node_id)" + ' + )" + ALL_CANDIDATES="${ALL_UNMINIMIZED}"$'\n'"${REVIEW_CANDIDATES}" + fi MATCHED_IDS="" MATCHED_COUNT=0 @@ -147,15 +183,18 @@ jobs: [ -z "$login" ] && continue login_lc="$(printf '%s' "$login" | tr '[:upper:]' '[:lower:]')" if printf '%s\n' "$BLOCKED_USERS" | grep -qxF "$login_lc"; then - is_minimized="$( + if ! is_minimized="$( gh api graphql -F id="$node_id" -f query=" query(\$id: ID!) { node(id: \$id) { ... on Minimizable { isMinimized } } } - " --jq '.data.node.isMinimized' - )" + " --jq 'if .data.node == null then "missing" else .data.node.isMinimized end' + )"; then + is_minimized="missing" + fi + [ "$is_minimized" = "missing" ] && continue [ "$is_minimized" = "true" ] && continue MATCHED_IDS="${MATCHED_IDS}${node_id}"$'\n' MATCHED_COUNT=$((MATCHED_COUNT + 1))