Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/comment-spam-protection.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
---

ci: add comment spam protection and auto-lock stale issues

New CI workflow that filters malicious links and mass-mentions in comments,
scores newly opened issues for spam, and auto-locks closed issues/PRs
inactive for 30+ days.
73 changes: 73 additions & 0 deletions .github/workflows/squad-comment-moderation.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
name: Squad Comment Moderation

on:
issue_comment:
types: [created, edited]
issues:
types: [opened]
schedule:
- cron: '0 6 * * *'

jobs:
# ── Job 1: Third-party filter for malicious links / mass-mentions ──
comment-filter:
if: github.event_name == 'issue_comment'
runs-on: ubuntu-latest
permissions:
issues: write
steps:
- name: Run Comment Filter
uses: DecimalTurn/Comment-Filter@9c95bdb06ae1dd6b8185d58f52a07a2a71e19d94 # v0
with:
exclude-contributors: 'true'

# ── Job 2: Custom spam scoring for newly-opened issues ──
moderate-new-content:
if: github.event_name == 'issues'
runs-on: ubuntu-latest
permissions:
contents: read
issues: write
steps:
- name: Checkout scripts
uses: actions/checkout@v4
with:
sparse-checkout: scripts/moderate-spam.mjs
sparse-checkout-cone-mode: false

- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: 22

- name: Evaluate issue for spam
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: node scripts/moderate-spam.mjs

Comment thread
diberry marked this conversation as resolved.
# ── Job 3: Auto-lock closed issues/PRs with no activity for 30+ days ──
auto-lock-stale:
if: github.event_name == 'schedule'
runs-on: ubuntu-latest
permissions:
issues: write
pull-requests: write
steps:
- name: Checkout scripts
uses: actions/checkout@v4
with:
sparse-checkout: scripts/lock-stale.mjs
sparse-checkout-cone-mode: false

- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: 22

- name: Lock stale closed issues and PRs
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_REPOSITORY: ${{ github.repository }}
LOCK_AFTER_DAYS: '30'
MAX_ITEMS: '50'
run: node scripts/lock-stale.mjs
Comment thread
diberry marked this conversation as resolved.
10 changes: 10 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -327,6 +327,16 @@ GitHub Actions runs on every push:

All checks must pass before merge.

### Comment Moderation

The `squad-comment-moderation` workflow protects the repo from comment spam:

- **Comment filter** — on every new or edited issue comment, a third-party action blocks malicious links and mass-mentions (contributors are excluded).
- **Spam scoring** — on newly opened issues, `scripts/moderate-spam.mjs` evaluates the content and closes suspected spam.
- **Auto-lock stale** — a daily cron job (`scripts/lock-stale.mjs`) locks closed issues and PRs with no activity for 30+ days to prevent necro-spam.

No contributor action is required — this runs automatically.

## Common Tasks

### Add a CLI Command
Expand Down
138 changes: 138 additions & 0 deletions scripts/lock-stale.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
#!/usr/bin/env node
import { fileURLToPath } from 'node:url';

/**
* Build the cutoff ISO date string for items not updated in `days` days.
* @param {number} days
* @returns {string}
*/
export function buildCutoffDate(days) {
const d = new Date(Date.now() - days * 24 * 60 * 60 * 1000);
return d.toISOString().replace(/\.\d{3}Z$/, 'Z');
}

/**
* Search GitHub for closed issues or PRs not updated since `cutoff`.
* @param {{ type: 'issue' | 'pr', repo: string, cutoff: string, maxItems: number, fetchFn: typeof fetch, headers: Record<string,string> }} opts
* @returns {Promise<Array<{ number: number, type: string }>>}
*/
export async function findStaleItems({ type, repo, cutoff, maxItems, fetchFn, headers }) {
const qualifier = type === 'pr' ? 'type:pr' : 'type:issue';
const q = `repo:${repo} is:closed is:unlocked ${qualifier} updated:<${cutoff}`;
const url = `https://api.github.com/search/issues?q=${encodeURIComponent(q)}&per_page=${maxItems}`;

const res = await fetchFn(url, { headers });
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`GitHub search failed (${res.status}): ${text}`);
}

