ai-triage.yml 9.9 KB

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