Browse Source

chore: add AI issue triage workflow and structured-field label rules

- Add AI triage workflow that flags low-quality issues and requests missing info
- Add sync-game-labels script and workflow to maintain game labels from serverlist
- Add structured label rules for severity, reproducibility, priority, and scope
- Update labeler workflow to support both issues and PRs with dedicated config
- Add PR review guidance instructions for maintainers
pull/4915/head
Daniel Gibbs 1 month ago
parent
commit
3cb1b55864
Failed to extract signature
  1. 29
      .github/instructions/pr-review.instructions.md
  2. 86
      .github/labeler.yml
  3. 87
      .github/scripts/sync-game-labels.sh
  4. 229
      .github/workflows/ai-triage.yml
  5. 32
      .github/workflows/labeler.yml
  6. 28
      .github/workflows/sync-game-labels.yml

29
.github/instructions/pr-review.instructions.md

@ -0,0 +1,29 @@
---
title: "LinuxGSM PR Review Guidance"
applyTo: "**"
description: "Use when reviewing pull requests in LinuxGSM; prioritize regressions, behavior changes, shell safety, and missing tests over style-only feedback."
---
Focus review effort on correctness and operational safety first.
Primary priorities:
- Identify behavior regressions and compatibility risks.
- Flag unsafe shell patterns (`rm -rf`, unquoted vars, unchecked command failures).
- Verify workflow changes do not weaken permissions or secret handling.
- Check for missing tests/validation when logic changes.
- Confirm labels, templates, and automation rules stay internally consistent.
Feedback expectations:
- Give concrete, actionable findings with file and reason.
- Prefer high-signal issues over style nits.
- If no defects are found, state that clearly and mention residual risk areas.
- Suggest minimal, low-risk fixes before proposing broad refactors.
LinuxGSM-specific checks:
- Shell scripts should preserve robust defaults (`set -euo pipefail` where appropriate).
- Label/workflow updates should avoid duplicate or stale taxonomy.
- Automation should fail safe (log and continue for advisory AI; block on true CI errors).
- Keep issue/PR automation rules aligned with templates and existing labels.

86
.github/labeler.yml