const data = await res.json();
return (data.items || []).map((item) => ({ number: item.number, type }));
}

/**
* Lock a list of issues/PRs with reason "resolved".
* Handles "already locked" (HTTP 422) gracefully.
* @param {{ items: Array<{ number: number, type: string }>, repo: string, fetchFn: typeof fetch, headers: Record<string,string>, delayMs?: number }} opts
* @returns {Promise<{ lockedIssues: number, lockedPRs: number, skipped: number }>}
*/
export async function lockStaleItems({ items, repo, fetchFn, headers, delayMs = 500 }) {
let lockedIssues = 0;
let lockedPRs = 0;
let skipped = 0;

for (let i = 0; i < items.length; i++) {
const item = items[i];
const lockUrl = `https://api.github.com/repos/${repo}/issues/${item.number}/lock`;

try {
const res = await fetchFn(lockUrl, {
method: 'PUT',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ lock_reason: 'resolved' }),
});

if (res.status === 204 || res.ok) {
if (item.type === 'pr') lockedPRs++;
else lockedIssues++;
console.log(` Locked ${item.type} #${item.number}`);
} else if (res.status === 422) {
skipped++;
console.log(` Skipped ${item.type} #${item.number} (already locked)`);
} else {
const text = await res.text().catch(() => '');
console.error(` ::warning::Failed to lock ${item.type} #${item.number}: HTTP ${res.status} ${text}`);
}
} catch (err) {
console.error(` ::warning::Failed to lock ${item.type} #${item.number}: ${err.message}`);
}

// Delay between calls to avoid rate limiting (skip after last item)
if (i < items.length - 1 && delayMs > 0) {
await new Promise((resolve) => setTimeout(resolve, delayMs));
}
}

return { lockedIssues, lockedPRs, skipped };
}

/**
* Main entry point: find and lock stale closed issues and PRs.
* Configured via env vars or explicit options for testing.
*/
export async function run({ env = process.env, fetchFn = globalThis.fetch } = {}) {
const token = env.GITHUB_TOKEN;
const repo = env.GITHUB_REPOSITORY;
const lockAfterDays = parseInt(env.LOCK_AFTER_DAYS || '30', 10);
const maxItems = parseInt(env.MAX_ITEMS || '50', 10);

if (!token || !repo) {
throw new Error('Missing required environment variables: GITHUB_TOKEN, GITHUB_REPOSITORY');
}

const headers = {
Authorization: `token ${token}`,
Accept: 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28',
};

const cutoff = buildCutoffDate(lockAfterDays);
console.log(`Locking closed items in ${repo} inactive since ${cutoff} (max ${maxItems})…`);

let issues = [];
let prs = [];

try {
issues = await findStaleItems({ type: 'issue', repo, cutoff, maxItems, fetchFn, headers });
} catch (err) {
console.error(`Failed to search stale issues: ${err.message}`);
}

try {
prs = await findStaleItems({ type: 'pr', repo, cutoff, maxItems, fetchFn, headers });
} catch (err) {
console.error(`Failed to search stale PRs: ${err.message}`);
}

const allItems = [...issues, ...prs];
if (allItems.length === 0) {
console.log('No stale items found.');
return { lockedIssues: 0, lockedPRs: 0, skipped: 0 };
}

console.log(`Found ${issues.length} issues and ${prs.length} PRs to process.`);

const result = await lockStaleItems({ items: allItems, repo, fetchFn, headers, delayMs: 500 });
console.log(`Locked ${result.lockedIssues} issues, ${result.lockedPRs} PRs. Skipped ${result.skipped} (already locked).`);
return result;
}

if (process.argv[1] === fileURLToPath(import.meta.url)) {
run()
.then(() => process.exit(0))
.catch((err) => {
console.error(err);
process.exit(1);
});
}
Loading
Loading