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
|
- name: Build production binary
|
||||||
run: |
|
run: |
|
||||||
make build
|
make build
|
||||||
echo "Binary built at: $(pwd)/bin/fgj"
|
echo "Binary built at: $(pwd)/bin/fj"
|
||||||
|
|
||||||
- name: Run functional tests
|
- name: Run functional tests
|
||||||
run: go test -v -race -tags=functional ./tests/functional/...
|
run: go test -v -race -tags=functional ./tests/functional/...
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ jobs:
|
||||||
- name: Build production binary
|
- name: Build production binary
|
||||||
run: |
|
run: |
|
||||||
make build
|
make build
|
||||||
echo "Binary built at: $(pwd)/bin/fgj"
|
echo "Binary built at: $(pwd)/bin/fj"
|
||||||
|
|
||||||
- name: Run functional tests
|
- name: Run functional tests
|
||||||
run: go test -v -race -tags=functional ./tests/functional/...
|
run: go test -v -race -tags=functional ./tests/functional/...
|
||||||
|
|
|
||||||
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -1,5 +1,6 @@
|
||||||
# Binaries
|
# Binaries
|
||||||
fgj
|
fj
|
||||||
|
bin/
|
||||||
*.exe
|
*.exe
|
||||||
*.exe~
|
*.exe~
|
||||||
*.dll
|
*.dll
|
||||||
|
|
@ -31,3 +32,5 @@ config.yaml
|
||||||
|
|
||||||
# Git worktrees
|
# Git worktrees
|
||||||
.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/),
|
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).
|
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
|
## [0.3.0b] - 2026-03-21
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
#### Repository Management
|
#### 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
|
### 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
|
## [0.3.0a] - 2026-03-21
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
#### Raw API Access
|
#### 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
|
- 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)
|
- JSON field assembly (`--field`/`-f`) with type inference (bool, int, float, null, string)
|
||||||
- Raw string fields (`--raw-field`/`-F`)
|
- 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`)
|
- Response header display (`--include`/`-i`)
|
||||||
|
|
||||||
#### Pull Request Management
|
#### 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`)
|
- Colorized output (`--color auto/always/never`)
|
||||||
- Changed file names only (`--name-only`)
|
- Changed file names only (`--name-only`)
|
||||||
- Diffstat summary (`--stat`)
|
- 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)
|
- Body from flag (`--body`/`-b`) or file (`--body-file`, `-` for stdin)
|
||||||
- JSON output (`--json`)
|
- 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`)
|
- Approve (`--approve`/`-a`), request changes (`--request-changes`/`-r`), or comment (`--comment`/`-c`)
|
||||||
- Body from flag or file
|
- Body from flag or file
|
||||||
- JSON output (`--json`)
|
- JSON output (`--json`)
|
||||||
|
|
@ -53,30 +182,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
#### Forgejo Actions
|
#### Forgejo Actions
|
||||||
- `fgj actions run watch <run-id>` - Poll a run until completion
|
- `fj actions run watch <run-id>` - Poll a run until completion
|
||||||
- `fgj actions run rerun <run-id>` - Trigger a rerun of a workflow run
|
- `fj actions run rerun <run-id>` - Trigger a rerun of a workflow run
|
||||||
- `fgj actions run cancel <run-id>` - Cancel an in-progress workflow run
|
- `fj actions run cancel <run-id>` - Cancel an in-progress workflow run
|
||||||
- `fgj actions workflow enable <workflow>` - Enable a workflow
|
- `fj actions workflow enable <workflow>` - Enable a workflow
|
||||||
- `fgj actions workflow disable <workflow>` - Disable a workflow
|
- `fj actions workflow disable <workflow>` - Disable a workflow
|
||||||
|
|
||||||
#### Repository Management
|
#### 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
|
#### Issue Management
|
||||||
- `fgj issue create -l <label>` - Assign labels when creating an issue
|
- `fj issue create -l <label>` - Assign labels when creating an issue
|
||||||
- `fgj issue edit --add-label` / `--remove-label` - Add or remove labels on existing issues
|
- `fj 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 close -c <comment>` - Close an issue with an optional comment
|
||||||
|
|
||||||
#### Workflow Management
|
#### 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
|
#### Auth Helpers
|
||||||
- `fgj auth token` - Print the stored token for the current host
|
- `fj auth token` - Print the stored token for the current host
|
||||||
- `fgj auth logout` - Remove authentication for a host
|
- `fj auth logout` - Remove authentication for a host
|
||||||
|
|
||||||
#### Shell Completions and Man Pages
|
#### Shell Completions and Man Pages
|
||||||
- `fgj completion [bash|zsh|fish|powershell]` - Generate shell completion scripts
|
- `fj completion [bash|zsh|fish|powershell]` - Generate shell completion scripts
|
||||||
- `fgj manpages --dir <path>` - Generate man pages for all commands
|
- `fj manpages --dir <path>` - Generate man pages for all commands
|
||||||
|
|
||||||
#### JSON Output
|
#### JSON Output
|
||||||
- `--json` flag for all list and view commands: PRs, issues, releases, workflow runs, workflows
|
- `--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
|
### Added
|
||||||
|
|
||||||
#### Release Management
|
#### Release Management
|
||||||
- `fgj release list` - List releases for a repository
|
- `fj release list` - List releases for a repository
|
||||||
- `fgj release view` - View details of a specific release (supports "latest" keyword)
|
- `fj release view` - View details of a specific release (supports "latest" keyword)
|
||||||
- `fgj release create` - Create new releases with optional asset uploads
|
- `fj release create` - Create new releases with optional asset uploads
|
||||||
- `fgj release upload` - Upload assets to existing releases with optional clobber support
|
- `fj release upload` - Upload assets to existing releases with optional clobber support
|
||||||
- `fgj release delete` - Delete releases (preserves Git tags)
|
- `fj release delete` - Delete releases (preserves Git tags)
|
||||||
|
|
||||||
#### Issue Management
|
#### 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
|
#### 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
|
#### Repository Detection
|
||||||
- Automatic hostname detection from git remote URLs
|
- 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
|
### Added
|
||||||
|
|
||||||
#### Core Features
|
#### Core Features
|
||||||
- Initial release of fgj - Forgejo CLI tool
|
- Initial release of fj - Forgejo CLI tool
|
||||||
- Multi-instance support for any Forgejo/Gitea instance
|
- Multi-instance support for any Forgejo/Gitea instance
|
||||||
- Automatic repository detection from git context (optional `-R` flag)
|
- Automatic repository detection from git context (optional `-R` flag)
|
||||||
- Secure authentication with personal access tokens
|
- Secure authentication with personal access tokens
|
||||||
- Configuration management via `~/.config/fgj/config.yaml`
|
- Configuration management via `~/.config/fj/config.yaml`
|
||||||
|
|
||||||
#### Pull Request Management
|
#### Pull Request Management
|
||||||
- `fgj pr list` - List pull requests with filtering by state
|
- `fj pr list` - List pull requests with filtering by state
|
||||||
- `fgj pr view` - View detailed pull request information
|
- `fj pr view` - View detailed pull request information
|
||||||
- `fgj pr create` - Create new pull requests
|
- `fj pr create` - Create new pull requests
|
||||||
- `fgj pr merge` - Merge pull requests with configurable merge methods
|
- `fj pr merge` - Merge pull requests with configurable merge methods
|
||||||
|
|
||||||
#### Issue Management
|
#### Issue Management
|
||||||
- `fgj issue list` - List issues with state filtering
|
- `fj issue list` - List issues with state filtering
|
||||||
- `fgj issue view` - View detailed issue information
|
- `fj issue view` - View detailed issue information
|
||||||
- `fgj issue create` - Create new issues
|
- `fj issue create` - Create new issues
|
||||||
- `fgj issue comment` - Add comments to issues
|
- `fj issue comment` - Add comments to issues
|
||||||
- `fgj issue close` - Close issues
|
- `fj issue close` - Close issues
|
||||||
|
|
||||||
#### Repository Operations
|
#### Repository Operations
|
||||||
- `fgj repo view` - View repository details
|
- `fj repo view` - View repository details
|
||||||
- `fgj repo list` - List user repositories
|
- `fj repo list` - List user repositories
|
||||||
- `fgj repo clone` - Clone repositories with protocol selection (HTTPS/SSH)
|
- `fj repo clone` - Clone repositories with protocol selection (HTTPS/SSH)
|
||||||
- `fgj repo fork` - Fork repositories
|
- `fj repo fork` - Fork repositories
|
||||||
|
|
||||||
#### Forgejo Actions Support
|
#### Forgejo Actions Support
|
||||||
- `fgj actions run list` - List workflow runs with status and metadata
|
- `fj actions run list` - List workflow runs with status and metadata
|
||||||
- `fgj actions run view` - View detailed run information, jobs, and logs
|
- `fj actions run view` - View detailed run information, jobs, and logs
|
||||||
- Support for `--verbose`, `--log`, `--log-failed`, and `--job` flags
|
- Support for `--verbose`, `--log`, `--log-failed`, and `--job` flags
|
||||||
- `fgj actions secret list` - List repository secrets
|
- `fj actions secret list` - List repository secrets
|
||||||
- `fgj actions secret create` - Create repository secrets
|
- `fj actions secret create` - Create repository secrets
|
||||||
- `fgj actions secret delete` - Delete repository secrets
|
- `fj actions secret delete` - Delete repository secrets
|
||||||
- `fgj actions variable list` - List repository variables
|
- `fj actions variable list` - List repository variables
|
||||||
- `fgj actions variable get` - Get variable values
|
- `fj actions variable get` - Get variable values
|
||||||
- `fgj actions variable create` - Create repository variables
|
- `fj actions variable create` - Create repository variables
|
||||||
- `fgj actions variable update` - Update repository variables
|
- `fj actions variable update` - Update repository variables
|
||||||
- `fgj actions variable delete` - Delete repository variables
|
- `fj actions variable delete` - Delete repository variables
|
||||||
|
|
||||||
#### Authentication
|
#### Authentication
|
||||||
- `fgj auth login` - Interactive authentication with Forgejo instances
|
- `fj auth login` - Interactive authentication with Forgejo instances
|
||||||
- `fgj auth status` - Check authentication status
|
- `fj auth status` - Check authentication status
|
||||||
- Environment variable support (`FGJ_HOST`, `FGJ_TOKEN`)
|
- Environment variable support (`FJ_HOST`, `FJ_TOKEN`)
|
||||||
|
|
||||||
#### Development
|
#### Development
|
||||||
- Comprehensive unit test suite
|
- 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
|
- Cobra framework for CLI structure
|
||||||
- Viper for configuration management
|
- Viper for configuration management
|
||||||
|
|
||||||
[0.3.0b]: https://forgejo.zerova.net/sid/fgj-sid/releases/tag/v0.3.0b
|
[0.3.0c]: https://forgejo.zerova.net/public/fj/releases/tag/v0.3.0c
|
||||||
[0.3.0a]: https://forgejo.zerova.net/sid/fgj-sid/releases/tag/v0.3.0a
|
[0.3.0b]: https://forgejo.zerova.net/public/fj/releases/tag/v0.3.0b
|
||||||
[0.3.0]: https://codeberg.org/romaintb/fgj/releases/tag/v0.3.0
|
[0.3.0a]: https://forgejo.zerova.net/public/fj/releases/tag/v0.3.0a
|
||||||
[0.2.0]: https://codeberg.org/romaintb/fgj/releases/tag/v0.2.0
|
[0.3.0]: https://codeberg.org/romaintb/fj/releases/tag/v0.3.0
|
||||||
[0.1.0]: https://codeberg.org/romaintb/fgj/releases/tag/v0.1.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"
|
@echo " make clean - Clean build artifacts"
|
||||||
|
|
||||||
build:
|
build:
|
||||||
go build -o bin/fgj .
|
go build -o bin/fj .
|
||||||
|
|
||||||
install: build
|
install: build
|
||||||
install -Dm755 bin/fgj /usr/bin/fgj
|
install -Dm755 bin/fj /usr/bin/fj
|
||||||
|
|
||||||
run:
|
run:
|
||||||
go 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)
|
[](https://golang.org)
|
||||||
[](LICENSE)
|
[](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
|
## Features
|
||||||
|
|
||||||
|
|
@ -13,9 +13,13 @@
|
||||||
- Pull request management (create, list, view, merge, diff, comment, review)
|
- Pull request management (create, list, view, merge, diff, comment, review)
|
||||||
- Issue tracking (create, list, view, comment, close, labels)
|
- Issue tracking (create, list, view, comment, close, labels)
|
||||||
- Repository operations (view, list, create, edit, clone, fork)
|
- 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)
|
- Forgejo Actions (workflow runs, watch/rerun/cancel, enable/disable, secrets, variables)
|
||||||
- Releases (create, upload, delete)
|
- 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
|
- Shell completions (bash, zsh, fish, PowerShell) and man pages
|
||||||
- JSON output (`--json`) for all list/view commands
|
- JSON output (`--json`) for all list/view commands
|
||||||
- Structured JSON error output (`--json-errors`) for machine consumption
|
- Structured JSON error output (`--json-errors`) for machine consumption
|
||||||
|
|
@ -29,22 +33,22 @@
|
||||||
### macOS (Homebrew)
|
### macOS (Homebrew)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
brew tap sid/fgj-sid https://forgejo.zerova.net/sid/homebrew-fgj-sid.git
|
brew tap public/sid git@forgejo.zerova.net:public/homebrew-sid.git
|
||||||
brew install fgj
|
brew install fj
|
||||||
```
|
```
|
||||||
|
|
||||||
### Using Go Install
|
### Using Go Install
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
go install forgejo.zerova.net/sid/fgj-sid@latest
|
go install forgejo.zerova.net/public/fj@latest
|
||||||
```
|
```
|
||||||
|
|
||||||
### From Source
|
### From Source
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://forgejo.zerova.net/sid/fgj-sid.git
|
git clone https://forgejo.zerova.net/public/fj.git
|
||||||
cd fgj-sid
|
cd fj
|
||||||
go build -o fgj .
|
go build -o fj .
|
||||||
```
|
```
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
@ -54,7 +58,7 @@ go build -o fgj .
|
||||||
First, authenticate with your Forgejo or Gitea instance:
|
First, authenticate with your Forgejo or Gitea instance:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
fgj auth login
|
fj auth login
|
||||||
```
|
```
|
||||||
|
|
||||||
You'll be prompted for:
|
You'll be prompted for:
|
||||||
|
|
@ -70,34 +74,34 @@ To create a personal access token:
|
||||||
### 2. Check Authentication Status
|
### 2. Check Authentication Status
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
fgj auth status
|
fj auth status
|
||||||
```
|
```
|
||||||
|
|
||||||
### Auth Helpers
|
### Auth Helpers
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Print the stored token for the current host
|
# Print the stored token for the current host
|
||||||
fgj auth token
|
fj auth token
|
||||||
|
|
||||||
# Remove authentication for a host
|
# Remove authentication for a host
|
||||||
fgj auth logout
|
fj auth logout
|
||||||
```
|
```
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
### Repository Detection
|
### 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
|
```bash
|
||||||
# When inside a git repository, no -R flag needed!
|
# When inside a git repository, no -R flag needed!
|
||||||
cd /path/to/your/repo
|
cd /path/to/your/repo
|
||||||
fgj pr list # Automatically uses current repo
|
fj pr list # Automatically uses current repo
|
||||||
fgj issue list # Automatically uses current repo
|
fj issue list # Automatically uses current repo
|
||||||
fgj pr view 123 # Automatically uses current repo
|
fj pr view 123 # Automatically uses current repo
|
||||||
|
|
||||||
# Or explicitly specify a repository with -R
|
# 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.
|
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
|
```bash
|
||||||
# List pull requests (auto-detects repo and hostname from git)
|
# List pull requests (auto-detects repo and hostname from git)
|
||||||
fgj pr list
|
fj pr list
|
||||||
|
|
||||||
# Or specify explicitly
|
# Or specify explicitly
|
||||||
fgj pr list -R owner/repo
|
fj pr list -R owner/repo
|
||||||
|
|
||||||
# Filter by state
|
# Filter by state
|
||||||
fgj pr list --state closed
|
fj pr list --state closed
|
||||||
|
|
||||||
# View a specific pull request
|
# View a specific pull request
|
||||||
fgj pr view 123
|
fj pr view 123
|
||||||
|
|
||||||
# Create a pull request
|
# 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
|
# Merge a pull request
|
||||||
fgj pr merge 123 --merge-method squash
|
fj pr merge 123 --merge-method squash
|
||||||
|
|
||||||
# View PR diff
|
# View PR diff
|
||||||
fgj pr diff 123
|
fj pr diff 123
|
||||||
|
|
||||||
# View diff with color
|
# View diff with color
|
||||||
fgj pr diff 123 --color always
|
fj pr diff 123 --color always
|
||||||
|
|
||||||
# Show only changed file names
|
# Show only changed file names
|
||||||
fgj pr diff 123 --name-only
|
fj pr diff 123 --name-only
|
||||||
|
|
||||||
# Show diffstat summary
|
# Show diffstat summary
|
||||||
fgj pr diff 123 --stat
|
fj pr diff 123 --stat
|
||||||
|
|
||||||
# Comment on a pull request
|
# 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
|
# 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
|
# Approve a pull request
|
||||||
fgj pr review 123 --approve -b "LGTM"
|
fj pr review 123 --approve -b "LGTM"
|
||||||
|
|
||||||
# Request changes
|
# 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)
|
# 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
|
### Issues
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# List issues (auto-detects repo and hostname from git)
|
# List issues (auto-detects repo and hostname from git)
|
||||||
fgj issue list
|
fj issue list
|
||||||
|
|
||||||
# Or specify explicitly
|
# Or specify explicitly
|
||||||
fgj issue list -R owner/repo
|
fj issue list -R owner/repo
|
||||||
|
|
||||||
# Filter by state
|
# Filter by state
|
||||||
fgj issue list --state all
|
fj issue list --state all
|
||||||
|
|
||||||
# View an issue
|
# View an issue
|
||||||
fgj issue view 456
|
fj issue view 456
|
||||||
|
|
||||||
# Create an issue
|
# 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
|
# 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
|
# Comment on an issue
|
||||||
fgj issue comment 456 -b "My comment"
|
fj issue comment 456 -b "My comment"
|
||||||
|
|
||||||
# Close an issue
|
# Close an issue
|
||||||
fgj issue close 456
|
fj issue close 456
|
||||||
|
|
||||||
# Close an issue with a comment
|
# 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)
|
# Edit an issue (title, body, state, labels)
|
||||||
fgj issue edit 456 -t "New Title"
|
fj issue edit 456 -t "New Title"
|
||||||
fgj issue edit 456 --add-label priority --remove-label bug
|
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
|
### Repositories
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# View repository details
|
# View repository details
|
||||||
fgj repo view owner/repo
|
fj repo view owner/repo
|
||||||
|
|
||||||
# List your repositories
|
# List your repositories
|
||||||
fgj repo list
|
fj repo list
|
||||||
|
|
||||||
# Create a repository
|
# Create a repository
|
||||||
fgj repo create my-repo
|
fj repo create my-repo
|
||||||
fgj repo create my-repo -d "My project" --private --add-readme -g Go -l MIT
|
fj repo create my-repo -d "My project" --private --add-readme -g Go -l MIT
|
||||||
|
|
||||||
# Clone a repository
|
# Clone a repository
|
||||||
fgj repo clone owner/repo
|
fj repo clone owner/repo
|
||||||
|
|
||||||
# Clone via SSH
|
# Clone via SSH
|
||||||
fgj repo clone owner/repo -p ssh
|
fj repo clone owner/repo -p ssh
|
||||||
|
|
||||||
# Fork a repository
|
# Fork a repository
|
||||||
fgj repo fork owner/repo
|
fj repo fork owner/repo
|
||||||
|
|
||||||
# Edit repository settings
|
# Edit repository settings
|
||||||
fgj repo edit owner/repo --public
|
fj repo edit owner/repo --public
|
||||||
fgj repo edit owner/repo --private
|
fj repo edit owner/repo --private
|
||||||
fgj repo edit owner/repo -d "New description" --homepage https://example.com
|
fj repo edit owner/repo -d "New description" --homepage https://example.com
|
||||||
fgj repo edit --default-branch develop
|
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
|
### Releases
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# List releases
|
# List releases
|
||||||
fgj release list
|
fj release list
|
||||||
|
|
||||||
# View a release (or use "latest")
|
# 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
|
# 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
|
# 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)
|
# Delete a release (keeps the Git tag)
|
||||||
fgj release delete v1.2.3
|
fj release delete v1.2.3
|
||||||
```
|
```
|
||||||
|
|
||||||
### Forgejo Actions
|
### Forgejo Actions
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# List workflows
|
# List workflows
|
||||||
fgj actions workflow list
|
fj actions workflow list
|
||||||
|
|
||||||
# View a workflow
|
# View a workflow
|
||||||
fgj actions workflow view ci.yml
|
fj actions workflow view ci.yml
|
||||||
|
|
||||||
# Run a workflow (trigger workflow_dispatch)
|
# Run a workflow (trigger workflow_dispatch)
|
||||||
fgj actions workflow run deploy.yml
|
fj actions workflow run deploy.yml
|
||||||
|
|
||||||
# Run a workflow with inputs
|
# 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
|
# 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
|
# Enable or disable a workflow
|
||||||
fgj actions workflow enable ci.yml
|
fj actions workflow enable ci.yml
|
||||||
fgj actions workflow disable ci.yml
|
fj actions workflow disable ci.yml
|
||||||
|
|
||||||
# List workflow runs
|
# List workflow runs
|
||||||
fgj actions run list
|
fj actions run list
|
||||||
|
|
||||||
# View a specific run
|
# View a specific run
|
||||||
fgj actions run view 123
|
fj actions run view 123
|
||||||
|
|
||||||
# View run with job details
|
# View run with job details
|
||||||
fgj actions run view 123 --verbose
|
fj actions run view 123 --verbose
|
||||||
|
|
||||||
# View run logs
|
# View run logs
|
||||||
fgj actions run view 123 --log
|
fj actions run view 123 --log
|
||||||
|
|
||||||
# View specific job logs
|
# 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
|
# Watch a run until completion
|
||||||
fgj actions run watch 123
|
fj actions run watch 123
|
||||||
|
|
||||||
# Rerun a workflow run
|
# Rerun a workflow run
|
||||||
fgj actions run rerun 123
|
fj actions run rerun 123
|
||||||
|
|
||||||
# Cancel a running workflow
|
# Cancel a running workflow
|
||||||
fgj actions run cancel 123
|
fj actions run cancel 123
|
||||||
|
|
||||||
# List secrets
|
# List secrets
|
||||||
fgj actions secret list
|
fj actions secret list
|
||||||
|
|
||||||
# Create a secret
|
# Create a secret
|
||||||
fgj actions secret create MY_SECRET
|
fj actions secret create MY_SECRET
|
||||||
|
|
||||||
# Delete a secret
|
# Delete a secret
|
||||||
fgj actions secret delete MY_SECRET
|
fj actions secret delete MY_SECRET
|
||||||
|
|
||||||
# List variables
|
# List variables
|
||||||
fgj actions variable list
|
fj actions variable list
|
||||||
|
|
||||||
# Get a variable
|
# Get a variable
|
||||||
fgj actions variable get MY_VAR
|
fj actions variable get MY_VAR
|
||||||
|
|
||||||
# Create a variable
|
# Create a variable
|
||||||
fgj actions variable create MY_VAR "value"
|
fj actions variable create MY_VAR "value"
|
||||||
|
|
||||||
# Update a variable
|
# Update a variable
|
||||||
fgj actions variable update MY_VAR "new value"
|
fj actions variable update MY_VAR "new value"
|
||||||
|
|
||||||
# Delete a variable
|
# Delete a variable
|
||||||
fgj actions variable delete MY_VAR
|
fj actions variable delete MY_VAR
|
||||||
```
|
```
|
||||||
|
|
||||||
### Raw API Access
|
### Raw API Access
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# GET request (auto-detects owner/repo from git context)
|
# GET request (auto-detects owner/repo from git context)
|
||||||
fgj api /repos/{owner}/{repo}/pulls
|
fj api /repos/{owner}/{repo}/pulls
|
||||||
|
|
||||||
# POST with fields
|
# 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
|
# 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
|
# 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
|
# 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
|
# Include response headers
|
||||||
fgj api /repos/{owner}/{repo} -i
|
fj api /repos/{owner}/{repo} -i
|
||||||
|
|
||||||
# Suppress output (useful for DELETE)
|
# 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
|
## Shell Completions and Man Pages
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Generate shell completion scripts
|
# Generate shell completion scripts
|
||||||
fgj completion bash > /etc/bash_completion.d/fgj
|
fj completion bash > /etc/bash_completion.d/fj
|
||||||
fgj completion zsh > "${fpath[1]}/_fgj"
|
fj completion zsh > "${fpath[1]}/_fj"
|
||||||
fgj completion fish > ~/.config/fish/completions/fgj.fish
|
fj completion fish > ~/.config/fish/completions/fj.fish
|
||||||
|
|
||||||
# Generate man pages to a directory
|
# Generate man pages to a directory
|
||||||
fgj manpages --dir ~/.local/share/man/man1
|
fj manpages --dir ~/.local/share/man/man1
|
||||||
```
|
```
|
||||||
|
|
||||||
## JSON Output
|
## JSON Output
|
||||||
|
|
@ -347,15 +418,15 @@ fgj manpages --dir ~/.local/share/man/man1
|
||||||
Most list and view commands support `--json` for machine-readable output:
|
Most list and view commands support `--json` for machine-readable output:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
fgj pr list --json
|
fj pr list --json
|
||||||
fgj issue view 456 --json
|
fj issue view 456 --json
|
||||||
fgj release list --json
|
fj release list --json
|
||||||
fgj actions run list --json
|
fj actions run list --json
|
||||||
fgj actions workflow view ci.yml --json
|
fj actions workflow view ci.yml --json
|
||||||
|
|
||||||
# Get JSON output from PR comment/review
|
# Get JSON output from PR comment/review
|
||||||
fgj pr comment 123 -b "LGTM" --json
|
fj pr comment 123 -b "LGTM" --json
|
||||||
fgj pr review 123 --approve -b "Ship it" --json
|
fj pr review 123 --approve -b "Ship it" --json
|
||||||
```
|
```
|
||||||
|
|
||||||
### Structured Error Output
|
### Structured Error Output
|
||||||
|
|
@ -364,16 +435,16 @@ For machine consumption (ideal for AI agents and scripts), use `--json-errors` t
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Errors are written to stderr as JSON
|
# 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}}
|
# stderr: {"error":{"code":"not_found","message":"...","status":404}}
|
||||||
|
|
||||||
# Combine with --json for fully machine-readable I/O
|
# Combine with --json for fully machine-readable I/O
|
||||||
fgj pr list --json --json-errors
|
fj pr list --json --json-errors
|
||||||
```
|
```
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
Configuration is stored in `~/.config/fgj/config.yaml`:
|
Configuration is stored in `~/.config/fj/config.yaml`:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
hosts:
|
hosts:
|
||||||
|
|
@ -382,38 +453,71 @@ hosts:
|
||||||
token: your_token_here
|
token: your_token_here
|
||||||
user: your_username
|
user: your_username
|
||||||
git_protocol: ssh
|
git_protocol: ssh
|
||||||
|
match_dirs:
|
||||||
|
- / # catch-all: use this host when no git remote is detected
|
||||||
codeberg.org:
|
codeberg.org:
|
||||||
hostname: codeberg.org
|
hostname: codeberg.org
|
||||||
token: another_token
|
token: another_token
|
||||||
user: another_username
|
user: another_username
|
||||||
git_protocol: https
|
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
|
### Environment Variables
|
||||||
|
|
||||||
- `FGJ_HOST`: Override the default instance (auto-detected from git remote if not set)
|
- `FJ_HOST`: Override the default instance (auto-detected from git remote if not set)
|
||||||
- `FGJ_TOKEN`: Provide authentication token
|
- `FJ_TOKEN`: Provide authentication token
|
||||||
|
|
||||||
Hostname is resolved in this priority order:
|
Hostname is resolved in this priority order:
|
||||||
1. Command-specific flags (e.g., `--hostname`)
|
1. Command-specific flags (e.g., `--hostname`)
|
||||||
2. `FGJ_HOST` environment variable
|
2. `FJ_HOST` environment variable
|
||||||
3. Auto-detected from git remote URL
|
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
|
### Command-line Flags
|
||||||
|
|
||||||
- `--hostname`: Specify instance for a command (overrides auto-detection and environment variables)
|
- `--hostname`: Specify instance for a command (overrides auto-detection and environment variables)
|
||||||
- `--config`: Use a custom config file
|
- `--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
|
## 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
|
```bash
|
||||||
# Create PR from agent's changes
|
# 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
|
## Summary
|
||||||
- Added new feature X
|
- Added new feature X
|
||||||
- Fixed bug Y
|
- Fixed bug Y
|
||||||
|
|
@ -423,29 +527,29 @@ EOF
|
||||||
)" --json
|
)" --json
|
||||||
|
|
||||||
# Check PR status during development
|
# 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
|
# Review a PR diff, then approve
|
||||||
fgj pr diff 123
|
fj pr diff 123
|
||||||
fgj pr review 123 --approve -b "LGTM" --json
|
fj pr review 123 --approve -b "LGTM" --json
|
||||||
|
|
||||||
# Post review feedback
|
# 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
|
# 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
|
# Use raw API for anything not covered by commands
|
||||||
fgj api /repos/{owner}/{repo}/topics --json-errors
|
fj 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}/labels -X POST -f name=agent-reviewed -f color="#00ff00"
|
||||||
|
|
||||||
# Fully machine-readable error handling
|
# 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
|
## 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 Forgejo instances
|
||||||
- Self-hosted Gitea instances
|
- Self-hosted Gitea instances
|
||||||
|
|
@ -453,11 +557,11 @@ fgj pr view 9999 --json --json-errors 2>errors.json
|
||||||
|
|
||||||
## Contributing
|
## 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
|
## 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:**
|
**Not Yet Implemented:**
|
||||||
- `run delete` - Delete a workflow run
|
- `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`
|
- `pr checks`, `pr ready/draft`
|
||||||
- `issue reopen`, `issue assign`
|
- `issue reopen`, `issue assign`
|
||||||
- `release edit`, `release download`, `release generate-notes`
|
- `release edit`, `release download`, `release generate-notes`
|
||||||
- `repo delete`, `repo rename`
|
- `repo delete`
|
||||||
|
|
||||||
We welcome contributions to implement any of these features!
|
We welcome contributions to implement any of these features!
|
||||||
|
|
||||||
## Acknowledgments
|
## 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
|
## License
|
||||||
|
|
||||||
|
|
|
||||||
524
cmd/actions.go
524
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"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"forgejo.zerova.net/sid/fgj-sid/internal/config"
|
"forgejo.zerova.net/public/fj/internal/api"
|
||||||
"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/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{
|
var apiCmd = &cobra.Command{
|
||||||
Use: "api <endpoint> [flags]",
|
Use: "api <endpoint> [flags]",
|
||||||
Short: "Make an authenticated API request",
|
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.`,
|
If --field is used and no --method is specified, the method defaults to POST.`,
|
||||||
Example: ` # List pull requests for the current repository
|
Example: ` # List pull requests for the current repository
|
||||||
fgj api /repos/{owner}/{repo}/pulls
|
fj api /repos/{owner}/{repo}/pulls
|
||||||
|
|
||||||
# Create an issue
|
# 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
|
# Get a specific user
|
||||||
fgj api /users/johndoe
|
fj api /users/johndoe
|
||||||
|
|
||||||
# Use raw body from stdin
|
# 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),
|
Args: cobra.ExactArgs(1),
|
||||||
RunE: runAPI,
|
RunE: runAPI,
|
||||||
}
|
}
|
||||||
|
|
@ -50,6 +64,40 @@ func init() {
|
||||||
apiCmd.Flags().StringArrayP("header", "H", nil, "Add an HTTP request header (key:value)")
|
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().Bool("silent", false, "Do not print the response body")
|
||||||
apiCmd.Flags().BoolP("include", "i", false, "Include HTTP response headers in the output")
|
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 {
|
func runAPI(cmd *cobra.Command, args []string) error {
|
||||||
|
|
@ -72,7 +120,7 @@ func runAPI(cmd *cobra.Command, args []string) error {
|
||||||
|
|
||||||
detectedHost := getDetectedHost()
|
detectedHost := getDetectedHost()
|
||||||
|
|
||||||
host, err := cfg.GetHost(hostname, detectedHost)
|
host, err := cfg.GetHost(hostname, detectedHost, getCwd())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -139,15 +187,28 @@ func runAPI(cmd *cobra.Command, args []string) error {
|
||||||
body = bytes.NewReader(bodyBytes)
|
body = bytes.NewReader(bodyBytes)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build URL
|
// Build the request URL safely. Naive concatenation lets endpoints like
|
||||||
baseURL := "https://" + host.Hostname + "/api/v1"
|
// "/../admin/users" escape the /api/v1 base via Go's URL normalization
|
||||||
if !strings.HasPrefix(endpoint, "/") {
|
// of `..` segments — silently sending authenticated traffic to non-API
|
||||||
endpoint = "/" + endpoint
|
// 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
|
// Create HTTP request
|
||||||
req, err := http.NewRequest(method, url, body)
|
req, err := http.NewRequest(method, final.String(), body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to create request: %w", err)
|
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))
|
req.Header.Set(strings.TrimSpace(key), strings.TrimSpace(value))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Execute request
|
paginate, _ := cmd.Flags().GetBool("paginate")
|
||||||
httpClient := &http.Client{}
|
if paginate && method != http.MethodGet {
|
||||||
resp, err := httpClient.Do(req)
|
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 {
|
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() }()
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
|
||||||
// Print response headers if requested
|
body, err = io.ReadAll(io.LimitReader(resp.Body, maxAPIResponseBytes+1))
|
||||||
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)
|
|
||||||
if err != nil {
|
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
|
respBody, respHeader, statusCode, proto, status, err := doOnce(req)
|
||||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
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 {
|
if !silent {
|
||||||
fmt.Fprint(os.Stderr, string(respBody))
|
fmt.Fprint(ios.ErrOut, string(respBody))
|
||||||
if len(respBody) > 0 && respBody[len(respBody)-1] != '\n' {
|
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 {
|
if silent || len(respBody) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pretty-print JSON, or output raw if not JSON
|
contentType := respHeader.Get("Content-Type")
|
||||||
contentType := resp.Header.Get("Content-Type")
|
isJSON := strings.Contains(contentType, "json") || json.Valid(respBody)
|
||||||
if 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
|
var parsed any
|
||||||
if err := json.Unmarshal(respBody, &parsed); err == nil {
|
if err := json.Unmarshal(respBody, &parsed); err == nil {
|
||||||
enc := json.NewEncoder(os.Stdout)
|
return writeJSON(parsed)
|
||||||
enc.SetIndent("", " ")
|
|
||||||
return enc.Encode(parsed)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Raw output for non-JSON responses
|
_, err = ios.Out.Write(respBody)
|
||||||
_, err = os.Stdout.Write(respBody)
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
50
cmd/auth.go
50
cmd/auth.go
|
|
@ -7,8 +7,8 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
|
|
||||||
"forgejo.zerova.net/sid/fgj-sid/internal/api"
|
"forgejo.zerova.net/public/fj/internal/api"
|
||||||
"forgejo.zerova.net/sid/fgj-sid/internal/config"
|
"forgejo.zerova.net/public/fj/internal/config"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"github.com/spf13/viper"
|
"github.com/spf13/viper"
|
||||||
"golang.org/x/term"
|
"golang.org/x/term"
|
||||||
|
|
@ -16,7 +16,7 @@ import (
|
||||||
|
|
||||||
var authCmd = &cobra.Command{
|
var authCmd = &cobra.Command{
|
||||||
Use: "auth",
|
Use: "auth",
|
||||||
Short: "Authenticate fgj with a Forgejo instance",
|
Short: "Authenticate fj with a Forgejo instance",
|
||||||
Long: "Manage authentication state for Forgejo instances.",
|
Long: "Manage authentication state for Forgejo instances.",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -55,20 +55,29 @@ func init() {
|
||||||
authCmd.AddCommand(authLogoutCmd)
|
authCmd.AddCommand(authLogoutCmd)
|
||||||
authCmd.AddCommand(authTokenCmd)
|
authCmd.AddCommand(authTokenCmd)
|
||||||
|
|
||||||
authLoginCmd.Flags().String("hostname", "", "Forgejo instance hostname (e.g., codeberg.org)")
|
// --hostname is a persistent flag on rootCmd (cmd/root.go). Don't
|
||||||
authLoginCmd.Flags().StringP("token", "t", "", "Personal access token")
|
// re-declare it on auth subcommands — local flags shadow the persistent
|
||||||
authLogoutCmd.Flags().String("hostname", "", "Forgejo instance hostname (e.g., codeberg.org)")
|
// one, so `fj --hostname=X auth login` and `fj auth login --hostname=X`
|
||||||
authTokenCmd.Flags().String("hostname", "", "Forgejo instance hostname (e.g., codeberg.org)")
|
// 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 {
|
func runAuthLogin(cmd *cobra.Command, args []string) error {
|
||||||
hostname, _ := cmd.Flags().GetString("hostname")
|
hostname, _ := cmd.Flags().GetString("hostname")
|
||||||
token, _ := cmd.Flags().GetString("token")
|
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)
|
reader := bufio.NewReader(os.Stdin)
|
||||||
|
|
||||||
if hostname == "" {
|
if hostname == "" {
|
||||||
fmt.Print("Forgejo instance hostname (default: codeberg.org): ")
|
fmt.Fprint(ios.ErrOut, "Forgejo instance hostname (default: codeberg.org): ")
|
||||||
input, _ := reader.ReadString('\n')
|
input, _ := reader.ReadString('\n')
|
||||||
hostname = strings.TrimSpace(input)
|
hostname = strings.TrimSpace(input)
|
||||||
if hostname == "" {
|
if hostname == "" {
|
||||||
|
|
@ -77,12 +86,12 @@ func runAuthLogin(cmd *cobra.Command, args []string) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
if token == "" {
|
if token == "" {
|
||||||
fmt.Print("Personal access token: ")
|
fmt.Fprint(ios.ErrOut, "Personal access token: ")
|
||||||
tokenBytes, err := term.ReadPassword(int(syscall.Stdin))
|
tokenBytes, err := term.ReadPassword(int(syscall.Stdin))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to read token: %w", err)
|
return fmt.Errorf("failed to read token: %w", err)
|
||||||
}
|
}
|
||||||
fmt.Println()
|
fmt.Fprintln(ios.ErrOut)
|
||||||
token = strings.TrimSpace(string(tokenBytes))
|
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)
|
return fmt.Errorf("failed to create client: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ios.StartSpinner("Authenticating...")
|
||||||
user, _, err := client.GetMyUserInfo()
|
user, _, err := client.GetMyUserInfo()
|
||||||
|
ios.StopSpinner()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("authentication failed: %w", err)
|
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)
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
@ -128,14 +140,15 @@ func runAuthStatus(cmd *cobra.Command, args []string) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(cfg.Hosts) == 0 {
|
if len(cfg.Hosts) == 0 {
|
||||||
fmt.Println("Not authenticated with any Forgejo instances")
|
fmt.Fprintln(ios.Out, "Not authenticated with any Forgejo instances")
|
||||||
fmt.Println("Run 'fgj auth login' to authenticate")
|
fmt.Fprintln(ios.Out, "Run 'fj auth login' to authenticate")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Println("Authenticated instances:")
|
fmt.Fprintln(ios.Out, "Authenticated instances:")
|
||||||
for hostname, host := range cfg.Hosts {
|
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
|
return nil
|
||||||
|
|
@ -158,7 +171,8 @@ func runAuthLogout(cmd *cobra.Command, args []string) error {
|
||||||
return fmt.Errorf("failed to save config: %w", err)
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -174,7 +188,7 @@ func runAuthToken(cmd *cobra.Command, args []string) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Println(cfg.Hosts[resolved].Token)
|
fmt.Fprintln(ios.Out, cfg.Hosts[resolved].Token)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -183,7 +197,7 @@ func resolveAuthHostname(cfg *config.Config, hostname string) (string, error) {
|
||||||
hostname = viper.GetString("hostname")
|
hostname = viper.GetString("hostname")
|
||||||
}
|
}
|
||||||
if hostname == "" {
|
if hostname == "" {
|
||||||
hostname = os.Getenv("FGJ_HOST")
|
hostname = config.EnvWithFallback("FJ_HOST", "FGJ_HOST")
|
||||||
}
|
}
|
||||||
if hostname == "" {
|
if hostname == "" {
|
||||||
hostname = getDetectedHost()
|
hostname = getDetectedHost()
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ import (
|
||||||
var completionCmd = &cobra.Command{
|
var completionCmd = &cobra.Command{
|
||||||
Use: "completion [bash|zsh|fish|powershell]",
|
Use: "completion [bash|zsh|fish|powershell]",
|
||||||
Short: "Generate shell completion scripts",
|
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),
|
Args: cobra.MatchAll(cobra.ExactArgs(1), cobra.OnlyValidArgs),
|
||||||
ValidArgs: []string{"bash", "zsh", "fish", "powershell"},
|
ValidArgs: []string{"bash", "zsh", "fish", "powershell"},
|
||||||
DisableFlagsInUseLine: true,
|
DisableFlagsInUseLine: true,
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,9 @@ package cmd
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"os"
|
"strings"
|
||||||
|
|
||||||
"forgejo.zerova.net/sid/fgj-sid/internal/api"
|
"forgejo.zerova.net/public/fj/internal/api"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Error codes for structured error output.
|
// Error codes for structured error output.
|
||||||
|
|
@ -24,9 +24,15 @@ type CLIError struct {
|
||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
Detail string `json:"detail,omitempty"`
|
Detail string `json:"detail,omitempty"`
|
||||||
Status int `json:"status,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 {
|
func (e *CLIError) Error() string {
|
||||||
|
if e.Hint != "" {
|
||||||
|
return e.Message + "\nHint: " + e.Hint
|
||||||
|
}
|
||||||
return e.Message
|
return e.Message
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -40,8 +46,60 @@ func NewAPIError(status int, message string) *CLIError {
|
||||||
return &CLIError{Code: ErrAPIError, Message: message, Status: status}
|
return &CLIError{Code: ErrAPIError, Message: message, Status: status}
|
||||||
}
|
}
|
||||||
|
|
||||||
// writeJSONError writes a structured JSON error to stderr.
|
// ContextualError wraps common errors with helpful hints.
|
||||||
// It attempts to extract structured info from known error types.
|
//
|
||||||
|
// 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.
|
// WriteJSONError writes a structured JSON error to stderr.
|
||||||
// It is exported for use from main.go.
|
// It is exported for use from main.go.
|
||||||
func WriteJSONError(err error) {
|
func WriteJSONError(err error) {
|
||||||
|
|
@ -50,7 +108,9 @@ func WriteJSONError(err error) {
|
||||||
Message: 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 apiErr *api.APIError
|
||||||
var cErr *CLIError
|
var cErr *CLIError
|
||||||
|
|
||||||
|
|
@ -65,12 +125,13 @@ func WriteJSONError(err error) {
|
||||||
cliErr.Code = ErrAuthRequired
|
cliErr.Code = ErrAuthRequired
|
||||||
case apiErr.StatusCode == 404:
|
case apiErr.StatusCode == 404:
|
||||||
cliErr.Code = ErrNotFound
|
cliErr.Code = ErrNotFound
|
||||||
default:
|
|
||||||
cliErr.Code = ErrAPIError
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enc := json.NewEncoder(os.Stderr)
|
enc := json.NewEncoder(ios.ErrOut)
|
||||||
enc.SetIndent("", " ")
|
enc.SetIndent("", " ")
|
||||||
_ = enc.Encode(cliErr)
|
_ = 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 (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"net/http"
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
"text/tabwriter"
|
|
||||||
|
|
||||||
"code.gitea.io/sdk/gitea"
|
"code.gitea.io/sdk/gitea"
|
||||||
"forgejo.zerova.net/sid/fgj-sid/internal/api"
|
"forgejo.zerova.net/public/fj/internal/api"
|
||||||
"forgejo.zerova.net/sid/fgj-sid/internal/config"
|
"forgejo.zerova.net/public/fj/internal/config"
|
||||||
|
"forgejo.zerova.net/public/fj/internal/text"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -23,6 +22,14 @@ var issueListCmd = &cobra.Command{
|
||||||
Use: "list [flags]",
|
Use: "list [flags]",
|
||||||
Short: "List issues",
|
Short: "List issues",
|
||||||
Long: "List issues in a repository.",
|
Long: "List issues in a repository.",
|
||||||
|
Example: ` # List open issues
|
||||||
|
fj issue list
|
||||||
|
|
||||||
|
# List closed issues for a specific repo
|
||||||
|
fj issue list -s closed -R owner/repo
|
||||||
|
|
||||||
|
# Output as JSON
|
||||||
|
fj issue list --json`,
|
||||||
RunE: runIssueList,
|
RunE: runIssueList,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -30,6 +37,17 @@ var issueViewCmd = &cobra.Command{
|
||||||
Use: "view <number>",
|
Use: "view <number>",
|
||||||
Short: "View an issue",
|
Short: "View an issue",
|
||||||
Long: "Display detailed information about 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),
|
Args: cobra.ExactArgs(1),
|
||||||
RunE: runIssueView,
|
RunE: runIssueView,
|
||||||
}
|
}
|
||||||
|
|
@ -38,6 +56,11 @@ var issueCreateCmd = &cobra.Command{
|
||||||
Use: "create",
|
Use: "create",
|
||||||
Short: "Create an issue",
|
Short: "Create an issue",
|
||||||
Long: "Create a new issue.",
|
Long: "Create a new issue.",
|
||||||
|
Example: ` # Create an issue with a title
|
||||||
|
fj issue create -t "Fix login bug"
|
||||||
|
|
||||||
|
# Create an issue with title, body, and labels
|
||||||
|
fj issue create -t "Add dark mode" -b "We need a dark theme" -l feature -l ui`,
|
||||||
RunE: runIssueCreate,
|
RunE: runIssueCreate,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -45,6 +68,11 @@ var issueCommentCmd = &cobra.Command{
|
||||||
Use: "comment <number>",
|
Use: "comment <number>",
|
||||||
Short: "Add a comment to an issue",
|
Short: "Add a comment to an issue",
|
||||||
Long: "Add a comment to an existing 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),
|
Args: cobra.ExactArgs(1),
|
||||||
RunE: runIssueComment,
|
RunE: runIssueComment,
|
||||||
}
|
}
|
||||||
|
|
@ -53,14 +81,53 @@ var issueCloseCmd = &cobra.Command{
|
||||||
Use: "close <number>",
|
Use: "close <number>",
|
||||||
Short: "Close an issue",
|
Short: "Close an issue",
|
||||||
Long: "Close an existing 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),
|
Args: cobra.ExactArgs(1),
|
||||||
RunE: runIssueClose,
|
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{
|
var issueEditCmd = &cobra.Command{
|
||||||
Use: "edit <number>",
|
Use: "edit <number>",
|
||||||
Short: "Edit an issue",
|
Short: "Edit an issue",
|
||||||
Long: "Edit an existing issue's title, body, or state.",
|
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),
|
Args: cobra.ExactArgs(1),
|
||||||
RunE: runIssueEdit,
|
RunE: runIssueEdit,
|
||||||
}
|
}
|
||||||
|
|
@ -72,19 +139,31 @@ func init() {
|
||||||
issueCmd.AddCommand(issueCreateCmd)
|
issueCmd.AddCommand(issueCreateCmd)
|
||||||
issueCmd.AddCommand(issueCommentCmd)
|
issueCmd.AddCommand(issueCommentCmd)
|
||||||
issueCmd.AddCommand(issueCloseCmd)
|
issueCmd.AddCommand(issueCloseCmd)
|
||||||
|
issueCmd.AddCommand(issueReopenCmd)
|
||||||
|
issueCmd.AddCommand(issueDeleteCmd)
|
||||||
issueCmd.AddCommand(issueEditCmd)
|
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("repo", "R", "", "Repository in owner/name format")
|
||||||
issueListCmd.Flags().StringP("state", "s", "open", "Filter by state: open, closed, all")
|
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().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("repo", "R", "", "Repository in owner/name format")
|
||||||
issueCreateCmd.Flags().StringP("title", "t", "", "Title for the issue")
|
issueCreateCmd.Flags().StringP("title", "t", "", "Title for the issue")
|
||||||
issueCreateCmd.Flags().StringP("body", "b", "", "Body 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("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("repo", "R", "", "Repository in owner/name format")
|
||||||
issueCommentCmd.Flags().StringP("body", "b", "", "Comment body")
|
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("repo", "R", "", "Repository in owner/name format")
|
||||||
issueCloseCmd.Flags().StringP("comment", "c", "", "Comment body to add before closing")
|
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("repo", "R", "", "Repository in owner/name format")
|
||||||
issueEditCmd.Flags().StringP("title", "t", "", "New title for the issue")
|
issueEditCmd.Flags().StringP("title", "t", "", "New title for the issue")
|
||||||
issueEditCmd.Flags().StringP("body", "b", "", "New body 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().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("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().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 {
|
func runIssueList(cmd *cobra.Command, args []string) error {
|
||||||
repo, _ := cmd.Flags().GetString("repo")
|
repo, _ := cmd.Flags().GetString("repo")
|
||||||
state, _ := cmd.Flags().GetString("state")
|
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)
|
owner, name, err := parseRepo(repo)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -114,7 +203,7 @@ func runIssueList(cmd *cobra.Command, args []string) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost())
|
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -131,9 +220,27 @@ func runIssueList(cmd *cobra.Command, args []string) error {
|
||||||
return fmt.Errorf("invalid state: %s", state)
|
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,
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to list issues: %w", err)
|
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)
|
nonPRIssues = append(nonPRIssues, issue)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if limit > 0 && len(nonPRIssues) > limit {
|
||||||
|
nonPRIssues = nonPRIssues[:limit]
|
||||||
|
}
|
||||||
|
|
||||||
if jsonOutput, _ := cmd.Flags().GetBool("json"); jsonOutput {
|
if wantJSON(cmd) {
|
||||||
return writeJSON(nonPRIssues)
|
return outputJSON(cmd, nonPRIssues)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(nonPRIssues) == 0 {
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
tp := ios.NewTablePrinter()
|
||||||
_, _ = fmt.Fprintf(w, "NUMBER\tTITLE\tSTATE\n")
|
tp.AddHeader("NUMBER", "TITLE", "STATE")
|
||||||
for _, issue := range nonPRIssues {
|
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 tp.Render()
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func runIssueView(cmd *cobra.Command, args []string) error {
|
func runIssueView(cmd *cobra.Command, args []string) error {
|
||||||
repo, _ := cmd.Flags().GetString("repo")
|
repo, _ := cmd.Flags().GetString("repo")
|
||||||
issueNumber, err := strconv.ParseInt(args[0], 10, 64)
|
issueNumber, err := parseIssueArg(args[0])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("invalid issue number: %w", err)
|
return fmt.Errorf("invalid issue number: %w", err)
|
||||||
}
|
}
|
||||||
|
|
@ -181,13 +289,15 @@ func runIssueView(cmd *cobra.Command, args []string) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost())
|
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ios.StartSpinner("Fetching issue...")
|
||||||
issue, _, err := client.GetIssue(owner, name, issueNumber)
|
issue, _, err := client.GetIssue(owner, name, issueNumber)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
ios.StopSpinner()
|
||||||
return fmt.Errorf("failed to get issue: %w", err)
|
return fmt.Errorf("failed to get issue: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -196,8 +306,13 @@ func runIssueView(cmd *cobra.Command, args []string) error {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
comments = 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 {
|
payload := struct {
|
||||||
Issue *gitea.Issue `json:"issue"`
|
Issue *gitea.Issue `json:"issue"`
|
||||||
Comments []*gitea.Comment `json:"comments,omitempty"`
|
Comments []*gitea.Comment `json:"comments,omitempty"`
|
||||||
|
|
@ -205,26 +320,34 @@ func runIssueView(cmd *cobra.Command, args []string) error {
|
||||||
Issue: issue,
|
Issue: issue,
|
||||||
Comments: comments,
|
Comments: comments,
|
||||||
}
|
}
|
||||||
return writeJSON(payload)
|
return outputJSON(cmd, payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("Issue #%d\n", issue.Index)
|
if err := ios.StartPager(); err != nil {
|
||||||
fmt.Printf("Title: %s\n", issue.Title)
|
fmt.Fprintf(ios.ErrOut, "warning: failed to start pager: %v\n", err)
|
||||||
fmt.Printf("State: %s\n", issue.State)
|
}
|
||||||
fmt.Printf("Author: %s\n", issue.Poster.UserName)
|
defer ios.StopPager()
|
||||||
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"))
|
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 != "" {
|
if issue.Body != "" {
|
||||||
fmt.Printf("\n%s\n", issue.Body)
|
fmt.Fprintf(ios.Out, "\n%s\n", issue.Body)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(comments) > 0 {
|
if len(comments) > 0 {
|
||||||
fmt.Printf("\nComments (%d):\n", len(comments))
|
fmt.Fprintf(ios.Out, "\nComments (%d):\n", len(comments))
|
||||||
for _, comment := range 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.FullName,
|
||||||
comment.Poster.UserName,
|
comment.Poster.UserName,
|
||||||
comment.Created.Format("2006-01-02 15:04:05"),
|
text.FormatDate(comment.Created, isTTY),
|
||||||
comment.Body)
|
comment.Body)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -237,22 +360,36 @@ func runIssueCreate(cmd *cobra.Command, args []string) error {
|
||||||
title, _ := cmd.Flags().GetString("title")
|
title, _ := cmd.Flags().GetString("title")
|
||||||
body, _ := cmd.Flags().GetString("body")
|
body, _ := cmd.Flags().GetString("body")
|
||||||
labelNames, _ := cmd.Flags().GetStringSlice("label")
|
labelNames, _ := cmd.Flags().GetStringSlice("label")
|
||||||
|
assignees, _ := cmd.Flags().GetStringSlice("assignee")
|
||||||
|
milestoneName, _ := cmd.Flags().GetString("milestone")
|
||||||
|
|
||||||
owner, name, err := parseRepo(repo)
|
owner, name, err := parseRepo(repo)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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 == "" {
|
if title == "" {
|
||||||
return fmt.Errorf("title is required")
|
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()
|
cfg, err := config.Load()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost())
|
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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{
|
issue, _, err := client.CreateIssue(owner, name, gitea.CreateIssueOption{
|
||||||
Title: title,
|
Title: title,
|
||||||
Body: body,
|
Body: body,
|
||||||
Labels: labelIDs,
|
Labels: labelIDs,
|
||||||
|
Assignees: resolvedAssignees,
|
||||||
|
Milestone: milestoneID,
|
||||||
})
|
})
|
||||||
|
ios.StopSpinner()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to create issue: %w", err)
|
return fmt.Errorf("failed to create issue: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("Issue created: #%d\n", issue.Index)
|
cs := ios.ColorScheme()
|
||||||
fmt.Printf("View at: %s\n", issue.HTMLURL)
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
@ -283,7 +459,7 @@ func runIssueCreate(cmd *cobra.Command, args []string) error {
|
||||||
func runIssueComment(cmd *cobra.Command, args []string) error {
|
func runIssueComment(cmd *cobra.Command, args []string) error {
|
||||||
repo, _ := cmd.Flags().GetString("repo")
|
repo, _ := cmd.Flags().GetString("repo")
|
||||||
body, _ := cmd.Flags().GetString("body")
|
body, _ := cmd.Flags().GetString("body")
|
||||||
issueNumber, err := strconv.ParseInt(args[0], 10, 64)
|
issueNumber, err := parseIssueArg(args[0])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("invalid issue number: %w", err)
|
return fmt.Errorf("invalid issue number: %w", err)
|
||||||
}
|
}
|
||||||
|
|
@ -302,20 +478,23 @@ func runIssueComment(cmd *cobra.Command, args []string) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost())
|
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ios.StartSpinner("Adding comment...")
|
||||||
comment, _, err := client.CreateIssueComment(owner, name, issueNumber, gitea.CreateIssueCommentOption{
|
comment, _, err := client.CreateIssueComment(owner, name, issueNumber, gitea.CreateIssueCommentOption{
|
||||||
Body: body,
|
Body: body,
|
||||||
})
|
})
|
||||||
|
ios.StopSpinner()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to create comment: %w", err)
|
return fmt.Errorf("failed to create comment: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("Comment added to issue #%d\n", issueNumber)
|
cs := ios.ColorScheme()
|
||||||
fmt.Printf("View at: %s\n", comment.HTMLURL)
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
@ -323,7 +502,7 @@ func runIssueComment(cmd *cobra.Command, args []string) error {
|
||||||
func runIssueClose(cmd *cobra.Command, args []string) error {
|
func runIssueClose(cmd *cobra.Command, args []string) error {
|
||||||
repo, _ := cmd.Flags().GetString("repo")
|
repo, _ := cmd.Flags().GetString("repo")
|
||||||
commentBody, _ := cmd.Flags().GetString("comment")
|
commentBody, _ := cmd.Flags().GetString("comment")
|
||||||
issueNumber, err := strconv.ParseInt(args[0], 10, 64)
|
issueNumber, err := parseIssueArg(args[0])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("invalid issue number: %w", err)
|
return fmt.Errorf("invalid issue number: %w", err)
|
||||||
}
|
}
|
||||||
|
|
@ -338,29 +517,34 @@ func runIssueClose(cmd *cobra.Command, args []string) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost())
|
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if commentBody != "" {
|
if commentBody != "" {
|
||||||
|
ios.StartSpinner("Adding comment...")
|
||||||
_, _, err = client.CreateIssueComment(owner, name, issueNumber, gitea.CreateIssueCommentOption{
|
_, _, err = client.CreateIssueComment(owner, name, issueNumber, gitea.CreateIssueCommentOption{
|
||||||
Body: commentBody,
|
Body: commentBody,
|
||||||
})
|
})
|
||||||
|
ios.StopSpinner()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to create comment: %w", err)
|
return fmt.Errorf("failed to create comment: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ios.StartSpinner("Closing issue...")
|
||||||
stateClosed := gitea.StateClosed
|
stateClosed := gitea.StateClosed
|
||||||
_, _, err = client.EditIssue(owner, name, issueNumber, gitea.EditIssueOption{
|
_, _, err = client.EditIssue(owner, name, issueNumber, gitea.EditIssueOption{
|
||||||
State: &stateClosed,
|
State: &stateClosed,
|
||||||
})
|
})
|
||||||
|
ios.StopSpinner()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to close issue: %w", err)
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
@ -372,8 +556,10 @@ func runIssueEdit(cmd *cobra.Command, args []string) error {
|
||||||
stateStr, _ := cmd.Flags().GetString("state")
|
stateStr, _ := cmd.Flags().GetString("state")
|
||||||
addLabelNames, _ := cmd.Flags().GetStringSlice("add-label")
|
addLabelNames, _ := cmd.Flags().GetStringSlice("add-label")
|
||||||
removeLabelNames, _ := cmd.Flags().GetStringSlice("remove-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 {
|
if err != nil {
|
||||||
return fmt.Errorf("invalid issue number: %w", err)
|
return fmt.Errorf("invalid issue number: %w", err)
|
||||||
}
|
}
|
||||||
|
|
@ -383,8 +569,8 @@ func runIssueEdit(cmd *cobra.Command, args []string) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if title == "" && body == "" && stateStr == "" && len(addLabelNames) == 0 && len(removeLabelNames) == 0 {
|
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, or --remove-label must be provided")
|
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()
|
cfg, err := config.Load()
|
||||||
|
|
@ -392,7 +578,7 @@ func runIssueEdit(cmd *cobra.Command, args []string) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost())
|
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -420,9 +606,12 @@ func runIssueEdit(cmd *cobra.Command, args []string) error {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ios.StartSpinner("Updating issue...")
|
||||||
|
|
||||||
if title != "" || body != "" || stateStr != "" {
|
if title != "" || body != "" || stateStr != "" {
|
||||||
_, _, err = client.EditIssue(owner, name, issueNumber, editOpt)
|
_, _, err = client.EditIssue(owner, name, issueNumber, editOpt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
ios.StopSpinner()
|
||||||
return fmt.Errorf("failed to edit issue: %w", err)
|
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 {
|
if len(addLabelNames) > 0 {
|
||||||
labelIDs, err := resolveLabelIDs(client, owner, name, addLabelNames)
|
labelIDs, err := resolveLabelIDs(client, owner, name, addLabelNames)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
ios.StopSpinner()
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
_, _, err = client.AddIssueLabels(owner, name, issueNumber, gitea.IssueLabelsOption{
|
_, _, err = client.AddIssueLabels(owner, name, issueNumber, gitea.IssueLabelsOption{
|
||||||
Labels: labelIDs,
|
Labels: labelIDs,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
ios.StopSpinner()
|
||||||
return fmt.Errorf("failed to add labels: %w", err)
|
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 {
|
if len(removeLabelNames) > 0 {
|
||||||
labelIDs, err := resolveLabelIDs(client, owner, name, removeLabelNames)
|
labelIDs, err := resolveLabelIDs(client, owner, name, removeLabelNames)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
ios.StopSpinner()
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
for _, labelID := range labelIDs {
|
for _, labelID := range labelIDs {
|
||||||
_, err = client.DeleteIssueLabel(owner, name, issueNumber, labelID)
|
_, err = client.DeleteIssueLabel(owner, name, issueNumber, labelID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
ios.StopSpinner()
|
||||||
return fmt.Errorf("failed to remove label %d: %w", labelID, err)
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
148
cmd/json.go
148
cmd/json.go
|
|
@ -2,11 +2,155 @@ package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"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 {
|
func writeJSON(value any) error {
|
||||||
enc := json.NewEncoder(os.Stdout)
|
enc := json.NewEncoder(ios.Out)
|
||||||
enc.SetIndent("", " ")
|
enc.SetIndent("", " ")
|
||||||
return enc.Encode(value)
|
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{
|
var manpagesCmd = &cobra.Command{
|
||||||
Use: "manpages",
|
Use: "manpages",
|
||||||
Short: "Generate manpages",
|
Short: "Generate manpages",
|
||||||
Long: "Generate manpages for fgj commands.",
|
Long: "Generate manpages for fj commands.",
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
dir, _ := cmd.Flags().GetString("dir")
|
dir, _ := cmd.Flags().GetString("dir")
|
||||||
if dir == "" {
|
if dir == "" {
|
||||||
|
|
@ -29,7 +29,7 @@ var manpagesCmd = &cobra.Command{
|
||||||
}
|
}
|
||||||
|
|
||||||
header := &doc.GenManHeader{
|
header := &doc.GenManHeader{
|
||||||
Title: "FGJ",
|
Title: "FJ",
|
||||||
Section: "1",
|
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 (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"forgejo.zerova.net/sid/fgj-sid/internal/api"
|
"forgejo.zerova.net/public/fj/internal/api"
|
||||||
"forgejo.zerova.net/sid/fgj-sid/internal/config"
|
"forgejo.zerova.net/public/fj/internal/config"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"golang.org/x/term"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var prDiffCmd = &cobra.Command{
|
var prDiffCmd = &cobra.Command{
|
||||||
|
|
@ -17,16 +14,16 @@ var prDiffCmd = &cobra.Command{
|
||||||
Short: "Show the diff for a pull request",
|
Short: "Show the diff for a pull request",
|
||||||
Long: "Fetch and display the diff for a pull request.",
|
Long: "Fetch and display the diff for a pull request.",
|
||||||
Example: ` # View the diff for PR #123
|
Example: ` # View the diff for PR #123
|
||||||
fgj pr diff 123
|
fj pr diff 123
|
||||||
|
|
||||||
# Colorized diff output
|
# Colorized diff output
|
||||||
fgj pr diff 123 --color always
|
fj pr diff 123 --color always
|
||||||
|
|
||||||
# Show only changed file names
|
# Show only changed file names
|
||||||
fgj pr diff 123 --name-only
|
fj pr diff 123 --name-only
|
||||||
|
|
||||||
# Show diffstat summary
|
# Show diffstat summary
|
||||||
fgj pr diff 123 --stat`,
|
fj pr diff 123 --stat`,
|
||||||
Args: cobra.ExactArgs(1),
|
Args: cobra.ExactArgs(1),
|
||||||
RunE: runPRDiff,
|
RunE: runPRDiff,
|
||||||
}
|
}
|
||||||
|
|
@ -46,7 +43,7 @@ func runPRDiff(cmd *cobra.Command, args []string) error {
|
||||||
nameOnly, _ := cmd.Flags().GetBool("name-only")
|
nameOnly, _ := cmd.Flags().GetBool("name-only")
|
||||||
stat, _ := cmd.Flags().GetBool("stat")
|
stat, _ := cmd.Flags().GetBool("stat")
|
||||||
|
|
||||||
prNumber, err := strconv.ParseInt(args[0], 10, 64)
|
prNumber, err := parseIssueArg(args[0])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("invalid pull request number: %w", err)
|
return fmt.Errorf("invalid pull request number: %w", err)
|
||||||
}
|
}
|
||||||
|
|
@ -61,7 +58,7 @@ func runPRDiff(cmd *cobra.Command, args []string) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost())
|
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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",
|
diffURL := fmt.Sprintf("https://%s/api/v1/repos/%s/%s/pulls/%d.diff",
|
||||||
client.Hostname(), owner, name, prNumber)
|
client.Hostname(), owner, name, prNumber)
|
||||||
|
|
||||||
|
ios.StartSpinner("Fetching diff...")
|
||||||
diff, err := client.GetRawLog(diffURL)
|
diff, err := client.GetRawLog(diffURL)
|
||||||
|
ios.StopSpinner()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to get pull request diff: %w", err)
|
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)
|
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)
|
useColor := shouldColorize(colorMode)
|
||||||
if useColor {
|
if useColor {
|
||||||
return printColorizedDiff(diff)
|
return printColorizedDiff(diff)
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Print(diff)
|
fmt.Fprint(ios.Out, diff)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -99,7 +104,7 @@ func shouldColorize(mode string) bool {
|
||||||
case "never":
|
case "never":
|
||||||
return false
|
return false
|
||||||
default: // "auto"
|
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/")
|
name := strings.TrimPrefix(line, "+++ b/")
|
||||||
if name != "" && !seen[name] {
|
if name != "" && !seen[name] {
|
||||||
seen[name] = true
|
seen[name] = true
|
||||||
fmt.Println(name)
|
fmt.Fprintln(ios.Out, name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -165,10 +170,12 @@ func printDiffStat(diff string) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(stats) == 0 {
|
if len(stats) == 0 {
|
||||||
fmt.Println("0 files changed")
|
fmt.Fprintln(ios.Out, "0 files changed")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cs := ios.ColorScheme()
|
||||||
|
|
||||||
// Find the longest file name for alignment
|
// Find the longest file name for alignment
|
||||||
maxNameLen := 0
|
maxNameLen := 0
|
||||||
maxChanges := 0
|
maxChanges := 0
|
||||||
|
|
@ -210,44 +217,36 @@ func printDiffStat(diff string) error {
|
||||||
scaledDel = 1
|
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 {
|
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 {
|
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 {
|
if totalDeletions != 1 {
|
||||||
fmt.Print("s")
|
fmt.Fprint(ios.Out, "s")
|
||||||
}
|
}
|
||||||
fmt.Println("(-)")
|
fmt.Fprintln(ios.Out, "(-)")
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ANSI color codes for diff output.
|
// printColorizedDiff prints the diff with ANSI color codes using ColorScheme.
|
||||||
const (
|
|
||||||
colorReset = "\033[0m"
|
|
||||||
colorRed = "\033[31m"
|
|
||||||
colorGreen = "\033[32m"
|
|
||||||
colorCyan = "\033[36m"
|
|
||||||
colorBold = "\033[1m"
|
|
||||||
)
|
|
||||||
|
|
||||||
// printColorizedDiff prints the diff with ANSI color codes.
|
|
||||||
func printColorizedDiff(diff string) error {
|
func printColorizedDiff(diff string) error {
|
||||||
|
cs := ios.ColorScheme()
|
||||||
for _, line := range strings.Split(diff, "\n") {
|
for _, line := range strings.Split(diff, "\n") {
|
||||||
switch {
|
switch {
|
||||||
case strings.HasPrefix(line, "diff --git "):
|
case strings.HasPrefix(line, "diff --git "):
|
||||||
fmt.Println(colorBold + line + colorReset)
|
fmt.Fprintln(ios.Out, cs.Bold(line))
|
||||||
case strings.HasPrefix(line, "index "),
|
case strings.HasPrefix(line, "index "),
|
||||||
strings.HasPrefix(line, "--- "),
|
strings.HasPrefix(line, "--- "),
|
||||||
strings.HasPrefix(line, "+++ "),
|
strings.HasPrefix(line, "+++ "),
|
||||||
|
|
@ -256,15 +255,15 @@ func printColorizedDiff(diff string) error {
|
||||||
strings.HasPrefix(line, "similarity index"),
|
strings.HasPrefix(line, "similarity index"),
|
||||||
strings.HasPrefix(line, "rename from"),
|
strings.HasPrefix(line, "rename from"),
|
||||||
strings.HasPrefix(line, "rename to"):
|
strings.HasPrefix(line, "rename to"):
|
||||||
fmt.Println(colorBold + line + colorReset)
|
fmt.Fprintln(ios.Out, cs.Bold(line))
|
||||||
case strings.HasPrefix(line, "@@"):
|
case strings.HasPrefix(line, "@@"):
|
||||||
fmt.Println(colorCyan + line + colorReset)
|
fmt.Fprintln(ios.Out, cs.Cyan(line))
|
||||||
case strings.HasPrefix(line, "+"):
|
case strings.HasPrefix(line, "+"):
|
||||||
fmt.Println(colorGreen + line + colorReset)
|
fmt.Fprintln(ios.Out, cs.Green(line))
|
||||||
case strings.HasPrefix(line, "-"):
|
case strings.HasPrefix(line, "-"):
|
||||||
fmt.Println(colorRed + line + colorReset)
|
fmt.Fprintln(ios.Out, cs.Red(line))
|
||||||
default:
|
default:
|
||||||
fmt.Println(line)
|
fmt.Fprintln(ios.Out, line)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,10 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
|
||||||
|
|
||||||
"code.gitea.io/sdk/gitea"
|
"code.gitea.io/sdk/gitea"
|
||||||
"forgejo.zerova.net/sid/fgj-sid/internal/api"
|
"forgejo.zerova.net/public/fj/internal/api"
|
||||||
"forgejo.zerova.net/sid/fgj-sid/internal/config"
|
"forgejo.zerova.net/public/fj/internal/config"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -17,16 +16,16 @@ var prCommentCmd = &cobra.Command{
|
||||||
Short: "Add a comment to a pull request",
|
Short: "Add a comment to a pull request",
|
||||||
Long: "Add a comment to an existing pull request.",
|
Long: "Add a comment to an existing pull request.",
|
||||||
Example: ` # Add a comment
|
Example: ` # Add a comment
|
||||||
fgj pr comment 123 -b "Looks good!"
|
fj pr comment 123 -b "Looks good!"
|
||||||
|
|
||||||
# Comment from a file
|
# 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
|
# Comment from stdin
|
||||||
echo "LGTM" | fgj pr comment 123 --body-file -
|
echo "LGTM" | fj pr comment 123 --body-file -
|
||||||
|
|
||||||
# Output as JSON
|
# Output as JSON
|
||||||
fgj pr comment 123 -b "Nice work" --json`,
|
fj pr comment 123 -b "Nice work" --json`,
|
||||||
Args: cobra.ExactArgs(1),
|
Args: cobra.ExactArgs(1),
|
||||||
RunE: runPRComment,
|
RunE: runPRComment,
|
||||||
}
|
}
|
||||||
|
|
@ -36,16 +35,16 @@ var prReviewCmd = &cobra.Command{
|
||||||
Short: "Submit a review on a pull request",
|
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.",
|
Long: "Submit a review on a pull request. Exactly one of --approve, --request-changes, or --comment must be specified.",
|
||||||
Example: ` # Approve a PR
|
Example: ` # Approve a PR
|
||||||
fgj pr review 123 --approve -b "LGTM"
|
fj pr review 123 --approve -b "LGTM"
|
||||||
|
|
||||||
# Request changes
|
# 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
|
# 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
|
# 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),
|
Args: cobra.ExactArgs(1),
|
||||||
RunE: runPRReview,
|
RunE: runPRReview,
|
||||||
}
|
}
|
||||||
|
|
@ -57,7 +56,7 @@ func init() {
|
||||||
prCommentCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format")
|
prCommentCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format")
|
||||||
prCommentCmd.Flags().StringP("body", "b", "", "Comment body")
|
prCommentCmd.Flags().StringP("body", "b", "", "Comment body")
|
||||||
prCommentCmd.Flags().String("body-file", "", "Read body from file (use \"-\" for stdin)")
|
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().StringP("repo", "R", "", "Repository in owner/name format")
|
||||||
prReviewCmd.Flags().BoolP("approve", "a", false, "Approve the pull request")
|
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().BoolP("comment", "c", false, "Submit as a review comment")
|
||||||
prReviewCmd.Flags().StringP("body", "b", "", "Review body/message")
|
prReviewCmd.Flags().StringP("body", "b", "", "Review body/message")
|
||||||
prReviewCmd.Flags().String("body-file", "", "Read body from file (use \"-\" for stdin)")
|
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.
|
// 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 {
|
func runPRComment(cmd *cobra.Command, args []string) error {
|
||||||
repo, _ := cmd.Flags().GetString("repo")
|
repo, _ := cmd.Flags().GetString("repo")
|
||||||
prNumber, err := strconv.ParseInt(args[0], 10, 64)
|
prNumber, err := parseIssueArg(args[0])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("invalid pull request number: %w", err)
|
return fmt.Errorf("invalid pull request number: %w", err)
|
||||||
}
|
}
|
||||||
|
|
@ -122,24 +121,27 @@ func runPRComment(cmd *cobra.Command, args []string) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost())
|
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ios.StartSpinner("Adding comment...")
|
||||||
comment, _, err := client.CreateIssueComment(owner, name, prNumber, gitea.CreateIssueCommentOption{
|
comment, _, err := client.CreateIssueComment(owner, name, prNumber, gitea.CreateIssueCommentOption{
|
||||||
Body: body,
|
Body: body,
|
||||||
})
|
})
|
||||||
|
ios.StopSpinner()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to create comment: %w", err)
|
return fmt.Errorf("failed to create comment: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if jsonOutput, _ := cmd.Flags().GetBool("json"); jsonOutput {
|
if wantJSON(cmd) {
|
||||||
return writeJSON(comment)
|
return outputJSON(cmd, comment)
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("Comment added to PR #%d\n", prNumber)
|
cs := ios.ColorScheme()
|
||||||
fmt.Printf("View at: %s\n", comment.HTMLURL)
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
@ -150,7 +152,7 @@ func runPRReview(cmd *cobra.Command, args []string) error {
|
||||||
requestChanges, _ := cmd.Flags().GetBool("request-changes")
|
requestChanges, _ := cmd.Flags().GetBool("request-changes")
|
||||||
commentReview, _ := cmd.Flags().GetBool("comment")
|
commentReview, _ := cmd.Flags().GetBool("comment")
|
||||||
|
|
||||||
prNumber, err := strconv.ParseInt(args[0], 10, 64)
|
prNumber, err := parseIssueArg(args[0])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("invalid pull request number: %w", err)
|
return fmt.Errorf("invalid pull request number: %w", err)
|
||||||
}
|
}
|
||||||
|
|
@ -189,7 +191,7 @@ func runPRReview(cmd *cobra.Command, args []string) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost())
|
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -208,21 +210,24 @@ func runPRReview(cmd *cobra.Command, args []string) error {
|
||||||
action = "reviewed with comment"
|
action = "reviewed with comment"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ios.StartSpinner("Submitting review...")
|
||||||
review, _, err := client.CreatePullReview(owner, name, prNumber, gitea.CreatePullReviewOptions{
|
review, _, err := client.CreatePullReview(owner, name, prNumber, gitea.CreatePullReviewOptions{
|
||||||
State: state,
|
State: state,
|
||||||
Body: body,
|
Body: body,
|
||||||
})
|
})
|
||||||
|
ios.StopSpinner()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to create review: %w", err)
|
return fmt.Errorf("failed to create review: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if jsonOutput, _ := cmd.Flags().GetBool("json"); jsonOutput {
|
if wantJSON(cmd) {
|
||||||
return writeJSON(review)
|
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 != "" {
|
if review.HTMLURL != "" {
|
||||||
fmt.Printf("View at: %s\n", review.HTMLURL)
|
fmt.Fprintf(ios.Out, "View at: %s\n", review.HTMLURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|
|
||||||
287
cmd/release.go
287
cmd/release.go
|
|
@ -3,14 +3,15 @@ package cmd
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"path"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"text/tabwriter"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"code.gitea.io/sdk/gitea"
|
"code.gitea.io/sdk/gitea"
|
||||||
"forgejo.zerova.net/sid/fgj-sid/internal/api"
|
"forgejo.zerova.net/public/fj/internal/api"
|
||||||
"forgejo.zerova.net/sid/fgj-sid/internal/config"
|
"forgejo.zerova.net/public/fj/internal/config"
|
||||||
|
"forgejo.zerova.net/public/fj/internal/text"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -25,6 +26,14 @@ var releaseListCmd = &cobra.Command{
|
||||||
Use: "list",
|
Use: "list",
|
||||||
Short: "List releases",
|
Short: "List releases",
|
||||||
Long: "List releases in a repository.",
|
Long: "List releases in a repository.",
|
||||||
|
Example: ` # List releases
|
||||||
|
fj release list
|
||||||
|
|
||||||
|
# List only draft releases
|
||||||
|
fj release list --draft
|
||||||
|
|
||||||
|
# Output as JSON with a custom limit
|
||||||
|
fj release list --json --limit 10`,
|
||||||
RunE: runReleaseList,
|
RunE: runReleaseList,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -32,6 +41,17 @@ var releaseViewCmd = &cobra.Command{
|
||||||
Use: "view <tag|latest>",
|
Use: "view <tag|latest>",
|
||||||
Short: "View a release",
|
Short: "View a release",
|
||||||
Long: "Display detailed information about 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),
|
Args: cobra.ExactArgs(1),
|
||||||
RunE: runReleaseView,
|
RunE: runReleaseView,
|
||||||
}
|
}
|
||||||
|
|
@ -40,6 +60,17 @@ var releaseCreateCmd = &cobra.Command{
|
||||||
Use: "create <tag> [files...]",
|
Use: "create <tag> [files...]",
|
||||||
Short: "Create a release",
|
Short: "Create a release",
|
||||||
Long: "Create a new release and optionally upload assets.",
|
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),
|
Args: cobra.MinimumNArgs(1),
|
||||||
RunE: runReleaseCreate,
|
RunE: runReleaseCreate,
|
||||||
}
|
}
|
||||||
|
|
@ -48,14 +79,43 @@ var releaseUploadCmd = &cobra.Command{
|
||||||
Use: "upload <tag|latest> <files...>",
|
Use: "upload <tag|latest> <files...>",
|
||||||
Short: "Upload release assets",
|
Short: "Upload release assets",
|
||||||
Long: "Upload assets to an existing release.",
|
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),
|
Args: cobra.MinimumNArgs(2),
|
||||||
RunE: runReleaseUpload,
|
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{
|
var releaseDeleteCmd = &cobra.Command{
|
||||||
Use: "delete <tag|latest>",
|
Use: "delete <tag|latest>",
|
||||||
Short: "Delete a release",
|
Short: "Delete a release",
|
||||||
Long: "Delete a release by tag, keeping its Git tag intact.",
|
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),
|
Args: cobra.ExactArgs(1),
|
||||||
RunE: runReleaseDelete,
|
RunE: runReleaseDelete,
|
||||||
}
|
}
|
||||||
|
|
@ -66,16 +126,18 @@ func init() {
|
||||||
releaseCmd.AddCommand(releaseViewCmd)
|
releaseCmd.AddCommand(releaseViewCmd)
|
||||||
releaseCmd.AddCommand(releaseCreateCmd)
|
releaseCmd.AddCommand(releaseCreateCmd)
|
||||||
releaseCmd.AddCommand(releaseUploadCmd)
|
releaseCmd.AddCommand(releaseUploadCmd)
|
||||||
|
releaseCmd.AddCommand(releaseDownloadCmd)
|
||||||
releaseCmd.AddCommand(releaseDeleteCmd)
|
releaseCmd.AddCommand(releaseDeleteCmd)
|
||||||
|
|
||||||
releaseListCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format")
|
releaseListCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format")
|
||||||
releaseListCmd.Flags().Bool("draft", false, "Filter by draft status")
|
releaseListCmd.Flags().Bool("draft", false, "Filter by draft status")
|
||||||
releaseListCmd.Flags().Bool("prerelease", false, "Filter by prerelease status")
|
releaseListCmd.Flags().Bool("prerelease", false, "Filter by prerelease status")
|
||||||
releaseListCmd.Flags().Int("limit", 30, "Maximum number of releases to fetch")
|
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().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("repo", "R", "", "Repository in owner/name format")
|
||||||
releaseCreateCmd.Flags().StringP("title", "t", "", "Release title (defaults to tag)")
|
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().StringP("repo", "R", "", "Repository in owner/name format")
|
||||||
releaseUploadCmd.Flags().Bool("clobber", false, "Overwrite assets with the same name")
|
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().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 {
|
func runReleaseList(cmd *cobra.Command, args []string) error {
|
||||||
|
|
@ -113,7 +180,7 @@ func runReleaseList(cmd *cobra.Command, args []string) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost())
|
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -131,11 +198,13 @@ func runReleaseList(cmd *cobra.Command, args []string) error {
|
||||||
opts.IsPreRelease = &prereleaseValue
|
opts.IsPreRelease = &prereleaseValue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ios.StartSpinner("Fetching releases...")
|
||||||
var releases []*gitea.Release
|
var releases []*gitea.Release
|
||||||
for page := 1; len(releases) < limit; page++ {
|
for page := 1; len(releases) < limit; page++ {
|
||||||
opts.ListOptions = gitea.ListOptions{Page: page, PageSize: pageSize}
|
opts.ListOptions = gitea.ListOptions{Page: page, PageSize: pageSize}
|
||||||
batch, _, err := client.ListReleases(owner, name, opts)
|
batch, _, err := client.ListReleases(owner, name, opts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
ios.StopSpinner()
|
||||||
return fmt.Errorf("failed to list releases: %w", err)
|
return fmt.Errorf("failed to list releases: %w", err)
|
||||||
}
|
}
|
||||||
if len(batch) == 0 {
|
if len(batch) == 0 {
|
||||||
|
|
@ -143,29 +212,29 @@ func runReleaseList(cmd *cobra.Command, args []string) error {
|
||||||
}
|
}
|
||||||
releases = append(releases, batch...)
|
releases = append(releases, batch...)
|
||||||
}
|
}
|
||||||
|
ios.StopSpinner()
|
||||||
|
|
||||||
if len(releases) > limit {
|
if len(releases) > limit {
|
||||||
releases = releases[:limit]
|
releases = releases[:limit]
|
||||||
}
|
}
|
||||||
|
|
||||||
if jsonOutput, _ := cmd.Flags().GetBool("json"); jsonOutput {
|
if wantJSON(cmd) {
|
||||||
return writeJSON(releases)
|
return outputJSON(cmd, releases)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(releases) == 0 {
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
isTTY := ios.IsStdoutTTY()
|
||||||
_, _ = fmt.Fprintf(w, "TAG\tTITLE\tTYPE\tPUBLISHED\n")
|
tp := ios.NewTablePrinter()
|
||||||
|
tp.AddHeader("TAG", "TITLE", "TYPE", "PUBLISHED")
|
||||||
for _, rel := range releases {
|
for _, rel := range releases {
|
||||||
published := releaseTimestamp(rel).Format("2006-01-02")
|
published := text.FormatDate(releaseTimestamp(rel), isTTY)
|
||||||
_, _ = fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", rel.TagName, rel.Title, releaseType(rel), published)
|
tp.AddRow(rel.TagName, rel.Title, releaseType(rel), published)
|
||||||
}
|
}
|
||||||
_ = w.Flush()
|
return tp.Render()
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func runReleaseView(cmd *cobra.Command, args []string) error {
|
func runReleaseView(cmd *cobra.Command, args []string) error {
|
||||||
|
|
@ -182,22 +251,32 @@ func runReleaseView(cmd *cobra.Command, args []string) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost())
|
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ios.StartSpinner("Fetching release...")
|
||||||
release, err := getReleaseByTagOrLatest(client, owner, name, tag)
|
release, err := getReleaseByTagOrLatest(client, owner, name, tag)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
ios.StopSpinner()
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
attachments, err := listReleaseAttachments(client, owner, name, release.ID)
|
attachments, err := listReleaseAttachments(client, owner, name, release.ID)
|
||||||
|
ios.StopSpinner()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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 {
|
payload := struct {
|
||||||
Release *gitea.Release `json:"release"`
|
Release *gitea.Release `json:"release"`
|
||||||
Assets []*gitea.Attachment `json:"assets,omitempty"`
|
Assets []*gitea.Attachment `json:"assets,omitempty"`
|
||||||
|
|
@ -205,33 +284,41 @@ func runReleaseView(cmd *cobra.Command, args []string) error {
|
||||||
Release: release,
|
Release: release,
|
||||||
Assets: attachments,
|
Assets: attachments,
|
||||||
}
|
}
|
||||||
return writeJSON(payload)
|
return outputJSON(cmd, payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("Release %s\n", release.TagName)
|
if err := ios.StartPager(); err != nil {
|
||||||
fmt.Printf("Title: %s\n", release.Title)
|
fmt.Fprintf(ios.ErrOut, "warning: failed to start pager: %v\n", err)
|
||||||
fmt.Printf("Type: %s\n", releaseType(release))
|
}
|
||||||
|
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 != "" {
|
if release.Target != "" {
|
||||||
fmt.Printf("Target: %s\n", release.Target)
|
fmt.Fprintf(ios.Out, "Target: %s\n", release.Target)
|
||||||
}
|
}
|
||||||
if release.Publisher != nil {
|
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() {
|
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 != "" {
|
if release.HTMLURL != "" {
|
||||||
fmt.Printf("URL: %s\n", release.HTMLURL)
|
fmt.Fprintf(ios.Out, "URL: %s\n", release.HTMLURL)
|
||||||
}
|
}
|
||||||
if release.Note != "" {
|
if release.Note != "" {
|
||||||
fmt.Printf("\n%s\n", release.Note)
|
fmt.Fprintf(ios.Out, "\n%s\n", release.Note)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(attachments) > 0 {
|
if len(attachments) > 0 {
|
||||||
fmt.Printf("\nAssets (%d):\n", len(attachments))
|
fmt.Fprintf(ios.Out, "\nAssets (%d):\n", len(attachments))
|
||||||
for _, asset := range 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
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost())
|
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ios.StartSpinner("Creating release...")
|
||||||
release, _, err := client.CreateRelease(owner, name, gitea.CreateReleaseOption{
|
release, _, err := client.CreateRelease(owner, name, gitea.CreateReleaseOption{
|
||||||
TagName: tag,
|
TagName: tag,
|
||||||
Target: target,
|
Target: target,
|
||||||
|
|
@ -289,24 +377,29 @@ func runReleaseCreate(cmd *cobra.Command, args []string) error {
|
||||||
IsDraft: draft,
|
IsDraft: draft,
|
||||||
IsPrerelease: prerelease,
|
IsPrerelease: prerelease,
|
||||||
})
|
})
|
||||||
|
ios.StopSpinner()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to create release: %w", err)
|
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 != "" {
|
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 {
|
if len(files) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ios.StartSpinner("Uploading assets...")
|
||||||
if err := uploadReleaseAssets(client, owner, name, release.ID, files, false); err != nil {
|
if err := uploadReleaseAssets(client, owner, name, release.ID, files, false); err != nil {
|
||||||
|
ios.StopSpinner()
|
||||||
return err
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -327,26 +420,34 @@ func runReleaseUpload(cmd *cobra.Command, args []string) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost())
|
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ios.StartSpinner("Fetching release...")
|
||||||
release, err := getReleaseByTagOrLatest(client, owner, name, tag)
|
release, err := getReleaseByTagOrLatest(client, owner, name, tag)
|
||||||
|
ios.StopSpinner()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ios.StartSpinner("Uploading assets...")
|
||||||
if err := uploadReleaseAssets(client, owner, name, release.ID, files, clobber); err != nil {
|
if err := uploadReleaseAssets(client, owner, name, release.ID, files, clobber); err != nil {
|
||||||
|
ios.StopSpinner()
|
||||||
return err
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func runReleaseDelete(cmd *cobra.Command, args []string) error {
|
func runReleaseDownload(cmd *cobra.Command, args []string) error {
|
||||||
repo, _ := cmd.Flags().GetString("repo")
|
repo, _ := cmd.Flags().GetString("repo")
|
||||||
|
dir, _ := cmd.Flags().GetString("dir")
|
||||||
|
pattern, _ := cmd.Flags().GetString("pattern")
|
||||||
tag := args[0]
|
tag := args[0]
|
||||||
|
|
||||||
owner, name, err := parseRepo(repo)
|
owner, name, err := parseRepo(repo)
|
||||||
|
|
@ -359,21 +460,125 @@ func runReleaseDelete(cmd *cobra.Command, args []string) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost())
|
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ios.StartSpinner("Fetching release...")
|
||||||
release, err := getReleaseByTagOrLatest(client, owner, name, tag)
|
release, err := getReleaseByTagOrLatest(client, owner, name, tag)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
ios.StopSpinner()
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := client.DeleteRelease(owner, name, release.ID); err != nil {
|
attachments, err := listReleaseAttachments(client, owner, name, release.ID)
|
||||||
return fmt.Errorf("failed to delete release: %w", err)
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
247
cmd/repo.go
247
cmd/repo.go
|
|
@ -6,11 +6,11 @@ import (
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"text/tabwriter"
|
|
||||||
|
|
||||||
"code.gitea.io/sdk/gitea"
|
"code.gitea.io/sdk/gitea"
|
||||||
"forgejo.zerova.net/sid/fgj-sid/internal/api"
|
"forgejo.zerova.net/public/fj/internal/api"
|
||||||
"forgejo.zerova.net/sid/fgj-sid/internal/config"
|
"forgejo.zerova.net/public/fj/internal/config"
|
||||||
|
"forgejo.zerova.net/public/fj/internal/text"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -67,28 +67,45 @@ var repoEditCmd = &cobra.Command{
|
||||||
Short: "Edit repository settings",
|
Short: "Edit repository settings",
|
||||||
Long: "Edit settings of an existing repository such as visibility, description, homepage, and default branch.",
|
Long: "Edit settings of an existing repository such as visibility, description, homepage, and default branch.",
|
||||||
Example: ` # Make a repository private
|
Example: ` # Make a repository private
|
||||||
fgj repo edit owner/repo --private
|
fj repo edit owner/repo --private
|
||||||
|
|
||||||
# Make a repository public
|
# Make a repository public
|
||||||
fgj repo edit owner/repo --public
|
fj repo edit owner/repo --public
|
||||||
|
|
||||||
# Update description and homepage
|
# 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
|
# 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)
|
# Edit current repo (auto-detected from git context)
|
||||||
fgj repo edit --public`,
|
fj repo edit --public`,
|
||||||
Args: cobra.MaximumNArgs(1),
|
Args: cobra.MaximumNArgs(1),
|
||||||
RunE: runRepoEdit,
|
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() {
|
func init() {
|
||||||
rootCmd.AddCommand(repoCmd)
|
rootCmd.AddCommand(repoCmd)
|
||||||
repoCmd.AddCommand(repoCloneCmd)
|
repoCmd.AddCommand(repoCloneCmd)
|
||||||
repoCmd.AddCommand(repoCreateCmd)
|
repoCmd.AddCommand(repoCreateCmd)
|
||||||
repoCmd.AddCommand(repoEditCmd)
|
repoCmd.AddCommand(repoEditCmd)
|
||||||
|
repoCmd.AddCommand(repoRenameCmd)
|
||||||
repoCmd.AddCommand(repoForkCmd)
|
repoCmd.AddCommand(repoForkCmd)
|
||||||
repoCmd.AddCommand(repoListCmd)
|
repoCmd.AddCommand(repoListCmd)
|
||||||
repoCmd.AddCommand(repoViewCmd)
|
repoCmd.AddCommand(repoViewCmd)
|
||||||
|
|
@ -104,16 +121,26 @@ func init() {
|
||||||
repoCreateCmd.Flags().StringP("team", "t", "", "Team name to be granted access (org repos only)")
|
repoCreateCmd.Flags().StringP("team", "t", "", "Team name to be granted access (org repos only)")
|
||||||
repoCreateCmd.MarkFlagsMutuallyExclusive("public", "private")
|
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")
|
repoCloneCmd.Flags().StringP("protocol", "p", "https", "Clone protocol: https or ssh")
|
||||||
|
|
||||||
repoEditCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format")
|
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().StringP("description", "d", "", "Repository description")
|
||||||
repoEditCmd.Flags().String("homepage", "", "Repository home page URL")
|
repoEditCmd.Flags().String("homepage", "", "Repository home page URL")
|
||||||
repoEditCmd.Flags().String("default-branch", "", "Default branch name")
|
repoEditCmd.Flags().String("default-branch", "", "Default branch name")
|
||||||
repoEditCmd.Flags().Bool("private", false, "Make the repository private")
|
repoEditCmd.Flags().Bool("private", false, "Make the repository private")
|
||||||
repoEditCmd.Flags().Bool("public", false, "Make the repository public")
|
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")
|
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 {
|
func runRepoView(cmd *cobra.Command, args []string) error {
|
||||||
|
|
@ -132,28 +159,41 @@ func runRepoView(cmd *cobra.Command, args []string) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost())
|
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ios.StartSpinner("Fetching repository...")
|
||||||
repository, _, err := client.GetRepo(owner, name)
|
repository, _, err := client.GetRepo(owner, name)
|
||||||
|
ios.StopSpinner()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to get repository: %w", err)
|
return fmt.Errorf("failed to get repository: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("Repository: %s/%s\n", repository.Owner.UserName, repository.Name)
|
if web, _ := cmd.Flags().GetBool("web"); web {
|
||||||
fmt.Printf("Description: %s\n", repository.Description)
|
return ios.OpenInBrowser(repository.HTMLURL)
|
||||||
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)
|
if wantJSON(cmd) {
|
||||||
fmt.Printf("Default Branch: %s\n", repository.DefaultBranch)
|
return outputJSON(cmd, repository)
|
||||||
fmt.Printf("Stars: %d\n", repository.Stars)
|
}
|
||||||
fmt.Printf("Forks: %d\n", repository.Forks)
|
|
||||||
fmt.Printf("Open Issues: %d\n", repository.OpenIssues)
|
cs := ios.ColorScheme()
|
||||||
fmt.Printf("Private: %v\n", repository.Private)
|
isTTY := ios.IsStdoutTTY()
|
||||||
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"))
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
@ -164,42 +204,50 @@ func runRepoList(cmd *cobra.Command, args []string) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost())
|
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ios.StartSpinner("Fetching repositories...")
|
||||||
user, _, err := client.GetMyUserInfo()
|
user, _, err := client.GetMyUserInfo()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
ios.StopSpinner()
|
||||||
return fmt.Errorf("failed to get user info: %w", err)
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to list repositories: %w", err)
|
return fmt.Errorf("failed to list repositories: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if wantJSON(cmd) {
|
||||||
|
return outputJSON(cmd, repos)
|
||||||
|
}
|
||||||
|
|
||||||
if len(repos) == 0 {
|
if len(repos) == 0 {
|
||||||
fmt.Println("No repositories found")
|
fmt.Fprintln(ios.Out, "No repositories found")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
tp := ios.NewTablePrinter()
|
||||||
_, _ = fmt.Fprintf(w, "NAME\tVISIBILITY\tDESCRIPTION\n")
|
tp.AddHeader("NAME", "VISIBILITY", "DESCRIPTION")
|
||||||
for _, repo := range repos {
|
for _, repo := range repos {
|
||||||
visibility := "public"
|
visibility := "public"
|
||||||
if repo.Private {
|
if repo.Private {
|
||||||
visibility = "private"
|
visibility = "private"
|
||||||
}
|
}
|
||||||
desc := repo.Description
|
desc := text.Truncate(repo.Description, 50)
|
||||||
if len(desc) > 50 {
|
tp.AddRow(fmt.Sprintf("%s/%s", repo.Owner.UserName, repo.Name), visibility, desc)
|
||||||
desc = desc[:47] + "..."
|
|
||||||
}
|
}
|
||||||
_, _ = fmt.Fprintf(w, "%s/%s\t%s\t%s\n", repo.Owner.UserName, repo.Name, visibility, desc)
|
return tp.Render()
|
||||||
}
|
|
||||||
_ = w.Flush()
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func runRepoClone(cmd *cobra.Command, args []string) error {
|
func runRepoClone(cmd *cobra.Command, args []string) error {
|
||||||
|
|
@ -216,12 +264,14 @@ func runRepoClone(cmd *cobra.Command, args []string) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost())
|
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ios.StartSpinner("Fetching repository info...")
|
||||||
repository, _, err := client.GetRepo(owner, name)
|
repository, _, err := client.GetRepo(owner, name)
|
||||||
|
ios.StopSpinner()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to get repository: %w", err)
|
return fmt.Errorf("failed to get repository: %w", err)
|
||||||
}
|
}
|
||||||
|
|
@ -241,7 +291,7 @@ func runRepoClone(cmd *cobra.Command, args []string) error {
|
||||||
destination = name
|
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
|
// Create parent directory if it doesn't exist
|
||||||
if dir := filepath.Dir(destination); dir != "." {
|
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
|
// Execute git clone
|
||||||
gitCmd := exec.Command("git", "clone", cloneURL, destination)
|
gitCmd := exec.Command("git", "clone", cloneURL, destination)
|
||||||
gitCmd.Stdout = os.Stdout
|
gitCmd.Stdout = ios.Out
|
||||||
gitCmd.Stderr = os.Stderr
|
gitCmd.Stderr = ios.ErrOut
|
||||||
gitCmd.Stdin = os.Stdin
|
gitCmd.Stdin = ios.In
|
||||||
|
|
||||||
if err := gitCmd.Run(); err != nil {
|
if err := gitCmd.Run(); err != nil {
|
||||||
|
ios.StopSpinner()
|
||||||
return fmt.Errorf("failed to clone repository: %w", err)
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -277,19 +331,22 @@ func runRepoFork(cmd *cobra.Command, args []string) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost())
|
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ios.StartSpinner("Forking repository...")
|
||||||
fork, _, err := client.CreateFork(owner, name, gitea.CreateForkOption{})
|
fork, _, err := client.CreateFork(owner, name, gitea.CreateForkOption{})
|
||||||
|
ios.StopSpinner()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to fork repository: %w", err)
|
return fmt.Errorf("failed to fork repository: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("Repository forked successfully\n")
|
cs := ios.ColorScheme()
|
||||||
fmt.Printf("View at: %s\n", fork.HTMLURL)
|
fmt.Fprintf(ios.Out, "%s Repository forked successfully\n", cs.SuccessIcon())
|
||||||
fmt.Printf("Clone URL: %s\n", fork.CloneURL)
|
fmt.Fprintf(ios.Out, "View at: %s\n", fork.HTMLURL)
|
||||||
|
fmt.Fprintf(ios.Out, "Clone URL: %s\n", fork.CloneURL)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
@ -321,7 +378,7 @@ func runRepoCreate(cmd *cobra.Command, args []string) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost())
|
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -335,12 +392,14 @@ func runRepoCreate(cmd *cobra.Command, args []string) error {
|
||||||
License: license,
|
License: license,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ios.StartSpinner("Creating repository...")
|
||||||
var repo *gitea.Repository
|
var repo *gitea.Repository
|
||||||
if isOrg {
|
if isOrg {
|
||||||
repo, _, err = client.CreateOrgRepo(org, opt)
|
repo, _, err = client.CreateOrgRepo(org, opt)
|
||||||
} else {
|
} else {
|
||||||
repo, _, err = client.CreateRepo(opt)
|
repo, _, err = client.CreateRepo(opt)
|
||||||
}
|
}
|
||||||
|
ios.StopSpinner()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to create repository: %w", err)
|
return fmt.Errorf("failed to create repository: %w", err)
|
||||||
}
|
}
|
||||||
|
|
@ -354,7 +413,7 @@ func runRepoCreate(cmd *cobra.Command, args []string) error {
|
||||||
} else {
|
} else {
|
||||||
user, _, userErr := client.GetMyUserInfo()
|
user, _, userErr := client.GetMyUserInfo()
|
||||||
if userErr != nil {
|
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
|
homepage = "" // skip EditRepo
|
||||||
} else {
|
} else {
|
||||||
ownerName = user.UserName
|
ownerName = user.UserName
|
||||||
|
|
@ -366,36 +425,37 @@ func runRepoCreate(cmd *cobra.Command, args []string) error {
|
||||||
Website: &homepage,
|
Website: &homepage,
|
||||||
})
|
})
|
||||||
if err != nil {
|
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 team != "" {
|
||||||
if !isOrg {
|
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 {
|
} else {
|
||||||
_, err = client.AddRepoTeam(org, repo.Name, team)
|
_, err = client.AddRepoTeam(org, repo.Name, team)
|
||||||
if err != nil {
|
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 {
|
if doClone {
|
||||||
cloneURL := repo.CloneURL
|
cloneURL := repo.CloneURL
|
||||||
if hostCfg, hostErr := cfg.GetHost("", getDetectedHost()); hostErr == nil {
|
if hostCfg, hostErr := cfg.GetHost("", getDetectedHost(), getCwd()); hostErr == nil {
|
||||||
if hostCfg.GitProtocol == "ssh" {
|
if hostCfg.GitProtocol == "ssh" {
|
||||||
cloneURL = repo.SSHURL
|
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 := exec.Command("git", "clone", cloneURL, repo.Name)
|
||||||
gitCmd.Stdout = os.Stdout
|
gitCmd.Stdout = ios.Out
|
||||||
gitCmd.Stderr = os.Stderr
|
gitCmd.Stderr = ios.ErrOut
|
||||||
gitCmd.Stdin = os.Stdin
|
gitCmd.Stdin = ios.In
|
||||||
if err := gitCmd.Run(); err != nil {
|
if err := gitCmd.Run(); err != nil {
|
||||||
return fmt.Errorf("failed to clone repository: %w", err)
|
return fmt.Errorf("failed to clone repository: %w", err)
|
||||||
}
|
}
|
||||||
|
|
@ -441,7 +501,7 @@ func runRepoEdit(cmd *cobra.Command, args []string) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost())
|
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -449,6 +509,11 @@ func runRepoEdit(cmd *cobra.Command, args []string) error {
|
||||||
opt := gitea.EditRepoOption{}
|
opt := gitea.EditRepoOption{}
|
||||||
changed := false
|
changed := false
|
||||||
|
|
||||||
|
if cmd.Flags().Changed("name") {
|
||||||
|
n, _ := cmd.Flags().GetString("name")
|
||||||
|
opt.Name = &n
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
if cmd.Flags().Changed("description") {
|
if cmd.Flags().Changed("description") {
|
||||||
d, _ := cmd.Flags().GetString("description")
|
d, _ := cmd.Flags().GetString("description")
|
||||||
opt.Description = &d
|
opt.Description = &d
|
||||||
|
|
@ -476,36 +541,84 @@ func runRepoEdit(cmd *cobra.Command, args []string) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
if !changed {
|
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)
|
repository, _, err := client.EditRepo(owner, name, opt)
|
||||||
|
ios.StopSpinner()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to edit repository: %w", err)
|
return fmt.Errorf("failed to edit repository: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
jsonFlag, _ := cmd.Flags().GetBool("json")
|
if wantJSON(cmd) {
|
||||||
if jsonFlag {
|
return outputJSON(cmd, repository)
|
||||||
return writeJSON(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 != nil {
|
||||||
if *opt.Private {
|
if *opt.Private {
|
||||||
fmt.Println("Visibility: private")
|
fmt.Fprintln(ios.Out, "Visibility: private")
|
||||||
} else {
|
} else {
|
||||||
fmt.Println("Visibility: public")
|
fmt.Fprintln(ios.Out, "Visibility: public")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if opt.Description != nil {
|
if opt.Description != nil {
|
||||||
fmt.Printf("Description: %s\n", *opt.Description)
|
fmt.Fprintf(ios.Out, "Description: %s\n", *opt.Description)
|
||||||
}
|
}
|
||||||
if opt.Website != nil {
|
if opt.Website != nil {
|
||||||
fmt.Printf("Homepage: %s\n", *opt.Website)
|
fmt.Fprintf(ios.Out, "Homepage: %s\n", *opt.Website)
|
||||||
}
|
}
|
||||||
if opt.DefaultBranch != nil {
|
if opt.DefaultBranch != nil {
|
||||||
fmt.Printf("Default branch: %s\n", *opt.DefaultBranch)
|
fmt.Fprintf(ios.Out, "Default branch: %s\n", *opt.DefaultBranch)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
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 (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"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/cobra"
|
||||||
"github.com/spf13/viper"
|
"github.com/spf13/viper"
|
||||||
)
|
)
|
||||||
|
|
@ -14,11 +18,11 @@ var cfgFile string
|
||||||
var jsonErrors bool
|
var jsonErrors bool
|
||||||
|
|
||||||
var rootCmd = &cobra.Command{
|
var rootCmd = &cobra.Command{
|
||||||
Use: "fgj",
|
Use: "fj",
|
||||||
Short: "Forgejo CLI tool - work seamlessly with Forgejo from the command line",
|
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.`,
|
It brings pull requests, issues, and other Forgejo concepts to the terminal.`,
|
||||||
Version: "0.3.0b",
|
Version: "0.4.0",
|
||||||
SilenceErrors: true,
|
SilenceErrors: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -34,7 +38,7 @@ func Execute() error {
|
||||||
func init() {
|
func init() {
|
||||||
cobra.OnInitialize(initConfig)
|
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().BoolVar(&jsonErrors, "json-errors", false, "output errors as structured JSON to stderr")
|
||||||
rootCmd.PersistentFlags().String("hostname", "", "Forgejo instance hostname")
|
rootCmd.PersistentFlags().String("hostname", "", "Forgejo instance hostname")
|
||||||
_ = viper.BindPFlag("hostname", rootCmd.PersistentFlags().Lookup("hostname"))
|
_ = viper.BindPFlag("hostname", rootCmd.PersistentFlags().Lookup("hostname"))
|
||||||
|
|
@ -42,16 +46,33 @@ func init() {
|
||||||
|
|
||||||
func initConfig() {
|
func initConfig() {
|
||||||
if cfgFile != "" {
|
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)
|
viper.SetConfigFile(cfgFile)
|
||||||
|
config.SetExplicitConfigPath(cfgFile)
|
||||||
} else {
|
} else {
|
||||||
home, err := os.UserHomeDir()
|
home, err := os.UserHomeDir()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintln(os.Stderr, err)
|
fmt.Fprintln(ios.ErrOut, err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
configDir := home + "/.config/fgj"
|
configDir := home + "/.config/fj"
|
||||||
_ = os.MkdirAll(configDir, 0755)
|
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.AddConfigPath(configDir)
|
||||||
viper.SetConfigType("yaml")
|
viper.SetConfigType("yaml")
|
||||||
|
|
@ -59,9 +80,17 @@ func initConfig() {
|
||||||
}
|
}
|
||||||
|
|
||||||
viper.AutomaticEnv()
|
viper.AutomaticEnv()
|
||||||
viper.SetEnvPrefix("FGJ")
|
viper.SetEnvPrefix("FJ")
|
||||||
|
|
||||||
_ = viper.ReadInConfig()
|
_ = 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".
|
// parseRepo parses the repository string in the format "owner/name".
|
||||||
|
|
@ -94,3 +123,83 @@ func getDetectedHost() string {
|
||||||
}
|
}
|
||||||
return host
|
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 (
|
require (
|
||||||
code.gitea.io/sdk/gitea v0.22.1
|
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/go-version v1.7.0 // indirect
|
||||||
github.com/hashicorp/hcl v1.0.0 // indirect
|
github.com/hashicorp/hcl v1.0.0 // indirect
|
||||||
github.com/inconshreveable/mousetrap v1.1.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/magiconair/properties v1.8.7 // indirect
|
||||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||||
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
|
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
|
||||||
|
|
@ -34,7 +36,7 @@ require (
|
||||||
go.uber.org/multierr v1.9.0 // indirect
|
go.uber.org/multierr v1.9.0 // indirect
|
||||||
golang.org/x/crypto v0.39.0 // indirect
|
golang.org/x/crypto v0.39.0 // indirect
|
||||||
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // 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
|
golang.org/x/text v0.26.0 // indirect
|
||||||
gopkg.in/ini.v1 v1.67.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/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 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
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 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
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.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 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
||||||
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
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.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 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg=
|
||||||
golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ=
|
golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ=
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,22 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
"code.gitea.io/sdk/gitea"
|
"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 {
|
type Client struct {
|
||||||
*gitea.Client
|
*gitea.Client
|
||||||
hostname string
|
hostname string
|
||||||
|
|
@ -34,8 +45,8 @@ func NewClient(hostname, token string) (*Client, error) {
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewClientFromConfig(cfg *config.Config, hostname string, detectedHost string) (*Client, error) {
|
func NewClientFromConfig(cfg *config.Config, hostname string, detectedHost string, cwd string) (*Client, error) {
|
||||||
host, err := cfg.GetHost(hostname, detectedHost)
|
host, err := cfg.GetHost(hostname, detectedHost, cwd)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -63,8 +74,7 @@ func (c *Client) GetJSON(path string, result any) error {
|
||||||
}
|
}
|
||||||
req.Header.Set("Accept", "application/json")
|
req.Header.Set("Accept", "application/json")
|
||||||
|
|
||||||
httpClient := &http.Client{}
|
resp, err := sharedHTTPClient.Do(req)
|
||||||
resp, err := httpClient.Do(req)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to perform request: %w", err)
|
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 {
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
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{
|
return &APIError{
|
||||||
StatusCode: resp.StatusCode,
|
StatusCode: resp.StatusCode,
|
||||||
Body: string(body),
|
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")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
}
|
}
|
||||||
|
|
||||||
httpClient := &http.Client{}
|
resp, err := sharedHTTPClient.Do(req)
|
||||||
resp, err := httpClient.Do(req)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, fmt.Errorf("failed to perform request: %w", err)
|
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 {
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
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{
|
return resp.StatusCode, &APIError{
|
||||||
StatusCode: resp.StatusCode,
|
StatusCode: resp.StatusCode,
|
||||||
Body: string(bodyBytes),
|
Body: string(bodyBytes),
|
||||||
|
|
@ -154,6 +169,40 @@ func (c *Client) DoJSON(method string, path string, body any, result any) (int,
|
||||||
return resp.StatusCode, nil
|
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
|
// GetRawLog performs a GET request and returns the raw response body as string
|
||||||
func (c *Client) GetRawLog(url string) (string, error) {
|
func (c *Client) GetRawLog(url string) (string, error) {
|
||||||
req, err := http.NewRequest(http.MethodGet, url, nil)
|
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)
|
req.Header.Set("Authorization", "token "+c.token)
|
||||||
}
|
}
|
||||||
|
|
||||||
httpClient := &http.Client{}
|
resp, err := sharedHTTPClient.Do(req)
|
||||||
resp, err := httpClient.Do(req)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to perform request: %w", err)
|
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 {
|
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{
|
return "", &APIError{
|
||||||
StatusCode: resp.StatusCode,
|
StatusCode: resp.StatusCode,
|
||||||
Body: string(body),
|
Body: string(body),
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ package api
|
||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"forgejo.zerova.net/sid/fgj-sid/internal/config"
|
"forgejo.zerova.net/public/fj/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestClient_Hostname(t *testing.T) {
|
func TestClient_Hostname(t *testing.T) {
|
||||||
|
|
@ -21,7 +21,7 @@ func TestNewClientFromConfig_MissingHost(t *testing.T) {
|
||||||
Hosts: map[string]config.HostConfig{},
|
Hosts: map[string]config.HostConfig{},
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err := NewClientFromConfig(cfg, "nonexistent.org", "")
|
_, err := NewClientFromConfig(cfg, "nonexistent.org", "", "")
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Error("Expected error for nonexistent host")
|
t.Error("Expected error for nonexistent host")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/spf13/viper"
|
"github.com/spf13/viper"
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
|
|
@ -13,25 +14,41 @@ type Config struct {
|
||||||
Hosts map[string]HostConfig `yaml:"hosts"`
|
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 {
|
type HostConfig struct {
|
||||||
Hostname string `yaml:"hostname"`
|
Hostname string `yaml:"hostname"`
|
||||||
Token string `yaml:"token"`
|
Token string `yaml:"token"`
|
||||||
User string `yaml:"user,omitempty"`
|
User string `yaml:"user,omitempty"`
|
||||||
GitProtocol string `yaml:"git_protocol,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) {
|
func GetConfigDir() (string, error) {
|
||||||
if xdgConfigHome := os.Getenv("XDG_CONFIG_HOME"); xdgConfigHome != "" {
|
if xdgConfigHome := os.Getenv("XDG_CONFIG_HOME"); xdgConfigHome != "" {
|
||||||
return filepath.Join(xdgConfigHome, "fgj"), nil
|
return filepath.Join(xdgConfigHome, "fj"), nil
|
||||||
}
|
}
|
||||||
home, err := os.UserHomeDir()
|
home, err := os.UserHomeDir()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
return filepath.Join(home, ".config", "fgj"), nil
|
return filepath.Join(home, ".config", "fj"), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetConfigPath() (string, error) {
|
func GetConfigPath() (string, error) {
|
||||||
|
if explicitConfigPath != "" {
|
||||||
|
return explicitConfigPath, nil
|
||||||
|
}
|
||||||
dir, err := GetConfigDir()
|
dir, err := GetConfigDir()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
|
|
@ -65,9 +82,43 @@ func LoadFromPath(path string) (*Config, error) {
|
||||||
cfg.Hosts = make(map[string]HostConfig)
|
cfg.Hosts = make(map[string]HostConfig)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Parse again with yaml.Node to capture config file order for hosts
|
||||||
|
assignHostOrder(&cfg, data)
|
||||||
|
|
||||||
return &cfg, nil
|
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 {
|
func (c *Config) Save() error {
|
||||||
path, err := GetConfigPath()
|
path, err := GetConfigPath()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -94,22 +145,27 @@ func (c *Config) SaveToPath(path string) error {
|
||||||
// Priority order:
|
// Priority order:
|
||||||
// 1. Explicitly provided hostname parameter
|
// 1. Explicitly provided hostname parameter
|
||||||
// 2. CLI flag (--hostname)
|
// 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
|
// 4. Auto-detected hostname from git remote
|
||||||
// 5. Default to codeberg.org
|
// 5. match_dirs lookup (longest prefix match)
|
||||||
func (c *Config) GetHost(hostname string, detectedHost string) (HostConfig, error) {
|
// 6. Default to codeberg.org
|
||||||
|
func (c *Config) GetHost(hostname string, detectedHost string, cwd string) (HostConfig, error) {
|
||||||
if hostname == "" {
|
if hostname == "" {
|
||||||
hostname = viper.GetString("hostname")
|
hostname = viper.GetString("hostname")
|
||||||
}
|
}
|
||||||
|
|
||||||
if hostname == "" {
|
if hostname == "" {
|
||||||
hostname = os.Getenv("FGJ_HOST")
|
hostname = EnvWithFallback("FJ_HOST", "FGJ_HOST")
|
||||||
}
|
}
|
||||||
|
|
||||||
if hostname == "" {
|
if hostname == "" {
|
||||||
hostname = detectedHost
|
hostname = detectedHost
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if hostname == "" {
|
||||||
|
hostname = c.ResolveHostByPath(cwd)
|
||||||
|
}
|
||||||
|
|
||||||
if hostname == "" {
|
if hostname == "" {
|
||||||
hostname = "codeberg.org"
|
hostname = "codeberg.org"
|
||||||
}
|
}
|
||||||
|
|
@ -122,6 +178,90 @@ func (c *Config) GetHost(hostname string, detectedHost string) (HostConfig, erro
|
||||||
return host, nil
|
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) {
|
func (c *Config) SetHost(hostname string, host HostConfig) {
|
||||||
if c.Hosts == nil {
|
if c.Hosts == nil {
|
||||||
c.Hosts = make(map[string]HostConfig)
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("Unexpected error: %v", err)
|
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)
|
t.Errorf("Expected hostname 'codeberg.org', got '%s'", host.Hostname)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = cfg.GetHost("nonexistent.org", "")
|
_, err = cfg.GetHost("nonexistent.org", "", "")
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Error("Expected error for nonexistent host")
|
t.Error("Expected error for nonexistent host")
|
||||||
}
|
}
|
||||||
|
|
@ -83,7 +83,7 @@ func TestGetConfigDir_XDG(t *testing.T) {
|
||||||
t.Fatalf("Unexpected error: %v", err)
|
t.Fatalf("Unexpected error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
expected := "/custom/config/fgj"
|
expected := "/custom/config/fj"
|
||||||
if dir != expected {
|
if dir != expected {
|
||||||
t.Errorf("Expected %q, got %q", expected, dir)
|
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
|
// Empty hostname should default to codeberg.org
|
||||||
host, err := cfg.GetHost("", "")
|
host, err := cfg.GetHost("", "", "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Unexpected error: %v", err)
|
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
|
// Whitespace-only hostname should default to codeberg.org
|
||||||
host, err := cfg.GetHost(" ", "")
|
host, err := cfg.GetHost(" ", "", "")
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Logf("Got host: %+v (this may be expected behavior)", host)
|
t.Logf("Got host: %+v (this may be expected behavior)", host)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -315,7 +315,7 @@ func TestConfig_SetHost_EmptyToken(t *testing.T) {
|
||||||
|
|
||||||
cfg.SetHost("codeberg.org", hostConfig)
|
cfg.SetHost("codeberg.org", hostConfig)
|
||||||
|
|
||||||
host, err := cfg.GetHost("codeberg.org", "")
|
host, err := cfg.GetHost("codeberg.org", "", "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Unexpected error: %v", err)
|
t.Fatalf("Unexpected error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
@ -345,7 +345,7 @@ func TestConfig_SetHost_OverwriteExisting(t *testing.T) {
|
||||||
|
|
||||||
cfg.SetHost("codeberg.org", newConfig)
|
cfg.SetHost("codeberg.org", newConfig)
|
||||||
|
|
||||||
host, err := cfg.GetHost("codeberg.org", "")
|
host, err := cfg.GetHost("codeberg.org", "", "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Unexpected error: %v", err)
|
t.Fatalf("Unexpected error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
@ -388,7 +388,7 @@ func TestConfig_MultipleHosts(t *testing.T) {
|
||||||
|
|
||||||
// Verify each host can be retrieved correctly
|
// Verify each host can be retrieved correctly
|
||||||
for _, h := range hosts {
|
for _, h := range hosts {
|
||||||
host, err := cfg.GetHost(h.hostname, "")
|
host, err := cfg.GetHost(h.hostname, "", "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Errorf("Failed to get host %s: %v", h.hostname, err)
|
t.Errorf("Failed to get host %s: %v", h.hostname, err)
|
||||||
continue
|
continue
|
||||||
|
|
@ -422,13 +422,233 @@ func TestConfig_GitProtocol(t *testing.T) {
|
||||||
})
|
})
|
||||||
|
|
||||||
// Verify protocols are stored correctly
|
// Verify protocols are stored correctly
|
||||||
sshHost, _ := cfg.GetHost("test-ssh.org", "")
|
sshHost, _ := cfg.GetHost("test-ssh.org", "", "")
|
||||||
if sshHost.GitProtocol != "ssh" {
|
if sshHost.GitProtocol != "ssh" {
|
||||||
t.Errorf("Expected git_protocol 'ssh', got '%s'", sshHost.GitProtocol)
|
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" {
|
if httpsHost.GitProtocol != "https" {
|
||||||
t.Errorf("Expected git_protocol 'https', got '%s'", httpsHost.GitProtocol)
|
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")
|
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:
|
// parseRemoteURL extracts owner/name/hostname from various git URL formats:
|
||||||
// - https://codeberg.org/owner/name.git
|
// - https://codeberg.org/owner/name.git
|
||||||
// - git@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",
|
name: "HTTPS URL with .git",
|
||||||
url: "https://codeberg.org/romaintb/fgj.git",
|
url: "https://codeberg.org/romaintb/fj.git",
|
||||||
wantOwner: "romaintb",
|
wantOwner: "romaintb",
|
||||||
wantName: "fgj",
|
wantName: "fj",
|
||||||
wantHost: "codeberg.org",
|
wantHost: "codeberg.org",
|
||||||
wantErr: false,
|
wantErr: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "HTTPS URL without .git",
|
name: "HTTPS URL without .git",
|
||||||
url: "https://codeberg.org/romaintb/fgj",
|
url: "https://codeberg.org/romaintb/fj",
|
||||||
wantOwner: "romaintb",
|
wantOwner: "romaintb",
|
||||||
wantName: "fgj",
|
wantName: "fj",
|
||||||
wantHost: "codeberg.org",
|
wantHost: "codeberg.org",
|
||||||
wantErr: false,
|
wantErr: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "SSH URL with .git",
|
name: "SSH URL with .git",
|
||||||
url: "git@codeberg.org:romaintb/fgj.git",
|
url: "git@codeberg.org:romaintb/fj.git",
|
||||||
wantOwner: "romaintb",
|
wantOwner: "romaintb",
|
||||||
wantName: "fgj",
|
wantName: "fj",
|
||||||
wantHost: "codeberg.org",
|
wantHost: "codeberg.org",
|
||||||
wantErr: false,
|
wantErr: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "SSH URL without .git",
|
name: "SSH URL without .git",
|
||||||
url: "git@codeberg.org:romaintb/fgj",
|
url: "git@codeberg.org:romaintb/fj",
|
||||||
wantOwner: "romaintb",
|
wantOwner: "romaintb",
|
||||||
wantName: "fgj",
|
wantName: "fj",
|
||||||
wantHost: "codeberg.org",
|
wantHost: "codeberg.org",
|
||||||
wantErr: false,
|
wantErr: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "SSH protocol URL",
|
name: "SSH protocol URL",
|
||||||
url: "ssh://git@codeberg.org/romaintb/fgj.git",
|
url: "ssh://git@codeberg.org/romaintb/fj.git",
|
||||||
wantOwner: "romaintb",
|
wantOwner: "romaintb",
|
||||||
wantName: "fgj",
|
wantName: "fj",
|
||||||
wantHost: "codeberg.org",
|
wantHost: "codeberg.org",
|
||||||
wantErr: false,
|
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"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
"forgejo.zerova.net/sid/fgj-sid/cmd"
|
"forgejo.zerova.net/public/fj/cmd"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
if err := cmd.Execute(); err != nil {
|
if err := cmd.Execute(); err != nil {
|
||||||
|
err = cmd.ContextualError(err)
|
||||||
if cmd.JSONErrors() {
|
if cmd.JSONErrors() {
|
||||||
cmd.WriteJSONError(err)
|
cmd.WriteJSONError(err)
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import (
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"code.gitea.io/sdk/gitea"
|
"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 {
|
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 == "" {
|
if binaryPath == "" {
|
||||||
// Look for the binary in common locations
|
// Look for the binary in common locations
|
||||||
candidates := []string{
|
candidates := []string{
|
||||||
"./bin/fgj",
|
"./bin/fj",
|
||||||
"bin/fgj",
|
"bin/fj",
|
||||||
"/home/romain/work/fgj/bin/fgj",
|
"/home/romain/work/fj/bin/fj",
|
||||||
}
|
}
|
||||||
for _, candidate := range candidates {
|
for _, candidate := range candidates {
|
||||||
if _, err := os.Stat(candidate); err == nil {
|
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)
|
// If no binary found, return default (will error when executed)
|
||||||
binaryPath = "./bin/fgj"
|
binaryPath = "./bin/fj"
|
||||||
}
|
}
|
||||||
return binaryPath
|
return binaryPath
|
||||||
}
|
}
|
||||||
|
|
@ -295,3 +299,39 @@ func (env *TestEnv) RunCLI(args ...string) *CLIResult {
|
||||||
|
|
||||||
return result
|
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