diff --git a/.changeset/comment-spam-protection.md b/.changeset/comment-spam-protection.md new file mode 100644 index 000000000..bc1b28185 --- /dev/null +++ b/.changeset/comment-spam-protection.md @@ -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. diff --git a/.github/workflows/squad-comment-moderation.yml b/.github/workflows/squad-comment-moderation.yml new file mode 100644 index 000000000..8e9489773 --- /dev/null +++ b/.github/workflows/squad-comment-moderation.yml @@ -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 + + # ── 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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 69004421e..ff82922ae 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 diff --git a/scripts/lock-stale.mjs b/scripts/lock-stale.mjs new file mode 100644 index 000000000..27d8cde00 --- /dev/null +++ b/scripts/lock-stale.mjs @@ -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 }} opts + * @returns {Promise>} + */ +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, 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); + }); +} diff --git a/scripts/moderate-spam.mjs b/scripts/moderate-spam.mjs new file mode 100644 index 000000000..ac0c3654b --- /dev/null +++ b/scripts/moderate-spam.mjs @@ -0,0 +1,195 @@ +#!/usr/bin/env node +import { fileURLToPath } from 'node:url'; + +/** Spam signal patterns and thresholds. */ +export const SPAM_SIGNALS = { + shortUrls: /bit\.ly|tinyurl|t\.co|goo\.gl|rb\.gy/gi, + fileSharing: /dropbox\.com|drive\.google|mega\.nz|mediafire/gi, + cryptoScam: /free.*bitcoin|bitcoin.*giveaway|crypto.*invest.*now|guaranteed.*profit/gi, + adultContent: /onlyfans|dating.*site|meet.*singles/gi, + massTag: /@[A-Za-z\d](?:[A-Za-z\d-]*[A-Za-z\d])?\b/g, + accountAgeDays: 7, + minFollowers: 0, + minPublicRepos: 0, +}; + +/** + * Calculate a spam score for content authored by a given profile. + * @param {string} body - Issue/comment text to evaluate. + * @param {{ created_at: string, public_repos: number, followers: number } | null} authorProfile + * @returns {{ score: number, reasons: string[] }} + */ +export function calculateSpamScore(body, authorProfile) { + let score = 0; + const reasons = []; + + if (!body) body = ''; + + const contentPatterns = { + shortUrls: SPAM_SIGNALS.shortUrls, + fileSharing: SPAM_SIGNALS.fileSharing, + cryptoScam: SPAM_SIGNALS.cryptoScam, + adultContent: SPAM_SIGNALS.adultContent, + }; + + for (const [name, pattern] of Object.entries(contentPatterns)) { + pattern.lastIndex = 0; + if (pattern.test(body)) { + score += 3; + reasons.push(`content-pattern: ${name}`); + } + } + + // Flag 4+ @-mentions + SPAM_SIGNALS.massTag.lastIndex = 0; + const mentions = body.match(SPAM_SIGNALS.massTag); + if (mentions && mentions.length >= 4) { + score += 1; + reasons.push(`mass-mentions: ${mentions.length}`); + } + + // New account with zero activity + if (authorProfile) { + const createdAt = new Date(authorProfile.created_at); + const ageDays = (Date.now() - createdAt.getTime()) / (1000 * 60 * 60 * 24); + + if ( + ageDays < SPAM_SIGNALS.accountAgeDays && + authorProfile.public_repos === SPAM_SIGNALS.minPublicRepos && + authorProfile.followers === SPAM_SIGNALS.minFollowers + ) { + score += 2; + reasons.push(`new-account: ${Math.floor(ageDays)}d old, 0 repos, 0 followers`); + } + } else { + score += 1; + reasons.push('profile-unavailable'); + } + + return { score, reasons }; +} + +/** + * Evaluate a newly-opened issue and take moderation action when warranted. + * Designed to be called from CI with GITHUB_TOKEN, GITHUB_REPOSITORY, and + * GITHUB_EVENT_PATH environment variables set. + */ +export async function moderateContent({ env = process.env, fetchFn = globalThis.fetch } = {}) { + const token = env.GITHUB_TOKEN; + const repo = env.GITHUB_REPOSITORY; + const eventPath = env.GITHUB_EVENT_PATH; + + if (!token || !repo || !eventPath) { + throw new Error('Missing required environment variables: GITHUB_TOKEN, GITHUB_REPOSITORY, GITHUB_EVENT_PATH'); + } + + const { readFile } = await import('node:fs/promises'); + const event = JSON.parse(await readFile(eventPath, 'utf8')); + + const issue = event.issue; + if (!issue) { + console.log('No issue in event payload, skipping.'); + return; + } + + if (!issue.user) { + console.log('Skipping — issue author account unavailable'); + return; + } + + const author = issue.user.login; + const body = `${issue.title ?? ''}\n\n${issue.body ?? ''}`; + const [owner, repoName] = repo.split('/'); + + const headers = { + Authorization: `token ${token}`, + Accept: 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28', + }; + + let profile = null; + try { + const profileRes = await fetchFn(`https://api.github.com/users/${encodeURIComponent(author)}`, { headers }); + if (!profileRes.ok) { + console.warn(`Failed to fetch profile for ${author}: ${profileRes.status}`); + } else { + profile = await profileRes.json(); + } + } catch (err) { + console.warn(`Profile fetch error for ${author}: ${err.message}`); + } + + const { score, reasons } = calculateSpamScore(body, profile); + console.log(`Spam score for issue #${issue.number} by ${author}: ${score} (${reasons.join(', ')})`); + + if (score >= 5) { + try { + const closeRes = await fetchFn(`https://api.github.com/repos/${owner}/${repoName}/issues/${issue.number}`, { + method: 'PATCH', + headers: { ...headers, 'Content-Type': 'application/json' }, + body: JSON.stringify({ state: 'closed' }), + }); + if (!closeRes.ok) throw new Error(`HTTP ${closeRes.status}`); + console.log(` ✓ Closed issue #${issue.number}`); + } catch (err) { + console.error(` ✗ Failed to close issue #${issue.number}: ${err.message}`); + } + + try { + const labelRes = await fetchFn(`https://api.github.com/repos/${owner}/${repoName}/issues/${issue.number}/labels`, { + method: 'POST', + headers: { ...headers, 'Content-Type': 'application/json' }, + body: JSON.stringify({ labels: ['spam'] }), + }); + if (!labelRes.ok) throw new Error(`HTTP ${labelRes.status}`); + console.log(` ✓ Added 'spam' label to issue #${issue.number}`); + } catch (err) { + console.error(` ✗ Failed to label issue #${issue.number}: ${err.message}`); + } + + try { + const commentRes = await fetchFn(`https://api.github.com/repos/${owner}/${repoName}/issues/${issue.number}/comments`, { + method: 'POST', + headers: { ...headers, 'Content-Type': 'application/json' }, + body: JSON.stringify({ + body: [ + '🚫 **This issue was automatically closed as spam.**', + '', + `Detected signals: ${reasons.join(', ')}`, + '', + 'If this was a mistake, please contact a maintainer to reopen.', + ].join('\n'), + }), + }); + if (!commentRes.ok) throw new Error(`HTTP ${commentRes.status}`); + console.log(` ✓ Posted spam comment on issue #${issue.number}`); + } catch (err) { + console.error(` ✗ Failed to comment on issue #${issue.number}: ${err.message}`); + } + + console.log(`Issue #${issue.number} closed as spam.`); + } else if (score >= 3) { + try { + const labelRes = await fetchFn(`https://api.github.com/repos/${owner}/${repoName}/issues/${issue.number}/labels`, { + method: 'POST', + headers: { ...headers, 'Content-Type': 'application/json' }, + body: JSON.stringify({ labels: ['needs-review'] }), + }); + if (!labelRes.ok) throw new Error(`HTTP ${labelRes.status}`); + console.log(`Issue #${issue.number} flagged for review.`); + } catch (err) { + console.error(`Failed to add 'needs-review' label to issue #${issue.number}: ${err.message}`); + } + } else { + console.log(`Issue #${issue.number} looks clean.`); + } +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + moderateContent() + .then(() => process.exit(0)) + .catch((err) => { + console.error(err); + process.exit(1); + }); +} diff --git a/test/scripts/lock-stale.test.ts b/test/scripts/lock-stale.test.ts new file mode 100644 index 000000000..182dc10d9 --- /dev/null +++ b/test/scripts/lock-stale.test.ts @@ -0,0 +1,347 @@ +import { describe, it, expect, vi } from 'vitest'; +import { buildCutoffDate, findStaleItems, lockStaleItems, run } from '../../scripts/lock-stale.mjs'; + +const DEFAULT_HEADERS = { + Authorization: 'token test-token', + Accept: 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28', +}; + +/** Helper: create a mock fetch that routes by URL pattern. */ +function createMockFetch( + handler: (url: string, init?: RequestInit) => Promise<{ ok: boolean; status: number; json?: () => Promise; text?: () => Promise }>, +) { + return vi.fn(handler); +} + +/** Helper: build a GitHub search response with the given issue numbers. */ +function searchResponse(numbers: number[], type: 'issue' | 'pr' = 'issue') { + return { + total_count: numbers.length, + items: numbers.map((n) => ({ + number: n, + pull_request: type === 'pr' ? { url: `https://api.github.com/repos/owner/repo/pulls/${n}` } : undefined, + })), + }; +} + +describe('lock-stale', () => { + describe('buildCutoffDate', () => { + it('returns an ISO date string N days in the past', () => { + const result = buildCutoffDate(30); + const parsed = new Date(result); + const diffMs = Date.now() - parsed.getTime(); + const diffDays = diffMs / (1000 * 60 * 60 * 24); + // Allow slight tolerance for execution time + expect(diffDays).toBeGreaterThan(29.9); + expect(diffDays).toBeLessThan(30.1); + }); + }); + + describe('findStaleItems', () => { + it('returns correct items for issues', async () => { + const fetchFn = createMockFetch(async () => ({ + ok: true, + status: 200, + json: async () => searchResponse([101, 102, 103]), + text: async () => '', + })); + + const items = await findStaleItems({ + type: 'issue', + repo: 'owner/repo', + cutoff: '2024-01-01T00:00:00Z', + maxItems: 50, + fetchFn, + headers: DEFAULT_HEADERS, + }); + + expect(items).toEqual([ + { number: 101, type: 'issue' }, + { number: 102, type: 'issue' }, + { number: 103, type: 'issue' }, + ]); + + // Verify search query includes correct qualifiers + const calledUrl = fetchFn.mock.calls[0][0] as string; + expect(calledUrl).toContain('type%3Aissue'); + expect(calledUrl).toContain('is%3Aclosed'); + expect(calledUrl).toContain('is%3Aunlocked'); + }); + + it('returns correct items for PRs', async () => { + const fetchFn = createMockFetch(async () => ({ + ok: true, + status: 200, + json: async () => searchResponse([201, 202], 'pr'), + text: async () => '', + })); + + const items = await findStaleItems({ + type: 'pr', + repo: 'owner/repo', + cutoff: '2024-01-01T00:00:00Z', + maxItems: 50, + fetchFn, + headers: DEFAULT_HEADERS, + }); + + expect(items).toEqual([ + { number: 201, type: 'pr' }, + { number: 202, type: 'pr' }, + ]); + + const calledUrl = fetchFn.mock.calls[0][0] as string; + expect(calledUrl).toContain('type%3Apr'); + }); + + it('handles empty results (no stale items)', async () => { + const fetchFn = createMockFetch(async () => ({ + ok: true, + status: 200, + json: async () => ({ total_count: 0, items: [] }), + text: async () => '', + })); + + const items = await findStaleItems({ + type: 'issue', + repo: 'owner/repo', + cutoff: '2024-01-01T00:00:00Z', + maxItems: 50, + fetchFn, + headers: DEFAULT_HEADERS, + }); + + expect(items).toEqual([]); + }); + + it('handles API errors gracefully', async () => { + const fetchFn = createMockFetch(async () => ({ + ok: false, + status: 403, + json: async () => ({}), + text: async () => 'rate limit exceeded', + })); + + await expect( + findStaleItems({ + type: 'issue', + repo: 'owner/repo', + cutoff: '2024-01-01T00:00:00Z', + maxItems: 50, + fetchFn, + headers: DEFAULT_HEADERS, + }), + ).rejects.toThrow('GitHub search failed (403)'); + }); + + it('respects MAX_ITEMS limit in API query', async () => { + const fetchFn = createMockFetch(async () => ({ + ok: true, + status: 200, + json: async () => searchResponse([1, 2, 3]), + text: async () => '', + })); + + await findStaleItems({ + type: 'issue', + repo: 'owner/repo', + cutoff: '2024-01-01T00:00:00Z', + maxItems: 10, + fetchFn, + headers: DEFAULT_HEADERS, + }); + + const calledUrl = fetchFn.mock.calls[0][0] as string; + expect(calledUrl).toContain('per_page=10'); + }); + }); + + describe('lockStaleItems', () => { + it('calls lock API for each item', async () => { + const calls: Array<{ url: string; method: string }> = []; + const fetchFn = createMockFetch(async (url, init) => { + calls.push({ url, method: init?.method || 'GET' }); + return { ok: true, status: 204, json: async () => ({}), text: async () => '' }; + }); + + const items = [ + { number: 1, type: 'issue' }, + { number: 2, type: 'pr' }, + { number: 3, type: 'issue' }, + ]; + + const result = await lockStaleItems({ + items, + repo: 'owner/repo', + fetchFn, + headers: DEFAULT_HEADERS, + delayMs: 0, + }); + + expect(calls).toHaveLength(3); + expect(calls[0].url).toContain('/issues/1/lock'); + expect(calls[1].url).toContain('/issues/2/lock'); + expect(calls[2].url).toContain('/issues/3/lock'); + expect(calls.every((c) => c.method === 'PUT')).toBe(true); + + expect(result.lockedIssues).toBe(2); + expect(result.lockedPRs).toBe(1); + expect(result.skipped).toBe(0); + }); + + it('handles "already locked" response (HTTP 422) without error', async () => { + const fetchFn = createMockFetch(async () => ({ + ok: false, + status: 422, + json: async () => ({ message: 'already locked' }), + text: async () => 'already locked', + })); + + const items = [ + { number: 10, type: 'issue' }, + { number: 20, type: 'pr' }, + ]; + + const result = await lockStaleItems({ + items, + repo: 'owner/repo', + fetchFn, + headers: DEFAULT_HEADERS, + delayMs: 0, + }); + + expect(result.lockedIssues).toBe(0); + expect(result.lockedPRs).toBe(0); + expect(result.skipped).toBe(2); + }); + + it('handles API failure on individual lock and continues with remaining', async () => { + let callIndex = 0; + const fetchFn = createMockFetch(async () => { + callIndex++; + if (callIndex === 2) { + // Second call fails + return { ok: false, status: 500, json: async () => ({}), text: async () => 'server error' }; + } + return { ok: true, status: 204, json: async () => ({}), text: async () => '' }; + }); + + const items = [ + { number: 1, type: 'issue' }, + { number: 2, type: 'issue' }, + { number: 3, type: 'issue' }, + ]; + + const result = await lockStaleItems({ + items, + repo: 'owner/repo', + fetchFn, + headers: DEFAULT_HEADERS, + delayMs: 0, + }); + + // Items 1 and 3 succeed, item 2 fails + expect(result.lockedIssues).toBe(2); + expect(result.skipped).toBe(0); + expect(fetchFn).toHaveBeenCalledTimes(3); + }); + + it('handles network error on individual lock and continues', async () => { + let callIndex = 0; + const fetchFn = createMockFetch(async () => { + callIndex++; + if (callIndex === 1) throw new Error('Network failure'); + return { ok: true, status: 204, json: async () => ({}), text: async () => '' }; + }); + + const items = [ + { number: 1, type: 'issue' }, + { number: 2, type: 'pr' }, + ]; + + const result = await lockStaleItems({ + items, + repo: 'owner/repo', + fetchFn, + headers: DEFAULT_HEADERS, + delayMs: 0, + }); + + expect(result.lockedIssues).toBe(0); + expect(result.lockedPRs).toBe(1); + }); + }); + + describe('run', () => { + it('logs correct summary counts', async () => { + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + const fetchFn = createMockFetch(async (url) => { + // Search calls + if (url.includes('/search/issues')) { + if (url.includes('type%3Aissue')) { + return { ok: true, status: 200, json: async () => searchResponse([1, 2]), text: async () => '' }; + } + if (url.includes('type%3Apr')) { + return { ok: true, status: 200, json: async () => searchResponse([3], 'pr'), text: async () => '' }; + } + } + // Lock calls + return { ok: true, status: 204, json: async () => ({}), text: async () => '' }; + }); + + const env = { + GITHUB_TOKEN: 'test-token', + GITHUB_REPOSITORY: 'owner/repo', + LOCK_AFTER_DAYS: '30', + MAX_ITEMS: '50', + }; + + const result = await run({ env, fetchFn }); + + expect(result.lockedIssues).toBe(2); + expect(result.lockedPRs).toBe(1); + expect(result.skipped).toBe(0); + + const summaryCall = consoleSpy.mock.calls.find( + (c) => typeof c[0] === 'string' && c[0].includes('Locked 2 issues, 1 PRs'), + ); + expect(summaryCall).toBeDefined(); + + consoleSpy.mockRestore(); + }); + + it('throws on missing env vars', async () => { + await expect(run({ env: {} })).rejects.toThrow('Missing required environment variables'); + }); + + it('reports no stale items when search returns empty', async () => { + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + const fetchFn = createMockFetch(async () => ({ + ok: true, + status: 200, + json: async () => ({ total_count: 0, items: [] }), + text: async () => '', + })); + + const env = { + GITHUB_TOKEN: 'test-token', + GITHUB_REPOSITORY: 'owner/repo', + }; + + const result = await run({ env, fetchFn }); + + expect(result.lockedIssues).toBe(0); + expect(result.lockedPRs).toBe(0); + + const noItemsCall = consoleSpy.mock.calls.find( + (c) => typeof c[0] === 'string' && c[0].includes('No stale items found'), + ); + expect(noItemsCall).toBeDefined(); + + consoleSpy.mockRestore(); + }); + }); +}); diff --git a/test/scripts/moderate-spam.test.ts b/test/scripts/moderate-spam.test.ts new file mode 100644 index 000000000..242844a49 --- /dev/null +++ b/test/scripts/moderate-spam.test.ts @@ -0,0 +1,377 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { calculateSpamScore, moderateContent } from '../../scripts/moderate-spam.mjs'; +import { writeFileSync, unlinkSync, existsSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const EVENT_FILE = join(__dirname, '_test-event.json'); + +/** Helper to build a fake author profile. */ +function fakeProfile(overrides = {}) { + return { + created_at: new Date(Date.now() - 365 * 24 * 60 * 60 * 1000).toISOString(), // 1 year old + public_repos: 10, + followers: 5, + ...overrides, + }; +} + +function newAccountProfile() { + return fakeProfile({ + created_at: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(), // 2 days old + public_repos: 0, + followers: 0, + }); +} + +describe('moderate-spam', () => { + describe('calculateSpamScore', () => { + it('scores 0 for clean content from an established account', () => { + const { score, reasons } = calculateSpamScore( + 'I found a bug when running `npm test`. Here are the repro steps.', + fakeProfile(), + ); + expect(score).toBe(0); + expect(reasons).toHaveLength(0); + }); + + it('detects shortened URLs (+3)', () => { + const { score, reasons } = calculateSpamScore( + 'Check out this cool tool https://bit.ly/xyz123', + fakeProfile(), + ); + expect(score).toBe(3); + expect(reasons).toContain('content-pattern: shortUrls'); + }); + + it('detects crypto scam patterns (+3)', () => { + const { score, reasons } = calculateSpamScore( + 'Free bitcoin giveaway! Crypto invest now!', + fakeProfile(), + ); + expect(score).toBe(3); + expect(reasons).toContain('content-pattern: cryptoScam'); + }); + + it('detects adult content patterns (+3)', () => { + const { score, reasons } = calculateSpamScore( + 'Visit this amazing dating site for singles near you', + fakeProfile(), + ); + expect(score).toBe(3); + expect(reasons).toContain('content-pattern: adultContent'); + }); + + it('detects file-sharing link patterns (+3)', () => { + const { score, reasons } = calculateSpamScore( + 'Download the file from mega.nz/file/abc', + fakeProfile(), + ); + expect(score).toBe(3); + expect(reasons).toContain('content-pattern: fileSharing'); + }); + + it('detects mass @-mentions with 4+ tags (+1)', () => { + const { score, reasons } = calculateSpamScore( + 'Hey @alice @bob @charlie @dave @eve please look at this', + fakeProfile(), + ); + expect(score).toBe(1); + expect(reasons).toEqual(expect.arrayContaining([expect.stringContaining('mass-mentions')])); + }); + + it('does not flag fewer than 4 @-mentions', () => { + const { score } = calculateSpamScore( + 'cc @alice @bob @charlie', + fakeProfile(), + ); + expect(score).toBe(0); + }); + + it('flags new accounts with zero repos and followers (+2)', () => { + const { score, reasons } = calculateSpamScore( + 'This is a perfectly normal issue body.', + newAccountProfile(), + ); + expect(score).toBe(2); + expect(reasons).toEqual(expect.arrayContaining([expect.stringContaining('new-account')])); + }); + + it('does not flag new accounts that have repos or followers', () => { + const profile = fakeProfile({ + created_at: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(), + public_repos: 3, + followers: 0, + }); + const { score } = calculateSpamScore('Normal issue text.', profile); + expect(score).toBe(0); + }); + + it('combines content + account signals for high scores', () => { + const { score } = calculateSpamScore( + 'Get free bitcoin now! Visit bit.ly/scam', + newAccountProfile(), + ); + // cryptoScam (+3) + shortUrls (+3) + new-account (+2) = 8 + expect(score).toBeGreaterThanOrEqual(8); + }); + + it('returns score >= 5 for obvious spam (auto-close threshold)', () => { + const { score } = calculateSpamScore( + 'Free crypto invest now! Click tinyurl.com/deal @a @b @c @d @e', + newAccountProfile(), + ); + // cryptoScam (+3) + shortUrls (+3) + mass-mentions (+1) + new-account (+2) = 9 + expect(score).toBeGreaterThanOrEqual(5); + }); + + it('does not flag legitimate issues containing normal links', () => { + const { score } = calculateSpamScore( + 'See https://github.com/bradygaster/squad/issues/42 and https://npmjs.com/package/foo for context.', + fakeProfile(), + ); + expect(score).toBe(0); + }); + + it('handles null/empty body gracefully', () => { + const { score } = calculateSpamScore(null, fakeProfile()); + expect(score).toBe(0); + + const { score: score2 } = calculateSpamScore('', fakeProfile()); + expect(score2).toBe(0); + }); + + it('adds profile-unavailable signal for null authorProfile (+1)', () => { + const { score, reasons } = calculateSpamScore('Some issue text.', null); + expect(score).toBe(1); + expect(reasons).toContain('profile-unavailable'); + }); + + it('returns consistent scores on repeated calls (regex lastIndex safety)', () => { + const body = 'Check out bit.ly/spam and tinyurl.com/scam'; + const profile = fakeProfile(); + const first = calculateSpamScore(body, profile); + const second = calculateSpamScore(body, profile); + expect(first.score).toBe(second.score); + expect(first.reasons).toEqual(second.reasons); + }); + + it('scores overlapping patterns correctly', () => { + const { score, reasons } = calculateSpamScore( + 'Visit bit.ly/deal for free bitcoin and mega.nz downloads!', + fakeProfile(), + ); + // shortUrls (+3) + cryptoScam (+3) + fileSharing (+3) = 9 + expect(score).toBe(9); + expect(reasons).toContain('content-pattern: shortUrls'); + expect(reasons).toContain('content-pattern: cryptoScam'); + expect(reasons).toContain('content-pattern: fileSharing'); + }); + + it('scores null body + new account profile as +2', () => { + const { score, reasons } = calculateSpamScore(null, newAccountProfile()); + expect(score).toBe(2); + expect(reasons).toEqual(expect.arrayContaining([expect.stringContaining('new-account')])); + }); + }); + + describe('moderateContent', () => { + function writeEvent(payload: object) { + writeFileSync(EVENT_FILE, JSON.stringify(payload), 'utf8'); + } + + afterEach(() => { + if (existsSync(EVENT_FILE)) unlinkSync(EVENT_FILE); + }); + + function mockEnv(overrides: Record = {}) { + return { + GITHUB_TOKEN: 'test-token', + GITHUB_REPOSITORY: 'owner/repo', + GITHUB_EVENT_PATH: EVENT_FILE, + ...overrides, + }; + } + + function createMockFetch(profileData: object | null = null) { + const calls: Array<{ url: string; method: string; body?: unknown }> = []; + + const fn = vi.fn( + async (url: string, init?: { method?: string; body?: string }) => { + const method = init?.method ?? 'GET'; + const body = init?.body ? JSON.parse(init.body) : undefined; + calls.push({ url, method, body }); + + if (url.includes('/users/')) { + if (profileData === null) { + return { ok: false, status: 404, json: async () => ({}) }; + } + return { ok: true, status: 200, json: async () => profileData }; + } + + return { ok: true, status: 200, json: async () => ({}) }; + }, + ); + + return { fn, calls }; + } + + it('throws on missing env vars', async () => { + await expect(moderateContent({ env: {} })).rejects.toThrow( + 'Missing required environment variables', + ); + }); + + it('closes + labels + comments when score >= 5', async () => { + writeEvent({ + issue: { + number: 42, + title: 'Free bitcoin giveaway', + body: 'Visit bit.ly/scam for crypto invest now!', + user: { login: 'spammer' }, + }, + }); + + const { fn, calls } = createMockFetch(newAccountProfile()); + await moderateContent({ env: mockEnv(), fetchFn: fn }); + + const closeCall = calls.find( + (c) => c.url.includes('/issues/42') && !c.url.includes('/labels') && !c.url.includes('/comments') && c.method === 'PATCH', + ); + expect(closeCall).toBeDefined(); + expect((closeCall as { body: { state: string } }).body.state).toBe('closed'); + + const labelCall = calls.find( + (c) => c.url.includes('/issues/42/labels') && c.method === 'POST', + ); + expect(labelCall).toBeDefined(); + expect((labelCall as { body: { labels: string[] } }).body.labels).toContain('spam'); + + const commentCall = calls.find( + (c) => c.url.includes('/issues/42/comments') && c.method === 'POST', + ); + expect(commentCall).toBeDefined(); + }); + + it('adds needs-review label when score is 3-4', async () => { + writeEvent({ + issue: { + number: 10, + title: 'Normal title', + body: 'Download from mega.nz/file/abc', + user: { login: 'someuser' }, + }, + }); + + const { fn, calls } = createMockFetch(fakeProfile()); + await moderateContent({ env: mockEnv(), fetchFn: fn }); + + const labelCall = calls.find( + (c) => c.url.includes('/issues/10/labels') && c.method === 'POST', + ); + expect(labelCall).toBeDefined(); + expect((labelCall as { body: { labels: string[] } }).body.labels).toContain('needs-review'); + + const closeCall = calls.find( + (c) => c.url.includes('/issues/10') && !c.url.includes('/labels') && c.method === 'PATCH', + ); + expect(closeCall).toBeUndefined(); + }); + + it('makes no moderation API calls when score < 3', async () => { + writeEvent({ + issue: { + number: 5, + title: 'Bug report', + body: 'I found a bug in the CLI.', + user: { login: 'gooduser' }, + }, + }); + + const { fn, calls } = createMockFetch(fakeProfile()); + await moderateContent({ env: mockEnv(), fetchFn: fn }); + + const nonProfileCalls = calls.filter((c) => !c.url.includes('/users/')); + expect(nonProfileCalls).toHaveLength(0); + }); + + it('continues scoring when profile fetch fails with network error', async () => { + writeEvent({ + issue: { + number: 99, + title: 'Free bitcoin giveaway', + body: 'Visit bit.ly/scam for crypto invest now!', + user: { login: 'spammer' }, + }, + }); + + const calls: Array<{ url: string; method: string; body?: unknown }> = []; + const fn = vi.fn( + async (url: string, init?: { method?: string; body?: string }) => { + const method = init?.method ?? 'GET'; + const body = init?.body ? JSON.parse(init.body) : undefined; + calls.push({ url, method, body }); + + if (url.includes('/users/')) { + throw new Error('Network error'); + } + + return { ok: true, status: 200, json: async () => ({}) }; + }, + ); + + await moderateContent({ env: mockEnv(), fetchFn: fn }); + + // Content patterns still trigger: shortUrls(+3) + cryptoScam(+3) + profile-unavailable(+1) = 7 >= 5 + const closeCall = calls.find( + (c) => c.url.includes('/issues/99') && !c.url.includes('/labels') && !c.url.includes('/comments') && c.method === 'PATCH', + ); + expect(closeCall).toBeDefined(); + }); + + it('still labels and comments when close API call fails', async () => { + writeEvent({ + issue: { + number: 42, + title: 'Free bitcoin giveaway', + body: 'Visit bit.ly/scam for crypto invest now!', + user: { login: 'spammer' }, + }, + }); + + const calls: Array<{ url: string; method: string; body?: unknown }> = []; + const fn = vi.fn( + async (url: string, init?: { method?: string; body?: string }) => { + const method = init?.method ?? 'GET'; + const body = init?.body ? JSON.parse(init.body) : undefined; + calls.push({ url, method, body }); + + if (url.includes('/users/')) { + return { ok: true, status: 200, json: async () => newAccountProfile() }; + } + + // Fail the close call + if (method === 'PATCH' && url.includes('/issues/42')) { + return { ok: false, status: 500, json: async () => ({}) }; + } + + return { ok: true, status: 200, json: async () => ({}) }; + }, + ); + + await moderateContent({ env: mockEnv(), fetchFn: fn }); + + // Label and comment should still be attempted despite close failure + const labelCall = calls.find( + (c) => c.url.includes('/issues/42/labels') && c.method === 'POST', + ); + expect(labelCall).toBeDefined(); + + const commentCall = calls.find( + (c) => c.url.includes('/issues/42/comments') && c.method === 'POST', + ); + expect(commentCall).toBeDefined(); + }); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index 0611b925e..98daf8620 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,6 +1,17 @@ import { defineConfig } from 'vitest/config'; export default defineConfig({ + plugins: [ + { + name: 'strip-shebang', + enforce: 'pre', + transform(code) { + if (code.startsWith('#!')) { + return code.replace(/^#![^\n]*\n/, ''); + } + }, + }, + ], resolve: { // Force vitest to resolve @bradygaster/squad-sdk from the workspace root, // not from a duplicate copy under packages/squad-cli/node_modules/.