@ -69,7 +69,7 @@
"game: Ballistic Overkill":
- "/(Ballistic Overkill)/i"
"game: BATTALION: Legacy":
- "/(BATTALION: Legacy)/i"
- "/(BATTALION: Legacy|Battalion 1944)/i"
"game: Barotrauma":
- "/(Barotrauma)/i"
"game: Counter-Strike: Global Offensive":
@ -77,21 +77,27 @@
"game: Counter-Strike 2":
- "/(Counter-Strike 2|CS2)/i"
"game: Counter-Strike: Source":
- "/(Counter-Strike: Source|CS:S)/i"
- "/(Counter-Strike: Source|Counter Strike: Source|CS:S)/i"
"game: Counter-Strike 1.6":
- "/(Counter-Strike 1.6|Counter Strike 1.6|CS 1.6|cs1.6)/i"
"game: Dayz":
- "/(Dayz)/i"
"game: DayZ":
- "/(DayZ|Dayz)/i"
"game: Deathmatch Classic":
- "/(Deathmatch Classic|Death Match Classic|DMC)/i"
"game: Don't Starve Together":
- "/(Don't Starve Together|Dont Starve Together|DST)/i"
"game: Eco":
- "/(^Eco$)/i"
"game: Factorio":
- "/(Factorio)/i"
"game: Garry's Mod":
"game: Garrys Mod":
- "/(Garry's Mod|Garrys Mod|GMod)/i"
"game: Hurtworld":
- "/(Hurtworld|Hurtword)/i"
"game: Insurgency":
- "/(^Insurgency$|Insurgecy)/i"
"game: Insurgency: Sandstorm":
- "/(Insurgency: Sandstorm|Insurgency)/i"
- "/(Insurgency: Sandstorm)/i"
"game: Killing Floor 2":
- "/(Killing Floor 2|KF2)/i"
"game: Left 4 Dead 2":
@ -100,18 +106,20 @@
- "/(Minecraft)((?!bedrock).)*$/i"
"game: Minecraft Bedrock":
- "/(Bedrock)/i"
"game: Mumble":
- "/(Mumble)/i"
"game: Project Zomboid":
- "/(Project Zomboid|PZ)/i"
"game: Quake 3":
- "/(Quake 3|Q3A|q3)/i"
"game: Quake 3: Arena":
- "/(Quake 3: Arena|Quake 3|Q3A|q3)/i"
"game: Quake World":
- "/(Quake World|QuakeWorld)/i"
"game: Rising World":
- "/(Rising World)/i"
"game: Satisfactory":
- "/(Satisfactory)/i"
"game: Squad":
- "/(Squad)/i"
"game: Squad 44":
- "/(Squad 44|Post Scriptum)/i"
"game: Starbound":
- "/(Starbound)/i"
"game: Stationeers":
@ -120,6 +128,8 @@
- "/(Teamspeak 3|ts3)/i"
"game: Rust":
- "/(Rust)/i"
"game: Soldier Of Fortune 2: Gold Edition":
- "/(Soldier Of Fortune 2: Gold Edition|Soldier of Fortune 2)/i"
"game: Unturned":
- "/(Unturned)/i"
"game: Unreal Tournament 99":
@ -130,6 +140,8 @@
- "/(Unreal Tournament 3|ut3)/i"
"game: Valheim":
- "/(Valheim)/i"
"game: Zombie Master: Reborn":
- "/(Zombie Master: Reborn|Zombie Master Reborn)/i"
# Info
"info: alerts":
@ -157,6 +169,54 @@
"type: game server request":
- "/(Server Request)/i"
"type: bug":
- "/(bug)/i"
"type: feature request":
- "/(feature)/i"
- "/(\\[bug\\]|bug report|type: bug)/i"
"type: bugfix":
- "/(^fix(\\(.+\\))?:|\\[x\\] Bug fix)/im"
"type: feature":
- "/(feature request|new feature|^feat(\\(.+\\))?:|\\[x\\] New feature)/im"
"type: docs":
- "/(^docs(\\(.+\\))?:|documentation|\\[x\\] Comment update)/im"
"type: refactor":
- "/(^refactor(\\(.+\\))?:|\\[x\\] Refactor)/im"
"type: chore":
- "/(^chore(\\(.+\\))?:|^ci(\\(.+\\))?:)/im"
# Severity (bug reports)
"severity: low":
- "/(severity: low)/i"
"severity: medium":
- "/(severity: medium)/i"
"severity: high":
- "/(severity: high)/i"
"severity: critical":
- "/(severity: critical)/i"
# Reproducibility (bug reports)
"reproducible: always":
- "/(reproducible: always)/i"
"reproducible: sometimes":
- "/(reproducible: sometimes)/i"
"reproducible: unable":
- "/(reproducible: unable)/i"
# Regression (bug reports)
"regression: yes":
- "/(regression: yes)/i"
# Priority (feature requests)
"priority: low":
- "/(priority: low)/i"
"priority: medium":
- "/(priority: medium)/i"
"priority: high":
- "/(priority: high)/i"
# Scope (feature requests)
"scope: single game":
- "/(scope: single game)/i"
"scope: multiple games":
- "/(scope: multiple games)/i"
"scope: all servers":
- "/(scope: all servers)/i"
"scope: documentation":
- "/(scope: documentation)/i"

87
.github/scripts/sync-game-labels.sh

