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 diff --git a/org-tools/README.md b/org-tools/README.md new file mode 100644 index 0000000..4a1e974 --- /dev/null +++ b/org-tools/README.md @@ -0,0 +1,118 @@ +# GitHub Label Synchronization Tool + +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 + +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. + +> **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`** (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" + 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:** + 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?** +> 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. + +### ๐Ÿ”‘ Prerequisites (Token Permissions) + +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) + +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..d6df982 --- /dev/null +++ b/org-tools/labels/general-labels.yml @@ -0,0 +1,55 @@ +--- +- 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 diff --git a/org-tools/labels/triage-labels.yml b/org-tools/labels/triage-labels.yml new file mode 100644 index 0000000..f50c157 --- /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 100755 index 0000000..2ade499 --- /dev/null +++ b/org-tools/sync_labels.py @@ -0,0 +1,483 @@ +#!/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 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") + 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", "") + 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" + + 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 + + # 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. + """ + + # 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) + + # 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: + # initial validation while loading files + 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 100755 index 0000000..0673bfc --- /dev/null +++ b/org-tools/test_sync_labels.py @@ -0,0 +1,409 @@ +#!/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 raises ValueError on any duplicate labels""" + list_a = [ + { + "name": "bug", + "color": "111111", + "description": "Desc A", + "aliases": [], + "file_path": "general.yml", + }, + ] + list_b = [ + { + "name": "bug", + "color": "111111", + "description": "Desc A", + "aliases": [], + "file_path": "triage.yml", + }, + ] + 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""" + 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("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)) + + 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 + + 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))