refactor: split manage_labels.py into reusable modes (#6) #62
+78
-17
@@ -4,9 +4,14 @@
|
||||
Auth follows the project convention: credentials are pulled from the macOS
|
||||
keychain via `git credential fill` (HTTPS), then sent as Basic auth.
|
||||
|
||||
Usage:
|
||||
./manage_labels.py # create labels, then apply the mapping below
|
||||
./manage_labels.py --dry # print actions without writing
|
||||
Modes (default = create labels then apply the one-off MAPPING, preserving the
|
||||
original behavior):
|
||||
|
||||
./manage_labels.py # create labels + apply MAPPING
|
||||
./manage_labels.py --create-labels # idempotent label creation only
|
||||
./manage_labels.py --apply-mapping # one-off MAPPING labeling only
|
||||
./manage_labels.py --add-label 42 chore # add one label to one issue
|
||||
./manage_labels.py --dry ... # print actions without writing
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
@@ -34,7 +39,7 @@ LABELS = [
|
||||
"description": "Issue is being worked on"},
|
||||
]
|
||||
|
||||
# issue number -> label names to apply
|
||||
# issue number -> label names to apply (one-off backfill)
|
||||
MAPPING = {
|
||||
23: ["chore"],
|
||||
22: ["chore"],
|
||||
@@ -56,6 +61,11 @@ MAPPING = {
|
||||
|
||||
BASE_URL = repo_api_url(HOST, ORG, REPO)
|
||||
|
||||
USAGE = (
|
||||
"usage: manage_labels.py [--dry] "
|
||||
"[--create-labels | --apply-mapping | --add-label <issue> <label>]"
|
||||
)
|
||||
|
||||
|
||||
def api(method, path, auth, payload=None):
|
||||
"""Thin wrapper around auth.api_request that prepends BASE_URL and
|
||||
@@ -68,19 +78,15 @@ def api(method, path, auth, payload=None):
|
||||
return None
|
||||
|
||||
|
||||
def main():
|
||||
dry = "--dry" in sys.argv
|
||||
auth = get_auth_header(HOST)
|
||||
if auth is None:
|
||||
print("Could not get credentials from git credential fill",
|
||||
file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# 1. Existing labels -> name:id
|
||||
def _labels_by_name(auth):
|
||||
"""Return {label name: id} for the repo's existing labels."""
|
||||
existing = api("GET", "/labels?limit=100", auth) or []
|
||||
by_name = {l["name"]: l["id"] for l in existing}
|
||||
return {lb["name"]: lb["id"] for lb in existing}
|
||||
|
||||
# 2. Create missing labels
|
||||
|
||||
def create_labels(auth, dry=False):
|
||||
"""Idempotently create the LABELS set; return the resulting name->id map."""
|
||||
by_name = _labels_by_name(auth)
|
||||
for spec in LABELS:
|
||||
if spec["name"] in by_name:
|
||||
print(f"label exists: {spec['name']}")
|
||||
@@ -92,8 +98,13 @@ def main():
|
||||
if created:
|
||||
by_name[created["name"]] = created["id"]
|
||||
print(f"created label: {created['name']} (id {created['id']})")
|
||||
return by_name
|
||||
|
||||
# 3. Apply mapping
|
||||
|
||||
def apply_mapping(auth, by_name=None, dry=False):
|
||||
"""Apply the one-off MAPPING (PUT replaces each issue's label set)."""
|
||||
if by_name is None:
|
||||
by_name = _labels_by_name(auth)
|
||||
for issue, names in sorted(MAPPING.items(), reverse=True):
|
||||
ids = [by_name[n] for n in names if n in by_name]
|
||||
missing = [n for n in names if n not in by_name]
|
||||
@@ -105,9 +116,59 @@ def main():
|
||||
# PUT replaces the issue's labels with exactly this set (idempotent).
|
||||
res = api("PUT", f"/issues/{issue}/labels", auth, {"labels": ids})
|
||||
if res is not None:
|
||||
applied = [l["name"] for l in res]
|
||||
applied = [lb["name"] for lb in res]
|
||||
print(f"#{issue} labeled: {applied}")
|
||||
|
||||
|
||||
def add_label(auth, issue, label, dry=False):
|
||||
"""Ad-hoc: ADD a single existing label to one issue (append, not replace)."""
|
||||
by_name = _labels_by_name(auth)
|
||||
if label not in by_name:
|
||||
print(f" unknown label '{label}'; create it first (--create-labels)",
|
||||
file=sys.stderr)
|
||||
return False
|
||||
if dry:
|
||||
print(f"[dry] #{issue} += {label}")
|
||||
return True
|
||||
# POST appends to the issue's existing labels (does not replace).
|
||||
res = api("POST", f"/issues/{issue}/labels", auth, {"labels": [by_name[label]]})
|
||||
if res is not None:
|
||||
print(f"#{issue} += {label}")
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
argv = list(sys.argv[1:] if argv is None else argv)
|
||||
dry = "--dry" in argv or "--dry-run" in argv
|
||||
|
||||
auth = get_auth_header(HOST)
|
||||
if auth is None:
|
||||
print("Could not get credentials from git credential fill",
|
||||
file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
if "--create-labels" in argv:
|
||||
create_labels(auth, dry=dry)
|
||||
elif "--apply-mapping" in argv:
|
||||
apply_mapping(auth, dry=dry)
|
||||
elif "--add-label" in argv:
|
||||
i = argv.index("--add-label")
|
||||
if i + 2 >= len(argv):
|
||||
print(USAGE, file=sys.stderr)
|
||||
sys.exit(2)
|
||||
try:
|
||||
issue = int(argv[i + 1])
|
||||
except ValueError:
|
||||
print(f"--add-label: issue must be a number, got '{argv[i + 1]}'",
|
||||
file=sys.stderr)
|
||||
sys.exit(2)
|
||||
add_label(auth, issue, argv[i + 2], dry=dry)
|
||||
else:
|
||||
# Default (backward compatible): create labels, then apply the mapping.
|
||||
by_name = create_labels(auth, dry=dry)
|
||||
apply_mapping(auth, by_name, dry=dry)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
@@ -137,5 +137,89 @@ class TestConstants(unittest.TestCase):
|
||||
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")
|
||||
def test_create_labels_only_no_mapping(self, mock_api, _auth):
|
||||
def se(method, path, auth, payload=None):
|
||||
if method == "GET":
|
||||
return [] # no existing labels
|
||||
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")
|
||||
def test_apply_mapping_only_no_label_creation(self, mock_api, _auth):
|
||||
existing = [_make_label(l["name"], i + 1)
|
||||
for i, l in enumerate(manage_labels.LABELS)]
|
||||
|
||||
def se(method, path, auth, payload=None):
|
||||
if method == "GET":
|
||||
return existing
|
||||
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")
|
||||
def test_add_label_appends_to_issue(self, mock_api, _auth):
|
||||
existing = [_make_label("chore", 5)]
|
||||
|
||||
def se(method, path, auth, payload=None):
|
||||
if method == "GET":
|
||||
return existing
|
||||
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")
|
||||
def test_add_label_unknown_makes_no_write(self, mock_api, _auth):
|
||||
mock_api.side_effect = lambda *a, **k: [] if a[0] == "GET" else None
|
||||
manage_labels.main(["--add-label", "42", "ghost"])
|
||||
# Only the GET label lookup; no POST/PUT for an undefined label.
|
||||
self.assertTrue(all(c[0][0] == "GET" for c in mock_api.call_args_list))
|
||||
|
||||
@patch("manage_labels.get_auth_header", return_value=FAKE_AUTH)
|
||||
@patch("manage_labels.api")
|
||||
def test_add_label_dry_makes_no_write(self, mock_api, _auth):
|
||||
mock_api.side_effect = lambda *a, **k: [_make_label("chore", 5)] if a[0] == "GET" else None
|
||||
manage_labels.main(["--dry", "--add-label", "42", "chore"])
|
||||
self.assertTrue(all(c[0][0] == "GET" for c in mock_api.call_args_list))
|
||||
|
||||
@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()
|
||||
|
||||
Reference in New Issue
Block a user