commit 083b2209eadc001502fbf1b9eaefd3b89737833f Author: Gemma Date: Thu May 21 09:31:32 2026 +0200 feat: add local Gitea CLI helper diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b6cf5f0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +__pycache__/ +*.pyc +.env diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..14fac91 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..d53ec2c --- /dev/null +++ b/README.md @@ -0,0 +1,32 @@ +# gitea-cli + +Tiny stdlib-only Python helper for common operations against the local self-hosted Gitea instance. + +Defaults: + +- `GITEA_URL=http://192.168.33.22:3300` +- `GITEA_USER=gemma` + +Credentials are read at runtime from: + +1. `GITEA_TOKEN` +2. `GITEA_PASSWORD` +3. `/root/PASSWORD.md` line matching `gitea ` + +## Install + +```bash +sudo install -m 0755 gitea-cli /usr/local/bin/gitea-cli +sudo ln -sf /usr/local/bin/gitea-cli /usr/local/bin/gitea +``` + +## Usage + +```bash +gitea version +gitea list +gitea list --json +gitea get arcana-fx +gitea create my-repo --description "description" --private +gitea create my-repo --dry-run +``` diff --git a/gitea-cli b/gitea-cli new file mode 100755 index 0000000..9d84a81 --- /dev/null +++ b/gitea-cli @@ -0,0 +1,211 @@ +#!/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 ) + 2. GITEA_PASSWORD (HTTP basic auth with GITEA_USER) + 3. /root/PASSWORD.md line like: gitea +""" +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()