Compare commits
14 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0069198ca6 | ||
|
|
373c769d2c | ||
|
|
155ddb97ba | ||
|
|
133fb2fea4 | ||
|
|
0c181df1d1 | ||
|
|
f75b831a53 | ||
|
|
0fda0b8679 | ||
|
|
25868adcad | ||
|
|
c3e8ad67ed | ||
|
|
cf7c0e0878 | ||
|
|
bc43f6e5a5 | ||
|
|
a6cf9a7096 | ||
|
|
c2251d9932 | ||
|
|
2e6575c660 |
38 changed files with 1449 additions and 900 deletions
|
|
@ -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/...
|
||||
|
|
|
|||
|
|
@ -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
3
.gitignore
vendored
|
|
@ -1,5 +1,6 @@
|
|||
# Binaries
|
||||
fgj
|
||||
fj
|
||||
bin/
|
||||
*.exe
|
||||
*.exe~
|
||||
*.dll
|
||||
|
|
|
|||
255
CHANGELOG.md
255
CHANGELOG.md
|
|
@ -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/sid/fgj-sid/releases/tag/v0.3.0c
|
||||
[0.3.0b]: https://forgejo.zerova.net/sid/fgj-sid/releases/tag/v0.3.0b
|
||||
[0.3.0a]: https://forgejo.zerova.net/sid/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
166
CLAUDE.md
Normal 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.
|
||||
```
|
||||
4
Makefile
4
Makefile
|
|
@ -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 .
|
||||
|
|
|
|||
323
README.md
323
README.md
|
|
@ -1,11 +1,11 @@
|
|||
# fgj - Forgejo/Gitea CLI Tool
|
||||
# fj - Forgejo/Gitea CLI Tool
|
||||
|
||||
[](https://golang.org)
|
||||
[](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/sid/fgj-sid](https://forgejo.zerova.net/sid/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/sid/fgj-sid@latest
|
||||
go install forgejo.zerova.net/public/fj@latest
|
||||
```
|
||||
|
||||
### From Source
|
||||
|
||||
```bash
|
||||
git clone https://forgejo.zerova.net/sid/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:
|
||||
|
|
@ -453,38 +453,71 @@ hosts:
|
|||
token: your_token_here
|
||||
user: your_username
|
||||
git_protocol: ssh
|
||||
match_dirs:
|
||||
- / # catch-all: use this host when no git remote is detected
|
||||
codeberg.org:
|
||||
hostname: codeberg.org
|
||||
token: another_token
|
||||
user: another_username
|
||||
git_protocol: https
|
||||
match_dirs:
|
||||
- ~/repos/codeberg # use this host for repos under this directory
|
||||
```
|
||||
|
||||
### Directory-Based Host Selection (`match_dirs`)
|
||||
|
||||
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 `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:
|
||||
work.example.com:
|
||||
# ...
|
||||
match_dirs:
|
||||
- ~/work # any repo under ~/work uses this host
|
||||
personal.example.com:
|
||||
# ...
|
||||
match_dirs:
|
||||
- ~/personal
|
||||
- ~/side-projects # multiple directories can map to the same host
|
||||
codeberg.org:
|
||||
# ...
|
||||
match_dirs:
|
||||
- / # catch-all fallback (shortest prefix, lowest priority)
|
||||
```
|
||||
|
||||
- Paths support `~` expansion and symlink resolution
|
||||
- More specific (longer) paths always win over shorter ones
|
||||
- Use `/` as a catch-all to override the default `codeberg.org` fallback
|
||||
- On ties (same prefix length), the host appearing first in the config file wins
|
||||
|
||||
### 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. Default to `codeberg.org`
|
||||
4. `match_dirs` lookup (longest prefix match against current directory)
|
||||
5. Default to `codeberg.org`
|
||||
|
||||
### Command-line Flags
|
||||
|
||||
- `--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
|
||||
|
|
@ -494,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
|
||||
|
|
@ -524,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/sid/fgj-sid](https://forgejo.zerova.net/sid/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
|
||||
|
|
@ -543,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
|
||||
|
||||
|
|
|
|||
229
cmd/actions.go
229
cmd/actions.go
|
|
@ -10,8 +10,8 @@ import (
|
|||
"code.gitea.io/sdk/gitea"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"forgejo.zerova.net/sid/fgj-sid/internal/api"
|
||||
"forgejo.zerova.net/sid/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{
|
||||
// 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",
|
||||
Long: "List, view, and manage workflow runs.",
|
||||
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{
|
||||
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
|
||||
fgj actions run list
|
||||
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`,
|
||||
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{
|
||||
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
|
||||
fgj actions run view 123
|
||||
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`,
|
||||
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{
|
||||
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
|
||||
fgj actions run watch 123
|
||||
fj actions run watch 123
|
||||
|
||||
# Watch with a custom polling interval
|
||||
fgj actions run watch 123 -i 10s`,
|
||||
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{
|
||||
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
|
||||
fgj actions run rerun 123`,
|
||||
fj actions run rerun 123`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: runRunRerun,
|
||||
}
|
||||
addRepoFlags(c)
|
||||
return c
|
||||
}
|
||||
|
||||
var runCancelCmd = &cobra.Command{
|
||||
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
|
||||
fgj actions run cancel 123`,
|
||||
fj actions run cancel 123`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: runRunCancel,
|
||||
}
|
||||
addRepoFlags(c)
|
||||
return c
|
||||
}
|
||||
|
||||
// Workflow commands
|
||||
var workflowCmd = &cobra.Command{
|
||||
// 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",
|
||||
Long: "List, view, and run workflows.",
|
||||
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{
|
||||
func newWorkflowListCmd() *cobra.Command {
|
||||
c := &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List workflows",
|
||||
Long: "List all workflows in a repository.",
|
||||
Example: ` # List all workflows
|
||||
fgj actions workflow list
|
||||
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`,
|
||||
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{
|
||||
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
|
||||
fgj actions workflow view ci.yml
|
||||
fj actions workflow view ci.yml
|
||||
|
||||
# View as JSON
|
||||
fgj actions workflow view ci.yml --json`,
|
||||
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{
|
||||
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
|
||||
fgj actions workflow run deploy.yml
|
||||
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`,
|
||||
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{
|
||||
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
|
||||
fgj actions workflow enable ci.yml`,
|
||||
fj actions workflow enable ci.yml`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: runWorkflowEnable,
|
||||
}
|
||||
addRepoFlags(c)
|
||||
return c
|
||||
}
|
||||
|
||||
var workflowDisableCmd = &cobra.Command{
|
||||
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
|
||||
fgj actions workflow disable ci.yml`,
|
||||
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)
|
||||
|
|
|
|||
148
cmd/aliases.go
148
cmd/aliases.go
|
|
@ -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')"))
|
||||
}
|
||||
|
|
|
|||
213
cmd/api.go
213
cmd/api.go
|
|
@ -6,15 +6,23 @@ import (
|
|||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"forgejo.zerova.net/sid/fgj-sid/internal/config"
|
||||
"forgejo.zerova.net/sid/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
|
||||
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...")
|
||||
httpClient := &http.Client{}
|
||||
resp, err := httpClient.Do(req)
|
||||
resp, err := api.SharedHTTPClient.Do(r)
|
||||
ios.StopSpinner()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to perform request: %w", err)
|
||||
return nil, nil, 0, "", "", fmt.Errorf("failed to perform request: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
// Print response headers if requested
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
|
|
|||
27
cmd/auth.go
27
cmd/auth.go
|
|
@ -7,8 +7,8 @@ import (
|
|||
"strings"
|
||||
"syscall"
|
||||
|
||||
"forgejo.zerova.net/sid/fgj-sid/internal/api"
|
||||
"forgejo.zerova.net/sid/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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -3,10 +3,9 @@ package cmd
|
|||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"forgejo.zerova.net/sid/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)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
package cmd
|
||||
|
||||
import "forgejo.zerova.net/sid/fgj-sid/internal/iostreams"
|
||||
import "forgejo.zerova.net/public/fj/internal/iostreams"
|
||||
|
||||
var ios = iostreams.New()
|
||||
|
|
|
|||
64
cmd/issue.go
64
cmd/issue.go
|
|
@ -6,9 +6,9 @@ import (
|
|||
"strings"
|
||||
|
||||
"code.gitea.io/sdk/gitea"
|
||||
"forgejo.zerova.net/sid/fgj-sid/internal/api"
|
||||
"forgejo.zerova.net/sid/fgj-sid/internal/config"
|
||||
"forgejo.zerova.net/sid/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{
|
||||
// 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{PageSize: limit},
|
||||
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)
|
||||
|
|
|
|||
49
cmd/json.go
49
cmd/json.go
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
28
cmd/label.go
28
cmd/label.go
|
|
@ -5,8 +5,8 @@ import (
|
|||
"strings"
|
||||
|
||||
"code.gitea.io/sdk/gitea"
|
||||
"forgejo.zerova.net/sid/fgj-sid/internal/api"
|
||||
"forgejo.zerova.net/sid/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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,9 +7,9 @@ import (
|
|||
"time"
|
||||
|
||||
"code.gitea.io/sdk/gitea"
|
||||
"forgejo.zerova.net/sid/fgj-sid/internal/api"
|
||||
"forgejo.zerova.net/sid/fgj-sid/internal/config"
|
||||
"forgejo.zerova.net/sid/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
43
cmd/paginate.go
Normal 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
|
||||
}
|
||||
89
cmd/pr.go
89
cmd/pr.go
|
|
@ -7,10 +7,10 @@ import (
|
|||
"strings"
|
||||
|
||||
"code.gitea.io/sdk/gitea"
|
||||
"forgejo.zerova.net/sid/fgj-sid/internal/api"
|
||||
"forgejo.zerova.net/sid/fgj-sid/internal/config"
|
||||
gitpkg "forgejo.zerova.net/sid/fgj-sid/internal/git"
|
||||
"forgejo.zerova.net/sid/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...")
|
||||
var prs []*gitea.PullRequest
|
||||
if needsClientFilter {
|
||||
page := 1
|
||||
for {
|
||||
// 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: 50},
|
||||
ListOptions: gitea.ListOptions{Page: page, PageSize: pageSize},
|
||||
})
|
||||
if err != nil {
|
||||
ios.StopSpinner()
|
||||
return fmt.Errorf("failed to list pull requests: %w", err)
|
||||
}
|
||||
prs = append(prs, batch...)
|
||||
if len(batch) < 50 {
|
||||
break
|
||||
}
|
||||
page++
|
||||
return batch, err
|
||||
}
|
||||
var prs []*gitea.PullRequest
|
||||
if needsClientFilter {
|
||||
prs, err = paginateGitea(0, fetchPage) // pull all, then filter + limit
|
||||
if err == nil {
|
||||
prs = filterPRs(prs, author, assignee, labels, search, draft, head, base)
|
||||
if len(prs) > limit {
|
||||
if limit > 0 && 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 {
|
||||
prs, err = paginateGitea(limit, fetchPage)
|
||||
}
|
||||
ios.StopSpinner()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list pull requests: %w", err)
|
||||
}
|
||||
}
|
||||
ios.StopSpinner()
|
||||
|
||||
if wantJSON(cmd) {
|
||||
return outputJSON(cmd, prs)
|
||||
|
|
|
|||
|
|
@ -4,9 +4,9 @@ import (
|
|||
"fmt"
|
||||
|
||||
"code.gitea.io/sdk/gitea"
|
||||
"forgejo.zerova.net/sid/fgj-sid/internal/api"
|
||||
"forgejo.zerova.net/sid/fgj-sid/internal/config"
|
||||
"forgejo.zerova.net/sid/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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,8 +4,8 @@ import (
|
|||
"fmt"
|
||||
"strings"
|
||||
|
||||
"forgejo.zerova.net/sid/fgj-sid/internal/api"
|
||||
"forgejo.zerova.net/sid/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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,8 +6,8 @@ import (
|
|||
"os"
|
||||
|
||||
"code.gitea.io/sdk/gitea"
|
||||
"forgejo.zerova.net/sid/fgj-sid/internal/api"
|
||||
"forgejo.zerova.net/sid/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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,9 +9,9 @@ import (
|
|||
"time"
|
||||
|
||||
"code.gitea.io/sdk/gitea"
|
||||
"forgejo.zerova.net/sid/fgj-sid/internal/api"
|
||||
"forgejo.zerova.net/sid/fgj-sid/internal/config"
|
||||
"forgejo.zerova.net/sid/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,
|
||||
}
|
||||
|
|
|
|||
37
cmd/repo.go
37
cmd/repo.go
|
|
@ -8,9 +8,9 @@ import (
|
|||
"strings"
|
||||
|
||||
"code.gitea.io/sdk/gitea"
|
||||
"forgejo.zerova.net/sid/fgj-sid/internal/api"
|
||||
"forgejo.zerova.net/sid/fgj-sid/internal/config"
|
||||
"forgejo.zerova.net/sid/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)
|
||||
}
|
||||
|
|
|
|||
92
cmd/root.go
92
cmd/root.go
|
|
@ -2,11 +2,14 @@ package cmd
|
|||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"forgejo.zerova.net/sid/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.0e",
|
||||
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
|
||||
}
|
||||
|
|
|
|||
47
cmd/wiki.go
47
cmd/wiki.go
|
|
@ -7,9 +7,9 @@ import (
|
|||
"net/url"
|
||||
"time"
|
||||
|
||||
"forgejo.zerova.net/sid/fgj-sid/internal/api"
|
||||
"forgejo.zerova.net/sid/fgj-sid/internal/config"
|
||||
"forgejo.zerova.net/sid/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
2
go.mod
|
|
@ -1,4 +1,4 @@
|
|||
module forgejo.zerova.net/sid/fgj-sid
|
||||
module forgejo.zerova.net/public/fj
|
||||
|
||||
go 1.24.0
|
||||
|
||||
|
|
|
|||
|
|
@ -9,13 +9,19 @@ import (
|
|||
"time"
|
||||
|
||||
"code.gitea.io/sdk/gitea"
|
||||
"forgejo.zerova.net/sid/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
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ package api
|
|||
import (
|
||||
"testing"
|
||||
|
||||
"forgejo.zerova.net/sid/fgj-sid/internal/config"
|
||||
"forgejo.zerova.net/public/fj/internal/config"
|
||||
)
|
||||
|
||||
func TestClient_Hostname(t *testing.T) {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
2
main.go
2
main.go
|
|
@ -4,7 +4,7 @@ import (
|
|||
"fmt"
|
||||
"os"
|
||||
|
||||
"forgejo.zerova.net/sid/fgj-sid/cmd"
|
||||
"forgejo.zerova.net/public/fj/cmd"
|
||||
)
|
||||
|
||||
func main() {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 =====
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue