name: AI Moderator on: issues: types: [opened] pull_request: types: [opened] discussion: types: [created] permissions: issues: write pull-requests: write discussions: write contents: read jobs: moderate: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/github-script@v9 env: GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }} with: script: | const fs = require('fs'); const coc = (() => { try { return fs.readFileSync('.github/CODE_OF_CONDUCT.md', 'utf8'); } catch (e) { return ''; } })(); const contributing = (() => { try { return fs.readFileSync('CONTRIBUTING.md', 'utf8'); } catch (e) { return ''; } })(); const content = context.payload.issue?.body || context.payload.pull_request?.body || context.payload.discussion?.body || ''; if (!content.trim()) { console.log('No content to analyze'); return; } const actor = context.payload.sender?.login; if (actor === 'c-ludenberg') { console.log('Repo owner, skipping moderation'); return; } const isDiscussion = context.payload.discussion !== undefined; const prompt = [ 'You are an AI moderator for the Antergos NeXT project, an Artix-based', 'Linux distribution (dinit init, KDE Plasma).', 'Speak in first person. Be direct, confident, and have some snark.', 'The project has a playful but no-nonsense tone - match it.', '', 'Your job is NOT to police opinions, bug reports, feature requests,', 'pull requests, technical questions, or constructive criticism.', 'Those are all normal and welcome. A normal PR description or issue', 'is NEVER a violation, no matter how long, detailed, or assertive it is.', '', 'ONLY flag content that genuinely violates the Code of Conduct:', 'harassment, personal attacks, hate speech, slurs, spam, scams,', 'doxxing, malicious links, or threats.', '', 'When in doubt, or if the content is merely technical, opinionated,', 'or critical of the project - violation is FALSE.', '', 'If the content says it is a test or contains phrases like moderator', 'or test or reply here, play along with the joke but still enforce the rules.', '', 'Code of Conduct:', coc, '', 'Contributing Guidelines:', contributing, '', 'Analyze the content below for violations.', 'Respond with ONLY valid JSON - no markdown, no backticks.', '', '{', ' "violation": true/false,', ' "message": "your direct first-person response",', ' "action": "warn",', ' "severity": 1-10', '}', '', '"action" options: "warn" (post warning), "hide" (minimize comment), "lock" (lock issue/discussion)', 'For issues use "warn" or "lock". For comments you may use "hide".', 'Only use "lock" or "hide" for severe violations.', '', 'Content:', content ].join('\n'); const model = 'llama-3.1-8b-instant'; let decision; try { const response = await fetch('https://api.groq.com/openai/v1/chat/completions', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${process.env.GROQ_API_KEY}` }, body: JSON.stringify({ model, messages: [ { role: 'system', content: 'You are a helpful community moderator. Respond only with valid JSON as instructed.' }, { role: 'user', content: prompt } ], temperature: 0.1, max_tokens: 250 }) }); const data = await response.json(); const raw = data.choices?.[0]?.message?.content || '{}'; decision = JSON.parse(raw.replace(/```json|```/g, '').trim()); console.log(`Decision: ${JSON.stringify(decision)}`); } catch (err) { console.log(`API error: ${err}`); return; } if (!decision.violation) { console.log('Content deemed acceptable'); return; } if (typeof decision.severity === 'number' && decision.severity < 4) { console.log(`Low severity (${decision.severity}), no action taken`); return; } const number = context.payload.issue?.number || context.payload.pull_request?.number || context.payload.discussion?.number; if (!number) { console.log('No issue/PR/discussion number found'); return; } const owner = context.repo.owner; const repo = context.repo.repo; try { if (isDiscussion) { const discId = context.payload.discussion.node_id; if (discId) { const msg = decision.message.replace(/"/g, '\\"'); await github.graphql(` mutation { addDiscussionComment(input: {discussionId: "${discId}", body: "${msg}"}) { comment { id } } } `); } } else { await github.rest.issues.addLabels({ owner, repo, issue_number: number, labels: ['ai-moderated'] }); await github.rest.issues.createComment({ owner, repo, issue_number: number, body: decision.message }); } if (decision.action === 'hide') { const commentId = context.payload.comment?.node_id; if (commentId) { await github.graphql(` mutation { minimizeComment(input: {subjectId: "${commentId}", classifier: OFF_TOPIC}) { minimizedComment { isMinimized } } } `); console.log('Comment minimized'); } } if (decision.action === 'lock') { if (isDiscussion) { const discId = context.payload.discussion.node_id; if (discId) { await github.graphql(` mutation { lockLockable(input: {lockableId: "${discId}"}) { lockedRecord { activeLockReason } } } `); } } else { await github.rest.issues.lock({ owner, repo, issue_number: number, lock_reason: 'too_heated' }); } console.log('Locked'); } console.log('Action completed: ' + decision.action); } catch (err) { console.log(`GitHub API error: ${err}`); }