Compare commits
21 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0069198ca6 | ||
|
|
373c769d2c | ||
|
|
155ddb97ba | ||
|
|
133fb2fea4 | ||
|
|
0c181df1d1 | ||
|
|
f75b831a53 | ||
|
|
0fda0b8679 | ||
|
|
25868adcad | ||
|
|
c3e8ad67ed | ||
|
|
cf7c0e0878 | ||
|
|
bc43f6e5a5 | ||
|
|
a6cf9a7096 | ||
|
|
c2251d9932 | ||
|
|
2e6575c660 | ||
|
|
4669d21dea | ||
|
|
830eba1c0e | ||
|
|
ac780231a8 | ||
|
|
c293e233d2 | ||
|
|
113505de95 | ||
|
|
7c0dcc8696 | ||
|
|
95da06c003 |
43 changed files with 6483 additions and 1507 deletions
|
|
@ -72,7 +72,7 @@ jobs:
|
|||
- name: Build production binary
|
||||
run: |
|
||||
make build
|
||||
echo "Binary built at: $(pwd)/bin/fgj"
|
||||
echo "Binary built at: $(pwd)/bin/fj"
|
||||
|
||||
- name: Run functional tests
|
||||
run: go test -v -race -tags=functional ./tests/functional/...
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ jobs:
|
|||
- name: Build production binary
|
||||
run: |
|
||||
make build
|
||||
echo "Binary built at: $(pwd)/bin/fgj"
|
||||
echo "Binary built at: $(pwd)/bin/fj"
|
||||
|
||||
- name: Run functional tests
|
||||
run: go test -v -race -tags=functional ./tests/functional/...
|
||||
|
|
|
|||
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -1,5 +1,6 @@
|
|||
# Binaries
|
||||
fgj
|
||||
fj
|
||||
bin/
|
||||
*.exe
|
||||
*.exe~
|
||||
*.dll
|
||||
|
|
@ -31,3 +32,5 @@ config.yaml
|
|||
|
||||
# Git worktrees
|
||||
.worktrees/
|
||||
# Workspace (scratch data, cloned repos, analysis)
|
||||
.workspace/
|
||||
|
|
|
|||
250
CHANGELOG.md
250
CHANGELOG.md
|
|
@ -5,22 +5,151 @@ All notable changes to this project will be documented in this file.
|
|||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [0.4.0] - 2026-05-02
|
||||
|
||||
Audit-driven hardening pass. Three reviewers (Codex + two Claude agents
|
||||
with non-overlapping focuses) found 13 issues across cmd/ and internal/;
|
||||
this release ships fixes for all 13.
|
||||
|
||||
### BREAKING
|
||||
|
||||
- `--json=fields` syntax removed. The flag was a string with
|
||||
`NoOptDefVal=" "` sentinel — `--json` alone meant "everything",
|
||||
`--json=fields` projected. That produced `--json string[=" "]` in
|
||||
`--help` and required a literal `=` because `--json fields` was parsed
|
||||
as the bare flag plus a positional. **Migration**: `--json=fields` →
|
||||
`--json-fields fields`. Bare `--json` still means "all fields as JSON".
|
||||
`--json` and `--json-fields` are mutually exclusive; `--jq` composes
|
||||
with either.
|
||||
|
||||
### Added
|
||||
|
||||
- `fj api --json` / `--json-fields` / `--jq` — projection and jq filtering
|
||||
for raw API responses. Routes through the same `addJSONFlags` helpers
|
||||
as the other list commands. Closes the inconsistency where `fj api`
|
||||
was the only command returning raw JSON without these knobs.
|
||||
- `fj api --paginate` — follows RFC 5988 `Link: rel="next"` headers and
|
||||
concatenates JSON array pages, gh-compatible. Validates same-origin
|
||||
before forwarding the bearer token to the next URL.
|
||||
- `cmd/paginate.go` — generic `paginateGitea[T any]` helper. Applied to
|
||||
`repo list`, `pr list`, `issue list`. Previously only `release list`
|
||||
walked pages; the others passed `PageSize: limit` directly to the
|
||||
gitea SDK, which silently caps PageSize at 50, so `--limit > 50` was
|
||||
truncated without warning.
|
||||
- `CLAUDE.md` — guide for Claude Code sessions: layout, codex review
|
||||
pattern, release process, homebrew tap update steps.
|
||||
|
||||
### Changed
|
||||
|
||||
- `--json` flag rebuilt as a plain `Bool`. `--json-fields` keeps
|
||||
comma-separated projection. Both registered via `addJSONFlags` and
|
||||
marked `MutuallyExclusive`.
|
||||
- `cmd/actions.go` — `run` and `workflow` subtrees converted from
|
||||
package-level `var`s to factory functions (`newRunCmd`,
|
||||
`newWorkflowCmd`, ...). `cmd/aliases.go` shrank from 142 → 17 lines
|
||||
and now calls those same factories with a `parentLabel` parameter that
|
||||
disambiguates the alias variant. Result: `diff` of `fj run list
|
||||
--help` flags vs `fj actions run list --help` flags is now empty.
|
||||
Drift between the two paths is structurally impossible.
|
||||
- `fj api` now uses `internal/api.SharedHTTPClient` (30s timeout, pooled
|
||||
connections) instead of a zero-value `&http.Client{}` with no timeout.
|
||||
A hung Forgejo no longer pins the CLI indefinitely.
|
||||
- `fj api` response body bounded by `io.LimitReader` at 64 MB to prevent
|
||||
OOM-on-self.
|
||||
- `cmd/auth.go` removed redundant local `--hostname` declarations on
|
||||
three subcommands. The persistent flag on rootCmd is now the only
|
||||
declaration; previously local declarations shadowed it, so
|
||||
`fj --hostname=X auth login` and `fj auth login --hostname=X` went
|
||||
through different code paths.
|
||||
- `--token` on `auth login` emits a stderr warning when used (visible
|
||||
in `ps auxe` and shell history). Flag not removed; just discoverable.
|
||||
- Error handling: `Hint` is now a structured field on `CLIError`.
|
||||
JSON-error consumers get clean structure; the human renderer still
|
||||
appends `\nHint: ...`. Dropped substring matching of `"401"`/`"403"`
|
||||
against rendered error strings (would match issue #403); now relies
|
||||
exclusively on typed `*api.APIError`.
|
||||
- Network errors (`no such host`, `connection refused`, `i/o timeout`)
|
||||
return a structured `CLIError` with code `ErrNetworkError` and a hint.
|
||||
- Config dir created with mode 0700 instead of 0755.
|
||||
|
||||
### Fixed
|
||||
|
||||
- `--config <path>` now actually honored. Previously fed only into
|
||||
Viper; every command that touched config went through
|
||||
`internal/config.Load()` / `Save()` which always read the default
|
||||
path. So `fj --config other.yaml auth login` writes to other.yaml now.
|
||||
- `fj run list --json`, `fj workflow list --json`, `fj wiki view --json`
|
||||
now produce JSON. `cmd/aliases.go` registered `--json` as `Bool` but
|
||||
handlers called `wantJSON()` which does `GetString("json")` — pflag
|
||||
returned a type-error that `wantJSON` silently swallowed.
|
||||
`cmd/wiki.go` had the inverse bug (`GetBool` against an
|
||||
`addJSONFlags`-registered string flag). Both routed through
|
||||
`addJSONFlags`/`wantJSON`/`outputJSON` consistently now.
|
||||
- `migrateConfigDir` opens dst with `O_TRUNC`. Previously a partially-
|
||||
pre-existing dst file would have legacy contents overwrite a prefix
|
||||
and leave stale tail bytes — silent YAML/token corruption. Refactored
|
||||
close handling into `copyOneConfigFile`.
|
||||
|
||||
### Security
|
||||
|
||||
- `fj api` endpoint path traversal closed. `fj api '/../admin/users'`
|
||||
previously normalized through `http.NewRequest` to
|
||||
`https://host/admin/users` — silently sending authenticated traffic
|
||||
to non-API paths. Endpoint is now parsed via `url.Parse`, `..`
|
||||
segments rejected, then `JoinPath` onto the `/api/v1` base.
|
||||
URL-encoded `%2E%2E` is also caught because Go decodes before our
|
||||
split.
|
||||
- `fj api --paginate` validates same-origin before forwarding the
|
||||
bearer token to a `Link: rel="next"` URL. Refuses to reattach
|
||||
`Authorization` if the next URL's scheme isn't `https` or its host
|
||||
doesn't match the configured one.
|
||||
- `initConfig` warns on stderr if the resolved config file is world or
|
||||
group readable (`mode & 0o077 != 0`).
|
||||
|
||||
## [0.3.0c] - 2026-03-21
|
||||
|
||||
### Added
|
||||
|
||||
#### Label Management
|
||||
- `fj label list` - List repository labels
|
||||
- `fj label create` - Create a label with color and description
|
||||
- `fj label edit` - Edit label name, color, or description
|
||||
- `fj label delete` - Delete a label
|
||||
|
||||
#### Milestone Management
|
||||
- `fj milestone list` - List milestones with state filtering
|
||||
- `fj milestone view` - View milestone details
|
||||
- `fj milestone create` - Create a milestone with description and due date
|
||||
- `fj milestone edit` - Edit milestone title, description, due date, or state
|
||||
- `fj milestone delete` - Delete a milestone
|
||||
|
||||
#### Wiki Management
|
||||
- `fj wiki list` - List wiki pages
|
||||
- `fj wiki view` - View wiki page content
|
||||
- `fj wiki create` - Create a wiki page from flag or file
|
||||
- `fj wiki edit` - Edit a wiki page
|
||||
- `fj wiki delete` - Delete a wiki page
|
||||
|
||||
#### Issue Dependencies
|
||||
- `fj issue edit --add-dependency <number>` - Add issue dependency
|
||||
- `fj issue edit --remove-dependency <number>` - Remove issue dependency
|
||||
|
||||
## [0.3.0b] - 2026-03-21
|
||||
|
||||
### Added
|
||||
|
||||
#### Repository Management
|
||||
- `fgj repo edit` - Edit repository settings (visibility, description, homepage, default branch)
|
||||
- `fj repo edit` - Edit repository settings (visibility, description, homepage, default branch)
|
||||
|
||||
### Fixed
|
||||
- `fgj repo create --public` flag was defined but never read; now properly wired up
|
||||
- `fj repo create --public` flag was defined but never read; now properly wired up
|
||||
|
||||
## [0.3.0a] - 2026-03-21
|
||||
|
||||
### Added
|
||||
|
||||
#### Raw API Access
|
||||
- `fgj api <endpoint>` - Make authenticated REST API requests to any Forgejo/Gitea endpoint
|
||||
- `fj api <endpoint>` - Make authenticated REST API requests to any Forgejo/Gitea endpoint
|
||||
- HTTP method selection (`--method`/`-X`), auto-switches to POST when fields are provided
|
||||
- JSON field assembly (`--field`/`-f`) with type inference (bool, int, float, null, string)
|
||||
- Raw string fields (`--raw-field`/`-F`)
|
||||
|
|
@ -30,14 +159,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
- Response header display (`--include`/`-i`)
|
||||
|
||||
#### Pull Request Management
|
||||
- `fgj pr diff <number>` - View the diff for a pull request
|
||||
- `fj pr diff <number>` - View the diff for a pull request
|
||||
- Colorized output (`--color auto/always/never`)
|
||||
- Changed file names only (`--name-only`)
|
||||
- Diffstat summary (`--stat`)
|
||||
- `fgj pr comment <number>` - Add a comment to a pull request
|
||||
- `fj pr comment <number>` - Add a comment to a pull request
|
||||
- Body from flag (`--body`/`-b`) or file (`--body-file`, `-` for stdin)
|
||||
- JSON output (`--json`)
|
||||
- `fgj pr review <number>` - Submit a review on a pull request
|
||||
- `fj pr review <number>` - Submit a review on a pull request
|
||||
- Approve (`--approve`/`-a`), request changes (`--request-changes`/`-r`), or comment (`--comment`/`-c`)
|
||||
- Body from flag or file
|
||||
- JSON output (`--json`)
|
||||
|
|
@ -53,30 +182,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
### Added
|
||||
|
||||
#### Forgejo Actions
|
||||
- `fgj actions run watch <run-id>` - Poll a run until completion
|
||||
- `fgj actions run rerun <run-id>` - Trigger a rerun of a workflow run
|
||||
- `fgj actions run cancel <run-id>` - Cancel an in-progress workflow run
|
||||
- `fgj actions workflow enable <workflow>` - Enable a workflow
|
||||
- `fgj actions workflow disable <workflow>` - Disable a workflow
|
||||
- `fj actions run watch <run-id>` - Poll a run until completion
|
||||
- `fj actions run rerun <run-id>` - Trigger a rerun of a workflow run
|
||||
- `fj actions run cancel <run-id>` - Cancel an in-progress workflow run
|
||||
- `fj actions workflow enable <workflow>` - Enable a workflow
|
||||
- `fj actions workflow disable <workflow>` - Disable a workflow
|
||||
|
||||
#### Repository Management
|
||||
- `fgj repo create <name>` - Create a new repository with full option set: `--private`/`--public`, `--description`, `--add-readme`, `--gitignore`, `--license`, `--homepage`, `--clone`, `--team`
|
||||
- `fj repo create <name>` - Create a new repository with full option set: `--private`/`--public`, `--description`, `--add-readme`, `--gitignore`, `--license`, `--homepage`, `--clone`, `--team`
|
||||
|
||||
#### Issue Management
|
||||
- `fgj issue create -l <label>` - Assign labels when creating an issue
|
||||
- `fgj issue edit --add-label` / `--remove-label` - Add or remove labels on existing issues
|
||||
- `fgj issue close -c <comment>` - Close an issue with an optional comment
|
||||
- `fj issue create -l <label>` - Assign labels when creating an issue
|
||||
- `fj issue edit --add-label` / `--remove-label` - Add or remove labels on existing issues
|
||||
- `fj issue close -c <comment>` - Close an issue with an optional comment
|
||||
|
||||
#### Workflow Management
|
||||
- `fgj actions workflow list/view/run` - List, view, and trigger workflows
|
||||
- `fj actions workflow list/view/run` - List, view, and trigger workflows
|
||||
|
||||
#### Auth Helpers
|
||||
- `fgj auth token` - Print the stored token for the current host
|
||||
- `fgj auth logout` - Remove authentication for a host
|
||||
- `fj auth token` - Print the stored token for the current host
|
||||
- `fj auth logout` - Remove authentication for a host
|
||||
|
||||
#### Shell Completions and Man Pages
|
||||
- `fgj completion [bash|zsh|fish|powershell]` - Generate shell completion scripts
|
||||
- `fgj manpages --dir <path>` - Generate man pages for all commands
|
||||
- `fj completion [bash|zsh|fish|powershell]` - Generate shell completion scripts
|
||||
- `fj manpages --dir <path>` - Generate man pages for all commands
|
||||
|
||||
#### JSON Output
|
||||
- `--json` flag for all list and view commands: PRs, issues, releases, workflow runs, workflows
|
||||
|
|
@ -89,17 +218,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
### Added
|
||||
|
||||
#### Release Management
|
||||
- `fgj release list` - List releases for a repository
|
||||
- `fgj release view` - View details of a specific release (supports "latest" keyword)
|
||||
- `fgj release create` - Create new releases with optional asset uploads
|
||||
- `fgj release upload` - Upload assets to existing releases with optional clobber support
|
||||
- `fgj release delete` - Delete releases (preserves Git tags)
|
||||
- `fj release list` - List releases for a repository
|
||||
- `fj release view` - View details of a specific release (supports "latest" keyword)
|
||||
- `fj release create` - Create new releases with optional asset uploads
|
||||
- `fj release upload` - Upload assets to existing releases with optional clobber support
|
||||
- `fj release delete` - Delete releases (preserves Git tags)
|
||||
|
||||
#### Issue Management
|
||||
- `fgj issue edit` - Edit existing issues with support for updating title, body, and labels
|
||||
- `fj issue edit` - Edit existing issues with support for updating title, body, and labels
|
||||
|
||||
#### Pull Request Management
|
||||
- `fgj pr create --assignee` - Assign users when creating pull requests
|
||||
- `fj pr create --assignee` - Assign users when creating pull requests
|
||||
|
||||
#### Repository Detection
|
||||
- Automatic hostname detection from git remote URLs
|
||||
|
|
@ -120,48 +249,48 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
### Added
|
||||
|
||||
#### Core Features
|
||||
- Initial release of fgj - Forgejo CLI tool
|
||||
- Initial release of fj - Forgejo CLI tool
|
||||
- Multi-instance support for any Forgejo/Gitea instance
|
||||
- Automatic repository detection from git context (optional `-R` flag)
|
||||
- Secure authentication with personal access tokens
|
||||
- Configuration management via `~/.config/fgj/config.yaml`
|
||||
- Configuration management via `~/.config/fj/config.yaml`
|
||||
|
||||
#### Pull Request Management
|
||||
- `fgj pr list` - List pull requests with filtering by state
|
||||
- `fgj pr view` - View detailed pull request information
|
||||
- `fgj pr create` - Create new pull requests
|
||||
- `fgj pr merge` - Merge pull requests with configurable merge methods
|
||||
- `fj pr list` - List pull requests with filtering by state
|
||||
- `fj pr view` - View detailed pull request information
|
||||
- `fj pr create` - Create new pull requests
|
||||
- `fj pr merge` - Merge pull requests with configurable merge methods
|
||||
|
||||
#### Issue Management
|
||||
- `fgj issue list` - List issues with state filtering
|
||||
- `fgj issue view` - View detailed issue information
|
||||
- `fgj issue create` - Create new issues
|
||||
- `fgj issue comment` - Add comments to issues
|
||||
- `fgj issue close` - Close issues
|
||||
- `fj issue list` - List issues with state filtering
|
||||
- `fj issue view` - View detailed issue information
|
||||
- `fj issue create` - Create new issues
|
||||
- `fj issue comment` - Add comments to issues
|
||||
- `fj issue close` - Close issues
|
||||
|
||||
#### Repository Operations
|
||||
- `fgj repo view` - View repository details
|
||||
- `fgj repo list` - List user repositories
|
||||
- `fgj repo clone` - Clone repositories with protocol selection (HTTPS/SSH)
|
||||
- `fgj repo fork` - Fork repositories
|
||||
- `fj repo view` - View repository details
|
||||
- `fj repo list` - List user repositories
|
||||
- `fj repo clone` - Clone repositories with protocol selection (HTTPS/SSH)
|
||||
- `fj repo fork` - Fork repositories
|
||||
|
||||
#### Forgejo Actions Support
|
||||
- `fgj actions run list` - List workflow runs with status and metadata
|
||||
- `fgj actions run view` - View detailed run information, jobs, and logs
|
||||
- `fj actions run list` - List workflow runs with status and metadata
|
||||
- `fj actions run view` - View detailed run information, jobs, and logs
|
||||
- Support for `--verbose`, `--log`, `--log-failed`, and `--job` flags
|
||||
- `fgj actions secret list` - List repository secrets
|
||||
- `fgj actions secret create` - Create repository secrets
|
||||
- `fgj actions secret delete` - Delete repository secrets
|
||||
- `fgj actions variable list` - List repository variables
|
||||
- `fgj actions variable get` - Get variable values
|
||||
- `fgj actions variable create` - Create repository variables
|
||||
- `fgj actions variable update` - Update repository variables
|
||||
- `fgj actions variable delete` - Delete repository variables
|
||||
- `fj actions secret list` - List repository secrets
|
||||
- `fj actions secret create` - Create repository secrets
|
||||
- `fj actions secret delete` - Delete repository secrets
|
||||
- `fj actions variable list` - List repository variables
|
||||
- `fj actions variable get` - Get variable values
|
||||
- `fj actions variable create` - Create repository variables
|
||||
- `fj actions variable update` - Update repository variables
|
||||
- `fj actions variable delete` - Delete repository variables
|
||||
|
||||
#### Authentication
|
||||
- `fgj auth login` - Interactive authentication with Forgejo instances
|
||||
- `fgj auth status` - Check authentication status
|
||||
- Environment variable support (`FGJ_HOST`, `FGJ_TOKEN`)
|
||||
- `fj auth login` - Interactive authentication with Forgejo instances
|
||||
- `fj auth status` - Check authentication status
|
||||
- Environment variable support (`FJ_HOST`, `FJ_TOKEN`)
|
||||
|
||||
#### Development
|
||||
- Comprehensive unit test suite
|
||||
|
|
@ -175,8 +304,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
- Cobra framework for CLI structure
|
||||
- Viper for configuration management
|
||||
|
||||
[0.3.0b]: https://forgejo.zerova.net/sid/fgj-sid/releases/tag/v0.3.0b
|
||||
[0.3.0a]: https://forgejo.zerova.net/sid/fgj-sid/releases/tag/v0.3.0a
|
||||
[0.3.0]: https://codeberg.org/romaintb/fgj/releases/tag/v0.3.0
|
||||
[0.2.0]: https://codeberg.org/romaintb/fgj/releases/tag/v0.2.0
|
||||
[0.1.0]: https://codeberg.org/romaintb/fgj/releases/tag/v0.1.0
|
||||
[0.3.0c]: https://forgejo.zerova.net/public/fj/releases/tag/v0.3.0c
|
||||
[0.3.0b]: https://forgejo.zerova.net/public/fj/releases/tag/v0.3.0b
|
||||
[0.3.0a]: https://forgejo.zerova.net/public/fj/releases/tag/v0.3.0a
|
||||
[0.3.0]: https://codeberg.org/romaintb/fj/releases/tag/v0.3.0
|
||||
[0.2.0]: https://codeberg.org/romaintb/fj/releases/tag/v0.2.0
|
||||
[0.1.0]: https://codeberg.org/romaintb/fj/releases/tag/v0.1.0
|
||||
|
|
|
|||
166
CLAUDE.md
Normal file
166
CLAUDE.md
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
# fj — guide for Claude Code sessions
|
||||
|
||||
`fj` is a personal Forgejo/Gitea CLI tool, modeled on GitHub's `gh`. It targets `forgejo.zerova.net` (and Codeberg). The user (sid) owns it; the canonical repo is `public/fj` on forgejo.zerova.net (mirrored from there to nowhere else).
|
||||
|
||||
This file is read first by Claude Code when working in `~/repos/fj`. Goal: get a session productive quickly without re-deriving the dev workflow each time.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
~/repos/fj/
|
||||
├── cmd/ cobra command definitions, one file per subject area
|
||||
│ ├── root.go rootCmd, --config plumbing, OnInitialize
|
||||
│ ├── auth.go login/status/logout/token (uses persistent --hostname)
|
||||
│ ├── api.go raw API access; --json/--json-fields/--jq/--paginate
|
||||
│ ├── json.go shared JSON output helpers (addJSONFlags/wantJSON/outputJSON)
|
||||
│ ├── paginate.go generic paginateGitea[T] helper for list commands
|
||||
│ ├── errors.go CLIError with structured Hint field
|
||||
│ ├── actions.go Forgejo Actions; runs/workflows via factory functions
|
||||
│ ├── aliases.go top-level `fj run` / `fj workflow` aliases — calls actions.go factories
|
||||
│ ├── repo.go pr.go issue.go release.go wiki.go label.go milestone.go
|
||||
│ └── ...
|
||||
├── internal/
|
||||
│ ├── api/client.go SharedHTTPClient (30s timeout); GetJSON/DoJSON/DownloadFile
|
||||
│ ├── config/config.go YAML config; honors --config via SetExplicitConfigPath
|
||||
│ ├── git/ repo + host detection from `git remote`
|
||||
│ ├── iostreams/ wrapped stdin/stdout/stderr + spinner + pager + colors
|
||||
│ └── text/ formatting helpers
|
||||
├── main.go thin entrypoint; ContextualError + JSON-error rendering
|
||||
├── Makefile build / lint / test (no release automation)
|
||||
├── CHANGELOG.md Keep-a-Changelog format
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## Build, install, test
|
||||
|
||||
```bash
|
||||
go build ./... # quick build check
|
||||
go test ./... # unit tests
|
||||
go install . # build + install to ~/go/bin/fj (the binary that's on PATH)
|
||||
make lint # golangci-lint, if you have it
|
||||
```
|
||||
|
||||
After any change in cmd/ or internal/, run `go install .` and the global `fj` reflects it immediately. There's no daemon/restart.
|
||||
|
||||
## Auth
|
||||
|
||||
The user is authenticated as `sid` on `forgejo.zerova.net`. Token lives in `~/.config/fj/config.yaml` (mode 0600). For HTTPS git pushes from this host, the token can be injected via `git -c "http.extraHeader=Authorization: token <T>" push` — the local SSH key (`sid@debian` on forgejo) is also registered, so `git@forgejo.zerova.net:public/fj.git` works directly.
|
||||
|
||||
## Code review pattern (use this for non-trivial changes)
|
||||
|
||||
For audits or significant refactors, run **three reviewers in parallel** with non-overlapping focuses (we did this in the v0.4.0 cycle and it found bugs none would have caught alone):
|
||||
|
||||
- **Codex** — read-only sandbox, peer-AI cross-check
|
||||
```bash
|
||||
codex exec --skip-git-repo-check --sandbox read-only \
|
||||
-m gpt-5.4-mini --config model_reasoning_effort="medium" "<prompt>" 2>/dev/null
|
||||
```
|
||||
For follow-up rounds resume the same session: `echo "<prompt>" | codex exec --skip-git-repo-check resume --last 2>/dev/null`. Codex remembers prior critique.
|
||||
|
||||
- **Claude general-purpose agent A** — architecture / UX / code-quality
|
||||
- **Claude general-purpose agent B** — security / correctness / error handling
|
||||
|
||||
Tell each reviewer what the **siblings** are covering so they don't duplicate. Cap reports at ~600 words. Consolidate findings by severity (HIGH / MEDIUM / LOW) before presenting to the user.
|
||||
|
||||
## Release process
|
||||
|
||||
We use semver. **Pre-1.0**: breaking change → minor bump (e.g. v0.3.x → v0.4.0).
|
||||
|
||||
1. **Bump version**
|
||||
```go
|
||||
// cmd/root.go
|
||||
Version: "0.4.0",
|
||||
```
|
||||
|
||||
2. **Update CHANGELOG.md** — prepend a new section. Format:
|
||||
```markdown
|
||||
## [0.4.0] - YYYY-MM-DD
|
||||
|
||||
### BREAKING
|
||||
- <thing that broke>
|
||||
|
||||
### Added
|
||||
- ...
|
||||
### Changed
|
||||
- ...
|
||||
### Fixed
|
||||
- ...
|
||||
### Security
|
||||
- ...
|
||||
```
|
||||
|
||||
3. **Commit** the version+changelog bump as a single commit:
|
||||
```bash
|
||||
git commit -m "chore: bump version to 0.4.0"
|
||||
```
|
||||
|
||||
4. **Tag** the commit:
|
||||
```bash
|
||||
git tag -a v0.4.0 -m "Release v0.4.0: <one-line summary>"
|
||||
```
|
||||
|
||||
5. **Push** commits and tag:
|
||||
```bash
|
||||
git push origin main
|
||||
git push origin v0.4.0
|
||||
```
|
||||
|
||||
6. **Create the Forgejo release page** via fj itself:
|
||||
```bash
|
||||
fj release create v0.4.0 \
|
||||
--title "v0.4.0: <summary>" \
|
||||
--notes "$(awk '/^## \[0.4.0\]/{flag=1;next} /^## /{flag=0} flag' CHANGELOG.md)"
|
||||
```
|
||||
(The awk one-liner extracts the just-added CHANGELOG section as release notes.)
|
||||
|
||||
7. **Update the homebrew tap** — see the next section.
|
||||
|
||||
## Updating the homebrew tap (`public/homebrew-sid`)
|
||||
|
||||
The tap lives at `~/repos/homebrew-sid` (or `git@forgejo.zerova.net:public/homebrew-sid.git`). The `Formula/fj.rb` formula references the source by `tag:` + `revision:` (SHA), so a release bump touches three lines:
|
||||
|
||||
```ruby
|
||||
url "ssh://git@forgejo.zerova.net/public/fj.git",
|
||||
tag: "v0.4.0", # was v0.3.2
|
||||
revision: "<SHA of v0.4.0 tag>" # update
|
||||
|
||||
test do
|
||||
assert_match "0.4.0", shell_output("#{bin}/fj --version") # update
|
||||
end
|
||||
```
|
||||
|
||||
To get the SHA:
|
||||
```bash
|
||||
git -C ~/repos/fj rev-parse v0.4.0
|
||||
```
|
||||
|
||||
Then in `~/repos/homebrew-sid`:
|
||||
```bash
|
||||
# edit Formula/fj.rb (the three lines above)
|
||||
git commit -am "fj: bump to v0.4.0"
|
||||
git push origin main
|
||||
```
|
||||
|
||||
After push, users can `brew update && brew upgrade fj` to pick up the new version.
|
||||
|
||||
## Common footguns
|
||||
|
||||
- **`fj` reads the current dir's `git origin`** to detect the host. In a directory whose origin points at github.com (e.g. /opt/stacks/claude-code-proxy/build), bare `fj api ...` errors with "no configuration found for host github.com". Pass `--hostname forgejo.zerova.net` explicitly, or `cd` somewhere else.
|
||||
- **`--json=fields` was removed in v0.4.0** in favor of `--json-fields fields` (or `--json-fields=fields`). The old `=fields` form was a `NoOptDefVal=" "` sentinel hack. `--json` is now a plain Bool meaning "as JSON".
|
||||
- **`--config` was silently ignored before v0.4.0.** Old fj versions read --config into Viper but `internal/config.Load()` always read the default path. Fixed; `fj --config other.yaml auth login` now writes to other.yaml.
|
||||
- **The `actions` and `run`/`workflow` command trees share factory functions** in `cmd/actions.go` (`newRunCmd`, `newWorkflowCmd`). Don't add flags directly to `runListCmd` style globals — they don't exist anymore. Edit the factory and both `fj actions run list` and `fj run list` get the change.
|
||||
|
||||
## Useful commands
|
||||
|
||||
```bash
|
||||
# Live test against forgejo (using the new flags)
|
||||
fj --hostname forgejo.zerova.net api repos/public/fj --json-fields full_name,description
|
||||
|
||||
# Walk paginated endpoints
|
||||
fj --hostname forgejo.zerova.net api 'repos/public/fj/commits?limit=10' --paginate --jq '.[].sha[0:8]'
|
||||
|
||||
# Confirm both command trees stay in sync after edits
|
||||
diff <(fj run list --help | grep -E "^ -|^ --" | sort) \
|
||||
<(fj actions run list --help | grep -E "^ -|^ --" | sort)
|
||||
# Empty diff = trees agree. Any output = factory drift.
|
||||
```
|
||||
4
Makefile
4
Makefile
|
|
@ -11,10 +11,10 @@ help:
|
|||
@echo " make clean - Clean build artifacts"
|
||||
|
||||
build:
|
||||
go build -o bin/fgj .
|
||||
go build -o bin/fj .
|
||||
|
||||
install: build
|
||||
install -Dm755 bin/fgj /usr/bin/fgj
|
||||
install -Dm755 bin/fj /usr/bin/fj
|
||||
|
||||
run:
|
||||
go run .
|
||||
|
|
|
|||
354
README.md
354
README.md
|
|
@ -1,11 +1,11 @@
|
|||
# fgj - Forgejo/Gitea CLI Tool
|
||||
# fj - Forgejo/Gitea CLI Tool
|
||||
|
||||
[](https://golang.org)
|
||||
[](LICENSE)
|
||||
|
||||
`fgj` is a command-line tool for working with Forgejo and Gitea instances. It brings pull requests, issues, and other forge concepts to the terminal, similar to what `gh` does for GitHub. This fork adds agentic dev features — raw API access, PR review workflows, structured error output, and machine-readable I/O for AI coding agents.
|
||||
`fj` is a command-line tool for working with Forgejo and Gitea instances. It brings pull requests, issues, and other forge concepts to the terminal, similar to what `gh` does for GitHub. This fork adds agentic dev features — raw API access, PR review workflows, structured error output, and machine-readable I/O for AI coding agents.
|
||||
|
||||
> Forked from [codeberg.org/romaintb/fgj](https://codeberg.org/romaintb/fgj) and hosted at [forgejo.zerova.net/sid/fgj-sid](https://forgejo.zerova.net/sid/fgj-sid).
|
||||
> Forked from [codeberg.org/romaintb/fj](https://codeberg.org/romaintb/fj) and hosted at [forgejo.zerova.net/public/fj](https://forgejo.zerova.net/public/fj).
|
||||
|
||||
## Features
|
||||
|
||||
|
|
@ -13,9 +13,13 @@
|
|||
- Pull request management (create, list, view, merge, diff, comment, review)
|
||||
- Issue tracking (create, list, view, comment, close, labels)
|
||||
- Repository operations (view, list, create, edit, clone, fork)
|
||||
- Label management (list, create, edit, delete)
|
||||
- Milestone management (list, view, create, edit, delete)
|
||||
- Wiki page management (list, view, create, edit, delete)
|
||||
- Issue dependencies (`--add-dependency`, `--remove-dependency`)
|
||||
- Forgejo Actions (workflow runs, watch/rerun/cancel, enable/disable, secrets, variables)
|
||||
- Releases (create, upload, delete)
|
||||
- Raw API access (`fgj api`) for arbitrary REST calls
|
||||
- Raw API access (`fj api`) for arbitrary REST calls
|
||||
- Shell completions (bash, zsh, fish, PowerShell) and man pages
|
||||
- JSON output (`--json`) for all list/view commands
|
||||
- Structured JSON error output (`--json-errors`) for machine consumption
|
||||
|
|
@ -29,22 +33,22 @@
|
|||
### macOS (Homebrew)
|
||||
|
||||
```bash
|
||||
brew tap sid/fgj-sid https://forgejo.zerova.net/sid/homebrew-fgj-sid.git
|
||||
brew install fgj
|
||||
brew tap public/sid git@forgejo.zerova.net:public/homebrew-sid.git
|
||||
brew install fj
|
||||
```
|
||||
|
||||
### Using Go Install
|
||||
|
||||
```bash
|
||||
go install forgejo.zerova.net/sid/fgj-sid@latest
|
||||
go install forgejo.zerova.net/public/fj@latest
|
||||
```
|
||||
|
||||
### From Source
|
||||
|
||||
```bash
|
||||
git clone https://forgejo.zerova.net/sid/fgj-sid.git
|
||||
cd fgj-sid
|
||||
go build -o fgj .
|
||||
git clone https://forgejo.zerova.net/public/fj.git
|
||||
cd fj
|
||||
go build -o fj .
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
|
@ -54,7 +58,7 @@ go build -o fgj .
|
|||
First, authenticate with your Forgejo or Gitea instance:
|
||||
|
||||
```bash
|
||||
fgj auth login
|
||||
fj auth login
|
||||
```
|
||||
|
||||
You'll be prompted for:
|
||||
|
|
@ -70,34 +74,34 @@ To create a personal access token:
|
|||
### 2. Check Authentication Status
|
||||
|
||||
```bash
|
||||
fgj auth status
|
||||
fj auth status
|
||||
```
|
||||
|
||||
### Auth Helpers
|
||||
|
||||
```bash
|
||||
# Print the stored token for the current host
|
||||
fgj auth token
|
||||
fj auth token
|
||||
|
||||
# Remove authentication for a host
|
||||
fgj auth logout
|
||||
fj auth logout
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Repository Detection
|
||||
|
||||
`fgj` automatically detects the repository from your git context, similar to `gh`:
|
||||
`fj` automatically detects the repository from your git context, similar to `gh`:
|
||||
|
||||
```bash
|
||||
# When inside a git repository, no -R flag needed!
|
||||
cd /path/to/your/repo
|
||||
fgj pr list # Automatically uses current repo
|
||||
fgj issue list # Automatically uses current repo
|
||||
fgj pr view 123 # Automatically uses current repo
|
||||
fj pr list # Automatically uses current repo
|
||||
fj issue list # Automatically uses current repo
|
||||
fj pr view 123 # Automatically uses current repo
|
||||
|
||||
# Or explicitly specify a repository with -R
|
||||
fgj pr list -R owner/repo
|
||||
fj pr list -R owner/repo
|
||||
```
|
||||
|
||||
The tool reads `.git/config` to find the origin remote and extract both the owner/repo information and the instance hostname. If you're not in a git repository, you'll need to use the `-R` flag.
|
||||
|
|
@ -106,240 +110,307 @@ The tool reads `.git/config` to find the origin remote and extract both the owne
|
|||
|
||||
```bash
|
||||
# List pull requests (auto-detects repo and hostname from git)
|
||||
fgj pr list
|
||||
fj pr list
|
||||
|
||||
# Or specify explicitly
|
||||
fgj pr list -R owner/repo
|
||||
fj pr list -R owner/repo
|
||||
|
||||
# Filter by state
|
||||
fgj pr list --state closed
|
||||
fj pr list --state closed
|
||||
|
||||
# View a specific pull request
|
||||
fgj pr view 123
|
||||
fj pr view 123
|
||||
|
||||
# Create a pull request
|
||||
fgj pr create -t "PR Title" -b "PR Description" -H feature-branch -B main
|
||||
fj pr create -t "PR Title" -b "PR Description" -H feature-branch -B main
|
||||
|
||||
# Merge a pull request
|
||||
fgj pr merge 123 --merge-method squash
|
||||
fj pr merge 123 --merge-method squash
|
||||
|
||||
# View PR diff
|
||||
fgj pr diff 123
|
||||
fj pr diff 123
|
||||
|
||||
# View diff with color
|
||||
fgj pr diff 123 --color always
|
||||
fj pr diff 123 --color always
|
||||
|
||||
# Show only changed file names
|
||||
fgj pr diff 123 --name-only
|
||||
fj pr diff 123 --name-only
|
||||
|
||||
# Show diffstat summary
|
||||
fgj pr diff 123 --stat
|
||||
fj pr diff 123 --stat
|
||||
|
||||
# Comment on a pull request
|
||||
fgj pr comment 123 -b "Looks good, minor nit on line 42"
|
||||
fj pr comment 123 -b "Looks good, minor nit on line 42"
|
||||
|
||||
# Comment from a file
|
||||
fgj pr comment 123 --body-file review-notes.md
|
||||
fj pr comment 123 --body-file review-notes.md
|
||||
|
||||
# Approve a pull request
|
||||
fgj pr review 123 --approve -b "LGTM"
|
||||
fj pr review 123 --approve -b "LGTM"
|
||||
|
||||
# Request changes
|
||||
fgj pr review 123 --request-changes -b "Please fix the error handling"
|
||||
fj pr review 123 --request-changes -b "Please fix the error handling"
|
||||
|
||||
# Submit a review comment (neither approve nor request changes)
|
||||
fgj pr review 123 --comment -b "Some observations"
|
||||
fj pr review 123 --comment -b "Some observations"
|
||||
```
|
||||
|
||||
### Issues
|
||||
|
||||
```bash
|
||||
# List issues (auto-detects repo and hostname from git)
|
||||
fgj issue list
|
||||
fj issue list
|
||||
|
||||
# Or specify explicitly
|
||||
fgj issue list -R owner/repo
|
||||
fj issue list -R owner/repo
|
||||
|
||||
# Filter by state
|
||||
fgj issue list --state all
|
||||
fj issue list --state all
|
||||
|
||||
# View an issue
|
||||
fgj issue view 456
|
||||
fj issue view 456
|
||||
|
||||
# Create an issue
|
||||
fgj issue create -t "Issue Title" -b "Issue Description"
|
||||
fj issue create -t "Issue Title" -b "Issue Description"
|
||||
|
||||
# Create an issue with labels
|
||||
fgj issue create -t "Issue Title" -b "Issue Description" -l bug -l enhancement
|
||||
fj issue create -t "Issue Title" -b "Issue Description" -l bug -l enhancement
|
||||
|
||||
# Comment on an issue
|
||||
fgj issue comment 456 -b "My comment"
|
||||
fj issue comment 456 -b "My comment"
|
||||
|
||||
# Close an issue
|
||||
fgj issue close 456
|
||||
fj issue close 456
|
||||
|
||||
# Close an issue with a comment
|
||||
fgj issue close 456 -c "Fixed in v2.0"
|
||||
fj issue close 456 -c "Fixed in v2.0"
|
||||
|
||||
# Edit an issue (title, body, state, labels)
|
||||
fgj issue edit 456 -t "New Title"
|
||||
fgj issue edit 456 --add-label priority --remove-label bug
|
||||
fj issue edit 456 -t "New Title"
|
||||
fj issue edit 456 --add-label priority --remove-label bug
|
||||
|
||||
# Manage issue dependencies
|
||||
fj issue edit 456 --add-dependency 123
|
||||
fj issue edit 456 --remove-dependency 123
|
||||
```
|
||||
|
||||
### Labels
|
||||
|
||||
```bash
|
||||
# List labels
|
||||
fj label list
|
||||
|
||||
# Create a label
|
||||
fj label create bug --color ff0000 -d "Something isn't working"
|
||||
|
||||
# Edit a label
|
||||
fj label edit bug --name bugfix --color ee0000
|
||||
|
||||
# Delete a label
|
||||
fj label delete bug
|
||||
```
|
||||
|
||||
### Milestones
|
||||
|
||||
```bash
|
||||
# List milestones
|
||||
fj milestone list
|
||||
fj milestone list --state all
|
||||
|
||||
# View a milestone
|
||||
fj milestone view "v1.0"
|
||||
|
||||
# Create a milestone with due date
|
||||
fj 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
|
||||
|
||||
# Delete a milestone
|
||||
fj milestone delete "v2.0"
|
||||
```
|
||||
|
||||
### Wiki
|
||||
|
||||
```bash
|
||||
# List wiki pages
|
||||
fj wiki list
|
||||
|
||||
# View a wiki page
|
||||
fj wiki view "Home"
|
||||
|
||||
# Create a wiki page
|
||||
fj wiki create "Setup Guide" -b "# Setup\n\nFollow these steps..."
|
||||
|
||||
# Create from file
|
||||
fj wiki create "API Docs" --body-file docs/api.md
|
||||
|
||||
# Edit a wiki page
|
||||
fj wiki edit "Home" -b "Updated content"
|
||||
|
||||
# Delete a wiki page
|
||||
fj wiki delete "Old Page"
|
||||
```
|
||||
|
||||
### Repositories
|
||||
|
||||
```bash
|
||||
# View repository details
|
||||
fgj repo view owner/repo
|
||||
fj repo view owner/repo
|
||||
|
||||
# List your repositories
|
||||
fgj repo list
|
||||
fj repo list
|
||||
|
||||
# Create a repository
|
||||
fgj repo create my-repo
|
||||
fgj repo create my-repo -d "My project" --private --add-readme -g Go -l MIT
|
||||
fj repo create my-repo
|
||||
fj repo create my-repo -d "My project" --private --add-readme -g Go -l MIT
|
||||
|
||||
# Clone a repository
|
||||
fgj repo clone owner/repo
|
||||
fj repo clone owner/repo
|
||||
|
||||
# Clone via SSH
|
||||
fgj repo clone owner/repo -p ssh
|
||||
fj repo clone owner/repo -p ssh
|
||||
|
||||
# Fork a repository
|
||||
fgj repo fork owner/repo
|
||||
fj repo fork owner/repo
|
||||
|
||||
# Edit repository settings
|
||||
fgj repo edit owner/repo --public
|
||||
fgj repo edit owner/repo --private
|
||||
fgj repo edit owner/repo -d "New description" --homepage https://example.com
|
||||
fgj repo edit --default-branch develop
|
||||
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
|
||||
```
|
||||
|
||||
### Releases
|
||||
|
||||
```bash
|
||||
# List releases
|
||||
fgj release list
|
||||
fj release list
|
||||
|
||||
# View a release (or use "latest")
|
||||
fgj release view v1.2.3
|
||||
fj release view v1.2.3
|
||||
|
||||
# Create a release with notes and optional assets
|
||||
fgj release create v1.2.3 -t "v1.2.3" -n "Release notes" ./dist/app.tar.gz
|
||||
fj release create v1.2.3 -t "v1.2.3" -n "Release notes" ./dist/app.tar.gz
|
||||
|
||||
# Upload assets to an existing release
|
||||
fgj release upload v1.2.3 ./dist/app.tar.gz --clobber
|
||||
fj release upload v1.2.3 ./dist/app.tar.gz --clobber
|
||||
|
||||
# Delete a release (keeps the Git tag)
|
||||
fgj release delete v1.2.3
|
||||
fj release delete v1.2.3
|
||||
```
|
||||
|
||||
### Forgejo Actions
|
||||
|
||||
```bash
|
||||
# List workflows
|
||||
fgj actions workflow list
|
||||
fj actions workflow list
|
||||
|
||||
# View a workflow
|
||||
fgj actions workflow view ci.yml
|
||||
fj actions workflow view ci.yml
|
||||
|
||||
# Run a workflow (trigger workflow_dispatch)
|
||||
fgj actions workflow run deploy.yml
|
||||
fj actions workflow run deploy.yml
|
||||
|
||||
# Run a workflow with inputs
|
||||
fgj actions workflow run deploy.yml -f environment=production -f version=1.2.3
|
||||
fj actions workflow run deploy.yml -f environment=production -f version=1.2.3
|
||||
|
||||
# Run a workflow on a specific branch
|
||||
fgj actions workflow run deploy.yml -r feature-branch
|
||||
fj actions workflow run deploy.yml -r feature-branch
|
||||
|
||||
# Enable or disable a workflow
|
||||
fgj actions workflow enable ci.yml
|
||||
fgj actions workflow disable ci.yml
|
||||
fj actions workflow enable ci.yml
|
||||
fj actions workflow disable ci.yml
|
||||
|
||||
# List workflow runs
|
||||
fgj actions run list
|
||||
fj actions run list
|
||||
|
||||
# View a specific run
|
||||
fgj actions run view 123
|
||||
fj actions run view 123
|
||||
|
||||
# View run with job details
|
||||
fgj actions run view 123 --verbose
|
||||
fj actions run view 123 --verbose
|
||||
|
||||
# View run logs
|
||||
fgj actions run view 123 --log
|
||||
fj actions run view 123 --log
|
||||
|
||||
# View specific job logs
|
||||
fgj actions run view 123 --job 456 --log
|
||||
fj actions run view 123 --job 456 --log
|
||||
|
||||
# Watch a run until completion
|
||||
fgj actions run watch 123
|
||||
fj actions run watch 123
|
||||
|
||||
# Rerun a workflow run
|
||||
fgj actions run rerun 123
|
||||
fj actions run rerun 123
|
||||
|
||||
# Cancel a running workflow
|
||||
fgj actions run cancel 123
|
||||
fj actions run cancel 123
|
||||
|
||||
# List secrets
|
||||
fgj actions secret list
|
||||
fj actions secret list
|
||||
|
||||
# Create a secret
|
||||
fgj actions secret create MY_SECRET
|
||||
fj actions secret create MY_SECRET
|
||||
|
||||
# Delete a secret
|
||||
fgj actions secret delete MY_SECRET
|
||||
fj actions secret delete MY_SECRET
|
||||
|
||||
# List variables
|
||||
fgj actions variable list
|
||||
fj actions variable list
|
||||
|
||||
# Get a variable
|
||||
fgj actions variable get MY_VAR
|
||||
fj actions variable get MY_VAR
|
||||
|
||||
# Create a variable
|
||||
fgj actions variable create MY_VAR "value"
|
||||
fj actions variable create MY_VAR "value"
|
||||
|
||||
# Update a variable
|
||||
fgj actions variable update MY_VAR "new value"
|
||||
fj actions variable update MY_VAR "new value"
|
||||
|
||||
# Delete a variable
|
||||
fgj actions variable delete MY_VAR
|
||||
fj actions variable delete MY_VAR
|
||||
```
|
||||
|
||||
### Raw API Access
|
||||
|
||||
```bash
|
||||
# GET request (auto-detects owner/repo from git context)
|
||||
fgj api /repos/{owner}/{repo}/pulls
|
||||
fj api /repos/{owner}/{repo}/pulls
|
||||
|
||||
# POST with fields
|
||||
fgj api /repos/{owner}/{repo}/issues -X POST -f title="Bug report" -f body="Description"
|
||||
fj api /repos/{owner}/{repo}/issues -X POST -f title="Bug report" -f body="Description"
|
||||
|
||||
# Explicit method and hostname
|
||||
fgj api /repos/myorg/myrepo/labels --hostname my-forgejo.example.com
|
||||
fj api /repos/myorg/myrepo/labels --hostname my-forgejo.example.com
|
||||
|
||||
# Read request body from file
|
||||
fgj api /repos/{owner}/{repo}/issues -X POST --input issue.json
|
||||
fj api /repos/{owner}/{repo}/issues -X POST --input issue.json
|
||||
|
||||
# Read from stdin
|
||||
echo '{"title":"test"}' | fgj api /repos/{owner}/{repo}/issues -X POST --input -
|
||||
echo '{"title":"test"}' | fj api /repos/{owner}/{repo}/issues -X POST --input -
|
||||
|
||||
# Include response headers
|
||||
fgj api /repos/{owner}/{repo} -i
|
||||
fj api /repos/{owner}/{repo} -i
|
||||
|
||||
# Suppress output (useful for DELETE)
|
||||
fgj api /repos/{owner}/{repo}/issues/123 -X DELETE --silent
|
||||
fj api /repos/{owner}/{repo}/issues/123 -X DELETE --silent
|
||||
```
|
||||
|
||||
## Shell Completions and Man Pages
|
||||
|
||||
```bash
|
||||
# Generate shell completion scripts
|
||||
fgj completion bash > /etc/bash_completion.d/fgj
|
||||
fgj completion zsh > "${fpath[1]}/_fgj"
|
||||
fgj completion fish > ~/.config/fish/completions/fgj.fish
|
||||
fj completion bash > /etc/bash_completion.d/fj
|
||||
fj completion zsh > "${fpath[1]}/_fj"
|
||||
fj completion fish > ~/.config/fish/completions/fj.fish
|
||||
|
||||
# Generate man pages to a directory
|
||||
fgj manpages --dir ~/.local/share/man/man1
|
||||
fj manpages --dir ~/.local/share/man/man1
|
||||
```
|
||||
|
||||
## JSON Output
|
||||
|
|
@ -347,15 +418,15 @@ fgj manpages --dir ~/.local/share/man/man1
|
|||
Most list and view commands support `--json` for machine-readable output:
|
||||
|
||||
```bash
|
||||
fgj pr list --json
|
||||
fgj issue view 456 --json
|
||||
fgj release list --json
|
||||
fgj actions run list --json
|
||||
fgj actions workflow view ci.yml --json
|
||||
fj pr list --json
|
||||
fj issue view 456 --json
|
||||
fj release list --json
|
||||
fj actions run list --json
|
||||
fj actions workflow view ci.yml --json
|
||||
|
||||
# Get JSON output from PR comment/review
|
||||
fgj pr comment 123 -b "LGTM" --json
|
||||
fgj pr review 123 --approve -b "Ship it" --json
|
||||
fj pr comment 123 -b "LGTM" --json
|
||||
fj pr review 123 --approve -b "Ship it" --json
|
||||
```
|
||||
|
||||
### Structured Error Output
|
||||
|
|
@ -364,16 +435,16 @@ For machine consumption (ideal for AI agents and scripts), use `--json-errors` t
|
|||
|
||||
```bash
|
||||
# Errors are written to stderr as JSON
|
||||
fgj pr view 9999 --json-errors
|
||||
fj pr view 9999 --json-errors
|
||||
# stderr: {"error":{"code":"not_found","message":"...","status":404}}
|
||||
|
||||
# Combine with --json for fully machine-readable I/O
|
||||
fgj pr list --json --json-errors
|
||||
fj pr list --json --json-errors
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
Configuration is stored in `~/.config/fgj/config.yaml`:
|
||||
Configuration is stored in `~/.config/fj/config.yaml`:
|
||||
|
||||
```yaml
|
||||
hosts:
|
||||
|
|
@ -382,38 +453,71 @@ hosts:
|
|||
token: your_token_here
|
||||
user: your_username
|
||||
git_protocol: ssh
|
||||
match_dirs:
|
||||
- / # catch-all: use this host when no git remote is detected
|
||||
codeberg.org:
|
||||
hostname: codeberg.org
|
||||
token: another_token
|
||||
user: another_username
|
||||
git_protocol: https
|
||||
match_dirs:
|
||||
- ~/repos/codeberg # use this host for repos under this directory
|
||||
```
|
||||
|
||||
### Directory-Based Host Selection (`match_dirs`)
|
||||
|
||||
When you work with multiple Forgejo/Gitea instances, `fj` can automatically select the right host based on your current working directory — no `--hostname` flag needed.
|
||||
|
||||
Each host entry supports a `match_dirs` list of directory paths. When `fj` can't determine the host from a git remote, it finds the host whose `match_dirs` entry is the **longest prefix match** for your current directory.
|
||||
|
||||
```yaml
|
||||
hosts:
|
||||
work.example.com:
|
||||
# ...
|
||||
match_dirs:
|
||||
- ~/work # any repo under ~/work uses this host
|
||||
personal.example.com:
|
||||
# ...
|
||||
match_dirs:
|
||||
- ~/personal
|
||||
- ~/side-projects # multiple directories can map to the same host
|
||||
codeberg.org:
|
||||
# ...
|
||||
match_dirs:
|
||||
- / # catch-all fallback (shortest prefix, lowest priority)
|
||||
```
|
||||
|
||||
- Paths support `~` expansion and symlink resolution
|
||||
- More specific (longer) paths always win over shorter ones
|
||||
- Use `/` as a catch-all to override the default `codeberg.org` fallback
|
||||
- On ties (same prefix length), the host appearing first in the config file wins
|
||||
|
||||
### Environment Variables
|
||||
|
||||
- `FGJ_HOST`: Override the default instance (auto-detected from git remote if not set)
|
||||
- `FGJ_TOKEN`: Provide authentication token
|
||||
- `FJ_HOST`: Override the default instance (auto-detected from git remote if not set)
|
||||
- `FJ_TOKEN`: Provide authentication token
|
||||
|
||||
Hostname is resolved in this priority order:
|
||||
1. Command-specific flags (e.g., `--hostname`)
|
||||
2. `FGJ_HOST` environment variable
|
||||
2. `FJ_HOST` environment variable
|
||||
3. Auto-detected from git remote URL
|
||||
4. Default to `codeberg.org`
|
||||
4. `match_dirs` lookup (longest prefix match against current directory)
|
||||
5. Default to `codeberg.org`
|
||||
|
||||
### Command-line Flags
|
||||
|
||||
- `--hostname`: Specify instance for a command (overrides auto-detection and environment variables)
|
||||
- `--config`: Use a custom config file
|
||||
|
||||
When working in a git repository, `fgj` automatically detects the instance from your origin remote URL, so you typically don't need to specify `--hostname` unless working with multiple instances.
|
||||
When working in a git repository, `fj` automatically detects the instance from your origin remote URL, so you typically don't need to specify `--hostname` unless working with multiple instances.
|
||||
|
||||
## Use with AI Coding Agents
|
||||
|
||||
`fgj` is designed to work seamlessly with AI coding agents like Claude Code. Use `--json` and `--json-errors` for fully machine-readable I/O:
|
||||
`fj` is designed to work seamlessly with AI coding agents like Claude Code. Use `--json` and `--json-errors` for fully machine-readable I/O:
|
||||
|
||||
```bash
|
||||
# Create PR from agent's changes
|
||||
fgj pr create -R owner/repo -t "feat: add new feature" -b "$(cat <<EOF
|
||||
fj pr create -R owner/repo -t "feat: add new feature" -b "$(cat <<EOF
|
||||
## Summary
|
||||
- Added new feature X
|
||||
- Fixed bug Y
|
||||
|
|
@ -423,29 +527,29 @@ EOF
|
|||
)" --json
|
||||
|
||||
# Check PR status during development
|
||||
fgj pr list -R owner/repo --state open --json
|
||||
fj pr list -R owner/repo --state open --json
|
||||
|
||||
# Review a PR diff, then approve
|
||||
fgj pr diff 123
|
||||
fgj pr review 123 --approve -b "LGTM" --json
|
||||
fj pr diff 123
|
||||
fj pr review 123 --approve -b "LGTM" --json
|
||||
|
||||
# Post review feedback
|
||||
fgj pr comment 123 -b "Consider using a map here for O(1) lookup" --json
|
||||
fj pr comment 123 -b "Consider using a map here for O(1) lookup" --json
|
||||
|
||||
# Request changes with detailed feedback
|
||||
fgj pr review 123 --request-changes --body-file feedback.md --json
|
||||
fj pr review 123 --request-changes --body-file feedback.md --json
|
||||
|
||||
# Use raw API for anything not covered by commands
|
||||
fgj api /repos/{owner}/{repo}/topics --json-errors
|
||||
fgj api /repos/{owner}/{repo}/labels -X POST -f name=agent-reviewed -f color="#00ff00"
|
||||
fj api /repos/{owner}/{repo}/topics --json-errors
|
||||
fj api /repos/{owner}/{repo}/labels -X POST -f name=agent-reviewed -f color="#00ff00"
|
||||
|
||||
# Fully machine-readable error handling
|
||||
fgj pr view 9999 --json --json-errors 2>errors.json
|
||||
fj pr view 9999 --json --json-errors 2>errors.json
|
||||
```
|
||||
|
||||
## Supported Instances
|
||||
|
||||
`fgj` works with any Forgejo or Gitea instance, including:
|
||||
`fj` works with any Forgejo or Gitea instance, including:
|
||||
|
||||
- Self-hosted Forgejo instances
|
||||
- Self-hosted Gitea instances
|
||||
|
|
@ -453,11 +557,11 @@ fgj pr view 9999 --json --json-errors 2>errors.json
|
|||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome! Please feel free to submit a Pull Request at [forgejo.zerova.net/sid/fgj-sid](https://forgejo.zerova.net/sid/fgj-sid).
|
||||
Contributions are welcome! Please feel free to submit a Pull Request at [forgejo.zerova.net/public/fj](https://forgejo.zerova.net/public/fj).
|
||||
|
||||
## Missing Features / Roadmap
|
||||
|
||||
`fgj` aims to be a drop-in replacement for `gh` when working with Forgejo and Gitea instances. While we've implemented the core features, some `gh` commands are not yet available:
|
||||
`fj` aims to be a drop-in replacement for `gh` when working with Forgejo and Gitea instances. While we've implemented the core features, some `gh` commands are not yet available:
|
||||
|
||||
**Not Yet Implemented:**
|
||||
- `run delete` - Delete a workflow run
|
||||
|
|
@ -466,13 +570,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 rename`
|
||||
- `repo delete`
|
||||
|
||||
We welcome contributions to implement any of these features!
|
||||
|
||||
## Acknowledgments
|
||||
|
||||
Based on [fgj by romaintb](https://codeberg.org/romaintb/fgj). Enhanced with agentic dev features for AI-assisted workflows.
|
||||
Based on [fj by romaintb](https://codeberg.org/romaintb/fj). Enhanced with agentic dev features for AI-assisted workflows.
|
||||
|
||||
## License
|
||||
|
||||
|
|
|
|||
520
cmd/actions.go
520
cmd/actions.go
File diff suppressed because it is too large
Load diff
16
cmd/aliases.go
Normal file
16
cmd/aliases.go
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
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')"))
|
||||
}
|
||||
233
cmd/api.go
233
cmd/api.go
|
|
@ -6,15 +6,23 @@ import (
|
|||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"forgejo.zerova.net/sid/fgj-sid/internal/config"
|
||||
"forgejo.zerova.net/sid/fgj-sid/internal/git"
|
||||
"forgejo.zerova.net/public/fj/internal/api"
|
||||
"forgejo.zerova.net/public/fj/internal/config"
|
||||
"forgejo.zerova.net/public/fj/internal/git"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// maxAPIResponseBytes caps response bodies for `fj api`. Forgejo responses
|
||||
// are normally <1 MB; 64 MB is enough for any sane payload while preventing
|
||||
// a runaway body from OOMing the CLI when combined with the 30 s client
|
||||
// timeout.
|
||||
const maxAPIResponseBytes = 64 << 20
|
||||
|
||||
var apiCmd = &cobra.Command{
|
||||
Use: "api <endpoint> [flags]",
|
||||
Short: "Make an authenticated API request",
|
||||
|
|
@ -26,16 +34,22 @@ detected from the current git repository.
|
|||
|
||||
If --field is used and no --method is specified, the method defaults to POST.`,
|
||||
Example: ` # List pull requests for the current repository
|
||||
fgj api /repos/{owner}/{repo}/pulls
|
||||
fj api /repos/{owner}/{repo}/pulls
|
||||
|
||||
# Create an issue
|
||||
fgj api /repos/{owner}/{repo}/issues --method POST --field title=Bug --field body="It broke"
|
||||
fj api /repos/{owner}/{repo}/issues --method POST --field title=Bug --field body="It broke"
|
||||
|
||||
# Get a specific user
|
||||
fgj api /users/johndoe
|
||||
fj api /users/johndoe
|
||||
|
||||
# Use raw body from stdin
|
||||
echo '{"title":"test"}' | fgj api /repos/{owner}/{repo}/issues --input -`,
|
||||
echo '{"title":"test"}' | fj api /repos/{owner}/{repo}/issues --input -
|
||||
|
||||
# Filter the response with a jq expression
|
||||
fj api /repos/{owner}/{repo}/issues --jq '.[].title'
|
||||
|
||||
# Project the response down to specific fields
|
||||
fj api /repos/{owner}/{repo} --json-fields full_name,description,private`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: runAPI,
|
||||
}
|
||||
|
|
@ -50,6 +64,40 @@ func init() {
|
|||
apiCmd.Flags().StringArrayP("header", "H", nil, "Add an HTTP request header (key:value)")
|
||||
apiCmd.Flags().Bool("silent", false, "Do not print the response body")
|
||||
apiCmd.Flags().BoolP("include", "i", false, "Include HTTP response headers in the output")
|
||||
apiCmd.Flags().Bool("paginate", false, "Follow rel=\"next\" Link headers and concatenate JSON array pages (gh-compatible)")
|
||||
addJSONFlags(apiCmd, "Output the response as JSON")
|
||||
}
|
||||
|
||||
// parseLinkHeaderNext extracts the URL with rel="next" from an RFC 5988
|
||||
// Link header. Returns "" if not present.
|
||||
func parseLinkHeaderNext(link string) string {
|
||||
for _, segment := range strings.Split(link, ",") {
|
||||
segment = strings.TrimSpace(segment)
|
||||
if !strings.Contains(segment, `rel="next"`) {
|
||||
continue
|
||||
}
|
||||
start := strings.Index(segment, "<")
|
||||
end := strings.Index(segment, ">")
|
||||
if start >= 0 && end > start {
|
||||
return segment[start+1 : end]
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// concatPaginatedJSON parses each body as a JSON array and merges them.
|
||||
// Errors if any body isn't an array (e.g. an object response means the
|
||||
// endpoint isn't paginated and --paginate doesn't apply).
|
||||
func concatPaginatedJSON(bodies [][]byte) ([]byte, error) {
|
||||
merged := make([]json.RawMessage, 0)
|
||||
for i, b := range bodies {
|
||||
var page []json.RawMessage
|
||||
if err := json.Unmarshal(b, &page); err != nil {
|
||||
return nil, fmt.Errorf("--paginate requires JSON array responses; page %d wasn't an array: %w", i+1, err)
|
||||
}
|
||||
merged = append(merged, page...)
|
||||
}
|
||||
return json.Marshal(merged)
|
||||
}
|
||||
|
||||
func runAPI(cmd *cobra.Command, args []string) error {
|
||||
|
|
@ -72,7 +120,7 @@ func runAPI(cmd *cobra.Command, args []string) error {
|
|||
|
||||
detectedHost := getDetectedHost()
|
||||
|
||||
host, err := cfg.GetHost(hostname, detectedHost)
|
||||
host, err := cfg.GetHost(hostname, detectedHost, getCwd())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -139,15 +187,28 @@ func runAPI(cmd *cobra.Command, args []string) error {
|
|||
body = bytes.NewReader(bodyBytes)
|
||||
}
|
||||
|
||||
// Build URL
|
||||
baseURL := "https://" + host.Hostname + "/api/v1"
|
||||
if !strings.HasPrefix(endpoint, "/") {
|
||||
endpoint = "/" + endpoint
|
||||
// Build the request URL safely. Naive concatenation lets endpoints like
|
||||
// "/../admin/users" escape the /api/v1 base via Go's URL normalization
|
||||
// of `..` segments — silently sending authenticated traffic to non-API
|
||||
// paths. Parse the endpoint, reject `..`, then JoinPath onto the base.
|
||||
endpointURL, err := url.Parse(endpoint)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid endpoint %q: %w", endpoint, err)
|
||||
}
|
||||
url := baseURL + endpoint
|
||||
if endpointURL.Scheme != "" || endpointURL.Host != "" {
|
||||
return fmt.Errorf("endpoint must be a path, not a full URL: %s", endpoint)
|
||||
}
|
||||
for _, seg := range strings.Split(strings.Trim(endpointURL.Path, "/"), "/") {
|
||||
if seg == ".." {
|
||||
return fmt.Errorf("endpoint contains forbidden '..' segment: %s", endpoint)
|
||||
}
|
||||
}
|
||||
base := &url.URL{Scheme: "https", Host: host.Hostname, Path: "/api/v1"}
|
||||
final := base.JoinPath(endpointURL.Path)
|
||||
final.RawQuery = endpointURL.RawQuery
|
||||
|
||||
// Create HTTP request
|
||||
req, err := http.NewRequest(method, url, body)
|
||||
req, err := http.NewRequest(method, final.String(), body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
|
@ -170,59 +231,143 @@ func runAPI(cmd *cobra.Command, args []string) error {
|
|||
req.Header.Set(strings.TrimSpace(key), strings.TrimSpace(value))
|
||||
}
|
||||
|
||||
// Execute request
|
||||
httpClient := &http.Client{}
|
||||
resp, err := httpClient.Do(req)
|
||||
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 fmt.Errorf("failed to perform request: %w", err)
|
||||
return nil, nil, 0, "", "", fmt.Errorf("failed to perform request: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
// Print response headers if requested
|
||||
if include {
|
||||
fmt.Fprintf(os.Stdout, "%s %s\n", resp.Proto, resp.Status)
|
||||
for key, values := range resp.Header {
|
||||
for _, v := range values {
|
||||
fmt.Fprintf(os.Stdout, "%s: %s\n", key, v)
|
||||
}
|
||||
}
|
||||
fmt.Fprintln(os.Stdout)
|
||||
}
|
||||
|
||||
// Read response body
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
body, err = io.ReadAll(io.LimitReader(resp.Body, maxAPIResponseBytes+1))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read response body: %w", err)
|
||||
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
|
||||
}
|
||||
|
||||
// Handle non-2xx status codes
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
respBody, respHeader, statusCode, proto, status, err := doOnce(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if include {
|
||||
fmt.Fprintf(ios.Out, "%s %s\n", proto, status)
|
||||
for key, values := range respHeader {
|
||||
for _, v := range values {
|
||||
fmt.Fprintf(ios.Out, "%s: %s\n", key, v)
|
||||
}
|
||||
}
|
||||
fmt.Fprintln(ios.Out)
|
||||
}
|
||||
|
||||
if statusCode < 200 || statusCode >= 300 {
|
||||
if !silent {
|
||||
fmt.Fprint(os.Stderr, string(respBody))
|
||||
fmt.Fprint(ios.ErrOut, string(respBody))
|
||||
if len(respBody) > 0 && respBody[len(respBody)-1] != '\n' {
|
||||
fmt.Fprintln(os.Stderr)
|
||||
fmt.Fprintln(ios.ErrOut)
|
||||
}
|
||||
}
|
||||
os.Exit(1)
|
||||
return fmt.Errorf("API request failed with status %d", statusCode)
|
||||
}
|
||||
|
||||
// Follow `Link: rel="next"` headers when --paginate is set, accumulating
|
||||
// each page's body. After the loop, concatPaginatedJSON merges them into
|
||||
// a single JSON array. Endpoint must be paginatable (returns an array).
|
||||
if paginate {
|
||||
bodies := [][]byte{respBody}
|
||||
nextURL := parseLinkHeaderNext(respHeader.Get("Link"))
|
||||
for nextURL != "" {
|
||||
// Forgejo emits same-origin next-links in practice, but a buggy
|
||||
// or hostile upstream could redirect us to a foreign host — at
|
||||
// which point we'd leak the bearer token. Validate origin (and
|
||||
// resolve relative URLs against `base`) before forwarding auth.
|
||||
parsedNext, err := url.Parse(nextURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid Link rel=\"next\" URL %q: %w", nextURL, err)
|
||||
}
|
||||
if !parsedNext.IsAbs() {
|
||||
parsedNext = base.ResolveReference(parsedNext)
|
||||
}
|
||||
if parsedNext.Scheme != "https" || parsedNext.Host != host.Hostname {
|
||||
return fmt.Errorf("paginated next URL %s is not same-origin as https://%s; refusing to forward credentials", parsedNext.String(), host.Hostname)
|
||||
}
|
||||
|
||||
nextReq, err := http.NewRequest(http.MethodGet, parsedNext.String(), nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to build paginated request: %w", err)
|
||||
}
|
||||
if host.Token != "" {
|
||||
nextReq.Header.Set("Authorization", "token "+host.Token)
|
||||
}
|
||||
nextReq.Header.Set("Accept", "application/json")
|
||||
for _, h := range headers {
|
||||
key, value, found := strings.Cut(h, ":")
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
nextReq.Header.Set(strings.TrimSpace(key), strings.TrimSpace(value))
|
||||
}
|
||||
pageBody, pageHeader, pageStatus, _, _, err := doOnce(nextReq)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if pageStatus < 200 || pageStatus >= 300 {
|
||||
return fmt.Errorf("paginated request to %s failed with status %d", parsedNext.String(), pageStatus)
|
||||
}
|
||||
bodies = append(bodies, pageBody)
|
||||
nextURL = parseLinkHeaderNext(pageHeader.Get("Link"))
|
||||
}
|
||||
merged, err := concatPaginatedJSON(bodies)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
respBody = merged
|
||||
}
|
||||
|
||||
if silent || len(respBody) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Pretty-print JSON, or output raw if not JSON
|
||||
contentType := resp.Header.Get("Content-Type")
|
||||
if strings.Contains(contentType, "json") || json.Valid(respBody) {
|
||||
contentType := respHeader.Get("Content-Type")
|
||||
isJSON := strings.Contains(contentType, "json") || json.Valid(respBody)
|
||||
|
||||
// If the user asked for JSON projection or jq filtering, route through
|
||||
// the shared JSON output helpers so the API command is consistent with
|
||||
// `fj repo list`, `fj pr list`, etc.
|
||||
if wantJSON(cmd) {
|
||||
if !isJSON {
|
||||
return fmt.Errorf("--json/--json-fields/--jq requires a JSON response, but the server returned %s", contentType)
|
||||
}
|
||||
var parsed any
|
||||
if err := json.Unmarshal(respBody, &parsed); err != nil {
|
||||
return fmt.Errorf("response is not valid JSON: %w", err)
|
||||
}
|
||||
return outputJSON(cmd, parsed)
|
||||
}
|
||||
|
||||
// Pretty-print JSON by default, otherwise emit raw bytes.
|
||||
if isJSON {
|
||||
var parsed any
|
||||
if err := json.Unmarshal(respBody, &parsed); err == nil {
|
||||
enc := json.NewEncoder(os.Stdout)
|
||||
enc.SetIndent("", " ")
|
||||
return enc.Encode(parsed)
|
||||
return writeJSON(parsed)
|
||||
}
|
||||
}
|
||||
|
||||
// Raw output for non-JSON responses
|
||||
_, err = os.Stdout.Write(respBody)
|
||||
_, err = ios.Out.Write(respBody)
|
||||
return err
|
||||
}
|
||||
|
||||
|
|
|
|||
50
cmd/auth.go
50
cmd/auth.go
|
|
@ -7,8 +7,8 @@ import (
|
|||
"strings"
|
||||
"syscall"
|
||||
|
||||
"forgejo.zerova.net/sid/fgj-sid/internal/api"
|
||||
"forgejo.zerova.net/sid/fgj-sid/internal/config"
|
||||
"forgejo.zerova.net/public/fj/internal/api"
|
||||
"forgejo.zerova.net/public/fj/internal/config"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/viper"
|
||||
"golang.org/x/term"
|
||||
|
|
@ -16,7 +16,7 @@ import (
|
|||
|
||||
var authCmd = &cobra.Command{
|
||||
Use: "auth",
|
||||
Short: "Authenticate fgj with a Forgejo instance",
|
||||
Short: "Authenticate fj with a Forgejo instance",
|
||||
Long: "Manage authentication state for Forgejo instances.",
|
||||
}
|
||||
|
||||
|
|
@ -55,20 +55,29 @@ func init() {
|
|||
authCmd.AddCommand(authLogoutCmd)
|
||||
authCmd.AddCommand(authTokenCmd)
|
||||
|
||||
authLoginCmd.Flags().String("hostname", "", "Forgejo instance hostname (e.g., codeberg.org)")
|
||||
authLoginCmd.Flags().StringP("token", "t", "", "Personal access token")
|
||||
authLogoutCmd.Flags().String("hostname", "", "Forgejo instance hostname (e.g., codeberg.org)")
|
||||
authTokenCmd.Flags().String("hostname", "", "Forgejo instance hostname (e.g., codeberg.org)")
|
||||
// --hostname is a persistent flag on rootCmd (cmd/root.go). Don't
|
||||
// re-declare it on auth subcommands — local flags shadow the persistent
|
||||
// one, so `fj --hostname=X auth login` and `fj auth login --hostname=X`
|
||||
// went through different code paths (viper vs. local).
|
||||
authLoginCmd.Flags().StringP("token", "t", "", "Personal access token (DEPRECATED: visible in `ps auxe`; pipe via stdin instead)")
|
||||
}
|
||||
|
||||
func runAuthLogin(cmd *cobra.Command, args []string) error {
|
||||
hostname, _ := cmd.Flags().GetString("hostname")
|
||||
token, _ := cmd.Flags().GetString("token")
|
||||
|
||||
// Tokens passed via --token end up on the process command line and
|
||||
// therefore in `ps auxe` and shell history. Warn loudly so users notice.
|
||||
// (Don't refuse the flag — too disruptive for scripts that already use it.)
|
||||
if cmd.Flags().Changed("token") {
|
||||
fmt.Fprintln(ios.ErrOut, "warning: --token puts the token on the command line (visible in `ps auxe` and shell history)")
|
||||
fmt.Fprintln(ios.ErrOut, " prefer omitting --token and pasting at the prompt, or piping via stdin.")
|
||||
}
|
||||
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
|
||||
if hostname == "" {
|
||||
fmt.Print("Forgejo instance hostname (default: codeberg.org): ")
|
||||
fmt.Fprint(ios.ErrOut, "Forgejo instance hostname (default: codeberg.org): ")
|
||||
input, _ := reader.ReadString('\n')
|
||||
hostname = strings.TrimSpace(input)
|
||||
if hostname == "" {
|
||||
|
|
@ -77,12 +86,12 @@ func runAuthLogin(cmd *cobra.Command, args []string) error {
|
|||
}
|
||||
|
||||
if token == "" {
|
||||
fmt.Print("Personal access token: ")
|
||||
fmt.Fprint(ios.ErrOut, "Personal access token: ")
|
||||
tokenBytes, err := term.ReadPassword(int(syscall.Stdin))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read token: %w", err)
|
||||
}
|
||||
fmt.Println()
|
||||
fmt.Fprintln(ios.ErrOut)
|
||||
token = strings.TrimSpace(string(tokenBytes))
|
||||
}
|
||||
|
||||
|
|
@ -95,7 +104,9 @@ 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)
|
||||
}
|
||||
|
|
@ -116,7 +127,8 @@ func runAuthLogin(cmd *cobra.Command, args []string) error {
|
|||
return fmt.Errorf("failed to save config: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("✓ Authenticated as %s on %s\n", user.UserName, hostname)
|
||||
cs := ios.ColorScheme()
|
||||
fmt.Fprintf(ios.Out, "%s Authenticated as %s on %s\n", cs.SuccessIcon(), user.UserName, hostname)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
@ -128,14 +140,15 @@ func runAuthStatus(cmd *cobra.Command, args []string) error {
|
|||
}
|
||||
|
||||
if len(cfg.Hosts) == 0 {
|
||||
fmt.Println("Not authenticated with any Forgejo instances")
|
||||
fmt.Println("Run 'fgj auth login' to authenticate")
|
||||
fmt.Fprintln(ios.Out, "Not authenticated with any Forgejo instances")
|
||||
fmt.Fprintln(ios.Out, "Run 'fj auth login' to authenticate")
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Println("Authenticated instances:")
|
||||
fmt.Fprintln(ios.Out, "Authenticated instances:")
|
||||
for hostname, host := range cfg.Hosts {
|
||||
fmt.Printf(" • %s (user: %s)\n", hostname, host.User)
|
||||
cs := ios.ColorScheme()
|
||||
fmt.Fprintf(ios.Out, " %s %s (user: %s)\n", cs.SuccessIcon(), hostname, host.User)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
|
@ -158,7 +171,8 @@ func runAuthLogout(cmd *cobra.Command, args []string) error {
|
|||
return fmt.Errorf("failed to save config: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("✓ Logged out from %s\n", resolved)
|
||||
cs := ios.ColorScheme()
|
||||
fmt.Fprintf(ios.Out, "%s Logged out from %s\n", cs.SuccessIcon(), resolved)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
@ -174,7 +188,7 @@ func runAuthToken(cmd *cobra.Command, args []string) error {
|
|||
return err
|
||||
}
|
||||
|
||||
fmt.Println(cfg.Hosts[resolved].Token)
|
||||
fmt.Fprintln(ios.Out, cfg.Hosts[resolved].Token)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
@ -183,7 +197,7 @@ func resolveAuthHostname(cfg *config.Config, hostname string) (string, error) {
|
|||
hostname = viper.GetString("hostname")
|
||||
}
|
||||
if hostname == "" {
|
||||
hostname = os.Getenv("FGJ_HOST")
|
||||
hostname = config.EnvWithFallback("FJ_HOST", "FGJ_HOST")
|
||||
}
|
||||
if hostname == "" {
|
||||
hostname = getDetectedHost()
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import (
|
|||
var completionCmd = &cobra.Command{
|
||||
Use: "completion [bash|zsh|fish|powershell]",
|
||||
Short: "Generate shell completion scripts",
|
||||
Long: "Generate shell completion scripts for fgj.",
|
||||
Long: "Generate shell completion scripts for fj.",
|
||||
Args: cobra.MatchAll(cobra.ExactArgs(1), cobra.OnlyValidArgs),
|
||||
ValidArgs: []string{"bash", "zsh", "fish", "powershell"},
|
||||
DisableFlagsInUseLine: true,
|
||||
|
|
|
|||
|
|
@ -3,9 +3,9 @@ package cmd
|
|||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"forgejo.zerova.net/sid/fgj-sid/internal/api"
|
||||
"forgejo.zerova.net/public/fj/internal/api"
|
||||
)
|
||||
|
||||
// Error codes for structured error output.
|
||||
|
|
@ -24,9 +24,15 @@ type CLIError struct {
|
|||
Message string `json:"message"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
Status int `json:"status,omitempty"`
|
||||
// Hint is a separate field so JSON consumers get clean structure and
|
||||
// the human renderer can append "Hint: ..." without polluting Message.
|
||||
Hint string `json:"hint,omitempty"`
|
||||
}
|
||||
|
||||
func (e *CLIError) Error() string {
|
||||
if e.Hint != "" {
|
||||
return e.Message + "\nHint: " + e.Hint
|
||||
}
|
||||
return e.Message
|
||||
}
|
||||
|
||||
|
|
@ -40,8 +46,60 @@ func NewAPIError(status int, message string) *CLIError {
|
|||
return &CLIError{Code: ErrAPIError, Message: message, Status: status}
|
||||
}
|
||||
|
||||
// writeJSONError writes a structured JSON error to stderr.
|
||||
// It attempts to extract structured info from known error types.
|
||||
// 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 is exported for use from main.go.
|
||||
func WriteJSONError(err error) {
|
||||
|
|
@ -50,7 +108,9 @@ func WriteJSONError(err error) {
|
|||
Message: err.Error(),
|
||||
}
|
||||
|
||||
// Try to extract structured info from the error chain.
|
||||
// Try to extract structured info from the error chain. Prefer CLIError
|
||||
// (which carries Hint cleanly) over APIError so a wrapped CLIError
|
||||
// keeps its structured fields.
|
||||
var apiErr *api.APIError
|
||||
var cErr *CLIError
|
||||
|
||||
|
|
@ -65,12 +125,13 @@ func WriteJSONError(err error) {
|
|||
cliErr.Code = ErrAuthRequired
|
||||
case apiErr.StatusCode == 404:
|
||||
cliErr.Code = ErrNotFound
|
||||
default:
|
||||
cliErr.Code = ErrAPIError
|
||||
}
|
||||
}
|
||||
|
||||
enc := json.NewEncoder(os.Stderr)
|
||||
enc := json.NewEncoder(ios.ErrOut)
|
||||
enc.SetIndent("", " ")
|
||||
_ = enc.Encode(cliErr)
|
||||
}
|
||||
|
||||
// Compile-time check that CLIError satisfies the standard error interface.
|
||||
var _ error = (*CLIError)(nil)
|
||||
|
|
|
|||
5
cmd/ios_init.go
Normal file
5
cmd/ios_init.go
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
package cmd
|
||||
|
||||
import "forgejo.zerova.net/public/fj/internal/iostreams"
|
||||
|
||||
var ios = iostreams.New()
|
||||
403
cmd/issue.go
403
cmd/issue.go
|
|
@ -2,14 +2,13 @@ package cmd
|
|||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"net/http"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
|
||||
"code.gitea.io/sdk/gitea"
|
||||
"forgejo.zerova.net/sid/fgj-sid/internal/api"
|
||||
"forgejo.zerova.net/sid/fgj-sid/internal/config"
|
||||
"forgejo.zerova.net/public/fj/internal/api"
|
||||
"forgejo.zerova.net/public/fj/internal/config"
|
||||
"forgejo.zerova.net/public/fj/internal/text"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
|
|
@ -23,6 +22,14 @@ 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,
|
||||
}
|
||||
|
||||
|
|
@ -30,6 +37,17 @@ 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,
|
||||
}
|
||||
|
|
@ -38,6 +56,11 @@ 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,
|
||||
}
|
||||
|
||||
|
|
@ -45,6 +68,11 @@ 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,
|
||||
}
|
||||
|
|
@ -53,14 +81,53 @@ 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,
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
|
|
@ -72,19 +139,31 @@ 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().Bool("json", false, "Output issues as JSON")
|
||||
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")
|
||||
|
||||
issueViewCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format")
|
||||
issueViewCmd.Flags().Bool("json", false, "Output issue as JSON")
|
||||
addJSONFlags(issueViewCmd, "Output issue as JSON")
|
||||
issueViewCmd.Flags().BoolP("web", "w", false, "Open in web browser")
|
||||
|
||||
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")
|
||||
|
|
@ -92,17 +171,27 @@ 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")
|
||||
issueEditCmd.Flags().StringP("state", "s", "", "New state for the issue (open or closed)")
|
||||
issueEditCmd.Flags().StringSlice("add-label", nil, "Labels to add (can be specified multiple times)")
|
||||
issueEditCmd.Flags().StringSlice("remove-label", nil, "Labels to remove (can be specified multiple times)")
|
||||
issueEditCmd.Flags().Int64Slice("add-dependency", nil, "Issue numbers to add as dependencies (can be specified multiple times)")
|
||||
issueEditCmd.Flags().Int64Slice("remove-dependency", nil, "Issue numbers to remove as dependencies (can be specified multiple times)")
|
||||
}
|
||||
|
||||
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 {
|
||||
|
|
@ -114,7 +203,7 @@ func runIssueList(cmd *cobra.Command, args []string) error {
|
|||
return err
|
||||
}
|
||||
|
||||
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost())
|
||||
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -131,9 +220,27 @@ func runIssueList(cmd *cobra.Command, args []string) error {
|
|||
return fmt.Errorf("invalid state: %s", state)
|
||||
}
|
||||
|
||||
issues, _, err := client.ListRepoIssues(owner, name, gitea.ListIssueOption{
|
||||
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
|
||||
})
|
||||
ios.StopSpinner()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list issues: %w", err)
|
||||
}
|
||||
|
|
@ -144,29 +251,30 @@ func runIssueList(cmd *cobra.Command, args []string) error {
|
|||
nonPRIssues = append(nonPRIssues, issue)
|
||||
}
|
||||
}
|
||||
if limit > 0 && len(nonPRIssues) > limit {
|
||||
nonPRIssues = nonPRIssues[:limit]
|
||||
}
|
||||
|
||||
if jsonOutput, _ := cmd.Flags().GetBool("json"); jsonOutput {
|
||||
return writeJSON(nonPRIssues)
|
||||
if wantJSON(cmd) {
|
||||
return outputJSON(cmd, nonPRIssues)
|
||||
}
|
||||
|
||||
if len(nonPRIssues) == 0 {
|
||||
fmt.Printf("No %s issues in %s/%s\n", state, owner, name)
|
||||
fmt.Fprintf(ios.Out, "No %s issues in %s/%s\n", state, owner, name)
|
||||
return nil
|
||||
}
|
||||
|
||||
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
||||
_, _ = fmt.Fprintf(w, "NUMBER\tTITLE\tSTATE\n")
|
||||
tp := ios.NewTablePrinter()
|
||||
tp.AddHeader("NUMBER", "TITLE", "STATE")
|
||||
for _, issue := range nonPRIssues {
|
||||
_, _ = fmt.Fprintf(w, "#%d\t%s\t%s\n", issue.Index, issue.Title, issue.State)
|
||||
tp.AddRow(fmt.Sprintf("#%d", issue.Index), issue.Title, string(issue.State))
|
||||
}
|
||||
_ = w.Flush()
|
||||
|
||||
return nil
|
||||
return tp.Render()
|
||||
}
|
||||
|
||||
func runIssueView(cmd *cobra.Command, args []string) error {
|
||||
repo, _ := cmd.Flags().GetString("repo")
|
||||
issueNumber, err := strconv.ParseInt(args[0], 10, 64)
|
||||
issueNumber, err := parseIssueArg(args[0])
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid issue number: %w", err)
|
||||
}
|
||||
|
|
@ -181,13 +289,15 @@ func runIssueView(cmd *cobra.Command, args []string) error {
|
|||
return err
|
||||
}
|
||||
|
||||
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost())
|
||||
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd())
|
||||
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)
|
||||
}
|
||||
|
||||
|
|
@ -196,8 +306,13 @@ func runIssueView(cmd *cobra.Command, args []string) error {
|
|||
if err != nil {
|
||||
comments = nil
|
||||
}
|
||||
ios.StopSpinner()
|
||||
|
||||
if jsonOutput, _ := cmd.Flags().GetBool("json"); jsonOutput {
|
||||
if web, _ := cmd.Flags().GetBool("web"); web {
|
||||
return ios.OpenInBrowser(issue.HTMLURL)
|
||||
}
|
||||
|
||||
if wantJSON(cmd) {
|
||||
payload := struct {
|
||||
Issue *gitea.Issue `json:"issue"`
|
||||
Comments []*gitea.Comment `json:"comments,omitempty"`
|
||||
|
|
@ -205,26 +320,34 @@ func runIssueView(cmd *cobra.Command, args []string) error {
|
|||
Issue: issue,
|
||||
Comments: comments,
|
||||
}
|
||||
return writeJSON(payload)
|
||||
return outputJSON(cmd, payload)
|
||||
}
|
||||
|
||||
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 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))
|
||||
if issue.Body != "" {
|
||||
fmt.Printf("\n%s\n", issue.Body)
|
||||
fmt.Fprintf(ios.Out, "\n%s\n", issue.Body)
|
||||
}
|
||||
|
||||
if len(comments) > 0 {
|
||||
fmt.Printf("\nComments (%d):\n", len(comments))
|
||||
fmt.Fprintf(ios.Out, "\nComments (%d):\n", len(comments))
|
||||
for _, comment := range comments {
|
||||
fmt.Printf("\n---\n%s (@%s) - %s\n%s\n",
|
||||
fmt.Fprintf(ios.Out, "\n---\n%s (@%s) - %s\n%s\n",
|
||||
comment.Poster.FullName,
|
||||
comment.Poster.UserName,
|
||||
comment.Created.Format("2006-01-02 15:04:05"),
|
||||
text.FormatDate(comment.Created, isTTY),
|
||||
comment.Body)
|
||||
}
|
||||
}
|
||||
|
|
@ -237,22 +360,36 @@ 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)")
|
||||
}
|
||||
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost())
|
||||
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -265,17 +402,56 @@ 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,
|
||||
})
|
||||
ios.StopSpinner()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create issue: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Issue created: #%d\n", issue.Index)
|
||||
fmt.Printf("View at: %s\n", issue.HTMLURL)
|
||||
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)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
@ -283,7 +459,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 := strconv.ParseInt(args[0], 10, 64)
|
||||
issueNumber, err := parseIssueArg(args[0])
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid issue number: %w", err)
|
||||
}
|
||||
|
|
@ -302,20 +478,23 @@ func runIssueComment(cmd *cobra.Command, args []string) error {
|
|||
return err
|
||||
}
|
||||
|
||||
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost())
|
||||
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd())
|
||||
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)
|
||||
}
|
||||
|
||||
fmt.Printf("Comment added to issue #%d\n", issueNumber)
|
||||
fmt.Printf("View at: %s\n", comment.HTMLURL)
|
||||
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)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
@ -323,7 +502,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 := strconv.ParseInt(args[0], 10, 64)
|
||||
issueNumber, err := parseIssueArg(args[0])
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid issue number: %w", err)
|
||||
}
|
||||
|
|
@ -338,29 +517,34 @@ func runIssueClose(cmd *cobra.Command, args []string) error {
|
|||
return err
|
||||
}
|
||||
|
||||
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost())
|
||||
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd())
|
||||
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)
|
||||
}
|
||||
|
||||
fmt.Printf("Issue #%d closed\n", issueNumber)
|
||||
cs := ios.ColorScheme()
|
||||
fmt.Fprintf(ios.Out, "%s Issue #%d closed\n", cs.SuccessIcon(), issueNumber)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
@ -372,8 +556,10 @@ func runIssueEdit(cmd *cobra.Command, args []string) error {
|
|||
stateStr, _ := cmd.Flags().GetString("state")
|
||||
addLabelNames, _ := cmd.Flags().GetStringSlice("add-label")
|
||||
removeLabelNames, _ := cmd.Flags().GetStringSlice("remove-label")
|
||||
addDeps, _ := cmd.Flags().GetInt64Slice("add-dependency")
|
||||
removeDeps, _ := cmd.Flags().GetInt64Slice("remove-dependency")
|
||||
|
||||
issueNumber, err := strconv.ParseInt(args[0], 10, 64)
|
||||
issueNumber, err := parseIssueArg(args[0])
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid issue number: %w", err)
|
||||
}
|
||||
|
|
@ -383,8 +569,8 @@ func runIssueEdit(cmd *cobra.Command, args []string) error {
|
|||
return err
|
||||
}
|
||||
|
||||
if title == "" && body == "" && stateStr == "" && len(addLabelNames) == 0 && len(removeLabelNames) == 0 {
|
||||
return fmt.Errorf("at least one of --title, --body, --state, --add-label, or --remove-label must be provided")
|
||||
if title == "" && body == "" && stateStr == "" && len(addLabelNames) == 0 && len(removeLabelNames) == 0 && len(addDeps) == 0 && len(removeDeps) == 0 {
|
||||
return fmt.Errorf("at least one of --title, --body, --state, --add-label, --remove-label, --add-dependency, or --remove-dependency must be provided")
|
||||
}
|
||||
|
||||
cfg, err := config.Load()
|
||||
|
|
@ -392,7 +578,7 @@ func runIssueEdit(cmd *cobra.Command, args []string) error {
|
|||
return err
|
||||
}
|
||||
|
||||
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost())
|
||||
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -420,9 +606,12 @@ 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -430,12 +619,14 @@ 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -443,17 +634,135 @@ 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("Issue #%d updated\n", issueNumber)
|
||||
ios.StopSpinner()
|
||||
|
||||
for _, depNumber := range addDeps {
|
||||
depIssue, _, err := client.GetIssue(owner, name, depNumber)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get issue #%d: %w", depNumber, err)
|
||||
}
|
||||
depBody := map[string]int64{"id": depIssue.ID}
|
||||
path := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/dependencies", owner, name, issueNumber)
|
||||
_, err = client.DoJSON(http.MethodPost, path, depBody, nil)
|
||||
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)
|
||||
}
|
||||
|
||||
for _, depNumber := range removeDeps {
|
||||
depIssue, _, err := client.GetIssue(owner, name, depNumber)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get issue #%d: %w", depNumber, err)
|
||||
}
|
||||
depBody := map[string]int64{"id": depIssue.ID}
|
||||
path := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/dependencies", owner, name, issueNumber)
|
||||
_, err = client.DoJSON(http.MethodDelete, path, depBody, nil)
|
||||
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)
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
148
cmd/json.go
148
cmd/json.go
|
|
@ -2,11 +2,155 @@ package cmd
|
|||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/itchyny/gojq"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// 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(os.Stdout)
|
||||
enc := json.NewEncoder(ios.Out)
|
||||
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
|
||||
}
|
||||
|
|
|
|||
294
cmd/label.go
Normal file
294
cmd/label.go
Normal file
|
|
@ -0,0 +1,294 @@
|
|||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"code.gitea.io/sdk/gitea"
|
||||
"forgejo.zerova.net/public/fj/internal/api"
|
||||
"forgejo.zerova.net/public/fj/internal/config"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var labelCmd = &cobra.Command{
|
||||
Use: "label",
|
||||
Short: "Manage labels",
|
||||
Long: "List, create, edit, and delete repository labels.",
|
||||
}
|
||||
|
||||
var labelListCmd = &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List labels for a repository",
|
||||
Long: "List all labels defined in a repository.",
|
||||
Example: ` # List labels for the current repository
|
||||
fj label list
|
||||
|
||||
# List labels for a specific repository
|
||||
fj label list -R owner/repo
|
||||
|
||||
# Output as JSON
|
||||
fj label list --json`,
|
||||
RunE: runLabelList,
|
||||
}
|
||||
|
||||
var labelCreateCmd = &cobra.Command{
|
||||
Use: "create <name>",
|
||||
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
|
||||
|
||||
# Create a label with color and description
|
||||
fj 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`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: runLabelCreate,
|
||||
}
|
||||
|
||||
var labelEditCmd = &cobra.Command{
|
||||
Use: "edit <name>",
|
||||
Short: "Edit a label",
|
||||
Long: "Edit an existing label in a repository.",
|
||||
Example: ` # Rename a label
|
||||
fj label edit bug --name bugfix
|
||||
|
||||
# Change the color of a label
|
||||
fj label edit bug -c 00ff00
|
||||
|
||||
# Update description
|
||||
fj label edit bug -d "Something is broken"`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: runLabelEdit,
|
||||
}
|
||||
|
||||
var labelDeleteCmd = &cobra.Command{
|
||||
Use: "delete <name>",
|
||||
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
|
||||
|
||||
# Delete a label from a specific repository
|
||||
fj label delete bug -R owner/repo`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: runLabelDelete,
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(labelCmd)
|
||||
labelCmd.AddCommand(labelListCmd)
|
||||
labelCmd.AddCommand(labelCreateCmd)
|
||||
labelCmd.AddCommand(labelEditCmd)
|
||||
labelCmd.AddCommand(labelDeleteCmd)
|
||||
|
||||
labelListCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format")
|
||||
addJSONFlags(labelListCmd, "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")
|
||||
|
||||
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")
|
||||
|
||||
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) {
|
||||
repo, _ := cmd.Flags().GetString("repo")
|
||||
owner, name, err := parseRepo(repo)
|
||||
if err != nil {
|
||||
return nil, "", "", err
|
||||
}
|
||||
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
return nil, "", "", err
|
||||
}
|
||||
|
||||
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd())
|
||||
if err != nil {
|
||||
return nil, "", "", err
|
||||
}
|
||||
|
||||
return client, owner, name, nil
|
||||
}
|
||||
|
||||
// findLabelByName lists all repo labels and returns the one matching the given name.
|
||||
func findLabelByName(client *api.Client, owner, repo, labelName string) (*gitea.Label, error) {
|
||||
labels, _, err := client.ListRepoLabels(owner, repo, gitea.ListLabelsOptions{})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list labels: %w", err)
|
||||
}
|
||||
|
||||
for _, l := range labels {
|
||||
if strings.EqualFold(l.Name, labelName) {
|
||||
return l, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("label not found: %s", labelName)
|
||||
}
|
||||
|
||||
func runLabelList(cmd *cobra.Command, args []string) error {
|
||||
client, owner, name, err := newLabelClient(cmd)
|
||||
if err != nil {
|
||||
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)
|
||||
}
|
||||
|
||||
if len(labels) == 0 {
|
||||
fmt.Fprintln(ios.Out, "No labels found")
|
||||
return nil
|
||||
}
|
||||
|
||||
tp := ios.NewTablePrinter()
|
||||
tp.AddHeader("NAME", "COLOR", "DESCRIPTION")
|
||||
for _, l := range labels {
|
||||
tp.AddRow(l.Name, l.Color, l.Description)
|
||||
}
|
||||
return tp.Render()
|
||||
}
|
||||
|
||||
func runLabelCreate(cmd *cobra.Command, args []string) error {
|
||||
labelName := args[0]
|
||||
color, _ := cmd.Flags().GetString("color")
|
||||
description, _ := cmd.Flags().GetString("description")
|
||||
|
||||
client, owner, name, err := newLabelClient(cmd)
|
||||
if err != nil {
|
||||
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)
|
||||
}
|
||||
|
||||
cs := ios.ColorScheme()
|
||||
fmt.Fprintf(ios.Out, "%s Label created: %s\n", cs.SuccessIcon(), label.Name)
|
||||
return nil
|
||||
}
|
||||
|
||||
func runLabelEdit(cmd *cobra.Command, args []string) error {
|
||||
labelName := args[0]
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
opt := gitea.EditLabelOption{}
|
||||
changed := false
|
||||
|
||||
if cmd.Flags().Changed("name") {
|
||||
n, _ := cmd.Flags().GetString("name")
|
||||
opt.Name = &n
|
||||
changed = true
|
||||
}
|
||||
if cmd.Flags().Changed("color") {
|
||||
c, _ := cmd.Flags().GetString("color")
|
||||
opt.Color = &c
|
||||
changed = true
|
||||
}
|
||||
if cmd.Flags().Changed("description") {
|
||||
d, _ := cmd.Flags().GetString("description")
|
||||
opt.Description = &d
|
||||
changed = true
|
||||
}
|
||||
|
||||
if !changed {
|
||||
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)
|
||||
}
|
||||
|
||||
cs := ios.ColorScheme()
|
||||
fmt.Fprintf(ios.Out, "%s Label updated: %s\n", cs.SuccessIcon(), 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
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
return nil
|
||||
}
|
||||
|
|
@ -12,7 +12,7 @@ import (
|
|||
var manpagesCmd = &cobra.Command{
|
||||
Use: "manpages",
|
||||
Short: "Generate manpages",
|
||||
Long: "Generate manpages for fgj commands.",
|
||||
Long: "Generate manpages for fj commands.",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
dir, _ := cmd.Flags().GetString("dir")
|
||||
if dir == "" {
|
||||
|
|
@ -29,7 +29,7 @@ var manpagesCmd = &cobra.Command{
|
|||
}
|
||||
|
||||
header := &doc.GenManHeader{
|
||||
Title: "FGJ",
|
||||
Title: "FJ",
|
||||
Section: "1",
|
||||
}
|
||||
|
||||
|
|
|
|||
487
cmd/milestone.go
Normal file
487
cmd/milestone.go
Normal file
|
|
@ -0,0 +1,487 @@
|
|||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"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"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var milestoneCmd = &cobra.Command{
|
||||
Use: "milestone",
|
||||
Short: "Manage milestones",
|
||||
Long: "Create, view, list, edit, and delete milestones.",
|
||||
}
|
||||
|
||||
var milestoneListCmd = &cobra.Command{
|
||||
Use: "list [flags]",
|
||||
Short: "List milestones",
|
||||
Long: "List milestones in a repository.",
|
||||
Example: ` # List open milestones
|
||||
fj milestone list
|
||||
|
||||
# List all milestones for a specific repo
|
||||
fj milestone list -R owner/repo --state all
|
||||
|
||||
# Output as JSON
|
||||
fj milestone list --json`,
|
||||
RunE: runMilestoneList,
|
||||
}
|
||||
|
||||
var milestoneViewCmd = &cobra.Command{
|
||||
Use: "view <title-or-id>",
|
||||
Short: "View a milestone",
|
||||
Long: "Display detailed information about a milestone.",
|
||||
Example: ` # View by ID
|
||||
fj milestone view 1
|
||||
|
||||
# View by title
|
||||
fj milestone view "v1.0"
|
||||
|
||||
# Open in browser
|
||||
fj milestone view "v1.0" --web
|
||||
|
||||
# Output as JSON
|
||||
fj milestone view "v1.0" --json`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: runMilestoneView,
|
||||
}
|
||||
|
||||
var milestoneCreateCmd = &cobra.Command{
|
||||
Use: "create <title>",
|
||||
Short: "Create a milestone",
|
||||
Long: "Create a new milestone.",
|
||||
Example: ` # Create a simple milestone
|
||||
fj milestone create "v1.0"
|
||||
|
||||
# Create with description and due date
|
||||
fj milestone create "v2.0" -d "Second release" --due 2026-06-01
|
||||
|
||||
# Output as JSON
|
||||
fj milestone create "v1.0" --json`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: runMilestoneCreate,
|
||||
}
|
||||
|
||||
var milestoneEditCmd = &cobra.Command{
|
||||
Use: "edit <title-or-id>",
|
||||
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"
|
||||
|
||||
# Close a milestone
|
||||
fj milestone edit "v1.0" --state closed
|
||||
|
||||
# Update due date
|
||||
fj milestone edit 1 --due 2026-12-31`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: runMilestoneEdit,
|
||||
}
|
||||
|
||||
var milestoneDeleteCmd = &cobra.Command{
|
||||
Use: "delete <title-or-id>",
|
||||
Short: "Delete a milestone",
|
||||
Long: "Delete an existing milestone.",
|
||||
Example: ` # Delete by title
|
||||
fj milestone delete "v1.0"
|
||||
|
||||
# Delete by ID
|
||||
fj milestone delete 1
|
||||
|
||||
# Delete without confirmation
|
||||
fj milestone delete "v1.0" -y`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: runMilestoneDelete,
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(milestoneCmd)
|
||||
milestoneCmd.AddCommand(milestoneListCmd)
|
||||
milestoneCmd.AddCommand(milestoneViewCmd)
|
||||
milestoneCmd.AddCommand(milestoneCreateCmd)
|
||||
milestoneCmd.AddCommand(milestoneEditCmd)
|
||||
milestoneCmd.AddCommand(milestoneDeleteCmd)
|
||||
|
||||
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")
|
||||
|
||||
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")
|
||||
|
||||
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")
|
||||
|
||||
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")
|
||||
|
||||
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.
|
||||
// If the argument is numeric, it fetches by ID. Otherwise, it lists
|
||||
// milestones and finds a match by title.
|
||||
func resolveMilestone(client *api.Client, owner, name, arg string) (*gitea.Milestone, error) {
|
||||
if id, err := strconv.ParseInt(arg, 10, 64); err == nil {
|
||||
ms, _, err := client.GetMilestone(owner, name, id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get milestone %d: %w", id, err)
|
||||
}
|
||||
return ms, nil
|
||||
}
|
||||
|
||||
milestones, _, err := client.ListRepoMilestones(owner, name, gitea.ListMilestoneOption{
|
||||
State: gitea.StateAll,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list milestones: %w", err)
|
||||
}
|
||||
|
||||
for _, ms := range milestones {
|
||||
if strings.EqualFold(ms.Title, arg) {
|
||||
return ms, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("milestone not found: %s", arg)
|
||||
}
|
||||
|
||||
func parseDueDate(dateStr string) (*time.Time, error) {
|
||||
t, err := time.Parse("2006-01-02", dateStr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid due date %q: expected YYYY-MM-DD format", dateStr)
|
||||
}
|
||||
return &t, nil
|
||||
}
|
||||
|
||||
func runMilestoneList(cmd *cobra.Command, args []string) error {
|
||||
repo, _ := cmd.Flags().GetString("repo")
|
||||
state, _ := cmd.Flags().GetString("state")
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
var stateType gitea.StateType
|
||||
switch strings.ToLower(state) {
|
||||
case "open":
|
||||
stateType = gitea.StateOpen
|
||||
case "closed":
|
||||
stateType = gitea.StateClosed
|
||||
case "all":
|
||||
stateType = gitea.StateAll
|
||||
default:
|
||||
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 len(milestones) == 0 {
|
||||
fmt.Fprintf(ios.Out, "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")
|
||||
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),
|
||||
)
|
||||
}
|
||||
return tp.Render()
|
||||
}
|
||||
|
||||
func runMilestoneView(cmd *cobra.Command, args []string) error {
|
||||
repo, _ := cmd.Flags().GetString("repo")
|
||||
|
||||
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 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 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)
|
||||
if ms.Description != "" {
|
||||
fmt.Fprintf(ios.Out, "Description: %s\n", ms.Description)
|
||||
}
|
||||
if ms.Deadline != nil {
|
||||
fmt.Fprintf(ios.Out, "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))
|
||||
if ms.Updated != nil {
|
||||
fmt.Fprintf(ios.Out, "Updated: %s\n", text.FormatDate(*ms.Updated, isTTY))
|
||||
}
|
||||
if ms.Closed != nil {
|
||||
fmt.Fprintf(ios.Out, "Closed: %s\n", text.FormatDate(*ms.Closed, isTTY))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func runMilestoneCreate(cmd *cobra.Command, args []string) error {
|
||||
repo, _ := cmd.Flags().GetString("repo")
|
||||
description, _ := cmd.Flags().GetString("description")
|
||||
dueStr, _ := cmd.Flags().GetString("due")
|
||||
|
||||
title := 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
|
||||
}
|
||||
|
||||
opt := gitea.CreateMilestoneOption{
|
||||
Title: title,
|
||||
Description: description,
|
||||
}
|
||||
|
||||
if dueStr != "" {
|
||||
deadline, err := parseDueDate(dueStr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
cs := ios.ColorScheme()
|
||||
fmt.Fprintf(ios.Out, "%s Milestone created: %s\n", cs.SuccessIcon(), ms.Title)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func runMilestoneEdit(cmd *cobra.Command, args []string) error {
|
||||
repo, _ := cmd.Flags().GetString("repo")
|
||||
|
||||
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 milestone...")
|
||||
ms, err := resolveMilestone(client, owner, name, args[0])
|
||||
ios.StopSpinner()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
opt := gitea.EditMilestoneOption{}
|
||||
changed := false
|
||||
|
||||
if cmd.Flags().Changed("title") {
|
||||
t, _ := cmd.Flags().GetString("title")
|
||||
opt.Title = t
|
||||
changed = true
|
||||
}
|
||||
|
||||
if cmd.Flags().Changed("description") {
|
||||
d, _ := cmd.Flags().GetString("description")
|
||||
opt.Description = &d
|
||||
changed = true
|
||||
}
|
||||
|
||||
if cmd.Flags().Changed("due") {
|
||||
dueStr, _ := cmd.Flags().GetString("due")
|
||||
deadline, err := parseDueDate(dueStr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
opt.Deadline = deadline
|
||||
changed = true
|
||||
}
|
||||
|
||||
if cmd.Flags().Changed("state") {
|
||||
stateStr, _ := cmd.Flags().GetString("state")
|
||||
switch strings.ToLower(stateStr) {
|
||||
case "open":
|
||||
s := gitea.StateOpen
|
||||
opt.State = &s
|
||||
case "closed":
|
||||
s := gitea.StateClosed
|
||||
opt.State = &s
|
||||
default:
|
||||
return fmt.Errorf("invalid state: %s (must be 'open' or 'closed')", stateStr)
|
||||
}
|
||||
changed = true
|
||||
}
|
||||
|
||||
if !changed {
|
||||
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)
|
||||
}
|
||||
|
||||
cs := ios.ColorScheme()
|
||||
fmt.Fprintf(ios.Out, "%s Milestone updated: %s\n", cs.SuccessIcon(), 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 {
|
||||
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 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)
|
||||
|
||||
return nil
|
||||
}
|
||||
43
cmd/paginate.go
Normal file
43
cmd/paginate.go
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
package cmd
|
||||
|
||||
// paginateGitea walks pages of a gitea SDK list method until the response
|
||||
// is short (last page) or we hit limit. limit=0 means unlimited.
|
||||
//
|
||||
// Forgejo/Gitea caps PageSize at 50, so naive `PageSize: limit` for limit > 50
|
||||
// silently truncated results across most `fj * list` commands. This helper
|
||||
// centralizes the loop so every list command paginates consistently.
|
||||
//
|
||||
// fetch is called with (page, pageSize) and returns the items for that page.
|
||||
// The 1-based `page` matches the gitea SDK convention.
|
||||
func paginateGitea[T any](limit int, fetch func(page, pageSize int) ([]T, error)) ([]T, error) {
|
||||
const maxPageSize = 50
|
||||
pageSize := maxPageSize
|
||||
if limit > 0 && limit < pageSize {
|
||||
pageSize = limit
|
||||
}
|
||||
|
||||
var all []T
|
||||
for page := 1; ; page++ {
|
||||
if limit > 0 && len(all) >= limit {
|
||||
break
|
||||
}
|
||||
batch, err := fetch(page, pageSize)
|
||||
if err != nil {
|
||||
return all, err
|
||||
}
|
||||
if len(batch) == 0 {
|
||||
break
|
||||
}
|
||||
all = append(all, batch...)
|
||||
// A short page (less than the requested size) is the conventional
|
||||
// "you've reached the end" signal — saves one extra round-trip.
|
||||
if len(batch) < pageSize {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if limit > 0 && len(all) > limit {
|
||||
all = all[:limit]
|
||||
}
|
||||
return all, nil
|
||||
}
|
||||
99
cmd/pr_checks.go
Normal file
99
cmd/pr_checks.go
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
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)
|
||||
}
|
||||
}
|
||||
|
|
@ -2,14 +2,11 @@ package cmd
|
|||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"forgejo.zerova.net/sid/fgj-sid/internal/api"
|
||||
"forgejo.zerova.net/sid/fgj-sid/internal/config"
|
||||
"forgejo.zerova.net/public/fj/internal/api"
|
||||
"forgejo.zerova.net/public/fj/internal/config"
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
var prDiffCmd = &cobra.Command{
|
||||
|
|
@ -17,16 +14,16 @@ var prDiffCmd = &cobra.Command{
|
|||
Short: "Show the diff for a pull request",
|
||||
Long: "Fetch and display the diff for a pull request.",
|
||||
Example: ` # View the diff for PR #123
|
||||
fgj pr diff 123
|
||||
fj pr diff 123
|
||||
|
||||
# Colorized diff output
|
||||
fgj pr diff 123 --color always
|
||||
fj pr diff 123 --color always
|
||||
|
||||
# Show only changed file names
|
||||
fgj pr diff 123 --name-only
|
||||
fj pr diff 123 --name-only
|
||||
|
||||
# Show diffstat summary
|
||||
fgj pr diff 123 --stat`,
|
||||
fj pr diff 123 --stat`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: runPRDiff,
|
||||
}
|
||||
|
|
@ -46,7 +43,7 @@ func runPRDiff(cmd *cobra.Command, args []string) error {
|
|||
nameOnly, _ := cmd.Flags().GetBool("name-only")
|
||||
stat, _ := cmd.Flags().GetBool("stat")
|
||||
|
||||
prNumber, err := strconv.ParseInt(args[0], 10, 64)
|
||||
prNumber, err := parseIssueArg(args[0])
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid pull request number: %w", err)
|
||||
}
|
||||
|
|
@ -61,7 +58,7 @@ func runPRDiff(cmd *cobra.Command, args []string) error {
|
|||
return err
|
||||
}
|
||||
|
||||
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost())
|
||||
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -69,7 +66,9 @@ 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)
|
||||
}
|
||||
|
|
@ -82,12 +81,18 @@ 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.Print(diff)
|
||||
fmt.Fprint(ios.Out, diff)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
@ -99,7 +104,7 @@ func shouldColorize(mode string) bool {
|
|||
case "never":
|
||||
return false
|
||||
default: // "auto"
|
||||
return term.IsTerminal(int(os.Stdout.Fd()))
|
||||
return ios.ColorEnabled()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -111,7 +116,7 @@ func printNameOnly(diff string) error {
|
|||
name := strings.TrimPrefix(line, "+++ b/")
|
||||
if name != "" && !seen[name] {
|
||||
seen[name] = true
|
||||
fmt.Println(name)
|
||||
fmt.Fprintln(ios.Out, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -165,10 +170,12 @@ func printDiffStat(diff string) error {
|
|||
}
|
||||
|
||||
if len(stats) == 0 {
|
||||
fmt.Println("0 files changed")
|
||||
fmt.Fprintln(ios.Out, "0 files changed")
|
||||
return nil
|
||||
}
|
||||
|
||||
cs := ios.ColorScheme()
|
||||
|
||||
// Find the longest file name for alignment
|
||||
maxNameLen := 0
|
||||
maxChanges := 0
|
||||
|
|
@ -210,44 +217,36 @@ func printDiffStat(diff string) error {
|
|||
scaledDel = 1
|
||||
}
|
||||
}
|
||||
bar = strings.Repeat("+", scaledAdd) + strings.Repeat("-", scaledDel)
|
||||
bar = cs.Green(strings.Repeat("+", scaledAdd)) + cs.Red(strings.Repeat("-", scaledDel))
|
||||
}
|
||||
|
||||
fmt.Printf(" %-*s | %4d %s\n", maxNameLen, s.name, total, bar)
|
||||
fmt.Fprintf(ios.Out, " %-*s | %4d %s\n", maxNameLen, s.name, total, bar)
|
||||
}
|
||||
|
||||
fmt.Printf(" %d file", len(stats))
|
||||
fmt.Fprintf(ios.Out, " %d file", len(stats))
|
||||
if len(stats) != 1 {
|
||||
fmt.Print("s")
|
||||
fmt.Fprint(ios.Out, "s")
|
||||
}
|
||||
fmt.Printf(" changed, %d insertion", totalAdditions)
|
||||
fmt.Fprintf(ios.Out, " changed, %d insertion", totalAdditions)
|
||||
if totalAdditions != 1 {
|
||||
fmt.Print("s")
|
||||
fmt.Fprint(ios.Out, "s")
|
||||
}
|
||||
fmt.Printf("(+), %d deletion", totalDeletions)
|
||||
fmt.Fprintf(ios.Out, "(+), %d deletion", totalDeletions)
|
||||
if totalDeletions != 1 {
|
||||
fmt.Print("s")
|
||||
fmt.Fprint(ios.Out, "s")
|
||||
}
|
||||
fmt.Println("(-)")
|
||||
fmt.Fprintln(ios.Out, "(-)")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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.
|
||||
// printColorizedDiff prints the diff with ANSI color codes using ColorScheme.
|
||||
func printColorizedDiff(diff string) error {
|
||||
cs := ios.ColorScheme()
|
||||
for _, line := range strings.Split(diff, "\n") {
|
||||
switch {
|
||||
case strings.HasPrefix(line, "diff --git "):
|
||||
fmt.Println(colorBold + line + colorReset)
|
||||
fmt.Fprintln(ios.Out, cs.Bold(line))
|
||||
case strings.HasPrefix(line, "index "),
|
||||
strings.HasPrefix(line, "--- "),
|
||||
strings.HasPrefix(line, "+++ "),
|
||||
|
|
@ -256,15 +255,15 @@ func printColorizedDiff(diff string) error {
|
|||
strings.HasPrefix(line, "similarity index"),
|
||||
strings.HasPrefix(line, "rename from"),
|
||||
strings.HasPrefix(line, "rename to"):
|
||||
fmt.Println(colorBold + line + colorReset)
|
||||
fmt.Fprintln(ios.Out, cs.Bold(line))
|
||||
case strings.HasPrefix(line, "@@"):
|
||||
fmt.Println(colorCyan + line + colorReset)
|
||||
fmt.Fprintln(ios.Out, cs.Cyan(line))
|
||||
case strings.HasPrefix(line, "+"):
|
||||
fmt.Println(colorGreen + line + colorReset)
|
||||
fmt.Fprintln(ios.Out, cs.Green(line))
|
||||
case strings.HasPrefix(line, "-"):
|
||||
fmt.Println(colorRed + line + colorReset)
|
||||
fmt.Fprintln(ios.Out, cs.Red(line))
|
||||
default:
|
||||
fmt.Println(line)
|
||||
fmt.Fprintln(ios.Out, line)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
|
|
|
|||
|
|
@ -4,11 +4,10 @@ import (
|
|||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strconv"
|
||||
|
||||
"code.gitea.io/sdk/gitea"
|
||||
"forgejo.zerova.net/sid/fgj-sid/internal/api"
|
||||
"forgejo.zerova.net/sid/fgj-sid/internal/config"
|
||||
"forgejo.zerova.net/public/fj/internal/api"
|
||||
"forgejo.zerova.net/public/fj/internal/config"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
|
|
@ -17,16 +16,16 @@ var prCommentCmd = &cobra.Command{
|
|||
Short: "Add a comment to a pull request",
|
||||
Long: "Add a comment to an existing pull request.",
|
||||
Example: ` # Add a comment
|
||||
fgj pr comment 123 -b "Looks good!"
|
||||
fj pr comment 123 -b "Looks good!"
|
||||
|
||||
# Comment from a file
|
||||
fgj pr comment 123 --body-file review-notes.md
|
||||
fj pr comment 123 --body-file review-notes.md
|
||||
|
||||
# Comment from stdin
|
||||
echo "LGTM" | fgj pr comment 123 --body-file -
|
||||
echo "LGTM" | fj pr comment 123 --body-file -
|
||||
|
||||
# Output as JSON
|
||||
fgj pr comment 123 -b "Nice work" --json`,
|
||||
fj pr comment 123 -b "Nice work" --json`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: runPRComment,
|
||||
}
|
||||
|
|
@ -36,16 +35,16 @@ var prReviewCmd = &cobra.Command{
|
|||
Short: "Submit a review on a pull request",
|
||||
Long: "Submit a review on a pull request. Exactly one of --approve, --request-changes, or --comment must be specified.",
|
||||
Example: ` # Approve a PR
|
||||
fgj pr review 123 --approve -b "LGTM"
|
||||
fj pr review 123 --approve -b "LGTM"
|
||||
|
||||
# Request changes
|
||||
fgj pr review 123 --request-changes -b "Please fix the error handling"
|
||||
fj pr review 123 --request-changes -b "Please fix the error handling"
|
||||
|
||||
# Submit a review comment
|
||||
fgj pr review 123 --comment -b "Some observations"
|
||||
fj pr review 123 --comment -b "Some observations"
|
||||
|
||||
# Request changes with body from file
|
||||
fgj pr review 123 --request-changes --body-file feedback.md`,
|
||||
fj pr review 123 --request-changes --body-file feedback.md`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: runPRReview,
|
||||
}
|
||||
|
|
@ -57,7 +56,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)")
|
||||
prCommentCmd.Flags().Bool("json", false, "Output created comment as JSON")
|
||||
addJSONFlags(prCommentCmd, "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")
|
||||
|
|
@ -65,7 +64,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)")
|
||||
prReviewCmd.Flags().Bool("json", false, "Output created review as JSON")
|
||||
addJSONFlags(prReviewCmd, "Output created review as JSON")
|
||||
}
|
||||
|
||||
// readBody resolves the body text from --body and --body-file flags.
|
||||
|
|
@ -98,7 +97,7 @@ func readBody(cmd *cobra.Command) (string, error) {
|
|||
|
||||
func runPRComment(cmd *cobra.Command, args []string) error {
|
||||
repo, _ := cmd.Flags().GetString("repo")
|
||||
prNumber, err := strconv.ParseInt(args[0], 10, 64)
|
||||
prNumber, err := parseIssueArg(args[0])
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid pull request number: %w", err)
|
||||
}
|
||||
|
|
@ -122,24 +121,27 @@ func runPRComment(cmd *cobra.Command, args []string) error {
|
|||
return err
|
||||
}
|
||||
|
||||
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost())
|
||||
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd())
|
||||
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 jsonOutput, _ := cmd.Flags().GetBool("json"); jsonOutput {
|
||||
return writeJSON(comment)
|
||||
if wantJSON(cmd) {
|
||||
return outputJSON(cmd, comment)
|
||||
}
|
||||
|
||||
fmt.Printf("Comment added to PR #%d\n", prNumber)
|
||||
fmt.Printf("View at: %s\n", comment.HTMLURL)
|
||||
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)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
@ -150,7 +152,7 @@ func runPRReview(cmd *cobra.Command, args []string) error {
|
|||
requestChanges, _ := cmd.Flags().GetBool("request-changes")
|
||||
commentReview, _ := cmd.Flags().GetBool("comment")
|
||||
|
||||
prNumber, err := strconv.ParseInt(args[0], 10, 64)
|
||||
prNumber, err := parseIssueArg(args[0])
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid pull request number: %w", err)
|
||||
}
|
||||
|
|
@ -189,7 +191,7 @@ func runPRReview(cmd *cobra.Command, args []string) error {
|
|||
return err
|
||||
}
|
||||
|
||||
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost())
|
||||
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -208,21 +210,24 @@ 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 jsonOutput, _ := cmd.Flags().GetBool("json"); jsonOutput {
|
||||
return writeJSON(review)
|
||||
if wantJSON(cmd) {
|
||||
return outputJSON(cmd, review)
|
||||
}
|
||||
|
||||
fmt.Printf("PR #%d %s\n", prNumber, action)
|
||||
cs := ios.ColorScheme()
|
||||
fmt.Fprintf(ios.Out, "%s PR #%d %s\n", cs.SuccessIcon(), prNumber, action)
|
||||
if review.HTMLURL != "" {
|
||||
fmt.Printf("View at: %s\n", review.HTMLURL)
|
||||
fmt.Fprintf(ios.Out, "View at: %s\n", review.HTMLURL)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
|
|
|||
287
cmd/release.go
287
cmd/release.go
|
|
@ -3,14 +3,15 @@ package cmd
|
|||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
"time"
|
||||
|
||||
"code.gitea.io/sdk/gitea"
|
||||
"forgejo.zerova.net/sid/fgj-sid/internal/api"
|
||||
"forgejo.zerova.net/sid/fgj-sid/internal/config"
|
||||
"forgejo.zerova.net/public/fj/internal/api"
|
||||
"forgejo.zerova.net/public/fj/internal/config"
|
||||
"forgejo.zerova.net/public/fj/internal/text"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
|
|
@ -25,6 +26,14 @@ 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,
|
||||
}
|
||||
|
||||
|
|
@ -32,6 +41,17 @@ 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,
|
||||
}
|
||||
|
|
@ -40,6 +60,17 @@ 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,
|
||||
}
|
||||
|
|
@ -48,14 +79,43 @@ 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,
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
|
|
@ -66,16 +126,18 @@ 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")
|
||||
releaseListCmd.Flags().Bool("json", false, "Output releases as JSON")
|
||||
addJSONFlags(releaseListCmd, "Output releases as JSON")
|
||||
|
||||
releaseViewCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format")
|
||||
releaseViewCmd.Flags().Bool("json", false, "Output release as JSON")
|
||||
addJSONFlags(releaseViewCmd, "Output release as JSON")
|
||||
releaseViewCmd.Flags().BoolP("web", "w", false, "Open in web browser")
|
||||
|
||||
releaseCreateCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format")
|
||||
releaseCreateCmd.Flags().StringP("title", "t", "", "Release title (defaults to tag)")
|
||||
|
|
@ -88,7 +150,12 @@ 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 {
|
||||
|
|
@ -113,7 +180,7 @@ func runReleaseList(cmd *cobra.Command, args []string) error {
|
|||
return err
|
||||
}
|
||||
|
||||
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost())
|
||||
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -131,11 +198,13 @@ 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 {
|
||||
|
|
@ -143,29 +212,29 @@ func runReleaseList(cmd *cobra.Command, args []string) error {
|
|||
}
|
||||
releases = append(releases, batch...)
|
||||
}
|
||||
ios.StopSpinner()
|
||||
|
||||
if len(releases) > limit {
|
||||
releases = releases[:limit]
|
||||
}
|
||||
|
||||
if jsonOutput, _ := cmd.Flags().GetBool("json"); jsonOutput {
|
||||
return writeJSON(releases)
|
||||
if wantJSON(cmd) {
|
||||
return outputJSON(cmd, releases)
|
||||
}
|
||||
|
||||
if len(releases) == 0 {
|
||||
fmt.Printf("No releases in %s/%s\n", owner, name)
|
||||
fmt.Fprintf(ios.Out, "No releases in %s/%s\n", owner, name)
|
||||
return nil
|
||||
}
|
||||
|
||||
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
||||
_, _ = fmt.Fprintf(w, "TAG\tTITLE\tTYPE\tPUBLISHED\n")
|
||||
isTTY := ios.IsStdoutTTY()
|
||||
tp := ios.NewTablePrinter()
|
||||
tp.AddHeader("TAG", "TITLE", "TYPE", "PUBLISHED")
|
||||
for _, rel := range releases {
|
||||
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)
|
||||
published := text.FormatDate(releaseTimestamp(rel), isTTY)
|
||||
tp.AddRow(rel.TagName, rel.Title, releaseType(rel), published)
|
||||
}
|
||||
_ = w.Flush()
|
||||
|
||||
return nil
|
||||
return tp.Render()
|
||||
}
|
||||
|
||||
func runReleaseView(cmd *cobra.Command, args []string) error {
|
||||
|
|
@ -182,22 +251,32 @@ func runReleaseView(cmd *cobra.Command, args []string) error {
|
|||
return err
|
||||
}
|
||||
|
||||
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost())
|
||||
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 jsonOutput, _ := cmd.Flags().GetBool("json"); jsonOutput {
|
||||
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) {
|
||||
payload := struct {
|
||||
Release *gitea.Release `json:"release"`
|
||||
Assets []*gitea.Attachment `json:"assets,omitempty"`
|
||||
|
|
@ -205,33 +284,41 @@ func runReleaseView(cmd *cobra.Command, args []string) error {
|
|||
Release: release,
|
||||
Assets: attachments,
|
||||
}
|
||||
return writeJSON(payload)
|
||||
return outputJSON(cmd, payload)
|
||||
}
|
||||
|
||||
fmt.Printf("Release %s\n", release.TagName)
|
||||
fmt.Printf("Title: %s\n", release.Title)
|
||||
fmt.Printf("Type: %s\n", releaseType(release))
|
||||
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))
|
||||
if release.Target != "" {
|
||||
fmt.Printf("Target: %s\n", release.Target)
|
||||
fmt.Fprintf(ios.Out, "Target: %s\n", release.Target)
|
||||
}
|
||||
if release.Publisher != nil {
|
||||
fmt.Printf("Author: %s\n", release.Publisher.UserName)
|
||||
fmt.Fprintf(ios.Out, "Author: %s\n", release.Publisher.UserName)
|
||||
}
|
||||
fmt.Printf("Created: %s\n", release.CreatedAt.Format("2006-01-02 15:04:05"))
|
||||
fmt.Fprintf(ios.Out, "Created: %s\n", text.FormatDate(release.CreatedAt, isTTY))
|
||||
if !release.PublishedAt.IsZero() {
|
||||
fmt.Printf("Published: %s\n", release.PublishedAt.Format("2006-01-02 15:04:05"))
|
||||
fmt.Fprintf(ios.Out, "Published: %s\n", text.FormatDate(release.PublishedAt, isTTY))
|
||||
}
|
||||
if release.HTMLURL != "" {
|
||||
fmt.Printf("URL: %s\n", release.HTMLURL)
|
||||
fmt.Fprintf(ios.Out, "URL: %s\n", release.HTMLURL)
|
||||
}
|
||||
if release.Note != "" {
|
||||
fmt.Printf("\n%s\n", release.Note)
|
||||
fmt.Fprintf(ios.Out, "\n%s\n", release.Note)
|
||||
}
|
||||
|
||||
if len(attachments) > 0 {
|
||||
fmt.Printf("\nAssets (%d):\n", len(attachments))
|
||||
fmt.Fprintf(ios.Out, "\nAssets (%d):\n", len(attachments))
|
||||
for _, asset := range attachments {
|
||||
fmt.Printf("- %s (%d bytes) %s\n", asset.Name, asset.Size, asset.DownloadURL)
|
||||
fmt.Fprintf(ios.Out, "- %s (%d bytes) %s\n", asset.Name, asset.Size, asset.DownloadURL)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -276,11 +363,12 @@ func runReleaseCreate(cmd *cobra.Command, args []string) error {
|
|||
return err
|
||||
}
|
||||
|
||||
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost())
|
||||
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ios.StartSpinner("Creating release...")
|
||||
release, _, err := client.CreateRelease(owner, name, gitea.CreateReleaseOption{
|
||||
TagName: tag,
|
||||
Target: target,
|
||||
|
|
@ -289,24 +377,29 @@ 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)
|
||||
}
|
||||
|
||||
fmt.Printf("Release created: %s\n", release.TagName)
|
||||
cs := ios.ColorScheme()
|
||||
fmt.Fprintf(ios.Out, "%s Release created: %s\n", cs.SuccessIcon(), release.TagName)
|
||||
if release.HTMLURL != "" {
|
||||
fmt.Printf("View at: %s\n", release.HTMLURL)
|
||||
fmt.Fprintf(ios.Out, "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.Printf("Uploaded %d asset(s)\n", len(files))
|
||||
fmt.Fprintf(ios.Out, "%s Uploaded %s\n", cs.SuccessIcon(), text.Pluralize(len(files), "asset"))
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
@ -327,26 +420,34 @@ func runReleaseUpload(cmd *cobra.Command, args []string) error {
|
|||
return err
|
||||
}
|
||||
|
||||
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost())
|
||||
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd())
|
||||
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()
|
||||
|
||||
fmt.Printf("Uploaded %d asset(s)\n", len(files))
|
||||
cs := ios.ColorScheme()
|
||||
fmt.Fprintf(ios.Out, "%s Uploaded %s\n", cs.SuccessIcon(), text.Pluralize(len(files), "asset"))
|
||||
return nil
|
||||
}
|
||||
|
||||
func runReleaseDelete(cmd *cobra.Command, args []string) error {
|
||||
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)
|
||||
|
|
@ -359,21 +460,125 @@ func runReleaseDelete(cmd *cobra.Command, args []string) error {
|
|||
return err
|
||||
}
|
||||
|
||||
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost())
|
||||
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
|
||||
}
|
||||
|
||||
if _, err := client.DeleteRelease(owner, name, release.ID); err != nil {
|
||||
return fmt.Errorf("failed to delete release: %w", err)
|
||||
attachments, err := listReleaseAttachments(client, owner, name, release.ID)
|
||||
ios.StopSpinner()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("Release %s deleted\n", release.TagName)
|
||||
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)
|
||||
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)
|
||||
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)
|
||||
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)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
|
|||
247
cmd/repo.go
247
cmd/repo.go
|
|
@ -6,11 +6,11 @@ import (
|
|||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
|
||||
"code.gitea.io/sdk/gitea"
|
||||
"forgejo.zerova.net/sid/fgj-sid/internal/api"
|
||||
"forgejo.zerova.net/sid/fgj-sid/internal/config"
|
||||
"forgejo.zerova.net/public/fj/internal/api"
|
||||
"forgejo.zerova.net/public/fj/internal/config"
|
||||
"forgejo.zerova.net/public/fj/internal/text"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
|
|
@ -67,28 +67,45 @@ var repoEditCmd = &cobra.Command{
|
|||
Short: "Edit repository settings",
|
||||
Long: "Edit settings of an existing repository such as visibility, description, homepage, and default branch.",
|
||||
Example: ` # Make a repository private
|
||||
fgj repo edit owner/repo --private
|
||||
fj repo edit owner/repo --private
|
||||
|
||||
# Make a repository public
|
||||
fgj repo edit owner/repo --public
|
||||
fj repo edit owner/repo --public
|
||||
|
||||
# Update description and homepage
|
||||
fgj repo edit owner/repo -d "New description" --homepage https://example.com
|
||||
fj repo edit owner/repo -d "New description" --homepage https://example.com
|
||||
|
||||
# Change default branch
|
||||
fgj repo edit --default-branch develop
|
||||
fj repo edit --default-branch develop
|
||||
|
||||
# Rename a repository
|
||||
fj repo edit owner/repo --name new-name
|
||||
|
||||
# Edit current repo (auto-detected from git context)
|
||||
fgj repo edit --public`,
|
||||
fj repo edit --public`,
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: runRepoEdit,
|
||||
}
|
||||
|
||||
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)
|
||||
|
|
@ -104,16 +121,26 @@ 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")
|
||||
repoEditCmd.Flags().Bool("json", false, "Output updated repository as JSON")
|
||||
addJSONFlags(repoEditCmd, "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 {
|
||||
|
|
@ -132,28 +159,41 @@ func runRepoView(cmd *cobra.Command, args []string) error {
|
|||
return err
|
||||
}
|
||||
|
||||
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost())
|
||||
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd())
|
||||
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)
|
||||
}
|
||||
|
||||
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"))
|
||||
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))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
@ -164,42 +204,50 @@ func runRepoList(cmd *cobra.Command, args []string) error {
|
|||
return err
|
||||
}
|
||||
|
||||
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost())
|
||||
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd())
|
||||
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)
|
||||
}
|
||||
|
||||
repos, _, err := client.ListUserRepos(user.UserName, gitea.ListReposOptions{})
|
||||
limit, _ := cmd.Flags().GetInt("limit")
|
||||
repos, err := paginateGitea(limit, func(page, pageSize int) ([]*gitea.Repository, error) {
|
||||
batch, _, err := client.ListUserRepos(user.UserName, gitea.ListReposOptions{
|
||||
ListOptions: gitea.ListOptions{Page: page, PageSize: pageSize},
|
||||
})
|
||||
return batch, err
|
||||
})
|
||||
ios.StopSpinner()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list repositories: %w", err)
|
||||
}
|
||||
|
||||
if wantJSON(cmd) {
|
||||
return outputJSON(cmd, repos)
|
||||
}
|
||||
|
||||
if len(repos) == 0 {
|
||||
fmt.Println("No repositories found")
|
||||
fmt.Fprintln(ios.Out, "No repositories found")
|
||||
return nil
|
||||
}
|
||||
|
||||
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
||||
_, _ = fmt.Fprintf(w, "NAME\tVISIBILITY\tDESCRIPTION\n")
|
||||
tp := ios.NewTablePrinter()
|
||||
tp.AddHeader("NAME", "VISIBILITY", "DESCRIPTION")
|
||||
for _, repo := range repos {
|
||||
visibility := "public"
|
||||
if repo.Private {
|
||||
visibility = "private"
|
||||
}
|
||||
desc := repo.Description
|
||||
if len(desc) > 50 {
|
||||
desc = desc[:47] + "..."
|
||||
desc := text.Truncate(repo.Description, 50)
|
||||
tp.AddRow(fmt.Sprintf("%s/%s", repo.Owner.UserName, repo.Name), visibility, desc)
|
||||
}
|
||||
_, _ = fmt.Fprintf(w, "%s/%s\t%s\t%s\n", repo.Owner.UserName, repo.Name, visibility, desc)
|
||||
}
|
||||
_ = w.Flush()
|
||||
|
||||
return nil
|
||||
return tp.Render()
|
||||
}
|
||||
|
||||
func runRepoClone(cmd *cobra.Command, args []string) error {
|
||||
|
|
@ -216,12 +264,14 @@ func runRepoClone(cmd *cobra.Command, args []string) error {
|
|||
return err
|
||||
}
|
||||
|
||||
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost())
|
||||
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd())
|
||||
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)
|
||||
}
|
||||
|
|
@ -241,7 +291,7 @@ func runRepoClone(cmd *cobra.Command, args []string) error {
|
|||
destination = name
|
||||
}
|
||||
|
||||
fmt.Printf("Cloning %s/%s to %s...\n", owner, name, destination)
|
||||
fmt.Fprintf(ios.Out, "Cloning %s/%s to %s...\n", owner, name, destination)
|
||||
|
||||
// Create parent directory if it doesn't exist
|
||||
if dir := filepath.Dir(destination); dir != "." {
|
||||
|
|
@ -250,17 +300,21 @@ func runRepoClone(cmd *cobra.Command, args []string) error {
|
|||
}
|
||||
}
|
||||
|
||||
ios.StartSpinner("Cloning repository...")
|
||||
// Execute git clone
|
||||
gitCmd := exec.Command("git", "clone", cloneURL, destination)
|
||||
gitCmd.Stdout = os.Stdout
|
||||
gitCmd.Stderr = os.Stderr
|
||||
gitCmd.Stdin = os.Stdin
|
||||
gitCmd.Stdout = ios.Out
|
||||
gitCmd.Stderr = ios.ErrOut
|
||||
gitCmd.Stdin = ios.In
|
||||
|
||||
if err := gitCmd.Run(); err != nil {
|
||||
ios.StopSpinner()
|
||||
return fmt.Errorf("failed to clone repository: %w", err)
|
||||
}
|
||||
ios.StopSpinner()
|
||||
|
||||
fmt.Printf("Repository cloned successfully to %s\n", destination)
|
||||
cs := ios.ColorScheme()
|
||||
fmt.Fprintf(ios.Out, "%s Repository cloned successfully to %s\n", cs.SuccessIcon(), destination)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
@ -277,19 +331,22 @@ func runRepoFork(cmd *cobra.Command, args []string) error {
|
|||
return err
|
||||
}
|
||||
|
||||
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost())
|
||||
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd())
|
||||
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)
|
||||
}
|
||||
|
||||
fmt.Printf("Repository forked successfully\n")
|
||||
fmt.Printf("View at: %s\n", fork.HTMLURL)
|
||||
fmt.Printf("Clone URL: %s\n", fork.CloneURL)
|
||||
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)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
@ -321,7 +378,7 @@ func runRepoCreate(cmd *cobra.Command, args []string) error {
|
|||
return err
|
||||
}
|
||||
|
||||
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost())
|
||||
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -335,12 +392,14 @@ 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)
|
||||
}
|
||||
|
|
@ -354,7 +413,7 @@ func runRepoCreate(cmd *cobra.Command, args []string) error {
|
|||
} else {
|
||||
user, _, userErr := client.GetMyUserInfo()
|
||||
if userErr != nil {
|
||||
fmt.Fprintf(os.Stderr, "warning: repository created but could not determine owner for homepage: %v\n", userErr)
|
||||
fmt.Fprintf(ios.ErrOut, "warning: repository created but could not determine owner for homepage: %v\n", userErr)
|
||||
homepage = "" // skip EditRepo
|
||||
} else {
|
||||
ownerName = user.UserName
|
||||
|
|
@ -366,36 +425,37 @@ func runRepoCreate(cmd *cobra.Command, args []string) error {
|
|||
Website: &homepage,
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "warning: repository created but failed to set homepage: %v\n", err)
|
||||
fmt.Fprintf(ios.ErrOut, "warning: repository created but failed to set homepage: %v\n", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if team != "" {
|
||||
if !isOrg {
|
||||
fmt.Fprintln(os.Stderr, "warning: --team is only meaningful for organization repositories")
|
||||
fmt.Fprintln(ios.ErrOut, "warning: --team is only meaningful for organization repositories")
|
||||
} else {
|
||||
_, err = client.AddRepoTeam(org, repo.Name, team)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "warning: repository created but failed to add team %q: %v\n", team, err)
|
||||
fmt.Fprintf(ios.ErrOut, "warning: repository created but failed to add team %q: %v\n", team, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("Repository created: %s\n", repo.HTMLURL)
|
||||
cs := ios.ColorScheme()
|
||||
fmt.Fprintf(ios.Out, "%s Repository created: %s\n", cs.SuccessIcon(), repo.HTMLURL)
|
||||
|
||||
if doClone {
|
||||
cloneURL := repo.CloneURL
|
||||
if hostCfg, hostErr := cfg.GetHost("", getDetectedHost()); hostErr == nil {
|
||||
if hostCfg, hostErr := cfg.GetHost("", getDetectedHost(), getCwd()); hostErr == nil {
|
||||
if hostCfg.GitProtocol == "ssh" {
|
||||
cloneURL = repo.SSHURL
|
||||
}
|
||||
}
|
||||
fmt.Printf("Cloning into %s...\n", repo.Name)
|
||||
fmt.Fprintf(ios.Out, "Cloning into %s...\n", repo.Name)
|
||||
gitCmd := exec.Command("git", "clone", cloneURL, repo.Name)
|
||||
gitCmd.Stdout = os.Stdout
|
||||
gitCmd.Stderr = os.Stderr
|
||||
gitCmd.Stdin = os.Stdin
|
||||
gitCmd.Stdout = ios.Out
|
||||
gitCmd.Stderr = ios.ErrOut
|
||||
gitCmd.Stdin = ios.In
|
||||
if err := gitCmd.Run(); err != nil {
|
||||
return fmt.Errorf("failed to clone repository: %w", err)
|
||||
}
|
||||
|
|
@ -441,7 +501,7 @@ func runRepoEdit(cmd *cobra.Command, args []string) error {
|
|||
return err
|
||||
}
|
||||
|
||||
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost())
|
||||
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -449,6 +509,11 @@ 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
|
||||
|
|
@ -476,36 +541,84 @@ func runRepoEdit(cmd *cobra.Command, args []string) error {
|
|||
}
|
||||
|
||||
if !changed {
|
||||
return fmt.Errorf("no changes specified; use flags like --public, --private, --description, --homepage, or --default-branch")
|
||||
return fmt.Errorf("no changes specified; use flags like --name, --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)
|
||||
}
|
||||
|
||||
jsonFlag, _ := cmd.Flags().GetBool("json")
|
||||
if jsonFlag {
|
||||
return writeJSON(repository)
|
||||
if wantJSON(cmd) {
|
||||
return outputJSON(cmd, repository)
|
||||
}
|
||||
|
||||
fmt.Printf("Repository updated: %s\n", repository.HTMLURL)
|
||||
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)
|
||||
}
|
||||
if opt.Private != nil {
|
||||
if *opt.Private {
|
||||
fmt.Println("Visibility: private")
|
||||
fmt.Fprintln(ios.Out, "Visibility: private")
|
||||
} else {
|
||||
fmt.Println("Visibility: public")
|
||||
fmt.Fprintln(ios.Out, "Visibility: public")
|
||||
}
|
||||
}
|
||||
if opt.Description != nil {
|
||||
fmt.Printf("Description: %s\n", *opt.Description)
|
||||
fmt.Fprintf(ios.Out, "Description: %s\n", *opt.Description)
|
||||
}
|
||||
if opt.Website != nil {
|
||||
fmt.Printf("Homepage: %s\n", *opt.Website)
|
||||
fmt.Fprintf(ios.Out, "Homepage: %s\n", *opt.Website)
|
||||
}
|
||||
if opt.DefaultBranch != nil {
|
||||
fmt.Printf("Default branch: %s\n", *opt.DefaultBranch)
|
||||
fmt.Fprintf(ios.Out, "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
|
||||
}
|
||||
|
|
|
|||
127
cmd/root.go
127
cmd/root.go
|
|
@ -2,10 +2,14 @@ package cmd
|
|||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"forgejo.zerova.net/sid/fgj-sid/internal/git"
|
||||
"forgejo.zerova.net/public/fj/internal/config"
|
||||
"forgejo.zerova.net/public/fj/internal/git"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
|
@ -14,11 +18,11 @@ var cfgFile string
|
|||
var jsonErrors bool
|
||||
|
||||
var rootCmd = &cobra.Command{
|
||||
Use: "fgj",
|
||||
Use: "fj",
|
||||
Short: "Forgejo CLI tool - work seamlessly with Forgejo from the command line",
|
||||
Long: `fgj is a command line tool for Forgejo instances (including Codeberg).
|
||||
Long: `fj is a command line tool for Forgejo instances (including Codeberg).
|
||||
It brings pull requests, issues, and other Forgejo concepts to the terminal.`,
|
||||
Version: "0.3.0b",
|
||||
Version: "0.4.0",
|
||||
SilenceErrors: true,
|
||||
}
|
||||
|
||||
|
|
@ -34,7 +38,7 @@ func Execute() error {
|
|||
func init() {
|
||||
cobra.OnInitialize(initConfig)
|
||||
|
||||
rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.config/fgj/config.yaml)")
|
||||
rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.config/fj/config.yaml)")
|
||||
rootCmd.PersistentFlags().BoolVar(&jsonErrors, "json-errors", false, "output errors as structured JSON to stderr")
|
||||
rootCmd.PersistentFlags().String("hostname", "", "Forgejo instance hostname")
|
||||
_ = viper.BindPFlag("hostname", rootCmd.PersistentFlags().Lookup("hostname"))
|
||||
|
|
@ -42,16 +46,33 @@ 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(os.Stderr, err)
|
||||
fmt.Fprintln(ios.ErrOut, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
configDir := home + "/.config/fgj"
|
||||
_ = os.MkdirAll(configDir, 0755)
|
||||
configDir := home + "/.config/fj"
|
||||
legacyDir := home + "/.config/fgj"
|
||||
|
||||
// Migrate from ~/.config/fgj/ if the new dir doesn't exist yet.
|
||||
if _, err := os.Stat(configDir); os.IsNotExist(err) {
|
||||
if info, err := os.Stat(legacyDir); err == nil && info.IsDir() {
|
||||
if copyErr := migrateConfigDir(legacyDir, configDir); copyErr == nil {
|
||||
fmt.Fprintln(ios.ErrOut, "notice: migrated config from ~/.config/fgj/ to ~/.config/fj/")
|
||||
fmt.Fprintln(ios.ErrOut, " you can remove ~/.config/fgj/ when ready")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_ = os.MkdirAll(configDir, 0700)
|
||||
|
||||
viper.AddConfigPath(configDir)
|
||||
viper.SetConfigType("yaml")
|
||||
|
|
@ -59,9 +80,17 @@ func initConfig() {
|
|||
}
|
||||
|
||||
viper.AutomaticEnv()
|
||||
viper.SetEnvPrefix("FGJ")
|
||||
viper.SetEnvPrefix("FJ")
|
||||
|
||||
_ = viper.ReadInConfig()
|
||||
|
||||
// If the resolved config exists with overly permissive mode, warn — the
|
||||
// file holds API tokens. Don't fail-close; just nudge the user.
|
||||
if path, err := config.GetConfigPath(); err == nil {
|
||||
if info, statErr := os.Stat(path); statErr == nil && info.Mode()&0o077 != 0 {
|
||||
fmt.Fprintf(ios.ErrOut, "warning: %s mode %o is world/group readable; tokens may leak. chmod 600 it.\n", path, info.Mode().Perm())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// parseRepo parses the repository string in the format "owner/name".
|
||||
|
|
@ -94,3 +123,83 @@ 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
|
||||
}
|
||||
|
|
|
|||
404
cmd/wiki.go
Normal file
404
cmd/wiki.go
Normal file
|
|
@ -0,0 +1,404 @@
|
|||
package cmd
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"forgejo.zerova.net/public/fj/internal/api"
|
||||
"forgejo.zerova.net/public/fj/internal/config"
|
||||
"forgejo.zerova.net/public/fj/internal/text"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// Wiki API response types
|
||||
|
||||
type wikiPageMeta struct {
|
||||
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"`
|
||||
}
|
||||
|
||||
type wikiUser struct {
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
Date string `json:"date"`
|
||||
}
|
||||
|
||||
type wikiPage struct {
|
||||
Title string `json:"title"`
|
||||
HTMLURL string `json:"html_url"`
|
||||
SubURL string `json:"sub_url"`
|
||||
ContentBase64 string `json:"content_base64"`
|
||||
LastCommit *wikiCommit `json:"last_commit"`
|
||||
// Decoded content for JSON output
|
||||
Content string `json:"content,omitempty"`
|
||||
}
|
||||
|
||||
type wikiCreateRequest struct {
|
||||
Title string `json:"title"`
|
||||
ContentBase64 string `json:"content_base64"`
|
||||
}
|
||||
|
||||
var wikiCmd = &cobra.Command{
|
||||
Use: "wiki",
|
||||
Short: "Manage repository wiki pages",
|
||||
Long: "View and manage wiki pages for a repository.",
|
||||
}
|
||||
|
||||
var wikiListCmd = &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List wiki pages",
|
||||
Long: "List all wiki pages for a repository.",
|
||||
Example: ` # List wiki pages for the current repo
|
||||
fj wiki list
|
||||
|
||||
# List wiki pages for a specific repo
|
||||
fj wiki list -R owner/repo
|
||||
|
||||
# Output as JSON
|
||||
fj wiki list --json`,
|
||||
RunE: runWikiList,
|
||||
}
|
||||
|
||||
var wikiViewCmd = &cobra.Command{
|
||||
Use: "view <title>",
|
||||
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
|
||||
|
||||
# View a wiki page as JSON (includes content)
|
||||
fj wiki view Home --json
|
||||
|
||||
# View a wiki page from a specific repo
|
||||
fj wiki view "Getting-Started" -R owner/repo`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: runWikiView,
|
||||
}
|
||||
|
||||
var wikiCreateCmd = &cobra.Command{
|
||||
Use: "create <title>",
|
||||
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."
|
||||
|
||||
# Create a wiki page from a file
|
||||
fj wiki create "Setup Guide" --body-file setup.md
|
||||
|
||||
# Create a wiki page from stdin
|
||||
echo "# FAQ" | fj wiki create FAQ --body-file -
|
||||
|
||||
# Output as JSON
|
||||
fj wiki create "New Page" -b "Content here" --json`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: runWikiCreate,
|
||||
}
|
||||
|
||||
var wikiEditCmd = &cobra.Command{
|
||||
Use: "edit <title>",
|
||||
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."
|
||||
|
||||
# Edit a wiki page from a file
|
||||
fj 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 -
|
||||
|
||||
# Output as JSON
|
||||
fj wiki edit Home -b "Updated content" --json`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: runWikiEdit,
|
||||
}
|
||||
|
||||
var wikiDeleteCmd = &cobra.Command{
|
||||
Use: "delete <title>",
|
||||
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
|
||||
|
||||
# Delete a wiki page from a specific repo
|
||||
fj wiki delete "Outdated Guide" -R owner/repo`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: runWikiDelete,
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(wikiCmd)
|
||||
wikiCmd.AddCommand(wikiListCmd)
|
||||
wikiCmd.AddCommand(wikiViewCmd)
|
||||
wikiCmd.AddCommand(wikiCreateCmd)
|
||||
wikiCmd.AddCommand(wikiEditCmd)
|
||||
wikiCmd.AddCommand(wikiDeleteCmd)
|
||||
|
||||
wikiListCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format")
|
||||
addJSONFlags(wikiListCmd, "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")
|
||||
|
||||
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")
|
||||
|
||||
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")
|
||||
|
||||
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) {
|
||||
repo, _ := cmd.Flags().GetString("repo")
|
||||
owner, name, err := parseRepo(repo)
|
||||
if err != nil {
|
||||
return nil, "", "", err
|
||||
}
|
||||
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
return nil, "", "", err
|
||||
}
|
||||
|
||||
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd())
|
||||
if err != nil {
|
||||
return nil, "", "", err
|
||||
}
|
||||
|
||||
return client, owner, name, nil
|
||||
}
|
||||
|
||||
func runWikiList(cmd *cobra.Command, args []string) error {
|
||||
client, owner, name, err := newWikiClient(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
if len(pages) == 0 {
|
||||
fmt.Fprintln(ios.Out, "No wiki pages found")
|
||||
return nil
|
||||
}
|
||||
|
||||
isTTY := ios.IsStdoutTTY()
|
||||
tp := ios.NewTablePrinter()
|
||||
tp.AddHeader("TITLE", "LAST UPDATED")
|
||||
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)
|
||||
} else {
|
||||
updated = p.LastCommit.Committer.Date
|
||||
}
|
||||
}
|
||||
tp.AddRow(p.Title, updated)
|
||||
}
|
||||
return tp.Render()
|
||||
}
|
||||
|
||||
func runWikiView(cmd *cobra.Command, args []string) error {
|
||||
title := args[0]
|
||||
|
||||
client, owner, name, err := newWikiClient(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
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) {
|
||||
page.Content = string(content)
|
||||
return outputJSON(cmd, 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))
|
||||
// Ensure trailing newline
|
||||
if len(content) > 0 && content[len(content)-1] != '\n' {
|
||||
fmt.Fprintln(ios.Out)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func runWikiCreate(cmd *cobra.Command, args []string) error {
|
||||
title := args[0]
|
||||
|
||||
client, owner, name, err := newWikiClient(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
body, err := readBody(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if body == "" {
|
||||
return fmt.Errorf("content is required (use --body or --body-file)")
|
||||
}
|
||||
|
||||
path := fmt.Sprintf("/api/v1/repos/%s/%s/wiki/new",
|
||||
url.PathEscape(owner), url.PathEscape(name))
|
||||
|
||||
reqBody := wikiCreateRequest{
|
||||
Title: title,
|
||||
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)
|
||||
}
|
||||
|
||||
cs := ios.ColorScheme()
|
||||
fmt.Fprintf(ios.Out, "%s Wiki page created: %s\n", cs.SuccessIcon(), title)
|
||||
return nil
|
||||
}
|
||||
|
||||
func runWikiEdit(cmd *cobra.Command, args []string) error {
|
||||
title := args[0]
|
||||
|
||||
client, owner, name, err := newWikiClient(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
body, err := readBody(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if body == "" {
|
||||
return fmt.Errorf("content is required (use --body or --body-file)")
|
||||
}
|
||||
|
||||
path := fmt.Sprintf("/api/v1/repos/%s/%s/wiki/page/%s",
|
||||
url.PathEscape(owner), url.PathEscape(name), url.PathEscape(title))
|
||||
|
||||
reqBody := wikiCreateRequest{
|
||||
Title: title,
|
||||
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)
|
||||
}
|
||||
|
||||
cs := ios.ColorScheme()
|
||||
fmt.Fprintf(ios.Out, "%s Wiki page updated: %s\n", cs.SuccessIcon(), 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)
|
||||
return nil
|
||||
}
|
||||
8
go.mod
8
go.mod
|
|
@ -1,6 +1,6 @@
|
|||
module forgejo.zerova.net/sid/fgj-sid
|
||||
module forgejo.zerova.net/public/fj
|
||||
|
||||
go 1.23.0
|
||||
go 1.24.0
|
||||
|
||||
require (
|
||||
code.gitea.io/sdk/gitea v0.22.1
|
||||
|
|
@ -19,6 +19,8 @@ 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
|
||||
|
|
@ -34,7 +36,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.33.0 // indirect
|
||||
golang.org/x/sys v0.38.0 // indirect
|
||||
golang.org/x/text v0.26.0 // indirect
|
||||
gopkg.in/ini.v1 v1.67.0 // indirect
|
||||
)
|
||||
|
|
|
|||
6
go.sum
6
go.sum
|
|
@ -24,6 +24,10 @@ 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=
|
||||
|
|
@ -88,6 +92,8 @@ 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=
|
||||
|
|
|
|||
|
|
@ -6,11 +6,22 @@ import (
|
|||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"code.gitea.io/sdk/gitea"
|
||||
"forgejo.zerova.net/sid/fgj-sid/internal/config"
|
||||
"forgejo.zerova.net/public/fj/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
|
||||
|
|
@ -34,8 +45,8 @@ func NewClient(hostname, token string) (*Client, error) {
|
|||
}, nil
|
||||
}
|
||||
|
||||
func NewClientFromConfig(cfg *config.Config, hostname string, detectedHost string) (*Client, error) {
|
||||
host, err := cfg.GetHost(hostname, detectedHost)
|
||||
func NewClientFromConfig(cfg *config.Config, hostname string, detectedHost string, cwd string) (*Client, error) {
|
||||
host, err := cfg.GetHost(hostname, detectedHost, cwd)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -63,8 +74,7 @@ func (c *Client) GetJSON(path string, result any) error {
|
|||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
httpClient := &http.Client{}
|
||||
resp, err := httpClient.Do(req)
|
||||
resp, err := sharedHTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to perform request: %w", err)
|
||||
}
|
||||
|
|
@ -74,8 +84,11 @@ func (c *Client) GetJSON(path string, result any) error {
|
|||
}
|
||||
}()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
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)
|
||||
}
|
||||
return &APIError{
|
||||
StatusCode: resp.StatusCode,
|
||||
Body: string(body),
|
||||
|
|
@ -125,8 +138,7 @@ func (c *Client) DoJSON(method string, path string, body any, result any) (int,
|
|||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
httpClient := &http.Client{}
|
||||
resp, err := httpClient.Do(req)
|
||||
resp, err := sharedHTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to perform request: %w", err)
|
||||
}
|
||||
|
|
@ -136,8 +148,11 @@ func (c *Client) DoJSON(method string, path string, body any, result any) (int,
|
|||
}
|
||||
}()
|
||||
|
||||
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusNoContent {
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
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)
|
||||
}
|
||||
return resp.StatusCode, &APIError{
|
||||
StatusCode: resp.StatusCode,
|
||||
Body: string(bodyBytes),
|
||||
|
|
@ -154,6 +169,40 @@ 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)
|
||||
|
|
@ -166,8 +215,7 @@ func (c *Client) GetRawLog(url string) (string, error) {
|
|||
req.Header.Set("Authorization", "token "+c.token)
|
||||
}
|
||||
|
||||
httpClient := &http.Client{}
|
||||
resp, err := httpClient.Do(req)
|
||||
resp, err := sharedHTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to perform request: %w", err)
|
||||
}
|
||||
|
|
@ -178,7 +226,10 @@ func (c *Client) GetRawLog(url string) (string, error) {
|
|||
}()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
body, readErr := io.ReadAll(resp.Body)
|
||||
if readErr != nil {
|
||||
return "", fmt.Errorf("failed to read error response body: %w", readErr)
|
||||
}
|
||||
return "", &APIError{
|
||||
StatusCode: resp.StatusCode,
|
||||
Body: string(body),
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ package api
|
|||
import (
|
||||
"testing"
|
||||
|
||||
"forgejo.zerova.net/sid/fgj-sid/internal/config"
|
||||
"forgejo.zerova.net/public/fj/internal/config"
|
||||
)
|
||||
|
||||
func TestClient_Hostname(t *testing.T) {
|
||||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import (
|
|||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/viper"
|
||||
"gopkg.in/yaml.v3"
|
||||
|
|
@ -13,25 +14,41 @@ 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
|
||||
}
|
||||
|
||||
func GetConfigDir() (string, error) {
|
||||
if xdgConfigHome := os.Getenv("XDG_CONFIG_HOME"); xdgConfigHome != "" {
|
||||
return filepath.Join(xdgConfigHome, "fgj"), nil
|
||||
return filepath.Join(xdgConfigHome, "fj"), nil
|
||||
}
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(home, ".config", "fgj"), nil
|
||||
return filepath.Join(home, ".config", "fj"), nil
|
||||
}
|
||||
|
||||
func GetConfigPath() (string, error) {
|
||||
if explicitConfigPath != "" {
|
||||
return explicitConfigPath, nil
|
||||
}
|
||||
dir, err := GetConfigDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
|
|
@ -65,9 +82,43 @@ 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 {
|
||||
|
|
@ -94,22 +145,27 @@ func (c *Config) SaveToPath(path string) error {
|
|||
// Priority order:
|
||||
// 1. Explicitly provided hostname parameter
|
||||
// 2. CLI flag (--hostname)
|
||||
// 3. Environment variable (FGJ_HOST)
|
||||
// 3. Environment variable (FJ_HOST, with FGJ_HOST fallback)
|
||||
// 4. Auto-detected hostname from git remote
|
||||
// 5. Default to codeberg.org
|
||||
func (c *Config) GetHost(hostname string, detectedHost string) (HostConfig, error) {
|
||||
// 5. match_dirs lookup (longest prefix match)
|
||||
// 6. Default to codeberg.org
|
||||
func (c *Config) GetHost(hostname string, detectedHost string, cwd string) (HostConfig, error) {
|
||||
if hostname == "" {
|
||||
hostname = viper.GetString("hostname")
|
||||
}
|
||||
|
||||
if hostname == "" {
|
||||
hostname = os.Getenv("FGJ_HOST")
|
||||
hostname = EnvWithFallback("FJ_HOST", "FGJ_HOST")
|
||||
}
|
||||
|
||||
if hostname == "" {
|
||||
hostname = detectedHost
|
||||
}
|
||||
|
||||
if hostname == "" {
|
||||
hostname = c.ResolveHostByPath(cwd)
|
||||
}
|
||||
|
||||
if hostname == "" {
|
||||
hostname = "codeberg.org"
|
||||
}
|
||||
|
|
@ -122,6 +178,90 @@ func (c *Config) GetHost(hostname string, detectedHost string) (HostConfig, erro
|
|||
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)
|
||||
|
|
|
|||
|
|
@ -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/fgj"
|
||||
expected := "/custom/config/fj"
|
||||
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,13 +422,233 @@ 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -114,6 +114,48 @@ 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
|
||||
|
|
|
|||
|
|
@ -17,41 +17,41 @@ func TestParseRemoteURL(t *testing.T) {
|
|||
}{
|
||||
{
|
||||
name: "HTTPS URL with .git",
|
||||
url: "https://codeberg.org/romaintb/fgj.git",
|
||||
url: "https://codeberg.org/romaintb/fj.git",
|
||||
wantOwner: "romaintb",
|
||||
wantName: "fgj",
|
||||
wantName: "fj",
|
||||
wantHost: "codeberg.org",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "HTTPS URL without .git",
|
||||
url: "https://codeberg.org/romaintb/fgj",
|
||||
url: "https://codeberg.org/romaintb/fj",
|
||||
wantOwner: "romaintb",
|
||||
wantName: "fgj",
|
||||
wantName: "fj",
|
||||
wantHost: "codeberg.org",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "SSH URL with .git",
|
||||
url: "git@codeberg.org:romaintb/fgj.git",
|
||||
url: "git@codeberg.org:romaintb/fj.git",
|
||||
wantOwner: "romaintb",
|
||||
wantName: "fgj",
|
||||
wantName: "fj",
|
||||
wantHost: "codeberg.org",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "SSH URL without .git",
|
||||
url: "git@codeberg.org:romaintb/fgj",
|
||||
url: "git@codeberg.org:romaintb/fj",
|
||||
wantOwner: "romaintb",
|
||||
wantName: "fgj",
|
||||
wantName: "fj",
|
||||
wantHost: "codeberg.org",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "SSH protocol URL",
|
||||
url: "ssh://git@codeberg.org/romaintb/fgj.git",
|
||||
url: "ssh://git@codeberg.org/romaintb/fj.git",
|
||||
wantOwner: "romaintb",
|
||||
wantName: "fgj",
|
||||
wantName: "fj",
|
||||
wantHost: "codeberg.org",
|
||||
wantErr: false,
|
||||
},
|
||||
|
|
|
|||
77
internal/iostreams/color.go
Normal file
77
internal/iostreams/color.go
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
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)
|
||||
}
|
||||
275
internal/iostreams/iostreams.go
Normal file
275
internal/iostreams/iostreams.go
Normal file
|
|
@ -0,0 +1,275 @@
|
|||
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))
|
||||
}
|
||||
64
internal/iostreams/table.go
Normal file
64
internal/iostreams/table.go
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
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()
|
||||
}
|
||||
70
internal/text/text.go
Normal file
70
internal/text/text.go
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
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)
|
||||
}
|
||||
3
main.go
3
main.go
|
|
@ -4,11 +4,12 @@ import (
|
|||
"fmt"
|
||||
"os"
|
||||
|
||||
"forgejo.zerova.net/sid/fgj-sid/cmd"
|
||||
"forgejo.zerova.net/public/fj/cmd"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := cmd.Execute(); err != nil {
|
||||
err = cmd.ContextualError(err)
|
||||
if cmd.JSONErrors() {
|
||||
cmd.WriteJSONError(err)
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import (
|
|||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"code.gitea.io/sdk/gitea"
|
||||
|
|
@ -227,15 +228,18 @@ func (env *TestEnv) CleanupRepo(owner, repoName string) {
|
|||
}
|
||||
}
|
||||
|
||||
// GetBinaryPath returns the path to the built fgj binary
|
||||
// GetBinaryPath returns the path to the built fj binary
|
||||
func (env *TestEnv) GetBinaryPath() string {
|
||||
binaryPath := os.Getenv("FGJ_BINARY_PATH")
|
||||
binaryPath := os.Getenv("FJ_BINARY_PATH")
|
||||
if binaryPath == "" {
|
||||
binaryPath = os.Getenv("FGJ_BINARY_PATH")
|
||||
}
|
||||
if binaryPath == "" {
|
||||
// Look for the binary in common locations
|
||||
candidates := []string{
|
||||
"./bin/fgj",
|
||||
"bin/fgj",
|
||||
"/home/romain/work/fgj/bin/fgj",
|
||||
"./bin/fj",
|
||||
"bin/fj",
|
||||
"/home/romain/work/fj/bin/fj",
|
||||
}
|
||||
for _, candidate := range candidates {
|
||||
if _, err := os.Stat(candidate); err == nil {
|
||||
|
|
@ -247,7 +251,7 @@ func (env *TestEnv) GetBinaryPath() string {
|
|||
}
|
||||
}
|
||||
// If no binary found, return default (will error when executed)
|
||||
binaryPath = "./bin/fgj"
|
||||
binaryPath = "./bin/fj"
|
||||
}
|
||||
return binaryPath
|
||||
}
|
||||
|
|
@ -295,3 +299,39 @@ 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
Loading…
Add table
Add a link
Reference in a new issue