Explorar el Código

chore: add AI issue triage workflow and structured-field label rules (#4915)

- 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
Daniel Gibbs hace 1 mes
padre
commit
f9905881a9

+ 29 - 0
.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.

+ 73 - 13
.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 - 0
.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 - 0
.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);
+            }

+ 29 - 3
.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/issue-labeler@v3.4
+        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 - 0
.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