|
10 | 10 | types: [opened, edited] |
11 | 11 | issue_comment: |
12 | 12 | types: [created, edited] |
| 13 | + workflow_dispatch: |
| 14 | + inputs: |
| 15 | + issue_number: |
| 16 | + description: '要测试的 issue 号(读取内容→判断→命中即删除,不受 owner 白名单限制)' |
| 17 | + required: true |
| 18 | + type: string |
13 | 19 |
|
14 | 20 | permissions: |
15 | 21 | issues: write |
16 | 22 |
|
17 | 23 | jobs: |
| 24 | + # ---------- 手动端到端测试 ---------- |
| 25 | + test-dispatch: |
| 26 | + if: github.event_name == 'workflow_dispatch' |
| 27 | + runs-on: ubuntu-latest |
| 28 | + steps: |
| 29 | + - name: Fetch, classify and delete if spam |
| 30 | + uses: actions/github-script@v7 |
| 31 | + with: |
| 32 | + github-token: ${{ secrets.ADMIN_PAT }} |
| 33 | + script: | |
| 34 | + const baseUrl = (process.env.AI_BASE_URL || '').replace(/\/+$/, ''); |
| 35 | + const apiKey = process.env.AI_API_KEY; |
| 36 | + const model = process.env.AI_MODEL || 'gpt-4o-mini'; |
| 37 | + if (!baseUrl || !apiKey) { core.setFailed('缺少 AI_BASE_URL 或 AI_API_KEY secret'); return; } |
| 38 | +
|
| 39 | + const number = Number(context.payload.inputs.issue_number); |
| 40 | + const { data: issue } = await github.rest.issues.get({ |
| 41 | + owner: context.repo.owner, repo: context.repo.repo, issue_number: number, |
| 42 | + }); |
| 43 | + const text = `Issue 标题:${issue.title || ''}\n\nIssue 正文:\n${issue.body || ''}`; |
| 44 | +
|
| 45 | + const verdict = await classify(text, { fetch, core, baseUrl, apiKey, model, prompt: process.env.AI_PROMPT }); |
| 46 | + core.notice(`分类结果: spam=${verdict?.spam} 理由=${verdict?.reason || '无'}`); |
| 47 | + if (!verdict || !verdict.spam) { core.info('非垃圾,保留。'); return; } |
| 48 | +
|
| 49 | + await github.graphql( |
| 50 | + `mutation($id: ID!) { deleteIssue(input: { issueId: $id }) { repository { nameWithOwner } } }`, |
| 51 | + { id: issue.node_id } |
| 52 | + ); |
| 53 | + core.notice(`已删除垃圾 issue #${number}: ${issue.title}`); |
| 54 | +
|
| 55 | + async function classify(content, { fetch, core, baseUrl, apiKey, model, prompt }) { |
| 56 | + const systemPrompt = (prompt && prompt.trim()) ? prompt : [ |
| 57 | + '你是 GitHub 仓库的内容审核员,只负责判断一条 issue 或评论是否为"垃圾/恶意内容"。', |
| 58 | + '判定为垃圾(spam=true)的情形:', |
| 59 | + '1. 指控本项目刷 star / 买 star / star 造假、质疑 star 数据真实性、嘲讽"这项目就是刷出来的"等攻击性、无建设性的内容;', |
| 60 | + '2. 求 star、索要 star、"帮我点个 star"、互刷 star、star/关注 互换;', |
| 61 | + '3. 推广其他仓库、产品、社群、外链等纯广告;', |
| 62 | + '4. 与本项目无关的灌水、刷屏、辱骂、无意义内容。', |
| 63 | + '判定为正常(spam=false)的情形:', |
| 64 | + '- 真实的 bug 反馈、报错、复现步骤;', |
| 65 | + '- 功能建议、使用提问、技术讨论;', |
| 66 | + '- 即使语气不满,但在描述具体问题或提出具体改进,也算正常。', |
| 67 | + '只要命中任意一条垃圾情形即 spam=true;不确定时倾向 spam=false(宁可漏判,不要误删正常反馈)。', |
| 68 | + '严格只输出 JSON,不要任何解释或代码块包裹:{"spam": true 或 false, "reason": "简短中文理由"}。', |
| 69 | + ].join('\n'); |
| 70 | + const resp = await fetch(`${baseUrl}/chat/completions`, { |
| 71 | + method: 'POST', |
| 72 | + headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${apiKey}` }, |
| 73 | + body: JSON.stringify({ |
| 74 | + model, temperature: 0, |
| 75 | + response_format: { type: 'json_object' }, |
| 76 | + messages: [ |
| 77 | + { role: 'system', content: systemPrompt }, |
| 78 | + { role: 'user', content }, |
| 79 | + ], |
| 80 | + }), |
| 81 | + }); |
| 82 | + if (!resp.ok) { core.warning(`大模型请求失败: ${resp.status} ${await resp.text()}`); return null; } |
| 83 | + const data = await resp.json(); |
| 84 | + const raw = data?.choices?.[0]?.message?.content || '{}'; |
| 85 | + core.info(`模型原始输出: ${raw}`); |
| 86 | + try { return JSON.parse(raw.replace(/^```(?:json)?\s*|\s*```$/g, '').trim()); } |
| 87 | + catch (e) { core.warning(`无法解析模型输出,跳过: ${e}`); return null; } |
| 88 | + } |
| 89 | + env: |
| 90 | + AI_BASE_URL: ${{ secrets.AI_BASE_URL }} |
| 91 | + AI_API_KEY: ${{ secrets.AI_API_KEY }} |
| 92 | + AI_MODEL: ${{ secrets.AI_MODEL }} |
| 93 | + AI_PROMPT: ${{ secrets.AI_PROMPT }} |
| 94 | + |
18 | 95 | # ---------- 处理整条 issue ---------- |
19 | 96 | guard-issue: |
20 | 97 | if: >- |
|
0 commit comments