Compare commits

..

No commits in common. "main" and "fgj-test-1774152117515929000" have entirely different histories.

43 changed files with 1690 additions and 5463 deletions

View file

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

View file

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

5
.gitignore vendored
View file

@ -1,6 +1,5 @@
# Binaries
fj
bin/
fgj
*.exe
*.exe~
*.dll
@ -32,5 +31,3 @@ config.yaml
# Git worktrees
.worktrees/
# Workspace (scratch data, cloned repos, analysis)
.workspace/

View file

@ -5,151 +5,50 @@ 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
- `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
- `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
#### Milestone Management
- `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
- `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
#### Wiki Management
- `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
- `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
#### Issue Dependencies
- `fj issue edit --add-dependency <number>` - Add issue dependency
- `fj issue edit --remove-dependency <number>` - Remove issue dependency
- `fgj issue edit --add-dependency <number>` - Add issue dependency
- `fgj issue edit --remove-dependency <number>` - Remove issue dependency
## [0.3.0b] - 2026-03-21
### Added
#### Repository Management
- `fj repo edit` - Edit repository settings (visibility, description, homepage, default branch)
- `fgj repo edit` - Edit repository settings (visibility, description, homepage, default branch)
### Fixed
- `fj repo create --public` flag was defined but never read; now properly wired up
- `fgj repo create --public` flag was defined but never read; now properly wired up
## [0.3.0a] - 2026-03-21
### Added
#### Raw API Access
- `fj api <endpoint>` - Make authenticated REST API requests to any Forgejo/Gitea endpoint
- `fgj 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`)
@ -159,14 +58,14 @@ this release ships fixes for all 13.
- Response header display (`--include`/`-i`)
#### Pull Request Management
- `fj pr diff <number>` - View the diff for a pull request
- `fgj 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`)
- `fj pr comment <number>` - Add a comment to a pull request
- `fgj pr comment <number>` - Add a comment to a pull request
- Body from flag (`--body`/`-b`) or file (`--body-file`, `-` for stdin)
- JSON output (`--json`)
- `fj pr review <number>` - Submit a review on a pull request
- `fgj 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`)
@ -182,30 +81,30 @@ this release ships fixes for all 13.
### Added
#### Forgejo Actions
- `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
- `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
#### Repository Management
- `fj repo create <name>` - Create a new repository with full option set: `--private`/`--public`, `--description`, `--add-readme`, `--gitignore`, `--license`, `--homepage`, `--clone`, `--team`
- `fgj repo create <name>` - Create a new repository with full option set: `--private`/`--public`, `--description`, `--add-readme`, `--gitignore`, `--license`, `--homepage`, `--clone`, `--team`
#### Issue Management
- `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
- `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
#### Workflow Management
- `fj actions workflow list/view/run` - List, view, and trigger workflows
- `fgj actions workflow list/view/run` - List, view, and trigger workflows
#### Auth Helpers
- `fj auth token` - Print the stored token for the current host
- `fj auth logout` - Remove authentication for a host
- `fgj auth token` - Print the stored token for the current host
- `fgj auth logout` - Remove authentication for a host
#### Shell Completions and Man Pages
- `fj completion [bash|zsh|fish|powershell]` - Generate shell completion scripts
- `fj manpages --dir <path>` - Generate man pages for all commands
- `fgj completion [bash|zsh|fish|powershell]` - Generate shell completion scripts
- `fgj 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
@ -218,17 +117,17 @@ this release ships fixes for all 13.
### Added
#### Release Management
- `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)
- `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)
#### Issue Management
- `fj issue edit` - Edit existing issues with support for updating title, body, and labels
- `fgj issue edit` - Edit existing issues with support for updating title, body, and labels
#### Pull Request Management
- `fj pr create --assignee` - Assign users when creating pull requests
- `fgj pr create --assignee` - Assign users when creating pull requests
#### Repository Detection
- Automatic hostname detection from git remote URLs
@ -249,48 +148,48 @@ this release ships fixes for all 13.
### Added
#### Core Features
- Initial release of fj - Forgejo CLI tool
- Initial release of fgj - 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/fj/config.yaml`
- Configuration management via `~/.config/fgj/config.yaml`
#### Pull Request Management
- `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
- `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
#### Issue Management
- `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
- `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
#### Repository Operations
- `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
- `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
#### Forgejo Actions Support
- `fj actions run list` - List workflow runs with status and metadata
- `fj actions run view` - View detailed run information, jobs, and logs
- `fgj actions run list` - List workflow runs with status and metadata
- `fgj actions run view` - View detailed run information, jobs, and logs
- Support for `--verbose`, `--log`, `--log-failed`, and `--job` flags
- `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
- `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
#### Authentication
- `fj auth login` - Interactive authentication with Forgejo instances
- `fj auth status` - Check authentication status
- Environment variable support (`FJ_HOST`, `FJ_TOKEN`)
- `fgj auth login` - Interactive authentication with Forgejo instances
- `fgj auth status` - Check authentication status
- Environment variable support (`FGJ_HOST`, `FGJ_TOKEN`)
#### Development
- Comprehensive unit test suite
@ -304,9 +203,9 @@ this release ships fixes for all 13.
- Cobra framework for CLI structure
- Viper for configuration management
[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
[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

166
CLAUDE.md
View file

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

View file

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

324
README.md
View file

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

File diff suppressed because it is too large Load diff

View file

@ -1,16 +0,0 @@
package cmd
// 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() {
rootCmd.AddCommand(newRunCmd(" (alias for 'actions run')"))
rootCmd.AddCommand(newWorkflowCmd(" (alias for 'actions workflow')"))
}

View file

@ -6,23 +6,15 @@ import (
"fmt"
"io"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"forgejo.zerova.net/public/fj/internal/api"
"forgejo.zerova.net/public/fj/internal/config"
"forgejo.zerova.net/public/fj/internal/git"
"forgejo.zerova.net/sid/fgj-sid/internal/config"
"forgejo.zerova.net/sid/fgj-sid/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",
@ -34,22 +26,16 @@ detected from the current git repository.
If --field is used and no --method is specified, the method defaults to POST.`,
Example: ` # List pull requests for the current repository
fj api /repos/{owner}/{repo}/pulls
fgj api /repos/{owner}/{repo}/pulls
# Create an issue
fj api /repos/{owner}/{repo}/issues --method POST --field title=Bug --field body="It broke"
fgj api /repos/{owner}/{repo}/issues --method POST --field title=Bug --field body="It broke"
# Get a specific user
fj api /users/johndoe
fgj api /users/johndoe
# Use raw body from stdin
echo '{"title":"test"}' | fj api /repos/{owner}/{repo}/issues --input -
# Filter the response with a jq expression
fj api /repos/{owner}/{repo}/issues --jq '.[].title'
# Project the response down to specific fields
fj api /repos/{owner}/{repo} --json-fields full_name,description,private`,
echo '{"title":"test"}' | fgj api /repos/{owner}/{repo}/issues --input -`,
Args: cobra.ExactArgs(1),
RunE: runAPI,
}
@ -64,40 +50,6 @@ 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 {
@ -120,7 +72,7 @@ func runAPI(cmd *cobra.Command, args []string) error {
detectedHost := getDetectedHost()
host, err := cfg.GetHost(hostname, detectedHost, getCwd())
host, err := cfg.GetHost(hostname, detectedHost)
if err != nil {
return err
}
@ -187,28 +139,15 @@ func runAPI(cmd *cobra.Command, args []string) error {
body = bytes.NewReader(bodyBytes)
}
// 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)
// Build URL
baseURL := "https://" + host.Hostname + "/api/v1"
if !strings.HasPrefix(endpoint, "/") {
endpoint = "/" + 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
url := baseURL + endpoint
// Create HTTP request
req, err := http.NewRequest(method, final.String(), body)
req, err := http.NewRequest(method, url, body)
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
@ -231,143 +170,59 @@ func runAPI(cmd *cobra.Command, args []string) error {
req.Header.Set(strings.TrimSpace(key), strings.TrimSpace(value))
}
paginate, _ := cmd.Flags().GetBool("paginate")
if paginate && method != http.MethodGet {
return fmt.Errorf("--paginate only supports GET requests")
}
// doOnce executes a single request via the shared client (30 s timeout,
// pooled connections), reads the body bounded by maxAPIResponseBytes,
// and closes the body before returning. Previous zero-value http.Client{}
// had no timeout, pinning the CLI on a hung Forgejo indefinitely.
doOnce := func(r *http.Request) (body []byte, header http.Header, status int, proto string, statusText string, retErr error) {
ios.StartSpinner("Requesting...")
resp, err := api.SharedHTTPClient.Do(r)
ios.StopSpinner()
if err != nil {
return nil, nil, 0, "", "", fmt.Errorf("failed to perform request: %w", err)
}
defer func() { _ = resp.Body.Close() }()
body, err = io.ReadAll(io.LimitReader(resp.Body, maxAPIResponseBytes+1))
if err != nil {
return nil, nil, 0, "", "", fmt.Errorf("failed to read response body: %w", err)
}
if int64(len(body)) > maxAPIResponseBytes {
return nil, nil, 0, "", "", fmt.Errorf("response body exceeded %d bytes (use a different tool for bulk transfers)", maxAPIResponseBytes)
}
return body, resp.Header, resp.StatusCode, resp.Proto, resp.Status, nil
}
respBody, respHeader, statusCode, proto, status, err := doOnce(req)
// Execute request
httpClient := &http.Client{}
resp, err := httpClient.Do(req)
if err != nil {
return err
return fmt.Errorf("failed to perform request: %w", err)
}
defer func() { _ = resp.Body.Close() }()
// Print response headers if requested
if include {
fmt.Fprintf(ios.Out, "%s %s\n", proto, status)
for key, values := range respHeader {
fmt.Fprintf(os.Stdout, "%s %s\n", resp.Proto, resp.Status)
for key, values := range resp.Header {
for _, v := range values {
fmt.Fprintf(ios.Out, "%s: %s\n", key, v)
fmt.Fprintf(os.Stdout, "%s: %s\n", key, v)
}
}
fmt.Fprintln(ios.Out)
fmt.Fprintln(os.Stdout)
}
if statusCode < 200 || statusCode >= 300 {
// 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 !silent {
fmt.Fprint(ios.ErrOut, string(respBody))
fmt.Fprint(os.Stderr, string(respBody))
if len(respBody) > 0 && respBody[len(respBody)-1] != '\n' {
fmt.Fprintln(ios.ErrOut)
fmt.Fprintln(os.Stderr)
}
}
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
os.Exit(1)
}
if silent || len(respBody) == 0 {
return nil
}
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 {
// Pretty-print JSON, or output raw if not JSON
contentType := resp.Header.Get("Content-Type")
if strings.Contains(contentType, "json") || json.Valid(respBody) {
var parsed any
if err := json.Unmarshal(respBody, &parsed); err == nil {
return writeJSON(parsed)
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(parsed)
}
}
_, err = ios.Out.Write(respBody)
// Raw output for non-JSON responses
_, err = os.Stdout.Write(respBody)
return err
}

View file

@ -7,8 +7,8 @@ import (
"strings"
"syscall"
"forgejo.zerova.net/public/fj/internal/api"
"forgejo.zerova.net/public/fj/internal/config"
"forgejo.zerova.net/sid/fgj-sid/internal/api"
"forgejo.zerova.net/sid/fgj-sid/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 fj with a Forgejo instance",
Short: "Authenticate fgj with a Forgejo instance",
Long: "Manage authentication state for Forgejo instances.",
}
@ -55,29 +55,20 @@ func init() {
authCmd.AddCommand(authLogoutCmd)
authCmd.AddCommand(authTokenCmd)
// --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)")
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)")
}
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 == "" {
fmt.Fprint(ios.ErrOut, "Forgejo instance hostname (default: codeberg.org): ")
fmt.Print("Forgejo instance hostname (default: codeberg.org): ")
input, _ := reader.ReadString('\n')
hostname = strings.TrimSpace(input)
if hostname == "" {
@ -86,12 +77,12 @@ func runAuthLogin(cmd *cobra.Command, args []string) error {
}
if token == "" {
fmt.Fprint(ios.ErrOut, "Personal access token: ")
fmt.Print("Personal access token: ")
tokenBytes, err := term.ReadPassword(int(syscall.Stdin))
if err != nil {
return fmt.Errorf("failed to read token: %w", err)
}
fmt.Fprintln(ios.ErrOut)
fmt.Println()
token = strings.TrimSpace(string(tokenBytes))
}
@ -104,9 +95,7 @@ func runAuthLogin(cmd *cobra.Command, args []string) error {
return fmt.Errorf("failed to create client: %w", err)
}
ios.StartSpinner("Authenticating...")
user, _, err := client.GetMyUserInfo()
ios.StopSpinner()
if err != nil {
return fmt.Errorf("authentication failed: %w", err)
}
@ -127,8 +116,7 @@ func runAuthLogin(cmd *cobra.Command, args []string) error {
return fmt.Errorf("failed to save config: %w", err)
}
cs := ios.ColorScheme()
fmt.Fprintf(ios.Out, "%s Authenticated as %s on %s\n", cs.SuccessIcon(), user.UserName, hostname)
fmt.Printf("✓ Authenticated as %s on %s\n", user.UserName, hostname)
return nil
}
@ -140,15 +128,14 @@ 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 'fj auth login' to authenticate")
fmt.Println("Not authenticated with any Forgejo instances")
fmt.Println("Run 'fgj auth login' to authenticate")
return nil
}
fmt.Fprintln(ios.Out, "Authenticated instances:")
fmt.Println("Authenticated instances:")
for hostname, host := range cfg.Hosts {
cs := ios.ColorScheme()
fmt.Fprintf(ios.Out, " %s %s (user: %s)\n", cs.SuccessIcon(), hostname, host.User)
fmt.Printf(" • %s (user: %s)\n", hostname, host.User)
}
return nil
@ -171,8 +158,7 @@ func runAuthLogout(cmd *cobra.Command, args []string) error {
return fmt.Errorf("failed to save config: %w", err)
}
cs := ios.ColorScheme()
fmt.Fprintf(ios.Out, "%s Logged out from %s\n", cs.SuccessIcon(), resolved)
fmt.Printf("✓ Logged out from %s\n", resolved)
return nil
}
@ -188,7 +174,7 @@ func runAuthToken(cmd *cobra.Command, args []string) error {
return err
}
fmt.Fprintln(ios.Out, cfg.Hosts[resolved].Token)
fmt.Println(cfg.Hosts[resolved].Token)
return nil
}
@ -197,7 +183,7 @@ func resolveAuthHostname(cfg *config.Config, hostname string) (string, error) {
hostname = viper.GetString("hostname")
}
if hostname == "" {
hostname = config.EnvWithFallback("FJ_HOST", "FGJ_HOST")
hostname = os.Getenv("FGJ_HOST")
}
if hostname == "" {
hostname = getDetectedHost()

View file

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

View file

@ -3,9 +3,9 @@ package cmd
import (
"encoding/json"
"errors"
"strings"
"os"
"forgejo.zerova.net/public/fj/internal/api"
"forgejo.zerova.net/sid/fgj-sid/internal/api"
)
// Error codes for structured error output.
@ -24,15 +24,9 @@ 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
}
@ -46,60 +40,8 @@ func NewAPIError(status int, message string) *CLIError {
return &CLIError{Code: ErrAPIError, Message: message, Status: status}
}
// 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
}
// 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
}
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
}
// Plain network errors come back as fmt.Errorf strings from net/http.
msg := err.Error()
switch {
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) {
@ -108,9 +50,7 @@ func WriteJSONError(err error) {
Message: err.Error(),
}
// 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.
// Try to extract structured info from the error chain.
var apiErr *api.APIError
var cErr *CLIError
@ -125,13 +65,12 @@ func WriteJSONError(err error) {
cliErr.Code = ErrAuthRequired
case apiErr.StatusCode == 404:
cliErr.Code = ErrNotFound
default:
cliErr.Code = ErrAPIError
}
}
enc := json.NewEncoder(ios.ErrOut)
enc := json.NewEncoder(os.Stderr)
enc.SetIndent("", " ")
_ = enc.Encode(cliErr)
}
// Compile-time check that CLIError satisfies the standard error interface.
var _ error = (*CLIError)(nil)

View file

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

View file

