# Copyright Kevin Deldycke <[email protected]> and contributors.
#
# This program is Free Software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
"""Tests for the label rules in :mod:`repomatic.labels`.
A rule is one label mapped to a list of patterns: keywords or regexes over a
thread's text, globs over a pull request's changed paths. These tests pin the
resolution semantics (project entries overlay the bundled defaults per label),
the pattern compiler, and the two matchers.
"""
from __future__ import annotations
import json
import logging
from pathlib import Path
from unittest.mock import patch
import pytest
import tomlrt
from click.testing import CliRunner
from repomatic import labels as labels_module
from repomatic.cli.main import repomatic
from repomatic.config import Config, LabelsConfig, load_repomatic_config
from repomatic.github.actions import get_github_event
from repomatic.labels import (
DEFAULT_CONTENT_RULES,
DEFAULT_FILE_RULES,
apply_labels,
compile_content_pattern,
match_content_rules,
match_file_rules,
resolve_content_rules,
resolve_file_rules,
serialize_inline_labels,
)
from repomatic.sync_ops import DEPENDENCY_LABEL
from repomatic.tooling.bundle import get_data_content
def _make_config(**labels_kwargs):
return Config(labels=LabelsConfig(**labels_kwargs))
# -- Resolving rules over the defaults ----------------------------------------
[docs]
@pytest.mark.parametrize(
"resolver",
(resolve_content_rules, resolve_file_rules),
ids=("content", "file"),
)
def test_defaults_resolve_without_a_config(resolver):
"""The bundled defaults stand alone: no project config needed to match."""
rules = resolver()
assert rules
assert all(isinstance(patterns, tuple) and patterns for patterns in rules.values())
[docs]
@pytest.mark.parametrize(
("resolver", "field", "defaults", "default_label"),
(
(resolve_content_rules, "content_rules", DEFAULT_CONTENT_RULES, "🐛 bug"),
(resolve_file_rules, "file_rules", DEFAULT_FILE_RULES, "🔗 dependencies"),
),
ids=("content", "file"),
)
def test_project_entries_overlay_the_defaults(resolver, field, defaults, default_label):
"""A new label adds; a default label's entry is replaced, not merged."""
rules = resolver(
_make_config(**{
field: {
"🥭 mango": ["mango", "papaya"],
default_label: ["crash"],
},
})
)
assert rules["🥭 mango"] == ("mango", "papaya")
# Replacement, so a project can correct a default, not just widen it.
assert rules[default_label] == ("crash",)
# Untouched defaults keep flowing from upstream.
assert rules["🆙 changelog"] == defaults["🆙 changelog"]
[docs]
def test_empty_list_disables_a_default_label():
"""`"🐛 bug" = []` is the off switch for a bundled rule."""
rules = resolve_content_rules(_make_config(content_rules={"🐛 bug": []}))
assert "🐛 bug" not in rules
assert match_content_rules(rules, "A bug and a traceback") == set()
[docs]
def test_bare_string_is_a_single_pattern():
"""`"🥭 mango" = "mango"` reads as the one-element list it means."""
rules = resolve_content_rules(_make_config(content_rules={"🥭 mango": "mango"}))
assert rules["🥭 mango"] == ("mango",)
[docs]
def test_stale_array_of_tables_degrades_to_defaults(caplog):
"""The pre-7.11.0 list shape is reported, then ignored.
The config loader passes a mistyped value through rather than crashing, so
a downstream repo that has not migrated keeps every repomatic command
working and falls back to the bundled defaults.
"""
stale = [{"label": "🥭 mango", "patterns": ["mango"]}]
with caplog.at_level(logging.WARNING):
rules = resolve_content_rules(_make_config(content_rules=stale))
assert rules == DEFAULT_CONTENT_RULES
assert any("array-of-tables" in record.message for record in caplog.records)
[docs]
@pytest.mark.parametrize(
("overrides", "warning"),
(
pytest.param({" ": ["mango"]}, "blank label", id="blank-label"),
pytest.param({"🥭 mango": 42}, "expected a list", id="scalar"),
pytest.param({"🥭 mango": ["ok", 42]}, "expected a list", id="mixed-list"),
),
)
def test_unusable_entries_are_skipped_loudly(caplog, overrides, warning):
"""A malformed entry is dropped with a warning, never a crash."""
with caplog.at_level(logging.WARNING):
rules = resolve_content_rules(_make_config(content_rules=overrides))
assert "🥭 mango" not in rules
assert any(warning in record.message for record in caplog.records)
# -- Compiling content patterns -----------------------------------------------
[docs]
@pytest.mark.parametrize(
("pattern", "text", "expected"),
(
# Bare keyword: case-insensitive, word-anchored.
pytest.param("bug", "a BUG report", True, id="keyword-ignorecase"),
pytest.param("fix", "prefix handling", False, id="keyword-word-anchored"),
pytest.param("fix", "Fix the crash", True, id="keyword-at-edge"),
# A non-word edge takes no anchor: `.lock` matches inside `uv.lock`.
pytest.param(".lock", "the uv.lock file", True, id="dot-edge-unanchored"),
pytest.param(".lock", "unlock it", False, id="dot-is-literal"),
pytest.param("pyproject.toml", "in pyproject.toml", True, id="escaped-dot"),
pytest.param("pyproject.toml", "pyprojectXtoml", False, id="no-regex-dot"),
pytest.param("alpine linux", "On Alpine Linux 3.20", True, id="spaced"),
# Slashed form: a raw regex, flags opt-in.
pytest.param("/mango/i", "MANGO crate", True, id="regex-ignorecase"),
pytest.param("/mango/", "MANGO crate", False, id="regex-case-sensitive"),
pytest.param("/^mango$/m", "pears\nmango\n", True, id="regex-multiline"),
pytest.param("/mango.crate/s", "mango\ncrate", True, id="regex-dotall"),
# JavaScript-only flags are tolerated: they cannot change the verdict.
pytest.param("/mango/gu", "a mango", True, id="js-only-flags-ignored"),
),
)
def test_content_pattern_forms(pattern, text, expected):
"""Both the keyword and `/body/flags` spellings behave as documented."""
assert bool(match_content_rules({"🥭 mango": (pattern,)}, text)) is expected
[docs]
def test_content_patterns_are_or_joined():
"""Any one pattern matching applies the label: a keyword list just works."""
rules = {"🥭 mango": ("mango", "papaya", "guava")}
assert match_content_rules(rules, "a crate of papaya") == {"🥭 mango"}
assert match_content_rules(rules, "a crate of pears") == set()
# -- Matching file rules ------------------------------------------------------
[docs]
@pytest.mark.parametrize(
("patterns", "files", "expected"),
(
pytest.param(("*.md",), ["a.md", "b.py"], True, id="any-file-hits"),
pytest.param(("*.md",), ["a.py"], False, id="no-file-hits"),
pytest.param(("*.md",), ["sub/a.md"], False, id="star-stops-at-slash"),
pytest.param(("**/*.md",), ["sub/a.md"], True, id="globstar"),
pytest.param((".github/**/*",), [".github/w/x.yaml"], True, id="dotglob"),
pytest.param(("{a,b}/*.py",), ["b/x.py"], True, id="brace"),
# A negated glob subtracts from the label's other globs, the way a
# `.gitignore` line reads.
pytest.param(
("docs/**", "!docs/generated/**"),
["docs/guide.md"],
True,
id="negation-keeps-the-rest",
),
pytest.param(
("docs/**", "!docs/generated/**"),
["docs/generated/api.md"],
False,
id="negation-subtracts",
),
# A lone negation implies "everything else", not "nothing".
pytest.param(("!**/*.md",), ["cli.py"], True, id="lone-negation"),
pytest.param(("!**/*.md",), ["readme.md"], False, id="lone-negation-miss"),
pytest.param(("**",), [], False, id="empty-diff-never-matches"),
),
)
def test_file_globs(patterns, files, expected):
"""The glob dialect stays `minimatch`'s, evaluated as one set per label."""
assert bool(match_file_rules({"🥭 mango": patterns}, files)) is expected
# -- Bundled defaults ---------------------------------------------------------
[docs]
def test_default_rule_labels_exist_in_the_label_registry():
"""Every label the package hard-codes names one `labels.toml` defines.
Applying an unknown label fails the `gh` call outright, so a rule keyed on
a label the registry dropped or renamed is not a dead rule but a broken
one. The sponsor-label command's default rides the same registry and gets
the same check: its label once carried a stray plural that existed in no
repository, and every sponsor-labelling attempt died on it.
{data}`~repomatic.sync_ops.DEPENDENCY_LABEL` is in the population for the
same reason, being applied to every dependency-update PR.
"""
registry = tomlrt.loads(get_data_content("labels.toml"))
defined = {
label["name"]
for profile in registry["profiles"].values()
for label in profile["labels"]
}
rule_labels = set(DEFAULT_CONTENT_RULES) | set(DEFAULT_FILE_RULES)
assert rule_labels <= defined, rule_labels - defined
sponsor_default = next(
param.default
for param in repomatic.commands["sponsor-label"].params
if param.name == "label"
)
assert sponsor_default in defined
assert DEPENDENCY_LABEL in defined
[docs]
@pytest.mark.parametrize(
("text", "expected"),
(
pytest.param("Traceback on an empty crate", {"🐛 bug"}, id="bug"),
pytest.param("Update the README", {"📚 documentation"}, id="docs"),
pytest.param("A workflow needs coverage", {"🤖 ci"}, id="ci"),
pytest.param("Bump the changelog", {"🆙 changelog"}, id="changelog"),
pytest.param("uv.lock drifted", {"🔗 dependencies"}, id="deps"),
# `fix` must not fire inside `prefix`: keywords are word-anchored.
pytest.param("prefix handling is off", set(), id="no-substring-match"),
pytest.param("Nothing relevant at all", set(), id="no-match"),
),
)
def test_default_content_rules_match_as_advertised(text, expected):
"""The shipped keyword lists fire on their keyword and nothing else."""
assert match_content_rules(resolve_content_rules(), text) == expected
[docs]
@pytest.mark.parametrize(
("files", "expected"),
(
pytest.param(["changelog.md"], {"🆙 changelog"}, id="changelog"),
pytest.param(["uv.lock"], {"🔗 dependencies"}, id="lockfile"),
pytest.param(["docs/guide/index.md"], {"📚 documentation"}, id="docs-tree"),
pytest.param([".github/funding.yml"], {"🤖 ci"}, id="funding"),
pytest.param(["repomatic/cli/main.py"], set(), id="source-is-unlabelled"),
),
)
def test_default_file_rules_match_as_advertised(files, expected):
"""The shipped globs cover the paths they name, and no others."""
assert match_file_rules(resolve_file_rules(), files) == expected
# -- End-to-end through the config loader and the CLI -------------------------
[docs]
def test_label_keys_survive_config_loading():
"""Emoji, spaces, colons and hyphens in label keys reach the rules intact.
The loader normalizes configuration keys (`content-rules` →
`content_rules`), and a label like `📦 manager: apt-cyg` must not be
dragged through the same rewrite: mapping-typed fields are opaque to the
normalization, which this pins.
"""
config = load_repomatic_config({
"tool": {
"repomatic": {
"labels": {
"content-rules": {"📦 manager: apt-cyg": ["cygwin"]},
"file-rules": {"📦 manager: apt-cyg": ["managers/apt_cyg.*"]},
},
},
},
})
assert match_content_rules(resolve_content_rules(config), "Broken on Cygwin") == {
"📦 manager: apt-cyg"
}
assert match_file_rules(resolve_file_rules(config), ["managers/apt_cyg.py"]) == {
"📦 manager: apt-cyg"
}
def _invoke_apply_labels(tmp_path, event, gh_responses, *args):
"""Run `apply-labels` against a synthetic event, capturing `gh` calls."""
event_path = tmp_path / "event.json"
event_path.write_text(json.dumps(event), encoding="UTF-8")
# The event payload is cached per process, so a test that ran earlier in
# the same worker with no event would otherwise pin an empty one here.
get_github_event.cache_clear()
calls: list[list[str]] = []
def fake_gh(gh_args):
calls.append(list(gh_args))
return gh_responses.get(tuple(gh_args[:2]), "")
runner = CliRunner()
# Each consuming module bound `run_gh_command` at import, so every binding
# is patched: `pr` (changed files), `issue` (label writes), and `gh`
# itself, whose `gh_api_json` backs the manual-invocation subject fetch.
try:
with (
patch("repomatic.github.gh.run_gh_command", side_effect=fake_gh),
patch("repomatic.github.pr.run_gh_command", side_effect=fake_gh),
patch("repomatic.github.issue.run_gh_command", side_effect=fake_gh),
):
result = runner.invoke(
repomatic,
["apply-labels", "--repo", "kevin/fruits", *args],
env={
"GH_TOKEN": "x",
"GITHUB_EVENT_PATH": str(event_path),
"GITHUB_REPOSITORY": "kevin/fruits",
},
)
finally:
# Drop the synthetic event so later tests in this worker start clean.
get_github_event.cache_clear()
return result, calls
[docs]
def test_apply_labels_dry_run_on_an_issue(tmp_path):
"""An issue event matches content rules only, and dry-run writes nothing."""
event = {
"issue": {
"number": 42,
"title": "Traceback when the crate is empty",
"body": "The README does not mention it.",
},
}
result, calls = _invoke_apply_labels(tmp_path, event, {})
assert result.exit_code == 0, result.output
assert "Would label issue #42 with: 🐛 bug, 📚 documentation" in result.output
assert calls == []
[docs]
def test_apply_labels_live_on_a_pull_request(tmp_path):
"""A PR event adds the file-rule matches and writes the labels once."""
event = {
"pull_request": {
"number": 7,
"title": "Sync the orchard",
"body": "",
},
}
files = "changelog.md\nuv.lock\n"
result, calls = _invoke_apply_labels(
tmp_path,
event,
{("api", "repos/kevin/fruits/pulls/7/files"): files},
"--live",
)
assert result.exit_code == 0, result.output
assert "Labelled PR #7 with: 🆙 changelog, 🔗 dependencies" in result.output
edit = next(call for call in calls if call[:2] == ["pr", "edit"])
assert edit.count("--add-label") == 2
# ---------------------------------------------------------------------------
# Label registry sync: `serialize_inline_labels`, `apply_labels` and the
# `sync-labels` command driving labelmaker.
# ---------------------------------------------------------------------------
[docs]
def test_serialize_inline_labels_empty():
"""An empty input yields an empty string so the caller can skip labelmaker."""
assert serialize_inline_labels([]) == ""
[docs]
def test_serialize_inline_labels_all_invalid_returns_empty(caplog):
"""If every entry is nameless, the result is empty and warnings are logged."""
caplog.set_level(logging.WARNING)
entries = [{"color": "bfdadc"}, {"description": "no name here"}]
assert serialize_inline_labels(entries) == ""
assert len(caplog.records) == 2
for record in caplog.records:
assert "Skipping inline label without a `name`" in record.message
[docs]
def test_serialize_inline_labels_round_trip():
"""Output parses back to the expected labelmaker structure."""
entries = [
{"name": "bug", "color": "d73a4a", "description": "Something isn't working"},
{"name": "📦 manager: apk", "color": "bfdadc", "description": "apk"},
]
output = serialize_inline_labels(entries)
parsed = tomlrt.loads(output)
labels = parsed["profiles"]["default"]["labels"]
assert len(labels) == 2
assert labels[0]["name"] == "bug"
assert labels[0]["color"] == "d73a4a"
assert labels[0]["description"] == "Something isn't working"
assert labels[1]["name"] == "📦 manager: apk"
[docs]
def test_serialize_inline_labels_strips_leading_hash_on_color():
"""Hex colors with a leading `#` are normalized to bare hex."""
output = serialize_inline_labels([
{"name": "bug", "color": "#d73a4a", "description": "x"},
])
parsed = tomlrt.loads(output)
assert parsed["profiles"]["default"]["labels"][0]["color"] == "d73a4a"
[docs]
def test_serialize_inline_labels_omits_missing_optional_fields():
"""Missing `color` and `description` are omitted, not emitted as empty strings."""
output = serialize_inline_labels([{"name": "bug"}])
parsed = tomlrt.loads(output)
label = parsed["profiles"]["default"]["labels"][0]
assert label["name"] == "bug"
assert "color" not in label
assert "description" not in label
[docs]
def test_serialize_inline_labels_passes_through_labelmaker_fields():
"""Every per-label labelmaker field rides through, booleans included."""
output = serialize_inline_labels([
{
"name": "🔌 plugin",
"color": "fef2c0",
"description": "Plugin code",
"create": False,
"update": True,
"enforce-case": False,
"rename-from": ["🔌 bar-plugin", "plugin"],
"on-rename-clash": "error",
},
])
parsed = tomlrt.loads(output)
label = parsed["profiles"]["default"]["labels"][0]
assert label["create"] is False
assert label["update"] is True
assert label["enforce-case"] is False
assert label["rename-from"] == ["🔌 bar-plugin", "plugin"]
assert label["on-rename-clash"] == "error"
[docs]
def test_serialize_inline_labels_strips_hash_on_multi_color():
"""Multi-color lists are normalized to bare hex like single colors."""
output = serialize_inline_labels([
{"name": "bug", "color": ["#d73a4a", "bfdadc"]},
])
parsed = tomlrt.loads(output)
assert parsed["profiles"]["default"]["labels"][0]["color"] == [
"d73a4a",
"bfdadc",
]
[docs]
def test_serialize_inline_labels_omits_empty_values():
"""Empty strings and empty lists are omitted, not emitted as blanks."""
output = serialize_inline_labels([
{"name": "bug", "description": "", "rename-from": []},
])
parsed = tomlrt.loads(output)
label = parsed["profiles"]["default"]["labels"][0]
assert "description" not in label
assert "rename-from" not in label
[docs]
def test_serialize_inline_labels_warns_on_unknown_fields(caplog):
"""Unknown fields are dropped with a warning instead of aborting the sync."""
caplog.set_level(logging.WARNING)
output = serialize_inline_labels([
{"name": "bug", "color": "d73a4a", "renme-from": ["typo"], "bogus": 1},
])
parsed = tomlrt.loads(output)
label = parsed["profiles"]["default"]["labels"][0]
assert label["name"] == "bug"
assert "renme-from" not in label
assert "bogus" not in label
assert any(
"Ignoring unknown fields 'bogus', 'renme-from' on inline label 'bug'"
in r.message
for r in caplog.records
)
[docs]
def test_serialize_inline_labels_skips_blank_name(caplog):
"""A whitespace-only name is treated as missing."""
caplog.set_level(logging.WARNING)
output = serialize_inline_labels([
{"name": " ", "color": "bfdadc"},
{"name": "real", "color": "bfdadc"},
])
parsed = tomlrt.loads(output)
labels = parsed["profiles"]["default"]["labels"]
assert len(labels) == 1
assert labels[0]["name"] == "real"
assert any(
"Skipping inline label without a `name`" in r.message for r in caplog.records
)
[docs]
@pytest.mark.parametrize(
("name", "description"),
[
('quoted "label"', "has \"quotes\" and 'apostrophes'"),
("path\\separator", "back\\slash"),
("newline\ncontent", "tab\there"),
],
)
def test_serialize_inline_labels_escapes_special_characters(name, description):
"""Special characters survive serialization and re-parsing."""
output = serialize_inline_labels([
{"name": name, "color": "bfdadc", "description": description},
])
parsed = tomlrt.loads(output)
label = parsed["profiles"]["default"]["labels"][0]
assert label["name"] == name
assert label["description"] == description
# --- apply_labels path handling ---
[docs]
@pytest.fixture
def captured_labelmaker(monkeypatch):
"""Record every labelmaker invocation instead of running the binary."""
calls: list[tuple[str, ...]] = []
monkeypatch.setattr(
labels_module,
"ensure_binary",
lambda _name: Path("/fake/labelmaker"),
)
monkeypatch.setattr(
labels_module,
"_run_labelmaker",
lambda _lm, *args: calls.append(args),
)
return calls
[docs]
def test_apply_labels_reads_labels_toml_from_labels_dir(
tmp_path, monkeypatch, captured_labelmaker
):
"""`labels_dir` decides where `labels.toml` is read from, not the CWD."""
monkeypatch.chdir(tmp_path)
export = tmp_path / "export"
export.mkdir()
apply_labels(Config(), "owner/repo", is_awesome=False, labels_dir=export)
assert captured_labelmaker == [
("apply", str(export / "labels.toml"), "--profile", "default", "owner/repo")
]
[docs]
def test_apply_labels_defaults_to_current_directory(
tmp_path, monkeypatch, captured_labelmaker
):
"""Omitting `labels_dir` keeps the historical CWD-relative behaviour."""
monkeypatch.chdir(tmp_path)
apply_labels(Config(), "owner/repo", is_awesome=False)
assert captured_labelmaker[0][1] == "labels.toml"
[docs]
def test_apply_labels_applies_awesome_profile_from_labels_dir(
tmp_path, monkeypatch, captured_labelmaker
):
"""The awesome profile reads the same relocated `labels.toml`."""
monkeypatch.chdir(tmp_path)
export = tmp_path / "export"
export.mkdir()
apply_labels(Config(), "owner/awesome-list", is_awesome=True, labels_dir=export)
profiles = [call[3] for call in captured_labelmaker]
assert profiles == ["default", "awesome"]
assert {call[1] for call in captured_labelmaker} == {str(export / "labels.toml")}
[docs]
def test_apply_labels_download_shadows_committed_file_of_same_name(
tmp_path, monkeypatch, captured_labelmaker
):
"""A download wins over a committed file of the same name, applied once."""
monkeypatch.chdir(tmp_path)
committed = tmp_path / "extra-labels"
committed.mkdir()
(committed / "shared.toml").write_text("", encoding="UTF-8")
export = tmp_path / "export"
(export / "extra-labels").mkdir(parents=True)
(export / "extra-labels" / "shared.toml").write_text("", encoding="UTF-8")
apply_labels(Config(), "owner/repo", is_awesome=False, labels_dir=export)
applied = [call[1] for call in captured_labelmaker[1:]]
assert applied == [str(export / "extra-labels" / "shared.toml")]