mirror of
https://github.com/magnus919/agent-skills.git
synced 2026-09-11 19:47:12 +03:00
fix(forgejo-cli): harden API transport and guidance
This commit is contained in:
@@ -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.
|
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
|
## What You Get
|
||||||
|
|
||||||
| Contents | Purpose |
|
| Contents | Purpose |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `scripts/forgejo-cli` | Argparse Forgejo API v1 client |
|
| `scripts/forgejo-cli` | Argparse Forgejo API v1 client |
|
||||||
| `references/command-reference.md` | Endpoint and payload guide |
|
| `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 |
|
| `V2-SPEC.md` | v2 acceptance criteria |
|
||||||
| `tests/` | Offline stdlib contract tests |
|
| `tests/` | Offline stdlib contract tests |
|
||||||
|
|
||||||
@@ -22,6 +27,7 @@ Set `FORGEJO_AGENT_TOKEN` (default) or `FORGEJO_USER_TOKEN`, then run:
|
|||||||
```bash
|
```bash
|
||||||
python3 scripts/forgejo-cli --dry-run --json repo create --name demo --private
|
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 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
|
## Triggers
|
||||||
@@ -32,4 +38,4 @@ python3 scripts/forgejo-cli --server https://forge.example api --method GET --pa
|
|||||||
|
|
||||||
## Requirements
|
## 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.
|
||||||
|
|||||||
@@ -12,13 +12,14 @@ metadata:
|
|||||||
|
|
||||||
# Forgejo CLI v2
|
# 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
|
## Safety
|
||||||
|
|
||||||
- Mutations require `--force`/`--yes`/`-y`, or `--dry-run`.
|
- 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.
|
- `--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.
|
- `--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.
|
- `--help` does not read credentials or contact a server. Path segments are encoded.
|
||||||
|
|
||||||
## Common workflows
|
## 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).
|
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.
|
||||||
|
|||||||
@@ -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.
|
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.
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# Command reference
|
# 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 |
|
| 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`.
|
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
|
## 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]`
|
`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`.
|
||||||
@@ -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.
|
||||||
@@ -13,7 +13,7 @@ try:
|
|||||||
except ImportError: # pragma: no cover - surfaced only on actual requests
|
except ImportError: # pragma: no cover - surfaced only on actual requests
|
||||||
requests = None
|
requests = None
|
||||||
|
|
||||||
DEFAULT_SERVER = "https://forgejo.example.com"
|
DEFAULT_SERVER = os.getenv("FORGEJO_SERVER", "")
|
||||||
MUTATING = {"POST", "PUT", "PATCH", "DELETE"}
|
MUTATING = {"POST", "PUT", "PATCH", "DELETE"}
|
||||||
|
|
||||||
|
|
||||||
@@ -28,7 +28,9 @@ def env(name):
|
|||||||
path = Path.home() / ".hermes" / ".env"
|
path = Path.home() / ".hermes" / ".env"
|
||||||
try:
|
try:
|
||||||
for line in path.read_text().splitlines():
|
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 + "="):
|
if line.startswith(name + "="):
|
||||||
return line.split("=", 1)[1].strip().strip("\"'")
|
return line.split("=", 1)[1].strip().strip("\"'")
|
||||||
except OSError:
|
except OSError:
|
||||||
@@ -47,7 +49,8 @@ class Client:
|
|||||||
self.verbose = args.verbose
|
self.verbose = args.verbose
|
||||||
self.token = env("FORGEJO_USER_TOKEN" if args.user else "FORGEJO_AGENT_TOKEN")
|
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()
|
method = method.upper()
|
||||||
if not path.startswith("/api/v1/"):
|
if not path.startswith("/api/v1/"):
|
||||||
raise ForgejoError("API path must begin with /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 {},
|
"query": query or {}, "body": body, "headers": headers or {},
|
||||||
"raw": "<bytes>" if raw is not None else None,
|
"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 {}}
|
"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:
|
if self.verbose:
|
||||||
print(f"[verbose] {method} {self.server}{path}", file=sys.stderr)
|
print(f"[verbose] {method} {self.server}{path}", file=sys.stderr)
|
||||||
if not self.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")
|
raise ForgejoError("No API token available; set FORGEJO_AGENT_TOKEN or FORGEJO_USER_TOKEN, or provide Authorization")
|
||||||
if requests is None:
|
if requests is None:
|
||||||
raise ForgejoError("requests is required for live API calls")
|
raise ForgejoError("requests is required for live API calls")
|
||||||
try:
|
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}
|
kwargs = {"params": query, "headers": request_headers, "timeout": 30}
|
||||||
if files:
|
if files:
|
||||||
kwargs.update(files=files, data=form or {})
|
kwargs.update(files=files, data=form or {})
|
||||||
|
elif form:
|
||||||
|
kwargs["data"] = form
|
||||||
elif raw is not None:
|
elif raw is not None:
|
||||||
kwargs["data"] = raw
|
kwargs["data"] = raw
|
||||||
elif body is not None:
|
elif body is not None:
|
||||||
@@ -82,11 +91,16 @@ class Client:
|
|||||||
message = response.text
|
message = response.text
|
||||||
raise ForgejoError(f"{method} {path}: HTTP {response.status_code}: {message}")
|
raise ForgejoError(f"{method} {path}: HTTP {response.status_code}: {message}")
|
||||||
if response.status_code == 204 or not response.content:
|
if response.status_code == 204 or not response.content:
|
||||||
return {}
|
result = {}
|
||||||
try:
|
else:
|
||||||
return response.json()
|
try:
|
||||||
except ValueError:
|
result = response.json()
|
||||||
return {"raw": response.text}
|
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):
|
def global_flags(parser):
|
||||||
@@ -98,6 +112,9 @@ def global_flags(parser):
|
|||||||
parser.add_argument("--verbose", "-v", 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("--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("--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):
|
def scoped(parser, required_repo=True, index=False):
|
||||||
@@ -201,6 +218,9 @@ def resolve_path(args):
|
|||||||
if args.group == "hook":
|
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"
|
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 "")
|
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}
|
values = {k: seg(v) for k,v in vars(args).items() if v is not None}
|
||||||
return args.path_template.format(**values)
|
return args.path_template.format(**values)
|
||||||
|
|
||||||
@@ -208,13 +228,13 @@ def resolve_path(args):
|
|||||||
def normalize_global_flags(argv):
|
def normalize_global_flags(argv):
|
||||||
"""Permit documented global flags at any command nesting level."""
|
"""Permit documented global flags at any command nesting level."""
|
||||||
booleans = {"--agent", "--user", "--json", "--quiet", "-q", "--verbose", "-v",
|
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
|
front, rest, i = [], [], 0
|
||||||
while i < len(argv):
|
while i < len(argv):
|
||||||
value = argv[i]
|
value = argv[i]
|
||||||
if value in booleans:
|
if value in booleans:
|
||||||
front.append(value)
|
front.append(value)
|
||||||
elif value == "--server":
|
elif value in {"--server", "--page", "--limit"}:
|
||||||
if i + 1 >= len(argv):
|
if i + 1 >= len(argv):
|
||||||
rest.append(value)
|
rest.append(value)
|
||||||
else:
|
else:
|
||||||
@@ -229,6 +249,8 @@ def normalize_global_flags(argv):
|
|||||||
def main(argv=None):
|
def main(argv=None):
|
||||||
parser = build_parser(); args = parser.parse_args(normalize_global_flags(argv or sys.argv[1:]))
|
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.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":
|
if args.group == "api":
|
||||||
supplied = sum(bool(value) for value in (args.data, args.data_file, args.raw_file, args.upload_file))
|
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")
|
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 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 not args.file and not args.external_url: parser.error("upload requires --file or --external-url")
|
||||||
if args.external_url: body = None
|
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")
|
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
|
headers, raw, files, form = {}, None, None, None
|
||||||
if args.group == "api":
|
if args.group == "api":
|
||||||
@@ -266,8 +290,13 @@ def main(argv=None):
|
|||||||
if getattr(args, "release_upload", False) and args.external_url:
|
if getattr(args, "release_upload", False) and args.external_url:
|
||||||
form = {"external_url": args.external_url}
|
form = {"external_url": args.external_url}
|
||||||
if args.name: query["name"] = args.name
|
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))
|
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))
|
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)
|
elif not args.quiet: print(json.dumps(result, indent=2) if isinstance(result, (dict,list)) else result)
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import importlib.machinery
|
|||||||
import io
|
import io
|
||||||
import json
|
import json
|
||||||
import pathlib
|
import pathlib
|
||||||
|
from types import SimpleNamespace
|
||||||
import unittest
|
import unittest
|
||||||
from contextlib import redirect_stderr, redirect_stdout
|
from contextlib import redirect_stderr, redirect_stdout
|
||||||
|
|
||||||
@@ -38,6 +39,16 @@ class CliTests(unittest.TestCase):
|
|||||||
self.assertNotEqual(code, 0)
|
self.assertNotEqual(code, 0)
|
||||||
self.assertIn("mutation", err)
|
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):
|
def test_api_path_guard_and_plan(self):
|
||||||
self.assertNotEqual(self.run_cli(["api", "--method", "GET", "--path", "/bad"])[0], 0)
|
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"}'])
|
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):
|
def test_repo_creation_needs_no_owner(self):
|
||||||
self.assertEqual(self.plan(["repo", "create", "--name", "demo", "--private"])["path"], "/api/v1/user/repos")
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
Reference in New Issue
Block a user