212 lines
8.3 KiB
Python
Executable File
212 lines
8.3 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Tiny Gitea CLI for common local automation.
|
|
|
|
Defaults are tailored for this host:
|
|
GITEA_URL=http://192.168.33.22:3300
|
|
GITEA_USER=gemma
|
|
|
|
Credentials are read in this order:
|
|
1. GITEA_TOKEN (sent as token <value>)
|
|
2. GITEA_PASSWORD (HTTP basic auth with GITEA_USER)
|
|
3. /root/PASSWORD.md line like: gitea <password>
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import base64
|
|
import json
|
|
import os
|
|
import re
|
|
import sys
|
|
from pathlib import Path
|
|
from typing import Any
|
|
from urllib.error import HTTPError, URLError
|
|
from urllib.parse import urlencode
|
|
from urllib.request import Request, urlopen
|
|
|
|
DEFAULT_URL = "http://192.168.33.22:3300"
|
|
DEFAULT_USER = "gemma"
|
|
PASSWORD_FILE = Path(os.environ.get("GITEA_PASSWORD_FILE", "/root/PASSWORD.md"))
|
|
|
|
|
|
def load_password() -> str | None:
|
|
if os.environ.get("GITEA_PASSWORD"):
|
|
return os.environ["GITEA_PASSWORD"]
|
|
try:
|
|
text = PASSWORD_FILE.read_text(encoding="utf-8")
|
|
except OSError:
|
|
return None
|
|
for line in text.splitlines():
|
|
m = re.match(r"^\s*gitea\s+(.+?)\s*$", line, flags=re.I)
|
|
if m:
|
|
return m.group(1).strip()
|
|
return None
|
|
|
|
|
|
class Gitea:
|
|
def __init__(self, base_url: str, user: str, token: str | None, password: str | None):
|
|
self.base_url = base_url.rstrip("/")
|
|
self.user = user
|
|
self.token = token
|
|
self.password = password
|
|
|
|
def request(self, method: str, path: str, body: dict[str, Any] | None = None, query: dict[str, Any] | None = None) -> Any:
|
|
qs = f"?{urlencode({k: v for k, v in (query or {}).items() if v is not None})}" if query else ""
|
|
url = f"{self.base_url}/api/v1{path}{qs}"
|
|
data = None
|
|
headers = {"Accept": "application/json", "User-Agent": "local-gitea-cli/0.1"}
|
|
if body is not None:
|
|
data = json.dumps(body).encode("utf-8")
|
|
headers["Content-Type"] = "application/json"
|
|
if self.token:
|
|
headers["Authorization"] = f"token {self.token}"
|
|
elif self.password:
|
|
raw = f"{self.user}:{self.password}".encode("utf-8")
|
|
headers["Authorization"] = "Basic " + base64.b64encode(raw).decode("ascii")
|
|
req = Request(url, data=data, headers=headers, method=method.upper())
|
|
try:
|
|
with urlopen(req, timeout=20) as res:
|
|
payload = res.read().decode("utf-8")
|
|
return json.loads(payload) if payload else None
|
|
except HTTPError as e:
|
|
detail = e.read().decode("utf-8", "replace")
|
|
try:
|
|
parsed = json.loads(detail)
|
|
detail = parsed.get("message") or parsed.get("error") or detail
|
|
except Exception:
|
|
pass
|
|
raise SystemExit(f"Gitea API error {e.code} {e.reason}: {detail}") from e
|
|
except URLError as e:
|
|
raise SystemExit(f"Gitea connection error: {e.reason}") from e
|
|
|
|
|
|
def print_table(rows: list[dict[str, Any]], cols: list[tuple[str, str]]) -> None:
|
|
if not rows:
|
|
print("No repositories.")
|
|
return
|
|
values = [[str(row.get(key, "")) for key, _ in cols] for row in rows]
|
|
widths = [max(len(title), *(len(v[i]) for v in values)) for i, (_, title) in enumerate(cols)]
|
|
print(" ".join(title.ljust(widths[i]) for i, (_, title) in enumerate(cols)))
|
|
print(" ".join("-" * widths[i] for i in range(len(cols))))
|
|
for vals in values:
|
|
print(" ".join(vals[i].ljust(widths[i]) for i in range(len(cols))))
|
|
|
|
|
|
def cmd_version(api: Gitea, _args: argparse.Namespace) -> None:
|
|
print(json.dumps(api.request("GET", "/version"), indent=2))
|
|
|
|
|
|
def cmd_list(api: Gitea, args: argparse.Namespace) -> None:
|
|
repos: list[dict[str, Any]] = []
|
|
page = 1
|
|
while True:
|
|
path = f"/orgs/{args.org}/repos" if args.org else "/user/repos"
|
|
chunk = api.request("GET", path, query={"page": page, "limit": args.limit})
|
|
if not chunk:
|
|
break
|
|
repos.extend(chunk)
|
|
if len(chunk) < args.limit:
|
|
break
|
|
page += 1
|
|
if args.json:
|
|
print(json.dumps(repos, indent=2))
|
|
return
|
|
rows = []
|
|
for r in repos:
|
|
rows.append({
|
|
"name": r.get("full_name") or r.get("name"),
|
|
"private": "yes" if r.get("private") else "no",
|
|
"default": r.get("default_branch") or "",
|
|
"ssh": r.get("ssh_url") or "",
|
|
"html": r.get("html_url") or "",
|
|
})
|
|
print_table(rows, [("name", "REPO"), ("private", "PRIVATE"), ("default", "BRANCH"), ("ssh", "SSH"), ("html", "URL")])
|
|
|
|
|
|
def cmd_create(api: Gitea, args: argparse.Namespace) -> None:
|
|
body: dict[str, Any] = {
|
|
"name": args.name,
|
|
"private": args.private,
|
|
"auto_init": args.init,
|
|
}
|
|
if args.description:
|
|
body["description"] = args.description
|
|
if args.default_branch:
|
|
body["default_branch"] = args.default_branch
|
|
if args.gitignores:
|
|
body["gitignores"] = args.gitignores
|
|
if args.license:
|
|
body["license"] = args.license
|
|
if args.readme:
|
|
body["readme"] = args.readme
|
|
if args.dry_run:
|
|
target = f"/orgs/{args.org}/repos" if args.org else "/user/repos"
|
|
print(f"DRY RUN POST {api.base_url}/api/v1{target}")
|
|
print(json.dumps(body, indent=2))
|
|
return
|
|
path = f"/orgs/{args.org}/repos" if args.org else "/user/repos"
|
|
repo = api.request("POST", path, body=body)
|
|
print(f"Created: {repo.get('full_name') or repo.get('name')}")
|
|
print(f"URL: {repo.get('html_url')}")
|
|
print(f"SSH: {repo.get('ssh_url')}")
|
|
|
|
|
|
def cmd_get(api: Gitea, args: argparse.Namespace) -> None:
|
|
owner, name = args.repo.split("/", 1) if "/" in args.repo else (api.user, args.repo)
|
|
repo = api.request("GET", f"/repos/{owner}/{name}")
|
|
if args.json:
|
|
print(json.dumps(repo, indent=2))
|
|
else:
|
|
print(f"Name: {repo.get('full_name')}")
|
|
print(f"Private: {repo.get('private')}")
|
|
print(f"Branch: {repo.get('default_branch')}")
|
|
print(f"URL: {repo.get('html_url')}")
|
|
print(f"SSH: {repo.get('ssh_url')}")
|
|
print(f"Description: {repo.get('description') or ''}")
|
|
|
|
|
|
def build_parser() -> argparse.ArgumentParser:
|
|
p = argparse.ArgumentParser(description="Small local Gitea CLI: list/create repos")
|
|
p.add_argument("--url", default=os.environ.get("GITEA_URL", DEFAULT_URL), help="Gitea base URL")
|
|
p.add_argument("--user", default=os.environ.get("GITEA_USER", DEFAULT_USER), help="Gitea username")
|
|
p.add_argument("--token", default=os.environ.get("GITEA_TOKEN"), help="Gitea API token")
|
|
sub = p.add_subparsers(dest="cmd", required=True)
|
|
|
|
sp = sub.add_parser("version", help="Show server version")
|
|
sp.set_defaults(fn=cmd_version)
|
|
|
|
sp = sub.add_parser("list", aliases=["ls"], help="List repositories for authenticated user or org")
|
|
sp.add_argument("--org", help="List repositories in an organization")
|
|
sp.add_argument("--limit", type=int, default=50, help="Page size")
|
|
sp.add_argument("--json", action="store_true", help="Print raw JSON")
|
|
sp.set_defaults(fn=cmd_list)
|
|
|
|
sp = sub.add_parser("create", help="Create a user or org repository")
|
|
sp.add_argument("name", help="Repository name")
|
|
sp.add_argument("--org", help="Create under organization instead of authenticated user")
|
|
sp.add_argument("--description", "-d", help="Repository description")
|
|
sp.add_argument("--private", action=argparse.BooleanOptionalAction, default=True, help="Create private repo, default true")
|
|
sp.add_argument("--init", action=argparse.BooleanOptionalAction, default=True, help="Auto-initialize repo, default true")
|
|
sp.add_argument("--default-branch", default="main")
|
|
sp.add_argument("--gitignores", help="Gitea gitignore template")
|
|
sp.add_argument("--license", help="Gitea license template")
|
|
sp.add_argument("--readme", default="Default", help="Gitea readme template")
|
|
sp.add_argument("--dry-run", action="store_true", help="Print request without creating")
|
|
sp.set_defaults(fn=cmd_create)
|
|
|
|
sp = sub.add_parser("get", help="Show one repo; accepts name or owner/name")
|
|
sp.add_argument("repo")
|
|
sp.add_argument("--json", action="store_true")
|
|
sp.set_defaults(fn=cmd_get)
|
|
return p
|
|
|
|
|
|
def main() -> None:
|
|
args = build_parser().parse_args()
|
|
api = Gitea(args.url, args.user, args.token, load_password())
|
|
args.fn(api, args)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|