ai-triage.yml 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229
  1. name: AI Issue Triage
  2. on:
  3. issues:
  4. types:
  5. - opened
  6. - edited
  7. permissions:
  8. issues: write
  9. contents: read
  10. jobs:
  11. ai-triage:
  12. if: github.repository_owner == 'GameServerManagers'
  13. runs-on: ubuntu-latest
  14. steps:
  15. - name: Triage issue with GitHub Models
  16. uses: actions/github-script@v7
  17. env:
  18. GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
  19. with:
  20. script: |
  21. const title = context.payload.issue.title || '';
  22. const body = context.payload.issue.body || '';
  23. const number = context.payload.issue.number;
  24. const owner = context.repo.owner;
  25. const repo = context.repo.repo;
  26. const AI_MARKER = '<!-- ai-triage -->';
  27. function parseTriageResponse(raw) {
  28. const input = (raw || '').trim();
  29. if (!input) return {};
  30. const candidates = [input];
  31. const fenced = input.match(/```(?:json)?\s*([\s\S]*?)```/i);
  32. if (fenced?.[1]) candidates.push(fenced[1].trim());
  33. const firstBrace = input.indexOf('{');
  34. const lastBrace = input.lastIndexOf('}');
  35. if (firstBrace !== -1 && lastBrace > firstBrace) {
  36. candidates.push(input.slice(firstBrace, lastBrace + 1));
  37. }
  38. for (const candidate of candidates) {
  39. try {
  40. return JSON.parse(candidate);
  41. } catch (_err) {
  42. // Continue trying fallbacks.
  43. }
  44. }
  45. return {};
  46. }
  47. // For short bodies, apply "needs: more info" label directly.
  48. // Skip the AI call but still label the issue.
  49. const isShortBody = body.trim().length < 80;
  50. if (isShortBody) {
  51. try {
  52. await github.rest.issues.addLabels({
  53. owner, repo, issue_number: number,
  54. labels: ['needs: more info'],
  55. });
  56. } catch (err) {
  57. console.log('Could not apply label for short body:', err.message);
  58. }
  59. return;
  60. }
  61. // ── Call GitHub Models ────────────────────────────────────────
  62. let triage;
  63. try {
  64. const res = await fetch(
  65. 'https://models.inference.ai.azure.com/chat/completions',
  66. {
  67. method: 'POST',
  68. headers: {
  69. 'Authorization': `Bearer ${process.env.GITHUB_TOKEN}`,
  70. 'Content-Type': 'application/json',
  71. },
  72. body: JSON.stringify({
  73. model: 'gpt-4o-mini',
  74. temperature: 0.1,
  75. max_tokens: 400,
  76. messages: [
  77. {
  78. role: 'system',
  79. content:
  80. 'You are a triage assistant for LinuxGSM, an open-source ' +
  81. 'Linux game server manager. Your role is to:\n' +
  82. '1. Analyze issue quality (completeness, clarity)\n' +
  83. '2. Extract game names mentioned in the issue, even if misspelled or abbreviated\n' +
  84. '3. Suggest corrections for likely typos using fuzzy matching\n' +
  85. '4. Respond ONLY with a valid JSON object — no markdown fences.\n\n' +
  86. 'Common game name variations and typos you should recognize:\n' +
  87. '- "Valhiem" → "Valheim"\n' +
  88. '- "Rrust" → "Rust"\n' +
  89. '- "Conterstrike" / "CS" / "CSGO" → "Counter-Strike: Global Offensive"\n' +
  90. '- "Garrys" / "GMod" → "Garrys Mod"\n' +
  91. '- "ARK" / "Ark" → "ARK: Survival Evolved"\n' +
  92. '- "DayZ" / "Dayz" → "DayZ"\n' +
  93. '- "Insurgency Sandstorm" / "Insurgency 2" → "Insurgency: Sandstorm"',
  94. },
  95. {
  96. role: 'user',
  97. content:
  98. `Title: ${title}\n\nBody:\n${body.slice(0, 3000)}\n\n` +
  99. 'Respond with this JSON schema:\n' +
  100. '{\n' +
  101. ' "quality": "good" | "ok" | "poor",\n' +
  102. ' "missing_info": ["list of specific missing fields"],\n' +
  103. ' "detected_game": "canonical game name if one is mentioned, or null",\n' +
  104. ' "game_confidence": "high" | "medium" | "low" | null,\n' +
  105. ' "game_note": "correction suggestion if the user misspelled a game name, or empty string",\n' +
  106. ' "comment": "one or two sentence note to the reporter, or empty string"\n' +
  107. '}',
  108. },
  109. ],
  110. }),
  111. }
  112. );
  113. if (!res.ok) {
  114. console.log(`GitHub Models returned ${res.status} — skipping AI triage.`);
  115. return;
  116. }
  117. const data = await res.json();
  118. const raw = data.choices?.[0]?.message?.content || '{}';
  119. triage = parseTriageResponse(raw);
  120. } catch (err) {
  121. // Never fail the workflow if the AI call errors — it's advisory only.
  122. console.log('AI triage skipped:', err.message);
  123. return;
  124. }
  125. if (!triage || typeof triage !== 'object') {
  126. triage = {};
  127. }
  128. // ── Act on the result ────────────────────────────────────────
  129. const isPoor = triage.quality === 'poor';
  130. const missing = Array.isArray(triage.missing_info) ? triage.missing_info : [];
  131. const hasIssues = isPoor || missing.length > 0;
  132. // Prepare labels to apply
  133. const labelsToApply = [];
  134. // Check if a game was detected with high confidence
  135. const detectedGame = triage.detected_game;
  136. const gameConfidence = triage.game_confidence;
  137. if (detectedGame && gameConfidence === 'high') {
  138. labelsToApply.push(`game: ${detectedGame}`);
  139. }
  140. // Apply "needs: more info" label if quality issues detected
  141. if (hasIssues) {
  142. labelsToApply.push('needs: more info');
  143. }
  144. // Apply labels one-by-one so a single failure does not block all labels.
  145. const uniqueLabels = [...new Set(labelsToApply)];
  146. for (const label of uniqueLabels) {
  147. try {
  148. await github.rest.issues.addLabels({
  149. owner,
  150. repo,
  151. issue_number: number,
  152. labels: [label],
  153. });
  154. } catch (err) {
  155. console.log(`Could not apply label "${label}":`, err.message);
  156. }
  157. }
  158. // Post a comment only when there is something specific to say
  159. const gameNote = triage.game_note || '';
  160. const reporterComment = triage.comment || '';
  161. if (!hasIssues && !gameNote) return;
  162. const missingBlock = missing.length > 0
  163. ? `\n\n**Missing information:**\n${missing.map(m => `- ${m}`).join('\n')}`
  164. : '';
  165. const gameBlock = gameNote
  166. ? `\n\n**Game name note:** ${gameNote}`
  167. : '';
  168. const triageCommentBody =
  169. `${AI_MARKER}\n` +
  170. `Thanks for opening this issue! 👋\n\n` +
  171. `${reporterComment}` +
  172. `${missingBlock}` +
  173. `${gameBlock}\n\n` +
  174. `_This note was generated automatically by AI triage and may not be perfect. ` +
  175. `A maintainer will review shortly._`;
  176. try {
  177. const comments = await github.rest.issues.listComments({
  178. owner,
  179. repo,
  180. issue_number: number,
  181. per_page: 100,
  182. });
  183. const existingAiComment = comments.data.find(
  184. (comment) => comment.user?.type === 'Bot' && comment.body?.includes(AI_MARKER)
  185. );
  186. if (existingAiComment) {
  187. await github.rest.issues.updateComment({
  188. owner,
  189. repo,
  190. comment_id: existingAiComment.id,
  191. body: triageCommentBody,
  192. });
  193. } else {
  194. await github.rest.issues.createComment({
  195. owner,
  196. repo,
  197. issue_number: number,
  198. body: triageCommentBody,
  199. });
  200. }
  201. } catch (err) {
  202. console.log('Could not post comment:', err.message);
  203. }