Kaynağa Gözat

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 ay önce
ebeveyn
işleme
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:
     attributes:
       value: |
       value: |
         Thanks for taking the time to fill out this bug report!
         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
   - type: input
     id: user-story
     id: user-story
     attributes:
     attributes:
@@ -16,6 +61,14 @@ body:
       placeholder: As a [user description], I want [desired action] so that [desired outcome].
       placeholder: As a [user description], I want [desired action] so that [desired outcome].
     validations:
     validations:
       required: true
       required: true
+  - type: input
+    id: script-name
+    attributes:
+      label: Script name
+      description: LinuxGSM script name in use.
+      placeholder: vhserver
+    validations:
+      required: true
   - type: input
   - type: input
     id: game
     id: game
     attributes:
     attributes:
@@ -66,6 +119,22 @@ body:
         - "command: send"
         - "command: send"
     validations:
     validations:
       required: true
       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
   - type: textarea
     id: further-info
     id: further-info
     attributes:
     attributes:
@@ -74,11 +143,19 @@ body:
       placeholder: Tell us what you see!
       placeholder: Tell us what you see!
     validations:
     validations:
       required: true
       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
   - type: textarea
     id: logs
     id: logs
     attributes:
     attributes:
       label: Relevant log output
       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
       render: shell
   - type: textarea
   - type: textarea
     id: steps
     id: steps
@@ -90,3 +167,5 @@ body:
         2. Click on '....'
         2. Click on '....'
         3. Scroll down to '....'
         3. Scroll down to '....'
         4. See error
         4. See error
+    validations:
+      required: true

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

@@ -6,3 +6,6 @@ contact_links:
   - name: Discord Server
   - name: Discord Server
     about: Join the LinuxGSM Discord community server. Discuss your LinuxGSM setup, get help and advice
     about: Join the LinuxGSM Discord community server. Discuss your LinuxGSM setup, get help and advice
     url: https://linuxgsm.com/discord
     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:
     attributes:
       value: |
       value: |
         Thanks for taking the time to fill out this feature request!
         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
   - type: input
     id: user-story
     id: user-story
     attributes:
     attributes:
@@ -64,12 +75,41 @@ body:
         - "command: update-lgsm"
         - "command: update-lgsm"
         - "command: wipe"
         - "command: wipe"
         - "command: send"
         - "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:
     validations:
       required: true
       required: true
   - type: textarea
   - type: textarea
     id: further-info
     id: further-info
     attributes:
     attributes:
       label: Further information
       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:
     validations:
       required: true
       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
   - type: markdown
     attributes:
     attributes:
       value: |
       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
   - type: input
     id: game-server
     id: game-server
     attributes:
     attributes:
@@ -15,11 +15,19 @@ body:
       description: What game server would you like to add?
       description: What game server would you like to add?
     validations:
     validations:
       required: true
       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
   - type: checkboxes
     id: on-linux
     id: on-linux
     attributes:
     attributes:
       label: Linux support
       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:
       options:
         - label: "Yes"
         - label: "Yes"
     validations:
     validations:
@@ -38,20 +46,40 @@ body:
     id: steam-id
     id: steam-id
     attributes:
     attributes:
       label: Steam appid
       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"
       placeholder: "892970"
     validations:
     validations:
       required: false
       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
   - type: textarea
     id: guides
     id: guides
     attributes:
     attributes:
       label: Guides
       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
   - type: checkboxes
     id: terms
     id: terms
     attributes:
     attributes:
       label: Code of Conduct
       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:
       options:
         - label: I agree to follow this project's Code of Conduct
         - label: I agree to follow this project's Code of Conduct
           required: true
           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":
 "command: backup":
-  - "/(backup)/i"
+  - "/(command:\\s*backup)/i"
 "command: console":
 "command: console":
-  - "/(console|tmux)/i"
+  - "/(command:\\s*console)/i"
 "command: debug":
 "command: debug":
   - "/(command: debug)/i"
   - "/(command: debug)/i"
 "command: details":
 "command: details":
   - "/(command: details)/i"
   - "/(command: details)/i"
 "command: fast-dl":
 "command: fast-dl":
-  - "/(fast-dl|fastdl)/i"
+  - "/(command:\\s*fast-?dl)/i"
 "command: install":
 "command: install":
-  - "/(install)/i"
+  - "/(command:\\s*install)/i"
 "command: mods":
 "command: mods":
