mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
ci: auto-minimize comments from org-blocked users (#7899)
* ci: auto-minimize comments from org-blocked users Adds a scheduled workflow that runs every hour to scan recent issue/PR comments and minimize any from users blocked at the org level. This cleans up spam comments that were posted before a block was applied. The workflow: 1. Fetches the org's blocked-user list via REST API 2. Queries recent comments (last 2h) via GraphQL 3. Matches comment authors against the blocked list 4. Minimizes unmatched comments as OFF_TOPIC via GraphQL Also triggerable manually via workflow_dispatch with a configurable lookback window. * ci: use repo blocklist file instead of org blocked-users API The org blocked-users API requires admin:org scope which the CI bot PAT doesn't have. Switch to a plain-text blocklist file at .github/spam-blocklist.txt — one username per line, case-insensitive, # for comments. No special scopes needed. Also adds danialzivehdadr as the first entry. * ci: make auto-minimize-spam failures visible, handle empty blocklist * fix(ci): address review feedback on auto-minimize-spam workflow (#7899) - Change hours input type from string to number (project convention) - Extract duplicated step-summary writes into write_summary() function - Capture gh stderr (2>&1) and include it in failure warnings - Document coverage limits in header comment * fix(ci): quote auto-minimize-spam expressions to pass yamllint (#7899) * test(ci): add regression guards for auto-minimize-spam workflow invariants (#7899) --------- Co-authored-by: qwen-code-dev-bot <[email protected]> Co-authored-by: Shaojin Wen <[email protected]> Co-authored-by: qwen-code-ci-bot <[email protected]>
This commit is contained in:
4 files changed
+256
-1
No files matched your search
@@ -0,0 +1,73 @@
|
||||
// Regression guards for the security-critical invariants of the
|
||||
// auto-minimize-spam workflow. Follows the pattern established by
|
||||
// qwen-triage-workflow.test.mjs: a future edit that removes the repository
|
||||
// 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 { readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { describe, it } from 'node:test';
|
||||
import { parse } from 'yaml';
|
||||
|
||||
const workflowPath = join(
|
||||
dirname(fileURLToPath(import.meta.url)),
|
||||
'..',
|
||||
'workflows',
|
||||
'auto-minimize-spam.yml',
|
||||
);
|
||||
const doc = parse(readFileSync(workflowPath, 'utf8'));
|
||||
const minimizeJob = doc.jobs.minimize;
|
||||
const steps = minimizeJob.steps;
|
||||
const checkoutStep = steps.find((s) => s.uses?.startsWith('actions/checkout'));
|
||||
const minimizeStep = steps.find((s) =>
|
||||
s.name?.includes('Minimize comments'),
|
||||
);
|
||||
|
||||
describe('auto-minimize-spam: repository guard', () => {
|
||||
it('gates the job on the canonical repository', () => {
|
||||
assert.match(
|
||||
String(minimizeJob.if),
|
||||
/github\.repository == 'QwenLM\/qwen-code'/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('auto-minimize-spam: permissions', () => {
|
||||
it('has a minimal top-level permissions block', () => {
|
||||
const perms = doc.permissions;
|
||||
assert.deepEqual(perms, {
|
||||
contents: 'read',
|
||||
issues: 'write',
|
||||
'pull-requests': 'write',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not set job-level permissions', () => {
|
||||
assert.equal(
|
||||
minimizeJob.permissions,
|
||||
undefined,
|
||||
'job-level permissions override the top-level block',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('auto-minimize-spam: credential scoping', () => {
|
||||
it('disables persist-credentials on checkout', () => {
|
||||
assert.ok(checkoutStep, 'checkout step must exist');
|
||||
assert.equal(checkoutStep.with['persist-credentials'], false);
|
||||
});
|
||||
|
||||
it('scopes GH_TOKEN to step-level env, not job-level', () => {
|
||||
assert.equal(
|
||||
minimizeJob.env,
|
||||
undefined,
|
||||
'job-level env would expose GH_TOKEN to every step',
|
||||
);
|
||||
assert.ok(minimizeStep, 'minimize step must exist');
|
||||
assert.ok(
|
||||
minimizeStep.env?.GH_TOKEN,
|
||||
'GH_TOKEN must be set in the minimize step env',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
# Users whose comments should be auto-minimized as off-topic.
|
||||
# One username per line, case-insensitive. Lines starting with # are comments.
|
||||
# Add entries here when a user is blocked at the org level or identified as spam.
|
||||
danialzivehdadr
|
||||
@@ -0,0 +1,178 @@
|
||||
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.
|
||||
#
|
||||
# The blocklist is a plain-text file in the repo — one username per
|
||||
# line, case-insensitive, # for comments. No special API scopes
|
||||
# needed beyond issues:write + pull-requests:write.
|
||||
#
|
||||
# Note: the lookback window (LOOKBACK_HOURS) only filters issues —
|
||||
# the GraphQL pullRequests connection has no `since` filter, so PRs
|
||||
# are always scoped to the 100 most recently updated.
|
||||
#
|
||||
# Coverage limits: only issue-style comments are scanned — PR review
|
||||
# (inline) comments, review bodies, and Discussions are not.
|
||||
# comments(last: 30) means a >30-comment flood on a single thread is
|
||||
# only partially cleaned per run.
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '30 * * * *' # Every hour at :30
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
hours:
|
||||
description: 'Look back this many hours (default 2)'
|
||||
required: false
|
||||
default: 2
|
||||
type: 'number'
|
||||
|
||||
permissions:
|
||||
contents: 'read'
|
||||
issues: 'write'
|
||||
pull-requests: 'write'
|
||||
|
||||
concurrency:
|
||||
group: 'auto-minimize-spam'
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
minimize:
|
||||
if: "${{ github.repository == 'QwenLM/qwen-code' }}"
|
||||
runs-on: 'ubuntu-latest'
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: 'Checkout blocklist'
|
||||
uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3
|
||||
with:
|
||||
sparse-checkout: '.github/spam-blocklist.txt'
|
||||
persist-credentials: false
|
||||
|
||||
- name: 'Minimize comments from blocklisted users'
|
||||
env:
|
||||
GH_TOKEN: '${{ secrets.CI_BOT_PAT }}'
|
||||
LOOKBACK_HOURS: "${{ inputs.hours || '2' }}"
|
||||
run: |-
|
||||
set -euo pipefail
|
||||
|
||||
REPO="$GITHUB_REPOSITORY"
|
||||
SINCE="$(date -u -d "${LOOKBACK_HOURS} hours ago" +%Y-%m-%dT%H:%M:%SZ)"
|
||||
BLOCKLIST=".github/spam-blocklist.txt"
|
||||
|
||||
write_summary() {
|
||||
{
|
||||
echo "## Summary"
|
||||
echo "- Scanned comments since: ${SINCE}"
|
||||
echo "- Blocklisted users: ${BLOCKED_COUNT}"
|
||||
echo "- Comments minimized: $1"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
}
|
||||
|
||||
echo "Scanning comments since $SINCE in $REPO"
|
||||
|
||||
# ── 1. Parse blocklist ────────────────────────────────────────
|
||||
if [ ! -f "$BLOCKLIST" ]; then
|
||||
echo "::notice::No blocklist file found at $BLOCKLIST; nothing to do."
|
||||
exit 0
|
||||
fi
|
||||
# Strip comments, blank lines, whitespace; lowercase for matching.
|
||||
# `|| true` keeps an all-comment/blank blocklist from tripping
|
||||
# `set -e` (grep exits 1 when nothing passes the filter).
|
||||
BLOCKED_USERS="$(
|
||||
grep -v '^\s*#' "$BLOCKLIST" \
|
||||
| grep -v '^\s*$' \
|
||||
| sed 's/^[[:space:]]*//;s/[[:space:]]*$//' \
|
||||
| tr '[:upper:]' '[:lower:]' \
|
||||
| sort -u \
|
||||
|| true
|
||||
)"
|
||||
BLOCKED_COUNT="$(printf '%s\n' "$BLOCKED_USERS" | grep -c . || true)"
|
||||
echo "Blocklisted users: ${BLOCKED_COUNT}"
|
||||
if [ "$BLOCKED_COUNT" -eq 0 ]; then
|
||||
echo "Blocklist is empty; nothing to do."
|
||||
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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
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)"
|
||||
'
|
||||
)"
|
||||
|
||||
MATCHED_IDS=""
|
||||
MATCHED_COUNT=0
|
||||
while IFS=$'\t' read -r login node_id; do
|
||||
[ -z "$login" ] && continue
|
||||
login_lc="$(printf '%s' "$login" | tr '[:upper:]' '[:lower:]')"
|
||||
if printf '%s\n' "$BLOCKED_USERS" | grep -qxF "$login_lc"; then
|
||||
MATCHED_IDS="${MATCHED_IDS}${node_id}"$'\n'
|
||||
MATCHED_COUNT=$((MATCHED_COUNT + 1))
|
||||
echo " matched: @${login} → ${node_id}"
|
||||
fi
|
||||
done <<< "$ALL_UNMINIMIZED"
|
||||
|
||||
echo "Unminimized comments from blocklisted users: ${MATCHED_COUNT}"
|
||||
if [ "$MATCHED_COUNT" -eq 0 ]; then
|
||||
echo "Nothing to minimize."
|
||||
write_summary 0
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ── 3. Minimize each matched comment ──────────────────────────
|
||||
SUCCESS=0
|
||||
FAIL=0
|
||||
while IFS= read -r node_id; do
|
||||
[ -z "$node_id" ] && continue
|
||||
result="$(
|
||||
gh api graphql -f query="
|
||||
mutation {
|
||||
minimizeComment(input: {subjectId: \"${node_id}\", classifier: OFF_TOPIC}) {
|
||||
minimizedComment { isMinimized }
|
||||
}
|
||||
}
|
||||
" --jq '.data.minimizeComment.minimizedComment.isMinimized' 2>&1
|
||||
)" || true
|
||||
if [ "$result" = "true" ]; then
|
||||
SUCCESS=$((SUCCESS + 1))
|
||||
else
|
||||
FAIL=$((FAIL + 1))
|
||||
echo "::warning::Failed to minimize ${node_id}: ${result}"
|
||||
fi
|
||||
done <<< "$MATCHED_IDS"
|
||||
|
||||
echo "Minimized ${SUCCESS} comments, ${FAIL} failed."
|
||||
write_summary "$SUCCESS"
|
||||
if [ "$FAIL" -gt 0 ]; then
|
||||
echo "- Failures: ${FAIL}" >> "$GITHUB_STEP_SUMMARY"
|
||||
exit 1
|
||||
fi
|
||||
@@ -50,7 +50,7 @@ env:
|
||||
# BOTH the github_ci_only helper step and the full-profile Test step, so a
|
||||
# new helper test can't be added to one path and silently dropped from the
|
||||
# other.
|
||||
HELPER_TESTS: '.github/scripts/pr-safety-precheck.test.mjs .github/scripts/ci/classify-profile.test.mjs .github/scripts/ci/main-failure-signature.test.mjs .github/scripts/classify-release-notes.test.mjs .github/scripts/dsw-swe-verified/make-manifest.test.mjs .github/scripts/resolve-sandbox-image.test.mjs .github/scripts/web-shell-visuals-publish.test.mjs .github/scripts/web-shell-visuals-compose.test.mjs .github/scripts/serve-ab-diff.test.mjs .github/scripts/qwen-triage-workflow.test.mjs'
|
||||
HELPER_TESTS: '.github/scripts/pr-safety-precheck.test.mjs .github/scripts/ci/classify-profile.test.mjs .github/scripts/ci/main-failure-signature.test.mjs .github/scripts/classify-release-notes.test.mjs .github/scripts/dsw-swe-verified/make-manifest.test.mjs .github/scripts/resolve-sandbox-image.test.mjs .github/scripts/web-shell-visuals-publish.test.mjs .github/scripts/web-shell-visuals-compose.test.mjs .github/scripts/serve-ab-diff.test.mjs .github/scripts/qwen-triage-workflow.test.mjs .github/scripts/auto-minimize-spam.test.mjs'
|
||||
|
||||
jobs:
|
||||
classify_pr:
|
||||
|
||||
Reference in new issue
Block a user