deer-flow/scripts/sync_labels.py
CodeAlex 0e9690493d
fix(scripts): keep sync_labels.py from crashing on non-UTF-8 stdio (#7473)
* fix(scripts): keep sync_labels.py from crashing on non-UTF-8 stdio

The ✓ / ✗ status glyphs and the em dash in the Done line crashed the sync
with UnicodeEncodeError whenever stdout was redirected on a host whose
stdio encoding cannot represent them (Windows code pages, POSIX C locale,
PYTHONIOENCODING=ascii) — after `gh label create --force` had already run,
leaving a half-applied sync and a traceback. gh's UTF-8 diagnostics were
additionally decoded with the host locale, mangling failure messages.
Closes #7389.

- Reconfigure stdout/stderr in main(): UTF-8 with backslashreplace when
  redirected, backslashreplace only on consoles.
- Decode the gh subprocess as UTF-8 with errors="replace", matching
  scripts/support_bundle.py's _run_command.
- Add backend/tests/test_sync_labels_script.py: hermetic offline cases
  (stub yaml, stub gh) running the real script under PYTHONIOENCODING=ascii
  for the success path, a UTF-8 gh failure, and --dry-run. All three go
  red on the unfixed script.

* style(tests): fix sync-labels regression lint

* docs(scripts): document label sync encoding policy

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-10-25 15:30:07 +08:00

108 lines
3.6 KiB
Python

#!/usr/bin/env python3
"""Sync GitHub labels from the declarative source of truth.
Reads ``.github/labels.yml`` and creates/updates each label via the GitHub CLI
(``gh label create --force``). Sync is additive/update-only: labels not listed
in the file are left untouched (never deleted).
Usage:
uv run --with pyyaml python scripts/sync_labels.py [--repo OWNER/NAME] [--dry-run]
Requires the ``gh`` CLI to be installed and authenticated (or ``GH_TOKEN`` set,
as in CI). When ``--repo`` is omitted, ``gh`` uses the current repository.
"""
from __future__ import annotations
import argparse
import subprocess
import sys
from pathlib import Path
try:
import yaml
except ModuleNotFoundError: # pragma: no cover - guidance for local runs
sys.exit(
"PyYAML is required. Run via:\n"
" uv run --with pyyaml python scripts/sync_labels.py"
)
LABELS_FILE = Path(__file__).resolve().parent.parent / ".github" / "labels.yml"
def load_labels(path: Path) -> list[dict[str, str]]:
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
labels = data.get("labels")
if not isinstance(labels, list) or not labels:
sys.exit(f"No labels found in {path}")
for label in labels:
if not isinstance(label, dict) or "name" not in label:
sys.exit(f"Invalid label entry (missing 'name'): {label!r}")
return labels
def sync_label(label: dict[str, str], repo: str | None, dry_run: bool) -> bool:
name = str(label["name"])
color = str(label.get("color", "ededed")).lstrip("#")
description = str(label.get("description", ""))
cmd = ["gh", "label", "create", name, "--color", color, "--force"]
if description:
cmd += ["--description", description]
if repo:
cmd += ["--repo", repo]
if dry_run:
print(f"[dry-run] {' '.join(cmd)}")
return True
# gh emits UTF-8; decoding with the host locale mangles (or crashes on)
# failure messages containing non-ASCII on cp1252/cp936/C-locale hosts.
result = subprocess.run(cmd, capture_output=True, encoding="utf-8", errors="replace")
if result.returncode != 0:
print(f" ✗ {name}: {result.stderr.strip()}", file=sys.stderr)
return False
print(f" ✓ {name}")
return True
def main() -> int:
# The status glyphs (✓ / ✗) must survive hosts whose stdio encoding cannot
# represent them (Windows code pages, POSIX C locale): the crash happens
# after `gh label create --force` has already run, leaving a half-applied
# sync and a traceback instead of a status line.
for stream in (sys.stdout, sys.stderr):
if not hasattr(stream, "reconfigure"):
continue
if stream.isatty():
stream.reconfigure(errors="backslashreplace")
else:
stream.reconfigure(encoding="utf-8", errors="backslashreplace")
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--repo", help="Target repository as OWNER/NAME")
parser.add_argument(
"--dry-run",
action="store_true",
help="Print the gh commands without executing them",
)
args = parser.parse_args()
labels = load_labels(LABELS_FILE)
target = args.repo or "(current repository)"
print(f"Syncing {len(labels)} labels to {target}")
failures = sum(
0 if sync_label(label, args.repo, args.dry_run) else 1 for label in labels
)
if failures:
print(f"\n{failures} label(s) failed to sync", file=sys.stderr)
return 1
print(f"\nDone — {len(labels)} labels in sync.")
return 0
if __name__ == "__main__":
raise SystemExit(main())