From f08b21d2d618ea5f749ef9caaa1a6307d700e5b5 Mon Sep 17 00:00:00 2001 From: Magnus Hedemark Date: Thu, 21 May 2026 23:14:20 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20add=20forgejo-cli=20skill=20=E2=80=94?= =?UTF-8?q?=20Forgejo/Gitea=20Git=20forge=20CLI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CLI wrapper for the Forgejo/Gitea REST API v1. Commands: - me: current user profile - repos: list user repositories - search: search repositories by query - issues: list issues in a repo (filters out PRs) - view: view issue or PR details with body, labels, milestone - prs: list pull requests with head/base branch info All cli-builder patterns: --json, --dry-run, --quiet, --verbose, lazy auth, emit() dual-output, structured logging, pre-parsed global flags. Auth via FORGEJO_TOKEN (token auth header). Signed-off-by: Jasper --- forgejo-cli/SKILL.md | 113 +++++++ forgejo-cli/scripts/forgejo-cli | 524 ++++++++++++++++++++++++++++++++ 2 files changed, 637 insertions(+) create mode 100644 forgejo-cli/SKILL.md create mode 100755 forgejo-cli/scripts/forgejo-cli diff --git a/forgejo-cli/SKILL.md b/forgejo-cli/SKILL.md new file mode 100644 index 0000000..d72f03c --- /dev/null +++ b/forgejo-cli/SKILL.md @@ -0,0 +1,113 @@ +--- +name: forgejo-cli +description: >- + Interact with a Forgejo or Gitea self-hosted Git forge from the terminal: + list repositories, search repos, manage issues, view pull requests, and + get user info. Use when the user mentions Forgejo, Gitea, a self-hosted + Git server, or asks about repos, issues, PRs, or code review. +license: MIT +compatibility: Requires FORGEJO_TOKEN env var (generate from Settings → + Applications → Personal Access Tokens), FORGEJO_SERVER, Python 3.8+, + and the `requests` library. +metadata: + tags: [forgejo, gitea, git, self-hosted, code-review, api-client] + sources: + - https://forgejo.org/docs/next/user/oauth2/ + - https://codeberg.org/forgejo/forgejo +--- + +# forgejo-cli — Forgejo/Gitea Git Forge from the Terminal + +Interact with a self-hosted Forgejo or Gitea server via the REST API v1. List repositories, search, manage issues, and view pull requests. + +## Setup + +1. Generate a personal access token: Settings → Applications → Create Personal Access Token +2. Set environment variables: + +```bash +export FORGEJO_SERVER="https://git.your-domain.com" +export FORGEJO_TOKEN="your-personal-access-token" +``` + +`--help` and `--dry-run` work without credentials. + +## Essential Commands + +### me — Current user profile + +```bash +forgejo-cli me # your account info +forgejo-cli me --json # machine-readable +``` + +### repos — List your repositories + +```bash +forgejo-cli repos # all your repos +forgejo-cli repos --limit 100 # more results +forgejo-cli repos --json # machine-readable +``` + +### search — Search repositories + +```bash +forgejo-cli search --query "cli" # by name/description +forgejo-cli search --query "infra" --limit 5 # top 5 +forgejo-cli search --query "docs" --json # machine-readable +``` + +### issues — List issues in a repo + +```bash +forgejo-cli issues --repo owner/repo # open issues +forgejo-cli issues --repo owner/repo --state closed +forgejo-cli issues --repo owner/repo --state all --limit 50 +forgejo-cli issues --repo owner/repo --json +``` + +Forgejo's issues endpoint also returns pull requests — the CLI filters them out automatically. + +### view — View an issue or PR + +```bash +forgejo-cli view --repo owner/repo --issue 42 # full details +forgejo-cli view --repo owner/repo --issue 42 --json +``` + +Shows: number, title, state, author, body, labels, milestone, assignee, timestamps, and whether it's a pull request. + +### prs — List pull requests + +```bash +forgejo-cli prs --repo owner/repo # open PRs +forgejo-cli prs --repo owner/repo --state merged # merged PRs +forgejo-cli prs --repo owner/repo --state all # all PRs +forgejo-cli prs --repo owner/repo --json +``` + +Shows: number, title, state, author, head and base branches, and merge status. + +## Global Flags + +All flags work in any position: + +```bash +forgejo-cli --json issues --repo owner/repo # flag before subcommand +forgejo-cli issues --repo owner/repo --json # flag after subcommand +forgejo-cli --dry-run issues --repo owner/repo # preview +forgejo-cli --quiet repos # suppress non-essential output +``` + +## Known Gotchas + +- **Repo format** must be `owner/repo` (e.g. `--repo myorg/my-project`). Forgejo does not accept bare repo names. +- **Issues and PRs share an endpoint** — The Forgejo API returns pull requests mixed into the issues list. The CLI filters PRs out when listing issues. To view PRs, use `prs`. +- **Pagination** — The Forgejo API paginates with `page` and `limit` params. The CLI sets reasonable defaults but doesn't auto-page. Use `--limit` to control results. +- **Token permissions** — Personal access tokens are scoped per-repo or globally. A 403 on a specific repo usually means the token doesn't have access to it. +- **Authorization header format** — Forgejo/Gitea uses `Authorization: token YOUR_TOKEN`, not `Bearer`. The CLI handles this automatically. + +## References + +- [scripts/forgejo-cli](scripts/forgejo-cli) — The CLI binary. Built following the cli-builder patterns: non-interactive, `--json`, `--dry-run`, `--quiet`, `--verbose`, dual-output via `emit()`, lazy auth, structured logging. +- [Forgejo REST API docs](https://forgejo.org/docs/next/user/api-usage/) — Official API reference. diff --git a/forgejo-cli/scripts/forgejo-cli b/forgejo-cli/scripts/forgejo-cli new file mode 100755 index 0000000..86e0a4a --- /dev/null +++ b/forgejo-cli/scripts/forgejo-cli @@ -0,0 +1,524 @@ +#!/usr/bin/env python3 +"""forgejo-cli — Forgejo/Gitea Git forge from the terminal. + +Interact with a Forgejo or Gitea self-hosted Git server via the REST API v1. +Requires FORGEJO_TOKEN env var (generate from Settings → Applications +→ Create Personal Access Token) and FORGEJO_SERVER. +""" + +import argparse +import json +import os +import sys +import warnings +from typing import Any, Dict, List, Optional, Tuple + +warnings.simplefilter("ignore") + +import requests + +# === Config === +DEFAULT_SERVER = "https://git.example.com" +ENV_TOKEN = os.getenv("FORGEJO_TOKEN", "") +ENV_SERVER = os.getenv("FORGEJO_SERVER", DEFAULT_SERVER) + +# === Logging === +QUIET = False + + +def log(msg: str) -> None: + if not QUIET and not GLOBAL_FLAGS.get("json", False): + print(msg) + + +def warn(msg: str) -> None: + print(f"Warning: {msg}", file=sys.stderr) + + +def die(msg: str, exit_code: int = 1) -> None: + print(f"Error: {msg}", file=sys.stderr) + sys.exit(exit_code) + + +def emit(human: str, data: Any) -> None: + if GLOBAL_FLAGS.get("json", False): + print(json.dumps(data, default=str)) + else: + print(human) + + +# === Global flags === +GLOBAL_FLAGS: Dict[str, Any] = { + "json": False, "dry_run": False, "force": False, + "quiet": False, "verbose": False +} + + +def _preparse_global_flags(argv: List[str]) -> Tuple[Dict[str, Any], List[str]]: + GLOBAL_BOOLS = {"--json", "--dry-run", "--force", "--quiet", "--verbose"} + flags: Dict[str, Any] = {} + filtered: List[str] = [argv[0]] + i = 1 + while i < len(argv): + arg = argv[i] + if arg in GLOBAL_BOOLS: + flags[arg.lstrip("-").replace("-", "_")] = True + i += 1 + elif arg in ("--help", "-h"): + return flags, argv + elif arg == "--": + filtered.extend(argv[i:]) + break + else: + filtered.append(arg) + i += 1 + return flags, filtered + + +# === Forgejo API Client === +class ForgejoClient: + """REST API client for Forgejo/Gitea v1.""" + + def __init__(self, token: str = "", server: str = "", + dry_run: bool = False): + self.token = token or ENV_TOKEN + self.server = (server or ENV_SERVER).rstrip("/") + self.dry_run = dry_run + + def _headers(self) -> Dict[str, str]: + return { + "Authorization": f"token {self.token}", + "Accept": "application/json", + } + + def _request(self, method: str, path: str, + params: Optional[Dict] = None, + json_data: Any = None) -> Any: + url = f"{self.server}/api/v1{path}" + + if self.dry_run: + return {"dry_run": True, "method": method.upper(), + "url": url, "params": params, "json": json_data} + + if not self.token: + die("FORGEJO_TOKEN not set. " + "Generate one at: Settings → Applications → Create Personal Access Token") + + try: + resp = requests.request( + method=method, url=url, + params=params, json=json_data, + headers=self._headers(), timeout=30 + ) + except requests.ConnectionError as e: + die(f"Cannot connect to {self.server}: {e}\n" + f" Check FORGEJO_SERVER or use --server") + + if resp.status_code == 401: + die("Auth failed (401). Check FORGEJO_TOKEN.") + if resp.status_code == 403: + die("Forbidden (403). Your token may not have access.") + if resp.status_code == 404: + return None + if resp.status_code == 204: + return {} + if resp.status_code >= 400: + try: + detail = resp.json() + except Exception: + detail = resp.text[:200] + die(f"API error ({resp.status_code}): {detail}") + + # Forgejo paginates with X-Total-Count header and page/limit params + try: + return resp.json() + except ValueError: + return {"raw": resp.text[:500]} + + def _get(self, path: str, params: Optional[Dict] = None) -> Any: + return self._request("GET", path, params=params) + + def _post(self, path: str, json_data: Any = None) -> Any: + return self._request("POST", path, json_data=json_data) + + # === Endpoints === + + def get_user(self) -> Any: + return self._get("/user") + + def list_repos(self, page: int = 1, limit: int = 50) -> Any: + return self._get("/user/repos", params={"page": page, "limit": limit}) + + def search_repos(self, query: str, page: int = 1, limit: int = 20) -> Any: + return self._get("/repos/search", params={"q": query, "page": page, "limit": limit}) + + def get_repo(self, owner: str, repo: str) -> Any: + return self._get(f"/repos/{owner}/{repo}") + + def list_issues(self, owner: str, repo: str, state: str = "open", + page: int = 1, limit: int = 20) -> Any: + return self._get(f"/repos/{owner}/{repo}/issues", + params={"state": state, "page": page, "limit": limit}) + + def get_issue(self, owner: str, repo: str, index: int) -> Any: + return self._get(f"/repos/{owner}/{repo}/issues/{index}") + + def create_issue(self, owner: str, repo: str, title: str, + body: str = "", labels: Optional[List[int]] = None, + assignees: Optional[List[str]] = None) -> Any: + payload: Dict[str, Any] = {"title": title} + if body: + payload["body"] = body + if labels: + payload["labels"] = labels + if assignees: + payload["assignees"] = assignees + return self._post(f"/repos/{owner}/{repo}/issues", json_data=payload) + + def list_pulls(self, owner: str, repo: str, state: str = "open", + page: int = 1, limit: int = 20) -> Any: + return self._get(f"/repos/{owner}/{repo}/pulls", + params={"state": state, "page": page, "limit": limit}) + + def get_pull(self, owner: str, repo: str, index: int) -> Any: + return self._get(f"/repos/{owner}/{repo}/pulls/{index}") + + +# === Command Handlers === + +def cmd_me(client: ForgejoClient, args: List[str]) -> None: + if client.dry_run: + emit("[dry-run] Would fetch current user profile", + {"dry_run": True, "command": "me"}) + return + data = client.get_user() + if not data: + emit("Could not fetch user info.", {"error": "not found"}) + return + full_name = data.get("full_name", "") + full_str = f" ({full_name})" if full_name else "" + emit( + f"👤 {data.get('login', '?')}{full_str}\n" + f" ID: {data.get('id', '?')}\n" + f" Email: {data.get('email', '?')}\n" + f" Repos: {data.get('repo_count', '?')}\n" + f" Created: {data.get('created', '?')}", + {"login": data.get("login"), "full_name": data.get("full_name"), + "id": data.get("id"), "email": data.get("email"), + "repo_count": data.get("repo_count"), + "created": data.get("created")} + ) + + +def cmd_repos(client: ForgejoClient, args: List[str]) -> None: + parser = argparse.ArgumentParser(prog="forgejo-cli repos") + parser.add_argument("--limit", type=int, default=50, help="Max results") + parser.add_argument("--owner", help="Filter by owner (searches if provided)") + parsed, _ = parser.parse_known_args(args) + + if client.dry_run: + emit("[dry-run] Would list repositories", + {"dry_run": True, "command": "repos"}) + return + + data = client.list_repos(limit=parsed.limit) + if not data: + emit("No repositories found.", {"repos": []}) + return + + repos = data if isinstance(data, list) else [] + lines = [] + out = [] + for r in repos: + name = r.get("full_name", r.get("name", "?")) + desc = r.get("description", "") or "" + desc_short = f" — {desc[:60]}" if desc else "" + private = "🔒" if r.get("private") else "📖" + stars = r.get("stars_count", 0) + forks = r.get("forks_count", 0) + lines.append(f" {private} {name:40}{desc_short} ★{stars} 🍴{forks}") + out.append({ + "full_name": name, "private": r.get("private"), + "description": desc, "stars": stars, "forks": forks, + "language": r.get("language"), + "default_branch": r.get("default_branch"), + "html_url": r.get("html_url") + }) + + emit(f"{len(repos)} repositories:\n" + "\n".join(lines), + {"total": len(repos), "repos": out}) + + +def cmd_search(client: ForgejoClient, args: List[str]) -> None: + parser = argparse.ArgumentParser(prog="forgejo-cli search") + parser.add_argument("--query", "-q", required=True, help="Search query") + parser.add_argument("--limit", type=int, default=20, help="Max results") + parsed, _ = parser.parse_known_args(args) + + if client.dry_run: + emit(f"[dry-run] Would search: {parsed.query}", + {"dry_run": True, "command": "search", + "query": parsed.query, "limit": parsed.limit}) + return + + data = client.search_repos(parsed.query, limit=parsed.limit) + repos = data.get("data", []) if data else [] + + if not repos: + emit("No repositories found.", {"repos": []}) + return + + lines = [] + out = [] + for r in repos: + name = r.get("full_name", r.get("name", "?")) + desc = r.get("description", "") or "" + desc_short = f" — {desc[:60]}" if desc else "" + private = "🔒" if r.get("private") else "📖" + stars = r.get("stars_count", 0) + lines.append(f" {private} {name:40}{desc_short} ★{stars}") + out.append({ + "full_name": name, "private": r.get("private"), + "description": desc, "stars": stars, + "language": r.get("language"), + "default_branch": r.get("default_branch"), + "html_url": r.get("html_url") + }) + + total = data.get("total_count", len(repos)) + emit(f"{total} result(s):\n" + "\n".join(lines), + {"total": total, "repos": out}) + + +def cmd_issues(client: ForgejoClient, args: List[str]) -> None: + parser = argparse.ArgumentParser(prog="forgejo-cli issues") + parser.add_argument("--repo", "-r", required=True, help="Repo (owner/repo)") + parser.add_argument("--state", default="open", choices=["open", "closed", "all"], + help="Issue state") + parser.add_argument("--limit", type=int, default=20, help="Max results") + parsed, _ = parser.parse_known_args(args) + + repo = parsed.repo + if "/" not in repo: + die("Use --repo owner/repo format (e.g. --repo owner/my-repo)") + + owner, reponame = repo.split("/", 1) + + if client.dry_run: + emit(f"[dry-run] Would list issues in {repo}", + {"dry_run": True, "command": "issues", + "repo": repo, "state": parsed.state}) + return + + data = client.list_issues(owner, reponame, state=parsed.state, limit=parsed.limit) + if not data: + emit(f"No issues found in {repo}.", {"issues": []}) + return + + # Forgejo returns pull requests in the issues endpoint too — filter them out + issues = [i for i in data if not i.get("pull_request")] + lines = [] + out = [] + for issue in issues: + idx = issue.get("number", "?") + title = issue.get("title", "?") + state = issue.get("state", "?") + user = issue.get("user", {}).get("login", "?") + comments = issue.get("comments", 0) + labels = ", ".join([l.get("name", "") for l in issue.get("labels", [])]) + label_str = f" [{labels}]" if labels else "" + lines.append(f" #{idx:<5} [{state:6}] {title:55} (@{user}){label_str}") + out.append({ + "number": idx, "title": title, "state": state, + "user": user, "comments": comments, + "labels": [l.get("name") for l in issue.get("labels", [])], + "created": issue.get("created_at"), + "updated": issue.get("updated_at"), + "html_url": issue.get("html_url") + }) + + emit(f"{len(issues)} issue(s) in {repo} ({parsed.state}):\n" + "\n".join(lines), + {"total": len(issues), "repo": repo, "state": parsed.state, "issues": out}) + + +def cmd_view(client: ForgejoClient, args: List[str]) -> None: + parser = argparse.ArgumentParser(prog="forgejo-cli view") + parser.add_argument("--repo", "-r", required=True, help="Repo (owner/repo)") + parser.add_argument("--issue", "-i", type=int, required=True, + help="Issue or PR number") + parsed, _ = parser.parse_known_args(args) + + repo = parsed.repo + if "/" not in repo: + die("Use --repo owner/repo format (e.g. --repo owner/my-repo)") + + owner, reponame = repo.split("/", 1) + + if client.dry_run: + emit(f"[dry-run] Would fetch issue #{parsed.issue} from {repo}", + {"dry_run": True, "command": "view", + "repo": repo, "issue": parsed.issue}) + return + + data = client.get_issue(owner, reponame, parsed.issue) + if not data: + emit(f"Issue #{parsed.issue} not found in {repo}.", + {"error": "not found", "repo": repo, "issue": parsed.issue}) + return + + idx = data.get("number", "?") + title = data.get("title", "?") + state = data.get("state", "?") + user = data.get("user", {}).get("login", "?") + body = data.get("body", "(no description)") + created = data.get("created_at", "?") + updated = data.get("updated_at", "?") + comments = data.get("comments", 0) + is_pr = "yes" if data.get("pull_request") else "no" + labels = ", ".join([l.get("name", "") for l in data.get("labels", [])]) + milestone = data.get("milestone", {}) + milestone_str = f" Milestone: {milestone.get('title', '?')}" if milestone else "" + assignee = data.get("assignee", {}) + assignee_str = f" Assignee: @{assignee.get('login', '?')}" if assignee else "" + + human = ( + f"📋 #{idx}: {title}\n" + f" State: {state} PR: {is_pr} Comments: {comments}\n" + f" Author: @{user}{assignee_str}\n{milestone_str}" + f" Labels: {labels or '(none)'}\n" + f" Created: {created} Updated: {updated}\n" + f"─── Description ───\n{body}" + ) + emit(human, { + "number": idx, "title": title, "state": state, + "is_pull_request": bool(data.get("pull_request")), + "user": user, "body": body, "created": created, + "updated": updated, "comments": comments, + "labels": [l.get("name") for l in data.get("labels", [])], + "milestone": milestone.get("title") if milestone else None, + "assignee": data.get("assignee", {}).get("login") if data.get("assignee") else None, + "html_url": data.get("html_url") + }) + + +def cmd_prs(client: ForgejoClient, args: List[str]) -> None: + parser = argparse.ArgumentParser(prog="forgejo-cli prs") + parser.add_argument("--repo", "-r", required=True, help="Repo (owner/repo)") + parser.add_argument("--state", default="open", choices=["open", "closed", "all"], + help="PR state") + parser.add_argument("--limit", type=int, default=20, help="Max results") + parsed, _ = parser.parse_known_args(args) + + repo = parsed.repo + if "/" not in repo: + die("Use --repo owner/repo format (e.g. --repo owner/my-repo)") + + owner, reponame = repo.split("/", 1) + + if client.dry_run: + emit(f"[dry-run] Would list PRs in {repo}", + {"dry_run": True, "command": "prs", + "repo": repo, "state": parsed.state}) + return + + data = client.list_pulls(owner, reponame, state=parsed.state, limit=parsed.limit) + if not data: + emit(f"No pull requests found in {repo}.", {"pulls": []}) + return + + lines = [] + out = [] + for pr in data: + idx = pr.get("number", "?") + title = pr.get("title", "?") + state = pr.get("state", "?") + user = pr.get("user", {}).get("login", "?") + head_branch = (pr.get("head", {}) or {}).get("label", "?") + base_branch = (pr.get("base", {}) or {}).get("label", "?") + merged = pr.get("merged", False) + merge_str = " 🔀" if merged else "" + lines.append(f" !{idx:<4} [{state:6}] {title:50} → {head_branch} → {base_branch} @{user}{merge_str}") + out.append({ + "number": idx, "title": title, "state": state, + "user": user, "head": head_branch, "base": base_branch, + "merged": merged, "created": pr.get("created_at"), + "html_url": pr.get("html_url") + }) + + emit(f"{len(data)} PR(s) in {repo} ({parsed.state}):\n" + "\n".join(lines), + {"total": len(data), "repo": repo, "state": parsed.state, "pulls": out}) + + +# === Main === + +def main() -> None: + global GLOBAL_FLAGS, QUIET + GLOBAL_FLAGS, filtered_argv = _preparse_global_flags(sys.argv) + if GLOBAL_FLAGS.get("quiet", False): + QUIET = True + if GLOBAL_FLAGS.get("json", False): + warnings.simplefilter("ignore") + + parser = argparse.ArgumentParser( + prog="forgejo-cli", + description="Forgejo/Gitea Git forge from the terminal.", + epilog="Global flags work anywhere: forgejo-cli --json issues --repo owner/repo" + ) + sub = parser.add_subparsers(dest="command", help="Available commands") + + sub.add_parser("me", help="Get current user profile") + + p_repos = sub.add_parser("repos", help="List your repositories") + p_repos.add_argument("--limit", type=int, default=50) + p_repos.add_argument("--owner") + + p_search = sub.add_parser("search", help="Search repositories") + p_search.add_argument("--query", "-q", required=True, help="Search query") + p_search.add_argument("--limit", type=int, default=20) + + p_issues = sub.add_parser("issues", help="List issues in a repo") + p_issues.add_argument("--repo", "-r", required=True, help="owner/repo") + p_issues.add_argument("--state", default="open", choices=["open", "closed", "all"]) + p_issues.add_argument("--limit", type=int, default=20) + + p_view = sub.add_parser("view", help="View an issue or PR") + p_view.add_argument("--repo", "-r", required=True, help="owner/repo") + p_view.add_argument("--issue", "-i", type=int, required=True, help="Issue number") + + p_prs = sub.add_parser("prs", help="List pull requests") + p_prs.add_argument("--repo", "-r", required=True, help="owner/repo") + p_prs.add_argument("--state", default="open", choices=["open", "closed", "all"]) + p_prs.add_argument("--limit", type=int, default=20) + + args = parser.parse_args(filtered_argv[1:]) + if not args.command: + parser.print_help() + sys.exit(1) + + needs_auth = not GLOBAL_FLAGS.get("dry_run", False) + if needs_auth and not ENV_TOKEN: + die("Set FORGEJO_TOKEN in your environment.\n" + " Generate one: Settings → Applications → Personal Access Tokens\n" + " Or use --dry-run to preview without credentials.") + + client = ForgejoClient(dry_run=GLOBAL_FLAGS.get("dry_run", False)) + + cmd_map = { + "me": cmd_me, + "repos": cmd_repos, + "search": cmd_search, + "issues": cmd_issues, + "view": cmd_view, + "prs": cmd_prs, + } + handler = cmd_map.get(args.command) + if not handler: + parser.print_help() + sys.exit(1) + + remaining = filtered_argv[filtered_argv.index(args.command) + 1:] + handler(client, remaining) + + +if __name__ == "__main__": + main()