fix(forgejo-cli): harden API transport and guidance

This commit is contained in:
Magnus Hedemark
2026-07-11 13:24:50 -04:00
parent 0ab926b57b
commit bcfc61b54d
9 changed files with 182 additions and 19 deletions
+7 -1
View File
@@ -6,12 +6,17 @@ Manage a Forgejo server from the terminal without hand-written `curl` or acciden
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.
It deliberately does not try to duplicate every API endpoint. Instead, it offers guarded first-class commands for common repository work and a generic client for the rest. The references explain when to use each, how to select a compatible schema, and how to handle pagination and token scopes.
## What You Get
| Contents | Purpose |
| --- | --- |
| `scripts/forgejo-cli` | Argparse Forgejo API v1 client |
| `references/command-reference.md` | Endpoint and payload guide |
| `references/api-usage.md` | Authentication, pagination, versioning, and schema-discovery procedure |
| `references/endpoint-routing.md` | Decision guide for first-class commands versus generic API calls |
| `references/troubleshooting.md` | Safe diagnosis of API and transport failures |
| `V2-SPEC.md` | v2 acceptance criteria |
| `tests/` | Offline stdlib contract tests |
@@ -22,6 +27,7 @@ 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
python3 scripts/forgejo-cli --server https://forge.example --page 1 --limit 50 --include-response issue list --owner acme --repo app --json
```
## Triggers
@@ -32,4 +38,4 @@ python3 scripts/forgejo-cli --server https://forge.example api --method GET --pa
## Requirements
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.
Python 3.8+ and `requests` for live API calls. `--help` and `--dry-run` need no token or dependency. Set `FORGEJO_AGENT_TOKEN` or `FORGEJO_USER_TOKEN` for token authentication. A deliberate `Authorization` header supports other documented schemes, but avoid putting secrets directly in shell history. Consult your Forgejo server's `/api/swagger` or `/swagger.v1.json` for exact schemas.
+6 -1
View File
@@ -12,13 +12,14 @@ metadata:
# Forgejo CLI v2
Run `python3 scripts/forgejo-cli`. `--agent` (default) uses `FORGEJO_AGENT_TOKEN`; `--user` uses `FORGEJO_USER_TOKEN`; `--server URL` selects an installation.
Run `python3 scripts/forgejo-cli`. `--agent` (default) uses `FORGEJO_AGENT_TOKEN`; `--user` uses `FORGEJO_USER_TOKEN`; `--server URL` selects an installation. Set `FORGEJO_SERVER` in your shell if your wrapper supplies it, otherwise pass `--server` explicitly.
## Safety
- 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.
- `--page` and `--limit` work for any list/search request. Add `--include-response` to receive the HTTP status plus `link` and `x-total-count` pagination headers.
- `--help` does not read credentials or contact a server. Path segments are encoded.
## Common workflows
@@ -31,3 +32,7 @@ python3 scripts/forgejo-cli --dry-run --json api --method POST \
```
First-class groups: `issue`, `pr`, `repo`, `content`, `label`, `milestone`, `release`, `hook`, and `user`. `content` expects base64 and update/delete require the current SHA. Use `api` for any other `/api/v1/` endpoint; it supports JSON, raw files, multipart uploads/forms, and custom headers. Consult `/api/swagger` or `/swagger.v1.json` on the selected server. See [command reference](references/command-reference.md).
## Choosing a command
Use a first-class command for its covered daily workflow. Use `api` when the operation is absent, requires version-specific fields, or needs a schema not represented by a simple flag. Before using `api`, read [endpoint routing](references/endpoint-routing.md); read [API usage](references/api-usage.md) for authentication, pagination, compatibility, and safe schema discovery. Read [troubleshooting](references/troubleshooting.md) for 401/403/422 responses and transport failures.
+1 -1
View File
@@ -2,4 +2,4 @@
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.
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. Live transport tests must cover multipart or form delivery, caller-provided authentication, response pagination metadata, missing-server failure, and nested content paths. Documentation must explain version-aware Swagger discovery, scope-aware authentication, pagination, and when a generic endpoint is the appropriate choice.
+28
View File
@@ -0,0 +1,28 @@
# API usage, compatibility, and pagination
## Establish the server contract
1. Pass the target with `--server https://forge.example`; do not treat the placeholder default as a usable service.
2. Request `GET /api/v1/version` with the generic command and record the Forgejo major version.
3. Open `https://forge.example/swagger.v1.json` (or `/api/swagger`) and use that server's schema for request fields. Forgejo guarantees API compatibility within a major version; do not assume a current server has the same endpoints or fields as an older Gitea-compatible installation. The upstream usage guide is https://forgejo.org/docs/latest/user/api-usage/.
## Authenticate safely
Use `FORGEJO_AGENT_TOKEN` for automation and `FORGEJO_USER_TOKEN` for user-authorized work. Give tokens the narrowest Forgejo scope that can complete the request. The client sends these as `Authorization: token …`.
The generic `--header Authorization=…` path supports Basic or Bearer authentication when an endpoint requires it. Prefer an environment-backed wrapper or a token for routine use: command-line credentials may be recorded in shell history and process listings. Never put tokens in `--data`, dry-run output, issue text, or logs.
## Paginate intentionally
All list/search commands accept `--page N --limit N`, including generic API calls. Use `--include-response --json` on a live request to retain Forgejo's `link` and `x-total-count` headers:
```bash
python3 scripts/forgejo-cli --server https://forge.example --page 1 --limit 50 \
--include-response --json issue list --owner acme --repo app
```
Follow the `rel="next"` URL until absent. Do not assume a fixed page size: server administrators configure defaults and maximum response items.
## Scope boundaries
The generic `api` command intentionally accepts only `/api/v1/` paths. Package registries and other non-v1 endpoints require their native client or a separately reviewed HTTP workflow. First-class commands provide guardrails; the generic command provides coverage, not schema validation.
+22 -2
View File
@@ -1,6 +1,6 @@
# 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.
Global flags: `--agent`, `--user`, `--server URL`, `--json`, `--quiet/-q`, `--verbose/-v`, `--dry-run/-n`, `--force/--yes/-y`, `--page`, `--limit`, and `--include-response`. POST, PUT, PATCH, and DELETE require `--force` unless dry-running. `--page` and `--limit` are appended as query parameters; `--include-response` wraps live output as `{data, status, headers}` and preserves `link` and `x-total-count`.
| Group | Commands | API route |
| --- | --- | --- |
@@ -15,8 +15,28 @@ Global flags: `--agent`, `--user`, `--server URL`, `--json`, `--quiet/-q`, `--ve
Comma-separated `--labels`, `--assignees`, and `--events` become JSON arrays. PR merge maps `--style` to Forgejo's `Do` field. Hook create accepts `--url`, `--secret`, `--events`, and `--type`; it builds Forgejo's required nested `config` payload. Release create requires `--tag-name`; its display name is `--name`. Content is base64 in `--content`; update and delete require `--sha`.
## First-class command details
- `issue list` and `pr list` default to `--state open`; override it when reviewing closed or all work. `issue close` and `issue reopen` use the same edit endpoint with an explicit state.
- `content --path` preserves directory separators and encodes unsafe characters. Supply base64 data to `--content`, not plain text. Fetch the current file SHA before an update or delete to avoid overwriting a newer version.
- `repo list --owner OWNER` lists that owner's public/visible repositories; without `--owner`, it lists the authenticated user's repositories. `repo create` intentionally requires no owner because it creates under the authenticated user.
- `release upload --file PATH` sends a multipart attachment. `release upload --external-url URL` sends the equivalent form field. They are mutually exclusive.
- `hook` operates on repository hooks when both `--owner` and `--repo` are present, otherwise on the authenticated user's hooks. Hook creation requires `--url`.
## Safe examples
```bash
# See the exact update request before changing a file.
python3 scripts/forgejo-cli --dry-run --json content update \
--owner acme --repo app --path docs/guide.md --content BASE64 --sha CURRENT_SHA
# Obtain pagination links and a total count from a live list request.
python3 scripts/forgejo-cli --server https://forge.example --page 1 --limit 50 \
--include-response --json pr list --owner acme --repo app
```
## Generic API
`forgejo-cli api --method GET|POST|PUT|PATCH|DELETE --path /api/v1/... [--query KEY=VALUE] [--data JSON | --data-file FILE] [--raw-file FILE] [--upload-file FILE --form KEY=VALUE] [--header KEY=VALUE] [--content-type TYPE]`
Only `/api/v1/` paths are accepted. JSON, raw binary, multipart file/form payloads, and custom headers are supported. Use it for Actions, packages, organizations, teams, admin APIs, notifications, repository permissions, and new endpoints; consult the target server's Swagger document for schemas.
Only `/api/v1/` paths are accepted. JSON, raw binary, multipart file/form payloads, and custom headers are supported. A supplied `Authorization` header is accepted when no token environment variable is set, enabling documented Basic or Bearer authentication; avoid putting credentials in shell history. Use it for Actions, packages, organizations, teams, admin APIs, notifications, repository permissions, and new endpoints; consult the target server's Swagger document for schemas.
@@ -0,0 +1,14 @@
# Endpoint routing guide
Use the first-class command when it exposes every field needed by the task. It gives semantic flags, safe path encoding, mutation confirmation, and predictable JSON output.
| Task | Default command | Use `api` instead when |
| --- | --- | --- |
| Issues and comments | `issue` | attachments, reactions, dependencies, time tracking, or a schema field not exposed by flags |
| Pull requests | `pr` | commits/files/statuses, reviewers beyond the basic review call, or version-specific merge fields |
| Repository and files | `repo`, `content` | collaborators, branch/tag protection, keys, mirrors, transfer, archive, or advanced settings |
| Metadata and delivery | `label`, `milestone`, `release`, `hook` | a payload needs fields not represented by the first-class command |
| Account settings | `user` | tokens, notifications, subscriptions, SSH/GPG keys, organizations, teams, or admin operations |
| Actions, packages, projects | `api` | always: these APIs evolve independently and need the live Swagger schema or native package client |
For a generic call, first read the target server's Swagger operation, copy its method and required fields exactly, then dry-run the request. Add `--force` only after reviewing the plan. If the endpoint returns a list, include `--page`, `--limit`, and `--include-response`.
+13
View File
@@ -0,0 +1,13 @@
# Troubleshooting Forgejo API calls
| Symptom | Check | Safe next action |
| --- | --- | --- |
| `No API token available` | Required environment variable is absent | Export the intended token or provide a deliberate `Authorization` header for the one request |
| 401 | Token type, expiry, server URL, or authentication scheme | Run a read-only `user show`; do not retry mutations blindly |
| 403 | Token scope, repository access, or server policy | Inspect the target endpoint's required scope and permissions; use a narrower correctly scoped token rather than escalating indiscriminately |
| 404 | Owner/repo/path or server-version mismatch | Check `/api/v1/version` and the server's Swagger document before changing the path |
| 422 | Valid route but invalid payload | Compare the complete JSON body against the live Swagger schema; dry-run first-class calls to inspect generated fields |
| Missing list results | Pagination | Add `--page`, `--limit`, and `--include-response`; follow the `link` header |
| Connection error | DNS, TLS, proxy, or server availability | Verify the exact `--server` URL with a harmless version request; do not expose tokens in diagnostic output |
Use `--verbose` only to inspect method and URL. It deliberately does not print tokens or request bodies. For mutation failures, retain the dry-run JSON plan and the status/message, but redact credentials before sharing either.
+43 -14
View File
@@ -13,7 +13,7 @@ try:
except ImportError: # pragma: no cover - surfaced only on actual requests
requests = None
DEFAULT_SERVER = "https://forgejo.example.com"
DEFAULT_SERVER = os.getenv("FORGEJO_SERVER", "")
MUTATING = {"POST", "PUT", "PATCH", "DELETE"}
@@ -28,7 +28,9 @@ def env(name):
path = Path.home() / ".hermes" / ".env"
try:
for line in path.read_text().splitlines():
line = line.strip().removeprefix("export ")
line = line.strip()
if line.startswith("export "):
line = line[len("export "):]
if line.startswith(name + "="):
return line.split("=", 1)[1].strip().strip("\"'")
except OSError:
@@ -47,7 +49,8 @@ class Client:
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, headers=None, raw=None, files=None, form=None):
def request(self, method, path, query=None, body=None, headers=None, raw=None, files=None, form=None,
include_response=False):
method = method.upper()
if not path.startswith("/api/v1/"):
raise ForgejoError("API path must begin with /api/v1/")
@@ -56,17 +59,23 @@ class Client:
"query": query or {}, "body": body, "headers": headers or {},
"raw": "<bytes>" if raw is not None else None,
"files": {k: getattr(v, "name", str(v)) for k, v in (files or {}).items()}, "form": form or {}}
if not self.server:
raise ForgejoError("No Forgejo server selected; pass --server URL or set FORGEJO_SERVER")
if self.verbose:
print(f"[verbose] {method} {self.server}{path}", file=sys.stderr)
if not self.token:
raise ForgejoError("No API token available; set FORGEJO_AGENT_TOKEN or FORGEJO_USER_TOKEN")
if not self.token and not any(key.lower() == "authorization" for key in (headers or {})):
raise ForgejoError("No API token available; set FORGEJO_AGENT_TOKEN or FORGEJO_USER_TOKEN, or provide Authorization")
if requests is None:
raise ForgejoError("requests is required for live API calls")
try:
request_headers = {"Authorization": "token " + self.token, **(headers or {})}
request_headers = {**(headers or {})}
if self.token:
request_headers.setdefault("Authorization", "token " + self.token)
kwargs = {"params": query, "headers": request_headers, "timeout": 30}
if files:
kwargs.update(files=files, data=form or {})
elif form:
kwargs["data"] = form
elif raw is not None:
kwargs["data"] = raw
elif body is not None:
@@ -82,11 +91,16 @@ class Client:
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}
result = {}
else:
try:
result = response.json()
except ValueError:
result = {"raw": response.text}
if include_response:
metadata = {key.lower(): response.headers[key] for key in ("Link", "X-Total-Count") if key in response.headers}
return {"data": result, "status": response.status_code, "headers": metadata}
return result
def global_flags(parser):
@@ -98,6 +112,9 @@ def global_flags(parser):
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")
parser.add_argument("--page", type=int, help="page number for list/search endpoints")
parser.add_argument("--limit", type=int, help="items per page for list/search endpoints")
parser.add_argument("--include-response", action="store_true", help="include status and pagination headers in output")
def scoped(parser, required_repo=True, index=False):
@@ -201,6 +218,9 @@ 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 "")
if args.group == "content":
return "/api/v1/repos/{}/{}/contents/{}".format(
seg(args.owner), seg(args.repo), quote(args.path, safe="/"))
values = {k: seg(v) for k,v in vars(args).items() if v is not None}
return args.path_template.format(**values)
@@ -208,13 +228,13 @@ def resolve_path(args):
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"}
"--dry-run", "-n", "--force", "--yes", "-y", "--include-response"}
front, rest, i = [], [], 0
while i < len(argv):
value = argv[i]
if value in booleans:
front.append(value)
elif value == "--server":
elif value in {"--server", "--page", "--limit"}:
if i + 1 >= len(argv):
rest.append(value)
else:
@@ -229,6 +249,8 @@ def normalize_global_flags(argv):
def main(argv=None):
parser = build_parser(); args = parser.parse_args(normalize_global_flags(argv or sys.argv[1:]))
if args.agent and args.user: parser.error("choose only one of --agent or --user")
if args.page is not None and args.page < 1: parser.error("--page must be at least 1")
if args.limit is not None and args.limit < 1: parser.error("--limit must be at least 1")
if args.group == "api":
supplied = sum(bool(value) for value in (args.data, args.data_file, args.raw_file, args.upload_file))
if supplied > 1: parser.error("choose one payload source: --data, --data-file, --raw-file, or --upload-file")
@@ -252,6 +274,8 @@ def main(argv=None):
if args.file and args.external_url: parser.error("choose --file or --external-url, not both")
if not args.file and not args.external_url: parser.error("upload requires --file or --external-url")
if args.external_url: body = None
if args.page is not None: query.setdefault("page", args.page)
if args.limit is not None: query.setdefault("limit", args.limit)
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")
headers, raw, files, form = {}, None, None, None
if args.group == "api":
@@ -266,8 +290,13 @@ def main(argv=None):
if getattr(args, "release_upload", False) and args.external_url:
form = {"external_url": args.external_url}
if args.name: query["name"] = args.name
try: result = Client(args).request(method, path, query, body, headers, raw, files, form)
try:
result = Client(args).request(method, path, query, body, headers, raw, files, form,
include_response=args.include_response)
except (ForgejoError, json.JSONDecodeError) as exc: parser.error(str(exc))
finally:
for file in (files or {}).values():
file.close()
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)
+48
View File
@@ -2,6 +2,7 @@ import importlib.machinery
import io
import json
import pathlib
from types import SimpleNamespace
import unittest
from contextlib import redirect_stderr, redirect_stdout
@@ -38,6 +39,16 @@ class CliTests(unittest.TestCase):
self.assertNotEqual(code, 0)
self.assertIn("mutation", err)
def test_live_request_requires_a_server(self):
old = cli.DEFAULT_SERVER
cli.DEFAULT_SERVER = ""
try:
code, _, err = self.run_cli(["user", "show"])
finally:
cli.DEFAULT_SERVER = old
self.assertNotEqual(code, 0)
self.assertIn("No Forgejo server selected", 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"}'])
@@ -67,6 +78,43 @@ class CliTests(unittest.TestCase):
def test_repo_creation_needs_no_owner(self):
self.assertEqual(self.plan(["repo", "create", "--name", "demo", "--private"])["path"], "/api/v1/user/repos")
def test_nested_content_path_and_pagination_plan(self):
plan = self.plan(["--page", "2", "--limit", "75", "content", "get", "--owner", "me", "--repo", "x", "--path", "dir/a b.txt"])
self.assertEqual(plan["path"], "/api/v1/repos/me/x/contents/dir/a%20b.txt")
self.assertEqual(plan["query"], {"page": 2, "limit": 75})
def test_form_transport_custom_authorization_and_response_metadata(self):
class Response:
status_code = 200
content = b'{"ok":true}'
text = '{"ok":true}'
headers = {"Link": '<https://forge.example/api/v1/user/repos?page=2>; rel="next"', "X-Total-Count": "51"}
def json(self):
return {"ok": True}
class Requests:
RequestException = Exception
call = None
@classmethod
def request(cls, *args, **kwargs):
cls.call = (args, kwargs)
return Response()
original = cli.requests
cli.requests = Requests
try:
args = SimpleNamespace(server="https://forge.example", dry_run=False, verbose=False, user=False)
result = cli.Client(args).request(
"POST", "/api/v1/repos/me/x/releases/1/assets", headers={"Authorization": "Basic test"},
form={"external_url": "https://example.invalid/file"}, include_response=True)
finally:
cli.requests = original
self.assertEqual(Requests.call[1]["data"], {"external_url": "https://example.invalid/file"})
self.assertEqual(result["data"], {"ok": True})
self.assertEqual(result["headers"]["x-total-count"], "51")
if __name__ == "__main__":
unittest.main()