@ -3,12 +3,14 @@ package cmd
import (
"fmt"
"net/http"
"os"
"strconv"
"strings"
"text/tabwriter"
"code.gitea.io/sdk/gitea"
"forgejo.zerova.net/public/fj/internal/api"
"forgejo.zerova.net/public/fj/internal/config"
"forgejo.zerova.net/public/fj/internal/text"
"forgejo.zerova.net/sid/fgj-sid/internal/api"
"forgejo.zerova.net/sid/fgj-sid/internal/config"
"github.com/spf13/cobra"
)
@ -22,114 +24,46 @@ var issueListCmd = &cobra.Command{
Use: "list [flags]",
Short: "List issues",
Long: "List issues in a repository.",
Example: ` # List open issues
fj issue list
# List closed issues for a specific repo
fj issue list -s closed -R owner/repo
# Output as JSON
fj issue list --json`,
RunE: runIssueList,
RunE: runIssueList,
}
var issueViewCmd = &cobra.Command{
Use: "view <number>",
Short: "View an issue",
Long: "Display detailed information about an issue.",
Example: ` # View issue #42
fj issue view 42
# View using URL
fj issue view https://codeberg.org/owner/repo/issues/42
# Open in browser
fj issue view 42 --web
# View an issue from a specific repo as JSON
fj issue view 42 -R owner/repo --json`,
Args: cobra.ExactArgs(1),
RunE: runIssueView,
Args: cobra.ExactArgs(1),
RunE: runIssueView,
}
var issueCreateCmd = &cobra.Command{
Use: "create",
Short: "Create an issue",
Long: "Create a new issue.",
Example: ` # Create an issue with a title
fj issue create -t "Fix login bug"
# Create an issue with title, body, and labels
fj issue create -t "Add dark mode" -b "We need a dark theme" -l feature -l ui`,
RunE: runIssueCreate,
RunE: runIssueCreate,
}
var issueCommentCmd = &cobra.Command{
Use: "comment <number>",
Short: "Add a comment to an issue",
Long: "Add a comment to an existing issue.",
Example: ` # Add a comment to issue #42
fj issue comment 42 -b "This is fixed in the latest release"
# Comment on an issue in a specific repo
fj issue comment 10 -b "Confirmed on my end" -R owner/repo`,
Args: cobra.ExactArgs(1),
RunE: runIssueComment,
Args: cobra.ExactArgs(1),
RunE: runIssueComment,
}
var issueCloseCmd = &cobra.Command{
Use: "close <number>",
Short: "Close an issue",
Long: "Close an existing issue.",
Example: ` # Close issue #42
fj issue close 42
# Close with a comment
fj issue close 42 -c "Fixed in commit abc1234"`,
Args: cobra.ExactArgs(1),
RunE: runIssueClose,
}
var issueReopenCmd = &cobra.Command{
Use: "reopen <number>",
Short: "Reopen an issue",
Long: "Reopen a closed issue.",
Example: ` # Reopen issue #42
fj issue reopen 42`,
Args: cobra.ExactArgs(1),
RunE: runIssueReopen,
}
var issueDeleteCmd = &cobra.Command{
Use: "delete <number>",
Short: "Delete an issue",
Long: "Delete an issue permanently.",
Example: ` # Delete issue #42
fj issue delete 42
# Delete without confirmation
fj issue delete 42 -y`,
Args: cobra.ExactArgs(1),
RunE: runIssueDelete,
Args: cobra.ExactArgs(1),
RunE: runIssueClose,
}
var issueEditCmd = &cobra.Command{
Use: "edit <number>",
Short: "Edit an issue",
Long: "Edit an existing issue's title, body, or state.",
Example: ` # Update the title of issue #42
fj issue edit 42 -t "Updated title"
# Reopen a closed issue
fj issue edit 42 -s open
# Add and remove labels
fj issue edit 42 --add-label bug --remove-label wontfix
# Add a dependency
fj issue edit 42 --add-dependency 10`,
Args: cobra.ExactArgs(1),
RunE: runIssueEdit,
Args: cobra.ExactArgs(1),
RunE: runIssueEdit,
}
func init() {
@ -139,31 +73,19 @@ func init() {
issueCmd.AddCommand(issueCreateCmd)
issueCmd.AddCommand(issueCommentCmd)
issueCmd.AddCommand(issueCloseCmd)
issueCmd.AddCommand(issueReopenCmd)
issueCmd.AddCommand(issueDeleteCmd)
issueCmd.AddCommand(issueEditCmd)
issueReopenCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format")
issueListCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format")
issueListCmd.Flags().StringP("state", "s", "open", "Filter by state: open, closed, all")
issueListCmd.Flags().IntP("limit", "L", 30, "Maximum number of results")
issueListCmd.Flags().StringP("assignee", "a", "", "Filter by assignee username")
issueListCmd.Flags().String("author", "", "Filter by author username")
issueListCmd.Flags().StringSliceP("label", "l", nil, "Filter by label names")
issueListCmd.Flags().StringP("search", "S", "", "Search keyword filter")
addJSONFlags(issueListCmd, "Output issues as JSON")
issueListCmd.Flags().Bool("json", false, "Output issues as JSON")
issueViewCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format")
addJSONFlags(issueViewCmd, "Output issue as JSON")
issueViewCmd.Flags().BoolP("web", "w", false, "Open in web browser")
issueViewCmd.Flags().Bool("json", false, "Output issue as JSON")
issueCreateCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format")
issueCreateCmd.Flags().StringP("title", "t", "", "Title for the issue")
issueCreateCmd.Flags().StringP("body", "b", "", "Body for the issue")
issueCreateCmd.Flags().StringSliceP("label", "l", nil, "Labels to add (can be specified multiple times)")
issueCreateCmd.Flags().StringSliceP("assignee", "a", nil, "Assign people by their login. Use \"@me\" to self-assign.")
issueCreateCmd.Flags().StringP("milestone", "m", "", "Milestone name to associate with the issue")
issueCommentCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format")
issueCommentCmd.Flags().StringP("body", "b", "", "Comment body")
@ -171,9 +93,6 @@ func init() {
issueCloseCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format")
issueCloseCmd.Flags().StringP("comment", "c", "", "Comment body to add before closing")
issueDeleteCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format")
issueDeleteCmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompt")
issueEditCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format")
issueEditCmd.Flags().StringP("title", "t", "", "New title for the issue")
issueEditCmd.Flags().StringP("body", "b", "", "New body for the issue")
@ -187,11 +106,6 @@ func init() {
func runIssueList(cmd *cobra.Command, args []string) error {
repo, _ := cmd.Flags().GetString("repo")
state, _ := cmd.Flags().GetString("state")
limit, _ := cmd.Flags().GetInt("limit")
assignee, _ := cmd.Flags().GetString("assignee")
author, _ := cmd.Flags().GetString("author")
labels, _ := cmd.Flags().GetStringSlice("label")
search, _ := cmd.Flags().GetString("search")
owner, name, err := parseRepo(repo)
if err != nil {
@ -203,7 +117,7 @@ func runIssueList(cmd *cobra.Command, args []string) error {
return err
}
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd())
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost())
if err != nil {
return err
}
@ -220,27 +134,9 @@ func runIssueList(cmd *cobra.Command, args []string) error {
return fmt.Errorf("invalid state: %s", state)
}
ios.StartSpinner("Fetching issues...")
// ListRepoIssues returns both issues AND PRs (we filter PRs out below).
// Pull more than `limit` so post-filter we still have `limit` real issues
// — overshoot 2x as a heuristic. paginateGitea(0, ...) would be safer
// but spends extra round-trips; keep it bounded.
fetchLimit := limit * 2
if fetchLimit < 50 {
fetchLimit = 50
}
issues, err := paginateGitea(fetchLimit, func(page, pageSize int) ([]*gitea.Issue, error) {
batch, _, err := client.ListRepoIssues(owner, name, gitea.ListIssueOption{
State: stateType,
Labels: labels,
KeyWord: search,
CreatedBy: author,
AssignedBy: assignee,
ListOptions: gitea.ListOptions{Page: page, PageSize: pageSize},
})
return batch, err
issues, _, err := client.ListRepoIssues(owner, name, gitea.ListIssueOption{
State: stateType,
})
ios.StopSpinner()
if err != nil {
return fmt.Errorf("failed to list issues: %w", err)
}
@ -251,30 +147,29 @@ 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)
if jsonOutput, _ := cmd.Flags().GetBool("json"); jsonOutput {
return writeJSON(nonPRIssues)
}
if len(nonPRIssues) == 0 {
fmt.Fprintf(ios.Out, "No %s issues in %s/%s\n", state, owner, name)
fmt.Printf("No %s issues in %s/%s\n", state, owner, name)
return nil
}
tp := ios.NewTablePrinter()
tp.AddHeader("NUMBER", "TITLE", "STATE")
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
_, _ = fmt.Fprintf(w, "NUMBER\tTITLE\tSTATE\n")
for _, issue := range nonPRIssues {
tp.AddRow(fmt.Sprintf("#%d", issue.Index), issue.Title, string(issue.State))
_, _ = fmt.Fprintf(w, "#%d\t%s\t%s\n", issue.Index, issue.Title, issue.State)
}
return tp.Render()
_ = w.Flush()
return nil
}
func runIssueView(cmd *cobra.Command, args []string) error {
repo, _ := cmd.Flags().GetString("repo")
issueNumber, err := parseIssueArg(args[0])
issueNumber, err := strconv.ParseInt(args[0], 10, 64)
if err != nil {
return fmt.Errorf("invalid issue number: %w", err)
}
@ -289,15 +184,13 @@ func runIssueView(cmd *cobra.Command, args []string) error {
return err
}
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd())
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost())
if err != nil {
return err
}
ios.StartSpinner("Fetching issue...")
issue, _, err := client.GetIssue(owner, name, issueNumber)
if err != nil {
ios.StopSpinner()
return fmt.Errorf("failed to get issue: %w", err)
}
@ -306,13 +199,8 @@ func runIssueView(cmd *cobra.Command, args []string) error {
if err != nil {
comments = nil
}
ios.StopSpinner()
if web, _ := cmd.Flags().GetBool("web"); web {
return ios.OpenInBrowser(issue.HTMLURL)
}
if wantJSON(cmd) {
if jsonOutput, _ := cmd.Flags().GetBool("json"); jsonOutput {
payload := struct {
Issue *gitea.Issue `json:"issue"`
Comments []*gitea.Comment `json:"comments,omitempty"`
@ -320,34 +208,26 @@ func runIssueView(cmd *cobra.Command, args []string) error {
Issue: issue,
Comments: comments,
}
return outputJSON(cmd, payload)
return writeJSON(payload)
}
if err := ios.StartPager(); err != nil {
fmt.Fprintf(ios.ErrOut, "warning: failed to start pager: %v\n", err)
}
defer ios.StopPager()
cs := ios.ColorScheme()
isTTY := ios.IsStdoutTTY()
fmt.Fprintf(ios.Out, "Issue #%d\n", issue.Index)
fmt.Fprintf(ios.Out, "Title: %s\n", cs.Bold(issue.Title))
fmt.Fprintf(ios.Out, "State: %s\n", issue.State)
fmt.Fprintf(ios.Out, "Author: %s\n", issue.Poster.UserName)
fmt.Fprintf(ios.Out, "Created: %s\n", text.FormatDate(issue.Created, isTTY))
fmt.Fprintf(ios.Out, "Updated: %s\n", text.FormatDate(issue.Updated, isTTY))
fmt.Printf("Issue #%d\n", issue.Index)
fmt.Printf("Title: %s\n", issue.Title)
fmt.Printf("State: %s\n", issue.State)
fmt.Printf("Author: %s\n", issue.Poster.UserName)
fmt.Printf("Created: %s\n", issue.Created.Format("2006-01-02 15:04:05"))
fmt.Printf("Updated: %s\n", issue.Updated.Format("2006-01-02 15:04:05"))
if issue.Body != "" {
fmt.Fprintf(ios.Out, "\n%s\n", issue.Body)
fmt.Printf("\n%s\n", issue.Body)
}
if len(comments) > 0 {
fmt.Fprintf(ios.Out, "\nComments (%d):\n", len(comments))
fmt.Printf("\nComments (%d):\n", len(comments))
for _, comment := range comments {
fmt.Fprintf(ios.Out, "\n---\n%s (@%s) - %s\n%s\n",
fmt.Printf("\n---\n%s (@%s) - %s\n%s\n",
comment.Poster.FullName,
comment.Poster.UserName,
text.FormatDate(comment.Created, isTTY),
comment.Created.Format("2006-01-02 15:04:05"),
comment.Body)
}
}
@ -360,28 +240,14 @@ func runIssueCreate(cmd *cobra.Command, args []string) error {
title, _ := cmd.Flags().GetString("title")
body, _ := cmd.Flags().GetString("body")
labelNames, _ := cmd.Flags().GetStringSlice("label")
assignees, _ := cmd.Flags().GetStringSlice("assignee")
milestoneName, _ := cmd.Flags().GetString("milestone")
owner, name, err := parseRepo(repo)
if err != nil {
return err
}
// Interactive mode: prompt for missing fields when TTY
if title == "" && ios.IsStdinTTY() {
title, err = promptLine("Title: ")
if err != nil {
return err
}
if title == "" {
return fmt.Errorf("title is required")
}
if body == "" {
body, _ = promptLine("Body (optional): ")
}
} else if title == "" {
return fmt.Errorf("title is required (use -t flag)")
if title == "" {
return fmt.Errorf("title is required")
}
cfg, err := config.Load()
@ -389,7 +255,7 @@ func runIssueCreate(cmd *cobra.Command, args []string) error {
return err
}
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd())
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost())
if err != nil {
return err
}
@ -402,56 +268,17 @@ func runIssueCreate(cmd *cobra.Command, args []string) error {
}
}
// Resolve @me in assignees
resolvedAssignees := make([]string, 0, len(assignees))
for _, assignee := range assignees {
if assignee == "@me" {
user, _, userErr := client.GetMyUserInfo()
if userErr != nil {
return fmt.Errorf("failed to get current user info: %w", userErr)
}
resolvedAssignees = append(resolvedAssignees, user.UserName)
} else {
resolvedAssignees = append(resolvedAssignees, assignee)
}
}
// Resolve milestone name to ID
var milestoneID int64
if milestoneName != "" {
milestones, _, msErr := client.ListRepoMilestones(owner, name, gitea.ListMilestoneOption{})
if msErr != nil {
return fmt.Errorf("failed to list milestones: %w", msErr)
}
found := false
for _, ms := range milestones {
if ms.Title == milestoneName {
milestoneID = ms.ID
found = true
break
}
}
if !found {
return fmt.Errorf("milestone not found: %s", milestoneName)
}
}
ios.StartSpinner("Creating issue...")
issue, _, err := client.CreateIssue(owner, name, gitea.CreateIssueOption{
Title: title,
Body: body,
Labels: labelIDs,
Assignees: resolvedAssignees,
Milestone: milestoneID,
Title: title,
Body: body,
Labels: labelIDs,
})
ios.StopSpinner()
if err != nil {
return fmt.Errorf("failed to create issue: %w", err)
}
cs := ios.ColorScheme()
fmt.Fprintf(ios.Out, "%s Issue created: #%d\n", cs.SuccessIcon(), issue.Index)
fmt.Fprintf(ios.Out, "View at: %s\n", issue.HTMLURL)
fmt.Printf("Issue created: #%d\n", issue.Index)
fmt.Printf("View at: %s\n", issue.HTMLURL)
return nil
}
@ -459,7 +286,7 @@ func runIssueCreate(cmd *cobra.Command, args []string) error {
func runIssueComment(cmd *cobra.Command, args []string) error {
repo, _ := cmd.Flags().GetString("repo")
body, _ := cmd.Flags().GetString("body")
issueNumber, err := parseIssueArg(args[0])
issueNumber, err := strconv.ParseInt(args[0], 10, 64)
if err != nil {
return fmt.Errorf("invalid issue number: %w", err)
}
@ -478,23 +305,20 @@ func runIssueComment(cmd *cobra.Command, args []string) error {
return err
}
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd())
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost())
if err != nil {
return err
}
ios.StartSpinner("Adding comment...")
comment, _, err := client.CreateIssueComment(owner, name, issueNumber, gitea.CreateIssueCommentOption{
Body: body,
})
ios.StopSpinner()
if err != nil {
return fmt.Errorf("failed to create comment: %w", err)
}
cs := ios.ColorScheme()
fmt.Fprintf(ios.Out, "%s Comment added to issue #%d\n", cs.SuccessIcon(), issueNumber)
fmt.Fprintf(ios.Out, "View at: %s\n", comment.HTMLURL)
fmt.Printf("Comment added to issue #%d\n", issueNumber)
fmt.Printf("View at: %s\n", comment.HTMLURL)
return nil
}
@ -502,7 +326,7 @@ func runIssueComment(cmd *cobra.Command, args []string) error {
func runIssueClose(cmd *cobra.Command, args []string) error {
repo, _ := cmd.Flags().GetString("repo")
commentBody, _ := cmd.Flags().GetString("comment")
issueNumber, err := parseIssueArg(args[0])
issueNumber, err := strconv.ParseInt(args[0], 10, 64)
if err != nil {
return fmt.Errorf("invalid issue number: %w", err)
}
@ -517,34 +341,29 @@ func runIssueClose(cmd *cobra.Command, args []string) error {
return err
}
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd())
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost())
if err != nil {
return err
}
if commentBody != "" {
ios.StartSpinner("Adding comment...")
_, _, err = client.CreateIssueComment(owner, name, issueNumber, gitea.CreateIssueCommentOption{
Body: commentBody,
})
ios.StopSpinner()
if err != nil {
return fmt.Errorf("failed to create comment: %w", err)
}
}
ios.StartSpinner("Closing issue...")
stateClosed := gitea.StateClosed
_, _, err = client.EditIssue(owner, name, issueNumber, gitea.EditIssueOption{
State: &stateClosed,
})
ios.StopSpinner()
if err != nil {
return fmt.Errorf("failed to close issue: %w", err)
}
cs := ios.ColorScheme()
fmt.Fprintf(ios.Out, "%s Issue #%d closed\n", cs.SuccessIcon(), issueNumber)
fmt.Printf("Issue #%d closed\n", issueNumber)
return nil
}
@ -559,7 +378,7 @@ func runIssueEdit(cmd *cobra.Command, args []string) error {
addDeps, _ := cmd.Flags().GetInt64Slice("add-dependency")
removeDeps, _ := cmd.Flags().GetInt64Slice("remove-dependency")
issueNumber, err := parseIssueArg(args[0])
issueNumber, err := strconv.ParseInt(args[0], 10, 64)
if err != nil {
return fmt.Errorf("invalid issue number: %w", err)
}
@ -578,7 +397,7 @@ func runIssueEdit(cmd *cobra.Command, args []string) error {
return err
}
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd())
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost())
if err != nil {
return err
}
@ -606,12 +425,9 @@ func runIssueEdit(cmd *cobra.Command, args []string) error {
}
}
ios.StartSpinner("Updating issue...")
if title != "" || body != "" || stateStr != "" {
_, _, err = client.EditIssue(owner, name, issueNumber, editOpt)
if err != nil {
ios.StopSpinner()
return fmt.Errorf("failed to edit issue: %w", err)
}
}
@ -619,14 +435,12 @@ func runIssueEdit(cmd *cobra.Command, args []string) error {
if len(addLabelNames) > 0 {
labelIDs, err := resolveLabelIDs(client, owner, name, addLabelNames)
if err != nil {
ios.StopSpinner()
return err
}
_, _, err = client.AddIssueLabels(owner, name, issueNumber, gitea.IssueLabelsOption{
Labels: labelIDs,
})
if err != nil {
ios.StopSpinner()
return fmt.Errorf("failed to add labels: %w", err)
}
}
@ -634,20 +448,16 @@ func runIssueEdit(cmd *cobra.Command, args []string) error {
if len(removeLabelNames) > 0 {
labelIDs, err := resolveLabelIDs(client, owner, name, removeLabelNames)
if err != nil {
ios.StopSpinner()
return err
}
for _, labelID := range labelIDs {
_, err = client.DeleteIssueLabel(owner, name, issueNumber, labelID)
if err != nil {
ios.StopSpinner()
return fmt.Errorf("failed to remove label %d: %w", labelID, err)
}
}
}
ios.StopSpinner()
for _, depNumber := range addDeps {
depIssue, _, err := client.GetIssue(owner, name, depNumber)
if err != nil {
@ -659,7 +469,7 @@ func runIssueEdit(cmd *cobra.Command, args []string) error {
if err != nil {
return fmt.Errorf("failed to add dependency #%d: %w", depNumber, err)
}
fmt.Fprintf(ios.Out, "Added dependency: #%d depends on #%d\n", issueNumber, depNumber)
fmt.Printf("Added dependency: #%d depends on #%d\n", issueNumber, depNumber)
}
for _, depNumber := range removeDeps {
@ -673,96 +483,10 @@ func runIssueEdit(cmd *cobra.Command, args []string) error {
if err != nil {
return fmt.Errorf("failed to remove dependency #%d: %w", depNumber, err)
}
fmt.Fprintf(ios.Out, "Removed dependency: #%d no longer depends on #%d\n", issueNumber, depNumber)
fmt.Printf("Removed dependency: #%d no longer depends on #%d\n", issueNumber, depNumber)
}
cs := ios.ColorScheme()
fmt.Fprintf(ios.Out, "%s Issue #%d updated\n", cs.SuccessIcon(), issueNumber)
return nil
}
func runIssueDelete(cmd *cobra.Command, args []string) error {
repo, _ := cmd.Flags().GetString("repo")
yes, _ := cmd.Flags().GetBool("yes")
issueNumber, err := parseIssueArg(args[0])
if err != nil {
return fmt.Errorf("invalid issue number: %w", err)
}
owner, name, err := parseRepo(repo)
if err != nil {
return err
}
cfg, err := config.Load()
if err != nil {
return err
}
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd())
if err != nil {
return err
}
if !yes {
confirmed, confirmErr := ios.ConfirmAction(fmt.Sprintf("Permanently delete issue #%d from %s/%s?", issueNumber, owner, name))
if confirmErr != nil {
return confirmErr
}
if !confirmed {
fmt.Fprintln(ios.ErrOut, "Aborted")
return nil
}
}
ios.StartSpinner("Deleting issue...")
_, err = client.DeleteIssue(owner, name, issueNumber)
ios.StopSpinner()
if err != nil {
return fmt.Errorf("failed to delete issue: %w", err)
}
cs := ios.ColorScheme()
fmt.Fprintf(ios.Out, "%s Issue #%d deleted from %s/%s\n", cs.SuccessIcon(), issueNumber, owner, name)
return nil
}
func runIssueReopen(cmd *cobra.Command, args []string) error {
repo, _ := cmd.Flags().GetString("repo")
issueNumber, err := parseIssueArg(args[0])
if err != nil {
return fmt.Errorf("invalid issue number: %w", err)
}
owner, name, err := parseRepo(repo)
if err != nil {
return err
}
cfg, err := config.Load()
if err != nil {
return err
}
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd())
if err != nil {
return err
}
ios.StartSpinner("Reopening issue...")
stateOpen := gitea.StateOpen
_, _, err = client.EditIssue(owner, name, issueNumber, gitea.EditIssueOption{
State: &stateOpen,
})
ios.StopSpinner()
if err != nil {
return fmt.Errorf("failed to reopen issue: %w", err)
}
cs := ios.ColorScheme()
fmt.Fprintf(ios.Out, "%s Issue #%d reopened\n", cs.SuccessIcon(), issueNumber)
fmt.Printf("Issue #%d updated\n", issueNumber)
return nil
}

View file

@ -2,155 +2,11 @@ package cmd
import (
"encoding/json"
"fmt"
"strings"
"github.com/itchyny/gojq"
"github.com/spf13/cobra"
"os"
)
// addJSONFlags adds --json, --json-fields, and --jq flags to a command.
//
// 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.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.
func wantJSON(cmd *cobra.Command) bool {
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-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 {
fields, _ := cmd.Flags().GetString("json-fields")
jqExpr, _ := cmd.Flags().GetString("jq")
return writeJSONFiltered(value, fields, jqExpr)
}
// writeJSON writes a value as pretty-printed JSON to ios.Out.
func writeJSON(value any) error {
enc := json.NewEncoder(ios.Out)
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(value)
}
// writeJSONFiltered writes a value as JSON, optionally selecting specific fields
// and/or applying a jq expression. If fields is empty and jqExpr is empty, it
// writes the full value.
func writeJSONFiltered(value any, fields string, jqExpr string) error {
// If no filtering, just write the full JSON.
if fields == "" && jqExpr == "" {
return writeJSON(value)
}
// Convert value to a generic interface via JSON round-trip so we can
// manipulate it with maps/slices.
raw, err := json.Marshal(value)
if err != nil {
return fmt.Errorf("marshaling JSON: %w", err)
}
var data any
if err := json.Unmarshal(raw, &data); err != nil {
return fmt.Errorf("unmarshaling JSON: %w", err)
}
// Apply field selection if specified.
if fields != "" {
fieldList := strings.Split(fields, ",")
for i, f := range fieldList {
fieldList[i] = strings.TrimSpace(f)
}
data = selectFields(data, fieldList)
}
// Apply jq expression if specified.
if jqExpr != "" {
return applyJQ(data, jqExpr)
}
return writeJSON(data)
}
// selectFields filters a JSON value to only include the specified fields.
// Works on both single objects and arrays of objects.
func selectFields(data any, fields []string) any {
switch v := data.(type) {
case []any:
result := make([]any, len(v))
for i, item := range v {
result[i] = selectFields(item, fields)
}
return result
case map[string]any:
result := make(map[string]any)
for _, field := range fields {
if val, ok := v[field]; ok {
result[field] = val
}
}
return result
default:
return data
}
}
// applyJQ applies a jq expression to data and writes each output value.
func applyJQ(data any, expr string) error {
query, err := gojq.Parse(expr)
if err != nil {
return fmt.Errorf("invalid jq expression: %w", err)
}
iter := query.Run(data)
enc := json.NewEncoder(ios.Out)
enc.SetIndent("", " ")
for {
v, ok := iter.Next()
if !ok {
break
}
if err, isErr := v.(error); isErr {
return fmt.Errorf("jq error: %w", err)
}
// For string values, print raw (no JSON encoding) to match jq behavior.
if s, ok := v.(string); ok {
fmt.Fprintln(ios.Out, s)
} else {
if err := enc.Encode(v); err != nil {
return err
}
}
}
return nil
}

View file

@ -2,11 +2,13 @@ package cmd
import (
"fmt"
"os"
"strings"
"text/tabwriter"
"code.gitea.io/sdk/gitea"
"forgejo.zerova.net/public/fj/internal/api"
"forgejo.zerova.net/public/fj/internal/config"
"forgejo.zerova.net/sid/fgj-sid/internal/api"
"forgejo.zerova.net/sid/fgj-sid/internal/config"
"github.com/spf13/cobra"
)
@ -21,13 +23,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
fj label list
fgj label list
# List labels for a specific repository
fj label list -R owner/repo
fgj label list -R owner/repo
# Output as JSON
fj label list --json`,
fgj label list --json`,
RunE: runLabelList,
}
@ -36,13 +38,13 @@ var labelCreateCmd = &cobra.Command{
Short: "Create a label",
Long: "Create a new label in a repository.",
Example: ` # Create a label with a color
fj label create bug -c ff0000
fgj label create bug -c ff0000
# Create a label with color and description
fj label create feature -c 00ff00 -d "New feature request"
fgj label create feature -c 00ff00 -d "New feature request"
# Create a label in a specific repository
fj label create urgent -c ff0000 -R owner/repo`,
fgj label create urgent -c ff0000 -R owner/repo`,
Args: cobra.ExactArgs(1),
RunE: runLabelCreate,
}
@ -52,13 +54,13 @@ var labelEditCmd = &cobra.Command{
Short: "Edit a label",
Long: "Edit an existing label in a repository.",
Example: ` # Rename a label
fj label edit bug --name bugfix
fgj label edit bug --name bugfix
# Change the color of a label
fj label edit bug -c 00ff00
fgj label edit bug -c 00ff00
# Update description
fj label edit bug -d "Something is broken"`,
fgj label edit bug -d "Something is broken"`,
Args: cobra.ExactArgs(1),
RunE: runLabelEdit,
}
@ -68,13 +70,10 @@ var labelDeleteCmd = &cobra.Command{
Short: "Delete a label",
Long: "Delete a label from a repository.",
Example: ` # Delete a label
fj label delete bug
# Delete without confirmation
fj label delete bug -y
fgj label delete bug
# Delete a label from a specific repository
fj label delete bug -R owner/repo`,
fgj label delete bug -R owner/repo`,
Args: cobra.ExactArgs(1),
RunE: runLabelDelete,
}
@ -87,21 +86,20 @@ func init() {
labelCmd.AddCommand(labelDeleteCmd)
labelListCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format")
addJSONFlags(labelListCmd, "Output as JSON")
labelListCmd.Flags().Bool("json", false, "Output as JSON")
labelCreateCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format")
labelCreateCmd.Flags().StringP("color", "c", "", "Label color (hex, e.g. 00ff00)")
labelCreateCmd.Flags().StringP("description", "d", "", "Label description")
addJSONFlags(labelCreateCmd, "Output as JSON")
labelCreateCmd.Flags().Bool("json", false, "Output as JSON")
labelEditCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format")
labelEditCmd.Flags().String("name", "", "New name for the label")
labelEditCmd.Flags().StringP("color", "c", "", "New color (hex, e.g. 00ff00)")
labelEditCmd.Flags().StringP("description", "d", "", "New description")
addJSONFlags(labelEditCmd, "Output as JSON")
labelEditCmd.Flags().Bool("json", false, "Output as JSON")
labelDeleteCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format")
labelDeleteCmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompt")
}
func newLabelClient(cmd *cobra.Command) (*api.Client, string, string, error) {
@ -116,7 +114,7 @@ func newLabelClient(cmd *cobra.Command) (*api.Client, string, string, error) {
return nil, "", "", err
}
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd())
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost())
if err != nil {
return nil, "", "", err
}
@ -146,28 +144,29 @@ func runLabelList(cmd *cobra.Command, args []string) error {
return err
}
ios.StartSpinner("Fetching labels...")
labels, _, err := client.ListRepoLabels(owner, name, gitea.ListLabelsOptions{})
ios.StopSpinner()
if err != nil {
return fmt.Errorf("failed to list labels: %w", err)
}
if wantJSON(cmd) {
return outputJSON(cmd, labels)
jsonFlag, _ := cmd.Flags().GetBool("json")
if jsonFlag {
return writeJSON(labels)
}
if len(labels) == 0 {
fmt.Fprintln(ios.Out, "No labels found")
fmt.Println("No labels found")
return nil
}
tp := ios.NewTablePrinter()
tp.AddHeader("NAME", "COLOR", "DESCRIPTION")
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
_, _ = fmt.Fprintf(w, "NAME\tCOLOR\tDESCRIPTION\n")
for _, l := range labels {
tp.AddRow(l.Name, l.Color, l.Description)
_, _ = fmt.Fprintf(w, "%s\t%s\t%s\n", l.Name, l.Color, l.Description)
}
return tp.Render()
_ = w.Flush()
return nil
}
func runLabelCreate(cmd *cobra.Command, args []string) error {
@ -180,23 +179,21 @@ func runLabelCreate(cmd *cobra.Command, args []string) error {
return err
}
ios.StartSpinner("Creating label...")
label, _, err := client.CreateLabel(owner, name, gitea.CreateLabelOption{
Name: labelName,
Color: color,
Description: description,
})
ios.StopSpinner()
if err != nil {
return fmt.Errorf("failed to create label: %w", err)
}
if wantJSON(cmd) {
return outputJSON(cmd, label)
jsonFlag, _ := cmd.Flags().GetBool("json")
if jsonFlag {
return writeJSON(label)
}
cs := ios.ColorScheme()
fmt.Fprintf(ios.Out, "%s Label created: %s\n", cs.SuccessIcon(), label.Name)
fmt.Printf("Label created: %s\n", label.Name)
return nil
}
@ -208,9 +205,7 @@ func runLabelEdit(cmd *cobra.Command, args []string) error {
return err
}
ios.StartSpinner("Fetching label...")
existing, err := findLabelByName(client, owner, name, labelName)
ios.StopSpinner()
if err != nil {
return err
}
@ -238,57 +233,46 @@ func runLabelEdit(cmd *cobra.Command, args []string) error {
return fmt.Errorf("no changes specified; use flags like --name, --color, or --description")
}
ios.StartSpinner("Updating label...")
label, _, err := client.EditLabel(owner, name, existing.ID, opt)
ios.StopSpinner()
if err != nil {
return fmt.Errorf("failed to edit label: %w", err)
}
if wantJSON(cmd) {
return outputJSON(cmd, label)
jsonFlag, _ := cmd.Flags().GetBool("json")
if jsonFlag {
return writeJSON(label)
}
cs := ios.ColorScheme()
fmt.Fprintf(ios.Out, "%s Label updated: %s\n", cs.SuccessIcon(), label.Name)
fmt.Printf("Label updated: %s\n", label.Name)
return nil
}
func runLabelDelete(cmd *cobra.Command, args []string) error {
labelName := args[0]
yes, _ := cmd.Flags().GetBool("yes")
client, owner, name, err := newLabelClient(cmd)
if err != nil {
return err
}
ios.StartSpinner("Fetching label...")
existing, err := findLabelByName(client, owner, name, labelName)
ios.StopSpinner()
if err != nil {
return err
}
if !yes {
confirmed, err := ios.ConfirmAction(fmt.Sprintf("Delete label %q?", labelName))
if err != nil {
return err
}
if !confirmed {
fmt.Fprintln(ios.ErrOut, "Aborted")
return nil
}
fmt.Printf("Are you sure you want to delete label %q? (y/N): ", labelName)
var confirm string
_, _ = fmt.Scanln(&confirm)
if strings.ToLower(confirm) != "y" {
fmt.Println("Aborted")
return nil
}
ios.StartSpinner("Deleting label...")
_, err = client.DeleteLabel(owner, name, existing.ID)
ios.StopSpinner()
if err != nil {
return fmt.Errorf("failed to delete label: %w", err)
}
cs := ios.ColorScheme()
fmt.Fprintf(ios.Out, "%s Label deleted: %s\n", cs.SuccessIcon(), labelName)
fmt.Printf("Label deleted: %s\n", labelName)
return nil
}

View file

@ -12,7 +12,7 @@ import (
var manpagesCmd = &cobra.Command{
Use: "manpages",
Short: "Generate manpages",
Long: "Generate manpages for fj commands.",
Long: "Generate manpages for fgj 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: "FJ",
Title: "FGJ",
Section: "1",
}

View file

@ -2,14 +2,15 @@ package cmd
import (
"fmt"
"os"
"strconv"
"strings"
"text/tabwriter"
"time"
"code.gitea.io/sdk/gitea"
"forgejo.zerova.net/public/fj/internal/api"
"forgejo.zerova.net/public/fj/internal/config"
"forgejo.zerova.net/public/fj/internal/text"
"forgejo.zerova.net/sid/fgj-sid/internal/api"
"forgejo.zerova.net/sid/fgj-sid/internal/config"
"github.com/spf13/cobra"
)
@ -24,13 +25,13 @@ var milestoneListCmd = &cobra.Command{
Short: "List milestones",
Long: "List milestones in a repository.",
Example: ` # List open milestones
fj milestone list
fgj milestone list
# List all milestones for a specific repo
fj milestone list -R owner/repo --state all
fgj milestone list -R owner/repo --state all
# Output as JSON
fj milestone list --json`,
fgj milestone list --json`,
RunE: runMilestoneList,
}
@ -39,16 +40,13 @@ var milestoneViewCmd = &cobra.Command{
Short: "View a milestone",
Long: "Display detailed information about a milestone.",
Example: ` # View by ID
fj milestone view 1
fgj milestone view 1
# View by title
fj milestone view "v1.0"
# Open in browser
fj milestone view "v1.0" --web
fgj milestone view "v1.0"
# Output as JSON
fj milestone view "v1.0" --json`,
fgj milestone view "v1.0" --json`,
Args: cobra.ExactArgs(1),
RunE: runMilestoneView,
}
@ -58,13 +56,13 @@ var milestoneCreateCmd = &cobra.Command{
Short: "Create a milestone",
Long: "Create a new milestone.",
Example: ` # Create a simple milestone
fj milestone create "v1.0"
fgj milestone create "v1.0"
# Create with description and due date
fj milestone create "v2.0" -d "Second release" --due 2026-06-01
fgj milestone create "v2.0" -d "Second release" --due 2026-06-01
# Output as JSON
fj milestone create "v1.0" --json`,
fgj milestone create "v1.0" --json`,
Args: cobra.ExactArgs(1),
RunE: runMilestoneCreate,
}
@ -74,13 +72,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
fj milestone edit "v1.0" --title "v1.1"
fgj milestone edit "v1.0" --title "v1.1"
# Close a milestone
fj milestone edit "v1.0" --state closed
fgj milestone edit "v1.0" --state closed
# Update due date
fj milestone edit 1 --due 2026-12-31`,
fgj milestone edit 1 --due 2026-12-31`,
Args: cobra.ExactArgs(1),
RunE: runMilestoneEdit,
}
@ -90,13 +88,10 @@ var milestoneDeleteCmd = &cobra.Command{
Short: "Delete a milestone",
Long: "Delete an existing milestone.",
Example: ` # Delete by title
fj milestone delete "v1.0"
fgj milestone delete "v1.0"
# Delete by ID
fj milestone delete 1
# Delete without confirmation
fj milestone delete "v1.0" -y`,
fgj milestone delete 1`,
Args: cobra.ExactArgs(1),
RunE: runMilestoneDelete,
}
@ -111,26 +106,24 @@ func init() {
milestoneListCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format")
milestoneListCmd.Flags().String("state", "open", "Filter by state: open, closed, all")
addJSONFlags(milestoneListCmd, "Output milestones as JSON")
milestoneListCmd.Flags().Bool("json", false, "Output milestones as JSON")
milestoneViewCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format")
addJSONFlags(milestoneViewCmd, "Output milestone as JSON")
milestoneViewCmd.Flags().BoolP("web", "w", false, "Open in web browser")
milestoneViewCmd.Flags().Bool("json", false, "Output milestone as JSON")
milestoneCreateCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format")
milestoneCreateCmd.Flags().StringP("description", "d", "", "Description of the milestone")
milestoneCreateCmd.Flags().String("due", "", "Due date in YYYY-MM-DD format")
addJSONFlags(milestoneCreateCmd, "Output created milestone as JSON")
milestoneCreateCmd.Flags().Bool("json", false, "Output created milestone as JSON")
milestoneEditCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format")
milestoneEditCmd.Flags().String("title", "", "New title for the milestone")
milestoneEditCmd.Flags().StringP("description", "d", "", "New description for the milestone")
milestoneEditCmd.Flags().String("due", "", "Due date in YYYY-MM-DD format")
milestoneEditCmd.Flags().String("state", "", "New state: open or closed")
addJSONFlags(milestoneEditCmd, "Output updated milestone as JSON")
milestoneEditCmd.Flags().Bool("json", false, "Output updated milestone as JSON")
milestoneDeleteCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format")
milestoneDeleteCmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompt")
}
// resolveMilestone resolves a title-or-id argument to a milestone.
@ -183,7 +176,7 @@ func runMilestoneList(cmd *cobra.Command, args []string) error {
return err
}
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd())
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost())
if err != nil {
return err
}
@ -200,40 +193,35 @@ func runMilestoneList(cmd *cobra.Command, args []string) error {
return fmt.Errorf("invalid state: %s", state)
}
ios.StartSpinner("Fetching milestones...")
milestones, _, err := client.ListRepoMilestones(owner, name, gitea.ListMilestoneOption{
State: stateType,
})
ios.StopSpinner()
if err != nil {
return fmt.Errorf("failed to list milestones: %w", err)
}
if wantJSON(cmd) {
return outputJSON(cmd, milestones)
if jsonOutput, _ := cmd.Flags().GetBool("json"); jsonOutput {
return writeJSON(milestones)
}
if len(milestones) == 0 {
fmt.Fprintf(ios.Out, "No %s milestones in %s/%s\n", state, owner, name)
fmt.Printf("No %s milestones in %s/%s\n", state, owner, name)
return nil
}
tp := ios.NewTablePrinter()
tp.AddHeader("ID", "TITLE", "STATE", "DUE DATE", "OPEN/CLOSED ISSUES")
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
_, _ = fmt.Fprintf(w, "ID\tTITLE\tSTATE\tDUE DATE\tOPEN/CLOSED ISSUES\n")
for _, ms := range milestones {
due := ""
if ms.Deadline != nil {
due = ms.Deadline.Format("2006-01-02")
}
tp.AddRow(
fmt.Sprintf("%d", ms.ID),
ms.Title,
string(ms.State),
due,
fmt.Sprintf("%d/%d", ms.OpenIssues, ms.ClosedIssues),
)
_, _ = fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%d/%d\n",
ms.ID, ms.Title, ms.State, due, ms.OpenIssues, ms.ClosedIssues)
}
return tp.Render()
_ = w.Flush()
return nil
}
func runMilestoneView(cmd *cobra.Command, args []string) error {
@ -249,50 +237,37 @@ func runMilestoneView(cmd *cobra.Command, args []string) error {
return err
}
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd())
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost())
if err != nil {
return err
}
ios.StartSpinner("Fetching milestone...")
ms, err := resolveMilestone(client, owner, name, args[0])
ios.StopSpinner()
if err != nil {
return err
}
if web, _ := cmd.Flags().GetBool("web"); web {
// Milestones don't have HTMLURL in the API, construct it
cfg2, _ := config.Load()
host, _ := cfg2.GetHost("", getDetectedHost(), getCwd())
url := fmt.Sprintf("https://%s/%s/%s/milestone/%d", host.Hostname, owner, name, ms.ID)
return ios.OpenInBrowser(url)
if jsonOutput, _ := cmd.Flags().GetBool("json"); jsonOutput {
return writeJSON(ms)
}
if wantJSON(cmd) {
return outputJSON(cmd, ms)
}
cs := ios.ColorScheme()
isTTY := ios.IsStdoutTTY()
fmt.Fprintf(ios.Out, "ID: %d\n", ms.ID)
fmt.Fprintf(ios.Out, "Title: %s\n", cs.Bold(ms.Title))
fmt.Fprintf(ios.Out, "State: %s\n", ms.State)
fmt.Printf("ID: %d\n", ms.ID)
fmt.Printf("Title: %s\n", ms.Title)
fmt.Printf("State: %s\n", ms.State)
if ms.Description != "" {
fmt.Fprintf(ios.Out, "Description: %s\n", ms.Description)
fmt.Printf("Description: %s\n", ms.Description)
}
if ms.Deadline != nil {
fmt.Fprintf(ios.Out, "Due Date: %s\n", ms.Deadline.Format("2006-01-02"))
fmt.Printf("Due Date: %s\n", ms.Deadline.Format("2006-01-02"))
}
fmt.Fprintf(ios.Out, "Open Issues: %d\n", ms.OpenIssues)
fmt.Fprintf(ios.Out, "Closed Issues: %d\n", ms.ClosedIssues)
fmt.Fprintf(ios.Out, "Created: %s\n", text.FormatDate(ms.Created, isTTY))
fmt.Printf("Open Issues: %d\n", ms.OpenIssues)
fmt.Printf("Closed Issues: %d\n", ms.ClosedIssues)
fmt.Printf("Created: %s\n", ms.Created.Format("2006-01-02 15:04:05"))
if ms.Updated != nil {
fmt.Fprintf(ios.Out, "Updated: %s\n", text.FormatDate(*ms.Updated, isTTY))
fmt.Printf("Updated: %s\n", ms.Updated.Format("2006-01-02 15:04:05"))
}
if ms.Closed != nil {
fmt.Fprintf(ios.Out, "Closed: %s\n", text.FormatDate(*ms.Closed, isTTY))
fmt.Printf("Closed: %s\n", ms.Closed.Format("2006-01-02 15:04:05"))
}
return nil
@ -315,7 +290,7 @@ func runMilestoneCreate(cmd *cobra.Command, args []string) error {
return err
}
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd())
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost())
if err != nil {
return err
}
@ -333,19 +308,16 @@ func runMilestoneCreate(cmd *cobra.Command, args []string) error {
opt.Deadline = deadline
}
ios.StartSpinner("Creating milestone...")
ms, _, err := client.CreateMilestone(owner, name, opt)
ios.StopSpinner()
if err != nil {
return fmt.Errorf("failed to create milestone: %w", err)
}
if wantJSON(cmd) {
return outputJSON(cmd, ms)
if jsonOutput, _ := cmd.Flags().GetBool("json"); jsonOutput {
return writeJSON(ms)
}
cs := ios.ColorScheme()
fmt.Fprintf(ios.Out, "%s Milestone created: %s\n", cs.SuccessIcon(), ms.Title)
fmt.Printf("Milestone created: %s\n", ms.Title)
return nil
}
@ -363,14 +335,12 @@ func runMilestoneEdit(cmd *cobra.Command, args []string) error {
return err
}
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd())
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost())
if err != nil {
return err
}
ios.StartSpinner("Fetching milestone...")
ms, err := resolveMilestone(client, owner, name, args[0])
ios.StopSpinner()
if err != nil {
return err
}
@ -419,26 +389,22 @@ func runMilestoneEdit(cmd *cobra.Command, args []string) error {
return fmt.Errorf("no changes specified; use flags like --title, --description, --due, or --state")
}
ios.StartSpinner("Updating milestone...")
updated, _, err := client.EditMilestone(owner, name, ms.ID, opt)
ios.StopSpinner()
if err != nil {
return fmt.Errorf("failed to edit milestone: %w", err)
}
if wantJSON(cmd) {
return outputJSON(cmd, updated)
if jsonOutput, _ := cmd.Flags().GetBool("json"); jsonOutput {
return writeJSON(updated)
}
cs := ios.ColorScheme()
fmt.Fprintf(ios.Out, "%s Milestone updated: %s\n", cs.SuccessIcon(), updated.Title)
fmt.Printf("Milestone updated: %s\n", updated.Title)
return nil
}
func runMilestoneDelete(cmd *cobra.Command, args []string) error {
repo, _ := cmd.Flags().GetString("repo")
yes, _ := cmd.Flags().GetBool("yes")
owner, name, err := parseRepo(repo)
if err != nil {
@ -450,38 +416,22 @@ func runMilestoneDelete(cmd *cobra.Command, args []string) error {
return err
}
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd())
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost())
if err != nil {
return err
}
ios.StartSpinner("Fetching milestone...")
ms, err := resolveMilestone(client, owner, name, args[0])
ios.StopSpinner()
if err != nil {
return err
}
if !yes {
confirmed, err := ios.ConfirmAction(fmt.Sprintf("Delete milestone %q?", ms.Title))
if err != nil {
return err
}
if !confirmed {
fmt.Fprintln(ios.ErrOut, "Aborted")
return nil
}
}
ios.StartSpinner("Deleting milestone...")
_, err = client.DeleteMilestone(owner, name, ms.ID)
ios.StopSpinner()
if err != nil {
return fmt.Errorf("failed to delete milestone: %w", err)
}
cs := ios.ColorScheme()
fmt.Fprintf(ios.Out, "%s Milestone deleted: %s\n", cs.SuccessIcon(), ms.Title)
fmt.Printf("Milestone deleted: %s\n", ms.Title)
return nil
}

View file

@ -1,43 +0,0 @@
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
}

871
cmd/pr.go

File diff suppressed because it is too large Load diff

View file

@ -1,99 +0,0 @@
package cmd
import (
"fmt"
"code.gitea.io/sdk/gitea"
"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"
)
var prChecksCmd = &cobra.Command{
Use: "checks <number>",
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
fj pr checks 5
# Output as JSON
fj pr checks 5 --json`,
Args: cobra.ExactArgs(1),
RunE: runPRChecks,
}
func init() {
prCmd.AddCommand(prChecksCmd)
prChecksCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format")
addJSONFlags(prChecksCmd, "Output checks as JSON")
}
func runPRChecks(cmd *cobra.Command, args []string) error {
repo, _ := cmd.Flags().GetString("repo")
prNumber, err := parseIssueArg(args[0])
if err != nil {
return fmt.Errorf("invalid pull request number: %w", err)
}
owner, name, err := parseRepo(repo)
if err != nil {
return err
}
cfg, err := config.Load()
if err != nil {
return err
}
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd())
if err != nil {
return err
}
ios.StartSpinner("Fetching pull request...")
pr, _, err := client.GetPullRequest(owner, name, prNumber)
if err != nil {
ios.StopSpinner()
return fmt.Errorf("failed to get pull request: %w", err)
}
statuses, _, err := client.ListStatuses(owner, name, pr.Head.Sha, gitea.ListStatusesOption{})
ios.StopSpinner()
if err != nil {
return fmt.Errorf("failed to get commit statuses: %w", err)
}
if wantJSON(cmd) {
return outputJSON(cmd, statuses)
}
if len(statuses) == 0 {
fmt.Fprintf(ios.Out, "No status checks found for PR #%d\n", prNumber)
return nil
}
cs := ios.ColorScheme()
tp := ios.NewTablePrinter()
tp.AddHeader("STATUS", "CONTEXT", "DESCRIPTION")
for _, s := range statuses {
status := formatCheckStatus(s.State, cs)
tp.AddRow(status, s.Context, s.Description)
}
return tp.Render()
}
func formatCheckStatus(state gitea.StatusState, cs *iostreams.ColorScheme) string {
switch state {
case gitea.StatusSuccess:
return cs.Green("pass")
case gitea.StatusFailure, gitea.StatusError:
return cs.Red("fail")
case gitea.StatusPending:
return cs.Yellow("pending")
case gitea.StatusWarning:
return cs.Yellow("warn")
default:
return string(state)
}
}

View file

@ -2,11 +2,14 @@ package cmd
import (
"fmt"
"os"
"strconv"
"strings"
"forgejo.zerova.net/public/fj/internal/api"
"forgejo.zerova.net/public/fj/internal/config"
"forgejo.zerova.net/sid/fgj-sid/internal/api"
"forgejo.zerova.net/sid/fgj-sid/internal/config"
"github.com/spf13/cobra"
"golang.org/x/term"
)
var prDiffCmd = &cobra.Command{
@ -14,16 +17,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
fj pr diff 123
fgj pr diff 123
# Colorized diff output
fj pr diff 123 --color always
fgj pr diff 123 --color always
# Show only changed file names
fj pr diff 123 --name-only
fgj pr diff 123 --name-only
# Show diffstat summary
fj pr diff 123 --stat`,
fgj pr diff 123 --stat`,
Args: cobra.ExactArgs(1),
RunE: runPRDiff,
}
@ -43,7 +46,7 @@ func runPRDiff(cmd *cobra.Command, args []string) error {
nameOnly, _ := cmd.Flags().GetBool("name-only")
stat, _ := cmd.Flags().GetBool("stat")
prNumber, err := parseIssueArg(args[0])
prNumber, err := strconv.ParseInt(args[0], 10, 64)
if err != nil {
return fmt.Errorf("invalid pull request number: %w", err)
}
@ -58,7 +61,7 @@ func runPRDiff(cmd *cobra.Command, args []string) error {
return err
}
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd())
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost())
if err != nil {
return err
}
@ -66,9 +69,7 @@ func runPRDiff(cmd *cobra.Command, args []string) error {
diffURL := fmt.Sprintf("https://%s/api/v1/repos/%s/%s/pulls/%d.diff",
client.Hostname(), owner, name, prNumber)
ios.StartSpinner("Fetching diff...")
diff, err := client.GetRawLog(diffURL)
ios.StopSpinner()
if err != nil {
return fmt.Errorf("failed to get pull request diff: %w", err)
}
@ -81,18 +82,12 @@ func runPRDiff(cmd *cobra.Command, args []string) error {
return printDiffStat(diff)
}
// Start pager for diffs
if err := ios.StartPager(); err != nil {
fmt.Fprintf(ios.ErrOut, "warning: failed to start pager: %v\n", err)
}
defer ios.StopPager()
useColor := shouldColorize(colorMode)
if useColor {
return printColorizedDiff(diff)
}
fmt.Fprint(ios.Out, diff)
fmt.Print(diff)
return nil
}
@ -104,7 +99,7 @@ func shouldColorize(mode string) bool {
case "never":
return false
default: // "auto"
return ios.ColorEnabled()
return term.IsTerminal(int(os.Stdout.Fd()))
}
}
@ -116,7 +111,7 @@ func printNameOnly(diff string) error {
name := strings.TrimPrefix(line, "+++ b/")
if name != "" && !seen[name] {
seen[name] = true
fmt.Fprintln(ios.Out, name)
fmt.Println(name)
}
}
}
@ -125,9 +120,9 @@ func printNameOnly(diff string) error {
// fileStat holds per-file diff statistics.
type fileStat struct {
name string
additions int
deletions int
name string
additions int
deletions int
}
// printDiffStat parses the diff and prints a diffstat summary.
@ -170,12 +165,10 @@ func printDiffStat(diff string) error {
}
if len(stats) == 0 {
fmt.Fprintln(ios.Out, "0 files changed")
fmt.Println("0 files changed")
return nil
}
cs := ios.ColorScheme()
// Find the longest file name for alignment
maxNameLen := 0
maxChanges := 0
@ -217,36 +210,44 @@ func printDiffStat(diff string) error {
scaledDel = 1
}
}
bar = cs.Green(strings.Repeat("+", scaledAdd)) + cs.Red(strings.Repeat("-", scaledDel))
bar = strings.Repeat("+", scaledAdd) + strings.Repeat("-", scaledDel)
}
fmt.Fprintf(ios.Out, " %-*s | %4d %s\n", maxNameLen, s.name, total, bar)
fmt.Printf(" %-*s | %4d %s\n", maxNameLen, s.name, total, bar)
}
fmt.Fprintf(ios.Out, " %d file", len(stats))
fmt.Printf(" %d file", len(stats))
if len(stats) != 1 {
fmt.Fprint(ios.Out, "s")
fmt.Print("s")
}
fmt.Fprintf(ios.Out, " changed, %d insertion", totalAdditions)
fmt.Printf(" changed, %d insertion", totalAdditions)
if totalAdditions != 1 {
fmt.Fprint(ios.Out, "s")
fmt.Print("s")
}
fmt.Fprintf(ios.Out, "(+), %d deletion", totalDeletions)
fmt.Printf("(+), %d deletion", totalDeletions)
if totalDeletions != 1 {
fmt.Fprint(ios.Out, "s")
fmt.Print("s")
}
fmt.Fprintln(ios.Out, "(-)")
fmt.Println("(-)")
return nil
}
// printColorizedDiff prints the diff with ANSI color codes using ColorScheme.
// ANSI color codes for diff output.
const (
colorReset = "\033[0m"
colorRed = "\033[31m"
colorGreen = "\033[32m"
colorCyan = "\033[36m"
colorBold = "\033[1m"
)
// printColorizedDiff prints the diff with ANSI color codes.
func printColorizedDiff(diff string) error {
cs := ios.ColorScheme()
for _, line := range strings.Split(diff, "\n") {
switch {
case strings.HasPrefix(line, "diff --git "):
fmt.Fprintln(ios.Out, cs.Bold(line))
fmt.Println(colorBold + line + colorReset)
case strings.HasPrefix(line, "index "),
strings.HasPrefix(line, "--- "),
strings.HasPrefix(line, "+++ "),
@ -255,15 +256,15 @@ func printColorizedDiff(diff string) error {
strings.HasPrefix(line, "similarity index"),
strings.HasPrefix(line, "rename from"),
strings.HasPrefix(line, "rename to"):
fmt.Fprintln(ios.Out, cs.Bold(line))
fmt.Println(colorBold + line + colorReset)
case strings.HasPrefix(line, "@@"):
fmt.Fprintln(ios.Out, cs.Cyan(line))
fmt.Println(colorCyan + line + colorReset)
case strings.HasPrefix(line, "+"):
fmt.Fprintln(ios.Out, cs.Green(line))
fmt.Println(colorGreen + line + colorReset)
case strings.HasPrefix(line, "-"):
fmt.Fprintln(ios.Out, cs.Red(line))
fmt.Println(colorRed + line + colorReset)
default:
fmt.Fprintln(ios.Out, line)
fmt.Println(line)
}
}
return nil

View file

@ -4,10 +4,11 @@ import (
"fmt"
"io"
"os"
"strconv"
"code.gitea.io/sdk/gitea"
"forgejo.zerova.net/public/fj/internal/api"
"forgejo.zerova.net/public/fj/internal/config"
"forgejo.zerova.net/sid/fgj-sid/internal/api"
"forgejo.zerova.net/sid/fgj-sid/internal/config"
"github.com/spf13/cobra"
)
@ -16,16 +17,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
fj pr comment 123 -b "Looks good!"
fgj pr comment 123 -b "Looks good!"
# Comment from a file
fj pr comment 123 --body-file review-notes.md
fgj pr comment 123 --body-file review-notes.md
# Comment from stdin
echo "LGTM" | fj pr comment 123 --body-file -
echo "LGTM" | fgj pr comment 123 --body-file -
# Output as JSON
fj pr comment 123 -b "Nice work" --json`,
fgj pr comment 123 -b "Nice work" --json`,
Args: cobra.ExactArgs(1),
RunE: runPRComment,
}
@ -35,16 +36,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
fj pr review 123 --approve -b "LGTM"
fgj pr review 123 --approve -b "LGTM"
# Request changes
fj pr review 123 --request-changes -b "Please fix the error handling"
fgj pr review 123 --request-changes -b "Please fix the error handling"
# Submit a review comment
fj pr review 123 --comment -b "Some observations"
fgj pr review 123 --comment -b "Some observations"
# Request changes with body from file
fj pr review 123 --request-changes --body-file feedback.md`,
fgj pr review 123 --request-changes --body-file feedback.md`,
Args: cobra.ExactArgs(1),
RunE: runPRReview,
}
@ -56,7 +57,7 @@ func init() {
prCommentCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format")
prCommentCmd.Flags().StringP("body", "b", "", "Comment body")
prCommentCmd.Flags().String("body-file", "", "Read body from file (use \"-\" for stdin)")
addJSONFlags(prCommentCmd, "Output created comment as JSON")
prCommentCmd.Flags().Bool("json", false, "Output created comment as JSON")
prReviewCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format")
prReviewCmd.Flags().BoolP("approve", "a", false, "Approve the pull request")
@ -64,7 +65,7 @@ func init() {
prReviewCmd.Flags().BoolP("comment", "c", false, "Submit as a review comment")
prReviewCmd.Flags().StringP("body", "b", "", "Review body/message")
prReviewCmd.Flags().String("body-file", "", "Read body from file (use \"-\" for stdin)")
addJSONFlags(prReviewCmd, "Output created review as JSON")
prReviewCmd.Flags().Bool("json", false, "Output created review as JSON")
}
// readBody resolves the body text from --body and --body-file flags.
@ -97,7 +98,7 @@ func readBody(cmd *cobra.Command) (string, error) {
func runPRComment(cmd *cobra.Command, args []string) error {
repo, _ := cmd.Flags().GetString("repo")
prNumber, err := parseIssueArg(args[0])
prNumber, err := strconv.ParseInt(args[0], 10, 64)
if err != nil {
return fmt.Errorf("invalid pull request number: %w", err)
}
@ -121,27 +122,24 @@ func runPRComment(cmd *cobra.Command, args []string) error {
return err
}
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd())
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost())
if err != nil {
return err
}
ios.StartSpinner("Adding comment...")
comment, _, err := client.CreateIssueComment(owner, name, prNumber, gitea.CreateIssueCommentOption{
Body: body,
})
ios.StopSpinner()
if err != nil {
return fmt.Errorf("failed to create comment: %w", err)
}
if wantJSON(cmd) {
return outputJSON(cmd, comment)
if jsonOutput, _ := cmd.Flags().GetBool("json"); jsonOutput {
return writeJSON(comment)
}
cs := ios.ColorScheme()
fmt.Fprintf(ios.Out, "%s Comment added to PR #%d\n", cs.SuccessIcon(), prNumber)
fmt.Fprintf(ios.Out, "View at: %s\n", comment.HTMLURL)
fmt.Printf("Comment added to PR #%d\n", prNumber)
fmt.Printf("View at: %s\n", comment.HTMLURL)
return nil
}
@ -152,7 +150,7 @@ func runPRReview(cmd *cobra.Command, args []string) error {
requestChanges, _ := cmd.Flags().GetBool("request-changes")
commentReview, _ := cmd.Flags().GetBool("comment")
prNumber, err := parseIssueArg(args[0])
prNumber, err := strconv.ParseInt(args[0], 10, 64)
if err != nil {
return fmt.Errorf("invalid pull request number: %w", err)
}
@ -191,7 +189,7 @@ func runPRReview(cmd *cobra.Command, args []string) error {
return err
}
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd())
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost())
if err != nil {
return err
}
@ -210,24 +208,21 @@ func runPRReview(cmd *cobra.Command, args []string) error {
action = "reviewed with comment"
}
ios.StartSpinner("Submitting review...")
review, _, err := client.CreatePullReview(owner, name, prNumber, gitea.CreatePullReviewOptions{
State: state,
Body: body,
})
ios.StopSpinner()
if err != nil {
return fmt.Errorf("failed to create review: %w", err)
}
if wantJSON(cmd) {
return outputJSON(cmd, review)
if jsonOutput, _ := cmd.Flags().GetBool("json"); jsonOutput {
return writeJSON(review)
}
cs := ios.ColorScheme()
fmt.Fprintf(ios.Out, "%s PR #%d %s\n", cs.SuccessIcon(), prNumber, action)
fmt.Printf("PR #%d %s\n", prNumber, action)
if review.HTMLURL != "" {
fmt.Fprintf(ios.Out, "View at: %s\n", review.HTMLURL)
fmt.Printf("View at: %s\n", review.HTMLURL)
}
return nil

View file

@ -3,15 +3,14 @@ package cmd
import (
"fmt"
"os"
"path"
"path/filepath"
"strings"
"text/tabwriter"
"time"
"code.gitea.io/sdk/gitea"
"forgejo.zerova.net/public/fj/internal/api"
"forgejo.zerova.net/public/fj/internal/config"
"forgejo.zerova.net/public/fj/internal/text"
"forgejo.zerova.net/sid/fgj-sid/internal/api"
"forgejo.zerova.net/sid/fgj-sid/internal/config"
"github.com/spf13/cobra"
)
@ -26,98 +25,39 @@ var releaseListCmd = &cobra.Command{
Use: "list",
Short: "List releases",
Long: "List releases in a repository.",
Example: ` # List releases
fj release list
# List only draft releases
fj release list --draft
# Output as JSON with a custom limit
fj release list --json --limit 10`,
RunE: runReleaseList,
RunE: runReleaseList,
}
var releaseViewCmd = &cobra.Command{
Use: "view <tag|latest>",
Short: "View a release",
Long: "Display detailed information about a release.",
Example: ` # View a release by tag
fj release view v1.0.0
# View the latest release
fj release view latest
# Open in browser
fj release view v1.0.0 --web
# Output as JSON
fj release view v1.0.0 --json`,
Args: cobra.ExactArgs(1),
RunE: runReleaseView,
Args: cobra.ExactArgs(1),
RunE: runReleaseView,
}
var releaseCreateCmd = &cobra.Command{
Use: "create <tag> [files...]",
Short: "Create a release",
Long: "Create a new release and optionally upload assets.",
Example: ` # Create a release
fj release create v1.0.0
# Create with title and notes
fj release create v1.0.0 -t "First stable release" -n "Bug fixes and improvements"
# Create a draft prerelease with assets
fj release create v2.0.0-rc1 --draft --prerelease dist/*.tar.gz
# Create from release notes file
fj release create v1.0.0 -F CHANGELOG.md`,
Args: cobra.MinimumNArgs(1),
RunE: runReleaseCreate,
Args: cobra.MinimumNArgs(1),
RunE: runReleaseCreate,
}
var releaseUploadCmd = &cobra.Command{
Use: "upload <tag|latest> <files...>",
Short: "Upload release assets",
Long: "Upload assets to an existing release.",
Example: ` # Upload assets to a release
fj release upload v1.0.0 dist/app-linux-amd64 dist/app-darwin-arm64
# Upload to the latest release, overwriting existing assets
fj release upload latest build/output.zip --clobber`,
Args: cobra.MinimumNArgs(2),
RunE: runReleaseUpload,
}
var releaseDownloadCmd = &cobra.Command{
Use: "download <tag>",
Short: "Download release assets",
Long: "Download assets from a release.",
Example: ` # Download all assets from a release
fj release download v1.0.0
# Download to a specific directory
fj release download v1.0.0 -D ./downloads
# Download a specific asset by name pattern
fj release download v1.0.0 -p "*.tar.gz"`,
Args: cobra.ExactArgs(1),
RunE: runReleaseDownload,
Args: cobra.MinimumNArgs(2),
RunE: runReleaseUpload,
}
var releaseDeleteCmd = &cobra.Command{
Use: "delete <tag|latest>",
Short: "Delete a release",
Long: "Delete a release by tag, keeping its Git tag intact.",
Example: ` # Delete a release by tag
fj release delete v1.0.0
# Delete the latest release
fj release delete latest
# Delete without confirmation
fj release delete v1.0.0 -y`,
Args: cobra.ExactArgs(1),
RunE: runReleaseDelete,
Args: cobra.ExactArgs(1),
RunE: runReleaseDelete,
}
func init() {
@ -126,18 +66,16 @@ func init() {
releaseCmd.AddCommand(releaseViewCmd)
releaseCmd.AddCommand(releaseCreateCmd)
releaseCmd.AddCommand(releaseUploadCmd)
releaseCmd.AddCommand(releaseDownloadCmd)
releaseCmd.AddCommand(releaseDeleteCmd)
releaseListCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format")
releaseListCmd.Flags().Bool("draft", false, "Filter by draft status")
releaseListCmd.Flags().Bool("prerelease", false, "Filter by prerelease status")
releaseListCmd.Flags().Int("limit", 30, "Maximum number of releases to fetch")
addJSONFlags(releaseListCmd, "Output releases as JSON")
releaseListCmd.Flags().Bool("json", false, "Output releases as JSON")
releaseViewCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format")
addJSONFlags(releaseViewCmd, "Output release as JSON")
releaseViewCmd.Flags().BoolP("web", "w", false, "Open in web browser")
releaseViewCmd.Flags().Bool("json", false, "Output release as JSON")
releaseCreateCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format")
releaseCreateCmd.Flags().StringP("title", "t", "", "Release title (defaults to tag)")
@ -150,12 +88,7 @@ func init() {
releaseUploadCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format")
releaseUploadCmd.Flags().Bool("clobber", false, "Overwrite assets with the same name")
releaseDownloadCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format")
releaseDownloadCmd.Flags().StringP("dir", "D", ".", "Directory to download files into")
releaseDownloadCmd.Flags().StringP("pattern", "p", "", "Glob pattern to filter assets by name")
releaseDeleteCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format")
releaseDeleteCmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompt")
}
func runReleaseList(cmd *cobra.Command, args []string) error {
@ -180,7 +113,7 @@ func runReleaseList(cmd *cobra.Command, args []string) error {
return err
}
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd())
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost())
if err != nil {
return err
}
@ -198,13 +131,11 @@ func runReleaseList(cmd *cobra.Command, args []string) error {
opts.IsPreRelease = &prereleaseValue
}
ios.StartSpinner("Fetching releases...")
var releases []*gitea.Release
for page := 1; len(releases) < limit; page++ {
opts.ListOptions = gitea.ListOptions{Page: page, PageSize: pageSize}
batch, _, err := client.ListReleases(owner, name, opts)
if err != nil {
ios.StopSpinner()
return fmt.Errorf("failed to list releases: %w", err)
}
if len(batch) == 0 {
@ -212,29 +143,29 @@ func runReleaseList(cmd *cobra.Command, args []string) error {
}
releases = append(releases, batch...)
}
ios.StopSpinner()
if len(releases) > limit {
releases = releases[:limit]
}
if wantJSON(cmd) {
return outputJSON(cmd, releases)
if jsonOutput, _ := cmd.Flags().GetBool("json"); jsonOutput {
return writeJSON(releases)
}
if len(releases) == 0 {
fmt.Fprintf(ios.Out, "No releases in %s/%s\n", owner, name)
fmt.Printf("No releases in %s/%s\n", owner, name)
return nil
}
isTTY := ios.IsStdoutTTY()
tp := ios.NewTablePrinter()
tp.AddHeader("TAG", "TITLE", "TYPE", "PUBLISHED")
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
_, _ = fmt.Fprintf(w, "TAG\tTITLE\tTYPE\tPUBLISHED\n")
for _, rel := range releases {
published := text.FormatDate(releaseTimestamp(rel), isTTY)
tp.AddRow(rel.TagName, rel.Title, releaseType(rel), published)
published := releaseTimestamp(rel).Format("2006-01-02")
_, _ = fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", rel.TagName, rel.Title, releaseType(rel), published)
}
return tp.Render()
_ = w.Flush()
return nil
}
func runReleaseView(cmd *cobra.Command, args []string) error {
@ -251,32 +182,22 @@ func runReleaseView(cmd *cobra.Command, args []string) error {
return err
}
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd())
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost())
if err != nil {
return err
}
ios.StartSpinner("Fetching release...")
release, err := getReleaseByTagOrLatest(client, owner, name, tag)
if err != nil {
ios.StopSpinner()
return err
}
attachments, err := listReleaseAttachments(client, owner, name, release.ID)
ios.StopSpinner()
if err != nil {
return err
}
if web, _ := cmd.Flags().GetBool("web"); web {
if release.HTMLURL != "" {
return ios.OpenInBrowser(release.HTMLURL)
}
return fmt.Errorf("release has no HTML URL")
}
if wantJSON(cmd) {
if jsonOutput, _ := cmd.Flags().GetBool("json"); jsonOutput {
payload := struct {
Release *gitea.Release `json:"release"`
Assets []*gitea.Attachment `json:"assets,omitempty"`
@ -284,41 +205,33 @@ func runReleaseView(cmd *cobra.Command, args []string) error {
Release: release,
Assets: attachments,
}
return outputJSON(cmd, payload)
return writeJSON(payload)
}
if err := ios.StartPager(); err != nil {
fmt.Fprintf(ios.ErrOut, "warning: failed to start pager: %v\n", err)
}
defer ios.StopPager()
cs := ios.ColorScheme()
isTTY := ios.IsStdoutTTY()
fmt.Fprintf(ios.Out, "Release %s\n", cs.Bold(release.TagName))
fmt.Fprintf(ios.Out, "Title: %s\n", release.Title)
fmt.Fprintf(ios.Out, "Type: %s\n", releaseType(release))
fmt.Printf("Release %s\n", release.TagName)
fmt.Printf("Title: %s\n", release.Title)
fmt.Printf("Type: %s\n", releaseType(release))
if release.Target != "" {
fmt.Fprintf(ios.Out, "Target: %s\n", release.Target)
fmt.Printf("Target: %s\n", release.Target)
}
if release.Publisher != nil {
fmt.Fprintf(ios.Out, "Author: %s\n", release.Publisher.UserName)
fmt.Printf("Author: %s\n", release.Publisher.UserName)
}
fmt.Fprintf(ios.Out, "Created: %s\n", text.FormatDate(release.CreatedAt, isTTY))
fmt.Printf("Created: %s\n", release.CreatedAt.Format("2006-01-02 15:04:05"))
if !release.PublishedAt.IsZero() {
fmt.Fprintf(ios.Out, "Published: %s\n", text.FormatDate(release.PublishedAt, isTTY))
fmt.Printf("Published: %s\n", release.PublishedAt.Format("2006-01-02 15:04:05"))
}
if release.HTMLURL != "" {
fmt.Fprintf(ios.Out, "URL: %s\n", release.HTMLURL)
fmt.Printf("URL: %s\n", release.HTMLURL)
}
if release.Note != "" {
fmt.Fprintf(ios.Out, "\n%s\n", release.Note)
fmt.Printf("\n%s\n", release.Note)
}
if len(attachments) > 0 {
fmt.Fprintf(ios.Out, "\nAssets (%d):\n", len(attachments))
fmt.Printf("\nAssets (%d):\n", len(attachments))
for _, asset := range attachments {
fmt.Fprintf(ios.Out, "- %s (%d bytes) %s\n", asset.Name, asset.Size, asset.DownloadURL)
fmt.Printf("- %s (%d bytes) %s\n", asset.Name, asset.Size, asset.DownloadURL)
}
}
@ -363,12 +276,11 @@ func runReleaseCreate(cmd *cobra.Command, args []string) error {
return err
}
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd())
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost())
if err != nil {
return err
}
ios.StartSpinner("Creating release...")
release, _, err := client.CreateRelease(owner, name, gitea.CreateReleaseOption{
TagName: tag,
Target: target,
@ -377,29 +289,24 @@ func runReleaseCreate(cmd *cobra.Command, args []string) error {
IsDraft: draft,
IsPrerelease: prerelease,
})
ios.StopSpinner()
if err != nil {
return fmt.Errorf("failed to create release: %w", err)
}
cs := ios.ColorScheme()
fmt.Fprintf(ios.Out, "%s Release created: %s\n", cs.SuccessIcon(), release.TagName)
fmt.Printf("Release created: %s\n", release.TagName)
if release.HTMLURL != "" {
fmt.Fprintf(ios.Out, "View at: %s\n", release.HTMLURL)
fmt.Printf("View at: %s\n", release.HTMLURL)
}
if len(files) == 0 {
return nil
}
ios.StartSpinner("Uploading assets...")
if err := uploadReleaseAssets(client, owner, name, release.ID, files, false); err != nil {
ios.StopSpinner()
return err
}
ios.StopSpinner()
fmt.Fprintf(ios.Out, "%s Uploaded %s\n", cs.SuccessIcon(), text.Pluralize(len(files), "asset"))
fmt.Printf("Uploaded %d asset(s)\n", len(files))
return nil
}
@ -420,121 +327,26 @@ func runReleaseUpload(cmd *cobra.Command, args []string) error {
return err
}
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd())
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost())
if err != nil {
return err
}
ios.StartSpinner("Fetching release...")
release, err := getReleaseByTagOrLatest(client, owner, name, tag)
ios.StopSpinner()
if err != nil {
return err
}
ios.StartSpinner("Uploading assets...")
if err := uploadReleaseAssets(client, owner, name, release.ID, files, clobber); err != nil {
ios.StopSpinner()
return err
}
ios.StopSpinner()
cs := ios.ColorScheme()
fmt.Fprintf(ios.Out, "%s Uploaded %s\n", cs.SuccessIcon(), text.Pluralize(len(files), "asset"))
return nil
}
func runReleaseDownload(cmd *cobra.Command, args []string) error {
repo, _ := cmd.Flags().GetString("repo")
dir, _ := cmd.Flags().GetString("dir")
pattern, _ := cmd.Flags().GetString("pattern")
tag := args[0]
owner, name, err := parseRepo(repo)
if err != nil {
return err
}
cfg, err := config.Load()
if err != nil {
return err
}
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd())
if err != nil {
return err
}
ios.StartSpinner("Fetching release...")
release, err := getReleaseByTagOrLatest(client, owner, name, tag)
if err != nil {
ios.StopSpinner()
return err
}
attachments, err := listReleaseAttachments(client, owner, name, release.ID)
ios.StopSpinner()
if err != nil {
return err
}
if len(attachments) == 0 {
fmt.Fprintf(ios.Out, "No assets found for release %s\n", release.TagName)
return nil
}
// Filter by pattern if provided
var toDownload []*gitea.Attachment
for _, a := range attachments {
if pattern != "" {
matched, matchErr := path.Match(pattern, a.Name)
if matchErr != nil {
return fmt.Errorf("invalid glob pattern %q: %w", pattern, matchErr)
}
if !matched {
continue
}
}
toDownload = append(toDownload, a)
}
if len(toDownload) == 0 {
fmt.Fprintf(ios.Out, "No assets matching pattern %q in release %s\n", pattern, release.TagName)
return nil
}
// Ensure download directory exists
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("failed to create directory %s: %w", dir, err)
}
for _, a := range toDownload {
destPath := filepath.Join(dir, a.Name)
f, createErr := os.Create(destPath)
if createErr != nil {
return fmt.Errorf("failed to create file %s: %w", destPath, createErr)
}
dlErr := client.DownloadFile(a.DownloadURL, f)
closeErr := f.Close()
if dlErr != nil {
return fmt.Errorf("failed to download %s: %w", a.Name, dlErr)
}
if closeErr != nil {
return fmt.Errorf("failed to close %s: %w", destPath, closeErr)
}
fmt.Fprintf(ios.Out, "Downloaded %s (%d bytes)\n", a.Name, a.Size)
}
cs := ios.ColorScheme()
fmt.Fprintf(ios.Out, "\n%s %s downloaded to %s\n", cs.SuccessIcon(), text.Pluralize(len(toDownload), "asset"), dir)
fmt.Printf("Uploaded %d asset(s)\n", len(files))
return nil
}
func runReleaseDelete(cmd *cobra.Command, args []string) error {
repo, _ := cmd.Flags().GetString("repo")
yes, _ := cmd.Flags().GetBool("yes")
tag := args[0]
owner, name, err := parseRepo(repo)
@ -547,38 +359,21 @@ func runReleaseDelete(cmd *cobra.Command, args []string) error {
return err
}
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd())
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost())
if err != nil {
return err
}
ios.StartSpinner("Fetching release...")
release, err := getReleaseByTagOrLatest(client, owner, name, tag)
ios.StopSpinner()
if err != nil {
return err
}
if !yes {
confirmed, err := ios.ConfirmAction(fmt.Sprintf("Delete release %s?", release.TagName))
if err != nil {
return err
}
if !confirmed {
fmt.Fprintln(ios.ErrOut, "Aborted")
return nil
}
}
ios.StartSpinner("Deleting release...")
if _, err := client.DeleteRelease(owner, name, release.ID); err != nil {
ios.StopSpinner()
return fmt.Errorf("failed to delete release: %w", err)
}
ios.StopSpinner()
cs := ios.ColorScheme()
fmt.Fprintf(ios.Out, "%s Release %s deleted\n", cs.SuccessIcon(), release.TagName)
fmt.Printf("Release %s deleted\n", release.TagName)
return nil
}

