Files
gitea-cli/gitea-cli
2026-05-21 09:31:32 +02:00

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()