From c76326c74f3b5059de9f05c85cc6ae15517f583b Mon Sep 17 00:00:00 2001 From: Daniel Gibbs Date: Sun, 3 May 2026 15:51:30 +0000 Subject: [PATCH] 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] 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] 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. --- .github/ISSUE_TEMPLATE/bug_report.yml | 81 +- .github/ISSUE_TEMPLATE/config.yml | 3 + .github/ISSUE_TEMPLATE/feature_request.yml | 42 +- .github/ISSUE_TEMPLATE/server_request.yml | 38 +- .../instructions/pr-review.instructions.md | 29 + .github/labeler.yml | 188 +- .github/pull_request_template.md | 28 +- .../details-check-generate-matrix.sh | 8 +- .../serverlist-validate-game-icons.sh | 0 .../serverlist-validate.sh | 16 - .github/scripts/sync-game-labels.sh | 87 + .../{workflows => scripts}/version-check.sh | 0 ...update-copyright-years-in-license-file.yml | 29 - .github/workflows/details-check.yml | 2 +- .github/workflows/git-sync.yml | 2 +- .github/workflows/labeler.yml | 1812 ++++++++++++++++- .github/workflows/lock.yml | 2 +- .github/workflows/potential-duplicates.yml | 27 - .github/workflows/serverlist-validate.yml | 4 +- .github/workflows/sync-game-labels.yml | 28 + .github/workflows/version-check.yml | 2 +- CODE_OF_CONDUCT.md | 263 +-- 22 files changed, 2369 insertions(+), 322 deletions(-) create mode 100644 .github/instructions/pr-review.instructions.md rename .github/{workflows => scripts}/details-check-generate-matrix.sh (89%) rename .github/{workflows => scripts}/serverlist-validate-game-icons.sh (100%) rename .github/{workflows => scripts}/serverlist-validate.sh (60%) create mode 100644 .github/scripts/sync-game-labels.sh rename .github/{workflows => scripts}/version-check.sh (100%) delete mode 100644 .github/workflows/action-update-copyright-years-in-license-file.yml delete mode 100644 .github/workflows/potential-duplicates.yml create mode 100644 .github/workflows/sync-game-labels.yml diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index e9f2d000b..cbf5f2662 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -8,6 +8,51 @@ body: attributes: value: | Thanks for taking the time to fill out this bug report! + - type: dropdown + id: severity + attributes: + label: Severity + description: Triage metadata used for prioritization. + options: + - "severity: low" + - "severity: medium" + - "severity: high" + - "severity: critical" + validations: + required: true + - type: dropdown + id: reproducibility + attributes: + label: Reproducibility + description: Triage metadata used for prioritization. + options: + - "reproducible: always" + - "reproducible: sometimes" + - "reproducible: unable" + validations: + required: true + - type: dropdown + id: regression + attributes: + label: Regression + description: Triage metadata used for prioritization. + options: + - "regression: yes" + - "regression: no" + - "regression: unknown" + validations: + required: true + - type: dropdown + id: affects-latest + attributes: + label: Affects latest release + description: Triage metadata used for prioritization. + options: + - "latest-release: yes" + - "latest-release: no" + - "latest-release: unknown" + validations: + required: true - type: input id: user-story attributes: @@ -16,6 +61,14 @@ body: placeholder: As a [user description], I want [desired action] so that [desired outcome]. validations: required: true + - type: input + id: script-name + attributes: + label: Script name + description: LinuxGSM script name in use. + placeholder: vhserver + validations: + required: true - type: input id: game attributes: @@ -66,6 +119,22 @@ body: - "command: send" validations: required: true + - type: textarea + id: expected-behavior + attributes: + label: Expected behavior + description: What should happen? + placeholder: Describe the expected result. + validations: + required: true + - type: textarea + id: actual-behavior + attributes: + label: Actual behavior + description: What actually happens? + placeholder: Describe the observed result. + validations: + required: true - type: textarea id: further-info attributes: @@ -74,11 +143,19 @@ body: placeholder: Tell us what you see! validations: required: true + - type: checkboxes + id: prechecks + attributes: + label: Pre-checks + description: Confirm standard troubleshooting has been completed. + options: + - label: I ran update and validate before reporting this issue. + required: true - type: textarea id: logs attributes: label: Relevant log output - description: Please copy and paste any relevant log output. This will be automatically formatted into code, so no need for backticks. + description: Include the exact command used and the full related output (debug/details if available). This will be automatically formatted into code. render: shell - type: textarea id: steps @@ -90,3 +167,5 @@ body: 2. Click on '....' 3. Scroll down to '....' 4. See error + validations: + required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index d2113c6ca..f9eb19b0c 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -6,3 +6,6 @@ contact_links: - name: Discord Server about: Join the LinuxGSM Discord community server. Discuss your LinuxGSM setup, get help and advice url: https://linuxgsm.com/discord + - name: Report a security vulnerability + about: Please report security vulnerabilities privately, not in public issues. + url: https://github.com/GameServerManagers/LinuxGSM/security/advisories/new diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index f1a8ccaf6..67d7508e1 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -8,6 +8,17 @@ body: attributes: value: | Thanks for taking the time to fill out this feature request! + - type: dropdown + id: priority + attributes: + label: Priority + description: How important is this feature to you? + options: + - "priority: low" + - "priority: medium" + - "priority: high" + validations: + required: true - type: input id: user-story attributes: @@ -64,12 +75,41 @@ body: - "command: update-lgsm" - "command: wipe" - "command: send" + validations: + required: false + - type: textarea + id: problem-statement + attributes: + label: Problem statement + description: What is painful today, and why is this needed? + placeholder: Describe the current limitation or pain point. + validations: + required: true + - type: dropdown + id: scope-impact + attributes: + label: Scope and impact + description: Which area would this change impact? + options: + - "scope: single game" + - "scope: multiple games" + - "scope: all servers" + - "scope: documentation only" + - "scope: ci/cd or automation" + - "scope: other" validations: required: true - type: textarea id: further-info attributes: label: Further information - description: A clear description of what the feature is and any ideas on how to achieve this. + description: A clear description of the proposed solution and any implementation ideas. validations: required: true + - type: textarea + id: alternatives + attributes: + label: Alternatives considered + description: Describe alternatives or workarounds you considered. + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/server_request.yml b/.github/ISSUE_TEMPLATE/server_request.yml index 31dbc6b81..849bcaa21 100644 --- a/.github/ISSUE_TEMPLATE/server_request.yml +++ b/.github/ISSUE_TEMPLATE/server_request.yml @@ -7,7 +7,7 @@ body: - type: markdown attributes: value: | - Thanks for taking the time to fill out this game server! + Thanks for taking the time to fill out this game server request! - type: input id: game-server attributes: @@ -15,11 +15,19 @@ body: description: What game server would you like to add? validations: required: true + - type: checkboxes + id: dedicated-server + attributes: + label: Dedicated server + description: Confirm this is a dedicated server request and not client hosting. + options: + - label: "Yes, this is a dedicated server (not client hosting)." + required: true - type: checkboxes id: on-linux attributes: label: Linux support - description: Does this game server have Linux support? (not wine) + description: Does this game server have native Linux server support? (not wine) options: - label: "Yes" validations: @@ -38,20 +46,40 @@ body: id: steam-id attributes: label: Steam appid - description: What is the Steam appid of the game server? Use SteamDB to get the appid. (https://steamdb.info). + description: What is the Steam appid of the dedicated server? Required when Steam is Yes. Use SteamDB to get the appid (https://steamdb.info). placeholder: "892970" validations: required: false + - type: textarea + id: official-docs + attributes: + label: Official dedicated server documentation + description: Provide official documentation links for installing/running the dedicated server. + placeholder: | + https://example.com/docs/server-setup + https://example.com/docs/dedicated-server + validations: + required: true + - type: textarea + id: linux-binary-proof + attributes: + label: Linux binary proof + description: Provide evidence that Linux server binaries are available (official docs/download links/version notes). + placeholder: | + https://example.com/downloads/linux-dedicated-server + https://example.com/release-notes/linux-server + validations: + required: true - type: textarea id: guides attributes: label: Guides - description: Links to guides on how to install the game server + description: Links to community or third-party guides on how to install the game server. - type: checkboxes id: terms attributes: label: Code of Conduct - description: By submitting this issue, you agree to follow our [Code of Conduct](https://example.com) + description: By submitting this issue, you agree to follow our [Code of Conduct](https://github.com/GameServerManagers/LinuxGSM/blob/master/CODE_OF_CONDUCT.md) options: - label: I agree to follow this project's Code of Conduct required: true diff --git a/.github/instructions/pr-review.instructions.md b/.github/instructions/pr-review.instructions.md new file mode 100644 index 000000000..9d0d8fef2 --- /dev/null +++ b/.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. diff --git a/.github/labeler.yml b/.github/labeler.yml index 34ffd66f1..631af01bb 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -1,17 +1,17 @@ "command: backup": - - "/(backup)/i" + - "/(command:\\s*backup)/i" "command: console": - - "/(console|tmux)/i" + - "/(command:\\s*console)/i" "command: debug": - "/(command: debug)/i" "command: details": - "/(command: details)/i" "command: fast-dl": - - "/(fast-dl|fastdl)/i" + - "/(command:\\s*fast-?dl)/i" "command: install": - - "/(install)/i" + - "/(command:\\s*install)/i" "command: mods": - - "/(command: mods)/i" + - "/(command:\\s*mods(?:-install|-update|-remove)?)/i" "command: monitor": - "/(command: monitor)/i" "command: post-details": @@ -27,136 +27,108 @@ "command: stop": - "/(command: stop)/i" "command: update-lgsm": - - "/(update-lgsm)/i" + - "/(command:\\s*update-lgsm)/i" "command: update": - - "/(command: update)/i" + - "/(command:\\s*update(?!-lgsm)\\b)/i" "command: validate": - - "/(validate)/i" + - "/(command:\\s*validate)/i" "command: wipe": - - "/(wipe)/i" + - "/(command:\\s*wipe)/i" # Distros "distro: AlmaLinux": - - "/(Alma)/i" + - "/\\bAlmaLinux(?:\\s+\\d+)?\\b/i" "distro: Arch Linux": - - "/(Arch Linux)/i" + - "/\\bArch Linux\\b/i" "distro: CentOS": - - "/(CentOS)/i" + - "/\\bCentOS(?:\\s+\\d+)?\\b/i" "distro: Debian": - - "/(Debian)/i" + - "/\\bDebian(?:\\s+\\d+)?\\b/i" "distro: Fedora": - - "/(Fedora)/i" + - "/\\bFedora(?:\\s+\\d+)?\\b/i" "distro: openSUSE": - - "/(openSUSE|suse)/i" + - "/\\bopenSUSE\\b/i" "distro: Rocky Linux": - - "/(Rocky)/i" + - "/\\bRocky(?:\\s+Linux)?(?:\\s+\\d+)?\\b/i" "distro: Slackware": - - "/(Slackware)/i" + - "/\\bSlackware(?:\\s+\\d+)?\\b/i" "distro: Ubuntu": - - "/(Ubuntu)/i" - -# Games -"game: 7 Days to Die": - - "/(7 Days to Die|sdtd)/i" -"game: Ark: Survival Evolved": - - "/(Ark: Survival Evolved|Ark)/i" -"game: ARMA 3": - - "/(ARMA 3|ARMA3)/i" -"game: Assetto Corsa": - - "/(Assetto Corsa)/i" -"game: Avorion": - - "/(Avorion)/i" -"game: Ballistic Overkill": - - "/(Ballistic Overkill)/i" -"game: BATTALION: Legacy": - - "/(BATTALION: Legacy)/i" -"game: Barotrauma": - - "/(Barotrauma)/i" -"game: Counter-Strike: Global Offensive": - - "/(Counter-Strike: Global Offensive|CS:GO|csgo)/i" -"game: Counter-Strike 2": - - "/(Counter-Strike 2|CS2)/i" -"game: Counter-Strike: Source": - - "/(Counter-Strike: Source|CS:S)/i" -"game: Counter-Strike 1.6": - - "/(Counter-Strike 1.6|Counter Strike 1.6|CS 1.6|cs1.6)/i" -"game: Dayz": - - "/(Dayz)/i" -"game: Don't Starve Together": - - "/(Don't Starve Together|Dont Starve Together|DST)/i" -"game: Eco": - - "/(^Eco$)/i" -"game: Factorio": - - "/(Factorio)/i" -"game: Garry's Mod": - - "/(Garry's Mod|Garrys Mod|GMod)/i" -"game: Insurgency: Sandstorm": - - "/(Insurgency: Sandstorm|Insurgency)/i" -"game: Killing Floor 2": - - "/(Killing Floor 2|KF2)/i" -"game: Left 4 Dead 2": - - "/(Left 4 Dead 2|L4D2)/i" -"game: Minecraft": - - "/(Minecraft)((?!bedrock).)*$/i" -"game: Minecraft Bedrock": - - "/(Bedrock)/i" -"game: Mumble": - - "/(Mumble)/i" -"game: Project Zomboid": - - "/(Project Zomboid|PZ)/i" -"game: Quake 3": - - "/(Quake 3|Q3A|q3)/i" -"game: Rising World": - - "/(Rising World)/i" -"game: Satisfactory": - - "/(Satisfactory)/i" -"game: Squad": - - "/(Squad)/i" -"game: Starbound": - - "/(Starbound)/i" -"game: Stationeers": - - "/(Stationeers)/i" -"game: Teamspeak 3": - - "/(Teamspeak 3|ts3)/i" -"game: Rust": - - "/(Rust)/i" -"game: Unturned": - - "/(Unturned)/i" -"game: Unreal Tournament 99": - - "/(Unreal Tournament 99|ut99)/i" -"game: Unreal Tournament 2004": - - "/(Unreal Tournament 2004|ut2k4)/i" -"game: Unreal Tournament 3": - - "/(Unreal Tournament 3|ut3)/i" -"game: Valheim": - - "/(Valheim)/i" + - "/\\bUbuntu(?:\\s+\\d+(?:\\.\\d+)?)?\\b/i" # Info "info: alerts": - - "/(alert)/i" + - "/(alert_(discord|email|gotify|ifttt|ntfy|pushbullet|pushover|rocketchat|slack|telegram)|command:\\s*test-alert)/i" "info: dependency": - - "/(dependency|deps)/i" + - "/\\b(dependency|dependencies|deps)\\b/i" "info: docker": - - "/(docker)/i" + - "/\\bdocker\\b/i" "info: docs": - - "/(documentation|^docs$)/i" + - "/(^docs$)/i" "info: email": - - "/(postfix|sendmail|exim|smtp)/i" + - "/\\b(postfix|sendmail|exim|smtp)\\b/i" "info: query": - - "/(gamedig|gsquery)/i" + - "/\\b(gamedig|gsquery)\\b/i" "info: steamcmd": - - "/(steamcmd)/i" + - "/\\bsteamcmd\\b/i" "info: systemd": - - "/(systemd)/i" + - "/\\bsystemd\\b/i" "info: tmux": - - "/(tmux)/i" + - "/(tmuxception|check_tmuxception)/i" "info: website": - - "/(website)/i" + - "/\\bwebsite\\b/i" # Type "type: game server request": - - "/(Server Request)/i" + - "/(^\\[server request\\]|^server request:|type:\\s*game server request)/im" "type: bug": - - "/(bug)/i" -"type: feature request": - - "/(feature)/i" + - "/(\\[bug\\]|bug report|type: bug)/i" +"type: bugfix": + - "/(^fix(\\(.+\\))?:|\\[x\\] Bug fix)/im" +"type: feature": + - "/(feature request|new feature|^feat(\\(.+\\))?:|\\[x\\] New feature)/im" +"type: docs": + - "/(^docs(\\(.+\\))?:|\\[x\\] Comment update)/im" +"type: refactor": + - "/(^refactor(\\(.+\\))?:|\\[x\\] Refactor)/im" +"type: chore": + - "/(^chore(\\(.+\\))?:|^ci(\\(.+\\))?:)/im" + +# Severity (bug reports) +"severity: low": + - "/(severity: low)/i" +"severity: medium": + - "/(severity: medium)/i" +"severity: high": + - "/(severity: high)/i" +"severity: critical": + - "/(severity: critical)/i" + +# Reproducibility (bug reports) +"reproducible: always": + - "/(reproducible: always)/i" +"reproducible: sometimes": + - "/(reproducible: sometimes)/i" +"reproducible: unable": + - "/(reproducible: unable)/i" + +# Regression (bug reports) +"regression: yes": + - "/(regression: yes)/i" + +# Priority (feature requests) +"priority: low": + - "/(priority: low)/i" +"priority: medium": + - "/(priority: medium)/i" +"priority: high": + - "/(priority: high)/i" + +# Scope (feature requests) +"scope: single game": + - "/(scope: single game)/i" +"scope: multiple games": + - "/(scope: multiple games)/i" +"scope: all servers": + - "/(scope: all servers)/i" +"scope: documentation": + - "/(scope: documentation|scope: documentation only)/i" diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 9f6864e57..d95b4278e 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -12,18 +12,40 @@ Fixes #[issue] - [ ] Refactor (restructures existing code). - [ ] Comment update (typo, spelling, explanation, examples, etc). +## Testing + +Please list the exact validation you performed and the outcome. + +- Commands/tests run: +- Result: +- Environment used (distro/version): + +## Risk and rollback + +- Risk level: low / medium / high +- Rollback plan: + +## Breaking changes + +- [ ] No breaking changes. +- [ ] Breaking changes included (describe below). + +## Documentation impact + +- [ ] No documentation update required. +- [ ] User documentation update required. +- [ ] Developer documentation update required. + ## Checklist PR will not be merged until all steps are complete. - [ ] This pull request links to an issue. -- [ ] This pull request uses the `develop` branch as its base. +- [ ] This pull request uses the develop branch as its base. - [ ] This pull request subject follows the Conventional Commits standard. - [ ] This code follows the style guidelines of this project. - [ ] I have performed a self-review of my code. -- [ ] I have checked that this code is commented where required. - [ ] I have provided a detailed enough description of this PR. -- [ ] I have checked if documentation needs updating. ## Documentation diff --git a/.github/workflows/details-check-generate-matrix.sh b/.github/scripts/details-check-generate-matrix.sh similarity index 89% rename from .github/workflows/details-check-generate-matrix.sh rename to .github/scripts/details-check-generate-matrix.sh index 2c0803519..3d56674c2 100755 --- a/.github/workflows/details-check-generate-matrix.sh +++ b/.github/scripts/details-check-generate-matrix.sh @@ -15,10 +15,10 @@ while read -r line; do distro=$(echo "$line" | awk -F, '{ print $4 }') export distro { - echo -n "{"; - echo -n "\"shortname\":"; - echo -n "\"${shortname}\""; - echo -n "},"; + echo -n "{" + echo -n "\"shortname\":" + echo -n "\"${shortname}\"" + echo -n "}," } >> "shortnamearray.json" done < <(tail -n +2 serverlist.csv) sed -i '$ s/.$//' "shortnamearray.json" diff --git a/.github/workflows/serverlist-validate-game-icons.sh b/.github/scripts/serverlist-validate-game-icons.sh similarity index 100% rename from .github/workflows/serverlist-validate-game-icons.sh rename to .github/scripts/serverlist-validate-game-icons.sh diff --git a/.github/workflows/serverlist-validate.sh b/.github/scripts/serverlist-validate.sh similarity index 60% rename from .github/workflows/serverlist-validate.sh rename to .github/scripts/serverlist-validate.sh index 3d83d89da..9f3826cfa 100755 --- a/.github/workflows/serverlist-validate.sh +++ b/.github/scripts/serverlist-validate.sh @@ -22,20 +22,4 @@ for csv in "${csvlist[@]}"; do fi done -# Compare all game servers listed in serverlist.csv to $shortname-icon.png files in ${datadir}/gameicons -# if the game server is listed in serverlist.csv then it will have a $shortname-icon.png file - -# loop though shortname in serverlist.csv -echo "" -echo "Checking that all the game servers listed in serverlist.csv have a shortname-icon.png file" -for shortname in $(tail -n +2 serverlist.csv | cut -d ',' -f1); do - # check if $shortname-icon.png exists - if [ ! -f "gameicons/${shortname}-icon.png" ]; then - echo "ERROR: gameicons/${shortname}-icon.png does not exist" - exitcode=1 - else - echo "OK: gameicons/${shortname}-icon.png exists" - fi -done - exit "${exitcode}" diff --git a/.github/scripts/sync-game-labels.sh b/.github/scripts/sync-game-labels.sh new file mode 100644 index 000000000..2914f3d80 --- /dev/null +++ b/.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: " 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}" diff --git a/.github/workflows/version-check.sh b/.github/scripts/version-check.sh similarity index 100% rename from .github/workflows/version-check.sh rename to .github/scripts/version-check.sh diff --git a/.github/workflows/action-update-copyright-years-in-license-file.yml b/.github/workflows/action-update-copyright-years-in-license-file.yml deleted file mode 100644 index ab1549f0e..000000000 --- a/.github/workflows/action-update-copyright-years-in-license-file.yml +++ /dev/null @@ -1,29 +0,0 @@ -name: Update copyright year(s) in license file - -on: - workflow_dispatch: - schedule: - - cron: "0 3 1 1 *" # 03:00 AM on January 1 - -permissions: - contents: write - -jobs: - update-license-year: - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v6 - with: - fetch-depth: 0 - persist-credentials: false - - name: Action Update License Year - uses: FantasticFiasco/action-update-license-year@v3 - with: - token: ${{ secrets.GITHUB_TOKEN }} - path: LICENSE.md - - name: Merge pull request - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - gh pr merge --merge --delete-branch diff --git a/.github/workflows/details-check.yml b/.github/workflows/details-check.yml index c58aa0ae4..ccccbbdaa 100644 --- a/.github/workflows/details-check.yml +++ b/.github/workflows/details-check.yml @@ -24,7 +24,7 @@ jobs: uses: actions/checkout@v4 - name: Generate matrix with generate-matrix.sh - run: chmod +x .github/workflows/details-check-generate-matrix.sh; .github/workflows/details-check-generate-matrix.sh + run: .github/scripts/details-check-generate-matrix.sh - name: Set Matrix id: set-matrix diff --git a/.github/workflows/git-sync.yml b/.github/workflows/git-sync.yml index 42c660d9b..986346c49 100644 --- a/.github/workflows/git-sync.yml +++ b/.github/workflows/git-sync.yml @@ -15,7 +15,7 @@ jobs: runs-on: ubuntu-latest steps: - name: SSH Agent - uses: webfactory/ssh-agent@v0.9.0 + uses: webfactory/ssh-agent@v0.10.0 with: ssh-private-key: ${{ secrets.BITBUCKET_SECRET }} diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml index 4a946a861..b04d13d9c 100644 --- a/.github/workflows/labeler.yml +++ b/.github/workflows/labeler.yml @@ -1,17 +1,67 @@ -name: Issue Labeler +name: Unified Labeling on: + workflow_dispatch: + inputs: + issue_state: + description: Issue state to backfill + required: true + default: all + type: choice + options: + - all + - open + - closed + limit: + description: Max issues to process (0 = all) + required: true + default: "0" + type: string + ai_game_fallback: + description: Use AI only when deterministic game mapping finds no game + required: true + default: "false" + type: choice + options: + - "false" + - "true" issues: types: - opened - edited + - reopened + - labeled + - unlabeled + - assigned + - unassigned + - milestoned + - demilestoned + - transferred + - pinned + - unpinned + issue_comment: + types: + - created + - edited + - deleted + pull_request: + types: + - opened + - edited + - synchronize + - reopened permissions: issues: write + pull-requests: write contents: read + models: read + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" jobs: - issue-labeler: - if: github.repository_owner == 'GameServerManagers' + 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 @@ -21,9 +71,1763 @@ jobs: configuration-path: .github/labeler.yml enable-versioned-regex: 0 include-title: 1 + sync-labels: 0 + + issue-ai-maintenance: + if: github.repository_owner == 'GameServerManagers' && (github.event_name == 'issues' || github.event_name == 'issue_comment') + runs-on: ubuntu-latest + steps: + - name: Reconcile issue labels and AI triage + uses: actions/github-script@v9 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + script: | + const owner = context.repo.owner; + const repo = context.repo.repo; + const eventName = context.eventName; + const action = context.payload.action; + const issueNumber = context.payload.issue?.number; + const AI_MARKER = ''; + + if (!issueNumber) { + console.log('No issue number found in payload.'); + return; + } + + // Avoid bot-to-bot relabel loops on label events. + if ( + eventName === 'issues' && + ['labeled', 'unlabeled'].includes(action) && + context.actor === 'github-actions[bot]' + ) { + console.log('Skipping self-triggered label event.'); + return; + } + + const issueResp = await github.rest.issues.get({ + owner, + repo, + issue_number: issueNumber, + }); + const issue = issueResp.data; + const title = issue.title || ''; + const body = issue.body || ''; + const existingLabels = new Set((issue.labels || []).map((l) => l.name).filter(Boolean)); + + function parseTriageResponse(raw) { + const input = (raw || '').trim(); + if (!input) return {}; + + const candidates = [input]; + const fenced = input.match(/```(?:json)?\s*([\s\S]*?)```/i); + if (fenced?.[1]) candidates.push(fenced[1].trim()); + + const firstBrace = input.indexOf('{'); + const lastBrace = input.lastIndexOf('}'); + if (firstBrace !== -1 && lastBrace > firstBrace) { + candidates.push(input.slice(firstBrace, lastBrace + 1)); + } + + for (const candidate of candidates) { + try { + return JSON.parse(candidate); + } catch (_err) { + // Continue trying fallbacks. + } + } + + return {}; + } + + function extractSection(sectionName) { + const escaped = sectionName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const re = new RegExp(`### ${escaped}\\n\\n([\\s\\S]*?)(\\n### |$)`, 'i'); + return (body.match(re)?.[1] || '').trim(); + } + + function normalizeName(value) { + return (value || '') + .toLowerCase() + .replace(/[’'`]/g, '') + .replace(/[^a-z0-9]+/g, ' ') + .trim(); + } + + function parseGameCandidates(gameField) { + if (!gameField || /^_?no response_?$/i.test(gameField)) { + return []; + } + return gameField + .replace(/\(.*?\)/g, ' ') + .split(/\n|,|\s+&\s+|\s+and\s+|\//i) + .map((v) => v.trim()) + .filter(Boolean); + } + + function findGamesFromText(text, gameAliasToLabel, gameAliasToScript) { + const labels = new Set(); + const scripts = new Set(); + const normalizedText = normalizeName(text); + if (!normalizedText) return { labels, scripts }; + + const escapeRegex = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const aliases = []; + for (const [alias, label] of gameAliasToLabel.entries()) { + if (alias.length < 3) continue; + aliases.push({ alias, label, script: gameAliasToScript.get(alias) || null }); + } + + // Prefer longer aliases first so "killing floor 2" does not also match "killing floor". + aliases.sort((a, b) => b.alias.length - a.alias.length); + + const usedRanges = []; + const isOverlapping = (start, end) => + usedRanges.some((range) => start < range.end && end > range.start); + + for (const entry of aliases) { + const pattern = new RegExp(`\\b${escapeRegex(entry.alias).replace(/\\ /g, '\\s+')}\\b`, 'g'); + let match; + while ((match = pattern.exec(normalizedText)) !== null) { + const start = match.index; + const end = start + match[0].length; + if (isOverlapping(start, end)) continue; + + labels.add(entry.label); + if (entry.script) scripts.add(entry.script); + usedRanges.push({ start, end }); + } + } + + return { labels, scripts }; + } + + function hasAliasHitForLabel(text, targetLabel, gameAliasToLabel) { + const normalizedText = normalizeName(text); + if (!normalizedText || !targetLabel) return false; + + const paddedText = ` ${normalizedText} `; + for (const [alias, label] of gameAliasToLabel.entries()) { + if (label !== targetLabel) continue; + if (alias.length < 3) continue; + if (paddedText.includes(` ${alias} `)) return true; + + // Allow obvious joined-word variants for multi-token aliases + // (e.g., "counter strike 1 6" matching "counterstrike 1.6"). + const aliasTokens = alias.split(/\s+/).filter(Boolean); + if (aliasTokens.length > 1) { + const escapedTokens = aliasTokens.map((token) => + token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + ); + const flexibleAliasPattern = new RegExp(`\\b${escapedTokens.join('\\s*')}\\b`); + if (flexibleAliasPattern.test(normalizedText)) return true; + } + } + + return false; + } + + function parseServerlistCsv(csvText) { + const rows = []; + const lines = (csvText || '').split(/\r?\n/); + for (let i = 1; i < lines.length; i += 1) { + const line = lines[i]?.trim(); + if (!line) continue; + const parts = line.split(','); + if (parts.length < 3) continue; + rows.push({ + shortname: parts[0].trim(), + gameservername: parts[1].trim(), + gamename: parts[2].trim(), + }); + } + return rows; + } + function inferTypeFromTitle(issueTitle) { + if (/^\[bug\]/i.test(issueTitle)) return 'type: bug'; + if (/\bserver\s+request\b/i.test(issueTitle)) return 'type: game server request'; + const hasBracketPrefix = /^\[[^\]]+\]/.test(issueTitle || ''); + const isServerCreation = + /\bserver\s+creation\b/i.test(issueTitle) || + (hasBracketPrefix && /\bcreation\b/i.test(issueTitle)); + const isServerSupportRequest = + /\bserver\s+support\b/i.test(issueTitle) || + (/\bsupport\s+for\b/i.test(issueTitle) && /\bserver\b/i.test(issueTitle)); + if (isServerCreation || isServerSupportRequest) return 'type: game server request'; + if (/^\[feature\]/i.test(issueTitle)) return 'type: feature'; + if (/^\[server request\]/i.test(issueTitle)) return 'type: game server request'; + if (/^\[docs?\]/i.test(issueTitle)) return 'type: docs'; + return null; + } + + function inferDesiredType(issueTitle, labelNames) { + const titleType = inferTypeFromTitle(issueTitle); + if (titleType) return titleType; + + // Prefer server requests over generic feature when both labels exist. + if (labelNames.has('type: game server request')) return 'type: game server request'; + + for (const label of [ + 'type: bug', + 'type: feature', + 'type: game server request', + 'type: docs', + ]) { + if (labelNames.has(label)) return label; + } + + return null; + } + + function inferIssueTypeNameFromDesiredType(typeLabel) { + if (typeLabel === 'type: bug') return 'Bug'; + if (typeLabel === 'type: feature') return 'Feature'; + if (typeLabel === 'type: game server request') return 'Server Request'; + if (typeLabel === 'type: docs') return 'Task'; + return null; + } + + function parseCommandSelections(sectionValue) { + const selected = new Set(); + const re = /command:\s*([a-z-]+)/gi; + let m; + while ((m = re.exec(sectionValue || '')) !== null) { + let value = m[1].toLowerCase(); + if (value.startsWith('mods-')) value = 'mods'; + if (value === 'auto-update') value = 'update'; + selected.add(`command: ${value}`); + } + return selected; + } + + function parseDistroSelections(sectionValue) { + const text = sectionValue || ''; + const selected = new Set(); + if (/\bUbuntu\b/i.test(text)) selected.add('distro: Ubuntu'); + if (/\bDebian\b/i.test(text)) selected.add('distro: Debian'); + if (/\bAlmaLinux\b/i.test(text)) selected.add('distro: AlmaLinux'); + if (/\bRocky\b/i.test(text)) selected.add('distro: Rocky Linux'); + if (/\bCentOS\b/i.test(text)) selected.add('distro: CentOS'); + if (/\bFedora\b/i.test(text)) selected.add('distro: Fedora'); + if (/\bopenSUSE\b/i.test(text)) selected.add('distro: openSUSE'); + if (/\bArch Linux\b/i.test(text)) selected.add('distro: Arch Linux'); + if (/\bSlackware\b/i.test(text)) selected.add('distro: Slackware'); + return selected; + } + + const repoLabels = await github.paginate(github.rest.issues.listLabelsForRepo, { + owner, + repo, + per_page: 100, + }); + + const gameLabelByNormalized = new Map(); + for (const label of repoLabels) { + if (!label.name.startsWith('game: ')) continue; + gameLabelByNormalized.set(normalizeName(label.name.slice(6)), label.name); + } + const existingEngineLabels = new Set( + repoLabels.map((label) => label.name).filter((name) => name.startsWith('engine: ')) + ); + + const gameAliasToLabel = new Map(); + const gameAliasToScript = new Map(); + const engineByScript = new Map(); + for (const [normalizedGameName, label] of gameLabelByNormalized.entries()) { + gameAliasToLabel.set(normalizedGameName, label); + } + + try { + const serverlistContent = await github.rest.repos.getContent({ + owner, + repo, + path: 'lgsm/data/serverlist.csv', + }); + const encoded = serverlistContent.data?.content || ''; + const csvText = Buffer.from(encoded, 'base64').toString('utf8'); + const serverRows = parseServerlistCsv(csvText); + + for (const row of serverRows) { + const canonicalLabel = gameLabelByNormalized.get(normalizeName(row.gamename)); + if (!canonicalLabel) continue; + + for (const alias of [row.shortname, row.gameservername, row.gamename]) { + const key = normalizeName(alias); + if (!key) continue; + gameAliasToLabel.set(key, canonicalLabel); + gameAliasToScript.set(key, row.gameservername); + } + } + } catch (err) { + console.log(`Could not load serverlist aliases: ${err.message}`); + } + + async function ensureEngineLabel(engineLabel) { + if (existingEngineLabels.has(engineLabel)) return; + try { + await github.rest.issues.createLabel({ + owner, + repo, + name: engineLabel, + color: '000000', + description: `Issues related to ${engineLabel.slice(8)} engine`, + }); + existingEngineLabels.add(engineLabel); + } catch (err) { + if (err.status === 422) { + existingEngineLabels.add(engineLabel); + return; + } + console.log(`Could not create engine label "${engineLabel}": ${err.message}`); + } + } + + async function getEngineForScript(scriptName) { + if (!scriptName) return null; + if (engineByScript.has(scriptName)) { + return engineByScript.get(scriptName); + } + try { + const cfgContent = await github.rest.repos.getContent({ + owner, + repo, + path: `lgsm/config-default/config-lgsm/${scriptName}/_default.cfg`, + }); + const encoded = cfgContent.data?.content || ''; + const cfgText = Buffer.from(encoded, 'base64').toString('utf8'); + const engine = cfgText.match(/^engine="([^"]+)"/m)?.[1] || null; + engineByScript.set(scriptName, engine); + return engine; + } catch (err) { + console.log(`Could not detect engine for ${scriptName}: ${err.message}`); + engineByScript.set(scriptName, null); + return null; + } + } + const labelsToAdd = new Set(); + const labelsToRemove = new Set(); + + // Deterministic reconciliation on every interaction. + const desiredType = inferDesiredType(title, existingLabels); + if (desiredType) { + labelsToAdd.add(desiredType); + for (const label of existingLabels) { + if (label.startsWith('type: ') && label !== desiredType) { + labelsToRemove.add(label); + } + } + + const desiredIssueTypeName = inferIssueTypeNameFromDesiredType(desiredType); + if (desiredIssueTypeName) { + try { + const issueTypeData = await github.graphql( + `query($owner:String!,$repo:String!,$number:Int!){ + repository(owner:$owner,name:$repo){ + issueTypes(first:20){ nodes { id name } } + issue(number:$number){ id issueType { id name } } + } + }`, + { owner, repo, number: issueNumber } + ); + + const issueNode = issueTypeData.repository?.issue; + const issueTypes = issueTypeData.repository?.issueTypes?.nodes || []; + const desiredIssueType = issueTypes.find((t) => t.name === desiredIssueTypeName); + + if (issueNode?.id && desiredIssueType?.id && issueNode.issueType?.id !== desiredIssueType.id) { + await github.graphql( + `mutation($id:ID!,$issueTypeId:ID!){ + updateIssue(input:{id:$id,issueTypeId:$issueTypeId}){ + issue { id number issueType { id name } } + } + }`, + { id: issueNode.id, issueTypeId: desiredIssueType.id } + ); + } + } catch (err) { + console.log(`Could not sync Issue Type: ${err.message}`); + } + } + } + + const commandSection = extractSection('Command'); + const desiredCommands = parseCommandSelections(commandSection); + if (desiredCommands.size > 0) { + for (const label of desiredCommands) labelsToAdd.add(label); + for (const label of existingLabels) { + if (label.startsWith('command: ') && !desiredCommands.has(label)) { + labelsToRemove.add(label); + } + } + } + + const distroSection = extractSection('Linux distro'); + const desiredDistros = parseDistroSelections(distroSection); + if (desiredDistros.size > 0) { + for (const label of desiredDistros) labelsToAdd.add(label); + for (const label of existingLabels) { + if (label.startsWith('distro: ') && !desiredDistros.has(label)) { + labelsToRemove.add(label); + } + } + } + + const tmuxContextPattern = /\b(tmuxception|check_tmuxception)\b/i; + if (existingLabels.has('info: tmux') && !tmuxContextPattern.test(`${title}\n${body}`)) { + labelsToRemove.add('info: tmux'); + } + + const desiredGames = new Set(); + const desiredServerScripts = new Set(); + const gameField = extractSection('Game'); + const gameCandidates = parseGameCandidates(gameField); + const hasStructuredGameSelection = gameCandidates.length > 0; + for (const candidate of gameCandidates) { + const normalizedCandidate = normalizeName(candidate); + const mapped = gameAliasToLabel.get(normalizedCandidate) || gameLabelByNormalized.get(normalizedCandidate); + if (mapped) desiredGames.add(mapped); + const mappedScript = gameAliasToScript.get(normalizedCandidate); + if (mappedScript) desiredServerScripts.add(mappedScript); + } + + // Legacy issues often have no form section; fall back to deterministic text matching. + if (desiredGames.size === 0) { + const fromText = findGamesFromText(`${title}\n${body}`, gameAliasToLabel, gameAliasToScript); + for (const label of fromText.labels) desiredGames.add(label); + for (const scriptName of fromText.scripts) desiredServerScripts.add(scriptName); + } + + // AI advisory is only needed on issue opened/edited. + let triage = {}; + let ranAi = false; + const shouldRunAi = eventName === 'issues' && ['opened', 'edited'].includes(action); + if (shouldRunAi) { + ranAi = true; + + const isShortBody = body.trim().length < 80; + if (isShortBody) { + labelsToAdd.add('needs: more info'); + } else { + try { + const res = await fetch( + `https://models.github.ai/orgs/${owner}/inference/chat/completions`, + { + method: 'POST', + headers: { + Accept: 'application/vnd.github+json', + Authorization: `Bearer ${process.env.GITHUB_TOKEN}`, + 'X-GitHub-Api-Version': '2026-03-10', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + model: 'openai/gpt-4.1-mini', + temperature: 0.1, + max_tokens: 400, + messages: [ + { + role: 'system', + content: + 'You are a triage assistant for LinuxGSM, an open-source Linux game server manager. ' + + 'Return only JSON. Analyze issue quality, suggest missing info, detect game names, and suggest contextual labels ' + + 'only when highly certain. Never set type: docs just because docs links are mentioned.', + }, + { + role: 'user', + content: + `Title: ${title}\n\nBody:\n${body.slice(0, 3000)}\n\n` + + 'Return JSON schema:\n' + + '{\n' + + ' "quality": "good" | "ok" | "poor",\n' + + ' "missing_info": ["list of specific missing fields"],\n' + + ' "detected_game": "canonical game name if one is mentioned, or null",\n' + + ' "game_confidence": "high" | "medium" | "low" | null,\n' + + ' "context_labels": ["labels"],\n' + + ' "context_confidence": "high" | "medium" | "low" | null,\n' + + ' "game_note": "string",\n' + + ' "comment": "string"\n' + + '}', + }, + ], + }), + } + ); + + if (res.ok) { + const data = await res.json(); + const raw = data.choices?.[0]?.message?.content || '{}'; + triage = parseTriageResponse(raw); + } else { + console.log(`GitHub Models returned ${res.status} - skipping AI triage.`); + } + } catch (err) { + console.log('AI triage skipped:', err.message); + } + } + } + + const allowedContextLabels = new Set([ + 'type: docs', + 'info: docs', + 'info: dependency', + 'info: docker', + 'info: email', + 'info: query', + 'info: steamcmd', + 'info: systemd', + 'info: website', + 'info: alerts', + ]); + + const isPoor = triage?.quality === 'poor'; + const missing = Array.isArray(triage?.missing_info) ? triage.missing_info : []; + const hasIssues = isPoor || missing.length > 0; + + // Fallback to AI-detected game only if explicit form mapping was unavailable. + const detectedGame = triage?.detected_game; + const gameConfidence = triage?.game_confidence; + if (desiredGames.size === 0 && detectedGame && gameConfidence === 'high') { + const normalizedDetectedGame = normalizeName(detectedGame); + const mapped = gameLabelByNormalized.get(normalizedDetectedGame); + if (mapped) { + desiredGames.add(mapped); + } + const mappedScript = gameAliasToScript.get(normalizedDetectedGame); + if (mappedScript) desiredServerScripts.add(mappedScript); + } + + // Resolve server scripts from canonical game labels when only labels were mapped. + for (const gameLabel of desiredGames) { + const gameName = gameLabel.slice(6); + const mappedScript = gameAliasToScript.get(normalizeName(gameName)); + if (mappedScript) desiredServerScripts.add(mappedScript); + } + + const desiredEngineLabels = new Set(); + for (const scriptName of desiredServerScripts) { + const engine = await getEngineForScript(scriptName); + if (!engine) continue; + const engineLabel = `engine: ${engine}`; + await ensureEngineLabel(engineLabel); + desiredEngineLabels.add(engineLabel); + } + + if (desiredEngineLabels.size > 0) { + for (const label of desiredEngineLabels) labelsToAdd.add(label); + for (const label of existingLabels) { + if (label.startsWith('engine: ') && !desiredEngineLabels.has(label)) { + labelsToRemove.add(label); + } + } + } + + if (desiredGames.size > 0) { + for (const label of desiredGames) labelsToAdd.add(label); + if (hasStructuredGameSelection) { + for (const label of existingLabels) { + if (label.startsWith('game: ') && !desiredGames.has(label)) { + labelsToRemove.add(label); + } + } + } else { + // For legacy issues without structured game selection, only prune stale + // broader labels when a more specific inferred game label exists. + const desiredGameNamesNormalized = new Set( + [...desiredGames].map((label) => normalizeName(label.slice(6))) + ); + for (const label of existingLabels) { + if (!label.startsWith('game: ') || desiredGames.has(label)) continue; + const existingGameName = normalizeName(label.slice(6)); + const isBroaderOverlap = [...desiredGameNamesNormalized].some( + (desiredName) => desiredName !== existingGameName && desiredName.startsWith(`${existingGameName} `) + ); + if (isBroaderOverlap) { + labelsToRemove.add(label); + } + } + } + } + + if (triage?.context_confidence === 'high') { + const contextLabels = Array.isArray(triage.context_labels) ? triage.context_labels : []; + for (const label of contextLabels) { + if (!allowedContextLabels.has(label)) continue; + if ( + label === 'type: docs' && + (existingLabels.has('type: game server request') || desiredType === 'type: game server request') + ) { + continue; + } + labelsToAdd.add(label); + } + } + + if (ranAi && hasIssues) { + labelsToAdd.add('needs: more info'); + } + + if (ranAi && !hasIssues && existingLabels.has('needs: more info')) { + labelsToRemove.add('needs: more info'); + } + + // Avoid pointless API calls. + const finalAdds = [...labelsToAdd].filter((label) => !existingLabels.has(label)); + const finalRemoves = [...labelsToRemove].filter((label) => existingLabels.has(label)); + + for (const label of finalRemoves) { + try { + await github.rest.issues.removeLabel({ + owner, + repo, + issue_number: issueNumber, + name: label, + }); + console.log(`Removed label: ${label}`); + } catch (err) { + console.log(`Could not remove label "${label}": ${err.message}`); + } + } + + for (const label of finalAdds) { + try { + await github.rest.issues.addLabels({ + owner, + repo, + issue_number: issueNumber, + labels: [label], + }); + console.log(`Added label: ${label}`); + } catch (err) { + console.log(`Could not add label "${label}": ${err.message}`); + } + } + + // Post AI comment only for opened/edited issues when useful. + if (ranAi) { + const gameNote = triage?.game_note || ''; + const reporterComment = triage?.comment || ''; + + if (hasIssues || gameNote) { + const missingBlock = missing.length > 0 + ? `\n\n**Missing information:**\n${missing.map((m) => `- ${m}`).join('\n')}` + : ''; + const gameBlock = gameNote ? `\n\n**Game name note:** ${gameNote}` : ''; + + const triageCommentBody = + `${AI_MARKER}\n` + + `Thanks for opening this issue!\n\n` + + `${reporterComment}` + + `${missingBlock}` + + `${gameBlock}\n\n` + + `_This note was generated automatically by AI triage and may not be perfect. ` + + `A maintainer will review shortly._`; + + try { + const comments = await github.paginate(github.rest.issues.listComments, { + owner, + repo, + issue_number: issueNumber, + per_page: 100, + }); + + const existingAiComment = [...comments].reverse().find( + (comment) => comment.user?.type === 'Bot' && comment.body?.includes(AI_MARKER) + ); + + if (existingAiComment) { + await github.rest.issues.updateComment({ + owner, + repo, + comment_id: existingAiComment.id, + body: triageCommentBody, + }); + } else { + await github.rest.issues.createComment({ + owner, + repo, + issue_number: issueNumber, + body: triageCommentBody, + }); + } + } catch (err) { + console.log('Could not post comment:', err.message); + } + } + } + + issue-potential-duplicates: + if: github.repository_owner == 'GameServerManagers' && github.event_name == 'issues' && (github.event.action == 'opened' || github.event.action == 'edited' || github.event.action == 'reopened') + runs-on: ubuntu-latest + steps: + - name: Detect potential duplicates + uses: actions/github-script@v9 + with: + script: | + const owner = context.repo.owner; + const repo = context.repo.repo; + const issueNumber = context.payload.issue?.number; + const DUPLICATE_LABEL = 'potential-duplicate'; + const DUPLICATE_MARKER = ''; + const MAX_CANDIDATES = 5; + const THRESHOLD = 0.45; + + if (!issueNumber) { + console.log('No issue number found in payload.'); + return; + } + + const issueResp = await github.rest.issues.get({ + owner, + repo, + issue_number: issueNumber, + }); + + const issue = issueResp.data; + if (issue.pull_request) { + console.log('Skipping pull request payload.'); + return; + } + + function normalizeText(value) { + return (value || '') + .toLowerCase() + .replace(/[`'"’]/g, '') + .replace(/[^a-z0-9\s]/g, ' ') + .replace(/\s+/g, ' ') + .trim(); + } + + function tokenize(value) { + const stopwords = new Set([ + 'a', 'an', 'and', 'are', 'as', 'at', 'be', 'by', 'for', 'from', 'how', 'i', 'in', 'is', 'it', 'its', + 'of', 'on', 'or', 'that', 'the', 'this', 'to', 'when', 'with', 'wont', 'cannot', 'cant', 'fails', 'fail', + 'issue', 'bug', 'request', 'server', 'command', 'linuxgsm' + ]); + return new Set( + normalizeText(value) + .split(' ') + .filter((token) => token.length > 2 && !stopwords.has(token)) + ); + } + + function jaccard(aSet, bSet) { + if (aSet.size === 0 || bSet.size === 0) return 0; + let intersection = 0; + for (const v of aSet) { + if (bSet.has(v)) intersection += 1; + } + const union = new Set([...aSet, ...bSet]).size; + return union === 0 ? 0 : intersection / union; + } + + function bodySignature(text) { + return normalizeText(text).split(' ').slice(0, 200).join(' '); + } + + const currentTitleTokens = tokenize(issue.title || ''); + const currentBodyTokens = tokenize(bodySignature(issue.body || '')); + + const recentIssues = await github.paginate(github.rest.issues.listForRepo, { + owner, + repo, + state: 'all', + sort: 'updated', + direction: 'desc', + per_page: 100, + }); + + const ranked = []; + for (const candidate of recentIssues) { + if (!candidate || candidate.number === issueNumber || candidate.pull_request) continue; + + const candidateTitleTokens = tokenize(candidate.title || ''); + const candidateBodyTokens = tokenize(bodySignature(candidate.body || '')); + const titleScore = jaccard(currentTitleTokens, candidateTitleTokens); + const bodyScore = jaccard(currentBodyTokens, candidateBodyTokens); + const score = titleScore * 0.8 + bodyScore * 0.2; + + if (score < THRESHOLD) continue; + + ranked.push({ + number: candidate.number, + title: candidate.title, + state: candidate.state, + html_url: candidate.html_url, + score, + }); + } + + ranked.sort((a, b) => b.score - a.score); + const topMatches = ranked.slice(0, MAX_CANDIDATES); + + async function ensurePotentialDuplicateLabel() { + try { + await github.rest.issues.getLabel({ owner, repo, name: DUPLICATE_LABEL }); + } catch (err) { + if (err.status !== 404) throw err; + await github.rest.issues.createLabel({ + owner, + repo, + name: DUPLICATE_LABEL, + color: 'd4c5f9', + description: 'Potentially duplicates another existing issue', + }); + } + } + + const existingLabelNames = new Set((issue.labels || []).map((l) => l.name)); + + const comments = await github.paginate(github.rest.issues.listComments, { + owner, + repo, + issue_number: issueNumber, + per_page: 100, + }); + const existingComment = [...comments] + .reverse() + .find((comment) => comment.user?.type === 'Bot' && comment.body?.includes(DUPLICATE_MARKER)); + + if (topMatches.length === 0) { + if (existingLabelNames.has(DUPLICATE_LABEL)) { + try { + await github.rest.issues.removeLabel({ + owner, + repo, + issue_number: issueNumber, + name: DUPLICATE_LABEL, + }); + } catch (err) { + console.log(`Could not remove ${DUPLICATE_LABEL}: ${err.message}`); + } + } + + if (existingComment) { + try { + await github.rest.issues.updateComment({ + owner, + repo, + comment_id: existingComment.id, + body: + `${DUPLICATE_MARKER}\n` + + `Potential duplicate scan did not find strong matches at this time.\n\n` + + `_This note is maintained automatically._`, + }); + } catch (err) { + console.log(`Could not update duplicate comment: ${err.message}`); + } + } + + return; + } + + await ensurePotentialDuplicateLabel(); + + if (!existingLabelNames.has(DUPLICATE_LABEL)) { + try { + await github.rest.issues.addLabels({ + owner, + repo, + issue_number: issueNumber, + labels: [DUPLICATE_LABEL], + }); + } catch (err) { + console.log(`Could not add ${DUPLICATE_LABEL}: ${err.message}`); + } + } + + const lines = topMatches + .map((m) => `- #${m.number} (${Math.round(m.score * 100)}%) ${m.title}`) + .join('\n'); + + const commentBody = + `${DUPLICATE_MARKER}\n` + + `Potential duplicates:\n${lines}\n\n` + + `_This note is generated automatically using repository issue similarity and may include false positives._`; + + if (existingComment) { + await github.rest.issues.updateComment({ + owner, + repo, + comment_id: existingComment.id, + body: commentBody, + }); + } else { + await github.rest.issues.createComment({ + owner, + repo, + issue_number: issueNumber, + body: commentBody, + }); + } + + backfill-relabel: + if: github.repository_owner == 'GameServerManagers' && github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + env: + ISSUE_STATE: ${{ inputs.issue_state }} + ISSUE_LIMIT: ${{ inputs.limit }} + AI_GAME_FALLBACK: ${{ inputs.ai_game_fallback }} + steps: + - name: Trigger relabel backfill + uses: actions/github-script@v9 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + script: | + const owner = context.repo.owner; + const repo = context.repo.repo; + const state = process.env.ISSUE_STATE || 'all'; + const rawLimit = Number.parseInt(process.env.ISSUE_LIMIT || '0', 10); + const limit = Number.isFinite(rawLimit) && rawLimit > 0 ? rawLimit : 0; + const useAiGameFallback = String(process.env.AI_GAME_FALLBACK || 'false').toLowerCase() === 'true'; + const processedIssues = []; + const failedIssues = []; + let aiGameAttempts = 0; + let aiGameMatches = 0; + let aiGameRateLimited = 0; + let aiFallbackDisabledReason = ''; + let stoppedForApiRateLimit = false; + let apiRateLimitStopReason = ''; + + // === Helpers (mirrored from issue-ai-maintenance) === + + function normalizeName(value) { + return (value || '') + .toLowerCase() + .replace(/[''`]/g, '') + .replace(/[^a-z0-9]+/g, ' ') + .trim(); + } + + function parseGameCandidates(gameField) { + if (!gameField || /^_?no response_?$/i.test(gameField)) return []; + return gameField + .replace(/\(.*?\)/g, ' ') + .split(/\n|,|\s+&\s+|\s+and\s+|\//i) + .map((v) => v.trim()) + .filter(Boolean); + } + + function findGamesFromText(text, gameAliasToLabel, gameAliasToScript) { + const labels = new Set(); + const scripts = new Set(); + const normalizedText = normalizeName(text); + if (!normalizedText) return { labels, scripts }; + + const escapeRegex = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const aliases = []; + for (const [alias, label] of gameAliasToLabel.entries()) { + if (alias.length < 3) continue; + aliases.push({ alias, label, script: gameAliasToScript.get(alias) || null }); + } + + // Prefer longer aliases first so "killing floor 2" does not also match "killing floor". + aliases.sort((a, b) => b.alias.length - a.alias.length); + + const usedRanges = []; + const isOverlapping = (start, end) => + usedRanges.some((range) => start < range.end && end > range.start); + + for (const entry of aliases) { + const pattern = new RegExp(`\\b${escapeRegex(entry.alias).replace(/\\ /g, '\\s+')}\\b`, 'g'); + let match; + while ((match = pattern.exec(normalizedText)) !== null) { + const start = match.index; + const end = start + match[0].length; + if (isOverlapping(start, end)) continue; + + labels.add(entry.label); + if (entry.script) scripts.add(entry.script); + usedRanges.push({ start, end }); + } + } + + return { labels, scripts }; + } + + function parseAiGameResponse(raw) { + const input = (raw || '').trim(); + if (!input) return {}; + + const candidates = [input]; + const fenced = input.match(/```(?:json)?\s*([\s\S]*?)```/i); + if (fenced?.[1]) candidates.push(fenced[1].trim()); + + const firstBrace = input.indexOf('{'); + const lastBrace = input.lastIndexOf('}'); + if (firstBrace !== -1 && lastBrace > firstBrace) { + candidates.push(input.slice(firstBrace, lastBrace + 1)); + } + + for (const candidate of candidates) { + try { + return JSON.parse(candidate); + } catch (_err) { + // Continue trying fallbacks. + } + } + + return {}; + } + + function isGenericNonGameDetection(value) { + const normalized = normalizeName(value); + if (!normalized) return false; + + return [ + 'srcds', + 'source dedicated server', + 'dedicated server', + 'source engine', + 'goldsrc', + 'steamcmd', + 'linuxgsm', + 'lgsm', + ].some((term) => normalized.includes(term)); + } + + function parseAiRateLimitInfo(response) { + const retryAfter = response.headers.get('retry-after') || response.headers.get('Retry-After') || ''; + const limit = response.headers.get('x-ratelimit-limit') || ''; + const remaining = response.headers.get('x-ratelimit-remaining') || ''; + const resetEpoch = response.headers.get('x-ratelimit-reset') || ''; + const requestId = response.headers.get('x-github-request-id') || ''; + + let resetIso = ''; + const parsedReset = Number.parseInt(resetEpoch, 10); + if (Number.isFinite(parsedReset) && parsedReset > 0) { + resetIso = new Date(parsedReset * 1000).toISOString(); + } + + return { + retryAfter, + limit, + remaining, + resetEpoch, + resetIso, + requestId, + }; + } + + function formatAiRateLimitInfo(info) { + const parts = []; + if (info.retryAfter) parts.push(`retry-after=${info.retryAfter}s`); + if (info.limit) parts.push(`limit=${info.limit}`); + if (info.remaining) parts.push(`remaining=${info.remaining}`); + if (info.resetEpoch) parts.push(`reset=${info.resetEpoch}${info.resetIso ? ` (${info.resetIso})` : ''}`); + if (info.requestId) parts.push(`request-id=${info.requestId}`); + return parts.length > 0 ? parts.join(', ') : 'no rate-limit headers returned'; + } + + function isApiRateLimitError(err) { + const message = String(err?.message || '').toLowerCase(); + return ( + err?.status === 429 || + message.includes('api rate limit exceeded') || + message.includes('secondary rate limit') || + (message.includes('rate limit') && err?.status === 403) + ); + } + + function formatApiRateLimitError(err) { + const headers = err?.response?.headers || err?.headers || {}; + const limit = headers['x-ratelimit-limit'] || ''; + const remaining = headers['x-ratelimit-remaining'] || ''; + const resetEpoch = headers['x-ratelimit-reset'] || ''; + const requestId = headers['x-github-request-id'] || ''; + + let resetIso = ''; + const parsedReset = Number.parseInt(resetEpoch || '', 10); + if (Number.isFinite(parsedReset) && parsedReset > 0) { + resetIso = new Date(parsedReset * 1000).toISOString(); + } + + const parts = []; + if (limit) parts.push(`limit=${limit}`); + if (remaining) parts.push(`remaining=${remaining}`); + if (resetEpoch) parts.push(`reset=${resetEpoch}${resetIso ? ` (${resetIso})` : ''}`); + if (requestId) parts.push(`request-id=${requestId}`); + return parts.length > 0 ? parts.join(', ') : 'no rate-limit headers returned'; + } + + function hasAliasHitForLabel(text, targetLabel, gameAliasToLabel) { + const normalizedText = normalizeName(text); + if (!normalizedText || !targetLabel) return false; + + const paddedText = ` ${normalizedText} `; + for (const [alias, label] of gameAliasToLabel.entries()) { + if (label !== targetLabel) continue; + if (alias.length < 3) continue; + if (paddedText.includes(` ${alias} `)) return true; + + // Allow obvious joined-word variants for multi-token aliases + // (e.g., "counter strike 1 6" matching "counterstrike 1.6"). + const aliasTokens = alias.split(/\s+/).filter(Boolean); + if (aliasTokens.length > 1) { + const escapedTokens = aliasTokens.map((token) => + token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + ); + const flexibleAliasPattern = new RegExp(`\\b${escapedTokens.join('\\s*')}\\b`); + if (flexibleAliasPattern.test(normalizedText)) return true; + } + } + + return false; + } + + function parseServerlistCsv(csvText) { + const rows = []; + const lines = (csvText || '').split(/\r?\n/); + for (let i = 1; i < lines.length; i += 1) { + const line = lines[i]?.trim(); + if (!line) continue; + const parts = line.split(','); + if (parts.length < 3) continue; + rows.push({ + shortname: parts[0].trim(), + gameservername: parts[1].trim(), + gamename: parts[2].trim(), + }); + } + return rows; + } + + function inferTypeFromTitle(issueTitle) { + if (/^\[bug\]/i.test(issueTitle)) return 'type: bug'; + if (/\bserver\s+request\b/i.test(issueTitle)) return 'type: game server request'; + const hasBracketPrefix = /^\[[^\]]+\]/.test(issueTitle || ''); + const isServerCreation = + /\bserver\s+creation\b/i.test(issueTitle) || + (hasBracketPrefix && /\bcreation\b/i.test(issueTitle)); + const isServerSupportRequest = + /\bserver\s+support\b/i.test(issueTitle) || + (/\bsupport\s+for\b/i.test(issueTitle) && /\bserver\b/i.test(issueTitle)); + if (isServerCreation || isServerSupportRequest) return 'type: game server request'; + if (/^\[feature\]/i.test(issueTitle)) return 'type: feature'; + if (/^\[server request\]/i.test(issueTitle)) return 'type: game server request'; + if (/^\[docs?\]/i.test(issueTitle)) return 'type: docs'; + return null; + } + + function inferDesiredType(issueTitle, labelNames) { + const titleType = inferTypeFromTitle(issueTitle); + if (titleType) return titleType; + + // Prefer server requests over generic feature when both labels exist. + if (labelNames.has('type: game server request')) return 'type: game server request'; + + for (const label of [ + 'type: bug', + 'type: feature', + 'type: game server request', + 'type: docs', + ]) { + if (labelNames.has(label)) return label; + } + return null; + } + + function inferIssueTypeNameFromDesiredType(typeLabel) { + if (typeLabel === 'type: bug') return 'Bug'; + if (typeLabel === 'type: feature') return 'Feature'; + if (typeLabel === 'type: game server request') return 'Server Request'; + if (typeLabel === 'type: docs') return 'Task'; + return null; + } + + function parseCommandSelections(sectionValue) { + const selected = new Set(); + const re = /command:\s*([a-z-]+)/gi; + let m; + while ((m = re.exec(sectionValue || '')) !== null) { + let value = m[1].toLowerCase(); + if (value.startsWith('mods-')) value = 'mods'; + if (value === 'auto-update') value = 'update'; + selected.add(`command: ${value}`); + } + return selected; + } + + function parseDistroSelections(sectionValue) { + const text = sectionValue || ''; + const selected = new Set(); + if (/\bUbuntu\b/i.test(text)) selected.add('distro: Ubuntu'); + if (/\bDebian\b/i.test(text)) selected.add('distro: Debian'); + if (/\bAlmaLinux\b/i.test(text)) selected.add('distro: AlmaLinux'); + if (/\bRocky\b/i.test(text)) selected.add('distro: Rocky Linux'); + if (/\bCentOS\b/i.test(text)) selected.add('distro: CentOS'); + if (/\bFedora\b/i.test(text)) selected.add('distro: Fedora'); + if (/\bopenSUSE\b/i.test(text)) selected.add('distro: openSUSE'); + if (/\bArch Linux\b/i.test(text)) selected.add('distro: Arch Linux'); + if (/\bSlackware\b/i.test(text)) selected.add('distro: Slackware'); + return selected; + } + + function extractSection(body, sectionName) { + const escaped = sectionName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const re = new RegExp(`### ${escaped}\\n\\n([\\s\\S]*?)(\\n### |$)`, 'i'); + return (body.match(re)?.[1] || '').trim(); + } + + // === Load shared data once === + + const repoLabels = await github.paginate(github.rest.issues.listLabelsForRepo, { + owner, + repo, + per_page: 100, + }); + + const gameLabelByNormalized = new Map(); + for (const label of repoLabels) { + if (!label.name.startsWith('game: ')) continue; + gameLabelByNormalized.set(normalizeName(label.name.slice(6)), label.name); + } + + const existingEngineLabels = new Set( + repoLabels.map((l) => l.name).filter((name) => name.startsWith('engine: ')) + ); + + const gameAliasToLabel = new Map(); + const gameAliasToScript = new Map(); + const engineByScript = new Map(); + + for (const [normalizedGameName, label] of gameLabelByNormalized.entries()) { + gameAliasToLabel.set(normalizedGameName, label); + } + + try { + const serverlistContent = await github.rest.repos.getContent({ + owner, + repo, + path: 'lgsm/data/serverlist.csv', + }); + const csvText = Buffer.from(serverlistContent.data?.content || '', 'base64').toString('utf8'); + const serverRows = parseServerlistCsv(csvText); + for (const row of serverRows) { + const canonicalLabel = gameLabelByNormalized.get(normalizeName(row.gamename)); + if (!canonicalLabel) continue; + for (const alias of [row.shortname, row.gameservername, row.gamename]) { + const key = normalizeName(alias); + if (!key) continue; + gameAliasToLabel.set(key, canonicalLabel); + gameAliasToScript.set(key, row.gameservername); + } + } + } catch (err) { + console.log(`Could not load serverlist aliases: ${err.message}`); + } + + async function ensureEngineLabel(engineLabel) { + if (existingEngineLabels.has(engineLabel)) return; + try { + await github.rest.issues.createLabel({ + owner, + repo, + name: engineLabel, + color: '000000', + description: `Issues related to ${engineLabel.slice(8)} engine`, + }); + existingEngineLabels.add(engineLabel); + } catch (err) { + if (err.status === 422) { + existingEngineLabels.add(engineLabel); + return; + } + console.log(`Could not create engine label "${engineLabel}": ${err.message}`); + } + } + + async function getEngineForScript(scriptName) { + if (!scriptName) return null; + if (engineByScript.has(scriptName)) return engineByScript.get(scriptName); + try { + const cfgContent = await github.rest.repos.getContent({ + owner, + repo, + path: `lgsm/config-default/config-lgsm/${scriptName}/_default.cfg`, + }); + const cfgText = Buffer.from(cfgContent.data?.content || '', 'base64').toString('utf8'); + const engine = cfgText.match(/^engine="([^"]+)"/m)?.[1] || null; + engineByScript.set(scriptName, engine); + return engine; + } catch (_err) { + engineByScript.set(scriptName, null); + return null; + } + } + + // === Process issues === + + const issues = await github.paginate(github.rest.issues.listForRepo, { + owner, + repo, + state, + sort: 'created', + direction: 'asc', + per_page: 100, + }); + + const targets = issues.filter((issue) => !issue.pull_request); + const selectedTargets = limit > 0 ? targets.slice(0, limit) : targets; + + console.log( + `Starting relabel backfill for ${selectedTargets.length} issue(s) ` + + `(state=${state}, limit=${limit === 0 ? 'all' : limit}).` + ); + + let processed = 0; + for (const rawIssue of selectedTargets) { + if (stoppedForApiRateLimit) break; + console.log(`Processing issue #${rawIssue.number}: ${rawIssue.title}`); + try { + const issueResp = await github.rest.issues.get({ + owner, + repo, + issue_number: rawIssue.number, + }); + const issue = issueResp.data; + const title = issue.title || ''; + const body = issue.body || ''; + const existingLabels = new Set((issue.labels || []).map((l) => l.name).filter(Boolean)); + const labelsToAdd = new Set(); + const labelsToRemove = new Set(); + const isLocked = issue.locked === true; + let issueTypeSet = null; + + // Type reconciliation + const desiredType = inferDesiredType(title, existingLabels); + if (desiredType) { + labelsToAdd.add(desiredType); + for (const label of existingLabels) { + if (label.startsWith('type: ') && label !== desiredType) labelsToRemove.add(label); + } + + const desiredIssueTypeName = inferIssueTypeNameFromDesiredType(desiredType); + if (desiredIssueTypeName) { + try { + const issueTypeData = await github.graphql( + `query($owner:String!,$repo:String!,$number:Int!){ + repository(owner:$owner,name:$repo){ + issueTypes(first:20){ nodes { id name } } + issue(number:$number){ id issueType { id name } } + } + }`, + { owner, repo, number: rawIssue.number } + ); + const issueNode = issueTypeData.repository?.issue; + const issueTypes = issueTypeData.repository?.issueTypes?.nodes || []; + const desiredIssueType = issueTypes.find((t) => t.name === desiredIssueTypeName); + if ( + issueNode?.id && + desiredIssueType?.id && + issueNode.issueType?.id !== desiredIssueType.id + ) { + await github.graphql( + `mutation($id:ID!,$issueTypeId:ID!){ + updateIssue(input:{id:$id,issueTypeId:$issueTypeId}){ + issue { id number issueType { id name } } + } + }`, + { id: issueNode.id, issueTypeId: desiredIssueType.id } + ); + issueTypeSet = desiredIssueTypeName; + console.log(`#${rawIssue.number}: set Issue Type to ${desiredIssueTypeName}`); + } + } catch (err) { + if (isApiRateLimitError(err)) throw err; + console.log(`#${rawIssue.number}: could not sync Issue Type: ${err.message}`); + } + } + } + + // Commands + const commandSection = extractSection(body, 'Command'); + const desiredCommands = parseCommandSelections(commandSection); + if (desiredCommands.size > 0) { + for (const label of desiredCommands) labelsToAdd.add(label); + for (const label of existingLabels) { + if (label.startsWith('command: ') && !desiredCommands.has(label)) labelsToRemove.add(label); + } + } + + // Distros + const distroSection = extractSection(body, 'Linux distro'); + const desiredDistros = parseDistroSelections(distroSection); + if (desiredDistros.size > 0) { + for (const label of desiredDistros) labelsToAdd.add(label); + for (const label of existingLabels) { + if (label.startsWith('distro: ') && !desiredDistros.has(label)) labelsToRemove.add(label); + } + } + + // Tmux false positive cleanup + if ( + existingLabels.has('info: tmux') && + !/\b(tmuxception|check_tmuxception)\b/i.test(`${title}\n${body}`) + ) { + labelsToRemove.add('info: tmux'); + } + + // Games and engines + const desiredGames = new Set(); + const gameLabelSource = new Map(); // label → 'form-field' | 'text-match' | 'ai-fallback' + const desiredServerScripts = new Set(); + const gameField = extractSection(body, 'Game'); + const gameCandidates = parseGameCandidates(gameField); + const hasStructuredGameSelection = gameCandidates.length > 0; + for (const candidate of gameCandidates) { + const normalizedCandidate = normalizeName(candidate); + const mapped = + gameAliasToLabel.get(normalizedCandidate) || gameLabelByNormalized.get(normalizedCandidate); + if (mapped) { + desiredGames.add(mapped); + gameLabelSource.set(mapped, 'form-field'); + } + const mappedScript = gameAliasToScript.get(normalizedCandidate); + if (mappedScript) desiredServerScripts.add(mappedScript); + } + + // Legacy issues often have no form section; fall back to deterministic text matching. + if (desiredGames.size === 0) { + const fromText = findGamesFromText(`${title}\n${body}`, gameAliasToLabel, gameAliasToScript); + for (const label of fromText.labels) { + desiredGames.add(label); + gameLabelSource.set(label, 'text-match'); + } + for (const scriptName of fromText.scripts) desiredServerScripts.add(scriptName); + } + + // Optional AI fallback for legacy issues where deterministic matching finds nothing. + if (useAiGameFallback && desiredGames.size === 0) { + if (aiFallbackDisabledReason) { + console.log(`#${rawIssue.number}: AI fallback skipped (${aiFallbackDisabledReason})`); + } else { + aiGameAttempts += 1; + const aiPayload = { + model: 'openai/gpt-4.1-mini', + temperature: 0.1, + max_tokens: 120, + messages: [ + { + role: 'system', + content: + 'Return JSON only. Identify the specific game referenced in this LinuxGSM issue with high precision. ' + + 'If only generic platform/engine terms are present (e.g. srcds, source dedicated server, steamcmd), return detected_game as null.', + }, + { + role: 'user', + content: + `Title: ${title}\n\nBody:\n${body.slice(0, 2500)}\n\n` + + 'Return JSON: {"detected_game":"string or null","game_confidence":"high|medium|low|null"}', + }, + ], + }; + const aiUrl = `https://models.github.ai/orgs/${owner}/inference/chat/completions`; + const aiHeaders = { + Accept: 'application/vnd.github+json', + Authorization: `Bearer ${process.env.GITHUB_TOKEN}`, + 'X-GitHub-Api-Version': '2026-03-10', + 'Content-Type': 'application/json', + }; + try { + let res = await fetch(aiUrl, { method: 'POST', headers: aiHeaders, body: JSON.stringify(aiPayload) }); + + // On 429 honour Retry-After (capped at 60 s) then retry once. + if (res.status === 429) { + aiGameRateLimited += 1; + const rateInfo = parseAiRateLimitInfo(res); + const rawRetryAfter = Number.parseInt(rateInfo.retryAfter || '10', 10); + const retryAfter = Math.min(Number.isFinite(rawRetryAfter) ? rawRetryAfter : 10, 60); + if (Number.isFinite(rawRetryAfter) && rawRetryAfter > 300) { + aiFallbackDisabledReason = `global cooldown active (retry-after=${rawRetryAfter}s)`; + console.log( + `#${rawIssue.number}: AI fallback disabled for remaining run (${aiFallbackDisabledReason}; ${formatAiRateLimitInfo(rateInfo)})` + ); + } else { + console.log( + `#${rawIssue.number}: AI fallback rate-limited - waiting ${retryAfter}s then retrying (${formatAiRateLimitInfo(rateInfo)})` + ); + await new Promise((r) => setTimeout(r, retryAfter * 1000)); + res = await fetch(aiUrl, { method: 'POST', headers: aiHeaders, body: JSON.stringify(aiPayload) }); + } + } + + if (res.ok) { + const data = await res.json(); + const raw = data.choices?.[0]?.message?.content || '{}'; + const parsed = parseAiGameResponse(raw); + const detectedGame = normalizeName(parsed?.detected_game || ''); + const confidence = (parsed?.game_confidence || '').toLowerCase(); + if (detectedGame && confidence === 'high') { + const mappedLabel = + gameAliasToLabel.get(detectedGame) || gameLabelByNormalized.get(detectedGame); + if (mappedLabel) { + const hasAliasEvidence = hasAliasHitForLabel( + `${title}\n${body}`, + mappedLabel, + gameAliasToLabel + ); + if (hasAliasEvidence) { + desiredGames.add(mappedLabel); + gameLabelSource.set(mappedLabel, 'ai-fallback'); + const mappedScript = gameAliasToScript.get(detectedGame); + if (mappedScript) desiredServerScripts.add(mappedScript); + aiGameMatches += 1; + console.log( + `#${rawIssue.number}: AI fallback accepted game "${mappedLabel}" from "${parsed?.detected_game}"` + ); + } else { + console.log( + `#${rawIssue.number}: AI fallback rejected game "${mappedLabel}" (no literal alias evidence in issue text)` + ); + } + } else { + if (isGenericNonGameDetection(parsed?.detected_game || '')) { + console.log( + `#${rawIssue.number}: AI fallback skipped generic non-game detection "${parsed?.detected_game}"` + ); + } else { + console.log( + `#${rawIssue.number}: AI fallback returned unmapped game "${parsed?.detected_game}"` + ); + } + } + } + } else { + if (res.status === 429) { + const rateInfo = parseAiRateLimitInfo(res); + console.log( + `#${rawIssue.number}: AI fallback skipped (HTTP 429, ${formatAiRateLimitInfo(rateInfo)})` + ); + } else { + console.log(`#${rawIssue.number}: AI fallback skipped (HTTP ${res.status})`); + } + } + } catch (err) { + console.log(`#${rawIssue.number}: AI fallback error: ${err.message}`); + } + } + } + + for (const gameLabel of desiredGames) { + const mappedScript = gameAliasToScript.get(normalizeName(gameLabel.slice(6))); + if (mappedScript) desiredServerScripts.add(mappedScript); + } + + const desiredEngineLabels = new Set(); + for (const scriptName of desiredServerScripts) { + const engine = await getEngineForScript(scriptName); + if (!engine) continue; + const engineLabel = `engine: ${engine}`; + await ensureEngineLabel(engineLabel); + desiredEngineLabels.add(engineLabel); + } + + if (desiredEngineLabels.size > 0) { + for (const label of desiredEngineLabels) labelsToAdd.add(label); + for (const label of existingLabels) { + if (label.startsWith('engine: ') && !desiredEngineLabels.has(label)) labelsToRemove.add(label); + } + } + + if (desiredGames.size > 0) { + for (const label of desiredGames) labelsToAdd.add(label); + if (hasStructuredGameSelection) { + for (const label of existingLabels) { + if (label.startsWith('game: ') && !desiredGames.has(label)) labelsToRemove.add(label); + } + } else { + // For legacy issues without structured game selection, only prune stale + // broader labels when a more specific inferred game label exists. + const desiredGameNamesNormalized = new Set( + [...desiredGames].map((label) => normalizeName(label.slice(6))) + ); + for (const label of existingLabels) { + if (!label.startsWith('game: ') || desiredGames.has(label)) continue; + const existingGameName = normalizeName(label.slice(6)); + const isBroaderOverlap = [...desiredGameNamesNormalized].some( + (desiredName) => desiredName !== existingGameName && desiredName.startsWith(`${existingGameName} `) + ); + if (isBroaderOverlap) labelsToRemove.add(label); + } + } + } + + // Apply changes + const finalAdds = [...labelsToAdd].filter((label) => !existingLabels.has(label)); + const finalRemoves = [...labelsToRemove].filter((label) => existingLabels.has(label)); + + let labelAdded = 0; + let labelRemoved = 0; + + for (const label of finalRemoves) { + try { + await github.rest.issues.removeLabel({ + owner, + repo, + issue_number: rawIssue.number, + name: label, + }); + labelRemoved += 1; + console.log(`#${rawIssue.number}: removed "${label}"`); + } catch (err) { + if (isApiRateLimitError(err)) throw err; + console.log(`#${rawIssue.number}: could not remove "${label}": ${err.message}`); + } + } + + for (const label of finalAdds) { + try { + await github.rest.issues.addLabels({ + owner, + repo, + issue_number: rawIssue.number, + labels: [label], + }); + labelAdded += 1; + const gameSource = gameLabelSource.get(label); + console.log(`#${rawIssue.number}: added "${label}"${gameSource ? ` (${gameSource})` : ''}`); + } catch (err) { + if (isApiRateLimitError(err)) throw err; + console.log(`#${rawIssue.number}: could not add "${label}": ${err.message}`); + } + } + + processed += 1; + processedIssues.push({ + number: rawIssue.number, + title: rawIssue.title, + adds: labelAdded, + removes: labelRemoved, + issueTypeSet, + locked: isLocked, + }); + console.log( + `#${rawIssue.number}: done (+${labelAdded} added, -${labelRemoved} removed${ + issueTypeSet ? `, type→${issueTypeSet}` : '' + }${isLocked ? ', locked' : ''})` + ); + } catch (err) { + if (isApiRateLimitError(err)) { + stoppedForApiRateLimit = true; + apiRateLimitStopReason = formatApiRateLimitError(err); + console.log( + `Stopping backfill due to API rate limit at #${rawIssue.number} (${apiRateLimitStopReason})` + ); + failedIssues.push({ + number: rawIssue.number, + title: rawIssue.title, + stage: 'rate-limit', + error: err.message, + }); + break; + } else { + console.log(`Error processing #${rawIssue.number}: ${err.message}`); + failedIssues.push({ + number: rawIssue.number, + title: rawIssue.title, + stage: 'process', + error: err.message, + }); + } + } + } + + console.log( + `Relabel backfill complete: ${processed} processed, ${failedIssues.length} failed${ + stoppedForApiRateLimit ? `, stopped early (${apiRateLimitStopReason})` : '' + }.` + ); + + await core.summary + .addHeading('Relabel Backfill Summary') + .addTable([ + [ + { data: 'Requested state', header: true }, + { data: 'Limit', header: true }, + { data: 'AI fallback', header: true }, + { data: 'AI attempts', header: true }, + { data: 'AI matches', header: true }, + { data: 'AI 429s', header: true }, + { data: 'AI disabled reason', header: true }, + { data: 'Stopped early', header: true }, + { data: 'Target issues', header: true }, + { data: 'Processed', header: true }, + { data: 'Failures', header: true }, + ], + [ + state, + limit === 0 ? 'all' : String(limit), + useAiGameFallback ? 'enabled' : 'disabled', + String(aiGameAttempts), + String(aiGameMatches), + String(aiGameRateLimited), + aiFallbackDisabledReason || '—', + stoppedForApiRateLimit ? apiRateLimitStopReason : 'no', + String(selectedTargets.length), + String(processed), + String(failedIssues.length), + ], + ]) + .write(); + + if (processedIssues.length > 0) { + const processedRows = processedIssues.slice(0, 50).map((issue) => [ + `#${issue.number}${issue.locked ? ' 🔒' : ''}`, + `[${issue.title}](https://github.com/${owner}/${repo}/issues/${issue.number})`, + `+${issue.adds} / -${issue.removes}`, + issue.issueTypeSet || '—', + ]); + + await core.summary + .addHeading('Processed Issues') + .addTable([ + [ + { data: 'Issue', header: true }, + { data: 'Title', header: true }, + { data: 'Label changes', header: true }, + { data: 'Issue Type set', header: true }, + ], + ...processedRows, + ]) + .write(); + } + + if (failedIssues.length > 0) { + const failureRows = failedIssues.slice(0, 50).map((issue) => [ + `#${issue.number}`, + issue.stage, + issue.error, + ]); + + await core.summary + .addHeading('Failures') + .addTable([ + [ + { data: 'Issue', header: true }, + { data: 'Stage', header: true }, + { data: 'Error', header: true }, + ], + ...failureRows, + ]) + .write(); + } + + pr-labeler: + if: github.repository_owner == 'GameServerManagers' && github.event_name == 'pull_request' + runs-on: ubuntu-latest + steps: + - name: PR Labeler + uses: github/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' + if: github.repository_owner == 'GameServerManagers' && github.event_name == 'issues' && github.event.action == 'opened' runs-on: ubuntu-latest steps: - name: Is Sponsor Label diff --git a/.github/workflows/lock.yml b/.github/workflows/lock.yml index 8a08284a4..a0a810172 100644 --- a/.github/workflows/lock.yml +++ b/.github/workflows/lock.yml @@ -15,7 +15,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Lock Threads - uses: dessant/lock-threads@v5 + uses: dessant/lock-threads@v6 with: github-token: ${{ secrets.GITHUB_TOKEN }} issue-comment: > diff --git a/.github/workflows/potential-duplicates.yml b/.github/workflows/potential-duplicates.yml deleted file mode 100644 index 39a3189d8..000000000 --- a/.github/workflows/potential-duplicates.yml +++ /dev/null @@ -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}} diff --git a/.github/workflows/serverlist-validate.yml b/.github/workflows/serverlist-validate.yml index c2605b062..4fb830702 100644 --- a/.github/workflows/serverlist-validate.yml +++ b/.github/workflows/serverlist-validate.yml @@ -15,7 +15,7 @@ jobs: uses: actions/checkout@v4 - name: Compare Versions - run: chmod +x .github/workflows/serverlist-validate.sh; .github/workflows/serverlist-validate.sh + run: .github/scripts/serverlist-validate.sh - name: Validate Game Icons - run: chmod +x .github/workflows/serverlist-validate-game-icons.sh; .github/workflows/serverlist-validate-game-icons.sh + run: .github/scripts/serverlist-validate-game-icons.sh diff --git a/.github/workflows/sync-game-labels.yml b/.github/workflows/sync-game-labels.yml new file mode 100644 index 000000000..eccb0ed46 --- /dev/null +++ b/.github/workflows/sync-game-labels.yml @@ -0,0 +1,28 @@ +name: Sync Game Labels +on: + push: + branches: + - master + - develop + paths: + - "lgsm/data/serverlist.csv" + workflow_dispatch: {} + +permissions: + issues: write + contents: read + +jobs: + sync-game-labels: + if: github.repository_owner == 'GameServerManagers' + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Sync game labels from serverlist + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + chmod +x .github/scripts/sync-game-labels.sh + .github/scripts/sync-game-labels.sh diff --git a/.github/workflows/version-check.yml b/.github/workflows/version-check.yml index cfa7615bd..d3c4a5276 100644 --- a/.github/workflows/version-check.yml +++ b/.github/workflows/version-check.yml @@ -14,4 +14,4 @@ jobs: uses: actions/checkout@v4 - name: Version Check - run: chmod +x .github/workflows/version-check.sh; .github/workflows/version-check.sh + run: .github/scripts/version-check.sh diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index fb935a066..4b118cd09 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -2,131 +2,158 @@ ## Our Pledge -We as members, contributors, and leaders pledge to make participation in our -community a harassment-free experience for everyone, regardless of age, body -size, visible or invisible disability, ethnicity, sex characteristics, gender -identity and expression, level of experience, education, socio-economic status, -nationality, personal appearance, race, caste, color, religion, or sexual identity -and orientation. - -We pledge to act and interact in ways that contribute to an open, welcoming, -diverse, inclusive, and healthy community. - -## Our Standards - -Examples of behavior that contributes to a positive environment for our -community include: - -- Demonstrating empathy and kindness toward other people -- Being respectful of differing opinions, viewpoints, and experiences -- Giving and gracefully accepting constructive feedback -- Accepting responsibility and apologizing to those affected by our mistakes, - and learning from the experience -- Focusing on what is best not just for us as individuals, but for the - overall community - -Examples of unacceptable behavior include: - -- The use of sexualized language or imagery, and sexual attention or - advances of any kind -- Trolling, insulting or derogatory comments, and personal or political attacks -- Public or private harassment -- Publishing others' private information, such as a physical or email - address, without their explicit permission -- Other conduct which could reasonably be considered inappropriate in a - professional setting - -## Enforcement Responsibilities - -Community leaders are responsible for clarifying and enforcing our standards of -acceptable behavior and will take appropriate and fair corrective action in -response to any behavior that they deem inappropriate, threatening, offensive, -or harmful. - -Community leaders have the right and responsibility to remove, edit, or reject -comments, commits, code, wiki edits, issues, and other contributions that are -not aligned to this Code of Conduct, and will communicate reasons for moderation -decisions when appropriate. +We pledge to make our community welcoming, safe, and equitable for all. + +We are committed to fostering an environment that respects and promotes the +dignity, rights, and contributions of all individuals, regardless of characteristics +including race, ethnicity, caste, color, age, physical characteristics, +neurodiversity, disability, sex or gender, gender identity or expression, sexual +orientation, language, philosophy or religion, national or social origin, +socio-economic position, level of education, or other status. The same privileges of +participation are extended to everyone who participates in good faith and in +accordance with this Covenant. + +## Encouraged Behaviors + +While acknowledging differences in social norms, we all strive to meet our +community's expectations for positive behavior. We also understand that our words +and actions may be interpreted differently than we intend based on culture, +background, or native language. + +With these considerations in mind, we agree to behave mindfully toward each other +and act in ways that center our shared values, including: + +1. Respecting the **purpose of our community**, our activities, and our ways of gathering. +2. Engaging **kindly and honestly** with others. +3. Respecting **different viewpoints** and experiences. +4. **Taking responsibility** for our actions and contributions. +5. Gracefully giving and accepting **constructive feedback**. +6. Committing to **repairing harm** when it occurs. +7. Behaving in other ways that promote and sustain the **well-being of our community**. + +## Restricted Behaviors + +We agree to restrict the following behaviors in our community. Instances, +threats, and promotion of these behaviors are violations of this Code of Conduct. + +1. **Harassment.** Violating explicitly expressed boundaries or engaging in + unnecessary personal attention after any clear request to stop. +2. **Character attacks.** Making insulting, demeaning, or pejorative comments + directed at a community member or group of people. +3. **Stereotyping or discrimination.** Characterizing anyone's personality or + behavior on the basis of immutable identities or traits. +4. **Sexualization.** Behaving in a way that would generally be considered + inappropriately intimate in the context or purpose of the community. +5. **Violating confidentiality.** Sharing or acting on someone's personal or + private information without their permission. +6. **Endangerment.** Causing, encouraging, or threatening violence or other harm + toward any person or group. +7. Behaving in other ways that **threaten the well-being** of our community. + +### Other Restrictions + +1. **Misleading identity.** Impersonating someone else for any reason, or + pretending to be someone else to evade enforcement actions. +2. **Failing to credit sources.** Not properly crediting the sources of content + you contribute. +3. **Promotional materials.** Sharing marketing or other commercial content in a + way that is outside the norms of the community. +4. **Irresponsible communication.** Failing to responsibly present content which + includes, links or describes any other restricted behaviors. + +## Reporting an Issue + +Tensions can occur between community members even when they are trying their best +to collaborate. Not every conflict represents a code of conduct violation, and this +Code of Conduct reinforces encouraged behaviors and norms that can help avoid +conflicts and minimize harm. + +When an incident does occur, it is important to report it promptly. To report a +possible violation, please use one of the following methods: + +- **GitHub (private):** [Submit a private security advisory](https://github.com/GameServerManagers/LinuxGSM/security/advisories/new) +- **Discord:** Contact a moderator via the [LinuxGSM Discord server](https://linuxgsm.com/discord) + +Community Moderators take reports of violations seriously and will make every +effort to respond in a timely manner. They will investigate all reports of code of +conduct violations, reviewing messages, logs, and recordings, or interviewing +witnesses and other participants. Community Moderators will keep investigation and +enforcement actions as transparent as possible while prioritizing safety and +confidentiality. In order to honor these values, enforcement actions are carried out +in private with the involved parties, but communicating to the whole community may +be part of a mutually agreed upon resolution. + +## Addressing and Repairing Harm + +If an investigation by the Community Moderators finds that this Code of Conduct +has been violated, the following enforcement ladder may be used to determine how +best to repair harm, based on the incident's impact on the individuals involved +and the community as a whole. Depending on the severity of a violation, lower +rungs on the ladder may be skipped. + +1. **Warning** + 1. Event: A violation involving a single incident or series of incidents. + 2. Consequence: A private, written warning from the Community Moderators. + 3. Repair: Examples of repair include a private written apology, acknowledgement + of responsibility, and seeking clarification on expectations. + +2. **Temporarily Limited Activities** + 1. Event: A repeated incidence of a violation that previously resulted in a + warning, or the first incidence of a more serious violation. + 2. Consequence: A private, written warning with a time-limited cooldown period + designed to underscore the seriousness of the situation and give the community + members involved time to process the incident. The cooldown period may be + limited to particular communication channels or interactions with particular + community members. + 3. Repair: Examples of repair may include making an apology, using the cooldown + period to reflect on actions and impact, and being thoughtful about + re-entering community spaces after the period is over. + +3. **Temporary Suspension** + 1. Event: A pattern of repeated violation which the Community Moderators have + tried to address with warnings, or a single serious violation. + 2. Consequence: A private written warning with conditions for return from + suspension. In general, temporary suspensions give the person being suspended + time to reflect upon their behavior and possible corrective actions. + 3. Repair: Examples of repair include respecting the spirit of the suspension, + meeting the specified conditions for return, and being thoughtful about how to + reintegrate with the community when the suspension is lifted. + +4. **Permanent Ban** + 1. Event: A pattern of repeated code of conduct violations that other steps on + the ladder have failed to resolve, or a violation so serious that the Community + Moderators determine there is no way to keep the community safe with this + person as a member. + 2. Consequence: Access to all community spaces, tools, and communication channels + is removed. In general, permanent bans should be rarely used, should have + strong reasoning behind them, and should only be resorted to if working through + other remedies has failed to change the behavior. + 3. Repair: There is no possible repair in cases of this severity. + +This enforcement ladder is intended as a guideline. It does not limit the ability +of Community Moderators to use their discretion and judgment, in keeping with the +best interests of our community. ## Scope -This Code of Conduct applies within all community spaces, and also applies when -an individual is officially representing the community in public spaces. -Examples of representing our community include using an official e-mail address, +This Code of Conduct applies within all community spaces, and also applies when an +individual is officially representing the community in public or other spaces. +Examples of representing our community include using an official email address, posting via an official social media account, or acting as an appointed representative at an online or offline event. -## Enforcement - -Instances of abusive, harassing, or otherwise unacceptable behavior may be -reported to the community leaders responsible for enforcement at -[INSERT CONTACT METHOD]. -All complaints will be reviewed and investigated promptly and fairly. - -All community leaders are obligated to respect the privacy and security of the -reporter of any incident. - -## Enforcement Guidelines - -Community leaders will follow these Community Impact Guidelines in determining -the consequences for any action they deem in violation of this Code of Conduct: - -### 1. Correction - -**Community Impact**: Use of inappropriate language or other behavior deemed -unprofessional or unwelcome in the community. - -**Consequence**: A private, written warning from community leaders, providing -clarity around the nature of the violation and an explanation of why the -behavior was inappropriate. A public apology may be requested. - -### 2. Warning - -**Community Impact**: A violation through a single incident or series -of actions. - -**Consequence**: A warning with consequences for continued behavior. No -interaction with the people involved, including unsolicited interaction with -those enforcing the Code of Conduct, for a specified period of time. This -includes avoiding interactions in community spaces as well as external channels -like social media. Violating these terms may lead to a temporary or -permanent ban. - -### 3. Temporary Ban - -**Community Impact**: A serious violation of community standards, including -sustained inappropriate behavior. - -**Consequence**: A temporary ban from any sort of interaction or public -communication with the community for a specified period of time. No public or -private interaction with the people involved, including unsolicited interaction -with those enforcing the Code of Conduct, is allowed during this period. -Violating these terms may lead to a permanent ban. - -### 4. Permanent Ban - -**Community Impact**: Demonstrating a pattern of violation of community -standards, including sustained inappropriate behavior, harassment of an -individual, or aggression toward or disparagement of classes of individuals. - -**Consequence**: A permanent ban from any sort of public interaction within -the community. - ## Attribution -This Code of Conduct is adapted from the [Contributor Covenant][homepage], -version 2.1, available at -[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. - -Community Impact Guidelines were inspired by -[Mozilla's code of conduct enforcement ladder][mozilla coc]. +This Code of Conduct is adapted from the Contributor Covenant, version 3.0, +permanently available at . -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 +. -[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 +. Translations are provided at +. Additional enforcement and +community guideline resources can be found at +. The enforcement ladder was +inspired by the work of [Mozilla's code of conduct team](https://github.com/mozilla/inclusion).