feat: add local Gitea CLI helper

This commit is contained in:
Gemma
2026-05-21 09:31:32 +02:00
commit 083b2209ea
4 changed files with 267 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
__pycache__/
*.pyc
.env

21
LICENSE Normal file
View File

@@ -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.

32
README.md Normal file
View File

@@ -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 <password>`
## 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
```

211
gitea-cli Executable file
View File

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