20 changed files with 1333 additions and 320 deletions
@ -0,0 +1,29 @@ |
|||||
|
--- |
||||
|
title: "LinuxGSM PR Review Guidance" |
||||
|
applyTo: "**" |
||||
|
description: "Use when reviewing pull requests in LinuxGSM; prioritize regressions, behavior changes, shell safety, and missing tests over style-only feedback." |
||||
|
--- |
||||
|
|
||||
|
Focus review effort on correctness and operational safety first. |
||||
|
|
||||
|
Primary priorities: |
||||
|
|
||||
|
- Identify behavior regressions and compatibility risks. |
||||
|
- Flag unsafe shell patterns (`rm -rf`, unquoted vars, unchecked command failures). |
||||
|
- Verify workflow changes do not weaken permissions or secret handling. |
||||
|
- Check for missing tests/validation when logic changes. |
||||
|
- Confirm labels, templates, and automation rules stay internally consistent. |
||||
|
|
||||
|
Feedback expectations: |
||||
|
|
||||
|
- Give concrete, actionable findings with file and reason. |
||||
|
- Prefer high-signal issues over style nits. |
||||
|
- If no defects are found, state that clearly and mention residual risk areas. |
||||
|
- Suggest minimal, low-risk fixes before proposing broad refactors. |
||||
|
|
||||
|
LinuxGSM-specific checks: |
||||
|
|
||||
|
- Shell scripts should preserve robust defaults (`set -euo pipefail` where appropriate). |
||||
|
- Label/workflow updates should avoid duplicate or stale taxonomy. |
||||
|
- Automation should fail safe (log and continue for advisory AI; block on true CI errors). |
||||
|
- Keep issue/PR automation rules aligned with templates and existing labels. |
||||
@ -0,0 +1,87 @@ |
|||||
|
#!/usr/bin/env bash |
||||
|
# sync-game-labels.sh |
||||
|
# Reads lgsm/data/serverlist.csv and ensures a "game: <name>" label exists in |
||||
|
# the GitHub repo for every unique game name. Safe to run multiple times. |
||||
|
# |
||||
|
# Requires: gh CLI authenticated with issues:write scope. |
||||
|
# Usage: .github/scripts/sync-game-labels.sh [OWNER/REPO] |
||||
|
# |
||||
|
# The OWNER/REPO argument is optional; if omitted gh uses the current repo. |
||||
|
|
||||
|
set -euo pipefail |
||||
|
|
||||
|
REPO="${1:-}" |
||||
|
SERVERLIST="lgsm/data/serverlist.csv" |
||||
|
LABEL_COLOR="5b21b6" |
||||
|
LABEL_PREFIX="game: " |
||||
|
|
||||
|
normalize_label() { |
||||
|
printf '%s' "$1" | tr '[:upper:]' '[:lower:]' |
||||
|
} |
||||
|
|
||||
|
if [[ ! -f "${SERVERLIST}" ]]; then |
||||
|
echo "ERROR: ${SERVERLIST} not found. Run from the repository root." |
||||
|
exit 1 |
||||
|
fi |
||||
|
|
||||
|
declare -A EXISTING_COLORS=() |
||||
|
declare -A EXISTING_DESCRIPTIONS=() |
||||
|
declare -A EXISTING_NAMES=() |
||||
|
|
||||
|
# Fetch all existing game label metadata once (up to 1000) and cache locally. |
||||
|
echo "Fetching existing labels..." |
||||
|
while IFS=$'\t' read -r NAME COLOR DESCRIPTION; do |
||||
|
[[ -n "${NAME}" ]] || continue |
||||
|
EXISTING_COLORS["${NAME}"]="${COLOR}" |
||||
|
EXISTING_DESCRIPTIONS["${NAME}"]="${DESCRIPTION}" |
||||
|
EXISTING_NAMES["$(normalize_label "${NAME}")"]="${NAME}" |
||||
|
done < <( |
||||
|
gh label list --limit 1000 --json name,color,description ${REPO:+--repo "$REPO"} \ |
||||
|
| jq -r '.[] | select(.name | startswith("game: ")) | [.name, .color, (.description // "")] | @tsv' |
||||
|
) |
||||
|
|
||||
|
# Parse unique game names from the CSV (column 3, skip header). |
||||
|
mapfile -t GAMES < <( |
||||
|
tail -n +2 "${SERVERLIST}" \ |
||||
|
| cut -d',' -f3 \ |
||||
|
| sort -u |
||||
|
) |
||||
|
|
||||
|
CREATED=0 |
||||
|
UPDATED=0 |
||||
|
UNCHANGED=0 |
||||
|
|
||||
|
for GAME in "${GAMES[@]}"; do |
||||
|
LABEL="${LABEL_PREFIX}${GAME}" |
||||
|
DESCRIPTION="Issues related to ${GAME}" |
||||
|
NORMALIZED_LABEL="$(normalize_label "${LABEL}")" |
||||
|
|
||||
|
if [[ -v EXISTING_NAMES["${NORMALIZED_LABEL}"] ]]; then |
||||
|
CURRENT_LABEL="${EXISTING_NAMES["${NORMALIZED_LABEL}"]}" |
||||
|
CURRENT_COLOR="${EXISTING_COLORS["${CURRENT_LABEL}"]}" |
||||
|
CURRENT_DESCRIPTION="${EXISTING_DESCRIPTIONS["${CURRENT_LABEL}"]}" |
||||
|
|
||||
|
if [[ "${CURRENT_LABEL}" != "${LABEL}" || "${CURRENT_COLOR}" != "${LABEL_COLOR}" || "${CURRENT_DESCRIPTION}" != "${DESCRIPTION}" ]]; then |
||||
|
echo " update ${LABEL}" |
||||
|
gh label edit "${CURRENT_LABEL}" \ |
||||
|
--name "${LABEL}" \ |
||||
|
--color "${LABEL_COLOR}" \ |
||||
|
--description "${DESCRIPTION}" \ |
||||
|
${REPO:+--repo "$REPO"} |
||||
|
((UPDATED++)) || true |
||||
|
else |
||||
|
echo " ok ${LABEL}" |
||||
|
((UNCHANGED++)) || true |
||||
|
fi |
||||
|
else |
||||
|
echo " create ${LABEL}" |
||||
|
gh label create "${LABEL}" \ |
||||
|
--color "${LABEL_COLOR}" \ |
||||
|
--description "${DESCRIPTION}" \ |
||||
|
${REPO:+--repo "$REPO"} |
||||
|
((CREATED++)) || true |
||||
|
fi |
||||
|
done |
||||
|
|
||||
|
echo "" |
||||
|
echo "Done. Created: ${CREATED} Updated: ${UPDATED} Unchanged: ${UNCHANGED}" |
||||
@ -1,29 +0,0 @@ |
|||||
name: Update copyright year(s) in license file |
|
||||
|
|
||||
on: |
|
||||
workflow_dispatch: |
|
||||
schedule: |
|
||||
- cron: "0 3 1 1 *" # 03:00 AM on January 1 |
|
||||
|
|
||||
permissions: |
|
||||
contents: write |
|
||||
|
|
||||
jobs: |
|
||||
update-license-year: |
|
||||
runs-on: ubuntu-latest |
|
||||
steps: |
|
||||
- name: Checkout |
|
||||
uses: actions/checkout@v6 |
|
||||
with: |
|
||||
fetch-depth: 0 |
|
||||
persist-credentials: false |
|
||||
- name: Action Update License Year |
|
||||
uses: FantasticFiasco/action-update-license-year@v3 |
|
||||
with: |
|
||||
token: ${{ secrets.GITHUB_TOKEN }} |
|
||||
path: LICENSE.md |
|
||||
- name: Merge pull request |
|
||||
env: |
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} |
|
||||
run: | |
|
||||
gh pr merge --merge --delete-branch |
|
||||
@ -1,17 +1,43 @@ |
|||||
name: Issue Labeler |
name: Unified Labeling |
||||
on: |
on: |
||||
issues: |
issues: |
||||
types: |
types: |
||||
- opened |
- opened |
||||
- edited |
- 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: |
permissions: |
||||
issues: write |
issues: write |
||||
|
pull-requests: write |
||||
contents: read |
contents: read |
||||
|
models: read |
||||
|
|
||||
|
env: |
||||
|
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" |
||||
|
|
||||
jobs: |
jobs: |
||||
issue-labeler: |
issue-regex-labeler: |
||||
if: github.repository_owner == 'GameServerManagers' |
if: github.repository_owner == 'GameServerManagers' && github.event_name == 'issues' && (github.event.action == 'opened' || github.event.action == 'edited') |
||||
runs-on: ubuntu-latest |
runs-on: ubuntu-latest |
||||
steps: |
steps: |
||||
- name: Issue Labeler |
- name: Issue Labeler |
||||
@ -21,9 +47,753 @@ jobs: |
|||||
configuration-path: .github/labeler.yml |
configuration-path: .github/labeler.yml |
||||
enable-versioned-regex: 0 |
enable-versioned-regex: 0 |
||||
include-title: 1 |
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@v7 |
||||
|
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 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 (/^\[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 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 = inferTypeFromTitle(title); |
||||
|
if (desiredType) { |
||||
|
labelsToAdd.add(desiredType); |
||||
|
for (const label of existingLabels) { |
||||
|
if (label.startsWith('type: ') && label !== desiredType) { |
||||
|
labelsToRemove.add(label); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
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'); |
||||
|
for (const candidate of parseGameCandidates(gameField)) { |
||||
|
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); |
||||
|
} |
||||
|
|
||||
|
// 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); |
||||
|
for (const label of existingLabels) { |
||||
|
if (label.startsWith('game: ') && !desiredGames.has(label)) { |
||||
|
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@v7 |
||||
|
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, |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
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: |
is-sponsor-label: |
||||
if: github.repository_owner == 'GameServerManagers' |
if: github.repository_owner == 'GameServerManagers' && github.event_name == 'issues' && github.event.action == 'opened' |
||||
runs-on: ubuntu-latest |
runs-on: ubuntu-latest |
||||
steps: |
steps: |
||||
- name: Is Sponsor Label |
- name: Is Sponsor Label |
||||
|
|||||
@ -1,27 +0,0 @@ |
|||||
name: Potential Duplicates |
|
||||
on: |
|
||||
issues: |
|
||||
types: |
|
||||
- opened |
|
||||
|
|
||||
permissions: |
|
||||
issues: write |
|
||||
|
|
||||
jobs: |
|
||||
potential-duplicates: |
|
||||
if: github.repository_owner == 'GameServerManagers' |
|
||||
runs-on: ubuntu-latest |
|
||||
steps: |
|
||||
- name: Potential Duplicates |
|
||||
uses: wow-actions/potential-duplicates@v1 |
|
||||
with: |
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} |
|
||||
filter: "" |
|
||||
exclude: "" |
|
||||
label: potential-duplicate |
|
||||
state: all |
|
||||
threshold: 0.8 |
|
||||
comment: > |
|
||||
Potential duplicates: {{#issues}} |
|
||||
- [#{{ number }}] {{ title }} ({{ accuracy }}%) |
|
||||
{{/issues}} |
|
||||
@ -0,0 +1,28 @@ |
|||||
|
name: Sync Game Labels |
||||
|
on: |
||||
|
push: |
||||
|
branches: |
||||
|
- master |
||||
|
- develop |
||||
|
paths: |
||||
|
- "lgsm/data/serverlist.csv" |
||||
|
workflow_dispatch: {} |
||||
|
|
||||
|
permissions: |
||||
|
issues: write |
||||
|
contents: read |
||||
|
|
||||
|
jobs: |
||||
|
sync-game-labels: |
||||
|
if: github.repository_owner == 'GameServerManagers' |
||||
|
runs-on: ubuntu-latest |
||||
|
steps: |
||||
|
- name: Checkout |
||||
|
uses: actions/checkout@v5 |
||||
|
|
||||
|
- name: Sync game labels from serverlist |
||||
|
env: |
||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} |
||||
|
run: | |
||||
|
chmod +x .github/scripts/sync-game-labels.sh |
||||
|
.github/scripts/sync-game-labels.sh |
||||
Loading…
Reference in new issue