gameservergame-servergame-servershacktoberfestdedicated-game-serversgamelinuxgsmserverbashgaminglinuxmultiplayer-game-servershell
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
1836 lines
77 KiB
1836 lines
77 KiB
name: Unified Labeling
|
|
on:
|
|
workflow_dispatch:
|
|
inputs:
|
|
issue_state:
|
|
description: Issue state to backfill
|
|
required: true
|
|
default: all
|
|
type: choice
|
|
options:
|
|
- all
|
|
- open
|
|
- closed
|
|
limit:
|
|
description: Max issues to process (0 = all)
|
|
required: true
|
|
default: "0"
|
|
type: string
|
|
ai_game_fallback:
|
|
description: Use AI only when deterministic game mapping finds no game
|
|
required: true
|
|
default: "false"
|
|
type: choice
|
|
options:
|
|
- "false"
|
|
- "true"
|
|
issues:
|
|
types:
|
|
- opened
|
|
- edited
|
|
- reopened
|
|
- labeled
|
|
- unlabeled
|
|
- assigned
|
|
- unassigned
|
|
- milestoned
|
|
- demilestoned
|
|
- transferred
|
|
- pinned
|
|
- unpinned
|
|
issue_comment:
|
|
types:
|
|
- created
|
|
- edited
|
|
- deleted
|
|
pull_request:
|
|
types:
|
|
- opened
|
|
- edited
|
|
- synchronize
|
|
- reopened
|
|
|
|
permissions:
|
|
issues: write
|
|
pull-requests: write
|
|
contents: read
|
|
models: read
|
|
|
|
env:
|
|
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
|
|
|
jobs:
|
|
issue-regex-labeler:
|
|
if: github.repository_owner == 'GameServerManagers' && github.event_name == 'issues' && (github.event.action == 'opened' || github.event.action == 'edited')
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- name: Issue Labeler
|
|
uses: github/[email protected]
|
|
with:
|
|
repo-token: "${{ secrets.GITHUB_TOKEN }}"
|
|
configuration-path: .github/labeler.yml
|
|
enable-versioned-regex: 0
|
|
include-title: 1
|
|
sync-labels: 0
|
|
|
|
issue-ai-maintenance:
|
|
if: github.repository_owner == 'GameServerManagers' && (github.event_name == 'issues' || github.event_name == 'issue_comment')
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- name: Reconcile issue labels and AI triage
|
|
uses: actions/github-script@v9
|
|
env:
|
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
with:
|
|
script: |
|
|
const owner = context.repo.owner;
|
|
const repo = context.repo.repo;
|
|
const eventName = context.eventName;
|
|
const action = context.payload.action;
|
|
const issueNumber = context.payload.issue?.number;
|
|
const AI_MARKER = '<!-- ai-triage -->';
|
|
|
|
if (!issueNumber) {
|
|
console.log('No issue number found in payload.');
|
|
return;
|
|
}
|
|
|
|
// Avoid bot-to-bot relabel loops on label events.
|
|
if (
|
|
eventName === 'issues' &&
|
|
['labeled', 'unlabeled'].includes(action) &&
|
|
context.actor === 'github-actions[bot]'
|
|
) {
|
|
console.log('Skipping self-triggered label event.');
|
|
return;
|
|
}
|
|
|
|
const issueResp = await github.rest.issues.get({
|
|
owner,
|
|
repo,
|
|
issue_number: issueNumber,
|
|
});
|
|
const issue = issueResp.data;
|
|
const title = issue.title || '';
|
|
const body = issue.body || '';
|
|
const existingLabels = new Set((issue.labels || []).map((l) => l.name).filter(Boolean));
|
|
|
|
function parseTriageResponse(raw) {
|
|
const input = (raw || '').trim();
|
|
if (!input) return {};
|
|
|
|
const candidates = [input];
|
|
const fenced = input.match(/```(?:json)?\s*([\s\S]*?)```/i);
|
|
if (fenced?.[1]) candidates.push(fenced[1].trim());
|
|
|
|
const firstBrace = input.indexOf('{');
|
|
const lastBrace = input.lastIndexOf('}');
|
|
if (firstBrace !== -1 && lastBrace > firstBrace) {
|
|
candidates.push(input.slice(firstBrace, lastBrace + 1));
|
|
}
|
|
|
|
for (const candidate of candidates) {
|
|
try {
|
|
return JSON.parse(candidate);
|
|
} catch (_err) {
|
|
// Continue trying fallbacks.
|
|
}
|
|
}
|
|
|
|
return {};
|
|
}
|
|
|
|
function extractSection(sectionName) {
|
|
const escaped = sectionName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
const re = new RegExp(`### ${escaped}\\n\\n([\\s\\S]*?)(\\n### |$)`, 'i');
|
|
return (body.match(re)?.[1] || '').trim();
|
|
}
|
|
|
|
function normalizeName(value) {
|
|
return (value || '')
|
|
.toLowerCase()
|
|
.replace(/[’'`]/g, '')
|
|
.replace(/[^a-z0-9]+/g, ' ')
|
|
.trim();
|
|
}
|
|
|
|
function parseGameCandidates(gameField) {
|
|
if (!gameField || /^_?no response_?$/i.test(gameField)) {
|
|
return [];
|
|
}
|
|
return gameField
|
|
.replace(/\(.*?\)/g, ' ')
|
|
.split(/\n|,|\s+&\s+|\s+and\s+|\//i)
|
|
.map((v) => v.trim())
|
|
.filter(Boolean);
|
|
}
|
|
|
|
function findGamesFromText(text, gameAliasToLabel, gameAliasToScript) {
|
|
const labels = new Set();
|
|
const scripts = new Set();
|
|
const normalizedText = normalizeName(text);
|
|
if (!normalizedText) return { labels, scripts };
|
|
|
|
const escapeRegex = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
const aliases = [];
|
|
for (const [alias, label] of gameAliasToLabel.entries()) {
|
|
if (alias.length < 3) continue;
|
|
aliases.push({ alias, label, script: gameAliasToScript.get(alias) || null });
|
|
}
|
|
|
|
// Prefer longer aliases first so "killing floor 2" does not also match "killing floor".
|
|
aliases.sort((a, b) => b.alias.length - a.alias.length);
|
|
|
|
const usedRanges = [];
|
|
const isOverlapping = (start, end) =>
|
|
usedRanges.some((range) => start < range.end && end > range.start);
|
|
|
|
for (const entry of aliases) {
|
|
const pattern = new RegExp(`\\b${escapeRegex(entry.alias).replace(/\\ /g, '\\s+')}\\b`, 'g');
|
|
let match;
|
|
while ((match = pattern.exec(normalizedText)) !== null) {
|
|
const start = match.index;
|
|
const end = start + match[0].length;
|
|
if (isOverlapping(start, end)) continue;
|
|
|
|
labels.add(entry.label);
|
|
if (entry.script) scripts.add(entry.script);
|
|
usedRanges.push({ start, end });
|
|
}
|
|
}
|
|
|
|
return { labels, scripts };
|
|
}
|
|
|
|
function hasAliasHitForLabel(text, targetLabel, gameAliasToLabel) {
|
|
const normalizedText = normalizeName(text);
|
|
if (!normalizedText || !targetLabel) return false;
|
|
|
|
const paddedText = ` ${normalizedText} `;
|
|
for (const [alias, label] of gameAliasToLabel.entries()) {
|
|
if (label !== targetLabel) continue;
|
|
if (alias.length < 3) continue;
|
|
if (paddedText.includes(` ${alias} `)) return true;
|
|
|
|
// Allow obvious joined-word variants for multi-token aliases
|
|
// (e.g., "counter strike 1 6" matching "counterstrike 1.6").
|
|
const aliasTokens = alias.split(/\s+/).filter(Boolean);
|
|
if (aliasTokens.length > 1) {
|
|
const escapedTokens = aliasTokens.map((token) =>
|
|
token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
);
|
|
const flexibleAliasPattern = new RegExp(`\\b${escapedTokens.join('\\s*')}\\b`);
|
|
if (flexibleAliasPattern.test(normalizedText)) return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
function parseServerlistCsv(csvText) {
|
|
const rows = [];
|
|
const lines = (csvText || '').split(/\r?\n/);
|
|
for (let i = 1; i < lines.length; i += 1) {
|
|
const line = lines[i]?.trim();
|
|
if (!line) continue;
|
|
const parts = line.split(',');
|
|
if (parts.length < 3) continue;
|
|
rows.push({
|
|
shortname: parts[0].trim(),
|
|
gameservername: parts[1].trim(),
|
|
gamename: parts[2].trim(),
|
|
});
|
|
}
|
|
return rows;
|
|
}
|
|
function inferTypeFromTitle(issueTitle) {
|
|
if (/^\[bug\]/i.test(issueTitle)) return 'type: bug';
|
|
if (/\bserver\s+request\b/i.test(issueTitle)) return 'type: game server request';
|
|
const hasBracketPrefix = /^\[[^\]]+\]/.test(issueTitle || '');
|
|
const isServerCreation =
|
|
/\bserver\s+creation\b/i.test(issueTitle) ||
|
|
(hasBracketPrefix && /\bcreation\b/i.test(issueTitle));
|
|
const isServerSupportRequest =
|
|
/\bserver\s+support\b/i.test(issueTitle) ||
|
|
(/\bsupport\s+for\b/i.test(issueTitle) && /\bserver\b/i.test(issueTitle));
|
|
if (isServerCreation || isServerSupportRequest) return 'type: game server request';
|
|
if (/^\[feature\]/i.test(issueTitle)) return 'type: feature';
|
|
if (/^\[server request\]/i.test(issueTitle)) return 'type: game server request';
|
|
if (/^\[docs?\]/i.test(issueTitle)) return 'type: docs';
|
|
return null;
|
|
}
|
|
|
|
function inferDesiredType(issueTitle, labelNames) {
|
|
const titleType = inferTypeFromTitle(issueTitle);
|
|
if (titleType) return titleType;
|
|
|
|
// Prefer server requests over generic feature when both labels exist.
|
|
if (labelNames.has('type: game server request')) return 'type: game server request';
|
|
|
|
for (const label of [
|
|
'type: bug',
|
|
'type: feature',
|
|
'type: game server request',
|
|
'type: docs',
|
|
]) {
|
|
if (labelNames.has(label)) return label;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function inferIssueTypeNameFromDesiredType(typeLabel) {
|
|
if (typeLabel === 'type: bug') return 'Bug';
|
|
if (typeLabel === 'type: feature') return 'Feature';
|
|
if (typeLabel === 'type: game server request') return 'Server Request';
|
|
if (typeLabel === 'type: docs') return 'Task';
|
|
return null;
|
|
}
|
|
|
|
function parseCommandSelections(sectionValue) {
|
|
const selected = new Set();
|
|
const re = /command:\s*([a-z-]+)/gi;
|
|
let m;
|
|
while ((m = re.exec(sectionValue || '')) !== null) {
|
|
let value = m[1].toLowerCase();
|
|
if (value.startsWith('mods-')) value = 'mods';
|
|
if (value === 'auto-update') value = 'update';
|
|
selected.add(`command: ${value}`);
|
|
}
|
|
return selected;
|
|
}
|
|
|
|
function parseDistroSelections(sectionValue) {
|
|
const text = sectionValue || '';
|
|
const selected = new Set();
|
|
if (/\bUbuntu\b/i.test(text)) selected.add('distro: Ubuntu');
|
|
if (/\bDebian\b/i.test(text)) selected.add('distro: Debian');
|
|
if (/\bAlmaLinux\b/i.test(text)) selected.add('distro: AlmaLinux');
|
|
if (/\bRocky\b/i.test(text)) selected.add('distro: Rocky Linux');
|
|
if (/\bCentOS\b/i.test(text)) selected.add('distro: CentOS');
|
|
if (/\bFedora\b/i.test(text)) selected.add('distro: Fedora');
|
|
if (/\bopenSUSE\b/i.test(text)) selected.add('distro: openSUSE');
|
|
if (/\bArch Linux\b/i.test(text)) selected.add('distro: Arch Linux');
|
|
if (/\bSlackware\b/i.test(text)) selected.add('distro: Slackware');
|
|
return selected;
|
|
}
|
|
|
|
const repoLabels = await github.paginate(github.rest.issues.listLabelsForRepo, {
|
|
owner,
|
|
repo,
|
|
per_page: 100,
|
|
});
|
|
|
|
const gameLabelByNormalized = new Map();
|
|
for (const label of repoLabels) {
|
|
if (!label.name.startsWith('game: ')) continue;
|
|
gameLabelByNormalized.set(normalizeName(label.name.slice(6)), label.name);
|
|
}
|
|
const existingEngineLabels = new Set(
|
|
repoLabels.map((label) => label.name).filter((name) => name.startsWith('engine: '))
|
|
);
|
|
|
|
const gameAliasToLabel = new Map();
|
|
const gameAliasToScript = new Map();
|
|
const engineByScript = new Map();
|
|
for (const [normalizedGameName, label] of gameLabelByNormalized.entries()) {
|
|
gameAliasToLabel.set(normalizedGameName, label);
|
|
}
|
|
|
|
try {
|
|
const serverlistContent = await github.rest.repos.getContent({
|
|
owner,
|
|
repo,
|
|
path: 'lgsm/data/serverlist.csv',
|
|
});
|
|
const encoded = serverlistContent.data?.content || '';
|
|
const csvText = Buffer.from(encoded, 'base64').toString('utf8');
|
|
const serverRows = parseServerlistCsv(csvText);
|
|
|
|
for (const row of serverRows) {
|
|
const canonicalLabel = gameLabelByNormalized.get(normalizeName(row.gamename));
|
|
if (!canonicalLabel) continue;
|
|
|
|
for (const alias of [row.shortname, row.gameservername, row.gamename]) {
|
|
const key = normalizeName(alias);
|
|
if (!key) continue;
|
|
gameAliasToLabel.set(key, canonicalLabel);
|
|
gameAliasToScript.set(key, row.gameservername);
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.log(`Could not load serverlist aliases: ${err.message}`);
|
|
}
|
|
|
|
async function ensureEngineLabel(engineLabel) {
|
|
if (existingEngineLabels.has(engineLabel)) return;
|
|
try {
|
|
await github.rest.issues.createLabel({
|
|
owner,
|
|
repo,
|
|
name: engineLabel,
|
|
color: '000000',
|
|
description: `Issues related to ${engineLabel.slice(8)} engine`,
|
|
});
|
|
existingEngineLabels.add(engineLabel);
|
|
} catch (err) {
|
|
if (err.status === 422) {
|
|
existingEngineLabels.add(engineLabel);
|
|
return;
|
|
}
|
|
console.log(`Could not create engine label "${engineLabel}": ${err.message}`);
|
|
}
|
|
}
|
|
|
|
async function getEngineForScript(scriptName) {
|
|
if (!scriptName) return null;
|
|
if (engineByScript.has(scriptName)) {
|
|
return engineByScript.get(scriptName);
|
|
}
|
|
try {
|
|
const cfgContent = await github.rest.repos.getContent({
|
|
owner,
|
|
repo,
|
|
path: `lgsm/config-default/config-lgsm/${scriptName}/_default.cfg`,
|
|
});
|
|
const encoded = cfgContent.data?.content || '';
|
|
const cfgText = Buffer.from(encoded, 'base64').toString('utf8');
|
|
const engine = cfgText.match(/^engine="([^"]+)"/m)?.[1] || null;
|
|
engineByScript.set(scriptName, engine);
|
|
return engine;
|
|
} catch (err) {
|
|
console.log(`Could not detect engine for ${scriptName}: ${err.message}`);
|
|
engineByScript.set(scriptName, null);
|
|
return null;
|
|
}
|
|
}
|
|
const labelsToAdd = new Set();
|
|
const labelsToRemove = new Set();
|
|
|
|
// Deterministic reconciliation on every interaction.
|
|
const desiredType = inferDesiredType(title, existingLabels);
|
|
if (desiredType) {
|
|
labelsToAdd.add(desiredType);
|
|
for (const label of existingLabels) {
|
|
if (label.startsWith('type: ') && label !== desiredType) {
|
|
labelsToRemove.add(label);
|
|
}
|
|
}
|
|
|
|
const desiredIssueTypeName = inferIssueTypeNameFromDesiredType(desiredType);
|
|
if (desiredIssueTypeName) {
|
|
try {
|
|
const issueTypeData = await github.graphql(
|
|
`query($owner:String!,$repo:String!,$number:Int!){
|
|
repository(owner:$owner,name:$repo){
|
|
issueTypes(first:20){ nodes { id name } }
|
|
issue(number:$number){ id issueType { id name } }
|
|
}
|
|
}`,
|
|
{ owner, repo, number: issueNumber }
|
|
);
|
|
|
|
const issueNode = issueTypeData.repository?.issue;
|
|
const issueTypes = issueTypeData.repository?.issueTypes?.nodes || [];
|
|
const desiredIssueType = issueTypes.find((t) => t.name === desiredIssueTypeName);
|
|
|
|
if (issueNode?.id && desiredIssueType?.id && issueNode.issueType?.id !== desiredIssueType.id) {
|
|
await github.graphql(
|
|
`mutation($id:ID!,$issueTypeId:ID!){
|
|
updateIssue(input:{id:$id,issueTypeId:$issueTypeId}){
|
|
issue { id number issueType { id name } }
|
|
}
|
|
}`,
|
|
{ id: issueNode.id, issueTypeId: desiredIssueType.id }
|
|
);
|
|
}
|
|
} catch (err) {
|
|
console.log(`Could not sync Issue Type: ${err.message}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
const commandSection = extractSection('Command');
|
|
const desiredCommands = parseCommandSelections(commandSection);
|
|
if (desiredCommands.size > 0) {
|
|
for (const label of desiredCommands) labelsToAdd.add(label);
|
|
for (const label of existingLabels) {
|
|
if (label.startsWith('command: ') && !desiredCommands.has(label)) {
|
|
labelsToRemove.add(label);
|
|
}
|
|
}
|
|
}
|
|
|
|
const distroSection = extractSection('Linux distro');
|
|
const desiredDistros = parseDistroSelections(distroSection);
|
|
if (desiredDistros.size > 0) {
|
|
for (const label of desiredDistros) labelsToAdd.add(label);
|
|
for (const label of existingLabels) {
|
|
if (label.startsWith('distro: ') && !desiredDistros.has(label)) {
|
|
labelsToRemove.add(label);
|
|
}
|
|
}
|
|
}
|
|
|
|
const tmuxContextPattern = /\b(tmuxception|check_tmuxception)\b/i;
|
|
if (existingLabels.has('info: tmux') && !tmuxContextPattern.test(`${title}\n${body}`)) {
|
|
labelsToRemove.add('info: tmux');
|
|
}
|
|
|
|
const desiredGames = new Set();
|
|
const desiredServerScripts = new Set();
|
|
const gameField = extractSection('Game');
|
|
const gameCandidates = parseGameCandidates(gameField);
|
|
const hasStructuredGameSelection = gameCandidates.length > 0;
|
|
for (const candidate of gameCandidates) {
|
|
const normalizedCandidate = normalizeName(candidate);
|
|
const mapped = gameAliasToLabel.get(normalizedCandidate) || gameLabelByNormalized.get(normalizedCandidate);
|
|
if (mapped) desiredGames.add(mapped);
|
|
const mappedScript = gameAliasToScript.get(normalizedCandidate);
|
|
if (mappedScript) desiredServerScripts.add(mappedScript);
|
|
}
|
|
|
|
// Legacy issues often have no form section; fall back to deterministic text matching.
|
|
if (desiredGames.size === 0) {
|
|
const fromText = findGamesFromText(`${title}\n${body}`, gameAliasToLabel, gameAliasToScript);
|
|
for (const label of fromText.labels) desiredGames.add(label);
|
|
for (const scriptName of fromText.scripts) desiredServerScripts.add(scriptName);
|
|
}
|
|
|
|
// AI advisory is only needed on issue opened/edited.
|
|
let triage = {};
|
|
let ranAi = false;
|
|
const shouldRunAi = eventName === 'issues' && ['opened', 'edited'].includes(action);
|
|
if (shouldRunAi) {
|
|
ranAi = true;
|
|
|
|
const isShortBody = body.trim().length < 80;
|
|
if (isShortBody) {
|
|
labelsToAdd.add('needs: more info');
|
|
} else {
|
|
try {
|
|
const res = await fetch(
|
|
`https://models.github.ai/orgs/${owner}/inference/chat/completions`,
|
|
{
|
|
method: 'POST',
|
|
headers: {
|
|
Accept: 'application/vnd.github+json',
|
|
Authorization: `Bearer ${process.env.GITHUB_TOKEN}`,
|
|
'X-GitHub-Api-Version': '2026-03-10',
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({
|
|
model: 'openai/gpt-4.1-mini',
|
|
temperature: 0.1,
|
|
max_tokens: 400,
|
|
messages: [
|
|
{
|
|
role: 'system',
|
|
content:
|
|
'You are a triage assistant for LinuxGSM, an open-source Linux game server manager. ' +
|
|
'Return only JSON. Analyze issue quality, suggest missing info, detect game names, and suggest contextual labels ' +
|
|
'only when highly certain. Never set type: docs just because docs links are mentioned.',
|
|
},
|
|
{
|
|
role: 'user',
|
|
content:
|
|
`Title: ${title}\n\nBody:\n${body.slice(0, 3000)}\n\n` +
|
|
'Return JSON schema:\n' +
|
|
'{\n' +
|
|
' "quality": "good" | "ok" | "poor",\n' +
|
|
' "missing_info": ["list of specific missing fields"],\n' +
|
|
' "detected_game": "canonical game name if one is mentioned, or null",\n' +
|
|
' "game_confidence": "high" | "medium" | "low" | null,\n' +
|
|
' "context_labels": ["labels"],\n' +
|
|
' "context_confidence": "high" | "medium" | "low" | null,\n' +
|
|
' "game_note": "string",\n' +
|
|
' "comment": "string"\n' +
|
|
'}',
|
|
},
|
|
],
|
|
}),
|
|
}
|
|
);
|
|
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
const raw = data.choices?.[0]?.message?.content || '{}';
|
|
triage = parseTriageResponse(raw);
|
|
} else {
|
|
console.log(`GitHub Models returned ${res.status} - skipping AI triage.`);
|
|
}
|
|
} catch (err) {
|
|
console.log('AI triage skipped:', err.message);
|
|
}
|
|
}
|
|
}
|
|
|
|
const allowedContextLabels = new Set([
|
|
'type: docs',
|
|
'info: docs',
|
|
'info: dependency',
|
|
'info: docker',
|
|
'info: email',
|
|
'info: query',
|
|
'info: steamcmd',
|
|
'info: systemd',
|
|
'info: website',
|
|
'info: alerts',
|
|
]);
|
|
|
|
const isPoor = triage?.quality === 'poor';
|
|
const missing = Array.isArray(triage?.missing_info) ? triage.missing_info : [];
|
|
const hasIssues = isPoor || missing.length > 0;
|
|
|
|
// Fallback to AI-detected game only if explicit form mapping was unavailable.
|
|
const detectedGame = triage?.detected_game;
|
|
const gameConfidence = triage?.game_confidence;
|
|
if (desiredGames.size === 0 && detectedGame && gameConfidence === 'high') {
|
|
const normalizedDetectedGame = normalizeName(detectedGame);
|
|
const mapped = gameLabelByNormalized.get(normalizedDetectedGame);
|
|
if (mapped) {
|
|
desiredGames.add(mapped);
|
|
}
|
|
const mappedScript = gameAliasToScript.get(normalizedDetectedGame);
|
|
if (mappedScript) desiredServerScripts.add(mappedScript);
|
|
}
|
|
|
|
// Resolve server scripts from canonical game labels when only labels were mapped.
|
|
for (const gameLabel of desiredGames) {
|
|
const gameName = gameLabel.slice(6);
|
|
const mappedScript = gameAliasToScript.get(normalizeName(gameName));
|
|
if (mappedScript) desiredServerScripts.add(mappedScript);
|
|
}
|
|
|
|
const desiredEngineLabels = new Set();
|
|
for (const scriptName of desiredServerScripts) {
|
|
const engine = await getEngineForScript(scriptName);
|
|
if (!engine) continue;
|
|
const engineLabel = `engine: ${engine}`;
|
|
await ensureEngineLabel(engineLabel);
|
|
desiredEngineLabels.add(engineLabel);
|
|
}
|
|
|
|
if (desiredEngineLabels.size > 0) {
|
|
for (const label of desiredEngineLabels) labelsToAdd.add(label);
|
|
for (const label of existingLabels) {
|
|
if (label.startsWith('engine: ') && !desiredEngineLabels.has(label)) {
|
|
labelsToRemove.add(label);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (desiredGames.size > 0) {
|
|
for (const label of desiredGames) labelsToAdd.add(label);
|
|
if (hasStructuredGameSelection) {
|
|
for (const label of existingLabels) {
|
|
if (label.startsWith('game: ') && !desiredGames.has(label)) {
|
|
labelsToRemove.add(label);
|
|
}
|
|
}
|
|
} else {
|
|
// For legacy issues without structured game selection, only prune stale
|
|
// broader labels when a more specific inferred game label exists.
|
|
const desiredGameNamesNormalized = new Set(
|
|
[...desiredGames].map((label) => normalizeName(label.slice(6)))
|
|
);
|
|
for (const label of existingLabels) {
|
|
if (!label.startsWith('game: ') || desiredGames.has(label)) continue;
|
|
const existingGameName = normalizeName(label.slice(6));
|
|
const isBroaderOverlap = [...desiredGameNamesNormalized].some(
|
|
(desiredName) => desiredName !== existingGameName && desiredName.startsWith(`${existingGameName} `)
|
|
);
|
|
if (isBroaderOverlap) {
|
|
labelsToRemove.add(label);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (triage?.context_confidence === 'high') {
|
|
const contextLabels = Array.isArray(triage.context_labels) ? triage.context_labels : [];
|
|
for (const label of contextLabels) {
|
|
if (!allowedContextLabels.has(label)) continue;
|
|
if (
|
|
label === 'type: docs' &&
|
|
(existingLabels.has('type: game server request') || desiredType === 'type: game server request')
|
|
) {
|
|
continue;
|
|
}
|
|
labelsToAdd.add(label);
|
|
}
|
|
}
|
|
|
|
if (ranAi && hasIssues) {
|
|
labelsToAdd.add('needs: more info');
|
|
}
|
|
|
|
if (ranAi && !hasIssues && existingLabels.has('needs: more info')) {
|
|
labelsToRemove.add('needs: more info');
|
|
}
|
|
|
|
// Avoid pointless API calls.
|
|
const finalAdds = [...labelsToAdd].filter((label) => !existingLabels.has(label));
|
|
const finalRemoves = [...labelsToRemove].filter((label) => existingLabels.has(label));
|
|
|
|
for (const label of finalRemoves) {
|
|
try {
|
|
await github.rest.issues.removeLabel({
|
|
owner,
|
|
repo,
|
|
issue_number: issueNumber,
|
|
name: label,
|
|
});
|
|
console.log(`Removed label: ${label}`);
|
|
} catch (err) {
|
|
console.log(`Could not remove label "${label}": ${err.message}`);
|
|
}
|
|
}
|
|
|
|
for (const label of finalAdds) {
|
|
try {
|
|
await github.rest.issues.addLabels({
|
|
owner,
|
|
repo,
|
|
issue_number: issueNumber,
|
|
labels: [label],
|
|
});
|
|
console.log(`Added label: ${label}`);
|
|
} catch (err) {
|
|
console.log(`Could not add label "${label}": ${err.message}`);
|
|
}
|
|
}
|
|
|
|
// Post AI comment only for opened/edited issues when useful.
|
|
if (ranAi) {
|
|
const gameNote = triage?.game_note || '';
|
|
const reporterComment = triage?.comment || '';
|
|
|
|
if (hasIssues || gameNote) {
|
|
const missingBlock = missing.length > 0
|
|
? `\n\n**Missing information:**\n${missing.map((m) => `- ${m}`).join('\n')}`
|
|
: '';
|
|
const gameBlock = gameNote ? `\n\n**Game name note:** ${gameNote}` : '';
|
|
|
|
const triageCommentBody =
|
|
`${AI_MARKER}\n` +
|
|
`Thanks for opening this issue!\n\n` +
|
|
`${reporterComment}` +
|
|
`${missingBlock}` +
|
|
`${gameBlock}\n\n` +
|
|
`_This note was generated automatically by AI triage and may not be perfect. ` +
|
|
`A maintainer will review shortly._`;
|
|
|
|
try {
|
|
const comments = await github.paginate(github.rest.issues.listComments, {
|
|
owner,
|
|
repo,
|
|
issue_number: issueNumber,
|
|
per_page: 100,
|
|
});
|
|
|
|
const existingAiComment = [...comments].reverse().find(
|
|
(comment) => comment.user?.type === 'Bot' && comment.body?.includes(AI_MARKER)
|
|
);
|
|
|
|
if (existingAiComment) {
|
|
await github.rest.issues.updateComment({
|
|
owner,
|
|
repo,
|
|
comment_id: existingAiComment.id,
|
|
body: triageCommentBody,
|
|
});
|
|
} else {
|
|
await github.rest.issues.createComment({
|
|
owner,
|
|
repo,
|
|
issue_number: issueNumber,
|
|
body: triageCommentBody,
|
|
});
|
|
}
|
|
} catch (err) {
|
|
console.log('Could not post comment:', err.message);
|
|
}
|
|
}
|
|
}
|
|
|
|
issue-potential-duplicates:
|
|
if: github.repository_owner == 'GameServerManagers' && github.event_name == 'issues' && (github.event.action == 'opened' || github.event.action == 'edited' || github.event.action == 'reopened')
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- name: Detect potential duplicates
|
|
uses: actions/github-script@v9
|
|
with:
|
|
script: |
|
|
const owner = context.repo.owner;
|
|
const repo = context.repo.repo;
|
|
const issueNumber = context.payload.issue?.number;
|
|
const DUPLICATE_LABEL = 'potential-duplicate';
|
|
const DUPLICATE_MARKER = '<!-- potential-duplicate-check -->';
|
|
const MAX_CANDIDATES = 5;
|
|
const THRESHOLD = 0.45;
|
|
|
|
if (!issueNumber) {
|
|
console.log('No issue number found in payload.');
|
|
return;
|
|
}
|
|
|
|
const issueResp = await github.rest.issues.get({
|
|
owner,
|
|
repo,
|
|
issue_number: issueNumber,
|
|
});
|
|
|
|
const issue = issueResp.data;
|
|
if (issue.pull_request) {
|
|
console.log('Skipping pull request payload.');
|
|
return;
|
|
}
|
|
|
|
function normalizeText(value) {
|
|
return (value || '')
|
|
.toLowerCase()
|
|
.replace(/[`'"’]/g, '')
|
|
.replace(/[^a-z0-9\s]/g, ' ')
|
|
.replace(/\s+/g, ' ')
|
|
.trim();
|
|
}
|
|
|
|
function tokenize(value) {
|
|
const stopwords = new Set([
|
|
'a', 'an', 'and', 'are', 'as', 'at', 'be', 'by', 'for', 'from', 'how', 'i', 'in', 'is', 'it', 'its',
|
|
'of', 'on', 'or', 'that', 'the', 'this', 'to', 'when', 'with', 'wont', 'cannot', 'cant', 'fails', 'fail',
|
|
'issue', 'bug', 'request', 'server', 'command', 'linuxgsm'
|
|
]);
|
|
return new Set(
|
|
normalizeText(value)
|
|
.split(' ')
|
|
.filter((token) => token.length > 2 && !stopwords.has(token))
|
|
);
|
|
}
|
|
|
|
function jaccard(aSet, bSet) {
|
|
if (aSet.size === 0 || bSet.size === 0) return 0;
|
|
let intersection = 0;
|
|
for (const v of aSet) {
|
|
if (bSet.has(v)) intersection += 1;
|
|
}
|
|
const union = new Set([...aSet, ...bSet]).size;
|
|
return union === 0 ? 0 : intersection / union;
|
|
}
|
|
|
|
function bodySignature(text) {
|
|
return normalizeText(text).split(' ').slice(0, 200).join(' ');
|
|
}
|
|
|
|
const currentTitleTokens = tokenize(issue.title || '');
|
|
const currentBodyTokens = tokenize(bodySignature(issue.body || ''));
|
|
|
|
const recentIssues = await github.paginate(github.rest.issues.listForRepo, {
|
|
owner,
|
|
repo,
|
|
state: 'all',
|
|
sort: 'updated',
|
|
direction: 'desc',
|
|
per_page: 100,
|
|
});
|
|
|
|
const ranked = [];
|
|
for (const candidate of recentIssues) {
|
|
if (!candidate || candidate.number === issueNumber || candidate.pull_request) continue;
|
|
|
|
const candidateTitleTokens = tokenize(candidate.title || '');
|
|
const candidateBodyTokens = tokenize(bodySignature(candidate.body || ''));
|
|
const titleScore = jaccard(currentTitleTokens, candidateTitleTokens);
|
|
const bodyScore = jaccard(currentBodyTokens, candidateBodyTokens);
|
|
const score = titleScore * 0.8 + bodyScore * 0.2;
|
|
|
|
if (score < THRESHOLD) continue;
|
|
|
|
ranked.push({
|
|
number: candidate.number,
|
|
title: candidate.title,
|
|
state: candidate.state,
|
|
html_url: candidate.html_url,
|
|
score,
|
|
});
|
|
}
|
|
|
|
ranked.sort((a, b) => b.score - a.score);
|
|
const topMatches = ranked.slice(0, MAX_CANDIDATES);
|
|
|
|
async function ensurePotentialDuplicateLabel() {
|
|
try {
|
|
await github.rest.issues.getLabel({ owner, repo, name: DUPLICATE_LABEL });
|
|
} catch (err) {
|
|
if (err.status !== 404) throw err;
|
|
await github.rest.issues.createLabel({
|
|
owner,
|
|
repo,
|
|
name: DUPLICATE_LABEL,
|
|
color: 'd4c5f9',
|
|
description: 'Potentially duplicates another existing issue',
|
|
});
|
|
}
|
|
}
|
|
|
|
const existingLabelNames = new Set((issue.labels || []).map((l) => l.name));
|
|
|
|
const comments = await github.paginate(github.rest.issues.listComments, {
|
|
owner,
|
|
repo,
|
|
issue_number: issueNumber,
|
|
per_page: 100,
|
|
});
|
|
const existingComment = [...comments]
|
|
.reverse()
|
|
.find((comment) => comment.user?.type === 'Bot' && comment.body?.includes(DUPLICATE_MARKER));
|
|
|
|
if (topMatches.length === 0) {
|
|
if (existingLabelNames.has(DUPLICATE_LABEL)) {
|
|
try {
|
|
await github.rest.issues.removeLabel({
|
|
owner,
|
|
repo,
|
|
issue_number: issueNumber,
|
|
name: DUPLICATE_LABEL,
|
|
});
|
|
} catch (err) {
|
|
console.log(`Could not remove ${DUPLICATE_LABEL}: ${err.message}`);
|
|
}
|
|
}
|
|
|
|
if (existingComment) {
|
|
try {
|
|
await github.rest.issues.updateComment({
|
|
owner,
|
|
repo,
|
|
comment_id: existingComment.id,
|
|
body:
|
|
`${DUPLICATE_MARKER}\n` +
|
|
`Potential duplicate scan did not find strong matches at this time.\n\n` +
|
|
`_This note is maintained automatically._`,
|
|
});
|
|
} catch (err) {
|
|
console.log(`Could not update duplicate comment: ${err.message}`);
|
|
}
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
await ensurePotentialDuplicateLabel();
|
|
|
|
if (!existingLabelNames.has(DUPLICATE_LABEL)) {
|
|
try {
|
|
await github.rest.issues.addLabels({
|
|
owner,
|
|
repo,
|
|
issue_number: issueNumber,
|
|
labels: [DUPLICATE_LABEL],
|
|
});
|
|
} catch (err) {
|
|
console.log(`Could not add ${DUPLICATE_LABEL}: ${err.message}`);
|
|
}
|
|
}
|
|
|
|
const lines = topMatches
|
|
.map((m) => `- #${m.number} (${Math.round(m.score * 100)}%) ${m.title}`)
|
|
.join('\n');
|
|
|
|
const commentBody =
|
|
`${DUPLICATE_MARKER}\n` +
|
|
`Potential duplicates:\n${lines}\n\n` +
|
|
`_This note is generated automatically using repository issue similarity and may include false positives._`;
|
|
|
|
if (existingComment) {
|
|
await github.rest.issues.updateComment({
|
|
owner,
|
|
repo,
|
|
comment_id: existingComment.id,
|
|
body: commentBody,
|
|
});
|
|
} else {
|
|
await github.rest.issues.createComment({
|
|
owner,
|
|
repo,
|
|
issue_number: issueNumber,
|
|
body: commentBody,
|
|
});
|
|
}
|
|
|
|
backfill-relabel:
|
|
if: github.repository_owner == 'GameServerManagers' && github.event_name == 'workflow_dispatch'
|
|
runs-on: ubuntu-latest
|
|
env:
|
|
ISSUE_STATE: ${{ inputs.issue_state }}
|
|
ISSUE_LIMIT: ${{ inputs.limit }}
|
|
AI_GAME_FALLBACK: ${{ inputs.ai_game_fallback }}
|
|
steps:
|
|
- name: Trigger relabel backfill
|
|
uses: actions/github-script@v9
|
|
env:
|
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
with:
|
|
script: |
|
|
const owner = context.repo.owner;
|
|
const repo = context.repo.repo;
|
|
const state = process.env.ISSUE_STATE || 'all';
|
|
const rawLimit = Number.parseInt(process.env.ISSUE_LIMIT || '0', 10);
|
|
const limit = Number.isFinite(rawLimit) && rawLimit > 0 ? rawLimit : 0;
|
|
const useAiGameFallback = String(process.env.AI_GAME_FALLBACK || 'false').toLowerCase() === 'true';
|
|
const processedIssues = [];
|
|
const failedIssues = [];
|
|
let aiGameAttempts = 0;
|
|
let aiGameMatches = 0;
|
|
let aiGameRateLimited = 0;
|
|
let aiFallbackDisabledReason = '';
|
|
let stoppedForApiRateLimit = false;
|
|
let apiRateLimitStopReason = '';
|
|
|
|
// === Helpers (mirrored from issue-ai-maintenance) ===
|
|
|
|
function normalizeName(value) {
|
|
return (value || '')
|
|
.toLowerCase()
|
|
.replace(/[''`]/g, '')
|
|
.replace(/[^a-z0-9]+/g, ' ')
|
|
.trim();
|
|
}
|
|
|
|
function parseGameCandidates(gameField) {
|
|
if (!gameField || /^_?no response_?$/i.test(gameField)) return [];
|
|
return gameField
|
|
.replace(/\(.*?\)/g, ' ')
|
|
.split(/\n|,|\s+&\s+|\s+and\s+|\//i)
|
|
.map((v) => v.trim())
|
|
.filter(Boolean);
|
|
}
|
|
|
|
function findGamesFromText(text, gameAliasToLabel, gameAliasToScript) {
|
|
const labels = new Set();
|
|
const scripts = new Set();
|
|
const normalizedText = normalizeName(text);
|
|
if (!normalizedText) return { labels, scripts };
|
|
|
|
const escapeRegex = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
const aliases = [];
|
|
for (const [alias, label] of gameAliasToLabel.entries()) {
|
|
if (alias.length < 3) continue;
|
|
aliases.push({ alias, label, script: gameAliasToScript.get(alias) || null });
|
|
}
|
|
|
|
// Prefer longer aliases first so "killing floor 2" does not also match "killing floor".
|
|
aliases.sort((a, b) => b.alias.length - a.alias.length);
|
|
|
|
const usedRanges = [];
|
|
const isOverlapping = (start, end) =>
|
|
usedRanges.some((range) => start < range.end && end > range.start);
|
|
|
|
for (const entry of aliases) {
|
|
const pattern = new RegExp(`\\b${escapeRegex(entry.alias).replace(/\\ /g, '\\s+')}\\b`, 'g');
|
|
let match;
|
|
while ((match = pattern.exec(normalizedText)) !== null) {
|
|
const start = match.index;
|
|
const end = start + match[0].length;
|
|
if (isOverlapping(start, end)) continue;
|
|
|
|
labels.add(entry.label);
|
|
if (entry.script) scripts.add(entry.script);
|
|
usedRanges.push({ start, end });
|
|
}
|
|
}
|
|
|
|
return { labels, scripts };
|
|
}
|
|
|
|
function parseAiGameResponse(raw) {
|
|
const input = (raw || '').trim();
|
|
if (!input) return {};
|
|
|
|
const candidates = [input];
|
|
const fenced = input.match(/```(?:json)?\s*([\s\S]*?)```/i);
|
|
if (fenced?.[1]) candidates.push(fenced[1].trim());
|
|
|
|
const firstBrace = input.indexOf('{');
|
|
const lastBrace = input.lastIndexOf('}');
|
|
if (firstBrace !== -1 && lastBrace > firstBrace) {
|
|
candidates.push(input.slice(firstBrace, lastBrace + 1));
|
|
}
|
|
|
|
for (const candidate of candidates) {
|
|
try {
|
|
return JSON.parse(candidate);
|
|
} catch (_err) {
|
|
// Continue trying fallbacks.
|
|
}
|
|
}
|
|
|
|
return {};
|
|
}
|
|
|
|
function isGenericNonGameDetection(value) {
|
|
const normalized = normalizeName(value);
|
|
if (!normalized) return false;
|
|
|
|
return [
|
|
'srcds',
|
|
'source dedicated server',
|
|
'dedicated server',
|
|
'source engine',
|
|
'goldsrc',
|
|
'steamcmd',
|
|
'linuxgsm',
|
|
'lgsm',
|
|
].some((term) => normalized.includes(term));
|
|
}
|
|
|
|
function parseAiRateLimitInfo(response) {
|
|
const retryAfter = response.headers.get('retry-after') || response.headers.get('Retry-After') || '';
|
|
const limit = response.headers.get('x-ratelimit-limit') || '';
|
|
const remaining = response.headers.get('x-ratelimit-remaining') || '';
|
|
const resetEpoch = response.headers.get('x-ratelimit-reset') || '';
|
|
const requestId = response.headers.get('x-github-request-id') || '';
|
|
|
|
let resetIso = '';
|
|
const parsedReset = Number.parseInt(resetEpoch, 10);
|
|
if (Number.isFinite(parsedReset) && parsedReset > 0) {
|
|
resetIso = new Date(parsedReset * 1000).toISOString();
|
|
}
|
|
|
|
return {
|
|
retryAfter,
|
|
limit,
|
|
remaining,
|
|
resetEpoch,
|
|
resetIso,
|
|
requestId,
|
|
};
|
|
}
|
|
|
|
function formatAiRateLimitInfo(info) {
|
|
const parts = [];
|
|
if (info.retryAfter) parts.push(`retry-after=${info.retryAfter}s`);
|
|
if (info.limit) parts.push(`limit=${info.limit}`);
|
|
if (info.remaining) parts.push(`remaining=${info.remaining}`);
|
|
if (info.resetEpoch) parts.push(`reset=${info.resetEpoch}${info.resetIso ? ` (${info.resetIso})` : ''}`);
|
|
if (info.requestId) parts.push(`request-id=${info.requestId}`);
|
|
return parts.length > 0 ? parts.join(', ') : 'no rate-limit headers returned';
|
|
}
|
|
|
|
function isApiRateLimitError(err) {
|
|
const message = String(err?.message || '').toLowerCase();
|
|
return (
|
|
err?.status === 429 ||
|
|
message.includes('api rate limit exceeded') ||
|
|
message.includes('secondary rate limit') ||
|
|
(message.includes('rate limit') && err?.status === 403)
|
|
);
|
|
}
|
|
|
|
function formatApiRateLimitError(err) {
|
|
const headers = err?.response?.headers || err?.headers || {};
|
|
const limit = headers['x-ratelimit-limit'] || '';
|
|
const remaining = headers['x-ratelimit-remaining'] || '';
|
|
const resetEpoch = headers['x-ratelimit-reset'] || '';
|
|
const requestId = headers['x-github-request-id'] || '';
|
|
|
|
let resetIso = '';
|
|
const parsedReset = Number.parseInt(resetEpoch || '', 10);
|
|
if (Number.isFinite(parsedReset) && parsedReset > 0) {
|
|
resetIso = new Date(parsedReset * 1000).toISOString();
|
|
}
|
|
|
|
const parts = [];
|
|
if (limit) parts.push(`limit=${limit}`);
|
|
if (remaining) parts.push(`remaining=${remaining}`);
|
|
if (resetEpoch) parts.push(`reset=${resetEpoch}${resetIso ? ` (${resetIso})` : ''}`);
|
|
if (requestId) parts.push(`request-id=${requestId}`);
|
|
return parts.length > 0 ? parts.join(', ') : 'no rate-limit headers returned';
|
|
}
|
|
|
|
function hasAliasHitForLabel(text, targetLabel, gameAliasToLabel) {
|
|
const normalizedText = normalizeName(text);
|
|
if (!normalizedText || !targetLabel) return false;
|
|
|
|
const paddedText = ` ${normalizedText} `;
|
|
for (const [alias, label] of gameAliasToLabel.entries()) {
|
|
if (label !== targetLabel) continue;
|
|
if (alias.length < 3) continue;
|
|
if (paddedText.includes(` ${alias} `)) return true;
|
|
|
|
// Allow obvious joined-word variants for multi-token aliases
|
|
// (e.g., "counter strike 1 6" matching "counterstrike 1.6").
|
|
const aliasTokens = alias.split(/\s+/).filter(Boolean);
|
|
if (aliasTokens.length > 1) {
|
|
const escapedTokens = aliasTokens.map((token) =>
|
|
token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
);
|
|
const flexibleAliasPattern = new RegExp(`\\b${escapedTokens.join('\\s*')}\\b`);
|
|
if (flexibleAliasPattern.test(normalizedText)) return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
function parseServerlistCsv(csvText) {
|
|
const rows = [];
|
|
const lines = (csvText || '').split(/\r?\n/);
|
|
for (let i = 1; i < lines.length; i += 1) {
|
|
const line = lines[i]?.trim();
|
|
if (!line) continue;
|
|
const parts = line.split(',');
|
|
if (parts.length < 3) continue;
|
|
rows.push({
|
|
shortname: parts[0].trim(),
|
|
gameservername: parts[1].trim(),
|
|
gamename: parts[2].trim(),
|
|
});
|
|
}
|
|
return rows;
|
|
}
|
|
|
|
function inferTypeFromTitle(issueTitle) {
|
|
if (/^\[bug\]/i.test(issueTitle)) return 'type: bug';
|
|
if (/\bserver\s+request\b/i.test(issueTitle)) return 'type: game server request';
|
|
const hasBracketPrefix = /^\[[^\]]+\]/.test(issueTitle || '');
|
|
const isServerCreation =
|
|
/\bserver\s+creation\b/i.test(issueTitle) ||
|
|
(hasBracketPrefix && /\bcreation\b/i.test(issueTitle));
|
|
const isServerSupportRequest =
|
|
/\bserver\s+support\b/i.test(issueTitle) ||
|
|
(/\bsupport\s+for\b/i.test(issueTitle) && /\bserver\b/i.test(issueTitle));
|
|
if (isServerCreation || isServerSupportRequest) return 'type: game server request';
|
|
if (/^\[feature\]/i.test(issueTitle)) return 'type: feature';
|
|
if (/^\[server request\]/i.test(issueTitle)) return 'type: game server request';
|
|
if (/^\[docs?\]/i.test(issueTitle)) return 'type: docs';
|
|
return null;
|
|
}
|
|
|
|
function inferDesiredType(issueTitle, labelNames) {
|
|
const titleType = inferTypeFromTitle(issueTitle);
|
|
if (titleType) return titleType;
|
|
|
|
// Prefer server requests over generic feature when both labels exist.
|
|
if (labelNames.has('type: game server request')) return 'type: game server request';
|
|
|
|
for (const label of [
|
|
'type: bug',
|
|
'type: feature',
|
|
'type: game server request',
|
|
'type: docs',
|
|
]) {
|
|
if (labelNames.has(label)) return label;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function inferIssueTypeNameFromDesiredType(typeLabel) {
|
|
if (typeLabel === 'type: bug') return 'Bug';
|
|
if (typeLabel === 'type: feature') return 'Feature';
|
|
if (typeLabel === 'type: game server request') return 'Server Request';
|
|
if (typeLabel === 'type: docs') return 'Task';
|
|
return null;
|
|
}
|
|
|
|
function parseCommandSelections(sectionValue) {
|
|
const selected = new Set();
|
|
const re = /command:\s*([a-z-]+)/gi;
|
|
let m;
|
|
while ((m = re.exec(sectionValue || '')) !== null) {
|
|
let value = m[1].toLowerCase();
|
|
if (value.startsWith('mods-')) value = 'mods';
|
|
if (value === 'auto-update') value = 'update';
|
|
selected.add(`command: ${value}`);
|
|
}
|
|
return selected;
|
|
}
|
|
|
|
function parseDistroSelections(sectionValue) {
|
|
const text = sectionValue || '';
|
|
const selected = new Set();
|
|
if (/\bUbuntu\b/i.test(text)) selected.add('distro: Ubuntu');
|
|
if (/\bDebian\b/i.test(text)) selected.add('distro: Debian');
|
|
if (/\bAlmaLinux\b/i.test(text)) selected.add('distro: AlmaLinux');
|
|
if (/\bRocky\b/i.test(text)) selected.add('distro: Rocky Linux');
|
|
if (/\bCentOS\b/i.test(text)) selected.add('distro: CentOS');
|
|
if (/\bFedora\b/i.test(text)) selected.add('distro: Fedora');
|
|
if (/\bopenSUSE\b/i.test(text)) selected.add('distro: openSUSE');
|
|
if (/\bArch Linux\b/i.test(text)) selected.add('distro: Arch Linux');
|
|
if (/\bSlackware\b/i.test(text)) selected.add('distro: Slackware');
|
|
return selected;
|
|
}
|
|
|
|
function extractSection(body, sectionName) {
|
|
const escaped = sectionName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
const re = new RegExp(`### ${escaped}\\n\\n([\\s\\S]*?)(\\n### |$)`, 'i');
|
|
return (body.match(re)?.[1] || '').trim();
|
|
}
|
|
|
|
// === Load shared data once ===
|
|
|
|
const repoLabels = await github.paginate(github.rest.issues.listLabelsForRepo, {
|
|
owner,
|
|
repo,
|
|
per_page: 100,
|
|
});
|
|
|
|
const gameLabelByNormalized = new Map();
|
|
for (const label of repoLabels) {
|
|
if (!label.name.startsWith('game: ')) continue;
|
|
gameLabelByNormalized.set(normalizeName(label.name.slice(6)), label.name);
|
|
}
|
|
|
|
const existingEngineLabels = new Set(
|
|
repoLabels.map((l) => l.name).filter((name) => name.startsWith('engine: '))
|
|
);
|
|
|
|
const gameAliasToLabel = new Map();
|
|
const gameAliasToScript = new Map();
|
|
const engineByScript = new Map();
|
|
|
|
for (const [normalizedGameName, label] of gameLabelByNormalized.entries()) {
|
|
gameAliasToLabel.set(normalizedGameName, label);
|
|
}
|
|
|
|
try {
|
|
const serverlistContent = await github.rest.repos.getContent({
|
|
owner,
|
|
repo,
|
|
path: 'lgsm/data/serverlist.csv',
|
|
});
|
|
const csvText = Buffer.from(serverlistContent.data?.content || '', 'base64').toString('utf8');
|
|
const serverRows = parseServerlistCsv(csvText);
|
|
for (const row of serverRows) {
|
|
const canonicalLabel = gameLabelByNormalized.get(normalizeName(row.gamename));
|
|
if (!canonicalLabel) continue;
|
|
for (const alias of [row.shortname, row.gameservername, row.gamename]) {
|
|
const key = normalizeName(alias);
|
|
if (!key) continue;
|
|
gameAliasToLabel.set(key, canonicalLabel);
|
|
gameAliasToScript.set(key, row.gameservername);
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.log(`Could not load serverlist aliases: ${err.message}`);
|
|
}
|
|
|
|
async function ensureEngineLabel(engineLabel) {
|
|
if (existingEngineLabels.has(engineLabel)) return;
|
|
try {
|
|
await github.rest.issues.createLabel({
|
|
owner,
|
|
repo,
|
|
name: engineLabel,
|
|
color: '000000',
|
|
description: `Issues related to ${engineLabel.slice(8)} engine`,
|
|
});
|
|
existingEngineLabels.add(engineLabel);
|
|
} catch (err) {
|
|
if (err.status === 422) {
|
|
existingEngineLabels.add(engineLabel);
|
|
return;
|
|
}
|
|
console.log(`Could not create engine label "${engineLabel}": ${err.message}`);
|
|
}
|
|
}
|
|
|
|
async function getEngineForScript(scriptName) {
|
|
if (!scriptName) return null;
|
|
if (engineByScript.has(scriptName)) return engineByScript.get(scriptName);
|
|
try {
|
|
const cfgContent = await github.rest.repos.getContent({
|
|
owner,
|
|
repo,
|
|
path: `lgsm/config-default/config-lgsm/${scriptName}/_default.cfg`,
|
|
});
|
|
const cfgText = Buffer.from(cfgContent.data?.content || '', 'base64').toString('utf8');
|
|
const engine = cfgText.match(/^engine="([^"]+)"/m)?.[1] || null;
|
|
engineByScript.set(scriptName, engine);
|
|
return engine;
|
|
} catch (_err) {
|
|
engineByScript.set(scriptName, null);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// === Process issues ===
|
|
|
|
const issues = await github.paginate(github.rest.issues.listForRepo, {
|
|
owner,
|
|
repo,
|
|
state,
|
|
sort: 'created',
|
|
direction: 'asc',
|
|
per_page: 100,
|
|
});
|
|
|
|
const targets = issues.filter((issue) => !issue.pull_request);
|
|
const selectedTargets = limit > 0 ? targets.slice(0, limit) : targets;
|
|
|
|
console.log(
|
|
`Starting relabel backfill for ${selectedTargets.length} issue(s) ` +
|
|
`(state=${state}, limit=${limit === 0 ? 'all' : limit}).`
|
|
);
|
|
|
|
let processed = 0;
|
|
for (const rawIssue of selectedTargets) {
|
|
if (stoppedForApiRateLimit) break;
|
|
console.log(`Processing issue #${rawIssue.number}: ${rawIssue.title}`);
|
|
try {
|
|
const issueResp = await github.rest.issues.get({
|
|
owner,
|
|
repo,
|
|
issue_number: rawIssue.number,
|
|
});
|
|
const issue = issueResp.data;
|
|
const title = issue.title || '';
|
|
const body = issue.body || '';
|
|
const existingLabels = new Set((issue.labels || []).map((l) => l.name).filter(Boolean));
|
|
const labelsToAdd = new Set();
|
|
const labelsToRemove = new Set();
|
|
const isLocked = issue.locked === true;
|
|
let issueTypeSet = null;
|
|
|
|
// Type reconciliation
|
|
const desiredType = inferDesiredType(title, existingLabels);
|
|
if (desiredType) {
|
|
labelsToAdd.add(desiredType);
|
|
for (const label of existingLabels) {
|
|
if (label.startsWith('type: ') && label !== desiredType) labelsToRemove.add(label);
|
|
}
|
|
|
|
const desiredIssueTypeName = inferIssueTypeNameFromDesiredType(desiredType);
|
|
if (desiredIssueTypeName) {
|
|
try {
|
|
const issueTypeData = await github.graphql(
|
|
`query($owner:String!,$repo:String!,$number:Int!){
|
|
repository(owner:$owner,name:$repo){
|
|
issueTypes(first:20){ nodes { id name } }
|
|
issue(number:$number){ id issueType { id name } }
|
|
}
|
|
}`,
|
|
{ owner, repo, number: rawIssue.number }
|
|
);
|
|
const issueNode = issueTypeData.repository?.issue;
|
|
const issueTypes = issueTypeData.repository?.issueTypes?.nodes || [];
|
|
const desiredIssueType = issueTypes.find((t) => t.name === desiredIssueTypeName);
|
|
if (
|
|
issueNode?.id &&
|
|
desiredIssueType?.id &&
|
|
issueNode.issueType?.id !== desiredIssueType.id
|
|
) {
|
|
await github.graphql(
|
|
`mutation($id:ID!,$issueTypeId:ID!){
|
|
updateIssue(input:{id:$id,issueTypeId:$issueTypeId}){
|
|
issue { id number issueType { id name } }
|
|
}
|
|
}`,
|
|
{ id: issueNode.id, issueTypeId: desiredIssueType.id }
|
|
);
|
|
issueTypeSet = desiredIssueTypeName;
|
|
console.log(`#${rawIssue.number}: set Issue Type to ${desiredIssueTypeName}`);
|
|
}
|
|
} catch (err) {
|
|
if (isApiRateLimitError(err)) throw err;
|
|
console.log(`#${rawIssue.number}: could not sync Issue Type: ${err.message}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Commands
|
|
const commandSection = extractSection(body, 'Command');
|
|
const desiredCommands = parseCommandSelections(commandSection);
|
|
if (desiredCommands.size > 0) {
|
|
for (const label of desiredCommands) labelsToAdd.add(label);
|
|
for (const label of existingLabels) {
|
|
if (label.startsWith('command: ') && !desiredCommands.has(label)) labelsToRemove.add(label);
|
|
}
|
|
}
|
|
|
|
// Distros
|
|
const distroSection = extractSection(body, 'Linux distro');
|
|
const desiredDistros = parseDistroSelections(distroSection);
|
|
if (desiredDistros.size > 0) {
|
|
for (const label of desiredDistros) labelsToAdd.add(label);
|
|
for (const label of existingLabels) {
|
|
if (label.startsWith('distro: ') && !desiredDistros.has(label)) labelsToRemove.add(label);
|
|
}
|
|
}
|
|
|
|
// Tmux false positive cleanup
|
|
if (
|
|
existingLabels.has('info: tmux') &&
|
|
!/\b(tmuxception|check_tmuxception)\b/i.test(`${title}\n${body}`)
|
|
) {
|
|
labelsToRemove.add('info: tmux');
|
|
}
|
|
|
|
// Games and engines
|
|
const desiredGames = new Set();
|
|
const gameLabelSource = new Map(); // label → 'form-field' | 'text-match' | 'ai-fallback'
|
|
const desiredServerScripts = new Set();
|
|
const gameField = extractSection(body, 'Game');
|
|
const gameCandidates = parseGameCandidates(gameField);
|
|
const hasStructuredGameSelection = gameCandidates.length > 0;
|
|
for (const candidate of gameCandidates) {
|
|
const normalizedCandidate = normalizeName(candidate);
|
|
const mapped =
|
|
gameAliasToLabel.get(normalizedCandidate) || gameLabelByNormalized.get(normalizedCandidate);
|
|
if (mapped) {
|
|
desiredGames.add(mapped);
|
|
gameLabelSource.set(mapped, 'form-field');
|
|
}
|
|
const mappedScript = gameAliasToScript.get(normalizedCandidate);
|
|
if (mappedScript) desiredServerScripts.add(mappedScript);
|
|
}
|
|
|
|
// Legacy issues often have no form section; fall back to deterministic text matching.
|
|
if (desiredGames.size === 0) {
|
|
const fromText = findGamesFromText(`${title}\n${body}`, gameAliasToLabel, gameAliasToScript);
|
|
for (const label of fromText.labels) {
|
|
desiredGames.add(label);
|
|
gameLabelSource.set(label, 'text-match');
|
|
}
|
|
for (const scriptName of fromText.scripts) desiredServerScripts.add(scriptName);
|
|
}
|
|
|
|
// Optional AI fallback for legacy issues where deterministic matching finds nothing.
|
|
if (useAiGameFallback && desiredGames.size === 0) {
|
|
if (aiFallbackDisabledReason) {
|
|
console.log(`#${rawIssue.number}: AI fallback skipped (${aiFallbackDisabledReason})`);
|
|
} else {
|
|
aiGameAttempts += 1;
|
|
const aiPayload = {
|
|
model: 'openai/gpt-4.1-mini',
|
|
temperature: 0.1,
|
|
max_tokens: 120,
|
|
messages: [
|
|
{
|
|
role: 'system',
|
|
content:
|
|
'Return JSON only. Identify the specific game referenced in this LinuxGSM issue with high precision. ' +
|
|
'If only generic platform/engine terms are present (e.g. srcds, source dedicated server, steamcmd), return detected_game as null.',
|
|
},
|
|
{
|
|
role: 'user',
|
|
content:
|
|
`Title: ${title}\n\nBody:\n${body.slice(0, 2500)}\n\n` +
|
|
'Return JSON: {"detected_game":"string or null","game_confidence":"high|medium|low|null"}',
|
|
},
|
|
],
|
|
};
|
|
const aiUrl = `https://models.github.ai/orgs/${owner}/inference/chat/completions`;
|
|
const aiHeaders = {
|
|
Accept: 'application/vnd.github+json',
|
|
Authorization: `Bearer ${process.env.GITHUB_TOKEN}`,
|
|
'X-GitHub-Api-Version': '2026-03-10',
|
|
'Content-Type': 'application/json',
|
|
};
|
|
try {
|
|
let res = await fetch(aiUrl, { method: 'POST', headers: aiHeaders, body: JSON.stringify(aiPayload) });
|
|
|
|
// On 429 honour Retry-After (capped at 60 s) then retry once.
|
|
if (res.status === 429) {
|
|
aiGameRateLimited += 1;
|
|
const rateInfo = parseAiRateLimitInfo(res);
|
|
const rawRetryAfter = Number.parseInt(rateInfo.retryAfter || '10', 10);
|
|
const retryAfter = Math.min(Number.isFinite(rawRetryAfter) ? rawRetryAfter : 10, 60);
|
|
if (Number.isFinite(rawRetryAfter) && rawRetryAfter > 300) {
|
|
aiFallbackDisabledReason = `global cooldown active (retry-after=${rawRetryAfter}s)`;
|
|
console.log(
|
|
`#${rawIssue.number}: AI fallback disabled for remaining run (${aiFallbackDisabledReason}; ${formatAiRateLimitInfo(rateInfo)})`
|
|
);
|
|
} else {
|
|
console.log(
|
|
`#${rawIssue.number}: AI fallback rate-limited - waiting ${retryAfter}s then retrying (${formatAiRateLimitInfo(rateInfo)})`
|
|
);
|
|
await new Promise((r) => setTimeout(r, retryAfter * 1000));
|
|
res = await fetch(aiUrl, { method: 'POST', headers: aiHeaders, body: JSON.stringify(aiPayload) });
|
|
}
|
|
}
|
|
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
const raw = data.choices?.[0]?.message?.content || '{}';
|
|
const parsed = parseAiGameResponse(raw);
|
|
const detectedGame = normalizeName(parsed?.detected_game || '');
|
|
const confidence = (parsed?.game_confidence || '').toLowerCase();
|
|
if (detectedGame && confidence === 'high') {
|
|
const mappedLabel =
|
|
gameAliasToLabel.get(detectedGame) || gameLabelByNormalized.get(detectedGame);
|
|
if (mappedLabel) {
|
|
const hasAliasEvidence = hasAliasHitForLabel(
|
|
`${title}\n${body}`,
|
|
mappedLabel,
|
|
gameAliasToLabel
|
|
);
|
|
if (hasAliasEvidence) {
|
|
desiredGames.add(mappedLabel);
|
|
gameLabelSource.set(mappedLabel, 'ai-fallback');
|
|
const mappedScript = gameAliasToScript.get(detectedGame);
|
|
if (mappedScript) desiredServerScripts.add(mappedScript);
|
|
aiGameMatches += 1;
|
|
console.log(
|
|
`#${rawIssue.number}: AI fallback accepted game "${mappedLabel}" from "${parsed?.detected_game}"`
|
|
);
|
|
} else {
|
|
console.log(
|
|
`#${rawIssue.number}: AI fallback rejected game "${mappedLabel}" (no literal alias evidence in issue text)`
|
|
);
|
|
}
|
|
} else {
|
|
if (isGenericNonGameDetection(parsed?.detected_game || '')) {
|
|
console.log(
|
|
`#${rawIssue.number}: AI fallback skipped generic non-game detection "${parsed?.detected_game}"`
|
|
);
|
|
} else {
|
|
console.log(
|
|
`#${rawIssue.number}: AI fallback returned unmapped game "${parsed?.detected_game}"`
|
|
);
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
if (res.status === 429) {
|
|
const rateInfo = parseAiRateLimitInfo(res);
|
|
console.log(
|
|
`#${rawIssue.number}: AI fallback skipped (HTTP 429, ${formatAiRateLimitInfo(rateInfo)})`
|
|
);
|
|
} else {
|
|
console.log(`#${rawIssue.number}: AI fallback skipped (HTTP ${res.status})`);
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.log(`#${rawIssue.number}: AI fallback error: ${err.message}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const gameLabel of desiredGames) {
|
|
const mappedScript = gameAliasToScript.get(normalizeName(gameLabel.slice(6)));
|
|
if (mappedScript) desiredServerScripts.add(mappedScript);
|
|
}
|
|
|
|
const desiredEngineLabels = new Set();
|
|
for (const scriptName of desiredServerScripts) {
|
|
const engine = await getEngineForScript(scriptName);
|
|
if (!engine) continue;
|
|
const engineLabel = `engine: ${engine}`;
|
|
await ensureEngineLabel(engineLabel);
|
|
desiredEngineLabels.add(engineLabel);
|
|
}
|
|
|
|
if (desiredEngineLabels.size > 0) {
|
|
for (const label of desiredEngineLabels) labelsToAdd.add(label);
|
|
for (const label of existingLabels) {
|
|
if (label.startsWith('engine: ') && !desiredEngineLabels.has(label)) labelsToRemove.add(label);
|
|
}
|
|
}
|
|
|
|
if (desiredGames.size > 0) {
|
|
for (const label of desiredGames) labelsToAdd.add(label);
|
|
if (hasStructuredGameSelection) {
|
|
for (const label of existingLabels) {
|
|
if (label.startsWith('game: ') && !desiredGames.has(label)) labelsToRemove.add(label);
|
|
}
|
|
} else {
|
|
// For legacy issues without structured game selection, only prune stale
|
|
// broader labels when a more specific inferred game label exists.
|
|
const desiredGameNamesNormalized = new Set(
|
|
[...desiredGames].map((label) => normalizeName(label.slice(6)))
|
|
);
|
|
for (const label of existingLabels) {
|
|
if (!label.startsWith('game: ') || desiredGames.has(label)) continue;
|
|
const existingGameName = normalizeName(label.slice(6));
|
|
const isBroaderOverlap = [...desiredGameNamesNormalized].some(
|
|
(desiredName) => desiredName !== existingGameName && desiredName.startsWith(`${existingGameName} `)
|
|
);
|
|
if (isBroaderOverlap) labelsToRemove.add(label);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Apply changes
|
|
const finalAdds = [...labelsToAdd].filter((label) => !existingLabels.has(label));
|
|
const finalRemoves = [...labelsToRemove].filter((label) => existingLabels.has(label));
|
|
|
|
let labelAdded = 0;
|
|
let labelRemoved = 0;
|
|
|
|
for (const label of finalRemoves) {
|
|
try {
|
|
await github.rest.issues.removeLabel({
|
|
owner,
|
|
repo,
|
|
issue_number: rawIssue.number,
|
|
name: label,
|
|
});
|
|
labelRemoved += 1;
|
|
console.log(`#${rawIssue.number}: removed "${label}"`);
|
|
} catch (err) {
|
|
if (isApiRateLimitError(err)) throw err;
|
|
console.log(`#${rawIssue.number}: could not remove "${label}": ${err.message}`);
|
|
}
|
|
}
|
|
|
|
for (const label of finalAdds) {
|
|
try {
|
|
await github.rest.issues.addLabels({
|
|
owner,
|
|
repo,
|
|
issue_number: rawIssue.number,
|
|
labels: [label],
|
|
});
|
|
labelAdded += 1;
|
|
const gameSource = gameLabelSource.get(label);
|
|
console.log(`#${rawIssue.number}: added "${label}"${gameSource ? ` (${gameSource})` : ''}`);
|
|
} catch (err) {
|
|
if (isApiRateLimitError(err)) throw err;
|
|
console.log(`#${rawIssue.number}: could not add "${label}": ${err.message}`);
|
|
}
|
|
}
|
|
|
|
processed += 1;
|
|
processedIssues.push({
|
|
number: rawIssue.number,
|
|
title: rawIssue.title,
|
|
adds: labelAdded,
|
|
removes: labelRemoved,
|
|
issueTypeSet,
|
|
locked: isLocked,
|
|
});
|
|
console.log(
|
|
`#${rawIssue.number}: done (+${labelAdded} added, -${labelRemoved} removed${
|
|
issueTypeSet ? `, type→${issueTypeSet}` : ''
|
|
}${isLocked ? ', locked' : ''})`
|
|
);
|
|
} catch (err) {
|
|
if (isApiRateLimitError(err)) {
|
|
stoppedForApiRateLimit = true;
|
|
apiRateLimitStopReason = formatApiRateLimitError(err);
|
|
console.log(
|
|
`Stopping backfill due to API rate limit at #${rawIssue.number} (${apiRateLimitStopReason})`
|
|
);
|
|
failedIssues.push({
|
|
number: rawIssue.number,
|
|
title: rawIssue.title,
|
|
stage: 'rate-limit',
|
|
error: err.message,
|
|
});
|
|
break;
|
|
} else {
|
|
console.log(`Error processing #${rawIssue.number}: ${err.message}`);
|
|
failedIssues.push({
|
|
number: rawIssue.number,
|
|
title: rawIssue.title,
|
|
stage: 'process',
|
|
error: err.message,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
console.log(
|
|
`Relabel backfill complete: ${processed} processed, ${failedIssues.length} failed${
|
|
stoppedForApiRateLimit ? `, stopped early (${apiRateLimitStopReason})` : ''
|
|
}.`
|
|
);
|
|
|
|
await core.summary
|
|
.addHeading('Relabel Backfill Summary')
|
|
.addTable([
|
|
[
|
|
{ data: 'Requested state', header: true },
|
|
{ data: 'Limit', header: true },
|
|
{ data: 'AI fallback', header: true },
|
|
{ data: 'AI attempts', header: true },
|
|
{ data: 'AI matches', header: true },
|
|
{ data: 'AI 429s', header: true },
|
|
{ data: 'AI disabled reason', header: true },
|
|
{ data: 'Stopped early', header: true },
|
|
{ data: 'Target issues', header: true },
|
|
{ data: 'Processed', header: true },
|
|
{ data: 'Failures', header: true },
|
|
],
|
|
[
|
|
state,
|
|
limit === 0 ? 'all' : String(limit),
|
|
useAiGameFallback ? 'enabled' : 'disabled',
|
|
String(aiGameAttempts),
|
|
String(aiGameMatches),
|
|
String(aiGameRateLimited),
|
|
aiFallbackDisabledReason || '—',
|
|
stoppedForApiRateLimit ? apiRateLimitStopReason : 'no',
|
|
String(selectedTargets.length),
|
|
String(processed),
|
|
String(failedIssues.length),
|
|
],
|
|
])
|
|
.write();
|
|
|
|
if (processedIssues.length > 0) {
|
|
const processedRows = processedIssues.slice(0, 50).map((issue) => [
|
|
`#${issue.number}${issue.locked ? ' 🔒' : ''}`,
|
|
`[${issue.title}](https://github.com/${owner}/${repo}/issues/${issue.number})`,
|
|
`+${issue.adds} / -${issue.removes}`,
|
|
issue.issueTypeSet || '—',
|
|
]);
|
|
|
|
await core.summary
|
|
.addHeading('Processed Issues')
|
|
.addTable([
|
|
[
|
|
{ data: 'Issue', header: true },
|
|
{ data: 'Title', header: true },
|
|
{ data: 'Label changes', header: true },
|
|
{ data: 'Issue Type set', header: true },
|
|
],
|
|
...processedRows,
|
|
])
|
|
.write();
|
|
}
|
|
|
|
if (failedIssues.length > 0) {
|
|
const failureRows = failedIssues.slice(0, 50).map((issue) => [
|
|
`#${issue.number}`,
|
|
issue.stage,
|
|
issue.error,
|
|
]);
|
|
|
|
await core.summary
|
|
.addHeading('Failures')
|
|
.addTable([
|
|
[
|
|
{ data: 'Issue', header: true },
|
|
{ data: 'Stage', header: true },
|
|
{ data: 'Error', header: true },
|
|
],
|
|
...failureRows,
|
|
])
|
|
.write();
|
|
}
|
|
|
|
pr-labeler:
|
|
if: github.repository_owner == 'GameServerManagers' && github.event_name == 'pull_request'
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- name: PR Labeler
|
|
uses: github/[email protected]
|
|
with:
|
|
repo-token: "${{ secrets.GITHUB_TOKEN }}"
|
|
configuration-path: .github/labeler.yml
|
|
enable-versioned-regex: 0
|
|
include-title: 1
|
|
include-body: 0
|
|
sync-labels: 1
|
|
|
|
is-sponsor-label:
|
|
if: github.repository_owner == 'GameServerManagers' && github.event_name == 'issues' && github.event.action == 'opened'
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- name: Is Sponsor Label
|
|
uses: JasonEtco/is-sponsor-label-action@v2
|
|
env:
|
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
|