fj/cmd/api.go

391 lines
12 KiB
Go
Raw Normal View History

package cmd
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
fix(cmd): correctness + audit hardening across cmd/ + internal/ Addresses audit findings from a tri-partite review (codex + 2 Claude agents). Multiple distinct fixes here because they touched overlapping files; happy to split via interactive rebase if a reviewer prefers. ## Correctness bugs (HIGH) * `--config` is now actually honored. cmd/root.initConfig fed Viper but every command that mattered loaded config via `internal/config.Load()` which always read the default path. Added `config.SetExplicitConfigPath` consulted by `GetConfigPath`; `--config other.yaml auth login` now writes to other.yaml. - internal/config/config.go, cmd/root.go * `--json` now works on `fj run …`, `fj workflow …`, and `fj wiki view`. cmd/aliases.go registered `--json` as a Bool but the handlers call `wantJSON()` which does `GetString("json")` and silently ignores the type-error return. cmd/wiki.go did the inverse (`GetBool("json")` against a string-registered flag). Both now use `addJSONFlags`/`wantJSON`/ `outputJSON` consistently. - cmd/aliases.go, cmd/wiki.go * `fj api` no longer lets endpoints escape the /api/v1 base via path-traversal. `fj api '/../admin/users'` previously normalized to `/admin/users` because `http.NewRequest` resolves `..` segments — silently sending authenticated traffic to non-API routes. Endpoint is now parsed, `..` segments are rejected, and JoinPath is used. - cmd/api.go ## Design rework (BREAKING — gets rid of the `--json=fields` quirk) * `--json` flag rebuilt from a string-with-NoOptDefVal=" " sentinel into a plain Bool. `--json-fields` keeps comma-separated projection. The two are mutually exclusive (`MarkFlagsMutuallyExclusive`). `--jq` composes with either or neither. The previous design produced a `--json string[=" "]` in --help and required `--json=fields` (with literal "=") because `--json fields` was parsed as the bare flag plus a positional. Gone. - cmd/json.go: addJSONFlags / wantJSON / outputJSON - cmd/api.go: example block reflects the new shape Migration: `--json=fields` → `--json-fields fields`. Bare `--json` still means "everything as JSON". * `fj api` now uses `internal/api.SharedHTTPClient` (30 s timeout, pooled) instead of constructing a zero-value `&http.Client{}` with no timeout. A hung Forgejo no longer pins the CLI indefinitely. Response body is also bounded by `io.LimitReader` at 64 MB to prevent OOM-on-self. - internal/api/client.go (export SharedHTTPClient), cmd/api.go * `--hostname` declared as a persistent flag on rootCmd is now the only declaration. cmd/auth.go re-declared `--hostname` on three subcommands, shadowing the persistent flag — meaning `fj --hostname=X auth login` and `fj auth login --hostname=X` went through different code paths (viper read vs. local flag read). Local declarations removed. - cmd/auth.go ## Hardening (MEDIUM/LOW) * `--token` on `auth login` now emits a stderr warning when used, since it puts the PAT on argv (visible in `ps auxe`/shell history). Flag not removed — too disruptive — but discoverable now. - cmd/auth.go * Error handling no longer regex-matches "401"/"403" against rendered error strings (would have triggered "auth login" hint for an error that just mentioned issue #403). Now relies on typed `*api.APIError`. Hints moved to a separate `Hint` field on `CLIError`, so JSON-error consumers get clean structure and the human renderer still appends "\nHint: …". - cmd/errors.go * `migrateConfigDir` now opens dst with `O_TRUNC` instead of just `O_CREATE|O_WRONLY`. Previously a partially-pre-existing dst file would have legacy contents overwrite a prefix and leave stale tail bytes — silent YAML/token corruption. - cmd/root.go (extracted into copyOneConfigFile with proper close handling) * Config dir created with mode 0700 instead of 0755. `initConfig` warns on stderr if the resolved config file is world/group readable (`mode & 0o077 != 0`); doesn't fail-close. - cmd/root.go * Network errors (`no such host`, `connection refused`, `i/o timeout`) now return a structured `CLIError` with code `ErrNetworkError` and a hint, instead of a fmt.Errorf chain. - cmd/errors.go Verified: `go build ./...` and `go test ./...` clean. Live integration tested against forgejo.zerova.net. Out of scope, deferred to follow-up commits: - Pagination unification across `repo list`/`pr list`/`issue list` (only `release list` walks pages today; others silently truncate). - `fj api --paginate` to follow pages like `gh api --paginate`. - De-duplicating cmd/aliases.go ↔ cmd/actions.go subtrees.
2026-05-02 15:41:48 -06:00
"net/url"
"os"
"strconv"
"strings"
fix(cmd): correctness + audit hardening across cmd/ + internal/ Addresses audit findings from a tri-partite review (codex + 2 Claude agents). Multiple distinct fixes here because they touched overlapping files; happy to split via interactive rebase if a reviewer prefers. ## Correctness bugs (HIGH) * `--config` is now actually honored. cmd/root.initConfig fed Viper but every command that mattered loaded config via `internal/config.Load()` which always read the default path. Added `config.SetExplicitConfigPath` consulted by `GetConfigPath`; `--config other.yaml auth login` now writes to other.yaml. - internal/config/config.go, cmd/root.go * `--json` now works on `fj run …`, `fj workflow …`, and `fj wiki view`. cmd/aliases.go registered `--json` as a Bool but the handlers call `wantJSON()` which does `GetString("json")` and silently ignores the type-error return. cmd/wiki.go did the inverse (`GetBool("json")` against a string-registered flag). Both now use `addJSONFlags`/`wantJSON`/ `outputJSON` consistently. - cmd/aliases.go, cmd/wiki.go * `fj api` no longer lets endpoints escape the /api/v1 base via path-traversal. `fj api '/../admin/users'` previously normalized to `/admin/users` because `http.NewRequest` resolves `..` segments — silently sending authenticated traffic to non-API routes. Endpoint is now parsed, `..` segments are rejected, and JoinPath is used. - cmd/api.go ## Design rework (BREAKING — gets rid of the `--json=fields` quirk) * `--json` flag rebuilt from a string-with-NoOptDefVal=" " sentinel into a plain Bool. `--json-fields` keeps comma-separated projection. The two are mutually exclusive (`MarkFlagsMutuallyExclusive`). `--jq` composes with either or neither. The previous design produced a `--json string[=" "]` in --help and required `--json=fields` (with literal "=") because `--json fields` was parsed as the bare flag plus a positional. Gone. - cmd/json.go: addJSONFlags / wantJSON / outputJSON - cmd/api.go: example block reflects the new shape Migration: `--json=fields` → `--json-fields fields`. Bare `--json` still means "everything as JSON". * `fj api` now uses `internal/api.SharedHTTPClient` (30 s timeout, pooled) instead of constructing a zero-value `&http.Client{}` with no timeout. A hung Forgejo no longer pins the CLI indefinitely. Response body is also bounded by `io.LimitReader` at 64 MB to prevent OOM-on-self. - internal/api/client.go (export SharedHTTPClient), cmd/api.go * `--hostname` declared as a persistent flag on rootCmd is now the only declaration. cmd/auth.go re-declared `--hostname` on three subcommands, shadowing the persistent flag — meaning `fj --hostname=X auth login` and `fj auth login --hostname=X` went through different code paths (viper read vs. local flag read). Local declarations removed. - cmd/auth.go ## Hardening (MEDIUM/LOW) * `--token` on `auth login` now emits a stderr warning when used, since it puts the PAT on argv (visible in `ps auxe`/shell history). Flag not removed — too disruptive — but discoverable now. - cmd/auth.go * Error handling no longer regex-matches "401"/"403" against rendered error strings (would have triggered "auth login" hint for an error that just mentioned issue #403). Now relies on typed `*api.APIError`. Hints moved to a separate `Hint` field on `CLIError`, so JSON-error consumers get clean structure and the human renderer still appends "\nHint: …". - cmd/errors.go * `migrateConfigDir` now opens dst with `O_TRUNC` instead of just `O_CREATE|O_WRONLY`. Previously a partially-pre-existing dst file would have legacy contents overwrite a prefix and leave stale tail bytes — silent YAML/token corruption. - cmd/root.go (extracted into copyOneConfigFile with proper close handling) * Config dir created with mode 0700 instead of 0755. `initConfig` warns on stderr if the resolved config file is world/group readable (`mode & 0o077 != 0`); doesn't fail-close. - cmd/root.go * Network errors (`no such host`, `connection refused`, `i/o timeout`) now return a structured `CLIError` with code `ErrNetworkError` and a hint, instead of a fmt.Errorf chain. - cmd/errors.go Verified: `go build ./...` and `go test ./...` clean. Live integration tested against forgejo.zerova.net. Out of scope, deferred to follow-up commits: - Pagination unification across `repo list`/`pr list`/`issue list` (only `release list` walks pages today; others silently truncate). - `fj api --paginate` to follow pages like `gh api --paginate`. - De-duplicating cmd/aliases.go ↔ cmd/actions.go subtrees.
2026-05-02 15:41:48 -06:00
"forgejo.zerova.net/public/fj/internal/api"
"forgejo.zerova.net/public/fj/internal/config"
"forgejo.zerova.net/public/fj/internal/git"
"github.com/spf13/cobra"
)
fix(cmd): correctness + audit hardening across cmd/ + internal/ Addresses audit findings from a tri-partite review (codex + 2 Claude agents). Multiple distinct fixes here because they touched overlapping files; happy to split via interactive rebase if a reviewer prefers. ## Correctness bugs (HIGH) * `--config` is now actually honored. cmd/root.initConfig fed Viper but every command that mattered loaded config via `internal/config.Load()` which always read the default path. Added `config.SetExplicitConfigPath` consulted by `GetConfigPath`; `--config other.yaml auth login` now writes to other.yaml. - internal/config/config.go, cmd/root.go * `--json` now works on `fj run …`, `fj workflow …`, and `fj wiki view`. cmd/aliases.go registered `--json` as a Bool but the handlers call `wantJSON()` which does `GetString("json")` and silently ignores the type-error return. cmd/wiki.go did the inverse (`GetBool("json")` against a string-registered flag). Both now use `addJSONFlags`/`wantJSON`/ `outputJSON` consistently. - cmd/aliases.go, cmd/wiki.go * `fj api` no longer lets endpoints escape the /api/v1 base via path-traversal. `fj api '/../admin/users'` previously normalized to `/admin/users` because `http.NewRequest` resolves `..` segments — silently sending authenticated traffic to non-API routes. Endpoint is now parsed, `..` segments are rejected, and JoinPath is used. - cmd/api.go ## Design rework (BREAKING — gets rid of the `--json=fields` quirk) * `--json` flag rebuilt from a string-with-NoOptDefVal=" " sentinel into a plain Bool. `--json-fields` keeps comma-separated projection. The two are mutually exclusive (`MarkFlagsMutuallyExclusive`). `--jq` composes with either or neither. The previous design produced a `--json string[=" "]` in --help and required `--json=fields` (with literal "=") because `--json fields` was parsed as the bare flag plus a positional. Gone. - cmd/json.go: addJSONFlags / wantJSON / outputJSON - cmd/api.go: example block reflects the new shape Migration: `--json=fields` → `--json-fields fields`. Bare `--json` still means "everything as JSON". * `fj api` now uses `internal/api.SharedHTTPClient` (30 s timeout, pooled) instead of constructing a zero-value `&http.Client{}` with no timeout. A hung Forgejo no longer pins the CLI indefinitely. Response body is also bounded by `io.LimitReader` at 64 MB to prevent OOM-on-self. - internal/api/client.go (export SharedHTTPClient), cmd/api.go * `--hostname` declared as a persistent flag on rootCmd is now the only declaration. cmd/auth.go re-declared `--hostname` on three subcommands, shadowing the persistent flag — meaning `fj --hostname=X auth login` and `fj auth login --hostname=X` went through different code paths (viper read vs. local flag read). Local declarations removed. - cmd/auth.go ## Hardening (MEDIUM/LOW) * `--token` on `auth login` now emits a stderr warning when used, since it puts the PAT on argv (visible in `ps auxe`/shell history). Flag not removed — too disruptive — but discoverable now. - cmd/auth.go * Error handling no longer regex-matches "401"/"403" against rendered error strings (would have triggered "auth login" hint for an error that just mentioned issue #403). Now relies on typed `*api.APIError`. Hints moved to a separate `Hint` field on `CLIError`, so JSON-error consumers get clean structure and the human renderer still appends "\nHint: …". - cmd/errors.go * `migrateConfigDir` now opens dst with `O_TRUNC` instead of just `O_CREATE|O_WRONLY`. Previously a partially-pre-existing dst file would have legacy contents overwrite a prefix and leave stale tail bytes — silent YAML/token corruption. - cmd/root.go (extracted into copyOneConfigFile with proper close handling) * Config dir created with mode 0700 instead of 0755. `initConfig` warns on stderr if the resolved config file is world/group readable (`mode & 0o077 != 0`); doesn't fail-close. - cmd/root.go * Network errors (`no such host`, `connection refused`, `i/o timeout`) now return a structured `CLIError` with code `ErrNetworkError` and a hint, instead of a fmt.Errorf chain. - cmd/errors.go Verified: `go build ./...` and `go test ./...` clean. Live integration tested against forgejo.zerova.net. Out of scope, deferred to follow-up commits: - Pagination unification across `repo list`/`pr list`/`issue list` (only `release list` walks pages today; others silently truncate). - `fj api --paginate` to follow pages like `gh api --paginate`. - De-duplicating cmd/aliases.go ↔ cmd/actions.go subtrees.
2026-05-02 15:41:48 -06:00
// maxAPIResponseBytes caps response bodies for `fj api`. Forgejo responses
// are normally <1 MB; 64 MB is enough for any sane payload while preventing
// a runaway body from OOMing the CLI when combined with the 30 s client
// timeout.
const maxAPIResponseBytes = 64 << 20
var apiCmd = &cobra.Command{
Use: "api <endpoint> [flags]",
Short: "Make an authenticated API request",
Long: `Makes an authenticated HTTP request to the Forgejo API and prints the response.
The endpoint argument should be a path like "/repos/{owner}/{repo}/pulls".
Placeholders {owner} and {repo} are automatically replaced with values
detected from the current git repository.
If --field is used and no --method is specified, the method defaults to POST.`,
Example: ` # List pull requests for the current repository
fj api /repos/{owner}/{repo}/pulls
# Create an issue
fj api /repos/{owner}/{repo}/issues --method POST --field title=Bug --field body="It broke"
# Get a specific user
fj api /users/johndoe
# Use raw body from stdin
echo '{"title":"test"}' | fj api /repos/{owner}/{repo}/issues --input -
# Filter the response with a jq expression
fj api /repos/{owner}/{repo}/issues --jq '.[].title'
fix(cmd): correctness + audit hardening across cmd/ + internal/ Addresses audit findings from a tri-partite review (codex + 2 Claude agents). Multiple distinct fixes here because they touched overlapping files; happy to split via interactive rebase if a reviewer prefers. ## Correctness bugs (HIGH) * `--config` is now actually honored. cmd/root.initConfig fed Viper but every command that mattered loaded config via `internal/config.Load()` which always read the default path. Added `config.SetExplicitConfigPath` consulted by `GetConfigPath`; `--config other.yaml auth login` now writes to other.yaml. - internal/config/config.go, cmd/root.go * `--json` now works on `fj run …`, `fj workflow …`, and `fj wiki view`. cmd/aliases.go registered `--json` as a Bool but the handlers call `wantJSON()` which does `GetString("json")` and silently ignores the type-error return. cmd/wiki.go did the inverse (`GetBool("json")` against a string-registered flag). Both now use `addJSONFlags`/`wantJSON`/ `outputJSON` consistently. - cmd/aliases.go, cmd/wiki.go * `fj api` no longer lets endpoints escape the /api/v1 base via path-traversal. `fj api '/../admin/users'` previously normalized to `/admin/users` because `http.NewRequest` resolves `..` segments — silently sending authenticated traffic to non-API routes. Endpoint is now parsed, `..` segments are rejected, and JoinPath is used. - cmd/api.go ## Design rework (BREAKING — gets rid of the `--json=fields` quirk) * `--json` flag rebuilt from a string-with-NoOptDefVal=" " sentinel into a plain Bool. `--json-fields` keeps comma-separated projection. The two are mutually exclusive (`MarkFlagsMutuallyExclusive`). `--jq` composes with either or neither. The previous design produced a `--json string[=" "]` in --help and required `--json=fields` (with literal "=") because `--json fields` was parsed as the bare flag plus a positional. Gone. - cmd/json.go: addJSONFlags / wantJSON / outputJSON - cmd/api.go: example block reflects the new shape Migration: `--json=fields` → `--json-fields fields`. Bare `--json` still means "everything as JSON". * `fj api` now uses `internal/api.SharedHTTPClient` (30 s timeout, pooled) instead of constructing a zero-value `&http.Client{}` with no timeout. A hung Forgejo no longer pins the CLI indefinitely. Response body is also bounded by `io.LimitReader` at 64 MB to prevent OOM-on-self. - internal/api/client.go (export SharedHTTPClient), cmd/api.go * `--hostname` declared as a persistent flag on rootCmd is now the only declaration. cmd/auth.go re-declared `--hostname` on three subcommands, shadowing the persistent flag — meaning `fj --hostname=X auth login` and `fj auth login --hostname=X` went through different code paths (viper read vs. local flag read). Local declarations removed. - cmd/auth.go ## Hardening (MEDIUM/LOW) * `--token` on `auth login` now emits a stderr warning when used, since it puts the PAT on argv (visible in `ps auxe`/shell history). Flag not removed — too disruptive — but discoverable now. - cmd/auth.go * Error handling no longer regex-matches "401"/"403" against rendered error strings (would have triggered "auth login" hint for an error that just mentioned issue #403). Now relies on typed `*api.APIError`. Hints moved to a separate `Hint` field on `CLIError`, so JSON-error consumers get clean structure and the human renderer still appends "\nHint: …". - cmd/errors.go * `migrateConfigDir` now opens dst with `O_TRUNC` instead of just `O_CREATE|O_WRONLY`. Previously a partially-pre-existing dst file would have legacy contents overwrite a prefix and leave stale tail bytes — silent YAML/token corruption. - cmd/root.go (extracted into copyOneConfigFile with proper close handling) * Config dir created with mode 0700 instead of 0755. `initConfig` warns on stderr if the resolved config file is world/group readable (`mode & 0o077 != 0`); doesn't fail-close. - cmd/root.go * Network errors (`no such host`, `connection refused`, `i/o timeout`) now return a structured `CLIError` with code `ErrNetworkError` and a hint, instead of a fmt.Errorf chain. - cmd/errors.go Verified: `go build ./...` and `go test ./...` clean. Live integration tested against forgejo.zerova.net. Out of scope, deferred to follow-up commits: - Pagination unification across `repo list`/`pr list`/`issue list` (only `release list` walks pages today; others silently truncate). - `fj api --paginate` to follow pages like `gh api --paginate`. - De-duplicating cmd/aliases.go ↔ cmd/actions.go subtrees.
2026-05-02 15:41:48 -06:00
# Project the response down to specific fields
fj api /repos/{owner}/{repo} --json-fields full_name,description,private`,
Args: cobra.ExactArgs(1),
RunE: runAPI,
}
func init() {
rootCmd.AddCommand(apiCmd)
apiCmd.Flags().StringP("method", "X", "", "HTTP method (default: GET, or POST if --field is used)")
apiCmd.Flags().StringArrayP("field", "f", nil, "Add a typed field to the request body (key=value)")
apiCmd.Flags().StringArrayP("raw-field", "F", nil, "Add a string field to the request body (key=value)")
apiCmd.Flags().String("input", "", "Read request body from file (use \"-\" for stdin)")
apiCmd.Flags().StringArrayP("header", "H", nil, "Add an HTTP request header (key:value)")
apiCmd.Flags().Bool("silent", false, "Do not print the response body")
apiCmd.Flags().BoolP("include", "i", false, "Include HTTP response headers in the output")
feat(cmd): pagination unification + `fj api --paginate` Before this, only `release list` walked pages. `repo list`, `pr list` (the non-filter branch), and `issue list` all passed `PageSize: limit` directly to the gitea SDK — which silently caps PageSize at 50, so any request for more than 50 results was truncated to 50 with no warning. `--limit` was effectively a per-page hint, not a real limit. ## Changes - New `cmd/paginate.go` — generic `paginateGitea[T any]` that walks pages until the response is short or the limit is reached. Uses Go 1.20 generics so each list command keeps its existing typed slice without conversion overhead. - `repo list` — paginates ListUserRepos. - `pr list` — paginates ListRepoPullRequests in both branches: - With client-side filters (assignee, author, labels, search, draft, head, base): pull all pages then filter+limit. - Without filters: paginate up to limit. - `issue list` — paginates ListRepoIssues. Overshoots 2x because the API returns both issues AND PRs and we filter PRs out client-side; the overshoot keeps us bounded but reduces the chance of returning fewer results than `--limit`. ## `fj api --paginate` Mirrors `gh api --paginate`: - Follows RFC 5988 `Link: rel="next"` headers (Forgejo emits these on list endpoints). - Concatenates each page's JSON array into a single array via `concatPaginatedJSON`. If a page is not a JSON array, errors with a clear message — `--paginate` only makes sense for paginatable endpoints. - GET-only (errors on POST/PUT/DELETE). - Reuses the same auth and custom headers across pages; the body-size limit applies per-page. Refactored the request execution into a `doOnce` closure so the loop body isn't a copy of the single-request path. Verified live: $ fj api 'repos/public/claude-code-proxy/commits?limit=2' \ --paginate --jq '. | length' 44 (44 = total commits in the repo, walked via Link headers from a 2-per-page starting query.) Out of scope for this commit, deferred: - De-duplicating cmd/aliases.go ↔ cmd/actions.go subtrees (the type mismatch they caused is already fixed in the prior commit; the duplication itself is polish).
2026-05-02 15:46:22 -06:00
apiCmd.Flags().Bool("paginate", false, "Follow rel=\"next\" Link headers and concatenate JSON array pages (gh-compatible)")
addJSONFlags(apiCmd, "Output the response as JSON")
}
// parseLinkHeaderNext extracts the URL with rel="next" from an RFC 5988
// Link header. Returns "" if not present.
func parseLinkHeaderNext(link string) string {
for _, segment := range strings.Split(link, ",") {
segment = strings.TrimSpace(segment)
if !strings.Contains(segment, `rel="next"`) {
continue
}
start := strings.Index(segment, "<")
end := strings.Index(segment, ">")
if start >= 0 && end > start {
return segment[start+1 : end]
}
}
return ""
}
// concatPaginatedJSON parses each body as a JSON array and merges them.
// Errors if any body isn't an array (e.g. an object response means the
// endpoint isn't paginated and --paginate doesn't apply).
func concatPaginatedJSON(bodies [][]byte) ([]byte, error) {
merged := make([]json.RawMessage, 0)
for i, b := range bodies {
var page []json.RawMessage
if err := json.Unmarshal(b, &page); err != nil {
return nil, fmt.Errorf("--paginate requires JSON array responses; page %d wasn't an array: %w", i+1, err)
}
merged = append(merged, page...)
}
return json.Marshal(merged)
}
func runAPI(cmd *cobra.Command, args []string) error {
endpoint := args[0]
method, _ := cmd.Flags().GetString("method")
fields, _ := cmd.Flags().GetStringArray("field")
rawFields, _ := cmd.Flags().GetStringArray("raw-field")
inputFile, _ := cmd.Flags().GetString("input")
headers, _ := cmd.Flags().GetStringArray("header")
hostname, _ := cmd.Flags().GetString("hostname")
silent, _ := cmd.Flags().GetBool("silent")
include, _ := cmd.Flags().GetBool("include")
// Resolve hostname and token from config
cfg, err := config.Load()
if err != nil {
return err
}
detectedHost := getDetectedHost()
host, err := cfg.GetHost(hostname, detectedHost, getCwd())
if err != nil {
return err
}
// Interpolate {owner} and {repo} placeholders
if strings.Contains(endpoint, "{owner}") || strings.Contains(endpoint, "{repo}") {
owner, repo, detectErr := git.DetectRepo()
if detectErr != nil {
return fmt.Errorf("cannot determine repository for path interpolation: %w", detectErr)
}
endpoint = strings.ReplaceAll(endpoint, "{owner}", owner)
endpoint = strings.ReplaceAll(endpoint, "{repo}", repo)
}
// Determine HTTP method
hasBody := len(fields) > 0 || len(rawFields) > 0 || inputFile != ""
if method == "" {
if hasBody {
method = http.MethodPost
} else {
method = http.MethodGet
}
}
method = strings.ToUpper(method)
// Build request body
var body io.Reader
if inputFile != "" {
if len(fields) > 0 || len(rawFields) > 0 {
return fmt.Errorf("--input cannot be combined with --field or --raw-field")
}
if inputFile == "-" {
body = os.Stdin
} else {
f, openErr := os.Open(inputFile)
if openErr != nil {
return fmt.Errorf("failed to open input file: %w", openErr)
}
defer func() { _ = f.Close() }()
body = f
}
} else if len(fields) > 0 || len(rawFields) > 0 {
bodyMap := make(map[string]any)
for _, f := range fields {
key, value, parseErr := parseField(f, false)
if parseErr != nil {
return parseErr
}
bodyMap[key] = value
}
for _, f := range rawFields {
key, value, parseErr := parseField(f, true)
if parseErr != nil {
return parseErr
}
bodyMap[key] = value
}
bodyBytes, marshalErr := json.Marshal(bodyMap)
if marshalErr != nil {
return fmt.Errorf("failed to marshal request body: %w", marshalErr)
}
body = bytes.NewReader(bodyBytes)
}
fix(cmd): correctness + audit hardening across cmd/ + internal/ Addresses audit findings from a tri-partite review (codex + 2 Claude agents). Multiple distinct fixes here because they touched overlapping files; happy to split via interactive rebase if a reviewer prefers. ## Correctness bugs (HIGH) * `--config` is now actually honored. cmd/root.initConfig fed Viper but every command that mattered loaded config via `internal/config.Load()` which always read the default path. Added `config.SetExplicitConfigPath` consulted by `GetConfigPath`; `--config other.yaml auth login` now writes to other.yaml. - internal/config/config.go, cmd/root.go * `--json` now works on `fj run …`, `fj workflow …`, and `fj wiki view`. cmd/aliases.go registered `--json` as a Bool but the handlers call `wantJSON()` which does `GetString("json")` and silently ignores the type-error return. cmd/wiki.go did the inverse (`GetBool("json")` against a string-registered flag). Both now use `addJSONFlags`/`wantJSON`/ `outputJSON` consistently. - cmd/aliases.go, cmd/wiki.go * `fj api` no longer lets endpoints escape the /api/v1 base via path-traversal. `fj api '/../admin/users'` previously normalized to `/admin/users` because `http.NewRequest` resolves `..` segments — silently sending authenticated traffic to non-API routes. Endpoint is now parsed, `..` segments are rejected, and JoinPath is used. - cmd/api.go ## Design rework (BREAKING — gets rid of the `--json=fields` quirk) * `--json` flag rebuilt from a string-with-NoOptDefVal=" " sentinel into a plain Bool. `--json-fields` keeps comma-separated projection. The two are mutually exclusive (`MarkFlagsMutuallyExclusive`). `--jq` composes with either or neither. The previous design produced a `--json string[=" "]` in --help and required `--json=fields` (with literal "=") because `--json fields` was parsed as the bare flag plus a positional. Gone. - cmd/json.go: addJSONFlags / wantJSON / outputJSON - cmd/api.go: example block reflects the new shape Migration: `--json=fields` → `--json-fields fields`. Bare `--json` still means "everything as JSON". * `fj api` now uses `internal/api.SharedHTTPClient` (30 s timeout, pooled) instead of constructing a zero-value `&http.Client{}` with no timeout. A hung Forgejo no longer pins the CLI indefinitely. Response body is also bounded by `io.LimitReader` at 64 MB to prevent OOM-on-self. - internal/api/client.go (export SharedHTTPClient), cmd/api.go * `--hostname` declared as a persistent flag on rootCmd is now the only declaration. cmd/auth.go re-declared `--hostname` on three subcommands, shadowing the persistent flag — meaning `fj --hostname=X auth login` and `fj auth login --hostname=X` went through different code paths (viper read vs. local flag read). Local declarations removed. - cmd/auth.go ## Hardening (MEDIUM/LOW) * `--token` on `auth login` now emits a stderr warning when used, since it puts the PAT on argv (visible in `ps auxe`/shell history). Flag not removed — too disruptive — but discoverable now. - cmd/auth.go * Error handling no longer regex-matches "401"/"403" against rendered error strings (would have triggered "auth login" hint for an error that just mentioned issue #403). Now relies on typed `*api.APIError`. Hints moved to a separate `Hint` field on `CLIError`, so JSON-error consumers get clean structure and the human renderer still appends "\nHint: …". - cmd/errors.go * `migrateConfigDir` now opens dst with `O_TRUNC` instead of just `O_CREATE|O_WRONLY`. Previously a partially-pre-existing dst file would have legacy contents overwrite a prefix and leave stale tail bytes — silent YAML/token corruption. - cmd/root.go (extracted into copyOneConfigFile with proper close handling) * Config dir created with mode 0700 instead of 0755. `initConfig` warns on stderr if the resolved config file is world/group readable (`mode & 0o077 != 0`); doesn't fail-close. - cmd/root.go * Network errors (`no such host`, `connection refused`, `i/o timeout`) now return a structured `CLIError` with code `ErrNetworkError` and a hint, instead of a fmt.Errorf chain. - cmd/errors.go Verified: `go build ./...` and `go test ./...` clean. Live integration tested against forgejo.zerova.net. Out of scope, deferred to follow-up commits: - Pagination unification across `repo list`/`pr list`/`issue list` (only `release list` walks pages today; others silently truncate). - `fj api --paginate` to follow pages like `gh api --paginate`. - De-duplicating cmd/aliases.go ↔ cmd/actions.go subtrees.
2026-05-02 15:41:48 -06:00
// Build the request URL safely. Naive concatenation lets endpoints like
// "/../admin/users" escape the /api/v1 base via Go's URL normalization
// of `..` segments — silently sending authenticated traffic to non-API
// paths. Parse the endpoint, reject `..`, then JoinPath onto the base.
endpointURL, err := url.Parse(endpoint)
if err != nil {
return fmt.Errorf("invalid endpoint %q: %w", endpoint, err)
}
if endpointURL.Scheme != "" || endpointURL.Host != "" {
return fmt.Errorf("endpoint must be a path, not a full URL: %s", endpoint)
}
fix(cmd): correctness + audit hardening across cmd/ + internal/ Addresses audit findings from a tri-partite review (codex + 2 Claude agents). Multiple distinct fixes here because they touched overlapping files; happy to split via interactive rebase if a reviewer prefers. ## Correctness bugs (HIGH) * `--config` is now actually honored. cmd/root.initConfig fed Viper but every command that mattered loaded config via `internal/config.Load()` which always read the default path. Added `config.SetExplicitConfigPath` consulted by `GetConfigPath`; `--config other.yaml auth login` now writes to other.yaml. - internal/config/config.go, cmd/root.go * `--json` now works on `fj run …`, `fj workflow …`, and `fj wiki view`. cmd/aliases.go registered `--json` as a Bool but the handlers call `wantJSON()` which does `GetString("json")` and silently ignores the type-error return. cmd/wiki.go did the inverse (`GetBool("json")` against a string-registered flag). Both now use `addJSONFlags`/`wantJSON`/ `outputJSON` consistently. - cmd/aliases.go, cmd/wiki.go * `fj api` no longer lets endpoints escape the /api/v1 base via path-traversal. `fj api '/../admin/users'` previously normalized to `/admin/users` because `http.NewRequest` resolves `..` segments — silently sending authenticated traffic to non-API routes. Endpoint is now parsed, `..` segments are rejected, and JoinPath is used. - cmd/api.go ## Design rework (BREAKING — gets rid of the `--json=fields` quirk) * `--json` flag rebuilt from a string-with-NoOptDefVal=" " sentinel into a plain Bool. `--json-fields` keeps comma-separated projection. The two are mutually exclusive (`MarkFlagsMutuallyExclusive`). `--jq` composes with either or neither. The previous design produced a `--json string[=" "]` in --help and required `--json=fields` (with literal "=") because `--json fields` was parsed as the bare flag plus a positional. Gone. - cmd/json.go: addJSONFlags / wantJSON / outputJSON - cmd/api.go: example block reflects the new shape Migration: `--json=fields` → `--json-fields fields`. Bare `--json` still means "everything as JSON". * `fj api` now uses `internal/api.SharedHTTPClient` (30 s timeout, pooled) instead of constructing a zero-value `&http.Client{}` with no timeout. A hung Forgejo no longer pins the CLI indefinitely. Response body is also bounded by `io.LimitReader` at 64 MB to prevent OOM-on-self. - internal/api/client.go (export SharedHTTPClient), cmd/api.go * `--hostname` declared as a persistent flag on rootCmd is now the only declaration. cmd/auth.go re-declared `--hostname` on three subcommands, shadowing the persistent flag — meaning `fj --hostname=X auth login` and `fj auth login --hostname=X` went through different code paths (viper read vs. local flag read). Local declarations removed. - cmd/auth.go ## Hardening (MEDIUM/LOW) * `--token` on `auth login` now emits a stderr warning when used, since it puts the PAT on argv (visible in `ps auxe`/shell history). Flag not removed — too disruptive — but discoverable now. - cmd/auth.go * Error handling no longer regex-matches "401"/"403" against rendered error strings (would have triggered "auth login" hint for an error that just mentioned issue #403). Now relies on typed `*api.APIError`. Hints moved to a separate `Hint` field on `CLIError`, so JSON-error consumers get clean structure and the human renderer still appends "\nHint: …". - cmd/errors.go * `migrateConfigDir` now opens dst with `O_TRUNC` instead of just `O_CREATE|O_WRONLY`. Previously a partially-pre-existing dst file would have legacy contents overwrite a prefix and leave stale tail bytes — silent YAML/token corruption. - cmd/root.go (extracted into copyOneConfigFile with proper close handling) * Config dir created with mode 0700 instead of 0755. `initConfig` warns on stderr if the resolved config file is world/group readable (`mode & 0o077 != 0`); doesn't fail-close. - cmd/root.go * Network errors (`no such host`, `connection refused`, `i/o timeout`) now return a structured `CLIError` with code `ErrNetworkError` and a hint, instead of a fmt.Errorf chain. - cmd/errors.go Verified: `go build ./...` and `go test ./...` clean. Live integration tested against forgejo.zerova.net. Out of scope, deferred to follow-up commits: - Pagination unification across `repo list`/`pr list`/`issue list` (only `release list` walks pages today; others silently truncate). - `fj api --paginate` to follow pages like `gh api --paginate`. - De-duplicating cmd/aliases.go ↔ cmd/actions.go subtrees.
2026-05-02 15:41:48 -06:00
for _, seg := range strings.Split(strings.Trim(endpointURL.Path, "/"), "/") {
if seg == ".." {
return fmt.Errorf("endpoint contains forbidden '..' segment: %s", endpoint)
}
}
base := &url.URL{Scheme: "https", Host: host.Hostname, Path: "/api/v1"}
final := base.JoinPath(endpointURL.Path)
final.RawQuery = endpointURL.RawQuery
// Create HTTP request
fix(cmd): correctness + audit hardening across cmd/ + internal/ Addresses audit findings from a tri-partite review (codex + 2 Claude agents). Multiple distinct fixes here because they touched overlapping files; happy to split via interactive rebase if a reviewer prefers. ## Correctness bugs (HIGH) * `--config` is now actually honored. cmd/root.initConfig fed Viper but every command that mattered loaded config via `internal/config.Load()` which always read the default path. Added `config.SetExplicitConfigPath` consulted by `GetConfigPath`; `--config other.yaml auth login` now writes to other.yaml. - internal/config/config.go, cmd/root.go * `--json` now works on `fj run …`, `fj workflow …`, and `fj wiki view`. cmd/aliases.go registered `--json` as a Bool but the handlers call `wantJSON()` which does `GetString("json")` and silently ignores the type-error return. cmd/wiki.go did the inverse (`GetBool("json")` against a string-registered flag). Both now use `addJSONFlags`/`wantJSON`/ `outputJSON` consistently. - cmd/aliases.go, cmd/wiki.go * `fj api` no longer lets endpoints escape the /api/v1 base via path-traversal. `fj api '/../admin/users'` previously normalized to `/admin/users` because `http.NewRequest` resolves `..` segments — silently sending authenticated traffic to non-API routes. Endpoint is now parsed, `..` segments are rejected, and JoinPath is used. - cmd/api.go ## Design rework (BREAKING — gets rid of the `--json=fields` quirk) * `--json` flag rebuilt from a string-with-NoOptDefVal=" " sentinel into a plain Bool. `--json-fields` keeps comma-separated projection. The two are mutually exclusive (`MarkFlagsMutuallyExclusive`). `--jq` composes with either or neither. The previous design produced a `--json string[=" "]` in --help and required `--json=fields` (with literal "=") because `--json fields` was parsed as the bare flag plus a positional. Gone. - cmd/json.go: addJSONFlags / wantJSON / outputJSON - cmd/api.go: example block reflects the new shape Migration: `--json=fields` → `--json-fields fields`. Bare `--json` still means "everything as JSON". * `fj api` now uses `internal/api.SharedHTTPClient` (30 s timeout, pooled) instead of constructing a zero-value `&http.Client{}` with no timeout. A hung Forgejo no longer pins the CLI indefinitely. Response body is also bounded by `io.LimitReader` at 64 MB to prevent OOM-on-self. - internal/api/client.go (export SharedHTTPClient), cmd/api.go * `--hostname` declared as a persistent flag on rootCmd is now the only declaration. cmd/auth.go re-declared `--hostname` on three subcommands, shadowing the persistent flag — meaning `fj --hostname=X auth login` and `fj auth login --hostname=X` went through different code paths (viper read vs. local flag read). Local declarations removed. - cmd/auth.go ## Hardening (MEDIUM/LOW) * `--token` on `auth login` now emits a stderr warning when used, since it puts the PAT on argv (visible in `ps auxe`/shell history). Flag not removed — too disruptive — but discoverable now. - cmd/auth.go * Error handling no longer regex-matches "401"/"403" against rendered error strings (would have triggered "auth login" hint for an error that just mentioned issue #403). Now relies on typed `*api.APIError`. Hints moved to a separate `Hint` field on `CLIError`, so JSON-error consumers get clean structure and the human renderer still appends "\nHint: …". - cmd/errors.go * `migrateConfigDir` now opens dst with `O_TRUNC` instead of just `O_CREATE|O_WRONLY`. Previously a partially-pre-existing dst file would have legacy contents overwrite a prefix and leave stale tail bytes — silent YAML/token corruption. - cmd/root.go (extracted into copyOneConfigFile with proper close handling) * Config dir created with mode 0700 instead of 0755. `initConfig` warns on stderr if the resolved config file is world/group readable (`mode & 0o077 != 0`); doesn't fail-close. - cmd/root.go * Network errors (`no such host`, `connection refused`, `i/o timeout`) now return a structured `CLIError` with code `ErrNetworkError` and a hint, instead of a fmt.Errorf chain. - cmd/errors.go Verified: `go build ./...` and `go test ./...` clean. Live integration tested against forgejo.zerova.net. Out of scope, deferred to follow-up commits: - Pagination unification across `repo list`/`pr list`/`issue list` (only `release list` walks pages today; others silently truncate). - `fj api --paginate` to follow pages like `gh api --paginate`. - De-duplicating cmd/aliases.go ↔ cmd/actions.go subtrees.
2026-05-02 15:41:48 -06:00
req, err := http.NewRequest(method, final.String(), body)
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
// Set auth header
if host.Token != "" {
req.Header.Set("Authorization", "token "+host.Token)
}
req.Header.Set("Accept", "application/json")
if hasBody {
req.Header.Set("Content-Type", "application/json")
}
// Apply custom headers
for _, h := range headers {
key, value, found := strings.Cut(h, ":")
if !found {
return fmt.Errorf("invalid header format %q (expected key:value)", h)
}
req.Header.Set(strings.TrimSpace(key), strings.TrimSpace(value))
}
feat(cmd): pagination unification + `fj api --paginate` Before this, only `release list` walked pages. `repo list`, `pr list` (the non-filter branch), and `issue list` all passed `PageSize: limit` directly to the gitea SDK — which silently caps PageSize at 50, so any request for more than 50 results was truncated to 50 with no warning. `--limit` was effectively a per-page hint, not a real limit. ## Changes - New `cmd/paginate.go` — generic `paginateGitea[T any]` that walks pages until the response is short or the limit is reached. Uses Go 1.20 generics so each list command keeps its existing typed slice without conversion overhead. - `repo list` — paginates ListUserRepos. - `pr list` — paginates ListRepoPullRequests in both branches: - With client-side filters (assignee, author, labels, search, draft, head, base): pull all pages then filter+limit. - Without filters: paginate up to limit. - `issue list` — paginates ListRepoIssues. Overshoots 2x because the API returns both issues AND PRs and we filter PRs out client-side; the overshoot keeps us bounded but reduces the chance of returning fewer results than `--limit`. ## `fj api --paginate` Mirrors `gh api --paginate`: - Follows RFC 5988 `Link: rel="next"` headers (Forgejo emits these on list endpoints). - Concatenates each page's JSON array into a single array via `concatPaginatedJSON`. If a page is not a JSON array, errors with a clear message — `--paginate` only makes sense for paginatable endpoints. - GET-only (errors on POST/PUT/DELETE). - Reuses the same auth and custom headers across pages; the body-size limit applies per-page. Refactored the request execution into a `doOnce` closure so the loop body isn't a copy of the single-request path. Verified live: $ fj api 'repos/public/claude-code-proxy/commits?limit=2' \ --paginate --jq '. | length' 44 (44 = total commits in the repo, walked via Link headers from a 2-per-page starting query.) Out of scope for this commit, deferred: - De-duplicating cmd/aliases.go ↔ cmd/actions.go subtrees (the type mismatch they caused is already fixed in the prior commit; the duplication itself is polish).
2026-05-02 15:46:22 -06:00
paginate, _ := cmd.Flags().GetBool("paginate")
if paginate && method != http.MethodGet {
return fmt.Errorf("--paginate only supports GET requests")
}
// doOnce executes a single request via the shared client (30 s timeout,
// pooled connections), reads the body bounded by maxAPIResponseBytes,
// and closes the body before returning. Previous zero-value http.Client{}
// had no timeout, pinning the CLI on a hung Forgejo indefinitely.
doOnce := func(r *http.Request) (body []byte, header http.Header, status int, proto string, statusText string, retErr error) {
ios.StartSpinner("Requesting...")
resp, err := api.SharedHTTPClient.Do(r)
ios.StopSpinner()
if err != nil {
return nil, nil, 0, "", "", fmt.Errorf("failed to perform request: %w", err)
}
defer func() { _ = resp.Body.Close() }()
body, err = io.ReadAll(io.LimitReader(resp.Body, maxAPIResponseBytes+1))
if err != nil {
return nil, nil, 0, "", "", fmt.Errorf("failed to read response body: %w", err)
}
if int64(len(body)) > maxAPIResponseBytes {
return nil, nil, 0, "", "", fmt.Errorf("response body exceeded %d bytes (use a different tool for bulk transfers)", maxAPIResponseBytes)
}
return body, resp.Header, resp.StatusCode, resp.Proto, resp.Status, nil
}
respBody, respHeader, statusCode, proto, status, err := doOnce(req)
if err != nil {
feat(cmd): pagination unification + `fj api --paginate` Before this, only `release list` walked pages. `repo list`, `pr list` (the non-filter branch), and `issue list` all passed `PageSize: limit` directly to the gitea SDK — which silently caps PageSize at 50, so any request for more than 50 results was truncated to 50 with no warning. `--limit` was effectively a per-page hint, not a real limit. ## Changes - New `cmd/paginate.go` — generic `paginateGitea[T any]` that walks pages until the response is short or the limit is reached. Uses Go 1.20 generics so each list command keeps its existing typed slice without conversion overhead. - `repo list` — paginates ListUserRepos. - `pr list` — paginates ListRepoPullRequests in both branches: - With client-side filters (assignee, author, labels, search, draft, head, base): pull all pages then filter+limit. - Without filters: paginate up to limit. - `issue list` — paginates ListRepoIssues. Overshoots 2x because the API returns both issues AND PRs and we filter PRs out client-side; the overshoot keeps us bounded but reduces the chance of returning fewer results than `--limit`. ## `fj api --paginate` Mirrors `gh api --paginate`: - Follows RFC 5988 `Link: rel="next"` headers (Forgejo emits these on list endpoints). - Concatenates each page's JSON array into a single array via `concatPaginatedJSON`. If a page is not a JSON array, errors with a clear message — `--paginate` only makes sense for paginatable endpoints. - GET-only (errors on POST/PUT/DELETE). - Reuses the same auth and custom headers across pages; the body-size limit applies per-page. Refactored the request execution into a `doOnce` closure so the loop body isn't a copy of the single-request path. Verified live: $ fj api 'repos/public/claude-code-proxy/commits?limit=2' \ --paginate --jq '. | length' 44 (44 = total commits in the repo, walked via Link headers from a 2-per-page starting query.) Out of scope for this commit, deferred: - De-duplicating cmd/aliases.go ↔ cmd/actions.go subtrees (the type mismatch they caused is already fixed in the prior commit; the duplication itself is polish).
2026-05-02 15:46:22 -06:00
return err
}
if include {
feat(cmd): pagination unification + `fj api --paginate` Before this, only `release list` walked pages. `repo list`, `pr list` (the non-filter branch), and `issue list` all passed `PageSize: limit` directly to the gitea SDK — which silently caps PageSize at 50, so any request for more than 50 results was truncated to 50 with no warning. `--limit` was effectively a per-page hint, not a real limit. ## Changes - New `cmd/paginate.go` — generic `paginateGitea[T any]` that walks pages until the response is short or the limit is reached. Uses Go 1.20 generics so each list command keeps its existing typed slice without conversion overhead. - `repo list` — paginates ListUserRepos. - `pr list` — paginates ListRepoPullRequests in both branches: - With client-side filters (assignee, author, labels, search, draft, head, base): pull all pages then filter+limit. - Without filters: paginate up to limit. - `issue list` — paginates ListRepoIssues. Overshoots 2x because the API returns both issues AND PRs and we filter PRs out client-side; the overshoot keeps us bounded but reduces the chance of returning fewer results than `--limit`. ## `fj api --paginate` Mirrors `gh api --paginate`: - Follows RFC 5988 `Link: rel="next"` headers (Forgejo emits these on list endpoints). - Concatenates each page's JSON array into a single array via `concatPaginatedJSON`. If a page is not a JSON array, errors with a clear message — `--paginate` only makes sense for paginatable endpoints. - GET-only (errors on POST/PUT/DELETE). - Reuses the same auth and custom headers across pages; the body-size limit applies per-page. Refactored the request execution into a `doOnce` closure so the loop body isn't a copy of the single-request path. Verified live: $ fj api 'repos/public/claude-code-proxy/commits?limit=2' \ --paginate --jq '. | length' 44 (44 = total commits in the repo, walked via Link headers from a 2-per-page starting query.) Out of scope for this commit, deferred: - De-duplicating cmd/aliases.go ↔ cmd/actions.go subtrees (the type mismatch they caused is already fixed in the prior commit; the duplication itself is polish).
2026-05-02 15:46:22 -06:00
fmt.Fprintf(ios.Out, "%s %s\n", proto, status)
for key, values := range respHeader {
for _, v := range values {
fmt.Fprintf(ios.Out, "%s: %s\n", key, v)
}
}
fmt.Fprintln(ios.Out)
}
feat(cmd): pagination unification + `fj api --paginate` Before this, only `release list` walked pages. `repo list`, `pr list` (the non-filter branch), and `issue list` all passed `PageSize: limit` directly to the gitea SDK — which silently caps PageSize at 50, so any request for more than 50 results was truncated to 50 with no warning. `--limit` was effectively a per-page hint, not a real limit. ## Changes - New `cmd/paginate.go` — generic `paginateGitea[T any]` that walks pages until the response is short or the limit is reached. Uses Go 1.20 generics so each list command keeps its existing typed slice without conversion overhead. - `repo list` — paginates ListUserRepos. - `pr list` — paginates ListRepoPullRequests in both branches: - With client-side filters (assignee, author, labels, search, draft, head, base): pull all pages then filter+limit. - Without filters: paginate up to limit. - `issue list` — paginates ListRepoIssues. Overshoots 2x because the API returns both issues AND PRs and we filter PRs out client-side; the overshoot keeps us bounded but reduces the chance of returning fewer results than `--limit`. ## `fj api --paginate` Mirrors `gh api --paginate`: - Follows RFC 5988 `Link: rel="next"` headers (Forgejo emits these on list endpoints). - Concatenates each page's JSON array into a single array via `concatPaginatedJSON`. If a page is not a JSON array, errors with a clear message — `--paginate` only makes sense for paginatable endpoints. - GET-only (errors on POST/PUT/DELETE). - Reuses the same auth and custom headers across pages; the body-size limit applies per-page. Refactored the request execution into a `doOnce` closure so the loop body isn't a copy of the single-request path. Verified live: $ fj api 'repos/public/claude-code-proxy/commits?limit=2' \ --paginate --jq '. | length' 44 (44 = total commits in the repo, walked via Link headers from a 2-per-page starting query.) Out of scope for this commit, deferred: - De-duplicating cmd/aliases.go ↔ cmd/actions.go subtrees (the type mismatch they caused is already fixed in the prior commit; the duplication itself is polish).
2026-05-02 15:46:22 -06:00
if statusCode < 200 || statusCode >= 300 {
if !silent {
fmt.Fprint(ios.ErrOut, string(respBody))
if len(respBody) > 0 && respBody[len(respBody)-1] != '\n' {
fmt.Fprintln(ios.ErrOut)
}
}
feat(cmd): pagination unification + `fj api --paginate` Before this, only `release list` walked pages. `repo list`, `pr list` (the non-filter branch), and `issue list` all passed `PageSize: limit` directly to the gitea SDK — which silently caps PageSize at 50, so any request for more than 50 results was truncated to 50 with no warning. `--limit` was effectively a per-page hint, not a real limit. ## Changes - New `cmd/paginate.go` — generic `paginateGitea[T any]` that walks pages until the response is short or the limit is reached. Uses Go 1.20 generics so each list command keeps its existing typed slice without conversion overhead. - `repo list` — paginates ListUserRepos. - `pr list` — paginates ListRepoPullRequests in both branches: - With client-side filters (assignee, author, labels, search, draft, head, base): pull all pages then filter+limit. - Without filters: paginate up to limit. - `issue list` — paginates ListRepoIssues. Overshoots 2x because the API returns both issues AND PRs and we filter PRs out client-side; the overshoot keeps us bounded but reduces the chance of returning fewer results than `--limit`. ## `fj api --paginate` Mirrors `gh api --paginate`: - Follows RFC 5988 `Link: rel="next"` headers (Forgejo emits these on list endpoints). - Concatenates each page's JSON array into a single array via `concatPaginatedJSON`. If a page is not a JSON array, errors with a clear message — `--paginate` only makes sense for paginatable endpoints. - GET-only (errors on POST/PUT/DELETE). - Reuses the same auth and custom headers across pages; the body-size limit applies per-page. Refactored the request execution into a `doOnce` closure so the loop body isn't a copy of the single-request path. Verified live: $ fj api 'repos/public/claude-code-proxy/commits?limit=2' \ --paginate --jq '. | length' 44 (44 = total commits in the repo, walked via Link headers from a 2-per-page starting query.) Out of scope for this commit, deferred: - De-duplicating cmd/aliases.go ↔ cmd/actions.go subtrees (the type mismatch they caused is already fixed in the prior commit; the duplication itself is polish).
2026-05-02 15:46:22 -06:00
return fmt.Errorf("API request failed with status %d", statusCode)
}
// Follow `Link: rel="next"` headers when --paginate is set, accumulating
// each page's body. After the loop, concatPaginatedJSON merges them into
// a single JSON array. Endpoint must be paginatable (returns an array).
if paginate {
bodies := [][]byte{respBody}
nextURL := parseLinkHeaderNext(respHeader.Get("Link"))
for nextURL != "" {
nextReq, err := http.NewRequest(http.MethodGet, nextURL, nil)
if err != nil {
return fmt.Errorf("failed to build paginated request: %w", err)
}
if host.Token != "" {
nextReq.Header.Set("Authorization", "token "+host.Token)
}
nextReq.Header.Set("Accept", "application/json")
for _, h := range headers {
key, value, found := strings.Cut(h, ":")
if !found {
continue
}
nextReq.Header.Set(strings.TrimSpace(key), strings.TrimSpace(value))
}
pageBody, pageHeader, pageStatus, _, _, err := doOnce(nextReq)
if err != nil {
return err
}
if pageStatus < 200 || pageStatus >= 300 {
return fmt.Errorf("paginated request to %s failed with status %d", nextURL, pageStatus)
}
bodies = append(bodies, pageBody)
nextURL = parseLinkHeaderNext(pageHeader.Get("Link"))
}
merged, err := concatPaginatedJSON(bodies)
if err != nil {
return err
}
respBody = merged
}
if silent || len(respBody) == 0 {
return nil
}
feat(cmd): pagination unification + `fj api --paginate` Before this, only `release list` walked pages. `repo list`, `pr list` (the non-filter branch), and `issue list` all passed `PageSize: limit` directly to the gitea SDK — which silently caps PageSize at 50, so any request for more than 50 results was truncated to 50 with no warning. `--limit` was effectively a per-page hint, not a real limit. ## Changes - New `cmd/paginate.go` — generic `paginateGitea[T any]` that walks pages until the response is short or the limit is reached. Uses Go 1.20 generics so each list command keeps its existing typed slice without conversion overhead. - `repo list` — paginates ListUserRepos. - `pr list` — paginates ListRepoPullRequests in both branches: - With client-side filters (assignee, author, labels, search, draft, head, base): pull all pages then filter+limit. - Without filters: paginate up to limit. - `issue list` — paginates ListRepoIssues. Overshoots 2x because the API returns both issues AND PRs and we filter PRs out client-side; the overshoot keeps us bounded but reduces the chance of returning fewer results than `--limit`. ## `fj api --paginate` Mirrors `gh api --paginate`: - Follows RFC 5988 `Link: rel="next"` headers (Forgejo emits these on list endpoints). - Concatenates each page's JSON array into a single array via `concatPaginatedJSON`. If a page is not a JSON array, errors with a clear message — `--paginate` only makes sense for paginatable endpoints. - GET-only (errors on POST/PUT/DELETE). - Reuses the same auth and custom headers across pages; the body-size limit applies per-page. Refactored the request execution into a `doOnce` closure so the loop body isn't a copy of the single-request path. Verified live: $ fj api 'repos/public/claude-code-proxy/commits?limit=2' \ --paginate --jq '. | length' 44 (44 = total commits in the repo, walked via Link headers from a 2-per-page starting query.) Out of scope for this commit, deferred: - De-duplicating cmd/aliases.go ↔ cmd/actions.go subtrees (the type mismatch they caused is already fixed in the prior commit; the duplication itself is polish).
2026-05-02 15:46:22 -06:00
contentType := respHeader.Get("Content-Type")
isJSON := strings.Contains(contentType, "json") || json.Valid(respBody)
// If the user asked for JSON projection or jq filtering, route through
// the shared JSON output helpers so the API command is consistent with
// `fj repo list`, `fj pr list`, etc.
if wantJSON(cmd) {
if !isJSON {
return fmt.Errorf("--json/--json-fields/--jq requires a JSON response, but the server returned %s", contentType)
}
var parsed any
if err := json.Unmarshal(respBody, &parsed); err != nil {
return fmt.Errorf("response is not valid JSON: %w", err)
}
return outputJSON(cmd, parsed)
}
// Pretty-print JSON by default, otherwise emit raw bytes.
if isJSON {
var parsed any
if err := json.Unmarshal(respBody, &parsed); err == nil {
return writeJSON(parsed)
}
}
_, err = ios.Out.Write(respBody)
return err
}
// parseField parses a "key=value" string. When rawString is true, the value is
// always treated as a string. Otherwise, the function attempts JSON type
// inference: booleans ("true"/"false"), null, numbers, and falls back to string.
func parseField(field string, rawString bool) (string, any, error) {
key, value, found := strings.Cut(field, "=")
if !found {
return "", nil, fmt.Errorf("invalid field format %q (expected key=value)", field)
}
if rawString {
return key, value, nil
}
// JSON type inference
switch {
case value == "true":
return key, true, nil
case value == "false":
return key, false, nil
case value == "null":
return key, nil, nil
default:
// Try number
if n, err := strconv.ParseInt(value, 10, 64); err == nil {
return key, n, nil
}
if f, err := strconv.ParseFloat(value, 64); err == nil {
return key, f, nil
}
return key, value, nil
}
}