gameservergame-servergame-servershacktoberfestdedicated-game-serversgamelinuxgsmserverbashgaminglinuxmultiplayer-game-servershell
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
243 lines
9.9 KiB
243 lines
9.9 KiB
name: AI Issue Triage
|
|
on:
|
|
issues:
|
|
types:
|
|
- opened
|
|
- edited
|
|
|
|
# Note: This workflow uses GitHub Models which may not be available
|
|
# in all environments. If GitHub Models API returns 401 or is unavailable,
|
|
# the workflow will skip gracefully and the issue will still be processed
|
|
# by other automation (labeler, etc.)
|
|
|
|
permissions:
|
|
issues: write
|
|
contents: read
|
|
models: 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 ────────────────────────────────────────
|
|
// Note: GitHub Models access may not be available in all environments.
|
|
// If the API returns 401 (Unauthorized), it means GitHub Models is not
|
|
// enabled for this repository or the current token lacks access.
|
|
// The workflow will gracefully skip AI triage and continue.
|
|
let triage;
|
|
try {
|
|
const res = await fetch(
|
|
`https://models.github.ai/orgs/${owner}/inference/chat/completions`,
|
|
{
|
|
method: 'POST',
|
|
headers: {
|
|
'Accept': 'application/vnd.github+json',
|
|
'Authorization': `Bearer ${process.env.GITHUB_TOKEN}`,
|
|
'X-GitHub-Api-Version': '2026-03-10',
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({
|
|
model: 'openai/gpt-4.1-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' +
|
|
'}',
|
|
},
|
|
],
|
|
}),
|
|
}
|
|
);
|
|
|
|
// GitHub Models may return 401 if not available for this repository.
|
|
// This is expected and not an error — the workflow simply skips AI triage.
|
|
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);
|
|
}
|
|
|