mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-08-22 07:04:58 +00:00
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:
parent
5abd367f47
commit
d8b5d532c6
2 changed files with 188 additions and 50 deletions
99
.github/scripts/auto-minimize-spam.test.mjs
vendored
99
.github/scripts/auto-minimize-spam.test.mjs
vendored
|
|
@ -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');
|
||||
|
|
|
|||
139
.github/workflows/auto-minimize-spam.yml
vendored
139
.github/workflows/auto-minimize-spam.yml
vendored
|
|
@ -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))
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue