#!/usr/bin/env python3 """Create one or more Gitea issues from a JSON spec.""" from __future__ import annotations import argparse import json import os import re import subprocess import sys import urllib.error import urllib.parse import urllib.request SSH_RE = re.compile(r"^git@(?P[^:]+):(?P.+?)(?:\.git)?/?$") REPO_PATH_RE = re.compile(r"^(?P[^/]+)/(?P[^/]+)$") def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser( description="Batch-create Gitea issues from a JSON spec." ) parser.add_argument("spec", help="Path to JSON spec file") parser.add_argument( "--repo-url", help=( "Override target repo. Accepts full https URL, SSH git origin, " "or owner/repo with GITEA_BASE_URL." ), ) parser.add_argument( "--repo", help="Shorthand owner/repo. Requires GITEA_BASE_URL.", ) parser.add_argument( "--dry-run", action="store_true", help="Validate and print the payload without creating issues", ) return parser.parse_args() def load_token(required: bool = True) -> str: token = os.getenv("GITEA_TOKEN") if not token and required: raise SystemExit( "Missing GITEA_TOKEN. Export the token before creating issues." ) return token or "" def load_spec(path: str) -> dict: with open(path, "r", encoding="utf-8") as handle: data = json.load(handle) if not isinstance(data, dict) or not isinstance(data.get("issues"), list): raise SystemExit("Spec must be an object with an 'issues' array.") if not data["issues"]: raise SystemExit("Spec contains no issues.") return data def normalize_base_url(base_url: str) -> str: return base_url.rstrip("/") def parse_http_repo_url(repo_url: str) -> tuple[str, str, str]: parsed = urllib.parse.urlsplit(repo_url) if parsed.scheme not in {"http", "https"} or not parsed.netloc: raise SystemExit("Invalid repo URL. Expected format: https://host/owner/repo") path = parsed.path.rstrip("/") if path.endswith(".git"): path = path[:-4] parts = [part for part in path.split("/") if part] if len(parts) < 2: raise SystemExit("Invalid repo URL. Expected format: https://host/owner/repo") owner, repo = parts[-2], parts[-1] prefix = "/".join(parts[:-2]) origin = f"{parsed.scheme}://{parsed.netloc}" if prefix: origin = f"{origin}/{prefix}" return origin, owner, repo def parse_repo_target(repo_target: str, base_url: str | None = None) -> tuple[str, str, str, str]: value = repo_target.strip() if not value: raise SystemExit("Repo target cannot be empty.") if value.startswith("http://") or value.startswith("https://"): origin, owner, repo = parse_http_repo_url(value) repo_url = f"{origin}/{owner}/{repo}" return origin, owner, repo, repo_url if value.startswith("ssh://"): parsed = urllib.parse.urlsplit(value) path = parsed.path.rstrip("/") if path.endswith(".git"): path = path[:-4] parts = [part for part in path.split("/") if part] if len(parts) < 2: raise SystemExit("Invalid SSH repo URL. Expected ssh://git@host/owner/repo.git") owner, repo = parts[-2], parts[-1] host = parsed.hostname if not host: raise SystemExit("Invalid SSH repo URL. Missing host.") origin = normalize_base_url(base_url or f"https://{host}") return origin, owner, repo, f"{origin}/{owner}/{repo}" ssh_match = SSH_RE.match(value) if ssh_match: path = ssh_match.group("path").rstrip("/") if path.endswith(".git"): path = path[:-4] parts = [part for part in path.split("/") if part] if len(parts) < 2: raise SystemExit("Invalid SSH repo target. Expected git@host:owner/repo.git") owner, repo = parts[-2], parts[-1] origin = normalize_base_url(base_url or f"https://{ssh_match.group('host')}") return origin, owner, repo, f"{origin}/{owner}/{repo}" path_match = REPO_PATH_RE.match(value) if path_match: if not base_url: raise SystemExit( "Repo shorthand owner/repo requires GITEA_BASE_URL or --repo-url." ) origin = normalize_base_url(base_url) owner = path_match.group("owner") repo = path_match.group("repo") return origin, owner, repo, f"{origin}/{owner}/{repo}" raise SystemExit( "Invalid repo target. Use https://host/owner/repo, git@host:owner/repo.git, " "ssh://git@host/owner/repo.git, or owner/repo with GITEA_BASE_URL." ) def get_origin_repo_target() -> str: try: return subprocess.check_output( ["git", "remote", "get-url", "origin"], text=True, stderr=subprocess.STDOUT, ).strip() except subprocess.CalledProcessError as exc: raise SystemExit(f"Failed to read git origin: {exc.output.strip()}") from exc def resolve_repo(spec: dict, args: argparse.Namespace) -> tuple[str, str, str, str]: base_url = os.getenv("GITEA_BASE_URL") repo_target = ( args.repo_url or args.repo or spec.get("repo_url") or spec.get("repo") or get_origin_repo_target() ) return parse_repo_target(repo_target, base_url=base_url) def request_json( url: str, token: str, method: str = "GET", payload: dict | None = None, ) -> dict | list: data = None headers = { "Authorization": f"token {token}", "Accept": "application/json", } if payload is not None: data = json.dumps(payload).encode("utf-8") headers["Content-Type"] = "application/json" req = urllib.request.Request(url, data=data, headers=headers, method=method) try: with urllib.request.urlopen(req) as response: return json.load(response) except urllib.error.HTTPError as exc: body = exc.read().decode("utf-8", errors="replace") raise SystemExit( f"Gitea API request failed: {exc.code} {exc.reason} | {body}" ) from exc except urllib.error.URLError as exc: raise SystemExit(f"Failed to reach Gitea API: {exc.reason}") from exc def fetch_label_map(api_base: str, token: str) -> dict[str, int]: data = request_json(f"{api_base}/labels?page=1&limit=100", token) if not isinstance(data, list): raise SystemExit("Unexpected labels API response.") return { item["name"]: item["id"] for item in data if "name" in item and "id" in item } def resolve_label_ids(label_names: list[str], label_map: dict[str, int]) -> tuple[list[int], list[str]]: ids: list[int] = [] missing: list[str] = [] for name in label_names: if name in label_map: ids.append(label_map[name]) else: missing.append(name) return ids, missing def main() -> int: args = parse_args() token = load_token(required=not args.dry_run) spec = load_spec(args.spec) origin, owner, repo, repo_url = resolve_repo(spec, args) api_base = f"{origin}/api/v1/repos/{owner}/{repo}" label_map = fetch_label_map(api_base, token) if token else {} issues = spec["issues"] for issue in issues: if not isinstance(issue, dict): raise SystemExit("Each issue entry must be an object.") if not issue.get("title") or not issue.get("body"): raise SystemExit("Each issue must include non-empty 'title' and 'body'.") for issue in issues: label_names = issue.get("labels") or [] if not isinstance(label_names, list) or not all( isinstance(name, str) for name in label_names ): raise SystemExit("'labels' must be an array of strings.") label_ids, missing_labels = resolve_label_ids(label_names, label_map) payload = { "title": issue["title"], "body": issue["body"], "labels": label_ids, } if args.dry_run: print(f"repo_url={repo_url}") print(f"DRY-RUN\t{issue['title']}") print(json.dumps(payload, ensure_ascii=False, indent=2)) if missing_labels: print(f"missing_labels={missing_labels}") if not token: print("warning\tno token provided, remote label validation skipped") continue created = request_json(f"{api_base}/issues", token, method="POST", payload=payload) if not isinstance(created, dict): raise SystemExit("Unexpected issue creation response.") print( f"#{created.get('number')}\t{created.get('title')}\t{created.get('html_url')}" ) if missing_labels: print(f"warning\tmissing labels skipped: {', '.join(missing_labels)}") return 0 if __name__ == "__main__": sys.exit(main())