Replace single-page GET labels?limit=100 with api_get_all-backed _repo_label_id_map so later-page labels (e.g. type:feature, workflow-hardening) are not falsely rejected during full-set replacement. Also add post-mutation verification, fix related MCP/CLI label inventory call sites, and add multi-page regression tests for the #601 reconciler failure mode. Closes #627
233 lines
9.9 KiB
Python
233 lines
9.9 KiB
Python
"""Tests for manage_labels.py.
|
|
|
|
All API calls are mocked — no real network or keychain access.
|
|
"""
|
|
import io
|
|
import json
|
|
import sys
|
|
import unittest
|
|
import contextlib
|
|
from unittest.mock import MagicMock, call, patch
|
|
|
|
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
|
|
import manage_labels # noqa: E402
|
|
import issue_workflow_labels # noqa: E402
|
|
|
|
|
|
FAKE_AUTH = "Basic dGVzdDp0ZXN0" # base64("test:test")
|
|
|
|
|
|
def _make_label(name, lid):
|
|
return {"id": lid, "name": name, "color": "000000"}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Label creation
|
|
# ---------------------------------------------------------------------------
|
|
class TestLabelCreation(unittest.TestCase):
|
|
"""Verify create-or-skip logic for the label set."""
|
|
|
|
@patch("manage_labels.get_auth_header", return_value=FAKE_AUTH)
|
|
@patch("manage_labels.api_get_all")
|
|
@patch("manage_labels.api")
|
|
def test_skips_existing_labels(self, mock_api, mock_get_all, _auth):
|
|
# Simulate all labels already exist (paginated inventory #627)
|
|
existing = [_make_label(l["name"], i) for i, l in enumerate(manage_labels.LABELS)]
|
|
mock_get_all.return_value = existing
|
|
|
|
# Patch sys.argv to avoid --dry
|
|
with patch.object(sys, "argv", ["manage_labels.py"]):
|
|
with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()):
|
|
manage_labels.main()
|
|
|
|
mock_get_all.assert_called()
|
|
post_label_calls = [
|
|
c for c in mock_api.call_args_list
|
|
if c[0][0] == "POST" and c[0][1] == "/labels"
|
|
]
|
|
self.assertEqual(len(post_label_calls), 0)
|
|
|
|
@patch("manage_labels.get_auth_header", return_value=FAKE_AUTH)
|
|
@patch("manage_labels.api_get_all", return_value=[])
|
|
@patch("manage_labels.api")
|
|
def test_creates_missing_labels(self, mock_api, _get_all, _auth):
|
|
# Simulate no existing labels
|
|
def side_effect(method, path, auth, payload=None):
|
|
if method == "POST" and path == "/labels":
|
|
return {"id": 999, "name": payload["name"]}
|
|
if method == "PUT":
|
|
return [{"name": n} for n in (payload or {}).get("labels", [])]
|
|
return None
|
|
|
|
mock_api.side_effect = side_effect
|
|
with patch.object(sys, "argv", ["manage_labels.py"]):
|
|
with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()):
|
|
manage_labels.main()
|
|
|
|
post_calls = [
|
|
c for c in mock_api.call_args_list
|
|
if c[0][0] == "POST" and c[0][1] == "/labels"
|
|
]
|
|
self.assertEqual(len(post_calls), len(manage_labels.LABELS))
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Dry run mode
|
|
# ---------------------------------------------------------------------------
|
|
class TestDryRun(unittest.TestCase):
|
|
|
|
@patch("manage_labels.get_auth_header", return_value=FAKE_AUTH)
|
|
@patch("manage_labels.api_get_all", return_value=[])
|
|
@patch("manage_labels.api")
|
|
def test_dry_run_makes_no_writes(self, mock_api, _get_all, _auth):
|
|
with patch.object(sys, "argv", ["manage_labels.py", "--dry"]):
|
|
with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()):
|
|
manage_labels.main()
|
|
|
|
# Dry run should not call write methods via api()
|
|
for c in mock_api.call_args_list:
|
|
method = c[0][0]
|
|
self.assertEqual(method, "GET",
|
|
f"Dry run should not call {method}")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Label mapping
|
|
# ---------------------------------------------------------------------------
|
|
class TestLabelMapping(unittest.TestCase):
|
|
|
|
@patch("manage_labels.get_auth_header", return_value=FAKE_AUTH)
|
|
@patch("manage_labels.api_get_all")
|
|
@patch("manage_labels.api")
|
|
def test_applies_mapping_to_issues(self, mock_api, mock_get_all, _auth):
|
|
existing = [_make_label(l["name"], i + 1) for i, l in enumerate(manage_labels.LABELS)]
|
|
mock_get_all.return_value = existing
|
|
|
|
def side_effect(method, path, auth, payload=None):
|
|
if method == "PUT":
|
|
return [{"name": "applied"}]
|
|
return None
|
|
|
|
mock_api.side_effect = side_effect
|
|
with patch.object(sys, "argv", ["manage_labels.py"]):
|
|
with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()):
|
|
manage_labels.main()
|
|
|
|
put_calls = [c for c in mock_api.call_args_list if c[0][0] == "PUT"]
|
|
self.assertEqual(len(put_calls), len(manage_labels.MAPPING))
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# LABELS and MAPPING constants
|
|
# ---------------------------------------------------------------------------
|
|
class TestConstants(unittest.TestCase):
|
|
"""Sanity-check the hardcoded label set and mapping."""
|
|
|
|
def test_status_in_progress_label_exists(self):
|
|
names = [l["name"] for l in manage_labels.LABELS]
|
|
self.assertIn("status:in-progress", names)
|
|
|
|
def test_canonical_workflow_labels_are_defined(self):
|
|
names = {l["name"] for l in manage_labels.LABELS}
|
|
for spec in issue_workflow_labels.CANONICAL_LABEL_SPECS:
|
|
self.assertIn(spec.name, names)
|
|
|
|
def test_all_mapped_labels_are_defined(self):
|
|
defined = {l["name"] for l in manage_labels.LABELS}
|
|
for issue, names in manage_labels.MAPPING.items():
|
|
for name in names:
|
|
self.assertIn(name, defined,
|
|
f"Issue #{issue} maps to undefined label '{name}'")
|
|
|
|
def test_label_colors_are_valid_hex(self):
|
|
import re
|
|
for label in manage_labels.LABELS:
|
|
self.assertRegex(label["color"], r"^[0-9a-fA-F]{6}$",
|
|
f"Label '{label['name']}' has invalid color")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Modes: --create-labels / --apply-mapping / --add-label (#6)
|
|
# ---------------------------------------------------------------------------
|
|
class TestModes(unittest.TestCase):
|
|
|
|
def _methods(self, mock_api):
|
|
return [(c[0][0], c[0][1]) for c in mock_api.call_args_list]
|
|
|
|
@patch("manage_labels.get_auth_header", return_value=FAKE_AUTH)
|
|
@patch("manage_labels.api_get_all", return_value=[])
|
|
@patch("manage_labels.api")
|
|
def test_create_labels_only_no_mapping(self, mock_api, _get_all, _auth):
|
|
def se(method, path, auth, payload=None):
|
|
if method == "POST" and path == "/labels":
|
|
return {"id": 1, "name": payload["name"]}
|
|
return None
|
|
mock_api.side_effect = se
|
|
manage_labels.main(["--create-labels"])
|
|
methods = self._methods(mock_api)
|
|
self.assertTrue(any(m == ("POST", "/labels") for m in methods))
|
|
self.assertFalse(any(m[0] == "PUT" for m in methods)) # no mapping applied
|
|
|
|
@patch("manage_labels.get_auth_header", return_value=FAKE_AUTH)
|
|
@patch("manage_labels.api_get_all")
|
|
@patch("manage_labels.api")
|
|
def test_apply_mapping_only_no_label_creation(self, mock_api, mock_get_all, _auth):
|
|
existing = [_make_label(l["name"], i + 1)
|
|
for i, l in enumerate(manage_labels.LABELS)]
|
|
mock_get_all.return_value = existing
|
|
|
|
def se(method, path, auth, payload=None):
|
|
if method == "PUT":
|
|
return [{"name": "applied"}]
|
|
return None
|
|
mock_api.side_effect = se
|
|
manage_labels.main(["--apply-mapping"])
|
|
methods = self._methods(mock_api)
|
|
self.assertFalse(any(m == ("POST", "/labels") for m in methods))
|
|
put_calls = [m for m in methods if m[0] == "PUT"]
|
|
self.assertEqual(len(put_calls), len(manage_labels.MAPPING))
|
|
|
|
@patch("manage_labels.get_auth_header", return_value=FAKE_AUTH)
|
|
@patch("manage_labels.api_get_all", return_value=[_make_label("chore", 5)])
|
|
@patch("manage_labels.api")
|
|
def test_add_label_appends_to_issue(self, mock_api, _get_all, _auth):
|
|
def se(method, path, auth, payload=None):
|
|
if method == "POST":
|
|
return [{"name": "chore"}]
|
|
return None
|
|
mock_api.side_effect = se
|
|
manage_labels.main(["--add-label", "42", "chore"])
|
|
posts = [c for c in mock_api.call_args_list
|
|
if c[0][0] == "POST" and c[0][1] == "/issues/42/labels"]
|
|
self.assertEqual(len(posts), 1)
|
|
self.assertEqual(posts[0][0][3], {"labels": [5]}) # append, id 5
|
|
# POST appends; no PUT (which would replace the whole set).
|
|
self.assertFalse(any(c[0][0] == "PUT" for c in mock_api.call_args_list))
|
|
|
|
@patch("manage_labels.get_auth_header", return_value=FAKE_AUTH)
|
|
@patch("manage_labels.api_get_all", return_value=[])
|
|
@patch("manage_labels.api")
|
|
def test_add_label_unknown_makes_no_write(self, mock_api, _get_all, _auth):
|
|
manage_labels.main(["--add-label", "42", "ghost"])
|
|
# No write via api() for an undefined label.
|
|
self.assertTrue(all(c[0][0] == "GET" for c in mock_api.call_args_list)
|
|
or len(mock_api.call_args_list) == 0)
|
|
|
|
@patch("manage_labels.get_auth_header", return_value=FAKE_AUTH)
|
|
@patch("manage_labels.api_get_all", return_value=[_make_label("chore", 5)])
|
|
@patch("manage_labels.api")
|
|
def test_add_label_dry_makes_no_write(self, mock_api, _get_all, _auth):
|
|
manage_labels.main(["--dry", "--add-label", "42", "chore"])
|
|
self.assertTrue(all(c[0][0] == "GET" for c in mock_api.call_args_list)
|
|
or len(mock_api.call_args_list) == 0)
|
|
|
|
@patch("manage_labels.get_auth_header", return_value=FAKE_AUTH)
|
|
@patch("manage_labels.api")
|
|
def test_add_label_non_numeric_issue_exits(self, mock_api, _auth):
|
|
with self.assertRaises(SystemExit):
|
|
manage_labels.main(["--add-label", "notanum", "chore"])
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|