mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
The hosted ubuntu pool is capped at 20 concurrent jobs for the org and sat at 16-20 all day on 2026-10-17 while the ECS pool idled (10 of 94 ecs-qwen slots busy). Seconds-long control-plane jobs queued behind the long hosted lanes for a median of 28 minutes. Route three of them to the ECS pool with the house repository guard and kill-switch. None checks out or runs PR code: - delay-automatic-review gates every automatic PR review. Its comment said the environment wait timer would idle an ECS slot, but the job only gets a runner after the timer elapses (run 38656917461: created 23:54:53, set up 23:59:56, done two seconds later). Also correct the stale 10-minute timer note; the environment is set to 5. - assign-issue-owner runs only the trusted base-ref script on PAT-authored label events. - auto-minimize-spam reads the blocklist from the default branch. Its sparse entry becomes the .github/scripts directory: cone mode rejects a file entry on a reused workspace (#13306), and still brings the files directly under .github/, the blocklist among them. Both checkout jobs gain the self-hosted ownership heal that assign-pr-owner carries. Pin the routing, the kill-switch, the default-branch-only checkout and the reused-workspace safety in ci-runner-routing.test.mjs.
258 lines
10 KiB
YAML
258 lines
10 KiB
YAML
name: 'Auto-minimize spam comments'
|
|
|
|
# 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
|
|
# needed beyond issues:write + pull-requests:write.
|
|
#
|
|
# Note: the lookback window (LOOKBACK_HOURS) filters issues and inline
|
|
# review comments. The GraphQL pullRequests connection has no `since`
|
|
# filter, so PRs are always scoped to the 100 most recently updated.
|
|
#
|
|
# Coverage limits: review bodies and Discussions are not scanned.
|
|
# 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
|
|
issue_comment:
|
|
types: ['created']
|
|
pull_request_review_comment:
|
|
types: ['created']
|
|
push:
|
|
branches: ['main']
|
|
paths: ['.github/spam-blocklist.txt']
|
|
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-${{ github.event.comment.node_id || 'scan' }}"
|
|
cancel-in-progress: false
|
|
|
|
jobs:
|
|
minimize:
|
|
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
|
|
)
|
|
)
|
|
)
|
|
}}
|
|
# Reads the blocklist from the default branch and runs no repository code
|
|
# (comment events use base-repo YAML), so the persistent ECS pool is safe
|
|
# and skips the saturated hosted queue.
|
|
# Kill-switch: MAINTAINER_ECS_RUNNER_DISABLED.
|
|
runs-on: '${{ (github.repository == ''QwenLM/qwen-code'' && vars.MAINTAINER_ECS_RUNNER_DISABLED != ''true'') && fromJSON(''["self-hosted", "linux", "x64", "ecs-qwen"]'') || fromJSON(''["ubuntu-latest"]'') }}'
|
|
timeout-minutes: 10
|
|
steps:
|
|
- name: 'Restore workspace ownership'
|
|
if: "${{ runner.environment == 'self-hosted' }}"
|
|
run: |-
|
|
set -uo pipefail
|
|
RUNNER_UID="$(id -u)"
|
|
RUNNER_GID="$(id -g)"
|
|
if [ "$RUNNER_UID" != "0" ]; then
|
|
chown -R "$RUNNER_UID:$RUNNER_GID" "$GITHUB_WORKSPACE" 2>/dev/null || sudo -n chown -R "$RUNNER_UID:$RUNNER_GID" "$GITHUB_WORKSPACE" || echo "::warning::could not restore workspace ownership; checkout may fail on leftover root-owned files"
|
|
fi
|
|
chmod -R u+rwX "$GITHUB_WORKSPACE" 2>/dev/null || sudo -n chmod -R u+rwX "$GITHUB_WORKSPACE" || echo "::warning::could not restore workspace write permissions; checkout may fail on leftover read-only files"
|
|
|
|
- name: 'Checkout blocklist'
|
|
uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3
|
|
with:
|
|
# A directory, not the file itself; see assign-pr-owner.yml. Cone
|
|
# mode adds the files directly under .github/, the blocklist among
|
|
# them.
|
|
sparse-checkout: '.github/scripts'
|
|
ref: '${{ github.event.repository.default_branch }}'
|
|
persist-credentials: false
|
|
|
|
- name: 'Minimize comments from blocklisted users'
|
|
env:
|
|
GH_TOKEN: '${{ github.token }}'
|
|
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
|
|
|
|
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"
|
|
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"
|
|
}
|
|
|
|
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. 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 }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
" --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)"
|
|
'
|
|
)"
|
|
ALL_CANDIDATES="${ALL_UNMINIMIZED}"$'\n'"${REVIEW_CANDIDATES}"
|
|
fi
|
|
|
|
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
|
|
if ! is_minimized="$(
|
|
gh api graphql -F id="$node_id" -f query="
|
|
query(\$id: ID!) {
|
|
node(id: \$id) {
|
|
... on Minimizable { 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))
|
|
echo " matched: @${login} → ${node_id}"
|
|
fi
|
|
done <<< "$ALL_CANDIDATES"
|
|
|
|
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
|