| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327232823292330233123322333233423352336233723382339234023412342234323442345234623472348234923502351235223532354235523562357235823592360236123622363236423652366236723682369237023712372237323742375237623772378237923802381238223832384238523862387238823892390239123922393239423952396239723982399240024012402240324042405240624072408240924102411241224132414241524162417241824192420242124222423242424252426242724282429243024312432243324342435243624372438243924402441244224432444244524462447244824492450245124522453245424552456245724582459 |
- 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_target so pr-labeler can label PRs from forks (pull_request gets a read-only token for forks).
- # Safe here: no job checks out or runs PR code on this event.
- pull_request_target:
- 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.issue.state == 'open' && (github.event.action == 'opened' || github.event.action == 'edited')
- runs-on: ubuntu-latest
- steps:
- - name: Issue Labeler
- uses: github/issue-labeler@v3.5
- 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') && github.event.issue.state == 'open'
- 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 parseVdf(text) {
- const root = {};
- const stack = [root];
- let pendingKey = null;
- const tokenPattern = /"((?:[^"\\]|\\.)*)"|([{}])/g;
- let match;
- while ((match = tokenPattern.exec(text)) !== null) {
- const current = stack[stack.length - 1];
- if (match[2] === '{') {
- const child = {};
- if (pendingKey !== null) current[pendingKey] = child;
- stack.push(child);
- pendingKey = null;
- } else if (match[2] === '}') {
- if (stack.length > 1) stack.pop();
- pendingKey = null;
- } else if (pendingKey === null) {
- pendingKey = match[1];
- } else {
- current[pendingKey] = match[1];
- pendingKey = null;
- }
- }
- return root;
- }
- function parseSteamAppInfo(output, appId) {
- const start = output.search(new RegExp(`^"${appId}"\\s*$`, 'm'));
- if (start === -1) return null;
- return parseVdf(output.slice(start))[String(appId)] ?? null;
- }
- 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 appInfo = parseSteamAppInfo(output, appId);
- if (!appInfo) {
- return {
- status: 'unknown',
- reason: `SteamCMD app_info returned no data for AppID ${appId}.`,
- };
- }
- const osListOf = (node) => String(node?.config?.oslist ?? '').toLowerCase();
- const isObject = (value) => value && typeof value === 'object';
- const commonOsList = String(appInfo.common?.oslist ?? '').toLowerCase();
- const launches = Object.values(appInfo.config?.launch ?? {}).filter(isObject);
- const serverDepots = Object.entries(appInfo.depots ?? {}).filter(
- ([id, depot]) =>
- /^\d+$/.test(id) &&
- isObject(depot) &&
- !['1004', '1005', '1006'].includes(id) &&
- depot.depotfromapp === undefined &&
- depot.sharedinstall !== '1'
- );
- const hasLinuxSignal =
- commonOsList.includes('linux') ||
- launches.some(
- (launch) =>
- osListOf(launch).includes('linux') ||
- (!osListOf(launch) && /\.(sh|x86_64)$/i.test(launch.executable ?? ''))
- ) ||
- serverDepots.some(([, depot]) => osListOf(depot).includes('linux'));
- const hasWindowsOnlySignal =
- !hasLinuxSignal &&
- (commonOsList === 'windows' ||
- (launches.length > 0 &&
- launches.every(
- (launch) =>
- osListOf(launch) === 'windows' ||
- /\.(exe|bat|cmd)$/i.test(launch.executable ?? '')
- )));
- 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.
- let finalAdds = [...labelsToAdd].filter((label) => !existingLabels.has(label));
- let finalRemoves = [...labelsToRemove].filter((label) => existingLabels.has(label));
- const isGameOrEngineLabel = (label) => label.startsWith('game: ') || label.startsWith('engine: ');
- if ([...finalAdds, ...finalRemoves].some(isGameOrEngineLabel)) {
- try {
- const issueEvents = await github.paginate(github.rest.issues.listEvents, {
- owner,
- repo,
- issue_number: issueNumber,
- per_page: 100,
- });
- const lastManualLabelEvent = new Map();
- for (const e of issueEvents) {
- if (!['labeled', 'unlabeled'].includes(e.event) || !e.label?.name) continue;
- lastManualLabelEvent.set(e.label.name, e.actor?.type === 'Bot' ? null : e.event);
- }
- finalAdds = finalAdds.filter((label) => {
- if (!isGameOrEngineLabel(label) || lastManualLabelEvent.get(label) !== 'unlabeled') return true;
- console.log(`Label "${label}" was removed manually; not adding it back.`);
- return false;
- });
- finalRemoves = finalRemoves.filter((label) => {
- if (!isGameOrEngineLabel(label) || lastManualLabelEvent.get(label) !== 'labeled') return true;
- console.log(`Label "${label}" was added manually; keeping it.`);
- return false;
- });
- } catch (err) {
- console.log(`Could not read issue events: ${err.message}`);
- }
- }
- 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;
- let linuxLabelOverrideBy = null;
- try {
- const issueEvents = await github.paginate(github.rest.issues.listEvents, {
- owner,
- repo,
- issue_number: issueNumber,
- per_page: 100,
- });
- const lastLinuxLabelEvent = issueEvents
- .filter(
- (e) =>
- ['labeled', 'unlabeled'].includes(e.event) &&
- [NO_LINUX_LABEL, CONFIRMED_LINUX_LABEL].includes(e.label?.name)
- )
- .at(-1);
- if (lastLinuxLabelEvent && lastLinuxLabelEvent.actor?.type !== 'Bot') {
- linuxLabelOverrideBy = lastLinuxLabelEvent.actor?.login ?? 'a maintainer';
- console.log(`Linux support label last set by ${linuxLabelOverrideBy}; keeping it.`);
- }
- } catch (err) {
- console.log(`Could not read issue events: ${err.message}`);
- }
- const shouldApplyNoLinuxLabel = linuxLabelOverrideBy
- ? existingLabels.has(NO_LINUX_LABEL)
- : confirmedNoLinux || suggestsNoLinux;
- const shouldApplyConfirmedLinuxLabel = linuxLabelOverrideBy
- ? existingLabels.has(CONFIRMED_LINUX_LABEL) && !existingLabels.has(NO_LINUX_LABEL)
- : 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 (linuxLabelOverrideBy && shouldApplyNoLinuxLabel) {
- verdictLine = 'A maintainer has confirmed this server has no native Linux support.';
- } else if (linuxLabelOverrideBy && shouldApplyConfirmedLinuxLabel) {
- verdictLine = 'A maintainer has confirmed native Linux support.';
- } else 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 overrideBlock = linuxLabelOverrideBy
- ? `**Maintainer override:** the Linux support label was set manually by @${linuxLabelOverrideBy} and is no longer changed automatically.\n\n`
- : '';
- const linuxCommentBody =
- `${LINUX_MARKER}\n` +
- `${linuxCommentHeader}\n\n` +
- `${overrideBlock}` +
- `${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
- let finalAdds = [...labelsToAdd].filter((label) => !existingLabels.has(label));
- let finalRemoves = [...labelsToRemove].filter((label) => existingLabels.has(label));
- const isGameOrEngineLabel = (label) => label.startsWith('game: ') || label.startsWith('engine: ');
- if ([...finalAdds, ...finalRemoves].some(isGameOrEngineLabel)) {
- try {
- const issueEvents = await github.paginate(github.rest.issues.listEvents, {
- owner,
- repo,
- issue_number: rawIssue.number,
- per_page: 100,
- });
- const lastManualLabelEvent = new Map();
- for (const e of issueEvents) {
- if (!['labeled', 'unlabeled'].includes(e.event) || !e.label?.name) continue;
- lastManualLabelEvent.set(e.label.name, e.actor?.type === 'Bot' ? null : e.event);
- }
- finalAdds = finalAdds.filter((label) => {
- if (!isGameOrEngineLabel(label) || lastManualLabelEvent.get(label) !== 'unlabeled') return true;
- console.log(`#${rawIssue.number}: "${label}" was removed manually; not adding it back`);
- return false;
- });
- finalRemoves = finalRemoves.filter((label) => {
- if (!isGameOrEngineLabel(label) || lastManualLabelEvent.get(label) !== 'labeled') return true;
- console.log(`#${rawIssue.number}: "${label}" was added manually; keeping it`);
- return false;
- });
- } catch (err) {
- if (isApiRateLimitError(err)) throw err;
- console.log(`#${rawIssue.number}: could not read issue events: ${err.message}`);
- }
- }
- 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_target'
- runs-on: ubuntu-latest
- permissions:
- contents: read
- issues: write
- pull-requests: write
- steps:
- - name: PR Labeler
- uses: github/issue-labeler@v3.5
- 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@v7
- - 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
|