Compare commits

..

8 commits

Author SHA1 Message Date
sid
e3d7904929 feat: repo archive/unarchive + completion install
- fgj repo {archive,unarchive}: toggle a repo's archived state via
  EditRepo with *bool Archived. Archive prompts for confirmation
  (requires --yes in non-TTY envs); unarchive is reversible, no prompt.
  Accepts positional owner/name or -R flag; -R wins when both given.

- fgj completion install [shell]: idempotent install of completion
  scripts to shell-standard locations. Auto-detects shell from $SHELL
  if omitted. Paths: bash → XDG (or brew prefix on macOS), zsh →
  ~/.zsh/completions/_fgj, fish → ~/.config/fish/completions/fgj.fish.
  --system (bash only) prints the sudo command for /etc paths without
  writing. --dry-run prints the target path without writing.
  Compares existing-file contents before overwrite to stay idempotent.

Both files built by sub-agents in parallel. Build + vet + test clean.
2026-04-19 23:12:12 -06:00
sid
2d69873f3e feat: logins list/default, actions run delete, date filters, label update alias
- fgj logins {list,default}: complementary UI to 'fgj auth'. 'list'
  shows all configured hosts (hostname, user, protocol, default flag,
  match_dirs) with --json. 'default [hostname]' gets or sets which
  host wins in resolution when no other signal is present.
  Adds 'Default bool' field to HostConfig; GetHost consults it between
  match_dirs and the codeberg.org fallback. Multiple defaults tolerated
  with a stderr warning; alphabetical-first wins.

- fgj actions run delete: delete a completed workflow run via raw
  DELETE (SDK v0.23.2 has no DeleteRepoActionRun). Fetches run first
  and refuses to delete non-terminal states unless --force; suggests
  'actions run cancel' for those. Confirmation prompt unless --yes.

- pr list / issue list gain --since and --before date filter flags.
  Accepts YYYY-MM-DD, RFC 3339, YYYY-MM-DD HH:MM:SS, and relative
  deltas (7d, 24h, 2w, 1m — months=30 days). Issues use server-side
  filter via ListIssueOption.Since/Before; PRs fall back to client-side
  (SDK lacks Since/Before on ListPullRequestsOptions).

- fgj label update added as alias for 'fgj label edit' (tea-compat).

All changes:
  cmd/logins.go (new, 140 LOC)
  cmd/actions_run_delete.go (new, ~85 LOC)
  cmd/pr.go, cmd/issue.go (+parseDateArg helper, filter wiring)
  cmd/label.go (1-line alias)
  internal/config/config.go (Default field + DefaultHost method)
  CHANGELOG.md
Built in parallel by three sub-agents; plus the label alias done
serially. go build / go vet / go test -race all clean.
2026-04-19 23:04:33 -06:00
sid
d15deaf064 feat: times, branch protection, release assets, milestone issues, notification states
Five parallel tea-parity additions (~1100 LOC):

- fgj time {list,add,delete,reset} (aliases: times, t)
  Tracked time entries. 'list' with no arg uses ListMyTrackedTimes
  across all your repos; with an issue number uses ListIssueTrackedTimes.
  'add' accepts Go duration strings (30m, 1h30m). 'delete' removes a
  single entry by id; 'reset' clears all times on an issue. Confirmation
  on delete/reset unless --yes or no TTY.

- fgj branch {protect,unprotect}
  Branch protection rules. 'protect' idempotently creates-or-edits via
  Get/Create/EditBranchProtection with --require-approvals,
  --require-signed-commits, --dismiss-stale-approvals,
  --block-on-rejected-reviews, --block-on-outdated-branch,
  --push-whitelist, --merge-whitelist, --require-status-checks.
  Empty whitelist flags leave existing rule fields untouched.
  'unprotect' deletes; 404 is a friendly no-op.

- fgj release asset {list,create,delete} (alias: assets)
  Granular attachment management. Resolves the release by tag or
  "latest" using the existing helpers in cmd/release.go. 'create'
  validates all paths up front then uploads each. 'delete' accepts
  numeric ids OR filenames (cross-references the attachment list).
  Per-asset confirmation unless --yes.

- fgj milestone issues {add,remove} (alias: i)
  Associate/disassociate issues with a milestone. Milestone accepted
  as title or numeric id (reuses resolveMilestone from milestone.go).
  'remove' passes EditIssueOption{Milestone: &zero} — the Gitea/Forgejo
  convention for clearing the association. Continues on per-issue
  failure and exits 1 if any failed.

- fgj notification {unread,pin,unpin}
  Complement the existing list/read. Factory pattern over
  ReadNotification(id, NotifyStatus) with three distinct constants.

All five files are self-contained: each has its own init() attaching
to the existing parent cobra.Command (branchCmd, milestoneCmd,
notificationCmd, releaseCmd) without modifying any other file. Built
by parallel sub-agents; all compile, vet, and test clean.
2026-04-19 22:24:53 -06:00
sid
4eeef2ceca feat: pr approve/reject, repo migrate/template, secret stdin fix, docs
- fgj pr approve / pr reject: thin shortcuts over 'pr review --approve'
  and '--request-changes'. Reject requires a body.
- fgj repo migrate: wrap SDK MigrateRepo. Supports git, github, gitlab,
  gitea, gogs services; mirror mode with --mirror-interval; selective
  import (wiki/labels/milestones/issues/PRs/releases/LFS); auth via
  --auth-token or --auth-username/--auth-password. Defaults owner to
  the authenticated user.
- fgj repo create-from-template: wrap SDK CreateRepoFromTemplate with
  fine-grained --with-{content,topics,labels,webhooks,git-hooks,avatar}
  flags. Template is owner/name; new repo defaults to the current user.
- Rework 'fgj actions secret create' input. New cmd/secret_input.go
  resolves values from --body, --body-file (path or '-'), hidden TTY
  prompt via term.ReadPassword, or piped stdin. Trims trailing
  whitespace, rejects empty values. Replaces fmt.Scanln which broke on
  spaces/newlines and echoed input.
- CHANGELOG: v0.4.0 Unreleased section documenting all additions,
  changes, and development items.
- README: updated feature list with new commands.
2026-04-19 22:14:43 -06:00
sid
424fb63a8b chore: bump Gitea SDK v0.22.1 → v0.23.2
v0.24.0 and v0.24.1 require Go 1.26 (via module go directive), which
forces a toolchain download on any builder running older Go. v0.23.2
is the most recent release that works with our Go 1.24 baseline.

Worth it: v0.23.x brings significant Issue/PR/Action method coverage
beyond v0.22.1. Tests + vet still clean, live smoke against
forgejo.zerova.net still passes (whoami, repo list).

ResolvePullReviewComment / UnresolvePullReviewComment are still not
in the SDK at v0.23.2 — they landed in v0.24 — so pr_review_comments.go
continues to call the raw REST endpoints. Swap to native methods once
we move to Go 1.26.
2026-04-19 22:04:32 -06:00
sid
adccd6f6f7 feat: add webhook, repo delete/search, admin, pr clean/resolve/review-comments
Second pass of tea-parity work:

- fgj webhook {list,create,update,delete}: full CRUD over repo webhooks.
  Create supports all standard hook types (gitea, slack, discord, etc.),
  event selection, content type, secret, branch filter, auth header.
  Update is partial — flags you omit leave existing config unchanged.
- fgj repo delete: type-to-confirm deletion; --yes skips for scripts;
  refuses without a TTY unless --yes is passed.
- fgj repo search: SDK SearchRepos with query, topic/description,
  private/archived, --type (source/fork/mirror), owner, sort/order.
- fgj admin user list: admin-gated user enumeration.
- fgj pr clean: delete the local branch from 'pr checkout'. Refuses
  if the PR is still open (use --force) or if the branch is currently
  checked out.
- fgj pr review-comments: list inline review comments across every
  review on a PR (ListPullReviews + ListPullReviewComments per review).
- fgj pr resolve / unresolve: mark review comments as (un)resolved.
  Uses raw POST since SDK v0.22.1 predates these endpoints; requires
  Forgejo 8.x+ / Gitea 1.22+ server-side.

All share the standard parseRepo + config.Load + NewClientFromConfig
pattern; list commands support --json / --jq.
2026-04-19 22:01:29 -06:00
sid
17ca49d0c5 feat: add branch, notification, org, open, whoami commands
Ports five commands from tea that fgj-sid was missing:

- fgj branch {list,rename,delete} — list branches with protection
  status, rename, and delete with confirmation.
- fgj notification {list,read} — list user notifications (unread by
  default, --all for everything), mark individual threads read.
- fgj org {list,create,delete} — manage organizations on the host.
  Create accepts --description/--full-name/--website/--location and
  --visibility (public/limited/private).
- fgj open [number] — open the repo, issue, or PR in a browser.
  Auto-detects issue-vs-PR via GetIssue. Falls back to printing the
  URL when stdout is not a TTY or --url is passed.
- fgj whoami — display authenticated user + host.

All commands follow the established pattern (parseRepo + config.Load +
api.NewClientFromConfig + ios), support --json where list semantics
apply, and share a new loadClient helper for host-scoped (non-repo)
commands. Tested live against forgejo.zerova.net.

Refs audit recommendation.md §'v0.5.0 — Missing resources'.
2026-04-19 21:27:55 -06:00
sid
d4b5b79541 feat(release): v0.4.0 foundations — ldflags version + goreleaser + CI Go 1.24
- Move version out of cmd/root.go hardcode into an ldflags-injected
  var. Makefile derives from 'git describe --tags --always --dirty';
  plain 'go build' / 'go run' get 'dev'. Release builds will get the
  tag via goreleaser.
- Add .goreleaser.yaml: multi-platform (linux/darwin/windows/freebsd ×
  amd64/arm64/arm) builds with SHA256 checksums, tar.gz/zip archives,
  forgejo release publishing. No GPG/S3 yet — deferred until a key is
  provisioned.
- Add .gitea/workflows/release.yml to run goreleaser on tag push.
  Uses built-in GITEA_TOKEN with override via RELEASE_TOKEN secret.
- Align CI Go version with go.mod (1.24). Previously CI ran 1.21,
  which would have silently missed any 1.22+ feature use.
- Move itchyny/gojq from indirect to direct (it's used in api.go).
  Drop stale x/sys v0.33.0 entry from go.sum.
- Ignore dist/ and bin/ in .gitignore.
- CHANGELOG: document v0.3.1 fix and Unreleased development changes.
2026-04-19 21:04:57 -06:00
65 changed files with 5106 additions and 1444 deletions

View file

@ -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.21' go-version: '1.24'
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.21' go-version: '1.24'
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.21' go-version: '1.24'
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.21' go-version: '1.24'
cache: true cache: true
- name: Build production binary - name: Build production binary
run: | run: |
make build make build
echo "Binary built at: $(pwd)/bin/fj" echo "Binary built at: $(pwd)/bin/fgj"
- name: Run functional tests - name: Run functional tests
run: go test -v -race -tags=functional ./tests/functional/... run: go test -v -race -tags=functional ./tests/functional/...

View file

@ -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.21' go-version: '1.24'
cache: true cache: true
- name: Build production binary - name: Build production binary
run: | run: |
make build make build
echo "Binary built at: $(pwd)/bin/fj" echo "Binary built at: $(pwd)/bin/fgj"
- name: Run functional tests - name: Run functional tests
run: go test -v -race -tags=functional ./tests/functional/... run: go test -v -race -tags=functional ./tests/functional/...

View file

@ -0,0 +1,36 @@
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
View file

@ -1,12 +1,15 @@
# Binaries # Binaries
fj fgj
bin/
*.exe *.exe
*.exe~ *.exe~
*.dll *.dll
*.so *.so
*.dylib *.dylib
# Goreleaser output
dist/
bin/
# Test binary # Test binary
*.test *.test

91
.goreleaser.yaml Normal file
View file

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

View file

@ -5,151 +5,180 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.4.0] - 2026-05-02 ## [Unreleased] — 0.4.0
Audit-driven hardening pass. Three reviewers (Codex + two Claude agents ### Added — Repository Management
with non-overlapping focuses) found 13 issues across cmd/ and internal/;
this release ships fixes for all 13.
### BREAKING - `fgj branch {list,rename,delete}` — list branches with protection
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).
- `--json=fields` syntax removed. The flag was a string with ### Added — Pull Requests
`NoOptDefVal=" "` sentinel — `--json` alone meant "everything",
`--json=fields` projected. That produced `--json string[=" "]` in
`--help` and required a literal `=` because `--json fields` was parsed
as the bare flag plus a positional. **Migration**: `--json=fields`
`--json-fields fields`. Bare `--json` still means "all fields as JSON".
`--json` and `--json-fields` are mutually exclusive; `--jq` composes
with either.
### Added - `fgj pr clean <n>` — delete the local branch created by
`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.
- `fj api --json` / `--json-fields` / `--jq` — projection and jq filtering ### Added — Notifications & Organizations
for raw API responses. Routes through the same `addJSONFlags` helpers
as the other list commands. Closes the inconsistency where `fj api` - `fgj notification list [--all]` / `fgj notification read <id>`
was the only command returning raw JSON without these knobs. list unread (default) or all notifications; mark individual
- `fj api --paginate` — follows RFC 5988 `Link: rel="next"` headers and threads read.
concatenates JSON array pages, gh-compatible. Validates same-origin - `fgj notification {unread,pin,unpin}` — flip thread state
before forwarding the bearer token to the next URL. (complements `read`). Uses the Gitea `NotifyStatus` enum.
- `cmd/paginate.go` — generic `paginateGitea[T any]` helper. Applied to - `fgj org {list,create,delete}` — list your orgs, create with
`repo list`, `pr list`, `issue list`. Previously only `release list` visibility/description, delete with confirmation.
walked pages; the others passed `PageSize: limit` directly to the - `fgj webhook {list,create,update,delete}` — full CRUD on repo
gitea SDK, which silently caps PageSize at 50, so `--limit > 50` was webhooks: gitea/slack/discord/etc. hook types, event selection,
truncated without warning. content type, secret, branch filter, auth header.
- `CLAUDE.md` — guide for Claude Code sessions: layout, codex review
pattern, release process, homebrew tap update steps. ### Added — Releases, Actions, Milestones, Time
- `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
- `--json` flag rebuilt as a plain `Bool`. `--json-fields` keeps - `fgj actions secret create` stdin handling reworked. Adds `--body`
comma-separated projection. Both registered via `addJSONFlags` and (inline) and `--body-file` (path or `-` for stdin) flags; interactive
marked `MutuallyExclusive`. prompts now use hidden input via `term.ReadPassword`; piped stdin is
- `cmd/actions.go``run` and `workflow` subtrees converted from read whole (prior `fmt.Scanln` broke on spaces/newlines and echoed
package-level `var`s to factory functions (`newRunCmd`, the typed value). Empty values are rejected.
`newWorkflowCmd`, ...). `cmd/aliases.go` shrank from 142 → 17 lines - `HostConfig` gains an optional `default: true` field. When no other
and now calls those same factories with a `parentLabel` parameter that signal selects a host (flag, `FGJ_HOST`, git remote, `match_dirs`),
disambiguates the alias variant. Result: `diff` of `fj run list the host marked default wins before the `codeberg.org` fallback.
--help` flags vs `fj actions run list --help` flags is now empty. Multiple `default: true` entries are tolerated with a stderr
Drift between the two paths is structurally impossible. warning; alphabetical-first wins.
- `fj api` now uses `internal/api.SharedHTTPClient` (30s timeout, pooled - Gitea SDK bumped `v0.22.1``v0.23.2` (last release compatible with
connections) instead of a zero-value `&http.Client{}` with no timeout. Go 1.24; `v0.24+` requires Go 1.26).
A hung Forgejo no longer pins the CLI indefinitely.
- `fj api` response body bounded by `io.LimitReader` at 64 MB to prevent ### Development
OOM-on-self.
- `cmd/auth.go` removed redundant local `--hostname` declarations on - Switched to standard semver tags (`v0.3.1`, `v0.4.0`, …); retired
three subcommands. The persistent flag on rootCmd is now the only letter-suffix scheme (`v0.3.0a``v0.3.0f`) which Go's module resolver
declaration; previously local declarations shadowed it, so ignored, leaving `go install @latest` pointing at the pre-migration
`fj --hostname=X auth login` and `fj auth login --hostname=X` went `v0.3.0` tag.
through different code paths. - Version string is now injected at build time via `-ldflags`; the
- `--token` on `auth login` emits a stderr warning when used (visible hardcoded constant in `cmd/root.go` has been replaced with a
in `ps auxe` and shell history). Flag not removed; just discoverable. `var version = "dev"` fallback. `make build` derives the version from
- Error handling: `Hint` is now a structured field on `CLIError`. `git describe --tags --always --dirty`.
JSON-error consumers get clean structure; the human renderer still - Added `.goreleaser.yaml` for multi-platform release builds
appends `\nHint: ...`. Dropped substring matching of `"401"`/`"403"` (linux/darwin/windows/freebsd × amd64/arm64/arm) with SHA256
against rendered error strings (would match issue #403); now relies checksums and auto-generated release notes.
exclusively on typed `*api.APIError`. - Added `.gitea/workflows/release.yml` that publishes release artifacts
- Network errors (`no such host`, `connection refused`, `i/o timeout`) to the Forgejo release page on tag push.
return a structured `CLIError` with code `ErrNetworkError` and a hint. - Aligned CI Go version (`1.24`) with `go.mod`; previously CI ran on
- Config dir created with mode 0700 instead of 0755. `1.21` while `go.mod` required `1.24`.
## [0.3.1] - 2026-04-19
### Fixed ### Fixed
- `--config <path>` now actually honored. Previously fed only into - `go install forgejo.zerova.net/public/fgj-sid@latest` now resolves
Viper; every command that touched config went through correctly. Previous releases used letter-suffix tags (`v0.3.0a``f`)
`internal/config.Load()` / `Save()` which always read the default which are not valid Go module versions and were ignored by the
path. So `fj --config other.yaml auth login` writes to other.yaml now. module resolver, leaving `@latest` pinned to `v0.3.0` — a commit
- `fj run list --json`, `fj workflow list --json`, `fj wiki view --json` that predates the module-path migration from `codeberg.org/romaintb/fgj`.
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
- `fj label list` - List repository labels - `fgj label list` - List repository labels
- `fj label create` - Create a label with color and description - `fgj label create` - Create a label with color and description
- `fj label edit` - Edit label name, color, or description - `fgj label edit` - Edit label name, color, or description
- `fj label delete` - Delete a label - `fgj label delete` - Delete a label
#### Milestone Management #### Milestone Management
- `fj milestone list` - List milestones with state filtering - `fgj milestone list` - List milestones with state filtering
- `fj milestone view` - View milestone details - `fgj milestone view` - View milestone details
- `fj milestone create` - Create a milestone with description and due date - `fgj milestone create` - Create a milestone with description and due date
- `fj milestone edit` - Edit milestone title, description, due date, or state - `fgj milestone edit` - Edit milestone title, description, due date, or state
- `fj milestone delete` - Delete a milestone - `fgj milestone delete` - Delete a milestone
#### Wiki Management #### Wiki Management
- `fj wiki list` - List wiki pages - `fgj wiki list` - List wiki pages
- `fj wiki view` - View wiki page content - `fgj wiki view` - View wiki page content
- `fj wiki create` - Create a wiki page from flag or file - `fgj wiki create` - Create a wiki page from flag or file
- `fj wiki edit` - Edit a wiki page - `fgj wiki edit` - Edit a wiki page
- `fj wiki delete` - Delete a wiki page - `fgj wiki delete` - Delete a wiki page
#### Issue Dependencies #### Issue Dependencies
- `fj issue edit --add-dependency <number>` - Add issue dependency - `fgj issue edit --add-dependency <number>` - Add issue dependency
- `fj issue edit --remove-dependency <number>` - Remove issue dependency - `fgj issue edit --remove-dependency <number>` - Remove issue dependency
## [0.3.0b] - 2026-03-21 ## [0.3.0b] - 2026-03-21
### Added ### Added
#### Repository Management #### Repository Management
- `fj repo edit` - Edit repository settings (visibility, description, homepage, default branch) - `fgj repo edit` - Edit repository settings (visibility, description, homepage, default branch)
### Fixed ### Fixed
- `fj repo create --public` flag was defined but never read; now properly wired up - `fgj repo create --public` flag was defined but never read; now properly wired up
## [0.3.0a] - 2026-03-21 ## [0.3.0a] - 2026-03-21
### Added ### Added
#### Raw API Access #### Raw API Access
- `fj api <endpoint>` - Make authenticated REST API requests to any Forgejo/Gitea endpoint - `fgj api <endpoint>` - Make authenticated REST API requests to any Forgejo/Gitea endpoint
- HTTP method selection (`--method`/`-X`), auto-switches to POST when fields are provided - HTTP method selection (`--method`/`-X`), auto-switches to POST when fields are provided
- JSON field assembly (`--field`/`-f`) with type inference (bool, int, float, null, string) - JSON field assembly (`--field`/`-f`) with type inference (bool, int, float, null, string)
- Raw string fields (`--raw-field`/`-F`) - Raw string fields (`--raw-field`/`-F`)
@ -159,14 +188,14 @@ this release ships fixes for all 13.
- Response header display (`--include`/`-i`) - Response header display (`--include`/`-i`)
#### Pull Request Management #### Pull Request Management
- `fj pr diff <number>` - View the diff for a pull request - `fgj pr diff <number>` - View the diff for a pull request
- Colorized output (`--color auto/always/never`) - Colorized output (`--color auto/always/never`)
- Changed file names only (`--name-only`) - Changed file names only (`--name-only`)
- Diffstat summary (`--stat`) - Diffstat summary (`--stat`)
- `fj pr comment <number>` - Add a comment to a pull request - `fgj pr comment <number>` - Add a comment to a pull request
- Body from flag (`--body`/`-b`) or file (`--body-file`, `-` for stdin) - Body from flag (`--body`/`-b`) or file (`--body-file`, `-` for stdin)
- JSON output (`--json`) - JSON output (`--json`)
- `fj pr review <number>` - Submit a review on a pull request - `fgj pr review <number>` - Submit a review on a pull request
- Approve (`--approve`/`-a`), request changes (`--request-changes`/`-r`), or comment (`--comment`/`-c`) - Approve (`--approve`/`-a`), request changes (`--request-changes`/`-r`), or comment (`--comment`/`-c`)
- Body from flag or file - Body from flag or file
- JSON output (`--json`) - JSON output (`--json`)
@ -182,30 +211,30 @@ this release ships fixes for all 13.
### Added ### Added
#### Forgejo Actions #### Forgejo Actions
- `fj actions run watch <run-id>` - Poll a run until completion - `fgj actions run watch <run-id>` - Poll a run until completion
- `fj actions run rerun <run-id>` - Trigger a rerun of a workflow run - `fgj actions run rerun <run-id>` - Trigger a rerun of a workflow run
- `fj actions run cancel <run-id>` - Cancel an in-progress workflow run - `fgj actions run cancel <run-id>` - Cancel an in-progress workflow run
- `fj actions workflow enable <workflow>` - Enable a workflow - `fgj actions workflow enable <workflow>` - Enable a workflow
- `fj actions workflow disable <workflow>` - Disable a workflow - `fgj actions workflow disable <workflow>` - Disable a workflow
#### Repository Management #### Repository Management
- `fj repo create <name>` - Create a new repository with full option set: `--private`/`--public`, `--description`, `--add-readme`, `--gitignore`, `--license`, `--homepage`, `--clone`, `--team` - `fgj repo create <name>` - Create a new repository with full option set: `--private`/`--public`, `--description`, `--add-readme`, `--gitignore`, `--license`, `--homepage`, `--clone`, `--team`
#### Issue Management #### Issue Management
- `fj issue create -l <label>` - Assign labels when creating an issue - `fgj issue create -l <label>` - Assign labels when creating an issue
- `fj issue edit --add-label` / `--remove-label` - Add or remove labels on existing issues - `fgj issue edit --add-label` / `--remove-label` - Add or remove labels on existing issues
- `fj issue close -c <comment>` - Close an issue with an optional comment - `fgj issue close -c <comment>` - Close an issue with an optional comment
#### Workflow Management #### Workflow Management
- `fj actions workflow list/view/run` - List, view, and trigger workflows - `fgj actions workflow list/view/run` - List, view, and trigger workflows
#### Auth Helpers #### Auth Helpers
- `fj auth token` - Print the stored token for the current host - `fgj auth token` - Print the stored token for the current host
- `fj auth logout` - Remove authentication for a host - `fgj auth logout` - Remove authentication for a host
#### Shell Completions and Man Pages #### Shell Completions and Man Pages
- `fj completion [bash|zsh|fish|powershell]` - Generate shell completion scripts - `fgj completion [bash|zsh|fish|powershell]` - Generate shell completion scripts
- `fj manpages --dir <path>` - Generate man pages for all commands - `fgj manpages --dir <path>` - Generate man pages for all commands
#### JSON Output #### JSON Output
- `--json` flag for all list and view commands: PRs, issues, releases, workflow runs, workflows - `--json` flag for all list and view commands: PRs, issues, releases, workflow runs, workflows
@ -218,17 +247,17 @@ this release ships fixes for all 13.
### Added ### Added
#### Release Management #### Release Management
- `fj release list` - List releases for a repository - `fgj release list` - List releases for a repository
- `fj release view` - View details of a specific release (supports "latest" keyword) - `fgj release view` - View details of a specific release (supports "latest" keyword)
- `fj release create` - Create new releases with optional asset uploads - `fgj release create` - Create new releases with optional asset uploads
- `fj release upload` - Upload assets to existing releases with optional clobber support - `fgj release upload` - Upload assets to existing releases with optional clobber support
- `fj release delete` - Delete releases (preserves Git tags) - `fgj release delete` - Delete releases (preserves Git tags)
#### Issue Management #### Issue Management
- `fj issue edit` - Edit existing issues with support for updating title, body, and labels - `fgj issue edit` - Edit existing issues with support for updating title, body, and labels
#### Pull Request Management #### Pull Request Management
- `fj pr create --assignee` - Assign users when creating pull requests - `fgj pr create --assignee` - Assign users when creating pull requests
#### Repository Detection #### Repository Detection
- Automatic hostname detection from git remote URLs - Automatic hostname detection from git remote URLs
@ -249,48 +278,48 @@ this release ships fixes for all 13.
### Added ### Added
#### Core Features #### Core Features
- Initial release of fj - Forgejo CLI tool - Initial release of fgj - Forgejo CLI tool
- Multi-instance support for any Forgejo/Gitea instance - Multi-instance support for any Forgejo/Gitea instance
- Automatic repository detection from git context (optional `-R` flag) - Automatic repository detection from git context (optional `-R` flag)
- Secure authentication with personal access tokens - Secure authentication with personal access tokens
- Configuration management via `~/.config/fj/config.yaml` - Configuration management via `~/.config/fgj/config.yaml`
#### Pull Request Management #### Pull Request Management
- `fj pr list` - List pull requests with filtering by state - `fgj pr list` - List pull requests with filtering by state
- `fj pr view` - View detailed pull request information - `fgj pr view` - View detailed pull request information
- `fj pr create` - Create new pull requests - `fgj pr create` - Create new pull requests
- `fj pr merge` - Merge pull requests with configurable merge methods - `fgj pr merge` - Merge pull requests with configurable merge methods
#### Issue Management #### Issue Management
- `fj issue list` - List issues with state filtering - `fgj issue list` - List issues with state filtering
- `fj issue view` - View detailed issue information - `fgj issue view` - View detailed issue information
- `fj issue create` - Create new issues - `fgj issue create` - Create new issues
- `fj issue comment` - Add comments to issues - `fgj issue comment` - Add comments to issues
- `fj issue close` - Close issues - `fgj issue close` - Close issues
#### Repository Operations #### Repository Operations
- `fj repo view` - View repository details - `fgj repo view` - View repository details
- `fj repo list` - List user repositories - `fgj repo list` - List user repositories
- `fj repo clone` - Clone repositories with protocol selection (HTTPS/SSH) - `fgj repo clone` - Clone repositories with protocol selection (HTTPS/SSH)
- `fj repo fork` - Fork repositories - `fgj repo fork` - Fork repositories
#### Forgejo Actions Support #### Forgejo Actions Support
- `fj actions run list` - List workflow runs with status and metadata - `fgj actions run list` - List workflow runs with status and metadata
- `fj actions run view` - View detailed run information, jobs, and logs - `fgj actions run view` - View detailed run information, jobs, and logs
- Support for `--verbose`, `--log`, `--log-failed`, and `--job` flags - Support for `--verbose`, `--log`, `--log-failed`, and `--job` flags
- `fj actions secret list` - List repository secrets - `fgj actions secret list` - List repository secrets
- `fj actions secret create` - Create repository secrets - `fgj actions secret create` - Create repository secrets
- `fj actions secret delete` - Delete repository secrets - `fgj actions secret delete` - Delete repository secrets
- `fj actions variable list` - List repository variables - `fgj actions variable list` - List repository variables
- `fj actions variable get` - Get variable values - `fgj actions variable get` - Get variable values
- `fj actions variable create` - Create repository variables - `fgj actions variable create` - Create repository variables
- `fj actions variable update` - Update repository variables - `fgj actions variable update` - Update repository variables
- `fj actions variable delete` - Delete repository variables - `fgj actions variable delete` - Delete repository variables
#### Authentication #### Authentication
- `fj auth login` - Interactive authentication with Forgejo instances - `fgj auth login` - Interactive authentication with Forgejo instances
- `fj auth status` - Check authentication status - `fgj auth status` - Check authentication status
- Environment variable support (`FJ_HOST`, `FJ_TOKEN`) - Environment variable support (`FGJ_HOST`, `FGJ_TOKEN`)
#### Development #### Development
- Comprehensive unit test suite - Comprehensive unit test suite
@ -304,9 +333,9 @@ this release ships fixes for all 13.
- Cobra framework for CLI structure - Cobra framework for CLI structure
- Viper for configuration management - Viper for configuration management
[0.3.0c]: https://forgejo.zerova.net/public/fj/releases/tag/v0.3.0c [0.3.0c]: https://forgejo.zerova.net/public/fgj-sid/releases/tag/v0.3.0c
[0.3.0b]: https://forgejo.zerova.net/public/fj/releases/tag/v0.3.0b [0.3.0b]: https://forgejo.zerova.net/public/fgj-sid/releases/tag/v0.3.0b
[0.3.0a]: https://forgejo.zerova.net/public/fj/releases/tag/v0.3.0a [0.3.0a]: https://forgejo.zerova.net/public/fgj-sid/releases/tag/v0.3.0a
[0.3.0]: https://codeberg.org/romaintb/fj/releases/tag/v0.3.0 [0.3.0]: https://codeberg.org/romaintb/fgj/releases/tag/v0.3.0
[0.2.0]: https://codeberg.org/romaintb/fj/releases/tag/v0.2.0 [0.2.0]: https://codeberg.org/romaintb/fgj/releases/tag/v0.2.0
[0.1.0]: https://codeberg.org/romaintb/fj/releases/tag/v0.1.0 [0.1.0]: https://codeberg.org/romaintb/fgj/releases/tag/v0.1.0

166
CLAUDE.md
View file

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

View file

@ -1,20 +1,26 @@
.PHONY: help build run test clean lint lint-fix install .PHONY: help build run test clean lint lint-fix install release-snapshot release-check
# 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" @echo " make build - Build the application (version: $(VERSION))"
@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 -o bin/fj . go build -ldflags "$(LDFLAGS)" -o bin/fgj .
install: build install: build
install -Dm755 bin/fj /usr/bin/fj install -Dm755 bin/fgj /usr/bin/fgj
run: run:
go run . go run .
@ -29,5 +35,11 @@ lint-fix:
golangci-lint run --fix ./... golangci-lint run --fix ./...
clean: clean:
rm -rf bin/ rm -rf bin/ dist/
go clean go clean
release-snapshot:
goreleaser release --snapshot --clean --skip=publish
release-check:
goreleaser check

317
README.md
View file

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

View file

@ -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/fj/internal/api" "forgejo.zerova.net/public/fgj-sid/internal/api"
"forgejo.zerova.net/public/fj/internal/config" "forgejo.zerova.net/public/fgj-sid/internal/config"
) )
// ActionRun represents a workflow run // ActionRun represents a workflow run
@ -87,224 +87,146 @@ 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 and Workflow command trees are built via factory functions // Run commands (compatible with gh run)
// (newRunCmd / newWorkflowCmd) so cmd/aliases.go can build an identical var runCmd = &cobra.Command{
// top-level tree under rootCmd without duplicating Use/Short/Long/Example/ Use: "run",
// flag declarations. Single source of truth — drift impossible. Short: "View and manage workflow runs",
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
} }
func newRunListCmd() *cobra.Command { var runListCmd = &cobra.Command{
c := &cobra.Command{ Use: "list",
Use: "list", Short: "List recent workflow runs",
Short: "List recent workflow runs", Long: "List recent workflow runs for a repository.",
Long: "List recent workflow runs for a repository.", Example: ` # List recent workflow runs
Example: ` # List recent workflow runs fgj actions run list
fj actions run list
# List runs with a custom limit # List runs with a custom limit
fj actions run list -L 50 fgj actions run list -L 50
# Output as JSON # Output as JSON
fj actions run list --json`, fgj 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
} }
func newRunViewCmd() *cobra.Command { var runViewCmd = &cobra.Command{
c := &cobra.Command{ Use: "view <run-id>",
Use: "view <run-id>", Short: "View a workflow run",
Short: "View a workflow run", Long: "View details about a specific workflow run.",
Long: "View details about a specific workflow run.", Example: ` # View a workflow run
Example: ` # View a workflow run fgj actions run view 123
fj actions run view 123
# View with job details # View with job details
fj actions run view 123 -v fgj actions run view 123 -v
# View logs for a specific job # View logs for a specific job
fj actions run view 123 --job 456 --log fgj actions run view 123 --job 456 --log
# View only failed logs # View only failed logs
fj actions run view 123 --log-failed`, fgj 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
} }
func newRunWatchCmd() *cobra.Command { var runWatchCmd = &cobra.Command{
c := &cobra.Command{ Use: "watch <run-id>",
Use: "watch <run-id>", Short: "Watch a workflow run",
Short: "Watch a workflow run", Long: "Poll a workflow run until it completes.",
Long: "Poll a workflow run until it completes.", Example: ` # Watch a run until it completes
Example: ` # Watch a run until it completes fgj actions run watch 123
fj actions run watch 123
# Watch with a custom polling interval # Watch with a custom polling interval
fj actions run watch 123 -i 10s`, fgj 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
} }
func newRunRerunCmd() *cobra.Command { var runRerunCmd = &cobra.Command{
c := &cobra.Command{ Use: "rerun <run-id>",
Use: "rerun <run-id>", Short: "Rerun a workflow run",
Short: "Rerun a workflow run", Long: "Trigger a rerun for a specific workflow run.",
Long: "Trigger a rerun for a specific workflow run.", Example: ` # Rerun a failed workflow run
Example: ` # Rerun a failed workflow run fgj actions run rerun 123`,
fj actions run rerun 123`, Args: cobra.ExactArgs(1),
Args: cobra.ExactArgs(1), RunE: runRunRerun,
RunE: runRunRerun,
}
addRepoFlags(c)
return c
} }
func newRunCancelCmd() *cobra.Command { var runCancelCmd = &cobra.Command{
c := &cobra.Command{ Use: "cancel <run-id>",
Use: "cancel <run-id>", Short: "Cancel a workflow run",
Short: "Cancel a workflow run", Long: "Cancel a running workflow run.",
Long: "Cancel a running workflow run.", Example: ` # Cancel a running workflow
Example: ` # Cancel a running workflow fgj actions run cancel 123`,
fj actions run cancel 123`, Args: cobra.ExactArgs(1),
Args: cobra.ExactArgs(1), RunE: runRunCancel,
RunE: runRunCancel,
}
addRepoFlags(c)
return c
} }
// newWorkflowCmd builds the `workflow` subtree. parentLabel is interpolated // Workflow commands
// the same way as newRunCmd's, so the alias variant can self-identify. var workflowCmd = &cobra.Command{
func newWorkflowCmd(parentLabel string) *cobra.Command { Use: "workflow",
cmd := &cobra.Command{ Short: "Manage workflows",
Use: "workflow", Long: "List, view, and run workflows.",
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
} }
func newWorkflowListCmd() *cobra.Command { var workflowListCmd = &cobra.Command{
c := &cobra.Command{ Use: "list",
Use: "list", Short: "List workflows",
Short: "List workflows", Long: "List all workflows in a repository.",
Long: "List all workflows in a repository.", Example: ` # List all workflows
Example: ` # List all workflows fgj actions workflow list
fj actions workflow list
# List workflows as JSON # List workflows as JSON
fj actions workflow list --json fgj actions workflow list --json
# List workflows for a specific repo # List workflows for a specific repo
fj actions workflow list -R owner/repo`, fgj 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
} }
func newWorkflowViewCmd() *cobra.Command { var workflowViewCmd = &cobra.Command{
c := &cobra.Command{ Use: "view <workflow>",
Use: "view <workflow>", Short: "View a workflow",
Short: "View a workflow", Long: "View details about a specific workflow. You can specify the workflow by name or filename.",
Long: "View details about a specific workflow. You can specify the workflow by name or filename.", Example: ` # View a workflow by filename
Example: ` # View a workflow by filename fgj actions workflow view ci.yml
fj actions workflow view ci.yml
# View as JSON # View as JSON
fj actions workflow view ci.yml --json`, fgj 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
} }
func newWorkflowRunCmd() *cobra.Command { var workflowRunCmd = &cobra.Command{
c := &cobra.Command{ Use: "run <workflow>",
Use: "run <workflow>", Short: "Run a workflow",
Short: "Run a workflow", Long: "Trigger a workflow_dispatch event for a workflow. The workflow must support the workflow_dispatch trigger.",
Long: "Trigger a workflow_dispatch event for a workflow. The workflow must support the workflow_dispatch trigger.", Example: ` # Trigger a workflow on the default branch
Example: ` # Trigger a workflow on the default branch fgj actions workflow run deploy.yml
fj actions workflow run deploy.yml
# Trigger on a specific branch with input parameters # Trigger on a specific branch with input parameters
fj actions workflow run deploy.yml -r staging -f environment=staging -f version=1.2.3`, fgj 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
} }
func newWorkflowEnableCmd() *cobra.Command { var workflowEnableCmd = &cobra.Command{
c := &cobra.Command{ Use: "enable <workflow>",
Use: "enable <workflow>", Short: "Enable a 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.",
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.", Example: ` # Enable a workflow
Example: ` # Enable a workflow fgj actions workflow enable ci.yml`,
fj actions workflow enable ci.yml`, Args: cobra.ExactArgs(1),
Args: cobra.ExactArgs(1), RunE: runWorkflowEnable,
RunE: runWorkflowEnable,
}
addRepoFlags(c)
return c
} }
func newWorkflowDisableCmd() *cobra.Command { var workflowDisableCmd = &cobra.Command{
c := &cobra.Command{ Use: "disable <workflow>",
Use: "disable <workflow>", Short: "Disable a 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.",
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.", Example: ` # Disable a workflow
Example: ` # Disable a workflow fgj actions workflow disable ci.yml`,
fj actions workflow disable ci.yml`, Args: cobra.ExactArgs(1),
Args: cobra.ExactArgs(1), RunE: runWorkflowDisable,
RunE: runWorkflowDisable,
}
addRepoFlags(c)
return c
} }
// Secret commands // Secret commands
@ -319,22 +241,36 @@ 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
fj actions secret list fgj actions secret list
# List secrets for a specific repo # List secrets for a specific repo
fj actions secret list -R owner/repo`, fgj 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. The secret value will be read from stdin.", Long: `Create or update a secret for Forgejo Actions.
Example: ` # Create a secret (will prompt for value)
fj actions secret create DEPLOY_TOKEN
# Create a secret for a specific repo The secret value is read from the first available source:
fj actions secret create API_KEY -R owner/repo`, 1. --body <value>
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,
} }
@ -344,7 +280,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
fj actions secret delete DEPLOY_TOKEN`, fgj actions secret delete DEPLOY_TOKEN`,
Args: cobra.ExactArgs(1), Args: cobra.ExactArgs(1),
RunE: runActionsSecretDelete, RunE: runActionsSecretDelete,
} }
@ -361,10 +297,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
fj actions variable list fgj actions variable list
# List variables for a specific repo # List variables for a specific repo
fj actions variable list -R owner/repo`, fgj actions variable list -R owner/repo`,
RunE: runActionsVariableList, RunE: runActionsVariableList,
} }
@ -373,7 +309,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
fj actions variable get ENVIRONMENT`, fgj actions variable get ENVIRONMENT`,
Args: cobra.ExactArgs(1), Args: cobra.ExactArgs(1),
RunE: runActionsVariableGet, RunE: runActionsVariableGet,
} }
@ -383,10 +319,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
fj actions variable create ENVIRONMENT production fgj actions variable create ENVIRONMENT production
# Create a variable for a specific repo # Create a variable for a specific repo
fj actions variable create NODE_VERSION 20 -R owner/repo`, fgj actions variable create NODE_VERSION 20 -R owner/repo`,
Args: cobra.ExactArgs(2), Args: cobra.ExactArgs(2),
RunE: runActionsVariableCreate, RunE: runActionsVariableCreate,
} }
@ -396,7 +332,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
fj actions variable update ENVIRONMENT staging`, fgj actions variable update ENVIRONMENT staging`,
Args: cobra.ExactArgs(2), Args: cobra.ExactArgs(2),
RunE: runActionsVariableUpdate, RunE: runActionsVariableUpdate,
} }
@ -406,7 +342,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
fj actions variable delete ENVIRONMENT`, fgj actions variable delete ENVIRONMENT`,
Args: cobra.ExactArgs(1), Args: cobra.ExactArgs(1),
RunE: runActionsVariableDelete, RunE: runActionsVariableDelete,
} }
@ -414,10 +350,21 @@ var actionsVariableDeleteCmd = &cobra.Command{
func init() { func init() {
rootCmd.AddCommand(actionsCmd) rootCmd.AddCommand(actionsCmd)
// Run and Workflow trees come from the factory functions defined above // Add run commands (gh run compatible)
// so cmd/aliases.go can build identical top-level trees under rootCmd. actionsCmd.AddCommand(runCmd)
actionsCmd.AddCommand(newRunCmd("")) runCmd.AddCommand(runListCmd)
actionsCmd.AddCommand(newWorkflowCmd("")) runCmd.AddCommand(runViewCmd)
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)
@ -433,9 +380,39 @@ 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
@ -1293,12 +1270,9 @@ func runActionsSecretCreate(cmd *cobra.Command, args []string) error {
secretName := args[0] secretName := args[0]
// Read secret value from stdin secretValue, err := readSecretValue(cmd, secretName)
fmt.Fprint(ios.ErrOut, "Enter secret value: ")
var secretValue string
_, err = fmt.Scanln(&secretValue)
if err != nil { if err != nil {
return fmt.Errorf("failed to read secret value: %w", err) return err
} }
opt := gitea.CreateSecretOption{ opt := gitea.CreateSecretOption{
@ -1306,12 +1280,12 @@ func runActionsSecretCreate(cmd *cobra.Command, args []string) error {
Data: secretValue, Data: secretValue,
} }
_, err = client.CreateRepoActionSecret(owner, name, opt) if _, err := client.CreateRepoActionSecret(owner, name, opt); err != nil {
if err != nil {
return fmt.Errorf("failed to create secret: %w", err) return fmt.Errorf("failed to create secret: %w", err)
} }
fmt.Fprintf(ios.Out, "Secret '%s' created successfully\n", secretName) cs := ios.ColorScheme()
fmt.Fprintf(ios.Out, "%s Secret %q created\n", cs.SuccessIcon(), secretName)
return nil return nil
} }

99
cmd/actions_run_delete.go Normal file
View file

@ -0,0 +1,99 @@
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 Normal file
View file

@ -0,0 +1,85 @@
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()
}

View file

@ -1,16 +1,142 @@
package cmd package cmd
// Top-level aliases for "actions run" and "actions workflow" — matches gh import (
// CLI's ergonomics so users can type `fj run list` and `fj workflow list` "time"
// 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
// help text are guaranteed identical between the two paths. Previously // Top-level aliases for "actions run" and "actions workflow" commands,
// this file rebuilt parallel trees by hand and silently drifted (the // matching gh CLI's command structure (e.g., "fgj run list" instead of "fgj actions run list").
// `--json` Bool/string mismatch was the symptom that surfaced).
func init() { func init() {
rootCmd.AddCommand(newRunCmd(" (alias for 'actions run')")) // --- run alias ---
rootCmd.AddCommand(newWorkflowCmd(" (alias for 'actions workflow')")) runAliasCmd := &cobra.Command{
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)
} }

View file

@ -6,23 +6,15 @@ import (
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"net/url"
"os" "os"
"strconv" "strconv"
"strings" "strings"
"forgejo.zerova.net/public/fj/internal/api" "forgejo.zerova.net/public/fgj-sid/internal/config"
"forgejo.zerova.net/public/fj/internal/config" "forgejo.zerova.net/public/fgj-sid/internal/git"
"forgejo.zerova.net/public/fj/internal/git"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
// maxAPIResponseBytes caps response bodies for `fj api`. Forgejo responses
// are normally <1 MB; 64 MB is enough for any sane payload while preventing
// a runaway body from OOMing the CLI when combined with the 30 s client
// timeout.
const maxAPIResponseBytes = 64 << 20
var apiCmd = &cobra.Command{ var apiCmd = &cobra.Command{
Use: "api <endpoint> [flags]", Use: "api <endpoint> [flags]",
Short: "Make an authenticated API request", Short: "Make an authenticated API request",
@ -34,22 +26,16 @@ detected from the current git repository.
If --field is used and no --method is specified, the method defaults to POST.`, If --field is used and no --method is specified, the method defaults to POST.`,
Example: ` # List pull requests for the current repository Example: ` # List pull requests for the current repository
fj api /repos/{owner}/{repo}/pulls fgj api /repos/{owner}/{repo}/pulls
# Create an issue # Create an issue
fj api /repos/{owner}/{repo}/issues --method POST --field title=Bug --field body="It broke" fgj api /repos/{owner}/{repo}/issues --method POST --field title=Bug --field body="It broke"
# Get a specific user # Get a specific user
fj api /users/johndoe fgj api /users/johndoe
# Use raw body from stdin # Use raw body from stdin
echo '{"title":"test"}' | fj api /repos/{owner}/{repo}/issues --input - echo '{"title":"test"}' | fgj api /repos/{owner}/{repo}/issues --input -`,
# Filter the response with a jq expression
fj api /repos/{owner}/{repo}/issues --jq '.[].title'
# Project the response down to specific fields
fj api /repos/{owner}/{repo} --json-fields full_name,description,private`,
Args: cobra.ExactArgs(1), Args: cobra.ExactArgs(1),
RunE: runAPI, RunE: runAPI,
} }
@ -64,40 +50,6 @@ func init() {
apiCmd.Flags().StringArrayP("header", "H", nil, "Add an HTTP request header (key:value)") apiCmd.Flags().StringArrayP("header", "H", nil, "Add an HTTP request header (key:value)")
apiCmd.Flags().Bool("silent", false, "Do not print the response body") apiCmd.Flags().Bool("silent", false, "Do not print the response body")
apiCmd.Flags().BoolP("include", "i", false, "Include HTTP response headers in the output") apiCmd.Flags().BoolP("include", "i", false, "Include HTTP response headers in the output")
apiCmd.Flags().Bool("paginate", false, "Follow rel=\"next\" Link headers and concatenate JSON array pages (gh-compatible)")
addJSONFlags(apiCmd, "Output the response as JSON")
}
// parseLinkHeaderNext extracts the URL with rel="next" from an RFC 5988
// Link header. Returns "" if not present.
func parseLinkHeaderNext(link string) string {
for _, segment := range strings.Split(link, ",") {
segment = strings.TrimSpace(segment)
if !strings.Contains(segment, `rel="next"`) {
continue
}
start := strings.Index(segment, "<")
end := strings.Index(segment, ">")
if start >= 0 && end > start {
return segment[start+1 : end]
}
}
return ""
}
// concatPaginatedJSON parses each body as a JSON array and merges them.
// Errors if any body isn't an array (e.g. an object response means the
// endpoint isn't paginated and --paginate doesn't apply).
func concatPaginatedJSON(bodies [][]byte) ([]byte, error) {
merged := make([]json.RawMessage, 0)
for i, b := range bodies {
var page []json.RawMessage
if err := json.Unmarshal(b, &page); err != nil {
return nil, fmt.Errorf("--paginate requires JSON array responses; page %d wasn't an array: %w", i+1, err)
}
merged = append(merged, page...)
}
return json.Marshal(merged)
} }
func runAPI(cmd *cobra.Command, args []string) error { func runAPI(cmd *cobra.Command, args []string) error {
@ -187,28 +139,15 @@ func runAPI(cmd *cobra.Command, args []string) error {
body = bytes.NewReader(bodyBytes) body = bytes.NewReader(bodyBytes)
} }
// Build the request URL safely. Naive concatenation lets endpoints like // Build URL
// "/../admin/users" escape the /api/v1 base via Go's URL normalization baseURL := "https://" + host.Hostname + "/api/v1"
// of `..` segments — silently sending authenticated traffic to non-API if !strings.HasPrefix(endpoint, "/") {
// paths. Parse the endpoint, reject `..`, then JoinPath onto the base. endpoint = "/" + endpoint
endpointURL, err := url.Parse(endpoint)
if err != nil {
return fmt.Errorf("invalid endpoint %q: %w", endpoint, err)
} }
if endpointURL.Scheme != "" || endpointURL.Host != "" { url := baseURL + endpoint
return fmt.Errorf("endpoint must be a path, not a full URL: %s", endpoint)
}
for _, seg := range strings.Split(strings.Trim(endpointURL.Path, "/"), "/") {
if seg == ".." {
return fmt.Errorf("endpoint contains forbidden '..' segment: %s", endpoint)
}
}
base := &url.URL{Scheme: "https", Host: host.Hostname, Path: "/api/v1"}
final := base.JoinPath(endpointURL.Path)
final.RawQuery = endpointURL.RawQuery
// Create HTTP request // Create HTTP request
req, err := http.NewRequest(method, final.String(), body) req, err := http.NewRequest(method, url, body)
if err != nil { if err != nil {
return fmt.Errorf("failed to create request: %w", err) return fmt.Errorf("failed to create request: %w", err)
} }
@ -231,42 +170,20 @@ func runAPI(cmd *cobra.Command, args []string) error {
req.Header.Set(strings.TrimSpace(key), strings.TrimSpace(value)) req.Header.Set(strings.TrimSpace(key), strings.TrimSpace(value))
} }
paginate, _ := cmd.Flags().GetBool("paginate") // Execute request
if paginate && method != http.MethodGet { ios.StartSpinner("Requesting...")
return fmt.Errorf("--paginate only supports GET requests") httpClient := &http.Client{}
} resp, err := httpClient.Do(req)
ios.StopSpinner()
// doOnce executes a single request via the shared client (30 s timeout,
// pooled connections), reads the body bounded by maxAPIResponseBytes,
// and closes the body before returning. Previous zero-value http.Client{}
// had no timeout, pinning the CLI on a hung Forgejo indefinitely.
doOnce := func(r *http.Request) (body []byte, header http.Header, status int, proto string, statusText string, retErr error) {
ios.StartSpinner("Requesting...")
resp, err := api.SharedHTTPClient.Do(r)
ios.StopSpinner()
if err != nil {
return nil, nil, 0, "", "", fmt.Errorf("failed to perform request: %w", err)
}
defer func() { _ = resp.Body.Close() }()
body, err = io.ReadAll(io.LimitReader(resp.Body, maxAPIResponseBytes+1))
if err != nil {
return nil, nil, 0, "", "", fmt.Errorf("failed to read response body: %w", err)
}
if int64(len(body)) > maxAPIResponseBytes {
return nil, nil, 0, "", "", fmt.Errorf("response body exceeded %d bytes (use a different tool for bulk transfers)", maxAPIResponseBytes)
}
return body, resp.Header, resp.StatusCode, resp.Proto, resp.Status, nil
}
respBody, respHeader, statusCode, proto, status, err := doOnce(req)
if err != nil { if err != nil {
return err return fmt.Errorf("failed to perform request: %w", err)
} }
defer func() { _ = resp.Body.Close() }()
// Print response headers if requested
if include { if include {
fmt.Fprintf(ios.Out, "%s %s\n", proto, status) fmt.Fprintf(ios.Out, "%s %s\n", resp.Proto, resp.Status)
for key, values := range respHeader { for key, values := range resp.Header {
for _, v := range values { for _, v := range values {
fmt.Fprintf(ios.Out, "%s: %s\n", key, v) fmt.Fprintf(ios.Out, "%s: %s\n", key, v)
} }
@ -274,99 +191,39 @@ func runAPI(cmd *cobra.Command, args []string) error {
fmt.Fprintln(ios.Out) fmt.Fprintln(ios.Out)
} }
if statusCode < 200 || statusCode >= 300 { // Read response body
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("failed to read response body: %w", err)
}
// Handle non-2xx status codes
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
if !silent { if !silent {
fmt.Fprint(ios.ErrOut, string(respBody)) fmt.Fprint(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", statusCode) return fmt.Errorf("API request failed with status %d", resp.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
} }
contentType := respHeader.Get("Content-Type") // Pretty-print JSON, or output raw if not JSON
isJSON := strings.Contains(contentType, "json") || json.Valid(respBody) contentType := resp.Header.Get("Content-Type")
if strings.Contains(contentType, "json") || json.Valid(respBody) {
// If the user asked for JSON projection or jq filtering, route through
// the shared JSON output helpers so the API command is consistent with
// `fj repo list`, `fj pr list`, etc.
if wantJSON(cmd) {
if !isJSON {
return fmt.Errorf("--json/--json-fields/--jq requires a JSON response, but the server returned %s", contentType)
}
var parsed any
if err := json.Unmarshal(respBody, &parsed); err != nil {
return fmt.Errorf("response is not valid JSON: %w", err)
}
return outputJSON(cmd, parsed)
}
// Pretty-print JSON by default, otherwise emit raw bytes.
if isJSON {
var parsed any var parsed any
if err := json.Unmarshal(respBody, &parsed); err == nil { if err := json.Unmarshal(respBody, &parsed); err == nil {
return writeJSON(parsed) enc := json.NewEncoder(ios.Out)
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
} }

View file

@ -7,8 +7,8 @@ import (
"strings" "strings"
"syscall" "syscall"
"forgejo.zerova.net/public/fj/internal/api" "forgejo.zerova.net/public/fgj-sid/internal/api"
"forgejo.zerova.net/public/fj/internal/config" "forgejo.zerova.net/public/fgj-sid/internal/config"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/spf13/viper" "github.com/spf13/viper"
"golang.org/x/term" "golang.org/x/term"
@ -16,7 +16,7 @@ import (
var authCmd = &cobra.Command{ var authCmd = &cobra.Command{
Use: "auth", Use: "auth",
Short: "Authenticate fj with a Forgejo instance", Short: "Authenticate fgj with a Forgejo instance",
Long: "Manage authentication state for Forgejo instances.", Long: "Manage authentication state for Forgejo instances.",
} }
@ -55,25 +55,16 @@ func init() {
authCmd.AddCommand(authLogoutCmd) authCmd.AddCommand(authLogoutCmd)
authCmd.AddCommand(authTokenCmd) authCmd.AddCommand(authTokenCmd)
// --hostname is a persistent flag on rootCmd (cmd/root.go). Don't authLoginCmd.Flags().String("hostname", "", "Forgejo instance hostname (e.g., codeberg.org)")
// re-declare it on auth subcommands — local flags shadow the persistent authLoginCmd.Flags().StringP("token", "t", "", "Personal access token")
// one, so `fj --hostname=X auth login` and `fj auth login --hostname=X` authLogoutCmd.Flags().String("hostname", "", "Forgejo instance hostname (e.g., codeberg.org)")
// went through different code paths (viper vs. local). authTokenCmd.Flags().String("hostname", "", "Forgejo instance hostname (e.g., codeberg.org)")
authLoginCmd.Flags().StringP("token", "t", "", "Personal access token (DEPRECATED: visible in `ps auxe`; pipe via stdin instead)")
} }
func runAuthLogin(cmd *cobra.Command, args []string) error { func runAuthLogin(cmd *cobra.Command, args []string) error {
hostname, _ := cmd.Flags().GetString("hostname") hostname, _ := cmd.Flags().GetString("hostname")
token, _ := cmd.Flags().GetString("token") token, _ := cmd.Flags().GetString("token")
// Tokens passed via --token end up on the process command line and
// therefore in `ps auxe` and shell history. Warn loudly so users notice.
// (Don't refuse the flag — too disruptive for scripts that already use it.)
if cmd.Flags().Changed("token") {
fmt.Fprintln(ios.ErrOut, "warning: --token puts the token on the command line (visible in `ps auxe` and shell history)")
fmt.Fprintln(ios.ErrOut, " prefer omitting --token and pasting at the prompt, or piping via stdin.")
}
reader := bufio.NewReader(os.Stdin) reader := bufio.NewReader(os.Stdin)
if hostname == "" { if hostname == "" {
@ -141,7 +132,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 'fj auth login' to authenticate") fmt.Fprintln(ios.Out, "Run 'fgj auth login' to authenticate")
return nil return nil
} }
@ -197,7 +188,7 @@ func resolveAuthHostname(cfg *config.Config, hostname string) (string, error) {
hostname = viper.GetString("hostname") hostname = viper.GetString("hostname")
} }
if hostname == "" { if hostname == "" {
hostname = config.EnvWithFallback("FJ_HOST", "FGJ_HOST") hostname = os.Getenv("FGJ_HOST")
} }
if hostname == "" { if hostname == "" {
hostname = getDetectedHost() hostname = getDetectedHost()

185
cmd/branch.go Normal file
View file

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

204
cmd/branch_protect.go Normal file
View file

@ -0,0 +1,204 @@
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")
}

View file

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

188
cmd/completion_install.go Normal file
View file

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

View file

@ -3,9 +3,10 @@ package cmd
import ( import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt"
"strings" "strings"
"forgejo.zerova.net/public/fj/internal/api" "forgejo.zerova.net/public/fgj-sid/internal/api"
) )
// Error codes for structured error output. // Error codes for structured error output.
@ -24,15 +25,9 @@ type CLIError struct {
Message string `json:"message"` Message string `json:"message"`
Detail string `json:"detail,omitempty"` Detail string `json:"detail,omitempty"`
Status int `json:"status,omitempty"` Status int `json:"status,omitempty"`
// Hint is a separate field so JSON consumers get clean structure and
// the human renderer can append "Hint: ..." without polluting Message.
Hint string `json:"hint,omitempty"`
} }
func (e *CLIError) Error() string { func (e *CLIError) Error() string {
if e.Hint != "" {
return e.Message + "\nHint: " + e.Hint
}
return e.Message return e.Message
} }
@ -47,59 +42,46 @@ 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
} }
// If the error chain already holds a CLIError, leave it — it owns its msg := err.Error()
// Code/Hint already.
var cErr *CLIError // Check for API errors with status codes
if errors.As(err, &cErr) { var apiErr *api.APIError
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
} }
var apiErr *api.APIError // Check for network/connection errors
if errors.As(err, &apiErr) { switch {
c := &CLIError{ case strings.Contains(msg, "no such host"):
Code: ErrAPIError, return fmt.Errorf("%w\nHint: Check your internet connection and that the host is correct.", err)
Message: err.Error(), case strings.Contains(msg, "connection refused"):
Status: apiErr.StatusCode, return fmt.Errorf("%w\nHint: Check your internet connection and that the host is correct.", err)
Detail: apiErr.Body,
}
switch apiErr.StatusCode {
case 401, 403:
c.Code = ErrAuthRequired
c.Hint = "Try authenticating with: fj auth login"
case 404:
c.Code = ErrNotFound
c.Hint = "Resource not found. Check the repository and number are correct."
}
return c
} }
// Plain network errors come back as fmt.Errorf strings from net/http. // Check for string-based status code patterns (from wrapped errors)
msg := err.Error()
switch { switch {
case strings.Contains(msg, "no such host"), case strings.Contains(msg, "401") || strings.Contains(msg, "403"):
strings.Contains(msg, "connection refused"), if strings.Contains(msg, "authentication") || strings.Contains(msg, "unauthorized") || strings.Contains(msg, "forbidden") {
strings.Contains(msg, "i/o timeout"): return fmt.Errorf("%w\nHint: Try authenticating with: fgj auth login", err)
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) {
@ -108,9 +90,7 @@ func WriteJSONError(err error) {
Message: err.Error(), Message: err.Error(),
} }
// Try to extract structured info from the error chain. Prefer CLIError // Try to extract structured info from the error chain.
// (which carries Hint cleanly) over APIError so a wrapped CLIError
// keeps its structured fields.
var apiErr *api.APIError var apiErr *api.APIError
var cErr *CLIError var cErr *CLIError
@ -125,6 +105,8 @@ 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
} }
} }
@ -132,6 +114,3 @@ 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)

View file

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

View file

@ -4,11 +4,12 @@ import (
"fmt" "fmt"
"net/http" "net/http"
"strings" "strings"
"time"
"code.gitea.io/sdk/gitea" "code.gitea.io/sdk/gitea"
"forgejo.zerova.net/public/fj/internal/api" "forgejo.zerova.net/public/fgj-sid/internal/api"
"forgejo.zerova.net/public/fj/internal/config" "forgejo.zerova.net/public/fgj-sid/internal/config"
"forgejo.zerova.net/public/fj/internal/text" "forgejo.zerova.net/public/fgj-sid/internal/text"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@ -23,13 +24,19 @@ 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
fj issue list fgj issue list
# List closed issues for a specific repo # List closed issues for a specific repo
fj issue list -s closed -R owner/repo fgj issue list -s closed -R owner/repo
# Output as JSON # Output as JSON
fj issue list --json`, fgj 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,
} }
@ -38,16 +45,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
fj issue view 42 fgj issue view 42
# View using URL # View using URL
fj issue view https://codeberg.org/owner/repo/issues/42 fgj issue view https://codeberg.org/owner/repo/issues/42
# Open in browser # Open in browser
fj issue view 42 --web fgj issue view 42 --web
# View an issue from a specific repo as JSON # View an issue from a specific repo as JSON
fj issue view 42 -R owner/repo --json`, fgj issue view 42 -R owner/repo --json`,
Args: cobra.ExactArgs(1), Args: cobra.ExactArgs(1),
RunE: runIssueView, RunE: runIssueView,
} }
@ -57,10 +64,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
fj issue create -t "Fix login bug" fgj issue create -t "Fix login bug"
# Create an issue with title, body, and labels # Create an issue with title, body, and labels
fj issue create -t "Add dark mode" -b "We need a dark theme" -l feature -l ui`, fgj issue create -t "Add dark mode" -b "We need a dark theme" -l feature -l ui`,
RunE: runIssueCreate, RunE: runIssueCreate,
} }
@ -69,10 +76,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
fj issue comment 42 -b "This is fixed in the latest release" fgj 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
fj issue comment 10 -b "Confirmed on my end" -R owner/repo`, fgj issue comment 10 -b "Confirmed on my end" -R owner/repo`,
Args: cobra.ExactArgs(1), Args: cobra.ExactArgs(1),
RunE: runIssueComment, RunE: runIssueComment,
} }
@ -82,10 +89,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
fj issue close 42 fgj issue close 42
# Close with a comment # Close with a comment
fj issue close 42 -c "Fixed in commit abc1234"`, fgj issue close 42 -c "Fixed in commit abc1234"`,
Args: cobra.ExactArgs(1), Args: cobra.ExactArgs(1),
RunE: runIssueClose, RunE: runIssueClose,
} }
@ -95,7 +102,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
fj issue reopen 42`, fgj issue reopen 42`,
Args: cobra.ExactArgs(1), Args: cobra.ExactArgs(1),
RunE: runIssueReopen, RunE: runIssueReopen,
} }
@ -105,10 +112,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
fj issue delete 42 fgj issue delete 42
# Delete without confirmation # Delete without confirmation
fj issue delete 42 -y`, fgj issue delete 42 -y`,
Args: cobra.ExactArgs(1), Args: cobra.ExactArgs(1),
RunE: runIssueDelete, RunE: runIssueDelete,
} }
@ -118,16 +125,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
fj issue edit 42 -t "Updated title" fgj issue edit 42 -t "Updated title"
# Reopen a closed issue # Reopen a closed issue
fj issue edit 42 -s open fgj issue edit 42 -s open
# Add and remove labels # Add and remove labels
fj issue edit 42 --add-label bug --remove-label wontfix fgj issue edit 42 --add-label bug --remove-label wontfix
# Add a dependency # Add a dependency
fj issue edit 42 --add-dependency 10`, fgj issue edit 42 --add-dependency 10`,
Args: cobra.ExactArgs(1), Args: cobra.ExactArgs(1),
RunE: runIssueEdit, RunE: runIssueEdit,
} }
@ -152,6 +159,8 @@ 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")
@ -192,6 +201,24 @@ 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 {
@ -221,24 +248,15 @@ func runIssueList(cmd *cobra.Command, args []string) error {
} }
ios.StartSpinner("Fetching issues...") ios.StartSpinner("Fetching issues...")
// ListRepoIssues returns both issues AND PRs (we filter PRs out below). issues, _, err := client.ListRepoIssues(owner, name, gitea.ListIssueOption{
// Pull more than `limit` so post-filter we still have `limit` real issues State: stateType,
// — overshoot 2x as a heuristic. paginateGitea(0, ...) would be safer Labels: labels,
// but spends extra round-trips; keep it bounded. KeyWord: search,
fetchLimit := limit * 2 CreatedBy: author,
if fetchLimit < 50 { AssignedBy: assignee,
fetchLimit = 50 Since: sinceTime,
} Before: beforeTime,
issues, err := paginateGitea(fetchLimit, func(page, pageSize int) ([]*gitea.Issue, error) { ListOptions: gitea.ListOptions{PageSize: limit},
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 {
@ -251,9 +269,6 @@ 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)

View file

@ -10,48 +10,47 @@ 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)
// //
// Flag design (BREAKING CHANGE — the previous --json was a string with // --json-fields is kept as a backwards-compatible alias.
// 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.Bool("json", false, jsonDesc) f.String("json", "", jsonDesc)
f.String("json-fields", "", "Output as JSON, projecting only these comma-separated fields") f.Lookup("json").NoOptDefVal = " " // space sentinel: flag present with no value
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, // wantJSON returns true if the user requested JSON output via --json, --json-fields, or --jq.
// --json-fields, or --jq.
func wantJSON(cmd *cobra.Command) bool { func wantJSON(cmd *cobra.Command) bool {
if b, _ := cmd.Flags().GetBool("json"); b { if j, _ := cmd.Flags().GetString("json"); j != "" {
return true
}
if f, _ := cmd.Flags().GetString("json-fields"); f != "" {
return true return true
} }
if jq, _ := cmd.Flags().GetString("jq"); jq != "" { if jq, _ := cmd.Flags().GetString("jq"); jq != "" {
return true return true
} }
if f, _ := cmd.Flags().GetString("json-fields"); f != "" {
return true
}
return false return false
} }
// outputJSON writes a value as JSON, respecting --json-fields and --jq. // outputJSON writes a value as JSON, respecting --json, --json-fields, and --jq flags.
// --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 {
fields, _ := cmd.Flags().GetString("json-fields") jsonVal, _ := cmd.Flags().GetString("json")
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)
} }

View file

@ -5,8 +5,8 @@ import (
"strings" "strings"
"code.gitea.io/sdk/gitea" "code.gitea.io/sdk/gitea"
"forgejo.zerova.net/public/fj/internal/api" "forgejo.zerova.net/public/fgj-sid/internal/api"
"forgejo.zerova.net/public/fj/internal/config" "forgejo.zerova.net/public/fgj-sid/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
fj label list fgj label list
# List labels for a specific repository # List labels for a specific repository
fj label list -R owner/repo fgj label list -R owner/repo
# Output as JSON # Output as JSON
fj label list --json`, fgj label list --json`,
RunE: runLabelList, RunE: runLabelList,
} }
@ -36,29 +36,30 @@ 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
fj label create bug -c ff0000 fgj label create bug -c ff0000
# Create a label with color and description # Create a label with color and description
fj label create feature -c 00ff00 -d "New feature request" fgj label create feature -c 00ff00 -d "New feature request"
# Create a label in a specific repository # Create a label in a specific repository
fj label create urgent -c ff0000 -R owner/repo`, fgj label create urgent -c ff0000 -R owner/repo`,
Args: cobra.ExactArgs(1), Args: cobra.ExactArgs(1),
RunE: runLabelCreate, RunE: runLabelCreate,
} }
var labelEditCmd = &cobra.Command{ var labelEditCmd = &cobra.Command{
Use: "edit <name>", Use: "edit <name>",
Short: "Edit a label", Aliases: []string{"update"},
Long: "Edit an existing label in a repository.", Short: "Edit a label",
Long: "Edit an existing label in a repository.",
Example: ` # Rename a label Example: ` # Rename a label
fj label edit bug --name bugfix fgj label edit bug --name bugfix
# Change the color of a label # Change the color of a label
fj label edit bug -c 00ff00 fgj label edit bug -c 00ff00
# Update description # Update description
fj label edit bug -d "Something is broken"`, fgj label edit bug -d "Something is broken"`,
Args: cobra.ExactArgs(1), Args: cobra.ExactArgs(1),
RunE: runLabelEdit, RunE: runLabelEdit,
} }
@ -68,13 +69,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
fj label delete bug fgj label delete bug
# Delete without confirmation # Delete without confirmation
fj label delete bug -y fgj label delete bug -y
# Delete a label from a specific repository # Delete a label from a specific repository
fj label delete bug -R owner/repo`, fgj label delete bug -R owner/repo`,
Args: cobra.ExactArgs(1), Args: cobra.ExactArgs(1),
RunE: runLabelDelete, RunE: runLabelDelete,
} }

160
cmd/logins.go Normal file
View file

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

View file

@ -12,7 +12,7 @@ import (
var manpagesCmd = &cobra.Command{ var manpagesCmd = &cobra.Command{
Use: "manpages", Use: "manpages",
Short: "Generate manpages", Short: "Generate manpages",
Long: "Generate manpages for fj commands.", Long: "Generate manpages for fgj commands.",
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
dir, _ := cmd.Flags().GetString("dir") dir, _ := cmd.Flags().GetString("dir")
if dir == "" { if dir == "" {
@ -29,7 +29,7 @@ var manpagesCmd = &cobra.Command{
} }
header := &doc.GenManHeader{ header := &doc.GenManHeader{
Title: "FJ", Title: "FGJ",
Section: "1", Section: "1",
} }

View file

@ -7,9 +7,9 @@ import (
"time" "time"
"code.gitea.io/sdk/gitea" "code.gitea.io/sdk/gitea"
"forgejo.zerova.net/public/fj/internal/api" "forgejo.zerova.net/public/fgj-sid/internal/api"
"forgejo.zerova.net/public/fj/internal/config" "forgejo.zerova.net/public/fgj-sid/internal/config"
"forgejo.zerova.net/public/fj/internal/text" "forgejo.zerova.net/public/fgj-sid/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
fj milestone list fgj milestone list
# List all milestones for a specific repo # List all milestones for a specific repo
fj milestone list -R owner/repo --state all fgj milestone list -R owner/repo --state all
# Output as JSON # Output as JSON
fj milestone list --json`, fgj 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
fj milestone view 1 fgj milestone view 1
# View by title # View by title
fj milestone view "v1.0" fgj milestone view "v1.0"
# Open in browser # Open in browser
fj milestone view "v1.0" --web fgj milestone view "v1.0" --web
# Output as JSON # Output as JSON
fj milestone view "v1.0" --json`, fgj 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
fj milestone create "v1.0" fgj milestone create "v1.0"
# Create with description and due date # Create with description and due date
fj milestone create "v2.0" -d "Second release" --due 2026-06-01 fgj milestone create "v2.0" -d "Second release" --due 2026-06-01
# Output as JSON # Output as JSON
fj milestone create "v1.0" --json`, fgj 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
fj milestone edit "v1.0" --title "v1.1" fgj milestone edit "v1.0" --title "v1.1"
# Close a milestone # Close a milestone
fj milestone edit "v1.0" --state closed fgj milestone edit "v1.0" --state closed
# Update due date # Update due date
fj milestone edit 1 --due 2026-12-31`, fgj 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
fj milestone delete "v1.0" fgj milestone delete "v1.0"
# Delete by ID # Delete by ID
fj milestone delete 1 fgj milestone delete 1
# Delete without confirmation # Delete without confirmation
fj milestone delete "v1.0" -y`, fgj milestone delete "v1.0" -y`,
Args: cobra.ExactArgs(1), Args: cobra.ExactArgs(1),
RunE: runMilestoneDelete, RunE: runMilestoneDelete,
} }

164
cmd/milestone_issues.go Normal file
View file

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

137
cmd/notification.go Normal file
View file

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

View file

@ -0,0 +1,60 @@
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 Normal file
View file

@ -0,0 +1,104 @@
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 Normal file
View file

@ -0,0 +1,186 @@
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())
}

View file

@ -1,43 +0,0 @@
package cmd
// paginateGitea walks pages of a gitea SDK list method until the response
// is short (last page) or we hit limit. limit=0 means unlimited.
//
// Forgejo/Gitea caps PageSize at 50, so naive `PageSize: limit` for limit > 50
// silently truncated results across most `fj * list` commands. This helper
// centralizes the loop so every list command paginates consistently.
//
// fetch is called with (page, pageSize) and returns the items for that page.
// The 1-based `page` matches the gitea SDK convention.
func paginateGitea[T any](limit int, fetch func(page, pageSize int) ([]T, error)) ([]T, error) {
const maxPageSize = 50
pageSize := maxPageSize
if limit > 0 && limit < pageSize {
pageSize = limit
}
var all []T
for page := 1; ; page++ {
if limit > 0 && len(all) >= limit {
break
}
batch, err := fetch(page, pageSize)
if err != nil {
return all, err
}
if len(batch) == 0 {
break
}
all = append(all, batch...)
// A short page (less than the requested size) is the conventional
// "you've reached the end" signal — saves one extra round-trip.
if len(batch) < pageSize {
break
}
}
if limit > 0 && len(all) > limit {
all = all[:limit]
}
return all, nil
}

203
cmd/pr.go
View file

@ -4,16 +4,66 @@ 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/fj/internal/api" "forgejo.zerova.net/public/fgj-sid/internal/api"
"forgejo.zerova.net/public/fj/internal/config" "forgejo.zerova.net/public/fgj-sid/internal/config"
gitpkg "forgejo.zerova.net/public/fj/internal/git" gitpkg "forgejo.zerova.net/public/fgj-sid/internal/git"
"forgejo.zerova.net/public/fj/internal/text" "forgejo.zerova.net/public/fgj-sid/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"},
@ -26,13 +76,19 @@ 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
fj pr list fgj pr list
# List all pull requests for a specific repo # List all pull requests for a specific repo
fj pr list -s all -R owner/repo fgj pr list -s all -R owner/repo
# Output as JSON # Output as JSON
fj pr list --json`, fgj 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,
} }
@ -41,19 +97,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
fj pr view 5 fgj pr view 5
# View using URL # View using URL
fj pr view https://codeberg.org/owner/repo/pulls/5 fgj pr view https://codeberg.org/owner/repo/pulls/5
# View PR for current branch # View PR for current branch
fj pr view fgj pr view
# Open in browser # Open in browser
fj pr view 5 --web fgj pr view 5 --web
# View as JSON # View as JSON
fj pr view 5 --json`, fgj pr view 5 --json`,
Args: cobra.MaximumNArgs(1), Args: cobra.MaximumNArgs(1),
RunE: runPRView, RunE: runPRView,
} }
@ -63,13 +119,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
fj pr create -t "Add login page" -H feature/login fgj pr create -t "Add login page" -H feature/login
# Create with body and custom base branch # Create with body and custom base branch
fj pr create -t "Fix bug" -b "Closes #42" -H fix/bug -B develop fgj pr create -t "Fix bug" -b "Closes #42" -H fix/bug -B develop
# Create and self-assign # Create and self-assign
fj pr create -t "Update docs" -H docs/update -a @me`, fgj pr create -t "Update docs" -H docs/update -a @me`,
RunE: runPRCreate, RunE: runPRCreate,
} }
@ -78,16 +134,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
fj pr merge 5 fgj pr merge 5
# Squash merge # Squash merge
fj pr merge 5 --merge-method squash fgj pr merge 5 --merge-method squash
# Rebase merge # Rebase merge
fj pr merge 5 --merge-method rebase fgj pr merge 5 --merge-method rebase
# Merge without confirmation # Merge without confirmation
fj pr merge 5 -y`, fgj pr merge 5 -y`,
Args: cobra.ExactArgs(1), Args: cobra.ExactArgs(1),
RunE: runPRMerge, RunE: runPRMerge,
} }
@ -97,10 +153,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
fj pr close 5 fgj pr close 5
# Close with a comment # Close with a comment
fj pr close 5 -c "Won't merge, superseded by #10"`, fgj pr close 5 -c "Won't merge, superseded by #10"`,
Args: cobra.ExactArgs(1), Args: cobra.ExactArgs(1),
RunE: runPRClose, RunE: runPRClose,
} }
@ -110,7 +166,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
fj pr reopen 5`, fgj pr reopen 5`,
Args: cobra.ExactArgs(1), Args: cobra.ExactArgs(1),
RunE: runPRReopen, RunE: runPRReopen,
} }
@ -120,13 +176,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
fj pr edit 5 -t "Updated title" fgj pr edit 5 -t "Updated title"
# Add assignees and labels # Add assignees and labels
fj pr edit 5 --add-assignee user1 --add-label bug fgj pr edit 5 --add-assignee user1 --add-label bug
# Remove a reviewer and set milestone # Remove a reviewer and set milestone
fj pr edit 5 --remove-reviewer user2 --milestone "v1.0"`, fgj pr edit 5 --remove-reviewer user2 --milestone "v1.0"`,
Args: cobra.ExactArgs(1), Args: cobra.ExactArgs(1),
RunE: runPREdit, RunE: runPREdit,
} }
@ -136,7 +192,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
fj pr checkout 5`, fgj pr checkout 5`,
Args: cobra.ExactArgs(1), Args: cobra.ExactArgs(1),
RunE: runPRCheckout, RunE: runPRCheckout,
} }
@ -167,6 +223,8 @@ 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")
@ -217,6 +275,27 @@ 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 {
@ -249,35 +328,46 @@ func runPRList(cmd *cobra.Command, args []string) error {
return fmt.Errorf("invalid state: %s", state) return fmt.Errorf("invalid state: %s", state)
} }
needsClientFilter := assignee != "" || author != "" || len(labels) > 0 || search != "" || draft || head != "" || base != "" // server-side since/before unsupported for pulls; filtering client-side
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 {
prs, err = paginateGitea(0, fetchPage) // pull all, then filter + limit page := 1
if err == nil { for {
prs = filterPRs(prs, author, assignee, labels, search, draft, head, base) batch, _, err := client.ListRepoPullRequests(owner, name, gitea.ListPullRequestsOptions{
if limit > 0 && len(prs) > limit { State: stateType,
prs = prs[:limit] ListOptions: gitea.ListOptions{Page: page, PageSize: 50},
})
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 = paginateGitea(limit, fetchPage) prs, _, err = client.ListRepoPullRequests(owner, name, gitea.ListPullRequestsOptions{
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)
@ -347,6 +437,27 @@ 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")

102
cmd/pr_approve_reject.go Normal file
View file

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

View file

@ -4,9 +4,9 @@ import (
"fmt" "fmt"
"code.gitea.io/sdk/gitea" "code.gitea.io/sdk/gitea"
"forgejo.zerova.net/public/fj/internal/api" "forgejo.zerova.net/public/fgj-sid/internal/api"
"forgejo.zerova.net/public/fj/internal/config" "forgejo.zerova.net/public/fgj-sid/internal/config"
"forgejo.zerova.net/public/fj/internal/iostreams" "forgejo.zerova.net/public/fgj-sid/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
fj pr checks 5 fgj pr checks 5
# Output as JSON # Output as JSON
fj pr checks 5 --json`, fgj pr checks 5 --json`,
Args: cobra.ExactArgs(1), Args: cobra.ExactArgs(1),
RunE: runPRChecks, RunE: runPRChecks,
} }

98
cmd/pr_clean.go Normal file
View file

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

View file

@ -4,8 +4,8 @@ import (
"fmt" "fmt"
"strings" "strings"
"forgejo.zerova.net/public/fj/internal/api" "forgejo.zerova.net/public/fgj-sid/internal/api"
"forgejo.zerova.net/public/fj/internal/config" "forgejo.zerova.net/public/fgj-sid/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
fj pr diff 123 fgj pr diff 123
# Colorized diff output # Colorized diff output
fj pr diff 123 --color always fgj pr diff 123 --color always
# Show only changed file names # Show only changed file names
fj pr diff 123 --name-only fgj pr diff 123 --name-only
# Show diffstat summary # Show diffstat summary
fj pr diff 123 --stat`, fgj pr diff 123 --stat`,
Args: cobra.ExactArgs(1), Args: cobra.ExactArgs(1),
RunE: runPRDiff, RunE: runPRDiff,
} }

View file

@ -6,8 +6,8 @@ import (
"os" "os"
"code.gitea.io/sdk/gitea" "code.gitea.io/sdk/gitea"
"forgejo.zerova.net/public/fj/internal/api" "forgejo.zerova.net/public/fgj-sid/internal/api"
"forgejo.zerova.net/public/fj/internal/config" "forgejo.zerova.net/public/fgj-sid/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
fj pr comment 123 -b "Looks good!" fgj pr comment 123 -b "Looks good!"
# Comment from a file # Comment from a file
fj pr comment 123 --body-file review-notes.md fgj pr comment 123 --body-file review-notes.md
# Comment from stdin # Comment from stdin
echo "LGTM" | fj pr comment 123 --body-file - echo "LGTM" | fgj pr comment 123 --body-file -
# Output as JSON # Output as JSON
fj pr comment 123 -b "Nice work" --json`, fgj pr comment 123 -b "Nice work" --json`,
Args: cobra.ExactArgs(1), Args: cobra.ExactArgs(1),
RunE: runPRComment, RunE: runPRComment,
} }
@ -35,16 +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
fj pr review 123 --approve -b "LGTM" fgj pr review 123 --approve -b "LGTM"
# Request changes # Request changes
fj pr review 123 --request-changes -b "Please fix the error handling" fgj pr review 123 --request-changes -b "Please fix the error handling"
# Submit a review comment # Submit a review comment
fj pr review 123 --comment -b "Some observations" fgj pr review 123 --comment -b "Some observations"
# Request changes with body from file # Request changes with body from file
fj pr review 123 --request-changes --body-file feedback.md`, fgj pr review 123 --request-changes --body-file feedback.md`,
Args: cobra.ExactArgs(1), Args: cobra.ExactArgs(1),
RunE: runPRReview, RunE: runPRReview,
} }

171
cmd/pr_review_comments.go Normal file
View file

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

View file

@ -9,9 +9,9 @@ import (
"time" "time"
"code.gitea.io/sdk/gitea" "code.gitea.io/sdk/gitea"
"forgejo.zerova.net/public/fj/internal/api" "forgejo.zerova.net/public/fgj-sid/internal/api"
"forgejo.zerova.net/public/fj/internal/config" "forgejo.zerova.net/public/fgj-sid/internal/config"
"forgejo.zerova.net/public/fj/internal/text" "forgejo.zerova.net/public/fgj-sid/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
fj release list fgj release list
# List only draft releases # List only draft releases
fj release list --draft fgj release list --draft
# Output as JSON with a custom limit # Output as JSON with a custom limit
fj release list --json --limit 10`, fgj 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
fj release view v1.0.0 fgj release view v1.0.0
# View the latest release # View the latest release
fj release view latest fgj release view latest
# Open in browser # Open in browser
fj release view v1.0.0 --web fgj release view v1.0.0 --web
# Output as JSON # Output as JSON
fj release view v1.0.0 --json`, fgj 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
fj release create v1.0.0 fgj release create v1.0.0
# Create with title and notes # Create with title and notes
fj release create v1.0.0 -t "First stable release" -n "Bug fixes and improvements" fgj 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
fj release create v2.0.0-rc1 --draft --prerelease dist/*.tar.gz fgj release create v2.0.0-rc1 --draft --prerelease dist/*.tar.gz
# Create from release notes file # Create from release notes file
fj release create v1.0.0 -F CHANGELOG.md`, fgj 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
fj release upload v1.0.0 dist/app-linux-amd64 dist/app-darwin-arm64 fgj 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
fj release upload latest build/output.zip --clobber`, fgj 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
fj release download v1.0.0 fgj release download v1.0.0
# Download to a specific directory # Download to a specific directory
fj release download v1.0.0 -D ./downloads fgj release download v1.0.0 -D ./downloads
# Download a specific asset by name pattern # Download a specific asset by name pattern
fj release download v1.0.0 -p "*.tar.gz"`, fgj 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
fj release delete v1.0.0 fgj release delete v1.0.0
# Delete the latest release # Delete the latest release
fj release delete latest fgj release delete latest
# Delete without confirmation # Delete without confirmation
fj release delete v1.0.0 -y`, fgj release delete v1.0.0 -y`,
Args: cobra.ExactArgs(1), Args: cobra.ExactArgs(1),
RunE: runReleaseDelete, RunE: runReleaseDelete,
} }

