Skip to content

[Server Request] OpenTTD #17

[Server Request] OpenTTD

[Server Request] OpenTTD #17

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