@ -0,0 +1,87 @@
#!/usr/bin/env bash
# sync-game-labels.sh
# Reads lgsm/data/serverlist.csv and ensures a "game: <name>" label exists in
# the GitHub repo for every unique game name. Safe to run multiple times.
#
# Requires: gh CLI authenticated with issues:write scope.
# Usage: .github/scripts/sync-game-labels.sh [OWNER/REPO]
#
# The OWNER/REPO argument is optional; if omitted gh uses the current repo.
set -euo pipefail
REPO="${1:-}"
SERVERLIST="lgsm/data/serverlist.csv"
LABEL_COLOR="5b21b6"
LABEL_PREFIX="game: "
normalize_label() {
printf '%s' "$1" | tr '[:upper:]' '[:lower:]'
}
if [[ ! -f "${SERVERLIST}" ]]; then
echo "ERROR: ${SERVERLIST} not found. Run from the repository root."
exit 1
fi
declare -A EXISTING_COLORS=()
declare -A EXISTING_DESCRIPTIONS=()
declare -A EXISTING_NAMES=()
# Fetch all existing game label metadata once (up to 1000) and cache locally.
echo "Fetching existing labels..."
while IFS=$'\t' read -r NAME COLOR DESCRIPTION; do
[[ -n "${NAME}" ]] || continue
EXISTING_COLORS["${NAME}"]="${COLOR}"
EXISTING_DESCRIPTIONS["${NAME}"]="${DESCRIPTION}"
EXISTING_NAMES["$(normalize_label "${NAME}")"]="${NAME}"
done < <(
gh label list --limit 1000 --json name,color,description ${REPO:+--repo "$REPO"} \
| jq -r '.[] | select(.name | startswith("game: ")) | [.name, .color, (.description // "")] | @tsv'
)
# Parse unique game names from the CSV (column 3, skip header).
mapfile -t GAMES < <(
tail -n +2 "${SERVERLIST}" \
| cut -d',' -f3 \
| sort -u
)
CREATED=0
UPDATED=0
UNCHANGED=0
for GAME in "${GAMES[@]}"; do
LABEL="${LABEL_PREFIX}${GAME}"
DESCRIPTION="Issues related to ${GAME}"
NORMALIZED_LABEL="$(normalize_label "${LABEL}")"
if [[ -v EXISTING_NAMES["${NORMALIZED_LABEL}"] ]]; then
CURRENT_LABEL="${EXISTING_NAMES["${NORMALIZED_LABEL}"]}"
CURRENT_COLOR="${EXISTING_COLORS["${CURRENT_LABEL}"]}"
CURRENT_DESCRIPTION="${EXISTING_DESCRIPTIONS["${CURRENT_LABEL}"]}"
if [[ "${CURRENT_LABEL}" != "${LABEL}" || "${CURRENT_COLOR}" != "${LABEL_COLOR}" || "${CURRENT_DESCRIPTION}" != "${DESCRIPTION}" ]]; then
echo " update ${LABEL}"
gh label edit "${CURRENT_LABEL}" \
--name "${LABEL}" \
--color "${LABEL_COLOR}" \
--description "${DESCRIPTION}" \
${REPO:+--repo "$REPO"}
(( UPDATED++ )) || true
else
echo " ok ${LABEL}"
(( UNCHANGED++ )) || true
fi
else
echo " create ${LABEL}"
gh label create "${LABEL}" \
--color "${LABEL_COLOR}" \
--description "${DESCRIPTION}" \
${REPO:+--repo "$REPO"}
(( CREATED++ )) || true
fi
done
echo ""
echo "Done. Created: ${CREATED} Updated: ${UPDATED} Unchanged: ${UNCHANGED}"

229
.github/workflows/ai-triage.yml

@ -0,0 +1,229 @@
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);
}

32
.github/workflows/labeler.yml

@ -4,14 +4,24 @@ on:
types:
- opened
- edited
pull_request:
types:
- opened
- edited
- synchronize
- reopened
permissions:
issues: write
pull-requests: write
contents: read
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
jobs:
issue-labeler:
if: github.repository_owner == 'GameServerManagers'
if: github.repository_owner == 'GameServerManagers' && github.event_name == 'issues'
runs-on: ubuntu-latest
steps:
- name: Issue Labeler
@ -21,12 +31,28 @@ jobs:
configuration-path: .github/labeler.yml
enable-versioned-regex: 0
include-title: 1
sync-labels: 1
pr-labeler:
if: github.repository_owner == 'GameServerManagers' && github.event_name == 'pull_request'
runs-on: ubuntu-latest
steps:
- name: PR Labeler
uses: github/[email protected]
with:
repo-token: "${{ secrets.GITHUB_TOKEN }}"
configuration-path: .github/labeler.yml
enable-versioned-regex: 0
include-title: 1
include-body: 0
sync-labels: 1
is-sponsor-label:
if: github.repository_owner == 'GameServerManagers'
if: github.repository_owner == 'GameServerManagers' && github.event_name == 'issues'
runs-on: ubuntu-latest
steps:
- name: Is Sponsor Label
uses: JasonEtco/is-sponsor-label-action@v2
if: github.event.action == 'opened'
uses: JasonEtco/is-sponsor-label-action@v3
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

28
.github/workflows/sync-game-labels.yml

@ -0,0 +1,28 @@
name: Sync Game Labels
on:
push:
branches:
- master
- develop
paths:
- "lgsm/data/serverlist.csv"
workflow_dispatch: {}
permissions:
issues: write
contents: read
jobs:
sync-game-labels:
if: github.repository_owner == 'GameServerManagers'
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v5
- name: Sync game labels from serverlist
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
chmod +x .github/scripts/sync-game-labels.sh
.github/scripts/sync-game-labels.sh
Loading…
Cancel
Save