310
cmd/release_assets.go Normal file
View file

@ -0,0 +1,310 @@
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])
}

View file

@ -8,9 +8,9 @@ import (
"strings" "strings"
"code.gitea.io/sdk/gitea" "code.gitea.io/sdk/gitea"
"forgejo.zerova.net/public/fj/internal/api" "forgejo.zerova.net/public/fgj-sid/internal/api"
"forgejo.zerova.net/public/fj/internal/config" "forgejo.zerova.net/public/fgj-sid/internal/config"
"forgejo.zerova.net/public/fj/internal/text" "forgejo.zerova.net/public/fgj-sid/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
fj repo edit owner/repo --private fgj repo edit owner/repo --private
# Make a repository public # Make a repository public
fj repo edit owner/repo --public fgj repo edit owner/repo --public
# Update description and homepage # Update description and homepage
fj repo edit owner/repo -d "New description" --homepage https://example.com fgj repo edit owner/repo -d "New description" --homepage https://example.com
# Change default branch # Change default branch
fj repo edit --default-branch develop fgj repo edit --default-branch develop
# Rename a repository # Rename a repository
fj repo edit owner/repo --name new-name fgj repo edit owner/repo --name new-name
# Edit current repo (auto-detected from git context) # Edit current repo (auto-detected from git context)
fj repo edit --public`, fgj repo edit --public`,
Args: cobra.MaximumNArgs(1), Args: cobra.MaximumNArgs(1),
RunE: runRepoEdit, RunE: runRepoEdit,
} }
@ -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 `fj repo edit --name <new-name>`.", Long: "Rename an existing repository. This is a shorthand for `fgj repo edit --name <new-name>`.",
Example: ` # Rename current repo Example: ` # Rename current repo
fj repo rename new-name fgj repo rename new-name
# Rename a specific repo # Rename a specific repo
fj repo rename new-name -R owner/old-name`, fgj repo rename new-name -R owner/old-name`,
Args: cobra.ExactArgs(1), Args: cobra.ExactArgs(1),
RunE: runRepoRename, RunE: runRepoRename,
} }
@ -216,18 +216,17 @@ 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)
} }
limit, _ := cmd.Flags().GetInt("limit") repos, _, err := client.ListUserRepos(user.UserName, gitea.ListReposOptions{})
repos, err := paginateGitea(limit, func(page, pageSize int) ([]*gitea.Repository, error) {
batch, _, err := client.ListUserRepos(user.UserName, gitea.ListReposOptions{
ListOptions: gitea.ListOptions{Page: page, PageSize: pageSize},
})
return batch, err
})
ios.StopSpinner() 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)
} }

