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
This commit is contained in:
易良 2026-08-17 05:12:04 +00:00 committed by GitHub
parent 5abd367f47
commit d8b5d532c6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 188 additions and 50 deletions

View file

@ -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');

View file

@ -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))