[Server Request] OpenTTD #17
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Issue Triage & Automation | |
| on: | |
| workflow_dispatch: | |
| inputs: | |
| issue_state: | |
| description: Issue state to backfill | |
| required: true | |
| default: all | |
| type: choice | |
| options: | |
| - all | |
| - open | |
| - closed | |
| limit: | |
| description: Max issues to process (0 = all) | |
| required: true | |
| default: "0" | |
| type: string | |
| ai_game_fallback: | |
| description: Use AI only when deterministic game mapping finds no game | |
| required: true | |
| default: "false" | |
| type: choice | |
| options: | |
| - "false" | |
| - "true" | |
| issues: | |
| types: | |
| - opened | |
| - edited | |
| - reopened | |
| - labeled | |
| - unlabeled | |
| - assigned | |
| - unassigned | |
| - milestoned | |
| - demilestoned | |
| - transferred | |
| - pinned | |
| - unpinned | |
| issue_comment: | |
| types: | |
| - created | |
| - edited | |
| - deleted | |
| pull_request: | |
| types: | |
| - opened | |
| - edited | |
| - synchronize | |
| - reopened | |
| push: | |
| branches: | |
| - master | |
| - develop | |
| paths: | |
| - "lgsm/data/serverlist.csv" | |
| permissions: | |
| issues: write | |
| pull-requests: write | |
| contents: read | |
| models: read | |
| env: | |
| FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" | |
| jobs: | |
| issue-regex-labeler: | |
| if: github.repository_owner == 'GameServerManagers' && github.event_name == 'issues' && (github.event.action == 'opened' || github.event.action == 'edited') | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Issue 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 | |
| sync-labels: 0 | |
| issue-ai-maintenance: | |
| if: github.repository_owner == 'GameServerManagers' && (github.event_name == 'issues' || github.event_name == 'issue_comment') | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Reconcile issue labels and AI triage | |
| uses: actions/github-script@v9 | |
| env: | |
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| with: | |
| script: | | |
| const { execFileSync } = require('node:child_process'); | |
| const owner = context.repo.owner; | |
| const repo = context.repo.repo; | |
| const eventName = context.eventName; | |
| const action = context.payload.action; | |
| const issueNumber = context.payload.issue?.number; | |
| const AI_MARKER = '<!-- ai-triage -->'; | |
| if (!issueNumber) { | |
| console.log('No issue number found in payload.'); | |
| return; | |
| } | |
| // Avoid bot-to-bot relabel loops on label events. | |
| if ( | |
| eventName === 'issues' && | |
| ['labeled', 'unlabeled'].includes(action) && | |
| context.actor === 'github-actions[bot]' | |
| ) { | |
| console.log('Skipping self-triggered label event.'); | |
| return; | |
| } | |
| const issueResp = await github.rest.issues.get({ | |
| owner, | |
| repo, | |
| issue_number: issueNumber, | |
| }); | |
| const issue = issueResp.data; | |
| const title = issue.title || ''; | |
| const body = issue.body || ''; | |
| const existingLabels = new Set((issue.labels || []).map((l) => l.name).filter(Boolean)); | |
| 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 {}; | |
| } | |
| function extractSection(sectionName) { | |
| const escaped = sectionName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); | |
| const re = new RegExp(`### ${escaped}\\n\\n([\\s\\S]*?)(\\n### |$)`, 'i'); | |
| return (body.match(re)?.[1] || '').trim(); | |
| } | |
| function normalizeName(value) { | |
| return (value || '') | |
| .toLowerCase() | |
| .replace(/[’'`]/g, '') | |
| .replace(/[^a-z0-9]+/g, ' ') | |
| .trim(); | |
| } | |
| function parseGameCandidates(gameField) { | |
| if (!gameField || /^_?no response_?$/i.test(gameField)) { | |
| return []; | |
| } | |
| return gameField | |
| .replace(/\(.*?\)/g, ' ') | |
| .split(/\n|,|\s+&\s+|\s+and\s+|\//i) | |
| .map((v) => v.trim()) | |
| .filter(Boolean); | |
| } | |
| function findGamesFromText(text, gameAliasToLabel, gameAliasToScript) { | |
| const labels = new Set(); | |
| const scripts = new Set(); | |
| const normalizedText = normalizeName(text); | |
| if (!normalizedText) return { labels, scripts }; | |
| const escapeRegex = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); | |
| const aliases = []; | |
| for (const [alias, label] of gameAliasToLabel.entries()) { | |
| if (alias.length < 3) continue; | |
| aliases.push({ alias, label, script: gameAliasToScript.get(alias) || null }); | |
| } | |
| // Prefer longer aliases first so "killing floor 2" does not also match "killing floor". | |
| aliases.sort((a, b) => b.alias.length - a.alias.length); | |
| const usedRanges = []; | |
| const isOverlapping = (start, end) => | |
| usedRanges.some((range) => start < range.end && end > range.start); | |
| for (const entry of aliases) { | |
| const pattern = new RegExp(`\\b${escapeRegex(entry.alias).replace(/\\ /g, '\\s+')}\\b`, 'g'); | |
| let match; | |
| while ((match = pattern.exec(normalizedText)) !== null) { | |
| const start = match.index; | |
| const end = start + match[0].length; | |
| if (isOverlapping(start, end)) continue; | |
| labels.add(entry.label); | |
| if (entry.script) scripts.add(entry.script); | |
| usedRanges.push({ start, end }); | |
| } | |
| } | |
| return { labels, scripts }; | |
| } | |
| function hasAliasHitForLabel(text, targetLabel, gameAliasToLabel) { | |
| const normalizedText = normalizeName(text); | |
| if (!normalizedText || !targetLabel) return false; | |
| const paddedText = ` ${normalizedText} `; | |
| for (const [alias, label] of gameAliasToLabel.entries()) { | |
| if (label !== targetLabel) continue; | |
| if (alias.length < 3) continue; | |
| if (paddedText.includes(` ${alias} `)) return true; | |
| // Allow obvious joined-word variants for multi-token aliases | |
| // (e.g., "counter strike 1 6" matching "counterstrike 1.6"). | |
| const aliasTokens = alias.split(/\s+/).filter(Boolean); | |
| if (aliasTokens.length > 1) { | |
| const escapedTokens = aliasTokens.map((token) => | |
| token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') | |
| ); | |
| const flexibleAliasPattern = new RegExp(`\\b${escapedTokens.join('\\s*')}\\b`); | |
| if (flexibleAliasPattern.test(normalizedText)) return true; | |
| } | |
| } | |
| return false; | |
| } | |
| function runSteamCmdLinuxCheck(appId) { | |
| if (!appId) { | |
| return { status: 'skipped', reason: 'No Steam AppID provided.' }; | |
| } | |
| const image = 'gameservermanagers/steamcmd:latest'; | |
| const args = [ | |
| 'run', | |
| '--rm', | |
| '-e', | |
| 'PUID=1001', | |
| '-e', | |
| 'PGID=1001', | |
| image, | |
| '+@ShutdownOnFailedCommand', | |
| '1', | |
| '+@NoPromptForPassword', | |
| '1', | |
| '+login', | |
| 'anonymous', | |
| '+app_info_update', | |
| '1', | |
| '+app_info_print', | |
| String(appId), | |
| '+quit', | |
| ]; | |
| try { | |
| const output = execFileSync('docker', args, { | |
| encoding: 'utf8', | |
| stdio: ['ignore', 'pipe', 'pipe'], | |
| timeout: 120000, | |
| maxBuffer: 10 * 1024 * 1024, | |
| }); | |
| const normalized = output.toLowerCase(); | |
| const linuxSignals = [ | |
| /"oslist"\s+"linux"/i, | |
| /"oslist"\s+"linux,windows"/i, | |
| /"oslist"\s+"windows,linux"/i, | |
| /"platforms"[\s\S]*?"linux"\s+"1"/i, | |
| /linux32/i, | |
| /linux64/i, | |
| ]; | |
| const windowsOnlySignals = [ | |
| /"oslist"\s+"windows"/i, | |
| /"platforms"[\s\S]*?"windows"\s+"1"/i, | |
| ]; | |
| const hasLinuxSignal = linuxSignals.some((re) => re.test(normalized)); | |
| const hasWindowsOnlySignal = | |
| !hasLinuxSignal && windowsOnlySignals.some((re) => re.test(normalized)); | |
| if (hasLinuxSignal) { | |
| return { | |
| status: 'linux', | |
| reason: `SteamCMD app_info contains Linux platform/depot metadata for AppID ${appId}.`, | |
| }; | |
| } | |
| if (hasWindowsOnlySignal) { | |
| return { | |
| status: 'windows-only', | |
| reason: `SteamCMD app_info contains Windows-only platform metadata for AppID ${appId}.`, | |
| }; | |
| } | |
| return { | |
| status: 'unknown', | |
| reason: `SteamCMD app_info returned no clear Linux server metadata for AppID ${appId}.`, | |
| }; | |
| } catch (err) { | |
| const stderr = err.stderr ? String(err.stderr).trim() : ''; | |
| const stdout = err.stdout ? String(err.stdout).trim() : ''; | |
| const message = stderr || stdout || err.message; | |
| return { | |
| status: 'error', | |
| reason: `SteamCMD lookup failed: ${message}`, | |
| }; | |
| } | |
| } | |
| function parseServerlistCsv(csvText) { | |
| const rows = []; | |
| const lines = (csvText || '').split(/\r?\n/); | |
| for (let i = 1; i < lines.length; i += 1) { | |
| const line = lines[i]?.trim(); | |
| if (!line) continue; | |
| const parts = line.split(','); | |
| if (parts.length < 3) continue; | |
| rows.push({ | |
| shortname: parts[0].trim(), | |
| gameservername: parts[1].trim(), | |
| gamename: parts[2].trim(), | |
| }); | |
| } | |
| return rows; | |
| } | |
| function inferTypeFromTitle(issueTitle) { | |
| if (/^\[bug\]/i.test(issueTitle)) return 'type: bug'; | |
| if (/\bserver\s+request\b/i.test(issueTitle)) return 'type: game server request'; | |
| const hasBracketPrefix = /^\[[^\]]+\]/.test(issueTitle || ''); | |
| const isServerCreation = | |
| /\bserver\s+creation\b/i.test(issueTitle) || | |
| (hasBracketPrefix && /\bcreation\b/i.test(issueTitle)); | |
| const isServerSupportRequest = | |
| /\bserver\s+support\b/i.test(issueTitle) || | |
| (/\bsupport\s+for\b/i.test(issueTitle) && /\bserver\b/i.test(issueTitle)); | |
| if (isServerCreation || isServerSupportRequest) return 'type: game server request'; | |
| if (/^\[feature\]/i.test(issueTitle)) return 'type: feature'; | |
| if (/^\[server request\]/i.test(issueTitle)) return 'type: game server request'; | |
| if (/^\[docs?\]/i.test(issueTitle)) return 'type: docs'; | |
| return null; | |
| } | |
| function inferDesiredType(issueTitle, labelNames) { | |
| const titleType = inferTypeFromTitle(issueTitle); | |
| if (titleType) return titleType; | |
| // Prefer server requests over generic feature when both labels exist. | |
| if (labelNames.has('type: game server request')) return 'type: game server request'; | |
| for (const label of [ | |
| 'type: bug', | |
| 'type: feature', | |
| 'type: game server request', | |
| 'type: docs', | |
| ]) { | |
| if (labelNames.has(label)) return label; | |
| } | |
| return null; | |
| } | |
| function inferIssueTypeNameFromDesiredType(typeLabel) { | |
| if (typeLabel === 'type: bug') return 'Bug'; | |
| if (typeLabel === 'type: feature') return 'Feature'; | |
| if (typeLabel === 'type: game server request') return 'Server Request'; | |
| if (typeLabel === 'type: docs') return 'Task'; | |
| return null; | |
| } | |
| function parseCommandSelections(sectionValue) { | |
| const selected = new Set(); | |
| const re = /command:\s*([a-z-]+)/gi; | |
| let m; | |
| while ((m = re.exec(sectionValue || '')) !== null) { | |
| let value = m[1].toLowerCase(); | |
| if (value.startsWith('mods-')) value = 'mods'; | |
| if (value === 'auto-update') value = 'update'; | |
| selected.add(`command: ${value}`); | |
| } | |
| return selected; | |
| } | |
| function parseDistroSelections(sectionValue) { | |
| const text = sectionValue || ''; | |
| const selected = new Set(); | |
| if (/\bUbuntu\b/i.test(text)) selected.add('distro: Ubuntu'); | |
| if (/\bDebian\b/i.test(text)) selected.add('distro: Debian'); | |
| if (/\bAlmaLinux\b/i.test(text)) selected.add('distro: AlmaLinux'); | |
| if (/\bRocky\b/i.test(text)) selected.add('distro: Rocky Linux'); | |
| if (/\bCentOS\b/i.test(text)) selected.add('distro: CentOS'); | |
| if (/\bFedora\b/i.test(text)) selected.add('distro: Fedora'); | |
| if (/\bopenSUSE\b/i.test(text)) selected.add('distro: openSUSE'); | |
| if (/\bArch Linux\b/i.test(text)) selected.add('distro: Arch Linux'); | |
| if (/\bSlackware\b/i.test(text)) selected.add('distro: Slackware'); | |
| return selected; | |
| } | |
| const repoLabels = await github.paginate(github.rest.issues.listLabelsForRepo, { | |
| owner, | |
| repo, | |
| per_page: 100, | |
| }); | |
| const gameLabelByNormalized = new Map(); | |
| for (const label of repoLabels) { | |
| if (!label.name.startsWith('game: ')) continue; | |
| gameLabelByNormalized.set(normalizeName(label.name.slice(6)), label.name); | |
| } | |
| const existingEngineLabels = new Set( | |
| repoLabels.map((label) => label.name).filter((name) => name.startsWith('engine: ')) | |
| ); | |
| const gameAliasToLabel = new Map(); | |
| const gameAliasToScript = new Map(); | |
| const engineByScript = new Map(); | |
| for (const [normalizedGameName, label] of gameLabelByNormalized.entries()) { | |
| gameAliasToLabel.set(normalizedGameName, label); | |
| } | |
| try { | |
| const serverlistContent = await github.rest.repos.getContent({ | |
| owner, | |
| repo, | |
| path: 'lgsm/data/serverlist.csv', | |
| }); | |
| const encoded = serverlistContent.data?.content || ''; | |
| const csvText = Buffer.from(encoded, 'base64').toString('utf8'); | |
| const serverRows = parseServerlistCsv(csvText); | |
| for (const row of serverRows) { | |
| const canonicalLabel = gameLabelByNormalized.get(normalizeName(row.gamename)); | |
| if (!canonicalLabel) continue; | |
| for (const alias of [row.shortname, row.gameservername, row.gamename]) { | |
| const key = normalizeName(alias); | |
| if (!key) continue; | |
| gameAliasToLabel.set(key, canonicalLabel); | |
| gameAliasToScript.set(key, row.gameservername); | |
| } | |
| } | |
| } catch (err) { | |
| console.log(`Could not load serverlist aliases: ${err.message}`); | |
| } | |
| async function ensureEngineLabel(engineLabel) { | |
| if (existingEngineLabels.has(engineLabel)) return; | |
| try { | |
| await github.rest.issues.createLabel({ | |
| owner, | |
| repo, | |
| name: engineLabel, | |
| color: '000000', | |
| description: `Issues related to ${engineLabel.slice(8)} engine`, | |
| }); | |
| existingEngineLabels.add(engineLabel); | |
| } catch (err) { | |
| if (err.status === 422) { | |
| existingEngineLabels.add(engineLabel); | |
| return; | |
| } | |
| console.log(`Could not create engine label "${engineLabel}": ${err.message}`); | |
| } | |
| } | |
| async function getEngineForScript(scriptName) { | |
| if (!scriptName) return null; | |
| if (engineByScript.has(scriptName)) { | |
| return engineByScript.get(scriptName); | |
| } | |
| try { | |
| const cfgContent = await github.rest.repos.getContent({ | |
| owner, | |
| repo, | |
| path: `lgsm/config-default/config-lgsm/${scriptName}/_default.cfg`, | |
| }); | |
| const encoded = cfgContent.data?.content || ''; | |
| const cfgText = Buffer.from(encoded, 'base64').toString('utf8'); | |
| const engine = cfgText.match(/^engine="([^"]+)"/m)?.[1] || null; | |
| engineByScript.set(scriptName, engine); | |
| return engine; | |
| } catch (err) { | |
| console.log(`Could not detect engine for ${scriptName}: ${err.message}`); | |
| engineByScript.set(scriptName, null); | |
| return null; | |
| } | |
| } | |
| const labelsToAdd = new Set(); | |
| const labelsToRemove = new Set(); | |
| // Deterministic reconciliation on every interaction. | |
| const desiredType = inferDesiredType(title, existingLabels); | |
| if (desiredType) { | |
| labelsToAdd.add(desiredType); | |
| for (const label of existingLabels) { | |
| if (label.startsWith('type: ') && label !== desiredType) { | |
| labelsToRemove.add(label); | |
| } | |
| } | |
| const desiredIssueTypeName = inferIssueTypeNameFromDesiredType(desiredType); | |
| if (desiredIssueTypeName) { | |
| try { | |
| const issueTypeData = await github.graphql( | |
| `query($owner:String!,$repo:String!,$number:Int!){ | |
| repository(owner:$owner,name:$repo){ | |
| issueTypes(first:20){ nodes { id name } } | |
| issue(number:$number){ id issueType { id name } } | |
| } | |
| }`, | |
| { owner, repo, number: issueNumber } | |
| ); | |
| const issueNode = issueTypeData.repository?.issue; | |
| const issueTypes = issueTypeData.repository?.issueTypes?.nodes || []; | |
| const desiredIssueType = issueTypes.find((t) => t.name === desiredIssueTypeName); | |
| if (issueNode?.id && desiredIssueType?.id && issueNode.issueType?.id !== desiredIssueType.id) { | |
| await github.graphql( | |
| `mutation($id:ID!,$issueTypeId:ID!){ | |
| updateIssue(input:{id:$id,issueTypeId:$issueTypeId}){ | |
| issue { id number issueType { id name } } | |
| } | |
| }`, | |
| { id: issueNode.id, issueTypeId: desiredIssueType.id } | |
| ); | |
| } | |
| } catch (err) { | |
| console.log(`Could not sync Issue Type: ${err.message}`); | |
| } | |
| } | |
| } | |
| const commandSection = extractSection('Command'); | |
| const desiredCommands = parseCommandSelections(commandSection); | |
| if (desiredCommands.size > 0) { | |
| for (const label of desiredCommands) labelsToAdd.add(label); | |
| for (const label of existingLabels) { | |
| if (label.startsWith('command: ') && !desiredCommands.has(label)) { | |
| labelsToRemove.add(label); | |
| } | |
| } | |
| } | |
| const distroSection = extractSection('Linux distro'); | |
| const desiredDistros = parseDistroSelections(distroSection); | |
| if (desiredDistros.size > 0) { | |
| for (const label of desiredDistros) labelsToAdd.add(label); | |
| for (const label of existingLabels) { | |
| if (label.startsWith('distro: ') && !desiredDistros.has(label)) { | |
| labelsToRemove.add(label); | |
| } | |
| } | |
| } | |
| const tmuxContextPattern = /\b(tmuxception|check_tmuxception)\b/i; | |
| if (existingLabels.has('info: tmux') && !tmuxContextPattern.test(`${title}\n${body}`)) { | |
| labelsToRemove.add('info: tmux'); | |
| } | |
| const desiredGames = new Set(); | |
| const desiredServerScripts = new Set(); | |
| // 'Game server' is the section name in server_request.yml; 'Game' is used in bug_report.yml. | |
| const gameField = extractSection('Game server') || extractSection('Game'); | |
| const gameCandidates = parseGameCandidates(gameField); | |
| const hasStructuredGameSelection = gameCandidates.length > 0; | |
| for (const candidate of gameCandidates) { | |
| const normalizedCandidate = normalizeName(candidate); | |
| const mapped = gameAliasToLabel.get(normalizedCandidate) || gameLabelByNormalized.get(normalizedCandidate); | |
| if (mapped) desiredGames.add(mapped); | |
| const mappedScript = gameAliasToScript.get(normalizedCandidate); | |
| if (mappedScript) desiredServerScripts.add(mappedScript); | |
| } | |
| // Legacy issues often have no form section; fall back to deterministic text matching. | |
| // If a structured Game field exists but does not map, do not guess from free text. | |
| if (desiredGames.size === 0 && !hasStructuredGameSelection) { | |
| const fromText = findGamesFromText(`${title}\n${body}`, gameAliasToLabel, gameAliasToScript); | |
| for (const label of fromText.labels) desiredGames.add(label); | |
| for (const scriptName of fromText.scripts) desiredServerScripts.add(scriptName); | |
| } | |
| // AI advisory is only needed on issue opened/edited. | |
| let triage = {}; | |
| let ranAi = false; | |
| const shouldRunAi = eventName === 'issues' && ['opened', 'edited'].includes(action); | |
| const shouldRunLinuxSupportCheck = | |
| eventName === 'issues' && | |
| ['opened', 'edited', 'reopened', 'labeled', 'unlabeled'].includes(action); | |
| if (shouldRunAi) { | |
| ranAi = true; | |
| const isShortBody = body.trim().length < 80; | |
| if (isShortBody) { | |
| labelsToAdd.add('needs: more info'); | |
| } else { | |
| 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. ' + | |
| 'Return only JSON. Analyze issue quality, suggest missing info, detect game names, and suggest contextual labels ' + | |
| 'only when highly certain. Never set type: docs just because docs links are mentioned.', | |
| }, | |
| { | |
| role: 'user', | |
| content: | |
| `Title: ${title}\n\nBody:\n${body.slice(0, 3000)}\n\n` + | |
| 'Return 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' + | |
| ' "context_labels": ["labels"],\n' + | |
| ' "context_confidence": "high" | "medium" | "low" | null,\n' + | |
| ' "game_note": "string",\n' + | |
| ' "comment": "string"\n' + | |
| '}', | |
| }, | |
| ], | |
| }), | |
| } | |
| ); | |
| if (res.ok) { | |
| const data = await res.json(); | |
| const raw = data.choices?.[0]?.message?.content || '{}'; | |
| triage = parseTriageResponse(raw); | |
| } else { | |
| console.log(`GitHub Models returned ${res.status} - skipping AI triage.`); | |
| } | |
| } catch (err) { | |
| console.log('AI triage skipped:', err.message); | |
| } | |
| } | |
| } | |
| const allowedContextLabels = new Set([ | |
| 'type: docs', | |
| 'info: docs', | |
| 'info: dependency', | |
| 'info: docker', | |
| 'info: email', | |
| 'info: query', | |
| 'info: steamcmd', | |
| 'info: systemd', | |
| 'info: website', | |
| 'info: alerts', | |
| ]); | |
| const isPoor = triage?.quality === 'poor'; | |
| const missing = Array.isArray(triage?.missing_info) ? triage.missing_info : []; | |
| const hasIssues = isPoor || missing.length > 0; | |
| // Fallback to AI-detected game only when no structured Game field exists. | |
| const detectedGame = triage?.detected_game; | |
| const gameConfidence = triage?.game_confidence; | |
| if (desiredGames.size === 0 && !hasStructuredGameSelection && detectedGame && gameConfidence === 'high') { | |
| const normalizedDetectedGame = normalizeName(detectedGame); | |
| const mapped = gameLabelByNormalized.get(normalizedDetectedGame); | |
| if (mapped) { | |
| desiredGames.add(mapped); | |
| } | |
| const mappedScript = gameAliasToScript.get(normalizedDetectedGame); | |
| if (mappedScript) desiredServerScripts.add(mappedScript); | |
| } | |
| // Resolve server scripts from canonical game labels when only labels were mapped. | |
| for (const gameLabel of desiredGames) { | |
| const gameName = gameLabel.slice(6); | |
| const mappedScript = gameAliasToScript.get(normalizeName(gameName)); | |
| if (mappedScript) desiredServerScripts.add(mappedScript); | |
| } | |
| const desiredEngineLabels = new Set(); | |
| for (const scriptName of desiredServerScripts) { | |
| const engine = await getEngineForScript(scriptName); | |
| if (!engine) continue; | |
| const engineLabel = `engine: ${engine}`; | |
| await ensureEngineLabel(engineLabel); | |
| desiredEngineLabels.add(engineLabel); | |
| } | |
| if (desiredEngineLabels.size > 0) { | |
| for (const label of desiredEngineLabels) labelsToAdd.add(label); | |
| for (const label of existingLabels) { | |
| if (label.startsWith('engine: ') && !desiredEngineLabels.has(label)) { | |
| labelsToRemove.add(label); | |
| } | |
| } | |
| } | |
| if (desiredGames.size > 0) { | |
| for (const label of desiredGames) labelsToAdd.add(label); | |
| if (hasStructuredGameSelection) { | |
| for (const label of existingLabels) { | |
| if (label.startsWith('game: ') && !desiredGames.has(label)) { | |
| labelsToRemove.add(label); | |
| } | |
| } | |
| } else { | |
| // For legacy issues without structured game selection, only prune stale | |
| // broader labels when a more specific inferred game label exists. | |
| const desiredGameNamesNormalized = new Set( | |
| [...desiredGames].map((label) => normalizeName(label.slice(6))) | |
| ); | |
| for (const label of existingLabels) { | |
| if (!label.startsWith('game: ') || desiredGames.has(label)) continue; | |
| const existingGameName = normalizeName(label.slice(6)); | |
| const isBroaderOverlap = [...desiredGameNamesNormalized].some( | |
| (desiredName) => desiredName !== existingGameName && desiredName.startsWith(`${existingGameName} `) | |
| ); | |
| if (isBroaderOverlap) { | |
| labelsToRemove.add(label); | |
| } | |
| } | |
| } | |
| } | |
| if (triage?.context_confidence === 'high') { | |
| const contextLabels = Array.isArray(triage.context_labels) ? triage.context_labels : []; | |
| for (const label of contextLabels) { | |
| if (!allowedContextLabels.has(label)) continue; | |
| if ( | |
| label === 'type: docs' && | |
| (existingLabels.has('type: game server request') || desiredType === 'type: game server request') | |
| ) { | |
| continue; | |
| } | |
| labelsToAdd.add(label); | |
| } | |
| } | |
| if (ranAi && hasIssues) { | |
| labelsToAdd.add('needs: more info'); | |
| } | |
| if (ranAi && !hasIssues && existingLabels.has('needs: more info')) { | |
| labelsToRemove.add('needs: more info'); | |
| } | |
| // Avoid pointless API calls. | |
| const finalAdds = [...labelsToAdd].filter((label) => !existingLabels.has(label)); | |
| const finalRemoves = [...labelsToRemove].filter((label) => existingLabels.has(label)); | |
| for (const label of finalRemoves) { | |
| try { | |
| await github.rest.issues.removeLabel({ | |
| owner, | |
| repo, | |
| issue_number: issueNumber, | |
| name: label, | |
| }); | |
| console.log(`Removed label: ${label}`); | |
| } catch (err) { | |
| console.log(`Could not remove label "${label}": ${err.message}`); | |
| } | |
| } | |
| for (const label of finalAdds) { | |
| try { | |
| await github.rest.issues.addLabels({ | |
| owner, | |
| repo, | |
| issue_number: issueNumber, | |
| labels: [label], | |
| }); | |
| console.log(`Added label: ${label}`); | |
| } catch (err) { | |
| console.log(`Could not add label "${label}": ${err.message}`); | |
| } | |
| } | |
| // Post AI comment only for opened/edited issues when useful. | |
| if (ranAi) { | |
| const gameNote = triage?.game_note || ''; | |
| const reporterComment = triage?.comment || ''; | |
| if (hasIssues || gameNote) { | |
| 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.paginate(github.rest.issues.listComments, { | |
| owner, | |
| repo, | |
| issue_number: issueNumber, | |
| per_page: 100, | |
| }); | |
| const existingAiComment = [...comments].reverse().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: issueNumber, | |
| body: triageCommentBody, | |
| }); | |
| } | |
| } catch (err) { | |
| console.log('Could not post comment:', err.message); | |
| } | |
| } | |
| } | |
| // === Linux support verification for server request issues === | |
| // Runs only on opened/edited events to avoid reprocessing every label change. | |
| const isServerRequest = | |
| desiredType === 'type: game server request' || | |
| existingLabels.has('type: game server request') || | |
| /\[server request\]/i.test(title); | |
| if (isServerRequest && shouldRunLinuxSupportCheck) { | |
| const officialDocsSection = extractSection('Official dedicated server documentation'); | |
| const linuxBinaryProofSection = extractSection('Linux binary proof'); | |
| const guidesSection = extractSection('Guides'); | |
| const steamSection = extractSection('Steam').trim(); | |
| const isSteamNo = /^no$/i.test(steamSection); | |
| const isSteamYes = /^yes$/i.test(steamSection); | |
| const steamAppIdRaw = extractSection('Steam appid').trim(); | |
| const steamAppId = /^\d+$/.test(steamAppIdRaw) ? steamAppIdRaw : null; | |
| const supportEvidenceText = [officialDocsSection, linuxBinaryProofSection, guidesSection] | |
| .join('\n') | |
| .trim(); | |
| // Deterministic textual checks to avoid trusting checkbox-only reports. | |
| const windowsOnlyPatterns = [ | |
| /\bwindows\s+only\b/i, | |
| /\bonly\s+windows\b/i, | |
| /\bno\s+linux\s+support\b/i, | |
| /\blinux\s+not\s+supported\b/i, | |
| /\bdoes\s+not\s+support\s+linux\b/i, | |
| ]; | |
| const wineRequiredPatterns = [ | |
| /\brequires?\s+wine\b/i, | |
| /\buse\s+wine\b/i, | |
| /\brun\s+with\s+wine\b/i, | |
| /\bvia\s+wine\b/i, | |
| /\bproton\b/i, | |
| ]; | |
| const linuxEvidencePatterns = [ | |
| /\blinux\b/i, | |
| /\bubuntu\b/i, | |
| /\bdebian\b/i, | |
| /\blinuxgsm\b/i, | |
| /\bsteamcmd\s*\+app_update\b/i, | |
| ]; | |
| const windowsBinaryHint = /\b\.exe\b/i.test(supportEvidenceText); | |
| const deterministicWindowsOnly = windowsOnlyPatterns.some((re) => re.test(supportEvidenceText)); | |
| const deterministicWineRequired = wineRequiredPatterns.some((re) => re.test(supportEvidenceText)); | |
| const hasLinuxEvidence = linuxEvidencePatterns.some((re) => re.test(supportEvidenceText)); | |
| // Steam store API is client-app metadata only. It is kept for comment context, | |
| // but it is NOT used to determine dedicated server Linux support. | |
| let steamLinuxSupport = null; // true=yes, false=no, null=unknown/informational-only | |
| let steamAppIsServerTool = false; // success:false from store API = likely server-tool AppID | |
| let steamCmdAssessment = null; | |
| if (steamAppId) { | |
| try { | |
| const steamRes = await fetch( | |
| `https://store.steampowered.com/api/appdetails?appids=${steamAppId}&filters=platforms`, | |
| { signal: AbortSignal.timeout(8000) } | |
| ); | |
| if (steamRes.ok) { | |
| const steamData = await steamRes.json(); | |
| const appData = steamData[steamAppId]; | |
| if (appData?.success && appData?.data?.platforms) { | |
| steamLinuxSupport = appData.data.platforms.linux === true; | |
| console.log(`Steam AppID ${steamAppId} linux=${steamLinuxSupport}`); | |
| } else if (appData?.success === false) { | |
| // Dedicated server tool AppIDs have no store page — inconclusive, not negative. | |
| steamAppIsServerTool = true; | |
| console.log(`Steam AppID ${steamAppId} has no store page (likely a server-tool AppID)`); | |
| } | |
| } | |
| } catch (err) { | |
| console.log(`Steam API check failed: ${err.message}`); | |
| } | |
| steamCmdAssessment = runSteamCmdLinuxCheck(steamAppId); | |
| console.log(`SteamCMD assessment: ${JSON.stringify(steamCmdAssessment)}`); | |
| } | |
| // AI analysis of official docs/guides for Linux evidence. | |
| let aiLinuxAssessment = null; | |
| if (supportEvidenceText.length > 10) { | |
| try { | |
| const linuxAiRes = 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: 200, | |
| messages: [ | |
| { | |
| role: 'system', | |
| content: | |
| 'You analyze game server documentation to determine Linux support. ' + | |
| 'Return only JSON. Be conservative: only say "no" if evidence clearly shows Windows-only.', | |
| }, | |
| { | |
| role: 'user', | |
| content: | |
| `Analyze for native Linux dedicated server support:\n\nOfficial docs: ${officialDocsSection.slice(0, 400)}\nLinux binary proof: ${linuxBinaryProofSection.slice(0, 500)}\nGuides: ${guidesSection.slice(0, 800)}\n\n` + | |
| 'Return JSON: {"linux_support": "yes"|"no"|"unknown", "confidence": "high"|"medium"|"low", "reason": "one sentence"}', | |
| }, | |
| ], | |
| }), | |
| } | |
| ); | |
| if (linuxAiRes.ok) { | |
| const linuxAiData = await linuxAiRes.json(); | |
| const raw = linuxAiData.choices?.[0]?.message?.content || '{}'; | |
| aiLinuxAssessment = parseTriageResponse(raw); | |
| console.log(`AI linux assessment: ${JSON.stringify(aiLinuxAssessment)}`); | |
| } | |
| } catch (err) { | |
| console.log(`Linux AI check failed: ${err.message}`); | |
| } | |
| } | |
| // Linux checkbox — used as soft positive evidence only when no negative signals exist. | |
| // We don't fully trust it (users tick it without checking) but it matters when | |
| // server-specific evidence is still inconclusive and no negative patterns were found. | |
| const linuxCheckboxChecked = /\[x\]/i.test(extractSection('Linux support')); | |
| // Determine verdict: confirmed = deterministic evidence; suggested = AI advisory. | |
| const noLinuxFromDeterministicText = | |
| deterministicWindowsOnly || | |
| (deterministicWineRequired && !hasLinuxEvidence) || | |
| (windowsBinaryHint && !hasLinuxEvidence); | |
| const noLinuxFromSteamCmd = steamCmdAssessment?.status === 'windows-only'; | |
| const noLinuxFromAi = | |
| aiLinuxAssessment?.linux_support === 'no' && | |
| (aiLinuxAssessment?.confidence === 'high' || aiLinuxAssessment?.confidence === 'medium'); | |
| const confirmedNoLinux = noLinuxFromDeterministicText || noLinuxFromSteamCmd; | |
| const suggestsNoLinux = noLinuxFromAi && !confirmedNoLinux; | |
| const confirmedLinuxFromSteamCmd = steamCmdAssessment?.status === 'linux'; | |
| const linuxYesFromAi = | |
| aiLinuxAssessment?.linux_support === 'yes' && | |
| (aiLinuxAssessment?.confidence === 'high' || aiLinuxAssessment?.confidence === 'medium'); | |
| const confirmedLinuxSupport = | |
| confirmedLinuxFromSteamCmd || linuxYesFromAi; | |
| // Soft positive: checkbox checked with no negative signals and no definitive server evidence. | |
| const likelySupportedByCheckbox = | |
| linuxCheckboxChecked && | |
| !confirmedNoLinux && | |
| !suggestsNoLinux && | |
| !confirmedLinuxFromSteamCmd && | |
| !linuxYesFromAi; | |
| const NO_LINUX_LABEL = 'status: no linux support'; | |
| const CONFIRMED_LINUX_LABEL = 'status: linux support confirmed'; | |
| const LINUX_MARKER = '<!-- linux-support-check -->'; | |
| const steamDbLink = steamAppId ? `https://steamdb.info/app/${steamAppId}/` : null; | |
| const shouldApplyNoLinuxLabel = confirmedNoLinux || suggestsNoLinux; | |
| const shouldApplyConfirmedLinuxLabel = confirmedLinuxSupport && !confirmedNoLinux && !suggestsNoLinux; | |
| if (shouldApplyNoLinuxLabel) { | |
| // Auto-create the label if it does not exist yet. | |
| try { | |
| await github.rest.issues.getLabel({ owner, repo, name: NO_LINUX_LABEL }); | |
| } catch (err) { | |
| if (err.status === 404) { | |
| try { | |
| await github.rest.issues.createLabel({ | |
| owner, | |
| repo, | |
| name: NO_LINUX_LABEL, | |
| color: 'd73a4a', | |
| description: 'Game server does not have confirmed native Linux support', | |
| }); | |
| } catch (createErr) { | |
| console.log(`Could not create label "${NO_LINUX_LABEL}": ${createErr.message}`); | |
| } | |
| } | |
| } | |
| if (!existingLabels.has(NO_LINUX_LABEL)) { | |
| try { | |
| await github.rest.issues.addLabels({ owner, repo, issue_number: issueNumber, labels: [NO_LINUX_LABEL] }); | |
| console.log(`Added label: ${NO_LINUX_LABEL}`); | |
| } catch (err) { | |
| console.log(`Could not add label "${NO_LINUX_LABEL}": ${err.message}`); | |
| } | |
| } | |
| if (existingLabels.has(CONFIRMED_LINUX_LABEL)) { | |
| try { | |
| await github.rest.issues.removeLabel({ owner, repo, issue_number: issueNumber, name: CONFIRMED_LINUX_LABEL }); | |
| console.log(`Removed label: ${CONFIRMED_LINUX_LABEL}`); | |
| } catch (err) { | |
| console.log(`Could not remove label "${CONFIRMED_LINUX_LABEL}": ${err.message}`); | |
| } | |
| } | |
| } else if (existingLabels.has(NO_LINUX_LABEL)) { | |
| try { | |
| await github.rest.issues.removeLabel({ owner, repo, issue_number: issueNumber, name: NO_LINUX_LABEL }); | |
| console.log(`Removed label: ${NO_LINUX_LABEL}`); | |
| } catch (err) { | |
| console.log(`Could not remove label "${NO_LINUX_LABEL}": ${err.message}`); | |
| } | |
| } | |
| if (shouldApplyConfirmedLinuxLabel) { | |
| try { | |
| await github.rest.issues.getLabel({ owner, repo, name: CONFIRMED_LINUX_LABEL }); | |
| } catch (err) { | |
| if (err.status === 404) { | |
| try { | |
| await github.rest.issues.createLabel({ | |
| owner, | |
| repo, | |
| name: CONFIRMED_LINUX_LABEL, | |
| color: '0e8a16', | |
| description: 'Game server has confirmed native Linux support', | |
| }); | |
| } catch (createErr) { | |
| console.log(`Could not create label "${CONFIRMED_LINUX_LABEL}": ${createErr.message}`); | |
| } | |
| } | |
| } | |
| if (!existingLabels.has(CONFIRMED_LINUX_LABEL)) { | |
| try { | |
| await github.rest.issues.addLabels({ owner, repo, issue_number: issueNumber, labels: [CONFIRMED_LINUX_LABEL] }); | |
| console.log(`Added label: ${CONFIRMED_LINUX_LABEL}`); | |
| } catch (err) { | |
| console.log(`Could not add label "${CONFIRMED_LINUX_LABEL}": ${err.message}`); | |
| } | |
| } | |
| } else if (existingLabels.has(CONFIRMED_LINUX_LABEL)) { | |
| try { | |
| await github.rest.issues.removeLabel({ owner, repo, issue_number: issueNumber, name: CONFIRMED_LINUX_LABEL }); | |
| console.log(`Removed label: ${CONFIRMED_LINUX_LABEL}`); | |
| } catch (err) { | |
| console.log(`Could not remove label "${CONFIRMED_LINUX_LABEL}": ${err.message}`); | |
| } | |
| } | |
| const reasons = []; | |
| if (deterministicWindowsOnly) reasons.push('the provided docs/guides explicitly indicate Windows-only or no Linux support'); | |
| if (deterministicWineRequired && !hasLinuxEvidence) reasons.push('the provided docs/guides indicate a Wine/Proton requirement rather than native Linux binaries'); | |
| if (windowsBinaryHint && !hasLinuxEvidence) reasons.push('the provided evidence appears to reference Windows binaries (.exe) without clear Linux server evidence'); | |
| if (isSteamNo) reasons.push('request is marked as non-Steam, so Steam platform checks were intentionally skipped'); | |
| if (steamAppIsServerTool) reasons.push(`AppID ${steamAppId} has no Steam store page (typical for dedicated server tool AppIDs)`); | |
| if (noLinuxFromSteamCmd && steamCmdAssessment?.reason) reasons.push(steamCmdAssessment.reason); | |
| if (confirmedLinuxFromSteamCmd && steamCmdAssessment?.reason) reasons.push(steamCmdAssessment.reason); | |
| if (steamCmdAssessment?.status === 'unknown' && steamCmdAssessment?.reason) reasons.push(steamCmdAssessment.reason); | |
| if (steamCmdAssessment?.status === 'error' && steamCmdAssessment?.reason) reasons.push(steamCmdAssessment.reason); | |
| if (noLinuxFromAi && aiLinuxAssessment?.reason) reasons.push(`AI analysis of provided documentation: ${aiLinuxAssessment.reason}`); | |
| if (linuxYesFromAi && aiLinuxAssessment?.reason) reasons.push(`AI analysis indicates Linux support: ${aiLinuxAssessment.reason}`); | |
| if (likelySupportedByCheckbox) reasons.push('requester confirmed Linux support via the form checkbox; no contradicting evidence found'); | |
| let verdictLine = 'Linux support could not be confirmed automatically from the submitted details.'; | |
| if (confirmedNoLinux) { | |
| verdictLine = 'This server request does **not** appear to have native Linux support, which is required for LinuxGSM.'; | |
| } else if (suggestsNoLinux) { | |
| verdictLine = 'This server request **may not** have native Linux support based on submitted evidence.'; | |
| } else if (confirmedLinuxFromSteamCmd) { | |
| verdictLine = 'SteamCMD metadata indicates this server has Linux platform/depot support.'; | |
| } else if (linuxYesFromAi) { | |
| verdictLine = 'Submitted documentation appears to indicate Linux server support.'; | |
| } else if (likelySupportedByCheckbox) { | |
| verdictLine = 'Linux support is **likely** — the requester confirmed it and no contradicting evidence was found. A maintainer should verify before accepting.'; | |
| } | |
| const steamApiStatus = isSteamNo | |
| ? 'Not applicable' | |
| : steamLinuxSupport === true | |
| ? 'Client app marked Linux-supported (informational only)' | |
| : steamLinuxSupport === false | |
| ? 'Client app marked Linux-unsupported (informational only)' | |
| : steamAppIsServerTool | |
| ? 'No store page for this AppID (informational only)' | |
| : 'No definitive platform response'; | |
| const steamCmdStatus = isSteamNo | |
| ? 'Not applicable' | |
| : !steamAppId | |
| ? 'Skipped until valid AppID is provided' | |
| : steamCmdAssessment?.status === 'linux' | |
| ? 'Linux platform/depot metadata found' | |
| : steamCmdAssessment?.status === 'windows-only' | |
| ? 'Windows-only platform metadata found' | |
| : steamCmdAssessment?.status === 'unknown' | |
| ? 'No clear Linux server metadata found' | |
| : steamCmdAssessment?.status === 'error' | |
| ? 'Lookup failed' | |
| : 'Not run'; | |
| const steamBlock = isSteamNo | |
| ? '**Steam:** No (non-Steam request)\n**Steam Store API:** Not applicable\n\n' | |
| : steamAppId | |
| ? `**Steam:** ${isSteamYes ? 'Yes' : 'Unspecified'}\n` + | |
| `**Steam AppID:** ${steamAppId}\n` + | |
| `**Steam Store API:** ${steamApiStatus}\n` + | |
| `**SteamCMD:** ${steamCmdStatus}\n` + | |
| `**SteamDB:** ${steamDbLink}\n\n` | |
| : `**Steam:** ${isSteamYes ? 'Yes' : 'Unspecified'}\n` + | |
| '**Steam AppID:** Not provided in the issue form.\n' + | |
| (isSteamYes ? '**Steam Store API:** Skipped until valid AppID is provided.\n**SteamCMD:** Skipped until valid AppID is provided.\n\n' : '\n'); | |
| const linuxCommentHeader = shouldApplyConfirmedLinuxLabel | |
| ? '**Linux Support Check** :rocket:' | |
| : '**Linux Support Check**'; | |
| const confirmedLinuxLabelBlock = shouldApplyConfirmedLinuxLabel | |
| ? `**Label applied:** ${CONFIRMED_LINUX_LABEL}\n\n` | |
| : ''; | |
| const linuxCommentBody = | |
| `${LINUX_MARKER}\n` + | |
| `${linuxCommentHeader}\n\n` + | |
| `${verdictLine}\n\n` + | |
| `${confirmedLinuxLabelBlock}` + | |
| `${steamBlock}` + | |
| (reasons.length > 0 | |
| ? `**Evidence:**\n${reasons.map((r) => `- ${r}`).join('\n')}\n\n` | |
| : '') + | |
| `LinuxGSM only supports **native Linux dedicated servers**. Wine and Windows-only servers are not supported.\n\n` + | |
| `If support is unclear, please provide:\n` + | |
| `- Official Linux dedicated server documentation\n` + | |
| `- Linux server binaries or release notes\n` + | |
| `- Linux startup instructions or commands\n\n` + | |
| `_This check was performed automatically using SteamCMD, submitted issue details, and AI assistance for document interpretation. Steam store data is shown only as client-app context and is not used to determine server support._`; | |
| try { | |
| const allComments = await github.paginate(github.rest.issues.listComments, { | |
| owner, | |
| repo, | |
| issue_number: issueNumber, | |
| per_page: 100, | |
| }); | |
| const existingLinuxComment = [...allComments].reverse().find( | |
| (c) => c.user?.type === 'Bot' && c.body?.includes(LINUX_MARKER) | |
| ); | |
| if (existingLinuxComment) { | |
| await github.rest.issues.updateComment({ | |
| owner, | |
| repo, | |
| comment_id: existingLinuxComment.id, | |
| body: linuxCommentBody, | |
| }); | |
| } else { | |
| await github.rest.issues.createComment({ | |
| owner, | |
| repo, | |
| issue_number: issueNumber, | |
| body: linuxCommentBody, | |
| }); | |
| } | |
| } catch (err) { | |
| console.log(`Could not post Linux support comment: ${err.message}`); | |
| } | |
| } | |
| issue-potential-duplicates: | |
| if: github.repository_owner == 'GameServerManagers' && github.event_name == 'issues' && (github.event.action == 'opened' || github.event.action == 'edited' || github.event.action == 'reopened') | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Detect potential duplicates | |
| uses: actions/github-script@v9 | |
| with: | |
| script: | | |
| const owner = context.repo.owner; | |
| const repo = context.repo.repo; | |
| const issueNumber = context.payload.issue?.number; | |
| const DUPLICATE_LABEL = 'potential-duplicate'; | |
| const DUPLICATE_MARKER = '<!-- potential-duplicate-check -->'; | |
| const MAX_CANDIDATES = 5; | |
| const THRESHOLD = 0.45; | |
| if (!issueNumber) { | |
| console.log('No issue number found in payload.'); | |
| return; | |
| } | |
| const issueResp = await github.rest.issues.get({ | |
| owner, | |
| repo, | |
| issue_number: issueNumber, | |
| }); | |
| const issue = issueResp.data; | |
| if (issue.pull_request) { | |
| console.log('Skipping pull request payload.'); | |
| return; | |
| } | |
| function normalizeText(value) { | |
| return (value || '') | |
| .toLowerCase() | |
| .replace(/[`'"’]/g, '') | |
| .replace(/[^a-z0-9\s]/g, ' ') | |
| .replace(/\s+/g, ' ') | |
| .trim(); | |
| } | |
| function tokenize(value) { | |
| const stopwords = new Set([ | |
| 'a', 'an', 'and', 'are', 'as', 'at', 'be', 'by', 'for', 'from', 'how', 'i', 'in', 'is', 'it', 'its', | |
| 'of', 'on', 'or', 'that', 'the', 'this', 'to', 'when', 'with', 'wont', 'cannot', 'cant', 'fails', 'fail', | |
| 'issue', 'bug', 'request', 'server', 'command', 'linuxgsm' | |
| ]); | |
| return new Set( | |
| normalizeText(value) | |
| .split(' ') | |
| .filter((token) => token.length > 2 && !stopwords.has(token)) | |
| ); | |
| } | |
| function jaccard(aSet, bSet) { | |
| if (aSet.size === 0 || bSet.size === 0) return 0; | |
| let intersection = 0; | |
| for (const v of aSet) { | |
| if (bSet.has(v)) intersection += 1; | |
| } | |
| const union = new Set([...aSet, ...bSet]).size; | |
| return union === 0 ? 0 : intersection / union; | |
| } | |
| function bodySignature(text) { | |
| return normalizeText(text).split(' ').slice(0, 200).join(' '); | |
| } | |
| const currentTitleTokens = tokenize(issue.title || ''); | |
| const currentBodyTokens = tokenize(bodySignature(issue.body || '')); | |
| const recentIssues = await github.paginate(github.rest.issues.listForRepo, { | |
| owner, | |
| repo, | |
| state: 'all', | |
| sort: 'updated', | |
| direction: 'desc', | |
| per_page: 100, | |
| }); | |
| const ranked = []; | |
| for (const candidate of recentIssues) { | |
| if (!candidate || candidate.number === issueNumber || candidate.pull_request) continue; | |
| const candidateTitleTokens = tokenize(candidate.title || ''); | |
| const candidateBodyTokens = tokenize(bodySignature(candidate.body || '')); | |
| const titleScore = jaccard(currentTitleTokens, candidateTitleTokens); | |
| const bodyScore = jaccard(currentBodyTokens, candidateBodyTokens); | |
| const score = titleScore * 0.8 + bodyScore * 0.2; | |
| if (score < THRESHOLD) continue; | |
| ranked.push({ | |
| number: candidate.number, | |
| title: candidate.title, | |
| state: candidate.state, | |
| html_url: candidate.html_url, | |
| score, | |
| }); | |
| } | |
| ranked.sort((a, b) => b.score - a.score); | |
| const topMatches = ranked.slice(0, MAX_CANDIDATES); | |
| async function ensurePotentialDuplicateLabel() { | |
| try { | |
| await github.rest.issues.getLabel({ owner, repo, name: DUPLICATE_LABEL }); | |
| } catch (err) { | |
| if (err.status !== 404) throw err; | |
| await github.rest.issues.createLabel({ | |
| owner, | |
| repo, | |
| name: DUPLICATE_LABEL, | |
| color: 'd4c5f9', | |
| description: 'Potentially duplicates another existing issue', | |
| }); | |
| } | |
| } | |
| const existingLabelNames = new Set((issue.labels || []).map((l) => l.name)); | |
| const comments = await github.paginate(github.rest.issues.listComments, { | |
| owner, | |
| repo, | |
| issue_number: issueNumber, | |
| per_page: 100, | |
| }); | |
| const existingComment = [...comments] | |
| .reverse() | |
| .find((comment) => comment.user?.type === 'Bot' && comment.body?.includes(DUPLICATE_MARKER)); | |
| if (topMatches.length === 0) { | |
| if (existingLabelNames.has(DUPLICATE_LABEL)) { | |
| try { | |
| await github.rest.issues.removeLabel({ | |
| owner, | |
| repo, | |
| issue_number: issueNumber, | |
| name: DUPLICATE_LABEL, | |
| }); | |
| } catch (err) { | |
| console.log(`Could not remove ${DUPLICATE_LABEL}: ${err.message}`); | |
| } | |
| } | |
| if (existingComment) { | |
| try { | |
| await github.rest.issues.updateComment({ | |
| owner, | |
| repo, | |
| comment_id: existingComment.id, | |
| body: | |
| `${DUPLICATE_MARKER}\n` + | |
| `Potential duplicate scan did not find strong matches at this time.\n\n` + | |
| `_This note is maintained automatically._`, | |
| }); | |
| } catch (err) { | |
| console.log(`Could not update duplicate comment: ${err.message}`); | |
| } | |
| } | |
| return; | |
| } | |
| await ensurePotentialDuplicateLabel(); | |
| if (!existingLabelNames.has(DUPLICATE_LABEL)) { | |
| try { | |
| await github.rest.issues.addLabels({ | |
| owner, | |
| repo, | |
| issue_number: issueNumber, | |
| labels: [DUPLICATE_LABEL], | |
| }); | |
| } catch (err) { | |
| console.log(`Could not add ${DUPLICATE_LABEL}: ${err.message}`); | |
| } | |
| } | |
| const lines = topMatches | |
| .map((m) => `- #${m.number} (${Math.round(m.score * 100)}%) ${m.title}`) | |
| .join('\n'); | |
| const commentBody = | |
| `${DUPLICATE_MARKER}\n` + | |
| `Potential duplicates:\n${lines}\n\n` + | |
| `_This note is generated automatically using repository issue similarity and may include false positives._`; | |
| if (existingComment) { | |
| await github.rest.issues.updateComment({ | |
| owner, | |
| repo, | |
| comment_id: existingComment.id, | |
| body: commentBody, | |
| }); | |
| } else { | |
| await github.rest.issues.createComment({ | |
| owner, | |
| repo, | |
| issue_number: issueNumber, | |
| body: commentBody, | |
| }); | |
| } | |
| backfill-relabel: | |
| if: github.repository_owner == 'GameServerManagers' && github.event_name == 'workflow_dispatch' | |
| runs-on: ubuntu-latest | |
| env: | |
| ISSUE_STATE: ${{ inputs.issue_state }} | |
| ISSUE_LIMIT: ${{ inputs.limit }} | |
| AI_GAME_FALLBACK: ${{ inputs.ai_game_fallback }} | |
| steps: | |
| - name: Trigger relabel backfill | |
| uses: actions/github-script@v9 | |
| env: | |
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| with: | |
| script: | | |
| const owner = context.repo.owner; | |
| const repo = context.repo.repo; | |
| const state = process.env.ISSUE_STATE || 'all'; | |
| const rawLimit = Number.parseInt(process.env.ISSUE_LIMIT || '0', 10); | |
| const limit = Number.isFinite(rawLimit) && rawLimit > 0 ? rawLimit : 0; | |
| const useAiGameFallback = String(process.env.AI_GAME_FALLBACK || 'false').toLowerCase() === 'true'; | |
| const processedIssues = []; | |
| const failedIssues = []; | |
| let aiGameAttempts = 0; | |
| let aiGameMatches = 0; | |
| let aiGameRateLimited = 0; | |
| let aiFallbackDisabledReason = ''; | |
| let stoppedForApiRateLimit = false; | |
| let apiRateLimitStopReason = ''; | |
| // === Helpers (mirrored from issue-ai-maintenance) === | |
| function normalizeName(value) { | |
| return (value || '') | |
| .toLowerCase() | |
| .replace(/[''`]/g, '') | |
| .replace(/[^a-z0-9]+/g, ' ') | |
| .trim(); | |
| } | |
| function parseGameCandidates(gameField) { | |
| if (!gameField || /^_?no response_?$/i.test(gameField)) return []; | |
| return gameField | |
| .replace(/\(.*?\)/g, ' ') | |
| .split(/\n|,|\s+&\s+|\s+and\s+|\//i) | |
| .map((v) => v.trim()) | |
| .filter(Boolean); | |
| } | |
| function findGamesFromText(text, gameAliasToLabel, gameAliasToScript) { | |
| const labels = new Set(); | |
| const scripts = new Set(); | |
| const normalizedText = normalizeName(text); | |
| if (!normalizedText) return { labels, scripts }; | |
| const escapeRegex = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); | |
| const aliases = []; | |
| for (const [alias, label] of gameAliasToLabel.entries()) { | |
| if (alias.length < 3) continue; | |
| aliases.push({ alias, label, script: gameAliasToScript.get(alias) || null }); | |
| } | |
| // Prefer longer aliases first so "killing floor 2" does not also match "killing floor". | |
| aliases.sort((a, b) => b.alias.length - a.alias.length); | |
| const usedRanges = []; | |
| const isOverlapping = (start, end) => | |
| usedRanges.some((range) => start < range.end && end > range.start); | |
| for (const entry of aliases) { | |
| const pattern = new RegExp(`\\b${escapeRegex(entry.alias).replace(/\\ /g, '\\s+')}\\b`, 'g'); | |
| let match; | |
| while ((match = pattern.exec(normalizedText)) !== null) { | |
| const start = match.index; | |
| const end = start + match[0].length; | |
| if (isOverlapping(start, end)) continue; | |
| labels.add(entry.label); | |
| if (entry.script) scripts.add(entry.script); | |
| usedRanges.push({ start, end }); | |
| } | |
| } | |
| return { labels, scripts }; | |
| } | |
| function parseAiGameResponse(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 {}; | |
| } | |
| function isGenericNonGameDetection(value) { | |
| const normalized = normalizeName(value); | |
| if (!normalized) return false; | |
| return [ | |
| 'srcds', | |
| 'source dedicated server', | |
| 'dedicated server', | |
| 'source engine', | |
| 'goldsrc', | |
| 'steamcmd', | |
| 'linuxgsm', | |
| 'lgsm', | |
| ].some((term) => normalized.includes(term)); | |
| } | |
| function parseAiRateLimitInfo(response) { | |
| const retryAfter = response.headers.get('retry-after') || response.headers.get('Retry-After') || ''; | |
| const limit = response.headers.get('x-ratelimit-limit') || ''; | |
| const remaining = response.headers.get('x-ratelimit-remaining') || ''; | |
| const resetEpoch = response.headers.get('x-ratelimit-reset') || ''; | |
| const requestId = response.headers.get('x-github-request-id') || ''; | |
| let resetIso = ''; | |
| const parsedReset = Number.parseInt(resetEpoch, 10); | |
| if (Number.isFinite(parsedReset) && parsedReset > 0) { | |
| resetIso = new Date(parsedReset * 1000).toISOString(); | |
| } | |
| return { | |
| retryAfter, | |
| limit, | |
| remaining, | |
| resetEpoch, | |
| resetIso, | |
| requestId, | |
| }; | |
| } | |
| function formatAiRateLimitInfo(info) { | |
| const parts = []; | |
| if (info.retryAfter) parts.push(`retry-after=${info.retryAfter}s`); | |
| if (info.limit) parts.push(`limit=${info.limit}`); | |
| if (info.remaining) parts.push(`remaining=${info.remaining}`); | |
| if (info.resetEpoch) parts.push(`reset=${info.resetEpoch}${info.resetIso ? ` (${info.resetIso})` : ''}`); | |
| if (info.requestId) parts.push(`request-id=${info.requestId}`); | |
| return parts.length > 0 ? parts.join(', ') : 'no rate-limit headers returned'; | |
| } | |
| function isApiRateLimitError(err) { | |
| const message = String(err?.message || '').toLowerCase(); | |
| return ( | |
| err?.status === 429 || | |
| message.includes('api rate limit exceeded') || | |
| message.includes('secondary rate limit') || | |
| (message.includes('rate limit') && err?.status === 403) | |
| ); | |
| } | |
| function formatApiRateLimitError(err) { | |
| const headers = err?.response?.headers || err?.headers || {}; | |
| const limit = headers['x-ratelimit-limit'] || ''; | |
| const remaining = headers['x-ratelimit-remaining'] || ''; | |
| const resetEpoch = headers['x-ratelimit-reset'] || ''; | |
| const requestId = headers['x-github-request-id'] || ''; | |
| let resetIso = ''; | |
| const parsedReset = Number.parseInt(resetEpoch || '', 10); | |
| if (Number.isFinite(parsedReset) && parsedReset > 0) { | |
| resetIso = new Date(parsedReset * 1000).toISOString(); | |
| } | |
| const parts = []; | |
| if (limit) parts.push(`limit=${limit}`); | |
| if (remaining) parts.push(`remaining=${remaining}`); | |
| if (resetEpoch) parts.push(`reset=${resetEpoch}${resetIso ? ` (${resetIso})` : ''}`); | |
| if (requestId) parts.push(`request-id=${requestId}`); | |
| return parts.length > 0 ? parts.join(', ') : 'no rate-limit headers returned'; | |
| } | |
| function hasAliasHitForLabel(text, targetLabel, gameAliasToLabel) { | |
| const normalizedText = normalizeName(text); | |
| if (!normalizedText || !targetLabel) return false; | |
| const paddedText = ` ${normalizedText} `; | |
| for (const [alias, label] of gameAliasToLabel.entries()) { | |
| if (label !== targetLabel) continue; | |
| if (alias.length < 3) continue; | |
| if (paddedText.includes(` ${alias} `)) return true; | |
| // Allow obvious joined-word variants for multi-token aliases | |
| // (e.g., "counter strike 1 6" matching "counterstrike 1.6"). | |
| const aliasTokens = alias.split(/\s+/).filter(Boolean); | |
| if (aliasTokens.length > 1) { | |
| const escapedTokens = aliasTokens.map((token) => | |
| token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') | |
| ); | |
| const flexibleAliasPattern = new RegExp(`\\b${escapedTokens.join('\\s*')}\\b`); | |
| if (flexibleAliasPattern.test(normalizedText)) return true; | |
| } | |
| } | |
| return false; | |
| } | |
| function parseServerlistCsv(csvText) { | |
| const rows = []; | |
| const lines = (csvText || '').split(/\r?\n/); | |
| for (let i = 1; i < lines.length; i += 1) { | |
| const line = lines[i]?.trim(); | |
| if (!line) continue; | |
| const parts = line.split(','); | |
| if (parts.length < 3) continue; | |
| rows.push({ | |
| shortname: parts[0].trim(), | |
| gameservername: parts[1].trim(), | |
| gamename: parts[2].trim(), | |
| }); | |
| } | |
| return rows; | |
| } | |
| function inferTypeFromTitle(issueTitle) { | |
| if (/^\[bug\]/i.test(issueTitle)) return 'type: bug'; | |
| if (/\bserver\s+request\b/i.test(issueTitle)) return 'type: game server request'; | |
| const hasBracketPrefix = /^\[[^\]]+\]/.test(issueTitle || ''); | |
| const isServerCreation = | |
| /\bserver\s+creation\b/i.test(issueTitle) || | |
| (hasBracketPrefix && /\bcreation\b/i.test(issueTitle)); | |
| const isServerSupportRequest = | |
| /\bserver\s+support\b/i.test(issueTitle) || | |
| (/\bsupport\s+for\b/i.test(issueTitle) && /\bserver\b/i.test(issueTitle)); | |
| if (isServerCreation || isServerSupportRequest) return 'type: game server request'; | |
| if (/^\[feature\]/i.test(issueTitle)) return 'type: feature'; | |
| if (/^\[server request\]/i.test(issueTitle)) return 'type: game server request'; | |
| if (/^\[docs?\]/i.test(issueTitle)) return 'type: docs'; | |
| return null; | |
| } | |
| function inferDesiredType(issueTitle, labelNames) { | |
| const titleType = inferTypeFromTitle(issueTitle); | |
| if (titleType) return titleType; | |
| // Prefer server requests over generic feature when both labels exist. | |
| if (labelNames.has('type: game server request')) return 'type: game server request'; | |
| for (const label of [ | |
| 'type: bug', | |
| 'type: feature', | |
| 'type: game server request', | |
| 'type: docs', | |
| ]) { | |
| if (labelNames.has(label)) return label; | |
| } | |
| return null; | |
| } | |
| function inferIssueTypeNameFromDesiredType(typeLabel) { | |
| if (typeLabel === 'type: bug') return 'Bug'; | |
| if (typeLabel === 'type: feature') return 'Feature'; | |
| if (typeLabel === 'type: game server request') return 'Server Request'; | |
| if (typeLabel === 'type: docs') return 'Task'; | |
| return null; | |
| } | |
| function parseCommandSelections(sectionValue) { | |
| const selected = new Set(); | |
| const re = /command:\s*([a-z-]+)/gi; | |
| let m; | |
| while ((m = re.exec(sectionValue || '')) !== null) { | |
| let value = m[1].toLowerCase(); | |
| if (value.startsWith('mods-')) value = 'mods'; | |
| if (value === 'auto-update') value = 'update'; | |
| selected.add(`command: ${value}`); | |
| } | |
| return selected; | |
| } | |
| function parseDistroSelections(sectionValue) { | |
| const text = sectionValue || ''; | |
| const selected = new Set(); | |
| if (/\bUbuntu\b/i.test(text)) selected.add('distro: Ubuntu'); | |
| if (/\bDebian\b/i.test(text)) selected.add('distro: Debian'); | |
| if (/\bAlmaLinux\b/i.test(text)) selected.add('distro: AlmaLinux'); | |
| if (/\bRocky\b/i.test(text)) selected.add('distro: Rocky Linux'); | |
| if (/\bCentOS\b/i.test(text)) selected.add('distro: CentOS'); | |
| if (/\bFedora\b/i.test(text)) selected.add('distro: Fedora'); | |
| if (/\bopenSUSE\b/i.test(text)) selected.add('distro: openSUSE'); | |
| if (/\bArch Linux\b/i.test(text)) selected.add('distro: Arch Linux'); | |
| if (/\bSlackware\b/i.test(text)) selected.add('distro: Slackware'); | |
| return selected; | |
| } | |
| function extractSection(body, sectionName) { | |
| const escaped = sectionName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); | |
| const re = new RegExp(`### ${escaped}\\n\\n([\\s\\S]*?)(\\n### |$)`, 'i'); | |
| return (body.match(re)?.[1] || '').trim(); | |
| } | |
| // === Load shared data once === | |
| const repoLabels = await github.paginate(github.rest.issues.listLabelsForRepo, { | |
| owner, | |
| repo, | |
| per_page: 100, | |
| }); | |
| const gameLabelByNormalized = new Map(); | |
| for (const label of repoLabels) { | |
| if (!label.name.startsWith('game: ')) continue; | |
| gameLabelByNormalized.set(normalizeName(label.name.slice(6)), label.name); | |
| } | |
| const existingEngineLabels = new Set( | |
| repoLabels.map((l) => l.name).filter((name) => name.startsWith('engine: ')) | |
| ); | |
| const gameAliasToLabel = new Map(); | |
| const gameAliasToScript = new Map(); | |
| const engineByScript = new Map(); | |
| for (const [normalizedGameName, label] of gameLabelByNormalized.entries()) { | |
| gameAliasToLabel.set(normalizedGameName, label); | |
| } | |
| try { | |
| const serverlistContent = await github.rest.repos.getContent({ | |
| owner, | |
| repo, | |
| path: 'lgsm/data/serverlist.csv', | |
| }); | |
| const csvText = Buffer.from(serverlistContent.data?.content || '', 'base64').toString('utf8'); | |
| const serverRows = parseServerlistCsv(csvText); | |
| for (const row of serverRows) { | |
| const canonicalLabel = gameLabelByNormalized.get(normalizeName(row.gamename)); | |
| if (!canonicalLabel) continue; | |
| for (const alias of [row.shortname, row.gameservername, row.gamename]) { | |
| const key = normalizeName(alias); | |
| if (!key) continue; | |
| gameAliasToLabel.set(key, canonicalLabel); | |
| gameAliasToScript.set(key, row.gameservername); | |
| } | |
| } | |
| } catch (err) { | |
| console.log(`Could not load serverlist aliases: ${err.message}`); | |
| } | |
| async function ensureEngineLabel(engineLabel) { | |
| if (existingEngineLabels.has(engineLabel)) return; | |
| try { | |
| await github.rest.issues.createLabel({ | |
| owner, | |
| repo, | |
| name: engineLabel, | |
| color: '000000', | |
| description: `Issues related to ${engineLabel.slice(8)} engine`, | |
| }); | |
| existingEngineLabels.add(engineLabel); | |
| } catch (err) { | |
| if (err.status === 422) { | |
| existingEngineLabels.add(engineLabel); | |
| return; | |
| } | |
| console.log(`Could not create engine label "${engineLabel}": ${err.message}`); | |
| } | |
| } | |
| async function getEngineForScript(scriptName) { | |
| if (!scriptName) return null; | |
| if (engineByScript.has(scriptName)) return engineByScript.get(scriptName); | |
| try { | |
| const cfgContent = await github.rest.repos.getContent({ | |
| owner, | |
| repo, | |
| path: `lgsm/config-default/config-lgsm/${scriptName}/_default.cfg`, | |
| }); | |
| const cfgText = Buffer.from(cfgContent.data?.content || '', 'base64').toString('utf8'); | |
| const engine = cfgText.match(/^engine="([^"]+)"/m)?.[1] || null; | |
| engineByScript.set(scriptName, engine); | |
| return engine; | |
| } catch (_err) { | |
| engineByScript.set(scriptName, null); | |
| return null; | |
| } | |
| } | |
| // === Process issues === | |
| const issues = await github.paginate(github.rest.issues.listForRepo, { | |
| owner, | |
| repo, | |
| state, | |
| sort: 'created', | |
| direction: 'asc', | |
| per_page: 100, | |
| }); | |
| const targets = issues.filter((issue) => !issue.pull_request); | |
| const selectedTargets = limit > 0 ? targets.slice(0, limit) : targets; | |
| console.log( | |
| `Starting relabel backfill for ${selectedTargets.length} issue(s) ` + | |
| `(state=${state}, limit=${limit === 0 ? 'all' : limit}).` | |
| ); | |
| let processed = 0; | |
| for (const rawIssue of selectedTargets) { | |
| if (stoppedForApiRateLimit) break; | |
| console.log(`Processing issue #${rawIssue.number}: ${rawIssue.title}`); | |
| try { | |
| const issueResp = await github.rest.issues.get({ | |
| owner, | |
| repo, | |
| issue_number: rawIssue.number, | |
| }); | |
| const issue = issueResp.data; | |
| const title = issue.title || ''; | |
| const body = issue.body || ''; | |
| const existingLabels = new Set((issue.labels || []).map((l) => l.name).filter(Boolean)); | |
| const labelsToAdd = new Set(); | |
| const labelsToRemove = new Set(); | |
| const isLocked = issue.locked === true; | |
| let issueTypeSet = null; | |
| // Type reconciliation | |
| const desiredType = inferDesiredType(title, existingLabels); | |
| if (desiredType) { | |
| labelsToAdd.add(desiredType); | |
| for (const label of existingLabels) { | |
| if (label.startsWith('type: ') && label !== desiredType) labelsToRemove.add(label); | |
| } | |
| const desiredIssueTypeName = inferIssueTypeNameFromDesiredType(desiredType); | |
| if (desiredIssueTypeName) { | |
| try { | |
| const issueTypeData = await github.graphql( | |
| `query($owner:String!,$repo:String!,$number:Int!){ | |
| repository(owner:$owner,name:$repo){ | |
| issueTypes(first:20){ nodes { id name } } | |
| issue(number:$number){ id issueType { id name } } | |
| } | |
| }`, | |
| { owner, repo, number: rawIssue.number } | |
| ); | |
| const issueNode = issueTypeData.repository?.issue; | |
| const issueTypes = issueTypeData.repository?.issueTypes?.nodes || []; | |
| const desiredIssueType = issueTypes.find((t) => t.name === desiredIssueTypeName); | |
| if ( | |
| issueNode?.id && | |
| desiredIssueType?.id && | |
| issueNode.issueType?.id !== desiredIssueType.id | |
| ) { | |
| await github.graphql( | |
| `mutation($id:ID!,$issueTypeId:ID!){ | |
| updateIssue(input:{id:$id,issueTypeId:$issueTypeId}){ | |
| issue { id number issueType { id name } } | |
| } | |
| }`, | |
| { id: issueNode.id, issueTypeId: desiredIssueType.id } | |
| ); | |
| issueTypeSet = desiredIssueTypeName; | |
| console.log(`#${rawIssue.number}: set Issue Type to ${desiredIssueTypeName}`); | |
| } | |
| } catch (err) { | |
| if (isApiRateLimitError(err)) throw err; | |
| console.log(`#${rawIssue.number}: could not sync Issue Type: ${err.message}`); | |
| } | |
| } | |
| } | |
| // Commands | |
| const commandSection = extractSection(body, 'Command'); | |
| const desiredCommands = parseCommandSelections(commandSection); | |
| if (desiredCommands.size > 0) { | |
| for (const label of desiredCommands) labelsToAdd.add(label); | |
| for (const label of existingLabels) { | |
| if (label.startsWith('command: ') && !desiredCommands.has(label)) labelsToRemove.add(label); | |
| } | |
| } | |
| // Distros | |
| const distroSection = extractSection(body, 'Linux distro'); | |
| const desiredDistros = parseDistroSelections(distroSection); | |
| if (desiredDistros.size > 0) { | |
| for (const label of desiredDistros) labelsToAdd.add(label); | |
| for (const label of existingLabels) { | |
| if (label.startsWith('distro: ') && !desiredDistros.has(label)) labelsToRemove.add(label); | |
| } | |
| } | |
| // Tmux false positive cleanup | |
| if ( | |
| existingLabels.has('info: tmux') && | |
| !/\b(tmuxception|check_tmuxception)\b/i.test(`${title}\n${body}`) | |
| ) { | |
| labelsToRemove.add('info: tmux'); | |
| } | |
| // Games and engines | |
| const desiredGames = new Set(); | |
| const gameLabelSource = new Map(); // label → 'form-field' | 'text-match' | 'ai-fallback' | |
| const desiredServerScripts = new Set(); | |
| // 'Game server' is the section name in server_request.yml; 'Game' is used in bug_report.yml. | |
| const gameField = extractSection(body, 'Game server') || extractSection(body, 'Game'); | |
| const gameCandidates = parseGameCandidates(gameField); | |
| const hasStructuredGameSelection = gameCandidates.length > 0; | |
| for (const candidate of gameCandidates) { | |
| const normalizedCandidate = normalizeName(candidate); | |
| const mapped = | |
| gameAliasToLabel.get(normalizedCandidate) || gameLabelByNormalized.get(normalizedCandidate); | |
| if (mapped) { | |
| desiredGames.add(mapped); | |
| gameLabelSource.set(mapped, 'form-field'); | |
| } | |
| const mappedScript = gameAliasToScript.get(normalizedCandidate); | |
| if (mappedScript) desiredServerScripts.add(mappedScript); | |
| } | |
| // Legacy issues often have no form section; fall back to deterministic text matching. | |
| if (desiredGames.size === 0) { | |
| const fromText = findGamesFromText(`${title}\n${body}`, gameAliasToLabel, gameAliasToScript); | |
| for (const label of fromText.labels) { | |
| desiredGames.add(label); | |
| gameLabelSource.set(label, 'text-match'); | |
| } | |
| for (const scriptName of fromText.scripts) desiredServerScripts.add(scriptName); | |
| } | |
| // Optional AI fallback for legacy issues where deterministic matching finds nothing. | |
| if (useAiGameFallback && desiredGames.size === 0) { | |
| if (aiFallbackDisabledReason) { | |
| console.log(`#${rawIssue.number}: AI fallback skipped (${aiFallbackDisabledReason})`); | |
| } else { | |
| aiGameAttempts += 1; | |
| const aiPayload = { | |
| model: 'openai/gpt-4.1-mini', | |
| temperature: 0.1, | |
| max_tokens: 120, | |
| messages: [ | |
| { | |
| role: 'system', | |
| content: | |
| 'Return JSON only. Identify the specific game referenced in this LinuxGSM issue with high precision. ' + | |
| 'If only generic platform/engine terms are present (e.g. srcds, source dedicated server, steamcmd), return detected_game as null.', | |
| }, | |
| { | |
| role: 'user', | |
| content: | |
| `Title: ${title}\n\nBody:\n${body.slice(0, 2500)}\n\n` + | |
| 'Return JSON: {"detected_game":"string or null","game_confidence":"high|medium|low|null"}', | |
| }, | |
| ], | |
| }; | |
| const aiUrl = `https://models.github.ai/orgs/${owner}/inference/chat/completions`; | |
| const aiHeaders = { | |
| Accept: 'application/vnd.github+json', | |
| Authorization: `Bearer ${process.env.GITHUB_TOKEN}`, | |
| 'X-GitHub-Api-Version': '2026-03-10', | |
| 'Content-Type': 'application/json', | |
| }; | |
| try { | |
| let res = await fetch(aiUrl, { method: 'POST', headers: aiHeaders, body: JSON.stringify(aiPayload) }); | |
| // On 429 honour Retry-After (capped at 60 s) then retry once. | |
| if (res.status === 429) { | |
| aiGameRateLimited += 1; | |
| const rateInfo = parseAiRateLimitInfo(res); | |
| const rawRetryAfter = Number.parseInt(rateInfo.retryAfter || '10', 10); | |
| const retryAfter = Math.min(Number.isFinite(rawRetryAfter) ? rawRetryAfter : 10, 60); | |
| if (Number.isFinite(rawRetryAfter) && rawRetryAfter > 300) { | |
| aiFallbackDisabledReason = `global cooldown active (retry-after=${rawRetryAfter}s)`; | |
| console.log( | |
| `#${rawIssue.number}: AI fallback disabled for remaining run (${aiFallbackDisabledReason}; ${formatAiRateLimitInfo(rateInfo)})` | |
| ); | |
| } else { | |
| console.log( | |
| `#${rawIssue.number}: AI fallback rate-limited - waiting ${retryAfter}s then retrying (${formatAiRateLimitInfo(rateInfo)})` | |
| ); | |
| await new Promise((r) => setTimeout(r, retryAfter * 1000)); | |
| res = await fetch(aiUrl, { method: 'POST', headers: aiHeaders, body: JSON.stringify(aiPayload) }); | |
| } | |
| } | |
| if (res.ok) { | |
| const data = await res.json(); | |
| const raw = data.choices?.[0]?.message?.content || '{}'; | |
| const parsed = parseAiGameResponse(raw); | |
| const detectedGame = normalizeName(parsed?.detected_game || ''); | |
| const confidence = (parsed?.game_confidence || '').toLowerCase(); | |
| if (detectedGame && confidence === 'high') { | |
| const mappedLabel = | |
| gameAliasToLabel.get(detectedGame) || gameLabelByNormalized.get(detectedGame); | |
| if (mappedLabel) { | |
| const hasAliasEvidence = hasAliasHitForLabel( | |
| `${title}\n${body}`, | |
| mappedLabel, | |
| gameAliasToLabel | |
| ); | |
| if (hasAliasEvidence) { | |
| desiredGames.add(mappedLabel); | |
| gameLabelSource.set(mappedLabel, 'ai-fallback'); | |
| const mappedScript = gameAliasToScript.get(detectedGame); | |
| if (mappedScript) desiredServerScripts.add(mappedScript); | |
| aiGameMatches += 1; | |
| console.log( | |
| `#${rawIssue.number}: AI fallback accepted game "${mappedLabel}" from "${parsed?.detected_game}"` | |
| ); | |
| } else { | |
| console.log( | |
| `#${rawIssue.number}: AI fallback rejected game "${mappedLabel}" (no literal alias evidence in issue text)` | |
| ); | |
| } | |
| } else { | |
| if (isGenericNonGameDetection(parsed?.detected_game || '')) { | |
| console.log( | |
| `#${rawIssue.number}: AI fallback skipped generic non-game detection "${parsed?.detected_game}"` | |
| ); | |
| } else { | |
| console.log( | |
| `#${rawIssue.number}: AI fallback returned unmapped game "${parsed?.detected_game}"` | |
| ); | |
| } | |
| } | |
| } | |
| } else { | |
| if (res.status === 429) { | |
| const rateInfo = parseAiRateLimitInfo(res); | |
| console.log( | |
| `#${rawIssue.number}: AI fallback skipped (HTTP 429, ${formatAiRateLimitInfo(rateInfo)})` | |
| ); | |
| } else { | |
| console.log(`#${rawIssue.number}: AI fallback skipped (HTTP ${res.status})`); | |
| } | |
| } | |
| } catch (err) { | |
| console.log(`#${rawIssue.number}: AI fallback error: ${err.message}`); | |
| } | |
| } | |
| } | |
| for (const gameLabel of desiredGames) { | |
| const mappedScript = gameAliasToScript.get(normalizeName(gameLabel.slice(6))); | |
| if (mappedScript) desiredServerScripts.add(mappedScript); | |
| } | |
| const desiredEngineLabels = new Set(); | |
| for (const scriptName of desiredServerScripts) { | |
| const engine = await getEngineForScript(scriptName); | |
| if (!engine) continue; | |
| const engineLabel = `engine: ${engine}`; | |
| await ensureEngineLabel(engineLabel); | |
| desiredEngineLabels.add(engineLabel); | |
| } | |
| if (desiredEngineLabels.size > 0) { | |
| for (const label of desiredEngineLabels) labelsToAdd.add(label); | |
| for (const label of existingLabels) { | |
| if (label.startsWith('engine: ') && !desiredEngineLabels.has(label)) labelsToRemove.add(label); | |
| } | |
| } | |
| if (desiredGames.size > 0) { | |
| for (const label of desiredGames) labelsToAdd.add(label); | |
| if (hasStructuredGameSelection) { | |
| for (const label of existingLabels) { | |
| if (label.startsWith('game: ') && !desiredGames.has(label)) labelsToRemove.add(label); | |
| } | |
| } else { | |
| // For legacy issues without structured game selection, only prune stale | |
| // broader labels when a more specific inferred game label exists. | |
| const desiredGameNamesNormalized = new Set( | |
| [...desiredGames].map((label) => normalizeName(label.slice(6))) | |
| ); | |
| for (const label of existingLabels) { | |
| if (!label.startsWith('game: ') || desiredGames.has(label)) continue; | |
| const existingGameName = normalizeName(label.slice(6)); | |
| const isBroaderOverlap = [...desiredGameNamesNormalized].some( | |
| (desiredName) => desiredName !== existingGameName && desiredName.startsWith(`${existingGameName} `) | |
| ); | |
| if (isBroaderOverlap) labelsToRemove.add(label); | |
| } | |
| } | |
| } | |
| // Apply changes | |
| const finalAdds = [...labelsToAdd].filter((label) => !existingLabels.has(label)); | |
| const finalRemoves = [...labelsToRemove].filter((label) => existingLabels.has(label)); | |
| let labelAdded = 0; | |
| let labelRemoved = 0; | |
| for (const label of finalRemoves) { | |
| try { | |
| await github.rest.issues.removeLabel({ | |
| owner, | |
| repo, | |
| issue_number: rawIssue.number, | |
| name: label, | |
| }); | |
| labelRemoved += 1; | |
| console.log(`#${rawIssue.number}: removed "${label}"`); | |
| } catch (err) { | |
| if (isApiRateLimitError(err)) throw err; | |
| console.log(`#${rawIssue.number}: could not remove "${label}": ${err.message}`); | |
| } | |
| } | |
| for (const label of finalAdds) { | |
| try { | |
| await github.rest.issues.addLabels({ | |
| owner, | |
| repo, | |
| issue_number: rawIssue.number, | |
| labels: [label], | |
| }); | |
| labelAdded += 1; | |
| const gameSource = gameLabelSource.get(label); | |
| console.log(`#${rawIssue.number}: added "${label}"${gameSource ? ` (${gameSource})` : ''}`); | |
| } catch (err) { | |
| if (isApiRateLimitError(err)) throw err; | |
| console.log(`#${rawIssue.number}: could not add "${label}": ${err.message}`); | |
| } | |
| } | |
| processed += 1; | |
| processedIssues.push({ | |
| number: rawIssue.number, | |
| title: rawIssue.title, | |
| adds: labelAdded, | |
| removes: labelRemoved, | |
| issueTypeSet, | |
| locked: isLocked, | |
| }); | |
| console.log( | |
| `#${rawIssue.number}: done (+${labelAdded} added, -${labelRemoved} removed${ | |
| issueTypeSet ? `, type→${issueTypeSet}` : '' | |
| }${isLocked ? ', locked' : ''})` | |
| ); | |
| } catch (err) { | |
| if (isApiRateLimitError(err)) { | |
| stoppedForApiRateLimit = true; | |
| apiRateLimitStopReason = formatApiRateLimitError(err); | |
| console.log( | |
| `Stopping backfill due to API rate limit at #${rawIssue.number} (${apiRateLimitStopReason})` | |
| ); | |
| failedIssues.push({ | |
| number: rawIssue.number, | |
| title: rawIssue.title, | |
| stage: 'rate-limit', | |
| error: err.message, | |
| }); | |
| break; | |
| } else { | |
| console.log(`Error processing #${rawIssue.number}: ${err.message}`); | |
| failedIssues.push({ | |
| number: rawIssue.number, | |
| title: rawIssue.title, | |
| stage: 'process', | |
| error: err.message, | |
| }); | |
| } | |
| } | |
| } | |
| console.log( | |
| `Relabel backfill complete: ${processed} processed, ${failedIssues.length} failed${ | |
| stoppedForApiRateLimit ? `, stopped early (${apiRateLimitStopReason})` : '' | |
| }.` | |
| ); | |
| await core.summary | |
| .addHeading('Relabel Backfill Summary') | |
| .addTable([ | |
| [ | |
| { data: 'Requested state', header: true }, | |
| { data: 'Limit', header: true }, | |
| { data: 'AI fallback', header: true }, | |
| { data: 'AI attempts', header: true }, | |
| { data: 'AI matches', header: true }, | |
| { data: 'AI 429s', header: true }, | |
| { data: 'AI disabled reason', header: true }, | |
| { data: 'Stopped early', header: true }, | |
| { data: 'Target issues', header: true }, | |
| { data: 'Processed', header: true }, | |
| { data: 'Failures', header: true }, | |
| ], | |
| [ | |
| state, | |
| limit === 0 ? 'all' : String(limit), | |
| useAiGameFallback ? 'enabled' : 'disabled', | |
| String(aiGameAttempts), | |
| String(aiGameMatches), | |
| String(aiGameRateLimited), | |
| aiFallbackDisabledReason || '—', | |
| stoppedForApiRateLimit ? apiRateLimitStopReason : 'no', | |
| String(selectedTargets.length), | |
| String(processed), | |
| String(failedIssues.length), | |
| ], | |
| ]) | |
| .write(); | |
| if (processedIssues.length > 0) { | |
| const processedRows = processedIssues.slice(0, 50).map((issue) => [ | |
| `#${issue.number}${issue.locked ? ' 🔒' : ''}`, | |
| `[${issue.title}](https://github.com/${owner}/${repo}/issues/${issue.number})`, | |
| `+${issue.adds} / -${issue.removes}`, | |
| issue.issueTypeSet || '—', | |
| ]); | |
| await core.summary | |
| .addHeading('Processed Issues') | |
| .addTable([ | |
| [ | |
| { data: 'Issue', header: true }, | |
| { data: 'Title', header: true }, | |
| { data: 'Label changes', header: true }, | |
| { data: 'Issue Type set', header: true }, | |
| ], | |
| ...processedRows, | |
| ]) | |
| .write(); | |
| } | |
| if (failedIssues.length > 0) { | |
| const failureRows = failedIssues.slice(0, 50).map((issue) => [ | |
| `#${issue.number}`, | |
| issue.stage, | |
| issue.error, | |
| ]); | |
| await core.summary | |
| .addHeading('Failures') | |
| .addTable([ | |
| [ | |
| { data: 'Issue', header: true }, | |
| { data: 'Stage', header: true }, | |
| { data: 'Error', header: true }, | |
| ], | |
| ...failureRows, | |
| ]) | |
| .write(); | |
| } | |
| 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' && github.event_name == 'issues' && github.event.action == 'opened' | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Is Sponsor Label | |
| uses: JasonEtco/is-sponsor-label-action@v2 | |
| env: | |
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| sync-game-labels: | |
| if: github.repository_owner == 'GameServerManagers' && github.event_name == 'push' && contains(github.event.head_commit.modified, 'lgsm/data/serverlist.csv') | |
| 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 |