action-issue-triage-automation.yml 101 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304
  1. name: Issue Triage & Automation
  2. on:
  3. workflow_dispatch:
  4. inputs:
  5. issue_state:
  6. description: Issue state to backfill
  7. required: true
  8. default: all
  9. type: choice
  10. options:
  11. - all
  12. - open
  13. - closed
  14. limit:
  15. description: Max issues to process (0 = all)
  16. required: true
  17. default: "0"
  18. type: string
  19. ai_game_fallback:
  20. description: Use AI only when deterministic game mapping finds no game
  21. required: true
  22. default: "false"
  23. type: choice
  24. options:
  25. - "false"
  26. - "true"
  27. issues:
  28. types:
  29. - opened
  30. - edited
  31. - reopened
  32. - labeled
  33. - unlabeled
  34. - assigned
  35. - unassigned
  36. - milestoned
  37. - demilestoned
  38. - transferred
  39. - pinned
  40. - unpinned
  41. issue_comment:
  42. types:
  43. - created
  44. - edited
  45. - deleted
  46. pull_request:
  47. types:
  48. - opened
  49. - edited
  50. - synchronize
  51. - reopened
  52. push:
  53. branches:
  54. - master
  55. - develop
  56. paths:
  57. - "lgsm/data/serverlist.csv"
  58. permissions:
  59. issues: write
  60. pull-requests: write
  61. contents: read
  62. models: read
  63. env:
  64. FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
  65. jobs:
  66. issue-regex-labeler:
  67. if: github.repository_owner == 'GameServerManagers' && github.event_name == 'issues' && (github.event.action == 'opened' || github.event.action == 'edited')
  68. runs-on: ubuntu-latest
  69. steps:
  70. - name: Issue Labeler
  71. uses: github/issue-labeler@v3.4
  72. with:
  73. repo-token: "${{ secrets.GITHUB_TOKEN }}"
  74. configuration-path: .github/labeler.yml
  75. enable-versioned-regex: 0
  76. include-title: 1
  77. sync-labels: 0
  78. issue-ai-maintenance:
  79. if: github.repository_owner == 'GameServerManagers' && (github.event_name == 'issues' || github.event_name == 'issue_comment')
  80. runs-on: ubuntu-latest
  81. steps:
  82. - name: Reconcile issue labels and AI triage
  83. uses: actions/github-script@v9
  84. env:
  85. GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
  86. with:
  87. script: |
  88. const { execFileSync } = require('node:child_process');
  89. const owner = context.repo.owner;
  90. const repo = context.repo.repo;
  91. const eventName = context.eventName;
  92. const action = context.payload.action;
  93. const issueNumber = context.payload.issue?.number;
  94. const AI_MARKER = '<!-- ai-triage -->';
  95. if (!issueNumber) {
  96. console.log('No issue number found in payload.');
  97. return;
  98. }
  99. // Avoid bot-to-bot relabel loops on label events.
  100. if (
  101. eventName === 'issues' &&
  102. ['labeled', 'unlabeled'].includes(action) &&
  103. context.actor === 'github-actions[bot]'
  104. ) {
  105. console.log('Skipping self-triggered label event.');
  106. return;
  107. }
  108. const issueResp = await github.rest.issues.get({
  109. owner,
  110. repo,
  111. issue_number: issueNumber,
  112. });
  113. const issue = issueResp.data;
  114. const title = issue.title || '';
  115. const body = issue.body || '';
  116. const existingLabels = new Set((issue.labels || []).map((l) => l.name).filter(Boolean));
  117. function parseTriageResponse(raw) {
  118. const input = (raw || '').trim();
  119. if (!input) return {};
  120. const candidates = [input];
  121. const fenced = input.match(/```(?:json)?\s*([\s\S]*?)```/i);
  122. if (fenced?.[1]) candidates.push(fenced[1].trim());
  123. const firstBrace = input.indexOf('{');
  124. const lastBrace = input.lastIndexOf('}');
  125. if (firstBrace !== -1 && lastBrace > firstBrace) {
  126. candidates.push(input.slice(firstBrace, lastBrace + 1));
  127. }
  128. for (const candidate of candidates) {
  129. try {
  130. return JSON.parse(candidate);
  131. } catch (_err) {
  132. // Continue trying fallbacks.
  133. }
  134. }
  135. return {};
  136. }
  137. function extractSection(sectionName) {
  138. const escaped = sectionName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
  139. const re = new RegExp(`### ${escaped}\\n\\n([\\s\\S]*?)(\\n### |$)`, 'i');
  140. return (body.match(re)?.[1] || '').trim();
  141. }
  142. function normalizeName(value) {
  143. return (value || '')
  144. .toLowerCase()
  145. .replace(/[’'`]/g, '')
  146. .replace(/[^a-z0-9]+/g, ' ')
  147. .trim();
  148. }
  149. function parseGameCandidates(gameField) {
  150. if (!gameField || /^_?no response_?$/i.test(gameField)) {
  151. return [];
  152. }
  153. return gameField
  154. .replace(/\(.*?\)/g, ' ')
  155. .split(/\n|,|\s+&\s+|\s+and\s+|\//i)
  156. .map((v) => v.trim())
  157. .filter(Boolean);
  158. }
  159. function findGamesFromText(text, gameAliasToLabel, gameAliasToScript) {
  160. const labels = new Set();
  161. const scripts = new Set();
  162. const normalizedText = normalizeName(text);
  163. if (!normalizedText) return { labels, scripts };
  164. const escapeRegex = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
  165. const aliases = [];
  166. for (const [alias, label] of gameAliasToLabel.entries()) {
  167. if (alias.length < 3) continue;
  168. aliases.push({ alias, label, script: gameAliasToScript.get(alias) || null });
  169. }
  170. // Prefer longer aliases first so "killing floor 2" does not also match "killing floor".
  171. aliases.sort((a, b) => b.alias.length - a.alias.length);
  172. const usedRanges = [];
  173. const isOverlapping = (start, end) =>
  174. usedRanges.some((range) => start < range.end && end > range.start);
  175. for (const entry of aliases) {
  176. const pattern = new RegExp(`\\b${escapeRegex(entry.alias).replace(/\\ /g, '\\s+')}\\b`, 'g');
  177. let match;
  178. while ((match = pattern.exec(normalizedText)) !== null) {
  179. const start = match.index;
  180. const end = start + match[0].length;
  181. if (isOverlapping(start, end)) continue;
  182. labels.add(entry.label);
  183. if (entry.script) scripts.add(entry.script);
  184. usedRanges.push({ start, end });
  185. }
  186. }
  187. return { labels, scripts };
  188. }
  189. function hasAliasHitForLabel(text, targetLabel, gameAliasToLabel) {
  190. const normalizedText = normalizeName(text);
  191. if (!normalizedText || !targetLabel) return false;
  192. const paddedText = ` ${normalizedText} `;
  193. for (const [alias, label] of gameAliasToLabel.entries()) {
  194. if (label !== targetLabel) continue;
  195. if (alias.length < 3) continue;
  196. if (paddedText.includes(` ${alias} `)) return true;
  197. // Allow obvious joined-word variants for multi-token aliases
  198. // (e.g., "counter strike 1 6" matching "counterstrike 1.6").
  199. const aliasTokens = alias.split(/\s+/).filter(Boolean);
  200. if (aliasTokens.length > 1) {
  201. const escapedTokens = aliasTokens.map((token) =>
  202. token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
  203. );
  204. const flexibleAliasPattern = new RegExp(`\\b${escapedTokens.join('\\s*')}\\b`);
  205. if (flexibleAliasPattern.test(normalizedText)) return true;
  206. }
  207. }
  208. return false;
  209. }
  210. function runSteamCmdLinuxCheck(appId) {
  211. if (!appId) {
  212. return { status: 'skipped', reason: 'No Steam AppID provided.' };
  213. }
  214. const image = 'gameservermanagers/steamcmd:latest';
  215. const args = [
  216. 'run',
  217. '--rm',
  218. '-e',
  219. 'PUID=1001',
  220. '-e',
  221. 'PGID=1001',
  222. image,
  223. '+@ShutdownOnFailedCommand',
  224. '1',
  225. '+@NoPromptForPassword',
  226. '1',
  227. '+login',
  228. 'anonymous',
  229. '+app_info_update',
  230. '1',
  231. '+app_info_print',
  232. String(appId),
  233. '+quit',
  234. ];
  235. try {
  236. const output = execFileSync('docker', args, {
  237. encoding: 'utf8',
  238. stdio: ['ignore', 'pipe', 'pipe'],
  239. timeout: 120000,
  240. maxBuffer: 10 * 1024 * 1024,
  241. });
  242. const normalized = output.toLowerCase();
  243. const linuxSignals = [
  244. /"oslist"\s+"linux"/i,
  245. /"oslist"\s+"linux,windows"/i,
  246. /"oslist"\s+"windows,linux"/i,
  247. /"platforms"[\s\S]*?"linux"\s+"1"/i,
  248. /linux32/i,
  249. /linux64/i,
  250. ];
  251. const windowsOnlySignals = [
  252. /"oslist"\s+"windows"/i,
  253. /"platforms"[\s\S]*?"windows"\s+"1"/i,
  254. ];
  255. const hasLinuxSignal = linuxSignals.some((re) => re.test(normalized));
  256. const hasWindowsOnlySignal =
  257. !hasLinuxSignal && windowsOnlySignals.some((re) => re.test(normalized));
  258. if (hasLinuxSignal) {
  259. return {
  260. status: 'linux',
  261. reason: `SteamCMD app_info contains Linux platform/depot metadata for AppID ${appId}.`,
  262. };
  263. }
  264. if (hasWindowsOnlySignal) {
  265. return {
  266. status: 'windows-only',
  267. reason: `SteamCMD app_info contains Windows-only platform metadata for AppID ${appId}.`,
  268. };
  269. }
  270. return {
  271. status: 'unknown',
  272. reason: `SteamCMD app_info returned no clear Linux server metadata for AppID ${appId}.`,
  273. };
  274. } catch (err) {
  275. const stderr = err.stderr ? String(err.stderr).trim() : '';
  276. const stdout = err.stdout ? String(err.stdout).trim() : '';
  277. const message = stderr || stdout || err.message;
  278. return {
  279. status: 'error',
  280. reason: `SteamCMD lookup failed: ${message}`,
  281. };
  282. }
  283. }
  284. function parseServerlistCsv(csvText) {
  285. const rows = [];
  286. const lines = (csvText || '').split(/\r?\n/);
  287. for (let i = 1; i < lines.length; i += 1) {
  288. const line = lines[i]?.trim();
  289. if (!line) continue;
  290. const parts = line.split(',');
  291. if (parts.length < 3) continue;
  292. rows.push({
  293. shortname: parts[0].trim(),
  294. gameservername: parts[1].trim(),
  295. gamename: parts[2].trim(),
  296. });
  297. }
  298. return rows;
  299. }
  300. function inferTypeFromTitle(issueTitle) {
  301. if (/^\[bug\]/i.test(issueTitle)) return 'type: bug';
  302. if (/\bserver\s+request\b/i.test(issueTitle)) return 'type: game server request';
  303. const hasBracketPrefix = /^\[[^\]]+\]/.test(issueTitle || '');
  304. const isServerCreation =
  305. /\bserver\s+creation\b/i.test(issueTitle) ||
  306. (hasBracketPrefix && /\bcreation\b/i.test(issueTitle));
  307. const isServerSupportRequest =
  308. /\bserver\s+support\b/i.test(issueTitle) ||
  309. (/\bsupport\s+for\b/i.test(issueTitle) && /\bserver\b/i.test(issueTitle));
  310. if (isServerCreation || isServerSupportRequest) return 'type: game server request';
  311. if (/^\[feature\]/i.test(issueTitle)) return 'type: feature';
  312. if (/^\[server request\]/i.test(issueTitle)) return 'type: game server request';
  313. if (/^\[docs?\]/i.test(issueTitle)) return 'type: docs';
  314. return null;
  315. }
  316. function inferDesiredType(issueTitle, labelNames) {
  317. const titleType = inferTypeFromTitle(issueTitle);
  318. if (titleType) return titleType;
  319. // Prefer server requests over generic feature when both labels exist.
  320. if (labelNames.has('type: game server request')) return 'type: game server request';
  321. for (const label of [
  322. 'type: bug',
  323. 'type: feature',
  324. 'type: game server request',
  325. 'type: docs',
  326. ]) {
  327. if (labelNames.has(label)) return label;
  328. }
  329. return null;
  330. }
  331. function inferIssueTypeNameFromDesiredType(typeLabel) {
  332. if (typeLabel === 'type: bug') return 'Bug';
  333. if (typeLabel === 'type: feature') return 'Feature';
  334. if (typeLabel === 'type: game server request') return 'Server Request';
  335. if (typeLabel === 'type: docs') return 'Task';
  336. return null;
  337. }
  338. function parseCommandSelections(sectionValue) {
  339. const selected = new Set();
  340. const re = /command:\s*([a-z-]+)/gi;
  341. let m;
  342. while ((m = re.exec(sectionValue || '')) !== null) {
  343. let value = m[1].toLowerCase();
  344. if (value.startsWith('mods-')) value = 'mods';
  345. if (value === 'auto-update') value = 'update';
  346. selected.add(`command: ${value}`);
  347. }
  348. return selected;
  349. }
  350. function parseDistroSelections(sectionValue) {
  351. const text = sectionValue || '';
  352. const selected = new Set();
  353. if (/\bUbuntu\b/i.test(text)) selected.add('distro: Ubuntu');
  354. if (/\bDebian\b/i.test(text)) selected.add('distro: Debian');
  355. if (/\bAlmaLinux\b/i.test(text)) selected.add('distro: AlmaLinux');
  356. if (/\bRocky\b/i.test(text)) selected.add('distro: Rocky Linux');
  357. if (/\bCentOS\b/i.test(text)) selected.add('distro: CentOS');
  358. if (/\bFedora\b/i.test(text)) selected.add('distro: Fedora');
  359. if (/\bopenSUSE\b/i.test(text)) selected.add('distro: openSUSE');
  360. if (/\bArch Linux\b/i.test(text)) selected.add('distro: Arch Linux');
  361. if (/\bSlackware\b/i.test(text)) selected.add('distro: Slackware');
  362. return selected;
  363. }
  364. const repoLabels = await github.paginate(github.rest.issues.listLabelsForRepo, {
  365. owner,
  366. repo,
  367. per_page: 100,
  368. });
  369. const gameLabelByNormalized = new Map();
  370. for (const label of repoLabels) {
  371. if (!label.name.startsWith('game: ')) continue;
  372. gameLabelByNormalized.set(normalizeName(label.name.slice(6)), label.name);
  373. }
  374. const existingEngineLabels = new Set(
  375. repoLabels.map((label) => label.name).filter((name) => name.startsWith('engine: '))
  376. );
  377. const gameAliasToLabel = new Map();
  378. const gameAliasToScript = new Map();
  379. const engineByScript = new Map();
  380. for (const [normalizedGameName, label] of gameLabelByNormalized.entries()) {
  381. gameAliasToLabel.set(normalizedGameName, label);
  382. }
  383. try {
  384. const serverlistContent = await github.rest.repos.getContent({
  385. owner,
  386. repo,
  387. path: 'lgsm/data/serverlist.csv',
  388. });
  389. const encoded = serverlistContent.data?.content || '';
  390. const csvText = Buffer.from(encoded, 'base64').toString('utf8');
  391. const serverRows = parseServerlistCsv(csvText);
  392. for (const row of serverRows) {
  393. const canonicalLabel = gameLabelByNormalized.get(normalizeName(row.gamename));
  394. if (!canonicalLabel) continue;
  395. for (const alias of [row.shortname, row.gameservername, row.gamename]) {
  396. const key = normalizeName(alias);
  397. if (!key) continue;
  398. gameAliasToLabel.set(key, canonicalLabel);
  399. gameAliasToScript.set(key, row.gameservername);
  400. }
  401. }
  402. } catch (err) {
  403. console.log(`Could not load serverlist aliases: ${err.message}`);
  404. }
  405. async function ensureEngineLabel(engineLabel) {
  406. if (existingEngineLabels.has(engineLabel)) return;
  407. try {
  408. await github.rest.issues.createLabel({
  409. owner,
  410. repo,
  411. name: engineLabel,
  412. color: '000000',
  413. description: `Issues related to ${engineLabel.slice(8)} engine`,
  414. });
  415. existingEngineLabels.add(engineLabel);
  416. } catch (err) {
  417. if (err.status === 422) {
  418. existingEngineLabels.add(engineLabel);
  419. return;
  420. }
  421. console.log(`Could not create engine label "${engineLabel}": ${err.message}`);
  422. }
  423. }
  424. async function getEngineForScript(scriptName) {
  425. if (!scriptName) return null;
  426. if (engineByScript.has(scriptName)) {
  427. return engineByScript.get(scriptName);
  428. }
  429. try {
  430. const cfgContent = await github.rest.repos.getContent({
  431. owner,
  432. repo,
  433. path: `lgsm/config-default/config-lgsm/${scriptName}/_default.cfg`,
  434. });
  435. const encoded = cfgContent.data?.content || '';
  436. const cfgText = Buffer.from(encoded, 'base64').toString('utf8');
  437. const engine = cfgText.match(/^engine="([^"]+)"/m)?.[1] || null;
  438. engineByScript.set(scriptName, engine);
  439. return engine;
  440. } catch (err) {
  441. console.log(`Could not detect engine for ${scriptName}: ${err.message}`);
  442. engineByScript.set(scriptName, null);
  443. return null;
  444. }
  445. }
  446. const labelsToAdd = new Set();
  447. const labelsToRemove = new Set();
  448. // Deterministic reconciliation on every interaction.
  449. const desiredType = inferDesiredType(title, existingLabels);
  450. if (desiredType) {
  451. labelsToAdd.add(desiredType);
  452. for (const label of existingLabels) {
  453. if (label.startsWith('type: ') && label !== desiredType) {
  454. labelsToRemove.add(label);
  455. }
  456. }
  457. const desiredIssueTypeName = inferIssueTypeNameFromDesiredType(desiredType);
  458. if (desiredIssueTypeName) {
  459. try {
  460. const issueTypeData = await github.graphql(
  461. `query($owner:String!,$repo:String!,$number:Int!){
  462. repository(owner:$owner,name:$repo){
  463. issueTypes(first:20){ nodes { id name } }
  464. issue(number:$number){ id issueType { id name } }
  465. }
  466. }`,
  467. { owner, repo, number: issueNumber }
  468. );
  469. const issueNode = issueTypeData.repository?.issue;
  470. const issueTypes = issueTypeData.repository?.issueTypes?.nodes || [];
  471. const desiredIssueType = issueTypes.find((t) => t.name === desiredIssueTypeName);
  472. if (issueNode?.id && desiredIssueType?.id && issueNode.issueType?.id !== desiredIssueType.id) {
  473. await github.graphql(
  474. `mutation($id:ID!,$issueTypeId:ID!){
  475. updateIssue(input:{id:$id,issueTypeId:$issueTypeId}){
  476. issue { id number issueType { id name } }
  477. }
  478. }`,
  479. { id: issueNode.id, issueTypeId: desiredIssueType.id }
  480. );
  481. }
  482. } catch (err) {
  483. console.log(`Could not sync Issue Type: ${err.message}`);
  484. }
  485. }
  486. }
  487. const commandSection = extractSection('Command');
  488. const desiredCommands = parseCommandSelections(commandSection);
  489. if (desiredCommands.size > 0) {
  490. for (const label of desiredCommands) labelsToAdd.add(label);
  491. for (const label of existingLabels) {
  492. if (label.startsWith('command: ') && !desiredCommands.has(label)) {
  493. labelsToRemove.add(label);
  494. }
  495. }
  496. }
  497. const distroSection = extractSection('Linux distro');
  498. const desiredDistros = parseDistroSelections(distroSection);
  499. if (desiredDistros.size > 0) {
  500. for (const label of desiredDistros) labelsToAdd.add(label);
  501. for (const label of existingLabels) {
  502. if (label.startsWith('distro: ') && !desiredDistros.has(label)) {
  503. labelsToRemove.add(label);
  504. }
  505. }
  506. }
  507. const tmuxContextPattern = /\b(tmuxception|check_tmuxception)\b/i;
  508. if (existingLabels.has('info: tmux') && !tmuxContextPattern.test(`${title}\n${body}`)) {
  509. labelsToRemove.add('info: tmux');
  510. }
  511. const desiredGames = new Set();
  512. const desiredServerScripts = new Set();
  513. // 'Game server' is the section name in server_request.yml; 'Game' is used in bug_report.yml.
  514. const gameField = extractSection('Game server') || extractSection('Game');
  515. const gameCandidates = parseGameCandidates(gameField);
  516. const hasStructuredGameSelection = gameCandidates.length > 0;
  517. for (const candidate of gameCandidates) {
  518. const normalizedCandidate = normalizeName(candidate);
  519. const mapped = gameAliasToLabel.get(normalizedCandidate) || gameLabelByNormalized.get(normalizedCandidate);
  520. if (mapped) desiredGames.add(mapped);
  521. const mappedScript = gameAliasToScript.get(normalizedCandidate);
  522. if (mappedScript) desiredServerScripts.add(mappedScript);
  523. }
  524. // Legacy issues often have no form section; fall back to deterministic text matching.
  525. // If a structured Game field exists but does not map, do not guess from free text.
  526. if (desiredGames.size === 0 && !hasStructuredGameSelection) {
  527. const fromText = findGamesFromText(`${title}\n${body}`, gameAliasToLabel, gameAliasToScript);
  528. for (const label of fromText.labels) desiredGames.add(label);
  529. for (const scriptName of fromText.scripts) desiredServerScripts.add(scriptName);
  530. }
  531. // AI advisory is only needed on issue opened/edited.
  532. let triage = {};
  533. let ranAi = false;
  534. const shouldRunAi = eventName === 'issues' && ['opened', 'edited'].includes(action);
  535. const shouldRunLinuxSupportCheck =
  536. eventName === 'issues' &&
  537. ['opened', 'edited', 'reopened', 'labeled', 'unlabeled'].includes(action);
  538. if (shouldRunAi) {
  539. ranAi = true;
  540. const isShortBody = body.trim().length < 80;
  541. if (isShortBody) {
  542. labelsToAdd.add('needs: more info');
  543. } else {
  544. try {
  545. const res = await fetch(
  546. `https://models.github.ai/orgs/${owner}/inference/chat/completions`,
  547. {
  548. method: 'POST',
  549. headers: {
  550. Accept: 'application/vnd.github+json',
  551. Authorization: `Bearer ${process.env.GITHUB_TOKEN}`,
  552. 'X-GitHub-Api-Version': '2026-03-10',
  553. 'Content-Type': 'application/json',
  554. },
  555. body: JSON.stringify({
  556. model: 'openai/gpt-4.1-mini',
  557. temperature: 0.1,
  558. max_tokens: 400,
  559. messages: [
  560. {
  561. role: 'system',
  562. content:
  563. 'You are a triage assistant for LinuxGSM, an open-source Linux game server manager. ' +
  564. 'Return only JSON. Analyze issue quality, suggest missing info, detect game names, and suggest contextual labels ' +
  565. 'only when highly certain. Never set type: docs just because docs links are mentioned.',
  566. },
  567. {
  568. role: 'user',
  569. content:
  570. `Title: ${title}\n\nBody:\n${body.slice(0, 3000)}\n\n` +
  571. 'Return JSON schema:\n' +
  572. '{\n' +
  573. ' "quality": "good" | "ok" | "poor",\n' +
  574. ' "missing_info": ["list of specific missing fields"],\n' +
  575. ' "detected_game": "canonical game name if one is mentioned, or null",\n' +
  576. ' "game_confidence": "high" | "medium" | "low" | null,\n' +
  577. ' "context_labels": ["labels"],\n' +
  578. ' "context_confidence": "high" | "medium" | "low" | null,\n' +
  579. ' "game_note": "string",\n' +
  580. ' "comment": "string"\n' +
  581. '}',
  582. },
  583. ],
  584. }),
  585. }
  586. );
  587. if (res.ok) {
  588. const data = await res.json();
  589. const raw = data.choices?.[0]?.message?.content || '{}';
  590. triage = parseTriageResponse(raw);
  591. } else {
  592. console.log(`GitHub Models returned ${res.status} - skipping AI triage.`);
  593. }
  594. } catch (err) {
  595. console.log('AI triage skipped:', err.message);
  596. }
  597. }
  598. }
  599. const allowedContextLabels = new Set([
  600. 'type: docs',
  601. 'info: docs',
  602. 'info: dependency',
  603. 'info: docker',
  604. 'info: email',
  605. 'info: query',
  606. 'info: steamcmd',
  607. 'info: systemd',
  608. 'info: website',
  609. 'info: alerts',
  610. ]);
  611. const isPoor = triage?.quality === 'poor';
  612. const missing = Array.isArray(triage?.missing_info) ? triage.missing_info : [];
  613. const hasIssues = isPoor || missing.length > 0;
  614. // Fallback to AI-detected game only when no structured Game field exists.
  615. const detectedGame = triage?.detected_game;
  616. const gameConfidence = triage?.game_confidence;
  617. if (desiredGames.size === 0 && !hasStructuredGameSelection && detectedGame && gameConfidence === 'high') {
  618. const normalizedDetectedGame = normalizeName(detectedGame);
  619. const mapped = gameLabelByNormalized.get(normalizedDetectedGame);
  620. if (mapped) {
  621. desiredGames.add(mapped);
  622. }
  623. const mappedScript = gameAliasToScript.get(normalizedDetectedGame);
  624. if (mappedScript) desiredServerScripts.add(mappedScript);
  625. }
  626. // Resolve server scripts from canonical game labels when only labels were mapped.
  627. for (const gameLabel of desiredGames) {
  628. const gameName = gameLabel.slice(6);
  629. const mappedScript = gameAliasToScript.get(normalizeName(gameName));
  630. if (mappedScript) desiredServerScripts.add(mappedScript);
  631. }
  632. const desiredEngineLabels = new Set();
  633. for (const scriptName of desiredServerScripts) {
  634. const engine = await getEngineForScript(scriptName);
  635. if (!engine) continue;
  636. const engineLabel = `engine: ${engine}`;
  637. await ensureEngineLabel(engineLabel);
  638. desiredEngineLabels.add(engineLabel);
  639. }
  640. if (desiredEngineLabels.size > 0) {
  641. for (const label of desiredEngineLabels) labelsToAdd.add(label);
  642. for (const label of existingLabels) {
  643. if (label.startsWith('engine: ') && !desiredEngineLabels.has(label)) {
  644. labelsToRemove.add(label);
  645. }
  646. }
  647. }
  648. if (desiredGames.size > 0) {
  649. for (const label of desiredGames) labelsToAdd.add(label);
  650. if (hasStructuredGameSelection) {
  651. for (const label of existingLabels) {
  652. if (label.startsWith('game: ') && !desiredGames.has(label)) {
  653. labelsToRemove.add(label);
  654. }
  655. }
  656. } else {
  657. // For legacy issues without structured game selection, only prune stale
  658. // broader labels when a more specific inferred game label exists.
  659. const desiredGameNamesNormalized = new Set(
  660. [...desiredGames].map((label) => normalizeName(label.slice(6)))
  661. );
  662. for (const label of existingLabels) {
  663. if (!label.startsWith('game: ') || desiredGames.has(label)) continue;
  664. const existingGameName = normalizeName(label.slice(6));
  665. const isBroaderOverlap = [...desiredGameNamesNormalized].some(
  666. (desiredName) => desiredName !== existingGameName && desiredName.startsWith(`${existingGameName} `)
  667. );
  668. if (isBroaderOverlap) {
  669. labelsToRemove.add(label);
  670. }
  671. }
  672. }
  673. }
  674. if (triage?.context_confidence === 'high') {
  675. const contextLabels = Array.isArray(triage.context_labels) ? triage.context_labels : [];
  676. for (const label of contextLabels) {
  677. if (!allowedContextLabels.has(label)) continue;
  678. if (
  679. label === 'type: docs' &&
  680. (existingLabels.has('type: game server request') || desiredType === 'type: game server request')
  681. ) {
  682. continue;
  683. }
  684. labelsToAdd.add(label);
  685. }
  686. }
  687. if (ranAi && hasIssues) {
  688. labelsToAdd.add('needs: more info');
  689. }
  690. if (ranAi && !hasIssues && existingLabels.has('needs: more info')) {
  691. labelsToRemove.add('needs: more info');
  692. }
  693. // Avoid pointless API calls.
  694. const finalAdds = [...labelsToAdd].filter((label) => !existingLabels.has(label));
  695. const finalRemoves = [...labelsToRemove].filter((label) => existingLabels.has(label));
  696. for (const label of finalRemoves) {
  697. try {
  698. await github.rest.issues.removeLabel({
  699. owner,
  700. repo,
  701. issue_number: issueNumber,
  702. name: label,
  703. });
  704. console.log(`Removed label: ${label}`);
  705. } catch (err) {
  706. console.log(`Could not remove label "${label}": ${err.message}`);
  707. }
  708. }
  709. for (const label of finalAdds) {
  710. try {
  711. await github.rest.issues.addLabels({
  712. owner,
  713. repo,
  714. issue_number: issueNumber,
  715. labels: [label],
  716. });
  717. console.log(`Added label: ${label}`);
  718. } catch (err) {
  719. console.log(`Could not add label "${label}": ${err.message}`);
  720. }
  721. }
  722. // Post AI comment only for opened/edited issues when useful.
  723. if (ranAi) {
  724. const gameNote = triage?.game_note || '';
  725. const reporterComment = triage?.comment || '';
  726. if (hasIssues || gameNote) {
  727. const missingBlock = missing.length > 0
  728. ? `\n\n**Missing information:**\n${missing.map((m) => `- ${m}`).join('\n')}`
  729. : '';
  730. const gameBlock = gameNote ? `\n\n**Game name note:** ${gameNote}` : '';
  731. const triageCommentBody =
  732. `${AI_MARKER}\n` +
  733. `Thanks for opening this issue!\n\n` +
  734. `${reporterComment}` +
  735. `${missingBlock}` +
  736. `${gameBlock}\n\n` +
  737. `_This note was generated automatically by AI triage and may not be perfect. ` +
  738. `A maintainer will review shortly._`;
  739. try {
  740. const comments = await github.paginate(github.rest.issues.listComments, {
  741. owner,
  742. repo,
  743. issue_number: issueNumber,
  744. per_page: 100,
  745. });
  746. const existingAiComment = [...comments].reverse().find(
  747. (comment) => comment.user?.type === 'Bot' && comment.body?.includes(AI_MARKER)
  748. );
  749. if (existingAiComment) {
  750. await github.rest.issues.updateComment({
  751. owner,
  752. repo,
  753. comment_id: existingAiComment.id,
  754. body: triageCommentBody,
  755. });
  756. } else {
  757. await github.rest.issues.createComment({
  758. owner,
  759. repo,
  760. issue_number: issueNumber,
  761. body: triageCommentBody,
  762. });
  763. }
  764. } catch (err) {
  765. console.log('Could not post comment:', err.message);
  766. }
  767. }
  768. }
  769. // === Linux support verification for server request issues ===
  770. // Runs only on opened/edited events to avoid reprocessing every label change.
  771. const isServerRequest =
  772. desiredType === 'type: game server request' ||
  773. existingLabels.has('type: game server request') ||
  774. /\[server request\]/i.test(title);
  775. if (isServerRequest && shouldRunLinuxSupportCheck) {
  776. const officialDocsSection = extractSection('Official dedicated server documentation');
  777. const linuxBinaryProofSection = extractSection('Linux binary proof');
  778. const guidesSection = extractSection('Guides');
  779. const steamSection = extractSection('Steam').trim();
  780. const isSteamNo = /^no$/i.test(steamSection);
  781. const isSteamYes = /^yes$/i.test(steamSection);
  782. const steamAppIdRaw = extractSection('Steam appid').trim();
  783. const steamAppId = /^\d+$/.test(steamAppIdRaw) ? steamAppIdRaw : null;
  784. const supportEvidenceText = [officialDocsSection, linuxBinaryProofSection, guidesSection]
  785. .join('\n')
  786. .trim();
  787. // Deterministic textual checks to avoid trusting checkbox-only reports.
  788. const windowsOnlyPatterns = [
  789. /\bwindows\s+only\b/i,
  790. /\bonly\s+windows\b/i,
  791. /\bno\s+linux\s+support\b/i,
  792. /\blinux\s+not\s+supported\b/i,
  793. /\bdoes\s+not\s+support\s+linux\b/i,
  794. ];
  795. const wineRequiredPatterns = [
  796. /\brequires?\s+wine\b/i,
  797. /\buse\s+wine\b/i,
  798. /\brun\s+with\s+wine\b/i,
  799. /\bvia\s+wine\b/i,
  800. /\bproton\b/i,
  801. ];
  802. const linuxEvidencePatterns = [
  803. /\blinux\b/i,
  804. /\bubuntu\b/i,
  805. /\bdebian\b/i,
  806. /\blinuxgsm\b/i,
  807. /\bsteamcmd\s*\+app_update\b/i,
  808. ];
  809. const windowsBinaryHint = /\b\.exe\b/i.test(supportEvidenceText);
  810. const deterministicWindowsOnly = windowsOnlyPatterns.some((re) => re.test(supportEvidenceText));
  811. const deterministicWineRequired = wineRequiredPatterns.some((re) => re.test(supportEvidenceText));
  812. const hasLinuxEvidence = linuxEvidencePatterns.some((re) => re.test(supportEvidenceText));
  813. // Steam store API is client-app metadata only. It is kept for comment context,
  814. // but it is NOT used to determine dedicated server Linux support.
  815. let steamLinuxSupport = null; // true=yes, false=no, null=unknown/informational-only
  816. let steamAppIsServerTool = false; // success:false from store API = likely server-tool AppID
  817. let steamCmdAssessment = null;
  818. if (steamAppId) {
  819. try {
  820. const steamRes = await fetch(
  821. `https://store.steampowered.com/api/appdetails?appids=${steamAppId}&filters=platforms`,
  822. { signal: AbortSignal.timeout(8000) }
  823. );
  824. if (steamRes.ok) {
  825. const steamData = await steamRes.json();
  826. const appData = steamData[steamAppId];
  827. if (appData?.success && appData?.data?.platforms) {
  828. steamLinuxSupport = appData.data.platforms.linux === true;
  829. console.log(`Steam AppID ${steamAppId} linux=${steamLinuxSupport}`);
  830. } else if (appData?.success === false) {
  831. // Dedicated server tool AppIDs have no store page — inconclusive, not negative.
  832. steamAppIsServerTool = true;
  833. console.log(`Steam AppID ${steamAppId} has no store page (likely a server-tool AppID)`);
  834. }
  835. }
  836. } catch (err) {
  837. console.log(`Steam API check failed: ${err.message}`);
  838. }
  839. steamCmdAssessment = runSteamCmdLinuxCheck(steamAppId);
  840. console.log(`SteamCMD assessment: ${JSON.stringify(steamCmdAssessment)}`);
  841. }
  842. // AI analysis of official docs/guides for Linux evidence.
  843. let aiLinuxAssessment = null;
  844. if (supportEvidenceText.length > 10) {
  845. try {
  846. const linuxAiRes = await fetch(
  847. `https://models.github.ai/orgs/${owner}/inference/chat/completions`,
  848. {
  849. method: 'POST',
  850. headers: {
  851. Accept: 'application/vnd.github+json',
  852. Authorization: `Bearer ${process.env.GITHUB_TOKEN}`,
  853. 'X-GitHub-Api-Version': '2026-03-10',
  854. 'Content-Type': 'application/json',
  855. },
  856. body: JSON.stringify({
  857. model: 'openai/gpt-4.1-mini',
  858. temperature: 0.1,
  859. max_tokens: 200,
  860. messages: [
  861. {
  862. role: 'system',
  863. content:
  864. 'You analyze game server documentation to determine Linux support. ' +
  865. 'Return only JSON. Be conservative: only say "no" if evidence clearly shows Windows-only.',
  866. },
  867. {
  868. role: 'user',
  869. content:
  870. `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` +
  871. 'Return JSON: {"linux_support": "yes"|"no"|"unknown", "confidence": "high"|"medium"|"low", "reason": "one sentence"}',
  872. },
  873. ],
  874. }),
  875. }
  876. );
  877. if (linuxAiRes.ok) {
  878. const linuxAiData = await linuxAiRes.json();
  879. const raw = linuxAiData.choices?.[0]?.message?.content || '{}';
  880. aiLinuxAssessment = parseTriageResponse(raw);
  881. console.log(`AI linux assessment: ${JSON.stringify(aiLinuxAssessment)}`);
  882. }
  883. } catch (err) {
  884. console.log(`Linux AI check failed: ${err.message}`);
  885. }
  886. }
  887. // Linux checkbox — used as soft positive evidence only when no negative signals exist.
  888. // We don't fully trust it (users tick it without checking) but it matters when
  889. // server-specific evidence is still inconclusive and no negative patterns were found.
  890. const linuxCheckboxChecked = /\[x\]/i.test(extractSection('Linux support'));
  891. // Determine verdict: confirmed = deterministic evidence; suggested = AI advisory.
  892. const noLinuxFromDeterministicText =
  893. deterministicWindowsOnly ||
  894. (deterministicWineRequired && !hasLinuxEvidence) ||
  895. (windowsBinaryHint && !hasLinuxEvidence);
  896. const noLinuxFromSteamCmd = steamCmdAssessment?.status === 'windows-only';
  897. const noLinuxFromAi =
  898. aiLinuxAssessment?.linux_support === 'no' &&
  899. (aiLinuxAssessment?.confidence === 'high' || aiLinuxAssessment?.confidence === 'medium');
  900. const confirmedNoLinux = noLinuxFromDeterministicText || noLinuxFromSteamCmd;
  901. const suggestsNoLinux = noLinuxFromAi && !confirmedNoLinux;
  902. const confirmedLinuxFromSteamCmd = steamCmdAssessment?.status === 'linux';
  903. const linuxYesFromAi =
  904. aiLinuxAssessment?.linux_support === 'yes' &&
  905. (aiLinuxAssessment?.confidence === 'high' || aiLinuxAssessment?.confidence === 'medium');
  906. const confirmedLinuxSupport =
  907. confirmedLinuxFromSteamCmd || linuxYesFromAi;
  908. // Soft positive: checkbox checked with no negative signals and no definitive server evidence.
  909. const likelySupportedByCheckbox =
  910. linuxCheckboxChecked &&
  911. !confirmedNoLinux &&
  912. !suggestsNoLinux &&
  913. !confirmedLinuxFromSteamCmd &&
  914. !linuxYesFromAi;
  915. const NO_LINUX_LABEL = 'status: no linux support';
  916. const CONFIRMED_LINUX_LABEL = 'status: linux support confirmed';
  917. const LINUX_MARKER = '<!-- linux-support-check -->';
  918. const steamDbLink = steamAppId ? `https://steamdb.info/app/${steamAppId}/` : null;
  919. const shouldApplyNoLinuxLabel = confirmedNoLinux || suggestsNoLinux;
  920. const shouldApplyConfirmedLinuxLabel = confirmedLinuxSupport && !confirmedNoLinux && !suggestsNoLinux;
  921. if (shouldApplyNoLinuxLabel) {
  922. // Auto-create the label if it does not exist yet.
  923. try {
  924. await github.rest.issues.getLabel({ owner, repo, name: NO_LINUX_LABEL });
  925. } catch (err) {
  926. if (err.status === 404) {
  927. try {
  928. await github.rest.issues.createLabel({
  929. owner,
  930. repo,
  931. name: NO_LINUX_LABEL,
  932. color: 'd73a4a',
  933. description: 'Game server does not have confirmed native Linux support',
  934. });
  935. } catch (createErr) {
  936. console.log(`Could not create label "${NO_LINUX_LABEL}": ${createErr.message}`);
  937. }
  938. }
  939. }
  940. if (!existingLabels.has(NO_LINUX_LABEL)) {
  941. try {
  942. await github.rest.issues.addLabels({ owner, repo, issue_number: issueNumber, labels: [NO_LINUX_LABEL] });
  943. console.log(`Added label: ${NO_LINUX_LABEL}`);
  944. } catch (err) {
  945. console.log(`Could not add label "${NO_LINUX_LABEL}": ${err.message}`);
  946. }
  947. }
  948. if (existingLabels.has(CONFIRMED_LINUX_LABEL)) {
  949. try {
  950. await github.rest.issues.removeLabel({ owner, repo, issue_number: issueNumber, name: CONFIRMED_LINUX_LABEL });
  951. console.log(`Removed label: ${CONFIRMED_LINUX_LABEL}`);
  952. } catch (err) {
  953. console.log(`Could not remove label "${CONFIRMED_LINUX_LABEL}": ${err.message}`);
  954. }
  955. }
  956. } else if (existingLabels.has(NO_LINUX_LABEL)) {
  957. try {
  958. await github.rest.issues.removeLabel({ owner, repo, issue_number: issueNumber, name: NO_LINUX_LABEL });
  959. console.log(`Removed label: ${NO_LINUX_LABEL}`);
  960. } catch (err) {
  961. console.log(`Could not remove label "${NO_LINUX_LABEL}": ${err.message}`);
  962. }
  963. }
  964. if (shouldApplyConfirmedLinuxLabel) {
  965. try {
  966. await github.rest.issues.getLabel({ owner, repo, name: CONFIRMED_LINUX_LABEL });
  967. } catch (err) {
  968. if (err.status === 404) {
  969. try {
  970. await github.rest.issues.createLabel({
  971. owner,
  972. repo,
  973. name: CONFIRMED_LINUX_LABEL,
  974. color: '0e8a16',
  975. description: 'Game server has confirmed native Linux support',
  976. });
  977. } catch (createErr) {
  978. console.log(`Could not create label "${CONFIRMED_LINUX_LABEL}": ${createErr.message}`);
  979. }
  980. }
  981. }
  982. if (!existingLabels.has(CONFIRMED_LINUX_LABEL)) {
  983. try {
  984. await github.rest.issues.addLabels({ owner, repo, issue_number: issueNumber, labels: [CONFIRMED_LINUX_LABEL] });
  985. console.log(`Added label: ${CONFIRMED_LINUX_LABEL}`);
  986. } catch (err) {
  987. console.log(`Could not add label "${CONFIRMED_LINUX_LABEL}": ${err.message}`);
  988. }
  989. }
  990. } else if (existingLabels.has(CONFIRMED_LINUX_LABEL)) {
  991. try {
  992. await github.rest.issues.removeLabel({ owner, repo, issue_number: issueNumber, name: CONFIRMED_LINUX_LABEL });
  993. console.log(`Removed label: ${CONFIRMED_LINUX_LABEL}`);
  994. } catch (err) {
  995. console.log(`Could not remove label "${CONFIRMED_LINUX_LABEL}": ${err.message}`);
  996. }
  997. }
  998. const reasons = [];
  999. if (deterministicWindowsOnly) reasons.push('the provided docs/guides explicitly indicate Windows-only or no Linux support');
  1000. if (deterministicWineRequired && !hasLinuxEvidence) reasons.push('the provided docs/guides indicate a Wine/Proton requirement rather than native Linux binaries');
  1001. if (windowsBinaryHint && !hasLinuxEvidence) reasons.push('the provided evidence appears to reference Windows binaries (.exe) without clear Linux server evidence');
  1002. if (isSteamNo) reasons.push('request is marked as non-Steam, so Steam platform checks were intentionally skipped');
  1003. if (steamAppIsServerTool) reasons.push(`AppID ${steamAppId} has no Steam store page (typical for dedicated server tool AppIDs)`);
  1004. if (noLinuxFromSteamCmd && steamCmdAssessment?.reason) reasons.push(steamCmdAssessment.reason);
  1005. if (confirmedLinuxFromSteamCmd && steamCmdAssessment?.reason) reasons.push(steamCmdAssessment.reason);
  1006. if (steamCmdAssessment?.status === 'unknown' && steamCmdAssessment?.reason) reasons.push(steamCmdAssessment.reason);
  1007. if (steamCmdAssessment?.status === 'error' && steamCmdAssessment?.reason) reasons.push(steamCmdAssessment.reason);
  1008. if (noLinuxFromAi && aiLinuxAssessment?.reason) reasons.push(`AI analysis of provided documentation: ${aiLinuxAssessment.reason}`);
  1009. if (linuxYesFromAi && aiLinuxAssessment?.reason) reasons.push(`AI analysis indicates Linux support: ${aiLinuxAssessment.reason}`);
  1010. if (likelySupportedByCheckbox) reasons.push('requester confirmed Linux support via the form checkbox; no contradicting evidence found');
  1011. let verdictLine = 'Linux support could not be confirmed automatically from the submitted details.';
  1012. if (confirmedNoLinux) {
  1013. verdictLine = 'This server request does **not** appear to have native Linux support, which is required for LinuxGSM.';
  1014. } else if (suggestsNoLinux) {
  1015. verdictLine = 'This server request **may not** have native Linux support based on submitted evidence.';
  1016. } else if (confirmedLinuxFromSteamCmd) {
  1017. verdictLine = 'SteamCMD metadata indicates this server has Linux platform/depot support.';
  1018. } else if (linuxYesFromAi) {
  1019. verdictLine = 'Submitted documentation appears to indicate Linux server support.';
  1020. } else if (likelySupportedByCheckbox) {
  1021. verdictLine = 'Linux support is **likely** — the requester confirmed it and no contradicting evidence was found. A maintainer should verify before accepting.';
  1022. }
  1023. const steamApiStatus = isSteamNo
  1024. ? 'Not applicable'
  1025. : steamLinuxSupport === true
  1026. ? 'Client app marked Linux-supported (informational only)'
  1027. : steamLinuxSupport === false
  1028. ? 'Client app marked Linux-unsupported (informational only)'
  1029. : steamAppIsServerTool
  1030. ? 'No store page for this AppID (informational only)'
  1031. : 'No definitive platform response';
  1032. const steamCmdStatus = isSteamNo
  1033. ? 'Not applicable'
  1034. : !steamAppId
  1035. ? 'Skipped until valid AppID is provided'
  1036. : steamCmdAssessment?.status === 'linux'
  1037. ? 'Linux platform/depot metadata found'
  1038. : steamCmdAssessment?.status === 'windows-only'
  1039. ? 'Windows-only platform metadata found'
  1040. : steamCmdAssessment?.status === 'unknown'
  1041. ? 'No clear Linux server metadata found'
  1042. : steamCmdAssessment?.status === 'error'
  1043. ? 'Lookup failed'
  1044. : 'Not run';
  1045. const steamBlock = isSteamNo
  1046. ? '**Steam:** No (non-Steam request)\n**Steam Store API:** Not applicable\n\n'
  1047. : steamAppId
  1048. ? `**Steam:** ${isSteamYes ? 'Yes' : 'Unspecified'}\n` +
  1049. `**Steam AppID:** ${steamAppId}\n` +
  1050. `**Steam Store API:** ${steamApiStatus}\n` +
  1051. `**SteamCMD:** ${steamCmdStatus}\n` +
  1052. `**SteamDB:** ${steamDbLink}\n\n`
  1053. : `**Steam:** ${isSteamYes ? 'Yes' : 'Unspecified'}\n` +
  1054. '**Steam AppID:** Not provided in the issue form.\n' +
  1055. (isSteamYes ? '**Steam Store API:** Skipped until valid AppID is provided.\n**SteamCMD:** Skipped until valid AppID is provided.\n\n' : '\n');
  1056. const linuxCommentHeader = shouldApplyConfirmedLinuxLabel
  1057. ? '**Linux Support Check** :rocket:'
  1058. : '**Linux Support Check**';
  1059. const confirmedLinuxLabelBlock = shouldApplyConfirmedLinuxLabel
  1060. ? `**Label applied:** ${CONFIRMED_LINUX_LABEL}\n\n`
  1061. : '';
  1062. const linuxCommentBody =
  1063. `${LINUX_MARKER}\n` +
  1064. `${linuxCommentHeader}\n\n` +
  1065. `${verdictLine}\n\n` +
  1066. `${confirmedLinuxLabelBlock}` +
  1067. `${steamBlock}` +
  1068. (reasons.length > 0
  1069. ? `**Evidence:**\n${reasons.map((r) => `- ${r}`).join('\n')}\n\n`
  1070. : '') +
  1071. `LinuxGSM only supports **native Linux dedicated servers**. Wine and Windows-only servers are not supported.\n\n` +
  1072. `If support is unclear, please provide:\n` +
  1073. `- Official Linux dedicated server documentation\n` +
  1074. `- Linux server binaries or release notes\n` +
  1075. `- Linux startup instructions or commands\n\n` +
  1076. `_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._`;
  1077. try {
  1078. const allComments = await github.paginate(github.rest.issues.listComments, {
  1079. owner,
  1080. repo,
  1081. issue_number: issueNumber,
  1082. per_page: 100,
  1083. });
  1084. const existingLinuxComment = [...allComments].reverse().find(
  1085. (c) => c.user?.type === 'Bot' && c.body?.includes(LINUX_MARKER)
  1086. );
  1087. if (existingLinuxComment) {
  1088. await github.rest.issues.updateComment({
  1089. owner,
  1090. repo,
  1091. comment_id: existingLinuxComment.id,
  1092. body: linuxCommentBody,
  1093. });
  1094. } else {
  1095. await github.rest.issues.createComment({
  1096. owner,
  1097. repo,
  1098. issue_number: issueNumber,
  1099. body: linuxCommentBody,
  1100. });
  1101. }
  1102. } catch (err) {
  1103. console.log(`Could not post Linux support comment: ${err.message}`);
  1104. }
  1105. }
  1106. issue-potential-duplicates:
  1107. if: github.repository_owner == 'GameServerManagers' && github.event_name == 'issues' && (github.event.action == 'opened' || github.event.action == 'edited' || github.event.action == 'reopened')
  1108. runs-on: ubuntu-latest
  1109. steps:
  1110. - name: Detect potential duplicates
  1111. uses: actions/github-script@v9
  1112. with:
  1113. script: |
  1114. const owner = context.repo.owner;
  1115. const repo = context.repo.repo;
  1116. const issueNumber = context.payload.issue?.number;
  1117. const DUPLICATE_LABEL = 'potential-duplicate';
  1118. const DUPLICATE_MARKER = '<!-- potential-duplicate-check -->';
  1119. const MAX_CANDIDATES = 5;
  1120. const THRESHOLD = 0.45;
  1121. if (!issueNumber) {
  1122. console.log('No issue number found in payload.');
  1123. return;
  1124. }
  1125. const issueResp = await github.rest.issues.get({
  1126. owner,
  1127. repo,
  1128. issue_number: issueNumber,
  1129. });
  1130. const issue = issueResp.data;
  1131. if (issue.pull_request) {
  1132. console.log('Skipping pull request payload.');
  1133. return;
  1134. }
  1135. function normalizeText(value) {
  1136. return (value || '')
  1137. .toLowerCase()
  1138. .replace(/[`'"’]/g, '')
  1139. .replace(/[^a-z0-9\s]/g, ' ')
  1140. .replace(/\s+/g, ' ')
  1141. .trim();
  1142. }
  1143. function tokenize(value) {
  1144. const stopwords = new Set([
  1145. 'a', 'an', 'and', 'are', 'as', 'at', 'be', 'by', 'for', 'from', 'how', 'i', 'in', 'is', 'it', 'its',
  1146. 'of', 'on', 'or', 'that', 'the', 'this', 'to', 'when', 'with', 'wont', 'cannot', 'cant', 'fails', 'fail',
  1147. 'issue', 'bug', 'request', 'server', 'command', 'linuxgsm'
  1148. ]);
  1149. return new Set(
  1150. normalizeText(value)
  1151. .split(' ')
  1152. .filter((token) => token.length > 2 && !stopwords.has(token))
  1153. );
  1154. }
  1155. function jaccard(aSet, bSet) {
  1156. if (aSet.size === 0 || bSet.size === 0) return 0;
  1157. let intersection = 0;
  1158. for (const v of aSet) {
  1159. if (bSet.has(v)) intersection += 1;
  1160. }
  1161. const union = new Set([...aSet, ...bSet]).size;
  1162. return union === 0 ? 0 : intersection / union;
  1163. }
  1164. function bodySignature(text) {
  1165. return normalizeText(text).split(' ').slice(0, 200).join(' ');
  1166. }
  1167. const currentTitleTokens = tokenize(issue.title || '');
  1168. const currentBodyTokens = tokenize(bodySignature(issue.body || ''));
  1169. const recentIssues = await github.paginate(github.rest.issues.listForRepo, {
  1170. owner,
  1171. repo,
  1172. state: 'all',
  1173. sort: 'updated',
  1174. direction: 'desc',
  1175. per_page: 100,
  1176. });
  1177. const ranked = [];
  1178. for (const candidate of recentIssues) {
  1179. if (!candidate || candidate.number === issueNumber || candidate.pull_request) continue;
  1180. const candidateTitleTokens = tokenize(candidate.title || '');
  1181. const candidateBodyTokens = tokenize(bodySignature(candidate.body || ''));
  1182. const titleScore = jaccard(currentTitleTokens, candidateTitleTokens);
  1183. const bodyScore = jaccard(currentBodyTokens, candidateBodyTokens);
  1184. const score = titleScore * 0.8 + bodyScore * 0.2;
  1185. if (score < THRESHOLD) continue;
  1186. ranked.push({
  1187. number: candidate.number,
  1188. title: candidate.title,
  1189. state: candidate.state,
  1190. html_url: candidate.html_url,
  1191. score,
  1192. });
  1193. }
  1194. ranked.sort((a, b) => b.score - a.score);
  1195. const topMatches = ranked.slice(0, MAX_CANDIDATES);
  1196. async function ensurePotentialDuplicateLabel() {
  1197. try {
  1198. await github.rest.issues.getLabel({ owner, repo, name: DUPLICATE_LABEL });
  1199. } catch (err) {
  1200. if (err.status !== 404) throw err;
  1201. await github.rest.issues.createLabel({
  1202. owner,
  1203. repo,
  1204. name: DUPLICATE_LABEL,
  1205. color: 'd4c5f9',
  1206. description: 'Potentially duplicates another existing issue',
  1207. });
  1208. }
  1209. }
  1210. const existingLabelNames = new Set((issue.labels || []).map((l) => l.name));
  1211. const comments = await github.paginate(github.rest.issues.listComments, {
  1212. owner,
  1213. repo,
  1214. issue_number: issueNumber,
  1215. per_page: 100,
  1216. });
  1217. const existingComment = [...comments]
  1218. .reverse()
  1219. .find((comment) => comment.user?.type === 'Bot' && comment.body?.includes(DUPLICATE_MARKER));
  1220. if (topMatches.length === 0) {
  1221. if (existingLabelNames.has(DUPLICATE_LABEL)) {
  1222. try {
  1223. await github.rest.issues.removeLabel({
  1224. owner,
  1225. repo,
  1226. issue_number: issueNumber,
  1227. name: DUPLICATE_LABEL,
  1228. });
  1229. } catch (err) {
  1230. console.log(`Could not remove ${DUPLICATE_LABEL}: ${err.message}`);
  1231. }
  1232. }
  1233. if (existingComment) {
  1234. try {
  1235. await github.rest.issues.updateComment({
  1236. owner,
  1237. repo,
  1238. comment_id: existingComment.id,
  1239. body:
  1240. `${DUPLICATE_MARKER}\n` +
  1241. `Potential duplicate scan did not find strong matches at this time.\n\n` +
  1242. `_This note is maintained automatically._`,
  1243. });
  1244. } catch (err) {
  1245. console.log(`Could not update duplicate comment: ${err.message}`);
  1246. }
  1247. }
  1248. return;
  1249. }
  1250. await ensurePotentialDuplicateLabel();
  1251. if (!existingLabelNames.has(DUPLICATE_LABEL)) {
  1252. try {
  1253. await github.rest.issues.addLabels({
  1254. owner,
  1255. repo,
  1256. issue_number: issueNumber,
  1257. labels: [DUPLICATE_LABEL],
  1258. });
  1259. } catch (err) {
  1260. console.log(`Could not add ${DUPLICATE_LABEL}: ${err.message}`);
  1261. }
  1262. }
  1263. const lines = topMatches
  1264. .map((m) => `- #${m.number} (${Math.round(m.score * 100)}%) ${m.title}`)
  1265. .join('\n');
  1266. const commentBody =
  1267. `${DUPLICATE_MARKER}\n` +
  1268. `Potential duplicates:\n${lines}\n\n` +
  1269. `_This note is generated automatically using repository issue similarity and may include false positives._`;
  1270. if (existingComment) {
  1271. await github.rest.issues.updateComment({
  1272. owner,
  1273. repo,
  1274. comment_id: existingComment.id,
  1275. body: commentBody,
  1276. });
  1277. } else {
  1278. await github.rest.issues.createComment({
  1279. owner,
  1280. repo,
  1281. issue_number: issueNumber,
  1282. body: commentBody,
  1283. });
  1284. }
  1285. backfill-relabel:
  1286. if: github.repository_owner == 'GameServerManagers' && github.event_name == 'workflow_dispatch'
  1287. runs-on: ubuntu-latest
  1288. env:
  1289. ISSUE_STATE: ${{ inputs.issue_state }}
  1290. ISSUE_LIMIT: ${{ inputs.limit }}
  1291. AI_GAME_FALLBACK: ${{ inputs.ai_game_fallback }}
  1292. steps:
  1293. - name: Trigger relabel backfill
  1294. uses: actions/github-script@v9
  1295. env:
  1296. GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
  1297. with:
  1298. script: |
  1299. const owner = context.repo.owner;
  1300. const repo = context.repo.repo;
  1301. const state = process.env.ISSUE_STATE || 'all';
  1302. const rawLimit = Number.parseInt(process.env.ISSUE_LIMIT || '0', 10);
  1303. const limit = Number.isFinite(rawLimit) && rawLimit > 0 ? rawLimit : 0;
  1304. const useAiGameFallback = String(process.env.AI_GAME_FALLBACK || 'false').toLowerCase() === 'true';
  1305. const processedIssues = [];
  1306. const failedIssues = [];
  1307. let aiGameAttempts = 0;
  1308. let aiGameMatches = 0;
  1309. let aiGameRateLimited = 0;
  1310. let aiFallbackDisabledReason = '';
  1311. let stoppedForApiRateLimit = false;
  1312. let apiRateLimitStopReason = '';
  1313. // === Helpers (mirrored from issue-ai-maintenance) ===
  1314. function normalizeName(value) {
  1315. return (value || '')
  1316. .toLowerCase()
  1317. .replace(/[''`]/g, '')
  1318. .replace(/[^a-z0-9]+/g, ' ')
  1319. .trim();
  1320. }
  1321. function parseGameCandidates(gameField) {
  1322. if (!gameField || /^_?no response_?$/i.test(gameField)) return [];
  1323. return gameField
  1324. .replace(/\(.*?\)/g, ' ')
  1325. .split(/\n|,|\s+&\s+|\s+and\s+|\//i)
  1326. .map((v) => v.trim())
  1327. .filter(Boolean);
  1328. }
  1329. function findGamesFromText(text, gameAliasToLabel, gameAliasToScript) {
  1330. const labels = new Set();
  1331. const scripts = new Set();
  1332. const normalizedText = normalizeName(text);
  1333. if (!normalizedText) return { labels, scripts };
  1334. const escapeRegex = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
  1335. const aliases = [];
  1336. for (const [alias, label] of gameAliasToLabel.entries()) {
  1337. if (alias.length < 3) continue;
  1338. aliases.push({ alias, label, script: gameAliasToScript.get(alias) || null });
  1339. }
  1340. // Prefer longer aliases first so "killing floor 2" does not also match "killing floor".
  1341. aliases.sort((a, b) => b.alias.length - a.alias.length);
  1342. const usedRanges = [];
  1343. const isOverlapping = (start, end) =>
  1344. usedRanges.some((range) => start < range.end && end > range.start);
  1345. for (const entry of aliases) {
  1346. const pattern = new RegExp(`\\b${escapeRegex(entry.alias).replace(/\\ /g, '\\s+')}\\b`, 'g');
  1347. let match;
  1348. while ((match = pattern.exec(normalizedText)) !== null) {
  1349. const start = match.index;
  1350. const end = start + match[0].length;
  1351. if (isOverlapping(start, end)) continue;
  1352. labels.add(entry.label);
  1353. if (entry.script) scripts.add(entry.script);
  1354. usedRanges.push({ start, end });
  1355. }
  1356. }
  1357. return { labels, scripts };
  1358. }
  1359. function parseAiGameResponse(raw) {
  1360. const input = (raw || '').trim();
  1361. if (!input) return {};
  1362. const candidates = [input];
  1363. const fenced = input.match(/```(?:json)?\s*([\s\S]*?)```/i);
  1364. if (fenced?.[1]) candidates.push(fenced[1].trim());
  1365. const firstBrace = input.indexOf('{');
  1366. const lastBrace = input.lastIndexOf('}');
  1367. if (firstBrace !== -1 && lastBrace > firstBrace) {
  1368. candidates.push(input.slice(firstBrace, lastBrace + 1));
  1369. }
  1370. for (const candidate of candidates) {
  1371. try {
  1372. return JSON.parse(candidate);
  1373. } catch (_err) {
  1374. // Continue trying fallbacks.
  1375. }
  1376. }
  1377. return {};
  1378. }
  1379. function isGenericNonGameDetection(value) {
  1380. const normalized = normalizeName(value);
  1381. if (!normalized) return false;
  1382. return [
  1383. 'srcds',
  1384. 'source dedicated server',
  1385. 'dedicated server',
  1386. 'source engine',
  1387. 'goldsrc',
  1388. 'steamcmd',
  1389. 'linuxgsm',
  1390. 'lgsm',
  1391. ].some((term) => normalized.includes(term));
  1392. }
  1393. function parseAiRateLimitInfo(response) {
  1394. const retryAfter = response.headers.get('retry-after') || response.headers.get('Retry-After') || '';
  1395. const limit = response.headers.get('x-ratelimit-limit') || '';
  1396. const remaining = response.headers.get('x-ratelimit-remaining') || '';
  1397. const resetEpoch = response.headers.get('x-ratelimit-reset') || '';
  1398. const requestId = response.headers.get('x-github-request-id') || '';
  1399. let resetIso = '';
  1400. const parsedReset = Number.parseInt(resetEpoch, 10);
  1401. if (Number.isFinite(parsedReset) && parsedReset > 0) {
  1402. resetIso = new Date(parsedReset * 1000).toISOString();
  1403. }
  1404. return {
  1405. retryAfter,
  1406. limit,
  1407. remaining,
  1408. resetEpoch,
  1409. resetIso,
  1410. requestId,
  1411. };
  1412. }
  1413. function formatAiRateLimitInfo(info) {
  1414. const parts = [];
  1415. if (info.retryAfter) parts.push(`retry-after=${info.retryAfter}s`);
  1416. if (info.limit) parts.push(`limit=${info.limit}`);
  1417. if (info.remaining) parts.push(`remaining=${info.remaining}`);
  1418. if (info.resetEpoch) parts.push(`reset=${info.resetEpoch}${info.resetIso ? ` (${info.resetIso})` : ''}`);
  1419. if (info.requestId) parts.push(`request-id=${info.requestId}`);
  1420. return parts.length > 0 ? parts.join(', ') : 'no rate-limit headers returned';
  1421. }
  1422. function isApiRateLimitError(err) {
  1423. const message = String(err?.message || '').toLowerCase();
  1424. return (
  1425. err?.status === 429 ||
  1426. message.includes('api rate limit exceeded') ||
  1427. message.includes('secondary rate limit') ||
  1428. (message.includes('rate limit') && err?.status === 403)
  1429. );
  1430. }
  1431. function formatApiRateLimitError(err) {
  1432. const headers = err?.response?.headers || err?.headers || {};
  1433. const limit = headers['x-ratelimit-limit'] || '';
  1434. const remaining = headers['x-ratelimit-remaining'] || '';
  1435. const resetEpoch = headers['x-ratelimit-reset'] || '';
  1436. const requestId = headers['x-github-request-id'] || '';
  1437. let resetIso = '';
  1438. const parsedReset = Number.parseInt(resetEpoch || '', 10);
  1439. if (Number.isFinite(parsedReset) && parsedReset > 0) {
  1440. resetIso = new Date(parsedReset * 1000).toISOString();
  1441. }
  1442. const parts = [];
  1443. if (limit) parts.push(`limit=${limit}`);
  1444. if (remaining) parts.push(`remaining=${remaining}`);
  1445. if (resetEpoch) parts.push(`reset=${resetEpoch}${resetIso ? ` (${resetIso})` : ''}`);
  1446. if (requestId) parts.push(`request-id=${requestId}`);
  1447. return parts.length > 0 ? parts.join(', ') : 'no rate-limit headers returned';
  1448. }
  1449. function hasAliasHitForLabel(text, targetLabel, gameAliasToLabel) {
  1450. const normalizedText = normalizeName(text);
  1451. if (!normalizedText || !targetLabel) return false;
  1452. const paddedText = ` ${normalizedText} `;
  1453. for (const [alias, label] of gameAliasToLabel.entries()) {
  1454. if (label !== targetLabel) continue;
  1455. if (alias.length < 3) continue;
  1456. if (paddedText.includes(` ${alias} `)) return true;
  1457. // Allow obvious joined-word variants for multi-token aliases
  1458. // (e.g., "counter strike 1 6" matching "counterstrike 1.6").
  1459. const aliasTokens = alias.split(/\s+/).filter(Boolean);
  1460. if (aliasTokens.length > 1) {
  1461. const escapedTokens = aliasTokens.map((token) =>
  1462. token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
  1463. );
  1464. const flexibleAliasPattern = new RegExp(`\\b${escapedTokens.join('\\s*')}\\b`);
  1465. if (flexibleAliasPattern.test(normalizedText)) return true;
  1466. }
  1467. }
  1468. return false;
  1469. }
  1470. function parseServerlistCsv(csvText) {
  1471. const rows = [];
  1472. const lines = (csvText || '').split(/\r?\n/);
  1473. for (let i = 1; i < lines.length; i += 1) {
  1474. const line = lines[i]?.trim();
  1475. if (!line) continue;
  1476. const parts = line.split(',');
  1477. if (parts.length < 3) continue;
  1478. rows.push({
  1479. shortname: parts[0].trim(),
  1480. gameservername: parts[1].trim(),
  1481. gamename: parts[2].trim(),
  1482. });
  1483. }
  1484. return rows;
  1485. }
  1486. function inferTypeFromTitle(issueTitle) {
  1487. if (/^\[bug\]/i.test(issueTitle)) return 'type: bug';
  1488. if (/\bserver\s+request\b/i.test(issueTitle)) return 'type: game server request';
  1489. const hasBracketPrefix = /^\[[^\]]+\]/.test(issueTitle || '');
  1490. const isServerCreation =
  1491. /\bserver\s+creation\b/i.test(issueTitle) ||
  1492. (hasBracketPrefix && /\bcreation\b/i.test(issueTitle));
  1493. const isServerSupportRequest =
  1494. /\bserver\s+support\b/i.test(issueTitle) ||
  1495. (/\bsupport\s+for\b/i.test(issueTitle) && /\bserver\b/i.test(issueTitle));
  1496. if (isServerCreation || isServerSupportRequest) return 'type: game server request';
  1497. if (/^\[feature\]/i.test(issueTitle)) return 'type: feature';
  1498. if (/^\[server request\]/i.test(issueTitle)) return 'type: game server request';
  1499. if (/^\[docs?\]/i.test(issueTitle)) return 'type: docs';
  1500. return null;
  1501. }
  1502. function inferDesiredType(issueTitle, labelNames) {
  1503. const titleType = inferTypeFromTitle(issueTitle);
  1504. if (titleType) return titleType;
  1505. // Prefer server requests over generic feature when both labels exist.
  1506. if (labelNames.has('type: game server request')) return 'type: game server request';
  1507. for (const label of [
  1508. 'type: bug',
  1509. 'type: feature',
  1510. 'type: game server request',
  1511. 'type: docs',
  1512. ]) {
  1513. if (labelNames.has(label)) return label;
  1514. }
  1515. return null;
  1516. }
  1517. function inferIssueTypeNameFromDesiredType(typeLabel) {
  1518. if (typeLabel === 'type: bug') return 'Bug';
  1519. if (typeLabel === 'type: feature') return 'Feature';
  1520. if (typeLabel === 'type: game server request') return 'Server Request';
  1521. if (typeLabel === 'type: docs') return 'Task';
  1522. return null;
  1523. }
  1524. function parseCommandSelections(sectionValue) {
  1525. const selected = new Set();
  1526. const re = /command:\s*([a-z-]+)/gi;
  1527. let m;
  1528. while ((m = re.exec(sectionValue || '')) !== null) {
  1529. let value = m[1].toLowerCase();
  1530. if (value.startsWith('mods-')) value = 'mods';
  1531. if (value === 'auto-update') value = 'update';
  1532. selected.add(`command: ${value}`);
  1533. }
  1534. return selected;
  1535. }
  1536. function parseDistroSelections(sectionValue) {
  1537. const text = sectionValue || '';
  1538. const selected = new Set();
  1539. if (/\bUbuntu\b/i.test(text)) selected.add('distro: Ubuntu');
  1540. if (/\bDebian\b/i.test(text)) selected.add('distro: Debian');
  1541. if (/\bAlmaLinux\b/i.test(text)) selected.add('distro: AlmaLinux');
  1542. if (/\bRocky\b/i.test(text)) selected.add('distro: Rocky Linux');
  1543. if (/\bCentOS\b/i.test(text)) selected.add('distro: CentOS');
  1544. if (/\bFedora\b/i.test(text)) selected.add('distro: Fedora');
  1545. if (/\bopenSUSE\b/i.test(text)) selected.add('distro: openSUSE');
  1546. if (/\bArch Linux\b/i.test(text)) selected.add('distro: Arch Linux');
  1547. if (/\bSlackware\b/i.test(text)) selected.add('distro: Slackware');
  1548. return selected;
  1549. }
  1550. function extractSection(body, sectionName) {
  1551. const escaped = sectionName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
  1552. const re = new RegExp(`### ${escaped}\\n\\n([\\s\\S]*?)(\\n### |$)`, 'i');
  1553. return (body.match(re)?.[1] || '').trim();
  1554. }
  1555. // === Load shared data once ===
  1556. const repoLabels = await github.paginate(github.rest.issues.listLabelsForRepo, {
  1557. owner,
  1558. repo,
  1559. per_page: 100,
  1560. });
  1561. const gameLabelByNormalized = new Map();
  1562. for (const label of repoLabels) {
  1563. if (!label.name.startsWith('game: ')) continue;
  1564. gameLabelByNormalized.set(normalizeName(label.name.slice(6)), label.name);
  1565. }
  1566. const existingEngineLabels = new Set(
  1567. repoLabels.map((l) => l.name).filter((name) => name.startsWith('engine: '))
  1568. );
  1569. const gameAliasToLabel = new Map();
  1570. const gameAliasToScript = new Map();
  1571. const engineByScript = new Map();
  1572. for (const [normalizedGameName, label] of gameLabelByNormalized.entries()) {
  1573. gameAliasToLabel.set(normalizedGameName, label);
  1574. }
  1575. try {
  1576. const serverlistContent = await github.rest.repos.getContent({
  1577. owner,
  1578. repo,
  1579. path: 'lgsm/data/serverlist.csv',
  1580. });
  1581. const csvText = Buffer.from(serverlistContent.data?.content || '', 'base64').toString('utf8');
  1582. const serverRows = parseServerlistCsv(csvText);
  1583. for (const row of serverRows) {
  1584. const canonicalLabel = gameLabelByNormalized.get(normalizeName(row.gamename));
  1585. if (!canonicalLabel) continue;
  1586. for (const alias of [row.shortname, row.gameservername, row.gamename]) {
  1587. const key = normalizeName(alias);
  1588. if (!key) continue;
  1589. gameAliasToLabel.set(key, canonicalLabel);
  1590. gameAliasToScript.set(key, row.gameservername);
  1591. }
  1592. }
  1593. } catch (err) {
  1594. console.log(`Could not load serverlist aliases: ${err.message}`);
  1595. }
  1596. async function ensureEngineLabel(engineLabel) {
  1597. if (existingEngineLabels.has(engineLabel)) return;
  1598. try {
  1599. await github.rest.issues.createLabel({
  1600. owner,
  1601. repo,
  1602. name: engineLabel,
  1603. color: '000000',
  1604. description: `Issues related to ${engineLabel.slice(8)} engine`,
  1605. });
  1606. existingEngineLabels.add(engineLabel);
  1607. } catch (err) {
  1608. if (err.status === 422) {
  1609. existingEngineLabels.add(engineLabel);
  1610. return;
  1611. }
  1612. console.log(`Could not create engine label "${engineLabel}": ${err.message}`);
  1613. }
  1614. }
  1615. async function getEngineForScript(scriptName) {
  1616. if (!scriptName) return null;
  1617. if (engineByScript.has(scriptName)) return engineByScript.get(scriptName);
  1618. try {
  1619. const cfgContent = await github.rest.repos.getContent({
  1620. owner,
  1621. repo,
  1622. path: `lgsm/config-default/config-lgsm/${scriptName}/_default.cfg`,
  1623. });
  1624. const cfgText = Buffer.from(cfgContent.data?.content || '', 'base64').toString('utf8');
  1625. const engine = cfgText.match(/^engine="([^"]+)"/m)?.[1] || null;
  1626. engineByScript.set(scriptName, engine);
  1627. return engine;
  1628. } catch (_err) {
  1629. engineByScript.set(scriptName, null);
  1630. return null;
  1631. }
  1632. }
  1633. // === Process issues ===
  1634. const issues = await github.paginate(github.rest.issues.listForRepo, {
  1635. owner,
  1636. repo,
  1637. state,
  1638. sort: 'created',
  1639. direction: 'asc',
  1640. per_page: 100,
  1641. });
  1642. const targets = issues.filter((issue) => !issue.pull_request);
  1643. const selectedTargets = limit > 0 ? targets.slice(0, limit) : targets;
  1644. console.log(
  1645. `Starting relabel backfill for ${selectedTargets.length} issue(s) ` +
  1646. `(state=${state}, limit=${limit === 0 ? 'all' : limit}).`
  1647. );
  1648. let processed = 0;
  1649. for (const rawIssue of selectedTargets) {
  1650. if (stoppedForApiRateLimit) break;
  1651. console.log(`Processing issue #${rawIssue.number}: ${rawIssue.title}`);
  1652. try {
  1653. const issueResp = await github.rest.issues.get({
  1654. owner,
  1655. repo,
  1656. issue_number: rawIssue.number,
  1657. });
  1658. const issue = issueResp.data;
  1659. const title = issue.title || '';
  1660. const body = issue.body || '';
  1661. const existingLabels = new Set((issue.labels || []).map((l) => l.name).filter(Boolean));
  1662. const labelsToAdd = new Set();
  1663. const labelsToRemove = new Set();
  1664. const isLocked = issue.locked === true;
  1665. let issueTypeSet = null;
  1666. // Type reconciliation
  1667. const desiredType = inferDesiredType(title, existingLabels);
  1668. if (desiredType) {
  1669. labelsToAdd.add(desiredType);
  1670. for (const label of existingLabels) {
  1671. if (label.startsWith('type: ') && label !== desiredType) labelsToRemove.add(label);
  1672. }
  1673. const desiredIssueTypeName = inferIssueTypeNameFromDesiredType(desiredType);
  1674. if (desiredIssueTypeName) {
  1675. try {
  1676. const issueTypeData = await github.graphql(
  1677. `query($owner:String!,$repo:String!,$number:Int!){
  1678. repository(owner:$owner,name:$repo){
  1679. issueTypes(first:20){ nodes { id name } }
  1680. issue(number:$number){ id issueType { id name } }
  1681. }
  1682. }`,
  1683. { owner, repo, number: rawIssue.number }
  1684. );
  1685. const issueNode = issueTypeData.repository?.issue;
  1686. const issueTypes = issueTypeData.repository?.issueTypes?.nodes || [];
  1687. const desiredIssueType = issueTypes.find((t) => t.name === desiredIssueTypeName);
  1688. if (
  1689. issueNode?.id &&
  1690. desiredIssueType?.id &&
  1691. issueNode.issueType?.id !== desiredIssueType.id
  1692. ) {
  1693. await github.graphql(
  1694. `mutation($id:ID!,$issueTypeId:ID!){
  1695. updateIssue(input:{id:$id,issueTypeId:$issueTypeId}){
  1696. issue { id number issueType { id name } }
  1697. }
  1698. }`,
  1699. { id: issueNode.id, issueTypeId: desiredIssueType.id }
  1700. );
  1701. issueTypeSet = desiredIssueTypeName;
  1702. console.log(`#${rawIssue.number}: set Issue Type to ${desiredIssueTypeName}`);
  1703. }
  1704. } catch (err) {
  1705. if (isApiRateLimitError(err)) throw err;
  1706. console.log(`#${rawIssue.number}: could not sync Issue Type: ${err.message}`);
  1707. }
  1708. }
  1709. }
  1710. // Commands
  1711. const commandSection = extractSection(body, 'Command');
  1712. const desiredCommands = parseCommandSelections(commandSection);
  1713. if (desiredCommands.size > 0) {
  1714. for (const label of desiredCommands) labelsToAdd.add(label);
  1715. for (const label of existingLabels) {
  1716. if (label.startsWith('command: ') && !desiredCommands.has(label)) labelsToRemove.add(label);
  1717. }
  1718. }
  1719. // Distros
  1720. const distroSection = extractSection(body, 'Linux distro');
  1721. const desiredDistros = parseDistroSelections(distroSection);
  1722. if (desiredDistros.size > 0) {
  1723. for (const label of desiredDistros) labelsToAdd.add(label);
  1724. for (const label of existingLabels) {
  1725. if (label.startsWith('distro: ') && !desiredDistros.has(label)) labelsToRemove.add(label);
  1726. }
  1727. }
  1728. // Tmux false positive cleanup
  1729. if (
  1730. existingLabels.has('info: tmux') &&
  1731. !/\b(tmuxception|check_tmuxception)\b/i.test(`${title}\n${body}`)
  1732. ) {
  1733. labelsToRemove.add('info: tmux');
  1734. }
  1735. // Games and engines
  1736. const desiredGames = new Set();
  1737. const gameLabelSource = new Map(); // label → 'form-field' | 'text-match' | 'ai-fallback'
  1738. const desiredServerScripts = new Set();
  1739. // 'Game server' is the section name in server_request.yml; 'Game' is used in bug_report.yml.
  1740. const gameField = extractSection(body, 'Game server') || extractSection(body, 'Game');
  1741. const gameCandidates = parseGameCandidates(gameField);
  1742. const hasStructuredGameSelection = gameCandidates.length > 0;
  1743. for (const candidate of gameCandidates) {
  1744. const normalizedCandidate = normalizeName(candidate);
  1745. const mapped =
  1746. gameAliasToLabel.get(normalizedCandidate) || gameLabelByNormalized.get(normalizedCandidate);
  1747. if (mapped) {
  1748. desiredGames.add(mapped);
  1749. gameLabelSource.set(mapped, 'form-field');
  1750. }
  1751. const mappedScript = gameAliasToScript.get(normalizedCandidate);
  1752. if (mappedScript) desiredServerScripts.add(mappedScript);
  1753. }
  1754. // Legacy issues often have no form section; fall back to deterministic text matching.
  1755. if (desiredGames.size === 0) {
  1756. const fromText = findGamesFromText(`${title}\n${body}`, gameAliasToLabel, gameAliasToScript);
  1757. for (const label of fromText.labels) {
  1758. desiredGames.add(label);
  1759. gameLabelSource.set(label, 'text-match');
  1760. }
  1761. for (const scriptName of fromText.scripts) desiredServerScripts.add(scriptName);
  1762. }
  1763. // Optional AI fallback for legacy issues where deterministic matching finds nothing.
  1764. if (useAiGameFallback && desiredGames.size === 0) {
  1765. if (aiFallbackDisabledReason) {
  1766. console.log(`#${rawIssue.number}: AI fallback skipped (${aiFallbackDisabledReason})`);
  1767. } else {
  1768. aiGameAttempts += 1;
  1769. const aiPayload = {
  1770. model: 'openai/gpt-4.1-mini',
  1771. temperature: 0.1,
  1772. max_tokens: 120,
  1773. messages: [
  1774. {
  1775. role: 'system',
  1776. content:
  1777. 'Return JSON only. Identify the specific game referenced in this LinuxGSM issue with high precision. ' +
  1778. 'If only generic platform/engine terms are present (e.g. srcds, source dedicated server, steamcmd), return detected_game as null.',
  1779. },
  1780. {
  1781. role: 'user',
  1782. content:
  1783. `Title: ${title}\n\nBody:\n${body.slice(0, 2500)}\n\n` +
  1784. 'Return JSON: {"detected_game":"string or null","game_confidence":"high|medium|low|null"}',
  1785. },
  1786. ],
  1787. };
  1788. const aiUrl = `https://models.github.ai/orgs/${owner}/inference/chat/completions`;
  1789. const aiHeaders = {
  1790. Accept: 'application/vnd.github+json',
  1791. Authorization: `Bearer ${process.env.GITHUB_TOKEN}`,
  1792. 'X-GitHub-Api-Version': '2026-03-10',
  1793. 'Content-Type': 'application/json',
  1794. };
  1795. try {
  1796. let res = await fetch(aiUrl, { method: 'POST', headers: aiHeaders, body: JSON.stringify(aiPayload) });
  1797. // On 429 honour Retry-After (capped at 60 s) then retry once.
  1798. if (res.status === 429) {
  1799. aiGameRateLimited += 1;
  1800. const rateInfo = parseAiRateLimitInfo(res);
  1801. const rawRetryAfter = Number.parseInt(rateInfo.retryAfter || '10', 10);
  1802. const retryAfter = Math.min(Number.isFinite(rawRetryAfter) ? rawRetryAfter : 10, 60);
  1803. if (Number.isFinite(rawRetryAfter) && rawRetryAfter > 300) {
  1804. aiFallbackDisabledReason = `global cooldown active (retry-after=${rawRetryAfter}s)`;
  1805. console.log(
  1806. `#${rawIssue.number}: AI fallback disabled for remaining run (${aiFallbackDisabledReason}; ${formatAiRateLimitInfo(rateInfo)})`
  1807. );
  1808. } else {
  1809. console.log(
  1810. `#${rawIssue.number}: AI fallback rate-limited - waiting ${retryAfter}s then retrying (${formatAiRateLimitInfo(rateInfo)})`
  1811. );
  1812. await new Promise((r) => setTimeout(r, retryAfter * 1000));
  1813. res = await fetch(aiUrl, { method: 'POST', headers: aiHeaders, body: JSON.stringify(aiPayload) });
  1814. }
  1815. }
  1816. if (res.ok) {
  1817. const data = await res.json();
  1818. const raw = data.choices?.[0]?.message?.content || '{}';
  1819. const parsed = parseAiGameResponse(raw);
  1820. const detectedGame = normalizeName(parsed?.detected_game || '');
  1821. const confidence = (parsed?.game_confidence || '').toLowerCase();
  1822. if (detectedGame && confidence === 'high') {
  1823. const mappedLabel =
  1824. gameAliasToLabel.get(detectedGame) || gameLabelByNormalized.get(detectedGame);
  1825. if (mappedLabel) {
  1826. const hasAliasEvidence = hasAliasHitForLabel(
  1827. `${title}\n${body}`,
  1828. mappedLabel,
  1829. gameAliasToLabel
  1830. );
  1831. if (hasAliasEvidence) {
  1832. desiredGames.add(mappedLabel);
  1833. gameLabelSource.set(mappedLabel, 'ai-fallback');
  1834. const mappedScript = gameAliasToScript.get(detectedGame);
  1835. if (mappedScript) desiredServerScripts.add(mappedScript);
  1836. aiGameMatches += 1;
  1837. console.log(
  1838. `#${rawIssue.number}: AI fallback accepted game "${mappedLabel}" from "${parsed?.detected_game}"`
  1839. );
  1840. } else {
  1841. console.log(
  1842. `#${rawIssue.number}: AI fallback rejected game "${mappedLabel}" (no literal alias evidence in issue text)`
  1843. );
  1844. }
  1845. } else {
  1846. if (isGenericNonGameDetection(parsed?.detected_game || '')) {
  1847. console.log(
  1848. `#${rawIssue.number}: AI fallback skipped generic non-game detection "${parsed?.detected_game}"`
  1849. );
  1850. } else {
  1851. console.log(
  1852. `#${rawIssue.number}: AI fallback returned unmapped game "${parsed?.detected_game}"`
  1853. );
  1854. }
  1855. }
  1856. }
  1857. } else {
  1858. if (res.status === 429) {
  1859. const rateInfo = parseAiRateLimitInfo(res);
  1860. console.log(
  1861. `#${rawIssue.number}: AI fallback skipped (HTTP 429, ${formatAiRateLimitInfo(rateInfo)})`
  1862. );
  1863. } else {
  1864. console.log(`#${rawIssue.number}: AI fallback skipped (HTTP ${res.status})`);
  1865. }
  1866. }
  1867. } catch (err) {
  1868. console.log(`#${rawIssue.number}: AI fallback error: ${err.message}`);
  1869. }
  1870. }
  1871. }
  1872. for (const gameLabel of desiredGames) {
  1873. const mappedScript = gameAliasToScript.get(normalizeName(gameLabel.slice(6)));
  1874. if (mappedScript) desiredServerScripts.add(mappedScript);
  1875. }
  1876. const desiredEngineLabels = new Set();
  1877. for (const scriptName of desiredServerScripts) {
  1878. const engine = await getEngineForScript(scriptName);
  1879. if (!engine) continue;
  1880. const engineLabel = `engine: ${engine}`;
  1881. await ensureEngineLabel(engineLabel);
  1882. desiredEngineLabels.add(engineLabel);
  1883. }
  1884. if (desiredEngineLabels.size > 0) {
  1885. for (const label of desiredEngineLabels) labelsToAdd.add(label);
  1886. for (const label of existingLabels) {
  1887. if (label.startsWith('engine: ') && !desiredEngineLabels.has(label)) labelsToRemove.add(label);
  1888. }
  1889. }
  1890. if (desiredGames.size > 0) {
  1891. for (const label of desiredGames) labelsToAdd.add(label);
  1892. if (hasStructuredGameSelection) {
  1893. for (const label of existingLabels) {
  1894. if (label.startsWith('game: ') && !desiredGames.has(label)) labelsToRemove.add(label);
  1895. }
  1896. } else {
  1897. // For legacy issues without structured game selection, only prune stale
  1898. // broader labels when a more specific inferred game label exists.
  1899. const desiredGameNamesNormalized = new Set(
  1900. [...desiredGames].map((label) => normalizeName(label.slice(6)))
  1901. );
  1902. for (const label of existingLabels) {
  1903. if (!label.startsWith('game: ') || desiredGames.has(label)) continue;
  1904. const existingGameName = normalizeName(label.slice(6));
  1905. const isBroaderOverlap = [...desiredGameNamesNormalized].some(
  1906. (desiredName) => desiredName !== existingGameName && desiredName.startsWith(`${existingGameName} `)
  1907. );
  1908. if (isBroaderOverlap) labelsToRemove.add(label);
  1909. }
  1910. }
  1911. }
  1912. // Apply changes
  1913. const finalAdds = [...labelsToAdd].filter((label) => !existingLabels.has(label));
  1914. const finalRemoves = [...labelsToRemove].filter((label) => existingLabels.has(label));
  1915. let labelAdded = 0;
  1916. let labelRemoved = 0;
  1917. for (const label of finalRemoves) {
  1918. try {
  1919. await github.rest.issues.removeLabel({
  1920. owner,
  1921. repo,
  1922. issue_number: rawIssue.number,
  1923. name: label,
  1924. });
  1925. labelRemoved += 1;
  1926. console.log(`#${rawIssue.number}: removed "${label}"`);
  1927. } catch (err) {
  1928. if (isApiRateLimitError(err)) throw err;
  1929. console.log(`#${rawIssue.number}: could not remove "${label}": ${err.message}`);
  1930. }
  1931. }
  1932. for (const label of finalAdds) {
  1933. try {
  1934. await github.rest.issues.addLabels({
  1935. owner,
  1936. repo,
  1937. issue_number: rawIssue.number,
  1938. labels: [label],
  1939. });
  1940. labelAdded += 1;
  1941. const gameSource = gameLabelSource.get(label);
  1942. console.log(`#${rawIssue.number}: added "${label}"${gameSource ? ` (${gameSource})` : ''}`);
  1943. } catch (err) {
  1944. if (isApiRateLimitError(err)) throw err;
  1945. console.log(`#${rawIssue.number}: could not add "${label}": ${err.message}`);
  1946. }
  1947. }
  1948. processed += 1;
  1949. processedIssues.push({
  1950. number: rawIssue.number,
  1951. title: rawIssue.title,
  1952. adds: labelAdded,
  1953. removes: labelRemoved,
  1954. issueTypeSet,
  1955. locked: isLocked,
  1956. });
  1957. console.log(
  1958. `#${rawIssue.number}: done (+${labelAdded} added, -${labelRemoved} removed${
  1959. issueTypeSet ? `, type→${issueTypeSet}` : ''
  1960. }${isLocked ? ', locked' : ''})`
  1961. );
  1962. } catch (err) {
  1963. if (isApiRateLimitError(err)) {
  1964. stoppedForApiRateLimit = true;
  1965. apiRateLimitStopReason = formatApiRateLimitError(err);
  1966. console.log(
  1967. `Stopping backfill due to API rate limit at #${rawIssue.number} (${apiRateLimitStopReason})`
  1968. );
  1969. failedIssues.push({
  1970. number: rawIssue.number,
  1971. title: rawIssue.title,
  1972. stage: 'rate-limit',
  1973. error: err.message,
  1974. });
  1975. break;
  1976. } else {
  1977. console.log(`Error processing #${rawIssue.number}: ${err.message}`);
  1978. failedIssues.push({
  1979. number: rawIssue.number,
  1980. title: rawIssue.title,
  1981. stage: 'process',
  1982. error: err.message,
  1983. });
  1984. }
  1985. }
  1986. }
  1987. console.log(
  1988. `Relabel backfill complete: ${processed} processed, ${failedIssues.length} failed${
  1989. stoppedForApiRateLimit ? `, stopped early (${apiRateLimitStopReason})` : ''
  1990. }.`
  1991. );
  1992. await core.summary
  1993. .addHeading('Relabel Backfill Summary')
  1994. .addTable([
  1995. [
  1996. { data: 'Requested state', header: true },
  1997. { data: 'Limit', header: true },
  1998. { data: 'AI fallback', header: true },
  1999. { data: 'AI attempts', header: true },
  2000. { data: 'AI matches', header: true },
  2001. { data: 'AI 429s', header: true },
  2002. { data: 'AI disabled reason', header: true },
  2003. { data: 'Stopped early', header: true },
  2004. { data: 'Target issues', header: true },
  2005. { data: 'Processed', header: true },
  2006. { data: 'Failures', header: true },
  2007. ],
  2008. [
  2009. state,
  2010. limit === 0 ? 'all' : String(limit),
  2011. useAiGameFallback ? 'enabled' : 'disabled',
  2012. String(aiGameAttempts),
  2013. String(aiGameMatches),
  2014. String(aiGameRateLimited),
  2015. aiFallbackDisabledReason || '—',
  2016. stoppedForApiRateLimit ? apiRateLimitStopReason : 'no',
  2017. String(selectedTargets.length),
  2018. String(processed),
  2019. String(failedIssues.length),
  2020. ],
  2021. ])
  2022. .write();
  2023. if (processedIssues.length > 0) {
  2024. const processedRows = processedIssues.slice(0, 50).map((issue) => [
  2025. `#${issue.number}${issue.locked ? ' 🔒' : ''}`,
  2026. `[${issue.title}](https://github.com/${owner}/${repo}/issues/${issue.number})`,
  2027. `+${issue.adds} / -${issue.removes}`,
  2028. issue.issueTypeSet || '—',
  2029. ]);
  2030. await core.summary
  2031. .addHeading('Processed Issues')
  2032. .addTable([
  2033. [
  2034. { data: 'Issue', header: true },
  2035. { data: 'Title', header: true },
  2036. { data: 'Label changes', header: true },
  2037. { data: 'Issue Type set', header: true },
  2038. ],
  2039. ...processedRows,
  2040. ])
  2041. .write();
  2042. }
  2043. if (failedIssues.length > 0) {
  2044. const failureRows = failedIssues.slice(0, 50).map((issue) => [
  2045. `#${issue.number}`,
  2046. issue.stage,
  2047. issue.error,
  2048. ]);
  2049. await core.summary
  2050. .addHeading('Failures')
  2051. .addTable([
  2052. [
  2053. { data: 'Issue', header: true },
  2054. { data: 'Stage', header: true },
  2055. { data: 'Error', header: true },
  2056. ],
  2057. ...failureRows,
  2058. ])
  2059. .write();
  2060. }
  2061. pr-labeler:
  2062. if: github.repository_owner == 'GameServerManagers' && github.event_name == 'pull_request'
  2063. runs-on: ubuntu-latest
  2064. steps:
  2065. - name: PR Labeler
  2066. uses: github/issue-labeler@v3.4
  2067. with:
  2068. repo-token: "${{ secrets.GITHUB_TOKEN }}"
  2069. configuration-path: .github/labeler.yml
  2070. enable-versioned-regex: 0
  2071. include-title: 1
  2072. include-body: 0
  2073. sync-labels: 1
  2074. is-sponsor-label:
  2075. if: github.repository_owner == 'GameServerManagers' && github.event_name == 'issues' && github.event.action == 'opened'
  2076. runs-on: ubuntu-latest
  2077. steps:
  2078. - name: Is Sponsor Label
  2079. uses: JasonEtco/is-sponsor-label-action@v2
  2080. env:
  2081. GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
  2082. sync-game-labels:
  2083. if: github.repository_owner == 'GameServerManagers' && github.event_name == 'push' && contains(github.event.head_commit.modified, 'lgsm/data/serverlist.csv')
  2084. runs-on: ubuntu-latest
  2085. steps:
  2086. - name: Checkout
  2087. uses: actions/checkout@v5
  2088. - name: Sync game labels from serverlist
  2089. env:
  2090. GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
  2091. run: |
  2092. chmod +x .github/scripts/sync-game-labels.sh
  2093. .github/scripts/sync-game-labels.sh