feat(workflow): Multiple fixes in one (#160)
* fix(workflow): Re-organized the workflow, put the scripts in their own scripts folder * fix(workflow): Fixed so that we have a default SELECT selection in the issue templates * fix(workflow): Added a way to run the issue workflows manually as well * feat(workflow): Added workflow for closing issues with no selected plugin * fix(workflow): Changed so that we only use one file for jobs that only care about the plugin folders * fix(workflows): resolve repository root after script move --------- Co-authored-by: Lemmy <studio@quadbyte.net>
This commit is contained in:
co-authored by
Lemmy
parent
68d904fcea
commit
9b2179899b
@@ -0,0 +1 @@
|
||||
PyGithub==2.9.1
|
||||
@@ -0,0 +1,40 @@
|
||||
import os
|
||||
import sys
|
||||
|
||||
from github import Auth, Github
|
||||
|
||||
token = os.environ["GITHUB_TOKEN"]
|
||||
repo_name = os.environ["REPOSITORY"]
|
||||
issue_number = os.environ["ISSUE_NUMBER"]
|
||||
|
||||
auth = Auth.Token(token)
|
||||
gh = Github(auth=auth)
|
||||
repo = gh.get_repo(repo_name)
|
||||
|
||||
if issue_number.isdigit():
|
||||
issue_number = int(issue_number)
|
||||
else:
|
||||
print("Issue number is not numeric!")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
issue = repo.get_issue(issue_number)
|
||||
body = issue.body
|
||||
lines = body.splitlines()
|
||||
|
||||
try:
|
||||
title_index = lines.index("### Plugin")
|
||||
except ValueError:
|
||||
print("No Plugin title found!")
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
for i in range(title_index + 1, len(lines)):
|
||||
if lines[i]:
|
||||
break
|
||||
|
||||
plugin = lines[i].strip()
|
||||
|
||||
if "--- SELECT ---" in plugin:
|
||||
issue.create_comment("Specify which plugin this issue is about!")
|
||||
issue.edit(state='closed', state_reason='null')
|
||||
@@ -0,0 +1,73 @@
|
||||
import os
|
||||
import sys
|
||||
|
||||
from github import Auth, Github
|
||||
|
||||
token = os.environ["GITHUB_TOKEN"]
|
||||
repo_name = os.environ["REPOSITORY"]
|
||||
issue_number = os.environ["ISSUE_NUMBER"]
|
||||
|
||||
auth = Auth.Token(token)
|
||||
gh = Github(auth=auth)
|
||||
repo = gh.get_repo(repo_name)
|
||||
|
||||
if issue_number.isdigit():
|
||||
issue_number = int(issue_number)
|
||||
else:
|
||||
print("Issue number is not numeric!")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
issue = repo.get_issue(issue_number)
|
||||
body = issue.body
|
||||
lines = body.splitlines()
|
||||
|
||||
try:
|
||||
title_index = lines.index("### Plugin")
|
||||
except ValueError:
|
||||
print("No Plugin title found!")
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
for i in range(title_index + 1, len(lines)):
|
||||
if lines[i]:
|
||||
break
|
||||
|
||||
plugin = lines[i].strip()
|
||||
plugin_split = plugin.split('/')
|
||||
|
||||
# The Plugin field is free text, so accept both the canonical "<author>/<plugin>" id and
|
||||
# a bare plugin name, and skip quietly on anything else instead of failing the run.
|
||||
if len(plugin_split) == 2:
|
||||
plugin_name = plugin_split[1].strip()
|
||||
elif len(plugin_split) == 1:
|
||||
plugin_name = plugin_split[0]
|
||||
else:
|
||||
print(f"Unknown format of plugin name, got {plugin}")
|
||||
sys.exit(0)
|
||||
|
||||
if not plugin_name:
|
||||
print("No plugin name given!")
|
||||
sys.exit(0)
|
||||
|
||||
manifest_file = f"{plugin_name}/plugin.toml"
|
||||
|
||||
if os.path.exists(manifest_file):
|
||||
file_commits = repo.get_commits(path=manifest_file).reversed
|
||||
author = file_commits[0].author
|
||||
|
||||
if author is not None:
|
||||
author = author.login
|
||||
else:
|
||||
print("Author name is null, returning!")
|
||||
sys.exit(1)
|
||||
else:
|
||||
print(f"Plugin manifest doesn't exist, {manifest_file}")
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
if author:
|
||||
issue.create_comment(f"CC @{author}")
|
||||
else:
|
||||
print("Could not get the author.")
|
||||
sys.exit(1)
|
||||
@@ -0,0 +1,62 @@
|
||||
import os
|
||||
import sys
|
||||
|
||||
from github import Auth, Github
|
||||
|
||||
token = os.environ["GITHUB_TOKEN"]
|
||||
repo_name = os.environ["REPOSITORY"]
|
||||
issue_number = os.environ["ISSUE_NUMBER"]
|
||||
|
||||
auth = Auth.Token(token)
|
||||
gh = Github(auth=auth)
|
||||
repo = gh.get_repo(repo_name)
|
||||
|
||||
if issue_number.isdigit():
|
||||
issue_number = int(issue_number)
|
||||
else:
|
||||
print("Issue number is not numeric!")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
issue = repo.get_issue(issue_number)
|
||||
body = issue.body
|
||||
lines = body.splitlines()
|
||||
|
||||
try:
|
||||
title_index = lines.index("### Plugin")
|
||||
except ValueError:
|
||||
print("No Plugin title found!")
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
for i in range(title_index + 1, len(lines)):
|
||||
if lines[i]:
|
||||
break
|
||||
|
||||
plugin = lines[i].strip()
|
||||
plugin_split = plugin.split('/')
|
||||
|
||||
# The Plugin field is free text, so accept both the canonical "<author>/<plugin>" id and
|
||||
# a bare plugin name, and skip quietly on anything else instead of failing the run.
|
||||
if len(plugin_split) == 2:
|
||||
plugin_name = plugin_split[1].strip()
|
||||
elif len(plugin_split) == 1:
|
||||
plugin_name = plugin_split[0]
|
||||
else:
|
||||
print(f"Unknown format of plugin name, got {plugin}")
|
||||
sys.exit(0)
|
||||
|
||||
if not plugin_name:
|
||||
print("No plugin name given!")
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
title = issue.title
|
||||
|
||||
if title.startswith(f"[{plugin_name}]"):
|
||||
print("Plugin name already exists in the title of the issue.")
|
||||
sys.exit(0)
|
||||
|
||||
new_title = f"[{plugin_name}]{title}"
|
||||
|
||||
issue.edit(title=new_title)
|
||||
@@ -0,0 +1,425 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
VALIDATOR_PATH = Path(__file__).with_name("validate-plugins.py")
|
||||
SPEC = importlib.util.spec_from_file_location("validate_plugins", VALIDATOR_PATH)
|
||||
assert SPEC is not None and SPEC.loader is not None
|
||||
validate_plugins = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(validate_plugins)
|
||||
|
||||
|
||||
class LauncherPrefixTests(unittest.TestCase):
|
||||
def validate_prefix(self, prefix: str) -> list[str]:
|
||||
validator = validate_plugins.Validator(Path("/repo"))
|
||||
validator.validate_launcher_fields(
|
||||
Path("/repo/example/plugin.toml"),
|
||||
"launcher_provider[0]",
|
||||
{"prefix": prefix},
|
||||
)
|
||||
return validator.errors
|
||||
|
||||
def test_accepts_lowercase_ascii_letters(self) -> None:
|
||||
self.assertEqual(self.validate_prefix("bla"), [])
|
||||
|
||||
def test_rejects_leading_symbol(self) -> None:
|
||||
self.assertNotEqual(self.validate_prefix("/bla"), [])
|
||||
|
||||
def test_rejects_uppercase_letters(self) -> None:
|
||||
self.assertNotEqual(self.validate_prefix("Bla"), [])
|
||||
|
||||
def test_rejects_digits(self) -> None:
|
||||
self.assertNotEqual(self.validate_prefix("bla2"), [])
|
||||
|
||||
def test_rejects_other_symbols(self) -> None:
|
||||
self.assertNotEqual(self.validate_prefix("bla-bla"), [])
|
||||
|
||||
|
||||
class AllowedTagsTests(unittest.TestCase):
|
||||
def validate_tags(self, tags: object) -> list[str]:
|
||||
validator = validate_plugins.Validator(Path("/repo"))
|
||||
validator.validate_tags(Path("/repo/example/plugin.toml"), tags)
|
||||
return validator.errors
|
||||
|
||||
def test_accepts_every_allowed_tag(self) -> None:
|
||||
self.assertEqual(self.validate_tags(sorted(validate_plugins.ALLOWED_TAGS)), [])
|
||||
|
||||
def test_rejects_unknown_tag(self) -> None:
|
||||
self.assertEqual(
|
||||
self.validate_tags(["utility", "unknown"]),
|
||||
[
|
||||
"example/plugin.toml: root: "
|
||||
"tags[1] 'unknown' is not an allowed tag"
|
||||
],
|
||||
)
|
||||
|
||||
def test_rejects_wrong_case(self) -> None:
|
||||
self.assertNotEqual(self.validate_tags(["Utility"]), [])
|
||||
|
||||
def test_retains_string_list_validation(self) -> None:
|
||||
errors = self.validate_tags(["utility", "utility", ""])
|
||||
self.assertTrue(any("duplicate 'utility'" in error for error in errors))
|
||||
self.assertTrue(any("tags[2] must be a non-empty string" in error for error in errors))
|
||||
|
||||
|
||||
class DescriptionTests(unittest.TestCase):
|
||||
def validate_description(self, description: object) -> list[str]:
|
||||
validator = validate_plugins.Validator(Path("/repo"))
|
||||
validator.validate_description(Path("/repo/example/plugin.toml"), description)
|
||||
return validator.errors
|
||||
|
||||
def test_accepts_description_at_limit(self) -> None:
|
||||
self.assertEqual(
|
||||
self.validate_description("x" * validate_plugins.DESCRIPTION_MAX_CHARS),
|
||||
[],
|
||||
)
|
||||
|
||||
def test_rejects_description_over_limit(self) -> None:
|
||||
errors = self.validate_description(
|
||||
"x" * (validate_plugins.DESCRIPTION_MAX_CHARS + 1)
|
||||
)
|
||||
self.assertEqual(len(errors), 1)
|
||||
self.assertIn("is 121 characters", errors[0])
|
||||
self.assertIn("at or below 120", errors[0])
|
||||
|
||||
|
||||
class PluginConfigAccessorTests(unittest.TestCase):
|
||||
def test_accepts_universal_accessor(self) -> None:
|
||||
self.assertEqual(
|
||||
validate_plugins.obsolete_config_accessors('local value = noctalia.getConfig("key")'),
|
||||
[],
|
||||
)
|
||||
|
||||
def test_rejects_every_entry_specific_alias(self) -> None:
|
||||
source = "\n".join(
|
||||
[
|
||||
'barWidget.getConfig("one")',
|
||||
'desktopWidget.getConfig("two")',
|
||||
'panel . getConfig("three")',
|
||||
'launcher.getConfig("four")',
|
||||
]
|
||||
)
|
||||
self.assertEqual(
|
||||
validate_plugins.obsolete_config_accessors(source),
|
||||
[
|
||||
("barWidget.getConfig", 1),
|
||||
("desktopWidget.getConfig", 2),
|
||||
("panel.getConfig", 3),
|
||||
("launcher.getConfig", 4),
|
||||
],
|
||||
)
|
||||
|
||||
def test_ignores_comments_and_strings(self) -> None:
|
||||
source = "\n".join(
|
||||
[
|
||||
'-- barWidget.getConfig("comment")',
|
||||
'--[[ panel.getConfig("block comment") ]]',
|
||||
'local text = "launcher.getConfig(\\\"string\\\")"',
|
||||
'local block = [[desktopWidget.getConfig("long string")]]',
|
||||
]
|
||||
)
|
||||
self.assertEqual(validate_plugins.obsolete_config_accessors(source), [])
|
||||
|
||||
|
||||
class TranslationKeyTests(unittest.TestCase):
|
||||
def test_accepts_valid_keys(self) -> None:
|
||||
for key in (
|
||||
"settings.translation_language.label",
|
||||
"settings.translation_language.options.zh-hans",
|
||||
"settings.translation_language.options.en",
|
||||
"a.b-c.d_e.f0",
|
||||
):
|
||||
with self.subTest(key=key):
|
||||
self.assertTrue(validate_plugins.is_valid_translation_key(key))
|
||||
|
||||
def test_rejects_invalid_keys(self) -> None:
|
||||
for key in (
|
||||
"settings.options.zh-Hans", # uppercase segment
|
||||
"settings.options.zh-Hant",
|
||||
"settings.Label",
|
||||
"settings._leading", # leading underscore in a segment
|
||||
"settings..options", # empty segment
|
||||
"",
|
||||
):
|
||||
with self.subTest(key=key):
|
||||
self.assertFalse(validate_plugins.is_valid_translation_key(key))
|
||||
|
||||
def test_rejects_dotted_json_object_keys(self) -> None:
|
||||
# A flat dotted key is what the i18n platform expands into nested objects; the JSON
|
||||
# source must nest instead, so an object key with a dot is invalid.
|
||||
self.assertFalse(validate_plugins.is_valid_key_segment("settings.label"))
|
||||
self.assertTrue(validate_plugins.is_valid_key_segment("eyecare-active-duration"))
|
||||
translations = {"settings.eyecare-active-duration.label": "Active Duration"}
|
||||
self.assertEqual(
|
||||
validate_plugins.invalid_translation_keys(translations),
|
||||
["settings.eyecare-active-duration.label"],
|
||||
)
|
||||
|
||||
def test_walks_nested_keys_and_reports_full_paths(self) -> None:
|
||||
translations = {
|
||||
"settings": {
|
||||
"translation_language": {
|
||||
"options": {"zh-Hans": "Simplified", "zh-Hant": "Traditional", "en": "English"}
|
||||
}
|
||||
}
|
||||
}
|
||||
self.assertEqual(
|
||||
validate_plugins.invalid_translation_keys(translations),
|
||||
[
|
||||
"settings.translation_language.options.zh-Hans",
|
||||
"settings.translation_language.options.zh-Hant",
|
||||
],
|
||||
)
|
||||
|
||||
def validate_keys(self, translations: object) -> list[str]:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
plugin_dir = Path(directory) / "example"
|
||||
plugin_dir.mkdir()
|
||||
validator = validate_plugins.Validator(Path(directory))
|
||||
validator.validate_translation_keys(plugin_dir, translations)
|
||||
return validator.errors
|
||||
|
||||
def test_reports_bad_json_keys_without_a_reference(self) -> None:
|
||||
errors = self.validate_keys({"settings": {"options": {"zh-Hans": "Simplified"}}})
|
||||
self.assertEqual(len(errors), 1)
|
||||
self.assertIn("settings.options.zh-Hans", errors[0])
|
||||
self.assertIn("invalid translation key format", errors[0])
|
||||
|
||||
def test_accepts_valid_json_keys(self) -> None:
|
||||
self.assertEqual(self.validate_keys({"settings": {"options": {"zh-hans": "Simplified"}}}), [])
|
||||
|
||||
def validate_reference(self, label_key: object) -> list[str]:
|
||||
validator = validate_plugins.Validator(Path("/repo"))
|
||||
validator.validate_translation_key(
|
||||
Path("/repo/example/plugin.toml"),
|
||||
{label_key: "x"} if isinstance(label_key, str) else {},
|
||||
"setting[0]",
|
||||
"label_key",
|
||||
label_key,
|
||||
)
|
||||
return validator.errors
|
||||
|
||||
def test_rejects_badly_formatted_reference(self) -> None:
|
||||
errors = self.validate_reference("settings.options.zh-Hans")
|
||||
self.assertEqual(len(errors), 1)
|
||||
self.assertIn("is not a valid translation key", errors[0])
|
||||
|
||||
def test_accepts_well_formatted_existing_reference(self) -> None:
|
||||
self.assertEqual(self.validate_reference("settings.options.zh-hans"), [])
|
||||
|
||||
|
||||
class ReadmeTests(unittest.TestCase):
|
||||
MANIFEST = {
|
||||
"id": "me/example",
|
||||
"dependencies": ["example-cli"],
|
||||
"setting": [{"key": "interval"}],
|
||||
"widget": [{"id": "widget", "entry": "widget.luau"}],
|
||||
"panel": [{"id": "panel", "entry": "panel.luau"}],
|
||||
"launcher_provider": [
|
||||
{"id": "search", "entry": "launcher.luau", "prefix": "ex"}
|
||||
],
|
||||
}
|
||||
|
||||
VALID_README = """# Example
|
||||
|
||||
Example provides a useful widget, panel, and launcher for demonstration purposes.
|
||||
|
||||
## Plugin
|
||||
|
||||
| Field | Value |
|
||||
| --- | --- |
|
||||
| ID | `me/example` |
|
||||
| Entries | Widget: `widget`; panel: `panel`; launcher: `search` |
|
||||
| Launcher Prefix | `/ex` |
|
||||
|
||||
## Requirements
|
||||
|
||||
Install `example-cli` on `PATH`.
|
||||
|
||||
## Usage
|
||||
|
||||
Add the widget, type `/ex`, or open the panel:
|
||||
|
||||
```sh
|
||||
noctalia msg panel-toggle me/example:panel
|
||||
```
|
||||
|
||||
## Settings
|
||||
|
||||
Configure the update interval in plugin settings.
|
||||
"""
|
||||
|
||||
def validate_readme(self, contents: str) -> list[str]:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
plugin_dir = root / "example"
|
||||
plugin_dir.mkdir()
|
||||
(plugin_dir / "README.md").write_text(contents, encoding="utf-8")
|
||||
validator = validate_plugins.Validator(root)
|
||||
validator.validate_readme(plugin_dir, self.MANIFEST)
|
||||
return validator.errors
|
||||
|
||||
def test_accepts_official_plugin_readme_structure(self) -> None:
|
||||
self.assertEqual(self.validate_readme(self.VALID_README), [])
|
||||
|
||||
def test_requires_core_sections_and_intro(self) -> None:
|
||||
errors = self.validate_readme("# Example\n\nToo short.\n")
|
||||
self.assertTrue(any("short introduction" in error for error in errors))
|
||||
self.assertTrue(any("## Plugin" in error for error in errors))
|
||||
self.assertTrue(any("## Usage" in error for error in errors))
|
||||
|
||||
def test_headings_inside_code_fences_do_not_satisfy_sections(self) -> None:
|
||||
errors = self.validate_readme(
|
||||
"# Example\n\nA sufficiently descriptive introduction for this example plugin.\n\n"
|
||||
"```md\n## Plugin\n## Usage\n```\n"
|
||||
)
|
||||
self.assertTrue(any("## Plugin" in error for error in errors))
|
||||
self.assertTrue(any("## Usage" in error for error in errors))
|
||||
|
||||
def test_derives_documented_values_from_manifest(self) -> None:
|
||||
readme = self.VALID_README
|
||||
replacements = {
|
||||
"`me/example`": "`me/wrong`",
|
||||
"`widget`": "`other-widget`",
|
||||
"noctalia msg panel-toggle me/example:panel": "noctalia msg panel-toggle me/example:wrong",
|
||||
"`/ex`": "`/wrong`",
|
||||
"`example-cli`": "`other-cli`",
|
||||
}
|
||||
for old, new in replacements.items():
|
||||
with self.subTest(missing=old):
|
||||
errors = self.validate_readme(readme.replace(old, new))
|
||||
self.assertTrue(errors)
|
||||
|
||||
def test_requires_conditional_sections(self) -> None:
|
||||
without_requirements = self.VALID_README.replace(
|
||||
"## Requirements\n\nInstall `example-cli` on `PATH`.\n\n", ""
|
||||
)
|
||||
without_settings = self.VALID_README.replace(
|
||||
"## Settings\n\nConfigure the update interval in plugin settings.\n", ""
|
||||
)
|
||||
self.assertTrue(
|
||||
any("## Requirements" in error for error in self.validate_readme(without_requirements))
|
||||
)
|
||||
self.assertTrue(any("## Settings" in error for error in self.validate_readme(without_settings)))
|
||||
|
||||
|
||||
class SettingTypeTests(unittest.TestCase):
|
||||
TRANSLATIONS = {"settings": {"value": {"label": "Value"}}}
|
||||
|
||||
def validate_setting(self, setting: dict, plugin_api: object = 6) -> list[str]:
|
||||
validator = validate_plugins.Validator(Path("/repo"))
|
||||
validator.validate_settings(
|
||||
Path("/repo/example/plugin.toml"),
|
||||
self.TRANSLATIONS,
|
||||
[{"key": "value", "label_key": "settings.value.label", **setting}],
|
||||
"setting",
|
||||
plugin_api,
|
||||
)
|
||||
return validator.errors
|
||||
|
||||
def test_setting_type_catalog_matches_shell_schema(self) -> None:
|
||||
self.assertEqual(
|
||||
validate_plugins.SETTING_TYPES,
|
||||
{
|
||||
"string",
|
||||
"string_list",
|
||||
"string_map",
|
||||
"bool",
|
||||
"int",
|
||||
"double",
|
||||
"select",
|
||||
"file",
|
||||
"folder",
|
||||
"glyph",
|
||||
"color",
|
||||
},
|
||||
)
|
||||
|
||||
def test_accepts_double_with_numeric_bounds(self) -> None:
|
||||
self.assertEqual(
|
||||
self.validate_setting(
|
||||
{"type": "double", "default": 0.5, "min": 0.0, "max": 1.0, "step": 0.05}
|
||||
),
|
||||
[],
|
||||
)
|
||||
|
||||
def test_rejects_invalid_double_default(self) -> None:
|
||||
errors = self.validate_setting({"type": "double", "default": "fast"})
|
||||
self.assertTrue(any("default must be a finite number" in error for error in errors))
|
||||
|
||||
def test_rejects_invalid_double_range(self) -> None:
|
||||
errors = self.validate_setting(
|
||||
{"type": "double", "default": 0.5, "min": 1.0, "max": 0.0}
|
||||
)
|
||||
self.assertTrue(any("min must be less than or equal to max" in error for error in errors))
|
||||
|
||||
def test_accepts_string_map(self) -> None:
|
||||
self.assertEqual(
|
||||
self.validate_setting(
|
||||
{"type": "string_map", "default": {"eDP-1": "laptop", "DP-1": "monitor"}}
|
||||
),
|
||||
[],
|
||||
)
|
||||
|
||||
def test_rejects_non_string_map_value(self) -> None:
|
||||
errors = self.validate_setting({"type": "string_map", "default": {"eDP-1": 1}})
|
||||
self.assertTrue(any("default.eDP-1 must be a string" in error for error in errors))
|
||||
|
||||
def test_string_map_requires_plugin_api_6(self) -> None:
|
||||
errors = self.validate_setting({"type": "string_map", "default": {}}, plugin_api=5)
|
||||
self.assertTrue(any("string_map requires plugin_api >= 6" in error for error in errors))
|
||||
|
||||
|
||||
class WidgetActionsTests(unittest.TestCase):
|
||||
def validate_actions(self, entry: dict, plugin_api: object = 14) -> list[str]:
|
||||
validator = validate_plugins.Validator(Path("/repo"))
|
||||
validator.validate_widget_fields(
|
||||
Path("/repo/example/plugin.toml"),
|
||||
"widget[0]",
|
||||
entry,
|
||||
plugin_api,
|
||||
)
|
||||
return validator.errors
|
||||
|
||||
def test_accepts_every_gesture(self) -> None:
|
||||
actions = {gesture: "volume-mute" for gesture in validate_plugins.WIDGET_GESTURES}
|
||||
self.assertEqual(self.validate_actions({"actions": actions}), [])
|
||||
|
||||
def test_accepts_exec_and_none(self) -> None:
|
||||
self.assertEqual(
|
||||
self.validate_actions({"actions": {"middle": "exec playerctl pause", "right": "none"}}),
|
||||
[],
|
||||
)
|
||||
|
||||
def test_entry_without_actions_is_fine(self) -> None:
|
||||
self.assertEqual(self.validate_actions({"id": "bar"}), [])
|
||||
|
||||
def test_rejects_unknown_gesture(self) -> None:
|
||||
self.assertNotEqual(self.validate_actions({"actions": {"ctrl+left": "volume-mute"}}), [])
|
||||
|
||||
def test_rejects_non_table(self) -> None:
|
||||
self.assertNotEqual(self.validate_actions({"actions": "volume-mute"}), [])
|
||||
|
||||
def test_rejects_non_string_action(self) -> None:
|
||||
self.assertNotEqual(self.validate_actions({"actions": {"middle": 42}}), [])
|
||||
|
||||
def test_rejects_empty_action(self) -> None:
|
||||
self.assertNotEqual(self.validate_actions({"actions": {"middle": ""}}), [])
|
||||
|
||||
def test_rejects_bare_exec(self) -> None:
|
||||
self.assertNotEqual(self.validate_actions({"actions": {"middle": "exec"}}), [])
|
||||
|
||||
def test_requires_plugin_api_14(self) -> None:
|
||||
errors = self.validate_actions({"actions": {"middle": "volume-mute"}}, plugin_api=13)
|
||||
self.assertTrue(any("plugin_api >= 14" in error for error in errors))
|
||||
|
||||
def test_widget_entry_accepts_actions_field(self) -> None:
|
||||
self.assertIn("actions", validate_plugins.ENTRY_FIELDS["widget"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,279 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT_DIR = Path(__file__).resolve().parents[3]
|
||||
CATALOG_PATH = ROOT_DIR / "catalog.toml"
|
||||
REQUIRED_FIELDS = ("id", "name", "version", "author", "plugin_api", "tags")
|
||||
OPTIONAL_STRING_FIELDS = ("license", "icon", "description")
|
||||
OPTIONAL_BOOL_FIELDS = ("deprecated",)
|
||||
|
||||
# Oldest plugin API any supported Noctalia accepts (kOldestSupportedPluginApiVersion in the
|
||||
# shell's src/scripting/plugin_api.h). Release rows below it can never be installed, so the
|
||||
# history walk stops there.
|
||||
OLDEST_SUPPORTED_PLUGIN_API = 3
|
||||
|
||||
|
||||
def git_commit_time(path: Path, *extra_args: str) -> int | None:
|
||||
"""Commit time in Unix seconds, or None when `path` has no matching commit.
|
||||
|
||||
An uncommitted plugin has no history, so `git log` prints nothing.
|
||||
"""
|
||||
stdout = subprocess.run(
|
||||
["git", "log", "-1", *extra_args, "--format=%ct", "--", path],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
).stdout.strip()
|
||||
return int(stdout) if stdout else None
|
||||
|
||||
|
||||
def git_output(*args: str) -> str:
|
||||
return subprocess.run(
|
||||
["git", *args], capture_output=True, text=True, check=True
|
||||
).stdout
|
||||
|
||||
|
||||
def plugin_history(subdir: str) -> list[tuple[str, int, dict]]:
|
||||
"""Every readable revision of `<subdir>/plugin.toml`, newest first.
|
||||
|
||||
One `git show` per revision, and both the release ladder and the per-version release
|
||||
dates come out of this single walk. (A directory rename starts the history over, the
|
||||
same way it resets `added_at`.)
|
||||
"""
|
||||
history = []
|
||||
revisions = git_output(
|
||||
"log", "--format=%H %ct", "--", f"{subdir}/plugin.toml"
|
||||
).splitlines()
|
||||
|
||||
for line in revisions:
|
||||
revision, _, commit_time = line.partition(" ")
|
||||
try:
|
||||
manifest = tomllib.loads(
|
||||
git_output("show", f"{revision}:{subdir}/plugin.toml")
|
||||
)
|
||||
except (subprocess.CalledProcessError, tomllib.TOMLDecodeError):
|
||||
continue # unreadable or pre-`plugin_api` history
|
||||
history.append((revision, int(commit_time), manifest))
|
||||
|
||||
return history
|
||||
|
||||
|
||||
def release_times(history: list[tuple[str, int, dict]]) -> dict[str, int]:
|
||||
"""When each version string was first committed, keyed by version.
|
||||
|
||||
The earliest commit carrying a version is the bump that released it. A later commit
|
||||
editing plugin.toml without bumping (tags, description, translations) must not move the
|
||||
date, and a version that reappears after a revert keeps its original one.
|
||||
"""
|
||||
times: dict[str, int] = {}
|
||||
for _, commit_time, manifest in reversed(history): # oldest first
|
||||
version = manifest.get("version")
|
||||
if isinstance(version, str) and version:
|
||||
times.setdefault(version, commit_time)
|
||||
return times
|
||||
|
||||
|
||||
def release_history(
|
||||
history: list[tuple[str, int, dict]], tip_api: int, released: dict[str, int]
|
||||
) -> list[dict]:
|
||||
"""Older revisions of a plugin, one per API level below the tip's, newest first.
|
||||
|
||||
A Noctalia below the tip's `plugin_api` has nothing to install unless the catalog names
|
||||
a revision it can run. Walking `<subdir>/plugin.toml` newest-first and keeping only the
|
||||
revisions that lower the API level yields a strictly decreasing sequence, so each row is
|
||||
the newest revision at or below its own level -- exactly what the host resolves against.
|
||||
"""
|
||||
releases = []
|
||||
lowest_api = tip_api
|
||||
|
||||
for revision, _, manifest in history:
|
||||
if lowest_api <= OLDEST_SUPPORTED_PLUGIN_API:
|
||||
break
|
||||
|
||||
plugin_api = manifest.get("plugin_api")
|
||||
version = manifest.get("version")
|
||||
if not isinstance(plugin_api, int) or isinstance(plugin_api, bool):
|
||||
continue
|
||||
if not isinstance(version, str) or not version:
|
||||
continue
|
||||
if plugin_api >= lowest_api or plugin_api < OLDEST_SUPPORTED_PLUGIN_API:
|
||||
continue
|
||||
|
||||
# `rev` is the newest revision still on this API level, not necessarily the bump
|
||||
# commit, so the date comes from the version rather than from `rev` itself.
|
||||
releases.append(
|
||||
{
|
||||
"plugin_api": plugin_api,
|
||||
"version": version,
|
||||
"rev": revision,
|
||||
"updated_at": released[version],
|
||||
}
|
||||
)
|
||||
lowest_api = plugin_api
|
||||
|
||||
return releases
|
||||
|
||||
|
||||
def load_plugin_manifest(path: Path) -> dict:
|
||||
with path.open("rb") as handle:
|
||||
manifest = tomllib.load(handle)
|
||||
|
||||
missing = [field for field in REQUIRED_FIELDS if field not in manifest]
|
||||
if missing:
|
||||
missing_fields = ", ".join(missing)
|
||||
raise ValueError(f"{path.relative_to(ROOT_DIR)} is missing: {missing_fields}")
|
||||
|
||||
plugin_api = manifest["plugin_api"]
|
||||
if not isinstance(plugin_api, int) or isinstance(plugin_api, bool) or plugin_api <= 0:
|
||||
raise ValueError(
|
||||
f"{path.relative_to(ROOT_DIR)} has invalid plugin_api; expected a positive integer"
|
||||
)
|
||||
|
||||
if not isinstance(manifest["tags"], list) or not all(
|
||||
isinstance(tag, str) for tag in manifest["tags"]
|
||||
):
|
||||
raise ValueError(f"{path.relative_to(ROOT_DIR)} has invalid tags; expected strings")
|
||||
|
||||
out = {field: manifest[field] for field in REQUIRED_FIELDS}
|
||||
for field in OPTIONAL_STRING_FIELDS:
|
||||
if field in manifest:
|
||||
if not isinstance(manifest[field], str):
|
||||
raise ValueError(f"{path.relative_to(ROOT_DIR)} has invalid {field}; expected string")
|
||||
out[field] = manifest[field]
|
||||
for field in OPTIONAL_BOOL_FIELDS:
|
||||
if field in manifest:
|
||||
if not isinstance(manifest[field], bool):
|
||||
raise ValueError(f"{path.relative_to(ROOT_DIR)} has invalid {field}; expected bool")
|
||||
out[field] = manifest[field]
|
||||
|
||||
# Git dates a committed plugin, and is stable across checkouts. Anything git cannot date
|
||||
# falls back to the file's mtime: an uncommitted plugin still gets a sensible entry so the
|
||||
# catalog can be generated mid-development. (A rename also breaks the link to the commit
|
||||
# that first added the file, which is why added_at falls back too.)
|
||||
# `updated_at` is only the last plugin.toml touch here; discover_plugins replaces it with
|
||||
# the date `version` was actually bumped once the file's history has been walked.
|
||||
mtime = int(path.stat().st_mtime)
|
||||
out["updated_at"] = git_commit_time(path) or mtime
|
||||
out["added_at"] = git_commit_time(path, "--diff-filter=A") or out["updated_at"]
|
||||
|
||||
return out
|
||||
|
||||
|
||||
def existing_catalog_order() -> dict[str, int]:
|
||||
if not CATALOG_PATH.exists():
|
||||
return {}
|
||||
|
||||
content = CATALOG_PATH.read_text(encoding="utf-8")
|
||||
ids = re.findall(r'(?m)^id\s*=\s*"([^"]+)"', content)
|
||||
return {plugin_id: index for index, plugin_id in enumerate(ids)}
|
||||
|
||||
|
||||
def discover_plugins() -> list[dict]:
|
||||
order = existing_catalog_order()
|
||||
plugins = []
|
||||
|
||||
for manifest_path in sorted(ROOT_DIR.glob("*/plugin.toml")):
|
||||
manifest = load_plugin_manifest(manifest_path)
|
||||
directory = manifest_path.parent.name
|
||||
history = plugin_history(directory)
|
||||
released = release_times(history)
|
||||
manifest["_directory"] = directory
|
||||
manifest["_order"] = order.get(manifest["id"], len(order))
|
||||
# A bump that is not committed yet has no commit to date it, so the last touch stands.
|
||||
manifest["updated_at"] = released.get(manifest["version"], manifest["updated_at"])
|
||||
manifest["releases"] = release_history(history, manifest["plugin_api"], released)
|
||||
plugins.append(manifest)
|
||||
|
||||
plugins.sort(key=lambda plugin: (plugin["_order"], plugin["_directory"]))
|
||||
return plugins
|
||||
|
||||
|
||||
def toml_string(value: str) -> str:
|
||||
return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"'
|
||||
|
||||
|
||||
def toml_bool(value: bool) -> str:
|
||||
return "true" if value else "false"
|
||||
|
||||
|
||||
def render_catalog(plugins: list[dict]) -> str:
|
||||
lines = [
|
||||
"# This file is auto-generated. Do not edit manually.",
|
||||
"# Do not include it in your commit.",
|
||||
"# Noctalia plugins catalog.",
|
||||
"# Index of every plugin this source ships: the minimum needed to render, search,",
|
||||
"# and compat-check the list. The per-plugin plugin.toml stays authoritative; the",
|
||||
"# host re-reads it on enable. Keep one [[plugin]] row per plugin subdirectory.",
|
||||
"# A [[plugin.release]] row names an older revision for a Noctalia below the tip's",
|
||||
"# plugin_api, so an older release stays installable instead of the plugin vanishing.",
|
||||
"# On every row, updated_at dates the commit that first shipped that row's version.",
|
||||
"",
|
||||
]
|
||||
|
||||
for index, plugin in enumerate(plugins):
|
||||
if index:
|
||||
lines.append("")
|
||||
|
||||
lines.extend(
|
||||
[
|
||||
"[[plugin]]",
|
||||
f"id = {toml_string(plugin['id'])}",
|
||||
f"name = {toml_string(plugin['name'])}",
|
||||
f"version = {toml_string(plugin['version'])}",
|
||||
f"updated_at = {plugin['updated_at']}",
|
||||
f"added_at = {plugin['added_at']}",
|
||||
f"author = {toml_string(plugin['author'])}",
|
||||
]
|
||||
)
|
||||
if "license" in plugin:
|
||||
lines.append(f"license = {toml_string(plugin['license'])}")
|
||||
if "icon" in plugin:
|
||||
lines.append(f"icon = {toml_string(plugin['icon'])}")
|
||||
if "description" in plugin:
|
||||
lines.append(f"description = {toml_string(plugin['description'])}")
|
||||
if "deprecated" in plugin:
|
||||
lines.append(f"deprecated = {toml_bool(plugin['deprecated'])}")
|
||||
lines.extend(
|
||||
[
|
||||
f"plugin_api = {plugin['plugin_api']}",
|
||||
"tags = ["
|
||||
+ ", ".join(toml_string(tag) for tag in plugin["tags"])
|
||||
+ "]",
|
||||
]
|
||||
)
|
||||
for release in plugin["releases"]:
|
||||
lines.extend(
|
||||
[
|
||||
"",
|
||||
"[[plugin.release]]",
|
||||
f"plugin_api = {release['plugin_api']}",
|
||||
f"version = {toml_string(release['version'])}",
|
||||
f"updated_at = {release['updated_at']}",
|
||||
f"rev = {toml_string(release['rev'])}",
|
||||
]
|
||||
)
|
||||
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
plugins = discover_plugins()
|
||||
CATALOG_PATH.write_text(render_catalog(plugins), encoding="utf-8")
|
||||
print(f"Updated {CATALOG_PATH.relative_to(ROOT_DIR)} with {len(plugins)} plugin(s).")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
raise SystemExit(main())
|
||||
except Exception as error:
|
||||
print(f"error: {error}", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
@@ -0,0 +1,271 @@
|
||||
def get_plugins() -> list[str]:
|
||||
from pathlib import Path
|
||||
|
||||
return sorted(manifest.parent.name for manifest in Path(".").glob("*/plugin.toml"))
|
||||
|
||||
|
||||
def build_dropdown_component(plugins: list[str]) -> str:
|
||||
return f"""\
|
||||
- type: dropdown
|
||||
id: plugin-id
|
||||
attributes:
|
||||
label: Plugin
|
||||
description: The plugin id.
|
||||
default: 0
|
||||
options:
|
||||
- --- SELECT ---
|
||||
{
|
||||
"".join([f"""\
|
||||
- {plugin}
|
||||
""" for plugin in plugins])
|
||||
}
|
||||
validations:
|
||||
required: true
|
||||
"""
|
||||
|
||||
|
||||
def build_bug_report(plugins: list[str]) -> str:
|
||||
return f"""\
|
||||
name: Bug Report
|
||||
description: Report a bug in a community plugin
|
||||
title: "[BUG] "
|
||||
labels: ["bug"]
|
||||
|
||||
body:
|
||||
- type: checkboxes
|
||||
id: submission-checklist
|
||||
attributes:
|
||||
label: Submission checklist
|
||||
description: Please confirm the following before submitting.
|
||||
options:
|
||||
- label: I have searched existing issues and confirmed this is not a duplicate.
|
||||
required: true
|
||||
- label: I am using the latest available version of Noctalia and of the plugin.
|
||||
required: true
|
||||
- label: This is a bug in a community plugin, not in Noctalia itself (those belong in the noctalia repo).
|
||||
required: true
|
||||
|
||||
{build_dropdown_component(plugins)}
|
||||
|
||||
- type: input
|
||||
id: plugin-version
|
||||
attributes:
|
||||
label: Plugin version
|
||||
description: The version shown in the plugin's plugin.toml.
|
||||
placeholder: "1.0.0"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: Bug description
|
||||
description: A clear and concise description of the issue.
|
||||
placeholder: Describe the problem...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: steps
|
||||
attributes:
|
||||
label: Steps to reproduce
|
||||
description: Steps required to reproduce the issue.
|
||||
placeholder: |
|
||||
1. Enable ...
|
||||
2. Click ...
|
||||
3. Observe ...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: expected
|
||||
attributes:
|
||||
label: Expected behavior
|
||||
description: What did you expect to happen?
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: actual
|
||||
attributes:
|
||||
label: Actual behavior
|
||||
description: What actually happened?
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Logs / error output
|
||||
description: |
|
||||
Paste any relevant logs here. Plugin errors are logged by the shell.
|
||||
|
||||
Large outputs can be wrapped in a `<details>` block.
|
||||
render: text
|
||||
|
||||
- type: input
|
||||
id: noctalia-version
|
||||
attributes:
|
||||
label: Noctalia version
|
||||
description: Enter the version shown by Noctalia, or the commit hash if you are running a development build.
|
||||
placeholder: "v5.x.x (commit hash)"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: compositor
|
||||
attributes:
|
||||
label: Compositor
|
||||
description: Select the compositor where the issue occurs.
|
||||
options:
|
||||
- Niri
|
||||
- Hyprland
|
||||
- Sway
|
||||
- Scroll
|
||||
- Labwc
|
||||
- Mango
|
||||
- Other
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: environment
|
||||
attributes:
|
||||
label: Environment information
|
||||
description: |
|
||||
Anything else about your system that matters: distribution, installation method, and any external
|
||||
command the plugin depends on (and its version).
|
||||
|
||||
If you selected `Other` for compositor, please specify it here.
|
||||
render: text
|
||||
|
||||
- type: textarea
|
||||
id: additional
|
||||
attributes:
|
||||
label: Additional context
|
||||
description: |
|
||||
Add any other context, screenshots, or relevant information here.
|
||||
"""
|
||||
|
||||
|
||||
def build_feature_request(plugins: list[str]) -> str:
|
||||
return f"""\
|
||||
name: Feature Request
|
||||
description: Suggest an improvement to a community plugin
|
||||
title: "[FEATURE] "
|
||||
labels: ["feature"]
|
||||
|
||||
body:
|
||||
- type: checkboxes
|
||||
id: submission-checklist
|
||||
attributes:
|
||||
label: Submission checklist
|
||||
description: Please confirm the following before submitting.
|
||||
options:
|
||||
- label: I have searched existing issues and confirmed this has not been requested before.
|
||||
required: true
|
||||
- label: I have checked existing pull requests for similar changes.
|
||||
required: true
|
||||
- label: This is about a community plugin, not about Noctalia itself (those belong in the noctalia repo).
|
||||
required: true
|
||||
|
||||
{build_dropdown_component(plugins)}
|
||||
|
||||
- type: dropdown
|
||||
id: feature-type
|
||||
attributes:
|
||||
label: Feature type
|
||||
description: What kind of feature or improvement is this?
|
||||
options:
|
||||
- UI / visual improvement
|
||||
- New functionality
|
||||
- Performance improvement
|
||||
- Configuration / customization
|
||||
- Accessibility improvement
|
||||
- Integration support
|
||||
- Documentation improvement
|
||||
- Other
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: summary
|
||||
attributes:
|
||||
label: Feature summary
|
||||
description: A concise description of the feature or enhancement.
|
||||
placeholder: What would you like to see added or changed?
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: motivation
|
||||
attributes:
|
||||
label: Motivation / use case
|
||||
description: |
|
||||
Why would this feature be useful?
|
||||
What problem does it solve or improve?
|
||||
placeholder: Explain the benefit or real-world use case...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: proposed-solution
|
||||
attributes:
|
||||
label: Proposed solution
|
||||
description: |
|
||||
Describe how you think this could work.
|
||||
|
||||
Mockups, examples, screenshots, or references are welcome.
|
||||
placeholder: Describe your idea...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: alternatives
|
||||
attributes:
|
||||
label: Alternatives considered
|
||||
description: |
|
||||
Have you considered any alternative solutions or workarounds?
|
||||
placeholder: Optional...
|
||||
|
||||
- type: textarea
|
||||
id: references
|
||||
attributes:
|
||||
label: References / related projects
|
||||
description: |
|
||||
Link any related projects, concepts, screenshots, issues, or examples here.
|
||||
placeholder: |
|
||||
https://github.com/...
|
||||
https://example.com/...
|
||||
|
||||
- type: textarea
|
||||
id: additional
|
||||
attributes:
|
||||
label: Additional context
|
||||
description: |
|
||||
Add any additional information, screenshots, mockups, or context here.
|
||||
"""
|
||||
|
||||
|
||||
def replace_templates():
|
||||
print("Starting to replace templates...")
|
||||
|
||||
plugins = get_plugins()
|
||||
|
||||
bug_report_string = build_bug_report(plugins)
|
||||
feature_request_string = build_feature_request(plugins)
|
||||
|
||||
with open(".github/ISSUE_TEMPLATE/bug_report.yml", "wt") as file:
|
||||
print("Replacing bug_report.yml")
|
||||
file.write(bug_report_string)
|
||||
file.close()
|
||||
|
||||
with open(".github/ISSUE_TEMPLATE/feature_request.yml", "wt") as file:
|
||||
print("Replacing feature_request.yml")
|
||||
file.write(feature_request_string)
|
||||
file.close()
|
||||
|
||||
print("Replacing complete...")
|
||||
|
||||
if __name__ == "__main__":
|
||||
replace_templates()
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user