163
cmd/repo_archive.go Normal file
View file

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

78
cmd/repo_delete.go Normal file
View file

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

170
cmd/repo_migrate.go Normal file
View file

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

146
cmd/repo_search.go Normal file
View file

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

113
cmd/repo_template.go Normal file
View file

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

View file

@ -2,14 +2,11 @@ package cmd
import ( import (
"fmt" "fmt"
"io"
"os" "os"
"path/filepath"
"strconv" "strconv"
"strings" "strings"
"forgejo.zerova.net/public/fj/internal/config" "forgejo.zerova.net/public/fgj-sid/internal/git"
"forgejo.zerova.net/public/fj/internal/git"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/spf13/viper" "github.com/spf13/viper"
) )
@ -17,12 +14,16 @@ 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: "fj", Use: "fgj",
Short: "Forgejo CLI tool - work seamlessly with Forgejo from the command line", Short: "Forgejo CLI tool - work seamlessly with Forgejo from the command line",
Long: `fj is a command line tool for Forgejo instances (including Codeberg). Long: `fgj is a command line tool for Forgejo instances (including Codeberg).
It brings pull requests, issues, and other Forgejo concepts to the terminal.`, It brings pull requests, issues, and other Forgejo concepts to the terminal.`,
Version: "0.4.0", Version: version,
SilenceErrors: true, SilenceErrors: true,
} }
@ -38,7 +39,7 @@ func Execute() error {
func init() { func init() {
cobra.OnInitialize(initConfig) cobra.OnInitialize(initConfig)
rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.config/fj/config.yaml)") rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.config/fgj/config.yaml)")
rootCmd.PersistentFlags().BoolVar(&jsonErrors, "json-errors", false, "output errors as structured JSON to stderr") rootCmd.PersistentFlags().BoolVar(&jsonErrors, "json-errors", false, "output errors as structured JSON to stderr")
rootCmd.PersistentFlags().String("hostname", "", "Forgejo instance hostname") rootCmd.PersistentFlags().String("hostname", "", "Forgejo instance hostname")
_ = viper.BindPFlag("hostname", rootCmd.PersistentFlags().Lookup("hostname")) _ = viper.BindPFlag("hostname", rootCmd.PersistentFlags().Lookup("hostname"))
@ -46,12 +47,7 @@ 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 {
@ -59,20 +55,8 @@ func initConfig() {
os.Exit(1) os.Exit(1)
} }
configDir := home + "/.config/fj" configDir := home + "/.config/fgj"
legacyDir := home + "/.config/fgj" _ = os.MkdirAll(configDir, 0755)
// Migrate from ~/.config/fgj/ if the new dir doesn't exist yet.
if _, err := os.Stat(configDir); os.IsNotExist(err) {
if info, err := os.Stat(legacyDir); err == nil && info.IsDir() {
if copyErr := migrateConfigDir(legacyDir, configDir); copyErr == nil {
fmt.Fprintln(ios.ErrOut, "notice: migrated config from ~/.config/fgj/ to ~/.config/fj/")
fmt.Fprintln(ios.ErrOut, " you can remove ~/.config/fgj/ when ready")
}
}
}
_ = os.MkdirAll(configDir, 0700)
viper.AddConfigPath(configDir) viper.AddConfigPath(configDir)
viper.SetConfigType("yaml") viper.SetConfigType("yaml")
@ -80,17 +64,9 @@ func initConfig() {
} }
viper.AutomaticEnv() viper.AutomaticEnv()
viper.SetEnvPrefix("FJ") viper.SetEnvPrefix("FGJ")
_ = viper.ReadInConfig() _ = viper.ReadInConfig()
// If the resolved config exists with overly permissive mode, warn — the
// file holds API tokens. Don't fail-close; just nudge the user.
if path, err := config.GetConfigPath(); err == nil {
if info, statErr := os.Stat(path); statErr == nil && info.Mode()&0o077 != 0 {
fmt.Fprintf(ios.ErrOut, "warning: %s mode %o is world/group readable; tokens may leak. chmod 600 it.\n", path, info.Mode().Perm())
}
}
} }
// parseRepo parses the repository string in the format "owner/name". // parseRepo parses the repository string in the format "owner/name".
@ -155,51 +131,3 @@ 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
}

