Compare commits
11 commits
feat/v0.4-
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0069198ca6 | ||
|
|
373c769d2c | ||
|
|
155ddb97ba | ||
|
|
133fb2fea4 | ||
|
|
0c181df1d1 | ||
|
|
f75b831a53 | ||
|
|
0fda0b8679 | ||
|
|
25868adcad | ||
|
|
c3e8ad67ed | ||
|
|
cf7c0e0878 | ||
|
|
bc43f6e5a5 |
65 changed files with 1445 additions and 5107 deletions
|
|
@ -15,7 +15,7 @@ jobs:
|
||||||
- name: Set up Go
|
- name: Set up Go
|
||||||
uses: https://github.com/actions/setup-go@v5
|
uses: https://github.com/actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version: '1.24'
|
go-version: '1.21'
|
||||||
cache: true
|
cache: true
|
||||||
|
|
||||||
- name: Install golangci-lint
|
- name: Install golangci-lint
|
||||||
|
|
@ -34,7 +34,7 @@ jobs:
|
||||||
- name: Set up Go
|
- name: Set up Go
|
||||||
uses: https://github.com/actions/setup-go@v5
|
uses: https://github.com/actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version: '1.24'
|
go-version: '1.21'
|
||||||
cache: true
|
cache: true
|
||||||
|
|
||||||
- name: Build application
|
- name: Build application
|
||||||
|
|
@ -49,7 +49,7 @@ jobs:
|
||||||
- name: Set up Go
|
- name: Set up Go
|
||||||
uses: https://github.com/actions/setup-go@v5
|
uses: https://github.com/actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version: '1.24'
|
go-version: '1.21'
|
||||||
cache: true
|
cache: true
|
||||||
|
|
||||||
- name: Run unit tests
|
- name: Run unit tests
|
||||||
|
|
@ -66,13 +66,13 @@ jobs:
|
||||||
- name: Set up Go
|
- name: Set up Go
|
||||||
uses: https://github.com/actions/setup-go@v5
|
uses: https://github.com/actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version: '1.24'
|
go-version: '1.21'
|
||||||
cache: true
|
cache: true
|
||||||
|
|
||||||
- name: Build production binary
|
- name: Build production binary
|
||||||
run: |
|
run: |
|
||||||
make build
|
make build
|
||||||
echo "Binary built at: $(pwd)/bin/fgj"
|
echo "Binary built at: $(pwd)/bin/fj"
|
||||||
|
|
||||||
- name: Run functional tests
|
- name: Run functional tests
|
||||||
run: go test -v -race -tags=functional ./tests/functional/...
|
run: go test -v -race -tags=functional ./tests/functional/...
|
||||||
|
|
|
||||||
|
|
@ -18,13 +18,13 @@ jobs:
|
||||||
- name: Set up Go
|
- name: Set up Go
|
||||||
uses: https://github.com/actions/setup-go@v5
|
uses: https://github.com/actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version: '1.24'
|
go-version: '1.21'
|
||||||
cache: true
|
cache: true
|
||||||
|
|
||||||
- name: Build production binary
|
- name: Build production binary
|
||||||
run: |
|
run: |
|
||||||
make build
|
make build
|
||||||
echo "Binary built at: $(pwd)/bin/fgj"
|
echo "Binary built at: $(pwd)/bin/fj"
|
||||||
|
|
||||||
- name: Run functional tests
|
- name: Run functional tests
|
||||||
run: go test -v -race -tags=functional ./tests/functional/...
|
run: go test -v -race -tags=functional ./tests/functional/...
|
||||||
|
|
|
||||||
|
|
@ -1,36 +0,0 @@
|
||||||
name: Release
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
tags:
|
|
||||||
- 'v*'
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
goreleaser:
|
|
||||||
runs-on: codeberg-small
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: https://github.com/actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Fetch tags
|
|
||||||
run: git fetch --force --tags
|
|
||||||
|
|
||||||
- name: Set up Go
|
|
||||||
uses: https://github.com/actions/setup-go@v5
|
|
||||||
with:
|
|
||||||
go-version: '1.24'
|
|
||||||
cache: true
|
|
||||||
|
|
||||||
- name: Run GoReleaser
|
|
||||||
uses: https://github.com/goreleaser/goreleaser-action@v6
|
|
||||||
with:
|
|
||||||
distribution: goreleaser
|
|
||||||
version: "~> v2"
|
|
||||||
args: release --clean
|
|
||||||
env:
|
|
||||||
# Forgejo Actions injects GITEA_TOKEN for the workflow by default;
|
|
||||||
# override with RELEASE_TOKEN secret if a longer-lived token is needed.
|
|
||||||
GITEA_TOKEN: ${{ secrets.RELEASE_TOKEN || secrets.GITEA_TOKEN }}
|
|
||||||
GORELEASER_FORCE_TOKEN: gitea
|
|
||||||
7
.gitignore
vendored
7
.gitignore
vendored
|
|
@ -1,15 +1,12 @@
|
||||||
# Binaries
|
# Binaries
|
||||||
fgj
|
fj
|
||||||
|
bin/
|
||||||
*.exe
|
*.exe
|
||||||
*.exe~
|
*.exe~
|
||||||
*.dll
|
*.dll
|
||||||
*.so
|
*.so
|
||||||
*.dylib
|
*.dylib
|
||||||
|
|
||||||
# Goreleaser output
|
|
||||||
dist/
|
|
||||||
bin/
|
|
||||||
|
|
||||||
# Test binary
|
# Test binary
|
||||||
*.test
|
*.test
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,91 +0,0 @@
|
||||||
# yaml-language-server: $schema=https://goreleaser.com/static/schema.json
|
|
||||||
version: 2
|
|
||||||
|
|
||||||
project_name: fgj
|
|
||||||
|
|
||||||
before:
|
|
||||||
hooks:
|
|
||||||
- go mod tidy
|
|
||||||
|
|
||||||
builds:
|
|
||||||
- id: fgj
|
|
||||||
binary: fgj
|
|
||||||
main: .
|
|
||||||
env:
|
|
||||||
- CGO_ENABLED=0
|
|
||||||
flags:
|
|
||||||
- -trimpath
|
|
||||||
ldflags:
|
|
||||||
- -s -w -X "forgejo.zerova.net/public/fgj-sid/cmd.version={{ .Version }}"
|
|
||||||
goos:
|
|
||||||
- linux
|
|
||||||
- darwin
|
|
||||||
- windows
|
|
||||||
- freebsd
|
|
||||||
goarch:
|
|
||||||
- amd64
|
|
||||||
- arm64
|
|
||||||
- arm
|
|
||||||
goarm:
|
|
||||||
- "6"
|
|
||||||
- "7"
|
|
||||||
ignore:
|
|
||||||
- goos: darwin
|
|
||||||
goarch: arm
|
|
||||||
- goos: windows
|
|
||||||
goarch: arm
|
|
||||||
- goos: freebsd
|
|
||||||
goarch: arm
|
|
||||||
|
|
||||||
archives:
|
|
||||||
- id: default
|
|
||||||
name_template: >-
|
|
||||||
{{ .ProjectName }}-
|
|
||||||
{{- .Version }}-
|
|
||||||
{{- .Os }}-
|
|
||||||
{{- if eq .Arch "amd64" }}x86_64
|
|
||||||
{{- else if eq .Arch "386" }}i386
|
|
||||||
{{- else }}{{ .Arch }}{{ end }}
|
|
||||||
{{- if .Arm }}v{{ .Arm }}{{ end }}
|
|
||||||
formats: [tar.gz]
|
|
||||||
format_overrides:
|
|
||||||
- goos: windows
|
|
||||||
formats: [zip]
|
|
||||||
files:
|
|
||||||
- README.md
|
|
||||||
- LICENSE
|
|
||||||
- CHANGELOG.md
|
|
||||||
|
|
||||||
checksum:
|
|
||||||
name_template: "checksums.txt"
|
|
||||||
algorithm: sha256
|
|
||||||
|
|
||||||
snapshot:
|
|
||||||
version_template: "{{ incpatch .Version }}-next"
|
|
||||||
|
|
||||||
changelog:
|
|
||||||
sort: asc
|
|
||||||
use: git
|
|
||||||
filters:
|
|
||||||
exclude:
|
|
||||||
- "^docs:"
|
|
||||||
- "^test:"
|
|
||||||
- "^chore:"
|
|
||||||
- "^ci:"
|
|
||||||
- "Merge pull request"
|
|
||||||
- "Merge branch"
|
|
||||||
|
|
||||||
gitea_urls:
|
|
||||||
api: https://forgejo.zerova.net/api/v1
|
|
||||||
download: https://forgejo.zerova.net
|
|
||||||
|
|
||||||
release:
|
|
||||||
draft: false
|
|
||||||
prerelease: auto
|
|
||||||
mode: replace
|
|
||||||
header: |
|
|
||||||
## fgj {{ .Tag }}
|
|
||||||
|
|
||||||
Install with `go install forgejo.zerova.net/public/fgj-sid@{{ .Tag }}` or download a prebuilt binary below.
|
|
||||||
footer: |
|
|
||||||
**Full Changelog**: https://forgejo.zerova.net/public/fgj-sid/compare/{{ .PreviousTag }}...{{ .Tag }}
|
|
||||||
361
CHANGELOG.md
361
CHANGELOG.md
|
|
@ -5,180 +5,151 @@ All notable changes to this project will be documented in this file.
|
||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
## [Unreleased] — 0.4.0
|
## [0.4.0] - 2026-05-02
|
||||||
|
|
||||||
### Added — Repository Management
|
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.
|
||||||
|
|
||||||
- `fgj branch {list,rename,delete}` — list branches with protection
|
### BREAKING
|
||||||
status, rename branches, delete (protected branches are refused).
|
|
||||||
- `fgj branch {protect,unprotect}` — create, replace, or remove branch
|
|
||||||
protection rules with `--require-approvals`,
|
|
||||||
`--dismiss-stale-approvals`, `--require-signed-commits`,
|
|
||||||
`--block-on-rejected-reviews`, `--block-on-outdated-branch`,
|
|
||||||
`--push-whitelist`, `--merge-whitelist`, `--require-status-checks`.
|
|
||||||
`protect` is idempotent (create-or-edit).
|
|
||||||
- `fgj repo delete` — type-to-confirm deletion; `--yes` for scripts.
|
|
||||||
- `fgj repo search` — search repositories on the current host by
|
|
||||||
query, topic, or description; filter by `--type`, `--owner`,
|
|
||||||
`--private`, `--archived`.
|
|
||||||
- `fgj repo migrate` — import from GitHub, GitLab, Gitea, Gogs, or
|
|
||||||
a plain Git remote; supports mirror mode and selective import of
|
|
||||||
wiki/labels/milestones/issues/PRs/releases/LFS.
|
|
||||||
- `fgj repo create-from-template` — scaffold a repo from a template,
|
|
||||||
with fine-grained control over what to copy (content, topics,
|
|
||||||
labels, webhooks, git hooks, avatar).
|
|
||||||
|
|
||||||
### Added — Pull Requests
|
- `--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.
|
||||||
|
|
||||||
- `fgj pr clean <n>` — delete the local branch created by
|
### Added
|
||||||
`fgj pr checkout`. Refuses if the PR is open (use `--force`) or
|
|
||||||
if the branch is currently checked out.
|
|
||||||
- `fgj pr approve <n>` / `fgj pr reject <n>` — shortcuts over
|
|
||||||
`pr review`; `reject` requires a body.
|
|
||||||
- `fgj pr review-comments <n>` — list inline review comments across
|
|
||||||
every review on a PR.
|
|
||||||
- `fgj pr resolve <comment-id>` / `fgj pr unresolve <comment-id>` —
|
|
||||||
mark review threads (un)resolved. Requires Forgejo 8.x+ /
|
|
||||||
Gitea 1.22+ server-side.
|
|
||||||
|
|
||||||
### Added — Notifications & Organizations
|
- `fj api --json` / `--json-fields` / `--jq` — projection and jq filtering
|
||||||
|
for raw API responses. Routes through the same `addJSONFlags` helpers
|
||||||
- `fgj notification list [--all]` / `fgj notification read <id>` —
|
as the other list commands. Closes the inconsistency where `fj api`
|
||||||
list unread (default) or all notifications; mark individual
|
was the only command returning raw JSON without these knobs.
|
||||||
threads read.
|
- `fj api --paginate` — follows RFC 5988 `Link: rel="next"` headers and
|
||||||
- `fgj notification {unread,pin,unpin}` — flip thread state
|
concatenates JSON array pages, gh-compatible. Validates same-origin
|
||||||
(complements `read`). Uses the Gitea `NotifyStatus` enum.
|
before forwarding the bearer token to the next URL.
|
||||||
- `fgj org {list,create,delete}` — list your orgs, create with
|
- `cmd/paginate.go` — generic `paginateGitea[T any]` helper. Applied to
|
||||||
visibility/description, delete with confirmation.
|
`repo list`, `pr list`, `issue list`. Previously only `release list`
|
||||||
- `fgj webhook {list,create,update,delete}` — full CRUD on repo
|
walked pages; the others passed `PageSize: limit` directly to the
|
||||||
webhooks: gitea/slack/discord/etc. hook types, event selection,
|
gitea SDK, which silently caps PageSize at 50, so `--limit > 50` was
|
||||||
content type, secret, branch filter, auth header.
|
truncated without warning.
|
||||||
|
- `CLAUDE.md` — guide for Claude Code sessions: layout, codex review
|
||||||
### Added — Releases, Actions, Milestones, Time
|
pattern, release process, homebrew tap update steps.
|
||||||
|
|
||||||
- `fgj release asset {list,create,delete}` — granular release
|
|
||||||
attachment management. `delete` accepts numeric IDs or filenames.
|
|
||||||
- `fgj actions run delete` — delete a completed workflow run. Refuses
|
|
||||||
non-terminal runs unless `--force`; suggests `actions run cancel`
|
|
||||||
for those.
|
|
||||||
- `fgj milestone issues {add,remove}` — associate or disassociate
|
|
||||||
issues with a milestone. Milestone accepted as title or id.
|
|
||||||
- `fgj time {list,add,delete,reset}` — tracked-time management. Accepts
|
|
||||||
Go duration strings (`30m`, `1h30m`). `list` with no arg shows the
|
|
||||||
authenticated user's times across all repos.
|
|
||||||
|
|
||||||
### Added — Misc
|
|
||||||
|
|
||||||
- `fgj open [number] [--url]` — launch the repo / issue / PR page in
|
|
||||||
the default browser; auto-detects issue-vs-PR; prints URL on
|
|
||||||
non-TTY stdout or with `--url`.
|
|
||||||
- `fgj whoami` — show the authenticated user and host.
|
|
||||||
- `fgj admin user list` — admin-gated user enumeration.
|
|
||||||
- `fgj logins {list,default}` — complement to `fgj auth`. `list` shows
|
|
||||||
all configured hosts in a table, highlighting the default. `default`
|
|
||||||
gets/sets which hostname wins when no other signal is present.
|
|
||||||
- `pr list` / `issue list` gain `--since` and `--before` flags
|
|
||||||
accepting `YYYY-MM-DD`, RFC 3339, `YYYY-MM-DD HH:MM:SS`, or relative
|
|
||||||
deltas (`7d`, `24h`, `2w`, `1m`). Server-side filter for issues,
|
|
||||||
client-side for PRs (SDK lacks a PR-side filter).
|
|
||||||
- `fgj label update` added as an alias for `fgj label edit`.
|
|
||||||
- `fgj repo {archive,unarchive}` — toggle a repository's archived state
|
|
||||||
via `EditRepo`. Archiving prompts for confirmation (requires `--yes`
|
|
||||||
in non-TTY environments); unarchiving is reversible and skips the
|
|
||||||
prompt.
|
|
||||||
- `fgj completion install [shell]` — idempotently writes the
|
|
||||||
completion script to the shell-standard location (XDG for bash,
|
|
||||||
`~/.zsh/completions/_fgj` for zsh, `~/.config/fish/completions/fgj.fish`
|
|
||||||
for fish; brew prefix on macOS when present). Supports `--dry-run`
|
|
||||||
and `--system` (bash only, prints the required sudo command).
|
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- `fgj actions secret create` stdin handling reworked. Adds `--body`
|
- `--json` flag rebuilt as a plain `Bool`. `--json-fields` keeps
|
||||||
(inline) and `--body-file` (path or `-` for stdin) flags; interactive
|
comma-separated projection. Both registered via `addJSONFlags` and
|
||||||
prompts now use hidden input via `term.ReadPassword`; piped stdin is
|
marked `MutuallyExclusive`.
|
||||||
read whole (prior `fmt.Scanln` broke on spaces/newlines and echoed
|
- `cmd/actions.go` — `run` and `workflow` subtrees converted from
|
||||||
the typed value). Empty values are rejected.
|
package-level `var`s to factory functions (`newRunCmd`,
|
||||||
- `HostConfig` gains an optional `default: true` field. When no other
|
`newWorkflowCmd`, ...). `cmd/aliases.go` shrank from 142 → 17 lines
|
||||||
signal selects a host (flag, `FGJ_HOST`, git remote, `match_dirs`),
|
and now calls those same factories with a `parentLabel` parameter that
|
||||||
the host marked default wins before the `codeberg.org` fallback.
|
disambiguates the alias variant. Result: `diff` of `fj run list
|
||||||
Multiple `default: true` entries are tolerated with a stderr
|
--help` flags vs `fj actions run list --help` flags is now empty.
|
||||||
warning; alphabetical-first wins.
|
Drift between the two paths is structurally impossible.
|
||||||
- Gitea SDK bumped `v0.22.1` → `v0.23.2` (last release compatible with
|
- `fj api` now uses `internal/api.SharedHTTPClient` (30s timeout, pooled
|
||||||
Go 1.24; `v0.24+` requires Go 1.26).
|
connections) instead of a zero-value `&http.Client{}` with no timeout.
|
||||||
|
A hung Forgejo no longer pins the CLI indefinitely.
|
||||||
### Development
|
- `fj api` response body bounded by `io.LimitReader` at 64 MB to prevent
|
||||||
|
OOM-on-self.
|
||||||
- Switched to standard semver tags (`v0.3.1`, `v0.4.0`, …); retired
|
- `cmd/auth.go` removed redundant local `--hostname` declarations on
|
||||||
letter-suffix scheme (`v0.3.0a`…`v0.3.0f`) which Go's module resolver
|
three subcommands. The persistent flag on rootCmd is now the only
|
||||||
ignored, leaving `go install @latest` pointing at the pre-migration
|
declaration; previously local declarations shadowed it, so
|
||||||
`v0.3.0` tag.
|
`fj --hostname=X auth login` and `fj auth login --hostname=X` went
|
||||||
- Version string is now injected at build time via `-ldflags`; the
|
through different code paths.
|
||||||
hardcoded constant in `cmd/root.go` has been replaced with a
|
- `--token` on `auth login` emits a stderr warning when used (visible
|
||||||
`var version = "dev"` fallback. `make build` derives the version from
|
in `ps auxe` and shell history). Flag not removed; just discoverable.
|
||||||
`git describe --tags --always --dirty`.
|
- Error handling: `Hint` is now a structured field on `CLIError`.
|
||||||
- Added `.goreleaser.yaml` for multi-platform release builds
|
JSON-error consumers get clean structure; the human renderer still
|
||||||
(linux/darwin/windows/freebsd × amd64/arm64/arm) with SHA256
|
appends `\nHint: ...`. Dropped substring matching of `"401"`/`"403"`
|
||||||
checksums and auto-generated release notes.
|
against rendered error strings (would match issue #403); now relies
|
||||||
- Added `.gitea/workflows/release.yml` that publishes release artifacts
|
exclusively on typed `*api.APIError`.
|
||||||
to the Forgejo release page on tag push.
|
- Network errors (`no such host`, `connection refused`, `i/o timeout`)
|
||||||
- Aligned CI Go version (`1.24`) with `go.mod`; previously CI ran on
|
return a structured `CLIError` with code `ErrNetworkError` and a hint.
|
||||||
`1.21` while `go.mod` required `1.24`.
|
- Config dir created with mode 0700 instead of 0755.
|
||||||
|
|
||||||
## [0.3.1] - 2026-04-19
|
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- `go install forgejo.zerova.net/public/fgj-sid@latest` now resolves
|
- `--config <path>` now actually honored. Previously fed only into
|
||||||
correctly. Previous releases used letter-suffix tags (`v0.3.0a`–`f`)
|
Viper; every command that touched config went through
|
||||||
which are not valid Go module versions and were ignored by the
|
`internal/config.Load()` / `Save()` which always read the default
|
||||||
module resolver, leaving `@latest` pinned to `v0.3.0` — a commit
|
path. So `fj --config other.yaml auth login` writes to other.yaml now.
|
||||||
that predates the module-path migration from `codeberg.org/romaintb/fgj`.
|
- `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
|
## [0.3.0c] - 2026-03-21
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
#### Label Management
|
#### Label Management
|
||||||
- `fgj label list` - List repository labels
|
- `fj label list` - List repository labels
|
||||||
- `fgj label create` - Create a label with color and description
|
- `fj label create` - Create a label with color and description
|
||||||
- `fgj label edit` - Edit label name, color, or description
|
- `fj label edit` - Edit label name, color, or description
|
||||||
- `fgj label delete` - Delete a label
|
- `fj label delete` - Delete a label
|
||||||
|
|
||||||
#### Milestone Management
|
#### Milestone Management
|
||||||
- `fgj milestone list` - List milestones with state filtering
|
- `fj milestone list` - List milestones with state filtering
|
||||||
- `fgj milestone view` - View milestone details
|
- `fj milestone view` - View milestone details
|
||||||
- `fgj milestone create` - Create a milestone with description and due date
|
- `fj milestone create` - Create a milestone with description and due date
|
||||||
- `fgj milestone edit` - Edit milestone title, description, due date, or state
|
- `fj milestone edit` - Edit milestone title, description, due date, or state
|
||||||
- `fgj milestone delete` - Delete a milestone
|
- `fj milestone delete` - Delete a milestone
|
||||||
|
|
||||||
#### Wiki Management
|
#### Wiki Management
|
||||||
- `fgj wiki list` - List wiki pages
|
- `fj wiki list` - List wiki pages
|
||||||
- `fgj wiki view` - View wiki page content
|
- `fj wiki view` - View wiki page content
|
||||||
- `fgj wiki create` - Create a wiki page from flag or file
|
- `fj wiki create` - Create a wiki page from flag or file
|
||||||
- `fgj wiki edit` - Edit a wiki page
|
- `fj wiki edit` - Edit a wiki page
|
||||||
- `fgj wiki delete` - Delete a wiki page
|
- `fj wiki delete` - Delete a wiki page
|
||||||
|
|
||||||
#### Issue Dependencies
|
#### Issue Dependencies
|
||||||
- `fgj issue edit --add-dependency <number>` - Add issue dependency
|
- `fj issue edit --add-dependency <number>` - Add issue dependency
|
||||||
- `fgj issue edit --remove-dependency <number>` - Remove issue dependency
|
- `fj issue edit --remove-dependency <number>` - Remove issue dependency
|
||||||
|
|
||||||
## [0.3.0b] - 2026-03-21
|
## [0.3.0b] - 2026-03-21
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
#### Repository Management
|
#### Repository Management
|
||||||
- `fgj repo edit` - Edit repository settings (visibility, description, homepage, default branch)
|
- `fj repo edit` - Edit repository settings (visibility, description, homepage, default branch)
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
- `fgj repo create --public` flag was defined but never read; now properly wired up
|
- `fj repo create --public` flag was defined but never read; now properly wired up
|
||||||
|
|
||||||
## [0.3.0a] - 2026-03-21
|
## [0.3.0a] - 2026-03-21
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
#### Raw API Access
|
#### Raw API Access
|
||||||
- `fgj api <endpoint>` - Make authenticated REST API requests to any Forgejo/Gitea endpoint
|
- `fj api <endpoint>` - Make authenticated REST API requests to any Forgejo/Gitea endpoint
|
||||||
- HTTP method selection (`--method`/`-X`), auto-switches to POST when fields are provided
|
- HTTP method selection (`--method`/`-X`), auto-switches to POST when fields are provided
|
||||||
- JSON field assembly (`--field`/`-f`) with type inference (bool, int, float, null, string)
|
- JSON field assembly (`--field`/`-f`) with type inference (bool, int, float, null, string)
|
||||||
- Raw string fields (`--raw-field`/`-F`)
|
- Raw string fields (`--raw-field`/`-F`)
|
||||||
|
|
@ -188,14 +159,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||||
- Response header display (`--include`/`-i`)
|
- Response header display (`--include`/`-i`)
|
||||||
|
|
||||||
#### Pull Request Management
|
#### Pull Request Management
|
||||||
- `fgj pr diff <number>` - View the diff for a pull request
|
- `fj pr diff <number>` - View the diff for a pull request
|
||||||
- Colorized output (`--color auto/always/never`)
|
- Colorized output (`--color auto/always/never`)
|
||||||
- Changed file names only (`--name-only`)
|
- Changed file names only (`--name-only`)
|
||||||
- Diffstat summary (`--stat`)
|
- Diffstat summary (`--stat`)
|
||||||
- `fgj pr comment <number>` - Add a comment to a pull request
|
- `fj pr comment <number>` - Add a comment to a pull request
|
||||||
- Body from flag (`--body`/`-b`) or file (`--body-file`, `-` for stdin)
|
- Body from flag (`--body`/`-b`) or file (`--body-file`, `-` for stdin)
|
||||||
- JSON output (`--json`)
|
- JSON output (`--json`)
|
||||||
- `fgj pr review <number>` - Submit a review on a pull request
|
- `fj pr review <number>` - Submit a review on a pull request
|
||||||
- Approve (`--approve`/`-a`), request changes (`--request-changes`/`-r`), or comment (`--comment`/`-c`)
|
- Approve (`--approve`/`-a`), request changes (`--request-changes`/`-r`), or comment (`--comment`/`-c`)
|
||||||
- Body from flag or file
|
- Body from flag or file
|
||||||
- JSON output (`--json`)
|
- JSON output (`--json`)
|
||||||
|
|
@ -211,30 +182,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
#### Forgejo Actions
|
#### Forgejo Actions
|
||||||
- `fgj actions run watch <run-id>` - Poll a run until completion
|
- `fj actions run watch <run-id>` - Poll a run until completion
|
||||||
- `fgj actions run rerun <run-id>` - Trigger a rerun of a workflow run
|
- `fj actions run rerun <run-id>` - Trigger a rerun of a workflow run
|
||||||
- `fgj actions run cancel <run-id>` - Cancel an in-progress workflow run
|
- `fj actions run cancel <run-id>` - Cancel an in-progress workflow run
|
||||||
- `fgj actions workflow enable <workflow>` - Enable a workflow
|
- `fj actions workflow enable <workflow>` - Enable a workflow
|
||||||
- `fgj actions workflow disable <workflow>` - Disable a workflow
|
- `fj actions workflow disable <workflow>` - Disable a workflow
|
||||||
|
|
||||||
#### Repository Management
|
#### Repository Management
|
||||||
- `fgj repo create <name>` - Create a new repository with full option set: `--private`/`--public`, `--description`, `--add-readme`, `--gitignore`, `--license`, `--homepage`, `--clone`, `--team`
|
- `fj repo create <name>` - Create a new repository with full option set: `--private`/`--public`, `--description`, `--add-readme`, `--gitignore`, `--license`, `--homepage`, `--clone`, `--team`
|
||||||
|
|
||||||
#### Issue Management
|
#### Issue Management
|
||||||
- `fgj issue create -l <label>` - Assign labels when creating an issue
|
- `fj issue create -l <label>` - Assign labels when creating an issue
|
||||||
- `fgj issue edit --add-label` / `--remove-label` - Add or remove labels on existing issues
|
- `fj issue edit --add-label` / `--remove-label` - Add or remove labels on existing issues
|
||||||
- `fgj issue close -c <comment>` - Close an issue with an optional comment
|
- `fj issue close -c <comment>` - Close an issue with an optional comment
|
||||||
|
|
||||||
#### Workflow Management
|
#### Workflow Management
|
||||||
- `fgj actions workflow list/view/run` - List, view, and trigger workflows
|
- `fj actions workflow list/view/run` - List, view, and trigger workflows
|
||||||
|
|
||||||
#### Auth Helpers
|
#### Auth Helpers
|
||||||
- `fgj auth token` - Print the stored token for the current host
|
- `fj auth token` - Print the stored token for the current host
|
||||||
- `fgj auth logout` - Remove authentication for a host
|
- `fj auth logout` - Remove authentication for a host
|
||||||
|
|
||||||
#### Shell Completions and Man Pages
|
#### Shell Completions and Man Pages
|
||||||
- `fgj completion [bash|zsh|fish|powershell]` - Generate shell completion scripts
|
- `fj completion [bash|zsh|fish|powershell]` - Generate shell completion scripts
|
||||||
- `fgj manpages --dir <path>` - Generate man pages for all commands
|
- `fj manpages --dir <path>` - Generate man pages for all commands
|
||||||
|
|
||||||
#### JSON Output
|
#### JSON Output
|
||||||
- `--json` flag for all list and view commands: PRs, issues, releases, workflow runs, workflows
|
- `--json` flag for all list and view commands: PRs, issues, releases, workflow runs, workflows
|
||||||
|
|
@ -247,17 +218,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
#### Release Management
|
#### Release Management
|
||||||
- `fgj release list` - List releases for a repository
|
- `fj release list` - List releases for a repository
|
||||||
- `fgj release view` - View details of a specific release (supports "latest" keyword)
|
- `fj release view` - View details of a specific release (supports "latest" keyword)
|
||||||
- `fgj release create` - Create new releases with optional asset uploads
|
- `fj release create` - Create new releases with optional asset uploads
|
||||||
- `fgj release upload` - Upload assets to existing releases with optional clobber support
|
- `fj release upload` - Upload assets to existing releases with optional clobber support
|
||||||
- `fgj release delete` - Delete releases (preserves Git tags)
|
- `fj release delete` - Delete releases (preserves Git tags)
|
||||||
|
|
||||||
#### Issue Management
|
#### Issue Management
|
||||||
- `fgj issue edit` - Edit existing issues with support for updating title, body, and labels
|
- `fj issue edit` - Edit existing issues with support for updating title, body, and labels
|
||||||
|
|
||||||
#### Pull Request Management
|
#### Pull Request Management
|
||||||
- `fgj pr create --assignee` - Assign users when creating pull requests
|
- `fj pr create --assignee` - Assign users when creating pull requests
|
||||||
|
|
||||||
#### Repository Detection
|
#### Repository Detection
|
||||||
- Automatic hostname detection from git remote URLs
|
- Automatic hostname detection from git remote URLs
|
||||||
|
|
@ -278,48 +249,48 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
#### Core Features
|
#### Core Features
|
||||||
- Initial release of fgj - Forgejo CLI tool
|
- Initial release of fj - Forgejo CLI tool
|
||||||
- Multi-instance support for any Forgejo/Gitea instance
|
- Multi-instance support for any Forgejo/Gitea instance
|
||||||
- Automatic repository detection from git context (optional `-R` flag)
|
- Automatic repository detection from git context (optional `-R` flag)
|
||||||
- Secure authentication with personal access tokens
|
- Secure authentication with personal access tokens
|
||||||
- Configuration management via `~/.config/fgj/config.yaml`
|
- Configuration management via `~/.config/fj/config.yaml`
|
||||||
|
|
||||||
#### Pull Request Management
|
#### Pull Request Management
|
||||||
- `fgj pr list` - List pull requests with filtering by state
|
- `fj pr list` - List pull requests with filtering by state
|
||||||
- `fgj pr view` - View detailed pull request information
|
- `fj pr view` - View detailed pull request information
|
||||||
- `fgj pr create` - Create new pull requests
|
- `fj pr create` - Create new pull requests
|
||||||
- `fgj pr merge` - Merge pull requests with configurable merge methods
|
- `fj pr merge` - Merge pull requests with configurable merge methods
|
||||||
|
|
||||||
#### Issue Management
|
#### Issue Management
|
||||||
- `fgj issue list` - List issues with state filtering
|
- `fj issue list` - List issues with state filtering
|
||||||
- `fgj issue view` - View detailed issue information
|
- `fj issue view` - View detailed issue information
|
||||||
- `fgj issue create` - Create new issues
|
- `fj issue create` - Create new issues
|
||||||
- `fgj issue comment` - Add comments to issues
|
- `fj issue comment` - Add comments to issues
|
||||||
- `fgj issue close` - Close issues
|
- `fj issue close` - Close issues
|
||||||
|
|
||||||
#### Repository Operations
|
#### Repository Operations
|
||||||
- `fgj repo view` - View repository details
|
- `fj repo view` - View repository details
|
||||||
- `fgj repo list` - List user repositories
|
- `fj repo list` - List user repositories
|
||||||
- `fgj repo clone` - Clone repositories with protocol selection (HTTPS/SSH)
|
- `fj repo clone` - Clone repositories with protocol selection (HTTPS/SSH)
|
||||||
- `fgj repo fork` - Fork repositories
|
- `fj repo fork` - Fork repositories
|
||||||
|
|
||||||
#### Forgejo Actions Support
|
#### Forgejo Actions Support
|
||||||
- `fgj actions run list` - List workflow runs with status and metadata
|
- `fj actions run list` - List workflow runs with status and metadata
|
||||||
- `fgj actions run view` - View detailed run information, jobs, and logs
|
- `fj actions run view` - View detailed run information, jobs, and logs
|
||||||
- Support for `--verbose`, `--log`, `--log-failed`, and `--job` flags
|
- Support for `--verbose`, `--log`, `--log-failed`, and `--job` flags
|
||||||
- `fgj actions secret list` - List repository secrets
|
- `fj actions secret list` - List repository secrets
|
||||||
- `fgj actions secret create` - Create repository secrets
|
- `fj actions secret create` - Create repository secrets
|
||||||
- `fgj actions secret delete` - Delete repository secrets
|
- `fj actions secret delete` - Delete repository secrets
|
||||||
- `fgj actions variable list` - List repository variables
|
- `fj actions variable list` - List repository variables
|
||||||
- `fgj actions variable get` - Get variable values
|
- `fj actions variable get` - Get variable values
|
||||||
- `fgj actions variable create` - Create repository variables
|
- `fj actions variable create` - Create repository variables
|
||||||
- `fgj actions variable update` - Update repository variables
|
- `fj actions variable update` - Update repository variables
|
||||||
- `fgj actions variable delete` - Delete repository variables
|
- `fj actions variable delete` - Delete repository variables
|
||||||
|
|
||||||
#### Authentication
|
#### Authentication
|
||||||
- `fgj auth login` - Interactive authentication with Forgejo instances
|
- `fj auth login` - Interactive authentication with Forgejo instances
|
||||||
- `fgj auth status` - Check authentication status
|
- `fj auth status` - Check authentication status
|
||||||
- Environment variable support (`FGJ_HOST`, `FGJ_TOKEN`)
|
- Environment variable support (`FJ_HOST`, `FJ_TOKEN`)
|
||||||
|
|
||||||
#### Development
|
#### Development
|
||||||
- Comprehensive unit test suite
|
- Comprehensive unit test suite
|
||||||
|
|
@ -333,9 +304,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||||
- Cobra framework for CLI structure
|
- Cobra framework for CLI structure
|
||||||
- Viper for configuration management
|
- Viper for configuration management
|
||||||
|
|
||||||
[0.3.0c]: https://forgejo.zerova.net/public/fgj-sid/releases/tag/v0.3.0c
|
[0.3.0c]: https://forgejo.zerova.net/public/fj/releases/tag/v0.3.0c
|
||||||
[0.3.0b]: https://forgejo.zerova.net/public/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/public/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
Normal file
166
CLAUDE.md
Normal file
|
|
@ -0,0 +1,166 @@
|
||||||
|
# fj — guide for Claude Code sessions
|
||||||
|
|
||||||
|
`fj` is a personal Forgejo/Gitea CLI tool, modeled on GitHub's `gh`. It targets `forgejo.zerova.net` (and Codeberg). The user (sid) owns it; the canonical repo is `public/fj` on forgejo.zerova.net (mirrored from there to nowhere else).
|
||||||
|
|
||||||
|
This file is read first by Claude Code when working in `~/repos/fj`. Goal: get a session productive quickly without re-deriving the dev workflow each time.
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
~/repos/fj/
|
||||||
|
├── cmd/ cobra command definitions, one file per subject area
|
||||||
|
│ ├── root.go rootCmd, --config plumbing, OnInitialize
|
||||||
|
│ ├── auth.go login/status/logout/token (uses persistent --hostname)
|
||||||
|
│ ├── api.go raw API access; --json/--json-fields/--jq/--paginate
|
||||||
|
│ ├── json.go shared JSON output helpers (addJSONFlags/wantJSON/outputJSON)
|
||||||
|
│ ├── paginate.go generic paginateGitea[T] helper for list commands
|
||||||
|
│ ├── errors.go CLIError with structured Hint field
|
||||||
|
│ ├── actions.go Forgejo Actions; runs/workflows via factory functions
|
||||||
|
│ ├── aliases.go top-level `fj run` / `fj workflow` aliases — calls actions.go factories
|
||||||
|
│ ├── repo.go pr.go issue.go release.go wiki.go label.go milestone.go
|
||||||
|
│ └── ...
|
||||||
|
├── internal/
|
||||||
|
│ ├── api/client.go SharedHTTPClient (30s timeout); GetJSON/DoJSON/DownloadFile
|
||||||
|
│ ├── config/config.go YAML config; honors --config via SetExplicitConfigPath
|
||||||
|
│ ├── git/ repo + host detection from `git remote`
|
||||||
|
│ ├── iostreams/ wrapped stdin/stdout/stderr + spinner + pager + colors
|
||||||
|
│ └── text/ formatting helpers
|
||||||
|
├── main.go thin entrypoint; ContextualError + JSON-error rendering
|
||||||
|
├── Makefile build / lint / test (no release automation)
|
||||||
|
├── CHANGELOG.md Keep-a-Changelog format
|
||||||
|
└── README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## Build, install, test
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go build ./... # quick build check
|
||||||
|
go test ./... # unit tests
|
||||||
|
go install . # build + install to ~/go/bin/fj (the binary that's on PATH)
|
||||||
|
make lint # golangci-lint, if you have it
|
||||||
|
```
|
||||||
|
|
||||||
|
After any change in cmd/ or internal/, run `go install .` and the global `fj` reflects it immediately. There's no daemon/restart.
|
||||||
|
|
||||||
|
## Auth
|
||||||
|
|
||||||
|
The user is authenticated as `sid` on `forgejo.zerova.net`. Token lives in `~/.config/fj/config.yaml` (mode 0600). For HTTPS git pushes from this host, the token can be injected via `git -c "http.extraHeader=Authorization: token <T>" push` — the local SSH key (`sid@debian` on forgejo) is also registered, so `git@forgejo.zerova.net:public/fj.git` works directly.
|
||||||
|
|
||||||
|
## Code review pattern (use this for non-trivial changes)
|
||||||
|
|
||||||
|
For audits or significant refactors, run **three reviewers in parallel** with non-overlapping focuses (we did this in the v0.4.0 cycle and it found bugs none would have caught alone):
|
||||||
|
|
||||||
|
- **Codex** — read-only sandbox, peer-AI cross-check
|
||||||
|
```bash
|
||||||
|
codex exec --skip-git-repo-check --sandbox read-only \
|
||||||
|
-m gpt-5.4-mini --config model_reasoning_effort="medium" "<prompt>" 2>/dev/null
|
||||||
|
```
|
||||||
|
For follow-up rounds resume the same session: `echo "<prompt>" | codex exec --skip-git-repo-check resume --last 2>/dev/null`. Codex remembers prior critique.
|
||||||
|
|
||||||
|
- **Claude general-purpose agent A** — architecture / UX / code-quality
|
||||||
|
- **Claude general-purpose agent B** — security / correctness / error handling
|
||||||
|
|
||||||
|
Tell each reviewer what the **siblings** are covering so they don't duplicate. Cap reports at ~600 words. Consolidate findings by severity (HIGH / MEDIUM / LOW) before presenting to the user.
|
||||||
|
|
||||||
|
## Release process
|
||||||
|
|
||||||
|
We use semver. **Pre-1.0**: breaking change → minor bump (e.g. v0.3.x → v0.4.0).
|
||||||
|
|
||||||
|
1. **Bump version**
|
||||||
|
```go
|
||||||
|
// cmd/root.go
|
||||||
|
Version: "0.4.0",
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Update CHANGELOG.md** — prepend a new section. Format:
|
||||||
|
```markdown
|
||||||
|
## [0.4.0] - YYYY-MM-DD
|
||||||
|
|
||||||
|
### BREAKING
|
||||||
|
- <thing that broke>
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- ...
|
||||||
|
### Changed
|
||||||
|
- ...
|
||||||
|
### Fixed
|
||||||
|
- ...
|
||||||
|
### Security
|
||||||
|
- ...
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Commit** the version+changelog bump as a single commit:
|
||||||
|
```bash
|
||||||
|
git commit -m "chore: bump version to 0.4.0"
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Tag** the commit:
|
||||||
|
```bash
|
||||||
|
git tag -a v0.4.0 -m "Release v0.4.0: <one-line summary>"
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **Push** commits and tag:
|
||||||
|
```bash
|
||||||
|
git push origin main
|
||||||
|
git push origin v0.4.0
|
||||||
|
```
|
||||||
|
|
||||||
|
6. **Create the Forgejo release page** via fj itself:
|
||||||
|
```bash
|
||||||
|
fj release create v0.4.0 \
|
||||||
|
--title "v0.4.0: <summary>" \
|
||||||
|
--notes "$(awk '/^## \[0.4.0\]/{flag=1;next} /^## /{flag=0} flag' CHANGELOG.md)"
|
||||||
|
```
|
||||||
|
(The awk one-liner extracts the just-added CHANGELOG section as release notes.)
|
||||||
|
|
||||||
|
7. **Update the homebrew tap** — see the next section.
|
||||||
|
|
||||||
|
## Updating the homebrew tap (`public/homebrew-sid`)
|
||||||
|
|
||||||
|
The tap lives at `~/repos/homebrew-sid` (or `git@forgejo.zerova.net:public/homebrew-sid.git`). The `Formula/fj.rb` formula references the source by `tag:` + `revision:` (SHA), so a release bump touches three lines:
|
||||||
|
|
||||||
|
```ruby
|
||||||
|
url "ssh://git@forgejo.zerova.net/public/fj.git",
|
||||||
|
tag: "v0.4.0", # was v0.3.2
|
||||||
|
revision: "<SHA of v0.4.0 tag>" # update
|
||||||
|
|
||||||
|
test do
|
||||||
|
assert_match "0.4.0", shell_output("#{bin}/fj --version") # update
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
To get the SHA:
|
||||||
|
```bash
|
||||||
|
git -C ~/repos/fj rev-parse v0.4.0
|
||||||
|
```
|
||||||
|
|
||||||
|
Then in `~/repos/homebrew-sid`:
|
||||||
|
```bash
|
||||||
|
# edit Formula/fj.rb (the three lines above)
|
||||||
|
git commit -am "fj: bump to v0.4.0"
|
||||||
|
git push origin main
|
||||||
|
```
|
||||||
|
|
||||||
|
After push, users can `brew update && brew upgrade fj` to pick up the new version.
|
||||||
|
|
||||||
|
## Common footguns
|
||||||
|
|
||||||
|
- **`fj` reads the current dir's `git origin`** to detect the host. In a directory whose origin points at github.com (e.g. /opt/stacks/claude-code-proxy/build), bare `fj api ...` errors with "no configuration found for host github.com". Pass `--hostname forgejo.zerova.net` explicitly, or `cd` somewhere else.
|
||||||
|
- **`--json=fields` was removed in v0.4.0** in favor of `--json-fields fields` (or `--json-fields=fields`). The old `=fields` form was a `NoOptDefVal=" "` sentinel hack. `--json` is now a plain Bool meaning "as JSON".
|
||||||
|
- **`--config` was silently ignored before v0.4.0.** Old fj versions read --config into Viper but `internal/config.Load()` always read the default path. Fixed; `fj --config other.yaml auth login` now writes to other.yaml.
|
||||||
|
- **The `actions` and `run`/`workflow` command trees share factory functions** in `cmd/actions.go` (`newRunCmd`, `newWorkflowCmd`). Don't add flags directly to `runListCmd` style globals — they don't exist anymore. Edit the factory and both `fj actions run list` and `fj run list` get the change.
|
||||||
|
|
||||||
|
## Useful commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Live test against forgejo (using the new flags)
|
||||||
|
fj --hostname forgejo.zerova.net api repos/public/fj --json-fields full_name,description
|
||||||
|
|
||||||
|
# Walk paginated endpoints
|
||||||
|
fj --hostname forgejo.zerova.net api 'repos/public/fj/commits?limit=10' --paginate --jq '.[].sha[0:8]'
|
||||||
|
|
||||||
|
# Confirm both command trees stay in sync after edits
|
||||||
|
diff <(fj run list --help | grep -E "^ -|^ --" | sort) \
|
||||||
|
<(fj actions run list --help | grep -E "^ -|^ --" | sort)
|
||||||
|
# Empty diff = trees agree. Any output = factory drift.
|
||||||
|
```
|
||||||
22
Makefile
22
Makefile
|
|
@ -1,26 +1,20 @@
|
||||||
.PHONY: help build run test clean lint lint-fix install release-snapshot release-check
|
.PHONY: help build run test clean lint lint-fix install
|
||||||
|
|
||||||
# Version derived from git tags; falls back to short SHA; appends -dirty if tree is modified.
|
|
||||||
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
|
|
||||||
LDFLAGS := -X 'forgejo.zerova.net/public/fgj-sid/cmd.version=$(VERSION)'
|
|
||||||
|
|
||||||
help:
|
help:
|
||||||
@echo "Available commands:"
|
@echo "Available commands:"
|
||||||
@echo " make build - Build the application (version: $(VERSION))"
|
@echo " make build - Build the application"
|
||||||
@echo " make install - Install the binary to /usr/bin"
|
@echo " make install - Install the binary to /usr/bin"
|
||||||
@echo " make run - Run the application"
|
@echo " make run - Run the application"
|
||||||
@echo " make test - Run tests"
|
@echo " make test - Run tests"
|
||||||
@echo " make lint - Run golangci-lint"
|
@echo " make lint - Run golangci-lint"
|
||||||
@echo " make lint-fix - Run golangci-lint with auto-fix"
|
@echo " make lint-fix - Run golangci-lint with auto-fix"
|
||||||
@echo " make clean - Clean build artifacts"
|
@echo " make clean - Clean build artifacts"
|
||||||
@echo " make release-snapshot - Build snapshot release artifacts via goreleaser"
|
|
||||||
@echo " make release-check - Validate .goreleaser.yaml"
|
|
||||||
|
|
||||||
build:
|
build:
|
||||||
go build -ldflags "$(LDFLAGS)" -o bin/fgj .
|
go build -o bin/fj .
|
||||||
|
|
||||||
install: build
|
install: build
|
||||||
install -Dm755 bin/fgj /usr/bin/fgj
|
install -Dm755 bin/fj /usr/bin/fj
|
||||||
|
|
||||||
run:
|
run:
|
||||||
go run .
|
go run .
|
||||||
|
|
@ -35,11 +29,5 @@ lint-fix:
|
||||||
golangci-lint run --fix ./...
|
golangci-lint run --fix ./...
|
||||||
|
|
||||||
clean:
|
clean:
|
||||||
rm -rf bin/ dist/
|
rm -rf bin/
|
||||||
go clean
|
go clean
|
||||||
|
|
||||||
release-snapshot:
|
|
||||||
goreleaser release --snapshot --clean --skip=publish
|
|
||||||
|
|
||||||
release-check:
|
|
||||||
goreleaser check
|
|
||||||
|
|
|
||||||
317
README.md
317
README.md
|
|
@ -1,34 +1,29 @@
|
||||||
# fgj - Forgejo/Gitea CLI Tool
|
# fj - Forgejo/Gitea CLI Tool
|
||||||
|
|
||||||
[](https://golang.org)
|
[](https://golang.org)
|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
|
|
||||||
`fgj` is a command-line tool for working with Forgejo and Gitea instances. It brings pull requests, issues, and other forge concepts to the terminal, similar to what `gh` does for GitHub. This fork adds agentic dev features — raw API access, PR review workflows, structured error output, and machine-readable I/O for AI coding agents.
|
`fj` is a command-line tool for working with Forgejo and Gitea instances. It brings pull requests, issues, and other forge concepts to the terminal, similar to what `gh` does for GitHub. This fork adds agentic dev features — raw API access, PR review workflows, structured error output, and machine-readable I/O for AI coding agents.
|
||||||
|
|
||||||
> Forked from [codeberg.org/romaintb/fgj](https://codeberg.org/romaintb/fgj) and hosted at [forgejo.zerova.net/public/fgj-sid](https://forgejo.zerova.net/public/fgj-sid).
|
> Forked from [codeberg.org/romaintb/fj](https://codeberg.org/romaintb/fj) and hosted at [forgejo.zerova.net/public/fj](https://forgejo.zerova.net/public/fj).
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- Multi-instance support (works with any Forgejo or Gitea instance)
|
- Multi-instance support (works with any Forgejo or Gitea instance)
|
||||||
- Pull requests — list, view, create, merge, close, reopen, edit, checkout, clean, diff, comment, review, approve, reject, checks, review-comments, resolve/unresolve
|
- Pull request management (create, list, view, merge, diff, comment, review)
|
||||||
- Issues — create, list, view, comment, close, reopen, edit, labels, dependencies
|
- Issue tracking (create, list, view, comment, close, labels)
|
||||||
- Repositories — view, list, create, edit, clone, fork, rename, delete, search, migrate, create-from-template
|
- Repository operations (view, list, create, edit, clone, fork)
|
||||||
- Branches — list, rename, delete
|
- Label management (list, create, edit, delete)
|
||||||
- Labels / milestones / wiki — full CRUD
|
- Milestone management (list, view, create, edit, delete)
|
||||||
- Organizations — list, create, delete
|
- Wiki page management (list, view, create, edit, delete)
|
||||||
- Webhooks — list, create, update, delete
|
- Issue dependencies (`--add-dependency`, `--remove-dependency`)
|
||||||
- Notifications — list (unread or all), mark read
|
- Forgejo Actions (workflow runs, watch/rerun/cancel, enable/disable, secrets, variables)
|
||||||
- Forgejo Actions — workflow runs, watch/rerun/cancel, workflows enable/disable, secrets, variables
|
- Releases (create, upload, delete)
|
||||||
- Releases — create, upload, delete
|
- Raw API access (`fj api`) for arbitrary REST calls
|
||||||
- Admin — `admin user list` (admin-token only)
|
|
||||||
- `fgj open` — launch a repo / issue / PR in the browser
|
|
||||||
- `fgj whoami` — show the authenticated user on the current host
|
|
||||||
- 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`, `--json-fields`, `--jq`) 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
|
||||||
- Automatic repository and hostname detection from git context
|
- Automatic repository and hostname detection from git context
|
||||||
- Directory-scoped host defaults (`match_dirs`)
|
|
||||||
- Secure authentication with personal access tokens
|
- Secure authentication with personal access tokens
|
||||||
- XDG Base Directory compliant config location
|
- XDG Base Directory compliant config location
|
||||||
- AI coding agent friendly
|
- AI coding agent friendly
|
||||||
|
|
@ -38,22 +33,22 @@
|
||||||
### macOS (Homebrew)
|
### macOS (Homebrew)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
brew tap sid/fgj-sid https://forgejo.zerova.net/sid/homebrew-fgj-sid.git
|
brew tap public/sid git@forgejo.zerova.net:public/homebrew-sid.git
|
||||||
brew install fgj
|
brew install fj
|
||||||
```
|
```
|
||||||
|
|
||||||
### Using Go Install
|
### Using Go Install
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
go install forgejo.zerova.net/public/fgj-sid@latest
|
go install forgejo.zerova.net/public/fj@latest
|
||||||
```
|
```
|
||||||
|
|
||||||
### From Source
|
### From Source
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://forgejo.zerova.net/public/fgj-sid.git
|
git clone https://forgejo.zerova.net/public/fj.git
|
||||||
cd fgj-sid
|
cd fj
|
||||||
go build -o fgj .
|
go build -o fj .
|
||||||
```
|
```
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
@ -63,7 +58,7 @@ go build -o fgj .
|
||||||
First, authenticate with your Forgejo or Gitea instance:
|
First, authenticate with your Forgejo or Gitea instance:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
fgj auth login
|
fj auth login
|
||||||
```
|
```
|
||||||
|
|
||||||
You'll be prompted for:
|
You'll be prompted for:
|
||||||
|
|
@ -79,34 +74,34 @@ To create a personal access token:
|
||||||
### 2. Check Authentication Status
|
### 2. Check Authentication Status
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
fgj auth status
|
fj auth status
|
||||||
```
|
```
|
||||||
|
|
||||||
### Auth Helpers
|
### Auth Helpers
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Print the stored token for the current host
|
# Print the stored token for the current host
|
||||||
fgj auth token
|
fj auth token
|
||||||
|
|
||||||
# Remove authentication for a host
|
# Remove authentication for a host
|
||||||
fgj auth logout
|
fj auth logout
|
||||||
```
|
```
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
### Repository Detection
|
### Repository Detection
|
||||||
|
|
||||||
`fgj` automatically detects the repository from your git context, similar to `gh`:
|
`fj` automatically detects the repository from your git context, similar to `gh`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# When inside a git repository, no -R flag needed!
|
# When inside a git repository, no -R flag needed!
|
||||||
cd /path/to/your/repo
|
cd /path/to/your/repo
|
||||||
fgj pr list # Automatically uses current repo
|
fj pr list # Automatically uses current repo
|
||||||
fgj issue list # Automatically uses current repo
|
fj issue list # Automatically uses current repo
|
||||||
fgj pr view 123 # Automatically uses current repo
|
fj pr view 123 # Automatically uses current repo
|
||||||
|
|
||||||
# Or explicitly specify a repository with -R
|
# Or explicitly specify a repository with -R
|
||||||
fgj pr list -R owner/repo
|
fj pr list -R owner/repo
|
||||||
```
|
```
|
||||||
|
|
||||||
The tool reads `.git/config` to find the origin remote and extract both the owner/repo information and the instance hostname. If you're not in a git repository, you'll need to use the `-R` flag.
|
The tool reads `.git/config` to find the origin remote and extract both the owner/repo information and the instance hostname. If you're not in a git repository, you'll need to use the `-R` flag.
|
||||||
|
|
@ -115,307 +110,307 @@ The tool reads `.git/config` to find the origin remote and extract both the owne
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# List pull requests (auto-detects repo and hostname from git)
|
# List pull requests (auto-detects repo and hostname from git)
|
||||||
fgj pr list
|
fj pr list
|
||||||
|
|
||||||
# Or specify explicitly
|
# Or specify explicitly
|
||||||
fgj pr list -R owner/repo
|
fj pr list -R owner/repo
|
||||||
|
|
||||||
# Filter by state
|
# Filter by state
|
||||||
fgj pr list --state closed
|
fj pr list --state closed
|
||||||
|
|
||||||
# View a specific pull request
|
# View a specific pull request
|
||||||
fgj pr view 123
|
fj pr view 123
|
||||||
|
|
||||||
# Create a pull request
|
# Create a pull request
|
||||||
fgj pr create -t "PR Title" -b "PR Description" -H feature-branch -B main
|
fj pr create -t "PR Title" -b "PR Description" -H feature-branch -B main
|
||||||
|
|
||||||
# Merge a pull request
|
# Merge a pull request
|
||||||
fgj pr merge 123 --merge-method squash
|
fj pr merge 123 --merge-method squash
|
||||||
|
|
||||||
# View PR diff
|
# View PR diff
|
||||||
fgj pr diff 123
|
fj pr diff 123
|
||||||
|
|
||||||
# View diff with color
|
# View diff with color
|
||||||
fgj pr diff 123 --color always
|
fj pr diff 123 --color always
|
||||||
|
|
||||||
# Show only changed file names
|
# Show only changed file names
|
||||||
fgj pr diff 123 --name-only
|
fj pr diff 123 --name-only
|
||||||
|
|
||||||
# Show diffstat summary
|
# Show diffstat summary
|
||||||
fgj pr diff 123 --stat
|
fj pr diff 123 --stat
|
||||||
|
|
||||||
# Comment on a pull request
|
# Comment on a pull request
|
||||||
fgj pr comment 123 -b "Looks good, minor nit on line 42"
|
fj pr comment 123 -b "Looks good, minor nit on line 42"
|
||||||
|
|
||||||
# Comment from a file
|
# Comment from a file
|
||||||
fgj pr comment 123 --body-file review-notes.md
|
fj pr comment 123 --body-file review-notes.md
|
||||||
|
|
||||||
# Approve a pull request
|
# Approve a pull request
|
||||||
fgj pr review 123 --approve -b "LGTM"
|
fj pr review 123 --approve -b "LGTM"
|
||||||
|
|
||||||
# Request changes
|
# Request changes
|
||||||
fgj pr review 123 --request-changes -b "Please fix the error handling"
|
fj pr review 123 --request-changes -b "Please fix the error handling"
|
||||||
|
|
||||||
# Submit a review comment (neither approve nor request changes)
|
# Submit a review comment (neither approve nor request changes)
|
||||||
fgj pr review 123 --comment -b "Some observations"
|
fj pr review 123 --comment -b "Some observations"
|
||||||
```
|
```
|
||||||
|
|
||||||
### Issues
|
### Issues
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# List issues (auto-detects repo and hostname from git)
|
# List issues (auto-detects repo and hostname from git)
|
||||||
fgj issue list
|
fj issue list
|
||||||
|
|
||||||
# Or specify explicitly
|
# Or specify explicitly
|
||||||
fgj issue list -R owner/repo
|
fj issue list -R owner/repo
|
||||||
|
|
||||||
# Filter by state
|
# Filter by state
|
||||||
fgj issue list --state all
|
fj issue list --state all
|
||||||
|
|
||||||
# View an issue
|
# View an issue
|
||||||
fgj issue view 456
|
fj issue view 456
|
||||||
|
|
||||||
# Create an issue
|
# Create an issue
|
||||||
fgj issue create -t "Issue Title" -b "Issue Description"
|
fj issue create -t "Issue Title" -b "Issue Description"
|
||||||
|
|
||||||
# Create an issue with labels
|
# Create an issue with labels
|
||||||
fgj issue create -t "Issue Title" -b "Issue Description" -l bug -l enhancement
|
fj issue create -t "Issue Title" -b "Issue Description" -l bug -l enhancement
|
||||||
|
|
||||||
# Comment on an issue
|
# Comment on an issue
|
||||||
fgj issue comment 456 -b "My comment"
|
fj issue comment 456 -b "My comment"
|
||||||
|
|
||||||
# Close an issue
|
# Close an issue
|
||||||
fgj issue close 456
|
fj issue close 456
|
||||||
|
|
||||||
# Close an issue with a comment
|
# Close an issue with a comment
|
||||||
fgj issue close 456 -c "Fixed in v2.0"
|
fj issue close 456 -c "Fixed in v2.0"
|
||||||
|
|
||||||
# Edit an issue (title, body, state, labels)
|
# Edit an issue (title, body, state, labels)
|
||||||
fgj issue edit 456 -t "New Title"
|
fj issue edit 456 -t "New Title"
|
||||||
fgj issue edit 456 --add-label priority --remove-label bug
|
fj issue edit 456 --add-label priority --remove-label bug
|
||||||
|
|
||||||
# Manage issue dependencies
|
# Manage issue dependencies
|
||||||
fgj issue edit 456 --add-dependency 123
|
fj issue edit 456 --add-dependency 123
|
||||||
fgj issue edit 456 --remove-dependency 123
|
fj issue edit 456 --remove-dependency 123
|
||||||
```
|
```
|
||||||
|
|
||||||
### Labels
|
### Labels
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# List labels
|
# List labels
|
||||||
fgj label list
|
fj label list
|
||||||
|
|
||||||
# Create a label
|
# Create a label
|
||||||
fgj label create bug --color ff0000 -d "Something isn't working"
|
fj label create bug --color ff0000 -d "Something isn't working"
|
||||||
|
|
||||||
# Edit a label
|
# Edit a label
|
||||||
fgj label edit bug --name bugfix --color ee0000
|
fj label edit bug --name bugfix --color ee0000
|
||||||
|
|
||||||
# Delete a label
|
# Delete a label
|
||||||
fgj label delete bug
|
fj label delete bug
|
||||||
```
|
```
|
||||||
|
|
||||||
### Milestones
|
### Milestones
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# List milestones
|
# List milestones
|
||||||
fgj milestone list
|
fj milestone list
|
||||||
fgj milestone list --state all
|
fj milestone list --state all
|
||||||
|
|
||||||
# View a milestone
|
# View a milestone
|
||||||
fgj milestone view "v1.0"
|
fj milestone view "v1.0"
|
||||||
|
|
||||||
# Create a milestone with due date
|
# Create a milestone with due date
|
||||||
fgj milestone create "v2.0" -d "Next major release" --due 2026-06-01
|
fj milestone create "v2.0" -d "Next major release" --due 2026-06-01
|
||||||
|
|
||||||
# Edit a milestone
|
# Edit a milestone
|
||||||
fgj milestone edit "v2.0" --title "v2.0-rc1" --state closed
|
fj milestone edit "v2.0" --title "v2.0-rc1" --state closed
|
||||||
|
|
||||||
# Delete a milestone
|
# Delete a milestone
|
||||||
fgj milestone delete "v2.0"
|
fj milestone delete "v2.0"
|
||||||
```
|
```
|
||||||
|
|
||||||
### Wiki
|
### Wiki
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# List wiki pages
|
# List wiki pages
|
||||||
fgj wiki list
|
fj wiki list
|
||||||
|
|
||||||
# View a wiki page
|
# View a wiki page
|
||||||
fgj wiki view "Home"
|
fj wiki view "Home"
|
||||||
|
|
||||||
# Create a wiki page
|
# Create a wiki page
|
||||||
fgj wiki create "Setup Guide" -b "# Setup\n\nFollow these steps..."
|
fj wiki create "Setup Guide" -b "# Setup\n\nFollow these steps..."
|
||||||
|
|
||||||
# Create from file
|
# Create from file
|
||||||
fgj wiki create "API Docs" --body-file docs/api.md
|
fj wiki create "API Docs" --body-file docs/api.md
|
||||||
|
|
||||||
# Edit a wiki page
|
# Edit a wiki page
|
||||||
fgj wiki edit "Home" -b "Updated content"
|
fj wiki edit "Home" -b "Updated content"
|
||||||
|
|
||||||
# Delete a wiki page
|
# Delete a wiki page
|
||||||
fgj wiki delete "Old Page"
|
fj wiki delete "Old Page"
|
||||||
```
|
```
|
||||||
|
|
||||||
### Repositories
|
### Repositories
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# View repository details
|
# View repository details
|
||||||
fgj repo view owner/repo
|
fj repo view owner/repo
|
||||||
|
|
||||||
# List your repositories
|
# List your repositories
|
||||||
fgj repo list
|
fj repo list
|
||||||
|
|
||||||
# Create a repository
|
# Create a repository
|
||||||
fgj repo create my-repo
|
fj repo create my-repo
|
||||||
fgj repo create my-repo -d "My project" --private --add-readme -g Go -l MIT
|
fj repo create my-repo -d "My project" --private --add-readme -g Go -l MIT
|
||||||
|
|
||||||
# Clone a repository
|
# Clone a repository
|
||||||
fgj repo clone owner/repo
|
fj repo clone owner/repo
|
||||||
|
|
||||||
# Clone via SSH
|
# Clone via SSH
|
||||||
fgj repo clone owner/repo -p ssh
|
fj repo clone owner/repo -p ssh
|
||||||
|
|
||||||
# Fork a repository
|
# Fork a repository
|
||||||
fgj repo fork owner/repo
|
fj repo fork owner/repo
|
||||||
|
|
||||||
# Edit repository settings
|
# Edit repository settings
|
||||||
fgj repo edit owner/repo --public
|
fj repo edit owner/repo --public
|
||||||
fgj repo edit owner/repo --private
|
fj repo edit owner/repo --private
|
||||||
fgj repo edit owner/repo -d "New description" --homepage https://example.com
|
fj repo edit owner/repo -d "New description" --homepage https://example.com
|
||||||
fgj repo edit --default-branch develop
|
fj repo edit --default-branch develop
|
||||||
fgj repo edit owner/repo --name new-name
|
fj repo edit owner/repo --name new-name
|
||||||
|
|
||||||
# Rename a repository (shorthand)
|
# Rename a repository (shorthand)
|
||||||
fgj repo rename new-name
|
fj repo rename new-name
|
||||||
fgj repo rename new-name -R owner/old-name
|
fj repo rename new-name -R owner/old-name
|
||||||
```
|
```
|
||||||
|
|
||||||
### Releases
|
### Releases
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# List releases
|
# List releases
|
||||||
fgj release list
|
fj release list
|
||||||
|
|
||||||
# View a release (or use "latest")
|
# View a release (or use "latest")
|
||||||
fgj release view v1.2.3
|
fj release view v1.2.3
|
||||||
|
|
||||||
# Create a release with notes and optional assets
|
# Create a release with notes and optional assets
|
||||||
fgj release create v1.2.3 -t "v1.2.3" -n "Release notes" ./dist/app.tar.gz
|
fj release create v1.2.3 -t "v1.2.3" -n "Release notes" ./dist/app.tar.gz
|
||||||
|
|
||||||
# Upload assets to an existing release
|
# Upload assets to an existing release
|
||||||
fgj release upload v1.2.3 ./dist/app.tar.gz --clobber
|
fj release upload v1.2.3 ./dist/app.tar.gz --clobber
|
||||||
|
|
||||||
# Delete a release (keeps the Git tag)
|
# Delete a release (keeps the Git tag)
|
||||||
fgj release delete v1.2.3
|
fj release delete v1.2.3
|
||||||
```
|
```
|
||||||
|
|
||||||
### Forgejo Actions
|
### Forgejo Actions
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# List workflows
|
# List workflows
|
||||||
fgj actions workflow list
|
fj actions workflow list
|
||||||
|
|
||||||
# View a workflow
|
# View a workflow
|
||||||
fgj actions workflow view ci.yml
|
fj actions workflow view ci.yml
|
||||||
|
|
||||||
# Run a workflow (trigger workflow_dispatch)
|
# Run a workflow (trigger workflow_dispatch)
|
||||||
fgj actions workflow run deploy.yml
|
fj actions workflow run deploy.yml
|
||||||
|
|
||||||
# Run a workflow with inputs
|
# Run a workflow with inputs
|
||||||
fgj actions workflow run deploy.yml -f environment=production -f version=1.2.3
|
fj actions workflow run deploy.yml -f environment=production -f version=1.2.3
|
||||||
|
|
||||||
# Run a workflow on a specific branch
|
# Run a workflow on a specific branch
|
||||||
fgj actions workflow run deploy.yml -r feature-branch
|
fj actions workflow run deploy.yml -r feature-branch
|
||||||
|
|
||||||
# Enable or disable a workflow
|
# Enable or disable a workflow
|
||||||
fgj actions workflow enable ci.yml
|
fj actions workflow enable ci.yml
|
||||||
fgj actions workflow disable ci.yml
|
fj actions workflow disable ci.yml
|
||||||
|
|
||||||
# List workflow runs
|
# List workflow runs
|
||||||
fgj actions run list
|
fj actions run list
|
||||||
|
|
||||||
# View a specific run
|
# View a specific run
|
||||||
fgj actions run view 123
|
fj actions run view 123
|
||||||
|
|
||||||
# View run with job details
|
# View run with job details
|
||||||
fgj actions run view 123 --verbose
|
fj actions run view 123 --verbose
|
||||||
|
|
||||||
# View run logs
|
# View run logs
|
||||||
fgj actions run view 123 --log
|
fj actions run view 123 --log
|
||||||
|
|
||||||
# View specific job logs
|
# View specific job logs
|
||||||
fgj actions run view 123 --job 456 --log
|
fj actions run view 123 --job 456 --log
|
||||||
|
|
||||||
# Watch a run until completion
|
# Watch a run until completion
|
||||||
fgj actions run watch 123
|
fj actions run watch 123
|
||||||
|
|
||||||
# Rerun a workflow run
|
# Rerun a workflow run
|
||||||
fgj actions run rerun 123
|
fj actions run rerun 123
|
||||||
|
|
||||||
# Cancel a running workflow
|
# Cancel a running workflow
|
||||||
fgj actions run cancel 123
|
fj actions run cancel 123
|
||||||
|
|
||||||
# List secrets
|
# List secrets
|
||||||
fgj actions secret list
|
fj actions secret list
|
||||||
|
|
||||||
# Create a secret
|
# Create a secret
|
||||||
fgj actions secret create MY_SECRET
|
fj actions secret create MY_SECRET
|
||||||
|
|
||||||
# Delete a secret
|
# Delete a secret
|
||||||
fgj actions secret delete MY_SECRET
|
fj actions secret delete MY_SECRET
|
||||||
|
|
||||||
# List variables
|
# List variables
|
||||||
fgj actions variable list
|
fj actions variable list
|
||||||
|
|
||||||
# Get a variable
|
# Get a variable
|
||||||
fgj actions variable get MY_VAR
|
fj actions variable get MY_VAR
|
||||||
|
|
||||||
# Create a variable
|
# Create a variable
|
||||||
fgj actions variable create MY_VAR "value"
|
fj actions variable create MY_VAR "value"
|
||||||
|
|
||||||
# Update a variable
|
# Update a variable
|
||||||
fgj actions variable update MY_VAR "new value"
|
fj actions variable update MY_VAR "new value"
|
||||||
|
|
||||||
# Delete a variable
|
# Delete a variable
|
||||||
fgj actions variable delete MY_VAR
|
fj actions variable delete MY_VAR
|
||||||
```
|
```
|
||||||
|
|
||||||
### Raw API Access
|
### Raw API Access
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# GET request (auto-detects owner/repo from git context)
|
# GET request (auto-detects owner/repo from git context)
|
||||||
fgj api /repos/{owner}/{repo}/pulls
|
fj api /repos/{owner}/{repo}/pulls
|
||||||
|
|
||||||
# POST with fields
|
# POST with fields
|
||||||
fgj api /repos/{owner}/{repo}/issues -X POST -f title="Bug report" -f body="Description"
|
fj api /repos/{owner}/{repo}/issues -X POST -f title="Bug report" -f body="Description"
|
||||||
|
|
||||||
# Explicit method and hostname
|
# Explicit method and hostname
|
||||||
fgj api /repos/myorg/myrepo/labels --hostname my-forgejo.example.com
|
fj api /repos/myorg/myrepo/labels --hostname my-forgejo.example.com
|
||||||
|
|
||||||
# Read request body from file
|
# Read request body from file
|
||||||
fgj api /repos/{owner}/{repo}/issues -X POST --input issue.json
|
fj api /repos/{owner}/{repo}/issues -X POST --input issue.json
|
||||||
|
|
||||||
# Read from stdin
|
# Read from stdin
|
||||||
echo '{"title":"test"}' | fgj api /repos/{owner}/{repo}/issues -X POST --input -
|
echo '{"title":"test"}' | fj api /repos/{owner}/{repo}/issues -X POST --input -
|
||||||
|
|
||||||
# Include response headers
|
# Include response headers
|
||||||
fgj api /repos/{owner}/{repo} -i
|
fj api /repos/{owner}/{repo} -i
|
||||||
|
|
||||||
# Suppress output (useful for DELETE)
|
# Suppress output (useful for DELETE)
|
||||||
fgj api /repos/{owner}/{repo}/issues/123 -X DELETE --silent
|
fj api /repos/{owner}/{repo}/issues/123 -X DELETE --silent
|
||||||
```
|
```
|
||||||
|
|
||||||
## Shell Completions and Man Pages
|
## Shell Completions and Man Pages
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Generate shell completion scripts
|
# Generate shell completion scripts
|
||||||
fgj completion bash > /etc/bash_completion.d/fgj
|
fj completion bash > /etc/bash_completion.d/fj
|
||||||
fgj completion zsh > "${fpath[1]}/_fgj"
|
fj completion zsh > "${fpath[1]}/_fj"
|
||||||
fgj completion fish > ~/.config/fish/completions/fgj.fish
|
fj completion fish > ~/.config/fish/completions/fj.fish
|
||||||
|
|
||||||
# Generate man pages to a directory
|
# Generate man pages to a directory
|
||||||
fgj manpages --dir ~/.local/share/man/man1
|
fj manpages --dir ~/.local/share/man/man1
|
||||||
```
|
```
|
||||||
|
|
||||||
## JSON Output
|
## JSON Output
|
||||||
|
|
@ -423,15 +418,15 @@ fgj manpages --dir ~/.local/share/man/man1
|
||||||
Most list and view commands support `--json` for machine-readable output:
|
Most list and view commands support `--json` for machine-readable output:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
fgj pr list --json
|
fj pr list --json
|
||||||
fgj issue view 456 --json
|
fj issue view 456 --json
|
||||||
fgj release list --json
|
fj release list --json
|
||||||
fgj actions run list --json
|
fj actions run list --json
|
||||||
fgj actions workflow view ci.yml --json
|
fj actions workflow view ci.yml --json
|
||||||
|
|
||||||
# Get JSON output from PR comment/review
|
# Get JSON output from PR comment/review
|
||||||
fgj pr comment 123 -b "LGTM" --json
|
fj pr comment 123 -b "LGTM" --json
|
||||||
fgj pr review 123 --approve -b "Ship it" --json
|
fj pr review 123 --approve -b "Ship it" --json
|
||||||
```
|
```
|
||||||
|
|
||||||
### Structured Error Output
|
### Structured Error Output
|
||||||
|
|
@ -440,16 +435,16 @@ For machine consumption (ideal for AI agents and scripts), use `--json-errors` t
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Errors are written to stderr as JSON
|
# Errors are written to stderr as JSON
|
||||||
fgj pr view 9999 --json-errors
|
fj pr view 9999 --json-errors
|
||||||
# stderr: {"error":{"code":"not_found","message":"...","status":404}}
|
# stderr: {"error":{"code":"not_found","message":"...","status":404}}
|
||||||
|
|
||||||
# Combine with --json for fully machine-readable I/O
|
# Combine with --json for fully machine-readable I/O
|
||||||
fgj pr list --json --json-errors
|
fj pr list --json --json-errors
|
||||||
```
|
```
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
Configuration is stored in `~/.config/fgj/config.yaml`:
|
Configuration is stored in `~/.config/fj/config.yaml`:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
hosts:
|
hosts:
|
||||||
|
|
@ -471,9 +466,9 @@ hosts:
|
||||||
|
|
||||||
### Directory-Based Host Selection (`match_dirs`)
|
### Directory-Based Host Selection (`match_dirs`)
|
||||||
|
|
||||||
When you work with multiple Forgejo/Gitea instances, `fgj` can automatically select the right host based on your current working directory — no `--hostname` flag needed.
|
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 `fgj` 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.
|
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
|
```yaml
|
||||||
hosts:
|
hosts:
|
||||||
|
|
@ -499,12 +494,12 @@ hosts:
|
||||||
|
|
||||||
### Environment Variables
|
### Environment Variables
|
||||||
|
|
||||||
- `FGJ_HOST`: Override the default instance (auto-detected from git remote if not set)
|
- `FJ_HOST`: Override the default instance (auto-detected from git remote if not set)
|
||||||
- `FGJ_TOKEN`: Provide authentication token
|
- `FJ_TOKEN`: Provide authentication token
|
||||||
|
|
||||||
Hostname is resolved in this priority order:
|
Hostname is resolved in this priority order:
|
||||||
1. Command-specific flags (e.g., `--hostname`)
|
1. Command-specific flags (e.g., `--hostname`)
|
||||||
2. `FGJ_HOST` environment variable
|
2. `FJ_HOST` environment variable
|
||||||
3. Auto-detected from git remote URL
|
3. Auto-detected from git remote URL
|
||||||
4. `match_dirs` lookup (longest prefix match against current directory)
|
4. `match_dirs` lookup (longest prefix match against current directory)
|
||||||
5. Default to `codeberg.org`
|
5. Default to `codeberg.org`
|
||||||
|
|
@ -514,15 +509,15 @@ Hostname is resolved in this priority order:
|
||||||
- `--hostname`: Specify instance for a command (overrides auto-detection and environment variables)
|
- `--hostname`: Specify instance for a command (overrides auto-detection and environment variables)
|
||||||
- `--config`: Use a custom config file
|
- `--config`: Use a custom config file
|
||||||
|
|
||||||
When working in a git repository, `fgj` automatically detects the instance from your origin remote URL, so you typically don't need to specify `--hostname` unless working with multiple instances.
|
When working in a git repository, `fj` automatically detects the instance from your origin remote URL, so you typically don't need to specify `--hostname` unless working with multiple instances.
|
||||||
|
|
||||||
## Use with AI Coding Agents
|
## Use with AI Coding Agents
|
||||||
|
|
||||||
`fgj` is designed to work seamlessly with AI coding agents like Claude Code. Use `--json` and `--json-errors` for fully machine-readable I/O:
|
`fj` is designed to work seamlessly with AI coding agents like Claude Code. Use `--json` and `--json-errors` for fully machine-readable I/O:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Create PR from agent's changes
|
# Create PR from agent's changes
|
||||||
fgj pr create -R owner/repo -t "feat: add new feature" -b "$(cat <<EOF
|
fj pr create -R owner/repo -t "feat: add new feature" -b "$(cat <<EOF
|
||||||
## Summary
|
## Summary
|
||||||
- Added new feature X
|
- Added new feature X
|
||||||
- Fixed bug Y
|
- Fixed bug Y
|
||||||
|
|
@ -532,29 +527,29 @@ EOF
|
||||||
)" --json
|
)" --json
|
||||||
|
|
||||||
# Check PR status during development
|
# Check PR status during development
|
||||||
fgj pr list -R owner/repo --state open --json
|
fj pr list -R owner/repo --state open --json
|
||||||
|
|
||||||
# Review a PR diff, then approve
|
# Review a PR diff, then approve
|
||||||
fgj pr diff 123
|
fj pr diff 123
|
||||||
fgj pr review 123 --approve -b "LGTM" --json
|
fj pr review 123 --approve -b "LGTM" --json
|
||||||
|
|
||||||
# Post review feedback
|
# Post review feedback
|
||||||
fgj pr comment 123 -b "Consider using a map here for O(1) lookup" --json
|
fj pr comment 123 -b "Consider using a map here for O(1) lookup" --json
|
||||||
|
|
||||||
# Request changes with detailed feedback
|
# Request changes with detailed feedback
|
||||||
fgj pr review 123 --request-changes --body-file feedback.md --json
|
fj pr review 123 --request-changes --body-file feedback.md --json
|
||||||
|
|
||||||
# Use raw API for anything not covered by commands
|
# Use raw API for anything not covered by commands
|
||||||
fgj api /repos/{owner}/{repo}/topics --json-errors
|
fj api /repos/{owner}/{repo}/topics --json-errors
|
||||||
fgj api /repos/{owner}/{repo}/labels -X POST -f name=agent-reviewed -f color="#00ff00"
|
fj api /repos/{owner}/{repo}/labels -X POST -f name=agent-reviewed -f color="#00ff00"
|
||||||
|
|
||||||
# Fully machine-readable error handling
|
# Fully machine-readable error handling
|
||||||
fgj pr view 9999 --json --json-errors 2>errors.json
|
fj pr view 9999 --json --json-errors 2>errors.json
|
||||||
```
|
```
|
||||||
|
|
||||||
## Supported Instances
|
## Supported Instances
|
||||||
|
|
||||||
`fgj` works with any Forgejo or Gitea instance, including:
|
`fj` works with any Forgejo or Gitea instance, including:
|
||||||
|
|
||||||
- Self-hosted Forgejo instances
|
- Self-hosted Forgejo instances
|
||||||
- Self-hosted Gitea instances
|
- Self-hosted Gitea instances
|
||||||
|
|
@ -562,11 +557,11 @@ fgj pr view 9999 --json --json-errors 2>errors.json
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
Contributions are welcome! Please feel free to submit a Pull Request at [forgejo.zerova.net/public/fgj-sid](https://forgejo.zerova.net/public/fgj-sid).
|
Contributions are welcome! Please feel free to submit a Pull Request at [forgejo.zerova.net/public/fj](https://forgejo.zerova.net/public/fj).
|
||||||
|
|
||||||
## Missing Features / Roadmap
|
## Missing Features / Roadmap
|
||||||
|
|
||||||
`fgj` aims to be a drop-in replacement for `gh` when working with Forgejo and Gitea instances. While we've implemented the core features, some `gh` commands are not yet available:
|
`fj` aims to be a drop-in replacement for `gh` when working with Forgejo and Gitea instances. While we've implemented the core features, some `gh` commands are not yet available:
|
||||||
|
|
||||||
**Not Yet Implemented:**
|
**Not Yet Implemented:**
|
||||||
- `run delete` - Delete a workflow run
|
- `run delete` - Delete a workflow run
|
||||||
|
|
@ -581,7 +576,7 @@ We welcome contributions to implement any of these features!
|
||||||
|
|
||||||
## Acknowledgments
|
## Acknowledgments
|
||||||
|
|
||||||
Based on [fgj by romaintb](https://codeberg.org/romaintb/fgj). Enhanced with agentic dev features for AI-assisted workflows.
|
Based on [fj by romaintb](https://codeberg.org/romaintb/fj). Enhanced with agentic dev features for AI-assisted workflows.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|
|
||||||
384
cmd/actions.go
384
cmd/actions.go
|
|
@ -10,8 +10,8 @@ import (
|
||||||
"code.gitea.io/sdk/gitea"
|
"code.gitea.io/sdk/gitea"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
"forgejo.zerova.net/public/fgj-sid/internal/api"
|
"forgejo.zerova.net/public/fj/internal/api"
|
||||||
"forgejo.zerova.net/public/fgj-sid/internal/config"
|
"forgejo.zerova.net/public/fj/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ActionRun represents a workflow run
|
// ActionRun represents a workflow run
|
||||||
|
|
@ -87,146 +87,224 @@ var actionsCmd = &cobra.Command{
|
||||||
Long: "View and manage workflows, runs, secrets, and variables for Forgejo Actions in your repositories.",
|
Long: "View and manage workflows, runs, secrets, and variables for Forgejo Actions in your repositories.",
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run commands (compatible with gh run)
|
// Run and Workflow command trees are built via factory functions
|
||||||
var runCmd = &cobra.Command{
|
// (newRunCmd / newWorkflowCmd) so cmd/aliases.go can build an identical
|
||||||
Use: "run",
|
// top-level tree under rootCmd without duplicating Use/Short/Long/Example/
|
||||||
Short: "View and manage workflow runs",
|
// flag declarations. Single source of truth — drift impossible.
|
||||||
Long: "List, view, and manage workflow runs.",
|
|
||||||
|
// newRunCmd builds the `run` subtree. parentLabel is interpolated into the
|
||||||
|
// parent's Short/Long so the alias-tree variant can advertise itself as
|
||||||
|
// "alias for 'actions run'" without diverging on the children.
|
||||||
|
func newRunCmd(parentLabel string) *cobra.Command {
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "run",
|
||||||
|
Short: "View and manage workflow runs" + parentLabel,
|
||||||
|
Long: "List, view, and manage workflow runs." + parentLabel,
|
||||||
|
}
|
||||||
|
cmd.AddCommand(newRunListCmd())
|
||||||
|
cmd.AddCommand(newRunViewCmd())
|
||||||
|
cmd.AddCommand(newRunWatchCmd())
|
||||||
|
cmd.AddCommand(newRunRerunCmd())
|
||||||
|
cmd.AddCommand(newRunCancelCmd())
|
||||||
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
var runListCmd = &cobra.Command{
|
func newRunListCmd() *cobra.Command {
|
||||||
Use: "list",
|
c := &cobra.Command{
|
||||||
Short: "List recent workflow runs",
|
Use: "list",
|
||||||
Long: "List recent workflow runs for a repository.",
|
Short: "List recent workflow runs",
|
||||||
Example: ` # List recent workflow runs
|
Long: "List recent workflow runs for a repository.",
|
||||||
fgj actions run list
|
Example: ` # List recent workflow runs
|
||||||
|
fj actions run list
|
||||||
|
|
||||||
# List runs with a custom limit
|
# List runs with a custom limit
|
||||||
fgj actions run list -L 50
|
fj actions run list -L 50
|
||||||
|
|
||||||
# Output as JSON
|
# Output as JSON
|
||||||
fgj actions run list --json`,
|
fj actions run list --json`,
|
||||||
RunE: runRunList,
|
RunE: runRunList,
|
||||||
|
}
|
||||||
|
addRepoFlags(c)
|
||||||
|
c.Flags().IntP("limit", "L", 20, "Maximum number of runs to list")
|
||||||
|
addJSONFlags(c, "Output workflow runs as JSON")
|
||||||
|
return c
|
||||||
}
|
}
|
||||||
|
|
||||||
var runViewCmd = &cobra.Command{
|
func newRunViewCmd() *cobra.Command {
|
||||||
Use: "view <run-id>",
|
c := &cobra.Command{
|
||||||
Short: "View a workflow run",
|
Use: "view <run-id>",
|
||||||
Long: "View details about a specific workflow run.",
|
Short: "View a workflow run",
|
||||||
Example: ` # View a workflow run
|
Long: "View details about a specific workflow run.",
|
||||||
fgj actions run view 123
|
Example: ` # View a workflow run
|
||||||
|
fj actions run view 123
|
||||||
|
|
||||||
# View with job details
|
# View with job details
|
||||||
fgj actions run view 123 -v
|
fj actions run view 123 -v
|
||||||
|
|
||||||
# View logs for a specific job
|
# View logs for a specific job
|
||||||
fgj actions run view 123 --job 456 --log
|
fj actions run view 123 --job 456 --log
|
||||||
|
|
||||||
# View only failed logs
|
# View only failed logs
|
||||||
fgj actions run view 123 --log-failed`,
|
fj actions run view 123 --log-failed`,
|
||||||
Args: cobra.ExactArgs(1),
|
Args: cobra.ExactArgs(1),
|
||||||
RunE: runRunView,
|
RunE: runRunView,
|
||||||
|
}
|
||||||
|
addRepoFlags(c)
|
||||||
|
c.Flags().BoolP("verbose", "v", false, "Show job steps")
|
||||||
|
c.Flags().BoolP("log", "", false, "View full log for either a run or specific job")
|
||||||
|
c.Flags().StringP("job", "j", "", "View a specific job ID from a run")
|
||||||
|
c.Flags().BoolP("log-failed", "", false, "View the log for any failed steps in a run or specific job")
|
||||||
|
addJSONFlags(c, "Output workflow run as JSON")
|
||||||
|
return c
|
||||||
}
|
}
|
||||||
|
|
||||||
var runWatchCmd = &cobra.Command{
|
func newRunWatchCmd() *cobra.Command {
|
||||||
Use: "watch <run-id>",
|
c := &cobra.Command{
|
||||||
Short: "Watch a workflow run",
|
Use: "watch <run-id>",
|
||||||
Long: "Poll a workflow run until it completes.",
|
Short: "Watch a workflow run",
|
||||||
Example: ` # Watch a run until it completes
|
Long: "Poll a workflow run until it completes.",
|
||||||
fgj actions run watch 123
|
Example: ` # Watch a run until it completes
|
||||||
|
fj actions run watch 123
|
||||||
|
|
||||||
# Watch with a custom polling interval
|
# Watch with a custom polling interval
|
||||||
fgj actions run watch 123 -i 10s`,
|
fj actions run watch 123 -i 10s`,
|
||||||
Args: cobra.ExactArgs(1),
|
Args: cobra.ExactArgs(1),
|
||||||
RunE: runRunWatch,
|
RunE: runRunWatch,
|
||||||
|
}
|
||||||
|
addRepoFlags(c)
|
||||||
|
c.Flags().DurationP("interval", "i", 5*time.Second, "Polling interval")
|
||||||
|
return c
|
||||||
}
|
}
|
||||||
|
|
||||||
var runRerunCmd = &cobra.Command{
|
func newRunRerunCmd() *cobra.Command {
|
||||||
Use: "rerun <run-id>",
|
c := &cobra.Command{
|
||||||
Short: "Rerun a workflow run",
|
Use: "rerun <run-id>",
|
||||||
Long: "Trigger a rerun for a specific workflow run.",
|
Short: "Rerun a workflow run",
|
||||||
Example: ` # Rerun a failed workflow run
|
Long: "Trigger a rerun for a specific workflow run.",
|
||||||
fgj actions run rerun 123`,
|
Example: ` # Rerun a failed workflow run
|
||||||
Args: cobra.ExactArgs(1),
|
fj actions run rerun 123`,
|
||||||
RunE: runRunRerun,
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: runRunRerun,
|
||||||
|
}
|
||||||
|
addRepoFlags(c)
|
||||||
|
return c
|
||||||
}
|
}
|
||||||
|
|
||||||
var runCancelCmd = &cobra.Command{
|
func newRunCancelCmd() *cobra.Command {
|
||||||
Use: "cancel <run-id>",
|
c := &cobra.Command{
|
||||||
Short: "Cancel a workflow run",
|
Use: "cancel <run-id>",
|
||||||
Long: "Cancel a running workflow run.",
|
Short: "Cancel a workflow run",
|
||||||
Example: ` # Cancel a running workflow
|
Long: "Cancel a running workflow run.",
|
||||||
fgj actions run cancel 123`,
|
Example: ` # Cancel a running workflow
|
||||||
Args: cobra.ExactArgs(1),
|
fj actions run cancel 123`,
|
||||||
RunE: runRunCancel,
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: runRunCancel,
|
||||||
|
}
|
||||||
|
addRepoFlags(c)
|
||||||
|
return c
|
||||||
}
|
}
|
||||||
|
|
||||||
// Workflow commands
|
// newWorkflowCmd builds the `workflow` subtree. parentLabel is interpolated
|
||||||
var workflowCmd = &cobra.Command{
|
// the same way as newRunCmd's, so the alias variant can self-identify.
|
||||||
Use: "workflow",
|
func newWorkflowCmd(parentLabel string) *cobra.Command {
|
||||||
Short: "Manage workflows",
|
cmd := &cobra.Command{
|
||||||
Long: "List, view, and run workflows.",
|
Use: "workflow",
|
||||||
|
Short: "Manage workflows" + parentLabel,
|
||||||
|
Long: "List, view, and run workflows." + parentLabel,
|
||||||
|
}
|
||||||
|
cmd.AddCommand(newWorkflowListCmd())
|
||||||
|
cmd.AddCommand(newWorkflowViewCmd())
|
||||||
|
cmd.AddCommand(newWorkflowRunCmd())
|
||||||
|
cmd.AddCommand(newWorkflowEnableCmd())
|
||||||
|
cmd.AddCommand(newWorkflowDisableCmd())
|
||||||
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
var workflowListCmd = &cobra.Command{
|
func newWorkflowListCmd() *cobra.Command {
|
||||||
Use: "list",
|
c := &cobra.Command{
|
||||||
Short: "List workflows",
|
Use: "list",
|
||||||
Long: "List all workflows in a repository.",
|
Short: "List workflows",
|
||||||
Example: ` # List all workflows
|
Long: "List all workflows in a repository.",
|
||||||
fgj actions workflow list
|
Example: ` # List all workflows
|
||||||
|
fj actions workflow list
|
||||||
|
|
||||||
# List workflows as JSON
|
# List workflows as JSON
|
||||||
fgj actions workflow list --json
|
fj actions workflow list --json
|
||||||
|
|
||||||
# List workflows for a specific repo
|
# List workflows for a specific repo
|
||||||
fgj actions workflow list -R owner/repo`,
|
fj actions workflow list -R owner/repo`,
|
||||||
RunE: runWorkflowList,
|
RunE: runWorkflowList,
|
||||||
|
}
|
||||||
|
addRepoFlags(c)
|
||||||
|
c.Flags().IntP("limit", "L", 20, "Maximum number of workflows to list")
|
||||||
|
addJSONFlags(c, "Output workflows as JSON")
|
||||||
|
return c
|
||||||
}
|
}
|
||||||
|
|
||||||
var workflowViewCmd = &cobra.Command{
|
func newWorkflowViewCmd() *cobra.Command {
|
||||||
Use: "view <workflow>",
|
c := &cobra.Command{
|
||||||
Short: "View a workflow",
|
Use: "view <workflow>",
|
||||||
Long: "View details about a specific workflow. You can specify the workflow by name or filename.",
|
Short: "View a workflow",
|
||||||
Example: ` # View a workflow by filename
|
Long: "View details about a specific workflow. You can specify the workflow by name or filename.",
|
||||||
fgj actions workflow view ci.yml
|
Example: ` # View a workflow by filename
|
||||||
|
fj actions workflow view ci.yml
|
||||||
|
|
||||||
# View as JSON
|
# View as JSON
|
||||||
fgj actions workflow view ci.yml --json`,
|
fj actions workflow view ci.yml --json`,
|
||||||
Args: cobra.ExactArgs(1),
|
Args: cobra.ExactArgs(1),
|
||||||
RunE: runWorkflowView,
|
RunE: runWorkflowView,
|
||||||
|
}
|
||||||
|
addRepoFlags(c)
|
||||||
|
addJSONFlags(c, "Output workflow as JSON")
|
||||||
|
return c
|
||||||
}
|
}
|
||||||
|
|
||||||
var workflowRunCmd = &cobra.Command{
|
func newWorkflowRunCmd() *cobra.Command {
|
||||||
Use: "run <workflow>",
|
c := &cobra.Command{
|
||||||
Short: "Run a workflow",
|
Use: "run <workflow>",
|
||||||
Long: "Trigger a workflow_dispatch event for a workflow. The workflow must support the workflow_dispatch trigger.",
|
Short: "Run a workflow",
|
||||||
Example: ` # Trigger a workflow on the default branch
|
Long: "Trigger a workflow_dispatch event for a workflow. The workflow must support the workflow_dispatch trigger.",
|
||||||
fgj actions workflow run deploy.yml
|
Example: ` # Trigger a workflow on the default branch
|
||||||
|
fj actions workflow run deploy.yml
|
||||||
|
|
||||||
# Trigger on a specific branch with input parameters
|
# Trigger on a specific branch with input parameters
|
||||||
fgj actions workflow run deploy.yml -r staging -f environment=staging -f version=1.2.3`,
|
fj actions workflow run deploy.yml -r staging -f environment=staging -f version=1.2.3`,
|
||||||
Args: cobra.ExactArgs(1),
|
Args: cobra.ExactArgs(1),
|
||||||
RunE: runWorkflowRun,
|
RunE: runWorkflowRun,
|
||||||
|
}
|
||||||
|
addRepoFlags(c)
|
||||||
|
c.Flags().StringP("ref", "r", "", "Branch or tag name to run the workflow on (defaults to repository's default branch)")
|
||||||
|
c.Flags().StringSliceP("field", "f", nil, "Add a string parameter in key=value format (can be used multiple times)")
|
||||||
|
c.Flags().StringSliceP("raw-field", "F", nil, "Add a string parameter in key=value format, reading from file if value starts with @ (can be used multiple times)")
|
||||||
|
return c
|
||||||
}
|
}
|
||||||
|
|
||||||
var workflowEnableCmd = &cobra.Command{
|
func newWorkflowEnableCmd() *cobra.Command {
|
||||||
Use: "enable <workflow>",
|
c := &cobra.Command{
|
||||||
Short: "Enable a workflow",
|
Use: "enable <workflow>",
|
||||||
Long: "Enable a workflow so it can be triggered.\n\nNote: This feature requires Forgejo 15.0+ or Gitea 1.24+.\nFor older versions, use the web UI to enable workflows.",
|
Short: "Enable a workflow",
|
||||||
Example: ` # Enable a workflow
|
Long: "Enable a workflow so it can be triggered.\n\nNote: This feature requires Forgejo 15.0+ or Gitea 1.24+.\nFor older versions, use the web UI to enable workflows.",
|
||||||
fgj actions workflow enable ci.yml`,
|
Example: ` # Enable a workflow
|
||||||
Args: cobra.ExactArgs(1),
|
fj actions workflow enable ci.yml`,
|
||||||
RunE: runWorkflowEnable,
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: runWorkflowEnable,
|
||||||
|
}
|
||||||
|
addRepoFlags(c)
|
||||||
|
return c
|
||||||
}
|
}
|
||||||
|
|
||||||
var workflowDisableCmd = &cobra.Command{
|
func newWorkflowDisableCmd() *cobra.Command {
|
||||||
Use: "disable <workflow>",
|
c := &cobra.Command{
|
||||||
Short: "Disable a workflow",
|
Use: "disable <workflow>",
|
||||||
Long: "Disable a workflow so it cannot be triggered.\n\nNote: This feature requires Forgejo 15.0+ or Gitea 1.24+.\nFor older versions, use the web UI to disable workflows.",
|
Short: "Disable a workflow",
|
||||||
Example: ` # Disable a workflow
|
Long: "Disable a workflow so it cannot be triggered.\n\nNote: This feature requires Forgejo 15.0+ or Gitea 1.24+.\nFor older versions, use the web UI to disable workflows.",
|
||||||
fgj actions workflow disable ci.yml`,
|
Example: ` # Disable a workflow
|
||||||
Args: cobra.ExactArgs(1),
|
fj actions workflow disable ci.yml`,
|
||||||
RunE: runWorkflowDisable,
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: runWorkflowDisable,
|
||||||
|
}
|
||||||
|
addRepoFlags(c)
|
||||||
|
return c
|
||||||
}
|
}
|
||||||
|
|
||||||
// Secret commands
|
// Secret commands
|
||||||
|
|
@ -241,36 +319,22 @@ var actionsSecretListCmd = &cobra.Command{
|
||||||
Short: "List repository secrets",
|
Short: "List repository secrets",
|
||||||
Long: "List all secrets for a repository.",
|
Long: "List all secrets for a repository.",
|
||||||
Example: ` # List all secrets
|
Example: ` # List all secrets
|
||||||
fgj actions secret list
|
fj actions secret list
|
||||||
|
|
||||||
# List secrets for a specific repo
|
# List secrets for a specific repo
|
||||||
fgj actions secret list -R owner/repo`,
|
fj actions secret list -R owner/repo`,
|
||||||
RunE: runActionsSecretList,
|
RunE: runActionsSecretList,
|
||||||
}
|
}
|
||||||
|
|
||||||
var actionsSecretCreateCmd = &cobra.Command{
|
var actionsSecretCreateCmd = &cobra.Command{
|
||||||
Use: "create <name>",
|
Use: "create <name>",
|
||||||
Short: "Create or update a repository secret",
|
Short: "Create or update a repository secret",
|
||||||
Long: `Create or update a secret for Forgejo Actions.
|
Long: "Create or update a secret for Forgejo Actions. The secret value will be read from stdin.",
|
||||||
|
Example: ` # Create a secret (will prompt for value)
|
||||||
|
fj actions secret create DEPLOY_TOKEN
|
||||||
|
|
||||||
The secret value is read from the first available source:
|
# Create a secret for a specific repo
|
||||||
1. --body <value>
|
fj actions secret create API_KEY -R owner/repo`,
|
||||||
2. --body-file <path> (use "-" for stdin)
|
|
||||||
3. interactive prompt (hidden input) if stdin is a TTY
|
|
||||||
4. stdin (when piped)
|
|
||||||
|
|
||||||
Trailing newlines are trimmed. Empty values are rejected.`,
|
|
||||||
Example: ` # Interactive (hidden prompt)
|
|
||||||
fgj actions secret create DEPLOY_TOKEN
|
|
||||||
|
|
||||||
# Pipe a value
|
|
||||||
op read op://vault/github/token | fgj actions secret create GH_TOKEN --body-file -
|
|
||||||
|
|
||||||
# Read from a file
|
|
||||||
fgj actions secret create TLS_KEY --body-file ./server.key
|
|
||||||
|
|
||||||
# Inline (visible in shell history — use sparingly)
|
|
||||||
fgj actions secret create DEBUG_FLAG --body "on"`,
|
|
||||||
Args: cobra.ExactArgs(1),
|
Args: cobra.ExactArgs(1),
|
||||||
RunE: runActionsSecretCreate,
|
RunE: runActionsSecretCreate,
|
||||||
}
|
}
|
||||||
|
|
@ -280,7 +344,7 @@ var actionsSecretDeleteCmd = &cobra.Command{
|
||||||
Short: "Delete a repository secret",
|
Short: "Delete a repository secret",
|
||||||
Long: "Delete a secret from Forgejo Actions.",
|
Long: "Delete a secret from Forgejo Actions.",
|
||||||
Example: ` # Delete a secret
|
Example: ` # Delete a secret
|
||||||
fgj actions secret delete DEPLOY_TOKEN`,
|
fj actions secret delete DEPLOY_TOKEN`,
|
||||||
Args: cobra.ExactArgs(1),
|
Args: cobra.ExactArgs(1),
|
||||||
RunE: runActionsSecretDelete,
|
RunE: runActionsSecretDelete,
|
||||||
}
|
}
|
||||||
|
|
@ -297,10 +361,10 @@ var actionsVariableListCmd = &cobra.Command{
|
||||||
Short: "List repository variables",
|
Short: "List repository variables",
|
||||||
Long: "List all variables for a repository.",
|
Long: "List all variables for a repository.",
|
||||||
Example: ` # List all variables
|
Example: ` # List all variables
|
||||||
fgj actions variable list
|
fj actions variable list
|
||||||
|
|
||||||
# List variables for a specific repo
|
# List variables for a specific repo
|
||||||
fgj actions variable list -R owner/repo`,
|
fj actions variable list -R owner/repo`,
|
||||||
RunE: runActionsVariableList,
|
RunE: runActionsVariableList,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -309,7 +373,7 @@ var actionsVariableGetCmd = &cobra.Command{
|
||||||
Short: "Get a repository variable",
|
Short: "Get a repository variable",
|
||||||
Long: "Get the value of a specific repository variable.",
|
Long: "Get the value of a specific repository variable.",
|
||||||
Example: ` # Get a variable value
|
Example: ` # Get a variable value
|
||||||
fgj actions variable get ENVIRONMENT`,
|
fj actions variable get ENVIRONMENT`,
|
||||||
Args: cobra.ExactArgs(1),
|
Args: cobra.ExactArgs(1),
|
||||||
RunE: runActionsVariableGet,
|
RunE: runActionsVariableGet,
|
||||||
}
|
}
|
||||||
|
|
@ -319,10 +383,10 @@ var actionsVariableCreateCmd = &cobra.Command{
|
||||||
Short: "Create a repository variable",
|
Short: "Create a repository variable",
|
||||||
Long: "Create a new variable for Forgejo Actions.",
|
Long: "Create a new variable for Forgejo Actions.",
|
||||||
Example: ` # Create a variable
|
Example: ` # Create a variable
|
||||||
fgj actions variable create ENVIRONMENT production
|
fj actions variable create ENVIRONMENT production
|
||||||
|
|
||||||
# Create a variable for a specific repo
|
# Create a variable for a specific repo
|
||||||
fgj actions variable create NODE_VERSION 20 -R owner/repo`,
|
fj actions variable create NODE_VERSION 20 -R owner/repo`,
|
||||||
Args: cobra.ExactArgs(2),
|
Args: cobra.ExactArgs(2),
|
||||||
RunE: runActionsVariableCreate,
|
RunE: runActionsVariableCreate,
|
||||||
}
|
}
|
||||||
|
|
@ -332,7 +396,7 @@ var actionsVariableUpdateCmd = &cobra.Command{
|
||||||
Short: "Update a repository variable",
|
Short: "Update a repository variable",
|
||||||
Long: "Update an existing variable for Forgejo Actions.",
|
Long: "Update an existing variable for Forgejo Actions.",
|
||||||
Example: ` # Update a variable
|
Example: ` # Update a variable
|
||||||
fgj actions variable update ENVIRONMENT staging`,
|
fj actions variable update ENVIRONMENT staging`,
|
||||||
Args: cobra.ExactArgs(2),
|
Args: cobra.ExactArgs(2),
|
||||||
RunE: runActionsVariableUpdate,
|
RunE: runActionsVariableUpdate,
|
||||||
}
|
}
|
||||||
|
|
@ -342,7 +406,7 @@ var actionsVariableDeleteCmd = &cobra.Command{
|
||||||
Short: "Delete a repository variable",
|
Short: "Delete a repository variable",
|
||||||
Long: "Delete a variable from Forgejo Actions.",
|
Long: "Delete a variable from Forgejo Actions.",
|
||||||
Example: ` # Delete a variable
|
Example: ` # Delete a variable
|
||||||
fgj actions variable delete ENVIRONMENT`,
|
fj actions variable delete ENVIRONMENT`,
|
||||||
Args: cobra.ExactArgs(1),
|
Args: cobra.ExactArgs(1),
|
||||||
RunE: runActionsVariableDelete,
|
RunE: runActionsVariableDelete,
|
||||||
}
|
}
|
||||||
|
|
@ -350,21 +414,10 @@ var actionsVariableDeleteCmd = &cobra.Command{
|
||||||
func init() {
|
func init() {
|
||||||
rootCmd.AddCommand(actionsCmd)
|
rootCmd.AddCommand(actionsCmd)
|
||||||
|
|
||||||
// Add run commands (gh run compatible)
|
// Run and Workflow trees come from the factory functions defined above
|
||||||
actionsCmd.AddCommand(runCmd)
|
// so cmd/aliases.go can build identical top-level trees under rootCmd.
|
||||||
runCmd.AddCommand(runListCmd)
|
actionsCmd.AddCommand(newRunCmd(""))
|
||||||
runCmd.AddCommand(runViewCmd)
|
actionsCmd.AddCommand(newWorkflowCmd(""))
|
||||||
runCmd.AddCommand(runWatchCmd)
|
|
||||||
runCmd.AddCommand(runRerunCmd)
|
|
||||||
runCmd.AddCommand(runCancelCmd)
|
|
||||||
|
|
||||||
// Add workflow commands (gh workflow compatible)
|
|
||||||
actionsCmd.AddCommand(workflowCmd)
|
|
||||||
workflowCmd.AddCommand(workflowListCmd)
|
|
||||||
workflowCmd.AddCommand(workflowViewCmd)
|
|
||||||
workflowCmd.AddCommand(workflowRunCmd)
|
|
||||||
workflowCmd.AddCommand(workflowEnableCmd)
|
|
||||||
workflowCmd.AddCommand(workflowDisableCmd)
|
|
||||||
|
|
||||||
// Add secret commands
|
// Add secret commands
|
||||||
actionsCmd.AddCommand(actionsSecretCmd)
|
actionsCmd.AddCommand(actionsSecretCmd)
|
||||||
|
|
@ -380,39 +433,9 @@ func init() {
|
||||||
actionsVariableCmd.AddCommand(actionsVariableUpdateCmd)
|
actionsVariableCmd.AddCommand(actionsVariableUpdateCmd)
|
||||||
actionsVariableCmd.AddCommand(actionsVariableDeleteCmd)
|
actionsVariableCmd.AddCommand(actionsVariableDeleteCmd)
|
||||||
|
|
||||||
// Add flags for run commands
|
|
||||||
addRepoFlags(runListCmd)
|
|
||||||
runListCmd.Flags().IntP("limit", "L", 20, "Maximum number of runs to list")
|
|
||||||
addJSONFlags(runListCmd, "Output workflow runs as JSON")
|
|
||||||
addRepoFlags(runViewCmd)
|
|
||||||
runViewCmd.Flags().BoolP("verbose", "v", false, "Show job steps")
|
|
||||||
runViewCmd.Flags().BoolP("log", "", false, "View full log for either a run or specific job")
|
|
||||||
runViewCmd.Flags().StringP("job", "j", "", "View a specific job ID from a run")
|
|
||||||
runViewCmd.Flags().BoolP("log-failed", "", false, "View the log for any failed steps in a run or specific job")
|
|
||||||
addJSONFlags(runViewCmd, "Output workflow run as JSON")
|
|
||||||
addRepoFlags(runWatchCmd)
|
|
||||||
runWatchCmd.Flags().DurationP("interval", "i", 5*time.Second, "Polling interval")
|
|
||||||
addRepoFlags(runRerunCmd)
|
|
||||||
addRepoFlags(runCancelCmd)
|
|
||||||
|
|
||||||
// Add flags for workflow commands
|
|
||||||
addRepoFlags(workflowListCmd)
|
|
||||||
workflowListCmd.Flags().IntP("limit", "L", 20, "Maximum number of workflows to list")
|
|
||||||
addJSONFlags(workflowListCmd, "Output workflows as JSON")
|
|
||||||
addRepoFlags(workflowViewCmd)
|
|
||||||
addJSONFlags(workflowViewCmd, "Output workflow as JSON")
|
|
||||||
addRepoFlags(workflowRunCmd)
|
|
||||||
addRepoFlags(workflowEnableCmd)
|
|
||||||
addRepoFlags(workflowDisableCmd)
|
|
||||||
workflowRunCmd.Flags().StringP("ref", "r", "", "Branch or tag name to run the workflow on (defaults to repository's default branch)")
|
|
||||||
workflowRunCmd.Flags().StringSliceP("field", "f", nil, "Add a string parameter in key=value format (can be used multiple times)")
|
|
||||||
workflowRunCmd.Flags().StringSliceP("raw-field", "F", nil, "Add a string parameter in key=value format, reading from file if value starts with @ (can be used multiple times)")
|
|
||||||
|
|
||||||
// Add flags for secret commands
|
// Add flags for secret commands
|
||||||
addRepoFlags(actionsSecretListCmd)
|
addRepoFlags(actionsSecretListCmd)
|
||||||
addRepoFlags(actionsSecretCreateCmd)
|
addRepoFlags(actionsSecretCreateCmd)
|
||||||
actionsSecretCreateCmd.Flags().String("body", "", "Secret value (visible in shell history)")
|
|
||||||
actionsSecretCreateCmd.Flags().String("body-file", "", "Read secret value from file, or '-' for stdin")
|
|
||||||
addRepoFlags(actionsSecretDeleteCmd)
|
addRepoFlags(actionsSecretDeleteCmd)
|
||||||
|
|
||||||
// Add flags for variable commands
|
// Add flags for variable commands
|
||||||
|
|
@ -1270,9 +1293,12 @@ func runActionsSecretCreate(cmd *cobra.Command, args []string) error {
|
||||||
|
|
||||||
secretName := args[0]
|
secretName := args[0]
|
||||||
|
|
||||||
secretValue, err := readSecretValue(cmd, secretName)
|
// Read secret value from stdin
|
||||||
|
fmt.Fprint(ios.ErrOut, "Enter secret value: ")
|
||||||
|
var secretValue string
|
||||||
|
_, err = fmt.Scanln(&secretValue)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return fmt.Errorf("failed to read secret value: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
opt := gitea.CreateSecretOption{
|
opt := gitea.CreateSecretOption{
|
||||||
|
|
@ -1280,12 +1306,12 @@ func runActionsSecretCreate(cmd *cobra.Command, args []string) error {
|
||||||
Data: secretValue,
|
Data: secretValue,
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := client.CreateRepoActionSecret(owner, name, opt); err != nil {
|
_, err = client.CreateRepoActionSecret(owner, name, opt)
|
||||||
|
if err != nil {
|
||||||
return fmt.Errorf("failed to create secret: %w", err)
|
return fmt.Errorf("failed to create secret: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
cs := ios.ColorScheme()
|
fmt.Fprintf(ios.Out, "Secret '%s' created successfully\n", secretName)
|
||||||
fmt.Fprintf(ios.Out, "%s Secret %q created\n", cs.SuccessIcon(), secretName)
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,99 +0,0 @@
|
||||||
package cmd
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"strconv"
|
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
|
|
||||||
"forgejo.zerova.net/public/fgj-sid/internal/api"
|
|
||||||
"forgejo.zerova.net/public/fgj-sid/internal/config"
|
|
||||||
)
|
|
||||||
|
|
||||||
var runDeleteCmd = &cobra.Command{
|
|
||||||
Use: "delete <run-id>",
|
|
||||||
Aliases: []string{"rm"},
|
|
||||||
Short: "Delete a workflow run",
|
|
||||||
Long: `Delete a completed workflow run.
|
|
||||||
|
|
||||||
By default, the run is fetched first and deletion is refused if the run
|
|
||||||
is still pending, running, or waiting. Use --force to override this and
|
|
||||||
delete a non-terminal run. To stop an in-progress run, use
|
|
||||||
'fgj actions run cancel' instead.`,
|
|
||||||
Example: ` # Delete a completed run (with confirmation)
|
|
||||||
fgj actions run delete 123
|
|
||||||
|
|
||||||
# Delete without confirmation
|
|
||||||
fgj actions run delete 123 -y
|
|
||||||
|
|
||||||
# Force delete a non-terminal run
|
|
||||||
fgj actions run delete 123 --force -y`,
|
|
||||||
Args: cobra.ExactArgs(1),
|
|
||||||
RunE: runRunDelete,
|
|
||||||
}
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
runCmd.AddCommand(runDeleteCmd)
|
|
||||||
addRepoFlags(runDeleteCmd)
|
|
||||||
runDeleteCmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompt")
|
|
||||||
runDeleteCmd.Flags().Bool("force", false, "Allow deleting a non-terminal (pending/running/waiting) run")
|
|
||||||
}
|
|
||||||
|
|
||||||
func runRunDelete(cmd *cobra.Command, args []string) error {
|
|
||||||
cfg, err := config.Load()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to load config: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd())
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to create client: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
repo, _ := cmd.Flags().GetString("repo")
|
|
||||||
owner, name, err := parseRepo(repo)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
runID, err := strconv.ParseInt(args[0], 10, 64)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("invalid run ID: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
skipConfirm, _ := cmd.Flags().GetBool("yes")
|
|
||||||
force, _ := cmd.Flags().GetBool("force")
|
|
||||||
|
|
||||||
// Fetch the run to check state and to display status in the confirmation prompt.
|
|
||||||
runEndpoint := fmt.Sprintf("/api/v1/repos/%s/%s/actions/runs/%d", owner, name, runID)
|
|
||||||
var run ActionRun
|
|
||||||
if err := client.GetJSON(runEndpoint, &run); err != nil {
|
|
||||||
return fmt.Errorf("failed to get run: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !isRunComplete(run.Status) && !force {
|
|
||||||
return fmt.Errorf("run %d is %s; refusing to delete a non-terminal run. Use 'fgj actions run cancel %d' to stop it, or pass --force to delete anyway",
|
|
||||||
runID, run.Status, runID)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !skipConfirm && ios.IsStdinTTY() {
|
|
||||||
answer, err := promptLine(fmt.Sprintf("Delete run %d (%s) in %s/%s? [y/N]: ", runID, run.Status, owner, name))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if answer != "y" && answer != "Y" && answer != "yes" {
|
|
||||||
fmt.Fprintln(ios.ErrOut, "Cancelled.")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
deleteEndpoint := fmt.Sprintf("/api/v1/repos/%s/%s/actions/runs/%d", owner, name, runID)
|
|
||||||
if _, err := client.DoJSON(http.MethodDelete, deleteEndpoint, nil, nil); err != nil {
|
|
||||||
return fmt.Errorf("failed to delete run %d: %w", runID, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
cs := ios.ColorScheme()
|
|
||||||
fmt.Fprintf(ios.Out, "%s Deleted run %d\n", cs.SuccessIcon(), runID)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
85
cmd/admin.go
85
cmd/admin.go
|
|
@ -1,85 +0,0 @@
|
||||||
package cmd
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"code.gitea.io/sdk/gitea"
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
)
|
|
||||||
|
|
||||||
var adminCmd = &cobra.Command{
|
|
||||||
Use: "admin",
|
|
||||||
Aliases: []string{"a"},
|
|
||||||
Short: "Operations requiring admin access",
|
|
||||||
Long: "Administrative operations on the current host. These require an admin-scoped token.",
|
|
||||||
}
|
|
||||||
|
|
||||||
var adminUserCmd = &cobra.Command{
|
|
||||||
Use: "user",
|
|
||||||
Aliases: []string{"users", "u"},
|
|
||||||
Short: "Manage users on the host",
|
|
||||||
Long: "Admin-scoped user management.",
|
|
||||||
}
|
|
||||||
|
|
||||||
var adminUserListCmd = &cobra.Command{
|
|
||||||
Use: "list",
|
|
||||||
Aliases: []string{"ls"},
|
|
||||||
Short: "List all users on the host",
|
|
||||||
Example: ` # List users
|
|
||||||
fgj admin user list
|
|
||||||
|
|
||||||
# Limit and output as JSON
|
|
||||||
fgj admin user list --limit 100 --json`,
|
|
||||||
RunE: runAdminUserList,
|
|
||||||
}
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
rootCmd.AddCommand(adminCmd)
|
|
||||||
adminCmd.AddCommand(adminUserCmd)
|
|
||||||
adminUserCmd.AddCommand(adminUserListCmd)
|
|
||||||
|
|
||||||
adminUserListCmd.Flags().IntP("limit", "L", 50, "Maximum number of users to list")
|
|
||||||
addJSONFlags(adminUserListCmd, "Output as JSON")
|
|
||||||
}
|
|
||||||
|
|
||||||
func runAdminUserList(cmd *cobra.Command, args []string) error {
|
|
||||||
client, err := loadClient()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
limit, _ := cmd.Flags().GetInt("limit")
|
|
||||||
if limit <= 0 {
|
|
||||||
limit = 50
|
|
||||||
}
|
|
||||||
|
|
||||||
users, _, err := client.AdminListUsers(gitea.AdminListUsersOptions{
|
|
||||||
ListOptions: gitea.ListOptions{PageSize: limit},
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to list users (admin token required): %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if wantJSON(cmd) {
|
|
||||||
return outputJSON(cmd, users)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(users) == 0 {
|
|
||||||
fmt.Fprintln(ios.Out, "No users found.")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
tp := ios.NewTablePrinter()
|
|
||||||
tp.AddHeader("LOGIN", "FULL NAME", "EMAIL", "ADMIN", "ACTIVE")
|
|
||||||
for _, u := range users {
|
|
||||||
admin, active := "", "yes"
|
|
||||||
if u.IsAdmin {
|
|
||||||
admin = "yes"
|
|
||||||
}
|
|
||||||
if !u.IsActive {
|
|
||||||
active = "no"
|
|
||||||
}
|
|
||||||
tp.AddRow(u.UserName, u.FullName, u.Email, admin, active)
|
|
||||||
}
|
|
||||||
return tp.Render()
|
|
||||||
}
|
|
||||||
148
cmd/aliases.go
148
cmd/aliases.go
|
|
@ -1,142 +1,16 @@
|
||||||
package cmd
|
package cmd
|
||||||
|
|
||||||
import (
|
// Top-level aliases for "actions run" and "actions workflow" — matches gh
|
||||||
"time"
|
// CLI's ergonomics so users can type `fj run list` and `fj workflow list`
|
||||||
|
// instead of `fj actions run list`.
|
||||||
"github.com/spf13/cobra"
|
//
|
||||||
)
|
// Both trees are built from the same factory functions defined in
|
||||||
|
// `cmd/actions.go` (newRunCmd / newWorkflowCmd), which means flags and
|
||||||
// Top-level aliases for "actions run" and "actions workflow" commands,
|
// help text are guaranteed identical between the two paths. Previously
|
||||||
// matching gh CLI's command structure (e.g., "fgj run list" instead of "fgj actions run list").
|
// this file rebuilt parallel trees by hand and silently drifted (the
|
||||||
|
// `--json` Bool/string mismatch was the symptom that surfaced).
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
// --- run alias ---
|
rootCmd.AddCommand(newRunCmd(" (alias for 'actions run')"))
|
||||||
runAliasCmd := &cobra.Command{
|
rootCmd.AddCommand(newWorkflowCmd(" (alias for 'actions workflow')"))
|
||||||
Use: "run",
|
|
||||||
Short: "View and manage workflow runs (alias for 'actions run')",
|
|
||||||
Long: "List, view, and manage workflow runs.\n\nThis is a top-level alias for 'actions run'.",
|
|
||||||
}
|
|
||||||
|
|
||||||
runAliasListCmd := &cobra.Command{
|
|
||||||
Use: "list",
|
|
||||||
Short: "List recent workflow runs",
|
|
||||||
Long: "List recent workflow runs for a repository.",
|
|
||||||
RunE: runRunList,
|
|
||||||
}
|
|
||||||
addRepoFlags(runAliasListCmd)
|
|
||||||
runAliasListCmd.Flags().IntP("limit", "L", 20, "Maximum number of runs to list")
|
|
||||||
runAliasListCmd.Flags().Bool("json", false, "Output workflow runs as JSON")
|
|
||||||
|
|
||||||
runAliasViewCmd := &cobra.Command{
|
|
||||||
Use: "view <run-id>",
|
|
||||||
Short: "View a workflow run",
|
|
||||||
Long: "View details about a specific workflow run.",
|
|
||||||
Args: cobra.ExactArgs(1),
|
|
||||||
RunE: runRunView,
|
|
||||||
}
|
|
||||||
addRepoFlags(runAliasViewCmd)
|
|
||||||
runAliasViewCmd.Flags().BoolP("verbose", "v", false, "Show job steps")
|
|
||||||
runAliasViewCmd.Flags().BoolP("log", "", false, "View full log for either a run or specific job")
|
|
||||||
runAliasViewCmd.Flags().StringP("job", "j", "", "View a specific job ID from a run")
|
|
||||||
runAliasViewCmd.Flags().BoolP("log-failed", "", false, "View the log for any failed steps in a run or specific job")
|
|
||||||
runAliasViewCmd.Flags().Bool("json", false, "Output workflow run as JSON")
|
|
||||||
|
|
||||||
runAliasWatchCmd := &cobra.Command{
|
|
||||||
Use: "watch <run-id>",
|
|
||||||
Short: "Watch a workflow run",
|
|
||||||
Long: "Poll a workflow run until it completes.",
|
|
||||||
Args: cobra.ExactArgs(1),
|
|
||||||
RunE: runRunWatch,
|
|
||||||
}
|
|
||||||
addRepoFlags(runAliasWatchCmd)
|
|
||||||
runAliasWatchCmd.Flags().DurationP("interval", "i", 5*time.Second, "Polling interval")
|
|
||||||
|
|
||||||
runAliasRerunCmd := &cobra.Command{
|
|
||||||
Use: "rerun <run-id>",
|
|
||||||
Short: "Rerun a workflow run",
|
|
||||||
Long: "Trigger a rerun for a specific workflow run.",
|
|
||||||
Args: cobra.ExactArgs(1),
|
|
||||||
RunE: runRunRerun,
|
|
||||||
}
|
|
||||||
addRepoFlags(runAliasRerunCmd)
|
|
||||||
|
|
||||||
runAliasCancelCmd := &cobra.Command{
|
|
||||||
Use: "cancel <run-id>",
|
|
||||||
Short: "Cancel a workflow run",
|
|
||||||
Long: "Cancel a running workflow run.",
|
|
||||||
Args: cobra.ExactArgs(1),
|
|
||||||
RunE: runRunCancel,
|
|
||||||
}
|
|
||||||
addRepoFlags(runAliasCancelCmd)
|
|
||||||
|
|
||||||
runAliasCmd.AddCommand(runAliasListCmd)
|
|
||||||
runAliasCmd.AddCommand(runAliasViewCmd)
|
|
||||||
runAliasCmd.AddCommand(runAliasWatchCmd)
|
|
||||||
runAliasCmd.AddCommand(runAliasRerunCmd)
|
|
||||||
runAliasCmd.AddCommand(runAliasCancelCmd)
|
|
||||||
rootCmd.AddCommand(runAliasCmd)
|
|
||||||
|
|
||||||
// --- workflow alias ---
|
|
||||||
workflowAliasCmd := &cobra.Command{
|
|
||||||
Use: "workflow",
|
|
||||||
Short: "Manage workflows (alias for 'actions workflow')",
|
|
||||||
Long: "List, view, and run workflows.\n\nThis is a top-level alias for 'actions workflow'.",
|
|
||||||
}
|
|
||||||
|
|
||||||
workflowAliasListCmd := &cobra.Command{
|
|
||||||
Use: "list",
|
|
||||||
Short: "List workflows",
|
|
||||||
Long: "List all workflows in a repository.",
|
|
||||||
RunE: runWorkflowList,
|
|
||||||
}
|
|
||||||
addRepoFlags(workflowAliasListCmd)
|
|
||||||
workflowAliasListCmd.Flags().IntP("limit", "L", 20, "Maximum number of workflows to list")
|
|
||||||
workflowAliasListCmd.Flags().Bool("json", false, "Output workflows as JSON")
|
|
||||||
|
|
||||||
workflowAliasViewCmd := &cobra.Command{
|
|
||||||
Use: "view <workflow>",
|
|
||||||
Short: "View a workflow",
|
|
||||||
Long: "View details about a specific workflow. You can specify the workflow by name or filename.",
|
|
||||||
Args: cobra.ExactArgs(1),
|
|
||||||
RunE: runWorkflowView,
|
|
||||||
}
|
|
||||||
addRepoFlags(workflowAliasViewCmd)
|
|
||||||
workflowAliasViewCmd.Flags().Bool("json", false, "Output workflow as JSON")
|
|
||||||
|
|
||||||
workflowAliasRunCmd := &cobra.Command{
|
|
||||||
Use: "run <workflow>",
|
|
||||||
Short: "Run a workflow",
|
|
||||||
Long: "Trigger a workflow_dispatch event for a workflow. The workflow must support the workflow_dispatch trigger.",
|
|
||||||
Args: cobra.ExactArgs(1),
|
|
||||||
RunE: runWorkflowRun,
|
|
||||||
}
|
|
||||||
addRepoFlags(workflowAliasRunCmd)
|
|
||||||
workflowAliasRunCmd.Flags().StringP("ref", "r", "", "Branch or tag name to run the workflow on (defaults to repository's default branch)")
|
|
||||||
workflowAliasRunCmd.Flags().StringSliceP("field", "f", nil, "Add a string parameter in key=value format (can be used multiple times)")
|
|
||||||
workflowAliasRunCmd.Flags().StringSliceP("raw-field", "F", nil, "Add a string parameter in key=value format, reading from file if value starts with @ (can be used multiple times)")
|
|
||||||
|
|
||||||
workflowAliasEnableCmd := &cobra.Command{
|
|
||||||
Use: "enable <workflow>",
|
|
||||||
Short: "Enable a workflow",
|
|
||||||
Long: "Enable a workflow so it can be triggered.\n\nNote: This feature requires Forgejo 15.0+ or Gitea 1.24+.\nFor older versions, use the web UI to enable workflows.",
|
|
||||||
Args: cobra.ExactArgs(1),
|
|
||||||
RunE: runWorkflowEnable,
|
|
||||||
}
|
|
||||||
addRepoFlags(workflowAliasEnableCmd)
|
|
||||||
|
|
||||||
workflowAliasDisableCmd := &cobra.Command{
|
|
||||||
Use: "disable <workflow>",
|
|
||||||
Short: "Disable a workflow",
|
|
||||||
Long: "Disable a workflow so it cannot be triggered.\n\nNote: This feature requires Forgejo 15.0+ or Gitea 1.24+.\nFor older versions, use the web UI to disable workflows.",
|
|
||||||
Args: cobra.ExactArgs(1),
|
|
||||||
RunE: runWorkflowDisable,
|
|
||||||
}
|
|
||||||
addRepoFlags(workflowAliasDisableCmd)
|
|
||||||
|
|
||||||
workflowAliasCmd.AddCommand(workflowAliasListCmd)
|
|
||||||
workflowAliasCmd.AddCommand(workflowAliasViewCmd)
|
|
||||||
workflowAliasCmd.AddCommand(workflowAliasRunCmd)
|
|
||||||
workflowAliasCmd.AddCommand(workflowAliasEnableCmd)
|
|
||||||
workflowAliasCmd.AddCommand(workflowAliasDisableCmd)
|
|
||||||
rootCmd.AddCommand(workflowAliasCmd)
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
221
cmd/api.go
221
cmd/api.go
|
|
@ -6,15 +6,23 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"forgejo.zerova.net/public/fgj-sid/internal/config"
|
"forgejo.zerova.net/public/fj/internal/api"
|
||||||
"forgejo.zerova.net/public/fgj-sid/internal/git"
|
"forgejo.zerova.net/public/fj/internal/config"
|
||||||
|
"forgejo.zerova.net/public/fj/internal/git"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// maxAPIResponseBytes caps response bodies for `fj api`. Forgejo responses
|
||||||
|
// are normally <1 MB; 64 MB is enough for any sane payload while preventing
|
||||||
|
// a runaway body from OOMing the CLI when combined with the 30 s client
|
||||||
|
// timeout.
|
||||||
|
const maxAPIResponseBytes = 64 << 20
|
||||||
|
|
||||||
var apiCmd = &cobra.Command{
|
var apiCmd = &cobra.Command{
|
||||||
Use: "api <endpoint> [flags]",
|
Use: "api <endpoint> [flags]",
|
||||||
Short: "Make an authenticated API request",
|
Short: "Make an authenticated API request",
|
||||||
|
|
@ -26,16 +34,22 @@ detected from the current git repository.
|
||||||
|
|
||||||
If --field is used and no --method is specified, the method defaults to POST.`,
|
If --field is used and no --method is specified, the method defaults to POST.`,
|
||||||
Example: ` # List pull requests for the current repository
|
Example: ` # List pull requests for the current repository
|
||||||
fgj api /repos/{owner}/{repo}/pulls
|
fj api /repos/{owner}/{repo}/pulls
|
||||||
|
|
||||||
# Create an issue
|
# Create an issue
|
||||||
fgj api /repos/{owner}/{repo}/issues --method POST --field title=Bug --field body="It broke"
|
fj api /repos/{owner}/{repo}/issues --method POST --field title=Bug --field body="It broke"
|
||||||
|
|
||||||
# Get a specific user
|
# Get a specific user
|
||||||
fgj api /users/johndoe
|
fj api /users/johndoe
|
||||||
|
|
||||||
# Use raw body from stdin
|
# Use raw body from stdin
|
||||||
echo '{"title":"test"}' | fgj api /repos/{owner}/{repo}/issues --input -`,
|
echo '{"title":"test"}' | fj api /repos/{owner}/{repo}/issues --input -
|
||||||
|
|
||||||
|
# Filter the response with a jq expression
|
||||||
|
fj api /repos/{owner}/{repo}/issues --jq '.[].title'
|
||||||
|
|
||||||
|
# Project the response down to specific fields
|
||||||
|
fj api /repos/{owner}/{repo} --json-fields full_name,description,private`,
|
||||||
Args: cobra.ExactArgs(1),
|
Args: cobra.ExactArgs(1),
|
||||||
RunE: runAPI,
|
RunE: runAPI,
|
||||||
}
|
}
|
||||||
|
|
@ -50,6 +64,40 @@ func init() {
|
||||||
apiCmd.Flags().StringArrayP("header", "H", nil, "Add an HTTP request header (key:value)")
|
apiCmd.Flags().StringArrayP("header", "H", nil, "Add an HTTP request header (key:value)")
|
||||||
apiCmd.Flags().Bool("silent", false, "Do not print the response body")
|
apiCmd.Flags().Bool("silent", false, "Do not print the response body")
|
||||||
apiCmd.Flags().BoolP("include", "i", false, "Include HTTP response headers in the output")
|
apiCmd.Flags().BoolP("include", "i", false, "Include HTTP response headers in the output")
|
||||||
|
apiCmd.Flags().Bool("paginate", false, "Follow rel=\"next\" Link headers and concatenate JSON array pages (gh-compatible)")
|
||||||
|
addJSONFlags(apiCmd, "Output the response as JSON")
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseLinkHeaderNext extracts the URL with rel="next" from an RFC 5988
|
||||||
|
// Link header. Returns "" if not present.
|
||||||
|
func parseLinkHeaderNext(link string) string {
|
||||||
|
for _, segment := range strings.Split(link, ",") {
|
||||||
|
segment = strings.TrimSpace(segment)
|
||||||
|
if !strings.Contains(segment, `rel="next"`) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
start := strings.Index(segment, "<")
|
||||||
|
end := strings.Index(segment, ">")
|
||||||
|
if start >= 0 && end > start {
|
||||||
|
return segment[start+1 : end]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// concatPaginatedJSON parses each body as a JSON array and merges them.
|
||||||
|
// Errors if any body isn't an array (e.g. an object response means the
|
||||||
|
// endpoint isn't paginated and --paginate doesn't apply).
|
||||||
|
func concatPaginatedJSON(bodies [][]byte) ([]byte, error) {
|
||||||
|
merged := make([]json.RawMessage, 0)
|
||||||
|
for i, b := range bodies {
|
||||||
|
var page []json.RawMessage
|
||||||
|
if err := json.Unmarshal(b, &page); err != nil {
|
||||||
|
return nil, fmt.Errorf("--paginate requires JSON array responses; page %d wasn't an array: %w", i+1, err)
|
||||||
|
}
|
||||||
|
merged = append(merged, page...)
|
||||||
|
}
|
||||||
|
return json.Marshal(merged)
|
||||||
}
|
}
|
||||||
|
|
||||||
func runAPI(cmd *cobra.Command, args []string) error {
|
func runAPI(cmd *cobra.Command, args []string) error {
|
||||||
|
|
@ -139,15 +187,28 @@ func runAPI(cmd *cobra.Command, args []string) error {
|
||||||
body = bytes.NewReader(bodyBytes)
|
body = bytes.NewReader(bodyBytes)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build URL
|
// Build the request URL safely. Naive concatenation lets endpoints like
|
||||||
baseURL := "https://" + host.Hostname + "/api/v1"
|
// "/../admin/users" escape the /api/v1 base via Go's URL normalization
|
||||||
if !strings.HasPrefix(endpoint, "/") {
|
// of `..` segments — silently sending authenticated traffic to non-API
|
||||||
endpoint = "/" + endpoint
|
// paths. Parse the endpoint, reject `..`, then JoinPath onto the base.
|
||||||
|
endpointURL, err := url.Parse(endpoint)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid endpoint %q: %w", endpoint, err)
|
||||||
}
|
}
|
||||||
url := baseURL + endpoint
|
if endpointURL.Scheme != "" || endpointURL.Host != "" {
|
||||||
|
return fmt.Errorf("endpoint must be a path, not a full URL: %s", endpoint)
|
||||||
|
}
|
||||||
|
for _, seg := range strings.Split(strings.Trim(endpointURL.Path, "/"), "/") {
|
||||||
|
if seg == ".." {
|
||||||
|
return fmt.Errorf("endpoint contains forbidden '..' segment: %s", endpoint)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
base := &url.URL{Scheme: "https", Host: host.Hostname, Path: "/api/v1"}
|
||||||
|
final := base.JoinPath(endpointURL.Path)
|
||||||
|
final.RawQuery = endpointURL.RawQuery
|
||||||
|
|
||||||
// Create HTTP request
|
// Create HTTP request
|
||||||
req, err := http.NewRequest(method, url, body)
|
req, err := http.NewRequest(method, final.String(), body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to create request: %w", err)
|
return fmt.Errorf("failed to create request: %w", err)
|
||||||
}
|
}
|
||||||
|
|
@ -170,20 +231,42 @@ func runAPI(cmd *cobra.Command, args []string) error {
|
||||||
req.Header.Set(strings.TrimSpace(key), strings.TrimSpace(value))
|
req.Header.Set(strings.TrimSpace(key), strings.TrimSpace(value))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Execute request
|
paginate, _ := cmd.Flags().GetBool("paginate")
|
||||||
ios.StartSpinner("Requesting...")
|
if paginate && method != http.MethodGet {
|
||||||
httpClient := &http.Client{}
|
return fmt.Errorf("--paginate only supports GET requests")
|
||||||
resp, err := httpClient.Do(req)
|
}
|
||||||
ios.StopSpinner()
|
|
||||||
if err != nil {
|
// doOnce executes a single request via the shared client (30 s timeout,
|
||||||
return fmt.Errorf("failed to perform request: %w", err)
|
// 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 {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
defer func() { _ = resp.Body.Close() }()
|
|
||||||
|
|
||||||
// Print response headers if requested
|
|
||||||
if include {
|
if include {
|
||||||
fmt.Fprintf(ios.Out, "%s %s\n", resp.Proto, resp.Status)
|
fmt.Fprintf(ios.Out, "%s %s\n", proto, status)
|
||||||
for key, values := range resp.Header {
|
for key, values := range respHeader {
|
||||||
for _, v := range values {
|
for _, v := range values {
|
||||||
fmt.Fprintf(ios.Out, "%s: %s\n", key, v)
|
fmt.Fprintf(ios.Out, "%s: %s\n", key, v)
|
||||||
}
|
}
|
||||||
|
|
@ -191,39 +274,99 @@ func runAPI(cmd *cobra.Command, args []string) error {
|
||||||
fmt.Fprintln(ios.Out)
|
fmt.Fprintln(ios.Out)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read response body
|
if statusCode < 200 || statusCode >= 300 {
|
||||||
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(ios.ErrOut, 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(ios.ErrOut)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return fmt.Errorf("API request failed with status %d", resp.StatusCode)
|
return fmt.Errorf("API request failed with status %d", statusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Follow `Link: rel="next"` headers when --paginate is set, accumulating
|
||||||
|
// each page's body. After the loop, concatPaginatedJSON merges them into
|
||||||
|
// a single JSON array. Endpoint must be paginatable (returns an array).
|
||||||
|
if paginate {
|
||||||
|
bodies := [][]byte{respBody}
|
||||||
|
nextURL := parseLinkHeaderNext(respHeader.Get("Link"))
|
||||||
|
for nextURL != "" {
|
||||||
|
// Forgejo emits same-origin next-links in practice, but a buggy
|
||||||
|
// or hostile upstream could redirect us to a foreign host — at
|
||||||
|
// which point we'd leak the bearer token. Validate origin (and
|
||||||
|
// resolve relative URLs against `base`) before forwarding auth.
|
||||||
|
parsedNext, err := url.Parse(nextURL)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid Link rel=\"next\" URL %q: %w", nextURL, err)
|
||||||
|
}
|
||||||
|
if !parsedNext.IsAbs() {
|
||||||
|
parsedNext = base.ResolveReference(parsedNext)
|
||||||
|
}
|
||||||
|
if parsedNext.Scheme != "https" || parsedNext.Host != host.Hostname {
|
||||||
|
return fmt.Errorf("paginated next URL %s is not same-origin as https://%s; refusing to forward credentials", parsedNext.String(), host.Hostname)
|
||||||
|
}
|
||||||
|
|
||||||
|
nextReq, err := http.NewRequest(http.MethodGet, parsedNext.String(), nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to build paginated request: %w", err)
|
||||||
|
}
|
||||||
|
if host.Token != "" {
|
||||||
|
nextReq.Header.Set("Authorization", "token "+host.Token)
|
||||||
|
}
|
||||||
|
nextReq.Header.Set("Accept", "application/json")
|
||||||
|
for _, h := range headers {
|
||||||
|
key, value, found := strings.Cut(h, ":")
|
||||||
|
if !found {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
nextReq.Header.Set(strings.TrimSpace(key), strings.TrimSpace(value))
|
||||||
|
}
|
||||||
|
pageBody, pageHeader, pageStatus, _, _, err := doOnce(nextReq)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if pageStatus < 200 || pageStatus >= 300 {
|
||||||
|
return fmt.Errorf("paginated request to %s failed with status %d", parsedNext.String(), pageStatus)
|
||||||
|
}
|
||||||
|
bodies = append(bodies, pageBody)
|
||||||
|
nextURL = parseLinkHeaderNext(pageHeader.Get("Link"))
|
||||||
|
}
|
||||||
|
merged, err := concatPaginatedJSON(bodies)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
respBody = merged
|
||||||
}
|
}
|
||||||
|
|
||||||
if silent || len(respBody) == 0 {
|
if silent || len(respBody) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pretty-print JSON, or output raw if not JSON
|
contentType := respHeader.Get("Content-Type")
|
||||||
contentType := resp.Header.Get("Content-Type")
|
isJSON := strings.Contains(contentType, "json") || json.Valid(respBody)
|
||||||
if strings.Contains(contentType, "json") || json.Valid(respBody) {
|
|
||||||
|
// If the user asked for JSON projection or jq filtering, route through
|
||||||
|
// the shared JSON output helpers so the API command is consistent with
|
||||||
|
// `fj repo list`, `fj pr list`, etc.
|
||||||
|
if wantJSON(cmd) {
|
||||||
|
if !isJSON {
|
||||||
|
return fmt.Errorf("--json/--json-fields/--jq requires a JSON response, but the server returned %s", contentType)
|
||||||
|
}
|
||||||
|
var parsed any
|
||||||
|
if err := json.Unmarshal(respBody, &parsed); err != nil {
|
||||||
|
return fmt.Errorf("response is not valid JSON: %w", err)
|
||||||
|
}
|
||||||
|
return outputJSON(cmd, parsed)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pretty-print JSON by default, otherwise emit raw bytes.
|
||||||
|
if isJSON {
|
||||||
var parsed any
|
var parsed any
|
||||||
if err := json.Unmarshal(respBody, &parsed); err == nil {
|
if err := json.Unmarshal(respBody, &parsed); err == nil {
|
||||||
enc := json.NewEncoder(ios.Out)
|
return writeJSON(parsed)
|
||||||
enc.SetIndent("", " ")
|
|
||||||
return enc.Encode(parsed)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Raw output for non-JSON responses
|
|
||||||
_, err = ios.Out.Write(respBody)
|
_, err = ios.Out.Write(respBody)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
|
||||||
27
cmd/auth.go
27
cmd/auth.go
|
|
@ -7,8 +7,8 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
|
|
||||||
"forgejo.zerova.net/public/fgj-sid/internal/api"
|
"forgejo.zerova.net/public/fj/internal/api"
|
||||||
"forgejo.zerova.net/public/fgj-sid/internal/config"
|
"forgejo.zerova.net/public/fj/internal/config"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"github.com/spf13/viper"
|
"github.com/spf13/viper"
|
||||||
"golang.org/x/term"
|
"golang.org/x/term"
|
||||||
|
|
@ -16,7 +16,7 @@ import (
|
||||||
|
|
||||||
var authCmd = &cobra.Command{
|
var authCmd = &cobra.Command{
|
||||||
Use: "auth",
|
Use: "auth",
|
||||||
Short: "Authenticate fgj with a Forgejo instance",
|
Short: "Authenticate fj with a Forgejo instance",
|
||||||
Long: "Manage authentication state for Forgejo instances.",
|
Long: "Manage authentication state for Forgejo instances.",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -55,16 +55,25 @@ func init() {
|
||||||
authCmd.AddCommand(authLogoutCmd)
|
authCmd.AddCommand(authLogoutCmd)
|
||||||
authCmd.AddCommand(authTokenCmd)
|
authCmd.AddCommand(authTokenCmd)
|
||||||
|
|
||||||
authLoginCmd.Flags().String("hostname", "", "Forgejo instance hostname (e.g., codeberg.org)")
|
// --hostname is a persistent flag on rootCmd (cmd/root.go). Don't
|
||||||
authLoginCmd.Flags().StringP("token", "t", "", "Personal access token")
|
// re-declare it on auth subcommands — local flags shadow the persistent
|
||||||
authLogoutCmd.Flags().String("hostname", "", "Forgejo instance hostname (e.g., codeberg.org)")
|
// one, so `fj --hostname=X auth login` and `fj auth login --hostname=X`
|
||||||
authTokenCmd.Flags().String("hostname", "", "Forgejo instance hostname (e.g., codeberg.org)")
|
// went through different code paths (viper vs. local).
|
||||||
|
authLoginCmd.Flags().StringP("token", "t", "", "Personal access token (DEPRECATED: visible in `ps auxe`; pipe via stdin instead)")
|
||||||
}
|
}
|
||||||
|
|
||||||
func runAuthLogin(cmd *cobra.Command, args []string) error {
|
func runAuthLogin(cmd *cobra.Command, args []string) error {
|
||||||
hostname, _ := cmd.Flags().GetString("hostname")
|
hostname, _ := cmd.Flags().GetString("hostname")
|
||||||
token, _ := cmd.Flags().GetString("token")
|
token, _ := cmd.Flags().GetString("token")
|
||||||
|
|
||||||
|
// Tokens passed via --token end up on the process command line and
|
||||||
|
// therefore in `ps auxe` and shell history. Warn loudly so users notice.
|
||||||
|
// (Don't refuse the flag — too disruptive for scripts that already use it.)
|
||||||
|
if cmd.Flags().Changed("token") {
|
||||||
|
fmt.Fprintln(ios.ErrOut, "warning: --token puts the token on the command line (visible in `ps auxe` and shell history)")
|
||||||
|
fmt.Fprintln(ios.ErrOut, " prefer omitting --token and pasting at the prompt, or piping via stdin.")
|
||||||
|
}
|
||||||
|
|
||||||
reader := bufio.NewReader(os.Stdin)
|
reader := bufio.NewReader(os.Stdin)
|
||||||
|
|
||||||
if hostname == "" {
|
if hostname == "" {
|
||||||
|
|
@ -132,7 +141,7 @@ 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.Fprintln(ios.Out, "Not authenticated with any Forgejo instances")
|
||||||
fmt.Fprintln(ios.Out, "Run 'fgj auth login' to authenticate")
|
fmt.Fprintln(ios.Out, "Run 'fj auth login' to authenticate")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -188,7 +197,7 @@ func resolveAuthHostname(cfg *config.Config, hostname string) (string, error) {
|
||||||
hostname = viper.GetString("hostname")
|
hostname = viper.GetString("hostname")
|
||||||
}
|
}
|
||||||
if hostname == "" {
|
if hostname == "" {
|
||||||
hostname = os.Getenv("FGJ_HOST")
|
hostname = config.EnvWithFallback("FJ_HOST", "FGJ_HOST")
|
||||||
}
|
}
|
||||||
if hostname == "" {
|
if hostname == "" {
|
||||||
hostname = getDetectedHost()
|
hostname = getDetectedHost()
|
||||||
|
|
|
||||||
185
cmd/branch.go
185
cmd/branch.go
|
|
@ -1,185 +0,0 @@
|
||||||
package cmd
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"code.gitea.io/sdk/gitea"
|
|
||||||
"forgejo.zerova.net/public/fgj-sid/internal/api"
|
|
||||||
"forgejo.zerova.net/public/fgj-sid/internal/config"
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
)
|
|
||||||
|
|
||||||
var branchCmd = &cobra.Command{
|
|
||||||
Use: "branch",
|
|
||||||
Aliases: []string{"b"},
|
|
||||||
Short: "Manage repository branches",
|
|
||||||
Long: "List, rename, and delete branches in a repository.",
|
|
||||||
}
|
|
||||||
|
|
||||||
var branchListCmd = &cobra.Command{
|
|
||||||
Use: "list",
|
|
||||||
Short: "List repository branches",
|
|
||||||
Long: "List branches in a repository, showing protection status.",
|
|
||||||
Example: ` # List branches in the current repository
|
|
||||||
fgj branch list
|
|
||||||
|
|
||||||
# List branches in a specific repository
|
|
||||||
fgj branch list -R owner/repo
|
|
||||||
|
|
||||||
# Output as JSON
|
|
||||||
fgj branch list --json`,
|
|
||||||
RunE: runBranchList,
|
|
||||||
}
|
|
||||||
|
|
||||||
var branchRenameCmd = &cobra.Command{
|
|
||||||
Use: "rename <old-name> <new-name>",
|
|
||||||
Short: "Rename a branch",
|
|
||||||
Long: "Rename a branch in a repository. Requires Forgejo/Gitea support for branch rename (usually present).",
|
|
||||||
Example: ` # Rename a branch in the current repository
|
|
||||||
fgj branch rename old-name new-name
|
|
||||||
|
|
||||||
# Rename a branch in a specific repository
|
|
||||||
fgj branch rename main trunk -R owner/repo`,
|
|
||||||
Args: cobra.ExactArgs(2),
|
|
||||||
RunE: runBranchRename,
|
|
||||||
}
|
|
||||||
|
|
||||||
var branchDeleteCmd = &cobra.Command{
|
|
||||||
Use: "delete <name>",
|
|
||||||
Aliases: []string{"rm"},
|
|
||||||
Short: "Delete a branch",
|
|
||||||
Long: "Delete a branch from a repository. Protected branches cannot be deleted.",
|
|
||||||
Example: ` # Delete a branch
|
|
||||||
fgj branch delete feature/old-work
|
|
||||||
|
|
||||||
# Delete without confirmation
|
|
||||||
fgj branch delete feature/old-work -y`,
|
|
||||||
Args: cobra.ExactArgs(1),
|
|
||||||
RunE: runBranchDelete,
|
|
||||||
}
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
rootCmd.AddCommand(branchCmd)
|
|
||||||
branchCmd.AddCommand(branchListCmd)
|
|
||||||
branchCmd.AddCommand(branchRenameCmd)
|
|
||||||
branchCmd.AddCommand(branchDeleteCmd)
|
|
||||||
|
|
||||||
addRepoFlags(branchListCmd)
|
|
||||||
addJSONFlags(branchListCmd, "Output as JSON")
|
|
||||||
|
|
||||||
addRepoFlags(branchRenameCmd)
|
|
||||||
|
|
||||||
addRepoFlags(branchDeleteCmd)
|
|
||||||
branchDeleteCmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompt")
|
|
||||||
}
|
|
||||||
|
|
||||||
func runBranchList(cmd *cobra.Command, args []string) error {
|
|
||||||
client, owner, name, err := newBranchClient(cmd)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
branches, _, err := client.ListRepoBranches(owner, name, gitea.ListRepoBranchesOptions{})
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to list branches: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if wantJSON(cmd) {
|
|
||||||
return outputJSON(cmd, branches)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(branches) == 0 {
|
|
||||||
fmt.Fprintln(ios.Out, "No branches found")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
tp := ios.NewTablePrinter()
|
|
||||||
tp.AddHeader("NAME", "PROTECTED", "COMMIT")
|
|
||||||
for _, b := range branches {
|
|
||||||
protected := ""
|
|
||||||
if b.Protected {
|
|
||||||
protected = "yes"
|
|
||||||
}
|
|
||||||
sha := ""
|
|
||||||
if b.Commit != nil {
|
|
||||||
if len(b.Commit.ID) >= 7 {
|
|
||||||
sha = b.Commit.ID[:7]
|
|
||||||
} else {
|
|
||||||
sha = b.Commit.ID
|
|
||||||
}
|
|
||||||
}
|
|
||||||
tp.AddRow(b.Name, protected, sha)
|
|
||||||
}
|
|
||||||
return tp.Render()
|
|
||||||
}
|
|
||||||
|
|
||||||
func runBranchRename(cmd *cobra.Command, args []string) error {
|
|
||||||
client, owner, name, err := newBranchClient(cmd)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
oldName, newName := args[0], args[1]
|
|
||||||
|
|
||||||
_, _, err = client.UpdateRepoBranch(owner, name, oldName, gitea.UpdateRepoBranchOption{Name: newName})
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to rename branch %q to %q: %w", oldName, newName, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
cs := ios.ColorScheme()
|
|
||||||
fmt.Fprintf(ios.Out, "%s Renamed branch %q to %q\n", cs.SuccessIcon(), oldName, newName)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func runBranchDelete(cmd *cobra.Command, args []string) error {
|
|
||||||
client, owner, name, err := newBranchClient(cmd)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
branchName := args[0]
|
|
||||||
skipConfirm, _ := cmd.Flags().GetBool("yes")
|
|
||||||
|
|
||||||
if !skipConfirm && ios.IsStdinTTY() {
|
|
||||||
answer, err := promptLine(fmt.Sprintf("Delete branch %q in %s/%s? [y/N]: ", branchName, owner, name))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if answer != "y" && answer != "Y" && answer != "yes" {
|
|
||||||
fmt.Fprintln(ios.ErrOut, "Cancelled.")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ok, _, err := client.DeleteRepoBranch(owner, name, branchName)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to delete branch %q: %w", branchName, err)
|
|
||||||
}
|
|
||||||
if !ok {
|
|
||||||
return fmt.Errorf("branch %q was not deleted (it may be protected or not exist)", branchName)
|
|
||||||
}
|
|
||||||
|
|
||||||
cs := ios.ColorScheme()
|
|
||||||
fmt.Fprintf(ios.Out, "%s Deleted branch %q\n", cs.SuccessIcon(), branchName)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func newBranchClient(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
|
|
||||||
}
|
|
||||||
|
|
@ -1,204 +0,0 @@
|
||||||
package cmd
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"code.gitea.io/sdk/gitea"
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
)
|
|
||||||
|
|
||||||
var branchProtectCmd = &cobra.Command{
|
|
||||||
Use: "protect <branch-name>",
|
|
||||||
Short: "Protect a branch",
|
|
||||||
Long: `Create or update a branch protection rule for the given branch.
|
|
||||||
|
|
||||||
If a protection rule already exists for the branch it is updated in place;
|
|
||||||
otherwise a new one is created. Fields you do not set on the command line
|
|
||||||
are left at their server-side defaults (or left unchanged when editing).`,
|
|
||||||
Example: ` # Require 2 approving reviews before merging
|
|
||||||
fgj branch protect main --require-approvals 2
|
|
||||||
|
|
||||||
# Dismiss stale approvals and require signed commits
|
|
||||||
fgj branch protect main --dismiss-stale-approvals --require-signed-commits
|
|
||||||
|
|
||||||
# Allow specific users to push directly, and require CI contexts
|
|
||||||
fgj branch protect main \
|
|
||||||
--push-whitelist alice,bob \
|
|
||||||
--require-status-checks ci/build,ci/test`,
|
|
||||||
Args: cobra.ExactArgs(1),
|
|
||||||
RunE: runBranchProtect,
|
|
||||||
}
|
|
||||||
|
|
||||||
var branchUnprotectCmd = &cobra.Command{
|
|
||||||
Use: "unprotect <branch-name>",
|
|
||||||
Short: "Remove a branch protection rule",
|
|
||||||
Long: "Remove the protection rule attached to a branch. If the branch has no protection this is a no-op.",
|
|
||||||
Example: ` # Remove protection interactively
|
|
||||||
fgj branch unprotect main
|
|
||||||
|
|
||||||
# Remove without confirmation
|
|
||||||
fgj branch unprotect main -y`,
|
|
||||||
Args: cobra.ExactArgs(1),
|
|
||||||
RunE: runBranchUnprotect,
|
|
||||||
}
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
branchCmd.AddCommand(branchProtectCmd)
|
|
||||||
branchCmd.AddCommand(branchUnprotectCmd)
|
|
||||||
|
|
||||||
addRepoFlags(branchProtectCmd)
|
|
||||||
branchProtectCmd.Flags().Int64("require-approvals", 0, "Minimum number of approving reviews required")
|
|
||||||
branchProtectCmd.Flags().Bool("dismiss-stale-approvals", false, "Dismiss stale approvals when new commits are pushed")
|
|
||||||
branchProtectCmd.Flags().Bool("require-signed-commits", false, "Require commits on the branch to be signed")
|
|
||||||
branchProtectCmd.Flags().Bool("block-on-rejected-reviews", false, "Block merges when a review requests changes")
|
|
||||||
branchProtectCmd.Flags().Bool("block-on-outdated-branch", false, "Require the PR branch to be up-to-date with the base")
|
|
||||||
branchProtectCmd.Flags().StringSlice("push-whitelist", nil, "Usernames allowed to push directly (comma-separated or repeatable)")
|
|
||||||
branchProtectCmd.Flags().StringSlice("merge-whitelist", nil, "Usernames allowed to merge (comma-separated or repeatable)")
|
|
||||||
branchProtectCmd.Flags().StringSlice("require-status-checks", nil, "CI status contexts that must pass (comma-separated or repeatable)")
|
|
||||||
addJSONFlags(branchProtectCmd, "Output the resulting protection rule as JSON")
|
|
||||||
|
|
||||||
addRepoFlags(branchUnprotectCmd)
|
|
||||||
branchUnprotectCmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompt")
|
|
||||||
}
|
|
||||||
|
|
||||||
func runBranchProtect(cmd *cobra.Command, args []string) error {
|
|
||||||
client, owner, name, err := newBranchClient(cmd)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
branchName := args[0]
|
|
||||||
|
|
||||||
requireApprovals, _ := cmd.Flags().GetInt64("require-approvals")
|
|
||||||
dismissStale, _ := cmd.Flags().GetBool("dismiss-stale-approvals")
|
|
||||||
requireSigned, _ := cmd.Flags().GetBool("require-signed-commits")
|
|
||||||
blockRejected, _ := cmd.Flags().GetBool("block-on-rejected-reviews")
|
|
||||||
blockOutdated, _ := cmd.Flags().GetBool("block-on-outdated-branch")
|
|
||||||
pushWhitelist, _ := cmd.Flags().GetStringSlice("push-whitelist")
|
|
||||||
mergeWhitelist, _ := cmd.Flags().GetStringSlice("merge-whitelist")
|
|
||||||
statusChecks, _ := cmd.Flags().GetStringSlice("require-status-checks")
|
|
||||||
|
|
||||||
// Check whether a protection rule already exists for this branch.
|
|
||||||
existing, resp, getErr := client.GetBranchProtection(owner, name, branchName)
|
|
||||||
exists := getErr == nil && existing != nil
|
|
||||||
if getErr != nil && !isNotFound(resp, getErr) {
|
|
||||||
return fmt.Errorf("failed to look up branch protection for %q: %w", branchName, getErr)
|
|
||||||
}
|
|
||||||
|
|
||||||
var result *gitea.BranchProtection
|
|
||||||
|
|
||||||
if exists {
|
|
||||||
edit := gitea.EditBranchProtectionOption{
|
|
||||||
RequiredApprovals: &requireApprovals,
|
|
||||||
DismissStaleApprovals: &dismissStale,
|
|
||||||
RequireSignedCommits: &requireSigned,
|
|
||||||
BlockOnRejectedReviews: &blockRejected,
|
|
||||||
BlockOnOutdatedBranch: &blockOutdated,
|
|
||||||
}
|
|
||||||
if len(pushWhitelist) > 0 {
|
|
||||||
enable := true
|
|
||||||
edit.EnablePushWhitelist = &enable
|
|
||||||
edit.PushWhitelistUsernames = pushWhitelist
|
|
||||||
}
|
|
||||||
if len(mergeWhitelist) > 0 {
|
|
||||||
enable := true
|
|
||||||
edit.EnableMergeWhitelist = &enable
|
|
||||||
edit.MergeWhitelistUsernames = mergeWhitelist
|
|
||||||
}
|
|
||||||
if len(statusChecks) > 0 {
|
|
||||||
enable := true
|
|
||||||
edit.EnableStatusCheck = &enable
|
|
||||||
edit.StatusCheckContexts = statusChecks
|
|
||||||
}
|
|
||||||
|
|
||||||
bp, _, err := client.EditBranchProtection(owner, name, branchName, edit)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to update branch protection for %q: %w", branchName, err)
|
|
||||||
}
|
|
||||||
result = bp
|
|
||||||
} else {
|
|
||||||
create := gitea.CreateBranchProtectionOption{
|
|
||||||
BranchName: branchName,
|
|
||||||
RequiredApprovals: requireApprovals,
|
|
||||||
DismissStaleApprovals: dismissStale,
|
|
||||||
RequireSignedCommits: requireSigned,
|
|
||||||
BlockOnRejectedReviews: blockRejected,
|
|
||||||
BlockOnOutdatedBranch: blockOutdated,
|
|
||||||
}
|
|
||||||
if len(pushWhitelist) > 0 {
|
|
||||||
create.EnablePushWhitelist = true
|
|
||||||
create.PushWhitelistUsernames = pushWhitelist
|
|
||||||
}
|
|
||||||
if len(mergeWhitelist) > 0 {
|
|
||||||
create.EnableMergeWhitelist = true
|
|
||||||
create.MergeWhitelistUsernames = mergeWhitelist
|
|
||||||
}
|
|
||||||
if len(statusChecks) > 0 {
|
|
||||||
create.EnableStatusCheck = true
|
|
||||||
create.StatusCheckContexts = statusChecks
|
|
||||||
}
|
|
||||||
|
|
||||||
bp, _, err := client.CreateBranchProtection(owner, name, create)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to create branch protection for %q: %w", branchName, err)
|
|
||||||
}
|
|
||||||
result = bp
|
|
||||||
}
|
|
||||||
|
|
||||||
if wantJSON(cmd) {
|
|
||||||
return outputJSON(cmd, result)
|
|
||||||
}
|
|
||||||
|
|
||||||
cs := ios.ColorScheme()
|
|
||||||
fmt.Fprintf(ios.Out, "%s Protected branch %q\n", cs.SuccessIcon(), branchName)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func runBranchUnprotect(cmd *cobra.Command, args []string) error {
|
|
||||||
client, owner, name, err := newBranchClient(cmd)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
branchName := args[0]
|
|
||||||
skipConfirm, _ := cmd.Flags().GetBool("yes")
|
|
||||||
|
|
||||||
if !skipConfirm && ios.IsStdinTTY() {
|
|
||||||
answer, err := promptLine(fmt.Sprintf("Remove protection from branch %q in %s/%s? [y/N]: ", branchName, owner, name))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if answer != "y" && answer != "Y" && answer != "yes" {
|
|
||||||
fmt.Fprintln(ios.ErrOut, "Cancelled.")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := client.DeleteBranchProtection(owner, name, branchName)
|
|
||||||
if err != nil {
|
|
||||||
if isNotFound(resp, err) {
|
|
||||||
fmt.Fprintf(ios.Out, "Branch %q has no protection rule; nothing to do.\n", branchName)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return fmt.Errorf("failed to remove branch protection for %q: %w", branchName, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
cs := ios.ColorScheme()
|
|
||||||
fmt.Fprintf(ios.Out, "%s Removed protection from branch %q\n", cs.SuccessIcon(), branchName)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// isNotFound reports whether a gitea SDK call failed with a 404. The SDK
|
|
||||||
// sometimes returns a nil Response on transport-level errors, so we fall back
|
|
||||||
// to a string check on the error message in that case.
|
|
||||||
func isNotFound(resp *gitea.Response, err error) bool {
|
|
||||||
if err == nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if resp != nil && resp.Response != nil && resp.StatusCode == http.StatusNotFound {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return strings.Contains(err.Error(), "404")
|
|
||||||
}
|
|
||||||
|
|
@ -11,7 +11,7 @@ import (
|
||||||
var completionCmd = &cobra.Command{
|
var completionCmd = &cobra.Command{
|
||||||
Use: "completion [bash|zsh|fish|powershell]",
|
Use: "completion [bash|zsh|fish|powershell]",
|
||||||
Short: "Generate shell completion scripts",
|
Short: "Generate shell completion scripts",
|
||||||
Long: "Generate shell completion scripts for fgj.",
|
Long: "Generate shell completion scripts for fj.",
|
||||||
Args: cobra.MatchAll(cobra.ExactArgs(1), cobra.OnlyValidArgs),
|
Args: cobra.MatchAll(cobra.ExactArgs(1), cobra.OnlyValidArgs),
|
||||||
ValidArgs: []string{"bash", "zsh", "fish", "powershell"},
|
ValidArgs: []string{"bash", "zsh", "fish", "powershell"},
|
||||||
DisableFlagsInUseLine: true,
|
DisableFlagsInUseLine: true,
|
||||||
|
|
|
||||||
|
|
@ -1,188 +0,0 @@
|
||||||
package cmd
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
"path/filepath"
|
|
||||||
"runtime"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
)
|
|
||||||
|
|
||||||
var completionInstallCmd = &cobra.Command{
|
|
||||||
Use: "install [shell]",
|
|
||||||
Short: "Install shell completions to a standard location",
|
|
||||||
Long: "Install shell completions for fgj to a shell-appropriate location. If [shell] is omitted, it is detected from $SHELL. Supported: bash, zsh, fish.",
|
|
||||||
Args: cobra.MaximumNArgs(1),
|
|
||||||
ValidArgs: []string{"bash", "zsh", "fish"},
|
|
||||||
RunE: runCompletionInstall,
|
|
||||||
}
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
completionCmd.AddCommand(completionInstallCmd)
|
|
||||||
completionInstallCmd.Flags().Bool("system", false, "Install system-wide (bash only; prints required sudo command)")
|
|
||||||
completionInstallCmd.Flags().Bool("dry-run", false, "Print the target path and exit without writing")
|
|
||||||
}
|
|
||||||
|
|
||||||
func runCompletionInstall(cmd *cobra.Command, args []string) error {
|
|
||||||
shell := ""
|
|
||||||
if len(args) == 1 {
|
|
||||||
shell = args[0]
|
|
||||||
} else {
|
|
||||||
detected, err := detectShell()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
shell = detected
|
|
||||||
}
|
|
||||||
|
|
||||||
switch shell {
|
|
||||||
case "bash", "zsh", "fish":
|
|
||||||
// supported
|
|
||||||
case "powershell":
|
|
||||||
return fmt.Errorf("powershell auto-install is not supported; run `fgj completion powershell` and save the output manually")
|
|
||||||
default:
|
|
||||||
return fmt.Errorf("unsupported shell: %s (supported: bash, zsh, fish)", shell)
|
|
||||||
}
|
|
||||||
|
|
||||||
system, _ := cmd.Flags().GetBool("system")
|
|
||||||
dryRun, _ := cmd.Flags().GetBool("dry-run")
|
|
||||||
|
|
||||||
// Generate completion script into a buffer.
|
|
||||||
var buf bytes.Buffer
|
|
||||||
switch shell {
|
|
||||||
case "bash":
|
|
||||||
if err := rootCmd.GenBashCompletion(&buf); err != nil {
|
|
||||||
return fmt.Errorf("failed to generate bash completions: %w", err)
|
|
||||||
}
|
|
||||||
case "zsh":
|
|
||||||
if err := rootCmd.GenZshCompletion(&buf); err != nil {
|
|
||||||
return fmt.Errorf("failed to generate zsh completions: %w", err)
|
|
||||||
}
|
|
||||||
case "fish":
|
|
||||||
if err := rootCmd.GenFishCompletion(&buf, true); err != nil {
|
|
||||||
return fmt.Errorf("failed to generate fish completions: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resolve the target path.
|
|
||||||
targetPath, sudoHint, err := completionTargetPath(shell, system)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if sudoHint != "" {
|
|
||||||
// System-wide bash path requires root. Print the command and exit.
|
|
||||||
fmt.Fprintf(ios.Out, "System-wide install requires root. Run:\n %s\n", sudoHint)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if dryRun {
|
|
||||||
fmt.Fprintln(ios.Out, targetPath)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create parent directory.
|
|
||||||
if err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil {
|
|
||||||
return fmt.Errorf("failed to create %s: %w", filepath.Dir(targetPath), err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Idempotent write: if the file already exists and matches, skip.
|
|
||||||
if existing, err := os.ReadFile(targetPath); err == nil {
|
|
||||||
if bytes.Equal(existing, buf.Bytes()) {
|
|
||||||
fmt.Fprintf(ios.Out, "Completion already installed at %s (up to date)\n", targetPath)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := os.WriteFile(targetPath, buf.Bytes(), 0o644); err != nil {
|
|
||||||
return fmt.Errorf("failed to write %s: %w", targetPath, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
cs := ios.ColorScheme()
|
|
||||||
fmt.Fprintf(ios.Out, "%s Installed %s completions to %s\n", cs.SuccessIcon(), shell, targetPath)
|
|
||||||
|
|
||||||
if shell == "zsh" {
|
|
||||||
fmt.Fprintln(ios.Out, "")
|
|
||||||
fmt.Fprintln(ios.Out, "If you haven't already, add the following to your ~/.zshrc:")
|
|
||||||
fmt.Fprintln(ios.Out, " fpath=(~/.zsh/completions $fpath)")
|
|
||||||
fmt.Fprintln(ios.Out, " autoload -U compinit && compinit")
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// completionTargetPath resolves the target install path for the given shell.
|
|
||||||
// For bash system-wide installs, it returns an empty path and a sudo hint command
|
|
||||||
// that the caller should print instead of writing.
|
|
||||||
func completionTargetPath(shell string, system bool) (string, string, error) {
|
|
||||||
home, err := os.UserHomeDir()
|
|
||||||
if err != nil {
|
|
||||||
return "", "", fmt.Errorf("failed to resolve home directory: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
switch shell {
|
|
||||||
case "bash":
|
|
||||||
if system {
|
|
||||||
if _, err := os.Stat("/etc/bash_completion.d"); err == nil {
|
|
||||||
exe, exeErr := os.Executable()
|
|
||||||
if exeErr != nil || exe == "" {
|
|
||||||
exe = "fgj"
|
|
||||||
}
|
|
||||||
sudo := fmt.Sprintf("sudo sh -c '%s completion bash > /etc/bash_completion.d/fgj'", exe)
|
|
||||||
return "", sudo, nil
|
|
||||||
}
|
|
||||||
return "", "", fmt.Errorf("--system requested but /etc/bash_completion.d does not exist")
|
|
||||||
}
|
|
||||||
if runtime.GOOS == "darwin" {
|
|
||||||
if prefix, ok := brewPrefix(); ok {
|
|
||||||
return filepath.Join(prefix, "etc", "bash_completion.d", "fgj"), "", nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
xdg := os.Getenv("XDG_DATA_HOME")
|
|
||||||
if xdg == "" {
|
|
||||||
xdg = filepath.Join(home, ".local", "share")
|
|
||||||
}
|
|
||||||
return filepath.Join(xdg, "bash-completion", "completions", "fgj"), "", nil
|
|
||||||
|
|
||||||
case "zsh":
|
|
||||||
zdot := os.Getenv("ZDOTDIR")
|
|
||||||
if zdot == "" {
|
|
||||||
zdot = home
|
|
||||||
}
|
|
||||||
return filepath.Join(zdot, ".zsh", "completions", "_fgj"), "", nil
|
|
||||||
|
|
||||||
case "fish":
|
|
||||||
return filepath.Join(home, ".config", "fish", "completions", "fgj.fish"), "", nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return "", "", fmt.Errorf("unsupported shell: %s", shell)
|
|
||||||
}
|
|
||||||
|
|
||||||
// detectShell reads $SHELL and returns its basename.
|
|
||||||
func detectShell() (string, error) {
|
|
||||||
s := os.Getenv("SHELL")
|
|
||||||
if s == "" {
|
|
||||||
return "", fmt.Errorf("cannot detect shell: $SHELL is empty (pass shell name explicitly)")
|
|
||||||
}
|
|
||||||
return filepath.Base(s), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// brewPrefix returns the Homebrew prefix if brew is available on PATH.
|
|
||||||
func brewPrefix() (string, bool) {
|
|
||||||
if _, err := exec.LookPath("brew"); err != nil {
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
out, err := exec.Command("brew", "--prefix").Output()
|
|
||||||
if err != nil {
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
prefix := strings.TrimSpace(string(out))
|
|
||||||
if prefix == "" {
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
return prefix, true
|
|
||||||
}
|
|
||||||
|
|
@ -3,10 +3,9 @@ package cmd
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"forgejo.zerova.net/public/fgj-sid/internal/api"
|
"forgejo.zerova.net/public/fj/internal/api"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Error codes for structured error output.
|
// Error codes for structured error output.
|
||||||
|
|
@ -25,9 +24,15 @@ type CLIError struct {
|
||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
Detail string `json:"detail,omitempty"`
|
Detail string `json:"detail,omitempty"`
|
||||||
Status int `json:"status,omitempty"`
|
Status int `json:"status,omitempty"`
|
||||||
|
// Hint is a separate field so JSON consumers get clean structure and
|
||||||
|
// the human renderer can append "Hint: ..." without polluting Message.
|
||||||
|
Hint string `json:"hint,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *CLIError) Error() string {
|
func (e *CLIError) Error() string {
|
||||||
|
if e.Hint != "" {
|
||||||
|
return e.Message + "\nHint: " + e.Hint
|
||||||
|
}
|
||||||
return e.Message
|
return e.Message
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -42,46 +47,59 @@ func NewAPIError(status int, message string) *CLIError {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ContextualError wraps common errors with helpful hints.
|
// ContextualError wraps common errors with helpful hints.
|
||||||
|
//
|
||||||
|
// Auth/404 hints come exclusively from a typed *api.APIError now — we used
|
||||||
|
// to substring-match "401"/"403" against the rendered error string, which
|
||||||
|
// would trigger an "auth login" hint for any error mentioning issue #403.
|
||||||
|
// If the API client doesn't surface APIError, no hint is added; that's a
|
||||||
|
// signal to fix the client wrapper, not to layer regex on top.
|
||||||
func ContextualError(err error) error {
|
func ContextualError(err error) error {
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
msg := err.Error()
|
// If the error chain already holds a CLIError, leave it — it owns its
|
||||||
|
// Code/Hint already.
|
||||||
// Check for API errors with status codes
|
var cErr *CLIError
|
||||||
var apiErr *api.APIError
|
if errors.As(err, &cErr) {
|
||||||
if errors.As(err, &apiErr) {
|
|
||||||
switch {
|
|
||||||
case apiErr.StatusCode == 401 || apiErr.StatusCode == 403:
|
|
||||||
return fmt.Errorf("%w\nHint: Try authenticating with: fgj auth login", err)
|
|
||||||
case apiErr.StatusCode == 404:
|
|
||||||
return fmt.Errorf("%w\nHint: Resource not found. Check the repository and number are correct.", err)
|
|
||||||
}
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for network/connection errors
|
var apiErr *api.APIError
|
||||||
switch {
|
if errors.As(err, &apiErr) {
|
||||||
case strings.Contains(msg, "no such host"):
|
c := &CLIError{
|
||||||
return fmt.Errorf("%w\nHint: Check your internet connection and that the host is correct.", err)
|
Code: ErrAPIError,
|
||||||
case strings.Contains(msg, "connection refused"):
|
Message: err.Error(),
|
||||||
return fmt.Errorf("%w\nHint: Check your internet connection and that the host is correct.", err)
|
Status: apiErr.StatusCode,
|
||||||
|
Detail: apiErr.Body,
|
||||||
|
}
|
||||||
|
switch apiErr.StatusCode {
|
||||||
|
case 401, 403:
|
||||||
|
c.Code = ErrAuthRequired
|
||||||
|
c.Hint = "Try authenticating with: fj auth login"
|
||||||
|
case 404:
|
||||||
|
c.Code = ErrNotFound
|
||||||
|
c.Hint = "Resource not found. Check the repository and number are correct."
|
||||||
|
}
|
||||||
|
return c
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for string-based status code patterns (from wrapped errors)
|
// Plain network errors come back as fmt.Errorf strings from net/http.
|
||||||
|
msg := err.Error()
|
||||||
switch {
|
switch {
|
||||||
case strings.Contains(msg, "401") || strings.Contains(msg, "403"):
|
case strings.Contains(msg, "no such host"),
|
||||||
if strings.Contains(msg, "authentication") || strings.Contains(msg, "unauthorized") || strings.Contains(msg, "forbidden") {
|
strings.Contains(msg, "connection refused"),
|
||||||
return fmt.Errorf("%w\nHint: Try authenticating with: fgj auth login", err)
|
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
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// writeJSONError writes a structured JSON error to stderr.
|
|
||||||
// It attempts to extract structured info from known error types.
|
|
||||||
// WriteJSONError writes a structured JSON error to stderr.
|
// 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) {
|
||||||
|
|
@ -90,7 +108,9 @@ func WriteJSONError(err error) {
|
||||||
Message: err.Error(),
|
Message: err.Error(),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to extract structured info from the error chain.
|
// Try to extract structured info from the error chain. Prefer CLIError
|
||||||
|
// (which carries Hint cleanly) over APIError so a wrapped CLIError
|
||||||
|
// keeps its structured fields.
|
||||||
var apiErr *api.APIError
|
var apiErr *api.APIError
|
||||||
var cErr *CLIError
|
var cErr *CLIError
|
||||||
|
|
||||||
|
|
@ -105,8 +125,6 @@ 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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -114,3 +132,6 @@ func WriteJSONError(err error) {
|
||||||
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 +1,5 @@
|
||||||
package cmd
|
package cmd
|
||||||
|
|
||||||
import "forgejo.zerova.net/public/fgj-sid/internal/iostreams"
|
import "forgejo.zerova.net/public/fj/internal/iostreams"
|
||||||
|
|
||||||
var ios = iostreams.New()
|
var ios = iostreams.New()
|
||||||
|
|
|
||||||
103
cmd/issue.go
103
cmd/issue.go
|
|
@ -4,12 +4,11 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
|
||||||
|
|
||||||
"code.gitea.io/sdk/gitea"
|
"code.gitea.io/sdk/gitea"
|
||||||
"forgejo.zerova.net/public/fgj-sid/internal/api"
|
"forgejo.zerova.net/public/fj/internal/api"
|
||||||
"forgejo.zerova.net/public/fgj-sid/internal/config"
|
"forgejo.zerova.net/public/fj/internal/config"
|
||||||
"forgejo.zerova.net/public/fgj-sid/internal/text"
|
"forgejo.zerova.net/public/fj/internal/text"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -24,19 +23,13 @@ var issueListCmd = &cobra.Command{
|
||||||
Short: "List issues",
|
Short: "List issues",
|
||||||
Long: "List issues in a repository.",
|
Long: "List issues in a repository.",
|
||||||
Example: ` # List open issues
|
Example: ` # List open issues
|
||||||
fgj issue list
|
fj issue list
|
||||||
|
|
||||||
# List closed issues for a specific repo
|
# List closed issues for a specific repo
|
||||||
fgj issue list -s closed -R owner/repo
|
fj issue list -s closed -R owner/repo
|
||||||
|
|
||||||
# Output as JSON
|
# Output as JSON
|
||||||
fgj issue list --json
|
fj issue list --json`,
|
||||||
|
|
||||||
# PRs updated in the last 7 days
|
|
||||||
fgj pr list --since 7d
|
|
||||||
|
|
||||||
# Issues touched between two dates
|
|
||||||
fgj issue list --since 2026-04-01 --before 2026-04-15`,
|
|
||||||
RunE: runIssueList,
|
RunE: runIssueList,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -45,16 +38,16 @@ var issueViewCmd = &cobra.Command{
|
||||||
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
|
Example: ` # View issue #42
|
||||||
fgj issue view 42
|
fj issue view 42
|
||||||
|
|
||||||
# View using URL
|
# View using URL
|
||||||
fgj issue view https://codeberg.org/owner/repo/issues/42
|
fj issue view https://codeberg.org/owner/repo/issues/42
|
||||||
|
|
||||||
# Open in browser
|
# Open in browser
|
||||||
fgj issue view 42 --web
|
fj issue view 42 --web
|
||||||
|
|
||||||
# View an issue from a specific repo as JSON
|
# View an issue from a specific repo as JSON
|
||||||
fgj issue view 42 -R owner/repo --json`,
|
fj issue view 42 -R owner/repo --json`,
|
||||||
Args: cobra.ExactArgs(1),
|
Args: cobra.ExactArgs(1),
|
||||||
RunE: runIssueView,
|
RunE: runIssueView,
|
||||||
}
|
}
|
||||||
|
|
@ -64,10 +57,10 @@ var issueCreateCmd = &cobra.Command{
|
||||||
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
|
Example: ` # Create an issue with a title
|
||||||
fgj issue create -t "Fix login bug"
|
fj issue create -t "Fix login bug"
|
||||||
|
|
||||||
# Create an issue with title, body, and labels
|
# Create an issue with title, body, and labels
|
||||||
fgj issue create -t "Add dark mode" -b "We need a dark theme" -l feature -l ui`,
|
fj issue create -t "Add dark mode" -b "We need a dark theme" -l feature -l ui`,
|
||||||
RunE: runIssueCreate,
|
RunE: runIssueCreate,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -76,10 +69,10 @@ var issueCommentCmd = &cobra.Command{
|
||||||
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
|
Example: ` # Add a comment to issue #42
|
||||||
fgj issue comment 42 -b "This is fixed in the latest release"
|
fj issue comment 42 -b "This is fixed in the latest release"
|
||||||
|
|
||||||
# Comment on an issue in a specific repo
|
# Comment on an issue in a specific repo
|
||||||
fgj issue comment 10 -b "Confirmed on my end" -R owner/repo`,
|
fj issue comment 10 -b "Confirmed on my end" -R owner/repo`,
|
||||||
Args: cobra.ExactArgs(1),
|
Args: cobra.ExactArgs(1),
|
||||||
RunE: runIssueComment,
|
RunE: runIssueComment,
|
||||||
}
|
}
|
||||||
|
|
@ -89,10 +82,10 @@ var issueCloseCmd = &cobra.Command{
|
||||||
Short: "Close an issue",
|
Short: "Close an issue",
|
||||||
Long: "Close an existing issue.",
|
Long: "Close an existing issue.",
|
||||||
Example: ` # Close issue #42
|
Example: ` # Close issue #42
|
||||||
fgj issue close 42
|
fj issue close 42
|
||||||
|
|
||||||
# Close with a comment
|
# Close with a comment
|
||||||
fgj issue close 42 -c "Fixed in commit abc1234"`,
|
fj issue close 42 -c "Fixed in commit abc1234"`,
|
||||||
Args: cobra.ExactArgs(1),
|
Args: cobra.ExactArgs(1),
|
||||||
RunE: runIssueClose,
|
RunE: runIssueClose,
|
||||||
}
|
}
|
||||||
|
|
@ -102,7 +95,7 @@ var issueReopenCmd = &cobra.Command{
|
||||||
Short: "Reopen an issue",
|
Short: "Reopen an issue",
|
||||||
Long: "Reopen a closed issue.",
|
Long: "Reopen a closed issue.",
|
||||||
Example: ` # Reopen issue #42
|
Example: ` # Reopen issue #42
|
||||||
fgj issue reopen 42`,
|
fj issue reopen 42`,
|
||||||
Args: cobra.ExactArgs(1),
|
Args: cobra.ExactArgs(1),
|
||||||
RunE: runIssueReopen,
|
RunE: runIssueReopen,
|
||||||
}
|
}
|
||||||
|
|
@ -112,10 +105,10 @@ var issueDeleteCmd = &cobra.Command{
|
||||||
Short: "Delete an issue",
|
Short: "Delete an issue",
|
||||||
Long: "Delete an issue permanently.",
|
Long: "Delete an issue permanently.",
|
||||||
Example: ` # Delete issue #42
|
Example: ` # Delete issue #42
|
||||||
fgj issue delete 42
|
fj issue delete 42
|
||||||
|
|
||||||
# Delete without confirmation
|
# Delete without confirmation
|
||||||
fgj issue delete 42 -y`,
|
fj issue delete 42 -y`,
|
||||||
Args: cobra.ExactArgs(1),
|
Args: cobra.ExactArgs(1),
|
||||||
RunE: runIssueDelete,
|
RunE: runIssueDelete,
|
||||||
}
|
}
|
||||||
|
|
@ -125,16 +118,16 @@ var issueEditCmd = &cobra.Command{
|
||||||
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
|
Example: ` # Update the title of issue #42
|
||||||
fgj issue edit 42 -t "Updated title"
|
fj issue edit 42 -t "Updated title"
|
||||||
|
|
||||||
# Reopen a closed issue
|
# Reopen a closed issue
|
||||||
fgj issue edit 42 -s open
|
fj issue edit 42 -s open
|
||||||
|
|
||||||
# Add and remove labels
|
# Add and remove labels
|
||||||
fgj issue edit 42 --add-label bug --remove-label wontfix
|
fj issue edit 42 --add-label bug --remove-label wontfix
|
||||||
|
|
||||||
# Add a dependency
|
# Add a dependency
|
||||||
fgj issue edit 42 --add-dependency 10`,
|
fj issue edit 42 --add-dependency 10`,
|
||||||
Args: cobra.ExactArgs(1),
|
Args: cobra.ExactArgs(1),
|
||||||
RunE: runIssueEdit,
|
RunE: runIssueEdit,
|
||||||
}
|
}
|
||||||
|
|
@ -159,8 +152,6 @@ func init() {
|
||||||
issueListCmd.Flags().String("author", "", "Filter by author username")
|
issueListCmd.Flags().String("author", "", "Filter by author username")
|
||||||
issueListCmd.Flags().StringSliceP("label", "l", nil, "Filter by label names")
|
issueListCmd.Flags().StringSliceP("label", "l", nil, "Filter by label names")
|
||||||
issueListCmd.Flags().StringP("search", "S", "", "Search keyword filter")
|
issueListCmd.Flags().StringP("search", "S", "", "Search keyword filter")
|
||||||
issueListCmd.Flags().String("since", "", "Only items updated at or after this date (YYYY-MM-DD, RFC 3339, or relative like 7d)")
|
|
||||||
issueListCmd.Flags().String("before", "", "Only items updated strictly before this date (YYYY-MM-DD, RFC 3339, or relative like 1d)")
|
|
||||||
addJSONFlags(issueListCmd, "Output issues as JSON")
|
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")
|
||||||
|
|
@ -201,24 +192,6 @@ func runIssueList(cmd *cobra.Command, args []string) error {
|
||||||
author, _ := cmd.Flags().GetString("author")
|
author, _ := cmd.Flags().GetString("author")
|
||||||
labels, _ := cmd.Flags().GetStringSlice("label")
|
labels, _ := cmd.Flags().GetStringSlice("label")
|
||||||
search, _ := cmd.Flags().GetString("search")
|
search, _ := cmd.Flags().GetString("search")
|
||||||
sinceStr, _ := cmd.Flags().GetString("since")
|
|
||||||
beforeStr, _ := cmd.Flags().GetString("before")
|
|
||||||
|
|
||||||
var sinceTime, beforeTime time.Time
|
|
||||||
if sinceStr != "" {
|
|
||||||
t, err := parseDateArg(sinceStr)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("invalid --since: %w", err)
|
|
||||||
}
|
|
||||||
sinceTime = t
|
|
||||||
}
|
|
||||||
if beforeStr != "" {
|
|
||||||
t, err := parseDateArg(beforeStr)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("invalid --before: %w", err)
|
|
||||||
}
|
|
||||||
beforeTime = t
|
|
||||||
}
|
|
||||||
|
|
||||||
owner, name, err := parseRepo(repo)
|
owner, name, err := parseRepo(repo)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -248,15 +221,24 @@ func runIssueList(cmd *cobra.Command, args []string) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
ios.StartSpinner("Fetching issues...")
|
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
|
||||||
Labels: labels,
|
// — overshoot 2x as a heuristic. paginateGitea(0, ...) would be safer
|
||||||
KeyWord: search,
|
// but spends extra round-trips; keep it bounded.
|
||||||
CreatedBy: author,
|
fetchLimit := limit * 2
|
||||||
AssignedBy: assignee,
|
if fetchLimit < 50 {
|
||||||
Since: sinceTime,
|
fetchLimit = 50
|
||||||
Before: beforeTime,
|
}
|
||||||
ListOptions: gitea.ListOptions{PageSize: limit},
|
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()
|
ios.StopSpinner()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -269,6 +251,9 @@ 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 wantJSON(cmd) {
|
||||||
return outputJSON(cmd, nonPRIssues)
|
return outputJSON(cmd, nonPRIssues)
|
||||||
|
|
|
||||||
49
cmd/json.go
49
cmd/json.go
|
|
@ -10,47 +10,48 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
// addJSONFlags adds --json, --json-fields, and --jq flags to a command.
|
// addJSONFlags adds --json, --json-fields, and --jq flags to a command.
|
||||||
// --json is an optional-value string flag:
|
|
||||||
// - --json (no value) → output all fields as JSON
|
|
||||||
// - --json title,state → output only those fields (gh-compatible)
|
|
||||||
//
|
//
|
||||||
// --json-fields is kept as a backwards-compatible alias.
|
// Flag design (BREAKING CHANGE — the previous --json was a string with
|
||||||
|
// NoOptDefVal=" " so `--json=fields` projected and `--json` alone meant
|
||||||
|
// "everything". That sentinel produced a `--json string[=" "]` in --help
|
||||||
|
// and left users guessing about the equals sign). Now:
|
||||||
|
//
|
||||||
|
// - --json : Bool. "Output the response as JSON." (all fields)
|
||||||
|
// - --json-fields … : String. Comma-separated projection.
|
||||||
|
// - --jq … : String. jq expression filter.
|
||||||
|
//
|
||||||
|
// --json and --json-fields are mutually exclusive — pick one. --jq composes
|
||||||
|
// with either (or neither, in which case it implies "as JSON").
|
||||||
func addJSONFlags(cmd *cobra.Command, jsonDesc string) {
|
func addJSONFlags(cmd *cobra.Command, jsonDesc string) {
|
||||||
f := cmd.Flags()
|
f := cmd.Flags()
|
||||||
f.String("json", "", jsonDesc)
|
f.Bool("json", false, jsonDesc)
|
||||||
f.Lookup("json").NoOptDefVal = " " // space sentinel: flag present with no value
|
f.String("json-fields", "", "Output as JSON, projecting only these comma-separated fields")
|
||||||
f.String("json-fields", "", "Comma-separated list of JSON fields to include")
|
|
||||||
f.String("jq", "", "Filter JSON output using a jq expression")
|
f.String("jq", "", "Filter JSON output using a jq expression")
|
||||||
|
cmd.MarkFlagsMutuallyExclusive("json", "json-fields")
|
||||||
}
|
}
|
||||||
|
|
||||||
// wantJSON returns true if the user requested JSON output via --json, --json-fields, or --jq.
|
// wantJSON returns true if the user requested JSON output via --json,
|
||||||
|
// --json-fields, or --jq.
|
||||||
func wantJSON(cmd *cobra.Command) bool {
|
func wantJSON(cmd *cobra.Command) bool {
|
||||||
if j, _ := cmd.Flags().GetString("json"); j != "" {
|
if b, _ := cmd.Flags().GetBool("json"); b {
|
||||||
return true
|
|
||||||
}
|
|
||||||
if jq, _ := cmd.Flags().GetString("jq"); jq != "" {
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
if f, _ := cmd.Flags().GetString("json-fields"); f != "" {
|
if f, _ := cmd.Flags().GetString("json-fields"); f != "" {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
if jq, _ := cmd.Flags().GetString("jq"); jq != "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// outputJSON writes a value as JSON, respecting --json, --json-fields, and --jq flags.
|
// outputJSON writes a value as JSON, respecting --json-fields and --jq.
|
||||||
|
// --json (the bool) is the "no projection, no filter" signal handled
|
||||||
|
// implicitly: when neither --json-fields nor --jq is set, the whole value
|
||||||
|
// is emitted.
|
||||||
func outputJSON(cmd *cobra.Command, value any) error {
|
func outputJSON(cmd *cobra.Command, value any) error {
|
||||||
jsonVal, _ := cmd.Flags().GetString("json")
|
fields, _ := cmd.Flags().GetString("json-fields")
|
||||||
jsonFields, _ := cmd.Flags().GetString("json-fields")
|
|
||||||
jqExpr, _ := cmd.Flags().GetString("jq")
|
jqExpr, _ := cmd.Flags().GetString("jq")
|
||||||
|
|
||||||
fields := ""
|
|
||||||
jsonVal = strings.TrimSpace(jsonVal)
|
|
||||||
if jsonVal != "" {
|
|
||||||
fields = jsonVal
|
|
||||||
} else if jsonFields != "" {
|
|
||||||
fields = jsonFields
|
|
||||||
}
|
|
||||||
|
|
||||||
return writeJSONFiltered(value, fields, jqExpr)
|
return writeJSONFiltered(value, fields, jqExpr)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
35
cmd/label.go
35
cmd/label.go
|
|
@ -5,8 +5,8 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"code.gitea.io/sdk/gitea"
|
"code.gitea.io/sdk/gitea"
|
||||||
"forgejo.zerova.net/public/fgj-sid/internal/api"
|
"forgejo.zerova.net/public/fj/internal/api"
|
||||||
"forgejo.zerova.net/public/fgj-sid/internal/config"
|
"forgejo.zerova.net/public/fj/internal/config"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -21,13 +21,13 @@ var labelListCmd = &cobra.Command{
|
||||||
Short: "List labels for a repository",
|
Short: "List labels for a repository",
|
||||||
Long: "List all labels defined in a repository.",
|
Long: "List all labels defined in a repository.",
|
||||||
Example: ` # List labels for the current repository
|
Example: ` # List labels for the current repository
|
||||||
fgj label list
|
fj label list
|
||||||
|
|
||||||
# List labels for a specific repository
|
# List labels for a specific repository
|
||||||
fgj label list -R owner/repo
|
fj label list -R owner/repo
|
||||||
|
|
||||||
# Output as JSON
|
# Output as JSON
|
||||||
fgj label list --json`,
|
fj label list --json`,
|
||||||
RunE: runLabelList,
|
RunE: runLabelList,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -36,30 +36,29 @@ var labelCreateCmd = &cobra.Command{
|
||||||
Short: "Create a label",
|
Short: "Create a label",
|
||||||
Long: "Create a new label in a repository.",
|
Long: "Create a new label in a repository.",
|
||||||
Example: ` # Create a label with a color
|
Example: ` # Create a label with a color
|
||||||
fgj label create bug -c ff0000
|
fj label create bug -c ff0000
|
||||||
|
|
||||||
# Create a label with color and description
|
# Create a label with color and description
|
||||||
fgj label create feature -c 00ff00 -d "New feature request"
|
fj label create feature -c 00ff00 -d "New feature request"
|
||||||
|
|
||||||
# Create a label in a specific repository
|
# Create a label in a specific repository
|
||||||
fgj label create urgent -c ff0000 -R owner/repo`,
|
fj label create urgent -c ff0000 -R owner/repo`,
|
||||||
Args: cobra.ExactArgs(1),
|
Args: cobra.ExactArgs(1),
|
||||||
RunE: runLabelCreate,
|
RunE: runLabelCreate,
|
||||||
}
|
}
|
||||||
|
|
||||||
var labelEditCmd = &cobra.Command{
|
var labelEditCmd = &cobra.Command{
|
||||||
Use: "edit <name>",
|
Use: "edit <name>",
|
||||||
Aliases: []string{"update"},
|
Short: "Edit a label",
|
||||||
Short: "Edit a label",
|
Long: "Edit an existing label in a repository.",
|
||||||
Long: "Edit an existing label in a repository.",
|
|
||||||
Example: ` # Rename a label
|
Example: ` # Rename a label
|
||||||
fgj label edit bug --name bugfix
|
fj label edit bug --name bugfix
|
||||||
|
|
||||||
# Change the color of a label
|
# Change the color of a label
|
||||||
fgj label edit bug -c 00ff00
|
fj label edit bug -c 00ff00
|
||||||
|
|
||||||
# Update description
|
# Update description
|
||||||
fgj label edit bug -d "Something is broken"`,
|
fj label edit bug -d "Something is broken"`,
|
||||||
Args: cobra.ExactArgs(1),
|
Args: cobra.ExactArgs(1),
|
||||||
RunE: runLabelEdit,
|
RunE: runLabelEdit,
|
||||||
}
|
}
|
||||||
|
|
@ -69,13 +68,13 @@ var labelDeleteCmd = &cobra.Command{
|
||||||
Short: "Delete a label",
|
Short: "Delete a label",
|
||||||
Long: "Delete a label from a repository.",
|
Long: "Delete a label from a repository.",
|
||||||
Example: ` # Delete a label
|
Example: ` # Delete a label
|
||||||
fgj label delete bug
|
fj label delete bug
|
||||||
|
|
||||||
# Delete without confirmation
|
# Delete without confirmation
|
||||||
fgj label delete bug -y
|
fj label delete bug -y
|
||||||
|
|
||||||
# Delete a label from a specific repository
|
# Delete a label from a specific repository
|
||||||
fgj label delete bug -R owner/repo`,
|
fj label delete bug -R owner/repo`,
|
||||||
Args: cobra.ExactArgs(1),
|
Args: cobra.ExactArgs(1),
|
||||||
RunE: runLabelDelete,
|
RunE: runLabelDelete,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
160
cmd/logins.go
160
cmd/logins.go
|
|
@ -1,160 +0,0 @@
|
||||||
package cmd
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"forgejo.zerova.net/public/fgj-sid/internal/config"
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
)
|
|
||||||
|
|
||||||
var loginsCmd = &cobra.Command{
|
|
||||||
Use: "logins",
|
|
||||||
Short: "Manage configured Forgejo/Gitea logins",
|
|
||||||
Long: `Manage configured Forgejo/Gitea logins.
|
|
||||||
|
|
||||||
This is a complementary command to 'fgj auth' using the noun vocabulary
|
|
||||||
familiar to users of tea. Use 'fgj auth login' to add a new login and
|
|
||||||
'fgj auth logout' to remove one.`,
|
|
||||||
}
|
|
||||||
|
|
||||||
var loginsListCmd = &cobra.Command{
|
|
||||||
Use: "list",
|
|
||||||
Aliases: []string{"ls"},
|
|
||||||
Short: "List all configured logins",
|
|
||||||
Long: "List all configured Forgejo/Gitea logins with their hostname, user, protocol, default flag, and match_dirs.",
|
|
||||||
RunE: runLoginsList,
|
|
||||||
}
|
|
||||||
|
|
||||||
var loginsDefaultCmd = &cobra.Command{
|
|
||||||
Use: "default [hostname]",
|
|
||||||
Short: "Get or set the default login",
|
|
||||||
Long: `Get or set the default login.
|
|
||||||
|
|
||||||
With no argument, prints the currently-configured default hostname
|
|
||||||
(or "no default set" if none is configured).
|
|
||||||
|
|
||||||
With a hostname argument, marks that login as the default and unsets
|
|
||||||
the default flag on all other logins.`,
|
|
||||||
Args: cobra.MaximumNArgs(1),
|
|
||||||
RunE: runLoginsDefault,
|
|
||||||
}
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
rootCmd.AddCommand(loginsCmd)
|
|
||||||
loginsCmd.AddCommand(loginsListCmd)
|
|
||||||
loginsCmd.AddCommand(loginsDefaultCmd)
|
|
||||||
|
|
||||||
addJSONFlags(loginsListCmd, "Output logins as JSON")
|
|
||||||
}
|
|
||||||
|
|
||||||
// loginEntry is the JSON representation of a configured login.
|
|
||||||
type loginEntry struct {
|
|
||||||
Hostname string `json:"hostname"`
|
|
||||||
User string `json:"user"`
|
|
||||||
GitProtocol string `json:"git_protocol"`
|
|
||||||
Default bool `json:"default"`
|
|
||||||
MatchDirs []string `json:"match_dirs"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func runLoginsList(cmd *cobra.Command, args []string) error {
|
|
||||||
cfg, err := config.Load()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to load config: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
hostnames := make([]string, 0, len(cfg.Hosts))
|
|
||||||
for hostname := range cfg.Hosts {
|
|
||||||
hostnames = append(hostnames, hostname)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort by config-file order; fall back to alphabetical when Order is
|
|
||||||
// equal (e.g., both zero because the config was constructed in-memory
|
|
||||||
// rather than loaded from disk).
|
|
||||||
sort.Slice(hostnames, func(i, j int) bool {
|
|
||||||
a, b := cfg.Hosts[hostnames[i]], cfg.Hosts[hostnames[j]]
|
|
||||||
if a.Order != b.Order {
|
|
||||||
return a.Order < b.Order
|
|
||||||
}
|
|
||||||
return hostnames[i] < hostnames[j]
|
|
||||||
})
|
|
||||||
|
|
||||||
if wantJSON(cmd) {
|
|
||||||
entries := make([]loginEntry, 0, len(hostnames))
|
|
||||||
for _, hostname := range hostnames {
|
|
||||||
h := cfg.Hosts[hostname]
|
|
||||||
dirs := h.MatchDirs
|
|
||||||
if dirs == nil {
|
|
||||||
dirs = []string{}
|
|
||||||
}
|
|
||||||
entries = append(entries, loginEntry{
|
|
||||||
Hostname: hostname,
|
|
||||||
User: h.User,
|
|
||||||
GitProtocol: h.GitProtocol,
|
|
||||||
Default: h.Default,
|
|
||||||
MatchDirs: dirs,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return outputJSON(cmd, entries)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(hostnames) == 0 {
|
|
||||||
fmt.Fprintln(ios.Out, "No logins configured")
|
|
||||||
fmt.Fprintln(ios.Out, "Run 'fgj auth login' to add a login")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
tp := ios.NewTablePrinter()
|
|
||||||
tp.AddHeader("HOSTNAME", "USER", "PROTOCOL", "DEFAULT", "MATCH_DIRS")
|
|
||||||
for _, hostname := range hostnames {
|
|
||||||
h := cfg.Hosts[hostname]
|
|
||||||
defaultMark := ""
|
|
||||||
if h.Default {
|
|
||||||
defaultMark = "*"
|
|
||||||
}
|
|
||||||
tp.AddRow(
|
|
||||||
hostname,
|
|
||||||
h.User,
|
|
||||||
h.GitProtocol,
|
|
||||||
defaultMark,
|
|
||||||
strings.Join(h.MatchDirs, ", "),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return tp.Render()
|
|
||||||
}
|
|
||||||
|
|
||||||
func runLoginsDefault(cmd *cobra.Command, args []string) error {
|
|
||||||
cfg, err := config.Load()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to load config: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(args) == 0 {
|
|
||||||
current := cfg.DefaultHost()
|
|
||||||
if current == "" {
|
|
||||||
fmt.Fprintln(ios.Out, "no default set")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
fmt.Fprintln(ios.Out, current)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
target := args[0]
|
|
||||||
if _, ok := cfg.Hosts[target]; !ok {
|
|
||||||
return fmt.Errorf("no configuration found for host %s", target)
|
|
||||||
}
|
|
||||||
|
|
||||||
for hostname, h := range cfg.Hosts {
|
|
||||||
h.Default = (hostname == target)
|
|
||||||
cfg.Hosts[hostname] = h
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := cfg.Save(); err != nil {
|
|
||||||
return fmt.Errorf("failed to save config: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
cs := ios.ColorScheme()
|
|
||||||
fmt.Fprintf(ios.Out, "%s Default host set to %s\n", cs.SuccessIcon(), target)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
@ -12,7 +12,7 @@ import (
|
||||||
var manpagesCmd = &cobra.Command{
|
var manpagesCmd = &cobra.Command{
|
||||||
Use: "manpages",
|
Use: "manpages",
|
||||||
Short: "Generate manpages",
|
Short: "Generate manpages",
|
||||||
Long: "Generate manpages for fgj commands.",
|
Long: "Generate manpages for fj commands.",
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
dir, _ := cmd.Flags().GetString("dir")
|
dir, _ := cmd.Flags().GetString("dir")
|
||||||
if dir == "" {
|
if dir == "" {
|
||||||
|
|
@ -29,7 +29,7 @@ var manpagesCmd = &cobra.Command{
|
||||||
}
|
}
|
||||||
|
|
||||||
header := &doc.GenManHeader{
|
header := &doc.GenManHeader{
|
||||||
Title: "FGJ",
|
Title: "FJ",
|
||||||
Section: "1",
|
Section: "1",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,9 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"code.gitea.io/sdk/gitea"
|
"code.gitea.io/sdk/gitea"
|
||||||
"forgejo.zerova.net/public/fgj-sid/internal/api"
|
"forgejo.zerova.net/public/fj/internal/api"
|
||||||
"forgejo.zerova.net/public/fgj-sid/internal/config"
|
"forgejo.zerova.net/public/fj/internal/config"
|
||||||
"forgejo.zerova.net/public/fgj-sid/internal/text"
|
"forgejo.zerova.net/public/fj/internal/text"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -24,13 +24,13 @@ var milestoneListCmd = &cobra.Command{
|
||||||
Short: "List milestones",
|
Short: "List milestones",
|
||||||
Long: "List milestones in a repository.",
|
Long: "List milestones in a repository.",
|
||||||
Example: ` # List open milestones
|
Example: ` # List open milestones
|
||||||
fgj milestone list
|
fj milestone list
|
||||||
|
|
||||||
# List all milestones for a specific repo
|
# List all milestones for a specific repo
|
||||||
fgj milestone list -R owner/repo --state all
|
fj milestone list -R owner/repo --state all
|
||||||
|
|
||||||
# Output as JSON
|
# Output as JSON
|
||||||
fgj milestone list --json`,
|
fj milestone list --json`,
|
||||||
RunE: runMilestoneList,
|
RunE: runMilestoneList,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -39,16 +39,16 @@ var milestoneViewCmd = &cobra.Command{
|
||||||
Short: "View a milestone",
|
Short: "View a milestone",
|
||||||
Long: "Display detailed information about a milestone.",
|
Long: "Display detailed information about a milestone.",
|
||||||
Example: ` # View by ID
|
Example: ` # View by ID
|
||||||
fgj milestone view 1
|
fj milestone view 1
|
||||||
|
|
||||||
# View by title
|
# View by title
|
||||||
fgj milestone view "v1.0"
|
fj milestone view "v1.0"
|
||||||
|
|
||||||
# Open in browser
|
# Open in browser
|
||||||
fgj milestone view "v1.0" --web
|
fj milestone view "v1.0" --web
|
||||||
|
|
||||||
# Output as JSON
|
# Output as JSON
|
||||||
fgj milestone view "v1.0" --json`,
|
fj milestone view "v1.0" --json`,
|
||||||
Args: cobra.ExactArgs(1),
|
Args: cobra.ExactArgs(1),
|
||||||
RunE: runMilestoneView,
|
RunE: runMilestoneView,
|
||||||
}
|
}
|
||||||
|
|
@ -58,13 +58,13 @@ var milestoneCreateCmd = &cobra.Command{
|
||||||
Short: "Create a milestone",
|
Short: "Create a milestone",
|
||||||
Long: "Create a new milestone.",
|
Long: "Create a new milestone.",
|
||||||
Example: ` # Create a simple milestone
|
Example: ` # Create a simple milestone
|
||||||
fgj milestone create "v1.0"
|
fj milestone create "v1.0"
|
||||||
|
|
||||||
# Create with description and due date
|
# Create with description and due date
|
||||||
fgj milestone create "v2.0" -d "Second release" --due 2026-06-01
|
fj milestone create "v2.0" -d "Second release" --due 2026-06-01
|
||||||
|
|
||||||
# Output as JSON
|
# Output as JSON
|
||||||
fgj milestone create "v1.0" --json`,
|
fj milestone create "v1.0" --json`,
|
||||||
Args: cobra.ExactArgs(1),
|
Args: cobra.ExactArgs(1),
|
||||||
RunE: runMilestoneCreate,
|
RunE: runMilestoneCreate,
|
||||||
}
|
}
|
||||||
|
|
@ -74,13 +74,13 @@ var milestoneEditCmd = &cobra.Command{
|
||||||
Short: "Edit a milestone",
|
Short: "Edit a milestone",
|
||||||
Long: "Edit an existing milestone's title, description, due date, or state.",
|
Long: "Edit an existing milestone's title, description, due date, or state.",
|
||||||
Example: ` # Rename a milestone
|
Example: ` # Rename a milestone
|
||||||
fgj milestone edit "v1.0" --title "v1.1"
|
fj milestone edit "v1.0" --title "v1.1"
|
||||||
|
|
||||||
# Close a milestone
|
# Close a milestone
|
||||||
fgj milestone edit "v1.0" --state closed
|
fj milestone edit "v1.0" --state closed
|
||||||
|
|
||||||
# Update due date
|
# Update due date
|
||||||
fgj milestone edit 1 --due 2026-12-31`,
|
fj milestone edit 1 --due 2026-12-31`,
|
||||||
Args: cobra.ExactArgs(1),
|
Args: cobra.ExactArgs(1),
|
||||||
RunE: runMilestoneEdit,
|
RunE: runMilestoneEdit,
|
||||||
}
|
}
|
||||||
|
|
@ -90,13 +90,13 @@ var milestoneDeleteCmd = &cobra.Command{
|
||||||
Short: "Delete a milestone",
|
Short: "Delete a milestone",
|
||||||
Long: "Delete an existing milestone.",
|
Long: "Delete an existing milestone.",
|
||||||
Example: ` # Delete by title
|
Example: ` # Delete by title
|
||||||
fgj milestone delete "v1.0"
|
fj milestone delete "v1.0"
|
||||||
|
|
||||||
# Delete by ID
|
# Delete by ID
|
||||||
fgj milestone delete 1
|
fj milestone delete 1
|
||||||
|
|
||||||
# Delete without confirmation
|
# Delete without confirmation
|
||||||
fgj milestone delete "v1.0" -y`,
|
fj milestone delete "v1.0" -y`,
|
||||||
Args: cobra.ExactArgs(1),
|
Args: cobra.ExactArgs(1),
|
||||||
RunE: runMilestoneDelete,
|
RunE: runMilestoneDelete,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,164 +0,0 @@
|
||||||
package cmd
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"code.gitea.io/sdk/gitea"
|
|
||||||
"forgejo.zerova.net/public/fgj-sid/internal/api"
|
|
||||||
"forgejo.zerova.net/public/fgj-sid/internal/config"
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
)
|
|
||||||
|
|
||||||
var milestoneIssuesCmd = &cobra.Command{
|
|
||||||
Use: "issues",
|
|
||||||
Aliases: []string{"i"},
|
|
||||||
Short: "Manage issues associated with a milestone",
|
|
||||||
Long: "Associate or disassociate issues with a milestone.",
|
|
||||||
}
|
|
||||||
|
|
||||||
var milestoneIssuesAddCmd = &cobra.Command{
|
|
||||||
Use: "add <title-or-id> <issue-number>...",
|
|
||||||
Short: "Add issues to a milestone",
|
|
||||||
Long: "Associate one or more issues with a milestone.",
|
|
||||||
Example: ` # Add issues #5 and #7 to milestone "v1.0"
|
|
||||||
fgj milestone issues add "v1.0" 5 7
|
|
||||||
|
|
||||||
# Add issue #12 to milestone with ID 3 in a specific repo
|
|
||||||
fgj milestone issues add 3 12 -R owner/repo`,
|
|
||||||
Args: cobra.MinimumNArgs(2),
|
|
||||||
RunE: runMilestoneIssuesAdd,
|
|
||||||
}
|
|
||||||
|
|
||||||
var milestoneIssuesRemoveCmd = &cobra.Command{
|
|
||||||
Use: "remove <title-or-id> <issue-number>...",
|
|
||||||
Short: "Remove issues from a milestone",
|
|
||||||
Long: "Disassociate one or more issues from a milestone.",
|
|
||||||
Example: ` # Remove issues #5 and #7 from milestone "v1.0"
|
|
||||||
fgj milestone issues remove "v1.0" 5 7
|
|
||||||
|
|
||||||
# Remove issue #12 from milestone with ID 3 in a specific repo
|
|
||||||
fgj milestone issues remove 3 12 -R owner/repo`,
|
|
||||||
Args: cobra.MinimumNArgs(2),
|
|
||||||
RunE: runMilestoneIssuesRemove,
|
|
||||||
}
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
milestoneCmd.AddCommand(milestoneIssuesCmd)
|
|
||||||
milestoneIssuesCmd.AddCommand(milestoneIssuesAddCmd)
|
|
||||||
milestoneIssuesCmd.AddCommand(milestoneIssuesRemoveCmd)
|
|
||||||
|
|
||||||
addRepoFlags(milestoneIssuesAddCmd)
|
|
||||||
addRepoFlags(milestoneIssuesRemoveCmd)
|
|
||||||
}
|
|
||||||
|
|
||||||
func runMilestoneIssuesAdd(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
|
|
||||||
}
|
|
||||||
|
|
||||||
milestoneID := ms.ID
|
|
||||||
cs := ios.ColorScheme()
|
|
||||||
hadError := false
|
|
||||||
|
|
||||||
for _, arg := range args[1:] {
|
|
||||||
issueNum, parseErr := parseIssueArg(arg)
|
|
||||||
if parseErr != nil {
|
|
||||||
fmt.Fprintf(ios.ErrOut, "invalid issue number %q: %v\n", arg, parseErr)
|
|
||||||
hadError = true
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
_, _, editErr := client.EditIssue(owner, name, issueNum, gitea.EditIssueOption{
|
|
||||||
Milestone: &milestoneID,
|
|
||||||
})
|
|
||||||
if editErr != nil {
|
|
||||||
fmt.Fprintf(ios.ErrOut, "failed to add issue #%d to milestone %q: %v\n", issueNum, ms.Title, editErr)
|
|
||||||
hadError = true
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Fprintf(ios.Out, "%s Added issue #%d to milestone %q\n", cs.SuccessIcon(), issueNum, ms.Title)
|
|
||||||
}
|
|
||||||
|
|
||||||
if hadError {
|
|
||||||
return fmt.Errorf("one or more issues could not be added to milestone %q", ms.Title)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func runMilestoneIssuesRemove(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
|
|
||||||
}
|
|
||||||
|
|
||||||
cs := ios.ColorScheme()
|
|
||||||
hadError := false
|
|
||||||
var zero int64 = 0
|
|
||||||
|
|
||||||
for _, arg := range args[1:] {
|
|
||||||
issueNum, parseErr := parseIssueArg(arg)
|
|
||||||
if parseErr != nil {
|
|
||||||
fmt.Fprintf(ios.ErrOut, "invalid issue number %q: %v\n", arg, parseErr)
|
|
||||||
hadError = true
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Setting Milestone to a pointer-to-zero clears the milestone association
|
|
||||||
// in Gitea's API (PATCH /repos/{owner}/{repo}/issues/{num} with {"milestone": 0}).
|
|
||||||
_, _, editErr := client.EditIssue(owner, name, issueNum, gitea.EditIssueOption{
|
|
||||||
Milestone: &zero,
|
|
||||||
})
|
|
||||||
if editErr != nil {
|
|
||||||
fmt.Fprintf(ios.ErrOut, "failed to remove issue #%d from milestone %q: %v\n", issueNum, ms.Title, editErr)
|
|
||||||
hadError = true
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Fprintf(ios.Out, "%s Removed issue #%d from milestone %q\n", cs.SuccessIcon(), issueNum, ms.Title)
|
|
||||||
}
|
|
||||||
|
|
||||||
if hadError {
|
|
||||||
return fmt.Errorf("one or more issues could not be removed from milestone %q", ms.Title)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
@ -1,137 +0,0 @@
|
||||||
package cmd
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"code.gitea.io/sdk/gitea"
|
|
||||||
"forgejo.zerova.net/public/fgj-sid/internal/api"
|
|
||||||
"forgejo.zerova.net/public/fgj-sid/internal/config"
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
)
|
|
||||||
|
|
||||||
var notificationCmd = &cobra.Command{
|
|
||||||
Use: "notification",
|
|
||||||
Aliases: []string{"notifications", "n"},
|
|
||||||
Short: "Manage user notifications",
|
|
||||||
Long: "List and mark notifications for the authenticated user.",
|
|
||||||
}
|
|
||||||
|
|
||||||
var notificationListCmd = &cobra.Command{
|
|
||||||
Use: "list",
|
|
||||||
Aliases: []string{"ls"},
|
|
||||||
Short: "List notifications",
|
|
||||||
Long: "List notifications for the authenticated user. Shows unread by default.",
|
|
||||||
Example: ` # List unread notifications
|
|
||||||
fgj notification list
|
|
||||||
|
|
||||||
# Include read and pinned notifications
|
|
||||||
fgj notification list --all
|
|
||||||
|
|
||||||
# Limit number of results
|
|
||||||
fgj notification list -L 50
|
|
||||||
|
|
||||||
# Output as JSON
|
|
||||||
fgj notification list --json`,
|
|
||||||
RunE: runNotificationList,
|
|
||||||
}
|
|
||||||
|
|
||||||
var notificationReadCmd = &cobra.Command{
|
|
||||||
Use: "read <id>",
|
|
||||||
Aliases: []string{"r"},
|
|
||||||
Short: "Mark a notification as read",
|
|
||||||
Long: "Mark a single notification thread as read by its ID.",
|
|
||||||
Args: cobra.ExactArgs(1),
|
|
||||||
RunE: runNotificationRead,
|
|
||||||
}
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
rootCmd.AddCommand(notificationCmd)
|
|
||||||
notificationCmd.AddCommand(notificationListCmd)
|
|
||||||
notificationCmd.AddCommand(notificationReadCmd)
|
|
||||||
|
|
||||||
notificationListCmd.Flags().Bool("all", false, "Include read and pinned notifications (not just unread)")
|
|
||||||
notificationListCmd.Flags().IntP("limit", "L", 30, "Maximum number of notifications to list")
|
|
||||||
addJSONFlags(notificationListCmd, "Output as JSON")
|
|
||||||
}
|
|
||||||
|
|
||||||
func runNotificationList(cmd *cobra.Command, args []string) error {
|
|
||||||
cfg, err := config.Load()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd())
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
all, _ := cmd.Flags().GetBool("all")
|
|
||||||
limit, _ := cmd.Flags().GetInt("limit")
|
|
||||||
if limit <= 0 {
|
|
||||||
limit = 30
|
|
||||||
}
|
|
||||||
|
|
||||||
opt := gitea.ListNotificationOptions{
|
|
||||||
ListOptions: gitea.ListOptions{PageSize: limit},
|
|
||||||
Status: []gitea.NotifyStatus{gitea.NotifyStatusUnread},
|
|
||||||
}
|
|
||||||
if all {
|
|
||||||
opt.Status = []gitea.NotifyStatus{gitea.NotifyStatusUnread, gitea.NotifyStatusRead, gitea.NotifyStatusPinned}
|
|
||||||
}
|
|
||||||
|
|
||||||
threads, _, err := client.ListNotifications(opt)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to list notifications: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if wantJSON(cmd) {
|
|
||||||
return outputJSON(cmd, threads)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(threads) == 0 {
|
|
||||||
fmt.Fprintln(ios.Out, "No notifications.")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
tp := ios.NewTablePrinter()
|
|
||||||
tp.AddHeader("ID", "REPO", "TYPE", "STATE", "TITLE")
|
|
||||||
for _, t := range threads {
|
|
||||||
repo := ""
|
|
||||||
if t.Repository != nil {
|
|
||||||
repo = t.Repository.FullName
|
|
||||||
}
|
|
||||||
subjType, subjState, title := "", "", ""
|
|
||||||
if t.Subject != nil {
|
|
||||||
subjType = string(t.Subject.Type)
|
|
||||||
subjState = string(t.Subject.State)
|
|
||||||
title = t.Subject.Title
|
|
||||||
}
|
|
||||||
tp.AddRow(fmt.Sprintf("%d", t.ID), repo, subjType, subjState, title)
|
|
||||||
}
|
|
||||||
return tp.Render()
|
|
||||||
}
|
|
||||||
|
|
||||||
func runNotificationRead(cmd *cobra.Command, args []string) error {
|
|
||||||
id, err := parseIssueArg(args[0])
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("invalid notification id %q: %w", args[0], err)
|
|
||||||
}
|
|
||||||
|
|
||||||
cfg, err := config.Load()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd())
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, _, err := client.ReadNotification(id, gitea.NotifyStatusRead); err != nil {
|
|
||||||
return fmt.Errorf("failed to mark notification %d as read: %w", id, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
cs := ios.ColorScheme()
|
|
||||||
fmt.Fprintf(ios.Out, "%s Marked notification %d as read\n", cs.SuccessIcon(), id)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
@ -1,60 +0,0 @@
|
||||||
package cmd
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"code.gitea.io/sdk/gitea"
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
)
|
|
||||||
|
|
||||||
var notificationUnreadCmd = &cobra.Command{
|
|
||||||
Use: "unread <id>",
|
|
||||||
Short: "Mark a notification as unread",
|
|
||||||
Long: "Mark a single notification thread as unread by its ID.",
|
|
||||||
Args: cobra.ExactArgs(1),
|
|
||||||
RunE: runNotificationState(gitea.NotifyStatusUnread, "unread"),
|
|
||||||
}
|
|
||||||
|
|
||||||
var notificationPinCmd = &cobra.Command{
|
|
||||||
Use: "pin <id>",
|
|
||||||
Short: "Pin a notification",
|
|
||||||
Long: "Mark a single notification thread as pinned by its ID.",
|
|
||||||
Args: cobra.ExactArgs(1),
|
|
||||||
RunE: runNotificationState(gitea.NotifyStatusPinned, "pinned"),
|
|
||||||
}
|
|
||||||
|
|
||||||
var notificationUnpinCmd = &cobra.Command{
|
|
||||||
Use: "unpin <id>",
|
|
||||||
Short: "Un-pin a notification",
|
|
||||||
Long: "Un-pin a notification thread (marks it as read).",
|
|
||||||
Args: cobra.ExactArgs(1),
|
|
||||||
RunE: runNotificationState(gitea.NotifyStatusRead, "unpinned"),
|
|
||||||
}
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
notificationCmd.AddCommand(notificationUnreadCmd)
|
|
||||||
notificationCmd.AddCommand(notificationPinCmd)
|
|
||||||
notificationCmd.AddCommand(notificationUnpinCmd)
|
|
||||||
}
|
|
||||||
|
|
||||||
func runNotificationState(status gitea.NotifyStatus, verb string) func(*cobra.Command, []string) error {
|
|
||||||
return func(cmd *cobra.Command, args []string) error {
|
|
||||||
id, err := parseIssueArg(args[0])
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("invalid notification id %q: %w", args[0], err)
|
|
||||||
}
|
|
||||||
|
|
||||||
client, err := loadClient()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, _, err := client.ReadNotification(id, status); err != nil {
|
|
||||||
return fmt.Errorf("failed to mark notification %d as %s: %w", id, verb, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
cs := ios.ColorScheme()
|
|
||||||
fmt.Fprintf(ios.Out, "%s Marked notification %d as %s\n", cs.SuccessIcon(), id, verb)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
104
cmd/open.go
104
cmd/open.go
|
|
@ -1,104 +0,0 @@
|
||||||
package cmd
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"os/exec"
|
|
||||||
"runtime"
|
|
||||||
|
|
||||||
"forgejo.zerova.net/public/fgj-sid/internal/api"
|
|
||||||
"forgejo.zerova.net/public/fgj-sid/internal/config"
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
)
|
|
||||||
|
|
||||||
var openCmd = &cobra.Command{
|
|
||||||
Use: "open [issue-or-pr-number]",
|
|
||||||
Aliases: []string{"o"},
|
|
||||||
Short: "Open a repository, issue, or pull request in a browser",
|
|
||||||
Long: `Open the repository page in a web browser. When an issue or pull request
|
|
||||||
number is given, that page is opened instead.
|
|
||||||
|
|
||||||
Repository is auto-detected from the current git context, or specified with -R.`,
|
|
||||||
Example: ` # Open the current repository
|
|
||||||
fgj open
|
|
||||||
|
|
||||||
# Open a specific repository
|
|
||||||
fgj open -R owner/repo
|
|
||||||
|
|
||||||
# Open issue or PR #42 (Forgejo routes both via the same number)
|
|
||||||
fgj open 42
|
|
||||||
fgj open '#42'
|
|
||||||
|
|
||||||
# Print the URL instead of launching a browser
|
|
||||||
fgj open 42 --url`,
|
|
||||||
Args: cobra.MaximumNArgs(1),
|
|
||||||
RunE: runOpen,
|
|
||||||
}
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
rootCmd.AddCommand(openCmd)
|
|
||||||
addRepoFlags(openCmd)
|
|
||||||
openCmd.Flags().Bool("url", false, "Print URL instead of opening a browser")
|
|
||||||
}
|
|
||||||
|
|
||||||
func runOpen(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
|
|
||||||
}
|
|
||||||
|
|
||||||
url := fmt.Sprintf("https://%s/%s/%s", client.Hostname(), owner, name)
|
|
||||||
|
|
||||||
if len(args) == 1 {
|
|
||||||
num, err := parseIssueArg(args[0])
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("invalid issue or PR number %q: %w", args[0], err)
|
|
||||||
}
|
|
||||||
issue, _, err := client.GetIssue(owner, name, num)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to look up #%d: %w", num, err)
|
|
||||||
}
|
|
||||||
kind := "issues"
|
|
||||||
if issue.PullRequest != nil {
|
|
||||||
kind = "pulls"
|
|
||||||
}
|
|
||||||
url = fmt.Sprintf("https://%s/%s/%s/%s/%d", client.Hostname(), owner, name, kind, num)
|
|
||||||
}
|
|
||||||
|
|
||||||
printOnly, _ := cmd.Flags().GetBool("url")
|
|
||||||
if printOnly || !ios.IsStdoutTTY() {
|
|
||||||
fmt.Fprintln(ios.Out, url)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := launchBrowser(url); err != nil {
|
|
||||||
fmt.Fprintf(ios.ErrOut, "Could not open browser (%v); URL: %s\n", err, url)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
fmt.Fprintf(ios.ErrOut, "Opening %s in your browser.\n", url)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// launchBrowser opens url in the OS default browser.
|
|
||||||
func launchBrowser(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:
|
|
||||||
cmd = exec.Command("xdg-open", url)
|
|
||||||
}
|
|
||||||
return cmd.Start()
|
|
||||||
}
|
|
||||||
186
cmd/org.go
186
cmd/org.go
|
|
@ -1,186 +0,0 @@
|
||||||
package cmd
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"code.gitea.io/sdk/gitea"
|
|
||||||
"forgejo.zerova.net/public/fgj-sid/internal/api"
|
|
||||||
"forgejo.zerova.net/public/fgj-sid/internal/config"
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
)
|
|
||||||
|
|
||||||
var orgCmd = &cobra.Command{
|
|
||||||
Use: "org",
|
|
||||||
Aliases: []string{"organization", "organizations"},
|
|
||||||
Short: "Manage organizations",
|
|
||||||
Long: "List, create, and delete organizations on the current host.",
|
|
||||||
}
|
|
||||||
|
|
||||||
var orgListCmd = &cobra.Command{
|
|
||||||
Use: "list",
|
|
||||||
Aliases: []string{"ls"},
|
|
||||||
Short: "List organizations",
|
|
||||||
Long: "List organizations the authenticated user is a member of.",
|
|
||||||
Example: ` # List your organizations
|
|
||||||
fgj org list
|
|
||||||
|
|
||||||
# Output as JSON
|
|
||||||
fgj org list --json`,
|
|
||||||
RunE: runOrgList,
|
|
||||||
}
|
|
||||||
|
|
||||||
var orgCreateCmd = &cobra.Command{
|
|
||||||
Use: "create <name>",
|
|
||||||
Short: "Create an organization",
|
|
||||||
Long: "Create a new organization. You become the initial owner.",
|
|
||||||
Example: ` # Create an organization
|
|
||||||
fgj org create my-org
|
|
||||||
|
|
||||||
# Create with description and visibility
|
|
||||||
fgj org create my-org --description "Internal tooling" --visibility private`,
|
|
||||||
Args: cobra.ExactArgs(1),
|
|
||||||
RunE: runOrgCreate,
|
|
||||||
}
|
|
||||||
|
|
||||||
var orgDeleteCmd = &cobra.Command{
|
|
||||||
Use: "delete <name>",
|
|
||||||
Aliases: []string{"rm"},
|
|
||||||
Short: "Delete an organization",
|
|
||||||
Long: "Delete an organization. This is irreversible and removes all the organization's repositories.",
|
|
||||||
Args: cobra.ExactArgs(1),
|
|
||||||
RunE: runOrgDelete,
|
|
||||||
}
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
rootCmd.AddCommand(orgCmd)
|
|
||||||
orgCmd.AddCommand(orgListCmd)
|
|
||||||
orgCmd.AddCommand(orgCreateCmd)
|
|
||||||
orgCmd.AddCommand(orgDeleteCmd)
|
|
||||||
|
|
||||||
orgListCmd.Flags().IntP("limit", "L", 50, "Maximum number of organizations to list")
|
|
||||||
addJSONFlags(orgListCmd, "Output as JSON")
|
|
||||||
|
|
||||||
orgCreateCmd.Flags().String("description", "", "Organization description")
|
|
||||||
orgCreateCmd.Flags().String("full-name", "", "Full display name")
|
|
||||||
orgCreateCmd.Flags().String("website", "", "Website URL")
|
|
||||||
orgCreateCmd.Flags().String("location", "", "Location")
|
|
||||||
orgCreateCmd.Flags().String("visibility", "public", "Visibility: public, limited, or private")
|
|
||||||
|
|
||||||
orgDeleteCmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompt")
|
|
||||||
}
|
|
||||||
|
|
||||||
func runOrgList(cmd *cobra.Command, args []string) error {
|
|
||||||
client, err := loadClient()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
limit, _ := cmd.Flags().GetInt("limit")
|
|
||||||
if limit <= 0 {
|
|
||||||
limit = 50
|
|
||||||
}
|
|
||||||
|
|
||||||
orgs, _, err := client.ListMyOrgs(gitea.ListOrgsOptions{
|
|
||||||
ListOptions: gitea.ListOptions{PageSize: limit},
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to list organizations: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if wantJSON(cmd) {
|
|
||||||
return outputJSON(cmd, orgs)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(orgs) == 0 {
|
|
||||||
fmt.Fprintln(ios.Out, "No organizations found.")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
tp := ios.NewTablePrinter()
|
|
||||||
tp.AddHeader("NAME", "FULL NAME", "VISIBILITY", "DESCRIPTION")
|
|
||||||
for _, o := range orgs {
|
|
||||||
tp.AddRow(o.UserName, o.FullName, string(o.Visibility), o.Description)
|
|
||||||
}
|
|
||||||
return tp.Render()
|
|
||||||
}
|
|
||||||
|
|
||||||
func runOrgCreate(cmd *cobra.Command, args []string) error {
|
|
||||||
client, err := loadClient()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
name := args[0]
|
|
||||||
desc, _ := cmd.Flags().GetString("description")
|
|
||||||
fullName, _ := cmd.Flags().GetString("full-name")
|
|
||||||
website, _ := cmd.Flags().GetString("website")
|
|
||||||
location, _ := cmd.Flags().GetString("location")
|
|
||||||
visStr, _ := cmd.Flags().GetString("visibility")
|
|
||||||
|
|
||||||
var vis gitea.VisibleType
|
|
||||||
switch visStr {
|
|
||||||
case "public", "":
|
|
||||||
vis = gitea.VisibleTypePublic
|
|
||||||
case "limited":
|
|
||||||
vis = gitea.VisibleTypeLimited
|
|
||||||
case "private":
|
|
||||||
vis = gitea.VisibleTypePrivate
|
|
||||||
default:
|
|
||||||
return fmt.Errorf("invalid visibility %q (must be public, limited, or private)", visStr)
|
|
||||||
}
|
|
||||||
|
|
||||||
org, _, err := client.CreateOrg(gitea.CreateOrgOption{
|
|
||||||
Name: name,
|
|
||||||
FullName: fullName,
|
|
||||||
Description: desc,
|
|
||||||
Website: website,
|
|
||||||
Location: location,
|
|
||||||
Visibility: vis,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to create organization %q: %w", name, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
cs := ios.ColorScheme()
|
|
||||||
fmt.Fprintf(ios.Out, "%s Created organization %q\n", cs.SuccessIcon(), org.UserName)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func runOrgDelete(cmd *cobra.Command, args []string) error {
|
|
||||||
client, err := loadClient()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
name := args[0]
|
|
||||||
skipConfirm, _ := cmd.Flags().GetBool("yes")
|
|
||||||
|
|
||||||
if !skipConfirm && ios.IsStdinTTY() {
|
|
||||||
answer, err := promptLine(fmt.Sprintf("Delete organization %q? This is irreversible and deletes all repositories. [y/N]: ", name))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if answer != "y" && answer != "Y" && answer != "yes" {
|
|
||||||
fmt.Fprintln(ios.ErrOut, "Cancelled.")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := client.DeleteOrg(name); err != nil {
|
|
||||||
return fmt.Errorf("failed to delete organization %q: %w", name, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
cs := ios.ColorScheme()
|
|
||||||
fmt.Fprintf(ios.Out, "%s Deleted organization %q\n", cs.SuccessIcon(), name)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// loadClient constructs an api.Client from config without requiring a repo context.
|
|
||||||
// Use this for commands that operate on the host itself (orgs, notifications, user).
|
|
||||||
func loadClient() (*api.Client, error) {
|
|
||||||
cfg, err := config.Load()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd())
|
|
||||||
}
|
|
||||||
43
cmd/paginate.go
Normal file
43
cmd/paginate.go
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
package cmd
|
||||||
|
|
||||||
|
// paginateGitea walks pages of a gitea SDK list method until the response
|
||||||
|
// is short (last page) or we hit limit. limit=0 means unlimited.
|
||||||
|
//
|
||||||
|
// Forgejo/Gitea caps PageSize at 50, so naive `PageSize: limit` for limit > 50
|
||||||
|
// silently truncated results across most `fj * list` commands. This helper
|
||||||
|
// centralizes the loop so every list command paginates consistently.
|
||||||
|
//
|
||||||
|
// fetch is called with (page, pageSize) and returns the items for that page.
|
||||||
|
// The 1-based `page` matches the gitea SDK convention.
|
||||||
|
func paginateGitea[T any](limit int, fetch func(page, pageSize int) ([]T, error)) ([]T, error) {
|
||||||
|
const maxPageSize = 50
|
||||||
|
pageSize := maxPageSize
|
||||||
|
if limit > 0 && limit < pageSize {
|
||||||
|
pageSize = limit
|
||||||
|
}
|
||||||
|
|
||||||
|
var all []T
|
||||||
|
for page := 1; ; page++ {
|
||||||
|
if limit > 0 && len(all) >= limit {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
batch, err := fetch(page, pageSize)
|
||||||
|
if err != nil {
|
||||||
|
return all, err
|
||||||
|
}
|
||||||
|
if len(batch) == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
all = append(all, batch...)
|
||||||
|
// A short page (less than the requested size) is the conventional
|
||||||
|
// "you've reached the end" signal — saves one extra round-trip.
|
||||||
|
if len(batch) < pageSize {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if limit > 0 && len(all) > limit {
|
||||||
|
all = all[:limit]
|
||||||
|
}
|
||||||
|
return all, nil
|
||||||
|
}
|
||||||
203
cmd/pr.go
203
cmd/pr.go
|
|
@ -4,66 +4,16 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
|
||||||
|
|
||||||
"code.gitea.io/sdk/gitea"
|
"code.gitea.io/sdk/gitea"
|
||||||
"forgejo.zerova.net/public/fgj-sid/internal/api"
|
"forgejo.zerova.net/public/fj/internal/api"
|
||||||
"forgejo.zerova.net/public/fgj-sid/internal/config"
|
"forgejo.zerova.net/public/fj/internal/config"
|
||||||
gitpkg "forgejo.zerova.net/public/fgj-sid/internal/git"
|
gitpkg "forgejo.zerova.net/public/fj/internal/git"
|
||||||
"forgejo.zerova.net/public/fgj-sid/internal/text"
|
"forgejo.zerova.net/public/fj/internal/text"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
// parseDateArg parses a --since / --before argument.
|
|
||||||
// Accepted forms: "2006-01-02", RFC 3339, "2006-01-02 15:04:05" (local),
|
|
||||||
// or a relative delta like "7d", "24h", "2w", "1m" (months treated as 30 days).
|
|
||||||
func parseDateArg(s string) (time.Time, error) {
|
|
||||||
s = strings.TrimSpace(s)
|
|
||||||
if s == "" {
|
|
||||||
return time.Time{}, fmt.Errorf("empty date")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Relative delta: <N><unit>
|
|
||||||
if last := s[len(s)-1]; last == 'h' || last == 'd' || last == 'w' || last == 'm' {
|
|
||||||
numPart := s[:len(s)-1]
|
|
||||||
if n, err := strconv.Atoi(numPart); err == nil && n >= 0 {
|
|
||||||
var d time.Duration
|
|
||||||
switch last {
|
|
||||||
case 'h':
|
|
||||||
d = time.Duration(n) * time.Hour
|
|
||||||
case 'd':
|
|
||||||
d = time.Duration(n) * 24 * time.Hour
|
|
||||||
case 'w':
|
|
||||||
d = time.Duration(n) * 7 * 24 * time.Hour
|
|
||||||
case 'm':
|
|
||||||
// Months treated as 30 days (crude but documented).
|
|
||||||
d = time.Duration(n) * 30 * 24 * time.Hour
|
|
||||||
}
|
|
||||||
return time.Now().Add(-d), nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
layouts := []string{
|
|
||||||
"2006-01-02",
|
|
||||||
time.RFC3339,
|
|
||||||
"2006-01-02 15:04:05",
|
|
||||||
}
|
|
||||||
for _, layout := range layouts {
|
|
||||||
if layout == "2006-01-02" || layout == "2006-01-02 15:04:05" {
|
|
||||||
if t, err := time.ParseInLocation(layout, s, time.Local); err == nil {
|
|
||||||
return t, nil
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if t, err := time.Parse(layout, s); err == nil {
|
|
||||||
return t, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return time.Time{}, fmt.Errorf("unrecognized date format: %q (expected YYYY-MM-DD, RFC 3339, 'YYYY-MM-DD HH:MM:SS', or a relative delta like 7d/24h/2w/1m)", s)
|
|
||||||
}
|
|
||||||
|
|
||||||
var prCmd = &cobra.Command{
|
var prCmd = &cobra.Command{
|
||||||
Use: "pr",
|
Use: "pr",
|
||||||
Aliases: []string{"pull-request"},
|
Aliases: []string{"pull-request"},
|
||||||
|
|
@ -76,19 +26,13 @@ var prListCmd = &cobra.Command{
|
||||||
Short: "List pull requests",
|
Short: "List pull requests",
|
||||||
Long: "List pull requests in a repository.",
|
Long: "List pull requests in a repository.",
|
||||||
Example: ` # List open pull requests
|
Example: ` # List open pull requests
|
||||||
fgj pr list
|
fj pr list
|
||||||
|
|
||||||
# List all pull requests for a specific repo
|
# List all pull requests for a specific repo
|
||||||
fgj pr list -s all -R owner/repo
|
fj pr list -s all -R owner/repo
|
||||||
|
|
||||||
# Output as JSON
|
# Output as JSON
|
||||||
fgj pr list --json
|
fj pr list --json`,
|
||||||
|
|
||||||
# PRs updated in the last 7 days
|
|
||||||
fgj pr list --since 7d
|
|
||||||
|
|
||||||
# Issues touched between two dates
|
|
||||||
fgj issue list --since 2026-04-01 --before 2026-04-15`,
|
|
||||||
RunE: runPRList,
|
RunE: runPRList,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -97,19 +41,19 @@ var prViewCmd = &cobra.Command{
|
||||||
Short: "View a pull request",
|
Short: "View a pull request",
|
||||||
Long: "Display detailed information about a pull request.",
|
Long: "Display detailed information about a pull request.",
|
||||||
Example: ` # View pull request #5
|
Example: ` # View pull request #5
|
||||||
fgj pr view 5
|
fj pr view 5
|
||||||
|
|
||||||
# View using URL
|
# View using URL
|
||||||
fgj pr view https://codeberg.org/owner/repo/pulls/5
|
fj pr view https://codeberg.org/owner/repo/pulls/5
|
||||||
|
|
||||||
# View PR for current branch
|
# View PR for current branch
|
||||||
fgj pr view
|
fj pr view
|
||||||
|
|
||||||
# Open in browser
|
# Open in browser
|
||||||
fgj pr view 5 --web
|
fj pr view 5 --web
|
||||||
|
|
||||||
# View as JSON
|
# View as JSON
|
||||||
fgj pr view 5 --json`,
|
fj pr view 5 --json`,
|
||||||
Args: cobra.MaximumNArgs(1),
|
Args: cobra.MaximumNArgs(1),
|
||||||
RunE: runPRView,
|
RunE: runPRView,
|
||||||
}
|
}
|
||||||
|
|
@ -119,13 +63,13 @@ var prCreateCmd = &cobra.Command{
|
||||||
Short: "Create a pull request",
|
Short: "Create a pull request",
|
||||||
Long: "Create a new pull request.",
|
Long: "Create a new pull request.",
|
||||||
Example: ` # Create a pull request from feature branch to main
|
Example: ` # Create a pull request from feature branch to main
|
||||||
fgj pr create -t "Add login page" -H feature/login
|
fj pr create -t "Add login page" -H feature/login
|
||||||
|
|
||||||
# Create with body and custom base branch
|
# Create with body and custom base branch
|
||||||
fgj pr create -t "Fix bug" -b "Closes #42" -H fix/bug -B develop
|
fj pr create -t "Fix bug" -b "Closes #42" -H fix/bug -B develop
|
||||||
|
|
||||||
# Create and self-assign
|
# Create and self-assign
|
||||||
fgj pr create -t "Update docs" -H docs/update -a @me`,
|
fj pr create -t "Update docs" -H docs/update -a @me`,
|
||||||
RunE: runPRCreate,
|
RunE: runPRCreate,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -134,16 +78,16 @@ var prMergeCmd = &cobra.Command{
|
||||||
Short: "Merge a pull request",
|
Short: "Merge a pull request",
|
||||||
Long: "Merge a pull request.",
|
Long: "Merge a pull request.",
|
||||||
Example: ` # Merge pull request #5
|
Example: ` # Merge pull request #5
|
||||||
fgj pr merge 5
|
fj pr merge 5
|
||||||
|
|
||||||
# Squash merge
|
# Squash merge
|
||||||
fgj pr merge 5 --merge-method squash
|
fj pr merge 5 --merge-method squash
|
||||||
|
|
||||||
# Rebase merge
|
# Rebase merge
|
||||||
fgj pr merge 5 --merge-method rebase
|
fj pr merge 5 --merge-method rebase
|
||||||
|
|
||||||
# Merge without confirmation
|
# Merge without confirmation
|
||||||
fgj pr merge 5 -y`,
|
fj pr merge 5 -y`,
|
||||||
Args: cobra.ExactArgs(1),
|
Args: cobra.ExactArgs(1),
|
||||||
RunE: runPRMerge,
|
RunE: runPRMerge,
|
||||||
}
|
}
|
||||||
|
|
@ -153,10 +97,10 @@ var prCloseCmd = &cobra.Command{
|
||||||
Short: "Close a pull request",
|
Short: "Close a pull request",
|
||||||
Long: "Close a pull request without merging.",
|
Long: "Close a pull request without merging.",
|
||||||
Example: ` # Close PR #5
|
Example: ` # Close PR #5
|
||||||
fgj pr close 5
|
fj pr close 5
|
||||||
|
|
||||||
# Close with a comment
|
# Close with a comment
|
||||||
fgj pr close 5 -c "Won't merge, superseded by #10"`,
|
fj pr close 5 -c "Won't merge, superseded by #10"`,
|
||||||
Args: cobra.ExactArgs(1),
|
Args: cobra.ExactArgs(1),
|
||||||
RunE: runPRClose,
|
RunE: runPRClose,
|
||||||
}
|
}
|
||||||
|
|
@ -166,7 +110,7 @@ var prReopenCmd = &cobra.Command{
|
||||||
Short: "Reopen a pull request",
|
Short: "Reopen a pull request",
|
||||||
Long: "Reopen a closed pull request.",
|
Long: "Reopen a closed pull request.",
|
||||||
Example: ` # Reopen PR #5
|
Example: ` # Reopen PR #5
|
||||||
fgj pr reopen 5`,
|
fj pr reopen 5`,
|
||||||
Args: cobra.ExactArgs(1),
|
Args: cobra.ExactArgs(1),
|
||||||
RunE: runPRReopen,
|
RunE: runPRReopen,
|
||||||
}
|
}
|
||||||
|
|
@ -176,13 +120,13 @@ var prEditCmd = &cobra.Command{
|
||||||
Short: "Edit a pull request",
|
Short: "Edit a pull request",
|
||||||
Long: "Edit a pull request's title, body, or metadata.",
|
Long: "Edit a pull request's title, body, or metadata.",
|
||||||
Example: ` # Update the title of PR #5
|
Example: ` # Update the title of PR #5
|
||||||
fgj pr edit 5 -t "Updated title"
|
fj pr edit 5 -t "Updated title"
|
||||||
|
|
||||||
# Add assignees and labels
|
# Add assignees and labels
|
||||||
fgj pr edit 5 --add-assignee user1 --add-label bug
|
fj pr edit 5 --add-assignee user1 --add-label bug
|
||||||
|
|
||||||
# Remove a reviewer and set milestone
|
# Remove a reviewer and set milestone
|
||||||
fgj pr edit 5 --remove-reviewer user2 --milestone "v1.0"`,
|
fj pr edit 5 --remove-reviewer user2 --milestone "v1.0"`,
|
||||||
Args: cobra.ExactArgs(1),
|
Args: cobra.ExactArgs(1),
|
||||||
RunE: runPREdit,
|
RunE: runPREdit,
|
||||||
}
|
}
|
||||||
|
|
@ -192,7 +136,7 @@ var prCheckoutCmd = &cobra.Command{
|
||||||
Short: "Check out a pull request locally",
|
Short: "Check out a pull request locally",
|
||||||
Long: "Check out the head branch of a pull request.",
|
Long: "Check out the head branch of a pull request.",
|
||||||
Example: ` # Check out PR #5
|
Example: ` # Check out PR #5
|
||||||
fgj pr checkout 5`,
|
fj pr checkout 5`,
|
||||||
Args: cobra.ExactArgs(1),
|
Args: cobra.ExactArgs(1),
|
||||||
RunE: runPRCheckout,
|
RunE: runPRCheckout,
|
||||||
}
|
}
|
||||||
|
|
@ -223,8 +167,6 @@ func init() {
|
||||||
prListCmd.Flags().Bool("draft", false, "Filter by draft status")
|
prListCmd.Flags().Bool("draft", false, "Filter by draft status")
|
||||||
prListCmd.Flags().String("head", "", "Filter by head branch")
|
prListCmd.Flags().String("head", "", "Filter by head branch")
|
||||||
prListCmd.Flags().String("base", "", "Filter by base branch")
|
prListCmd.Flags().String("base", "", "Filter by base branch")
|
||||||
prListCmd.Flags().String("since", "", "Only items updated at or after this date (YYYY-MM-DD, RFC 3339, or relative like 7d)")
|
|
||||||
prListCmd.Flags().String("before", "", "Only items updated strictly before this date (YYYY-MM-DD, RFC 3339, or relative like 1d)")
|
|
||||||
prListCmd.Flags().BoolP("web", "w", false, "Open list in web browser")
|
prListCmd.Flags().BoolP("web", "w", false, "Open list in web browser")
|
||||||
addJSONFlags(prListCmd, "Output pull requests as JSON")
|
addJSONFlags(prListCmd, "Output pull requests as JSON")
|
||||||
|
|
||||||
|
|
@ -275,27 +217,6 @@ func runPRList(cmd *cobra.Command, args []string) error {
|
||||||
draft, _ := cmd.Flags().GetBool("draft")
|
draft, _ := cmd.Flags().GetBool("draft")
|
||||||
head, _ := cmd.Flags().GetString("head")
|
head, _ := cmd.Flags().GetString("head")
|
||||||
base, _ := cmd.Flags().GetString("base")
|
base, _ := cmd.Flags().GetString("base")
|
||||||
sinceStr, _ := cmd.Flags().GetString("since")
|
|
||||||
beforeStr, _ := cmd.Flags().GetString("before")
|
|
||||||
|
|
||||||
var sinceTime, beforeTime time.Time
|
|
||||||
var hasSince, hasBefore bool
|
|
||||||
if sinceStr != "" {
|
|
||||||
t, err := parseDateArg(sinceStr)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("invalid --since: %w", err)
|
|
||||||
}
|
|
||||||
sinceTime = t
|
|
||||||
hasSince = true
|
|
||||||
}
|
|
||||||
if beforeStr != "" {
|
|
||||||
t, err := parseDateArg(beforeStr)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("invalid --before: %w", err)
|
|
||||||
}
|
|
||||||
beforeTime = t
|
|
||||||
hasBefore = true
|
|
||||||
}
|
|
||||||
|
|
||||||
owner, name, err := parseRepo(repo)
|
owner, name, err := parseRepo(repo)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -328,46 +249,35 @@ func runPRList(cmd *cobra.Command, args []string) error {
|
||||||
return fmt.Errorf("invalid state: %s", state)
|
return fmt.Errorf("invalid state: %s", state)
|
||||||
}
|
}
|
||||||
|
|
||||||
// server-side since/before unsupported for pulls; filtering client-side
|
needsClientFilter := assignee != "" || author != "" || len(labels) > 0 || search != "" || draft || head != "" || base != ""
|
||||||
needsClientFilter := assignee != "" || author != "" || len(labels) > 0 || search != "" || draft || head != "" || base != "" || hasSince || hasBefore
|
|
||||||
|
|
||||||
ios.StartSpinner("Fetching pull requests...")
|
ios.StartSpinner("Fetching pull requests...")
|
||||||
|
// When client-side filtering is needed, pull pages until exhausted (no
|
||||||
|
// limit) so we can apply filters; otherwise paginate up to the user's
|
||||||
|
// limit. Either way, paginate — `PageSize: limit` capped at 50 silently.
|
||||||
|
fetchPage := func(page, pageSize int) ([]*gitea.PullRequest, error) {
|
||||||
|
batch, _, err := client.ListRepoPullRequests(owner, name, gitea.ListPullRequestsOptions{
|
||||||
|
State: stateType,
|
||||||
|
ListOptions: gitea.ListOptions{Page: page, PageSize: pageSize},
|
||||||
|
})
|
||||||
|
return batch, err
|
||||||
|
}
|
||||||
var prs []*gitea.PullRequest
|
var prs []*gitea.PullRequest
|
||||||
if needsClientFilter {
|
if needsClientFilter {
|
||||||
page := 1
|
prs, err = paginateGitea(0, fetchPage) // pull all, then filter + limit
|
||||||
for {
|
if err == nil {
|
||||||
batch, _, err := client.ListRepoPullRequests(owner, name, gitea.ListPullRequestsOptions{
|
prs = filterPRs(prs, author, assignee, labels, search, draft, head, base)
|
||||||
State: stateType,
|
if limit > 0 && len(prs) > limit {
|
||||||
ListOptions: gitea.ListOptions{Page: page, PageSize: 50},
|
prs = prs[:limit]
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
ios.StopSpinner()
|
|
||||||
return fmt.Errorf("failed to list pull requests: %w", err)
|
|
||||||
}
|
}
|
||||||
prs = append(prs, batch...)
|
|
||||||
if len(batch) < 50 {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
page++
|
|
||||||
}
|
|
||||||
prs = filterPRs(prs, author, assignee, labels, search, draft, head, base)
|
|
||||||
if hasSince || hasBefore {
|
|
||||||
prs = filterPRsByDate(prs, sinceTime, hasSince, beforeTime, hasBefore)
|
|
||||||
}
|
|
||||||
if len(prs) > limit {
|
|
||||||
prs = prs[:limit]
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
prs, _, err = client.ListRepoPullRequests(owner, name, gitea.ListPullRequestsOptions{
|
prs, err = paginateGitea(limit, fetchPage)
|
||||||
State: stateType,
|
|
||||||
ListOptions: gitea.ListOptions{PageSize: limit},
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
ios.StopSpinner()
|
|
||||||
return fmt.Errorf("failed to list pull requests: %w", err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
ios.StopSpinner()
|
ios.StopSpinner()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to list pull requests: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
if wantJSON(cmd) {
|
if wantJSON(cmd) {
|
||||||
return outputJSON(cmd, prs)
|
return outputJSON(cmd, prs)
|
||||||
|
|
@ -437,27 +347,6 @@ func filterPRs(prs []*gitea.PullRequest, author, assignee string, labels []strin
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// filterPRsByDate applies the --since / --before range against pr.Updated.
|
|
||||||
func filterPRsByDate(prs []*gitea.PullRequest, since time.Time, hasSince bool, before time.Time, hasBefore bool) []*gitea.PullRequest {
|
|
||||||
if !hasSince && !hasBefore {
|
|
||||||
return prs
|
|
||||||
}
|
|
||||||
result := make([]*gitea.PullRequest, 0, len(prs))
|
|
||||||
for _, pr := range prs {
|
|
||||||
if pr.Updated == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if hasSince && pr.Updated.Before(since) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if hasBefore && !pr.Updated.Before(before) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
result = append(result, pr)
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
func runPRView(cmd *cobra.Command, args []string) error {
|
func runPRView(cmd *cobra.Command, args []string) error {
|
||||||
repo, _ := cmd.Flags().GetString("repo")
|
repo, _ := cmd.Flags().GetString("repo")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,102 +0,0 @@
|
||||||
package cmd
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"code.gitea.io/sdk/gitea"
|
|
||||||
"forgejo.zerova.net/public/fgj-sid/internal/api"
|
|
||||||
"forgejo.zerova.net/public/fgj-sid/internal/config"
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
)
|
|
||||||
|
|
||||||
var prApproveCmd = &cobra.Command{
|
|
||||||
Use: "approve <number>",
|
|
||||||
Aliases: []string{"lgtm"},
|
|
||||||
Short: "Approve a pull request",
|
|
||||||
Long: "Shortcut for 'fgj pr review <n> --approve'. Body is optional.",
|
|
||||||
Example: ` # Approve with no body
|
|
||||||
fgj pr approve 42
|
|
||||||
|
|
||||||
# Approve with a message
|
|
||||||
fgj pr approve 42 -b "Thanks, shipping."`,
|
|
||||||
Args: cobra.ExactArgs(1),
|
|
||||||
RunE: runPRApproveReject(gitea.ReviewStateApproved, "approved", false),
|
|
||||||
}
|
|
||||||
|
|
||||||
var prRejectCmd = &cobra.Command{
|
|
||||||
Use: "reject <number>",
|
|
||||||
Short: "Request changes on a pull request",
|
|
||||||
Long: "Shortcut for 'fgj pr review <n> --request-changes'. Body is required.",
|
|
||||||
Example: ` # Reject with explanation
|
|
||||||
fgj pr reject 42 -b "See the inline comments on auth.go"
|
|
||||||
|
|
||||||
# Reject with a longer message from a file
|
|
||||||
fgj pr reject 42 --body-file feedback.md`,
|
|
||||||
Args: cobra.ExactArgs(1),
|
|
||||||
RunE: runPRApproveReject(gitea.ReviewStateRequestChanges, "reviewed with requested changes", true),
|
|
||||||
}
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
prCmd.AddCommand(prApproveCmd)
|
|
||||||
prCmd.AddCommand(prRejectCmd)
|
|
||||||
|
|
||||||
for _, c := range []*cobra.Command{prApproveCmd, prRejectCmd} {
|
|
||||||
c.Flags().StringP("repo", "R", "", "Repository in owner/name format")
|
|
||||||
c.Flags().StringP("body", "b", "", "Review body/message")
|
|
||||||
c.Flags().String("body-file", "", "Read body from file (use \"-\" for stdin)")
|
|
||||||
addJSONFlags(c, "Output created review as JSON")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func runPRApproveReject(state gitea.ReviewStateType, verb string, requireBody bool) func(cmd *cobra.Command, args []string) error {
|
|
||||||
return func(cmd *cobra.Command, args []string) error {
|
|
||||||
prNumber, err := parseIssueArg(args[0])
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("invalid pull request number: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
body, err := readBody(cmd)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if requireBody && body == "" {
|
|
||||||
return fmt.Errorf("a body is required (use --body or --body-file)")
|
|
||||||
}
|
|
||||||
|
|
||||||
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("Submitting review...")
|
|
||||||
review, _, err := client.CreatePullReview(owner, name, prNumber, gitea.CreatePullReviewOptions{
|
|
||||||
State: state,
|
|
||||||
Body: body,
|
|
||||||
})
|
|
||||||
ios.StopSpinner()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to submit review: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if wantJSON(cmd) {
|
|
||||||
return outputJSON(cmd, review)
|
|
||||||
}
|
|
||||||
|
|
||||||
cs := ios.ColorScheme()
|
|
||||||
fmt.Fprintf(ios.Out, "%s PR #%d %s\n", cs.SuccessIcon(), prNumber, verb)
|
|
||||||
if review.HTMLURL != "" {
|
|
||||||
fmt.Fprintf(ios.Out, "View at: %s\n", review.HTMLURL)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -4,9 +4,9 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"code.gitea.io/sdk/gitea"
|
"code.gitea.io/sdk/gitea"
|
||||||
"forgejo.zerova.net/public/fgj-sid/internal/api"
|
"forgejo.zerova.net/public/fj/internal/api"
|
||||||
"forgejo.zerova.net/public/fgj-sid/internal/config"
|
"forgejo.zerova.net/public/fj/internal/config"
|
||||||
"forgejo.zerova.net/public/fgj-sid/internal/iostreams"
|
"forgejo.zerova.net/public/fj/internal/iostreams"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -15,10 +15,10 @@ var prChecksCmd = &cobra.Command{
|
||||||
Short: "Show CI status checks for a pull request",
|
Short: "Show CI status checks for a pull request",
|
||||||
Long: "Show the status of CI checks for a pull request.",
|
Long: "Show the status of CI checks for a pull request.",
|
||||||
Example: ` # Show checks for PR #5
|
Example: ` # Show checks for PR #5
|
||||||
fgj pr checks 5
|
fj pr checks 5
|
||||||
|
|
||||||
# Output as JSON
|
# Output as JSON
|
||||||
fgj pr checks 5 --json`,
|
fj pr checks 5 --json`,
|
||||||
Args: cobra.ExactArgs(1),
|
Args: cobra.ExactArgs(1),
|
||||||
RunE: runPRChecks,
|
RunE: runPRChecks,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,98 +0,0 @@
|
||||||
package cmd
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"os/exec"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"forgejo.zerova.net/public/fgj-sid/internal/api"
|
|
||||||
"forgejo.zerova.net/public/fgj-sid/internal/config"
|
|
||||||
"forgejo.zerova.net/public/fgj-sid/internal/git"
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
)
|
|
||||||
|
|
||||||
var prCleanCmd = &cobra.Command{
|
|
||||||
Use: "clean <number>",
|
|
||||||
Short: "Delete the local branch created by 'pr checkout'",
|
|
||||||
Long: `Remove the local branch that was checked out for a pull request.
|
|
||||||
|
|
||||||
For safety, the PR must be closed (merged or declined). If the branch is
|
|
||||||
currently checked out, switch away first — this command refuses to delete
|
|
||||||
your active branch.
|
|
||||||
|
|
||||||
Pass --force to delete the local branch even if the PR is still open.`,
|
|
||||||
Example: ` # Clean up after a merged PR
|
|
||||||
fgj pr clean 42
|
|
||||||
|
|
||||||
# Force-delete local branch for an open PR
|
|
||||||
fgj pr clean 42 --force`,
|
|
||||||
Args: cobra.ExactArgs(1),
|
|
||||||
RunE: runPRClean,
|
|
||||||
}
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
prCmd.AddCommand(prCleanCmd)
|
|
||||||
addRepoFlags(prCleanCmd)
|
|
||||||
prCleanCmd.Flags().Bool("force", false, "Delete the local branch even if the PR is still open")
|
|
||||||
}
|
|
||||||
|
|
||||||
func runPRClean(cmd *cobra.Command, args []string) error {
|
|
||||||
prNumber, err := parseIssueArg(args[0])
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("invalid pull request number: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
repoFlag, _ := cmd.Flags().GetString("repo")
|
|
||||||
owner, name, err := parseRepo(repoFlag)
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
force, _ := cmd.Flags().GetBool("force")
|
|
||||||
|
|
||||||
pr, _, err := client.GetPullRequest(owner, name, prNumber)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to get pull request: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !force && string(pr.State) == "open" {
|
|
||||||
return fmt.Errorf("PR #%d is still open; refuse to delete local branch without --force", prNumber)
|
|
||||||
}
|
|
||||||
|
|
||||||
headBranch := pr.Head.Ref
|
|
||||||
if headBranch == "" {
|
|
||||||
return fmt.Errorf("PR #%d has no head branch to clean (it may have been deleted already)", prNumber)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Refuse to delete the current branch.
|
|
||||||
current, err := git.GetCurrentBranch()
|
|
||||||
if err == nil && current == headBranch {
|
|
||||||
return fmt.Errorf("branch %q is currently checked out; switch to another branch first (e.g. 'git switch main')", headBranch)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check local branch exists.
|
|
||||||
if out, _ := exec.Command("git", "rev-parse", "--verify", "--quiet", "refs/heads/"+headBranch).Output(); len(strings.TrimSpace(string(out))) == 0 {
|
|
||||||
fmt.Fprintf(ios.ErrOut, "Local branch %q not found; nothing to clean.\n", headBranch)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
delCmd := exec.Command("git", "branch", "-D", headBranch)
|
|
||||||
delCmd.Stdout = ios.Out
|
|
||||||
delCmd.Stderr = ios.ErrOut
|
|
||||||
if err := delCmd.Run(); err != nil {
|
|
||||||
return fmt.Errorf("failed to delete local branch %q: %w", headBranch, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
cs := ios.ColorScheme()
|
|
||||||
fmt.Fprintf(ios.Out, "%s Deleted local branch %q\n", cs.SuccessIcon(), headBranch)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
@ -4,8 +4,8 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"forgejo.zerova.net/public/fgj-sid/internal/api"
|
"forgejo.zerova.net/public/fj/internal/api"
|
||||||
"forgejo.zerova.net/public/fgj-sid/internal/config"
|
"forgejo.zerova.net/public/fj/internal/config"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -14,16 +14,16 @@ var prDiffCmd = &cobra.Command{
|
||||||
Short: "Show the diff for a pull request",
|
Short: "Show the diff for a pull request",
|
||||||
Long: "Fetch and display the diff for a pull request.",
|
Long: "Fetch and display the diff for a pull request.",
|
||||||
Example: ` # View the diff for PR #123
|
Example: ` # View the diff for PR #123
|
||||||
fgj pr diff 123
|
fj pr diff 123
|
||||||
|
|
||||||
# Colorized diff output
|
# Colorized diff output
|
||||||
fgj pr diff 123 --color always
|
fj pr diff 123 --color always
|
||||||
|
|
||||||
# Show only changed file names
|
# Show only changed file names
|
||||||
fgj pr diff 123 --name-only
|
fj pr diff 123 --name-only
|
||||||
|
|
||||||
# Show diffstat summary
|
# Show diffstat summary
|
||||||
fgj pr diff 123 --stat`,
|
fj pr diff 123 --stat`,
|
||||||
Args: cobra.ExactArgs(1),
|
Args: cobra.ExactArgs(1),
|
||||||
RunE: runPRDiff,
|
RunE: runPRDiff,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,8 @@ import (
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
"code.gitea.io/sdk/gitea"
|
"code.gitea.io/sdk/gitea"
|
||||||
"forgejo.zerova.net/public/fgj-sid/internal/api"
|
"forgejo.zerova.net/public/fj/internal/api"
|
||||||
"forgejo.zerova.net/public/fgj-sid/internal/config"
|
"forgejo.zerova.net/public/fj/internal/config"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -16,16 +16,16 @@ var prCommentCmd = &cobra.Command{
|
||||||
Short: "Add a comment to a pull request",
|
Short: "Add a comment to a pull request",
|
||||||
Long: "Add a comment to an existing pull request.",
|
Long: "Add a comment to an existing pull request.",
|
||||||
Example: ` # Add a comment
|
Example: ` # Add a comment
|
||||||
fgj pr comment 123 -b "Looks good!"
|
fj pr comment 123 -b "Looks good!"
|
||||||
|
|
||||||
# Comment from a file
|
# Comment from a file
|
||||||
fgj pr comment 123 --body-file review-notes.md
|
fj pr comment 123 --body-file review-notes.md
|
||||||
|
|
||||||
# Comment from stdin
|
# Comment from stdin
|
||||||
echo "LGTM" | fgj pr comment 123 --body-file -
|
echo "LGTM" | fj pr comment 123 --body-file -
|
||||||
|
|
||||||
# Output as JSON
|
# Output as JSON
|
||||||
fgj pr comment 123 -b "Nice work" --json`,
|
fj pr comment 123 -b "Nice work" --json`,
|
||||||
Args: cobra.ExactArgs(1),
|
Args: cobra.ExactArgs(1),
|
||||||
RunE: runPRComment,
|
RunE: runPRComment,
|
||||||
}
|
}
|
||||||
|
|
@ -35,16 +35,16 @@ var prReviewCmd = &cobra.Command{
|
||||||
Short: "Submit a review on a pull request",
|
Short: "Submit a review on a pull request",
|
||||||
Long: "Submit a review on a pull request. Exactly one of --approve, --request-changes, or --comment must be specified.",
|
Long: "Submit a review on a pull request. Exactly one of --approve, --request-changes, or --comment must be specified.",
|
||||||
Example: ` # Approve a PR
|
Example: ` # Approve a PR
|
||||||
fgj pr review 123 --approve -b "LGTM"
|
fj pr review 123 --approve -b "LGTM"
|
||||||
|
|
||||||
# Request changes
|
# Request changes
|
||||||
fgj pr review 123 --request-changes -b "Please fix the error handling"
|
fj pr review 123 --request-changes -b "Please fix the error handling"
|
||||||
|
|
||||||
# Submit a review comment
|
# Submit a review comment
|
||||||
fgj pr review 123 --comment -b "Some observations"
|
fj pr review 123 --comment -b "Some observations"
|
||||||
|
|
||||||
# Request changes with body from file
|
# Request changes with body from file
|
||||||
fgj pr review 123 --request-changes --body-file feedback.md`,
|
fj pr review 123 --request-changes --body-file feedback.md`,
|
||||||
Args: cobra.ExactArgs(1),
|
Args: cobra.ExactArgs(1),
|
||||||
RunE: runPRReview,
|
RunE: runPRReview,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,171 +0,0 @@
|
||||||
package cmd
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"code.gitea.io/sdk/gitea"
|
|
||||||
"forgejo.zerova.net/public/fgj-sid/internal/api"
|
|
||||||
"forgejo.zerova.net/public/fgj-sid/internal/config"
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
)
|
|
||||||
|
|
||||||
var prReviewCommentsCmd = &cobra.Command{
|
|
||||||
Use: "review-comments <number>",
|
|
||||||
Aliases: []string{"rc"},
|
|
||||||
Short: "List review comments on a pull request",
|
|
||||||
Long: "List all review comments (inline code comments) across every review on a PR.",
|
|
||||||
Example: ` # List review comments on PR #42
|
|
||||||
fgj pr review-comments 42
|
|
||||||
|
|
||||||
# As JSON
|
|
||||||
fgj pr review-comments 42 --json`,
|
|
||||||
Args: cobra.ExactArgs(1),
|
|
||||||
RunE: runPRReviewComments,
|
|
||||||
}
|
|
||||||
|
|
||||||
var prResolveCmd = &cobra.Command{
|
|
||||||
Use: "resolve <comment-id>",
|
|
||||||
Short: "Resolve a PR review comment",
|
|
||||||
Long: `Mark a pull request review comment as resolved. Comment IDs are shown
|
|
||||||
in the output of 'fgj pr review-comments'.
|
|
||||||
|
|
||||||
Requires Forgejo 8.x+ / Gitea 1.22+.`,
|
|
||||||
Args: cobra.ExactArgs(1),
|
|
||||||
RunE: runPRResolveComment(true),
|
|
||||||
}
|
|
||||||
|
|
||||||
var prUnresolveCmd = &cobra.Command{
|
|
||||||
Use: "unresolve <comment-id>",
|
|
||||||
Short: "Unresolve a PR review comment",
|
|
||||||
Long: "Reopen a previously-resolved pull request review comment.",
|
|
||||||
Args: cobra.ExactArgs(1),
|
|
||||||
RunE: runPRResolveComment(false),
|
|
||||||
}
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
prCmd.AddCommand(prReviewCommentsCmd)
|
|
||||||
prCmd.AddCommand(prResolveCmd)
|
|
||||||
prCmd.AddCommand(prUnresolveCmd)
|
|
||||||
|
|
||||||
addRepoFlags(prReviewCommentsCmd)
|
|
||||||
addJSONFlags(prReviewCommentsCmd, "Output as JSON")
|
|
||||||
|
|
||||||
addRepoFlags(prResolveCmd)
|
|
||||||
addRepoFlags(prUnresolveCmd)
|
|
||||||
}
|
|
||||||
|
|
||||||
func runPRReviewComments(cmd *cobra.Command, args []string) error {
|
|
||||||
prNumber, err := parseIssueArg(args[0])
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("invalid pull request number: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
client, owner, name, err := newPRCommentClient(cmd)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
reviews, _, err := client.ListPullReviews(owner, name, prNumber, gitea.ListPullReviewsOptions{})
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to list reviews: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var all []*gitea.PullReviewComment
|
|
||||||
for _, r := range reviews {
|
|
||||||
comments, _, err := client.ListPullReviewComments(owner, name, prNumber, r.ID)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to list comments for review %d: %w", r.ID, err)
|
|
||||||
}
|
|
||||||
all = append(all, comments...)
|
|
||||||
}
|
|
||||||
|
|
||||||
if wantJSON(cmd) {
|
|
||||||
return outputJSON(cmd, all)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(all) == 0 {
|
|
||||||
fmt.Fprintln(ios.Out, "No review comments on this PR.")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
tp := ios.NewTablePrinter()
|
|
||||||
tp.AddHeader("ID", "REVIEWER", "PATH", "LINE", "RESOLVED", "BODY")
|
|
||||||
for _, c := range all {
|
|
||||||
reviewer := ""
|
|
||||||
if c.Reviewer != nil {
|
|
||||||
reviewer = c.Reviewer.UserName
|
|
||||||
}
|
|
||||||
resolved := ""
|
|
||||||
if c.Resolver != nil {
|
|
||||||
resolved = "yes"
|
|
||||||
}
|
|
||||||
// Collapse multi-line bodies for table view.
|
|
||||||
body := strings.ReplaceAll(c.Body, "\n", " ")
|
|
||||||
if len(body) > 80 {
|
|
||||||
body = body[:77] + "..."
|
|
||||||
}
|
|
||||||
tp.AddRow(
|
|
||||||
fmt.Sprintf("%d", c.ID),
|
|
||||||
reviewer,
|
|
||||||
c.Path,
|
|
||||||
fmt.Sprintf("%d", c.LineNum),
|
|
||||||
resolved,
|
|
||||||
body,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return tp.Render()
|
|
||||||
}
|
|
||||||
|
|
||||||
// runPRResolveComment returns a RunE closure that either resolves or unresolves
|
|
||||||
// a review comment, depending on the `resolve` flag. The underlying SDK
|
|
||||||
// (v0.22.1) doesn't expose these endpoints yet, so we call them raw.
|
|
||||||
func runPRResolveComment(resolve bool) func(cmd *cobra.Command, args []string) error {
|
|
||||||
return func(cmd *cobra.Command, args []string) error {
|
|
||||||
id, err := parseIssueArg(args[0])
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("invalid comment id: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
client, owner, name, err := newPRCommentClient(cmd)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
path := fmt.Sprintf("/api/v1/repos/%s/%s/pulls/comments/%d", owner, name, id)
|
|
||||||
action := "unresolve"
|
|
||||||
if resolve {
|
|
||||||
action = "resolve"
|
|
||||||
}
|
|
||||||
endpoint := path + "/" + action
|
|
||||||
|
|
||||||
if err := client.PostJSON(endpoint, map[string]any{}, nil); err != nil {
|
|
||||||
return fmt.Errorf("failed to %s comment %d: %w", action, id, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
cs := ios.ColorScheme()
|
|
||||||
verb := "Resolved"
|
|
||||||
if !resolve {
|
|
||||||
verb = "Unresolved"
|
|
||||||
}
|
|
||||||
fmt.Fprintf(ios.Out, "%s %s comment %d\n", cs.SuccessIcon(), verb, id)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func newPRCommentClient(cmd *cobra.Command) (*api.Client, string, string, error) {
|
|
||||||
repoFlag, _ := cmd.Flags().GetString("repo")
|
|
||||||
owner, name, err := parseRepo(repoFlag)
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
@ -9,9 +9,9 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"code.gitea.io/sdk/gitea"
|
"code.gitea.io/sdk/gitea"
|
||||||
"forgejo.zerova.net/public/fgj-sid/internal/api"
|
"forgejo.zerova.net/public/fj/internal/api"
|
||||||
"forgejo.zerova.net/public/fgj-sid/internal/config"
|
"forgejo.zerova.net/public/fj/internal/config"
|
||||||
"forgejo.zerova.net/public/fgj-sid/internal/text"
|
"forgejo.zerova.net/public/fj/internal/text"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -27,13 +27,13 @@ var releaseListCmd = &cobra.Command{
|
||||||
Short: "List releases",
|
Short: "List releases",
|
||||||
Long: "List releases in a repository.",
|
Long: "List releases in a repository.",
|
||||||
Example: ` # List releases
|
Example: ` # List releases
|
||||||
fgj release list
|
fj release list
|
||||||
|
|
||||||
# List only draft releases
|
# List only draft releases
|
||||||
fgj release list --draft
|
fj release list --draft
|
||||||
|
|
||||||
# Output as JSON with a custom limit
|
# Output as JSON with a custom limit
|
||||||
fgj release list --json --limit 10`,
|
fj release list --json --limit 10`,
|
||||||
RunE: runReleaseList,
|
RunE: runReleaseList,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -42,16 +42,16 @@ var releaseViewCmd = &cobra.Command{
|
||||||
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
|
Example: ` # View a release by tag
|
||||||
fgj release view v1.0.0
|
fj release view v1.0.0
|
||||||
|
|
||||||
# View the latest release
|
# View the latest release
|
||||||
fgj release view latest
|
fj release view latest
|
||||||
|
|
||||||
# Open in browser
|
# Open in browser
|
||||||
fgj release view v1.0.0 --web
|
fj release view v1.0.0 --web
|
||||||
|
|
||||||
# Output as JSON
|
# Output as JSON
|
||||||
fgj release view v1.0.0 --json`,
|
fj release view v1.0.0 --json`,
|
||||||
Args: cobra.ExactArgs(1),
|
Args: cobra.ExactArgs(1),
|
||||||
RunE: runReleaseView,
|
RunE: runReleaseView,
|
||||||
}
|
}
|
||||||
|
|
@ -61,16 +61,16 @@ var releaseCreateCmd = &cobra.Command{
|
||||||
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
|
Example: ` # Create a release
|
||||||
fgj release create v1.0.0
|
fj release create v1.0.0
|
||||||
|
|
||||||
# Create with title and notes
|
# Create with title and notes
|
||||||
fgj release create v1.0.0 -t "First stable release" -n "Bug fixes and improvements"
|
fj release create v1.0.0 -t "First stable release" -n "Bug fixes and improvements"
|
||||||
|
|
||||||
# Create a draft prerelease with assets
|
# Create a draft prerelease with assets
|
||||||
fgj release create v2.0.0-rc1 --draft --prerelease dist/*.tar.gz
|
fj release create v2.0.0-rc1 --draft --prerelease dist/*.tar.gz
|
||||||
|
|
||||||
# Create from release notes file
|
# Create from release notes file
|
||||||
fgj release create v1.0.0 -F CHANGELOG.md`,
|
fj release create v1.0.0 -F CHANGELOG.md`,
|
||||||
Args: cobra.MinimumNArgs(1),
|
Args: cobra.MinimumNArgs(1),
|
||||||
RunE: runReleaseCreate,
|
RunE: runReleaseCreate,
|
||||||
}
|
}
|
||||||
|
|
@ -80,10 +80,10 @@ var releaseUploadCmd = &cobra.Command{
|
||||||
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
|
Example: ` # Upload assets to a release
|
||||||
fgj release upload v1.0.0 dist/app-linux-amd64 dist/app-darwin-arm64
|
fj release upload v1.0.0 dist/app-linux-amd64 dist/app-darwin-arm64
|
||||||
|
|
||||||
# Upload to the latest release, overwriting existing assets
|
# Upload to the latest release, overwriting existing assets
|
||||||
fgj release upload latest build/output.zip --clobber`,
|
fj release upload latest build/output.zip --clobber`,
|
||||||
Args: cobra.MinimumNArgs(2),
|
Args: cobra.MinimumNArgs(2),
|
||||||
RunE: runReleaseUpload,
|
RunE: runReleaseUpload,
|
||||||
}
|
}
|
||||||
|
|
@ -93,13 +93,13 @@ var releaseDownloadCmd = &cobra.Command{
|
||||||
Short: "Download release assets",
|
Short: "Download release assets",
|
||||||
Long: "Download assets from a release.",
|
Long: "Download assets from a release.",
|
||||||
Example: ` # Download all assets from a release
|
Example: ` # Download all assets from a release
|
||||||
fgj release download v1.0.0
|
fj release download v1.0.0
|
||||||
|
|
||||||
# Download to a specific directory
|
# Download to a specific directory
|
||||||
fgj release download v1.0.0 -D ./downloads
|
fj release download v1.0.0 -D ./downloads
|
||||||
|
|
||||||
# Download a specific asset by name pattern
|
# Download a specific asset by name pattern
|
||||||
fgj release download v1.0.0 -p "*.tar.gz"`,
|
fj release download v1.0.0 -p "*.tar.gz"`,
|
||||||
Args: cobra.ExactArgs(1),
|
Args: cobra.ExactArgs(1),
|
||||||
RunE: runReleaseDownload,
|
RunE: runReleaseDownload,
|
||||||
}
|
}
|
||||||
|
|
@ -109,13 +109,13 @@ var releaseDeleteCmd = &cobra.Command{
|
||||||
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
|
Example: ` # Delete a release by tag
|
||||||
fgj release delete v1.0.0
|
fj release delete v1.0.0
|
||||||
|
|
||||||
# Delete the latest release
|
# Delete the latest release
|
||||||
fgj release delete latest
|
fj release delete latest
|
||||||
|
|
||||||
# Delete without confirmation
|
# Delete without confirmation
|
||||||
fgj release delete v1.0.0 -y`,
|
fj release delete v1.0.0 -y`,
|
||||||
Args: cobra.ExactArgs(1),
|
Args: cobra.ExactArgs(1),
|
||||||
RunE: runReleaseDelete,
|
RunE: runReleaseDelete,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,310 +0,0 @@
|
||||||
package cmd
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strconv"
|
|
||||||
|
|
||||||
"code.gitea.io/sdk/gitea"
|
|
||||||
"forgejo.zerova.net/public/fgj-sid/internal/api"
|
|
||||||
"forgejo.zerova.net/public/fgj-sid/internal/config"
|
|
||||||
"forgejo.zerova.net/public/fgj-sid/internal/text"
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
)
|
|
||||||
|
|
||||||
var releaseAssetCmd = &cobra.Command{
|
|
||||||
Use: "asset",
|
|
||||||
Aliases: []string{"assets"},
|
|
||||||
Short: "Manage release assets",
|
|
||||||
Long: "List, upload, and delete individual attachments on a release.",
|
|
||||||
}
|
|
||||||
|
|
||||||
var releaseAssetListCmd = &cobra.Command{
|
|
||||||
Use: "list <tag|latest>",
|
|
||||||
Short: "List release assets",
|
|
||||||
Long: "List attachments on a release identified by tag name (or \"latest\").",
|
|
||||||
Example: ` # List assets on a release
|
|
||||||
fgj release asset list v1.0.0
|
|
||||||
|
|
||||||
# List assets on the latest release as JSON
|
|
||||||
fgj release asset list latest --json`,
|
|
||||||
Args: cobra.ExactArgs(1),
|
|
||||||
RunE: runReleaseAssetList,
|
|
||||||
}
|
|
||||||
|
|
||||||
var releaseAssetCreateCmd = &cobra.Command{
|
|
||||||
Use: "create <tag|latest> <files...>",
|
|
||||||
Short: "Upload one or more release assets",
|
|
||||||
Long: "Upload one or more files as attachments to an existing release.",
|
|
||||||
Example: ` # Upload a single asset
|
|
||||||
fgj release asset create v1.0.0 dist/app-linux-amd64
|
|
||||||
|
|
||||||
# Upload multiple assets to the latest release
|
|
||||||
fgj release asset create latest dist/*.tar.gz
|
|
||||||
|
|
||||||
# Upload a single file under a different name
|
|
||||||
fgj release asset create v1.0.0 ./build/out --name app-v1.0.0`,
|
|
||||||
Args: cobra.MinimumNArgs(2),
|
|
||||||
RunE: runReleaseAssetCreate,
|
|
||||||
}
|
|
||||||
|
|
||||||
var releaseAssetDeleteCmd = &cobra.Command{
|
|
||||||
Use: "delete <tag|latest> <asset-id-or-name>...",
|
|
||||||
Short: "Delete one or more release assets",
|
|
||||||
Long: "Delete attachments from a release. Each argument may be a numeric asset ID or an asset filename.",
|
|
||||||
Example: ` # Delete by filename
|
|
||||||
fgj release asset delete v1.0.0 app-linux-amd64
|
|
||||||
|
|
||||||
# Delete multiple by ID
|
|
||||||
fgj release asset delete v1.0.0 42 43
|
|
||||||
|
|
||||||
# Skip confirmation
|
|
||||||
fgj release asset delete latest output.zip --yes`,
|
|
||||||
Args: cobra.MinimumNArgs(2),
|
|
||||||
RunE: runReleaseAssetDelete,
|
|
||||||
}
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
releaseCmd.AddCommand(releaseAssetCmd)
|
|
||||||
releaseAssetCmd.AddCommand(releaseAssetListCmd)
|
|
||||||
releaseAssetCmd.AddCommand(releaseAssetCreateCmd)
|
|
||||||
releaseAssetCmd.AddCommand(releaseAssetDeleteCmd)
|
|
||||||
|
|
||||||
addRepoFlags(releaseAssetListCmd)
|
|
||||||
addJSONFlags(releaseAssetListCmd, "Output assets as JSON")
|
|
||||||
|
|
||||||
addRepoFlags(releaseAssetCreateCmd)
|
|
||||||
releaseAssetCreateCmd.Flags().String("name", "", "Override the uploaded filename (single file only)")
|
|
||||||
|
|
||||||
addRepoFlags(releaseAssetDeleteCmd)
|
|
||||||
releaseAssetDeleteCmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompt")
|
|
||||||
}
|
|
||||||
|
|
||||||
func runReleaseAssetList(cmd *cobra.Command, args []string) error {
|
|
||||||
tag := args[0]
|
|
||||||
|
|
||||||
client, owner, name, err := newReleaseAssetClient(cmd)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
ios.StartSpinner("Fetching release...")
|
|
||||||
release, err := getReleaseByTagOrLatest(client, owner, name, tag)
|
|
||||||
if err != nil {
|
|
||||||
ios.StopSpinner()
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
var all []*gitea.Attachment
|
|
||||||
for page := 1; ; page++ {
|
|
||||||
attachments, _, err := client.ListReleaseAttachments(owner, name, release.ID, gitea.ListReleaseAttachmentsOptions{
|
|
||||||
ListOptions: gitea.ListOptions{Page: page, PageSize: 50},
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
ios.StopSpinner()
|
|
||||||
return fmt.Errorf("failed to list release assets: %w", err)
|
|
||||||
}
|
|
||||||
if len(attachments) == 0 {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
all = append(all, attachments...)
|
|
||||||
}
|
|
||||||
ios.StopSpinner()
|
|
||||||
|
|
||||||
if wantJSON(cmd) {
|
|
||||||
return outputJSON(cmd, all)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(all) == 0 {
|
|
||||||
fmt.Fprintf(ios.Out, "No assets on release %s\n", release.TagName)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
isTTY := ios.IsStdoutTTY()
|
|
||||||
tp := ios.NewTablePrinter()
|
|
||||||
tp.AddHeader("ID", "NAME", "SIZE", "DOWNLOADS", "CREATED")
|
|
||||||
for _, a := range all {
|
|
||||||
tp.AddRow(
|
|
||||||
strconv.FormatInt(a.ID, 10),
|
|
||||||
a.Name,
|
|
||||||
humanSize(a.Size),
|
|
||||||
strconv.FormatInt(a.DownloadCount, 10),
|
|
||||||
text.FormatDate(a.Created, isTTY),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return tp.Render()
|
|
||||||
}
|
|
||||||
|
|
||||||
func runReleaseAssetCreate(cmd *cobra.Command, args []string) error {
|
|
||||||
tag := args[0]
|
|
||||||
files := args[1:]
|
|
||||||
|
|
||||||
nameOverride, _ := cmd.Flags().GetString("name")
|
|
||||||
if nameOverride != "" && len(files) != 1 {
|
|
||||||
return fmt.Errorf("--name may only be used when uploading a single file")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fail early if any file is missing.
|
|
||||||
for _, f := range files {
|
|
||||||
info, err := os.Stat(f)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to stat %s: %w", f, err)
|
|
||||||
}
|
|
||||||
if info.IsDir() {
|
|
||||||
return fmt.Errorf("%s is a directory, not a file", f)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
client, owner, name, err := newReleaseAssetClient(cmd)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
ios.StartSpinner("Fetching release...")
|
|
||||||
release, err := getReleaseByTagOrLatest(client, owner, name, tag)
|
|
||||||
ios.StopSpinner()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
cs := ios.ColorScheme()
|
|
||||||
for _, file := range files {
|
|
||||||
filename := filepath.Base(file)
|
|
||||||
if nameOverride != "" {
|
|
||||||
filename = nameOverride
|
|
||||||
}
|
|
||||||
|
|
||||||
handle, err := os.Open(file)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to open %s: %w", file, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
attachment, _, uploadErr := client.CreateReleaseAttachment(owner, name, release.ID, handle, filename)
|
|
||||||
closeErr := handle.Close()
|
|
||||||
if uploadErr != nil {
|
|
||||||
return fmt.Errorf("failed to upload %s: %w", file, uploadErr)
|
|
||||||
}
|
|
||||||
if closeErr != nil {
|
|
||||||
return fmt.Errorf("failed to close %s: %w", file, closeErr)
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Fprintf(ios.Out, "%s Uploaded %s (id %d, %s)\n", cs.SuccessIcon(), attachment.Name, attachment.ID, humanSize(attachment.Size))
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func runReleaseAssetDelete(cmd *cobra.Command, args []string) error {
|
|
||||||
tag := args[0]
|
|
||||||
targets := args[1:]
|
|
||||||
skipConfirm, _ := cmd.Flags().GetBool("yes")
|
|
||||||
|
|
||||||
client, owner, name, err := newReleaseAssetClient(cmd)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
ios.StartSpinner("Fetching release...")
|
|
||||||
release, err := getReleaseByTagOrLatest(client, owner, name, tag)
|
|
||||||
ios.StopSpinner()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resolve each target to an attachment (ID + name). List attachments once
|
|
||||||
// if any target is a non-numeric name so we can match by filename.
|
|
||||||
var cached []*gitea.Attachment
|
|
||||||
resolvedIDs := make([]int64, 0, len(targets))
|
|
||||||
resolvedNames := make([]string, 0, len(targets))
|
|
||||||
|
|
||||||
for _, t := range targets {
|
|
||||||
if id, err := strconv.ParseInt(t, 10, 64); err == nil && id > 0 {
|
|
||||||
resolvedIDs = append(resolvedIDs, id)
|
|
||||||
resolvedNames = append(resolvedNames, t)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if cached == nil {
|
|
||||||
cached, err = listReleaseAttachments(client, owner, name, release.ID)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var matched *gitea.Attachment
|
|
||||||
for _, a := range cached {
|
|
||||||
if a.Name == t {
|
|
||||||
matched = a
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if matched == nil {
|
|
||||||
return fmt.Errorf("no asset named %q on release %s", t, release.TagName)
|
|
||||||
}
|
|
||||||
resolvedIDs = append(resolvedIDs, matched.ID)
|
|
||||||
resolvedNames = append(resolvedNames, matched.Name)
|
|
||||||
}
|
|
||||||
|
|
||||||
cs := ios.ColorScheme()
|
|
||||||
for i, id := range resolvedIDs {
|
|
||||||
display := resolvedNames[i]
|
|
||||||
|
|
||||||
if !skipConfirm && ios.IsStdinTTY() {
|
|
||||||
answer, err := promptLine(fmt.Sprintf("Delete asset %s (id %d) from release %s? [y/N]: ", display, id, release.TagName))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if answer != "y" && answer != "Y" && answer != "yes" {
|
|
||||||
fmt.Fprintf(ios.ErrOut, "Skipped %s\n", display)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := client.DeleteReleaseAttachment(owner, name, release.ID, id); err != nil {
|
|
||||||
return fmt.Errorf("failed to delete asset %s: %w", display, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Fprintf(ios.Out, "%s Deleted asset %s (id %d)\n", cs.SuccessIcon(), display, id)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func newReleaseAssetClient(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
|
|
||||||
}
|
|
||||||
|
|
||||||
// humanSize formats a byte count using power-of-1024 units.
|
|
||||||
func humanSize(n int64) string {
|
|
||||||
const unit = int64(1024)
|
|
||||||
if n < unit {
|
|
||||||
return fmt.Sprintf("%d B", n)
|
|
||||||
}
|
|
||||||
div, exp := unit, 0
|
|
||||||
for v := n / unit; v >= unit; v /= unit {
|
|
||||||
div *= unit
|
|
||||||
exp++
|
|
||||||
}
|
|
||||||
suffixes := []string{"KB", "MB", "GB", "TB", "PB", "EB"}
|
|
||||||
if exp >= len(suffixes) {
|
|
||||||
exp = len(suffixes) - 1
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("%.1f %s", float64(n)/float64(div), suffixes[exp])
|
|
||||||
}
|
|
||||||
37
cmd/repo.go
37
cmd/repo.go
|
|
@ -8,9 +8,9 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"code.gitea.io/sdk/gitea"
|
"code.gitea.io/sdk/gitea"
|
||||||
"forgejo.zerova.net/public/fgj-sid/internal/api"
|
"forgejo.zerova.net/public/fj/internal/api"
|
||||||
"forgejo.zerova.net/public/fgj-sid/internal/config"
|
"forgejo.zerova.net/public/fj/internal/config"
|
||||||
"forgejo.zerova.net/public/fgj-sid/internal/text"
|
"forgejo.zerova.net/public/fj/internal/text"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -67,22 +67,22 @@ var repoEditCmd = &cobra.Command{
|
||||||
Short: "Edit repository settings",
|
Short: "Edit repository settings",
|
||||||
Long: "Edit settings of an existing repository such as visibility, description, homepage, and default branch.",
|
Long: "Edit settings of an existing repository such as visibility, description, homepage, and default branch.",
|
||||||
Example: ` # Make a repository private
|
Example: ` # Make a repository private
|
||||||
fgj repo edit owner/repo --private
|
fj repo edit owner/repo --private
|
||||||
|
|
||||||
# Make a repository public
|
# Make a repository public
|
||||||
fgj repo edit owner/repo --public
|
fj repo edit owner/repo --public
|
||||||
|
|
||||||
# Update description and homepage
|
# Update description and homepage
|
||||||
fgj repo edit owner/repo -d "New description" --homepage https://example.com
|
fj repo edit owner/repo -d "New description" --homepage https://example.com
|
||||||
|
|
||||||
# Change default branch
|
# Change default branch
|
||||||
fgj repo edit --default-branch develop
|
fj repo edit --default-branch develop
|
||||||
|
|
||||||
# Rename a repository
|
# Rename a repository
|
||||||
fgj repo edit owner/repo --name new-name
|
fj repo edit owner/repo --name new-name
|
||||||
|
|
||||||
# Edit current repo (auto-detected from git context)
|
# Edit current repo (auto-detected from git context)
|
||||||
fgj repo edit --public`,
|
fj repo edit --public`,
|
||||||
Args: cobra.MaximumNArgs(1),
|
Args: cobra.MaximumNArgs(1),
|
||||||
RunE: runRepoEdit,
|
RunE: runRepoEdit,
|
||||||
}
|
}
|
||||||
|
|
@ -90,12 +90,12 @@ var repoEditCmd = &cobra.Command{
|
||||||
var repoRenameCmd = &cobra.Command{
|
var repoRenameCmd = &cobra.Command{
|
||||||
Use: "rename <new-name>",
|
Use: "rename <new-name>",
|
||||||
Short: "Rename a repository",
|
Short: "Rename a repository",
|
||||||
Long: "Rename an existing repository. This is a shorthand for `fgj repo edit --name <new-name>`.",
|
Long: "Rename an existing repository. This is a shorthand for `fj repo edit --name <new-name>`.",
|
||||||
Example: ` # Rename current repo
|
Example: ` # Rename current repo
|
||||||
fgj repo rename new-name
|
fj repo rename new-name
|
||||||
|
|
||||||
# Rename a specific repo
|
# Rename a specific repo
|
||||||
fgj repo rename new-name -R owner/old-name`,
|
fj repo rename new-name -R owner/old-name`,
|
||||||
Args: cobra.ExactArgs(1),
|
Args: cobra.ExactArgs(1),
|
||||||
RunE: runRepoRename,
|
RunE: runRepoRename,
|
||||||
}
|
}
|
||||||
|
|
@ -216,17 +216,18 @@ func runRepoList(cmd *cobra.Command, args []string) error {
|
||||||
return fmt.Errorf("failed to get user info: %w", err)
|
return fmt.Errorf("failed to get user info: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
repos, _, err := client.ListUserRepos(user.UserName, gitea.ListReposOptions{})
|
limit, _ := cmd.Flags().GetInt("limit")
|
||||||
|
repos, err := paginateGitea(limit, func(page, pageSize int) ([]*gitea.Repository, error) {
|
||||||
|
batch, _, err := client.ListUserRepos(user.UserName, gitea.ListReposOptions{
|
||||||
|
ListOptions: gitea.ListOptions{Page: page, PageSize: pageSize},
|
||||||
|
})
|
||||||
|
return batch, err
|
||||||
|
})
|
||||||
ios.StopSpinner()
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
limit, _ := cmd.Flags().GetInt("limit")
|
|
||||||
if limit > 0 && len(repos) > limit {
|
|
||||||
repos = repos[:limit]
|
|
||||||
}
|
|
||||||
|
|
||||||
if wantJSON(cmd) {
|
if wantJSON(cmd) {
|
||||||
return outputJSON(cmd, repos)
|
return outputJSON(cmd, repos)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,163 +0,0 @@
|
||||||
package cmd
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"code.gitea.io/sdk/gitea"
|
|
||||||
"forgejo.zerova.net/public/fgj-sid/internal/api"
|
|
||||||
"forgejo.zerova.net/public/fgj-sid/internal/config"
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
)
|
|
||||||
|
|
||||||
var repoArchiveCmd = &cobra.Command{
|
|
||||||
Use: "archive [owner/name]",
|
|
||||||
Short: "Archive a repository",
|
|
||||||
Long: `Mark a repository as archived. Archived repositories remain visible but
|
|
||||||
become read-only: pushes, issues, pull requests, and releases are disabled.
|
|
||||||
|
|
||||||
The target repo may be passed as a positional argument, via -R/--repo, or
|
|
||||||
auto-detected from the current git context. If both positional and -R are
|
|
||||||
given, the -R flag wins.`,
|
|
||||||
Example: ` # Archive a specific repo (prompted confirmation)
|
|
||||||
fgj repo archive owner/name
|
|
||||||
|
|
||||||
# Archive the current repo without prompting
|
|
||||||
fgj repo archive --yes
|
|
||||||
|
|
||||||
# Archive using the -R flag
|
|
||||||
fgj repo archive -R owner/name -y`,
|
|
||||||
Args: cobra.MaximumNArgs(1),
|
|
||||||
RunE: runRepoArchive,
|
|
||||||
}
|
|
||||||
|
|
||||||
var repoUnarchiveCmd = &cobra.Command{
|
|
||||||
Use: "unarchive [owner/name]",
|
|
||||||
Short: "Unarchive a repository",
|
|
||||||
Long: `Clear the archived flag on a repository, restoring normal read-write
|
|
||||||
behaviour.
|
|
||||||
|
|
||||||
The target repo may be passed as a positional argument, via -R/--repo, or
|
|
||||||
auto-detected from the current git context. If both positional and -R are
|
|
||||||
given, the -R flag wins.`,
|
|
||||||
Example: ` # Unarchive a specific repo
|
|
||||||
fgj repo unarchive owner/name
|
|
||||||
|
|
||||||
# Unarchive the current repo
|
|
||||||
fgj repo unarchive`,
|
|
||||||
Args: cobra.MaximumNArgs(1),
|
|
||||||
RunE: runRepoUnarchive,
|
|
||||||
}
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
repoCmd.AddCommand(repoArchiveCmd)
|
|
||||||
repoCmd.AddCommand(repoUnarchiveCmd)
|
|
||||||
|
|
||||||
addRepoFlags(repoArchiveCmd)
|
|
||||||
repoArchiveCmd.Flags().BoolP("yes", "y", false, "Skip the confirmation prompt")
|
|
||||||
addJSONFlags(repoArchiveCmd, "Output updated repository as JSON")
|
|
||||||
|
|
||||||
addRepoFlags(repoUnarchiveCmd)
|
|
||||||
repoUnarchiveCmd.Flags().BoolP("yes", "y", false, "Skip the confirmation prompt (unused; kept for symmetry with archive)")
|
|
||||||
addJSONFlags(repoUnarchiveCmd, "Output updated repository as JSON")
|
|
||||||
}
|
|
||||||
|
|
||||||
// resolveRepoTarget returns owner/name honouring the "optional positional OR
|
|
||||||
// -R flag" pattern used elsewhere in the CLI: -R wins when both are supplied.
|
|
||||||
func resolveRepoTarget(cmd *cobra.Command, args []string) (string, string, error) {
|
|
||||||
var repo string
|
|
||||||
if len(args) > 0 {
|
|
||||||
repo = args[0]
|
|
||||||
}
|
|
||||||
if r, _ := cmd.Flags().GetString("repo"); r != "" {
|
|
||||||
repo = r
|
|
||||||
}
|
|
||||||
return parseRepo(repo)
|
|
||||||
}
|
|
||||||
|
|
||||||
func runRepoArchive(cmd *cobra.Command, args []string) error {
|
|
||||||
owner, name, err := resolveRepoTarget(cmd, args)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
slug := fmt.Sprintf("%s/%s", owner, name)
|
|
||||||
skipConfirm, _ := cmd.Flags().GetBool("yes")
|
|
||||||
|
|
||||||
if !skipConfirm {
|
|
||||||
if !ios.IsStdinTTY() {
|
|
||||||
return fmt.Errorf("refusing to archive %s without a TTY; pass -y/--yes to confirm non-interactively", slug)
|
|
||||||
}
|
|
||||||
prompt := fmt.Sprintf("Archive %s? This disables issues/PRs/pushes. [y/N]: ", slug)
|
|
||||||
answer, err := promptLine(prompt)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if answer != "y" && answer != "Y" && answer != "yes" && answer != "Yes" && answer != "YES" {
|
|
||||||
fmt.Fprintln(ios.ErrOut, "Aborted.")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
cfg, err := config.Load()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd())
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
archived := true
|
|
||||||
opt := gitea.EditRepoOption{Archived: &archived}
|
|
||||||
|
|
||||||
ios.StartSpinner("Archiving repository...")
|
|
||||||
repository, _, err := client.EditRepo(owner, name, opt)
|
|
||||||
ios.StopSpinner()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to archive %s: %w", slug, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if wantJSON(cmd) {
|
|
||||||
return outputJSON(cmd, repository)
|
|
||||||
}
|
|
||||||
|
|
||||||
cs := ios.ColorScheme()
|
|
||||||
fmt.Fprintf(ios.Out, "%s Archived %s\n", cs.SuccessIcon(), slug)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func runRepoUnarchive(cmd *cobra.Command, args []string) error {
|
|
||||||
owner, name, err := resolveRepoTarget(cmd, args)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
slug := fmt.Sprintf("%s/%s", owner, name)
|
|
||||||
|
|
||||||
cfg, err := config.Load()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd())
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
archived := false
|
|
||||||
opt := gitea.EditRepoOption{Archived: &archived}
|
|
||||||
|
|
||||||
ios.StartSpinner("Unarchiving repository...")
|
|
||||||
repository, _, err := client.EditRepo(owner, name, opt)
|
|
||||||
ios.StopSpinner()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to unarchive %s: %w", slug, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if wantJSON(cmd) {
|
|
||||||
return outputJSON(cmd, repository)
|
|
||||||
}
|
|
||||||
|
|
||||||
cs := ios.ColorScheme()
|
|
||||||
fmt.Fprintf(ios.Out, "%s Unarchived %s\n", cs.SuccessIcon(), slug)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
@ -1,78 +0,0 @@
|
||||||
package cmd
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"forgejo.zerova.net/public/fgj-sid/internal/api"
|
|
||||||
"forgejo.zerova.net/public/fgj-sid/internal/config"
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
)
|
|
||||||
|
|
||||||
var repoDeleteCmd = &cobra.Command{
|
|
||||||
Use: "delete [owner/name]",
|
|
||||||
Aliases: []string{"rm"},
|
|
||||||
Short: "Delete a repository",
|
|
||||||
Long: `Delete a repository. This is irreversible and removes all issues, PRs,
|
|
||||||
wikis, and release artifacts.
|
|
||||||
|
|
||||||
For safety, you must either pass -y/--yes, or type the full owner/name
|
|
||||||
string when prompted.`,
|
|
||||||
Example: ` # Delete a repository (prompted confirmation)
|
|
||||||
fgj repo delete owner/name
|
|
||||||
|
|
||||||
# Delete without confirmation (scripts)
|
|
||||||
fgj repo delete owner/name --yes`,
|
|
||||||
Args: cobra.MaximumNArgs(1),
|
|
||||||
RunE: runRepoDelete,
|
|
||||||
}
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
repoCmd.AddCommand(repoDeleteCmd)
|
|
||||||
repoDeleteCmd.Flags().BoolP("yes", "y", false, "Skip the type-to-confirm prompt")
|
|
||||||
}
|
|
||||||
|
|
||||||
func runRepoDelete(cmd *cobra.Command, args []string) error {
|
|
||||||
var target string
|
|
||||||
if len(args) == 1 {
|
|
||||||
target = args[0]
|
|
||||||
}
|
|
||||||
owner, name, err := parseRepo(target)
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
slug := fmt.Sprintf("%s/%s", owner, name)
|
|
||||||
skipConfirm, _ := cmd.Flags().GetBool("yes")
|
|
||||||
|
|
||||||
if !skipConfirm {
|
|
||||||
if !ios.IsStdinTTY() {
|
|
||||||
return fmt.Errorf("refusing to delete %s without a TTY; pass --yes to confirm non-interactively", slug)
|
|
||||||
}
|
|
||||||
prompt := fmt.Sprintf("Type the full repo slug to confirm deletion (%s): ", slug)
|
|
||||||
answer, err := promptLine(prompt)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if answer != slug {
|
|
||||||
fmt.Fprintln(ios.ErrOut, "Confirmation mismatch; aborting.")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := client.DeleteRepo(owner, name); err != nil {
|
|
||||||
return fmt.Errorf("failed to delete %s: %w", slug, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
cs := ios.ColorScheme()
|
|
||||||
fmt.Fprintf(ios.Out, "%s Deleted %s\n", cs.SuccessIcon(), slug)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
@ -1,170 +0,0 @@
|
||||||
package cmd
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"code.gitea.io/sdk/gitea"
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
)
|
|
||||||
|
|
||||||
var repoMigrateCmd = &cobra.Command{
|
|
||||||
Use: "migrate <clone-url>",
|
|
||||||
Aliases: []string{"m"},
|
|
||||||
Short: "Migrate a repository from an external service",
|
|
||||||
Long: `Import a repository from GitHub, GitLab, Gogs, Gitea, or a plain Git
|
|
||||||
remote. By default the migration is a one-shot import; pass --mirror
|
|
||||||
to keep syncing on an interval.
|
|
||||||
|
|
||||||
Authentication for the source repo is passed via --auth-token or
|
|
||||||
--auth-username + --auth-password. Neither is stored after the
|
|
||||||
migration completes on the server side.`,
|
|
||||||
Example: ` # Migrate a GitHub repo to this user's account
|
|
||||||
fgj repo migrate https://github.com/cli/cli \
|
|
||||||
--name gh-mirror --service github --auth-token "$GH_TOKEN"
|
|
||||||
|
|
||||||
# Mirror a plain Git remote into an org
|
|
||||||
fgj repo migrate https://example.com/project.git \
|
|
||||||
--name project --owner infrastructure --mirror --mirror-interval 8h
|
|
||||||
|
|
||||||
# Migrate with all content kinds
|
|
||||||
fgj repo migrate https://gitea.com/user/repo \
|
|
||||||
--name repo --service gitea --auth-token "$TOKEN" \
|
|
||||||
--wiki --labels --milestones --issues --pulls --releases --lfs`,
|
|
||||||
Args: cobra.ExactArgs(1),
|
|
||||||
RunE: runRepoMigrate,
|
|
||||||
}
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
repoCmd.AddCommand(repoMigrateCmd)
|
|
||||||
|
|
||||||
repoMigrateCmd.Flags().String("name", "", "Name for the new repository (required)")
|
|
||||||
repoMigrateCmd.Flags().String("owner", "", "Owner (user or org) for the new repository (defaults to you)")
|
|
||||||
repoMigrateCmd.Flags().String("service", "git", "Source service: git, github, gitlab, gitea, gogs")
|
|
||||||
repoMigrateCmd.Flags().StringP("description", "d", "", "Description of the new repository")
|
|
||||||
repoMigrateCmd.Flags().String("auth-token", "", "Auth token for the source repo (preferred over username/password)")
|
|
||||||
repoMigrateCmd.Flags().String("auth-username", "", "Auth username for the source repo")
|
|
||||||
repoMigrateCmd.Flags().String("auth-password", "", "Auth password for the source repo")
|
|
||||||
repoMigrateCmd.Flags().Bool("private", false, "Make the new repository private")
|
|
||||||
repoMigrateCmd.Flags().Bool("mirror", false, "Mirror the source (keep syncing) instead of one-shot import")
|
|
||||||
repoMigrateCmd.Flags().String("mirror-interval", "", "Mirror sync interval (e.g. 8h, 24h); only with --mirror")
|
|
||||||
repoMigrateCmd.Flags().Bool("wiki", false, "Include wiki in the migration")
|
|
||||||
repoMigrateCmd.Flags().Bool("labels", false, "Include labels")
|
|
||||||
repoMigrateCmd.Flags().Bool("milestones", false, "Include milestones")
|
|
||||||
repoMigrateCmd.Flags().Bool("issues", false, "Include issues")
|
|
||||||
repoMigrateCmd.Flags().Bool("pulls", false, "Include pull requests")
|
|
||||||
repoMigrateCmd.Flags().Bool("releases", false, "Include releases")
|
|
||||||
repoMigrateCmd.Flags().Bool("lfs", false, "Include Git LFS content")
|
|
||||||
repoMigrateCmd.Flags().String("lfs-endpoint", "", "Explicit Git LFS server URL")
|
|
||||||
|
|
||||||
_ = repoMigrateCmd.MarkFlagRequired("name")
|
|
||||||
addJSONFlags(repoMigrateCmd, "Output created repository as JSON")
|
|
||||||
}
|
|
||||||
|
|
||||||
func runRepoMigrate(cmd *cobra.Command, args []string) error {
|
|
||||||
cloneURL := args[0]
|
|
||||||
|
|
||||||
repoName, _ := cmd.Flags().GetString("name")
|
|
||||||
if strings.TrimSpace(repoName) == "" {
|
|
||||||
return fmt.Errorf("--name is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
owner, _ := cmd.Flags().GetString("owner")
|
|
||||||
serviceStr, _ := cmd.Flags().GetString("service")
|
|
||||||
|
|
||||||
service, err := parseGitService(serviceStr)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
client, err := loadClient()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default owner = authenticated user.
|
|
||||||
if owner == "" {
|
|
||||||
user, _, err := client.GetMyUserInfo()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to resolve current user (pass --owner to override): %w", err)
|
|
||||||
}
|
|
||||||
owner = user.UserName
|
|
||||||
}
|
|
||||||
|
|
||||||
description, _ := cmd.Flags().GetString("description")
|
|
||||||
authToken, _ := cmd.Flags().GetString("auth-token")
|
|
||||||
authUser, _ := cmd.Flags().GetString("auth-username")
|
|
||||||
authPass, _ := cmd.Flags().GetString("auth-password")
|
|
||||||
private, _ := cmd.Flags().GetBool("private")
|
|
||||||
mirror, _ := cmd.Flags().GetBool("mirror")
|
|
||||||
mirrorInterval, _ := cmd.Flags().GetString("mirror-interval")
|
|
||||||
if mirrorInterval != "" && !mirror {
|
|
||||||
return fmt.Errorf("--mirror-interval requires --mirror")
|
|
||||||
}
|
|
||||||
|
|
||||||
wiki, _ := cmd.Flags().GetBool("wiki")
|
|
||||||
labels, _ := cmd.Flags().GetBool("labels")
|
|
||||||
milestones, _ := cmd.Flags().GetBool("milestones")
|
|
||||||
issues, _ := cmd.Flags().GetBool("issues")
|
|
||||||
pulls, _ := cmd.Flags().GetBool("pulls")
|
|
||||||
releases, _ := cmd.Flags().GetBool("releases")
|
|
||||||
lfs, _ := cmd.Flags().GetBool("lfs")
|
|
||||||
lfsEndpoint, _ := cmd.Flags().GetString("lfs-endpoint")
|
|
||||||
|
|
||||||
opt := gitea.MigrateRepoOption{
|
|
||||||
RepoName: repoName,
|
|
||||||
RepoOwner: owner,
|
|
||||||
CloneAddr: cloneURL,
|
|
||||||
Service: service,
|
|
||||||
AuthUsername: authUser,
|
|
||||||
AuthPassword: authPass,
|
|
||||||
AuthToken: authToken,
|
|
||||||
Private: private,
|
|
||||||
Description: description,
|
|
||||||
Mirror: mirror,
|
|
||||||
MirrorInterval: mirrorInterval,
|
|
||||||
Wiki: wiki,
|
|
||||||
Labels: labels,
|
|
||||||
Milestones: milestones,
|
|
||||||
Issues: issues,
|
|
||||||
PullRequests: pulls,
|
|
||||||
Releases: releases,
|
|
||||||
LFS: lfs,
|
|
||||||
LFSEndpoint: lfsEndpoint,
|
|
||||||
}
|
|
||||||
|
|
||||||
ios.StartSpinner("Starting migration...")
|
|
||||||
repo, _, err := client.MigrateRepo(opt)
|
|
||||||
ios.StopSpinner()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("migration failed: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if wantJSON(cmd) {
|
|
||||||
return outputJSON(cmd, repo)
|
|
||||||
}
|
|
||||||
|
|
||||||
cs := ios.ColorScheme()
|
|
||||||
fmt.Fprintf(ios.Out, "%s Migrated to %s\n", cs.SuccessIcon(), repo.FullName)
|
|
||||||
if repo.HTMLURL != "" {
|
|
||||||
fmt.Fprintf(ios.Out, " %s\n", repo.HTMLURL)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseGitService(s string) (gitea.GitServiceType, error) {
|
|
||||||
switch strings.ToLower(s) {
|
|
||||||
case "", "git", "plain":
|
|
||||||
return gitea.GitServicePlain, nil
|
|
||||||
case "github":
|
|
||||||
return gitea.GitServiceGithub, nil
|
|
||||||
case "gitlab":
|
|
||||||
return gitea.GitServiceGitlab, nil
|
|
||||||
case "gitea", "forgejo":
|
|
||||||
return gitea.GitServiceGitea, nil
|
|
||||||
case "gogs":
|
|
||||||
return gitea.GitServiceGogs, nil
|
|
||||||
default:
|
|
||||||
return "", fmt.Errorf("unknown --service %q (expected: git, github, gitlab, gitea, gogs)", s)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,146 +0,0 @@
|
||||||
package cmd
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"code.gitea.io/sdk/gitea"
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
)
|
|
||||||
|
|
||||||
var repoSearchCmd = &cobra.Command{
|
|
||||||
Use: "search [query]",
|
|
||||||
Aliases: []string{"s"},
|
|
||||||
Short: "Search repositories on the current host",
|
|
||||||
Long: `Search repositories using the host's search index.
|
|
||||||
|
|
||||||
The query is matched against name by default. Pass --topic to match against
|
|
||||||
topics only, or --description to include descriptions. --type limits results
|
|
||||||
to "source" (non-fork, non-mirror), "fork", or "mirror" repositories.`,
|
|
||||||
Example: ` # Search by name substring
|
|
||||||
fgj repo search tea
|
|
||||||
|
|
||||||
# Search by topic
|
|
||||||
fgj repo search ci --topic
|
|
||||||
|
|
||||||
# Find only forks
|
|
||||||
fgj repo search go --type fork
|
|
||||||
|
|
||||||
# List private repos owned by a user (no query)
|
|
||||||
fgj repo search --owner alice --private --limit 50
|
|
||||||
|
|
||||||
# Output as JSON
|
|
||||||
fgj repo search platform --json`,
|
|
||||||
Args: cobra.MaximumNArgs(1),
|
|
||||||
RunE: runRepoSearch,
|
|
||||||
}
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
repoCmd.AddCommand(repoSearchCmd)
|
|
||||||
|
|
||||||
repoSearchCmd.Flags().Bool("topic", false, "Match query against topics only")
|
|
||||||
repoSearchCmd.Flags().Bool("description", false, "Include descriptions in the search")
|
|
||||||
repoSearchCmd.Flags().Bool("private", false, "Limit to private repositories")
|
|
||||||
repoSearchCmd.Flags().Bool("archived", false, "Include archived repositories")
|
|
||||||
repoSearchCmd.Flags().Bool("exclude-templates", false, "Exclude template repositories")
|
|
||||||
repoSearchCmd.Flags().String("type", "", "Filter by repo type: source, fork, mirror")
|
|
||||||
repoSearchCmd.Flags().String("owner", "", "Limit to repos owned by this user or org")
|
|
||||||
repoSearchCmd.Flags().String("sort", "", "Sort by: alpha, created, updated, size, id")
|
|
||||||
repoSearchCmd.Flags().String("order", "", "Order: asc or desc")
|
|
||||||
repoSearchCmd.Flags().IntP("limit", "L", 30, "Maximum number of results")
|
|
||||||
|
|
||||||
addJSONFlags(repoSearchCmd, "Output as JSON")
|
|
||||||
}
|
|
||||||
|
|
||||||
func runRepoSearch(cmd *cobra.Command, args []string) error {
|
|
||||||
client, err := loadClient()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
query := ""
|
|
||||||
if len(args) == 1 {
|
|
||||||
query = args[0]
|
|
||||||
}
|
|
||||||
|
|
||||||
topic, _ := cmd.Flags().GetBool("topic")
|
|
||||||
desc, _ := cmd.Flags().GetBool("description")
|
|
||||||
private, _ := cmd.Flags().GetBool("private")
|
|
||||||
archived, _ := cmd.Flags().GetBool("archived")
|
|
||||||
excludeTemplates, _ := cmd.Flags().GetBool("exclude-templates")
|
|
||||||
sort, _ := cmd.Flags().GetString("sort")
|
|
||||||
order, _ := cmd.Flags().GetString("order")
|
|
||||||
typeFlag, _ := cmd.Flags().GetString("type")
|
|
||||||
limit, _ := cmd.Flags().GetInt("limit")
|
|
||||||
if limit <= 0 {
|
|
||||||
limit = 30
|
|
||||||
}
|
|
||||||
|
|
||||||
var repoType gitea.RepoType
|
|
||||||
switch typeFlag {
|
|
||||||
case "":
|
|
||||||
repoType = gitea.RepoTypeNone
|
|
||||||
case "source":
|
|
||||||
repoType = gitea.RepoTypeSource
|
|
||||||
case "fork":
|
|
||||||
repoType = gitea.RepoTypeFork
|
|
||||||
case "mirror":
|
|
||||||
repoType = gitea.RepoTypeMirror
|
|
||||||
default:
|
|
||||||
return fmt.Errorf("invalid --type %q (must be source, fork, or mirror)", typeFlag)
|
|
||||||
}
|
|
||||||
|
|
||||||
opt := gitea.SearchRepoOptions{
|
|
||||||
ListOptions: gitea.ListOptions{PageSize: limit},
|
|
||||||
Keyword: query,
|
|
||||||
KeywordIsTopic: topic,
|
|
||||||
KeywordInDescription: desc,
|
|
||||||
IsPrivate: optionalBool(private),
|
|
||||||
IsArchived: optionalBool(archived),
|
|
||||||
ExcludeTemplate: excludeTemplates,
|
|
||||||
Type: repoType,
|
|
||||||
Sort: sort,
|
|
||||||
Order: order,
|
|
||||||
}
|
|
||||||
|
|
||||||
if o, _ := cmd.Flags().GetString("owner"); o != "" {
|
|
||||||
u, _, err := client.GetUserInfo(o)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to resolve owner %q: %w", o, err)
|
|
||||||
}
|
|
||||||
opt.OwnerID = u.ID
|
|
||||||
}
|
|
||||||
|
|
||||||
repos, _, err := client.SearchRepos(opt)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("search failed: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if wantJSON(cmd) {
|
|
||||||
return outputJSON(cmd, repos)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(repos) == 0 {
|
|
||||||
fmt.Fprintln(ios.Out, "No repositories match.")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
tp := ios.NewTablePrinter()
|
|
||||||
tp.AddHeader("FULL NAME", "VISIBILITY", "DESCRIPTION", "STARS")
|
|
||||||
for _, r := range repos {
|
|
||||||
visibility := "public"
|
|
||||||
if r.Private {
|
|
||||||
visibility = "private"
|
|
||||||
}
|
|
||||||
tp.AddRow(r.FullName, visibility, r.Description, fmt.Sprintf("%d", r.Stars))
|
|
||||||
}
|
|
||||||
return tp.Render()
|
|
||||||
}
|
|
||||||
|
|
||||||
// optionalBool returns a pointer when the user explicitly wants the positive
|
|
||||||
// filter (IsPrivate/IsArchived); nil means "no filter" to the SDK.
|
|
||||||
func optionalBool(v bool) *bool {
|
|
||||||
if !v {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return &v
|
|
||||||
}
|
|
||||||
|
|
@ -1,113 +0,0 @@
|
||||||
package cmd
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"code.gitea.io/sdk/gitea"
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
)
|
|
||||||
|
|
||||||
var repoCreateFromTemplateCmd = &cobra.Command{
|
|
||||||
Use: "create-from-template <template-owner/template-name> <new-name>",
|
|
||||||
Aliases: []string{"ct"},
|
|
||||||
Short: "Create a repository from a template",
|
|
||||||
Long: `Scaffold a new repository based on an existing template repository.
|
|
||||||
|
|
||||||
By default this copies only the default branch content. Pass --with-<kind>
|
|
||||||
flags to include topics, labels, webhooks, git hooks, and other template
|
|
||||||
metadata.`,
|
|
||||||
Example: ` # Create a new repo under your account from a template
|
|
||||||
fgj repo create-from-template org/template-name my-new-repo
|
|
||||||
|
|
||||||
# Target a specific owner and make it private
|
|
||||||
fgj repo create-from-template org/template-name new-repo --owner myorg --private
|
|
||||||
|
|
||||||
# Copy everything: content, topics, labels, webhooks, hooks, avatar
|
|
||||||
fgj repo create-from-template org/template-name new-repo \
|
|
||||||
--with-content --with-topics --with-labels \
|
|
||||||
--with-webhooks --with-git-hooks --with-avatar`,
|
|
||||||
Args: cobra.ExactArgs(2),
|
|
||||||
RunE: runRepoCreateFromTemplate,
|
|
||||||
}
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
repoCmd.AddCommand(repoCreateFromTemplateCmd)
|
|
||||||
|
|
||||||
repoCreateFromTemplateCmd.Flags().String("owner", "", "Owner (user or org) for the new repository (defaults to you)")
|
|
||||||
repoCreateFromTemplateCmd.Flags().StringP("description", "d", "", "Description for the new repository")
|
|
||||||
repoCreateFromTemplateCmd.Flags().Bool("private", false, "Make the new repository private")
|
|
||||||
repoCreateFromTemplateCmd.Flags().Bool("with-content", true, "Include default branch content from the template")
|
|
||||||
repoCreateFromTemplateCmd.Flags().Bool("with-topics", false, "Include topics from the template")
|
|
||||||
repoCreateFromTemplateCmd.Flags().Bool("with-labels", false, "Include labels from the template")
|
|
||||||
repoCreateFromTemplateCmd.Flags().Bool("with-webhooks", false, "Include webhooks from the template")
|
|
||||||
repoCreateFromTemplateCmd.Flags().Bool("with-git-hooks", false, "Include git hooks from the template")
|
|
||||||
repoCreateFromTemplateCmd.Flags().Bool("with-avatar", false, "Include the template repo's avatar")
|
|
||||||
|
|
||||||
addJSONFlags(repoCreateFromTemplateCmd, "Output created repository as JSON")
|
|
||||||
}
|
|
||||||
|
|
||||||
func runRepoCreateFromTemplate(cmd *cobra.Command, args []string) error {
|
|
||||||
templateSlug := args[0]
|
|
||||||
newName := args[1]
|
|
||||||
|
|
||||||
parts := strings.SplitN(templateSlug, "/", 2)
|
|
||||||
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
|
|
||||||
return fmt.Errorf("template must be in owner/name format (got %q)", templateSlug)
|
|
||||||
}
|
|
||||||
templateOwner, templateName := parts[0], parts[1]
|
|
||||||
|
|
||||||
client, err := loadClient()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
owner, _ := cmd.Flags().GetString("owner")
|
|
||||||
if owner == "" {
|
|
||||||
user, _, err := client.GetMyUserInfo()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to resolve current user (pass --owner to override): %w", err)
|
|
||||||
}
|
|
||||||
owner = user.UserName
|
|
||||||
}
|
|
||||||
|
|
||||||
description, _ := cmd.Flags().GetString("description")
|
|
||||||
private, _ := cmd.Flags().GetBool("private")
|
|
||||||
withContent, _ := cmd.Flags().GetBool("with-content")
|
|
||||||
withTopics, _ := cmd.Flags().GetBool("with-topics")
|
|
||||||
withLabels, _ := cmd.Flags().GetBool("with-labels")
|
|
||||||
withWebhooks, _ := cmd.Flags().GetBool("with-webhooks")
|
|
||||||
withGitHooks, _ := cmd.Flags().GetBool("with-git-hooks")
|
|
||||||
withAvatar, _ := cmd.Flags().GetBool("with-avatar")
|
|
||||||
|
|
||||||
opt := gitea.CreateRepoFromTemplateOption{
|
|
||||||
Owner: owner,
|
|
||||||
Name: newName,
|
|
||||||
Description: description,
|
|
||||||
Private: private,
|
|
||||||
GitContent: withContent,
|
|
||||||
Topics: withTopics,
|
|
||||||
Labels: withLabels,
|
|
||||||
Webhooks: withWebhooks,
|
|
||||||
GitHooks: withGitHooks,
|
|
||||||
Avatar: withAvatar,
|
|
||||||
}
|
|
||||||
|
|
||||||
ios.StartSpinner("Creating from template...")
|
|
||||||
repo, _, err := client.CreateRepoFromTemplate(templateOwner, templateName, opt)
|
|
||||||
ios.StopSpinner()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("template instantiation failed: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if wantJSON(cmd) {
|
|
||||||
return outputJSON(cmd, repo)
|
|
||||||
}
|
|
||||||
|
|
||||||
cs := ios.ColorScheme()
|
|
||||||
fmt.Fprintf(ios.Out, "%s Created %s from template %s\n", cs.SuccessIcon(), repo.FullName, templateSlug)
|
|
||||||
if repo.HTMLURL != "" {
|
|
||||||
fmt.Fprintf(ios.Out, " %s\n", repo.HTMLURL)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
96
cmd/root.go
96
cmd/root.go
|
|
@ -2,11 +2,14 @@ package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"forgejo.zerova.net/public/fgj-sid/internal/git"
|
"forgejo.zerova.net/public/fj/internal/config"
|
||||||
|
"forgejo.zerova.net/public/fj/internal/git"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"github.com/spf13/viper"
|
"github.com/spf13/viper"
|
||||||
)
|
)
|
||||||
|
|
@ -14,16 +17,12 @@ import (
|
||||||
var cfgFile string
|
var cfgFile string
|
||||||
var jsonErrors bool
|
var jsonErrors bool
|
||||||
|
|
||||||
// version is set at build time via -ldflags "-X .../cmd.version=...".
|
|
||||||
// Defaults to "dev" for plain `go build` / `go run`.
|
|
||||||
var version = "dev"
|
|
||||||
|
|
||||||
var rootCmd = &cobra.Command{
|
var rootCmd = &cobra.Command{
|
||||||
Use: "fgj",
|
Use: "fj",
|
||||||
Short: "Forgejo CLI tool - work seamlessly with Forgejo from the command line",
|
Short: "Forgejo CLI tool - work seamlessly with Forgejo from the command line",
|
||||||
Long: `fgj is a command line tool for Forgejo instances (including Codeberg).
|
Long: `fj is a command line tool for Forgejo instances (including Codeberg).
|
||||||
It brings pull requests, issues, and other Forgejo concepts to the terminal.`,
|
It brings pull requests, issues, and other Forgejo concepts to the terminal.`,
|
||||||
Version: version,
|
Version: "0.4.0",
|
||||||
SilenceErrors: true,
|
SilenceErrors: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -39,7 +38,7 @@ func Execute() error {
|
||||||
func init() {
|
func init() {
|
||||||
cobra.OnInitialize(initConfig)
|
cobra.OnInitialize(initConfig)
|
||||||
|
|
||||||
rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.config/fgj/config.yaml)")
|
rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.config/fj/config.yaml)")
|
||||||
rootCmd.PersistentFlags().BoolVar(&jsonErrors, "json-errors", false, "output errors as structured JSON to stderr")
|
rootCmd.PersistentFlags().BoolVar(&jsonErrors, "json-errors", false, "output errors as structured JSON to stderr")
|
||||||
rootCmd.PersistentFlags().String("hostname", "", "Forgejo instance hostname")
|
rootCmd.PersistentFlags().String("hostname", "", "Forgejo instance hostname")
|
||||||
_ = viper.BindPFlag("hostname", rootCmd.PersistentFlags().Lookup("hostname"))
|
_ = viper.BindPFlag("hostname", rootCmd.PersistentFlags().Lookup("hostname"))
|
||||||
|
|
@ -47,7 +46,12 @@ 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 {
|
||||||
|
|
@ -55,8 +59,20 @@ func initConfig() {
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
configDir := home + "/.config/fgj"
|
configDir := home + "/.config/fj"
|
||||||
_ = os.MkdirAll(configDir, 0755)
|
legacyDir := home + "/.config/fgj"
|
||||||
|
|
||||||
|
// Migrate from ~/.config/fgj/ if the new dir doesn't exist yet.
|
||||||
|
if _, err := os.Stat(configDir); os.IsNotExist(err) {
|
||||||
|
if info, err := os.Stat(legacyDir); err == nil && info.IsDir() {
|
||||||
|
if copyErr := migrateConfigDir(legacyDir, configDir); copyErr == nil {
|
||||||
|
fmt.Fprintln(ios.ErrOut, "notice: migrated config from ~/.config/fgj/ to ~/.config/fj/")
|
||||||
|
fmt.Fprintln(ios.ErrOut, " you can remove ~/.config/fgj/ when ready")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = os.MkdirAll(configDir, 0700)
|
||||||
|
|
||||||
viper.AddConfigPath(configDir)
|
viper.AddConfigPath(configDir)
|
||||||
viper.SetConfigType("yaml")
|
viper.SetConfigType("yaml")
|
||||||
|
|
@ -64,9 +80,17 @@ func initConfig() {
|
||||||
}
|
}
|
||||||
|
|
||||||
viper.AutomaticEnv()
|
viper.AutomaticEnv()
|
||||||
viper.SetEnvPrefix("FGJ")
|
viper.SetEnvPrefix("FJ")
|
||||||
|
|
||||||
_ = viper.ReadInConfig()
|
_ = viper.ReadInConfig()
|
||||||
|
|
||||||
|
// If the resolved config exists with overly permissive mode, warn — the
|
||||||
|
// file holds API tokens. Don't fail-close; just nudge the user.
|
||||||
|
if path, err := config.GetConfigPath(); err == nil {
|
||||||
|
if info, statErr := os.Stat(path); statErr == nil && info.Mode()&0o077 != 0 {
|
||||||
|
fmt.Fprintf(ios.ErrOut, "warning: %s mode %o is world/group readable; tokens may leak. chmod 600 it.\n", path, info.Mode().Perm())
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseRepo parses the repository string in the format "owner/name".
|
// parseRepo parses the repository string in the format "owner/name".
|
||||||
|
|
@ -131,3 +155,51 @@ func parseIssueArg(arg string) (int64, error) {
|
||||||
}
|
}
|
||||||
return strconv.ParseInt(arg, 10, 64)
|
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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,68 +0,0 @@
|
||||||
package cmd
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
"golang.org/x/term"
|
|
||||||
)
|
|
||||||
|
|
||||||
// readSecretValue resolves the value for a secret/token flag from, in order:
|
|
||||||
// 1. --body (inline; visible in shell history)
|
|
||||||
// 2. --body-file (file path, or "-" for stdin)
|
|
||||||
// 3. interactive TTY prompt (hidden)
|
|
||||||
// 4. piped stdin
|
|
||||||
//
|
|
||||||
// Trailing whitespace (including the final newline common in heredocs and
|
|
||||||
// `echo ... | fgj ...`) is trimmed. An empty resolved value is rejected so we
|
|
||||||
// never silently write an empty secret.
|
|
||||||
func readSecretValue(cmd *cobra.Command, label string) (string, error) {
|
|
||||||
if v, _ := cmd.Flags().GetString("body"); v != "" {
|
|
||||||
return strings.TrimRight(v, "\r\n"), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if path, _ := cmd.Flags().GetString("body-file"); path != "" {
|
|
||||||
var raw []byte
|
|
||||||
var err error
|
|
||||||
if path == "-" {
|
|
||||||
raw, err = io.ReadAll(ios.In)
|
|
||||||
} else {
|
|
||||||
raw, err = os.ReadFile(path)
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to read secret from %q: %w", path, err)
|
|
||||||
}
|
|
||||||
value := strings.TrimRight(string(raw), "\r\n")
|
|
||||||
if value == "" {
|
|
||||||
return "", fmt.Errorf("secret value from %q is empty", path)
|
|
||||||
}
|
|
||||||
return value, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if ios.IsStdinTTY() {
|
|
||||||
fmt.Fprintf(ios.ErrOut, "Value for %s: ", label)
|
|
||||||
pw, err := term.ReadPassword(int(os.Stdin.Fd()))
|
|
||||||
fmt.Fprintln(ios.ErrOut)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to read secret: %w", err)
|
|
||||||
}
|
|
||||||
value := strings.TrimRight(string(pw), "\r\n")
|
|
||||||
if value == "" {
|
|
||||||
return "", fmt.Errorf("secret value is empty")
|
|
||||||
}
|
|
||||||
return value, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
raw, err := io.ReadAll(ios.In)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to read secret from stdin: %w", err)
|
|
||||||
}
|
|
||||||
value := strings.TrimRight(string(raw), "\r\n")
|
|
||||||
if value == "" {
|
|
||||||
return "", fmt.Errorf("secret value from stdin is empty")
|
|
||||||
}
|
|
||||||
return value, nil
|
|
||||||
}
|
|
||||||
386
cmd/times.go
386
cmd/times.go
|
|
@ -1,386 +0,0 @@
|
||||||
package cmd
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"code.gitea.io/sdk/gitea"
|
|
||||||
"forgejo.zerova.net/public/fgj-sid/internal/api"
|
|
||||||
"forgejo.zerova.net/public/fgj-sid/internal/config"
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
)
|
|
||||||
|
|
||||||
var timeCmd = &cobra.Command{
|
|
||||||
Use: "time",
|
|
||||||
Aliases: []string{"times", "t"},
|
|
||||||
Short: "Manage tracked time entries",
|
|
||||||
Long: `Manage tracked time entries on issues and pull requests.
|
|
||||||
|
|
||||||
Time tracking must be enabled on the repository for add/delete/reset to succeed.
|
|
||||||
Durations are parsed with Go's time.ParseDuration, so values like "30m", "1h30m",
|
|
||||||
"2h", or "45s" are all accepted.`,
|
|
||||||
}
|
|
||||||
|
|
||||||
var timeListCmd = &cobra.Command{
|
|
||||||
Use: "list [issue-number]",
|
|
||||||
Short: "List tracked time entries",
|
|
||||||
Long: `List tracked time entries.
|
|
||||||
|
|
||||||
When no issue number is given, shows the authenticated user's tracked times
|
|
||||||
across all repositories on the current host. When an issue number is given,
|
|
||||||
shows the tracked times recorded against that issue in the current repository
|
|
||||||
(or the repository selected with -R/--repo).
|
|
||||||
|
|
||||||
The issue argument may be a bare number (123), a "#"-prefixed number (#123),
|
|
||||||
or a full issue URL.`,
|
|
||||||
Example: ` # List your tracked times across all repos
|
|
||||||
fgj time list
|
|
||||||
|
|
||||||
# List tracked times on issue #42 in the current repo
|
|
||||||
fgj time list 42
|
|
||||||
|
|
||||||
# List tracked times on issue #42 in a specific repo
|
|
||||||
fgj time list 42 -R owner/repo
|
|
||||||
|
|
||||||
# Output as JSON
|
|
||||||
fgj time list --json
|
|
||||||
fgj time list 42 --json`,
|
|
||||||
Args: cobra.MaximumNArgs(1),
|
|
||||||
RunE: runTimeList,
|
|
||||||
}
|
|
||||||
|
|
||||||
var timeAddCmd = &cobra.Command{
|
|
||||||
Use: "add <issue-number> <duration>",
|
|
||||||
Short: "Add a tracked time entry to an issue",
|
|
||||||
Long: `Add a tracked time entry to an issue or pull request.
|
|
||||||
|
|
||||||
The duration argument accepts any string that Go's time.ParseDuration
|
|
||||||
understands, for example:
|
|
||||||
|
|
||||||
30s thirty seconds
|
|
||||||
45m forty-five minutes
|
|
||||||
1h one hour
|
|
||||||
1h30m ninety minutes
|
|
||||||
2h15m30s two hours, fifteen minutes, thirty seconds
|
|
||||||
|
|
||||||
The value is rounded down to whole seconds before being sent to the server.
|
|
||||||
The issue argument may be a bare number, #-prefixed, or an issue URL.`,
|
|
||||||
Example: ` # Add 30 minutes to issue #42 in the current repo
|
|
||||||
fgj time add 42 30m
|
|
||||||
|
|
||||||
# Add 1h 30m to issue #7 in another repo
|
|
||||||
fgj time add 7 1h30m -R owner/repo
|
|
||||||
|
|
||||||
# Add just a few seconds (useful for testing)
|
|
||||||
fgj time add #42 45s`,
|
|
||||||
Args: cobra.ExactArgs(2),
|
|
||||||
RunE: runTimeAdd,
|
|
||||||
}
|
|
||||||
|
|
||||||
var timeDeleteCmd = &cobra.Command{
|
|
||||||
Use: "delete <issue-number> <time-id>",
|
|
||||||
Aliases: []string{"rm"},
|
|
||||||
Short: "Delete a specific tracked time entry",
|
|
||||||
Long: `Delete a single tracked time entry from an issue by its entry ID.
|
|
||||||
|
|
||||||
Use "fgj time list <issue-number>" to find the ID of the entry you want to
|
|
||||||
remove. A confirmation prompt is shown unless --yes is passed or stdin is
|
|
||||||
not a TTY.`,
|
|
||||||
Example: ` # Delete tracked time entry 123 from issue #42
|
|
||||||
fgj time delete 42 123
|
|
||||||
|
|
||||||
# Delete without confirmation
|
|
||||||
fgj time delete 42 123 --yes
|
|
||||||
|
|
||||||
# Target a specific repository
|
|
||||||
fgj time rm 42 123 -R owner/repo`,
|
|
||||||
Args: cobra.ExactArgs(2),
|
|
||||||
RunE: runTimeDelete,
|
|
||||||
}
|
|
||||||
|
|
||||||
var timeResetCmd = &cobra.Command{
|
|
||||||
Use: "reset <issue-number>",
|
|
||||||
Short: "Delete all tracked time entries on an issue",
|
|
||||||
Long: `Reset (delete all) tracked time entries on an issue or pull request.
|
|
||||||
|
|
||||||
This removes every time entry recorded against the issue, regardless of who
|
|
||||||
logged it. A confirmation prompt is shown unless --yes is passed or stdin is
|
|
||||||
not a TTY.`,
|
|
||||||
Example: ` # Reset tracked times on issue #42 in the current repo
|
|
||||||
fgj time reset 42
|
|
||||||
|
|
||||||
# Reset without confirmation
|
|
||||||
fgj time reset 42 --yes
|
|
||||||
|
|
||||||
# Reset in a specific repo
|
|
||||||
fgj time reset 42 -R owner/repo`,
|
|
||||||
Args: cobra.ExactArgs(1),
|
|
||||||
RunE: runTimeReset,
|
|
||||||
}
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
rootCmd.AddCommand(timeCmd)
|
|
||||||
timeCmd.AddCommand(timeListCmd)
|
|
||||||
timeCmd.AddCommand(timeAddCmd)
|
|
||||||
timeCmd.AddCommand(timeDeleteCmd)
|
|
||||||
timeCmd.AddCommand(timeResetCmd)
|
|
||||||
|
|
||||||
addRepoFlags(timeListCmd)
|
|
||||||
addJSONFlags(timeListCmd, "Output as JSON")
|
|
||||||
|
|
||||||
addRepoFlags(timeAddCmd)
|
|
||||||
|
|
||||||
addRepoFlags(timeDeleteCmd)
|
|
||||||
timeDeleteCmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompt")
|
|
||||||
|
|
||||||
addRepoFlags(timeResetCmd)
|
|
||||||
timeResetCmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompt")
|
|
||||||
}
|
|
||||||
|
|
||||||
func runTimeList(cmd *cobra.Command, args []string) error {
|
|
||||||
// No issue argument: list the authenticated user's tracked times across
|
|
||||||
// all repos on the current host. This path does not require a repo
|
|
||||||
// context, so we use loadClient.
|
|
||||||
if len(args) == 0 {
|
|
||||||
client, err := loadClient()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
times, _, err := client.ListMyTrackedTimes(gitea.ListTrackedTimesOptions{})
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to list tracked times: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if wantJSON(cmd) {
|
|
||||||
return outputJSON(cmd, times)
|
|
||||||
}
|
|
||||||
|
|
||||||
return renderTimesTable(times, true)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Issue-scoped list.
|
|
||||||
index, err := parseIssueArg(args[0])
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("invalid issue %q: %w", args[0], err)
|
|
||||||
}
|
|
||||||
|
|
||||||
client, owner, repoName, err := newTimeClient(cmd)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
times, _, err := client.ListIssueTrackedTimes(owner, repoName, index, gitea.ListTrackedTimesOptions{})
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to list tracked times for %s/%s#%d: %w", owner, repoName, index, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if wantJSON(cmd) {
|
|
||||||
return outputJSON(cmd, times)
|
|
||||||
}
|
|
||||||
|
|
||||||
return renderTimesTable(times, true)
|
|
||||||
}
|
|
||||||
|
|
||||||
func runTimeAdd(cmd *cobra.Command, args []string) error {
|
|
||||||
index, err := parseIssueArg(args[0])
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("invalid issue %q: %w", args[0], err)
|
|
||||||
}
|
|
||||||
|
|
||||||
dur, err := time.ParseDuration(args[1])
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("invalid duration %q (expected a Go duration like 30m, 1h30m, 2h): %w", args[1], err)
|
|
||||||
}
|
|
||||||
seconds := int64(dur.Seconds())
|
|
||||||
if seconds <= 0 {
|
|
||||||
return fmt.Errorf("duration must be greater than zero seconds")
|
|
||||||
}
|
|
||||||
|
|
||||||
client, owner, repoName, err := newTimeClient(cmd)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
tt, _, err := client.AddTime(owner, repoName, index, gitea.AddTimeOption{
|
|
||||||
Time: seconds,
|
|
||||||
Created: time.Time{},
|
|
||||||
User: "",
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to add tracked time to %s/%s#%d: %w", owner, repoName, index, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
cs := ios.ColorScheme()
|
|
||||||
fmt.Fprintf(ios.Out, "%s Added %s to %s/%s#%d (entry %d)\n",
|
|
||||||
cs.SuccessIcon(), formatDurationSeconds(tt.Time), owner, repoName, index, tt.ID)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func runTimeDelete(cmd *cobra.Command, args []string) error {
|
|
||||||
index, err := parseIssueArg(args[0])
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("invalid issue %q: %w", args[0], err)
|
|
||||||
}
|
|
||||||
|
|
||||||
timeID, err := parseIssueArg(args[1])
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("invalid time id %q: %w", args[1], err)
|
|
||||||
}
|
|
||||||
|
|
||||||
client, owner, repoName, err := newTimeClient(cmd)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
skipConfirm, _ := cmd.Flags().GetBool("yes")
|
|
||||||
if !skipConfirm && ios.IsStdinTTY() {
|
|
||||||
answer, err := promptLine(fmt.Sprintf("Delete tracked time entry %d on %s/%s#%d? [y/N]: ", timeID, owner, repoName, index))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if answer != "y" && answer != "Y" && answer != "yes" {
|
|
||||||
fmt.Fprintln(ios.ErrOut, "Cancelled.")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := client.DeleteTime(owner, repoName, index, timeID); err != nil {
|
|
||||||
return fmt.Errorf("failed to delete tracked time entry %d on %s/%s#%d: %w", timeID, owner, repoName, index, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
cs := ios.ColorScheme()
|
|
||||||
fmt.Fprintf(ios.Out, "%s Deleted tracked time entry %d on %s/%s#%d\n", cs.SuccessIcon(), timeID, owner, repoName, index)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func runTimeReset(cmd *cobra.Command, args []string) error {
|
|
||||||
index, err := parseIssueArg(args[0])
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("invalid issue %q: %w", args[0], err)
|
|
||||||
}
|
|
||||||
|
|
||||||
client, owner, repoName, err := newTimeClient(cmd)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
skipConfirm, _ := cmd.Flags().GetBool("yes")
|
|
||||||
if !skipConfirm && ios.IsStdinTTY() {
|
|
||||||
answer, err := promptLine(fmt.Sprintf("Delete ALL tracked time entries on %s/%s#%d? [y/N]: ", owner, repoName, index))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if answer != "y" && answer != "Y" && answer != "yes" {
|
|
||||||
fmt.Fprintln(ios.ErrOut, "Cancelled.")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := client.ResetIssueTime(owner, repoName, index); err != nil {
|
|
||||||
return fmt.Errorf("failed to reset tracked times on %s/%s#%d: %w", owner, repoName, index, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
cs := ios.ColorScheme()
|
|
||||||
fmt.Fprintf(ios.Out, "%s Reset tracked times on %s/%s#%d\n", cs.SuccessIcon(), owner, repoName, index)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// renderTimesTable prints a table of tracked time entries. When showRepo is
|
|
||||||
// true the ISSUE column includes the owner/repo prefix (useful for the
|
|
||||||
// cross-repo "list my times" view).
|
|
||||||
func renderTimesTable(times []*gitea.TrackedTime, showRepo bool) error {
|
|
||||||
if len(times) == 0 {
|
|
||||||
fmt.Fprintln(ios.Out, "No tracked time entries.")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
tp := ios.NewTablePrinter()
|
|
||||||
tp.AddHeader("ID", "DATE", "USER", "ISSUE", "TIME")
|
|
||||||
for _, t := range times {
|
|
||||||
date := ""
|
|
||||||
if !t.Created.IsZero() {
|
|
||||||
date = t.Created.Local().Format("2006-01-02")
|
|
||||||
}
|
|
||||||
|
|
||||||
issueCol := ""
|
|
||||||
if t.Issue != nil {
|
|
||||||
if showRepo && t.Issue.Repository != nil && t.Issue.Repository.FullName != "" {
|
|
||||||
issueCol = fmt.Sprintf("%s#%d", t.Issue.Repository.FullName, t.Issue.Index)
|
|
||||||
} else {
|
|
||||||
issueCol = fmt.Sprintf("#%d", t.Issue.Index)
|
|
||||||
}
|
|
||||||
} else if t.IssueID != 0 {
|
|
||||||
issueCol = fmt.Sprintf("#%d", t.IssueID)
|
|
||||||
}
|
|
||||||
|
|
||||||
tp.AddRow(
|
|
||||||
fmt.Sprintf("%d", t.ID),
|
|
||||||
date,
|
|
||||||
t.UserName,
|
|
||||||
issueCol,
|
|
||||||
formatDurationSeconds(t.Time),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return tp.Render()
|
|
||||||
}
|
|
||||||
|
|
||||||
// formatDurationSeconds renders a seconds count as a compact human duration.
|
|
||||||
// Examples: 0 -> "0m", 45 -> "45s", 90 -> "1m 30s", 3600 -> "1h",
|
|
||||||
// 5400 -> "1h 30m", 3661 -> "1h 1m 1s".
|
|
||||||
func formatDurationSeconds(seconds int64) string {
|
|
||||||
if seconds <= 0 {
|
|
||||||
return "0m"
|
|
||||||
}
|
|
||||||
|
|
||||||
h := seconds / 3600
|
|
||||||
m := (seconds % 3600) / 60
|
|
||||||
s := seconds % 60
|
|
||||||
|
|
||||||
parts := make([]string, 0, 3)
|
|
||||||
if h > 0 {
|
|
||||||
parts = append(parts, fmt.Sprintf("%dh", h))
|
|
||||||
}
|
|
||||||
if m > 0 {
|
|
||||||
parts = append(parts, fmt.Sprintf("%dm", m))
|
|
||||||
}
|
|
||||||
if s > 0 && h == 0 {
|
|
||||||
// Only show seconds when the duration is under an hour; keeps the
|
|
||||||
// table tidy for long entries where the second-level detail is
|
|
||||||
// rarely interesting.
|
|
||||||
parts = append(parts, fmt.Sprintf("%ds", s))
|
|
||||||
}
|
|
||||||
if len(parts) == 0 {
|
|
||||||
return "0m"
|
|
||||||
}
|
|
||||||
|
|
||||||
// Join with spaces: "1h 30m".
|
|
||||||
out := parts[0]
|
|
||||||
for _, p := range parts[1:] {
|
|
||||||
out += " " + p
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// newTimeClient builds an api.Client plus the resolved owner/repo from the
|
|
||||||
// current -R/--repo flag or the surrounding git context. Used by every
|
|
||||||
// repo-scoped subcommand (add, delete, reset, and issue-scoped list).
|
|
||||||
func newTimeClient(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
|
|
||||||
}
|
|
||||||
293
cmd/webhook.go
293
cmd/webhook.go
|
|
@ -1,293 +0,0 @@
|
||||||
package cmd
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"code.gitea.io/sdk/gitea"
|
|
||||||
"forgejo.zerova.net/public/fgj-sid/internal/api"
|
|
||||||
"forgejo.zerova.net/public/fgj-sid/internal/config"
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
)
|
|
||||||
|
|
||||||
var webhookCmd = &cobra.Command{
|
|
||||||
Use: "webhook",
|
|
||||||
Aliases: []string{"webhooks", "hook"},
|
|
||||||
Short: "Manage repository webhooks",
|
|
||||||
Long: "List, create, update, and delete webhooks attached to a repository.",
|
|
||||||
}
|
|
||||||
|
|
||||||
var webhookListCmd = &cobra.Command{
|
|
||||||
Use: "list",
|
|
||||||
Aliases: []string{"ls"},
|
|
||||||
Short: "List webhooks for a repository",
|
|
||||||
Example: ` # List webhooks on the current repository
|
|
||||||
fgj webhook list
|
|
||||||
|
|
||||||
# List with JSON output
|
|
||||||
fgj webhook list --json`,
|
|
||||||
RunE: runWebhookList,
|
|
||||||
}
|
|
||||||
|
|
||||||
var webhookCreateCmd = &cobra.Command{
|
|
||||||
Use: "create <url>",
|
|
||||||
Short: "Create a repository webhook",
|
|
||||||
Long: `Create a webhook that delivers events to <url>.
|
|
||||||
|
|
||||||
Event names follow the Gitea/Forgejo webhook event model: push, pull_request,
|
|
||||||
issues, issue_comment, release, create, delete, fork, wiki, repository, and others.
|
|
||||||
Omit --events to deliver only the default (push).`,
|
|
||||||
Example: ` # Create a Gitea-format push webhook
|
|
||||||
fgj webhook create https://example.com/hook
|
|
||||||
|
|
||||||
# Multiple events and a content type
|
|
||||||
fgj webhook create https://ci.example.com/hook \
|
|
||||||
--events push,pull_request,release \
|
|
||||||
--content-type application/json \
|
|
||||||
--secret "$HOOK_SECRET"
|
|
||||||
|
|
||||||
# Slack-style webhook
|
|
||||||
fgj webhook create https://hooks.slack.com/services/XXX --type slack`,
|
|
||||||
Args: cobra.ExactArgs(1),
|
|
||||||
RunE: runWebhookCreate,
|
|
||||||
}
|
|
||||||
|
|
||||||
var webhookUpdateCmd = &cobra.Command{
|
|
||||||
Use: "update <id>",
|
|
||||||
Aliases: []string{"edit"},
|
|
||||||
Short: "Update a repository webhook",
|
|
||||||
Long: "Update an existing webhook. Flags you omit are left unchanged.",
|
|
||||||
Args: cobra.ExactArgs(1),
|
|
||||||
Example: ` # Disable a webhook
|
|
||||||
fgj webhook update 12 --active=false
|
|
||||||
|
|
||||||
# Change events and URL
|
|
||||||
fgj webhook update 12 --url https://new.example.com/hook --events push,release`,
|
|
||||||
RunE: runWebhookUpdate,
|
|
||||||
}
|
|
||||||
|
|
||||||
var webhookDeleteCmd = &cobra.Command{
|
|
||||||
Use: "delete <id>",
|
|
||||||
Aliases: []string{"rm"},
|
|
||||||
Short: "Delete a repository webhook",
|
|
||||||
Args: cobra.ExactArgs(1),
|
|
||||||
RunE: runWebhookDelete,
|
|
||||||
}
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
rootCmd.AddCommand(webhookCmd)
|
|
||||||
webhookCmd.AddCommand(webhookListCmd)
|
|
||||||
webhookCmd.AddCommand(webhookCreateCmd)
|
|
||||||
webhookCmd.AddCommand(webhookUpdateCmd)
|
|
||||||
webhookCmd.AddCommand(webhookDeleteCmd)
|
|
||||||
|
|
||||||
addRepoFlags(webhookListCmd)
|
|
||||||
addJSONFlags(webhookListCmd, "Output as JSON")
|
|
||||||
|
|
||||||
addRepoFlags(webhookCreateCmd)
|
|
||||||
webhookCreateCmd.Flags().String("type", "gitea", "Hook type (gitea, slack, discord, msteams, telegram, feishu, gogs)")
|
|
||||||
webhookCreateCmd.Flags().StringSlice("events", []string{"push"}, "Events to deliver (comma-separated)")
|
|
||||||
webhookCreateCmd.Flags().String("content-type", "application/json", "Content type (application/json or application/x-www-form-urlencoded)")
|
|
||||||
webhookCreateCmd.Flags().String("secret", "", "HMAC secret used to sign payloads")
|
|
||||||
webhookCreateCmd.Flags().String("branch-filter", "", "Glob filter for branches that trigger the hook")
|
|
||||||
webhookCreateCmd.Flags().String("authorization-header", "", "Authorization header value sent with each delivery")
|
|
||||||
webhookCreateCmd.Flags().Bool("active", true, "Whether the hook is active on creation")
|
|
||||||
|
|
||||||
addRepoFlags(webhookUpdateCmd)
|
|
||||||
webhookUpdateCmd.Flags().String("url", "", "New target URL")
|
|
||||||
webhookUpdateCmd.Flags().StringSlice("events", nil, "New event list (replaces existing)")
|
|
||||||
webhookUpdateCmd.Flags().String("content-type", "", "New content type")
|
|
||||||
webhookUpdateCmd.Flags().String("secret", "", "New HMAC secret")
|
|
||||||
webhookUpdateCmd.Flags().String("branch-filter", "", "New branch filter")
|
|
||||||
webhookUpdateCmd.Flags().String("authorization-header", "", "New authorization header")
|
|
||||||
webhookUpdateCmd.Flags().Bool("active", true, "Enable or disable the hook")
|
|
||||||
|
|
||||||
addRepoFlags(webhookDeleteCmd)
|
|
||||||
webhookDeleteCmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompt")
|
|
||||||
}
|
|
||||||
|
|
||||||
func runWebhookList(cmd *cobra.Command, args []string) error {
|
|
||||||
client, owner, name, err := newWebhookClient(cmd)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
hooks, _, err := client.ListRepoHooks(owner, name, gitea.ListHooksOptions{})
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to list webhooks: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if wantJSON(cmd) {
|
|
||||||
return outputJSON(cmd, hooks)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(hooks) == 0 {
|
|
||||||
fmt.Fprintln(ios.Out, "No webhooks.")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
tp := ios.NewTablePrinter()
|
|
||||||
tp.AddHeader("ID", "TYPE", "URL", "EVENTS", "ACTIVE")
|
|
||||||
for _, h := range hooks {
|
|
||||||
url := h.Config["url"]
|
|
||||||
active := "no"
|
|
||||||
if h.Active {
|
|
||||||
active = "yes"
|
|
||||||
}
|
|
||||||
tp.AddRow(
|
|
||||||
strconv.FormatInt(h.ID, 10),
|
|
||||||
h.Type,
|
|
||||||
url,
|
|
||||||
strings.Join(h.Events, ","),
|
|
||||||
active,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return tp.Render()
|
|
||||||
}
|
|
||||||
|
|
||||||
func runWebhookCreate(cmd *cobra.Command, args []string) error {
|
|
||||||
client, owner, name, err := newWebhookClient(cmd)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
url := args[0]
|
|
||||||
hookType, _ := cmd.Flags().GetString("type")
|
|
||||||
events, _ := cmd.Flags().GetStringSlice("events")
|
|
||||||
contentType, _ := cmd.Flags().GetString("content-type")
|
|
||||||
secret, _ := cmd.Flags().GetString("secret")
|
|
||||||
branchFilter, _ := cmd.Flags().GetString("branch-filter")
|
|
||||||
authHeader, _ := cmd.Flags().GetString("authorization-header")
|
|
||||||
active, _ := cmd.Flags().GetBool("active")
|
|
||||||
|
|
||||||
opt := gitea.CreateHookOption{
|
|
||||||
Type: gitea.HookType(hookType),
|
|
||||||
Config: map[string]string{
|
|
||||||
"url": url,
|
|
||||||
"content_type": contentType,
|
|
||||||
},
|
|
||||||
Events: events,
|
|
||||||
BranchFilter: branchFilter,
|
|
||||||
Active: active,
|
|
||||||
AuthorizationHeader: authHeader,
|
|
||||||
}
|
|
||||||
if secret != "" {
|
|
||||||
opt.Config["secret"] = secret
|
|
||||||
}
|
|
||||||
|
|
||||||
hook, _, err := client.CreateRepoHook(owner, name, opt)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to create webhook: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
cs := ios.ColorScheme()
|
|
||||||
fmt.Fprintf(ios.Out, "%s Created webhook %d (%s → %s)\n", cs.SuccessIcon(), hook.ID, hook.Type, url)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func runWebhookUpdate(cmd *cobra.Command, args []string) error {
|
|
||||||
client, owner, name, err := newWebhookClient(cmd)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
id, err := strconv.ParseInt(args[0], 10, 64)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("invalid webhook id %q: %w", args[0], err)
|
|
||||||
}
|
|
||||||
|
|
||||||
opt := gitea.EditHookOption{}
|
|
||||||
|
|
||||||
// Only set fields the user explicitly provided.
|
|
||||||
cfg := map[string]string{}
|
|
||||||
if url, _ := cmd.Flags().GetString("url"); url != "" {
|
|
||||||
cfg["url"] = url
|
|
||||||
}
|
|
||||||
if ct, _ := cmd.Flags().GetString("content-type"); ct != "" {
|
|
||||||
cfg["content_type"] = ct
|
|
||||||
}
|
|
||||||
if secret, _ := cmd.Flags().GetString("secret"); secret != "" {
|
|
||||||
cfg["secret"] = secret
|
|
||||||
}
|
|
||||||
if len(cfg) > 0 {
|
|
||||||
opt.Config = cfg
|
|
||||||
}
|
|
||||||
|
|
||||||
if cmd.Flags().Changed("events") {
|
|
||||||
events, _ := cmd.Flags().GetStringSlice("events")
|
|
||||||
opt.Events = events
|
|
||||||
}
|
|
||||||
if cmd.Flags().Changed("branch-filter") {
|
|
||||||
bf, _ := cmd.Flags().GetString("branch-filter")
|
|
||||||
opt.BranchFilter = bf
|
|
||||||
}
|
|
||||||
if cmd.Flags().Changed("authorization-header") {
|
|
||||||
auth, _ := cmd.Flags().GetString("authorization-header")
|
|
||||||
opt.AuthorizationHeader = auth
|
|
||||||
}
|
|
||||||
if cmd.Flags().Changed("active") {
|
|
||||||
active, _ := cmd.Flags().GetBool("active")
|
|
||||||
opt.Active = &active
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := client.EditRepoHook(owner, name, id, opt); err != nil {
|
|
||||||
return fmt.Errorf("failed to update webhook %d: %w", id, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
cs := ios.ColorScheme()
|
|
||||||
fmt.Fprintf(ios.Out, "%s Updated webhook %d\n", cs.SuccessIcon(), id)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func runWebhookDelete(cmd *cobra.Command, args []string) error {
|
|
||||||
client, owner, name, err := newWebhookClient(cmd)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
id, err := strconv.ParseInt(args[0], 10, 64)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("invalid webhook id %q: %w", args[0], err)
|
|
||||||
}
|
|
||||||
|
|
||||||
skipConfirm, _ := cmd.Flags().GetBool("yes")
|
|
||||||
if !skipConfirm && ios.IsStdinTTY() {
|
|
||||||
answer, err := promptLine(fmt.Sprintf("Delete webhook %d in %s/%s? [y/N]: ", id, owner, name))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if answer != "y" && answer != "Y" && answer != "yes" {
|
|
||||||
fmt.Fprintln(ios.ErrOut, "Cancelled.")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := client.DeleteRepoHook(owner, name, id); err != nil {
|
|
||||||
return fmt.Errorf("failed to delete webhook %d: %w", id, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
cs := ios.ColorScheme()
|
|
||||||
fmt.Fprintf(ios.Out, "%s Deleted webhook %d\n", cs.SuccessIcon(), id)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func newWebhookClient(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
|
|
||||||
}
|
|
||||||
|
|
@ -1,53 +0,0 @@
|
||||||
package cmd
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
)
|
|
||||||
|
|
||||||
var whoamiCmd = &cobra.Command{
|
|
||||||
Use: "whoami",
|
|
||||||
Short: "Show the authenticated user on the current host",
|
|
||||||
Long: "Display login, full name, and email for the authenticated user on the active host.",
|
|
||||||
Example: ` # Show who you are on the active host
|
|
||||||
fgj whoami
|
|
||||||
|
|
||||||
# On a specific host
|
|
||||||
fgj whoami --hostname forgejo.example.com
|
|
||||||
|
|
||||||
# As JSON
|
|
||||||
fgj whoami --json`,
|
|
||||||
RunE: runWhoami,
|
|
||||||
}
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
rootCmd.AddCommand(whoamiCmd)
|
|
||||||
addJSONFlags(whoamiCmd, "Output as JSON")
|
|
||||||
}
|
|
||||||
|
|
||||||
func runWhoami(cmd *cobra.Command, args []string) error {
|
|
||||||
client, err := loadClient()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
user, _, err := client.GetMyUserInfo()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to fetch current user: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if wantJSON(cmd) {
|
|
||||||
return outputJSON(cmd, user)
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Fprintf(ios.Out, "%s\n", user.UserName)
|
|
||||||
if user.FullName != "" && user.FullName != user.UserName {
|
|
||||||
fmt.Fprintf(ios.Out, " name: %s\n", user.FullName)
|
|
||||||
}
|
|
||||||
if user.Email != "" {
|
|
||||||
fmt.Fprintf(ios.Out, " email: %s\n", user.Email)
|
|
||||||
}
|
|
||||||
fmt.Fprintf(ios.Out, " host: %s\n", client.Hostname())
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
47
cmd/wiki.go
47
cmd/wiki.go
|
|
@ -7,9 +7,9 @@ import (
|
||||||
"net/url"
|
"net/url"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"forgejo.zerova.net/public/fgj-sid/internal/api"
|
"forgejo.zerova.net/public/fj/internal/api"
|
||||||
"forgejo.zerova.net/public/fgj-sid/internal/config"
|
"forgejo.zerova.net/public/fj/internal/config"
|
||||||
"forgejo.zerova.net/public/fgj-sid/internal/text"
|
"forgejo.zerova.net/public/fj/internal/text"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -61,13 +61,13 @@ var wikiListCmd = &cobra.Command{
|
||||||
Short: "List wiki pages",
|
Short: "List wiki pages",
|
||||||
Long: "List all wiki pages for a repository.",
|
Long: "List all wiki pages for a repository.",
|
||||||
Example: ` # List wiki pages for the current repo
|
Example: ` # List wiki pages for the current repo
|
||||||
fgj wiki list
|
fj wiki list
|
||||||
|
|
||||||
# List wiki pages for a specific repo
|
# List wiki pages for a specific repo
|
||||||
fgj wiki list -R owner/repo
|
fj wiki list -R owner/repo
|
||||||
|
|
||||||
# Output as JSON
|
# Output as JSON
|
||||||
fgj wiki list --json`,
|
fj wiki list --json`,
|
||||||
RunE: runWikiList,
|
RunE: runWikiList,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -76,16 +76,16 @@ var wikiViewCmd = &cobra.Command{
|
||||||
Short: "View a wiki page",
|
Short: "View a wiki page",
|
||||||
Long: "Display the content of a wiki page.",
|
Long: "Display the content of a wiki page.",
|
||||||
Example: ` # View a wiki page
|
Example: ` # View a wiki page
|
||||||
fgj wiki view Home
|
fj wiki view Home
|
||||||
|
|
||||||
# Open in browser
|
# Open in browser
|
||||||
fgj wiki view Home --web
|
fj wiki view Home --web
|
||||||
|
|
||||||
# View a wiki page as JSON (includes content)
|
# View a wiki page as JSON (includes content)
|
||||||
fgj wiki view Home --json
|
fj wiki view Home --json
|
||||||
|
|
||||||
# View a wiki page from a specific repo
|
# View a wiki page from a specific repo
|
||||||
fgj wiki view "Getting-Started" -R owner/repo`,
|
fj wiki view "Getting-Started" -R owner/repo`,
|
||||||
Args: cobra.ExactArgs(1),
|
Args: cobra.ExactArgs(1),
|
||||||
RunE: runWikiView,
|
RunE: runWikiView,
|
||||||
}
|
}
|
||||||
|
|
@ -95,16 +95,16 @@ var wikiCreateCmd = &cobra.Command{
|
||||||
Short: "Create a wiki page",
|
Short: "Create a wiki page",
|
||||||
Long: "Create a new wiki page in the repository.",
|
Long: "Create a new wiki page in the repository.",
|
||||||
Example: ` # Create a wiki page with inline content
|
Example: ` # Create a wiki page with inline content
|
||||||
fgj wiki create "Getting Started" -b "# Welcome\nThis is the getting started guide."
|
fj wiki create "Getting Started" -b "# Welcome\nThis is the getting started guide."
|
||||||
|
|
||||||
# Create a wiki page from a file
|
# Create a wiki page from a file
|
||||||
fgj wiki create "Setup Guide" --body-file setup.md
|
fj wiki create "Setup Guide" --body-file setup.md
|
||||||
|
|
||||||
# Create a wiki page from stdin
|
# Create a wiki page from stdin
|
||||||
echo "# FAQ" | fgj wiki create FAQ --body-file -
|
echo "# FAQ" | fj wiki create FAQ --body-file -
|
||||||
|
|
||||||
# Output as JSON
|
# Output as JSON
|
||||||
fgj wiki create "New Page" -b "Content here" --json`,
|
fj wiki create "New Page" -b "Content here" --json`,
|
||||||
Args: cobra.ExactArgs(1),
|
Args: cobra.ExactArgs(1),
|
||||||
RunE: runWikiCreate,
|
RunE: runWikiCreate,
|
||||||
}
|
}
|
||||||
|
|
@ -114,16 +114,16 @@ var wikiEditCmd = &cobra.Command{
|
||||||
Short: "Edit a wiki page",
|
Short: "Edit a wiki page",
|
||||||
Long: "Edit an existing wiki page in the repository.",
|
Long: "Edit an existing wiki page in the repository.",
|
||||||
Example: ` # Edit a wiki page with new content
|
Example: ` # Edit a wiki page with new content
|
||||||
fgj wiki edit Home -b "# Updated Home\nNew content here."
|
fj wiki edit Home -b "# Updated Home\nNew content here."
|
||||||
|
|
||||||
# Edit a wiki page from a file
|
# Edit a wiki page from a file
|
||||||
fgj wiki edit "Setup Guide" --body-file updated-setup.md
|
fj wiki edit "Setup Guide" --body-file updated-setup.md
|
||||||
|
|
||||||
# Edit a wiki page from stdin
|
# Edit a wiki page from stdin
|
||||||
cat new-content.md | fgj wiki edit Home --body-file -
|
cat new-content.md | fj wiki edit Home --body-file -
|
||||||
|
|
||||||
# Output as JSON
|
# Output as JSON
|
||||||
fgj wiki edit Home -b "Updated content" --json`,
|
fj wiki edit Home -b "Updated content" --json`,
|
||||||
Args: cobra.ExactArgs(1),
|
Args: cobra.ExactArgs(1),
|
||||||
RunE: runWikiEdit,
|
RunE: runWikiEdit,
|
||||||
}
|
}
|
||||||
|
|
@ -133,13 +133,13 @@ var wikiDeleteCmd = &cobra.Command{
|
||||||
Short: "Delete a wiki page",
|
Short: "Delete a wiki page",
|
||||||
Long: "Delete a wiki page from the repository.",
|
Long: "Delete a wiki page from the repository.",
|
||||||
Example: ` # Delete a wiki page
|
Example: ` # Delete a wiki page
|
||||||
fgj wiki delete "Old Page"
|
fj wiki delete "Old Page"
|
||||||
|
|
||||||
# Delete without confirmation
|
# Delete without confirmation
|
||||||
fgj wiki delete "Old Page" -y
|
fj wiki delete "Old Page" -y
|
||||||
|
|
||||||
# Delete a wiki page from a specific repo
|
# Delete a wiki page from a specific repo
|
||||||
fgj wiki delete "Outdated Guide" -R owner/repo`,
|
fj wiki delete "Outdated Guide" -R owner/repo`,
|
||||||
Args: cobra.ExactArgs(1),
|
Args: cobra.ExactArgs(1),
|
||||||
RunE: runWikiDelete,
|
RunE: runWikiDelete,
|
||||||
}
|
}
|
||||||
|
|
@ -266,10 +266,9 @@ func runWikiView(cmd *cobra.Command, args []string) error {
|
||||||
return fmt.Errorf("wiki page has no HTML URL")
|
return fmt.Errorf("wiki page has no HTML URL")
|
||||||
}
|
}
|
||||||
|
|
||||||
jsonFlag, _ := cmd.Flags().GetBool("json")
|
if wantJSON(cmd) {
|
||||||
if jsonFlag {
|
|
||||||
page.Content = string(content)
|
page.Content = string(content)
|
||||||
return writeJSON(page)
|
return outputJSON(cmd, page)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := ios.StartPager(); err != nil {
|
if err := ios.StartPager(); err != nil {
|
||||||
|
|
|
||||||
6
go.mod
6
go.mod
|
|
@ -1,10 +1,9 @@
|
||||||
module forgejo.zerova.net/public/fgj-sid
|
module forgejo.zerova.net/public/fj
|
||||||
|
|
||||||
go 1.24.0
|
go 1.24.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
code.gitea.io/sdk/gitea v0.23.2
|
code.gitea.io/sdk/gitea v0.22.1
|
||||||
github.com/itchyny/gojq v0.12.18
|
|
||||||
github.com/spf13/cobra v1.8.1
|
github.com/spf13/cobra v1.8.1
|
||||||
github.com/spf13/viper v1.19.0
|
github.com/spf13/viper v1.19.0
|
||||||
golang.org/x/term v0.32.0
|
golang.org/x/term v0.32.0
|
||||||
|
|
@ -20,6 +19,7 @@ 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/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
|
||||||
|
|
|
||||||
6
go.sum
6
go.sum
|
|
@ -1,5 +1,5 @@
|
||||||
code.gitea.io/sdk/gitea v0.23.2 h1:iJB1FDmLegwfwjX8gotBDHdPSbk/ZR8V9VmEJaVsJYg=
|
code.gitea.io/sdk/gitea v0.22.1 h1:7K05KjRORyTcTYULQ/AwvlVS6pawLcWyXZcTr7gHFyA=
|
||||||
code.gitea.io/sdk/gitea v0.23.2/go.mod h1:yyF5+GhljqvA30sRDreoyHILruNiy4ASufugzYg0VHM=
|
code.gitea.io/sdk/gitea v0.22.1/go.mod h1:yyF5+GhljqvA30sRDreoyHILruNiy4ASufugzYg0VHM=
|
||||||
github.com/42wim/httpsig v1.2.3 h1:xb0YyWhkYj57SPtfSttIobJUPJZB9as1nsfo7KWVcEs=
|
github.com/42wim/httpsig v1.2.3 h1:xb0YyWhkYj57SPtfSttIobJUPJZB9as1nsfo7KWVcEs=
|
||||||
github.com/42wim/httpsig v1.2.3/go.mod h1:nZq9OlYKDrUBhptd77IHx4/sZZD+IxTBADvAPI9G/EM=
|
github.com/42wim/httpsig v1.2.3/go.mod h1:nZq9OlYKDrUBhptd77IHx4/sZZD+IxTBADvAPI9G/EM=
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.4 h1:wfIWP927BUkWJb2NmU/kNDYIBTh/ziUX91+lVfRxZq4=
|
github.com/cpuguy83/go-md2man/v2 v2.0.4 h1:wfIWP927BUkWJb2NmU/kNDYIBTh/ziUX91+lVfRxZq4=
|
||||||
|
|
@ -90,6 +90,8 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v
|
||||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
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/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
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/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=
|
||||||
|
|
|
||||||
|
|
@ -9,13 +9,19 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"code.gitea.io/sdk/gitea"
|
"code.gitea.io/sdk/gitea"
|
||||||
"forgejo.zerova.net/public/fgj-sid/internal/config"
|
"forgejo.zerova.net/public/fj/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
var sharedHTTPClient = &http.Client{
|
// SharedHTTPClient is the package-wide HTTP client. Exported so other
|
||||||
|
// packages (notably cmd/api.go) can reuse the same timeout and connection
|
||||||
|
// pooling instead of constructing zero-value clients with no timeout.
|
||||||
|
var SharedHTTPClient = &http.Client{
|
||||||
Timeout: 30 * time.Second,
|
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
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ package api
|
||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"forgejo.zerova.net/public/fgj-sid/internal/config"
|
"forgejo.zerova.net/public/fj/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestClient_Hostname(t *testing.T) {
|
func TestClient_Hostname(t *testing.T) {
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/spf13/viper"
|
"github.com/spf13/viper"
|
||||||
|
|
@ -15,28 +14,41 @@ type Config struct {
|
||||||
Hosts map[string]HostConfig `yaml:"hosts"`
|
Hosts map[string]HostConfig `yaml:"hosts"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// explicitConfigPath, when non-empty, overrides the default config file
|
||||||
|
// location for both Load() and Save(). It's set by cmd/root.initConfig when
|
||||||
|
// the user passes --config <path>. Stored at package scope so existing
|
||||||
|
// call sites of config.Load()/c.Save() continue to work without each one
|
||||||
|
// having to know about the flag.
|
||||||
|
var explicitConfigPath string
|
||||||
|
|
||||||
|
// SetExplicitConfigPath wires a user-supplied --config path through to
|
||||||
|
// Load/Save. Pass "" to clear.
|
||||||
|
func SetExplicitConfigPath(p string) { explicitConfigPath = p }
|
||||||
|
|
||||||
type HostConfig struct {
|
type HostConfig struct {
|
||||||
Hostname string `yaml:"hostname"`
|
Hostname string `yaml:"hostname"`
|
||||||
Token string `yaml:"token"`
|
Token string `yaml:"token"`
|
||||||
User string `yaml:"user,omitempty"`
|
User string `yaml:"user,omitempty"`
|
||||||
GitProtocol string `yaml:"git_protocol,omitempty"`
|
GitProtocol string `yaml:"git_protocol,omitempty"`
|
||||||
MatchDirs []string `yaml:"match_dirs,omitempty"`
|
MatchDirs []string `yaml:"match_dirs,omitempty"`
|
||||||
Default bool `yaml:"default,omitempty"`
|
|
||||||
Order int `yaml:"-"` // config file order, set at load time
|
Order int `yaml:"-"` // config file order, set at load time
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetConfigDir() (string, error) {
|
func GetConfigDir() (string, error) {
|
||||||
if xdgConfigHome := os.Getenv("XDG_CONFIG_HOME"); xdgConfigHome != "" {
|
if xdgConfigHome := os.Getenv("XDG_CONFIG_HOME"); xdgConfigHome != "" {
|
||||||
return filepath.Join(xdgConfigHome, "fgj"), nil
|
return filepath.Join(xdgConfigHome, "fj"), nil
|
||||||
}
|
}
|
||||||
home, err := os.UserHomeDir()
|
home, err := os.UserHomeDir()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
return filepath.Join(home, ".config", "fgj"), nil
|
return filepath.Join(home, ".config", "fj"), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetConfigPath() (string, error) {
|
func GetConfigPath() (string, error) {
|
||||||
|
if explicitConfigPath != "" {
|
||||||
|
return explicitConfigPath, nil
|
||||||
|
}
|
||||||
dir, err := GetConfigDir()
|
dir, err := GetConfigDir()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
|
|
@ -133,18 +145,17 @@ func (c *Config) SaveToPath(path string) error {
|
||||||
// Priority order:
|
// Priority order:
|
||||||
// 1. Explicitly provided hostname parameter
|
// 1. Explicitly provided hostname parameter
|
||||||
// 2. CLI flag (--hostname)
|
// 2. CLI flag (--hostname)
|
||||||
// 3. Environment variable (FGJ_HOST)
|
// 3. Environment variable (FJ_HOST, with FGJ_HOST fallback)
|
||||||
// 4. Auto-detected hostname from git remote
|
// 4. Auto-detected hostname from git remote
|
||||||
// 5. match_dirs lookup (longest prefix match)
|
// 5. match_dirs lookup (longest prefix match)
|
||||||
// 6. Configured default host (HostConfig.Default == true)
|
// 6. Default to codeberg.org
|
||||||
// 7. Default to codeberg.org
|
|
||||||
func (c *Config) GetHost(hostname string, detectedHost string, cwd 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 = os.Getenv("FGJ_HOST")
|
hostname = EnvWithFallback("FJ_HOST", "FGJ_HOST")
|
||||||
}
|
}
|
||||||
|
|
||||||
if hostname == "" {
|
if hostname == "" {
|
||||||
|
|
@ -155,10 +166,6 @@ func (c *Config) GetHost(hostname string, detectedHost string, cwd string) (Host
|
||||||
hostname = c.ResolveHostByPath(cwd)
|
hostname = c.ResolveHostByPath(cwd)
|
||||||
}
|
}
|
||||||
|
|
||||||
if hostname == "" {
|
|
||||||
hostname = c.DefaultHost()
|
|
||||||
}
|
|
||||||
|
|
||||||
if hostname == "" {
|
if hostname == "" {
|
||||||
hostname = "codeberg.org"
|
hostname = "codeberg.org"
|
||||||
}
|
}
|
||||||
|
|
@ -171,27 +178,6 @@ func (c *Config) GetHost(hostname string, detectedHost string, cwd string) (Host
|
||||||
return host, nil
|
return host, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// DefaultHost returns the hostname of the host marked Default == true.
|
|
||||||
// If no host is marked default, returns "". If multiple hosts are marked
|
|
||||||
// default (a user-error case), the one that sorts first alphabetically is
|
|
||||||
// returned and a warning is printed to stderr.
|
|
||||||
func (c *Config) DefaultHost() string {
|
|
||||||
var matches []string
|
|
||||||
for hostname, host := range c.Hosts {
|
|
||||||
if host.Default {
|
|
||||||
matches = append(matches, hostname)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(matches) == 0 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
sort.Strings(matches)
|
|
||||||
if len(matches) > 1 {
|
|
||||||
fmt.Fprintf(os.Stderr, "warning: multiple hosts marked default (%s); using %s\n", strings.Join(matches, ", "), matches[0])
|
|
||||||
}
|
|
||||||
return matches[0]
|
|
||||||
}
|
|
||||||
|
|
||||||
// ResolveHostByPath finds the host whose match_dirs entry is the longest
|
// ResolveHostByPath finds the host whose match_dirs entry is the longest
|
||||||
// prefix of cwd. Returns "" if no match is found.
|
// prefix of cwd. Returns "" if no match is found.
|
||||||
// Both cwd and match_dirs entries are resolved through filepath.EvalSymlinks
|
// Both cwd and match_dirs entries are resolved through filepath.EvalSymlinks
|
||||||
|
|
@ -256,6 +242,15 @@ func (c *Config) ResolveHostByPath(cwd string) string {
|
||||||
}
|
}
|
||||||
|
|
||||||
// expandHome replaces a leading ~ with the user's home directory.
|
// 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 {
|
func expandHome(path string) string {
|
||||||
if path == "~" || strings.HasPrefix(path, "~/") {
|
if path == "~" || strings.HasPrefix(path, "~/") {
|
||||||
home, err := os.UserHomeDir()
|
home, err := os.UserHomeDir()
|
||||||
|
|
|
||||||
|
|
@ -83,7 +83,7 @@ func TestGetConfigDir_XDG(t *testing.T) {
|
||||||
t.Fatalf("Unexpected error: %v", err)
|
t.Fatalf("Unexpected error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
expected := "/custom/config/fgj"
|
expected := "/custom/config/fj"
|
||||||
if dir != expected {
|
if dir != expected {
|
||||||
t.Errorf("Expected %q, got %q", expected, dir)
|
t.Errorf("Expected %q, got %q", expected, dir)
|
||||||
}
|
}
|
||||||
|
|
@ -439,7 +439,7 @@ func TestResolveHostByPath(t *testing.T) {
|
||||||
"forgejo.zerova.net": {
|
"forgejo.zerova.net": {
|
||||||
Hostname: "forgejo.zerova.net",
|
Hostname: "forgejo.zerova.net",
|
||||||
Token: "token1",
|
Token: "token1",
|
||||||
MatchDirs: []string{"/Users/sid/repos/fgj", "/Users/sid/repos/zerova"},
|
MatchDirs: []string{"/Users/sid/repos/fj", "/Users/sid/repos/zerova"},
|
||||||
},
|
},
|
||||||
"codeberg.org": {
|
"codeberg.org": {
|
||||||
Hostname: "codeberg.org",
|
Hostname: "codeberg.org",
|
||||||
|
|
@ -459,10 +459,10 @@ func TestResolveHostByPath(t *testing.T) {
|
||||||
cwd string
|
cwd string
|
||||||
want string
|
want string
|
||||||
}{
|
}{
|
||||||
{"exact dir match", "/Users/sid/repos/fgj", "forgejo.zerova.net"},
|
{"exact dir match", "/Users/sid/repos/fj", "forgejo.zerova.net"},
|
||||||
{"nested dir match", "/Users/sid/repos/fgj/cmd/root.go", "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"},
|
{"second match dir", "/Users/sid/repos/zerova/pkg", "forgejo.zerova.net"},
|
||||||
{"longest prefix wins over /", "/Users/sid/repos/fgj/internal", "forgejo.zerova.net"},
|
{"longest prefix wins over /", "/Users/sid/repos/fj/internal", "forgejo.zerova.net"},
|
||||||
{"/ as global catch-all", "/tmp", "codeberg.org"},
|
{"/ as global catch-all", "/tmp", "codeberg.org"},
|
||||||
{"/ matches root itself", "/", "codeberg.org"},
|
{"/ matches root itself", "/", "codeberg.org"},
|
||||||
{"no match_dirs host not selected", "/some/random/path", "codeberg.org"},
|
{"no match_dirs host not selected", "/some/random/path", "codeberg.org"},
|
||||||
|
|
@ -512,7 +512,7 @@ func TestGetHost_MatchDirsIntegration(t *testing.T) {
|
||||||
"forgejo.zerova.net": {
|
"forgejo.zerova.net": {
|
||||||
Hostname: "forgejo.zerova.net",
|
Hostname: "forgejo.zerova.net",
|
||||||
Token: "token1",
|
Token: "token1",
|
||||||
MatchDirs: []string{"/Users/sid/repos/fgj"},
|
MatchDirs: []string{"/Users/sid/repos/fj"},
|
||||||
},
|
},
|
||||||
"codeberg.org": {
|
"codeberg.org": {
|
||||||
Hostname: "codeberg.org",
|
Hostname: "codeberg.org",
|
||||||
|
|
@ -522,7 +522,7 @@ func TestGetHost_MatchDirsIntegration(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// cwd match should resolve to forgejo.zerova.net
|
// cwd match should resolve to forgejo.zerova.net
|
||||||
host, err := cfg.GetHost("", "", "/Users/sid/repos/fgj/cmd")
|
host, err := cfg.GetHost("", "", "/Users/sid/repos/fj/cmd")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,41 +17,41 @@ func TestParseRemoteURL(t *testing.T) {
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "HTTPS URL with .git",
|
name: "HTTPS URL with .git",
|
||||||
url: "https://codeberg.org/romaintb/fgj.git",
|
url: "https://codeberg.org/romaintb/fj.git",
|
||||||
wantOwner: "romaintb",
|
wantOwner: "romaintb",
|
||||||
wantName: "fgj",
|
wantName: "fj",
|
||||||
wantHost: "codeberg.org",
|
wantHost: "codeberg.org",
|
||||||
wantErr: false,
|
wantErr: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "HTTPS URL without .git",
|
name: "HTTPS URL without .git",
|
||||||
url: "https://codeberg.org/romaintb/fgj",
|
url: "https://codeberg.org/romaintb/fj",
|
||||||
wantOwner: "romaintb",
|
wantOwner: "romaintb",
|
||||||
wantName: "fgj",
|
wantName: "fj",
|
||||||
wantHost: "codeberg.org",
|
wantHost: "codeberg.org",
|
||||||
wantErr: false,
|
wantErr: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "SSH URL with .git",
|
name: "SSH URL with .git",
|
||||||
url: "git@codeberg.org:romaintb/fgj.git",
|
url: "git@codeberg.org:romaintb/fj.git",
|
||||||
wantOwner: "romaintb",
|
wantOwner: "romaintb",
|
||||||
wantName: "fgj",
|
wantName: "fj",
|
||||||
wantHost: "codeberg.org",
|
wantHost: "codeberg.org",
|
||||||
wantErr: false,
|
wantErr: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "SSH URL without .git",
|
name: "SSH URL without .git",
|
||||||
url: "git@codeberg.org:romaintb/fgj",
|
url: "git@codeberg.org:romaintb/fj",
|
||||||
wantOwner: "romaintb",
|
wantOwner: "romaintb",
|
||||||
wantName: "fgj",
|
wantName: "fj",
|
||||||
wantHost: "codeberg.org",
|
wantHost: "codeberg.org",
|
||||||
wantErr: false,
|
wantErr: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "SSH protocol URL",
|
name: "SSH protocol URL",
|
||||||
url: "ssh://git@codeberg.org/romaintb/fgj.git",
|
url: "ssh://git@codeberg.org/romaintb/fj.git",
|
||||||
wantOwner: "romaintb",
|
wantOwner: "romaintb",
|
||||||
wantName: "fgj",
|
wantName: "fj",
|
||||||
wantHost: "codeberg.org",
|
wantHost: "codeberg.org",
|
||||||
wantErr: false,
|
wantErr: false,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -37,10 +37,10 @@ type IOStreams struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates an IOStreams wired to the real os.Stdin, os.Stdout, and os.Stderr,
|
// New creates an IOStreams wired to the real os.Stdin, os.Stdout, and os.Stderr,
|
||||||
// with TTY status auto-detected. Setting FGJ_FORCE_TTY=1 forces all streams to
|
// with TTY status auto-detected. Setting FJ_FORCE_TTY=1 (or legacy FGJ_FORCE_TTY=1)
|
||||||
// be treated as TTYs.
|
// forces all streams to be treated as TTYs.
|
||||||
func New() *IOStreams {
|
func New() *IOStreams {
|
||||||
forceTTY := os.Getenv("FGJ_FORCE_TTY") != ""
|
forceTTY := os.Getenv("FJ_FORCE_TTY") != "" || os.Getenv("FGJ_FORCE_TTY") != ""
|
||||||
|
|
||||||
stdinTTY := forceTTY || (isTerminal(os.Stdin.Fd()))
|
stdinTTY := forceTTY || (isTerminal(os.Stdin.Fd()))
|
||||||
stdoutTTY := forceTTY || (isTerminal(os.Stdout.Fd()))
|
stdoutTTY := forceTTY || (isTerminal(os.Stdout.Fd()))
|
||||||
|
|
@ -118,14 +118,17 @@ func (s *IOStreams) ColorScheme() *ColorScheme {
|
||||||
}
|
}
|
||||||
|
|
||||||
// StartPager starts an external pager process and redirects Out to its stdin.
|
// StartPager starts an external pager process and redirects Out to its stdin.
|
||||||
// It checks FGJ_PAGER, then PAGER, then defaults to "less". If LESS is not
|
// It checks FJ_PAGER (or legacy FGJ_PAGER), then PAGER, then defaults to "less".
|
||||||
// already set, it is set to "FRX" for a good default experience.
|
// If LESS is not already set, it is set to "FRX" for a good default experience.
|
||||||
func (s *IOStreams) StartPager() error {
|
func (s *IOStreams) StartPager() error {
|
||||||
if !s.isStdoutTTY {
|
if !s.isStdoutTTY {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
pagerCmd := os.Getenv("FGJ_PAGER")
|
pagerCmd := os.Getenv("FJ_PAGER")
|
||||||
|
if pagerCmd == "" {
|
||||||
|
pagerCmd = os.Getenv("FGJ_PAGER")
|
||||||
|
}
|
||||||
if pagerCmd == "" {
|
if pagerCmd == "" {
|
||||||
pagerCmd = os.Getenv("PAGER")
|
pagerCmd = os.Getenv("PAGER")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
2
main.go
2
main.go
|
|
@ -4,7 +4,7 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
"forgejo.zerova.net/public/fgj-sid/cmd"
|
"forgejo.zerova.net/public/fj/cmd"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
|
|
||||||
|
|
@ -228,15 +228,18 @@ func (env *TestEnv) CleanupRepo(owner, repoName string) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetBinaryPath returns the path to the built fgj binary
|
// GetBinaryPath returns the path to the built fj binary
|
||||||
func (env *TestEnv) GetBinaryPath() string {
|
func (env *TestEnv) GetBinaryPath() string {
|
||||||
binaryPath := os.Getenv("FGJ_BINARY_PATH")
|
binaryPath := os.Getenv("FJ_BINARY_PATH")
|
||||||
|
if binaryPath == "" {
|
||||||
|
binaryPath = os.Getenv("FGJ_BINARY_PATH")
|
||||||
|
}
|
||||||
if binaryPath == "" {
|
if binaryPath == "" {
|
||||||
// Look for the binary in common locations
|
// Look for the binary in common locations
|
||||||
candidates := []string{
|
candidates := []string{
|
||||||
"./bin/fgj",
|
"./bin/fj",
|
||||||
"bin/fgj",
|
"bin/fj",
|
||||||
"/home/romain/work/fgj/bin/fgj",
|
"/home/romain/work/fj/bin/fj",
|
||||||
}
|
}
|
||||||
for _, candidate := range candidates {
|
for _, candidate := range candidates {
|
||||||
if _, err := os.Stat(candidate); err == nil {
|
if _, err := os.Stat(candidate); err == nil {
|
||||||
|
|
@ -248,7 +251,7 @@ func (env *TestEnv) GetBinaryPath() string {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// If no binary found, return default (will error when executed)
|
// If no binary found, return default (will error when executed)
|
||||||
binaryPath = "./bin/fgj"
|
binaryPath = "./bin/fj"
|
||||||
}
|
}
|
||||||
return binaryPath
|
return binaryPath
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -86,7 +86,7 @@ func TestCLIIssueList(t *testing.T) {
|
||||||
env := NewTestEnv(t)
|
env := NewTestEnv(t)
|
||||||
|
|
||||||
// Create a test issue so the list is not empty
|
// Create a test issue so the list is not empty
|
||||||
issueNum := env.CreateTestIssue("[FGJ E2E Test] Issue List", "For issue list test")
|
issueNum := env.CreateTestIssue("[FJ E2E Test] Issue List", "For issue list test")
|
||||||
defer env.CleanupIssue(issueNum)
|
defer env.CleanupIssue(issueNum)
|
||||||
|
|
||||||
result := env.RunCLI(
|
result := env.RunCLI(
|
||||||
|
|
@ -109,7 +109,7 @@ func TestCLIIssueList(t *testing.T) {
|
||||||
func TestCLIIssueListJSON(t *testing.T) {
|
func TestCLIIssueListJSON(t *testing.T) {
|
||||||
env := NewTestEnv(t)
|
env := NewTestEnv(t)
|
||||||
|
|
||||||
issueNum := env.CreateTestIssue("[FGJ E2E Test] JSON List", "For JSON output test")
|
issueNum := env.CreateTestIssue("[FJ E2E Test] JSON List", "For JSON output test")
|
||||||
defer env.CleanupIssue(issueNum)
|
defer env.CleanupIssue(issueNum)
|
||||||
|
|
||||||
result := env.RunCLI(
|
result := env.RunCLI(
|
||||||
|
|
@ -137,7 +137,7 @@ func TestCLIIssueListJSON(t *testing.T) {
|
||||||
func TestCLIIssueView(t *testing.T) {
|
func TestCLIIssueView(t *testing.T) {
|
||||||
env := NewTestEnv(t)
|
env := NewTestEnv(t)
|
||||||
|
|
||||||
issueNum := env.CreateTestIssue("[FGJ E2E Test] View Test", "Testing issue view")
|
issueNum := env.CreateTestIssue("[FJ E2E Test] View Test", "Testing issue view")
|
||||||
defer env.CleanupIssue(issueNum)
|
defer env.CleanupIssue(issueNum)
|
||||||
|
|
||||||
result := env.RunCLI(
|
result := env.RunCLI(
|
||||||
|
|
@ -160,7 +160,7 @@ func TestCLIIssueView(t *testing.T) {
|
||||||
func TestCLIIssueViewJSON(t *testing.T) {
|
func TestCLIIssueViewJSON(t *testing.T) {
|
||||||
env := NewTestEnv(t)
|
env := NewTestEnv(t)
|
||||||
|
|
||||||
issueNum := env.CreateTestIssue("[FGJ E2E Test] JSON View", "Testing JSON view")
|
issueNum := env.CreateTestIssue("[FJ E2E Test] JSON View", "Testing JSON view")
|
||||||
defer env.CleanupIssue(issueNum)
|
defer env.CleanupIssue(issueNum)
|
||||||
|
|
||||||
result := env.RunCLI(
|
result := env.RunCLI(
|
||||||
|
|
@ -198,8 +198,8 @@ func TestCLIIssueCreate(t *testing.T) {
|
||||||
"--hostname", env.Hostname,
|
"--hostname", env.Hostname,
|
||||||
"-R", fmt.Sprintf("%s/%s", env.Owner, env.RepoName),
|
"-R", fmt.Sprintf("%s/%s", env.Owner, env.RepoName),
|
||||||
"issue", "create",
|
"issue", "create",
|
||||||
"-t", "[FGJ E2E Test] CLI Created Issue",
|
"-t", "[FJ E2E Test] CLI Created Issue",
|
||||||
"-b", "Created directly via fgj CLI",
|
"-b", "Created directly via fj CLI",
|
||||||
)
|
)
|
||||||
|
|
||||||
if result.ExitCode != 0 {
|
if result.ExitCode != 0 {
|
||||||
|
|
@ -229,7 +229,7 @@ func TestCLIIssueCreateWithLabels(t *testing.T) {
|
||||||
"--hostname", env.Hostname,
|
"--hostname", env.Hostname,
|
||||||
"-R", fmt.Sprintf("%s/%s", env.Owner, env.RepoName),
|
"-R", fmt.Sprintf("%s/%s", env.Owner, env.RepoName),
|
||||||
"issue", "create",
|
"issue", "create",
|
||||||
"-t", "[FGJ E2E Test] Issue with Labels",
|
"-t", "[FJ E2E Test] Issue with Labels",
|
||||||
"-b", "This issue was created with labels",
|
"-b", "This issue was created with labels",
|
||||||
"-l", "bug",
|
"-l", "bug",
|
||||||
"-l", "enhancement",
|
"-l", "enhancement",
|
||||||
|
|
@ -275,7 +275,7 @@ func TestCLIIssueCreateWithLabels(t *testing.T) {
|
||||||
func TestCLIIssueComment(t *testing.T) {
|
func TestCLIIssueComment(t *testing.T) {
|
||||||
env := NewTestEnv(t)
|
env := NewTestEnv(t)
|
||||||
|
|
||||||
issueNum := env.CreateTestIssue("[FGJ E2E Test] Comment Test", "Testing comment via CLI")
|
issueNum := env.CreateTestIssue("[FJ E2E Test] Comment Test", "Testing comment via CLI")
|
||||||
defer env.CleanupIssue(issueNum)
|
defer env.CleanupIssue(issueNum)
|
||||||
|
|
||||||
result := env.RunCLI(
|
result := env.RunCLI(
|
||||||
|
|
@ -313,7 +313,7 @@ func TestCLIIssueComment(t *testing.T) {
|
||||||
func TestCLIIssueClose(t *testing.T) {
|
func TestCLIIssueClose(t *testing.T) {
|
||||||
env := NewTestEnv(t)
|
env := NewTestEnv(t)
|
||||||
|
|
||||||
issueNum := env.CreateTestIssue("[FGJ E2E Test] Close Test", "Will be closed via CLI")
|
issueNum := env.CreateTestIssue("[FJ E2E Test] Close Test", "Will be closed via CLI")
|
||||||
|
|
||||||
result := env.RunCLI(
|
result := env.RunCLI(
|
||||||
"--hostname", env.Hostname,
|
"--hostname", env.Hostname,
|
||||||
|
|
@ -341,7 +341,7 @@ func TestCLIIssueClose(t *testing.T) {
|
||||||
func TestCLIIssueCloseWithComment(t *testing.T) {
|
func TestCLIIssueCloseWithComment(t *testing.T) {
|
||||||
env := NewTestEnv(t)
|
env := NewTestEnv(t)
|
||||||
|
|
||||||
issueNum := env.CreateTestIssue("[FGJ E2E Test] Close with comment", "Will be closed with a comment")
|
issueNum := env.CreateTestIssue("[FJ E2E Test] Close with comment", "Will be closed with a comment")
|
||||||
|
|
||||||
commentText := "Fixed in v2.0 - closing via functional test"
|
commentText := "Fixed in v2.0 - closing via functional test"
|
||||||
|
|
||||||
|
|
@ -389,7 +389,7 @@ func TestCLIIssueCloseWithComment(t *testing.T) {
|
||||||
func TestCLIIssueEditTitle(t *testing.T) {
|
func TestCLIIssueEditTitle(t *testing.T) {
|
||||||
env := NewTestEnv(t)
|
env := NewTestEnv(t)
|
||||||
|
|
||||||
issueNum := env.CreateTestIssue("[FGJ E2E Test] Original Title", "Will be edited")
|
issueNum := env.CreateTestIssue("[FJ E2E Test] Original Title", "Will be edited")
|
||||||
defer env.CleanupIssue(issueNum)
|
defer env.CleanupIssue(issueNum)
|
||||||
|
|
||||||
result := env.RunCLI(
|
result := env.RunCLI(
|
||||||
|
|
@ -397,7 +397,7 @@ func TestCLIIssueEditTitle(t *testing.T) {
|
||||||
"-R", fmt.Sprintf("%s/%s", env.Owner, env.RepoName),
|
"-R", fmt.Sprintf("%s/%s", env.Owner, env.RepoName),
|
||||||
"issue", "edit",
|
"issue", "edit",
|
||||||
fmt.Sprintf("%d", issueNum),
|
fmt.Sprintf("%d", issueNum),
|
||||||
"-t", "[FGJ E2E Test] Updated Title",
|
"-t", "[FJ E2E Test] Updated Title",
|
||||||
)
|
)
|
||||||
|
|
||||||
if result.ExitCode != 0 {
|
if result.ExitCode != 0 {
|
||||||
|
|
@ -409,7 +409,7 @@ func TestCLIIssueEditTitle(t *testing.T) {
|
||||||
t.Fatalf("failed to get issue: %v", err)
|
t.Fatalf("failed to get issue: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if issue.Title != "[FGJ E2E Test] Updated Title" {
|
if issue.Title != "[FJ E2E Test] Updated Title" {
|
||||||
t.Fatalf("expected updated title, got '%s'", issue.Title)
|
t.Fatalf("expected updated title, got '%s'", issue.Title)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -421,7 +421,7 @@ func TestCLIIssueEditAddLabels(t *testing.T) {
|
||||||
|
|
||||||
env.EnsureTestLabels()
|
env.EnsureTestLabels()
|
||||||
|
|
||||||
issueNum := env.CreateTestIssue("[FGJ E2E Test] Add Labels", "Will have labels added")
|
issueNum := env.CreateTestIssue("[FJ E2E Test] Add Labels", "Will have labels added")
|
||||||
defer env.CleanupIssue(issueNum)
|
defer env.CleanupIssue(issueNum)
|
||||||
|
|
||||||
result := env.RunCLI(
|
result := env.RunCLI(
|
||||||
|
|
@ -464,7 +464,7 @@ func TestCLIIssueEditRemoveLabels(t *testing.T) {
|
||||||
env.EnsureTestLabels()
|
env.EnsureTestLabels()
|
||||||
|
|
||||||
issue, _, err := env.Client.CreateIssue(env.Owner, env.RepoName, gitea.CreateIssueOption{
|
issue, _, err := env.Client.CreateIssue(env.Owner, env.RepoName, gitea.CreateIssueOption{
|
||||||
Title: "[FGJ E2E Test] Remove Labels",
|
Title: "[FJ E2E Test] Remove Labels",
|
||||||
Body: "Will have labels removed",
|
Body: "Will have labels removed",
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -616,7 +616,7 @@ func TestCLIPRComment(t *testing.T) {
|
||||||
env := NewTestEnv(t)
|
env := NewTestEnv(t)
|
||||||
|
|
||||||
// PRs share the comment API with issues
|
// PRs share the comment API with issues
|
||||||
issueNum := env.CreateTestIssue("[FGJ E2E Test] PR Comment Test", "Testing pr comment command")
|
issueNum := env.CreateTestIssue("[FJ E2E Test] PR Comment Test", "Testing pr comment command")
|
||||||
defer env.CleanupIssue(issueNum)
|
defer env.CleanupIssue(issueNum)
|
||||||
|
|
||||||
result := env.RunCLI(
|
result := env.RunCLI(
|
||||||
|
|
@ -624,14 +624,14 @@ func TestCLIPRComment(t *testing.T) {
|
||||||
"-R", fmt.Sprintf("%s/%s", env.Owner, env.RepoName),
|
"-R", fmt.Sprintf("%s/%s", env.Owner, env.RepoName),
|
||||||
"pr", "comment",
|
"pr", "comment",
|
||||||
fmt.Sprintf("%d", issueNum),
|
fmt.Sprintf("%d", issueNum),
|
||||||
"-b", "Automated test comment via fgj pr comment",
|
"-b", "Automated test comment via fj pr comment",
|
||||||
)
|
)
|
||||||
|
|
||||||
if result.ExitCode != 0 {
|
if result.ExitCode != 0 {
|
||||||
t.Fatalf("pr comment failed with exit code %d: %s", result.ExitCode, result.Stderr)
|
t.Fatalf("pr comment failed with exit code %d: %s", result.ExitCode, result.Stderr)
|
||||||
}
|
}
|
||||||
|
|
||||||
t.Logf("Successfully commented on issue #%d via fgj pr comment", issueNum)
|
t.Logf("Successfully commented on issue #%d via fj pr comment", issueNum)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== CLI Repo Commands =====
|
// ===== CLI Repo Commands =====
|
||||||
|
|
@ -678,14 +678,14 @@ func TestCLIRepoList(t *testing.T) {
|
||||||
func TestCLIRepoCreate(t *testing.T) {
|
func TestCLIRepoCreate(t *testing.T) {
|
||||||
env := NewTestEnv(t)
|
env := NewTestEnv(t)
|
||||||
|
|
||||||
repoName := fmt.Sprintf("fgj-test-create-%d", time.Now().UnixNano())
|
repoName := fmt.Sprintf("fj-test-create-%d", time.Now().UnixNano())
|
||||||
defer env.CleanupRepo(env.Owner, repoName)
|
defer env.CleanupRepo(env.Owner, repoName)
|
||||||
|
|
||||||
result := env.RunCLI(
|
result := env.RunCLI(
|
||||||
"--hostname", env.Hostname,
|
"--hostname", env.Hostname,
|
||||||
"repo", "create", repoName,
|
"repo", "create", repoName,
|
||||||
"--public",
|
"--public",
|
||||||
"-d", "Created by fgj functional test",
|
"-d", "Created by fj functional test",
|
||||||
)
|
)
|
||||||
|
|
||||||
if result.ExitCode != 0 {
|
if result.ExitCode != 0 {
|
||||||
|
|
@ -703,8 +703,8 @@ func TestCLIRepoCreate(t *testing.T) {
|
||||||
if repo.Private {
|
if repo.Private {
|
||||||
t.Fatalf("expected public repo, got private")
|
t.Fatalf("expected public repo, got private")
|
||||||
}
|
}
|
||||||
if repo.Description != "Created by fgj functional test" {
|
if repo.Description != "Created by fj functional test" {
|
||||||
t.Fatalf("expected description %q, got %q", "Created by fgj functional test", repo.Description)
|
t.Fatalf("expected description %q, got %q", "Created by fj functional test", repo.Description)
|
||||||
}
|
}
|
||||||
|
|
||||||
t.Logf("Successfully created repository %s via CLI", repo.FullName)
|
t.Logf("Successfully created repository %s via CLI", repo.FullName)
|
||||||
|
|
@ -756,7 +756,7 @@ func TestCLIRepoClone(t *testing.T) {
|
||||||
env := NewTestEnv(t)
|
env := NewTestEnv(t)
|
||||||
|
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
clonePath := fmt.Sprintf("%s/fgj-clone", tmpDir)
|
clonePath := fmt.Sprintf("%s/fj-clone", tmpDir)
|
||||||
|
|
||||||
result := env.RunCLI(
|
result := env.RunCLI(
|
||||||
"--hostname", env.Hostname,
|
"--hostname", env.Hostname,
|
||||||
|
|
@ -799,13 +799,13 @@ func TestCLIReleaseList(t *testing.T) {
|
||||||
func TestCLIReleaseCreateUploadDelete(t *testing.T) {
|
func TestCLIReleaseCreateUploadDelete(t *testing.T) {
|
||||||
env := NewTestEnv(t)
|
env := NewTestEnv(t)
|
||||||
|
|
||||||
tag := fmt.Sprintf("fgj-test-%d", time.Now().UnixNano())
|
tag := fmt.Sprintf("fj-test-%d", time.Now().UnixNano())
|
||||||
title := "FGJ CLI Release Test"
|
title := "FJ CLI Release Test"
|
||||||
notes := "Release created by functional tests"
|
notes := "Release created by functional tests"
|
||||||
|
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
assetPath := fmt.Sprintf("%s/asset.txt", tmpDir)
|
assetPath := fmt.Sprintf("%s/asset.txt", tmpDir)
|
||||||
if err := os.WriteFile(assetPath, []byte("fgj release asset"), 0600); err != nil {
|
if err := os.WriteFile(assetPath, []byte("fj release asset"), 0600); err != nil {
|
||||||
t.Fatalf("failed to create asset file: %v", err)
|
t.Fatalf("failed to create asset file: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -863,7 +863,7 @@ func TestCLIReleaseView(t *testing.T) {
|
||||||
env := NewTestEnv(t)
|
env := NewTestEnv(t)
|
||||||
|
|
||||||
// Create a release to view
|
// Create a release to view
|
||||||
tag := fmt.Sprintf("fgj-view-test-%d", time.Now().UnixNano())
|
tag := fmt.Sprintf("fj-view-test-%d", time.Now().UnixNano())
|
||||||
|
|
||||||
createResult := env.RunCLI(
|
createResult := env.RunCLI(
|
||||||
"--hostname", env.Hostname,
|
"--hostname", env.Hostname,
|
||||||
|
|
@ -1149,7 +1149,7 @@ func TestCLIAPIGet(t *testing.T) {
|
||||||
t.Fatalf("expected repo name %q in JSON output, got %v", env.RepoName, data["name"])
|
t.Fatalf("expected repo name %q in JSON output, got %v", env.RepoName, data["name"])
|
||||||
}
|
}
|
||||||
|
|
||||||
t.Logf("Successfully retrieved repo info via fgj api GET")
|
t.Logf("Successfully retrieved repo info via fj api GET")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCLIAPIPostAndDelete(t *testing.T) {
|
func TestCLIAPIPostAndDelete(t *testing.T) {
|
||||||
|
|
@ -1161,8 +1161,8 @@ func TestCLIAPIPostAndDelete(t *testing.T) {
|
||||||
"--hostname", env.Hostname,
|
"--hostname", env.Hostname,
|
||||||
"api", endpoint,
|
"api", endpoint,
|
||||||
"-X", "POST",
|
"-X", "POST",
|
||||||
"-f", "title=[FGJ E2E Test] API Post Test",
|
"-f", "title=[FJ E2E Test] API Post Test",
|
||||||
"-f", "body=Created via fgj api command",
|
"-f", "body=Created via fj api command",
|
||||||
)
|
)
|
||||||
|
|
||||||
if result.ExitCode != 0 {
|
if result.ExitCode != 0 {
|
||||||
|
|
@ -1182,7 +1182,7 @@ func TestCLIAPIPostAndDelete(t *testing.T) {
|
||||||
issueNum := int64(issueNumber)
|
issueNum := int64(issueNumber)
|
||||||
defer env.CleanupIssue(issueNum)
|
defer env.CleanupIssue(issueNum)
|
||||||
|
|
||||||
t.Logf("Successfully created issue #%d via fgj api POST", issueNum)
|
t.Logf("Successfully created issue #%d via fj api POST", issueNum)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== Structured Error Output =====
|
// ===== Structured Error Output =====
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue