Compare commits
No commits in common. "main" and "v0.3.0b" have entirely different histories.
43 changed files with 1497 additions and 6473 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/fj"
|
echo "Binary built at: $(pwd)/bin/fgj"
|
||||||
|
|
||||||
- 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/fj"
|
echo "Binary built at: $(pwd)/bin/fgj"
|
||||||
|
|
||||||
- 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,6 +1,5 @@
|
||||||
# Binaries
|
# Binaries
|
||||||
fj
|
fgj
|
||||||
bin/
|
|
||||||
*.exe
|
*.exe
|
||||||
*.exe~
|
*.exe~
|
||||||
*.dll
|
*.dll
|
||||||
|
|
@ -32,5 +31,3 @@ config.yaml
|
||||||
|
|
||||||
# Git worktrees
|
# Git worktrees
|
||||||
.worktrees/
|
.worktrees/
|
||||||
# Workspace (scratch data, cloned repos, analysis)
|
|
||||||
.workspace/
|
|
||||||
|
|
|
||||||
250
CHANGELOG.md
250
CHANGELOG.md
|
|
@ -5,151 +5,22 @@ 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
|
||||||
- `fj repo edit` - Edit repository settings (visibility, description, homepage, default branch)
|
- `fgj repo edit` - Edit repository settings (visibility, description, homepage, default branch)
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
- `fj repo create --public` flag was defined but never read; now properly wired up
|
- `fgj repo create --public` flag was defined but never read; now properly wired up
|
||||||
|
|
||||||
## [0.3.0a] - 2026-03-21
|
## [0.3.0a] - 2026-03-21
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
#### Raw API Access
|
#### Raw API Access
|
||||||
- `fj api <endpoint>` - Make authenticated REST API requests to any Forgejo/Gitea endpoint
|
- `fgj api <endpoint>` - Make authenticated REST API requests to any Forgejo/Gitea endpoint
|
||||||
- HTTP method selection (`--method`/`-X`), auto-switches to POST when fields are provided
|
- 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`)
|
||||||
|
|
@ -159,14 +30,14 @@ this release ships fixes for all 13.
|
||||||
- Response header display (`--include`/`-i`)
|
- Response header display (`--include`/`-i`)
|
||||||
|
|
||||||
#### Pull Request Management
|
#### Pull Request Management
|
||||||
- `fj pr diff <number>` - View the diff for a pull request
|
- `fgj pr diff <number>` - View the diff for a pull request
|
||||||
- Colorized output (`--color auto/always/never`)
|
- Colorized output (`--color auto/always/never`)
|
||||||
- Changed file names only (`--name-only`)
|
- Changed file names only (`--name-only`)
|
||||||
- Diffstat summary (`--stat`)
|
- Diffstat summary (`--stat`)
|
||||||
- `fj pr comment <number>` - Add a comment to a pull request
|
- `fgj pr comment <number>` - Add a comment to a pull request
|
||||||
- Body from flag (`--body`/`-b`) or file (`--body-file`, `-` for stdin)
|
- Body from flag (`--body`/`-b`) or file (`--body-file`, `-` for stdin)
|
||||||
- JSON output (`--json`)
|
- JSON output (`--json`)
|
||||||
- `fj pr review <number>` - Submit a review on a pull request
|
- `fgj pr review <number>` - Submit a review on a pull request
|
||||||
- Approve (`--approve`/`-a`), request changes (`--request-changes`/`-r`), or comment (`--comment`/`-c`)
|
- 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`)
|
||||||
|
|
@ -182,30 +53,30 @@ this release ships fixes for all 13.
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
#### Forgejo Actions
|
#### Forgejo Actions
|
||||||
- `fj actions run watch <run-id>` - Poll a run until completion
|
- `fgj actions run watch <run-id>` - Poll a run until completion
|
||||||
- `fj actions run rerun <run-id>` - Trigger a rerun of a workflow run
|
- `fgj actions run rerun <run-id>` - Trigger a rerun of a workflow run
|
||||||
- `fj actions run cancel <run-id>` - Cancel an in-progress workflow run
|
- `fgj actions run cancel <run-id>` - Cancel an in-progress workflow run
|
||||||
- `fj actions workflow enable <workflow>` - Enable a workflow
|
- `fgj actions workflow enable <workflow>` - Enable a workflow
|
||||||
- `fj actions workflow disable <workflow>` - Disable a workflow
|
- `fgj actions workflow disable <workflow>` - Disable a workflow
|
||||||
|
|
||||||
#### Repository Management
|
#### Repository Management
|
||||||
- `fj repo create <name>` - Create a new repository with full option set: `--private`/`--public`, `--description`, `--add-readme`, `--gitignore`, `--license`, `--homepage`, `--clone`, `--team`
|
- `fgj repo create <name>` - Create a new repository with full option set: `--private`/`--public`, `--description`, `--add-readme`, `--gitignore`, `--license`, `--homepage`, `--clone`, `--team`
|
||||||
|
|
||||||
#### Issue Management
|
#### Issue Management
|
||||||
- `fj issue create -l <label>` - Assign labels when creating an issue
|
- `fgj issue create -l <label>` - Assign labels when creating an issue
|
||||||
- `fj issue edit --add-label` / `--remove-label` - Add or remove labels on existing issues
|
- `fgj issue edit --add-label` / `--remove-label` - Add or remove labels on existing issues
|
||||||
- `fj issue close -c <comment>` - Close an issue with an optional comment
|
- `fgj issue close -c <comment>` - Close an issue with an optional comment
|
||||||
|
|
||||||
#### Workflow Management
|
#### Workflow Management
|
||||||
- `fj actions workflow list/view/run` - List, view, and trigger workflows
|
- `fgj actions workflow list/view/run` - List, view, and trigger workflows
|
||||||
|
|
||||||
#### Auth Helpers
|
#### Auth Helpers
|
||||||
- `fj auth token` - Print the stored token for the current host
|
- `fgj auth token` - Print the stored token for the current host
|
||||||
- `fj auth logout` - Remove authentication for a host
|
- `fgj auth logout` - Remove authentication for a host
|
||||||
|
|
||||||
#### Shell Completions and Man Pages
|
#### Shell Completions and Man Pages
|
||||||
- `fj completion [bash|zsh|fish|powershell]` - Generate shell completion scripts
|
- `fgj completion [bash|zsh|fish|powershell]` - Generate shell completion scripts
|
||||||
- `fj manpages --dir <path>` - Generate man pages for all commands
|
- `fgj 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
|
||||||
|
|
@ -218,17 +89,17 @@ this release ships fixes for all 13.
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
#### Release Management
|
#### Release Management
|
||||||
- `fj release list` - List releases for a repository
|
- `fgj release list` - List releases for a repository
|
||||||
- `fj release view` - View details of a specific release (supports "latest" keyword)
|
- `fgj release view` - View details of a specific release (supports "latest" keyword)
|
||||||
- `fj release create` - Create new releases with optional asset uploads
|
- `fgj release create` - Create new releases with optional asset uploads
|
||||||
- `fj release upload` - Upload assets to existing releases with optional clobber support
|
- `fgj release upload` - Upload assets to existing releases with optional clobber support
|
||||||
- `fj release delete` - Delete releases (preserves Git tags)
|
- `fgj release delete` - Delete releases (preserves Git tags)
|
||||||
|
|
||||||
#### Issue Management
|
#### Issue Management
|
||||||
- `fj issue edit` - Edit existing issues with support for updating title, body, and labels
|
- `fgj issue edit` - Edit existing issues with support for updating title, body, and labels
|
||||||
|
|
||||||
#### Pull Request Management
|
#### Pull Request Management
|
||||||
- `fj pr create --assignee` - Assign users when creating pull requests
|
- `fgj pr create --assignee` - Assign users when creating pull requests
|
||||||
|
|
||||||
#### Repository Detection
|
#### Repository Detection
|
||||||
- Automatic hostname detection from git remote URLs
|
- Automatic hostname detection from git remote URLs
|
||||||
|
|
@ -249,48 +120,48 @@ this release ships fixes for all 13.
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
#### Core Features
|
#### Core Features
|
||||||
- Initial release of fj - Forgejo CLI tool
|
- Initial release of fgj - 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/fj/config.yaml`
|
- Configuration management via `~/.config/fgj/config.yaml`
|
||||||
|
|
||||||
#### Pull Request Management
|
#### Pull Request Management
|
||||||
- `fj pr list` - List pull requests with filtering by state
|
- `fgj pr list` - List pull requests with filtering by state
|
||||||
- `fj pr view` - View detailed pull request information
|
- `fgj pr view` - View detailed pull request information
|
||||||
- `fj pr create` - Create new pull requests
|
- `fgj pr create` - Create new pull requests
|
||||||
- `fj pr merge` - Merge pull requests with configurable merge methods
|
- `fgj pr merge` - Merge pull requests with configurable merge methods
|
||||||
|
|
||||||
#### Issue Management
|
#### Issue Management
|
||||||
- `fj issue list` - List issues with state filtering
|
- `fgj issue list` - List issues with state filtering
|
||||||
- `fj issue view` - View detailed issue information
|
- `fgj issue view` - View detailed issue information
|
||||||
- `fj issue create` - Create new issues
|
- `fgj issue create` - Create new issues
|
||||||
- `fj issue comment` - Add comments to issues
|
- `fgj issue comment` - Add comments to issues
|
||||||
- `fj issue close` - Close issues
|
- `fgj issue close` - Close issues
|
||||||
|
|
||||||
#### Repository Operations
|
#### Repository Operations
|
||||||
- `fj repo view` - View repository details
|
- `fgj repo view` - View repository details
|
||||||
- `fj repo list` - List user repositories
|
- `fgj repo list` - List user repositories
|
||||||
- `fj repo clone` - Clone repositories with protocol selection (HTTPS/SSH)
|
- `fgj repo clone` - Clone repositories with protocol selection (HTTPS/SSH)
|
||||||
- `fj repo fork` - Fork repositories
|
- `fgj repo fork` - Fork repositories
|
||||||
|
|
||||||
#### Forgejo Actions Support
|
#### Forgejo Actions Support
|
||||||
- `fj actions run list` - List workflow runs with status and metadata
|
- `fgj actions run list` - List workflow runs with status and metadata
|
||||||
- `fj actions run view` - View detailed run information, jobs, and logs
|
- `fgj 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
|
||||||
- `fj actions secret list` - List repository secrets
|
- `fgj actions secret list` - List repository secrets
|
||||||
- `fj actions secret create` - Create repository secrets
|
- `fgj actions secret create` - Create repository secrets
|
||||||
- `fj actions secret delete` - Delete repository secrets
|
- `fgj actions secret delete` - Delete repository secrets
|
||||||
- `fj actions variable list` - List repository variables
|
- `fgj actions variable list` - List repository variables
|
||||||
- `fj actions variable get` - Get variable values
|
- `fgj actions variable get` - Get variable values
|
||||||
- `fj actions variable create` - Create repository variables
|
- `fgj actions variable create` - Create repository variables
|
||||||
- `fj actions variable update` - Update repository variables
|
- `fgj actions variable update` - Update repository variables
|
||||||
- `fj actions variable delete` - Delete repository variables
|
- `fgj actions variable delete` - Delete repository variables
|
||||||
|
|
||||||
#### Authentication
|
#### Authentication
|
||||||
- `fj auth login` - Interactive authentication with Forgejo instances
|
- `fgj auth login` - Interactive authentication with Forgejo instances
|
||||||
- `fj auth status` - Check authentication status
|
- `fgj auth status` - Check authentication status
|
||||||
- Environment variable support (`FJ_HOST`, `FJ_TOKEN`)
|
- Environment variable support (`FGJ_HOST`, `FGJ_TOKEN`)
|
||||||
|
|
||||||
#### Development
|
#### Development
|
||||||
- Comprehensive unit test suite
|
- Comprehensive unit test suite
|
||||||
|
|
@ -304,9 +175,8 @@ this release ships fixes for all 13.
|
||||||
- Cobra framework for CLI structure
|
- Cobra framework for CLI structure
|
||||||
- Viper for configuration management
|
- Viper for configuration management
|
||||||
|
|
||||||
[0.3.0c]: https://forgejo.zerova.net/public/fj/releases/tag/v0.3.0c
|
[0.3.0b]: https://forgejo.zerova.net/sid/fgj-sid/releases/tag/v0.3.0b
|
||||||
[0.3.0b]: https://forgejo.zerova.net/public/fj/releases/tag/v0.3.0b
|
[0.3.0a]: https://forgejo.zerova.net/sid/fgj-sid/releases/tag/v0.3.0a
|
||||||
[0.3.0a]: https://forgejo.zerova.net/public/fj/releases/tag/v0.3.0a
|
[0.3.0]: https://codeberg.org/romaintb/fgj/releases/tag/v0.3.0
|
||||||
[0.3.0]: https://codeberg.org/romaintb/fj/releases/tag/v0.3.0
|
[0.2.0]: https://codeberg.org/romaintb/fgj/releases/tag/v0.2.0
|
||||||
[0.2.0]: https://codeberg.org/romaintb/fj/releases/tag/v0.2.0
|
[0.1.0]: https://codeberg.org/romaintb/fgj/releases/tag/v0.1.0
|
||||||
[0.1.0]: https://codeberg.org/romaintb/fj/releases/tag/v0.1.0
|
|
||||||
|
|
|
||||||
166
CLAUDE.md
166
CLAUDE.md
|
|
@ -1,166 +0,0 @@
|
||||||
# fj — guide for Claude Code sessions
|
|
||||||
|
|
||||||
`fj` is a personal Forgejo/Gitea CLI tool, modeled on GitHub's `gh`. It targets `forgejo.zerova.net` (and Codeberg). The user (sid) owns it; the canonical repo is `public/fj` on forgejo.zerova.net (mirrored from there to nowhere else).
|
|
||||||
|
|
||||||
This file is read first by Claude Code when working in `~/repos/fj`. Goal: get a session productive quickly without re-deriving the dev workflow each time.
|
|
||||||
|
|
||||||
## Layout
|
|
||||||
|
|
||||||
```
|
|
||||||
~/repos/fj/
|
|
||||||
├── cmd/ cobra command definitions, one file per subject area
|
|
||||||
│ ├── root.go rootCmd, --config plumbing, OnInitialize
|
|
||||||
│ ├── auth.go login/status/logout/token (uses persistent --hostname)
|
|
||||||
│ ├── api.go raw API access; --json/--json-fields/--jq/--paginate
|
|
||||||
│ ├── json.go shared JSON output helpers (addJSONFlags/wantJSON/outputJSON)
|
|
||||||
│ ├── paginate.go generic paginateGitea[T] helper for list commands
|
|
||||||
│ ├── errors.go CLIError with structured Hint field
|
|
||||||
│ ├── actions.go Forgejo Actions; runs/workflows via factory functions
|
|
||||||
│ ├── aliases.go top-level `fj run` / `fj workflow` aliases — calls actions.go factories
|
|
||||||
│ ├── repo.go pr.go issue.go release.go wiki.go label.go milestone.go
|
|
||||||
│ └── ...
|
|
||||||
├── internal/
|
|
||||||
│ ├── api/client.go SharedHTTPClient (30s timeout); GetJSON/DoJSON/DownloadFile
|
|
||||||
│ ├── config/config.go YAML config; honors --config via SetExplicitConfigPath
|
|
||||||
│ ├── git/ repo + host detection from `git remote`
|
|
||||||
│ ├── iostreams/ wrapped stdin/stdout/stderr + spinner + pager + colors
|
|
||||||
│ └── text/ formatting helpers
|
|
||||||
├── main.go thin entrypoint; ContextualError + JSON-error rendering
|
|
||||||
├── Makefile build / lint / test (no release automation)
|
|
||||||
├── CHANGELOG.md Keep-a-Changelog format
|
|
||||||
└── README.md
|
|
||||||
```
|
|
||||||
|
|
||||||
## Build, install, test
|
|
||||||
|
|
||||||
```bash
|
|
||||||
go build ./... # quick build check
|
|
||||||
go test ./... # unit tests
|
|
||||||
go install . # build + install to ~/go/bin/fj (the binary that's on PATH)
|
|
||||||
make lint # golangci-lint, if you have it
|
|
||||||
```
|
|
||||||
|
|
||||||
After any change in cmd/ or internal/, run `go install .` and the global `fj` reflects it immediately. There's no daemon/restart.
|
|
||||||
|
|
||||||
## Auth
|
|
||||||
|
|
||||||
The user is authenticated as `sid` on `forgejo.zerova.net`. Token lives in `~/.config/fj/config.yaml` (mode 0600). For HTTPS git pushes from this host, the token can be injected via `git -c "http.extraHeader=Authorization: token <T>" push` — the local SSH key (`sid@debian` on forgejo) is also registered, so `git@forgejo.zerova.net:public/fj.git` works directly.
|
|
||||||
|
|
||||||
## Code review pattern (use this for non-trivial changes)
|
|
||||||
|
|
||||||
For audits or significant refactors, run **three reviewers in parallel** with non-overlapping focuses (we did this in the v0.4.0 cycle and it found bugs none would have caught alone):
|
|
||||||
|
|
||||||
- **Codex** — read-only sandbox, peer-AI cross-check
|
|
||||||
```bash
|
|
||||||
codex exec --skip-git-repo-check --sandbox read-only \
|
|
||||||
-m gpt-5.4-mini --config model_reasoning_effort="medium" "<prompt>" 2>/dev/null
|
|
||||||
```
|
|
||||||
For follow-up rounds resume the same session: `echo "<prompt>" | codex exec --skip-git-repo-check resume --last 2>/dev/null`. Codex remembers prior critique.
|
|
||||||
|
|
||||||
- **Claude general-purpose agent A** — architecture / UX / code-quality
|
|
||||||
- **Claude general-purpose agent B** — security / correctness / error handling
|
|
||||||
|
|
||||||
Tell each reviewer what the **siblings** are covering so they don't duplicate. Cap reports at ~600 words. Consolidate findings by severity (HIGH / MEDIUM / LOW) before presenting to the user.
|
|
||||||
|
|
||||||
## Release process
|
|
||||||
|
|
||||||
We use semver. **Pre-1.0**: breaking change → minor bump (e.g. v0.3.x → v0.4.0).
|
|
||||||
|
|
||||||
1. **Bump version**
|
|
||||||
```go
|
|
||||||
// cmd/root.go
|
|
||||||
Version: "0.4.0",
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Update CHANGELOG.md** — prepend a new section. Format:
|
|
||||||
```markdown
|
|
||||||
## [0.4.0] - YYYY-MM-DD
|
|
||||||
|
|
||||||
### BREAKING
|
|
||||||
- <thing that broke>
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- ...
|
|
||||||
### Changed
|
|
||||||
- ...
|
|
||||||
### Fixed
|
|
||||||
- ...
|
|
||||||
### Security
|
|
||||||
- ...
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Commit** the version+changelog bump as a single commit:
|
|
||||||
```bash
|
|
||||||
git commit -m "chore: bump version to 0.4.0"
|
|
||||||
```
|
|
||||||
|
|
||||||
4. **Tag** the commit:
|
|
||||||
```bash
|
|
||||||
git tag -a v0.4.0 -m "Release v0.4.0: <one-line summary>"
|
|
||||||
```
|
|
||||||
|
|
||||||
5. **Push** commits and tag:
|
|
||||||
```bash
|
|
||||||
git push origin main
|
|
||||||
git push origin v0.4.0
|
|
||||||
```
|
|
||||||
|
|
||||||
6. **Create the Forgejo release page** via fj itself:
|
|
||||||
```bash
|
|
||||||
fj release create v0.4.0 \
|
|
||||||
--title "v0.4.0: <summary>" \
|
|
||||||
--notes "$(awk '/^## \[0.4.0\]/{flag=1;next} /^## /{flag=0} flag' CHANGELOG.md)"
|
|
||||||
```
|
|
||||||
(The awk one-liner extracts the just-added CHANGELOG section as release notes.)
|
|
||||||
|
|
||||||
7. **Update the homebrew tap** — see the next section.
|
|
||||||
|
|
||||||
## Updating the homebrew tap (`public/homebrew-sid`)
|
|
||||||
|
|
||||||
The tap lives at `~/repos/homebrew-sid` (or `git@forgejo.zerova.net:public/homebrew-sid.git`). The `Formula/fj.rb` formula references the source by `tag:` + `revision:` (SHA), so a release bump touches three lines:
|
|
||||||
|
|
||||||
```ruby
|
|
||||||
url "ssh://git@forgejo.zerova.net/public/fj.git",
|
|
||||||
tag: "v0.4.0", # was v0.3.2
|
|
||||||
revision: "<SHA of v0.4.0 tag>" # update
|
|
||||||
|
|
||||||
test do
|
|
||||||
assert_match "0.4.0", shell_output("#{bin}/fj --version") # update
|
|
||||||
end
|
|
||||||
```
|
|
||||||
|
|
||||||
To get the SHA:
|
|
||||||
```bash
|
|
||||||
git -C ~/repos/fj rev-parse v0.4.0
|
|
||||||
```
|
|
||||||
|
|
||||||
Then in `~/repos/homebrew-sid`:
|
|
||||||
```bash
|
|
||||||
# edit Formula/fj.rb (the three lines above)
|
|
||||||
git commit -am "fj: bump to v0.4.0"
|
|
||||||
git push origin main
|
|
||||||
```
|
|
||||||
|
|
||||||
After push, users can `brew update && brew upgrade fj` to pick up the new version.
|
|
||||||
|
|
||||||
## Common footguns
|
|
||||||
|
|
||||||
- **`fj` reads the current dir's `git origin`** to detect the host. In a directory whose origin points at github.com (e.g. /opt/stacks/claude-code-proxy/build), bare `fj api ...` errors with "no configuration found for host github.com". Pass `--hostname forgejo.zerova.net` explicitly, or `cd` somewhere else.
|
|
||||||
- **`--json=fields` was removed in v0.4.0** in favor of `--json-fields fields` (or `--json-fields=fields`). The old `=fields` form was a `NoOptDefVal=" "` sentinel hack. `--json` is now a plain Bool meaning "as JSON".
|
|
||||||
- **`--config` was silently ignored before v0.4.0.** Old fj versions read --config into Viper but `internal/config.Load()` always read the default path. Fixed; `fj --config other.yaml auth login` now writes to other.yaml.
|
|
||||||
- **The `actions` and `run`/`workflow` command trees share factory functions** in `cmd/actions.go` (`newRunCmd`, `newWorkflowCmd`). Don't add flags directly to `runListCmd` style globals — they don't exist anymore. Edit the factory and both `fj actions run list` and `fj run list` get the change.
|
|
||||||
|
|
||||||
## Useful commands
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Live test against forgejo (using the new flags)
|
|
||||||
fj --hostname forgejo.zerova.net api repos/public/fj --json-fields full_name,description
|
|
||||||
|
|
||||||
# Walk paginated endpoints
|
|
||||||
fj --hostname forgejo.zerova.net api 'repos/public/fj/commits?limit=10' --paginate --jq '.[].sha[0:8]'
|
|
||||||
|
|
||||||
# Confirm both command trees stay in sync after edits
|
|
||||||
diff <(fj run list --help | grep -E "^ -|^ --" | sort) \
|
|
||||||
<(fj actions run list --help | grep -E "^ -|^ --" | sort)
|
|
||||||
# Empty diff = trees agree. Any output = factory drift.
|
|
||||||
```
|
|
||||||
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/fj .
|
go build -o bin/fgj .
|
||||||
|
|
||||||
install: build
|
install: build
|
||||||
install -Dm755 bin/fj /usr/bin/fj
|
install -Dm755 bin/fgj /usr/bin/fgj
|
||||||
|
|
||||||
run:
|
run:
|
||||||
go run .
|
go run .
|
||||||
|
|
|
||||||
354
README.md
354
README.md
|
|
@ -1,11 +1,11 @@
|
||||||
# fj - Forgejo/Gitea CLI Tool
|
# fgj - Forgejo/Gitea CLI Tool
|
||||||
|
|
||||||
[](https://golang.org)
|
[](https://golang.org)
|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
|
|
||||||
`fj` is a command-line tool for working with Forgejo and Gitea instances. It brings pull requests, issues, and other forge concepts to the terminal, similar to what `gh` does for GitHub. This fork adds agentic dev features — raw API access, PR review workflows, structured error output, and machine-readable I/O for AI coding agents.
|
`fgj` is a command-line tool for working with Forgejo and Gitea instances. It brings pull requests, issues, and other forge concepts to the terminal, similar to what `gh` does for GitHub. This fork adds agentic dev features — raw API access, PR review workflows, structured error output, and machine-readable I/O for AI coding agents.
|
||||||
|
|
||||||
> Forked from [codeberg.org/romaintb/fj](https://codeberg.org/romaintb/fj) and hosted at [forgejo.zerova.net/public/fj](https://forgejo.zerova.net/public/fj).
|
> Forked from [codeberg.org/romaintb/fgj](https://codeberg.org/romaintb/fgj) and hosted at [forgejo.zerova.net/sid/fgj-sid](https://forgejo.zerova.net/sid/fgj-sid).
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
|
|
@ -13,13 +13,9 @@
|
||||||
- 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 (`fj api`) for arbitrary REST calls
|
- Raw API access (`fgj 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
|
||||||
|
|
@ -33,22 +29,22 @@
|
||||||
### macOS (Homebrew)
|
### macOS (Homebrew)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
brew tap public/sid git@forgejo.zerova.net:public/homebrew-sid.git
|
brew tap sid/fgj-sid https://forgejo.zerova.net/sid/homebrew-fgj-sid.git
|
||||||
brew install fj
|
brew install fgj
|
||||||
```
|
```
|
||||||
|
|
||||||
### Using Go Install
|
### Using Go Install
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
go install forgejo.zerova.net/public/fj@latest
|
go install forgejo.zerova.net/sid/fgj-sid@latest
|
||||||
```
|
```
|
||||||
|
|
||||||
### From Source
|
### From Source
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://forgejo.zerova.net/public/fj.git
|
git clone https://forgejo.zerova.net/sid/fgj-sid.git
|
||||||
cd fj
|
cd fgj-sid
|
||||||
go build -o fj .
|
go build -o fgj .
|
||||||
```
|
```
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
@ -58,7 +54,7 @@ go build -o fj .
|
||||||
First, authenticate with your Forgejo or Gitea instance:
|
First, authenticate with your Forgejo or Gitea instance:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
fj auth login
|
fgj auth login
|
||||||
```
|
```
|
||||||
|
|
||||||
You'll be prompted for:
|
You'll be prompted for:
|
||||||
|
|
@ -74,34 +70,34 @@ To create a personal access token:
|
||||||
### 2. Check Authentication Status
|
### 2. Check Authentication Status
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
fj auth status
|
fgj auth status
|
||||||
```
|
```
|
||||||
|
|
||||||
### Auth Helpers
|
### Auth Helpers
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Print the stored token for the current host
|
# Print the stored token for the current host
|
||||||
fj auth token
|
fgj auth token
|
||||||
|
|
||||||
# Remove authentication for a host
|
# Remove authentication for a host
|
||||||
fj auth logout
|
fgj auth logout
|
||||||
```
|
```
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
### Repository Detection
|
### Repository Detection
|
||||||
|
|
||||||
`fj` automatically detects the repository from your git context, similar to `gh`:
|
`fgj` automatically detects the repository from your git context, similar to `gh`:
|
||||||
|
|
||||||
```bash
|
```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
|
||||||
fj pr list # Automatically uses current repo
|
fgj pr list # Automatically uses current repo
|
||||||
fj issue list # Automatically uses current repo
|
fgj issue list # Automatically uses current repo
|
||||||
fj pr view 123 # Automatically uses current repo
|
fgj pr view 123 # Automatically uses current repo
|
||||||
|
|
||||||
# Or explicitly specify a repository with -R
|
# Or explicitly specify a repository with -R
|
||||||
fj pr list -R owner/repo
|
fgj pr list -R owner/repo
|
||||||
```
|
```
|
||||||
|
|
||||||
The tool reads `.git/config` to find the origin remote and extract both the owner/repo information and the instance hostname. If you're not in a git repository, you'll need to use the `-R` flag.
|
The tool reads `.git/config` to find the origin remote and extract both the owner/repo information and the instance hostname. If you're not in a git repository, you'll need to use the `-R` flag.
|
||||||
|
|
@ -110,307 +106,240 @@ 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)
|
||||||
fj pr list
|
fgj pr list
|
||||||
|
|
||||||
# Or specify explicitly
|
# Or specify explicitly
|
||||||
fj pr list -R owner/repo
|
fgj pr list -R owner/repo
|
||||||
|
|
||||||
# Filter by state
|
# Filter by state
|
||||||
fj pr list --state closed
|
fgj pr list --state closed
|
||||||
|
|
||||||
# View a specific pull request
|
# View a specific pull request
|
||||||
fj pr view 123
|
fgj pr view 123
|
||||||
|
|
||||||
# Create a pull request
|
# Create a pull request
|
||||||
fj pr create -t "PR Title" -b "PR Description" -H feature-branch -B main
|
fgj pr create -t "PR Title" -b "PR Description" -H feature-branch -B main
|
||||||
|
|
||||||
# Merge a pull request
|
# Merge a pull request
|
||||||
fj pr merge 123 --merge-method squash
|
fgj pr merge 123 --merge-method squash
|
||||||
|
|
||||||
# View PR diff
|
# View PR diff
|
||||||
fj pr diff 123
|
fgj pr diff 123
|
||||||
|
|
||||||
# View diff with color
|
# View diff with color
|
||||||
fj pr diff 123 --color always
|
fgj pr diff 123 --color always
|
||||||
|
|
||||||
# Show only changed file names
|
# Show only changed file names
|
||||||
fj pr diff 123 --name-only
|
fgj pr diff 123 --name-only
|
||||||
|
|
||||||
# Show diffstat summary
|
# Show diffstat summary
|
||||||
fj pr diff 123 --stat
|
fgj pr diff 123 --stat
|
||||||
|
|
||||||
# Comment on a pull request
|
# Comment on a pull request
|
||||||
fj pr comment 123 -b "Looks good, minor nit on line 42"
|
fgj pr comment 123 -b "Looks good, minor nit on line 42"
|
||||||
|
|
||||||
# Comment from a file
|
# Comment from a file
|
||||||
fj pr comment 123 --body-file review-notes.md
|
fgj pr comment 123 --body-file review-notes.md
|
||||||
|
|
||||||
# Approve a pull request
|
# Approve a pull request
|
||||||
fj pr review 123 --approve -b "LGTM"
|
fgj pr review 123 --approve -b "LGTM"
|
||||||
|
|
||||||
# Request changes
|
# Request changes
|
||||||
fj pr review 123 --request-changes -b "Please fix the error handling"
|
fgj pr review 123 --request-changes -b "Please fix the error handling"
|
||||||
|
|
||||||
# Submit a review comment (neither approve nor request changes)
|
# Submit a review comment (neither approve nor request changes)
|
||||||
fj pr review 123 --comment -b "Some observations"
|
fgj pr review 123 --comment -b "Some observations"
|
||||||
```
|
```
|
||||||
|
|
||||||
### Issues
|
### Issues
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# List issues (auto-detects repo and hostname from git)
|
# List issues (auto-detects repo and hostname from git)
|
||||||
fj issue list
|
fgj issue list
|
||||||
|
|
||||||
# Or specify explicitly
|
# Or specify explicitly
|
||||||
fj issue list -R owner/repo
|
fgj issue list -R owner/repo
|
||||||
|
|
||||||
# Filter by state
|
# Filter by state
|
||||||
fj issue list --state all
|
fgj issue list --state all
|
||||||
|
|
||||||
# View an issue
|
# View an issue
|
||||||
fj issue view 456
|
fgj issue view 456
|
||||||
|
|
||||||
# Create an issue
|
# Create an issue
|
||||||
fj issue create -t "Issue Title" -b "Issue Description"
|
fgj issue create -t "Issue Title" -b "Issue Description"
|
||||||
|
|
||||||
# Create an issue with labels
|
# Create an issue with labels
|
||||||
fj issue create -t "Issue Title" -b "Issue Description" -l bug -l enhancement
|
fgj issue create -t "Issue Title" -b "Issue Description" -l bug -l enhancement
|
||||||
|
|
||||||
# Comment on an issue
|
# Comment on an issue
|
||||||
fj issue comment 456 -b "My comment"
|
fgj issue comment 456 -b "My comment"
|
||||||
|
|
||||||
# Close an issue
|
# Close an issue
|
||||||
fj issue close 456
|
fgj issue close 456
|
||||||
|
|
||||||
# Close an issue with a comment
|
# Close an issue with a comment
|
||||||
fj issue close 456 -c "Fixed in v2.0"
|
fgj issue close 456 -c "Fixed in v2.0"
|
||||||
|
|
||||||
# Edit an issue (title, body, state, labels)
|
# Edit an issue (title, body, state, labels)
|
||||||
fj issue edit 456 -t "New Title"
|
fgj issue edit 456 -t "New Title"
|
||||||
fj issue edit 456 --add-label priority --remove-label bug
|
fgj issue edit 456 --add-label priority --remove-label bug
|
||||||
|
|
||||||
# Manage issue dependencies
|
|
||||||
fj issue edit 456 --add-dependency 123
|
|
||||||
fj issue edit 456 --remove-dependency 123
|
|
||||||
```
|
|
||||||
|
|
||||||
### 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
|
||||||
fj repo view owner/repo
|
fgj repo view owner/repo
|
||||||
|
|
||||||
# List your repositories
|
# List your repositories
|
||||||
fj repo list
|
fgj repo list
|
||||||
|
|
||||||
# Create a repository
|
# Create a repository
|
||||||
fj repo create my-repo
|
fgj repo create my-repo
|
||||||
fj repo create my-repo -d "My project" --private --add-readme -g Go -l MIT
|
fgj repo create my-repo -d "My project" --private --add-readme -g Go -l MIT
|
||||||
|
|
||||||
# Clone a repository
|
# Clone a repository
|
||||||
fj repo clone owner/repo
|
fgj repo clone owner/repo
|
||||||
|
|
||||||
# Clone via SSH
|
# Clone via SSH
|
||||||
fj repo clone owner/repo -p ssh
|
fgj repo clone owner/repo -p ssh
|
||||||
|
|
||||||
# Fork a repository
|
# Fork a repository
|
||||||
fj repo fork owner/repo
|
fgj repo fork owner/repo
|
||||||
|
|
||||||
# Edit repository settings
|
# Edit repository settings
|
||||||
fj repo edit owner/repo --public
|
fgj repo edit owner/repo --public
|
||||||
fj repo edit owner/repo --private
|
fgj repo edit owner/repo --private
|
||||||
fj repo edit owner/repo -d "New description" --homepage https://example.com
|
fgj repo edit owner/repo -d "New description" --homepage https://example.com
|
||||||
fj repo edit --default-branch develop
|
fgj 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
|
||||||
fj release list
|
fgj release list
|
||||||
|
|
||||||
# View a release (or use "latest")
|
# View a release (or use "latest")
|
||||||
fj release view v1.2.3
|
fgj release view v1.2.3
|
||||||
|
|
||||||
# Create a release with notes and optional assets
|
# Create a release with notes and optional assets
|
||||||
fj release create v1.2.3 -t "v1.2.3" -n "Release notes" ./dist/app.tar.gz
|
fgj release create v1.2.3 -t "v1.2.3" -n "Release notes" ./dist/app.tar.gz
|
||||||
|
|
||||||
# Upload assets to an existing release
|
# Upload assets to an existing release
|
||||||
fj release upload v1.2.3 ./dist/app.tar.gz --clobber
|
fgj release upload v1.2.3 ./dist/app.tar.gz --clobber
|
||||||
|
|
||||||
# Delete a release (keeps the Git tag)
|
# Delete a release (keeps the Git tag)
|
||||||
fj release delete v1.2.3
|
fgj release delete v1.2.3
|
||||||
```
|
```
|
||||||
|
|
||||||
### Forgejo Actions
|
### Forgejo Actions
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# List workflows
|
# List workflows
|
||||||
fj actions workflow list
|
fgj actions workflow list
|
||||||
|
|
||||||
# View a workflow
|
# View a workflow
|
||||||
fj actions workflow view ci.yml
|
fgj actions workflow view ci.yml
|
||||||
|
|
||||||
# Run a workflow (trigger workflow_dispatch)
|
# Run a workflow (trigger workflow_dispatch)
|
||||||
fj actions workflow run deploy.yml
|
fgj actions workflow run deploy.yml
|
||||||
|
|
||||||
# Run a workflow with inputs
|
# Run a workflow with inputs
|
||||||
fj actions workflow run deploy.yml -f environment=production -f version=1.2.3
|
fgj actions workflow run deploy.yml -f environment=production -f version=1.2.3
|
||||||
|
|
||||||
# Run a workflow on a specific branch
|
# Run a workflow on a specific branch
|
||||||
fj actions workflow run deploy.yml -r feature-branch
|
fgj actions workflow run deploy.yml -r feature-branch
|
||||||
|
|
||||||
# Enable or disable a workflow
|
# Enable or disable a workflow
|
||||||
fj actions workflow enable ci.yml
|
fgj actions workflow enable ci.yml
|
||||||
fj actions workflow disable ci.yml
|
fgj actions workflow disable ci.yml
|
||||||
|
|
||||||
# List workflow runs
|
# List workflow runs
|
||||||
fj actions run list
|
fgj actions run list
|
||||||
|
|
||||||
# View a specific run
|
# View a specific run
|
||||||
fj actions run view 123
|
fgj actions run view 123
|
||||||
|
|
||||||
# View run with job details
|
# View run with job details
|
||||||
fj actions run view 123 --verbose
|
fgj actions run view 123 --verbose
|
||||||
|
|
||||||
# View run logs
|
# View run logs
|
||||||
fj actions run view 123 --log
|
fgj actions run view 123 --log
|
||||||
|
|
||||||
# View specific job logs
|
# View specific job logs
|
||||||
fj actions run view 123 --job 456 --log
|
fgj actions run view 123 --job 456 --log
|
||||||
|
|
||||||
# Watch a run until completion
|
# Watch a run until completion
|
||||||
fj actions run watch 123
|
fgj actions run watch 123
|
||||||
|
|
||||||
# Rerun a workflow run
|
# Rerun a workflow run
|
||||||
fj actions run rerun 123
|
fgj actions run rerun 123
|
||||||
|
|
||||||
# Cancel a running workflow
|
# Cancel a running workflow
|
||||||
fj actions run cancel 123
|
fgj actions run cancel 123
|
||||||
|
|
||||||
# List secrets
|
# List secrets
|
||||||
fj actions secret list
|
fgj actions secret list
|
||||||
|
|
||||||
# Create a secret
|
# Create a secret
|
||||||
fj actions secret create MY_SECRET
|
fgj actions secret create MY_SECRET
|
||||||
|
|
||||||
# Delete a secret
|
# Delete a secret
|
||||||
fj actions secret delete MY_SECRET
|
fgj actions secret delete MY_SECRET
|
||||||
|
|
||||||
# List variables
|
# List variables
|
||||||
fj actions variable list
|
fgj actions variable list
|
||||||
|
|
||||||
# Get a variable
|
# Get a variable
|
||||||
fj actions variable get MY_VAR
|
fgj actions variable get MY_VAR
|
||||||
|
|
||||||
# Create a variable
|
# Create a variable
|
||||||
fj actions variable create MY_VAR "value"
|
fgj actions variable create MY_VAR "value"
|
||||||
|
|
||||||
# Update a variable
|
# Update a variable
|
||||||
fj actions variable update MY_VAR "new value"
|
fgj actions variable update MY_VAR "new value"
|
||||||
|
|
||||||
# Delete a variable
|
# Delete a variable
|
||||||
fj actions variable delete MY_VAR
|
fgj 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)
|
||||||
fj api /repos/{owner}/{repo}/pulls
|
fgj api /repos/{owner}/{repo}/pulls
|
||||||
|
|
||||||
# POST with fields
|
# POST with fields
|
||||||
fj api /repos/{owner}/{repo}/issues -X POST -f title="Bug report" -f body="Description"
|
fgj api /repos/{owner}/{repo}/issues -X POST -f title="Bug report" -f body="Description"
|
||||||
|
|
||||||
# Explicit method and hostname
|
# Explicit method and hostname
|
||||||
fj api /repos/myorg/myrepo/labels --hostname my-forgejo.example.com
|
fgj api /repos/myorg/myrepo/labels --hostname my-forgejo.example.com
|
||||||
|
|
||||||
# Read request body from file
|
# Read request body from file
|
||||||
fj api /repos/{owner}/{repo}/issues -X POST --input issue.json
|
fgj api /repos/{owner}/{repo}/issues -X POST --input issue.json
|
||||||
|
|
||||||
# Read from stdin
|
# Read from stdin
|
||||||
echo '{"title":"test"}' | fj api /repos/{owner}/{repo}/issues -X POST --input -
|
echo '{"title":"test"}' | fgj api /repos/{owner}/{repo}/issues -X POST --input -
|
||||||
|
|
||||||
# Include response headers
|
# Include response headers
|
||||||
fj api /repos/{owner}/{repo} -i
|
fgj api /repos/{owner}/{repo} -i
|
||||||
|
|
||||||
# Suppress output (useful for DELETE)
|
# Suppress output (useful for DELETE)
|
||||||
fj api /repos/{owner}/{repo}/issues/123 -X DELETE --silent
|
fgj api /repos/{owner}/{repo}/issues/123 -X DELETE --silent
|
||||||
```
|
```
|
||||||
|
|
||||||
## Shell Completions and Man Pages
|
## Shell Completions and Man Pages
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Generate shell completion scripts
|
# Generate shell completion scripts
|
||||||
fj completion bash > /etc/bash_completion.d/fj
|
fgj completion bash > /etc/bash_completion.d/fgj
|
||||||
fj completion zsh > "${fpath[1]}/_fj"
|
fgj completion zsh > "${fpath[1]}/_fgj"
|
||||||
fj completion fish > ~/.config/fish/completions/fj.fish
|
fgj completion fish > ~/.config/fish/completions/fgj.fish
|
||||||
|
|
||||||
# Generate man pages to a directory
|
# Generate man pages to a directory
|
||||||
fj manpages --dir ~/.local/share/man/man1
|
fgj manpages --dir ~/.local/share/man/man1
|
||||||
```
|
```
|
||||||
|
|
||||||
## JSON Output
|
## JSON Output
|
||||||
|
|
@ -418,15 +347,15 @@ fj 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
|
||||||
fj pr list --json
|
fgj pr list --json
|
||||||
fj issue view 456 --json
|
fgj issue view 456 --json
|
||||||
fj release list --json
|
fgj release list --json
|
||||||
fj actions run list --json
|
fgj actions run list --json
|
||||||
fj actions workflow view ci.yml --json
|
fgj actions workflow view ci.yml --json
|
||||||
|
|
||||||
# Get JSON output from PR comment/review
|
# Get JSON output from PR comment/review
|
||||||
fj pr comment 123 -b "LGTM" --json
|
fgj pr comment 123 -b "LGTM" --json
|
||||||
fj pr review 123 --approve -b "Ship it" --json
|
fgj pr review 123 --approve -b "Ship it" --json
|
||||||
```
|
```
|
||||||
|
|
||||||
### Structured Error Output
|
### Structured Error Output
|
||||||
|
|
@ -435,16 +364,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
|
||||||
fj pr view 9999 --json-errors
|
fgj 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
|
||||||
fj pr list --json --json-errors
|
fgj pr list --json --json-errors
|
||||||
```
|
```
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
Configuration is stored in `~/.config/fj/config.yaml`:
|
Configuration is stored in `~/.config/fgj/config.yaml`:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
hosts:
|
hosts:
|
||||||
|
|
@ -453,71 +382,38 @@ 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
|
||||||
|
|
||||||
- `FJ_HOST`: Override the default instance (auto-detected from git remote if not set)
|
- `FGJ_HOST`: Override the default instance (auto-detected from git remote if not set)
|
||||||
- `FJ_TOKEN`: Provide authentication token
|
- `FGJ_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. `FJ_HOST` environment variable
|
2. `FGJ_HOST` environment variable
|
||||||
3. Auto-detected from git remote URL
|
3. Auto-detected from git remote URL
|
||||||
4. `match_dirs` lookup (longest prefix match against current directory)
|
4. Default to `codeberg.org`
|
||||||
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, `fj` automatically detects the instance from your origin remote URL, so you typically don't need to specify `--hostname` unless working with multiple instances.
|
When working in a git repository, `fgj` automatically detects the instance from your origin remote URL, so you typically don't need to specify `--hostname` unless working with multiple instances.
|
||||||
|
|
||||||
## Use with AI Coding Agents
|
## Use with AI Coding Agents
|
||||||
|
|
||||||
`fj` is designed to work seamlessly with AI coding agents like Claude Code. Use `--json` and `--json-errors` for fully machine-readable I/O:
|
`fgj` is designed to work seamlessly with AI coding agents like Claude Code. Use `--json` and `--json-errors` for fully machine-readable I/O:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Create PR from agent's changes
|
# Create PR from agent's changes
|
||||||
fj pr create -R owner/repo -t "feat: add new feature" -b "$(cat <<EOF
|
fgj pr create -R owner/repo -t "feat: add new feature" -b "$(cat <<EOF
|
||||||
## Summary
|
## Summary
|
||||||
- Added new feature X
|
- Added new feature X
|
||||||
- Fixed bug Y
|
- Fixed bug Y
|
||||||
|
|
@ -527,29 +423,29 @@ EOF
|
||||||
)" --json
|
)" --json
|
||||||
|
|
||||||
# Check PR status during development
|
# Check PR status during development
|
||||||
fj pr list -R owner/repo --state open --json
|
fgj pr list -R owner/repo --state open --json
|
||||||
|
|
||||||
# Review a PR diff, then approve
|
# Review a PR diff, then approve
|
||||||
fj pr diff 123
|
fgj pr diff 123
|
||||||
fj pr review 123 --approve -b "LGTM" --json
|
fgj pr review 123 --approve -b "LGTM" --json
|
||||||
|
|
||||||
# Post review feedback
|
# Post review feedback
|
||||||
fj pr comment 123 -b "Consider using a map here for O(1) lookup" --json
|
fgj pr comment 123 -b "Consider using a map here for O(1) lookup" --json
|
||||||
|
|
||||||
# Request changes with detailed feedback
|
# Request changes with detailed feedback
|
||||||
fj pr review 123 --request-changes --body-file feedback.md --json
|
fgj pr review 123 --request-changes --body-file feedback.md --json
|
||||||
|
|
||||||
# Use raw API for anything not covered by commands
|
# Use raw API for anything not covered by commands
|
||||||
fj api /repos/{owner}/{repo}/topics --json-errors
|
fgj api /repos/{owner}/{repo}/topics --json-errors
|
||||||
fj api /repos/{owner}/{repo}/labels -X POST -f name=agent-reviewed -f color="#00ff00"
|
fgj api /repos/{owner}/{repo}/labels -X POST -f name=agent-reviewed -f color="#00ff00"
|
||||||
|
|
||||||
# Fully machine-readable error handling
|
# Fully machine-readable error handling
|
||||||
fj pr view 9999 --json --json-errors 2>errors.json
|
fgj pr view 9999 --json --json-errors 2>errors.json
|
||||||
```
|
```
|
||||||
|
|
||||||
## Supported Instances
|
## Supported Instances
|
||||||
|
|
||||||
`fj` works with any Forgejo or Gitea instance, including:
|
`fgj` works with any Forgejo or Gitea instance, including:
|
||||||
|
|
||||||
- Self-hosted Forgejo instances
|
- Self-hosted Forgejo instances
|
||||||
- Self-hosted Gitea instances
|
- Self-hosted Gitea instances
|
||||||
|
|
@ -557,11 +453,11 @@ fj 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/public/fj](https://forgejo.zerova.net/public/fj).
|
Contributions are welcome! Please feel free to submit a Pull Request at [forgejo.zerova.net/sid/fgj-sid](https://forgejo.zerova.net/sid/fgj-sid).
|
||||||
|
|
||||||
## Missing Features / Roadmap
|
## Missing Features / Roadmap
|
||||||
|
|
||||||
`fj` aims to be a drop-in replacement for `gh` when working with Forgejo and Gitea instances. While we've implemented the core features, some `gh` commands are not yet available:
|
`fgj` aims to be a drop-in replacement for `gh` when working with Forgejo and Gitea instances. While we've implemented the core features, some `gh` commands are not yet available:
|
||||||
|
|
||||||
**Not Yet Implemented:**
|
**Not Yet Implemented:**
|
||||||
- `run delete` - Delete a workflow run
|
- `run delete` - Delete a workflow run
|
||||||
|
|
@ -570,13 +466,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 delete`, `repo rename`
|
||||||
|
|
||||||
We welcome contributions to implement any of these features!
|
We welcome contributions to implement any of these features!
|
||||||
|
|
||||||
## Acknowledgments
|
## Acknowledgments
|
||||||
|
|
||||||
Based on [fj by romaintb](https://codeberg.org/romaintb/fj). Enhanced with agentic dev features for AI-assisted workflows.
|
Based on [fgj by romaintb](https://codeberg.org/romaintb/fgj). Enhanced with agentic dev features for AI-assisted workflows.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|
|
||||||
650
cmd/actions.go
650
cmd/actions.go
File diff suppressed because it is too large
Load diff
|
|
@ -1,16 +0,0 @@
|
||||||
package cmd
|
|
||||||
|
|
||||||
// Top-level aliases for "actions run" and "actions workflow" — matches gh
|
|
||||||
// CLI's ergonomics so users can type `fj run list` and `fj workflow list`
|
|
||||||
// instead of `fj actions run list`.
|
|
||||||
//
|
|
||||||
// Both trees are built from the same factory functions defined in
|
|
||||||
// `cmd/actions.go` (newRunCmd / newWorkflowCmd), which means flags and
|
|
||||||
// help text are guaranteed identical between the two paths. Previously
|
|
||||||
// this file rebuilt parallel trees by hand and silently drifted (the
|
|
||||||
// `--json` Bool/string mismatch was the symptom that surfaced).
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
rootCmd.AddCommand(newRunCmd(" (alias for 'actions run')"))
|
|
||||||
rootCmd.AddCommand(newWorkflowCmd(" (alias for 'actions workflow')"))
|
|
||||||
}
|
|
||||||
229
cmd/api.go
229
cmd/api.go
|
|
@ -6,23 +6,15 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"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/sid/fgj-sid/internal/git"
|
||||||
"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",
|
||||||
|
|
@ -34,22 +26,16 @@ 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
|
||||||
fj api /repos/{owner}/{repo}/pulls
|
fgj api /repos/{owner}/{repo}/pulls
|
||||||
|
|
||||||
# Create an issue
|
# Create an issue
|
||||||
fj api /repos/{owner}/{repo}/issues --method POST --field title=Bug --field body="It broke"
|
fgj api /repos/{owner}/{repo}/issues --method POST --field title=Bug --field body="It broke"
|
||||||
|
|
||||||
# Get a specific user
|
# Get a specific user
|
||||||
fj api /users/johndoe
|
fgj api /users/johndoe
|
||||||
|
|
||||||
# Use raw body from stdin
|
# Use raw body from stdin
|
||||||
echo '{"title":"test"}' | fj api /repos/{owner}/{repo}/issues --input -
|
echo '{"title":"test"}' | fgj 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,
|
||||||
}
|
}
|
||||||
|
|
@ -64,40 +50,6 @@ 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 {
|
||||||
|
|
@ -120,7 +72,7 @@ func runAPI(cmd *cobra.Command, args []string) error {
|
||||||
|
|
||||||
detectedHost := getDetectedHost()
|
detectedHost := getDetectedHost()
|
||||||
|
|
||||||
host, err := cfg.GetHost(hostname, detectedHost, getCwd())
|
host, err := cfg.GetHost(hostname, detectedHost)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -187,28 +139,15 @@ func runAPI(cmd *cobra.Command, args []string) error {
|
||||||
body = bytes.NewReader(bodyBytes)
|
body = bytes.NewReader(bodyBytes)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build the request URL safely. Naive concatenation lets endpoints like
|
// Build URL
|
||||||
// "/../admin/users" escape the /api/v1 base via Go's URL normalization
|
baseURL := "https://" + host.Hostname + "/api/v1"
|
||||||
// of `..` segments — silently sending authenticated traffic to non-API
|
if !strings.HasPrefix(endpoint, "/") {
|
||||||
// paths. Parse the endpoint, reject `..`, then JoinPath onto the base.
|
endpoint = "/" + endpoint
|
||||||
endpointURL, err := url.Parse(endpoint)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("invalid endpoint %q: %w", endpoint, err)
|
|
||||||
}
|
}
|
||||||
if endpointURL.Scheme != "" || endpointURL.Host != "" {
|
url := baseURL + endpoint
|
||||||
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, final.String(), body)
|
req, err := http.NewRequest(method, url, 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)
|
||||||
}
|
}
|
||||||
|
|
@ -231,143 +170,59 @@ 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))
|
||||||
}
|
}
|
||||||
|
|
||||||
paginate, _ := cmd.Flags().GetBool("paginate")
|
// Execute request
|
||||||
if paginate && method != http.MethodGet {
|
httpClient := &http.Client{}
|
||||||
return fmt.Errorf("--paginate only supports GET requests")
|
resp, err := httpClient.Do(req)
|
||||||
}
|
|
||||||
|
|
||||||
// doOnce executes a single request via the shared client (30 s timeout,
|
|
||||||
// pooled connections), reads the body bounded by maxAPIResponseBytes,
|
|
||||||
// and closes the body before returning. Previous zero-value http.Client{}
|
|
||||||
// had no timeout, pinning the CLI on a hung Forgejo indefinitely.
|
|
||||||
doOnce := func(r *http.Request) (body []byte, header http.Header, status int, proto string, statusText string, retErr error) {
|
|
||||||
ios.StartSpinner("Requesting...")
|
|
||||||
resp, err := api.SharedHTTPClient.Do(r)
|
|
||||||
ios.StopSpinner()
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, 0, "", "", fmt.Errorf("failed to perform request: %w", err)
|
|
||||||
}
|
|
||||||
defer func() { _ = resp.Body.Close() }()
|
|
||||||
|
|
||||||
body, err = io.ReadAll(io.LimitReader(resp.Body, maxAPIResponseBytes+1))
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, 0, "", "", fmt.Errorf("failed to read response body: %w", err)
|
|
||||||
}
|
|
||||||
if int64(len(body)) > maxAPIResponseBytes {
|
|
||||||
return nil, nil, 0, "", "", fmt.Errorf("response body exceeded %d bytes (use a different tool for bulk transfers)", maxAPIResponseBytes)
|
|
||||||
}
|
|
||||||
return body, resp.Header, resp.StatusCode, resp.Proto, resp.Status, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
respBody, respHeader, statusCode, proto, status, err := doOnce(req)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return fmt.Errorf("failed to perform request: %w", err)
|
||||||
}
|
}
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
|
||||||
|
// Print response headers if requested
|
||||||
if include {
|
if include {
|
||||||
fmt.Fprintf(ios.Out, "%s %s\n", proto, status)
|
fmt.Fprintf(os.Stdout, "%s %s\n", resp.Proto, resp.Status)
|
||||||
for key, values := range respHeader {
|
for key, values := range resp.Header {
|
||||||
for _, v := range values {
|
for _, v := range values {
|
||||||
fmt.Fprintf(ios.Out, "%s: %s\n", key, v)
|
fmt.Fprintf(os.Stdout, "%s: %s\n", key, v)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fmt.Fprintln(ios.Out)
|
fmt.Fprintln(os.Stdout)
|
||||||
}
|
}
|
||||||
|
|
||||||
if statusCode < 200 || statusCode >= 300 {
|
// Read response body
|
||||||
|
respBody, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to read response body: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle non-2xx status codes
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
if !silent {
|
if !silent {
|
||||||
fmt.Fprint(ios.ErrOut, string(respBody))
|
fmt.Fprint(os.Stderr, string(respBody))
|
||||||
if len(respBody) > 0 && respBody[len(respBody)-1] != '\n' {
|
if len(respBody) > 0 && respBody[len(respBody)-1] != '\n' {
|
||||||
fmt.Fprintln(ios.ErrOut)
|
fmt.Fprintln(os.Stderr)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return fmt.Errorf("API request failed with status %d", statusCode)
|
os.Exit(1)
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
||||||
}
|
}
|
||||||
|
|
||||||
contentType := respHeader.Get("Content-Type")
|
// Pretty-print JSON, or output raw if not JSON
|
||||||
isJSON := strings.Contains(contentType, "json") || json.Valid(respBody)
|
contentType := resp.Header.Get("Content-Type")
|
||||||
|
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 {
|
||||||
return writeJSON(parsed)
|
enc := json.NewEncoder(os.Stdout)
|
||||||
|
enc.SetIndent("", " ")
|
||||||
|
return enc.Encode(parsed)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = ios.Out.Write(respBody)
|
// Raw output for non-JSON responses
|
||||||
|
_, err = os.Stdout.Write(respBody)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
50
cmd/auth.go
50
cmd/auth.go
|
|
@ -7,8 +7,8 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
|
|
||||||
"forgejo.zerova.net/public/fj/internal/api"
|
"forgejo.zerova.net/sid/fgj-sid/internal/api"
|
||||||
"forgejo.zerova.net/public/fj/internal/config"
|
"forgejo.zerova.net/sid/fgj-sid/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 fj with a Forgejo instance",
|
Short: "Authenticate fgj with a Forgejo instance",
|
||||||
Long: "Manage authentication state for Forgejo instances.",
|
Long: "Manage authentication state for Forgejo instances.",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -55,29 +55,20 @@ func init() {
|
||||||
authCmd.AddCommand(authLogoutCmd)
|
authCmd.AddCommand(authLogoutCmd)
|
||||||
authCmd.AddCommand(authTokenCmd)
|
authCmd.AddCommand(authTokenCmd)
|
||||||
|
|
||||||
// --hostname is a persistent flag on rootCmd (cmd/root.go). Don't
|
authLoginCmd.Flags().String("hostname", "", "Forgejo instance hostname (e.g., codeberg.org)")
|
||||||
// re-declare it on auth subcommands — local flags shadow the persistent
|
authLoginCmd.Flags().StringP("token", "t", "", "Personal access token")
|
||||||
// one, so `fj --hostname=X auth login` and `fj auth login --hostname=X`
|
authLogoutCmd.Flags().String("hostname", "", "Forgejo instance hostname (e.g., codeberg.org)")
|
||||||
// went through different code paths (viper vs. local).
|
authTokenCmd.Flags().String("hostname", "", "Forgejo instance hostname (e.g., codeberg.org)")
|
||||||
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.Fprint(ios.ErrOut, "Forgejo instance hostname (default: codeberg.org): ")
|
fmt.Print("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 == "" {
|
||||||
|
|
@ -86,12 +77,12 @@ func runAuthLogin(cmd *cobra.Command, args []string) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
if token == "" {
|
if token == "" {
|
||||||
fmt.Fprint(ios.ErrOut, "Personal access token: ")
|
fmt.Print("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.Fprintln(ios.ErrOut)
|
fmt.Println()
|
||||||
token = strings.TrimSpace(string(tokenBytes))
|
token = strings.TrimSpace(string(tokenBytes))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -104,9 +95,7 @@ func runAuthLogin(cmd *cobra.Command, args []string) error {
|
||||||
return fmt.Errorf("failed to create client: %w", err)
|
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)
|
||||||
}
|
}
|
||||||
|
|
@ -127,8 +116,7 @@ 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
cs := ios.ColorScheme()
|
fmt.Printf("✓ Authenticated as %s on %s\n", user.UserName, hostname)
|
||||||
fmt.Fprintf(ios.Out, "%s Authenticated as %s on %s\n", cs.SuccessIcon(), user.UserName, hostname)
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
@ -140,15 +128,14 @@ func runAuthStatus(cmd *cobra.Command, args []string) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(cfg.Hosts) == 0 {
|
if len(cfg.Hosts) == 0 {
|
||||||
fmt.Fprintln(ios.Out, "Not authenticated with any Forgejo instances")
|
fmt.Println("Not authenticated with any Forgejo instances")
|
||||||
fmt.Fprintln(ios.Out, "Run 'fj auth login' to authenticate")
|
fmt.Println("Run 'fgj auth login' to authenticate")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Fprintln(ios.Out, "Authenticated instances:")
|
fmt.Println("Authenticated instances:")
|
||||||
for hostname, host := range cfg.Hosts {
|
for hostname, host := range cfg.Hosts {
|
||||||
cs := ios.ColorScheme()
|
fmt.Printf(" • %s (user: %s)\n", hostname, host.User)
|
||||||
fmt.Fprintf(ios.Out, " %s %s (user: %s)\n", cs.SuccessIcon(), hostname, host.User)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|
@ -171,8 +158,7 @@ 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
cs := ios.ColorScheme()
|
fmt.Printf("✓ Logged out from %s\n", resolved)
|
||||||
fmt.Fprintf(ios.Out, "%s Logged out from %s\n", cs.SuccessIcon(), resolved)
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -188,7 +174,7 @@ func runAuthToken(cmd *cobra.Command, args []string) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Fprintln(ios.Out, cfg.Hosts[resolved].Token)
|
fmt.Println(cfg.Hosts[resolved].Token)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -197,7 +183,7 @@ func resolveAuthHostname(cfg *config.Config, hostname string) (string, error) {
|
||||||
hostname = viper.GetString("hostname")
|
hostname = viper.GetString("hostname")
|
||||||
}
|
}
|
||||||
if hostname == "" {
|
if hostname == "" {
|
||||||
hostname = config.EnvWithFallback("FJ_HOST", "FGJ_HOST")
|
hostname = os.Getenv("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 fj.",
|
Long: "Generate shell completion scripts for fgj.",
|
||||||
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"
|
||||||
"strings"
|
"os"
|
||||||
|
|
||||||
"forgejo.zerova.net/public/fj/internal/api"
|
"forgejo.zerova.net/sid/fgj-sid/internal/api"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Error codes for structured error output.
|
// Error codes for structured error output.
|
||||||
|
|
@ -24,15 +24,9 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -46,60 +40,8 @@ func NewAPIError(status int, message string) *CLIError {
|
||||||
return &CLIError{Code: ErrAPIError, Message: message, Status: status}
|
return &CLIError{Code: ErrAPIError, Message: message, Status: status}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ContextualError wraps common errors with helpful hints.
|
// writeJSONError writes a structured JSON error to stderr.
|
||||||
//
|
// 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) {
|
||||||
|
|
@ -108,9 +50,7 @@ func WriteJSONError(err error) {
|
||||||
Message: err.Error(),
|
Message: err.Error(),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to extract structured info from the error chain. Prefer CLIError
|
// Try to extract structured info from the error chain.
|
||||||
// (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
|
||||||
|
|
||||||
|
|
@ -125,13 +65,12 @@ 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(ios.ErrOut)
|
enc := json.NewEncoder(os.Stderr)
|
||||||
enc.SetIndent("", " ")
|
enc.SetIndent("", " ")
|
||||||
_ = enc.Encode(cliErr)
|
_ = enc.Encode(cliErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compile-time check that CLIError satisfies the standard error interface.
|
|
||||||
var _ error = (*CLIError)(nil)
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
package cmd
|
|
||||||
|
|
||||||
import "forgejo.zerova.net/public/fj/internal/iostreams"
|
|
||||||
|
|
||||||
var ios = iostreams.New()
|
|
||||||
435
cmd/issue.go
435
cmd/issue.go
|
|
@ -2,13 +2,14 @@ package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"os"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"text/tabwriter"
|
||||||
|
|
||||||
"code.gitea.io/sdk/gitea"
|
"code.gitea.io/sdk/gitea"
|
||||||
"forgejo.zerova.net/public/fj/internal/api"
|
"forgejo.zerova.net/sid/fgj-sid/internal/api"
|
||||||
"forgejo.zerova.net/public/fj/internal/config"
|
"forgejo.zerova.net/sid/fgj-sid/internal/config"
|
||||||
"forgejo.zerova.net/public/fj/internal/text"
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -22,114 +23,46 @@ 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
|
RunE: runIssueList,
|
||||||
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,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var issueViewCmd = &cobra.Command{
|
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
|
Args: cobra.ExactArgs(1),
|
||||||
fj issue view 42
|
RunE: runIssueView,
|
||||||
|
|
||||||
# View using URL
|
|
||||||
fj issue view https://codeberg.org/owner/repo/issues/42
|
|
||||||
|
|
||||||
# Open in browser
|
|
||||||
fj issue view 42 --web
|
|
||||||
|
|
||||||
# View an issue from a specific repo as JSON
|
|
||||||
fj issue view 42 -R owner/repo --json`,
|
|
||||||
Args: cobra.ExactArgs(1),
|
|
||||||
RunE: runIssueView,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var issueCreateCmd = &cobra.Command{
|
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
|
RunE: runIssueCreate,
|
||||||
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,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var issueCommentCmd = &cobra.Command{
|
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
|
Args: cobra.ExactArgs(1),
|
||||||
fj issue comment 42 -b "This is fixed in the latest release"
|
RunE: runIssueComment,
|
||||||
|
|
||||||
# Comment on an issue in a specific repo
|
|
||||||
fj issue comment 10 -b "Confirmed on my end" -R owner/repo`,
|
|
||||||
Args: cobra.ExactArgs(1),
|
|
||||||
RunE: runIssueComment,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var issueCloseCmd = &cobra.Command{
|
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
|
Args: cobra.ExactArgs(1),
|
||||||
fj issue close 42
|
RunE: runIssueClose,
|
||||||
|
|
||||||
# Close with a comment
|
|
||||||
fj issue close 42 -c "Fixed in commit abc1234"`,
|
|
||||||
Args: cobra.ExactArgs(1),
|
|
||||||
RunE: runIssueClose,
|
|
||||||
}
|
|
||||||
|
|
||||||
var issueReopenCmd = &cobra.Command{
|
|
||||||
Use: "reopen <number>",
|
|
||||||
Short: "Reopen an issue",
|
|
||||||
Long: "Reopen a closed issue.",
|
|
||||||
Example: ` # Reopen issue #42
|
|
||||||
fj issue reopen 42`,
|
|
||||||
Args: cobra.ExactArgs(1),
|
|
||||||
RunE: runIssueReopen,
|
|
||||||
}
|
|
||||||
|
|
||||||
var issueDeleteCmd = &cobra.Command{
|
|
||||||
Use: "delete <number>",
|
|
||||||
Short: "Delete an issue",
|
|
||||||
Long: "Delete an issue permanently.",
|
|
||||||
Example: ` # Delete issue #42
|
|
||||||
fj issue delete 42
|
|
||||||
|
|
||||||
# Delete without confirmation
|
|
||||||
fj issue delete 42 -y`,
|
|
||||||
Args: cobra.ExactArgs(1),
|
|
||||||
RunE: runIssueDelete,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var issueEditCmd = &cobra.Command{
|
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
|
Args: cobra.ExactArgs(1),
|
||||||
fj issue edit 42 -t "Updated title"
|
RunE: runIssueEdit,
|
||||||
|
|
||||||
# Reopen a closed issue
|
|
||||||
fj issue edit 42 -s open
|
|
||||||
|
|
||||||
# Add and remove labels
|
|
||||||
fj issue edit 42 --add-label bug --remove-label wontfix
|
|
||||||
|
|
||||||
# Add a dependency
|
|
||||||
fj issue edit 42 --add-dependency 10`,
|
|
||||||
Args: cobra.ExactArgs(1),
|
|
||||||
RunE: runIssueEdit,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
|
|
@ -139,31 +72,19 @@ 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().IntP("limit", "L", 30, "Maximum number of results")
|
issueListCmd.Flags().Bool("json", false, "Output issues as JSON")
|
||||||
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")
|
||||||
addJSONFlags(issueViewCmd, "Output issue as JSON")
|
issueViewCmd.Flags().Bool("json", false, "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")
|
||||||
|
|
@ -171,27 +92,17 @@ 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 {
|
||||||
|
|
@ -203,7 +114,7 @@ func runIssueList(cmd *cobra.Command, args []string) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd())
|
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -220,27 +131,9 @@ func runIssueList(cmd *cobra.Command, args []string) error {
|
||||||
return fmt.Errorf("invalid state: %s", state)
|
return fmt.Errorf("invalid state: %s", state)
|
||||||
}
|
}
|
||||||
|
|
||||||
ios.StartSpinner("Fetching issues...")
|
issues, _, err := client.ListRepoIssues(owner, name, gitea.ListIssueOption{
|
||||||
// ListRepoIssues returns both issues AND PRs (we filter PRs out below).
|
State: stateType,
|
||||||
// Pull more than `limit` so post-filter we still have `limit` real issues
|
|
||||||
// — overshoot 2x as a heuristic. paginateGitea(0, ...) would be safer
|
|
||||||
// but spends extra round-trips; keep it bounded.
|
|
||||||
fetchLimit := limit * 2
|
|
||||||
if fetchLimit < 50 {
|
|
||||||
fetchLimit = 50
|
|
||||||
}
|
|
||||||
issues, err := paginateGitea(fetchLimit, func(page, pageSize int) ([]*gitea.Issue, error) {
|
|
||||||
batch, _, err := client.ListRepoIssues(owner, name, gitea.ListIssueOption{
|
|
||||||
State: stateType,
|
|
||||||
Labels: labels,
|
|
||||||
KeyWord: search,
|
|
||||||
CreatedBy: author,
|
|
||||||
AssignedBy: assignee,
|
|
||||||
ListOptions: gitea.ListOptions{Page: page, PageSize: pageSize},
|
|
||||||
})
|
|
||||||
return batch, err
|
|
||||||
})
|
})
|
||||||
ios.StopSpinner()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to list issues: %w", err)
|
return fmt.Errorf("failed to list issues: %w", err)
|
||||||
}
|
}
|
||||||
|
|
@ -251,30 +144,29 @@ 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 wantJSON(cmd) {
|
if jsonOutput, _ := cmd.Flags().GetBool("json"); jsonOutput {
|
||||||
return outputJSON(cmd, nonPRIssues)
|
return writeJSON(nonPRIssues)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(nonPRIssues) == 0 {
|
if len(nonPRIssues) == 0 {
|
||||||
fmt.Fprintf(ios.Out, "No %s issues in %s/%s\n", state, owner, name)
|
fmt.Printf("No %s issues in %s/%s\n", state, owner, name)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
tp := ios.NewTablePrinter()
|
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
||||||
tp.AddHeader("NUMBER", "TITLE", "STATE")
|
_, _ = fmt.Fprintf(w, "NUMBER\tTITLE\tSTATE\n")
|
||||||
for _, issue := range nonPRIssues {
|
for _, issue := range nonPRIssues {
|
||||||
tp.AddRow(fmt.Sprintf("#%d", issue.Index), issue.Title, string(issue.State))
|
_, _ = fmt.Fprintf(w, "#%d\t%s\t%s\n", issue.Index, issue.Title, issue.State)
|
||||||
}
|
}
|
||||||
return tp.Render()
|
_ = w.Flush()
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func runIssueView(cmd *cobra.Command, args []string) error {
|
func runIssueView(cmd *cobra.Command, args []string) error {
|
||||||
repo, _ := cmd.Flags().GetString("repo")
|
repo, _ := cmd.Flags().GetString("repo")
|
||||||
issueNumber, err := parseIssueArg(args[0])
|
issueNumber, err := strconv.ParseInt(args[0], 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("invalid issue number: %w", err)
|
return fmt.Errorf("invalid issue number: %w", err)
|
||||||
}
|
}
|
||||||
|
|
@ -289,15 +181,13 @@ func runIssueView(cmd *cobra.Command, args []string) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd())
|
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost())
|
||||||
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -306,13 +196,8 @@ func runIssueView(cmd *cobra.Command, args []string) error {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
comments = nil
|
comments = nil
|
||||||
}
|
}
|
||||||
ios.StopSpinner()
|
|
||||||
|
|
||||||
if web, _ := cmd.Flags().GetBool("web"); web {
|
if jsonOutput, _ := cmd.Flags().GetBool("json"); jsonOutput {
|
||||||
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"`
|
||||||
|
|
@ -320,34 +205,26 @@ func runIssueView(cmd *cobra.Command, args []string) error {
|
||||||
Issue: issue,
|
Issue: issue,
|
||||||
Comments: comments,
|
Comments: comments,
|
||||||
}
|
}
|
||||||
return outputJSON(cmd, payload)
|
return writeJSON(payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := ios.StartPager(); err != nil {
|
fmt.Printf("Issue #%d\n", issue.Index)
|
||||||
fmt.Fprintf(ios.ErrOut, "warning: failed to start pager: %v\n", err)
|
fmt.Printf("Title: %s\n", issue.Title)
|
||||||
}
|
fmt.Printf("State: %s\n", issue.State)
|
||||||
defer ios.StopPager()
|
fmt.Printf("Author: %s\n", issue.Poster.UserName)
|
||||||
|
fmt.Printf("Created: %s\n", issue.Created.Format("2006-01-02 15:04:05"))
|
||||||
cs := ios.ColorScheme()
|
fmt.Printf("Updated: %s\n", issue.Updated.Format("2006-01-02 15:04:05"))
|
||||||
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.Fprintf(ios.Out, "\n%s\n", issue.Body)
|
fmt.Printf("\n%s\n", issue.Body)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(comments) > 0 {
|
if len(comments) > 0 {
|
||||||
fmt.Fprintf(ios.Out, "\nComments (%d):\n", len(comments))
|
fmt.Printf("\nComments (%d):\n", len(comments))
|
||||||
for _, comment := range comments {
|
for _, comment := range comments {
|
||||||
fmt.Fprintf(ios.Out, "\n---\n%s (@%s) - %s\n%s\n",
|
fmt.Printf("\n---\n%s (@%s) - %s\n%s\n",
|
||||||
comment.Poster.FullName,
|
comment.Poster.FullName,
|
||||||
comment.Poster.UserName,
|
comment.Poster.UserName,
|
||||||
text.FormatDate(comment.Created, isTTY),
|
comment.Created.Format("2006-01-02 15:04:05"),
|
||||||
comment.Body)
|
comment.Body)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -360,28 +237,14 @@ 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 == "" {
|
||||||
if title == "" && ios.IsStdinTTY() {
|
return fmt.Errorf("title is required")
|
||||||
title, err = promptLine("Title: ")
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if title == "" {
|
|
||||||
return fmt.Errorf("title is required")
|
|
||||||
}
|
|
||||||
if body == "" {
|
|
||||||
body, _ = promptLine("Body (optional): ")
|
|
||||||
}
|
|
||||||
} else if title == "" {
|
|
||||||
return fmt.Errorf("title is required (use -t flag)")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg, err := config.Load()
|
cfg, err := config.Load()
|
||||||
|
|
@ -389,7 +252,7 @@ func runIssueCreate(cmd *cobra.Command, args []string) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd())
|
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -402,56 +265,17 @@ func runIssueCreate(cmd *cobra.Command, args []string) error {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve @me in assignees
|
|
||||||
resolvedAssignees := make([]string, 0, len(assignees))
|
|
||||||
for _, assignee := range assignees {
|
|
||||||
if assignee == "@me" {
|
|
||||||
user, _, userErr := client.GetMyUserInfo()
|
|
||||||
if userErr != nil {
|
|
||||||
return fmt.Errorf("failed to get current user info: %w", userErr)
|
|
||||||
}
|
|
||||||
resolvedAssignees = append(resolvedAssignees, user.UserName)
|
|
||||||
} else {
|
|
||||||
resolvedAssignees = append(resolvedAssignees, assignee)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resolve milestone name to ID
|
|
||||||
var milestoneID int64
|
|
||||||
if milestoneName != "" {
|
|
||||||
milestones, _, msErr := client.ListRepoMilestones(owner, name, gitea.ListMilestoneOption{})
|
|
||||||
if msErr != nil {
|
|
||||||
return fmt.Errorf("failed to list milestones: %w", msErr)
|
|
||||||
}
|
|
||||||
found := false
|
|
||||||
for _, ms := range milestones {
|
|
||||||
if ms.Title == milestoneName {
|
|
||||||
milestoneID = ms.ID
|
|
||||||
found = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !found {
|
|
||||||
return fmt.Errorf("milestone not found: %s", milestoneName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ios.StartSpinner("Creating issue...")
|
|
||||||
issue, _, err := client.CreateIssue(owner, name, gitea.CreateIssueOption{
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
cs := ios.ColorScheme()
|
fmt.Printf("Issue created: #%d\n", issue.Index)
|
||||||
fmt.Fprintf(ios.Out, "%s Issue created: #%d\n", cs.SuccessIcon(), issue.Index)
|
fmt.Printf("View at: %s\n", issue.HTMLURL)
|
||||||
fmt.Fprintf(ios.Out, "View at: %s\n", issue.HTMLURL)
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
@ -459,7 +283,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 := parseIssueArg(args[0])
|
issueNumber, err := strconv.ParseInt(args[0], 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("invalid issue number: %w", err)
|
return fmt.Errorf("invalid issue number: %w", err)
|
||||||
}
|
}
|
||||||
|
|
@ -478,23 +302,20 @@ func runIssueComment(cmd *cobra.Command, args []string) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd())
|
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost())
|
||||||
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
cs := ios.ColorScheme()
|
fmt.Printf("Comment added to issue #%d\n", issueNumber)
|
||||||
fmt.Fprintf(ios.Out, "%s Comment added to issue #%d\n", cs.SuccessIcon(), issueNumber)
|
fmt.Printf("View at: %s\n", comment.HTMLURL)
|
||||||
fmt.Fprintf(ios.Out, "View at: %s\n", comment.HTMLURL)
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
@ -502,7 +323,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 := parseIssueArg(args[0])
|
issueNumber, err := strconv.ParseInt(args[0], 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("invalid issue number: %w", err)
|
return fmt.Errorf("invalid issue number: %w", err)
|
||||||
}
|
}
|
||||||
|
|
@ -517,34 +338,29 @@ func runIssueClose(cmd *cobra.Command, args []string) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd())
|
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost())
|
||||||
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
cs := ios.ColorScheme()
|
fmt.Printf("Issue #%d closed\n", issueNumber)
|
||||||
fmt.Fprintf(ios.Out, "%s Issue #%d closed\n", cs.SuccessIcon(), issueNumber)
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
@ -556,10 +372,8 @@ 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 := parseIssueArg(args[0])
|
issueNumber, err := strconv.ParseInt(args[0], 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("invalid issue number: %w", err)
|
return fmt.Errorf("invalid issue number: %w", err)
|
||||||
}
|
}
|
||||||
|
|
@ -569,8 +383,8 @@ func runIssueEdit(cmd *cobra.Command, args []string) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if title == "" && body == "" && stateStr == "" && len(addLabelNames) == 0 && len(removeLabelNames) == 0 && len(addDeps) == 0 && len(removeDeps) == 0 {
|
if title == "" && body == "" && stateStr == "" && len(addLabelNames) == 0 && len(removeLabelNames) == 0 {
|
||||||
return fmt.Errorf("at least one of --title, --body, --state, --add-label, --remove-label, --add-dependency, or --remove-dependency must be provided")
|
return fmt.Errorf("at least one of --title, --body, --state, --add-label, or --remove-label must be provided")
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg, err := config.Load()
|
cfg, err := config.Load()
|
||||||
|
|
@ -578,7 +392,7 @@ func runIssueEdit(cmd *cobra.Command, args []string) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd())
|
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -606,12 +420,9 @@ 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -619,14 +430,12 @@ 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -634,135 +443,17 @@ 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ios.StopSpinner()
|
fmt.Printf("Issue #%d updated\n", issueNumber)
|
||||||
|
|
||||||
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,155 +2,11 @@ package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"os"
|
||||||
"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(ios.Out)
|
enc := json.NewEncoder(os.Stdout)
|
||||||
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
294
cmd/label.go
|
|
@ -1,294 +0,0 @@
|
||||||
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 fj commands.",
|
Long: "Generate manpages for fgj 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: "FJ",
|
Title: "FGJ",
|
||||||
Section: "1",
|
Section: "1",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
487
cmd/milestone.go
487
cmd/milestone.go
|
|
@ -1,487 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
@ -1,43 +0,0 @@
|
||||||
package cmd
|
|
||||||
|
|
||||||
// paginateGitea walks pages of a gitea SDK list method until the response
|
|
||||||
// is short (last page) or we hit limit. limit=0 means unlimited.
|
|
||||||
//
|
|
||||||
// Forgejo/Gitea caps PageSize at 50, so naive `PageSize: limit` for limit > 50
|
|
||||||
// silently truncated results across most `fj * list` commands. This helper
|
|
||||||
// centralizes the loop so every list command paginates consistently.
|
|
||||||
//
|
|
||||||
// fetch is called with (page, pageSize) and returns the items for that page.
|
|
||||||
// The 1-based `page` matches the gitea SDK convention.
|
|
||||||
func paginateGitea[T any](limit int, fetch func(page, pageSize int) ([]T, error)) ([]T, error) {
|
|
||||||
const maxPageSize = 50
|
|
||||||
pageSize := maxPageSize
|
|
||||||
if limit > 0 && limit < pageSize {
|
|
||||||
pageSize = limit
|
|
||||||
}
|
|
||||||
|
|
||||||
var all []T
|
|
||||||
for page := 1; ; page++ {
|
|
||||||
if limit > 0 && len(all) >= limit {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
batch, err := fetch(page, pageSize)
|
|
||||||
if err != nil {
|
|
||||||
return all, err
|
|
||||||
}
|
|
||||||
if len(batch) == 0 {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
all = append(all, batch...)
|
|
||||||
// A short page (less than the requested size) is the conventional
|
|
||||||
// "you've reached the end" signal — saves one extra round-trip.
|
|
||||||
if len(batch) < pageSize {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if limit > 0 && len(all) > limit {
|
|
||||||
all = all[:limit]
|
|
||||||
}
|
|
||||||
return all, nil
|
|
||||||
}
|
|
||||||
|
|
@ -1,99 +0,0 @@
|
||||||
package cmd
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"code.gitea.io/sdk/gitea"
|
|
||||||
"forgejo.zerova.net/public/fj/internal/api"
|
|
||||||
"forgejo.zerova.net/public/fj/internal/config"
|
|
||||||
"forgejo.zerova.net/public/fj/internal/iostreams"
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
)
|
|
||||||
|
|
||||||
var prChecksCmd = &cobra.Command{
|
|
||||||
Use: "checks <number>",
|
|
||||||
Short: "Show CI status checks for a pull request",
|
|
||||||
Long: "Show the status of CI checks for a pull request.",
|
|
||||||
Example: ` # Show checks for PR #5
|
|
||||||
fj pr checks 5
|
|
||||||
|
|
||||||
# Output as JSON
|
|
||||||
fj pr checks 5 --json`,
|
|
||||||
Args: cobra.ExactArgs(1),
|
|
||||||
RunE: runPRChecks,
|
|
||||||
}
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
prCmd.AddCommand(prChecksCmd)
|
|
||||||
prChecksCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format")
|
|
||||||
addJSONFlags(prChecksCmd, "Output checks as JSON")
|
|
||||||
}
|
|
||||||
|
|
||||||
func runPRChecks(cmd *cobra.Command, args []string) error {
|
|
||||||
repo, _ := cmd.Flags().GetString("repo")
|
|
||||||
prNumber, err := parseIssueArg(args[0])
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("invalid pull request number: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
owner, name, err := parseRepo(repo)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
cfg, err := config.Load()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd())
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
ios.StartSpinner("Fetching pull request...")
|
|
||||||
pr, _, err := client.GetPullRequest(owner, name, prNumber)
|
|
||||||
if err != nil {
|
|
||||||
ios.StopSpinner()
|
|
||||||
return fmt.Errorf("failed to get pull request: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
statuses, _, err := client.ListStatuses(owner, name, pr.Head.Sha, gitea.ListStatusesOption{})
|
|
||||||
ios.StopSpinner()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to get commit statuses: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if wantJSON(cmd) {
|
|
||||||
return outputJSON(cmd, statuses)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(statuses) == 0 {
|
|
||||||
fmt.Fprintf(ios.Out, "No status checks found for PR #%d\n", prNumber)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
cs := ios.ColorScheme()
|
|
||||||
tp := ios.NewTablePrinter()
|
|
||||||
tp.AddHeader("STATUS", "CONTEXT", "DESCRIPTION")
|
|
||||||
for _, s := range statuses {
|
|
||||||
status := formatCheckStatus(s.State, cs)
|
|
||||||
tp.AddRow(status, s.Context, s.Description)
|
|
||||||
}
|
|
||||||
return tp.Render()
|
|
||||||
}
|
|
||||||
|
|
||||||
func formatCheckStatus(state gitea.StatusState, cs *iostreams.ColorScheme) string {
|
|
||||||
switch state {
|
|
||||||
case gitea.StatusSuccess:
|
|
||||||
return cs.Green("pass")
|
|
||||||
case gitea.StatusFailure, gitea.StatusError:
|
|
||||||
return cs.Red("fail")
|
|
||||||
case gitea.StatusPending:
|
|
||||||
return cs.Yellow("pending")
|
|
||||||
case gitea.StatusWarning:
|
|
||||||
return cs.Yellow("warn")
|
|
||||||
default:
|
|
||||||
return string(state)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -2,11 +2,14 @@ package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"forgejo.zerova.net/public/fj/internal/api"
|
"forgejo.zerova.net/sid/fgj-sid/internal/api"
|
||||||
"forgejo.zerova.net/public/fj/internal/config"
|
"forgejo.zerova.net/sid/fgj-sid/internal/config"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
"golang.org/x/term"
|
||||||
)
|
)
|
||||||
|
|
||||||
var prDiffCmd = &cobra.Command{
|
var prDiffCmd = &cobra.Command{
|
||||||
|
|
@ -14,16 +17,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
|
||||||
fj pr diff 123
|
fgj pr diff 123
|
||||||
|
|
||||||
# Colorized diff output
|
# Colorized diff output
|
||||||
fj pr diff 123 --color always
|
fgj pr diff 123 --color always
|
||||||
|
|
||||||
# Show only changed file names
|
# Show only changed file names
|
||||||
fj pr diff 123 --name-only
|
fgj pr diff 123 --name-only
|
||||||
|
|
||||||
# Show diffstat summary
|
# Show diffstat summary
|
||||||
fj pr diff 123 --stat`,
|
fgj pr diff 123 --stat`,
|
||||||
Args: cobra.ExactArgs(1),
|
Args: cobra.ExactArgs(1),
|
||||||
RunE: runPRDiff,
|
RunE: runPRDiff,
|
||||||
}
|
}
|
||||||
|
|
@ -43,7 +46,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 := parseIssueArg(args[0])
|
prNumber, err := strconv.ParseInt(args[0], 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("invalid pull request number: %w", err)
|
return fmt.Errorf("invalid pull request number: %w", err)
|
||||||
}
|
}
|
||||||
|
|
@ -58,7 +61,7 @@ func runPRDiff(cmd *cobra.Command, args []string) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd())
|
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -66,9 +69,7 @@ func runPRDiff(cmd *cobra.Command, args []string) error {
|
||||||
diffURL := fmt.Sprintf("https://%s/api/v1/repos/%s/%s/pulls/%d.diff",
|
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)
|
||||||
}
|
}
|
||||||
|
|
@ -81,18 +82,12 @@ 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.Fprint(ios.Out, diff)
|
fmt.Print(diff)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -104,7 +99,7 @@ func shouldColorize(mode string) bool {
|
||||||
case "never":
|
case "never":
|
||||||
return false
|
return false
|
||||||
default: // "auto"
|
default: // "auto"
|
||||||
return ios.ColorEnabled()
|
return term.IsTerminal(int(os.Stdout.Fd()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -116,7 +111,7 @@ func printNameOnly(diff string) error {
|
||||||
name := strings.TrimPrefix(line, "+++ b/")
|
name := strings.TrimPrefix(line, "+++ b/")
|
||||||
if name != "" && !seen[name] {
|
if name != "" && !seen[name] {
|
||||||
seen[name] = true
|
seen[name] = true
|
||||||
fmt.Fprintln(ios.Out, name)
|
fmt.Println(name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -125,9 +120,9 @@ func printNameOnly(diff string) error {
|
||||||
|
|
||||||
// fileStat holds per-file diff statistics.
|
// fileStat holds per-file diff statistics.
|
||||||
type fileStat struct {
|
type fileStat struct {
|
||||||
name string
|
name string
|
||||||
additions int
|
additions int
|
||||||
deletions int
|
deletions int
|
||||||
}
|
}
|
||||||
|
|
||||||
// printDiffStat parses the diff and prints a diffstat summary.
|
// printDiffStat parses the diff and prints a diffstat summary.
|
||||||
|
|
@ -170,12 +165,10 @@ func printDiffStat(diff string) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(stats) == 0 {
|
if len(stats) == 0 {
|
||||||
fmt.Fprintln(ios.Out, "0 files changed")
|
fmt.Println("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
|
||||||
|
|
@ -217,36 +210,44 @@ func printDiffStat(diff string) error {
|
||||||
scaledDel = 1
|
scaledDel = 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
bar = cs.Green(strings.Repeat("+", scaledAdd)) + cs.Red(strings.Repeat("-", scaledDel))
|
bar = strings.Repeat("+", scaledAdd) + strings.Repeat("-", scaledDel)
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Fprintf(ios.Out, " %-*s | %4d %s\n", maxNameLen, s.name, total, bar)
|
fmt.Printf(" %-*s | %4d %s\n", maxNameLen, s.name, total, bar)
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Fprintf(ios.Out, " %d file", len(stats))
|
fmt.Printf(" %d file", len(stats))
|
||||||
if len(stats) != 1 {
|
if len(stats) != 1 {
|
||||||
fmt.Fprint(ios.Out, "s")
|
fmt.Print("s")
|
||||||
}
|
}
|
||||||
fmt.Fprintf(ios.Out, " changed, %d insertion", totalAdditions)
|
fmt.Printf(" changed, %d insertion", totalAdditions)
|
||||||
if totalAdditions != 1 {
|
if totalAdditions != 1 {
|
||||||
fmt.Fprint(ios.Out, "s")
|
fmt.Print("s")
|
||||||
}
|
}
|
||||||
fmt.Fprintf(ios.Out, "(+), %d deletion", totalDeletions)
|
fmt.Printf("(+), %d deletion", totalDeletions)
|
||||||
if totalDeletions != 1 {
|
if totalDeletions != 1 {
|
||||||
fmt.Fprint(ios.Out, "s")
|
fmt.Print("s")
|
||||||
}
|
}
|
||||||
fmt.Fprintln(ios.Out, "(-)")
|
fmt.Println("(-)")
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// printColorizedDiff prints the diff with ANSI color codes using ColorScheme.
|
// ANSI color codes for diff output.
|
||||||
|
const (
|
||||||
|
colorReset = "\033[0m"
|
||||||
|
colorRed = "\033[31m"
|
||||||
|
colorGreen = "\033[32m"
|
||||||
|
colorCyan = "\033[36m"
|
||||||
|
colorBold = "\033[1m"
|
||||||
|
)
|
||||||
|
|
||||||
|
// printColorizedDiff prints the diff with ANSI color codes.
|
||||||
func printColorizedDiff(diff string) error {
|
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.Fprintln(ios.Out, cs.Bold(line))
|
fmt.Println(colorBold + line + colorReset)
|
||||||
case strings.HasPrefix(line, "index "),
|
case strings.HasPrefix(line, "index "),
|
||||||
strings.HasPrefix(line, "--- "),
|
strings.HasPrefix(line, "--- "),
|
||||||
strings.HasPrefix(line, "+++ "),
|
strings.HasPrefix(line, "+++ "),
|
||||||
|
|
@ -255,15 +256,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.Fprintln(ios.Out, cs.Bold(line))
|
fmt.Println(colorBold + line + colorReset)
|
||||||
case strings.HasPrefix(line, "@@"):
|
case strings.HasPrefix(line, "@@"):
|
||||||
fmt.Fprintln(ios.Out, cs.Cyan(line))
|
fmt.Println(colorCyan + line + colorReset)
|
||||||
case strings.HasPrefix(line, "+"):
|
case strings.HasPrefix(line, "+"):
|
||||||
fmt.Fprintln(ios.Out, cs.Green(line))
|
fmt.Println(colorGreen + line + colorReset)
|
||||||
case strings.HasPrefix(line, "-"):
|
case strings.HasPrefix(line, "-"):
|
||||||
fmt.Fprintln(ios.Out, cs.Red(line))
|
fmt.Println(colorRed + line + colorReset)
|
||||||
default:
|
default:
|
||||||
fmt.Fprintln(ios.Out, line)
|
fmt.Println(line)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,11 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
"code.gitea.io/sdk/gitea"
|
"code.gitea.io/sdk/gitea"
|
||||||
"forgejo.zerova.net/public/fj/internal/api"
|
"forgejo.zerova.net/sid/fgj-sid/internal/api"
|
||||||
"forgejo.zerova.net/public/fj/internal/config"
|
"forgejo.zerova.net/sid/fgj-sid/internal/config"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -16,16 +17,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
|
||||||
fj pr comment 123 -b "Looks good!"
|
fgj pr comment 123 -b "Looks good!"
|
||||||
|
|
||||||
# Comment from a file
|
# Comment from a file
|
||||||
fj pr comment 123 --body-file review-notes.md
|
fgj pr comment 123 --body-file review-notes.md
|
||||||
|
|
||||||
# Comment from stdin
|
# Comment from stdin
|
||||||
echo "LGTM" | fj pr comment 123 --body-file -
|
echo "LGTM" | fgj pr comment 123 --body-file -
|
||||||
|
|
||||||
# Output as JSON
|
# Output as JSON
|
||||||
fj pr comment 123 -b "Nice work" --json`,
|
fgj pr comment 123 -b "Nice work" --json`,
|
||||||
Args: cobra.ExactArgs(1),
|
Args: cobra.ExactArgs(1),
|
||||||
RunE: runPRComment,
|
RunE: runPRComment,
|
||||||
}
|
}
|
||||||
|
|
@ -35,16 +36,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
|
||||||
fj pr review 123 --approve -b "LGTM"
|
fgj pr review 123 --approve -b "LGTM"
|
||||||
|
|
||||||
# Request changes
|
# Request changes
|
||||||
fj pr review 123 --request-changes -b "Please fix the error handling"
|
fgj pr review 123 --request-changes -b "Please fix the error handling"
|
||||||
|
|
||||||
# Submit a review comment
|
# Submit a review comment
|
||||||
fj pr review 123 --comment -b "Some observations"
|
fgj pr review 123 --comment -b "Some observations"
|
||||||
|
|
||||||
# Request changes with body from file
|
# Request changes with body from file
|
||||||
fj pr review 123 --request-changes --body-file feedback.md`,
|
fgj pr review 123 --request-changes --body-file feedback.md`,
|
||||||
Args: cobra.ExactArgs(1),
|
Args: cobra.ExactArgs(1),
|
||||||
RunE: runPRReview,
|
RunE: runPRReview,
|
||||||
}
|
}
|
||||||
|
|
@ -56,7 +57,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)")
|
||||||
addJSONFlags(prCommentCmd, "Output created comment as JSON")
|
prCommentCmd.Flags().Bool("json", false, "Output created comment as JSON")
|
||||||
|
|
||||||
prReviewCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format")
|
prReviewCmd.Flags().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")
|
||||||
|
|
@ -64,7 +65,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)")
|
||||||
addJSONFlags(prReviewCmd, "Output created review as JSON")
|
prReviewCmd.Flags().Bool("json", false, "Output created review as JSON")
|
||||||
}
|
}
|
||||||
|
|
||||||
// readBody resolves the body text from --body and --body-file flags.
|
// readBody resolves the body text from --body and --body-file flags.
|
||||||
|
|
@ -97,7 +98,7 @@ func readBody(cmd *cobra.Command) (string, error) {
|
||||||
|
|
||||||
func runPRComment(cmd *cobra.Command, args []string) error {
|
func runPRComment(cmd *cobra.Command, args []string) error {
|
||||||
repo, _ := cmd.Flags().GetString("repo")
|
repo, _ := cmd.Flags().GetString("repo")
|
||||||
prNumber, err := parseIssueArg(args[0])
|
prNumber, err := strconv.ParseInt(args[0], 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("invalid pull request number: %w", err)
|
return fmt.Errorf("invalid pull request number: %w", err)
|
||||||
}
|
}
|
||||||
|
|
@ -121,27 +122,24 @@ func runPRComment(cmd *cobra.Command, args []string) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd())
|
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost())
|
||||||
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 wantJSON(cmd) {
|
if jsonOutput, _ := cmd.Flags().GetBool("json"); jsonOutput {
|
||||||
return outputJSON(cmd, comment)
|
return writeJSON(comment)
|
||||||
}
|
}
|
||||||
|
|
||||||
cs := ios.ColorScheme()
|
fmt.Printf("Comment added to PR #%d\n", prNumber)
|
||||||
fmt.Fprintf(ios.Out, "%s Comment added to PR #%d\n", cs.SuccessIcon(), prNumber)
|
fmt.Printf("View at: %s\n", comment.HTMLURL)
|
||||||
fmt.Fprintf(ios.Out, "View at: %s\n", comment.HTMLURL)
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
@ -152,7 +150,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 := parseIssueArg(args[0])
|
prNumber, err := strconv.ParseInt(args[0], 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("invalid pull request number: %w", err)
|
return fmt.Errorf("invalid pull request number: %w", err)
|
||||||
}
|
}
|
||||||
|
|
@ -191,7 +189,7 @@ func runPRReview(cmd *cobra.Command, args []string) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd())
|
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -210,24 +208,21 @@ 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 wantJSON(cmd) {
|
if jsonOutput, _ := cmd.Flags().GetBool("json"); jsonOutput {
|
||||||
return outputJSON(cmd, review)
|
return writeJSON(review)
|
||||||
}
|
}
|
||||||
|
|
||||||
cs := ios.ColorScheme()
|
fmt.Printf("PR #%d %s\n", prNumber, action)
|
||||||
fmt.Fprintf(ios.Out, "%s PR #%d %s\n", cs.SuccessIcon(), prNumber, action)
|
|
||||||
if review.HTMLURL != "" {
|
if review.HTMLURL != "" {
|
||||||
fmt.Fprintf(ios.Out, "View at: %s\n", review.HTMLURL)
|
fmt.Printf("View at: %s\n", review.HTMLURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|
|
||||||
299
cmd/release.go
299
cmd/release.go
|
|
@ -3,15 +3,14 @@ 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/public/fj/internal/api"
|
"forgejo.zerova.net/sid/fgj-sid/internal/api"
|
||||||
"forgejo.zerova.net/public/fj/internal/config"
|
"forgejo.zerova.net/sid/fgj-sid/internal/config"
|
||||||
"forgejo.zerova.net/public/fj/internal/text"
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -26,98 +25,39 @@ 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
|
RunE: runReleaseList,
|
||||||
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,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var releaseViewCmd = &cobra.Command{
|
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
|
Args: cobra.ExactArgs(1),
|
||||||
fj release view v1.0.0
|
RunE: runReleaseView,
|
||||||
|
|
||||||
# View the latest release
|
|
||||||
fj release view latest
|
|
||||||
|
|
||||||
# Open in browser
|
|
||||||
fj release view v1.0.0 --web
|
|
||||||
|
|
||||||
# Output as JSON
|
|
||||||
fj release view v1.0.0 --json`,
|
|
||||||
Args: cobra.ExactArgs(1),
|
|
||||||
RunE: runReleaseView,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var releaseCreateCmd = &cobra.Command{
|
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
|
Args: cobra.MinimumNArgs(1),
|
||||||
fj release create v1.0.0
|
RunE: runReleaseCreate,
|
||||||
|
|
||||||
# Create with title and notes
|
|
||||||
fj release create v1.0.0 -t "First stable release" -n "Bug fixes and improvements"
|
|
||||||
|
|
||||||
# Create a draft prerelease with assets
|
|
||||||
fj release create v2.0.0-rc1 --draft --prerelease dist/*.tar.gz
|
|
||||||
|
|
||||||
# Create from release notes file
|
|
||||||
fj release create v1.0.0 -F CHANGELOG.md`,
|
|
||||||
Args: cobra.MinimumNArgs(1),
|
|
||||||
RunE: runReleaseCreate,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var releaseUploadCmd = &cobra.Command{
|
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
|
Args: cobra.MinimumNArgs(2),
|
||||||
fj release upload v1.0.0 dist/app-linux-amd64 dist/app-darwin-arm64
|
RunE: runReleaseUpload,
|
||||||
|
|
||||||
# Upload to the latest release, overwriting existing assets
|
|
||||||
fj release upload latest build/output.zip --clobber`,
|
|
||||||
Args: cobra.MinimumNArgs(2),
|
|
||||||
RunE: runReleaseUpload,
|
|
||||||
}
|
|
||||||
|
|
||||||
var releaseDownloadCmd = &cobra.Command{
|
|
||||||
Use: "download <tag>",
|
|
||||||
Short: "Download release assets",
|
|
||||||
Long: "Download assets from a release.",
|
|
||||||
Example: ` # Download all assets from a release
|
|
||||||
fj release download v1.0.0
|
|
||||||
|
|
||||||
# Download to a specific directory
|
|
||||||
fj release download v1.0.0 -D ./downloads
|
|
||||||
|
|
||||||
# Download a specific asset by name pattern
|
|
||||||
fj release download v1.0.0 -p "*.tar.gz"`,
|
|
||||||
Args: cobra.ExactArgs(1),
|
|
||||||
RunE: runReleaseDownload,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var releaseDeleteCmd = &cobra.Command{
|
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
|
Args: cobra.ExactArgs(1),
|
||||||
fj release delete v1.0.0
|
RunE: runReleaseDelete,
|
||||||
|
|
||||||
# Delete the latest release
|
|
||||||
fj release delete latest
|
|
||||||
|
|
||||||
# Delete without confirmation
|
|
||||||
fj release delete v1.0.0 -y`,
|
|
||||||
Args: cobra.ExactArgs(1),
|
|
||||||
RunE: runReleaseDelete,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
|
|
@ -126,18 +66,16 @@ 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")
|
||||||
addJSONFlags(releaseListCmd, "Output releases as JSON")
|
releaseListCmd.Flags().Bool("json", false, "Output releases as JSON")
|
||||||
|
|
||||||
releaseViewCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format")
|
releaseViewCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format")
|
||||||
addJSONFlags(releaseViewCmd, "Output release as JSON")
|
releaseViewCmd.Flags().Bool("json", false, "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)")
|
||||||
|
|
@ -150,12 +88,7 @@ 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 {
|
||||||
|
|
@ -180,7 +113,7 @@ func runReleaseList(cmd *cobra.Command, args []string) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd())
|
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -198,13 +131,11 @@ 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 {
|
||||||
|
|
@ -212,29 +143,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 wantJSON(cmd) {
|
if jsonOutput, _ := cmd.Flags().GetBool("json"); jsonOutput {
|
||||||
return outputJSON(cmd, releases)
|
return writeJSON(releases)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(releases) == 0 {
|
if len(releases) == 0 {
|
||||||
fmt.Fprintf(ios.Out, "No releases in %s/%s\n", owner, name)
|
fmt.Printf("No releases in %s/%s\n", owner, name)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
isTTY := ios.IsStdoutTTY()
|
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
||||||
tp := ios.NewTablePrinter()
|
_, _ = fmt.Fprintf(w, "TAG\tTITLE\tTYPE\tPUBLISHED\n")
|
||||||
tp.AddHeader("TAG", "TITLE", "TYPE", "PUBLISHED")
|
|
||||||
for _, rel := range releases {
|
for _, rel := range releases {
|
||||||
published := text.FormatDate(releaseTimestamp(rel), isTTY)
|
published := releaseTimestamp(rel).Format("2006-01-02")
|
||||||
tp.AddRow(rel.TagName, rel.Title, releaseType(rel), published)
|
_, _ = fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", rel.TagName, rel.Title, releaseType(rel), published)
|
||||||
}
|
}
|
||||||
return tp.Render()
|
_ = w.Flush()
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func runReleaseView(cmd *cobra.Command, args []string) error {
|
func runReleaseView(cmd *cobra.Command, args []string) error {
|
||||||
|
|
@ -251,32 +182,22 @@ func runReleaseView(cmd *cobra.Command, args []string) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd())
|
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost())
|
||||||
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 web, _ := cmd.Flags().GetBool("web"); web {
|
if jsonOutput, _ := cmd.Flags().GetBool("json"); jsonOutput {
|
||||||
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"`
|
||||||
|
|
@ -284,41 +205,33 @@ func runReleaseView(cmd *cobra.Command, args []string) error {
|
||||||
Release: release,
|
Release: release,
|
||||||
Assets: attachments,
|
Assets: attachments,
|
||||||
}
|
}
|
||||||
return outputJSON(cmd, payload)
|
return writeJSON(payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := ios.StartPager(); err != nil {
|
fmt.Printf("Release %s\n", release.TagName)
|
||||||
fmt.Fprintf(ios.ErrOut, "warning: failed to start pager: %v\n", err)
|
fmt.Printf("Title: %s\n", release.Title)
|
||||||
}
|
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.Fprintf(ios.Out, "Target: %s\n", release.Target)
|
fmt.Printf("Target: %s\n", release.Target)
|
||||||
}
|
}
|
||||||
if release.Publisher != nil {
|
if release.Publisher != nil {
|
||||||
fmt.Fprintf(ios.Out, "Author: %s\n", release.Publisher.UserName)
|
fmt.Printf("Author: %s\n", release.Publisher.UserName)
|
||||||
}
|
}
|
||||||
fmt.Fprintf(ios.Out, "Created: %s\n", text.FormatDate(release.CreatedAt, isTTY))
|
fmt.Printf("Created: %s\n", release.CreatedAt.Format("2006-01-02 15:04:05"))
|
||||||
if !release.PublishedAt.IsZero() {
|
if !release.PublishedAt.IsZero() {
|
||||||
fmt.Fprintf(ios.Out, "Published: %s\n", text.FormatDate(release.PublishedAt, isTTY))
|
fmt.Printf("Published: %s\n", release.PublishedAt.Format("2006-01-02 15:04:05"))
|
||||||
}
|
}
|
||||||
if release.HTMLURL != "" {
|
if release.HTMLURL != "" {
|
||||||
fmt.Fprintf(ios.Out, "URL: %s\n", release.HTMLURL)
|
fmt.Printf("URL: %s\n", release.HTMLURL)
|
||||||
}
|
}
|
||||||
if release.Note != "" {
|
if release.Note != "" {
|
||||||
fmt.Fprintf(ios.Out, "\n%s\n", release.Note)
|
fmt.Printf("\n%s\n", release.Note)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(attachments) > 0 {
|
if len(attachments) > 0 {
|
||||||
fmt.Fprintf(ios.Out, "\nAssets (%d):\n", len(attachments))
|
fmt.Printf("\nAssets (%d):\n", len(attachments))
|
||||||
for _, asset := range attachments {
|
for _, asset := range attachments {
|
||||||
fmt.Fprintf(ios.Out, "- %s (%d bytes) %s\n", asset.Name, asset.Size, asset.DownloadURL)
|
fmt.Printf("- %s (%d bytes) %s\n", asset.Name, asset.Size, asset.DownloadURL)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -363,12 +276,11 @@ func runReleaseCreate(cmd *cobra.Command, args []string) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd())
|
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost())
|
||||||
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,
|
||||||
|
|
@ -377,29 +289,24 @@ 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
cs := ios.ColorScheme()
|
fmt.Printf("Release created: %s\n", release.TagName)
|
||||||
fmt.Fprintf(ios.Out, "%s Release created: %s\n", cs.SuccessIcon(), release.TagName)
|
|
||||||
if release.HTMLURL != "" {
|
if release.HTMLURL != "" {
|
||||||
fmt.Fprintf(ios.Out, "View at: %s\n", release.HTMLURL)
|
fmt.Printf("View at: %s\n", release.HTMLURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(files) == 0 {
|
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.Fprintf(ios.Out, "%s Uploaded %s\n", cs.SuccessIcon(), text.Pluralize(len(files), "asset"))
|
fmt.Printf("Uploaded %d asset(s)\n", len(files))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -420,121 +327,26 @@ func runReleaseUpload(cmd *cobra.Command, args []string) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd())
|
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost())
|
||||||
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
|
|
||||||
}
|
|
||||||
ios.StopSpinner()
|
|
||||||
|
|
||||||
cs := ios.ColorScheme()
|
|
||||||
fmt.Fprintf(ios.Out, "%s Uploaded %s\n", cs.SuccessIcon(), text.Pluralize(len(files), "asset"))
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func runReleaseDownload(cmd *cobra.Command, args []string) error {
|
|
||||||
repo, _ := cmd.Flags().GetString("repo")
|
|
||||||
dir, _ := cmd.Flags().GetString("dir")
|
|
||||||
pattern, _ := cmd.Flags().GetString("pattern")
|
|
||||||
tag := args[0]
|
|
||||||
|
|
||||||
owner, name, err := parseRepo(repo)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg, err := config.Load()
|
fmt.Printf("Uploaded %d asset(s)\n", len(files))
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd())
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
ios.StartSpinner("Fetching release...")
|
|
||||||
release, err := getReleaseByTagOrLatest(client, owner, name, tag)
|
|
||||||
if err != nil {
|
|
||||||
ios.StopSpinner()
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
attachments, err := listReleaseAttachments(client, owner, name, release.ID)
|
|
||||||
ios.StopSpinner()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(attachments) == 0 {
|
|
||||||
fmt.Fprintf(ios.Out, "No assets found for release %s\n", release.TagName)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter by pattern if provided
|
|
||||||
var toDownload []*gitea.Attachment
|
|
||||||
for _, a := range attachments {
|
|
||||||
if pattern != "" {
|
|
||||||
matched, matchErr := path.Match(pattern, a.Name)
|
|
||||||
if matchErr != nil {
|
|
||||||
return fmt.Errorf("invalid glob pattern %q: %w", pattern, matchErr)
|
|
||||||
}
|
|
||||||
if !matched {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
toDownload = append(toDownload, a)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(toDownload) == 0 {
|
|
||||||
fmt.Fprintf(ios.Out, "No assets matching pattern %q in release %s\n", pattern, release.TagName)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure download directory exists
|
|
||||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
||||||
return fmt.Errorf("failed to create directory %s: %w", dir, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, a := range toDownload {
|
|
||||||
destPath := filepath.Join(dir, a.Name)
|
|
||||||
f, createErr := os.Create(destPath)
|
|
||||||
if createErr != nil {
|
|
||||||
return fmt.Errorf("failed to create file %s: %w", destPath, createErr)
|
|
||||||
}
|
|
||||||
|
|
||||||
dlErr := client.DownloadFile(a.DownloadURL, f)
|
|
||||||
closeErr := f.Close()
|
|
||||||
if dlErr != nil {
|
|
||||||
return fmt.Errorf("failed to download %s: %w", a.Name, dlErr)
|
|
||||||
}
|
|
||||||
if closeErr != nil {
|
|
||||||
return fmt.Errorf("failed to close %s: %w", destPath, closeErr)
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Fprintf(ios.Out, "Downloaded %s (%d bytes)\n", a.Name, a.Size)
|
|
||||||
}
|
|
||||||
|
|
||||||
cs := ios.ColorScheme()
|
|
||||||
fmt.Fprintf(ios.Out, "\n%s %s downloaded to %s\n", cs.SuccessIcon(), text.Pluralize(len(toDownload), "asset"), dir)
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func runReleaseDelete(cmd *cobra.Command, args []string) error {
|
func runReleaseDelete(cmd *cobra.Command, args []string) error {
|
||||||
repo, _ := cmd.Flags().GetString("repo")
|
repo, _ := cmd.Flags().GetString("repo")
|
||||||
yes, _ := cmd.Flags().GetBool("yes")
|
|
||||||
tag := args[0]
|
tag := args[0]
|
||||||
|
|
||||||
owner, name, err := parseRepo(repo)
|
owner, name, err := parseRepo(repo)
|
||||||
|
|
@ -547,38 +359,21 @@ func runReleaseDelete(cmd *cobra.Command, args []string) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd())
|
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost())
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
if _, err := client.DeleteRelease(owner, name, release.ID); err != nil {
|
||||||
ios.StopSpinner()
|
|
||||||
return fmt.Errorf("failed to delete release: %w", err)
|
return fmt.Errorf("failed to delete release: %w", err)
|
||||||
}
|
}
|
||||||
ios.StopSpinner()
|
|
||||||
|
|
||||||
cs := ios.ColorScheme()
|
fmt.Printf("Release %s deleted\n", release.TagName)
|
||||||
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/public/fj/internal/api"
|
"forgejo.zerova.net/sid/fgj-sid/internal/api"
|
||||||
"forgejo.zerova.net/public/fj/internal/config"
|
"forgejo.zerova.net/sid/fgj-sid/internal/config"
|
||||||
"forgejo.zerova.net/public/fj/internal/text"
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -67,45 +67,28 @@ 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
|
||||||
fj repo edit owner/repo --private
|
fgj repo edit owner/repo --private
|
||||||
|
|
||||||
# Make a repository public
|
# Make a repository public
|
||||||
fj repo edit owner/repo --public
|
fgj repo edit owner/repo --public
|
||||||
|
|
||||||
# Update description and homepage
|
# Update description and homepage
|
||||||
fj repo edit owner/repo -d "New description" --homepage https://example.com
|
fgj repo edit owner/repo -d "New description" --homepage https://example.com
|
||||||
|
|
||||||
# Change default branch
|
# Change default branch
|
||||||
fj repo edit --default-branch develop
|
fgj 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)
|
||||||
fj repo edit --public`,
|
fgj 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)
|
||||||
|
|
@ -121,26 +104,16 @@ 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")
|
||||||
addJSONFlags(repoEditCmd, "Output updated repository as JSON")
|
repoEditCmd.Flags().Bool("json", false, "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 {
|
||||||
|
|
@ -159,41 +132,28 @@ func runRepoView(cmd *cobra.Command, args []string) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd())
|
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost())
|
||||||
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
if web, _ := cmd.Flags().GetBool("web"); web {
|
fmt.Printf("Repository: %s/%s\n", repository.Owner.UserName, repository.Name)
|
||||||
return ios.OpenInBrowser(repository.HTMLURL)
|
fmt.Printf("Description: %s\n", repository.Description)
|
||||||
}
|
fmt.Printf("URL: %s\n", repository.HTMLURL)
|
||||||
|
fmt.Printf("Clone URL (HTTPS): %s\n", repository.CloneURL)
|
||||||
if wantJSON(cmd) {
|
fmt.Printf("Clone URL (SSH): %s\n", repository.SSHURL)
|
||||||
return outputJSON(cmd, repository)
|
fmt.Printf("Default Branch: %s\n", repository.DefaultBranch)
|
||||||
}
|
fmt.Printf("Stars: %d\n", repository.Stars)
|
||||||
|
fmt.Printf("Forks: %d\n", repository.Forks)
|
||||||
cs := ios.ColorScheme()
|
fmt.Printf("Open Issues: %d\n", repository.OpenIssues)
|
||||||
isTTY := ios.IsStdoutTTY()
|
fmt.Printf("Private: %v\n", repository.Private)
|
||||||
|
fmt.Printf("Created: %s\n", repository.Created.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.Printf("Updated: %s\n", repository.Updated.Format("2006-01-02 15:04:05"))
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|
@ -204,50 +164,42 @@ func runRepoList(cmd *cobra.Command, args []string) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd())
|
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost())
|
||||||
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
limit, _ := cmd.Flags().GetInt("limit")
|
repos, _, err := client.ListUserRepos(user.UserName, gitea.ListReposOptions{})
|
||||||
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.Fprintln(ios.Out, "No repositories found")
|
fmt.Println("No repositories found")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
tp := ios.NewTablePrinter()
|
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
||||||
tp.AddHeader("NAME", "VISIBILITY", "DESCRIPTION")
|
_, _ = fmt.Fprintf(w, "NAME\tVISIBILITY\tDESCRIPTION\n")
|
||||||
for _, repo := range repos {
|
for _, repo := range repos {
|
||||||
visibility := "public"
|
visibility := "public"
|
||||||
if repo.Private {
|
if repo.Private {
|
||||||
visibility = "private"
|
visibility = "private"
|
||||||
}
|
}
|
||||||
desc := text.Truncate(repo.Description, 50)
|
desc := repo.Description
|
||||||
tp.AddRow(fmt.Sprintf("%s/%s", repo.Owner.UserName, repo.Name), visibility, desc)
|
if len(desc) > 50 {
|
||||||
|
desc = desc[:47] + "..."
|
||||||
|
}
|
||||||
|
_, _ = fmt.Fprintf(w, "%s/%s\t%s\t%s\n", repo.Owner.UserName, repo.Name, visibility, desc)
|
||||||
}
|
}
|
||||||
return tp.Render()
|
_ = w.Flush()
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func runRepoClone(cmd *cobra.Command, args []string) error {
|
func runRepoClone(cmd *cobra.Command, args []string) error {
|
||||||
|
|
@ -264,14 +216,12 @@ func runRepoClone(cmd *cobra.Command, args []string) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd())
|
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost())
|
||||||
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)
|
||||||
}
|
}
|
||||||
|
|
@ -291,7 +241,7 @@ func runRepoClone(cmd *cobra.Command, args []string) error {
|
||||||
destination = name
|
destination = name
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Fprintf(ios.Out, "Cloning %s/%s to %s...\n", owner, name, destination)
|
fmt.Printf("Cloning %s/%s to %s...\n", owner, name, destination)
|
||||||
|
|
||||||
// Create parent directory if it doesn't exist
|
// Create parent directory if it doesn't exist
|
||||||
if dir := filepath.Dir(destination); dir != "." {
|
if dir := filepath.Dir(destination); dir != "." {
|
||||||
|
|
@ -300,21 +250,17 @@ func runRepoClone(cmd *cobra.Command, args []string) error {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ios.StartSpinner("Cloning repository...")
|
|
||||||
// Execute git clone
|
// Execute git clone
|
||||||
gitCmd := exec.Command("git", "clone", cloneURL, destination)
|
gitCmd := exec.Command("git", "clone", cloneURL, destination)
|
||||||
gitCmd.Stdout = ios.Out
|
gitCmd.Stdout = os.Stdout
|
||||||
gitCmd.Stderr = ios.ErrOut
|
gitCmd.Stderr = os.Stderr
|
||||||
gitCmd.Stdin = ios.In
|
gitCmd.Stdin = os.Stdin
|
||||||
|
|
||||||
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()
|
|
||||||
|
|
||||||
cs := ios.ColorScheme()
|
fmt.Printf("Repository cloned successfully to %s\n", destination)
|
||||||
fmt.Fprintf(ios.Out, "%s Repository cloned successfully to %s\n", cs.SuccessIcon(), destination)
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -331,22 +277,19 @@ func runRepoFork(cmd *cobra.Command, args []string) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd())
|
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost())
|
||||||
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
cs := ios.ColorScheme()
|
fmt.Printf("Repository forked successfully\n")
|
||||||
fmt.Fprintf(ios.Out, "%s Repository forked successfully\n", cs.SuccessIcon())
|
fmt.Printf("View at: %s\n", fork.HTMLURL)
|
||||||
fmt.Fprintf(ios.Out, "View at: %s\n", fork.HTMLURL)
|
fmt.Printf("Clone URL: %s\n", fork.CloneURL)
|
||||||
fmt.Fprintf(ios.Out, "Clone URL: %s\n", fork.CloneURL)
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
@ -378,7 +321,7 @@ func runRepoCreate(cmd *cobra.Command, args []string) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd())
|
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -392,14 +335,12 @@ 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)
|
||||||
}
|
}
|
||||||
|
|
@ -413,7 +354,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(ios.ErrOut, "warning: repository created but could not determine owner for homepage: %v\n", userErr)
|
fmt.Fprintf(os.Stderr, "warning: repository created but could not determine owner for homepage: %v\n", userErr)
|
||||||
homepage = "" // skip EditRepo
|
homepage = "" // skip EditRepo
|
||||||
} else {
|
} else {
|
||||||
ownerName = user.UserName
|
ownerName = user.UserName
|
||||||
|
|
@ -425,37 +366,36 @@ func runRepoCreate(cmd *cobra.Command, args []string) error {
|
||||||
Website: &homepage,
|
Website: &homepage,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(ios.ErrOut, "warning: repository created but failed to set homepage: %v\n", err)
|
fmt.Fprintf(os.Stderr, "warning: repository created but failed to set homepage: %v\n", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if team != "" {
|
if team != "" {
|
||||||
if !isOrg {
|
if !isOrg {
|
||||||
fmt.Fprintln(ios.ErrOut, "warning: --team is only meaningful for organization repositories")
|
fmt.Fprintln(os.Stderr, "warning: --team is only meaningful for organization repositories")
|
||||||
} else {
|
} else {
|
||||||
_, err = client.AddRepoTeam(org, repo.Name, team)
|
_, err = client.AddRepoTeam(org, repo.Name, team)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(ios.ErrOut, "warning: repository created but failed to add team %q: %v\n", team, err)
|
fmt.Fprintf(os.Stderr, "warning: repository created but failed to add team %q: %v\n", team, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
cs := ios.ColorScheme()
|
fmt.Printf("Repository created: %s\n", repo.HTMLURL)
|
||||||
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(), getCwd()); hostErr == nil {
|
if hostCfg, hostErr := cfg.GetHost("", getDetectedHost()); hostErr == nil {
|
||||||
if hostCfg.GitProtocol == "ssh" {
|
if hostCfg.GitProtocol == "ssh" {
|
||||||
cloneURL = repo.SSHURL
|
cloneURL = repo.SSHURL
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fmt.Fprintf(ios.Out, "Cloning into %s...\n", repo.Name)
|
fmt.Printf("Cloning into %s...\n", repo.Name)
|
||||||
gitCmd := exec.Command("git", "clone", cloneURL, repo.Name)
|
gitCmd := exec.Command("git", "clone", cloneURL, repo.Name)
|
||||||
gitCmd.Stdout = ios.Out
|
gitCmd.Stdout = os.Stdout
|
||||||
gitCmd.Stderr = ios.ErrOut
|
gitCmd.Stderr = os.Stderr
|
||||||
gitCmd.Stdin = ios.In
|
gitCmd.Stdin = os.Stdin
|
||||||
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)
|
||||||
}
|
}
|
||||||
|
|
@ -501,7 +441,7 @@ func runRepoEdit(cmd *cobra.Command, args []string) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd())
|
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -509,11 +449,6 @@ 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
|
||||||
|
|
@ -541,84 +476,36 @@ func runRepoEdit(cmd *cobra.Command, args []string) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
if !changed {
|
if !changed {
|
||||||
return fmt.Errorf("no changes specified; use flags like --name, --public, --private, --description, --homepage, or --default-branch")
|
return fmt.Errorf("no changes specified; use flags like --public, --private, --description, --homepage, or --default-branch")
|
||||||
}
|
}
|
||||||
|
|
||||||
ios.StartSpinner("Updating repository...")
|
|
||||||
repository, _, err := client.EditRepo(owner, name, opt)
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
if wantJSON(cmd) {
|
jsonFlag, _ := cmd.Flags().GetBool("json")
|
||||||
return outputJSON(cmd, repository)
|
if jsonFlag {
|
||||||
|
return writeJSON(repository)
|
||||||
}
|
}
|
||||||
|
|
||||||
cs := ios.ColorScheme()
|
fmt.Printf("Repository updated: %s\n", repository.HTMLURL)
|
||||||
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.Fprintln(ios.Out, "Visibility: private")
|
fmt.Println("Visibility: private")
|
||||||
} else {
|
} else {
|
||||||
fmt.Fprintln(ios.Out, "Visibility: public")
|
fmt.Println("Visibility: public")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if opt.Description != nil {
|
if opt.Description != nil {
|
||||||
fmt.Fprintf(ios.Out, "Description: %s\n", *opt.Description)
|
fmt.Printf("Description: %s\n", *opt.Description)
|
||||||
}
|
}
|
||||||
if opt.Website != nil {
|
if opt.Website != nil {
|
||||||
fmt.Fprintf(ios.Out, "Homepage: %s\n", *opt.Website)
|
fmt.Printf("Homepage: %s\n", *opt.Website)
|
||||||
}
|
}
|
||||||
if opt.DefaultBranch != nil {
|
if opt.DefaultBranch != nil {
|
||||||
fmt.Fprintf(ios.Out, "Default branch: %s\n", *opt.DefaultBranch)
|
fmt.Printf("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,14 +2,10 @@ package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"forgejo.zerova.net/public/fj/internal/config"
|
"forgejo.zerova.net/sid/fgj-sid/internal/git"
|
||||||
"forgejo.zerova.net/public/fj/internal/git"
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"github.com/spf13/viper"
|
"github.com/spf13/viper"
|
||||||
)
|
)
|
||||||
|
|
@ -18,11 +14,11 @@ var cfgFile string
|
||||||
var jsonErrors bool
|
var jsonErrors bool
|
||||||
|
|
||||||
var rootCmd = &cobra.Command{
|
var rootCmd = &cobra.Command{
|
||||||
Use: "fj",
|
Use: "fgj",
|
||||||
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: `fj is a command line tool for Forgejo instances (including Codeberg).
|
Long: `fgj is a command line tool for Forgejo instances (including Codeberg).
|
||||||
It brings pull requests, issues, and other Forgejo concepts to the terminal.`,
|
It brings pull requests, issues, and other Forgejo concepts to the terminal.`,
|
||||||
Version: "0.4.0",
|
Version: "0.3.0b",
|
||||||
SilenceErrors: true,
|
SilenceErrors: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -38,7 +34,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/fj/config.yaml)")
|
rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.config/fgj/config.yaml)")
|
||||||
rootCmd.PersistentFlags().BoolVar(&jsonErrors, "json-errors", false, "output errors as structured JSON to stderr")
|
rootCmd.PersistentFlags().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"))
|
||||||
|
|
@ -46,33 +42,16 @@ 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(ios.ErrOut, err)
|
fmt.Fprintln(os.Stderr, err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
configDir := home + "/.config/fj"
|
configDir := home + "/.config/fgj"
|
||||||
legacyDir := home + "/.config/fgj"
|
_ = os.MkdirAll(configDir, 0755)
|
||||||
|
|
||||||
// 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")
|
||||||
|
|
@ -80,17 +59,9 @@ func initConfig() {
|
||||||
}
|
}
|
||||||
|
|
||||||
viper.AutomaticEnv()
|
viper.AutomaticEnv()
|
||||||
viper.SetEnvPrefix("FJ")
|
viper.SetEnvPrefix("FGJ")
|
||||||
|
|
||||||
_ = 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".
|
||||||
|
|
@ -123,83 +94,3 @@ 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
404
cmd/wiki.go
|
|
@ -1,404 +0,0 @@
|
||||||
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/public/fj
|
module forgejo.zerova.net/sid/fgj-sid
|
||||||
|
|
||||||
go 1.24.0
|
go 1.23.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
code.gitea.io/sdk/gitea v0.22.1
|
code.gitea.io/sdk/gitea v0.22.1
|
||||||
|
|
@ -19,8 +19,6 @@ require (
|
||||||
github.com/hashicorp/go-version v1.7.0 // indirect
|
github.com/hashicorp/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
|
||||||
|
|
@ -36,7 +34,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.38.0 // indirect
|
golang.org/x/sys v0.33.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,10 +24,6 @@ github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
|
||||||
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
|
github.com/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=
|
||||||
|
|
@ -92,8 +88,6 @@ golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7w
|
||||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.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,22 +6,11 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
|
||||||
|
|
||||||
"code.gitea.io/sdk/gitea"
|
"code.gitea.io/sdk/gitea"
|
||||||
"forgejo.zerova.net/public/fj/internal/config"
|
"forgejo.zerova.net/sid/fgj-sid/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
// SharedHTTPClient is the package-wide HTTP client. Exported so other
|
|
||||||
// packages (notably cmd/api.go) can reuse the same timeout and connection
|
|
||||||
// pooling instead of constructing zero-value clients with no timeout.
|
|
||||||
var SharedHTTPClient = &http.Client{
|
|
||||||
Timeout: 30 * time.Second,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Internal alias kept so existing call sites compile unchanged.
|
|
||||||
var sharedHTTPClient = SharedHTTPClient
|
|
||||||
|
|
||||||
type Client struct {
|
type Client struct {
|
||||||
*gitea.Client
|
*gitea.Client
|
||||||
hostname string
|
hostname string
|
||||||
|
|
@ -45,8 +34,8 @@ func NewClient(hostname, token string) (*Client, error) {
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewClientFromConfig(cfg *config.Config, hostname string, detectedHost string, cwd string) (*Client, error) {
|
func NewClientFromConfig(cfg *config.Config, hostname string, detectedHost string) (*Client, error) {
|
||||||
host, err := cfg.GetHost(hostname, detectedHost, cwd)
|
host, err := cfg.GetHost(hostname, detectedHost)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -74,7 +63,8 @@ func (c *Client) GetJSON(path string, result any) error {
|
||||||
}
|
}
|
||||||
req.Header.Set("Accept", "application/json")
|
req.Header.Set("Accept", "application/json")
|
||||||
|
|
||||||
resp, err := sharedHTTPClient.Do(req)
|
httpClient := &http.Client{}
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
|
|
@ -84,11 +74,8 @@ func (c *Client) GetJSON(path string, result any) error {
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
if resp.StatusCode != http.StatusOK {
|
||||||
body, readErr := io.ReadAll(resp.Body)
|
body, _ := 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),
|
||||||
|
|
@ -138,7 +125,8 @@ 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")
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := sharedHTTPClient.Do(req)
|
httpClient := &http.Client{}
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
|
|
@ -148,11 +136,8 @@ func (c *Client) DoJSON(method string, path string, body any, result any) (int,
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusNoContent {
|
||||||
bodyBytes, readErr := io.ReadAll(resp.Body)
|
bodyBytes, _ := 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),
|
||||||
|
|
@ -169,40 +154,6 @@ 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)
|
||||||
|
|
@ -215,7 +166,8 @@ func (c *Client) GetRawLog(url string) (string, error) {
|
||||||
req.Header.Set("Authorization", "token "+c.token)
|
req.Header.Set("Authorization", "token "+c.token)
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := sharedHTTPClient.Do(req)
|
httpClient := &http.Client{}
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
|
|
@ -226,10 +178,7 @@ func (c *Client) GetRawLog(url string) (string, error) {
|
||||||
}()
|
}()
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
body, readErr := io.ReadAll(resp.Body)
|
body, _ := 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/public/fj/internal/config"
|
"forgejo.zerova.net/sid/fgj-sid/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,7 +4,6 @@ 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"
|
||||||
|
|
@ -14,41 +13,25 @@ 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, "fj"), nil
|
return filepath.Join(xdgConfigHome, "fgj"), nil
|
||||||
}
|
}
|
||||||
home, err := os.UserHomeDir()
|
home, err := os.UserHomeDir()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
return filepath.Join(home, ".config", "fj"), nil
|
return filepath.Join(home, ".config", "fgj"), 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
|
||||||
|
|
@ -82,43 +65,9 @@ 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 {
|
||||||
|
|
@ -145,27 +94,22 @@ 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 (FJ_HOST, with FGJ_HOST fallback)
|
// 3. Environment variable (FGJ_HOST)
|
||||||
// 4. Auto-detected hostname from git remote
|
// 4. Auto-detected hostname from git remote
|
||||||
// 5. match_dirs lookup (longest prefix match)
|
// 5. Default to codeberg.org
|
||||||
// 6. Default to codeberg.org
|
func (c *Config) GetHost(hostname string, detectedHost string) (HostConfig, error) {
|
||||||
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 = EnvWithFallback("FJ_HOST", "FGJ_HOST")
|
hostname = os.Getenv("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"
|
||||||
}
|
}
|
||||||
|
|
@ -178,90 +122,6 @@ func (c *Config) GetHost(hostname string, detectedHost string, cwd string) (Host
|
||||||
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/fj"
|
expected := "/custom/config/fgj"
|
||||||
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,233 +422,13 @@ 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,48 +114,6 @@ 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/fj.git",
|
url: "https://codeberg.org/romaintb/fgj.git",
|
||||||
wantOwner: "romaintb",
|
wantOwner: "romaintb",
|
||||||
wantName: "fj",
|
wantName: "fgj",
|
||||||
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/fj",
|
url: "https://codeberg.org/romaintb/fgj",
|
||||||
wantOwner: "romaintb",
|
wantOwner: "romaintb",
|
||||||
wantName: "fj",
|
wantName: "fgj",
|
||||||
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/fj.git",
|
url: "git@codeberg.org:romaintb/fgj.git",
|
||||||
wantOwner: "romaintb",
|
wantOwner: "romaintb",
|
||||||
wantName: "fj",
|
wantName: "fgj",
|
||||||
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/fj",
|
url: "git@codeberg.org:romaintb/fgj",
|
||||||
wantOwner: "romaintb",
|
wantOwner: "romaintb",
|
||||||
wantName: "fj",
|
wantName: "fgj",
|
||||||
wantHost: "codeberg.org",
|
wantHost: "codeberg.org",
|
||||||
wantErr: false,
|
wantErr: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "SSH protocol URL",
|
name: "SSH protocol URL",
|
||||||
url: "ssh://git@codeberg.org/romaintb/fj.git",
|
url: "ssh://git@codeberg.org/romaintb/fgj.git",
|
||||||
wantOwner: "romaintb",
|
wantOwner: "romaintb",
|
||||||
wantName: "fj",
|
wantName: "fgj",
|
||||||
wantHost: "codeberg.org",
|
wantHost: "codeberg.org",
|
||||||
wantErr: false,
|
wantErr: false,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,77 +0,0 @@
|
||||||
package iostreams
|
|
||||||
|
|
||||||
import "fmt"
|
|
||||||
|
|
||||||
// ColorScheme provides semantic color methods that respect whether color output is enabled.
|
|
||||||
type ColorScheme struct {
|
|
||||||
enabled bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewColorScheme creates a ColorScheme. When enabled is false, all methods return
|
|
||||||
// undecorated text.
|
|
||||||
func NewColorScheme(enabled bool) *ColorScheme {
|
|
||||||
return &ColorScheme{enabled: enabled}
|
|
||||||
}
|
|
||||||
|
|
||||||
// colorize wraps text in ANSI escape codes if color is enabled.
|
|
||||||
func (cs *ColorScheme) colorize(code string, text string) string {
|
|
||||||
if !cs.enabled {
|
|
||||||
return text
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("\033[%sm%s\033[0m", code, text)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bold renders text in bold.
|
|
||||||
func (cs *ColorScheme) Bold(s string) string {
|
|
||||||
return cs.colorize("1", s)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Red renders text in red.
|
|
||||||
func (cs *ColorScheme) Red(s string) string {
|
|
||||||
return cs.colorize("31", s)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Green renders text in green.
|
|
||||||
func (cs *ColorScheme) Green(s string) string {
|
|
||||||
return cs.colorize("32", s)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Yellow renders text in yellow.
|
|
||||||
func (cs *ColorScheme) Yellow(s string) string {
|
|
||||||
return cs.colorize("33", s)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cyan renders text in cyan.
|
|
||||||
func (cs *ColorScheme) Cyan(s string) string {
|
|
||||||
return cs.colorize("36", s)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Magenta renders text in magenta.
|
|
||||||
func (cs *ColorScheme) Magenta(s string) string {
|
|
||||||
return cs.colorize("35", s)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Muted renders text in gray (dimmed).
|
|
||||||
func (cs *ColorScheme) Muted(s string) string {
|
|
||||||
return cs.colorize("90", s)
|
|
||||||
}
|
|
||||||
|
|
||||||
// SuccessIcon returns a green check mark if color is enabled, plain otherwise.
|
|
||||||
func (cs *ColorScheme) SuccessIcon() string {
|
|
||||||
return cs.Green("✓")
|
|
||||||
}
|
|
||||||
|
|
||||||
// WarningIcon returns a yellow exclamation mark if color is enabled, plain otherwise.
|
|
||||||
func (cs *ColorScheme) WarningIcon() string {
|
|
||||||
return cs.Yellow("!")
|
|
||||||
}
|
|
||||||
|
|
||||||
// FailureIcon returns a red X mark if color is enabled, plain otherwise.
|
|
||||||
func (cs *ColorScheme) FailureIcon() string {
|
|
||||||
return cs.Red("✗")
|
|
||||||
}
|
|
||||||
|
|
||||||
// SuccessIconWithColor returns the success icon followed by the message in green.
|
|
||||||
func (cs *ColorScheme) SuccessIconWithColor(msg string) string {
|
|
||||||
return cs.SuccessIcon() + " " + cs.Green(msg)
|
|
||||||
}
|
|
||||||
|
|
@ -1,275 +0,0 @@
|
||||||
package iostreams
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
"runtime"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"golang.org/x/term"
|
|
||||||
)
|
|
||||||
|
|
||||||
// IOStreams provides the standard streams for the CLI along with TTY detection,
|
|
||||||
// color support, pager integration, and other terminal helpers.
|
|
||||||
type IOStreams struct {
|
|
||||||
In io.Reader
|
|
||||||
Out io.Writer
|
|
||||||
ErrOut io.Writer
|
|
||||||
|
|
||||||
// Private fields for state
|
|
||||||
isStdinTTY bool
|
|
||||||
isStdoutTTY bool
|
|
||||||
isStderrTTY bool
|
|
||||||
|
|
||||||
pagerProcess *exec.Cmd
|
|
||||||
pagerPipe io.WriteCloser
|
|
||||||
originalOut io.Writer
|
|
||||||
|
|
||||||
colorScheme *ColorScheme
|
|
||||||
|
|
||||||
spinnerMu sync.Mutex
|
|
||||||
spinnerCancel chan struct{}
|
|
||||||
}
|
|
||||||
|
|
||||||
// New creates an IOStreams wired to the real os.Stdin, os.Stdout, and os.Stderr,
|
|
||||||
// with TTY status auto-detected. Setting FJ_FORCE_TTY=1 (or legacy FGJ_FORCE_TTY=1)
|
|
||||||
// forces all streams to be treated as TTYs.
|
|
||||||
func New() *IOStreams {
|
|
||||||
forceTTY := os.Getenv("FJ_FORCE_TTY") != "" || os.Getenv("FGJ_FORCE_TTY") != ""
|
|
||||||
|
|
||||||
stdinTTY := forceTTY || (isTerminal(os.Stdin.Fd()))
|
|
||||||
stdoutTTY := forceTTY || (isTerminal(os.Stdout.Fd()))
|
|
||||||
stderrTTY := forceTTY || (isTerminal(os.Stderr.Fd()))
|
|
||||||
|
|
||||||
return &IOStreams{
|
|
||||||
In: os.Stdin,
|
|
||||||
Out: os.Stdout,
|
|
||||||
ErrOut: os.Stderr,
|
|
||||||
isStdinTTY: stdinTTY,
|
|
||||||
isStdoutTTY: stdoutTTY,
|
|
||||||
isStderrTTY: stderrTTY,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test creates an IOStreams backed by bytes.Buffers, suitable for unit tests.
|
|
||||||
// All TTY flags are false.
|
|
||||||
func Test() *IOStreams {
|
|
||||||
return &IOStreams{
|
|
||||||
In: &bytes.Buffer{},
|
|
||||||
Out: &bytes.Buffer{},
|
|
||||||
ErrOut: &bytes.Buffer{},
|
|
||||||
isStdinTTY: false,
|
|
||||||
isStdoutTTY: false,
|
|
||||||
isStderrTTY: false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsStdinTTY reports whether standard input is connected to a terminal.
|
|
||||||
func (s *IOStreams) IsStdinTTY() bool {
|
|
||||||
return s.isStdinTTY
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsStdoutTTY reports whether standard output is connected to a terminal.
|
|
||||||
func (s *IOStreams) IsStdoutTTY() bool {
|
|
||||||
return s.isStdoutTTY
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsStderrTTY reports whether standard error is connected to a terminal.
|
|
||||||
func (s *IOStreams) IsStderrTTY() bool {
|
|
||||||
return s.isStderrTTY
|
|
||||||
}
|
|
||||||
|
|
||||||
// TerminalWidth returns the width of the terminal connected to stdout. If stdout
|
|
||||||
// is not a terminal, it returns 80.
|
|
||||||
func (s *IOStreams) TerminalWidth() int {
|
|
||||||
if !s.isStdoutTTY {
|
|
||||||
return 80
|
|
||||||
}
|
|
||||||
if f, ok := s.Out.(*os.File); ok {
|
|
||||||
w, _, err := term.GetSize(int(f.Fd()))
|
|
||||||
if err == nil && w > 0 {
|
|
||||||
return w
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return 80
|
|
||||||
}
|
|
||||||
|
|
||||||
// ColorEnabled returns true when color output should be used. Color is enabled
|
|
||||||
// when stdout is a TTY and the NO_COLOR environment variable is not set.
|
|
||||||
func (s *IOStreams) ColorEnabled() bool {
|
|
||||||
if os.Getenv("NO_COLOR") != "" {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return s.isStdoutTTY
|
|
||||||
}
|
|
||||||
|
|
||||||
// ColorScheme returns a lazily-initialized ColorScheme that respects the current
|
|
||||||
// color settings.
|
|
||||||
func (s *IOStreams) ColorScheme() *ColorScheme {
|
|
||||||
if s.colorScheme == nil {
|
|
||||||
s.colorScheme = NewColorScheme(s.ColorEnabled())
|
|
||||||
}
|
|
||||||
return s.colorScheme
|
|
||||||
}
|
|
||||||
|
|
||||||
// StartPager starts an external pager process and redirects Out to its stdin.
|
|
||||||
// It checks FJ_PAGER (or legacy FGJ_PAGER), then PAGER, then defaults to "less".
|
|
||||||
// If LESS is not already set, it is set to "FRX" for a good default experience.
|
|
||||||
func (s *IOStreams) StartPager() error {
|
|
||||||
if !s.isStdoutTTY {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
pagerCmd := os.Getenv("FJ_PAGER")
|
|
||||||
if pagerCmd == "" {
|
|
||||||
pagerCmd = os.Getenv("FGJ_PAGER")
|
|
||||||
}
|
|
||||||
if pagerCmd == "" {
|
|
||||||
pagerCmd = os.Getenv("PAGER")
|
|
||||||
}
|
|
||||||
if pagerCmd == "" {
|
|
||||||
pagerCmd = "less"
|
|
||||||
}
|
|
||||||
|
|
||||||
if os.Getenv("LESS") == "" {
|
|
||||||
os.Setenv("LESS", "FRX")
|
|
||||||
}
|
|
||||||
|
|
||||||
parts := strings.Fields(pagerCmd)
|
|
||||||
//nolint:gosec // pager command is user-configured
|
|
||||||
cmd := exec.Command(parts[0], parts[1:]...)
|
|
||||||
cmd.Stdout = s.Out
|
|
||||||
cmd.Stderr = s.ErrOut
|
|
||||||
|
|
||||||
pipe, err := cmd.StdinPipe()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("creating pager pipe: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := cmd.Start(); err != nil {
|
|
||||||
return fmt.Errorf("starting pager: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
s.pagerProcess = cmd
|
|
||||||
s.pagerPipe = pipe
|
|
||||||
s.originalOut = s.Out
|
|
||||||
s.Out = pipe
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// StopPager closes the pager's stdin pipe and waits for the process to exit.
|
|
||||||
// It restores Out to the original writer.
|
|
||||||
func (s *IOStreams) StopPager() {
|
|
||||||
if s.pagerPipe == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
_ = s.pagerPipe.Close()
|
|
||||||
_ = s.pagerProcess.Wait()
|
|
||||||
|
|
||||||
s.Out = s.originalOut
|
|
||||||
s.pagerPipe = nil
|
|
||||||
s.pagerProcess = nil
|
|
||||||
s.originalOut = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// spinnerFrames are the Braille-based animation frames for the spinner.
|
|
||||||
var spinnerFrames = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
|
|
||||||
|
|
||||||
// StartSpinner shows an animated spinner with the given label on stderr. It only
|
|
||||||
// runs when stderr is a TTY. Call StopSpinner to halt it.
|
|
||||||
func (s *IOStreams) StartSpinner(label string) {
|
|
||||||
if !s.isStderrTTY {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
s.spinnerMu.Lock()
|
|
||||||
defer s.spinnerMu.Unlock()
|
|
||||||
|
|
||||||
// Stop any existing spinner first.
|
|
||||||
if s.spinnerCancel != nil {
|
|
||||||
close(s.spinnerCancel)
|
|
||||||
s.spinnerCancel = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
cancel := make(chan struct{})
|
|
||||||
s.spinnerCancel = cancel
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
ticker := time.NewTicker(80 * time.Millisecond)
|
|
||||||
defer ticker.Stop()
|
|
||||||
|
|
||||||
i := 0
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-cancel:
|
|
||||||
// Clear the spinner line.
|
|
||||||
fmt.Fprintf(s.ErrOut, "\r\033[K")
|
|
||||||
return
|
|
||||||
case <-ticker.C:
|
|
||||||
frame := spinnerFrames[i%len(spinnerFrames)]
|
|
||||||
fmt.Fprintf(s.ErrOut, "\r%s %s", frame, label)
|
|
||||||
i++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
|
|
||||||
// StopSpinner halts the spinner and clears the line on stderr.
|
|
||||||
func (s *IOStreams) StopSpinner() {
|
|
||||||
s.spinnerMu.Lock()
|
|
||||||
defer s.spinnerMu.Unlock()
|
|
||||||
|
|
||||||
if s.spinnerCancel != nil {
|
|
||||||
close(s.spinnerCancel)
|
|
||||||
s.spinnerCancel = nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// OpenInBrowser opens the given URL in the user's default browser.
|
|
||||||
func (s *IOStreams) OpenInBrowser(url string) error {
|
|
||||||
var cmd *exec.Cmd
|
|
||||||
switch runtime.GOOS {
|
|
||||||
case "darwin":
|
|
||||||
cmd = exec.Command("open", url)
|
|
||||||
case "windows":
|
|
||||||
cmd = exec.Command("cmd", "/c", "start", url)
|
|
||||||
default: // linux, freebsd, etc.
|
|
||||||
cmd = exec.Command("xdg-open", url)
|
|
||||||
}
|
|
||||||
return cmd.Start()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ConfirmAction prompts the user with a yes/no question and returns their
|
|
||||||
// answer. It returns an error if stdin is not a TTY (non-interactive).
|
|
||||||
func (s *IOStreams) ConfirmAction(prompt string) (bool, error) {
|
|
||||||
if !s.isStdinTTY {
|
|
||||||
return false, fmt.Errorf("cannot prompt for confirmation: not an interactive terminal")
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Fprintf(s.ErrOut, "%s [y/N]: ", prompt)
|
|
||||||
|
|
||||||
var response string
|
|
||||||
if _, err := fmt.Fscan(s.In, &response); err != nil {
|
|
||||||
return false, fmt.Errorf("reading response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
response = strings.ToLower(strings.TrimSpace(response))
|
|
||||||
return response == "y" || response == "yes", nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewTablePrinter creates a TablePrinter that writes to this IOStreams' output.
|
|
||||||
func (s *IOStreams) NewTablePrinter() *TablePrinter {
|
|
||||||
return NewTablePrinter(s)
|
|
||||||
}
|
|
||||||
|
|
||||||
// isTerminal reports whether the given file descriptor is a terminal.
|
|
||||||
func isTerminal(fd uintptr) bool {
|
|
||||||
return term.IsTerminal(int(fd))
|
|
||||||
}
|
|
||||||
|
|
@ -1,64 +0,0 @@
|
||||||
package iostreams
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
"text/tabwriter"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TablePrinter prints TTY-aware tables. In TTY mode it uses aligned columns with
|
|
||||||
// bold headers. In pipe mode it emits tab-separated values without headers.
|
|
||||||
type TablePrinter struct {
|
|
||||||
ios *IOStreams
|
|
||||||
headers []string
|
|
||||||
rows [][]string
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewTablePrinter creates a TablePrinter that writes to ios.Out.
|
|
||||||
func NewTablePrinter(ios *IOStreams) *TablePrinter {
|
|
||||||
return &TablePrinter{
|
|
||||||
ios: ios,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// AddHeader sets the column headers. Headers are only displayed in TTY mode.
|
|
||||||
func (t *TablePrinter) AddHeader(headers ...string) {
|
|
||||||
t.headers = headers
|
|
||||||
}
|
|
||||||
|
|
||||||
// AddRow appends a row of fields to the table.
|
|
||||||
func (t *TablePrinter) AddRow(fields ...string) {
|
|
||||||
t.rows = append(t.rows, fields)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Render writes the table to the IOStreams output. In TTY mode it uses tabwriter
|
|
||||||
// with bold headers. In pipe mode it emits tab-separated values without headers.
|
|
||||||
func (t *TablePrinter) Render() error {
|
|
||||||
if !t.ios.IsStdoutTTY() {
|
|
||||||
// Pipe mode: tab-separated, no headers
|
|
||||||
for _, row := range t.rows {
|
|
||||||
if _, err := fmt.Fprintln(t.ios.Out, strings.Join(row, "\t")); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// TTY mode: use tabwriter with aligned columns
|
|
||||||
w := tabwriter.NewWriter(t.ios.Out, 0, 0, 2, ' ', 0)
|
|
||||||
|
|
||||||
if len(t.headers) > 0 {
|
|
||||||
cs := t.ios.ColorScheme()
|
|
||||||
boldHeaders := make([]string, len(t.headers))
|
|
||||||
for i, h := range t.headers {
|
|
||||||
boldHeaders[i] = cs.Bold(h)
|
|
||||||
}
|
|
||||||
fmt.Fprintln(w, strings.Join(boldHeaders, "\t"))
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, row := range t.rows {
|
|
||||||
fmt.Fprintln(w, strings.Join(row, "\t"))
|
|
||||||
}
|
|
||||||
|
|
||||||
return w.Flush()
|
|
||||||
}
|
|
||||||
|
|
@ -1,70 +0,0 @@
|
||||||
package text
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"math"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Pluralize returns "1 issue" or "2 issues" depending on count.
|
|
||||||
// It applies a simple "s" suffix rule.
|
|
||||||
func Pluralize(count int, singular string) string {
|
|
||||||
if count == 1 {
|
|
||||||
return fmt.Sprintf("%d %s", count, singular)
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("%d %ss", count, singular)
|
|
||||||
}
|
|
||||||
|
|
||||||
// FuzzyAgo returns a human-friendly relative time string like "just now",
|
|
||||||
// "2 minutes ago", "3 hours ago", etc.
|
|
||||||
func FuzzyAgo(t time.Time) string {
|
|
||||||
d := time.Since(t)
|
|
||||||
|
|
||||||
if d < time.Minute {
|
|
||||||
return "just now"
|
|
||||||
}
|
|
||||||
|
|
||||||
minutes := int(math.Floor(d.Minutes()))
|
|
||||||
if minutes < 60 {
|
|
||||||
return fmt.Sprintf("%s ago", Pluralize(minutes, "minute"))
|
|
||||||
}
|
|
||||||
|
|
||||||
hours := int(math.Floor(d.Hours()))
|
|
||||||
if hours < 24 {
|
|
||||||
return fmt.Sprintf("%s ago", Pluralize(hours, "hour"))
|
|
||||||
}
|
|
||||||
|
|
||||||
days := hours / 24
|
|
||||||
if days < 30 {
|
|
||||||
return fmt.Sprintf("%s ago", Pluralize(days, "day"))
|
|
||||||
}
|
|
||||||
|
|
||||||
months := days / 30
|
|
||||||
if months < 12 {
|
|
||||||
return fmt.Sprintf("%s ago", Pluralize(months, "month"))
|
|
||||||
}
|
|
||||||
|
|
||||||
years := months / 12
|
|
||||||
return fmt.Sprintf("%s ago", Pluralize(years, "year"))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Truncate shortens text to maxWidth, replacing the end with "..." if it exceeds
|
|
||||||
// the limit. If maxWidth is less than or equal to 3, the result is just "...".
|
|
||||||
func Truncate(text string, maxWidth int) string {
|
|
||||||
if len(text) <= maxWidth {
|
|
||||||
return text
|
|
||||||
}
|
|
||||||
if maxWidth <= 3 {
|
|
||||||
return "..."[:maxWidth]
|
|
||||||
}
|
|
||||||
return text[:maxWidth-3] + "..."
|
|
||||||
}
|
|
||||||
|
|
||||||
// FormatDate returns a human-friendly relative time for TTY output, or an
|
|
||||||
// RFC3339 timestamp for piped output.
|
|
||||||
func FormatDate(t time.Time, isTTY bool) string {
|
|
||||||
if isTTY {
|
|
||||||
return FuzzyAgo(t)
|
|
||||||
}
|
|
||||||
return t.Format(time.RFC3339)
|
|
||||||
}
|
|
||||||
3
main.go
3
main.go
|
|
@ -4,12 +4,11 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
"forgejo.zerova.net/public/fj/cmd"
|
"forgejo.zerova.net/sid/fgj-sid/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,7 +9,6 @@ import (
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"code.gitea.io/sdk/gitea"
|
"code.gitea.io/sdk/gitea"
|
||||||
|
|
@ -228,18 +227,15 @@ func (env *TestEnv) CleanupRepo(owner, repoName string) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetBinaryPath returns the path to the built fj binary
|
// GetBinaryPath returns the path to the built fgj binary
|
||||||
func (env *TestEnv) GetBinaryPath() string {
|
func (env *TestEnv) GetBinaryPath() string {
|
||||||
binaryPath := os.Getenv("FJ_BINARY_PATH")
|
binaryPath := os.Getenv("FGJ_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/fj",
|
"./bin/fgj",
|
||||||
"bin/fj",
|
"bin/fgj",
|
||||||
"/home/romain/work/fj/bin/fj",
|
"/home/romain/work/fgj/bin/fgj",
|
||||||
}
|
}
|
||||||
for _, candidate := range candidates {
|
for _, candidate := range candidates {
|
||||||
if _, err := os.Stat(candidate); err == nil {
|
if _, err := os.Stat(candidate); err == nil {
|
||||||
|
|
@ -251,7 +247,7 @@ func (env *TestEnv) GetBinaryPath() string {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// If no binary found, return default (will error when executed)
|
// If no binary found, return default (will error when executed)
|
||||||
binaryPath = "./bin/fj"
|
binaryPath = "./bin/fgj"
|
||||||
}
|
}
|
||||||
return binaryPath
|
return binaryPath
|
||||||
}
|
}
|
||||||
|
|
@ -299,39 +295,3 @@ 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