Compare commits

..

11 commits
v0.3.1 ... main

Author SHA1 Message Date
sid
0069198ca6 chore: bump version to 0.4.0
Some checks failed
CI / lint (push) Has been cancelled
CI / build (push) Has been cancelled
CI / test (push) Has been cancelled
CI / functional (push) Has been cancelled
CHANGELOG.md updated with the audit-driven hardening pass spanning
13 findings across cmd/ and internal/. Adds CLAUDE.md documenting
dev workflow, codex review pattern, release process, and homebrew
tap update steps.
2026-05-02 16:05:15 -06:00
sid
373c769d2c refactor(cmd): unify actions/aliases command trees via factory functions
Some checks are pending
CI / lint (push) Waiting to run
CI / build (push) Waiting to run
CI / test (push) Waiting to run
CI / functional (push) Blocked by required conditions
The remaining audit finding: cmd/aliases.go rebuilt parallel `run *` and
`workflow *` command subtrees by hand to expose them at top level
(matching gh CLI's `gh run list` ergonomics). That duplication is what
let the `--json` Bool/string mismatch fixed in 0c181df slip in — the
flag was registered correctly under `actions run list` but Bool-typed
under the top-level `run list`, and `wantJSON` silently swallowed the
type-error return.

Switch each `run *` and `workflow *` command from a package-level
`var xxxCmd = &cobra.Command{...}` declaration to a factory function
`newXxxCmd() *cobra.Command` that returns a fully-configured Command
(struct, examples, args, RunE, AND its own flag registrations).

Each parent factory (newRunCmd, newWorkflowCmd) takes a `parentLabel`
string that's appended to the parent's Short/Long, so the alias-tree
variant says "(alias for 'actions run')" without the children diverging.

actions.go init() now does:
  actionsCmd.AddCommand(newRunCmd(""))
  actionsCmd.AddCommand(newWorkflowCmd(""))

aliases.go shrinks from 142 lines to 17 lines:
  rootCmd.AddCommand(newRunCmd(" (alias for 'actions run')"))
  rootCmd.AddCommand(newWorkflowCmd(" (alias for 'actions workflow')"))

Verified: `diff` of `fj run list --help` flags vs `fj actions run list
--help` flags is empty. Both trees produce IDENTICAL surfaces. Future
flag changes touch one factory and propagate to both paths.

Note: secret/variable subcommands aren't aliased so they keep the
package-level var pattern. Only the run/workflow subtrees moved.
2026-05-02 15:56:58 -06:00
sid
155ddb97ba fix(api): validate same-origin before forwarding auth on --paginate
Some checks are pending
CI / lint (push) Waiting to run
CI / build (push) Waiting to run
CI / test (push) Waiting to run
CI / functional (push) Blocked by required conditions
Codex flagged: the --paginate loop rebuilt the next request from the raw
`Link: rel="next"` URL and reattached the bearer token without checking
that the next URL was on the same host. Forgejo emits same-origin next-
links in practice, but a buggy or malicious upstream could redirect us
to a foreign host, at which point the token would leak.

Now the loop:
- url.Parse the Link target.
- Resolve relative URLs against the original base (https://<host>/api/v1).
- Refuse to proceed if the resolved URL's scheme isn't https or its host
  doesn't match `host.Hostname`. The error names both the foreign URL
  and the expected origin so the user can tell why pagination stopped.

Verified: same-origin pagination still works (`--paginate` against
forgejo.zerova.net commits returns 44 across 22 pages).
2026-05-02 15:48:59 -06:00
sid
133fb2fea4 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
sid
0c181df1d1 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
sid
f75b831a53 feat(api): add --json, --json-fields, --jq to fj api
Some checks are pending
CI / lint (push) Waiting to run
CI / build (push) Waiting to run
CI / test (push) Waiting to run
CI / functional (push) Blocked by required conditions
`fj api` was the only command that returned raw API JSON without exposing
the same projection/filtering knobs that `fj repo list`, `fj pr list`,
etc. already provide. Callers had to pipe to `python -m json.tool` or
`jq` to extract fields, which is inconsistent and discoverable only
after hitting the gap.

Wire the existing addJSONFlags / wantJSON / outputJSON helpers from
cmd/json.go so the API command participates in the same JSON output
pipeline. No behavioral change when none of the new flags are set —
default still pretty-prints JSON and writes raw bytes for non-JSON
responses.

Verified against live forgejo:

  $ fj api repos/public/claude-code-proxy --jq .full_name
  public/claude-code-proxy

  $ fj api repos/public/claude-code-proxy --json=full_name,description
  { "description": "...", "full_name": "public/claude-code-proxy" }

  $ fj api 'repos/public/claude-code-proxy/commits?limit=3' \
        --jq '.[] | "\(.sha[0:8]) \(.commit.message | split("\n")[0])"'
  8e550b97 Local fork: hardening + ops improvements ...
  b9da198e Harden proxy auth, storage, and conversation access
  6cda3631 Harden streaming, pagination, and config loading

Note: `--json=fields` requires the equals sign because the flag has
NoOptDefVal=" " (so `--json` alone is valid for "everything as JSON").
The Example block in --help documents both the `--json=` form and the
`--json-fields` alias which doesn't have that quirk.
2026-05-02 15:22:44 -06:00
sid
0fda0b8679 fix brew tap instructions to use public/homebrew-sid
Some checks failed
CI / lint (push) Has been cancelled
CI / build (push) Has been cancelled
CI / test (push) Has been cancelled
CI / functional (push) Has been cancelled
2026-04-26 08:34:10 -06:00
sid
25868adcad bump version to 0.3.2
Some checks are pending
CI / lint (push) Waiting to run
CI / build (push) Waiting to run
CI / test (push) Waiting to run
CI / functional (push) Blocked by required conditions
2026-04-26 08:33:17 -06:00
sid
c3e8ad67ed complete fgj → fj rename: env vars, config migration, docs
Some checks are pending
CI / lint (push) Waiting to run
CI / build (push) Waiting to run
CI / test (push) Waiting to run
CI / functional (push) Blocked by required conditions
- Rename env vars: FGJ_HOST → FJ_HOST, FGJ_TOKEN → FJ_TOKEN,
  FGJ_FORCE_TTY → FJ_FORCE_TTY, FGJ_PAGER → FJ_PAGER,
  FGJ_BINARY_PATH → FJ_BINARY_PATH (all with legacy fallback)
- Auto-migrate ~/.config/fgj/ → ~/.config/fj/ on first run
- Update man page title, README, CHANGELOG
- Update test fixture labels from [FGJ E2E Test] to [FJ E2E Test]
2026-04-26 08:23:48 -06:00
sid
cf7c0e0878 fix .gitignore: update binary name, ignore bin/ dir
Some checks are pending
CI / lint (push) Waiting to run
CI / build (push) Waiting to run
CI / test (push) Waiting to run
CI / functional (push) Blocked by required conditions
2026-04-26 08:17:15 -06:00
sid
bc43f6e5a5 rename fgj to fj
Module path, binary name, config dir, help text, and docs
all updated from fgj-sid/fgj to fj.
2026-04-26 08:16:52 -06:00
38 changed files with 1417 additions and 901 deletions

View file

@ -72,7 +72,7 @@ jobs:
- name: Build production binary
run: |
make build
echo "Binary built at: $(pwd)/bin/fgj"
echo "Binary built at: $(pwd)/bin/fj"
- name: Run functional tests
run: go test -v -race -tags=functional ./tests/functional/...

View file

@ -24,7 +24,7 @@ jobs:
- name: Build production binary
run: |
make build
echo "Binary built at: $(pwd)/bin/fgj"
echo "Binary built at: $(pwd)/bin/fj"
- name: Run functional tests
run: go test -v -race -tags=functional ./tests/functional/...

3
.gitignore vendored
View file

@ -1,5 +1,6 @@
# Binaries
fgj
fj
bin/
*.exe
*.exe~
*.dll

View file

@ -5,50 +5,151 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.4.0] - 2026-05-02
Audit-driven hardening pass. Three reviewers (Codex + two Claude agents
with non-overlapping focuses) found 13 issues across cmd/ and internal/;
this release ships fixes for all 13.
### BREAKING
- `--json=fields` syntax removed. The flag was a string with
`NoOptDefVal=" "` sentinel — `--json` alone meant "everything",
`--json=fields` projected. That produced `--json string[=" "]` in
`--help` and required a literal `=` because `--json fields` was parsed
as the bare flag plus a positional. **Migration**: `--json=fields`
`--json-fields fields`. Bare `--json` still means "all fields as JSON".
`--json` and `--json-fields` are mutually exclusive; `--jq` composes
with either.
### Added
- `fj api --json` / `--json-fields` / `--jq` — projection and jq filtering
for raw API responses. Routes through the same `addJSONFlags` helpers
as the other list commands. Closes the inconsistency where `fj api`
was the only command returning raw JSON without these knobs.
- `fj api --paginate` — follows RFC 5988 `Link: rel="next"` headers and
concatenates JSON array pages, gh-compatible. Validates same-origin
before forwarding the bearer token to the next URL.
- `cmd/paginate.go` — generic `paginateGitea[T any]` helper. Applied to
`repo list`, `pr list`, `issue list`. Previously only `release list`
walked pages; the others passed `PageSize: limit` directly to the
gitea SDK, which silently caps PageSize at 50, so `--limit > 50` was
truncated without warning.
- `CLAUDE.md` — guide for Claude Code sessions: layout, codex review
pattern, release process, homebrew tap update steps.
### Changed
- `--json` flag rebuilt as a plain `Bool`. `--json-fields` keeps
comma-separated projection. Both registered via `addJSONFlags` and
marked `MutuallyExclusive`.
- `cmd/actions.go``run` and `workflow` subtrees converted from
package-level `var`s to factory functions (`newRunCmd`,
`newWorkflowCmd`, ...). `cmd/aliases.go` shrank from 142 → 17 lines
and now calls those same factories with a `parentLabel` parameter that
disambiguates the alias variant. Result: `diff` of `fj run list
--help` flags vs `fj actions run list --help` flags is now empty.
Drift between the two paths is structurally impossible.
- `fj api` now uses `internal/api.SharedHTTPClient` (30s timeout, pooled
connections) instead of a zero-value `&http.Client{}` with no timeout.
A hung Forgejo no longer pins the CLI indefinitely.
- `fj api` response body bounded by `io.LimitReader` at 64 MB to prevent
OOM-on-self.
- `cmd/auth.go` removed redundant local `--hostname` declarations on
three subcommands. The persistent flag on rootCmd is now the only
declaration; previously local declarations shadowed it, so
`fj --hostname=X auth login` and `fj auth login --hostname=X` went
through different code paths.
- `--token` on `auth login` emits a stderr warning when used (visible
in `ps auxe` and shell history). Flag not removed; just discoverable.
- Error handling: `Hint` is now a structured field on `CLIError`.
JSON-error consumers get clean structure; the human renderer still
appends `\nHint: ...`. Dropped substring matching of `"401"`/`"403"`
against rendered error strings (would match issue #403); now relies
exclusively on typed `*api.APIError`.
- Network errors (`no such host`, `connection refused`, `i/o timeout`)
return a structured `CLIError` with code `ErrNetworkError` and a hint.
- Config dir created with mode 0700 instead of 0755.
### Fixed
- `--config <path>` now actually honored. Previously fed only into
Viper; every command that touched config went through
`internal/config.Load()` / `Save()` which always read the default
path. So `fj --config other.yaml auth login` writes to other.yaml now.
- `fj run list --json`, `fj workflow list --json`, `fj wiki view --json`
now produce JSON. `cmd/aliases.go` registered `--json` as `Bool` but
handlers called `wantJSON()` which does `GetString("json")` — pflag
returned a type-error that `wantJSON` silently swallowed.
`cmd/wiki.go` had the inverse bug (`GetBool` against an
`addJSONFlags`-registered string flag). Both routed through
`addJSONFlags`/`wantJSON`/`outputJSON` consistently now.
- `migrateConfigDir` opens dst with `O_TRUNC`. Previously a partially-
pre-existing dst file would have legacy contents overwrite a prefix
and leave stale tail bytes — silent YAML/token corruption. Refactored
close handling into `copyOneConfigFile`.
### Security
- `fj api` endpoint path traversal closed. `fj api '/../admin/users'`
previously normalized through `http.NewRequest` to
`https://host/admin/users` — silently sending authenticated traffic
to non-API paths. Endpoint is now parsed via `url.Parse`, `..`
segments rejected, then `JoinPath` onto the `/api/v1` base.
URL-encoded `%2E%2E` is also caught because Go decodes before our
split.
- `fj api --paginate` validates same-origin before forwarding the
bearer token to a `Link: rel="next"` URL. Refuses to reattach
`Authorization` if the next URL's scheme isn't `https` or its host
doesn't match the configured one.
- `initConfig` warns on stderr if the resolved config file is world or
group readable (`mode & 0o077 != 0`).
## [0.3.0c] - 2026-03-21
### Added
#### Label Management
- `fgj label list` - List repository labels
- `fgj label create` - Create a label with color and description
- `fgj label edit` - Edit label name, color, or description
- `fgj label delete` - Delete a label
- `fj label list` - List repository labels
- `fj label create` - Create a label with color and description
- `fj label edit` - Edit label name, color, or description
- `fj label delete` - Delete a label
#### Milestone Management
- `fgj milestone list` - List milestones with state filtering
- `fgj milestone view` - View milestone details
- `fgj milestone create` - Create a milestone with description and due date
- `fgj milestone edit` - Edit milestone title, description, due date, or state
- `fgj milestone delete` - Delete a milestone
- `fj milestone list` - List milestones with state filtering
- `fj milestone view` - View milestone details
- `fj milestone create` - Create a milestone with description and due date
- `fj milestone edit` - Edit milestone title, description, due date, or state
- `fj milestone delete` - Delete a milestone
#### Wiki Management
- `fgj wiki list` - List wiki pages
- `fgj wiki view` - View wiki page content
- `fgj wiki create` - Create a wiki page from flag or file
- `fgj wiki edit` - Edit a wiki page
- `fgj wiki delete` - Delete a wiki page
- `fj wiki list` - List wiki pages
- `fj wiki view` - View wiki page content
- `fj wiki create` - Create a wiki page from flag or file
- `fj wiki edit` - Edit a wiki page
- `fj wiki delete` - Delete a wiki page
#### Issue Dependencies
- `fgj issue edit --add-dependency <number>` - Add issue dependency
- `fgj issue edit --remove-dependency <number>` - Remove issue dependency
- `fj issue edit --add-dependency <number>` - Add issue dependency
- `fj issue edit --remove-dependency <number>` - Remove issue dependency
## [0.3.0b] - 2026-03-21
### Added
#### Repository Management
- `fgj repo edit` - Edit repository settings (visibility, description, homepage, default branch)
- `fj repo edit` - Edit repository settings (visibility, description, homepage, default branch)
### Fixed
- `fgj repo create --public` flag was defined but never read; now properly wired up
- `fj repo create --public` flag was defined but never read; now properly wired up
## [0.3.0a] - 2026-03-21
### Added
#### Raw API Access
- `fgj api <endpoint>` - Make authenticated REST API requests to any Forgejo/Gitea endpoint
- `fj api <endpoint>` - Make authenticated REST API requests to any Forgejo/Gitea endpoint
- HTTP method selection (`--method`/`-X`), auto-switches to POST when fields are provided
- JSON field assembly (`--field`/`-f`) with type inference (bool, int, float, null, string)
- Raw string fields (`--raw-field`/`-F`)
@ -58,14 +159,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Response header display (`--include`/`-i`)
#### Pull Request Management
- `fgj pr diff <number>` - View the diff for a pull request
- `fj pr diff <number>` - View the diff for a pull request
- Colorized output (`--color auto/always/never`)
- Changed file names only (`--name-only`)
- Diffstat summary (`--stat`)
- `fgj pr comment <number>` - Add a comment to a pull request
- `fj pr comment <number>` - Add a comment to a pull request
- Body from flag (`--body`/`-b`) or file (`--body-file`, `-` for stdin)
- JSON output (`--json`)
- `fgj pr review <number>` - Submit a review on a pull request
- `fj pr review <number>` - Submit a review on a pull request
- Approve (`--approve`/`-a`), request changes (`--request-changes`/`-r`), or comment (`--comment`/`-c`)
- Body from flag or file
- JSON output (`--json`)
@ -81,30 +182,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
#### Forgejo Actions
- `fgj actions run watch <run-id>` - Poll a run until completion
- `fgj actions run rerun <run-id>` - Trigger a rerun of a workflow run
- `fgj actions run cancel <run-id>` - Cancel an in-progress workflow run
- `fgj actions workflow enable <workflow>` - Enable a workflow
- `fgj actions workflow disable <workflow>` - Disable a workflow
- `fj actions run watch <run-id>` - Poll a run until completion
- `fj actions run rerun <run-id>` - Trigger a rerun of a workflow run
- `fj actions run cancel <run-id>` - Cancel an in-progress workflow run
- `fj actions workflow enable <workflow>` - Enable a workflow
- `fj actions workflow disable <workflow>` - Disable a workflow
#### Repository Management
- `fgj repo create <name>` - Create a new repository with full option set: `--private`/`--public`, `--description`, `--add-readme`, `--gitignore`, `--license`, `--homepage`, `--clone`, `--team`
- `fj repo create <name>` - Create a new repository with full option set: `--private`/`--public`, `--description`, `--add-readme`, `--gitignore`, `--license`, `--homepage`, `--clone`, `--team`
#### Issue Management
- `fgj issue create -l <label>` - Assign labels when creating an issue
- `fgj issue edit --add-label` / `--remove-label` - Add or remove labels on existing issues
- `fgj issue close -c <comment>` - Close an issue with an optional comment
- `fj issue create -l <label>` - Assign labels when creating an issue
- `fj issue edit --add-label` / `--remove-label` - Add or remove labels on existing issues
- `fj issue close -c <comment>` - Close an issue with an optional comment
#### Workflow Management
- `fgj actions workflow list/view/run` - List, view, and trigger workflows
- `fj actions workflow list/view/run` - List, view, and trigger workflows
#### Auth Helpers
- `fgj auth token` - Print the stored token for the current host
- `fgj auth logout` - Remove authentication for a host
- `fj auth token` - Print the stored token for the current host
- `fj auth logout` - Remove authentication for a host
#### Shell Completions and Man Pages
- `fgj completion [bash|zsh|fish|powershell]` - Generate shell completion scripts
- `fgj manpages --dir <path>` - Generate man pages for all commands
- `fj completion [bash|zsh|fish|powershell]` - Generate shell completion scripts
- `fj manpages --dir <path>` - Generate man pages for all commands
#### JSON Output
- `--json` flag for all list and view commands: PRs, issues, releases, workflow runs, workflows
@ -117,17 +218,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
#### Release Management
- `fgj release list` - List releases for a repository
- `fgj release view` - View details of a specific release (supports "latest" keyword)
- `fgj release create` - Create new releases with optional asset uploads
- `fgj release upload` - Upload assets to existing releases with optional clobber support
- `fgj release delete` - Delete releases (preserves Git tags)
- `fj release list` - List releases for a repository
- `fj release view` - View details of a specific release (supports "latest" keyword)
- `fj release create` - Create new releases with optional asset uploads
- `fj release upload` - Upload assets to existing releases with optional clobber support
- `fj release delete` - Delete releases (preserves Git tags)
#### Issue Management
- `fgj issue edit` - Edit existing issues with support for updating title, body, and labels
- `fj issue edit` - Edit existing issues with support for updating title, body, and labels
#### Pull Request Management
- `fgj pr create --assignee` - Assign users when creating pull requests
- `fj pr create --assignee` - Assign users when creating pull requests
#### Repository Detection
- Automatic hostname detection from git remote URLs
@ -148,48 +249,48 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
#### Core Features
- Initial release of fgj - Forgejo CLI tool
- Initial release of fj - Forgejo CLI tool
- Multi-instance support for any Forgejo/Gitea instance
- Automatic repository detection from git context (optional `-R` flag)
- Secure authentication with personal access tokens
- Configuration management via `~/.config/fgj/config.yaml`
- Configuration management via `~/.config/fj/config.yaml`
#### Pull Request Management
- `fgj pr list` - List pull requests with filtering by state
- `fgj pr view` - View detailed pull request information
- `fgj pr create` - Create new pull requests
- `fgj pr merge` - Merge pull requests with configurable merge methods
- `fj pr list` - List pull requests with filtering by state
- `fj pr view` - View detailed pull request information
- `fj pr create` - Create new pull requests
- `fj pr merge` - Merge pull requests with configurable merge methods
#### Issue Management
- `fgj issue list` - List issues with state filtering
- `fgj issue view` - View detailed issue information
- `fgj issue create` - Create new issues
- `fgj issue comment` - Add comments to issues
- `fgj issue close` - Close issues
- `fj issue list` - List issues with state filtering
- `fj issue view` - View detailed issue information
- `fj issue create` - Create new issues
- `fj issue comment` - Add comments to issues
- `fj issue close` - Close issues
#### Repository Operations
- `fgj repo view` - View repository details
- `fgj repo list` - List user repositories
- `fgj repo clone` - Clone repositories with protocol selection (HTTPS/SSH)
- `fgj repo fork` - Fork repositories
- `fj repo view` - View repository details
- `fj repo list` - List user repositories
- `fj repo clone` - Clone repositories with protocol selection (HTTPS/SSH)
- `fj repo fork` - Fork repositories
#### Forgejo Actions Support
- `fgj actions run list` - List workflow runs with status and metadata
- `fgj actions run view` - View detailed run information, jobs, and logs
- `fj actions run list` - List workflow runs with status and metadata
- `fj actions run view` - View detailed run information, jobs, and logs
- Support for `--verbose`, `--log`, `--log-failed`, and `--job` flags
- `fgj actions secret list` - List repository secrets
- `fgj actions secret create` - Create repository secrets
- `fgj actions secret delete` - Delete repository secrets
- `fgj actions variable list` - List repository variables
- `fgj actions variable get` - Get variable values
- `fgj actions variable create` - Create repository variables
- `fgj actions variable update` - Update repository variables
- `fgj actions variable delete` - Delete repository variables
- `fj actions secret list` - List repository secrets
- `fj actions secret create` - Create repository secrets
- `fj actions secret delete` - Delete repository secrets
- `fj actions variable list` - List repository variables
- `fj actions variable get` - Get variable values
- `fj actions variable create` - Create repository variables
- `fj actions variable update` - Update repository variables
- `fj actions variable delete` - Delete repository variables
#### Authentication
- `fgj auth login` - Interactive authentication with Forgejo instances
- `fgj auth status` - Check authentication status
- Environment variable support (`FGJ_HOST`, `FGJ_TOKEN`)
- `fj auth login` - Interactive authentication with Forgejo instances
- `fj auth status` - Check authentication status
- Environment variable support (`FJ_HOST`, `FJ_TOKEN`)
#### Development
- Comprehensive unit test suite
@ -203,9 +304,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Cobra framework for CLI structure
- Viper for configuration management
[0.3.0c]: https://forgejo.zerova.net/public/fgj-sid/releases/tag/v0.3.0c
[0.3.0b]: https://forgejo.zerova.net/public/fgj-sid/releases/tag/v0.3.0b
[0.3.0a]: https://forgejo.zerova.net/public/fgj-sid/releases/tag/v0.3.0a
[0.3.0]: https://codeberg.org/romaintb/fgj/releases/tag/v0.3.0
[0.2.0]: https://codeberg.org/romaintb/fgj/releases/tag/v0.2.0
[0.1.0]: https://codeberg.org/romaintb/fgj/releases/tag/v0.1.0
[0.3.0c]: https://forgejo.zerova.net/public/fj/releases/tag/v0.3.0c
[0.3.0b]: https://forgejo.zerova.net/public/fj/releases/tag/v0.3.0b
[0.3.0a]: https://forgejo.zerova.net/public/fj/releases/tag/v0.3.0a
[0.3.0]: https://codeberg.org/romaintb/fj/releases/tag/v0.3.0
[0.2.0]: https://codeberg.org/romaintb/fj/releases/tag/v0.2.0
[0.1.0]: https://codeberg.org/romaintb/fj/releases/tag/v0.1.0

166
CLAUDE.md Normal file
View file

@ -0,0 +1,166 @@
# fj — guide for Claude Code sessions
`fj` is a personal Forgejo/Gitea CLI tool, modeled on GitHub's `gh`. It targets `forgejo.zerova.net` (and Codeberg). The user (sid) owns it; the canonical repo is `public/fj` on forgejo.zerova.net (mirrored from there to nowhere else).
This file is read first by Claude Code when working in `~/repos/fj`. Goal: get a session productive quickly without re-deriving the dev workflow each time.
## Layout
```
~/repos/fj/
├── cmd/ cobra command definitions, one file per subject area
│ ├── root.go rootCmd, --config plumbing, OnInitialize
│ ├── auth.go login/status/logout/token (uses persistent --hostname)
│ ├── api.go raw API access; --json/--json-fields/--jq/--paginate
│ ├── json.go shared JSON output helpers (addJSONFlags/wantJSON/outputJSON)
│ ├── paginate.go generic paginateGitea[T] helper for list commands
│ ├── errors.go CLIError with structured Hint field
│ ├── actions.go Forgejo Actions; runs/workflows via factory functions
│ ├── aliases.go top-level `fj run` / `fj workflow` aliases — calls actions.go factories
│ ├── repo.go pr.go issue.go release.go wiki.go label.go milestone.go
│ └── ...
├── internal/
│ ├── api/client.go SharedHTTPClient (30s timeout); GetJSON/DoJSON/DownloadFile
│ ├── config/config.go YAML config; honors --config via SetExplicitConfigPath
│ ├── git/ repo + host detection from `git remote`
│ ├── iostreams/ wrapped stdin/stdout/stderr + spinner + pager + colors
│ └── text/ formatting helpers
├── main.go thin entrypoint; ContextualError + JSON-error rendering
├── Makefile build / lint / test (no release automation)
├── CHANGELOG.md Keep-a-Changelog format
└── README.md
```
## Build, install, test
```bash
go build ./... # quick build check
go test ./... # unit tests
go install . # build + install to ~/go/bin/fj (the binary that's on PATH)
make lint # golangci-lint, if you have it
```
After any change in cmd/ or internal/, run `go install .` and the global `fj` reflects it immediately. There's no daemon/restart.
## Auth
The user is authenticated as `sid` on `forgejo.zerova.net`. Token lives in `~/.config/fj/config.yaml` (mode 0600). For HTTPS git pushes from this host, the token can be injected via `git -c "http.extraHeader=Authorization: token <T>" push` — the local SSH key (`sid@debian` on forgejo) is also registered, so `git@forgejo.zerova.net:public/fj.git` works directly.
## Code review pattern (use this for non-trivial changes)
For audits or significant refactors, run **three reviewers in parallel** with non-overlapping focuses (we did this in the v0.4.0 cycle and it found bugs none would have caught alone):
- **Codex** — read-only sandbox, peer-AI cross-check
```bash
codex exec --skip-git-repo-check --sandbox read-only \
-m gpt-5.4-mini --config model_reasoning_effort="medium" "<prompt>" 2>/dev/null
```
For follow-up rounds resume the same session: `echo "<prompt>" | codex exec --skip-git-repo-check resume --last 2>/dev/null`. Codex remembers prior critique.
- **Claude general-purpose agent A** — architecture / UX / code-quality
- **Claude general-purpose agent B** — security / correctness / error handling
Tell each reviewer what the **siblings** are covering so they don't duplicate. Cap reports at ~600 words. Consolidate findings by severity (HIGH / MEDIUM / LOW) before presenting to the user.
## Release process
We use semver. **Pre-1.0**: breaking change → minor bump (e.g. v0.3.x → v0.4.0).
1. **Bump version**
```go
// cmd/root.go
Version: "0.4.0",
```
2. **Update CHANGELOG.md** — prepend a new section. Format:
```markdown
## [0.4.0] - YYYY-MM-DD
### BREAKING
- <thing that broke>
### Added
- ...
### Changed
- ...
### Fixed
- ...
### Security
- ...
```
3. **Commit** the version+changelog bump as a single commit:
```bash
git commit -m "chore: bump version to 0.4.0"
```
4. **Tag** the commit:
```bash
git tag -a v0.4.0 -m "Release v0.4.0: <one-line summary>"
```
5. **Push** commits and tag:
```bash
git push origin main
git push origin v0.4.0
```
6. **Create the Forgejo release page** via fj itself:
```bash
fj release create v0.4.0 \
--title "v0.4.0: <summary>" \
--notes "$(awk '/^## \[0.4.0\]/{flag=1;next} /^## /{flag=0} flag' CHANGELOG.md)"
```
(The awk one-liner extracts the just-added CHANGELOG section as release notes.)
7. **Update the homebrew tap** — see the next section.
## Updating the homebrew tap (`public/homebrew-sid`)
The tap lives at `~/repos/homebrew-sid` (or `git@forgejo.zerova.net:public/homebrew-sid.git`). The `Formula/fj.rb` formula references the source by `tag:` + `revision:` (SHA), so a release bump touches three lines:
```ruby
url "ssh://git@forgejo.zerova.net/public/fj.git",
tag: "v0.4.0", # was v0.3.2
revision: "<SHA of v0.4.0 tag>" # update
test do
assert_match "0.4.0", shell_output("#{bin}/fj --version") # update
end
```
To get the SHA:
```bash
git -C ~/repos/fj rev-parse v0.4.0
```
Then in `~/repos/homebrew-sid`:
```bash
# edit Formula/fj.rb (the three lines above)
git commit -am "fj: bump to v0.4.0"
git push origin main
```
After push, users can `brew update && brew upgrade fj` to pick up the new version.
## Common footguns
- **`fj` reads the current dir's `git origin`** to detect the host. In a directory whose origin points at github.com (e.g. /opt/stacks/claude-code-proxy/build), bare `fj api ...` errors with "no configuration found for host github.com". Pass `--hostname forgejo.zerova.net` explicitly, or `cd` somewhere else.
- **`--json=fields` was removed in v0.4.0** in favor of `--json-fields fields` (or `--json-fields=fields`). The old `=fields` form was a `NoOptDefVal=" "` sentinel hack. `--json` is now a plain Bool meaning "as JSON".
- **`--config` was silently ignored before v0.4.0.** Old fj versions read --config into Viper but `internal/config.Load()` always read the default path. Fixed; `fj --config other.yaml auth login` now writes to other.yaml.
- **The `actions` and `run`/`workflow` command trees share factory functions** in `cmd/actions.go` (`newRunCmd`, `newWorkflowCmd`). Don't add flags directly to `runListCmd` style globals — they don't exist anymore. Edit the factory and both `fj actions run list` and `fj run list` get the change.
## Useful commands
```bash
# Live test against forgejo (using the new flags)
fj --hostname forgejo.zerova.net api repos/public/fj --json-fields full_name,description
# Walk paginated endpoints
fj --hostname forgejo.zerova.net api 'repos/public/fj/commits?limit=10' --paginate --jq '.[].sha[0:8]'
# Confirm both command trees stay in sync after edits
diff <(fj run list --help | grep -E "^ -|^ --" | sort) \
<(fj actions run list --help | grep -E "^ -|^ --" | sort)
# Empty diff = trees agree. Any output = factory drift.
```

View file

@ -11,10 +11,10 @@ help:
@echo " make clean - Clean build artifacts"
build:
go build -o bin/fgj .
go build -o bin/fj .
install: build
install -Dm755 bin/fgj /usr/bin/fgj
install -Dm755 bin/fj /usr/bin/fj
run:
go run .

292
README.md
View file

@ -1,11 +1,11 @@
# fgj - Forgejo/Gitea CLI Tool
# fj - Forgejo/Gitea CLI Tool
[![Go Version](https://img.shields.io/badge/Go-1.23+-00ADD8?style=flat-square&logo=go)](https://golang.org)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg?style=flat-square)](LICENSE)
`fgj` is a command-line tool for working with Forgejo and Gitea instances. It brings pull requests, issues, and other forge concepts to the terminal, similar to what `gh` does for GitHub. This fork adds agentic dev features — raw API access, PR review workflows, structured error output, and machine-readable I/O for AI coding agents.
`fj` is a command-line tool for working with Forgejo and Gitea instances. It brings pull requests, issues, and other forge concepts to the terminal, similar to what `gh` does for GitHub. This fork adds agentic dev features — raw API access, PR review workflows, structured error output, and machine-readable I/O for AI coding agents.
> Forked from [codeberg.org/romaintb/fgj](https://codeberg.org/romaintb/fgj) and hosted at [forgejo.zerova.net/public/fgj-sid](https://forgejo.zerova.net/public/fgj-sid).
> Forked from [codeberg.org/romaintb/fj](https://codeberg.org/romaintb/fj) and hosted at [forgejo.zerova.net/public/fj](https://forgejo.zerova.net/public/fj).
## Features
@ -19,7 +19,7 @@
- Issue dependencies (`--add-dependency`, `--remove-dependency`)
- Forgejo Actions (workflow runs, watch/rerun/cancel, enable/disable, secrets, variables)
- Releases (create, upload, delete)
- Raw API access (`fgj api`) for arbitrary REST calls
- Raw API access (`fj api`) for arbitrary REST calls
- Shell completions (bash, zsh, fish, PowerShell) and man pages
- JSON output (`--json`) for all list/view commands
- Structured JSON error output (`--json-errors`) for machine consumption
@ -33,22 +33,22 @@
### macOS (Homebrew)
```bash
brew tap sid/fgj-sid https://forgejo.zerova.net/sid/homebrew-fgj-sid.git
brew install fgj
brew tap public/sid git@forgejo.zerova.net:public/homebrew-sid.git
brew install fj
```
### Using Go Install
```bash
go install forgejo.zerova.net/public/fgj-sid@latest
go install forgejo.zerova.net/public/fj@latest
```
### From Source
```bash
git clone https://forgejo.zerova.net/public/fgj-sid.git
cd fgj-sid
go build -o fgj .
git clone https://forgejo.zerova.net/public/fj.git
cd fj
go build -o fj .
```
## Quick Start
@ -58,7 +58,7 @@ go build -o fgj .
First, authenticate with your Forgejo or Gitea instance:
```bash
fgj auth login
fj auth login
```
You'll be prompted for:
@ -74,34 +74,34 @@ To create a personal access token:
### 2. Check Authentication Status
```bash
fgj auth status
fj auth status
```
### Auth Helpers
```bash
# Print the stored token for the current host
fgj auth token
fj auth token
# Remove authentication for a host
fgj auth logout
fj auth logout
```
## Usage
### Repository Detection
`fgj` automatically detects the repository from your git context, similar to `gh`:
`fj` automatically detects the repository from your git context, similar to `gh`:
```bash
# When inside a git repository, no -R flag needed!
cd /path/to/your/repo
fgj pr list # Automatically uses current repo
fgj issue list # Automatically uses current repo
fgj pr view 123 # Automatically uses current repo
fj pr list # Automatically uses current repo
fj issue list # Automatically uses current repo
fj pr view 123 # Automatically uses current repo
# Or explicitly specify a repository with -R
fgj pr list -R owner/repo
fj pr list -R owner/repo
```
The tool reads `.git/config` to find the origin remote and extract both the owner/repo information and the instance hostname. If you're not in a git repository, you'll need to use the `-R` flag.
@ -110,307 +110,307 @@ The tool reads `.git/config` to find the origin remote and extract both the owne
```bash
# List pull requests (auto-detects repo and hostname from git)
fgj pr list
fj pr list
# Or specify explicitly
fgj pr list -R owner/repo
fj pr list -R owner/repo
# Filter by state
fgj pr list --state closed
fj pr list --state closed
# View a specific pull request
fgj pr view 123
fj pr view 123
# Create a pull request
fgj pr create -t "PR Title" -b "PR Description" -H feature-branch -B main
fj pr create -t "PR Title" -b "PR Description" -H feature-branch -B main
# Merge a pull request
fgj pr merge 123 --merge-method squash
fj pr merge 123 --merge-method squash
# View PR diff
fgj pr diff 123
fj pr diff 123
# View diff with color
fgj pr diff 123 --color always
fj pr diff 123 --color always
# Show only changed file names
fgj pr diff 123 --name-only
fj pr diff 123 --name-only
# Show diffstat summary
fgj pr diff 123 --stat
fj pr diff 123 --stat
# Comment on a pull request
fgj pr comment 123 -b "Looks good, minor nit on line 42"
fj pr comment 123 -b "Looks good, minor nit on line 42"
# Comment from a file
fgj pr comment 123 --body-file review-notes.md
fj pr comment 123 --body-file review-notes.md
# Approve a pull request
fgj pr review 123 --approve -b "LGTM"
fj pr review 123 --approve -b "LGTM"
# Request changes
fgj pr review 123 --request-changes -b "Please fix the error handling"
fj pr review 123 --request-changes -b "Please fix the error handling"
# Submit a review comment (neither approve nor request changes)
fgj pr review 123 --comment -b "Some observations"
fj pr review 123 --comment -b "Some observations"
```
### Issues
```bash
# List issues (auto-detects repo and hostname from git)
fgj issue list
fj issue list
# Or specify explicitly
fgj issue list -R owner/repo
fj issue list -R owner/repo
# Filter by state
fgj issue list --state all
fj issue list --state all
# View an issue
fgj issue view 456
fj issue view 456
# Create an issue
fgj issue create -t "Issue Title" -b "Issue Description"
fj issue create -t "Issue Title" -b "Issue Description"
# Create an issue with labels
fgj issue create -t "Issue Title" -b "Issue Description" -l bug -l enhancement
fj issue create -t "Issue Title" -b "Issue Description" -l bug -l enhancement
# Comment on an issue
fgj issue comment 456 -b "My comment"
fj issue comment 456 -b "My comment"
# Close an issue
fgj issue close 456
fj issue close 456
# Close an issue with a comment
fgj issue close 456 -c "Fixed in v2.0"
fj issue close 456 -c "Fixed in v2.0"
# Edit an issue (title, body, state, labels)
fgj issue edit 456 -t "New Title"
fgj issue edit 456 --add-label priority --remove-label bug
fj issue edit 456 -t "New Title"
fj issue edit 456 --add-label priority --remove-label bug
# Manage issue dependencies
fgj issue edit 456 --add-dependency 123
fgj issue edit 456 --remove-dependency 123
fj issue edit 456 --add-dependency 123
fj issue edit 456 --remove-dependency 123
```
### Labels
```bash
# List labels
fgj label list
fj label list
# Create a label
fgj label create bug --color ff0000 -d "Something isn't working"
fj label create bug --color ff0000 -d "Something isn't working"
# Edit a label
fgj label edit bug --name bugfix --color ee0000
fj label edit bug --name bugfix --color ee0000
# Delete a label
fgj label delete bug
fj label delete bug
```
### Milestones
```bash
# List milestones
fgj milestone list
fgj milestone list --state all
fj milestone list
fj milestone list --state all
# View a milestone
fgj milestone view "v1.0"
fj milestone view "v1.0"
# Create a milestone with due date
fgj milestone create "v2.0" -d "Next major release" --due 2026-06-01
fj milestone create "v2.0" -d "Next major release" --due 2026-06-01
# Edit a milestone
fgj milestone edit "v2.0" --title "v2.0-rc1" --state closed
fj milestone edit "v2.0" --title "v2.0-rc1" --state closed
# Delete a milestone
fgj milestone delete "v2.0"
fj milestone delete "v2.0"
```
### Wiki
```bash
# List wiki pages
fgj wiki list
fj wiki list
# View a wiki page
fgj wiki view "Home"
fj wiki view "Home"
# Create a wiki page
fgj wiki create "Setup Guide" -b "# Setup\n\nFollow these steps..."
fj wiki create "Setup Guide" -b "# Setup\n\nFollow these steps..."
# Create from file
fgj wiki create "API Docs" --body-file docs/api.md
fj wiki create "API Docs" --body-file docs/api.md
# Edit a wiki page
fgj wiki edit "Home" -b "Updated content"
fj wiki edit "Home" -b "Updated content"
# Delete a wiki page
fgj wiki delete "Old Page"
fj wiki delete "Old Page"
```
### Repositories
```bash
# View repository details
fgj repo view owner/repo
fj repo view owner/repo
# List your repositories
fgj repo list
fj repo list
# Create a repository
fgj repo create my-repo
fgj repo create my-repo -d "My project" --private --add-readme -g Go -l MIT
fj repo create my-repo
fj repo create my-repo -d "My project" --private --add-readme -g Go -l MIT
# Clone a repository
fgj repo clone owner/repo
fj repo clone owner/repo
# Clone via SSH
fgj repo clone owner/repo -p ssh
fj repo clone owner/repo -p ssh
# Fork a repository
fgj repo fork owner/repo
fj repo fork owner/repo
# Edit repository settings
fgj repo edit owner/repo --public
fgj repo edit owner/repo --private
fgj repo edit owner/repo -d "New description" --homepage https://example.com
fgj repo edit --default-branch develop
fgj repo edit owner/repo --name new-name
fj repo edit owner/repo --public
fj repo edit owner/repo --private
fj repo edit owner/repo -d "New description" --homepage https://example.com
fj repo edit --default-branch develop
fj repo edit owner/repo --name new-name
# Rename a repository (shorthand)
fgj repo rename new-name
fgj repo rename new-name -R owner/old-name
fj repo rename new-name
fj repo rename new-name -R owner/old-name
```
### Releases
```bash
# List releases
fgj release list
fj release list
# View a release (or use "latest")
fgj release view v1.2.3
fj release view v1.2.3
# Create a release with notes and optional assets
fgj release create v1.2.3 -t "v1.2.3" -n "Release notes" ./dist/app.tar.gz
fj release create v1.2.3 -t "v1.2.3" -n "Release notes" ./dist/app.tar.gz
# Upload assets to an existing release
fgj release upload v1.2.3 ./dist/app.tar.gz --clobber
fj release upload v1.2.3 ./dist/app.tar.gz --clobber
# Delete a release (keeps the Git tag)
fgj release delete v1.2.3
fj release delete v1.2.3
```
### Forgejo Actions
```bash
# List workflows
fgj actions workflow list
fj actions workflow list
# View a workflow
fgj actions workflow view ci.yml
fj actions workflow view ci.yml
# Run a workflow (trigger workflow_dispatch)
fgj actions workflow run deploy.yml
fj actions workflow run deploy.yml
# Run a workflow with inputs
fgj actions workflow run deploy.yml -f environment=production -f version=1.2.3
fj actions workflow run deploy.yml -f environment=production -f version=1.2.3
# Run a workflow on a specific branch
fgj actions workflow run deploy.yml -r feature-branch
fj actions workflow run deploy.yml -r feature-branch
# Enable or disable a workflow
fgj actions workflow enable ci.yml
fgj actions workflow disable ci.yml
fj actions workflow enable ci.yml
fj actions workflow disable ci.yml
# List workflow runs
fgj actions run list
fj actions run list
# View a specific run
fgj actions run view 123
fj actions run view 123
# View run with job details
fgj actions run view 123 --verbose
fj actions run view 123 --verbose
# View run logs
fgj actions run view 123 --log
fj actions run view 123 --log
# View specific job logs
fgj actions run view 123 --job 456 --log
fj actions run view 123 --job 456 --log
# Watch a run until completion
fgj actions run watch 123
fj actions run watch 123
# Rerun a workflow run
fgj actions run rerun 123
fj actions run rerun 123
# Cancel a running workflow
fgj actions run cancel 123
fj actions run cancel 123
# List secrets
fgj actions secret list
fj actions secret list
# Create a secret
fgj actions secret create MY_SECRET
fj actions secret create MY_SECRET
# Delete a secret
fgj actions secret delete MY_SECRET
fj actions secret delete MY_SECRET
# List variables
fgj actions variable list
fj actions variable list
# Get a variable
fgj actions variable get MY_VAR
fj actions variable get MY_VAR
# Create a variable
fgj actions variable create MY_VAR "value"
fj actions variable create MY_VAR "value"
# Update a variable
fgj actions variable update MY_VAR "new value"
fj actions variable update MY_VAR "new value"
# Delete a variable
fgj actions variable delete MY_VAR
fj actions variable delete MY_VAR
```
### Raw API Access
```bash
# GET request (auto-detects owner/repo from git context)
fgj api /repos/{owner}/{repo}/pulls
fj api /repos/{owner}/{repo}/pulls
# POST with fields
fgj api /repos/{owner}/{repo}/issues -X POST -f title="Bug report" -f body="Description"
fj api /repos/{owner}/{repo}/issues -X POST -f title="Bug report" -f body="Description"
# Explicit method and hostname
fgj api /repos/myorg/myrepo/labels --hostname my-forgejo.example.com
fj api /repos/myorg/myrepo/labels --hostname my-forgejo.example.com
# Read request body from file
fgj api /repos/{owner}/{repo}/issues -X POST --input issue.json
fj api /repos/{owner}/{repo}/issues -X POST --input issue.json
# Read from stdin
echo '{"title":"test"}' | fgj api /repos/{owner}/{repo}/issues -X POST --input -
echo '{"title":"test"}' | fj api /repos/{owner}/{repo}/issues -X POST --input -
# Include response headers
fgj api /repos/{owner}/{repo} -i
fj api /repos/{owner}/{repo} -i
# Suppress output (useful for DELETE)
fgj api /repos/{owner}/{repo}/issues/123 -X DELETE --silent
fj api /repos/{owner}/{repo}/issues/123 -X DELETE --silent
```
## Shell Completions and Man Pages
```bash
# Generate shell completion scripts
fgj completion bash > /etc/bash_completion.d/fgj
fgj completion zsh > "${fpath[1]}/_fgj"
fgj completion fish > ~/.config/fish/completions/fgj.fish
fj completion bash > /etc/bash_completion.d/fj
fj completion zsh > "${fpath[1]}/_fj"
fj completion fish > ~/.config/fish/completions/fj.fish
# Generate man pages to a directory
fgj manpages --dir ~/.local/share/man/man1
fj manpages --dir ~/.local/share/man/man1
```
## JSON Output
@ -418,15 +418,15 @@ fgj manpages --dir ~/.local/share/man/man1
Most list and view commands support `--json` for machine-readable output:
```bash
fgj pr list --json
fgj issue view 456 --json
fgj release list --json
fgj actions run list --json
fgj actions workflow view ci.yml --json
fj pr list --json
fj issue view 456 --json
fj release list --json
fj actions run list --json
fj actions workflow view ci.yml --json
# Get JSON output from PR comment/review
fgj pr comment 123 -b "LGTM" --json
fgj pr review 123 --approve -b "Ship it" --json
fj pr comment 123 -b "LGTM" --json
fj pr review 123 --approve -b "Ship it" --json
```
### Structured Error Output
@ -435,16 +435,16 @@ For machine consumption (ideal for AI agents and scripts), use `--json-errors` t
```bash
# Errors are written to stderr as JSON
fgj pr view 9999 --json-errors
fj pr view 9999 --json-errors
# stderr: {"error":{"code":"not_found","message":"...","status":404}}
# Combine with --json for fully machine-readable I/O
fgj pr list --json --json-errors
fj pr list --json --json-errors
```
## Configuration
Configuration is stored in `~/.config/fgj/config.yaml`:
Configuration is stored in `~/.config/fj/config.yaml`:
```yaml
hosts:
@ -466,9 +466,9 @@ hosts:
### Directory-Based Host Selection (`match_dirs`)
When you work with multiple Forgejo/Gitea instances, `fgj` can automatically select the right host based on your current working directory — no `--hostname` flag needed.
When you work with multiple Forgejo/Gitea instances, `fj` can automatically select the right host based on your current working directory — no `--hostname` flag needed.
Each host entry supports a `match_dirs` list of directory paths. When `fgj` can't determine the host from a git remote, it finds the host whose `match_dirs` entry is the **longest prefix match** for your current directory.
Each host entry supports a `match_dirs` list of directory paths. When `fj` can't determine the host from a git remote, it finds the host whose `match_dirs` entry is the **longest prefix match** for your current directory.
```yaml
hosts:
@ -494,12 +494,12 @@ hosts:
### Environment Variables
- `FGJ_HOST`: Override the default instance (auto-detected from git remote if not set)
- `FGJ_TOKEN`: Provide authentication token
- `FJ_HOST`: Override the default instance (auto-detected from git remote if not set)
- `FJ_TOKEN`: Provide authentication token
Hostname is resolved in this priority order:
1. Command-specific flags (e.g., `--hostname`)
2. `FGJ_HOST` environment variable
2. `FJ_HOST` environment variable
3. Auto-detected from git remote URL
4. `match_dirs` lookup (longest prefix match against current directory)
5. Default to `codeberg.org`
@ -509,15 +509,15 @@ Hostname is resolved in this priority order:
- `--hostname`: Specify instance for a command (overrides auto-detection and environment variables)
- `--config`: Use a custom config file
When working in a git repository, `fgj` automatically detects the instance from your origin remote URL, so you typically don't need to specify `--hostname` unless working with multiple instances.
When working in a git repository, `fj` automatically detects the instance from your origin remote URL, so you typically don't need to specify `--hostname` unless working with multiple instances.
## Use with AI Coding Agents
`fgj` is designed to work seamlessly with AI coding agents like Claude Code. Use `--json` and `--json-errors` for fully machine-readable I/O:
`fj` is designed to work seamlessly with AI coding agents like Claude Code. Use `--json` and `--json-errors` for fully machine-readable I/O:
```bash
# Create PR from agent's changes
fgj pr create -R owner/repo -t "feat: add new feature" -b "$(cat <<EOF
fj pr create -R owner/repo -t "feat: add new feature" -b "$(cat <<EOF
## Summary
- Added new feature X
- Fixed bug Y
@ -527,29 +527,29 @@ EOF
)" --json
# Check PR status during development
fgj pr list -R owner/repo --state open --json
fj pr list -R owner/repo --state open --json
# Review a PR diff, then approve
fgj pr diff 123
fgj pr review 123 --approve -b "LGTM" --json
fj pr diff 123
fj pr review 123 --approve -b "LGTM" --json
# Post review feedback
fgj pr comment 123 -b "Consider using a map here for O(1) lookup" --json
fj pr comment 123 -b "Consider using a map here for O(1) lookup" --json
# Request changes with detailed feedback
fgj pr review 123 --request-changes --body-file feedback.md --json
fj pr review 123 --request-changes --body-file feedback.md --json
# Use raw API for anything not covered by commands
fgj api /repos/{owner}/{repo}/topics --json-errors
fgj api /repos/{owner}/{repo}/labels -X POST -f name=agent-reviewed -f color="#00ff00"
fj api /repos/{owner}/{repo}/topics --json-errors
fj api /repos/{owner}/{repo}/labels -X POST -f name=agent-reviewed -f color="#00ff00"
# Fully machine-readable error handling
fgj pr view 9999 --json --json-errors 2>errors.json
fj pr view 9999 --json --json-errors 2>errors.json
```
## Supported Instances
`fgj` works with any Forgejo or Gitea instance, including:
`fj` works with any Forgejo or Gitea instance, including:
- Self-hosted Forgejo instances
- Self-hosted Gitea instances
@ -557,11 +557,11 @@ fgj pr view 9999 --json --json-errors 2>errors.json
## Contributing
Contributions are welcome! Please feel free to submit a Pull Request at [forgejo.zerova.net/public/fgj-sid](https://forgejo.zerova.net/public/fgj-sid).
Contributions are welcome! Please feel free to submit a Pull Request at [forgejo.zerova.net/public/fj](https://forgejo.zerova.net/public/fj).
## Missing Features / Roadmap
`fgj` aims to be a drop-in replacement for `gh` when working with Forgejo and Gitea instances. While we've implemented the core features, some `gh` commands are not yet available:
`fj` aims to be a drop-in replacement for `gh` when working with Forgejo and Gitea instances. While we've implemented the core features, some `gh` commands are not yet available:
**Not Yet Implemented:**
- `run delete` - Delete a workflow run
@ -576,7 +576,7 @@ We welcome contributions to implement any of these features!
## Acknowledgments
Based on [fgj by romaintb](https://codeberg.org/romaintb/fgj). Enhanced with agentic dev features for AI-assisted workflows.
Based on [fj by romaintb](https://codeberg.org/romaintb/fj). Enhanced with agentic dev features for AI-assisted workflows.
## License

View file

@ -10,8 +10,8 @@ import (
"code.gitea.io/sdk/gitea"
"github.com/spf13/cobra"
"forgejo.zerova.net/public/fgj-sid/internal/api"
"forgejo.zerova.net/public/fgj-sid/internal/config"
"forgejo.zerova.net/public/fj/internal/api"
"forgejo.zerova.net/public/fj/internal/config"
)
// ActionRun represents a workflow run
@ -87,146 +87,224 @@ var actionsCmd = &cobra.Command{
Long: "View and manage workflows, runs, secrets, and variables for Forgejo Actions in your repositories.",
}
// Run commands (compatible with gh run)
var runCmd = &cobra.Command{
Use: "run",
Short: "View and manage workflow runs",
Long: "List, view, and manage workflow runs.",
// Run and Workflow command trees are built via factory functions
// (newRunCmd / newWorkflowCmd) so cmd/aliases.go can build an identical
// top-level tree under rootCmd without duplicating Use/Short/Long/Example/
// flag declarations. Single source of truth — drift impossible.
// newRunCmd builds the `run` subtree. parentLabel is interpolated into the
// parent's Short/Long so the alias-tree variant can advertise itself as
// "alias for 'actions run'" without diverging on the children.
func newRunCmd(parentLabel string) *cobra.Command {
cmd := &cobra.Command{
Use: "run",
Short: "View and manage workflow runs" + parentLabel,
Long: "List, view, and manage workflow runs." + parentLabel,
}
cmd.AddCommand(newRunListCmd())
cmd.AddCommand(newRunViewCmd())
cmd.AddCommand(newRunWatchCmd())
cmd.AddCommand(newRunRerunCmd())
cmd.AddCommand(newRunCancelCmd())
return cmd
}
var runListCmd = &cobra.Command{
Use: "list",
Short: "List recent workflow runs",
Long: "List recent workflow runs for a repository.",
Example: ` # List recent workflow runs
fgj actions run list
func newRunListCmd() *cobra.Command {
c := &cobra.Command{
Use: "list",
Short: "List recent workflow runs",
Long: "List recent workflow runs for a repository.",
Example: ` # List recent workflow runs
fj actions run list
# List runs with a custom limit
fgj actions run list -L 50
fj actions run list -L 50
# Output as JSON
fgj actions run list --json`,
RunE: runRunList,
fj actions run list --json`,
RunE: runRunList,
}
addRepoFlags(c)
c.Flags().IntP("limit", "L", 20, "Maximum number of runs to list")
addJSONFlags(c, "Output workflow runs as JSON")
return c
}
var runViewCmd = &cobra.Command{
Use: "view <run-id>",
Short: "View a workflow run",
Long: "View details about a specific workflow run.",
Example: ` # View a workflow run
fgj actions run view 123
func newRunViewCmd() *cobra.Command {
c := &cobra.Command{
Use: "view <run-id>",
Short: "View a workflow run",
Long: "View details about a specific workflow run.",
Example: ` # View a workflow run
fj actions run view 123
# View with job details
fgj actions run view 123 -v
fj actions run view 123 -v
# View logs for a specific job
fgj actions run view 123 --job 456 --log
fj actions run view 123 --job 456 --log
# View only failed logs
fgj actions run view 123 --log-failed`,
Args: cobra.ExactArgs(1),
RunE: runRunView,
fj actions run view 123 --log-failed`,
Args: cobra.ExactArgs(1),
RunE: runRunView,
}
addRepoFlags(c)
c.Flags().BoolP("verbose", "v", false, "Show job steps")
c.Flags().BoolP("log", "", false, "View full log for either a run or specific job")
c.Flags().StringP("job", "j", "", "View a specific job ID from a run")
c.Flags().BoolP("log-failed", "", false, "View the log for any failed steps in a run or specific job")
addJSONFlags(c, "Output workflow run as JSON")
return c
}
var runWatchCmd = &cobra.Command{
Use: "watch <run-id>",
Short: "Watch a workflow run",
Long: "Poll a workflow run until it completes.",
Example: ` # Watch a run until it completes
fgj actions run watch 123
func newRunWatchCmd() *cobra.Command {
c := &cobra.Command{
Use: "watch <run-id>",
Short: "Watch a workflow run",
Long: "Poll a workflow run until it completes.",
Example: ` # Watch a run until it completes
fj actions run watch 123
# Watch with a custom polling interval
fgj actions run watch 123 -i 10s`,
Args: cobra.ExactArgs(1),
RunE: runRunWatch,
fj actions run watch 123 -i 10s`,
Args: cobra.ExactArgs(1),
RunE: runRunWatch,
}
addRepoFlags(c)
c.Flags().DurationP("interval", "i", 5*time.Second, "Polling interval")
return c
}
var runRerunCmd = &cobra.Command{
Use: "rerun <run-id>",
Short: "Rerun a workflow run",
Long: "Trigger a rerun for a specific workflow run.",
Example: ` # Rerun a failed workflow run
fgj actions run rerun 123`,
Args: cobra.ExactArgs(1),
RunE: runRunRerun,
func newRunRerunCmd() *cobra.Command {
c := &cobra.Command{
Use: "rerun <run-id>",
Short: "Rerun a workflow run",
Long: "Trigger a rerun for a specific workflow run.",
Example: ` # Rerun a failed workflow run
fj actions run rerun 123`,
Args: cobra.ExactArgs(1),
RunE: runRunRerun,
}
addRepoFlags(c)
return c
}
var runCancelCmd = &cobra.Command{
Use: "cancel <run-id>",
Short: "Cancel a workflow run",
Long: "Cancel a running workflow run.",
Example: ` # Cancel a running workflow
fgj actions run cancel 123`,
Args: cobra.ExactArgs(1),
RunE: runRunCancel,
func newRunCancelCmd() *cobra.Command {
c := &cobra.Command{
Use: "cancel <run-id>",
Short: "Cancel a workflow run",
Long: "Cancel a running workflow run.",
Example: ` # Cancel a running workflow
fj actions run cancel 123`,
Args: cobra.ExactArgs(1),
RunE: runRunCancel,
}
addRepoFlags(c)
return c
}
// Workflow commands
var workflowCmd = &cobra.Command{
Use: "workflow",
Short: "Manage workflows",
Long: "List, view, and run workflows.",
// newWorkflowCmd builds the `workflow` subtree. parentLabel is interpolated
// the same way as newRunCmd's, so the alias variant can self-identify.
func newWorkflowCmd(parentLabel string) *cobra.Command {
cmd := &cobra.Command{
Use: "workflow",
Short: "Manage workflows" + parentLabel,
Long: "List, view, and run workflows." + parentLabel,
}
cmd.AddCommand(newWorkflowListCmd())
cmd.AddCommand(newWorkflowViewCmd())
cmd.AddCommand(newWorkflowRunCmd())
cmd.AddCommand(newWorkflowEnableCmd())
cmd.AddCommand(newWorkflowDisableCmd())
return cmd
}
var workflowListCmd = &cobra.Command{
Use: "list",
Short: "List workflows",
Long: "List all workflows in a repository.",
Example: ` # List all workflows
fgj actions workflow list
func newWorkflowListCmd() *cobra.Command {
c := &cobra.Command{
Use: "list",
Short: "List workflows",
Long: "List all workflows in a repository.",
Example: ` # List all workflows
fj actions workflow list
# List workflows as JSON
fgj actions workflow list --json
fj actions workflow list --json
# List workflows for a specific repo
fgj actions workflow list -R owner/repo`,
RunE: runWorkflowList,
fj actions workflow list -R owner/repo`,
RunE: runWorkflowList,
}
addRepoFlags(c)
c.Flags().IntP("limit", "L", 20, "Maximum number of workflows to list")
addJSONFlags(c, "Output workflows as JSON")
return c
}
var workflowViewCmd = &cobra.Command{
Use: "view <workflow>",
Short: "View a workflow",
Long: "View details about a specific workflow. You can specify the workflow by name or filename.",
Example: ` # View a workflow by filename
fgj actions workflow view ci.yml
func newWorkflowViewCmd() *cobra.Command {
c := &cobra.Command{
Use: "view <workflow>",
Short: "View a workflow",
Long: "View details about a specific workflow. You can specify the workflow by name or filename.",
Example: ` # View a workflow by filename
fj actions workflow view ci.yml
# View as JSON
fgj actions workflow view ci.yml --json`,
Args: cobra.ExactArgs(1),
RunE: runWorkflowView,
fj actions workflow view ci.yml --json`,
Args: cobra.ExactArgs(1),
RunE: runWorkflowView,
}
addRepoFlags(c)
addJSONFlags(c, "Output workflow as JSON")
return c
}
var workflowRunCmd = &cobra.Command{
Use: "run <workflow>",
Short: "Run a workflow",
Long: "Trigger a workflow_dispatch event for a workflow. The workflow must support the workflow_dispatch trigger.",
Example: ` # Trigger a workflow on the default branch
fgj actions workflow run deploy.yml
func newWorkflowRunCmd() *cobra.Command {
c := &cobra.Command{
Use: "run <workflow>",
Short: "Run a workflow",
Long: "Trigger a workflow_dispatch event for a workflow. The workflow must support the workflow_dispatch trigger.",
Example: ` # Trigger a workflow on the default branch
fj actions workflow run deploy.yml
# Trigger on a specific branch with input parameters
fgj actions workflow run deploy.yml -r staging -f environment=staging -f version=1.2.3`,
Args: cobra.ExactArgs(1),
RunE: runWorkflowRun,
fj actions workflow run deploy.yml -r staging -f environment=staging -f version=1.2.3`,
Args: cobra.ExactArgs(1),
RunE: runWorkflowRun,
}
addRepoFlags(c)
c.Flags().StringP("ref", "r", "", "Branch or tag name to run the workflow on (defaults to repository's default branch)")
c.Flags().StringSliceP("field", "f", nil, "Add a string parameter in key=value format (can be used multiple times)")
c.Flags().StringSliceP("raw-field", "F", nil, "Add a string parameter in key=value format, reading from file if value starts with @ (can be used multiple times)")
return c
}
var workflowEnableCmd = &cobra.Command{
Use: "enable <workflow>",
Short: "Enable a workflow",
Long: "Enable a workflow so it can be triggered.\n\nNote: This feature requires Forgejo 15.0+ or Gitea 1.24+.\nFor older versions, use the web UI to enable workflows.",
Example: ` # Enable a workflow
fgj actions workflow enable ci.yml`,
Args: cobra.ExactArgs(1),
RunE: runWorkflowEnable,
func newWorkflowEnableCmd() *cobra.Command {
c := &cobra.Command{
Use: "enable <workflow>",
Short: "Enable a workflow",
Long: "Enable a workflow so it can be triggered.\n\nNote: This feature requires Forgejo 15.0+ or Gitea 1.24+.\nFor older versions, use the web UI to enable workflows.",
Example: ` # Enable a workflow
fj actions workflow enable ci.yml`,
Args: cobra.ExactArgs(1),
RunE: runWorkflowEnable,
}
addRepoFlags(c)
return c
}
var workflowDisableCmd = &cobra.Command{
Use: "disable <workflow>",
Short: "Disable a workflow",
Long: "Disable a workflow so it cannot be triggered.\n\nNote: This feature requires Forgejo 15.0+ or Gitea 1.24+.\nFor older versions, use the web UI to disable workflows.",
Example: ` # Disable a workflow
fgj actions workflow disable ci.yml`,
Args: cobra.ExactArgs(1),
RunE: runWorkflowDisable,
func newWorkflowDisableCmd() *cobra.Command {
c := &cobra.Command{
Use: "disable <workflow>",
Short: "Disable a workflow",
Long: "Disable a workflow so it cannot be triggered.\n\nNote: This feature requires Forgejo 15.0+ or Gitea 1.24+.\nFor older versions, use the web UI to disable workflows.",
Example: ` # Disable a workflow
fj actions workflow disable ci.yml`,
Args: cobra.ExactArgs(1),
RunE: runWorkflowDisable,
}
addRepoFlags(c)
return c
}
// Secret commands
@ -241,10 +319,10 @@ var actionsSecretListCmd = &cobra.Command{
Short: "List repository secrets",
Long: "List all secrets for a repository.",
Example: ` # List all secrets
fgj actions secret list
fj actions secret list
# List secrets for a specific repo
fgj actions secret list -R owner/repo`,
fj actions secret list -R owner/repo`,
RunE: runActionsSecretList,
}
@ -253,10 +331,10 @@ var actionsSecretCreateCmd = &cobra.Command{
Short: "Create or update a repository secret",
Long: "Create or update a secret for Forgejo Actions. The secret value will be read from stdin.",
Example: ` # Create a secret (will prompt for value)
fgj actions secret create DEPLOY_TOKEN
fj actions secret create DEPLOY_TOKEN
# Create a secret for a specific repo
fgj actions secret create API_KEY -R owner/repo`,
fj actions secret create API_KEY -R owner/repo`,
Args: cobra.ExactArgs(1),
RunE: runActionsSecretCreate,
}
@ -266,7 +344,7 @@ var actionsSecretDeleteCmd = &cobra.Command{
Short: "Delete a repository secret",
Long: "Delete a secret from Forgejo Actions.",
Example: ` # Delete a secret
fgj actions secret delete DEPLOY_TOKEN`,
fj actions secret delete DEPLOY_TOKEN`,
Args: cobra.ExactArgs(1),
RunE: runActionsSecretDelete,
}
@ -283,10 +361,10 @@ var actionsVariableListCmd = &cobra.Command{
Short: "List repository variables",
Long: "List all variables for a repository.",
Example: ` # List all variables
fgj actions variable list
fj actions variable list
# List variables for a specific repo
fgj actions variable list -R owner/repo`,
fj actions variable list -R owner/repo`,
RunE: runActionsVariableList,
}
@ -295,7 +373,7 @@ var actionsVariableGetCmd = &cobra.Command{
Short: "Get a repository variable",
Long: "Get the value of a specific repository variable.",
Example: ` # Get a variable value
fgj actions variable get ENVIRONMENT`,
fj actions variable get ENVIRONMENT`,
Args: cobra.ExactArgs(1),
RunE: runActionsVariableGet,
}
@ -305,10 +383,10 @@ var actionsVariableCreateCmd = &cobra.Command{
Short: "Create a repository variable",
Long: "Create a new variable for Forgejo Actions.",
Example: ` # Create a variable
fgj actions variable create ENVIRONMENT production
fj actions variable create ENVIRONMENT production
# Create a variable for a specific repo
fgj actions variable create NODE_VERSION 20 -R owner/repo`,
fj actions variable create NODE_VERSION 20 -R owner/repo`,
Args: cobra.ExactArgs(2),
RunE: runActionsVariableCreate,
}
@ -318,7 +396,7 @@ var actionsVariableUpdateCmd = &cobra.Command{
Short: "Update a repository variable",
Long: "Update an existing variable for Forgejo Actions.",
Example: ` # Update a variable
fgj actions variable update ENVIRONMENT staging`,
fj actions variable update ENVIRONMENT staging`,
Args: cobra.ExactArgs(2),
RunE: runActionsVariableUpdate,
}
@ -328,7 +406,7 @@ var actionsVariableDeleteCmd = &cobra.Command{
Short: "Delete a repository variable",
Long: "Delete a variable from Forgejo Actions.",
Example: ` # Delete a variable
fgj actions variable delete ENVIRONMENT`,
fj actions variable delete ENVIRONMENT`,
Args: cobra.ExactArgs(1),
RunE: runActionsVariableDelete,
}
@ -336,21 +414,10 @@ var actionsVariableDeleteCmd = &cobra.Command{
func init() {
rootCmd.AddCommand(actionsCmd)
// Add run commands (gh run compatible)
actionsCmd.AddCommand(runCmd)
runCmd.AddCommand(runListCmd)
runCmd.AddCommand(runViewCmd)
runCmd.AddCommand(runWatchCmd)
runCmd.AddCommand(runRerunCmd)
runCmd.AddCommand(runCancelCmd)
// Add workflow commands (gh workflow compatible)
actionsCmd.AddCommand(workflowCmd)
workflowCmd.AddCommand(workflowListCmd)
workflowCmd.AddCommand(workflowViewCmd)
workflowCmd.AddCommand(workflowRunCmd)
workflowCmd.AddCommand(workflowEnableCmd)
workflowCmd.AddCommand(workflowDisableCmd)
// Run and Workflow trees come from the factory functions defined above
// so cmd/aliases.go can build identical top-level trees under rootCmd.
actionsCmd.AddCommand(newRunCmd(""))
actionsCmd.AddCommand(newWorkflowCmd(""))
// Add secret commands
actionsCmd.AddCommand(actionsSecretCmd)
@ -366,34 +433,6 @@ func init() {
actionsVariableCmd.AddCommand(actionsVariableUpdateCmd)
actionsVariableCmd.AddCommand(actionsVariableDeleteCmd)
// Add flags for run commands
addRepoFlags(runListCmd)
runListCmd.Flags().IntP("limit", "L", 20, "Maximum number of runs to list")
addJSONFlags(runListCmd, "Output workflow runs as JSON")
addRepoFlags(runViewCmd)
runViewCmd.Flags().BoolP("verbose", "v", false, "Show job steps")
runViewCmd.Flags().BoolP("log", "", false, "View full log for either a run or specific job")
runViewCmd.Flags().StringP("job", "j", "", "View a specific job ID from a run")
runViewCmd.Flags().BoolP("log-failed", "", false, "View the log for any failed steps in a run or specific job")
addJSONFlags(runViewCmd, "Output workflow run as JSON")
addRepoFlags(runWatchCmd)
runWatchCmd.Flags().DurationP("interval", "i", 5*time.Second, "Polling interval")
addRepoFlags(runRerunCmd)
addRepoFlags(runCancelCmd)
// Add flags for workflow commands
addRepoFlags(workflowListCmd)
workflowListCmd.Flags().IntP("limit", "L", 20, "Maximum number of workflows to list")
addJSONFlags(workflowListCmd, "Output workflows as JSON")
addRepoFlags(workflowViewCmd)
addJSONFlags(workflowViewCmd, "Output workflow as JSON")
addRepoFlags(workflowRunCmd)
addRepoFlags(workflowEnableCmd)
addRepoFlags(workflowDisableCmd)
workflowRunCmd.Flags().StringP("ref", "r", "", "Branch or tag name to run the workflow on (defaults to repository's default branch)")
workflowRunCmd.Flags().StringSliceP("field", "f", nil, "Add a string parameter in key=value format (can be used multiple times)")
workflowRunCmd.Flags().StringSliceP("raw-field", "F", nil, "Add a string parameter in key=value format, reading from file if value starts with @ (can be used multiple times)")
// Add flags for secret commands
addRepoFlags(actionsSecretListCmd)
addRepoFlags(actionsSecretCreateCmd)

View file

@ -1,142 +1,16 @@
package cmd
import (
"time"
"github.com/spf13/cobra"
)
// Top-level aliases for "actions run" and "actions workflow" commands,
// matching gh CLI's command structure (e.g., "fgj run list" instead of "fgj actions run list").
// Top-level aliases for "actions run" and "actions workflow" — matches gh
// CLI's ergonomics so users can type `fj run list` and `fj workflow list`
// instead of `fj actions run list`.
//
// Both trees are built from the same factory functions defined in
// `cmd/actions.go` (newRunCmd / newWorkflowCmd), which means flags and
// help text are guaranteed identical between the two paths. Previously
// this file rebuilt parallel trees by hand and silently drifted (the
// `--json` Bool/string mismatch was the symptom that surfaced).
func init() {
// --- run alias ---
runAliasCmd := &cobra.Command{
Use: "run",
Short: "View and manage workflow runs (alias for 'actions run')",
Long: "List, view, and manage workflow runs.\n\nThis is a top-level alias for 'actions run'.",
}
runAliasListCmd := &cobra.Command{
Use: "list",
Short: "List recent workflow runs",
Long: "List recent workflow runs for a repository.",
RunE: runRunList,
}
addRepoFlags(runAliasListCmd)
runAliasListCmd.Flags().IntP("limit", "L", 20, "Maximum number of runs to list")
runAliasListCmd.Flags().Bool("json", false, "Output workflow runs as JSON")
runAliasViewCmd := &cobra.Command{
Use: "view <run-id>",
Short: "View a workflow run",
Long: "View details about a specific workflow run.",
Args: cobra.ExactArgs(1),
RunE: runRunView,
}
addRepoFlags(runAliasViewCmd)
runAliasViewCmd.Flags().BoolP("verbose", "v", false, "Show job steps")
runAliasViewCmd.Flags().BoolP("log", "", false, "View full log for either a run or specific job")
runAliasViewCmd.Flags().StringP("job", "j", "", "View a specific job ID from a run")
runAliasViewCmd.Flags().BoolP("log-failed", "", false, "View the log for any failed steps in a run or specific job")
runAliasViewCmd.Flags().Bool("json", false, "Output workflow run as JSON")
runAliasWatchCmd := &cobra.Command{
Use: "watch <run-id>",
Short: "Watch a workflow run",
Long: "Poll a workflow run until it completes.",
Args: cobra.ExactArgs(1),
RunE: runRunWatch,
}
addRepoFlags(runAliasWatchCmd)
runAliasWatchCmd.Flags().DurationP("interval", "i", 5*time.Second, "Polling interval")
runAliasRerunCmd := &cobra.Command{
Use: "rerun <run-id>",
Short: "Rerun a workflow run",
Long: "Trigger a rerun for a specific workflow run.",
Args: cobra.ExactArgs(1),
RunE: runRunRerun,
}
addRepoFlags(runAliasRerunCmd)
runAliasCancelCmd := &cobra.Command{
Use: "cancel <run-id>",
Short: "Cancel a workflow run",
Long: "Cancel a running workflow run.",
Args: cobra.ExactArgs(1),
RunE: runRunCancel,
}
addRepoFlags(runAliasCancelCmd)
runAliasCmd.AddCommand(runAliasListCmd)
runAliasCmd.AddCommand(runAliasViewCmd)
runAliasCmd.AddCommand(runAliasWatchCmd)
runAliasCmd.AddCommand(runAliasRerunCmd)
runAliasCmd.AddCommand(runAliasCancelCmd)
rootCmd.AddCommand(runAliasCmd)
// --- workflow alias ---
workflowAliasCmd := &cobra.Command{
Use: "workflow",
Short: "Manage workflows (alias for 'actions workflow')",
Long: "List, view, and run workflows.\n\nThis is a top-level alias for 'actions workflow'.",
}
workflowAliasListCmd := &cobra.Command{
Use: "list",
Short: "List workflows",
Long: "List all workflows in a repository.",
RunE: runWorkflowList,
}
addRepoFlags(workflowAliasListCmd)
workflowAliasListCmd.Flags().IntP("limit", "L", 20, "Maximum number of workflows to list")
workflowAliasListCmd.Flags().Bool("json", false, "Output workflows as JSON")
workflowAliasViewCmd := &cobra.Command{
Use: "view <workflow>",
Short: "View a workflow",
Long: "View details about a specific workflow. You can specify the workflow by name or filename.",
Args: cobra.ExactArgs(1),
RunE: runWorkflowView,
}
addRepoFlags(workflowAliasViewCmd)
workflowAliasViewCmd.Flags().Bool("json", false, "Output workflow as JSON")
workflowAliasRunCmd := &cobra.Command{
Use: "run <workflow>",
Short: "Run a workflow",
Long: "Trigger a workflow_dispatch event for a workflow. The workflow must support the workflow_dispatch trigger.",
Args: cobra.ExactArgs(1),
RunE: runWorkflowRun,
}
addRepoFlags(workflowAliasRunCmd)
workflowAliasRunCmd.Flags().StringP("ref", "r", "", "Branch or tag name to run the workflow on (defaults to repository's default branch)")
workflowAliasRunCmd.Flags().StringSliceP("field", "f", nil, "Add a string parameter in key=value format (can be used multiple times)")
workflowAliasRunCmd.Flags().StringSliceP("raw-field", "F", nil, "Add a string parameter in key=value format, reading from file if value starts with @ (can be used multiple times)")
workflowAliasEnableCmd := &cobra.Command{
Use: "enable <workflow>",
Short: "Enable a workflow",
Long: "Enable a workflow so it can be triggered.\n\nNote: This feature requires Forgejo 15.0+ or Gitea 1.24+.\nFor older versions, use the web UI to enable workflows.",
Args: cobra.ExactArgs(1),
RunE: runWorkflowEnable,
}
addRepoFlags(workflowAliasEnableCmd)
workflowAliasDisableCmd := &cobra.Command{
Use: "disable <workflow>",
Short: "Disable a workflow",
Long: "Disable a workflow so it cannot be triggered.\n\nNote: This feature requires Forgejo 15.0+ or Gitea 1.24+.\nFor older versions, use the web UI to disable workflows.",
Args: cobra.ExactArgs(1),
RunE: runWorkflowDisable,
}
addRepoFlags(workflowAliasDisableCmd)
workflowAliasCmd.AddCommand(workflowAliasListCmd)
workflowAliasCmd.AddCommand(workflowAliasViewCmd)
workflowAliasCmd.AddCommand(workflowAliasRunCmd)
workflowAliasCmd.AddCommand(workflowAliasEnableCmd)
workflowAliasCmd.AddCommand(workflowAliasDisableCmd)
rootCmd.AddCommand(workflowAliasCmd)
rootCmd.AddCommand(newRunCmd(" (alias for 'actions run')"))
rootCmd.AddCommand(newWorkflowCmd(" (alias for 'actions workflow')"))
}

View file

@ -6,15 +6,23 @@ import (
"fmt"
"io"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"forgejo.zerova.net/public/fgj-sid/internal/config"
"forgejo.zerova.net/public/fgj-sid/internal/git"
"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"
)
// 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",
@ -26,16 +34,22 @@ 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
fgj api /repos/{owner}/{repo}/pulls
fj api /repos/{owner}/{repo}/pulls
# Create an issue
fgj api /repos/{owner}/{repo}/issues --method POST --field title=Bug --field body="It broke"
fj api /repos/{owner}/{repo}/issues --method POST --field title=Bug --field body="It broke"
# Get a specific user
fgj api /users/johndoe
fj api /users/johndoe
# Use raw body from stdin
echo '{"title":"test"}' | fgj api /repos/{owner}/{repo}/issues --input -`,
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'
# Project the response down to specific fields
fj api /repos/{owner}/{repo} --json-fields full_name,description,private`,
Args: cobra.ExactArgs(1),
RunE: runAPI,
}
@ -50,6 +64,40 @@ func init() {
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")
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 {
@ -139,15 +187,28 @@ func runAPI(cmd *cobra.Command, args []string) error {
body = bytes.NewReader(bodyBytes)
}
// Build URL
baseURL := "https://" + host.Hostname + "/api/v1"
if !strings.HasPrefix(endpoint, "/") {
endpoint = "/" + endpoint
// 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)
}
url := baseURL + endpoint
if endpointURL.Scheme != "" || endpointURL.Host != "" {
return fmt.Errorf("endpoint must be a path, not a full URL: %s", endpoint)
}
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
req, err := http.NewRequest(method, url, body)
req, err := http.NewRequest(method, final.String(), body)
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
@ -170,20 +231,42 @@ func runAPI(cmd *cobra.Command, args []string) error {
req.Header.Set(strings.TrimSpace(key), strings.TrimSpace(value))
}
// Execute request
ios.StartSpinner("Requesting...")
httpClient := &http.Client{}
resp, err := httpClient.Do(req)
ios.StopSpinner()
if err != nil {
return fmt.Errorf("failed to perform request: %w", err)
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 {
return err
}
defer func() { _ = resp.Body.Close() }()
// Print response headers if requested
if include {
fmt.Fprintf(ios.Out, "%s %s\n", resp.Proto, resp.Status)
for key, values := range resp.Header {
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)
}
@ -191,39 +274,99 @@ func runAPI(cmd *cobra.Command, args []string) error {
fmt.Fprintln(ios.Out)
}
// Read response body
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("failed to read response body: %w", err)
}
// Handle non-2xx status codes
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
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)
}
}
return fmt.Errorf("API request failed with status %d", resp.StatusCode)
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 != "" {
// Forgejo emits same-origin next-links in practice, but a buggy
// or hostile upstream could redirect us to a foreign host — at
// which point we'd leak the bearer token. Validate origin (and
// resolve relative URLs against `base`) before forwarding auth.
parsedNext, err := url.Parse(nextURL)
if err != nil {
return fmt.Errorf("invalid Link rel=\"next\" URL %q: %w", nextURL, err)
}
if !parsedNext.IsAbs() {
parsedNext = base.ResolveReference(parsedNext)
}
if parsedNext.Scheme != "https" || parsedNext.Host != host.Hostname {
return fmt.Errorf("paginated next URL %s is not same-origin as https://%s; refusing to forward credentials", parsedNext.String(), host.Hostname)
}
nextReq, err := http.NewRequest(http.MethodGet, parsedNext.String(), 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", parsedNext.String(), 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
}
// Pretty-print JSON, or output raw if not JSON
contentType := resp.Header.Get("Content-Type")
if strings.Contains(contentType, "json") || json.Valid(respBody) {
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 {
enc := json.NewEncoder(ios.Out)
enc.SetIndent("", " ")
return enc.Encode(parsed)
return writeJSON(parsed)
}
}
// Raw output for non-JSON responses
_, err = ios.Out.Write(respBody)
return err
}

View file

@ -7,8 +7,8 @@ import (
"strings"
"syscall"
"forgejo.zerova.net/public/fgj-sid/internal/api"
"forgejo.zerova.net/public/fgj-sid/internal/config"
"forgejo.zerova.net/public/fj/internal/api"
"forgejo.zerova.net/public/fj/internal/config"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"golang.org/x/term"
@ -16,7 +16,7 @@ import (
var authCmd = &cobra.Command{
Use: "auth",
Short: "Authenticate fgj with a Forgejo instance",
Short: "Authenticate fj with a Forgejo instance",
Long: "Manage authentication state for Forgejo instances.",
}
@ -55,16 +55,25 @@ func init() {
authCmd.AddCommand(authLogoutCmd)
authCmd.AddCommand(authTokenCmd)
authLoginCmd.Flags().String("hostname", "", "Forgejo instance hostname (e.g., codeberg.org)")
authLoginCmd.Flags().StringP("token", "t", "", "Personal access token")
authLogoutCmd.Flags().String("hostname", "", "Forgejo instance hostname (e.g., codeberg.org)")
authTokenCmd.Flags().String("hostname", "", "Forgejo instance hostname (e.g., codeberg.org)")
// --hostname is a persistent flag on rootCmd (cmd/root.go). Don't
// re-declare it on auth subcommands — local flags shadow the persistent
// one, so `fj --hostname=X auth login` and `fj auth login --hostname=X`
// went through different code paths (viper vs. local).
authLoginCmd.Flags().StringP("token", "t", "", "Personal access token (DEPRECATED: visible in `ps auxe`; pipe via stdin instead)")
}
func runAuthLogin(cmd *cobra.Command, args []string) error {
hostname, _ := cmd.Flags().GetString("hostname")
token, _ := cmd.Flags().GetString("token")
// Tokens passed via --token end up on the process command line and
// therefore in `ps auxe` and shell history. Warn loudly so users notice.
// (Don't refuse the flag — too disruptive for scripts that already use it.)
if cmd.Flags().Changed("token") {
fmt.Fprintln(ios.ErrOut, "warning: --token puts the token on the command line (visible in `ps auxe` and shell history)")
fmt.Fprintln(ios.ErrOut, " prefer omitting --token and pasting at the prompt, or piping via stdin.")
}
reader := bufio.NewReader(os.Stdin)
if hostname == "" {
@ -132,7 +141,7 @@ func runAuthStatus(cmd *cobra.Command, args []string) error {
if len(cfg.Hosts) == 0 {
fmt.Fprintln(ios.Out, "Not authenticated with any Forgejo instances")
fmt.Fprintln(ios.Out, "Run 'fgj auth login' to authenticate")
fmt.Fprintln(ios.Out, "Run 'fj auth login' to authenticate")
return nil
}
@ -188,7 +197,7 @@ func resolveAuthHostname(cfg *config.Config, hostname string) (string, error) {
hostname = viper.GetString("hostname")
}
if hostname == "" {
hostname = os.Getenv("FGJ_HOST")
hostname = config.EnvWithFallback("FJ_HOST", "FGJ_HOST")
}
if hostname == "" {
hostname = getDetectedHost()

View file

@ -11,7 +11,7 @@ import (
var completionCmd = &cobra.Command{
Use: "completion [bash|zsh|fish|powershell]",
Short: "Generate shell completion scripts",
Long: "Generate shell completion scripts for fgj.",
Long: "Generate shell completion scripts for fj.",
Args: cobra.MatchAll(cobra.ExactArgs(1), cobra.OnlyValidArgs),
ValidArgs: []string{"bash", "zsh", "fish", "powershell"},
DisableFlagsInUseLine: true,

View file

@ -3,10 +3,9 @@ package cmd
import (
"encoding/json"
"errors"
"fmt"
"strings"
"forgejo.zerova.net/public/fgj-sid/internal/api"
"forgejo.zerova.net/public/fj/internal/api"
)
// Error codes for structured error output.
@ -25,9 +24,15 @@ type CLIError struct {
Message string `json:"message"`
Detail string `json:"detail,omitempty"`
Status int `json:"status,omitempty"`
// Hint is a separate field so JSON consumers get clean structure and
// the human renderer can append "Hint: ..." without polluting Message.
Hint string `json:"hint,omitempty"`
}
func (e *CLIError) Error() string {
if e.Hint != "" {
return e.Message + "\nHint: " + e.Hint
}
return e.Message
}
@ -42,46 +47,59 @@ func NewAPIError(status int, message string) *CLIError {
}
// ContextualError wraps common errors with helpful hints.
//
// Auth/404 hints come exclusively from a typed *api.APIError now — we used
// to substring-match "401"/"403" against the rendered error string, which
// would trigger an "auth login" hint for any error mentioning issue #403.
// If the API client doesn't surface APIError, no hint is added; that's a
// signal to fix the client wrapper, not to layer regex on top.
func ContextualError(err error) error {
if err == nil {
return nil
}
msg := err.Error()
// Check for API errors with status codes
var apiErr *api.APIError
if errors.As(err, &apiErr) {
switch {
case apiErr.StatusCode == 401 || apiErr.StatusCode == 403:
return fmt.Errorf("%w\nHint: Try authenticating with: fgj auth login", err)
case apiErr.StatusCode == 404:
return fmt.Errorf("%w\nHint: Resource not found. Check the repository and number are correct.", err)
}
// If the error chain already holds a CLIError, leave it — it owns its
// Code/Hint already.
var cErr *CLIError
if errors.As(err, &cErr) {
return err
}
// Check for network/connection errors
switch {
case strings.Contains(msg, "no such host"):
return fmt.Errorf("%w\nHint: Check your internet connection and that the host is correct.", err)
case strings.Contains(msg, "connection refused"):
return fmt.Errorf("%w\nHint: Check your internet connection and that the host is correct.", err)
var apiErr *api.APIError
if errors.As(err, &apiErr) {
c := &CLIError{
Code: ErrAPIError,
Message: err.Error(),
Status: apiErr.StatusCode,
Detail: apiErr.Body,
}
switch apiErr.StatusCode {
case 401, 403:
c.Code = ErrAuthRequired
c.Hint = "Try authenticating with: fj auth login"
case 404:
c.Code = ErrNotFound
c.Hint = "Resource not found. Check the repository and number are correct."
}
return c
}
// Check for string-based status code patterns (from wrapped errors)
// Plain network errors come back as fmt.Errorf strings from net/http.
msg := err.Error()
switch {
case strings.Contains(msg, "401") || strings.Contains(msg, "403"):
if strings.Contains(msg, "authentication") || strings.Contains(msg, "unauthorized") || strings.Contains(msg, "forbidden") {
return fmt.Errorf("%w\nHint: Try authenticating with: fgj auth login", err)
case strings.Contains(msg, "no such host"),
strings.Contains(msg, "connection refused"),
strings.Contains(msg, "i/o timeout"):
return &CLIError{
Code: ErrNetworkError,
Message: msg,
Hint: "Check your internet connection and that the host is correct.",
}
}
return err
}
// writeJSONError writes a structured JSON error to stderr.
// It attempts to extract structured info from known error types.
// WriteJSONError writes a structured JSON error to stderr.
// It is exported for use from main.go.
func WriteJSONError(err error) {
@ -90,7 +108,9 @@ func WriteJSONError(err error) {
Message: err.Error(),
}
// Try to extract structured info from the error chain.
// Try to extract structured info from the error chain. Prefer CLIError
// (which carries Hint cleanly) over APIError so a wrapped CLIError
// keeps its structured fields.
var apiErr *api.APIError
var cErr *CLIError
@ -105,8 +125,6 @@ func WriteJSONError(err error) {
cliErr.Code = ErrAuthRequired
case apiErr.StatusCode == 404:
cliErr.Code = ErrNotFound
default:
cliErr.Code = ErrAPIError
}
}
@ -114,3 +132,6 @@ func WriteJSONError(err error) {
enc.SetIndent("", " ")
_ = enc.Encode(cliErr)
}
// Compile-time check that CLIError satisfies the standard error interface.
var _ error = (*CLIError)(nil)

View file

@ -1,5 +1,5 @@
package cmd
import "forgejo.zerova.net/public/fgj-sid/internal/iostreams"
import "forgejo.zerova.net/public/fj/internal/iostreams"
var ios = iostreams.New()

View file

@ -6,9 +6,9 @@ import (
"strings"
"code.gitea.io/sdk/gitea"
"forgejo.zerova.net/public/fgj-sid/internal/api"
"forgejo.zerova.net/public/fgj-sid/internal/config"
"forgejo.zerova.net/public/fgj-sid/internal/text"
"forgejo.zerova.net/public/fj/internal/api"
"forgejo.zerova.net/public/fj/internal/config"
"forgejo.zerova.net/public/fj/internal/text"
"github.com/spf13/cobra"
)
@ -23,13 +23,13 @@ var issueListCmd = &cobra.Command{
Short: "List issues",
Long: "List issues in a repository.",
Example: ` # List open issues
fgj issue list
fj issue list
# List closed issues for a specific repo
fgj issue list -s closed -R owner/repo
fj issue list -s closed -R owner/repo
# Output as JSON
fgj issue list --json`,
fj issue list --json`,
RunE: runIssueList,
}
@ -38,16 +38,16 @@ var issueViewCmd = &cobra.Command{
Short: "View an issue",
Long: "Display detailed information about an issue.",
Example: ` # View issue #42
fgj issue view 42
fj issue view 42
# View using URL
fgj issue view https://codeberg.org/owner/repo/issues/42
fj issue view https://codeberg.org/owner/repo/issues/42
# Open in browser
fgj issue view 42 --web
fj issue view 42 --web
# View an issue from a specific repo as JSON
fgj issue view 42 -R owner/repo --json`,
fj issue view 42 -R owner/repo --json`,
Args: cobra.ExactArgs(1),
RunE: runIssueView,
}
@ -57,10 +57,10 @@ var issueCreateCmd = &cobra.Command{
Short: "Create an issue",
Long: "Create a new issue.",
Example: ` # Create an issue with a title
fgj issue create -t "Fix login bug"
fj issue create -t "Fix login bug"
# Create an issue with title, body, and labels
fgj issue create -t "Add dark mode" -b "We need a dark theme" -l feature -l ui`,
fj issue create -t "Add dark mode" -b "We need a dark theme" -l feature -l ui`,
RunE: runIssueCreate,
}
@ -69,10 +69,10 @@ var issueCommentCmd = &cobra.Command{
Short: "Add a comment to an issue",
Long: "Add a comment to an existing issue.",
Example: ` # Add a comment to issue #42
fgj issue comment 42 -b "This is fixed in the latest release"
fj issue comment 42 -b "This is fixed in the latest release"
# Comment on an issue in a specific repo
fgj issue comment 10 -b "Confirmed on my end" -R owner/repo`,
fj issue comment 10 -b "Confirmed on my end" -R owner/repo`,
Args: cobra.ExactArgs(1),
RunE: runIssueComment,
}
@ -82,10 +82,10 @@ var issueCloseCmd = &cobra.Command{
Short: "Close an issue",
Long: "Close an existing issue.",
Example: ` # Close issue #42
fgj issue close 42
fj issue close 42
# Close with a comment
fgj issue close 42 -c "Fixed in commit abc1234"`,
fj issue close 42 -c "Fixed in commit abc1234"`,
Args: cobra.ExactArgs(1),
RunE: runIssueClose,
}
@ -95,7 +95,7 @@ var issueReopenCmd = &cobra.Command{
Short: "Reopen an issue",
Long: "Reopen a closed issue.",
Example: ` # Reopen issue #42
fgj issue reopen 42`,
fj issue reopen 42`,
Args: cobra.ExactArgs(1),
RunE: runIssueReopen,
}
@ -105,10 +105,10 @@ var issueDeleteCmd = &cobra.Command{
Short: "Delete an issue",
Long: "Delete an issue permanently.",
Example: ` # Delete issue #42
fgj issue delete 42
fj issue delete 42
# Delete without confirmation
fgj issue delete 42 -y`,
fj issue delete 42 -y`,
Args: cobra.ExactArgs(1),
RunE: runIssueDelete,
}
@ -118,16 +118,16 @@ var issueEditCmd = &cobra.Command{
Short: "Edit an issue",
Long: "Edit an existing issue's title, body, or state.",
Example: ` # Update the title of issue #42
fgj issue edit 42 -t "Updated title"
fj issue edit 42 -t "Updated title"
# Reopen a closed issue
fgj issue edit 42 -s open
fj issue edit 42 -s open
# Add and remove labels
fgj issue edit 42 --add-label bug --remove-label wontfix
fj issue edit 42 --add-label bug --remove-label wontfix
# Add a dependency
fgj issue edit 42 --add-dependency 10`,
fj issue edit 42 --add-dependency 10`,
Args: cobra.ExactArgs(1),
RunE: runIssueEdit,
}
@ -221,13 +221,24 @@ func runIssueList(cmd *cobra.Command, args []string) error {
}
ios.StartSpinner("Fetching issues...")
issues, _, err := client.ListRepoIssues(owner, name, gitea.ListIssueOption{
State: stateType,
Labels: labels,
KeyWord: search,
CreatedBy: author,
AssignedBy: assignee,
ListOptions: gitea.ListOptions{PageSize: limit},
// ListRepoIssues returns both issues AND PRs (we filter PRs out below).
// Pull more than `limit` so post-filter we still have `limit` real issues
// — overshoot 2x as a heuristic. paginateGitea(0, ...) would be safer
// but spends extra round-trips; keep it bounded.
fetchLimit := limit * 2
if fetchLimit < 50 {
fetchLimit = 50
}
issues, err := paginateGitea(fetchLimit, func(page, pageSize int) ([]*gitea.Issue, error) {
batch, _, err := client.ListRepoIssues(owner, name, gitea.ListIssueOption{
State: stateType,
Labels: labels,
KeyWord: search,
CreatedBy: author,
AssignedBy: assignee,
ListOptions: gitea.ListOptions{Page: page, PageSize: pageSize},
})
return batch, err
})
ios.StopSpinner()
if err != nil {
@ -240,6 +251,9 @@ func runIssueList(cmd *cobra.Command, args []string) error {
nonPRIssues = append(nonPRIssues, issue)
}
}
if limit > 0 && len(nonPRIssues) > limit {
nonPRIssues = nonPRIssues[:limit]
}
if wantJSON(cmd) {
return outputJSON(cmd, nonPRIssues)

View file

@ -10,47 +10,48 @@ import (
)
// addJSONFlags adds --json, --json-fields, and --jq flags to a command.
// --json is an optional-value string flag:
// - --json (no value) → output all fields as JSON
// - --json title,state → output only those fields (gh-compatible)
//
// --json-fields is kept as a backwards-compatible alias.
// Flag design (BREAKING CHANGE — the previous --json was a string with
// NoOptDefVal=" " so `--json=fields` projected and `--json` alone meant
// "everything". That sentinel produced a `--json string[=" "]` in --help
// and left users guessing about the equals sign). Now:
//
// - --json : Bool. "Output the response as JSON." (all fields)
// - --json-fields … : String. Comma-separated projection.
// - --jq … : String. jq expression filter.
//
// --json and --json-fields are mutually exclusive — pick one. --jq composes
// with either (or neither, in which case it implies "as JSON").
func addJSONFlags(cmd *cobra.Command, jsonDesc string) {
f := cmd.Flags()
f.String("json", "", jsonDesc)
f.Lookup("json").NoOptDefVal = " " // space sentinel: flag present with no value
f.String("json-fields", "", "Comma-separated list of JSON fields to include")
f.Bool("json", false, jsonDesc)
f.String("json-fields", "", "Output as JSON, projecting only these comma-separated fields")
f.String("jq", "", "Filter JSON output using a jq expression")
cmd.MarkFlagsMutuallyExclusive("json", "json-fields")
}
// wantJSON returns true if the user requested JSON output via --json, --json-fields, or --jq.
// wantJSON returns true if the user requested JSON output via --json,
// --json-fields, or --jq.
func wantJSON(cmd *cobra.Command) bool {
if j, _ := cmd.Flags().GetString("json"); j != "" {
return true
}
if jq, _ := cmd.Flags().GetString("jq"); jq != "" {
if b, _ := cmd.Flags().GetBool("json"); b {
return true
}
if f, _ := cmd.Flags().GetString("json-fields"); f != "" {
return true
}
if jq, _ := cmd.Flags().GetString("jq"); jq != "" {
return true
}
return false
}
// outputJSON writes a value as JSON, respecting --json, --json-fields, and --jq flags.
// outputJSON writes a value as JSON, respecting --json-fields and --jq.
// --json (the bool) is the "no projection, no filter" signal handled
// implicitly: when neither --json-fields nor --jq is set, the whole value
// is emitted.
func outputJSON(cmd *cobra.Command, value any) error {
jsonVal, _ := cmd.Flags().GetString("json")
jsonFields, _ := cmd.Flags().GetString("json-fields")
fields, _ := cmd.Flags().GetString("json-fields")
jqExpr, _ := cmd.Flags().GetString("jq")
fields := ""
jsonVal = strings.TrimSpace(jsonVal)
if jsonVal != "" {
fields = jsonVal
} else if jsonFields != "" {
fields = jsonFields
}
return writeJSONFiltered(value, fields, jqExpr)
}

View file

@ -5,8 +5,8 @@ import (
"strings"
"code.gitea.io/sdk/gitea"
"forgejo.zerova.net/public/fgj-sid/internal/api"
"forgejo.zerova.net/public/fgj-sid/internal/config"
"forgejo.zerova.net/public/fj/internal/api"
"forgejo.zerova.net/public/fj/internal/config"
"github.com/spf13/cobra"
)
@ -21,13 +21,13 @@ var labelListCmd = &cobra.Command{
Short: "List labels for a repository",
Long: "List all labels defined in a repository.",
Example: ` # List labels for the current repository
fgj label list
fj label list
# List labels for a specific repository
fgj label list -R owner/repo
fj label list -R owner/repo
# Output as JSON
fgj label list --json`,
fj label list --json`,
RunE: runLabelList,
}
@ -36,13 +36,13 @@ var labelCreateCmd = &cobra.Command{
Short: "Create a label",
Long: "Create a new label in a repository.",
Example: ` # Create a label with a color
fgj label create bug -c ff0000
fj label create bug -c ff0000
# Create a label with color and description
fgj label create feature -c 00ff00 -d "New feature request"
fj label create feature -c 00ff00 -d "New feature request"
# Create a label in a specific repository
fgj label create urgent -c ff0000 -R owner/repo`,
fj label create urgent -c ff0000 -R owner/repo`,
Args: cobra.ExactArgs(1),
RunE: runLabelCreate,
}
@ -52,13 +52,13 @@ var labelEditCmd = &cobra.Command{
Short: "Edit a label",
Long: "Edit an existing label in a repository.",
Example: ` # Rename a label
fgj label edit bug --name bugfix
fj label edit bug --name bugfix
# Change the color of a label
fgj label edit bug -c 00ff00
fj label edit bug -c 00ff00
# Update description
fgj label edit bug -d "Something is broken"`,
fj label edit bug -d "Something is broken"`,
Args: cobra.ExactArgs(1),
RunE: runLabelEdit,
}
@ -68,13 +68,13 @@ var labelDeleteCmd = &cobra.Command{
Short: "Delete a label",
Long: "Delete a label from a repository.",
Example: ` # Delete a label
fgj label delete bug
fj label delete bug
# Delete without confirmation
fgj label delete bug -y
fj label delete bug -y
# Delete a label from a specific repository
fgj label delete bug -R owner/repo`,
fj label delete bug -R owner/repo`,
Args: cobra.ExactArgs(1),
RunE: runLabelDelete,
}

View file

@ -12,7 +12,7 @@ import (
var manpagesCmd = &cobra.Command{
Use: "manpages",
Short: "Generate manpages",
Long: "Generate manpages for fgj commands.",
Long: "Generate manpages for fj commands.",
RunE: func(cmd *cobra.Command, args []string) error {
dir, _ := cmd.Flags().GetString("dir")
if dir == "" {
@ -29,7 +29,7 @@ var manpagesCmd = &cobra.Command{
}
header := &doc.GenManHeader{
Title: "FGJ",
Title: "FJ",
Section: "1",
}

View file

@ -7,9 +7,9 @@ import (
"time"
"code.gitea.io/sdk/gitea"
"forgejo.zerova.net/public/fgj-sid/internal/api"
"forgejo.zerova.net/public/fgj-sid/internal/config"
"forgejo.zerova.net/public/fgj-sid/internal/text"
"forgejo.zerova.net/public/fj/internal/api"
"forgejo.zerova.net/public/fj/internal/config"
"forgejo.zerova.net/public/fj/internal/text"
"github.com/spf13/cobra"
)
@ -24,13 +24,13 @@ var milestoneListCmd = &cobra.Command{
Short: "List milestones",
Long: "List milestones in a repository.",
Example: ` # List open milestones
fgj milestone list
fj milestone list
# List all milestones for a specific repo
fgj milestone list -R owner/repo --state all
fj milestone list -R owner/repo --state all
# Output as JSON
fgj milestone list --json`,
fj milestone list --json`,
RunE: runMilestoneList,
}
@ -39,16 +39,16 @@ var milestoneViewCmd = &cobra.Command{
Short: "View a milestone",
Long: "Display detailed information about a milestone.",
Example: ` # View by ID
fgj milestone view 1
fj milestone view 1
# View by title
fgj milestone view "v1.0"
fj milestone view "v1.0"
# Open in browser
fgj milestone view "v1.0" --web
fj milestone view "v1.0" --web
# Output as JSON
fgj milestone view "v1.0" --json`,
fj milestone view "v1.0" --json`,
Args: cobra.ExactArgs(1),
RunE: runMilestoneView,
}
@ -58,13 +58,13 @@ var milestoneCreateCmd = &cobra.Command{
Short: "Create a milestone",
Long: "Create a new milestone.",
Example: ` # Create a simple milestone
fgj milestone create "v1.0"
fj milestone create "v1.0"
# Create with description and due date
fgj milestone create "v2.0" -d "Second release" --due 2026-06-01
fj milestone create "v2.0" -d "Second release" --due 2026-06-01
# Output as JSON
fgj milestone create "v1.0" --json`,
fj milestone create "v1.0" --json`,
Args: cobra.ExactArgs(1),
RunE: runMilestoneCreate,
}
@ -74,13 +74,13 @@ var milestoneEditCmd = &cobra.Command{
Short: "Edit a milestone",
Long: "Edit an existing milestone's title, description, due date, or state.",
Example: ` # Rename a milestone
fgj milestone edit "v1.0" --title "v1.1"
fj milestone edit "v1.0" --title "v1.1"
# Close a milestone
fgj milestone edit "v1.0" --state closed
fj milestone edit "v1.0" --state closed
# Update due date
fgj milestone edit 1 --due 2026-12-31`,
fj milestone edit 1 --due 2026-12-31`,
Args: cobra.ExactArgs(1),
RunE: runMilestoneEdit,
}
@ -90,13 +90,13 @@ var milestoneDeleteCmd = &cobra.Command{
Short: "Delete a milestone",
Long: "Delete an existing milestone.",
Example: ` # Delete by title
fgj milestone delete "v1.0"
fj milestone delete "v1.0"
# Delete by ID
fgj milestone delete 1
fj milestone delete 1
# Delete without confirmation
fgj milestone delete "v1.0" -y`,
fj milestone delete "v1.0" -y`,
Args: cobra.ExactArgs(1),
RunE: runMilestoneDelete,
}

43
cmd/paginate.go Normal file
View file

@ -0,0 +1,43 @@
package cmd
// paginateGitea walks pages of a gitea SDK list method until the response
// is short (last page) or we hit limit. limit=0 means unlimited.
//
// Forgejo/Gitea caps PageSize at 50, so naive `PageSize: limit` for limit > 50
// silently truncated results across most `fj * list` commands. This helper
// centralizes the loop so every list command paginates consistently.
//
// fetch is called with (page, pageSize) and returns the items for that page.
// The 1-based `page` matches the gitea SDK convention.
func paginateGitea[T any](limit int, fetch func(page, pageSize int) ([]T, error)) ([]T, error) {
const maxPageSize = 50
pageSize := maxPageSize
if limit > 0 && limit < pageSize {
pageSize = limit
}
var all []T
for page := 1; ; page++ {
if limit > 0 && len(all) >= limit {
break
}
batch, err := fetch(page, pageSize)
if err != nil {
return all, err
}
if len(batch) == 0 {
break
}
all = append(all, batch...)
// A short page (less than the requested size) is the conventional
// "you've reached the end" signal — saves one extra round-trip.
if len(batch) < pageSize {
break
}
}
if limit > 0 && len(all) > limit {
all = all[:limit]
}
return all, nil
}

View file

@ -7,10 +7,10 @@ import (
"strings"
"code.gitea.io/sdk/gitea"
"forgejo.zerova.net/public/fgj-sid/internal/api"
"forgejo.zerova.net/public/fgj-sid/internal/config"
gitpkg "forgejo.zerova.net/public/fgj-sid/internal/git"
"forgejo.zerova.net/public/fgj-sid/internal/text"
"forgejo.zerova.net/public/fj/internal/api"
"forgejo.zerova.net/public/fj/internal/config"
gitpkg "forgejo.zerova.net/public/fj/internal/git"
"forgejo.zerova.net/public/fj/internal/text"
"github.com/spf13/cobra"
)
@ -26,13 +26,13 @@ var prListCmd = &cobra.Command{
Short: "List pull requests",
Long: "List pull requests in a repository.",
Example: ` # List open pull requests
fgj pr list
fj pr list
# List all pull requests for a specific repo
fgj pr list -s all -R owner/repo
fj pr list -s all -R owner/repo
# Output as JSON
fgj pr list --json`,
fj pr list --json`,
RunE: runPRList,
}
@ -41,19 +41,19 @@ var prViewCmd = &cobra.Command{
Short: "View a pull request",
Long: "Display detailed information about a pull request.",
Example: ` # View pull request #5
fgj pr view 5
fj pr view 5
# View using URL
fgj pr view https://codeberg.org/owner/repo/pulls/5
fj pr view https://codeberg.org/owner/repo/pulls/5
# View PR for current branch
fgj pr view
fj pr view
# Open in browser
fgj pr view 5 --web
fj pr view 5 --web
# View as JSON
fgj pr view 5 --json`,
fj pr view 5 --json`,
Args: cobra.MaximumNArgs(1),
RunE: runPRView,
}
@ -63,13 +63,13 @@ var prCreateCmd = &cobra.Command{
Short: "Create a pull request",
Long: "Create a new pull request.",
Example: ` # Create a pull request from feature branch to main
fgj pr create -t "Add login page" -H feature/login
fj pr create -t "Add login page" -H feature/login
# Create with body and custom base branch
fgj pr create -t "Fix bug" -b "Closes #42" -H fix/bug -B develop
fj pr create -t "Fix bug" -b "Closes #42" -H fix/bug -B develop
# Create and self-assign
fgj pr create -t "Update docs" -H docs/update -a @me`,
fj pr create -t "Update docs" -H docs/update -a @me`,
RunE: runPRCreate,
}
@ -78,16 +78,16 @@ var prMergeCmd = &cobra.Command{
Short: "Merge a pull request",
Long: "Merge a pull request.",
Example: ` # Merge pull request #5
fgj pr merge 5
fj pr merge 5
# Squash merge
fgj pr merge 5 --merge-method squash
fj pr merge 5 --merge-method squash
# Rebase merge
fgj pr merge 5 --merge-method rebase
fj pr merge 5 --merge-method rebase
# Merge without confirmation
fgj pr merge 5 -y`,
fj pr merge 5 -y`,
Args: cobra.ExactArgs(1),
RunE: runPRMerge,
}
@ -97,10 +97,10 @@ var prCloseCmd = &cobra.Command{
Short: "Close a pull request",
Long: "Close a pull request without merging.",
Example: ` # Close PR #5
fgj pr close 5
fj pr close 5
# Close with a comment
fgj pr close 5 -c "Won't merge, superseded by #10"`,
fj pr close 5 -c "Won't merge, superseded by #10"`,
Args: cobra.ExactArgs(1),
RunE: runPRClose,
}
@ -110,7 +110,7 @@ var prReopenCmd = &cobra.Command{
Short: "Reopen a pull request",
Long: "Reopen a closed pull request.",
Example: ` # Reopen PR #5
fgj pr reopen 5`,
fj pr reopen 5`,
Args: cobra.ExactArgs(1),
RunE: runPRReopen,
}
@ -120,13 +120,13 @@ var prEditCmd = &cobra.Command{
Short: "Edit a pull request",
Long: "Edit a pull request's title, body, or metadata.",
Example: ` # Update the title of PR #5
fgj pr edit 5 -t "Updated title"
fj pr edit 5 -t "Updated title"
# Add assignees and labels
fgj pr edit 5 --add-assignee user1 --add-label bug
fj pr edit 5 --add-assignee user1 --add-label bug
# Remove a reviewer and set milestone
fgj pr edit 5 --remove-reviewer user2 --milestone "v1.0"`,
fj pr edit 5 --remove-reviewer user2 --milestone "v1.0"`,
Args: cobra.ExactArgs(1),
RunE: runPREdit,
}
@ -136,7 +136,7 @@ var prCheckoutCmd = &cobra.Command{
Short: "Check out a pull request locally",
Long: "Check out the head branch of a pull request.",
Example: ` # Check out PR #5
fgj pr checkout 5`,
fj pr checkout 5`,
Args: cobra.ExactArgs(1),
RunE: runPRCheckout,
}
@ -252,39 +252,32 @@ func runPRList(cmd *cobra.Command, args []string) error {
needsClientFilter := assignee != "" || author != "" || len(labels) > 0 || search != "" || draft || head != "" || base != ""
ios.StartSpinner("Fetching pull requests...")
// When client-side filtering is needed, pull pages until exhausted (no
// limit) so we can apply filters; otherwise paginate up to the user's
// limit. Either way, paginate — `PageSize: limit` capped at 50 silently.
fetchPage := func(page, pageSize int) ([]*gitea.PullRequest, error) {
batch, _, err := client.ListRepoPullRequests(owner, name, gitea.ListPullRequestsOptions{
State: stateType,
ListOptions: gitea.ListOptions{Page: page, PageSize: pageSize},
})
return batch, err
}
var prs []*gitea.PullRequest
if needsClientFilter {
page := 1
for {
batch, _, err := client.ListRepoPullRequests(owner, name, gitea.ListPullRequestsOptions{
State: stateType,
ListOptions: gitea.ListOptions{Page: page, PageSize: 50},
})
if err != nil {
ios.StopSpinner()
return fmt.Errorf("failed to list pull requests: %w", err)
prs, err = paginateGitea(0, fetchPage) // pull all, then filter + limit
if err == nil {
prs = filterPRs(prs, author, assignee, labels, search, draft, head, base)
if limit > 0 && len(prs) > limit {
prs = prs[:limit]
}
prs = append(prs, batch...)
if len(batch) < 50 {
break
}
page++
}
prs = filterPRs(prs, author, assignee, labels, search, draft, head, base)
if len(prs) > limit {
prs = prs[:limit]
}
} else {
prs, _, err = client.ListRepoPullRequests(owner, name, gitea.ListPullRequestsOptions{
State: stateType,
ListOptions: gitea.ListOptions{PageSize: limit},
})
if err != nil {
ios.StopSpinner()
return fmt.Errorf("failed to list pull requests: %w", err)
}
prs, err = paginateGitea(limit, fetchPage)
}
ios.StopSpinner()
if err != nil {
return fmt.Errorf("failed to list pull requests: %w", err)
}
if wantJSON(cmd) {
return outputJSON(cmd, prs)

View file

@ -4,9 +4,9 @@ import (
"fmt"
"code.gitea.io/sdk/gitea"
"forgejo.zerova.net/public/fgj-sid/internal/api"
"forgejo.zerova.net/public/fgj-sid/internal/config"
"forgejo.zerova.net/public/fgj-sid/internal/iostreams"
"forgejo.zerova.net/public/fj/internal/api"
"forgejo.zerova.net/public/fj/internal/config"
"forgejo.zerova.net/public/fj/internal/iostreams"
"github.com/spf13/cobra"
)
@ -15,10 +15,10 @@ var prChecksCmd = &cobra.Command{
Short: "Show CI status checks for a pull request",
Long: "Show the status of CI checks for a pull request.",
Example: ` # Show checks for PR #5
fgj pr checks 5
fj pr checks 5
# Output as JSON
fgj pr checks 5 --json`,
fj pr checks 5 --json`,
Args: cobra.ExactArgs(1),
RunE: runPRChecks,
}

View file

@ -4,8 +4,8 @@ import (
"fmt"
"strings"
"forgejo.zerova.net/public/fgj-sid/internal/api"
"forgejo.zerova.net/public/fgj-sid/internal/config"
"forgejo.zerova.net/public/fj/internal/api"
"forgejo.zerova.net/public/fj/internal/config"
"github.com/spf13/cobra"
)
@ -14,16 +14,16 @@ var prDiffCmd = &cobra.Command{
Short: "Show the diff for a pull request",
Long: "Fetch and display the diff for a pull request.",
Example: ` # View the diff for PR #123
fgj pr diff 123
fj pr diff 123
# Colorized diff output
fgj pr diff 123 --color always
fj pr diff 123 --color always
# Show only changed file names
fgj pr diff 123 --name-only
fj pr diff 123 --name-only
# Show diffstat summary
fgj pr diff 123 --stat`,
fj pr diff 123 --stat`,
Args: cobra.ExactArgs(1),
RunE: runPRDiff,
}

View file

@ -6,8 +6,8 @@ import (
"os"
"code.gitea.io/sdk/gitea"
"forgejo.zerova.net/public/fgj-sid/internal/api"
"forgejo.zerova.net/public/fgj-sid/internal/config"
"forgejo.zerova.net/public/fj/internal/api"
"forgejo.zerova.net/public/fj/internal/config"
"github.com/spf13/cobra"
)
@ -16,16 +16,16 @@ var prCommentCmd = &cobra.Command{
Short: "Add a comment to a pull request",
Long: "Add a comment to an existing pull request.",
Example: ` # Add a comment
fgj pr comment 123 -b "Looks good!"
fj pr comment 123 -b "Looks good!"
# Comment from a file
fgj pr comment 123 --body-file review-notes.md
fj pr comment 123 --body-file review-notes.md
# Comment from stdin
echo "LGTM" | fgj pr comment 123 --body-file -
echo "LGTM" | fj pr comment 123 --body-file -
# Output as JSON
fgj pr comment 123 -b "Nice work" --json`,
fj pr comment 123 -b "Nice work" --json`,
Args: cobra.ExactArgs(1),
RunE: runPRComment,
}
@ -35,16 +35,16 @@ var prReviewCmd = &cobra.Command{
Short: "Submit a review on a pull request",
Long: "Submit a review on a pull request. Exactly one of --approve, --request-changes, or --comment must be specified.",
Example: ` # Approve a PR
fgj pr review 123 --approve -b "LGTM"
fj pr review 123 --approve -b "LGTM"
# Request changes
fgj pr review 123 --request-changes -b "Please fix the error handling"
fj pr review 123 --request-changes -b "Please fix the error handling"
# Submit a review comment
fgj pr review 123 --comment -b "Some observations"
fj pr review 123 --comment -b "Some observations"
# Request changes with body from file
fgj pr review 123 --request-changes --body-file feedback.md`,
fj pr review 123 --request-changes --body-file feedback.md`,
Args: cobra.ExactArgs(1),
RunE: runPRReview,
}

View file

@ -9,9 +9,9 @@ import (
"time"
"code.gitea.io/sdk/gitea"
"forgejo.zerova.net/public/fgj-sid/internal/api"
"forgejo.zerova.net/public/fgj-sid/internal/config"
"forgejo.zerova.net/public/fgj-sid/internal/text"
"forgejo.zerova.net/public/fj/internal/api"
"forgejo.zerova.net/public/fj/internal/config"
"forgejo.zerova.net/public/fj/internal/text"
"github.com/spf13/cobra"
)
@ -27,13 +27,13 @@ var releaseListCmd = &cobra.Command{
Short: "List releases",
Long: "List releases in a repository.",
Example: ` # List releases
fgj release list
fj release list
# List only draft releases
fgj release list --draft
fj release list --draft
# Output as JSON with a custom limit
fgj release list --json --limit 10`,
fj release list --json --limit 10`,
RunE: runReleaseList,
}
@ -42,16 +42,16 @@ var releaseViewCmd = &cobra.Command{
Short: "View a release",
Long: "Display detailed information about a release.",
Example: ` # View a release by tag
fgj release view v1.0.0
fj release view v1.0.0
# View the latest release
fgj release view latest
fj release view latest
# Open in browser
fgj release view v1.0.0 --web
fj release view v1.0.0 --web
# Output as JSON
fgj release view v1.0.0 --json`,
fj release view v1.0.0 --json`,
Args: cobra.ExactArgs(1),
RunE: runReleaseView,
}
@ -61,16 +61,16 @@ var releaseCreateCmd = &cobra.Command{
Short: "Create a release",
Long: "Create a new release and optionally upload assets.",
Example: ` # Create a release
fgj release create v1.0.0
fj release create v1.0.0
# Create with title and notes
fgj release create v1.0.0 -t "First stable release" -n "Bug fixes and improvements"
fj release create v1.0.0 -t "First stable release" -n "Bug fixes and improvements"
# Create a draft prerelease with assets
fgj release create v2.0.0-rc1 --draft --prerelease dist/*.tar.gz
fj release create v2.0.0-rc1 --draft --prerelease dist/*.tar.gz
# Create from release notes file
fgj release create v1.0.0 -F CHANGELOG.md`,
fj release create v1.0.0 -F CHANGELOG.md`,
Args: cobra.MinimumNArgs(1),
RunE: runReleaseCreate,
}
@ -80,10 +80,10 @@ var releaseUploadCmd = &cobra.Command{
Short: "Upload release assets",
Long: "Upload assets to an existing release.",
Example: ` # Upload assets to a release
fgj release upload v1.0.0 dist/app-linux-amd64 dist/app-darwin-arm64
fj release upload v1.0.0 dist/app-linux-amd64 dist/app-darwin-arm64
# Upload to the latest release, overwriting existing assets
fgj release upload latest build/output.zip --clobber`,
fj release upload latest build/output.zip --clobber`,
Args: cobra.MinimumNArgs(2),
RunE: runReleaseUpload,
}
@ -93,13 +93,13 @@ var releaseDownloadCmd = &cobra.Command{
Short: "Download release assets",
Long: "Download assets from a release.",
Example: ` # Download all assets from a release
fgj release download v1.0.0
fj release download v1.0.0
# Download to a specific directory
fgj release download v1.0.0 -D ./downloads
fj release download v1.0.0 -D ./downloads
# Download a specific asset by name pattern
fgj release download v1.0.0 -p "*.tar.gz"`,
fj release download v1.0.0 -p "*.tar.gz"`,
Args: cobra.ExactArgs(1),
RunE: runReleaseDownload,
}
@ -109,13 +109,13 @@ var releaseDeleteCmd = &cobra.Command{
Short: "Delete a release",
Long: "Delete a release by tag, keeping its Git tag intact.",
Example: ` # Delete a release by tag
fgj release delete v1.0.0
fj release delete v1.0.0
# Delete the latest release
fgj release delete latest
fj release delete latest
# Delete without confirmation
fgj release delete v1.0.0 -y`,
fj release delete v1.0.0 -y`,
Args: cobra.ExactArgs(1),
RunE: runReleaseDelete,
}

View file

@ -8,9 +8,9 @@ import (
"strings"
"code.gitea.io/sdk/gitea"
"forgejo.zerova.net/public/fgj-sid/internal/api"
"forgejo.zerova.net/public/fgj-sid/internal/config"
"forgejo.zerova.net/public/fgj-sid/internal/text"
"forgejo.zerova.net/public/fj/internal/api"
"forgejo.zerova.net/public/fj/internal/config"
"forgejo.zerova.net/public/fj/internal/text"
"github.com/spf13/cobra"
)
@ -67,22 +67,22 @@ var repoEditCmd = &cobra.Command{
Short: "Edit repository settings",
Long: "Edit settings of an existing repository such as visibility, description, homepage, and default branch.",
Example: ` # Make a repository private
fgj repo edit owner/repo --private
fj repo edit owner/repo --private
# Make a repository public
fgj repo edit owner/repo --public
fj repo edit owner/repo --public
# Update description and homepage
fgj repo edit owner/repo -d "New description" --homepage https://example.com
fj repo edit owner/repo -d "New description" --homepage https://example.com
# Change default branch
fgj repo edit --default-branch develop
fj repo edit --default-branch develop
# Rename a repository
fgj repo edit owner/repo --name new-name
fj repo edit owner/repo --name new-name
# Edit current repo (auto-detected from git context)
fgj repo edit --public`,
fj repo edit --public`,
Args: cobra.MaximumNArgs(1),
RunE: runRepoEdit,
}
@ -90,12 +90,12 @@ var repoEditCmd = &cobra.Command{
var repoRenameCmd = &cobra.Command{
Use: "rename <new-name>",
Short: "Rename a repository",
Long: "Rename an existing repository. This is a shorthand for `fgj repo edit --name <new-name>`.",
Long: "Rename an existing repository. This is a shorthand for `fj repo edit --name <new-name>`.",
Example: ` # Rename current repo
fgj repo rename new-name
fj repo rename new-name
# Rename a specific repo
fgj repo rename new-name -R owner/old-name`,
fj repo rename new-name -R owner/old-name`,
Args: cobra.ExactArgs(1),
RunE: runRepoRename,
}
@ -216,17 +216,18 @@ func runRepoList(cmd *cobra.Command, args []string) error {
return fmt.Errorf("failed to get user info: %w", err)
}
repos, _, err := client.ListUserRepos(user.UserName, gitea.ListReposOptions{})
limit, _ := cmd.Flags().GetInt("limit")
repos, err := paginateGitea(limit, func(page, pageSize int) ([]*gitea.Repository, error) {
batch, _, err := client.ListUserRepos(user.UserName, gitea.ListReposOptions{
ListOptions: gitea.ListOptions{Page: page, PageSize: pageSize},
})
return batch, err
})
ios.StopSpinner()
if err != nil {
return fmt.Errorf("failed to list repositories: %w", err)
}
limit, _ := cmd.Flags().GetInt("limit")
if limit > 0 && len(repos) > limit {
repos = repos[:limit]
}
if wantJSON(cmd) {
return outputJSON(cmd, repos)
}

View file

@ -2,11 +2,14 @@ package cmd
import (
"fmt"
"io"
"os"
"path/filepath"
"strconv"
"strings"
"forgejo.zerova.net/public/fgj-sid/internal/git"
"forgejo.zerova.net/public/fj/internal/config"
"forgejo.zerova.net/public/fj/internal/git"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
@ -15,11 +18,11 @@ var cfgFile string
var jsonErrors bool
var rootCmd = &cobra.Command{
Use: "fgj",
Use: "fj",
Short: "Forgejo CLI tool - work seamlessly with Forgejo from the command line",
Long: `fgj is a command line tool for Forgejo instances (including Codeberg).
Long: `fj is a command line tool for Forgejo instances (including Codeberg).
It brings pull requests, issues, and other Forgejo concepts to the terminal.`,
Version: "0.3.1",
Version: "0.4.0",
SilenceErrors: true,
}
@ -35,7 +38,7 @@ func Execute() error {
func init() {
cobra.OnInitialize(initConfig)
rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.config/fgj/config.yaml)")
rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.config/fj/config.yaml)")
rootCmd.PersistentFlags().BoolVar(&jsonErrors, "json-errors", false, "output errors as structured JSON to stderr")
rootCmd.PersistentFlags().String("hostname", "", "Forgejo instance hostname")
_ = viper.BindPFlag("hostname", rootCmd.PersistentFlags().Lookup("hostname"))
@ -43,7 +46,12 @@ func init() {
func initConfig() {
if cfgFile != "" {
// Tell viper to load this file for env-style overrides AND make
// internal/config.Load()/.Save() use it (this is the load-bearing
// half — without SetExplicitConfigPath, --config was silently
// ignored by every auth-touching command).
viper.SetConfigFile(cfgFile)
config.SetExplicitConfigPath(cfgFile)
} else {
home, err := os.UserHomeDir()
if err != nil {
@ -51,8 +59,20 @@ func initConfig() {
os.Exit(1)
}
configDir := home + "/.config/fgj"
_ = os.MkdirAll(configDir, 0755)
configDir := home + "/.config/fj"
legacyDir := home + "/.config/fgj"
// Migrate from ~/.config/fgj/ if the new dir doesn't exist yet.
if _, err := os.Stat(configDir); os.IsNotExist(err) {
if info, err := os.Stat(legacyDir); err == nil && info.IsDir() {
if copyErr := migrateConfigDir(legacyDir, configDir); copyErr == nil {
fmt.Fprintln(ios.ErrOut, "notice: migrated config from ~/.config/fgj/ to ~/.config/fj/")
fmt.Fprintln(ios.ErrOut, " you can remove ~/.config/fgj/ when ready")
}
}
}
_ = os.MkdirAll(configDir, 0700)
viper.AddConfigPath(configDir)
viper.SetConfigType("yaml")
@ -60,9 +80,17 @@ func initConfig() {
}
viper.AutomaticEnv()
viper.SetEnvPrefix("FGJ")
viper.SetEnvPrefix("FJ")
_ = viper.ReadInConfig()
// If the resolved config exists with overly permissive mode, warn — the
// file holds API tokens. Don't fail-close; just nudge the user.
if path, err := config.GetConfigPath(); err == nil {
if info, statErr := os.Stat(path); statErr == nil && info.Mode()&0o077 != 0 {
fmt.Fprintf(ios.ErrOut, "warning: %s mode %o is world/group readable; tokens may leak. chmod 600 it.\n", path, info.Mode().Perm())
}
}
}
// parseRepo parses the repository string in the format "owner/name".
@ -127,3 +155,51 @@ func parseIssueArg(arg string) (int64, error) {
}
return strconv.ParseInt(arg, 10, 64)
}
// migrateConfigDir copies all files from src to dst (one level, no subdirs).
// Uses O_TRUNC so a partially-pre-existing dst file is fully replaced rather
// than having the legacy contents overwrite a prefix and leaving stale tail
// bytes — which for a YAML token store would silently corrupt config.
func migrateConfigDir(src, dst string) error {
if err := os.MkdirAll(dst, 0700); err != nil {
return err
}
entries, err := os.ReadDir(src)
if err != nil {
return err
}
for _, e := range entries {
if e.IsDir() {
continue
}
if err := copyOneConfigFile(filepath.Join(src, e.Name()), filepath.Join(dst, e.Name())); err != nil {
return err
}
}
return nil
}
func copyOneConfigFile(srcPath, dstPath string) (retErr error) {
in, err := os.Open(srcPath)
if err != nil {
return err
}
defer func() {
if cerr := in.Close(); retErr == nil {
retErr = cerr
}
}()
out, err := os.OpenFile(dstPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600)
if err != nil {
return err
}
defer func() {
if cerr := out.Close(); retErr == nil {
retErr = cerr
}
}()
_, err = io.Copy(out, in)
return err
}

View file

@ -7,9 +7,9 @@ import (
"net/url"
"time"
"forgejo.zerova.net/public/fgj-sid/internal/api"
"forgejo.zerova.net/public/fgj-sid/internal/config"
"forgejo.zerova.net/public/fgj-sid/internal/text"
"forgejo.zerova.net/public/fj/internal/api"
"forgejo.zerova.net/public/fj/internal/config"
"forgejo.zerova.net/public/fj/internal/text"
"github.com/spf13/cobra"
)
@ -61,13 +61,13 @@ var wikiListCmd = &cobra.Command{
Short: "List wiki pages",
Long: "List all wiki pages for a repository.",
Example: ` # List wiki pages for the current repo
fgj wiki list
fj wiki list
# List wiki pages for a specific repo
fgj wiki list -R owner/repo
fj wiki list -R owner/repo
# Output as JSON
fgj wiki list --json`,
fj wiki list --json`,
RunE: runWikiList,
}
@ -76,16 +76,16 @@ var wikiViewCmd = &cobra.Command{
Short: "View a wiki page",
Long: "Display the content of a wiki page.",
Example: ` # View a wiki page
fgj wiki view Home
fj wiki view Home
# Open in browser
fgj wiki view Home --web
fj wiki view Home --web
# View a wiki page as JSON (includes content)
fgj wiki view Home --json
fj wiki view Home --json
# View a wiki page from a specific repo
fgj wiki view "Getting-Started" -R owner/repo`,
fj wiki view "Getting-Started" -R owner/repo`,
Args: cobra.ExactArgs(1),
RunE: runWikiView,
}
@ -95,16 +95,16 @@ var wikiCreateCmd = &cobra.Command{
Short: "Create a wiki page",
Long: "Create a new wiki page in the repository.",
Example: ` # Create a wiki page with inline content
fgj wiki create "Getting Started" -b "# Welcome\nThis is the getting started guide."
fj wiki create "Getting Started" -b "# Welcome\nThis is the getting started guide."
# Create a wiki page from a file
fgj wiki create "Setup Guide" --body-file setup.md
fj wiki create "Setup Guide" --body-file setup.md
# Create a wiki page from stdin
echo "# FAQ" | fgj wiki create FAQ --body-file -
echo "# FAQ" | fj wiki create FAQ --body-file -
# Output as JSON
fgj wiki create "New Page" -b "Content here" --json`,
fj wiki create "New Page" -b "Content here" --json`,
Args: cobra.ExactArgs(1),
RunE: runWikiCreate,
}
@ -114,16 +114,16 @@ var wikiEditCmd = &cobra.Command{
Short: "Edit a wiki page",
Long: "Edit an existing wiki page in the repository.",
Example: ` # Edit a wiki page with new content
fgj wiki edit Home -b "# Updated Home\nNew content here."
fj wiki edit Home -b "# Updated Home\nNew content here."
# Edit a wiki page from a file
fgj wiki edit "Setup Guide" --body-file updated-setup.md
fj wiki edit "Setup Guide" --body-file updated-setup.md
# Edit a wiki page from stdin
cat new-content.md | fgj wiki edit Home --body-file -
cat new-content.md | fj wiki edit Home --body-file -
# Output as JSON
fgj wiki edit Home -b "Updated content" --json`,
fj wiki edit Home -b "Updated content" --json`,
Args: cobra.ExactArgs(1),
RunE: runWikiEdit,
}
@ -133,13 +133,13 @@ var wikiDeleteCmd = &cobra.Command{
Short: "Delete a wiki page",
Long: "Delete a wiki page from the repository.",
Example: ` # Delete a wiki page
fgj wiki delete "Old Page"
fj wiki delete "Old Page"
# Delete without confirmation
fgj wiki delete "Old Page" -y
fj wiki delete "Old Page" -y
# Delete a wiki page from a specific repo
fgj wiki delete "Outdated Guide" -R owner/repo`,
fj wiki delete "Outdated Guide" -R owner/repo`,
Args: cobra.ExactArgs(1),
RunE: runWikiDelete,
}
@ -266,10 +266,9 @@ func runWikiView(cmd *cobra.Command, args []string) error {
return fmt.Errorf("wiki page has no HTML URL")
}
jsonFlag, _ := cmd.Flags().GetBool("json")
if jsonFlag {
if wantJSON(cmd) {
page.Content = string(content)
return writeJSON(page)
return outputJSON(cmd, page)
}
if err := ios.StartPager(); err != nil {

2
go.mod
View file

@ -1,4 +1,4 @@
module forgejo.zerova.net/public/fgj-sid
module forgejo.zerova.net/public/fj
go 1.24.0

View file

@ -9,13 +9,19 @@ import (
"time"
"code.gitea.io/sdk/gitea"
"forgejo.zerova.net/public/fgj-sid/internal/config"
"forgejo.zerova.net/public/fj/internal/config"
)
var sharedHTTPClient = &http.Client{
// SharedHTTPClient is the package-wide HTTP client. Exported so other
// packages (notably cmd/api.go) can reuse the same timeout and connection
// pooling instead of constructing zero-value clients with no timeout.
var SharedHTTPClient = &http.Client{
Timeout: 30 * time.Second,
}
// Internal alias kept so existing call sites compile unchanged.
var sharedHTTPClient = SharedHTTPClient
type Client struct {
*gitea.Client
hostname string

View file

@ -3,7 +3,7 @@ package api
import (
"testing"
"forgejo.zerova.net/public/fgj-sid/internal/config"
"forgejo.zerova.net/public/fj/internal/config"
)
func TestClient_Hostname(t *testing.T) {

View file

@ -14,6 +14,17 @@ type Config struct {
Hosts map[string]HostConfig `yaml:"hosts"`
}
// explicitConfigPath, when non-empty, overrides the default config file
// location for both Load() and Save(). It's set by cmd/root.initConfig when
// the user passes --config <path>. Stored at package scope so existing
// call sites of config.Load()/c.Save() continue to work without each one
// having to know about the flag.
var explicitConfigPath string
// SetExplicitConfigPath wires a user-supplied --config path through to
// Load/Save. Pass "" to clear.
func SetExplicitConfigPath(p string) { explicitConfigPath = p }
type HostConfig struct {
Hostname string `yaml:"hostname"`
Token string `yaml:"token"`
@ -25,16 +36,19 @@ type HostConfig struct {
func GetConfigDir() (string, error) {
if xdgConfigHome := os.Getenv("XDG_CONFIG_HOME"); xdgConfigHome != "" {
return filepath.Join(xdgConfigHome, "fgj"), nil
return filepath.Join(xdgConfigHome, "fj"), nil
}
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(home, ".config", "fgj"), nil
return filepath.Join(home, ".config", "fj"), nil
}
func GetConfigPath() (string, error) {
if explicitConfigPath != "" {
return explicitConfigPath, nil
}
dir, err := GetConfigDir()
if err != nil {
return "", err
@ -131,7 +145,7 @@ func (c *Config) SaveToPath(path string) error {
// Priority order:
// 1. Explicitly provided hostname parameter
// 2. CLI flag (--hostname)
// 3. Environment variable (FGJ_HOST)
// 3. Environment variable (FJ_HOST, with FGJ_HOST fallback)
// 4. Auto-detected hostname from git remote
// 5. match_dirs lookup (longest prefix match)
// 6. Default to codeberg.org
@ -141,7 +155,7 @@ func (c *Config) GetHost(hostname string, detectedHost string, cwd string) (Host
}
if hostname == "" {
hostname = os.Getenv("FGJ_HOST")
hostname = EnvWithFallback("FJ_HOST", "FGJ_HOST")
}
if hostname == "" {
@ -228,6 +242,15 @@ func (c *Config) ResolveHostByPath(cwd string) string {
}
// expandHome replaces a leading ~ with the user's home directory.
// EnvWithFallback returns the value of the primary env var, falling back to
// the legacy name if the primary is unset. This eases the FGJ_ → FJ_ rename.
func EnvWithFallback(primary, legacy string) string {
if v := os.Getenv(primary); v != "" {
return v
}
return os.Getenv(legacy)
}
func expandHome(path string) string {
if path == "~" || strings.HasPrefix(path, "~/") {
home, err := os.UserHomeDir()

View file

@ -83,7 +83,7 @@ func TestGetConfigDir_XDG(t *testing.T) {
t.Fatalf("Unexpected error: %v", err)
}
expected := "/custom/config/fgj"
expected := "/custom/config/fj"
if dir != expected {
t.Errorf("Expected %q, got %q", expected, dir)
}
@ -439,7 +439,7 @@ func TestResolveHostByPath(t *testing.T) {
"forgejo.zerova.net": {
Hostname: "forgejo.zerova.net",
Token: "token1",
MatchDirs: []string{"/Users/sid/repos/fgj", "/Users/sid/repos/zerova"},
MatchDirs: []string{"/Users/sid/repos/fj", "/Users/sid/repos/zerova"},
},
"codeberg.org": {
Hostname: "codeberg.org",
@ -459,10 +459,10 @@ func TestResolveHostByPath(t *testing.T) {
cwd string
want string
}{
{"exact dir match", "/Users/sid/repos/fgj", "forgejo.zerova.net"},
{"nested dir match", "/Users/sid/repos/fgj/cmd/root.go", "forgejo.zerova.net"},
{"exact dir match", "/Users/sid/repos/fj", "forgejo.zerova.net"},
{"nested dir match", "/Users/sid/repos/fj/cmd/root.go", "forgejo.zerova.net"},
{"second match dir", "/Users/sid/repos/zerova/pkg", "forgejo.zerova.net"},
{"longest prefix wins over /", "/Users/sid/repos/fgj/internal", "forgejo.zerova.net"},
{"longest prefix wins over /", "/Users/sid/repos/fj/internal", "forgejo.zerova.net"},
{"/ as global catch-all", "/tmp", "codeberg.org"},
{"/ matches root itself", "/", "codeberg.org"},
{"no match_dirs host not selected", "/some/random/path", "codeberg.org"},
@ -512,7 +512,7 @@ func TestGetHost_MatchDirsIntegration(t *testing.T) {
"forgejo.zerova.net": {
Hostname: "forgejo.zerova.net",
Token: "token1",
MatchDirs: []string{"/Users/sid/repos/fgj"},
MatchDirs: []string{"/Users/sid/repos/fj"},
},
"codeberg.org": {
Hostname: "codeberg.org",
@ -522,7 +522,7 @@ func TestGetHost_MatchDirsIntegration(t *testing.T) {
}
// cwd match should resolve to forgejo.zerova.net
host, err := cfg.GetHost("", "", "/Users/sid/repos/fgj/cmd")
host, err := cfg.GetHost("", "", "/Users/sid/repos/fj/cmd")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}

View file

@ -17,41 +17,41 @@ func TestParseRemoteURL(t *testing.T) {
}{
{
name: "HTTPS URL with .git",
url: "https://codeberg.org/romaintb/fgj.git",
url: "https://codeberg.org/romaintb/fj.git",
wantOwner: "romaintb",
wantName: "fgj",
wantName: "fj",
wantHost: "codeberg.org",
wantErr: false,
},
{
name: "HTTPS URL without .git",
url: "https://codeberg.org/romaintb/fgj",
url: "https://codeberg.org/romaintb/fj",
wantOwner: "romaintb",
wantName: "fgj",
wantName: "fj",
wantHost: "codeberg.org",
wantErr: false,
},
{
name: "SSH URL with .git",
url: "git@codeberg.org:romaintb/fgj.git",
url: "git@codeberg.org:romaintb/fj.git",
wantOwner: "romaintb",
wantName: "fgj",
wantName: "fj",
wantHost: "codeberg.org",
wantErr: false,
},
{
name: "SSH URL without .git",
url: "git@codeberg.org:romaintb/fgj",
url: "git@codeberg.org:romaintb/fj",
wantOwner: "romaintb",
wantName: "fgj",
wantName: "fj",
wantHost: "codeberg.org",
wantErr: false,
},
{
name: "SSH protocol URL",
url: "ssh://git@codeberg.org/romaintb/fgj.git",
url: "ssh://git@codeberg.org/romaintb/fj.git",
wantOwner: "romaintb",
wantName: "fgj",
wantName: "fj",
wantHost: "codeberg.org",
wantErr: false,
},

View file

@ -37,10 +37,10 @@ type IOStreams struct {
}
// New creates an IOStreams wired to the real os.Stdin, os.Stdout, and os.Stderr,
// with TTY status auto-detected. Setting FGJ_FORCE_TTY=1 forces all streams to
// be treated as TTYs.
// with TTY status auto-detected. Setting FJ_FORCE_TTY=1 (or legacy FGJ_FORCE_TTY=1)
// forces all streams to be treated as TTYs.
func New() *IOStreams {
forceTTY := os.Getenv("FGJ_FORCE_TTY") != ""
forceTTY := os.Getenv("FJ_FORCE_TTY") != "" || os.Getenv("FGJ_FORCE_TTY") != ""
stdinTTY := forceTTY || (isTerminal(os.Stdin.Fd()))
stdoutTTY := forceTTY || (isTerminal(os.Stdout.Fd()))
@ -118,14 +118,17 @@ func (s *IOStreams) ColorScheme() *ColorScheme {
}
// StartPager starts an external pager process and redirects Out to its stdin.
// It checks FGJ_PAGER, then PAGER, then defaults to "less". If LESS is not
// already set, it is set to "FRX" for a good default experience.
// It checks FJ_PAGER (or legacy FGJ_PAGER), then PAGER, then defaults to "less".
// If LESS is not already set, it is set to "FRX" for a good default experience.
func (s *IOStreams) StartPager() error {
if !s.isStdoutTTY {
return nil
}
pagerCmd := os.Getenv("FGJ_PAGER")
pagerCmd := os.Getenv("FJ_PAGER")
if pagerCmd == "" {
pagerCmd = os.Getenv("FGJ_PAGER")
}
if pagerCmd == "" {
pagerCmd = os.Getenv("PAGER")
}

View file

@ -4,7 +4,7 @@ import (
"fmt"
"os"
"forgejo.zerova.net/public/fgj-sid/cmd"
"forgejo.zerova.net/public/fj/cmd"
)
func main() {

View file

@ -228,15 +228,18 @@ func (env *TestEnv) CleanupRepo(owner, repoName string) {
}
}
// GetBinaryPath returns the path to the built fgj binary
// GetBinaryPath returns the path to the built fj binary
func (env *TestEnv) GetBinaryPath() string {
binaryPath := os.Getenv("FGJ_BINARY_PATH")
binaryPath := os.Getenv("FJ_BINARY_PATH")
if binaryPath == "" {
binaryPath = os.Getenv("FGJ_BINARY_PATH")
}
if binaryPath == "" {
// Look for the binary in common locations
candidates := []string{
"./bin/fgj",
"bin/fgj",
"/home/romain/work/fgj/bin/fgj",
"./bin/fj",
"bin/fj",
"/home/romain/work/fj/bin/fj",
}
for _, candidate := range candidates {
if _, err := os.Stat(candidate); err == nil {
@ -248,7 +251,7 @@ func (env *TestEnv) GetBinaryPath() string {
}
}
// If no binary found, return default (will error when executed)
binaryPath = "./bin/fgj"
binaryPath = "./bin/fj"
}
return binaryPath
}

View file

@ -86,7 +86,7 @@ func TestCLIIssueList(t *testing.T) {
env := NewTestEnv(t)
// Create a test issue so the list is not empty
issueNum := env.CreateTestIssue("[FGJ E2E Test] Issue List", "For issue list test")
issueNum := env.CreateTestIssue("[FJ E2E Test] Issue List", "For issue list test")
defer env.CleanupIssue(issueNum)
result := env.RunCLI(
@ -109,7 +109,7 @@ func TestCLIIssueList(t *testing.T) {
func TestCLIIssueListJSON(t *testing.T) {
env := NewTestEnv(t)
issueNum := env.CreateTestIssue("[FGJ E2E Test] JSON List", "For JSON output test")
issueNum := env.CreateTestIssue("[FJ E2E Test] JSON List", "For JSON output test")
defer env.CleanupIssue(issueNum)
result := env.RunCLI(
@ -137,7 +137,7 @@ func TestCLIIssueListJSON(t *testing.T) {
func TestCLIIssueView(t *testing.T) {
env := NewTestEnv(t)
issueNum := env.CreateTestIssue("[FGJ E2E Test] View Test", "Testing issue view")
issueNum := env.CreateTestIssue("[FJ E2E Test] View Test", "Testing issue view")
defer env.CleanupIssue(issueNum)
result := env.RunCLI(
@ -160,7 +160,7 @@ func TestCLIIssueView(t *testing.T) {
func TestCLIIssueViewJSON(t *testing.T) {
env := NewTestEnv(t)
issueNum := env.CreateTestIssue("[FGJ E2E Test] JSON View", "Testing JSON view")
issueNum := env.CreateTestIssue("[FJ E2E Test] JSON View", "Testing JSON view")
defer env.CleanupIssue(issueNum)
result := env.RunCLI(
@ -198,8 +198,8 @@ func TestCLIIssueCreate(t *testing.T) {
"--hostname", env.Hostname,
"-R", fmt.Sprintf("%s/%s", env.Owner, env.RepoName),
"issue", "create",
"-t", "[FGJ E2E Test] CLI Created Issue",
"-b", "Created directly via fgj CLI",
"-t", "[FJ E2E Test] CLI Created Issue",
"-b", "Created directly via fj CLI",
)
if result.ExitCode != 0 {
@ -229,7 +229,7 @@ func TestCLIIssueCreateWithLabels(t *testing.T) {
"--hostname", env.Hostname,
"-R", fmt.Sprintf("%s/%s", env.Owner, env.RepoName),
"issue", "create",
"-t", "[FGJ E2E Test] Issue with Labels",
"-t", "[FJ E2E Test] Issue with Labels",
"-b", "This issue was created with labels",
"-l", "bug",
"-l", "enhancement",
@ -275,7 +275,7 @@ func TestCLIIssueCreateWithLabels(t *testing.T) {
func TestCLIIssueComment(t *testing.T) {
env := NewTestEnv(t)
issueNum := env.CreateTestIssue("[FGJ E2E Test] Comment Test", "Testing comment via CLI")
issueNum := env.CreateTestIssue("[FJ E2E Test] Comment Test", "Testing comment via CLI")
defer env.CleanupIssue(issueNum)
result := env.RunCLI(
@ -313,7 +313,7 @@ func TestCLIIssueComment(t *testing.T) {
func TestCLIIssueClose(t *testing.T) {
env := NewTestEnv(t)
issueNum := env.CreateTestIssue("[FGJ E2E Test] Close Test", "Will be closed via CLI")
issueNum := env.CreateTestIssue("[FJ E2E Test] Close Test", "Will be closed via CLI")
result := env.RunCLI(
"--hostname", env.Hostname,
@ -341,7 +341,7 @@ func TestCLIIssueClose(t *testing.T) {
func TestCLIIssueCloseWithComment(t *testing.T) {
env := NewTestEnv(t)
issueNum := env.CreateTestIssue("[FGJ E2E Test] Close with comment", "Will be closed with a comment")
issueNum := env.CreateTestIssue("[FJ E2E Test] Close with comment", "Will be closed with a comment")
commentText := "Fixed in v2.0 - closing via functional test"
@ -389,7 +389,7 @@ func TestCLIIssueCloseWithComment(t *testing.T) {
func TestCLIIssueEditTitle(t *testing.T) {
env := NewTestEnv(t)
issueNum := env.CreateTestIssue("[FGJ E2E Test] Original Title", "Will be edited")
issueNum := env.CreateTestIssue("[FJ E2E Test] Original Title", "Will be edited")
defer env.CleanupIssue(issueNum)
result := env.RunCLI(
@ -397,7 +397,7 @@ func TestCLIIssueEditTitle(t *testing.T) {
"-R", fmt.Sprintf("%s/%s", env.Owner, env.RepoName),
"issue", "edit",
fmt.Sprintf("%d", issueNum),
"-t", "[FGJ E2E Test] Updated Title",
"-t", "[FJ E2E Test] Updated Title",
)
if result.ExitCode != 0 {
@ -409,7 +409,7 @@ func TestCLIIssueEditTitle(t *testing.T) {
t.Fatalf("failed to get issue: %v", err)
}
if issue.Title != "[FGJ E2E Test] Updated Title" {
if issue.Title != "[FJ E2E Test] Updated Title" {
t.Fatalf("expected updated title, got '%s'", issue.Title)
}
@ -421,7 +421,7 @@ func TestCLIIssueEditAddLabels(t *testing.T) {
env.EnsureTestLabels()
issueNum := env.CreateTestIssue("[FGJ E2E Test] Add Labels", "Will have labels added")
issueNum := env.CreateTestIssue("[FJ E2E Test] Add Labels", "Will have labels added")
defer env.CleanupIssue(issueNum)
result := env.RunCLI(
@ -464,7 +464,7 @@ func TestCLIIssueEditRemoveLabels(t *testing.T) {
env.EnsureTestLabels()
issue, _, err := env.Client.CreateIssue(env.Owner, env.RepoName, gitea.CreateIssueOption{
Title: "[FGJ E2E Test] Remove Labels",
Title: "[FJ E2E Test] Remove Labels",
Body: "Will have labels removed",
})
if err != nil {
@ -616,7 +616,7 @@ func TestCLIPRComment(t *testing.T) {
env := NewTestEnv(t)
// PRs share the comment API with issues
issueNum := env.CreateTestIssue("[FGJ E2E Test] PR Comment Test", "Testing pr comment command")
issueNum := env.CreateTestIssue("[FJ E2E Test] PR Comment Test", "Testing pr comment command")
defer env.CleanupIssue(issueNum)
result := env.RunCLI(
@ -624,14 +624,14 @@ func TestCLIPRComment(t *testing.T) {
"-R", fmt.Sprintf("%s/%s", env.Owner, env.RepoName),
"pr", "comment",
fmt.Sprintf("%d", issueNum),
"-b", "Automated test comment via fgj pr comment",
"-b", "Automated test comment via fj pr comment",
)
if result.ExitCode != 0 {
t.Fatalf("pr comment failed with exit code %d: %s", result.ExitCode, result.Stderr)
}
t.Logf("Successfully commented on issue #%d via fgj pr comment", issueNum)
t.Logf("Successfully commented on issue #%d via fj pr comment", issueNum)
}
// ===== CLI Repo Commands =====
@ -678,14 +678,14 @@ func TestCLIRepoList(t *testing.T) {
func TestCLIRepoCreate(t *testing.T) {
env := NewTestEnv(t)
repoName := fmt.Sprintf("fgj-test-create-%d", time.Now().UnixNano())
repoName := fmt.Sprintf("fj-test-create-%d", time.Now().UnixNano())
defer env.CleanupRepo(env.Owner, repoName)
result := env.RunCLI(
"--hostname", env.Hostname,
"repo", "create", repoName,
"--public",
"-d", "Created by fgj functional test",
"-d", "Created by fj functional test",
)
if result.ExitCode != 0 {
@ -703,8 +703,8 @@ func TestCLIRepoCreate(t *testing.T) {
if repo.Private {
t.Fatalf("expected public repo, got private")
}
if repo.Description != "Created by fgj functional test" {
t.Fatalf("expected description %q, got %q", "Created by fgj functional test", repo.Description)
if repo.Description != "Created by fj functional test" {
t.Fatalf("expected description %q, got %q", "Created by fj functional test", repo.Description)
}
t.Logf("Successfully created repository %s via CLI", repo.FullName)
@ -756,7 +756,7 @@ func TestCLIRepoClone(t *testing.T) {
env := NewTestEnv(t)
tmpDir := t.TempDir()
clonePath := fmt.Sprintf("%s/fgj-clone", tmpDir)
clonePath := fmt.Sprintf("%s/fj-clone", tmpDir)
result := env.RunCLI(
"--hostname", env.Hostname,
@ -799,13 +799,13 @@ func TestCLIReleaseList(t *testing.T) {
func TestCLIReleaseCreateUploadDelete(t *testing.T) {
env := NewTestEnv(t)
tag := fmt.Sprintf("fgj-test-%d", time.Now().UnixNano())
title := "FGJ CLI Release Test"
tag := fmt.Sprintf("fj-test-%d", time.Now().UnixNano())
title := "FJ CLI Release Test"
notes := "Release created by functional tests"
tmpDir := t.TempDir()
assetPath := fmt.Sprintf("%s/asset.txt", tmpDir)
if err := os.WriteFile(assetPath, []byte("fgj release asset"), 0600); err != nil {
if err := os.WriteFile(assetPath, []byte("fj release asset"), 0600); err != nil {
t.Fatalf("failed to create asset file: %v", err)
}
@ -863,7 +863,7 @@ func TestCLIReleaseView(t *testing.T) {
env := NewTestEnv(t)
// Create a release to view
tag := fmt.Sprintf("fgj-view-test-%d", time.Now().UnixNano())
tag := fmt.Sprintf("fj-view-test-%d", time.Now().UnixNano())
createResult := env.RunCLI(
"--hostname", env.Hostname,
@ -1149,7 +1149,7 @@ func TestCLIAPIGet(t *testing.T) {
t.Fatalf("expected repo name %q in JSON output, got %v", env.RepoName, data["name"])
}
t.Logf("Successfully retrieved repo info via fgj api GET")
t.Logf("Successfully retrieved repo info via fj api GET")
}
func TestCLIAPIPostAndDelete(t *testing.T) {
@ -1161,8 +1161,8 @@ func TestCLIAPIPostAndDelete(t *testing.T) {
"--hostname", env.Hostname,
"api", endpoint,
"-X", "POST",
"-f", "title=[FGJ E2E Test] API Post Test",
"-f", "body=Created via fgj api command",
"-f", "title=[FJ E2E Test] API Post Test",
"-f", "body=Created via fj api command",
)
if result.ExitCode != 0 {
@ -1182,7 +1182,7 @@ func TestCLIAPIPostAndDelete(t *testing.T) {
issueNum := int64(issueNumber)
defer env.CleanupIssue(issueNum)
t.Logf("Successfully created issue #%d via fgj api POST", issueNum)
t.Logf("Successfully created issue #%d via fj api POST", issueNum)
}
// ===== Structured Error Output =====