From eb86c8228df28d5994b779dace6029c489da3548 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Agust=C3=ADn=20M=C3=A9ndez?= Date: Sun, 13 Sep 2026 15:43:16 -0500 Subject: [PATCH 1/2] Replace unmaintained labels CLI with a stdlib label sync script Port of the fix applied to django-pagination-py3 (commit bcfa5b7). The "Sync Github labels" workflow has been failing since GitHub made label archiving GA (2026-08-27). Every label object in the REST API now carries `archived_at` and `archived_by`, and the `labels` package (hackebrot/labels) deserialises responses into a strict frozen attrs class: TypeError: Label.__init__() got an unexpected keyword argument 'archived_at' That package last shipped in May 2020 and the upstream issue for this exact error is still open with no response, so pinning cannot help. GitHub also adds fields over time, so the replacement reads only the fields it manages and ignores everything else. - add .github/sync_labels.py: stdlib only (tomllib + urllib), no pip install needed; keeps .github/labels.toml as the source of truth and reproduces `labels sync` semantics (create missing, update drifted, delete extras), plus --dry-run, --no-delete, config validation and real exit codes - run it from the workflow and scope permissions explicitly (contents: read, issues: write) instead of relying on the repo default - compare colours case-insensitively: the old CLI diffed "687DEA" against GitHub's normalised "687dea" and re-PATCHed those labels on every run - rename the github-actions label to Dependabot's own github_actions, so the sync stops deleting the label Dependabot recreates on every bump The remote dry-run against matagus/quickup reports: github_actions recoloured 000000 -> 595e8a, the unused hyphen variant deleted, 28 labels unchanged. --- .github/labels.toml | 6 +- .github/sync_labels.py | 442 +++++++++++++++++++++++++++++++++++ .github/workflows/labels.yml | 18 +- 3 files changed, 459 insertions(+), 7 deletions(-) create mode 100755 .github/sync_labels.py diff --git a/.github/labels.toml b/.github/labels.toml index 3503e70..c92ee17 100644 --- a/.github/labels.toml +++ b/.github/labels.toml @@ -63,10 +63,10 @@ color = "AF6601" name = "feature request" description = "Feature Requests" -[github-actions] +[github_actions] color = "595E8A" -name = "github-actions" -description = "Github Actions" +name = "github_actions" +description = "Pull requests that update GitHub Actions code" ["good first issue"] color = "2AF655" diff --git a/.github/sync_labels.py b/.github/sync_labels.py new file mode 100755 index 0000000..7d8142a --- /dev/null +++ b/.github/sync_labels.py @@ -0,0 +1,442 @@ +#!/usr/bin/env python3 +"""Synchronise GitHub issue labels with ``.github/labels.toml``. + +The TOML file is the single source of truth: labels missing on the remote are +created, labels whose colour or description drifted are updated, and remote +labels that are not declared in the config are deleted (see ``--no-delete``). + +Why this script exists +---------------------- +The workflow used to run the ``labels`` CLI from PyPI (hackebrot/labels). That +project has had no release since 2020 and it deserialises API responses into a +strict ``attrs`` class, so it died the moment GitHub added the ``archived_at`` +field to label objects (label archiving went GA on 2026-08-27):: + + TypeError: Label.__init__() got an unexpected keyword argument 'archived_at' + +This replacement only uses the Python standard library and reads exactly the +fields it needs, so additional fields in API responses can never break it +again. + +Requires Python 3.11+ (for :mod:`tomllib`). + +Usage +----- +In CI (owner/repo/token come from the environment):: + + python .github/sync_labels.py + +Locally:: + + export GITHUB_TOKEN=ghp_xxx + python .github/sync_labels.py --dry-run + +Exit codes: ``0`` success, ``1`` an API call failed, ``2`` bad usage/config. +""" + +import argparse +import json +import os +from pathlib import Path +import re +import sys +import tomllib +from typing import Any +import urllib.error +import urllib.parse +import urllib.request + +API_VERSION = "2022-11-28" +USER_AGENT = "quickup-label-sync" +PER_PAGE = 100 +DESCRIPTION_MAX_LENGTH = 100 + +# Fields this script manages. Anything else GitHub returns (id, node_id, url, +# default, archived_at, ...) is deliberately ignored. +_NEXT_LINK = re.compile(r'<([^>]+)>;\s*rel="next"') + + +class SyncError(Exception): + """Raised when the local config is invalid.""" + + +def _normalise_color(color: str, *, label_name: str) -> str: + """Return a colour as GitHub stores it: 6 lowercase hex digits, no ``#``.""" + value = color.strip().lstrip("#").lower() + if not re.fullmatch(r"[0-9a-f]{6}", value): + raise SyncError(f"label '{label_name}': invalid color {color!r} (expected RRGGBB)") + return value + + +def read_config(path: Path) -> dict[str, dict[str, str]]: + """Parse the labels TOML file into ``{name: {color, description}}``.""" + try: + with path.open("rb") as handle: + raw: dict[str, Any] = tomllib.load(handle) + except FileNotFoundError: + raise SyncError(f"config file not found: {path}") from None + except tomllib.TOMLDecodeError as exc: + raise SyncError(f"invalid TOML in {path}: {exc}") from None + + labels: dict[str, dict[str, str]] = {} + for section, table in raw.items(): + if not isinstance(table, dict): + raise SyncError(f"label '{section}': expected a TOML table") + + name = str(table.get("name", section)).strip() + if name != section: + raise SyncError(f"label '{section}': section name must equal the 'name' field " f"(got {name!r})") + if not name: + raise SyncError(f"label '{section}': name must not be empty") + + color = table.get("color") + if not isinstance(color, str): + raise SyncError(f"label '{name}': 'color' is required and must be a string") + + description = table.get("description", "") or "" + if not isinstance(description, str): + raise SyncError(f"label '{name}': 'description' must be a string") + if len(description) > DESCRIPTION_MAX_LENGTH: + raise SyncError( + f"label '{name}': description is {len(description)} chars, " + f"GitHub allows at most {DESCRIPTION_MAX_LENGTH}" + ) + + labels[name] = { + "color": _normalise_color(color, label_name=name), + "description": description.strip(), + } + return labels + + +def _request( + method: str, + url: str, + token: str, + payload: dict[str, Any] | None = None, +) -> tuple[int, dict[str, str], Any]: + """Perform an API request, returning ``(status, headers, parsed_body)``.""" + data = json.dumps(payload).encode() if payload is not None else None + request = urllib.request.Request(url, data=data, method=method) + request.add_header("Authorization", f"Bearer {token}") + request.add_header("Accept", "application/vnd.github+json") + request.add_header("X-GitHub-Api-Version", API_VERSION) + request.add_header("User-Agent", USER_AGENT) + if data is not None: + request.add_header("Content-Type", "application/json") + + try: + with urllib.request.urlopen(request) as response: + status = response.status + headers = {k.lower(): v for k, v in response.headers.items()} + body = response.read().decode() + except urllib.error.HTTPError as exc: + status = exc.code + headers = {k.lower(): v for k, v in exc.headers.items()} + body = exc.read().decode() + except urllib.error.URLError as exc: + raise SyncError(f"{method} {url} failed: {exc.reason}") from None + + parsed: Any = None + if body.strip(): + try: + parsed = json.loads(body) + except json.JSONDecodeError: + parsed = body.strip() + return status, headers, parsed + + +def _error_detail(status: int, parsed: Any) -> str: + """Build a readable error message from an API response.""" + if isinstance(parsed, dict): + message = parsed.get("message") or parsed.get("error") or str(parsed) + errors = parsed.get("errors") + if errors: + message = f"{message} ({errors})" + return f"HTTP {status}: {message}" + return f"HTTP {status}: {parsed}" + + +def list_labels(api_url: str, repo: str, token: str) -> list[dict[str, Any]]: + """Return every label of ``repo``, following pagination.""" + labels: list[dict[str, Any]] = [] + url: str | None = f"{api_url}/repos/{repo}/labels?per_page={PER_PAGE}" + while url: + status, headers, parsed = _request("GET", url, token) + if status != 200: + raise SyncError(f"listing labels failed: {_error_detail(status, parsed)}") + if not isinstance(parsed, list): + raise SyncError(f"unexpected response listing labels: {parsed!r}") + labels.extend(parsed) + match = _NEXT_LINK.search(headers.get("link", "")) + url = match.group(1) if match else None + return labels + + +def _mutate( + method: str, + url: str, + token: str, + expected: int, + action: str, + payload: dict[str, Any] | None = None, +) -> bool: + """Run a create/update/delete call; report and swallow failures.""" + status, _, parsed = _request(method, url, token, payload) + if status == expected: + return True + print(f" !! {action} failed: {_error_detail(status, parsed)}", file=sys.stderr) + return False + + +def compute_diff( + local: dict[str, dict[str, str]], + remote: list[dict[str, Any]], + *, + delete_extras: bool, +) -> dict[str, list[tuple[str, str | None, str | None]]]: + """Group labels into create/update/delete/unchanged buckets. + + Each entry is ``(name, old_value, new_value)`` for the changed fields, so + the report can show what actually differs. + """ + remote_by_name: dict[str, dict[str, Any]] = {} + for entry in remote: + name = str(entry.get("name", "")) + if name: + remote_by_name[name] = entry + + buckets: dict[str, list[tuple[str, str | None, str | None]]] = { + "create": [], + "update": [], + "delete": [], + "unchanged": [], + } + + for name, wanted in sorted(local.items()): + current = remote_by_name.get(name) + if current is None: + buckets["create"].append((name, None, None)) + continue + + changes: list[tuple[str, str | None, str | None]] = [] + remote_color = str(current.get("color") or "").strip().lstrip("#").lower() + if remote_color != wanted["color"]: + changes.append(("color", remote_color, wanted["color"])) + remote_description = (current.get("description") or "").strip() + if remote_description != wanted["description"]: + changes.append(("description", remote_description, wanted["description"])) + + if changes: + for field, old, new in changes: + buckets["update"].append((name, f"{field}: {old!r}", repr(new))) + else: + buckets["unchanged"].append((name, None, None)) + + if delete_extras: + for name in sorted(remote_by_name): + if name not in local: + buckets["delete"].append((name, None, None)) + + return buckets + + +def _remote_meta(remote: list[dict[str, Any]], name: str) -> str: + """Return a note about the remote label, e.g. its archived state.""" + for entry in remote: + if str(entry.get("name", "")) == name and entry.get("archived_at"): + return " (archived remotely; archive state is left untouched)" + return "" + + +def print_report( + buckets: dict[str, list[tuple[str, str | None, str | None]]], + remote: list[dict[str, Any]], + *, + dry_run: bool, +) -> None: + """Print what will happen (dry run) or what happened.""" + prefix = "Would " if dry_run else "" + if buckets["create"]: + print(f"{prefix}create {len(buckets['create'])} label(s):") + for name, _, _ in buckets["create"]: + print(f" + {name}") + if buckets["update"]: + # Updates are recorded per changed field; report unique label names. + names = sorted({name for name, _, _ in buckets["update"]}) + print(f"{prefix}update {len(names)} label(s):") + for name in names: + changes = [f"{old} -> {new}" for label_name, old, new in buckets["update"] if label_name == name] + print(f" ~ {name}: {', '.join(changes)}{_remote_meta(remote, name)}") + if buckets["delete"]: + print(f"{prefix}delete {len(buckets['delete'])} label(s):") + for name, _, _ in buckets["delete"]: + print(f" - {name}") + if not (buckets["create"] or buckets["update"] or buckets["delete"]): + print("Nothing to do: remote labels already match the config.") + else: + print(f"Unchanged: {len({n for n, _, _ in buckets['unchanged']})} label(s)") + + +def apply_changes( + buckets: dict[str, list[tuple[str, str | None, str | None]]], + local: dict[str, dict[str, str]], + *, + api_url: str, + repo: str, + token: str, +) -> bool: + """Apply create/update/delete calls. Returns False if any call failed.""" + base = f"{api_url}/repos/{repo}/labels" + ok = True + + # Delete first so a rename in the config cannot collide with an existing + # label (GitHub rejects duplicate names on create). + for name, _, _ in buckets["delete"]: + url = f"{base}/{urllib.parse.quote(name, safe='')}" + print(f"Deleting '{name}' ...") + ok &= _mutate("DELETE", url, token, 204, f"deleting '{name}'") + + for name in sorted({n for n, _, _ in buckets["update"]}): + wanted = local[name] + url = f"{base}/{urllib.parse.quote(name, safe='')}" + print(f"Updating '{name}' ...") + ok &= _mutate( + "PATCH", + url, + token, + 200, + f"updating '{name}'", + { + "new_name": name, + "color": wanted["color"], + "description": wanted["description"], + }, + ) + + for name, _, _ in buckets["create"]: + wanted = local[name] + print(f"Creating '{name}' ...") + ok &= _mutate( + "POST", + base, + token, + 201, + f"creating '{name}'", + { + "name": name, + "color": wanted["color"], + "description": wanted["description"], + }, + ) + return ok + + +def parse_args(argv: list[str] | None = None) -> argparse.Namespace: + repository = os.environ.get("GITHUB_REPOSITORY", "") + default_owner, _, default_repo = repository.partition("/") + + parser = argparse.ArgumentParser( + description="Sync GitHub issue labels from a TOML config.", + ) + parser.add_argument( + "-c", + "--config", + type=Path, + default=Path(".github/labels.toml"), + help="path to the labels TOML file (default: %(default)s)", + ) + parser.add_argument( + "-o", + "--owner", + default=default_owner or None, + help="repository owner (default: from $GITHUB_REPOSITORY)", + ) + parser.add_argument( + "-r", + "--repo", + default=default_repo or None, + help="repository name (default: from $GITHUB_REPOSITORY)", + ) + parser.add_argument( + "-t", + "--token", + default=os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN"), + help="GitHub token (default: $GITHUB_TOKEN or $GH_TOKEN)", + ) + parser.add_argument( + "--api-url", + default=os.environ.get("GITHUB_API_URL", "https://api.github.com"), + help="GitHub API root (default: $GITHUB_API_URL or https://api.github.com)", + ) + parser.add_argument( + "-n", + "--dry-run", + action="store_true", + help="report what would change without touching the remote", + ) + parser.add_argument( + "--no-delete", + action="store_true", + help="keep remote labels that are missing from the config", + ) + return parser.parse_args(argv) + + +def main(argv: list[str] | None = None) -> int: + args = parse_args(argv) + + missing = [ + f"--{name}" for name, value in (("owner", args.owner), ("repo", args.repo), ("token", args.token)) if not value + ] + if missing: + print( + f"error: missing required value(s): {', '.join(missing)} " + "(set GITHUB_REPOSITORY / GITHUB_TOKEN or pass them explicitly)", + file=sys.stderr, + ) + return 2 + + repo = f"{args.owner}/{args.repo}" + try: + local = read_config(args.config) + except SyncError as exc: + print(f"error: {exc}", file=sys.stderr) + return 2 + + print(f"Repository: {repo}") + print(f"Config: {args.config} ({len(local)} label(s))") + + try: + remote = list_labels(args.api_url, repo, args.token) + except SyncError as exc: + print(f"error: {exc}", file=sys.stderr) + return 1 + print(f"Remote: {len(remote)} label(s)") + print() + + buckets = compute_diff(local, remote, delete_extras=not args.no_delete) + print_report(buckets, remote, dry_run=args.dry_run) + + if args.dry_run: + return 0 + if not (buckets["create"] or buckets["update"] or buckets["delete"]): + return 0 + + print() + try: + ok = apply_changes( + buckets, + local, + api_url=args.api_url, + repo=repo, + token=args.token, + ) + except SyncError as exc: + print(f"error: {exc}", file=sys.stderr) + return 1 + return 0 if ok else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/workflows/labels.yml b/.github/workflows/labels.yml index 1c6ca09..76c3871 100644 --- a/.github/workflows/labels.yml +++ b/.github/workflows/labels.yml @@ -6,17 +6,27 @@ on: - main paths: - ".github/**" + workflow_dispatch: + +permissions: + contents: read jobs: labels: runs-on: ubuntu-latest + permissions: + contents: read + issues: write steps: - uses: actions/checkout@v7 - name: Set up Python uses: actions/setup-python@v7 with: - python-version: 3.x - - name: Install labels - run: pip install labels + python-version: "3.x" + # Self-contained script: the `labels` PyPI package (hackebrot/labels) is + # unmaintained since 2020 and crashes on the `archived_at` field GitHub + # added to label objects in Aug 2026. See .github/sync_labels.py. - name: Sync config with Github - run: labels -u ${{ github.repository_owner }} -t ${{ secrets.GITHUB_TOKEN }} sync -f .github/labels.toml + run: python .github/sync_labels.py --config .github/labels.toml + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} From 64d55f15c1e4f16dba5ad6692262424de3b25b14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Agust=C3=ADn=20M=C3=A9ndez?= Date: Sun, 13 Sep 2026 16:01:09 -0500 Subject: [PATCH 2/2] Fix issue for python 3.10 related to toml/tomlib --- .github/sync_labels.py | 11 ++++++++--- .pre-commit-config.yaml | 8 ++++++++ pyproject.toml | 4 +++- 3 files changed, 19 insertions(+), 4 deletions(-) diff --git a/.github/sync_labels.py b/.github/sync_labels.py index 7d8142a..49cb2e7 100755 --- a/.github/sync_labels.py +++ b/.github/sync_labels.py @@ -16,9 +16,10 @@ This replacement only uses the Python standard library and reads exactly the fields it needs, so additional fields in API responses can never break it -again. +again. The only exception is TOML parsing on Python 3.10, which has no +:mod:`tomllib` and falls back to the ``tomli`` backport. -Requires Python 3.11+ (for :mod:`tomllib`). +Requires Python 3.10+ (``pip install tomli`` on 3.10 only). Usage ----- @@ -40,12 +41,16 @@ from pathlib import Path import re import sys -import tomllib from typing import Any import urllib.error import urllib.parse import urllib.request +if sys.version_info >= (3, 11): + import tomllib +else: # Python 3.10: tomllib is not in the stdlib yet + import tomli as tomllib + API_VERSION = "2022-11-28" USER_AGENT = "quickup-label-sync" PER_PAGE = 100 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 7478e0d..0d416f4 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -84,9 +84,17 @@ repos: - "inquirer" - "cyclopts" - "pytest" + # .github/sync_labels.py falls back to tomli under Python 3.10, which + # is the interpreter this hook's venv uses in the 3.10 CI matrix job. + - "tomli" - repo: https://github.com/asottile/pyupgrade rev: v3.21.2 hooks: - id: pyupgrade args: ["--py312-plus"] + # The CI helper scripts must stay runnable on the workflow's interpreter, + # which is not necessarily 3.12+. pyupgrade --py312-plus rewrites away + # their `sys.version_info` guards (e.g. the tomllib/tomli fallback in + # .github/sync_labels.py), so leave them alone. + exclude: ^\.github/ diff --git a/pyproject.toml b/pyproject.toml index 243505b..f3a1959 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,7 +36,9 @@ dependencies = [ [project.optional-dependencies] dev = [ - "pytest", "pytest-mock", "pytest-cov", "coverage" + "pytest", "pytest-mock", "pytest-cov", "coverage", + # .github/sync_labels.py needs a TOML parser on Python 3.10 (no tomllib) + "tomli; python_version < '3.11'", ] docs = [ "sphinx>=7.1.2,<8",