| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229 |
- name: AI Issue Triage
- on:
- issues:
- types:
- - opened
- - edited
- permissions:
- issues: write
- contents: read
- jobs:
- ai-triage:
- if: github.repository_owner == 'GameServerManagers'
- runs-on: ubuntu-latest
- steps:
- - name: Triage issue with GitHub Models
- uses: actions/github-script@v7
- env:
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- with:
- script: |
- const title = context.payload.issue.title || '';
- const body = context.payload.issue.body || '';
- const number = context.payload.issue.number;
- const owner = context.repo.owner;
- const repo = context.repo.repo;
- const AI_MARKER = '<!-- ai-triage -->';
- function parseTriageResponse(raw) {
- const input = (raw || '').trim();
- if (!input) return {};
- const candidates = [input];
- const fenced = input.match(/```(?:json)?\s*([\s\S]*?)```/i);
- if (fenced?.[1]) candidates.push(fenced[1].trim());
- const firstBrace = input.indexOf('{');
- const lastBrace = input.lastIndexOf('}');
- if (firstBrace !== -1 && lastBrace > firstBrace) {
- candidates.push(input.slice(firstBrace, lastBrace + 1));
- }
- for (const candidate of candidates) {
- try {
- return JSON.parse(candidate);
- } catch (_err) {
- // Continue trying fallbacks.
- }
- }
- return {};
- }
- // For short bodies, apply "needs: more info" label directly.
- // Skip the AI call but still label the issue.
- const isShortBody = body.trim().length < 80;
- if (isShortBody) {
- try {
- await github.rest.issues.addLabels({
- owner, repo, issue_number: number,
- labels: ['needs: more info'],
- });
- } catch (err) {
- console.log('Could not apply label for short body:', err.message);
- }
- return;
- }
- // ── Call GitHub Models ────────────────────────────────────────
- let triage;
- try {
- const res = await fetch(
- 'https://models.inference.ai.azure.com/chat/completions',
- {
- method: 'POST',
- headers: {
- 'Authorization': `Bearer ${process.env.GITHUB_TOKEN}`,
- 'Content-Type': 'application/json',
- },
- body: JSON.stringify({
- model: 'gpt-4o-mini',
- temperature: 0.1,
- max_tokens: 400,
- messages: [
- {
- role: 'system',
- content:
- 'You are a triage assistant for LinuxGSM, an open-source ' +
- 'Linux game server manager. Your role is to:\n' +
- '1. Analyze issue quality (completeness, clarity)\n' +
- '2. Extract game names mentioned in the issue, even if misspelled or abbreviated\n' +
- '3. Suggest corrections for likely typos using fuzzy matching\n' +
- '4. Respond ONLY with a valid JSON object — no markdown fences.\n\n' +
- 'Common game name variations and typos you should recognize:\n' +
- '- "Valhiem" → "Valheim"\n' +
- '- "Rrust" → "Rust"\n' +
- '- "Conterstrike" / "CS" / "CSGO" → "Counter-Strike: Global Offensive"\n' +
- '- "Garrys" / "GMod" → "Garrys Mod"\n' +
- '- "ARK" / "Ark" → "ARK: Survival Evolved"\n' +
- '- "DayZ" / "Dayz" → "DayZ"\n' +
- '- "Insurgency Sandstorm" / "Insurgency 2" → "Insurgency: Sandstorm"',
- },
- {
- role: 'user',
- content:
- `Title: ${title}\n\nBody:\n${body.slice(0, 3000)}\n\n` +
- 'Respond with this JSON schema:\n' +
- '{\n' +
- ' "quality": "good" | "ok" | "poor",\n' +
- ' "missing_info": ["list of specific missing fields"],\n' +
- ' "detected_game": "canonical game name if one is mentioned, or null",\n' +
- ' "game_confidence": "high" | "medium" | "low" | null,\n' +
- ' "game_note": "correction suggestion if the user misspelled a game name, or empty string",\n' +
- ' "comment": "one or two sentence note to the reporter, or empty string"\n' +
- '}',
- },
- ],
- }),
- }
- );
- if (!res.ok) {
- console.log(`GitHub Models returned ${res.status} — skipping AI triage.`);
- return;
- }
- const data = await res.json();
- const raw = data.choices?.[0]?.message?.content || '{}';
- triage = parseTriageResponse(raw);
- } catch (err) {
- // Never fail the workflow if the AI call errors — it's advisory only.
- console.log('AI triage skipped:', err.message);
- return;
- }
- if (!triage || typeof triage !== 'object') {
- triage = {};
- }
- // ── Act on the result ────────────────────────────────────────
- const isPoor = triage.quality === 'poor';
- const missing = Array.isArray(triage.missing_info) ? triage.missing_info : [];
- const hasIssues = isPoor || missing.length > 0;
- // Prepare labels to apply
- const labelsToApply = [];
- // Check if a game was detected with high confidence
- const detectedGame = triage.detected_game;
- const gameConfidence = triage.game_confidence;
- if (detectedGame && gameConfidence === 'high') {
- labelsToApply.push(`game: ${detectedGame}`);
- }
- // Apply "needs: more info" label if quality issues detected
- if (hasIssues) {
- labelsToApply.push('needs: more info');
- }
- // Apply labels one-by-one so a single failure does not block all labels.
- const uniqueLabels = [...new Set(labelsToApply)];
- for (const label of uniqueLabels) {
- try {
- await github.rest.issues.addLabels({
- owner,
- repo,
- issue_number: number,
- labels: [label],
- });
- } catch (err) {
- console.log(`Could not apply label "${label}":`, err.message);
- }
- }
- // Post a comment only when there is something specific to say
- const gameNote = triage.game_note || '';
- const reporterComment = triage.comment || '';
- if (!hasIssues && !gameNote) return;
- const missingBlock = missing.length > 0
- ? `\n\n**Missing information:**\n${missing.map(m => `- ${m}`).join('\n')}`
- : '';
- const gameBlock = gameNote
- ? `\n\n**Game name note:** ${gameNote}`
- : '';
- const triageCommentBody =
- `${AI_MARKER}\n` +
- `Thanks for opening this issue! 👋\n\n` +
- `${reporterComment}` +
- `${missingBlock}` +
- `${gameBlock}\n\n` +
- `_This note was generated automatically by AI triage and may not be perfect. ` +
- `A maintainer will review shortly._`;
- try {
- const comments = await github.rest.issues.listComments({
- owner,
- repo,
- issue_number: number,
- per_page: 100,
- });
- const existingAiComment = comments.data.find(
- (comment) => comment.user?.type === 'Bot' && comment.body?.includes(AI_MARKER)
- );
- if (existingAiComment) {
- await github.rest.issues.updateComment({
- owner,
- repo,
- comment_id: existingAiComment.id,
- body: triageCommentBody,
- });
- } else {
- await github.rest.issues.createComment({
- owner,
- repo,
- issue_number: number,
- body: triageCommentBody,
- });
- }
- } catch (err) {
- console.log('Could not post comment:', err.message);
- }
|