-  - "/(command: mods)/i"
+  - "/(command:\\s*mods(?:-install|-update|-remove)?)/i"
 "command: monitor":
 "command: monitor":
   - "/(command: monitor)/i"
   - "/(command: monitor)/i"
 "command: post-details":
 "command: post-details":
@@ -27,136 +27,108 @@
 "command: stop":
 "command: stop":
   - "/(command: stop)/i"
   - "/(command: stop)/i"
 "command: update-lgsm":
 "command: update-lgsm":
-  - "/(update-lgsm)/i"
+  - "/(command:\\s*update-lgsm)/i"
 "command: update":
 "command: update":
-  - "/(command: update)/i"
+  - "/(command:\\s*update(?!-lgsm)\\b)/i"
 "command: validate":
 "command: validate":
-  - "/(validate)/i"
+  - "/(command:\\s*validate)/i"
 "command: wipe":
 "command: wipe":
-  - "/(wipe)/i"
+  - "/(command:\\s*wipe)/i"
 
 
 # Distros
 # Distros
 "distro: AlmaLinux":
 "distro: AlmaLinux":
-  - "/(Alma)/i"
+  - "/\\bAlmaLinux(?:\\s+\\d+)?\\b/i"
 "distro: Arch Linux":
 "distro: Arch Linux":
-  - "/(Arch Linux)/i"
+  - "/\\bArch Linux\\b/i"
 "distro: CentOS":
 "distro: CentOS":
-  - "/(CentOS)/i"
+  - "/\\bCentOS(?:\\s+\\d+)?\\b/i"
 "distro: Debian":
 "distro: Debian":
-  - "/(Debian)/i"
+  - "/\\bDebian(?:\\s+\\d+)?\\b/i"
 "distro: Fedora":
 "distro: Fedora":
-  - "/(Fedora)/i"
+  - "/\\bFedora(?:\\s+\\d+)?\\b/i"
 "distro: openSUSE":
 "distro: openSUSE":
-  - "/(openSUSE|suse)/i"
+  - "/\\bopenSUSE\\b/i"
 "distro: Rocky Linux":
 "distro: Rocky Linux":
-  - "/(Rocky)/i"
+  - "/\\bRocky(?:\\s+Linux)?(?:\\s+\\d+)?\\b/i"
 "distro: Slackware":
 "distro: Slackware":
-  - "/(Slackware)/i"
+  - "/\\bSlackware(?:\\s+\\d+)?\\b/i"
 "distro: Ubuntu":
 "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
 "info: alerts":
 "info: alerts":
-  - "/(alert)/i"
+  - "/(alert_(discord|email|gotify|ifttt|ntfy|pushbullet|pushover|rocketchat|slack|telegram)|command:\\s*test-alert)/i"
 "info: dependency":
 "info: dependency":
-  - "/(dependency|deps)/i"
+  - "/\\b(dependency|dependencies|deps)\\b/i"
 "info: docker":
 "info: docker":
-  - "/(docker)/i"
+  - "/\\bdocker\\b/i"
 "info: docs":
 "info: docs":
-  - "/(documentation|^docs$)/i"
+  - "/(^docs$)/i"
 "info: email":
 "info: email":
-  - "/(postfix|sendmail|exim|smtp)/i"
+  - "/\\b(postfix|sendmail|exim|smtp)\\b/i"
 "info: query":
 "info: query":
-  - "/(gamedig|gsquery)/i"
+  - "/\\b(gamedig|gsquery)\\b/i"
 "info: steamcmd":
 "info: steamcmd":
-  - "/(steamcmd)/i"
+  - "/\\bsteamcmd\\b/i"
 "info: systemd":
 "info: systemd":
-  - "/(systemd)/i"
+  - "/\\bsystemd\\b/i"
 "info: tmux":
 "info: tmux":
-  - "/(tmux)/i"
+  - "/(tmuxception|check_tmuxception)/i"
 "info: website":
 "info: website":
-  - "/(website)/i"
+  - "/\\bwebsite\\b/i"
 
 
 # Type
 # Type
 "type: game server request":
 "type: game server request":
