ソースを参照

chore: unify issue labeling automation and reduce false positives

chore(deps): bump webfactory/ssh-agent from 0.9.0 to 0.10.0 (#4910)

Bumps [webfactory/ssh-agent](https://github.com/webfactory/ssh-agent) from 0.9.0 to 0.10.0.
- [Release notes](https://github.com/webfactory/ssh-agent/releases)
- [Changelog](https://github.com/webfactory/ssh-agent/blob/master/CHANGELOG.md)
- [Commits](https://github.com/webfactory/ssh-agent/compare/v0.9.0...v0.10.0)

---
updated-dependencies:
- dependency-name: webfactory/ssh-agent
  dependency-version: 0.10.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
chore(deps): bump dessant/lock-threads from 5 to 6 (#4909)

Bumps [dessant/lock-threads](https://github.com/dessant/lock-threads) from 5 to 6.
- [Release notes](https://github.com/dessant/lock-threads/releases)
- [Changelog](https://github.com/dessant/lock-threads/blob/main/CHANGELOG.md)
- [Commits](https://github.com/dessant/lock-threads/compare/v5...v6)

---
updated-dependencies:
- dependency-name: dessant/lock-threads
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
feat: sync GitHub issue types with `type:` labels

feat: enable manual backfill for issue labels

This change introduces a `workflow_dispatch` triggered job that allows for manually re-evaluating and applying labels to existing issues.

The new job iterates through a specified set of issues (filtered by state and an optional limit), adds a temporary `maintenance: relabel-backfill` label, and immediately removes it. The primary `labeling-issues` workflow is updated to specifically *not* ignore these bot-generated label events when the temporary label is involved, thus forcing a re-evaluation of the issue by the current labeling logic.

This provides a mechanism to correct mislabeled issues or apply updated labeling rules to the entire issue backlog.

feat: enhance relabel backfill with detailed summary and update github-script

This change significantly improves the observability of the manual issue relabel backfill workflow by introducing:

- Detailed console logging for each issue being processed.
- A comprehensive `core.summary` output in the workflow run, providing tables for overall statistics, processed issues, and any encountered failures.

Additionally, the `actions/github-script` action is updated to v8.

chore: make issue type inference more robust by respecting existing labels

Previously, the automated issue type inference relied solely on the issue title. This could result in valid `type:` labels being removed if the title was ambiguous or didn't explicitly match a predefined pattern.

This change introduces a fallback mechanism where existing `type:` labels are considered if a type cannot be clearly inferred from the title. This reduces unnecessary label churn and improves the accuracy of automated labeling.

fix: backfill runs triage inline instead of label-toggle

GitHub does not fire new workflow runs when GITHUB_TOKEN creates label
events (built-in loop prevention). Replace the label-toggle backfill
approach with an inline version that mirrors the deterministic
reconciliation logic from issue-ai-maintenance directly: type labels,
command/distro labels, game/engine labels, Issue Type GraphQL sync,
and tmux false-positive cleanup. No AI call is made during backfill.

Also removes the now-unnecessary BACKFILL_TRIGGER_LABEL exemption from
the bot-loop guard in issue-ai-maintenance.

fix: track issue type + locked state in backfill summary

- Add 'Issue Type set' column to processed issues summary table
- Track issueTypeSet per issue (null when already correct/unchanged)
- Skip REST label mutations for locked issues (they return 403) with
  a console note; Issue Type GraphQL sync still runs for locked issues
- Show lock emoji in issue number column when issue is locked
- Track actual applied add/remove counts (not desired counts)

fix: allow backfill label updates on locked issues

Remove locked-issue skip branch in backfill so label add/remove operations
run for locked issues as well. Keep lock marker in summary for visibility.

fix: keep legacy server request issues classified correctly

Recognize 'server request' anywhere in issue titles (e.g. '[callofduty1] Server Request')
and prefer type: game server request over generic feature when both labels exist.
Apply this in both issue-ai-maintenance and backfill logic.

fix: classify legacy server-request titles in backfill and maintenance

Detect legacy server-request phrasing in issue titles:
- bracketed game/server titles ending with 'Creation'
- titles containing 'Server Creation'
- titles containing 'Server Support' or 'Support for ... server'

Apply the same heuristics in both issue-ai-maintenance and backfill
inferTypeFromTitle paths so old issues are not downgraded to feature.

feat: infer game labels from legacy issue text in relabel

When no structured Game form section is present, infer game labels/scripts
deterministically from title/body using serverlist alias mappings.
Apply this to both issue-ai-maintenance and backfill to improve
historical game labeling without relying on AI.

feat: add optional AI fallback for backfill game detection

Add workflow_dispatch input ai_game_fallback (default false).
In backfill mode, only call AI when deterministic game mapping finds no
match; accept only high-confidence results and map through known game
aliases/labels. Include AI usage stats in the run summary table.

fix: avoid pruning legacy game labels without structured game input

Only remove existing game:* labels when an issue has explicit structured
Game form selections. For legacy title/body inference (and AI fallback),
add matched game labels but do not remove other existing game labels.
This prevents edge cases like issue #1 from losing valid multi-game tags.

fix: require alias evidence for AI game fallback labels

Backfill AI game fallback now accepts a detected game only when the issue
text contains a literal alias token for the mapped game label. Add explicit
logs for AI accept/reject/unmapped outcomes to make attribution auditable
in job logs and prevent false positives like issue #17.

feat: annotate game label adds with detection source in backfill logs

Each game label add now shows its source in the per-issue log line:
  #240: added "game: Opposing Force" (text-match)
  #248: added "game: Counter-Strike: Global Offensive" (form-field)
  #N:   added "game: X" (ai-fallback)

Non-game labels (engine, type, needs, etc.) are unchanged.

fix: add missing hasAliasHitForLabel to backfill script context

Each github-script step runs in its own isolated JS context. The backfill
step was calling hasAliasHitForLabel (used by the AI alias-evidence gate)
but the function was only defined in the triage step, causing a ReferenceError
on any issue that triggered AI fallback.

fix: retry AI fallback once on HTTP 429 with Retry-After backoff

When the GitHub Models API rate-limits the backfill (429), read the
Retry-After header (capped at 60s), wait, then retry the request once.
If the retry also fails the issue is skipped as before.

fix: accept joined-token alias evidence in AI game fallback gate

Alias evidence now allows multi-token aliases to match when words are
joined in issue text (e.g. counterstrike vs counter strike), while
keeping exact token checks for single-word aliases.

fix: treat generic AI detections as non-game in backfill

When AI fallback returns generic platform/engine terms (e.g. srcds,
source dedicated server, steamcmd), treat them as non-game detections
instead of logging them as unmapped games. Also prompt the model to
return null for generic terms.

chore: log AI rate-limit headers and 429 count in backfill

Capture Retry-After, X-RateLimit-* and request id on 429 responses,
log them on retry and final skip, and include total AI 429 hits in the
workflow summary table.

fix: disable AI fallback for run on long Retry-After cooldown

When GitHub Models returns 429 with a large Retry-After (over 300s),
stop AI fallback for the remainder of the backfill run instead of
sleeping and retrying per issue. Include disable reason in summary.

fix: prevent overlapping game alias double-matches in text detection

Prefer longest non-overlapping alias matches so titles like
"Killing Floor 2" do not also infer "Killing Floor" unless both
are explicitly present as separate mentions.

fix: prune stale broad game labels when specific game is inferred

For legacy issues without structured game selection, remove existing game
labels only when they are broader overlaps of a newly inferred specific
game label (e.g. remove Killing Floor when Killing Floor 2 is inferred).

fix: stop relabel backfill early when API rate limit is hit

Detect GitHub API rate limit errors during processing, stop the run
gracefully, and report header-derived rate limit details in logs and
summary instead of emitting repeated per-issue failures.

feat(labeler): implement Linux support verification for server requests

* Added checks for Linux support based on issue content and Steam API data.
* Integrated AI analysis for documentation to assess Linux compatibility.
* Automatically create or remove labels based on support verification results.
* Enhanced feedback for users regarding Linux server support status.

Co-authored-by: Copilot <copilot@github.com>

fix(labeler): broaden Linux support check triggers for server requests

fix(labeler): improve game detection logic for structured fields

* Refine fallback logic to avoid guessing from free text when a structured Game field exists.
* Ensure AI-detected game fallback only occurs when no structured Game field is present.

Co-authored-by: Copilot <copilot@github.com>

fix(labeler): update game section extraction logic

* Adjusted the extraction of the game field to accommodate both 'Game server' and 'Game' sections.
* This change improves compatibility with different input formats in server requests and bug reports.

Co-authored-by: Copilot <copilot@github.com>

feat(labeler): enhance Linux support detection logic

* Added handling for dedicated server tool AppIDs in Steam API checks.
* Introduced a checkbox confirmation mechanism for Linux support, improving evidence assessment.
* Updated verdict messaging to clarify Linux support status based on new checks.

Co-authored-by: Copilot <copilot@github.com>

feat(labeler): implement SteamCMD Linux support check

* Added a new function `runSteamCmdLinuxCheck` to assess Linux platform support using SteamCMD.
* Enhanced existing logic to incorporate SteamCMD results alongside Steam API checks.
* Updated comments to clarify the distinction between server tool AppIDs and client platform support.
* Improved error handling and logging for SteamCMD assessments.

Co-authored-by: Copilot <copilot@github.com>

feat(labeler): add confirmed Linux support label logic

* Introduced logic to determine and apply a `status: linux support confirmed` label based on AI assessment and Steam confirmation.
* Implemented error handling for label creation if it does not exist.
* Enhanced existing label management to ensure accurate representation of Linux support status.

Co-authored-by: Copilot <copilot@github.com>

fix(labeler): refine Steam support checks for dedicated servers

* Updated comments to clarify the purpose of Steam Store API checks.
* Removed unnecessary checks for Steam API results in determining Linux support.
* Enhanced logging and reasoning for server tool AppIDs.
* Improved clarity in the output messages regarding Linux support status.

Co-authored-by: Copilot <copilot@github.com>

feat(labeler): enhance Linux support check comment formatting

* Added conditional header for Linux support check comments.
* Included a block to indicate if the confirmed Linux label was applied.

Co-authored-by: Copilot <copilot@github.com>

fix: apply linux status labels directly instead of via deferred labelsToAdd set

chore: synced file(s) with dgibbs64/repo-sync (#4911)

* chore: synced local '.github/' with remote 'general/.github/'

* chore: synced local './' with remote 'bash/'
refactor: rename labeler.yml to issue-triage-automation.yml and consolidate sync-game-labels

- Rename 'labeler.yml' to 'issue-triage-automation.yml' to better reflect the workflow's expanded responsibilities (issue triage, game detection, AI analysis, Linux support verification, and label syncing)
- Consolidate 'sync-game-labels.yml' into the main workflow
- Add push trigger for serverlist.csv changes
- Add sync-game-labels job that runs on push events when serverlist.csv is modified
- Remove the separate sync-game-labels.yml workflow

This simplifies workflow organization by having all issue/PR maintenance logic in a single, well-named workflow.

refactor: standardize workflow filenames with action prefix

chore(sync): synced local '.github/' with remote 'general/.github/' (#4918)

Synced from dgibbs64/repo-sync@732370ff26a0d88345bb607f666e3110d343d997 — chore(workflow): add permissions section to action-sync.yml

* Added an empty `permissions` section to the workflow file.
* This change ensures that permissions are explicitly defined for the jobs.
Daniel Gibbs 1 ヶ月 前
コミット
c188059835

+ 28 - 28
.devcontainer/devcontainer.json

@@ -1,30 +1,30 @@
 {
-	"name": "BASH Dev Container",
-	"image": "mcr.microsoft.com/devcontainers/base:ubuntu",
-	"features": {
-		"ghcr.io/devcontainers-community/npm-features/prettier:1": {
-			"plugins": "prettier-plugin-sh"
-		},
-		"ghcr.io/devcontainers-extra/features/actionlint:1": {},
-		"ghcr.io/devcontainers-extra/features/checkov:1": {},
-		"ghcr.io/devcontainers-extra/features/markdownlint-cli:1": {},
-		"ghcr.io/devcontainers-extra/features/shellcheck:1": {},
-		"ghcr.io/devcontainers-extra/features/yamllint:2": {},
-		"ghcr.io/devcontainers/features/github-cli:1": {}
-	},
-	"customizations": {
-		"vscode": {
-			"extensions": [
-				"DavidAnson.vscode-markdownlint",
-				"editorconfig.editorconfig",
-				"esbenp.prettier-vscode",
-				"github.vscode-github-actions",
-				"GitHub.vscode-pull-request-github",
-				"redhat.vscode-yaml",
-				"timonwong.shellcheck",
-				"yzhang.markdown-all-in-one"
-			]
-		}
-	},
-	"postCreateCommand": "npm init -y >/dev/null 2>&1 || true && npm install --no-save prettier prettier-plugin-sh prettier-plugin-jinja-template"
+  "name": "BASH Dev Container",
+  "image": "mcr.microsoft.com/devcontainers/base:ubuntu",
+  "features": {
+    "ghcr.io/devcontainers-community/npm-features/prettier:1": {
+      "plugins": "prettier-plugin-sh"
+    },
+    "ghcr.io/devcontainers-extra/features/actionlint:1": {},
+    "ghcr.io/devcontainers-extra/features/checkov:1": {},
+    "ghcr.io/devcontainers-extra/features/markdownlint-cli:1": {},
+    "ghcr.io/devcontainers-extra/features/shellcheck:1": {},
+    "ghcr.io/devcontainers-extra/features/yamllint:2": {},
+    "ghcr.io/devcontainers/features/github-cli:1": {}
+  },
+  "customizations": {
+    "vscode": {
+      "extensions": [
+        "DavidAnson.vscode-markdownlint",
+        "editorconfig.editorconfig",
+        "esbenp.prettier-vscode",
+        "github.vscode-github-actions",
+        "GitHub.vscode-pull-request-github",
+        "redhat.vscode-yaml",
+        "timonwong.shellcheck",
+        "yzhang.markdown-all-in-one"
+      ]
+    }
+  },
+  "postCreateCommand": "npm init -y >/dev/null 2>&1 || true && npm install --no-save prettier prettier-plugin-sh prettier-plugin-jinja-template && sudo apt-get update && sudo apt-get install -y ripgrep"
 }

+ 80 - 1
.github/ISSUE_TEMPLATE/bug_report.yml

@@ -8,6 +8,51 @@ body:
     attributes:
       value: |
         Thanks for taking the time to fill out this bug report!
+  - type: dropdown
+    id: severity
+    attributes:
+      label: Severity
+      description: Triage metadata used for prioritization.
+      options:
+        - "severity: low"
+        - "severity: medium"
+        - "severity: high"
+        - "severity: critical"
+    validations:
+      required: true
+  - type: dropdown
+    id: reproducibility
+    attributes:
+      label: Reproducibility
+      description: Triage metadata used for prioritization.
+      options:
+        - "reproducible: always"
+        - "reproducible: sometimes"
+        - "reproducible: unable"
+    validations:
+      required: true
+  - type: dropdown
+    id: regression
+    attributes:
+      label: Regression
+      description: Triage metadata used for prioritization.
+      options:
+        - "regression: yes"
+        - "regression: no"
+        - "regression: unknown"
+    validations:
+      required: true
+  - type: dropdown
+    id: affects-latest
+    attributes:
+      label: Affects latest release
+      description: Triage metadata used for prioritization.
+      options:
+        - "latest-release: yes"
+        - "latest-release: no"
+        - "latest-release: unknown"
+    validations:
+      required: true
   - type: input
     id: user-story
     attributes:
@@ -16,6 +61,14 @@ body:
       placeholder: As a [user description], I want [desired action] so that [desired outcome].
     validations:
       required: true
+  - type: input
+    id: script-name
+    attributes:
+      label: Script name
+      description: LinuxGSM script name in use.
+      placeholder: vhserver
+    validations:
+      required: true
   - type: input
     id: game
     attributes:
@@ -66,6 +119,22 @@ body:
         - "command: send"
     validations:
       required: true
+  - type: textarea
+    id: expected-behavior
+    attributes:
+      label: Expected behavior
+      description: What should happen?
+      placeholder: Describe the expected result.
+    validations:
+      required: true
+  - type: textarea
+    id: actual-behavior
+    attributes:
+      label: Actual behavior
+      description: What actually happens?
+      placeholder: Describe the observed result.
+    validations:
+      required: true
   - type: textarea
     id: further-info
     attributes:
@@ -74,11 +143,19 @@ body:
       placeholder: Tell us what you see!
     validations:
       required: true
+  - type: checkboxes
+    id: prechecks
+    attributes:
+      label: Pre-checks
+      description: Confirm standard troubleshooting has been completed.
+      options:
+        - label: I ran update and validate before reporting this issue.
+          required: true
   - type: textarea
     id: logs
     attributes:
       label: Relevant log output
-      description: Please copy and paste any relevant log output. This will be automatically formatted into code, so no need for backticks.
+      description: Include the exact command used and the full related output (debug/details if available). This will be automatically formatted into code.
       render: shell
   - type: textarea
     id: steps
@@ -90,3 +167,5 @@ body:
         2. Click on '....'
         3. Scroll down to '....'
         4. See error
+    validations:
+      required: true

+ 3 - 0
.github/ISSUE_TEMPLATE/config.yml

@@ -6,3 +6,6 @@ contact_links:
   - name: Discord Server
     about: Join the LinuxGSM Discord community server. Discuss your LinuxGSM setup, get help and advice
     url: https://linuxgsm.com/discord
+  - name: Report a security vulnerability
+    about: Please report security vulnerabilities privately, not in public issues.
+    url: https://github.com/GameServerManagers/LinuxGSM/security/advisories/new

+ 41 - 1
.github/ISSUE_TEMPLATE/feature_request.yml

@@ -8,6 +8,17 @@ body:
     attributes:
       value: |
         Thanks for taking the time to fill out this feature request!
+  - type: dropdown
+    id: priority
+    attributes:
+      label: Priority
+      description: How important is this feature to you?
+      options:
+        - "priority: low"
+        - "priority: medium"
+        - "priority: high"
+    validations:
+      required: true
   - type: input
     id: user-story
     attributes:
@@ -64,12 +75,41 @@ body:
         - "command: update-lgsm"
         - "command: wipe"
         - "command: send"
+    validations:
+      required: false
+  - type: textarea
+    id: problem-statement
+    attributes:
+      label: Problem statement
+      description: What is painful today, and why is this needed?
+      placeholder: Describe the current limitation or pain point.
+    validations:
+      required: true
+  - type: dropdown
+    id: scope-impact
+    attributes:
+      label: Scope and impact
+      description: Which area would this change impact?
+      options:
+        - "scope: single game"
+        - "scope: multiple games"
+        - "scope: all servers"
+        - "scope: documentation only"
+        - "scope: ci/cd or automation"
+        - "scope: other"
     validations:
       required: true
   - type: textarea
     id: further-info
     attributes:
       label: Further information
-      description: A clear description of what the feature is and any ideas on how to achieve this.
+      description: A clear description of the proposed solution and any implementation ideas.
     validations:
       required: true
+  - type: textarea
+    id: alternatives
+    attributes:
+      label: Alternatives considered
+      description: Describe alternatives or workarounds you considered.
+    validations:
+      required: false

+ 33 - 5
.github/ISSUE_TEMPLATE/server_request.yml

@@ -7,7 +7,7 @@ body:
   - type: markdown
     attributes:
       value: |
-        Thanks for taking the time to fill out this game server!
+        Thanks for taking the time to fill out this game server request!
   - type: input
     id: game-server
     attributes:
@@ -15,11 +15,19 @@ body:
       description: What game server would you like to add?
     validations:
       required: true
+  - type: checkboxes
+    id: dedicated-server
+    attributes:
+      label: Dedicated server
+      description: Confirm this is a dedicated server request and not client hosting.
+      options:
+        - label: "Yes, this is a dedicated server (not client hosting)."
+          required: true
   - type: checkboxes
     id: on-linux
     attributes:
       label: Linux support
-      description: Does this game server have Linux support? (not wine)
+      description: Does this game server have native Linux server support? (not wine)
       options:
         - label: "Yes"
     validations:
@@ -38,20 +46,40 @@ body:
     id: steam-id
     attributes:
       label: Steam appid
-      description: What is the Steam appid of the game server? Use SteamDB to get the appid. (https://steamdb.info).
+      description: What is the Steam appid of the dedicated server? Required when Steam is Yes. Use SteamDB to get the appid (https://steamdb.info).
       placeholder: "892970"
     validations:
       required: false
+  - type: textarea
+    id: official-docs
+    attributes:
+      label: Official dedicated server documentation
+      description: Provide official documentation links for installing/running the dedicated server.
+      placeholder: |
+        https://example.com/docs/server-setup
+        https://example.com/docs/dedicated-server
+    validations:
+      required: true
+  - type: textarea
+    id: linux-binary-proof
+    attributes:
+      label: Linux binary proof
+      description: Provide evidence that Linux server binaries are available (official docs/download links/version notes).
+      placeholder: |
+        https://example.com/downloads/linux-dedicated-server
+        https://example.com/release-notes/linux-server
+    validations:
+      required: true
   - type: textarea
     id: guides
     attributes:
       label: Guides
-      description: Links to guides on how to install the game server
+      description: Links to community or third-party guides on how to install the game server.
   - type: checkboxes
     id: terms
     attributes:
       label: Code of Conduct
-      description: By submitting this issue, you agree to follow our [Code of Conduct](https://example.com)
+      description: By submitting this issue, you agree to follow our [Code of Conduct](https://github.com/GameServerManagers/LinuxGSM/blob/master/CODE_OF_CONDUCT.md)
       options:
         - label: I agree to follow this project's Code of Conduct
           required: true

+ 29 - 0
.github/instructions/pr-review.instructions.md

@@ -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.

+ 80 - 108
.github/labeler.yml

@@ -1,17 +1,17 @@
 "command: backup":
-  - "/(backup)/i"
+  - "/(command:\\s*backup)/i"
 "command: console":
-  - "/(console|tmux)/i"
+  - "/(command:\\s*console)/i"
 "command: debug":
   - "/(command: debug)/i"
 "command: details":
   - "/(command: details)/i"
 "command: fast-dl":
-  - "/(fast-dl|fastdl)/i"
+  - "/(command:\\s*fast-?dl)/i"
 "command: install":
-  - "/(install)/i"
+  - "/(command:\\s*install)/i"
 "command: mods":
-  - "/(command: mods)/i"
+  - "/(command:\\s*mods(?:-install|-update|-remove)?)/i"
 "command: monitor":
   - "/(command: monitor)/i"
 "command: post-details":
@@ -27,136 +27,108 @@
 "command: stop":
   - "/(command: stop)/i"
 "command: update-lgsm":
-  - "/(update-lgsm)/i"
+  - "/(command:\\s*update-lgsm)/i"
 "command: update":
-  - "/(command: update)/i"
+  - "/(command:\\s*update(?!-lgsm)\\b)/i"
 "command: validate":
-  - "/(validate)/i"
+  - "/(command:\\s*validate)/i"
 "command: wipe":
-  - "/(wipe)/i"
+  - "/(command:\\s*wipe)/i"
 
 # Distros
 "distro: AlmaLinux":
-  - "/(Alma)/i"
+  - "/\\bAlmaLinux(?:\\s+\\d+)?\\b/i"
 "distro: Arch Linux":
-  - "/(Arch Linux)/i"
+  - "/\\bArch Linux\\b/i"
 "distro: CentOS":
-  - "/(CentOS)/i"
+  - "/\\bCentOS(?:\\s+\\d+)?\\b/i"
 "distro: Debian":
-  - "/(Debian)/i"
+  - "/\\bDebian(?:\\s+\\d+)?\\b/i"
 "distro: Fedora":
-  - "/(Fedora)/i"
+  - "/\\bFedora(?:\\s+\\d+)?\\b/i"
 "distro: openSUSE":
-  - "/(openSUSE|suse)/i"
+  - "/\\bopenSUSE\\b/i"
 "distro: Rocky Linux":
-  - "/(Rocky)/i"
+  - "/\\bRocky(?:\\s+Linux)?(?:\\s+\\d+)?\\b/i"
 "distro: Slackware":
-  - "/(Slackware)/i"
+  - "/\\bSlackware(?:\\s+\\d+)?\\b/i"
 "distro: Ubuntu":
-  - "/(Ubuntu)/i"
-
-# Games
-"game: 7 Days to Die":
-  - "/(7 Days to Die|sdtd)/i"
-"game: Ark: Survival Evolved":
-  - "/(Ark: Survival Evolved|Ark)/i"
-"game: ARMA 3":
-  - "/(ARMA 3|ARMA3)/i"
-"game: Assetto Corsa":
-  - "/(Assetto Corsa)/i"
-"game: Avorion":
-  - "/(Avorion)/i"
-"game: Ballistic Overkill":
-  - "/(Ballistic Overkill)/i"
-"game: BATTALION: Legacy":
-  - "/(BATTALION: Legacy)/i"
-"game: Barotrauma":
-  - "/(Barotrauma)/i"
-"game: Counter-Strike: Global Offensive":
-  - "/(Counter-Strike: Global Offensive|CS:GO|csgo)/i"
-"game: Counter-Strike 2":
-  - "/(Counter-Strike 2|CS2)/i"
-"game: Counter-Strike: Source":
-  - "/(Counter-Strike: Source|CS:S)/i"
-"game: Counter-Strike 1.6":
-  - "/(Counter-Strike 1.6|Counter Strike 1.6|CS 1.6|cs1.6)/i"
-"game: Dayz":
-  - "/(Dayz)/i"
-"game: Don't Starve Together":
-  - "/(Don't Starve Together|Dont Starve Together|DST)/i"
-"game: Eco":
-  - "/(^Eco$)/i"
-"game: Factorio":
-  - "/(Factorio)/i"
-"game: Garry's Mod":
-  - "/(Garry's Mod|Garrys Mod|GMod)/i"
-"game: Insurgency: Sandstorm":
-  - "/(Insurgency: Sandstorm|Insurgency)/i"
-"game: Killing Floor 2":
-  - "/(Killing Floor 2|KF2)/i"
-"game: Left 4 Dead 2":
-  - "/(Left 4 Dead 2|L4D2)/i"
-"game: Minecraft":
-  - "/(Minecraft)((?!bedrock).)*$/i"
-"game: Minecraft Bedrock":
-  - "/(Bedrock)/i"
-"game: Mumble":
-  - "/(Mumble)/i"
-"game: Project Zomboid":
-  - "/(Project Zomboid|PZ)/i"
-"game: Quake 3":
-  - "/(Quake 3|Q3A|q3)/i"
-"game: Rising World":
-  - "/(Rising World)/i"
-"game: Satisfactory":
-  - "/(Satisfactory)/i"
-"game: Squad":
-  - "/(Squad)/i"
-"game: Starbound":
-  - "/(Starbound)/i"
-"game: Stationeers":
-  - "/(Stationeers)/i"
-"game: Teamspeak 3":
-  - "/(Teamspeak 3|ts3)/i"
-"game: Rust":
-  - "/(Rust)/i"
-"game: Unturned":
-  - "/(Unturned)/i"
-"game: Unreal Tournament 99":
-  - "/(Unreal Tournament 99|ut99)/i"
-"game: Unreal Tournament 2004":
-  - "/(Unreal Tournament 2004|ut2k4)/i"
-"game: Unreal Tournament 3":
-  - "/(Unreal Tournament 3|ut3)/i"
-"game: Valheim":
-  - "/(Valheim)/i"
+  - "/\\bUbuntu(?:\\s+\\d+(?:\\.\\d+)?)?\\b/i"
 
 # Info
 "info: alerts":
-  - "/(alert)/i"
+  - "/(alert_(discord|email|gotify|ifttt|ntfy|pushbullet|pushover|rocketchat|slack|telegram)|command:\\s*test-alert)/i"
 "info: dependency":
-  - "/(dependency|deps)/i"
+  - "/\\b(dependency|dependencies|deps)\\b/i"
 "info: docker":
-  - "/(docker)/i"
+  - "/\\bdocker\\b/i"
 "info: docs":
-  - "/(documentation|^docs$)/i"
+  - "/(^docs$)/i"
 "info: email":
-  - "/(postfix|sendmail|exim|smtp)/i"
+  - "/\\b(postfix|sendmail|exim|smtp)\\b/i"
 "info: query":
-  - "/(gamedig|gsquery)/i"
+  - "/\\b(gamedig|gsquery)\\b/i"
 "info: steamcmd":
-  - "/(steamcmd)/i"
+  - "/\\bsteamcmd\\b/i"
 "info: systemd":
-  - "/(systemd)/i"
+  - "/\\bsystemd\\b/i"
 "info: tmux":
-  - "/(tmux)/i"
+  - "/(tmuxception|check_tmuxception)/i"
 "info: website":
-  - "/(website)/i"
+  - "/\\bwebsite\\b/i"
 
 # Type
 "type: game server request":
-  - "/(Server Request)/i"
+  - "/(^\\[server request\\]|^server request:|type:\\s*game server request)/im"
 "type: bug":
-  - "/(bug)/i"
-"type: feature request":
-  - "/(feature)/i"
+  - "/(\\[bug\\]|bug report|type: bug)/i"
+"type: bugfix":
+  - "/(^fix(\\(.+\\))?:|\\[x\\] Bug fix)/im"
+"type: feature":
+  - "/(feature request|new feature|^feat(\\(.+\\))?:|\\[x\\] New feature)/im"
+"type: docs":
+  - "/(^docs(\\(.+\\))?:|\\[x\\] Comment update)/im"
+"type: refactor":
+  - "/(^refactor(\\(.+\\))?:|\\[x\\] Refactor)/im"
+"type: chore":
+  - "/(^chore(\\(.+\\))?:|^ci(\\(.+\\))?:)/im"
+
+# Severity (bug reports)
+"severity: low":
+  - "/(severity: low)/i"
+"severity: medium":
+  - "/(severity: medium)/i"
+"severity: high":
+  - "/(severity: high)/i"
+"severity: critical":
+  - "/(severity: critical)/i"
+
+# Reproducibility (bug reports)
+"reproducible: always":
+  - "/(reproducible: always)/i"
+"reproducible: sometimes":
+  - "/(reproducible: sometimes)/i"
+"reproducible: unable":
+  - "/(reproducible: unable)/i"
+
+# Regression (bug reports)
+"regression: yes":
+  - "/(regression: yes)/i"
+
+# Priority (feature requests)
+"priority: low":
+  - "/(priority: low)/i"
+"priority: medium":
+  - "/(priority: medium)/i"
+"priority: high":
+  - "/(priority: high)/i"
+
+# Scope (feature requests)
+"scope: single game":
+  - "/(scope: single game)/i"
+"scope: multiple games":
+  - "/(scope: multiple games)/i"
+"scope: all servers":
+  - "/(scope: all servers)/i"
+"scope: documentation":
+  - "/(scope: documentation|scope: documentation only)/i"

+ 25 - 3
.github/pull_request_template.md

@@ -12,18 +12,40 @@ Fixes #[issue]
 - [ ] Refactor (restructures existing code).
 - [ ] Comment update (typo, spelling, explanation, examples, etc).
 
+## Testing
+
+Please list the exact validation you performed and the outcome.
+
+- Commands/tests run:
+- Result:
+- Environment used (distro/version):
+
+## Risk and rollback
+
+- Risk level: low / medium / high
+- Rollback plan:
+
+## Breaking changes
+
+- [ ] No breaking changes.
+- [ ] Breaking changes included (describe below).
+
+## Documentation impact
+
+- [ ] No documentation update required.
+- [ ] User documentation update required.
+- [ ] Developer documentation update required.
+
 ## Checklist
 
 PR will not be merged until all steps are complete.
 
 - [ ] This pull request links to an issue.
-- [ ] This pull request uses the `develop` branch as its base.
+- [ ] This pull request uses the develop branch as its base.
 - [ ] This pull request subject follows the Conventional Commits standard.
 - [ ] This code follows the style guidelines of this project.
 - [ ] I have performed a self-review of my code.
-- [ ] I have checked that this code is commented where required.
 - [ ] I have provided a detailed enough description of this PR.
-- [ ] I have checked if documentation needs updating.
 
 ## Documentation
 

+ 4 - 4
.github/workflows/details-check-generate-matrix.sh → .github/scripts/details-check-generate-matrix.sh

@@ -15,10 +15,10 @@ while read -r line; do
 	distro=$(echo "$line" | awk -F, '{ print $4 }')
 	export distro
 	{
-		echo -n "{";
-		echo -n "\"shortname\":";
-		echo -n "\"${shortname}\"";
-		echo -n "},";
+		echo -n "{"
+		echo -n "\"shortname\":"
+		echo -n "\"${shortname}\""
+		echo -n "},"
 	} >> "shortnamearray.json"
 done < <(tail -n +2 serverlist.csv)
 sed -i '$ s/.$//' "shortnamearray.json"

+ 0 - 0
.github/workflows/serverlist-validate-game-icons.sh → .github/scripts/serverlist-validate-game-icons.sh


+ 0 - 16
.github/workflows/serverlist-validate.sh → .github/scripts/serverlist-validate.sh

@@ -22,20 +22,4 @@ for csv in "${csvlist[@]}"; do
 	fi
 done
 
-# Compare all game servers listed in serverlist.csv to $shortname-icon.png files in ${datadir}/gameicons
-# if the game server is listed in serverlist.csv then it will have a $shortname-icon.png file
-
-# loop though shortname in serverlist.csv
-echo ""
-echo "Checking that all the game servers listed in serverlist.csv have a shortname-icon.png file"
-for shortname in $(tail -n +2 serverlist.csv | cut -d ',' -f1); do
-	# check if $shortname-icon.png exists
-	if [ ! -f "gameicons/${shortname}-icon.png" ]; then
-		echo "ERROR: gameicons/${shortname}-icon.png does not exist"
-		exitcode=1
-	else
-		echo "OK: gameicons/${shortname}-icon.png exists"
-	fi
-done
-
 exit "${exitcode}"

+ 87 - 0
.github/scripts/sync-game-labels.sh

@@ -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}"

+ 0 - 0
.github/workflows/version-check.sh → .github/scripts/version-check.sh


+ 0 - 0
.github/workflows/add-to-project.yml → .github/workflows/action-add-to-project.yml


+ 1 - 1
.github/workflows/details-check.yml → .github/workflows/action-details-check.yml

@@ -24,7 +24,7 @@ jobs:
         uses: actions/checkout@v4
 
       - name: Generate matrix with generate-matrix.sh
-        run: chmod +x .github/workflows/details-check-generate-matrix.sh; .github/workflows/details-check-generate-matrix.sh
+        run: .github/scripts/details-check-generate-matrix.sh
 
       - name: Set Matrix
         id: set-matrix

+ 1 - 1
.github/workflows/git-sync.yml → .github/workflows/action-git-sync.yml

@@ -15,7 +15,7 @@ jobs:
     runs-on: ubuntu-latest
     steps:
       - name: SSH Agent
-        uses: webfactory/ssh-agent@v0.9.0
+        uses: webfactory/ssh-agent@v0.10.0
         with:
           ssh-private-key: ${{ secrets.BITBUCKET_SECRET }}
 

+ 2304 - 0
.github/workflows/action-issue-triage-automation.yml

@@ -0,0 +1,2304 @@
+name: Issue Triage & Automation
+on:
+  workflow_dispatch:
+    inputs:
+      issue_state:
+        description: Issue state to backfill
+        required: true
+        default: all
+        type: choice
+        options:
+          - all
+          - open
+          - closed
+      limit:
+        description: Max issues to process (0 = all)
+        required: true
+        default: "0"
+        type: string
+      ai_game_fallback:
+        description: Use AI only when deterministic game mapping finds no game
+        required: true
+        default: "false"
+        type: choice
+        options:
+          - "false"
+          - "true"
+  issues:
+    types:
+      - opened
+      - edited
+      - reopened
+      - labeled
+      - unlabeled
+      - assigned
+      - unassigned
+      - milestoned
+      - demilestoned
+      - transferred
+      - pinned
+      - unpinned
+  issue_comment:
+    types:
+      - created
+      - edited
+      - deleted
+  pull_request:
+    types:
+      - opened
+      - edited
+      - synchronize
+      - reopened
+  push:
+    branches:
+      - master
+      - develop
+    paths:
+      - "lgsm/data/serverlist.csv"
+
+permissions:
+  issues: write
+  pull-requests: write
+  contents: read
+  models: read
+
+env:
+  FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
+
+jobs:
+  issue-regex-labeler:
+    if: github.repository_owner == 'GameServerManagers' && github.event_name == 'issues' && (github.event.action == 'opened' || github.event.action == 'edited')
+    runs-on: ubuntu-latest
+    steps:
+      - name: Issue Labeler
+        uses: github/issue-labeler@v3.4
+        with:
+          repo-token: "${{ secrets.GITHUB_TOKEN }}"
+          configuration-path: .github/labeler.yml
+          enable-versioned-regex: 0
+          include-title: 1
+          sync-labels: 0
+
+  issue-ai-maintenance:
+    if: github.repository_owner == 'GameServerManagers' && (github.event_name == 'issues' || github.event_name == 'issue_comment')
+    runs-on: ubuntu-latest
+    steps:
+      - name: Reconcile issue labels and AI triage
+        uses: actions/github-script@v9
+        env:
+          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+        with:
+          script: |
+            const { execFileSync } = require('node:child_process');
+            const owner = context.repo.owner;
+            const repo = context.repo.repo;
+            const eventName = context.eventName;
+            const action = context.payload.action;
+            const issueNumber = context.payload.issue?.number;
+            const AI_MARKER = '<!-- ai-triage -->';
+
+            if (!issueNumber) {
+              console.log('No issue number found in payload.');
+              return;
+            }
+
+            // Avoid bot-to-bot relabel loops on label events.
+            if (
+              eventName === 'issues' &&
+              ['labeled', 'unlabeled'].includes(action) &&
+              context.actor === 'github-actions[bot]'
+            ) {
+              console.log('Skipping self-triggered label event.');
+              return;
+            }
+
+            const issueResp = await github.rest.issues.get({
+              owner,
+              repo,
+              issue_number: issueNumber,
+            });
+            const issue = issueResp.data;
+            const title = issue.title || '';
+            const body = issue.body || '';
+            const existingLabels = new Set((issue.labels || []).map((l) => l.name).filter(Boolean));
+
+            function parseTriageResponse(raw) {
+              const input = (raw || '').trim();
+              if (!input) return {};
+
+              const candidates = [input];
+              const fenced = input.match(/```(?:json)?\s*([\s\S]*?)```/i);
+              if (fenced?.[1]) candidates.push(fenced[1].trim());
+
+              const firstBrace = input.indexOf('{');
+              const lastBrace = input.lastIndexOf('}');
+              if (firstBrace !== -1 && lastBrace > firstBrace) {
+                candidates.push(input.slice(firstBrace, lastBrace + 1));
+              }
+
+              for (const candidate of candidates) {
+                try {
+                  return JSON.parse(candidate);
+                } catch (_err) {
+                  // Continue trying fallbacks.
+                }
+              }
+
+              return {};
+            }
+
+            function extractSection(sectionName) {
+              const escaped = sectionName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+              const re = new RegExp(`### ${escaped}\\n\\n([\\s\\S]*?)(\\n### |$)`, 'i');
+              return (body.match(re)?.[1] || '').trim();
+            }
+
+            function normalizeName(value) {
+              return (value || '')
+                .toLowerCase()
+                .replace(/[’'`]/g, '')
+                .replace(/[^a-z0-9]+/g, ' ')
+                .trim();
+            }
+
+            function parseGameCandidates(gameField) {
+              if (!gameField || /^_?no response_?$/i.test(gameField)) {
+                return [];
+              }
+              return gameField
+                .replace(/\(.*?\)/g, ' ')
+                .split(/\n|,|\s+&\s+|\s+and\s+|\//i)
+                .map((v) => v.trim())
+                .filter(Boolean);
+            }
+
+            function findGamesFromText(text, gameAliasToLabel, gameAliasToScript) {
+              const labels = new Set();
+              const scripts = new Set();
+              const normalizedText = normalizeName(text);
+              if (!normalizedText) return { labels, scripts };
+
+              const escapeRegex = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+              const aliases = [];
+              for (const [alias, label] of gameAliasToLabel.entries()) {
+                if (alias.length < 3) continue;
+                aliases.push({ alias, label, script: gameAliasToScript.get(alias) || null });
+              }
+
+              // Prefer longer aliases first so "killing floor 2" does not also match "killing floor".
+              aliases.sort((a, b) => b.alias.length - a.alias.length);
+
+              const usedRanges = [];
+              const isOverlapping = (start, end) =>
+                usedRanges.some((range) => start < range.end && end > range.start);
+
+              for (const entry of aliases) {
+                const pattern = new RegExp(`\\b${escapeRegex(entry.alias).replace(/\\ /g, '\\s+')}\\b`, 'g');
+                let match;
+                while ((match = pattern.exec(normalizedText)) !== null) {
+                  const start = match.index;
+                  const end = start + match[0].length;
+                  if (isOverlapping(start, end)) continue;
+
+                  labels.add(entry.label);
+                  if (entry.script) scripts.add(entry.script);
+                  usedRanges.push({ start, end });
+                }
+              }
+
+              return { labels, scripts };
+            }
+
+            function hasAliasHitForLabel(text, targetLabel, gameAliasToLabel) {
+              const normalizedText = normalizeName(text);
+              if (!normalizedText || !targetLabel) return false;
+
+              const paddedText = ` ${normalizedText} `;
+              for (const [alias, label] of gameAliasToLabel.entries()) {
+                if (label !== targetLabel) continue;
+                if (alias.length < 3) continue;
+                if (paddedText.includes(` ${alias} `)) return true;
+
+                // Allow obvious joined-word variants for multi-token aliases
+                // (e.g., "counter strike 1 6" matching "counterstrike 1.6").
+                const aliasTokens = alias.split(/\s+/).filter(Boolean);
+                if (aliasTokens.length > 1) {
+                  const escapedTokens = aliasTokens.map((token) =>
+                    token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
+                  );
+                  const flexibleAliasPattern = new RegExp(`\\b${escapedTokens.join('\\s*')}\\b`);
+                  if (flexibleAliasPattern.test(normalizedText)) return true;
+                }
+              }
+
+              return false;
+            }
+
+            function runSteamCmdLinuxCheck(appId) {
+              if (!appId) {
+                return { status: 'skipped', reason: 'No Steam AppID provided.' };
+              }
+
+              const image = 'gameservermanagers/steamcmd:latest';
+              const args = [
+                'run',
+                '--rm',
+                '-e',
+                'PUID=1001',
+                '-e',
+                'PGID=1001',
+                image,
+                '+@ShutdownOnFailedCommand',
+                '1',
+                '+@NoPromptForPassword',
+                '1',
+                '+login',
+                'anonymous',
+                '+app_info_update',
+                '1',
+                '+app_info_print',
+                String(appId),
+                '+quit',
+              ];
+
+              try {
+                const output = execFileSync('docker', args, {
+                  encoding: 'utf8',
+                  stdio: ['ignore', 'pipe', 'pipe'],
+                  timeout: 120000,
+                  maxBuffer: 10 * 1024 * 1024,
+                });
+
+                const normalized = output.toLowerCase();
+                const linuxSignals = [
+                  /"oslist"\s+"linux"/i,
+                  /"oslist"\s+"linux,windows"/i,
+                  /"oslist"\s+"windows,linux"/i,
+                  /"platforms"[\s\S]*?"linux"\s+"1"/i,
+                  /linux32/i,
+                  /linux64/i,
+                ];
+                const windowsOnlySignals = [
+                  /"oslist"\s+"windows"/i,
+                  /"platforms"[\s\S]*?"windows"\s+"1"/i,
+                ];
+
+                const hasLinuxSignal = linuxSignals.some((re) => re.test(normalized));
+                const hasWindowsOnlySignal =
+                  !hasLinuxSignal && windowsOnlySignals.some((re) => re.test(normalized));
+
+                if (hasLinuxSignal) {
+                  return {
+                    status: 'linux',
+                    reason: `SteamCMD app_info contains Linux platform/depot metadata for AppID ${appId}.`,
+                  };
+                }
+
+                if (hasWindowsOnlySignal) {
+                  return {
+                    status: 'windows-only',
+                    reason: `SteamCMD app_info contains Windows-only platform metadata for AppID ${appId}.`,
+                  };
+                }
+
+                return {
+                  status: 'unknown',
+                  reason: `SteamCMD app_info returned no clear Linux server metadata for AppID ${appId}.`,
+                };
+              } catch (err) {
+                const stderr = err.stderr ? String(err.stderr).trim() : '';
+                const stdout = err.stdout ? String(err.stdout).trim() : '';
+                const message = stderr || stdout || err.message;
+                return {
+                  status: 'error',
+                  reason: `SteamCMD lookup failed: ${message}`,
+                };
+              }
+            }
+
+            function parseServerlistCsv(csvText) {
+              const rows = [];
+              const lines = (csvText || '').split(/\r?\n/);
+              for (let i = 1; i < lines.length; i += 1) {
+                const line = lines[i]?.trim();
+                if (!line) continue;
+                const parts = line.split(',');
+                if (parts.length < 3) continue;
+                rows.push({
+                  shortname: parts[0].trim(),
+                  gameservername: parts[1].trim(),
+                  gamename: parts[2].trim(),
+                });
+              }
+              return rows;
+            }
+            function inferTypeFromTitle(issueTitle) {
+              if (/^\[bug\]/i.test(issueTitle)) return 'type: bug';
+              if (/\bserver\s+request\b/i.test(issueTitle)) return 'type: game server request';
+              const hasBracketPrefix = /^\[[^\]]+\]/.test(issueTitle || '');
+              const isServerCreation =
+                /\bserver\s+creation\b/i.test(issueTitle) ||
+                (hasBracketPrefix && /\bcreation\b/i.test(issueTitle));
+              const isServerSupportRequest =
+                /\bserver\s+support\b/i.test(issueTitle) ||
+                (/\bsupport\s+for\b/i.test(issueTitle) && /\bserver\b/i.test(issueTitle));
+              if (isServerCreation || isServerSupportRequest) return 'type: game server request';
+              if (/^\[feature\]/i.test(issueTitle)) return 'type: feature';
+              if (/^\[server request\]/i.test(issueTitle)) return 'type: game server request';
+              if (/^\[docs?\]/i.test(issueTitle)) return 'type: docs';
+              return null;
+            }
+
+            function inferDesiredType(issueTitle, labelNames) {
+              const titleType = inferTypeFromTitle(issueTitle);
+              if (titleType) return titleType;
+
+              // Prefer server requests over generic feature when both labels exist.
+              if (labelNames.has('type: game server request')) return 'type: game server request';
+
+              for (const label of [
+                'type: bug',
+                'type: feature',
+                'type: game server request',
+                'type: docs',
+              ]) {
+                if (labelNames.has(label)) return label;
+              }
+
+              return null;
+            }
+
+            function inferIssueTypeNameFromDesiredType(typeLabel) {
+              if (typeLabel === 'type: bug') return 'Bug';
+              if (typeLabel === 'type: feature') return 'Feature';
+              if (typeLabel === 'type: game server request') return 'Server Request';
+              if (typeLabel === 'type: docs') return 'Task';
+              return null;
+            }
+
+            function parseCommandSelections(sectionValue) {
+              const selected = new Set();
+              const re = /command:\s*([a-z-]+)/gi;
+              let m;
+              while ((m = re.exec(sectionValue || '')) !== null) {
+                let value = m[1].toLowerCase();
+                if (value.startsWith('mods-')) value = 'mods';
+                if (value === 'auto-update') value = 'update';
+                selected.add(`command: ${value}`);
+              }
+              return selected;
+            }
+
+            function parseDistroSelections(sectionValue) {
+              const text = sectionValue || '';
+              const selected = new Set();
+              if (/\bUbuntu\b/i.test(text)) selected.add('distro: Ubuntu');
+              if (/\bDebian\b/i.test(text)) selected.add('distro: Debian');
+              if (/\bAlmaLinux\b/i.test(text)) selected.add('distro: AlmaLinux');
+              if (/\bRocky\b/i.test(text)) selected.add('distro: Rocky Linux');
+              if (/\bCentOS\b/i.test(text)) selected.add('distro: CentOS');
+              if (/\bFedora\b/i.test(text)) selected.add('distro: Fedora');
+              if (/\bopenSUSE\b/i.test(text)) selected.add('distro: openSUSE');
+              if (/\bArch Linux\b/i.test(text)) selected.add('distro: Arch Linux');
+              if (/\bSlackware\b/i.test(text)) selected.add('distro: Slackware');
+              return selected;
+            }
+
+            const repoLabels = await github.paginate(github.rest.issues.listLabelsForRepo, {
+              owner,
+              repo,
+              per_page: 100,
+            });
+
+            const gameLabelByNormalized = new Map();
+            for (const label of repoLabels) {
+              if (!label.name.startsWith('game: ')) continue;
+              gameLabelByNormalized.set(normalizeName(label.name.slice(6)), label.name);
+            }
+            const existingEngineLabels = new Set(
+              repoLabels.map((label) => label.name).filter((name) => name.startsWith('engine: '))
+            );
+
+            const gameAliasToLabel = new Map();
+            const gameAliasToScript = new Map();
+            const engineByScript = new Map();
+            for (const [normalizedGameName, label] of gameLabelByNormalized.entries()) {
+              gameAliasToLabel.set(normalizedGameName, label);
+            }
+
+            try {
+              const serverlistContent = await github.rest.repos.getContent({
+                owner,
+                repo,
+                path: 'lgsm/data/serverlist.csv',
+              });
+              const encoded = serverlistContent.data?.content || '';
+              const csvText = Buffer.from(encoded, 'base64').toString('utf8');
+              const serverRows = parseServerlistCsv(csvText);
+
+              for (const row of serverRows) {
+                const canonicalLabel = gameLabelByNormalized.get(normalizeName(row.gamename));
+                if (!canonicalLabel) continue;
+
+                for (const alias of [row.shortname, row.gameservername, row.gamename]) {
+                  const key = normalizeName(alias);
+                  if (!key) continue;
+                  gameAliasToLabel.set(key, canonicalLabel);
+                  gameAliasToScript.set(key, row.gameservername);
+                }
+              }
+            } catch (err) {
+              console.log(`Could not load serverlist aliases: ${err.message}`);
+            }
+
+            async function ensureEngineLabel(engineLabel) {
+              if (existingEngineLabels.has(engineLabel)) return;
+              try {
+                await github.rest.issues.createLabel({
+                  owner,
+                  repo,
+                  name: engineLabel,
+                  color: '000000',
+                  description: `Issues related to ${engineLabel.slice(8)} engine`,
+                });
+                existingEngineLabels.add(engineLabel);
+              } catch (err) {
+                if (err.status === 422) {
+                  existingEngineLabels.add(engineLabel);
+                  return;
+                }
+                console.log(`Could not create engine label "${engineLabel}": ${err.message}`);
+              }
+            }
+
+            async function getEngineForScript(scriptName) {
+              if (!scriptName) return null;
+              if (engineByScript.has(scriptName)) {
+                return engineByScript.get(scriptName);
+              }
+              try {
+                const cfgContent = await github.rest.repos.getContent({
+                  owner,
+                  repo,
+                  path: `lgsm/config-default/config-lgsm/${scriptName}/_default.cfg`,
+                });
+                const encoded = cfgContent.data?.content || '';
+                const cfgText = Buffer.from(encoded, 'base64').toString('utf8');
+                const engine = cfgText.match(/^engine="([^"]+)"/m)?.[1] || null;
+                engineByScript.set(scriptName, engine);
+                return engine;
+              } catch (err) {
+                console.log(`Could not detect engine for ${scriptName}: ${err.message}`);
+                engineByScript.set(scriptName, null);
+                return null;
+              }
+            }
+            const labelsToAdd = new Set();
+            const labelsToRemove = new Set();
+
+            // Deterministic reconciliation on every interaction.
+            const desiredType = inferDesiredType(title, existingLabels);
+            if (desiredType) {
+              labelsToAdd.add(desiredType);
+              for (const label of existingLabels) {
+                if (label.startsWith('type: ') && label !== desiredType) {
+                  labelsToRemove.add(label);
+                }
+              }
+
+              const desiredIssueTypeName = inferIssueTypeNameFromDesiredType(desiredType);
+              if (desiredIssueTypeName) {
+                try {
+                  const issueTypeData = await github.graphql(
+                    `query($owner:String!,$repo:String!,$number:Int!){
+                      repository(owner:$owner,name:$repo){
+                        issueTypes(first:20){ nodes { id name } }
+                        issue(number:$number){ id issueType { id name } }
+                      }
+                    }`,
+                    { owner, repo, number: issueNumber }
+                  );
+
+                  const issueNode = issueTypeData.repository?.issue;
+                  const issueTypes = issueTypeData.repository?.issueTypes?.nodes || [];
+                  const desiredIssueType = issueTypes.find((t) => t.name === desiredIssueTypeName);
+
+                  if (issueNode?.id && desiredIssueType?.id && issueNode.issueType?.id !== desiredIssueType.id) {
+                    await github.graphql(
+                      `mutation($id:ID!,$issueTypeId:ID!){
+                        updateIssue(input:{id:$id,issueTypeId:$issueTypeId}){
+                          issue { id number issueType { id name } }
+                        }
+                      }`,
+                      { id: issueNode.id, issueTypeId: desiredIssueType.id }
+                    );
+                  }
+                } catch (err) {
+                  console.log(`Could not sync Issue Type: ${err.message}`);
+                }
+              }
+            }
+
+            const commandSection = extractSection('Command');
+            const desiredCommands = parseCommandSelections(commandSection);
+            if (desiredCommands.size > 0) {
+              for (const label of desiredCommands) labelsToAdd.add(label);
+              for (const label of existingLabels) {
+                if (label.startsWith('command: ') && !desiredCommands.has(label)) {
+                  labelsToRemove.add(label);
+                }
+              }
+            }
+
+            const distroSection = extractSection('Linux distro');
+            const desiredDistros = parseDistroSelections(distroSection);
+            if (desiredDistros.size > 0) {
+              for (const label of desiredDistros) labelsToAdd.add(label);
+              for (const label of existingLabels) {
+                if (label.startsWith('distro: ') && !desiredDistros.has(label)) {
+                  labelsToRemove.add(label);
+                }
+              }
+            }
+
+            const tmuxContextPattern = /\b(tmuxception|check_tmuxception)\b/i;
+            if (existingLabels.has('info: tmux') && !tmuxContextPattern.test(`${title}\n${body}`)) {
+              labelsToRemove.add('info: tmux');
+            }
+
+            const desiredGames = new Set();
+            const desiredServerScripts = new Set();
+            // 'Game server' is the section name in server_request.yml; 'Game' is used in bug_report.yml.
+            const gameField = extractSection('Game server') || extractSection('Game');
+            const gameCandidates = parseGameCandidates(gameField);
+            const hasStructuredGameSelection = gameCandidates.length > 0;
+            for (const candidate of gameCandidates) {
+              const normalizedCandidate = normalizeName(candidate);
+              const mapped = gameAliasToLabel.get(normalizedCandidate) || gameLabelByNormalized.get(normalizedCandidate);
+              if (mapped) desiredGames.add(mapped);
+              const mappedScript = gameAliasToScript.get(normalizedCandidate);
+              if (mappedScript) desiredServerScripts.add(mappedScript);
+            }
+
+            // Legacy issues often have no form section; fall back to deterministic text matching.
+            // If a structured Game field exists but does not map, do not guess from free text.
+            if (desiredGames.size === 0 && !hasStructuredGameSelection) {
+              const fromText = findGamesFromText(`${title}\n${body}`, gameAliasToLabel, gameAliasToScript);
+              for (const label of fromText.labels) desiredGames.add(label);
+              for (const scriptName of fromText.scripts) desiredServerScripts.add(scriptName);
+            }
+
+            // AI advisory is only needed on issue opened/edited.
+            let triage = {};
+            let ranAi = false;
+            const shouldRunAi = eventName === 'issues' && ['opened', 'edited'].includes(action);
+            const shouldRunLinuxSupportCheck =
+              eventName === 'issues' &&
+              ['opened', 'edited', 'reopened', 'labeled', 'unlabeled'].includes(action);
+            if (shouldRunAi) {
+              ranAi = true;
+
+              const isShortBody = body.trim().length < 80;
+              if (isShortBody) {
+                labelsToAdd.add('needs: more info');
+              } else {
+                try {
+                  const res = await fetch(
+                    `https://models.github.ai/orgs/${owner}/inference/chat/completions`,
+                    {
+                      method: 'POST',
+                      headers: {
+                        Accept: 'application/vnd.github+json',
+                        Authorization: `Bearer ${process.env.GITHUB_TOKEN}`,
+                        'X-GitHub-Api-Version': '2026-03-10',
+                        'Content-Type': 'application/json',
+                      },
+                      body: JSON.stringify({
+                        model: 'openai/gpt-4.1-mini',
+                        temperature: 0.1,
+                        max_tokens: 400,
+                        messages: [
+                          {
+                            role: 'system',
+                            content:
+                              'You are a triage assistant for LinuxGSM, an open-source Linux game server manager. ' +
+                              'Return only JSON. Analyze issue quality, suggest missing info, detect game names, and suggest contextual labels ' +
+                              'only when highly certain. Never set type: docs just because docs links are mentioned.',
+                          },
+                          {
+                            role: 'user',
+                            content:
+                              `Title: ${title}\n\nBody:\n${body.slice(0, 3000)}\n\n` +
+                              'Return JSON schema:\n' +
+                              '{\n' +
+                              '  "quality": "good" | "ok" | "poor",\n' +
+                              '  "missing_info": ["list of specific missing fields"],\n' +
+                              '  "detected_game": "canonical game name if one is mentioned, or null",\n' +
+                              '  "game_confidence": "high" | "medium" | "low" | null,\n' +
+                              '  "context_labels": ["labels"],\n' +
+                              '  "context_confidence": "high" | "medium" | "low" | null,\n' +
+                              '  "game_note": "string",\n' +
+                              '  "comment": "string"\n' +
+                              '}',
+                          },
+                        ],
+                      }),
+                    }
+                  );
+
+                  if (res.ok) {
+                    const data = await res.json();
+                    const raw = data.choices?.[0]?.message?.content || '{}';
+                    triage = parseTriageResponse(raw);
+                  } else {
+                    console.log(`GitHub Models returned ${res.status} - skipping AI triage.`);
+                  }
+                } catch (err) {
+                  console.log('AI triage skipped:', err.message);
+                }
+              }
+            }
+
+            const allowedContextLabels = new Set([
+              'type: docs',
+              'info: docs',
+              'info: dependency',
+              'info: docker',
+              'info: email',
+              'info: query',
+              'info: steamcmd',
+              'info: systemd',
+              'info: website',
+              'info: alerts',
+            ]);
+
+            const isPoor = triage?.quality === 'poor';
+            const missing = Array.isArray(triage?.missing_info) ? triage.missing_info : [];
+            const hasIssues = isPoor || missing.length > 0;
+
+            // Fallback to AI-detected game only when no structured Game field exists.
+            const detectedGame = triage?.detected_game;
+            const gameConfidence = triage?.game_confidence;
+            if (desiredGames.size === 0 && !hasStructuredGameSelection && detectedGame && gameConfidence === 'high') {
+              const normalizedDetectedGame = normalizeName(detectedGame);
+              const mapped = gameLabelByNormalized.get(normalizedDetectedGame);
+              if (mapped) {
+                desiredGames.add(mapped);
+              }
+              const mappedScript = gameAliasToScript.get(normalizedDetectedGame);
+              if (mappedScript) desiredServerScripts.add(mappedScript);
+            }
+
+            // Resolve server scripts from canonical game labels when only labels were mapped.
+            for (const gameLabel of desiredGames) {
+              const gameName = gameLabel.slice(6);
+              const mappedScript = gameAliasToScript.get(normalizeName(gameName));
+              if (mappedScript) desiredServerScripts.add(mappedScript);
+            }
+
+            const desiredEngineLabels = new Set();
+            for (const scriptName of desiredServerScripts) {
+              const engine = await getEngineForScript(scriptName);
+              if (!engine) continue;
+              const engineLabel = `engine: ${engine}`;
+              await ensureEngineLabel(engineLabel);
+              desiredEngineLabels.add(engineLabel);
+            }
+
+            if (desiredEngineLabels.size > 0) {
+              for (const label of desiredEngineLabels) labelsToAdd.add(label);
+              for (const label of existingLabels) {
+                if (label.startsWith('engine: ') && !desiredEngineLabels.has(label)) {
+                  labelsToRemove.add(label);
+                }
+              }
+            }
+
+            if (desiredGames.size > 0) {
+              for (const label of desiredGames) labelsToAdd.add(label);
+              if (hasStructuredGameSelection) {
+                for (const label of existingLabels) {
+                  if (label.startsWith('game: ') && !desiredGames.has(label)) {
+                    labelsToRemove.add(label);
+                  }
+                }
+              } else {
+                // For legacy issues without structured game selection, only prune stale
+                // broader labels when a more specific inferred game label exists.
+                const desiredGameNamesNormalized = new Set(
+                  [...desiredGames].map((label) => normalizeName(label.slice(6)))
+                );
+                for (const label of existingLabels) {
+                  if (!label.startsWith('game: ') || desiredGames.has(label)) continue;
+                  const existingGameName = normalizeName(label.slice(6));
+                  const isBroaderOverlap = [...desiredGameNamesNormalized].some(
+                    (desiredName) => desiredName !== existingGameName && desiredName.startsWith(`${existingGameName} `)
+                  );
+                  if (isBroaderOverlap) {
+                    labelsToRemove.add(label);
+                  }
+                }
+              }
+            }
+
+            if (triage?.context_confidence === 'high') {
+              const contextLabels = Array.isArray(triage.context_labels) ? triage.context_labels : [];
+              for (const label of contextLabels) {
+                if (!allowedContextLabels.has(label)) continue;
+                if (
+                  label === 'type: docs' &&
+                  (existingLabels.has('type: game server request') || desiredType === 'type: game server request')
+                ) {
+                  continue;
+                }
+                labelsToAdd.add(label);
+              }
+            }
+
+            if (ranAi && hasIssues) {
+              labelsToAdd.add('needs: more info');
+            }
+
+            if (ranAi && !hasIssues && existingLabels.has('needs: more info')) {
+              labelsToRemove.add('needs: more info');
+            }
+
+            // Avoid pointless API calls.
+            const finalAdds = [...labelsToAdd].filter((label) => !existingLabels.has(label));
+            const finalRemoves = [...labelsToRemove].filter((label) => existingLabels.has(label));
+
+            for (const label of finalRemoves) {
+              try {
+                await github.rest.issues.removeLabel({
+                  owner,
+                  repo,
+                  issue_number: issueNumber,
+                  name: label,
+                });
+                console.log(`Removed label: ${label}`);
+              } catch (err) {
+                console.log(`Could not remove label "${label}": ${err.message}`);
+              }
+            }
+
+            for (const label of finalAdds) {
+              try {
+                await github.rest.issues.addLabels({
+                  owner,
+                  repo,
+                  issue_number: issueNumber,
+                  labels: [label],
+                });
+                console.log(`Added label: ${label}`);
+              } catch (err) {
+                console.log(`Could not add label "${label}": ${err.message}`);
+              }
+            }
+
+            // Post AI comment only for opened/edited issues when useful.
+            if (ranAi) {
+              const gameNote = triage?.game_note || '';
+              const reporterComment = triage?.comment || '';
+
+              if (hasIssues || gameNote) {
+                const missingBlock = missing.length > 0
+                  ? `\n\n**Missing information:**\n${missing.map((m) => `- ${m}`).join('\n')}`
+                  : '';
+                const gameBlock = gameNote ? `\n\n**Game name note:** ${gameNote}` : '';
+
+                const triageCommentBody =
+                  `${AI_MARKER}\n` +
+                  `Thanks for opening this issue!\n\n` +
+                  `${reporterComment}` +
+                  `${missingBlock}` +
+                  `${gameBlock}\n\n` +
+                  `_This note was generated automatically by AI triage and may not be perfect. ` +
+                  `A maintainer will review shortly._`;
+
+                try {
+                  const comments = await github.paginate(github.rest.issues.listComments, {
+                    owner,
+                    repo,
+                    issue_number: issueNumber,
+                    per_page: 100,
+                  });
+
+                  const existingAiComment = [...comments].reverse().find(
+                    (comment) => comment.user?.type === 'Bot' && comment.body?.includes(AI_MARKER)
+                  );
+
+                  if (existingAiComment) {
+                    await github.rest.issues.updateComment({
+                      owner,
+                      repo,
+                      comment_id: existingAiComment.id,
+                      body: triageCommentBody,
+                    });
+                  } else {
+                    await github.rest.issues.createComment({
+                      owner,
+                      repo,
+                      issue_number: issueNumber,
+                      body: triageCommentBody,
+                    });
+                  }
+                } catch (err) {
+                  console.log('Could not post comment:', err.message);
+                }
+              }
+            }
+
+            // === Linux support verification for server request issues ===
+            // Runs only on opened/edited events to avoid reprocessing every label change.
+            const isServerRequest =
+              desiredType === 'type: game server request' ||
+              existingLabels.has('type: game server request') ||
+              /\[server request\]/i.test(title);
+
+            if (isServerRequest && shouldRunLinuxSupportCheck) {
+              const officialDocsSection = extractSection('Official dedicated server documentation');
+              const linuxBinaryProofSection = extractSection('Linux binary proof');
+              const guidesSection = extractSection('Guides');
+              const steamSection = extractSection('Steam').trim();
+              const isSteamNo = /^no$/i.test(steamSection);
+              const isSteamYes = /^yes$/i.test(steamSection);
+              const steamAppIdRaw = extractSection('Steam appid').trim();
+              const steamAppId = /^\d+$/.test(steamAppIdRaw) ? steamAppIdRaw : null;
+
+              const supportEvidenceText = [officialDocsSection, linuxBinaryProofSection, guidesSection]
+                .join('\n')
+                .trim();
+
+              // Deterministic textual checks to avoid trusting checkbox-only reports.
+              const windowsOnlyPatterns = [
+                /\bwindows\s+only\b/i,
+                /\bonly\s+windows\b/i,
+                /\bno\s+linux\s+support\b/i,
+                /\blinux\s+not\s+supported\b/i,
+                /\bdoes\s+not\s+support\s+linux\b/i,
+              ];
+              const wineRequiredPatterns = [
+                /\brequires?\s+wine\b/i,
+                /\buse\s+wine\b/i,
+                /\brun\s+with\s+wine\b/i,
+                /\bvia\s+wine\b/i,
+                /\bproton\b/i,
+              ];
+              const linuxEvidencePatterns = [
+                /\blinux\b/i,
+                /\bubuntu\b/i,
+                /\bdebian\b/i,
+                /\blinuxgsm\b/i,
+                /\bsteamcmd\s*\+app_update\b/i,
+              ];
+              const windowsBinaryHint = /\b\.exe\b/i.test(supportEvidenceText);
+              const deterministicWindowsOnly = windowsOnlyPatterns.some((re) => re.test(supportEvidenceText));
+              const deterministicWineRequired = wineRequiredPatterns.some((re) => re.test(supportEvidenceText));
+              const hasLinuxEvidence = linuxEvidencePatterns.some((re) => re.test(supportEvidenceText));
+
+              // Steam store API is client-app metadata only. It is kept for comment context,
+              // but it is NOT used to determine dedicated server Linux support.
+              let steamLinuxSupport = null; // true=yes, false=no, null=unknown/informational-only
+              let steamAppIsServerTool = false; // success:false from store API = likely server-tool AppID
+              let steamCmdAssessment = null;
+              if (steamAppId) {
+                try {
+                  const steamRes = await fetch(
+                    `https://store.steampowered.com/api/appdetails?appids=${steamAppId}&filters=platforms`,
+                    { signal: AbortSignal.timeout(8000) }
+                  );
+                  if (steamRes.ok) {
+                    const steamData = await steamRes.json();
+                    const appData = steamData[steamAppId];
+                    if (appData?.success && appData?.data?.platforms) {
+                      steamLinuxSupport = appData.data.platforms.linux === true;
+                      console.log(`Steam AppID ${steamAppId} linux=${steamLinuxSupport}`);
+                    } else if (appData?.success === false) {
+                      // Dedicated server tool AppIDs have no store page — inconclusive, not negative.
+                      steamAppIsServerTool = true;
+                      console.log(`Steam AppID ${steamAppId} has no store page (likely a server-tool AppID)`);
+                    }
+                  }
+                } catch (err) {
+                  console.log(`Steam API check failed: ${err.message}`);
+                }
+
+                steamCmdAssessment = runSteamCmdLinuxCheck(steamAppId);
+                console.log(`SteamCMD assessment: ${JSON.stringify(steamCmdAssessment)}`);
+              }
+
+              // AI analysis of official docs/guides for Linux evidence.
+              let aiLinuxAssessment = null;
+              if (supportEvidenceText.length > 10) {
+                try {
+                  const linuxAiRes = await fetch(
+                    `https://models.github.ai/orgs/${owner}/inference/chat/completions`,
+                    {
+                      method: 'POST',
+                      headers: {
+                        Accept: 'application/vnd.github+json',
+                        Authorization: `Bearer ${process.env.GITHUB_TOKEN}`,
+                        'X-GitHub-Api-Version': '2026-03-10',
+                        'Content-Type': 'application/json',
+                      },
+                      body: JSON.stringify({
+                        model: 'openai/gpt-4.1-mini',
+                        temperature: 0.1,
+                        max_tokens: 200,
+                        messages: [
+                          {
+                            role: 'system',
+                            content:
+                              'You analyze game server documentation to determine Linux support. ' +
+                              'Return only JSON. Be conservative: only say "no" if evidence clearly shows Windows-only.',
+                          },
+                          {
+                            role: 'user',
+                            content:
+                              `Analyze for native Linux dedicated server support:\n\nOfficial docs: ${officialDocsSection.slice(0, 400)}\nLinux binary proof: ${linuxBinaryProofSection.slice(0, 500)}\nGuides: ${guidesSection.slice(0, 800)}\n\n` +
+                              'Return JSON: {"linux_support": "yes"|"no"|"unknown", "confidence": "high"|"medium"|"low", "reason": "one sentence"}',
+                          },
+                        ],
+                      }),
+                    }
+                  );
+                  if (linuxAiRes.ok) {
+                    const linuxAiData = await linuxAiRes.json();
+                    const raw = linuxAiData.choices?.[0]?.message?.content || '{}';
+                    aiLinuxAssessment = parseTriageResponse(raw);
+                    console.log(`AI linux assessment: ${JSON.stringify(aiLinuxAssessment)}`);
+                  }
+                } catch (err) {
+                  console.log(`Linux AI check failed: ${err.message}`);
+                }
+              }
+
+              // Linux checkbox — used as soft positive evidence only when no negative signals exist.
+              // We don't fully trust it (users tick it without checking) but it matters when
+              // server-specific evidence is still inconclusive and no negative patterns were found.
+              const linuxCheckboxChecked = /\[x\]/i.test(extractSection('Linux support'));
+
+              // Determine verdict: confirmed = deterministic evidence; suggested = AI advisory.
+              const noLinuxFromDeterministicText =
+                deterministicWindowsOnly ||
+                (deterministicWineRequired && !hasLinuxEvidence) ||
+                (windowsBinaryHint && !hasLinuxEvidence);
+              const noLinuxFromSteamCmd = steamCmdAssessment?.status === 'windows-only';
+              const noLinuxFromAi =
+                aiLinuxAssessment?.linux_support === 'no' &&
+                (aiLinuxAssessment?.confidence === 'high' || aiLinuxAssessment?.confidence === 'medium');
+
+              const confirmedNoLinux = noLinuxFromDeterministicText || noLinuxFromSteamCmd;
+              const suggestsNoLinux = noLinuxFromAi && !confirmedNoLinux;
+              const confirmedLinuxFromSteamCmd = steamCmdAssessment?.status === 'linux';
+              const linuxYesFromAi =
+                aiLinuxAssessment?.linux_support === 'yes' &&
+                (aiLinuxAssessment?.confidence === 'high' || aiLinuxAssessment?.confidence === 'medium');
+              const confirmedLinuxSupport =
+                confirmedLinuxFromSteamCmd || linuxYesFromAi;
+              // Soft positive: checkbox checked with no negative signals and no definitive server evidence.
+              const likelySupportedByCheckbox =
+                linuxCheckboxChecked &&
+                !confirmedNoLinux &&
+                !suggestsNoLinux &&
+                !confirmedLinuxFromSteamCmd &&
+                !linuxYesFromAi;
+
+              const NO_LINUX_LABEL = 'status: no linux support';
+              const CONFIRMED_LINUX_LABEL = 'status: linux support confirmed';
+              const LINUX_MARKER = '<!-- linux-support-check -->';
+              const steamDbLink = steamAppId ? `https://steamdb.info/app/${steamAppId}/` : null;
+              const shouldApplyNoLinuxLabel = confirmedNoLinux || suggestsNoLinux;
+              const shouldApplyConfirmedLinuxLabel = confirmedLinuxSupport && !confirmedNoLinux && !suggestsNoLinux;
+
+              if (shouldApplyNoLinuxLabel) {
+                // Auto-create the label if it does not exist yet.
+                try {
+                  await github.rest.issues.getLabel({ owner, repo, name: NO_LINUX_LABEL });
+                } catch (err) {
+                  if (err.status === 404) {
+                    try {
+                      await github.rest.issues.createLabel({
+                        owner,
+                        repo,
+                        name: NO_LINUX_LABEL,
+                        color: 'd73a4a',
+                        description: 'Game server does not have confirmed native Linux support',
+                      });
+                    } catch (createErr) {
+                      console.log(`Could not create label "${NO_LINUX_LABEL}": ${createErr.message}`);
+                    }
+                  }
+                }
+                if (!existingLabels.has(NO_LINUX_LABEL)) {
+                  try {
+                    await github.rest.issues.addLabels({ owner, repo, issue_number: issueNumber, labels: [NO_LINUX_LABEL] });
+                    console.log(`Added label: ${NO_LINUX_LABEL}`);
+                  } catch (err) {
+                    console.log(`Could not add label "${NO_LINUX_LABEL}": ${err.message}`);
+                  }
+                }
+                if (existingLabels.has(CONFIRMED_LINUX_LABEL)) {
+                  try {
+                    await github.rest.issues.removeLabel({ owner, repo, issue_number: issueNumber, name: CONFIRMED_LINUX_LABEL });
+                    console.log(`Removed label: ${CONFIRMED_LINUX_LABEL}`);
+                  } catch (err) {
+                    console.log(`Could not remove label "${CONFIRMED_LINUX_LABEL}": ${err.message}`);
+                  }
+                }
+              } else if (existingLabels.has(NO_LINUX_LABEL)) {
+                try {
+                  await github.rest.issues.removeLabel({ owner, repo, issue_number: issueNumber, name: NO_LINUX_LABEL });
+                  console.log(`Removed label: ${NO_LINUX_LABEL}`);
+                } catch (err) {
+                  console.log(`Could not remove label "${NO_LINUX_LABEL}": ${err.message}`);
+                }
+              }
+
+              if (shouldApplyConfirmedLinuxLabel) {
+                try {
+                  await github.rest.issues.getLabel({ owner, repo, name: CONFIRMED_LINUX_LABEL });
+                } catch (err) {
+                  if (err.status === 404) {
+                    try {
+                      await github.rest.issues.createLabel({
+                        owner,
+                        repo,
+                        name: CONFIRMED_LINUX_LABEL,
+                        color: '0e8a16',
+                        description: 'Game server has confirmed native Linux support',
+                      });
+                    } catch (createErr) {
+                      console.log(`Could not create label "${CONFIRMED_LINUX_LABEL}": ${createErr.message}`);
+                    }
+                  }
+                }
+                if (!existingLabels.has(CONFIRMED_LINUX_LABEL)) {
+                  try {
+                    await github.rest.issues.addLabels({ owner, repo, issue_number: issueNumber, labels: [CONFIRMED_LINUX_LABEL] });
+                    console.log(`Added label: ${CONFIRMED_LINUX_LABEL}`);
+                  } catch (err) {
+                    console.log(`Could not add label "${CONFIRMED_LINUX_LABEL}": ${err.message}`);
+                  }
+                }
+              } else if (existingLabels.has(CONFIRMED_LINUX_LABEL)) {
+                try {
+                  await github.rest.issues.removeLabel({ owner, repo, issue_number: issueNumber, name: CONFIRMED_LINUX_LABEL });
+                  console.log(`Removed label: ${CONFIRMED_LINUX_LABEL}`);
+                } catch (err) {
+                  console.log(`Could not remove label "${CONFIRMED_LINUX_LABEL}": ${err.message}`);
+                }
+              }
+
+              const reasons = [];
+              if (deterministicWindowsOnly) reasons.push('the provided docs/guides explicitly indicate Windows-only or no Linux support');
+              if (deterministicWineRequired && !hasLinuxEvidence) reasons.push('the provided docs/guides indicate a Wine/Proton requirement rather than native Linux binaries');
+              if (windowsBinaryHint && !hasLinuxEvidence) reasons.push('the provided evidence appears to reference Windows binaries (.exe) without clear Linux server evidence');
+              if (isSteamNo) reasons.push('request is marked as non-Steam, so Steam platform checks were intentionally skipped');
+              if (steamAppIsServerTool) reasons.push(`AppID ${steamAppId} has no Steam store page (typical for dedicated server tool AppIDs)`);
+              if (noLinuxFromSteamCmd && steamCmdAssessment?.reason) reasons.push(steamCmdAssessment.reason);
+              if (confirmedLinuxFromSteamCmd && steamCmdAssessment?.reason) reasons.push(steamCmdAssessment.reason);
+              if (steamCmdAssessment?.status === 'unknown' && steamCmdAssessment?.reason) reasons.push(steamCmdAssessment.reason);
+              if (steamCmdAssessment?.status === 'error' && steamCmdAssessment?.reason) reasons.push(steamCmdAssessment.reason);
+              if (noLinuxFromAi && aiLinuxAssessment?.reason) reasons.push(`AI analysis of provided documentation: ${aiLinuxAssessment.reason}`);
+              if (linuxYesFromAi && aiLinuxAssessment?.reason) reasons.push(`AI analysis indicates Linux support: ${aiLinuxAssessment.reason}`);
+              if (likelySupportedByCheckbox) reasons.push('requester confirmed Linux support via the form checkbox; no contradicting evidence found');
+
+              let verdictLine = 'Linux support could not be confirmed automatically from the submitted details.';
+              if (confirmedNoLinux) {
+                verdictLine = 'This server request does **not** appear to have native Linux support, which is required for LinuxGSM.';
+              } else if (suggestsNoLinux) {
+                verdictLine = 'This server request **may not** have native Linux support based on submitted evidence.';
+              } else if (confirmedLinuxFromSteamCmd) {
+                verdictLine = 'SteamCMD metadata indicates this server has Linux platform/depot support.';
+              } else if (linuxYesFromAi) {
+                verdictLine = 'Submitted documentation appears to indicate Linux server support.';
+              } else if (likelySupportedByCheckbox) {
+                verdictLine = 'Linux support is **likely** — the requester confirmed it and no contradicting evidence was found. A maintainer should verify before accepting.';
+              }
+
+              const steamApiStatus = isSteamNo
+                ? 'Not applicable'
+                : steamLinuxSupport === true
+                  ? 'Client app marked Linux-supported (informational only)'
+                  : steamLinuxSupport === false
+                    ? 'Client app marked Linux-unsupported (informational only)'
+                    : steamAppIsServerTool
+                      ? 'No store page for this AppID (informational only)'
+                      : 'No definitive platform response';
+
+              const steamCmdStatus = isSteamNo
+                ? 'Not applicable'
+                : !steamAppId
+                  ? 'Skipped until valid AppID is provided'
+                  : steamCmdAssessment?.status === 'linux'
+                    ? 'Linux platform/depot metadata found'
+                    : steamCmdAssessment?.status === 'windows-only'
+                      ? 'Windows-only platform metadata found'
+                      : steamCmdAssessment?.status === 'unknown'
+                        ? 'No clear Linux server metadata found'
+                        : steamCmdAssessment?.status === 'error'
+                          ? 'Lookup failed'
+                          : 'Not run';
+
+              const steamBlock = isSteamNo
+                ? '**Steam:** No (non-Steam request)\n**Steam Store API:** Not applicable\n\n'
+                : steamAppId
+                  ? `**Steam:** ${isSteamYes ? 'Yes' : 'Unspecified'}\n` +
+                    `**Steam AppID:** ${steamAppId}\n` +
+                    `**Steam Store API:** ${steamApiStatus}\n` +
+                    `**SteamCMD:** ${steamCmdStatus}\n` +
+                    `**SteamDB:** ${steamDbLink}\n\n`
+                  : `**Steam:** ${isSteamYes ? 'Yes' : 'Unspecified'}\n` +
+                    '**Steam AppID:** Not provided in the issue form.\n' +
+                    (isSteamYes ? '**Steam Store API:** Skipped until valid AppID is provided.\n**SteamCMD:** Skipped until valid AppID is provided.\n\n' : '\n');
+
+              const linuxCommentHeader = shouldApplyConfirmedLinuxLabel
+                ? '**Linux Support Check** :rocket:'
+                : '**Linux Support Check**';
+              const confirmedLinuxLabelBlock = shouldApplyConfirmedLinuxLabel
+                ? `**Label applied:** ${CONFIRMED_LINUX_LABEL}\n\n`
+                : '';
+
+              const linuxCommentBody =
+                `${LINUX_MARKER}\n` +
+                `${linuxCommentHeader}\n\n` +
+                `${verdictLine}\n\n` +
+                `${confirmedLinuxLabelBlock}` +
+                `${steamBlock}` +
+                (reasons.length > 0
+                  ? `**Evidence:**\n${reasons.map((r) => `- ${r}`).join('\n')}\n\n`
+                  : '') +
+                `LinuxGSM only supports **native Linux dedicated servers**. Wine and Windows-only servers are not supported.\n\n` +
+                `If support is unclear, please provide:\n` +
+                `- Official Linux dedicated server documentation\n` +
+                `- Linux server binaries or release notes\n` +
+                `- Linux startup instructions or commands\n\n` +
+                `_This check was performed automatically using SteamCMD, submitted issue details, and AI assistance for document interpretation. Steam store data is shown only as client-app context and is not used to determine server support._`;
+
+              try {
+                const allComments = await github.paginate(github.rest.issues.listComments, {
+                  owner,
+                  repo,
+                  issue_number: issueNumber,
+                  per_page: 100,
+                });
+                const existingLinuxComment = [...allComments].reverse().find(
+                  (c) => c.user?.type === 'Bot' && c.body?.includes(LINUX_MARKER)
+                );
+
+                if (existingLinuxComment) {
+                  await github.rest.issues.updateComment({
+                    owner,
+                    repo,
+                    comment_id: existingLinuxComment.id,
+                    body: linuxCommentBody,
+                  });
+                } else {
+                  await github.rest.issues.createComment({
+                    owner,
+                    repo,
+                    issue_number: issueNumber,
+                    body: linuxCommentBody,
+                  });
+                }
+              } catch (err) {
+                console.log(`Could not post Linux support comment: ${err.message}`);
+              }
+            }
+
+  issue-potential-duplicates:
+    if: github.repository_owner == 'GameServerManagers' && github.event_name == 'issues' && (github.event.action == 'opened' || github.event.action == 'edited' || github.event.action == 'reopened')
+    runs-on: ubuntu-latest
+    steps:
+      - name: Detect potential duplicates
+        uses: actions/github-script@v9
+        with:
+          script: |
+            const owner = context.repo.owner;
+            const repo = context.repo.repo;
+            const issueNumber = context.payload.issue?.number;
+            const DUPLICATE_LABEL = 'potential-duplicate';
+            const DUPLICATE_MARKER = '<!-- potential-duplicate-check -->';
+            const MAX_CANDIDATES = 5;
+            const THRESHOLD = 0.45;
+
+            if (!issueNumber) {
+              console.log('No issue number found in payload.');
+              return;
+            }
+
+            const issueResp = await github.rest.issues.get({
+              owner,
+              repo,
+              issue_number: issueNumber,
+            });
+
+            const issue = issueResp.data;
+            if (issue.pull_request) {
+              console.log('Skipping pull request payload.');
+              return;
+            }
+
+            function normalizeText(value) {
+              return (value || '')
+                .toLowerCase()
+                .replace(/[`'"’]/g, '')
+                .replace(/[^a-z0-9\s]/g, ' ')
+                .replace(/\s+/g, ' ')
+                .trim();
+            }
+
+            function tokenize(value) {
+              const stopwords = new Set([
+                'a', 'an', 'and', 'are', 'as', 'at', 'be', 'by', 'for', 'from', 'how', 'i', 'in', 'is', 'it', 'its',
+                'of', 'on', 'or', 'that', 'the', 'this', 'to', 'when', 'with', 'wont', 'cannot', 'cant', 'fails', 'fail',
+                'issue', 'bug', 'request', 'server', 'command', 'linuxgsm'
+              ]);
+              return new Set(
+                normalizeText(value)
+                  .split(' ')
+                  .filter((token) => token.length > 2 && !stopwords.has(token))
+              );
+            }
+
+            function jaccard(aSet, bSet) {
+              if (aSet.size === 0 || bSet.size === 0) return 0;
+              let intersection = 0;
+              for (const v of aSet) {
+                if (bSet.has(v)) intersection += 1;
+              }
+              const union = new Set([...aSet, ...bSet]).size;
+              return union === 0 ? 0 : intersection / union;
+            }
+
+            function bodySignature(text) {
+              return normalizeText(text).split(' ').slice(0, 200).join(' ');
+            }
+
+            const currentTitleTokens = tokenize(issue.title || '');
+            const currentBodyTokens = tokenize(bodySignature(issue.body || ''));
+
+            const recentIssues = await github.paginate(github.rest.issues.listForRepo, {
+              owner,
+              repo,
+              state: 'all',
+              sort: 'updated',
+              direction: 'desc',
+              per_page: 100,
+            });
+
+            const ranked = [];
+            for (const candidate of recentIssues) {
+              if (!candidate || candidate.number === issueNumber || candidate.pull_request) continue;
+
+              const candidateTitleTokens = tokenize(candidate.title || '');
+              const candidateBodyTokens = tokenize(bodySignature(candidate.body || ''));
+              const titleScore = jaccard(currentTitleTokens, candidateTitleTokens);
+              const bodyScore = jaccard(currentBodyTokens, candidateBodyTokens);
+              const score = titleScore * 0.8 + bodyScore * 0.2;
+
+              if (score < THRESHOLD) continue;
+
+              ranked.push({
+                number: candidate.number,
+                title: candidate.title,
+                state: candidate.state,
+                html_url: candidate.html_url,
+                score,
+              });
+            }
+
+            ranked.sort((a, b) => b.score - a.score);
+            const topMatches = ranked.slice(0, MAX_CANDIDATES);
+
+            async function ensurePotentialDuplicateLabel() {
+              try {
+                await github.rest.issues.getLabel({ owner, repo, name: DUPLICATE_LABEL });
+              } catch (err) {
+                if (err.status !== 404) throw err;
+                await github.rest.issues.createLabel({
+                  owner,
+                  repo,
+                  name: DUPLICATE_LABEL,
+                  color: 'd4c5f9',
+                  description: 'Potentially duplicates another existing issue',
+                });
+              }
+            }
+
+            const existingLabelNames = new Set((issue.labels || []).map((l) => l.name));
+
+            const comments = await github.paginate(github.rest.issues.listComments, {
+              owner,
+              repo,
+              issue_number: issueNumber,
+              per_page: 100,
+            });
+            const existingComment = [...comments]
+              .reverse()
+              .find((comment) => comment.user?.type === 'Bot' && comment.body?.includes(DUPLICATE_MARKER));
+
+            if (topMatches.length === 0) {
+              if (existingLabelNames.has(DUPLICATE_LABEL)) {
+                try {
+                  await github.rest.issues.removeLabel({
+                    owner,
+                    repo,
+                    issue_number: issueNumber,
+                    name: DUPLICATE_LABEL,
+                  });
+                } catch (err) {
+                  console.log(`Could not remove ${DUPLICATE_LABEL}: ${err.message}`);
+                }
+              }
+
+              if (existingComment) {
+                try {
+                  await github.rest.issues.updateComment({
+                    owner,
+                    repo,
+                    comment_id: existingComment.id,
+                    body:
+                      `${DUPLICATE_MARKER}\n` +
+                      `Potential duplicate scan did not find strong matches at this time.\n\n` +
+                      `_This note is maintained automatically._`,
+                  });
+                } catch (err) {
+                  console.log(`Could not update duplicate comment: ${err.message}`);
+                }
+              }
+
+              return;
+            }
+
+            await ensurePotentialDuplicateLabel();
+
+            if (!existingLabelNames.has(DUPLICATE_LABEL)) {
+              try {
+                await github.rest.issues.addLabels({
+                  owner,
+                  repo,
+                  issue_number: issueNumber,
+                  labels: [DUPLICATE_LABEL],
+                });
+              } catch (err) {
+                console.log(`Could not add ${DUPLICATE_LABEL}: ${err.message}`);
+              }
+            }
+
+            const lines = topMatches
+              .map((m) => `- #${m.number} (${Math.round(m.score * 100)}%) ${m.title}`)
+              .join('\n');
+
+            const commentBody =
+              `${DUPLICATE_MARKER}\n` +
+              `Potential duplicates:\n${lines}\n\n` +
+              `_This note is generated automatically using repository issue similarity and may include false positives._`;
+
+            if (existingComment) {
+              await github.rest.issues.updateComment({
+                owner,
+                repo,
+                comment_id: existingComment.id,
+                body: commentBody,
+              });
+            } else {
+              await github.rest.issues.createComment({
+                owner,
+                repo,
+                issue_number: issueNumber,
+                body: commentBody,
+              });
+            }
+
+  backfill-relabel:
+    if: github.repository_owner == 'GameServerManagers' && github.event_name == 'workflow_dispatch'
+    runs-on: ubuntu-latest
+    env:
+      ISSUE_STATE: ${{ inputs.issue_state }}
+      ISSUE_LIMIT: ${{ inputs.limit }}
+      AI_GAME_FALLBACK: ${{ inputs.ai_game_fallback }}
+    steps:
+      - name: Trigger relabel backfill
+        uses: actions/github-script@v9
+        env:
+          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+        with:
+          script: |
+            const owner = context.repo.owner;
+            const repo = context.repo.repo;
+            const state = process.env.ISSUE_STATE || 'all';
+            const rawLimit = Number.parseInt(process.env.ISSUE_LIMIT || '0', 10);
+            const limit = Number.isFinite(rawLimit) && rawLimit > 0 ? rawLimit : 0;
+            const useAiGameFallback = String(process.env.AI_GAME_FALLBACK || 'false').toLowerCase() === 'true';
+            const processedIssues = [];
+            const failedIssues = [];
+            let aiGameAttempts = 0;
+            let aiGameMatches = 0;
+            let aiGameRateLimited = 0;
+            let aiFallbackDisabledReason = '';
+            let stoppedForApiRateLimit = false;
+            let apiRateLimitStopReason = '';
+
+            // === Helpers (mirrored from issue-ai-maintenance) ===
+
+            function normalizeName(value) {
+              return (value || '')
+                .toLowerCase()
+                .replace(/[''`]/g, '')
+                .replace(/[^a-z0-9]+/g, ' ')
+                .trim();
+            }
+
+            function parseGameCandidates(gameField) {
+              if (!gameField || /^_?no response_?$/i.test(gameField)) return [];
+              return gameField
+                .replace(/\(.*?\)/g, ' ')
+                .split(/\n|,|\s+&\s+|\s+and\s+|\//i)
+                .map((v) => v.trim())
+                .filter(Boolean);
+            }
+
+            function findGamesFromText(text, gameAliasToLabel, gameAliasToScript) {
+              const labels = new Set();
+              const scripts = new Set();
+              const normalizedText = normalizeName(text);
+              if (!normalizedText) return { labels, scripts };
+
+              const escapeRegex = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+              const aliases = [];
+              for (const [alias, label] of gameAliasToLabel.entries()) {
+                if (alias.length < 3) continue;
+                aliases.push({ alias, label, script: gameAliasToScript.get(alias) || null });
+              }
+
+              // Prefer longer aliases first so "killing floor 2" does not also match "killing floor".
+              aliases.sort((a, b) => b.alias.length - a.alias.length);
+
+              const usedRanges = [];
+              const isOverlapping = (start, end) =>
+                usedRanges.some((range) => start < range.end && end > range.start);
+
+              for (const entry of aliases) {
+                const pattern = new RegExp(`\\b${escapeRegex(entry.alias).replace(/\\ /g, '\\s+')}\\b`, 'g');
+                let match;
+                while ((match = pattern.exec(normalizedText)) !== null) {
+                  const start = match.index;
+                  const end = start + match[0].length;
+                  if (isOverlapping(start, end)) continue;
+
+                  labels.add(entry.label);
+                  if (entry.script) scripts.add(entry.script);
+                  usedRanges.push({ start, end });
+                }
+              }
+
+              return { labels, scripts };
+            }
+
+            function parseAiGameResponse(raw) {
+              const input = (raw || '').trim();
+              if (!input) return {};
+
+              const candidates = [input];
+              const fenced = input.match(/```(?:json)?\s*([\s\S]*?)```/i);
+              if (fenced?.[1]) candidates.push(fenced[1].trim());
+
+              const firstBrace = input.indexOf('{');
+              const lastBrace = input.lastIndexOf('}');
+              if (firstBrace !== -1 && lastBrace > firstBrace) {
+                candidates.push(input.slice(firstBrace, lastBrace + 1));
+              }
+
+              for (const candidate of candidates) {
+                try {
+                  return JSON.parse(candidate);
+                } catch (_err) {
+                  // Continue trying fallbacks.
+                }
+              }
+
+              return {};
+            }
+
+            function isGenericNonGameDetection(value) {
+              const normalized = normalizeName(value);
+              if (!normalized) return false;
+
+              return [
+                'srcds',
+                'source dedicated server',
+                'dedicated server',
+                'source engine',
+                'goldsrc',
+                'steamcmd',
+                'linuxgsm',
+                'lgsm',
+              ].some((term) => normalized.includes(term));
+            }
+
+            function parseAiRateLimitInfo(response) {
+              const retryAfter = response.headers.get('retry-after') || response.headers.get('Retry-After') || '';
+              const limit = response.headers.get('x-ratelimit-limit') || '';
+              const remaining = response.headers.get('x-ratelimit-remaining') || '';
+              const resetEpoch = response.headers.get('x-ratelimit-reset') || '';
+              const requestId = response.headers.get('x-github-request-id') || '';
+
+              let resetIso = '';
+              const parsedReset = Number.parseInt(resetEpoch, 10);
+              if (Number.isFinite(parsedReset) && parsedReset > 0) {
+                resetIso = new Date(parsedReset * 1000).toISOString();
+              }
+
+              return {
+                retryAfter,
+                limit,
+                remaining,
+                resetEpoch,
+                resetIso,
+                requestId,
+              };
+            }
+
+            function formatAiRateLimitInfo(info) {
+              const parts = [];
+              if (info.retryAfter) parts.push(`retry-after=${info.retryAfter}s`);
+              if (info.limit) parts.push(`limit=${info.limit}`);
+              if (info.remaining) parts.push(`remaining=${info.remaining}`);
+              if (info.resetEpoch) parts.push(`reset=${info.resetEpoch}${info.resetIso ? ` (${info.resetIso})` : ''}`);
+              if (info.requestId) parts.push(`request-id=${info.requestId}`);
+              return parts.length > 0 ? parts.join(', ') : 'no rate-limit headers returned';
+            }
+
+            function isApiRateLimitError(err) {
+              const message = String(err?.message || '').toLowerCase();
+              return (
+                err?.status === 429 ||
+                message.includes('api rate limit exceeded') ||
+                message.includes('secondary rate limit') ||
+                (message.includes('rate limit') && err?.status === 403)
+              );
+            }
+
+            function formatApiRateLimitError(err) {
+              const headers = err?.response?.headers || err?.headers || {};
+              const limit = headers['x-ratelimit-limit'] || '';
+              const remaining = headers['x-ratelimit-remaining'] || '';
+              const resetEpoch = headers['x-ratelimit-reset'] || '';
+              const requestId = headers['x-github-request-id'] || '';
+
+              let resetIso = '';
+              const parsedReset = Number.parseInt(resetEpoch || '', 10);
+              if (Number.isFinite(parsedReset) && parsedReset > 0) {
+                resetIso = new Date(parsedReset * 1000).toISOString();
+              }
+
+              const parts = [];
+              if (limit) parts.push(`limit=${limit}`);
+              if (remaining) parts.push(`remaining=${remaining}`);
+              if (resetEpoch) parts.push(`reset=${resetEpoch}${resetIso ? ` (${resetIso})` : ''}`);
+              if (requestId) parts.push(`request-id=${requestId}`);
+              return parts.length > 0 ? parts.join(', ') : 'no rate-limit headers returned';
+            }
+
+            function hasAliasHitForLabel(text, targetLabel, gameAliasToLabel) {
+              const normalizedText = normalizeName(text);
+              if (!normalizedText || !targetLabel) return false;
+
+              const paddedText = ` ${normalizedText} `;
+              for (const [alias, label] of gameAliasToLabel.entries()) {
+                if (label !== targetLabel) continue;
+                if (alias.length < 3) continue;
+                if (paddedText.includes(` ${alias} `)) return true;
+
+                // Allow obvious joined-word variants for multi-token aliases
+                // (e.g., "counter strike 1 6" matching "counterstrike 1.6").
+                const aliasTokens = alias.split(/\s+/).filter(Boolean);
+                if (aliasTokens.length > 1) {
+                  const escapedTokens = aliasTokens.map((token) =>
+                    token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
+                  );
+                  const flexibleAliasPattern = new RegExp(`\\b${escapedTokens.join('\\s*')}\\b`);
+                  if (flexibleAliasPattern.test(normalizedText)) return true;
+                }
+              }
+
+              return false;
+            }
+
+            function parseServerlistCsv(csvText) {
+              const rows = [];
+              const lines = (csvText || '').split(/\r?\n/);
+              for (let i = 1; i < lines.length; i += 1) {
+                const line = lines[i]?.trim();
+                if (!line) continue;
+                const parts = line.split(',');
+                if (parts.length < 3) continue;
+                rows.push({
+                  shortname: parts[0].trim(),
+                  gameservername: parts[1].trim(),
+                  gamename: parts[2].trim(),
+                });
+              }
+              return rows;
+            }
+
+            function inferTypeFromTitle(issueTitle) {
+              if (/^\[bug\]/i.test(issueTitle)) return 'type: bug';
+              if (/\bserver\s+request\b/i.test(issueTitle)) return 'type: game server request';
+              const hasBracketPrefix = /^\[[^\]]+\]/.test(issueTitle || '');
+              const isServerCreation =
+                /\bserver\s+creation\b/i.test(issueTitle) ||
+                (hasBracketPrefix && /\bcreation\b/i.test(issueTitle));
+              const isServerSupportRequest =
+                /\bserver\s+support\b/i.test(issueTitle) ||
+                (/\bsupport\s+for\b/i.test(issueTitle) && /\bserver\b/i.test(issueTitle));
+              if (isServerCreation || isServerSupportRequest) return 'type: game server request';
+              if (/^\[feature\]/i.test(issueTitle)) return 'type: feature';
+              if (/^\[server request\]/i.test(issueTitle)) return 'type: game server request';
+              if (/^\[docs?\]/i.test(issueTitle)) return 'type: docs';
+              return null;
+            }
+
+            function inferDesiredType(issueTitle, labelNames) {
+              const titleType = inferTypeFromTitle(issueTitle);
+              if (titleType) return titleType;
+
+              // Prefer server requests over generic feature when both labels exist.
+              if (labelNames.has('type: game server request')) return 'type: game server request';
+
+              for (const label of [
+                'type: bug',
+                'type: feature',
+                'type: game server request',
+                'type: docs',
+              ]) {
+                if (labelNames.has(label)) return label;
+              }
+              return null;
+            }
+
+            function inferIssueTypeNameFromDesiredType(typeLabel) {
+              if (typeLabel === 'type: bug') return 'Bug';
+              if (typeLabel === 'type: feature') return 'Feature';
+              if (typeLabel === 'type: game server request') return 'Server Request';
+              if (typeLabel === 'type: docs') return 'Task';
+              return null;
+            }
+
+            function parseCommandSelections(sectionValue) {
+              const selected = new Set();
+              const re = /command:\s*([a-z-]+)/gi;
+              let m;
+              while ((m = re.exec(sectionValue || '')) !== null) {
+                let value = m[1].toLowerCase();
+                if (value.startsWith('mods-')) value = 'mods';
+                if (value === 'auto-update') value = 'update';
+                selected.add(`command: ${value}`);
+              }
+              return selected;
+            }
+
+            function parseDistroSelections(sectionValue) {
+              const text = sectionValue || '';
+              const selected = new Set();
+              if (/\bUbuntu\b/i.test(text)) selected.add('distro: Ubuntu');
+              if (/\bDebian\b/i.test(text)) selected.add('distro: Debian');
+              if (/\bAlmaLinux\b/i.test(text)) selected.add('distro: AlmaLinux');
+              if (/\bRocky\b/i.test(text)) selected.add('distro: Rocky Linux');
+              if (/\bCentOS\b/i.test(text)) selected.add('distro: CentOS');
+              if (/\bFedora\b/i.test(text)) selected.add('distro: Fedora');
+              if (/\bopenSUSE\b/i.test(text)) selected.add('distro: openSUSE');
+              if (/\bArch Linux\b/i.test(text)) selected.add('distro: Arch Linux');
+              if (/\bSlackware\b/i.test(text)) selected.add('distro: Slackware');
+              return selected;
+            }
+
+            function extractSection(body, sectionName) {
+              const escaped = sectionName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+              const re = new RegExp(`### ${escaped}\\n\\n([\\s\\S]*?)(\\n### |$)`, 'i');
+              return (body.match(re)?.[1] || '').trim();
+            }
+
+            // === Load shared data once ===
+
+            const repoLabels = await github.paginate(github.rest.issues.listLabelsForRepo, {
+              owner,
+              repo,
+              per_page: 100,
+            });
+
+            const gameLabelByNormalized = new Map();
+            for (const label of repoLabels) {
+              if (!label.name.startsWith('game: ')) continue;
+              gameLabelByNormalized.set(normalizeName(label.name.slice(6)), label.name);
+            }
+
+            const existingEngineLabels = new Set(
+              repoLabels.map((l) => l.name).filter((name) => name.startsWith('engine: '))
+            );
+
+            const gameAliasToLabel = new Map();
+            const gameAliasToScript = new Map();
+            const engineByScript = new Map();
+
+            for (const [normalizedGameName, label] of gameLabelByNormalized.entries()) {
+              gameAliasToLabel.set(normalizedGameName, label);
+            }
+
+            try {
+              const serverlistContent = await github.rest.repos.getContent({
+                owner,
+                repo,
+                path: 'lgsm/data/serverlist.csv',
+              });
+              const csvText = Buffer.from(serverlistContent.data?.content || '', 'base64').toString('utf8');
+              const serverRows = parseServerlistCsv(csvText);
+              for (const row of serverRows) {
+                const canonicalLabel = gameLabelByNormalized.get(normalizeName(row.gamename));
+                if (!canonicalLabel) continue;
+                for (const alias of [row.shortname, row.gameservername, row.gamename]) {
+                  const key = normalizeName(alias);
+                  if (!key) continue;
+                  gameAliasToLabel.set(key, canonicalLabel);
+                  gameAliasToScript.set(key, row.gameservername);
+                }
+              }
+            } catch (err) {
+              console.log(`Could not load serverlist aliases: ${err.message}`);
+            }
+
+            async function ensureEngineLabel(engineLabel) {
+              if (existingEngineLabels.has(engineLabel)) return;
+              try {
+                await github.rest.issues.createLabel({
+                  owner,
+                  repo,
+                  name: engineLabel,
+                  color: '000000',
+                  description: `Issues related to ${engineLabel.slice(8)} engine`,
+                });
+                existingEngineLabels.add(engineLabel);
+              } catch (err) {
+                if (err.status === 422) {
+                  existingEngineLabels.add(engineLabel);
+                  return;
+                }
+                console.log(`Could not create engine label "${engineLabel}": ${err.message}`);
+              }
+            }
+
+            async function getEngineForScript(scriptName) {
+              if (!scriptName) return null;
+              if (engineByScript.has(scriptName)) return engineByScript.get(scriptName);
+              try {
+                const cfgContent = await github.rest.repos.getContent({
+                  owner,
+                  repo,
+                  path: `lgsm/config-default/config-lgsm/${scriptName}/_default.cfg`,
+                });
+                const cfgText = Buffer.from(cfgContent.data?.content || '', 'base64').toString('utf8');
+                const engine = cfgText.match(/^engine="([^"]+)"/m)?.[1] || null;
+                engineByScript.set(scriptName, engine);
+                return engine;
+              } catch (_err) {
+                engineByScript.set(scriptName, null);
+                return null;
+              }
+            }
+
+            // === Process issues ===
+
+            const issues = await github.paginate(github.rest.issues.listForRepo, {
+              owner,
+              repo,
+              state,
+              sort: 'created',
+              direction: 'asc',
+              per_page: 100,
+            });
+
+            const targets = issues.filter((issue) => !issue.pull_request);
+            const selectedTargets = limit > 0 ? targets.slice(0, limit) : targets;
+
+            console.log(
+              `Starting relabel backfill for ${selectedTargets.length} issue(s) ` +
+                `(state=${state}, limit=${limit === 0 ? 'all' : limit}).`
+            );
+
+            let processed = 0;
+            for (const rawIssue of selectedTargets) {
+              if (stoppedForApiRateLimit) break;
+              console.log(`Processing issue #${rawIssue.number}: ${rawIssue.title}`);
+              try {
+                const issueResp = await github.rest.issues.get({
+                  owner,
+                  repo,
+                  issue_number: rawIssue.number,
+                });
+                const issue = issueResp.data;
+                const title = issue.title || '';
+                const body = issue.body || '';
+                const existingLabels = new Set((issue.labels || []).map((l) => l.name).filter(Boolean));
+                const labelsToAdd = new Set();
+                const labelsToRemove = new Set();
+                const isLocked = issue.locked === true;
+                let issueTypeSet = null;
+
+                // Type reconciliation
+                const desiredType = inferDesiredType(title, existingLabels);
+                if (desiredType) {
+                  labelsToAdd.add(desiredType);
+                  for (const label of existingLabels) {
+                    if (label.startsWith('type: ') && label !== desiredType) labelsToRemove.add(label);
+                  }
+
+                  const desiredIssueTypeName = inferIssueTypeNameFromDesiredType(desiredType);
+                  if (desiredIssueTypeName) {
+                    try {
+                      const issueTypeData = await github.graphql(
+                        `query($owner:String!,$repo:String!,$number:Int!){
+                          repository(owner:$owner,name:$repo){
+                            issueTypes(first:20){ nodes { id name } }
+                            issue(number:$number){ id issueType { id name } }
+                          }
+                        }`,
+                        { owner, repo, number: rawIssue.number }
+                      );
+                      const issueNode = issueTypeData.repository?.issue;
+                      const issueTypes = issueTypeData.repository?.issueTypes?.nodes || [];
+                      const desiredIssueType = issueTypes.find((t) => t.name === desiredIssueTypeName);
+                      if (
+                        issueNode?.id &&
+                        desiredIssueType?.id &&
+                        issueNode.issueType?.id !== desiredIssueType.id
+                      ) {
+                        await github.graphql(
+                          `mutation($id:ID!,$issueTypeId:ID!){
+                            updateIssue(input:{id:$id,issueTypeId:$issueTypeId}){
+                              issue { id number issueType { id name } }
+                            }
+                          }`,
+                          { id: issueNode.id, issueTypeId: desiredIssueType.id }
+                        );
+                        issueTypeSet = desiredIssueTypeName;
+                        console.log(`#${rawIssue.number}: set Issue Type to ${desiredIssueTypeName}`);
+                      }
+                    } catch (err) {
+                      if (isApiRateLimitError(err)) throw err;
+                      console.log(`#${rawIssue.number}: could not sync Issue Type: ${err.message}`);
+                    }
+                  }
+                }
+
+                // Commands
+                const commandSection = extractSection(body, 'Command');
+                const desiredCommands = parseCommandSelections(commandSection);
+                if (desiredCommands.size > 0) {
+                  for (const label of desiredCommands) labelsToAdd.add(label);
+                  for (const label of existingLabels) {
+                    if (label.startsWith('command: ') && !desiredCommands.has(label)) labelsToRemove.add(label);
+                  }
+                }
+
+                // Distros
+                const distroSection = extractSection(body, 'Linux distro');
+                const desiredDistros = parseDistroSelections(distroSection);
+                if (desiredDistros.size > 0) {
+                  for (const label of desiredDistros) labelsToAdd.add(label);
+                  for (const label of existingLabels) {
+                    if (label.startsWith('distro: ') && !desiredDistros.has(label)) labelsToRemove.add(label);
+                  }
+                }
+
+                // Tmux false positive cleanup
+                if (
+                  existingLabels.has('info: tmux') &&
+                  !/\b(tmuxception|check_tmuxception)\b/i.test(`${title}\n${body}`)
+                ) {
+                  labelsToRemove.add('info: tmux');
+                }
+
+                // Games and engines
+                const desiredGames = new Set();
+                const gameLabelSource = new Map(); // label → 'form-field' | 'text-match' | 'ai-fallback'
+                const desiredServerScripts = new Set();
+                // 'Game server' is the section name in server_request.yml; 'Game' is used in bug_report.yml.
+                const gameField = extractSection(body, 'Game server') || extractSection(body, 'Game');
+                const gameCandidates = parseGameCandidates(gameField);
+                const hasStructuredGameSelection = gameCandidates.length > 0;
+                for (const candidate of gameCandidates) {
+                  const normalizedCandidate = normalizeName(candidate);
+                  const mapped =
+                    gameAliasToLabel.get(normalizedCandidate) || gameLabelByNormalized.get(normalizedCandidate);
+                  if (mapped) {
+                    desiredGames.add(mapped);
+                    gameLabelSource.set(mapped, 'form-field');
+                  }
+                  const mappedScript = gameAliasToScript.get(normalizedCandidate);
+                  if (mappedScript) desiredServerScripts.add(mappedScript);
+                }
+
+                // Legacy issues often have no form section; fall back to deterministic text matching.
+                if (desiredGames.size === 0) {
+                  const fromText = findGamesFromText(`${title}\n${body}`, gameAliasToLabel, gameAliasToScript);
+                  for (const label of fromText.labels) {
+                    desiredGames.add(label);
+                    gameLabelSource.set(label, 'text-match');
+                  }
+                  for (const scriptName of fromText.scripts) desiredServerScripts.add(scriptName);
+                }
+
+                // Optional AI fallback for legacy issues where deterministic matching finds nothing.
+                if (useAiGameFallback && desiredGames.size === 0) {
+                  if (aiFallbackDisabledReason) {
+                    console.log(`#${rawIssue.number}: AI fallback skipped (${aiFallbackDisabledReason})`);
+                  } else {
+                  aiGameAttempts += 1;
+                  const aiPayload = {
+                    model: 'openai/gpt-4.1-mini',
+                    temperature: 0.1,
+                    max_tokens: 120,
+                    messages: [
+                      {
+                        role: 'system',
+                        content:
+                          'Return JSON only. Identify the specific game referenced in this LinuxGSM issue with high precision. ' +
+                          'If only generic platform/engine terms are present (e.g. srcds, source dedicated server, steamcmd), return detected_game as null.',
+                      },
+                      {
+                        role: 'user',
+                        content:
+                          `Title: ${title}\n\nBody:\n${body.slice(0, 2500)}\n\n` +
+                          'Return JSON: {"detected_game":"string or null","game_confidence":"high|medium|low|null"}',
+                      },
+                    ],
+                  };
+                  const aiUrl = `https://models.github.ai/orgs/${owner}/inference/chat/completions`;
+                  const aiHeaders = {
+                    Accept: 'application/vnd.github+json',
+                    Authorization: `Bearer ${process.env.GITHUB_TOKEN}`,
+                    'X-GitHub-Api-Version': '2026-03-10',
+                    'Content-Type': 'application/json',
+                  };
+                  try {
+                    let res = await fetch(aiUrl, { method: 'POST', headers: aiHeaders, body: JSON.stringify(aiPayload) });
+
+                    // On 429 honour Retry-After (capped at 60 s) then retry once.
+                    if (res.status === 429) {
+                      aiGameRateLimited += 1;
+                      const rateInfo = parseAiRateLimitInfo(res);
+                      const rawRetryAfter = Number.parseInt(rateInfo.retryAfter || '10', 10);
+                      const retryAfter = Math.min(Number.isFinite(rawRetryAfter) ? rawRetryAfter : 10, 60);
+                      if (Number.isFinite(rawRetryAfter) && rawRetryAfter > 300) {
+                        aiFallbackDisabledReason = `global cooldown active (retry-after=${rawRetryAfter}s)`;
+                        console.log(
+                          `#${rawIssue.number}: AI fallback disabled for remaining run (${aiFallbackDisabledReason}; ${formatAiRateLimitInfo(rateInfo)})`
+                        );
+                      } else {
+                        console.log(
+                          `#${rawIssue.number}: AI fallback rate-limited - waiting ${retryAfter}s then retrying (${formatAiRateLimitInfo(rateInfo)})`
+                        );
+                        await new Promise((r) => setTimeout(r, retryAfter * 1000));
+                        res = await fetch(aiUrl, { method: 'POST', headers: aiHeaders, body: JSON.stringify(aiPayload) });
+                      }
+                    }
+
+                    if (res.ok) {
+                      const data = await res.json();
+                      const raw = data.choices?.[0]?.message?.content || '{}';
+                      const parsed = parseAiGameResponse(raw);
+                      const detectedGame = normalizeName(parsed?.detected_game || '');
+                      const confidence = (parsed?.game_confidence || '').toLowerCase();
+                      if (detectedGame && confidence === 'high') {
+                        const mappedLabel =
+                          gameAliasToLabel.get(detectedGame) || gameLabelByNormalized.get(detectedGame);
+                        if (mappedLabel) {
+                          const hasAliasEvidence = hasAliasHitForLabel(
+                            `${title}\n${body}`,
+                            mappedLabel,
+                            gameAliasToLabel
+                          );
+                          if (hasAliasEvidence) {
+                            desiredGames.add(mappedLabel);
+                            gameLabelSource.set(mappedLabel, 'ai-fallback');
+                            const mappedScript = gameAliasToScript.get(detectedGame);
+                            if (mappedScript) desiredServerScripts.add(mappedScript);
+                            aiGameMatches += 1;
+                            console.log(
+                              `#${rawIssue.number}: AI fallback accepted game "${mappedLabel}" from "${parsed?.detected_game}"`
+                            );
+                          } else {
+                            console.log(
+                              `#${rawIssue.number}: AI fallback rejected game "${mappedLabel}" (no literal alias evidence in issue text)`
+                            );
+                          }
+                        } else {
+                          if (isGenericNonGameDetection(parsed?.detected_game || '')) {
+                            console.log(
+                              `#${rawIssue.number}: AI fallback skipped generic non-game detection "${parsed?.detected_game}"`
+                            );
+                          } else {
+                            console.log(
+                              `#${rawIssue.number}: AI fallback returned unmapped game "${parsed?.detected_game}"`
+                            );
+                          }
+                        }
+                      }
+                    } else {
+                      if (res.status === 429) {
+                        const rateInfo = parseAiRateLimitInfo(res);
+                        console.log(
+                          `#${rawIssue.number}: AI fallback skipped (HTTP 429, ${formatAiRateLimitInfo(rateInfo)})`
+                        );
+                      } else {
+                        console.log(`#${rawIssue.number}: AI fallback skipped (HTTP ${res.status})`);
+                      }
+                    }
+                  } catch (err) {
+                    console.log(`#${rawIssue.number}: AI fallback error: ${err.message}`);
+                  }
+                  }
+                }
+
+                for (const gameLabel of desiredGames) {
+                  const mappedScript = gameAliasToScript.get(normalizeName(gameLabel.slice(6)));
+                  if (mappedScript) desiredServerScripts.add(mappedScript);
+                }
+
+                const desiredEngineLabels = new Set();
+                for (const scriptName of desiredServerScripts) {
+                  const engine = await getEngineForScript(scriptName);
+                  if (!engine) continue;
+                  const engineLabel = `engine: ${engine}`;
+                  await ensureEngineLabel(engineLabel);
+                  desiredEngineLabels.add(engineLabel);
+                }
+
+                if (desiredEngineLabels.size > 0) {
+                  for (const label of desiredEngineLabels) labelsToAdd.add(label);
+                  for (const label of existingLabels) {
+                    if (label.startsWith('engine: ') && !desiredEngineLabels.has(label)) labelsToRemove.add(label);
+                  }
+                }
+
+                if (desiredGames.size > 0) {
+                  for (const label of desiredGames) labelsToAdd.add(label);
+                  if (hasStructuredGameSelection) {
+                    for (const label of existingLabels) {
+                      if (label.startsWith('game: ') && !desiredGames.has(label)) labelsToRemove.add(label);
+                    }
+                  } else {
+                    // For legacy issues without structured game selection, only prune stale
+                    // broader labels when a more specific inferred game label exists.
+                    const desiredGameNamesNormalized = new Set(
+                      [...desiredGames].map((label) => normalizeName(label.slice(6)))
+                    );
+                    for (const label of existingLabels) {
+                      if (!label.startsWith('game: ') || desiredGames.has(label)) continue;
+                      const existingGameName = normalizeName(label.slice(6));
+                      const isBroaderOverlap = [...desiredGameNamesNormalized].some(
+                        (desiredName) => desiredName !== existingGameName && desiredName.startsWith(`${existingGameName} `)
+                      );
+                      if (isBroaderOverlap) labelsToRemove.add(label);
+                    }
+                  }
+                }
+
+                // Apply changes
+                const finalAdds = [...labelsToAdd].filter((label) => !existingLabels.has(label));
+                const finalRemoves = [...labelsToRemove].filter((label) => existingLabels.has(label));
+
+                let labelAdded = 0;
+                let labelRemoved = 0;
+
+                for (const label of finalRemoves) {
+                  try {
+                    await github.rest.issues.removeLabel({
+                      owner,
+                      repo,
+                      issue_number: rawIssue.number,
+                      name: label,
+                    });
+                    labelRemoved += 1;
+                    console.log(`#${rawIssue.number}: removed "${label}"`);
+                  } catch (err) {
+                    if (isApiRateLimitError(err)) throw err;
+                    console.log(`#${rawIssue.number}: could not remove "${label}": ${err.message}`);
+                  }
+                }
+
+                for (const label of finalAdds) {
+                  try {
+                    await github.rest.issues.addLabels({
+                      owner,
+                      repo,
+                      issue_number: rawIssue.number,
+                      labels: [label],
+                    });
+                    labelAdded += 1;
+                    const gameSource = gameLabelSource.get(label);
+                    console.log(`#${rawIssue.number}: added "${label}"${gameSource ? ` (${gameSource})` : ''}`);
+                  } catch (err) {
+                    if (isApiRateLimitError(err)) throw err;
+                    console.log(`#${rawIssue.number}: could not add "${label}": ${err.message}`);
+                  }
+                }
+
+                processed += 1;
+                processedIssues.push({
+                  number: rawIssue.number,
+                  title: rawIssue.title,
+                  adds: labelAdded,
+                  removes: labelRemoved,
+                  issueTypeSet,
+                  locked: isLocked,
+                });
+                console.log(
+                  `#${rawIssue.number}: done (+${labelAdded} added, -${labelRemoved} removed${
+                    issueTypeSet ? `, type→${issueTypeSet}` : ''
+                  }${isLocked ? ', locked' : ''})`
+                );
+              } catch (err) {
+                if (isApiRateLimitError(err)) {
+                  stoppedForApiRateLimit = true;
+                  apiRateLimitStopReason = formatApiRateLimitError(err);
+                  console.log(
+                    `Stopping backfill due to API rate limit at #${rawIssue.number} (${apiRateLimitStopReason})`
+                  );
+                  failedIssues.push({
+                    number: rawIssue.number,
+                    title: rawIssue.title,
+                    stage: 'rate-limit',
+                    error: err.message,
+                  });
+                  break;
+                } else {
+                  console.log(`Error processing #${rawIssue.number}: ${err.message}`);
+                  failedIssues.push({
+                    number: rawIssue.number,
+                    title: rawIssue.title,
+                    stage: 'process',
+                    error: err.message,
+                  });
+                }
+              }
+            }
+
+            console.log(
+              `Relabel backfill complete: ${processed} processed, ${failedIssues.length} failed${
+                stoppedForApiRateLimit ? `, stopped early (${apiRateLimitStopReason})` : ''
+              }.`
+            );
+
+            await core.summary
+              .addHeading('Relabel Backfill Summary')
+              .addTable([
+                [
+                  { data: 'Requested state', header: true },
+                  { data: 'Limit', header: true },
+                  { data: 'AI fallback', header: true },
+                  { data: 'AI attempts', header: true },
+                  { data: 'AI matches', header: true },
+                  { data: 'AI 429s', header: true },
+                  { data: 'AI disabled reason', header: true },
+                  { data: 'Stopped early', header: true },
+                  { data: 'Target issues', header: true },
+                  { data: 'Processed', header: true },
+                  { data: 'Failures', header: true },
+                ],
+                [
+                  state,
+                  limit === 0 ? 'all' : String(limit),
+                  useAiGameFallback ? 'enabled' : 'disabled',
+                  String(aiGameAttempts),
+                  String(aiGameMatches),
+                  String(aiGameRateLimited),
+                  aiFallbackDisabledReason || '—',
+                  stoppedForApiRateLimit ? apiRateLimitStopReason : 'no',
+                  String(selectedTargets.length),
+                  String(processed),
+                  String(failedIssues.length),
+                ],
+              ])
+              .write();
+
+            if (processedIssues.length > 0) {
+              const processedRows = processedIssues.slice(0, 50).map((issue) => [
+                `#${issue.number}${issue.locked ? ' 🔒' : ''}`,
+                `[${issue.title}](https://github.com/${owner}/${repo}/issues/${issue.number})`,
+                `+${issue.adds} / -${issue.removes}`,
+                issue.issueTypeSet || '—',
+              ]);
+
+              await core.summary
+                .addHeading('Processed Issues')
+                .addTable([
+                  [
+                    { data: 'Issue', header: true },
+                    { data: 'Title', header: true },
+                    { data: 'Label changes', header: true },
+                    { data: 'Issue Type set', header: true },
+                  ],
+                  ...processedRows,
+                ])
+                .write();
+            }
+
+            if (failedIssues.length > 0) {
+              const failureRows = failedIssues.slice(0, 50).map((issue) => [
+                `#${issue.number}`,
+                issue.stage,
+                issue.error,
+              ]);
+
+              await core.summary
+                .addHeading('Failures')
+                .addTable([
+                  [
+                    { data: 'Issue', header: true },
+                    { data: 'Stage', header: true },
+                    { data: 'Error', header: true },
+                  ],
+                  ...failureRows,
+                ])
+                .write();
+            }
+
+  pr-labeler:
+    if: github.repository_owner == 'GameServerManagers' && github.event_name == 'pull_request'
+    runs-on: ubuntu-latest
+    steps:
+      - name: PR Labeler
+        uses: github/issue-labeler@v3.4
+        with:
+          repo-token: "${{ secrets.GITHUB_TOKEN }}"
+          configuration-path: .github/labeler.yml
+          enable-versioned-regex: 0
+          include-title: 1
+          include-body: 0
+          sync-labels: 1
+
+  is-sponsor-label:
+    if: github.repository_owner == 'GameServerManagers' && github.event_name == 'issues' && github.event.action == 'opened'
+    runs-on: ubuntu-latest
+    steps:
+      - name: Is Sponsor Label
+        uses: JasonEtco/is-sponsor-label-action@v2
+        env:
+          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+
+  sync-game-labels:
+    if: github.repository_owner == 'GameServerManagers' && github.event_name == 'push' && contains(github.event.head_commit.modified, 'lgsm/data/serverlist.csv')
+    runs-on: ubuntu-latest
+    steps:
+      - name: Checkout
+        uses: actions/checkout@v5
+
+      - name: Sync game labels from serverlist
+        env:
+          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+        run: |
+          chmod +x .github/scripts/sync-game-labels.sh
+          .github/scripts/sync-game-labels.sh

+ 1 - 1
.github/workflows/lock.yml → .github/workflows/action-lock.yml

@@ -15,7 +15,7 @@ jobs:
     runs-on: ubuntu-latest
     steps:
       - name: Lock Threads
-        uses: dessant/lock-threads@v5
+        uses: dessant/lock-threads@v6
         with:
           github-token: ${{ secrets.GITHUB_TOKEN }}
           issue-comment: >

+ 2 - 2
.github/workflows/serverlist-validate.yml → .github/workflows/action-serverlist-validate.yml

@@ -15,7 +15,7 @@ jobs:
         uses: actions/checkout@v4
 
       - name: Compare Versions
-        run: chmod +x .github/workflows/serverlist-validate.sh; .github/workflows/serverlist-validate.sh
+        run: .github/scripts/serverlist-validate.sh
 
       - name: Validate Game Icons
-        run: chmod +x .github/workflows/serverlist-validate-game-icons.sh; .github/workflows/serverlist-validate-game-icons.sh
+        run: .github/scripts/serverlist-validate-game-icons.sh

+ 2 - 4
.github/workflows/action-super-linter.yml

@@ -27,9 +27,9 @@ jobs:
       - name: Checkout code
         uses: actions/checkout@v6
         with:
-          # Full clone required so super-linter can resolve GITHUB_BEFORE_SHA.
+          # super-linter needs the full git history to get the
+          # list of files that changed across commits
           fetch-depth: 0
-          fetch-tags: false
           persist-credentials: false
 
       - name: Install Prettier plugins (for summary formatting)
@@ -41,7 +41,6 @@ jobs:
         env:
           # To report GitHub Actions status checks
           GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-          DEFAULT_BRANCH: ${{ github.ref_name }}
           VALIDATE_BIOME_FORMAT: false
           VALIDATE_BIOME_LINT: false
           VALIDATE_GITHUB_ACTIONS_ZIZMOR: false
@@ -49,7 +48,6 @@ jobs:
           VALIDATE_JSON_PRETTIER: false
           VALIDATE_MARKDOWN_PRETTIER: false
           VALIDATE_NATURAL_LANGUAGE: false
-          VALIDATE_PYTHON_RUFF_FORMAT: false
           VALIDATE_SHELL_SHFMT: false
           VALIDATE_TRIVY: false
           VALIDATE_YAML_PRETTIER: false

+ 0 - 0
.github/workflows/trigger-docker-build.yml → .github/workflows/action-trigger-docker-build.yml


+ 0 - 0
.github/workflows/update-check.yml → .github/workflows/action-update-check.yml


+ 1 - 1
.github/workflows/version-check.yml → .github/workflows/action-version-check.yml

@@ -14,4 +14,4 @@ jobs:
         uses: actions/checkout@v4
 
       - name: Version Check
-        run: chmod +x .github/workflows/version-check.sh; .github/workflows/version-check.sh
+        run: .github/scripts/version-check.sh

+ 0 - 32
.github/workflows/labeler.yml

@@ -1,32 +0,0 @@
-name: Issue Labeler
-on:
-  issues:
-    types:
-      - opened
-      - edited
-
-permissions:
-  issues: write
-  contents: read
-
-jobs:
-  issue-labeler:
-    if: github.repository_owner == 'GameServerManagers'
-    runs-on: ubuntu-latest
-    steps:
-      - name: Issue Labeler
-        uses: github/issue-labeler@v3.4
-        with:
-          repo-token: "${{ secrets.GITHUB_TOKEN }}"
-          configuration-path: .github/labeler.yml
-          enable-versioned-regex: 0
-          include-title: 1
-
-  is-sponsor-label:
-    if: github.repository_owner == 'GameServerManagers'
-    runs-on: ubuntu-latest
-    steps:
-      - name: Is Sponsor Label
-        uses: JasonEtco/is-sponsor-label-action@v2
-        env:
-          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

+ 0 - 27
.github/workflows/potential-duplicates.yml

@@ -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 - 29
.github/workflows/update-copyright-years-in-license-file.yml

@@ -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
-  pull-requests: write
-
-jobs:
-  update-license-year:
-    if: github.repository_owner == 'GameServerManagers'
-    runs-on: ubuntu-latest
-    steps:
-      - name: Checkout
-        uses: actions/checkout@v4
-        with:
-          fetch-depth: 0
-      - 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 - 1
.prettierrc.json

@@ -1,3 +1,3 @@
 {
-	"plugins": ["prettier-plugin-sh"]
+  "plugins": ["prettier-plugin-sh"]
 }

+ 10 - 10
.vscode/extensions.json

@@ -1,12 +1,12 @@
 {
-	"recommendations": [
-		"DavidAnson.vscode-markdownlint",
-		"editorconfig.editorconfig",
-		"esbenp.prettier-vscode",
-		"github.vscode-github-actions",
-		"GitHub.vscode-pull-request-github",
-		"redhat.vscode-yaml",
-		"timonwong.shellcheck",
-		"yzhang.markdown-all-in-one"
-	]
+  "recommendations": [
+    "DavidAnson.vscode-markdownlint",
+    "editorconfig.editorconfig",
+    "esbenp.prettier-vscode",
+    "github.vscode-github-actions",
+    "GitHub.vscode-pull-request-github",
+    "redhat.vscode-yaml",
+    "timonwong.shellcheck",
+    "yzhang.markdown-all-in-one"
+  ]
 }

+ 145 - 118
CODE_OF_CONDUCT.md

@@ -2,131 +2,158 @@
 
 ## Our Pledge
 
-We as members, contributors, and leaders pledge to make participation in our
-community a harassment-free experience for everyone, regardless of age, body
-size, visible or invisible disability, ethnicity, sex characteristics, gender
-identity and expression, level of experience, education, socio-economic status,
-nationality, personal appearance, race, caste, color, religion, or sexual identity
-and orientation.
-
-We pledge to act and interact in ways that contribute to an open, welcoming,
-diverse, inclusive, and healthy community.
-
-## Our Standards
-
-Examples of behavior that contributes to a positive environment for our
-community include:
-
-- Demonstrating empathy and kindness toward other people
-- Being respectful of differing opinions, viewpoints, and experiences
-- Giving and gracefully accepting constructive feedback
-- Accepting responsibility and apologizing to those affected by our mistakes,
-  and learning from the experience
-- Focusing on what is best not just for us as individuals, but for the
-  overall community
-
-Examples of unacceptable behavior include:
-
-- The use of sexualized language or imagery, and sexual attention or
-  advances of any kind
-- Trolling, insulting or derogatory comments, and personal or political attacks
-- Public or private harassment
-- Publishing others' private information, such as a physical or email
-  address, without their explicit permission
-- Other conduct which could reasonably be considered inappropriate in a
-  professional setting
-
-## Enforcement Responsibilities
-
-Community leaders are responsible for clarifying and enforcing our standards of
-acceptable behavior and will take appropriate and fair corrective action in
-response to any behavior that they deem inappropriate, threatening, offensive,
-or harmful.
-
-Community leaders have the right and responsibility to remove, edit, or reject
-comments, commits, code, wiki edits, issues, and other contributions that are
-not aligned to this Code of Conduct, and will communicate reasons for moderation
-decisions when appropriate.
+We pledge to make our community welcoming, safe, and equitable for all.
+
+We are committed to fostering an environment that respects and promotes the
+dignity, rights, and contributions of all individuals, regardless of characteristics
+including race, ethnicity, caste, color, age, physical characteristics,
+neurodiversity, disability, sex or gender, gender identity or expression, sexual
+orientation, language, philosophy or religion, national or social origin,
+socio-economic position, level of education, or other status. The same privileges of
+participation are extended to everyone who participates in good faith and in
+accordance with this Covenant.
+
+## Encouraged Behaviors
+
+While acknowledging differences in social norms, we all strive to meet our
+community's expectations for positive behavior. We also understand that our words
+and actions may be interpreted differently than we intend based on culture,
+background, or native language.
+
+With these considerations in mind, we agree to behave mindfully toward each other
+and act in ways that center our shared values, including:
+
+1. Respecting the **purpose of our community**, our activities, and our ways of gathering.
+2. Engaging **kindly and honestly** with others.
+3. Respecting **different viewpoints** and experiences.
+4. **Taking responsibility** for our actions and contributions.
+5. Gracefully giving and accepting **constructive feedback**.
+6. Committing to **repairing harm** when it occurs.
+7. Behaving in other ways that promote and sustain the **well-being of our community**.
+
+## Restricted Behaviors
+
+We agree to restrict the following behaviors in our community. Instances,
+threats, and promotion of these behaviors are violations of this Code of Conduct.
+
+1. **Harassment.** Violating explicitly expressed boundaries or engaging in
+   unnecessary personal attention after any clear request to stop.
+2. **Character attacks.** Making insulting, demeaning, or pejorative comments
+   directed at a community member or group of people.
+3. **Stereotyping or discrimination.** Characterizing anyone's personality or
+   behavior on the basis of immutable identities or traits.
+4. **Sexualization.** Behaving in a way that would generally be considered
+   inappropriately intimate in the context or purpose of the community.
+5. **Violating confidentiality.** Sharing or acting on someone's personal or
+   private information without their permission.
+6. **Endangerment.** Causing, encouraging, or threatening violence or other harm
+   toward any person or group.
+7. Behaving in other ways that **threaten the well-being** of our community.
+
+### Other Restrictions
+
+1. **Misleading identity.** Impersonating someone else for any reason, or
+   pretending to be someone else to evade enforcement actions.
+2. **Failing to credit sources.** Not properly crediting the sources of content
+   you contribute.
+3. **Promotional materials.** Sharing marketing or other commercial content in a
+   way that is outside the norms of the community.
+4. **Irresponsible communication.** Failing to responsibly present content which
+   includes, links or describes any other restricted behaviors.
+
+## Reporting an Issue
+
+Tensions can occur between community members even when they are trying their best
+to collaborate. Not every conflict represents a code of conduct violation, and this
+Code of Conduct reinforces encouraged behaviors and norms that can help avoid
+conflicts and minimize harm.
+
+When an incident does occur, it is important to report it promptly. To report a
+possible violation, please use one of the following methods:
+
+- **GitHub (private):** [Submit a private security advisory](https://github.com/GameServerManagers/LinuxGSM/security/advisories/new)
+- **Discord:** Contact a moderator via the [LinuxGSM Discord server](https://linuxgsm.com/discord)
+
+Community Moderators take reports of violations seriously and will make every
+effort to respond in a timely manner. They will investigate all reports of code of
+conduct violations, reviewing messages, logs, and recordings, or interviewing
+witnesses and other participants. Community Moderators will keep investigation and
+enforcement actions as transparent as possible while prioritizing safety and
+confidentiality. In order to honor these values, enforcement actions are carried out
+in private with the involved parties, but communicating to the whole community may
+be part of a mutually agreed upon resolution.
+
+## Addressing and Repairing Harm
+
+If an investigation by the Community Moderators finds that this Code of Conduct
+has been violated, the following enforcement ladder may be used to determine how
+best to repair harm, based on the incident's impact on the individuals involved
+and the community as a whole. Depending on the severity of a violation, lower
+rungs on the ladder may be skipped.
+
+1. **Warning**
+   1. Event: A violation involving a single incident or series of incidents.
+   2. Consequence: A private, written warning from the Community Moderators.
+   3. Repair: Examples of repair include a private written apology, acknowledgement
+      of responsibility, and seeking clarification on expectations.
+
+2. **Temporarily Limited Activities**
+   1. Event: A repeated incidence of a violation that previously resulted in a
+      warning, or the first incidence of a more serious violation.
+   2. Consequence: A private, written warning with a time-limited cooldown period
+      designed to underscore the seriousness of the situation and give the community
+      members involved time to process the incident. The cooldown period may be
+      limited to particular communication channels or interactions with particular
+      community members.
+   3. Repair: Examples of repair may include making an apology, using the cooldown
+      period to reflect on actions and impact, and being thoughtful about
+      re-entering community spaces after the period is over.
+
+3. **Temporary Suspension**
+   1. Event: A pattern of repeated violation which the Community Moderators have
+      tried to address with warnings, or a single serious violation.
+   2. Consequence: A private written warning with conditions for return from
+      suspension. In general, temporary suspensions give the person being suspended
+      time to reflect upon their behavior and possible corrective actions.
+   3. Repair: Examples of repair include respecting the spirit of the suspension,
+      meeting the specified conditions for return, and being thoughtful about how to
+      reintegrate with the community when the suspension is lifted.
+
+4. **Permanent Ban**
+   1. Event: A pattern of repeated code of conduct violations that other steps on
+      the ladder have failed to resolve, or a violation so serious that the Community
+      Moderators determine there is no way to keep the community safe with this
+      person as a member.
+   2. Consequence: Access to all community spaces, tools, and communication channels
+      is removed. In general, permanent bans should be rarely used, should have
+      strong reasoning behind them, and should only be resorted to if working through
+      other remedies has failed to change the behavior.
+   3. Repair: There is no possible repair in cases of this severity.
+
+This enforcement ladder is intended as a guideline. It does not limit the ability
+of Community Moderators to use their discretion and judgment, in keeping with the
+best interests of our community.
 
 ## Scope
 
-This Code of Conduct applies within all community spaces, and also applies when
-an individual is officially representing the community in public spaces.
-Examples of representing our community include using an official e-mail address,
+This Code of Conduct applies within all community spaces, and also applies when an
+individual is officially representing the community in public or other spaces.
+Examples of representing our community include using an official email address,
 posting via an official social media account, or acting as an appointed
 representative at an online or offline event.
 
-## Enforcement
-
-Instances of abusive, harassing, or otherwise unacceptable behavior may be
-reported to the community leaders responsible for enforcement at
-[INSERT CONTACT METHOD].
-All complaints will be reviewed and investigated promptly and fairly.
-
-All community leaders are obligated to respect the privacy and security of the
-reporter of any incident.
-
-## Enforcement Guidelines
-
-Community leaders will follow these Community Impact Guidelines in determining
-the consequences for any action they deem in violation of this Code of Conduct:
-
-### 1. Correction
-
-**Community Impact**: Use of inappropriate language or other behavior deemed
-unprofessional or unwelcome in the community.
-
-**Consequence**: A private, written warning from community leaders, providing
-clarity around the nature of the violation and an explanation of why the
-behavior was inappropriate. A public apology may be requested.
-
-### 2. Warning
-
-**Community Impact**: A violation through a single incident or series
-of actions.
-
-**Consequence**: A warning with consequences for continued behavior. No
-interaction with the people involved, including unsolicited interaction with
-those enforcing the Code of Conduct, for a specified period of time. This
-includes avoiding interactions in community spaces as well as external channels
-like social media. Violating these terms may lead to a temporary or
-permanent ban.
-
-### 3. Temporary Ban
-
-**Community Impact**: A serious violation of community standards, including
-sustained inappropriate behavior.
-
-**Consequence**: A temporary ban from any sort of interaction or public
-communication with the community for a specified period of time. No public or
-private interaction with the people involved, including unsolicited interaction
-with those enforcing the Code of Conduct, is allowed during this period.
-Violating these terms may lead to a permanent ban.
-
-### 4. Permanent Ban
-
-**Community Impact**: Demonstrating a pattern of violation of community
-standards, including sustained inappropriate behavior, harassment of an
-individual, or aggression toward or disparagement of classes of individuals.
-
-**Consequence**: A permanent ban from any sort of public interaction within
-the community.
-
 ## Attribution
 
-This Code of Conduct is adapted from the [Contributor Covenant][homepage],
-version 2.1, available at
-[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
-
-Community Impact Guidelines were inspired by
-[Mozilla's code of conduct enforcement ladder][mozilla coc].
+This Code of Conduct is adapted from the Contributor Covenant, version 3.0,
+permanently available at <https://www.contributor-covenant.org/version/3/0/>.
 
-For answers to common questions about this code of conduct, see the FAQ at
-[https://www.contributor-covenant.org/faq][faq]. Translations are available
-at [https://www.contributor-covenant.org/translations][translations].
+Contributor Covenant is stewarded by the Organization for Ethical Source and
+licensed under CC BY-SA 4.0. To view a copy of this license, visit
+<https://creativecommons.org/licenses/by-sa/4.0/>.
 
-[homepage]: https://www.contributor-covenant.org
-[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
-[mozilla coc]: https://github.com/mozilla/diversity
-[faq]: https://www.contributor-covenant.org/faq
-[translations]: https://www.contributor-covenant.org/translations
+For answers to common questions about Contributor Covenant, see the FAQ at
+<https://www.contributor-covenant.org/faq>. Translations are provided at
+<https://www.contributor-covenant.org/translations>. Additional enforcement and
+community guideline resources can be found at
+<https://www.contributor-covenant.org/resources>. The enforcement ladder was
+inspired by the work of [Mozilla's code of conduct team](https://github.com/mozilla/inclusion).

+ 1 - 1
README.md

@@ -1,7 +1,7 @@
 <p align="center">
   <a href="https://linuxgsm.com"><img src="https://i.imgur.com/Eoh1jsi.jpg" alt="LinuxGSM">
   <a href="https://www.codacy.com/gh/GameServerManagers/LinuxGSM/dashboard"><img src="https://img.shields.io/codacy/grade/d19c5234dc3743d8a8a14093711ca52d?style=flat-square&logo=codacy&logoColor=white" alt="Codacy grade"></a>
-  <a href="https://bitbucket.org/GameServerManagers/linuxgsm"><img alt="GitHub Workflow Status" src="https://img.shields.io/github/actions/workflow/status/GameServerManagers/LinuxGSM/git-sync.yml?color=0052CC&logo=bitbucket&style=flat-square"></a>
+  <a href="https://bitbucket.org/GameServerManagers/linuxgsm"><img alt="GitHub Workflow Status" src="https://img.shields.io/github/actions/workflow/status/GameServerManagers/LinuxGSM/action-git-sync.yml?color=0052CC&logo=bitbucket&style=flat-square"></a>
   <a href="https://linuxgsm.com/discord"><img alt="Discord" src="https://img.shields.io/discord/127498813903601664?color=5865F2&label=%20&logo=discord&logoColor=ffffff&style=flat-square"></a>
   <a href="https://developer.valvesoftware.com/wiki/SteamCMD"><img src="https://img.shields.io/badge/SteamCMD-000000?style=flat-square&amp;logo=Steam&amp;logoColor=white" alt="SteamCMD"></a>
   <a href="https://github.com/GameServerManagers/LinuxGSM/blob/master/LICENSE.md"><img src="https://img.shields.io/github/license/gameservermanagers/LinuxGSM?style=flat-square" alt="MIT License"></a>