68
cmd/secret_input.go Normal file
View file

@ -0,0 +1,68 @@
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 Normal file
View file

@ -0,0 +1,386 @@
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 Normal file
View file

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

53
cmd/whoami.go Normal file
View file

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

View file

@ -7,9 +7,9 @@ import (
"net/url" "net/url"
"time" "time"
"forgejo.zerova.net/public/fj/internal/api" "forgejo.zerova.net/public/fgj-sid/internal/api"
"forgejo.zerova.net/public/fj/internal/config" "forgejo.zerova.net/public/fgj-sid/internal/config"
"forgejo.zerova.net/public/fj/internal/text" "forgejo.zerova.net/public/fgj-sid/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
fj wiki list fgj wiki list
# List wiki pages for a specific repo # List wiki pages for a specific repo
fj wiki list -R owner/repo fgj wiki list -R owner/repo
# Output as JSON # Output as JSON
fj wiki list --json`, fgj 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
fj wiki view Home fgj wiki view Home
# Open in browser # Open in browser
fj wiki view Home --web fgj wiki view Home --web
# View a wiki page as JSON (includes content) # View a wiki page as JSON (includes content)
fj wiki view Home --json fgj wiki view Home --json
# View a wiki page from a specific repo # View a wiki page from a specific repo
fj wiki view "Getting-Started" -R owner/repo`, fgj wiki view "Getting-Started" -R owner/repo`,
Args: cobra.ExactArgs(1), 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
fj wiki create "Getting Started" -b "# Welcome\nThis is the getting started guide." fgj wiki create "Getting Started" -b "# Welcome\nThis is the getting started guide."
# Create a wiki page from a file # Create a wiki page from a file
fj wiki create "Setup Guide" --body-file setup.md fgj wiki create "Setup Guide" --body-file setup.md
# Create a wiki page from stdin # Create a wiki page from stdin
echo "# FAQ" | fj wiki create FAQ --body-file - echo "# FAQ" | fgj wiki create FAQ --body-file -
# Output as JSON # Output as JSON
fj wiki create "New Page" -b "Content here" --json`, fgj wiki create "New Page" -b "Content here" --json`,
Args: cobra.ExactArgs(1), 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
fj wiki edit Home -b "# Updated Home\nNew content here." fgj wiki edit Home -b "# Updated Home\nNew content here."
# Edit a wiki page from a file # Edit a wiki page from a file
fj wiki edit "Setup Guide" --body-file updated-setup.md fgj wiki edit "Setup Guide" --body-file updated-setup.md
# Edit a wiki page from stdin # Edit a wiki page from stdin
cat new-content.md | fj wiki edit Home --body-file - cat new-content.md | fgj wiki edit Home --body-file -
# Output as JSON # Output as JSON
fj wiki edit Home -b "Updated content" --json`, fgj 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
fj wiki delete "Old Page" fgj wiki delete "Old Page"
# Delete without confirmation # Delete without confirmation
fj wiki delete "Old Page" -y fgj wiki delete "Old Page" -y
# Delete a wiki page from a specific repo # Delete a wiki page from a specific repo
fj wiki delete "Outdated Guide" -R owner/repo`, fgj wiki delete "Outdated Guide" -R owner/repo`,
Args: cobra.ExactArgs(1), Args: cobra.ExactArgs(1),
RunE: runWikiDelete, RunE: runWikiDelete,
} }
@ -266,9 +266,10 @@ 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")
} }
if wantJSON(cmd) { jsonFlag, _ := cmd.Flags().GetBool("json")
if jsonFlag {
page.Content = string(content) page.Content = string(content)
return outputJSON(cmd, page) return writeJSON(page)
} }
if err := ios.StartPager(); err != nil { if err := ios.StartPager(); err != nil {

6
go.mod
View file

@ -1,9 +1,10 @@
module forgejo.zerova.net/public/fj module forgejo.zerova.net/public/fgj-sid
go 1.24.0 go 1.24.0
require ( require (
code.gitea.io/sdk/gitea v0.22.1 code.gitea.io/sdk/gitea v0.23.2
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
@ -19,7 +20,6 @@ require (
github.com/hashicorp/go-version v1.7.0 // indirect github.com/hashicorp/go-version v1.7.0 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect github.com/hashicorp/hcl v1.0.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/itchyny/gojq v0.12.18 // indirect
github.com/itchyny/timefmt-go v0.1.7 // indirect github.com/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
View file

@ -1,5 +1,5 @@
code.gitea.io/sdk/gitea v0.22.1 h1:7K05KjRORyTcTYULQ/AwvlVS6pawLcWyXZcTr7gHFyA= code.gitea.io/sdk/gitea v0.23.2 h1:iJB1FDmLegwfwjX8gotBDHdPSbk/ZR8V9VmEJaVsJYg=
code.gitea.io/sdk/gitea v0.22.1/go.mod h1:yyF5+GhljqvA30sRDreoyHILruNiy4ASufugzYg0VHM= code.gitea.io/sdk/gitea v0.23.2/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,8 +90,6 @@ 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=

View file

@ -9,19 +9,13 @@ import (
"time" "time"
"code.gitea.io/sdk/gitea" "code.gitea.io/sdk/gitea"
"forgejo.zerova.net/public/fj/internal/config" "forgejo.zerova.net/public/fgj-sid/internal/config"
) )
// SharedHTTPClient is the package-wide HTTP client. Exported so other var sharedHTTPClient = &http.Client{
// 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

View file

@ -3,7 +3,7 @@ package api
import ( import (
"testing" "testing"
"forgejo.zerova.net/public/fj/internal/config" "forgejo.zerova.net/public/fgj-sid/internal/config"
) )
func TestClient_Hostname(t *testing.T) { func TestClient_Hostname(t *testing.T) {

View file

@ -4,6 +4,7 @@ import (
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
"sort"
"strings" "strings"
"github.com/spf13/viper" "github.com/spf13/viper"
@ -14,41 +15,28 @@ 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, "fj"), nil return filepath.Join(xdgConfigHome, "fgj"), nil
} }
home, err := os.UserHomeDir() home, err := os.UserHomeDir()
if err != nil { if err != nil {
return "", err return "", err
} }
return filepath.Join(home, ".config", "fj"), nil return filepath.Join(home, ".config", "fgj"), nil
} }
func GetConfigPath() (string, error) { func GetConfigPath() (string, error) {
if explicitConfigPath != "" {
return explicitConfigPath, nil
}
dir, err := GetConfigDir() dir, err := GetConfigDir()
if err != nil { if err != nil {
return "", err return "", err
@ -145,17 +133,18 @@ func (c *Config) SaveToPath(path string) error {
// Priority order: // Priority order:
// 1. Explicitly provided hostname parameter // 1. Explicitly provided hostname parameter
// 2. CLI flag (--hostname) // 2. CLI flag (--hostname)
// 3. Environment variable (FJ_HOST, with FGJ_HOST fallback) // 3. Environment variable (FGJ_HOST)
// 4. Auto-detected hostname from git remote // 4. Auto-detected hostname from git remote
// 5. match_dirs lookup (longest prefix match) // 5. match_dirs lookup (longest prefix match)
// 6. Default to codeberg.org // 6. Configured default host (HostConfig.Default == true)
// 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 = EnvWithFallback("FJ_HOST", "FGJ_HOST") hostname = os.Getenv("FGJ_HOST")
} }
if hostname == "" { if hostname == "" {
@ -166,6 +155,10 @@ 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"
} }
@ -178,6 +171,27 @@ 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
@ -242,15 +256,6 @@ 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()

View file

@ -83,7 +83,7 @@ func TestGetConfigDir_XDG(t *testing.T) {
t.Fatalf("Unexpected error: %v", err) t.Fatalf("Unexpected error: %v", err)
} }
expected := "/custom/config/fj" expected := "/custom/config/fgj"
if dir != expected { if dir != expected {
t.Errorf("Expected %q, got %q", expected, dir) t.Errorf("Expected %q, got %q", expected, dir)
} }
@ -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/fj", "/Users/sid/repos/zerova"}, MatchDirs: []string{"/Users/sid/repos/fgj", "/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/fj", "forgejo.zerova.net"}, {"exact dir match", "/Users/sid/repos/fgj", "forgejo.zerova.net"},
{"nested dir match", "/Users/sid/repos/fj/cmd/root.go", "forgejo.zerova.net"}, {"nested dir match", "/Users/sid/repos/fgj/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/fj/internal", "forgejo.zerova.net"}, {"longest prefix wins over /", "/Users/sid/repos/fgj/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/fj"}, MatchDirs: []string{"/Users/sid/repos/fgj"},
}, },
"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/fj/cmd") host, err := cfg.GetHost("", "", "/Users/sid/repos/fgj/cmd")
if err != nil { if err != nil {
t.Fatalf("unexpected error: %v", err) t.Fatalf("unexpected error: %v", err)
} }

View file

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

View file

@ -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 FJ_FORCE_TTY=1 (or legacy FGJ_FORCE_TTY=1) // with TTY status auto-detected. Setting FGJ_FORCE_TTY=1 forces all streams to
// forces all streams to be treated as TTYs. // be treated as TTYs.
func New() *IOStreams { func New() *IOStreams {
forceTTY := os.Getenv("FJ_FORCE_TTY") != "" || os.Getenv("FGJ_FORCE_TTY") != "" forceTTY := 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,17 +118,14 @@ 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 FJ_PAGER (or legacy FGJ_PAGER), then PAGER, then defaults to "less". // It checks FGJ_PAGER, then PAGER, then defaults to "less". If LESS is not
// If LESS is not already set, it is set to "FRX" for a good default experience. // 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("FJ_PAGER") pagerCmd := os.Getenv("FGJ_PAGER")
if pagerCmd == "" {
pagerCmd = os.Getenv("FGJ_PAGER")
}
if pagerCmd == "" { if pagerCmd == "" {
pagerCmd = os.Getenv("PAGER") pagerCmd = os.Getenv("PAGER")
} }

View file

@ -4,7 +4,7 @@ import (
"fmt" "fmt"
"os" "os"
"forgejo.zerova.net/public/fj/cmd" "forgejo.zerova.net/public/fgj-sid/cmd"
) )
func main() { func main() {

View file

@ -228,18 +228,15 @@ func (env *TestEnv) CleanupRepo(owner, repoName string) {
} }
} }
// GetBinaryPath returns the path to the built fj binary // GetBinaryPath returns the path to the built fgj binary
func (env *TestEnv) GetBinaryPath() string { func (env *TestEnv) GetBinaryPath() string {
binaryPath := os.Getenv("FJ_BINARY_PATH") binaryPath := os.Getenv("FGJ_BINARY_PATH")
if binaryPath == "" {
binaryPath = os.Getenv("FGJ_BINARY_PATH")
}
if binaryPath == "" { if binaryPath == "" {
// Look for the binary in common locations // Look for the binary in common locations
candidates := []string{ candidates := []string{
"./bin/fj", "./bin/fgj",
"bin/fj", "bin/fgj",
"/home/romain/work/fj/bin/fj", "/home/romain/work/fgj/bin/fgj",
} }
for _, candidate := range candidates { for _, candidate := range candidates {
if _, err := os.Stat(candidate); err == nil { if _, err := os.Stat(candidate); err == nil {
@ -251,7 +248,7 @@ func (env *TestEnv) GetBinaryPath() string {
} }
} }
// If no binary found, return default (will error when executed) // If no binary found, return default (will error when executed)
binaryPath = "./bin/fj" binaryPath = "./bin/fgj"
} }
return binaryPath return binaryPath
} }

View file

@ -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("[FJ E2E Test] Issue List", "For issue list test") issueNum := env.CreateTestIssue("[FGJ 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("[FJ E2E Test] JSON List", "For JSON output test") issueNum := env.CreateTestIssue("[FGJ 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("[FJ E2E Test] View Test", "Testing issue view") issueNum := env.CreateTestIssue("[FGJ 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("[FJ E2E Test] JSON View", "Testing JSON view") issueNum := env.CreateTestIssue("[FGJ 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", "[FJ E2E Test] CLI Created Issue", "-t", "[FGJ E2E Test] CLI Created Issue",
"-b", "Created directly via fj CLI", "-b", "Created directly via fgj 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", "[FJ E2E Test] Issue with Labels", "-t", "[FGJ 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("[FJ E2E Test] Comment Test", "Testing comment via CLI") issueNum := env.CreateTestIssue("[FGJ 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("[FJ E2E Test] Close Test", "Will be closed via CLI") issueNum := env.CreateTestIssue("[FGJ 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("[FJ E2E Test] Close with comment", "Will be closed with a comment") issueNum := env.CreateTestIssue("[FGJ 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("[FJ E2E Test] Original Title", "Will be edited") issueNum := env.CreateTestIssue("[FGJ 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", "[FJ E2E Test] Updated Title", "-t", "[FGJ 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 != "[FJ E2E Test] Updated Title" { if issue.Title != "[FGJ 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("[FJ E2E Test] Add Labels", "Will have labels added") issueNum := env.CreateTestIssue("[FGJ 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: "[FJ E2E Test] Remove Labels", Title: "[FGJ 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("[FJ E2E Test] PR Comment Test", "Testing pr comment command") issueNum := env.CreateTestIssue("[FGJ 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 fj pr comment", "-b", "Automated test comment via fgj 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 fj pr comment", issueNum) t.Logf("Successfully commented on issue #%d via fgj 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("fj-test-create-%d", time.Now().UnixNano()) repoName := fmt.Sprintf("fgj-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 fj functional test", "-d", "Created by fgj 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 fj functional test" { if repo.Description != "Created by fgj functional test" {
t.Fatalf("expected description %q, got %q", "Created by fj functional test", repo.Description) t.Fatalf("expected description %q, got %q", "Created by fgj 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/fj-clone", tmpDir) clonePath := fmt.Sprintf("%s/fgj-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("fj-test-%d", time.Now().UnixNano()) tag := fmt.Sprintf("fgj-test-%d", time.Now().UnixNano())
title := "FJ CLI Release Test" title := "FGJ 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("fj release asset"), 0600); err != nil { if err := os.WriteFile(assetPath, []byte("fgj 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("fj-view-test-%d", time.Now().UnixNano()) tag := fmt.Sprintf("fgj-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 fj api GET") t.Logf("Successfully retrieved repo info via fgj 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=[FJ E2E Test] API Post Test", "-f", "title=[FGJ E2E Test] API Post Test",
"-f", "body=Created via fj api command", "-f", "body=Created via fgj 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 fj api POST", issueNum) t.Logf("Successfully created issue #%d via fgj api POST", issueNum)
} }
// ===== Structured Error Output ===== // ===== Structured Error Output =====