-  - "/(Server Request)/i"
+  - "/(^\\[server request\\]|^server request:|type:\\s*game server request)/im"
 "type: bug":
 "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).
 - [ ] Refactor (restructures existing code).
 - [ ] Comment update (typo, spelling, explanation, examples, etc).
 - [ ] 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
 ## Checklist
 
 
 PR will not be merged until all steps are complete.
 PR will not be merged until all steps are complete.
 
 
 - [ ] This pull request links to an issue.
 - [ ] 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 pull request subject follows the Conventional Commits standard.
 - [ ] This code follows the style guidelines of this project.
 - [ ] This code follows the style guidelines of this project.
 - [ ] I have performed a self-review of my code.
 - [ ] 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 provided a detailed enough description of this PR.
-- [ ] I have checked if documentation needs updating.
 
 
 ## Documentation
 ## 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 }')
 	distro=$(echo "$line" | awk -F, '{ print $4 }')
 	export distro
 	export distro
 	{
 	{
-		echo -n "{";
-		echo -n "\"shortname\":";
-		echo -n "\"${shortname}\"";
-		echo -n "},";
+		echo -n "{"
+		echo -n "\"shortname\":"
+		echo -n "\"${shortname}\""
+		echo -n "},"
 	} >> "shortnamearray.json"
 	} >> "shortnamearray.json"
 done < <(tail -n +2 serverlist.csv)
 done < <(tail -n +2 serverlist.csv)
 sed -i '$ s/.$//' "shortnamearray.json"
 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
 	fi
 done
 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}"
 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
         uses: actions/checkout@v4
 
 
       - name: Generate matrix with generate-matrix.sh
       - 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
       - name: Set Matrix
         id: 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
     runs-on: ubuntu-latest
     steps:
     steps:
       - name: SSH Agent
       - name: SSH Agent
-        uses: webfactory/ssh-agent@v0.9.0
+        uses: webfactory/ssh-agent@v0.10.0
         with:
         with:
           ssh-private-key: ${{ secrets.BITBUCKET_SECRET }}
           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
     runs-on: ubuntu-latest
     steps:
     steps:
       - name: Lock Threads
       - name: Lock Threads
-        uses: dessant/lock-threads@v5
+        uses: dessant/lock-threads@v6
         with:
         with:
           github-token: ${{ secrets.GITHUB_TOKEN }}
           github-token: ${{ secrets.GITHUB_TOKEN }}
           issue-comment: >
           issue-comment: >

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

@@ -15,7 +15,7 @@ jobs:
         uses: actions/checkout@v4
         uses: actions/checkout@v4
 
 
       - name: Compare Versions
       - 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
       - 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
       - name: Checkout code
         uses: actions/checkout@v6
         uses: actions/checkout@v6
         with:
         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-depth: 0
-          fetch-tags: false
           persist-credentials: false
           persist-credentials: false
 
 
       - name: Install Prettier plugins (for summary formatting)
       - name: Install Prettier plugins (for summary formatting)
@@ -41,7 +41,6 @@ jobs:
         env:
         env:
           # To report GitHub Actions status checks
           # To report GitHub Actions status checks
           GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
           GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-          DEFAULT_BRANCH: ${{ github.ref_name }}
           VALIDATE_BIOME_FORMAT: false
           VALIDATE_BIOME_FORMAT: false
           VALIDATE_BIOME_LINT: false
           VALIDATE_BIOME_LINT: false
           VALIDATE_GITHUB_ACTIONS_ZIZMOR: false
           VALIDATE_GITHUB_ACTIONS_ZIZMOR: false
@@ -49,7 +48,6 @@ jobs:
           VALIDATE_JSON_PRETTIER: false
           VALIDATE_JSON_PRETTIER: false
           VALIDATE_MARKDOWN_PRETTIER: false
           VALIDATE_MARKDOWN_PRETTIER: false
           VALIDATE_NATURAL_LANGUAGE: false
           VALIDATE_NATURAL_LANGUAGE: false
-          VALIDATE_PYTHON_RUFF_FORMAT: false
           VALIDATE_SHELL_SHFMT: false
           VALIDATE_SHELL_SHFMT: false
           VALIDATE_TRIVY: false
           VALIDATE_TRIVY: false
           VALIDATE_YAML_PRETTIER: 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
         uses: actions/checkout@v4
 
 
       - name: Version Check
       - 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
 ## 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
 ## 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
 posting via an official social media account, or acting as an appointed
 representative at an online or offline event.
 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
 ## 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">
 <p align="center">
   <a href="https://linuxgsm.com"><img src="https://i.imgur.com/Eoh1jsi.jpg" alt="LinuxGSM">
   <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://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://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://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>
   <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>