View file

@ -6,11 +6,11 @@ import (
"os/exec"
"path/filepath"
"strings"
"text/tabwriter"
"code.gitea.io/sdk/gitea"
"forgejo.zerova.net/public/fj/internal/api"
"forgejo.zerova.net/public/fj/internal/config"
"forgejo.zerova.net/public/fj/internal/text"
"forgejo.zerova.net/sid/fgj-sid/internal/api"
"forgejo.zerova.net/sid/fgj-sid/internal/config"
"github.com/spf13/cobra"
)
@ -67,45 +67,28 @@ 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
fj repo edit owner/repo --private
fgj repo edit owner/repo --private
# Make a repository public
fj repo edit owner/repo --public
fgj repo edit owner/repo --public
# Update description and homepage
fj repo edit owner/repo -d "New description" --homepage https://example.com
fgj repo edit owner/repo -d "New description" --homepage https://example.com
# Change default branch
fj repo edit --default-branch develop
# Rename a repository
fj repo edit owner/repo --name new-name
fgj repo edit --default-branch develop
# Edit current repo (auto-detected from git context)
fj repo edit --public`,
fgj repo edit --public`,
Args: cobra.MaximumNArgs(1),
RunE: runRepoEdit,
}
var repoRenameCmd = &cobra.Command{
Use: "rename <new-name>",
Short: "Rename a repository",
Long: "Rename an existing repository. This is a shorthand for `fj repo edit --name <new-name>`.",
Example: ` # Rename current repo
fj repo rename new-name
# Rename a specific repo
fj repo rename new-name -R owner/old-name`,
Args: cobra.ExactArgs(1),
RunE: runRepoRename,
}
func init() {
rootCmd.AddCommand(repoCmd)
repoCmd.AddCommand(repoCloneCmd)
repoCmd.AddCommand(repoCreateCmd)
repoCmd.AddCommand(repoEditCmd)
repoCmd.AddCommand(repoRenameCmd)
repoCmd.AddCommand(repoForkCmd)
repoCmd.AddCommand(repoListCmd)
repoCmd.AddCommand(repoViewCmd)
@ -121,26 +104,16 @@ func init() {
repoCreateCmd.Flags().StringP("team", "t", "", "Team name to be granted access (org repos only)")
repoCreateCmd.MarkFlagsMutuallyExclusive("public", "private")
addJSONFlags(repoViewCmd, "Output repository as JSON")
repoViewCmd.Flags().BoolP("web", "w", false, "Open in web browser")
addJSONFlags(repoListCmd, "Output repositories as JSON")
repoListCmd.Flags().IntP("limit", "L", 0, "Maximum number of repositories to list (0 = no limit)")
repoCloneCmd.Flags().StringP("protocol", "p", "https", "Clone protocol: https or ssh")
repoEditCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format")
repoEditCmd.Flags().String("name", "", "Rename the repository")
repoEditCmd.Flags().StringP("description", "d", "", "Repository description")
repoEditCmd.Flags().String("homepage", "", "Repository home page URL")
repoEditCmd.Flags().String("default-branch", "", "Default branch name")
repoEditCmd.Flags().Bool("private", false, "Make the repository private")
repoEditCmd.Flags().Bool("public", false, "Make the repository public")
addJSONFlags(repoEditCmd, "Output updated repository as JSON")
repoEditCmd.Flags().Bool("json", false, "Output updated repository as JSON")
repoEditCmd.MarkFlagsMutuallyExclusive("public", "private")
repoRenameCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format")
addJSONFlags(repoRenameCmd, "Output updated repository as JSON")
}
func runRepoView(cmd *cobra.Command, args []string) error {
@ -159,41 +132,28 @@ func runRepoView(cmd *cobra.Command, args []string) error {
return err
}
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd())
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost())
if err != nil {
return err
}
ios.StartSpinner("Fetching repository...")
repository, _, err := client.GetRepo(owner, name)
ios.StopSpinner()
if err != nil {
return fmt.Errorf("failed to get repository: %w", err)
}
if web, _ := cmd.Flags().GetBool("web"); web {
return ios.OpenInBrowser(repository.HTMLURL)
}
if wantJSON(cmd) {
return outputJSON(cmd, repository)
}
cs := ios.ColorScheme()
isTTY := ios.IsStdoutTTY()
fmt.Fprintf(ios.Out, "Repository: %s\n", cs.Bold(fmt.Sprintf("%s/%s", repository.Owner.UserName, repository.Name)))
fmt.Fprintf(ios.Out, "Description: %s\n", repository.Description)
fmt.Fprintf(ios.Out, "URL: %s\n", repository.HTMLURL)
fmt.Fprintf(ios.Out, "Clone URL (HTTPS): %s\n", repository.CloneURL)
fmt.Fprintf(ios.Out, "Clone URL (SSH): %s\n", repository.SSHURL)
fmt.Fprintf(ios.Out, "Default Branch: %s\n", repository.DefaultBranch)
fmt.Fprintf(ios.Out, "Stars: %d\n", repository.Stars)
fmt.Fprintf(ios.Out, "Forks: %d\n", repository.Forks)
fmt.Fprintf(ios.Out, "Open Issues: %d\n", repository.OpenIssues)
fmt.Fprintf(ios.Out, "Private: %v\n", repository.Private)
fmt.Fprintf(ios.Out, "Created: %s\n", text.FormatDate(repository.Created, isTTY))
fmt.Fprintf(ios.Out, "Updated: %s\n", text.FormatDate(repository.Updated, isTTY))
fmt.Printf("Repository: %s/%s\n", repository.Owner.UserName, repository.Name)
fmt.Printf("Description: %s\n", repository.Description)
fmt.Printf("URL: %s\n", repository.HTMLURL)
fmt.Printf("Clone URL (HTTPS): %s\n", repository.CloneURL)
fmt.Printf("Clone URL (SSH): %s\n", repository.SSHURL)
fmt.Printf("Default Branch: %s\n", repository.DefaultBranch)
fmt.Printf("Stars: %d\n", repository.Stars)
fmt.Printf("Forks: %d\n", repository.Forks)
fmt.Printf("Open Issues: %d\n", repository.OpenIssues)
fmt.Printf("Private: %v\n", repository.Private)
fmt.Printf("Created: %s\n", repository.Created.Format("2006-01-02 15:04:05"))
fmt.Printf("Updated: %s\n", repository.Updated.Format("2006-01-02 15:04:05"))
return nil
}
@ -204,50 +164,42 @@ func runRepoList(cmd *cobra.Command, args []string) error {
return err
}
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd())
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost())
if err != nil {
return err
}
ios.StartSpinner("Fetching repositories...")
user, _, err := client.GetMyUserInfo()
if err != nil {
ios.StopSpinner()
return fmt.Errorf("failed to get user info: %w", err)
}
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()
repos, _, err := client.ListUserRepos(user.UserName, gitea.ListReposOptions{})
if err != nil {
return fmt.Errorf("failed to list repositories: %w", err)
}
if wantJSON(cmd) {
return outputJSON(cmd, repos)
}
if len(repos) == 0 {
fmt.Fprintln(ios.Out, "No repositories found")
fmt.Println("No repositories found")
return nil
}
tp := ios.NewTablePrinter()
tp.AddHeader("NAME", "VISIBILITY", "DESCRIPTION")
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
_, _ = fmt.Fprintf(w, "NAME\tVISIBILITY\tDESCRIPTION\n")
for _, repo := range repos {
visibility := "public"
if repo.Private {
visibility = "private"
}
desc := text.Truncate(repo.Description, 50)
tp.AddRow(fmt.Sprintf("%s/%s", repo.Owner.UserName, repo.Name), visibility, desc)
desc := repo.Description
if len(desc) > 50 {
desc = desc[:47] + "..."
}
_, _ = fmt.Fprintf(w, "%s/%s\t%s\t%s\n", repo.Owner.UserName, repo.Name, visibility, desc)
}
return tp.Render()
_ = w.Flush()
return nil
}
func runRepoClone(cmd *cobra.Command, args []string) error {
@ -264,14 +216,12 @@ func runRepoClone(cmd *cobra.Command, args []string) error {
return err
}
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd())
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost())
if err != nil {
return err
}
ios.StartSpinner("Fetching repository info...")
repository, _, err := client.GetRepo(owner, name)
ios.StopSpinner()
if err != nil {
return fmt.Errorf("failed to get repository: %w", err)
}
@ -291,7 +241,7 @@ func runRepoClone(cmd *cobra.Command, args []string) error {
destination = name
}
fmt.Fprintf(ios.Out, "Cloning %s/%s to %s...\n", owner, name, destination)
fmt.Printf("Cloning %s/%s to %s...\n", owner, name, destination)
// Create parent directory if it doesn't exist
if dir := filepath.Dir(destination); dir != "." {
@ -300,21 +250,17 @@ func runRepoClone(cmd *cobra.Command, args []string) error {
}
}
ios.StartSpinner("Cloning repository...")
// Execute git clone
gitCmd := exec.Command("git", "clone", cloneURL, destination)
gitCmd.Stdout = ios.Out
gitCmd.Stderr = ios.ErrOut
gitCmd.Stdin = ios.In
gitCmd.Stdout = os.Stdout
gitCmd.Stderr = os.Stderr
gitCmd.Stdin = os.Stdin
if err := gitCmd.Run(); err != nil {
ios.StopSpinner()
return fmt.Errorf("failed to clone repository: %w", err)
}
ios.StopSpinner()
cs := ios.ColorScheme()
fmt.Fprintf(ios.Out, "%s Repository cloned successfully to %s\n", cs.SuccessIcon(), destination)
fmt.Printf("Repository cloned successfully to %s\n", destination)
return nil
}
@ -331,22 +277,19 @@ func runRepoFork(cmd *cobra.Command, args []string) error {
return err
}
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd())
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost())
if err != nil {
return err
}
ios.StartSpinner("Forking repository...")
fork, _, err := client.CreateFork(owner, name, gitea.CreateForkOption{})
ios.StopSpinner()
if err != nil {
return fmt.Errorf("failed to fork repository: %w", err)
}
cs := ios.ColorScheme()
fmt.Fprintf(ios.Out, "%s Repository forked successfully\n", cs.SuccessIcon())
fmt.Fprintf(ios.Out, "View at: %s\n", fork.HTMLURL)
fmt.Fprintf(ios.Out, "Clone URL: %s\n", fork.CloneURL)
fmt.Printf("Repository forked successfully\n")
fmt.Printf("View at: %s\n", fork.HTMLURL)
fmt.Printf("Clone URL: %s\n", fork.CloneURL)
return nil
}
@ -378,7 +321,7 @@ func runRepoCreate(cmd *cobra.Command, args []string) error {
return err
}
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd())
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost())
if err != nil {
return err
}
@ -392,14 +335,12 @@ func runRepoCreate(cmd *cobra.Command, args []string) error {
License: license,
}
ios.StartSpinner("Creating repository...")
var repo *gitea.Repository
if isOrg {
repo, _, err = client.CreateOrgRepo(org, opt)
} else {
repo, _, err = client.CreateRepo(opt)
}
ios.StopSpinner()
if err != nil {
return fmt.Errorf("failed to create repository: %w", err)
}
@ -413,7 +354,7 @@ func runRepoCreate(cmd *cobra.Command, args []string) error {
} else {
user, _, userErr := client.GetMyUserInfo()
if userErr != nil {
fmt.Fprintf(ios.ErrOut, "warning: repository created but could not determine owner for homepage: %v\n", userErr)
fmt.Fprintf(os.Stderr, "warning: repository created but could not determine owner for homepage: %v\n", userErr)
homepage = "" // skip EditRepo
} else {
ownerName = user.UserName
@ -425,37 +366,36 @@ func runRepoCreate(cmd *cobra.Command, args []string) error {
Website: &homepage,
})
if err != nil {
fmt.Fprintf(ios.ErrOut, "warning: repository created but failed to set homepage: %v\n", err)
fmt.Fprintf(os.Stderr, "warning: repository created but failed to set homepage: %v\n", err)
}
}
}
if team != "" {
if !isOrg {
fmt.Fprintln(ios.ErrOut, "warning: --team is only meaningful for organization repositories")
fmt.Fprintln(os.Stderr, "warning: --team is only meaningful for organization repositories")
} else {
_, err = client.AddRepoTeam(org, repo.Name, team)
if err != nil {
fmt.Fprintf(ios.ErrOut, "warning: repository created but failed to add team %q: %v\n", team, err)
fmt.Fprintf(os.Stderr, "warning: repository created but failed to add team %q: %v\n", team, err)
}
}
}
cs := ios.ColorScheme()
fmt.Fprintf(ios.Out, "%s Repository created: %s\n", cs.SuccessIcon(), repo.HTMLURL)
fmt.Printf("Repository created: %s\n", repo.HTMLURL)
if doClone {
cloneURL := repo.CloneURL
if hostCfg, hostErr := cfg.GetHost("", getDetectedHost(), getCwd()); hostErr == nil {
if hostCfg, hostErr := cfg.GetHost("", getDetectedHost()); hostErr == nil {
if hostCfg.GitProtocol == "ssh" {
cloneURL = repo.SSHURL
}
}
fmt.Fprintf(ios.Out, "Cloning into %s...\n", repo.Name)
fmt.Printf("Cloning into %s...\n", repo.Name)
gitCmd := exec.Command("git", "clone", cloneURL, repo.Name)
gitCmd.Stdout = ios.Out
gitCmd.Stderr = ios.ErrOut
gitCmd.Stdin = ios.In
gitCmd.Stdout = os.Stdout
gitCmd.Stderr = os.Stderr
gitCmd.Stdin = os.Stdin
if err := gitCmd.Run(); err != nil {
return fmt.Errorf("failed to clone repository: %w", err)
}
@ -501,7 +441,7 @@ func runRepoEdit(cmd *cobra.Command, args []string) error {
return err
}
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd())
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost())
if err != nil {
return err
}
@ -509,11 +449,6 @@ func runRepoEdit(cmd *cobra.Command, args []string) error {
opt := gitea.EditRepoOption{}
changed := false
if cmd.Flags().Changed("name") {
n, _ := cmd.Flags().GetString("name")
opt.Name = &n
changed = true
}
if cmd.Flags().Changed("description") {
d, _ := cmd.Flags().GetString("description")
opt.Description = &d
@ -541,84 +476,36 @@ func runRepoEdit(cmd *cobra.Command, args []string) error {
}
if !changed {
return fmt.Errorf("no changes specified; use flags like --name, --public, --private, --description, --homepage, or --default-branch")
return fmt.Errorf("no changes specified; use flags like --public, --private, --description, --homepage, or --default-branch")
}
ios.StartSpinner("Updating repository...")
repository, _, err := client.EditRepo(owner, name, opt)
ios.StopSpinner()
if err != nil {
return fmt.Errorf("failed to edit repository: %w", err)
}
if wantJSON(cmd) {
return outputJSON(cmd, repository)
jsonFlag, _ := cmd.Flags().GetBool("json")
if jsonFlag {
return writeJSON(repository)
}
cs := ios.ColorScheme()
fmt.Fprintf(ios.Out, "%s Repository updated: %s\n", cs.SuccessIcon(), repository.HTMLURL)
if opt.Name != nil {
fmt.Fprintf(ios.Out, "Renamed to: %s\n", repository.FullName)
}
fmt.Printf("Repository updated: %s\n", repository.HTMLURL)
if opt.Private != nil {
if *opt.Private {
fmt.Fprintln(ios.Out, "Visibility: private")
fmt.Println("Visibility: private")
} else {
fmt.Fprintln(ios.Out, "Visibility: public")
fmt.Println("Visibility: public")
}
}
if opt.Description != nil {
fmt.Fprintf(ios.Out, "Description: %s\n", *opt.Description)
fmt.Printf("Description: %s\n", *opt.Description)
}
if opt.Website != nil {
fmt.Fprintf(ios.Out, "Homepage: %s\n", *opt.Website)
fmt.Printf("Homepage: %s\n", *opt.Website)
}
if opt.DefaultBranch != nil {
fmt.Fprintf(ios.Out, "Default branch: %s\n", *opt.DefaultBranch)
fmt.Printf("Default branch: %s\n", *opt.DefaultBranch)
}
return nil
}
func runRepoRename(cmd *cobra.Command, args []string) error {
var repo string
if r, _ := cmd.Flags().GetString("repo"); r != "" {
repo = r
}
owner, name, err := parseRepo(repo)
if err != nil {
return err
}
newName := args[0]
cfg, err := config.Load()
if err != nil {
return err
}
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd())
if err != nil {
return err
}
opt := gitea.EditRepoOption{
Name: &newName,
}
ios.StartSpinner("Renaming repository...")
repository, _, err := client.EditRepo(owner, name, opt)
ios.StopSpinner()
if err != nil {
return fmt.Errorf("failed to rename repository: %w", err)
}
if wantJSON(cmd) {
return outputJSON(cmd, repository)
}
cs := ios.ColorScheme()
fmt.Fprintf(ios.Out, "%s Renamed %s/%s to %s\n", cs.SuccessIcon(), owner, name, repository.FullName)
return nil
}

View file

@ -2,14 +2,10 @@ package cmd
import (
"fmt"
"io"
"os"
"path/filepath"
"strconv"
"strings"
"forgejo.zerova.net/public/fj/internal/config"
"forgejo.zerova.net/public/fj/internal/git"
"forgejo.zerova.net/sid/fgj-sid/internal/git"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
@ -18,11 +14,11 @@ var cfgFile string
var jsonErrors bool
var rootCmd = &cobra.Command{
Use: "fj",
Use: "fgj",
Short: "Forgejo CLI tool - work seamlessly with Forgejo from the command line",
Long: `fj is a command line tool for Forgejo instances (including Codeberg).
Long: `fgj is a command line tool for Forgejo instances (including Codeberg).
It brings pull requests, issues, and other Forgejo concepts to the terminal.`,
Version: "0.4.0",
Version: "0.3.0c",
SilenceErrors: true,
}
@ -38,7 +34,7 @@ func Execute() error {
func init() {
cobra.OnInitialize(initConfig)
rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.config/fj/config.yaml)")
rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.config/fgj/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"))
@ -46,33 +42,16 @@ 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 {
fmt.Fprintln(ios.ErrOut, err)
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
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)
configDir := home + "/.config/fgj"
_ = os.MkdirAll(configDir, 0755)
viper.AddConfigPath(configDir)
viper.SetConfigType("yaml")
@ -80,17 +59,9 @@ func initConfig() {
}
viper.AutomaticEnv()
viper.SetEnvPrefix("FJ")
viper.SetEnvPrefix("FGJ")
_ = 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".
@ -123,83 +94,3 @@ func getDetectedHost() string {
}
return host
}
// getCwd returns the current working directory, or "" on error.
func getCwd() string {
cwd, err := os.Getwd()
if err != nil {
return ""
}
return cwd
}
// promptLine prints a prompt to stderr and reads a line from stdin.
func promptLine(prompt string) (string, error) {
fmt.Fprint(ios.ErrOut, prompt)
var buf [1024]byte
n, err := ios.In.Read(buf[:])
if err != nil {
return "", fmt.Errorf("reading input: %w", err)
}
return strings.TrimSpace(string(buf[:n])), nil
}
// parseIssueArg parses an issue/PR number from various formats:
// "123", "#123", "https://host/owner/repo/pulls/123", "https://host/owner/repo/issues/123"
func parseIssueArg(arg string) (int64, error) {
arg = strings.TrimPrefix(arg, "#")
// Try URL format
if strings.HasPrefix(arg, "http") {
parts := strings.Split(strings.TrimRight(arg, "/"), "/")
arg = parts[len(parts)-1]
}
return strconv.ParseInt(arg, 10, 64)
}
// migrateConfigDir copies all files from src to dst (one level, no subdirs).
// Uses O_TRUNC so a partially-pre-existing dst file is fully replaced rather
// than having the legacy contents overwrite a prefix and leaving stale tail
// bytes — which for a YAML token store would silently corrupt config.
func migrateConfigDir(src, dst string) error {
if err := os.MkdirAll(dst, 0700); err != nil {
return err
}
entries, err := os.ReadDir(src)
if err != nil {
return err
}
for _, e := range entries {
if e.IsDir() {
continue
}
if err := copyOneConfigFile(filepath.Join(src, e.Name()), filepath.Join(dst, e.Name())); err != nil {
return err
}
}
return nil
}
func copyOneConfigFile(srcPath, dstPath string) (retErr error) {
in, err := os.Open(srcPath)
if err != nil {
return err
}
defer func() {
if cerr := in.Close(); retErr == nil {
retErr = cerr
}
}()
out, err := os.OpenFile(dstPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600)
if err != nil {
return err
}
defer func() {
if cerr := out.Close(); retErr == nil {
retErr = cerr
}
}()
_, err = io.Copy(out, in)
return err
}

View file

@ -5,28 +5,29 @@ import (
"fmt"
"net/http"
"net/url"
"os"
"text/tabwriter"
"time"
"forgejo.zerova.net/public/fj/internal/api"
"forgejo.zerova.net/public/fj/internal/config"
"forgejo.zerova.net/public/fj/internal/text"
"forgejo.zerova.net/sid/fgj-sid/internal/api"
"forgejo.zerova.net/sid/fgj-sid/internal/config"
"github.com/spf13/cobra"
)
// Wiki API response types
type wikiPageMeta struct {
Title string `json:"title"`
HTMLURL string `json:"html_url"`
SubURL string `json:"sub_url"`
Title string `json:"title"`
HTMLURL string `json:"html_url"`
SubURL string `json:"sub_url"`
LastCommit *wikiCommit `json:"last_commit"`
}
type wikiCommit struct {
ID string `json:"id"`
Author *wikiUser `json:"author"`
Committer *wikiUser `json:"committer"`
Message string `json:"message"`
ID string `json:"id"`
Author *wikiUser `json:"author"`
Committer *wikiUser `json:"committer"`
Message string `json:"message"`
}
type wikiUser struct {
@ -61,13 +62,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
fj wiki list
fgj wiki list
# List wiki pages for a specific repo
fj wiki list -R owner/repo
fgj wiki list -R owner/repo
# Output as JSON
fj wiki list --json`,
fgj wiki list --json`,
RunE: runWikiList,
}
@ -76,16 +77,13 @@ var wikiViewCmd = &cobra.Command{
Short: "View a wiki page",
Long: "Display the content of a wiki page.",
Example: ` # View a wiki page
fj wiki view Home
# Open in browser
fj wiki view Home --web
fgj wiki view Home
# View a wiki page as JSON (includes content)
fj wiki view Home --json
fgj wiki view Home --json
# View a wiki page from a specific repo
fj wiki view "Getting-Started" -R owner/repo`,
fgj wiki view "Getting-Started" -R owner/repo`,
Args: cobra.ExactArgs(1),
RunE: runWikiView,
}
@ -95,16 +93,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
fj wiki create "Getting Started" -b "# Welcome\nThis is the getting started guide."
fgj wiki create "Getting Started" -b "# Welcome\nThis is the getting started guide."
# Create a wiki page from a file
fj wiki create "Setup Guide" --body-file setup.md
fgj wiki create "Setup Guide" --body-file setup.md
# Create a wiki page from stdin
echo "# FAQ" | fj wiki create FAQ --body-file -
echo "# FAQ" | fgj wiki create FAQ --body-file -
# Output as JSON
fj wiki create "New Page" -b "Content here" --json`,
fgj wiki create "New Page" -b "Content here" --json`,
Args: cobra.ExactArgs(1),
RunE: runWikiCreate,
}
@ -114,16 +112,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
fj wiki edit Home -b "# Updated Home\nNew content here."
fgj wiki edit Home -b "# Updated Home\nNew content here."
# Edit a wiki page from a file
fj wiki edit "Setup Guide" --body-file updated-setup.md
fgj wiki edit "Setup Guide" --body-file updated-setup.md
# Edit a wiki page from stdin
cat new-content.md | fj wiki edit Home --body-file -
cat new-content.md | fgj wiki edit Home --body-file -
# Output as JSON
fj wiki edit Home -b "Updated content" --json`,
fgj wiki edit Home -b "Updated content" --json`,
Args: cobra.ExactArgs(1),
RunE: runWikiEdit,
}
@ -133,13 +131,10 @@ var wikiDeleteCmd = &cobra.Command{
Short: "Delete a wiki page",
Long: "Delete a wiki page from the repository.",
Example: ` # Delete a wiki page
fj wiki delete "Old Page"
# Delete without confirmation
fj wiki delete "Old Page" -y
fgj wiki delete "Old Page"
# Delete a wiki page from a specific repo
fj wiki delete "Outdated Guide" -R owner/repo`,
fgj wiki delete "Outdated Guide" -R owner/repo`,
Args: cobra.ExactArgs(1),
RunE: runWikiDelete,
}
@ -153,24 +148,22 @@ func init() {
wikiCmd.AddCommand(wikiDeleteCmd)
wikiListCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format")
addJSONFlags(wikiListCmd, "Output as JSON")
wikiListCmd.Flags().Bool("json", false, "Output as JSON")
wikiViewCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format")
addJSONFlags(wikiViewCmd, "Output as JSON")
wikiViewCmd.Flags().BoolP("web", "w", false, "Open in web browser")
wikiViewCmd.Flags().Bool("json", false, "Output as JSON")
wikiCreateCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format")
wikiCreateCmd.Flags().StringP("body", "b", "", "Wiki page content")
wikiCreateCmd.Flags().String("body-file", "", "Read content from file (use \"-\" for stdin)")
addJSONFlags(wikiCreateCmd, "Output created page as JSON")
wikiCreateCmd.Flags().Bool("json", false, "Output created page as JSON")
wikiEditCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format")
wikiEditCmd.Flags().StringP("body", "b", "", "Wiki page content")
wikiEditCmd.Flags().String("body-file", "", "Read content from file (use \"-\" for stdin)")
addJSONFlags(wikiEditCmd, "Output updated page as JSON")
wikiEditCmd.Flags().Bool("json", false, "Output updated page as JSON")
wikiDeleteCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format")
wikiDeleteCmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompt")
}
func newWikiClient(cmd *cobra.Command) (*api.Client, string, string, error) {
@ -185,7 +178,7 @@ func newWikiClient(cmd *cobra.Command) (*api.Client, string, string, error) {
return nil, "", "", err
}
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd())
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost())
if err != nil {
return nil, "", "", err
}
@ -201,38 +194,37 @@ func runWikiList(cmd *cobra.Command, args []string) error {
path := fmt.Sprintf("/api/v1/repos/%s/%s/wiki/pages", url.PathEscape(owner), url.PathEscape(name))
ios.StartSpinner("Fetching wiki pages...")
var pages []wikiPageMeta
if err := client.GetJSON(path, &pages); err != nil {
ios.StopSpinner()
return fmt.Errorf("failed to list wiki pages: %w", err)
}
ios.StopSpinner()
if wantJSON(cmd) {
return outputJSON(cmd, pages)
jsonFlag, _ := cmd.Flags().GetBool("json")
if jsonFlag {
return writeJSON(pages)
}
if len(pages) == 0 {
fmt.Fprintln(ios.Out, "No wiki pages found")
fmt.Println("No wiki pages found")
return nil
}
isTTY := ios.IsStdoutTTY()
tp := ios.NewTablePrinter()
tp.AddHeader("TITLE", "LAST UPDATED")
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
_, _ = fmt.Fprintf(w, "TITLE\tLAST UPDATED\n")
for _, p := range pages {
updated := ""
if p.LastCommit != nil && p.LastCommit.Committer != nil && p.LastCommit.Committer.Date != "" {
if t, err := time.Parse(time.RFC3339, p.LastCommit.Committer.Date); err == nil {
updated = text.FormatDate(t, isTTY)
updated = t.Format("2006-01-02 15:04:05")
} else {
updated = p.LastCommit.Committer.Date
}
}
tp.AddRow(p.Title, updated)
_, _ = fmt.Fprintf(w, "%s\t%s\n", p.Title, updated)
}
return tp.Render()
_ = w.Flush()
return nil
}
func runWikiView(cmd *cobra.Command, args []string) error {
@ -246,41 +238,27 @@ func runWikiView(cmd *cobra.Command, args []string) error {
path := fmt.Sprintf("/api/v1/repos/%s/%s/wiki/page/%s",
url.PathEscape(owner), url.PathEscape(name), url.PathEscape(title))
ios.StartSpinner("Fetching wiki page...")
var page wikiPage
if err := client.GetJSON(path, &page); err != nil {
ios.StopSpinner()
return fmt.Errorf("failed to get wiki page: %w", err)
}
ios.StopSpinner()
content, err := base64.StdEncoding.DecodeString(page.ContentBase64)
if err != nil {
return fmt.Errorf("failed to decode wiki page content: %w", err)
}
if web, _ := cmd.Flags().GetBool("web"); web {
if page.HTMLURL != "" {
return ios.OpenInBrowser(page.HTMLURL)
}
return fmt.Errorf("wiki page has no HTML URL")
}
if wantJSON(cmd) {
jsonFlag, _ := cmd.Flags().GetBool("json")
if jsonFlag {
page.Content = string(content)
return outputJSON(cmd, page)
return writeJSON(page)
}
if err := ios.StartPager(); err != nil {
fmt.Fprintf(ios.ErrOut, "warning: failed to start pager: %v\n", err)
}
defer ios.StopPager()
fmt.Fprintf(ios.Out, "# %s\n\n", page.Title)
fmt.Fprint(ios.Out, string(content))
fmt.Printf("# %s\n\n", page.Title)
fmt.Print(string(content))
// Ensure trailing newline
if len(content) > 0 && content[len(content)-1] != '\n' {
fmt.Fprintln(ios.Out)
fmt.Println()
}
return nil
@ -310,20 +288,17 @@ func runWikiCreate(cmd *cobra.Command, args []string) error {
ContentBase64: base64.StdEncoding.EncodeToString([]byte(body)),
}
ios.StartSpinner("Creating wiki page...")
var page wikiPage
if _, err := client.DoJSON(http.MethodPost, path, reqBody, &page); err != nil {
ios.StopSpinner()
return fmt.Errorf("failed to create wiki page: %w", err)
}
ios.StopSpinner()
if wantJSON(cmd) {
return outputJSON(cmd, page)
jsonFlag, _ := cmd.Flags().GetBool("json")
if jsonFlag {
return writeJSON(page)
}
cs := ios.ColorScheme()
fmt.Fprintf(ios.Out, "%s Wiki page created: %s\n", cs.SuccessIcon(), title)
fmt.Printf("Wiki page created: %s\n", title)
return nil
}
@ -351,54 +326,35 @@ func runWikiEdit(cmd *cobra.Command, args []string) error {
ContentBase64: base64.StdEncoding.EncodeToString([]byte(body)),
}
ios.StartSpinner("Updating wiki page...")
var page wikiPage
if _, err := client.DoJSON(http.MethodPatch, path, reqBody, &page); err != nil {
ios.StopSpinner()
return fmt.Errorf("failed to update wiki page: %w", err)
}
ios.StopSpinner()
if wantJSON(cmd) {
return outputJSON(cmd, page)
jsonFlag, _ := cmd.Flags().GetBool("json")
if jsonFlag {
return writeJSON(page)
}
cs := ios.ColorScheme()
fmt.Fprintf(ios.Out, "%s Wiki page updated: %s\n", cs.SuccessIcon(), title)
fmt.Printf("Wiki page updated: %s\n", title)
return nil
}
func runWikiDelete(cmd *cobra.Command, args []string) error {
title := args[0]
yes, _ := cmd.Flags().GetBool("yes")
client, owner, name, err := newWikiClient(cmd)
if err != nil {
return err
}
if !yes {
confirmed, err := ios.ConfirmAction(fmt.Sprintf("Delete wiki page %q?", title))
if err != nil {
return err
}
if !confirmed {
fmt.Fprintln(ios.ErrOut, "Aborted")
return nil
}
}
path := fmt.Sprintf("/api/v1/repos/%s/%s/wiki/page/%s",
url.PathEscape(owner), url.PathEscape(name), url.PathEscape(title))
ios.StartSpinner("Deleting wiki page...")
if _, err := client.DoJSON(http.MethodDelete, path, nil, nil); err != nil {
ios.StopSpinner()
return fmt.Errorf("failed to delete wiki page: %w", err)
}
ios.StopSpinner()
cs := ios.ColorScheme()
fmt.Fprintf(ios.Out, "%s Wiki page deleted: %s\n", cs.SuccessIcon(), title)
fmt.Printf("Wiki page deleted: %s\n", title)
return nil
}

8
go.mod
View file

@ -1,6 +1,6 @@
module forgejo.zerova.net/public/fj
module forgejo.zerova.net/sid/fgj-sid
go 1.24.0
go 1.23.0
require (
code.gitea.io/sdk/gitea v0.22.1
@ -19,8 +19,6 @@ require (
github.com/hashicorp/go-version v1.7.0 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/itchyny/gojq v0.12.18 // indirect
github.com/itchyny/timefmt-go v0.1.7 // indirect
github.com/magiconair/properties v1.8.7 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
@ -36,7 +34,7 @@ require (
go.uber.org/multierr v1.9.0 // indirect
golang.org/x/crypto v0.39.0 // indirect
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect
golang.org/x/sys v0.38.0 // indirect
golang.org/x/sys v0.33.0 // indirect
golang.org/x/text v0.26.0 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
)

6
go.sum
View file

@ -24,10 +24,6 @@ github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/itchyny/gojq v0.12.18 h1:gFGHyt/MLbG9n6dqnvlliiya2TaMMh6FFaR2b1H6Drc=
github.com/itchyny/gojq v0.12.18/go.mod h1:4hPoZ/3lN9fDL1D+aK7DY1f39XZpY9+1Xpjz8atrEkg=
github.com/itchyny/timefmt-go v0.1.7 h1:xyftit9Tbw+Dc/huSSPJaEmX1TVL8lw5vxjJLK4GMMA=
github.com/itchyny/timefmt-go v0.1.7/go.mod h1:5E46Q+zj7vbTgWY8o5YkMeYb4I6GeWLFnetPy5oBrAI=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
@ -92,8 +88,6 @@ golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg=
golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ=

View file

@ -6,22 +6,11 @@ import (
"fmt"
"io"
"net/http"
"time"
"code.gitea.io/sdk/gitea"
"forgejo.zerova.net/public/fj/internal/config"
"forgejo.zerova.net/sid/fgj-sid/internal/config"
)
// 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
@ -45,8 +34,8 @@ func NewClient(hostname, token string) (*Client, error) {
}, nil
}
func NewClientFromConfig(cfg *config.Config, hostname string, detectedHost string, cwd string) (*Client, error) {
host, err := cfg.GetHost(hostname, detectedHost, cwd)
func NewClientFromConfig(cfg *config.Config, hostname string, detectedHost string) (*Client, error) {
host, err := cfg.GetHost(hostname, detectedHost)
if err != nil {
return nil, err
}
@ -74,7 +63,8 @@ func (c *Client) GetJSON(path string, result any) error {
}
req.Header.Set("Accept", "application/json")
resp, err := sharedHTTPClient.Do(req)
httpClient := &http.Client{}
resp, err := httpClient.Do(req)
if err != nil {
return fmt.Errorf("failed to perform request: %w", err)
}
@ -84,11 +74,8 @@ func (c *Client) GetJSON(path string, result any) error {
}
}()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
body, readErr := io.ReadAll(resp.Body)
if readErr != nil {
return fmt.Errorf("failed to read error response body: %w", readErr)
}
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return &APIError{
StatusCode: resp.StatusCode,
Body: string(body),
@ -138,7 +125,8 @@ func (c *Client) DoJSON(method string, path string, body any, result any) (int,
req.Header.Set("Content-Type", "application/json")
}
resp, err := sharedHTTPClient.Do(req)
httpClient := &http.Client{}
resp, err := httpClient.Do(req)
if err != nil {
return 0, fmt.Errorf("failed to perform request: %w", err)
}
@ -148,11 +136,8 @@ func (c *Client) DoJSON(method string, path string, body any, result any) (int,
}
}()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
bodyBytes, readErr := io.ReadAll(resp.Body)
if readErr != nil {
return resp.StatusCode, fmt.Errorf("failed to read error response body: %w", readErr)
}
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusNoContent {
bodyBytes, _ := io.ReadAll(resp.Body)
return resp.StatusCode, &APIError{
StatusCode: resp.StatusCode,
Body: string(bodyBytes),
@ -169,40 +154,6 @@ func (c *Client) DoJSON(method string, path string, body any, result any) (int,
return resp.StatusCode, nil
}
// Token returns the client's authentication token.
func (c *Client) Token() string {
return c.token
}
// DownloadFile performs an authenticated GET request and writes the response body to the given writer.
func (c *Client) DownloadFile(url string, w io.Writer) error {
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
if c.token != "" {
req.Header.Set("Authorization", "token "+c.token)
}
resp, err := sharedHTTPClient.Do(req)
if err != nil {
return fmt.Errorf("failed to perform request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("download failed with status %d: %s", resp.StatusCode, string(body))
}
if _, err := io.Copy(w, resp.Body); err != nil {
return fmt.Errorf("failed to write file: %w", err)
}
return nil
}
// GetRawLog performs a GET request and returns the raw response body as string
func (c *Client) GetRawLog(url string) (string, error) {
req, err := http.NewRequest(http.MethodGet, url, nil)
@ -215,7 +166,8 @@ func (c *Client) GetRawLog(url string) (string, error) {
req.Header.Set("Authorization", "token "+c.token)
}
resp, err := sharedHTTPClient.Do(req)
httpClient := &http.Client{}
resp, err := httpClient.Do(req)
if err != nil {
return "", fmt.Errorf("failed to perform request: %w", err)
}
@ -226,10 +178,7 @@ func (c *Client) GetRawLog(url string) (string, error) {
}()
if resp.StatusCode != http.StatusOK {
body, readErr := io.ReadAll(resp.Body)
if readErr != nil {
return "", fmt.Errorf("failed to read error response body: %w", readErr)
}
body, _ := io.ReadAll(resp.Body)
return "", &APIError{
StatusCode: resp.StatusCode,
Body: string(body),

View file

@ -3,7 +3,7 @@ package api
import (
"testing"
"forgejo.zerova.net/public/fj/internal/config"
"forgejo.zerova.net/sid/fgj-sid/internal/config"
)
func TestClient_Hostname(t *testing.T) {
@ -21,7 +21,7 @@ func TestNewClientFromConfig_MissingHost(t *testing.T) {
Hosts: map[string]config.HostConfig{},
}
_, err := NewClientFromConfig(cfg, "nonexistent.org", "", "")
_, err := NewClientFromConfig(cfg, "nonexistent.org", "")
if err == nil {
t.Error("Expected error for nonexistent host")
}

View file

@ -4,7 +4,6 @@ import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/spf13/viper"
"gopkg.in/yaml.v3"
@ -14,41 +13,25 @@ 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"`
User string `yaml:"user,omitempty"`
GitProtocol string `yaml:"git_protocol,omitempty"`
MatchDirs []string `yaml:"match_dirs,omitempty"`
Order int `yaml:"-"` // config file order, set at load time
Hostname string `yaml:"hostname"`
Token string `yaml:"token"`
User string `yaml:"user,omitempty"`
GitProtocol string `yaml:"git_protocol,omitempty"`
}
func GetConfigDir() (string, error) {
if xdgConfigHome := os.Getenv("XDG_CONFIG_HOME"); xdgConfigHome != "" {
return filepath.Join(xdgConfigHome, "fj"), nil
return filepath.Join(xdgConfigHome, "fgj"), nil
}
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(home, ".config", "fj"), nil
return filepath.Join(home, ".config", "fgj"), nil
}
func GetConfigPath() (string, error) {
if explicitConfigPath != "" {
return explicitConfigPath, nil
}
dir, err := GetConfigDir()
if err != nil {
return "", err
@ -82,43 +65,9 @@ func LoadFromPath(path string) (*Config, error) {
cfg.Hosts = make(map[string]HostConfig)
}
// Parse again with yaml.Node to capture config file order for hosts
assignHostOrder(&cfg, data)
return &cfg, nil
}
// assignHostOrder walks the YAML document tree to find the "hosts" mapping
// and stamps each HostConfig.Order with its position in the file.
func assignHostOrder(cfg *Config, data []byte) {
var doc yaml.Node
if err := yaml.Unmarshal(data, &doc); err != nil || len(doc.Content) == 0 {
return
}
root := doc.Content[0] // mapping node
if root.Kind != yaml.MappingNode {
return
}
for i := 0; i+1 < len(root.Content); i += 2 {
if root.Content[i].Value == "hosts" {
hostsNode := root.Content[i+1]
if hostsNode.Kind != yaml.MappingNode {
return
}
order := 0
for j := 0; j+1 < len(hostsNode.Content); j += 2 {
key := hostsNode.Content[j].Value
if h, ok := cfg.Hosts[key]; ok {
h.Order = order
cfg.Hosts[key] = h
order++
}
}
return
}
}
}
func (c *Config) Save() error {
path, err := GetConfigPath()
if err != nil {
@ -145,27 +94,22 @@ func (c *Config) SaveToPath(path string) error {
// Priority order:
// 1. Explicitly provided hostname parameter
// 2. CLI flag (--hostname)
// 3. Environment variable (FJ_HOST, with FGJ_HOST fallback)
// 3. Environment variable (FGJ_HOST)
// 4. Auto-detected hostname from git remote
// 5. match_dirs lookup (longest prefix match)
// 6. Default to codeberg.org
func (c *Config) GetHost(hostname string, detectedHost string, cwd string) (HostConfig, error) {
// 5. Default to codeberg.org
func (c *Config) GetHost(hostname string, detectedHost string) (HostConfig, error) {
if hostname == "" {
hostname = viper.GetString("hostname")
}
if hostname == "" {
hostname = EnvWithFallback("FJ_HOST", "FGJ_HOST")
hostname = os.Getenv("FGJ_HOST")
}
if hostname == "" {
hostname = detectedHost
}
if hostname == "" {
hostname = c.ResolveHostByPath(cwd)
}
if hostname == "" {
hostname = "codeberg.org"
}
@ -178,90 +122,6 @@ func (c *Config) GetHost(hostname string, detectedHost string, cwd string) (Host
return host, nil
}
// ResolveHostByPath finds the host whose match_dirs entry is the longest
// prefix of cwd. Returns "" if no match is found.
// Both cwd and match_dirs entries are resolved through filepath.EvalSymlinks
// to handle symlinks (e.g. macOS /tmp → /private/tmp).
// On ties (same prefix length from multiple hosts), the host appearing first
// in the config file wins and a warning is printed to stderr.
func (c *Config) ResolveHostByPath(cwd string) string {
if cwd == "" {
return ""
}
// Resolve symlinks in cwd so /tmp becomes /private/tmp on macOS, etc.
if resolved, err := filepath.EvalSymlinks(cwd); err == nil {
cwd = resolved
}
bestHost := ""
bestLen := 0
bestOrder := 0
tied := false
for hostname, host := range c.Hosts {
for _, dir := range host.MatchDirs {
if dir == "" {
continue
}
// Expand ~ to home directory
dir = expandHome(dir)
// Resolve symlinks in the configured dir as well
if resolved, err := filepath.EvalSymlinks(dir); err == nil {
dir = resolved
}
// Normalize: ensure trailing slash for prefix matching
prefix := dir
if !strings.HasSuffix(prefix, "/") {
prefix += "/"
}
// Match if cwd equals dir exactly or is under it
if cwd == dir || strings.HasPrefix(cwd, prefix) {
if len(dir) > bestLen {
bestLen = len(dir)
bestHost = hostname
bestOrder = host.Order
tied = false
} else if len(dir) == bestLen && hostname != bestHost {
// Tie — pick the host with the lower Order (earlier in config)
if host.Order < bestOrder {
bestHost = hostname
bestOrder = host.Order
}
tied = true
}
}
}
}
if tied {
fmt.Fprintf(os.Stderr, "warning: multiple hosts match directory %q with the same specificity; using %s (first in config)\n", cwd, bestHost)
}
return bestHost
}
// 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()
if err != nil {
return path
}
return filepath.Join(home, path[1:])
}
return path
}
func (c *Config) SetHost(hostname string, host HostConfig) {
if c.Hosts == nil {
c.Hosts = make(map[string]HostConfig)

View file

@ -49,7 +49,7 @@ func TestConfig_GetHost(t *testing.T) {
},
}
host, err := cfg.GetHost("codeberg.org", "", "")
host, err := cfg.GetHost("codeberg.org", "")
if err != nil {
t.Fatalf("Unexpected error: %v", err)
}
@ -58,7 +58,7 @@ func TestConfig_GetHost(t *testing.T) {
t.Errorf("Expected hostname 'codeberg.org', got '%s'", host.Hostname)
}
_, err = cfg.GetHost("nonexistent.org", "", "")
_, err = cfg.GetHost("nonexistent.org", "")
if err == nil {
t.Error("Expected error for nonexistent host")
}
@ -83,7 +83,7 @@ func TestGetConfigDir_XDG(t *testing.T) {
t.Fatalf("Unexpected error: %v", err)
}
expected := "/custom/config/fj"
expected := "/custom/config/fgj"
if dir != expected {
t.Errorf("Expected %q, got %q", expected, dir)
}
@ -275,7 +275,7 @@ func TestConfig_GetHost_EmptyString(t *testing.T) {
}
// Empty hostname should default to codeberg.org
host, err := cfg.GetHost("", "", "")
host, err := cfg.GetHost("", "")
if err != nil {
t.Fatalf("Unexpected error: %v", err)
}
@ -296,7 +296,7 @@ func TestConfig_GetHost_WhitespaceString(t *testing.T) {
}
// Whitespace-only hostname should default to codeberg.org
host, err := cfg.GetHost(" ", "", "")
host, err := cfg.GetHost(" ", "")
if err == nil {
t.Logf("Got host: %+v (this may be expected behavior)", host)
} else {
@ -315,7 +315,7 @@ func TestConfig_SetHost_EmptyToken(t *testing.T) {
cfg.SetHost("codeberg.org", hostConfig)
host, err := cfg.GetHost("codeberg.org", "", "")
host, err := cfg.GetHost("codeberg.org", "")
if err != nil {
t.Fatalf("Unexpected error: %v", err)
}
@ -345,7 +345,7 @@ func TestConfig_SetHost_OverwriteExisting(t *testing.T) {
cfg.SetHost("codeberg.org", newConfig)
host, err := cfg.GetHost("codeberg.org", "", "")
host, err := cfg.GetHost("codeberg.org", "")
if err != nil {
t.Fatalf("Unexpected error: %v", err)
}
@ -388,7 +388,7 @@ func TestConfig_MultipleHosts(t *testing.T) {
// Verify each host can be retrieved correctly
for _, h := range hosts {
host, err := cfg.GetHost(h.hostname, "", "")
host, err := cfg.GetHost(h.hostname, "")
if err != nil {
t.Errorf("Failed to get host %s: %v", h.hostname, err)
continue
@ -422,233 +422,13 @@ func TestConfig_GitProtocol(t *testing.T) {
})
// Verify protocols are stored correctly
sshHost, _ := cfg.GetHost("test-ssh.org", "", "")
sshHost, _ := cfg.GetHost("test-ssh.org", "")
if sshHost.GitProtocol != "ssh" {
t.Errorf("Expected git_protocol 'ssh', got '%s'", sshHost.GitProtocol)
}
httpsHost, _ := cfg.GetHost("test-https.org", "", "")
httpsHost, _ := cfg.GetHost("test-https.org", "")
if httpsHost.GitProtocol != "https" {
t.Errorf("Expected git_protocol 'https', got '%s'", httpsHost.GitProtocol)
}
}
func TestResolveHostByPath(t *testing.T) {
cfg := &Config{
Hosts: map[string]HostConfig{
"forgejo.zerova.net": {
Hostname: "forgejo.zerova.net",
Token: "token1",
MatchDirs: []string{"/Users/sid/repos/fj", "/Users/sid/repos/zerova"},
},
"codeberg.org": {
Hostname: "codeberg.org",
Token: "token2",
MatchDirs: []string{"/"},
},
"gitea.example.com": {
Hostname: "gitea.example.com",
Token: "token3",
// no match_dirs — should never be selected by path
},
},
}
tests := []struct {
name string
cwd string
want string
}{
{"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/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"},
{"empty cwd returns empty", "", ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := cfg.ResolveHostByPath(tt.cwd)
if got != tt.want {
t.Errorf("ResolveHostByPath(%q) = %q, want %q", tt.cwd, got, tt.want)
}
})
}
}
func TestResolveHostByPath_LongestPrefixAcrossHosts(t *testing.T) {
cfg := &Config{
Hosts: map[string]HostConfig{
"broad.org": {
Hostname: "broad.org",
Token: "t1",
MatchDirs: []string{"/Users/sid"},
},
"specific.org": {
Hostname: "specific.org",
Token: "t2",
MatchDirs: []string{"/Users/sid/repos/myproject"},
},
},
}
got := cfg.ResolveHostByPath("/Users/sid/repos/myproject/main.go")
if got != "specific.org" {
t.Errorf("expected specific.org, got %q", got)
}
got = cfg.ResolveHostByPath("/Users/sid/other")
if got != "broad.org" {
t.Errorf("expected broad.org, got %q", got)
}
}
func TestGetHost_MatchDirsIntegration(t *testing.T) {
cfg := &Config{
Hosts: map[string]HostConfig{
"forgejo.zerova.net": {
Hostname: "forgejo.zerova.net",
Token: "token1",
MatchDirs: []string{"/Users/sid/repos/fj"},
},
"codeberg.org": {
Hostname: "codeberg.org",
Token: "token2",
},
},
}
// cwd match should resolve to forgejo.zerova.net
host, err := cfg.GetHost("", "", "/Users/sid/repos/fj/cmd")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if host.Hostname != "forgejo.zerova.net" {
t.Errorf("expected forgejo.zerova.net, got %s", host.Hostname)
}
// no cwd match falls through to codeberg.org default
host, err = cfg.GetHost("", "", "/tmp")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if host.Hostname != "codeberg.org" {
t.Errorf("expected codeberg.org, got %s", host.Hostname)
}
}
func TestResolveHostByPath_TildeExpansion(t *testing.T) {
home, err := os.UserHomeDir()
if err != nil {
t.Skip("cannot determine home directory")
}
cfg := &Config{
Hosts: map[string]HostConfig{
"tilde.org": {
Hostname: "tilde.org",
Token: "t1",
MatchDirs: []string{"~/repos"},
},
},
}
got := cfg.ResolveHostByPath(filepath.Join(home, "repos", "myproject"))
if got != "tilde.org" {
t.Errorf("expected tilde.org, got %q", got)
}
got = cfg.ResolveHostByPath(filepath.Join(home, "other"))
if got != "" {
t.Errorf("expected empty, got %q", got)
}
}
func TestResolveHostByPath_TieBreakByConfigOrder(t *testing.T) {
cfg := &Config{
Hosts: map[string]HostConfig{
"second.org": {
Hostname: "second.org",
Token: "t2",
MatchDirs: []string{"/shared/path"},
Order: 1,
},
"first.org": {
Hostname: "first.org",
Token: "t1",
MatchDirs: []string{"/shared/path"},
Order: 0,
},
},
}
got := cfg.ResolveHostByPath("/shared/path/subdir")
if got != "first.org" {
t.Errorf("expected first.org (earlier in config), got %q", got)
}
}
func TestAssignHostOrder(t *testing.T) {
yamlData := []byte(`hosts:
alpha.org:
hostname: alpha.org
token: t1
beta.org:
hostname: beta.org
token: t2
gamma.org:
hostname: gamma.org
token: t3
`)
cfg, err := LoadFromPath(writeTempConfig(t, yamlData))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if cfg.Hosts["alpha.org"].Order != 0 {
t.Errorf("alpha.org order = %d, want 0", cfg.Hosts["alpha.org"].Order)
}
if cfg.Hosts["beta.org"].Order != 1 {
t.Errorf("beta.org order = %d, want 1", cfg.Hosts["beta.org"].Order)
}
if cfg.Hosts["gamma.org"].Order != 2 {
t.Errorf("gamma.org order = %d, want 2", cfg.Hosts["gamma.org"].Order)
}
}
func writeTempConfig(t *testing.T, data []byte) string {
t.Helper()
path := filepath.Join(t.TempDir(), "config.yaml")
if err := os.WriteFile(path, data, 0600); err != nil {
t.Fatalf("failed to write temp config: %v", err)
}
return path
}
func TestExpandHome(t *testing.T) {
home, err := os.UserHomeDir()
if err != nil {
t.Skip("cannot determine home directory")
}
tests := []struct {
input string
want string
}{
{"~/repos", filepath.Join(home, "repos")},
{"~", home},
{"/absolute/path", "/absolute/path"},
{"relative/path", "relative/path"},
{"~other", "~other"}, // only ~/... is expanded, not ~user
}
for _, tt := range tests {
got := expandHome(tt.input)
if got != tt.want {
t.Errorf("expandHome(%q) = %q, want %q", tt.input, got, tt.want)
}
}
}

View file

@ -114,48 +114,6 @@ func parseGitConfig(configPath string) (string, error) {
return "", fmt.Errorf("no origin remote found in git config")
}
// GetCurrentBranch returns the name of the currently checked-out branch.
func GetCurrentBranch() (string, error) {
gitDir, err := findGitDir()
if err != nil {
return "", err
}
headPath := filepath.Join(gitDir, "HEAD")
data, err := os.ReadFile(headPath)
if err != nil {
return "", fmt.Errorf("failed to read .git/HEAD: %w", err)
}
headStr := strings.TrimSpace(string(data))
if strings.HasPrefix(headStr, "ref: refs/heads/") {
return strings.TrimPrefix(headStr, "ref: refs/heads/"), nil
}
return "", fmt.Errorf("HEAD is not on a branch (detached HEAD)")
}
// findGitDir searches for the .git directory starting from the current directory
func findGitDir() (string, error) {
cwd, err := os.Getwd()
if err != nil {
return "", fmt.Errorf("failed to get current directory: %w", err)
}
dir := cwd
for {
gitDir := filepath.Join(dir, ".git")
if info, err := os.Stat(gitDir); err == nil && info.IsDir() {
return gitDir, nil
}
parent := filepath.Dir(dir)
if parent == dir {
return "", fmt.Errorf("not in a git repository")
}
dir = parent
}
}
// parseRemoteURL extracts owner/name/hostname from various git URL formats:
// - https://codeberg.org/owner/name.git
// - git@codeberg.org:owner/name.git

View file

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

View file

@ -1,77 +0,0 @@
package iostreams
import "fmt"
// ColorScheme provides semantic color methods that respect whether color output is enabled.
type ColorScheme struct {
enabled bool
}
// NewColorScheme creates a ColorScheme. When enabled is false, all methods return
// undecorated text.
func NewColorScheme(enabled bool) *ColorScheme {
return &ColorScheme{enabled: enabled}
}
// colorize wraps text in ANSI escape codes if color is enabled.
func (cs *ColorScheme) colorize(code string, text string) string {
if !cs.enabled {
return text
}
return fmt.Sprintf("\033[%sm%s\033[0m", code, text)
}
// Bold renders text in bold.
func (cs *ColorScheme) Bold(s string) string {
return cs.colorize("1", s)
}
// Red renders text in red.
func (cs *ColorScheme) Red(s string) string {
return cs.colorize("31", s)
}
// Green renders text in green.
func (cs *ColorScheme) Green(s string) string {
return cs.colorize("32", s)
}
// Yellow renders text in yellow.
func (cs *ColorScheme) Yellow(s string) string {
return cs.colorize("33", s)
}
// Cyan renders text in cyan.
func (cs *ColorScheme) Cyan(s string) string {
return cs.colorize("36", s)
}
// Magenta renders text in magenta.
func (cs *ColorScheme) Magenta(s string) string {
return cs.colorize("35", s)
}
// Muted renders text in gray (dimmed).
func (cs *ColorScheme) Muted(s string) string {
return cs.colorize("90", s)
}
// SuccessIcon returns a green check mark if color is enabled, plain otherwise.
func (cs *ColorScheme) SuccessIcon() string {
return cs.Green("✓")
}
// WarningIcon returns a yellow exclamation mark if color is enabled, plain otherwise.
func (cs *ColorScheme) WarningIcon() string {
return cs.Yellow("!")
}
// FailureIcon returns a red X mark if color is enabled, plain otherwise.
func (cs *ColorScheme) FailureIcon() string {
return cs.Red("✗")
}
// SuccessIconWithColor returns the success icon followed by the message in green.
func (cs *ColorScheme) SuccessIconWithColor(msg string) string {
return cs.SuccessIcon() + " " + cs.Green(msg)
}

View file

@ -1,275 +0,0 @@
package iostreams
import (
"bytes"
"fmt"
"io"
"os"
"os/exec"
"runtime"
"strings"
"sync"
"time"
"golang.org/x/term"
)
// IOStreams provides the standard streams for the CLI along with TTY detection,
// color support, pager integration, and other terminal helpers.
type IOStreams struct {
In io.Reader
Out io.Writer
ErrOut io.Writer
// Private fields for state
isStdinTTY bool
isStdoutTTY bool
isStderrTTY bool
pagerProcess *exec.Cmd
pagerPipe io.WriteCloser
originalOut io.Writer
colorScheme *ColorScheme
spinnerMu sync.Mutex
spinnerCancel chan struct{}
}
// New creates an IOStreams wired to the real os.Stdin, os.Stdout, and os.Stderr,
// 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("FJ_FORCE_TTY") != "" || os.Getenv("FGJ_FORCE_TTY") != ""
stdinTTY := forceTTY || (isTerminal(os.Stdin.Fd()))
stdoutTTY := forceTTY || (isTerminal(os.Stdout.Fd()))
stderrTTY := forceTTY || (isTerminal(os.Stderr.Fd()))
return &IOStreams{
In: os.Stdin,
Out: os.Stdout,
ErrOut: os.Stderr,
isStdinTTY: stdinTTY,
isStdoutTTY: stdoutTTY,
isStderrTTY: stderrTTY,
}
}
// Test creates an IOStreams backed by bytes.Buffers, suitable for unit tests.
// All TTY flags are false.
func Test() *IOStreams {
return &IOStreams{
In: &bytes.Buffer{},
Out: &bytes.Buffer{},
ErrOut: &bytes.Buffer{},
isStdinTTY: false,
isStdoutTTY: false,
isStderrTTY: false,
}
}
// IsStdinTTY reports whether standard input is connected to a terminal.
func (s *IOStreams) IsStdinTTY() bool {
return s.isStdinTTY
}
// IsStdoutTTY reports whether standard output is connected to a terminal.
func (s *IOStreams) IsStdoutTTY() bool {
return s.isStdoutTTY
}
// IsStderrTTY reports whether standard error is connected to a terminal.
func (s *IOStreams) IsStderrTTY() bool {
return s.isStderrTTY
}
// TerminalWidth returns the width of the terminal connected to stdout. If stdout
// is not a terminal, it returns 80.
func (s *IOStreams) TerminalWidth() int {
if !s.isStdoutTTY {
return 80
}
if f, ok := s.Out.(*os.File); ok {
w, _, err := term.GetSize(int(f.Fd()))
if err == nil && w > 0 {
return w
}
}
return 80
}
// ColorEnabled returns true when color output should be used. Color is enabled
// when stdout is a TTY and the NO_COLOR environment variable is not set.
func (s *IOStreams) ColorEnabled() bool {
if os.Getenv("NO_COLOR") != "" {
return false
}
return s.isStdoutTTY
}
// ColorScheme returns a lazily-initialized ColorScheme that respects the current
// color settings.
func (s *IOStreams) ColorScheme() *ColorScheme {
if s.colorScheme == nil {
s.colorScheme = NewColorScheme(s.ColorEnabled())
}
return s.colorScheme
}
// StartPager starts an external pager process and redirects Out to its stdin.
// 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("FJ_PAGER")
if pagerCmd == "" {
pagerCmd = os.Getenv("FGJ_PAGER")
}
if pagerCmd == "" {
pagerCmd = os.Getenv("PAGER")
}
if pagerCmd == "" {
pagerCmd = "less"
}
if os.Getenv("LESS") == "" {
os.Setenv("LESS", "FRX")
}
parts := strings.Fields(pagerCmd)
//nolint:gosec // pager command is user-configured
cmd := exec.Command(parts[0], parts[1:]...)
cmd.Stdout = s.Out
cmd.Stderr = s.ErrOut
pipe, err := cmd.StdinPipe()
if err != nil {
return fmt.Errorf("creating pager pipe: %w", err)
}
if err := cmd.Start(); err != nil {
return fmt.Errorf("starting pager: %w", err)
}
s.pagerProcess = cmd
s.pagerPipe = pipe
s.originalOut = s.Out
s.Out = pipe
return nil
}
// StopPager closes the pager's stdin pipe and waits for the process to exit.
// It restores Out to the original writer.
func (s *IOStreams) StopPager() {
if s.pagerPipe == nil {
return
}
_ = s.pagerPipe.Close()
_ = s.pagerProcess.Wait()
s.Out = s.originalOut
s.pagerPipe = nil
s.pagerProcess = nil
s.originalOut = nil
}
// spinnerFrames are the Braille-based animation frames for the spinner.
var spinnerFrames = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
// StartSpinner shows an animated spinner with the given label on stderr. It only
// runs when stderr is a TTY. Call StopSpinner to halt it.
func (s *IOStreams) StartSpinner(label string) {
if !s.isStderrTTY {
return
}
s.spinnerMu.Lock()
defer s.spinnerMu.Unlock()
// Stop any existing spinner first.
if s.spinnerCancel != nil {
close(s.spinnerCancel)
s.spinnerCancel = nil
}
cancel := make(chan struct{})
s.spinnerCancel = cancel
go func() {
ticker := time.NewTicker(80 * time.Millisecond)
defer ticker.Stop()
i := 0
for {
select {
case <-cancel:
// Clear the spinner line.
fmt.Fprintf(s.ErrOut, "\r\033[K")
return
case <-ticker.C:
frame := spinnerFrames[i%len(spinnerFrames)]
fmt.Fprintf(s.ErrOut, "\r%s %s", frame, label)
i++
}
}
}()
}
// StopSpinner halts the spinner and clears the line on stderr.
func (s *IOStreams) StopSpinner() {
s.spinnerMu.Lock()
defer s.spinnerMu.Unlock()
if s.spinnerCancel != nil {
close(s.spinnerCancel)
s.spinnerCancel = nil
}
}
// OpenInBrowser opens the given URL in the user's default browser.
func (s *IOStreams) OpenInBrowser(url string) error {
var cmd *exec.Cmd
switch runtime.GOOS {
case "darwin":
cmd = exec.Command("open", url)
case "windows":
cmd = exec.Command("cmd", "/c", "start", url)
default: // linux, freebsd, etc.
cmd = exec.Command("xdg-open", url)
}
return cmd.Start()
}
// ConfirmAction prompts the user with a yes/no question and returns their
// answer. It returns an error if stdin is not a TTY (non-interactive).
func (s *IOStreams) ConfirmAction(prompt string) (bool, error) {
if !s.isStdinTTY {
return false, fmt.Errorf("cannot prompt for confirmation: not an interactive terminal")
}
fmt.Fprintf(s.ErrOut, "%s [y/N]: ", prompt)
var response string
if _, err := fmt.Fscan(s.In, &response); err != nil {
return false, fmt.Errorf("reading response: %w", err)
}
response = strings.ToLower(strings.TrimSpace(response))
return response == "y" || response == "yes", nil
}
// NewTablePrinter creates a TablePrinter that writes to this IOStreams' output.
func (s *IOStreams) NewTablePrinter() *TablePrinter {
return NewTablePrinter(s)
}
// isTerminal reports whether the given file descriptor is a terminal.
func isTerminal(fd uintptr) bool {
return term.IsTerminal(int(fd))
}

View file

@ -1,64 +0,0 @@
package iostreams
import (
"fmt"
"strings"
"text/tabwriter"
)
// TablePrinter prints TTY-aware tables. In TTY mode it uses aligned columns with
// bold headers. In pipe mode it emits tab-separated values without headers.
type TablePrinter struct {
ios *IOStreams
headers []string
rows [][]string
}
// NewTablePrinter creates a TablePrinter that writes to ios.Out.
func NewTablePrinter(ios *IOStreams) *TablePrinter {
return &TablePrinter{
ios: ios,
}
}
// AddHeader sets the column headers. Headers are only displayed in TTY mode.
func (t *TablePrinter) AddHeader(headers ...string) {
t.headers = headers
}
// AddRow appends a row of fields to the table.
func (t *TablePrinter) AddRow(fields ...string) {
t.rows = append(t.rows, fields)
}
// Render writes the table to the IOStreams output. In TTY mode it uses tabwriter
// with bold headers. In pipe mode it emits tab-separated values without headers.
func (t *TablePrinter) Render() error {
if !t.ios.IsStdoutTTY() {
// Pipe mode: tab-separated, no headers
for _, row := range t.rows {
if _, err := fmt.Fprintln(t.ios.Out, strings.Join(row, "\t")); err != nil {
return err
}
}
return nil
}
// TTY mode: use tabwriter with aligned columns
w := tabwriter.NewWriter(t.ios.Out, 0, 0, 2, ' ', 0)
if len(t.headers) > 0 {
cs := t.ios.ColorScheme()
boldHeaders := make([]string, len(t.headers))
for i, h := range t.headers {
boldHeaders[i] = cs.Bold(h)
}
fmt.Fprintln(w, strings.Join(boldHeaders, "\t"))
}
for _, row := range t.rows {
fmt.Fprintln(w, strings.Join(row, "\t"))
}
return w.Flush()
}

View file

@ -1,70 +0,0 @@
package text
import (
"fmt"
"math"
"time"
)
// Pluralize returns "1 issue" or "2 issues" depending on count.
// It applies a simple "s" suffix rule.
func Pluralize(count int, singular string) string {
if count == 1 {
return fmt.Sprintf("%d %s", count, singular)
}
return fmt.Sprintf("%d %ss", count, singular)
}
// FuzzyAgo returns a human-friendly relative time string like "just now",
// "2 minutes ago", "3 hours ago", etc.
func FuzzyAgo(t time.Time) string {
d := time.Since(t)
if d < time.Minute {
return "just now"
}
minutes := int(math.Floor(d.Minutes()))
if minutes < 60 {
return fmt.Sprintf("%s ago", Pluralize(minutes, "minute"))
}
hours := int(math.Floor(d.Hours()))
if hours < 24 {
return fmt.Sprintf("%s ago", Pluralize(hours, "hour"))
}
days := hours / 24
if days < 30 {
return fmt.Sprintf("%s ago", Pluralize(days, "day"))
}
months := days / 30
if months < 12 {
return fmt.Sprintf("%s ago", Pluralize(months, "month"))
}
years := months / 12
return fmt.Sprintf("%s ago", Pluralize(years, "year"))
}
// Truncate shortens text to maxWidth, replacing the end with "..." if it exceeds
// the limit. If maxWidth is less than or equal to 3, the result is just "...".
func Truncate(text string, maxWidth int) string {
if len(text) <= maxWidth {
return text
}
if maxWidth <= 3 {
return "..."[:maxWidth]
}
return text[:maxWidth-3] + "..."
}
// FormatDate returns a human-friendly relative time for TTY output, or an
// RFC3339 timestamp for piped output.
func FormatDate(t time.Time, isTTY bool) string {
if isTTY {
return FuzzyAgo(t)
}
return t.Format(time.RFC3339)
}

View file

@ -4,12 +4,11 @@ import (
"fmt"
"os"
"forgejo.zerova.net/public/fj/cmd"
"forgejo.zerova.net/sid/fgj-sid/cmd"
)
func main() {
if err := cmd.Execute(); err != nil {
err = cmd.ContextualError(err)
if cmd.JSONErrors() {
cmd.WriteJSONError(err)
} else {

View file

@ -9,7 +9,6 @@ import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"code.gitea.io/sdk/gitea"
@ -228,18 +227,15 @@ func (env *TestEnv) CleanupRepo(owner, repoName string) {
}
}
// GetBinaryPath returns the path to the built fj binary
// GetBinaryPath returns the path to the built fgj binary
func (env *TestEnv) GetBinaryPath() string {
binaryPath := os.Getenv("FJ_BINARY_PATH")
if binaryPath == "" {
binaryPath = os.Getenv("FGJ_BINARY_PATH")
}
binaryPath := os.Getenv("FGJ_BINARY_PATH")
if binaryPath == "" {
// Look for the binary in common locations
candidates := []string{
"./bin/fj",
"bin/fj",
"/home/romain/work/fj/bin/fj",
"./bin/fgj",
"bin/fgj",
"/home/romain/work/fgj/bin/fgj",
}
for _, candidate := range candidates {
if _, err := os.Stat(candidate); err == nil {
@ -251,7 +247,7 @@ func (env *TestEnv) GetBinaryPath() string {
}
}
// If no binary found, return default (will error when executed)
binaryPath = "./bin/fj"
binaryPath = "./bin/fgj"
}
return binaryPath
}
@ -299,39 +295,3 @@ func (env *TestEnv) RunCLI(args ...string) *CLIResult {
return result
}
// runCLIWithStdin executes the CLI binary with the given args and pipes input to stdin.
func (env *TestEnv) runCLIWithStdin(input string, args ...string) *CLIResult {
cmd := exec.Command(env.GetBinaryPath(), args...)
cmd.Env = os.Environ()
cmd.Stdin = strings.NewReader(input)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err := cmd.Run()
exitCode := 0
if err != nil {
if exitErr, ok := err.(*exec.ExitError); ok {
exitCode = exitErr.ExitCode()
}
}
result := &CLIResult{
Stdout: stdout.String(),
Stderr: stderr.String(),
ExitCode: exitCode,
}
env.T.Logf("Command: %s %v", env.GetBinaryPath(), args)
env.T.Logf("Exit code: %d", exitCode)
if stdout.Len() > 0 {
env.T.Logf("Stdout:\n%s", result.Stdout)
}
if stderr.Len() > 0 {
env.T.Logf("Stderr:\n%s", result.Stderr)
}
return result
}

File diff suppressed because it is too large Load diff