issue-guard.yml 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201
  1. name: Issue Guard
  2. on:
  3. issues:
  4. types: [opened]
  5. issue_comment:
  6. types: [created]
  7. pull_request:
  8. types: [opened]
  9. pull_request_review_comment:
  10. types: [created]
  11. jobs:
  12. moderate:
  13. runs-on: ubuntu-latest
  14. permissions:
  15. issues: write
  16. pull-requests: write
  17. models: read
  18. contents: read
  19. steps:
  20. - uses: actions/checkout@v4
  21. # AI 内容审核(垃圾检测 + 链接检测 + AI 生成检测)
  22. - uses: github/ai-moderator@v1
  23. with:
  24. token: ${{ secrets.GITHUB_TOKEN }}
  25. spam-label: 'spam'
  26. ai-label: 'ai-generated'
  27. minimize-detected-comments: true
  28. enable-spam-detection: true
  29. enable-link-spam-detection: true
  30. enable-ai-detection: true
  31. # 两层防护:
  32. # 1) 账号 < 7 天 → 直接删除/关闭
  33. # 2) 账号 >= 7 天 + AI 判定为垃圾 → 也删除/关闭
  34. - name: Spam defense
  35. uses: actions/github-script@v7
  36. with:
  37. script: |
  38. const MIN_ACCOUNT_AGE_DAYS = 7;
  39. const event = context.eventName;
  40. // 解析事件上下文
  41. let username, authorAssociation, number, nodeId, commentId;
  42. let isIssue = false, isPR = false, isComment = false;
  43. if (event === 'issues') {
  44. const issue = context.payload.issue;
  45. username = issue.user.login;
  46. authorAssociation = issue.author_association;
  47. number = issue.number;
  48. isIssue = true;
  49. } else if (event === 'issue_comment') {
  50. const comment = context.payload.comment;
  51. username = comment.user.login;
  52. authorAssociation = comment.author_association;
  53. number = context.payload.issue.number;
  54. nodeId = comment.node_id;
  55. commentId = comment.id;
  56. isComment = true;
  57. isIssue = true;
  58. } else if (event === 'pull_request') {
  59. const pr = context.payload.pull_request;
  60. username = pr.user.login;
  61. authorAssociation = pr.author_association;
  62. number = pr.number;
  63. isPR = true;
  64. } else if (event === 'pull_request_review_comment') {
  65. const comment = context.payload.comment;
  66. username = comment.user.login;
  67. authorAssociation = comment.author_association;
  68. number = context.payload.pull_request.number;
  69. nodeId = comment.node_id;
  70. commentId = comment.id;
  71. isComment = true;
  72. isPR = true;
  73. }
  74. // 跳过项目成员
  75. if (['OWNER', 'MEMBER', 'COLLABORATOR'].includes(authorAssociation)) {
  76. core.info(`Skip: ${username} (${authorAssociation})`);
  77. return;
  78. }
  79. // 获取账号年龄
  80. const { data: user } = await github.rest.users.getByUsername({ username });
  81. const accountAgeDays = Math.floor((Date.now() - new Date(user.created_at)) / 86400000);
  82. const isNewAccount = accountAgeDays < MIN_ACCOUNT_AGE_DAYS;
  83. // 判断是否需要拦截
  84. let shouldBlock = false;
  85. let reason = '';
  86. if (isNewAccount) {
  87. // 第一层:新账号直接拦截
  88. shouldBlock = true;
  89. reason = `new account (${accountAgeDays}d < ${MIN_ACCOUNT_AGE_DAYS}d)`;
  90. } else {
  91. // 第二层:老账号检查 AI 垃圾判定
  92. let isSpam = false;
  93. if (isComment) {
  94. const { node } = await github.graphql(`
  95. query($id: ID!) {
  96. node(id: $id) {
  97. ... on IssueComment { isMinimized }
  98. ... on PullRequestReviewComment { isMinimized }
  99. }
  100. }
  101. `, { id: nodeId });
  102. isSpam = node?.isMinimized === true;
  103. } else {
  104. const { data: labels } = await github.rest.issues.listLabelsOnIssue({
  105. ...context.repo,
  106. issue_number: number
  107. });
  108. isSpam = labels.some(l => l.name === 'spam');
  109. }
  110. if (isSpam) {
  111. shouldBlock = true;
  112. reason = `spam detected (account age: ${accountAgeDays}d)`;
  113. }
  114. }
  115. if (!shouldBlock) {
  116. core.info(`${username} (${accountAgeDays}d): OK`);
  117. return;
  118. }
  119. core.info(`Blocking ${event} #${number} from ${username}: ${reason}`);
  120. // 根据拦截原因生成不同提示
  121. const notice = isNewAccount
  122. ? [
  123. `@${username} 你好,感谢你对本项目的关注!`,
  124. '',
  125. '为维护社区环境、防止垃圾信息和自动化 bot 的干扰,',
  126. `本项目要求 GitHub 账号注册满 **${MIN_ACCOUNT_AGE_DAYS} 天**后才能参与互动。`,
  127. '',
  128. `你的账号注册时间为 ${accountAgeDays} 天,暂时无法参与。`,
  129. `请在账号满 ${MIN_ACCOUNT_AGE_DAYS} 天后重新提交,或通过项目 README 中的联系方式反馈。`,
  130. '',
  131. '感谢理解与支持 🙏',
  132. ].join('\n')
  133. : [
  134. `@${username} 你好,`,
  135. '',
  136. '你的内容已被自动审核系统标记为垃圾信息并移除。',
  137. '',
  138. '**如有误判,还请见谅!** 请通过项目 README 中的联系方式反馈。',
  139. '',
  140. '感谢理解 🙏',
  141. ].join('\n');
  142. // 执行拦截操作
  143. if (isComment) {
  144. // 评论:直接删除
  145. if (isIssue) {
  146. await github.rest.issues.deleteComment({
  147. ...context.repo,
  148. comment_id: commentId
  149. });
  150. } else {
  151. await github.rest.pulls.deleteReviewComment({
  152. ...context.repo,
  153. comment_id: commentId
  154. });
  155. }
  156. } else if (isIssue) {
  157. // 新建 Issue:评论提示 → 关闭 → 锁定
  158. await github.rest.issues.createComment({
  159. ...context.repo,
  160. issue_number: number,
  161. body: notice
  162. });
  163. await github.rest.issues.update({
  164. ...context.repo,
  165. issue_number: number,
  166. state: 'closed',
  167. state_reason: 'not_planned'
  168. });
  169. await github.rest.issues.lock({
  170. ...context.repo,
  171. issue_number: number,
  172. lock_reason: 'spam'
  173. });
  174. } else if (isPR) {
  175. // 新建 PR:评论提示 → 关闭
  176. await github.rest.issues.createComment({
  177. ...context.repo,
  178. issue_number: number,
  179. body: notice
  180. });
  181. await github.rest.pulls.update({
  182. ...context.repo,
  183. pull_number: number,
  184. state: 'closed'
  185. });
  186. }