Files
soft_quay/scripts/setup_gitea_labels.py
T
ilaandClaude Fable 5 0184595ccc
Harness governance / validate (push) Has been cancelled
Add harness coding docs for SoftBox (Go + Gio dual-build)
Initialize the full harness coding document set from the
harness_coding_docs template, customized for the SoftBox project:

- Vision, requirements, tech stack (modern Go 1.25 + Gio v0.10.1;
  Win7 legacy Go 1.20.14 + Gio v0.6.0), architecture, coding rules
- Protocol contracts (signed catalog, package protocol v1, Ed25519
  license, events, CLI) and Gio view structure
- Roadmap Phase 0-6 with 20 suggested tasks; T-001 (monorepo
  skeleton) filed and ready to claim
- Agent entry points (AGENTS.md, docs/00-ai-start-here.md),
  context manifest, governance scripts and tests
- Merge Go gitignore with harness rules; keep go.work tracked

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 14:33:54 +08:00

298 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""Preview or idempotently apply Harness Coding labels to one Gitea repo."""
from __future__ import annotations
import argparse
import json
import os
import sys
import urllib.error
import urllib.parse
import urllib.request
from typing import Any
LABELS: tuple[dict[str, Any], ...] = (
{
"name": "kind/task",
"color": "0052CC",
"description": "Harness Coding task",
"exclusive": False,
},
{
"name": "type/docs",
"color": "5319E7",
"description": "Documentation change",
"exclusive": True,
},
{
"name": "type/code",
"color": "1D76DB",
"description": "Code or automation change",
"exclusive": True,
},
{
"name": "status/todo",
"color": "C5DEF5",
"description": "Ready to claim",
"exclusive": True,
},
{
"name": "status/doing",
"color": "FBCA04",
"description": "Claimed and in progress",
"exclusive": True,
},
{
"name": "status/blocked",
"color": "D93F0B",
"description": "Blocked; claim retained",
"exclusive": True,
},
{
"name": "status/review",
"color": "BFD4F2",
"description": "Pull request under review",
"exclusive": True,
},
{
"name": "status/done",
"color": "0E8A16",
"description": "Merged and completed",
"exclusive": True,
},
{
"name": "priority/p0",
"color": "B60205",
"description": "Highest priority",
"exclusive": True,
},
{
"name": "priority/p1",
"color": "D93F0B",
"description": "High priority",
"exclusive": True,
},
{
"name": "priority/p2",
"color": "FBCA04",
"description": "Normal priority",
"exclusive": True,
},
)
class ApiError(RuntimeError):
def __init__(self, status: int, reason: str) -> None:
super().__init__(f"Gitea API 返回 HTTP {status}:{reason}")
self.status = status
class NoRedirect(urllib.request.HTTPRedirectHandler):
"""Never forward the Authorization header to a redirected origin."""
def redirect_request(self, *args: Any, **kwargs: Any) -> None:
return None
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="读取远端差异,并可幂等创建或校正 Harness Coding Gitea 标签。"
)
parser.add_argument(
"--repo",
default=os.environ.get("GITEA_REPOSITORY"),
help="目标 owner/repo;也可设置 GITEA_REPOSITORY。",
)
parser.add_argument(
"--apply",
action="store_true",
help="应用预览中的 create/update;省略时只读远端并打印差异。",
)
return parser.parse_args()
def validate_config(url: str, token: str, repo: str) -> tuple[str, str, str]:
parsed = urllib.parse.urlsplit(url.strip())
if parsed.scheme not in {"http", "https"} or not parsed.hostname:
raise ValueError("GITEA_URL 必须是 http(s) 实例根地址。")
if parsed.username or parsed.password or parsed.query or parsed.fragment:
raise ValueError("GITEA_URL 不得包含凭据、query 或 fragment。")
path = parsed.path.rstrip("/")
if path.lower().endswith("/api/v1"):
raise ValueError("GITEA_URL 不得包含 /api/v1。")
if parsed.scheme == "http" and os.environ.get("GITEA_ALLOW_INSECURE_HTTP") != "1":
raise ValueError("HTTP 需要显式设置 GITEA_ALLOW_INSECURE_HTTP=1。")
if not token:
raise ValueError("缺少 GITEA_TOKEN。")
parts = repo.split("/")
if len(parts) != 2 or not all(parts):
raise ValueError("--repo 必须使用 owner/repo 格式。")
root = urllib.parse.urlunsplit((parsed.scheme, parsed.netloc, path, "", ""))
return root.rstrip("/"), parts[0], parts[1]
class GiteaClient:
def __init__(self, root: str, owner: str, repo: str, token: str) -> None:
owner_q = urllib.parse.quote(owner, safe="")
repo_q = urllib.parse.quote(repo, safe="")
self.base = f"{root}/api/v1/repos/{owner_q}/{repo_q}"
self.token = token
proxy_handler = (
urllib.request.ProxyHandler({})
if os.environ.get("GITEA_DIRECT") == "1"
else urllib.request.ProxyHandler()
)
self.opener = urllib.request.build_opener(proxy_handler, NoRedirect())
def request(
self, method: str, path: str, payload: dict[str, Any] | None = None
) -> Any:
data = None if payload is None else json.dumps(payload).encode("utf-8")
request = urllib.request.Request(
self.base + path,
data=data,
method=method,
headers={
"Accept": "application/json",
"Authorization": f"token {self.token}",
"Content-Type": "application/json",
},
)
try:
with self.opener.open(request, timeout=30) as response:
body = response.read()
return json.loads(body.decode("utf-8")) if body else None
except urllib.error.HTTPError as exc:
raise ApiError(exc.code, exc.reason) from None
except urllib.error.URLError as exc:
raise RuntimeError(f"连接 Gitea 失败:{exc.reason}") from None
def list_labels(self) -> dict[str, dict[str, Any]]:
result: dict[str, dict[str, Any]] = {}
seen_pages: set[tuple[str, ...]] = set()
page = 1
while True:
labels = self.request("GET", f"/labels?limit=50&page={page}")
if not isinstance(labels, list):
raise RuntimeError("Gitea labels 响应格式异常。")
if not labels:
return result
if not all(isinstance(label, dict) for label in labels):
raise RuntimeError("Gitea labels 响应包含非对象条目。")
signature = tuple(str(label.get("id") or label.get("name")) for label in labels)
if signature in seen_pages or page > 1000:
raise RuntimeError("Gitea labels 分页重复,已停止以避免无限读取。")
seen_pages.add(signature)
for label in labels:
if isinstance(label, dict) and isinstance(label.get("name"), str):
result[label["name"]] = label
page += 1
def normalize_color(value: Any) -> str:
return str(value or "").lstrip("#").upper()
def needs_update(current: dict[str, Any], desired: dict[str, Any]) -> bool:
return (
normalize_color(current.get("color")) != desired["color"]
or str(current.get("description") or "") != desired["description"]
or bool(current.get("exclusive")) != desired["exclusive"]
)
def build_plan(
existing: dict[str, dict[str, Any]],
) -> list[tuple[str, dict[str, Any], dict[str, Any] | None]]:
plan = []
for desired in LABELS:
current = existing.get(desired["name"])
if current is None:
action = "create"
elif needs_update(current, desired):
action = "update"
else:
action = "unchanged"
plan.append((action, desired, current))
return plan
def show_plan(repo: str, plan: list[tuple[str, dict[str, Any], Any]]) -> None:
print(f"目标仓库:{repo}")
for action, desired, _ in plan:
scope = "exclusive" if desired["exclusive"] else "normal"
print(f"- {action:9} {desired['name']} #{desired['color']} {scope}")
counts = {name: sum(action == name for action, _, _ in plan) for name in (
"create",
"update",
"unchanged",
)}
print(
"计划汇总:"
f"创建 {counts['create']},更新 {counts['update']},未变化 {counts['unchanged']}。"
)
def apply_plan(
client: GiteaClient,
plan: list[tuple[str, dict[str, Any], dict[str, Any] | None]],
) -> None:
created = updated = unchanged = 0
for action, desired, current in plan:
if action == "unchanged":
unchanged += 1
continue
if action == "update":
label_id = None if current is None else current.get("id")
if not isinstance(label_id, int):
raise RuntimeError(f"标签 {desired['name']} 缺少数字 id。")
client.request("PATCH", f"/labels/{label_id}", dict(desired))
updated += 1
continue
try:
client.request("POST", "/labels", dict(desired))
created += 1
except ApiError as exc:
if exc.status != 422:
raise
latest = client.list_labels().get(desired["name"])
if latest is None:
raise
if needs_update(latest, desired):
label_id = latest.get("id")
if not isinstance(label_id, int):
raise RuntimeError(f"标签 {desired['name']} 缺少数字 id。")
client.request("PATCH", f"/labels/{label_id}", dict(desired))
updated += 1
else:
unchanged += 1
print(f"标签同步完成:创建 {created},更新 {updated},未变化 {unchanged}。")
def main() -> int:
args = parse_args()
if not args.repo:
print("ERROR: 需要 --repo owner/repo 或 GITEA_REPOSITORY。", file=sys.stderr)
return 2
url = os.environ.get("GITEA_URL", "")
token = os.environ.get("GITEA_TOKEN", "")
try:
root, owner, name = validate_config(url, token, args.repo)
client = GiteaClient(root, owner, name, token)
plan = build_plan(client.list_labels())
show_plan(args.repo, plan)
if not args.apply:
print("dry-run:未写入;追加 --apply 才会应用上述 create/update。")
return 0
apply_plan(client, plan)
return 0
except (ValueError, ApiError, RuntimeError) as exc:
print(f"ERROR: {exc}", file=sys.stderr)
return 2
if __name__ == "__main__":
raise SystemExit(main())