mirror of
https://github.com/github/spec-kit.git
synced 2026-03-18 19:33:09 +00:00
feat(extensions,presets): add priority-based resolution ordering (#1855)
* feat(extensions,presets): add priority-based resolution ordering Add priority field to extension and preset registries for deterministic template resolution when multiple sources provide the same template. Extensions: - Add `list_by_priority()` method to ExtensionRegistry - Add `--priority` option to `extension add` command - Add `extension set-priority` command - Show priority in `extension list` and `extension info` - Preserve priority during `extension update` - Update RFC documentation Presets: - Add `preset set-priority` command - Show priority in `preset info` output - Use priority ordering in PresetResolver for extensions Both systems: - Lower priority number = higher precedence (default: 10) - Backwards compatible with legacy entries (missing priority defaults to 10) - Comprehensive test coverage including backwards compatibility Closes #1845 Closes #1854 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: address code review feedback - list_by_priority(): add secondary sort by ID for deterministic ordering, return deep copies to prevent mutation - install_from_directory/zip: validate priority >= 1 early - extension add CLI: validate --priority >= 1 before install - PresetRegistry.update(): preserve installed_at timestamp - Test assertions: use exact source string instead of substring match Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: address additional review feedback - PresetResolver: add fallback to directory scanning when registry is empty/corrupted for robustness and backwards compatibility - PresetRegistry.update(): add guard to prevent injecting installed_at when absent in existing entry (mirrors ExtensionRegistry behavior) - RFC: update extension list example to match actual CLI output format Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: restore defensive code and RFC descriptions lost in rebase - Restore defensive code in list_by_priority() with .get() and isinstance check - Restore detailed --from URL and --dev option descriptions in RFC Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: add defensive code to presets list_by_priority() - Add .get() and isinstance check for corrupted/empty registry - Move copy import to module level (remove local import) - Matches defensive pattern used in extensions.py Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: address reviewer feedback on priority resolution - Rename _normalize_priority to normalize_priority (public API) - Add comprehensive tests for normalize_priority function (9 tests) - Filter non-dict metadata entries in list_by_priority() methods - Fix extension priority resolution to merge registered and unregistered extensions into unified sorted list (unregistered get implicit priority 10) - Add tests for extension priority resolution ordering (4 tests) The key fix ensures unregistered extensions with implicit priority 10 correctly beat registered extensions with priority > 10, and vice versa. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: DRY refactor and strengthen test assertions - Extract _get_all_extensions_by_priority() helper in PresetResolver to eliminate duplicated extension list construction - Add priority=10 assertion to test_legacy_extension_without_priority_field - Add priority=10 assertion to test_legacy_preset_without_priority_field Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: add isinstance(dict) checks for corrupted registry entries Add defensive checks throughout CLI commands and manager methods to handle cases where registry entries may be corrupted (non-dict values). This prevents AttributeError when calling .get() on non-dict metadata. Locations fixed: - __init__.py: preset/extension info, set-priority, enable/disable, upgrade commands - extensions.py: list_installed() - presets.py: list_installed() Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: normalize priority display to match resolution behavior Use normalize_priority() for all priority display in CLI commands to ensure displayed values match actual resolution behavior when registry data is corrupted/hand-edited. Locations fixed: - extensions.py: list_installed() - presets.py: list_installed(), PresetResolver - __init__.py: preset info, extension info, set-priority commands Also added GraphQL query for unresolved PR comments to CLAUDE.md. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: repair corrupted priority values in set-priority commands Changed set-priority commands to check if the raw stored value is already a valid int equal to the requested priority before skipping. This ensures corrupted values (e.g., "high") get repaired even when setting to the default priority (10). Also removed CLAUDE.md that was accidentally added to the repo. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: harden registry update methods against corrupted entries - Normalize priority when restoring during extension update to prevent propagating corrupted values (e.g., "high", 0, negative) - Add isinstance(dict) checks in ExtensionRegistry.update() and PresetRegistry.update() to handle corrupted entries (string/list) that would cause TypeError on merge Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: use safe fallback for version in list_installed() When registry entry is corrupted (non-dict), metadata becomes {} after the isinstance check. Use metadata.get("version", manifest.version) instead of metadata["version"] to avoid KeyError. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: iamaeroplane <michal.bachorik@gmail.com> Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -26,6 +26,7 @@ from specify_cli.extensions import (
|
||||
ExtensionError,
|
||||
ValidationError,
|
||||
CompatibilityError,
|
||||
normalize_priority,
|
||||
version_satisfies,
|
||||
)
|
||||
|
||||
@@ -121,6 +122,57 @@ def project_dir(temp_dir):
|
||||
return proj_dir
|
||||
|
||||
|
||||
# ===== normalize_priority Tests =====
|
||||
|
||||
class TestNormalizePriority:
|
||||
"""Test normalize_priority helper function."""
|
||||
|
||||
def test_valid_integer(self):
|
||||
"""Test with valid integer priority."""
|
||||
assert normalize_priority(5) == 5
|
||||
assert normalize_priority(1) == 1
|
||||
assert normalize_priority(100) == 100
|
||||
|
||||
def test_valid_string_number(self):
|
||||
"""Test with string that can be converted to int."""
|
||||
assert normalize_priority("5") == 5
|
||||
assert normalize_priority("10") == 10
|
||||
|
||||
def test_zero_returns_default(self):
|
||||
"""Test that zero priority returns default."""
|
||||
assert normalize_priority(0) == 10
|
||||
assert normalize_priority(0, default=5) == 5
|
||||
|
||||
def test_negative_returns_default(self):
|
||||
"""Test that negative priority returns default."""
|
||||
assert normalize_priority(-1) == 10
|
||||
assert normalize_priority(-100, default=5) == 5
|
||||
|
||||
def test_none_returns_default(self):
|
||||
"""Test that None returns default."""
|
||||
assert normalize_priority(None) == 10
|
||||
assert normalize_priority(None, default=5) == 5
|
||||
|
||||
def test_invalid_string_returns_default(self):
|
||||
"""Test that non-numeric string returns default."""
|
||||
assert normalize_priority("invalid") == 10
|
||||
assert normalize_priority("abc", default=5) == 5
|
||||
|
||||
def test_float_truncates(self):
|
||||
"""Test that float is truncated to int."""
|
||||
assert normalize_priority(5.9) == 5
|
||||
assert normalize_priority(3.1) == 3
|
||||
|
||||
def test_empty_string_returns_default(self):
|
||||
"""Test that empty string returns default."""
|
||||
assert normalize_priority("") == 10
|
||||
|
||||
def test_custom_default(self):
|
||||
"""Test custom default value."""
|
||||
assert normalize_priority(None, default=20) == 20
|
||||
assert normalize_priority("invalid", default=1) == 1
|
||||
|
||||
|
||||
# ===== ExtensionManifest Tests =====
|
||||
|
||||
class TestExtensionManifest:
|
||||
@@ -2363,3 +2415,378 @@ class TestExtensionListCLI:
|
||||
# Verify name and version are also shown
|
||||
assert "Test Extension" in result.output
|
||||
assert "1.0.0" in result.output
|
||||
|
||||
|
||||
class TestExtensionPriority:
|
||||
"""Test extension priority-based resolution."""
|
||||
|
||||
def test_list_by_priority_empty(self, temp_dir):
|
||||
"""Test list_by_priority on empty registry."""
|
||||
extensions_dir = temp_dir / "extensions"
|
||||
extensions_dir.mkdir()
|
||||
|
||||
registry = ExtensionRegistry(extensions_dir)
|
||||
result = registry.list_by_priority()
|
||||
|
||||
assert result == []
|
||||
|
||||
def test_list_by_priority_single(self, temp_dir):
|
||||
"""Test list_by_priority with single extension."""
|
||||
extensions_dir = temp_dir / "extensions"
|
||||
extensions_dir.mkdir()
|
||||
|
||||
registry = ExtensionRegistry(extensions_dir)
|
||||
registry.add("test-ext", {"version": "1.0.0", "priority": 5})
|
||||
|
||||
result = registry.list_by_priority()
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0][0] == "test-ext"
|
||||
assert result[0][1]["priority"] == 5
|
||||
|
||||
def test_list_by_priority_ordering(self, temp_dir):
|
||||
"""Test list_by_priority returns extensions sorted by priority."""
|
||||
extensions_dir = temp_dir / "extensions"
|
||||
extensions_dir.mkdir()
|
||||
|
||||
registry = ExtensionRegistry(extensions_dir)
|
||||
# Add in non-priority order
|
||||
registry.add("ext-low", {"version": "1.0.0", "priority": 20})
|
||||
registry.add("ext-high", {"version": "1.0.0", "priority": 1})
|
||||
registry.add("ext-mid", {"version": "1.0.0", "priority": 10})
|
||||
|
||||
result = registry.list_by_priority()
|
||||
|
||||
assert len(result) == 3
|
||||
# Lower priority number = higher precedence (first)
|
||||
assert result[0][0] == "ext-high"
|
||||
assert result[1][0] == "ext-mid"
|
||||
assert result[2][0] == "ext-low"
|
||||
|
||||
def test_list_by_priority_default(self, temp_dir):
|
||||
"""Test list_by_priority uses default priority of 10."""
|
||||
extensions_dir = temp_dir / "extensions"
|
||||
extensions_dir.mkdir()
|
||||
|
||||
registry = ExtensionRegistry(extensions_dir)
|
||||
# Add without explicit priority
|
||||
registry.add("ext-default", {"version": "1.0.0"})
|
||||
registry.add("ext-high", {"version": "1.0.0", "priority": 1})
|
||||
registry.add("ext-low", {"version": "1.0.0", "priority": 20})
|
||||
|
||||
result = registry.list_by_priority()
|
||||
|
||||
assert len(result) == 3
|
||||
# ext-high (1), ext-default (10), ext-low (20)
|
||||
assert result[0][0] == "ext-high"
|
||||
assert result[1][0] == "ext-default"
|
||||
assert result[2][0] == "ext-low"
|
||||
|
||||
def test_list_by_priority_invalid_priority_defaults(self, temp_dir):
|
||||
"""Malformed priority values fall back to the default priority."""
|
||||
extensions_dir = temp_dir / "extensions"
|
||||
extensions_dir.mkdir()
|
||||
|
||||
registry = ExtensionRegistry(extensions_dir)
|
||||
registry.add("ext-high", {"version": "1.0.0", "priority": 1})
|
||||
registry.data["extensions"]["ext-invalid"] = {
|
||||
"version": "1.0.0",
|
||||
"priority": "high",
|
||||
}
|
||||
registry._save()
|
||||
|
||||
result = registry.list_by_priority()
|
||||
|
||||
assert [item[0] for item in result] == ["ext-high", "ext-invalid"]
|
||||
assert result[1][1]["priority"] == 10
|
||||
|
||||
def test_install_with_priority(self, extension_dir, project_dir):
|
||||
"""Test that install_from_directory stores priority."""
|
||||
manager = ExtensionManager(project_dir)
|
||||
manager.install_from_directory(extension_dir, "0.1.0", register_commands=False, priority=5)
|
||||
|
||||
metadata = manager.registry.get("test-ext")
|
||||
assert metadata["priority"] == 5
|
||||
|
||||
def test_install_default_priority(self, extension_dir, project_dir):
|
||||
"""Test that install_from_directory uses default priority of 10."""
|
||||
manager = ExtensionManager(project_dir)
|
||||
manager.install_from_directory(extension_dir, "0.1.0", register_commands=False)
|
||||
|
||||
metadata = manager.registry.get("test-ext")
|
||||
assert metadata["priority"] == 10
|
||||
|
||||
def test_list_installed_includes_priority(self, extension_dir, project_dir):
|
||||
"""Test that list_installed includes priority in returned data."""
|
||||
manager = ExtensionManager(project_dir)
|
||||
manager.install_from_directory(extension_dir, "0.1.0", register_commands=False, priority=3)
|
||||
|
||||
installed = manager.list_installed()
|
||||
|
||||
assert len(installed) == 1
|
||||
assert installed[0]["priority"] == 3
|
||||
|
||||
def test_priority_preserved_on_update(self, temp_dir):
|
||||
"""Test that registry update preserves priority."""
|
||||
extensions_dir = temp_dir / "extensions"
|
||||
extensions_dir.mkdir()
|
||||
|
||||
registry = ExtensionRegistry(extensions_dir)
|
||||
registry.add("test-ext", {"version": "1.0.0", "priority": 5, "enabled": True})
|
||||
|
||||
# Update with new metadata (no priority specified)
|
||||
registry.update("test-ext", {"enabled": False})
|
||||
|
||||
updated = registry.get("test-ext")
|
||||
assert updated["priority"] == 5 # Preserved
|
||||
assert updated["enabled"] is False # Updated
|
||||
|
||||
def test_resolve_uses_unregistered_extension_dirs_when_registry_partially_corrupted(self, project_dir):
|
||||
"""Resolution scans unregistered extension dirs after valid registry entries."""
|
||||
extensions_dir = project_dir / ".specify" / "extensions"
|
||||
|
||||
valid_dir = extensions_dir / "valid-ext" / "templates"
|
||||
valid_dir.mkdir(parents=True)
|
||||
(valid_dir / "other-template.md").write_text("# Valid\n")
|
||||
|
||||
broken_dir = extensions_dir / "broken-ext" / "templates"
|
||||
broken_dir.mkdir(parents=True)
|
||||
(broken_dir / "target-template.md").write_text("# Broken Target\n")
|
||||
|
||||
registry = ExtensionRegistry(extensions_dir)
|
||||
registry.add("valid-ext", {"version": "1.0.0", "priority": 10})
|
||||
registry.data["extensions"]["broken-ext"] = "corrupted"
|
||||
registry._save()
|
||||
|
||||
from specify_cli.presets import PresetResolver
|
||||
|
||||
resolver = PresetResolver(project_dir)
|
||||
resolved = resolver.resolve("target-template")
|
||||
sourced = resolver.resolve_with_source("target-template")
|
||||
|
||||
assert resolved is not None
|
||||
assert resolved.name == "target-template.md"
|
||||
assert "Broken Target" in resolved.read_text()
|
||||
assert sourced is not None
|
||||
assert sourced["source"] == "extension:broken-ext (unregistered)"
|
||||
|
||||
|
||||
class TestExtensionPriorityCLI:
|
||||
"""Test extension priority CLI integration."""
|
||||
|
||||
def test_add_with_priority_option(self, extension_dir, project_dir):
|
||||
"""Test extension add command with --priority option."""
|
||||
from typer.testing import CliRunner
|
||||
from unittest.mock import patch
|
||||
from specify_cli import app
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
with patch.object(Path, "cwd", return_value=project_dir):
|
||||
result = runner.invoke(app, [
|
||||
"extension", "add", str(extension_dir), "--dev", "--priority", "3"
|
||||
])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
|
||||
manager = ExtensionManager(project_dir)
|
||||
metadata = manager.registry.get("test-ext")
|
||||
assert metadata["priority"] == 3
|
||||
|
||||
def test_list_shows_priority(self, extension_dir, project_dir):
|
||||
"""Test extension list shows priority."""
|
||||
from typer.testing import CliRunner
|
||||
from unittest.mock import patch
|
||||
from specify_cli import app
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
# Install extension with priority
|
||||
manager = ExtensionManager(project_dir)
|
||||
manager.install_from_directory(extension_dir, "0.1.0", register_commands=False, priority=7)
|
||||
|
||||
with patch.object(Path, "cwd", return_value=project_dir):
|
||||
result = runner.invoke(app, ["extension", "list"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "Priority: 7" in result.output
|
||||
|
||||
def test_set_priority_changes_priority(self, extension_dir, project_dir):
|
||||
"""Test set-priority command changes extension priority."""
|
||||
from typer.testing import CliRunner
|
||||
from unittest.mock import patch
|
||||
from specify_cli import app
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
# Install extension with default priority
|
||||
manager = ExtensionManager(project_dir)
|
||||
manager.install_from_directory(extension_dir, "0.1.0", register_commands=False)
|
||||
|
||||
# Verify default priority
|
||||
assert manager.registry.get("test-ext")["priority"] == 10
|
||||
|
||||
with patch.object(Path, "cwd", return_value=project_dir):
|
||||
result = runner.invoke(app, ["extension", "set-priority", "test-ext", "5"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "priority changed: 10 → 5" in result.output
|
||||
|
||||
# Reload registry to see updated value
|
||||
manager2 = ExtensionManager(project_dir)
|
||||
assert manager2.registry.get("test-ext")["priority"] == 5
|
||||
|
||||
def test_set_priority_same_value_no_change(self, extension_dir, project_dir):
|
||||
"""Test set-priority with same value shows already set message."""
|
||||
from typer.testing import CliRunner
|
||||
from unittest.mock import patch
|
||||
from specify_cli import app
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
# Install extension with priority 5
|
||||
manager = ExtensionManager(project_dir)
|
||||
manager.install_from_directory(extension_dir, "0.1.0", register_commands=False, priority=5)
|
||||
|
||||
with patch.object(Path, "cwd", return_value=project_dir):
|
||||
result = runner.invoke(app, ["extension", "set-priority", "test-ext", "5"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "already has priority 5" in result.output
|
||||
|
||||
def test_set_priority_invalid_value(self, extension_dir, project_dir):
|
||||
"""Test set-priority rejects invalid priority values."""
|
||||
from typer.testing import CliRunner
|
||||
from unittest.mock import patch
|
||||
from specify_cli import app
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
# Install extension
|
||||
manager = ExtensionManager(project_dir)
|
||||
manager.install_from_directory(extension_dir, "0.1.0", register_commands=False)
|
||||
|
||||
with patch.object(Path, "cwd", return_value=project_dir):
|
||||
result = runner.invoke(app, ["extension", "set-priority", "test-ext", "0"])
|
||||
|
||||
assert result.exit_code == 1, result.output
|
||||
assert "Priority must be a positive integer" in result.output
|
||||
|
||||
def test_set_priority_not_installed(self, project_dir):
|
||||
"""Test set-priority fails for non-installed extension."""
|
||||
from typer.testing import CliRunner
|
||||
from unittest.mock import patch
|
||||
from specify_cli import app
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
# Ensure .specify exists
|
||||
(project_dir / ".specify").mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with patch.object(Path, "cwd", return_value=project_dir):
|
||||
result = runner.invoke(app, ["extension", "set-priority", "nonexistent", "5"])
|
||||
|
||||
assert result.exit_code == 1, result.output
|
||||
assert "not installed" in result.output.lower() or "no extensions installed" in result.output.lower()
|
||||
|
||||
def test_set_priority_by_display_name(self, extension_dir, project_dir):
|
||||
"""Test set-priority works with extension display name."""
|
||||
from typer.testing import CliRunner
|
||||
from unittest.mock import patch
|
||||
from specify_cli import app
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
# Install extension
|
||||
manager = ExtensionManager(project_dir)
|
||||
manager.install_from_directory(extension_dir, "0.1.0", register_commands=False)
|
||||
|
||||
# Use display name "Test Extension" instead of ID "test-ext"
|
||||
with patch.object(Path, "cwd", return_value=project_dir):
|
||||
result = runner.invoke(app, ["extension", "set-priority", "Test Extension", "3"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "priority changed" in result.output
|
||||
|
||||
# Reload registry to see updated value
|
||||
manager2 = ExtensionManager(project_dir)
|
||||
assert manager2.registry.get("test-ext")["priority"] == 3
|
||||
|
||||
|
||||
class TestExtensionPriorityBackwardsCompatibility:
|
||||
"""Test backwards compatibility for extensions installed before priority feature."""
|
||||
|
||||
def test_legacy_extension_without_priority_field(self, temp_dir):
|
||||
"""Extensions installed before priority feature should default to 10."""
|
||||
extensions_dir = temp_dir / "extensions"
|
||||
extensions_dir.mkdir()
|
||||
|
||||
# Simulate legacy registry entry without priority field
|
||||
registry = ExtensionRegistry(extensions_dir)
|
||||
registry.data["extensions"]["legacy-ext"] = {
|
||||
"version": "1.0.0",
|
||||
"source": "local",
|
||||
"enabled": True,
|
||||
"installed_at": "2025-01-01T00:00:00Z",
|
||||
# No "priority" field - simulates pre-feature extension
|
||||
}
|
||||
registry._save()
|
||||
|
||||
# Reload registry
|
||||
registry2 = ExtensionRegistry(extensions_dir)
|
||||
|
||||
# list_by_priority should use default of 10
|
||||
result = registry2.list_by_priority()
|
||||
assert len(result) == 1
|
||||
assert result[0][0] == "legacy-ext"
|
||||
# Priority defaults to 10 and is normalized in returned metadata
|
||||
assert result[0][1]["priority"] == 10
|
||||
|
||||
def test_legacy_extension_in_list_installed(self, extension_dir, project_dir):
|
||||
"""list_installed returns priority=10 for legacy extensions without priority field."""
|
||||
manager = ExtensionManager(project_dir)
|
||||
|
||||
# Install extension normally
|
||||
manager.install_from_directory(extension_dir, "0.1.0", register_commands=False)
|
||||
|
||||
# Manually remove priority to simulate legacy extension
|
||||
ext_data = manager.registry.data["extensions"]["test-ext"]
|
||||
del ext_data["priority"]
|
||||
manager.registry._save()
|
||||
|
||||
# list_installed should still return priority=10
|
||||
installed = manager.list_installed()
|
||||
assert len(installed) == 1
|
||||
assert installed[0]["priority"] == 10
|
||||
|
||||
def test_mixed_legacy_and_new_extensions_ordering(self, temp_dir):
|
||||
"""Legacy extensions (no priority) sort with default=10 among prioritized extensions."""
|
||||
extensions_dir = temp_dir / "extensions"
|
||||
extensions_dir.mkdir()
|
||||
|
||||
registry = ExtensionRegistry(extensions_dir)
|
||||
|
||||
# Add extension with explicit priority=5
|
||||
registry.add("ext-with-priority", {"version": "1.0.0", "priority": 5})
|
||||
|
||||
# Add legacy extension without priority (manually)
|
||||
registry.data["extensions"]["legacy-ext"] = {
|
||||
"version": "1.0.0",
|
||||
"source": "local",
|
||||
"enabled": True,
|
||||
# No priority field
|
||||
}
|
||||
registry._save()
|
||||
|
||||
# Add extension with priority=15
|
||||
registry.add("ext-low-priority", {"version": "1.0.0", "priority": 15})
|
||||
|
||||
# Reload and check ordering
|
||||
registry2 = ExtensionRegistry(extensions_dir)
|
||||
result = registry2.list_by_priority()
|
||||
|
||||
assert len(result) == 3
|
||||
# Order: ext-with-priority (5), legacy-ext (defaults to 10), ext-low-priority (15)
|
||||
assert result[0][0] == "ext-with-priority"
|
||||
assert result[1][0] == "legacy-ext"
|
||||
assert result[2][0] == "ext-low-priority"
|
||||
|
||||
Reference in New Issue
Block a user