diff --git a/.github/workflows/label_sync.yml b/.github/workflows/label_sync.yml new file mode 100644 index 0000000..314b8d3 --- /dev/null +++ b/.github/workflows/label_sync.yml @@ -0,0 +1,74 @@ +name: "Labels: Sync" + +on: + workflow_dispatch: + inputs: + dry-run: + description: "Preview label changes without applying them" + required: false + type: boolean + default: true + repos: + description: >- + Optional comma-separated list of repos to target. + Leave empty to target all repos except complytime and roadmap. + required: false + type: string + default: "" + +permissions: + contents: read + +concurrency: + group: label-sync + cancel-in-progress: false + +jobs: + sync-labels: + if: github.repository_owner == 'complytime' + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + contents: read + steps: + - name: Checkout complytime/.github repo + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Setup Python + uses: actions/setup-python@42375524a786f35ee56e4dbe431d0097254bc67c # v6.0.0 + with: + python-version: "3.13" + + - name: Generate GitHub App token + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + client-id: ${{ secrets.COMPLYTIME_BOT_CLIENT_ID }} + private-key: ${{ secrets.COMPLYTIME_BOT_PRIVATE_KEY }} + owner: complytime + + - name: Sync labels + env: + GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} + INPUT_REPOS: ${{ inputs.repos }} + DRY_RUN: ${{ inputs.dry-run }} + run: | + set -eu + + REPO_ARGS="" + if [ -n "$INPUT_REPOS" ]; then + CLEANED=$(printf '%s' "$INPUT_REPOS" | tr ',' ' ') + REPO_ARGS="--repos $CLEANED" + fi + + if [ "$DRY_RUN" = "true" ]; then + DRY_ARG="--dry-run" + else + DRY_ARG="" + fi + + # shellcheck disable=SC2086 + python3 scripts/sync_labels.py \ + --policy labels-policy.json \ + $DRY_ARG \ + $REPO_ARGS diff --git a/MAINTAINING.md b/MAINTAINING.md index 6be4249..8878705 100644 --- a/MAINTAINING.md +++ b/MAINTAINING.md @@ -20,6 +20,7 @@ GitHub organization using two complementary tools: **peribolos** and | Dependabot alerts and fixes | safe-settings | `safe-settings/settings.yml` | | Branch protection rules | safe-settings | `safe-settings/settings.yml` | | Rulesets | safe-settings | `safe-settings/settings.yml` | +| Cross-repo label standardization | custom workflow | `labels-policy.json`, `scripts/sync_labels.py` | | `.github` repo ruleset | **manual** | GitHub UI | **Why two tools?** Peribolos manages org-level concerns (who is a member, @@ -88,6 +89,20 @@ that differ from its suborg defaults. See `safe-settings/repos/complyctl.yml` for an example (complyctl requires 2 approvers instead of the org default of 1). +### Reconcile Labels Across Repositories + +Use the **"Labels: Sync"** workflow for label-only changes that do not fit +safe-settings' "apply everything in this config" model. + +The workflow reads `labels-policy.json` and applies three kinds of changes: + +1. Ensure a shared set of standard labels exists everywhere. +2. Rename legacy labels only in repositories where they already exist. +3. Delete only explicitly-listed labels, leaving all other local labels alone. + +This is useful when some labels must remain repo-specific and should not be +replicated across the organization. + ## Override Validator Policies Override validators in `safe-settings/deployment-settings.yml` enforce @@ -219,6 +234,13 @@ Go to Actions > "Safe Settings Sync" > "Run workflow": `complytime-demos,community`). Leave empty to apply to all managed repos. +### Labels + +Go to Actions > "Labels: Sync" > "Run workflow": +- **dry-run**: `true` to preview, `false` to apply (defaults to `true`) +- **repos**: comma-separated list of repos to target. Leave empty to apply + to all repos except those excluded in `labels-policy.json` + ### Future automation After initial validation, the workflow can be extended with: diff --git a/README.md b/README.md index 861298b..f769a99 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,8 @@ complementary tools: — org membership, teams, and team-repo permissions (`peribolos.yaml`) - **[safe-settings](https://github.com/github/safe-settings)** — repository settings, branch protection, rulesets, and security config (`safe-settings/`) +- **Custom label sync** — targeted repository label normalization and cleanup + (`labels-policy.json`, `scripts/sync_labels.py`) For maintainer workflows, local testing, and troubleshooting, see **[MAINTAINING.md](MAINTAINING.md)**. diff --git a/labels-policy.json b/labels-policy.json new file mode 100644 index 0000000..57a8781 --- /dev/null +++ b/labels-policy.json @@ -0,0 +1,199 @@ +{ + "org": "complytime", + "exclude_repos": [ + "complytime", + "roadmap" + ], + "preserve_local_only_labels": [ + "60 day implementation", + "Fedora", + "ISO 42001 audit", + "RHEL Sec Compliance", + "automated", + "branch protection rules", + "ci", + "go", + "javascript", + "newsletter", + "peribolos-drift", + "post 60 day implementation", + "pre_commit", + "python", + "private-sprint-sub-issue" + ], + "rename_only_labels": [ + { + "from": [ + "60 days" + ], + "to": { + "name": "60 day implementation", + "color": "d4c5f9", + "description": "Tracking work scheduled for the 60 day implementation window." + } + }, + { + "from": [ + "clean up" + ], + "to": { + "name": "Clean up", + "color": "fbca04", + "description": "Maintenance work that cleans up existing code, config, or process." + } + }, + { + "from": [ + "test" + ], + "to": { + "name": "Test", + "color": "0e8a16", + "description": "Changes or work focused on testing." + } + }, + { + "from": [ + "uat" + ], + "to": { + "name": "UAT", + "color": "1d76db", + "description": "User acceptance testing and validation work." + } + }, + { + "from": [ + "docker" + ], + "to": { + "name": "container", + "color": "5319e7", + "description": "Container image, container runtime, or container packaging work." + } + }, + { + "from": [ + "llm_assisted" + ], + "to": { + "name": "AI assisted", + "color": "bfd4f2", + "description": "Work created or updated with AI assistance." + } + }, + { + "from": [ + "post 60 days" + ], + "to": { + "name": "post 60 day implementation", + "color": "f9d0c4", + "description": "Tracking work scheduled after the 60 day implementation window." + } + }, + { + "from": [ + "question", + "to validate", + "to validade" + ], + "to": { + "name": "validate", + "color": "fbca04", + "description": "Work that still needs validation before it is complete." + } + } + ], + "delete_labels": [ + "Risk", + "Unbound force", + "autorelease: pending", + "autorelease: tagged", + "baklava sprint 8", + "breaking_change", + "complyctl-import", + "complyctl-plugins", + "complyscribe-imported", + "private", + "proposal", + "research" + ], + "standard_labels": [ + { + "name": "Bug", + "color": "d73a4a", + "description": "Something is broken and needs to be fixed." + }, + { + "name": "Dependencies", + "color": "0366d6", + "description": "Dependency updates or dependency management work." + }, + { + "name": "documentation", + "color": "0075ca", + "description": "Improvements or additions to documentation." + }, + { + "name": "Duplicate", + "color": "cfd3d7", + "description": "This issue or pull request already exists elsewhere." + }, + { + "name": "Enhancement", + "color": "a2eeef", + "description": "New functionality, optimization, or an improvement to existing behavior." + }, + { + "name": "Github_actions", + "color": "5319e7", + "description": "Changes related to GitHub Actions workflows or automation." + }, + { + "name": "good first issue", + "color": "7057ff", + "description": "A good entry point for first-time contributors." + }, + { + "name": "help wanted", + "color": "008672", + "description": "Extra attention is needed from contributors or maintainers." + }, + { + "name": "Invalid", + "color": "e4e669", + "description": "This issue or pull request is not valid as filed." + }, + { + "name": "Maintenance", + "color": "fbca04", + "description": "Routine maintenance or upkeep work." + }, + { + "name": "Security", + "color": "b60205", + "description": "Security-related work, fixes, or hardening." + }, + { + "name": "Ux", + "color": "1d76db", + "description": "User experience, usability, or interaction design work." + }, + { + "name": "Wontfix", + "color": "ffffff", + "description": "This will not be worked on or accepted." + }, + { + "name": "stakeholder", + "color": "0e8a16", + "description": "Requires input from or coordination with project stakeholders." + }, + { + "name": "needs-adr", + "color": "5319e7", + "description": "An architecture decision record is needed before proceeding." + } + ] +} diff --git a/scripts/sync_labels.py b/scripts/sync_labels.py new file mode 100644 index 0000000..0631a7e --- /dev/null +++ b/scripts/sync_labels.py @@ -0,0 +1,290 @@ +#!/usr/bin/env python3 +"""Synchronize GitHub labels across complytime repositories. + +This tool intentionally supports three behaviors: +1. Ensure a core set of standard labels exists everywhere. +2. Rename legacy labels in-place only where they already exist. +3. Delete only explicitly-listed labels, leaving all other local labels alone. +""" + +from __future__ import annotations + +import argparse +import json +import os +import sys +import urllib.error +import urllib.parse +import urllib.request +from typing import Dict, Iterable, List, Tuple + + +API_ROOT = "https://api.github.com" + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Sync labels across GitHub repositories") + parser.add_argument("--policy", default="labels-policy.json", help="Path to the label policy JSON file") + parser.add_argument("--org", help="GitHub organization to manage; defaults to policy value") + parser.add_argument("--repos", nargs="*", help="Optional subset of repos to target") + parser.add_argument("--dry-run", action="store_true", help="Show intended changes without applying them") + parser.add_argument( + "--token-env", + default="GITHUB_TOKEN", + help="Environment variable that contains the GitHub token", + ) + return parser.parse_args() + + +def load_policy(path: str) -> dict: + with open(path, "r", encoding="utf-8") as handle: + return json.load(handle) + + +def get_token(name: str) -> str: + token = os.getenv(name) + if not token: + raise SystemExit(f"Missing required token environment variable: {name}") + return token + + +def gh_request( + token: str, + method: str, + path: str, + body: dict | None = None, + query: dict | None = None, +) -> dict | list | None: + url = f"{API_ROOT}{path}" + if query: + url = f"{url}?{urllib.parse.urlencode(query)}" + data = None if body is None else json.dumps(body).encode("utf-8") + request = urllib.request.Request( + url=url, + data=data, + method=method, + headers={ + "Accept": "application/vnd.github+json", + "Authorization": f"Bearer {token}", + "X-GitHub-Api-Version": "2022-11-28", + "Content-Type": "application/json", + }, + ) + try: + with urllib.request.urlopen(request, timeout=30) as response: + payload = response.read() + if not payload: + return None + return json.loads(payload.decode("utf-8")) + except urllib.error.HTTPError as exc: + detail = exc.read().decode("utf-8", errors="replace") + raise RuntimeError(f"{method} {path} failed: HTTP {exc.code}: {detail}") from exc + + +def gh_paginate(token: str, path: str) -> List[dict]: + items: List[dict] = [] + page = 1 + while True: + batch = gh_request(token, "GET", path, query={"per_page": 100, "page": page}) + assert isinstance(batch, list) + if not batch: + break + items.extend(batch) + page += 1 + return items + + +def normalize(name: str) -> str: + return name.strip().casefold() + + +def list_repos(token: str, org: str, include: Iterable[str] | None, exclude: Iterable[str]) -> List[str]: + include_set = {repo.strip() for repo in include or [] if repo.strip()} + exclude_set = {repo.strip() for repo in exclude if repo.strip()} + repos = [ + repo["name"] + for repo in gh_paginate(token, f"/orgs/{org}/repos") + if repo.get("name") not in exclude_set + ] + repos.sort() + if include_set: + repos = [repo for repo in repos if repo in include_set] + return repos + + +def list_labels(token: str, org: str, repo: str) -> List[dict]: + return gh_paginate(token, f"/repos/{org}/{repo}/labels") + + +def patch_label( + token: str, + org: str, + repo: str, + current_name: str, + desired: dict, + dry_run: bool, +) -> None: + print(f" update label: {current_name!r} -> {desired['name']!r}") + if dry_run: + return + gh_request( + token, + "PATCH", + f"/repos/{org}/{repo}/labels/{urllib.parse.quote(current_name, safe='')}", + body=desired, + ) + + +def create_label(token: str, org: str, repo: str, desired: dict, dry_run: bool) -> None: + print(f" create label: {desired['name']!r}") + if dry_run: + return + gh_request(token, "POST", f"/repos/{org}/{repo}/labels", body=desired) + + +def delete_label(token: str, org: str, repo: str, name: str, dry_run: bool) -> None: + print(f" delete label: {name!r}") + if dry_run: + return + gh_request( + token, + "DELETE", + f"/repos/{org}/{repo}/labels/{urllib.parse.quote(name, safe='')}", + ) + + +def ensure_standard_labels( + token: str, + org: str, + repo: str, + labels_by_norm: Dict[str, dict], + policy_labels: List[dict], + dry_run: bool, +) -> None: + for desired in policy_labels: + key = normalize(desired["name"]) + existing = labels_by_norm.get(key) + if existing is None: + create_label(token, org, repo, desired, dry_run) + labels_by_norm[key] = {"name": desired["name"], **desired} + continue + + needs_update = ( + existing.get("name") != desired["name"] + or normalize(existing.get("color", "")) != normalize(desired["color"]) + or (existing.get("description") or "") != desired["description"] + ) + if needs_update: + patch_label(token, org, repo, existing["name"], desired, dry_run) + labels_by_norm[key] = {"name": desired["name"], **desired} + + +def apply_rename_only_labels( + token: str, + org: str, + repo: str, + labels_by_norm: Dict[str, dict], + rename_rules: List[dict], + dry_run: bool, +) -> None: + for rule in rename_rules: + desired = rule["to"] + destination_key = normalize(desired["name"]) + destination = labels_by_norm.get(destination_key) + + sources: List[Tuple[str, dict]] = [] + for legacy_name in rule["from"]: + source = labels_by_norm.get(normalize(legacy_name)) + if source is not None: + sources.append((legacy_name, source)) + + if not sources and destination is None: + continue + + if destination is not None: + needs_update = ( + destination.get("name") != desired["name"] + or normalize(destination.get("color", "")) != normalize(desired["color"]) + or (destination.get("description") or "") != desired["description"] + ) + if needs_update: + patch_label(token, org, repo, destination["name"], desired, dry_run) + labels_by_norm[destination_key] = {"name": desired["name"], **desired} + + if destination is None and sources: + source_name = sources[0][1]["name"] + patch_label(token, org, repo, source_name, desired, dry_run) + labels_by_norm[destination_key] = {"name": desired["name"], **desired} + del labels_by_norm[normalize(source_name)] + sources = sources[1:] + + for _, source in sources: + if normalize(source["name"]) == destination_key: + continue + delete_label(token, org, repo, source["name"], dry_run) + labels_by_norm.pop(normalize(source["name"]), None) + + +def apply_deletes( + token: str, + org: str, + repo: str, + labels_by_norm: Dict[str, dict], + names: List[str], + dry_run: bool, +) -> None: + for name in names: + existing = labels_by_norm.get(normalize(name)) + if existing is None: + continue + delete_label(token, org, repo, existing["name"], dry_run) + labels_by_norm.pop(normalize(existing["name"]), None) + + +def main() -> int: + args = parse_args() + policy = load_policy(args.policy) + token = get_token(args.token_env) + org = args.org or policy["org"] + repos = list_repos(token, org, args.repos, policy["exclude_repos"]) + + if not repos: + print("No repositories selected.") + return 0 + + print(f"Target repositories ({len(repos)}): {', '.join(repos)}") + for repo in repos: + print(f"\n== {repo} ==") + labels = list_labels(token, org, repo) + labels_by_norm = {normalize(label["name"]): label for label in labels} + apply_rename_only_labels( + token, + org, + repo, + labels_by_norm, + policy["rename_only_labels"], + args.dry_run, + ) + ensure_standard_labels( + token, + org, + repo, + labels_by_norm, + policy["standard_labels"], + args.dry_run, + ) + apply_deletes( + token, + org, + repo, + labels_by_norm, + policy["delete_labels"], + args.dry_run, + ) + + print("\nDone.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())