From 24351ba7284bb3fe8cbbb26160900741b69507f0 Mon Sep 17 00:00:00 2001 From: Peyman Date: Thu, 28 May 2026 16:18:30 +0000 Subject: [PATCH 01/11] feat: add label synchronization script and initial triage label definitions --- org-tools/README.md | 96 ++++++ org-tools/labels/general-labels.yml | 57 ++++ org-tools/labels/triage-labels.yml | 39 +++ org-tools/sync_labels.py | 487 ++++++++++++++++++++++++++++ org-tools/test_sync_labels.py | 438 +++++++++++++++++++++++++ 5 files changed, 1117 insertions(+) create mode 100644 org-tools/README.md create mode 100644 org-tools/labels/general-labels.yml create mode 100644 org-tools/labels/triage-labels.yml create mode 100644 org-tools/sync_labels.py create mode 100644 org-tools/test_sync_labels.py diff --git a/org-tools/README.md b/org-tools/README.md new file mode 100644 index 0000000..affd07f --- /dev/null +++ b/org-tools/README.md @@ -0,0 +1,96 @@ +# GitHub Label Synchronization Tool + +A local Python CLI utility to synchronize label configurations from central YAML files to GitHub organization repositories. + +--- + +## ๐Ÿ“ Configuration Format + +Each YAML configuration file expects a list of label objects: + +```yaml +- name: "type/bug" + color: "d73a4a" + description: "Something is broken or not working as expected" + aliases: + - "bug" + - "defect" +``` + +### Fields: +* **`name`** (Required): The final target name for the label on GitHub. +* **`color`** (Required): A 6-character hex code without a leading `#` (e.g., `"d73a4a"`). +* **`description`** (Optional): A short description of the label. +* **`aliases`** (Optional): A list of previous names. If found, the tool will perform an in-place rename to the target `name` on GitHub, preserving all existing Issue/PR assignments. + +### ๐Ÿ”„ In-Place Label Renaming Example +If you want to rename an existing label (e.g., from `bug` to `type/bug`) without losing any of the issues or PRs currently associated with it: + +1. Define the new target **`name`** as `type/bug`. +2. Add the old label name `bug` inside the **`aliases`** list: + +```yaml +- name: "type/bug" + color: "d73a4a" + description: "Something is broken or not working as expected" + aliases: + - "bug" +``` + +**How it works under the hood:** +* **If `type/bug` does NOT exist in the repository, but `bug` DOES exist:** The script will rename `bug` to `type/bug` in-place. All issues and pull requests previously tagged with `bug` will now be automatically tagged with `type/bug`! +* **If `type/bug` ALREADY exists in the repository:** The script will update `type/bug` (color/description) to match your configuration. However, to prevent destructive API failures, it **will not** automatically delete `bug`. +* **What to do if BOTH exist on GitHub:** + If both `bug` and `type/bug` already exist, the rename call is safely skipped to prevent API errors. If you want to merge them: + 1. In GitHub, filter issues by `label:bug`, select all, and bulk-add the `type/bug` label. + 2. Go to GitHub's repository label settings and manually delete the old `bug` label. + +> [!NOTE] +> **Why this safe approach?** +> By keeping this transition explicit and avoiding automated destructive merges or deletions, the tool guarantees that no label configurations are merged by accident in the future if configuration files are modified or copy-pasted incorrectly. This safety mechanism is specifically designed to safeguard your data when both the new standardized label and the old alias label already exist and have active issues or pull requests assigned to them. + +--- + +## ๐Ÿš€ Running the CLI + +This tool is designed to run easily with **`uv`**, which handles dependencies automatically. + +### 1. Dry Run (Preview Changes) +By default, the tool runs in Dry-Run mode to preview operations safely without making live changes: + +```bash +uv run org-tools/sync_labels.py \ + --token "YOUR_GITHUB_TOKEN" \ + --org "YOUR_ORGANIZATION" \ + --repos "demo-repository" +``` + +### 2. Targeting Specific Repositories (Filter) +Use the `--repos` flag to specify which repositories to sync, or `--all-repos` with `--exclude-repos` to filter specific ones out: + +```bash +# Sync specific repositories +uv run org-tools/sync_labels.py \ + --token "YOUR_GITHUB_TOKEN" \ + --org "YOUR_ORGANIZATION" \ + --repos "repo-a,repo-b" \ + --apply + +# Sync all repositories except excluded ones +uv run org-tools/sync_labels.py \ + --token "YOUR_GITHUB_TOKEN" \ + --org "YOUR_ORGANIZATION" \ + --all-repos \ + --exclude-repos ".github,sandbox" \ + --apply +``` + +--- + +## ๐Ÿงช Running Unit Tests Offline + +To run the offline test suite: + +```bash +uv run org-tools/test_sync_labels.py +``` diff --git a/org-tools/labels/general-labels.yml b/org-tools/labels/general-labels.yml new file mode 100644 index 0000000..d848493 --- /dev/null +++ b/org-tools/labels/general-labels.yml @@ -0,0 +1,57 @@ +--- +- name: bug + color: d73a4a + description: Something isn't working + +- name: dependencies + color: 0366d6 + description: Pull requests that update a dependency file + +- name: devops + color: eb376c + +- name: documentation + color: 0075ca + description: Improvements or additions to documentation + +- name: duplicate + color: cfd3d7 + description: This issue or pull request already exists + +- name: enhancement + color: a2eeef + description: New feature or request + +- name: github_actions + color: '000000' + description: Pull requests that update GitHub Actions code + +- name: governance + color: 7dd2d6 + +- name: payments + color: 2504F2 + +- name: python:uv + color: 2b67c6 + description: Pull requests that update python:uv code + +- name: question + color: d876e3 + description: Further information is requested + +- name: TC review + color: 88b91e + description: Ready for TC review + +- name: Triage + color: f1d66a + +- name: WIP + color: d5854f + +- name: wontfix + color: ffffff + description: This will not be worked on + aliases: + - "cannotfix" diff --git a/org-tools/labels/triage-labels.yml b/org-tools/labels/triage-labels.yml new file mode 100644 index 0000000..2db7dd0 --- /dev/null +++ b/org-tools/labels/triage-labels.yml @@ -0,0 +1,39 @@ +--- +- name: status:needs-triage + color: 'FBCA04' + description: Signal that the PR is ready for human triage + +- name: status:under-review + color: '1D76DB' + +- name: status:stale-review + color: '6F7A8A' + description: Applied if a PR is waiting on a reviewer for too long + +- name: status:blocked + color: 'D93F0B' + +- name: status:ready-to-merge + color: '0E8A16' + description: Signaling to the DevOps team that it can be safely merged + +- name: status:stale + color: '6F7A8A' + description: Applied when PR is waiting for author response for 30 days + +- name: status:merged + color: '6F42C1' + description: Signifies PR completion and provides visibility for reporting + +- name: area:payments + color: '0052CC' + +- name: gov:needs-tc-review + color: 'D93F0B' + +- name: gov:needs-gc-review + color: 'FDE5BE' + +- name: gov:approved + color: 'C2E0C6' + description: Triggers the final code ownership checks diff --git a/org-tools/sync_labels.py b/org-tools/sync_labels.py new file mode 100644 index 0000000..aba92fa --- /dev/null +++ b/org-tools/sync_labels.py @@ -0,0 +1,487 @@ +#!/usr/bin/env python3 +# /// script +# dependencies = [ +# "PyGithub", +# "PyYAML", +# ] +# /// +""" +Sync GitHub Labels (PyGithub edition) +------------------------------------- +A PyGithub-based Python script to synchronize label configurations from +YAML files (general-labels.yml and triage-labels.yml) to one or more GitHub +repositories in an organization. +""" + +import argparse +import logging +import os +import re +import sys +from typing import Any, Dict, List, Optional, Set +import yaml + +from github import Auth, Github, GithubException + +# Setup logging +logging.basicConfig(level=logging.INFO, format="%(message)s") +logger = logging.getLogger("sync_labels") + + +def parse_yaml_labels(file_path: str) -> List[Dict[str, Any]]: + """ + Parses YAML label configurations using PyYAML. + Expects a list of blocks, each containing: + - name: label-name + color: 'hex' (or hex without quotes) + description: "description string" (optional) + aliases: (optional list or string) + """ + with open(file_path, "r", encoding="utf-8") as f: + data = yaml.safe_load(f) + + if not data: + return [] + + labels: List[Dict[str, Any]] = [] + for item in data: + if not isinstance(item, dict): + continue + name = item.get("name", "") + name_str = str(name).strip() if name is not None else "" + + color = item.get("color") + color_str = str(color).strip() if color is not None else "" + + description = item.get("description", "") + description_str = ( + str(description).strip() if description is not None else "" + ) + + raw_aliases = item.get("aliases") or [] + aliases: List[str] = [] + if isinstance(raw_aliases, str): + aliases = [a.strip() for a in raw_aliases.split(",") if a.strip()] + elif isinstance(raw_aliases, list): + aliases = [str(a).strip() for a in raw_aliases if str(a).strip()] + + if name_str: + labels.append( + { + "name": name_str, + "color": color_str, + "description": description_str, + "aliases": aliases, + "file_path": file_path, + } + ) + return labels + + +def validate_and_check_conflicts( + labels: List[Dict[str, Any]], check_file_context: bool = True +) -> None: + """ + Validates the aggregated label set for schema compliance, duplicates, alias conflicts, and alias loop cycles. + If check_file_context is True, it will also perform local syntactic/structural checks relative to labels in the same file. + Raises ValueError with details including file origins if any conflict or schema issue is found. + """ + seen_names: Dict[str, Dict[str, Any]] = {} + alias_to_name: Dict[str, str] = {} + alias_to_file: Dict[str, str] = {} + + # Hex color regex pattern + hex_color_pattern = re.compile(r"^[0-9a-fA-F]{6}$") + + # 1. Collect defined names for local file context validations if needed + file_to_names: Dict[str, Set[str]] = {} + if check_file_context: + for label in labels: + name = label["name"] + file_path = label.get("file_path") or "unknown configuration file" + if file_path not in file_to_names: + file_to_names[file_path] = set() + file_to_names[file_path].add(name) + + for label in labels: + name = label["name"] + color = (label.get("color") or "").strip() + desc = label.get("description") or "" + aliases = label.get("aliases") or [] + file_path = label.get("file_path") or "unknown configuration file" + + # Schema validations + # 1. Empty or invalid label name + if not name or not name.strip(): + raise ValueError( + f"Schema Error: Label name cannot be empty or blank (found in '{file_path}')." + ) + + # 2. Invalid Hex Color validation + if not color: + raise ValueError( + f"Schema Error: Label '{name}' defined in '{file_path}' is missing a hex color code." + ) + if not hex_color_pattern.match(color): + raise ValueError( + f"Schema Error: Label '{name}' defined in '{file_path}' has an invalid hex color '#{color}'. " + f"Color must be a valid 6-character hex code (e.g., 'd73a4a')." + ) + + # Check exact duplicates or conflicting duplicates + if name in seen_names: + existing = seen_names[name] + existing_color = (existing.get("color") or "").strip().lower() + existing_desc = existing.get("description") or "" + existing_aliases = sorted(existing.get("aliases") or []) + existing_file = ( + existing.get("file_path") or "unknown configuration file" + ) + + if ( + existing_color != color.lower() + or existing_desc != desc + or existing_aliases != sorted(aliases) + ): + raise ValueError( + f"Conflict detected for label '{name}':\n" + f" Defined in '{existing_file}': Color #{existing_color}, Desc '{existing_desc}', Aliases {existing_aliases}\n" + f" Defined in '{file_path}': Color #{color}, Desc '{desc}', Aliases {aliases}" + ) + else: + seen_names[name] = label + + # Check aliases + for alias in aliases: + # Check for simple loop: label alias is its own name + if alias == name: + raise ValueError( + f"Conflict: Label '{name}' in '{file_path}' cannot have itself as an alias." + ) + + if check_file_context and file_path in file_to_names: + if alias in file_to_names[file_path]: + raise ValueError( + f"Conflict in '{file_path}': Alias '{alias}' for label '{name}' " + f"is already defined as a separate label name in the same file." + ) + + if alias in alias_to_name and alias_to_name[alias] != name: + other_name = alias_to_name[alias] + other_file = alias_to_file[alias] + raise ValueError( + f"Conflict: Alias '{alias}' is defined for both label '{name}' (in '{file_path}') " + f"and label '{other_name}' (in '{other_file}')." + ) + alias_to_name[alias] = name + alias_to_file[alias] = file_path + + # Topological/Cycle check for indirect loops + for label in labels: + name = label["name"] + file_path = label.get("file_path") or "unknown configuration file" + path = [name] + curr = name + while curr in alias_to_name: + next_step = alias_to_name[curr] + if next_step in path: + path.append(next_step) + cycle_str = " -> ".join(path) + raise ValueError( + f"Conflict: Cyclic alias dependency detected starting at label '{name}' in '{file_path}': {cycle_str}" + ) + path.append(next_step) + curr = next_step + + # Final pass for standard name/alias conflicts + for label in labels: + name = label["name"] + file_path = label.get("file_path") or "unknown configuration file" + aliases = label.get("aliases") or [] + + for alias in aliases: + if alias in seen_names: + target_file = ( + seen_names[alias].get("file_path") + or "unknown configuration file" + ) + raise ValueError( + f"Conflict: Alias '{alias}' defined for label '{name}' in '{file_path}' " + f"conflicts with existing label name '{alias}' defined in '{target_file}'." + ) + + if name in alias_to_name: + target_label = alias_to_name[name] + target_file = alias_to_file[name] + raise ValueError( + f"Conflict: Label name '{name}' defined in '{file_path}' is already registered " + f"as an alias for label '{target_label}' in '{target_file}'." + ) + + +def merge_labels( + list_a: List[Dict[str, Any]], list_b: List[Dict[str, Any]] +) -> List[Dict[str, Any]]: + """ + Merge two lists of label dicts. Validates the entire combined set first for consistency and conflicts before merging. + """ + # 1. Validate the aggregated list of all loaded labels first + all_labels = list_a + list_b + validate_and_check_conflicts(all_labels) + + # 2. If no issues found, safely perform the merge + merged: Dict[str, Dict[str, Any]] = {} + for label in list_a: + merged[label["name"]] = label + + for label in list_b: + merged[label["name"]] = label + + return list(merged.values()) + + +def verify_access( + org_name: str, + repo_names: Optional[List[str]], + token: str, + exclude_repos: Optional[List[str]] = None, +) -> List[Any]: + """ + Verifies API access to the organization and target repositories before starting operations. + Returns the list of resolved repository objects. + """ + logger.info(f"Verifying API access for organization '{org_name}'...") + auth = Auth.Token(token) + g = Github(auth=auth) + try: + org = g.get_organization(org_name) + except GithubException as e: + logger.error( + f"Access Verification Failed: Cannot fetch organization '{org_name}': {e}" + ) + sys.exit(1) + + target_repos = [] + if repo_names: + for rname in repo_names: + try: + repo = org.get_repo(rname) + # Verify permissions if possible, or try a minimal read call + _ = repo.permissions + target_repos.append(repo) + except GithubException as e: + logger.error( + f"Access Verification Failed: Cannot access repository '{rname}' in org '{org_name}': {e}" + ) + sys.exit(1) + else: + try: + target_repos = list(org.get_repos()) + except GithubException as e: + logger.error( + f"Access Verification Failed: Cannot list repositories under organization '{org_name}': {e}" + ) + sys.exit(1) + + if exclude_repos: + target_repos = [r for r in target_repos if r.name not in exclude_repos] + + logger.info( + "Access verification completed successfully. All target repositories are accessible." + ) + return target_repos + + +def sync_labels( + org_name: str, + target_repos: List[Any], + target_labels: List[Dict[str, Any]], + dry_run: bool = True, +) -> None: + logger.info( + f"\n=== Starting Label Sync for Org: {org_name} (Dry Run: {dry_run}) ===" + ) + + for repo in target_repos: + logger.info(f"\nSyncing repository: {repo.name}...") + try: + existing = {label.name: label for label in repo.get_labels()} + except GithubException as e: + logger.warning(f" Skipping due to error fetching labels: {e}") + continue + + # Keep track of labels we renamed or processed to avoid duplicate operations + processed_labels: Set[str] = set() + + for target in target_labels: + name = target["name"] + color = target.get("color") or "ffffff" + desc = target.get("description") or "" + aliases = target.get("aliases") or [] + + # 1. Check if label needs to be renamed from an alias + renamed = False + for alias in aliases: + if alias in existing and name not in existing: + logger.info( + f" [RENAME] Old label '{alias}' -> New label '{name}' (Color: #{color}, Desc: '{desc}')" + ) + if not dry_run: + try: + label_obj = existing[alias] + label_obj.edit( + name=name, color=color, description=desc + ) + # Update existing map + existing[name] = label_obj + del existing[alias] + except GithubException as e: + logger.error( + f" Failed to rename label '{alias}' to '{name}': {e}" + ) + renamed = True + processed_labels.add(name) + break + + if renamed: + continue + + # 2. Standard Create/Update Logic + if name not in existing: + logger.info( + f" [CREATE] Label '{name}' (Color: #{color}, Desc: '{desc}')" + ) + if not dry_run: + try: + repo.create_label( + name=name, color=color, description=desc + ) + except GithubException as e: + logger.error(f" Failed to create label: {e}") + else: + curr = existing[name] + curr_color = curr.color.lower() + curr_desc = curr.description or "" + + if curr_color != color.lower() or curr_desc != desc: + logger.info(f" [UPDATE] Label '{name}':") + if curr_color != color.lower(): + logger.info(f" Color: #{curr_color} -> #{color}") + if curr_desc != desc: + logger.info(f" Desc: '{curr_desc}' -> '{desc}'") + if not dry_run: + try: + curr.edit(name=name, color=color, description=desc) + except GithubException as e: + logger.error(f" Failed to update label: {e}") + + +def main() -> None: + description_text = """ +GitHub Label Synchronization Tool. + +Note: This tool is strictly ADDITIVE and non-destructive. It will only create +missing labels or update matching labels if their properties (color or description) +differ from the YAML configurations. It will NEVER delete existing labels from your repositories. +""" + parser = argparse.ArgumentParser( + description=description_text, + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.add_argument( + "--token", + required=True, + help="Required. Your GitHub Personal Access Token.", + ) + parser.add_argument( + "--org", + required=True, + help="Required. The target GitHub Organization name.", + ) + parser.add_argument( + "--repos", + help="A comma-separated list of specific repository names to sync.", + ) + parser.add_argument( + "--all-repos", + action="store_true", + help="Target ALL repositories under the specified GitHub Organization.", + ) + parser.add_argument( + "--exclude-repos", + help="A comma-separated list of repository names to exclude from syncing.", + ) + parser.add_argument( + "--apply", + action="store_true", + help="Execute synchronization live on GitHub.", + ) + parser.add_argument( + "--general-config", + default="org-tools/labels/general-labels.yml", + help="Path to general labels YAML config.", + ) + parser.add_argument( + "--triage-config", + default="org-tools/labels/triage-labels.yml", + help="Path to triage labels YAML config.", + ) + + if len(sys.argv) == 1: + parser.print_help(sys.stderr) + sys.exit(1) + + args = parser.parse_args() + + if not args.repos and not args.all_repos: + parser.error( + "You must specify target repositories using --repos, or explicitly use the --all-repos flag to sync the entire organization." + ) + + try: + general_labels = parse_yaml_labels(args.general_config) + validate_and_check_conflicts(general_labels, check_file_context=True) + + triage_labels = parse_yaml_labels(args.triage_config) + validate_and_check_conflicts(triage_labels, check_file_context=True) + except Exception as e: + logger.error(f"Error parsing or verifying configuration files: {e}") + sys.exit(1) + + try: + target_labels = merge_labels(general_labels, triage_labels) + except ValueError as e: + logger.error(f"Validation Error: {e}") + sys.exit(1) + + repos_list = ( + [r.strip() for r in args.repos.split(",") if r.strip()] + if args.repos + else None + ) + exclude_list = ( + [r.strip() for r in args.exclude_repos.split(",") if r.strip()] + if args.exclude_repos + else None + ) + + # 1. Verify Access first + target_repos = verify_access( + org_name=args.org, + repo_names=repos_list, + token=args.token, + exclude_repos=exclude_list, + ) + + # 2. Perform standard label sync + sync_labels( + org_name=args.org, + target_repos=target_repos, + target_labels=target_labels, + dry_run=not args.apply, + ) + logger.info("\nSynchronization execution complete.") + + +if __name__ == "__main__": + main() diff --git a/org-tools/test_sync_labels.py b/org-tools/test_sync_labels.py new file mode 100644 index 0000000..b2c7dd5 --- /dev/null +++ b/org-tools/test_sync_labels.py @@ -0,0 +1,438 @@ +#!/usr/bin/env python3 +# /// script +# dependencies = [ +# "PyGithub", +# "PyYAML", +# ] +# /// +import sys +import unittest +from unittest.mock import MagicMock, patch +import os + +# Mock the entire 'github' module before importing our script +mock_github = MagicMock() + + +class MockGithubException(Exception): + def __init__(self, status, message, headers=None): + super().__init__(message) + self.status = status + self.message = message + + +mock_github.GithubException = MockGithubException +sys.modules["github"] = mock_github +sys.modules["github.GithubException"] = MockGithubException + +# Ensure current directory is search path to import target +sys.path.append(os.path.dirname(__file__)) +import sync_labels # noqa: E402 + + +class CheckedTextTestResult(unittest.TextTestResult): + """A custom TestResult class that prints green checkmarks for passing tests.""" + + def addSuccess(self, test): + super().addSuccess(test) + # Print description/name of the test with green checkmark + desc = test.shortDescription() or str(test) + self.stream.writeln(f" \033[92mโœ“\033[0m {desc}") + self.stream.flush() + + def addFailure(self, test, err): + super().addFailure(test, err) + desc = test.shortDescription() or str(test) + self.stream.writeln(f" \033[91mโœ— (Failed)\033[0m {desc}") + self.stream.flush() + + def addError(self, test, err): + super().addError(test, err) + desc = test.shortDescription() or str(test) + self.stream.writeln(f" \033[93mโš  (Error)\033[0m {desc}") + self.stream.flush() + + +class CheckedTextTestRunner(unittest.TextTestRunner): + """A test runner that uses CheckedTextTestResult.""" + + def _makeResult(self): + return CheckedTextTestResult( + self.stream, self.descriptions, self.verbosity + ) + + +class TestLabelSync(unittest.TestCase): + def setUp(self): + mock_github.Github.reset_mock() + + def test_merge_labels_resolves_duplicates_correctly(self): + """Merge identical duplicate labels correctly without error""" + list_a = [ + { + "name": "bug", + "color": "111111", + "description": "Desc A", + "aliases": [], + }, + { + "name": "feature", + "color": "222222", + "description": "Desc B", + "aliases": [], + }, + ] + list_b = [ + { + "name": "bug", + "color": "111111", + "description": "Desc A", + "aliases": [], + }, + { + "name": "docs", + "color": "444444", + "description": "Desc C", + "aliases": [], + }, + ] + merged = sync_labels.merge_labels(list_a, list_b) + merged_dict = {item["name"]: item for item in merged} + + self.assertEqual(len(merged), 3) + self.assertEqual(merged_dict["bug"]["color"], "111111") + self.assertEqual(merged_dict["feature"]["color"], "222222") + self.assertEqual(merged_dict["docs"]["color"], "444444") + + def test_merge_labels_throws_on_conflicting_duplicates(self): + """Merge raises ValueError if duplicate label has conflicting color/description""" + list_a = [ + { + "name": "bug", + "color": "111111", + "description": "Desc A", + "aliases": [], + "file_path": "general.yml", + }, + ] + list_b = [ + { + "name": "bug", + "color": "222222", + "description": "Conflicting Desc", + "aliases": [], + "file_path": "triage.yml", + }, + ] + with self.assertRaises(ValueError) as ctx: + sync_labels.merge_labels(list_a, list_b) + self.assertIn("Conflict detected for label 'bug'", str(ctx.exception)) + self.assertIn("Defined in 'general.yml'", str(ctx.exception)) + self.assertIn("Defined in 'triage.yml'", str(ctx.exception)) + + def test_merge_labels_throws_on_alias_conflicts(self): + """Merge raises ValueError if an alias conflicts with a label name or another alias""" + # Alias conflicts with label name + list_a = [ + { + "name": "bug", + "color": "111111", + "description": "Desc A", + "aliases": ["issue"], + "file_path": "general.yml", + }, + ] + list_b = [ + { + "name": "issue", + "color": "222222", + "description": "Desc B", + "aliases": [], + "file_path": "triage.yml", + }, + ] + with self.assertRaises(ValueError) as ctx: + sync_labels.merge_labels(list_a, list_b) + self.assertIn("Conflict", str(ctx.exception)) + self.assertIn("general.yml", str(ctx.exception)) + self.assertIn("triage.yml", str(ctx.exception)) + + # Duplicate aliases across different labels + list_c = [ + { + "name": "bug", + "color": "111111", + "description": "Desc A", + "aliases": ["problem"], + "file_path": "general.yml", + }, + { + "name": "defect", + "color": "222222", + "description": "Desc B", + "aliases": ["problem"], + "file_path": "triage.yml", + }, + ] + with self.assertRaises(ValueError) as ctx: + sync_labels.validate_and_check_conflicts(list_c) + self.assertIn( + "Conflict: Alias 'problem' is defined for both label 'defect'", + str(ctx.exception), + ) + self.assertIn("general.yml", str(ctx.exception)) + self.assertIn("triage.yml", str(ctx.exception)) + + def test_parse_yaml_labels_with_aliases(self): + """Parse yaml configurations supporting inline and block aliases list""" + yaml_content = """ +- name: bug + color: 'd73a4a' + description: "Something isn't working" + aliases: + - error + - issue +- name: enhancement + color: a2eeef + description: New feature or request + aliases: feature, request +""" + with patch( + "builtins.open", unittest.mock.mock_open(read_data=yaml_content) + ): + labels = sync_labels.parse_yaml_labels("dummy.yml") + sync_labels.validate_and_check_conflicts( + labels, check_file_context=True + ) + + self.assertEqual(len(labels), 2) + self.assertEqual(labels[0]["name"], "bug") + self.assertEqual(labels[0]["aliases"], ["error", "issue"]) + self.assertEqual(labels[1]["name"], "enhancement") + self.assertEqual(labels[1]["aliases"], ["feature", "request"]) + + def test_verify_access_success(self): + """Verify access succeeds when org and repos are readable""" + g_mock = MagicMock() + mock_github.Github.return_value = g_mock + + org_mock = MagicMock() + g_mock.get_organization.return_value = org_mock + + repo_mock = MagicMock() + repo_mock.name = "test-repo" + org_mock.get_repo.return_value = repo_mock + + target_repos = sync_labels.verify_access( + org_name="test-org", + repo_names=["test-repo"], + token="test-token", + ) + self.assertEqual(len(target_repos), 1) + self.assertEqual(target_repos[0].name, "test-repo") + + def test_alias_loop_detection(self): + """Verify that simple self-loops or cyclic alias dependencies raise a ValueError""" + # Self loop + labels_self_loop = [ + { + "name": "bug", + "color": "111111", + "description": "Desc", + "aliases": ["bug"], + "file_path": "general.yml", + } + ] + with self.assertRaises(ValueError) as ctx: + sync_labels.validate_and_check_conflicts(labels_self_loop) + self.assertIn("cannot have itself as an alias", str(ctx.exception)) + + # Multi-label cycle (A has alias B, B has alias A) -> represented as alias transitions B -> A and A -> B + labels_cycle = [ + { + "name": "bug", + "color": "111111", + "description": "Desc", + "aliases": ["defect"], + "file_path": "general.yml", + }, + { + "name": "defect", + "color": "222222", + "description": "Desc", + "aliases": ["bug"], + "file_path": "triage.yml", + }, + ] + with self.assertRaises(ValueError) as ctx: + sync_labels.validate_and_check_conflicts(labels_cycle) + self.assertIn("Cyclic alias dependency detected", str(ctx.exception)) + + def test_schema_validations(self): + """Verify that invalid hex colors or empty labels trigger schema errors once loaded""" + # Blank name + labels_empty_name = [ + { + "name": " ", + "color": "111111", + "description": "", + "aliases": [], + "file_path": "general.yml", + } + ] + with self.assertRaises(ValueError) as ctx: + sync_labels.validate_and_check_conflicts(labels_empty_name) + self.assertIn("Label name cannot be empty or blank", str(ctx.exception)) + + # Invalid hex code (e.g. too short or non-hex character) + labels_invalid_color = [ + { + "name": "bug", + "color": "12345", + "description": "Desc", + "aliases": [], + "file_path": "general.yml", + } + ] + with self.assertRaises(ValueError) as ctx: + sync_labels.validate_and_check_conflicts(labels_invalid_color) + self.assertIn("invalid hex color", str(ctx.exception)) + + def test_in_file_alias_name_overlap_throws(self): + """Verify validate_and_check_conflicts catches an alias matching a defined label name in the same file""" + labels = [ + { + "name": "bug", + "color": "111111", + "description": "", + "aliases": ["feature"], + "file_path": "dummy.yml", + }, + { + "name": "feature", + "color": "222222", + "description": "", + "aliases": [], + "file_path": "dummy.yml", + }, + ] + with self.assertRaises(ValueError) as ctx: + sync_labels.validate_and_check_conflicts( + labels, check_file_context=True + ) + self.assertIn( + "is already defined as a separate label name in the same file", + str(ctx.exception), + ) + + def test_live_configuration_files(self): + """Verify that live general-labels.yml and triage-labels.yml are syntactically valid and conflict-free""" + workspace_dir = os.path.abspath( + os.path.join(os.path.dirname(__file__), "..") + ) + general_path = os.path.join( + workspace_dir, + "org-tools", + "labels", + "general-labels.yml", + ) + triage_path = os.path.join( + workspace_dir, "org-tools", "labels", "triage-labels.yml" + ) + + # Confirm files exist + self.assertTrue( + os.path.exists(general_path), + f"Missing configuration file at: {general_path}", + ) + self.assertTrue( + os.path.exists(triage_path), + f"Missing configuration file at: {triage_path}", + ) + + # 1. Parse files + general_labels = sync_labels.parse_yaml_labels(general_path) + triage_labels = sync_labels.parse_yaml_labels(triage_path) + + # 2. Validate individual files + try: + sync_labels.validate_and_check_conflicts( + general_labels, check_file_context=True + ) + except ValueError as e: + self.fail( + f"Validation failed inside live 'general-labels.yml': {e}" + ) + + try: + sync_labels.validate_and_check_conflicts( + triage_labels, check_file_context=True + ) + except ValueError as e: + self.fail(f"Validation failed inside live 'triage-labels.yml': {e}") + + # 3. Validate merged files + try: + _ = sync_labels.merge_labels(general_labels, triage_labels) + except ValueError as e: + self.fail( + f"Conflict detected when merging live 'general-labels.yml' and 'triage-labels.yml':\n{e}" + ) + + def test_verify_access_failure_exits(self): + """Verify access aborts execution when org or repo is inaccessible""" + g_mock = MagicMock() + mock_github.Github.return_value = g_mock + + org_mock = MagicMock() + g_mock.get_organization.side_effect = MockGithubException( + 404, "Not Found" + ) + + with self.assertRaises(SystemExit): + sync_labels.verify_access("fail-org", ["some-repo"], "token") + + def test_sync_labels_performs_renames_first(self): + """Sync renames existing alias label to target label name instead of creating a duplicate""" + repo_mock = MagicMock() + repo_mock.name = "test-repo" + + label_old = MagicMock() + label_old.name = "issue" + label_old.color = "cccccc" + label_old.description = "Old issue description" + + repo_mock.get_labels.return_value = [label_old] + + target_labels = [ + { + "name": "bug", + "color": "ff0000", + "description": "New bug description", + "aliases": ["issue"], + } + ] + + # Dry run mode + sync_labels.sync_labels( + org_name="test-org", + target_repos=[repo_mock], + target_labels=target_labels, + dry_run=True, + ) + label_old.edit.assert_not_called() + + # Apply mode + sync_labels.sync_labels( + org_name="test-org", + target_repos=[repo_mock], + target_labels=target_labels, + dry_run=False, + ) + label_old.edit.assert_called_once_with( + name="bug", color="ff0000", description="New bug description" + ) + + +if __name__ == "__main__": + unittest.main(testRunner=CheckedTextTestRunner(verbosity=2)) From 9ee5989a243c3433e15403be7bffe92716ea78bd Mon Sep 17 00:00:00 2001 From: Peyman Date: Thu, 28 May 2026 16:29:06 +0000 Subject: [PATCH 02/11] style: format and lint org-tools files according to pre-commit requirements --- org-tools/README.md | 19 ++++++++++++------- org-tools/labels/general-labels.yml | 2 +- org-tools/labels/triage-labels.yml | 22 +++++++++++----------- org-tools/sync_labels.py | 28 ++++++++-------------------- org-tools/test_sync_labels.py | 29 +++++++---------------------- 5 files changed, 39 insertions(+), 61 deletions(-) mode change 100644 => 100755 org-tools/sync_labels.py mode change 100644 => 100755 org-tools/test_sync_labels.py diff --git a/org-tools/README.md b/org-tools/README.md index affd07f..76301e8 100644 --- a/org-tools/README.md +++ b/org-tools/README.md @@ -18,12 +18,14 @@ Each YAML configuration file expects a list of label objects: ``` ### Fields: -* **`name`** (Required): The final target name for the label on GitHub. -* **`color`** (Required): A 6-character hex code without a leading `#` (e.g., `"d73a4a"`). -* **`description`** (Optional): A short description of the label. -* **`aliases`** (Optional): A list of previous names. If found, the tool will perform an in-place rename to the target `name` on GitHub, preserving all existing Issue/PR assignments. + +- **`name`** (Required): The final target name for the label on GitHub. +- **`color`** (Required): A 6-character hex code without a leading `#` (e.g., `"d73a4a"`). +- **`description`** (Optional): A short description of the label. +- **`aliases`** (Optional): A list of previous names. If found, the tool will perform an in-place rename to the target `name` on GitHub, preserving all existing Issue/PR assignments. ### ๐Ÿ”„ In-Place Label Renaming Example + If you want to rename an existing label (e.g., from `bug` to `type/bug`) without losing any of the issues or PRs currently associated with it: 1. Define the new target **`name`** as `type/bug`. @@ -38,9 +40,10 @@ If you want to rename an existing label (e.g., from `bug` to `type/bug`) without ``` **How it works under the hood:** -* **If `type/bug` does NOT exist in the repository, but `bug` DOES exist:** The script will rename `bug` to `type/bug` in-place. All issues and pull requests previously tagged with `bug` will now be automatically tagged with `type/bug`! -* **If `type/bug` ALREADY exists in the repository:** The script will update `type/bug` (color/description) to match your configuration. However, to prevent destructive API failures, it **will not** automatically delete `bug`. -* **What to do if BOTH exist on GitHub:** + +- **If `type/bug` does NOT exist in the repository, but `bug` DOES exist:** The script will rename `bug` to `type/bug` in-place. All issues and pull requests previously tagged with `bug` will now be automatically tagged with `type/bug`! +- **If `type/bug` ALREADY exists in the repository:** The script will update `type/bug` (color/description) to match your configuration. However, to prevent destructive API failures, it **will not** automatically delete `bug`. +- **What to do if BOTH exist on GitHub:** If both `bug` and `type/bug` already exist, the rename call is safely skipped to prevent API errors. If you want to merge them: 1. In GitHub, filter issues by `label:bug`, select all, and bulk-add the `type/bug` label. 2. Go to GitHub's repository label settings and manually delete the old `bug` label. @@ -56,6 +59,7 @@ If you want to rename an existing label (e.g., from `bug` to `type/bug`) without This tool is designed to run easily with **`uv`**, which handles dependencies automatically. ### 1. Dry Run (Preview Changes) + By default, the tool runs in Dry-Run mode to preview operations safely without making live changes: ```bash @@ -66,6 +70,7 @@ uv run org-tools/sync_labels.py \ ``` ### 2. Targeting Specific Repositories (Filter) + Use the `--repos` flag to specify which repositories to sync, or `--all-repos` with `--exclude-repos` to filter specific ones out: ```bash diff --git a/org-tools/labels/general-labels.yml b/org-tools/labels/general-labels.yml index d848493..216225b 100644 --- a/org-tools/labels/general-labels.yml +++ b/org-tools/labels/general-labels.yml @@ -23,7 +23,7 @@ description: New feature or request - name: github_actions - color: '000000' + color: "000000" description: Pull requests that update GitHub Actions code - name: governance diff --git a/org-tools/labels/triage-labels.yml b/org-tools/labels/triage-labels.yml index 2db7dd0..f50c157 100644 --- a/org-tools/labels/triage-labels.yml +++ b/org-tools/labels/triage-labels.yml @@ -1,39 +1,39 @@ --- - name: status:needs-triage - color: 'FBCA04' + color: "FBCA04" description: Signal that the PR is ready for human triage - name: status:under-review - color: '1D76DB' + color: "1D76DB" - name: status:stale-review - color: '6F7A8A' + color: "6F7A8A" description: Applied if a PR is waiting on a reviewer for too long - name: status:blocked - color: 'D93F0B' + color: "D93F0B" - name: status:ready-to-merge - color: '0E8A16' + color: "0E8A16" description: Signaling to the DevOps team that it can be safely merged - name: status:stale - color: '6F7A8A' + color: "6F7A8A" description: Applied when PR is waiting for author response for 30 days - name: status:merged - color: '6F42C1' + color: "6F42C1" description: Signifies PR completion and provides visibility for reporting - name: area:payments - color: '0052CC' + color: "0052CC" - name: gov:needs-tc-review - color: 'D93F0B' + color: "D93F0B" - name: gov:needs-gc-review - color: 'FDE5BE' + color: "FDE5BE" - name: gov:approved - color: 'C2E0C6' + color: "C2E0C6" description: Triggers the final code ownership checks diff --git a/org-tools/sync_labels.py b/org-tools/sync_labels.py old mode 100644 new mode 100755 index aba92fa..15b1d63 --- a/org-tools/sync_labels.py +++ b/org-tools/sync_labels.py @@ -15,7 +15,6 @@ import argparse import logging -import os import re import sys from typing import Any, Dict, List, Optional, Set @@ -54,9 +53,7 @@ def parse_yaml_labels(file_path: str) -> List[Dict[str, Any]]: color_str = str(color).strip() if color is not None else "" description = item.get("description", "") - description_str = ( - str(description).strip() if description is not None else "" - ) + description_str = str(description).strip() if description is not None else "" raw_aliases = item.get("aliases") or [] aliases: List[str] = [] @@ -134,9 +131,7 @@ def validate_and_check_conflicts( existing_color = (existing.get("color") or "").strip().lower() existing_desc = existing.get("description") or "" existing_aliases = sorted(existing.get("aliases") or []) - existing_file = ( - existing.get("file_path") or "unknown configuration file" - ) + existing_file = existing.get("file_path") or "unknown configuration file" if ( existing_color != color.lower() @@ -202,8 +197,7 @@ def validate_and_check_conflicts( for alias in aliases: if alias in seen_names: target_file = ( - seen_names[alias].get("file_path") - or "unknown configuration file" + seen_names[alias].get("file_path") or "unknown configuration file" ) raise ValueError( f"Conflict: Alias '{alias}' defined for label '{name}' in '{file_path}' " @@ -329,9 +323,7 @@ def sync_labels( if not dry_run: try: label_obj = existing[alias] - label_obj.edit( - name=name, color=color, description=desc - ) + label_obj.edit(name=name, color=color, description=desc) # Update existing map existing[name] = label_obj del existing[alias] @@ -353,9 +345,7 @@ def sync_labels( ) if not dry_run: try: - repo.create_label( - name=name, color=color, description=desc - ) + repo.create_label(name=name, color=color, description=desc) except GithubException as e: logger.error(f" Failed to create label: {e}") else: @@ -380,8 +370,8 @@ def main() -> None: description_text = """ GitHub Label Synchronization Tool. -Note: This tool is strictly ADDITIVE and non-destructive. It will only create -missing labels or update matching labels if their properties (color or description) +Note: This tool is strictly ADDITIVE and non-destructive. It will only create +missing labels or update matching labels if their properties (color or description) differ from the YAML configurations. It will NEVER delete existing labels from your repositories. """ parser = argparse.ArgumentParser( @@ -455,9 +445,7 @@ def main() -> None: sys.exit(1) repos_list = ( - [r.strip() for r in args.repos.split(",") if r.strip()] - if args.repos - else None + [r.strip() for r in args.repos.split(",") if r.strip()] if args.repos else None ) exclude_list = ( [r.strip() for r in args.exclude_repos.split(",") if r.strip()] diff --git a/org-tools/test_sync_labels.py b/org-tools/test_sync_labels.py old mode 100644 new mode 100755 index b2c7dd5..fb44bf7 --- a/org-tools/test_sync_labels.py +++ b/org-tools/test_sync_labels.py @@ -57,9 +57,7 @@ class CheckedTextTestRunner(unittest.TextTestRunner): """A test runner that uses CheckedTextTestResult.""" def _makeResult(self): - return CheckedTextTestResult( - self.stream, self.descriptions, self.verbosity - ) + return CheckedTextTestResult(self.stream, self.descriptions, self.verbosity) class TestLabelSync(unittest.TestCase): @@ -197,13 +195,9 @@ def test_parse_yaml_labels_with_aliases(self): description: New feature or request aliases: feature, request """ - with patch( - "builtins.open", unittest.mock.mock_open(read_data=yaml_content) - ): + with patch("builtins.open", unittest.mock.mock_open(read_data=yaml_content)): labels = sync_labels.parse_yaml_labels("dummy.yml") - sync_labels.validate_and_check_conflicts( - labels, check_file_context=True - ) + sync_labels.validate_and_check_conflicts(labels, check_file_context=True) self.assertEqual(len(labels), 2) self.assertEqual(labels[0]["name"], "bug") @@ -317,9 +311,7 @@ def test_in_file_alias_name_overlap_throws(self): }, ] with self.assertRaises(ValueError) as ctx: - sync_labels.validate_and_check_conflicts( - labels, check_file_context=True - ) + sync_labels.validate_and_check_conflicts(labels, check_file_context=True) self.assertIn( "is already defined as a separate label name in the same file", str(ctx.exception), @@ -327,9 +319,7 @@ def test_in_file_alias_name_overlap_throws(self): def test_live_configuration_files(self): """Verify that live general-labels.yml and triage-labels.yml are syntactically valid and conflict-free""" - workspace_dir = os.path.abspath( - os.path.join(os.path.dirname(__file__), "..") - ) + workspace_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) general_path = os.path.join( workspace_dir, "org-tools", @@ -360,9 +350,7 @@ def test_live_configuration_files(self): general_labels, check_file_context=True ) except ValueError as e: - self.fail( - f"Validation failed inside live 'general-labels.yml': {e}" - ) + self.fail(f"Validation failed inside live 'general-labels.yml': {e}") try: sync_labels.validate_and_check_conflicts( @@ -384,10 +372,7 @@ def test_verify_access_failure_exits(self): g_mock = MagicMock() mock_github.Github.return_value = g_mock - org_mock = MagicMock() - g_mock.get_organization.side_effect = MockGithubException( - 404, "Not Found" - ) + g_mock.get_organization.side_effect = MockGithubException(404, "Not Found") with self.assertRaises(SystemExit): sync_labels.verify_access("fail-org", ["some-repo"], "token") From eb1056ff9e08958c0be0f02152e01dac9899a465 Mon Sep 17 00:00:00 2001 From: Peyman Date: Fri, 29 May 2026 08:28:21 +0000 Subject: [PATCH 03/11] chore: remove cannotfix alias from wontfix label definition --- org-tools/labels/general-labels.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/org-tools/labels/general-labels.yml b/org-tools/labels/general-labels.yml index 216225b..9615dae 100644 --- a/org-tools/labels/general-labels.yml +++ b/org-tools/labels/general-labels.yml @@ -53,5 +53,4 @@ - name: wontfix color: ffffff description: This will not be worked on - aliases: - - "cannotfix" + From d859b5e3b5ba9ce3a1f72c317d2180325ca960f1 Mon Sep 17 00:00:00 2001 From: Peyman Date: Fri, 29 May 2026 08:31:41 +0000 Subject: [PATCH 04/11] chore: add .gitignore file --- .gitignore | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 .gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4d01de8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,31 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +.venv/ +venv/ +ENV/ +env/ +.env +.venv + +# Node +node_modules/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# IDE / Editor +.idea/ +.vscode/ +*.suo +*.ntvs* +*.njsproj +*.sln +*.swp +*~ + +# OS files +.DS_Store +Thumbs.db From 74ed56fc78b7c0be930b61a4b9e74141264e35b5 Mon Sep 17 00:00:00 2001 From: Peyman Date: Fri, 29 May 2026 08:37:18 +0000 Subject: [PATCH 05/11] chore: remove trailing newline in general-labels.yml --- org-tools/labels/general-labels.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/org-tools/labels/general-labels.yml b/org-tools/labels/general-labels.yml index 9615dae..0d75d12 100644 --- a/org-tools/labels/general-labels.yml +++ b/org-tools/labels/general-labels.yml @@ -53,4 +53,3 @@ - name: wontfix color: ffffff description: This will not be worked on - From 6b9746ef434a637d491ff35986753139689467a1 Mon Sep 17 00:00:00 2001 From: Peyman Date: Fri, 29 May 2026 08:53:07 +0000 Subject: [PATCH 06/11] refactor: enforce string format for label colors in configuration and add validation to sync script --- org-tools/labels/general-labels.yml | 28 ++++++++++++++-------------- org-tools/sync_labels.py | 4 ++++ 2 files changed, 18 insertions(+), 14 deletions(-) diff --git a/org-tools/labels/general-labels.yml b/org-tools/labels/general-labels.yml index 0d75d12..d6df982 100644 --- a/org-tools/labels/general-labels.yml +++ b/org-tools/labels/general-labels.yml @@ -1,25 +1,25 @@ --- - name: bug - color: d73a4a + color: "d73a4a" description: Something isn't working - name: dependencies - color: 0366d6 + color: "0366d6" description: Pull requests that update a dependency file - name: devops - color: eb376c + color: "eb376c" - name: documentation - color: 0075ca + color: "0075ca" description: Improvements or additions to documentation - name: duplicate - color: cfd3d7 + color: "cfd3d7" description: This issue or pull request already exists - name: enhancement - color: a2eeef + color: "a2eeef" description: New feature or request - name: github_actions @@ -27,29 +27,29 @@ description: Pull requests that update GitHub Actions code - name: governance - color: 7dd2d6 + color: "7dd2d6" - name: payments - color: 2504F2 + color: "2504F2" - name: python:uv - color: 2b67c6 + color: "2b67c6" description: Pull requests that update python:uv code - name: question - color: d876e3 + color: "d876e3" description: Further information is requested - name: TC review - color: 88b91e + color: "88b91e" description: Ready for TC review - name: Triage - color: f1d66a + color: "f1d66a" - name: WIP - color: d5854f + color: "d5854f" - name: wontfix - color: ffffff + color: "ffffff" description: This will not be worked on diff --git a/org-tools/sync_labels.py b/org-tools/sync_labels.py index 15b1d63..63c2073 100755 --- a/org-tools/sync_labels.py +++ b/org-tools/sync_labels.py @@ -50,6 +50,10 @@ def parse_yaml_labels(file_path: str) -> List[Dict[str, Any]]: name_str = str(name).strip() if name is not None else "" color = item.get("color") + if color is not None and not isinstance(color, str): + raise ValueError( + f"Configuration Error: The color value for label '{name_str}' must be enclosed in quotes (e.g., color: \"{color}\")." + ) color_str = str(color).strip() if color is not None else "" description = item.get("description", "") From 83c4ba09c34e4310dbde31365d51138c1ce7265a Mon Sep 17 00:00:00 2001 From: Peyman Date: Fri, 29 May 2026 09:13:00 +0000 Subject: [PATCH 07/11] refactor: strictly forbid duplicate labels during merge instead of allowing identical ones --- org-tools/sync_labels.py | 15 +++++---------- org-tools/test_sync_labels.py | 28 +++++++--------------------- 2 files changed, 12 insertions(+), 31 deletions(-) diff --git a/org-tools/sync_labels.py b/org-tools/sync_labels.py index 63c2073..2a7f4cb 100755 --- a/org-tools/sync_labels.py +++ b/org-tools/sync_labels.py @@ -137,16 +137,11 @@ def validate_and_check_conflicts( existing_aliases = sorted(existing.get("aliases") or []) existing_file = existing.get("file_path") or "unknown configuration file" - if ( - existing_color != color.lower() - or existing_desc != desc - or existing_aliases != sorted(aliases) - ): - raise ValueError( - f"Conflict detected for label '{name}':\n" - f" Defined in '{existing_file}': Color #{existing_color}, Desc '{existing_desc}', Aliases {existing_aliases}\n" - f" Defined in '{file_path}': Color #{color}, Desc '{desc}', Aliases {aliases}" - ) + raise ValueError( + f"Duplicate label detected for '{name}':\n" + f" Defined in '{existing_file}': Color #{existing_color}, Desc '{existing_desc}', Aliases {existing_aliases}\n" + f" Defined in '{file_path}': Color #{color}, Desc '{desc}', Aliases {aliases}" + ) else: seen_names[name] = label diff --git a/org-tools/test_sync_labels.py b/org-tools/test_sync_labels.py index fb44bf7..0673bfc 100755 --- a/org-tools/test_sync_labels.py +++ b/org-tools/test_sync_labels.py @@ -65,19 +65,14 @@ def setUp(self): mock_github.Github.reset_mock() def test_merge_labels_resolves_duplicates_correctly(self): - """Merge identical duplicate labels correctly without error""" + """Merge raises ValueError on any duplicate labels""" list_a = [ { "name": "bug", "color": "111111", "description": "Desc A", "aliases": [], - }, - { - "name": "feature", - "color": "222222", - "description": "Desc B", - "aliases": [], + "file_path": "general.yml", }, ] list_b = [ @@ -86,21 +81,12 @@ def test_merge_labels_resolves_duplicates_correctly(self): "color": "111111", "description": "Desc A", "aliases": [], - }, - { - "name": "docs", - "color": "444444", - "description": "Desc C", - "aliases": [], + "file_path": "triage.yml", }, ] - merged = sync_labels.merge_labels(list_a, list_b) - merged_dict = {item["name"]: item for item in merged} - - self.assertEqual(len(merged), 3) - self.assertEqual(merged_dict["bug"]["color"], "111111") - self.assertEqual(merged_dict["feature"]["color"], "222222") - self.assertEqual(merged_dict["docs"]["color"], "444444") + with self.assertRaises(ValueError) as ctx: + sync_labels.merge_labels(list_a, list_b) + self.assertIn("Duplicate label detected for 'bug'", str(ctx.exception)) def test_merge_labels_throws_on_conflicting_duplicates(self): """Merge raises ValueError if duplicate label has conflicting color/description""" @@ -124,7 +110,7 @@ def test_merge_labels_throws_on_conflicting_duplicates(self): ] with self.assertRaises(ValueError) as ctx: sync_labels.merge_labels(list_a, list_b) - self.assertIn("Conflict detected for label 'bug'", str(ctx.exception)) + self.assertIn("Duplicate label detected for 'bug'", str(ctx.exception)) self.assertIn("Defined in 'general.yml'", str(ctx.exception)) self.assertIn("Defined in 'triage.yml'", str(ctx.exception)) From cde88dcb44a25622a81905b2c1a45a05722e2b50 Mon Sep 17 00:00:00 2001 From: Peyman Date: Fri, 29 May 2026 09:33:32 +0000 Subject: [PATCH 08/11] feat: add individual validation for label configuration files during load and merge processes --- org-tools/sync_labels.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/org-tools/sync_labels.py b/org-tools/sync_labels.py index 2a7f4cb..2ade499 100755 --- a/org-tools/sync_labels.py +++ b/org-tools/sync_labels.py @@ -218,6 +218,14 @@ def merge_labels( """ Merge two lists of label dicts. Validates the entire combined set first for consistency and conflicts before merging. """ + + # validate each label list individually (duplicate validation for merge) + try: + validate_and_check_conflicts(list_a, check_file_context=True) + validate_and_check_conflicts(list_b, check_file_context=True) + except Exception as e: + logger.error(f"Error parsing or verifying configuration files: {e}") + sys.exit(1) # 1. Validate the aggregated list of all loaded labels first all_labels = list_a + list_b validate_and_check_conflicts(all_labels) @@ -428,6 +436,7 @@ def main() -> None: ) try: + # initial validation while loading files general_labels = parse_yaml_labels(args.general_config) validate_and_check_conflicts(general_labels, check_file_context=True) From ff1c04bcf1f968d58c67b9fbee55ff2c9b73003a Mon Sep 17 00:00:00 2001 From: Peyman Date: Fri, 29 May 2026 10:41:13 +0000 Subject: [PATCH 09/11] docs: clarify label synchronization safety constraints --- org-tools/README.md | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/org-tools/README.md b/org-tools/README.md index 76301e8..252b420 100644 --- a/org-tools/README.md +++ b/org-tools/README.md @@ -2,6 +2,8 @@ A local Python CLI utility to synchronize label configurations from central YAML files to GitHub organization repositories. +Note that this script only changes the Label name/color/description and avoids any manipulation of label assignment to issues or pull requests. + --- ## ๐Ÿ“ Configuration Format @@ -24,12 +26,21 @@ Each YAML configuration file expects a list of label objects: - **`description`** (Optional): A short description of the label. - **`aliases`** (Optional): A list of previous names. If found, the tool will perform an in-place rename to the target `name` on GitHub, preserving all existing Issue/PR assignments. +> **Conflict Validation Policy:** +> +> | Config Scenario | Example Case | Result | +> | :--------------------- | :----------------------------------------------------------- | :----------------------------------- | +> | **Duplicate Names** | Label `bug` in File A and Label `bug` in File B | โŒ **Forbidden** (Duplicate Error) | +> | **Name-Alias Overlap** | Label `type/bug` has Alias `bug`, AND Label `bug` is defined | โŒ **Forbidden** (Conflict Error) | +> | **Shared Aliases** | Label `type/bug` and `defect` both have Alias `issue` | โŒ **Forbidden** (Conflict Error) | +> | **Cyclic Redirects** | Label A has alias B, Label B has alias A | โŒ **Forbidden** (Cyclic Loop Error) | + ### ๐Ÿ”„ In-Place Label Renaming Example If you want to rename an existing label (e.g., from `bug` to `type/bug`) without losing any of the issues or PRs currently associated with it: -1. Define the new target **`name`** as `type/bug`. -2. Add the old label name `bug` inside the **`aliases`** list: +1. Define the new target **`name`** (e.g. `type/bug`) that does not exist already. +2. Add the old label name (e.g. `bug`) inside the **`aliases`** list: ```yaml - name: "type/bug" @@ -44,9 +55,11 @@ If you want to rename an existing label (e.g., from `bug` to `type/bug`) without - **If `type/bug` does NOT exist in the repository, but `bug` DOES exist:** The script will rename `bug` to `type/bug` in-place. All issues and pull requests previously tagged with `bug` will now be automatically tagged with `type/bug`! - **If `type/bug` ALREADY exists in the repository:** The script will update `type/bug` (color/description) to match your configuration. However, to prevent destructive API failures, it **will not** automatically delete `bug`. - **What to do if BOTH exist on GitHub:** + This script does not support merging labels because of destructive side effects. If both `bug` and `type/bug` already exist, the rename call is safely skipped to prevent API errors. If you want to merge them: 1. In GitHub, filter issues by `label:bug`, select all, and bulk-add the `type/bug` label. 2. Go to GitHub's repository label settings and manually delete the old `bug` label. + This will prevent any accidental merge of labels which could have sever consequences on underlying issues or pull requests. > [!NOTE] > **Why this safe approach?** From 655d3bd6197e0e7633d0fa5c49ccd7fa29994915 Mon Sep 17 00:00:00 2001 From: Peyman Date: Fri, 29 May 2026 11:24:26 +0000 Subject: [PATCH 10/11] docs: add GitHub Personal Access Token permission requirements --- org-tools/README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/org-tools/README.md b/org-tools/README.md index 252b420..7c26de1 100644 --- a/org-tools/README.md +++ b/org-tools/README.md @@ -71,6 +71,10 @@ If you want to rename an existing label (e.g., from `bug` to `type/bug`) without This tool is designed to run easily with **`uv`**, which handles dependencies automatically. +### ๐Ÿ”‘ Prerequisites (Token Permissions) + +Before running the tool, ensure your GitHub Personal Access Token (`--token`) has proper access to the organization and its repositories and manage labels. + ### 1. Dry Run (Preview Changes) By default, the tool runs in Dry-Run mode to preview operations safely without making live changes: From c032deae3c6c9d9ab83b523a9ba30c0c8e7d35cd Mon Sep 17 00:00:00 2001 From: Peyman Date: Fri, 29 May 2026 12:49:25 +0000 Subject: [PATCH 11/11] docs: add link to GitHub personal access token documentation in README --- org-tools/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/org-tools/README.md b/org-tools/README.md index 7c26de1..4a1e974 100644 --- a/org-tools/README.md +++ b/org-tools/README.md @@ -73,7 +73,7 @@ This tool is designed to run easily with **`uv`**, which handles dependencies au ### ๐Ÿ”‘ Prerequisites (Token Permissions) -Before running the tool, ensure your GitHub Personal Access Token (`--token`) has proper access to the organization and its repositories and manage labels. +Before running the tool, ensure you have a [GitHub Personal Access Token](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens) (`--token`) with proper access to the organization and its repositories to manage labels. ### 1. Dry Run (Preview Changes)