diff --git a/README.md b/README.md index ac2b2ed..5eab1f7 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ fixed-layout, accessibility, and media overlays. Portable across any AgentSkills ### [forgejo-cli](forgejo-cli/SKILL.md) -Forgejo or Gitea self-hosted Git forge from the terminal. List repositories, search repos, manage issues, and view pull requests. +Safe Forgejo API v1 CLI for issues, pull requests, repositories, file contents, metadata, webhooks, and user settings. Includes a guarded generic `/api/v1/` route for version-specific endpoints such as Actions and admin APIs. ### [gutenberg](gutenberg/SKILL.md) diff --git a/forgejo-cli/README.md b/forgejo-cli/README.md index 586786e..2b0ca20 100644 --- a/forgejo-cli/README.md +++ b/forgejo-cli/README.md @@ -1,34 +1,35 @@ -# Forgejo / Gitea CLI — Self-Hosted Git Forge +# Forgejo CLI v2 — safe repository automation -Manage issues, pull requests, repositories, labels, webhooks, and Actions runners on a self-hosted Forgejo or Gitea instance — all from the terminal. +Manage a Forgejo server from the terminal without hand-written `curl` or accidental mutations. It covers daily repository work—issues, pull requests, repositories, contents, metadata, webhooks, and settings—and has a guarded API escape hatch for the rest. ## Why Install This Skill -When your agent loads this skill, it can **manage your entire self-hosted Git forge** without a browser. That means: - -- **List, create, and search repositories** — manage your code hosting -- **Handle issues** — list, show, create, comment, label, assign -- **Manage pull requests** — list, diff, review, comment, merge with branch protection -- **Configure webhooks** — list, create, delete -- **Manage labels** — list and create custom labels +The CLI gives agents one predictable safety contract: JSON is machine-readable, diagnostics stay off stdout, mutations require confirmation, and dry runs never need a token or network access. For version-specific features such as Actions runners and variables, packages, organizations, teams, admin APIs, notifications, and permissions, use the generic API command with your server's Swagger schema. ## What You Get -| Directory | Purpose | -|-----------|---------| -| `SKILL.md` | Complete command reference with authentication setup and examples | -| `scripts/forgejo-cli` | Python CLI tool | +| Contents | Purpose | +| --- | --- | +| `scripts/forgejo-cli` | Argparse Forgejo API v1 client | +| `references/command-reference.md` | Endpoint and payload guide | +| `V2-SPEC.md` | v2 acceptance criteria | +| `tests/` | Offline stdlib contract tests | ## Quick Start -Two token profiles in `~/.hermes/.env`: -- `FORGEJO_AGENT_TOKEN` — for automated operations (default) -- `FORGEJO_USER_TOKEN` — for user-level operations (`--user` flag) +Set `FORGEJO_AGENT_TOKEN` (default) or `FORGEJO_USER_TOKEN`, then run: + +```bash +python3 scripts/forgejo-cli --dry-run --json repo create --name demo --private +python3 scripts/forgejo-cli --server https://forge.example api --method GET --path /api/v1/user --json +``` ## Triggers -Load this when dealing with Forgejo, Gitea, self-hosted git forges, or any repository management on your own infrastructure. +- Managing Forgejo/Gitea issues, PRs, repos, file contents, releases, or hooks. +- Calling a version-specific `/api/v1/` endpoint safely. +- Previewing a Forgejo mutation. ## Requirements -Python 3.8+ with `requests` library. Self-hosted Forgejo or Gitea instance. +Python 3.8+ and `requests` for live API calls. `--help` and `--dry-run` need no token or dependency. Consult your Forgejo server's `/api/swagger` or `/swagger.v1.json` for exact schemas. diff --git a/forgejo-cli/SKILL.md b/forgejo-cli/SKILL.md index 3c9ac53..1caea43 100644 --- a/forgejo-cli/SKILL.md +++ b/forgejo-cli/SKILL.md @@ -1,306 +1,31 @@ --- name: forgejo-cli -description: "CLI for Forgejo API — issues, PRs, repos, labels, webhooks, Actions runners. Dual auth (AGENT/USER)." -version: 1.1.1 -tags: [forgejo, git, api, code-review] +description: Use when managing a Forgejo or Gitea server from the terminal: issues, pull requests, repositories, file contents, labels, milestones, releases, webhooks, user settings, or any /api/v1 endpoint through a safe generic API command. +license: MIT +compatibility: Python 3.8+; requests is required only for live API calls. +metadata: + version: 2.0.0 + tags: [forgejo, gitea, git, api, code-review] --- -# forgejo-cli +# Forgejo CLI v2 -Python CLI at `~/.hermes/scripts/forgejo-cli` wrapping the Forgejo API v1. +Run `python3 scripts/forgejo-cli`. `--agent` (default) uses `FORGEJO_AGENT_TOKEN`; `--user` uses `FORGEJO_USER_TOKEN`; `--server URL` selects an installation. -## Auth +## Safety -Two tokens stored in `~/.hermes/.env`: -- `FORGEJO_AGENT_TOKEN` — jasper bot (default) -- `FORGEJO_USER_TOKEN` — magnus user (use `--user` flag) +- Mutations require `--force`/`--yes`/`-y`, or `--dry-run`. +- `--dry-run --json` emits one plan with `method`, `path`, `query`, and `body`; it makes no network request. +- `--json` writes exactly one JSON value to stdout; diagnostics go to stderr. +- `--help` does not read credentials or contact a server. Path segments are encoded. -## Usage - -``` -forgejo-cli [] [OPTIONS] - -Commands: - issue Manage issues (list, show, create, comment, label, assign) - pr Manage pull requests (list, show, create, diff, review, comment, merge) - repo Manage repositories (list, show, create, search) - label Manage labels (list, create) - hook Manage webhooks (list, create, delete) - user User info and settings - comment Manage comments (list, create, delete) - -Global flags: - --json Machine-readable JSON output - --dry-run Preview without making changes - --agent Use AGENT token (default) - --user Use USER token - --force Skip confirmations - --quiet Suppress non-essential output -``` - -## Common Operations +## Common workflows ```bash -# List issues -forgejo-cli issue list --owner magnus --repo test - -# Show issue -forgejo-cli issue show --owner magnus --repo test --index 3 - -# Add comment -forgejo-cli issue comment --owner magnus --repo test --index 3 --body "Fixed" - -# Get PR diff for review -forgejo-cli pr diff --owner magnus --repo myrepo --index 1 - -# Submit PR review (as jasper) -forgejo-cli pr review --owner magnus --repo myrepo --index 1 --body "LGTM" --event approve - -# Merge a PR -forgejo-cli pr merge --owner magnus --repo myrepo --index 3 --dry-run # Preview first -forgejo-cli pr merge --owner magnus --repo myrepo --index 3 --force # Execute merge - -# Merge via API (when CLI returns 405 or PR has conflicts to resolve first). -# Consult the official Forgejo API usage guide for the current request schema. - -# Create a PR -forgejo-cli pr create --owner magnus --repo myrepo --title "feat: add auth" --head feat/add-auth --base main --body "Closes #42" -forgejo-cli pr create --owner magnus --repo myrepo --title "draft: WIP" --head feat/wip --base main --draft - -# Create an issue with labels (label IDs are NUMERIC) -forgejo-cli issue create --owner magnus --repo myrepo --title "Bug: login fails" --body "Details here" --labels 8,9 - -# List repos -forgejo-cli repo list --json - -# Create a repo (NOT YET IMPLEMENTED in CLI — use the REST API directly) -# Documentation says `repo create` but the method isn't coded yet - -# List labels -forgejo-cli label list --owner magnus --repo test - -# Get current user info -forgejo-cli user show -forgejo-cli --user user show +python3 scripts/forgejo-cli issue list --owner acme --repo app --json +python3 scripts/forgejo-cli --dry-run --json repo create --name demo --private +python3 scripts/forgejo-cli --dry-run --json api --method POST \ + --path /api/v1/repos/acme/app/actions/variables --data '{"name":"KEY","value":"value"}' ``` -## Server Setup - -The Forgejo instance runs via Docker on `phatalbert`. Consult the instance runbook for its deployment-specific details; use the official Forgejo Docker guide for supported container configuration. - -## Test Suite - -Test script at `~/.hermes/scripts/forgejo-cli-test.sh`. Run with: -```bash -bash ~/.hermes/scripts/forgejo-cli-test.sh -``` - -## Forgejo Docker Deployment - -Use the official Forgejo Docker installation guide for supported image, volume, UID/GID, port, and upgrade practices. - -## Forgejo Actions (CI/CD) - -Forgejo Actions is a CI/CD system similar to GitHub Actions. Requires both server-side config and a runner. The `forgejo-actions` skill covers runner lifecycle, step container behavior, workflow patterns, and debugging in detail. - -### Enabling Actions on Forgejo - -Add to `/data/gitea/conf/app.ini` inside the forgejo container: -```bash -docker exec forgejo sh -c 'printf "\n[actions]\nENABLED=true\n" >> /data/gitea/conf/app.ini' -docker restart forgejo -``` - -### Registering a runner - -```bash -# Get registration token -curl -s "https://git.brandyapple.com/api/v1/admin/runners/registration-token" \ - -H "Authorization: token $FORGEJO_USER_TOKEN" - -# Register and start on the target host -docker run --rm \ - -v /var/run/docker.sock:/var/run/docker.sock \ - -v runner-data:/data \ - data.forgejo.org/forgejo/runner:4.0.0 \ - forgejo-runner register \ - --instance https://git.brandyapple.com \ - --token --name -runner \ - --labels docker:docker://node:20-bookworm --no-interactive - -# Run daemon -docker run -d --name forgejo-runner --user root \ - -v /var/run/docker.sock:/var/run/docker.sock \ - -v runner-data:/data --restart unless-stopped \ - data.forgejo.org/forgejo/runner:4.0.0 \ - forgejo-runner daemon -``` - -### Critical runner config - -After registration, edit `/data/config.yaml` in the runner volume. See the `forgejo-actions` skill for full config reference — key settings: -- `container.docker_host: automount` — required to mount host Docker socket (runner > 5.0.3). Default is `"-"` which skips mounting. -- `container.valid_volumes: ['**']` — allows volume mounts from host - -### Debugging - -Step output is only visible in the Forgejo web UI, not in `docker logs forgejo-runner`. See `forgejo-actions` skill for Docker events debugging patterns. - -Full runner/deploy workflow details in the `forgejo-gitea` skill's "Forgejo Actions (CI/CD)" section. - -## Release Workflow - -The forgejo-cli does not implement `release create`. Use the Forgejo API directly for the full release lifecycle: - -```bash -# 1. Tag and push -git tag -a vX.Y.Z -m "vX.Y.Z — Title" -git push origin vX.Y.Z - -# 2. Write release notes and POST data to a JSON file -# (Use the JSON-file approach to avoid shell escaping issues) -cat > /tmp/release-data.json << 'ENDJSON' -{ - "tag_name": "vX.Y.Z", - "name": "vX.Y.Z — Release Title", - "body": "## What's New\n\nRelease notes here.\n", - "draft": false, - "prerelease": false -} -ENDJSON - -# 3. Create the release -curl -s -X POST "https://git.brandyapple.com/api/v1/repos/{owner}/{repo}/releases" \ - -H "Authorization: token $FORGEJO_AGENT_TOKEN" \ - -H "Content-Type: application/json" \ - -d @/tmp/release-data.json - -# 4. Get the release ID for any subsequent edits -curl -s "https://git.brandyapple.com/api/v1/repos/{owner}/{repo}/releases" \ - -H "Authorization: token $FORGEJO_AGENT_TOKEN" | \ - python3 -c "import sys,json; [print(f'ID: {r[\"id\"]} Tag: {r[\"tag_name\"]}') for r in json.load(sys.stdin)]" -``` - -### Gotcha: `name` not `title` - -Forgejo's release API uses **`name`** as the release display title, **not** `title`. If you send `"title": "vX.Y.Z — Release"`, the field is silently ignored and the tag name is used as a fallback. The correct field: - -```json -{"tag_name": "vX.Y.Z", "name": "vX.Y.Z — Release Title", "body": "..."} -``` - -To fix a release that was created with the wrong name, PATCH by release ID: - -```bash -curl -s -X PATCH "https://git.brandyapple.com/api/v1/repos/{owner}/{repo}/releases/{id}" \ - -H "Authorization: token $FORGEJO_AGENT_TOKEN" \ - -H "Content-Type: application/json" \ - -d '{"name": "vX.Y.Z — Corrected Title"}' -``` - -PATCH by tag (`/releases/tag/{tag}`) returns 404 — you must use the numeric release ID. - -For the current release API schema and recovery process, consult the official Forgejo API usage guide. - -## PR Review Workflow - -For review endpoints and payloads, consult the official Forgejo API usage guide before automating a review workflow. - -## Pitfalls - -### `--labels` requires numeric IDs (not strings) - -The `--labels` flag accepts comma-separated label IDs. These must be **integers**. Non-numeric values are silently dropped. - -```bash -# ✅ Correct: numeric IDs -forgejo-cli issue create --owner magnus --repo test --title "Bug" --labels 8,9 - -# ❌ Wrong: string values cause 422 API error -forgejo-cli issue create --owner magnus --repo test --title "Bug" --labels "8,9" -``` - -To look up label IDs by name: - -```bash -forgejo-cli label list --owner magnus --repo test --json -``` - -This returns labels with their numeric `id` field. See issue #48 for the v1.1.1 fix history. - -### Shell metacharacters in `--body` break `issue create` - -The `--body` value is passed through the shell, so text containing `$`, backticks, parentheses, `&`, `|`, `;`, or unbalanced quotes causes parsing errors or silent truncation. - -**Fix:** Use the Forgejo API directly with a JSON file for complex bodies: - -```bash -# Write body to file -cat > /tmp/body.json << 'ENDOFBODY' -{"title": "Issue title", "body": "Complex body with (parens) and $dollar signs"} -ENDOFBODY - -# POST via API -. ~/.hermes/.env 2>/dev/null -curl -s -X POST "https://git.brandyapple.com/api/v1/repos/{owner}/{repo}/issues" \ - -H "Authorization: Bearer $FORGEJO_USER_TOKEN" \ - -H "Content-Type: application/json" \ - -d @/tmp/body.json -``` - -Or pipe from Python to avoid any shell escaping: -```bash -. ~/.hermes/.env 2>/dev/null -python3 -c "import json; body = open('/tmp/body.md').read(); print(json.dumps({'title': '...', 'body': body}))" \ - | curl -s -X POST "https://git.brandyapple.com/api/v1/repos/{owner}/{repo}/issues" \ - -H "Authorization: Bearer $FORGE...EN" \ - -H "Content-Type: application/json" \ - -d @- -``` - -**Best option for complex bodies: Use `execute_code` with `urllib.request`.** - -This eliminates ALL shell interaction — no quoting, no temp files, no token expansion: -```python -import json, urllib.request, os - -env_path = os.path.expanduser("~/.hermes/.env") -token = None -with open(env_path) as f: - for line in f: - line = line.strip() - if "FORGEJO_AGENT_TOKEN" in line and "=" in line: - token = line.split("=", 1)[1].strip().strip('"').strip("'") - -body = open("/tmp/body.md").read() -payload = json.dumps({"title": "Issue title", "body": body}) - -req = urllib.request.Request( - "https://git.brandyapple.com/api/v1/repos/{owner}/{repo}/issues", - data=payload.encode(), - headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"}, - method="POST" -) -with urllib.request.urlopen(req) as resp: - r = json.loads(resp.read()) - print(f"Created #{r['number']}: {r['title']}") -``` - -The same pattern works for PR creation — POST to `/pulls` instead of `/issues` with `head` and `base` fields. Verify the current schema in the official Forgejo API usage guide. - -## Known Gaps ⚠️ - -| Claimed Feature | Actual Status | Workaround | -|---|---|---| -| `repo create` | Not implemented (only `list`, `show`, `search` exist) | Use the raw REST API | -| `repo show` | Accepts `--owner --repo` | `repo get` in code; `repo show` alias may not exist — try `--json` on `repo list` filtered by name | -| `pr merge` | Requires `--force` or `--dry-run` flag (not obvious from help output). Returns 405 when PR isn't mergeable (branch divergence, conflicts) | Use the REST API after resolving mergeability | -| `release create` | Not implemented (no release commands exist at all) | Use the raw REST API | -| Standalone PR comment (merged PR) | No subcommand for commenting on already-merged PRs | Use `POST /issues/{id}/comments` | -When a CLI subcommand is missing, the Forgejo REST API at `git.brandyapple.com/api/v1` is the backup. Consult the official API guide before composing a request. - -## Authoritative References - -- [Forgejo API usage](https://forgejo.org/docs/latest/user/api-usage/) -- [Forgejo Docker installation](https://forgejo.org/docs/latest/admin/installation/docker/) -- [Forgejo Actions](https://forgejo.org/docs/latest/user/actions/) +First-class groups: `issue`, `pr`, `repo`, `content`, `label`, `milestone`, `release`, `hook`, and `user`. Use `api` for any other `/api/v1/` endpoint, including Actions, packages, organizations, teams, admin functions, notifications, and permissions. Consult `/api/swagger` or `/swagger.v1.json` on the selected server. See [command reference](references/command-reference.md). diff --git a/forgejo-cli/V2-SPEC.md b/forgejo-cli/V2-SPEC.md new file mode 100644 index 0000000..6e83659 --- /dev/null +++ b/forgejo-cli/V2-SPEC.md @@ -0,0 +1,5 @@ +# Forgejo CLI v2 implementation specification + +Provide polished collaboration and repository commands plus a safe generic `/api/v1/` escape hatch. The CLI must require confirmation for mutations, produce credential-free dry-run plans, encode path segments, and keep JSON stdout machine-readable. + +Acceptance: tests verify help, mutation gating, generic path validation, JSON plans, repository creation without owner/repo, and representative issue, PR, release, content, and webhook requests. diff --git a/forgejo-cli/references/command-reference.md b/forgejo-cli/references/command-reference.md new file mode 100644 index 0000000..4ab96b2 --- /dev/null +++ b/forgejo-cli/references/command-reference.md @@ -0,0 +1,21 @@ +# Command reference + +Global flags: `--agent`, `--user`, `--server URL`, `--json`, `--quiet/-q`, `--verbose/-v`, `--dry-run/-n`, and `--force/--yes/-y`. POST, PUT, PATCH, and DELETE require `--force` unless dry-running. + +| Group | Commands | API route | +| --- | --- | --- | +| `issue` | list, show, create, edit, close, reopen, assign, labels, comment | `/repos/{owner}/{repo}/issues` | +| `pr` | list, show, create, edit, diff, comment, reviews, review, merge | `/repos/{owner}/{repo}/pulls` | +| `repo` | list, show, search, create, edit, delete, branches | `/user/repos`, `/repos`, `/repos/search` | +| `content` | get, create, update, delete | `/repos/{owner}/{repo}/contents/{path}` | +| `label`, `milestone`, `release` | list, show, create, edit, delete | matching repository metadata collection | +| `hook` | list, show, create, edit, delete | `/user/hooks` or `/repos/{owner}/{repo}/hooks` | +| `user` | show, settings, update-settings | `/user`, `/user/settings` | + +Comma-separated `--labels`, `--assignees`, and `--events` become JSON arrays. PR inline comments accept `--commit-id`, `--path`, and `--line`. `user update-settings --data JSON` sends its JSON payload. + +## Generic API + +`forgejo-cli api --method GET|POST|PUT|PATCH|DELETE --path /api/v1/... [--query KEY=VALUE] [--data JSON | --data-file FILE]` + +Only `/api/v1/` paths are accepted. Use it for Actions, packages, organizations, teams, admin APIs, notifications, repository permissions, and new endpoints; consult the target server's Swagger document for schemas. diff --git a/forgejo-cli/scripts/forgejo-cli b/forgejo-cli/scripts/forgejo-cli index 6072c32..93a0087 100755 --- a/forgejo-cli/scripts/forgejo-cli +++ b/forgejo-cli/scripts/forgejo-cli @@ -1,834 +1,222 @@ #!/usr/bin/env python3 -"""forgejo-cli — Forgejo API client with dual auth (AGENT/USER). +"""A safe, agent-friendly Forgejo API v1 command-line client.""" -Usage: - forgejo-cli [] [OPTIONS] +import argparse +import json +import os +import sys +from pathlib import Path +from urllib.parse import quote -Commands: - issue Manage issues (list, show, create, comment, label, assign) - pr Manage pull requests (list, show, diff, review, comment, merge) - repo Manage repositories (list, show, create) - label Manage labels (list, create) - hook Manage webhooks (list, create, delete) - user User info and settings - comment Manage comments (list, create, delete) +try: + import requests +except ImportError: # pragma: no cover - surfaced only on actual requests + requests = None -Global flags: - --agent Use AGENT token (jasper) [default] - --user Use USER token (magnus) - --server URL Forgejo server URL - --json Machine-readable JSON output - --dry-run Preview without making changes - --quiet Suppress non-essential output - --verbose Diagnostic output to stderr - --force Skip confirmations -""" - -import json, os, sys, warnings -from typing import Optional - -# ── Suppress import-time warnings ────────────────────────────────── -warnings.simplefilter("ignore") - -import requests - -# ── Paths & Defaults ────────────────────────────────────────────── -DEFAULT_SERVER = "https://git.brandyapple.com" -CONFIG_DIR = os.path.expanduser("~/.hermes") - -# ── Logging helpers ──────────────────────────────────────────────── -QUIET = False -JSON_MODE = False -VERBOSE = False -FORCE = False -DRY_RUN = False - -def log(msg: str) -> None: - if not QUIET and not JSON_MODE: - print(msg) - -def warn(msg: str) -> None: - print(f"Warning: {msg}", file=sys.stderr) - -def die(msg: str, code: int = 1) -> None: - print(f"Error: {msg}", file=sys.stderr) - sys.exit(code) - -def info(msg: str) -> None: - if VERBOSE: - print(f"[info] {msg}", file=sys.stderr) - -def emit(human: str, machine: dict) -> None: - if JSON_MODE: - print(json.dumps(machine)) - else: - print(human) +DEFAULT_SERVER = "https://forgejo.example.com" +MUTATING = {"POST", "PUT", "PATCH", "DELETE"} -# ── Config loading ───────────────────────────────────────────────── -def _get_env(key: str, default: str = "") -> str: - val = os.getenv(key) - if val: - return val - env_path = os.path.join(CONFIG_DIR, ".env") - if os.path.isfile(env_path): - try: - with open(env_path) as f: - for line in f: - line = line.strip() - if line.startswith("export "): - line = line[len("export "):] - if line.startswith(f"{key}="): - return line.split("=", 1)[1].strip("\"'") - except OSError: - pass - return default - - -# ── API Client ───────────────────────────────────────────────────── class ForgejoError(Exception): pass -class ForgejoClient: - """Forgejo API client with dual auth and dry-run support.""" +def env(name): + value = os.getenv(name) + if value: + return value + path = Path.home() / ".hermes" / ".env" + try: + for line in path.read_text().splitlines(): + line = line.strip().removeprefix("export ") + if line.startswith(name + "="): + return line.split("=", 1)[1].strip().strip("\"'") + except OSError: + pass + return "" - def __init__(self, server: str, token: str, dry_run: bool = False): - self.server = server.rstrip("/") - self.token = token - self.dry_run = dry_run - def _headers(self) -> dict: - if not self.token and not self.dry_run: - die("No API token available. Use --agent or --user, or set FORGEJO_AGENT_TOKEN / FORGEJO_USER_TOKEN") - return {"Authorization": f"token {self.token}", "Content-Type": "application/json"} +def seg(value): + return quote(str(value), safe="") - def _request(self, method: str, path: str, params: dict = None, - json_data: dict = None) -> dict: - url = f"{self.server}{path}" +class Client: + def __init__(self, args): + self.server = args.server.rstrip("/") + self.dry_run = args.dry_run + self.verbose = args.verbose + self.token = env("FORGEJO_USER_TOKEN" if args.user else "FORGEJO_AGENT_TOKEN") + + def request(self, method, path, query=None, body=None): + method = method.upper() + if not path.startswith("/api/v1/"): + raise ForgejoError("API path must begin with /api/v1/") if self.dry_run: - info(f"[dry-run] {method} {path}") - if params: - info(f" params: {json.dumps(params)}") - if json_data: - info(f" data: {json.dumps(json_data)[:500]}") - return {"_dry_run": True} - - headers = self._headers() - try: - resp = requests.request(method, url, params=params, - json=json_data, headers=headers, timeout=30) - except requests.ConnectionError as e: - raise ForgejoError(f"Cannot connect to {self.server}: {e}") - - if resp.status_code in (200, 201): - try: - return resp.json() if resp.text.strip() else {} - except json.JSONDecodeError: - return {"raw": resp.text[:500]} - - if resp.status_code == 204: - return {} - - if resp.status_code == 401: - raise ForgejoError("Auth failed (401). Check your token.") - if resp.status_code == 403: - raise ForgejoError("Permission denied (403).") - if resp.status_code == 404: - raise ForgejoError(f"Not found (404): {path}") - - try: - detail = resp.json().get("message", resp.text[:200]) - except (json.JSONDecodeError, ValueError): - detail = resp.text[:200] - raise ForgejoError(f"API error ({resp.status_code}): {detail}") - - # ── Issue endpoints ──────────────────────────────────────────── - def issue_list(self, owner: str, repo: str, state: str = "open", - page: int = 1, limit: int = 50) -> list: - r = self._request("GET", f"/api/v1/repos/{owner}/{repo}/issues", - params={"state": state, "page": page, "limit": limit}) - return r if isinstance(r, list) else [] - - def issue_get(self, owner: str, repo: str, index: int) -> dict: - return self._request("GET", f"/api/v1/repos/{owner}/{repo}/issues/{index}") - - def issue_create(self, owner: str, repo: str, title: str, - body: str = "", labels: list = None, - assignees: list = None) -> dict: - data = {"title": title} - if body: - data["body"] = body - if labels: - data["labels"] = labels - if assignees: - data["assignees"] = assignees - return self._request("POST", f"/api/v1/repos/{owner}/{repo}/issues", - json_data=data) - - def issue_edit(self, owner: str, repo: str, index: int, - **kwargs) -> dict: - return self._request("PATCH", f"/api/v1/repos/{owner}/{repo}/issues/{index}", - json_data=kwargs) - - def issue_comment(self, owner: str, repo: str, index: int, - body: str) -> dict: - return self._request("POST", - f"/api/v1/repos/{owner}/{repo}/issues/{index}/comments", - json_data={"body": body}) - - def issue_labels(self, owner: str, repo: str, index: int) -> list: - r = self._request("GET", - f"/api/v1/repos/{owner}/{repo}/issues/{index}/labels") - return r if isinstance(r, list) else [] - - def issue_set_labels(self, owner: str, repo: str, index: int, - labels: list) -> dict: - return self._request("PUT", - f"/api/v1/repos/{owner}/{repo}/issues/{index}/labels", - json_data={"labels": labels}) - - def issue_add_label(self, owner: str, repo: str, index: int, - label_id: int) -> dict: - return self._request("POST", - f"/api/v1/repos/{owner}/{repo}/issues/{index}/labels", - json_data=[label_id]) - - # ── PR endpoints ─────────────────────────────────────────────── - def pr_list(self, owner: str, repo: str, state: str = "open", - page: int = 1, limit: int = 50) -> list: - r = self._request("GET", f"/api/v1/repos/{owner}/{repo}/pulls", - params={"state": state, "page": page, "limit": limit}) - return r if isinstance(r, list) else [] - - def pr_get(self, owner: str, repo: str, index: int) -> dict: - return self._request("GET", f"/api/v1/repos/{owner}/{repo}/pulls/{index}") - - def pr_diff(self, owner: str, repo: str, index: int) -> str: - url = f"{self.server}/api/v1/repos/{owner}/{repo}/pulls/{index}.diff" - if self.dry_run: - return "[dry-run]" + return {"dry_run": True, "method": method, "path": path, + "query": query or {}, "body": body} if not self.token: - die("No API token available.") + raise ForgejoError("No API token available; set FORGEJO_AGENT_TOKEN or FORGEJO_USER_TOKEN") + if requests is None: + raise ForgejoError("requests is required for live API calls") try: - resp = requests.get(url, headers={"Authorization": f"token {self.token}"}, - timeout=30) - return resp.text if resp.status_code == 200 else "" - except requests.ConnectionError as e: - raise ForgejoError(f"Cannot connect: {e}") - - def pr_reviews(self, owner: str, repo: str, index: int) -> list: - r = self._request("GET", - f"/api/v1/repos/{owner}/{repo}/pulls/{index}/reviews") - return r if isinstance(r, list) else [] - - def pr_submit_review(self, owner: str, repo: str, index: int, - body: str, event: str = "COMMENT") -> dict: - return self._request("POST", - f"/api/v1/repos/{owner}/{repo}/pulls/{index}/reviews", - json_data={"body": body, "event": event}) - - def pr_merge(self, owner: str, repo: str, index: int, - merge_style: str = "merge") -> dict: - return self._request("POST", - f"/api/v1/repos/{owner}/{repo}/pulls/{index}/merge", - json_data={"Do": merge_style}) - - def pr_comment(self, owner: str, repo: str, index: int, - body: str, commit_id: str = None, - path: str = None, line: int = None) -> dict: - data = {"body": body} - if commit_id: - data["commit_id"] = commit_id - if path: - data["path"] = path - if line: - data["line"] = line - return self._request("POST", - f"/api/v1/repos/{owner}/{repo}/pulls/{index}/comments", - json_data=data) - - def pr_create(self, owner: str, repo: str, title: str, - head: str, base: str, body: str = "", - draft: bool = False) -> dict: - data = {"title": title, "head": head, "base": base} - if body: - data["body"] = body - if draft: - data["draft"] = True - return self._request("POST", - f"/api/v1/repos/{owner}/{repo}/pulls", - json_data=data) - - # ── Comment endpoints ────────────────────────────────────────── - def comment_list(self, owner: str, repo: str, issue_index: int) -> list: - r = self._request("GET", - f"/api/v1/repos/{owner}/{repo}/issues/{issue_index}/comments") - return r if isinstance(r, list) else [] - - def comment_create(self, owner: str, repo: str, issue_index: int, - body: str) -> dict: - return self._request("POST", - f"/api/v1/repos/{owner}/{repo}/issues/{issue_index}/comments", - json_data={"body": body}) - - def comment_delete(self, owner: str, repo: str, comment_id: int) -> dict: - return self._request("DELETE", - f"/api/v1/repos/{owner}/{repo}/issues/comments/{comment_id}") - - # ── Label endpoints ──────────────────────────────────────────── - def label_list(self, owner: str, repo: str = None, org: str = None) -> list: - if org: - r = self._request("GET", f"/api/v1/orgs/{org}/labels") - else: - r = self._request("GET", f"/api/v1/repos/{owner}/{repo}/labels") - return r if isinstance(r, list) else [] - - def label_create(self, owner: str, repo: str, name: str, - color: str = "#000000", description: str = "") -> dict: - return self._request("POST", f"/api/v1/repos/{owner}/{repo}/labels", - json_data={"name": name, "color": color, - "description": description}) - - # ── Hook endpoints ───────────────────────────────────────────── - def hook_list(self) -> list: - r = self._request("GET", "/api/v1/user/hooks") - return r if isinstance(r, list) else [] - - def hook_create(self, url: str, secret: str, events: list, - content_type: str = "json") -> dict: - return self._request("POST", "/api/v1/user/hooks", - json_data={"type": "forgejo", - "config": {"url": url, - "content_type": content_type, - "secret": secret}, - "events": events, "active": True}) - - def hook_delete(self, hook_id: int) -> dict: - return self._request("DELETE", f"/api/v1/user/hooks/{hook_id}") - - # ── User endpoints ───────────────────────────────────────────── - def user_get(self) -> dict: - return self._request("GET", "/api/v1/user") - - def user_settings_get(self) -> dict: - return self._request("GET", "/api/v1/user/settings") - - def user_settings_update(self, **kwargs) -> dict: - return self._request("PATCH", "/api/v1/user/settings", json_data=kwargs) - - # ── Repo endpoints ───────────────────────────────────────────── - def repo_list(self, owner: str = None) -> list: - if owner: - r = self._request("GET", f"/api/v1/users/{owner}/repos") - else: - r = self._request("GET", "/api/v1/user/repos") - return r if isinstance(r, list) else [] - - def repo_get(self, owner: str, repo: str) -> dict: - return self._request("GET", f"/api/v1/repos/{owner}/{repo}") - - def repo_search(self, query: str, limit: int = 20) -> list: - r = self._request("GET", "/api/v1/repos/search", - params={"q": query, "limit": limit}) - if isinstance(r, dict): - return r.get("data", []) - return [] + response = requests.request(method, self.server + path, params=query, + json=body, headers={"Authorization": "token " + self.token, + "Content-Type": "application/json"}, timeout=30) + except requests.RequestException as exc: + raise ForgejoError(f"{method} {path}: connection error: {exc}") from exc + if response.status_code >= 400: + try: + message = response.json().get("message", response.text) + except ValueError: + message = response.text + raise ForgejoError(f"{method} {path}: HTTP {response.status_code}: {message}") + if response.status_code == 204 or not response.content: + return {} + try: + return response.json() + except ValueError: + return {"raw": response.text} -# ── Global flag pre-parser ───────────────────────────────────────── -GLOBAL_BOOLS = {"--json", "--dry-run", "-n", "--force", "--yes", "-y", - "--quiet", "-q", "--verbose", "-v", "--agent", "--user", "--help", "-h"} -GLOBAL_VALUES = {"--server"} +def global_flags(parser): + parser.add_argument("--agent", action="store_true", help="use FORGEJO_AGENT_TOKEN (default)") + parser.add_argument("--user", action="store_true", help="use FORGEJO_USER_TOKEN") + parser.add_argument("--server", default=DEFAULT_SERVER, help="Forgejo server URL") + parser.add_argument("--json", action="store_true", help="emit exactly one JSON value") + parser.add_argument("--quiet", "-q", action="store_true") + parser.add_argument("--verbose", "-v", action="store_true") + parser.add_argument("--dry-run", "-n", action="store_true", help="plan request without credentials or network") + parser.add_argument("--force", "--yes", "-y", action="store_true", help="allow a mutation") -def _preparse_globals(argv): - globals_map = {} - filtered = [argv[0]] - i = 1 + +def scoped(parser, required_repo=True, index=False): + parser.add_argument("--owner", required=required_repo) + parser.add_argument("--repo", required=required_repo) + if index: + parser.add_argument("--index", required=True, type=int) + + +def add_action(sub, name, *, method, path, body=None, query=None, **options): + p = sub.add_parser(name, help=options.pop("help", None)) + for opt, kwargs in options.items(): + flags = ["--" + opt.replace("_", "-")] + p.add_argument(*flags, **kwargs) + p.set_defaults(method=method, path_template=path, body_fields=body or [], query_fields=query or []) + return p + + +def build_parser(): + p = argparse.ArgumentParser(prog="forgejo-cli", description=__doc__) + global_flags(p) + groups = p.add_subparsers(dest="group", required=True) + + # Issues + issue = groups.add_parser("issue", help="manage issues"); si = issue.add_subparsers(dest="action", required=True) + for action, method, path, idx in [("list","GET","/api/v1/repos/{owner}/{repo}/issues",False),("show","GET","/api/v1/repos/{owner}/{repo}/issues/{index}",True)]: + x=si.add_parser(action); scoped(x, index=idx); x.add_argument("--state", default="open"); x.set_defaults(method=method,path_template=path,query_fields=["state"]) + x=si.add_parser("create"); scoped(x); x.add_argument("--title",required=True); x.add_argument("--body",default=""); x.add_argument("--labels",default=""); x.add_argument("--assignees",default=""); x.set_defaults(method="POST",path_template="/api/v1/repos/{owner}/{repo}/issues",body_fields=["title","body","labels","assignees"]) + for action, method, fields in [("edit","PATCH",["title","body","state","assignees"]),("assign","PATCH",["assignees"]),("close","PATCH",[]),("reopen","PATCH",[])]: + x=si.add_parser(action); scoped(x,index=True) + for f in fields: x.add_argument("--"+f.replace("_","-")) + x.set_defaults(method=method,path_template="/api/v1/repos/{owner}/{repo}/issues/{index}",body_fields=fields, fixed_body={"state": "closed" if action=="close" else "open" if action=="reopen" else None}) + x=si.add_parser("labels"); scoped(x,index=True); x.add_argument("--labels",default=""); x.set_defaults(method="PUT",path_template="/api/v1/repos/{owner}/{repo}/issues/{index}/labels",body_fields=["labels"]) + x=si.add_parser("comment"); scoped(x,index=True); x.add_argument("--body",required=True); x.set_defaults(method="POST",path_template="/api/v1/repos/{owner}/{repo}/issues/{index}/comments",body_fields=["body"]) + + # Pull requests + pr=groups.add_parser("pr",help="manage pull requests"); sp=pr.add_subparsers(dest="action",required=True) + for action, method, path, idx in [("list","GET","/api/v1/repos/{owner}/{repo}/pulls",False),("show","GET","/api/v1/repos/{owner}/{repo}/pulls/{index}",True),("diff","GET","/api/v1/repos/{owner}/{repo}/pulls/{index}.diff",True),("reviews","GET","/api/v1/repos/{owner}/{repo}/pulls/{index}/reviews",True)]: + x=sp.add_parser(action); scoped(x,index=idx); x.add_argument("--state",default="open"); x.set_defaults(method=method,path_template=path,query_fields=["state"]) + x=sp.add_parser("create"); scoped(x); [x.add_argument(a,required=True) for a in ("--title","--head","--base")]; x.add_argument("--body",default=""); x.add_argument("--draft",action="store_true"); x.set_defaults(method="POST",path_template="/api/v1/repos/{owner}/{repo}/pulls",body_fields=["title","head","base","body","draft"]) + x=sp.add_parser("edit"); scoped(x,index=True); x.add_argument("--title"); x.add_argument("--body"); x.add_argument("--state"); x.set_defaults(method="PATCH",path_template="/api/v1/repos/{owner}/{repo}/pulls/{index}",body_fields=["title","body","state"]) + x=sp.add_parser("comment"); scoped(x,index=True); x.add_argument("--body",required=True); x.add_argument("--commit-id"); x.add_argument("--path"); x.add_argument("--line",type=int); x.set_defaults(method="POST",path_template="/api/v1/repos/{owner}/{repo}/pulls/{index}/comments",body_fields=["body","commit_id","path","line"]) + x=sp.add_parser("review"); scoped(x,index=True); x.add_argument("--body",default=""); x.add_argument("--event",default="COMMENT"); x.set_defaults(method="POST",path_template="/api/v1/repos/{owner}/{repo}/pulls/{index}/reviews",body_fields=["body","event"]) + x=sp.add_parser("merge"); scoped(x,index=True); x.add_argument("--style",default="merge",choices=["merge","rebase","rebase-merge","squash","manually-merged"]); x.set_defaults(method="POST",path_template="/api/v1/repos/{owner}/{repo}/pulls/{index}/merge",body_fields=["style"]) + + # Repository and contents + repo=groups.add_parser("repo",help="manage repositories"); sr=repo.add_subparsers(dest="action",required=True) + x=sr.add_parser("list"); x.add_argument("--owner"); x.set_defaults(method="GET",path_template="/api/v1/user/repos",query_fields=[]) + x=sr.add_parser("search"); x.add_argument("--query",required=True); x.set_defaults(method="GET",path_template="/api/v1/repos/search",query_fields=["query"]) + x=sr.add_parser("create"); x.add_argument("--name",required=True); x.add_argument("--description",default=""); x.add_argument("--private",action="store_true"); x.set_defaults(method="POST",path_template="/api/v1/user/repos",body_fields=["name","description","private"]) + for action,method in [("show","GET"),("edit","PATCH"),("delete","DELETE"),("branches","GET")]: + x=sr.add_parser(action); scoped(x); x.add_argument("--description"); x.add_argument("--private",action="store_true"); suffix="/branches" if action=="branches" else ""; x.set_defaults(method=method,path_template="/api/v1/repos/{owner}/{repo}"+suffix,body_fields=["description","private"] if action=="edit" else []) + content=groups.add_parser("content",help="manage file contents"); sc=content.add_subparsers(dest="action",required=True) + for action,method in [("get","GET"),("create","POST"),("update","PUT"),("delete","DELETE")]: + x=sc.add_parser(action); scoped(x); x.add_argument("--path",required=True); x.add_argument("--content"); x.add_argument("--sha"); x.add_argument("--message"); x.add_argument("--branch"); x.set_defaults(method=method,path_template="/api/v1/repos/{owner}/{repo}/contents/{path}",body_fields=["content","sha","message","branch"]) + + # Metadata resources share CRUD endpoint shapes. + for group, plural in [("label","labels"),("milestone","milestones"),("release","releases")]: + g=groups.add_parser(group,help=f"manage {plural}"); s=g.add_subparsers(dest="action",required=True) + for action,method in [("list","GET"),("show","GET"),("create","POST"),("edit","PATCH"),("delete","DELETE")]: + x=s.add_parser(action); scoped(x); x.add_argument("--id",type=int,required=action in {"show","edit","delete"}); x.add_argument("--name"); x.add_argument("--title"); x.add_argument("--body"); x.add_argument("--tag-name"); suffix="" if action in {"list","create"} else "/{id}"; x.set_defaults(method=method,path_template=f"/api/v1/repos/{{owner}}/{{repo}}/{plural}"+suffix,body_fields=["name","title","body","tag_name"]) + hook=groups.add_parser("hook",help="manage user or repository webhooks"); sh=hook.add_subparsers(dest="action",required=True) + for action,method in [("list","GET"),("show","GET"),("create","POST"),("edit","PATCH"),("delete","DELETE")]: + x=sh.add_parser(action); x.add_argument("--owner"); x.add_argument("--repo"); x.add_argument("--id",type=int,required=action in {"show","edit","delete"}); x.add_argument("--url"); x.add_argument("--secret"); x.add_argument("--events"); x.set_defaults(method=method,path_template="",body_fields=["url","secret","events"]) + user=groups.add_parser("user",help="show profile or settings"); su=user.add_subparsers(dest="action",required=True) + for action,method,path in [("show","GET","/api/v1/user"),("settings","GET","/api/v1/user/settings"),("update-settings","PATCH","/api/v1/user/settings")]: + x=su.add_parser(action); x.add_argument("--data"); x.set_defaults(method=method,path_template=path,body_fields=[]) + api=groups.add_parser("api",help="call any /api/v1 endpoint safely"); api.add_argument("--method",required=True,choices=["GET","POST","PUT","PATCH","DELETE"]); api.add_argument("--path",required=True); api.add_argument("--query",action="append",default=[]); api.add_argument("--data"); api.add_argument("--data-file"); api.set_defaults(group="api") + return p + + +def body_for(args): + if getattr(args, "data", None): return json.loads(args.data) + if getattr(args, "data_file", None): return json.loads(Path(args.data_file).read_text()) + result = {} + for key in getattr(args, "body_fields", []): + value = getattr(args, key, None) + if value not in (None, "", False): + if key in {"labels", "assignees", "events"} and isinstance(value, str): value = [int(x) if x.strip().isdigit() else x.strip() for x in value.split(",") if x.strip()] + result[key] = value + result.update({k:v for k,v in getattr(args,"fixed_body",{}).items() if v is not None}) + return result or None + + +def resolve_path(args): + if args.group == "hook": + base = f"/api/v1/repos/{seg(args.owner)}/{seg(args.repo)}/hooks" if args.owner and args.repo else "/api/v1/user/hooks" + return base + (f"/{args.id}" if args.action in {"show","edit","delete"} else "") + values = {k: seg(v) for k,v in vars(args).items() if v is not None} + return args.path_template.format(**values) + + +def normalize_global_flags(argv): + """Permit documented global flags at any command nesting level.""" + booleans = {"--agent", "--user", "--json", "--quiet", "-q", "--verbose", "-v", + "--dry-run", "-n", "--force", "--yes", "-y"} + front, rest, i = [], [], 0 while i < len(argv): - arg = argv[i] - if arg in GLOBAL_BOOLS: - globals_map[arg.lstrip("-").replace("-", "_")] = True - i += 1 - elif arg in GLOBAL_VALUES: - key = arg.lstrip("-").replace("-", "_") - if i + 1 < len(argv) and not argv[i + 1].startswith("-"): - globals_map[key] = argv[i + 1] - i += 2 + value = argv[i] + if value in booleans: + front.append(value) + elif value == "--server": + if i + 1 >= len(argv): + rest.append(value) else: - die(f"{arg} requires a value") - elif arg == "--": - filtered.extend(argv[i:]) - break - else: - filtered.append(arg) - i += 1 - return globals_map, filtered - - -# ── Help ─────────────────────────────────────────────────────────── -def show_help(cmd: str = "", sub: str = ""): - if cmd == "issue": - print("""Usage: forgejo-cli issue [OPTIONS] - -Subcommands: - list List issues (--owner, --repo, --state) - show Show issue details (--owner, --repo, --index) - create Create an issue (--owner, --repo, --title, --body, --labels, --assignees) - comment Add comment (--owner, --repo, --index, --body) - label Set issue labels (--owner, --repo, --index, --labels) - assign Assign issue (--owner, --repo, --index, --assignees) - -Examples: - forgejo-cli issue list --owner magnus --repo test - forgejo-cli issue show --owner magnus --repo test --index 3 - forgejo-cli issue create --owner magnus --repo test --title "Bug" --body "..." --labels bug - forgejo-cli issue comment --owner magnus --repo test --index 3 --body "fixed" -""") - elif cmd == "pr": - print("""Usage: forgejo-cli pr [OPTIONS] - -Subcommands: - list List PRs (--owner, --repo, --state) - show Show PR details (--owner, --repo, --index) - create Create a PR (--owner, --repo, --title, --head, --base, [--body], [--draft]) - diff Get PR diff (--owner, --repo, --index) - comment Add PR comment (--owner, --repo, --index, --body) - review Submit PR review (--owner, --repo, --index, --body, --event) - merge Merge a PR (--owner, --repo, --index) - -Examples: - forgejo-cli pr create --owner magnus --repo myrepo --title "feat: add auth" --head feat/add-auth --base main --body "Closes #42" - forgejo-cli pr diff --owner magnus --repo test --index 1 - forgejo-cli pr review --owner magnus --repo test --index 1 --body "LGTM" --event approve -""") - elif cmd == "repo": - print("""Usage: forgejo-cli repo [OPTIONS] - -Subcommands: - list List repos (--owner) - show Show repo details (--owner, --repo) - search Search repos (--query) - -Examples: - forgejo-cli repo list - forgejo-cli repo show --owner magnus --repo test -""") - elif cmd == "label": - print("""Usage: forgejo-cli label [OPTIONS] - -Subcommands: - list List labels (--owner, --repo) - create Create label (--owner, --repo, --name, --color) -""") - elif cmd == "hook": - print("""Usage: forgejo-cli hook [OPTIONS] - -Subcommands: - list List webhooks - create Create webhook (--url, --secret, --events) - delete Delete webhook (--id) -""") - elif cmd == "user": - print("""Usage: forgejo-cli user [OPTIONS] - -Subcommands: - show Show current user info - settings Get/edit user settings -""") - elif cmd == "comment": - print("""Usage: forgejo-cli comment [OPTIONS] - -Subcommands: - list List comments (--owner, --repo, --index) - create Create comment (--owner, --repo, --index, --body) -""") - else: - print(__doc__.strip()) - - -# ── Token resolution ─────────────────────────────────────────────── -def _resolve_token(use_user: bool = False) -> str: - if use_user: - return _get_env("FORGEJO_USER_TOKEN", "") - return _get_env("FORGEJO_AGENT_TOKEN", "") - - -# ── Command Handlers ────────────────────────────────────────────── - -def cmd_issue(client, args): - if not args: - show_help("issue") - return - sub = args[0] - rest = args[1:] - - owner = repo = index = title = body = state = None - labels = [] - assignees = [] - - i = 0 - while i < len(rest): - a = rest[i] - if a == "--owner" and i + 1 < len(rest): owner = rest[i + 1]; i += 2 - elif a == "--repo" and i + 1 < len(rest): repo = rest[i + 1]; i += 2 - elif a == "--index" and i + 1 < len(rest): index = int(rest[i + 1]); i += 2 - elif a == "--title" and i + 1 < len(rest): title = rest[i + 1]; i += 2 - elif a == "--body" and i + 1 < len(rest): body = rest[i + 1]; i += 2 - elif a == "--state" and i + 1 < len(rest): state = rest[i + 1]; i += 2 - elif a == "--labels" and i + 1 < len(rest): labels = [int(x.strip()) for x in rest[i + 1].split(",") if x.strip().isdigit()]; i += 2 - elif a == "--assignees" and i + 1 < len(rest): assignees = [x.strip() for x in rest[i + 1].split(",")]; i += 2 - else: die(f"Unknown option: {a}") - - if sub == "list": - # validation inline (was val()) - data = client.issue_list(owner, repo, state or "open") - if JSON_MODE: - print(json.dumps(data)) - else: - for iss in data: - print(f"#{iss['number']} [{iss['state']}] {iss['title']}") - elif sub == "show": - # validation inline (was val()) - data = client.issue_get(owner, repo, index) - emit(f"#{data.get('number')} [{data.get('state')}] {data.get('title')}\n{data.get('body','')}", - data) - elif sub == "create": - # validation inline (was val()) - if DRY_RUN: - emit(f"[dry-run] Would create issue '{title}' in {owner}/{repo}", - {"dry_run": True, "owner": owner, "repo": repo, "title": title}) - return - data = client.issue_create(owner, repo, title, body or "", - labels or None, assignees or None) - emit(json.dumps(data, indent=2), - f"Created issue #{data.get('number')}: {data.get('title')}") - elif sub == "comment": - # validation inline (was val()) - data = client.issue_comment(owner, repo, index, body) - emit(json.dumps(data, indent=2), - f"Comment added to #{index}") - elif sub == "label": - # validation inline (was val()) - data = client.issue_set_labels(owner, repo, index, labels) - emit(json.dumps(data, indent=2), - f"Labels set on #{index}: {', '.join(labels)}") - elif sub == "assign": - # validation inline (was val()) - data = client.issue_edit(owner, repo, index, assignees=assignees) - emit(json.dumps(data, indent=2), - f"Assigned #{index} to {', '.join(assignees)}") - else: - die(f"Unknown issue subcommand: {sub}") - - -def cmd_pr(client, args): - if not args: - show_help("pr") - return - sub = args[0] - rest = args[1:] - - owner = repo = index = title = body = state = event = None - commit_id = path = line = None - head = base = None - draft = False - - i = 0 - while i < len(rest): - a = rest[i] - if a == "--owner" and i + 1 < len(rest): owner = rest[i + 1]; i += 2 - elif a == "--repo" and i + 1 < len(rest): repo = rest[i + 1]; i += 2 - elif a == "--index" and i + 1 < len(rest): index = int(rest[i + 1]); i += 2 - elif a == "--body" and i + 1 < len(rest): body = rest[i + 1]; i += 2 - elif a == "--state" and i + 1 < len(rest): state = rest[i + 1]; i += 2 - elif a == "--event" and i + 1 < len(rest): event = rest[i + 1]; i += 2 - elif a == "--head" and i + 1 < len(rest): head = rest[i + 1]; i += 2 - elif a == "--base" and i + 1 < len(rest): base = rest[i + 1]; i += 2 - elif a == "--title" and i + 1 < len(rest): title = rest[i + 1]; i += 2 - elif a == "--draft": draft = True; i += 1 - elif a == "--commit" and i + 1 < len(rest): commit_id = rest[i + 1]; i += 2 - elif a == "--path" and i + 1 < len(rest): path = rest[i + 1]; i += 2 - elif a == "--line" and i + 1 < len(rest): line = int(rest[i + 1]); i += 2 - else: die(f"Unknown option: {a}") - - if sub == "list": - # validation inline (was val()) - data = client.pr_list(owner, repo, state or "open") - if JSON_MODE: - print(json.dumps(data)) - else: - for pr in data: - print(f"!{pr['number']} [{pr.get('state','')}] {pr.get('title','')}") - elif sub == "show": - # validation inline (was val()) - data = client.pr_get(owner, repo, index) - emit(json.dumps(data, indent=2), - f"!{data.get('number')} [{data.get('state')}] {data.get('title')}") - elif sub == "diff": - # validation inline (was val()) - diff = client.pr_diff(owner, repo, index) - print(diff) - elif sub == "comment": - # validation inline (was val()) - data = client.pr_comment(owner, repo, index, body, commit_id, path, line) - emit(json.dumps(data, indent=2), f"PR comment added") - elif sub == "review": - # validation inline (was val()) - data = client.pr_submit_review(owner, repo, index, body, event or "COMMENT") - emit(json.dumps(data, indent=2), f"Review submitted on !{index}") - elif sub == "merge": - # validation inline (was val()) - if not FORCE and not DRY_RUN: - die("Use --force or --dry-run to merge PR !{index}") - data = client.pr_merge(owner, repo, index) - emit(json.dumps(data, indent=2), f"Merged !{index}") - elif sub == "create": - if not all([owner, repo, title, head, base]): - die("Required: --owner, --repo, --title, --head, --base") - data = client.pr_create(owner, repo, title, head, base, body or "", draft) - pr_num = data.get('number', '?') - emit(json.dumps(data, indent=2), - f"Created PR #{pr_num}: {title}") - else: - die(f"Unknown pr subcommand: {sub}") - - -def cmd_repo(client, args): - if not args: - show_help("repo") - return - sub = args[0] - rest = args[1:] - - owner = repo = query = None - i = 0 - while i < len(rest): - a = rest[i] - if a == "--owner" and i + 1 < len(rest): owner = rest[i + 1]; i += 2 - elif a == "--repo" and i + 1 < len(rest): repo = rest[i + 1]; i += 2 - elif a == "--query" and i + 1 < len(rest): query = rest[i + 1]; i += 2 - else: die(f"Unknown option: {a}") - - if sub == "list": - data = client.repo_list(owner) - if JSON_MODE: - print(json.dumps(data)) - else: - for r in data: - print(f"{r.get('full_name','')} [{r.get('language','')}]") - elif sub == "show": - owner or die("--owner required"); repo or die("--repo required") - data = client.repo_get(owner, repo) - emit(json.dumps(data, indent=2), - f"{data.get('full_name')} - {data.get('description','')}") - elif sub == "search": - query or die("--query required") - data = client.repo_search(query) - if JSON_MODE: - print(json.dumps(data)) - else: - for r in data: - print(f"{r.get('full_name','')}") - else: - die(f"Unknown repo subcommand: {sub}") - - -def cmd_label(client, args): - if not args: - show_help("label") - return - sub = args[0] - rest = args[1:] - - owner = repo = name = color = None - i = 0 - while i < len(rest): - a = rest[i] - if a == "--owner" and i + 1 < len(rest): owner = rest[i + 1]; i += 2 - elif a == "--repo" and i + 1 < len(rest): repo = rest[i + 1]; i += 2 - elif a == "--name" and i + 1 < len(rest): name = rest[i + 1]; i += 2 - elif a == "--color" and i + 1 < len(rest): color = rest[i + 1]; i += 2 - else: die(f"Unknown option: {a}") - - if sub == "list": - owner or die("--owner required") - data = client.label_list(owner, repo) - if JSON_MODE: - print(json.dumps(data)) - else: - for lbl in data: - print(f"{lbl.get('name','')} ({lbl.get('color','')})") - elif sub == "create": - owner or die("--owner required"); repo or die("--repo required") - name or die("--name required") - data = client.label_create(owner, repo, name, color or "#000000") - emit(json.dumps(data, indent=2), f"Created label '{name}'") - else: - die(f"Unknown label subcommand: {sub}") - - -def cmd_hook(client, args): - if not args: - show_help("hook") - return - sub = args[0] - rest = args[1:] - - hook_url = secret = events = None - hook_id = None - i = 0 - while i < len(rest): - a = rest[i] - if a == "--url" and i + 1 < len(rest): hook_url = rest[i + 1]; i += 2 - elif a == "--secret" and i + 1 < len(rest): secret = rest[i + 1]; i += 2 - elif a == "--events" and i + 1 < len(rest): events = [x.strip() for x in rest[i + 1].split(",")]; i += 2 - elif a == "--id" and i + 1 < len(rest): hook_id = int(rest[i + 1]); i += 2 - else: die(f"Unknown option: {a}") - - if sub == "list": - data = client.hook_list() - if JSON_MODE: - print(json.dumps(data)) - else: - for h in data: - print(f"#{h.get('id')} {h.get('url','')}") - elif sub == "create": - hook_url or die("--url required"); secret or die("--secret required") - data = client.hook_create(hook_url, secret, events or ["issues"]) - emit(json.dumps(data, indent=2), f"Created hook #{data.get('id')}") - elif sub == "delete": - hook_id or die("--id required") - data = client.hook_delete(hook_id) - emit(json.dumps(data, indent=2), f"Deleted hook #{hook_id}") - else: - die(f"Unknown hook subcommand: {sub}") - - -def cmd_user(client, args): - if not args: - show_help("user") - return - sub = args[0] - rest = args[1:] - - data = {} - i = 0 - while i < len(rest): - a = rest[i] - if a.startswith("--"): - if i + 1 < len(rest) and not rest[i + 1].startswith("--"): - data[a.lstrip("-").replace("-", "_")] = rest[i + 1] - i += 2 - else: - data[a.lstrip("-").replace("-", "_")] = True + front.extend([value, argv[i + 1]]) i += 1 else: - i += 1 + rest.append(value) + i += 1 + return front + rest - if sub == "show": - info = client.user_get() - emit(f"{info.get('login')} ({info.get('full_name','')}) - admin={info.get('is_admin',False)}", - info) - elif sub == "settings": - if data: - info = client.user_settings_update(**data) - emit(json.dumps(info, indent=2), "Settings updated") - else: - info = client.user_settings_get() - emit(json.dumps(info, indent=2), info) + +def main(argv=None): + parser = build_parser(); args = parser.parse_args(normalize_global_flags(argv or sys.argv[1:])) + if args.group == "api": + if not args.path.startswith("/api/v1/"): parser.error("api --path must begin with /api/v1/") + query = dict(item.split("=", 1) for item in args.query if "=" in item) + if len(query) != len(args.query): parser.error("--query must use KEY=VALUE") + method, path, body = args.method, args.path, body_for(args) else: - die(f"Unknown user subcommand: {sub}") - - -def cmd_comment(client, args): - if not args: - show_help("comment") - return - sub = args[0] - rest = args[1:] - - owner = repo = index = body = None - i = 0 - while i < len(rest): - a = rest[i] - if a == "--owner" and i + 1 < len(rest): owner = rest[i + 1]; i += 2 - elif a == "--repo" and i + 1 < len(rest): repo = rest[i + 1]; i += 2 - elif a == "--index" and i + 1 < len(rest): index = int(rest[i + 1]); i += 2 - elif a == "--body" and i + 1 < len(rest): body = rest[i + 1]; i += 2 - else: die(f"Unknown option: {a}") - - if sub == "list": - owner or die("--owner required"); repo or die("--repo required"); index or die("--index required") - data = client.comment_list(owner, repo, index) - if JSON_MODE: - print(json.dumps(data)) - else: - for c in data: - print(f"[{c.get('id')}] {c.get('user',{}).get('login','')}: {c.get('body','')[:80]}") - elif sub == "create": - owner or die("--owner required"); repo or die("--repo required") - index or die("--index required"); body or die("--body required") - data = client.comment_create(owner, repo, index, body) - emit(json.dumps(data, indent=2), f"Comment #{data.get('id')} created") - else: - die(f"Unknown comment subcommand: {sub}") - - -# ── Main ─────────────────────────────────────────────────────────── -def main(): - global QUIET, JSON_MODE, VERBOSE, FORCE, DRY_RUN - - # Pre-parse global flags - globals_map, filtered_argv = _preparse_globals(sys.argv) - - QUIET = globals_map.get("quiet", False) or globals_map.get("q", False) - JSON_MODE = globals_map.get("json", False) - VERBOSE = globals_map.get("verbose", False) or globals_map.get("v", False) - FORCE = globals_map.get("force", False) or globals_map.get("yes", False) or globals_map.get("y", False) - DRY_RUN = globals_map.get("dry_run", False) or globals_map.get("n", False) - use_user = globals_map.get("user", False) - server = globals_map.get("server", DEFAULT_SERVER) - - if JSON_MODE: - warnings.simplefilter("ignore") - - args = filtered_argv[1:] - - if not args or "--help" in args or "-h" in args: - show_help() - return - - cmd = args[0] - sub_args = args[1:] - - if "--help" in sub_args or "-h" in sub_args: - show_help(cmd) - return - - token = _resolve_token(use_user) - client = ForgejoClient(server, token, dry_run=DRY_RUN) - - handlers = { - "issue": cmd_issue, - "pr": cmd_pr, - "repo": cmd_repo, - "label": cmd_label, - "hook": cmd_hook, - "user": cmd_user, - "comment": cmd_comment, - } - - handler = handlers.get(cmd) - if handler: - try: - handler(client, sub_args) - except ForgejoError as e: - die(str(e)) - else: - die(f"Unknown command: {cmd}") - show_help() + method, path, body = args.method, resolve_path(args), body_for(args) + query = {k: getattr(args,k) for k in getattr(args,"query_fields",[]) if getattr(args,k,None) is not None} + if args.group == "repo" and args.action == "list" and args.owner: path=f"/api/v1/users/{seg(args.owner)}/repos" + if args.group == "repo" and args.action == "search": query={"q": query.pop("query")} + if method in MUTATING and not (args.force or args.dry_run): parser.error(f"{method} is a mutation; use --force/--yes or --dry-run") + try: result = Client(args).request(method, path, query, body) + except (ForgejoError, json.JSONDecodeError) as exc: parser.error(str(exc)) + if args.json: print(json.dumps(result, sort_keys=True)) + elif not args.quiet: print(json.dumps(result, indent=2) if isinstance(result, (dict,list)) else result) if __name__ == "__main__": diff --git a/forgejo-cli/tests/test_cli.py b/forgejo-cli/tests/test_cli.py new file mode 100644 index 0000000..b56174f --- /dev/null +++ b/forgejo-cli/tests/test_cli.py @@ -0,0 +1,63 @@ +import importlib.machinery +import io +import json +import pathlib +import unittest +from contextlib import redirect_stderr, redirect_stdout + +SCRIPT = pathlib.Path(__file__).parents[1] / "scripts" / "forgejo-cli" +cli = importlib.machinery.SourceFileLoader("forgejo_cli", str(SCRIPT)).load_module() + + +class CliTests(unittest.TestCase): + def run_cli(self, args): + out, err = io.StringIO(), io.StringIO() + with redirect_stdout(out), redirect_stderr(err): + try: + cli.main(args) + except SystemExit as exc: + return exc.code, out.getvalue(), err.getvalue() + return 0, out.getvalue(), err.getvalue() + + def plan(self, args): + code, out, err = self.run_cli(["--dry-run", "--json"] + args) + self.assertEqual(code, 0, err) + return json.loads(out) + + def test_help_needs_no_credentials(self): + self.assertEqual(self.run_cli(["--help"])[0], 0) + self.assertEqual(self.run_cli(["issue", "--help"])[0], 0) + + def test_issue_create_plan(self): + plan = self.plan(["issue", "create", "--owner", "me", "--repo", "x", "--title", "hello"]) + self.assertEqual((plan["method"], plan["path"]), ("POST", "/api/v1/repos/me/x/issues")) + self.assertEqual(plan["body"]["title"], "hello") + + def test_mutation_requires_force(self): + code, _, err = self.run_cli(["repo", "create", "--name", "x"]) + self.assertNotEqual(code, 0) + self.assertIn("mutation", err) + + def test_api_path_guard_and_plan(self): + self.assertNotEqual(self.run_cli(["api", "--method", "GET", "--path", "/bad"])[0], 0) + plan = self.plan(["api", "--method", "PATCH", "--path", "/api/v1/user/settings", "--query", "theme=dark", "--data", '{"language":"en"}']) + self.assertEqual(plan["query"], {"theme": "dark"}) + self.assertEqual(plan["body"], {"language": "en"}) + + def test_representative_groups(self): + cases = [ + (["pr", "create", "--owner", "me", "--repo", "x", "--title", "t", "--head", "h", "--base", "main"], "POST", "/api/v1/repos/me/x/pulls"), + (["release", "create", "--owner", "me", "--repo", "x", "--tag-name", "v2"], "POST", "/api/v1/repos/me/x/releases"), + (["content", "update", "--owner", "me", "--repo", "x", "--path", "a b.txt", "--content", "eA==", "--sha", "abc"], "PUT", "/api/v1/repos/me/x/contents/a%20b.txt"), + (["hook", "create", "--owner", "me", "--repo", "x", "--url", "https://hook"], "POST", "/api/v1/repos/me/x/hooks"), + ] + for args, method, path in cases: + plan = self.plan(args) + self.assertEqual((plan["method"], plan["path"]), (method, path)) + + def test_repo_creation_needs_no_owner(self): + self.assertEqual(self.plan(["repo", "create", "--name", "demo", "--private"])["path"], "/api/v1/user/repos") + + +if __name__ == "__main__": + unittest.main()