name: Auto-label issues on: issues: types: [opened, edited] concurrency: group: auto-label-${{ github.event.issue.number || github.event.issue.index || 'manual' }} cancel-in-progress: true jobs: label: runs-on: mac-ci container: image: mcr.microsoft.com/devcontainers/javascript-node:22-bookworm steps: - name: Apply labels from Type/State/Category env: # IMPORTANT: préfère FORGE_BASE (LAN) si défini, sinon FORGE_API FORGE_BASE: ${{ vars.FORGE_BASE || vars.FORGE_API || vars.FORGE_API_BASE }} FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }} REPO_FULL: ${{ gitea.repository }} EVENT_PATH: ${{ github.event_path }} NODE_OPTIONS: --dns-result-order=ipv4first run: | python3 - <<'PY' import json, os, re, time, urllib.request, urllib.error, socket forge = (os.environ.get("FORGE_BASE") or "").rstrip("/") if not forge: raise SystemExit("Missing FORGE_BASE/FORGE_API repo variable (e.g. http://192.168.1.20:3000)") token = os.environ.get("FORGE_TOKEN") or "" if not token: raise SystemExit("Missing secret FORGE_TOKEN") owner, repo = os.environ["REPO_FULL"].split("/", 1) event_path = os.environ["EVENT_PATH"] with open(event_path, "r", encoding="utf-8") as f: ev = json.load(f) issue = ev.get("issue") or {} title = issue.get("title") or "" body = issue.get("body") or "" number = issue.get("number") or issue.get("index") if not number: raise SystemExit("No issue number/index in event payload") def pick_line(key: str) -> str: m = re.search(rf"^\s*{re.escape(key)}\s*:\s*([^\n\r]+)", body, flags=re.M) return m.group(1).strip() if m else "" desired = set() t = pick_line("Type") s = pick_line("State") c = pick_line("Category") print("PARSED:", {"Type": t, "State": s, "Category": c}) # 1) explicite depuis le body if t: desired.add(t) if s: desired.add(s) if c: desired.add(c) # 2) fallback depuis le titre si Type absent if not t: if title.startswith("[Correction]"): desired.add("type/correction") elif title.startswith("[Fact-check]") or title.startswith("[Vérification]"): desired.add("type/fact-check") # 3) fallback State si absent if not s: if "type/fact-check" in desired: desired.add("state/a-sourcer") elif "type/correction" in desired: desired.add("state/recevable") if not desired: print("No labels to apply.") raise SystemExit(0) api = forge + "/api/v1" headers = { "Authorization": f"token {token}", "Accept": "application/json", "Content-Type": "application/json", "User-Agent": "archicratie-auto-label/1.1", } def jreq(method, url, payload=None, timeout=60, retries=4, backoff=2.0): data = None if payload is None else json.dumps(payload).encode("utf-8") last_err = None for i in range(retries): req = urllib.request.Request(url, data=data, headers=headers, method=method) try: with urllib.request.urlopen(req, timeout=timeout) as r: b = r.read() return json.loads(b.decode("utf-8")) if b else None except urllib.error.HTTPError as e: b = e.read().decode("utf-8", errors="replace") raise RuntimeError(f"HTTP {e.code} {method} {url}\n{b}") from e except (TimeoutError, socket.timeout, urllib.error.URLError) as e: last_err = e # retry only on network/timeout time.sleep(backoff * (i + 1)) raise RuntimeError(f"Network/timeout after retries: {method} {url}\n{last_err}") # labels repo labels = jreq("GET", f"{api}/repos/{owner}/{repo}/labels?limit=1000", timeout=60) or [] name_to_id = {x.get("name"): x.get("id") for x in labels} missing = [x for x in desired if x not in name_to_id] if missing: raise SystemExit("Missing labels in repo: " + ", ".join(sorted(missing))) wanted_ids = sorted({int(name_to_id[x]) for x in desired}) # labels actuels de l'issue current = jreq("GET", f"{api}/repos/{owner}/{repo}/issues/{number}/labels", timeout=60) or [] current_ids = {int(x.get("id")) for x in current if x.get("id") is not None} final_ids = sorted(current_ids.union(wanted_ids)) # Replace labels = union (n'enlève rien) url = f"{api}/repos/{owner}/{repo}/issues/{number}/labels" # IMPORTANT: on n'envoie JAMAIS une liste brute ici (ça a causé le 422) jreq("PUT", url, {"labels": final_ids}, timeout=90, retries=4) # vérif post-apply (anti "timeout mais appliqué") post = jreq("GET", f"{api}/repos/{owner}/{repo}/issues/{number}/labels", timeout=60) or [] post_ids = {int(x.get("id")) for x in post if x.get("id") is not None} missing_ids = [i for i in wanted_ids if i not in post_ids] if missing_ids: raise RuntimeError(f"Labels not applied after PUT (missing ids): {missing_ids}") print(f"OK labels #{number}: {sorted(desired)}") PY