feat: add local Gitea CLI helper
This commit is contained in:
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.env
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal 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
32
README.md
Normal 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
211
gitea-cli
Executable 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()
|
||||
Reference in New Issue
Block a user